This commit is contained in:
team3
2026-07-03 11:45:27 +02:00
parent 285317927d
commit abcadd145d
44 changed files with 1909 additions and 292 deletions

View File

@@ -25,6 +25,9 @@ def _fake_single_slot(tmp_path):
if "-pair-" in key:
pairs = prompt.count("\nA: ")
out = {"pairs": {str(i + 1): "ja" for i in range(pairs)}}
elif "-dedup-" in key:
pairs = prompt.count("\nA: ")
out = {"pairs": {str(i + 1): "nein" for i in range(pairs)}}
elif "-clarify-" in key:
keep = [line[2:].split("")[0] for line in prompt.splitlines()
if line.startswith("- ")]
@@ -57,11 +60,11 @@ async def board_env(testdb, tmp_path, monkeypatch):
return False
monkeypatch.setattr(bi, "_emb_ok", no_emb)
async def fake_subblocks(ctx, set_p, files, entries, instructions, wipe=True, ns="", seeds=None, lbl=""):
async def fake_subblocks(ctx, set_p, files, entries, instructions, wipe=True, ns="", seeds=None, lbl="", sources=None):
title = list(entries.values())[0].split("")[0]
return {title: ["Sub Eins", "Sub Zwei"]}
async def fake_facts(ctx, set_p, files, raw, q, folder, instructions, ns="", lbl=""):
async def fake_facts(ctx, set_p, files, raw, q, folder, instructions, ns="", lbl="", sources=None):
facts = {t: {_norm_title(s): {"key_points": [f"Fakt zu {s}"], "cited_facts": []}
for s in subs} for t, subs in raw.items()}
return facts, {}
@@ -134,6 +137,7 @@ async def test_board1_full_flow(board_env):
# reader union survived the pipeline (consensus evidence on the block card)
alpha = next(c for c in done if c["payload"]["title"] == "Alpha")
assert set(alpha["payload"]["readers"]) == {"r1", "r2"}
assert alpha["payload"]["n_size"] == 2 # LPT estimate travels with the card
# board 2: one artefact card per block ran through to done_artefact (+ outline singleton)
art_done = await db.kanban_cards(TOPIC, board="artefacts", stage="done_artefact")
assert len(art_done) == 5 # 4 blocks + outline card
@@ -185,7 +189,7 @@ async def test_empty_subblocks_completes_without_deadletter(board_env, monkeypat
import blocks as blx
db, ctx, files = board_env
async def empty_subs(ctx, set_p, files, entries, instructions, wipe=True, ns="", seeds=None, lbl=""):
async def empty_subs(ctx, set_p, files, entries, instructions, wipe=True, ns="", seeds=None, lbl="", sources=None):
return {}
monkeypatch.setattr(ba, "_subblocks_block", empty_subs)
await db.kanban_upsert_card(TOPIC, "artefacts", "leer", "ablock", "subblocks",
@@ -264,7 +268,7 @@ async def test_panel_confirms_demote(board_env, tmp_path, monkeypatch):
assert c1["stage"] == "rejected"
assert c1["payload"]["reason"] == "fragment"
assert c1["payload"]["parent_norm"] == "codeblock"
assert (await db.kanban_get_card(TOPIC, B, "b-2"))["stage"] == "grouping"
assert (await db.kanban_get_card(TOPIC, B, "b-2"))["stage"] == "dedup"
async def test_panel_overrules_single_vote(board_env, tmp_path, monkeypatch):
@@ -278,7 +282,7 @@ async def test_panel_overrules_single_vote(board_env, tmp_path, monkeypatch):
("b-1", {"title": "Blockzitat", "description": "Zitat mit >"}),
("b-2", {"title": "Codeblock", "description": "Code mit Einrückung"}),
])
assert (await db.kanban_get_card(TOPIC, B, "b-1"))["stage"] == "grouping"
assert (await db.kanban_get_card(TOPIC, B, "b-1"))["stage"] == "dedup"
journal = json.loads(next(tmp_path.glob("inventar-filter-*.json")).read_text(encoding="utf-8"))
assert journal["ueberstimmt"] == ["Blockzitat"]
assert journal["degradiert"] == 0
@@ -329,7 +333,7 @@ async def test_floor_vetoes_structureless_demote(board_env, tmp_path, monkeypatc
("b-3", {"title": "Aufgabenlisten (Task Lists)", "description": "Checkboxen"}),
("b-4", {"title": "Aufgabenlisten", "description": "GFM-Listen mit Checkbox"}),
])
assert (await db.kanban_get_card(TOPIC, B, "b-1"))["stage"] == "grouping" # floor veto
assert (await db.kanban_get_card(TOPIC, B, "b-1"))["stage"] == "dedup" # floor veto
assert (await db.kanban_get_card(TOPIC, B, "b-3"))["stage"] == "rejected" # containment holds
journal = json.loads(next(tmp_path.glob("inventar-filter-*.json")).read_text(encoding="utf-8"))
assert journal["floor_veto"] == ["Blockzitat"]
@@ -388,19 +392,268 @@ async def test_supplement_reopens_dead_lineage(board_env, tmp_path, monkeypatch)
assert fresh and fresh["stage"] == "ingest" and fresh["payload"]["supplement"] is True
# ── Dedup-Stage: globaler Paar-Abgleich nach dem Naming ─────────────────────────────
def _angle_vecs(mapping):
"""Vector fake with controllable cosine: mapped substring → angle (degrees) in a
shared 2D plane; unmapped texts get their own orthogonal axis (cos 0 to everything)."""
import math as m
import numpy as np
async def fake(flow, texts):
dim = 2 + len(texts)
rows = []
for i, t in enumerate(texts):
v = np.zeros(dim)
for key, deg in mapping.items():
if key in t:
v[0], v[1] = m.cos(m.radians(deg)), m.sin(m.radians(deg))
break
else:
v[2 + i] = 1.0
rows.append(v)
return np.vstack(rows)
return fake
async def _run_dedup(db, ctx, tmp_path, cards):
"""Seed block cards into dedup and run ONE barrier pass over them."""
for cid, p in cards:
await db.kanban_upsert_card(TOPIC, B, cid, "block", "dedup", p)
rows = [{"card_id": cid, "payload": dict(p)} for cid, p in cards]
await bi._proc_dedup(ctx, _mk_flow(tmp_path), rows)
@pytest.fixture
def emb_on(monkeypatch):
async def yes(flow):
return True
monkeypatch.setattr(bi, "_emb_ok", yes)
async def test_dedup_merges_confirmed_pair(board_env, tmp_path, monkeypatch, emb_on):
"""Judge-„ja" merged: Verlierer → grouped (reason/merged_into), Champion sammelt reader."""
# Titel-Cos ~0.77 (unter Auto-0.95) → Kandidat, der Judge entscheidet
monkeypatch.setattr(bi, "_vec_rows", _angle_vecs({"satisfiability": 0, "sat": 40}))
counter = {}
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
("-dedup-", {"pairs": {"1": "ja"}}),
], counter))
await _run_dedup(db_ := board_env[0], ctx := board_env[1], tmp_path, [
("b-1", {"title": "SAT", "description": "kurz", "readers": ["r1"]}),
("b-2", {"title": "SAT (Satisfiability Problem)",
"description": "Erfüllbarkeit Boolescher Ausdrücke", "readers": ["r2"]}),
])
loser = await db_.kanban_get_card(TOPIC, B, "b-1")
champ = await db_.kanban_get_card(TOPIC, B, "b-2")
assert loser["stage"] == "grouped"
assert loser["payload"]["reason"] == "merged"
assert loser["payload"]["merged_into"] == "SAT (Satisfiability Problem)"
assert champ["stage"] == "grouping"
assert set(champ["payload"]["readers"]) == {"r1", "r2"}
journal = json.loads(next(tmp_path.glob("inventar-dedup-*.json")).read_text(encoding="utf-8"))
assert journal["merged"] == [{"dublette": "SAT", "in": "SAT (Satisfiability Problem)"}]
assert journal["paare_detail"][0]["verdict"] == "ja"
assert counter["-dedup-"] == 2 # Zwei-Judge-Panel
async def test_dedup_judge_nein_keeps_both(board_env, tmp_path, monkeypatch, emb_on):
monkeypatch.setattr(bi, "_vec_rows", _angle_vecs({"modifizierter": 0, "greedy": 40}))
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
("-dedup-", {"pairs": {"1": "nein"}}),
]))
await _run_dedup(board_env[0], board_env[1], tmp_path, [
("b-1", {"title": "Greedy-Algorithmus", "description": "Basisverfahren"}),
("b-2", {"title": "Modifizierter Greedy-Algorithmus", "description": "Variante"}),
])
assert (await board_env[0].kanban_get_card(TOPIC, B, "b-1"))["stage"] == "grouping"
assert (await board_env[0].kanban_get_card(TOPIC, B, "b-2"))["stage"] == "grouping"
async def test_dedup_relation_guard_blocks_identical_tokens(board_env, tmp_path, monkeypatch, emb_on):
"""Gleiche Tokens, andere Richtung: Judge sagt „ja", Titel-Cos 1.0 (Auto-Kante) —
der Relation-Guard blockt beides, beide Karten überleben."""
monkeypatch.setattr(bi, "_vec_rows", _angle_vecs({"hamiltonian": 0}))
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
("-dedup-", lambda key: {"pairs": {"1": "ja"}}),
]))
await _run_dedup(board_env[0], board_env[1], tmp_path, [
("b-1", {"title": "Hamiltonian Cycle ≤ Hamiltonian Path", "description": "Reduktion"}),
("b-2", {"title": "Hamiltonian Path ≤ Hamiltonian Cycle", "description": "Reduktion"}),
])
assert (await board_env[0].kanban_get_card(TOPIC, B, "b-1"))["stage"] == "grouping"
assert (await board_env[0].kanban_get_card(TOPIC, B, "b-2"))["stage"] == "grouping"
journal = json.loads(next(tmp_path.glob("inventar-dedup-*.json")).read_text(encoding="utf-8"))
assert journal["paare_detail"][0]["verdict"] == "guard_veto"
async def test_dedup_panel_disagreement_keeps_both(board_env, tmp_path, monkeypatch, emb_on):
"""Merge braucht Einstimmigkeit: j1 ja + j2 nein → beide überleben (Journal: uneinig)."""
monkeypatch.setattr(bi, "_vec_rows", _angle_vecs({"satisfiability": 0, "sat": 40}))
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
("-dedup-", lambda key: {"pairs": {"1": "ja" if "-j1" in key else "nein"}}),
]))
await _run_dedup(board_env[0], board_env[1], tmp_path, [
("b-1", {"title": "SAT", "description": "d1"}),
("b-2", {"title": "SAT (Satisfiability Problem)", "description": "d2"}),
])
assert (await board_env[0].kanban_get_card(TOPIC, B, "b-1"))["stage"] == "grouping"
assert (await board_env[0].kanban_get_card(TOPIC, B, "b-2"))["stage"] == "grouping"
journal = json.loads(next(tmp_path.glob("inventar-dedup-*.json")).read_text(encoding="utf-8"))
assert journal["paare_detail"][0]["verdict"] == "uneinig"
async def test_dedup_title_only_candidate(board_env, tmp_path, monkeypatch, emb_on):
"""Titel-Cos über dem Floor reicht als Kandidat — auch wenn der Mittelwert
(verschiedene Beschreibungs-Facetten) darunter liegt."""
# Beschreibungen fast orthogonal (0° vs 80°), Titel ähnlich (0° vs 40°)
monkeypatch.setattr(bi, "_vec_rows", _angle_vecs(
{"völlig": 0, "ganz": 80, "alpha kern": 0, "alpha zentrum": 40}))
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
("-dedup-", {"pairs": {"1": "ja"}}),
]))
await _run_dedup(board_env[0], board_env[1], tmp_path, [
("b-1", {"title": "Alpha Kern", "description": "völlig anderes"}),
("b-2", {"title": "Alpha Zentrum", "description": "ganz anders zwei"}),
])
stages = sorted([(await board_env[0].kanban_get_card(TOPIC, B, c))["stage"]
for c in ("b-1", "b-2")])
assert stages == ["grouped", "grouping"] # Paar wurde gejudged und merged
def test_canonical_key_camel_and_catalogue():
"""CamelCase-Split + Katalog-Phrasen-Strip; Varianten-Ziffern bleiben erhalten."""
from blocks import _canonical_key as k
assert k("Set Cover") == k("SetCover-Problem") != ""
assert k("Definition 6.19 (NP)") == "np"
assert k("SAT") != k("3-SAT") # Varianten-Ziffer ist Signal, keine Katalognummer
assert k("ModifiedGreedy") == k("Modified Greedy")
# englische Katalog-Phrasen gleichwertig (Quellen sind nicht immer deutsch)
assert k("Corollary 3.2 (VC)") == "vc"
assert k("Chapter 7: Vertex Cover") == k("Vertex Cover")
assert k("Section 2.1 Matching") == k("Matching")
def test_relation_guard_ignores_trailing_scaffolding():
"""Trailing „Reduktion/Transformation" ist kein Operand — sonst blockt der Guard
den korrekten Merge; Richtungs-Konflikte bleiben erkannt."""
from blocks import _relation_conflict as c
assert not c("SetCover ≤ HittingSet", "SetCover ≤ HittingSet Reduktion")
assert not c("A → B", "A → B Transformation")
assert c("Hamiltonian Cycle ≤ Hamiltonian Path", "Hamiltonian Path ≤ Hamiltonian Cycle")
def test_relation_guard_english_and_operator_suffix():
"""Englisches „Reduction" ist Scaffolding wie „Reduktion"; ein angehängtes
p/m am Operator („≤p") gehört zum Operator, nicht zum Operanden.
Beides waren Fehl-Vetos im aak-Lauf. Varianten-Konflikte bleiben."""
from blocks import _relation_conflict as c
assert not c("3-Exact Cover ≤ SubSet Sum", "Reduction 3-EXACT COVER ≤ SUBSET SUM")
assert not c("k-CLIQUE ≤ k-INDEPENDENT SET", "Reduction k-CLIQUE ≤ k-INDEPENDENT SET")
assert not c("3-SAT ≤ 3-Färbung", "3-SAT ≤p 3-Färbung")
assert c("SAT ≤ Clique", "3-SAT ≤ Clique") # Variante als Operand bleibt Konflikt
async def test_dedup_casefold_title_candidate(board_env, tmp_path, monkeypatch, emb_on):
"""GROSSSCHREIBUNG darf den Titel-Kanal nicht brechen: Titel werden casefolded
eingebettet („VERTEX COVER" vs. „Vertex Cover (VC)" lag real bei Cos 0.55)."""
monkeypatch.setattr(bi, "_vec_rows", _angle_vecs({"vertex cover": 0}))
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
("-dedup-", {"pairs": {"1": "ja"}}),
]))
await _run_dedup(board_env[0], board_env[1], tmp_path, [
("b-1", {"title": "VERTEX COVER", "description": "knapp"}),
("b-2", {"title": "Vertex Cover (VC)", "description": "Knotenüberdeckung ausführlich"}),
])
stages = sorted([(await board_env[0].kanban_get_card(TOPIC, B, c))["stage"]
for c in ("b-1", "b-2")])
assert stages == ["grouped", "grouping"]
async def test_dedup_no_embedding_passes_through(board_env, tmp_path, monkeypatch):
"""Ohne Embedding-Modell winkt die Stage durch — 0 Agent-Calls (kein n²-Fallback)."""
counter = {}
monkeypatch.setattr(bi, "run_single_slot", _slot_router([("-dedup-", {"pairs": {}})], counter))
await _run_dedup(board_env[0], board_env[1], tmp_path, [
("b-1", {"title": "SAT", "description": "d"}),
("b-2", {"title": "SAT (Satisfiability Problem)", "description": "d"}),
])
assert (await board_env[0].kanban_get_card(TOPIC, B, "b-1"))["stage"] == "grouping"
assert (await board_env[0].kanban_get_card(TOPIC, B, "b-2"))["stage"] == "grouping"
assert counter == {}
async def test_dedup_second_wave_merges_into_context(board_env, tmp_path, monkeypatch, emb_on):
"""Supplement-Welle: Neuling merged in den bestätigten Block; der bleibt unberührt."""
db, ctx, files = board_env
monkeypatch.setattr(bi, "_vec_rows", _angle_vecs({"satisfiability": 0, "sat": 40}))
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
("-dedup-", {"pairs": {"1": "ja"}}),
]))
await db.kanban_upsert_card(TOPIC, B, "b-old", "block", "done_block",
{"title": "SAT", "description": "Erfüllbarkeitsproblem",
"readers": ["r1"], "mirrored_norm": "sat"})
await db.upsert_block(TOPIC, "sat", "SAT", "Erfüllbarkeitsproblem", [])
await _run_dedup(db, ctx, tmp_path, [
("b-new", {"title": "SAT (Satisfiability Problem)", "description": "kurz", "readers": ["r9"]}),
])
new = await db.kanban_get_card(TOPIC, B, "b-new")
old = await db.kanban_get_card(TOPIC, B, "b-old")
assert new["stage"] == "grouped"
assert new["payload"]["merged_into"] == "SAT"
assert old["stage"] == "done_block" # context never demoted
assert set(old["payload"]["readers"]) == {"r1", "r9"}
async def test_dedup_resume_no_new_calls(board_env, tmp_path, monkeypatch, emb_on):
monkeypatch.setattr(bi, "_vec_rows", _angle_vecs({"satisfiability": 0, "sat": 40}))
counter = {}
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
("-dedup-", {"pairs": {"1": "nein"}}),
], counter))
cards = [("b-1", {"title": "SAT", "description": "d1"}),
("b-2", {"title": "SAT (Satisfiability Problem)", "description": "d2"})]
await _run_dedup(board_env[0], board_env[1], tmp_path, cards)
assert counter["-dedup-"] == 2 # zwei Panel-Judges
await _run_dedup(board_env[0], board_env[1], tmp_path, cards)
assert counter["-dedup-"] == 2 # judge files reused
async def test_dedup_complete_link_no_chaining(board_env, tmp_path, monkeypatch, emb_on):
"""A≈B ja, B≈C ja, AC kein Kandidat → complete-link merged nur ein Paar."""
# Winkel 0/30/60: A-B und B-C sind Kandidaten (cos .87), A-C nicht (cos .5 < Floor)
monkeypatch.setattr(bi, "_vec_rows", _angle_vecs({"alpha": 0, "beta": 30, "gamma": 60}))
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
("-dedup-", {"pairs": {"1": "ja", "2": "ja"}}),
]))
await _run_dedup(board_env[0], board_env[1], tmp_path, [
("b-1", {"title": "Konzept Alpha", "description": "a"}),
("b-2", {"title": "Konzept Beta", "description": "bb"}),
("b-3", {"title": "Konzept Gamma", "description": "c"}),
])
db = board_env[0]
stages = {cid: (await db.kanban_get_card(TOPIC, B, cid))["stage"]
for cid in ("b-1", "b-2", "b-3")}
assert sorted(stages.values()) == ["grouped", "grouping", "grouping"]
# ── Makespan: Slot-Priorität, vorgezogene Gliederung ────────────────────────────────
def test_agent_priority_order():
"""Board 1 zuerst; in Board 2 gewinnen späte Stages (Restarbeit vor Nachschub)."""
from agents import _agent_priority as p
t = "blocks-Markdown"
assert p(f"{t}-research-1") < p(f"{t}-filter-abc-c0") < p(f"{t}-supplement")
assert p(f"{t}-research-1") < p(f"{t}-filter-abc-c0") < p(f"{t}-dedup-abc-c0") < p(f"{t}-supplement")
# Gruppierung-Keys heißen "gruppierung": ohne eigenen Eintrag fielen sie ans Ende
# und verhungerten hinter Board 2 (aak: 211 min Slot-Wartezeit)
assert p(f"{t}-dedup-abc-c0") < p(f"{t}-gruppierung-xyz-cTOP") < p(f"{t}-supplement")
assert p(f"{t}-gruppierung-xyz-cTOP") < p(f"{t}-ns-subblock-c1-r2-1")
assert (p(f"{t}-outline-judge") < p(f"{t}-ns-artifact-example-c0")
< p(f"{t}-ns-question-pattern-c0") < p(f"{t}-ns-relevance-final-c0")
< p(f"{t}-ns-level-final-c0") < p(f"{t}-ns-facts-erg-c0")
< p(f"{t}-ns-subblock-c1-r2-1"))
assert p(f"{t}-supplement") < p(f"{t}-outline-1")
assert p("guide-t-writer-k1") == 16 # unmatched → after everything
assert p("guide-t-writer-k1") == 18 # unmatched → after everything
async def test_outline_runs_before_artefacts_finish(board_env, monkeypatch):
@@ -509,3 +762,5 @@ def test_per_block_functions_accept_wrapper_kwargs():
params = inspect.signature(getattr(blx, fn)).parameters
assert "ns" in params and "lbl" in params, fn
assert "seeds" in inspect.signature(blx._subblocks_block).parameters
for fn in ("_subblocks_block", "_facts_block"): # Board 2 reicht die Block-Quellen durch
assert "sources" in inspect.signature(getattr(blx, fn)).parameters, fn

