fix: Gegenprobe der Abbildungen je Datenzeile; Standardmodell ~deepseek/deepseek-flash-latest

- check_figure_readings vergleicht Zahlentupel je Datenzeile statt aller Zahlen des
  Freitexts (Fehlalarm im ersten echten Lauf: zusätzliche Knotenaufzählung); fängt
  falsch zugeordnete Bewertungen und vertauschte Pfeilrichtungen
- feste Form 'Start → Ziel: Bewertung' in Verstehen- und Abbildungs-Prompt;
  abweichende Lesungen stehen im Log
- Standardmodell ist der Alias ~deepseek/deepseek-flash-latest (derzeit V4.1 Flash);
  Benchmarks weiter mit festen IDs

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-09-17 18:01:37 +02:00
commit 101d55b713
9 changed files with 55 additions and 28 deletions

View file

@ -325,7 +325,7 @@ Was dabei passiert:
Voraussetzungen und Grenzen:
- Das Modell der Verstehensstufe muss Bilder lesen können. `deepseek/deepseek-v4.1-flash` kann das; sonst ein Bildmodell über `LLM_MODEL_UNDERSTANDING` wählen (z. B. `qwen/qwen3.8-flash`).
- Das Modell der Verstehensstufe muss Bilder lesen können. Das Standardmodell `~deepseek/deepseek-flash-latest` (derzeit DeepSeek V4.1 Flash) kann das; sonst ein Bildmodell über `LLM_MODEL_UNDERSTANDING` wählen (z. B. `qwen/qwen3.8-flash`).
- Höchstens 6 Abbildungen, je 5 MB.
- Mehrkosten: ein bis zwei zusätzliche kleine Aufrufe für die Gegenprobe.
- Verweise auf Webadressen werden nicht geladen (das Programm lädt nichts aus dem Internet).
@ -401,7 +401,7 @@ Wo das Geld hingeht: rund zwei Drittel der Kosten sind **erzeugter Output**, dav
**Prompt-Cache:** Alle Aufrufe eines Laufs gehen an denselben OpenRouter-Anbieter, damit dessen Cache wiederholt gelesene Prompt-Teile billiger abrechnen kann. Der Cache-Anteil steht in der Abschlusszeile.
**Feste Modellnamen verwenden** (z. B. `deepseek/deepseek-v4.1-flash`), keine `…-latest`-Aliase – sonst ändern sich Ergebnisse unbemerkt.
**Standardmodell ist der Alias `~deepseek/deepseek-flash-latest`** – er zeigt immer auf das neueste DeepSeek-Flash-Modell (September 2026: V4.1 Flash). Das hält die Pipeline aktuell, kann Ergebnisse aber unbemerkt verändern, sobald DeepSeek ein neues Flash-Modell veröffentlicht. **Für Benchmarks und Vergleiche feste Modellnamen verwenden** (z. B. `--models deepseek/deepseek-v4.1-flash`).
---
@ -429,7 +429,7 @@ Wo das Geld hingeht: rund zwei Drittel der Kosten sind **erzeugter Output**, dav
|---|---|---|
| `OPENROUTER_API_KEY` | Schlüssel für OpenRouter | – |
| `GEMINI_API_KEY` | Schlüssel für Gemini | – |
| `LLM_MODEL` | Modell, falls `--model` fehlt | `deepseek/deepseek-v4.1-flash` |
| `LLM_MODEL` | Modell, falls `--model` fehlt | `~deepseek/deepseek-flash-latest` |
| `LLM_MODEL_UNDERSTANDING` | Modell für Verstehen und Abbildungs-Gegenprobe (muss bei Abbildungen Bilder lesen können) | Session-Modell |
| `LLM_MODEL_EXTRACTION` | Modell nur für die Extraktion | Session-Modell |
| `LLM_MODEL_REVIEW` | Modell nur für die Prüfung | Session-Modell |

View file

@ -102,7 +102,7 @@ Exit-Codes: `0` Ergebnis zuverlässig, `2` Ergebnis vorhanden, aber unzuverläss
|---|---|---|
| `--report DATEI` | Report als `.md` (z. B. VS Code-Vorschau: Strg+Umschalt+V) oder `.html` (Browser, Formeln via MathJax) | – |
| `--code DATEI` | Solver-Programm als eigenständiges, dokumentiertes Python-Skript speichern | – |
| `--model` / `LLM_MODEL` | LLM-Modell | `deepseek/deepseek-v4.1-flash` |
| `--model` / `LLM_MODEL` | LLM-Modell | `~deepseek/deepseek-flash-latest` (Alias, derzeit V4.1 Flash) |
| `--daten DATEI …` | beigelegte CSV-/Excel-Dateien; ihr Profil (Zeilen, Spalten, Spannweiten, Lücken) geht in die Prompts, die Dateien ins Arbeitsverzeichnis des Programms | – |
| `--von STUFE ERGEBNIS.JSON` | gespeichertes Ergebnis fortsetzen statt alles zu wiederholen: `solver` (nur Solver-Code neu) oder `pruefung` (ab Prüfung/Korrektur) | – |
| `--max-revisions` / `MAX_REVISIONS` | Korrekturrunden | 2 |

