init
This commit is contained in:
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
48
tests/conftest.py
Normal file
48
tests/conftest.py
Normal 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
114
tests/test_buendel.py
Normal 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
|
||||
209
tests/test_dedup_redundanz.py
Normal file
209
tests/test_dedup_redundanz.py
Normal 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
120
tests/test_e2e.py
Normal 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
85
tests/test_engine.py
Normal 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
282
tests/test_gates.py
Normal 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
44
tests/test_graph.py
Normal 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
22
tests/test_korpus.py
Normal 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
134
tests/test_llm.py
Normal 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
44
tests/test_skills.py
Normal 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
34
tests/test_struktur.py
Normal 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
167
tests/test_suche_laden.py
Normal 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
50
tests/test_textkit.py
Normal 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
122
tests/test_verdichtung.py
Normal 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"]
|
||||
Reference in New Issue
Block a user