This commit is contained in:
team3
2026-07-23 16:07:36 +02:00
commit cb68cd671b
88 changed files with 10029 additions and 0 deletions

0
tests/__init__.py Normal file
View File

48
tests/conftest.py Normal file
View File

@@ -0,0 +1,48 @@
import os
import sys
from pathlib import Path
os.environ["CREATOR_FAKE"] = "1"
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import pytest
from backend import config, db, engine, llm
# Kleine Fake-Welt → kleine Schwellen
config.SOLL_PUNKTE_MIN = 3
config.RECHERCHE_RUNDEN_MAX = 2
config.KAPITEL_SOLL_PUNKTE = 2
config.LUECKEN_RUNDEN_MAX = 1
config.POLL_SEKUNDEN = 0.05
config.DOMAIN_RATE_S = 0
config.DDGS_PAUSE_S = 0
config.QUERIES_JE_LENS = 2
config.VERDICHTUNG_ZIEL = 1 # zwingt den Verdichtungs-Pfad in der Fake-Welt
config.TAKT_SEKUNDEN = 0 # kein 3s-Takt in Tests
engine.module_laden()
@pytest.fixture(autouse=True)
def frische_db():
db.reset_for_tests()
llm._cooldown_bis = 0.0
llm._breite = config.MAX_PARALLEL_LLM
llm._inflight = 0
llm._naechster_start = 0.0
engine._laeufe.clear()
yield
def topic_anlegen(name="testthema", titel="Testthema"):
db.insert("topics", name=name, titel=titel)
return name
async def lauf_bis_ende(topic: str, budget: int | None = None, timeout=30):
import asyncio
run_id = engine.lauf_starten(topic, budget)
task = engine._laeufe[topic]
await asyncio.wait_for(task, timeout)
return db.one("SELECT * FROM runs WHERE id=?", run_id)

114
tests/test_buendel.py Normal file
View File

@@ -0,0 +1,114 @@
"""Bündeln: Claim mehrerer Tasks je Call, Teil-Antwort-Retry statt Verwerfen."""
import asyncio
from backend import db, engine, graph, guide
from .conftest import topic_anlegen
def _tasks_anlegen(topic, run_id, n=5):
return [db.insert("tasks", run_id=run_id, topic=topic, knoten="llm_pruefung",
item=f"p{i}", payload=db.j({"baustein_id": i}))
for i in range(n)]
def test_buendel_claim_mittel_nimmt_drei():
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
ids = _tasks_anlegen(topic, run_id)
g = graph.get()
db.execute("UPDATE tasks SET status='laufend' WHERE id=?", ids[0])
t = dict(db.one("SELECT * FROM tasks WHERE id=?", ids[0]))
mitglieder = engine._buendel_claimen(g, t, g.knoten["llm_pruefung"])
assert len(mitglieder) == 2 # Klasse mittel = 3 → Haupt-Task + 2
laufend = db.query("SELECT id FROM tasks WHERE topic=? AND status='laufend'",
topic)
assert len(laufend) == 3
assert db.one("SELECT COUNT(*) c FROM tasks WHERE topic=? AND "
"status='offen'", topic)["c"] == 2
def test_buendel_claim_nur_fuer_optin_worker():
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
for i in range(3): # writer: Klasse lang → nie bündeln
db.insert("tasks", run_id=run_id, topic=topic, knoten="writer",
item=f"w{i}", payload=db.j({"baustein_id": i}))
g = graph.get()
t = dict(db.one("SELECT * FROM tasks WHERE topic=? AND item='w0'", topic))
assert engine._buendel_claimen(g, t, g.knoten["writer"]) == []
def test_teil_kaputt_wird_neu_dann_fehler():
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
ids = _tasks_anlegen(topic, run_id, n=2)
db.execute("UPDATE tasks SET status='laufend' WHERE topic=?", topic)
g = graph.get()
kn = g.knoten["llm_pruefung"]
t = dict(db.one("SELECT * FROM tasks WHERE id=?", ids[0]))
t["_buendel"] = [dict(db.one("SELECT * FROM tasks WHERE id=?", ids[1]))]
erg = engine.Ergebnis(daten={"befunde": 0},
teil_status={ids[0]: "fertig", ids[1]: "neu"})
engine._abschluss_schreiben(g, t, kn, erg)
haupt = db.one("SELECT * FROM tasks WHERE id=?", ids[0])
teil = db.one("SELECT * FROM tasks WHERE id=?", ids[1])
assert haupt["status"] == "fertig"
assert teil["status"] == "offen" and teil["versuch"] == 1
# zweiter kaputter Durchlauf → sichtbar 'fehler' (max_versuche=2)
db.execute("UPDATE tasks SET status='laufend' WHERE id=?", ids[1])
t2 = dict(db.one("SELECT * FROM tasks WHERE id=?", ids[0]))
t2["_buendel"] = [dict(db.one("SELECT * FROM tasks WHERE id=?", ids[1]))]
engine._abschluss_schreiben(g, t2, kn, engine.Ergebnis(
teil_status={ids[1]: "neu"}))
assert db.one("SELECT status FROM tasks WHERE id=?", ids[1])["status"] == "fehler"
async def test_llm_pruefung_buendel_teilantwort(monkeypatch):
"""Fehlt eine Section in der Antwort, geht NUR ihr Task auf 'neu'."""
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
b1 = db.insert("bausteine", topic=topic, titel="B1")
b2 = db.insert("bausteine", topic=topic, titel="B2")
db.insert("sections", baustein_id=b1, text="Text eins.")
db.insert("sections", baustein_id=b2, text="Text zwei.")
t1 = db.insert("tasks", run_id=run_id, topic=topic, knoten="llm_pruefung",
item="p1", payload=db.j({"baustein_id": b1}), status="laufend")
t2 = db.insert("tasks", run_id=run_id, topic=topic, knoten="llm_pruefung",
item="p2", payload=db.j({"baustein_id": b2}), status="laufend")
from backend import llm as llm_mod
async def antwort(**kw):
assert f"=== SECTION {b2} ===" in kw["werte"]["sections"]
return f"===OK===\nSECTION: {b1}" # b2 fehlt in beiden Stimmen
monkeypatch.setattr(llm_mod, "call", antwort)
task = dict(db.one("SELECT * FROM tasks WHERE id=?", t1))
task["_buendel"] = [dict(db.one("SELECT * FROM tasks WHERE id=?", t2))]
erg = await guide.llm_pruefung(task)
assert erg.teil_status[t1] == "fertig"
assert erg.teil_status[t2] == "neu"
async def test_worker_exception_setzt_buendel_zurueck():
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
ids = _tasks_anlegen(topic, run_id, n=2)
db.execute("UPDATE tasks SET status='laufend' WHERE topic=?", topic)
g = graph.get()
async def kaputt(task):
raise RuntimeError("boom")
alt = engine.WORKER["guide.llm_pruefung"]
engine.WORKER["guide.llm_pruefung"] = kaputt
try:
t = dict(db.one("SELECT * FROM tasks WHERE id=?", ids[0]))
t["_buendel"] = [dict(db.one("SELECT * FROM tasks WHERE id=?", ids[1]))]
await engine._ausfuehren(g, t, g.knoten["llm_pruefung"],
asyncio.Semaphore(1), asyncio.Semaphore(1),
asyncio.Event())
finally:
engine.WORKER["guide.llm_pruefung"] = alt
for tid in ids:
z = db.one("SELECT * FROM tasks WHERE id=?", tid)
assert z["status"] == "offen" and z["versuch"] == 1

View File

