Files
creator2/tests/test_bausteine.py
2026-07-12 16:13:50 +02:00

368 lines
18 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.
"""Deterministische Bausteine: textkit, jsonx, auto_loop, QA-Note."""
import auto_loop
import jsonx
import qa
import textkit
def test_finde_zitat_whitespace_tolerant():
text = "Der Automat\nbesteht aus Zuständen."
span = textkit.finde_zitat(text, "Der Automat besteht aus Zuständen.")
assert span is not None
start, ende = span
assert text[start:ende].split() == ["Der", "Automat", "besteht", "aus", "Zuständen."]
def test_finde_zitat_case_fallback_und_fehlschlag():
assert textkit.finde_zitat("DER AUTOMAT LÄUFT", "der Automat läuft") is not None
assert textkit.finde_zitat("völlig anderer Text", "der Automat läuft") is None
def test_finde_zitat_locker_interpunktion():
# PDF-Extrakte variieren Interpunktion/Dashes/Quotes — Wortlaut gleich → Treffer
text = "Es gilt: „Ein Automat mit Zuständen akzeptiert reguläre Sprachen."
zitat = 'Ein Automat, mit Zuständen, akzeptiert reguläre Sprachen'
span = textkit.finde_zitat(text, zitat)
assert span is not None
assert "Automat" in text[span[0]:span[1]]
# Paraphrase (andere Wörter) bleibt draußen
assert textkit.finde_zitat(text, "Ein DFA mit Zuständen akzeptiert alle Sprachen") is None
# zu kurze Zitate matchen locker nicht (Eindeutigkeit)
assert textkit.finde_zitat("abc def", "ab, c") is None
def test_finde_zitat_dekomponierte_umlaute():
"""Lektion 82: pdftotext liefert dekomponierte Umlaute (a+U+0308), das LLM-Zitat
präkomponierte (ä) — die Locker-Stufe faltet beide auf den Basisbuchstaben.
Vorher scheiterte fast jedes deutsche Zitat (aak: 59/96 Atome ohne Anker)."""
import unicodedata
text = unicodedata.normalize("NFD", "Satz 6.25. 3-SAT ist NP-vollständig. Beweis folgt später.")
span = textkit.finde_zitat(text, "Satz 6.25. 3-SAT ist NP-vollständig.")
assert span is not None
assert text[span[0]:span[1]].startswith("Satz 6.25")
# umgekehrt: präkomponierter Text, dekomponiertes Zitat
assert textkit.finde_zitat(
"Die Erfüllbarkeit boolescher Ausdrücke ist zentral.",
unicodedata.normalize("NFD", "Erfüllbarkeit boolescher Ausdrücke ist zentral")) is not None
def test_snapshot_schreiben_normalisiert_nfc():
"""Korpus-Snapshots werden am Import NFC-normalisiert — die exakte Zitat-Stufe
darf nicht an unsichtbar anderen Bytes scheitern."""
import unicodedata
import korpus
roh = unicodedata.normalize("NFD", "NP-vollständig und erfüllbar")
assert not unicodedata.is_normalized("NFC", roh)
pfad, _h = korpus._snapshot_schreiben("nfc-test", roh)
inhalt = open(pfad, encoding="utf-8").read()
assert unicodedata.is_normalized("NFC", inhalt)
assert inhalt == unicodedata.normalize("NFC", roh)
def test_snapshot_schreiben_ersetzt_kontrollzeichen():
"""PDF-Schriften mappen Sonderglyphen (ε) auf Steuerbytes — der Reader echot sie
als Müll und das Zitat wird unmatchbar. Import ersetzt sie durch Leerzeichen;
\\n und \\t bleiben (Layout)."""
import korpus
pfad, _h = korpus._snapshot_schreiben("ctrl-test", "Algorithmus (A\x0f )\nZeile\tzwei\x07!")
inhalt = open(pfad, encoding="utf-8").read()
assert inhalt == "Algorithmus (A )\nZeile\tzwei !"
def test_finde_zitat_fuzzy_listing_zeilennummern():
"""Stufe 4 (Lektion 83): pdftotext streut Listing-Zeilennummern in den Text,
das Reader-Zitat lässt sie weg — fuzzy mit harter Distanzschranke matcht trotzdem."""
text = ("Algorithmus ListScheduling(L=(J1 , . . . , Jn ),m)\n 1 for i=1 to m do\n"
" 2 Ei = 0; Bi = ∅;\n 3 od\n 4 for j=1 to n do\n"
" 5 wähle i mit Ei minimal;\n 6 od\n")
zitat = ("Algorithmus ListScheduling(L=(J1 , . . . , Jn ),m) for i=1 to m do Ei = 0; "
"Bi = ∅; od for j=1 to n do wähle i mit Ei minimal; od")
span = textkit.finde_zitat(text, zitat)
assert span is not None
assert text[span[0]:span[1]].startswith("Algorithmus ListScheduling")
def test_finde_zitat_fuzzy_rekonstruierte_glyphe():
"""PDF verlor die ε-Glyphe („(A )"), der Reader zitiert „(Aε)" — 1 Zeichen
Differenz auf langem Zitat → Treffer."""
text = ("Man nennt eine solche Familie von Algorithmen (A ) ein vollständiges "
"polynomielles Approximationsschema (FPTAS).")
zitat = ("Man nennt eine solche Familie von Algorithmen (Aε) ein vollständiges "
"polynomielles Approximationsschema (FPTAS).")
assert textkit.finde_zitat(text, zitat) is not None
def test_finde_zitat_fuzzy_mehrdeutig_bleibt_draussen():
"""Eindeutigkeits-Guard: zwei fast identische Definitionen (SubSetSum/Partition-
Muster) → kein Anker statt Falsch-Anker."""
a = "Gegeben: n ganze Zahlen c1 , . . . , cn und eine Zahl K. Entscheide: Gibt es eine Teilmenge S?"
b = "Gegeben: n ganze Zahlen c1 , . . . , cn und eine Zahl X. Entscheide: Gibt es eine Teilmenge T?"
zitat = "Gegeben: n ganze Zahlen c1 , . . . , cn und eine Zahl Q. Entscheide: Gibt es eine Teilmenge U?"
assert textkit.finde_zitat(a + "\n\nDazwischen steht anderer Text.\n\n" + b, zitat) is None
def test_finde_zitat_fuzzy_grenzen():
"""Paraphrase über der Distanzschranke bleibt draußen; Kurz-Zitate nie fuzzy."""
text = "Der List-Scheduling-Algorithmus verteilt Jobs der Reihe nach auf die am wenigsten belastete Maschine."
assert textkit.finde_zitat(text, "List Scheduling weist jeden Job der aktuell "
"günstigsten Maschine in Reihenfolge zu und stoppt") is None
assert textkit.finde_zitat("kurzer Text über ähm Dinge", "kurzer Test über ähm Dinge") is None
def test_snapshot_schreiben_ftfy_mojibake():
"""ftfy am Import: Mojibake und Ligaturen werden repariert (Lektion 83)."""
import importlib.util
import pytest
if importlib.util.find_spec("ftfy") is None:
pytest.skip("ftfy nicht installiert — NFC-Fallback deckt test_snapshot_schreiben_normalisiert_nfc")
import korpus
pfad, _h = korpus._snapshot_schreiben("ftfy-test", "effiziente NP-vollständige Suffixe")
inhalt = open(pfad, encoding="utf-8").read()
assert "vollständige" in inhalt # Mojibake ä → ä
assert "Suffixe" in inhalt # Ligatur ffi → ffi
def test_abschnitte_und_ueberlappung():
text = ("Absatz eins.\n\n" * 50) + ("Absatz zwei.\n\n" * 50)
teile = textkit.abschnitte(text, max_chars=300)
assert all(len(t) <= 300 for _, t in teile)
assert "".join(t for _, t in teile) == text
assert textkit.ueberlappung((0, 10), (5, 15)) == 0.5
assert textkit.ueberlappung((0, 10), (20, 30)) == 0.0
def test_negations_guard():
assert textkit.negations_menge("L ist nicht regulär") != textkit.negations_menge("L ist regulär")
def test_jsonx_fences_und_prosa():
assert jsonx.parse('```json\n{"a": 1}\n```') == {"a": 1}
assert jsonx.parse('Hier das Ergebnis: [{"b": "x — y"}] Danke!') == [{"b": "x — y"}]
assert jsonx.parse("kein json") is None
assert jsonx.parse('{"s": "mit \\"quote\\" und }"}') == {"s": 'mit "quote" und }'}
async def test_auto_loop_bedingungen():
note, grund = await auto_loop.auto_repair_loop("t", 10.0, None)
assert grund == "fertig"
async def stillstand():
return 8.0, False
note, grund = await auto_loop.auto_repair_loop("t", 8.0, stillstand)
assert grund == "stillstand"
zaehler = {"n": 0}
async def livelock():
zaehler["n"] += 1
return 8.0, True # bewegt, aber nie besser → hartes Limit greift
note, grund = await auto_loop.auto_repair_loop("t", 7.0, livelock, max_iter=4)
assert grund == "limit" and zaehler["n"] == 4
def test_qa_note_formel():
assert qa.note([], 10) == 10.0
n = qa.note([{"art": "marker_fehlend", "item": "1", "detail": ""}], 10)
assert n < 10.0
viele = [{"art": "atom_ohne_anker", "item": str(i), "detail": ""} for i in range(50)]
assert 0.0 <= qa.note(viele, 10) <= 9.9
def test_det_auftraege_blockquote_und_artefakt():
import db
import guide
from conftest import topic_anlegen
topic = topic_anlegen("detcheck")
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
b_id = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu")
a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d",
status="neu", baustein_id=b_id, braucht=db.j([]))
lang = (f"<!-- atom: {a} | A -->\n" + "Fließtext. " * 45
+ "\n> wörtliches Rohzitat aus der Quelle\nEs gilt u 6= v."
+ "\n\nDas Blank-Symbol $[. ist speziell und liegt in $[ \\in \\Gamma$.")
kompakt = "- Definition mit x_i und Laufzeit 2^(n/2) als Klartext."
auftraege = guide._det_auftraege(topic, {"id": b_id, "ord": 0}, lang, kompakt)
text = " ".join(auftraege)
assert "Blockquote" in text and "6=" in text
assert "ungerade Anzahl $-Zeichen" in text
assert "Kompakt-Fassung" in text and "x_i" in text # Klartext-Formeln gefangen
def test_det_auftraege_neue_checks():
import db
import guide
from conftest import topic_anlegen
topic = topic_anlegen("detcheck2")
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
b_id = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu")
a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d",
status="neu", baustein_id=b_id, braucht=db.j([]))
wand = "Wort " * 130
lang = (f"<!-- atom: {a} | A -->\n<!-- atom: 99999 | Fremd -->\n"
"Der Beweis folgt aus Satz 6.25, dazu später mehr.\n\n"
+ wand + "\n\n#### Leere Überschrift\n\n#### Noch eine\nText dahinter.\n\n"
"The proof is left as an exercise for the reader and this paragraph"
" should be detected by the language check here.")
kompakt = f"<!-- atom: {a} | A -->\n- Punkt"
text = " ".join(guide._det_auftraege(topic, {"id": b_id, "ord": 0}, lang, kompakt))
assert "gehört nicht zu diesem Baustein" in text # marker_fremd
assert "Vertröstungs-Floskel" in text # „später mehr"
assert "Quellen-Referenz" in text # Satz 6.25
assert "Wörter (Regel: 4090)" in text # Absatz-Wand
assert "ohne folgenden Text" in text # leere Überschrift
assert "englische Passage" in text
assert "Marker gehören nur" in text # Marker in Kompakt-Fassung
# qa=True: Marker/Länge/Vorwärts haben eigene Befund-Arten, Stil-Checks bleiben
qa_text = " ".join(guide._det_auftraege(topic, {"id": b_id, "ord": 0},
lang, kompakt, qa=True))
assert "gehört nicht zu diesem Baustein" not in qa_text
assert "Vertröstungs-Floskel" in qa_text
async def test_fix_fehlschlag_behaelt_befunde(monkeypatch):
import db
import fake_agents
import guide
import llm
from conftest import run_anlegen, topic_anlegen
topic = topic_anlegen("fixfail")
run = run_anlegen(topic)
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
b_id = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu")
db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d",
status="neu", baustein_id=b_id, braucht=db.j([]))
db.insert("sections", baustein_id=b_id, stage="fix",
text_lang="alt", text_kompakt="", befunde=db.j(["Behebe (x): y"]))
# Fix liefert Text OHNE Marker → Marker-Invariante bricht → Fehlschlag-Pfad
monkeypatch.setitem(fake_agents._HANDLER, "Guide-Fix",
lambda p: {"kompakt": "", "lang": "kein Marker"})
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "guide"
stage = await guide._stage_fix(ctx, db.one("SELECT * FROM bausteine WHERE id=?", (b_id,)))
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b_id,))
assert stage == "done" and sec["text_lang"] == "alt"
assert db.uj(sec["befunde"]) == ["Behebe (x): y"] # Aufträge bleiben sichtbar
def test_marker_titel_erzeugen_keine_auftraege():
"""Atom-Titel im Marker (a_n, A*-Suche) dürfen keine Mathe-/Stil-Aufträge
erzeugen — der Fix darf Marker nie ändern, das wäre ein Endlos-Loop."""
import db
import guide
from conftest import topic_anlegen
topic = topic_anlegen("markerimmun")
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
b_id = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu")
a1 = db.insert("atome", topic=topic, titel="Folge a_n", typ="begriff", definition="d",
status="neu", baustein_id=b_id, braucht=db.j([]))
a2 = db.insert("atome", topic=topic, titel="A*-Suche", typ="begriff", definition="d",
status="neu", baustein_id=b_id, braucht=db.j([]))
lang = (f"<!-- atom: {a1} | Folge a_n -->\n<!-- atom: {a2} | A*-Suche -->\n"
+ "Sauberer Fließtext ohne Formeln. " * 12)
joined = " ".join(guide._det_auftraege(topic, {"id": b_id, "ord": 0}, lang, ""))
assert "a_n" not in joined and "A*" not in joined
assert "Formel" not in joined and "$-Zeichen" not in joined and "2^" not in joined
def test_persistieren_dedup_mit_detail():
"""Mehrere Befunde gleicher (art,item) mit verschiedenem detail = je eine Zeile."""
import db
import llm
import qa
from conftest import run_anlegen, topic_anlegen
topic = topic_anlegen("persist")
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")
qa._persistieren(ctx, "guide", [{"art": "det_check", "item": "5", "detail": "A"},
{"art": "det_check", "item": "5", "detail": "B"}])
assert len(db.query("SELECT id FROM befunde WHERE status='offen'")) == 2
qa._persistieren(ctx, "guide", [{"art": "det_check", "item": "5", "detail": "A"}])
stat = {(b["detail"], b["status"]) for b in db.query("SELECT detail, status FROM befunde")}
assert ("A", "offen") in stat and ("B", "repariert") in stat
async def test_fix_offen_in_messen():
"""Gescheiterter Fix (stage done, Rest-Aufträge) passiert das Gate nicht mehr."""
import db
import guide
import llm
from conftest import run_anlegen, topic_anlegen
topic = topic_anlegen("fixoffen")
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
b_id = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu")
a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d",
status="neu", baustein_id=b_id, braucht=db.j([]))
lang = f"<!-- atom: {a} | A -->\n" + "Wort " * 60
db.insert("sections", baustein_id=b_id, stage="done", text_lang=lang,
text_kompakt="- p", befunde=db.j(["KRITISCH (luecke): Atom-Inhalt fehlt"]))
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")
ctx.ebene = "guide"
assert "fix_offen" in {b["art"] for b in guide.messen(ctx)}
async def test_reparieren_bewegt_false_bei_identischem_text(monkeypatch):
"""Fix ändert den Text nicht → reparieren meldet ehrlich False (Stillstand)."""
import db
import fake_agents
import guide
import llm
from conftest import run_anlegen, topic_anlegen
topic = topic_anlegen("bewegt")
run = run_anlegen(topic)
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
b_id = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu")
a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d",
status="neu", baustein_id=b_id, braucht=db.j([]))
lang = f"<!-- atom: {a} | A -->\n" + "Wort " * 60
db.insert("sections", baustein_id=b_id, stage="fix", text_lang=lang,
text_kompakt="- p", befunde=db.j(["Behebe (laenge): kürzen"]))
monkeypatch.setitem(fake_agents._HANDLER, "Guide-Fix",
lambda p: {"kompakt": "- p", "lang": lang}) # identisch
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "guide"
bewegt = await guide.reparieren(
ctx, [{"art": "det_check", "item": str(b_id), "detail": "kürzen"}])
assert bewegt is False
def test_stil_kein_fehlalarm_deutsch():
"""Deutscher Satz mit Homographen an/will/Not → keine „englische Passage"."""
import guide
text = ("Wir passen die Kurve an und lesen den Wert an der Stelle ab, denn der"
" Algorithmus will nicht in Not geraten und braucht das an dieser Stelle.")
assert not any("englische Passage" in x for x in guide._stil_auftraege(text, "Langtext"))
def test_pruefe_dich_norm_match():
"""Frage-Matching ist Case/Whitespace-tolerant (Fix darf umformatieren)."""
import db
import guide
from conftest import topic_anlegen
topic = topic_anlegen("pruefe")
ziel = db.insert("lernziele", topic=topic, text="Z", status="aktiv")
b_id = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu")
a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d",
status="neu", baustein_id=b_id, braucht=db.j([]))
db.insert("artefakte", atom_id=a, typ="flashcard", status="verifiziert",
inhalt=db.j({"frage": "Was ist ein Graph?", "antwort": "Knoten und Kanten."}))
fragen = guide._pruefe_dich(b_id, "Prüfe dich: Was ist ein Graph?")
assert len(fragen) == 1 and fragen[0]["antwort"] == "Knoten und Kanten."
def test_katex_gate_und_markdown_hygiene(monkeypatch):
import guide
monkeypatch.delenv("CREATOR_FAKE_AGENTS", raising=False) # Gate echt laufen lassen
# Parse-Gate: echte KaTeX-Prüfung (nutzt node + frontend/node_modules)
fehler = guide._katex_fehler([{"tex": "\\undefinedmacro{x}", "display": False},
{"tex": "x^2", "display": False},
{"tex": "\\textsc{Subset Sum}", "display": False}])
assert len(fehler) == 1 and "undefinedmacro" in fehler[0] # \textsc ist per Makro ok
# Markdown-Hygiene: Beträge, Einzel-Stern, literales \n
auftraege = " ".join(guide._mathe_auftraege(
"Es gilt |C*| und *wichtig* laut Text.\\n- Punkt. Aber $\\neq$ bleibt ok.", "Test"))
assert "Beträge" in auftraege
assert "einzelnes „*“" in auftraege
assert "literales" in auftraege
lang_formel = "Es gilt $" + "x + ".join(["y"] * 30) + "$ hier."
assert "überlange Inline-Formel" in " ".join(guide._mathe_auftraege(lang_formel, "T"))