266 lines
13 KiB
Python
266 lines
13 KiB
Python
"""Cross-Block-Konsolidierung (board_artefacts._proc_konsolidierung) und Finalize —
|
||
Judges gefaked, gegen Test-DB. Die In-Block-Konsolidierung lebt seit dem Verschmelzungs-
|
||
Umbau in block_calls._verify_block und wird in tests/test_block_calls.py getestet."""
|
||
|
||
import json
|
||
|
||
import numpy as np
|
||
|
||
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")
|
||
|
||
|
||
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")
|
||
|
||
|
||
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, finalisiert=True):
|
||
"""Zwei finalisierte Karten in der End-Barriere; die Sub-Rows liegen in der DB
|
||
(post-finalize ist die DB die Wahrheit, nicht mehr das Karten-Payload)."""
|
||
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()}
|
||
if finalisiert:
|
||
payload.update(pattern={}, artefacts={})
|
||
await db.kanban_upsert_card(TOPIC, "artefacts", bnorm, "ablock", "konsolidierung", payload)
|
||
await _seed_block(db, bnorm, subs)
|
||
cards.append({"card_id": bnorm, "payload": payload})
|
||
files = {k: tmp_path / f"{k}.json" for k in
|
||
("sub_roh", "facts", "sidecar", "question_pattern", "artefakte")}
|
||
return flow, cards, files
|
||
|
||
|
||
async def test_crossblock_folds_loser(testdb, tmp_path, monkeypatch):
|
||
"""Einstimmig „a" → Betas geteilte Aussage wird variant, ihre Frage wandert zum
|
||
Gewinner (falte_sub), Karten gehen auf DONE."""
|
||
db = testdb
|
||
flow, cards, files = await _cross_env(db, tmp_path)
|
||
sn = blocks._norm_title("Gleiche Aussage")
|
||
await db.upsert_question_pattern(TOPIC, "beta", sn, "Beta", "Gleiche Aussage", "F?")
|
||
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, files, "", cards)
|
||
assert "Gleiche Aussage" in fake.calls[0]["prompt"]
|
||
for cid in ("alpha", "beta"):
|
||
assert (await db.kanban_get_card(TOPIC, "artefacts", cid))["stage"] == ba.DONE
|
||
beta_rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
|
||
assert beta_rows[sn] == "variant"
|
||
alpha_rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||
assert alpha_rows[sn] == "consensus"
|
||
fragen = await db.list_question_pattern(TOPIC)
|
||
assert {(r["block_norm"], r["sub_norm"]) for r in fragen} == {("alpha", sn)} # umgehängt
|
||
|
||
|
||
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, files = 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, files, "", cards)
|
||
assert len(fake.calls) == 3
|
||
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_dissent_without_tiebreaker_keeps_both(testdb, tmp_path, monkeypatch):
|
||
"""j3 liefert nichts (FAILED) → fail-open, Paar bleibt."""
|
||
db = testdb
|
||
flow, cards, files = 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, files, "", cards)
|
||
assert (await db.kanban_get_card(TOPIC, "artefacts", "beta"))["stage"] == ba.DONE
|
||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
|
||
assert rows[blocks._norm_title("Gleiche Aussage")] == "consensus"
|
||
|
||
|
||
async def test_crossblock_ersatzrichter(testdb, tmp_path, monkeypatch):
|
||
"""Nur ein Richter liefert → Ersatz jE als zweite Stimme; Einstimmigkeit faltet."""
|
||
db = testdb
|
||
flow, cards, files = 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, files, "", cards)
|
||
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_without_embedding_advances(testdb, tmp_path, monkeypatch):
|
||
db = testdb
|
||
flow, cards, files = 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, files, "", cards)
|
||
for cid in ("alpha", "beta"):
|
||
assert (await db.kanban_get_card(TOPIC, "artefacts", cid))["stage"] == ba.DONE
|
||
|
||
|
||
async def test_crossblock_nachzuegler_zurueck_zum_erzeugen(testdb, tmp_path, monkeypatch):
|
||
"""Resume-Karte ohne pattern im Payload → zurück nach generate (bzw. artefakte bei
|
||
vorhandenem sidecar), KEIN Dedup — finalize würde den Fold sonst re-spiegeln."""
|
||
db = testdb
|
||
flow, cards, files = await _cross_env(db, tmp_path, finalisiert=False)
|
||
cards[1]["payload"]["sidecar"] = {"Beta": []} # hat Verify schon hinter sich
|
||
await db.kanban_set_payload(TOPIC, "artefacts", "beta", cards[1]["payload"])
|
||
|
||
async def kein_agent(*a, **kw):
|
||
raise AssertionError("Nachzügler dürfen keinen Dedup auslösen")
|
||
|
||
monkeypatch.setattr(ba, "embedding", _FakeEmb)
|
||
monkeypatch.setattr(ba, "run_single_slot", kein_agent)
|
||
await ba._proc_konsolidierung(_ctx(), flow, files, "", cards)
|
||
assert (await db.kanban_get_card(TOPIC, "artefacts", "alpha"))["stage"] == "generate"
|
||
assert (await db.kanban_get_card(TOPIC, "artefacts", "beta"))["stage"] == "artefakte"
|
||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
|
||
assert rows[blocks._norm_title("Gleiche Aussage")] == "consensus"
|
||
|
||
|
||
async def test_finalize_defaultet_level_nachzuegler(testdb, tmp_path):
|
||
"""Finalize klassifiziert level-/relevance-lose consensus-Rows (Default advanced/relevant)."""
|
||
db = testdb
|
||
await db.put_subblock(TOPIC, "alpha", "nachzuegler", "Alpha", "Nachzügler", status="consensus")
|
||
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": {}, "facts": {},
|
||
"sidecar": {}, "pattern": {}, "artefacts": {}}}
|
||
await ba._proc_finalize(_ctx(), Flow(TOPIC, work_dir=tmp_path), files, [card])
|
||
row = next(r for r in await db.list_subblocks(TOPIC, "alpha"))
|
||
assert row["level"] == "advanced" and row["relevance"] == "relevant"
|
||
|
||
|
||
async def test_finalize_loescht_stale_consensus(testdb, tmp_path):
|
||
"""Alt-consensus-Rows, die der Lauf-Sidecar nicht mehr trägt, fliegen raus —
|
||
variant-Rows bleiben (QA liest die Status). Wurzel der 25 Board-2-losen Waisen."""
|
||
db = testdb
|
||
await db.put_subblock(TOPIC, "alpha", "alt-rest", "Alpha", "Alt-Rest", status="consensus")
|
||
await db.put_subblock(TOPIC, "alpha", "alte-variante", "Alpha", "Alte Variante", status="variant")
|
||
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": {}, "artefacts": {}}}
|
||
await ba._proc_finalize(_ctx(), Flow(TOPIC, work_dir=tmp_path), files, [card])
|
||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||
assert rows == {"neu": "consensus", "alte-variante": "variant"}
|
||
|
||
|
||
async def test_crossblock_chunking_faltet_global(testdb, tmp_path, monkeypatch):
|
||
"""Paare werden gechunkt beurteilt (ein Hänger blockiert nur noch seinen Chunk);
|
||
die Verdicts falten global über alle Chunks."""
|
||
db = testdb
|
||
flow = Flow(TOPIC, work_dir=tmp_path)
|
||
cards = []
|
||
for bnorm, subs in (("alpha", ["Gleiche Aussage", "Zweite gleiche Aussage", "Nur in Alpha"]),
|
||
("beta", ["Gleiche Aussage", "Zweite gleiche Aussage"])):
|
||
payload = {"title": bnorm.title(), "pattern": {}, "artefacts": {}}
|
||
await db.kanban_upsert_card(TOPIC, "artefacts", bnorm, "ablock", "konsolidierung", payload)
|
||
await _seed_block(db, bnorm, subs)
|
||
cards.append({"card_id": bnorm, "payload": payload})
|
||
files = {k: tmp_path / f"{k}.json" for k in
|
||
("sub_roh", "facts", "sidecar", "question_pattern", "artefakte")}
|
||
monkeypatch.setattr(ba, "embedding", _FakeEmb)
|
||
monkeypatch.setattr(ba, "CROSS_CHUNK_PAARE", 1) # 2 Paare → 2 Chunks
|
||
fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "j2": {"pairs": {"1": "a"}}})
|
||
monkeypatch.setattr(ba, "run_single_slot", fake)
|
||
await ba._proc_konsolidierung(_ctx(), flow, files, "", cards)
|
||
assert len(fake.calls) == 4 # 2 Chunks × j1/j2
|
||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
|
||
assert set(rows.values()) == {"variant"} # beide Dubletten global gefaltet
|