View file

@ -81,6 +81,9 @@ def _checked_figure_readings(session, task: str, figures, first, label: str) ->
if not befunde:
return first, []
log(f"[{label}] Abbildungen uneinheitlich gelesen – dritte Lesung: " + "; ".join(i.message for i in befunde)[:300])
for nummer, lesung in ((1, first), (2, zweite)):
for f in lesung:
log(f" Lesung {nummer}, {f.id}: " + f.transcription.replace("\n", " | ")[:300])
dritte = read_figures(session, task, figures).figures
if not check_figure_readings(first, dritte):
return first, []

View file

@ -206,23 +206,35 @@ def check_understanding(understanding, task: str) -> List[CheckIssue]:
return issues
def _numbers_multiset(text: str) -> Counter:
"""Zahlen eines Textes mit Häufigkeit – eine Kante mit Bewertung 3 zweimal ist nicht dasselbe wie einmal."""
zaehler = Counter()
for roh in NUMBER_PATTERN.findall(text or ""):
try:
zaehler[normalize_number(roh)] += 1
except ValueError:
continue
return zaehler
def _number_rows(text: str) -> Counter:
"""Je Datenzeile das Tupel ihrer Zahlen: „Pfeil 1 → 2: 3“ und „(1,2): 3“ ergeben beide (1, 2, 3).
Zeilen trennt der Zeilenumbruch oder ein Semikolon; Zeilen ohne Zahl zählen nicht. So ist der Vergleich
unabhängig von der Schreibweise, bemerkt aber eine Bewertung am falschen Pfeil.
"""
zeilen = Counter()
# „(1,2)“ ist ein Pfeil, keine Dezimalzahl 1,2 – ganzzahlige Paare in Klammern vorher trennen
text = re.sub(r"\((\d+)\s*,\s*(\d+)\)", r"(\1 \2)", text or "")
for zeile in re.split(r"[\n;]", text):
tupel = []
for roh in NUMBER_PATTERN.findall(zeile):
try:
tupel.append(normalize_number(roh))
except ValueError:
continue
if tupel:
zeilen[tuple(tupel)] += 1
return zeilen
def check_figure_readings(first, second) -> List[CheckIssue]:
"""Zwei unabhängige Übertragungen derselben Abbildungen vergleichen – Zahl für Zahl, ohne LLM.
Ein Bildmodell kann eine Pfeilbewertung dem falschen Pfeil zuordnen oder eine Ziffer verlesen; eine einzige
Lesung wäre dann ungeprüft die Datengrundlage jeder weiteren Stufe. Verglichen werden die Zahlen je Abbildung
samt Häufigkeit. Richtungsfehler bei gleichen Zahlen fängt das nicht – dafür steht das Original im Report.
Lesung wäre dann ungeprüft die Datengrundlage jeder weiteren Stufe. Verglichen werden die Datenzeilen als
Zahlentupel (`_number_rows`). Erster Ansatz – alle Zahlen des Freitexts – schlug im ersten echten Lauf
fälschlich an: eine Lesung zählte zusätzlich „Knoten: 1, 2, 3, 4“ auf (test_Image.md, 17.09.2026).
Eine vertauschte Pfeilrichtung (2 → 3 statt 3 → 2) fällt auf; eine in BEIDEN Lesungen gleich falsche nicht.
"""
issues = []
erste = {f.id: f for f in first}
@ -232,15 +244,14 @@ def check_figure_readings(first, second) -> List[CheckIssue]:
issues.append(CheckIssue(severity=CheckSeverity.ERROR, code="FIGURE_READINGS_DIFFER",
message=f"{kennung} wurde nur in einer von zwei Lesungen übertragen."))
continue
a = _numbers_multiset(erste[kennung].transcription)
b = _numbers_multiset(zweite[kennung].transcription)
a = _number_rows(erste[kennung].transcription)
b = _number_rows(zweite[kennung].transcription)
if a != b:
nur_a = sorted((a - b).elements(), key=float)
nur_b = sorted((b - a).elements(), key=float)
zeilen = lambda z: ", ".join("(" + " ".join(t) + ")" for t in sorted(z.elements())) or "–"
issues.append(CheckIssue(
severity=CheckSeverity.ERROR, code="FIGURE_READINGS_DIFFER",
message=f"{kennung} uneinheitlich gelesen – nur in Lesung 1: {', '.join(nur_a) or '–'}; "
f"nur in Lesung 2: {', '.join(nur_b) or '–'}."))
message=f"{kennung} uneinheitlich gelesen – Datenzeilen nur in Lesung 1: {zeilen(a - b)}; "
f"nur in Lesung 2: {zeilen(b - a)}."))
return issues

