Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,6 @@ frontend/release/

# Local Claude Code instructions
CLAUDE.md

*temp/
*.env*
*.env*
.aider*
2 changes: 1 addition & 1 deletion app/services/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ def __init__(self):
self.file_manipulator = FileManipulator()
self.external_apis_coordinator = ExternalAPIsCoordinator()

def fill_form(self, user_input: str, fields: list, pdf_form_path: str, model: str = None):
def fill_form(self, user_input: str, fields: list, pdf_form_path: str, model: str | None = None):
return self.file_manipulator.fill_form(user_input, fields, pdf_form_path, model=model)

def prepare_fillable(self, pdf_path: str):
Expand Down
35 changes: 31 additions & 4 deletions app/services/filler.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from datetime import datetime
from datetime import datetime, timezone

from pdfrw import PdfReader, PdfWriter

Expand All @@ -17,7 +17,7 @@ def fill_form(self, pdf_form: str, llm: LLM):
output_pdf = (
pdf_form[:-4]
+ "_"
+ datetime.now().strftime("%Y%m%d_%H%M%S")
+ datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
+ "_filled.pdf"
)

Expand All @@ -41,8 +41,35 @@ def fill_form(self, pdf_form: str, llm: LLM):
for annot in sorted_annots:
if annot.Subtype == "/Widget" and annot.T:
if i < len(answers_list):
annot.V = f"{answers_list[i]}"
annot.AP = None
answer = answers_list[i]

# Check if the field type is a Button (Checkbox/Radio)
field_type = annot.FT if annot.FT else (annot.Parent.FT if annot.Parent else None)
if str(field_type) == "/Btn":
# The LLM pipeline guarantees Python bool for boolean fields.
# We check isinstance(answer, bool) so only an explicit True
# activates the button — no fuzzy string matching needed.
is_truthy = isinstance(answer, bool) and answer

# Find the 'ON' state from the appearance dictionary
on_state = "/Yes" # Default assumption
if annot.AP and annot.AP.N:
keys = [k for k in annot.AP.N if k != "/Off"]
if keys:
on_state = keys[0]

if is_truthy:
from pdfrw import PdfName
annot.V = PdfName(on_state.strip("/"))
annot.AS = PdfName(on_state.strip("/"))
else:
from pdfrw import PdfName
annot.V = PdfName("Off")
annot.AS = PdfName("Off")
else:
annot.V = f"{answer}"
annot.AP = None

i += 1
else:
# Stop if we run out of answers
Expand Down
73 changes: 59 additions & 14 deletions app/services/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,45 @@

import requests
from requests.exceptions import RequestException, Timeout

from app.core.config import OLLAMA_HOST, OLLAMA_MODEL
from app.core.logging import get_logger

logger = get_logger(__name__)


class LLM:
def __init__(self, transcript_text: str=None, target_fields: list=None, json_dict: dict=None, model: str=None):
def __init__(self, transcript_text: str | None=None, target_fields: list | None=None, json_dict: dict | None=None, model: str | None=None):
self._transcript_text = transcript_text
self._target_fields = target_fields
self._json = json_dict if json_dict is not None else {}
# Optional per-request model override; falls back to OLLAMA_MODEL env.
self._model = model

def build_prompt(self, current_field: str, current_type: str = "string"):
def build_prompt(self, current_field: str, field_type: type = str):
"""
This method is in charge of the prompt engineering. It creates a specific prompt for each target field.
@params: current_field -> represents the current element of the json that is being prompted.
@params: current_type -> hint to the LLM about the expected value shape (date, number, etc.).
This method is in charge of the prompt engineering. It creates a specific prompt
for each target field, taking into account the expected field type.

If the field type is `bool`, the LLM is explicitly instructed to return only
the literal string `True` or `False` — no fuzzy values like 'yes' or '1'.

@params:
current_field -> the name of the JSON field to extract.
field_type -> the expected Python type (e.g. str, bool).
"""
prompt_path = os.path.join(os.path.dirname(__file__), "prompt.txt")
with open(prompt_path, "r") as f:
template = f.read()

current_type = "boolean" if field_type is bool else "string"

if field_type is bool:
bool_instruction = (
"\nIMPORTANT: This field is a boolean (checkbox or radio button). "
"You MUST respond with ONLY the literal word True or False. "
"Do not use 'Checked', 'yes', 'no', '1', '0', 'X', or any other value."
)
return template.format(field=current_field, type=current_type, text=self._transcript_text) + bool_instruction

return template.format(field=current_field, type=current_type, text=self._transcript_text)

Expand All @@ -35,8 +50,9 @@ def main_loop(self):
max_retries = 3

total_fields = len(self._target_fields)
for i, (field, field_type) in enumerate(self._target_fields.items(), 1):
prompt = self.build_prompt(field, field_type if isinstance(field_type, str) else "string")
for i, (field, field_val) in enumerate(self._target_fields.items(), 1):
field_type = field_val if isinstance(field_val, type) else str
prompt = self.build_prompt(field, field_type=field_type)
ollama_url = f"{OLLAMA_HOST}/api/generate"
ollama_model = self._model or OLLAMA_MODEL

Expand All @@ -52,7 +68,16 @@ def main_loop(self):
try:
response = requests.post(ollama_url, json=payload, timeout=timeout)
response.raise_for_status()
json_data = response.json()

temp_json_data = response.json()
parsed_response = temp_json_data["response"].strip().replace('"', "")

if field_type is bool and parsed_response.lower() not in ["true", "false"]:
logger.warning("LLM returned unexpected boolean value '%s' for field '%s' (attempt %d). Retrying...", parsed_response, field, attempt + 1)
payload["prompt"] += "\nERROR: Your previous response was invalid. You MUST respond with ONLY the literal word True or False."
continue

json_data = temp_json_data
break
except Timeout:
logger.warning("Ollama request timed out (attempt %d) for field '%s'. Retrying...", attempt + 1, field)
Expand All @@ -79,16 +104,36 @@ def main_loop(self):

def add_response_to_json(self, field: str, value: str):
"""
this method adds the following value under the specified field,
or under a new field if the field doesn't exist, to the json dict
Adds the LLM response under the specified field in the JSON dict.

If the field type in _target_fields is `bool`, the response is strictly
coerced: only the literal strings 'True' and 'False' (case-insensitive)
are accepted. Any other value is treated as None (unanswered).
"""
value = value.strip().replace('"', "")
parsed_value = None

if value != "-1":
parsed_value = value
# Determine expected type for this field
field_type = self._target_fields.get(field) if isinstance(self._target_fields, dict) else str
if not isinstance(field_type, type):
field_type = str

if field_type is bool:
# Strictly enforce True/False — no fuzzy matching
if value.lower() == "true":
parsed_value = True
elif value.lower() == "false":
parsed_value = False
else:
logger.warning("Boolean field '%s' received unexpected value '%s'. Defaulting to None.", field, value)
parsed_value = None
else:
if value != "-1":
parsed_value = value

if field in self._json.keys():
if ";" in value:
pass
if field in self._json:
self._json[field].append(parsed_value)
else:
self._json[field] = parsed_value
Expand Down
2 changes: 1 addition & 1 deletion tests/test_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@
{'role': 'user', 'content': 'Say hello in Spanish'}
])
print("Success! Response:", response['message']['content'])
except Exception as e:
except Exception as e: # noqa: BLE001
print("Error:", e)
Loading