Commit Graph

6 Commits

Author SHA1 Message Date
Dotty Dotter
6f35efe4d7 #47: Volles PDF mit Highlight statt 1-Seiten-Extract
User-Feedback: "Kontext geht verloren wenn nur 1 Seite kommt".

Änderung: render_highlighted_page liefert jetzt das GESAMTE Wahlprogramm-
PDF mit gelber Highlight-Annotation auf der Fundstelle, statt eines
1-Seiten-Auszugs. Der Browser öffnet das vollständige Programm.

Frontend hängt #page=N an die URL → Browser scrollt direkt zur
Fundstelle. found_page wird als X-Found-Page Header mitgeliefert,
falls der Text auf einer anderen Seite als angefordert gefunden wurde
(Pre-#60 halluzinierte Seitennummern).

Return-Typ geändert: (bytes, int) statt bytes — zweiter Wert ist die
1-indexed Seitennummer wo der Treffer tatsächlich liegt.

Tests angepasst: Tuple-Unpacking, Size-Check entfernt (volles PDF ist
größer als 1-Seiten-Extract, der alte Vergleich war obsolet).

Refs: #47
2026-04-10 10:16:00 +02:00
Dotty Dotter
4ec6190416 #47 PDF Zitat-Highlighting via PyMuPDF Single-Page-Render
Klick auf eine Zitat-Quelle im Report öffnet jetzt eine 1-Seiten-PDF-
Variante des Wahlprogramms mit gelb markiertem Snippet, statt nur zum
Page-Anchor zu springen und den Leser selbst suchen zu lassen.

Implementation:

embeddings.render_highlighted_page(programm_id, seite, query)
- Validiert programm_id gegen PROGRAMME (Path-Traversal-Schutz)
- Lädt das volle Wahlprogramm-PDF, extrahiert via insert_pdf nur die
  angeforderte Seite in einen neuen Document → kleinere Response
- search_for(query[:200]) → Bounding-Boxes aller Treffer
- Fallback: 5-Wort-Anker wenn Volltext-Match leer (LLM-Truncation,
  identisch zu find_chunk_for_text/Sub-D-Logik)
- add_highlight_annot mit gelber stroke-Color (1.0, 0.93, 0.0)
- Returns serialisierte PDF-Bytes oder None

embeddings._chunk_pdf_url
- Wenn chunk["text"] vorhanden: emittiert /api/wahlprogramm-cite-URL
  mit pid=, seite=, q=urlencoded(text[:200])
- Sonst: alter statischer /static/referenzen/X.pdf#page=N (Pre-#47
  rückwärts-kompatibel)
- text wird auf 200 Zeichen abgeschnitten, sonst blasen
  500-Zeichen-Snippets jedes Assessment-JSON auf

main.py /api/wahlprogramm-cite Endpoint
- Validiert pid gegen PROGRAMME registry
- seite: 1 ≤ n ≤ 2000
- Response: application/pdf, Cache-Control max-age=86400
- 404 bei unknown pid oder fehlendem PDF, 400 bei seite out of range

