Files
creator/backend/tests/test_subblocks.py
2026-07-05 15:26:22 +02:00

193 lines
9.3 KiB
Python

"""Subbaustein-Helfer: Varianten-Cluster, Evidence-Packs, Outline-Review, Hash/Key-Auflösung.
Die verschmolzene Block-Pipeline (Generate/Verify/Artefakte) testet tests/test_block_calls.py."""
import json
import numpy as np
import blocks as blx
from pipeline import GenContext
TOPIC = "t"
# ── _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
# ── 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: # neue Semantik: Antwort als TEXT, der Sink persistiert
return "ok", payload((0, json.dumps(out), ""))
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]
# ── Inline-Evidenz (Evidence-Packs für die verschmolzenen Calls) ─────────────────────
def _corpus(tmp_path):
d = tmp_path / "korpus"
d.mkdir()
(d / "Skript.txt").write_text(
"Kapitel 1\nAlpha Grundlagen: der Kernbegriff.\nMehr Text dazu.\n\n"
"Kapitel 2\nGamma Randnotiz ohne Bezug.\n", encoding="utf-8")
(d / "Aufgaben.txt").write_text("Übung 1\nAlpha Vertiefung der Konzepte.\n", encoding="utf-8")
return d
def test_evidence_pack_selects_matching_sections(tmp_path):
d = _corpus(tmp_path)
pack = blx._evidence_pack(d, None, ["Alpha Grundlagen"])
assert "── Skript.txt" in pack and "Kernbegriff" in pack
pack2 = blx._evidence_pack(d, ["Aufgaben.txt"], ["Alpha"]) # genannte Quellen engen ein
assert "Skript.txt" not in pack2 and "Aufgaben.txt" in pack2
assert blx._evidence_pack(None, None, ["x"]) == "" # kein Korpus → Selbst-Recherche bleibt
def test_evidence_pack_budget_and_guarantee(tmp_path):
d = tmp_path / "korpus"
d.mkdir()
(d / "A.txt").write_text("Alpha wichtig. " * 50, encoding="utf-8")
(d / "B.txt").write_text("Beta anderes Thema. " * 50, encoding="utf-8")
pack = blx._evidence_pack(d, None, ["Alpha"], budget=10)
assert "Alpha" in pack # Abdeckungs-Garantie schlägt das Budget
assert "Beta" not in pack # Top-up respektiert das Budget
def test_cite_ref_parses_positions(tmp_path):
d = _corpus(tmp_path)
files = blx._corpus_files(d, None)
f, lo, hi = blx._cite_ref("Skript.txt, Übung 6.47, Z.2-3", files)
assert f.name == "Skript.txt" and (lo, hi) == (2, 3)
f2, lo2, hi2 = blx._cite_ref("Aufgaben.txt Zeile 2", files)
assert f2.name == "Aufgaben.txt" and lo2 == hi2 == 2
assert blx._cite_ref("Skript.txt, Übung 6.47", files) is None # keine Zeilenangabe
assert blx._cite_ref("Z.5 irgendwo", files) is None # keine Datei
# englische Zitierformen (Quellen sind nicht immer deutsch)
f3, lo3, hi3 = blx._cite_ref("Skript.txt, line 2", files)
assert f3.name == "Skript.txt" and lo3 == hi3 == 2
f4, lo4, hi4 = blx._cite_ref("Aufgaben.txt, lines 1-2", files)
assert f4.name == "Aufgaben.txt" and (lo4, hi4) == (1, 2)
def test_cited_evidence_lines_and_fallback(tmp_path):
d = _corpus(tmp_path)
ev = blx._cited_evidence(d, None, ["Skript.txt, Z.2"], ["Alpha"])
assert "── Skript.txt · Z." in ev and "Kernbegriff" in ev
ev2 = blx._cited_evidence(d, None, ["ohne Position"], ["Alpha Grundlagen"])
assert "Kernbegriff" in ev2 # Keyword-Fallback
def test_sink_json_writes_only_valid(tmp_path):
p = tmp_path / "level-final-c1.json"
from pipeline import _enum_map_schema
levels = _enum_map_schema("levels", ("beginner", "advanced", "expert"))
ok = blx._sink_json((0, 'Vorab {"levels": {"1": "beginner"}} nach', ""), p,
lambda d: levels(d, {1}))
assert ok == {1: "beginner"}
assert json.loads(p.read_text(encoding="utf-8"))["levels"]["1"] == "beginner"
bad = blx._sink_json((0, "kein json", ""), tmp_path / "x.json", lambda d: d)
assert bad is None and not (tmp_path / "x.json").exists()
def test_material_folder_fallbacks(monkeypatch, tmp_path):
"""Echte Quelle gewinnt; sonst arbeit/material mit Inhalt; sonst None."""
monkeypatch.setattr(blx, "source_folder", lambda t: tmp_path / "quelle")
assert blx.material_folder("t") == tmp_path / "quelle"
monkeypatch.setattr(blx, "source_folder", lambda t: None)
monkeypatch.setattr(blx, "arbeit_dir", lambda t: tmp_path / "arbeit")
assert blx.material_folder("t") is None # kein Material-Ordner
md = tmp_path / "arbeit" / "material"
md.mkdir(parents=True)
assert blx.material_folder("t") is None # leer zählt nicht
(md / "research-1.txt").write_text("x", encoding="utf-8")
assert blx.material_folder("t") == md
def test_sub_key_resolves_short_titles():
"""Artefakt-Agenten echoen den Kurztitel; der Sub-Key heißt 'kurztitel: beschreibung'.
Eindeutiger Präfix wird aufgelöst, Mehrdeutiges und Fehlendes bleibt unverändert."""
import board_artefacts as ba
existing = {"autolink mit url: erzeugt link", "bilder: bindet bilder ein",
"doppel: eins", "doppel: zwei", "exakt"}
assert ba._sub_key(existing, "exakt") == "exakt"
assert ba._sub_key(existing, "autolink mit url") == "autolink mit url: erzeugt link"
assert ba._sub_key(existing, "doppel") == "doppel" # mehrdeutig → unverändert
assert ba._sub_key(existing, "fehlt") == "fehlt" # kein Treffer → unverändert
# Fuzzy: Paraphrase/Kürzung ohne Doppelpunkt-Präfix löst eindeutig auf
lang = {"der backslash selbst muss mit escaped werden, um literal zu erscheinen"}
assert ba._sub_key(lang, "der backslash selbst muss mit escaped werden") == next(iter(lang))
assert ba._sub_key(lang | {"der backslash am zeilenende"}, "der backslash") == "der backslash" # mehrdeutig
def test_subs_hash_invalidiert_bei_neuem_zuschnitt():
"""Gleicher Sub-Satz → gleicher Hash (Resume greift); geänderter → neuer Hash.
raw-Form (Strings) und sidecar-Form (dicts) hashen identisch."""
a = {"Block": ["s1", "s2"]}
assert blx._subs_hash(a) == blx._subs_hash({"Block": ["s1", "s2"]})
assert blx._subs_hash(a) != blx._subs_hash({"Block": ["s1", "s3"]})
assert blx._subs_hash(a) == blx._subs_hash({"Block": [{"title": "s1"}, {"title": "s2"}]})