update
This commit is contained in:
538
backend/tests/test_block_calls.py
Normal file
538
backend/tests/test_block_calls.py
Normal file
@@ -0,0 +1,538 @@
|
||||
"""Verschmolzene Board-2-Calls (block_calls.py): Generate-Konsens, Verify-Faltung mit
|
||||
Fix-Tail, Artefakte in einem Durchgang — Agenten gefaked, gegen Test-DB."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
import block_calls as bc
|
||||
import blocks as blx
|
||||
import board_artefacts as ba
|
||||
from pipeline import FAILED, OK, GenContext
|
||||
from textkit import _norm_title
|
||||
|
||||
TOPIC = "t"
|
||||
|
||||
|
||||
def _ctx():
|
||||
return GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
|
||||
|
||||
|
||||
def _sub(title, level="beginner", relevance="relevant", kp=None, cf=None):
|
||||
return {"title": title, "level": level, "relevance": relevance,
|
||||
"key_points": [f"kp {title}"] if kp is None else kp,
|
||||
"prerequisites": "", "hurdles": "", "cited_facts": cf or [], "example_idea": ""}
|
||||
|
||||
|
||||
# ── Schemas ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_gen_schema_normalisiert():
|
||||
"""Gültige Einträge werden normalisiert; ungültiges level/relevance fällt auf ""
|
||||
(Stimme entfällt, der Sub bleibt); Einträge ohne Titel fliegen."""
|
||||
out = bc._gen_schema({"subs": [
|
||||
{"title": " **A** ", "level": "Beginner", "relevance": "RELEVANT",
|
||||
"key_points": ["k", ""], "cited_facts": [{"text": "t", "source": " s "},
|
||||
{"text": ""}, "quatsch"]},
|
||||
{"title": "B", "level": "profi", "relevance": "mittel"},
|
||||
{"title": " "},
|
||||
]})
|
||||
assert [e["title"] for e in out] == ["A", "B"]
|
||||
assert out[0]["level"] == "beginner" and out[0]["relevance"] == "relevant"
|
||||
assert out[0]["key_points"] == ["k"]
|
||||
assert out[0]["cited_facts"] == [{"text": "t", "source": "s"}]
|
||||
assert out[1]["level"] == "" and out[1]["relevance"] == ""
|
||||
|
||||
|
||||
def test_gen_schema_kaputt_ist_none():
|
||||
assert bc._gen_schema(None) is None
|
||||
assert bc._gen_schema({"subs": "x"}) is None
|
||||
assert bc._gen_schema({"subs": []}) is None
|
||||
assert bc._gen_schema({"subs": [{"level": "beginner"}]}) is None # nur titellose Einträge
|
||||
|
||||
|
||||
def test_verify_schema_pflichtkeys_und_leeres_verdikt():
|
||||
"""Mindestens EIN Pflicht-Key muss da sein; leere Listen heißen „alles ok"."""
|
||||
assert bc._verify_schema({}, 3) is None
|
||||
assert bc._verify_schema({"irgendwas": 1}, 3) is None
|
||||
v = bc._verify_schema({"gruppen": []}, 3)
|
||||
assert v["gruppen"] == [] and v["fremd"] == set() and v["luecken"] == []
|
||||
assert v["uebernehmen"] == {} and v["facts_probleme"] == [] and v["levels"] == {}
|
||||
|
||||
|
||||
def test_verify_schema_grenzen_und_normalisierung():
|
||||
"""ids außerhalb 1..n und bools fallen raus; Ein-Element-Gruppen zählen nicht;
|
||||
uebernehmen/levels werden casefolded bzw. enum-geprüft."""
|
||||
v = bc._verify_schema({
|
||||
"gruppen": [{"haupt": 2, "weitere": [1, 9, True]}, {"haupt": 3, "weitere": []}],
|
||||
"kataloge": [{"titel": " K ", "mitglieder": [1, 2]}, {"titel": "", "mitglieder": [1, 2]}],
|
||||
"fremd": [True, 1, "2", 9],
|
||||
"luecken": [" x ", "", 7],
|
||||
"uebernehmen": {"3": " JA ", "9": "ja"},
|
||||
"facts_probleme": [{"nr": 2, "discard": 1, "hinweis": " h "}, {"nr": 9}, "quatsch"],
|
||||
"levels": {"1": "expert", "2": "quatsch"},
|
||||
"relevanz": {"1": "peripheral"},
|
||||
}, 3)
|
||||
assert v["gruppen"] == [{"haupt": 2, "ids": [1, 2]}]
|
||||
assert v["kataloge"] == [{"titel": "K", "ids": [1, 2]}]
|
||||
assert v["fremd"] == {1, 2}
|
||||
assert v["luecken"] == ["x"]
|
||||
assert v["uebernehmen"] == {3: "ja"}
|
||||
assert v["facts_probleme"] == [{"nr": 2, "discard": True, "hinweis": "h"}]
|
||||
assert v["levels"] == {1: "expert"} and v["relevanz"] == {1: "peripheral"}
|
||||
|
||||
|
||||
def test_art_gen_schema_pattern_ist_pflicht():
|
||||
"""Ohne verwertbares pattern kein Verdikt (Leitner hängt an den Fragen);
|
||||
cards/examples sind best-effort und werden einzeln validiert."""
|
||||
assert bc._art_gen_schema({"cards": [], "examples": []}) is None
|
||||
assert bc._art_gen_schema("x") is None
|
||||
out = bc._art_gen_schema({
|
||||
"pattern": [{"block": "B", "subblock": "S", "question": "F?"},
|
||||
{"block": "B", "subblock": "", "question": "F?"}],
|
||||
"cards": [{"block": "B", "subblock": "S", "question": "F?", "answer": "A"},
|
||||
{"block": "B", "subblock": "S", "question": "F?"}],
|
||||
"examples": [{"block": "B", "subblock": "S", "problem": "P", "steps": ["s1", ""], "result": ""},
|
||||
{"block": "B", "subblock": "S", "problem": "P", "steps": []}],
|
||||
})
|
||||
assert len(out["pattern"]) == 1 and len(out["cards"]) == 1
|
||||
assert out["examples"] == [{"block": "B", "subblock": "S", "problem": "P",
|
||||
"steps": ["s1"], "result": ""}]
|
||||
|
||||
|
||||
def test_art_check_schema_varianten():
|
||||
"""{"ok": true} → leeres Verdikt; ohne bekannten Key None; Beispiel-Indizes sind
|
||||
1-basiert, bools/0 zählen nicht."""
|
||||
ok = bc._art_check_schema({"ok": True})
|
||||
assert ok == {"pattern": [], "pattern_ergaenzt": [], "examples_probleme": set()}
|
||||
assert bc._art_check_schema({"foo": 1}) is None
|
||||
v = bc._art_check_schema({"examples_probleme": [1, "2", {"index": 3}, True, 0, -1],
|
||||
"pattern_ergaenzt": [{"block": "B", "subblock": "S", "question": "F?"}]})
|
||||
assert v["examples_probleme"] == {1, 2, 3}
|
||||
assert len(v["pattern_ergaenzt"]) == 1 and v["pattern"] == []
|
||||
|
||||
|
||||
# ── Generate ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def env(testdb, tmp_path, monkeypatch):
|
||||
"""Ohne Modell (exakte Norm-Gleichheit), ohne Korpus (thema-Selbst-Recherche)."""
|
||||
monkeypatch.setattr(bc, "EMBEDDING_AKTIV", False)
|
||||
monkeypatch.setattr(blx, "EMBEDDING_AKTIV", False) # _dedup_subblocks aus
|
||||
monkeypatch.setattr(bc, "material_folder", lambda t: None)
|
||||
monkeypatch.setattr(bc, "load_source", lambda t: {"type": "thema"})
|
||||
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."""
|
||||
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
|
||||
|
||||
fake_race.calls = calls
|
||||
return fake_race
|
||||
|
||||
|
||||
async def test_generate_schnittmenge_wird_consensus(env, monkeypatch):
|
||||
"""Von beiden Generatoren genannt → consensus (Facts-Union); Einzelnennungen
|
||||
werden unsicher und gehen zum Prüfer."""
|
||||
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")]},
|
||||
}))
|
||||
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"]
|
||||
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"
|
||||
|
||||
|
||||
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."""
|
||||
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"))
|
||||
|
||||
|
||||
async def test_generate_beide_ausgefallen_ist_none(env, monkeypatch):
|
||||
db, ctx, files = env
|
||||
monkeypatch.setattr(bc, "_race", _mk_gen_race({}))
|
||||
assert await bc._generate_block(ctx, files, "Alpha", "d") is None
|
||||
|
||||
|
||||
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."""
|
||||
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)
|
||||
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 gen2["raw"] == gen1["raw"]
|
||||
|
||||
|
||||
# ── Verify (+ Fix-Tail) ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _gen_von(title, subs, unsicher=None, votes=None):
|
||||
"""Karten-Payload wie aus _generate_block: raw/facts/unsicher/votes."""
|
||||
return {"raw": {title: list(subs)},
|
||||
"facts": {title: {_norm_title(s): {"key_points": [f"kp {s}"], "prerequisites": "",
|
||||
"hurdles": "", "cited_facts": [], "example_idea": ""}
|
||||
for s in subs}},
|
||||
"unsicher": unsicher or [], "votes": votes or {}}
|
||||
|
||||
|
||||
def _judge_slot(antworten, fix=None):
|
||||
"""run_single_slot-Fake: Prüfer-Antwort je j-Suffix, Fix-Antwort für -sb-fix-;
|
||||
fehlender Eintrag = FAILED."""
|
||||
calls = []
|
||||
|
||||
async def fake(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
|
||||
calls.append({"key": key, "prompt": prompt})
|
||||
if "-sb-fix-" in key:
|
||||
if fix is None:
|
||||
return FAILED, None
|
||||
return OK, payload((0, json.dumps(fix), ""))
|
||||
j = key.rsplit("-j", 1)[-1]
|
||||
antwort = antworten.get(j)
|
||||
if antwort is None:
|
||||
return FAILED, None
|
||||
return OK, payload((0, json.dumps(antwort), ""))
|
||||
|
||||
fake.calls = calls
|
||||
return fake
|
||||
|
||||
|
||||
async def _seed_rows(db, bnorm, titles, status="consensus"):
|
||||
for t in titles:
|
||||
await db.put_subblock(TOPIC, bnorm, _norm_title(t), bnorm.title(), t, status=status)
|
||||
|
||||
|
||||
async def test_verify_gruppe_faltet_einstimmig(env, monkeypatch):
|
||||
"""Beide Prüfer gruppieren 1+2 → haupt gewinnt, Verlierer wird variant und seine
|
||||
Facts wandern per Union zum Gewinner; Dissens-Gruppen falten nicht."""
|
||||
db, ctx, files = env
|
||||
subs = ["Marker Regel", "Marker Regel im Detail erklärt", "Eigenes Thema"]
|
||||
await _seed_rows(db, "alpha", subs)
|
||||
verdikt = {"gruppen": [{"haupt": 2, "weitere": [1]}]}
|
||||
fake = _judge_slot({"1": verdikt, "2": {"gruppen": [{"haupt": 2, "weitere": [1]},
|
||||
{"haupt": 3, "weitere": [2]}]}})
|
||||
monkeypatch.setattr(bc, "run_single_slot", fake)
|
||||
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
|
||||
assert res["raw"] == {"Alpha": [subs[1], subs[2]]} # Gruppe 2+3 war einseitig → kein Fold
|
||||
wf = res["facts"]["Alpha"][_norm_title(subs[1])]
|
||||
assert wf["key_points"] == [f"kp {subs[1]}", f"kp {subs[0]}"] # Union geerbt
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||
assert rows[_norm_title(subs[0])] == "variant"
|
||||
assert rows[_norm_title(subs[1])] == "consensus"
|
||||
|
||||
|
||||
async def test_verify_fremd_nur_einstimmig(env, monkeypatch):
|
||||
"""Fremd 2/2 → discarded + raus; einseitig fremd → bleibt."""
|
||||
db, ctx, files = env
|
||||
subs = ["CSS Regel", "Echte Regel"]
|
||||
await _seed_rows(db, "alpha", subs)
|
||||
fake = _judge_slot({"1": {"fremd": [1, 2]}, "2": {"fremd": [1]}})
|
||||
monkeypatch.setattr(bc, "run_single_slot", fake)
|
||||
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
|
||||
assert res["raw"] == {"Alpha": ["Echte Regel"]}
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||
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."""
|
||||
db, ctx, files = env
|
||||
subs = ["Sub A"]
|
||||
unsicher = [_sub("Unsicher B", kp=["kp b"]), _sub("Unsicher 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"}}})
|
||||
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"]
|
||||
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 2–3" in fake.calls[0]["prompt"]
|
||||
|
||||
|
||||
async def test_verify_facts_discard_nur_2von2(env, monkeypatch):
|
||||
"""Facts-Discard ist irreversibel → nur 2/2; die einseitige Stimme ohne Hinweis
|
||||
löst auch keinen Fix aus."""
|
||||
db, ctx, files = env
|
||||
subs = ["Sub A", "Sub B"]
|
||||
await _seed_rows(db, "alpha", subs)
|
||||
fake = _judge_slot({"1": {"facts_probleme": [{"nr": 1, "discard": True},
|
||||
{"nr": 2, "discard": True}]},
|
||||
"2": {"facts_probleme": [{"nr": 1, "discard": True}]}})
|
||||
monkeypatch.setattr(bc, "run_single_slot", fake)
|
||||
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
|
||||
assert res["raw"] == {"Alpha": ["Sub B"]}
|
||||
assert not any("-sb-fix-" in c["key"] for c in fake.calls)
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||
assert rows["sub a"] == "discarded" and rows["sub b"] == "consensus"
|
||||
|
||||
|
||||
async def test_verify_korrektur_ab_einer_stimme(env, monkeypatch):
|
||||
"""Ein Hinweis EINES Prüfers reicht: der Fix-Call läuft und ersetzt die Facts des
|
||||
beanstandeten Subs; die Einstufung bleibt."""
|
||||
db, ctx, files = env
|
||||
subs = ["Sub A", "Sub B"]
|
||||
await _seed_rows(db, "alpha", subs)
|
||||
fix = {"subs": [_sub("Sub A", kp=["korrigierte Aussage"])]}
|
||||
fake = _judge_slot({"1": {"facts_probleme": [{"nr": 1, "hinweis": "Zahl falsch"}]},
|
||||
"2": {"gruppen": []}}, fix=fix)
|
||||
monkeypatch.setattr(bc, "run_single_slot", fake)
|
||||
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
|
||||
assert any("-sb-fix-" in c["key"] for c in fake.calls)
|
||||
side = {s["title"]: s for s in res["sidecar"]["Alpha"]}
|
||||
assert side["Sub A"]["facts"]["key_points"] == ["korrigierte Aussage"]
|
||||
assert res["facts"]["Alpha"]["sub a"]["key_points"] == ["korrigierte Aussage"]
|
||||
assert side["Sub B"]["facts"]["key_points"] == ["kp Sub B"] # unbeanstandet
|
||||
|
||||
|
||||
async def test_verify_luecke_belegt_wird_neuer_sub(env, monkeypatch):
|
||||
"""Lücken-Schnitt beider Prüfer → Fix legt den belegten Fund als neuen consensus-Sub
|
||||
an; ein unbelegter „Fund" verfällt am Beleg-Gate."""
|
||||
db, ctx, files = env
|
||||
subs = ["Sub A"]
|
||||
await _seed_rows(db, "alpha", subs)
|
||||
fix = {"subs": [_sub("Escaping von Sonderzeichen", level="expert", kp=["belegt"]),
|
||||
_sub("Unbelegte Behauptung", kp=[])]}
|
||||
fake = _judge_slot({"1": {"luecken": ["Escaping fehlt"]},
|
||||
"2": {"luecken": ["Escaping unbehandelt"]}}, fix=fix)
|
||||
monkeypatch.setattr(bc, "run_single_slot", fake)
|
||||
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
|
||||
assert res["raw"] == {"Alpha": ["Sub A", "Escaping von Sonderzeichen"]}
|
||||
neu = next(s for s in res["sidecar"]["Alpha"] if s["title"] == "Escaping von Sonderzeichen")
|
||||
assert neu["level"] == "expert" and neu["facts"]["key_points"] == ["belegt"]
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||
assert rows[_norm_title("Escaping von Sonderzeichen")] == "consensus"
|
||||
assert _norm_title("Unbelegte Behauptung") not in rows
|
||||
|
||||
|
||||
async def test_verify_ersatzrichter_bei_ausfall(env, monkeypatch):
|
||||
"""Fällt EIN Prüfer aus, springt der Ersatz jE ein — Einstimmigkeit mit ihm faltet."""
|
||||
db, ctx, files = env
|
||||
subs = ["CSS Regel", "Echte Regel"]
|
||||
await _seed_rows(db, "alpha", subs)
|
||||
fake = _judge_slot({"1": {"fremd": [1]}, "E": {"fremd": [1]}}) # j2 → FAILED
|
||||
monkeypatch.setattr(bc, "run_single_slot", fake)
|
||||
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
|
||||
assert res["raw"] == {"Alpha": ["Echte Regel"]}
|
||||
assert [c["key"].rsplit("-j", 1)[-1] for c in fake.calls] == ["1", "2", "E"]
|
||||
|
||||
|
||||
async def test_verify_failopen_verwirft_nur_unsicher(env, monkeypatch):
|
||||
"""Nur 1 Prüfer (auch der Ersatz fällt aus) → fail-open: consensus bleibt unangetastet,
|
||||
unsicher wird verworfen (ohne Panel keine Übernahme-Entscheidung)."""
|
||||
db, ctx, files = env
|
||||
subs = ["Sub A"]
|
||||
unsicher = [_sub("Unsicher B")]
|
||||
await _seed_rows(db, "alpha", subs)
|
||||
await _seed_rows(db, "alpha", ["Unsicher B"], status="candidate")
|
||||
fake = _judge_slot({"1": {"fremd": [1], "uebernehmen": {"2": "ja"}}}) # j2+jE → FAILED
|
||||
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"]} # fremd-Einzelstimme wirkt NICHT
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||
assert rows["sub a"] == "consensus" and rows["unsicher b"] == "discarded"
|
||||
|
||||
|
||||
async def test_verify_level_korrektur_wiegt_doppelt(env, monkeypatch):
|
||||
"""Explizite Prüfer-Korrektur (×2) schlägt die Generator-Stimme; Patt fällt auf
|
||||
advanced/relevant (heutige Defaults)."""
|
||||
db, ctx, files = env
|
||||
subs = ["Sub A", "Sub B"]
|
||||
await _seed_rows(db, "alpha", subs)
|
||||
votes = {"sub a": {"level": ["beginner"], "relevance": []},
|
||||
"sub b": {"level": ["beginner", "expert"], "relevance": []}}
|
||||
fake = _judge_slot({"1": {"levels": {"1": "expert"}}, "2": {"gruppen": []}})
|
||||
monkeypatch.setattr(bc, "run_single_slot", fake)
|
||||
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs, votes=votes), {})
|
||||
side = {s["title"]: s for s in res["sidecar"]["Alpha"]}
|
||||
assert side["Sub A"]["level"] == "expert" # 2× Korrektur > 1× Generator
|
||||
assert side["Sub B"]["level"] == "advanced" # 1:1-Patt → Default
|
||||
assert side["Sub A"]["relevance"] == "relevant" # keine Stimme → Default
|
||||
|
||||
|
||||
async def test_verify_resume_ohne_neue_calls(env, monkeypatch):
|
||||
"""Vorhandene verify-j-Dateien → kein neuer Prüfer-Call."""
|
||||
db, ctx, files = env
|
||||
subs = ["Sub A"]
|
||||
await _seed_rows(db, "alpha", subs)
|
||||
fake = _judge_slot({"1": {"gruppen": []}, "2": {"gruppen": []}})
|
||||
monkeypatch.setattr(bc, "run_single_slot", fake)
|
||||
await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
|
||||
n = len(fake.calls)
|
||||
await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
|
||||
assert len(fake.calls) == n
|
||||
|
||||
|
||||
# ── Artefakte ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _sidecar(titles):
|
||||
return [{"title": t, "level": "beginner", "relevance": "relevant",
|
||||
"facts": {"key_points": [f"kp {t}"]}} for t in titles]
|
||||
|
||||
|
||||
def _art_slot(gen_out, check_out):
|
||||
"""run_single_slot-Fake für Artefakte: gen_out je Teil (dict oder callable(prompt)),
|
||||
check_out fürs Prüfer-Verdikt."""
|
||||
calls = []
|
||||
|
||||
async def fake(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
|
||||
calls.append({"key": key, "prompt": prompt})
|
||||
if "-art-gen-" in key:
|
||||
out = gen_out(prompt) if callable(gen_out) else gen_out
|
||||
return OK, payload((0, json.dumps(out), ""))
|
||||
if "-art-check-" in key:
|
||||
if check_out is None:
|
||||
return FAILED, None
|
||||
return OK, payload((0, json.dumps(check_out), ""))
|
||||
raise AssertionError(f"unerwarteter Call {key}")
|
||||
|
||||
fake.calls = calls
|
||||
return fake
|
||||
|
||||
|
||||
async def test_artefakte_ein_call_liefert_alles(env, monkeypatch):
|
||||
"""EIN Generator-Call liefert pattern+cards+examples, der Prüfer sagt ok →
|
||||
Rohfassung wird übernommen, block-Feld auf den Karten-Block normiert."""
|
||||
db, ctx, files = env
|
||||
gen_out = {"pattern": [{"block": "Echo", "subblock": "Sub A", "question": "F?"}],
|
||||
"cards": [{"block": "Echo", "subblock": "Sub A", "question": "F?", "answer": "A"}],
|
||||
"examples": [{"block": "Echo", "subblock": "Sub A", "problem": "P",
|
||||
"steps": ["s1"], "result": "R"}]}
|
||||
fake = _art_slot(gen_out, {"ok": True})
|
||||
monkeypatch.setattr(bc, "run_single_slot", fake)
|
||||
res = await bc._artefakte_block(ctx, files, "Alpha", _sidecar(["Sub A"]))
|
||||
assert [c["key"] for c in fake.calls if "-art-gen-" in c["key"]].__len__() == 1
|
||||
assert res["pattern"] == {"Alpha": [{"subblock": "Sub A", "question": "F?"}]}
|
||||
assert res["artefacts"]["flashcard"] == [{"block": "Alpha", "subblock": "Sub A",
|
||||
"question": "F?", "answer": "A"}]
|
||||
assert res["artefacts"]["example"][0]["block"] == "Alpha" # Agent-Echo „Echo" normiert
|
||||
|
||||
|
||||
async def test_artefakte_check_entfernt_beispiel_und_ergaenzt_frage(env, monkeypatch):
|
||||
"""examples_probleme wirft das beanstandete Beispiel; pattern_ergaenzt füllt die
|
||||
fehlende Frage nach — der Prüfer-Prompt listet den fraglosen Sub."""
|
||||
db, ctx, files = env
|
||||
gen_out = {"pattern": [{"block": "Alpha", "subblock": "Sub A", "question": "F?"}],
|
||||
"cards": [],
|
||||
"examples": [{"block": "Alpha", "subblock": "Sub A", "problem": "P1", "steps": ["x"], "result": ""},
|
||||
{"block": "Alpha", "subblock": "Sub A", "problem": "P2", "steps": ["y"], "result": ""}]}
|
||||
check = {"examples_probleme": [{"index": 1}],
|
||||
"pattern_ergaenzt": [{"block": "Alpha", "subblock": "Sub B", "question": "F B?"}]}
|
||||
fake = _art_slot(gen_out, check)
|
||||
monkeypatch.setattr(bc, "run_single_slot", fake)
|
||||
res = await bc._artefakte_block(ctx, files, "Alpha", _sidecar(["Sub A", "Sub B"]))
|
||||
assert [e["problem"] for e in res["artefacts"]["example"]] == ["P2"]
|
||||
assert res["pattern"]["Alpha"] == [{"subblock": "Sub A", "question": "F?"},
|
||||
{"subblock": "Sub B", "question": "F B?"}]
|
||||
check_prompt = next(c["prompt"] for c in fake.calls if "-art-check-" in c["key"])
|
||||
assert "STILL MISSING A QUESTION" in check_prompt and "Sub B" in check_prompt
|
||||
|
||||
|
||||
async def test_artefakte_split_ab_schwelle(env, monkeypatch):
|
||||
"""> ART_SPLIT_SUBS Subs → ZWEI parallele Generator-Calls, jeder sieht nur seine
|
||||
Hälfte; die Ergebnisse werden zusammengeführt."""
|
||||
db, ctx, files = env
|
||||
monkeypatch.setattr(bc, "ART_SPLIT_SUBS", 2)
|
||||
|
||||
def gen_out(prompt):
|
||||
subs = [t for t in ("Sub A", "Sub B", "Sub C") if f"- {t}" in prompt]
|
||||
return {"pattern": [{"block": "Alpha", "subblock": s, "question": f"F {s}?"} for s in subs],
|
||||
"cards": [], "examples": []}
|
||||
|
||||
fake = _art_slot(gen_out, {"ok": True})
|
||||
monkeypatch.setattr(bc, "run_single_slot", fake)
|
||||
res = await bc._artefakte_block(ctx, files, "Alpha", _sidecar(["Sub A", "Sub B", "Sub C"]))
|
||||
gen_keys = [c["key"] for c in fake.calls if "-art-gen-" in c["key"]]
|
||||
assert len(gen_keys) == 2 and gen_keys[0].endswith("-t1") and gen_keys[1].endswith("-t2")
|
||||
assert [p["subblock"] for p in res["pattern"]["Alpha"]] == ["Sub A", "Sub B", "Sub C"]
|
||||
|
||||
|
||||
async def test_artefakte_check_ausfall_uebernimmt_rohfassung(env, monkeypatch):
|
||||
"""Prüfer ohne Ergebnis → fail-open, die Generator-Rohfassung zählt."""
|
||||
db, ctx, files = env
|
||||
gen_out = {"pattern": [{"block": "Alpha", "subblock": "Sub A", "question": "F?"}],
|
||||
"cards": [], "examples": []}
|
||||
fake = _art_slot(gen_out, None)
|
||||
monkeypatch.setattr(bc, "run_single_slot", fake)
|
||||
res = await bc._artefakte_block(ctx, files, "Alpha", _sidecar(["Sub A"]))
|
||||
assert res["pattern"] == {"Alpha": [{"subblock": "Sub A", "question": "F?"}]}
|
||||
|
||||
|
||||
async def test_artefakte_leerer_block(env):
|
||||
db, ctx, files = env
|
||||
res = await bc._artefakte_block(ctx, files, "Alpha", [])
|
||||
assert res == {"pattern": {"Alpha": []}, "artefacts": {"flashcard": [], "example": []}}
|
||||
|
||||
|
||||
# ── Migration der alten Stage-Treppe ────────────────────────────────────────────────
|
||||
|
||||
async def test_migriere_alt_karten(testdb):
|
||||
"""Karten in alten Stages gehen mit reduziertem Payload zurück nach generate
|
||||
(alte Zwischenstände sind für die verschmolzenen Calls wertlos); Terminal- und
|
||||
Neu-Struktur-Karten bleiben unangetastet."""
|
||||
db = testdb
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "facts",
|
||||
{"title": "Alpha", "description": "d", "n_size": 3,
|
||||
"sources": ["s1"], "raw": {"Alpha": ["alt"]},
|
||||
"facts": {"Alpha": {}}})
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "beta", "ablock", "question_pattern",
|
||||
{"title": "Beta", "sidecar": {}})
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "gamma", "ablock", "done_artefact",
|
||||
{"title": "Gamma", "raw": {"Gamma": ["bleibt"]}})
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "delta", "ablock", "verify",
|
||||
{"title": "Delta", "raw": {"Delta": ["neu"]}})
|
||||
n = await ba.migriere_alt_karten(TOPIC)
|
||||
assert n == 2
|
||||
alpha = await db.kanban_get_card(TOPIC, "artefacts", "alpha")
|
||||
assert alpha["stage"] == "generate"
|
||||
assert alpha["payload"] == {"title": "Alpha", "description": "d", "n_size": 3, "sources": ["s1"]}
|
||||
assert (await db.kanban_get_card(TOPIC, "artefacts", "beta"))["stage"] == "generate"
|
||||
gamma = await db.kanban_get_card(TOPIC, "artefacts", "gamma")
|
||||
assert gamma["stage"] == "done_artefact" and gamma["payload"]["raw"] == {"Gamma": ["bleibt"]}
|
||||
delta = await db.kanban_get_card(TOPIC, "artefacts", "delta")
|
||||
assert delta["stage"] == "verify" and delta["payload"]["raw"] == {"Delta": ["neu"]}
|
||||
Reference in New Issue
Block a user