From a3b9a48a3a53d0c1605e1f3929f88901373a650e Mon Sep 17 00:00:00 2001 From: Dushyant Acharya Date: Sat, 26 Sep 2026 10:30:52 +0530 Subject: [PATCH] feat(pdf): checkboxes and radio button mapping for PDFs - Rebase onto latest upstream development branch - Implement checkbox and radio button mapping in pdfrw (app/services/filler.py) - Enforce strict boolean schema validation and prompt hints in LLM (app/services/llm.py) - Update type annotations to modern pipe syntax and use structured logging --- .gitignore | 4 +-- app/services/controller.py | 2 +- app/services/filler.py | 35 +++++++++++++++--- app/services/llm.py | 73 ++++++++++++++++++++++++++++++-------- tests/test_model.py | 2 +- 5 files changed, 94 insertions(+), 22 deletions(-) diff --git a/.gitignore b/.gitignore index 1515bf38..f16ffb74 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,6 @@ frontend/release/ # Local Claude Code instructions CLAUDE.md - *temp/ -*.env* \ No newline at end of file +*.env* +.aider* diff --git a/app/services/controller.py b/app/services/controller.py index 2a9efcbb..5daab443 100644 --- a/app/services/controller.py +++ b/app/services/controller.py @@ -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): diff --git a/app/services/filler.py b/app/services/filler.py index 148b2ff6..95ab3123 100644 --- a/app/services/filler.py +++ b/app/services/filler.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timezone from pdfrw import PdfReader, PdfWriter @@ -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" ) @@ -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 diff --git a/app/services/llm.py b/app/services/llm.py index 85e168c7..fc75935c 100644 --- a/app/services/llm.py +++ b/app/services/llm.py @@ -3,7 +3,6 @@ import requests from requests.exceptions import RequestException, Timeout - from app.core.config import OLLAMA_HOST, OLLAMA_MODEL from app.core.logging import get_logger @@ -11,22 +10,38 @@ 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) @@ -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 @@ -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) @@ -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 diff --git a/tests/test_model.py b/tests/test_model.py index 6de314f9..3fcfb0a7 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -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)