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. # 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", _ARTEFAKT_MARKER = ("-art-gen", "-art-check", "-sb-enrich", "-sb-verify", "-sb-fix",
"-sub-crossblock", "-outline") "-outline")
def agent_ebene(key: str) -> str: 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. """Loop QA→Repair für eine Ebene.
erst_note : float — bereits gemessene QA-Note vor dem ersten Repair (010). 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 reparieren() -> (float, bool): behebt die Befunde UND gibt die neu gemessene Note
(die Repair-Bausteine messen ohnehin am Ende — keine Doppelmessung). 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), Stopp bei: Note == VOLL (fertig), unbewegt ohne Verbesserung (stillstand),
oder max_iter Runden (limit). Returns {ebene, note, runden, grund} mit oder max_iter Runden (limit — begrenzt „bewegt ohne Notengewinn"-Livelocks).
grund ∈ {"fertig","stillstand","limit"} — der Aufrufer nutzt grund für die Returns {ebene, note, runden, grund} mit grund ∈ {"fertig","stillstand","limit"}.
Fortschritts-/Systemfehler-Meldung.
""" """
if erst_note >= VOLL: if erst_note >= VOLL:
return {"ebene": ebene, "note": erst_note, "runden": 0, "grund": "fertig"} return {"ebene": ebene, "note": erst_note, "runden": 0, "grund": "fertig"}
note = erst_note note = erst_note
for runde in range(1, max_iter + 1): for runde in range(1, max_iter + 1):
neu = await reparieren() neu, bewegt = await reparieren()
log.info("[%s] Auto-Loop Runde %d: %.1f%.1f", ebene, runde, note, neu) log.info("[%s] Auto-Loop Runde %d: %.1f%.1f (%s)",
ebene, runde, note, neu, "bewegt" if bewegt else "unbewegt")
if neu >= VOLL: if neu >= VOLL:
return {"ebene": ebene, "note": neu, "runden": runde, "grund": "fertig"} 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"} 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"} 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" 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: def _convert_pdfs(project: Path) -> None:
"""Convert PDFs in the project to .txt — agents read text instead of page images. """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 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 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 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")) pdfs = list(project.rglob("*.pdf"))
if not pdfs: if not pdfs:
return 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: for pdf in pdfs:
txt = pdf.with_suffix(".txt") 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 continue
picked = _pick_conversion(_pdf_markdown(pdf), _pdf_plaintext(pdf)) picked = _pick_conversion(_pdf_markdown(pdf), _pdf_plaintext(pdf))
if picked is None: if picked is None:
raise RuntimeError(f"PDF conversion failed ({pdf.name}): weder pymupdf4llm noch " raise RuntimeError(f"PDF conversion failed ({pdf.name}): weder pymupdf4llm noch "
"pdftotext verfügbar/erfolgreich (pip install pymupdf4llm oder poppler-utils)") "pdftotext verfügbar/erfolgreich (pip install pymupdf4llm oder poppler-utils)")
text, tool = picked 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}") _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"} _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 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) --- # --- Outline (blocks artifact: chapter structure, only read by the guide) ---
def _outline_review_schema(data, valid: set[int], n_chapters: int, n_blocks: int): 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(): async def _reparieren():
set_p("Befunde beheben (Guide)…") 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, await guide.generate_guide(topic=topic, format_name=fmt, guide_id=guide_id,
instructions=instructions, provider=provider) # resumt offene Karten instructions=instructions, provider=provider) # resumt offene Karten
return await _messen() return await _messen(), len(betroffen) > 0
res = await auto_repair_loop("Guide", erst, _reparieren) res = await auto_repair_loop("Guide", erst, _reparieren)
if res["grund"] != "fertig": 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 generate → verify (inkl. Fix-Tail) → artefakte (Gen + Prüfer) → finalize
(die verschmolzenen Calls liegen in block_calls.py — 45 serielle Segmente statt ~20). (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 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 files + the DB tables. Danach eine topic-weite BARRIERE: `outline` (prerequisite graph →
sub dedup, faltet per repair.falte_sub) und `outline` (prerequisite graph → chapter order), chapter order), re-run once per generation run."""
re-run once per generation run — outline läuft parallel zur Dedup-Barriere."""
import asyncio import asyncio
import hashlib
import json import json
import logging import logging
import re import re
import database as db import database as db
import blocks import blocks
import embedding
from block_calls import _artefakte_block, _generate_block, _verify_block from block_calls import _artefakte_block, _generate_block, _verify_block
from blocks import ARTEFACT_TYPES, _match_sub, _neg_set, _sink_json, _outline_block from blocks import ARTEFACT_TYPES, _match_sub, _outline_block
from config import CROSS_CHUNK_PAARE, EMBEDDING_AKTIV, SUB_DUP_KANDIDAT_COS
from fsutil import atomic_write_json from fsutil import atomic_write_json
from jsonio import read_json_file as _json_file from jsonio import read_json_file as _json_file
from kanban import Flow, Stage from kanban import Flow, Stage
from pipeline import FAILED, GenContext, _extra, _log, _prompt, _timeout, run_single_slot from pipeline import GenContext, _log
from textkit import _norm_title, _title, parse_facts from textkit import _norm_title, _title
log = logging.getLogger("creator.board_artefacts") 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) 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 ───────────────────── # ── Finalize (SERIAL): merge into the global files + DB tables ─────────────────────
def _merge_json(path, block_keys: dict) -> None: def _merge_json(path, block_keys: dict) -> None:
data = _json_file(path) data = _json_file(path)

View File

@@ -39,7 +39,7 @@ import kanban
from kanban import Flow, Stage, chain_stages from kanban import Flow, Stage, chain_stages
import blocks import blocks
from blocks import ( from blocks import (
_FILTER_NOTATION, _GROUP_STANDALONE, _FILTER_NOTATION,
_build_research_prompt, _canonical, _canonical_key, _chunk_nums, _cliques, _build_research_prompt, _canonical, _canonical_key, _chunk_nums, _cliques,
_completion_schema, _containment_parent, _crawl_index, _file_payload, _completion_schema, _containment_parent, _crawl_index, _file_payload,
_filter_schema, _filter_suspect, _is_artifact, _is_named_statement, _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, _direction_conflict, _relation_conflict, _root, _supplement_schema, _text_sections, _umbrella_schema,
_aspect_marker, _title_variants, _corpus_files, _evidence_pack, _sink_json, source_folder, _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, DEDUP_GLOBAL_FLOOR, DEDUP_PAIR_FLOOR, DEDUP_PAIRS_CHUNK, DEDUP_TITLE_AUTO, FILTER_CHUNK,
FILTER_RECHECK_PANEL, CONSOLIDATION_PANEL, RESEARCH_BATCH, RESEARCH_READERS, FILTER_RECHECK_PANEL, CONSOLIDATION_PANEL, RESEARCH_BATCH, RESEARCH_READERS,
RESEARCH_THEMA_AGENTS, RESEARCH_THEMA_AGENTS,
BLOCKS_GRUPPIERUNG_AKTIV, EMBEDDING_AKTIV, EMBEDDING_BLOCK_CAP, BLOCKS_GRUPPIERUNG_AKTIV, EMBEDDING_AKTIV, EMBEDDING_BLOCK_CAP,
EMBEDDING_SIBLING_CAP, EMBEDDING_SIBLING_FLOOR, FRAGMENT_MIN_COS, 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 fsutil import atomic_write_json, atomic_write_text
from jsonio import read_json_file as _json_file 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() 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): async def _proc_grouping(ctx: GenContext, flow: Flow, cards):
"""BARRIER/drain — umbrella grouping over the filter survivors: embedding sibling clusters """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, (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)) all_ids = set(range(1, n + 1))
full_list = "\n".join(f"{i}. {texts[i - 1]}" for i in 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]) h = _h(*[r["card_id"] for r in rows])
theme_lo, theme_hi = _themen_zielband(n)
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)
async def _assess(tag, cand_text, count): async def _assess(tag, cand_text, count):
path = work_dir / f"gruppierung-{h}-c{tag}.json" 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( status, _v = await run_single_slot(
ctx, f"Gruppierung {tag}", key=f"blocks-{topic}-gruppierung-{h}-c{tag}", ctx, f"Gruppierung {tag}", key=f"blocks-{topic}-gruppierung-{h}-c{tag}",
prompt=_prompt("Blocks-Gruppierung", topic=topic, candidates=cand_text, 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", role="judge", capabilities="files",
payload=lambda result, p=path: _umbrella_schema(_json_file(p), all_ids), payload=lambda result, p=path: _umbrella_schema(_json_file(p), all_ids),
timeout=_timeout("research_mapping", count)) 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 # 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). # ein Thema; ein „Fehl-Merge" ist billig (Item bleibt als Sub erhalten, keine Lücke).
mrows = [rows[m - 1] for m in members] mrows = [rows[m - 1] for m in members]
mc = _min_cos([m - 1 for m in members])
unorm = _norm_title(title) unorm = _norm_title(title)
member_norms = {r["title_norm"] for r in mrows} member_norms = {r["title_norm"] for r in mrows}
if unorm not in member_norms and unorm in seen_norm: 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 continue
used.update(members) used.update(members)
seen_norm.add(unorm) 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 # reconcile: same parent proposed twice under different titles → union
if len(chosen) >= 2: if len(chosen) >= 2:
uv = await _vec_rows(flow, [f"{c['umbrella']}{c['description']}" for c in chosen]) 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() 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 """Evidence gate for supplement proposals: keyword excerpts per proposal, ONE no-tool
judge marks material coverage (ja/nein). Proposals without any matching excerpt drop 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 topic = flow.topic
folder = source_folder(topic) folder = source_folder(topic)
packs = [(t, d, _evidence_pack(folder, None, [t], budget=6000)) for t, d in supplements] 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] cands = [(t, d, ev) for t, d, ev in packs if ev]
kept: list = [] kept: list = []
if cands: 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)) ids = set(range(1, len(cands) + 1))
verdict = _yesno_schema(_json_file(path), ids) verdict = _yesno_schema(_json_file(path), ids)
if verdict is None: if verdict is None:
lines = "\n\n".join(f"{k}. {t}{d}\nAUSZÜGE:\n{ev}" lines = "\n\n".join(f"{k}. {t}{d}\nAUSZÜGE:\n{ev}"
for k, (t, d, ev) in enumerate(cands, 1)) for k, (t, d, ev) in enumerate(cands, 1))
status, verdict = await run_single_slot( 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, prompt=_prompt("Blocks-Supplement-Beleg", topic=topic, proposals=lines,
extra=_extra(flow.state.get("instructions", ""))), extra=_extra(flow.state.get("instructions", ""))),
role="judge", capabilities="none", 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. # treats). Only proposals the material itself covers may enter the inventory.
if supplements and source_folder(topic): if supplements and source_folder(topic):
supplements = await _supplement_beleg(ctx, flow, supplements) supplements = await _supplement_beleg(ctx, flow, supplements)
# Dead lineage: blocks demoted by the fragment filter (and their cluster + title cards) new, reopened = await _ingest_proposals(topic, supplements or [])
# must NOT dedup a supplement proposal — their content is gone. A hit on a dead title if new or reopened:
# REOPENS the lineage instead: the title card rejoins its cluster (live re-cluster) and _log(topic, f"Supplement: {new} Block-Kandidat(en) → ingest, {reopened} wiedereröffnet")
# the respawned block gets a fresh fragment_filter pass. failed-quorum/pre-reject stay flow.wake.set()
# in the dedup: those were rejected as non-blocks, not lost as content.
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"} dead_reasons = {"fragment", "drop-collateral", "drop"}
cards = await db.kanban_cards(topic, board=BOARD) cards = await db.kanban_cards(topic, board=BOARD)
dead_clusters = {c["payload"].get("cluster") for c in cards 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)): if (k := _canonical_key(tt)):
known_keys.add(k) known_keys.add(k)
new = reopened = 0 new = reopened = 0
for t, d in (supplements or []): for t, d in supplements:
t, d = clean_title(t), clean_title(d) t, d = clean_title(t), clean_title(d)
norm = _norm_title(t) norm = _norm_title(t)
key = _canonical_key(t) key = _canonical_key(t)
@@ -1587,9 +1603,57 @@ async def _supplement_producer(ctx: GenContext, flow: Flow, titles: list[str]):
card["payload"]["supplement"] = True card["payload"]["supplement"] = True
await db.kanban_set_payload(topic, BOARD, norm, card["payload"]) await db.kanban_set_payload(topic, BOARD, norm, card["payload"])
new += 1 new += 1
if new or reopened: return new, reopened
_log(topic, f"Supplement: {new} Block-Kandidat(en) → ingest, {reopened} wiedereröffnet")
flow.wake.set()
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): 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) stages = chain_stages(stages)
if artefacts: if artefacts:
# Outline needs every block's TITLE + FACTS (aus generate), nothing later: cut the # 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 # post-generate stages from its barrier so it runs parallel to verify…finalize
# zur Cross-Dedup-Barriere des langsamsten Blocks (makespan tail). # des langsamsten Blocks (makespan tail).
outline = next(s for s in stages if s.stage == "outline") outline = next(s for s in stages if s.stage == "outline")
outline.upstream = [u for u in outline.upstream if u not in 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 [] producers = _build_producers(ctx, flow, q, folder, instructions) if (research and inventory) else []
async def _as_producer(coro): 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 _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): 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 — """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, 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.""" not a jailer); qa_force short-circuits to open."""
topic = flow.topic topic = flow.topic
try: try:
while not flow.stop: await _warte_inventar_ruhe(flow, inv_names)
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)
if flow.stop or ctx.is_cancelled(): if flow.stop or ctx.is_cancelled():
return return
if flow.state.get("qa_force"): 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() flow.wake.set()
return 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 from auto_loop import auto_repair_loop
import repair as _repair import repair as _repair
async def _reparieren(): 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)…") 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)) n = float(r.get("note", 10.0))
flow.state["qa_note"] = n 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) res = await auto_repair_loop("Inventar", note, _reparieren)
end_note = res["note"] end_note = res["note"]
@@ -1899,7 +1987,7 @@ async def _artefakt_auto_loop(ctx: GenContext, flow: Flow, set_p):
async def _reparieren(): async def _reparieren():
set_p("Befunde beheben (Artefakte)…") set_p("Befunde beheben (Artefakte)…")
r = await _repair.repair_befunde(topic, "artefacts") 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) res = await auto_repair_loop("Artefakte", na, _reparieren)
flow.state["note_artefakte"] = res["note"] flow.state["note_artefakte"] = res["note"]
@@ -1986,7 +2074,6 @@ COLUMNS = [
("artefacts", "verify", "Prüfen", "ablock"), ("artefacts", "verify", "Prüfen", "ablock"),
("artefacts", "artefakte", "Lernmittel", "ablock"), ("artefacts", "artefakte", "Lernmittel", "ablock"),
("artefacts", "finalize", "Zusammenführen", "ablock"), ("artefacts", "finalize", "Zusammenführen", "ablock"),
("artefacts", "konsolidierung", "Konsolidierung", "ablock"),
("artefacts", "outline", "Gliederung", "outline"), ("artefacts", "outline", "Gliederung", "outline"),
("artefacts", "done_artefact", "Fertig", "ablock"), ("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: def _qa_view(topic: str, counts: dict, flow) -> dict | None:
"""Latest QA report digest for the board header. `pausiert` = the gate stopped the """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, # 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 # so the first run after a rebuild diffs against the old state
if not counts.get("inventory", {}).get("done_block", 0) and not ( if not counts.get("inventory", {}).get("done_block", 0) and not (
flow and flow.state.get("qa_note") is not None): flow and flow.state.get("qa_note") is not None):
return None return None
import qa import qa
from auto_loop import VOLL
r = qa.latest_report(topic) r = qa.latest_report(topic)
if r is None: if r is None:
return None return None
@@ -2067,9 +2156,9 @@ def _qa_view(topic: str, counts: dict, flow) -> dict | None:
if note is None: if note is None:
return None return None
wartend = counts.get("artefacts", {}).get("generate", 0) 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"), return {"note": note, "note_artefakte": r.get("note_artefakte"),
"schwelle": QA_GATE_NOTE, "pausiert": pausiert, "schwelle": VOLL, "pausiert": pausiert,
"quoten": r.get("quoten", {}), "quoten": r.get("quoten", {}),
"befunde": (r.get("fremd", []) + r.get("unecht", []))[:6]} "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 # (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. # judge decides. Candidates only; a merge still needs judge unanimity.
SUB_DUP_KANDIDAT_COS = 0.75 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): # Umbrella grouping (block granularity level 2, step "Blocks-Gruppierung", AFTER the filter):
# collapse sibling DEFINITIONS that are components of ONE umbrella concept (TM model: # 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 # 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). # ≥ this (conservative → only true same-parent duplicates, never two distinct umbrellas).
GROUP_RECONCILE_FLOOR = 0.75 GROUP_RECONCILE_FLOOR = 0.75
# Over-merge backstop ONLY (no-structure floor). Research (meronymy ≠ similarity): parts of ONE model are # Zielband der Themen-Blockzahl im Gruppierungs-Prompt: k = GROUP_THEMES_PER_SQRT·√n
# legitimately DISSIMILAR (TM: Alphabet/Konfiguration/δ ~0.22), while distinct same-type concepts (P/NP/…) # (n = Items der Grouping-Welle), Band [0.7k, 1.3k] — weiche Vorgabe, kein Cap. √n hält
# are SIMILAR (~0.85) — so member-vs-member cosine is the WRONG instrument for over-merge (empirically # Blockzahl UND mittlere Blockgröße sublinear (n=30 → ~48, n=285 → ~1222; das alte
# inverted: TM 0.218 < the P/NP bundle 0.227). The real precision floor is the ATOMICITY type-guard # statische „1525" passte implizit nur zu n≈225625).
# (_GROUP_STANDALONE: a member that is a named algorithm/problem/theorem/complexity-class dissolves the GROUP_THEMES_PER_SQRT = 1.0
# umbrella). This floor is demoted to a near-zero backstop that only rejects a literally structureless # Fragment-demote backstop (no-structure floor): fragment↔parent cosine is a BAD fragment
# chain (random-pair baseline), set BELOW the legitimate heterogeneous minimum so it never kills a real model. # detector (measured, Markdown run: wrong demotes Blockzitate→Codeblöcke 0.353 and
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
# Zeichenkodierung→Überschriften 0.640 sit ABOVE any usable floor, while true NP proof-gadget # 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 # 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). # 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. # check loops leave any remaining objections standing after that.
CONSENSUS_MAX_ROUNDS = 3 CONSENSUS_MAX_ROUNDS = 3
# QA gate: after the inventory phase an automatic QA run scores the blocks; below the # QA gate: after the inventory phase an automatic QA run scores the blocks; below 100 %
# threshold the flow PAUSES before board 2 burns tokens (frontend offers force-continue). # 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_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 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 # 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 # 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}) 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 def delete_blocks(topic: str) -> None:
async with _tx() as db: async with _tx() as db:
await db.execute("DELETE FROM blocks WHERE topic = ?", (topic,)) 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 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]: async def kanban_dead(topic: str) -> list[dict]:
"""Dead-letter cards across boards (for the board UI + requeue).""" """Dead-letter cards across boards (for the board UI + requeue)."""
return await kanban_cards(topic, stage="dead") 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) _NUM_RE = re.compile(r"^\s*(\d+)[.)]\s+(.*\S)", re.MULTILINE)
_SUBLIST_RE = re.compile(r"^- (?:\[(\w+)\] )?(.+\S)\s*$", re.MULTILINE) _SUBLIST_RE = re.compile(r"^- (?:\[(\w+)\] )?(.+\S)\s*$", re.MULTILINE)
_ZIEL_RE = re.compile(r"\(([a-z]\d+)\)") _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: def _norm(s: str) -> str:
@@ -30,7 +29,7 @@ def _norm(s: str) -> str:
class Welt: class Welt:
"""Deterministisches Themen-Modell. bloecke: {titel: {"beschreibung": str, """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, def __init__(self, bloecke: dict | None = None, *, gruppen: list | None = None,
kataloge: list | None = None, stoerungen: 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: if "-supplement-beleg" in key or "-anker-beleg-" in key:
nums = {m.group(1) for m in _NUM_RE.finditer(prompt)} 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"}}) return j({"relevant": {k: "ja" for k in sorted(nums, key=int)} or {"1": "ja"}})
if "-supplement" in key: if "-supplement" in key or "-luecken-nachfass-" in key:
return j({"blocks": []}) return j({"blocks": []}) # Fake-Welt ist vollständig — Lücken-Research findet nichts
if "-source-relevance-" in key: if "-source-relevance-" in key:
nums = {m.group(1) for m in _NUM_RE.finditer(prompt)} 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"}}) 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": {}}) "uebernehmen": unsicher, "facts_probleme": [], "levels": {}, "relevanz": {}})
if "-sb-fix-" in key: if "-sb-fix-" in key:
return j({"subs": []}) 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: if "-art-gen-" in key:
subs = self._subs_im_prompt(prompt) subs = self._subs_im_prompt(prompt)
t = (self._bloecke_im_prompt(prompt) or ["?"])[0] t = (self._bloecke_im_prompt(prompt) or ["?"])[0]
@@ -308,7 +302,6 @@ def aktivieren(welt: Welt, setattr_fn=setattr) -> None:
import agents import agents
import blocks import blocks
import board_artefacts as ba
import board_inventory as bi import board_inventory as bi
import guide import guide
import guide_board import guide_board
@@ -360,7 +353,7 @@ def aktivieren(welt: Welt, setattr_fn=setattr) -> None:
_find = staticmethod(_real_emb._find) _find = staticmethod(_real_emb._find)
_union = staticmethod(_real_emb._union) _union = staticmethod(_real_emb._union)
for mod in (blocks, ba, qa, bi): for mod in (blocks, qa, bi):
setattr_fn(mod, "embedding", _FakeEmb) setattr_fn(mod, "embedding", _FakeEmb)
# _emb_ok bleibt echt (True über _FakeEmb): Bottom-up braucht den Grouping-Stage. # _emb_ok bleibt echt (True über _FakeEmb): Bottom-up braucht den Grouping-Stage.
# _FakeEmb bildet nur bei identischem Text Nachbarn — verschiedene Atome erzeugen keine # _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) is_cancelled=lambda: is_guide_cancelled(guide_id), guide_id=guide_id)
import uuid import uuid
from datetime import datetime, timezone 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]}") db.set_current_run(topic, f"{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M')}-g{uuid.uuid4().hex[:4]}")
try: try:
spec = (TEMPLATES_DIR / "Format" / "Section.md").read_text(encoding="utf-8") 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]: async def repair_karten(topic: str, format_name: str) -> list[str]:
"""QA-Befund-getriebenes Guide-Repair: Karten, die im jüngsten Guide-QA-Report """QA-Befund-getriebenes Guide-Repair (Pendant zum Blocks-Repair: „Score unter 10
Befunde tragen, gehen zurück auf `pruefer` (md bleibt) — Prüfer+Fix beheben gezielt, muss einen Fix-Pfad haben"). Kritische Befunde (marker_fehlend/ziel_ohne_anker/
generate_guide resumt die offenen Karten und misst am Ende neu. Pendant zum fachlich_falsch) → zurück auf `pruefer` (brauchen Facts-/Ziele-Kontext + Re-Prüfer).
Blocks-Repair („Score unter 10 muss einen Fix-Pfad haben"). → betroffene Blocktitel.""" 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 import qa as qa_mod
from guide_qa import block_budget, LAENGE_BAND
reports = qa_mod.report_paths(topic, guide=True) reports = qa_mod.report_paths(topic, guide=True)
rep = _json_file(reports[-1]) if reports else None rep = _json_file(reports[-1]) if reports else None
if not rep: if not rep:
return [] return []
cards = {c["block_norm"]: c for c in await db.list_guide_cards(topic, format_name)} 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" 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" for e in rep.get("ziel_ohne_anker", []): # "block_norm · (id) text"
norms.add(str(e).split(" · ")[0]) kritisch.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]))
for t in rep.get("fachlich_falsch", []) or []: 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 e in rep.get("redundanz", []): # {"a": "Block: absatz", "b": …}
for seite in ("a", "b"): a, b = str(e.get("a", "")), str(e.get("b", ""))
norms.add(_norm_title(str(e.get(seite, "")).split(":")[0])) 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 = [] betroffen = []
for n in sorted(norms & set(cards)): for n in sorted((kritisch | set(stil)) & set(cards)):
await db.set_guide_card(topic, format_name, n, stage="pruefer", status="open", gate_info="") 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"]) betroffen.append(cards[n]["block"])
return betroffen return betroffen

