text_to_solver_program/tests/test_figures.py
dschlueter 89b61d8c62 fix: Abbildungs-Gegenprobe ohne kopierbares Beispiel, Kennungen vereinheitlicht
- Prompt-Beispiel '1 → 2: 3' durch Schema 'A → B: Wert' ersetzt: eine Lesung hatte
  nur die Beispielzeile übernommen statt aller fünf Pfeile; alle Datenpunkte verlangt
- canonical_figure_id: '1' und 'Abbildung 1' gelten als dieselbe Abbildung – beim
  Vergleich, in der Pipeline und im Report (Formfehler im zweiten Testlauf)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 18:09:52 +02:00

165 lines
8.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Aufgaben mit Abbildungen (docling-Markdown): abtrennen, als Bild an die Verstehensstufe, doppelt ablesen."""
import base64
import pytest
from checks.model_checks import check_figure_readings, numbers_in
from core.model_schema import (FigureReading, FigureTranscription, SolverCode, SubQuestion, TaskUnderstanding)
from reporting.report import build_document, to_html, to_markdown
from TextToModel import run_pipeline
from understanding.figures import MAX_FIGURES, FigureError, split_figures, with_transcriptions
from understanding.understand import understand_task
# 1×1-PNG – Inhalt egal, es geht um den Weg durch die Pipeline
PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
AUFGABE = f"Gesucht sind die minimalen Fahrtkosten von Knoten 1 nach Knoten 4.\n\n![Image](data:image/png;base64,{PNG})\n"
# Graph aus test_Image.md
GRAPH = FigureTranscription(id="Abbildung 1", shows="gerichteter Graph mit 4 Knoten",
transcription="Pfeil 1 → 2: 3\nPfeil 1 → 3: 1\nPfeil 3 → 2: 1\nPfeil 2 → 4: 3\nPfeil 3 → 4: 5")
VERLESEN = GRAPH.model_copy(update={"transcription": GRAPH.transcription.replace("2 → 4: 3", "2 → 4: 5")})
# --- Abtrennen ohne LLM ---
def test_embedded_image_is_replaced_by_a_placeholder():
text, figuren = split_figures(AUFGABE)
assert "[Abbildung 1]" in text and "base64" not in text
assert len(figuren) == 1 and figuren[0].mime == "image/png" and figuren[0].data == PNG
assert numbers_in(text) == {"1", "4"} # keine Zahlen aus dem Base64-Zeichensalat
def test_referenced_image_file_is_loaded_relative_to_the_task(tmp_path):
(tmp_path / "bilder").mkdir()
(tmp_path / "bilder" / "graph.png").write_bytes(base64.b64decode(PNG))
text, figuren = split_figures("Siehe ![Netz](bilder/graph.png) und ![](fehlt.png) und ![Web](https://x.de/a.png)", tmp_path)
assert "[Abbildung 1: Netz]" in text
assert "![](fehlt.png)" in text and "https://x.de/a.png" in text # nicht vorhanden bzw. keine Datei: bleibt
assert len(figuren) == 1 and figuren[0].data == PNG and figuren[0].source.endswith("graph.png")
def test_limits_and_broken_base64_are_errors_not_silent_cuts():
zu_viele = "\n".join(f"![Image](data:image/png;base64,{PNG})" for _ in range(MAX_FIGURES + 1))
with pytest.raises(FigureError, match="höchstens"):
split_figures(zu_viele)
with pytest.raises(FigureError, match="Base64"):
split_figures("![Image](data:image/png;base64,abc)")
def test_task_without_images_is_unchanged():
assert split_figures("Minimiere 3x + 2y.") == ("Minimiere 3x + 2y.", [])
def test_transcription_is_appended_with_uncertain_parts():
unsicher = GRAPH.model_copy(update={"uncertain": ["Richtung des Pfeils zwischen 2 und 3"]})
text = with_transcriptions("Aufgabe [Abbildung 1]", [unsicher])
assert "Pfeil 3 → 4: 5" in text and "Unsicher gelesen: Richtung des Pfeils" in text
# --- Nachricht an das Bildmodell ---
class Aufzeichner:
def __init__(self, antwort): self.antwort, self.nachrichten = antwort, []
def create(self, response_model, messages, step="unbenannt"):
self.nachrichten.append((step, messages)); return self.antwort
def test_understanding_sends_images_as_image_parts():
text, figuren = split_figures(AUFGABE)
session = Aufzeichner("ok")
understand_task(session, text, figures=figuren)
inhalt = session.nachrichten[0][1][-1]["content"]
assert isinstance(inhalt, list) and inhalt[0]["type"] == "text" and "1 ABBILDUNG" in inhalt[0]["text"]
assert inhalt[-1] == {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{PNG}"}}
def test_understanding_without_images_stays_plain_text():
session = Aufzeichner("ok")
understand_task(session, "Minimiere 3x.")
assert isinstance(session.nachrichten[0][1][-1]["content"], str)
# --- Doppelt ablesen ---
def test_identical_readings_have_no_finding():
assert check_figure_readings([GRAPH], [GRAPH.model_copy()]) == []
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: (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():
assert "nur in einer" in check_figure_readings([GRAPH], [])[0].message
# --- Durch die ganze Pipeline (ohne LLM) ---
def _verstaendnis(figur):
return TaskUnderstanding(is_optimization_task=True, reason="-", facts=[], ignored_numbers=[],
sub_questions=[SubQuestion(id="1", quote="-", kind="rechnen", needs_solver=True)],
figures=[figur])
def _lauf(erste, lesungen):
from test_pipeline import GOOD, OK, FakeSession
from test_script_export import STRUCTURED_CODE
session = FakeSession([GOOD], [OK], [SolverCode(code=STRUCTURED_CODE)], understandings=[_verstaendnis(erste)],
readings=[FigureReading(figures=[f]) for f in lesungen])
return run_pipeline(AUFGABE, session=session, max_revisions=0, max_restarts=0), session
def test_only_the_understanding_sees_the_image_later_stages_get_the_transcription():
result, session = _lauf(GRAPH, [GRAPH])
bilder = [schritt for schritt, inhalt in session.contents if isinstance(inhalt, list)]
assert set(bilder) == {"Verstehen", "Abbildungen"}
spaeter = [inhalt for schritt, inhalt in session.contents if schritt not in ("Verstehen", "Abbildungen")]
assert spaeter and all("data:image" not in str(i) for i in spaeter)
assert any("Pfeil 3 → 4: 5" in str(i) for i in spaeter)
assert result.reliable and len(result.figures) == 1 and "Pfeil 2 → 4: 3" in result.task
def test_two_of_three_agreeing_readings_win():
result, _ = _lauf(VERLESEN, [GRAPH, GRAPH]) # erste Lesung verlesen, zweite und dritte stimmen überein
assert result.reliable and "Pfeil 2 → 4: 3" in result.task
def test_persistently_differing_readings_make_the_result_unreliable():
dritte = GRAPH.model_copy(update={"transcription": GRAPH.transcription.replace("1 → 3: 1", "1 → 3: 7")})
result, _ = _lauf(GRAPH, [VERLESEN, dritte])
assert not result.reliable
assert any(p.startswith("Abbildung:") and "uneinheitlich" in p for p in result.reliability_issues)
def test_report_shows_original_and_transcription():
result, _ = _lauf(GRAPH.model_copy(update={"uncertain": ["Pfeil 3 → 2 schwer lesbar"]}), [GRAPH])
html = to_html(build_document(result))
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])
def test_figure_ids_are_compared_by_number():
# beobachtet: Lesung 1 schrieb als Kennung nur „1“, Lesung 2 „Abbildung 1“ – Inhalt identisch
from understanding.figures import canonical_figure_id
assert check_figure_readings([GRAPH.model_copy(update={"id": "1"})], [GRAPH]) == []
assert canonical_figure_id("Abb. 2") == "Abbildung 2"
def test_short_id_in_understanding_still_reaches_the_report():
result, _ = _lauf(GRAPH.model_copy(update={"id": "1"}), [GRAPH])
html = to_html(build_document(result))
assert "nicht übertragen" not in html and "Pfeil 3 → 4: 5" in html