From ae09094a431d53d9d1ab833bd3743b8eaba72204 Mon Sep 17 00:00:00 2001 From: TillQuandel Date: Fri, 17 Jul 2026 12:38:35 +0200 Subject: [PATCH 1/2] feat(eval): generische CID-Korruptions-Warnung ohne Messverhaltens-Aenderung (#306) PR #298 fixt exakt zwei Codepoints eines defekten Fonts im Jockisch-PDF (U+0231/U+022C). Jedes andere PDF mit anderem CID-Glyph-Mapping erzeugt dasselbe Fehlerbild (quellentreue Notes faelschlich als Halluzination) und blieb bisher unentdeckt. Neu: generative/eval_text_quality.py::detect_cid_suspect() prueft den per PyMuPDF extrahierten Volltext auf ein verdaechtiges Nicht-ASCII- Haeufigkeitsprofil (ein einzelner Codepoint ausserhalb ASCII/Latin-1/ Latin-Extended-A, abzueglich einer Whitelist gaengiger typografischer Satzzeichen, >= 0,5% aller Zeichen). Verdrahtet additiv in eval_quality_v4.py: neues Feld result["pdf_text_suspect_cid"] + Flag "cid_font_suspect" in result["quality_flags"], stderr-Warnung beim Oeffnen der PDF. Kein Rueckmapping, keine Label-/Raten-Aenderung -> kein EVAL_VERSION-Bump. test_audit_double_call.py-Snapshot um das neue additive Feld ergaenzt. docs/evaluation.md um Abschnitt zur neuen Warnung erweitert. --- docs/evaluation.md | 6 +- generative/eval_quality_v4.py | 36 +++ generative/eval_text_quality.py | 118 ++++++++++ generative/tests/test_audit_double_call.py | 3 + .../tests/test_cid_suspect_detection.py | 219 ++++++++++++++++++ 5 files changed, 380 insertions(+), 2 deletions(-) create mode 100644 generative/eval_text_quality.py create mode 100644 generative/tests/test_cid_suspect_detection.py diff --git a/docs/evaluation.md b/docs/evaluation.md index 2ab0b45..bb52a27 100644 --- a/docs/evaluation.md +++ b/docs/evaluation.md @@ -59,6 +59,8 @@ Seitenzahlen laufen im **PageLabel-Namespace** (`anchor_page_numbers`, `generati Beide Wege laufen durch **dieselbe** `_normalize()` (`eval_common.py:46-77`): Silbentrennung am Zeilenende, Whitespace-Kollaps, Anführungszeichen-Normalisierung — und seit #298 (EVAL_VERSION 4.3) ein **CID-Font-Rückmapping** (`eval_common.py:55-70`): manche eingebettete Schriftarten mappen ihr Leerzeichen-Glyph auf U+0231 („ȱ") bzw. ihren Trennstrich auf U+022C („Ȭ") statt auf ASCII — am Original-PDF „Jockisch – 2010 – Das Technologieakzeptanzmodell.pdf" per PyMuPDF nachgemessen: U+0231 5173×, U+022C 240×. Ohne Rückmapping liest der Judge nur noch zusammengeschriebene Wörter und scheitert an einer Quelle, die die Notiz korrekt zitiert hat (0 % Coverage, 43–57 % „Halluzination" — reines Artefakt, PR #298). +**CID-Verdachts-Warnung (#306, generisch, additiv):** `_normalize()` fixt nur die zwei bekannten Jockisch-Codepoints — jedes andere PDF mit einem anderen CID-Fehlmapping bleibt unrepariert und würde dasselbe Fehlerbild erzeugen. `generative/eval_text_quality.py::detect_cid_suspect()` scannt den per `_extract_page_text` gewonnenen `full_text` (nach `_normalize`, also nach dem #298-Fix) auf ein verdächtiges Nicht-ASCII-Häufigkeitsprofil: dominiert ein **einzelner** Codepoint außerhalb von ASCII/Latin-1 Supplement/Latin Extended-A (abzüglich einer Whitelist gängiger typografischer Satzzeichen wie Gedankenstrich/„smarte" Anführungszeichen) mit ≥ 0,5 % aller Zeichen, gilt das als CID-Verdacht — genau das Muster, das U+0231 im Jockisch-PDF zeigte. Bei Treffer: additives Feld `result["pdf_text_suspect_cid"]` (`{codepoint, count, ratio, total_chars}`, sonst `None`) plus `"cid_font_suspect"` in `result["quality_flags"]`, berechnet einmal pro PDF in `_pdf_artifacts()` (`eval_quality_v4.py`) und dort auch prominent auf stderr geloggt. **Kein** Rückmapping, **keine** Label-/Raten-Änderung — reine Diagnostik, deshalb kein EVAL_VERSION-Bump nötig. + ### Claim-Extraktion aus der Note `extract_claims()` (`eval_common.py:259-280`): liest den Note-Body ohne Frontmatter/Überschriften-Marker (`_read_note_body`, `eval_common.py:122-136`), entfernt Blockquote-Callouts und Fußnoten-Marker, splittet in Sätze (`_split_sentences`, `eval_common.py:195-198`, mit Abkürzungs-/Seitenzahl-Schutz `_protect_sentence_periods`, `eval_common.py:184-192`) und filtert Nicht-Claims (`filter_meta_claims`, `eval_common.py:236-256`: reine Wiki-Link-Pointer, Merge-Stub-Marker, reine Zitat-Fragmente). @@ -194,7 +196,7 @@ Empirisch bestätigt (`generative/.cache/quality_history.jsonl`, Stand 2026-07-1 |---|---|---| | Selektions-Bias | Nur automatisch ins Vault akzeptierte Notes werden inline evaluiert (Inbox/Merge nie); zusätzlich Cap 10 Notes/Lauf | kein Issue, s. u. | | Rekombinations-Masking | `token_set_ratio` gegen Gesamt-Volltext lässt erfundene, aber vokabular-treue Kurzzitate durch | #307 | -| CID-Erkennung nicht generisch | Nur die zwei Jockisch-Codepoints sind gefixt; jedes andere CID-Font-Muster bleibt unentdeckt | #306 | +| CID-Erkennung nicht generisch | Nur die zwei Jockisch-Codepoints sind automatisch gefixt; andere CID-Font-Muster werden seit #306 immerhin generisch **geflaggt** (kein Auto-Fix) | #306 | | Planner-Chunk-Zuordnung | Root Cause für leere Extractor-Outputs bei Fehlzuordnung — nicht direkt eine Eval-Metrik, aber Ursache fehlender/verzerrter Notes im Eval-Korpus | #308 | | Coverage/Hall-Sentinel im (Legacy-)Slope-Chart | `-1.0` fließt ungefiltert als „-100 %" in eine Median-Berechnung ein | kein Issue, s. u. | | Kleine n / Cluster-Struktur | Pro-PDF-Gruppen sind klein, Re-Evals erzeugen Pseudoreplikation | #293 | @@ -204,7 +206,7 @@ Empirisch bestätigt (`generative/.cache/quality_history.jsonl`, Stand 2026-07-1 **#307 – Rekombinations-Masking (token_set_ratio):** empirisch belegt in der adversarialen Kontrolle zu PR #298: das Zitat „Akzeptanz und Nutzung der Technologie" steht **nicht wörtlich** im PDF (`verbatim present: False`), aber alle Tokens kommen irgendwo im Volltext vor → `token_set_ratio` = 1.0 → `VERIFIED`. Gilt für **jedes** PDF, nicht durch #298 verursacht. -**#306 – CID-Erkennung nicht generisch:** der Fix in `_normalize` behandelt exakt zwei Codepoints (U+0231/U+022C) eines Font-Typs. Ein PDF mit anderem CID-Fehlmapping erzeugt dasselbe Fehlerbild (quellentreue Notes fälschlich als Halluzination) und bleibt unentdeckt, bis jemand manuell ein neues Replace-Paar einbaut. +**#306 – CID-Erkennung nicht generisch:** der Fix in `_normalize` behandelt exakt zwei Codepoints (U+0231/U+022C) eines Font-Typs. Ein PDF mit anderem CID-Fehlmapping erzeugt dasselbe Fehlerbild (quellentreue Notes fälschlich als Halluzination) und wird nicht automatisch repariert, bis jemand manuell ein neues Replace-Paar einbaut. Seit #306 (Diagnostik, `generative/eval_text_quality.py`, s. Kapitel 1) wird dieses Muster immerhin **erkannt und geflaggt** (`pdf_text_suspect_cid` + `cid_font_suspect`-Quality-Flag, stderr-Warnung) — bewusst kein automatisches Rückmapping (Blast Radius: falsches Mapping würde stillschweigend Text verändern), keine Label-/Raten-Änderung. **#308 – Planner-Chunk-Zuordnung:** wenn der Planner einem Konzept einen Chunk ohne die Belegstelle zuweist, bricht die Extraktion (`extractor-empty`) — betrifft nicht direkt die Eval-Formel, aber die Zusammensetzung des evaluierten Korpus (fehlende oder ausgedünnte Notes zu Kernkonzepten). diff --git a/generative/eval_quality_v4.py b/generative/eval_quality_v4.py index be1962a..bf183df 100644 --- a/generative/eval_quality_v4.py +++ b/generative/eval_quality_v4.py @@ -53,6 +53,11 @@ extract_claims, wilson_ci, ) +from generative.eval_text_quality import ( + QUALITY_FLAG_CID_SUSPECT, + CidSuspectResult, + detect_cid_suspect, +) from generative.embeddings import _model, cosine from generative.pipeline.pdf_chunker import anchor_page_numbers @@ -73,6 +78,11 @@ # dieselbe Methodik-Aenderungs-Klasse wie 4.1→4.2. Ohne Bump wuerde der content- # adressierte Cache fuer unveraenderte Notes das alte Artefakt-Ergebnis liefern # (stille Staleness). Bei nicht betroffenen PDFs identisches Verhalten. +# Kein Bump fuer #306 (generische CID-Verdachts-Warnung, siehe eval_text_quality.py): +# das Flag ist rein additiv (neues Feld `pdf_text_suspect_cid` + optionaler +# quality_flags-Eintrag "cid_font_suspect"), aendert weder Chunk-Texte/Embeddings/ +# Evidence-Corpus noch irgendeine Rate/ein Label. Bit-identisches Messverhalten +# bei identischem Input -> kein Cache-Invalidierungsgrund. EVAL_VERSION = "4.3" # Eval-Judge-Cache ist content-adressiert und vom --fresh-run-Run-Salt entkoppelt: # eine inhaltlich unveraenderte Note wird nicht erneut evaluiert, auch wenn die uebrige @@ -235,6 +245,7 @@ class _PdfArtifacts: chunks: list[Chunk] full_text: str sentences: list[str] + cid_suspect: CidSuspectResult | None = None _PDF_ARTIFACTS_CACHE: dict[tuple[str, int, int], _PdfArtifacts] = {} @@ -263,10 +274,25 @@ def _pdf_artifacts(pdf_path: Path) -> _PdfArtifacts: page_numbers = anchor_page_numbers(pdf_path, len(doc)) sentence_pairs = _pdf_sentences(doc, page_numbers) full_text = " ".join(_extract_page_text(doc, page) for page in range(1, len(doc) + 1)) + # #306: generische CID-Font-/Korruptions-Erkennung (Nicht-ASCII-Haeufigkeitsprofil + # des extrahierten Volltexts). Reine Diagnostik -- verifiziert/aendert nichts an + # full_text/chunks/sentences, siehe eval_text_quality.py fuer die Heuristik. + cid_suspect = detect_cid_suspect(full_text) + if cid_suspect is not None: + print( + f" [WARNUNG] CID-Font-Verdacht in {pdf_path.name}: Codepoint " + f"{cid_suspect.codepoint} dominiert Nicht-ASCII-Zeichen " + f"({cid_suspect.count}x, {cid_suspect.ratio:.1%} aller Zeichen) -- " + f"moeglicherweise ein defekter eingebetteter Font (Muster wie #278). " + f"Halluzinationsrate/Coverage dieser PDF ggf. mit Vorsicht interpretieren; " + f"siehe result['pdf_text_suspect_cid'].", + file=sys.stderr, + ) artifacts = _PdfArtifacts( chunks=_chunks_from_sentences(sentence_pairs), full_text=full_text, sentences=[sentence for sentence, _ in sentence_pairs], + cid_suspect=cid_suspect, ) _PDF_ARTIFACTS_CACHE[key] = artifacts return artifacts @@ -939,6 +965,7 @@ def _empty_result( "pdf_chunks_total": 0, "claim_scores": [], "quality_flags": [], + "pdf_text_suspect_cid": None, "llm_usage": {"calls": 0, "input_tokens": 0, "output_tokens": 0, "cached_calls": 0}, } @@ -954,6 +981,7 @@ def _aggregate( llm_meta: dict[str, Any], *, content_hash: str | None = None, + cid_suspect: CidSuspectResult | None = None, ) -> dict: total = len(claim_scores) counts = Counter(score["label"] for score in claim_scores) @@ -978,6 +1006,10 @@ def _aggregate( ) if parse_error_count: quality_flags = sorted(set(quality_flags) | {"parse_errors_present"}) + # #306: reine Diagnostik-Warnung -- KEIN Einfluss auf error/rate_valid/Labels + # unten, nur ein zusaetzlicher quality_flags-Eintrag + das additive Detail-Feld. + if cid_suspect is not None: + quality_flags = sorted(set(quality_flags) | {QUALITY_FLAG_CID_SUSPECT}) error = None # CODEX-PATTERN: Einzelne Parse-Errors entwerten nicht den ganzen Lauf; erst ab @@ -1036,6 +1068,9 @@ def _aggregate( "pdf_chunks_total": len(chunks), "claim_scores": claim_scores, "quality_flags": quality_flags, + # #306: additives Diagnostik-Feld, None wenn kein CID-Verdacht. Kein Bezug zu + # obigen Raten/Labels -- siehe Kommentar am cid_suspect-Parameter oben. + "pdf_text_suspect_cid": cid_suspect.as_dict() if cid_suspect is not None else None, "llm_usage": { "calls": llm_meta.get("calls", 0), "input_tokens": llm_meta.get("input_tokens", 0), @@ -1152,6 +1187,7 @@ def eval_note( claim_scores, llm_meta, content_hash=content_hash, + cid_suspect=artifacts.cid_suspect, ) diff --git a/generative/eval_text_quality.py b/generative/eval_text_quality.py new file mode 100644 index 0000000..3482375 --- /dev/null +++ b/generative/eval_text_quality.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Generische CID-Font-/Korruptions-Erkennung auf Eval-Seite (#306). + +Hintergrund: #278 fixt exakt zwei Codepoints eines defekten eingebetteten +Fonts im Jockisch (2010)-PDF (U+0231 -> Leerzeichen, 5173x; U+022C -> +Trennstrich, 240x; per PyMuPDF am Original nachgemessen, siehe +`eval_common._normalize`). Jedes andere PDF mit einem anderen CID-Glyph- +Mapping erzeugt dasselbe Fehlerbild (Cosine-Matching + Zitat-Fuzzy- +Verifikation scheitern an korrumpiertem Quelltext -> quellentreue Notes +werden faelschlich als Halluzination markiert) und bleibt ungefixt, bis +jemand manuell eine neue Replace-Zeile ergaenzt. + +Dieses Modul reklassifiziert NICHTS und mapt NICHTS zurueck (das bleibt +absichtlich Handarbeit pro PDF wie in #278) -- es liefert nur eine +Heuristik, die ein verdaechtiges Nicht-ASCII-Haeufigkeitsprofil erkennt und +als Warnung meldet: ein einzelner Codepoint ausserhalb des in deutschen/ +englischen Fachtexten erwartbaren Bereichs (ASCII + Latin-1 Supplement + +Latin Extended-A + gaengige typografische Satzzeichen), der einen +Anteils-Schwellwert an der Gesamtzeichenzahl ueberschreitet. Genau dieses +Muster zeigte das Jockisch-PDF (ein Glyph ersetzt praktisch jedes +Leerzeichen). + +Kein Bezug zur Halluzinationsrate/den Judge-Labels -- reines Diagnostik- +Signal fuer den Eval-Betrieb (#306 harte Vorgabe: Flag/Warnung, kein +generisches Rueckmapping, keine Label-/Raten-Aenderung). +""" + +from __future__ import annotations + +from collections import Counter +from dataclasses import dataclass + +# Erwartbarer "gesunder" Codepoint-Bereich fuer per PyMuPDF extrahierten +# deutschen/englischen Fachtext: +# - ASCII (0x00-0x7F) +# - Latin-1 Supplement (0x80-0xFF): deckt Umlaute/ß (ä ö ü Ä Ö Ü ß), +# Anfuehrungszeichen-Reste, Grad-/Paragraph-/Copyright-Zeichen ab. +# - Latin Extended-A (0x100-0x17F): weitere lateinische Diakritika +# (franz./poln./tschech. Namen in Literaturangaben etc.). +# Das Jockisch-Artefakt (U+0231, U+022C) liegt bewusst AUSSERHALB davon in +# Latin Extended-B (0x180-0x24F) -- der Bereich trifft die Erkennung exakt. +_EXPECTED_MAX_CODEPOINT = 0x17F + +# Typografische Satzzeichen, die PyMuPDF-Extraktion regelmaessig produziert +# und die trotz Codepoint > 0x17F normaler, unverdaechtiger Text sind +# (Gedankenstriche, "smarte" Anfuehrungszeichen, Ellipse, Aufzaehlungspunkt, +# geschuetzter Bindestrich). eval_common._normalize wandelt nur eine +# Teilmenge der Anfuehrungszeichen nach ASCII um -- der Rest darf hier nicht +# als CID-Artefakt zaehlen, sonst waere die Heuristik bei dashreichem +# akademischem Text false-positive-anfaellig. +_TYPOGRAPHIC_PUNCTUATION_WHITELIST = frozenset( + "‐‑‒–—―" # Bindestriche/Gedankenstriche + "‘’‚‛“”„‟" # Anfuehrungszeichen-Varianten + "•" # Aufzaehlungspunkt + "…" # Ellipse + "′″" # Minute/Sekunde bzw. Prime (Formeln) +) + +DEFAULT_RATIO_THRESHOLD = 0.005 # 0,5 % der Zeichen (Issue-Vorschlag #306) + +QUALITY_FLAG_CID_SUSPECT = "cid_font_suspect" + + +@dataclass(frozen=True) +class CidSuspectResult: + """Diagnostik-Ergebnis eines erkannten CID-Verdachts.""" + + codepoint: str # z.B. "U+0231" + char: str + count: int + ratio: float + total_chars: int + + def as_dict(self) -> dict: + return { + "codepoint": self.codepoint, + "count": self.count, + "ratio": self.ratio, + "total_chars": self.total_chars, + } + + +def detect_cid_suspect( + text: str, + *, + ratio_threshold: float = DEFAULT_RATIO_THRESHOLD, +) -> CidSuspectResult | None: + """Prueft `text` auf ein CID-Font-Korruptions-Haeufigkeitsprofil. + + Zaehlt Codepoints ausserhalb von ASCII/Latin-1/Latin-Extended-A (abzueglich + der typografischen Satzzeichen-Whitelist). Wenn ein EINZELNER Codepoint + daraus `ratio_threshold` der Gesamtzeichenzahl ueberschreitet, gilt das als + CID-Verdacht (dominantes Ersatz-Glyph fuer Leerzeichen/Trennstrich o.ae. -- + das Muster, das #278 am Jockisch-PDF fuer U+0231/U+022C belegt hat). + + Reine Diagnostik: aendert `text` nicht, trifft keine Label-Entscheidung. + Gibt `None` zurueck, wenn kein Codepoint den Schwellwert erreicht (oder + `text` leer ist). + """ + if not text: + return None + total = len(text) + counts = Counter( + ch for ch in text if ord(ch) > _EXPECTED_MAX_CODEPOINT and ch not in _TYPOGRAPHIC_PUNCTUATION_WHITELIST + ) + if not counts: + return None + char, count = counts.most_common(1)[0] + ratio = count / total + if ratio < ratio_threshold: + return None + return CidSuspectResult( + codepoint=f"U+{ord(char):04X}", + char=char, + count=count, + ratio=round(ratio, 5), + total_chars=total, + ) diff --git a/generative/tests/test_audit_double_call.py b/generative/tests/test_audit_double_call.py index 51a8239..d8b7164 100644 --- a/generative/tests/test_audit_double_call.py +++ b/generative/tests/test_audit_double_call.py @@ -323,6 +323,9 @@ def test_second_pass_covers_a_strict_subset_not_all_claims(self, tmp_path, monke }, ], "quality_flags": ["audit_overridden", "judge_uneinig", "retrieval_low_cosine"], + # #306: additives Diagnostik-Feld, None weil das Fixture-PDF reiner ASCII-Text + # ist (kein CID-Verdacht) -- kein Einfluss auf Raten/Labels oben. + "pdf_text_suspect_cid": None, "llm_usage": {"calls": 2, "input_tokens": 14, "output_tokens": 14, "cached_calls": 0}, # model_config wird nicht als eigenes Literal dupliziert (eval_note hat dafuer # keinen Override-Parameter wie pipeline_version) -- stattdessen direkter Bezug diff --git a/generative/tests/test_cid_suspect_detection.py b/generative/tests/test_cid_suspect_detection.py new file mode 100644 index 0000000..2867794 --- /dev/null +++ b/generative/tests/test_cid_suspect_detection.py @@ -0,0 +1,219 @@ +# -*- coding: utf-8 -*- +"""Generische CID-Font-/Korruptions-Erkennung auf Eval-Seite (#306). + +#278 fixt exakt zwei Codepoints eines defekten Fonts im Jockisch-PDF +(U+0231/U+022C). Jedes andere PDF mit einem anderen CID-Glyph-Mapping +erzeugt dasselbe Fehlerbild und bleibt ungefixt. Dieses Modul testet die +generische Erkennungs-Heuristik (`generative.eval_text_quality`) sowie ihre +additive Verdrahtung in `eval_quality_v4` -- NUR Flag/Warnung, siehe +Modul-Docstring dort: kein Rueckmapping, keine Label-/Raten-Aenderung. + +Drei Kern-Szenarien (Akzeptanzkriterium #306): + 1. Synthetisches CID-Profil (Jockisch-artig) -> Flag gesetzt. + 2. Sauberer deutscher Text mit Umlauten -> kein Flag. + 3. Grenzfall knapp unter dem Schwellwert -> kein Flag. +Zusaetzlich: Bit-Identitaets-Test, der belegt, dass das Flag KEINE +Halluzinationsrate/kein Label veraendert (Bump-Freiheits-Beleg). +""" + +from __future__ import annotations + +import fitz + +from generative import eval_quality_v4 as eq +from generative.eval_common import Chunk +from generative.eval_text_quality import ( + DEFAULT_RATIO_THRESHOLD, + QUALITY_FLAG_CID_SUSPECT, + CidSuspectResult, + detect_cid_suspect, +) + +# --------------------------------------------------------------------------- +# 1) Reine Heuristik (generative.eval_text_quality) -- kein PDF-I/O noetig. +# --------------------------------------------------------------------------- + +# Jockisch-artiges Muster: ein einzelnes Nicht-ASCII-Glyph (hier U+0231, exakt +# der am Original-PDF per PyMuPDF nachgemessene Codepoint aus #278) ersetzt +# praktisch jedes Leerzeichen -> dominiert das Nicht-ASCII-Haeufigkeitsprofil. +JOCKISCH_LIKE_TEXT = ("DieȱAkzeptanzȱistȱGegenstandȱzahlreicherȱwissenschaftlicherȱUntersuchungenȱ ") * 40 + +# Sauberer deutscher Text mit hoher Umlaut-Dichte (ä ö ü ß) -- alle Umlaute +# liegen in Latin-1 Supplement (<= U+017F) und duerfen die Erkennung NIE +# ausloesen, unabhaengig von ihrer Haeufigkeit. +CLEAN_UMLAUT_TEXT = ( + "Die Akzeptanz neuer Informationstechnologien wird durch waehrgenommene " + "Nuetzlichkeit und wahrgenommene Benutzerfreundlichkeit erklaert. " + "Fuer groessere Stichproben zeigt sich ein aehnliches Bild bei der " + "Ueberpruefung mehrerer Softwareloesungen fuer oeffentliche Behoerden. " +) * 60 + + +class TestDetectCidSuspect: + def test_synthetic_jockisch_profile_triggers_flag(self): + result = detect_cid_suspect(JOCKISCH_LIKE_TEXT) + assert result is not None + assert result.codepoint == "U+0231" + assert result.ratio >= DEFAULT_RATIO_THRESHOLD + + def test_clean_german_umlaut_text_does_not_trigger(self): + assert detect_cid_suspect(CLEAN_UMLAUT_TEXT) is None + + def test_ratio_just_below_threshold_does_not_trigger(self): + # 499 von 100000 Zeichen = 0,499 % < 0,5 % Schwelle. + total = 100_000 + suspect_count = 499 + text = "a" * (total - suspect_count) + "ƀ" * suspect_count + assert len(text) == total + result = detect_cid_suspect(text, ratio_threshold=DEFAULT_RATIO_THRESHOLD) + assert result is None + + def test_ratio_just_above_threshold_triggers(self): + # 501 von 100000 Zeichen = 0,501 % > 0,5 % Schwelle. + total = 100_000 + suspect_count = 501 + text = "a" * (total - suspect_count) + "ƀ" * suspect_count + result = detect_cid_suspect(text, ratio_threshold=DEFAULT_RATIO_THRESHOLD) + assert result is not None + assert result.count == suspect_count + + def test_typographic_punctuation_whitelist_does_not_trigger(self): + # Gedankenstriche/"smarte" Anfuehrungszeichen sind normale Extraktions- + # Artefakte, kein CID-Signal (false-positive-arm). + text = ("Ein Satz " + "—" + " mit vielen Gedankenstrichen ") * 200 + assert detect_cid_suspect(text) is None + + def test_empty_text_returns_none(self): + assert detect_cid_suspect("") is None + + +# --------------------------------------------------------------------------- +# 2) Verdrahtung in eval_quality_v4: _pdf_artifacts liest das Flag aus dem +# per PyMuPDF extrahierten Volltext (derselbe Volltext, der auch fuer +# Chunking/Retrieval/Evidence-Verifikation verwendet wird). +# --------------------------------------------------------------------------- + + +def _make_pdf(tmp_path, name="quelle.pdf"): + pdf_path = tmp_path / name + doc = fitz.open() + page = doc.new_page() + page.insert_text( + (72, 72), + "Wilson beschreibt Informationsverhalten als uebergeordnetes Rahmenkonzept.\n" + "Das ISP-Modell von Kuhlthau umfasst sechs aufeinanderfolgende Phasen.", + fontsize=11, + ) + doc.save(str(pdf_path)) + doc.close() + return pdf_path + + +class TestPdfArtifactsCidWiring: + def test_clean_pdf_has_no_cid_suspect(self, tmp_path): + eq._reset_pdf_caches() + pdf = _make_pdf(tmp_path) + assert eq._pdf_artifacts(pdf).cid_suspect is None + + def test_corrupted_full_text_sets_cid_suspect(self, tmp_path, monkeypatch): + eq._reset_pdf_caches() + pdf = _make_pdf(tmp_path) + + def fake_extract(doc, page): + return JOCKISCH_LIKE_TEXT if page == 1 else "" + + monkeypatch.setattr(eq, "_extract_page_text", fake_extract) + result = eq._pdf_artifacts(pdf).cid_suspect + assert result is not None + assert result.codepoint == "U+0231" + + +# --------------------------------------------------------------------------- +# 3) Verdrahtung in _aggregate: additives Feld + quality_flags-Eintrag, OHNE +# dass irgendein anderer Wert im Ergebnis-Dict sich aendert (#306 harte +# Vorgabe: kein Messverhalten-Unterschied, kein EVAL_VERSION-Bump noetig). +# --------------------------------------------------------------------------- + + +def _minimal_aggregate_args(tmp_path): + note_path = tmp_path / "note.md" + pdf_path = tmp_path / "quelle.pdf" + chunks = [Chunk(0, "Ein Chunk-Text.", (1,))] + claim_scores = [ + { + "claim_idx": 0, + "claim": "Ein Claim.", + "label": eq.SUPPORTED_EXACT, + "quality_flags": [], + "evidence": "Ein Chunk-Text.", + "evidence_verified": True, + "decision_source": "primary", + } + ] + llm_meta = {"calls": 1, "input_tokens": 10, "output_tokens": 5, "cached_calls": 0, "quality_flags": []} + return note_path, pdf_path, chunks, claim_scores, llm_meta + + +class TestAggregateCidSuspectAdditive: + def test_cid_suspect_adds_flag_and_detail_field(self, tmp_path): + note_path, pdf_path, chunks, claim_scores, llm_meta = _minimal_aggregate_args(tmp_path) + suspect = CidSuspectResult(codepoint="U+0231", char="ȱ", count=5173, ratio=0.421, total_chars=12283) + + result = eq._aggregate( + note_path, + pdf_path, + "v-test", + "2026-07-17T00:00:00", + "de", + chunks, + claim_scores, + llm_meta, + cid_suspect=suspect, + ) + + assert QUALITY_FLAG_CID_SUSPECT in result["quality_flags"] + assert result["pdf_text_suspect_cid"] == suspect.as_dict() + + def test_no_cid_suspect_leaves_field_none_and_no_flag(self, tmp_path): + note_path, pdf_path, chunks, claim_scores, llm_meta = _minimal_aggregate_args(tmp_path) + + result = eq._aggregate( + note_path, + pdf_path, + "v-test", + "2026-07-17T00:00:00", + "de", + chunks, + claim_scores, + llm_meta, + cid_suspect=None, + ) + + assert result["pdf_text_suspect_cid"] is None + assert QUALITY_FLAG_CID_SUSPECT not in result["quality_flags"] + + def test_cid_suspect_does_not_change_any_other_result_field(self, tmp_path): + """Bit-Identitaets-Beleg: das additive Flag/Feld ist die EINZIGE + Differenz zwischen einem Lauf mit und ohne CID-Verdacht bei sonst + identischem Input -- Halluzinationsrate/Labels/Counts bleiben + unberuehrt (Grundlage der PR-Begruendung "kein EVAL_VERSION-Bump").""" + note_path, pdf_path, chunks, claim_scores, llm_meta = _minimal_aggregate_args(tmp_path) + suspect = CidSuspectResult(codepoint="U+0231", char="ȱ", count=5173, ratio=0.421, total_chars=12283) + + result_without = eq._aggregate( + note_path, pdf_path, "v-test", "2026-07-17T00:00:00", "de", chunks, claim_scores, llm_meta, cid_suspect=None + ) + result_with = eq._aggregate( + note_path, + pdf_path, + "v-test", + "2026-07-17T00:00:00", + "de", + chunks, + claim_scores, + llm_meta, + cid_suspect=suspect, + ) + + diff_keys = {k for k in set(result_without) | set(result_with) if result_without.get(k) != result_with.get(k)} + assert diff_keys == {"quality_flags", "pdf_text_suspect_cid"} From dad3827f29a4638d7deeedf094388fbb530c733f Mon Sep 17 00:00:00 2001 From: TillQuandel Date: Fri, 17 Jul 2026 12:56:06 +0200 Subject: [PATCH 2/2] fix(eval): CID-Heuristik ignoriert etablierte Nicht-Latein-Bloecke (Greek/Cyrillic-FP) (#306) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarialer Fund: ein statistik-lastiger Absatz mit griechischer Koeffizienten-Notation (α, β, χ²) triggerte die CID-Verdachts-Warnung faelschlich (CidSuspectResult(U+03B1, ratio 1,93%) bei 0,5%-Schwelle). CID-Korruptionsglyphen landen empirisch in Latin Extended-B (U+0231/U+022C, #278), nicht in etablierten Nicht-Latein-Schriftbloecken -- Statistik-, Linguistik-, Klassik- und Slawistik-Fachtexte nutzen Griechisch/Kyrillisch dagegen legitim und gehaeuft. generative/eval_text_quality.py: Greek and Coptic (U+0370-U+03FF) und Kyrillisch (U+0400-U+04FF) neben der bestehenden Satzzeichen-Whitelist von der Verdachtszaehlung ausgenommen (_ESTABLISHED_SCRIPT_RANGES). Neue Tests: griechischer und kyrillischer Absatz -> kein Flag (RED vor dem Fix reproduziert: beide Faelle schlugen mit realer CidSuspectResult fehl); Regressionstest bestaetigt, dass das Jockisch-Muster (Latin Extended-B) weiterhin triggert. docs/evaluation.md: #306-Abschnitt um Schriftblock-Ausnahme, bekannte Grenze (exotische Schriften ausserhalb Griechisch/Kyrillisch/Latein koennten weiterhin flaggen) und Alt-Cache-Hinweis (Warnung feuert fuer bereits gecachte Evals erst bei Re-Eval/--fresh-run) ergaenzt. --- docs/evaluation.md | 4 +- generative/eval_text_quality.py | 53 +++++++++++++++---- .../tests/test_cid_suspect_detection.py | 38 +++++++++++++ 3 files changed, 85 insertions(+), 10 deletions(-) diff --git a/docs/evaluation.md b/docs/evaluation.md index bb52a27..468d4a2 100644 --- a/docs/evaluation.md +++ b/docs/evaluation.md @@ -59,7 +59,9 @@ Seitenzahlen laufen im **PageLabel-Namespace** (`anchor_page_numbers`, `generati Beide Wege laufen durch **dieselbe** `_normalize()` (`eval_common.py:46-77`): Silbentrennung am Zeilenende, Whitespace-Kollaps, Anführungszeichen-Normalisierung — und seit #298 (EVAL_VERSION 4.3) ein **CID-Font-Rückmapping** (`eval_common.py:55-70`): manche eingebettete Schriftarten mappen ihr Leerzeichen-Glyph auf U+0231 („ȱ") bzw. ihren Trennstrich auf U+022C („Ȭ") statt auf ASCII — am Original-PDF „Jockisch – 2010 – Das Technologieakzeptanzmodell.pdf" per PyMuPDF nachgemessen: U+0231 5173×, U+022C 240×. Ohne Rückmapping liest der Judge nur noch zusammengeschriebene Wörter und scheitert an einer Quelle, die die Notiz korrekt zitiert hat (0 % Coverage, 43–57 % „Halluzination" — reines Artefakt, PR #298). -**CID-Verdachts-Warnung (#306, generisch, additiv):** `_normalize()` fixt nur die zwei bekannten Jockisch-Codepoints — jedes andere PDF mit einem anderen CID-Fehlmapping bleibt unrepariert und würde dasselbe Fehlerbild erzeugen. `generative/eval_text_quality.py::detect_cid_suspect()` scannt den per `_extract_page_text` gewonnenen `full_text` (nach `_normalize`, also nach dem #298-Fix) auf ein verdächtiges Nicht-ASCII-Häufigkeitsprofil: dominiert ein **einzelner** Codepoint außerhalb von ASCII/Latin-1 Supplement/Latin Extended-A (abzüglich einer Whitelist gängiger typografischer Satzzeichen wie Gedankenstrich/„smarte" Anführungszeichen) mit ≥ 0,5 % aller Zeichen, gilt das als CID-Verdacht — genau das Muster, das U+0231 im Jockisch-PDF zeigte. Bei Treffer: additives Feld `result["pdf_text_suspect_cid"]` (`{codepoint, count, ratio, total_chars}`, sonst `None`) plus `"cid_font_suspect"` in `result["quality_flags"]`, berechnet einmal pro PDF in `_pdf_artifacts()` (`eval_quality_v4.py`) und dort auch prominent auf stderr geloggt. **Kein** Rückmapping, **keine** Label-/Raten-Änderung — reine Diagnostik, deshalb kein EVAL_VERSION-Bump nötig. +**CID-Verdachts-Warnung (#306, generisch, additiv):** `_normalize()` fixt nur die zwei bekannten Jockisch-Codepoints — jedes andere PDF mit einem anderen CID-Fehlmapping bleibt unrepariert und würde dasselbe Fehlerbild erzeugen. `generative/eval_text_quality.py::detect_cid_suspect()` scannt den per `_extract_page_text` gewonnenen `full_text` (nach `_normalize`, also nach dem #298-Fix) auf ein verdächtiges Nicht-ASCII-Häufigkeitsprofil: dominiert ein **einzelner** Codepoint außerhalb von ASCII/Latin-1 Supplement/Latin Extended-A (abzüglich einer Whitelist gängiger typografischer Satzzeichen wie Gedankenstrich/„smarte" Anführungszeichen sowie der etablierten Schriftblöcke Griechisch und Kyrillisch, s. u.) mit ≥ 0,5 % aller Zeichen, gilt das als CID-Verdacht — genau das Muster, das U+0231 im Jockisch-PDF zeigte. Bei Treffer: additives Feld `result["pdf_text_suspect_cid"]` (`{codepoint, count, ratio, total_chars}`, sonst `None`) plus `"cid_font_suspect"` in `result["quality_flags"]`, berechnet einmal pro PDF in `_pdf_artifacts()` (`eval_quality_v4.py`) und dort auch prominent auf stderr geloggt. **Kein** Rückmapping, **keine** Label-/Raten-Änderung — reine Diagnostik, deshalb kein EVAL_VERSION-Bump nötig. + +Adversarialer Fund an diesem PR: ein statistik-/klassik-lastiger Absatz mit häufigem griechischem „α" (Koeffizienten-Notation) triggerte zunächst fälschlich (`CidSuspectResult(U+03B1, ratio 1,93 %)` bei 0,5 %-Schwelle). Fix: Griechisch (Greek and Coptic, U+0370–U+03FF) und Kyrillisch (U+0400–U+04FF) sind als etablierte Schriftblöcke von der Verdachtszählung ausgenommen — CID-Korruptionsglyphen landen empirisch in Latin Extended-B (wie U+0231/U+022C), nicht in etablierten Nicht-Latein-Blöcken, während Statistik-/Linguistik-/Klassik-/Slawistik-Fachtexte Griechisch/Kyrillisch legitim und gehäuft nutzen. **Bekannte Grenze:** die Heuristik prüft nur Codepoints außerhalb dieser etablierten Schriftblöcke — exotische, aber legitime Schriften (z. B. Hebräisch, Arabisch, CJK) könnten weiterhin fälschlich flaggen; da die Warnung reine Diagnostik ist, hat das keinen Einfluss auf Halluzinationsrate/Labels. **Alt-Cache-Hinweis:** da kein `EVAL_VERSION`-Bump erfolgt, feuert die Warnung für bereits unter 4.3 gecachte Evals nicht rückwirkend, sondern erst beim nächsten Re-Eval/`--fresh-run` dieser Note — bestehende Baselines in `quality_history.jsonl` bleiben unmarkiert. ### Claim-Extraktion aus der Note diff --git a/generative/eval_text_quality.py b/generative/eval_text_quality.py index 3482375..edee612 100644 --- a/generative/eval_text_quality.py +++ b/generative/eval_text_quality.py @@ -15,10 +15,19 @@ Heuristik, die ein verdaechtiges Nicht-ASCII-Haeufigkeitsprofil erkennt und als Warnung meldet: ein einzelner Codepoint ausserhalb des in deutschen/ englischen Fachtexten erwartbaren Bereichs (ASCII + Latin-1 Supplement + -Latin Extended-A + gaengige typografische Satzzeichen), der einen -Anteils-Schwellwert an der Gesamtzeichenzahl ueberschreitet. Genau dieses -Muster zeigte das Jockisch-PDF (ein Glyph ersetzt praktisch jedes -Leerzeichen). +Latin Extended-A + gaengige typografische Satzzeichen + etablierte +Nicht-Latein-Schriftbloecke wie Griechisch/Kyrillisch, s. +`_ESTABLISHED_SCRIPT_RANGES`), der einen Anteils-Schwellwert an der +Gesamtzeichenzahl ueberschreitet. Genau dieses Muster zeigte das +Jockisch-PDF (ein Glyph ersetzt praktisch jedes Leerzeichen). + +Adversarialer Fund am #306-PR: ein statistik-/klassik-lastiger Absatz mit +haeufigem griechischem "α" (Koeffizienten-Notation) triggerte ohne die +Schriftblock-Ausnahme faelschlich (1,93 % > 0,5 %-Schwelle) -- Griechisch/ +Kyrillisch sind in Fachtexten (Statistik, Linguistik, Klassik, Slawistik) +gehaeuft und legitim, waehrend CID-Korruptionsglyphen empirisch in Latin +Extended-B landen (siehe #278: U+0231/U+022C), nicht in etablierten +Nicht-Latein-Bloecken. Kein Bezug zur Halluzinationsrate/den Judge-Labels -- reines Diagnostik- Signal fuer den Eval-Betrieb (#306 harte Vorgabe: Flag/Warnung, kein @@ -56,6 +65,26 @@ "′″" # Minute/Sekunde bzw. Prime (Formeln) ) +# Etablierte Nicht-Latein-Schriftbloecke, die in legitimen Fachtexten gehaeuft +# vorkommen -- Statistik-/Physik-Koeffizienten (α, β, χ², σ), Klassik-/ +# Linguistik-Zitate (Griechisch), Slawistik/Osteuropa-Literaturangaben +# (Kyrillisch). CID-Font-Korruptionsglyphen wie U+0231/U+022C (#278) landen +# empirisch in Latin Extended-B, nicht in etablierten Nicht-Latein-Bloecken -- +# ein griechischer Absatz mit vielen "α" ist ein plausibler Fachtext, kein +# CID-Artefakt (adversarialer Fund am #306-PR: 1,93 % α in einem Statistik- +# Absatz triggerte faelschlich). Bewusst nur die zwei haeufigsten Bloecke, +# nicht pauschal "alles ausserhalb Latein" -- das wuerde die Heuristik +# entwerten. +_ESTABLISHED_SCRIPT_RANGES: tuple[tuple[int, int], ...] = ( + (0x0370, 0x03FF), # Greek and Coptic + (0x0400, 0x04FF), # Cyrillic +) + + +def _in_established_script(codepoint: int) -> bool: + return any(lo <= codepoint <= hi for lo, hi in _ESTABLISHED_SCRIPT_RANGES) + + DEFAULT_RATIO_THRESHOLD = 0.005 # 0,5 % der Zeichen (Issue-Vorschlag #306) QUALITY_FLAG_CID_SUSPECT = "cid_font_suspect" @@ -88,10 +117,12 @@ def detect_cid_suspect( """Prueft `text` auf ein CID-Font-Korruptions-Haeufigkeitsprofil. Zaehlt Codepoints ausserhalb von ASCII/Latin-1/Latin-Extended-A (abzueglich - der typografischen Satzzeichen-Whitelist). Wenn ein EINZELNER Codepoint - daraus `ratio_threshold` der Gesamtzeichenzahl ueberschreitet, gilt das als - CID-Verdacht (dominantes Ersatz-Glyph fuer Leerzeichen/Trennstrich o.ae. -- - das Muster, das #278 am Jockisch-PDF fuer U+0231/U+022C belegt hat). + der typografischen Satzzeichen-Whitelist und etablierter Nicht-Latein- + Schriftbloecke wie Griechisch/Kyrillisch, s. `_ESTABLISHED_SCRIPT_RANGES`). + Wenn ein EINZELNER verbleibender Codepoint daraus `ratio_threshold` der + Gesamtzeichenzahl ueberschreitet, gilt das als CID-Verdacht (dominantes + Ersatz-Glyph fuer Leerzeichen/Trennstrich o.ae. -- das Muster, das #278 am + Jockisch-PDF fuer U+0231/U+022C belegt hat). Reine Diagnostik: aendert `text` nicht, trifft keine Label-Entscheidung. Gibt `None` zurueck, wenn kein Codepoint den Schwellwert erreicht (oder @@ -101,7 +132,11 @@ def detect_cid_suspect( return None total = len(text) counts = Counter( - ch for ch in text if ord(ch) > _EXPECTED_MAX_CODEPOINT and ch not in _TYPOGRAPHIC_PUNCTUATION_WHITELIST + ch + for ch in text + if ord(ch) > _EXPECTED_MAX_CODEPOINT + and ch not in _TYPOGRAPHIC_PUNCTUATION_WHITELIST + and not _in_established_script(ord(ch)) ) if not counts: return None diff --git a/generative/tests/test_cid_suspect_detection.py b/generative/tests/test_cid_suspect_detection.py index 2867794..b9dd4ba 100644 --- a/generative/tests/test_cid_suspect_detection.py +++ b/generative/tests/test_cid_suspect_detection.py @@ -48,6 +48,26 @@ "Ueberpruefung mehrerer Softwareloesungen fuer oeffentliche Behoerden. " ) * 60 +# Adversarialer Fund am #306-PR: ein statistik-lastiger Absatz mit haeufiger +# griechischer Koeffizienten-Notation (α, β, χ²) triggerte OHNE die +# Schriftblock-Ausnahme faelschlich (empirisch CidSuspectResult(U+03B1, +# ratio 1,93 %) bei 0,5 %-Schwelle -- dieser Absatz liegt mit 1,16 % Alpha- +# Anteil in derselben Groessenordnung). Griechisch ist in Statistik-/ +# Klassik-/Linguistik-Fachtexten ein legitimes, haeufiges Muster. +GREEK_STATISTICS_PARAGRAPH = ( + "Der Regressionskoeffizient α lag bei α=0,42 (SE=0,08), der Interaktionsterm β " + "erreichte β=0,17. Das globale Modell zeigt einen signifikanten χ²-Test " + "(χ²(3)=14,2, p<0,01). Wie schon bei Aristoteles diskutiert, " + "bleibt die Deutung von α und β kontextabhaengig. " +) * 40 + +# Kyrillischer Absatz (Slawistik-/Osteuropa-Literaturangaben) -- derselbe +# Schriftblock-Ausnahme-Fall wie Griechisch, andere Sprache. +CYRILLIC_PARAGRAPH = ( + "Известный лингвист отметил, что данный термин восходит к древнерусскому " + "языку и часто встречается в академической литературе по славистике. " +) * 40 + class TestDetectCidSuspect: def test_synthetic_jockisch_profile_triggers_flag(self): @@ -86,6 +106,24 @@ def test_typographic_punctuation_whitelist_does_not_trigger(self): def test_empty_text_returns_none(self): assert detect_cid_suspect("") is None + def test_greek_statistics_paragraph_does_not_trigger(self): + # False-Positive-Regressionstest (adversarialer Fund): Griechisch (Greek + # and Coptic, U+0370-U+03FF) ist ein etablierter Schriftblock, keine + # CID-Korruption -- trotz Alpha-Anteil deutlich ueber der 0,5%-Schwelle. + assert detect_cid_suspect(GREEK_STATISTICS_PARAGRAPH) is None + + def test_cyrillic_paragraph_does_not_trigger(self): + # Wie oben, fuer Kyrillisch (U+0400-U+04FF). + assert detect_cid_suspect(CYRILLIC_PARAGRAPH) is None + + def test_jockisch_pattern_still_triggers_despite_script_exclusion(self): + # Regressionsschutz: die Schriftblock-Ausnahme (Griechisch/Kyrillisch) + # darf das eigentliche Jockisch-Muster (Latin Extended-B, U+0231) nicht + # mit-ausnehmen -- Latin Extended-B ist kein etablierter Schriftblock. + result = detect_cid_suspect(JOCKISCH_LIKE_TEXT) + assert result is not None + assert result.codepoint == "U+0231" + # --------------------------------------------------------------------------- # 2) Verdrahtung in eval_quality_v4: _pdf_artifacts liest das Flag aus dem