This commit is contained in:
team3
2026-07-03 11:45:27 +02:00
parent 285317927d
commit abcadd145d
44 changed files with 1909 additions and 292 deletions

View File

@@ -24,9 +24,9 @@ from pathlib import Path
import database as db
import embedding
from agents import kill_process, cancel_scope, clear_scope, run_agent
from config import CONSENSUS_GRACE, CONSENSUS_MAX_ROUNDS, DEFAULT_PROVIDER, CRAWL_KEEP_PATTERNS, CRAWL_NOISE_PATTERNS, CRAWL_MIN_CHARS, QUELLE_RELEVANZ_CHUNK, QUELLE_RELEVANZ_SNIPPET, EMBEDDING_AKTIV, EMBEDDING_SUB_DUP, BLOCKS_GRUPPIERUNG_AKTIV, EMBEDDING_SIBLING_FLOOR, EMBEDDING_SIBLING_CAP, GROUP_RECONCILE_FLOOR, GROUP_MIN_COS_FLOOR, SUB_VARIANT_COS, SEED_COVER_COS
from config import CONSENSUS_GRACE, CONSENSUS_MAX_ROUNDS, DEFAULT_PROVIDER, CRAWL_KEEP_PATTERNS, CRAWL_NOISE_PATTERNS, CRAWL_MIN_CHARS, QUELLE_RELEVANZ_CHUNK, QUELLE_RELEVANZ_SNIPPET, EMBEDDING_AKTIV, EMBEDDING_SUB_DUP, BLOCKS_GRUPPIERUNG_AKTIV, EMBEDDING_SIBLING_FLOOR, EMBEDDING_SIBLING_CAP, GROUP_RECONCILE_FLOOR, GROUP_MIN_COS_FLOOR, SUB_VARIANT_COS, SEED_COVER_COS, EVIDENCE_BUDGET_CHARS, EVIDENCE_CTX_LINES
from fsutil import atomic_write_text, atomic_write_json
from jsonio import read_json_file as _json_file
from jsonio import parse_json_text, read_json_file as _json_file
from paths import arbeit_dir, blocks_path, question_pattern_path, project_dir, subblocks_path, source_path, source_crawl_dir, safe_folder
from crawl import crawl
from pipeline import (
@@ -61,6 +61,8 @@ CONSOLIDATION_CHUNK = 600 # up to here ONE global judge (dedups everything); ab
DEDUP_PAIR_FLOOR = 0.6 # min cosine for a candidate pair (complete-link aggregates → no chaining)
DEDUP_PAIRS_CHUNK = 40 # pairs per judge package (pairwise verification instead of a block mixer)
DEDUP_TITLE_AUTO = 0.95 # near-identical TITLE cosine ⇒ same entity → merge without the judge (recall net)
DEDUP_GLOBAL_FLOOR = 0.65 # global post-naming dedup stage: candidate floor above the 0.5-0.65
# same-domain noise band, below the sibling zone (~0.85) — the judge decides there
FILTER_CHUNK = 35 # blocks to assess per judge in the degrade pass (full list as context)
# Balance question-pattern chunks by sub load via LPT (makespan), not by block count.
QUESTION_CHUNK_SUBS = 50 # target sum of relevant subs per chunk
@@ -308,9 +310,11 @@ def cancel_blocks(topic: str) -> bool:
async def blocks_status(topic: str) -> dict:
"""Kanban-based status: `generating` from the run registry, progress from the card
counts. `partial` = cards sit in non-terminal columns while nothing runs (continue-able)."""
ready = blocks_path(topic).exists() # inventory written → block overview available
generating = topic in _blocks_progress
counts = await db.kanban_stage_counts(topic)
# ready = finished inventory. The DB is the source of truth (a synced topic may lack
# blocks.md, the file is a legacy mirror) — either signal counts.
ready = blocks_path(topic).exists() or counts.get("inventory", {}).get("done_block", 0) > 0
terminal = {"clustered", "done_cluster", "grouped", "rejected", "done_block", "done_artefact", "dead"}
open_cards = sum(n for stages in counts.values()
for stage, n in stages.items() if stage not in terminal)
@@ -366,28 +370,92 @@ def _supplement_schema(data):
return out
def _convert_pdfs(project: Path) -> None:
"""Convert PDFs in the project to .txt (pdftotext) — agents read text instead of page images.
def _ocr_languages() -> str | None:
"""Installierte Tesseract-Sprachen (deu/eng), None → OCR aus."""
try:
import pymupdf
base = Path(pymupdf.get_tessdata())
except Exception:
return None
langs = [l for l in ("deu", "eng") if (base / f"{l}.traineddata").exists()]
return "+".join(langs) or None
Called before every project generation; converts only if the
.txt is missing or older than the PDF. The original is left untouched.
If pdftotext is missing and the project contains PDFs → hard error instead of
an unreliable direct-read mode (MiniMax image limit, vision cost).
"""
def _pdf_markdown(pdf: Path) -> str | None:
"""pymupdf4llm → Markdown string (None if the lib is missing or it fails)."""
try:
import pymupdf4llm
except ImportError:
return None
try:
# OCR nur, wenn Tesseract-Sprachdaten wirklich vorhanden sind —
# sonst wirft der OCR-Pfad und die ganze Datei faellt auf pdftotext.
langs = _ocr_languages()
kwargs = {"use_ocr": True, "ocr_language": langs} if langs else {"use_ocr": False}
return pymupdf4llm.to_markdown(str(pdf), show_progress=False, **kwargs)
except Exception:
log.warning("pymupdf4llm failed for %s", pdf.name, exc_info=True)
return None
def _pdf_plaintext(pdf: Path) -> str | None:
"""pdftotext -layout → plain string (None if missing/fails)."""
if shutil.which("pdftotext") is None:
return None
try:
out = subprocess.run(["pdftotext", "-layout", str(pdf), "-"],
check=True, timeout=120, capture_output=True)
return out.stdout.decode("utf-8", errors="replace")
except Exception:
log.warning("pdftotext failed for %s", pdf.name, exc_info=True)
return None
# Content-fidelity guard between the two converters (topic-NEUTRAL: measures loss, not domain).
# pymupdf4llm yields structured Markdown but silently DROPS rendered display formulas and can
# splinter combining diacritics (measured on a LaTeX script: „P = {L …}" gone, „h¨aufig").
# pdftotext is structure-poor but faithful. Take the Markdown only when it preserves the bulk
# of the content; otherwise the faithful plaintext wins.
_PDF_FIDELITY_SYMBOLS = "≤≥∈∉⊆∪∧∨¬→Σδα{}="
def _pick_conversion(md: str | None, plain: str | None) -> tuple[str, str] | None:
if md is None and plain is None:
return None
if md is None:
return plain, "pdftotext"
if plain is None:
return md, "pymupdf4llm"
ok_len = len(md) >= 0.7 * len(plain)
sym_plain = sum(plain.count(c) for c in _PDF_FIDELITY_SYMBOLS)
ok_sym = sym_plain == 0 or sum(md.count(c) for c in _PDF_FIDELITY_SYMBOLS) >= 0.8 * sym_plain
ok_diakritik = md.count("\u00a8") <= plain.count("\u00a8") + 2 # standalone ¨ = splintered umlauts
if ok_len and ok_sym and ok_diakritik:
return md, "pymupdf4llm"
return plain, "pdftotext"
def _convert_pdfs(project: Path) -> None:
"""Convert PDFs in the project to .txt — agents read text instead of page images.
Called before every project generation; converts only if the .txt is missing or
older than the PDF. Both converters run; the fidelity guard picks the better result
per file. Neither available → hard error instead of an unreliable direct-read mode
(MiniMax image limit, vision cost)."""
pdfs = list(project.rglob("*.pdf"))
if not pdfs:
return
if shutil.which("pdftotext") is None:
raise RuntimeError("pdftotext missing (install poppler-utils) — PDFs in the project cannot be read")
for pdf in pdfs:
txt = pdf.with_suffix(".txt")
if txt.exists() and txt.stat().st_mtime >= pdf.stat().st_mtime:
continue
try:
subprocess.run(["pdftotext", "-layout", str(pdf), str(txt)], check=True, timeout=120)
_log(project.name, f"PDF converted: {pdf.name}{txt.name}")
except Exception as e:
raise RuntimeError(f"PDF conversion failed ({pdf.name}): {e}") from e
picked = _pick_conversion(_pdf_markdown(pdf), _pdf_plaintext(pdf))
if picked is None:
raise RuntimeError(f"PDF conversion failed ({pdf.name}): weder pymupdf4llm noch "
"pdftotext verfügbar/erfolgreich (pip install pymupdf4llm oder poppler-utils)")
text, tool = picked
txt.write_text(text, encoding="utf-8")
_log(project.name, f"PDF konvertiert ({tool}): {pdf.name}{txt.name}")
_SOURCE_TEMPLATE = {"projekt": "Blocks-Source-Projekt", "uni": "Blocks-Source-Uni", "link": "Blocks-Source-Link"}
@@ -429,6 +497,142 @@ def _text_sections(text: str, goal: int = RESEARCH_SECTION_CHARS) -> list[str]:
return sections
# ── Inline evidence for judges ──────────────────────────────────────────────────────
# Judges used to re-search the corpus per session ({source} → "ls/find … read", ~10 tool
# turns each). The corpus excerpts now go INTO the prompt; the agent answers as text.
def _corpus_files(folder: Path | None, sources: list[str] | None) -> list[Path]:
"""The block's named source .txt files; fallback: every .txt in the folder."""
if folder is None or not folder.is_dir():
return []
if sources:
named = [folder / Path(s).with_suffix(".txt").name for s in sources if s]
named = [p for p in named if p.is_file()]
if named:
return named
return sorted(p for p in folder.glob("*.txt") if p.is_file())
def _q_tokens(text: str) -> set[str]:
return set(re.findall(r"\w{3,}", text.casefold()))
def _evidence_pack(folder: Path | None, sources: list[str] | None, queries: list[str],
budget: int = EVIDENCE_BUDGET_CHARS) -> str:
"""Keyword-selected corpus excerpts for a judge prompt. Sections are ranked by token
overlap with `queries` (block title + candidates); every query with any match gets its
best section (coverage guarantee), the rest of the budget takes the global top. Empty
string when there is no corpus — the caller keeps the old self-research source then."""
parts: list[tuple[str, int, str, set[str]]] = [] # (file, idx, text, tokens)
for f in _corpus_files(folder, sources):
try:
text = f.read_text(encoding="utf-8")
except OSError:
continue
for i, sec in enumerate(_text_sections(text), 1):
parts.append((f.name, i, sec, _q_tokens(sec)))
if not parts:
return ""
qtoks = [(_q_tokens(q)) for q in queries if q]
score = [sum(len(qt & p[3]) for qt in qtoks) for p in parts]
chosen: set[int] = set()
for qt in qtoks: # coverage guarantee: best section per query
best = max(range(len(parts)), key=lambda k: len(qt & parts[k][3]), default=None)
if best is not None and qt & parts[best][3]:
chosen.add(best)
used = sum(len(parts[k][2]) for k in chosen)
for k in sorted(range(len(parts)), key=lambda k: -score[k]): # top-up to budget
if k in chosen or score[k] <= 0:
continue
if used + len(parts[k][2]) > budget:
continue
chosen.add(k)
used += len(parts[k][2])
out, total = [], 0
for k in sorted(chosen): # document order for readability
fname, i, sec, _t = parts[k]
if total + len(sec) > max(budget, used): # hard cap incl. guarantee overshoot
break
out.append(f"── {fname} · Abschnitt {i} ──\n{sec}")
total += len(sec)
return "\n\n".join(out)
_CITE_POS = re.compile(r"\b(?:Z(?:eilen?)?|lines?)\.?\s*(\d+)(?:\s*[-]\s*(\d+))?", re.I)
def _cite_ref(cite: str, files: list[Path]) -> tuple[Path, int, int] | None:
"""(file, line_lo, line_hi) from a cited_facts source string
(„Skript.txt, Übung 6.47, Z.1341-1344") — None when file or position is missing."""
c = (cite or "").casefold()
f = next((p for p in files if p.name.casefold() in c or p.stem.casefold() in c), None)
m = _CITE_POS.search(cite or "")
if f is None or m is None:
return None
lo, hi = int(m.group(1)), int(m.group(2) or m.group(1))
return (f, min(lo, hi), max(lo, hi))
def _cited_evidence(folder: Path | None, sources: list[str] | None, cites: list[str],
fallback_queries: list[str], budget: int = EVIDENCE_BUDGET_CHARS) -> str:
"""Evidence for the facts check: the EXACT cited regions (±EVIDENCE_CTX_LINES, merged,
line numbers in the header) — precise and tiny. Cites without a parseable position fall
back to the keyword pack. Empty string without a corpus."""
files = _corpus_files(folder, sources)
if not files:
return ""
ranges: dict[Path, list[tuple[int, int]]] = {}
unresolved = False
for c in cites:
ref = _cite_ref(c, files)
if ref is None:
unresolved = True
continue
f, lo, hi = ref
ranges.setdefault(f, []).append((max(1, lo - EVIDENCE_CTX_LINES), hi + EVIDENCE_CTX_LINES))
out, total = [], 0
for f in files:
if f not in ranges:
continue
try:
lines = f.read_text(encoding="utf-8").splitlines()
except OSError:
continue
merged: list[list[int]] = []
for lo, hi in sorted(ranges[f]):
hi = min(hi, len(lines))
if merged and lo <= merged[-1][1] + 1:
merged[-1][1] = max(merged[-1][1], hi)
else:
merged.append([lo, hi])
for lo, hi in merged:
sec = "\n".join(lines[lo - 1:hi])
if not sec.strip() or total + len(sec) > budget:
continue
out.append(f"── {f.name} · Z. {lo}-{hi} ──\n{sec}")
total += len(sec)
if unresolved or not out:
pack = _evidence_pack(folder, sources, fallback_queries, max(0, budget - total))
if pack:
out.append(pack)
return "\n\n".join(out)
def _reply_text(result) -> str:
"""Assistant text of a no-tool agent call ((rc, stdout, stderr) from run_agent)."""
return (result[1] or "") if result else ""
def _sink_json(result, path: Path, schema):
"""Payload validator for no-tool agents: the JSON comes as reply TEXT; the engine
persists it to `path`, so resume guards and audit files keep working unchanged."""
data = parse_json_text(_reply_text(result))
val = schema(data)
if val is not None:
atomic_write_json(path, data)
return val
def _build_research_prompt(topic: str, out_path: Path, instructions: str, type: str, folder: Path | None, fokus: str = "", section: str = "") -> str:
if section:
# Section mode (uni/projekt): text directly in the prompt → small context, no file reading.
@@ -603,7 +807,7 @@ def _variant_clusters(titles: list[str], mentions: list[int], sims) -> list[dict
async def _subblocks_block(ctx: GenContext, set_p, files: dict, entries: dict, instructions: str,
wipe: bool = True, ns: str = "", seeds: list[str] | None = None,
lbl: str = "") -> dict | None:
lbl: str = "", sources: list[str] | None = None) -> dict | None:
"""Block B (DB + loop): per package, find subblocks in rounds (3 finders, until 0 new/cap),
collect in the DB (variant-clustered mentions ≥2 = consensus), a judge panel cleans up per
package; blocks below SUBBLOCK_MIN get focused catch-up rounds; `seeds` (demoted fragment
@@ -832,11 +1036,25 @@ async def _subblocks_block(ctx: GenContext, set_p, files: dict, entries: dict, i
for _, p in pending:
p.unlink(missing_ok=True)
if pending:
# Inline evidence: corpus excerpts in the prompt (no self-research); the judge
# answers as TEXT, the engine persists the j-file (resume + majority unchanged).
ev = _evidence_pack(folder, sources,
[title_by_num[num] for num in chunk]
+ [s for num in chunk for s in shown_by_num.get(num, [])]) if folder else ""
j_source = _prompt("Blocks-Source-Inline", excerpts=ev) if ev else source
def _sink(result, p):
text = _reply_text(result).strip()
d = _parse_subblocks(text)
if d:
atomic_write_text(p, text)
return d or None
slots = [{
"key": f"blocks-{topic}-{ns}subblock-final-c{c}{tag}-j{j}",
"prompt": _prompt("Subblock-Mapping", topic=topic, source=source, blocks="\n\n".join(block_texts), out_path=p, extra=_extra(instructions)),
"role": "judge", "capabilities": caps,
"payload": (lambda result, p=p: _parse_subblocks(_read(p)) or None),
"prompt": _prompt("Subblock-Mapping", topic=topic, source=j_source, blocks="\n\n".join(block_texts), out_path=p, extra=_extra(instructions)),
"role": "judge", "capabilities": "none" if ev else caps,
"payload": (lambda result, p=p: _sink(result, p)),
} for j, p in pending]
existing = SUBBLOCK_PANEL - len(pending)
await _race(topic, f"{lbl}Subblock-Clarification {c}", slots, max(1, 2 - existing),
@@ -1102,8 +1320,8 @@ async def _levels_block(ctx: GenContext, set_p, files: dict, raw: dict, instruct
slots = [{
"key": f"blocks-{topic}-{ns}level-c{c}-{i}",
"prompt": _prompt("Levels-Research", topic=topic, subblocks=enum, out_path=p, extra=_extra(instructions)),
"role": "quick", "capabilities": "files",
"payload": (lambda result, p=p, ids=local_set: _levels_schema(_json_file(p), ids)),
"role": "quick", "capabilities": "none", # pure mapping, everything inline → text reply
"payload": (lambda result, p=p, ids=local_set: _sink_json(result, p, lambda d: _levels_schema(d, ids))),
} for i, p in pending]
new = await _race(topic, f"{lbl}Levels package {c}", slots, 2 - existing, _timeout("level", len(item_idxs)), provider, cancelled=is_cancelled, grace=CONSENSUS_GRACE)
return not is_cancelled() and new is not None
@@ -1135,8 +1353,8 @@ async def _levels_block(ctx: GenContext, set_p, files: dict, raw: dict, instruct
ctx, f"{lbl}Levels-Clarification {c}",
key=f"blocks-{topic}-{ns}level-final-c{c}",
prompt=_prompt("Levels-Mapping", topic=topic, disputed=disputed_block, out_path=judge_path, extra=_extra(instructions)),
role="judge", capabilities="files",
payload=lambda result, p=judge_path, ids=set(strittig): _levels_schema(_json_file(p), ids),
role="judge", capabilities="none",
payload=lambda result, p=judge_path, ids=set(strittig): _sink_json(result, p, lambda d: _levels_schema(d, ids)),
timeout=_timeout("level_check", len(strittig)),
)
if status == FAILED:
@@ -1245,7 +1463,7 @@ def _facts_complete(files: dict) -> bool:
return isinstance(d, dict) and bool(d)
async def _facts_block(ctx, set_p, files: dict, raw: dict, q: dict, folder, instructions: str, ns: str = "", lbl: str = "") -> tuple | None:
async def _facts_block(ctx, set_p, files: dict, raw: dict, q: dict, folder, instructions: str, ns: str = "", lbl: str = "", sources: list[str] | None = None) -> tuple | None:
"""Block: per sub extract source facts (find) → verify (check) → correct/discard (fix).
Extract-once grounding: the result feeds level/relevance/questions/guide.
→ (facts_map, discarded_map) — facts_map {block: {sub_norm: facts}}, discarded_map
@@ -1367,13 +1585,24 @@ async def _facts_block(ctx, set_p, files: dict, raw: dict, q: dict, folder, inst
if not per:
return ci, set(), set()
facts_text = "\n\n".join(f"SUBBAUSTEIN: {fk['sub']}\n{_facts_lines(fk)}" for fm in per.values() for fk in fm.values())
# Inline evidence: the EXACT cited regions (falls back to keyword excerpts) go into
# the prompt; the judge answers as TEXT, the engine persists the check file.
cites = [bf.get("source", "") for fm in per.values() for fk in fm.values()
for bf in fk.get("cited_facts", [])]
fallback = [blocks[i][0] for i in idxs] + [fk["sub"] for fm in per.values() for fk in fm.values()]
ev = _cited_evidence(folder, sources, cites, fallback) if folder else ""
c_source = _prompt("Blocks-Source-Inline", excerpts=ev) if ev else source
pending = [j for j in (1, 2, 3)[:FACTS_CHECK_PANEL] if _facts_check_schema(_json_file(chk_path(ci, j))) is None]
await asyncio.gather(*[
rs = await asyncio.gather(*[
run_agent(f"blocks-{topic}-{ns}facts-check-c{ci}-j{j}",
_prompt("Facts-Check", topic=topic, source=source, facts=facts_text, out_path=chk_path(ci, j), extra=_extra(instructions)),
_timeout("content_check", len(per)), provider=provider, role="judge", capabilities=caps,
_prompt("Facts-Check", topic=topic, source=c_source, facts=facts_text, out_path=chk_path(ci, j), extra=_extra(instructions)),
_timeout("content_check", len(per)), provider=provider, role="judge",
capabilities="none" if ev else caps,
scope=topic, label=f"{lbl}Facts check {ci}/{j}")
for j in pending], return_exceptions=True)
for j, r in zip(pending, rs):
if isinstance(r, tuple):
_sink_json(r, chk_path(ci, j), _facts_check_schema)
outs = [s for j in (1, 2, 3)[:FACTS_CHECK_PANEL] if (s := _facts_check_schema(_json_file(chk_path(ci, j)))) is not None]
bvotes: dict[str, int] = {}
vvotes: dict[str, int] = {}
@@ -1484,8 +1713,8 @@ async def _relevance_block(ctx: GenContext, set_p, files: dict, sidecar: dict, i
slots = [{
"key": f"blocks-{topic}-{ns}relevance-c{c}-{i}",
"prompt": _prompt("Relevance-Research", topic=topic, subblocks=enum, out_path=p, extra=_extra(instructions)),
"role": "quick", "capabilities": "files",
"payload": (lambda result, p=p, ids=local_set: _relevance_schema(_json_file(p), ids)),
"role": "quick", "capabilities": "none", # pure mapping, everything inline → text reply
"payload": (lambda result, p=p, ids=local_set: _sink_json(result, p, lambda d: _relevance_schema(d, ids))),
} for i, p in pending]
new = await _race(topic, f"{lbl}Relevance package {c}", slots, 2 - existing, _timeout("relevance", len(item_idxs)), provider, cancelled=is_cancelled, grace=CONSENSUS_GRACE)
return not is_cancelled() and new is not None
@@ -1517,8 +1746,8 @@ async def _relevance_block(ctx: GenContext, set_p, files: dict, sidecar: dict, i
ctx, f"{lbl}Relevance-Clarification {c}",
key=f"blocks-{topic}-{ns}relevance-final-c{c}",
prompt=_prompt("Relevance-Mapping", topic=topic, disputed=disputed_block, out_path=judge_path, extra=_extra(instructions)),
role="judge", capabilities="files",
payload=lambda result, p=judge_path, ids=set(strittig): _relevance_schema(_json_file(p), ids),
role="judge", capabilities="none",
payload=lambda result, p=judge_path, ids=set(strittig): _sink_json(result, p, lambda d: _relevance_schema(d, ids)),
timeout=_timeout("relevance_check", len(strittig)),
)
if status == FAILED:
@@ -1677,8 +1906,8 @@ async def _question_pattern_block(ctx: GenContext, set_p, files: dict, sidecar:
ctx, f"{lbl}Question-Pattern-Clarification {ci}",
key=f"blocks-{topic}-{ns}question-pattern-final-c{ci}",
prompt=_prompt("Question-Pattern-Critique", topic=topic, table="\n\n".join(block_texts), out_path=fp, extra=_extra(instructions)),
role="judge", capabilities="files",
payload=lambda result, p=fp: _question_pattern_chunk_schema(_json_file(p)),
role="judge", capabilities="none", # pure review, everything inline → text reply
payload=lambda result, p=fp: _sink_json(result, p, _question_pattern_chunk_schema),
timeout=_timeout("question_pattern_check", subs_total),
)
if status == FAILED:
@@ -2087,11 +2316,22 @@ _CANON_STOP = re.compile(
r'von|of|für|for|und|and|zum|zur|im)\b', re.I)
# Catalogue references ("Definition 6.19", "Satz 7.8") are scaffolding INCLUDING their number —
# stripped as a phrase, so the digits don't pollute the key. Variant digits ("3-SAT") have no
# scaffolding word in front and survive.
_CANON_CATALOGUE = re.compile(
r'\b(?:definition|def|satz|lemma|korollar|corollary|theorem|proposition'
r'|kapitel|chapter|abschnitt|section)\s*\d+(?:\.\d+)*\b', re.I)
def _canonical_key(title: str) -> str:
"""Order-independent canonical key of a title (scaffolding stripped, relation operators normalized).
Two titles with the same key denote the same entity with ~100% precision (ER blocking). Empty string
if nothing survives (never auto-merged)."""
s = unicodedata.normalize("NFKC", title).casefold()
s = unicodedata.normalize("NFKC", title)
s = re.sub(r'([a-zäöüß])([A-ZÄÖÜ])', r'\1 \2', s) # CamelCase → two tokens
s = s.casefold()
s = _CANON_CATALOGUE.sub(' ', s)
s = re.sub(r'≟|\bversus\b|\bvs\.?\b|=', ' opeq ', s) # equality / "vs" → one token
s = re.sub(r'≤|⪯|→|⇒|⟹|\breduces?\s+to\b|\breduziert\b', ' opred ', s) # reduction → one token
s = _CANON_STOP.sub(' ', s)
@@ -2103,14 +2343,20 @@ def _canonical_key(title: str) -> str:
# operands AND direction (RDF-triple identity / SKOS narrowMatch — a subset/restriction is NOT the same).
# So "SAT ≤ Clique" ≠ "3-SAT ≤ Clique" (source differs) and "A → B" ≠ "B → A" (direction). Two DIFFERENT
# relations must never merge, even if a judge or a high title-cosine says so.
_REL_STRIP = re.compile(r'^\s*(?:satz|lemma|korollar|theorem)\s*[\d.]*\s*:?\s*|^\s*reduktion(?:en)?\s*:?\s*', re.I)
_REL_OPERATOR = re.compile(r'[≤⪯≥⊆⊊→⇒⟹⇔←]|=>|<=|->')
_REL_STRIP = re.compile(r'^\s*(?:satz|lemma|korollar|corollary|theorem|proposition)\s*[\d.]*\s*:?\s*'
r'|^\s*redu[ck]tion(?:en|s)?\s*:?\s*', re.I)
# trailing scaffolding ("SetCover ≤ HittingSet Reduktion") is NOT part of the target operand —
# without this strip the guard false-alarms and blocks the correct merge with the bare relation
_REL_STRIP_TAIL = re.compile(r'[\s\-]*(?:redu[ck]tion(?:en|s)?|transformation(?:en|s)?)\s*$', re.I)
# an attached p/m marker ("≤p", "≤m") is operator notation, not part of the right operand
_REL_OPERATOR = re.compile(r'[≤⪯≥⊆⊊→⇒⟹⇔←][pm]?|=>|<=|->', re.I)
def _relation_operands(title: str) -> tuple[str, str] | None:
"""(canonical_source, canonical_target) of a relation/reduction title, else None (not a relation).
Operands canonicalized (lowercased, non-alphanumerics stripped) so spacing/hyphenation don't matter."""
t = _REL_STRIP.sub('', title, count=1)
t = _REL_STRIP_TAIL.sub('', t, count=1)
m = _REL_OPERATOR.search(t)
if not m:
return None
@@ -2702,12 +2948,16 @@ async def _artefacts_block(ctx: GenContext, set_p, files: dict, sidecar: dict, i
for k, e in enumerate(items, 1))
pending = [j for j in (1, 2, 3)[:FACTS_CHECK_PANEL] if _example_check_schema(_json_file(cpath(j))) is None]
if pending:
await asyncio.gather(*[
# ground truth (facts) is fully inline → no tools, text reply, engine persists
rs = await asyncio.gather(*[
run_agent(f"blocks-{topic}-{ns}artifact-example-check-c{ci}-j{j}",
_prompt("Artifact-Example-Check", topic=topic, facts=block_text(idxs), examples=examples_txt, out_path=cpath(j), extra=_extra(instructions)),
_timeout("content_check", len(items)), provider=provider, role="judge", capabilities=caps,
_timeout("content_check", len(items)), provider=provider, role="judge", capabilities="none",
scope=topic, label=f"{lbl}Beispiel-Check {ci}/{j}")
for j in pending], return_exceptions=True)
for j, r in zip(pending, rs):
if isinstance(r, tuple):
_sink_json(r, cpath(j), _example_check_schema)
outs = [s for j in (1, 2, 3)[:FACTS_CHECK_PANEL] if (s := _example_check_schema(_json_file(cpath(j)))) is not None]
if not outs:
return items # no exam possible → keep (best-effort)