From aa4f3699f843477e6e1763835b8288e43070da70 Mon Sep 17 00:00:00 2001 From: sahithkasam Date: Tue, 22 Sep 2026 22:57:28 +0530 Subject: [PATCH] fix: safely render prompt templates with literal braces --- app/services/llm.py | 6 +++- app/services/prompt.txt | 6 ++-- docs/2. PROJECT_STRUCTURE.md | 9 ++++++ tests/test_llm.py | 55 ++++++++++++++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 tests/test_llm.py diff --git a/app/services/llm.py b/app/services/llm.py index 85e168c7..696e2dc2 100644 --- a/app/services/llm.py +++ b/app/services/llm.py @@ -1,5 +1,6 @@ import json import os +from string import Template import requests from requests.exceptions import RequestException, Timeout @@ -28,7 +29,10 @@ def build_prompt(self, current_field: str, current_type: str = "string"): with open(prompt_path, "r") as f: template = f.read() - return template.format(field=current_field, type=current_type, text=self._transcript_text) + # Dollar placeholders leave literal braces in JSON examples untouched. + return Template(template).safe_substitute( + field=current_field, type=current_type, text=self._transcript_text + ) def main_loop(self): timeout = 45 diff --git a/app/services/prompt.txt b/app/services/prompt.txt index 4d56e18f..0ead53c2 100644 --- a/app/services/prompt.txt +++ b/app/services/prompt.txt @@ -5,7 +5,7 @@ only a single string containing the identified value for the JSON field. If you don't identify the value in the provided text, return "-1". --- DATA: -Target JSON field to find in text: {field} -Expected value type: {type} +Target JSON field to find in text: $field +Expected value type: $type -TEXT: {text} +TEXT: $text diff --git a/docs/2. PROJECT_STRUCTURE.md b/docs/2. PROJECT_STRUCTURE.md index cee9c40f..37bb6485 100644 --- a/docs/2. PROJECT_STRUCTURE.md +++ b/docs/2. PROJECT_STRUCTURE.md @@ -71,6 +71,15 @@ app/ Tests live in `tests/`, run with `make test` (pytest inside the app container). `conftest.py` holds shared fixtures. Name files `test_.py` and mirror what you're testing: API endpoint tests alongside `test_api.py`, model/DB tests alongside `test_model.py`. Note: Tests will be undergoing many changes hence the docs can be outdated, Please raise issue if tests or docs are outdated. +### Editing the extraction prompt + +`app/services/prompt.txt` uses `string.Template` placeholders: `$field`, `$type`, +and `$text`. Literal braces, including JSON examples, do not need escaping. +Unknown placeholders are left unchanged. Use `$$` to include a literal dollar +sign before a known placeholder name (for example, `$$field` renders as `$field`). +Substituted field names and transcripts are inserted as-is, without further +placeholder expansion. + ## Docker & scripts - `docker/dev/` - development image (hot reload, source mounted) and its compose file. This is what `make up` runs. diff --git a/tests/test_llm.py b/tests/test_llm.py new file mode 100644 index 00000000..9fc2d4a9 --- /dev/null +++ b/tests/test_llm.py @@ -0,0 +1,55 @@ +"""Regression tests for prompt rendering without calling Ollama.""" + +from unittest.mock import mock_open, patch + +import pytest + +from app.services.llm import LLM + + +def test_build_prompt_uses_bundled_template(): + prompt = LLM(transcript_text="John Smith signed the form.").build_prompt( + "Signature", "signature" + ) + + assert "Target JSON field to find in text: Signature" in prompt + assert "Expected value type: signature" in prompt + assert "TEXT: John Smith signed the form." in prompt + + +@pytest.mark.parametrize( + "literal", + [ + 'EXAMPLE: {"value": "John Smith"}', + 'EXAMPLE: {"value": {"names": ["John", "Smith"]}}', + "An unmatched opening brace: {", + "An unmatched closing brace: }", + "Unknown slots: {unknown} $unknown ${unknown}", + "Literal dollars: $5 and a trailing $", + ], +) +def test_build_prompt_preserves_literal_template_content(literal): + template = "$field | $type | $text\n" + literal + with patch("app.services.llm.open", mock_open(read_data=template)): + prompt = LLM(transcript_text="John Smith").build_prompt( + "Signature", "signature" + ) + + assert prompt == "Signature | signature | John Smith\n" + literal + + +def test_build_prompt_does_not_substitute_inside_values(): + field = "Name $type ${text} {field}" + field_type = "string $text {type}" + transcript = 'Received {"value": "$field"}, ${type}, and {text}.' + with patch("app.services.llm.open", mock_open(read_data="$field | $type | $text")): + prompt = LLM(transcript_text=transcript).build_prompt(field, field_type) + + assert prompt == f"{field} | {field_type} | {transcript}" + + +def test_build_prompt_defaults_to_string_type(): + with patch("app.services.llm.open", mock_open(read_data="$field | $type | $text")): + prompt = LLM(transcript_text="John Smith").build_prompt("Name") + + assert prompt == "Name | string | John Smith"