View File

@@ -11,6 +11,7 @@ previous report of the same topic.
""" """
import asyncio import asyncio
import hashlib
import logging import logging
import re import re
import sys import sys
@@ -277,8 +278,10 @@ def hygiene(blocks: list[dict]) -> list[dict]:
def _zaehlbare_luecken(lk: list[dict], llm: bool) -> list[dict]: def _zaehlbare_luecken(lk: list[dict], llm: bool) -> list[dict]:
"""With --llm only non-refuted gaps count ('?' = unjudged stays, conservative) — refuted """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).""" 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 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: 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], 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 """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 agents.py begrenzen); Ergebnis mit globalen 1-basierten Indizes. Fail-open pro Chunk
(Items bleiben ohne Urteil), aber nie stumm. Ersetzt die drei strukturgleichen (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 agents import run_agent
from pipeline import _timeout, _yesno_schema from pipeline import _timeout, _yesno_schema
from jsonio import parse_json_text 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)) listing = "\n\n".join(f"{k}. {it}" for k, it in enumerate(teil, 1))
try: try:
rc, out, _err = await run_agent( 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}") _timeout("qa_judge"), role="judge", capabilities="none", scope=topic, label=f"{label} {key}")
except Exception: except Exception:
log.exception("[%s] %s-Judge %s+%d fehlgeschlagen — Items ohne Urteil", topic, label, key, lo) 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)))) 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]]: def lade_freispruch(topic: str) -> dict[str, list[str]]:
"""Persistierte 2:1-Freisprüche des Repair-Stichentscheids (repair._mit_stichentscheid): """Persistierte 2:1-Freisprüche des Repair-Stichentscheids (repair._mit_stichentscheid):
mehrheitlich als „behalten" geurteilte Befunde zählen nicht mehr in die Note — sonst 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 []) frei_fremd = set(frei.get("fremd") or [])
fremd_frei = [t for t in fr if _norm_title(t) in frei_fremd] 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] 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", v = await judge_wave("QA-Dubletten", topic, "dubletten", "pairs",
[f"A: {p['a']}\nB: {p['b']}" for p in d[:LLM_SAMPLE]]) [f"A: {p['a']}\nB: {p['b']}" for p in d_offen[:LLM_SAMPLE]])
for k, p in enumerate(d[:LLM_SAMPLE], 1): for k, p in enumerate(d_offen[:LLM_SAMPLE], 1):
p["llm"] = v.get(k, "?") p["llm"] = v.get(k, "?")
if llm and lk: if llm and lk_offen:
v = await judge_wave("QA-Luecken", topic, "luecken", "sections", v = await judge_wave("QA-Luecken", topic, "luecken", "sections",
[f"[{x['datei']} #{x['abschnitt']}] {x['vorschau']}" for x in lk[:LLM_SAMPLE]]) [f"[{x['datei']} #{x['abschnitt']}] {x['vorschau']}" for x in lk_offen[:LLM_SAMPLE]])
for k, x in enumerate(lk[:LLM_SAMPLE], 1): for k, x in enumerate(lk_offen[:LLM_SAMPLE], 1):
x["llm"] = v.get(k, "?") x["llm"] = v.get(k, "?")
if llm and sd: # full coverage in chunks — a sampled quota would mislead the note 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", 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(), "topic": topic, "erstellt": datetime.now(timezone.utc).isoformat(),
"run_id": summary.get("run_id", ""), "bloecke": len(blocks), "run_id": summary.get("run_id", ""), "bloecke": len(blocks),
"quoten": { "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), "luecken": round(len(_zaehlbare_luecken(lk, llm)) / n_sections, 3),
"fremd": round(len(fr) / max(len(blocks), 1), 3), "fremd": round(len(fr) / max(len(blocks), 1), 3),
**({"konzept_luecken": round(len(kl) / max(len(named), 1), 3)} if corpus else {}), **({"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, "quoten_artefakte": quoten_art,
**({"unecht": unecht} if unecht is not None else {}), **({"unecht": unecht} if unecht is not None else {}),
**({"fremd_freigesprochen": fremd_frei} if fremd_frei 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, "dubletten": d, "sub_dubletten": sd, "luecken": lk, "konzept_luecken": kl,
"fremd": fr, "beleg": bl, "hygiene": hy, "fremd": fr, "beleg": bl, "hygiene": hy,
"artefakte": art, "artefakte": art,

View File

@@ -2,16 +2,17 @@
Blindes Re-Filtern reproduziert die blinden Flecken der Pipeline (sie hat die Befunde ja 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 durchgelassen). Hier fließen die QA-BEFUNDE als Input in gezielte Aktionen: Hygiene
deterministisch, bestätigte Dubletten mergen (Zweitmeinung), Fremd/Unecht nur nach deterministisch (+ Beschreibung/Suffix per Sanierung/Rename), bestätigte Dubletten mergen
Gegen-Judge entfernen (fail-open: Zweifel/Fehler → behalten). Lücken brauchen Recherche, (Zweitmeinung), Fremd/Unecht nur nach Gegen-Judge entfernen, Beleg-Nachfass je Sub,
Verwaiste den nächsten Board-2-Lauf — beides wird nur ausgewiesen.""" Lücken per Stichentscheid klären (Freispruch oder bestätigt = braucht Recherche).
Fail-open überall: Zweifel/Judge-Fehler → behalten, nichts persistieren."""
import logging import logging
import re import re
import database as db import database as db
import qa 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 config import EVIDENCE_PER_BLOCK
from fsutil import atomic_write_json from fsutil import atomic_write_json
from jsonio import read_json_file as _json_file 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") log = logging.getLogger("creator.repair")
async def repair_befunde(topic: str, ebene: str | None = None) -> dict: async def repair_befunde(topic: str, ebene: str | None = None, luecken_urteil: bool = True) -> dict:
"""ebene=None → alle Fixes; „inventory" → nur Inventar (Hygiene/Merges/Fremd); """ebene=None → alle Fixes; „inventory" → nur Inventar; „artefacts" → nur Artefakte.
„artefacts" → nur Artefakte (Sub-Merges/Waisen). Misst am Ende IMMER beide Noten neu.""" 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) reports = qa.report_paths(topic)
report = _json_file(reports[-1]) if reports else None report = _json_file(reports[-1]) if reports else None
if not report: if not report:
@@ -34,9 +38,13 @@ async def repair_befunde(topic: str, ebene: str | None = None) -> dict:
art = ebene in (None, "artefacts") art = ebene in (None, "artefacts")
hygiene = await _fix_hygiene(topic, report, by_norm, files) if inv else [] 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 ([], []) 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 ([], []) 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 aufgeraeumt = (await _raeume_waisen(topic)) if art else 0
# llm=True: gleiche Messlatte wie QA-Button/Abschluss-QA — der llm=False-Report # 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: if neu:
await qa.write_report(neu) await qa.write_report(neu)
na = neu.get("note_artefakte") if neu else None 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, return {"hygiene": hygiene, "merges": merges, "sub_merges": sub_merges, "entfernt": entfernt,
"aufgeraeumt": aufgeraeumt, "freigesprochen": frei_subs + frei_bloecke, "beleg_fix": beleg_fix, "aufgeraeumt": aufgeraeumt, "freigesprochen": freigesprochen,
"braucht_research": len(report.get("luecken", [])), "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": 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 "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).""" """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: 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, async def _mit_stichentscheid(template: str, topic: str, key: str, slot: str,
lines: list[str], befund: str, kategorie: 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 """Zweitmeinung + Stichentscheid: Der Repair-Judge kann den QA-Befund kippen — bei
Dissens (QA sagt Befund, Judge sagt behalten) entscheidet ein DRITTER Judge nur über 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 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 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 zählt das Item ab dann nicht mehr (qa.lade_freispruch). j3-AUSFALL persistiert nicht
(fail-open ist kein Urteil). → (verdicts, freigesprochene Zeilen).""" (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] strittig = [i for i in range(1, len(lines) + 1) if v.get(i) != befund]
frei: list[str] = [] frei: list[str] = []
if strittig: 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" gegen = "nein" if befund == "ja" else "ja"
frei_keys: list[str] = [] frei_keys: list[str] = []
for pos, i in enumerate(strittig, 1): 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 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]: 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 """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 = [] fixed = []
for h in report.get("hygiene", []): for h in report.get("hygiene", []):
alt = h.get("titel", "") alt = h.get("titel", "")
@@ -117,33 +287,68 @@ async def _fix_hygiene(topic: str, report: dict, by_norm: dict, files: dict) ->
return fixed return fixed
async def _merge_dubletten(topic: str, report: dict, by_norm: dict, files: dict) -> list[str]: async def _merge_paar(topic: str, p: dict, by_norm: dict, files: dict) -> str | None:
"""Nur QA-bestätigte Paare (llm=ja); eine Zweitmeinung, Merge nur bei erneut ja. """Ein bestätigtes Dubletten-Paar mergen (spiegelt die dedup-Stage: Union ins
Merge spiegelt die dedup-Stage: Union ins Gewinner-Payload, Verlierer → grouped.""" Gewinner-Payload, Verlierer → grouped). → Journalzeile oder None."""
paare = [p for p in report.get("dubletten", []) if p.get("llm") == "ja" a, b = by_norm.get(_norm_title(p["a"])), by_norm.get(_norm_title(p["b"]))
and _norm_title(p.get("a", "")) in by_norm and _norm_title(p.get("b", "")) in by_norm] if not a or not b or a["card_id"] == b["card_id"]:
if not paare: return None
return [] win, lose = sorted((a, b), key=lambda c: (len(c["payload"].get("description") or ""),
v, _frei = await _mit_stichentscheid("QA-Dubletten", topic, "dubletten", "pairs", len(c["payload"].get("title") or "")), reverse=True)
[f"A: {p['a']}\nB: {p['b']}" for p in paare], "ja") wp, lp = dict(win["payload"]), dict(lose["payload"])
merged = [] wp["readers"] = sorted(set(wp.get("readers") or []) | set(lp.get("readers") or []))
for i, p in enumerate(paare, 1): wp["sources"] = sorted(set(wp.get("sources") or []) | set(lp.get("sources") or []))
a, b = by_norm.get(_norm_title(p["a"])), by_norm.get(_norm_title(p["b"])) lp.update(reason="merged", merged_into=wp.get("title", ""))
if v.get(i) != "ja" or not a or not b or a["card_id"] == b["card_id"]: await db.kanban_set_payload(topic, "inventory", win["card_id"], wp)
continue await db.kanban_set_payload(topic, "inventory", lose["card_id"], lp)
win, lose = sorted((a, b), key=lambda c: (len(c["payload"].get("description") or ""), await db.kanban_advance(topic, "inventory", lose["card_id"], "grouped")
len(c["payload"].get("title") or "")), reverse=True) await _purge_block(topic, lp.get("title", ""), files)
wp, lp = dict(win["payload"]), dict(lose["payload"]) by_norm.pop(_norm_title(lp.get("title", "")), None)
wp["readers"] = sorted(set(wp.get("readers") or []) | set(lp.get("readers") or [])) return f"{lp.get('title')}{wp.get('title')}"
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) async def _merge_dubletten(topic: str, report: dict, by_norm: dict, files: dict) -> tuple[list[str], list[str]]:
await db.kanban_set_payload(topic, "inventory", lose["card_id"], lp) """QA-bestätigte Paare (llm=ja): Zweitmeinung, Merge nur bei erneut ja; 2:1-„behalten"
await db.kanban_advance(topic, "inventory", lose["card_id"], "grouped") persistiert jetzt als Freispruch (fehlte — die Paare pendelten ewig im Verdacht).
await _purge_block(topic, lp.get("title", ""), files) Widerlegte/unbeurteilte Paare (llm=nein/?) bekommen den Klärungskanal: Judge-„nein"
by_norm.pop(_norm_title(lp.get("title", "")), None) Freispruch (mit QA-nein 2:0; beim bloßen Verdacht reicht das klare Gegen-Urteil),
merged.append(f"{lp.get('title')}{wp.get('title')}") Judge-„ja" → Stichentscheid (ja → Merge 2:1, nein → Freispruch 2:1, Ausfall → nichts)."""
return merged 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) _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, delete_topic_pipeline, delete_source, get_guide_content, delete_guide_content,
get_sub_artefakte, kanban_reset, delete_guide_board, get_sub_artefakte, kanban_reset, delete_guide_board,
get_practice_progress, upsert_practice_progress, sub_levels_norm, subs_per_level_norm, 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 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 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") @router.get("/runs")
async def get_runs(topic: str, limit: int = 10): async def get_runs(topic: str, limit: int = 10):
"""Lauf-Historie (Blocks + Guide): Zeitspanne, Agenten, Tokens, Fehler je run_id.""" """Lauf-Historie (Blocks + Guide): Zeitspanne, Agenten, Tokens, Fehler je run_id.
return {"runs": await list_runs(topic, limit)} `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") @router.get("/topics/progress")

View File

@@ -273,12 +273,12 @@ def test_meminfo_reads_proc():
def test_agent_ebene_klassifiziert_board(): 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" T = "shopware"
for key in (f"blocks-{T}-research-a1", f"blocks-{T}-pair-x", f"blocks-{T}-naming-y", 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"): f"blocks-{T}-gruppierung-h-cTOP", f"blocks-{T}-dedup-z"):
assert agents.agent_ebene(key) == "inventory", key 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", 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}-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 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 import auto_loop
@@ -14,7 +14,7 @@ async def test_verbessert_bis_voll():
noten = iter([9.0, 10.0]) noten = iter([9.0, 10.0])
async def rep(): 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 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 assert r["grund"] == "fertig" and r["runden"] == 2
@@ -22,19 +22,31 @@ async def test_verbessert_bis_voll():
async def test_stillstand(): async def test_stillstand():
async def rep(): 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) r = await auto_loop.auto_repair_loop("art", 8.0, rep)
assert r["grund"] == "stillstand" and r["runden"] == 1 assert r["grund"] == "stillstand" and r["runden"] == 1
assert r["note"] == 8.0 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(): async def test_limit():
stand = {"n": 1.0} stand = {"n": 1.0}
async def rep(): async def rep():
stand["n"] += 0.1 # verbessert stetig, erreicht aber nie 10.0 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) r = await auto_loop.auto_repair_loop("guide", 1.0, rep, max_iter=10)
assert r["grund"] == "limit" and r["runden"] == 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)]: ("_artefakte_block", fake_artefakte), ("_outline_block", fake_outline)]:
monkeypatch.setattr(ba, name, fn) 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 = tmp_path / "arbeit"
work.mkdir() work.mkdir()
files = {"arbeit": work, "final": tmp_path / "blocks.md", 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): async def bad_qa(topic, llm=False):
return {"note": 5.0, "topic": TOPIC, "quoten": {}, "fremd": [], "artefakte": {}} return {"note": 5.0, "topic": TOPIC, "quoten": {}, "fremd": [], "artefakte": {}}
async def stuck_repair(topic, ebene=None): async def stuck_repair(topic, ebene=None, luecken_urteil=True):
return {"note": 5.0} # bewegt nichts return {"note": 5.0, "aktionen": 0} # bewegt nichts
monkeypatch.setattr(qa_mod, "qa_report", bad_qa) monkeypatch.setattr(qa_mod, "qa_report", bad_qa)
monkeypatch.setattr(qa_mod, "_write_report", lambda r: None) 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 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): 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.""" """Auto an, Repair hebt die Note auf 100 % → Gate öffnet, Board 2 läuft durch."""
import qa as qa_mod 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): async def bad_qa(topic, llm=False):
return {"note": 8.0, "topic": TOPIC, "quoten": {}, "fremd": [], "artefakte": {}} return {"note": 8.0, "topic": TOPIC, "quoten": {}, "fremd": [], "artefakte": {}}
async def good_repair(topic, ebene=None): async def good_repair(topic, ebene=None, luecken_urteil=True):
return {"note": 10.0} return {"note": 10.0, "aktionen": 1}
monkeypatch.setattr(qa_mod, "qa_report", bad_qa) monkeypatch.setattr(qa_mod, "qa_report", bad_qa)
monkeypatch.setattr(qa_mod, "_write_report", lambda r: None) 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): async def good_qa(topic, llm=False):
return {"note": 10.0, "topic": TOPIC, "quoten": {}, "fremd": [], "artefakte": {}} 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") raise AssertionError("Auto aus darf nicht reparieren")
monkeypatch.setattr(qa_mod, "qa_report", good_qa) 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}} counts = {"inventory": {"done_block": 3}, "artefacts": {"generate": 4}}
v = bi._qa_view(TOPIC, counts, None) v = bi._qa_view(TOPIC, counts, None)
assert v["pausiert"] is True and v["note"] == 5.0 and v["befunde"] == ["X", "Y"] 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 from types import SimpleNamespace
laufend = SimpleNamespace(state={}) laufend = SimpleNamespace(state={})
assert bi._qa_view(TOPIC, counts, laufend)["pausiert"] is False # Flow läuft noch 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) ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
await bi._name_one(ctx, _mk_flow(tmp_path), {"card_id": "c4", "payload": payload}) 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" 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. """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 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 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 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): 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 """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.""" 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"}) await db.add_event(TOPIC, "fail", key="inventory:b-1", status="dead", meta={"error": "kaputt"})
res = await routes.get_runs(TOPIC) res = await routes.get_runs(TOPIC)
runs = res["runs"] 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 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]["agents"]["gesamt"] == 2 and runs[0]["agents"]["timeout"] == 1
assert runs[0]["tokens"]["output"] == 20 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" 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): async def test_fix_failed_behaelt_befunde(testdb, tmp_path, monkeypatch):
"""Scheitert der Fix, dürfen die Prüfer-Befunde nicht stumm verschwinden — sie """Scheitert der Fix, dürfen die Prüfer-Befunde nicht stumm verschwinden — sie
bleiben im gate_info sichtbar (vorher wurde gate_info geleert).""" 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): def test_cache_skips_fresh_txt(tmp_path):
_mini_pdf(tmp_path / "a.pdf") _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 = tmp_path / "a.txt"
marker.write_text("MARKER", encoding="utf-8") marker.write_text("MARKER", encoding="utf-8")
now = time.time() + 60 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 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): def test_fallback_to_pdftotext(tmp_path, monkeypatch):
_mini_pdf(tmp_path / "b.pdf") _mini_pdf(tmp_path / "b.pdf")
monkeypatch.setattr(blx, "_pdf_markdown", lambda p: None) 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") (qdir / "alt.json").write_text("{}", encoding="utf-8")
await routes.remove_topic("t") await routes.remove_topic("t")
assert not qdir.exists() 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)} rest = {(r["sub_norm"], r["type"]) for r in await db.get_sub_artefakte(TOPIC)}
assert rest == {("sub0", "flashcard"), ("doppel", "example")} assert rest == {("sub0", "flashcard"), ("doppel", "example")}
assert not [r for r in await db.list_question_pattern(TOPIC)] 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"}, "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"}, "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"}, "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 # Guide
"GATE_FIX_MIN": {"default": 3, "min": 1, "max": 6, "step": 1, "kategorie": "qualitaet", "fidelity": "board2"}, "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"}, "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) ────── // ── Laufzeit + Tokens aus /api/runs (5s-Takt, unabhängig vom 1,2s-Board-Poll) ──────
const run = ref(null) 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()) const now = ref(Date.now())
let clock = null let clock = null
async function loadRun() { async function loadRun() {
try { try {
const { runs } = await fetchRuns(props.topic, 1) const res = await fetchRuns(props.topic, 1)
run.value = runs[0] || null run.value = res.runs[0] || null
} catch { run.value = null } latest.value = res.latest || null
} catch { run.value = null; latest.value = null }
} }
const { start: startRunPoll } = usePolling(loadRun, () => props.generating, 5000) 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) { function boardZeit(b) {
if (!b?.start) return null if (!b?.start) return null
const start = Date.parse(b.start) 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) return fmtRuntime((ende - start) / 1000)
} }
function boardTokens(b) { function boardTokens(b) {
const t = b?.tokens const t = b?.tokens
return t && (t.input || t.output) ? fmtTokens((t.input || 0) + (t.output || 0)) : null return t && (t.input || t.output) ? fmtTokens((t.input || 0) + (t.output || 0)) : null
} }
const invStat = computed(() => run.value?.boards?.inventory) const invStat = computed(() => latest.value?.inventory)
const artStat = computed(() => run.value?.boards?.artefacts) const artStat = computed(() => latest.value?.artefacts)
watch(() => props.generating, (g) => { watch(() => props.generating, (g) => {
if (g) { startRunPoll(); if (!clock) clock = setInterval(() => { now.value = Date.now() }, 1000) } if (g) { startRunPoll(); if (!clock) clock = setInterval(() => { now.value = Date.now() }, 1000) }
else { loadRun(); if (clock) { clearInterval(clock); clock = null } } // Endstand else { loadRun(); if (clock) { clearInterval(clock); clock = null } } // Endstand
}, { immediate: true }) }, { immediate: true })
watch(() => props.topic, () => { run.value = null; loadRun() }) watch(() => props.topic, () => { run.value = null; latest.value = null; loadRun() })
onUnmounted(() => { if (clock) clearInterval(clock) }) onUnmounted(() => { if (clock) clearInterval(clock) })
const { isArmed, armOrRun, reset: resetConfirm } = useConfirm() // 2-Klick-Bestätigung 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)} %)` : ''}` : '' const suffix = max > 1 ? ` — Runde ${i + 1}${note != null ? ` (${Math.round(note * 10)} %)` : ''}` : ''
repairInfo.value = { ...repairInfo.value, [ebene]: repairText(r) + suffix } repairInfo.value = { ...repairInfo.value, [ebene]: repairText(r) + suffix }
if (note == null || note >= 10) break // 100 % erreicht 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 prev = note
} }
} catch (e) { } 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. // 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)) 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 run = ref(null)
const now = ref(Date.now()) const now = ref(Date.now())
let clock = null let clock = null
async function loadRun() { async function loadRun() {
try { try {
const { runs } = await fetchRuns(props.topic, 1) run.value = (await fetchRuns(props.topic, 1)).latest?.guide || null
run.value = runs[0] || null
} catch { run.value = null } } catch { run.value = null }
} }
const { start: startRunPoll } = usePolling(loadRun, () => generating.value, 5000) 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. 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 you may group (your starting point — a pre-clustered neighbourhood):
{candidates} {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}