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