Reconstruct-Pipeline (Issue #60 Option B) zieht das automatisch durch:
reconstruct_zitate ruft _chunk_pdf_url(matched_chunk) auf, der jetzt
bevorzugt die Cite-URL emittiert. Keine Änderung an reconstruct_zitate
selbst nötig.

Tests: 194/194 grün (185 + 9 neue):

- TestChunkPdfUrl: 4 Cases (cite vs static, unknown prog, 200-char-truncate)
- TestRenderHighlightedPage: 5 Cases (unknown pid, invalid seite, valid
  render, empty query, query-not-found-falls-back-zu-leerem-Highlight)
- Plus Bridge im Test-Stub: pymupdf-as-fitz Shim falls eine
  third-party "fitz" das Pkg shadowt (kommt auf älteren Dev-Setups vor)

Refs: #47
2026-04-10 01:09:45 +02:00
Dotty Dotter
6ced7ae018 #60 Reopen — Option B: server-side reconstruct of zitat quelle/url
Sub-D Live-Run gegen Prod-DB nach dem db3ada9-Deploy hat einen neuen
Halluzinations-Case gezeigt, den A+C nicht gefangen hat:

  BB 8/673 BSW: text aus bsw-bb-2024 S.27 (verifiziert via Volltext-Suche
  im PDF), aber LLM hat im quelle-Feld "S. 4" angegeben — die Seite des
  Top-2-Chunks im selben Retrieval-Window. Klassischer Cross-Mix zwischen
  Q-IDs.

Strukturelle Diagnose: Das [Qn]-Tag aus A ist nur ein weicher Anker im
Prompt. Das LLM darf Text aus Chunk Qn kopieren und trotzdem die quelle
aus Chunk Qm zusammenbauen. Die ZITATEREGEL kann das nicht verhindern,
solange wir der LLM-Selbstauskunft vertrauen.

Fix (Option B aus dem ursprünglichen Plan):

`embeddings.reconstruct_zitate(data, semantic_quotes)` läuft im
analyzer **nach** json.loads aber **vor** Pydantic-Validation:

1. Flachen die retrievten Chunks aller Parteien zu einer einzigen Liste.
2. Pro Zitat: text via Substring oder 5-Wort-Anker gegen alle Chunks
   matchen (Helpers `find_chunk_for_text` + `_normalize_for_match`,
   identische Logik wie Sub-D Test).
3. Match → quelle/url server-seitig durch _chunk_source_label und
   _chunk_pdf_url des matchenden Chunks ÜBERSCHREIBEN.
4. Kein Match → Zitat verworfen (statt mit erfundener quelle persistiert).

Damit kann der LLM nur noch sauber zitieren oder gar nicht — es gibt
keinen Pfad mehr zu "echter Text, falsche quelle".

Tests:
- TestReconstructZitate (5 cases): BB 8/673 Re-Mapping, Drop bei
  hallucinated, no-op bei leeren chunks, anchor-match-Fallback,
  short-needle und soft-hyphen Edge-Cases
- 185/185 grün (179 + 6 neu)

Refs: #60, #54 (Sub-D)
2026-04-09 22:52:17 +02:00
Dotty Dotter
db3ada9328 #60 Fix A+C: ENUM-basiertes Zitieren + top_k 2→5
Strukturelle Lösung für die LLM-Halluzinations-Cases aus #60:

A — ENUM-Anker
- format_quotes_for_prompt nummeriert jeden retrievten Chunk als [Q1], [Q2], …
- Neue ZITATEREGEL im Prompt erzwingt vier Bedingungen:
  1. Jedes Zitat MUSS auf genau einen [Qn]-Chunk verweisen
  2. Der text-String MUSS eine wörtliche, zusammenhängende Passage von
     min. 5 Wörtern aus genau diesem Chunk sein
  3. Die quelle MUSS exakt das Source-Label des gewählten Chunks sein
  4. Wenn kein Chunk passt: leeres zitate-Array — lieber 0 als erfunden
- analyzer.py:get_system_prompt: Wichtige-Regeln-Block zieht den selben
  Mechanismus nach, damit das LLM den [Qn]-Anker auch im System-Prompt
  sieht und nicht nur im User-Prompt.

C — Recall-Boost
- analyzer.py:run_analysis: top_k_per_partei 2 → 5. In den drei Cases
  aus #60 lagen die "richtigen" Seiten (S.36, S.37) bisher außerhalb
  des Top-3-Windows; mit Top-5 erhöht sich die Wahrscheinlichkeit, dass
  sie überhaupt im Kontext landen.

Hintergrund — die Halluzinationen waren KEIN Embedding-Bug:
Die retrievten Chunks für Case 1 enthielten S.58 (richtige Seite, falscher
Snippet) — das LLM hat den Snippet aus seinem Trainingswissen über
GRÜNE-Wahlprogramme rekonstruiert statt aus dem retrievten Chunk-Text zu
zitieren. Cases 2/3 hatten die zitierten Seiten gar nicht im Top-3-Window —
das LLM hat sowohl Seite als auch Snippet halluziniert. ENUM-Anker
verhindert beides strukturell, weil ein nicht-existenter [Qn] sofort
als Cheating sichtbar wäre.

Tests:
- test_chunks_get_enum_ids
- test_zitateregel_mentions_enum_anchor
- 179/179 grün

Refs: #60, #54 (Sub-D), #50 (Umbrella E2E)
2026-04-09 22:21:39 +02:00
Dotty Dotter
ed64399dbb Fix #60: NameError in get_relevant_quotes_for_antrag (Phase B refactor leftover)
Root cause: der #55-Refactor (eb045d0) hat in get_relevant_quotes_for_antrag
``partei_upper`` zu ``partei_lookup`` umbenannt — aber die Dict-Write-Zeile
``results[partei_upper] = ...`` wurde übersehen. Bei jedem Aufruf knallt seither
ein NameError, der in analyzer.py vom breiten ``except Exception`` verschluckt
und still auf die Keyword-Fallback-Suche umgeleitet wird. Konsequenz: 100% der
Assessments seit eb045d0 (inkl. autonomer Roadmap-Run #59) liefen ohne
Embedding-Retrieval — daher die LLM-Halluzinationen aus #60.

Fix:
- embeddings.py:528: partei_upper → partei_lookup
- analyzer.py:249: NameError/AttributeError/TypeError/KeyError nicht mehr
  schlucken. Programmierfehler im Embedding-Pfad sollen hart fehlschlagen,
  damit die nächste Refactor-Regression nicht wieder 24h still degradiert
  läuft. Echte Network-/API-Exceptions fallen weiterhin auf den
  Keyword-Pfad zurück.
- tests/test_embeddings.py: Regression-Test, der get_relevant_quotes_for_antrag
  mit gemockten chunks aufruft und sicherstellt, dass die Funktion nicht
  crasht und ein populiertes Result liefert. Hätte den Bug bei eb045d0
  sofort gefangen.

Refs: #60, #55, #59
2026-04-09 21:57:56 +02:00
Dotty Dotter
f98e64c734 Add pytest suite + fix two regex bugs uncovered by it (#46)
Erste Tests für die Codebase. 77 Tests, 0.08s Laufzeit, decken die
drei Bug-Klassen aus der April-2026-Adapter-Session ab plus haben
schon zwei weitere Bugs in Production-Code aufgedeckt.

## Setup

- requirements-dev.txt mit pytest + pytest-asyncio
- pytest.ini mit asyncio_mode=auto
- tests/conftest.py stubbt fitz/bs4/openai/pydantic_settings, damit
  die Suite ohne den vollen prod-requirements-Satz läuft (pure unit
  tests, kein PDF-Parsing, kein HTTP)

## Tests

- tests/test_parlamente.py (33 Tests)
  * PortalaAdapter._parse_hit_list_cards: doctype/doctype_full
    NameError-Regression aus 1cb030a, plus Title/Drucksache/Fraktion-
    /Datum/PDF-Extraktion gegen ein BE-Card-Fixture
  * PortalaAdapter._parse_hit_list_dump: gegen ein LSA-Perl-Dump-
    Fixture inkl. Hex-Escape-Decoding (\x{fc} → ü)
  * PortalaAdapter._parse_hit_list_html: Auto-Detection zwischen
    Card- und Dump-Format
  * PortalaAdapter._normalize_fraktion: kanonische Fraktion-Codes
    inkl. F.D.P.-mit-Punkten, BÜNDNIS 90, DIE LINKE, BSW
  * ParLDokAdapter._hit_to_drucksache: JSON-Hit → Drucksache
    Mapping inkl. /navpanes-Stripping, MdL-mit-Partei-in-Klammern,
    Landesregierung-Detection
  * ParLDokAdapter._fulltext_id: bundle.js-mirroring (deferred,
    aber dokumentiert)
  * ADAPTERS-Registry-Sanity

- tests/test_embeddings.py (11 Tests)
  * _chunk_source_label: Programm-Name + Seite (Halluzinations-
    Bug-Regression aus 1b5fd96)
  * format_quotes_for_prompt: jeder Chunk muss Programm-Name
    enthalten, strict-citation-Hinweis muss im Output sein,
    keine NRW-Halluzinationen für MV/BE-Chunk-Sets

- tests/test_wahlprogramme.py (14 Tests)
  * Registry-Struktur (jahr int, seiten int, .pdf-Endung)
  * File-Existenz: jede registrierte PDF muss in
    static/referenzen/ liegen — würde Tippfehler in den 22
    indexierten Programmen sofort fangen
  * embeddings.PROGRAMME-Konsistenz-Cross-Check

- tests/test_bundeslaender.py (15 Tests)
  * Sanity über 16-State-Registry
  * #48-Klassifikations-Regression: TH=ParlDok, HB=StarWeb,
    SN=Eigensystem
  * Wahltermine plausibel (zwischen 2026 und 2035)

- tests/test_analyzer.py (4 Tests)
  * Markdown-Codeblock-Stripping aus dem JSON-Retry-Loop

## Bug-Funde während der Test-Schreibphase

Zwei Production-Bugs in den _normalize_fraktion-Helfern wurden
durch die neuen Tests sofort aufgedeckt und im selben Commit gefixt:

1. PortalaAdapter._normalize_fraktion matched "F.D.P." (mit Punkten,
   wie historische SH/HB-Drucksachen) nicht — Regex \bFDP\b ist zu
   strikt. Fix: \bF\.?\s*D\.?\s*P\.?\b analog zu ParLDokAdapter.

2. ParLDokAdapter._normalize_fraktion (auch PortalaAdapter) matched
   "Ministerium der Finanzen" nicht als Landesregierung, weil
   \bMINISTER\b die Wortgrenze auch nach MINISTER verlangt — bei
   MINISTERIUM steht aber IUM danach, keine Wortgrenze. Fix:
   \bMINISTER ohne abschließendes \b.

Beide Bugs hätten Fraktion-Felder bei Drucksachen der Bremischen
Bürgerschaft (FDP-Listen) und bei Landesregierungs-Drucksachen
in MV/LSA fälschlich leer gelassen — exakt der "fraktionen=[]"-
Befund aus dem MV-Smoke-Test in #4.

Phase 0 aus Roadmap-Issue #49.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 23:26:06 +02:00