update
This commit is contained in:
507
backend/tests/test_konsolidierung.py
Normal file
507
backend/tests/test_konsolidierung.py
Normal file
@@ -0,0 +1,507 @@
|
||||
"""Sub-Konsolidierung: In-Block-Panel (blocks._konsolidiere_subblocks) und
|
||||
Cross-Block-Barrier (board_artefacts._proc_konsolidierung) — Judges gefaked, gegen Test-DB."""
|
||||
|
||||
import json
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import blocks
|
||||
import board_artefacts as ba
|
||||
from kanban import Flow
|
||||
from pipeline import FAILED, OK, GenContext
|
||||
|
||||
TOPIC = "konsolidierung"
|
||||
|
||||
|
||||
def _ctx():
|
||||
return GenContext(topic=TOPIC, provider="test", is_cancelled=lambda: False)
|
||||
|
||||
|
||||
def _fake_slot(antworten):
|
||||
"""run_single_slot-Fake: pro Judge-Key eine Antwort; schreibt via payload (wie der Engine-Sink)."""
|
||||
calls = []
|
||||
|
||||
async def fake(ctx, label, *, key, prompt, role, capabilities, payload, timeout):
|
||||
calls.append({"key": key, "prompt": prompt})
|
||||
j = key.rsplit("-", 1)[-1] # "j1"/"j2"
|
||||
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_block(db, bnorm, subs):
|
||||
for s in subs:
|
||||
await db.put_subblock(TOPIC, bnorm, blocks._norm_title(s), bnorm.title(), s, status="consensus")
|
||||
|
||||
|
||||
# ── In-Block ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def test_merge_on_unanimity(testdb, tmp_path, monkeypatch):
|
||||
"""Beide Judges gruppieren 1+2 → Gewinner (mehr key_points) bleibt, facts-Union,
|
||||
Verlierer wird DB-variant und fliegt aus raw/facts_map."""
|
||||
db = testdb
|
||||
subs = ["Durchstreichung: ~~text~~", "Durchstreichung: ~~text~~ streicht Text durch", "Fett: **text**"]
|
||||
await _seed_block(db, "betonung", subs)
|
||||
raw = {"Betonung": list(subs)}
|
||||
facts = {"Betonung": {
|
||||
blocks._norm_title(subs[0]): {"key_points": ["kp-a"], "cited_facts": [{"text": "z1"}]},
|
||||
blocks._norm_title(subs[1]): {"key_points": ["kp-b", "kp-c"], "cited_facts": [{"text": "z1"}, {"text": "z2"}]},
|
||||
}}
|
||||
fake = _fake_slot({"j1": {"gruppen": [[1, 2]], "luecken": ["Marker-Escaping fehlt"]},
|
||||
"j2": {"gruppen": [[2, 1]], "luecken": ["Escaping von Markern"]}})
|
||||
monkeypatch.setattr(blocks, "run_single_slot", fake)
|
||||
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, facts)
|
||||
assert raw["Betonung"] == [subs[1], "Fett: **text**"] # Gewinner: 2 key_points > 1
|
||||
wf = facts["Betonung"][blocks._norm_title(subs[1])]
|
||||
assert wf["key_points"] == ["kp-b", "kp-c", "kp-a"]
|
||||
assert wf["cited_facts"] == [{"text": "z1"}, {"text": "z2"}] # Union ohne Doppel
|
||||
assert blocks._norm_title(subs[0]) not in facts["Betonung"]
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "betonung")}
|
||||
assert rows[blocks._norm_title(subs[0])] == "variant"
|
||||
assert rows[blocks._norm_title(subs[1])] == "consensus"
|
||||
journale = list(tmp_path.glob("sub-konsolidierung-*.json"))
|
||||
j = json.loads([p for p in journale if "-j" not in p.stem][0].read_text())
|
||||
# Lücken-Schnitt: Token-Überlappung beider Judges, Formulierung von j1 gewinnt
|
||||
assert j["gruppen"][0]["behalten"] == subs[1] and j["luecken"] == ["Marker-Escaping fehlt"]
|
||||
|
||||
|
||||
async def test_dissent_keeps_everything(testdb, tmp_path, monkeypatch):
|
||||
"""Nur ein Judge gruppiert → keine Einstimmigkeit → kein Merge."""
|
||||
db = testdb
|
||||
subs = ["Eintrag eins", "Eintrag zwei"]
|
||||
await _seed_block(db, "block", subs)
|
||||
raw = {"Block": list(subs)}
|
||||
fake = _fake_slot({"j1": {"gruppen": [[1, 2]], "luecken": []},
|
||||
"j2": {"gruppen": [], "luecken": []}})
|
||||
monkeypatch.setattr(blocks, "run_single_slot", fake)
|
||||
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
|
||||
assert raw["Block"] == subs
|
||||
assert all(r["status"] == "consensus" for r in await db.list_subblocks(TOPIC, "block"))
|
||||
|
||||
|
||||
async def test_judge_failure_fail_open(testdb, tmp_path, monkeypatch):
|
||||
"""Ein Judge UND der Ersatz ohne Ergebnis → fail-open, nichts ändert sich."""
|
||||
db = testdb
|
||||
subs = ["Eintrag eins", "Eintrag zwei"]
|
||||
await _seed_block(db, "block", subs)
|
||||
raw = {"Block": list(subs)}
|
||||
fake = _fake_slot({"j1": {"gruppen": [[1, 2]], "luecken": []}}) # j2 UND j3 → FAILED
|
||||
monkeypatch.setattr(blocks, "run_single_slot", fake)
|
||||
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
|
||||
assert raw["Block"] == subs
|
||||
assert len(fake.calls) == 3 # j1, j2, Ersatz j3
|
||||
|
||||
|
||||
async def test_ersatzrichter_bei_ausfall(testdb, tmp_path, monkeypatch):
|
||||
"""j1 fällt aus → Ersatz j3 springt ein; Einstimmigkeit j2+j3 faltet.
|
||||
Vorher entwertete EIN Timeout die gute Stimme (13 Links-Dubletten überlebten)."""
|
||||
db = testdb
|
||||
subs = ["Kurz", "Deutlich längerer Eintrag"]
|
||||
await _seed_block(db, "block", subs)
|
||||
raw = {"Block": list(subs)}
|
||||
fake = _fake_slot({"j2": {"gruppen": [[1, 2]], "luecken": []},
|
||||
"j3": {"gruppen": [[2, 1]], "luecken": []}}) # j1 → FAILED
|
||||
monkeypatch.setattr(blocks, "run_single_slot", fake)
|
||||
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
|
||||
assert raw["Block"] == ["Deutlich längerer Eintrag"]
|
||||
|
||||
|
||||
async def test_negation_guard_blocks_merge(testdb, tmp_path, monkeypatch):
|
||||
"""Gegensätzliche Aussagen werden selbst bei einstimmigen Judges nicht gefaltet."""
|
||||
db = testdb
|
||||
subs = ["Tabs werden expandiert", "Tabs werden nicht expandiert"]
|
||||
await _seed_block(db, "tabs", subs)
|
||||
raw = {"Tabs": list(subs)}
|
||||
fake = _fake_slot({"j1": {"gruppen": [[1, 2]], "luecken": []},
|
||||
"j2": {"gruppen": [[1, 2]], "luecken": []}})
|
||||
monkeypatch.setattr(blocks, "run_single_slot", fake)
|
||||
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
|
||||
assert raw["Tabs"] == subs
|
||||
|
||||
|
||||
async def test_resume_skips_judges(testdb, tmp_path, monkeypatch):
|
||||
"""Vorhandene j-Dateien → kein neuer Agenten-Call, Ergebnis wird übernommen."""
|
||||
db = testdb
|
||||
subs = ["Eintrag eins", "Eintrag zwei lang"]
|
||||
await _seed_block(db, "block", subs)
|
||||
raw = {"Block": list(subs)}
|
||||
import hashlib
|
||||
h = hashlib.md5("\n".join(subs).encode()).hexdigest()[:8]
|
||||
for j in (1, 2):
|
||||
(tmp_path / f"sub-konsolidierung-{h}-j{j}.json").write_text(
|
||||
json.dumps({"gruppen": [[1, 2]], "luecken": []}), encoding="utf-8")
|
||||
|
||||
async def kein_agent(*a, **kw):
|
||||
raise AssertionError("Resume darf keinen Agenten starten")
|
||||
|
||||
monkeypatch.setattr(blocks, "run_single_slot", kein_agent)
|
||||
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
|
||||
assert raw["Block"] == ["Eintrag zwei lang"]
|
||||
|
||||
|
||||
def test_schema_accepts_both_group_forms():
|
||||
"""Alte Listenform [1,4] und neue {haupt, weitere}-Form parsen beide; kataloge/fremd optional."""
|
||||
alt = blocks._konsolidierung_schema({"gruppen": [[1, 4]], "luecken": []}, 5)
|
||||
assert alt["gruppen"] == [{"haupt": None, "ids": [1, 4]}] and alt["fremd"] == set()
|
||||
neu = blocks._konsolidierung_schema(
|
||||
{"gruppen": [{"haupt": 4, "weitere": [1]}],
|
||||
"kataloge": [{"titel": "Katalog: Symbole", "mitglieder": [2, 3]}],
|
||||
"fremd": [5], "luecken": ["x"]}, 5)
|
||||
assert neu["gruppen"] == [{"haupt": 4, "ids": [1, 4]}]
|
||||
assert neu["kataloge"] == [{"titel": "Katalog: Symbole", "ids": [2, 3]}]
|
||||
assert neu["fremd"] == {5} and neu["luecken"] == ["x"]
|
||||
assert blocks._konsolidierung_schema({"gruppen": [{"haupt": 9, "weitere": [1]}]}, 5) == \
|
||||
{"gruppen": [], "kataloge": [], "fremd": set(), "luecken": []} # id out of range
|
||||
|
||||
|
||||
async def test_haupt_beats_heuristic(testdb, tmp_path, monkeypatch):
|
||||
"""Judges nennen den kürzeren Eintrag als haupt → er gewinnt trotz weniger key_points."""
|
||||
db = testdb
|
||||
subs = ["Basis", "Detailregel mit sehr langem Titel und Facts"]
|
||||
await _seed_block(db, "block", subs)
|
||||
raw = {"Block": list(subs)}
|
||||
facts = {"Block": {blocks._norm_title(subs[1]): {"key_points": ["a", "b", "c"]}}}
|
||||
fake = _fake_slot({"j1": {"gruppen": [{"haupt": 1, "weitere": [2]}], "luecken": []},
|
||||
"j2": {"gruppen": [{"haupt": 1, "weitere": [2]}], "luecken": []}})
|
||||
monkeypatch.setattr(blocks, "run_single_slot", fake)
|
||||
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, facts)
|
||||
assert raw["Block"] == ["Basis"]
|
||||
assert facts["Block"][blocks._norm_title("Basis")]["key_points"] == ["a", "b", "c"] # Union geerbt
|
||||
|
||||
|
||||
async def test_katalog_bundles_to_new_row(testdb, tmp_path, monkeypatch):
|
||||
"""Einstimmige Katalog-Mitglieder → neue consensus-Zeile mit Facts-Union, Mitglieder variant."""
|
||||
db = testdb
|
||||
subs = ["Pfeilsymbole: a b c", "Mengensymbole: d e f", "Eigene Regel"]
|
||||
await _seed_block(db, "mathe", subs)
|
||||
raw = {"Mathe": list(subs)}
|
||||
facts = {"Mathe": {blocks._norm_title(subs[0]): {"key_points": ["kp1"]},
|
||||
blocks._norm_title(subs[1]): {"key_points": ["kp2"]}}}
|
||||
kat = {"titel": "Symbolkatalog: Pfeile und Mengen", "mitglieder": [1, 2]}
|
||||
fake = _fake_slot({"j1": {"gruppen": [], "kataloge": [kat], "luecken": []},
|
||||
"j2": {"gruppen": [], "kataloge": [kat], "luecken": []}})
|
||||
monkeypatch.setattr(blocks, "run_single_slot", fake)
|
||||
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, facts)
|
||||
assert raw["Mathe"] == ["Eigene Regel", "Symbolkatalog: Pfeile und Mengen"]
|
||||
kn = blocks._norm_title("Symbolkatalog: Pfeile und Mengen")
|
||||
assert sorted(facts["Mathe"][kn]["key_points"]) == ["kp1", "kp2"]
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "mathe")}
|
||||
assert rows[kn] == "consensus"
|
||||
assert rows[blocks._norm_title(subs[0])] == "variant"
|
||||
|
||||
|
||||
async def test_katalog_dissent_keeps_members(testdb, tmp_path, monkeypatch):
|
||||
db = testdb
|
||||
subs = ["Pfeilsymbole: a b c", "Mengensymbole: d e f"]
|
||||
await _seed_block(db, "mathe", subs)
|
||||
raw = {"Mathe": list(subs)}
|
||||
fake = _fake_slot({"j1": {"gruppen": [], "kataloge": [{"titel": "K", "mitglieder": [1, 2]}], "luecken": []},
|
||||
"j2": {"gruppen": [], "kataloge": [], "luecken": []}})
|
||||
monkeypatch.setattr(blocks, "run_single_slot", fake)
|
||||
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
|
||||
assert raw["Mathe"] == subs
|
||||
|
||||
|
||||
async def test_fremd_unanimous_discards(testdb, tmp_path, monkeypatch):
|
||||
"""Einstimmig fremd → discarded + raus; einseitig fremd → bleibt."""
|
||||
db = testdb
|
||||
subs = ["CSS display überschreibt Verhalten", "Echte Markdown-Regel", "Nur einer hält es für fremd"]
|
||||
await _seed_block(db, "block", subs)
|
||||
raw = {"Block": list(subs)}
|
||||
fake = _fake_slot({"j1": {"gruppen": [], "fremd": [1, 3], "luecken": []},
|
||||
"j2": {"gruppen": [], "fremd": [1], "luecken": []}})
|
||||
monkeypatch.setattr(blocks, "run_single_slot", fake)
|
||||
luecken = await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
|
||||
assert raw["Block"] == [subs[1], subs[2]]
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "block")}
|
||||
assert rows[blocks._norm_title(subs[0])] == "discarded"
|
||||
assert rows[blocks._norm_title(subs[2])] == "consensus"
|
||||
assert luecken == {}
|
||||
|
||||
|
||||
async def test_luecken_nur_bei_einstimmigkeit(testdb, tmp_path, monkeypatch):
|
||||
"""Nur Lücken mit Token-Überlappung BEIDER Judges überleben; einseitige fallen weg."""
|
||||
db = testdb
|
||||
subs = ["Eintrag eins", "Eintrag zwei"]
|
||||
await _seed_block(db, "block", subs)
|
||||
raw = {"Block": list(subs)}
|
||||
fake = _fake_slot({"j1": {"gruppen": [], "luecken": ["Inline-HTML fehlt", "Front-Matter"]},
|
||||
"j2": {"gruppen": [], "luecken": ["nichts zu Inline-HTML"]}})
|
||||
monkeypatch.setattr(blocks, "run_single_slot", fake)
|
||||
luecken = await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
|
||||
assert luecken == {"Block": ["Inline-HTML fehlt"]}
|
||||
|
||||
|
||||
async def test_kp_deckel_im_judge_prompt(testdb, tmp_path, monkeypatch):
|
||||
"""Prompt zeigt max. 3 key_points je Sub (Timeout-Schutz); die Union bleibt voll."""
|
||||
db = testdb
|
||||
subs = ["Eintrag eins", "Eintrag zwei"]
|
||||
await _seed_block(db, "block", subs)
|
||||
raw = {"Block": list(subs)}
|
||||
facts = {"Block": {blocks._norm_title(subs[0]): {"key_points": [f"kp{i}" for i in range(1, 6)]}}}
|
||||
fake = _fake_slot({"j1": {"gruppen": [], "luecken": []}, "j2": {"gruppen": [], "luecken": []}})
|
||||
monkeypatch.setattr(blocks, "run_single_slot", fake)
|
||||
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, facts)
|
||||
prompt = fake.calls[0]["prompt"]
|
||||
assert "kp3" in prompt and "kp4" not in prompt
|
||||
|
||||
|
||||
def test_luecken_schnitt_cap():
|
||||
l1 = [f"Aspekt-{k} fehlt" for k in ("eins", "zwei", "drei", "vier", "fünf")]
|
||||
assert blocks._luecken_schnitt(l1, list(l1)) == l1[:3] # Cap 3
|
||||
assert blocks._luecken_schnitt(["Inline-HTML"], ["Tabellen-Syntax"]) == []
|
||||
|
||||
|
||||
def test_neg_set_lemmatisiert():
|
||||
"""kein/keine/keinen falten auf einen Stamm; nicht vs. ohne bleiben verschieden."""
|
||||
a = blocks._neg_set("Fehlerverhalten (kein Syntaxfehler)")
|
||||
b = blocks._neg_set("Fehlerverhalten (keine Syntax-Fehlermeldung)")
|
||||
assert a == b == frozenset({"kein"})
|
||||
assert blocks._neg_set("nicht expandiert") != blocks._neg_set("ohne Expansion")
|
||||
assert blocks._neg_set("niemals gerendert") == blocks._neg_set("nie gerendert")
|
||||
|
||||
|
||||
# ── Lücken-Nachfass ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _nachfass_env(db, monkeypatch, facts_result):
|
||||
subs = ["Eintrag eins"]
|
||||
await _seed_block(db, "block", subs)
|
||||
raw = {"Block": list(subs)}
|
||||
facts_map = {"Block": {}}
|
||||
|
||||
async def fake_race(topic, label, slots, quorum, timeout, provider, cancelled=None, grace=0):
|
||||
return [{"Block": ["Eintrag eins", "Neuer Aspekt"]}]
|
||||
|
||||
async def fake_facts(ctx, set_p, files, fraw, q, folder, instructions, ns="", lbl="", sources=None, slim=False):
|
||||
assert slim is True # Nachfass nutzt die schlanke Facts-Variante
|
||||
assert list(fraw["Block"]) == ["Neuer Aspekt"] # nur der frische Fund geht ins Gate
|
||||
return facts_result
|
||||
|
||||
monkeypatch.setattr(blocks, "_race", fake_race)
|
||||
monkeypatch.setattr(blocks, "_facts_block", fake_facts)
|
||||
monkeypatch.setattr(blocks, "EMBEDDING_AKTIV", False)
|
||||
return raw, facts_map
|
||||
|
||||
|
||||
async def test_nachfass_adopts_backed_find(testdb, tmp_path, monkeypatch):
|
||||
db = testdb
|
||||
nn = blocks._norm_title("Neuer Aspekt")
|
||||
raw, facts_map = await _nachfass_env(db, monkeypatch,
|
||||
({"Block": {nn: {"key_points": ["kp"]}}}, {}))
|
||||
n = await blocks._luecken_runde(_ctx(), {"arbeit": tmp_path}, "Block", ["Aspekt"],
|
||||
raw, facts_map, {"type": "thema"}, None)
|
||||
assert n == 1 and raw["Block"] == ["Eintrag eins", "Neuer Aspekt"]
|
||||
assert facts_map["Block"][nn]["key_points"] == ["kp"]
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "block")}
|
||||
assert rows[nn] == "consensus"
|
||||
|
||||
|
||||
async def test_nachfass_drops_unbacked_find(testdb, tmp_path, monkeypatch):
|
||||
db = testdb
|
||||
nn = blocks._norm_title("Neuer Aspekt")
|
||||
raw, facts_map = await _nachfass_env(db, monkeypatch, ({}, {"Block": {nn}}))
|
||||
n = await blocks._luecken_runde(_ctx(), {"arbeit": tmp_path}, "Block", ["Aspekt"],
|
||||
raw, facts_map, {"type": "thema"}, None)
|
||||
assert n == 0 and raw["Block"] == ["Eintrag eins"]
|
||||
assert not any(r["sub_norm"] == nn for r in await db.list_subblocks(TOPIC, "block"))
|
||||
|
||||
|
||||
async def test_nachfass_drops_find_without_facts(testdb, tmp_path, monkeypatch):
|
||||
"""HARTES Gate: kein Facts-Eintrag = kein Beleg = keine Übernahme — nicht nur
|
||||
aktiv Verworfenes fliegt (Bilder-Lauf: 13 von 18 kamen ohne Beleg durch)."""
|
||||
db = testdb
|
||||
raw, facts_map = await _nachfass_env(db, monkeypatch, ({}, {})) # Facts fand NICHTS
|
||||
n = await blocks._luecken_runde(_ctx(), {"arbeit": tmp_path}, "Block", ["Aspekt"],
|
||||
raw, facts_map, {"type": "thema"}, None)
|
||||
assert n == 0 and raw["Block"] == ["Eintrag eins"]
|
||||
|
||||
|
||||
async def test_facts_stage_konsolidiert_nachfass_funde_erneut(testdb, tmp_path, monkeypatch):
|
||||
"""Kreis geschlossen: nach Übernahmen läuft die Konsolidierung ein zweites Mal;
|
||||
deren Lücken lösen KEINEN weiteren Nachfass aus."""
|
||||
db = testdb
|
||||
payload = {"title": "Alpha", "raw": {"Alpha": ["s1"]}}
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "facts", payload)
|
||||
calls = {"kons": 0, "nf": 0}
|
||||
|
||||
async def fake_facts(ctx, set_p, files, raw, q, folder, instructions, ns="", lbl="", sources=None, slim=False):
|
||||
return {"Alpha": {}}, {}
|
||||
|
||||
async def fake_kons(ctx, files, raw, facts_map, instructions="", ns="", lbl=""):
|
||||
calls["kons"] += 1
|
||||
return {"Alpha": ["Lücke X"]} # meldet auch in Runde 2 — darf nicht erneut nachfassen
|
||||
|
||||
async def fake_nf(ctx, files, title, luecken, raw, facts_map, q, folder,
|
||||
instructions="", ns="", lbl="", sources=None):
|
||||
calls["nf"] += 1
|
||||
return 2
|
||||
|
||||
monkeypatch.setattr(ba, "_facts_block", fake_facts)
|
||||
monkeypatch.setattr(ba, "_konsolidiere_subblocks", fake_kons)
|
||||
monkeypatch.setattr(ba, "_luecken_runde", fake_nf)
|
||||
flow = Flow(TOPIC, work_dir=tmp_path)
|
||||
await ba._proc_facts(_ctx(), flow, {"arbeit": tmp_path}, {"type": "thema"}, None, "",
|
||||
[{"card_id": "alpha", "payload": payload}])
|
||||
assert calls == {"kons": 2, "nf": 1}
|
||||
assert (await db.kanban_get_card(TOPIC, "artefacts", "alpha"))["stage"] == "levels"
|
||||
|
||||
|
||||
async def test_finalize_purges_stale_rows(testdb, tmp_path):
|
||||
"""Re-Run-Waisen: Finalize löscht Alt-Fragen/-Artefakte des Blocks vor dem Upsert."""
|
||||
db = testdb
|
||||
await db.upsert_question_pattern(TOPIC, "alpha", "alt-sub", "Alpha", "Alt", "Alte Frage?")
|
||||
await db.put_sub_artifact(TOPIC, "alpha", "alt-sub", "flashcard", "{}", "Alpha", "Alt")
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "finalize", {})
|
||||
files = {k: tmp_path / f"{k}.json" for k in
|
||||
("sub_roh", "facts", "sidecar", "question_pattern", "artefakte")}
|
||||
card = {"card_id": "alpha", "payload": {
|
||||
"title": "Alpha", "raw": {"Alpha": ["Neu"]}, "facts": {},
|
||||
"sidecar": {"Alpha": [{"title": "Neu", "level": "beginner"}]},
|
||||
"pattern": {"Alpha": [{"subblock": "Neu", "question": "F?"}]},
|
||||
"artefacts": {"flashcard": [{"block": "Alpha", "subblock": "Neu", "front": "F", "back": "B"}]}}}
|
||||
flow = Flow(TOPIC, work_dir=tmp_path)
|
||||
await ba._proc_finalize(_ctx(), flow, files, [card])
|
||||
assert {r["sub_norm"] for r in await db.list_question_pattern(TOPIC)} == {"neu"}
|
||||
assert {(r["sub_norm"], r["type"]) for r in await db.get_sub_artefakte(TOPIC)} == {("neu", "flashcard")}
|
||||
|
||||
|
||||
# ── Cross-Block ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _FakeEmb:
|
||||
"""Gleicher Text → gleicher Einheitsvektor, sonst orthogonal (cos 1.0 / 0.0)."""
|
||||
|
||||
@staticmethod
|
||||
def available():
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def embed_sims(texts):
|
||||
uniq = {t: k for k, t in enumerate(dict.fromkeys(texts))}
|
||||
arr = np.zeros((len(texts), max(len(uniq), 1)))
|
||||
for r, t in enumerate(texts):
|
||||
arr[r, uniq[t]] = 1.0
|
||||
return arr @ arr.T
|
||||
|
||||
|
||||
async def _cross_env(db, tmp_path):
|
||||
flow = Flow(TOPIC, work_dir=tmp_path)
|
||||
cards = []
|
||||
for bnorm, subs in (("alpha", ["Gleiche Aussage", "Nur in Alpha"]),
|
||||
("beta", ["Gleiche Aussage", "Nur in Beta"])):
|
||||
payload = {"title": bnorm.title(),
|
||||
"raw": {bnorm.title(): list(subs)},
|
||||
"sidecar": {bnorm.title(): [{"title": s, "level": "beginner"} for s in subs]},
|
||||
"facts": {bnorm.title(): {blocks._norm_title(s): {"key_points": [f"kp {s}"]} for s in subs}}}
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", bnorm, "ablock", "konsolidierung", payload)
|
||||
await _seed_block(db, bnorm, subs)
|
||||
cards.append({"card_id": bnorm, "payload": payload})
|
||||
return flow, cards
|
||||
|
||||
|
||||
async def test_crossblock_folds_loser(testdb, tmp_path, monkeypatch):
|
||||
"""Einstimmig „a" → Beta verliert die geteilte Aussage, Karten wandern zu levels."""
|
||||
db = testdb
|
||||
flow, cards = await _cross_env(db, tmp_path)
|
||||
monkeypatch.setattr(ba, "embedding", _FakeEmb)
|
||||
fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "j2": {"pairs": {"1": "a"}}})
|
||||
monkeypatch.setattr(ba, "run_single_slot", fake)
|
||||
await ba._proc_konsolidierung(_ctx(), flow, {}, "", cards)
|
||||
assert "Gleiche Aussage" in fake.calls[0]["prompt"]
|
||||
beta = await db.kanban_get_card(TOPIC, "artefacts", "beta")
|
||||
assert beta["stage"] == "question_pattern"
|
||||
assert beta["payload"]["raw"]["Beta"] == ["Nur in Beta"]
|
||||
assert blocks._norm_title("Gleiche Aussage") not in beta["payload"]["facts"]["Beta"]
|
||||
# Barriere liegt jetzt hinter levels/relevance → auch die sidecar muss den Fold tragen
|
||||
assert [e["title"] for e in beta["payload"]["sidecar"]["Beta"]] == ["Nur in Beta"]
|
||||
alpha = await db.kanban_get_card(TOPIC, "artefacts", "alpha")
|
||||
assert alpha["stage"] == "question_pattern"
|
||||
assert alpha["payload"]["raw"]["Alpha"] == ["Gleiche Aussage", "Nur in Alpha"]
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
|
||||
assert rows[blocks._norm_title("Gleiche Aussage")] == "variant"
|
||||
|
||||
|
||||
async def test_crossblock_tiebreaker_folds(testdb, tmp_path, monkeypatch):
|
||||
"""j1/j2 uneinig → j3 entscheidet mit Mehrheit; hier „a" → Beta verliert."""
|
||||
db = testdb
|
||||
flow, cards = await _cross_env(db, tmp_path)
|
||||
monkeypatch.setattr(ba, "embedding", _FakeEmb)
|
||||
fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "j2": {"pairs": {"1": "nein"}},
|
||||
"j3": {"pairs": {"1": "a"}}})
|
||||
monkeypatch.setattr(ba, "run_single_slot", fake)
|
||||
await ba._proc_konsolidierung(_ctx(), flow, {}, "", cards)
|
||||
assert len(fake.calls) == 3
|
||||
beta = await db.kanban_get_card(TOPIC, "artefacts", "beta")
|
||||
assert beta["payload"]["raw"]["Beta"] == ["Nur in Beta"]
|
||||
|
||||
|
||||
async def test_crossblock_dissent_without_tiebreaker_keeps_both(testdb, tmp_path, monkeypatch):
|
||||
"""j3 liefert nichts (FAILED) → fail-open, Paar bleibt."""
|
||||
db = testdb
|
||||
flow, cards = await _cross_env(db, tmp_path)
|
||||
monkeypatch.setattr(ba, "embedding", _FakeEmb)
|
||||
fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "j2": {"pairs": {"1": "b"}}}) # j3 fehlt → FAILED
|
||||
monkeypatch.setattr(ba, "run_single_slot", fake)
|
||||
await ba._proc_konsolidierung(_ctx(), flow, {}, "", cards)
|
||||
beta = await db.kanban_get_card(TOPIC, "artefacts", "beta")
|
||||
assert beta["stage"] == "question_pattern"
|
||||
assert beta["payload"]["raw"]["Beta"] == ["Gleiche Aussage", "Nur in Beta"]
|
||||
|
||||
|
||||
async def test_crossblock_ersatzrichter(testdb, tmp_path, monkeypatch):
|
||||
"""Nur ein Richter liefert → Ersatz jE als zweite Stimme; Einstimmigkeit faltet."""
|
||||
db = testdb
|
||||
flow, cards = await _cross_env(db, tmp_path)
|
||||
monkeypatch.setattr(ba, "embedding", _FakeEmb)
|
||||
fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "jE": {"pairs": {"1": "a"}}}) # j2 → FAILED
|
||||
monkeypatch.setattr(ba, "run_single_slot", fake)
|
||||
await ba._proc_konsolidierung(_ctx(), flow, {}, "", cards)
|
||||
beta = await db.kanban_get_card(TOPIC, "artefacts", "beta")
|
||||
assert beta["payload"]["raw"]["Beta"] == ["Nur in Beta"]
|
||||
|
||||
|
||||
async def test_crossblock_without_embedding_advances(testdb, tmp_path, monkeypatch):
|
||||
db = testdb
|
||||
flow, cards = await _cross_env(db, tmp_path)
|
||||
|
||||
class _Aus:
|
||||
@staticmethod
|
||||
def available():
|
||||
return False
|
||||
|
||||
async def kein_agent(*a, **kw):
|
||||
raise AssertionError("ohne Embedding kein Judge")
|
||||
|
||||
monkeypatch.setattr(ba, "embedding", _Aus)
|
||||
monkeypatch.setattr(ba, "run_single_slot", kein_agent)
|
||||
await ba._proc_konsolidierung(_ctx(), flow, {}, "", cards)
|
||||
for cid in ("alpha", "beta"):
|
||||
assert (await db.kanban_get_card(TOPIC, "artefacts", cid))["stage"] == "question_pattern"
|
||||
|
||||
|
||||
async def test_crossblock_context_wins(testdb, tmp_path, monkeypatch):
|
||||
"""Kontext-Sub (Block schon hinter der Barrier) gewinnt auch bei Verdict „b" —
|
||||
die Paket-Seite fällt, der Kontext bleibt unangetastet."""
|
||||
db = testdb
|
||||
flow = Flow(TOPIC, work_dir=tmp_path)
|
||||
payload = {"title": "Alpha", "raw": {"Alpha": ["Gleiche Aussage"]}, "facts": {"Alpha": {}}}
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "konsolidierung", payload)
|
||||
await _seed_block(db, "alpha", ["Gleiche Aussage"])
|
||||
cards = [{"card_id": "alpha", "payload": payload}]
|
||||
# Kontext-Block "gamma" ist bereits weiter (Stage levels) und hält dieselbe Aussage
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "gamma", "ablock", "levels",
|
||||
{"title": "Gamma", "raw": {"Gamma": ["Gleiche Aussage"]}, "facts": {}})
|
||||
await _seed_block(db, "gamma", ["Gleiche Aussage"])
|
||||
monkeypatch.setattr(ba, "embedding", _FakeEmb)
|
||||
# Verdict „a": das Paket (A) soll behalten — Kontext faltet trotzdem nie
|
||||
fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "j2": {"pairs": {"1": "a"}}})
|
||||
monkeypatch.setattr(ba, "run_single_slot", fake)
|
||||
await ba._proc_konsolidierung(_ctx(), flow, {}, "", cards)
|
||||
alpha = await db.kanban_get_card(TOPIC, "artefacts", "alpha")
|
||||
assert alpha["payload"]["raw"].get("Alpha", []) == [] # Paket-Seite gefaltet
|
||||
gamma_rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "gamma")}
|
||||
assert gamma_rows[blocks._norm_title("Gleiche Aussage")] == "consensus" # Kontext unberührt
|
||||
Reference in New Issue
Block a user