View File

@@ -104,6 +104,13 @@ async def test_pull_prefers_bigger_blocks(testdb):
await db.kanban_upsert_card(TOPIC, "inventory", "b", "block", "s1")
pulled = await db.kanban_pull(TOPIC, "inventory", "s1", 10)
assert [c["card_id"] for c in pulled] == ["a", "b"]
# n_size (Board 1) ist der Fallback-Schätzer; subs_n behält Vorrang
await db.kanban_upsert_card(TOPIC, "inventory", "n-klein", "block", "s2", {"n_size": 2})
await db.kanban_upsert_card(TOPIC, "inventory", "n-gross", "block", "s2", {"n_size": 9})
await db.kanban_upsert_card(TOPIC, "inventory", "n-ohne", "block", "s2")
await db.kanban_upsert_card(TOPIC, "inventory", "n-subs", "block", "s2", {"subs_n": 3, "n_size": 1})
pulled = await db.kanban_pull(TOPIC, "inventory", "s2", 10)
assert [c["card_id"] for c in pulled] == ["n-gross", "n-subs", "n-klein", "n-ohne"]
async def test_learnstate_smoke(testdb):
@@ -172,3 +179,50 @@ async def test_guide_reset_card_single(testdb):
assert await gb.reset_card(TOPIC, "Guide", "beta", 3) is True
cards = {c["block_norm"]: c for c in await db.list_guide_cards(TOPIC, "Guide")}
assert cards["beta"]["stage"] == "fakten_gate" and cards["beta"]["md"]
async def test_completeness_route(testdb, tmp_path, monkeypatch):
import routes, paths
db = testdb
monkeypatch.setattr(paths, "arbeit_dir", lambda t: tmp_path)
await db.upsert_block(TOPIC, "alpha", "Alpha", "d", "[]")
await db.set_block_status(TOPIC, "alpha", "consensus")
await db.upsert_subblock(TOPIC, "alpha", "s1", "Alpha", "Sub Eins")
await db.set_subblock_fields(TOPIC, "alpha", "s1", status="consensus")
await db.upsert_question_pattern(TOPIC, "alpha", "s1", "Alpha", "Sub Eins", "Frage?")
await db.put_sub_artifact(TOPIC, "alpha", "s1", "flashcard", "Alpha", "Sub Eins", "{}")
await db.put_lernziel(TOPIC, "alpha", "z1", "Ziel")
await db.set_ziel_covered(TOPIC, "alpha", "z1", True)
(tmp_path / "inventar-filter-x.json").write_text(
'{"degradiert": 3, "ueberstimmt": ["A"], "floor_veto": []}', encoding="utf-8")
res = await routes.blocks_completeness(TOPIC)
assert res["bloecke"] == 1 and res["subs"] == 1
assert res["frage_bloecke"] == 1 and res["lernkarten"] == 1
assert res["ziele_total"] == 1 and res["ziele_covered"] == 1
assert res["degradiert_geprueft"] == 3 and res["panel_gerettet"] == 1
assert res["dead"] == 0
async def test_blocks_ready_from_db(testdb, monkeypatch):
"""Regression: gesynctes Topic ohne blocks.md muss trotzdem ready sein (DB zählt)."""
import blocks as blx
db = testdb
await db.kanban_upsert_card(TOPIC, "inventory", "b-1", "block", "done_block", {"title": "Alpha"})
st = await blx.blocks_status(TOPIC)
assert st["ready"] is True and st["partial"] is False
async def test_remove_guide_format_clears_everything(testdb, monkeypatch):
"""Board-Remove räumt ALLE Läufe eines Formats + Karten (8 error-Zeilen stapelten sich)."""
import routes
from models import GuideFormatRequest
db = testdb
for i in range(3):
await db.create_guide({"id": f"g{i}", "topic": TOPIC, "format": "Guide",
"instructions": "", "status": "error", "progress": None,
"created_at": "2026-01-01", "updated_at": "2026-01-01"})
await db.upsert_guide_card(TOPIC, "Guide", "alpha", "Alpha")
res = await routes.remove_guide_format(GuideFormatRequest(topic=TOPIC, format="Guide"))
assert res["removed"] == 3
assert await db.list_guides() == [] or all(g["topic"] != TOPIC for g in await db.list_guides())
assert await db.list_guide_cards(TOPIC, "Guide") == []

