Skip to content
Merged
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
8 changes: 6 additions & 2 deletions docs/evaluation.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ 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 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

`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).
Expand Down Expand Up @@ -194,7 +198,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 |
Expand All @@ -204,7 +208,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).

Expand Down
36 changes: 36 additions & 0 deletions generative/eval_quality_v4.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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] = {}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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},
}

Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -1152,6 +1187,7 @@ def eval_note(
claim_scores,
llm_meta,
content_hash=content_hash,
cid_suspect=artifacts.cid_suspect,
)


Expand Down
153 changes: 153 additions & 0 deletions generative/eval_text_quality.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
#!/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 + 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
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)
)

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


@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 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
`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
and not _in_established_script(ord(ch))
)
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,
)
3 changes: 3 additions & 0 deletions generative/tests/test_audit_double_call.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading