This commit is contained in:
team3
2026-07-08 21:14:33 +02:00
parent 9a6ab0937b
commit f9d77a113b
30 changed files with 1064 additions and 654 deletions

View File

@@ -49,7 +49,7 @@ def active_agents(scope_prefix: str | None = None) -> list[dict]:
# Board-2-Calls tragen diese Marker im agent_key; alles andere unter blocks-{topic}- ist Board 1.
_ARTEFAKT_MARKER = ("-art-gen", "-art-check", "-sb-enrich", "-sb-verify", "-sb-fix",
"-sub-crossblock", "-outline")
"-outline")
def agent_ebene(key: str) -> str:

View File

@@ -17,23 +17,27 @@ async def auto_repair_loop(ebene: str, erst_note: float, reparieren, max_iter: i
"""Loop QA→Repair für eine Ebene.
erst_note : float — bereits gemessene QA-Note vor dem ersten Repair (010).
reparieren() -> float: behebt die Befunde UND gibt die neu gemessene Note zurück
(die Repair-Bausteine messen ohnehin am Ende — keine Doppelmessung).
reparieren() -> (float, bool): behebt die Befunde UND gibt die neu gemessene Note
zurück (die Repair-Bausteine messen ohnehin am Ende — keine Doppel-
messung) plus „bewegt": ob die Runde etwas getan hat (Fixes, Merges,
Freisprüche, Nach-Recherche). Eine bewegte Runde darf die Note auch
mal transient senken (neue Blöcke → neue Verdachte) — Stillstand ist
erst, wenn NICHTS mehr passiert UND die Note nicht steigt.
Stopp bei: Note == VOLL (fertig), Note verbessert sich nicht mehr (stillstand),
oder max_iter Runden (limit). Returns {ebene, note, runden, grund} mit
grund ∈ {"fertig","stillstand","limit"} — der Aufrufer nutzt grund für die
Fortschritts-/Systemfehler-Meldung.
Stopp bei: Note == VOLL (fertig), unbewegt ohne Verbesserung (stillstand),
oder max_iter Runden (limit — begrenzt „bewegt ohne Notengewinn"-Livelocks).
Returns {ebene, note, runden, grund} mit grund ∈ {"fertig","stillstand","limit"}.
"""
if erst_note >= VOLL:
return {"ebene": ebene, "note": erst_note, "runden": 0, "grund": "fertig"}
note = erst_note
for runde in range(1, max_iter + 1):
neu = await reparieren()
log.info("[%s] Auto-Loop Runde %d: %.1f%.1f", ebene, runde, note, neu)
neu, bewegt = await reparieren()
log.info("[%s] Auto-Loop Runde %d: %.1f%.1f (%s)",
ebene, runde, note, neu, "bewegt" if bewegt else "unbewegt")
if neu >= VOLL:
return {"ebene": ebene, "note": neu, "runden": runde, "grund": "fertig"}
if neu <= note: # keine Verbesserung ⇒ weitere Runden zwecklos (kein Repair-Pfad)
if not bewegt and neu <= note: # nichts getan UND keine Verbesserung ⇒ kein Repair-Pfad
return {"ebene": ebene, "note": neu, "runden": runde, "grund": "stillstand"}
note = neu
note = max(note, neu)
return {"ebene": ebene, "note": note, "runden": max_iter, "grund": "limit"}

View File

@@ -370,27 +370,60 @@ def _pick_conversion(md: str | None, plain: str | None) -> tuple[str, str] | Non
return plain, "pdftotext"
# Small-Caps-/Math-Italic-Artefakte der PDF-Extraktion: LaTeX-\textsc/Kerning liest sich als
# Binnen-Leerzeichen („H ITTING S ET", „N P") und erzeugt in der QA Phantom-Konzepte, die kein
# Block je ankern kann. Regel A: Einzelgroßbuchstabe + GROSSLAUF(≥2) mergen — außer der Lauf
# wird klein fortgesetzt („L NP-vollständig": L ist Variable, NP gehört zum Kompositum).
_PDF_CAPS_SPLIT = re.compile(r"(?<![A-Za-zÄÖÜäöüß])([A-ZÄÖÜ]) ([A-ZÄÖÜ]{2,})(?!-?[a-zäöüß])")
# Regel B: Einzelbuchstaben-PAAR („N P") — nur mergen, wenn das Ergebnis im selben Dokument
# mehrfach ungespalten vorkommt (Frequenz-Beleg statt Domänenliste: „NP" ja, „C Y" nein).
_PDF_LETTER_PAIR = re.compile(r"(?<![A-Za-zÄÖÜäöüß])([A-ZÄÖÜ]) ([A-ZÄÖÜ])(?![A-Za-zÄÖÜäöüß-])")
_PDF_NORM_VERSION = 1 # bump → nächster Lauf re-konvertiert alle PDFs (Marker .pdf-txt-norm)
def _entzerre_pdf_woerter(text: str) -> str:
"""Gespaltene Wörter aus der PDF-Konvertierung zusammenfügen (nur Merges, kein Umbau).
Bewusste Restlücken: Zeilenumbruch-Splits und Mehrfach-Splits ohne Großlauf — dafür
sind Lücken-Research + Freispruch das Netz."""
prev = None
while prev != text: # Ketten: „V ERTEX C OVER" braucht zwei Durchgänge pro Segment
prev = text
text = _PDF_CAPS_SPLIT.sub(r"\1\2", text)
def _belegt(m: re.Match) -> str:
merged = m.group(1) + m.group(2)
n = len(re.findall(rf"(?<![A-Za-zÄÖÜäöüß]){merged}(?![A-Za-zÄÖÜäöüß])", text))
return merged if n >= 3 else m.group(0)
return _PDF_LETTER_PAIR.sub(_belegt, text)
def _convert_pdfs(project: Path) -> None:
"""Convert PDFs in the project to .txt — agents read text instead of page images.
Called before every project generation; converts only if the .txt is missing or
older than the PDF. Both converters run; the fidelity guard picks the better result
per file. Neither available → hard error instead of an unreliable direct-read mode
(MiniMax image limit, vision cost)."""
(MiniMax image limit, vision cost). Ein Versions-Marker (.pdf-txt-norm) erzwingt nach
Änderungen an der Wort-Entzerrung einmalig die Re-Konvertierung trotz mtime-Cache."""
pdfs = list(project.rglob("*.pdf"))
if not pdfs:
return
marker = project / ".pdf-txt-norm"
aktuell = marker.exists() and marker.read_text(encoding="utf-8").strip() == str(_PDF_NORM_VERSION)
for pdf in pdfs:
txt = pdf.with_suffix(".txt")
if txt.exists() and txt.stat().st_mtime >= pdf.stat().st_mtime:
if aktuell and txt.exists() and txt.stat().st_mtime >= pdf.stat().st_mtime:
continue
picked = _pick_conversion(_pdf_markdown(pdf), _pdf_plaintext(pdf))
if picked is None:
raise RuntimeError(f"PDF conversion failed ({pdf.name}): weder pymupdf4llm noch "
"pdftotext verfügbar/erfolgreich (pip install pymupdf4llm oder poppler-utils)")
text, tool = picked
txt.write_text(text, encoding="utf-8")
txt.write_text(_entzerre_pdf_woerter(text), encoding="utf-8")
_log(project.name, f"PDF konvertiert ({tool}): {pdf.name}{txt.name}")
if not aktuell:
marker.write_text(str(_PDF_NORM_VERSION), encoding="utf-8")
_SOURCE_TEMPLATE = {"projekt": "Blocks-Source-Projekt", "uni": "Blocks-Source-Uni", "link": "Blocks-Source-Link"}
@@ -1350,28 +1383,6 @@ def _completion_schema(data, n_umbrellas: int, ids: set[int]):
return out
# Deterministic backstop to the grouping judge's TEST 1 (type gate): an umbrella may bundle ONLY
# constituent sub-definitions of ONE definition. If a member title carries a standalone-unit signal
# (a named algorithm / problem / reduction / theorem), the umbrella is dissolved — those stay their own
# blocks. Kept narrow so real definition-parts (Konfiguration, Übergangsfunktion δ, Literale, Makespan,
# m Maschinen) never match; checked against the aak over-merge (member „Greedy-Algorithmus GA" hits).
# Suffix-anchored head nouns (German compounds are head-final: „Approximations+algorithmus" has NO word
# boundary before „algorithmus", so \bAlgorithmus\b misses it → the MAX-SAT over-merge). \w* absorbs the
# modifier; the head noun stays the discriminator. FP-safe: no real TM/KNF definition-part ends in these
# heads (Berechnung is deliberately NOT a head → „Akzeptierende Berechnung" stays a valid member).
_GROUP_STANDALONE = re.compile(
r'\w*algorithm(?:us|en)\b|\w*problem(?:e|s|en)?\b|\w*reduktion(?:en)?\b|\bscheduling\b|[≤⪯]'
r'|^\s*(?:Satz|Lemma|Korollar|Theorem|Bemerkung|Beobachtung)\s*\d'
# atomicity: a named COMPLEXITY CLASS / a "…-Vollständigkeit(completeness)" / a "…Transformation" is a
# self-contained concept (learning-object / atomic-KC), never a sub-definition — a bundle of ≥1 such
# member is siblings, not one model → dissolve (catches the P/NP/NP-Vollständigkeit over-merge that NO
# cosine floor separates). Head-final compounds (\w*klasse absorbs "Komplexitäts+klasse"); FP-safe —
# no real TM/KNF/TSP/Scheduling definition-part carries these heads.
r'|\w*vollständigkeit\b|\w*completeness\b|\w*transformation(?:en)?\b|\w*klasse[nr]?\b', re.I)
# --- Outline (blocks artifact: chapter structure, only read by the guide) ---
def _outline_review_schema(data, valid: set[int], n_chapters: int, n_blocks: int):
@@ -1640,10 +1651,10 @@ async def _guide_ebene(topic: str, instructions: str, provider: str, is_cancelle
async def _reparieren():
set_p("Befunde beheben (Guide)…")
await guide_board.repair_karten(topic, fmt) # befundtragende Karten → pruefer
betroffen = await guide_board.repair_karten(topic, fmt) # Karten → pruefer/fix
await guide.generate_guide(topic=topic, format_name=fmt, guide_id=guide_id,
instructions=instructions, provider=provider) # resumt offene Karten
return await _messen()
return await _messen(), len(betroffen) > 0
res = await auto_repair_loop("Guide", erst, _reparieren)
if res["grund"] != "fertig":

View File

@@ -4,27 +4,23 @@ A card is spawned by board 1's `done` column per mirrored block and runs through
generate → verify (inkl. Fix-Tail) → artefakte (Gen + Prüfer) → finalize
(die verschmolzenen Calls liegen in block_calls.py — 45 serielle Segmente statt ~20).
finalize (SERIAL) merges the block's results into the global sidecar/facts/pattern/artefakte
files + the DB tables. Danach zwei topic-weite BARRIEREN: `konsolidierung` (cross-block
sub dedup, faltet per repair.falte_sub) und `outline` (prerequisite graph → chapter order),
re-run once per generation run — outline läuft parallel zur Dedup-Barriere."""
files + the DB tables. Danach eine topic-weite BARRIERE: `outline` (prerequisite graph →
chapter order), re-run once per generation run."""
import asyncio
import hashlib
import json
import logging
import re
import database as db
import blocks
import embedding
from block_calls import _artefakte_block, _generate_block, _verify_block
from blocks import ARTEFACT_TYPES, _match_sub, _neg_set, _sink_json, _outline_block
from config import CROSS_CHUNK_PAARE, EMBEDDING_AKTIV, SUB_DUP_KANDIDAT_COS
from blocks import ARTEFACT_TYPES, _match_sub, _outline_block
from fsutil import atomic_write_json
from jsonio import read_json_file as _json_file
from kanban import Flow, Stage
from pipeline import FAILED, GenContext, _extra, _log, _prompt, _timeout, run_single_slot
from textkit import _norm_title, _title, parse_facts
from pipeline import GenContext, _log
from textkit import _norm_title, _title
log = logging.getLogger("creator.board_artefacts")
@@ -274,162 +270,6 @@ async def _proc_artefakte(ctx: GenContext, flow: Flow, files: dict, instructions
await _gather_cards(ctx, flow, cards, one)
def _cross_schema(data) -> dict[int, str] | None:
"""{"pairs": {"1": "a"|"b"|"nein"}} → {pair_nr: verdict} · otherwise None."""
if not isinstance(data, dict) or not isinstance(data.get("pairs"), dict):
return None
out: dict[int, str] = {}
for k, v in data["pairs"].items():
try:
nr = int(k)
except (ValueError, TypeError):
continue
s = str(v).strip().casefold()
if s in ("a", "b", "nein"):
out[nr] = s
return out or None
async def _proc_konsolidierung(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
"""BARRIER/drain am RUN-ENDE — cross-block sub dedup: the SAME statement carried by two
blocks (measured on Markdown: tab handling in 3 blocks, HTML blocks, backslash escapes —
the in-block paths never see these). Embedding candidates (block≠block, cos ≥
SUB_DUP_KANDIDAT_COS) go to a two-judge panel; UNANIMITY decides which block keeps the
statement. Sitzt seit dem Umbau NACH finalize: als Mittel-Barriere wartete jede fertige
Karte auf die langsamste (gemessen: 8:46 min Leerlauf pro Block, kanban-smoke). Der
Verlierer wird per repair.falte_sub gefaltet (variant + Fragen/Artefakte umhängen) —
die wenigen Cross-Dubletten kosten so ein paar umsonst generierte Artefakte statt
Minuten Wandzeit für alle. Fail-open on judge failure/dissent."""
from repair import falte_sub
topic = flow.topic
work_dir = flow.work_dir
# Resume-Karten aus der alten Stage-Position (Barriere lag vor den Fragen): erst fertig
# generieren — die Barriere feuert erneut, wenn alle wieder hier sind. Direkt dedupen
# ginge schief: finalize würde den gefalteten Sub aus dem Karten-Sidecar re-spiegeln.
nachzuegler = [(c["card_id"], "artefakte" if "sidecar" in c["payload"] else "generate")
for c in cards if "pattern" not in c["payload"]]
if nachzuegler:
await db.kanban_advance_many(topic, BOARD, nachzuegler)
flow.wake.set()
return
async def _advance_all():
await db.kanban_advance_many(topic, BOARD, [(c["card_id"], DONE) for c in cards])
flow.wake.set()
rows = [r for r in await db.list_subblocks(topic) if r["status"] == "consensus"]
if len(rows) < 2 or not EMBEDDING_AKTIV or not await asyncio.to_thread(embedding.available):
await _advance_all()
return
sims = await asyncio.to_thread(embedding.embed_sims, [r["sub_title"] for r in rows])
if sims is None:
await _advance_all()
return
negs = [_neg_set(r["sub_title"]) for r in rows]
pairs = [(i, j) for i in range(len(rows)) for j in range(i + 1, len(rows))
if rows[i]["block_norm"] != rows[j]["block_norm"] and negs[i] == negs[j]
and float(sims[i][j]) >= SUB_DUP_KANDIDAT_COS]
if not pairs:
await _advance_all()
return
def _kp(r: dict) -> list:
return parse_facts(r.get("facts")).get("key_points") or []
def _side(tag: str, r: dict) -> str:
return f"{tag}: [Block: {r['block']}] {r['sub_title']}" + "".join(f"\n - {p}" for p in _kp(r))
# chunked judging: ONE call over all pairs scaled its timeout past 50 min, and a hung
# call blocked the barrier for the full window (measured on aak: 196 pairs, 2×54 min)
chunks = [pairs[lo:lo + CROSS_CHUNK_PAARE] for lo in range(0, len(pairs), CROSS_CHUNK_PAARE)]
async def _urteile_chunk(chunk: list[tuple[int, int]]) -> dict[int, str]:
"""Two judges (+ substitute, + tie-breaker) over one pair chunk → {local_k: verdict};
empty dict = fail-open (pairs stay)."""
lines = "\n\n".join(
f"{k}.\n{_side('A', rows[i])}\n{_side('B', rows[j])}"
for k, (i, j) in enumerate(chunk, 1))
h = hashlib.md5(lines.encode()).hexdigest()[:8]
paths = [work_dir / f"sub-crossblock-{h}-j{j}.json" for j in (1, 2)]
async def _judge(j, path, plines, n):
if _cross_schema(_json_file(path)) is not None:
return # resume
status, _v = await run_single_slot(
ctx, f"Sub-Crossblock j{j}", key=f"blocks-{topic}-sub-crossblock-{h}-j{j}",
prompt=_prompt("Subblock-Crossblock", topic=topic, pairs=plines, extra=_extra(instructions)),
role="judge", capabilities="none",
payload=lambda result, p=path: _sink_json(result, p, _cross_schema),
timeout=_timeout("subblock_check", n))
if status == FAILED:
_log(topic, f"Sub-Crossblock j{j} ohne Ergebnis — fail-open")
await asyncio.gather(*[_judge(j, p, lines, len(chunk)) for j, p in zip((1, 2), paths)])
if ctx.is_cancelled():
return {}
outs = [o for p in paths if (o := _cross_schema(_json_file(p))) is not None]
if len(outs) == 1: # Ersatz-Richter statt fail-open bei EINEM Ausfall
ersatz = work_dir / f"sub-crossblock-{h}-jE.json"
await _judge("E", ersatz, lines, len(chunk))
if ctx.is_cancelled():
return {}
outs = [o for p in [*paths, ersatz] if (o := _cross_schema(_json_file(p))) is not None]
if len(outs) != 2:
if outs:
_log(topic, "Sub-Crossblock: nur 1/2 Richter — fail-open")
return {}
final = {k: (outs[0].get(k, "nein") if outs[0].get(k, "nein") == outs[1].get(k, "nein")
else "uneinig") for k in range(1, len(chunk) + 1)}
disputed = [k for k, v in final.items() if v == "uneinig"]
if disputed: # tie-breaker: a third judge sees ONLY the disputed pairs, majority 2/3
d_lines = "\n\n".join(
f"{x}.\n{_side('A', rows[chunk[k - 1][0]])}\n{_side('B', rows[chunk[k - 1][1]])}"
for x, k in enumerate(disputed, 1))
p3 = work_dir / f"sub-crossblock-{h}-j3.json"
await _judge(3, p3, d_lines, len(disputed))
if ctx.is_cancelled():
return {}
v3 = _cross_schema(_json_file(p3)) or {}
if not v3:
_log(topic, "Sub-Crossblock j3 ohne Ergebnis — strittige Paare bleiben")
for x, k in enumerate(disputed, 1):
t = v3.get(x, "nein")
if t in (outs[0].get(k, "nein"), outs[1].get(k, "nein")):
final[k] = t # majority 2/3; anything else stays disputed → no fold
return final
chunk_finals = await asyncio.gather(*[_urteile_chunk(c) for c in chunks])
if ctx.is_cancelled():
return
final_all: dict[int, str] = {} # global pair index (1-based over `pairs`) → verdict
for cnr, fin in enumerate(chunk_finals):
for k, v in fin.items():
final_all[cnr * CROSS_CHUNK_PAARE + k] = v
journal = {"paare": len(pairs), "chunks": len(chunks), "gefaltet": [], "verdicts": []}
gone: set[tuple] = set()
for k, (i, j) in enumerate(pairs, 1):
verdict = final_all.get(k, "nein")
journal["verdicts"].append({"a": f"{rows[i]['block']} · {rows[i]['sub_title']}",
"b": f"{rows[j]['block']} · {rows[j]['sub_title']}",
"verdict": verdict})
if verdict not in ("a", "b"):
continue
win, lose = (rows[i], rows[j]) if verdict == "a" else (rows[j], rows[i])
wk = (win["block_norm"], win["sub_norm"])
lk = (lose["block_norm"], lose["sub_norm"])
if lk in gone or wk in gone: # keeper already folded → don't chain away the content
continue
await falte_sub(topic, files, win, lose)
gone.add(lk)
journal["gefaltet"].append({"weg": f"{lose['block']} · {lose['sub_title']}",
"bleibt": f"{win['block']} · {win['sub_title']}"})
if journal["gefaltet"]:
_log(topic, f"Sub-Crossblock: {len(journal['gefaltet'])} blockübergreifende Dublette(n) gefaltet")
hg = hashlib.md5("\n".join(f"{i}:{j}" for i, j in pairs).encode()).hexdigest()[:8]
atomic_write_json(work_dir / f"sub-crossblock-{hg}.json", journal, indent=1)
await _advance_all()
# ── Finalize (SERIAL): merge into the global files + DB tables ─────────────────────
def _merge_json(path, block_keys: dict) -> None:
data = _json_file(path)

View File

@@ -39,7 +39,7 @@ import kanban
from kanban import Flow, Stage, chain_stages
import blocks
from blocks import (
_FILTER_NOTATION, _GROUP_STANDALONE,
_FILTER_NOTATION,
_build_research_prompt, _canonical, _canonical_key, _chunk_nums, _cliques,
_completion_schema, _containment_parent, _crawl_index, _file_payload,
_filter_schema, _filter_suspect, _is_artifact, _is_named_statement,
@@ -47,13 +47,13 @@ from blocks import (
_direction_conflict, _relation_conflict, _root, _supplement_schema, _text_sections, _umbrella_schema,
_aspect_marker, _title_variants, _corpus_files, _evidence_pack, _sink_json, source_folder,
)
from config import (QA_GATE_NOTE, QA_GATE_LLM,
from config import (QA_GATE_NOTE, QA_GATE_LLM, LUECKEN_RESEARCH_MAX,
DEDUP_GLOBAL_FLOOR, DEDUP_PAIR_FLOOR, DEDUP_PAIRS_CHUNK, DEDUP_TITLE_AUTO, FILTER_CHUNK,
FILTER_RECHECK_PANEL, CONSOLIDATION_PANEL, RESEARCH_BATCH, RESEARCH_READERS,
RESEARCH_THEMA_AGENTS,
BLOCKS_GRUPPIERUNG_AKTIV, EMBEDDING_AKTIV, EMBEDDING_BLOCK_CAP,
EMBEDDING_SIBLING_CAP, EMBEDDING_SIBLING_FLOOR, FRAGMENT_MIN_COS,
GROUP_MIN_COS_FLOOR, GROUP_RECONCILE_FLOOR,
GROUP_RECONCILE_FLOOR, GROUP_THEMES_PER_SQRT,
)
from fsutil import atomic_write_json, atomic_write_text
from jsonio import read_json_file as _json_file
@@ -1272,6 +1272,15 @@ async def _proc_dedup(ctx: GenContext, flow: Flow, cards):
flow.wake.set()
def _themen_zielband(n: int) -> tuple[int, int]:
"""Weiches Prompt-Band der Themenzahl: k = GROUP_THEMES_PER_SQRT·√n, ±30 %, min 2.
n ist die Item-Zahl der GROUPING-WELLE — die Supplement-Welle bekommt bewusst ein
kleines Band aus ihrem kleinen n (sie sortiert nur Neuzugänge nach)."""
k = GROUP_THEMES_PER_SQRT * math.sqrt(max(n, 1))
lo = max(2, math.floor(0.7 * k))
return lo, max(lo + 1, math.ceil(1.3 * k))
async def _proc_grouping(ctx: GenContext, flow: Flow, cards):
"""BARRIER/drain — umbrella grouping over the filter survivors: embedding sibling clusters
(low floor, high recall) + top-down pass, one judge per cluster, type gate + min-cos backstop,
@@ -1300,11 +1309,7 @@ async def _proc_grouping(ctx: GenContext, flow: Flow, cards):
all_ids = set(range(1, n + 1))
full_list = "\n".join(f"{i}. {texts[i - 1]}" for i in range(1, n + 1))
h = _h(*[r["card_id"] for r in rows])
def _min_cos(idxs):
if len(idxs) < 2:
return 1.0
return round(min(float(sims[i][j]) for a, i in enumerate(idxs) for j in idxs[a + 1:]), 3)
theme_lo, theme_hi = _themen_zielband(n)
async def _assess(tag, cand_text, count):
path = work_dir / f"gruppierung-{h}-c{tag}.json"
@@ -1312,7 +1317,8 @@ async def _proc_grouping(ctx: GenContext, flow: Flow, cards):
status, _v = await run_single_slot(
ctx, f"Gruppierung {tag}", key=f"blocks-{topic}-gruppierung-{h}-c{tag}",
prompt=_prompt("Blocks-Gruppierung", topic=topic, candidates=cand_text,
list=full_list, out_path=path),
list=full_list, out_path=path,
theme_lo=theme_lo, theme_hi=theme_hi, n_items=n),
role="judge", capabilities="files",
payload=lambda result, p=path: _umbrella_schema(_json_file(p), all_ids),
timeout=_timeout("research_mapping", count))
@@ -1349,7 +1355,6 @@ async def _proc_grouping(ctx: GenContext, flow: Flow, cards):
# Bottom-up card-sorting: keine type-gate/min-cos-Vetos mehr — jedes Item soll in
# ein Thema; ein „Fehl-Merge" ist billig (Item bleibt als Sub erhalten, keine Lücke).
mrows = [rows[m - 1] for m in members]
mc = _min_cos([m - 1 for m in members])
unorm = _norm_title(title)
member_norms = {r["title_norm"] for r in mrows}
if unorm not in member_norms and unorm in seen_norm:
@@ -1358,7 +1363,7 @@ async def _proc_grouping(ctx: GenContext, flow: Flow, cards):
continue
used.update(members)
seen_norm.add(unorm)
chosen.append({"umbrella": title, "description": desc, "min_cos": mc, "members": members})
chosen.append({"umbrella": title, "description": desc, "members": members})
# reconcile: same parent proposed twice under different titles → union
if len(chosen) >= 2:
uv = await _vec_rows(flow, [f"{c['umbrella']}{c['description']}" for c in chosen])
@@ -1462,24 +1467,25 @@ async def _proc_gap_check(ctx: GenContext, flow: Flow, cards):
flow.wake.set()
async def _supplement_beleg(ctx: GenContext, flow: Flow, supplements: list) -> list:
async def _supplement_beleg(ctx: GenContext, flow: Flow, supplements: list, tag: str = "") -> list:
"""Evidence gate for supplement proposals: keyword excerpts per proposal, ONE no-tool
judge marks material coverage (ja/nein). Proposals without any matching excerpt drop
immediately; a failed gate keeps nothing (creep is costlier than a lost bonus round)."""
immediately; a failed gate keeps nothing (creep is costlier than a lost bonus round).
`tag` trennt Resume-Datei/Key je Aufrufer (Lücken-Recherche läuft mehrere Runden)."""
topic = flow.topic
folder = source_folder(topic)
packs = [(t, d, _evidence_pack(folder, None, [t], budget=6000)) for t, d in supplements]
cands = [(t, d, ev) for t, d, ev in packs if ev]
kept: list = []
if cands:
path = flow.work_dir / "supplement-beleg.json"
path = flow.work_dir / f"supplement-beleg{tag}.json"
ids = set(range(1, len(cands) + 1))
verdict = _yesno_schema(_json_file(path), ids)
if verdict is None:
lines = "\n\n".join(f"{k}. {t}{d}\nAUSZÜGE:\n{ev}"
for k, (t, d, ev) in enumerate(cands, 1))
status, verdict = await run_single_slot(
ctx, "Supplement-Beleg", key=f"blocks-{topic}-supplement-beleg",
ctx, "Supplement-Beleg", key=f"blocks-{topic}-supplement-beleg{tag}",
prompt=_prompt("Blocks-Supplement-Beleg", topic=topic, proposals=lines,
extra=_extra(flow.state.get("instructions", ""))),
role="judge", capabilities="none",
@@ -1525,11 +1531,21 @@ async def _supplement_producer(ctx: GenContext, flow: Flow, titles: list[str]):
# treats). Only proposals the material itself covers may enter the inventory.
if supplements and source_folder(topic):
supplements = await _supplement_beleg(ctx, flow, supplements)
# Dead lineage: blocks demoted by the fragment filter (and their cluster + title cards)
# must NOT dedup a supplement proposal — their content is gone. A hit on a dead title
# REOPENS the lineage instead: the title card rejoins its cluster (live re-cluster) and
# the respawned block gets a fresh fragment_filter pass. failed-quorum/pre-reject stay
# in the dedup: those were rejected as non-blocks, not lost as content.
new, reopened = await _ingest_proposals(topic, supplements or [])
if new or reopened:
_log(topic, f"Supplement: {new} Block-Kandidat(en) → ingest, {reopened} wiedereröffnet")
flow.wake.set()
async def _ingest_proposals(topic: str, supplements: list) -> tuple[int, int]:
"""Vorschläge (title, description) als Titel-Karten einspeisen — gemeinsamer Ingest-
Schwanz von Supplement- und Lücken-Recherche. → (neu, wiedereröffnet).
Dead lineage: blocks demoted by the fragment filter (and their cluster + title cards)
must NOT dedup a proposal — their content is gone. A hit on a dead title REOPENS the
lineage instead: the title card rejoins its cluster (live re-cluster) and the respawned
block gets a fresh fragment_filter pass. failed-quorum/pre-reject stay in the dedup:
those were rejected as non-blocks, not lost as content."""
dead_reasons = {"fragment", "drop-collateral", "drop"}
cards = await db.kanban_cards(topic, board=BOARD)
dead_clusters = {c["payload"].get("cluster") for c in cards
@@ -1563,7 +1579,7 @@ async def _supplement_producer(ctx: GenContext, flow: Flow, titles: list[str]):
if (k := _canonical_key(tt)):
known_keys.add(k)
new = reopened = 0
for t, d in (supplements or []):
for t, d in supplements:
t, d = clean_title(t), clean_title(d)
norm = _norm_title(t)
key = _canonical_key(t)
@@ -1587,9 +1603,57 @@ async def _supplement_producer(ctx: GenContext, flow: Flow, titles: list[str]):
card["payload"]["supplement"] = True
await db.kanban_set_payload(topic, BOARD, norm, card["payload"])
new += 1
if new or reopened:
_log(topic, f"Supplement: {new} Block-Kandidat(en) → ingest, {reopened} wiedereröffnet")
flow.wake.set()
return new, reopened
async def _luecken_producer(ctx: GenContext, flow: Flow, report: dict) -> int:
"""Gezielte Nach-Recherche für offene QA-Lücken (Auto-Loop): EIN Agent sieht Bestand
plus Lücken-Fundstellen (Abschnittstexte deterministisch re-extrahiert, gleicher
Splitter wie die QA) und schlägt NUR dafür Blöcke vor — leere Liste erlaubt.
Danach Evidence-Gate + Ingest wie beim Supplement; neue Titel durchlaufen alle
Inventar-Gates bis done_block. → Zahl neuer/wiedereröffneter Karten."""
import qa as _qa
topic = flow.topic
lk, kl = _qa.offene_luecken(report)
if not (lk or kl):
return 0
flow.add_producer() # Engine am Leben halten, solange der Research-Call läuft
try:
texts = _qa._corpus_texts(topic)
sections = {f: _qa._sections(t) for f, t in texts.items()}
gaps = []
for x in lk[:20]:
sec = sections.get(x.get("datei"), [])
k = int(x.get("abschnitt") or 0)
volltext = sec[k - 1][:1500] if 0 < k <= len(sec) else x.get("vorschau", "")
gaps.append(f"[{x.get('datei')} #{k}]\n{volltext}")
gaps += [f"NAMED RESULT (no covering block): {n}" for n in kl[:20]]
done = await db.kanban_cards(topic, board=BOARD, kind="block", stage="done_block")
runde = flow.state.get("luecken_runde", 0)
path = flow.work_dir / f"luecken-research-r{runde}.json"
supplements = _supplement_schema(_json_file(path))
if supplements is None:
status, supplements = await run_single_slot(
ctx, f"Lücken-Recherche r{runde}", key=f"blocks-{topic}-luecken-nachfass-r{runde}",
prompt=_prompt("Blocks-Luecken-Research", topic=topic,
blocks="\n".join(f"- {c['payload'].get('title', '')}" for c in done),
gaps="\n\n".join(gaps), out_path=path,
extra=_extra(flow.state.get("instructions", ""))),
role="quick", capabilities="files",
payload=lambda result, p=path: _supplement_schema(_json_file(p)),
timeout=_timeout("ergaenzung"))
if status != OK or supplements is None:
_log(topic, "Lücken-Recherche ohne Ergebnis — übersprungen")
return 0
if supplements and source_folder(topic):
supplements = await _supplement_beleg(ctx, flow, supplements, tag=f"-l{runde}")
new, reopened = await _ingest_proposals(topic, supplements or [])
if new or reopened:
_log(topic, f"Lücken-Recherche: {new} Kandidat(en) → ingest, {reopened} wiedereröffnet")
flow.wake.set()
return new + reopened
finally:
flow.done_producer()
async def _proc_done(ctx: GenContext, flow: Flow, cards):
@@ -1731,11 +1795,11 @@ async def run_boards(ctx: GenContext, set_p, files: dict, q: dict, folder, instr
stages = chain_stages(stages)
if artefacts:
# Outline needs every block's TITLE + FACTS (aus generate), nothing later: cut the
# post-generate stages from its barrier so it runs parallel to verify…finalize AND
# zur Cross-Dedup-Barriere des langsamsten Blocks (makespan tail).
# post-generate stages from its barrier so it runs parallel to verify…finalize
# des langsamsten Blocks (makespan tail).
outline = next(s for s in stages if s.stage == "outline")
outline.upstream = [u for u in outline.upstream if u not in
("verify", "artefakte", "finalize", "konsolidierung")]
("verify", "artefakte", "finalize")]
producers = _build_producers(ctx, flow, q, folder, instructions) if (research and inventory) else []
async def _as_producer(coro):
@@ -1792,17 +1856,24 @@ async def run_boards(ctx: GenContext, set_p, files: dict, q: dict, folder, instr
_QA_GATE_POLL = 2.0 # Sekunden zwischen Quiescence-Checks des QA-Wächters
async def _warte_inventar_ruhe(flow: Flow, inv_names: list[str]) -> None:
"""Bis das Inventar quiescent ist (Research fertig, keine aktiven Karten in den
Inventar-Stages) oder der Flow stoppt — Poll-Kopf des QA-Wächters, auch nach
Lücken-Recherche-Runden gebraucht (neue Karten laufen erst durch alle Gates)."""
while not flow.stop:
if (flow.research_done and not flow.active_in(inv_names)
and await db.kanban_count(flow.topic, inv_names) == 0):
return
await asyncio.sleep(_QA_GATE_POLL)
async def _qa_gate_watch(ctx: GenContext, flow: Flow, inv_names: list[str], set_p):
"""Companion task: once the inventory is quiescent, run the QA once and decide —
open the board-2 gate or pause the flow. Fail-OPEN on errors (QA is a helper,
not a jailer); qa_force short-circuits to open."""
topic = flow.topic
try:
while not flow.stop:
if (flow.research_done and not flow.active_in(inv_names)
and await db.kanban_count(topic, inv_names) == 0):
break
await asyncio.sleep(_QA_GATE_POLL)
await _warte_inventar_ruhe(flow, inv_names)
if flow.stop or ctx.is_cancelled():
return
if flow.state.get("qa_force"):
@@ -1832,16 +1903,33 @@ async def _qa_gate_watch(ctx: GenContext, flow: Flow, inv_names: list[str], set_
flow.wake.set()
return
# Auto an: „Befunde beheben"-Loop bis 100 % / Stillstand / 10×.
# Auto an: „Befunde beheben"-Loop bis 100 % / Stillstand / 10×. Offene Lücken
# bekommen erst bis zu LUECKEN_RESEARCH_MAX gezielte Nach-Recherche-Runden;
# das Lücken-Urteil (Freispruch/bestätigt) fällt erst, wenn die Recherche
# nichts mehr bewegt — sonst würde eine füllbare Lücke vorschnell verurteilt.
from auto_loop import auto_repair_loop
import repair as _repair
async def _reparieren():
rep = qa.latest_report(topic) or {}
research_bewegt = False
runde = flow.state.get("luecken_runde", 0)
lk, kl = qa.offene_luecken(rep)
if (lk or kl) and runde < LUECKEN_RESEARCH_MAX:
flow.state["luecken_runde"] = runde + 1
set_p(f"Lücken-Recherche {runde + 1}/{LUECKEN_RESEARCH_MAX}")
neue = await _luecken_producer(ctx, flow, rep)
research_bewegt = neue > 0
if neue: # neue Titel durchlaufen alle Gates — erst Ruhe, dann messen
await _warte_inventar_ruhe(flow, inv_names)
if flow.stop or ctx.is_cancelled():
return float(flow.state.get("qa_note") or 0.0), False
set_p("Befunde beheben (Inventar)…")
r = await _repair.repair_befunde(topic, "inventory") # behebt + misst neu
r = await _repair.repair_befunde(topic, "inventory",
luecken_urteil=not research_bewegt)
n = float(r.get("note", 10.0))
flow.state["qa_note"] = n
return n
return n, bool(r.get("aktionen", 0)) or research_bewegt
res = await auto_repair_loop("Inventar", note, _reparieren)
end_note = res["note"]
@@ -1899,7 +1987,7 @@ async def _artefakt_auto_loop(ctx: GenContext, flow: Flow, set_p):
async def _reparieren():
set_p("Befunde beheben (Artefakte)…")
r = await _repair.repair_befunde(topic, "artefacts")
return float(r.get("note_artefakte") or 10.0)
return float(r.get("note_artefakte") or 10.0), bool(r.get("aktionen", 0))
res = await auto_repair_loop("Artefakte", na, _reparieren)
flow.state["note_artefakte"] = res["note"]
@@ -1986,7 +2074,6 @@ COLUMNS = [
("artefacts", "verify", "Prüfen", "ablock"),
("artefacts", "artefakte", "Lernmittel", "ablock"),
("artefacts", "finalize", "Zusammenführen", "ablock"),
("artefacts", "konsolidierung", "Konsolidierung", "ablock"),
("artefacts", "outline", "Gliederung", "outline"),
("artefacts", "done_artefact", "Fertig", "ablock"),
]
@@ -2053,13 +2140,15 @@ async def board_snapshot(topic: str, limit: int = 20) -> dict:
def _qa_view(topic: str, counts: dict, flow) -> dict | None:
"""Latest QA report digest for the board header. `pausiert` = the gate stopped the
flow (score below threshold, board-2 cards waiting, no flow running)."""
flow (score below 100 %, board-2 cards waiting, no flow running) — das Auto-Gate
öffnet nur bei VOLL; eine 9.6 pausierte den Loop, galt hier aber nicht als pausiert."""
# no inventory (deleted/never built) → no badge; the report files stay on purpose,
# so the first run after a rebuild diffs against the old state
if not counts.get("inventory", {}).get("done_block", 0) and not (
flow and flow.state.get("qa_note") is not None):
return None
import qa
from auto_loop import VOLL
r = qa.latest_report(topic)
if r is None:
return None
@@ -2067,9 +2156,9 @@ def _qa_view(topic: str, counts: dict, flow) -> dict | None:
if note is None:
return None
wartend = counts.get("artefacts", {}).get("generate", 0)
pausiert = bool(note < QA_GATE_NOTE and wartend and flow is None)
pausiert = bool(note < VOLL and wartend and flow is None)
return {"note": note, "note_artefakte": r.get("note_artefakte"),
"schwelle": QA_GATE_NOTE, "pausiert": pausiert,
"schwelle": VOLL, "pausiert": pausiert,
"quoten": r.get("quoten", {}),
"befunde": (r.get("fremd", []) + r.get("unecht", []))[:6]}

