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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
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/
8 changes: 4 additions & 4 deletions ai/form_fill.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -70,7 +68,9 @@ 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)
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
90 changes: 51 additions & 39 deletions ai/rag/ingest.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,31 +26,32 @@

# ── 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 ───────────────────────────────────────────────

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 ───────────────────────────────────────────────────────────────


def get_qwen_client() -> OpenAI:
api_key = os.getenv("DASHSCOPE_API_KEY")
if not api_key:
Expand All @@ -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:
Expand All @@ -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,
Expand All @@ -101,6 +102,7 @@ def embed_texts(client: OpenAI, texts: list[str]) -> list[list[float]]:

# ── Chunking ──────────────────────────────────────────────────────────────────


def parse_paragraphs(text: str, law_abbrev: str) -> list[dict]:
"""
Split a law's markdown into one chunk per § paragraph.
Expand All @@ -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 = []
Expand All @@ -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()
Expand All @@ -141,19 +143,22 @@ 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 ──────────────────────────────────────────────────────────────────────


def ingest_all():
print("── Klar RAG Ingestion ──────────────────────────────────────")

Expand Down Expand Up @@ -197,37 +202,44 @@ 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()
print("RAG retrieval is ready.")


if __name__ == "__main__":
ingest_all()
ingest_all()
38 changes: 21 additions & 17 deletions ai/rag/retrieval.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,20 +25,21 @@

# ── 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 ────────────────────────────────────────────────────────────────────


@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 ─────────────────────────────────────────────────────────
Expand Down Expand Up @@ -87,6 +88,7 @@ def _embed_query(query: str) -> list[float]:

# ── Core retrieval ────────────────────────────────────────────────────────────


def retrieve_legal_context(
letter_type: str,
consequence: str,
Expand Down Expand Up @@ -124,14 +126,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

Expand All @@ -156,4 +160,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)
return "\n\n---\n\n".join(parts)
4 changes: 2 additions & 2 deletions ai/rag/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@

@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:
# explanation: {"chunk": str} — streamed token by token
# 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}
# error: {"message": str}
Loading
Loading