From 88285d3d0bd3ea9aa1edb45f8a0d82e9964220e4 Mon Sep 17 00:00:00 2001 From: aircode610 Date: Tue, 30 Jun 2026 14:09:35 +0200 Subject: [PATCH 1/5] fix(deps): pin chromadb<1.0.0 to mitigate CVE-2026-45829 chromadb >=1.0.0 contains a pre-auth code injection vulnerability (CVE-2026-45829). Pin both backend and ai requirements to <1.0.0 until a patched 1.x release is available. All project API usage (PersistentClient, get_or_create_collection, query, upsert, add) is fully compatible with 0.5.x/0.6.x. Closes #18 Co-Authored-By: Claude Opus 4.6 (1M context) --- ai/requirements.txt | 2 +- backend/requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ai/requirements.txt b/ai/requirements.txt index d383e38..ba6c03e 100644 --- a/ai/requirements.txt +++ b/ai/requirements.txt @@ -5,6 +5,6 @@ langchain-tavily>=0.1.0 langgraph>=0.4.0 tavily-python>=0.5.0 httpx>=0.27.0 -chromadb>=0.5.0 +chromadb>=0.5.0,<1.0.0 beautifulsoup4>=4.12.0 openai>=1.0.0 diff --git a/backend/requirements.txt b/backend/requirements.txt index 9bd8775..3860053 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -8,7 +8,7 @@ python-multipart>=0.0.12 python-dotenv>=1.0.0 bcrypt>=4.2.0 openai>=1.55.0 -chromadb>=0.5.20 +chromadb>=0.5.20,<1.0.0 pillow>=11.0.0 pdf2image>=1.17.0 httpx>=0.27.0 From 36e9ad8d28e1995652d3c309333b071008c483d9 Mon Sep 17 00:00:00 2001 From: aircode610 Date: Tue, 30 Jun 2026 15:10:54 +0200 Subject: [PATCH 2/5] fix(lint): resolve ruff check and format errors at CI defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: the last pushed commit (88285d3) failed the 'Lint Python' workflow — 'ruff check backend/ ai/' reported 22 errors and 'ruff format --check backend/ ai/' flagged 27 files. Genuine fixes (retained): - Remove unused imports F401: datetime, typing.Any, RagHit, json, generate_reply_text (each verified genuinely unused in its file) - Remove f-string prefixes without placeholders (F541) - Remove unused assignments classification_data, action_titles (F841) - Move module-level imports to top of public.py (E402) Format: - Apply 'ruff format' at ruff's DEFAULT line-length (88), matching what CI enforces. Does NOT introduce a ruff.toml that raises line-length to 100 (that would loosen the linter config); removed that config. No inline suppressions added; the CI workflow is untouched. --- ai/form_fill.py | 17 +- ai/prompts.py | 134 +++++++++----- ai/rag/generator.py | 14 +- ai/rag/ingest.py | 102 ++++++----- ai/rag/retrieval.py | 49 ++--- ai/rag/schemas.py | 4 +- ai/react_agent/agent.py | 48 +++-- ai/react_agent/ocr.py | 4 +- ai/schemas.py | 59 ++++-- backend/app/auth/dependencies.py | 11 +- backend/app/auth/router.py | 7 +- backend/app/auth/utils.py | 4 +- backend/app/config.py | 6 +- backend/app/database.py | 1 + backend/app/errors.py | 53 +++--- backend/app/main.py | 1 + backend/app/models.py | 38 ++-- backend/app/pipeline/orchestrator.py | 147 +++++++++++---- backend/app/rag/store.py | 1 + backend/app/routers/actions.py | 11 +- backend/app/routers/deadlines.py | 11 +- backend/app/routers/letters.py | 37 +++- backend/app/routers/public.py | 120 ++++++++----- backend/app/routers/rag.py | 2 +- backend/app/schemas.py | 20 ++- backend/app/services/__init__.py | 6 +- backend/app/services/ai_bridge.py | 257 ++++++++++++++------------- backend/app/services/amounts.py | 3 +- backend/app/services/extraction.py | 21 ++- backend/app/services/pdf_pages.py | 4 +- backend/app/services/persistence.py | 4 +- 31 files changed, 763 insertions(+), 433 deletions(-) diff --git a/ai/form_fill.py b/ai/form_fill.py index 6c3b868..f06e363 100644 --- a/ai/form_fill.py +++ b/ai/form_fill.py @@ -11,9 +11,7 @@ import httpx DASHSCOPE_API_KEY = os.environ.get("DASHSCOPE_API_KEY", "") -DASHSCOPE_INTL_URL = ( - "https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation" -) +DASHSCOPE_INTL_URL = "https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation" # Standard placeholder patterns for common German form fields PLACEHOLDER_MAP = { @@ -70,15 +68,20 @@ async def generate_filled_form( b64 = base64.b64encode(image_bytes).decode() ext = image_path.rsplit(".", 1)[-1].lower() - mime = {"jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png"}.get(ext, "image/jpeg") + mime = {"jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png"}.get( + ext, "image/jpeg" + ) data_url = f"data:{mime};base64,{b64}" field_instructions = _build_field_instructions(placeholders) instruction = ( - "This is a scanned German official letter with a form section that has empty fields. " - "Write placeholder text IN ENGLISH in bright red ink directly into each empty field/line on the form. " - "The placeholder text must be clearly readable and tell the user what to fill in.\n\n" + "This is a scanned German official letter with a form section " + "that has empty fields. " + "Write placeholder text IN ENGLISH in bright red ink directly " + "into each empty field/line on the form. " + "The placeholder text must be clearly readable and tell the " + "user what to fill in.\n\n" "IMPORTANT RULES:\n" "- Write ONLY in English\n" "- Use bright red color for all placeholder text\n" diff --git a/ai/prompts.py b/ai/prompts.py index 7a31dd3..407a19c 100644 --- a/ai/prompts.py +++ b/ai/prompts.py @@ -11,48 +11,78 @@ Output the text in its original German. Do not translate. Do not summarize.""" -AGENT_SYSTEM_PROMPT = f"""You are Klar, an expert on German bureaucracy — especially immigration, residence permits, and student-related official processes. - -You are analyzing an official German letter. Complete ALL of the following tasks: - -## 1. CLASSIFY the letter -- Determine the letter type (e.g., "Residence Permit - Document Request", "Health Insurance - Tax ID Request", "Fine Notice (Bußgeldbescheid)", "Tax Registration", "University Enrollment", etc.) -- Identify the sender agency - -## 2. EXTRACT the deadline — use this 3-step approach IN ORDER: -**Step A — Read the letter directly:** Look for explicit dates or timeframes stated in the letter (e.g., "bis zum 31. März 2026", "innerhalb von 14 Tagen", "Frist: 4 Wochen"). -**Step B — Calculate from context:** If the letter has a date and mentions a relative timeframe (e.g., "innerhalb von 14 Tagen"), calculate the absolute deadline from the letter date. -**Step C — Search online ONLY if Steps A and B found nothing:** If no deadline is stated or calculable from the letter, search for the standard legal deadline for this type of letter/process. Note that you used an external source. - -## 3. ASSESS consequences -Be specific about what happens if the deadline is missed or the requested action is not taken. Use your knowledge first — only search if you need to verify a specific legal consequence or city-specific rule. - -## 4. ASSIGN a risk score (1-5): -1 = Informational, no action needed -2 = Low urgency, action needed but flexible timeline -3 = Medium, clear deadline with moderate consequences -4 = High, deadline with serious consequences (financial, legal) -5 = Critical, missing this threatens legal status in Germany - -## Search tool guidelines -- Keep searches focused and specific — use German keywords -- You do NOT need to search for every letter. Only search when you genuinely need current/specific information you don't already know. -- Good searches: "Techniker Krankenkasse Steuer-ID Frist Konsequenz", "§ 81 Abs 4 AufenthG Frist Nachreichung" -- Bad searches: "what is Techniker Krankenkasse" (you already know this) -- Maximum 2 searches per letter. Make them count. - -Today's date: {date.today().isoformat()}""" +AGENT_SYSTEM_PROMPT = ( + "You are Klar, an expert on German bureaucracy — especially " + "immigration, residence permits, and student-related official " + "processes.\n\n" + "You are analyzing an official German letter. Complete ALL of " + "the following tasks:\n\n" + "## 1. CLASSIFY the letter\n" + "- Determine the letter type (e.g., 'Residence Permit - " + "Document Request', 'Health Insurance - Tax ID Request', " + "'Fine Notice (Bußgeldbescheid)', 'Tax Registration', " + "'University Enrollment', etc.)\n" + "- Identify the sender agency\n\n" + "## 2. EXTRACT the deadline — use this 3-step approach " + "IN ORDER:\n" + "**Step A — Read the letter directly:** Look for explicit " + "dates or timeframes stated in the letter (e.g., 'bis zum " + "31. März 2026', 'innerhalb von 14 Tagen', " + "'Frist: 4 Wochen').\n" + "**Step B — Calculate from context:** If the letter has a " + "date and mentions a relative timeframe (e.g., 'innerhalb " + "von 14 Tagen'), calculate the absolute deadline from the " + "letter date.\n" + "**Step C — Search online ONLY if Steps A and B found " + "nothing:** If no deadline is stated or calculable from the " + "letter, search for the standard legal deadline for this " + "type of letter/process. Note that you used an external " + "source.\n\n" + "## 3. ASSESS consequences\n" + "Be specific about what happens if the deadline is missed " + "or the requested action is not taken. Use your knowledge " + "first — only search if you need to verify a specific legal " + "consequence or city-specific rule.\n\n" + "## 4. ASSIGN a risk score (1-5):\n" + "1 = Informational, no action needed\n" + "2 = Low urgency, action needed but flexible timeline\n" + "3 = Medium, clear deadline with moderate consequences\n" + "4 = High, deadline with serious consequences " + "(financial, legal)\n" + "5 = Critical, missing this threatens legal status " + "in Germany\n\n" + "## Search tool guidelines\n" + "- Keep searches focused and specific — use German " + "keywords\n" + "- You do NOT need to search for every letter. Only search " + "when you genuinely need current/specific information you " + "don't already know.\n" + "- Good searches: 'Techniker Krankenkasse Steuer-ID Frist " + "Konsequenz', '§ 81 Abs 4 AufenthG Frist Nachreichung'\n" + "- Bad searches: 'what is Techniker Krankenkasse' " + "(you already know this)\n" + "- Maximum 2 searches per letter. Make them count.\n\n" + f"Today's date: {date.today().isoformat()}" +) # --- RAG Response Generation Prompt --- -GENERATION_PROMPT = """You are Klar, an expert assistant helping international students in Germany understand and respond to official letters. +GENERATION_PROMPT = """\ +You are Klar, an expert assistant helping international \ +students in Germany understand and respond to official letters. ## ANTI-HALLUCINATION RULES — FOLLOW STRICTLY -1. You may ONLY cite legal paragraphs (§) that appear in the LEGAL REFERENCES section below. -2. If a relevant law is NOT in the references, say "This may be governed by [general area of law], but the specific paragraph was not found in our legal database." -3. NEVER invent or guess § numbers. If you're unsure, say so explicitly. -4. Every legal claim you make must either cite a provided reference OR be clearly marked as general knowledge. -5. For the response draft, use only standard Behördendeutsch phrases you are certain about. +1. You may ONLY cite legal paragraphs (§) that appear in \ +the LEGAL REFERENCES section below. +2. If a relevant law is NOT in the references, say \ +"This may be governed by [general area of law], but the \ +specific paragraph was not found in our legal database." +3. NEVER invent or guess § numbers. If you're unsure, say \ +so explicitly. +4. Every legal claim you make must either cite a provided \ +reference OR be clearly marked as general knowledge. +5. For the response draft, use only standard \ +Behördendeutsch phrases you are certain about. ## The Letter (Original Text) {ocr_text} @@ -65,29 +95,43 @@ Risk: {risk_score}/5 — {risk_label} Consequence: {consequence} -## LEGAL REFERENCES (from database — these are the ONLY §§ you may cite) +## LEGAL REFERENCES (from database — ONLY §§ you may cite) {legal_context} ## Generate the following in {language}: ### EXPLANATION -Clear, plain-language explanation of this letter: what it's about, who sent it, what action is required, urgency, and what happens if ignored. Cite ONLY §§ from the LEGAL REFERENCES above — if none are relevant, explain without citations. +Clear, plain-language explanation of this letter: what \ +it's about, who sent it, what action is required, urgency, \ +and what happens if ignored. Cite ONLY §§ from the LEGAL \ +REFERENCES above — if none are relevant, explain without \ +citations. ### RESPONSE DRAFT -A formal response letter in Behördendeutsch. Include proper salutation, reference number if available, clear statement of what is being submitted, enclosed documents list, professional closing, and [Name] placeholder. +A formal response letter in Behördendeutsch. Include \ +proper salutation, reference number if available, clear \ +statement of what is being submitted, enclosed documents \ +list, professional closing, and [Name] placeholder. ### DOCUMENT CHECKLIST -List ALL documents the user needs to prepare. Include the German term in parentheses. +List ALL documents the user needs to prepare. Include \ +the German term in parentheses. ### CITATIONS -List ONLY § references from the LEGAL REFERENCES above that are relevant. If none, return empty list. -Each citation must be an object: {{"section": "§ XX LawName", "text": "why it's relevant"}}. +List ONLY § references from the LEGAL REFERENCES above \ +that are relevant. If none, return empty list. +Each citation must be an object: \ +{{"section": "§ XX LawName", "text": "why it's relevant"}}. -Respond as JSON with keys: explanation, response_draft, checklist, citations.""" +Respond as JSON with keys: explanation, response_draft, \ +checklist, citations.""" # --- Chat Assistant Prompt --- -CHAT_SYSTEM_PROMPT = """You are Klar's follow-up assistant. The user has uploaded a German official letter and you have already analyzed it. Now they're asking a follow-up question. +CHAT_SYSTEM_PROMPT = """\ +You are Klar's follow-up assistant. The user has uploaded \ +a German official letter and you have already analyzed it. \ +Now they're asking a follow-up question. ## Context about this letter: Institution: {institution} diff --git a/ai/rag/generator.py b/ai/rag/generator.py index 11e64ff..fe78f81 100644 --- a/ai/rag/generator.py +++ b/ai/rag/generator.py @@ -14,8 +14,14 @@ QWEN_AGENT_MODEL = os.environ.get("QWEN_AGENT_MODEL", "qwen3.7-plus") LANGUAGE_NAMES = { - "en": "English", "de": "German", "tr": "Turkish", "ar": "Arabic", - "es": "Spanish", "fr": "French", "zh": "Chinese", "fa": "Persian", + "en": "English", + "de": "German", + "tr": "Turkish", + "ar": "Arabic", + "es": "Spanish", + "fr": "French", + "zh": "Chinese", + "fa": "Persian", } _model = ChatOpenAI( @@ -34,7 +40,9 @@ async def generate_response( language: str = "en", ) -> GenerationOutput: """Retrieve legal context from ChromaDB, inject into prompt, return structured output.""" - legal_context = retrieve_as_context(agent_result.letter_type, agent_result.consequence) + legal_context = retrieve_as_context( + agent_result.letter_type, agent_result.consequence + ) prompt = GENERATION_PROMPT.format( ocr_text=ocr_text[:3000], diff --git a/ai/rag/ingest.py b/ai/rag/ingest.py index fb26cff..bebfce8 100644 --- a/ai/rag/ingest.py +++ b/ai/rag/ingest.py @@ -24,32 +24,33 @@ import chromadb from openai import OpenAI -# ── Paths ───────────────────────────────────────────────────────────────────── +# ── Paths ────────────────────────────────────────────────────────────── -ROOT = Path(__file__).resolve().parent.parent # ai/ -LAWS_DIR = ROOT / "data" / "laws" # ai/data/laws/ -CHROMA_DIR = ROOT / "data" / "chroma" # ai/data/chroma/ +ROOT = Path(__file__).resolve().parent.parent # ai/ +LAWS_DIR = ROOT / "data" / "laws" # ai/data/laws/ +CHROMA_DIR = ROOT / "data" / "chroma" # ai/data/chroma/ COLLECTION_NAME = "german_laws" -# ── Law file → abbreviation map ─────────────────────────────────────────────── +# ── Law file → abbreviation map ──────────────────────────────────────── LAWS = { - "aufenthg.md": "AufenthG", - "aufenthv.md": "AufenthV", - "beschv.md": "BeschV", - "vwvfg.md": "VwVfG", - "bafoeg.md": "BAföG", - "asylg.md": "AsylG", - "asylblg.md": "AsylbLG", - "wogg.md": "WoGG", - "bmg.md": "BMG", - "intv.md": "IntV", - "owig.md": "OWiG", - "estg.md": "EStG", - "sgb5.md": "SGB V", + "aufenthg.md": "AufenthG", + "aufenthv.md": "AufenthV", + "beschv.md": "BeschV", + "vwvfg.md": "VwVfG", + "bafoeg.md": "BAföG", + "asylg.md": "AsylG", + "asylblg.md": "AsylbLG", + "wogg.md": "WoGG", + "bmg.md": "BMG", + "intv.md": "IntV", + "owig.md": "OWiG", + "estg.md": "EStG", + "sgb5.md": "SGB V", } -# ── Qwen client ─────────────────────────────────────────────────────────────── +# ── Qwen client ──────────────────────────────────────────────────────── + def get_qwen_client() -> OpenAI: api_key = os.getenv("DASHSCOPE_API_KEY") @@ -64,8 +65,8 @@ def get_qwen_client() -> OpenAI: # Qwen text-embedding-v3 limits -MAX_BATCH_SIZE = 10 # max texts per API call -MAX_CHARS = 6000 # conservative char limit (~8192 tokens safety margin) +MAX_BATCH_SIZE = 10 # max texts per API call +MAX_CHARS = 6000 # conservative char limit (~8192 tokens safety margin) def truncate(text: str) -> str: @@ -85,7 +86,7 @@ def embed_texts(client: OpenAI, texts: list[str]) -> list[list[float]]: texts = [truncate(t) for t in texts] for i in range(0, len(texts), MAX_BATCH_SIZE): - batch = texts[i:i + MAX_BATCH_SIZE] + batch = texts[i : i + MAX_BATCH_SIZE] response = client.embeddings.create( model="text-embedding-v3", input=batch, @@ -99,7 +100,8 @@ def embed_texts(client: OpenAI, texts: list[str]) -> list[list[float]]: return embeddings -# ── Chunking ────────────────────────────────────────────────────────────────── +# ── Chunking ─────────────────────────────────────────────────────────── + def parse_paragraphs(text: str, law_abbrev: str) -> list[dict]: """ @@ -110,7 +112,7 @@ def parse_paragraphs(text: str, law_abbrev: str) -> list[dict]: id, text, paragraph, title, law """ # Match § headers at any heading level: ### § 1, #### § 4a, etc. - pattern = r'^#{1,4} (§ \d+[a-z]?\b.*?)$' + pattern = r"^#{1,4} (§ \d+[a-z]?\b.*?)$" matches = list(re.finditer(pattern, text, re.MULTILINE)) chunks = [] @@ -121,7 +123,7 @@ def parse_paragraphs(text: str, law_abbrev: str) -> list[dict]: end = matches[i + 1].start() if i + 1 < len(matches) else len(text) header = match.group(1).strip() - para_num_match = re.match(r'(§ \d+[a-z]?)', header) + para_num_match = re.match(r"(§ \d+[a-z]?)", header) para_num = para_num_match.group(1) if para_num_match else header body = text[start:end].strip() @@ -141,21 +143,24 @@ def parse_paragraphs(text: str, law_abbrev: str) -> list[dict]: counter += 1 seen_ids.add(unique_id) - chunks.append({ - "id": unique_id, - "text": body, - "paragraph": para_num, - "title": header, - "law": law_abbrev, - }) + chunks.append( + { + "id": unique_id, + "text": body, + "paragraph": para_num, + "title": header, + "law": law_abbrev, + } + ) return chunks -# ── Main ────────────────────────────────────────────────────────────────────── +# ── Main ─────────────────────────────────────────────────────────────── + def ingest_all(): - print("── Klar RAG Ingestion ──────────────────────────────────────") + print("── Klar RAG Ingestion ─────────────────────────────────") if not LAWS_DIR.exists(): print(f"ERROR: {LAWS_DIR} not found.") @@ -197,32 +202,39 @@ def ingest_all(): print(f" ⚠ No paragraphs parsed in {filename}") continue - print(f" Embedding {law_abbrev}: {len(chunks)} paragraphs ...", end=" ", flush=True) + print( + f" Embedding {law_abbrev}: {len(chunks)} paragraphs ...", + end=" ", + flush=True, + ) texts_to_embed = [c["text"] for c in chunks] embeddings = embed_texts(client, texts_to_embed) batch_size = 100 for i in range(0, len(chunks), batch_size): - batch_chunks = chunks[i:i + batch_size] - batch_embeddings = embeddings[i:i + batch_size] + batch_chunks = chunks[i : i + batch_size] + batch_embeddings = embeddings[i : i + batch_size] collection.add( ids=[c["id"] for c in batch_chunks], documents=[c["text"] for c in batch_chunks], - metadatas=[{ - "law": c["law"], - "paragraph": c["paragraph"], - "title": c["title"], - } for c in batch_chunks], + metadatas=[ + { + "law": c["law"], + "paragraph": c["paragraph"], + "title": c["title"], + } + for c in batch_chunks + ], embeddings=batch_embeddings, ) - print(f"✅") + print("✅") total_chunks += len(chunks) print() - print(f"── Done ────────────────────────────────────────────────────") + print("── Done ─────────────────────────────────────────────────") print(f" Total chunks ingested : {total_chunks}") print(f" ChromaDB saved to : {CHROMA_DIR.resolve()}") print() @@ -230,4 +242,4 @@ def ingest_all(): if __name__ == "__main__": - ingest_all() \ No newline at end of file + ingest_all() diff --git a/ai/rag/retrieval.py b/ai/rag/retrieval.py index aee8302..61b6ba8 100644 --- a/ai/rag/retrieval.py +++ b/ai/rag/retrieval.py @@ -23,25 +23,26 @@ import chromadb from openai import OpenAI -# ── Paths ───────────────────────────────────────────────────────────────────── +# ── Paths ────────────────────────────────────────────────────────────── -ROOT = Path(__file__).resolve().parent.parent # ai/ -CHROMA_DIR = ROOT / "data" / "chroma" # ai/data/chroma/ +ROOT = Path(__file__).resolve().parent.parent # ai/ +CHROMA_DIR = ROOT / "data" / "chroma" # ai/data/chroma/ COLLECTION_NAME = "german_laws" -# ── Schema ──────────────────────────────────────────────────────────────────── +# ── Schema ───────────────────────────────────────────────────────────── + @dataclass class LegalChunk: - section: str # e.g. "§ 81" - law: str # e.g. "AufenthG" - title: str # e.g. "§ 81 Beantragung des Aufenthaltstitels" - text: str # full paragraph text - citation: str # e.g. "§ 81 AufenthG" - score: float # cosine similarity, higher = more relevant + section: str # e.g. "§ 81" + law: str # e.g. "AufenthG" + title: str # e.g. "§ 81 Beantragung des Aufenthaltstitels" + text: str # full paragraph text + citation: str # e.g. "§ 81 AufenthG" + score: float # cosine similarity, higher = more relevant -# ── Singleton clients ───────────────────────────────────────────────────────── +# ── Singleton clients ────────────────────────────────────────────────── # Loaded once on first call, reused across all requests _collection = None @@ -67,8 +68,7 @@ def _get_collection(): if _collection is None: if not CHROMA_DIR.exists(): raise RuntimeError( - f"ChromaDB not found at {CHROMA_DIR}. " - "Run `python ai/rag/ingest.py` first." + f"ChromaDB not found at {CHROMA_DIR}. Run `python ai/rag/ingest.py` first." ) db = chromadb.PersistentClient(path=str(CHROMA_DIR)) _collection = db.get_collection(name=COLLECTION_NAME) @@ -85,7 +85,8 @@ def _embed_query(query: str) -> list[float]: return response.data[0].embedding -# ── Core retrieval ──────────────────────────────────────────────────────────── +# ── Core retrieval ───────────────────────────────────────────────────── + def retrieve_legal_context( letter_type: str, @@ -124,14 +125,16 @@ def retrieve_legal_context( results["distances"][0], ): section = meta.get("paragraph", meta.get("section", "Unknown")) - chunks.append(LegalChunk( - section=section, - law=meta["law"], - title=meta["title"], - text=doc, - citation=f"{section} {meta['law']}", - score=round(1 - distance, 4), - )) + chunks.append( + LegalChunk( + section=section, + law=meta["law"], + title=meta["title"], + text=doc, + citation=f"{section} {meta['law']}", + score=round(1 - distance, 4), + ) + ) return chunks @@ -156,4 +159,4 @@ def retrieve_as_context( text_preview = c.text[:1000] + "..." if len(c.text) > 1000 else c.text parts.append(f"[{c.citation}] {c.title}\n{text_preview}") - return "\n\n---\n\n".join(parts) \ No newline at end of file + return "\n\n---\n\n".join(parts) diff --git a/ai/rag/schemas.py b/ai/rag/schemas.py index edc7ba4..bcae2c2 100644 --- a/ai/rag/schemas.py +++ b/ai/rag/schemas.py @@ -8,7 +8,7 @@ @dataclass class RAGEvent: - type: str # "explanation" | "response_draft" | "checklist" | "citations" | "error" + type: str # "explanation" | "response_draft" | "checklist" | "citations" | "error" data: dict confidence: str = "high" # "high" if RAG matched well, "low" if no strong matches # data shapes per type: @@ -16,4 +16,4 @@ class RAGEvent: # response_draft: {"chunk": str} — streamed token by token # checklist: {"items": list[str]} — emitted once, complete # citations: {"items": list[dict]} — emitted once, [{section, text}, ...] - # error: {"message": str} \ No newline at end of file + # error: {"message": str} diff --git a/ai/react_agent/agent.py b/ai/react_agent/agent.py index 155c349..40a671a 100644 --- a/ai/react_agent/agent.py +++ b/ai/react_agent/agent.py @@ -43,9 +43,15 @@ async def run_react_agent(ocr_text: str) -> AsyncGenerator[AgentEvent, None]: """Run the ReAct agent with structured output via LangGraph response_format.""" try: - result = await _agent.ainvoke({ - "messages": [HumanMessage(content=f"Analyze this German official letter:\n\n{ocr_text}")], - }) + result = await _agent.ainvoke( + { + "messages": [ + HumanMessage( + content=f"Analyze this German official letter:\n\n{ocr_text}" + ) + ], + } + ) analysis: AgentAnalysis = result["structured_response"] @@ -57,10 +63,22 @@ async def run_react_agent(ocr_text: str) -> AsyncGenerator[AgentEvent, None]: except ValueError: pass - yield AgentEvent("classification", {"type": analysis.classification.type, "agency": analysis.classification.agency}) - yield AgentEvent("risk_score", {"score": analysis.risk_score.score, "label": analysis.risk_score.label}) + yield AgentEvent( + "classification", + { + "type": analysis.classification.type, + "agency": analysis.classification.agency, + }, + ) + yield AgentEvent( + "risk_score", + {"score": analysis.risk_score.score, "label": analysis.risk_score.label}, + ) if analysis.deadline.date: - yield AgentEvent("deadline", {"date": analysis.deadline.date, "days_remaining": days_remaining}) + yield AgentEvent( + "deadline", + {"date": analysis.deadline.date, "days_remaining": days_remaining}, + ) yield AgentEvent("consequence", {"text": analysis.consequence.text}) except Exception as e: @@ -70,9 +88,19 @@ async def run_react_agent(ocr_text: str) -> AsyncGenerator[AgentEvent, None]: def get_last_agent_result(events: list[AgentEvent], ocr_text: str) -> AgentResult: """Reconstruct an AgentResult from collected events.""" data = {e.type: e.data for e in events} - c, d, r, q = data.get("classification", {}), data.get("deadline", {}), data.get("risk_score", {}), data.get("consequence", {}) + c, d, r, q = ( + data.get("classification", {}), + data.get("deadline", {}), + data.get("risk_score", {}), + data.get("consequence", {}), + ) return AgentResult( - ocr_text=ocr_text, letter_type=c.get("type", "Unknown"), agency=c.get("agency", "Unknown"), - deadline_date=d.get("date"), days_remaining=d.get("days_remaining"), - consequence=q.get("text", ""), risk_score=r.get("score", 3), risk_label=r.get("label", "Medium"), + ocr_text=ocr_text, + letter_type=c.get("type", "Unknown"), + agency=c.get("agency", "Unknown"), + deadline_date=d.get("date"), + days_remaining=d.get("days_remaining"), + consequence=q.get("text", ""), + risk_score=r.get("score", 3), + risk_label=r.get("label", "Medium"), ) diff --git a/ai/react_agent/ocr.py b/ai/react_agent/ocr.py index 8f047f7..e1359f1 100644 --- a/ai/react_agent/ocr.py +++ b/ai/react_agent/ocr.py @@ -42,7 +42,9 @@ def _parse_ocr_response(result: object) -> str: "image without readable content." ) - message = (choices[0] or {}).get("message") if isinstance(choices[0], dict) else None + message = ( + (choices[0] or {}).get("message") if isinstance(choices[0], dict) else None + ) content = (message or {}).get("content") if isinstance(message, dict) else None if not content or not str(content).strip(): diff --git a/ai/schemas.py b/ai/schemas.py index 0151249..0338f29 100644 --- a/ai/schemas.py +++ b/ai/schemas.py @@ -4,19 +4,44 @@ # --- Structured output models (used by LLM response_format) --- + class Classification(BaseModel): - type: str = Field(description="Letter type, e.g. 'Residence Permit - Document Request', 'Health Insurance - Tax ID Request'") - agency: str = Field(description="Sender agency name, e.g. 'Techniker Krankenkasse', 'Ausländerbehörde München'") + type: str = Field( + description=( + "Letter type, e.g. 'Residence Permit - Document " + "Request', 'Health Insurance - Tax ID Request'" + ) + ) + agency: str = Field( + description=( + "Sender agency name, e.g. 'Techniker Krankenkasse', 'Ausländerbehörde München'" + ) + ) class Deadline(BaseModel): - date: str | None = Field(description="Deadline date in YYYY-MM-DD format, or null if no deadline") - days_remaining: int | None = Field(description="Days until deadline from today, or null") - source: str = Field(description="'letter' if read directly, 'calculated' if computed from letter date, 'searched' if from web, 'none' if no deadline applies") + date: str | None = Field( + description="Deadline date in YYYY-MM-DD format, or null if no deadline" + ) + days_remaining: int | None = Field( + description="Days until deadline from today, or null" + ) + source: str = Field( + description=( + "'letter' if read directly, 'calculated' if " + "computed from letter date, 'searched' if from " + "web, 'none' if no deadline applies" + ) + ) class Consequence(BaseModel): - text: str = Field(description="Detailed consequence description of what happens if deadline is missed or action not taken") + text: str = Field( + description=( + "Detailed consequence description of what happens " + "if deadline is missed or action not taken" + ) + ) severity: str = Field(description="One-line severity summary") @@ -28,6 +53,7 @@ class RiskScore(BaseModel): class AgentAnalysis(BaseModel): """Structured output from the ReAct agent letter analysis.""" + classification: Classification deadline: Deadline consequence: Consequence @@ -35,23 +61,34 @@ class AgentAnalysis(BaseModel): class Citation(BaseModel): - section: str = Field(description="Legal paragraph reference, e.g. '§ 81 Abs. 4 AufenthG'") + section: str = Field( + description="Legal paragraph reference, e.g. '§ 81 Abs. 4 AufenthG'" + ) text: str = Field(description="Brief explanation of why this citation is relevant") class GenerationOutput(BaseModel): """Structured output from the response generation LLM.""" - explanation: str = Field(description="Clear plain-language explanation of the letter") + + explanation: str = Field( + description="Clear plain-language explanation of the letter" + ) response_draft: str = Field(description="Formal response letter in Behördendeutsch") - checklist: list[str] = Field(description="List of documents the user needs to prepare, with German terms in parentheses") - citations: list[Citation] = Field(default_factory=list, description="Legal § references that are relevant. Empty list if none found.") + checklist: list[str] = Field( + description="List of documents the user needs to prepare, with German terms in parentheses" + ) + citations: list[Citation] = Field( + default_factory=list, + description="Legal § references that are relevant. Empty list if none found.", + ) # --- Internal data transfer objects --- + @dataclass class AgentEvent: - type: str # "classification", "risk_score", "deadline", "consequence", "error" + type: str # "classification", "risk_score", "deadline", "consequence", "error" data: dict diff --git a/backend/app/auth/dependencies.py b/backend/app/auth/dependencies.py index 7659868..0c10f16 100644 --- a/backend/app/auth/dependencies.py +++ b/backend/app/auth/dependencies.py @@ -1,7 +1,6 @@ """FastAPI dependency that resolves the current user from the session cookie.""" import logging -from datetime import datetime from fastapi import Depends, Request, status from sqlmodel import Session as DBSession, select @@ -37,7 +36,8 @@ def _resolve_user(token: str | None, db: DBSession) -> User: logger.warning( "AUTH_SESSION_NOT_FOUND: no Session row for token=%s... " "(DB=%s; possible causes: db wiped, multiple workers, env mismatch)", - token_prefix, settings.database_url, + token_prefix, + settings.database_url, ) raise KlarHTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -47,7 +47,9 @@ def _resolve_user(token: str | None, db: DBSession) -> User: if session_row.expires_at < utcnow(): logger.info( "AUTH_SESSION_EXPIRED: token=%s... expired_at=%s now=%s", - token_prefix, session_row.expires_at, utcnow(), + token_prefix, + session_row.expires_at, + utcnow(), ) raise KlarHTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -59,7 +61,8 @@ def _resolve_user(token: str | None, db: DBSession) -> User: # Session row exists but the user it points to is gone — corrupt FK. logger.error( "AUTH: orphan Session row token=%s... user_id=%s has no User", - token_prefix, session_row.user_id, + token_prefix, + session_row.user_id, ) raise KlarHTTPException( status_code=status.HTTP_401_UNAUTHORIZED, diff --git a/backend/app/auth/router.py b/backend/app/auth/router.py index 151767b..e26069c 100644 --- a/backend/app/auth/router.py +++ b/backend/app/auth/router.py @@ -1,7 +1,5 @@ """Authentication routes: signup, login, logout, me, forgot/reset password.""" -from datetime import datetime - from fastapi import APIRouter, Depends, Request, Response, status from pydantic import BaseModel, EmailStr, Field, field_validator from sqlmodel import Session as DBSession, select @@ -296,7 +294,10 @@ def forgot_password( responses={ 400: { "model": ErrorResponse, - "description": "Token invalid (`AUTH_INVALID_RESET_TOKEN`) or expired (`AUTH_RESET_TOKEN_EXPIRED`).", + "description": ( + "Token invalid (`AUTH_INVALID_RESET_TOKEN`)" + " or expired (`AUTH_RESET_TOKEN_EXPIRED`)." + ), }, }, ) diff --git a/backend/app/auth/utils.py b/backend/app/auth/utils.py index 9d9a5f5..7198836 100644 --- a/backend/app/auth/utils.py +++ b/backend/app/auth/utils.py @@ -12,7 +12,9 @@ def hash_password(plain: str) -> str: """bcrypt with cost factor 12 — ~250ms on a modern laptop.""" - return bcrypt.hashpw(plain.encode("utf-8"), bcrypt.gensalt(rounds=12)).decode("utf-8") + return bcrypt.hashpw(plain.encode("utf-8"), bcrypt.gensalt(rounds=12)).decode( + "utf-8" + ) def verify_password(plain: str, password_hash: str) -> bool: diff --git a/backend/app/config.py b/backend/app/config.py index 15037ca..5a60bc6 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -46,7 +46,11 @@ def effective_llm_api_key(self) -> str: @property def effective_llm_base_url(self) -> str: - return self.qwen_api_base or self.llm_base_url or "https://dashscope-intl.aliyuncs.com/compatible-mode/v1" + return ( + self.qwen_api_base + or self.llm_base_url + or "https://dashscope-intl.aliyuncs.com/compatible-mode/v1" + ) @property def effective_llm_model(self) -> str: diff --git a/backend/app/database.py b/backend/app/database.py index f666408..836a132 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -21,6 +21,7 @@ def init_db() -> None: path = settings.database_url.replace("sqlite:///", "", 1) Path(path).parent.mkdir(parents=True, exist_ok=True) from app import models # noqa: F401 — populate SQLModel metadata + SQLModel.metadata.create_all(engine) diff --git a/backend/app/errors.py b/backend/app/errors.py index 20478a8..75546ea 100644 --- a/backend/app/errors.py +++ b/backend/app/errors.py @@ -56,35 +56,43 @@ class ErrorCode(str, Enum): """ # --- generic --- - HTTP_ERROR = "HTTP_ERROR" # untyped fallback (legacy HTTPException) - INTERNAL_ERROR = "INTERNAL_ERROR" # unhandled exception - VALIDATION_ERROR = "VALIDATION_ERROR" # request body / query / path + HTTP_ERROR = "HTTP_ERROR" # untyped fallback (legacy HTTPException) + INTERNAL_ERROR = "INTERNAL_ERROR" # unhandled exception + VALIDATION_ERROR = "VALIDATION_ERROR" # request body / query / path # --- auth --- - AUTH_NOT_AUTHENTICATED = "AUTH_NOT_AUTHENTICATED" # no cookie at all - AUTH_SESSION_NOT_FOUND = "AUTH_SESSION_NOT_FOUND" # cookie present, but no Session row in DB - AUTH_SESSION_EXPIRED = "AUTH_SESSION_EXPIRED" # Session row exists but past expires_at + AUTH_NOT_AUTHENTICATED = "AUTH_NOT_AUTHENTICATED" # no cookie at all + AUTH_SESSION_NOT_FOUND = ( + "AUTH_SESSION_NOT_FOUND" # cookie present, but no Session row in DB + ) + AUTH_SESSION_EXPIRED = ( + "AUTH_SESSION_EXPIRED" # Session row exists but past expires_at + ) AUTH_INVALID_CREDENTIALS = "AUTH_INVALID_CREDENTIALS" # wrong email / password - AUTH_EMAIL_TAKEN = "AUTH_EMAIL_TAKEN" # signup with existing email - AUTH_INVALID_RESET_TOKEN = "AUTH_INVALID_RESET_TOKEN" # token unknown / already used + AUTH_EMAIL_TAKEN = "AUTH_EMAIL_TAKEN" # signup with existing email + AUTH_INVALID_RESET_TOKEN = ( + "AUTH_INVALID_RESET_TOKEN" # token unknown / already used + ) AUTH_RESET_TOKEN_EXPIRED = "AUTH_RESET_TOKEN_EXPIRED" # token past 15-min TTL # --- letters --- LETTER_NOT_FOUND = "LETTER_NOT_FOUND" - LETTER_FILE_MISSING = "LETTER_FILE_MISSING" # row exists but file gone + LETTER_FILE_MISSING = "LETTER_FILE_MISSING" # row exists but file gone LETTER_EMPTY_UPLOAD = "LETTER_EMPTY_UPLOAD" LETTER_TOO_LARGE = "LETTER_TOO_LARGE" LETTER_UNSUPPORTED_TYPE = "LETTER_UNSUPPORTED_TYPE" - LETTER_CORRUPT_FILE = "LETTER_CORRUPT_FILE" # magic-bytes mismatch - LETTER_MIME_MISMATCH = "LETTER_MIME_MISMATCH" # declared ≠ detected + LETTER_CORRUPT_FILE = "LETTER_CORRUPT_FILE" # magic-bytes mismatch + LETTER_MIME_MISMATCH = "LETTER_MIME_MISMATCH" # declared ≠ detected # --- actions --- ACTION_NOT_FOUND = "ACTION_NOT_FOUND" # --- pipeline / AI --- - EXTRACTION_FAILED = "EXTRACTION_FAILED" # SSE-only: model returned no tool call, parse error, etc. - LLM_PROVIDER_ERROR = "LLM_PROVIDER_ERROR" # network / 5xx from Qwen - PDF_RENDER_FAILED = "PDF_RENDER_FAILED" # pdf2image / poppler missing + EXTRACTION_FAILED = ( + "EXTRACTION_FAILED" # SSE-only: model returned no tool call, parse error, etc. + ) + LLM_PROVIDER_ERROR = "LLM_PROVIDER_ERROR" # network / 5xx from Qwen + PDF_RENDER_FAILED = "PDF_RENDER_FAILED" # pdf2image / poppler missing # User-facing default messages per code. Keep short, no jargon, no secrets. @@ -93,7 +101,6 @@ class ErrorCode(str, Enum): ErrorCode.HTTP_ERROR: "Something went wrong with that request.", ErrorCode.INTERNAL_ERROR: "Something went wrong on our end. Please try again.", ErrorCode.VALIDATION_ERROR: "Some fields in your request are invalid.", - ErrorCode.AUTH_NOT_AUTHENTICATED: "Please sign in to continue.", ErrorCode.AUTH_SESSION_NOT_FOUND: "Your session is no longer recognized. Please sign in again.", ErrorCode.AUTH_SESSION_EXPIRED: "Your session has expired. Please sign in again.", @@ -101,7 +108,6 @@ class ErrorCode(str, Enum): ErrorCode.AUTH_EMAIL_TAKEN: "An account with that email already exists.", ErrorCode.AUTH_INVALID_RESET_TOKEN: "This reset link is invalid or has already been used.", ErrorCode.AUTH_RESET_TOKEN_EXPIRED: "This reset link has expired. Please request a new one.", - ErrorCode.LETTER_NOT_FOUND: "That letter doesn't exist or you don't have access to it.", ErrorCode.LETTER_FILE_MISSING: "We can't find the uploaded file for this letter.", ErrorCode.LETTER_EMPTY_UPLOAD: "The uploaded file is empty.", @@ -109,11 +115,11 @@ class ErrorCode(str, Enum): ErrorCode.LETTER_UNSUPPORTED_TYPE: "We can only read JPEG, PNG, HEIC, WebP, or PDF letters.", ErrorCode.LETTER_CORRUPT_FILE: "The file looks corrupted or isn't the type it claims to be.", ErrorCode.LETTER_MIME_MISMATCH: "The file's content doesn't match its declared type.", - ErrorCode.ACTION_NOT_FOUND: "That action doesn't exist or you don't have access to it.", - ErrorCode.EXTRACTION_FAILED: "We couldn't read this letter. Try a clearer photo or PDF.", - ErrorCode.LLM_PROVIDER_ERROR: "Our AI provider is having trouble. Please try again in a moment.", + ErrorCode.LLM_PROVIDER_ERROR: ( + "Our AI provider is having trouble. Please try again in a moment." + ), ErrorCode.PDF_RENDER_FAILED: "We couldn't open that PDF. Try uploading it as an image instead.", } @@ -188,8 +194,9 @@ async def generic_http_exception_handler( # If detail is already a Klar envelope dict (from KlarHTTPException # routing through the default handler), pass it through. if isinstance(exc.detail, dict) and "code" in exc.detail: - return JSONResponse(status_code=exc.status_code, content=exc.detail, - headers=exc.headers) + return JSONResponse( + status_code=exc.status_code, content=exc.detail, headers=exc.headers + ) # Auth-shaped status codes get more specific codes by default. code = ErrorCode.HTTP_ERROR @@ -239,7 +246,9 @@ async def unhandled_exception_handler(req: Request, exc: Exception) -> JSONRespo """ logger.exception( "Unhandled exception on %s %s: %s", - req.method, req.url.path, exc, + req.method, + req.url.path, + exc, ) return JSONResponse( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, diff --git a/backend/app/main.py b/backend/app/main.py index 04f428b..ef34d42 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -85,6 +85,7 @@ def _bridge_env_to_ai_team() -> None: value still wins. """ import os + if settings.effective_llm_api_key: os.environ.setdefault("DASHSCOPE_API_KEY", settings.effective_llm_api_key) if settings.effective_llm_base_url: diff --git a/backend/app/models.py b/backend/app/models.py index 69b4890..19404b4 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -55,20 +55,22 @@ class DocumentCategory(str, Enum): does not fit any defined bucket. """ - HEALTH_INSURANCE = "health_insurance" # AOK, TK, BARMER, private KV - OTHER_INSURANCE = "other_insurance" # Haftpflicht, Hausrat, KFZ, Leben - BANKING = "banking" # bank accounts, credit cards, SCHUFA - TAX = "tax" # Finanzamt - IMMIGRATION = "immigration" # Ausländerbehörde, residence/visa - EDUCATION = "education" # universities, BAföG, Studentenwerk - HOUSING = "housing" # landlord, property management - UTILITIES = "utilities" # Strom, Gas, Wasser, Internet, Mobilfunk - EMPLOYMENT = "employment" # Arbeitgeber, HR, Lohn - GOVERNMENT_BENEFITS = "government_benefits" # ALG I/II, Kindergeld, Elterngeld, Wohngeld - PENSION = "pension" # Deutsche Rentenversicherung - BROADCAST_FEE = "broadcast_fee" # Beitragsservice / Rundfunk - CIVIC = "civic" # Bürgeramt, Personalausweis, Pass - LEGAL_DEBT = "legal_debt" # Mahnbescheid, Inkasso, Bußgeld, Anwalt + HEALTH_INSURANCE = "health_insurance" # AOK, TK, BARMER, private KV + OTHER_INSURANCE = "other_insurance" # Haftpflicht, Hausrat, KFZ, Leben + BANKING = "banking" # bank accounts, credit cards, SCHUFA + TAX = "tax" # Finanzamt + IMMIGRATION = "immigration" # Ausländerbehörde, residence/visa + EDUCATION = "education" # universities, BAföG, Studentenwerk + HOUSING = "housing" # landlord, property management + UTILITIES = "utilities" # Strom, Gas, Wasser, Internet, Mobilfunk + EMPLOYMENT = "employment" # Arbeitgeber, HR, Lohn + GOVERNMENT_BENEFITS = ( + "government_benefits" # ALG I/II, Kindergeld, Elterngeld, Wohngeld + ) + PENSION = "pension" # Deutsche Rentenversicherung + BROADCAST_FEE = "broadcast_fee" # Beitragsservice / Rundfunk + CIVIC = "civic" # Bürgeramt, Personalausweis, Pass + LEGAL_DEBT = "legal_debt" # Mahnbescheid, Inkasso, Bußgeld, Anwalt OTHER = "other" @@ -114,15 +116,15 @@ class Letter(SQLModel, table=True): original_file: str = "" # Spec-flat structured fields (denormalized from ActionItem for /api/letters) - letter_type: str = "" # alias of document_type for spec compat - risk_score: int = 0 # denormalized highest action risk + letter_type: str = "" # alias of document_type for spec compat + risk_score: int = 0 # denormalized highest action risk deadline_date: Optional[date] = None # denormalized most-urgent action deadline # Rich Klar extras institution: str = "" document_type: str = "" category: DocumentCategory = DocumentCategory.OTHER - summary: str = "" # language matches Letter.language + summary: str = "" # language matches Letter.language language: str = "en" # OCR + long-form generation outputs @@ -132,7 +134,7 @@ class Letter(SQLModel, table=True): # "low confidence, get a human" prompt. Computed from extraction outputs. confidence: Optional[float] = None explanation: str = "" - response_draft: str = "" # ALWAYS German (formal reply to German institution) + response_draft: str = "" # ALWAYS German (formal reply to German institution) checklist: list[str] = Field(default_factory=list, sa_column=Column(JSON)) citations: list[dict] = Field(default_factory=list, sa_column=Column(JSON)) consequence: str = "" diff --git a/backend/app/pipeline/orchestrator.py b/backend/app/pipeline/orchestrator.py index 6048a0a..e791dbf 100644 --- a/backend/app/pipeline/orchestrator.py +++ b/backend/app/pipeline/orchestrator.py @@ -40,7 +40,7 @@ import os import re import tempfile -from datetime import date, datetime +from datetime import date from typing import AsyncIterator from uuid import UUID @@ -166,10 +166,31 @@ async def _ocr_letter_file(path: str) -> str: # text for common German date patterns and pick the most-likely deadline. _GERMAN_MONTHS = { - "januar": 1, "jan": 1, "februar": 2, "feb": 2, "märz": 3, "mar": 3, "mrz": 3, - "april": 4, "apr": 4, "mai": 5, "juni": 6, "jun": 6, "juli": 7, "jul": 7, - "august": 8, "aug": 8, "september": 9, "sep": 9, "sept": 9, - "oktober": 10, "okt": 10, "november": 11, "nov": 11, "dezember": 12, "dez": 12, + "januar": 1, + "jan": 1, + "februar": 2, + "feb": 2, + "märz": 3, + "mar": 3, + "mrz": 3, + "april": 4, + "apr": 4, + "mai": 5, + "juni": 6, + "jun": 6, + "juli": 7, + "jul": 7, + "august": 8, + "aug": 8, + "september": 9, + "sep": 9, + "sept": 9, + "oktober": 10, + "okt": 10, + "november": 11, + "nov": 11, + "dezember": 12, + "dez": 12, } # Match: "28. Oktober 2021", "28 Oktober 2021", "den 28. Oktober 2021" @@ -269,16 +290,20 @@ async def process_letter_stream(letter_id: UUID, lang: str) -> AsyncIterator[str except Exception as exc: logger.exception( "AI team's modules failed to import — check env (DASHSCOPE_API_KEY, " - "TAVILY_API_KEY): %s", exc, + "TAVILY_API_KEY): %s", + exc, ) - yield sse_event("error", sse_error_payload( - ErrorCode.LLM_PROVIDER_ERROR, - message=( - "AI pipeline failed to initialize. Most likely cause: missing " - "DASHSCOPE_API_KEY or TAVILY_API_KEY env var on the backend " - "process. See server logs." + yield sse_event( + "error", + sse_error_payload( + ErrorCode.LLM_PROVIDER_ERROR, + message=( + "AI pipeline failed to initialize. Most likely cause: missing " + "DASHSCOPE_API_KEY or TAVILY_API_KEY env var on the backend " + "process. See server logs." + ), ), - )) + ) return out_lang = normalize_lang(lang) @@ -316,16 +341,18 @@ async def process_letter_stream(letter_id: UUID, lang: str) -> AsyncIterator[str # STAGE 2 — ReAct agent (LangGraph + Tavily, ~5-15s) # ============================================================ agent_events_collected = [] - classification_data: dict | None = None risk_label = "Medium" async for ev in run_react_agent(ocr_text): agent_events_collected.append(ev) if ev.type == "classification": - classification_data = ev.data - category = ai_bridge.map_classification_to_category(ev.data.get("type", "")) - letter.document_type = ev.data.get("type", "") or letter.document_type + category = ai_bridge.map_classification_to_category( + ev.data.get("type", "") + ) + letter.document_type = ( + ev.data.get("type", "") or letter.document_type + ) letter.letter_type = letter.document_type letter.category = category letter.institution = ev.data.get("agency", "") or letter.institution @@ -364,17 +391,38 @@ async def process_letter_stream(letter_id: UUID, lang: str) -> AsyncIterator[str yield sse_event("consequence", {"text": consequence_text}) elif ev.type == "error": - logger.warning("ReAct agent emitted error: %s", ev.data.get("message")) + logger.warning( + "ReAct agent emitted error: %s", ev.data.get("message") + ) # Don't propagate immediately — try to continue with what we have. await asyncio.sleep(0.05) # Reconstruct AgentAnalysis-like dict from collected events - from ai.schemas import AgentAnalysis, Classification, Deadline, Consequence, RiskScore as TheirRiskScore - cls_data = next((e.data for e in agent_events_collected if e.type == "classification"), {}) - dl_data = next((e.data for e in agent_events_collected if e.type == "deadline"), {}) - rs_data = next((e.data for e in agent_events_collected if e.type == "risk_score"), {"score": 3, "label": "Medium", "reason": ""}) - cq_data = next((e.data for e in agent_events_collected if e.type == "consequence"), {"text": "", "severity": ""}) + from ai.schemas import ( + AgentAnalysis, + Classification, + Consequence, + Deadline, + RiskScore as AiRiskScore, + ) + + def _evt(tp: str, default=None): + """Find first event of `tp` in collected agent events.""" + if default is None: + default = {} + return next( + (e.data for e in agent_events_collected if e.type == tp), + default, + ) + + cls_data = _evt("classification") + dl_data = _evt("deadline") + rs_data = _evt( + "risk_score", + {"score": 3, "label": "Medium", "reason": ""}, + ) + cq_data = _evt("consequence", {"text": "", "severity": ""}) # Fallback: if the agent didn't extract a deadline, scan the OCR # text with our German-date regex. Common for letters where the @@ -396,19 +444,40 @@ async def process_letter_stream(letter_id: UUID, lang: str) -> AsyncIterator[str # Also emit a deadline SSE event so the frontend sees it live yield sse_event( "deadline", - {"date": agent_date_iso, "days_remaining": days_remaining, - "note": "Found via OCR text scan (agent missed it)"}, + { + "date": agent_date_iso, + "days_remaining": days_remaining, + "note": "Found via OCR text scan (agent missed it)", + }, ) + has_agent_date = dl_data.get("date") and not fallback_date + if has_agent_date: + dl_source = "letter" + elif fallback_date: + dl_source = fallback_source + else: + dl_source = "none" + analysis = AgentAnalysis( - classification=Classification(type=cls_data.get("type", "Unknown"), agency=cls_data.get("agency", "Unknown")), + classification=Classification( + type=cls_data.get("type", "Unknown"), + agency=cls_data.get("agency", "Unknown"), + ), deadline=Deadline( date=dl_data.get("date"), days_remaining=dl_data.get("days_remaining"), - source="letter" if (dl_data.get("date") and not fallback_date) else fallback_source if fallback_date else "none", + source=dl_source, + ), + consequence=Consequence( + text=cq_data.get("text", ""), + severity=cq_data.get("severity", ""), + ), + risk_score=AiRiskScore( + score=rs_data.get("score", 3), + label=rs_data.get("label", "Medium"), + reason=rs_data.get("reason", ""), ), - consequence=Consequence(text=cq_data.get("text", ""), severity=cq_data.get("severity", "")), - risk_score=TheirRiskScore(score=rs_data.get("score", 3), label=rs_data.get("label", "Medium"), reason=rs_data.get("reason", "")), ) unpacked = ai_bridge.unpack_agent_analysis(analysis) @@ -453,7 +522,11 @@ async def process_letter_stream(letter_id: UUID, lang: str) -> AsyncIterator[str if not dl_data.get("date"): yield sse_event( "deadline", - {"date": None, "days_remaining": None, "note": "No explicit deadline"}, + { + "date": None, + "days_remaining": None, + "note": "No explicit deadline", + }, ) # ============================================================ @@ -461,6 +534,7 @@ async def process_letter_stream(letter_id: UUID, lang: str) -> AsyncIterator[str # ============================================================ try: from ai.rag.retrieval import retrieve_legal_context + # AI team's new signature (commit 61fd2b5): (letter_type, consequence, top_k) legal_chunks = retrieve_legal_context( letter_type=letter.document_type or "", @@ -468,14 +542,18 @@ async def process_letter_stream(letter_id: UUID, lang: str) -> AsyncIterator[str top_k=5, ) except Exception as e: - logger.warning("Legal retrieval failed: %s — continuing without citations", e) + logger.warning( + "Legal retrieval failed: %s — continuing without citations", + e, + ) legal_chunks = [] # ============================================================ # STAGE 4 — Grounded generation (~5-10s) # ============================================================ agent_result = ai_bridge.synthesize_agent_result(letter, action=action) - agent_result.risk_label = risk_label # use their qualitative label for grounding context + # Use their qualitative label for grounding context + agent_result.risk_label = risk_label generation = await ai_bridge.generate_grounded_response( ocr_text=ocr_text, @@ -483,7 +561,9 @@ async def process_letter_stream(letter_id: UUID, lang: str) -> AsyncIterator[str language=out_lang, legal_chunks=legal_chunks, ) - explanation, response_draft, checklist, citations = ai_bridge.unpack_generation_output(generation) + (explanation, response_draft, checklist, citations) = ( + ai_bridge.unpack_generation_output(generation) + ) # Stream explanation chunks for piece in _chunk_text_for_streaming(explanation, chunk_size=50): @@ -519,6 +599,7 @@ async def process_letter_stream(letter_id: UUID, lang: str) -> AsyncIterator[str # Project to the same PublicLetter shape GET /letters/{id} returns. from app.routers.public import _public_letter + public = _public_letter(db, letter) yield sse_event( diff --git a/backend/app/rag/store.py b/backend/app/rag/store.py index cec1126..195b435 100644 --- a/backend/app/rag/store.py +++ b/backend/app/rag/store.py @@ -36,6 +36,7 @@ def init_chroma() -> None: coll = get_collection() if coll.count() == 0: from app.rag.seed import seed_corpus + seed_corpus(coll) diff --git a/backend/app/routers/actions.py b/backend/app/routers/actions.py index 4bdcb58..c152515 100644 --- a/backend/app/routers/actions.py +++ b/backend/app/routers/actions.py @@ -52,8 +52,15 @@ def list_actions( 422, ErrorCode.VALIDATION_ERROR, message=f"Unknown status: {status!r}.", - details={"errors": [{"field": "status", "message": "must be one of " - + ", ".join(s.value for s in ActionStatus)}]}, + details={ + "errors": [ + { + "field": "status", + "message": "must be one of " + + ", ".join(s.value for s in ActionStatus), + } + ] + }, ) stmt = ( diff --git a/backend/app/routers/deadlines.py b/backend/app/routers/deadlines.py index c4dc27b..914f0dd 100644 --- a/backend/app/routers/deadlines.py +++ b/backend/app/routers/deadlines.py @@ -59,8 +59,15 @@ def list_deadlines( 422, ErrorCode.VALIDATION_ERROR, message=f"Unknown status: {status!r}.", - details={"errors": [{"field": "status", "message": "must be one of " - + ", ".join(s.value for s in ActionStatus)}]}, + details={ + "errors": [ + { + "field": "status", + "message": "must be one of " + + ", ".join(s.value for s in ActionStatus), + } + ] + }, ) stmt = ( select(ActionItem, Letter) diff --git a/backend/app/routers/letters.py b/backend/app/routers/letters.py index 4812bbb..1108884 100644 --- a/backend/app/routers/letters.py +++ b/backend/app/routers/letters.py @@ -17,7 +17,12 @@ User, utcnow, ) -from app.schemas import ErrorResponse, LetterListItem, LetterResponse, LetterUploadResponse +from app.schemas import ( + ErrorResponse, + LetterListItem, + LetterResponse, + LetterUploadResponse, +) from app.services.extraction import ( ExtractionError, extract_from_letter_file, @@ -141,7 +146,8 @@ async def upload_letter( raise KlarHTTPException(415, ErrorCode.LETTER_CORRUPT_FILE) # Allow image/jpeg ↔ image/jpg variants; otherwise demand strict match. if actual_mime != file.content_type and not ( - actual_mime.startswith("image/") and file.content_type.startswith("image/") + actual_mime.startswith("image/") + and file.content_type.startswith("image/") and actual_mime.split("/")[-1] == file.content_type.split("/")[-1] ): raise KlarHTTPException( @@ -267,7 +273,10 @@ async def extract_letter( ), responses={ 401: {"model": ErrorResponse, "description": "Not authenticated."}, - 422: {"model": ErrorResponse, "description": "Unknown status or category value."}, + 422: { + "model": ErrorResponse, + "description": "Unknown status or category value.", + }, }, ) def list_letters( @@ -287,8 +296,15 @@ def list_letters( 422, ErrorCode.VALIDATION_ERROR, message=f"Unknown status: {status!r}.", - details={"errors": [{"field": "status", "message": "must be one of " - + ", ".join(s.value for s in LetterStatus)}]}, + details={ + "errors": [ + { + "field": "status", + "message": "must be one of " + + ", ".join(s.value for s in LetterStatus), + } + ] + }, ) parsed_category: DocumentCategory | None = None if category: @@ -299,8 +315,15 @@ def list_letters( 422, ErrorCode.VALIDATION_ERROR, message=f"Unknown category: {category!r}.", - details={"errors": [{"field": "category", "message": "must be one of " - + ", ".join(c.value for c in DocumentCategory)}]}, + details={ + "errors": [ + { + "field": "category", + "message": "must be one of " + + ", ".join(c.value for c in DocumentCategory), + } + ] + }, ) stmt = select(Letter).where(Letter.user_id == user.id) diff --git a/backend/app/routers/public.py b/backend/app/routers/public.py index 4b03459..ea11771 100644 --- a/backend/app/routers/public.py +++ b/backend/app/routers/public.py @@ -22,7 +22,6 @@ """ import logging -from datetime import datetime from uuid import UUID from fastapi import APIRouter, Depends, File, Query, UploadFile @@ -30,8 +29,6 @@ from sqlmodel import Session, select from app.auth.dependencies import get_current_user - -logger = logging.getLogger("klar.public") from app.database import get_session from app.errors import ErrorCode, KlarHTTPException from app.models import ( @@ -45,6 +42,8 @@ ) from app.schemas import ( ActionUpdate, + ChatRequest, + ChatResponse, CitationItem, ErrorResponse, LetterUploadResponse, @@ -52,9 +51,6 @@ PublicActionListItem, PublicActionUpdateResponse, PublicLetter, - ChatRequest, - ChatResponse, - RagHit, RagQuery, RagResponse, ReplyDraft, @@ -65,13 +61,14 @@ from app.services.extraction import ( ExtractionError, extract_from_letter_file, - generate_reply_text, normalize_lang, ) from app.services.pdf_pages import PdfRenderError from app.services.persistence import persist_extraction from app.services.storage import detect_magic_mime, save_letter_file +logger = logging.getLogger("klar.public") + router = APIRouter(tags=["public"]) ACCEPTED_MIMES = { @@ -87,9 +84,7 @@ # ---------- shape projection: Letter + ActionItems → PublicLetter ---------- -def _public_action( - action: ActionItem, latest_risk: RiskScore | None -) -> PublicAction: +def _public_action(action: ActionItem, latest_risk: RiskScore | None) -> PublicAction: risk_breakdown = None if latest_risk is not None: risk_breakdown = RiskBreakdown( @@ -120,9 +115,7 @@ def _public_action( ) -def _load_risk_by_action( - db: Session, action_ids: list[UUID] -) -> dict[UUID, RiskScore]: +def _load_risk_by_action(db: Session, action_ids: list[UUID]) -> dict[UUID, RiskScore]: """Batch-load the most recent RiskScore per action — O(1) queries.""" if not action_ids: return {} @@ -140,9 +133,7 @@ def _load_risk_by_action( def _public_letter(db: Session, letter: Letter) -> PublicLetter: actions = list( - db.scalars( - select(ActionItem).where(ActionItem.letter_id == letter.id) - ).all() + db.scalars(select(ActionItem).where(ActionItem.letter_id == letter.id)).all() ) risk_by_action = _load_risk_by_action(db, [a.id for a in actions]) # Citations are stored as a list[dict] on the Letter row, but the public @@ -169,10 +160,7 @@ def _public_letter(db: Session, letter: Letter) -> PublicLetter: summary_en=letter.summary, # field renamed for frontend contract ocr_text=letter.ocr_text or None, confidence=letter.confidence, - actions=[ - _public_action(a, risk_by_action.get(a.id)) - for a in actions - ], + actions=[_public_action(a, risk_by_action.get(a.id)) for a in actions], extraction_warnings=letter.extraction_warnings or [], explanation=letter.explanation or "", consequence=letter.consequence or "", @@ -205,7 +193,9 @@ def _public_letter(db: Session, letter: Letter) -> PublicLetter: 413: {"model": ErrorResponse, "description": "`LETTER_TOO_LARGE`."}, 415: { "model": ErrorResponse, - "description": "`LETTER_UNSUPPORTED_TYPE` / `LETTER_CORRUPT_FILE` / `LETTER_MIME_MISMATCH`.", + "description": ( + "`LETTER_UNSUPPORTED_TYPE` / `LETTER_CORRUPT_FILE` / `LETTER_MIME_MISMATCH`." + ), }, 502: {"model": ErrorResponse, "description": "`EXTRACTION_FAILED`."}, }, @@ -283,7 +273,8 @@ async def post_letter( # implementation details to the client. logger.exception( "Qwen extraction failed for letter %s: %s", - letter.id, exc, + letter.id, + exc, ) letter.status = LetterStatus.ERROR db.add(letter) @@ -362,8 +353,15 @@ def list_actions_public( 422, ErrorCode.VALIDATION_ERROR, message=f"Unknown status: {status!r}.", - details={"errors": [{"field": "status", "message": "must be one of " - + ", ".join(s.value for s in ActionStatus)}]}, + details={ + "errors": [ + { + "field": "status", + "message": "must be one of " + + ", ".join(s.value for s in ActionStatus), + } + ] + }, ) stmt = ( @@ -456,7 +454,10 @@ def update_action_public( ), responses={ 401: {"model": ErrorResponse, "description": "Not authenticated."}, - 404: {"model": ErrorResponse, "description": "`LETTER_NOT_FOUND` or `ACTION_NOT_FOUND`."}, + 404: { + "model": ErrorResponse, + "description": "`LETTER_NOT_FOUND` or `ACTION_NOT_FOUND`.", + }, 502: {"model": ErrorResponse, "description": "`LLM_PROVIDER_ERROR`."}, }, ) @@ -483,16 +484,14 @@ async def generate_reply( action = db.get(ActionItem, action_uuid) if action is None or action.letter_id != letter.id: raise KlarHTTPException(404, ErrorCode.ACTION_NOT_FOUND) - action_titles = [action.title] else: actions = list( db.scalars( select(ActionItem).where(ActionItem.letter_id == letter.id) ).all() ) - # Prefer actions explicitly flagged reply_needed; fall back to all titles. - reply_actions = [a for a in actions if a.reply_needed] or actions - action_titles = [a.title for a in reply_actions] + # Prefer actions explicitly flagged reply_needed; fall back to all. + _ = [a for a in actions if a.reply_needed] or actions # 1) Retrieve real legal context from the AI team's law corpus try: @@ -532,12 +531,15 @@ async def generate_reply( except Exception as exc: logger.exception( "Reply generation failed for letter %s: %s", - letter_id, exc, + letter_id, + exc, ) raise KlarHTTPException(502, ErrorCode.LLM_PROVIDER_ERROR) # 4) Unpack + persist all 4 long-form fields - explanation, body_text, checklist, citations = ai_bridge.unpack_generation_output(generation) + explanation, body_text, checklist, citations = ai_bridge.unpack_generation_output( + generation + ) letter.explanation = explanation letter.response_draft = body_text letter.checklist = checklist @@ -604,7 +606,6 @@ async def chat_about_letter( db: Session = Depends(get_session), user: User = Depends(get_current_user), ): - import json import os from uuid import UUID as _UUID @@ -653,19 +654,23 @@ async def chat_about_letter( extra_body={"enable_thinking": False}, ) - response = await model.ainvoke([ - SystemMessage(content=system), - HumanMessage(content=payload.query), - ]) + response = await model.ainvoke( + [ + SystemMessage(content=system), + HumanMessage(content=payload.query), + ] + ) # Coerce raw dicts → CitationItem (same as _public_letter) clean_citations = [] for c in citations: if isinstance(c, dict) and c.get("section"): - clean_citations.append(CitationItem( - section=str(c.get("section", "")), - text=str(c.get("text", "")), - )) + clean_citations.append( + CitationItem( + section=str(c.get("section", "")), + text=str(c.get("text", "")), + ) + ) return ChatResponse(answer=response.content, citations=clean_citations) @@ -704,7 +709,7 @@ async def form_fill( placeholders = [] # From checklist (documents to prepare) - for item in (letter.checklist or []): + for item in letter.checklist or []: placeholders.append(str(item)) # From actions (steps the user needs to take) @@ -712,12 +717,25 @@ async def form_fill( db.scalars(select(ActionItem).where(ActionItem.letter_id == letter.id)).all() ) for action in actions: - for step in (action.steps or []): - if any(kw in step.lower() for kw in [ - "iban", "steuer", "name", "adresse", "address", "nummer", - "number", "unterschrift", "signature", "datum", "date", - "versichertennummer", "aktenzeichen", - ]): + for step in action.steps or []: + if any( + kw in step.lower() + for kw in [ + "iban", + "steuer", + "name", + "adresse", + "address", + "nummer", + "number", + "unterschrift", + "signature", + "datum", + "date", + "versichertennummer", + "aktenzeichen", + ] + ): placeholders.append(step) # Always include standard form fields — these match the PLACEHOLDER_MAP @@ -745,7 +763,9 @@ async def form_fill( placeholders=placeholders[:8], # Cap at 8 to keep prompt manageable ) except Exception as exc: - logger.exception("Form-fill generation failed for letter %s: %s", letter_id, exc) + logger.exception( + "Form-fill generation failed for letter %s: %s", letter_id, exc + ) raise KlarHTTPException(502, ErrorCode.LLM_PROVIDER_ERROR) return Response( @@ -782,7 +802,9 @@ async def form_fill( 413: {"model": ErrorResponse, "description": "`LETTER_TOO_LARGE`."}, 415: { "model": ErrorResponse, - "description": "`LETTER_UNSUPPORTED_TYPE` / `LETTER_CORRUPT_FILE` / `LETTER_MIME_MISMATCH`.", + "description": ( + "`LETTER_UNSUPPORTED_TYPE` / `LETTER_CORRUPT_FILE` / `LETTER_MIME_MISMATCH`." + ), }, }, ) diff --git a/backend/app/routers/rag.py b/backend/app/routers/rag.py index 419907a..b0a346b 100644 --- a/backend/app/routers/rag.py +++ b/backend/app/routers/rag.py @@ -5,7 +5,7 @@ from app.auth.dependencies import get_current_user from app.models import User from app.rag import store -from app.schemas import ErrorResponse, RagHit, RagQuery, RagResponse +from app.schemas import ErrorResponse, RagQuery, RagResponse from app.services import ai_bridge router = APIRouter(prefix="/api/rag", tags=["rag"]) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 04a5558..c52b183 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -14,7 +14,7 @@ """ from datetime import date, datetime -from typing import Any, Literal, Optional +from typing import Literal, Optional from uuid import UUID from pydantic import BaseModel, EmailStr, Field @@ -185,7 +185,9 @@ class PublicAction(BaseModel): description="Full RiskScore breakdown — powers the 'why this risk' view.", ) deadline_confidence: Optional[float] = Field( - default=None, ge=0.0, le=1.0, + default=None, + ge=0.0, + le=1.0, description="0..1 confidence in the deadline value (null when unknown).", ) deadline_source: Optional[DeadlineSource] = Field( @@ -200,7 +202,8 @@ class PublicAction(BaseModel): ) reply_needed: bool = False amount_due_eur: Optional[float] = Field( - default=None, ge=0.0, + default=None, + ge=0.0, description=( "Outstanding amount the user must pay for this action, in EUR. " "Extracted from the OCR text by a regex pattern matcher." @@ -227,7 +230,9 @@ class PublicLetter(BaseModel): description="Verbatim German OCR text from the source. Never localized.", ) confidence: Optional[float] = Field( - default=None, ge=0.0, le=1.0, + default=None, + ge=0.0, + le=1.0, description="0..1 overall extraction confidence. <0.85 triggers a 'get a human' UI prompt.", ) actions: list[PublicAction] = Field(default_factory=list) @@ -283,7 +288,8 @@ class PublicActionListItem(BaseModel): status: ActionStatus reply_needed: bool amount_due_eur: Optional[float] = Field( - default=None, ge=0.0, + default=None, + ge=0.0, description=( "Outstanding EUR amount for this action, mirrored from the same " "field on PublicAction. Included on the list shape so the " @@ -414,7 +420,9 @@ class ErrorResponse(BaseModel): description="Stable machine-readable identifier — see docs/06-api-contract.md." ) message: str = Field(description="Localized, user-facing copy.") - detail: str = Field(description="Alias of `message` — for clients that expect FastAPI's default error shape.") + detail: str = Field( + description="Alias of `message` — for clients that expect FastAPI's default error shape." + ) details: Optional[ErrorDetails] = None diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py index 3818b10..0faa8ea 100644 --- a/backend/app/services/__init__.py +++ b/backend/app/services/__init__.py @@ -9,7 +9,11 @@ stream_explanation, stream_response_draft, ) -from app.services.pdf_pages import iter_data_urls, pdf_to_image_bytes, split_to_image_bytes +from app.services.pdf_pages import ( + iter_data_urls, + pdf_to_image_bytes, + split_to_image_bytes, +) from app.services.persistence import persist_extraction from app.services.risk import compute_risk from app.services.storage import detect_magic_mime, is_pdf, save_letter_file, user_dir diff --git a/backend/app/services/ai_bridge.py b/backend/app/services/ai_bridge.py index 0776499..e1ecd88 100644 --- a/backend/app/services/ai_bridge.py +++ b/backend/app/services/ai_bridge.py @@ -50,102 +50,89 @@ _CATEGORY_PATTERNS: list[tuple[str, DocumentCategory]] = [ # Most specific first - ("residence permit", DocumentCategory.IMMIGRATION), - ("aufenthaltstitel", DocumentCategory.IMMIGRATION), - ("ausländerbehörde", DocumentCategory.IMMIGRATION), - ("visa", DocumentCategory.IMMIGRATION), - ("aufenthalts", DocumentCategory.IMMIGRATION), - ("immigration", DocumentCategory.IMMIGRATION), - - ("health insurance", DocumentCategory.HEALTH_INSURANCE), - ("krankenkasse", DocumentCategory.HEALTH_INSURANCE), - ("krankenversicherung", DocumentCategory.HEALTH_INSURANCE), - ("aok", DocumentCategory.HEALTH_INSURANCE), - ("techniker krankenkasse", DocumentCategory.HEALTH_INSURANCE), - ("barmer", DocumentCategory.HEALTH_INSURANCE), - ("dak-gesundheit", DocumentCategory.HEALTH_INSURANCE), - - ("car insurance", DocumentCategory.OTHER_INSURANCE), - ("haftpflicht", DocumentCategory.OTHER_INSURANCE), - ("hausrat", DocumentCategory.OTHER_INSURANCE), - ("kfz-versicherung", DocumentCategory.OTHER_INSURANCE), - ("liability insurance", DocumentCategory.OTHER_INSURANCE), - - ("tax", DocumentCategory.TAX), - ("finanzamt", DocumentCategory.TAX), - ("steuer", DocumentCategory.TAX), - - ("university", DocumentCategory.EDUCATION), - ("universität", DocumentCategory.EDUCATION), - ("hochschule", DocumentCategory.EDUCATION), - ("immatrikulation", DocumentCategory.EDUCATION), - ("studentenwerk", DocumentCategory.EDUCATION), - ("bafög", DocumentCategory.EDUCATION), - ("rückmeldung", DocumentCategory.EDUCATION), - ("enrollment", DocumentCategory.EDUCATION), - - ("rent", DocumentCategory.HOUSING), - ("vermieter", DocumentCategory.HOUSING), - ("hausverwaltung", DocumentCategory.HOUSING), - ("mieterhöhung", DocumentCategory.HOUSING), - ("nebenkosten", DocumentCategory.HOUSING), - ("landlord", DocumentCategory.HOUSING), - - ("electricity", DocumentCategory.UTILITIES), - ("gas bill", DocumentCategory.UTILITIES), - ("internet", DocumentCategory.UTILITIES), - ("telekom", DocumentCategory.UTILITIES), - ("vodafone", DocumentCategory.UTILITIES), - ("stadtwerke", DocumentCategory.UTILITIES), - ("vattenfall", DocumentCategory.UTILITIES), - ("strom", DocumentCategory.UTILITIES), - - ("employer", DocumentCategory.EMPLOYMENT), - ("arbeitgeber", DocumentCategory.EMPLOYMENT), - ("lohn", DocumentCategory.EMPLOYMENT), - ("gehalt", DocumentCategory.EMPLOYMENT), - ("payroll", DocumentCategory.EMPLOYMENT), - ("arbeitsvertrag", DocumentCategory.EMPLOYMENT), - - ("unemployment", DocumentCategory.GOVERNMENT_BENEFITS), - ("kindergeld", DocumentCategory.GOVERNMENT_BENEFITS), - ("elterngeld", DocumentCategory.GOVERNMENT_BENEFITS), - ("wohngeld", DocumentCategory.GOVERNMENT_BENEFITS), - ("arbeitslosengeld", DocumentCategory.GOVERNMENT_BENEFITS), - ("bürgergeld", DocumentCategory.GOVERNMENT_BENEFITS), - ("jobcenter", DocumentCategory.GOVERNMENT_BENEFITS), - ("familienkasse", DocumentCategory.GOVERNMENT_BENEFITS), - - ("pension", DocumentCategory.PENSION), - ("rentenversicherung", DocumentCategory.PENSION), - ("rente", DocumentCategory.PENSION), - - ("rundfunk", DocumentCategory.BROADCAST_FEE), - ("gez", DocumentCategory.BROADCAST_FEE), - ("beitragsservice", DocumentCategory.BROADCAST_FEE), - ("broadcasting fee", DocumentCategory.BROADCAST_FEE), - - ("bürgeramt", DocumentCategory.CIVIC), - ("einwohnermelde", DocumentCategory.CIVIC), - ("standesamt", DocumentCategory.CIVIC), - ("personalausweis", DocumentCategory.CIVIC), - ("reisepass", DocumentCategory.CIVIC), - ("meldebescheinigung", DocumentCategory.CIVIC), - - ("court", DocumentCategory.LEGAL_DEBT), - ("gericht", DocumentCategory.LEGAL_DEBT), - ("mahnbescheid", DocumentCategory.LEGAL_DEBT), - ("vollstreckung", DocumentCategory.LEGAL_DEBT), - ("inkasso", DocumentCategory.LEGAL_DEBT), - ("bußgeld", DocumentCategory.LEGAL_DEBT), - ("anwalt", DocumentCategory.LEGAL_DEBT), - ("debt collection", DocumentCategory.LEGAL_DEBT), - ("fine notice", DocumentCategory.LEGAL_DEBT), - - ("bank", DocumentCategory.BANKING), - ("sparkasse", DocumentCategory.BANKING), - ("schufa", DocumentCategory.BANKING), - ("kreditkarte", DocumentCategory.BANKING), + ("residence permit", DocumentCategory.IMMIGRATION), + ("aufenthaltstitel", DocumentCategory.IMMIGRATION), + ("ausländerbehörde", DocumentCategory.IMMIGRATION), + ("visa", DocumentCategory.IMMIGRATION), + ("aufenthalts", DocumentCategory.IMMIGRATION), + ("immigration", DocumentCategory.IMMIGRATION), + ("health insurance", DocumentCategory.HEALTH_INSURANCE), + ("krankenkasse", DocumentCategory.HEALTH_INSURANCE), + ("krankenversicherung", DocumentCategory.HEALTH_INSURANCE), + ("aok", DocumentCategory.HEALTH_INSURANCE), + ("techniker krankenkasse", DocumentCategory.HEALTH_INSURANCE), + ("barmer", DocumentCategory.HEALTH_INSURANCE), + ("dak-gesundheit", DocumentCategory.HEALTH_INSURANCE), + ("car insurance", DocumentCategory.OTHER_INSURANCE), + ("haftpflicht", DocumentCategory.OTHER_INSURANCE), + ("hausrat", DocumentCategory.OTHER_INSURANCE), + ("kfz-versicherung", DocumentCategory.OTHER_INSURANCE), + ("liability insurance", DocumentCategory.OTHER_INSURANCE), + ("tax", DocumentCategory.TAX), + ("finanzamt", DocumentCategory.TAX), + ("steuer", DocumentCategory.TAX), + ("university", DocumentCategory.EDUCATION), + ("universität", DocumentCategory.EDUCATION), + ("hochschule", DocumentCategory.EDUCATION), + ("immatrikulation", DocumentCategory.EDUCATION), + ("studentenwerk", DocumentCategory.EDUCATION), + ("bafög", DocumentCategory.EDUCATION), + ("rückmeldung", DocumentCategory.EDUCATION), + ("enrollment", DocumentCategory.EDUCATION), + ("rent", DocumentCategory.HOUSING), + ("vermieter", DocumentCategory.HOUSING), + ("hausverwaltung", DocumentCategory.HOUSING), + ("mieterhöhung", DocumentCategory.HOUSING), + ("nebenkosten", DocumentCategory.HOUSING), + ("landlord", DocumentCategory.HOUSING), + ("electricity", DocumentCategory.UTILITIES), + ("gas bill", DocumentCategory.UTILITIES), + ("internet", DocumentCategory.UTILITIES), + ("telekom", DocumentCategory.UTILITIES), + ("vodafone", DocumentCategory.UTILITIES), + ("stadtwerke", DocumentCategory.UTILITIES), + ("vattenfall", DocumentCategory.UTILITIES), + ("strom", DocumentCategory.UTILITIES), + ("employer", DocumentCategory.EMPLOYMENT), + ("arbeitgeber", DocumentCategory.EMPLOYMENT), + ("lohn", DocumentCategory.EMPLOYMENT), + ("gehalt", DocumentCategory.EMPLOYMENT), + ("payroll", DocumentCategory.EMPLOYMENT), + ("arbeitsvertrag", DocumentCategory.EMPLOYMENT), + ("unemployment", DocumentCategory.GOVERNMENT_BENEFITS), + ("kindergeld", DocumentCategory.GOVERNMENT_BENEFITS), + ("elterngeld", DocumentCategory.GOVERNMENT_BENEFITS), + ("wohngeld", DocumentCategory.GOVERNMENT_BENEFITS), + ("arbeitslosengeld", DocumentCategory.GOVERNMENT_BENEFITS), + ("bürgergeld", DocumentCategory.GOVERNMENT_BENEFITS), + ("jobcenter", DocumentCategory.GOVERNMENT_BENEFITS), + ("familienkasse", DocumentCategory.GOVERNMENT_BENEFITS), + ("pension", DocumentCategory.PENSION), + ("rentenversicherung", DocumentCategory.PENSION), + ("rente", DocumentCategory.PENSION), + ("rundfunk", DocumentCategory.BROADCAST_FEE), + ("gez", DocumentCategory.BROADCAST_FEE), + ("beitragsservice", DocumentCategory.BROADCAST_FEE), + ("broadcasting fee", DocumentCategory.BROADCAST_FEE), + ("bürgeramt", DocumentCategory.CIVIC), + ("einwohnermelde", DocumentCategory.CIVIC), + ("standesamt", DocumentCategory.CIVIC), + ("personalausweis", DocumentCategory.CIVIC), + ("reisepass", DocumentCategory.CIVIC), + ("meldebescheinigung", DocumentCategory.CIVIC), + ("court", DocumentCategory.LEGAL_DEBT), + ("gericht", DocumentCategory.LEGAL_DEBT), + ("mahnbescheid", DocumentCategory.LEGAL_DEBT), + ("vollstreckung", DocumentCategory.LEGAL_DEBT), + ("inkasso", DocumentCategory.LEGAL_DEBT), + ("bußgeld", DocumentCategory.LEGAL_DEBT), + ("anwalt", DocumentCategory.LEGAL_DEBT), + ("debt collection", DocumentCategory.LEGAL_DEBT), + ("fine notice", DocumentCategory.LEGAL_DEBT), + ("bank", DocumentCategory.BANKING), + ("sparkasse", DocumentCategory.BANKING), + ("schufa", DocumentCategory.BANKING), + ("kreditkarte", DocumentCategory.BANKING), ] @@ -160,7 +147,9 @@ def map_classification_to_category(free_text_type: str | None) -> DocumentCatego for pattern, cat in _CATEGORY_PATTERNS: if pattern in needle: return cat - logger.debug("map_classification_to_category: no match for %r → OTHER", free_text_type) + logger.debug( + "map_classification_to_category: no match for %r → OTHER", free_text_type + ) return DocumentCategory.OTHER @@ -169,10 +158,10 @@ def map_classification_to_category(free_text_type: str | None) -> DocumentCatego # ============================================================ _LABEL_TO_SEVERITY: dict[str, Severity] = { - "critical": Severity.CRITICAL, - "high": Severity.HIGH, - "medium": Severity.MEDIUM, - "low": Severity.LOW, + "critical": Severity.CRITICAL, + "high": Severity.HIGH, + "medium": Severity.MEDIUM, + "low": Severity.LOW, "informational": Severity.LOW, } @@ -194,10 +183,10 @@ def map_their_severity_label(label: str | None) -> Severity: # ============================================================ _SOURCE_MAPPING: dict[str, DeadlineSource] = { - "letter": DeadlineSource.EXPLICIT, + "letter": DeadlineSource.EXPLICIT, "calculated": DeadlineSource.INFERRED, - "searched": DeadlineSource.INFERRED, # Tavily web search - "none": DeadlineSource.UNKNOWN, + "searched": DeadlineSource.INFERRED, # Tavily web search + "none": DeadlineSource.UNKNOWN, } @@ -218,6 +207,7 @@ def deadline_was_web_searched(source: str | None) -> bool: # Our risk_score (0-100) → their RiskScore.label (for generator input) # ============================================================ + def risk_label_from_score(score: int | None) -> str: """Map our 0-100 score → their label string (used when synthesizing an `AgentResult` to feed their `generate_response`).""" @@ -237,6 +227,7 @@ def risk_label_from_score(score: int | None) -> str: # Letter + (optional) ActionItem → AgentResult (their dataclass) # ============================================================ + def synthesize_agent_result( letter: Letter, action: ActionItem | None = None, @@ -274,6 +265,7 @@ def synthesize_agent_result( # Their LegalChunk → our RagHit (for /rag/search response) # ============================================================ + def legal_chunk_to_rag_hit(chunk: "LegalChunk") -> RagHit: """Map their `LegalChunk` from `ai.rag.retrieval` → our `RagHit` wire shape. @@ -285,9 +277,9 @@ def legal_chunk_to_rag_hit(chunk: "LegalChunk") -> RagHit: text=chunk.text, score=1.0, metadata={ - "section": chunk.section, - "law": chunk.law, - "title": chunk.title, + "section": chunk.section, + "law": chunk.law, + "title": chunk.title, "citation": chunk.citation, }, ) @@ -297,6 +289,7 @@ def legal_chunk_to_rag_hit(chunk: "LegalChunk") -> RagHit: # Their Citation → JSON dict (stored on Letter.citations column) # ============================================================ + def citation_to_dict(c: "Citation") -> dict[str, Any]: """Persist-shape for the `Letter.citations` JSON column. @@ -305,8 +298,8 @@ def citation_to_dict(c: "Citation") -> dict[str, Any]: """ return { "section": c.section, - "text": c.text, - "score": 1.0, # their structured citation doesn't carry a score + "text": c.text, + "score": 1.0, # their structured citation doesn't carry a score } @@ -318,6 +311,7 @@ def citations_to_dicts(cits: list["Citation"]) -> list[dict[str, Any]]: # Their GenerationOutput → (explanation, response_draft, checklist[], citations[dict]) # ============================================================ + def unpack_generation_output( out: "GenerationOutput", ) -> tuple[str, str, list[str], list[dict[str, Any]]]: @@ -336,6 +330,7 @@ def unpack_generation_output( # Their AgentAnalysis → (category, document_type, severity, deadline_date, ...) # ============================================================ + async def generate_grounded_response( ocr_text: str, agent_result: "AgentResult", @@ -369,8 +364,7 @@ async def generate_grounded_response( # Build the legal-context section from retrieved chunks if legal_chunks: legal_lines = [ - f"### {c.citation} — {c.title}\n{c.text}\n" - for c in legal_chunks + f"### {c.citation} — {c.title}\n{c.text}\n" for c in legal_chunks ] legal_context = "\n".join(legal_lines) else: @@ -403,13 +397,18 @@ async def generate_grounded_response( ), temperature=0, max_tokens=4096, - extra_body={"enable_thinking": False, "response_format": {"type": "json_object"}}, + extra_body={ + "enable_thinking": False, + "response_format": {"type": "json_object"}, + }, ) response = await raw_model.ainvoke([HumanMessage(content=prompt)]) raw_text = response.content if hasattr(response, "content") else str(response) if isinstance(raw_text, list): # Some langchain versions return content as a list of parts - raw_text = "".join(p.get("text", "") if isinstance(p, dict) else str(p) for p in raw_text) + raw_text = "".join( + p.get("text", "") if isinstance(p, dict) else str(p) for p in raw_text + ) payload = json.loads(raw_text) @@ -421,10 +420,12 @@ async def generate_grounded_response( # Bare "§ 81 AufenthG" — wrap as Citation with empty explanation cleaned_citations.append(Citation(section=c, text="")) elif isinstance(c, dict): - cleaned_citations.append(Citation( - section=str(c.get("section") or c.get("§") or "§"), - text=str(c.get("text") or c.get("explanation") or ""), - )) + cleaned_citations.append( + Citation( + section=str(c.get("section") or c.get("§") or "§"), + text=str(c.get("text") or c.get("explanation") or ""), + ) + ) else: logger.debug("Skipping unparseable citation: %r", c) @@ -454,17 +455,19 @@ def unpack_agent_analysis( try: parsed_deadline = _date.fromisoformat(deadline_iso) except ValueError: - logger.debug("Their agent returned non-ISO deadline %r — dropping", deadline_iso) + logger.debug( + "Their agent returned non-ISO deadline %r — dropping", deadline_iso + ) return { - "category": map_classification_to_category(analysis.classification.type), - "document_type": analysis.classification.type or "", - "institution": analysis.classification.agency or "", - "deadline": parsed_deadline, - "deadline_source": map_their_deadline_source(analysis.deadline.source), + "category": map_classification_to_category(analysis.classification.type), + "document_type": analysis.classification.type or "", + "institution": analysis.classification.agency or "", + "deadline": parsed_deadline, + "deadline_source": map_their_deadline_source(analysis.deadline.source), "deadline_was_searched": deadline_was_web_searched(analysis.deadline.source), - "consequence": analysis.consequence.text or "", - "severity": map_their_severity_label(analysis.risk_score.label), - "risk_label": analysis.risk_score.label or "Medium", - "risk_reason": analysis.risk_score.reason or "", + "consequence": analysis.consequence.text or "", + "severity": map_their_severity_label(analysis.risk_score.label), + "risk_label": analysis.risk_score.label or "Medium", + "risk_reason": analysis.risk_score.reason or "", } diff --git a/backend/app/services/amounts.py b/backend/app/services/amounts.py index 0116538..d4f5151 100644 --- a/backend/app/services/amounts.py +++ b/backend/app/services/amounts.py @@ -138,8 +138,7 @@ def primary_outstanding_amount(text: str) -> float | None: head = text[:600].replace("\n", " ") tail = text[-600:].replace("\n", " ") if len(text) > 1200 else "" logger.warning( - "amount extractor: no €/EUR amount found in %d chars of OCR\n" - " HEAD: %r\n TAIL: %r", + "amount extractor: no €/EUR amount found in %d chars of OCR\n HEAD: %r\n TAIL: %r", len(text), head, tail, diff --git a/backend/app/services/extraction.py b/backend/app/services/extraction.py index 1c04fc6..d699d72 100644 --- a/backend/app/services/extraction.py +++ b/backend/app/services/extraction.py @@ -37,6 +37,7 @@ class ExtractionError(Exception): `EXTRACTION_FAILED` error instead of letting a raw exception become a 500. """ + # ISO 639-1 codes — matches the frontend's docs/06-frontend-integration-contract.md. # Qwen3.7-Plus handles all of these out of the box. Quality bar: # en/de: production-grade, the wedge languages @@ -316,7 +317,9 @@ async def extract_from_letter_file( json_parsed = json.loads(stripped) except json.JSONDecodeError: # Sometimes the model wraps the JSON in code fences - fence_match = re.search(r"```(?:json)?\s*(.*?)\s*```", stripped, re.DOTALL) + fence_match = re.search( + r"```(?:json)?\s*(.*?)\s*```", stripped, re.DOTALL + ) if fence_match: try: json_parsed = json.loads(fence_match.group(1).strip()) @@ -408,8 +411,12 @@ async def generate_reply_text( Used by POST /letters/{id}/reply (frontend contract §4.7). """ - actions_text = "\n".join(f"- {t}" for t in action_titles) or "- (keine spezifische Aktion)" - prompt = _response_prompt_from_letter(institution, document_type, actions_text, applicant) + actions_text = ( + "\n".join(f"- {t}" for t in action_titles) or "- (keine spezifische Aktion)" + ) + prompt = _response_prompt_from_letter( + institution, document_type, actions_text, applicant + ) client = _get_client() response = await client.chat.completions.create( @@ -464,7 +471,9 @@ async def _stream_text(prompt: str) -> AsyncIterator[str]: yield delta.content -async def stream_explanation(extracted: ExtractedLetter, lang: str) -> AsyncIterator[str]: +async def stream_explanation( + extracted: ExtractedLetter, lang: str +) -> AsyncIterator[str]: async for piece in _stream_text(_explanation_prompt(extracted, lang)): yield piece @@ -501,7 +510,9 @@ async def generate_checklist(extracted: ExtractedLetter, lang: str) -> list[str] # ---------- backwards-compat alias for the original entrypoint ---------- -async def extract_from_image(image_bytes: bytes, mime: str = "image/jpeg") -> ExtractedLetter: +async def extract_from_image( + image_bytes: bytes, mime: str = "image/jpeg" +) -> ExtractedLetter: """Legacy entrypoint: write bytes to a temp file then call the new API.""" import tempfile import os diff --git a/backend/app/services/pdf_pages.py b/backend/app/services/pdf_pages.py index 8f4ae61..fd0b810 100644 --- a/backend/app/services/pdf_pages.py +++ b/backend/app/services/pdf_pages.py @@ -20,7 +20,9 @@ class PdfRenderError(Exception): """ -def pdf_to_image_bytes(path: str, *, dpi: int = 200, max_pages: int = 12) -> list[bytes]: +def pdf_to_image_bytes( + path: str, *, dpi: int = 200, max_pages: int = 12 +) -> list[bytes]: """Render up to `max_pages` pages of `path` to PNG bytes. Imported lazily so callers that never touch a PDF don't pay the diff --git a/backend/app/services/persistence.py b/backend/app/services/persistence.py index eb28f81..049833b 100644 --- a/backend/app/services/persistence.py +++ b/backend/app/services/persistence.py @@ -34,7 +34,8 @@ def persist_extraction( # Overall confidence = min of available signals. Frontend uses <0.85 to # surface a "get a human" prompt. signals = [ - s for s in (extracted.language_confidence, extracted.category_confidence) + s + for s in (extracted.language_confidence, extracted.category_confidence) if s and s > 0 ] letter.confidence = min(signals) if signals else None @@ -84,6 +85,7 @@ def persist_extraction( # so totals across letters don't double-count. if letter_amount is not None and saved: from app.models import Severity as _Sev + sev_rank = {_Sev.CRITICAL: 4, _Sev.HIGH: 3, _Sev.MEDIUM: 2, _Sev.LOW: 1} amount_attached_to = max(saved, key=lambda x: sev_rank.get(x.severity, 0)) amount_attached_to.amount_due_eur = letter_amount From 57c4769e9bbfb1f2a7f909dc5d3d9f567159ec00 Mon Sep 17 00:00:00 2001 From: aircode610 Date: Thu, 2 Jul 2026 01:55:55 +0200 Subject: [PATCH 3/5] test(deps): assert chromadb CVE-2026-45829 range excluded from requirements Regression test for issue #18. Parses backend/ and ai/ requirements.txt and asserts the declared chromadb specifier rejects the vulnerable >=1.0.0 range (incl. the reported 1.5.9) while still allowing the supported 0.x line. Fails loudly if the <1.0.0 upper bound is ever dropped, preventing silent reintroduction of the vulnerability. Refs #18 --- backend/tests/test_chromadb_cve_pin.py | 81 ++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 backend/tests/test_chromadb_cve_pin.py diff --git a/backend/tests/test_chromadb_cve_pin.py b/backend/tests/test_chromadb_cve_pin.py new file mode 100644 index 0000000..09f6fa9 --- /dev/null +++ b/backend/tests/test_chromadb_cve_pin.py @@ -0,0 +1,81 @@ +"""Regression test for issue #18 / CVE-2026-45829. + +ChromaDB >= 1.0.0 ships a pre-authentication code-injection vulnerability +(CVE-2026-45829). The fix pins ``chromadb<1.0.0`` in every requirements file +so a vulnerable 1.x release can never be resolved. These tests fail loudly if +anyone later loosens that upper bound (e.g. drops the ``<1.0.0`` marker), which +would silently reintroduce the vulnerable range. + +The check is intentionally version-install-agnostic: it parses the declared +dependency specifier rather than the currently-installed package, so it holds +even in environments that still have an old vulnerable build cached. +""" + +from pathlib import Path + +import pytest +from packaging.requirements import Requirement + +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent + +# Requirements files that declare the chromadb dependency. +_REQ_FILES = [ + _REPO_ROOT / "backend" / "requirements.txt", + _REPO_ROOT / "ai" / "requirements.txt", +] + +# Versions inside the CVE-affected range (>= 1.0.0) that MUST be excluded. +# 1.5.9 is the exact version the issue reported as installed. +_VULNERABLE_VERSIONS = ["1.0.0", "1.0.1", "1.5.9", "1.9.0"] + +# A known-good pre-1.0 version the project supports, which MUST stay allowed. +_SAFE_VERSION = "0.6.3" + + +def _chromadb_requirement(req_file: Path) -> Requirement: + assert req_file.exists(), f"missing requirements file: {req_file}" + for raw in req_file.read_text().splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + # Strip inline comments so Requirement() parses cleanly. + line = line.split("#", 1)[0].strip() + if line.lower().startswith("chromadb"): + return Requirement(line) + raise AssertionError(f"no chromadb requirement found in {req_file}") + + +@pytest.mark.parametrize( + "req_file", _REQ_FILES, ids=lambda p: str(p.name and p.parent.name + "/" + p.name) +) +def test_chromadb_declared_in_requirements(req_file): + """Each requirements file still declares chromadb (guards the parse below).""" + req = _chromadb_requirement(req_file) + assert req.name == "chromadb" + # An explicit upper bound must be present — an unbounded spec is vulnerable. + assert str(req.specifier), f"chromadb has no version specifier in {req_file}" + + +@pytest.mark.parametrize( + "req_file", _REQ_FILES, ids=lambda p: p.parent.name + "/" + p.name +) +@pytest.mark.parametrize("version", _VULNERABLE_VERSIONS) +def test_vulnerable_chromadb_versions_excluded(req_file, version): + """CVE-2026-45829 range (chromadb >= 1.0.0) is not resolvable.""" + spec = _chromadb_requirement(req_file).specifier + assert version not in spec, ( + f"{req_file} allows vulnerable chromadb=={version} " + f"(CVE-2026-45829); specifier is {spec!r}" + ) + + +@pytest.mark.parametrize( + "req_file", _REQ_FILES, ids=lambda p: p.parent.name + "/" + p.name +) +def test_safe_chromadb_version_still_allowed(req_file): + """The pin excludes only the 1.x range, not the supported 0.x line.""" + spec = _chromadb_requirement(req_file).specifier + assert _SAFE_VERSION in spec, ( + f"{req_file} unexpectedly rejects supported chromadb=={_SAFE_VERSION}; " + f"specifier is {spec!r}" + ) From 8e881548e68ea044ac0f941329eb3b9668db7d56 Mon Sep 17 00:00:00 2001 From: aircode610 Date: Thu, 2 Jul 2026 02:06:19 +0200 Subject: [PATCH 4/5] ci(tests): install pytest-asyncio so async tests run in CI The backend test suite uses async def tests with pytest's asyncio_mode=auto (pytest.ini). Without the pytest-asyncio plugin, pytest reports 'async def functions are not natively supported' and every async test errors out, failing the tests job (see prior CI run). Add a dedicated tests job that installs pytest-asyncio alongside the project requirements and runs the suite, fixing the failing tests job. --- .github/workflows/lint.yml | 45 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .github/workflows/lint.yml diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..823c361 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,45 @@ +name: Lint Python + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + ruff: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install ruff + run: pip install ruff + + - name: Ruff check + run: ruff check backend/ ai/ + + - name: Ruff format check + run: ruff format --check backend/ ai/ + + tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: pip install -r backend/requirements.txt pytest pytest-asyncio + + - name: Run tests + working-directory: backend + env: + DATABASE_URL: "sqlite:///test.db" + JWT_SECRET: "test-secret-test-secret-test-secret-32" + run: python -m pytest tests/ -v From 1d13d043c0079f4bb9e208907f92d2df7f976f01 Mon Sep 17 00:00:00 2001 From: aircode610 Date: Thu, 2 Jul 2026 08:00:31 +0200 Subject: [PATCH 5/5] fix(form-fill): defensive error handling for malformed/PDF responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The form-fill route blindly indexed into the DashScope response (result["output"]["choices"][0]["message"]["content"][0]["image"]), identical to the issue-#8 OCR crash pattern. A content-filtered, empty, or errored response raised KeyError/IndexError/TypeError surfacing as a raw 500. Additionally, passing a PDF file directly to the image editor base64-encoded raw %PDF bytes as image/jpeg, producing a malformed response. Changes: - ai/form_fill.py: add FormFillError and _parse_image_url to defensively extract the image URL - backend/app/routers/public.py: render PDFs to PNG before calling the image editor; catch FormFillError and PdfRenderError with distinct, user-friendly error codes - backend/tests/: add 15 regression tests covering the form-fill defensive parsing, PDF→PNG rendering, and typed error propagation - test_pipeline.py: handle PDF input by rasterizing pages before OCR Co-Authored-By: Claude Opus 4.6 (1M context) --- ai/form_fill.py | 58 +++++++- backend/app/routers/public.py | 39 +++++- backend/tests/test_scanned_pdf_graceful.py | 156 +++++++++++++++++++++ test_pipeline.py | 27 +++- 4 files changed, 275 insertions(+), 5 deletions(-) diff --git a/ai/form_fill.py b/ai/form_fill.py index f06e363..05d6071 100644 --- a/ai/form_fill.py +++ b/ai/form_fill.py @@ -13,6 +13,58 @@ DASHSCOPE_API_KEY = os.environ.get("DASHSCOPE_API_KEY", "") DASHSCOPE_INTL_URL = "https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation" + +class FormFillError(Exception): + """Raised when the image-edit provider returns no usable image. + + Mirrors ``ai.react_agent.ocr.OcrError``. A content-filtered, empty, or + errored provider response can be missing any of the nested + ``output/choices/message/content/image`` keys. Blindly indexing into it + previously raised ``KeyError``/``IndexError``/``TypeError`` that bubbled up + as a 500 — the same class of bug as issue #8 on the OCR path. The backend + maps this to a friendly ``EXTRACTION_FAILED`` instead of crashing. + """ + + +def _parse_image_url(result: object) -> str: + """Defensively pull the generated image URL out of a DashScope + multimodal-generation response. + + The happy path is + ``result["output"]["choices"][0]["message"]["content"][0]["image"]``, but + a content-filtered / empty / errored response can be missing any of those + keys or return them as the wrong type. Any of those previously raised + ``KeyError``/``IndexError``/``TypeError`` and surfaced as a 500 — here they + become a typed ``FormFillError``. + """ + if not isinstance(result, dict): + raise FormFillError("The form-fill service returned an unexpected response.") + + output = result.get("output") + choices = output.get("choices") if isinstance(output, dict) else None + if not isinstance(choices, list) or not choices: + raise FormFillError( + "Could not generate an annotated form for this document. It may be " + "an unreadable scan — try a clearer photo or PDF." + ) + + message = choices[0].get("message") if isinstance(choices[0], dict) else None + content = message.get("content") if isinstance(message, dict) else None + if not isinstance(content, list) or not content: + raise FormFillError( + "Could not generate an annotated form for this document. It may be " + "an unreadable scan — try a clearer photo or PDF." + ) + + image_url = content[0].get("image") if isinstance(content[0], dict) else None + if not image_url or not str(image_url).strip(): + raise FormFillError( + "The form-fill service did not return an image. Please try again." + ) + + return str(image_url) + + # Standard placeholder patterns for common German form fields PLACEHOLDER_MAP = { "iban": "DE__ ____ ____ ____ ____ __", @@ -121,7 +173,11 @@ async def generate_filled_form( resp.raise_for_status() result = resp.json() - image_url = result["output"]["choices"][0]["message"]["content"][0]["image"] + # Defensive parse: a blank/unreadable scan or a content-filtered response + # can omit any of the nested keys. Never blind-index (that was the issue-#8 + # 500 pattern) — surface a typed FormFillError the caller maps to a + # friendly error instead. + image_url = _parse_image_url(result) async with httpx.AsyncClient(timeout=60.0) as client: img_resp = await client.get(image_url) diff --git a/backend/app/routers/public.py b/backend/app/routers/public.py index ea11771..b483d74 100644 --- a/backend/app/routers/public.py +++ b/backend/app/routers/public.py @@ -22,6 +22,8 @@ """ import logging +import os +import tempfile from uuid import UUID from fastapi import APIRouter, Depends, File, Query, UploadFile @@ -63,7 +65,7 @@ extract_from_letter_file, normalize_lang, ) -from app.services.pdf_pages import PdfRenderError +from app.services.pdf_pages import PdfRenderError, pdf_to_image_bytes from app.services.persistence import persist_extraction from app.services.storage import detect_magic_mime, save_letter_file @@ -755,18 +757,49 @@ async def form_fill( if not any(f.lower() in e for e in existing_lower): placeholders.append(f) + tmp_path: str | None = None try: - from ai.form_fill import generate_filled_form + from ai.form_fill import FormFillError, generate_filled_form + + # The image editor only understands image bytes. Handing it a PDF means + # base64-encoding raw %PDF bytes mislabelled as image/jpeg — the editor + # returns a malformed/empty response and the old blind index crashed + # (the same class of bug as the issue-#8 OCR path). Render the PDF's + # first page to a PNG and annotate that instead. PdfRenderError + # propagates for corrupt / unrenderable PDFs. + source_path = letter.original_file + if source_path.lower().endswith(".pdf"): + page_images = pdf_to_image_bytes(source_path, max_pages=1) + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp: + tmp.write(page_images[0]) + tmp_path = tmp.name + source_path = tmp_path image_bytes = await generate_filled_form( - image_path=letter.original_file, + image_path=source_path, placeholders=placeholders[:8], # Cap at 8 to keep prompt manageable ) + except PdfRenderError as exc: + # Corrupt / password-protected / poppler-missing PDF — distinct, + # actionable message ("try uploading it as an image instead"). + logger.info("Form-fill PDF render failed for letter %s: %s", letter_id, exc) + raise KlarHTTPException(502, ErrorCode.PDF_RENDER_FAILED, message=str(exc)) + except FormFillError as exc: + # Blank/unreadable scan or malformed provider response — surface the + # typed, user-friendly message instead of a raw 500. + logger.info("Form-fill produced no image for letter %s: %s", letter_id, exc) + raise KlarHTTPException(502, ErrorCode.EXTRACTION_FAILED, message=str(exc)) except Exception as exc: logger.exception( "Form-fill generation failed for letter %s: %s", letter_id, exc ) raise KlarHTTPException(502, ErrorCode.LLM_PROVIDER_ERROR) + finally: + if tmp_path: + try: + os.unlink(tmp_path) + except OSError: + pass return Response( content=image_bytes, diff --git a/backend/tests/test_scanned_pdf_graceful.py b/backend/tests/test_scanned_pdf_graceful.py index 4a07122..26879d6 100644 --- a/backend/tests/test_scanned_pdf_graceful.py +++ b/backend/tests/test_scanned_pdf_graceful.py @@ -354,3 +354,159 @@ async def _render_fail(path): assert "event: error" in blob assert ErrorCode.PDF_RENDER_FAILED.value in blob + + +# -------------------------------------------------------------------------- +# 7. ai/form_fill.py — the SAME blind-index crash pattern as the OCR path, +# on the /letters/{id}/form-fill route. A malformed / blank provider +# response must raise a typed FormFillError (never KeyError/IndexError), +# and a PDF letter must be rendered to an image before the image editor +# is called (raw %PDF bytes would produce a malformed response → crash). +# -------------------------------------------------------------------------- + + +def test_parse_image_url_happy_path(): + from ai.form_fill import _parse_image_url + + result = { + "output": { + "choices": [ + {"message": {"content": [{"image": "https://x/y.png"}]}}, + ] + } + } + assert _parse_image_url(result) == "https://x/y.png" + + +@pytest.mark.parametrize( + "result", + [ + "not a dict", + {}, + {"output": {}}, + {"output": {"choices": []}}, + {"output": {"choices": [{}]}}, + {"output": {"choices": [{"message": None}]}}, + {"output": {"choices": [{"message": {"content": None}}]}}, + {"output": {"choices": [{"message": {"content": []}}]}}, + {"output": {"choices": [{"message": {"content": [{}]}}]}}, + {"output": {"choices": [{"message": {"content": [{"image": ""}]}}]}}, + {"output": {"choices": [{"message": {"content": [{"image": " "}]}}]}}, + ], +) +def test_parse_image_url_malformed_raises_formfillerror(result): + from ai.form_fill import FormFillError, _parse_image_url + + with pytest.raises(FormFillError): + _parse_image_url(result) + + +async def _call_form_fill(monkeypatch, *, original_file, generate_stub): + """Drive public.form_fill with the image editor stubbed. + + Returns the KlarHTTPException the handler raises, or the raw image bytes + on success. + """ + from app.models import User + from app.routers import public + + import ai.form_fill as form_fill_mod + + monkeypatch.setattr(form_fill_mod, "generate_filled_form", generate_stub) + + with Session(engine) as db: + user = User(email=f"ff-{uuid4()}@example.com", language="en") + db.add(user) + db.commit() + db.refresh(user) + + letter = Letter( + user_id=user.id, + language="en", + status=LetterStatus.COMPLETED, + original_file=original_file, + ) + db.add(letter) + db.commit() + db.refresh(letter) + + try: + resp = await public.form_fill(letter_id=letter.id, db=db, user=user) + except Exception as exc: # noqa: BLE001 — we assert on the typed error + return exc + return resp + + +async def test_form_fill_malformed_response_returns_extraction_failed(monkeypatch): + """A blank/unreadable scan → FormFillError → typed 502, never a raw 500.""" + from ai.form_fill import FormFillError + from app.errors import KlarHTTPException + + async def _boom(*a, **k): + # Mirrors what _parse_image_url raises on a malformed provider response. + raise FormFillError("Could not generate an annotated form for this document.") + + exc = await _call_form_fill( + monkeypatch, original_file="/tmp/scan.png", generate_stub=_boom + ) + assert isinstance(exc, KlarHTTPException) + assert exc.status_code == 502 + assert exc.code == ErrorCode.EXTRACTION_FAILED + + +async def test_form_fill_pdf_is_rendered_to_image_first(monkeypatch, tmp_path): + """A PDF letter must be rendered to a PNG before the image editor is + called — the editor never receives a `.pdf` path (raw %PDF bytes would + yield a malformed response → the old crash).""" + from app.routers import public + from app.services import pdf_pages + + pdf_file = tmp_path / "letter.pdf" + pdf_file.write_bytes(b"%PDF-1.4 fake") + + monkeypatch.setattr( + pdf_pages, "pdf_to_image_bytes", lambda *a, **k: [b"\x89PNG-fake"] + ) + # The router imports pdf_to_image_bytes by name — patch that binding too. + monkeypatch.setattr(public, "pdf_to_image_bytes", lambda *a, **k: [b"\x89PNG-fake"]) + + seen_paths: list[str] = [] + + async def _capture(image_path, placeholders): + seen_paths.append(image_path) + return b"result-png-bytes" + + result = await _call_form_fill( + monkeypatch, original_file=str(pdf_file), generate_stub=_capture + ) + + assert result == b"result-png-bytes" or getattr(result, "body", None) + assert seen_paths, "generate_filled_form was never called" + assert not seen_paths[0].lower().endswith(".pdf") + assert seen_paths[0].lower().endswith(".png") + + +async def test_form_fill_pdf_render_failure_returns_pdf_render_failed( + monkeypatch, tmp_path +): + """A corrupt PDF → PdfRenderError → typed PDF_RENDER_FAILED, not a 500.""" + from app.errors import KlarHTTPException + from app.routers import public + + pdf_file = tmp_path / "corrupt.pdf" + pdf_file.write_bytes(b"%PDF-broken") + + def _render_boom(*a, **k): + raise PdfRenderError("Could not render this PDF.") + + monkeypatch.setattr(public, "pdf_to_image_bytes", _render_boom) + + async def _unused(*a, **k): + raise AssertionError("generate_filled_form should not be reached") + + exc = await _call_form_fill( + monkeypatch, original_file=str(pdf_file), generate_stub=_unused + ) + assert isinstance(exc, KlarHTTPException) + assert exc.status_code == 502 + assert exc.code == ErrorCode.PDF_RENDER_FAILED diff --git a/test_pipeline.py b/test_pipeline.py index 76a2f78..0163953 100644 --- a/test_pipeline.py +++ b/test_pipeline.py @@ -10,6 +10,7 @@ import json import os import sys +import tempfile import time # --- LangSmith EU tracing setup (must be before any langchain imports) --- @@ -50,9 +51,33 @@ async def main(): print(f" Project: {os.environ.get('LANGCHAIN_PROJECT')}") # --- Step 1: OCR --- + # PDFs must be rasterized to page images before sending to the image OCR + # model — raw %PDF bytes base64-encoded as image/jpeg yield nothing (and + # previously crashed the pipeline; see issue #8). print_header("STEP 1: OCR (Qwen-VL-OCR)") t0 = time.time() - ocr_text = await extract_text_from_image(image_path) + if image_path.lower().endswith(".pdf"): + from backend.app.services.pdf_pages import pdf_to_image_bytes + + page_images = pdf_to_image_bytes(image_path) + print(f" PDF detected — rendered {len(page_images)} page(s) to PNG for OCR") + page_texts: list[str] = [] + for idx, png_bytes in enumerate(page_images): + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp: + tmp.write(png_bytes) + tmp_path = tmp.name + try: + page_text = await extract_text_from_image(tmp_path) + if page_text and page_text.strip(): + page_texts.append(page_text.strip()) + finally: + os.unlink(tmp_path) + ocr_text = "\n\n".join(page_texts) + if not ocr_text.strip(): + print(" ⚠ No readable text found — the PDF may be a blank scan.") + sys.exit(1) + else: + ocr_text = await extract_text_from_image(image_path) ocr_time = time.time() - t0 print(ocr_text) print(f"\n [{len(ocr_text)} chars in {ocr_time:.1f}s]")