update
This commit is contained in:
295
backend/tests/test_subblocks.py
Normal file
295
backend/tests/test_subblocks.py
Normal file
@@ -0,0 +1,295 @@
|
||||
"""Subbaustein-Qualität: Varianten-Konsens, Seed-Garantie, Nachfass, Outline-Review."""
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import blocks as blx
|
||||
from pipeline import GenContext
|
||||
|
||||
TOPIC = "t"
|
||||
_MD_PATH = re.compile(r"(/\S+\.md)")
|
||||
|
||||
|
||||
# ── _variant_clusters (pure) ─────────────────────────────────────────────────────────
|
||||
|
||||
def _sims(pairs, n):
|
||||
m = np.eye(n)
|
||||
for i, j, v in pairs:
|
||||
m[i][j] = m[j][i] = v
|
||||
return m
|
||||
|
||||
|
||||
def test_variant_clusters_folds_paraphrases():
|
||||
titles = ["Harte Umbrüche brauchen Marker", "Harte Umbrüche erfordern explizite Marker!",
|
||||
"Tabs werden expandiert"]
|
||||
cl = blx._variant_clusters(titles, [1, 1, 1], _sims([(0, 1, 0.95)], 3))
|
||||
by_rep = {c["rep"]: c for c in cl}
|
||||
assert by_rep[1]["mentions"] == 2 and sorted(by_rep[1]["members"]) == [0, 1] # longest wins
|
||||
assert by_rep[2]["mentions"] == 1
|
||||
|
||||
|
||||
def test_variant_clusters_negation_guard():
|
||||
titles = ["Fenced können Absätze unterbrechen", "Fenced können Absätze nicht unterbrechen"]
|
||||
cl = blx._variant_clusters(titles, [1, 1], _sims([(0, 1, 0.95)], 2))
|
||||
assert len(cl) == 2 # antonyms never merge, no matter the cosine
|
||||
|
||||
|
||||
# ── _subblocks_block integration (fake race + fake embeddings) ──────────────────────
|
||||
|
||||
def _fake_sims(texts):
|
||||
"""Markertoken matrix: same first word → 0.95, else 0."""
|
||||
n = len(texts)
|
||||
m = np.eye(n)
|
||||
key = lambda t: t.split()[0].casefold()
|
||||
for i in range(n):
|
||||
for j in range(n):
|
||||
if i != j and key(texts[i]) == key(texts[j]):
|
||||
m[i][j] = 0.95
|
||||
return m
|
||||
|
||||
|
||||
def _mk_race(finder_by_agent):
|
||||
"""Key-routed _race fake. Finder round 1 → scripted per-agent subs; later finder and
|
||||
catch-up rounds → nothing; clarify judges echo the consensus lines from their prompt."""
|
||||
prompts = []
|
||||
|
||||
async def fake_race(topic, label, slots, quorum, timeout, provider, on_update=None,
|
||||
cancelled=None, *, grace=None, min_runtime=None, max_runtime=None):
|
||||
outs = []
|
||||
for slot in slots:
|
||||
key, prompt = slot["key"], slot["prompt"]
|
||||
prompts.append((key, prompt))
|
||||
text = None
|
||||
if "-subblock-final-" in key:
|
||||
kons = re.search(r"Konsens \(≥2 finders\):\n(.*?)\nUnsicher", prompt, re.S)
|
||||
subs = [l[2:] for l in (kons.group(1).splitlines() if kons else [])
|
||||
if l.startswith("- ") and l != "- (keiner)"]
|
||||
if subs:
|
||||
text = "<!-- block: Alpha -->\n" + "\n".join(f"- {s}" for s in subs)
|
||||
elif "-r1-" in key:
|
||||
agent = int(key.rsplit("-", 1)[1])
|
||||
subs = finder_by_agent.get(agent) or []
|
||||
if subs:
|
||||
text = "<!-- block: Alpha -->\n" + "\n".join(f"- {s}" for s in subs)
|
||||
if text is not None and (m := _MD_PATH.search(prompt)):
|
||||
with open(m.group(1), "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
outs.append(slot["payload"](None))
|
||||
outs = [o for o in outs if o]
|
||||
return outs or None
|
||||
return fake_race, prompts
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sub_env(testdb, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(blx, "EMBEDDING_AKTIV", True)
|
||||
monkeypatch.setattr(blx.embedding, "available", lambda: True)
|
||||
monkeypatch.setattr(blx.embedding, "embed_sims", _fake_sims)
|
||||
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
|
||||
files = {"arbeit": tmp_path}
|
||||
return testdb, ctx, files
|
||||
|
||||
|
||||
async def _run(ctx, files, monkeypatch, finder_by_agent, seeds=None):
|
||||
fake, prompts = _mk_race(finder_by_agent)
|
||||
monkeypatch.setattr(blx, "_race", fake)
|
||||
raw = await blx._subblocks_block(ctx, lambda *a, **k: None, files, {1: "Alpha — Grundkonzept"},
|
||||
"", wipe=False, ns="x-", seeds=seeds)
|
||||
return raw, prompts
|
||||
|
||||
|
||||
async def test_variant_consensus_end_to_end(sub_env, monkeypatch):
|
||||
"""3 Einzelfunde in 3 Formulierungen → EIN consensus-Repräsentant; Varianten gehen
|
||||
nicht als „Unsicher" ins Panel."""
|
||||
db, ctx, files = sub_env
|
||||
raw, prompts = await _run(ctx, files, monkeypatch, {
|
||||
1: ["Umbruch braucht Marker"],
|
||||
2: ["Umbruch erfordert explizite Marker!"],
|
||||
3: ["Umbruch verlangt zwei Leerzeichen als Marker"],
|
||||
})
|
||||
assert raw == {"Alpha": ["Umbruch verlangt zwei Leerzeichen als Marker"]} # longest = rep
|
||||
rows = await db.list_subblocks(TOPIC, "alpha")
|
||||
status = sorted(r["status"] for r in rows)
|
||||
assert status == ["consensus", "variant", "variant"]
|
||||
clarify_prompts = [p for k, p in prompts if "-subblock-final-" in k]
|
||||
assert clarify_prompts and "Umbruch braucht Marker" not in clarify_prompts[0]
|
||||
|
||||
|
||||
async def test_seed_promotes_single_find(sub_env, monkeypatch):
|
||||
"""Seed deckt einen verworfenen Einzelfund lexikalisch → Promotion zu consensus."""
|
||||
db, ctx, files = sub_env
|
||||
raw, _ = await _run(ctx, files, monkeypatch, {
|
||||
1: ["Alpha Grundlagen", "Zeilenumbruch Regeln im Detail"],
|
||||
2: ["Alpha Grundlagen"],
|
||||
}, seeds=["Zeilenumbruch Regeln"])
|
||||
assert "Zeilenumbruch Regeln im Detail" in raw["Alpha"]
|
||||
row = next(r for r in await db.list_subblocks(TOPIC, "alpha")
|
||||
if r["sub_norm"] == "zeilenumbruch regeln im detail")
|
||||
assert row["status"] == "consensus"
|
||||
|
||||
|
||||
async def test_seed_inserted_when_nothing_found(sub_env, monkeypatch):
|
||||
"""Seed ohne jeden Fund wird als eigener consensus-Sub eingefügt (Facts-Gate prüft später)."""
|
||||
db, ctx, files = sub_env
|
||||
raw, _ = await _run(ctx, files, monkeypatch, {
|
||||
1: ["Alpha Grundlagen"], 2: ["Alpha Grundlagen"],
|
||||
}, seeds=["Fußnoten Syntax"])
|
||||
assert "Fußnoten Syntax" in raw["Alpha"]
|
||||
row = next(r for r in await db.list_subblocks(TOPIC, "alpha")
|
||||
if r["sub_title"] == "Fußnoten Syntax")
|
||||
assert row["status"] == "consensus"
|
||||
|
||||
|
||||
async def test_seed_covered_no_duplicate(sub_env, monkeypatch):
|
||||
"""Seed lexikalisch von einem consensus-Sub abgedeckt → nichts eingefügt."""
|
||||
db, ctx, files = sub_env
|
||||
raw, _ = await _run(ctx, files, monkeypatch, {
|
||||
1: ["Tabs werden zu Leerzeichen expandiert"], 2: ["Tabs werden zu Leerzeichen expandiert"],
|
||||
}, seeds=["Tabs"])
|
||||
assert raw == {"Alpha": ["Tabs werden zu Leerzeichen expandiert"]}
|
||||
|
||||
|
||||
async def test_wipe_false_is_idempotent(sub_env, monkeypatch):
|
||||
"""Zweiter Karten-Lauf kumuliert keine Mentions (per-Block-Wipe)."""
|
||||
db, ctx, files = sub_env
|
||||
await _run(ctx, files, monkeypatch, {1: ["Alpha Grundlagen"], 2: ["Alpha Grundlagen"]})
|
||||
first = {r["sub_norm"]: r["mentions"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||
await _run(ctx, files, monkeypatch, {1: ["Alpha Grundlagen"], 2: ["Alpha Grundlagen"]})
|
||||
second = {r["sub_norm"]: r["mentions"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||
assert first == second
|
||||
|
||||
|
||||
async def test_catchup_adds_and_stops(sub_env, monkeypatch, tmp_path):
|
||||
"""Block unter SUBBLOCK_MIN: Nachfass-Runde findet Neues → eigenes Final-File,
|
||||
Konsens wächst; zweite Runde ohne Neues → Ende."""
|
||||
db, ctx, files = sub_env
|
||||
fake, prompts = _mk_race({1: ["Alpha Grundlagen"], 2: ["Alpha Grundlagen"]})
|
||||
base = fake
|
||||
hit = {"n": 0}
|
||||
|
||||
async def with_catchup(topic, label, slots, *a, **k):
|
||||
if any("-subblock-x" in s["key"] for s in slots):
|
||||
hit["n"] += 1
|
||||
if hit["n"] == 1: # first catch-up round: both agents agree on one new sub
|
||||
for slot in slots[:2]:
|
||||
m = _MD_PATH.search(slot["prompt"])
|
||||
with open(m.group(1), "w", encoding="utf-8") as f:
|
||||
f.write("<!-- block: Alpha -->\n- Vertiefung der Konzepte")
|
||||
return [slot["payload"](None) for slot in slots[:2]]
|
||||
return None
|
||||
return await base(topic, label, slots, *a, **k)
|
||||
|
||||
monkeypatch.setattr(blx, "_race", with_catchup)
|
||||
raw = await blx._subblocks_block(ctx, lambda *a, **k: None, files, {1: "Alpha — Grundkonzept"},
|
||||
"", wipe=False, ns="x-")
|
||||
assert set(raw["Alpha"]) == {"Alpha Grundlagen", "Vertiefung der Konzepte"}
|
||||
assert (tmp_path / "subblock-final-c1-x1.md").exists()
|
||||
assert hit["n"] == 2 # round 2 ran, found nothing, loop ended
|
||||
|
||||
|
||||
# ── Outline-Review ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_outline_review_schema():
|
||||
valid = {1, 2, 3, 4, 5, 6}
|
||||
ok = blx._outline_review_schema({"moves": {"3": 2}}, valid, 2, 6)
|
||||
assert ok == {3: 2}
|
||||
assert blx._outline_review_schema({"moves": {}}, valid, 2, 6) == {}
|
||||
assert blx._outline_review_schema({"moves": {"9": 1}}, valid, 2, 6) is None # unknown block
|
||||
assert blx._outline_review_schema({"moves": {"1": 5}}, valid, 2, 6) is None # chapter range
|
||||
assert blx._outline_review_schema({"moves": {"1": 2, "2": 2, "3": 2}}, valid, 2, 6) is None # mass move
|
||||
assert blx._outline_review_schema({"chapters": []}, valid, 2, 6) is None
|
||||
|
||||
|
||||
async def test_outline_review_moves_block(testdb, tmp_path, monkeypatch):
|
||||
"""Review verschiebt einen fehlplatzierten Block; kaputtes Review lässt den Plan unverändert."""
|
||||
entries = {i: f"Block {i} — d" for i in range(1, 7)}
|
||||
slots = [tmp_path / f"outline-{i}.json" for i in (1, 2, 3)]
|
||||
plan_a = {"chapters": [{"title": "K1", "numbers": [1, 2, 6]}, {"title": "K2", "numbers": [3, 4, 5]}]}
|
||||
for p in slots[:2]:
|
||||
p.write_text(json.dumps(plan_a), encoding="utf-8")
|
||||
files = {"arbeit": tmp_path, "outline": tmp_path / "outline.json", "outline_slots": slots,
|
||||
"facts": tmp_path / "facts.json"}
|
||||
|
||||
review_out = {"val": {"moves": {"6": 2}}}
|
||||
|
||||
async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
|
||||
out = None
|
||||
if key.endswith("outline-prereqs"):
|
||||
out = {"prereqs": {}}
|
||||
elif key.endswith("outline-judge"):
|
||||
out = plan_a
|
||||
elif key.endswith("outline-review"):
|
||||
out = review_out["val"]
|
||||
if out is not None:
|
||||
m = re.search(r"(/\S+\.json)", prompt)
|
||||
with open(m.group(1), "w", encoding="utf-8") as f:
|
||||
json.dump(out, f)
|
||||
return "ok", payload(None)
|
||||
|
||||
monkeypatch.setattr(blx, "run_single_slot", fake_slot)
|
||||
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
|
||||
plan = await blx._outline_block(ctx, lambda *a, **k: None, files, entries, "")
|
||||
assert plan["chapters"][0]["numbers"] == [1, 2]
|
||||
assert plan["chapters"][1]["numbers"] == [3, 4, 5, 6]
|
||||
|
||||
# broken review (mass move) → schema rejects, plan unchanged
|
||||
review_out["val"] = {"moves": {"1": 2, "2": 2, "3": 1}}
|
||||
(tmp_path / "outline-review.json").unlink()
|
||||
(tmp_path / "outline.json").unlink()
|
||||
plan2 = await blx._outline_block(ctx, lambda *a, **k: None, files, entries, "")
|
||||
assert plan2["chapters"][0]["numbers"] == [1, 2, 6]
|
||||
|
||||
async def test_paraphrase_saturation_stops_early(sub_env, monkeypatch):
|
||||
"""Runde 2 liefert nur eine Paraphrase → zählt nicht als neu, Schleife endet ohne r3.
|
||||
Die Paraphrase liegt trotzdem in der DB (Mention fürs Cluster-Voting)."""
|
||||
db, ctx, files = sub_env
|
||||
base_fake, prompts = _mk_race({1: ["Umbruch braucht Marker"], 2: ["Umbruch braucht Marker"]})
|
||||
|
||||
async def with_r2(topic, label, slots, *a, **k):
|
||||
if any("-r2-" in s["key"] for s in slots):
|
||||
outs = []
|
||||
for slot in slots[:2]:
|
||||
m = _MD_PATH.search(slot["prompt"])
|
||||
with open(m.group(1), "w", encoding="utf-8") as f:
|
||||
f.write("<!-- block: Alpha -->\n- Umbruch erfordert explizite Marker!")
|
||||
outs.append(slot["payload"](None))
|
||||
return outs
|
||||
return await base_fake(topic, label, slots, *a, **k)
|
||||
|
||||
monkeypatch.setattr(blx, "_race", with_r2)
|
||||
raw = await blx._subblocks_block(ctx, lambda *a, **k: None, files, {1: "Alpha — Grundkonzept"},
|
||||
"", wipe=False, ns="x-")
|
||||
assert raw["Alpha"] # Konsens steht
|
||||
assert not any("-r3-" in k for k, _ in prompts) # Paraphrase hielt die Schleife NICHT am Leben
|
||||
rows = await db.list_subblocks(TOPIC, "alpha")
|
||||
assert any(r["sub_title"] == "Umbruch erfordert explizite Marker!" for r in rows)
|
||||
|
||||
|
||||
async def test_round_cap_stops_endless_finders(sub_env, monkeypatch):
|
||||
"""Jede Runde ein echt neues Konzept → hartes Cap stoppt bei SUBBLOCK_MAX_ROUNDS."""
|
||||
db, ctx, files = sub_env
|
||||
_, prompts = _mk_race({})
|
||||
|
||||
async def endless(topic, label, slots, *a, **k):
|
||||
if "-subblock-final-" in slots[0]["key"]:
|
||||
return None # panel fails → consensus fallback
|
||||
outs = []
|
||||
import re as _re
|
||||
rn = _re.search(r"-r(\d+)-", slots[0]["key"])
|
||||
n = rn.group(1) if rn else "x"
|
||||
for slot in slots[:2]:
|
||||
m = _MD_PATH.search(slot["prompt"])
|
||||
with open(m.group(1), "w", encoding="utf-8") as f:
|
||||
f.write(f"<!-- block: Alpha -->\n- Konzept{n} ist eigenständig")
|
||||
prompts.append((slot["key"], slot["prompt"]))
|
||||
outs.append(slot["payload"](None))
|
||||
return outs
|
||||
|
||||
monkeypatch.setattr(blx, "_race", endless)
|
||||
await blx._subblocks_block(ctx, lambda *a, **k: None, files, {1: "Alpha — Grundkonzept"},
|
||||
"", wipe=False, ns="x-")
|
||||
max_round = max(int(k.split("-r")[1].split("-")[0]) for k, _ in prompts if "-r" in k and "-subblock-c" in k)
|
||||
assert max_round == blx.SUBBLOCK_MAX_ROUNDS
|
||||
Reference in New Issue
Block a user