View File

@@ -122,6 +122,69 @@ TOML ausführlich.""")[0]
assert "TOML ausführlich." in sec["md"] and "YAML kompakt." in sec["compact"]
async def test_card_examples_filters_and_formats(testdb):
"""_card_examples: Norm-Matching auf die übergebenen Subs; unmatchte Beispiele nur
beim Voll-Writer/Teil 1 (include_unmatched) — nie stillschweigend weg."""
import json as _json
import guide_board as gb
from types import SimpleNamespace
db = testdb
await db.put_sub_artifact("t", "gross", "sub eins", "example",
_json.dumps({"problem": "P1", "steps": ["a", "b"], "result": "R1"}),
"Gross", "Sub Eins")
await db.put_sub_artifact("t", "gross", "verwaist", "example",
_json.dumps({"problem": "P2", "steps": ["x"], "result": "R2"}),
"Gross", "Verwaister Sub")
env = SimpleNamespace(topic="t")
subs = [{"title": "Sub Eins", "level": "beginner"}]
full = await gb._card_examples(env, "gross", subs)
assert "Sub Eins" in full and "P1" in full and "1) a 2) b" in full and "R1" in full
assert "Subbaustein unklar" in full and "P2" in full # orphan attached with hint
half = await gb._card_examples(env, "gross", subs, include_unmatched=False)
assert "P1" in half and "P2" not in half # split half: only its own subs
assert await gb._card_examples(env, "leer", subs) == ""
def test_writer_template_has_examples_placeholder():
"""Smoke: alle Platzhalter versorgt — ein fehlender Kwarg stürbe als KeyError."""
from pipeline import _prompt
text = _prompt("Guide-Writer-Board", topic="t", format_name="Guide", chapter="K1",
assignment="- B", ziele="- z", facts="F", examples="", gaps="",
spec="", out_path="/tmp/x.md", extra="")
assert "VERIFIED FACTS" in text
async def test_fakten_gate_counts_examples_as_facts(testdb, monkeypatch, tmp_path):
"""Gate-Prompt enthält die Beispiele als verifizierte Fakten — sonst fliegen
gerechnete Beispielwerte als „nicht belegt" raus."""
import json as _json
import guide_board as gb
from types import SimpleNamespace
db = testdb
await db.upsert_guide_card("t", "Guide", "gross", "Gross")
await db.put_sub_artifact("t", "gross", "sub eins", "example",
_json.dumps({"problem": "P1", "steps": ["a"], "result": "R1"}),
"Gross", "Sub Eins")
captured = {}
async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
captured["prompt"] = prompt
return "ok", []
monkeypatch.setattr(gb, "run_single_slot", fake_slot)
monkeypatch.setattr(gb, "_card_facts", lambda e, b: "FAKT X")
env = SimpleNamespace(ctx=SimpleNamespace(topic="t", provider="p", is_cancelled=lambda: False),
guide_id="g", topic="t", format="Guide", instructions="",
subs_by_title={"Gross": [{"title": "Sub Eins", "level": "beginner"}]},
spec="", slot=lambda name: tmp_path / name)
card = {"block_norm": "gross", "block": "Gross", "stage": "fakten_gate", "status": "open",
"writer_rounds": 0, "gate_info": "",
"md": "<!-- section: Gross -->\n<!-- ausführlich -->\nText."}
ok = await gb._stage_fakten_gate(env, card)
assert ok is True
assert "VERIFIED WORKED EXAMPLES" in captured["prompt"] and "P1" in captured["prompt"]
async def test_writer_splits_oversized_first_draft(testdb, monkeypatch, tmp_path):
import guide_board as gb
from types import SimpleNamespace