@@ -0,0 +1,209 @@
"""Dedup-Kanäle (Akronym!, Subset-Falle, verwandt-Gedächtnis) + Dopplungs-Detektor."""
from backend import db, dedup, redundanz, textkit
from .conftest import topic_anlegen
def _atom(topic, titel, definition, soll_id=None, quelle=1, start=0, ende=10):
aid = db.insert("atome", topic=topic, titel=titel, definition=definition,
status="aktiv", soll_id=soll_id)
db.insert("anker", atom_id=aid, quelle_id=quelle, start=start, ende=ende,
zitat="z")
return aid
def test_auto_merge_anker_overlap():
topic = topic_anlegen()
a = _atom(topic, "A", "Definition eins", quelle=1, start=0, ende=100)
b = _atom(topic, "A2", "Definition eins etwas länger", quelle=1, start=20,
ende=110)
dedup._auto_merge_anker(topic)
aktive = db.query("SELECT id FROM atome WHERE topic=? AND status='aktiv'", topic)
assert len(aktive) == 1
assert db.one("SELECT merged_into FROM atome WHERE status='verworfen'")[
"merged_into"] in (a, b)
def test_akronym_kanal_liefert_kandidat():
topic = topic_anlegen()
a = _atom(topic, "DNS", "Das System löst Namen zu Adressen auf.",
quelle=1, start=0, ende=10)
b = _atom(topic, "Domain Name System", "Es übersetzt Domainnamen in Adressen.",
quelle=2, start=0, ende=10)
paare = dedup._kandidaten(topic)
assert any({p[0]["id"], p[1]["id"]} == {a, b} and p[2] == "akronym"
for p in paare)
def test_titel_fuzzy_subset_falle():
topic = topic_anlegen()
_atom(topic, "Zuhören", "Aufmerksames Aufnehmen von Gesagtem im Gespräch.",
quelle=1, start=0, ende=10)
_atom(topic, "Aktives Zuhören", "Technik der Gesprächsführung mit Rückfragen.",
quelle=2, start=0, ende=10)
paare = dedup._kandidaten(topic)
# Ein-Wort-Titel „Zuhören" darf NICHT über die Fuzzy-100-Subset-Falle kommen
assert not any(p[2] == "titel_fuzzy" for p in paare)
def test_verwandt_kante_verhindert_neupruefung():
topic = topic_anlegen()
a = _atom(topic, "Begriff Alpha", "Gemeinsame Wörter in dieser Definition hier.",
quelle=1, start=0, ende=10)
b = _atom(topic, "Begriff Alpha", "Gemeinsame Wörter in dieser Definition dort.",
quelle=2, start=50, ende=60)
assert dedup._kandidaten(topic)
db.insert("kanten", topic=topic, von_atom=min(a, b), zu_atom=max(a, b),
art="verwandt")
assert dedup._kandidaten(topic) == [] # nie zweimal Tokens
def test_negations_guard_blockiert():
topic = topic_anlegen()
_atom(topic, "Determinismus", "Der Automat ist deterministisch aufgebaut.",
quelle=1, start=0, ende=10)
_atom(topic, "Determinismus ", "Der Automat ist nicht deterministisch aufgebaut.",
quelle=2, start=0, ende=10)
assert dedup._kandidaten(topic) == []
async def test_keiner_atome_nicht_erneut_gefragt():
"""Endlosschleifen-Guard: geprüfte Atome ohne Soll erzeugen keine neuen
Zuordnungs-Tasks mehr (Echtlauf 2: dedup↔zuordnung drehte sich im Kreis)."""
from backend import inventar
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
db.insert("soll", topic=topic, punkt="Punkt", status="bestaetigt")
aid = _atom(topic, "Fremdes Atom", "Definition ohne Bezug zum Punkt hier.")
task = {"run_id": run_id, "topic": topic, "runde": 1, "item": "z0",
"payload": db.j({"atom_ids": [aid]})}
# Fake-Zuordnung antwortet KEINER (Titel unbekannt) → Atom bleibt ohne Soll
erg = await inventar.soll_zuordnung(task)
assert erg.daten["zugeordnet"] == 0
atom = db.one("SELECT soll_id, soll_geprueft FROM atome WHERE id=?", aid)
assert atom["soll_id"] is None and atom["soll_geprueft"] == 1
# Dedup erzeugt für dieses Atom KEINE neue Zuordnungs-Aufgabe mehr
erg2 = await dedup.dedup({"run_id": run_id, "topic": topic, "runde": 1,
"item": "r1:dedup", "payload": "{}"})
assert erg2.neue_tasks == []
def test_zahlen_namen_kanal():
"""Dieselbe Studie in Paraphrase (Audit: a20≈a78 Biwer 2023)."""
topic = topic_anlegen()
sid1 = db.insert("soll", topic=topic, punkt="P1", status="bestaetigt")
sid2 = db.insert("soll", topic=topic, punkt="P2", status="bestaetigt")
a = _atom(topic, "Studie zu Pausen",
"Biwer et al. 2023 verglichen 25 mit 35 Studierenden bei Pausen.",
soll_id=sid1, quelle=1, start=0, ende=10)
b = _atom(topic, "Selbstgesteuerte Pausen",
"In der Untersuchung von Biwer aus 2023 zeigten 35 Personen mehr "
"Ermüdung.", soll_id=sid2, quelle=2, start=0, ende=10)
paare = dedup._kandidaten(topic)
assert any({p[0]["id"], p[1]["id"]} == {a, b} and p[2] == "zahlen_namen"
for p in paare)
def test_gleicher_soll_ohne_jaccard_huerde():
topic = topic_anlegen()
sid = db.insert("soll", topic=topic, punkt="P", status="bestaetigt")
a = _atom(topic, "Ziel je Runde",
"Vor jeder Runde einen konkreten Zielsatz festhalten.",
soll_id=sid, quelle=1, start=0, ende=10)
b = _atom(topic, "Erfolgskriterium",
"Was hinterher anders sein soll, wird vorab notiert.",
soll_id=sid, quelle=2, start=0, ende=10)
paare = dedup._kandidaten(topic)
assert any({p[0]["id"], p[1]["id"]} == {a, b} and p[2] == "gleicher_soll"
for p in paare)
async def test_enthalten_merged_in_den_umfassenderen():
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
sid = db.insert("soll", topic=topic, punkt="P", status="bestaetigt")
kurz = _atom(topic, "Basisregeln", "Kurze Definition.",
soll_id=sid, quelle=1, start=0, ende=10)
lang = _atom(topic, "Basisregeln und Sonderfaelle",
"Deutlich längere Definition mit allen Sonderfällen und mehr "
"Kontext für Lernende.", soll_id=sid, quelle=2, start=0, ende=10)
await dedup.dedup({"run_id": run_id, "topic": topic, "runde": 1,
"item": "r1:dedup", "payload": "{}"})
gewinner = db.one("SELECT id FROM atome WHERE topic=? AND status='aktiv'",
topic)
assert gewinner["id"] == lang # längere Definition gewinnt
assert db.one("SELECT merged_into FROM atome WHERE id=?", kurz)[
"merged_into"] == lang
def test_redundanz_shingle_kanal():
"""Identische 5-Wort-Folge in sonst verschiedenen Sätzen (Audit:
„Ich melde mich in 20 Minuten" 3×, von Jaccard übersehen)."""
topic = topic_anlegen()
_section(topic, 0, "Bei Störungen hilft der Satz Ich melde mich in zwanzig "
"Minuten als freundliche Antwort im Büro.")
_section(topic, 1, "Kollegen akzeptieren meist ein kurzes Ich melde mich "
"in zwanzig Minuten und arbeiten dann weiter an ihren "
"eigenen Aufgaben im Projekt.")
paare = redundanz.kandidaten_paare(topic)
assert len(paare) == 1
def _section(topic, kapitel_ord, text, ziel_suffix=""):
kid = db.insert("kapitel", topic=topic, titel=f"K{kapitel_ord}", ord=kapitel_ord)
zid = db.insert("lernziele", topic=topic, text="z" + ziel_suffix)
bid = db.insert("bausteine", topic=topic, ziel_id=zid, titel=f"B{kapitel_ord}",
kapitel_id=kid, ord=0)
db.insert("sections", baustein_id=bid, text=text)
return bid
def test_redundanz_detektor_findet_dopplung():
topic = topic_anlegen()
satz = "Ein Protokoll regelt den Austausch von Nachrichten zwischen Partnern."
_section(topic, 0, f"Einleitung hier. {satz}")
spaeter = _section(topic, 1, f"Anderes Kapitel beginnt. {satz}")
paare = redundanz.kandidaten_paare(topic)
assert len(paare) == 1
assert max(paare[0]["a"], paare[0]["b"], key=lambda x: x["pos"])["bid"] == spaeter
def test_redundanz_gleiche_section_kein_paar():
topic = topic_anlegen()
satz = "Ein Protokoll regelt den Austausch von Nachrichten zwischen Partnern."
_section(topic, 0, f"{satz} Noch etwas Text dazwischen. {satz}")
assert redundanz.kandidaten_paare(topic) == []
def test_redundanz_verschiedene_saetze_kein_paar():
topic = topic_anlegen()
_section(topic, 0, "Ein Protokoll regelt den Nachrichtenaustausch der Partner.")
_section(topic, 1, "Kapitel zwei behandelt ein völlig anderes Feld der Praxis.")
assert redundanz.kandidaten_paare(topic) == []
async def test_fehlendes_urteil_nachcall_dann_befund(monkeypatch):
"""Fehlt ein Paar-Urteil, folgt EIN Nach-Call; bleibt es aus → sichtbarer
parse-Befund und KEINE verwandt-Kante (Paar bleibt künftig prüfbar)."""
from backend import llm as llm_mod
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
a = _atom(topic, "DNS", "Das System löst Namen zu Adressen auf.",
quelle=1, start=0, ende=10)
b = _atom(topic, "Domain Name System", "Es übersetzt Domainnamen.",
quelle=2, start=0, ende=10)
calls = {"n": 0}
async def ohne_urteil(**kw):
calls["n"] += 1
return "===URTEIL===\nPAAR: 99\nURTEIL: verschieden\nGRUND: x"
monkeypatch.setattr(llm_mod, "call", ohne_urteil)
await dedup.dedup({"run_id": run_id, "topic": topic, "runde": 1,
"item": "r1:dedup", "payload": "{}"})
assert calls["n"] == 4 # 2 Panel-Stimmen + 2 Nach-Call-Stimmen
assert db.one("SELECT id FROM befunde WHERE run_id=? AND art='parse'",
run_id)
assert db.query("SELECT id FROM kanten WHERE topic=?", topic) == []
aktiv = db.query("SELECT id FROM atome WHERE topic=? AND status='aktiv'",
topic)
assert {r["id"] for r in aktiv} == {a, b} # nichts still verschmolzen

