This commit is contained in:
team3
2026-07-07 00:25:23 +02:00
parent f2feb1dbd0
commit f0c1bdacfa
15 changed files with 1363 additions and 367 deletions

View File

@@ -123,86 +123,74 @@ def env(testdb, tmp_path, monkeypatch):
return testdb, _ctx(), {"arbeit": tmp_path}
def _mk_gen_race(outputs):
"""_race-Fake: pro Generator-Slot (…-gN) die gescriptete Antwort als Text;
fehlender Eintrag = Ausfall."""
def _mk_enrich_slot(output):
"""run_single_slot-Fake für die Anreicherung: schreibt die gescriptete Antwort via
payload (wie der Engine-Sink); output=None → Ausfall."""
calls = []
async def fake_race(topic, label, slots, quorum, timeout, provider, on_update=None,
cancelled=None, **kw):
outs = []
for slot in slots:
calls.append(slot["key"])
g = int(slot["key"].rsplit("-g", 1)[1])
out = outputs.get(g)
if out is not None:
outs.append(slot["payload"]((0, json.dumps(out), "")))
return [o for o in outs if o] or None
async def fake(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
calls.append({"key": key, "prompt": prompt})
if output is None:
return FAILED, None
return OK, payload((0, json.dumps(output), ""))
fake_race.calls = calls
return fake_race
fake.calls = calls
return fake
async def test_generate_schnittmenge_wird_consensus(env, monkeypatch):
"""Von beiden Generatoren genannt → consensus (Facts-Union); Einzelnennungen
werden unsicher und gehen zum Prüfer."""
async def test_enrich_reichert_feste_subs_an(env, monkeypatch):
"""Die Subs stehen fest (Board 1). EIN Call füllt Facts + Level + Relevanz — er
erfindet und entfernt nichts; die Board-1-Subs bleiben in Reihenfolge."""
db, ctx, files = env
monkeypatch.setattr(bc, "_race", _mk_gen_race({
1: {"subs": [_sub("Sub A", kp=["k1"]), _sub("Sub B")]},
2: {"subs": [_sub("Sub A", kp=["k2"]), _sub("Sub C")]},
}))
await _seed_rows(db, "alpha", ["Sub A", "Sub B"])
monkeypatch.setattr(bc, "run_single_slot", _mk_enrich_slot({"subs": [
_sub("Sub A", kp=["k1"]),
_sub("Sub B", level="expert", relevance="peripheral", kp=["k2"])]}))
gen = await bc._generate_block(ctx, files, "Alpha", "Grundkonzept")
assert gen["raw"] == {"Alpha": ["Sub A"]}
assert gen["facts"]["Alpha"]["sub a"]["key_points"] == ["k1", "k2"] # Union beider Nennungen
assert {u["title"] for u in gen["unsicher"]} == {"Sub B", "Sub C"}
assert gen["votes"]["sub a"]["level"] == ["beginner", "beginner"]
assert gen["raw"] == {"Alpha": ["Sub A", "Sub B"]}
assert gen["facts"]["Alpha"]["sub a"]["key_points"] == ["k1"]
assert gen["unsicher"] == []
assert gen["votes"]["sub b"] == {"level": ["expert"], "relevance": ["peripheral"]}
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
assert rows["sub a"] == "consensus"
assert rows["sub b"] == rows["sub c"] == "candidate"
assert rows["sub a"] == rows["sub b"] == "consensus"
async def test_generate_degraded_alles_unsicher(env, monkeypatch):
"""Liefert nur EIN Generator, ist kein Konsens möglich — alles wird unsicher,
der Prüfer entscheidet mit Material."""
async def test_enrich_ohne_subs_kein_call(env, monkeypatch):
"""Kein Board-1-Sub → nichts anzureichern, kein Modell-Call."""
db, ctx, files = env
monkeypatch.setattr(bc, "_race", _mk_gen_race({
1: {"subs": [_sub("Sub A"), _sub("Sub B")]}, # g2 fällt aus
}))
gen = await bc._generate_block(ctx, files, "Alpha", "Grundkonzept")
assert gen["raw"] == {"Alpha": []}
assert {u["title"] for u in gen["unsicher"]} == {"Sub A", "Sub B"}
assert not any(r["status"] == "consensus" for r in await db.list_subblocks(TOPIC, "alpha"))
called = []
async def spy(*a, **k):
called.append(1)
return OK, None
monkeypatch.setattr(bc, "run_single_slot", spy)
gen = await bc._generate_block(ctx, files, "Alpha", "d")
assert gen == {"raw": {"Alpha": []}, "facts": {"Alpha": {}}, "unsicher": [], "votes": {}}
assert called == []
async def test_generate_beide_ausgefallen_ist_none(env, monkeypatch):
async def test_enrich_ausgelassener_sub_bleibt_leer(env, monkeypatch):
"""Lässt der Call einen Sub aus, bleibt er erhalten (Vollständigkeit) — mit leeren
Facts, die der Prüfer/QA als Lücke sieht."""
db, ctx, files = env
monkeypatch.setattr(bc, "_race", _mk_gen_race({}))
assert await bc._generate_block(ctx, files, "Alpha", "d") is None
await _seed_rows(db, "alpha", ["Sub A", "Sub B"])
monkeypatch.setattr(bc, "run_single_slot", _mk_enrich_slot({"subs": [_sub("Sub A", kp=["k1"])]}))
gen = await bc._generate_block(ctx, files, "Alpha", "d")
assert gen["raw"] == {"Alpha": ["Sub A", "Sub B"]}
assert gen["facts"]["Alpha"]["sub b"]["key_points"] == []
assert gen["facts"]["Alpha"]["sub b"]["cited_facts"] == []
async def test_generate_seed_garantie(env, monkeypatch):
"""Ungedeckte Seeds gehen als unsicher zum Prüfer (Beleg-Gate liegt dort);
lexikalisch gedeckte Seeds erzeugen keine Dublette."""
async def test_enrich_resume_ohne_neue_calls(env, monkeypatch):
"""Vorhandene enrich-Datei → kein neuer Call, Ergebnis wird übernommen."""
db, ctx, files = env
monkeypatch.setattr(bc, "_race", _mk_gen_race({
1: {"subs": [_sub("Sub A")]}, 2: {"subs": [_sub("Sub A")]},
}))
gen = await bc._generate_block(ctx, files, "Alpha", "d",
seeds=["Escaping Regeln", "Sub"])
assert gen["raw"] == {"Alpha": ["Sub A"]}
assert [u["title"] for u in gen["unsicher"]] == ["Escaping Regeln"] # „Sub" ist gedeckt
assert gen["unsicher"][0]["key_points"] == [] # Seeds kommen ohne Beleg
async def test_generate_resume_ohne_neue_calls(env, monkeypatch):
"""Vorhandene gen-Dateien → kein neuer _race-Call, Ergebnis wird übernommen."""
db, ctx, files = env
fake = _mk_gen_race({1: {"subs": [_sub("Sub A")]}, 2: {"subs": [_sub("Sub A")]}})
monkeypatch.setattr(bc, "_race", fake)
await _seed_rows(db, "alpha", ["Sub A"])
fake = _mk_enrich_slot({"subs": [_sub("Sub A", kp=["k1"])]})
monkeypatch.setattr(bc, "run_single_slot", fake)
gen1 = await bc._generate_block(ctx, files, "Alpha", "d")
n = len(fake.calls)
gen2 = await bc._generate_block(ctx, files, "Alpha", "d")
assert len(fake.calls) == n # alles resumed
assert len(fake.calls) == n # aus Datei resumed
assert gen2["raw"] == gen1["raw"]
@@ -275,24 +263,27 @@ async def test_verify_fremd_nur_einstimmig(env, monkeypatch):
assert rows["css regel"] == "discarded" and rows["echte regel"] == "consensus"
async def test_verify_uebernahme_braucht_beide(env, monkeypatch):
"""Unsicher-Eintrag wird nur bei 2/2 „ja" consensus (samt Generator-Facts);
sonst discarded."""
async def test_verify_keep_all_behaelt_alle_atome(env, monkeypatch):
"""Bottom-up (keep_all=True): kein Board-1-Atom wird entfernt oder gefaltet, selbst
wenn beide Prüfer fremd/gruppen/discard melden. Nur Facts-Korrektur (Hinweis) und
Level/Relevanz greifen weiter."""
db, ctx, files = env
subs = ["Sub A"]
unsicher = [_sub("Unsicher B", kp=["kp b"]), _sub("Unsicher C")]
subs = ["Sub A", "Sub B", "Sub C"]
await _seed_rows(db, "alpha", subs)
await _seed_rows(db, "alpha", ["Unsicher B", "Unsicher C"], status="candidate")
fake = _judge_slot({"1": {"uebernehmen": {"2": "ja", "3": "ja"}},
"2": {"uebernehmen": {"2": "ja", "3": "nein"}}})
fix = {"subs": [_sub("Sub A", kp=["korrigiert"])]}
verdikt = {"fremd": [3], "gruppen": [{"haupt": 1, "weitere": [2]}],
"facts_probleme": [{"nr": 1, "discard": True, "hinweis": "Zahl falsch"},
{"nr": 2, "discard": True}],
"levels": {"2": "expert"}}
fake = _judge_slot({"1": verdikt, "2": verdikt}, fix=fix)
monkeypatch.setattr(bc, "run_single_slot", fake)
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs, unsicher=unsicher), {})
assert res["raw"] == {"Alpha": ["Sub A", "Unsicher B"]}
assert res["facts"]["Alpha"]["unsicher b"]["key_points"] == ["kp b"]
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {}, keep_all=True)
assert res["raw"] == {"Alpha": subs} # alle drei bleiben — nichts entfernt/gefaltet
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
assert rows["unsicher b"] == "consensus" and rows["unsicher c"] == "discarded"
# der Prüfer-Prompt weist die Unsicher-Nummern aus
assert "UNSICHER" in fake.calls[0]["prompt"] and "entries 23" in fake.calls[0]["prompt"]
assert all(rows[_norm_title(s)] == "consensus" for s in subs)
side = {s["title"]: s for s in res["sidecar"]["Alpha"]}
assert side["Sub A"]["facts"]["key_points"] == ["korrigiert"] # Hinweis trotz discard=True
assert side["Sub B"]["level"] == "expert" # Level-Korrektur greift
async def test_verify_facts_discard_nur_2von2(env, monkeypatch):

