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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
@@ -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
75 changes: 67 additions & 8 deletions ai/form_fill.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,59 @@
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"


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 = {
Expand Down Expand Up @@ -70,15 +120,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"
Expand Down Expand Up @@ -118,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)
Expand Down
134 changes: 89 additions & 45 deletions ai/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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}
Expand Down
14 changes: 11 additions & 3 deletions ai/rag/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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],
Expand Down
Loading
Loading