120
tests/test_e2e.py Normal file
View File

@@ -0,0 +1,120 @@
"""Fake-E2E: kompletter Graph in Sekunden. Prüft Kernziele am DB-Endzustand:
Coverage 100 % (Lücke über Web-Nachrecherche geschlossen), Dublette gemerged,
Dopplung entfernt, Marker global einmalig, Resume = 0 neue Events."""
from pathlib import Path
from backend import db, fakes, montage
from .conftest import lauf_bis_ende, topic_anlegen
def _pruefe_endzustand(topic: str):
t = db.one("SELECT * FROM topics WHERE name=?", topic)
assert t["status"] == "fertig"
# Coverage: jeder bestätigte Soll-Punkt hat ≥1 aktives Atom mit Baustein
luecken = db.query(
"SELECT s.id FROM soll s WHERE s.topic=? AND s.status='bestaetigt' AND "
"s.freispruch='' AND NOT EXISTS (SELECT 1 FROM atome a WHERE "
"a.soll_id=s.id AND a.status='aktiv' AND a.baustein_id IS NOT NULL)", topic)
assert luecken == []
assert db.one("SELECT COUNT(*) c FROM soll WHERE topic=? AND "
"status='bestaetigt'", topic)["c"] == 3
# Dublette gemerged: HTTP-Paar existiert nur einmal aktiv
http = db.query("SELECT titel FROM atome WHERE topic=? AND status='aktiv' AND "
"titel IN ('HTTP', 'Hypertext Transfer Protocol')", topic)
assert len(http) == 1
merged = db.one("SELECT id FROM atome WHERE topic=? AND merged_into IS NOT NULL",
topic)
assert merged is not None
guide = Path(montage.guide_pfad(topic)).read_text()
# Marker global genau einmal je Atom
aktive = db.query("SELECT id FROM atome WHERE topic=? AND status='aktiv' AND "
"baustein_id IS NOT NULL", topic)
for a in aktive:
assert guide.count(f"<!-- atom: {a['id']} -->") == 1
# Dopplung raus, Rückbezug als Prosa — KEINE Links im Guide (linear lesbar)
assert guide.count(fakes.DUP) <= 1
assert "Wie bereits bei den Grundlagen beschrieben" in guide
assert "](#" not in guide
import re as _re
assert not _re.search(r"^\*\*[^*\n]+\*\*\s+—", guide, _re.MULTILINE)
# Verdichtung: Splitter-Atome („Vorteil …") sind zu EINEM fusioniert
vorteile = db.query("SELECT id FROM atome WHERE topic=? AND status='aktiv' "
"AND titel LIKE 'Vorteil%'", topic)
assert len(vorteile) == 1
starts = db.query("SELECT DISTINCT start FROM anker WHERE atom_id=?",
vorteile[0]["id"])
assert len(starts) == 2 # beide Belegstellen geerbt (Leser-Duplikate egal)
# Kein Format-Leak im Guide
assert "TITEL:" not in guide and "DEFINITION:" not in guide
assert "## Fehlerbehandlung" in guide
# Lücken-Atom kam über Web-Nachrecherche
assert db.one("SELECT id FROM quellen WHERE topic=? AND zweck LIKE 'luecke:%' "
"AND status='geladen'", topic) is not None
async def test_kompletter_lauf():
topic = topic_anlegen()
run = await lauf_bis_ende(topic)
assert run["status"] == "done", run["grund"]
_pruefe_endzustand(topic)
async def test_resume_keine_neuen_events():
topic = topic_anlegen()
run1 = await lauf_bis_ende(topic)
assert run1["status"] == "done"
vorher = db.one("SELECT COUNT(*) c FROM events")["c"]
run2 = await lauf_bis_ende(topic)
assert run2["status"] == "done"
assert db.one("SELECT COUNT(*) c FROM events")["c"] == vorher
async def test_pause_und_fortsetzen():
import asyncio
from backend import engine
topic = topic_anlegen()
run_id = engine.lauf_starten(topic)
await asyncio.sleep(0.3)
engine.lauf_pausieren(topic)
await asyncio.wait_for(engine._laeufe[topic], 10)
zwischen = db.one("SELECT * FROM runs WHERE id=?", run_id)
if zwischen["status"] == "done":
return # Lauf war schneller als die Pause — ok
assert zwischen["status"] == "paused"
run = await lauf_bis_ende(topic)
assert run["status"] == "done"
assert run["id"] == run_id # Resume nutzt denselben Run
_pruefe_endzustand(topic)
# keine Task-Duplikate
doppel = db.query("SELECT topic, knoten, item, COUNT(*) c FROM tasks "
"GROUP BY topic, knoten, item HAVING c > 1")
assert doppel == []
async def test_reset_und_neulauf():
from backend import config, engine
from backend.main import _pipeline_daten_loeschen
topic = topic_anlegen()
run1 = await lauf_bis_ende(topic)
assert run1["status"] == "done"
# Reset muss auch pausierte Läufe schließen (sonst Resume mit altem Budget)
db.update("runs", "id=?", (run1["id"],), status="paused")
_pipeline_daten_loeschen(topic)
db.execute("UPDATE runs SET status='stopped' WHERE topic=? AND "
"status IN ('running','paused','budget')", topic)
db.update("topics", "name=?", (topic,), status="neu")
assert db.one("SELECT COUNT(*) c FROM runs WHERE topic=? AND "
"status='paused'", topic)["c"] == 0
assert db.one("SELECT COUNT(*) c FROM tasks WHERE topic=?", topic)["c"] == 0
assert db.one("SELECT COUNT(*) c FROM atome WHERE topic=?", topic)["c"] == 0
assert not (config.KORPUS_DIR / topic).exists()
run2 = await lauf_bis_ende(topic)
assert run2["status"] == "done"
_pruefe_endzustand(topic)
async def test_events_haben_skill_hash():
topic = topic_anlegen()
await lauf_bis_ende(topic)
ohne = db.query("SELECT id FROM events WHERE status='ok' AND skill_hash=''")
assert ohne == []

85
tests/test_engine.py Normal file
View File

@@ -0,0 +1,85 @@
"""Engine-Kern: atomarer Claim, Zombie-Reset, Idempotenz, Kanten-Validierung."""
import pytest
from backend import db, engine, graph
from .conftest import topic_anlegen
def test_claim_atomar():
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
tid = db.insert("tasks", run_id=run_id, topic=topic, knoten="suche", item="x")
assert db.execute("UPDATE tasks SET status='laufend' WHERE id=? AND "
"status='offen'", tid) == 1
# zweiter Claim verliert
assert db.execute("UPDATE tasks SET status='laufend' WHERE id=? AND "
"status='offen'", tid) == 0
def test_task_unique_idempotent():
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
a = db.insert("tasks", ignore=True, run_id=run_id, topic=topic,
knoten="suche", item="r1:q:abc")
b = db.insert("tasks", ignore=True, run_id=run_id, topic=topic,
knoten="suche", item="r1:q:abc")
assert a is not None and b is None
# Runde im Item-Schlüssel: 2. Runde blockiert nicht
c = db.insert("tasks", ignore=True, run_id=run_id, topic=topic,
knoten="suche", item="r2:q:abc")
assert c is not None
async def test_zombie_reset_beim_start():
import asyncio
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="stopped")
db.insert("tasks", run_id=run_id, topic=topic, knoten="montage",
item="montage", status="laufend")
run_id2 = engine.lauf_starten(topic)
await asyncio.sleep(0.1)
t = db.one("SELECT status, fehler FROM tasks WHERE topic=? AND item='montage'",
topic)
assert t["status"] != "laufend" or t["fehler"] != "" # Zombie zurückgesetzt
engine.lauf_stoppen(topic)
with __import__("contextlib").suppress(asyncio.CancelledError):
await engine._laeufe[topic]
assert run_id2 != run_id
def test_kanten_validierung():
g = graph.get()
kn = g.knoten["suche"]
t = {"id": 1, "run_id": 1, "topic": "x", "knoten": "suche", "item": "i",
"runde": 1}
erg = engine.Ergebnis(neue_tasks=[{"knoten": "montage", "item": "m"}])
with pytest.raises(graph.GraphFehler):
engine._abschluss_schreiben(g, t, kn, erg)
async def test_infra_zaehlt_nicht_gegen_versuch():
"""LaufPause aus einem Worker setzt den Task zurück auf offen (kein versuch+1)
und pausiert den Run."""
import asyncio
async def kaputt(task):
from backend.llm import LaufPause
raise LaufPause("test")
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
tid = db.insert("tasks", run_id=run_id, topic=topic, knoten="suche",
item="x", status="laufend", versuch=0)
g = graph.get()
alt = engine.WORKER["suche.suchen"]
engine.WORKER["suche.suchen"] = kaputt
try:
t = dict(db.one("SELECT * FROM tasks WHERE id=?", tid))
await engine._ausfuehren(g, t, g.knoten["suche"], asyncio.Semaphore(1),
asyncio.Semaphore(1), asyncio.Event())
finally:
engine.WORKER["suche.suchen"] = alt
task = db.one("SELECT * FROM tasks WHERE id=?", tid)
assert task["status"] == "offen"
assert task["versuch"] == 0 # Infra zählt nicht als Inhalts-Versuch
assert db.one("SELECT status FROM runs WHERE id=?", run_id)["status"] == "paused"