View File

@@ -0,0 +1,71 @@
"""PDF→Text-Konvertierung: pymupdf4llm primär, pdftotext-Fallback, mtime-Cache."""
import os
import time
import fitz # PyMuPDF
import pytest
import blocks as blx
def _mini_pdf(path, text="Approximationsalgorithmen sind wichtig."):
doc = fitz.open()
page = doc.new_page()
page.insert_text((72, 72), text, fontsize=12)
doc.save(str(path))
doc.close()
def test_convert_writes_markdown_txt(tmp_path):
_mini_pdf(tmp_path / "skript.pdf")
blx._convert_pdfs(tmp_path)
out = (tmp_path / "skript.txt").read_text(encoding="utf-8")
assert "Approximationsalgorithmen" in out
def test_cache_skips_fresh_txt(tmp_path):
_mini_pdf(tmp_path / "a.pdf")
marker = tmp_path / "a.txt"
marker.write_text("MARKER", encoding="utf-8")
now = time.time() + 60
os.utime(marker, (now, now))
blx._convert_pdfs(tmp_path)
assert marker.read_text(encoding="utf-8") == "MARKER" # nicht neu konvertiert
def test_fallback_to_pdftotext(tmp_path, monkeypatch):
_mini_pdf(tmp_path / "b.pdf")
monkeypatch.setattr(blx, "_pdf_markdown", lambda p: None)
monkeypatch.setattr(blx, "_pdf_plaintext", lambda p: "fallback")
blx._convert_pdfs(tmp_path)
assert (tmp_path / "b.txt").read_text(encoding="utf-8") == "fallback"
def test_ocr_languages_from_tessdata(tmp_path, monkeypatch):
import pymupdf
monkeypatch.setattr(pymupdf, "get_tessdata", lambda: str(tmp_path))
assert blx._ocr_languages() is None # keine Sprachdaten → OCR aus
(tmp_path / "eng.traineddata").touch()
assert blx._ocr_languages() == "eng"
(tmp_path / "deu.traineddata").touch()
assert blx._ocr_languages() == "deu+eng"
monkeypatch.setattr(pymupdf, "get_tessdata", lambda: (_ for _ in ()).throw(RuntimeError()))
assert blx._ocr_languages() is None
def test_fidelity_guard_prefers_faithful_plaintext():
plain = "Definition. P = {L ⊆ Σ | A ∈ L} und ≤ sowie häufig über. " * 20
# Markdown verlor die Formeln (Symbole weg) → plain gewinnt
md_lossy = "Definition. und sowie h¨aufig ¨uber. " * 20
text, tool = blx._pick_conversion(md_lossy, plain)
assert tool == "pdftotext"
# Markdown treu (Symbole + Länge da) → md gewinnt
md_ok = "# Def\n" + plain
text, tool = blx._pick_conversion(md_ok, plain)
assert tool == "pymupdf4llm"
# nur eine Quelle verfügbar
assert blx._pick_conversion(None, plain)[1] == "pdftotext"
assert blx._pick_conversion(md_ok, None)[1] == "pymupdf4llm"
assert blx._pick_conversion(None, None) is None

