447 lines
22 KiB
Python
447 lines
22 KiB
Python
"""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: 40–90)" 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, braucht=db.j([]))
|
||
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, 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_auftrag_offen_in_messen():
|
||
"""Gescheiterter Fix (stage done, offene Aufträge) passiert das Gate nicht mehr;
|
||
eskalierte falsch/luecke-Aufträge werden als fakten_konflikt sichtbar."""
|
||
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")
|
||
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")
|
||
db.insert("auftraege", baustein_id=b_id, art="stil", detail="zäh",
|
||
status="eskaliert") # eskalierter Stil: stumm (bewusste Rest-Schuld)
|
||
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("fakten_konflikt") == 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, braucht=db.j([]))
|
||
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, braucht=db.j([]))
|
||
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, braucht=db.j([]))
|
||
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
|