Files
creator2/tests/test_bausteine.py
2026-07-14 00:15:03 +02:00

539 lines
27 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)
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)
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_auftraege(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)
db.insert("sections", baustein_id=b_id, stage="fix", text_lang="alt", text_kompakt="")
au = db.insert("auftraege", baustein_id=b_id, art="falsch", detail="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"
# Auftrag bleibt offen und sichtbar — kein unsichtbar gescheiterter Fix
assert db.one("SELECT status FROM auftraege WHERE id=?", (au,))["status"] == "offen"
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)
a2 = db.insert("atome", topic=topic, titel="A*-Suche", typ="begriff", definition="d",
status="neu", baustein_id=b_id)
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_auftrag_offen_in_messen():
"""Gescheiterter Fix (stage done, offene Aufträge) passiert das Gate nicht mehr;
eskalierte dreiteilig: ungeklärt → klaerung_offen, geklärt → fakten_konflikt,
Stil → stil_rest (war vorher stumm)."""
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)
lang = f"<!-- atom: {a} | A -->\n" + "Wort " * 60
db.insert("sections", baustein_id=b_id, stage="done", text_lang=lang, text_kompakt="- p")
db.insert("auftraege", baustein_id=b_id, art="luecke", detail="Atom-Inhalt fehlt")
db.insert("auftraege", baustein_id=b_id, art="falsch", detail="Widerspruch",
status="eskaliert") # geklaert=0 → wartet auf Stichentscheid
db.insert("auftraege", baustein_id=b_id, art="falsch", detail="Unauflösbar",
status="eskaliert", geklaert=1)
db.insert("auftraege", baustein_id=b_id, art="stil", detail="zäh",
status="eskaliert")
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")
ctx.ebene = "guide"
arten = [b["art"] for b in guide.messen(ctx)]
assert arten.count("auftrag_offen") == 1
assert arten.count("klaerung_offen") == 1
assert arten.count("fakten_konflikt") == 1
assert arten.count("stil_rest") == 1
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)
lang = f"<!-- atom: {a} | A -->\n" + ("Wort " * 60).strip() # ohne Trailing-Space
db.insert("sections", baustein_id=b_id, stage="fix", text_lang=lang,
text_kompakt="- p")
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_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"))
def test_split_writer_delimited_und_fallback():
import guide
# delimited: roher LaTeX-Backslash bleibt (kein JSON-Escaping)
k, l = guide._split_writer("===LANG===\nText $a \\leq b$\n===KOMPAKT===\n- p")
assert l == "Text $a \\leq b$" and k == "- p"
# umgekehrte Reihenfolge der Blöcke
k2, l2 = guide._split_writer("===KOMPAKT===\n- x\n===LANG===\nY")
assert l2 == "Y" and k2 == "- x"
# Fallback: altes JSON
k3, l3 = guide._split_writer('{"kompakt": "- a", "lang": "b"}')
assert l3 == "b" and k3 == "- a"
# Fallback: nackter Text → alles lang
assert guide._split_writer("nur text")[1] == "nur text"
def test_jsonx_repariert_latex_escapes():
# rohes LaTeX im JSON-String (\i = ungültiges Escape) → Reparatur parst
assert jsonx.parse('[{"a": "$\\in \\Sigma$"}]') == [{"a": "$\\in \\Sigma$"}]
# \u ohne 4 Hex-Ziffern (\underline) zählt als ungültig → repariert
assert jsonx.parse('{"m": "\\underline{x}"}') == {"m": "\\underline{x}"}
# gültige Escapes bleiben unberührt (Reparatur nur im Fehlerfall)
assert jsonx.parse('{"x": "a\\nb"}') == {"x": "a\nb"}
assert jsonx.parse('{"u": "\\u00e4"}') == {"u": "ä"}
# echter Müll bleibt None
assert jsonx.parse("kein json hier") is None
def test_writer_saeubern_terminator():
import guide
# geleakter `=====`-Terminator (Setext-H1 unter Bullet) + </s> raus
roh = "- $M$ gilt\n\n$$L \\in NP$$\n- Für jedes $L$ gilt\n=====\n</s>"
sauber = guide._writer_saeubern(roh)
assert "=====" not in sauber and "</s>" not in sauber
assert sauber.endswith("- Für jedes $L$ gilt")
# _split_writer säubert den kompakt-Block (letzter Block fängt sonst das =====)
k, l = guide._split_writer("===LANG===\nText\n===KOMPAKT===\n- a\n- b\n=====\n</s>")
assert k == "- a\n- b" and l == "Text"
# Tabellen-Trenner (---) bleibt unangetastet
assert "---" in guide._writer_saeubern("| A | B |\n|---|---|\n| 1 | 2 |")
async def test_fix_cap_friert_section_ein(monkeypatch):
"""Nach FIX_MAX_VERSUCHE erfolglosen Fixes (Text unverändert) wird die Section
für Stil-Befunde eingefroren → reparieren meldet bewegt=False (Stillstand)."""
import db
import fake_agents
import guide
import llm
from conftest import run_anlegen, topic_anlegen
topic = topic_anlegen("fixcap")
run = run_anlegen(topic)
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
b = 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)
lang = f"<!-- atom: {a} | A -->\n" + "Wort " * 60
db.insert("sections", baustein_id=b, stage="fix", text_lang=lang, text_kompakt="- p",
fix_versuche=guide.FIX_MAX_VERSUCHE) # schon am Cap
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "guide"
bewegt = await guide.reparieren(
ctx, [{"art": "det_check", "item": str(b), "detail": "Kürzen"}])
assert bewegt is False # eingefroren → nicht geroutet → kein Fortschritt
async def test_marker_platzieren_deterministisch():
"""Der Writer setzt keine Beispiel-Marker mehr — _marker_platzieren fügt sie
hinter den Atom-Absatz ein (atom-genau)."""
import db
import guide
from conftest import topic_anlegen
topic = topic_anlegen("platz")
b = db.insert("bausteine", topic=topic, ziel_id=1, titel="B", ord=0, status="neu")
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
status="neu", baustein_id=b, ord=0)
art = db.insert("artefakte", atom_id=a, typ="beispiel", status="verifiziert",
inhalt=db.j({"form": "tabelle", "tabelle": "| A | B |\n|---|---|\n| 1 | 2 |"}))
lang = f"#### X\n<!-- atom: {a} | X -->\nErklärung von X.\n\nWeiterer Absatz zum Thema."
out = guide._marker_platzieren(db.one("SELECT * FROM bausteine WHERE id=?", (b,)), lang)
assert f"<!-- beispiel: {art} -->" in out
# der Marker steht NACH dem Atom-Absatz
assert out.index("<!-- beispiel:") < out.index("Weiterer Absatz")
# idempotent: zweiter Lauf dupliziert nicht
assert guide._marker_platzieren(db.one("SELECT * FROM bausteine WHERE id=?", (b,)),
out).count("<!-- beispiel:") == 1
def test_finde_zitat_latex_gerendert():
"""tex-Quellen: das Modell zitiert die gerenderte Lesart (k ∈ ), die Quelle
trägt Markup ($k \\in \\mathbb{N}$) — die Faltung macht beide gleich."""
text = ("Für das \\textsc{VertexCover} Problem ist neben einem Graphen $G$ eine"
" Zahl $k \\in \\mathbb{N}_{\\geq 0}$ gegeben und es wird gefragt, ob ein"
" Vertex Cover mit Größe höchstens $k$ in $G$ existiert.")
zitat = ("Für das VertexCover Problem ist neben einem Graphen G eine Zahl k ∈ "
" gegeben und es wird gefragt, ob ein Vertex Cover mit Größe höchstens k"
" in G existiert.")
span = textkit.finde_zitat(text, zitat)
assert span is not None and text[span[0]:span[1]].startswith("Für das")
# griechische Befehle ↔ Unicode-Buchstaben
text2 = ("Ein Wort $w \\in \\Sigma^*$ über dem Alphabet $\\Sigma$ ist eine"
" endliche Folge von Zeichen aus dem Alphabet.")
zitat2 = ("Ein Wort w ∈ Σ* über dem Alphabet Σ ist eine endliche Folge von"
" Zeichen aus dem Alphabet.")
assert textkit.finde_zitat(text2, zitat2) is not None
# LaTeX-Zitat gegen LaTeX-Quelle bleibt symmetrisch matchbar
assert textkit.finde_zitat(text, "Zahl $k \\in \\mathbb{N}_{\\geq 0}$ gegeben und"
" es wird gefragt, ob ein Vertex Cover") is not None
def test_finde_zitat_kurz_nur_bei_eindeutigkeit():
"""Kurze Zitate (gefaltet 1019 Zeichen) matchen nur bei GENAU EINEM
Vorkommen — Eindeutigkeit direkt geprüft statt Proxy-Länge."""
text = ("Vorher steht Text. Definiere $\\alpha_{\\text{Anfang}} = z_{00} \\wedge"
" s_{00}$ als Startbelegung. Nachher steht mehr.")
zitat = "α_Anfang = z_00 ∧ s_00" # gerendert, gefaltet 13 Zeichen
span = textkit.finde_zitat(text, zitat)
assert span is not None and "alpha" in text[span[0]:span[1]]
# zweites Vorkommen → mehrdeutig → None
assert textkit.finde_zitat(
text + " Wieder $\\alpha_{\\text{Anfang}} = z_{00} \\wedge s_{00}$.", zitat) is None
# unter 10 gefalteten Zeichen: nie
assert textkit.finde_zitat("Es gilt x=y hier.", "x = y") is None
def test_jsonx_repariert_zeilenumbruch_in_string():
# rohes mehrzeiliges Zitat (Markup-Zitat-Regel) → Kontrollzeichen-Reparatur
assert jsonx.parse('[{"zitat": "\\\\item Zeile eins\nZeile zwei"}]') == [
{"zitat": "\\item Zeile eins\nZeile zwei"}]
assert jsonx.parse('{"a": "x\n\ny\tz"}') == {"a": "x\n\ny\tz"}
# intaktes JSON bleibt unverändert
assert jsonx.parse('{"a": "x\\nb"}') == {"a": "x\nb"}
def test_finde_zitat_ueber_listenstart():
"""\\begin{itemize} darf kein „itemize"-Residuum in der Faltung lassen."""
text = ("Ein Algorithmus löst das Problem, wenn für alle $x \\in U$ gilt:\n"
"\\begin{itemize}\n\\item $x \\in L$ impliziert $A(x) = 1$,\n"
"\\item $x \\notin L$ impliziert $A(x) = 0$.\n\\end{itemize}")
zitat = ("Ein Algorithmus löst das Problem, wenn für alle x ∈ U gilt: x ∈ L"
" impliziert A(x) = 1, x ∉ L impliziert A(x) = 0.")
assert textkit.finde_zitat(text, zitat) is not None
def test_finde_zitat_wortalignment_verbalisierung():
"""Stufe 5: Modell verbalisiert Mathe (\\cdot → „mal", \\sqrt → sqrt()) —
auf Wort-Ebene wenige Edits → Treffer trotz Zeichen-Chaos."""
text = ("Angenommen es gäbe einen Algorithmus, der VertexCover in "
"$2^{o(|V'|)} \\cdot |I|^{O(1)}$ löst. Durch die Kombination der "
"Reduktionen entsteht ein Widerspruch zur ETH und damit die Aussage.")
zitat = ("Angenommen es gäbe einen Algorithmus, der VertexCover in "
"2^{o(|V'|)} mal |I|^{O(1)} löst. Durch die Kombination der "
"Reduktionen entsteht ein Widerspruch zur ETH und damit die Aussage.")
span = textkit.finde_zitat(text, zitat)
assert span is not None and text[span[0]:span[1]].startswith("Angenommen")
def test_finde_zitat_wortalignment_paraphrase_bleibt_draussen():
"""Echte Umformulierung: Wörter selbst anders → Wort-Distanz über Schranke."""
text = ("Dann wird geprüft, ob $|X| \\leq k$ gilt und es wird abgelehnt, "
"falls es nicht gilt. Danach wird ein Graph konstruiert, in dem alle "
"Knoten entfernt wurden und die Suche neu beginnt.")
zitat = ("Prüfe, ob |X| ≤ k, sonst ablehnen. Konstruiere den Graphen ohne "
"diese Knoten und starte die Suche von vorn im Verfahren.")
assert textkit.finde_zitat(text, zitat) is None
def test_finde_zitat_wortalignment_negations_guard():
text = ("Die Sprache ist in diesem Modell regulär und wird von einem "
"endlichen Automaten mit wenigen Zuständen ohne Keller akzeptiert.")
zitat = ("Die Sprache ist in diesem Modell nicht regulär und wird von einem "
"endlichen Automaten mit wenigen Zuständen ohne Keller akzeptiert.")
assert textkit.finde_zitat(text, zitat) is None