View File

@@ -75,9 +75,6 @@ SEED_COVER_COS = 0.80
# (Markdown: 50 pairs in the band, 4 above) — below every auto-merge threshold, so an LLM
# judge decides. Candidates only; a merge still needs judge unanimity.
SUB_DUP_KANDIDAT_COS = 0.75
# Cross-block judge pairs per call: ONE call over all pairs scaled its timeout to 54 min
# and a hung call blocked the barrier that long (aak: 196 pairs) — chunks cap it at ~15 min.
CROSS_CHUNK_PAARE = 40
# Umbrella grouping (block granularity level 2, step "Blocks-Gruppierung", AFTER the filter):
# collapse sibling DEFINITIONS that are components of ONE umbrella concept (TM model:
@@ -94,16 +91,13 @@ EMBEDDING_SIBLING_CAP = 18 # a rich model (TM) can have many constituent p
# titles (e.g. two „Turingmaschine"-umbrellas). Merge umbrella pairs whose title+description cosine is
# ≥ this (conservative → only true same-parent duplicates, never two distinct umbrellas).
GROUP_RECONCILE_FLOOR = 0.75
# Over-merge backstop ONLY (no-structure floor). Research (meronymy ≠ similarity): parts of ONE model are
# legitimately DISSIMILAR (TM: Alphabet/Konfiguration/δ ~0.22), while distinct same-type concepts (P/NP/…)
# are SIMILAR (~0.85) — so member-vs-member cosine is the WRONG instrument for over-merge (empirically
# inverted: TM 0.218 < the P/NP bundle 0.227). The real precision floor is the ATOMICITY type-guard
# (_GROUP_STANDALONE: a member that is a named algorithm/problem/theorem/complexity-class dissolves the
# umbrella). This floor is demoted to a near-zero backstop that only rejects a literally structureless
# chain (random-pair baseline), set BELOW the legitimate heterogeneous minimum so it never kills a real model.
GROUP_MIN_COS_FLOOR = 0.15
# Fragment-demote backstop, same logic as GROUP_MIN_COS_FLOOR: fragment↔parent cosine is a BAD
# fragment detector (measured, Markdown run: wrong demotes Blockzitate→Codeblöcke 0.353 and
# Zielband der Themen-Blockzahl im Gruppierungs-Prompt: k = GROUP_THEMES_PER_SQRT·√n
# (n = Items der Grouping-Welle), Band [0.7k, 1.3k] — weiche Vorgabe, kein Cap. √n hält
# Blockzahl UND mittlere Blockgröße sublinear (n=30 → ~48, n=285 → ~1222; das alte
# statische „1525" passte implizit nur zu n≈225625).
GROUP_THEMES_PER_SQRT = 1.0
# Fragment-demote backstop (no-structure floor): fragment↔parent cosine is a BAD fragment
# detector (measured, Markdown run: wrong demotes Blockzitate→Codeblöcke 0.353 and
# Zeichenkodierung→Überschriften 0.640 sit ABOVE any usable floor, while true NP proof-gadget
# demotes αu-Variablen→Cook/Levin 0.172 sit low). So this only vetoes judge/panel demotes with
# NO containment match whose pair is literally structureless (Emoji→Tabelle 0.136).
@@ -135,10 +129,15 @@ CONSENSUS_GRACE = 300
# check loops leave any remaining objections standing after that.
CONSENSUS_MAX_ROUNDS = 3
# QA gate: after the inventory phase an automatic QA run scores the blocks; below the
# threshold the flow PAUSES before board 2 burns tokens (frontend offers force-continue).
QA_GATE_NOTE = 9.5 # 0 = gate off; quota-based, so the tolerated finding count scales with topic size
# QA gate: after the inventory phase an automatic QA run scores the blocks; below 100 %
# the flow PAUSES before board 2 burns tokens (frontend offers force-continue).
# QA_GATE_NOTE ist nur noch der An/Aus-Schalter (> 0 = Gate aktiv) — das Gate selbst
# öffnet ausschließlich bei 100 % (auto_loop.VOLL); Repair/Recherche arbeiten dahin.
QA_GATE_NOTE = 9.5 # 0 = gate off
QA_GATE_LLM = True # include the LLM samples (Echtheit/Dubletten) in the gate run
# Lücken-Recherche im Auto-Loop: max. gezielte Nach-Recherche-Runden pro Lauf, bevor die
# Rest-Lücken dem Stichentscheid vorgelegt werden (Repair-Budget, keine Detektor-Konstante).
LUECKEN_RESEARCH_MAX = 2
# Inline evidence for judge agents: corpus excerpts go INTO the prompt instead of letting
# every judge re-search the source folder (measured: ~10 tool turns/judge, 82 % of the