282
tests/test_gates.py Normal file
View File

@@ -0,0 +1,282 @@
"""Gates: blockieren hart, Stillstand → Pause, Coverage-Formel deterministisch."""
import asyncio
from backend import db, engine, graph
from .conftest import lauf_bis_ende, topic_anlegen
async def test_gate_blockiert_hart_und_stillstand_pausiert(monkeypatch):
"""Konsens liefert nie bestätigte Punkte → gate_korpus bleibt rot →
nach Runden-Cap/Stillstand pausiert der Lauf. Stufe 2 startet NIE."""
from backend import korpus
async def konsens_kaputt(task):
return engine.Ergebnis(daten={"bestaetigt": 0})
monkeypatch.setitem(engine.WORKER, "korpus.soll_konsens", konsens_kaputt)
topic = topic_anlegen()
run = await lauf_bis_ende(topic)
assert run["status"] == "paused"
assert "gate" in run["grund"] or "stillstand" in run["grund"]
# Kein einziger Task jenseits der Korpus-Stufe
g = graph.get()
spaeter = [k.id for k in g.knoten.values() if k.stufe != "korpus"]
qs = ",".join("?" for _ in spaeter)
assert db.one(f"SELECT id FROM tasks WHERE topic=? AND knoten IN ({qs}) "
"LIMIT 1", topic, *spaeter) is None
# Status-Marker wurde nie gesetzt
assert db.one("SELECT status FROM topics WHERE name=?", topic)["status"] == "neu"
async def test_stillstand_nur_ohne_neue_tasks():
"""Gleicher Fingerprint, aber NEUE Reparatur-Tasks → kein Pause-Abbruch;
erst wenn nichts Neues mehr entsteht, greift der Stillstand."""
import asyncio
from backend import engine, graph
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
g = graph.get()
kn = g.knoten["gate_guide"]
befund = [{"art": "redundanz", "item": "baustein:1", "detail": "x"}]
async def gate_lauf(runde, tasks):
tid = db.insert("tasks", run_id=run_id, topic=topic, knoten="gate_guide",
item=f"runde:{runde}", runde=runde, status="laufend")
t = dict(db.one("SELECT * FROM tasks WHERE id=?", tid))
engine._abschluss_schreiben(
g, t, kn, engine.Ergebnis(gate_status="rot", befunde=befund,
neue_tasks=tasks))
await gate_lauf(1, [{"knoten": "fix", "item": "r2:fix:1"}])
await gate_lauf(2, [{"knoten": "fix", "item": "r3:klaerung:1"}]) # NEU → ok
assert db.one("SELECT status FROM runs WHERE id=?", run_id)[
"status"] == "running"
await gate_lauf(3, [{"knoten": "fix", "item": "r3:klaerung:1"}]) # ignoriert
assert db.one("SELECT status FROM runs WHERE id=?", run_id)[
"status"] == "paused"
async def test_gate_rot_ohne_reparatur_pausiert(monkeypatch):
"""Gate rot ohne Reparatur-Rezepte darf nicht endlos drehen → Pause."""
from backend import korpus
async def gate_rot(task):
return engine.Ergebnis(gate_status="rot", befunde=[
{"art": "test", "item": "x", "detail": str(task["runde"])}])
monkeypatch.setitem(engine.WORKER, "korpus.gate", gate_rot)
topic = topic_anlegen()
run = await lauf_bis_ende(topic)
assert run["status"] == "paused"
async def test_coverage_formel():
topic = topic_anlegen()
run = await lauf_bis_ende(topic)
assert run["status"] == "done"
gesamt = db.one("SELECT COUNT(*) c FROM soll WHERE topic=? AND "
"status='bestaetigt'", topic)["c"]
gedeckt = db.one(
"SELECT COUNT(*) c FROM soll s WHERE s.topic=? AND s.status='bestaetigt' "
"AND (s.freispruch!='' OR EXISTS (SELECT 1 FROM atome a WHERE "
"a.soll_id=s.id AND a.status='aktiv' AND a.baustein_id IS NOT NULL))",
topic)["c"]
assert gesamt == gedeckt == 3 # Coverage 100 %, deterministisch gezählt
async def test_resume_mit_budget_nachschlag():
import asyncio
from backend import engine
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="budget", budget_tokens=100)
run_id2 = engine.lauf_starten(topic, budget=500)
assert run_id2 == run_id # gleicher Run wird fortgesetzt
assert db.one("SELECT budget_tokens FROM runs WHERE id=?", run_id)[
"budget_tokens"] == 500
engine.lauf_stoppen(topic)
with __import__("contextlib").suppress(asyncio.CancelledError):
await engine._laeufe[topic]
async def test_gate_guide_neuschreiben_bei_kaputten_markern():
"""Marker-Defekt + Fix erschöpft → Gate erzeugt Writer-Neuschreiben-Task;
veraltete Marker-Befunde zählen nicht mehr als offen."""
from backend import guide
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
kid = db.insert("kapitel", topic=topic, titel="K", ord=0)
zid = db.insert("lernziele", topic=topic, text="z")
bid = db.insert("bausteine", topic=topic, ziel_id=zid, titel="B",
kapitel_id=kid, ord=0)
aid = db.insert("atome", topic=topic, titel="Atom X", status="aktiv",
baustein_id=bid, definition="d")
db.insert("sections", baustein_id=bid, text="Text ohne Marker.",
fix_versuche=3)
erg = await guide.gate({"run_id": run_id, "topic": topic, "runde": 1,
"item": "runde:1", "payload": "{}"})
assert erg.gate_status == "rot"
writer_tasks = [t for t in erg.neue_tasks if t["knoten"] == "writer"]
assert writer_tasks and writer_tasks[0]["payload"]["neu"] is True
# Neuschreiben resettet den Fix-Cap und ersetzt den Text
await guide.writer({"run_id": run_id, "topic": topic, "runde": 2,
"item": f"r2:baustein:{bid}",
"payload": db.j({"baustein_id": bid, "neu": True})})
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", bid)
assert f"<!-- atom: {aid} -->" in sec["text"]
assert sec["fix_versuche"] == 0
async def test_eskalations_exit_beendet_karussell():
"""Nach Fix-Cap + Klärung + 1 Neuschreiben wird der Befund eskaliert —
das Gate blockt nicht ewig (Echtlauf: 5 Sections kreisten bis Runde 8)."""
from backend import guide
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
kid = db.insert("kapitel", topic=topic, titel="K", ord=0)
zid = db.insert("lernziele", topic=topic, text="z")
bid = db.insert("bausteine", topic=topic, ziel_id=zid, titel="B",
kapitel_id=kid, ord=0)
aid = db.insert("atome", topic=topic, titel="Atom X", status="aktiv",
baustein_id=bid, definition="d")
db.insert("sections", baustein_id=bid,
text=f"<!-- atom: {aid} -->\nText.", fix_versuche=3)
db.insert("befunde", run_id=run_id, stufe="guide", art="falsch",
item=f"baustein:{bid}", detail="x", geklaert=1)
# 1 Neuschreiben wurde schon versucht:
db.insert("tasks", run_id=run_id, topic=topic, knoten="writer",
item=f"r9:baustein:{bid}", art="reparatur", status="fertig")
erg = await guide.gate({"run_id": run_id, "topic": topic, "runde": 1,
"item": "runde:1", "payload": "{}"})
b = db.one("SELECT status FROM befunde WHERE item=?", f"baustein:{bid}")
assert b["status"] == "eskaliert"
assert not [t for t in erg.neue_tasks if t["knoten"] in ("fix", "writer")]
async def test_atom_echo_semantisch():
"""Fettzeile ohne Gedankenstrich umging den Regex-Check (Audit Z.294/297) —
der semantische Vergleich mit der Atom-Definition fängt sie."""
from backend import guide
topic = topic_anlegen()
zid = db.insert("lernziele", topic=topic, text="z")
kid = db.insert("kapitel", topic=topic, titel="K", ord=0)
bid = db.insert("bausteine", topic=topic, ziel_id=zid, titel="B",
kapitel_id=kid, ord=0)
aid = db.insert("atome", topic=topic, titel="Pausenregeln", status="aktiv",
baustein_id=bid,
definition="Empfohlene Pausen sind Dehnen, Meditation und "
"kurze Spaziergänge an der frischen Luft.")
echo_text = (f"<!-- atom: {aid} -->\n"
"**Empfohlene Pausen sind Dehnen, Meditation und kurze "
"Spaziergänge an der frischen Luft.** Danach mehr. "
+ "Wort " * 40)
assert any(b["art"] == "atom_echo"
for b in guide._det_befunde(topic, bid, echo_text))
sauber = (f"<!-- atom: {aid} -->\n"
"Gute Pausen leben von Bewegung und bewusstem Abschalten. "
+ "Wort " * 40)
assert not any(b["art"] == "atom_echo"
for b in guide._det_befunde(topic, bid, sauber))
async def test_vorwaerts_ignoriert_verlinkte_erwaehnung():
from backend import guide
topic = topic_anlegen()
kid1 = db.insert("kapitel", topic=topic, titel="K1", ord=0)
kid2 = db.insert("kapitel", topic=topic, titel="K2", ord=1)
zid = db.insert("lernziele", topic=topic, text="z")
bid1 = db.insert("bausteine", topic=topic, ziel_id=zid, titel="B1",
kapitel_id=kid1, ord=0)
bid2 = db.insert("bausteine", topic=topic, ziel_id=zid, titel="B2",
kapitel_id=kid2, ord=0)
a1 = db.insert("atome", topic=topic, titel="Frühes Konzept", status="aktiv",
baustein_id=bid1)
db.insert("atome", topic=topic, titel="Langes Spätkonzept", status="aktiv",
baustein_id=bid2)
mit_link = (f"<!-- atom: {a1} -->\nSiehe [Langes Spätkonzept](#b2) später. "
+ "Wort " * 40)
assert not [b for b in guide._det_befunde(topic, bid1, mit_link)
if b["art"] == "vorwaerts"]
ohne_link = f"<!-- atom: {a1} -->\nDas Langes Spätkonzept kommt. " + "Wort " * 40
assert [b for b in guide._det_befunde(topic, bid1, ohne_link)
if b["art"] == "vorwaerts"]
async def test_gate_verwirft_soll_lose_atome():
from backend import inventar
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
geprueft = db.insert("atome", topic=topic, titel="Beifang", status="aktiv",
soll_geprueft=1)
frisch = db.insert("atome", topic=topic, titel="Frisch", status="aktiv",
soll_geprueft=0)
await inventar.gate({"run_id": run_id, "topic": topic, "runde": 1,
"item": "runde:1", "payload": "{}"})
assert db.one("SELECT status FROM atome WHERE id=?", geprueft)[
"status"] == "verworfen"
assert db.one("SELECT status FROM atome WHERE id=?", frisch)[
"status"] == "aktiv" # ungeprüfte bleiben
async def test_befund_dedupe_erhaelt_klaerungsstand():
from backend.guide import befund_merken
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
assert befund_merken(run_id, "llm_pruefung", "luecke", "baustein:1",
"Detail X", 1) is True
db.execute("UPDATE befunde SET geklaert=1")
# gleiche Runde später: KEINE neue Zeile, geklaert bleibt
assert befund_merken(run_id, "llm_pruefung", "luecke", "baustein:1",
"Detail X", 2) is False
rows = db.query("SELECT geklaert FROM befunde WHERE art='luecke'")
assert len(rows) == 1 and rows[0]["geklaert"] == 1
# behoben → Regression darf neu aufmachen
db.execute("UPDATE befunde SET status='behoben'")
assert befund_merken(run_id, "llm_pruefung", "luecke", "baustein:1",
"Detail X", 3) is True
async def test_finale_det_befunde_blocken_nicht():
"""Eskalierter/freigesprochener Vorwärtsverweis hält das Gate nicht mehr rot."""
from backend import guide
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
kid1 = db.insert("kapitel", topic=topic, titel="K1", ord=0)
kid2 = db.insert("kapitel", topic=topic, titel="K2", ord=1)
zid = db.insert("lernziele", topic=topic, text="z")
bid1 = db.insert("bausteine", topic=topic, ziel_id=zid, titel="B1",
kapitel_id=kid1, ord=0)
bid2 = db.insert("bausteine", topic=topic, ziel_id=zid, titel="B2",
kapitel_id=kid2, ord=0)
a1 = db.insert("atome", topic=topic, titel="Frühes Thema", status="aktiv",
baustein_id=bid1)
a2 = db.insert("atome", topic=topic, titel="Langes Spätkonzept",
status="aktiv", baustein_id=bid2)
db.insert("sections", baustein_id=bid1,
text=f"<!-- atom: {a1} -->\nDas Langes Spätkonzept kommt. "
+ "Wort " * 50)
db.insert("sections", baustein_id=bid2,
text=f"<!-- atom: {a2} -->\n" + "Wort " * 50)
db.insert("kapitel", topic=topic, titel="egal", ord=2)
erg1 = await guide.gate({"run_id": run_id, "topic": topic, "runde": 1,
"item": "runde:1", "payload": "{}"})
vor = [b for b in erg1.befunde if b["art"] == "vorwaerts"]
assert vor # blockt zunächst
db.insert("befunde", run_id=run_id, stufe="guide", art="vorwaerts",
item=vor[0]["item"], detail=vor[0]["detail"][:500],
status="freispruch")
erg2 = await guide.gate({"run_id": run_id, "topic": topic, "runde": 2,
"item": "runde:2", "payload": "{}"})
assert not [b for b in erg2.befunde if b["art"] == "vorwaerts"]
async def test_budget_stoppt_lauf():
topic = topic_anlegen()
run = await lauf_bis_ende(topic, budget=1)
# Fake-Calls kosten 0 Tokens — Budget greift nur mit echten Tokens.
# Simuliere Verbrauch und prüfe den Stopp-Pfad direkt:
if run["status"] == "done":
db.insert("events", run_id=run["id"], tok_in=5, tok_out=5)
from backend.ledger import BudgetErschoepft, budget_pruefen
import pytest
with pytest.raises(BudgetErschoepft):
budget_pruefen(run["id"])

