init
This commit is contained in:
33
tests/conftest.py
Normal file
33
tests/conftest.py
Normal file
@@ -0,0 +1,33 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "backend"))
|
||||
|
||||
import db # noqa: E402
|
||||
import embedding # noqa: E402
|
||||
import korpus # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fake_welt(tmp_path, monkeypatch):
|
||||
"""Jeder Test: frische In-Memory-DB, Fake-Agenten, Korpus/Topics in tmp.
|
||||
Embedding fest auf Jaccard-Fallback — kein Modell-Download, deterministisch."""
|
||||
monkeypatch.setenv("CREATOR_FAKE_AGENTS", "1")
|
||||
monkeypatch.setattr(embedding, "_geladen", True)
|
||||
monkeypatch.setattr(embedding, "_modell", None)
|
||||
monkeypatch.setattr(korpus, "KORPUS_DIR", tmp_path / "korpus")
|
||||
monkeypatch.setattr(korpus, "TOPICS_DIR", tmp_path / "topics")
|
||||
db.on_change = None
|
||||
db.reset_for_tests(":memory:")
|
||||
yield tmp_path
|
||||
|
||||
|
||||
def topic_anlegen(name="fake-thema", art="thema"):
|
||||
db.insert("topics", name=name, titel=name, art=art, provider="minimax")
|
||||
return name
|
||||
|
||||
|
||||
def run_anlegen(topic, budget=0):
|
||||
return db.insert("runs", topic=topic, status="running", budget_tokens=budget)
|
||||
44
tests/test_artefakte.py
Normal file
44
tests/test_artefakte.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Ebene 2: Quellen-Referenz-Guard — Karten müssen ohne den Quelltext
|
||||
funktionieren (Lauf 8: 17 verifizierte Karten fragten „was steht im Beleg")."""
|
||||
|
||||
import artefakte
|
||||
import db
|
||||
import llm
|
||||
from conftest import run_anlegen, topic_anlegen
|
||||
|
||||
|
||||
def test_referenz_muster_trifft_nur_referenzen():
|
||||
schlecht = [
|
||||
{"frage": "Welchen Namen trägt die Technik, die im Beleg erwähnt wird?",
|
||||
"antwort": "k-Enumeration.", "text": ""},
|
||||
{"frage": "Was ist MAX-3-SAT?",
|
||||
"antwort": "Aus dem Beleg geht nur hervor, dass es in Hausaufgabe 13.1 vorkommt.",
|
||||
"text": ""},
|
||||
{"frage": "Welche Aufgabe wird in Aufgabe 1 gestellt?", "antwort": "x", "text": ""},
|
||||
{"frage": "Wie groß ist |V'|?", "antwort": "Laut Musterlösung genau |V|.", "text": ""},
|
||||
]
|
||||
gut = [ # Fachwörter dürfen NICHT matchen (generisch bleiben)
|
||||
{"frage": "Was ist eine aussagenlogische Belegung?",
|
||||
"antwort": "Eine Zuordnung von Wahrheitswerten zu Variablen.", "text": ""},
|
||||
{"frage": "Was verbindet ein Fluss mit Quelle und Senke?",
|
||||
"antwort": "Er belegt jede Kante mit einem Wert unter der Kapazität.", "text": ""},
|
||||
]
|
||||
assert all(artefakte._referenziert_quelle(k) for k in schlecht)
|
||||
assert not any(artefakte._referenziert_quelle(k) for k in gut)
|
||||
|
||||
|
||||
async def test_guard_verwirft_kandidat_und_repair_kann_nachlegen():
|
||||
topic = topic_anlegen("guard")
|
||||
run = run_anlegen(topic)
|
||||
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
|
||||
status="neu", braucht=db.j([]))
|
||||
k = db.insert("artefakte", atom_id=a, typ="flashcard", status="kandidat",
|
||||
inhalt=db.j({"frage": "Was steht im Beleg?", "antwort": "x", "text": ""}))
|
||||
ctx = llm.Kontext(run, topic, "minimax")
|
||||
ctx.ebene = "artefakte"
|
||||
atom = db.one("SELECT * FROM atome WHERE id=?", (a,))
|
||||
await artefakte._verifizieren(ctx, [atom]) # Guard greift VOR dem Panel
|
||||
assert db.one("SELECT status FROM artefakte WHERE id=?", (k,))["status"] == "verworfen"
|
||||
# verworfene zählen nicht als versorgt: Repair generiert für das Atom nach
|
||||
chunks = artefakte._chunks(topic, nur_ohne=True)
|
||||
assert [x["id"] for c in chunks for x in c] == [a]
|
||||
187
tests/test_bausteine.py
Normal file
187
tests/test_bausteine.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""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 | E -->\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$.")
|
||||
auftraege = guide._det_auftraege(topic, {"id": b_id, "ord": 0}, lang)
|
||||
text = " ".join(auftraege)
|
||||
assert "Blockquote" in text and "6=" in text
|
||||
assert "ungerade Anzahl $-Zeichen" in text
|
||||
54
tests/test_budget_infra.py
Normal file
54
tests/test_budget_infra.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Budget-Stopp (hart) und Infra-Fail-closed (Lauf-Pause statt Teilergebnis)."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import agents
|
||||
import ledger
|
||||
import llm
|
||||
import pytest
|
||||
from conftest import run_anlegen, topic_anlegen
|
||||
|
||||
|
||||
async def test_budget_stoppt_hart(monkeypatch):
|
||||
topic = topic_anlegen()
|
||||
run = run_anlegen(topic, budget=120) # Fake-Call kostet 150 Tokens
|
||||
ctx = llm.Kontext(run, topic, "minimax")
|
||||
ctx.ebene = "test"
|
||||
with pytest.raises(ledger.BudgetErschoepft):
|
||||
await llm.call(ctx, stage="soll", template="Korpus-Soll",
|
||||
werte={"topic": topic, "quelle": "q", "text": "x"}, erwartet=list)
|
||||
assert ledger.verbraucht(run) >= 120 # der Verbrauch bleibt sichtbar
|
||||
|
||||
|
||||
async def test_infra_pause_statt_fail_open(monkeypatch):
|
||||
topic = topic_anlegen()
|
||||
run = run_anlegen(topic)
|
||||
ctx = llm.Kontext(run, topic, "minimax")
|
||||
ctx.ebene = "test"
|
||||
|
||||
async def immer_429(key, prompt, timeout, **kw):
|
||||
return agents.AgentErgebnis(1, "", "HTTP 429: rate limited")
|
||||
|
||||
monkeypatch.setattr(agents, "run_agent", immer_429)
|
||||
monkeypatch.setattr(llm, "INFRA_BACKOFF_BASE", 0.01)
|
||||
with pytest.raises(llm.LaufPause):
|
||||
await llm.call(ctx, stage="soll", template="Korpus-Soll",
|
||||
werte={"topic": topic, "quelle": "q", "text": "x"}, erwartet=list)
|
||||
stati = [e["status"] for e in
|
||||
__import__("db").query("SELECT status FROM events WHERE run_id=?", (run,))]
|
||||
assert stati and all(s == "infra" for s in stati) # jeder Versuch im Ledger
|
||||
|
||||
|
||||
async def test_inhaltsfehler_kein_laufabbruch(monkeypatch):
|
||||
topic = topic_anlegen()
|
||||
run = run_anlegen(topic)
|
||||
ctx = llm.Kontext(run, topic, "minimax")
|
||||
ctx.ebene = "test"
|
||||
|
||||
async def unsinn(key, prompt, timeout, **kw):
|
||||
return agents.AgentErgebnis(0, "kein json", "")
|
||||
|
||||
monkeypatch.setattr(agents, "run_agent", unsinn)
|
||||
res = await llm.call(ctx, stage="soll", template="Korpus-Soll",
|
||||
werte={"topic": topic, "quelle": "q", "text": "x"}, erwartet=list)
|
||||
assert res is None # begrenzte Restarts, dann None — kein Absturz, keine Pause
|
||||
167
tests/test_e2e.py
Normal file
167
tests/test_e2e.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""Fake-E2E: kompletter Generierungspfad in Sekunden. Thema (Web-Recherche) und
|
||||
Uni (Dateien), plus Resume-Idempotenz."""
|
||||
|
||||
import asyncio
|
||||
import itertools
|
||||
|
||||
import db
|
||||
import guide
|
||||
import korpus
|
||||
import pipeline
|
||||
from conftest import topic_anlegen
|
||||
from fake_agents import FAKE_TEXT
|
||||
|
||||
|
||||
async def _lauf_komplett(topic):
|
||||
run_id = pipeline.lauf_starten(topic)
|
||||
await pipeline._laeufe[topic]
|
||||
return run_id
|
||||
|
||||
|
||||
def _pruefe_endzustand(topic, run_id):
|
||||
assert db.one("SELECT status FROM topics WHERE name=?", (topic,))["status"] == "fertig"
|
||||
run = db.one("SELECT * FROM runs WHERE id=?", (run_id,))
|
||||
assert run["status"] == "done"
|
||||
offen = db.query("SELECT * FROM befunde WHERE run_id=? AND status='offen'", (run_id,))
|
||||
assert offen == [], f"offene Befunde: {offen}"
|
||||
atome = db.query("SELECT * FROM atome WHERE topic=? AND status NOT IN"
|
||||
" ('gemerged','verworfen')", (topic,))
|
||||
assert len(atome) == 3 # die Fake-Welt hat exakt drei Atome
|
||||
assert all(a["soll_id"] and a["ziel_id"] and a["baustein_id"] for a in atome)
|
||||
for a in atome:
|
||||
assert db.one("SELECT id FROM anker WHERE atom_id=?", (a["id"],))
|
||||
karten = db.query("SELECT * FROM artefakte WHERE atom_id=? AND typ='flashcard'"
|
||||
" AND status='verifiziert'", (a["id"],))
|
||||
assert karten
|
||||
md = guide.guide_markdown(topic)
|
||||
for a in atome:
|
||||
assert f"<!-- atom: {a['id']} |" in md
|
||||
# Kapitel-Struktur: H2 = Kapitel (Struktur-Ebene, mit Intro), H3 = Baustein
|
||||
assert "\n## " in "\n" + md and "### " in md
|
||||
assert "**Kernpunkte:**" in md and "**Prüfe dich:**" in md
|
||||
kaps = db.query("SELECT * FROM kapitel WHERE topic=? ORDER BY ord", (topic,))
|
||||
assert kaps, "keine Kapitel gebildet"
|
||||
for k in kaps:
|
||||
assert k["intro"], f"Kapitel-Intro fehlt: {k['titel']}"
|
||||
bausteine = db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (topic,))
|
||||
assert all(b["kapitel_id"] for b in bausteine)
|
||||
folge = [b["kapitel_id"] for b in bausteine]
|
||||
segmente = [k for k, _ in itertools.groupby(folge)]
|
||||
assert len(segmente) == len(set(segmente)) == len(kaps) # kontiguierlich, lückenlos
|
||||
for b in db.query("SELECT titel FROM bausteine WHERE topic=?", (topic,)):
|
||||
assert not b["titel"].startswith("Kann"), b["titel"] # Kurztitel, kein Ziel-Satz
|
||||
events = db.query("SELECT * FROM events WHERE run_id=?", (run_id,))
|
||||
assert events and all(e["template_hash"] for e in events if e["template"])
|
||||
|
||||
|
||||
async def test_e2e_thema():
|
||||
topic = topic_anlegen("fake-thema", art="thema")
|
||||
run_id = await _lauf_komplett(topic)
|
||||
_pruefe_endzustand(topic, run_id)
|
||||
# zwei kleine Ziele → Merge über Soll-Grenzen (Entkopplung): EIN Baustein
|
||||
bausteine = db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (topic,))
|
||||
assert len(bausteine) == 1
|
||||
|
||||
|
||||
async def test_e2e_uni(tmp_path):
|
||||
ordner = korpus.TOPICS_DIR / "fake-uni"
|
||||
ordner.mkdir(parents=True)
|
||||
(ordner / "skript.txt").write_text(FAKE_TEXT, encoding="utf-8")
|
||||
(ordner / "uebung.txt").write_text(FAKE_TEXT + "\nSerie 1.\n", encoding="utf-8")
|
||||
topic = topic_anlegen("fake-uni", art="uni")
|
||||
run_id = await _lauf_komplett(topic)
|
||||
_pruefe_endzustand(topic, run_id)
|
||||
quellen = db.query("SELECT * FROM quellen WHERE topic=?", (topic,))
|
||||
assert len(quellen) == 2 and all(q["art"] == "datei" for q in quellen)
|
||||
rollen = {q["titel"]: q["rolle"] for q in quellen}
|
||||
assert rollen["skript.txt"] == "stoff" and rollen["uebung.txt"] == "aufgaben"
|
||||
|
||||
|
||||
async def test_voll_reset_extrahiert_neu():
|
||||
topic = topic_anlegen("fake-vollreset", art="thema")
|
||||
run1 = await _lauf_komplett(topic)
|
||||
_pruefe_endzustand(topic, run1)
|
||||
pipeline.voll_reset(topic)
|
||||
assert db.query("SELECT * FROM atome WHERE topic=?", (topic,)) == []
|
||||
assert db.query("SELECT * FROM artefakte WHERE atom_id IN"
|
||||
" (SELECT id FROM atome WHERE topic=?)", (topic,)) == []
|
||||
run2 = await _lauf_komplett(topic)
|
||||
_pruefe_endzustand(topic, run2) # kompletter Neuaufbau inkl. neuer Atome
|
||||
|
||||
|
||||
async def test_soll_reset_kein_doppel():
|
||||
topic = topic_anlegen("fake-reset", art="thema")
|
||||
run1 = await _lauf_komplett(topic)
|
||||
_pruefe_endzustand(topic, run1)
|
||||
atome_vorher = {a["id"] for a in db.query(
|
||||
"SELECT id FROM atome WHERE topic=? AND status NOT IN ('gemerged','verworfen')", (topic,))}
|
||||
|
||||
pipeline.soll_reset(topic)
|
||||
assert db.query("SELECT * FROM soll WHERE topic=?", (topic,)) == []
|
||||
run2 = await _lauf_komplett(topic)
|
||||
_pruefe_endzustand(topic, run2)
|
||||
atome_nachher = {a["id"] for a in db.query(
|
||||
"SELECT id FROM atome WHERE topic=? AND status NOT IN ('gemerged','verworfen')", (topic,))}
|
||||
assert atome_nachher == atome_vorher # Extraktions-Guard: keine Doppel-Atome
|
||||
|
||||
|
||||
async def test_auto_stopp_und_fortsetzen():
|
||||
topic = topic_anlegen("fake-auto", art="thema")
|
||||
db.update("topics", "name", topic, auto=db.j({"inventar": False}))
|
||||
run1 = await _lauf_komplett(topic)
|
||||
assert db.one("SELECT status FROM topics WHERE name=?", (topic,))["status"] == "inventar_fertig"
|
||||
r = db.one("SELECT * FROM runs WHERE id=?", (run1,))
|
||||
assert r["status"] == "done" and "Auto-Stopp" in r["grund"]
|
||||
db.update("topics", "name", topic, auto=db.j({})) # Auto wieder an → durchlaufen
|
||||
run2 = await _lauf_komplett(topic)
|
||||
_pruefe_endzustand(topic, run2)
|
||||
|
||||
|
||||
async def test_ebenen_entfernen_kaskade():
|
||||
topic = topic_anlegen("fake-entfernen", art="thema")
|
||||
run1 = await _lauf_komplett(topic)
|
||||
_pruefe_endzustand(topic, run1)
|
||||
atome_vorher = {a["id"] for a in db.query(
|
||||
"SELECT id FROM atome WHERE topic=? AND status NOT IN ('gemerged','verworfen')", (topic,))}
|
||||
pipeline.ebenen_entfernen(topic, "struktur")
|
||||
assert db.query("SELECT * FROM bausteine WHERE topic=?", (topic,)) == []
|
||||
assert db.query("SELECT * FROM kapitel WHERE topic=?", (topic,)) == []
|
||||
assert db.query("SELECT * FROM lernziele WHERE topic=?", (topic,)) == []
|
||||
assert db.one("SELECT status FROM topics WHERE name=?", (topic,))["status"] == "artefakte_fertig"
|
||||
# Anzeige-Reset: Token-Zählung der entfernten Ebenen beginnt neu (Events bleiben)
|
||||
resets = db.uj(db.one("SELECT resets FROM topics WHERE name=?", (topic,))["resets"], {})
|
||||
assert set(resets) == {"struktur", "guide"}
|
||||
assert resets["struktur"] == db.one("SELECT MAX(id) AS m FROM events")["m"]
|
||||
run2 = await _lauf_komplett(topic)
|
||||
_pruefe_endzustand(topic, run2)
|
||||
atome_nachher = {a["id"] for a in db.query(
|
||||
"SELECT id FROM atome WHERE topic=? AND status NOT IN ('gemerged','verworfen')", (topic,))}
|
||||
assert atome_nachher == atome_vorher # Atome blieben erhalten
|
||||
|
||||
|
||||
async def test_topic_loeschen():
|
||||
topic = topic_anlegen("fake-weg", art="thema")
|
||||
run1 = await _lauf_komplett(topic)
|
||||
_pruefe_endzustand(topic, run1)
|
||||
pipeline.topic_loeschen(topic)
|
||||
for tabelle in ("topics", "quellen", "soll", "atome", "lernziele", "bausteine",
|
||||
"kapitel", "runs"):
|
||||
feld = "name" if tabelle == "topics" else "topic"
|
||||
assert db.query(f"SELECT * FROM {tabelle} WHERE {feld}=?", (topic,)) == [], tabelle
|
||||
|
||||
|
||||
async def test_resume_idempotent():
|
||||
topic = topic_anlegen("fake-resume", art="thema")
|
||||
run1 = await _lauf_komplett(topic)
|
||||
_pruefe_endzustand(topic, run1)
|
||||
atome_vorher = {a["id"] for a in db.query(
|
||||
"SELECT id FROM atome WHERE topic=? AND status NOT IN ('gemerged','verworfen')", (topic,))}
|
||||
calls_vorher = len(db.query("SELECT id FROM events", ()))
|
||||
|
||||
run2 = pipeline.lauf_starten(topic) # zweiter Lauf auf fertigem Topic
|
||||
await pipeline._laeufe[topic]
|
||||
assert db.one("SELECT status FROM runs WHERE id=?", (run2,))["status"] == "done"
|
||||
atome_nachher = {a["id"] for a in db.query(
|
||||
"SELECT id FROM atome WHERE topic=? AND status NOT IN ('gemerged','verworfen')", (topic,))}
|
||||
assert atome_nachher == atome_vorher # nichts dupliziert
|
||||
assert len(db.query("SELECT id FROM events", ())) == calls_vorher # keine neuen LLM-Calls
|
||||
94
tests/test_inventar.py
Normal file
94
tests/test_inventar.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""Anker-Repair: unverankerte Zitate (start=-1) werden erst deterministisch
|
||||
neu gematcht — ohne LLM-Call."""
|
||||
|
||||
import db
|
||||
import inventar
|
||||
import llm
|
||||
from conftest import run_anlegen, topic_anlegen
|
||||
|
||||
|
||||
def test_merge_kollision_und_kette():
|
||||
topic = topic_anlegen("mergekante")
|
||||
a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="lang genug",
|
||||
status="neu", braucht=db.j([]))
|
||||
b = db.insert("atome", topic=topic, titel="B", typ="begriff", definition="kurz",
|
||||
status="neu", braucht=db.j([]))
|
||||
c = db.insert("atome", topic=topic, titel="C", typ="begriff", definition="x",
|
||||
status="neu", braucht=db.j([]))
|
||||
# beide haben dieselbe Kante zu C → blindes Umhängen würde UNIQUE verletzen
|
||||
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'braucht')",
|
||||
(topic, a, c))
|
||||
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'braucht')",
|
||||
(topic, b, c))
|
||||
# und eine Kante zwischen den Merge-Partnern → würde Selbstkante
|
||||
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'braucht')",
|
||||
(topic, b, a))
|
||||
inventar._merge(topic, a, b)
|
||||
kanten = db.query("SELECT * FROM kanten WHERE topic=?", (topic,))
|
||||
assert len(kanten) == 1 and kanten[0]["von_atom"] == a and kanten[0]["zu_atom"] == c
|
||||
# Merge-Kette: (B, C) — B ist schon gemerged, Wurzel A übernimmt
|
||||
inventar._merge(topic, b, c)
|
||||
assert db.one("SELECT merged_into FROM atome WHERE id=?", (c,))["merged_into"] == a
|
||||
|
||||
|
||||
async def test_anker_rematch_ohne_llm(tmp_path):
|
||||
topic = topic_anlegen("rematch")
|
||||
run = run_anlegen(topic)
|
||||
snap = tmp_path / "q.md"
|
||||
snap.write_text("Vorher. Der Satz steht hier drin. Nachher.", encoding="utf-8")
|
||||
q = db.insert("quellen", topic=topic, art="datei", titel="q",
|
||||
snapshot=str(snap), hash="h", status="atome")
|
||||
a = db.insert("atome", topic=topic, titel="T", typ="begriff", definition="d",
|
||||
status="ohne_anker", braucht=db.j([]))
|
||||
# Extraktion fand das Zitat nicht (Whitespace-Differenz), hat es aber gespeichert
|
||||
db.insert("anker", atom_id=a, quelle_id=q, start=-1, ende=-1,
|
||||
zitat="Der Satz steht hier drin.")
|
||||
|
||||
ctx = llm.Kontext(run, topic, "minimax")
|
||||
ctx.ebene = "inventar"
|
||||
assert await inventar._anker_fixen_batch(ctx, [a]) is True
|
||||
row = db.one("SELECT * FROM anker WHERE atom_id=? AND start>=0", (a,))
|
||||
assert row is not None
|
||||
assert db.one("SELECT status FROM atome WHERE id=?", (a,))["status"] == "neu"
|
||||
assert db.query("SELECT id FROM events WHERE run_id=?", (run,)) == [] # kein LLM-Call
|
||||
assert inventar.messen(ctx) == [] or all(
|
||||
b["art"] != "atom_ohne_anker" for b in inventar.messen(ctx))
|
||||
|
||||
|
||||
def test_titel_kern_faltet_schreibvarianten():
|
||||
import textkit
|
||||
assert textkit.titel_kern("ΔTSP2 Tour‑Länge") == textkit.titel_kern("ΔTSP2 Tour-Länge")
|
||||
assert textkit.titel_kern("FeedbackVertexSet ∈ NP") == textkit.titel_kern("Feedback Vertex Set ∈ NP")
|
||||
assert textkit.titel_kern("!!!") == ""
|
||||
|
||||
|
||||
def test_fallback_nutzt_jaccard_schwelle(monkeypatch):
|
||||
import embedding
|
||||
a = "Polynomialzeitreduktionen sind transitiv wenn L1 auf L2 und L2 auf L3 reduzierbar sind"
|
||||
b = ("Polynomialzeitreduktionen sind transitiv wenn eine Sprache L1 auf L2"
|
||||
" und L2 auf L3 in Polynomialzeit reduzierbar ist")
|
||||
# Kosinus-Schwelle allein: Paraphrase rutscht durch (aak Lauf 8)
|
||||
assert embedding.kandidaten_paare([a, b], 0.75) == []
|
||||
paare = embedding.kandidaten_paare([a, b], 0.75, 0.3)
|
||||
assert [(i, j) for i, j, _ in paare] == [(0, 1)]
|
||||
|
||||
|
||||
async def test_titel_dublette_wird_gemerged_trotz_ferner_definition():
|
||||
topic = topic_anlegen("titeldup")
|
||||
run = run_anlegen(topic)
|
||||
# identischer Titel, Definitionen lexikalisch fern (Jaccard < 0.3):
|
||||
# nur der Titel-Kern-Generator bringt das Paar ans Panel
|
||||
a = db.insert("atome", topic=topic, titel="VERTEX COVER Problem", typ="begriff",
|
||||
definition="Entscheidungsproblem, ob ein Graph ein Vertex Cover"
|
||||
" der Größe höchstens k enthält.",
|
||||
status="neu", braucht=db.j([]))
|
||||
b = db.insert("atome", topic=topic, titel="VERTEX COVER Problem", typ="begriff",
|
||||
definition="Gefragt wird nach einer Knotenmenge, die jede Kante"
|
||||
" abdeckt und maximal k Elemente hat.",
|
||||
status="neu", braucht=db.j([]))
|
||||
ctx = llm.Kontext(run, topic, "minimax")
|
||||
ctx.ebene = "inventar"
|
||||
await inventar._judge_dedup(ctx)
|
||||
stati = {r["id"]: r["status"] for r in db.query(
|
||||
"SELECT id, status FROM atome WHERE topic=?", (topic,))}
|
||||
assert sorted(stati.values()) == ["gemerged", "neu"]
|
||||
105
tests/test_korpus.py
Normal file
105
tests/test_korpus.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""Ebene 0, Konsens: Vollständigkeits-Invariante (kein Kandidat verschwindet
|
||||
still), explizite Ablehnung, uni-Beleg-Regel (1 Beleg reicht)."""
|
||||
|
||||
import re
|
||||
|
||||
import db
|
||||
import fake_agents
|
||||
import korpus
|
||||
import llm
|
||||
from conftest import run_anlegen, topic_anlegen
|
||||
|
||||
|
||||
def _seed(art="uni", n_quellen=1):
|
||||
topic = topic_anlegen("k-test", art=art)
|
||||
quellen = [db.insert("quellen", topic=topic, art="datei", titel=f"q{i}.txt",
|
||||
snapshot=f"q{i}.txt", hash=f"h{i}",
|
||||
status="extrahiert", rolle="stoff")
|
||||
for i in range(n_quellen)]
|
||||
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")
|
||||
ctx.ebene = "korpus"
|
||||
return topic, quellen, ctx
|
||||
|
||||
|
||||
def _kandidat(topic, punkt, quelle):
|
||||
return db.insert("soll", topic=topic, punkt=punkt, status="kandidat",
|
||||
belege=db.j([{"quelle": quelle, "zitat": f"Zitat {punkt}"}]))
|
||||
|
||||
|
||||
def _ids(prompt):
|
||||
return [int(i) for i in re.findall(r"^(\d+): ", prompt.split("KANDIDATEN:")[1],
|
||||
re.MULTILINE)]
|
||||
|
||||
|
||||
async def test_nachrunde_ordnet_vergessene_zu(monkeypatch):
|
||||
topic, (q,), ctx = _seed()
|
||||
a = _kandidat(topic, "Alpha", q)
|
||||
b = _kandidat(topic, "Beta", q)
|
||||
c = _kandidat(topic, "Gamma", q)
|
||||
|
||||
def judge(prompt):
|
||||
if "NACHRUNDE" in prompt: # nur die fehlende id wird angeboten
|
||||
assert _ids(prompt) == [c] and "Alpha" in prompt
|
||||
return [{"punkt": "Alpha", "kandidaten": [c]}] # wörtlich → Merge
|
||||
return [{"punkt": "Alpha", "kandidaten": [a]},
|
||||
{"punkt": "Beta", "kandidaten": [b]}] # c stillschweigend vergessen
|
||||
|
||||
monkeypatch.setitem(fake_agents._HANDLER, "Korpus-Soll-Konsens", judge)
|
||||
assert await korpus._konsens(ctx) == 2 # uni: 1 Quelle reicht als Beleg
|
||||
alpha = db.one("SELECT * FROM soll WHERE topic=? AND status='bestaetigt'"
|
||||
" AND punkt='Alpha'", (topic,))
|
||||
assert len(db.uj(alpha["belege"])) == 2 # Gamma-Beleg in Alpha aufgegangen
|
||||
for i in (a, b, c):
|
||||
assert db.one("SELECT status FROM soll WHERE id=?", (i,))["status"] == "gefaltet"
|
||||
assert korpus.messen(ctx) == []
|
||||
|
||||
|
||||
async def test_ablehnung_ist_explizit(monkeypatch):
|
||||
topic, (q,), ctx = _seed()
|
||||
a = _kandidat(topic, "Alpha", q)
|
||||
m = _kandidat(topic, "Einführung ins Thema", q)
|
||||
|
||||
def judge(prompt):
|
||||
return [{"punkt": "Alpha", "kandidaten": [a]},
|
||||
{"abgelehnt": True, "grund": "Meta-Überschrift", "kandidaten": [m]}]
|
||||
|
||||
monkeypatch.setitem(fake_agents._HANDLER, "Korpus-Soll-Konsens", judge)
|
||||
assert await korpus._konsens(ctx) == 1
|
||||
assert db.one("SELECT status FROM soll WHERE id=?", (m,))["status"] == "abgelehnt"
|
||||
assert korpus.messen(ctx) == [] # abgelehnt ist erledigt, kein Befund
|
||||
|
||||
|
||||
async def test_hartnaeckig_vergessene_wird_befund(monkeypatch):
|
||||
topic, (q,), ctx = _seed()
|
||||
a = _kandidat(topic, "Alpha", q)
|
||||
c = _kandidat(topic, "Gamma", q)
|
||||
|
||||
def judge(prompt):
|
||||
if "NACHRUNDE" in prompt:
|
||||
return [] # Judge verweigert die Zuordnung dauerhaft
|
||||
return [{"punkt": "Alpha", "kandidaten": [a]}]
|
||||
|
||||
monkeypatch.setitem(fake_agents._HANDLER, "Korpus-Soll-Konsens", judge)
|
||||
await korpus._konsens(ctx)
|
||||
assert db.one("SELECT status FROM soll WHERE id=?", (c,))["status"] == "kandidat"
|
||||
befunde = korpus.messen(ctx)
|
||||
assert [b["art"] for b in befunde] == ["soll_kandidat_offen"]
|
||||
assert befunde[0]["item"] == str(c)
|
||||
|
||||
|
||||
async def test_thema_braucht_zwei_quellen(monkeypatch):
|
||||
topic, (q1, q2), ctx = _seed(art="thema", n_quellen=2)
|
||||
x1 = _kandidat(topic, "X", q1)
|
||||
x2 = _kandidat(topic, "X (Synonym)", q2)
|
||||
y = _kandidat(topic, "Y", q1)
|
||||
|
||||
def judge(prompt):
|
||||
if "NACHRUNDE" in prompt:
|
||||
return []
|
||||
return [{"punkt": "X", "kandidaten": [x1, x2]},
|
||||
{"punkt": "Y", "kandidaten": [y]}]
|
||||
|
||||
monkeypatch.setitem(fake_agents._HANDLER, "Korpus-Soll-Konsens", judge)
|
||||
assert await korpus._konsens(ctx) == 1 # X: 2 Quellen; Y: nur 1 → kein Punkt
|
||||
assert db.one("SELECT status FROM soll WHERE id=?", (y,))["status"] == "kandidat"
|
||||
assert {b["art"] for b in korpus.messen(ctx)} == {"soll_kandidat_offen"}
|
||||
127
tests/test_struktur.py
Normal file
127
tests/test_struktur.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""Struktur-Ebene: Topo-Sortierung, Zyklenbruch, Band-Schnitt — mit synthetischen Atomen."""
|
||||
|
||||
import db
|
||||
import llm
|
||||
import struktur
|
||||
from conftest import run_anlegen, topic_anlegen
|
||||
|
||||
|
||||
def _atom(topic, titel, ziel_id=None, soll_id=1):
|
||||
return db.insert("atome", topic=topic, titel=titel, typ="begriff",
|
||||
definition=f"Definition {titel}", status="neu",
|
||||
soll_id=soll_id, ziel_id=ziel_id, braucht=db.j([]))
|
||||
|
||||
|
||||
def test_topo_ordnung():
|
||||
rang = {1: (0, 1), 2: (0, 2), 3: (0, 3)}
|
||||
# 3 braucht 1, 2 braucht 3 → 1, 3, 2
|
||||
assert struktur._topo([1, 2, 3], [(3, 1), (2, 3)], rang) == [1, 3, 2]
|
||||
|
||||
|
||||
def test_topo_fremde_kanten_bleiben_draussen():
|
||||
# Kante zu Atom 3 (nicht in der Gruppe) darf weder 3 hineinziehen
|
||||
# noch Gruppenmitglieder verdrängen (aak-Bug: 22 Atome ohne Baustein)
|
||||
rang = {1: (0, 1), 2: (0, 2), 3: (0, 3)}
|
||||
out = struktur._topo([1, 2], [(1, 3), (2, 1)], rang)
|
||||
assert out == [1, 2]
|
||||
|
||||
|
||||
def test_zyklus_finden():
|
||||
assert struktur._finde_zyklus([1, 2], [(1, 2), (2, 1)]) is not None
|
||||
assert struktur._finde_zyklus([1, 2, 3], [(2, 1), (3, 2)]) is None
|
||||
|
||||
|
||||
async def test_zyklen_brechen_und_schneiden():
|
||||
topic = topic_anlegen()
|
||||
run = run_anlegen(topic)
|
||||
soll = db.insert("soll", topic=topic, punkt="P", status="bestaetigt", belege=db.j([]))
|
||||
ziel = db.insert("lernziele", topic=topic, text="Kann P", soll_id=soll, status="aktiv")
|
||||
a = _atom(topic, "A", ziel, soll)
|
||||
b = _atom(topic, "B", ziel, soll)
|
||||
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'braucht')",
|
||||
(topic, a, b))
|
||||
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'braucht')",
|
||||
(topic, b, a))
|
||||
ctx = llm.Kontext(run, topic, "minimax")
|
||||
ctx.ebene = "struktur"
|
||||
await struktur._zyklen_brechen(ctx)
|
||||
aktiv = db.query("SELECT * FROM kanten WHERE topic=? AND art='braucht' AND status='aktiv'",
|
||||
(topic,))
|
||||
assert len(aktiv) == 1 # genau eine Kante gebrochen
|
||||
|
||||
struktur._bausteine_schneiden(ctx)
|
||||
atome = struktur._atome(topic)
|
||||
assert all(x["baustein_id"] for x in atome)
|
||||
|
||||
|
||||
async def test_band_split():
|
||||
topic = topic_anlegen("band")
|
||||
run = run_anlegen(topic)
|
||||
soll = db.insert("soll", topic=topic, punkt="P", status="bestaetigt", belege=db.j([]))
|
||||
ziel = db.insert("lernziele", topic=topic, text="Kann P", soll_id=soll, status="aktiv")
|
||||
for i in range(18): # 18 Atome in EINEM Ziel → muss in ≤8er-Teile splitten
|
||||
_atom(topic, f"A{i}", ziel, soll)
|
||||
ctx = llm.Kontext(run, topic, "minimax")
|
||||
struktur._bausteine_schneiden(ctx)
|
||||
bausteine = db.query("SELECT * FROM bausteine WHERE topic=?", (topic,))
|
||||
assert len(bausteine) >= 3
|
||||
for bs in bausteine:
|
||||
n = db.one("SELECT COUNT(*) n FROM atome WHERE baustein_id=?", (bs["id"],))["n"]
|
||||
assert 4 <= n <= 8
|
||||
befunde = struktur.messen(ctx)
|
||||
assert not [x for x in befunde if x["art"] in ("band", "partition", "zyklus")]
|
||||
|
||||
|
||||
async def test_merge_ohne_soll_schranke_und_kapitel():
|
||||
# Entkopplung: kleines Ziel merged über Soll-Punkt-Grenzen (Partner per Kante);
|
||||
# Kapitel entstehen danach als kontiguierliche Segmente
|
||||
topic = topic_anlegen("kapitel")
|
||||
run = run_anlegen(topic)
|
||||
s1 = db.insert("soll", topic=topic, punkt="P1", status="bestaetigt", belege=db.j([]))
|
||||
s2 = db.insert("soll", topic=topic, punkt="P2", status="bestaetigt", belege=db.j([]))
|
||||
z1 = db.insert("lernziele", topic=topic, text="Kann P1", soll_id=s1, status="aktiv")
|
||||
z2 = db.insert("lernziele", topic=topic, text="Kann P2", soll_id=s2, status="aktiv")
|
||||
a = _atom(topic, "A", z1, s1) # 1-Atom-Ziel, anderer Soll-Punkt als z2
|
||||
b0 = _atom(topic, "B0", z2, s2)
|
||||
for i in (1, 2, 3):
|
||||
_atom(topic, f"B{i}", z2, s2)
|
||||
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'braucht')",
|
||||
(topic, a, b0))
|
||||
ctx = llm.Kontext(run, topic, "minimax")
|
||||
ctx.ebene = "struktur"
|
||||
struktur._bausteine_schneiden(ctx)
|
||||
atome = struktur._atome(topic)
|
||||
assert len({x["baustein_id"] for x in atome}) == 1 # gemerged trotz fremdem Soll
|
||||
await struktur._kapitel_bilden(ctx)
|
||||
bausteine = db.query("SELECT * FROM bausteine WHERE topic=?", (topic,))
|
||||
assert bausteine and all(x["kapitel_id"] for x in bausteine)
|
||||
assert struktur.messen(ctx) == []
|
||||
|
||||
|
||||
async def test_kapitel_retry_und_fallback(monkeypatch):
|
||||
import fake_agents
|
||||
topic = topic_anlegen("kapfall")
|
||||
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=f"B{i}", ord=i,
|
||||
status="neu") for i in range(4)]
|
||||
ctx = llm.Kontext(run, topic, "minimax")
|
||||
ctx.ebene = "struktur"
|
||||
|
||||
aufrufe = {"n": 0}
|
||||
def judge(prompt):
|
||||
aufrufe["n"] += 1
|
||||
if aufrufe["n"] == 1:
|
||||
return [{"titel": "kaputt", "bis": 999999}] # ungültige Grenze
|
||||
return [{"titel": "Gutes Kapitel", "bis": b[-1]}]
|
||||
monkeypatch.setitem(fake_agents._HANDLER, "Kapitel-Schnitt", judge)
|
||||
await struktur._kapitel_bilden(ctx)
|
||||
kaps = db.query("SELECT * FROM kapitel WHERE topic=?", (topic,))
|
||||
assert aufrufe["n"] == 2 and len(kaps) == 1 and kaps[0]["art"] == "judge"
|
||||
|
||||
monkeypatch.setitem(fake_agents._HANDLER, "Kapitel-Schnitt",
|
||||
lambda p: [{"titel": "x", "bis": 999999}])
|
||||
await struktur._kapitel_bilden(ctx) # beide Versuche ungültig → Fallback
|
||||
kaps = db.query("SELECT * FROM kapitel WHERE topic=?", (topic,))
|
||||
assert kaps and all(k["art"] == "fallback" for k in kaps)
|
||||
assert "kapitel_fallback" in {bf["art"] for bf in struktur.messen(ctx)}
|
||||
Reference in New Issue
Block a user