View File

@@ -702,6 +702,22 @@ async def set_block_status(topic: str, title_norm: str, status: str, title: str
await _update("blocks", fields, {"topic": topic, "title_norm": title_norm})
async def rename_block_norm(topic: str, alt_norm: str, neu_norm: str, neu_titel: str) -> None:
"""Block-Norm vollständig um-keyen (Repair: `(n)`-Kollisionssuffix ablegen): blocks-
Spiegel PLUS alle angehängten Tabellen in einer Transaktion — anders als
set_block_status(neu_norm=…) auch mit vorhandenen Subblocks/Fragen/Artefakten sicher."""
async with _tx() as db:
for table in ("subblocks", "question_pattern", "sub_artefakte"):
await db.execute(
f"UPDATE {table} SET block_norm = ?, block = ?, updated_at = ? "
"WHERE topic = ? AND block_norm = ?",
(neu_norm, neu_titel, _now(), topic, alt_norm))
await db.execute(
"UPDATE blocks SET title_norm = ?, title = ?, updated_at = ? "
"WHERE topic = ? AND title_norm = ?",
(neu_norm, neu_titel, _now(), topic, alt_norm))
async def delete_blocks(topic: str) -> None:
async with _tx() as db:
await db.execute("DELETE FROM blocks WHERE topic = ?", (topic,))
@@ -945,6 +961,35 @@ async def list_runs(topic: str, limit: int = 10) -> list[dict]:
return out
async def latest_board_runs(topic: str) -> dict:
"""Jüngster Lauf MIT Daten je Ebene — Anzeige-Quelle der Board-Kopfzeilen im Frontend.
„Nur Artefakte"-/Guide-Läufe bekommen eine frische run_id und lassen die anderen Ebenen
leer; der jüngste Lauf allein zeigt dann nichts. Hier zählt je Ebene der letzte Lauf,
der sie wirklich enthielt. inventory/artefacts via meta.$.board (agents.agent_ebene);
guide via run_id-Suffix „-g"+4hex (guide_board.run_guide_board setzt den Marker,
Blocks-Suffixe sind reines Hex — enthalten nie „g").
{ebene: {run_id, aktiv, agents, tokens, start, ende} | None}"""
db = await get_db()
out: dict = {}
for b in ("inventory", "artefacts"):
cur = await db.execute(
"SELECT run_id FROM events WHERE topic = ? AND kind = 'agent' AND run_id != '' "
"AND json_extract(meta,'$.board') = ? ORDER BY ts DESC LIMIT 1", (topic, b))
row = await cur.fetchone()
out[b] = None if row is None else {
"run_id": row[0], "aktiv": _current_run.get(topic) == row[0],
**await events_run_summary(topic, row[0], board=b)}
cur = await db.execute(
"SELECT run_id FROM events WHERE topic = ? AND kind = 'agent' AND run_id != '' "
"AND run_id GLOB '*-g[0-9a-f][0-9a-f][0-9a-f][0-9a-f]' ORDER BY ts DESC LIMIT 1",
(topic,))
row = await cur.fetchone()
out["guide"] = None if row is None else {
"run_id": row[0], "aktiv": _current_run.get(topic) == row[0],
**await events_run_summary(topic, row[0])} # Guide: ganzer Lauf (Events sind untagged)
return out
async def kanban_dead(topic: str) -> list[dict]:
"""Dead-letter cards across boards (for the board UI + requeue)."""
return await kanban_cards(topic, stage="dead")

View File

@@ -21,7 +21,6 @@ _PATH_RE = re.compile(r"(/\S+\.(?:json|md))")
_NUM_RE = re.compile(r"^\s*(\d+)[.)]\s+(.*\S)", re.MULTILINE)
_SUBLIST_RE = re.compile(r"^- (?:\[(\w+)\] )?(.+\S)\s*$", re.MULTILINE)
_ZIEL_RE = re.compile(r"\(([a-z]\d+)\)")
_PAIR_RE = re.compile(r"^(\d+)\.\s*\nA: \[Block: (.*?)\] (.*?)\n", re.MULTILINE)
def _norm(s: str) -> str:
@@ -30,7 +29,7 @@ def _norm(s: str) -> str:
class Welt:
"""Deterministisches Themen-Modell. bloecke: {titel: {"beschreibung": str,
"subs": [titel]}}; optionale Regeln steuern Konsolidierung/Cross-Block."""
"subs": [titel]}}; optionale Regeln steuern das Störfall-Verhalten."""
def __init__(self, bloecke: dict | None = None, *, gruppen: list | None = None,
kataloge: list | None = None, stoerungen: list | None = None):
@@ -149,8 +148,8 @@ class Welt:
if "-supplement-beleg" in key or "-anker-beleg-" in key:
nums = {m.group(1) for m in _NUM_RE.finditer(prompt)}
return j({"relevant": {k: "ja" for k in sorted(nums, key=int)} or {"1": "ja"}})
if "-supplement" in key:
return j({"blocks": []})
if "-supplement" in key or "-luecken-nachfass-" in key:
return j({"blocks": []}) # Fake-Welt ist vollständig — Lücken-Research findet nichts
if "-source-relevance-" in key:
nums = {m.group(1) for m in _NUM_RE.finditer(prompt)}
return j({"relevant": {k: "ja" for k in sorted(nums, key=int)} or {"1": "ja"}})
@@ -197,11 +196,6 @@ class Welt:
"uebernehmen": unsicher, "facts_probleme": [], "levels": {}, "relevanz": {}})
if "-sb-fix-" in key:
return j({"subs": []})
if "-sub-crossblock-" in key:
urteile = {}
for m in _PAIR_RE.finditer(prompt):
urteile[m.group(1)] = "a" # identischer Text (nur so wird gepaart) → A behält
return j({"pairs": urteile or {"1": "nein"}})
if "-art-gen-" in key:
subs = self._subs_im_prompt(prompt)
t = (self._bloecke_im_prompt(prompt) or ["?"])[0]
@@ -308,7 +302,6 @@ def aktivieren(welt: Welt, setattr_fn=setattr) -> None:
import agents
import blocks
import board_artefacts as ba
import board_inventory as bi
import guide
import guide_board
@@ -360,7 +353,7 @@ def aktivieren(welt: Welt, setattr_fn=setattr) -> None:
_find = staticmethod(_real_emb._find)
_union = staticmethod(_real_emb._union)
for mod in (blocks, ba, qa, bi):
for mod in (blocks, qa, bi):
setattr_fn(mod, "embedding", _FakeEmb)
# _emb_ok bleibt echt (True über _FakeEmb): Bottom-up braucht den Grouping-Stage.
# _FakeEmb bildet nur bei identischem Text Nachbarn — verschiedene Atome erzeugen keine

View File

@@ -643,6 +643,8 @@ async def run_guide_board(guide_id: str, topic: str, format_name: str, entries:
is_cancelled=lambda: is_guide_cancelled(guide_id), guide_id=guide_id)
import uuid
from datetime import datetime, timezone
# „-g"+hex4-Suffix markiert Guide-Läufe (Blocks-Suffixe sind reines Hex) —
# database.latest_board_runs erkennt Guide-run_ids an genau diesem Muster.
db.set_current_run(topic, f"{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M')}-g{uuid.uuid4().hex[:4]}")
try:
spec = (TEMPLATES_DIR / "Format" / "Section.md").read_text(encoding="utf-8")
@@ -751,33 +753,58 @@ async def board_snapshot(topic: str, format_name: str, limit: int = 20) -> dict:
async def repair_karten(topic: str, format_name: str) -> list[str]:
"""QA-Befund-getriebenes Guide-Repair: Karten, die im jüngsten Guide-QA-Report
Befunde tragen, gehen zurück auf `pruefer` (md bleibt) — Prüfer+Fix beheben gezielt,
generate_guide resumt die offenen Karten und misst am Ende neu. Pendant zum
Blocks-Repair („Score unter 10 muss einen Fix-Pfad haben"). → betroffene Blocktitel."""
"""QA-Befund-getriebenes Guide-Repair (Pendant zum Blocks-Repair: „Score unter 10
muss einen Fix-Pfad haben"). Kritische Befunde (marker_fehlend/ziel_ohne_anker/
fachlich_falsch) → zurück auf `pruefer` (brauchen Facts-/Ziele-Kontext + Re-Prüfer).
Reine Stil-Befunde (redundanz/laengen/lesbarkeit) → direkt auf `fix` mit expliziten
Auftragszeilen: der Prüfer sieht pro Karte nur die EIGENE Section und kann
Cross-Karten-Redundanz nie wiederfinden — der Fix bekommt den Kontext hier hinein
(kein KRITISCH-Präfix → genau EIN Rewrite ohne Re-Prüfer). generate_guide resumt
die offenen Karten und misst am Ende neu. → betroffene Blocktitel."""
import qa as qa_mod
from guide_qa import block_budget, LAENGE_BAND
reports = qa_mod.report_paths(topic, guide=True)
rep = _json_file(reports[-1]) if reports else None
if not rep:
return []
cards = {c["block_norm"]: c for c in await db.list_guide_cards(topic, format_name)}
norms: set[str] = set()
kritisch: set[str] = set()
for e in rep.get("marker_fehlend", []): # "Block · sub"
norms.add(_norm_title(str(e).split(" · ")[0]))
kritisch.add(_norm_title(str(e).split(" · ")[0]))
for e in rep.get("ziel_ohne_anker", []): # "block_norm · (id) text"
norms.add(str(e).split(" · ")[0])
for e in rep.get("laengen_ausreisser", []): # {"block": titel}
norms.add(_norm_title(e.get("block", "") if isinstance(e, dict) else str(e)))
for e in rep.get("lesbarkeit", []): # "Block: hinweis"
norms.add(_norm_title(str(e).split(":")[0]))
kritisch.add(str(e).split(" · ")[0])
for t in rep.get("fachlich_falsch", []) or []:
norms.add(_norm_title(str(t)))
kritisch.add(_norm_title(str(t)))
stil: dict[str, list[str]] = {}
for e in rep.get("redundanz", []): # {"a": "Block: absatz", "b": …}
for seite in ("a", "b"):
norms.add(_norm_title(str(e.get(seite, "")).split(":")[0]))
a, b = str(e.get("a", "")), str(e.get("b", ""))
bn = _norm_title(b.split(":")[0])
auszug = b.split(":", 1)[1].strip() if ":" in b else b
stil.setdefault(bn, []).append(
f"- BALLAST (kürzen): Absatz doppelt zu Block «{a.split(':')[0]}» („{auszug}\") — "
"hier straffen, der Inhalt bleibt im anderen Block")
for e in rep.get("laengen_ausreisser", []): # {"block", "zeichen", "budget"}
if not isinstance(e, dict):
continue
bn = _norm_title(e.get("block", ""))
budget = e.get("budget") or block_budget([])
lo, hi = LAENGE_BAND
stil.setdefault(bn, []).append(
f"- LÄNGE: {e.get('zeichen')} Zeichen (Budget {budget}, erlaubt "
f"{round(lo * budget)}{round(hi * budget)}): schreibe den ausführlich-Teil auf etwa "
f"{budget} Zeichen GESAMT um — Sockel-Prosa und Wiederholungen streichen, "
"alle Sub-Marker und Beispiele behalten")
for e in rep.get("lesbarkeit", []): # "Block: hinweis"
bn = _norm_title(str(e).split(":")[0])
hint = str(e).split(":", 1)[1].strip() if ":" in str(e) else str(e)
stil.setdefault(bn, []).append(f"- LESBARKEIT: {hint}")
betroffen = []
for n in sorted(norms & set(cards)):
await db.set_guide_card(topic, format_name, n, stage="pruefer", status="open", gate_info="")
for n in sorted((kritisch | set(stil)) & set(cards)):
if n in kritisch:
await db.set_guide_card(topic, format_name, n, stage="pruefer", status="open", gate_info="")
else:
await db.set_guide_card(topic, format_name, n, stage="fix", status="open",
gate_info="\n".join(stil[n]))
betroffen.append(cards[n]["block"])
return betroffen

View File

@@ -11,6 +11,7 @@ previous report of the same topic.
"""
import asyncio
import hashlib
import logging
import re
import sys
@@ -277,8 +278,10 @@ def hygiene(blocks: list[dict]) -> list[dict]:
def _zaehlbare_luecken(lk: list[dict], llm: bool) -> list[dict]:
"""With --llm only non-refuted gaps count ('?' = unjudged stays, conservative) — refuted
ones dragged the note although the judge cleared them (aak: 5 of 8, weight 3.0)."""
return [x for x in lk if x.get("llm") != "nein"] if llm else lk
ones dragged the note although the judge cleared them (aak: 5 of 8, weight 3.0).
Persistierte Freisprüche (2:1 im Repair) zählen nie."""
offen = [x for x in lk if not x.get("freispruch")]
return [x for x in offen if x.get("llm") != "nein"] if llm else offen
def note(quoten: dict, gewichte: dict = NOTE_GEWICHTE) -> float:
@@ -335,11 +338,13 @@ def _qa_prompt(name: str, **kwargs) -> str:
async def judge_wave(template: str, topic: str, key: str, slot: str, items: list[str],
*, chunk: int = JUDGE_CHUNK, prefix: str = "qa", label: str = "QA") -> dict[int, str]:
*, chunk: int = JUDGE_CHUNK, prefix: str = "qa", label: str = "QA",
extra: str = "") -> dict[int, str]:
"""Gechunkte Ja/Nein-Judge-Welle über ALLE Items, Chunks parallel (die Semaphoren in
agents.py begrenzen); Ergebnis mit globalen 1-basierten Indizes. Fail-open pro Chunk
(Items bleiben ohne Urteil), aber nie stumm. Ersetzt die drei strukturgleichen
Handkopien in repair/qa/guide_qa."""
Handkopien in repair/qa/guide_qa. `extra` füllt den {extra}-Slot des Templates
(z. B. Inventar-Kontext für das Lücken-Urteil im Repair)."""
from agents import run_agent
from pipeline import _timeout, _yesno_schema
from jsonio import parse_json_text
@@ -349,7 +354,7 @@ async def judge_wave(template: str, topic: str, key: str, slot: str, items: list
listing = "\n\n".join(f"{k}. {it}" for k, it in enumerate(teil, 1))
try:
rc, out, _err = await run_agent(
f"{prefix}-{topic}-{key}-{lo}", _qa_prompt(template, topic=topic, extra="", **{slot: listing}),
f"{prefix}-{topic}-{key}-{lo}", _qa_prompt(template, topic=topic, extra=extra, **{slot: listing}),
_timeout("qa_judge"), role="judge", capabilities="none", scope=topic, label=f"{label} {key}")
except Exception:
log.exception("[%s] %s-Judge %s+%d fehlgeschlagen — Items ohne Urteil", topic, label, key, lo)
@@ -406,6 +411,22 @@ def _paar_key(a: str, b: str) -> str:
return "||".join(sorted((_norm_title(a), _norm_title(b))))
def luecken_key(item: dict) -> str:
"""Stabiler Freispruch-Schlüssel einer Lücken-Fundstelle: Datei + Vorschau-Hash.
Abschnittsnummern verschieben sich bei Korpusänderung — dann veralten die Keys
und werden inert (gewollt: neue Fundstellen brauchen ein neues Urteil)."""
v = " ".join(str(item.get("vorschau", "")).casefold().split())
return f"{item.get('datei', '')}||{hashlib.sha1(v.encode()).hexdigest()[:16]}"
def offene_luecken(report: dict) -> tuple[list[dict], list[str]]:
"""Zählbare offene Lücken eines Reports: (luecken-Items ohne Freispruch/LLM-nein,
konzept_luecken-Namen). Grundlage für Lücken-Research und Lücken-Urteil im Repair."""
lk = [x for x in report.get("luecken", [])
if not x.get("freispruch") and x.get("llm") != "nein"]
return lk, list(report.get("konzept_luecken", []))
def lade_freispruch(topic: str) -> dict[str, list[str]]:
"""Persistierte 2:1-Freisprüche des Repair-Stichentscheids (repair._mit_stichentscheid):
mehrheitlich als „behalten" geurteilte Befunde zählen nicht mehr in die Note — sonst
@@ -445,16 +466,31 @@ async def qa_report(topic: str, llm: bool = False) -> dict | None:
frei_fremd = set(frei.get("fremd") or [])
fremd_frei = [t for t in fr if _norm_title(t) in frei_fremd]
fr = [t for t in fr if _norm_title(t) not in frei_fremd]
# Persistierte 2:1-Freisprüche: markiert bleibt sichtbar (diffbar), zählt aber nicht mehr
# in Quote und Judge-Welle — sonst pendelte der Verdachts-Floor ewig unter 10.
frei_dub = set(frei.get("dubletten") or [])
for p in d:
if _paar_key(p["a"], p["b"]) in frei_dub:
p["freispruch"] = True
d_offen = [p for p in d if not p.get("freispruch")]
frei_lk = set(frei.get("luecken") or [])
for x in lk:
if luecken_key(x) in frei_lk:
x["freispruch"] = True
lk_offen = [x for x in lk if not x.get("freispruch")]
frei_kl = set(frei.get("konzept_luecken") or [])
kl_frei = [nm for nm in kl if _norm_title(nm) in frei_kl]
kl = [nm for nm in kl if _norm_title(nm) not in frei_kl]
if llm and d:
if llm and d_offen:
v = await judge_wave("QA-Dubletten", topic, "dubletten", "pairs",
[f"A: {p['a']}\nB: {p['b']}" for p in d[:LLM_SAMPLE]])
for k, p in enumerate(d[:LLM_SAMPLE], 1):
[f"A: {p['a']}\nB: {p['b']}" for p in d_offen[:LLM_SAMPLE]])
for k, p in enumerate(d_offen[:LLM_SAMPLE], 1):
p["llm"] = v.get(k, "?")
if llm and lk:
if llm and lk_offen:
v = await judge_wave("QA-Luecken", topic, "luecken", "sections",
[f"[{x['datei']} #{x['abschnitt']}] {x['vorschau']}" for x in lk[:LLM_SAMPLE]])
for k, x in enumerate(lk[:LLM_SAMPLE], 1):
[f"[{x['datei']} #{x['abschnitt']}] {x['vorschau']}" for x in lk_offen[:LLM_SAMPLE]])
for k, x in enumerate(lk_offen[:LLM_SAMPLE], 1):
x["llm"] = v.get(k, "?")
if llm and sd: # full coverage in chunks — a sampled quota would mislead the note
v = await judge_wave("QA-Sub-Dubletten", topic, "sub-dubletten", "pairs",
@@ -502,7 +538,7 @@ async def qa_report(topic: str, llm: bool = False) -> dict | None:
"topic": topic, "erstellt": datetime.now(timezone.utc).isoformat(),
"run_id": summary.get("run_id", ""), "bloecke": len(blocks),
"quoten": {
"dubletten_verdacht": round(len(d) / max(len(blocks), 1), 3),
"dubletten_verdacht": round(len(d_offen) / max(len(blocks), 1), 3),
"luecken": round(len(_zaehlbare_luecken(lk, llm)) / n_sections, 3),
"fremd": round(len(fr) / max(len(blocks), 1), 3),
**({"konzept_luecken": round(len(kl) / max(len(named), 1), 3)} if corpus else {}),
@@ -512,6 +548,7 @@ async def qa_report(topic: str, llm: bool = False) -> dict | None:
"quoten_artefakte": quoten_art,
**({"unecht": unecht} if unecht is not None else {}),
**({"fremd_freigesprochen": fremd_frei} if fremd_frei else {}),
**({"konzept_luecken_freigesprochen": kl_frei} if kl_frei else {}),
"dubletten": d, "sub_dubletten": sd, "luecken": lk, "konzept_luecken": kl,
"fremd": fr, "beleg": bl, "hygiene": hy,
"artefakte": art,

View File

@@ -2,16 +2,17 @@
Blindes Re-Filtern reproduziert die blinden Flecken der Pipeline (sie hat die Befunde ja
durchgelassen). Hier fließen die QA-BEFUNDE als Input in gezielte Aktionen: Hygiene
deterministisch, bestätigte Dubletten mergen (Zweitmeinung), Fremd/Unecht nur nach
Gegen-Judge entfernen (fail-open: Zweifel/Fehler → behalten). Lücken brauchen Recherche,
Verwaiste den nächsten Board-2-Lauf — beides wird nur ausgewiesen."""
deterministisch (+ Beschreibung/Suffix per Sanierung/Rename), bestätigte Dubletten mergen
(Zweitmeinung), Fremd/Unecht nur nach Gegen-Judge entfernen, Beleg-Nachfass je Sub,
Lücken per Stichentscheid klären (Freispruch oder bestätigt = braucht Recherche).
Fail-open überall: Zweifel/Judge-Fehler → behalten, nichts persistieren."""
import logging
import re
import database as db
import qa
from blocks import _blocks_files, _evidence_pack, source_folder
from blocks import _blocks_files, _evidence_pack, material_folder, source_folder
from config import EVIDENCE_PER_BLOCK
from fsutil import atomic_write_json
from jsonio import read_json_file as _json_file
@@ -20,9 +21,12 @@ from textkit import _norm_title, _title, clean_title, parse_facts
log = logging.getLogger("creator.repair")
async def repair_befunde(topic: str, ebene: str | None = None) -> dict:
"""ebene=None → alle Fixes; „inventory" → nur Inventar (Hygiene/Merges/Fremd);
„artefacts" → nur Artefakte (Sub-Merges/Waisen). Misst am Ende IMMER beide Noten neu."""
async def repair_befunde(topic: str, ebene: str | None = None, luecken_urteil: bool = True) -> dict:
"""ebene=None → alle Fixes; „inventory" → nur Inventar; „artefacts" → nur Artefakte.
luecken_urteil: offene Lücken/Konzept-Lücken dem Stichentscheid vorlegen (Freispruch
oder bestätigt) — der QA-Gate-Watch schaltet es aus, solange seine Lücken-Recherche
noch Runden hat. Misst am Ende IMMER beide Noten neu. `aktionen` zählt alles, was die
Runde getan hat (Fixes+Merges+Freisprüche) — der Auto-Loop stoppt erst bei 0."""
reports = qa.report_paths(topic)
report = _json_file(reports[-1]) if reports else None
if not report:
@@ -34,9 +38,13 @@ async def repair_befunde(topic: str, ebene: str | None = None) -> dict:
art = ebene in (None, "artefacts")
hygiene = await _fix_hygiene(topic, report, by_norm, files) if inv else []
merges = await _merge_dubletten(topic, report, by_norm, files) if inv else []
hygiene += (await _fix_beschreibungen(topic, report, by_norm)) if inv else []
hygiene += (await _fix_suffixe(topic, report, by_norm, files)) if inv else []
merges, frei_paare = (await _merge_dubletten(topic, report, by_norm, files)) if inv else ([], [])
sub_merges, frei_subs = (await _merge_sub_dubletten(topic, report, files)) if art else ([], [])
entfernt, frei_bloecke = (await _entferne_fremd_unecht(topic, report, by_norm, files)) if inv else ([], [])
frei_luecken = (await _pruefe_luecken(topic, report, by_norm)) if inv and luecken_urteil else []
beleg_fix = (await _belege_nachfassen(topic, report, files)) if art else []
aufgeraeumt = (await _raeume_waisen(topic)) if art else 0
# llm=True: gleiche Messlatte wie QA-Button/Abschluss-QA — der llm=False-Report
@@ -45,16 +53,22 @@ async def repair_befunde(topic: str, ebene: str | None = None) -> dict:
if neu:
await qa.write_report(neu)
na = neu.get("note_artefakte") if neu else None
freigesprochen = frei_paare + frei_subs + frei_bloecke + frei_luecken
lk_rest, kl_rest = qa.offene_luecken(neu or {})
return {"hygiene": hygiene, "merges": merges, "sub_merges": sub_merges, "entfernt": entfernt,
"aufgeraeumt": aufgeraeumt, "freigesprochen": frei_subs + frei_bloecke,
"braucht_research": len(report.get("luecken", [])),
"beleg_fix": beleg_fix, "aufgeraeumt": aufgeraeumt, "freigesprochen": freigesprochen,
"aktionen": (len(hygiene) + len(merges) + len(sub_merges) + len(entfernt)
+ len(beleg_fix) + aufgeraeumt + len(freigesprochen)),
"braucht_research": len(lk_rest) + len(kl_rest), # offene Lücken NACH dieser Runde
"note": float(neu["note"]) if neu else 10.0, # neu gemessene Inventar-Note
"note_artefakte": float(na) if na is not None else None} # None solange Board 2 leer
async def _judge(template: str, topic: str, key: str, slot: str, items: list[str]) -> dict[int, str]:
async def _judge(template: str, topic: str, key: str, slot: str, items: list[str],
extra: str = "") -> dict[int, str]:
"""No-Tool-Judge-Welle (fail-open: Fehler → leeres Verdikt = behalten)."""
return await qa.judge_wave(template, topic, key, slot, items, prefix="repair", label="Repair")
return await qa.judge_wave(template, topic, key, slot, items, prefix="repair", label="Repair",
extra=extra)
def _speichere_freispruch(topic: str, kategorie: str, keys: list[str]) -> None:
@@ -67,7 +81,7 @@ def _speichere_freispruch(topic: str, kategorie: str, keys: list[str]) -> None:
async def _mit_stichentscheid(template: str, topic: str, key: str, slot: str,
lines: list[str], befund: str, kategorie: str = "",
ids: list[str] | None = None) -> tuple[dict[int, str], list[str]]:
ids: list[str] | None = None, extra: str = "") -> tuple[dict[int, str], list[str]]:
"""Zweitmeinung + Stichentscheid: Der Repair-Judge kann den QA-Befund kippen — bei
Dissens (QA sagt Befund, Judge sagt behalten) entscheidet ein DRITTER Judge nur über
die strittigen Items, Mehrheit 2/3 (Muster Crossblock-Tiebreaker). Ohne ihn pendelte
@@ -76,11 +90,11 @@ async def _mit_stichentscheid(template: str, topic: str, key: str, slot: str,
Explizites 2:1-„behalten" wird als FREISPRUCH persistiert (kategorie+ids) — die QA
zählt das Item ab dann nicht mehr (qa.lade_freispruch). j3-AUSFALL persistiert nicht
(fail-open ist kein Urteil). → (verdicts, freigesprochene Zeilen)."""
v = await _judge(template, topic, key, slot, lines)
v = await _judge(template, topic, key, slot, lines, extra=extra)
strittig = [i for i in range(1, len(lines) + 1) if v.get(i) != befund]
frei: list[str] = []
if strittig:
v3 = await _judge(template, topic, f"{key}-st", slot, [lines[i - 1] for i in strittig])
v3 = await _judge(template, topic, f"{key}-st", slot, [lines[i - 1] for i in strittig], extra=extra)
gegen = "nein" if befund == "ja" else "ja"
frei_keys: list[str] = []
for pos, i in enumerate(strittig, 1):
@@ -95,9 +109,165 @@ async def _mit_stichentscheid(template: str, topic: str, key: str, slot: str,
return v, frei
async def _fix_beschreibungen(topic: str, report: dict, by_norm: dict) -> list[str]:
"""Hygiene-Resttyp „leere-beschreibung": EINE Beschreibung aus Material-Auszügen
generieren (Blocks-Sanierung-Template; der Titel bleibt — Repair ist flow-los und
hat keinen Korpus-Anker-Kontext für Renames). Ohne Material/Auszüge bleibt der Befund."""
from agents import run_agent
from pipeline import _prompt, _timeout
from jsonio import parse_json_text
from board_inventory import _sanierung_schema
items = [h for h in report.get("hygiene", []) if "leere-beschreibung" in (h.get("probleme") or [])]
folder = source_folder(topic) or material_folder(topic)
if not items or not folder:
return []
out: list[str] = []
for h in items:
norm = _norm_title(h.get("titel", ""))
card = by_norm.get(norm)
if not card or (card["payload"].get("description") or "").strip():
continue
titel = card["payload"].get("title", "")
ev = _evidence_pack(folder, card["payload"].get("sources") or None, [titel], budget=4000)
if not ev:
continue
try:
rc, txt, _err = await run_agent(
f"repair-{topic}-sanierung-{norm[:24]}",
_prompt("Blocks-Sanierung", topic=topic, title=titel, description="(leer)", excerpts=ev),
_timeout("qa_judge"), role="judge", capabilities="none", scope=topic,
label="Repair Beschreibung")
except Exception:
log.exception("[%s] Beschreibungs-Sanierung fehlgeschlagen — Befund bleibt", topic)
continue
verdict = _sanierung_schema(parse_json_text(txt)) if rc == 0 else None
if not verdict or not verdict[1].strip():
continue
p = dict(card["payload"])
p["description"] = verdict[1].strip()
await db.kanban_set_payload(topic, "inventory", card["card_id"], p)
await db.set_block_status(topic, norm, "consensus", description=p["description"])
card["payload"] = p
out.append(f"beschrieben: {titel}")
return out
_SUFFIX_RE = re.compile(r"\s*\(\d+\)\s*$")
async def _fix_suffixe(topic: str, report: dict, by_norm: dict, files: dict) -> list[str]:
"""Hygiene-Resttyp „kollisions-suffix" („Titel (2)", entstanden beim Spiegeln):
Basis-Norm frei (Partner inzwischen gemerged/verworfen) → Rename auf den Basistitel
inkl. Re-Key aller angehängten Tabellen (db.rename_block_norm) + Board-2-Karte +
Sidecars. Basis besetzt → Judge entscheidet Dublette (Merge); sonst bleibt der
Befund sichtbar (zwei echte gleichnamige Konzepte sind ein Naming-Problem)."""
items = [h for h in report.get("hygiene", []) if "kollisions-suffix" in (h.get("probleme") or [])]
out: list[str] = []
for h in items:
alt = h.get("titel", "")
basis = _SUFFIX_RE.sub("", alt).strip()
alt_norm, basis_norm = _norm_title(alt), _norm_title(basis)
card = by_norm.get(alt_norm)
if not card or not basis or basis_norm == alt_norm:
continue
if basis_norm in by_norm: # Basis lebt noch → Dubletten-Frage statt Rename
merged, _frei = await _merge_dubletten(
topic, {"dubletten": [{"a": basis, "b": alt, "llm": "?"}]}, by_norm, files)
out += [f"suffix-merge: {z}" for z in merged]
continue
await db.rename_block_norm(topic, alt_norm, basis_norm, basis)
b2 = await db.kanban_get_card(topic, "artefacts", alt_norm)
if b2:
p2 = dict(b2["payload"])
p2["title"] = basis
await db.kanban_upsert_card(topic, "artefacts", basis_norm, b2["kind"], b2["stage"], p2)
await db.kanban_delete_card(topic, "artefacts", alt_norm)
p = dict(card["payload"])
p["title"] = basis
if p.get("mirrored_norm"):
p["mirrored_norm"] = basis_norm
await db.kanban_set_payload(topic, "inventory", card["card_id"], p)
card["payload"] = p
_rename_in_files(files, alt_norm, basis)
by_norm.pop(alt_norm, None)
by_norm[basis_norm] = card
out.append(f"suffix: {alt}{basis}")
return out
async def _pruefe_luecken(topic: str, report: dict, by_norm: dict) -> list[str]:
"""Lücken-Urteil (2:1): offene luecken-Fundstellen und konzept_luecken-Namen dem
Stichentscheid vorlegen — 2:1-„keine echte Lücke" wird als Freispruch persistiert,
bestätigte bleiben als braucht_research sichtbar (Gate-Watch recherchiert, der Nutzer
klickt „+ Recherche"). Das Inventar geht als Kontext in den {extra}-Slot, damit die
Judges Abdeckung gegen den Bestand prüfen können."""
lk, kl = qa.offene_luecken(report)
if not lk and not kl:
return []
extra = ("\n\nEXISTING INVENTORY BLOCKS (judge coverage against these):\n"
+ "\n".join(f"- {c['payload'].get('title', '')}" for c in by_norm.values()))
frei: list[str] = []
if lk:
_v, f = await _mit_stichentscheid(
"QA-Luecken", topic, "luecken", "sections",
[f"[{x.get('datei')} #{x.get('abschnitt')}] {x.get('vorschau', '')}" for x in lk],
"ja", kategorie="luecken", ids=[qa.luecken_key(x) for x in lk], extra=extra)
frei += f
if kl:
_v, f = await _mit_stichentscheid(
"QA-Konzept-Luecken", topic, "konzept-luecken", "results", list(kl),
"ja", kategorie="konzept_luecken", ids=[_norm_title(n) for n in kl], extra=extra)
frei += f
return frei
async def _belege_nachfassen(topic: str, report: dict, files: dict) -> list[str]:
"""subs_ohne_beleg (mentions=0): Beleg-Nachfass je Sub mit Material-Auszügen.
Judge+QA einig „nein" (kein Beleg) → Sub verwerfen (Artefakte/Fragen räumt das
nachlaufende _raeume_waisen); 2:1-„ja" → mentions=1 nachtragen — Datenfix am
gemessenen Datum selbst, kein Freispruch nötig, Detektor unverändert. Produktiv ist
der Detektor derzeit inaktiv (alle Schreiber setzen mentions=1) — Pfad ist Robustheit.
Ohne Quellordner kein Urteil möglich → Befund bleibt."""
eintraege = (report.get("beleg") or {}).get("subs_ohne_beleg") or []
folder = source_folder(topic) or material_folder(topic)
if not eintraege or not folder:
return []
rows = {f"{r['block']} · {r['sub_title']}": r for r in await db.list_subblocks(topic)
if r["status"] != "variant"}
items = [(e, rows[e]) for e in eintraege if e in rows and not rows[e]["mentions"]]
if not items:
return []
lines = []
for e, r in items:
ev = _evidence_pack(folder, None, [r["block"], r["sub_title"]], budget=EVIDENCE_PER_BLOCK)
lines.append(f"{e}\n{ev or '(keine Treffer im Material)'}")
v1 = await _judge("QA-Repair-Beleg", topic, "beleg", "blocks", lines)
strittig = [i for i in range(1, len(items) + 1) if v1.get(i) == "ja"]
v3 = (await _judge("QA-Repair-Beleg", topic, "beleg-st", "blocks",
[lines[i - 1] for i in strittig])) if strittig else {}
out: list[str] = []
async def _verwerfen(e: str, r: dict):
await db.set_subblock_fields(topic, r["block_norm"], r["sub_norm"], status="discarded")
_entferne_sub_in_files(files, r["block_norm"], r["sub_norm"])
out.append(f"entfernt: {e[:60]}")
for i, (e, r) in enumerate(items, 1):
if v1.get(i) == "nein": # QA (mentions=0) + Judge einig → weg
await _verwerfen(e, r)
elif v1.get(i) == "ja": # Dissens → Stichentscheid
pos = strittig.index(i) + 1
if v3.get(pos) == "ja":
await db.set_subblock_fields(topic, r["block_norm"], r["sub_norm"], mentions=1)
out.append(f"belegt: {e[:60]}")
elif v3.get(pos) == "nein":
await _verwerfen(e, r)
return out
async def _fix_hygiene(topic: str, report: dict, by_norm: dict, files: dict) -> list[str]:
"""Nur der norm-invariante Teil (`**`/Backticks); `(n)`-Suffix und leere Beschreibung
ändern die Norm bzw. brauchen Inhalt — bleiben Befund."""
ändern die Norm bzw. brauchen Inhalt — eigene Handler (_fix_suffixe/_fix_beschreibungen)."""
fixed = []
for h in report.get("hygiene", []):
alt = h.get("titel", "")
@@ -117,33 +287,68 @@ async def _fix_hygiene(topic: str, report: dict, by_norm: dict, files: dict) ->
return fixed
async def _merge_dubletten(topic: str, report: dict, by_norm: dict, files: dict) -> list[str]:
"""Nur QA-bestätigte Paare (llm=ja); eine Zweitmeinung, Merge nur bei erneut ja.
Merge spiegelt die dedup-Stage: Union ins Gewinner-Payload, Verlierer → grouped."""
paare = [p for p in report.get("dubletten", []) if p.get("llm") == "ja"
and _norm_title(p.get("a", "")) in by_norm and _norm_title(p.get("b", "")) in by_norm]
if not paare:
return []
v, _frei = await _mit_stichentscheid("QA-Dubletten", topic, "dubletten", "pairs",
[f"A: {p['a']}\nB: {p['b']}" for p in paare], "ja")
merged = []
for i, p in enumerate(paare, 1):
a, b = by_norm.get(_norm_title(p["a"])), by_norm.get(_norm_title(p["b"]))
if v.get(i) != "ja" or not a or not b or a["card_id"] == b["card_id"]:
continue
win, lose = sorted((a, b), key=lambda c: (len(c["payload"].get("description") or ""),
len(c["payload"].get("title") or "")), reverse=True)
wp, lp = dict(win["payload"]), dict(lose["payload"])
wp["readers"] = sorted(set(wp.get("readers") or []) | set(lp.get("readers") or []))
wp["sources"] = sorted(set(wp.get("sources") or []) | set(lp.get("sources") or []))
lp.update(reason="merged", merged_into=wp.get("title", ""))
await db.kanban_set_payload(topic, "inventory", win["card_id"], wp)
await db.kanban_set_payload(topic, "inventory", lose["card_id"], lp)
await db.kanban_advance(topic, "inventory", lose["card_id"], "grouped")
await _purge_block(topic, lp.get("title", ""), files)
by_norm.pop(_norm_title(lp.get("title", "")), None)
merged.append(f"{lp.get('title')}{wp.get('title')}")
return merged
async def _merge_paar(topic: str, p: dict, by_norm: dict, files: dict) -> str | None:
"""Ein bestätigtes Dubletten-Paar mergen (spiegelt die dedup-Stage: Union ins
Gewinner-Payload, Verlierer → grouped). → Journalzeile oder None."""
a, b = by_norm.get(_norm_title(p["a"])), by_norm.get(_norm_title(p["b"]))
if not a or not b or a["card_id"] == b["card_id"]:
return None
win, lose = sorted((a, b), key=lambda c: (len(c["payload"].get("description") or ""),
len(c["payload"].get("title") or "")), reverse=True)
wp, lp = dict(win["payload"]), dict(lose["payload"])
wp["readers"] = sorted(set(wp.get("readers") or []) | set(lp.get("readers") or []))
wp["sources"] = sorted(set(wp.get("sources") or []) | set(lp.get("sources") or []))
lp.update(reason="merged", merged_into=wp.get("title", ""))
await db.kanban_set_payload(topic, "inventory", win["card_id"], wp)
await db.kanban_set_payload(topic, "inventory", lose["card_id"], lp)
await db.kanban_advance(topic, "inventory", lose["card_id"], "grouped")
await _purge_block(topic, lp.get("title", ""), files)
by_norm.pop(_norm_title(lp.get("title", "")), None)
return f"{lp.get('title')}{wp.get('title')}"
async def _merge_dubletten(topic: str, report: dict, by_norm: dict, files: dict) -> tuple[list[str], list[str]]:
"""QA-bestätigte Paare (llm=ja): Zweitmeinung, Merge nur bei erneut ja; 2:1-„behalten"
persistiert jetzt als Freispruch (fehlte — die Paare pendelten ewig im Verdacht).
Widerlegte/unbeurteilte Paare (llm=nein/?) bekommen den Klärungskanal: Judge-„nein"
Freispruch (mit QA-nein 2:0; beim bloßen Verdacht reicht das klare Gegen-Urteil),
Judge-„ja" → Stichentscheid (ja → Merge 2:1, nein → Freispruch 2:1, Ausfall → nichts)."""
alle = [p for p in report.get("dubletten", []) if not p.get("freispruch")
and _norm_title(p.get("a", "")) in by_norm and _norm_title(p.get("b", "")) in by_norm]
ja = [p for p in alle if p.get("llm") == "ja"]
offen = [p for p in alle if p.get("llm") != "ja"]
merged: list[str] = []
frei: list[str] = []
if ja:
v, f = await _mit_stichentscheid("QA-Dubletten", topic, "dubletten", "pairs",
[f"A: {p['a']}\nB: {p['b']}" for p in ja], "ja",
kategorie="dubletten",
ids=[qa._paar_key(p["a"], p["b"]) for p in ja])
frei += f
for i, p in enumerate(ja, 1):
if v.get(i) == "ja" and (zeile := await _merge_paar(topic, p, by_norm, files)):
merged.append(zeile)
if offen:
ids = [qa._paar_key(p["a"], p["b"]) for p in offen]
v1 = await _judge("QA-Dubletten", topic, "dubletten-v", "pairs",
[f"A: {p['a']}\nB: {p['b']}" for p in offen])
frei_keys = [ids[i - 1] for i in range(1, len(offen) + 1) if v1.get(i) == "nein"]
frei += [f"{offen[i - 1]['a']} <-> {offen[i - 1]['b']}"[:80]
for i in range(1, len(offen) + 1) if v1.get(i) == "nein"]
strittig = [i for i in range(1, len(offen) + 1) if v1.get(i) == "ja"]
if strittig:
v3 = await _judge("QA-Dubletten", topic, "dubletten-v-st", "pairs",
[f"A: {offen[i - 1]['a']}\nB: {offen[i - 1]['b']}" for i in strittig])
for pos, i in enumerate(strittig, 1):
if v3.get(pos) == "ja" and (zeile := await _merge_paar(topic, offen[i - 1], by_norm, files)):
merged.append(zeile)
elif v3.get(pos) == "nein":
frei_keys.append(ids[i - 1])
frei.append(f"{offen[i - 1]['a']} <-> {offen[i - 1]['b']}"[:80])
if frei_keys:
_speichere_freispruch(topic, "dubletten", frei_keys)
log.info("[%s] Repair dubletten: %d Verdacht(e) freigesprochen", topic, len(frei_keys))
return merged, frei
_SUB_PAAR = re.compile(r"^\[(.+?)\] (.+)$", re.S)

View File

@@ -20,7 +20,7 @@ from database import (
delete_topic_pipeline, delete_source, get_guide_content, delete_guide_content,
get_sub_artefakte, kanban_reset, delete_guide_board,
get_practice_progress, upsert_practice_progress, sub_levels_norm, subs_per_level_norm,
list_runs, get_db,
list_runs, latest_board_runs, get_db,
)
from textkit import _norm_title
from blocks import generate_blocks, cancel_blocks, blocks_status, active_blocks, reset_blocks, load_source, load_overview, subblocks_title, subblocks_frei, load_question_pattern, load_question_pattern_free, _blocks_files
@@ -82,8 +82,10 @@ async def health():
@router.get("/runs")
async def get_runs(topic: str, limit: int = 10):
"""Lauf-Historie (Blocks + Guide): Zeitspanne, Agenten, Tokens, Fehler je run_id."""
return {"runs": await list_runs(topic, limit)}
"""Lauf-Historie (Blocks + Guide): Zeitspanne, Agenten, Tokens, Fehler je run_id.
`latest` = jüngster Lauf MIT Daten je Ebene (inventory/artefacts/guide) — die
Board-Kopfzeilen zeigen so auch nach „Nur Artefakte"-/Guide-Läufen ihre Zahlen."""
return {"runs": await list_runs(topic, limit), "latest": await latest_board_runs(topic)}
@router.get("/topics/progress")

View File

@@ -273,12 +273,12 @@ def test_meminfo_reads_proc():
def test_agent_ebene_klassifiziert_board():
"""Board-2-Marker (art-/sb-/crossblock/outline) → artefacts; alles andere → inventory."""
"""Board-2-Marker (art-/sb-/outline) → artefacts; alles andere → inventory."""
T = "shopware"
for key in (f"blocks-{T}-research-a1", f"blocks-{T}-pair-x", f"blocks-{T}-naming-y",
f"blocks-{T}-gruppierung-h-cTOP", f"blocks-{T}-dedup-z"):
assert agents.agent_ebene(key) == "inventory", key
for key in (f"blocks-{T}-alpha-sb-enrich-h", f"blocks-{T}-alpha-sb-verify-h-j1",
f"blocks-{T}-alpha-art-gen-h-t1", f"blocks-{T}-alpha-art-check-h",
f"blocks-{T}-sub-crossblock-h-j1", f"blocks-{T}-outline-judge"):
f"blocks-{T}-outline-judge"):
assert agents.agent_ebene(key) == "artefacts", key

View File

@@ -1,4 +1,4 @@
"""Auto-Repair-Loop-Primitive: Stopp bei 100 %, Stillstand, Limit."""
"""Auto-Repair-Loop-Primitive: Stopp bei 100 %, Stillstand (unbewegt), Limit."""
import auto_loop
@@ -14,7 +14,7 @@ async def test_verbessert_bis_voll():
noten = iter([9.0, 10.0])
async def rep():
return next(noten)
return next(noten), True
r = await auto_loop.auto_repair_loop("inv", 8.0, rep) # 8.0 → 9.0 → 10.0
assert r["grund"] == "fertig" and r["runden"] == 2
@@ -22,19 +22,31 @@ async def test_verbessert_bis_voll():
async def test_stillstand():
async def rep():
return 8.0 # Repair bewegt nichts
return 8.0, False # nichts getan, Note steht
r = await auto_loop.auto_repair_loop("art", 8.0, rep)
assert r["grund"] == "stillstand" and r["runden"] == 1
assert r["note"] == 8.0
async def test_bewegt_ohne_notengewinn_laeuft_weiter():
"""Eine bewegte Runde (z. B. Lücken-Research senkt die Note transient) ist KEIN
Stillstand — erst unbewegt + keine Verbesserung stoppt; max_iter begrenzt Livelocks."""
laeufe = iter([(7.5, True), (8.0, True), (8.0, False)])
async def rep():
return next(laeufe)
r = await auto_loop.auto_repair_loop("inv", 8.0, rep)
assert r["grund"] == "stillstand" and r["runden"] == 3
async def test_limit():
stand = {"n": 1.0}
async def rep():
stand["n"] += 0.1 # verbessert stetig, erreicht aber nie 10.0
return stand["n"]
return stand["n"], True
r = await auto_loop.auto_repair_loop("guide", 1.0, rep, max_iter=10)
assert r["grund"] == "limit" and r["runden"] == 10

View File

@@ -103,12 +103,6 @@ async def board_env(testdb, tmp_path, monkeypatch):
("_artefakte_block", fake_artefakte), ("_outline_block", fake_outline)]:
monkeypatch.setattr(ba, name, fn)
class _EmbOff: # Cross-Block-Barrier reicht ohne Modell alle Karten durch
@staticmethod
def available():
return False
monkeypatch.setattr(ba, "embedding", _EmbOff)
work = tmp_path / "arbeit"
work.mkdir()
files = {"arbeit": work, "final": tmp_path / "blocks.md",
@@ -973,8 +967,8 @@ async def test_qa_gate_auto_stillstand_pauses(board_env, monkeypatch):
async def bad_qa(topic, llm=False):
return {"note": 5.0, "topic": TOPIC, "quoten": {}, "fremd": [], "artefakte": {}}
async def stuck_repair(topic, ebene=None):
return {"note": 5.0} # bewegt nichts
async def stuck_repair(topic, ebene=None, luecken_urteil=True):
return {"note": 5.0, "aktionen": 0} # bewegt nichts
monkeypatch.setattr(qa_mod, "qa_report", bad_qa)
monkeypatch.setattr(qa_mod, "_write_report", lambda r: None)
@@ -988,6 +982,45 @@ async def test_qa_gate_auto_stillstand_pauses(board_env, monkeypatch):
assert await db.kanban_count(TOPIC, "done_artefact", board="artefacts") == 0
async def test_qa_gate_luecken_research_runden(board_env, monkeypatch):
"""Offene Lücken im Auto-Loop: erst LUECKEN_RESEARCH_MAX Recherche-Runden
(Lücken-Urteil ausgesetzt), dann das Urteil — erst danach Stillstand/Pause."""
import qa as qa_mod
import repair as repair_mod
db, ctx, files = board_env
gap_report = {"note": 8.0, "topic": TOPIC, "quoten": {}, "fremd": [], "artefakte": {},
"luecken": [{"datei": "f.txt", "abschnitt": 1, "vorschau": "x"}],
"konzept_luecken": []}
async def bad_qa(topic, llm=False):
return dict(gap_report)
research_calls = []
async def fake_producer(ctx2, flow, rep):
research_calls.append(1)
return 1 # Recherche hat Karten erzeugt → Runde gilt als bewegt
urteile = []
async def stuck_repair(topic, ebene=None, luecken_urteil=True):
urteile.append(luecken_urteil)
return {"note": 8.0, "aktionen": 0}
monkeypatch.setattr(qa_mod, "qa_report", bad_qa)
monkeypatch.setattr(qa_mod, "latest_report", lambda t, guide=False: dict(gap_report))
monkeypatch.setattr(qa_mod, "_write_report", lambda r: None)
monkeypatch.setattr(bi, "_luecken_producer", fake_producer)
monkeypatch.setattr(repair_mod, "repair_befunde", stuck_repair)
monkeypatch.setattr(bi, "_QA_GATE_POLL", 0.05)
await _seed(db)
ok = await _run_flow(ctx, files)
assert ok
assert len(research_calls) == 2 # LUECKEN_RESEARCH_MAX
assert urteile[:2] == [False, False] and urteile[2] is True
assert await db.kanban_count(TOPIC, "done_artefact", board="artefacts") == 0 # pausiert
async def test_qa_gate_auto_reaches_100(board_env, monkeypatch):
"""Auto an, Repair hebt die Note auf 100 % → Gate öffnet, Board 2 läuft durch."""
import qa as qa_mod
@@ -997,8 +1030,8 @@ async def test_qa_gate_auto_reaches_100(board_env, monkeypatch):
async def bad_qa(topic, llm=False):
return {"note": 8.0, "topic": TOPIC, "quoten": {}, "fremd": [], "artefakte": {}}
async def good_repair(topic, ebene=None):
return {"note": 10.0}
async def good_repair(topic, ebene=None, luecken_urteil=True):
return {"note": 10.0, "aktionen": 1}
monkeypatch.setattr(qa_mod, "qa_report", bad_qa)
monkeypatch.setattr(qa_mod, "_write_report", lambda r: None)
@@ -1019,7 +1052,7 @@ async def test_qa_gate_auto_off_pauses(board_env, monkeypatch):
async def good_qa(topic, llm=False):
return {"note": 10.0, "topic": TOPIC, "quoten": {}, "fremd": [], "artefakte": {}}
async def no_repair(topic, ebene=None):
async def no_repair(topic, ebene=None, luecken_urteil=True):
raise AssertionError("Auto aus darf nicht reparieren")
monkeypatch.setattr(qa_mod, "qa_report", good_qa)
@@ -1094,6 +1127,11 @@ def test_qa_view_pausiert_logic(tmp_path, monkeypatch):
counts = {"inventory": {"done_block": 3}, "artefacts": {"generate": 4}}
v = bi._qa_view(TOPIC, counts, None)
assert v["pausiert"] is True and v["note"] == 5.0 and v["befunde"] == ["X", "Y"]
# 9.6 lag über QA_GATE_NOTE, pausiert den Auto-Loop aber trotzdem (Gate öffnet nur bei VOLL)
(tmp_path / TOPIC / "r1.json").write_text(_json.dumps(
{"note": 9.6, "quoten": {}, "fremd": [], "unecht": []}), encoding="utf-8")
qa_mod._latest_cache.clear()
assert bi._qa_view(TOPIC, counts, None)["pausiert"] is True
from types import SimpleNamespace
laufend = SimpleNamespace(state={})
assert bi._qa_view(TOPIC, counts, laufend)["pausiert"] is False # Flow läuft noch
@@ -1515,3 +1553,27 @@ async def test_sanierung_anker_und_beschreibung_ok_kein_judge(testdb, tmp_path,
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
await bi._name_one(ctx, _mk_flow(tmp_path), {"card_id": "c4", "payload": payload})
assert (await db.kanban_get_card(TOPIC, B, "c4"))["stage"] == "naming_check"
def test_themen_zielband_skaliert_mit_atomzahl():
"""√n-Band: kleine Themen deutlich unter 15, große über dem alten 18er-Cluster-Cap."""
lo30, hi30 = bi._themen_zielband(30)
assert lo30 >= 2 and hi30 < 15
lo285, hi285 = bi._themen_zielband(285)
assert hi285 > 18
lo4, hi4 = bi._themen_zielband(4)
assert lo4 >= 2 and hi4 > lo4
# Monotonie: mehr Items → nie kleineres Band
prev = (0, 0)
for n in (4, 30, 100, 285, 1000):
band = bi._themen_zielband(n)
assert band >= prev
prev = band
def test_gruppierung_prompt_rendert_zielband(tmp_path):
"""Template↔Call-Site-Vertrag: alle Platzhalter versorgt, Band-String im Prompt."""
from pipeline import _prompt
p = _prompt("Blocks-Gruppierung", topic="t", candidates="x", list="1. a",
out_path=tmp_path / "g.json", theme_lo=4, theme_hi=8, n_items=9)
assert "~48 themes" in p and "(9 items)" in p

View File

@@ -1,7 +1,7 @@
"""E2E über die ECHTE Engine mit Fake-Agenten: kompletter Generierungspfad in Sekunden.
Anders als test_board_inventory (dort sind die Block-Funktionen gefakt) läuft hier alles
bis run_agent echt — _race, Quorum, Panels, Konsolidierung, Cross-Block, QA-Gate.
bis run_agent echt — _race, Quorum, Panels, In-Block-Konsolidierung, QA-Gate.
"""
import asyncio

View File

@@ -301,6 +301,33 @@ async def test_events_run_summary_per_board(testdb):
assert (await db.events_run_summary(TOPIC, "r2"))["agents"]["gesamt"] == 2 # ohne board = alle
async def test_latest_board_runs_je_ebene(testdb):
"""Je Ebene der jüngste Lauf MIT Daten: ein „Nur Artefakte"-Lauf (r2) lässt die
Inventar-Anzeige auf r1 stehen; Guide-Läufe (Suffix -g+hex4) haben eine eigene Zeile."""
db = testdb
assert await db.latest_board_runs(TOPIC) == {"inventory": None, "artefacts": None, "guide": None}
db.set_current_run(TOPIC, "20260708-1000-aaaa") # Voll-Lauf: beide Ebenen
await db.add_event(TOPIC, "agent", key="i1", status="ok",
meta={"board": "inventory", "tokens": {"input": 10, "output": 1}})
await db.add_event(TOPIC, "agent", key="a1", status="ok", meta={"board": "artefacts"})
db.set_current_run(TOPIC, "20260708-1100-bbbb") # „Nur Artefakte"
await db.add_event(TOPIC, "agent", key="a2", status="ok",
meta={"board": "artefacts", "tokens": {"input": 5, "output": 7}})
db.set_current_run(TOPIC, "20260708-1200-gabc1") # Guide (Events untagged)
await db.add_event(TOPIC, "agent", key="g1", status="ok",
meta={"tokens": {"input": 3, "output": 4}})
latest = await db.latest_board_runs(TOPIC)
assert latest["inventory"]["run_id"] == "20260708-1000-aaaa"
assert latest["inventory"]["tokens"]["input"] == 10
assert latest["artefacts"]["run_id"] == "20260708-1100-bbbb"
assert latest["artefacts"]["tokens"]["output"] == 7
assert latest["guide"]["run_id"] == "20260708-1200-gabc1"
assert latest["guide"]["tokens"]["input"] == 3
# aktiv folgt der Registry: nur der Guide-Lauf ist noch der aktuelle
assert latest["guide"]["aktiv"] and not latest["inventory"]["aktiv"]
db.set_current_run(TOPIC, None)
async def test_topic_delete_entfernt_guides_und_kanban(testdb, tmp_path, monkeypatch):
"""DELETE /topics: guides/guide_cards/kanban_cards mitlöschen — GET /topics leitet
Topics aus guides ab, sonst taucht das gelöschte Topic sofort wieder auf."""
@@ -351,6 +378,7 @@ async def test_runs_endpoint_liefert_bilanz(testdb):
await db.add_event(TOPIC, "fail", key="inventory:b-1", status="dead", meta={"error": "kaputt"})
res = await routes.get_runs(TOPIC)
runs = res["runs"]
assert set(res["latest"]) == {"inventory", "artefacts", "guide"} # je Ebene letzter Lauf mit Daten
assert len(runs) == 1 and runs[0]["run_id"] == "r1" and runs[0]["aktiv"] is True
assert runs[0]["agents"]["gesamt"] == 2 and runs[0]["agents"]["timeout"] == 1
assert runs[0]["tokens"]["output"] == 20

View File

@@ -0,0 +1,80 @@
"""Finalize (board_artefacts._proc_finalize) und blocks-Helfer — 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 blocks
import board_artefacts as ba
from kanban import Flow
from pipeline import GenContext
TOPIC = "finalize"
def _ctx():
return GenContext(topic=TOPIC, provider="test", is_cancelled=lambda: False)
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")}
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"}

View File

@@ -427,6 +427,36 @@ async def test_repair_karten_setzt_befundkarten_auf_pruefer(testdb, tmp_path, mo
assert cards["gamma"]["stage"] == "done"
async def test_repair_karten_stil_befunde_direkt_auf_fix(testdb, tmp_path, monkeypatch):
"""Nur-Stil-Befunde (redundanz/laengen/lesbarkeit) → direkt auf `fix` mit
Auftragszeilen im gate_info (KEIN KRITISCH-Präfix → ein Rewrite ohne Re-Prüfer);
die Redundanz-Zeile trägt den Cross-Karten-Kontext, den der Prüfer nie sieht."""
import json as _json
import qa as qa_mod
db = testdb
monkeypatch.setattr(qa_mod, "QA_DIR", tmp_path / "qa")
for n in ("alpha", "beta", "gamma"):
await db.upsert_guide_card(TOPIC, FMT, n, n.title())
await db.set_guide_card(TOPIC, FMT, n, stage="done", status="ok", md="<!-- section: X -->\nText")
tdir = tmp_path / "qa" / TOPIC
tdir.mkdir(parents=True)
(tdir / "guide-20260705-000001.json").write_text(_json.dumps({
"marker_fehlend": [], "fachlich_falsch": ["Gamma"], "ziel_ohne_anker": [],
"redundanz": [{"a": "Alpha: gemeinsamer Absatztext", "b": "Beta: gemeinsamer Absatztext"}],
"laengen_ausreisser": [{"block": "Beta", "zeichen": 9000, "budget": 3000}],
"lesbarkeit": ["Alpha: Sätze kürzen"],
}), encoding="utf-8")
betroffen = await gb.repair_karten(TOPIC, FMT)
assert sorted(betroffen) == ["Alpha", "Beta", "Gamma"]
cards = {c["block_norm"]: c for c in await db.list_guide_cards(TOPIC, FMT)}
assert cards["gamma"]["stage"] == "pruefer" # kritisch → Prüfer
assert cards["alpha"]["stage"] == cards["beta"]["stage"] == "fix"
assert not cards["beta"]["gate_info"].startswith("KRITISCH")
assert "BALLAST" in cards["beta"]["gate_info"] and "Alpha" in cards["beta"]["gate_info"]
assert "LÄNGE" in cards["beta"]["gate_info"] and "3000" in cards["beta"]["gate_info"]
assert "LESBARKEIT: Sätze kürzen" in cards["alpha"]["gate_info"]
async def test_fix_failed_behaelt_befunde(testdb, tmp_path, monkeypatch):
"""Scheitert der Fix, dürfen die Prüfer-Befunde nicht stumm verschwinden — sie
bleiben im gate_info sichtbar (vorher wurde gate_info geleert)."""

View File

@@ -1,265 +0,0 @@
"""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

View File

@@ -26,6 +26,7 @@ def test_convert_writes_markdown_txt(tmp_path):
def test_cache_skips_fresh_txt(tmp_path):
_mini_pdf(tmp_path / "a.pdf")
(tmp_path / ".pdf-txt-norm").write_text(str(blx._PDF_NORM_VERSION), encoding="utf-8")
marker = tmp_path / "a.txt"
marker.write_text("MARKER", encoding="utf-8")
now = time.time() + 60
@@ -34,6 +35,39 @@ def test_cache_skips_fresh_txt(tmp_path):
assert marker.read_text(encoding="utf-8") == "MARKER" # nicht neu konvertiert
def test_norm_version_erzwingt_rekonvertierung(tmp_path, monkeypatch):
"""Fehlender/alter .pdf-txt-norm-Marker ignoriert den mtime-Cache einmalig."""
_mini_pdf(tmp_path / "a.pdf")
stale = tmp_path / "a.txt"
stale.write_text("ALT", encoding="utf-8")
now = time.time() + 60
os.utime(stale, (now, now))
monkeypatch.setattr(blx, "_pdf_markdown", lambda p: None)
monkeypatch.setattr(blx, "_pdf_plaintext", lambda p: "neu konvertiert")
blx._convert_pdfs(tmp_path)
assert stale.read_text(encoding="utf-8") == "neu konvertiert"
assert (tmp_path / ".pdf-txt-norm").read_text(encoding="utf-8") == str(blx._PDF_NORM_VERSION)
stale.write_text("BLEIBT", encoding="utf-8")
os.utime(stale, (now, now))
blx._convert_pdfs(tmp_path) # Marker aktuell → Cache greift wieder
assert stale.read_text(encoding="utf-8") == "BLEIBT"
def test_entzerre_pdf_woerter():
"""Small-Caps-Splits mergen; Variablen-Schutzfall und unbelegte Paare bleiben."""
# Regel A inkl. Kette
assert blx._entzerre_pdf_woerter("Das H ITTING S ET Problem") == "Das HITTING SET Problem"
assert blx._entzerre_pdf_woerter("V ERTEX C OVER ist schwer") == "VERTEX COVER ist schwer"
# Schutzfall: Großlauf klein fortgesetzt → L ist Variable
assert blx._entzerre_pdf_woerter("die Sprache L NP-vollständig ist") == "die Sprache L NP-vollständig ist"
# Regel B: Paar nur mit Frequenz-Beleg (≥3× ungespalten im Dokument)
belegt = "N P ist zentral. " + "NP NP NP."
assert blx._entzerre_pdf_woerter(belegt).startswith("NP ist zentral.")
assert blx._entzerre_pdf_woerter("C Y bleibt getrennt") == "C Y bleibt getrennt"
# kein Match mitten im Wort
assert blx._entzerre_pdf_woerter("HALTTM IST hier") == "HALTTM IST hier"
def test_fallback_to_pdftotext(tmp_path, monkeypatch):
_mini_pdf(tmp_path / "b.pdf")
monkeypatch.setattr(blx, "_pdf_markdown", lambda p: None)

View File

@@ -313,3 +313,23 @@ async def test_topic_delete_entfernt_qa_ordner(testdb, tmp_path, monkeypatch):
(qdir / "alt.json").write_text("{}", encoding="utf-8")
await routes.remove_topic("t")
assert not qdir.exists()
def test_luecken_key_stabil_gegen_whitespace():
"""Freispruch-Schlüssel: Datei + normalisierter Vorschau-Hash — Whitespace-Varianten
derselben Fundstelle mappen auf denselben Key, andere Vorschau nicht."""
a = {"datei": "f.txt", "abschnitt": 3, "vorschau": "Der Satz\nvon Foo"}
b = {"datei": "f.txt", "abschnitt": 7, "vorschau": "der satz von foo"}
c = {"datei": "f.txt", "abschnitt": 3, "vorschau": "ganz anderer Text"}
assert qa.luecken_key(a) == qa.luecken_key(b)
assert qa.luecken_key(a) != qa.luecken_key(c)
assert qa.luecken_key(a).startswith("f.txt||")
def test_offene_luecken_filtert_freispruch_und_nein():
rep = {"luecken": [{"datei": "f", "vorschau": "a", "llm": "ja"},
{"datei": "f", "vorschau": "b", "llm": "nein"},
{"datei": "f", "vorschau": "c", "freispruch": True}],
"konzept_luecken": ["Satz von Foo"]}
lk, kl = qa.offene_luecken(rep)
assert [x["vorschau"] for x in lk] == ["a"] and kl == ["Satz von Foo"]

View File

@@ -298,3 +298,135 @@ async def test_waisen_cleanup(env, monkeypatch):
rest = {(r["sub_norm"], r["type"]) for r in await db.get_sub_artefakte(TOPIC)}
assert rest == {("sub0", "flashcard"), ("doppel", "example")}
assert not [r for r in await db.list_question_pattern(TOPIC)]
async def test_dubletten_verdacht_freispruch(env, monkeypatch):
"""Widerlegtes/unbeurteiltes Verdachtspaar bekommt den Klärungskanal: Judge sagt
behalten → Freispruch persistiert; der nächste qa_report zählt das Paar nicht mehr
(vorher zählte widerlegter Verdacht ewig in die Quote — Mess-Rauschen ohne Ausweg)."""
import qa as qa_mod
db, seed, files, write_report = env
await seed("Alpha", "beschr")
await seed("Alpha Kreis", "beschr")
write_report(_report(dubletten=[{"a": "Alpha", "b": "Alpha Kreis", "llm": "nein"}]))
async def fake_agent(key, prompt, timeout, **kw):
return 0, '{"relevant": {"1": "nein"}}', "" # Judge: keine Dublette
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
res = await repair.repair_befunde(TOPIC)
assert res["merges"] == [] and len(res["freigesprochen"]) == 1 and res["aktionen"] == 1
frei = qa_mod.lade_freispruch(TOPIC)
assert qa_mod._paar_key("Alpha", "Alpha Kreis") in set(frei.get("dubletten") or [])
async def test_dubletten_verdacht_stichentscheid_merged(env, monkeypatch):
"""Verdachtspaar (llm=nein), aber Judge+Stichentscheid sagen beide Dublette →
Merge mit 2:1 gegen das QA-Urteil."""
db, seed, files, write_report = env
await seed("Alpha", "kurz")
await seed("Alpha Kreis", "deutlich längere Beschreibung — Gewinner")
write_report(_report(dubletten=[{"a": "Alpha", "b": "Alpha Kreis", "llm": "nein"}]))
async def fake_agent(key, prompt, timeout, **kw):
return 0, '{"relevant": {"1": "ja"}}', ""
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
res = await repair.repair_befunde(TOPIC)
assert res["merges"] == ["Alpha → Alpha Kreis"] and res["freigesprochen"] == []
async def test_beleg_nachfass_discard_und_datenfix(env, monkeypatch):
"""mentions=0-Subs: Judge bestätigt „kein Beleg" → Sub verworfen; Dissens mit
Stichentscheid „belegt" → mentions=1 nachgetragen (Datenfix, kein Freispruch)."""
db, seed, files, write_report = env
await seed("Alpha", "beschr")
norm = repair._norm_title("Alpha")
await db.put_subblock(TOPIC, norm, "ohne beleg", "Alpha", "Ohne Beleg", status="consensus")
await db.put_subblock(TOPIC, norm, "doch belegt", "Alpha", "Doch Belegt", status="consensus")
await db.set_subblock_fields(TOPIC, norm, "ohne beleg", mentions=0)
await db.set_subblock_fields(TOPIC, norm, "doch belegt", mentions=0)
write_report(_report(beleg={"subs_ohne_beleg": ["Alpha · Ohne Beleg", "Alpha · Doch Belegt"]}))
monkeypatch.setattr(repair, "source_folder", lambda t: files["sidecar"].parent)
monkeypatch.setattr(repair, "_evidence_pack", lambda *a, **kw: "AUSZUG")
async def fake_agent(key, prompt, timeout, **kw):
if "-beleg-st-" in key:
return 0, '{"relevant": {"1": "ja"}}', "" # Stichentscheid: belegt
return 0, '{"relevant": {"1": "nein", "2": "ja"}}', ""
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
res = await repair.repair_befunde(TOPIC, "artefacts")
assert sorted(res["beleg_fix"]) == ["belegt: Alpha · Doch Belegt", "entfernt: Alpha · Ohne Beleg"]
rows = {r["sub_norm"]: r for r in await db.list_subblocks(TOPIC, norm)}
assert rows["ohne beleg"]["status"] == "discarded"
assert rows["doch belegt"]["status"] == "consensus" and rows["doch belegt"]["mentions"] == 1
async def test_fix_beschreibung_fuellt_leere(env, monkeypatch):
"""Hygiene „leere-beschreibung" → Sanierung schreibt EINE Beschreibung aus den
Auszügen; Titel bleibt unverändert."""
db, seed, files, write_report = env
cid = await seed("Leerling", "")
write_report(_report(hygiene=[{"titel": "Leerling", "probleme": ["leere-beschreibung"]}]))
monkeypatch.setattr(repair, "source_folder", lambda t: files["sidecar"].parent)
monkeypatch.setattr(repair, "_evidence_pack", lambda *a, **kw: "AUSZUG")
async def fake_agent(key, prompt, timeout, **kw):
assert "-sanierung-" in key
return 0, '{"title": "Leerling", "description": "Aus dem Material."}', ""
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
res = await repair.repair_befunde(TOPIC, "inventory")
assert "beschrieben: Leerling" in res["hygiene"]
card = await db.kanban_get_card(TOPIC, "inventory", cid)
assert card["payload"]["description"] == "Aus dem Material."
async def test_fix_suffix_rename_rekeyt_alles(env, monkeypatch):
"""Kollisions-Suffix, Basis-Norm frei → Rename inkl. Re-Key von Subblocks/Fragen/
Artefakten + Board-2-Karte; danach keine Invarianten-Waisen."""
from invarianten import pruefe_invarianten
db, seed, files, write_report = env
cid = await seed("Gamma (2)", "beschr")
norm_alt = repair._norm_title("Gamma (2)")
await db.upsert_question_pattern(TOPIC, norm_alt, "sub0", "Gamma (2)", "Sub 0", "F?")
write_report(_report(hygiene=[{"titel": "Gamma (2)", "probleme": ["kollisions-suffix"]}]))
async def no_agent(*a, **kw):
raise AssertionError("Rename bei freier Basis braucht keinen Agenten")
import agents; monkeypatch.setattr(agents, "run_agent", no_agent)
res = await repair.repair_befunde(TOPIC, "inventory")
assert "suffix: Gamma (2) → Gamma" in res["hygiene"]
card = await db.kanban_get_card(TOPIC, "inventory", cid)
assert card["payload"]["title"] == "Gamma" and card["payload"]["mirrored_norm"] == "gamma"
assert await db.kanban_get_card(TOPIC, "artefacts", norm_alt) is None
assert (await db.kanban_get_card(TOPIC, "artefacts", "gamma"))["payload"]["title"] == "Gamma"
assert {r["block_norm"] for r in await db.list_subblocks(TOPIC, "gamma")} == {"gamma"}
assert not await db.list_subblocks(TOPIC, norm_alt)
assert {r["block_norm"] for r in await db.list_question_pattern(TOPIC)} == {"gamma"}
befunde = await pruefe_invarianten(TOPIC) # DB-Sicht: keine Waisen nach dem Re-Key
assert not [b for b in befunde if "Waise" in b]
async def test_pruefe_luecken_freispruch(env, monkeypatch):
"""Offene Lücken + Konzept-Lücken: 2:1 „keine echte Lücke" → Freispruch persistiert;
_zaehlbare_luecken zählt freigesprochene Fundstellen nicht mehr."""
import qa as qa_mod
db, seed, files, write_report = env
await seed("Alpha", "beschr")
lk = [{"datei": "f.txt", "abschnitt": 1, "vorschau": "verwaister Abschnitt", "llm": "ja"}]
write_report(_report(luecken=lk, konzept_luecken=["Satz von Foo"]))
async def fake_agent(key, prompt, timeout, **kw):
return 0, '{"relevant": {"1": "nein"}}', "" # beide Judges: keine echte Lücke
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
res = await repair.repair_befunde(TOPIC, "inventory")
assert len(res["freigesprochen"]) == 2
frei = qa_mod.lade_freispruch(TOPIC)
assert qa_mod.luecken_key(lk[0]) in set(frei.get("luecken") or [])
assert "satz von foo" in {s.casefold() for s in frei.get("konzept_luecken") or []}
lk[0]["freispruch"] = True
assert qa_mod._zaehlbare_luecken(lk, llm=True) == []

View File

@@ -27,7 +27,7 @@ PARAMS: dict[str, dict] = {
"SEED_COVER_COS": {"default": 0.80, "min": 0.7, "max": 0.9, "step": 0.02, "kategorie": "auswahl", "fidelity": "board2"},
"SUB_DUP_KANDIDAT_COS": {"default": 0.75, "min": 0.65, "max": 0.85, "step": 0.02, "kategorie": "auswahl", "fidelity": "board2"},
"EMBEDDING_BLOCK_FLOOR": {"default": 0.5, "min": 0.35, "max": 0.65, "step": 0.05, "kategorie": "auswahl", "fidelity": "voll"},
"CROSS_CHUNK_PAARE": {"default": 40, "min": 15, "max": 80, "step": 10, "kategorie": "laufzeit", "fidelity": "board2"},
"GROUP_THEMES_PER_SQRT": {"default": 1.0, "min": 0.6, "max": 1.6, "step": 0.2, "kategorie": "auswahl", "fidelity": "voll"},
# Guide
"GATE_FIX_MIN": {"default": 3, "min": 1, "max": 6, "step": 1, "kategorie": "qualitaet", "fidelity": "board2"},
"WRITER_SPLIT_SUBS": {"default": 30, "min": 15, "max": 45, "step": 5, "kategorie": "qualitaet", "fidelity": "board2"},

View File

@@ -69,33 +69,36 @@ const pct = (note) => Math.round(note * 10) + ' %'
// ── Laufzeit + Tokens aus /api/runs (5s-Takt, unabhängig vom 1,2s-Board-Poll) ──────
const run = ref(null)
const latest = ref(null) // jüngster Lauf MIT Daten je Ebene — überlebt „Nur Artefakte"-Läufe
const now = ref(Date.now())
let clock = null
async function loadRun() {
try {
const { runs } = await fetchRuns(props.topic, 1)
run.value = runs[0] || null
} catch { run.value = null }
const res = await fetchRuns(props.topic, 1)
run.value = res.runs[0] || null
latest.value = res.latest || null
} catch { run.value = null; latest.value = null }
}
const { start: startRunPoll } = usePolling(loadRun, () => props.generating, 5000)
// Per-Ebene: Zeit + Tokens getrennt für Inventar vs. Artefakte (aus run.boards).
// Per-Ebene: Zeit + Tokens getrennt für Inventar vs. Artefakte. Live tickt nur die Ebene,
// deren Lauf der AKTUELLE ist (b.aktiv) — sonst zählt die alte Inventar-Zeile mit hoch.
function boardZeit(b) {
if (!b?.start) return null
const start = Date.parse(b.start)
const ende = run.value?.aktiv ? now.value : Date.parse(b.ende || b.start)
const ende = b.aktiv ? now.value : Date.parse(b.ende || b.start)
return fmtRuntime((ende - start) / 1000)
}
function boardTokens(b) {
const t = b?.tokens
return t && (t.input || t.output) ? fmtTokens((t.input || 0) + (t.output || 0)) : null
}
const invStat = computed(() => run.value?.boards?.inventory)
const artStat = computed(() => run.value?.boards?.artefacts)
const invStat = computed(() => latest.value?.inventory)
const artStat = computed(() => latest.value?.artefacts)
watch(() => props.generating, (g) => {
if (g) { startRunPoll(); if (!clock) clock = setInterval(() => { now.value = Date.now() }, 1000) }
else { loadRun(); if (clock) { clearInterval(clock); clock = null } } // Endstand
}, { immediate: true })
watch(() => props.topic, () => { run.value = null; loadRun() })
watch(() => props.topic, () => { run.value = null; latest.value = null; loadRun() })
onUnmounted(() => { if (clock) clearInterval(clock) })
const { isArmed, armOrRun, reset: resetConfirm } = useConfirm() // 2-Klick-Bestätigung
@@ -134,7 +137,7 @@ async function repairClick(ebene) {
const suffix = max > 1 ? ` — Runde ${i + 1}${note != null ? ` (${Math.round(note * 10)} %)` : ''}` : ''
repairInfo.value = { ...repairInfo.value, [ebene]: repairText(r) + suffix }
if (note == null || note >= 10) break // 100 % erreicht
if (prev != null && note <= prev) break // Stillstand: Repair bewegt nichts mehr
if (r.aktionen === 0 && prev != null && note <= prev) break // Stillstand: nichts getan, Note steht
prev = note
}
} catch (e) {

View File

@@ -41,14 +41,14 @@ const progressValue = computed(() => (total.value ? done.value / total.value : 0
// QA-Badge nur wenn es ein Board gibt — sonst zeigt ein verwaister Report eine alte Note.
const qaNote = computed(() => (total.value ? board.value?.qa_guide : null))
// Laufzeit + Tokens aus /api/runs (deckt auch Guide-Läufe ab — beide setzen run_id)
// Laufzeit + Tokens aus /api/runs — `latest.guide` ist der jüngste GUIDE-Lauf
// (der jüngste Lauf insgesamt kann ein Blocks-Lauf sein und zeigte hier dessen Zahlen).
const run = ref(null)
const now = ref(Date.now())
let clock = null
async function loadRun() {
try {
const { runs } = await fetchRuns(props.topic, 1)
run.value = runs[0] || null
run.value = (await fetchRuns(props.topic, 1)).latest?.guide || null
} catch { run.value = null }
}
const { start: startRunPoll } = usePolling(loadRun, () => generating.value, 5000)

View File

@@ -1,6 +1,6 @@
Topic "{topic}". A previous step produced a flat list of learning items that is TOO FINE-GRAINED for a table of contents — single environment variables, single CLI flags, single config fields, near-synonyms. Your job is **card sorting**: group the items into a small set of **theme blocks**, so that EVERY item lands in exactly one theme. Nothing is discarded — grouping never loses an item; it only organises them.
Think of the result as chapters: ~1525 themes for a whole topic, each a coherent learning block that a learner meets as one unit. A theme with only one fitting item is fine (a genuinely standalone concept), but prefer pulling related items together over leaving many singletons.
Think of the result as chapters: ~{theme_lo}{theme_hi} themes for a whole topic of this size ({n_items} items), each a coherent learning block that a learner meets as one unit. A theme with only one fitting item is fine (a genuinely standalone concept), but prefer pulling related items together over leaving many singletons.
CANDIDATES you may group (your starting point — a pre-clustered neighbourhood):
{candidates}

View File

@@ -0,0 +1,20 @@
Topic "{topic}". A quality audit found GAPS: source-material passages and named results that NO existing inventory block covers. Your job: propose new learning blocks ONLY for these gaps — nothing else. An empty list is a valid answer (when the gaps are filler or already covered under another name).
EXISTING BLOCKS (do NOT re-propose these or near-synonyms of them):
{blocks}
UNCOVERED MATERIAL (gap passages and named results, with source excerpts):
{gaps}
Rules:
- One block per genuinely missing, teachable concept — self-contained, exam-relevant.
- Ground every proposal in the material above; never invent canon knowledge the material does not treat.
- Title: concrete concept name (max 8 words, no catalog references like "(Satz 6.33)").
- Description: ONE sentence naming what the block teaches, grounded in the material.
- Skip organizational text, prefaces, exercise scaffolding, duplicates of existing blocks.
Write ONLY the JSON file to: {out_path}
Format (`blocks` may be empty):
{{"blocks": [{{"title": "…", "description": "…"}}]}}
{extra}

View File

@@ -1,13 +0,0 @@
Das Lernbaustein-Inventar zum Thema "{topic}" ist in Blöcke mit je eigenen Subbausteinen zerlegt. Manche Aussage taucht in ZWEI Blöcken auf — im Lernguide steht sie dann doppelt. Für jedes Paar unten: Treffen A und B DIESELBE Aussage?
PAARE (jeweils mit Block-Zugehörigkeit und Kernpunkten):
{pairs}
## Entscheidung pro Paar
- **DIESELBE Aussage** (auch anders formuliert, oder eine ist Teilmenge der anderen): Welcher Block ist die natürliche Heimat der Aussage? → antworte **"a"** (A behält sie, B verliert sie) oder **"b"** (B behält sie, A verliert sie). Heimat ist der Block, in dessen Kernthema die Aussage gehört — nicht der, der sie nur am Rand streift.
- **VERSCHIEDENE Aussagen** → antworte **"nein"**. Dazu zählt: gleiche Regel, aber auf VERSCHIEDENE Kontexte angewendet (die Anwendung im jeweiligen Block-Kontext ist eigener Lernstoff); Grundregel vs. Sonderfall; gegensätzliche Aussagen.
- Im Zweifel: **"nein"**.
Antworte NUR mit JSON, ohne weiteren Text (jede Paar-Nummer mit "a", "b" oder "nein"):
{{"pairs": {{"1": "a", "2": "nein"}}}}
{extra}

View File

@@ -0,0 +1,15 @@
You are an INDEPENDENT quality auditor for a learning-block inventory on the topic "{topic}". Below are NAMED results/concepts a scan found in the source material (named theorems, algorithms, definitions) that seem to map to NO inventory block. For each: is this a real, exam-relevant concept the inventory is missing → **ja**, or is it covered by an existing block under another name / a mangled-extraction artefact / mere organizational labeling → **nein**?
NAMED RESULTS WITHOUT A COVERING BLOCK:
{results}
Rules:
- Covered by an existing block under a synonym, symbol variant or broader umbrella → nein.
- Garbled extraction artefacts (split or shredded words) and pure numbering references → nein.
- A genuine named result the material teaches and no block captures → ja.
- When genuinely unsure → ja (a missed gap is worse than one extra research round).
Reply with ONLY the JSON — no other text, no code fences.
Format (one verdict per number):
{{"relevant": {{"1": "ja", "2": "nein"}}}}
{extra}