44
tests/test_graph.py Normal file
View File

@@ -0,0 +1,44 @@
"""Graph-Validierung: DAG-Pflicht, Rück-Kanten nur von Gates, Skills vorhanden."""
import pytest
import yaml
from backend import config, engine, graph
def test_pipeline_yaml_valide():
g = graph.laden(set(engine.WORKER))
assert len(g.knoten) >= 20
assert g.stufen == ["korpus", "inventar", "struktur", "guide", "montage"]
for stufe in g.stufen[:-1]:
assert g.gate_der_stufe(stufe) is not None
def _mit_kante(extra) -> None:
daten = yaml.safe_load(config.PIPELINE_YAML.read_text())
daten["kanten"].append(extra)
import tempfile
from pathlib import Path
with tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False) as f:
yaml.safe_dump(daten, f)
pfad = Path(f.name)
try:
graph.laden(set(engine.WORKER), pfad=pfad)
finally:
pfad.unlink()
graph.laden(set(engine.WORKER)) # Original wiederherstellen
def test_zyklus_wird_abgelehnt():
with pytest.raises(graph.GraphFehler, match="Zyklus"):
_mit_kante({"von": "montage", "nach": "recherche_plan", "art": "normal"})
def test_ruecklauf_nur_von_gates():
with pytest.raises(graph.GraphFehler, match="nur von Gates"):
_mit_kante({"von": "writer", "nach": "suche", "art": "ruecklauf"})
def test_layout_vollstaendig():
lay = graph.get().layout()
assert set(lay["knoten"]) == set(graph.get().knoten)
assert all("spalte" in v and "zeile" in v for v in lay["knoten"].values())

22
tests/test_korpus.py Normal file
View File