View file

@ -14,7 +14,9 @@ import instructor
from core.model_schema import CallRecord, LLMUsage
DEFAULT_MODEL = os.environ.get("LLM_MODEL", "deepseek/deepseek-v4.1-flash")
# Alias auf das jeweils neueste DeepSeek-Flash-Modell (September 2026: V4.1 Flash, nimmt auch Bilder an).
# Für vergleichbare Benchmarks ein festes Modell mit --models angeben.
DEFAULT_MODEL = os.environ.get("LLM_MODEL", "~deepseek/deepseek-flash-latest")
# Modell je Pipeline-Stufe: ein starkes Modell für Extraktion/Prüfung, ein schnelles für den Solver-Code
# (oder umgekehrt, je nach Messung). Leer = das Modell der Session (LLM_MODEL bzw. --model).
STEP_MODEL_ENV = {

View file

@ -132,8 +132,8 @@ class FigureTranscription(BaseModel):
id: str = Field(description="Kennung wie im Text, z. B. 'Abbildung 1'")
shows: str = Field(description="Was die Abbildung zeigt, in einem Satz (z. B. 'gerichteter Graph mit 4 Knoten')")
transcription: str = Field(
description="Vollständige Übertragung ALLER Inhalte: jede Zahl, jede Beschriftung, jede Pfeilrichtung, "
"am besten als Tabelle oder Liste (z. B. 'Pfeil 1→2: 3'). Nichts ergänzen, was nicht zu sehen ist.")
description="Nur die Daten, eine Zeile je Datenpunkt, jede Zahl genau einmal (Graph: '1 → 2: 3' je Pfeil; "
"Tabelle: 'Zeile / Spalte: Wert'). Keine Knotenaufzählung, keine Lagebeschreibung – das gehört nach shows.")
uncertain: List[str] = Field(
default_factory=list,
description="Stellen, die nicht sicher lesbar sind (verdeckt, unscharf, mehrdeutig); leer = alles sicher gelesen")

View file

@ -88,7 +88,7 @@ def test_identical_readings_have_no_finding():
def test_misread_value_is_found_with_both_numbers():
befunde = check_figure_readings([GRAPH], [VERLESEN])
assert len(befunde) == 1 and befunde[0].code == "FIGURE_READINGS_DIFFER"
assert "nur in Lesung 1: 3" in befunde[0].message and "nur in Lesung 2: 5" in befunde[0].message
assert "nur in Lesung 1: (2 4 3)" in befunde[0].message and "nur in Lesung 2: (2 4 5)" in befunde[0].message
def test_figure_missing_in_one_reading_is_found():
@ -139,3 +139,14 @@ def test_report_shows_original_and_transcription():
assert f'<img src="data:image/png;base64,{PNG}"' in html
assert "Pfeil 1 → 3: 1" in html and "unsicher gelesen" in html
assert f"![Abbildung 1](data:image/png;base64,{PNG})" in to_markdown(build_document(result))
def test_reading_comparison_ignores_wording_but_not_assignment():
# erster echter Lauf: Knotenaufzählung und doppelte Pfeilbeschreibung lösten einen Fehlalarm aus
anders_formuliert = GRAPH.model_copy(update={"transcription": "(1,2): 3; (1,3): 1; (3,2): 1; (2,4): 3; (3,4): 5"})
assert check_figure_readings([GRAPH], [anders_formuliert]) == []
vertauscht = GRAPH.model_copy(update={"transcription": GRAPH.transcription.replace("1 → 2: 3", "1 → 2: 1")
.replace("1 → 3: 1", "1 → 3: 3")}) # gleiche Zahlen, falsche Pfeile
assert check_figure_readings([GRAPH], [vertauscht])
richtung = GRAPH.model_copy(update={"transcription": GRAPH.transcription.replace("3 → 2: 1", "2 → 3: 1")})
assert check_figure_readings([GRAPH], [richtung])

