update
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -370,27 +370,60 @@ def _pick_conversion(md: str | None, plain: str | None) -> tuple[str, str] | Non
|
||||
return plain, "pdftotext"
|
||||
|
||||
|
||||
# Small-Caps-/Math-Italic-Artefakte der PDF-Extraktion: LaTeX-\textsc/Kerning liest sich als
|
||||
# Binnen-Leerzeichen („H ITTING S ET", „N P") und erzeugt in der QA Phantom-Konzepte, die kein
|
||||
# Block je ankern kann. Regel A: Einzelgroßbuchstabe + GROSSLAUF(≥2) mergen — außer der Lauf
|
||||
# wird klein fortgesetzt („L NP-vollständig": L ist Variable, NP gehört zum Kompositum).
|
||||
_PDF_CAPS_SPLIT = re.compile(r"(?<![A-Za-zÄÖÜäöüß])([A-ZÄÖÜ]) ([A-ZÄÖÜ]{2,})(?!-?[a-zäöüß])")
|
||||
# Regel B: Einzelbuchstaben-PAAR („N P") — nur mergen, wenn das Ergebnis im selben Dokument
|
||||
# mehrfach ungespalten vorkommt (Frequenz-Beleg statt Domänenliste: „NP" ja, „C Y" nein).
|
||||
_PDF_LETTER_PAIR = re.compile(r"(?<![A-Za-zÄÖÜäöüß])([A-ZÄÖÜ]) ([A-ZÄÖÜ])(?![A-Za-zÄÖÜäöüß-])")
|
||||
_PDF_NORM_VERSION = 1 # bump → nächster Lauf re-konvertiert alle PDFs (Marker .pdf-txt-norm)
|
||||
|
||||
|
||||
def _entzerre_pdf_woerter(text: str) -> str:
|
||||
"""Gespaltene Wörter aus der PDF-Konvertierung zusammenfügen (nur Merges, kein Umbau).
|
||||
Bewusste Restlücken: Zeilenumbruch-Splits und Mehrfach-Splits ohne Großlauf — dafür
|
||||
sind Lücken-Research + Freispruch das Netz."""
|
||||
prev = None
|
||||
while prev != text: # Ketten: „V ERTEX C OVER" braucht zwei Durchgänge pro Segment
|
||||
prev = text
|
||||
text = _PDF_CAPS_SPLIT.sub(r"\1\2", text)
|
||||
|
||||
def _belegt(m: re.Match) -> str:
|
||||
merged = m.group(1) + m.group(2)
|
||||
n = len(re.findall(rf"(?<![A-Za-zÄÖÜäöüß]){merged}(?![A-Za-zÄÖÜäöüß])", text))
|
||||
return merged if n >= 3 else m.group(0)
|
||||
|
||||
return _PDF_LETTER_PAIR.sub(_belegt, text)
|
||||
|
||||
|
||||
def _convert_pdfs(project: Path) -> None:
|
||||
"""Convert PDFs in the project to .txt — agents read text instead of page images.
|
||||
|
||||
Called before every project generation; converts only if the .txt is missing or
|
||||
older than the PDF. Both converters run; the fidelity guard picks the better result
|
||||
per file. Neither available → hard error instead of an unreliable direct-read mode
|
||||
(MiniMax image limit, vision cost)."""
|
||||
(MiniMax image limit, vision cost). Ein Versions-Marker (.pdf-txt-norm) erzwingt nach
|
||||
Änderungen an der Wort-Entzerrung einmalig die Re-Konvertierung trotz mtime-Cache."""
|
||||
pdfs = list(project.rglob("*.pdf"))
|
||||
if not pdfs:
|
||||
return
|
||||
marker = project / ".pdf-txt-norm"
|
||||
aktuell = marker.exists() and marker.read_text(encoding="utf-8").strip() == str(_PDF_NORM_VERSION)
|
||||
for pdf in pdfs:
|
||||
txt = pdf.with_suffix(".txt")
|
||||
if txt.exists() and txt.stat().st_mtime >= pdf.stat().st_mtime:
|
||||
if aktuell and txt.exists() and txt.stat().st_mtime >= pdf.stat().st_mtime:
|
||||
continue
|
||||
picked = _pick_conversion(_pdf_markdown(pdf), _pdf_plaintext(pdf))
|
||||
if picked is None:
|
||||
raise RuntimeError(f"PDF conversion failed ({pdf.name}): weder pymupdf4llm noch "
|
||||
"pdftotext verfügbar/erfolgreich (pip install pymupdf4llm oder poppler-utils)")
|
||||
text, tool = picked
|
||||
txt.write_text(text, encoding="utf-8")
|
||||
txt.write_text(_entzerre_pdf_woerter(text), encoding="utf-8")
|
||||
_log(project.name, f"PDF konvertiert ({tool}): {pdf.name} → {txt.name}")
|
||||
if not aktuell:
|
||||
marker.write_text(str(_PDF_NORM_VERSION), encoding="utf-8")
|
||||
|
||||
|
||||
_SOURCE_TEMPLATE = {"projekt": "Blocks-Source-Projekt", "uni": "Blocks-Source-Uni", "link": "Blocks-Source-Link"}
|
||||
@@ -1350,28 +1383,6 @@ def _completion_schema(data, n_umbrellas: int, ids: set[int]):
|
||||
return out
|
||||
|
||||
|
||||
# Deterministic backstop to the grouping judge's TEST 1 (type gate): an umbrella may bundle ONLY
|
||||
# constituent sub-definitions of ONE definition. If a member title carries a standalone-unit signal
|
||||
# (a named algorithm / problem / reduction / theorem), the umbrella is dissolved — those stay their own
|
||||
# blocks. Kept narrow so real definition-parts (Konfiguration, Übergangsfunktion δ, Literale, Makespan,
|
||||
# m Maschinen) never match; checked against the aak over-merge (member „Greedy-Algorithmus GA" hits).
|
||||
# Suffix-anchored head nouns (German compounds are head-final: „Approximations+algorithmus" has NO word
|
||||
# boundary before „algorithmus", so \bAlgorithmus\b misses it → the MAX-SAT over-merge). \w* absorbs the
|
||||
# modifier; the head noun stays the discriminator. FP-safe: no real TM/KNF definition-part ends in these
|
||||
# heads (Berechnung is deliberately NOT a head → „Akzeptierende Berechnung" stays a valid member).
|
||||
_GROUP_STANDALONE = re.compile(
|
||||
r'\w*algorithm(?:us|en)\b|\w*problem(?:e|s|en)?\b|\w*reduktion(?:en)?\b|\bscheduling\b|[≤⪯]'
|
||||
r'|^\s*(?:Satz|Lemma|Korollar|Theorem|Bemerkung|Beobachtung)\s*\d'
|
||||
# atomicity: a named COMPLEXITY CLASS / a "…-Vollständigkeit(completeness)" / a "…Transformation" is a
|
||||
# self-contained concept (learning-object / atomic-KC), never a sub-definition — a bundle of ≥1 such
|
||||
# member is siblings, not one model → dissolve (catches the P/NP/NP-Vollständigkeit over-merge that NO
|
||||
# cosine floor separates). Head-final compounds (\w*klasse absorbs "Komplexitäts+klasse"); FP-safe —
|
||||
# no real TM/KNF/TSP/Scheduling definition-part carries these heads.
|
||||
r'|\w*vollständigkeit\b|\w*completeness\b|\w*transformation(?:en)?\b|\w*klasse[nr]?\b', re.I)
|
||||
|
||||
|
||||
|
||||
|
||||
# --- Outline (blocks artifact: chapter structure, only read by the guide) ---
|
||||
|
||||
def _outline_review_schema(data, valid: set[int], n_chapters: int, n_blocks: int):
|
||||
@@ -1640,10 +1651,10 @@ async def _guide_ebene(topic: str, instructions: str, provider: str, is_cancelle
|
||||
|
||||
async def _reparieren():
|
||||
set_p("Befunde beheben (Guide)…")
|
||||
await guide_board.repair_karten(topic, fmt) # befundtragende Karten → pruefer
|
||||
betroffen = await guide_board.repair_karten(topic, fmt) # Karten → pruefer/fix
|
||||
await guide.generate_guide(topic=topic, format_name=fmt, guide_id=guide_id,
|
||||
instructions=instructions, provider=provider) # resumt offene Karten
|
||||
return await _messen()
|
||||
return await _messen(), len(betroffen) > 0
|
||||
|
||||
res = await auto_repair_loop("Guide", erst, _reparieren)
|
||||
if res["grund"] != "fertig":
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
80
backend/tests/test_finalize.py
Normal file
80
backend/tests/test_finalize.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Finalize (board_artefacts._proc_finalize) und blocks-Helfer — Judges gefaked, gegen
|
||||
Test-DB. Die In-Block-Konsolidierung lebt seit dem Verschmelzungs-Umbau in
|
||||
block_calls._verify_block und wird in tests/test_block_calls.py getestet."""
|
||||
|
||||
import blocks
|
||||
import board_artefacts as ba
|
||||
from kanban import Flow
|
||||
from pipeline import GenContext
|
||||
|
||||
TOPIC = "finalize"
|
||||
|
||||
|
||||
def _ctx():
|
||||
return GenContext(topic=TOPIC, provider="test", is_cancelled=lambda: False)
|
||||
|
||||
|
||||
def test_luecken_schnitt_cap():
|
||||
l1 = [f"Aspekt-{k} fehlt" for k in ("eins", "zwei", "drei", "vier", "fünf")]
|
||||
assert blocks._luecken_schnitt(l1, list(l1)) == l1[:3] # Cap 3
|
||||
assert blocks._luecken_schnitt(["Inline-HTML"], ["Tabellen-Syntax"]) == []
|
||||
|
||||
|
||||
def test_neg_set_lemmatisiert():
|
||||
"""kein/keine/keinen falten auf einen Stamm; nicht vs. ohne bleiben verschieden."""
|
||||
a = blocks._neg_set("Fehlerverhalten (kein Syntaxfehler)")
|
||||
b = blocks._neg_set("Fehlerverhalten (keine Syntax-Fehlermeldung)")
|
||||
assert a == b == frozenset({"kein"})
|
||||
assert blocks._neg_set("nicht expandiert") != blocks._neg_set("ohne Expansion")
|
||||
assert blocks._neg_set("niemals gerendert") == blocks._neg_set("nie gerendert")
|
||||
|
||||
|
||||
async def test_finalize_purges_stale_rows(testdb, tmp_path):
|
||||
"""Re-Run-Waisen: Finalize löscht Alt-Fragen/-Artefakte des Blocks vor dem Upsert."""
|
||||
db = testdb
|
||||
await db.upsert_question_pattern(TOPIC, "alpha", "alt-sub", "Alpha", "Alt", "Alte Frage?")
|
||||
await db.put_sub_artifact(TOPIC, "alpha", "alt-sub", "flashcard", "{}", "Alpha", "Alt")
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "finalize", {})
|
||||
files = {k: tmp_path / f"{k}.json" for k in
|
||||
("sub_roh", "facts", "sidecar", "question_pattern", "artefakte")}
|
||||
card = {"card_id": "alpha", "payload": {
|
||||
"title": "Alpha", "raw": {"Alpha": ["Neu"]}, "facts": {},
|
||||
"sidecar": {"Alpha": [{"title": "Neu", "level": "beginner"}]},
|
||||
"pattern": {"Alpha": [{"subblock": "Neu", "question": "F?"}]},
|
||||
"artefacts": {"flashcard": [{"block": "Alpha", "subblock": "Neu", "front": "F", "back": "B"}]}}}
|
||||
flow = Flow(TOPIC, work_dir=tmp_path)
|
||||
await ba._proc_finalize(_ctx(), flow, files, [card])
|
||||
assert {r["sub_norm"] for r in await db.list_question_pattern(TOPIC)} == {"neu"}
|
||||
assert {(r["sub_norm"], r["type"]) for r in await db.get_sub_artefakte(TOPIC)} == {("neu", "flashcard")}
|
||||
|
||||
|
||||
async def test_finalize_defaultet_level_nachzuegler(testdb, tmp_path):
|
||||
"""Finalize klassifiziert level-/relevance-lose consensus-Rows (Default advanced/relevant)."""
|
||||
db = testdb
|
||||
await db.put_subblock(TOPIC, "alpha", "nachzuegler", "Alpha", "Nachzügler", status="consensus")
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "finalize", {})
|
||||
files = {k: tmp_path / f"{k}.json" for k in
|
||||
("sub_roh", "facts", "sidecar", "question_pattern", "artefakte")}
|
||||
card = {"card_id": "alpha", "payload": {"title": "Alpha", "raw": {}, "facts": {},
|
||||
"sidecar": {}, "pattern": {}, "artefacts": {}}}
|
||||
await ba._proc_finalize(_ctx(), Flow(TOPIC, work_dir=tmp_path), files, [card])
|
||||
row = next(r for r in await db.list_subblocks(TOPIC, "alpha"))
|
||||
assert row["level"] == "advanced" and row["relevance"] == "relevant"
|
||||
|
||||
|
||||
async def test_finalize_loescht_stale_consensus(testdb, tmp_path):
|
||||
"""Alt-consensus-Rows, die der Lauf-Sidecar nicht mehr trägt, fliegen raus —
|
||||
variant-Rows bleiben (QA liest die Status). Wurzel der 25 Board-2-losen Waisen."""
|
||||
db = testdb
|
||||
await db.put_subblock(TOPIC, "alpha", "alt-rest", "Alpha", "Alt-Rest", status="consensus")
|
||||
await db.put_subblock(TOPIC, "alpha", "alte-variante", "Alpha", "Alte Variante", status="variant")
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "finalize", {})
|
||||
files = {k: tmp_path / f"{k}.json" for k in
|
||||
("sub_roh", "facts", "sidecar", "question_pattern", "artefakte")}
|
||||
card = {"card_id": "alpha", "payload": {
|
||||
"title": "Alpha", "raw": {"Alpha": ["Neu"]}, "facts": {},
|
||||
"sidecar": {"Alpha": [{"title": "Neu", "level": "beginner"}]},
|
||||
"pattern": {}, "artefacts": {}}}
|
||||
await ba._proc_finalize(_ctx(), Flow(TOPIC, work_dir=tmp_path), files, [card])
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||
assert rows == {"neu": "consensus", "alte-variante": "variant"}
|
||||
@@ -427,6 +427,36 @@ async def test_repair_karten_setzt_befundkarten_auf_pruefer(testdb, tmp_path, mo
|
||||
assert cards["gamma"]["stage"] == "done"
|
||||
|
||||
|
||||
async def test_repair_karten_stil_befunde_direkt_auf_fix(testdb, tmp_path, monkeypatch):
|
||||
"""Nur-Stil-Befunde (redundanz/laengen/lesbarkeit) → direkt auf `fix` mit
|
||||
Auftragszeilen im gate_info (KEIN KRITISCH-Präfix → ein Rewrite ohne Re-Prüfer);
|
||||
die Redundanz-Zeile trägt den Cross-Karten-Kontext, den der Prüfer nie sieht."""
|
||||
import json as _json
|
||||
import qa as qa_mod
|
||||
db = testdb
|
||||
monkeypatch.setattr(qa_mod, "QA_DIR", tmp_path / "qa")
|
||||
for n in ("alpha", "beta", "gamma"):
|
||||
await db.upsert_guide_card(TOPIC, FMT, n, n.title())
|
||||
await db.set_guide_card(TOPIC, FMT, n, stage="done", status="ok", md="<!-- section: X -->\nText")
|
||||
tdir = tmp_path / "qa" / TOPIC
|
||||
tdir.mkdir(parents=True)
|
||||
(tdir / "guide-20260705-000001.json").write_text(_json.dumps({
|
||||
"marker_fehlend": [], "fachlich_falsch": ["Gamma"], "ziel_ohne_anker": [],
|
||||
"redundanz": [{"a": "Alpha: gemeinsamer Absatztext", "b": "Beta: gemeinsamer Absatztext"}],
|
||||
"laengen_ausreisser": [{"block": "Beta", "zeichen": 9000, "budget": 3000}],
|
||||
"lesbarkeit": ["Alpha: Sätze kürzen"],
|
||||
}), encoding="utf-8")
|
||||
betroffen = await gb.repair_karten(TOPIC, FMT)
|
||||
assert sorted(betroffen) == ["Alpha", "Beta", "Gamma"]
|
||||
cards = {c["block_norm"]: c for c in await db.list_guide_cards(TOPIC, FMT)}
|
||||
assert cards["gamma"]["stage"] == "pruefer" # kritisch → Prüfer
|
||||
assert cards["alpha"]["stage"] == cards["beta"]["stage"] == "fix"
|
||||
assert not cards["beta"]["gate_info"].startswith("KRITISCH")
|
||||
assert "BALLAST" in cards["beta"]["gate_info"] and "Alpha" in cards["beta"]["gate_info"]
|
||||
assert "LÄNGE" in cards["beta"]["gate_info"] and "3000" in cards["beta"]["gate_info"]
|
||||
assert "LESBARKEIT: Sätze kürzen" in cards["alpha"]["gate_info"]
|
||||
|
||||
|
||||
async def test_fix_failed_behaelt_befunde(testdb, tmp_path, monkeypatch):
|
||||
"""Scheitert der Fix, dürfen die Prüfer-Befunde nicht stumm verschwinden — sie
|
||||
bleiben im gate_info sichtbar (vorher wurde gate_info geleert)."""
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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) == []
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
20
templates/Prompt/Blocks-Luecken-Research.md
Normal file
20
templates/Prompt/Blocks-Luecken-Research.md
Normal file
@@ -0,0 +1,20 @@
|
||||
Topic "{topic}". A quality audit found GAPS: source-material passages and named results that NO existing inventory block covers. Your job: propose new learning blocks ONLY for these gaps — nothing else. An empty list is a valid answer (when the gaps are filler or already covered under another name).
|
||||
|
||||
EXISTING BLOCKS (do NOT re-propose these or near-synonyms of them):
|
||||
{blocks}
|
||||
|
||||
UNCOVERED MATERIAL (gap passages and named results, with source excerpts):
|
||||
{gaps}
|
||||
|
||||
Rules:
|
||||
- One block per genuinely missing, teachable concept — self-contained, exam-relevant.
|
||||
- Ground every proposal in the material above; never invent canon knowledge the material does not treat.
|
||||
- Title: concrete concept name (max 8 words, no catalog references like "(Satz 6.33)").
|
||||
- Description: ONE sentence naming what the block teaches, grounded in the material.
|
||||
- Skip organizational text, prefaces, exercise scaffolding, duplicates of existing blocks.
|
||||
|
||||
Write ONLY the JSON file to: {out_path}
|
||||
|
||||
Format (`blocks` may be empty):
|
||||
{{"blocks": [{{"title": "…", "description": "…"}}]}}
|
||||
{extra}
|
||||
@@ -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}
|
||||
15
templates/QA/QA-Konzept-Luecken.md
Normal file
15
templates/QA/QA-Konzept-Luecken.md
Normal file
@@ -0,0 +1,15 @@
|
||||
You are an INDEPENDENT quality auditor for a learning-block inventory on the topic "{topic}". Below are NAMED results/concepts a scan found in the source material (named theorems, algorithms, definitions) that seem to map to NO inventory block. For each: is this a real, exam-relevant concept the inventory is missing → **ja**, or is it covered by an existing block under another name / a mangled-extraction artefact / mere organizational labeling → **nein**?
|
||||
|
||||
NAMED RESULTS WITHOUT A COVERING BLOCK:
|
||||
{results}
|
||||
|
||||
Rules:
|
||||
- Covered by an existing block under a synonym, symbol variant or broader umbrella → nein.
|
||||
- Garbled extraction artefacts (split or shredded words) and pure numbering references → nein.
|
||||
- A genuine named result the material teaches and no block captures → ja.
|
||||
- When genuinely unsure → ja (a missed gap is worse than one extra research round).
|
||||
|
||||
Reply with ONLY the JSON — no other text, no code fences.
|
||||
Format (one verdict per number):
|
||||
{{"relevant": {{"1": "ja", "2": "nein"}}}}
|
||||
{extra}
|
||||
Reference in New Issue
Block a user