@@ -0,0 +1,22 @@
"""Konsens: 1 kuratierter Wikipedia-Beleg genügt, 1 Blog-Beleg nicht."""
from backend import db, korpus
from .conftest import topic_anlegen
async def test_wiki_einzelbeleg_bestaetigt():
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
q_wiki = db.insert("quellen", topic=topic, url="https://de.wikipedia.org/w",
url_norm="u1", backend="wikipedia_de", status="geladen")
q_blog = db.insert("quellen", topic=topic, url="https://blog.de/x",
url_norm="u2", backend="ddgs", status="geladen")
db.insert("soll", topic=topic, punkt="Overflow-Pomodoros erklären",
belege=db.j([{"quelle": q_wiki, "zitat": "z1"}]))
db.insert("soll", topic=topic, punkt="Nur Blog-These",
belege=db.j([{"quelle": q_blog, "zitat": "z2"}]))
await korpus.soll_konsens({"run_id": run_id, "topic": topic, "runde": 1,
"item": "r1:konsens", "payload": "{}"})
wiki = db.one("SELECT status FROM soll WHERE punkt LIKE 'Overflow%'")
blog = db.one("SELECT status FROM soll WHERE punkt LIKE 'Nur Blog%'")
assert wiki["status"] == "bestaetigt" # kuratierte Quelle: 1 Beleg reicht
assert blog["status"] == "kandidat" # Blog allein bestätigt nicht

134
tests/test_llm.py Normal file
View File

@@ -0,0 +1,134 @@
"""LLM-Disziplin: Budget-Vorab-Stopp, Infra→Pause fail-closed, cap ohne Retry,
Drossel, Ledger im finally."""
import pytest
from backend import config, db, llm
from backend.ledger import BudgetErschoepft
from .conftest import topic_anlegen
SKILLS = ["richter", "deutsch-praezise", "klaerung"]
WERTE = {"befund": "x", "belege": "y"}
@pytest.fixture
def echt_modus(monkeypatch):
monkeypatch.setattr(config, "FAKE", False)
monkeypatch.setattr(config, "INFRA_BACKOFF_BASE", 0.01)
llm._cooldown_bis = 0.0
def _run(status="running", budget=0):
topic = topic_anlegen()
return db.insert("runs", topic=topic, status=status, budget_tokens=budget)
async def _call(run_id, **kw):
return await llm.call(run_id=run_id, stufe="t", knoten="klaerung", item="i",
skill_namen=SKILLS, werte=WERTE, **kw)
async def test_budget_vorab_stopp(echt_modus, monkeypatch):
run_id = _run(budget=100)
db.insert("events", run_id=run_id, tok_in=80, tok_out=30) # schon drüber
async def nie(*a, **k):
raise AssertionError("Call darf nicht abgesetzt werden")
monkeypatch.setattr(llm, "_api", nie)
with pytest.raises(BudgetErschoepft):
await _call(run_id)
async def test_infra_erschoepft_wird_pause(echt_modus, monkeypatch):
run_id = _run()
async def immer_429(*a, **k):
return llm.ApiErgebnis(1, err="HTTP 429: rate limited")
monkeypatch.setattr(llm, "_api", immer_429)
with pytest.raises(llm.LaufPause):
await _call(run_id)
# jeder Versuch im Ledger, Status infra
events = db.query("SELECT status FROM events WHERE run_id=?", run_id)
assert len(events) == config.INFRA_MAX_RETRIES + 1
assert all(e["status"] == "infra" for e in events)
async def test_cap_kein_retry(echt_modus, monkeypatch):
run_id = _run()
zaehler = {"n": 0}
async def cap(*a, **k):
zaehler["n"] += 1
return llm.ApiErgebnis(1, err="leere Antwort (stop=max_tokens)",
tokens={"input": 10, "output": 5})
monkeypatch.setattr(llm, "_api", cap)
assert await _call(run_id) is None
assert zaehler["n"] == 1 # deterministisch — kein Neuversuch
e = db.one("SELECT status, tok_in FROM events WHERE run_id=?", run_id)
assert e["status"] == "cap" and e["tok_in"] == 10 # Tokens trotz Fehler erfasst
async def test_drossel_halbiert_und_erholt(echt_modus):
llm._breite = 12
llm.drossel_melden(0.01)
assert llm._breite == 6
llm.drossel_melden(0.01)
assert llm._breite == max(config.DROSSEL_MIN_BREITE, 3)
for _ in range(config.DROSSEL_ERFOLGE_JE_PLUS):
llm._erfolg_melden()
assert llm._breite >= config.DROSSEL_MIN_BREITE + 1
async def test_manuelle_pause_bricht_wartende(echt_modus):
run_id = _run(status="paused")
with pytest.raises(llm.ManuellePause):
await _call(run_id)
async def test_llm_log_speichert_texte(echt_modus, monkeypatch):
monkeypatch.setattr(config, "LLM_LOG", True)
run_id = _run()
async def ok(*a, **k):
return llm.ApiErgebnis(0, text="ANTWORTTEXT",
tokens={"input": 5, "output": 3})
monkeypatch.setattr(llm, "_api", ok)
assert await _call(run_id) == "ANTWORTTEXT"
zeile = db.one("SELECT ct.prompt, ct.antwort FROM call_texte ct "
"JOIN events e ON e.id=ct.event_id WHERE e.run_id=?", run_id)
assert "aufgabe:klaerung" in zeile["prompt"]
assert zeile["antwort"] == "ANTWORTTEXT"
async def test_ohne_llm_log_keine_texte(echt_modus, monkeypatch):
run_id = _run()
async def ok(*a, **k):
return llm.ApiErgebnis(0, text="X")
monkeypatch.setattr(llm, "_api", ok)
await _call(run_id)
assert db.one("SELECT COUNT(*) c FROM call_texte")["c"] == 0
async def test_inhaltsfehler_begrenzte_restarts(echt_modus, monkeypatch):
run_id = _run()
zaehler = {"n": 0}
async def kaputt(*a, **k):
zaehler["n"] += 1
return llm.ApiErgebnis(1, err="leere Antwort (stop=end_turn)")
monkeypatch.setattr(llm, "_api", kaputt)
assert await _call(run_id) is None
assert zaehler["n"] == config.INHALT_MAX_RESTARTS + 1
async def test_takt_pacing(monkeypatch):
"""Bei aktivem Takt startet der zweite Call frühestens TAKT später."""
import time
monkeypatch.setattr(config, "TAKT_SEKUNDEN", 0.05)
llm._naechster_start = 0.0
t0 = time.monotonic()
await llm._slot()
llm._slot_frei()
await llm._slot()
llm._slot_frei()
assert time.monotonic() - t0 >= 0.05

44
tests/test_skills.py Normal file
View File

@@ -0,0 +1,44 @@
"""Skill-Komposition: 1+1+n erzwungen, Platzhalter validiert, Hash stabil."""
import pytest
from backend import skills
def test_komposition_reihenfolge():
prompt, h, paare = skills.komponieren(
["extraktor", "deutsch-praezise", "atom-extraktion"],
{"thema": "X", "abschnitte": "Y"})
assert prompt.startswith("<!-- aufgabe:atom-extraktion -->")
assert prompt.index("## Rolle") < prompt.index("## Arbeitsweise") \
< prompt.index("## Aufgabe")
assert "{{" not in prompt
assert len(h) == 12 and len(paare) == 3
def test_zwei_rollen_verboten():
with pytest.raises(skills.SkillFehler, match="genau 1 Rolle"):
skills.komponieren(["extraktor", "richter", "atom-extraktion"],
{"thema": "X", "abschnitte": "Y"})
def test_fehlender_platzhalter():
with pytest.raises(skills.SkillFehler, match="Platzhalter fehlen"):
skills.komponieren(["extraktor", "atom-extraktion"], {"thema": "X"})
def test_hash_stabil_und_aendert_sich():
_, h1, _ = skills.komponieren(["extraktor", "atom-extraktion"],
{"thema": "A", "abschnitte": "B"})
_, h2, _ = skills.komponieren(["extraktor", "atom-extraktion"],
{"thema": "ANDERS", "abschnitte": "B"})
assert h1 == h2 # Hash hängt an den Skill-DATEIEN, nicht am Input
_, h3, _ = skills.komponieren(["extraktor", "deutsch-praezise",
"atom-extraktion"],
{"thema": "A", "abschnitte": "B"})
assert h3 != h1 # andere Skill-Menge → anderer Hash
def test_aufgaben_name():
prompt, _, _ = skills.komponieren(["richter", "klaerung"],
{"befund": "x", "belege": "y"})
assert skills.aufgaben_name(prompt) == "klaerung"

34
tests/test_struktur.py Normal file
View File