View File

@@ -0,0 +1,146 @@
"""Flashcard-Übungspool: Leitner-Schritte, Deck-Bau (Level-Gate, fällig/neu), Persistenz."""
import json
from datetime import datetime, timedelta, timezone
from learning import LEITNER_MAX_BOX, PRACTICE_NEW_PER_SESSION, leitner_step
TOPIC = "t"
def _iso(days: float = 0) -> str:
return (datetime.now(timezone.utc) + timedelta(days=days)).isoformat()
async def _card(db, bn, sn, sub_title="Sub", q="Q?", block="Block"):
await db.put_sub_artifact(TOPIC, bn, sn, "flashcard",
json.dumps({"question": q, "answer": "A"}), block, sub_title)
# ── Leitner rein funktional ──────────────────────────────────────────────────────────
def test_leitner_step_transitions():
assert leitner_step(None, True) == (2, 1) # neue Karte gewusst → Box 2, morgen
assert leitner_step(None, False) == (1, 0) # neue Karte falsch → Box 1, sofort
assert leitner_step(2, True) == (3, 3)
assert leitner_step(LEITNER_MAX_BOX, True) == (LEITNER_MAX_BOX, 21) # Cap
assert leitner_step(4, False) == (1, 0) # falsch → zurück auf Anfang
# ── Persistenz ───────────────────────────────────────────────────────────────────────
async def test_progress_upsert_roundtrip(testdb):
db = testdb
await db.upsert_practice_progress(TOPIC, "b", "s", 2, _iso(1))
await db.upsert_practice_progress(TOPIC, "b", "s", 3, _iso(3))
rows = await db.get_practice_progress(TOPIC)
assert len(rows) == 1 and rows[0]["box"] == 3
async def test_progress_survives_artefakte_wipe(testdb):
db = testdb
await _card(db, "b", "s")
await db.upsert_practice_progress(TOPIC, "b", "s", 4, _iso(7))
await db.delete_sub_artefakte(TOPIC) # Regenerations-Wipe
assert (await db.get_practice_progress(TOPIC))[0]["box"] == 4
async def test_delete_topic_pipeline_clears_progress(testdb):
db = testdb
await db.upsert_practice_progress(TOPIC, "b", "s", 2, _iso(1))
await db.delete_topic_pipeline(TOPIC)
assert await db.get_practice_progress(TOPIC) == []
async def test_sub_levels_norm_and_counts(testdb):
db = testdb
await db.put_subblock(TOPIC, "b", "s1", "Block", "S1", level="beginner")
await db.put_subblock(TOPIC, "b", "s2", "Block", "S2", level="expert")
await db.put_subblock(TOPIC, "b", "s3", "Block", "S3", level="beginner", relevance="peripheral")
await db.put_subblock(TOPIC, "b", "s4", "Block", "S4", level="beginner", status="variant")
levels = await db.sub_levels_norm(TOPIC)
assert levels[("b", "s1")] == 1 and levels[("b", "s2")] == 3 and levels[("b", "s3")] == 4
assert ("b", "s4") not in levels # non-consensus ausgeschlossen
counts = await db.subs_per_level_norm(TOPIC)
assert counts["b"] == {1: 1, 2: 0, 3: 1, 4: 1}
# ── Deck-Bau ─────────────────────────────────────────────────────────────────────────
async def test_deck_level_gate_and_unlock(testdb):
from routes import build_practice_deck
db = testdb
# block_norm muss _norm_title(Roh-Titel) sein — so entsteht er auch in der Pipeline
await db.put_subblock(TOPIC, "block", "s1", "Block", "S1", level="beginner")
await db.put_subblock(TOPIC, "block", "s2", "Block", "S2", level="expert")
await _card(db, "block", "s1", "S1")
await _card(db, "block", "s2", "S2")
deck = await build_practice_deck(TOPIC)
assert [c["sub_norm"] for c in deck["cards"]] == ["s1"] # expert gesperrt
assert deck["counts"]["gesperrt"] == 1
# Score über S1+S2-Schwelle (2 Subs × 25 = 50) → expert (Level 3) frei
await db.set_block_score_and_streak(TOPIC, "Block", 50, 0)
deck = await build_practice_deck(TOPIC)
assert {c["sub_norm"] for c in deck["cards"]} == {"s1", "s2"}
async def test_deck_due_before_new_oldest_first(testdb):
from routes import build_practice_deck
db = testdb
for sn in ("s1", "s2", "s3"):
await db.put_subblock(TOPIC, "b", sn, "Block", sn.upper(), level="beginner")
await _card(db, "b", sn, sn.upper())
await db.upsert_practice_progress(TOPIC, "b", "s2", 2, _iso(-1))
await db.upsert_practice_progress(TOPIC, "b", "s3", 2, _iso(-5))
deck = await build_practice_deck(TOPIC)
assert [c["sub_norm"] for c in deck["cards"]] == ["s3", "s2", "s1"] # älteste fällige zuerst
assert [c["status"] for c in deck["cards"]] == ["due", "due", "new"]
assert deck["counts"] == {"due": 2, "new": 1, "new_total": 1, "gesperrt": 0}
async def test_deck_caps_new_and_reports_total(testdb):
from routes import build_practice_deck
db = testdb
for i in range(PRACTICE_NEW_PER_SESSION + 5):
sn = f"s{i:02d}"
await db.put_subblock(TOPIC, "b", sn, "Block", sn, level="beginner")
await _card(db, "b", sn, sn)
deck = await build_practice_deck(TOPIC)
assert deck["counts"]["new"] == PRACTICE_NEW_PER_SESSION
assert deck["counts"]["new_total"] == PRACTICE_NEW_PER_SESSION + 5
async def test_deck_future_due_sets_next_due_at(testdb):
from routes import build_practice_deck
db = testdb
await db.put_subblock(TOPIC, "b", "s1", "Block", "S1", level="beginner")
await _card(db, "b", "s1", "S1")
await db.upsert_practice_progress(TOPIC, "b", "s1", 3, _iso(3))
deck = await build_practice_deck(TOPIC)
assert deck["cards"] == [] and deck["counts"]["due"] == 0
assert deck["next_due_at"] is not None
async def test_deck_orphan_progress_and_legacy_block(testdb):
from routes import build_practice_deck
db = testdb
# Orphan: Progress ohne Karte → unschädlich, taucht nicht auf
await db.upsert_practice_progress(TOPIC, "weg", "s0", 2, _iso(-1))
# Legacy: Karte ohne subblocks-Zeilen → ungefiltert durchlassen
await _card(db, "leg", "sx", "SX")
deck = await build_practice_deck(TOPIC)
assert [c["block_norm"] for c in deck["cards"]] == ["leg"]
async def test_answer_books_without_card(testdb):
"""Antwort während Regeneration: bucht immer, kein Fehlerpfad."""
from models import PracticeAnswerRequest
from routes import practice_answer
db = testdb
res = await practice_answer(PracticeAnswerRequest(
topic=TOPIC, block_norm="b", sub_norm="s", correct=True))
assert res["box"] == 2
res = await practice_answer(PracticeAnswerRequest(
topic=TOPIC, block_norm="b", sub_norm="s", correct=False))
assert res["box"] == 1
assert (await db.get_practice_progress(TOPIC))[0]["box"] == 1