View File

@@ -78,7 +78,8 @@ async def board_env(testdb, tmp_path, monkeypatch):
for s in subs}},
"unsicher": [], "votes": {}}
async def fake_verify(ctx, files, title, gen, q, instructions="", ns="", lbl="", sources=None, melde=None):
async def fake_verify(ctx, files, title, gen, q, instructions="", ns="", lbl="", sources=None,
melde=None, keep_all=False):
subs = gen["raw"].get(title) or []
bfacts = gen["facts"].get(title) or {}
sidecar = [{"title": s, "level": "beginner",
@@ -776,9 +777,11 @@ async def test_outline_runs_before_artefacts_finish(board_env, monkeypatch):
base_verify = ba._verify_block
snapshot = {}
async def slow_verify(ctx, files, title, gen, q, instructions="", ns="", lbl="", sources=None, melde=None):
async def slow_verify(ctx, files, title, gen, q, instructions="", ns="", lbl="", sources=None,
melde=None, keep_all=False):
await asyncio.sleep(0.8) # keeps one card in `verify` while the outline fires
return await base_verify(ctx, files, title, gen, q, instructions, ns=ns, lbl=lbl, sources=sources)
return await base_verify(ctx, files, title, gen, q, instructions, ns=ns, lbl=lbl,
sources=sources, keep_all=keep_all)
base_outline = ba._outline_block

View File

@@ -42,11 +42,16 @@ async def test_e2e_thema_vollpfad(fake_welt, testdb, tmp_path):
db = testdb
done = [c for c in await db.kanban_cards(TOPIC, board="inventory", stage="done_block")]
titel = {c["payload"]["title"] for c in done}
# Bottom-up: 7 Atome zu 3 Themen-Clustern gruppiert
assert titel == {"Alpha-Konzept", "Beta-Verfahren", "Gamma-Anwendung"}
# Cross-Block-Dublette: „Gemeinsamer Grundbegriff" überlebt in genau EINEM Block
subs = [dict(r) for r in await db.list_subblocks(TOPIC)]
# jedes Atom liegt in genau EINEM Cluster — keine Cross-Block-Dublette mehr
gemeinsam = [r for r in subs if r["sub_norm"] == "gemeinsamer grundbegriff"]
assert sorted(r["status"] for r in gemeinsam) == ["consensus", "variant"]
assert [r["status"] for r in gemeinsam] == ["consensus"]
# Vollständigkeit: alle 7 Atome sind als consensus-Sub erhalten
consensus = {r["sub_norm"] for r in subs if r["status"] == "consensus"}
assert consensus == {"definition alpha", "alpha eigenschaften", "gemeinsamer grundbegriff",
"beta ablauf", "beta grenzen", "gamma praxisfall", "gamma werkzeuge"}
fehler = await pruefe_invarianten(TOPIC, files)
assert fehler == []
@@ -83,9 +88,7 @@ async def test_e2e_rerun_idempotent(fake_welt, testdb, tmp_path):
@pytest.mark.parametrize("stoerung", [
{"muster": r"-sub-crossblock-.*-j1$", "modus": "fehler", "mal": 3}, # Ersatzrichter jE
{"muster": r"-sb-verify-.*-j1$", "modus": "garbage", "mal": 1}, # Ersatz-Richter jE
{"muster": r"-sb-gen-.*-g1$", "modus": "fehler", "mal": 1}, # degraded: 1 Generator
{"muster": r"-art-gen-.*-t1$", "modus": "fehler", "mal": 1}, # Slot-Restart
{"muster": r"-research-2$", "modus": "fehler", "mal": 3}, # 1 Producer tot
])
@@ -97,35 +100,6 @@ async def test_e2e_stoerungen_flow_endet(fake_welt, testdb, tmp_path, stoerung):
assert await pruefe_invarianten(TOPIC, files) == []
async def test_e2e_crossblock_dissent_failopen(fake_welt, testdb, tmp_path):
"""j1 sagt a, j2 sagt b, j3 fällt aus → Paar bleibt (fail-open), Rest konsistent."""
fake_welt.stoerungen += [
{"muster": r"-sub-crossblock-.*-j2$", "modus": "antwort",
"antwort": '{"pairs": {"1": "b"}}', "mal": 1, "rest": 1},
{"muster": r"-sub-crossblock-.*-j3$", "modus": "fehler", "mal": 3, "rest": 3},
]
ok, files = await _lauf(tmp_path)
assert ok
db = testdb
subs = [dict(r) for r in await db.list_subblocks(TOPIC)]
gemeinsam = [r for r in subs if r["sub_norm"] == "gemeinsamer grundbegriff"]
assert sorted(r["status"] for r in gemeinsam) == ["consensus", "consensus"] # kein Fold
assert await pruefe_invarianten(TOPIC, files) == []
async def test_e2e_inblock_gruppe_faltet(fake_welt, testdb, tmp_path):
"""Welt-Regel: „Alpha Eigenschaften" faltet unter „Definition Alpha" — beide Judges
liefern die Gruppe, der Verlierer wird variant, seine facts wandern zum Gewinner."""
fake_welt.gruppen.append(("Definition Alpha", ["Alpha Eigenschaften"]))
ok, files = await _lauf(tmp_path)
assert ok
db = testdb
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha-konzept")}
assert rows.get("alpha eigenschaften") == "variant"
assert rows.get("definition alpha") == "consensus"
assert await pruefe_invarianten(TOPIC, files) == []
async def test_e2e_gate_vollinventur_ohne_fix(fake_welt, testdb, tmp_path):
"""Gate-Judge liefert eine Voll-Inventur (belegte Claims mit „Belegt…"-Grund) —
der Schema-Filter wirft sie raus, es läuft KEIN Fakten-Fix."""
@@ -189,10 +163,11 @@ async def test_e2e_uni_anker_gate(fake_welt, testdb, tmp_path, monkeypatch):
assert ok
db = testdb
alle = [dict(c) for c in await db.kanban_cards(TOPIC, board="inventory")]
assert any(c["stage"] == "rejected" and c["payload"].get("title") == "Kanon-Klassiker"
# das unbelegte Atom „Klassiker Detail" (einziges Atom von Kanon-Klassiker) wird rejected
assert any(c["stage"] == "rejected" and c["payload"].get("title") == "Klassiker Detail"
for c in alle)
assert not any(c["kind"] == "block" and c["payload"].get("title") == "Kanon-Klassiker"
for c in alle) # nie zum Block geworden
assert not any(c["payload"].get("title") in ("Klassiker Detail", "Kanon-Klassiker")
and c["stage"] == "done_block" for c in alle) # nie zum Block geworden
done = {c["payload"].get("title") for c in alle
if c["kind"] == "block" and c["stage"] == "done_block"}
assert {"Alpha-Konzept", "Beta-Verfahren", "Gamma-Anwendung"} <= done