@@ -0,0 +1,34 @@
"""Ziel-Dedup: synonyme Lernziele werden deterministisch gemerged."""
from backend import db, struktur
from .conftest import topic_anlegen
def test_ziele_dedup_merged_synonyme():
topic = topic_anlegen()
z1 = db.insert("lernziele", topic=topic, titel="Grundlagen einordnen",
text="Der Lernende kann Pomodoro-Grundlagen einordnen.")
z2 = db.insert("lernziele", topic=topic, titel="Grundlagen verorten",
text="Der Lernende kann Pomodoro-Grundlagen verorten.")
z3 = db.insert("lernziele", topic=topic, titel="Anwendung",
text="Der Lernende kann die Technik im Alltag anwenden.")
b2 = db.insert("bausteine", topic=topic, ziel_id=z2, titel="B2")
a1 = db.insert("atome", topic=topic, titel="A1", status="aktiv", ziel_id=z2,
baustein_id=b2)
merges = struktur._ziele_dedup(topic)
assert merges == 1
# z2 weg, sein Atom hängt jetzt an z1, Baustein-Zuordnung gelöst
assert db.one("SELECT id FROM lernziele WHERE id=?", z2) is None
atom = db.one("SELECT ziel_id, baustein_id FROM atome WHERE id=?", a1)
assert atom["ziel_id"] == z1 and atom["baustein_id"] is None
assert db.one("SELECT id FROM lernziele WHERE id=?", z3) is not None
def test_ziele_dedup_verschont_verschiedene():
topic = topic_anlegen()
db.insert("lernziele", topic=topic, titel="Pausen gestalten",
text="Der Lernende kann Pausen erholsam gestalten.")
db.insert("lernziele", topic=topic, titel="Werkzeuge nutzen",
text="Der Lernende kann passende Apps und Timer auswählen.")
assert struktur._ziele_dedup(topic) == 0
assert db.one("SELECT COUNT(*) c FROM lernziele WHERE topic=?",
topic)["c"] == 2

167
tests/test_suche_laden.py Normal file
View File

@@ -0,0 +1,167 @@
"""Suche: Fallback-Kette, Circuit Breaker, URL-Dedupe. Laden: Snapshot stabil."""
import pytest
from backend import config, db, laden, suche
from .conftest import topic_anlegen
def test_url_normieren():
assert suche.url_normieren("HTTPS://Beispiel.de/Pfad/?utm_source=x&a=1") == \
"https://beispiel.de/Pfad?a=1"
assert suche.url_normieren("https://beispiel.de/seite/") == \
suche.url_normieren("https://beispiel.de/seite")
async def test_fallback_kette(monkeypatch):
monkeypatch.setattr(config, "FAKE", False)
suche._ausfaelle.clear()
async def leer_wiki(q):
return []
async def kaputt(q):
raise RuntimeError("down")
async def liefert(q):
return [{"titel": "T", "url": "https://x.de/a", "backend": "b2"}]
monkeypatch.setattr(suche, "_wikipedia", leer_wiki)
monkeypatch.setattr(suche, "_KETTE", (("b1", kaputt), ("b2", liefert)))
monkeypatch.setattr(config, "SUCHE_RETRIES", 0)
treffer, fehler = await suche.web_suchen("query")
assert [t["backend"] for t in treffer] == ["b2"]
assert fehler == ["b1: RuntimeError"]
async def test_circuit_breaker(monkeypatch):
monkeypatch.setattr(config, "FAKE", False)
monkeypatch.setattr(config, "SUCHE_RETRIES", 0)
suche._ausfaelle.clear()
aufrufe = {"n": 0}
async def leer_wiki(q):
return []
async def kaputt(q):
aufrufe["n"] += 1
raise RuntimeError("down")
monkeypatch.setattr(suche, "_wikipedia", leer_wiki)
monkeypatch.setattr(suche, "_KETTE", (("b1", kaputt),))
for _ in range(config.CIRCUIT_BREAKER_N + 3):
await suche.web_suchen("q")
assert aufrufe["n"] == config.CIRCUIT_BREAKER_N # danach übersprungen
async def test_suche_dedupliziert_urls():
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
t = {"run_id": run_id, "topic": topic, "runde": 1,
"payload": db.j({"query": "thema ueberblick r1"})}
erg1 = await suche.suchen({**t})
erg2 = await suche.suchen({**t})
assert erg1.daten["neu"] == 1
assert erg2.daten["neu"] == 0 # gleiche URL → kein zweiter Laden-Task
async def test_relevanz_filter_wirft_listen_raus(monkeypatch):
"""Läuft OHNE Fake-Kurzschluss — genau der Pfad, der im Echtlauf brach."""
monkeypatch.setattr(config, "FAKE", False)
from backend import llm as llm_mod
async def urteil(**kw):
assert "quellen-relevanz" in kw["skill_namen"]
assert "Pasta" in kw["werte"]["treffer"] # Wiki läuft MIT durchs Urteil
return "===RELEVANT===\nIDS: 0,1,3"
monkeypatch.setattr(llm_mod, "call", urteil)
treffer = [
{"titel": "Pomodoro-Technik Wikipedia",
"url": "https://de.wikipedia.org/wiki/X", "backend": "wikipedia_de"},
{"titel": "Guter Artikel", "url": "https://a.de/1", "backend": "ddgs"},
{"titel": "Pasta", "url": "https://en.wikipedia.org/wiki/Pasta",
"backend": "wikipedia_en"},
{"titel": "Anleitung", "url": "https://a.de/3", "backend": "ddgs"},
]
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
task = {"run_id": run_id, "topic": topic, "item": "t", "runde": 1}
ergebnis = await suche._relevanz_filter(task, "Thema", treffer)
urls = [t["url"] for t in ergebnis]
assert "https://de.wikipedia.org/wiki/X" in urls
assert "https://a.de/1" in urls and "https://a.de/3" in urls
assert "https://en.wikipedia.org/wiki/Pasta" not in urls # Off-Topic-Wiki raus
async def test_wikipedia_seed_umgeht_deckel(monkeypatch):
monkeypatch.setattr(config, "QUELLEN_MAX", 1)
topic = topic_anlegen(name="seedtest", titel="Pomodoro Technik")
run_id = db.insert("runs", topic=topic, status="running")
erg = await suche.suchen({"run_id": run_id, "topic": topic, "runde": 1,
"item": "r1:wikiseed",
"payload": db.j({"seed": True})})
assert erg.daten["neu"] == 2 # de+en trotz Deckel 1
urls = [q["url"] for q in db.query(
"SELECT url FROM quellen WHERE topic=?", topic)]
assert any("de.wikipedia.org/wiki/Pomodoro_Technik" in u for u in urls)
assert any("en.wikipedia.org" in u for u in urls)
# idempotent: zweiter Lauf legt nichts Neues an
erg2 = await suche.suchen({"run_id": run_id, "topic": topic, "runde": 1,
"item": "r1:wikiseed",
"payload": db.j({"seed": True})})
assert erg2.daten["neu"] == 0
async def test_quellen_deckel(monkeypatch):
monkeypatch.setattr(config, "QUELLEN_MAX", 1)
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
basis = {"run_id": run_id, "topic": topic, "runde": 1, "item": "t"}
erg1 = await suche.suchen({**basis,
"payload": db.j({"query": "thema ueberblick r1"})})
erg2 = await suche.suchen({**basis,
"payload": db.j({"query": "thema tutorial r1"})})
assert erg1.daten["neu"] == 1
assert erg2.daten["neu"] == 0 # Deckel erreicht — keine neue Quelle
async def test_quellen_deckel_pro_topic(monkeypatch):
monkeypatch.setattr(config, "QUELLEN_MAX", 1)
topic = topic_anlegen()
db.update("topics", "name=?", (topic,), quellen_max=0) # 0 = unbegrenzt
run_id = db.insert("runs", topic=topic, status="running")
basis = {"run_id": run_id, "topic": topic, "runde": 1, "item": "t"}
erg1 = await suche.suchen({**basis,
"payload": db.j({"query": "thema ueberblick r1"})})
erg2 = await suche.suchen({**basis,
"payload": db.j({"query": "thema tutorial r1"})})
assert erg1.daten["neu"] == 1
assert erg2.daten["neu"] == 1 # Topic-Einstellung schlägt globalen Deckel
async def test_laden_snapshot_stabil():
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
qid = db.insert("quellen", topic=topic, url="https://beispiel.de/grundlagen",
url_norm="https://beispiel.de/grundlagen", zweck="korpus")
t = {"run_id": run_id, "topic": topic, "runde": 1,
"payload": db.j({"quelle_id": qid})}
erg1 = await laden.laden({**t})
q = db.one("SELECT * FROM quellen WHERE id=?", qid)
assert q["status"] == "geladen" and q["snapshot"]
inhalt1 = laden.snapshot_lesen(q)
erg2 = await laden.laden({**t}) # Resume: kein Neuschreiben
assert erg2.daten.get("skip")
assert laden.snapshot_lesen(q) == inhalt1
assert len(erg1.neue_tasks) >= 1 # Soll-Extraktions-Chunks
async def test_laden_leere_quelle_befund():
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
qid = db.insert("quellen", topic=topic, url="https://unbekannt.example/x",
url_norm="https://unbekannt.example/x")
erg = await laden.laden({"run_id": run_id, "topic": topic, "runde": 1,
"payload": db.j({"quelle_id": qid})})
assert erg.neue_tasks == []
assert db.one("SELECT status FROM quellen WHERE id=?", qid)["status"] == "leer"
assert db.one("SELECT id FROM befunde WHERE run_id=? AND art='leer'", run_id)

