From f9d77a113bb70fce9e64c7e9c07da5554e97aa1c Mon Sep 17 00:00:00 2001 From: team3 Date: Wed, 8 Jul 2026 21:14:33 +0200 Subject: [PATCH] update --- backend/agents.py | 2 +- backend/auto_loop.py | 24 +- backend/blocks.py | 65 ++-- backend/board_artefacts.py | 170 +--------- backend/board_inventory.py | 169 +++++++--- backend/config.py | 31 +- backend/database.py | 45 +++ backend/fake_agents.py | 15 +- backend/guide_board.py | 59 +++- backend/qa.py | 61 +++- backend/repair.py | 291 +++++++++++++++--- backend/routes.py | 8 +- backend/tests/test_agents_api.py | 4 +- backend/tests/test_auto_loop.py | 20 +- backend/tests/test_board_inventory.py | 84 ++++- backend/tests/test_e2e_fake.py | 2 +- backend/tests/test_events.py | 28 ++ backend/tests/test_finalize.py | 80 +++++ backend/tests/test_guide_board.py | 30 ++ backend/tests/test_konsolidierung.py | 265 ---------------- backend/tests/test_pdf_convert.py | 34 ++ backend/tests/test_qa.py | 20 ++ backend/tests/test_repair.py | 132 ++++++++ backend/train_params.py | 2 +- frontend/src/components/GenerationView.vue | 21 +- frontend/src/components/GuideBoardSection.vue | 6 +- templates/Prompt/Blocks-Gruppierung.md | 2 +- templates/Prompt/Blocks-Luecken-Research.md | 20 ++ templates/Prompt/Subblock-Crossblock.md | 13 - templates/QA/QA-Konzept-Luecken.md | 15 + 30 files changed, 1064 insertions(+), 654 deletions(-) create mode 100644 backend/tests/test_finalize.py delete mode 100644 backend/tests/test_konsolidierung.py create mode 100644 templates/Prompt/Blocks-Luecken-Research.md delete mode 100644 templates/Prompt/Subblock-Crossblock.md create mode 100644 templates/QA/QA-Konzept-Luecken.md diff --git a/backend/agents.py b/backend/agents.py index 796f615..f4bb5de 100644 --- a/backend/agents.py +++ b/backend/agents.py @@ -49,7 +49,7 @@ def active_agents(scope_prefix: str | None = None) -> list[dict]: # Board-2-Calls tragen diese Marker im agent_key; alles andere unter blocks-{topic}- ist Board 1. _ARTEFAKT_MARKER = ("-art-gen", "-art-check", "-sb-enrich", "-sb-verify", "-sb-fix", - "-sub-crossblock", "-outline") + "-outline") def agent_ebene(key: str) -> str: diff --git a/backend/auto_loop.py b/backend/auto_loop.py index bd05f13..acc618b 100644 --- a/backend/auto_loop.py +++ b/backend/auto_loop.py @@ -17,23 +17,27 @@ async def auto_repair_loop(ebene: str, erst_note: float, reparieren, max_iter: i """Loop QA→Repair für eine Ebene. erst_note : float — bereits gemessene QA-Note vor dem ersten Repair (0–10). - reparieren() -> float: behebt die Befunde UND gibt die neu gemessene Note zurück - (die Repair-Bausteine messen ohnehin am Ende — keine Doppelmessung). + reparieren() -> (float, bool): behebt die Befunde UND gibt die neu gemessene Note + zurück (die Repair-Bausteine messen ohnehin am Ende — keine Doppel- + messung) plus „bewegt": ob die Runde etwas getan hat (Fixes, Merges, + Freisprüche, Nach-Recherche). Eine bewegte Runde darf die Note auch + mal transient senken (neue Blöcke → neue Verdachte) — Stillstand ist + erst, wenn NICHTS mehr passiert UND die Note nicht steigt. - Stopp bei: Note == VOLL (fertig), Note verbessert sich nicht mehr (stillstand), - oder max_iter Runden (limit). Returns {ebene, note, runden, grund} mit - grund ∈ {"fertig","stillstand","limit"} — der Aufrufer nutzt grund für die - Fortschritts-/Systemfehler-Meldung. + Stopp bei: Note == VOLL (fertig), unbewegt ohne Verbesserung (stillstand), + oder max_iter Runden (limit — begrenzt „bewegt ohne Notengewinn"-Livelocks). + Returns {ebene, note, runden, grund} mit grund ∈ {"fertig","stillstand","limit"}. """ if erst_note >= VOLL: return {"ebene": ebene, "note": erst_note, "runden": 0, "grund": "fertig"} note = erst_note for runde in range(1, max_iter + 1): - neu = await reparieren() - log.info("[%s] Auto-Loop Runde %d: %.1f → %.1f", ebene, runde, note, neu) + neu, bewegt = await reparieren() + log.info("[%s] Auto-Loop Runde %d: %.1f → %.1f (%s)", + ebene, runde, note, neu, "bewegt" if bewegt else "unbewegt") if neu >= VOLL: return {"ebene": ebene, "note": neu, "runden": runde, "grund": "fertig"} - if neu <= note: # keine Verbesserung ⇒ weitere Runden zwecklos (kein Repair-Pfad) + if not bewegt and neu <= note: # nichts getan UND keine Verbesserung ⇒ kein Repair-Pfad return {"ebene": ebene, "note": neu, "runden": runde, "grund": "stillstand"} - note = neu + note = max(note, neu) return {"ebene": ebene, "note": note, "runden": max_iter, "grund": "limit"} diff --git a/backend/blocks.py b/backend/blocks.py index 7980501..d83e1ef 100644 --- a/backend/blocks.py +++ b/backend/blocks.py @@ -370,27 +370,60 @@ def _pick_conversion(md: str | None, plain: str | None) -> tuple[str, str] | Non return plain, "pdftotext" +# Small-Caps-/Math-Italic-Artefakte der PDF-Extraktion: LaTeX-\textsc/Kerning liest sich als +# Binnen-Leerzeichen („H ITTING S ET", „N P") und erzeugt in der QA Phantom-Konzepte, die kein +# Block je ankern kann. Regel A: Einzelgroßbuchstabe + GROSSLAUF(≥2) mergen — außer der Lauf +# wird klein fortgesetzt („L NP-vollständig": L ist Variable, NP gehört zum Kompositum). +_PDF_CAPS_SPLIT = re.compile(r"(? 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"(?= 3 else m.group(0) + + return _PDF_LETTER_PAIR.sub(_belegt, text) + + def _convert_pdfs(project: Path) -> None: """Convert PDFs in the project to .txt — agents read text instead of page images. Called before every project generation; converts only if the .txt is missing or older than the PDF. Both converters run; the fidelity guard picks the better result per file. Neither available → hard error instead of an unreliable direct-read mode - (MiniMax image limit, vision cost).""" + (MiniMax image limit, vision cost). Ein Versions-Marker (.pdf-txt-norm) erzwingt nach + Änderungen an der Wort-Entzerrung einmalig die Re-Konvertierung trotz mtime-Cache.""" pdfs = list(project.rglob("*.pdf")) if not pdfs: return + marker = project / ".pdf-txt-norm" + aktuell = marker.exists() and marker.read_text(encoding="utf-8").strip() == str(_PDF_NORM_VERSION) for pdf in pdfs: txt = pdf.with_suffix(".txt") - if txt.exists() and txt.stat().st_mtime >= pdf.stat().st_mtime: + if aktuell and txt.exists() and txt.stat().st_mtime >= pdf.stat().st_mtime: continue picked = _pick_conversion(_pdf_markdown(pdf), _pdf_plaintext(pdf)) if picked is None: raise RuntimeError(f"PDF conversion failed ({pdf.name}): weder pymupdf4llm noch " "pdftotext verfügbar/erfolgreich (pip install pymupdf4llm oder poppler-utils)") text, tool = picked - txt.write_text(text, encoding="utf-8") + txt.write_text(_entzerre_pdf_woerter(text), encoding="utf-8") _log(project.name, f"PDF konvertiert ({tool}): {pdf.name} → {txt.name}") + if not aktuell: + marker.write_text(str(_PDF_NORM_VERSION), encoding="utf-8") _SOURCE_TEMPLATE = {"projekt": "Blocks-Source-Projekt", "uni": "Blocks-Source-Uni", "link": "Blocks-Source-Link"} @@ -1350,28 +1383,6 @@ def _completion_schema(data, n_umbrellas: int, ids: set[int]): return out -# Deterministic backstop to the grouping judge's TEST 1 (type gate): an umbrella may bundle ONLY -# constituent sub-definitions of ONE definition. If a member title carries a standalone-unit signal -# (a named algorithm / problem / reduction / theorem), the umbrella is dissolved — those stay their own -# blocks. Kept narrow so real definition-parts (Konfiguration, Übergangsfunktion δ, Literale, Makespan, -# m Maschinen) never match; checked against the aak over-merge (member „Greedy-Algorithmus GA" hits). -# Suffix-anchored head nouns (German compounds are head-final: „Approximations+algorithmus" has NO word -# boundary before „algorithmus", so \bAlgorithmus\b misses it → the MAX-SAT over-merge). \w* absorbs the -# modifier; the head noun stays the discriminator. FP-safe: no real TM/KNF definition-part ends in these -# heads (Berechnung is deliberately NOT a head → „Akzeptierende Berechnung" stays a valid member). -_GROUP_STANDALONE = re.compile( - r'\w*algorithm(?:us|en)\b|\w*problem(?:e|s|en)?\b|\w*reduktion(?:en)?\b|\bscheduling\b|[≤⪯]' - r'|^\s*(?:Satz|Lemma|Korollar|Theorem|Bemerkung|Beobachtung)\s*\d' - # atomicity: a named COMPLEXITY CLASS / a "…-Vollständigkeit(completeness)" / a "…Transformation" is a - # self-contained concept (learning-object / atomic-KC), never a sub-definition — a bundle of ≥1 such - # member is siblings, not one model → dissolve (catches the P/NP/NP-Vollständigkeit over-merge that NO - # cosine floor separates). Head-final compounds (\w*klasse absorbs "Komplexitäts+klasse"); FP-safe — - # no real TM/KNF/TSP/Scheduling definition-part carries these heads. - r'|\w*vollständigkeit\b|\w*completeness\b|\w*transformation(?:en)?\b|\w*klasse[nr]?\b', re.I) - - - - # --- Outline (blocks artifact: chapter structure, only read by the guide) --- def _outline_review_schema(data, valid: set[int], n_chapters: int, n_blocks: int): @@ -1640,10 +1651,10 @@ async def _guide_ebene(topic: str, instructions: str, provider: str, is_cancelle async def _reparieren(): set_p("Befunde beheben (Guide)…") - await guide_board.repair_karten(topic, fmt) # befundtragende Karten → pruefer + betroffen = await guide_board.repair_karten(topic, fmt) # Karten → pruefer/fix await guide.generate_guide(topic=topic, format_name=fmt, guide_id=guide_id, instructions=instructions, provider=provider) # resumt offene Karten - return await _messen() + return await _messen(), len(betroffen) > 0 res = await auto_repair_loop("Guide", erst, _reparieren) if res["grund"] != "fertig": diff --git a/backend/board_artefacts.py b/backend/board_artefacts.py index 7363b5d..ac60cf9 100644 --- a/backend/board_artefacts.py +++ b/backend/board_artefacts.py @@ -4,27 +4,23 @@ A card is spawned by board 1's `done` column per mirrored block and runs through generate → verify (inkl. Fix-Tail) → artefakte (Gen + Prüfer) → finalize (die verschmolzenen Calls liegen in block_calls.py — 4–5 serielle Segmente statt ~20). finalize (SERIAL) merges the block's results into the global sidecar/facts/pattern/artefakte -files + the DB tables. Danach zwei topic-weite BARRIEREN: `konsolidierung` (cross-block -sub dedup, faltet per repair.falte_sub) und `outline` (prerequisite graph → chapter order), -re-run once per generation run — outline läuft parallel zur Dedup-Barriere.""" +files + the DB tables. Danach eine topic-weite BARRIERE: `outline` (prerequisite graph → +chapter order), re-run once per generation run.""" import asyncio -import hashlib import json import logging import re import database as db import blocks -import embedding from block_calls import _artefakte_block, _generate_block, _verify_block -from blocks import ARTEFACT_TYPES, _match_sub, _neg_set, _sink_json, _outline_block -from config import CROSS_CHUNK_PAARE, EMBEDDING_AKTIV, SUB_DUP_KANDIDAT_COS +from blocks import ARTEFACT_TYPES, _match_sub, _outline_block from fsutil import atomic_write_json from jsonio import read_json_file as _json_file from kanban import Flow, Stage -from pipeline import FAILED, GenContext, _extra, _log, _prompt, _timeout, run_single_slot -from textkit import _norm_title, _title, parse_facts +from pipeline import GenContext, _log +from textkit import _norm_title, _title log = logging.getLogger("creator.board_artefacts") @@ -274,162 +270,6 @@ async def _proc_artefakte(ctx: GenContext, flow: Flow, files: dict, instructions await _gather_cards(ctx, flow, cards, one) -def _cross_schema(data) -> dict[int, str] | None: - """{"pairs": {"1": "a"|"b"|"nein"}} → {pair_nr: verdict} · otherwise None.""" - if not isinstance(data, dict) or not isinstance(data.get("pairs"), dict): - return None - out: dict[int, str] = {} - for k, v in data["pairs"].items(): - try: - nr = int(k) - except (ValueError, TypeError): - continue - s = str(v).strip().casefold() - if s in ("a", "b", "nein"): - out[nr] = s - return out or None - - -async def _proc_konsolidierung(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards): - """BARRIER/drain am RUN-ENDE — cross-block sub dedup: the SAME statement carried by two - blocks (measured on Markdown: tab handling in 3 blocks, HTML blocks, backslash escapes — - the in-block paths never see these). Embedding candidates (block≠block, cos ≥ - SUB_DUP_KANDIDAT_COS) go to a two-judge panel; UNANIMITY decides which block keeps the - statement. Sitzt seit dem Umbau NACH finalize: als Mittel-Barriere wartete jede fertige - Karte auf die langsamste (gemessen: 8:46 min Leerlauf pro Block, kanban-smoke). Der - Verlierer wird per repair.falte_sub gefaltet (variant + Fragen/Artefakte umhängen) — - die wenigen Cross-Dubletten kosten so ein paar umsonst generierte Artefakte statt - Minuten Wandzeit für alle. Fail-open on judge failure/dissent.""" - from repair import falte_sub - topic = flow.topic - work_dir = flow.work_dir - # Resume-Karten aus der alten Stage-Position (Barriere lag vor den Fragen): erst fertig - # generieren — die Barriere feuert erneut, wenn alle wieder hier sind. Direkt dedupen - # ginge schief: finalize würde den gefalteten Sub aus dem Karten-Sidecar re-spiegeln. - nachzuegler = [(c["card_id"], "artefakte" if "sidecar" in c["payload"] else "generate") - for c in cards if "pattern" not in c["payload"]] - if nachzuegler: - await db.kanban_advance_many(topic, BOARD, nachzuegler) - flow.wake.set() - return - - async def _advance_all(): - await db.kanban_advance_many(topic, BOARD, [(c["card_id"], DONE) for c in cards]) - flow.wake.set() - - rows = [r for r in await db.list_subblocks(topic) if r["status"] == "consensus"] - if len(rows) < 2 or not EMBEDDING_AKTIV or not await asyncio.to_thread(embedding.available): - await _advance_all() - return - sims = await asyncio.to_thread(embedding.embed_sims, [r["sub_title"] for r in rows]) - if sims is None: - await _advance_all() - return - negs = [_neg_set(r["sub_title"]) for r in rows] - pairs = [(i, j) for i in range(len(rows)) for j in range(i + 1, len(rows)) - if rows[i]["block_norm"] != rows[j]["block_norm"] and negs[i] == negs[j] - and float(sims[i][j]) >= SUB_DUP_KANDIDAT_COS] - if not pairs: - await _advance_all() - return - - def _kp(r: dict) -> list: - return parse_facts(r.get("facts")).get("key_points") or [] - - def _side(tag: str, r: dict) -> str: - return f"{tag}: [Block: {r['block']}] {r['sub_title']}" + "".join(f"\n - {p}" for p in _kp(r)) - - # chunked judging: ONE call over all pairs scaled its timeout past 50 min, and a hung - # call blocked the barrier for the full window (measured on aak: 196 pairs, 2×54 min) - chunks = [pairs[lo:lo + CROSS_CHUNK_PAARE] for lo in range(0, len(pairs), CROSS_CHUNK_PAARE)] - - async def _urteile_chunk(chunk: list[tuple[int, int]]) -> dict[int, str]: - """Two judges (+ substitute, + tie-breaker) over one pair chunk → {local_k: verdict}; - empty dict = fail-open (pairs stay).""" - lines = "\n\n".join( - f"{k}.\n{_side('A', rows[i])}\n{_side('B', rows[j])}" - for k, (i, j) in enumerate(chunk, 1)) - h = hashlib.md5(lines.encode()).hexdigest()[:8] - paths = [work_dir / f"sub-crossblock-{h}-j{j}.json" for j in (1, 2)] - - async def _judge(j, path, plines, n): - if _cross_schema(_json_file(path)) is not None: - return # resume - status, _v = await run_single_slot( - ctx, f"Sub-Crossblock j{j}", key=f"blocks-{topic}-sub-crossblock-{h}-j{j}", - prompt=_prompt("Subblock-Crossblock", topic=topic, pairs=plines, extra=_extra(instructions)), - role="judge", capabilities="none", - payload=lambda result, p=path: _sink_json(result, p, _cross_schema), - timeout=_timeout("subblock_check", n)) - if status == FAILED: - _log(topic, f"Sub-Crossblock j{j} ohne Ergebnis — fail-open") - - await asyncio.gather(*[_judge(j, p, lines, len(chunk)) for j, p in zip((1, 2), paths)]) - if ctx.is_cancelled(): - return {} - outs = [o for p in paths if (o := _cross_schema(_json_file(p))) is not None] - if len(outs) == 1: # Ersatz-Richter statt fail-open bei EINEM Ausfall - ersatz = work_dir / f"sub-crossblock-{h}-jE.json" - await _judge("E", ersatz, lines, len(chunk)) - if ctx.is_cancelled(): - return {} - outs = [o for p in [*paths, ersatz] if (o := _cross_schema(_json_file(p))) is not None] - if len(outs) != 2: - if outs: - _log(topic, "Sub-Crossblock: nur 1/2 Richter — fail-open") - return {} - final = {k: (outs[0].get(k, "nein") if outs[0].get(k, "nein") == outs[1].get(k, "nein") - else "uneinig") for k in range(1, len(chunk) + 1)} - disputed = [k for k, v in final.items() if v == "uneinig"] - if disputed: # tie-breaker: a third judge sees ONLY the disputed pairs, majority 2/3 - d_lines = "\n\n".join( - f"{x}.\n{_side('A', rows[chunk[k - 1][0]])}\n{_side('B', rows[chunk[k - 1][1]])}" - for x, k in enumerate(disputed, 1)) - p3 = work_dir / f"sub-crossblock-{h}-j3.json" - await _judge(3, p3, d_lines, len(disputed)) - if ctx.is_cancelled(): - return {} - v3 = _cross_schema(_json_file(p3)) or {} - if not v3: - _log(topic, "Sub-Crossblock j3 ohne Ergebnis — strittige Paare bleiben") - for x, k in enumerate(disputed, 1): - t = v3.get(x, "nein") - if t in (outs[0].get(k, "nein"), outs[1].get(k, "nein")): - final[k] = t # majority 2/3; anything else stays disputed → no fold - return final - - chunk_finals = await asyncio.gather(*[_urteile_chunk(c) for c in chunks]) - if ctx.is_cancelled(): - return - final_all: dict[int, str] = {} # global pair index (1-based over `pairs`) → verdict - for cnr, fin in enumerate(chunk_finals): - for k, v in fin.items(): - final_all[cnr * CROSS_CHUNK_PAARE + k] = v - journal = {"paare": len(pairs), "chunks": len(chunks), "gefaltet": [], "verdicts": []} - gone: set[tuple] = set() - for k, (i, j) in enumerate(pairs, 1): - verdict = final_all.get(k, "nein") - journal["verdicts"].append({"a": f"{rows[i]['block']} · {rows[i]['sub_title']}", - "b": f"{rows[j]['block']} · {rows[j]['sub_title']}", - "verdict": verdict}) - if verdict not in ("a", "b"): - continue - win, lose = (rows[i], rows[j]) if verdict == "a" else (rows[j], rows[i]) - wk = (win["block_norm"], win["sub_norm"]) - lk = (lose["block_norm"], lose["sub_norm"]) - if lk in gone or wk in gone: # keeper already folded → don't chain away the content - continue - await falte_sub(topic, files, win, lose) - gone.add(lk) - journal["gefaltet"].append({"weg": f"{lose['block']} · {lose['sub_title']}", - "bleibt": f"{win['block']} · {win['sub_title']}"}) - if journal["gefaltet"]: - _log(topic, f"Sub-Crossblock: {len(journal['gefaltet'])} blockübergreifende Dublette(n) gefaltet") - hg = hashlib.md5("\n".join(f"{i}:{j}" for i, j in pairs).encode()).hexdigest()[:8] - atomic_write_json(work_dir / f"sub-crossblock-{hg}.json", journal, indent=1) - await _advance_all() - - # ── Finalize (SERIAL): merge into the global files + DB tables ───────────────────── def _merge_json(path, block_keys: dict) -> None: data = _json_file(path) diff --git a/backend/board_inventory.py b/backend/board_inventory.py index 379f6b4..bd8a35f 100644 --- a/backend/board_inventory.py +++ b/backend/board_inventory.py @@ -39,7 +39,7 @@ import kanban from kanban import Flow, Stage, chain_stages import blocks from blocks import ( - _FILTER_NOTATION, _GROUP_STANDALONE, + _FILTER_NOTATION, _build_research_prompt, _canonical, _canonical_key, _chunk_nums, _cliques, _completion_schema, _containment_parent, _crawl_index, _file_payload, _filter_schema, _filter_suspect, _is_artifact, _is_named_statement, @@ -47,13 +47,13 @@ from blocks import ( _direction_conflict, _relation_conflict, _root, _supplement_schema, _text_sections, _umbrella_schema, _aspect_marker, _title_variants, _corpus_files, _evidence_pack, _sink_json, source_folder, ) -from config import (QA_GATE_NOTE, QA_GATE_LLM, +from config import (QA_GATE_NOTE, QA_GATE_LLM, LUECKEN_RESEARCH_MAX, DEDUP_GLOBAL_FLOOR, DEDUP_PAIR_FLOOR, DEDUP_PAIRS_CHUNK, DEDUP_TITLE_AUTO, FILTER_CHUNK, FILTER_RECHECK_PANEL, CONSOLIDATION_PANEL, RESEARCH_BATCH, RESEARCH_READERS, RESEARCH_THEMA_AGENTS, BLOCKS_GRUPPIERUNG_AKTIV, EMBEDDING_AKTIV, EMBEDDING_BLOCK_CAP, EMBEDDING_SIBLING_CAP, EMBEDDING_SIBLING_FLOOR, FRAGMENT_MIN_COS, - GROUP_MIN_COS_FLOOR, GROUP_RECONCILE_FLOOR, + GROUP_RECONCILE_FLOOR, GROUP_THEMES_PER_SQRT, ) from fsutil import atomic_write_json, atomic_write_text from jsonio import read_json_file as _json_file @@ -1272,6 +1272,15 @@ async def _proc_dedup(ctx: GenContext, flow: Flow, cards): flow.wake.set() +def _themen_zielband(n: int) -> tuple[int, int]: + """Weiches Prompt-Band der Themenzahl: k = GROUP_THEMES_PER_SQRT·√n, ±30 %, min 2. + n ist die Item-Zahl der GROUPING-WELLE — die Supplement-Welle bekommt bewusst ein + kleines Band aus ihrem kleinen n (sie sortiert nur Neuzugänge nach).""" + k = GROUP_THEMES_PER_SQRT * math.sqrt(max(n, 1)) + lo = max(2, math.floor(0.7 * k)) + return lo, max(lo + 1, math.ceil(1.3 * k)) + + async def _proc_grouping(ctx: GenContext, flow: Flow, cards): """BARRIER/drain — umbrella grouping over the filter survivors: embedding sibling clusters (low floor, high recall) + top-down pass, one judge per cluster, type gate + min-cos backstop, @@ -1300,11 +1309,7 @@ async def _proc_grouping(ctx: GenContext, flow: Flow, cards): all_ids = set(range(1, n + 1)) full_list = "\n".join(f"{i}. {texts[i - 1]}" for i in range(1, n + 1)) h = _h(*[r["card_id"] for r in rows]) - - def _min_cos(idxs): - if len(idxs) < 2: - return 1.0 - return round(min(float(sims[i][j]) for a, i in enumerate(idxs) for j in idxs[a + 1:]), 3) + theme_lo, theme_hi = _themen_zielband(n) async def _assess(tag, cand_text, count): path = work_dir / f"gruppierung-{h}-c{tag}.json" @@ -1312,7 +1317,8 @@ async def _proc_grouping(ctx: GenContext, flow: Flow, cards): status, _v = await run_single_slot( ctx, f"Gruppierung {tag}", key=f"blocks-{topic}-gruppierung-{h}-c{tag}", prompt=_prompt("Blocks-Gruppierung", topic=topic, candidates=cand_text, - list=full_list, out_path=path), + list=full_list, out_path=path, + theme_lo=theme_lo, theme_hi=theme_hi, n_items=n), role="judge", capabilities="files", payload=lambda result, p=path: _umbrella_schema(_json_file(p), all_ids), timeout=_timeout("research_mapping", count)) @@ -1349,7 +1355,6 @@ async def _proc_grouping(ctx: GenContext, flow: Flow, cards): # Bottom-up card-sorting: keine type-gate/min-cos-Vetos mehr — jedes Item soll in # ein Thema; ein „Fehl-Merge" ist billig (Item bleibt als Sub erhalten, keine Lücke). mrows = [rows[m - 1] for m in members] - mc = _min_cos([m - 1 for m in members]) unorm = _norm_title(title) member_norms = {r["title_norm"] for r in mrows} if unorm not in member_norms and unorm in seen_norm: @@ -1358,7 +1363,7 @@ async def _proc_grouping(ctx: GenContext, flow: Flow, cards): continue used.update(members) seen_norm.add(unorm) - chosen.append({"umbrella": title, "description": desc, "min_cos": mc, "members": members}) + chosen.append({"umbrella": title, "description": desc, "members": members}) # reconcile: same parent proposed twice under different titles → union if len(chosen) >= 2: uv = await _vec_rows(flow, [f"{c['umbrella']} — {c['description']}" for c in chosen]) @@ -1462,24 +1467,25 @@ async def _proc_gap_check(ctx: GenContext, flow: Flow, cards): flow.wake.set() -async def _supplement_beleg(ctx: GenContext, flow: Flow, supplements: list) -> list: +async def _supplement_beleg(ctx: GenContext, flow: Flow, supplements: list, tag: str = "") -> list: """Evidence gate for supplement proposals: keyword excerpts per proposal, ONE no-tool judge marks material coverage (ja/nein). Proposals without any matching excerpt drop - immediately; a failed gate keeps nothing (creep is costlier than a lost bonus round).""" + immediately; a failed gate keeps nothing (creep is costlier than a lost bonus round). + `tag` trennt Resume-Datei/Key je Aufrufer (Lücken-Recherche läuft mehrere Runden).""" topic = flow.topic folder = source_folder(topic) packs = [(t, d, _evidence_pack(folder, None, [t], budget=6000)) for t, d in supplements] cands = [(t, d, ev) for t, d, ev in packs if ev] kept: list = [] if cands: - path = flow.work_dir / "supplement-beleg.json" + path = flow.work_dir / f"supplement-beleg{tag}.json" ids = set(range(1, len(cands) + 1)) verdict = _yesno_schema(_json_file(path), ids) if verdict is None: lines = "\n\n".join(f"{k}. {t} — {d}\nAUSZÜGE:\n{ev}" for k, (t, d, ev) in enumerate(cands, 1)) status, verdict = await run_single_slot( - ctx, "Supplement-Beleg", key=f"blocks-{topic}-supplement-beleg", + ctx, "Supplement-Beleg", key=f"blocks-{topic}-supplement-beleg{tag}", prompt=_prompt("Blocks-Supplement-Beleg", topic=topic, proposals=lines, extra=_extra(flow.state.get("instructions", ""))), role="judge", capabilities="none", @@ -1525,11 +1531,21 @@ async def _supplement_producer(ctx: GenContext, flow: Flow, titles: list[str]): # treats). Only proposals the material itself covers may enter the inventory. if supplements and source_folder(topic): supplements = await _supplement_beleg(ctx, flow, supplements) - # Dead lineage: blocks demoted by the fragment filter (and their cluster + title cards) - # must NOT dedup a supplement proposal — their content is gone. A hit on a dead title - # REOPENS the lineage instead: the title card rejoins its cluster (live re-cluster) and - # the respawned block gets a fresh fragment_filter pass. failed-quorum/pre-reject stay - # in the dedup: those were rejected as non-blocks, not lost as content. + new, reopened = await _ingest_proposals(topic, supplements or []) + if new or reopened: + _log(topic, f"Supplement: {new} Block-Kandidat(en) → ingest, {reopened} wiedereröffnet") + flow.wake.set() + + +async def _ingest_proposals(topic: str, supplements: list) -> tuple[int, int]: + """Vorschläge (title, description) als Titel-Karten einspeisen — gemeinsamer Ingest- + Schwanz von Supplement- und Lücken-Recherche. → (neu, wiedereröffnet). + + Dead lineage: blocks demoted by the fragment filter (and their cluster + title cards) + must NOT dedup a proposal — their content is gone. A hit on a dead title REOPENS the + lineage instead: the title card rejoins its cluster (live re-cluster) and the respawned + block gets a fresh fragment_filter pass. failed-quorum/pre-reject stay in the dedup: + those were rejected as non-blocks, not lost as content.""" dead_reasons = {"fragment", "drop-collateral", "drop"} cards = await db.kanban_cards(topic, board=BOARD) dead_clusters = {c["payload"].get("cluster") for c in cards @@ -1563,7 +1579,7 @@ async def _supplement_producer(ctx: GenContext, flow: Flow, titles: list[str]): if (k := _canonical_key(tt)): known_keys.add(k) new = reopened = 0 - for t, d in (supplements or []): + for t, d in supplements: t, d = clean_title(t), clean_title(d) norm = _norm_title(t) key = _canonical_key(t) @@ -1587,9 +1603,57 @@ async def _supplement_producer(ctx: GenContext, flow: Flow, titles: list[str]): card["payload"]["supplement"] = True await db.kanban_set_payload(topic, BOARD, norm, card["payload"]) new += 1 - if new or reopened: - _log(topic, f"Supplement: {new} Block-Kandidat(en) → ingest, {reopened} wiedereröffnet") - flow.wake.set() + return new, reopened + + +async def _luecken_producer(ctx: GenContext, flow: Flow, report: dict) -> int: + """Gezielte Nach-Recherche für offene QA-Lücken (Auto-Loop): EIN Agent sieht Bestand + plus Lücken-Fundstellen (Abschnittstexte deterministisch re-extrahiert, gleicher + Splitter wie die QA) und schlägt NUR dafür Blöcke vor — leere Liste erlaubt. + Danach Evidence-Gate + Ingest wie beim Supplement; neue Titel durchlaufen alle + Inventar-Gates bis done_block. → Zahl neuer/wiedereröffneter Karten.""" + import qa as _qa + topic = flow.topic + lk, kl = _qa.offene_luecken(report) + if not (lk or kl): + return 0 + flow.add_producer() # Engine am Leben halten, solange der Research-Call läuft + try: + texts = _qa._corpus_texts(topic) + sections = {f: _qa._sections(t) for f, t in texts.items()} + gaps = [] + for x in lk[:20]: + sec = sections.get(x.get("datei"), []) + k = int(x.get("abschnitt") or 0) + volltext = sec[k - 1][:1500] if 0 < k <= len(sec) else x.get("vorschau", "") + gaps.append(f"[{x.get('datei')} #{k}]\n{volltext}") + gaps += [f"NAMED RESULT (no covering block): {n}" for n in kl[:20]] + done = await db.kanban_cards(topic, board=BOARD, kind="block", stage="done_block") + runde = flow.state.get("luecken_runde", 0) + path = flow.work_dir / f"luecken-research-r{runde}.json" + supplements = _supplement_schema(_json_file(path)) + if supplements is None: + status, supplements = await run_single_slot( + ctx, f"Lücken-Recherche r{runde}", key=f"blocks-{topic}-luecken-nachfass-r{runde}", + prompt=_prompt("Blocks-Luecken-Research", topic=topic, + blocks="\n".join(f"- {c['payload'].get('title', '')}" for c in done), + gaps="\n\n".join(gaps), out_path=path, + extra=_extra(flow.state.get("instructions", ""))), + role="quick", capabilities="files", + payload=lambda result, p=path: _supplement_schema(_json_file(p)), + timeout=_timeout("ergaenzung")) + if status != OK or supplements is None: + _log(topic, "Lücken-Recherche ohne Ergebnis — übersprungen") + return 0 + if supplements and source_folder(topic): + supplements = await _supplement_beleg(ctx, flow, supplements, tag=f"-l{runde}") + new, reopened = await _ingest_proposals(topic, supplements or []) + if new or reopened: + _log(topic, f"Lücken-Recherche: {new} Kandidat(en) → ingest, {reopened} wiedereröffnet") + flow.wake.set() + return new + reopened + finally: + flow.done_producer() async def _proc_done(ctx: GenContext, flow: Flow, cards): @@ -1731,11 +1795,11 @@ async def run_boards(ctx: GenContext, set_p, files: dict, q: dict, folder, instr stages = chain_stages(stages) if artefacts: # Outline needs every block's TITLE + FACTS (aus generate), nothing later: cut the - # post-generate stages from its barrier so it runs parallel to verify…finalize AND - # zur Cross-Dedup-Barriere des langsamsten Blocks (makespan tail). + # post-generate stages from its barrier so it runs parallel to verify…finalize + # des langsamsten Blocks (makespan tail). outline = next(s for s in stages if s.stage == "outline") outline.upstream = [u for u in outline.upstream if u not in - ("verify", "artefakte", "finalize", "konsolidierung")] + ("verify", "artefakte", "finalize")] producers = _build_producers(ctx, flow, q, folder, instructions) if (research and inventory) else [] async def _as_producer(coro): @@ -1792,17 +1856,24 @@ async def run_boards(ctx: GenContext, set_p, files: dict, q: dict, folder, instr _QA_GATE_POLL = 2.0 # Sekunden zwischen Quiescence-Checks des QA-Wächters +async def _warte_inventar_ruhe(flow: Flow, inv_names: list[str]) -> None: + """Bis das Inventar quiescent ist (Research fertig, keine aktiven Karten in den + Inventar-Stages) oder der Flow stoppt — Poll-Kopf des QA-Wächters, auch nach + Lücken-Recherche-Runden gebraucht (neue Karten laufen erst durch alle Gates).""" + while not flow.stop: + if (flow.research_done and not flow.active_in(inv_names) + and await db.kanban_count(flow.topic, inv_names) == 0): + return + await asyncio.sleep(_QA_GATE_POLL) + + async def _qa_gate_watch(ctx: GenContext, flow: Flow, inv_names: list[str], set_p): """Companion task: once the inventory is quiescent, run the QA once and decide — open the board-2 gate or pause the flow. Fail-OPEN on errors (QA is a helper, not a jailer); qa_force short-circuits to open.""" topic = flow.topic try: - while not flow.stop: - if (flow.research_done and not flow.active_in(inv_names) - and await db.kanban_count(topic, inv_names) == 0): - break - await asyncio.sleep(_QA_GATE_POLL) + await _warte_inventar_ruhe(flow, inv_names) if flow.stop or ctx.is_cancelled(): return if flow.state.get("qa_force"): @@ -1832,16 +1903,33 @@ async def _qa_gate_watch(ctx: GenContext, flow: Flow, inv_names: list[str], set_ flow.wake.set() return - # Auto an: „Befunde beheben"-Loop bis 100 % / Stillstand / 10×. + # Auto an: „Befunde beheben"-Loop bis 100 % / Stillstand / 10×. Offene Lücken + # bekommen erst bis zu LUECKEN_RESEARCH_MAX gezielte Nach-Recherche-Runden; + # das Lücken-Urteil (Freispruch/bestätigt) fällt erst, wenn die Recherche + # nichts mehr bewegt — sonst würde eine füllbare Lücke vorschnell verurteilt. from auto_loop import auto_repair_loop import repair as _repair async def _reparieren(): + rep = qa.latest_report(topic) or {} + research_bewegt = False + runde = flow.state.get("luecken_runde", 0) + lk, kl = qa.offene_luecken(rep) + if (lk or kl) and runde < LUECKEN_RESEARCH_MAX: + flow.state["luecken_runde"] = runde + 1 + set_p(f"Lücken-Recherche {runde + 1}/{LUECKEN_RESEARCH_MAX}…") + neue = await _luecken_producer(ctx, flow, rep) + research_bewegt = neue > 0 + if neue: # neue Titel durchlaufen alle Gates — erst Ruhe, dann messen + await _warte_inventar_ruhe(flow, inv_names) + if flow.stop or ctx.is_cancelled(): + return float(flow.state.get("qa_note") or 0.0), False set_p("Befunde beheben (Inventar)…") - r = await _repair.repair_befunde(topic, "inventory") # behebt + misst neu + r = await _repair.repair_befunde(topic, "inventory", + luecken_urteil=not research_bewegt) n = float(r.get("note", 10.0)) flow.state["qa_note"] = n - return n + return n, bool(r.get("aktionen", 0)) or research_bewegt res = await auto_repair_loop("Inventar", note, _reparieren) end_note = res["note"] @@ -1899,7 +1987,7 @@ async def _artefakt_auto_loop(ctx: GenContext, flow: Flow, set_p): async def _reparieren(): set_p("Befunde beheben (Artefakte)…") r = await _repair.repair_befunde(topic, "artefacts") - return float(r.get("note_artefakte") or 10.0) + return float(r.get("note_artefakte") or 10.0), bool(r.get("aktionen", 0)) res = await auto_repair_loop("Artefakte", na, _reparieren) flow.state["note_artefakte"] = res["note"] @@ -1986,7 +2074,6 @@ COLUMNS = [ ("artefacts", "verify", "Prüfen", "ablock"), ("artefacts", "artefakte", "Lernmittel", "ablock"), ("artefacts", "finalize", "Zusammenführen", "ablock"), - ("artefacts", "konsolidierung", "Konsolidierung", "ablock"), ("artefacts", "outline", "Gliederung", "outline"), ("artefacts", "done_artefact", "Fertig", "ablock"), ] @@ -2053,13 +2140,15 @@ async def board_snapshot(topic: str, limit: int = 20) -> dict: def _qa_view(topic: str, counts: dict, flow) -> dict | None: """Latest QA report digest for the board header. `pausiert` = the gate stopped the - flow (score below threshold, board-2 cards waiting, no flow running).""" + flow (score below 100 %, board-2 cards waiting, no flow running) — das Auto-Gate + öffnet nur bei VOLL; eine 9.6 pausierte den Loop, galt hier aber nicht als pausiert.""" # no inventory (deleted/never built) → no badge; the report files stay on purpose, # so the first run after a rebuild diffs against the old state if not counts.get("inventory", {}).get("done_block", 0) and not ( flow and flow.state.get("qa_note") is not None): return None import qa + from auto_loop import VOLL r = qa.latest_report(topic) if r is None: return None @@ -2067,9 +2156,9 @@ def _qa_view(topic: str, counts: dict, flow) -> dict | None: if note is None: return None wartend = counts.get("artefacts", {}).get("generate", 0) - pausiert = bool(note < QA_GATE_NOTE and wartend and flow is None) + pausiert = bool(note < VOLL and wartend and flow is None) return {"note": note, "note_artefakte": r.get("note_artefakte"), - "schwelle": QA_GATE_NOTE, "pausiert": pausiert, + "schwelle": VOLL, "pausiert": pausiert, "quoten": r.get("quoten", {}), "befunde": (r.get("fremd", []) + r.get("unecht", []))[:6]} diff --git a/backend/config.py b/backend/config.py index 4921fb9..e512273 100644 --- a/backend/config.py +++ b/backend/config.py @@ -75,9 +75,6 @@ SEED_COVER_COS = 0.80 # (Markdown: 50 pairs in the band, 4 above) — below every auto-merge threshold, so an LLM # judge decides. Candidates only; a merge still needs judge unanimity. SUB_DUP_KANDIDAT_COS = 0.75 -# Cross-block judge pairs per call: ONE call over all pairs scaled its timeout to 54 min -# and a hung call blocked the barrier that long (aak: 196 pairs) — chunks cap it at ~15 min. -CROSS_CHUNK_PAARE = 40 # Umbrella grouping (block granularity level 2, step "Blocks-Gruppierung", AFTER the filter): # collapse sibling DEFINITIONS that are components of ONE umbrella concept (TM model: @@ -94,16 +91,13 @@ EMBEDDING_SIBLING_CAP = 18 # a rich model (TM) can have many constituent p # titles (e.g. two „Turingmaschine"-umbrellas). Merge umbrella pairs whose title+description cosine is # ≥ this (conservative → only true same-parent duplicates, never two distinct umbrellas). GROUP_RECONCILE_FLOOR = 0.75 -# Over-merge backstop ONLY (no-structure floor). Research (meronymy ≠ similarity): parts of ONE model are -# legitimately DISSIMILAR (TM: Alphabet/Konfiguration/δ ~0.22), while distinct same-type concepts (P/NP/…) -# are SIMILAR (~0.85) — so member-vs-member cosine is the WRONG instrument for over-merge (empirically -# inverted: TM 0.218 < the P/NP bundle 0.227). The real precision floor is the ATOMICITY type-guard -# (_GROUP_STANDALONE: a member that is a named algorithm/problem/theorem/complexity-class dissolves the -# umbrella). This floor is demoted to a near-zero backstop that only rejects a literally structureless -# chain (random-pair baseline), set BELOW the legitimate heterogeneous minimum so it never kills a real model. -GROUP_MIN_COS_FLOOR = 0.15 -# Fragment-demote backstop, same logic as GROUP_MIN_COS_FLOOR: fragment↔parent cosine is a BAD -# fragment detector (measured, Markdown run: wrong demotes Blockzitate→Codeblöcke 0.353 and +# Zielband der Themen-Blockzahl im Gruppierungs-Prompt: k = GROUP_THEMES_PER_SQRT·√n +# (n = Items der Grouping-Welle), Band [0.7k, 1.3k] — weiche Vorgabe, kein Cap. √n hält +# Blockzahl UND mittlere Blockgröße sublinear (n=30 → ~4–8, n=285 → ~12–22; das alte +# statische „15–25" passte implizit nur zu n≈225–625). +GROUP_THEMES_PER_SQRT = 1.0 +# Fragment-demote backstop (no-structure floor): fragment↔parent cosine is a BAD fragment +# detector (measured, Markdown run: wrong demotes Blockzitate→Codeblöcke 0.353 and # Zeichenkodierung→Überschriften 0.640 sit ABOVE any usable floor, while true NP proof-gadget # demotes αu-Variablen→Cook/Levin 0.172 sit low). So this only vetoes judge/panel demotes with # NO containment match whose pair is literally structureless (Emoji→Tabelle 0.136). @@ -135,10 +129,15 @@ CONSENSUS_GRACE = 300 # check loops leave any remaining objections standing after that. CONSENSUS_MAX_ROUNDS = 3 -# QA gate: after the inventory phase an automatic QA run scores the blocks; below the -# threshold the flow PAUSES before board 2 burns tokens (frontend offers force-continue). -QA_GATE_NOTE = 9.5 # 0 = gate off; quota-based, so the tolerated finding count scales with topic size +# QA gate: after the inventory phase an automatic QA run scores the blocks; below 100 % +# the flow PAUSES before board 2 burns tokens (frontend offers force-continue). +# QA_GATE_NOTE ist nur noch der An/Aus-Schalter (> 0 = Gate aktiv) — das Gate selbst +# öffnet ausschließlich bei 100 % (auto_loop.VOLL); Repair/Recherche arbeiten dahin. +QA_GATE_NOTE = 9.5 # 0 = gate off QA_GATE_LLM = True # include the LLM samples (Echtheit/Dubletten) in the gate run +# Lücken-Recherche im Auto-Loop: max. gezielte Nach-Recherche-Runden pro Lauf, bevor die +# Rest-Lücken dem Stichentscheid vorgelegt werden (Repair-Budget, keine Detektor-Konstante). +LUECKEN_RESEARCH_MAX = 2 # Inline evidence for judge agents: corpus excerpts go INTO the prompt instead of letting # every judge re-search the source folder (measured: ~10 tool turns/judge, 82 % of the diff --git a/backend/database.py b/backend/database.py index 2d39ec9..1ac8cc5 100644 --- a/backend/database.py +++ b/backend/database.py @@ -702,6 +702,22 @@ async def set_block_status(topic: str, title_norm: str, status: str, title: str await _update("blocks", fields, {"topic": topic, "title_norm": title_norm}) +async def rename_block_norm(topic: str, alt_norm: str, neu_norm: str, neu_titel: str) -> None: + """Block-Norm vollständig um-keyen (Repair: `(n)`-Kollisionssuffix ablegen): blocks- + Spiegel PLUS alle angehängten Tabellen in einer Transaktion — anders als + set_block_status(neu_norm=…) auch mit vorhandenen Subblocks/Fragen/Artefakten sicher.""" + async with _tx() as db: + for table in ("subblocks", "question_pattern", "sub_artefakte"): + await db.execute( + f"UPDATE {table} SET block_norm = ?, block = ?, updated_at = ? " + "WHERE topic = ? AND block_norm = ?", + (neu_norm, neu_titel, _now(), topic, alt_norm)) + await db.execute( + "UPDATE blocks SET title_norm = ?, title = ?, updated_at = ? " + "WHERE topic = ? AND title_norm = ?", + (neu_norm, neu_titel, _now(), topic, alt_norm)) + + async def delete_blocks(topic: str) -> None: async with _tx() as db: await db.execute("DELETE FROM blocks WHERE topic = ?", (topic,)) @@ -945,6 +961,35 @@ async def list_runs(topic: str, limit: int = 10) -> list[dict]: return out +async def latest_board_runs(topic: str) -> dict: + """Jüngster Lauf MIT Daten je Ebene — Anzeige-Quelle der Board-Kopfzeilen im Frontend. + „Nur Artefakte"-/Guide-Läufe bekommen eine frische run_id und lassen die anderen Ebenen + leer; der jüngste Lauf allein zeigt dann nichts. Hier zählt je Ebene der letzte Lauf, + der sie wirklich enthielt. inventory/artefacts via meta.$.board (agents.agent_ebene); + guide via run_id-Suffix „-g"+4hex (guide_board.run_guide_board setzt den Marker, + Blocks-Suffixe sind reines Hex — enthalten nie „g"). + → {ebene: {run_id, aktiv, agents, tokens, start, ende} | None}""" + db = await get_db() + out: dict = {} + for b in ("inventory", "artefacts"): + cur = await db.execute( + "SELECT run_id FROM events WHERE topic = ? AND kind = 'agent' AND run_id != '' " + "AND json_extract(meta,'$.board') = ? ORDER BY ts DESC LIMIT 1", (topic, b)) + row = await cur.fetchone() + out[b] = None if row is None else { + "run_id": row[0], "aktiv": _current_run.get(topic) == row[0], + **await events_run_summary(topic, row[0], board=b)} + cur = await db.execute( + "SELECT run_id FROM events WHERE topic = ? AND kind = 'agent' AND run_id != '' " + "AND run_id GLOB '*-g[0-9a-f][0-9a-f][0-9a-f][0-9a-f]' ORDER BY ts DESC LIMIT 1", + (topic,)) + row = await cur.fetchone() + out["guide"] = None if row is None else { + "run_id": row[0], "aktiv": _current_run.get(topic) == row[0], + **await events_run_summary(topic, row[0])} # Guide: ganzer Lauf (Events sind untagged) + return out + + async def kanban_dead(topic: str) -> list[dict]: """Dead-letter cards across boards (for the board UI + requeue).""" return await kanban_cards(topic, stage="dead") diff --git a/backend/fake_agents.py b/backend/fake_agents.py index adf083a..e6684b7 100644 --- a/backend/fake_agents.py +++ b/backend/fake_agents.py @@ -21,7 +21,6 @@ _PATH_RE = re.compile(r"(/\S+\.(?:json|md))") _NUM_RE = re.compile(r"^\s*(\d+)[.)]\s+(.*\S)", re.MULTILINE) _SUBLIST_RE = re.compile(r"^- (?:\[(\w+)\] )?(.+\S)\s*$", re.MULTILINE) _ZIEL_RE = re.compile(r"\(([a-z]\d+)\)") -_PAIR_RE = re.compile(r"^(\d+)\.\s*\nA: \[Block: (.*?)\] (.*?)\n", re.MULTILINE) def _norm(s: str) -> str: @@ -30,7 +29,7 @@ def _norm(s: str) -> str: class Welt: """Deterministisches Themen-Modell. bloecke: {titel: {"beschreibung": str, - "subs": [titel]}}; optionale Regeln steuern Konsolidierung/Cross-Block.""" + "subs": [titel]}}; optionale Regeln steuern das Störfall-Verhalten.""" def __init__(self, bloecke: dict | None = None, *, gruppen: list | None = None, kataloge: list | None = None, stoerungen: list | None = None): @@ -149,8 +148,8 @@ class Welt: if "-supplement-beleg" in key or "-anker-beleg-" in key: nums = {m.group(1) for m in _NUM_RE.finditer(prompt)} return j({"relevant": {k: "ja" for k in sorted(nums, key=int)} or {"1": "ja"}}) - if "-supplement" in key: - return j({"blocks": []}) + if "-supplement" in key or "-luecken-nachfass-" in key: + return j({"blocks": []}) # Fake-Welt ist vollständig — Lücken-Research findet nichts if "-source-relevance-" in key: nums = {m.group(1) for m in _NUM_RE.finditer(prompt)} return j({"relevant": {k: "ja" for k in sorted(nums, key=int)} or {"1": "ja"}}) @@ -197,11 +196,6 @@ class Welt: "uebernehmen": unsicher, "facts_probleme": [], "levels": {}, "relevanz": {}}) if "-sb-fix-" in key: return j({"subs": []}) - if "-sub-crossblock-" in key: - urteile = {} - for m in _PAIR_RE.finditer(prompt): - urteile[m.group(1)] = "a" # identischer Text (nur so wird gepaart) → A behält - return j({"pairs": urteile or {"1": "nein"}}) if "-art-gen-" in key: subs = self._subs_im_prompt(prompt) t = (self._bloecke_im_prompt(prompt) or ["?"])[0] @@ -308,7 +302,6 @@ def aktivieren(welt: Welt, setattr_fn=setattr) -> None: import agents import blocks - import board_artefacts as ba import board_inventory as bi import guide import guide_board @@ -360,7 +353,7 @@ def aktivieren(welt: Welt, setattr_fn=setattr) -> None: _find = staticmethod(_real_emb._find) _union = staticmethod(_real_emb._union) - for mod in (blocks, ba, qa, bi): + for mod in (blocks, qa, bi): setattr_fn(mod, "embedding", _FakeEmb) # _emb_ok bleibt echt (True über _FakeEmb): Bottom-up braucht den Grouping-Stage. # _FakeEmb bildet nur bei identischem Text Nachbarn — verschiedene Atome erzeugen keine diff --git a/backend/guide_board.py b/backend/guide_board.py index 0c34f67..b5cb691 100644 --- a/backend/guide_board.py +++ b/backend/guide_board.py @@ -643,6 +643,8 @@ async def run_guide_board(guide_id: str, topic: str, format_name: str, entries: is_cancelled=lambda: is_guide_cancelled(guide_id), guide_id=guide_id) import uuid from datetime import datetime, timezone + # „-g"+hex4-Suffix markiert Guide-Läufe (Blocks-Suffixe sind reines Hex) — + # database.latest_board_runs erkennt Guide-run_ids an genau diesem Muster. db.set_current_run(topic, f"{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M')}-g{uuid.uuid4().hex[:4]}") try: spec = (TEMPLATES_DIR / "Format" / "Section.md").read_text(encoding="utf-8") @@ -751,33 +753,58 @@ async def board_snapshot(topic: str, format_name: str, limit: int = 20) -> dict: async def repair_karten(topic: str, format_name: str) -> list[str]: - """QA-Befund-getriebenes Guide-Repair: Karten, die im jüngsten Guide-QA-Report - Befunde tragen, gehen zurück auf `pruefer` (md bleibt) — Prüfer+Fix beheben gezielt, - generate_guide resumt die offenen Karten und misst am Ende neu. Pendant zum - Blocks-Repair („Score unter 10 muss einen Fix-Pfad haben"). → betroffene Blocktitel.""" + """QA-Befund-getriebenes Guide-Repair (Pendant zum Blocks-Repair: „Score unter 10 + muss einen Fix-Pfad haben"). Kritische Befunde (marker_fehlend/ziel_ohne_anker/ + fachlich_falsch) → zurück auf `pruefer` (brauchen Facts-/Ziele-Kontext + Re-Prüfer). + Reine Stil-Befunde (redundanz/laengen/lesbarkeit) → direkt auf `fix` mit expliziten + Auftragszeilen: der Prüfer sieht pro Karte nur die EIGENE Section und kann + Cross-Karten-Redundanz nie wiederfinden — der Fix bekommt den Kontext hier hinein + (kein KRITISCH-Präfix → genau EIN Rewrite ohne Re-Prüfer). generate_guide resumt + die offenen Karten und misst am Ende neu. → betroffene Blocktitel.""" import qa as qa_mod + from guide_qa import block_budget, LAENGE_BAND reports = qa_mod.report_paths(topic, guide=True) rep = _json_file(reports[-1]) if reports else None if not rep: return [] cards = {c["block_norm"]: c for c in await db.list_guide_cards(topic, format_name)} - norms: set[str] = set() + kritisch: set[str] = set() for e in rep.get("marker_fehlend", []): # "Block · sub" - norms.add(_norm_title(str(e).split(" · ")[0])) + kritisch.add(_norm_title(str(e).split(" · ")[0])) for e in rep.get("ziel_ohne_anker", []): # "block_norm · (id) text" - norms.add(str(e).split(" · ")[0]) - for e in rep.get("laengen_ausreisser", []): # {"block": titel} - norms.add(_norm_title(e.get("block", "") if isinstance(e, dict) else str(e))) - for e in rep.get("lesbarkeit", []): # "Block: hinweis" - norms.add(_norm_title(str(e).split(":")[0])) + kritisch.add(str(e).split(" · ")[0]) for t in rep.get("fachlich_falsch", []) or []: - norms.add(_norm_title(str(t))) + kritisch.add(_norm_title(str(t))) + stil: dict[str, list[str]] = {} for e in rep.get("redundanz", []): # {"a": "Block: absatz", "b": …} - for seite in ("a", "b"): - norms.add(_norm_title(str(e.get(seite, "")).split(":")[0])) + a, b = str(e.get("a", "")), str(e.get("b", "")) + bn = _norm_title(b.split(":")[0]) + auszug = b.split(":", 1)[1].strip() if ":" in b else b + stil.setdefault(bn, []).append( + f"- BALLAST (kürzen): Absatz doppelt zu Block «{a.split(':')[0]}» („{auszug}…\") — " + "hier straffen, der Inhalt bleibt im anderen Block") + for e in rep.get("laengen_ausreisser", []): # {"block", "zeichen", "budget"} + if not isinstance(e, dict): + continue + bn = _norm_title(e.get("block", "")) + budget = e.get("budget") or block_budget([]) + lo, hi = LAENGE_BAND + stil.setdefault(bn, []).append( + f"- LÄNGE: {e.get('zeichen')} Zeichen (Budget {budget}, erlaubt " + f"{round(lo * budget)}–{round(hi * budget)}): schreibe den ausführlich-Teil auf etwa " + f"{budget} Zeichen GESAMT um — Sockel-Prosa und Wiederholungen streichen, " + "alle Sub-Marker und Beispiele behalten") + for e in rep.get("lesbarkeit", []): # "Block: hinweis" + bn = _norm_title(str(e).split(":")[0]) + hint = str(e).split(":", 1)[1].strip() if ":" in str(e) else str(e) + stil.setdefault(bn, []).append(f"- LESBARKEIT: {hint}") betroffen = [] - for n in sorted(norms & set(cards)): - await db.set_guide_card(topic, format_name, n, stage="pruefer", status="open", gate_info="") + for n in sorted((kritisch | set(stil)) & set(cards)): + if n in kritisch: + await db.set_guide_card(topic, format_name, n, stage="pruefer", status="open", gate_info="") + else: + await db.set_guide_card(topic, format_name, n, stage="fix", status="open", + gate_info="\n".join(stil[n])) betroffen.append(cards[n]["block"]) return betroffen diff --git a/backend/qa.py b/backend/qa.py index fdf9ca4..e72c95c 100644 --- a/backend/qa.py +++ b/backend/qa.py @@ -11,6 +11,7 @@ previous report of the same topic. """ import asyncio +import hashlib import logging import re import sys @@ -277,8 +278,10 @@ def hygiene(blocks: list[dict]) -> list[dict]: def _zaehlbare_luecken(lk: list[dict], llm: bool) -> list[dict]: """With --llm only non-refuted gaps count ('?' = unjudged stays, conservative) — refuted - ones dragged the note although the judge cleared them (aak: 5 of 8, weight 3.0).""" - return [x for x in lk if x.get("llm") != "nein"] if llm else lk + ones dragged the note although the judge cleared them (aak: 5 of 8, weight 3.0). + Persistierte Freisprüche (2:1 im Repair) zählen nie.""" + offen = [x for x in lk if not x.get("freispruch")] + return [x for x in offen if x.get("llm") != "nein"] if llm else offen def note(quoten: dict, gewichte: dict = NOTE_GEWICHTE) -> float: @@ -335,11 +338,13 @@ def _qa_prompt(name: str, **kwargs) -> str: async def judge_wave(template: str, topic: str, key: str, slot: str, items: list[str], - *, chunk: int = JUDGE_CHUNK, prefix: str = "qa", label: str = "QA") -> dict[int, str]: + *, chunk: int = JUDGE_CHUNK, prefix: str = "qa", label: str = "QA", + extra: str = "") -> dict[int, str]: """Gechunkte Ja/Nein-Judge-Welle über ALLE Items, Chunks parallel (die Semaphoren in agents.py begrenzen); Ergebnis mit globalen 1-basierten Indizes. Fail-open pro Chunk (Items bleiben ohne Urteil), aber nie stumm. Ersetzt die drei strukturgleichen - Handkopien in repair/qa/guide_qa.""" + Handkopien in repair/qa/guide_qa. `extra` füllt den {extra}-Slot des Templates + (z. B. Inventar-Kontext für das Lücken-Urteil im Repair).""" from agents import run_agent from pipeline import _timeout, _yesno_schema from jsonio import parse_json_text @@ -349,7 +354,7 @@ async def judge_wave(template: str, topic: str, key: str, slot: str, items: list listing = "\n\n".join(f"{k}. {it}" for k, it in enumerate(teil, 1)) try: rc, out, _err = await run_agent( - f"{prefix}-{topic}-{key}-{lo}", _qa_prompt(template, topic=topic, extra="", **{slot: listing}), + f"{prefix}-{topic}-{key}-{lo}", _qa_prompt(template, topic=topic, extra=extra, **{slot: listing}), _timeout("qa_judge"), role="judge", capabilities="none", scope=topic, label=f"{label} {key}") except Exception: log.exception("[%s] %s-Judge %s+%d fehlgeschlagen — Items ohne Urteil", topic, label, key, lo) @@ -406,6 +411,22 @@ def _paar_key(a: str, b: str) -> str: return "||".join(sorted((_norm_title(a), _norm_title(b)))) +def luecken_key(item: dict) -> str: + """Stabiler Freispruch-Schlüssel einer Lücken-Fundstelle: Datei + Vorschau-Hash. + Abschnittsnummern verschieben sich bei Korpusänderung — dann veralten die Keys + und werden inert (gewollt: neue Fundstellen brauchen ein neues Urteil).""" + v = " ".join(str(item.get("vorschau", "")).casefold().split()) + return f"{item.get('datei', '')}||{hashlib.sha1(v.encode()).hexdigest()[:16]}" + + +def offene_luecken(report: dict) -> tuple[list[dict], list[str]]: + """Zählbare offene Lücken eines Reports: (luecken-Items ohne Freispruch/LLM-nein, + konzept_luecken-Namen). Grundlage für Lücken-Research und Lücken-Urteil im Repair.""" + lk = [x for x in report.get("luecken", []) + if not x.get("freispruch") and x.get("llm") != "nein"] + return lk, list(report.get("konzept_luecken", [])) + + def lade_freispruch(topic: str) -> dict[str, list[str]]: """Persistierte 2:1-Freisprüche des Repair-Stichentscheids (repair._mit_stichentscheid): mehrheitlich als „behalten" geurteilte Befunde zählen nicht mehr in die Note — sonst @@ -445,16 +466,31 @@ async def qa_report(topic: str, llm: bool = False) -> dict | None: frei_fremd = set(frei.get("fremd") or []) fremd_frei = [t for t in fr if _norm_title(t) in frei_fremd] fr = [t for t in fr if _norm_title(t) not in frei_fremd] + # Persistierte 2:1-Freisprüche: markiert bleibt sichtbar (diffbar), zählt aber nicht mehr + # in Quote und Judge-Welle — sonst pendelte der Verdachts-Floor ewig unter 10. + frei_dub = set(frei.get("dubletten") or []) + for p in d: + if _paar_key(p["a"], p["b"]) in frei_dub: + p["freispruch"] = True + d_offen = [p for p in d if not p.get("freispruch")] + frei_lk = set(frei.get("luecken") or []) + for x in lk: + if luecken_key(x) in frei_lk: + x["freispruch"] = True + lk_offen = [x for x in lk if not x.get("freispruch")] + frei_kl = set(frei.get("konzept_luecken") or []) + kl_frei = [nm for nm in kl if _norm_title(nm) in frei_kl] + kl = [nm for nm in kl if _norm_title(nm) not in frei_kl] - if llm and d: + if llm and d_offen: v = await judge_wave("QA-Dubletten", topic, "dubletten", "pairs", - [f"A: {p['a']}\nB: {p['b']}" for p in d[:LLM_SAMPLE]]) - for k, p in enumerate(d[:LLM_SAMPLE], 1): + [f"A: {p['a']}\nB: {p['b']}" for p in d_offen[:LLM_SAMPLE]]) + for k, p in enumerate(d_offen[:LLM_SAMPLE], 1): p["llm"] = v.get(k, "?") - if llm and lk: + if llm and lk_offen: v = await judge_wave("QA-Luecken", topic, "luecken", "sections", - [f"[{x['datei']} #{x['abschnitt']}] {x['vorschau']}" for x in lk[:LLM_SAMPLE]]) - for k, x in enumerate(lk[:LLM_SAMPLE], 1): + [f"[{x['datei']} #{x['abschnitt']}] {x['vorschau']}" for x in lk_offen[:LLM_SAMPLE]]) + for k, x in enumerate(lk_offen[:LLM_SAMPLE], 1): x["llm"] = v.get(k, "?") if llm and sd: # full coverage in chunks — a sampled quota would mislead the note v = await judge_wave("QA-Sub-Dubletten", topic, "sub-dubletten", "pairs", @@ -502,7 +538,7 @@ async def qa_report(topic: str, llm: bool = False) -> dict | None: "topic": topic, "erstellt": datetime.now(timezone.utc).isoformat(), "run_id": summary.get("run_id", ""), "bloecke": len(blocks), "quoten": { - "dubletten_verdacht": round(len(d) / max(len(blocks), 1), 3), + "dubletten_verdacht": round(len(d_offen) / max(len(blocks), 1), 3), "luecken": round(len(_zaehlbare_luecken(lk, llm)) / n_sections, 3), "fremd": round(len(fr) / max(len(blocks), 1), 3), **({"konzept_luecken": round(len(kl) / max(len(named), 1), 3)} if corpus else {}), @@ -512,6 +548,7 @@ async def qa_report(topic: str, llm: bool = False) -> dict | None: "quoten_artefakte": quoten_art, **({"unecht": unecht} if unecht is not None else {}), **({"fremd_freigesprochen": fremd_frei} if fremd_frei else {}), + **({"konzept_luecken_freigesprochen": kl_frei} if kl_frei else {}), "dubletten": d, "sub_dubletten": sd, "luecken": lk, "konzept_luecken": kl, "fremd": fr, "beleg": bl, "hygiene": hy, "artefakte": art, diff --git a/backend/repair.py b/backend/repair.py index 0facc5e..1183ac5 100644 --- a/backend/repair.py +++ b/backend/repair.py @@ -2,16 +2,17 @@ Blindes Re-Filtern reproduziert die blinden Flecken der Pipeline (sie hat die Befunde ja durchgelassen). Hier fließen die QA-BEFUNDE als Input in gezielte Aktionen: Hygiene -deterministisch, bestätigte Dubletten mergen (Zweitmeinung), Fremd/Unecht nur nach -Gegen-Judge entfernen (fail-open: Zweifel/Fehler → behalten). Lücken brauchen Recherche, -Verwaiste den nächsten Board-2-Lauf — beides wird nur ausgewiesen.""" +deterministisch (+ Beschreibung/Suffix per Sanierung/Rename), bestätigte Dubletten mergen +(Zweitmeinung), Fremd/Unecht nur nach Gegen-Judge entfernen, Beleg-Nachfass je Sub, +Lücken per Stichentscheid klären (Freispruch oder bestätigt = braucht Recherche). +Fail-open überall: Zweifel/Judge-Fehler → behalten, nichts persistieren.""" import logging import re import database as db import qa -from blocks import _blocks_files, _evidence_pack, source_folder +from blocks import _blocks_files, _evidence_pack, material_folder, source_folder from config import EVIDENCE_PER_BLOCK from fsutil import atomic_write_json from jsonio import read_json_file as _json_file @@ -20,9 +21,12 @@ from textkit import _norm_title, _title, clean_title, parse_facts log = logging.getLogger("creator.repair") -async def repair_befunde(topic: str, ebene: str | None = None) -> dict: - """ebene=None → alle Fixes; „inventory" → nur Inventar (Hygiene/Merges/Fremd); - „artefacts" → nur Artefakte (Sub-Merges/Waisen). Misst am Ende IMMER beide Noten neu.""" +async def repair_befunde(topic: str, ebene: str | None = None, luecken_urteil: bool = True) -> dict: + """ebene=None → alle Fixes; „inventory" → nur Inventar; „artefacts" → nur Artefakte. + luecken_urteil: offene Lücken/Konzept-Lücken dem Stichentscheid vorlegen (Freispruch + oder bestätigt) — der QA-Gate-Watch schaltet es aus, solange seine Lücken-Recherche + noch Runden hat. Misst am Ende IMMER beide Noten neu. `aktionen` zählt alles, was die + Runde getan hat (Fixes+Merges+Freisprüche) — der Auto-Loop stoppt erst bei 0.""" reports = qa.report_paths(topic) report = _json_file(reports[-1]) if reports else None if not report: @@ -34,9 +38,13 @@ async def repair_befunde(topic: str, ebene: str | None = None) -> dict: art = ebene in (None, "artefacts") hygiene = await _fix_hygiene(topic, report, by_norm, files) if inv else [] - merges = await _merge_dubletten(topic, report, by_norm, files) if inv else [] + hygiene += (await _fix_beschreibungen(topic, report, by_norm)) if inv else [] + hygiene += (await _fix_suffixe(topic, report, by_norm, files)) if inv else [] + merges, frei_paare = (await _merge_dubletten(topic, report, by_norm, files)) if inv else ([], []) sub_merges, frei_subs = (await _merge_sub_dubletten(topic, report, files)) if art else ([], []) entfernt, frei_bloecke = (await _entferne_fremd_unecht(topic, report, by_norm, files)) if inv else ([], []) + frei_luecken = (await _pruefe_luecken(topic, report, by_norm)) if inv and luecken_urteil else [] + beleg_fix = (await _belege_nachfassen(topic, report, files)) if art else [] aufgeraeumt = (await _raeume_waisen(topic)) if art else 0 # llm=True: gleiche Messlatte wie QA-Button/Abschluss-QA — der llm=False-Report @@ -45,16 +53,22 @@ async def repair_befunde(topic: str, ebene: str | None = None) -> dict: if neu: await qa.write_report(neu) na = neu.get("note_artefakte") if neu else None + freigesprochen = frei_paare + frei_subs + frei_bloecke + frei_luecken + lk_rest, kl_rest = qa.offene_luecken(neu or {}) return {"hygiene": hygiene, "merges": merges, "sub_merges": sub_merges, "entfernt": entfernt, - "aufgeraeumt": aufgeraeumt, "freigesprochen": frei_subs + frei_bloecke, - "braucht_research": len(report.get("luecken", [])), + "beleg_fix": beleg_fix, "aufgeraeumt": aufgeraeumt, "freigesprochen": freigesprochen, + "aktionen": (len(hygiene) + len(merges) + len(sub_merges) + len(entfernt) + + len(beleg_fix) + aufgeraeumt + len(freigesprochen)), + "braucht_research": len(lk_rest) + len(kl_rest), # offene Lücken NACH dieser Runde "note": float(neu["note"]) if neu else 10.0, # neu gemessene Inventar-Note "note_artefakte": float(na) if na is not None else None} # None solange Board 2 leer -async def _judge(template: str, topic: str, key: str, slot: str, items: list[str]) -> dict[int, str]: +async def _judge(template: str, topic: str, key: str, slot: str, items: list[str], + extra: str = "") -> dict[int, str]: """No-Tool-Judge-Welle (fail-open: Fehler → leeres Verdikt = behalten).""" - return await qa.judge_wave(template, topic, key, slot, items, prefix="repair", label="Repair") + return await qa.judge_wave(template, topic, key, slot, items, prefix="repair", label="Repair", + extra=extra) def _speichere_freispruch(topic: str, kategorie: str, keys: list[str]) -> None: @@ -67,7 +81,7 @@ def _speichere_freispruch(topic: str, kategorie: str, keys: list[str]) -> None: async def _mit_stichentscheid(template: str, topic: str, key: str, slot: str, lines: list[str], befund: str, kategorie: str = "", - ids: list[str] | None = None) -> tuple[dict[int, str], list[str]]: + ids: list[str] | None = None, extra: str = "") -> tuple[dict[int, str], list[str]]: """Zweitmeinung + Stichentscheid: Der Repair-Judge kann den QA-Befund kippen — bei Dissens (QA sagt Befund, Judge sagt behalten) entscheidet ein DRITTER Judge nur über die strittigen Items, Mehrheit 2/3 (Muster Crossblock-Tiebreaker). Ohne ihn pendelte @@ -76,11 +90,11 @@ async def _mit_stichentscheid(template: str, topic: str, key: str, slot: str, Explizites 2:1-„behalten" wird als FREISPRUCH persistiert (kategorie+ids) — die QA zählt das Item ab dann nicht mehr (qa.lade_freispruch). j3-AUSFALL persistiert nicht (fail-open ist kein Urteil). → (verdicts, freigesprochene Zeilen).""" - v = await _judge(template, topic, key, slot, lines) + v = await _judge(template, topic, key, slot, lines, extra=extra) strittig = [i for i in range(1, len(lines) + 1) if v.get(i) != befund] frei: list[str] = [] if strittig: - v3 = await _judge(template, topic, f"{key}-st", slot, [lines[i - 1] for i in strittig]) + v3 = await _judge(template, topic, f"{key}-st", slot, [lines[i - 1] for i in strittig], extra=extra) gegen = "nein" if befund == "ja" else "ja" frei_keys: list[str] = [] for pos, i in enumerate(strittig, 1): @@ -95,9 +109,165 @@ async def _mit_stichentscheid(template: str, topic: str, key: str, slot: str, return v, frei +async def _fix_beschreibungen(topic: str, report: dict, by_norm: dict) -> list[str]: + """Hygiene-Resttyp „leere-beschreibung": EINE Beschreibung aus Material-Auszügen + generieren (Blocks-Sanierung-Template; der Titel bleibt — Repair ist flow-los und + hat keinen Korpus-Anker-Kontext für Renames). Ohne Material/Auszüge bleibt der Befund.""" + from agents import run_agent + from pipeline import _prompt, _timeout + from jsonio import parse_json_text + from board_inventory import _sanierung_schema + items = [h for h in report.get("hygiene", []) if "leere-beschreibung" in (h.get("probleme") or [])] + folder = source_folder(topic) or material_folder(topic) + if not items or not folder: + return [] + out: list[str] = [] + for h in items: + norm = _norm_title(h.get("titel", "")) + card = by_norm.get(norm) + if not card or (card["payload"].get("description") or "").strip(): + continue + titel = card["payload"].get("title", "") + ev = _evidence_pack(folder, card["payload"].get("sources") or None, [titel], budget=4000) + if not ev: + continue + try: + rc, txt, _err = await run_agent( + f"repair-{topic}-sanierung-{norm[:24]}", + _prompt("Blocks-Sanierung", topic=topic, title=titel, description="(leer)", excerpts=ev), + _timeout("qa_judge"), role="judge", capabilities="none", scope=topic, + label="Repair Beschreibung") + except Exception: + log.exception("[%s] Beschreibungs-Sanierung fehlgeschlagen — Befund bleibt", topic) + continue + verdict = _sanierung_schema(parse_json_text(txt)) if rc == 0 else None + if not verdict or not verdict[1].strip(): + continue + p = dict(card["payload"]) + p["description"] = verdict[1].strip() + await db.kanban_set_payload(topic, "inventory", card["card_id"], p) + await db.set_block_status(topic, norm, "consensus", description=p["description"]) + card["payload"] = p + out.append(f"beschrieben: {titel}") + return out + + +_SUFFIX_RE = re.compile(r"\s*\(\d+\)\s*$") + + +async def _fix_suffixe(topic: str, report: dict, by_norm: dict, files: dict) -> list[str]: + """Hygiene-Resttyp „kollisions-suffix" („Titel (2)", entstanden beim Spiegeln): + Basis-Norm frei (Partner inzwischen gemerged/verworfen) → Rename auf den Basistitel + inkl. Re-Key aller angehängten Tabellen (db.rename_block_norm) + Board-2-Karte + + Sidecars. Basis besetzt → Judge entscheidet Dublette (Merge); sonst bleibt der + Befund sichtbar (zwei echte gleichnamige Konzepte sind ein Naming-Problem).""" + items = [h for h in report.get("hygiene", []) if "kollisions-suffix" in (h.get("probleme") or [])] + out: list[str] = [] + for h in items: + alt = h.get("titel", "") + basis = _SUFFIX_RE.sub("", alt).strip() + alt_norm, basis_norm = _norm_title(alt), _norm_title(basis) + card = by_norm.get(alt_norm) + if not card or not basis or basis_norm == alt_norm: + continue + if basis_norm in by_norm: # Basis lebt noch → Dubletten-Frage statt Rename + merged, _frei = await _merge_dubletten( + topic, {"dubletten": [{"a": basis, "b": alt, "llm": "?"}]}, by_norm, files) + out += [f"suffix-merge: {z}" for z in merged] + continue + await db.rename_block_norm(topic, alt_norm, basis_norm, basis) + b2 = await db.kanban_get_card(topic, "artefacts", alt_norm) + if b2: + p2 = dict(b2["payload"]) + p2["title"] = basis + await db.kanban_upsert_card(topic, "artefacts", basis_norm, b2["kind"], b2["stage"], p2) + await db.kanban_delete_card(topic, "artefacts", alt_norm) + p = dict(card["payload"]) + p["title"] = basis + if p.get("mirrored_norm"): + p["mirrored_norm"] = basis_norm + await db.kanban_set_payload(topic, "inventory", card["card_id"], p) + card["payload"] = p + _rename_in_files(files, alt_norm, basis) + by_norm.pop(alt_norm, None) + by_norm[basis_norm] = card + out.append(f"suffix: {alt} → {basis}") + return out + + +async def _pruefe_luecken(topic: str, report: dict, by_norm: dict) -> list[str]: + """Lücken-Urteil (2:1): offene luecken-Fundstellen und konzept_luecken-Namen dem + Stichentscheid vorlegen — 2:1-„keine echte Lücke" wird als Freispruch persistiert, + bestätigte bleiben als braucht_research sichtbar (Gate-Watch recherchiert, der Nutzer + klickt „+ Recherche"). Das Inventar geht als Kontext in den {extra}-Slot, damit die + Judges Abdeckung gegen den Bestand prüfen können.""" + lk, kl = qa.offene_luecken(report) + if not lk and not kl: + return [] + extra = ("\n\nEXISTING INVENTORY BLOCKS (judge coverage against these):\n" + + "\n".join(f"- {c['payload'].get('title', '')}" for c in by_norm.values())) + frei: list[str] = [] + if lk: + _v, f = await _mit_stichentscheid( + "QA-Luecken", topic, "luecken", "sections", + [f"[{x.get('datei')} #{x.get('abschnitt')}] {x.get('vorschau', '')}" for x in lk], + "ja", kategorie="luecken", ids=[qa.luecken_key(x) for x in lk], extra=extra) + frei += f + if kl: + _v, f = await _mit_stichentscheid( + "QA-Konzept-Luecken", topic, "konzept-luecken", "results", list(kl), + "ja", kategorie="konzept_luecken", ids=[_norm_title(n) for n in kl], extra=extra) + frei += f + return frei + + +async def _belege_nachfassen(topic: str, report: dict, files: dict) -> list[str]: + """subs_ohne_beleg (mentions=0): Beleg-Nachfass je Sub mit Material-Auszügen. + Judge+QA einig „nein" (kein Beleg) → Sub verwerfen (Artefakte/Fragen räumt das + nachlaufende _raeume_waisen); 2:1-„ja" → mentions=1 nachtragen — Datenfix am + gemessenen Datum selbst, kein Freispruch nötig, Detektor unverändert. Produktiv ist + der Detektor derzeit inaktiv (alle Schreiber setzen mentions=1) — Pfad ist Robustheit. + Ohne Quellordner kein Urteil möglich → Befund bleibt.""" + eintraege = (report.get("beleg") or {}).get("subs_ohne_beleg") or [] + folder = source_folder(topic) or material_folder(topic) + if not eintraege or not folder: + return [] + rows = {f"{r['block']} · {r['sub_title']}": r for r in await db.list_subblocks(topic) + if r["status"] != "variant"} + items = [(e, rows[e]) for e in eintraege if e in rows and not rows[e]["mentions"]] + if not items: + return [] + lines = [] + for e, r in items: + ev = _evidence_pack(folder, None, [r["block"], r["sub_title"]], budget=EVIDENCE_PER_BLOCK) + lines.append(f"{e}\n{ev or '(keine Treffer im Material)'}") + v1 = await _judge("QA-Repair-Beleg", topic, "beleg", "blocks", lines) + strittig = [i for i in range(1, len(items) + 1) if v1.get(i) == "ja"] + v3 = (await _judge("QA-Repair-Beleg", topic, "beleg-st", "blocks", + [lines[i - 1] for i in strittig])) if strittig else {} + out: list[str] = [] + + async def _verwerfen(e: str, r: dict): + await db.set_subblock_fields(topic, r["block_norm"], r["sub_norm"], status="discarded") + _entferne_sub_in_files(files, r["block_norm"], r["sub_norm"]) + out.append(f"entfernt: {e[:60]}") + + for i, (e, r) in enumerate(items, 1): + if v1.get(i) == "nein": # QA (mentions=0) + Judge einig → weg + await _verwerfen(e, r) + elif v1.get(i) == "ja": # Dissens → Stichentscheid + pos = strittig.index(i) + 1 + if v3.get(pos) == "ja": + await db.set_subblock_fields(topic, r["block_norm"], r["sub_norm"], mentions=1) + out.append(f"belegt: {e[:60]}") + elif v3.get(pos) == "nein": + await _verwerfen(e, r) + return out + + async def _fix_hygiene(topic: str, report: dict, by_norm: dict, files: dict) -> list[str]: """Nur der norm-invariante Teil (`**`/Backticks); `(n)`-Suffix und leere Beschreibung - ändern die Norm bzw. brauchen Inhalt — bleiben Befund.""" + ändern die Norm bzw. brauchen Inhalt — eigene Handler (_fix_suffixe/_fix_beschreibungen).""" fixed = [] for h in report.get("hygiene", []): alt = h.get("titel", "") @@ -117,33 +287,68 @@ async def _fix_hygiene(topic: str, report: dict, by_norm: dict, files: dict) -> return fixed -async def _merge_dubletten(topic: str, report: dict, by_norm: dict, files: dict) -> list[str]: - """Nur QA-bestätigte Paare (llm=ja); eine Zweitmeinung, Merge nur bei erneut ja. - Merge spiegelt die dedup-Stage: Union ins Gewinner-Payload, Verlierer → grouped.""" - paare = [p for p in report.get("dubletten", []) if p.get("llm") == "ja" - and _norm_title(p.get("a", "")) in by_norm and _norm_title(p.get("b", "")) in by_norm] - if not paare: - return [] - v, _frei = await _mit_stichentscheid("QA-Dubletten", topic, "dubletten", "pairs", - [f"A: {p['a']}\nB: {p['b']}" for p in paare], "ja") - merged = [] - for i, p in enumerate(paare, 1): - a, b = by_norm.get(_norm_title(p["a"])), by_norm.get(_norm_title(p["b"])) - if v.get(i) != "ja" or not a or not b or a["card_id"] == b["card_id"]: - continue - win, lose = sorted((a, b), key=lambda c: (len(c["payload"].get("description") or ""), - len(c["payload"].get("title") or "")), reverse=True) - wp, lp = dict(win["payload"]), dict(lose["payload"]) - wp["readers"] = sorted(set(wp.get("readers") or []) | set(lp.get("readers") or [])) - wp["sources"] = sorted(set(wp.get("sources") or []) | set(lp.get("sources") or [])) - lp.update(reason="merged", merged_into=wp.get("title", "")) - await db.kanban_set_payload(topic, "inventory", win["card_id"], wp) - await db.kanban_set_payload(topic, "inventory", lose["card_id"], lp) - await db.kanban_advance(topic, "inventory", lose["card_id"], "grouped") - await _purge_block(topic, lp.get("title", ""), files) - by_norm.pop(_norm_title(lp.get("title", "")), None) - merged.append(f"{lp.get('title')} → {wp.get('title')}") - return merged +async def _merge_paar(topic: str, p: dict, by_norm: dict, files: dict) -> str | None: + """Ein bestätigtes Dubletten-Paar mergen (spiegelt die dedup-Stage: Union ins + Gewinner-Payload, Verlierer → grouped). → Journalzeile oder None.""" + a, b = by_norm.get(_norm_title(p["a"])), by_norm.get(_norm_title(p["b"])) + if not a or not b or a["card_id"] == b["card_id"]: + return None + win, lose = sorted((a, b), key=lambda c: (len(c["payload"].get("description") or ""), + len(c["payload"].get("title") or "")), reverse=True) + wp, lp = dict(win["payload"]), dict(lose["payload"]) + wp["readers"] = sorted(set(wp.get("readers") or []) | set(lp.get("readers") or [])) + wp["sources"] = sorted(set(wp.get("sources") or []) | set(lp.get("sources") or [])) + lp.update(reason="merged", merged_into=wp.get("title", "")) + await db.kanban_set_payload(topic, "inventory", win["card_id"], wp) + await db.kanban_set_payload(topic, "inventory", lose["card_id"], lp) + await db.kanban_advance(topic, "inventory", lose["card_id"], "grouped") + await _purge_block(topic, lp.get("title", ""), files) + by_norm.pop(_norm_title(lp.get("title", "")), None) + return f"{lp.get('title')} → {wp.get('title')}" + + +async def _merge_dubletten(topic: str, report: dict, by_norm: dict, files: dict) -> tuple[list[str], list[str]]: + """QA-bestätigte Paare (llm=ja): Zweitmeinung, Merge nur bei erneut ja; 2:1-„behalten" + persistiert jetzt als Freispruch (fehlte — die Paare pendelten ewig im Verdacht). + Widerlegte/unbeurteilte Paare (llm=nein/?) bekommen den Klärungskanal: Judge-„nein" → + Freispruch (mit QA-nein 2:0; beim bloßen Verdacht reicht das klare Gegen-Urteil), + Judge-„ja" → Stichentscheid (ja → Merge 2:1, nein → Freispruch 2:1, Ausfall → nichts).""" + alle = [p for p in report.get("dubletten", []) if not p.get("freispruch") + and _norm_title(p.get("a", "")) in by_norm and _norm_title(p.get("b", "")) in by_norm] + ja = [p for p in alle if p.get("llm") == "ja"] + offen = [p for p in alle if p.get("llm") != "ja"] + merged: list[str] = [] + frei: list[str] = [] + if ja: + v, f = await _mit_stichentscheid("QA-Dubletten", topic, "dubletten", "pairs", + [f"A: {p['a']}\nB: {p['b']}" for p in ja], "ja", + kategorie="dubletten", + ids=[qa._paar_key(p["a"], p["b"]) for p in ja]) + frei += f + for i, p in enumerate(ja, 1): + if v.get(i) == "ja" and (zeile := await _merge_paar(topic, p, by_norm, files)): + merged.append(zeile) + if offen: + ids = [qa._paar_key(p["a"], p["b"]) for p in offen] + v1 = await _judge("QA-Dubletten", topic, "dubletten-v", "pairs", + [f"A: {p['a']}\nB: {p['b']}" for p in offen]) + frei_keys = [ids[i - 1] for i in range(1, len(offen) + 1) if v1.get(i) == "nein"] + frei += [f"{offen[i - 1]['a']} <-> {offen[i - 1]['b']}"[:80] + for i in range(1, len(offen) + 1) if v1.get(i) == "nein"] + strittig = [i for i in range(1, len(offen) + 1) if v1.get(i) == "ja"] + if strittig: + v3 = await _judge("QA-Dubletten", topic, "dubletten-v-st", "pairs", + [f"A: {offen[i - 1]['a']}\nB: {offen[i - 1]['b']}" for i in strittig]) + for pos, i in enumerate(strittig, 1): + if v3.get(pos) == "ja" and (zeile := await _merge_paar(topic, offen[i - 1], by_norm, files)): + merged.append(zeile) + elif v3.get(pos) == "nein": + frei_keys.append(ids[i - 1]) + frei.append(f"{offen[i - 1]['a']} <-> {offen[i - 1]['b']}"[:80]) + if frei_keys: + _speichere_freispruch(topic, "dubletten", frei_keys) + log.info("[%s] Repair dubletten: %d Verdacht(e) freigesprochen", topic, len(frei_keys)) + return merged, frei _SUB_PAAR = re.compile(r"^\[(.+?)\] (.+)$", re.S) diff --git a/backend/routes.py b/backend/routes.py index ffd5990..782a510 100644 --- a/backend/routes.py +++ b/backend/routes.py @@ -20,7 +20,7 @@ from database import ( delete_topic_pipeline, delete_source, get_guide_content, delete_guide_content, get_sub_artefakte, kanban_reset, delete_guide_board, get_practice_progress, upsert_practice_progress, sub_levels_norm, subs_per_level_norm, - list_runs, get_db, + list_runs, latest_board_runs, get_db, ) from textkit import _norm_title from blocks import generate_blocks, cancel_blocks, blocks_status, active_blocks, reset_blocks, load_source, load_overview, subblocks_title, subblocks_frei, load_question_pattern, load_question_pattern_free, _blocks_files @@ -82,8 +82,10 @@ async def health(): @router.get("/runs") async def get_runs(topic: str, limit: int = 10): - """Lauf-Historie (Blocks + Guide): Zeitspanne, Agenten, Tokens, Fehler je run_id.""" - return {"runs": await list_runs(topic, limit)} + """Lauf-Historie (Blocks + Guide): Zeitspanne, Agenten, Tokens, Fehler je run_id. + `latest` = jüngster Lauf MIT Daten je Ebene (inventory/artefacts/guide) — die + Board-Kopfzeilen zeigen so auch nach „Nur Artefakte"-/Guide-Läufen ihre Zahlen.""" + return {"runs": await list_runs(topic, limit), "latest": await latest_board_runs(topic)} @router.get("/topics/progress") diff --git a/backend/tests/test_agents_api.py b/backend/tests/test_agents_api.py index eeee8b9..f919bcf 100644 --- a/backend/tests/test_agents_api.py +++ b/backend/tests/test_agents_api.py @@ -273,12 +273,12 @@ def test_meminfo_reads_proc(): def test_agent_ebene_klassifiziert_board(): - """Board-2-Marker (art-/sb-/crossblock/outline) → artefacts; alles andere → inventory.""" + """Board-2-Marker (art-/sb-/outline) → artefacts; alles andere → inventory.""" T = "shopware" for key in (f"blocks-{T}-research-a1", f"blocks-{T}-pair-x", f"blocks-{T}-naming-y", f"blocks-{T}-gruppierung-h-cTOP", f"blocks-{T}-dedup-z"): assert agents.agent_ebene(key) == "inventory", key for key in (f"blocks-{T}-alpha-sb-enrich-h", f"blocks-{T}-alpha-sb-verify-h-j1", f"blocks-{T}-alpha-art-gen-h-t1", f"blocks-{T}-alpha-art-check-h", - f"blocks-{T}-sub-crossblock-h-j1", f"blocks-{T}-outline-judge"): + f"blocks-{T}-outline-judge"): assert agents.agent_ebene(key) == "artefacts", key diff --git a/backend/tests/test_auto_loop.py b/backend/tests/test_auto_loop.py index a5c6206..321c181 100644 --- a/backend/tests/test_auto_loop.py +++ b/backend/tests/test_auto_loop.py @@ -1,4 +1,4 @@ -"""Auto-Repair-Loop-Primitive: Stopp bei 100 %, Stillstand, Limit.""" +"""Auto-Repair-Loop-Primitive: Stopp bei 100 %, Stillstand (unbewegt), Limit.""" import auto_loop @@ -14,7 +14,7 @@ async def test_verbessert_bis_voll(): noten = iter([9.0, 10.0]) async def rep(): - return next(noten) + return next(noten), True r = await auto_loop.auto_repair_loop("inv", 8.0, rep) # 8.0 → 9.0 → 10.0 assert r["grund"] == "fertig" and r["runden"] == 2 @@ -22,19 +22,31 @@ async def test_verbessert_bis_voll(): async def test_stillstand(): async def rep(): - return 8.0 # Repair bewegt nichts + return 8.0, False # nichts getan, Note steht r = await auto_loop.auto_repair_loop("art", 8.0, rep) assert r["grund"] == "stillstand" and r["runden"] == 1 assert r["note"] == 8.0 +async def test_bewegt_ohne_notengewinn_laeuft_weiter(): + """Eine bewegte Runde (z. B. Lücken-Research senkt die Note transient) ist KEIN + Stillstand — erst unbewegt + keine Verbesserung stoppt; max_iter begrenzt Livelocks.""" + laeufe = iter([(7.5, True), (8.0, True), (8.0, False)]) + + async def rep(): + return next(laeufe) + + r = await auto_loop.auto_repair_loop("inv", 8.0, rep) + assert r["grund"] == "stillstand" and r["runden"] == 3 + + async def test_limit(): stand = {"n": 1.0} async def rep(): stand["n"] += 0.1 # verbessert stetig, erreicht aber nie 10.0 - return stand["n"] + return stand["n"], True r = await auto_loop.auto_repair_loop("guide", 1.0, rep, max_iter=10) assert r["grund"] == "limit" and r["runden"] == 10 diff --git a/backend/tests/test_board_inventory.py b/backend/tests/test_board_inventory.py index 397ea0b..fe30001 100644 --- a/backend/tests/test_board_inventory.py +++ b/backend/tests/test_board_inventory.py @@ -103,12 +103,6 @@ async def board_env(testdb, tmp_path, monkeypatch): ("_artefakte_block", fake_artefakte), ("_outline_block", fake_outline)]: monkeypatch.setattr(ba, name, fn) - class _EmbOff: # Cross-Block-Barrier reicht ohne Modell alle Karten durch - @staticmethod - def available(): - return False - monkeypatch.setattr(ba, "embedding", _EmbOff) - work = tmp_path / "arbeit" work.mkdir() files = {"arbeit": work, "final": tmp_path / "blocks.md", @@ -973,8 +967,8 @@ async def test_qa_gate_auto_stillstand_pauses(board_env, monkeypatch): async def bad_qa(topic, llm=False): return {"note": 5.0, "topic": TOPIC, "quoten": {}, "fremd": [], "artefakte": {}} - async def stuck_repair(topic, ebene=None): - return {"note": 5.0} # bewegt nichts + async def stuck_repair(topic, ebene=None, luecken_urteil=True): + return {"note": 5.0, "aktionen": 0} # bewegt nichts monkeypatch.setattr(qa_mod, "qa_report", bad_qa) monkeypatch.setattr(qa_mod, "_write_report", lambda r: None) @@ -988,6 +982,45 @@ async def test_qa_gate_auto_stillstand_pauses(board_env, monkeypatch): assert await db.kanban_count(TOPIC, "done_artefact", board="artefacts") == 0 +async def test_qa_gate_luecken_research_runden(board_env, monkeypatch): + """Offene Lücken im Auto-Loop: erst LUECKEN_RESEARCH_MAX Recherche-Runden + (Lücken-Urteil ausgesetzt), dann das Urteil — erst danach Stillstand/Pause.""" + import qa as qa_mod + import repair as repair_mod + db, ctx, files = board_env + gap_report = {"note": 8.0, "topic": TOPIC, "quoten": {}, "fremd": [], "artefakte": {}, + "luecken": [{"datei": "f.txt", "abschnitt": 1, "vorschau": "x"}], + "konzept_luecken": []} + + async def bad_qa(topic, llm=False): + return dict(gap_report) + + research_calls = [] + + async def fake_producer(ctx2, flow, rep): + research_calls.append(1) + return 1 # Recherche hat Karten erzeugt → Runde gilt als bewegt + + urteile = [] + + async def stuck_repair(topic, ebene=None, luecken_urteil=True): + urteile.append(luecken_urteil) + return {"note": 8.0, "aktionen": 0} + + monkeypatch.setattr(qa_mod, "qa_report", bad_qa) + monkeypatch.setattr(qa_mod, "latest_report", lambda t, guide=False: dict(gap_report)) + monkeypatch.setattr(qa_mod, "_write_report", lambda r: None) + monkeypatch.setattr(bi, "_luecken_producer", fake_producer) + monkeypatch.setattr(repair_mod, "repair_befunde", stuck_repair) + monkeypatch.setattr(bi, "_QA_GATE_POLL", 0.05) + await _seed(db) + ok = await _run_flow(ctx, files) + assert ok + assert len(research_calls) == 2 # LUECKEN_RESEARCH_MAX + assert urteile[:2] == [False, False] and urteile[2] is True + assert await db.kanban_count(TOPIC, "done_artefact", board="artefacts") == 0 # pausiert + + async def test_qa_gate_auto_reaches_100(board_env, monkeypatch): """Auto an, Repair hebt die Note auf 100 % → Gate öffnet, Board 2 läuft durch.""" import qa as qa_mod @@ -997,8 +1030,8 @@ async def test_qa_gate_auto_reaches_100(board_env, monkeypatch): async def bad_qa(topic, llm=False): return {"note": 8.0, "topic": TOPIC, "quoten": {}, "fremd": [], "artefakte": {}} - async def good_repair(topic, ebene=None): - return {"note": 10.0} + async def good_repair(topic, ebene=None, luecken_urteil=True): + return {"note": 10.0, "aktionen": 1} monkeypatch.setattr(qa_mod, "qa_report", bad_qa) monkeypatch.setattr(qa_mod, "_write_report", lambda r: None) @@ -1019,7 +1052,7 @@ async def test_qa_gate_auto_off_pauses(board_env, monkeypatch): async def good_qa(topic, llm=False): return {"note": 10.0, "topic": TOPIC, "quoten": {}, "fremd": [], "artefakte": {}} - async def no_repair(topic, ebene=None): + async def no_repair(topic, ebene=None, luecken_urteil=True): raise AssertionError("Auto aus darf nicht reparieren") monkeypatch.setattr(qa_mod, "qa_report", good_qa) @@ -1094,6 +1127,11 @@ def test_qa_view_pausiert_logic(tmp_path, monkeypatch): counts = {"inventory": {"done_block": 3}, "artefacts": {"generate": 4}} v = bi._qa_view(TOPIC, counts, None) assert v["pausiert"] is True and v["note"] == 5.0 and v["befunde"] == ["X", "Y"] + # 9.6 lag über QA_GATE_NOTE, pausiert den Auto-Loop aber trotzdem (Gate öffnet nur bei VOLL) + (tmp_path / TOPIC / "r1.json").write_text(_json.dumps( + {"note": 9.6, "quoten": {}, "fremd": [], "unecht": []}), encoding="utf-8") + qa_mod._latest_cache.clear() + assert bi._qa_view(TOPIC, counts, None)["pausiert"] is True from types import SimpleNamespace laufend = SimpleNamespace(state={}) assert bi._qa_view(TOPIC, counts, laufend)["pausiert"] is False # Flow läuft noch @@ -1515,3 +1553,27 @@ async def test_sanierung_anker_und_beschreibung_ok_kein_judge(testdb, tmp_path, ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False) await bi._name_one(ctx, _mk_flow(tmp_path), {"card_id": "c4", "payload": payload}) assert (await db.kanban_get_card(TOPIC, B, "c4"))["stage"] == "naming_check" + + +def test_themen_zielband_skaliert_mit_atomzahl(): + """√n-Band: kleine Themen deutlich unter 15, große über dem alten 18er-Cluster-Cap.""" + lo30, hi30 = bi._themen_zielband(30) + assert lo30 >= 2 and hi30 < 15 + lo285, hi285 = bi._themen_zielband(285) + assert hi285 > 18 + lo4, hi4 = bi._themen_zielband(4) + assert lo4 >= 2 and hi4 > lo4 + # Monotonie: mehr Items → nie kleineres Band + prev = (0, 0) + for n in (4, 30, 100, 285, 1000): + band = bi._themen_zielband(n) + assert band >= prev + prev = band + + +def test_gruppierung_prompt_rendert_zielband(tmp_path): + """Template↔Call-Site-Vertrag: alle Platzhalter versorgt, Band-String im Prompt.""" + from pipeline import _prompt + p = _prompt("Blocks-Gruppierung", topic="t", candidates="x", list="1. a", + out_path=tmp_path / "g.json", theme_lo=4, theme_hi=8, n_items=9) + assert "~4–8 themes" in p and "(9 items)" in p diff --git a/backend/tests/test_e2e_fake.py b/backend/tests/test_e2e_fake.py index d42fe8c..9eb68a4 100644 --- a/backend/tests/test_e2e_fake.py +++ b/backend/tests/test_e2e_fake.py @@ -1,7 +1,7 @@ """E2E über die ECHTE Engine mit Fake-Agenten: kompletter Generierungspfad in Sekunden. Anders als test_board_inventory (dort sind die Block-Funktionen gefakt) läuft hier alles -bis run_agent echt — _race, Quorum, Panels, Konsolidierung, Cross-Block, QA-Gate. +bis run_agent echt — _race, Quorum, Panels, In-Block-Konsolidierung, QA-Gate. """ import asyncio diff --git a/backend/tests/test_events.py b/backend/tests/test_events.py index ba6a7a7..a9edfe1 100644 --- a/backend/tests/test_events.py +++ b/backend/tests/test_events.py @@ -301,6 +301,33 @@ async def test_events_run_summary_per_board(testdb): assert (await db.events_run_summary(TOPIC, "r2"))["agents"]["gesamt"] == 2 # ohne board = alle +async def test_latest_board_runs_je_ebene(testdb): + """Je Ebene der jüngste Lauf MIT Daten: ein „Nur Artefakte"-Lauf (r2) lässt die + Inventar-Anzeige auf r1 stehen; Guide-Läufe (Suffix -g+hex4) haben eine eigene Zeile.""" + db = testdb + assert await db.latest_board_runs(TOPIC) == {"inventory": None, "artefacts": None, "guide": None} + db.set_current_run(TOPIC, "20260708-1000-aaaa") # Voll-Lauf: beide Ebenen + await db.add_event(TOPIC, "agent", key="i1", status="ok", + meta={"board": "inventory", "tokens": {"input": 10, "output": 1}}) + await db.add_event(TOPIC, "agent", key="a1", status="ok", meta={"board": "artefacts"}) + db.set_current_run(TOPIC, "20260708-1100-bbbb") # „Nur Artefakte" + await db.add_event(TOPIC, "agent", key="a2", status="ok", + meta={"board": "artefacts", "tokens": {"input": 5, "output": 7}}) + db.set_current_run(TOPIC, "20260708-1200-gabc1") # Guide (Events untagged) + await db.add_event(TOPIC, "agent", key="g1", status="ok", + meta={"tokens": {"input": 3, "output": 4}}) + latest = await db.latest_board_runs(TOPIC) + assert latest["inventory"]["run_id"] == "20260708-1000-aaaa" + assert latest["inventory"]["tokens"]["input"] == 10 + assert latest["artefacts"]["run_id"] == "20260708-1100-bbbb" + assert latest["artefacts"]["tokens"]["output"] == 7 + assert latest["guide"]["run_id"] == "20260708-1200-gabc1" + assert latest["guide"]["tokens"]["input"] == 3 + # aktiv folgt der Registry: nur der Guide-Lauf ist noch der aktuelle + assert latest["guide"]["aktiv"] and not latest["inventory"]["aktiv"] + db.set_current_run(TOPIC, None) + + async def test_topic_delete_entfernt_guides_und_kanban(testdb, tmp_path, monkeypatch): """DELETE /topics: guides/guide_cards/kanban_cards mitlöschen — GET /topics leitet Topics aus guides ab, sonst taucht das gelöschte Topic sofort wieder auf.""" @@ -351,6 +378,7 @@ async def test_runs_endpoint_liefert_bilanz(testdb): await db.add_event(TOPIC, "fail", key="inventory:b-1", status="dead", meta={"error": "kaputt"}) res = await routes.get_runs(TOPIC) runs = res["runs"] + assert set(res["latest"]) == {"inventory", "artefacts", "guide"} # je Ebene letzter Lauf mit Daten assert len(runs) == 1 and runs[0]["run_id"] == "r1" and runs[0]["aktiv"] is True assert runs[0]["agents"]["gesamt"] == 2 and runs[0]["agents"]["timeout"] == 1 assert runs[0]["tokens"]["output"] == 20 diff --git a/backend/tests/test_finalize.py b/backend/tests/test_finalize.py new file mode 100644 index 0000000..9ece515 --- /dev/null +++ b/backend/tests/test_finalize.py @@ -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"} diff --git a/backend/tests/test_guide_board.py b/backend/tests/test_guide_board.py index c772e59..cd29df2 100644 --- a/backend/tests/test_guide_board.py +++ b/backend/tests/test_guide_board.py @@ -427,6 +427,36 @@ async def test_repair_karten_setzt_befundkarten_auf_pruefer(testdb, tmp_path, mo assert cards["gamma"]["stage"] == "done" +async def test_repair_karten_stil_befunde_direkt_auf_fix(testdb, tmp_path, monkeypatch): + """Nur-Stil-Befunde (redundanz/laengen/lesbarkeit) → direkt auf `fix` mit + Auftragszeilen im gate_info (KEIN KRITISCH-Präfix → ein Rewrite ohne Re-Prüfer); + die Redundanz-Zeile trägt den Cross-Karten-Kontext, den der Prüfer nie sieht.""" + import json as _json + import qa as qa_mod + db = testdb + monkeypatch.setattr(qa_mod, "QA_DIR", tmp_path / "qa") + for n in ("alpha", "beta", "gamma"): + await db.upsert_guide_card(TOPIC, FMT, n, n.title()) + await db.set_guide_card(TOPIC, FMT, n, stage="done", status="ok", md="\nText") + tdir = tmp_path / "qa" / TOPIC + tdir.mkdir(parents=True) + (tdir / "guide-20260705-000001.json").write_text(_json.dumps({ + "marker_fehlend": [], "fachlich_falsch": ["Gamma"], "ziel_ohne_anker": [], + "redundanz": [{"a": "Alpha: gemeinsamer Absatztext", "b": "Beta: gemeinsamer Absatztext"}], + "laengen_ausreisser": [{"block": "Beta", "zeichen": 9000, "budget": 3000}], + "lesbarkeit": ["Alpha: Sätze kürzen"], + }), encoding="utf-8") + betroffen = await gb.repair_karten(TOPIC, FMT) + assert sorted(betroffen) == ["Alpha", "Beta", "Gamma"] + cards = {c["block_norm"]: c for c in await db.list_guide_cards(TOPIC, FMT)} + assert cards["gamma"]["stage"] == "pruefer" # kritisch → Prüfer + assert cards["alpha"]["stage"] == cards["beta"]["stage"] == "fix" + assert not cards["beta"]["gate_info"].startswith("KRITISCH") + assert "BALLAST" in cards["beta"]["gate_info"] and "Alpha" in cards["beta"]["gate_info"] + assert "LÄNGE" in cards["beta"]["gate_info"] and "3000" in cards["beta"]["gate_info"] + assert "LESBARKEIT: Sätze kürzen" in cards["alpha"]["gate_info"] + + async def test_fix_failed_behaelt_befunde(testdb, tmp_path, monkeypatch): """Scheitert der Fix, dürfen die Prüfer-Befunde nicht stumm verschwinden — sie bleiben im gate_info sichtbar (vorher wurde gate_info geleert).""" diff --git a/backend/tests/test_konsolidierung.py b/backend/tests/test_konsolidierung.py deleted file mode 100644 index ea4169e..0000000 --- a/backend/tests/test_konsolidierung.py +++ /dev/null @@ -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 diff --git a/backend/tests/test_pdf_convert.py b/backend/tests/test_pdf_convert.py index 18da91f..8a2dca7 100644 --- a/backend/tests/test_pdf_convert.py +++ b/backend/tests/test_pdf_convert.py @@ -26,6 +26,7 @@ def test_convert_writes_markdown_txt(tmp_path): def test_cache_skips_fresh_txt(tmp_path): _mini_pdf(tmp_path / "a.pdf") + (tmp_path / ".pdf-txt-norm").write_text(str(blx._PDF_NORM_VERSION), encoding="utf-8") marker = tmp_path / "a.txt" marker.write_text("MARKER", encoding="utf-8") now = time.time() + 60 @@ -34,6 +35,39 @@ def test_cache_skips_fresh_txt(tmp_path): assert marker.read_text(encoding="utf-8") == "MARKER" # nicht neu konvertiert +def test_norm_version_erzwingt_rekonvertierung(tmp_path, monkeypatch): + """Fehlender/alter .pdf-txt-norm-Marker ignoriert den mtime-Cache einmalig.""" + _mini_pdf(tmp_path / "a.pdf") + stale = tmp_path / "a.txt" + stale.write_text("ALT", encoding="utf-8") + now = time.time() + 60 + os.utime(stale, (now, now)) + monkeypatch.setattr(blx, "_pdf_markdown", lambda p: None) + monkeypatch.setattr(blx, "_pdf_plaintext", lambda p: "neu konvertiert") + blx._convert_pdfs(tmp_path) + assert stale.read_text(encoding="utf-8") == "neu konvertiert" + assert (tmp_path / ".pdf-txt-norm").read_text(encoding="utf-8") == str(blx._PDF_NORM_VERSION) + stale.write_text("BLEIBT", encoding="utf-8") + os.utime(stale, (now, now)) + blx._convert_pdfs(tmp_path) # Marker aktuell → Cache greift wieder + assert stale.read_text(encoding="utf-8") == "BLEIBT" + + +def test_entzerre_pdf_woerter(): + """Small-Caps-Splits mergen; Variablen-Schutzfall und unbelegte Paare bleiben.""" + # Regel A inkl. Kette + assert blx._entzerre_pdf_woerter("Das H ITTING S ET Problem") == "Das HITTING SET Problem" + assert blx._entzerre_pdf_woerter("V ERTEX C OVER ist schwer") == "VERTEX COVER ist schwer" + # Schutzfall: Großlauf klein fortgesetzt → L ist Variable + assert blx._entzerre_pdf_woerter("die Sprache L NP-vollständig ist") == "die Sprache L NP-vollständig ist" + # Regel B: Paar nur mit Frequenz-Beleg (≥3× ungespalten im Dokument) + belegt = "N P ist zentral. " + "NP NP NP." + assert blx._entzerre_pdf_woerter(belegt).startswith("NP ist zentral.") + assert blx._entzerre_pdf_woerter("C Y bleibt getrennt") == "C Y bleibt getrennt" + # kein Match mitten im Wort + assert blx._entzerre_pdf_woerter("HALTTM IST hier") == "HALTTM IST hier" + + def test_fallback_to_pdftotext(tmp_path, monkeypatch): _mini_pdf(tmp_path / "b.pdf") monkeypatch.setattr(blx, "_pdf_markdown", lambda p: None) diff --git a/backend/tests/test_qa.py b/backend/tests/test_qa.py index dd936ed..bc1ed54 100644 --- a/backend/tests/test_qa.py +++ b/backend/tests/test_qa.py @@ -313,3 +313,23 @@ async def test_topic_delete_entfernt_qa_ordner(testdb, tmp_path, monkeypatch): (qdir / "alt.json").write_text("{}", encoding="utf-8") await routes.remove_topic("t") assert not qdir.exists() + + +def test_luecken_key_stabil_gegen_whitespace(): + """Freispruch-Schlüssel: Datei + normalisierter Vorschau-Hash — Whitespace-Varianten + derselben Fundstelle mappen auf denselben Key, andere Vorschau nicht.""" + a = {"datei": "f.txt", "abschnitt": 3, "vorschau": "Der Satz\nvon Foo"} + b = {"datei": "f.txt", "abschnitt": 7, "vorschau": "der satz von foo"} + c = {"datei": "f.txt", "abschnitt": 3, "vorschau": "ganz anderer Text"} + assert qa.luecken_key(a) == qa.luecken_key(b) + assert qa.luecken_key(a) != qa.luecken_key(c) + assert qa.luecken_key(a).startswith("f.txt||") + + +def test_offene_luecken_filtert_freispruch_und_nein(): + rep = {"luecken": [{"datei": "f", "vorschau": "a", "llm": "ja"}, + {"datei": "f", "vorschau": "b", "llm": "nein"}, + {"datei": "f", "vorschau": "c", "freispruch": True}], + "konzept_luecken": ["Satz von Foo"]} + lk, kl = qa.offene_luecken(rep) + assert [x["vorschau"] for x in lk] == ["a"] and kl == ["Satz von Foo"] diff --git a/backend/tests/test_repair.py b/backend/tests/test_repair.py index f952211..9d9fe74 100644 --- a/backend/tests/test_repair.py +++ b/backend/tests/test_repair.py @@ -298,3 +298,135 @@ async def test_waisen_cleanup(env, monkeypatch): rest = {(r["sub_norm"], r["type"]) for r in await db.get_sub_artefakte(TOPIC)} assert rest == {("sub0", "flashcard"), ("doppel", "example")} assert not [r for r in await db.list_question_pattern(TOPIC)] + + +async def test_dubletten_verdacht_freispruch(env, monkeypatch): + """Widerlegtes/unbeurteiltes Verdachtspaar bekommt den Klärungskanal: Judge sagt + behalten → Freispruch persistiert; der nächste qa_report zählt das Paar nicht mehr + (vorher zählte widerlegter Verdacht ewig in die Quote — Mess-Rauschen ohne Ausweg).""" + import qa as qa_mod + db, seed, files, write_report = env + await seed("Alpha", "beschr") + await seed("Alpha Kreis", "beschr") + write_report(_report(dubletten=[{"a": "Alpha", "b": "Alpha Kreis", "llm": "nein"}])) + + async def fake_agent(key, prompt, timeout, **kw): + return 0, '{"relevant": {"1": "nein"}}', "" # Judge: keine Dublette + + import agents; monkeypatch.setattr(agents, "run_agent", fake_agent) + res = await repair.repair_befunde(TOPIC) + assert res["merges"] == [] and len(res["freigesprochen"]) == 1 and res["aktionen"] == 1 + frei = qa_mod.lade_freispruch(TOPIC) + assert qa_mod._paar_key("Alpha", "Alpha Kreis") in set(frei.get("dubletten") or []) + + +async def test_dubletten_verdacht_stichentscheid_merged(env, monkeypatch): + """Verdachtspaar (llm=nein), aber Judge+Stichentscheid sagen beide Dublette → + Merge mit 2:1 gegen das QA-Urteil.""" + db, seed, files, write_report = env + await seed("Alpha", "kurz") + await seed("Alpha Kreis", "deutlich längere Beschreibung — Gewinner") + write_report(_report(dubletten=[{"a": "Alpha", "b": "Alpha Kreis", "llm": "nein"}])) + + async def fake_agent(key, prompt, timeout, **kw): + return 0, '{"relevant": {"1": "ja"}}', "" + + import agents; monkeypatch.setattr(agents, "run_agent", fake_agent) + res = await repair.repair_befunde(TOPIC) + assert res["merges"] == ["Alpha → Alpha Kreis"] and res["freigesprochen"] == [] + + +async def test_beleg_nachfass_discard_und_datenfix(env, monkeypatch): + """mentions=0-Subs: Judge bestätigt „kein Beleg" → Sub verworfen; Dissens mit + Stichentscheid „belegt" → mentions=1 nachgetragen (Datenfix, kein Freispruch).""" + db, seed, files, write_report = env + await seed("Alpha", "beschr") + norm = repair._norm_title("Alpha") + await db.put_subblock(TOPIC, norm, "ohne beleg", "Alpha", "Ohne Beleg", status="consensus") + await db.put_subblock(TOPIC, norm, "doch belegt", "Alpha", "Doch Belegt", status="consensus") + await db.set_subblock_fields(TOPIC, norm, "ohne beleg", mentions=0) + await db.set_subblock_fields(TOPIC, norm, "doch belegt", mentions=0) + write_report(_report(beleg={"subs_ohne_beleg": ["Alpha · Ohne Beleg", "Alpha · Doch Belegt"]})) + monkeypatch.setattr(repair, "source_folder", lambda t: files["sidecar"].parent) + monkeypatch.setattr(repair, "_evidence_pack", lambda *a, **kw: "AUSZUG") + + async def fake_agent(key, prompt, timeout, **kw): + if "-beleg-st-" in key: + return 0, '{"relevant": {"1": "ja"}}', "" # Stichentscheid: belegt + return 0, '{"relevant": {"1": "nein", "2": "ja"}}', "" + + import agents; monkeypatch.setattr(agents, "run_agent", fake_agent) + res = await repair.repair_befunde(TOPIC, "artefacts") + assert sorted(res["beleg_fix"]) == ["belegt: Alpha · Doch Belegt", "entfernt: Alpha · Ohne Beleg"] + rows = {r["sub_norm"]: r for r in await db.list_subblocks(TOPIC, norm)} + assert rows["ohne beleg"]["status"] == "discarded" + assert rows["doch belegt"]["status"] == "consensus" and rows["doch belegt"]["mentions"] == 1 + + +async def test_fix_beschreibung_fuellt_leere(env, monkeypatch): + """Hygiene „leere-beschreibung" → Sanierung schreibt EINE Beschreibung aus den + Auszügen; Titel bleibt unverändert.""" + db, seed, files, write_report = env + cid = await seed("Leerling", "") + write_report(_report(hygiene=[{"titel": "Leerling", "probleme": ["leere-beschreibung"]}])) + monkeypatch.setattr(repair, "source_folder", lambda t: files["sidecar"].parent) + monkeypatch.setattr(repair, "_evidence_pack", lambda *a, **kw: "AUSZUG") + + async def fake_agent(key, prompt, timeout, **kw): + assert "-sanierung-" in key + return 0, '{"title": "Leerling", "description": "Aus dem Material."}', "" + + import agents; monkeypatch.setattr(agents, "run_agent", fake_agent) + res = await repair.repair_befunde(TOPIC, "inventory") + assert "beschrieben: Leerling" in res["hygiene"] + card = await db.kanban_get_card(TOPIC, "inventory", cid) + assert card["payload"]["description"] == "Aus dem Material." + + +async def test_fix_suffix_rename_rekeyt_alles(env, monkeypatch): + """Kollisions-Suffix, Basis-Norm frei → Rename inkl. Re-Key von Subblocks/Fragen/ + Artefakten + Board-2-Karte; danach keine Invarianten-Waisen.""" + from invarianten import pruefe_invarianten + db, seed, files, write_report = env + cid = await seed("Gamma (2)", "beschr") + norm_alt = repair._norm_title("Gamma (2)") + await db.upsert_question_pattern(TOPIC, norm_alt, "sub0", "Gamma (2)", "Sub 0", "F?") + write_report(_report(hygiene=[{"titel": "Gamma (2)", "probleme": ["kollisions-suffix"]}])) + + async def no_agent(*a, **kw): + raise AssertionError("Rename bei freier Basis braucht keinen Agenten") + + import agents; monkeypatch.setattr(agents, "run_agent", no_agent) + res = await repair.repair_befunde(TOPIC, "inventory") + assert "suffix: Gamma (2) → Gamma" in res["hygiene"] + card = await db.kanban_get_card(TOPIC, "inventory", cid) + assert card["payload"]["title"] == "Gamma" and card["payload"]["mirrored_norm"] == "gamma" + assert await db.kanban_get_card(TOPIC, "artefacts", norm_alt) is None + assert (await db.kanban_get_card(TOPIC, "artefacts", "gamma"))["payload"]["title"] == "Gamma" + assert {r["block_norm"] for r in await db.list_subblocks(TOPIC, "gamma")} == {"gamma"} + assert not await db.list_subblocks(TOPIC, norm_alt) + assert {r["block_norm"] for r in await db.list_question_pattern(TOPIC)} == {"gamma"} + befunde = await pruefe_invarianten(TOPIC) # DB-Sicht: keine Waisen nach dem Re-Key + assert not [b for b in befunde if "Waise" in b] + + +async def test_pruefe_luecken_freispruch(env, monkeypatch): + """Offene Lücken + Konzept-Lücken: 2:1 „keine echte Lücke" → Freispruch persistiert; + _zaehlbare_luecken zählt freigesprochene Fundstellen nicht mehr.""" + import qa as qa_mod + db, seed, files, write_report = env + await seed("Alpha", "beschr") + lk = [{"datei": "f.txt", "abschnitt": 1, "vorschau": "verwaister Abschnitt", "llm": "ja"}] + write_report(_report(luecken=lk, konzept_luecken=["Satz von Foo"])) + + async def fake_agent(key, prompt, timeout, **kw): + return 0, '{"relevant": {"1": "nein"}}', "" # beide Judges: keine echte Lücke + + import agents; monkeypatch.setattr(agents, "run_agent", fake_agent) + res = await repair.repair_befunde(TOPIC, "inventory") + assert len(res["freigesprochen"]) == 2 + frei = qa_mod.lade_freispruch(TOPIC) + assert qa_mod.luecken_key(lk[0]) in set(frei.get("luecken") or []) + assert "satz von foo" in {s.casefold() for s in frei.get("konzept_luecken") or []} + lk[0]["freispruch"] = True + assert qa_mod._zaehlbare_luecken(lk, llm=True) == [] diff --git a/backend/train_params.py b/backend/train_params.py index ea163e3..825b455 100644 --- a/backend/train_params.py +++ b/backend/train_params.py @@ -27,7 +27,7 @@ PARAMS: dict[str, dict] = { "SEED_COVER_COS": {"default": 0.80, "min": 0.7, "max": 0.9, "step": 0.02, "kategorie": "auswahl", "fidelity": "board2"}, "SUB_DUP_KANDIDAT_COS": {"default": 0.75, "min": 0.65, "max": 0.85, "step": 0.02, "kategorie": "auswahl", "fidelity": "board2"}, "EMBEDDING_BLOCK_FLOOR": {"default": 0.5, "min": 0.35, "max": 0.65, "step": 0.05, "kategorie": "auswahl", "fidelity": "voll"}, - "CROSS_CHUNK_PAARE": {"default": 40, "min": 15, "max": 80, "step": 10, "kategorie": "laufzeit", "fidelity": "board2"}, + "GROUP_THEMES_PER_SQRT": {"default": 1.0, "min": 0.6, "max": 1.6, "step": 0.2, "kategorie": "auswahl", "fidelity": "voll"}, # Guide "GATE_FIX_MIN": {"default": 3, "min": 1, "max": 6, "step": 1, "kategorie": "qualitaet", "fidelity": "board2"}, "WRITER_SPLIT_SUBS": {"default": 30, "min": 15, "max": 45, "step": 5, "kategorie": "qualitaet", "fidelity": "board2"}, diff --git a/frontend/src/components/GenerationView.vue b/frontend/src/components/GenerationView.vue index 258248e..ce723db 100644 --- a/frontend/src/components/GenerationView.vue +++ b/frontend/src/components/GenerationView.vue @@ -69,33 +69,36 @@ const pct = (note) => Math.round(note * 10) + ' %' // ── Laufzeit + Tokens aus /api/runs (5s-Takt, unabhängig vom 1,2s-Board-Poll) ────── const run = ref(null) +const latest = ref(null) // jüngster Lauf MIT Daten je Ebene — überlebt „Nur Artefakte"-Läufe const now = ref(Date.now()) let clock = null async function loadRun() { try { - const { runs } = await fetchRuns(props.topic, 1) - run.value = runs[0] || null - } catch { run.value = null } + const res = await fetchRuns(props.topic, 1) + run.value = res.runs[0] || null + latest.value = res.latest || null + } catch { run.value = null; latest.value = null } } const { start: startRunPoll } = usePolling(loadRun, () => props.generating, 5000) -// Per-Ebene: Zeit + Tokens getrennt für Inventar vs. Artefakte (aus run.boards). +// Per-Ebene: Zeit + Tokens getrennt für Inventar vs. Artefakte. Live tickt nur die Ebene, +// deren Lauf der AKTUELLE ist (b.aktiv) — sonst zählt die alte Inventar-Zeile mit hoch. function boardZeit(b) { if (!b?.start) return null const start = Date.parse(b.start) - const ende = run.value?.aktiv ? now.value : Date.parse(b.ende || b.start) + const ende = b.aktiv ? now.value : Date.parse(b.ende || b.start) return fmtRuntime((ende - start) / 1000) } function boardTokens(b) { const t = b?.tokens return t && (t.input || t.output) ? fmtTokens((t.input || 0) + (t.output || 0)) : null } -const invStat = computed(() => run.value?.boards?.inventory) -const artStat = computed(() => run.value?.boards?.artefacts) +const invStat = computed(() => latest.value?.inventory) +const artStat = computed(() => latest.value?.artefacts) watch(() => props.generating, (g) => { if (g) { startRunPoll(); if (!clock) clock = setInterval(() => { now.value = Date.now() }, 1000) } else { loadRun(); if (clock) { clearInterval(clock); clock = null } } // Endstand }, { immediate: true }) -watch(() => props.topic, () => { run.value = null; loadRun() }) +watch(() => props.topic, () => { run.value = null; latest.value = null; loadRun() }) onUnmounted(() => { if (clock) clearInterval(clock) }) const { isArmed, armOrRun, reset: resetConfirm } = useConfirm() // 2-Klick-Bestätigung @@ -134,7 +137,7 @@ async function repairClick(ebene) { const suffix = max > 1 ? ` — Runde ${i + 1}${note != null ? ` (${Math.round(note * 10)} %)` : ''}` : '' repairInfo.value = { ...repairInfo.value, [ebene]: repairText(r) + suffix } if (note == null || note >= 10) break // 100 % erreicht - if (prev != null && note <= prev) break // Stillstand: Repair bewegt nichts mehr + if (r.aktionen === 0 && prev != null && note <= prev) break // Stillstand: nichts getan, Note steht prev = note } } catch (e) { diff --git a/frontend/src/components/GuideBoardSection.vue b/frontend/src/components/GuideBoardSection.vue index 6425abd..d657fde 100644 --- a/frontend/src/components/GuideBoardSection.vue +++ b/frontend/src/components/GuideBoardSection.vue @@ -41,14 +41,14 @@ const progressValue = computed(() => (total.value ? done.value / total.value : 0 // QA-Badge nur wenn es ein Board gibt — sonst zeigt ein verwaister Report eine alte Note. const qaNote = computed(() => (total.value ? board.value?.qa_guide : null)) -// Laufzeit + Tokens aus /api/runs (deckt auch Guide-Läufe ab — beide setzen run_id) +// Laufzeit + Tokens aus /api/runs — `latest.guide` ist der jüngste GUIDE-Lauf +// (der jüngste Lauf insgesamt kann ein Blocks-Lauf sein und zeigte hier dessen Zahlen). const run = ref(null) const now = ref(Date.now()) let clock = null async function loadRun() { try { - const { runs } = await fetchRuns(props.topic, 1) - run.value = runs[0] || null + run.value = (await fetchRuns(props.topic, 1)).latest?.guide || null } catch { run.value = null } } const { start: startRunPoll } = usePolling(loadRun, () => generating.value, 5000) diff --git a/templates/Prompt/Blocks-Gruppierung.md b/templates/Prompt/Blocks-Gruppierung.md index 15a4565..1282d10 100644 --- a/templates/Prompt/Blocks-Gruppierung.md +++ b/templates/Prompt/Blocks-Gruppierung.md @@ -1,6 +1,6 @@ Topic "{topic}". A previous step produced a flat list of learning items that is TOO FINE-GRAINED for a table of contents — single environment variables, single CLI flags, single config fields, near-synonyms. Your job is **card sorting**: group the items into a small set of **theme blocks**, so that EVERY item lands in exactly one theme. Nothing is discarded — grouping never loses an item; it only organises them. -Think of the result as chapters: ~15–25 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} diff --git a/templates/Prompt/Blocks-Luecken-Research.md b/templates/Prompt/Blocks-Luecken-Research.md new file mode 100644 index 0000000..6703a29 --- /dev/null +++ b/templates/Prompt/Blocks-Luecken-Research.md @@ -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} \ No newline at end of file diff --git a/templates/Prompt/Subblock-Crossblock.md b/templates/Prompt/Subblock-Crossblock.md deleted file mode 100644 index cc698b3..0000000 --- a/templates/Prompt/Subblock-Crossblock.md +++ /dev/null @@ -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} diff --git a/templates/QA/QA-Konzept-Luecken.md b/templates/QA/QA-Konzept-Luecken.md new file mode 100644 index 0000000..efdcb6f --- /dev/null +++ b/templates/QA/QA-Konzept-Luecken.md @@ -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} \ No newline at end of file