View File

@@ -62,24 +62,27 @@ def _mk_race(finder_by_agent):
for slot in slots:
key, prompt = slot["key"], slot["prompt"]
prompts.append((key, prompt))
text = None
fake_race.slots_seen.append(slot)
if "-subblock-final-" in key:
# no-tool judges reply as TEXT; the payload sink writes the j-file itself
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:
outs.append(slot["payload"]((0, text, "")))
continue
if "-r1-" in key:
agent = int(key.rsplit("-", 1)[1])
subs = finder_by_agent.get(agent) or []
if subs:
if subs and (m := _MD_PATH.search(prompt)):
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))
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
fake_race.slots_seen = []
return fake_race, prompts
@@ -293,3 +296,124 @@ async def test_round_cap_stops_endless_finders(sub_env, monkeypatch):
"", 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
# ── Inline-Evidenz für Judges (Token-Umbau) ──────────────────────────────────────────
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"
ok = blx._sink_json((0, 'Vorab {"levels": {"1": "beginner"}} nach', ""), p,
lambda d: blx._levels_schema(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()
async def test_clarify_inline_evidence_no_tools(sub_env, monkeypatch, tmp_path):
"""Mit Korpus: Judges bekommen Auszüge inline und laufen ohne Tools (Text-Antwort);
die j-Datei schreibt die Engine. Finder bleiben unverändert bei capabilities=files."""
db, ctx, files = sub_env
d = _corpus(tmp_path)
monkeypatch.setattr(blx, "source_folder", lambda t: d)
monkeypatch.setattr(blx, "load_source", lambda t: {"type": "uni"})
fake, prompts = _mk_race({1: ["Alpha Grundlagen"], 2: ["Alpha Grundlagen"]})
monkeypatch.setattr(blx, "_race", fake)
raw = await blx._subblocks_block(ctx, lambda *a, **k: None, files, {1: "Alpha — Grundkonzept"},
"", wipe=False, ns="x-", sources=["Skript.txt"])
assert raw == {"Alpha": ["Alpha Grundlagen"]}
judges = [s for s in fake.slots_seen if "-subblock-final-" in s["key"]]
finders = [s for s in fake.slots_seen if "-r1-" in s["key"]]
assert judges and all(s["capabilities"] == "none" for s in judges)
assert "── Skript.txt" in judges[0]["prompt"]
assert "ls/find" not in judges[0]["prompt"] # keine Selbst-Recherche-Anweisung mehr
assert finders and all(s["capabilities"] == "files" for s in finders)
assert list(tmp_path.glob("subblock-final-*-j*.md")) # Engine persistiert die Judge-Antwort
async def test_facts_check_inline_cited_evidence(sub_env, monkeypatch, tmp_path):
"""Facts-Check: zitierte Zeilenbereiche gehen inline mit, Judge läuft ohne Tools,
die Check-Datei schreibt die Engine aus der Text-Antwort."""
db, ctx, files = sub_env
d = _corpus(tmp_path)
facts = {"facts": [{"block": "Alpha", "subblock": "Sub Eins", "key_points": ["k"],
"prerequisites": "", "hurdles": "",
"cited_facts": [{"text": "T", "source": "Skript.txt, Z.2"}],
"example_idea": ""}]}
async def fake_slot(ctx2, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
if "-facts-erg-" in key:
return blx.FAILED, None
(tmp_path / "facts-c0.json").write_text(json.dumps(facts), encoding="utf-8")
return blx.OK, None
seen = []
async def fake_agent(key, prompt, timeout, provider="", role="", capabilities="", scope=None, label="", **kw):
seen.append((key, capabilities, prompt))
return (0, '{"ok": true}', "")
monkeypatch.setattr(blx, "run_single_slot", fake_slot)
monkeypatch.setattr(blx, "run_agent", fake_agent)
res = await blx._facts_block(ctx, lambda *a, **k: None, {"arbeit": tmp_path},
{"Alpha": ["Sub Eins"]}, {"type": "uni"}, d, "", ns="x-")
assert res is not None
facts_map, discarded = res
assert "Alpha" in facts_map and not discarded
assert len(seen) == blx.FACTS_CHECK_PANEL
key, caps, prompt = seen[0]
assert caps == "none" and "── Skript.txt · Z." in prompt
assert (tmp_path / "facts-check-c0-j1.json").exists() # Engine persistiert die Antwort