View file

@ -4,8 +4,8 @@ Deine Übertragung wird ohne LLM mit einer zweiten, unabhängigen Übertragung d
Für jede Abbildung, in der Reihenfolge ihrer Kennung („Abbildung 1“, „Abbildung 2“, …), ein Eintrag in figures:
- id: die Kennung genau wie angegeben.
- shows: was die Abbildung zeigt, in einem Satz (z. B. „gerichteter Graph mit 4 Knoten und 5 bewerteten Pfeilen“).
- transcription: ALLE Inhalte – jede Zahl, jede Beschriftung, bei Graphen jeden Pfeil mit Start, Ziel und Bewertung (eine Zeile je Pfeil, z. B. „Pfeil 1 → 2: 3“), bei Tabellen jede Zelle, bei Diagrammen Achsen, Einheiten und abgelesene Werte. Achte bei Pfeilen genau auf die Richtung (wo sitzt die Spitze?).
- shows: was die Abbildung zeigt, in einem Satz (z. B. „gerichteter Graph mit 4 Knoten und 5 bewerteten Pfeilen“); hier ist Platz für Lage und Aufbau.
- transcription: NUR die Daten, eine Zeile je Datenpunkt, jede Zahl genau einmal – bei Graphen je Pfeil „Start → Ziel: Bewertung“ (z. B. „1 → 2: 3“), bei Tabellen je Zelle „Zeile / Spalte: Wert“, bei Diagrammen je Punkt „x: y“. Keine Aufzählung von Knoten, keine Lagebeschreibung, keine Wiederholung – das gehört nach shows. Achte bei Pfeilen genau auf die Richtung (wo sitzt die Spitze?).
- uncertain: jede Stelle, die du nicht sicher lesen kannst (verdeckt, unscharf, mehrdeutige Richtung oder Zuordnung einer Zahl zu einem Pfeil). Leer nur, wenn alles eindeutig ist.
Ergänze nichts, was nicht zu sehen ist, und rechne nichts aus.

View file

@ -24,6 +24,6 @@ GEMEINSAMER RAHMEN (gilt für alle vier Stufen dieser Pipeline: Verstehen, Model
- ignored_numbers ist für Aufgabennummern („3.8“), Zahlen in Einheiten („in 1000 Stunden“), Jahreszahlen, Seitenzahlen, Nummern von Sätzen. Immer mit Grund.
Eine Zahl, die weder im einen noch im anderen steht, gilt als übersehen. [→ UNCAPTURED_NUMBERS]
4. ABBILDUNGEN: Liegen der Aufgabe Abbildungen bei (im Text als „[Abbildung N]“, die Bilder folgen der Nachricht), trage je Abbildung einen Eintrag in figures ein: id genau wie im Text, shows in einem Satz, transcription mit ALLEN Inhalten (jede Zahl, jede Beschriftung, bei Graphen jeder Pfeil mit Start, Ziel und Bewertung – achte auf die Richtung der Pfeilspitze; bei Tabellen jede Zelle), uncertain für alles, was nicht sicher lesbar ist. Die Zahlen der Abbildung gehören außerdem in facts, mit quote „Abbildung N: …“. Eine zweite, unabhängige Übertragung wird Zahl für Zahl mit deiner verglichen. [→ check_figure_readings]
4. ABBILDUNGEN: Liegen der Aufgabe Abbildungen bei (im Text als „[Abbildung N]“, die Bilder folgen der Nachricht), trage je Abbildung einen Eintrag in figures ein: id genau wie im Text, shows in einem Satz, transcription mit ALLEN Daten, NUR die Daten, eine Zeile je Datenpunkt, jede Zahl genau einmal – bei Graphen je Pfeil „Start → Ziel: Bewertung“ (z. B. „1 → 2: 3“), bei Tabellen je Zelle „Zeile / Spalte: Wert“, bei Diagrammen je Punkt „x: y“. Keine Aufzählung von Knoten, keine Lagebeschreibung, keine Wiederholung – das gehört nach shows. Achte auf die Richtung der Pfeilspitze., uncertain für alles, was nicht sicher lesbar ist. Die Zahlen der Abbildung gehören außerdem in facts, mit quote „Abbildung N: …“. Eine zweite, unabhängige Übertragung wird Zahl für Zahl mit deiner verglichen. [→ check_figure_readings]
5. Erfinde nichts. Was der Text nicht sagt, gehört nicht in facts – fehlende Angaben werden später als Annahme ausgewiesen, nicht hier geraten.