50
tests/test_textkit.py Normal file
View File

@@ -0,0 +1,50 @@
"""Anker-Matching (5 Stufen), Akronym-Kanal, Jaccard, Parser."""
from backend import textkit
def test_finde_zitat_exakt_und_whitespace():
text = "Der Satz steht\nhier drin. Und mehr."
span = textkit.finde_zitat(text, "Der Satz steht hier drin.")
assert span and text[span[0]:span[1]].startswith("Der")
def test_finde_zitat_casefold_und_umlaute():
text = "Die ÜBERTRAGUNG von Daten ist zentral für jedes Netzwerk im Betrieb."
assert textkit.finde_zitat(text, "die übertragung von daten ist zentral")
def test_finde_zitat_fuzzy_mit_negations_guard():
basis = ("Eine Sprache ist regulär, wenn ein endlicher Automat sie "
"akzeptiert und verarbeitet.")
text = f"Einleitung. {basis} Schluss."
leicht_anders = basis.replace("verarbeitet", "verarbeiten kann")
assert textkit.finde_zitat(text, leicht_anders)
# Negation im Zitat, nicht im Text → KEIN Anker (Antonym-Falle)
negiert = basis.replace("ist regulär", "ist nicht regulär")
assert textkit.finde_zitat(text, negiert) is None
def test_akronym_kanal():
assert textkit.ist_akronym_von("DNS", "Domain Name System")
assert not textkit.ist_akronym_von("HTTP", "Hypertext Markup Language")
assert not textkit.ist_akronym_von("A", "Anfang")
def test_jaccard_gestemmt():
a = "Die Führung von Teams erfordert Erfahrung."
b = "Teams führen erfordert erfahrene Personen."
assert textkit.jaccard(a, b) > textkit.jaccard(a, "Völlig anderes Thema hier.")
def test_bloecke_parser():
text = ("Prosa davor\n===ATOM===\nTITEL: A\nDEFINITION: Zeile 1\nZeile 2\n"
"===ATOM===\nTITEL: B\nDEFINITION: x\n")
bloecke = textkit.bloecke(text, "ATOM")
assert len(bloecke) == 2
assert bloecke[0]["titel"] == "A"
assert "Zeile 2" in bloecke[0]["definition"]
def test_ueberlappung():
assert textkit.ueberlappung((0, 100), (50, 150)) == 0.5
assert textkit.ueberlappung((0, 10), (20, 30)) == 0.0

122
tests/test_verdichtung.py Normal file
View File

@@ -0,0 +1,122 @@
"""Verdichtung: Fusion nur im Schnitt beider Panel-Stimmen, Anker-Vererbung,
verdichtet-Flag, Coverage bleibt erhalten."""
from backend import config, db, inventar
from .conftest import topic_anlegen
def _welt(topic, n_vorteile=3):
run_id = db.insert("runs", topic=topic, status="running")
sid = db.insert("soll", topic=topic, punkt="Vorteile erklären",
status="bestaetigt")
ids = []
for i in range(n_vorteile):
aid = db.insert("atome", topic=topic, titel=f"Vorteil Nr{i}",
definition=f"Definition des Vorteils Nummer {i} hier.",
status="aktiv", soll_id=sid)
db.insert("anker", atom_id=aid, quelle_id=i % 2 + 1, start=i * 100,
ende=i * 100 + 50, zitat=f"z{i}")
ids.append(aid)
return run_id, sid, ids
def _task(run_id, topic, sid):
return {"run_id": run_id, "topic": topic, "runde": 1,
"item": f"r1:verd:soll:{sid}", "payload": db.j({"soll_id": sid})}
async def test_fusion_mit_anker_vererbung():
topic = topic_anlegen()
run_id, sid, ids = _welt(topic)
erg = await inventar.verdichtung(_task(run_id, topic, sid))
assert erg.daten["nachher"] == 1 # Fake gruppiert alle "Vorteil"-Atome
kopf = db.one("SELECT id FROM atome WHERE soll_id=? AND status='aktiv'", sid)
assert db.one("SELECT COUNT(*) c FROM anker WHERE atom_id=?",
kopf["id"])["c"] == 3 # alle Anker geerbt
verworfen = db.query("SELECT merged_into FROM atome WHERE soll_id=? AND "
"status='verworfen'", sid)
assert len(verworfen) == 2
assert db.one("SELECT verdichtet FROM soll WHERE id=?", sid)["verdichtet"] == 1
# Coverage bleibt: Punkt hat weiter ≥1 aktives Atom
assert db.one("SELECT id FROM atome WHERE soll_id=? AND status='aktiv'", sid)
async def test_nur_einstimmige_paare_fusionieren(monkeypatch):
"""Stimme A gruppiert {1,2,3}, Stimme B nur {1,2} → fusioniert wird nur
das einstimmige Paar (1,2)."""
topic = topic_anlegen()
run_id, sid, ids = _welt(topic)
antworten = iter([
f"===EINHEIT===\nIDS: {ids[0]},{ids[1]},{ids[2]}",
f"===EINHEIT===\nIDS: {ids[0]},{ids[1]}\n===EINHEIT===\nIDS: {ids[2]}",
])
async def zwei_stimmen(**kw):
return next(antworten)
from backend import llm
monkeypatch.setattr(llm, "call", zwei_stimmen)
erg = await inventar.verdichtung(_task(run_id, topic, sid))
assert erg.daten["fusionen"] == 1
assert erg.daten["nachher"] == 2 # {0,1} fusioniert, 2 bleibt allein
async def test_flag_verhindert_zweiten_lauf():
topic = topic_anlegen()
run_id, sid, ids = _welt(topic)
await inventar.verdichtung(_task(run_id, topic, sid))
erg2 = await inventar.verdichtung({**_task(run_id, topic, sid),
"item": "r2:verd"})
assert erg2.daten.get("skip") is True
async def test_facetten_check_erzeugt_fenster_task(tmp_path):
"""Beleg-Stelle ohne Atom in der Nähe → luecke-Task mit gezieltem Fenster;
nach LUECKEN_RUNDEN_MAX Versuchen → eskaliert (dokumentierte Grenze)."""
from backend import config as cfg
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
weit = "Anfangssatz mit Atomnähe. " + "Füllwort " * 2000 + \
"Die Namensherkunft stammt von der Tomatenuhr aus Italien."
pfad = tmp_path / "q.md"
pfad.write_text(weit, encoding="utf-8")
qid = db.insert("quellen", topic=topic, url="https://x.de/a",
url_norm="https://x.de/a", status="geladen",
snapshot=str(pfad))
sid = db.insert("soll", topic=topic, punkt="Herkunft erklären",
status="bestaetigt", verdichtet=1,
belege=db.j([{"quelle": qid,
"zitat": "Die Namensherkunft stammt von der "
"Tomatenuhr aus Italien."}]))
aid = db.insert("atome", topic=topic, titel="Anfang", status="aktiv",
soll_id=sid)
db.insert("anker", atom_id=aid, quelle_id=qid, start=0, ende=20, zitat="z")
erg = await inventar.gate({"run_id": run_id, "topic": topic, "runde": 1,
"item": "runde:1", "payload": "{}"})
fac = [t for t in erg.neue_tasks if t["knoten"] == "luecke"
and t["payload"].get("fenster")]
assert fac, "Facetten-Lücke muss gezielten Fenster-Task erzeugen"
assert fac[0]["payload"]["fenster"]["quelle_id"] == qid
assert any(b["art"] == "facette" for b in erg.befunde)
# Cap: 2 erfolglose Versuche simulieren → Eskalation statt Endlosschleife
key = fac[0]["item"].split(":", 1)[1]
for r in (2, 3):
db.insert("tasks", run_id=run_id, topic=topic, knoten="luecke",
item=f"r{r}:{key}", status="fertig")
erg2 = await inventar.gate({"run_id": run_id, "topic": topic, "runde": 2,
"item": "runde:2", "payload": "{}"})
assert not any(b["art"] == "facette" for b in erg2.befunde)
assert db.one("SELECT status FROM befunde WHERE art='facette'")[
"status"] == "eskaliert"
async def test_gate_erzeugt_verdichtungs_task():
topic = topic_anlegen()
run_id, sid, ids = _welt(topic)
erg = await inventar.gate({"run_id": run_id, "topic": topic, "runde": 1,
"item": "runde:1", "payload": "{}"})
verd = [t for t in erg.neue_tasks if t["knoten"] == "verdichtung"]
assert verd and verd[0]["payload"]["soll_id"] == sid
# nach Verdichtung: kein neuer Task mehr
db.update("soll", "id=?", (sid,), verdichtet=1)
erg2 = await inventar.gate({"run_id": run_id, "topic": topic, "runde": 2,
"item": "runde:2", "payload": "{}"})
assert not [t for t in erg2.neue_tasks if t["knoten"] == "verdichtung"]