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

@@ -14,6 +14,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \ ca-certificates \
gnupg \ gnupg \
poppler-utils \ poppler-utils \
tesseract-ocr \
tesseract-ocr-deu \
tesseract-ocr-eng \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \ && apt-get install -y nodejs \
&& npm install -g @anthropic-ai/claude-code opencode-ai \ && npm install -g @anthropic-ai/claude-code opencode-ai \

View File

@@ -12,11 +12,12 @@ auth:
@echo "Verzeichnisse angelegt und auf uid 1000 chowned." @echo "Verzeichnisse angelegt und auf uid 1000 chowned."
install: install:
pip install --break-system-packages fastapi uvicorn[standard] aiosqlite uv playwright transformers trafilatura pip install --break-system-packages fastapi uvicorn[standard] aiosqlite uv playwright transformers trafilatura pymupdf4llm
pip install --break-system-packages torch --index-url https://download.pytorch.org/whl/cpu pip install --break-system-packages torch --index-url https://download.pytorch.org/whl/cpu
python3 -m playwright install chromium python3 -m playwright install chromium
@echo "Falls Chromium OS-Libs fehlen: 'sudo python3 -m playwright install-deps chromium' einmalig ausführen." @echo "Falls Chromium OS-Libs fehlen: 'sudo python3 -m playwright install-deps chromium' einmalig ausführen."
@which pdftotext >/dev/null 2>&1 || sudo apt-get install -y poppler-utils @which pdftotext >/dev/null 2>&1 || sudo apt-get install -y poppler-utils
@which tesseract >/dev/null 2>&1 || sudo apt-get install -y tesseract-ocr tesseract-ocr-deu tesseract-ocr-eng
cd frontend && npm install cd frontend && npm install
npm install -g opencode-ai npm install -g opencode-ai
@mkdir -p $(HOME)/.config/opencode @mkdir -p $(HOME)/.config/opencode

View File

@@ -106,8 +106,8 @@ _topic_sems: dict[str, _PrioritySemaphore] = {}
# Within board 2 the LATE stages win (outline → artefacts → … → subblocks): finish cards # Within board 2 the LATE stages win (outline → artefacts → … → subblocks): finish cards
# instead of opening new WIP, so the makespan tail block gets slots before fresh work. # instead of opening new WIP, so the makespan tail block gets slots before fresh work.
_STAGE_PRIORITY = ("research", "ingest", "cluster", "pair", "clarify", "naming", "filter", _STAGE_PRIORITY = ("research", "ingest", "cluster", "pair", "clarify", "naming", "filter",
"grouping", "supplement", "outline", "artifact", "question", "relevance", "dedup", "grouping", "gruppierung", "supplement", "outline", "artifact",
"level", "facts", "subblock") "question", "relevance", "level", "facts", "subblock")
def _agent_priority(key: str) -> int: def _agent_priority(key: str) -> int:

View File

@@ -24,9 +24,9 @@ from pathlib import Path
import database as db import database as db
import embedding import embedding
from agents import kill_process, cancel_scope, clear_scope, run_agent 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 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 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 crawl import crawl
from pipeline import ( 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_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_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_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) 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. # 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 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: async def blocks_status(topic: str) -> dict:
"""Kanban-based status: `generating` from the run registry, progress from the card """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).""" 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 generating = topic in _blocks_progress
counts = await db.kanban_stage_counts(topic) 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"} terminal = {"clustered", "done_cluster", "grouped", "rejected", "done_block", "done_artefact", "dead"}
open_cards = sum(n for stages in counts.values() open_cards = sum(n for stages in counts.values()
for stage, n in stages.items() if stage not in terminal) for stage, n in stages.items() if stage not in terminal)
@@ -366,28 +370,92 @@ def _supplement_schema(data):
return out return out
def _convert_pdfs(project: Path) -> None: def _ocr_languages() -> str | None:
"""Convert PDFs in the project to .txt (pdftotext) — agents read text instead of page images. """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. def _pdf_markdown(pdf: Path) -> str | None:
If pdftotext is missing and the project contains PDFs → hard error instead of """pymupdf4llm → Markdown string (None if the lib is missing or it fails)."""
an unreliable direct-read mode (MiniMax image limit, vision cost). 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")) pdfs = list(project.rglob("*.pdf"))
if not pdfs: if not pdfs:
return 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: 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 txt.exists() and txt.stat().st_mtime >= pdf.stat().st_mtime:
continue continue
try: picked = _pick_conversion(_pdf_markdown(pdf), _pdf_plaintext(pdf))
subprocess.run(["pdftotext", "-layout", str(pdf), str(txt)], check=True, timeout=120) if picked is None:
_log(project.name, f"PDF converted: {pdf.name}{txt.name}") raise RuntimeError(f"PDF conversion failed ({pdf.name}): weder pymupdf4llm noch "
except Exception as e: "pdftotext verfügbar/erfolgreich (pip install pymupdf4llm oder poppler-utils)")
raise RuntimeError(f"PDF conversion failed ({pdf.name}): {e}") from e 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"} _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 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: def _build_research_prompt(topic: str, out_path: Path, instructions: str, type: str, folder: Path | None, fokus: str = "", section: str = "") -> str:
if section: if section:
# Section mode (uni/projekt): text directly in the prompt → small context, no file reading. # 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, async def _subblocks_block(ctx: GenContext, set_p, files: dict, entries: dict, instructions: str,
wipe: bool = True, ns: str = "", seeds: list[str] | None = None, 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), """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 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 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: for _, p in pending:
p.unlink(missing_ok=True) p.unlink(missing_ok=True)
if pending: 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 = [{ slots = [{
"key": f"blocks-{topic}-{ns}subblock-final-c{c}{tag}-j{j}", "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)), "prompt": _prompt("Subblock-Mapping", topic=topic, source=j_source, blocks="\n\n".join(block_texts), out_path=p, extra=_extra(instructions)),
"role": "judge", "capabilities": caps, "role": "judge", "capabilities": "none" if ev else caps,
"payload": (lambda result, p=p: _parse_subblocks(_read(p)) or None), "payload": (lambda result, p=p: _sink(result, p)),
} for j, p in pending] } for j, p in pending]
existing = SUBBLOCK_PANEL - len(pending) existing = SUBBLOCK_PANEL - len(pending)
await _race(topic, f"{lbl}Subblock-Clarification {c}", slots, max(1, 2 - existing), 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 = [{ slots = [{
"key": f"blocks-{topic}-{ns}level-c{c}-{i}", "key": f"blocks-{topic}-{ns}level-c{c}-{i}",
"prompt": _prompt("Levels-Research", topic=topic, subblocks=enum, out_path=p, extra=_extra(instructions)), "prompt": _prompt("Levels-Research", topic=topic, subblocks=enum, out_path=p, extra=_extra(instructions)),
"role": "quick", "capabilities": "files", "role": "quick", "capabilities": "none", # pure mapping, everything inline → text reply
"payload": (lambda result, p=p, ids=local_set: _levels_schema(_json_file(p), ids)), "payload": (lambda result, p=p, ids=local_set: _sink_json(result, p, lambda d: _levels_schema(d, ids))),
} for i, p in pending] } 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) 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 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}", ctx, f"{lbl}Levels-Clarification {c}",
key=f"blocks-{topic}-{ns}level-final-c{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)), prompt=_prompt("Levels-Mapping", topic=topic, disputed=disputed_block, out_path=judge_path, extra=_extra(instructions)),
role="judge", capabilities="files", role="judge", capabilities="none",
payload=lambda result, p=judge_path, ids=set(strittig): _levels_schema(_json_file(p), ids), 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)), timeout=_timeout("level_check", len(strittig)),
) )
if status == FAILED: if status == FAILED:
@@ -1245,7 +1463,7 @@ def _facts_complete(files: dict) -> bool:
return isinstance(d, dict) and bool(d) 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). """Block: per sub extract source facts (find) → verify (check) → correct/discard (fix).
Extract-once grounding: the result feeds level/relevance/questions/guide. Extract-once grounding: the result feeds level/relevance/questions/guide.
→ (facts_map, discarded_map) — facts_map {block: {sub_norm: facts}}, discarded_map → (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: if not per:
return ci, set(), set() 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()) 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] 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}", 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)), _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=caps, _timeout("content_check", len(per)), provider=provider, role="judge",
capabilities="none" if ev else caps,
scope=topic, label=f"{lbl}Facts check {ci}/{j}") scope=topic, label=f"{lbl}Facts check {ci}/{j}")
for j in pending], return_exceptions=True) 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] 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] = {} bvotes: dict[str, int] = {}
vvotes: 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 = [{ slots = [{
"key": f"blocks-{topic}-{ns}relevance-c{c}-{i}", "key": f"blocks-{topic}-{ns}relevance-c{c}-{i}",
"prompt": _prompt("Relevance-Research", topic=topic, subblocks=enum, out_path=p, extra=_extra(instructions)), "prompt": _prompt("Relevance-Research", topic=topic, subblocks=enum, out_path=p, extra=_extra(instructions)),
"role": "quick", "capabilities": "files", "role": "quick", "capabilities": "none", # pure mapping, everything inline → text reply
"payload": (lambda result, p=p, ids=local_set: _relevance_schema(_json_file(p), ids)), "payload": (lambda result, p=p, ids=local_set: _sink_json(result, p, lambda d: _relevance_schema(d, ids))),
} for i, p in pending] } 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) 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 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}", ctx, f"{lbl}Relevance-Clarification {c}",
key=f"blocks-{topic}-{ns}relevance-final-c{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)), prompt=_prompt("Relevance-Mapping", topic=topic, disputed=disputed_block, out_path=judge_path, extra=_extra(instructions)),
role="judge", capabilities="files", role="judge", capabilities="none",
payload=lambda result, p=judge_path, ids=set(strittig): _relevance_schema(_json_file(p), ids), 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)), timeout=_timeout("relevance_check", len(strittig)),
) )
if status == FAILED: 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}", ctx, f"{lbl}Question-Pattern-Clarification {ci}",
key=f"blocks-{topic}-{ns}question-pattern-final-c{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)), prompt=_prompt("Question-Pattern-Critique", topic=topic, table="\n\n".join(block_texts), out_path=fp, extra=_extra(instructions)),
role="judge", capabilities="files", role="judge", capabilities="none", # pure review, everything inline → text reply
payload=lambda result, p=fp: _question_pattern_chunk_schema(_json_file(p)), payload=lambda result, p=fp: _sink_json(result, p, _question_pattern_chunk_schema),
timeout=_timeout("question_pattern_check", subs_total), timeout=_timeout("question_pattern_check", subs_total),
) )
if status == FAILED: 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) 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: def _canonical_key(title: str) -> str:
"""Order-independent canonical key of a title (scaffolding stripped, relation operators normalized). """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 Two titles with the same key denote the same entity with ~100% precision (ER blocking). Empty string
if nothing survives (never auto-merged).""" 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'≟|\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 = re.sub(r'≤|⪯|→|⇒|⟹|\breduces?\s+to\b|\breduziert\b', ' opred ', s) # reduction → one token
s = _CANON_STOP.sub(' ', s) 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). # 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 # 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. # 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_STRIP = re.compile(r'^\s*(?:satz|lemma|korollar|corollary|theorem|proposition)\s*[\d.]*\s*:?\s*'
_REL_OPERATOR = re.compile(r'[≤⪯≥⊆⊊→⇒⟹⇔←]|=>|<=|->') 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: def _relation_operands(title: str) -> tuple[str, str] | None:
"""(canonical_source, canonical_target) of a relation/reduction title, else None (not a relation). """(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.""" Operands canonicalized (lowercased, non-alphanumerics stripped) so spacing/hyphenation don't matter."""
t = _REL_STRIP.sub('', title, count=1) t = _REL_STRIP.sub('', title, count=1)
t = _REL_STRIP_TAIL.sub('', t, count=1)
m = _REL_OPERATOR.search(t) m = _REL_OPERATOR.search(t)
if not m: if not m:
return None 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)) 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] pending = [j for j in (1, 2, 3)[:FACTS_CHECK_PANEL] if _example_check_schema(_json_file(cpath(j))) is None]
if pending: 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}", 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)), _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}") scope=topic, label=f"{lbl}Beispiel-Check {ci}/{j}")
for j in pending], return_exceptions=True) 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] 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: if not outs:
return items # no exam possible → keep (best-effort) return items # no exam possible → keep (best-effort)

View File

@@ -81,6 +81,7 @@ def make_spawner(topic: str, files: dict):
await db.kanban_upsert_card(topic, BOARD, norm, "ablock", "subblocks", { await db.kanban_upsert_card(topic, BOARD, norm, "ablock", "subblocks", {
"title": payload.get("title", ""), "title": payload.get("title", ""),
"description": payload.get("description", ""), "description": payload.get("description", ""),
"n_size": payload.get("n_size", 0), # LPT estimate until subs_n exists
}) })
return spawn return spawn
@@ -165,7 +166,8 @@ async def _proc_subblocks(ctx: GenContext, flow: Flow, files: dict, instructions
+ "\n".join(f"- {s}" for s in sd)) + "\n".join(f"- {s}" for s in sd))
raw = await _subblocks_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm), raw = await _subblocks_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm),
{1: _entry_line(p)}, instr, wipe=False, ns=f"{_safe(norm)}-", {1: _entry_line(p)}, instr, wipe=False, ns=f"{_safe(norm)}-",
seeds=sd or None, lbl=f"{p.get('title', norm)} · ") seeds=sd or None, lbl=f"{p.get('title', norm)} · ",
sources=p.get("sources"))
if raw is None: if raw is None:
return _fail_or_cancel(ctx, f"Subblocks {p.get('title', norm)}") return _fail_or_cancel(ctx, f"Subblocks {p.get('title', norm)}")
p["raw"] = raw p["raw"] = raw
@@ -186,7 +188,7 @@ async def _proc_facts(ctx: GenContext, flow: Flow, files: dict, q: dict, folder,
raw = p.get("raw") or {} raw = p.get("raw") or {}
res = await _facts_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm), raw, q, res = await _facts_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm), raw, q,
folder, instructions, ns=f"{_safe(norm)}-", folder, instructions, ns=f"{_safe(norm)}-",
lbl=f"{p.get('title', norm)} · ") lbl=f"{p.get('title', norm)} · ", sources=p.get("sources"))
if res is None: if res is None:
return _fail_or_cancel(ctx, f"Facts {p.get('title', norm)}") return _fail_or_cancel(ctx, f"Facts {p.get('title', norm)}")
facts_map, discarded = res facts_map, discarded = res

View File

@@ -14,6 +14,7 @@ Stages (cards):
naming cluster judge picks the canonical member title naming cluster judge picks the canonical member title
naming_check cluster second judge verifies → spawns the block card naming_check cluster second judge verifies → spawns the block card
fragment_filter block BARRIER/drain: global re-merge + degrade pass (full list) fragment_filter block BARRIER/drain: global re-merge + degrade pass (full list)
dedup block BARRIER/drain: global judge-verified pair dedup (incl. context)
grouping block BARRIER/drain: umbrella grouping (type gate, reconcile) grouping block BARRIER/drain: umbrella grouping (type gate, reconcile)
gap_check block BARRIER/drain: one supplement round (web) → feeds ingest gap_check block BARRIER/drain: one supplement round (web) → feeds ingest
done block mirror into the legacy `blocks` table → done_block done block mirror into the legacy `blocks` table → done_block
@@ -34,7 +35,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 (
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, _FILTER_NOTATION, _GROUP_STANDALONE, RESEARCH_THEMA_AGENTS, _FILTER_NOTATION, _GROUP_STANDALONE,
_build_research_prompt, _canonical, _canonical_key, _chunk_nums, _cliques, _build_research_prompt, _canonical, _canonical_key, _chunk_nums, _cliques,
@@ -425,7 +426,7 @@ async def _proc_consensus_gate(ctx: GenContext, flow: Flow, cards):
rep = _rep(rows) rep = _rep(rows)
p = c["payload"] p = c["payload"]
p.update(title=rep["title"], description=rep["description"], readers=sorted(readers), p.update(title=rep["title"], description=rep["description"], readers=sorted(readers),
supplement=supplement) supplement=supplement, n_size=len(readers)) # LPT: big evidence first
if supplement or len(readers) >= 2: if supplement or len(readers) >= 2:
if _is_reference(rep["title"]) and not supplement: if _is_reference(rep["title"]) and not supplement:
p["quorum"] = "majority" # consensus reference title: rename/exam, not the hard bar p["quorum"] = "majority" # consensus reference title: rename/exam, not the hard bar
@@ -598,7 +599,7 @@ async def _namecheck_one(ctx: GenContext, flow: Flow, c):
sources = sorted(set().union(*[set(r.get("sources") or []) for r in rows])) if rows else [] sources = sorted(set().union(*[set(r.get("sources") or []) for r in rows])) if rows else []
await db.kanban_upsert_card(topic, BOARD, f"b-{cid}", "block", "fragment_filter", { await db.kanban_upsert_card(topic, BOARD, f"b-{cid}", "block", "fragment_filter", {
"title": p.get("title", ""), "description": p.get("description", ""), "title": p.get("title", ""), "description": p.get("description", ""),
"readers": readers, "sources": sources, "readers": readers, "sources": sources, "n_size": len(readers),
"supplement": bool(p.get("supplement")), "cluster": cid, "supplement": bool(p.get("supplement")), "cluster": cid,
}) })
await db.kanban_advance(topic, BOARD, cid, "done_cluster") await db.kanban_advance(topic, BOARD, cid, "done_cluster")
@@ -864,7 +865,162 @@ async def _proc_fragment_filter(ctx: GenContext, flow: Flow, cards):
"floor_veto": [allrows[nr - 1]["title"] for nr in floor_veto]}, indent=1) "floor_veto": [allrows[nr - 1]["title"] for nr in floor_veto]}, indent=1)
_log(topic, f"Fragment-Filter: {n_dem}{n_dem - len(journal)} ({len(journal)})") _log(topic, f"Fragment-Filter: {n_dem}{n_dem - len(journal)} ({len(journal)})")
demoted = {cid for cid, _ in moves} demoted = {cid for cid, _ in moves}
moves += [(r["card_id"], "grouping") for r in rows if r["card_id"] not in demoted] moves += [(r["card_id"], "dedup") for r in rows if r["card_id"] not in demoted]
await db.kanban_advance_many(topic, BOARD, moves)
flow.wake.set()
async def _proc_dedup(ctx: GenContext, flow: Flow, cards):
"""BARRIER/drain — global pair dedup over the NAMED blocks: filter's re-merge only
catches key-exact/≥0.95 titles inside the stage; here embedding candidates (mean OR
title cosine ≥ DEDUP_GLOBAL_FLOOR) + canonical-key blocking go to a TWO-judge panel
(merge needs unanimity — single judges conflate variants with their base entity),
auto edges (title ≥0.95, exact key) with relation guard, complete-link cliques merge
into the champion (readers/sources union). The second wave (supplement) compares
against the already-confirmed context blocks. Journal carries every pair verdict."""
topic = flow.topic
work_dir = flow.work_dir
rows = [{"card_id": c["card_id"], "payload": c["payload"],
"title": c["payload"].get("title", ""),
"description": c["payload"].get("description") or ""} for c in cards]
context = await _context_blocks(topic, exclude={r["card_id"] for r in rows})
allrows = rows + context
n_dem, n_all = len(rows), len(allrows)
async def _pass_through():
await db.kanban_advance_many(topic, BOARD, [(r["card_id"], "grouping") for r in rows])
flow.wake.set()
if n_all < 2 or not await _emb_ok(flow):
await _pass_through()
return
vf = await _vec_rows(flow, [_t_text(r) for r in allrows])
# titles casefolded: ALL-CAPS variants ("VERTEX COVER" vs "Vertex Cover (VC)") tank the
# raw title cosine below the candidate floor
vt = await _vec_rows(flow, [r["title"].casefold() for r in allrows])
if vf is None or vt is None:
await _pass_through()
return
sims_title = vt @ vt.T
sims = (vf @ vf.T + sims_title) / 2
def _demotable_pair(a: int, b: int) -> bool:
return a < n_dem or b < n_dem # confirmed context blocks never merge among themselves
pairs: set[tuple[int, int]] = set()
for i in range(n_all):
for j in range(i + 1, n_all):
# title-only cosine as second candidate source: descriptions of the same
# entity often stress different facets and dilute the mean below the floor
if _demotable_pair(i, j) and (float(sims[i][j]) >= DEDUP_GLOBAL_FLOOR
or float(sims_title[i][j]) >= DEDUP_GLOBAL_FLOOR):
pairs.add((i, j))
keys: dict[str, list[int]] = {}
for i, r in enumerate(allrows):
if (k := _canonical_key(r["title"])):
keys.setdefault(k, []).append(i)
for grp in keys.values():
for x in range(len(grp)):
for y in range(x + 1, len(grp)):
if _demotable_pair(grp[x], grp[y]):
pairs.add((grp[x], grp[y]))
ordered = sorted(pairs)
h = _h(*[r["card_id"] for r in allrows])
if not ordered:
atomic_write_json(work_dir / f"inventar-dedup-{h}.json",
{"vorher": n_dem, "paare": 0, "merged": []}, indent=1)
await _pass_through()
return
chunks = [ordered[k:k + DEDUP_PAIRS_CHUNK] for k in range(0, len(ordered), DEDUP_PAIRS_CHUNK)]
async def _judge(ci, chunk, jj):
path = work_dir / f"dedup-{h}-c{ci}-j{jj}.json"
if _pairs_schema(_json_file(path)) is not None:
return # resume
lines = "\n\n".join(f"{j + 1}.\nA: {_t_text(allrows[a])}\nB: {_t_text(allrows[b])}"
for j, (a, b) in enumerate(chunk))
status, _v = await run_single_slot(
ctx, f"Dedup {ci} j{jj}", key=f"blocks-{topic}-dedup-{h}-c{ci}-j{jj}",
prompt=_prompt("Blocks-Dedup", topic=topic, pairs=lines, out_path=path),
role="judge", capabilities="files",
payload=lambda result, p=path: _pairs_schema(_json_file(p)),
timeout=_timeout("selection_mapping", len(chunk)))
if status == FAILED:
raise RuntimeError(f"Dedup chunk {ci} j{jj} ohne Ergebnis")
# two-judge panel per chunk (one wave): a merge needs UNANIMITY — the observed
# failure mode is a single judge conflating a variant with its base entity
results = await asyncio.gather(*[_judge(ci, c, jj) for ci, c in enumerate(chunks)
for jj in (1, 2)], return_exceptions=True)
errs = [r for r in results if isinstance(r, Exception)]
if errs:
raise errs[0]
if ctx.is_cancelled():
return
edges: list[tuple[int, int]] = []
detail: dict[tuple[int, int], str] = {}
def _edge(a, b, kanal):
if _relation_conflict(allrows[a]["title"], allrows[b]["title"]):
detail[(a, b)] = "guard_veto"
else:
edges.append((a, b))
detail[(a, b)] = kanal
for ci, chunk in enumerate(chunks):
v1 = _pairs_schema(_json_file(work_dir / f"dedup-{h}-c{ci}-j1.json")) or {}
v2 = _pairs_schema(_json_file(work_dir / f"dedup-{h}-c{ci}-j2.json")) or {}
for j, (a, b) in enumerate(chunk):
ja1, ja2 = bool(v1.get(j + 1)), bool(v2.get(j + 1))
if ja1 and ja2:
_edge(a, b, "ja")
else:
detail[(a, b)] = "nein" if not (ja1 or ja2) else "uneinig"
for a, b in ordered: # auto recall net: near-identical titles merge without the judges
if float(sims_title[a][b]) >= DEDUP_TITLE_AUTO:
_edge(a, b, "auto_titel")
for grp in keys.values(): # exact-canonical-key auto-merge
for x in range(len(grp)):
for y in range(x + 1, len(grp)):
if _demotable_pair(grp[x], grp[y]):
_edge(grp[x], grp[y], "auto_key")
moves: list[tuple[str, str]] = []
journal: list[dict] = []
gone: set[int] = set()
for g in _cliques(n_all, edges):
ctx_members = [k for k in g if k >= n_dem]
if ctx_members:
rep = ctx_members[0] # confirmed block always wins
else:
rep = max(g, key=lambda k: (-_aspect_marker(allrows[k]["title"]),
len(allrows[k]["description"]), len(allrows[k]["title"]), -k))
rp = allrows[rep]["payload"]
changed = False
for k in g:
if k == rep or k >= n_dem:
continue # context members stay untouched
lp = allrows[k]["payload"]
rp["readers"] = sorted(set(rp.get("readers") or []) | set(lp.get("readers") or []))
rp["sources"] = sorted(set(rp.get("sources") or []) | set(lp.get("sources") or []))
lp.update(reason="merged", merged_into=allrows[rep]["title"])
await db.kanban_set_payload(topic, BOARD, allrows[k]["card_id"], lp)
moves.append((allrows[k]["card_id"], "grouped"))
gone.add(k)
journal.append({"dublette": allrows[k]["title"], "in": allrows[rep]["title"]})
changed = True
if changed:
await db.kanban_set_payload(topic, BOARD, allrows[rep]["card_id"], rp)
if rep >= n_dem and rp.get("mirrored_norm"): # mirror refresh (sources only)
await db.upsert_block(topic, rp["mirrored_norm"], allrows[rep]["title"],
allrows[rep]["description"], rp.get("sources") or [])
moves += [(r["card_id"], "grouping") for i, r in enumerate(rows) if i not in gone]
atomic_write_json(work_dir / f"inventar-dedup-{h}.json",
{"vorher": n_dem, "paare": len(ordered), "merged": journal,
"paare_detail": [{"a": allrows[a]["title"], "b": allrows[b]["title"],
"verdict": v} for (a, b), v in sorted(detail.items())]},
indent=1)
if journal:
_log(topic, f"Dedup: {len(journal)} Dublette(n) zusammengelegt")
await db.kanban_advance_many(topic, BOARD, moves) await db.kanban_advance_many(topic, BOARD, moves)
flow.wake.set() flow.wake.set()
@@ -1202,6 +1358,8 @@ def inventory_stages(ctx: GenContext, flow: Flow) -> list[Stage]:
Stage(BOARD, "naming_check", lambda cs: _proc_naming_check(ctx, flow, cs)), Stage(BOARD, "naming_check", lambda cs: _proc_naming_check(ctx, flow, cs)),
Stage(BOARD, "fragment_filter", lambda cs: _proc_fragment_filter(ctx, flow, cs), Stage(BOARD, "fragment_filter", lambda cs: _proc_fragment_filter(ctx, flow, cs),
barrier=True, drain=True, gate=research_done), barrier=True, drain=True, gate=research_done),
Stage(BOARD, "dedup", lambda cs: _proc_dedup(ctx, flow, cs),
barrier=True, drain=True, gate=research_done),
Stage(BOARD, "grouping", lambda cs: _proc_grouping(ctx, flow, cs), Stage(BOARD, "grouping", lambda cs: _proc_grouping(ctx, flow, cs),
barrier=True, drain=True, gate=research_done), barrier=True, drain=True, gate=research_done),
Stage(BOARD, "gap_check", lambda cs: _proc_gap_check(ctx, flow, cs), Stage(BOARD, "gap_check", lambda cs: _proc_gap_check(ctx, flow, cs),
@@ -1305,6 +1463,7 @@ COLUMNS = [
("inventory", "naming", "Naming", "cluster"), ("inventory", "naming", "Naming", "cluster"),
("inventory", "naming_check", "Naming-Check", "cluster"), ("inventory", "naming_check", "Naming-Check", "cluster"),
("inventory", "fragment_filter", "Fragment-Filter", "block"), ("inventory", "fragment_filter", "Fragment-Filter", "block"),
("inventory", "dedup", "Dubletten", "block"),
("inventory", "grouping", "Gruppierung", "block"), ("inventory", "grouping", "Gruppierung", "block"),
("inventory", "gap_check", "Lücken-Check", "block"), ("inventory", "gap_check", "Lücken-Check", "block"),
("inventory", "done", "Spiegeln", "block"), ("inventory", "done", "Spiegeln", "block"),
@@ -1324,7 +1483,7 @@ COLUMNS = [
_TITLE_STAGES = ["ingest", "cluster"] _TITLE_STAGES = ["ingest", "cluster"]
_CLUSTER_STAGES = ["pair_check", "consensus_gate", "clarify", "naming", "naming_check"] _CLUSTER_STAGES = ["pair_check", "consensus_gate", "clarify", "naming", "naming_check"]
_BLOCK_STAGES = ["fragment_filter", "grouping", "gap_check", "done"] _BLOCK_STAGES = ["fragment_filter", "dedup", "grouping", "gap_check", "done"]
_ART_STAGES = ["subblocks", "facts", "levels", "relevance", "question_pattern", _ART_STAGES = ["subblocks", "facts", "levels", "relevance", "question_pattern",
"artefacts", "finalize", "outline"] "artefacts", "finalize", "outline"]
# where a requeued dead card restarts, by kind # where a requeued dead card restarts, by kind

View File

@@ -139,6 +139,12 @@ CRAWL_MIN_CHARS = 400 # too little te
QUELLE_RELEVANZ_CHUNK = 12 # pages per rater package (small, since a snippet ships per page) QUELLE_RELEVANZ_CHUNK = 12 # pages per rater package (small, since a snippet ships per page)
QUELLE_RELEVANZ_SNIPPET = 800 # body characters per page in the prompt (URL is the primary signal) QUELLE_RELEVANZ_SNIPPET = 800 # body characters per page in the prompt (URL is the primary signal)
# 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
# run's tokens were cache reads from those loops).
EVIDENCE_BUDGET_CHARS = 48_000 # max excerpt characters per judge prompt
EVIDENCE_CTX_LINES = 15 # context lines around a cited source position (facts check)
# Timeouts per agent step: (base seconds, seconds per block/section). # Timeouts per agent step: (base seconds, seconds per block/section).
# Applies equally to all providers — whoever is too slow gets restarted or overtaken. # Applies equally to all providers — whoever is too slow gets restarted or overtaken.
TIMEOUTS = { TIMEOUTS = {

View File

@@ -186,6 +186,20 @@ CREATE TABLE IF NOT EXISTS sub_artefakte (
) )
""" """
# Leitner learning state per flashcard — IDENTITY-keyed (no content): survives the
# sub_artefakte wipe on regeneration; orphaned rows simply never match in the deck join.
CREATE_PRACTICE_PROGRESS = """
CREATE TABLE IF NOT EXISTS practice_progress (
topic TEXT NOT NULL,
block_norm TEXT NOT NULL,
sub_norm TEXT NOT NULL,
box INTEGER NOT NULL DEFAULT 1,
due_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (topic, block_norm, sub_norm)
)
"""
# Kanban dataflow (boards 'inventory' + 'artefacts'): ONE generic card table for all card kinds # Kanban dataflow (boards 'inventory' + 'artefacts'): ONE generic card table for all card kinds
# (title/cluster/block). `stage` is the queue key — a worker pulls WHERE stage = <its input stage>. # (title/cluster/block). `stage` is the queue key — a worker pulls WHERE stage = <its input stage>.
# `payload` is a JSON blob (title, description, sources, readers, mentions, parent_norm, journal …); # `payload` is a JSON blob (title, description, sources, readers, mentions, parent_norm, journal …);
@@ -307,6 +321,7 @@ async def init_db():
await db.execute(CREATE_SOURCE) await db.execute(CREATE_SOURCE)
await db.execute(CREATE_GUIDE_OUTLINE) await db.execute(CREATE_GUIDE_OUTLINE)
await db.execute(CREATE_SUB_ARTEFAKTE) await db.execute(CREATE_SUB_ARTEFAKTE)
await db.execute(CREATE_PRACTICE_PROGRESS)
await db.execute(CREATE_KANBAN_CARDS) await db.execute(CREATE_KANBAN_CARDS)
await db.execute(CREATE_EVENTS) await db.execute(CREATE_EVENTS)
await db.execute(CREATE_EVENTS_INDEX) await db.execute(CREATE_EVENTS_INDEX)
@@ -592,8 +607,10 @@ async def subs_per_level(topic: str, block: str) -> dict[int, int]:
from textkit import _norm_title from textkit import _norm_title
db = await get_db() db = await get_db()
cursor = await db.execute( cursor = await db.execute(
f"SELECT {_LEVEL_CASE} AS level, COUNT(*) FROM subblocks " # alias must NOT be named `level`: SQLite resolves an ambiguous GROUP BY name to
"WHERE topic = ? AND block_norm = ? AND status = 'consensus' GROUP BY level", # the source COLUMN, which silently miscounts peripheral subs
f"SELECT {_LEVEL_CASE} AS lv, COUNT(*) FROM subblocks "
"WHERE topic = ? AND block_norm = ? AND status = 'consensus' GROUP BY lv",
(topic, _norm_title(block)), (topic, _norm_title(block)),
) )
out = _empty_levels() out = _empty_levels()
@@ -606,8 +623,8 @@ async def subs_per_level_raw(topic: str) -> dict[str, dict[int, int]]:
"""Subblocks per level, grouped by RAW block title (= guide section title).""" """Subblocks per level, grouped by RAW block title (= guide section title)."""
db = await get_db() db = await get_db()
cursor = await db.execute( cursor = await db.execute(
f"SELECT block, {_LEVEL_CASE} AS level, COUNT(*) FROM subblocks " f"SELECT block, {_LEVEL_CASE} AS lv, COUNT(*) FROM subblocks "
"WHERE topic = ? AND status = 'consensus' GROUP BY block, level", "WHERE topic = ? AND status = 'consensus' GROUP BY block, lv",
(topic,), (topic,),
) )
out: dict[str, dict[int, int]] = {} out: dict[str, dict[int, int]] = {}
@@ -620,8 +637,8 @@ async def subs_per_level_all() -> dict[tuple[str, str], dict[int, int]]:
"""Subblocks per level per (topic, block_norm) — for the topic-wide levels derivation.""" """Subblocks per level per (topic, block_norm) — for the topic-wide levels derivation."""
db = await get_db() db = await get_db()
cursor = await db.execute( cursor = await db.execute(
f"SELECT topic, block_norm, {_LEVEL_CASE} AS level, COUNT(*) FROM subblocks " f"SELECT topic, block_norm, {_LEVEL_CASE} AS lv, COUNT(*) FROM subblocks "
"WHERE status = 'consensus' GROUP BY topic, block_norm, level" "WHERE status = 'consensus' GROUP BY topic, block_norm, lv"
) )
out: dict[tuple[str, str], dict[int, int]] = {} out: dict[tuple[str, str], dict[int, int]] = {}
for t, bn, level, n in await cursor.fetchall(): for t, bn, level, n in await cursor.fetchall():
@@ -741,11 +758,13 @@ def _card(row, cursor) -> dict:
async def kanban_pull(topic: str, board: str, stage: str, limit: int) -> list[dict]: async def kanban_pull(topic: str, board: str, stage: str, limit: int) -> list[dict]:
"""`limit` ready cards of `stage` (backoff expired). LPT: cards carrying a `subs_n` """`limit` ready cards of `stage` (backoff expired). LPT: cards carrying a `subs_n`
payload field (board 2, set after subblocks) are pulled BIGGEST first — the longest payload field (board 2, set after subblocks) are pulled BIGGEST first — the longest
block starts earliest and stops dominating the makespan tail. Others stay FIFO.""" block starts earliest and stops dominating the makespan tail. `n_size` (board 1,
reader count) is the coarser fallback estimate. Others stay FIFO."""
db = await get_db() db = await get_db()
cursor = await db.execute( cursor = await db.execute(
"""SELECT * FROM kanban_cards WHERE topic = ? AND board = ? AND stage = ? AND not_before <= ? """SELECT * FROM kanban_cards WHERE topic = ? AND board = ? AND stage = ? AND not_before <= ?
ORDER BY COALESCE(json_extract(payload, '$.subs_n'), 0) DESC, updated_at LIMIT ?""", ORDER BY COALESCE(json_extract(payload, '$.subs_n'),
json_extract(payload, '$.n_size'), 0) DESC, updated_at LIMIT ?""",
(topic, board, stage, _now(), limit)) (topic, board, stage, _now(), limit))
return [_card(row, cursor) for row in await cursor.fetchall()] return [_card(row, cursor) for row in await cursor.fetchall()]
@@ -1211,6 +1230,30 @@ async def list_question_pattern(topic: str, block_norm: str | None = None) -> li
return [_row_to_dict(row, cursor) for row in rows] return [_row_to_dict(row, cursor) for row in rows]
async def count_question_pattern_blocks(topic: str) -> int:
"""Blocks that have at least one exam question pattern."""
db = await get_db()
cur = await db.execute("SELECT COUNT(DISTINCT block_norm) FROM question_pattern WHERE topic = ?", (topic,))
return (await cur.fetchone())[0]
async def count_sub_artefakte(topic: str) -> int:
db = await get_db()
cur = await db.execute("SELECT COUNT(*) FROM sub_artefakte WHERE topic = ?", (topic,))
return (await cur.fetchone())[0]
async def event_span(topic: str) -> int:
"""Minutes between first and last pipeline event of the topic (0 if none)."""
db = await get_db()
cur = await db.execute("SELECT MIN(ts), MAX(ts) FROM events WHERE topic = ?", (topic,))
lo, hi = await cur.fetchone()
if not lo or not hi:
return 0
from datetime import datetime
return int((datetime.fromisoformat(hi) - datetime.fromisoformat(lo)).total_seconds() // 60)
async def delete_question_pattern(topic: str, block_norm: str | None = None) -> None: async def delete_question_pattern(topic: str, block_norm: str | None = None) -> None:
db = await get_db() db = await get_db()
if block_norm is None: if block_norm is None:
@@ -1378,18 +1421,64 @@ async def put_sub_artifact(topic: str, block_norm: str, sub_norm: str, type: str
await db.commit() await db.commit()
async def get_sub_artefakte(topic: str, type: str | None = None) -> list[dict]: async def get_sub_artefakte(topic: str, type: str | None = None,
block_norm: str | None = None) -> list[dict]:
db = await get_db() db = await get_db()
if type is None: sql = "SELECT * FROM sub_artefakte WHERE topic = ?"
cursor = await db.execute("SELECT * FROM sub_artefakte WHERE topic = ? ORDER BY rowid", (topic,)) args: list = [topic]
else: if type is not None:
cursor = await db.execute( sql += " AND type = ?"
"SELECT * FROM sub_artefakte WHERE topic = ? AND type = ? ORDER BY rowid", (topic, type) args.append(type)
) if block_norm is not None:
sql += " AND block_norm = ?"
args.append(block_norm)
cursor = await db.execute(sql + " ORDER BY rowid", args)
rows = await cursor.fetchall() rows = await cursor.fetchall()
return [_row_to_dict(row, cursor) for row in rows] return [_row_to_dict(row, cursor) for row in rows]
async def get_practice_progress(topic: str) -> list[dict]:
db = await get_db()
cursor = await db.execute(
"SELECT block_norm, sub_norm, box, due_at FROM practice_progress WHERE topic = ?", (topic,))
rows = await cursor.fetchall()
return [_row_to_dict(row, cursor) for row in rows]
async def upsert_practice_progress(topic: str, block_norm: str, sub_norm: str,
box: int, due_at: str) -> None:
db = await get_db()
await db.execute(
"""INSERT INTO practice_progress (topic, block_norm, sub_norm, box, due_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(topic, block_norm, sub_norm)
DO UPDATE SET box = excluded.box, due_at = excluded.due_at,
updated_at = excluded.updated_at""",
(topic, block_norm, sub_norm, box, due_at, _now()))
await db.commit()
async def sub_levels_norm(topic: str) -> dict[tuple[str, str], int]:
"""(block_norm, sub_norm) → level 1-4 for the consensus subs — the practice deck's gate."""
db = await get_db()
cursor = await db.execute(
f"SELECT block_norm, sub_norm, {_LEVEL_CASE} AS level FROM subblocks "
"WHERE topic = ? AND status = 'consensus'", (topic,))
return {(bn, sn): lv for bn, sn, lv in await cursor.fetchall()}
async def subs_per_level_norm(topic: str) -> dict[str, dict[int, int]]:
"""Subblocks per level, grouped by block_norm (sub_artefakte is norm-keyed)."""
db = await get_db()
cursor = await db.execute(
f"SELECT block_norm, {_LEVEL_CASE} AS lv, COUNT(*) FROM subblocks "
"WHERE topic = ? AND status = 'consensus' GROUP BY block_norm, lv", (topic,))
out: dict[str, dict[int, int]] = {}
for bn, level, n in await cursor.fetchall():
out.setdefault(bn, _empty_levels())[level] = n
return out
async def delete_sub_artefakte(topic: str, block_norm: str | None = None) -> None: async def delete_sub_artefakte(topic: str, block_norm: str | None = None) -> None:
db = await get_db() db = await get_db()
if block_norm is None: if block_norm is None:
@@ -1426,6 +1515,6 @@ async def delete_topic_pipeline(topic: str) -> None:
NOT the topic config `source` — that is managed separately (delete_source).""" NOT the topic config `source` — that is managed separately (delete_source)."""
db = await get_db() db = await get_db()
for tab in ("blocks", "subblocks", "question_pattern", "research_coverage", for tab in ("blocks", "subblocks", "question_pattern", "research_coverage",
"pipeline_state", "guide_outline", "sub_artefakte", "events"): "pipeline_state", "guide_outline", "sub_artefakte", "practice_progress", "events"):
await db.execute(f"DELETE FROM {tab} WHERE topic = ?", (topic,)) await db.execute(f"DELETE FROM {tab} WHERE topic = ?", (topic,))
await db.commit() await db.commit()

View File

@@ -137,6 +137,38 @@ def _card_facts(env: _Env, block_title: str) -> str:
return grounding or env.fallback_facts return grounding or env.fallback_facts
async def _card_examples(env: _Env, block_norm: str, subs: list[dict],
include_unmatched: bool = True) -> str:
"""Verified worked examples of the block as writer input, matched to `subs` via
sub_norm (a split half gets only its own). Rows whose sub does not match (generation
mismatch) go to the full writer / split part 1 so they never vanish silently."""
rows = await db.get_sub_artefakte(env.topic, type="example", block_norm=block_norm)
if not rows:
return ""
wanted = {_norm_title(s["title"]) for s in subs}
out = []
for r in rows:
matched = r["sub_norm"] in wanted
if not matched and not include_unmatched:
continue
data = json.loads(r["data"]) if isinstance(r["data"], str) else (r["data"] or {})
steps = " ".join(f"{i}) {s}" for i, s in enumerate(data.get("steps") or [], 1))
where = (f"Subbaustein „{r['sub_title']}" if matched
else "Subbaustein unklar — dort einweben, wo es fachlich passt")
out.append(f"- {where}:\n Problem: {data.get('problem', '')}\n"
f" Schritte: {steps}\n Ergebnis: {data.get('result', '')}")
if not out:
return ""
return ("VERIFIED WORKED EXAMPLES (already fact-checked; each belongs to ONE subblock):\n"
+ "\n".join(out) + "\n"
"Weave each example into the ausführlich text of EXACTLY its subblock, right "
"after the concept it applies has been explained — as a short worked-through "
"passage (problem → steps → result recognizable, flowing prose or a compact "
"numbered list). Take all values and results over VERBATIM, never recompute "
"or alter them. NEVER put examples into the compact layer. Subblocks without "
"an example get none.")
def _card_assignment(env: _Env, card: dict) -> str: def _card_assignment(env: _Env, card: dict) -> str:
from guide import _level_label from guide import _level_label
lines = [f"- {card['block']}"] lines = [f"- {card['block']}"]
@@ -254,7 +286,10 @@ async def _write_split(env: _Env, card: dict, ziele_text: str):
prompt=_prompt("Guide-Writer-Board", topic=env.topic, format_name=env.format, prompt=_prompt("Guide-Writer-Board", topic=env.topic, format_name=env.format,
chapter=card.get("chapter") or "Inhalte", chapter=card.get("chapter") or "Inhalte",
assignment=assignment, ziele=ziele_text, assignment=assignment, ziele=ziele_text,
facts=_card_facts(env, card["block"]), gaps="\n" + hints[i] + "\n", facts=_card_facts(env, card["block"]),
examples=await _card_examples(env, norm, parts[i],
include_unmatched=(i == 0)),
gaps="\n" + hints[i] + "\n",
spec=env.spec, out_path=path, extra=_extra(env.instructions)), spec=env.spec, out_path=path, extra=_extra(env.instructions)),
role="guide", capabilities="files", payload=_payload, role="guide", capabilities="files", payload=_payload,
timeout=_timeout("writer", 1)) timeout=_timeout("writer", 1))
@@ -300,7 +335,9 @@ async def _stage_writer(env: _Env, card: dict) -> bool:
prompt=_prompt("Guide-Writer-Board", topic=env.topic, format_name=env.format, prompt=_prompt("Guide-Writer-Board", topic=env.topic, format_name=env.format,
chapter=card.get("chapter") or "Inhalte", chapter=card.get("chapter") or "Inhalte",
assignment=_card_assignment(env, card), ziele=ziele_text, assignment=_card_assignment(env, card), ziele=ziele_text,
facts=_card_facts(env, card["block"]), gaps=gaps, spec=env.spec, facts=_card_facts(env, card["block"]),
examples=await _card_examples(env, norm, env.subs_by_title.get(card["block"], [])),
gaps=gaps, spec=env.spec,
out_path=path, extra=_extra(env.instructions)), out_path=path, extra=_extra(env.instructions)),
role="guide", capabilities="files", payload=_payload, role="guide", capabilities="files", payload=_payload,
timeout=_timeout("writer", 1)) timeout=_timeout("writer", 1))
@@ -320,6 +357,9 @@ async def _stage_fakten_gate(env: _Env, card: dict) -> bool:
await _set(env, card, status="error", gate_info="Writer-Fragment unlesbar") await _set(env, card, status="error", gate_info="Writer-Fragment unlesbar")
return False return False
facts = _card_facts(env, card["block"]) facts = _card_facts(env, card["block"])
ex = await _card_examples(env, norm, env.subs_by_title.get(card["block"], []))
if ex: # the fix agent sees the same facts variable — examples survive the fix pass
facts += "\n\nVERIFIED WORKED EXAMPLES (count as verified facts for this check):\n" + ex
path = env.slot(f"gate-{_safe(norm)}-r{card['writer_rounds']}.json") path = env.slot(f"gate-{_safe(norm)}-r{card['writer_rounds']}.json")
status, claims = await run_single_slot( status, claims = await run_single_slot(
env.ctx, f"Fakten-Gate {card['block']}", key=f"{env.guide_id}-gate-{_safe(norm)}-r{card['writer_rounds']}", env.ctx, f"Fakten-Gate {card['block']}", key=f"{env.guide_id}-gate-{_safe(norm)}-r{card['writer_rounds']}",

View File

@@ -28,6 +28,25 @@ LEVELS = (("beginner", 0.2), ("advanced", 0.4), ("expert", 0.6), ("master", 1.0)
POINTS_BASE = 25 # Points per subblock. Master cap = (all subs) × 25. POINTS_BASE = 25 # Points per subblock. Master cap = (all subs) × 25.
# Leitner boxes for the flashcard practice deck: roughly doubling intervals cover
# session → day → week → month. Box 1 with interval 0 = a failed card stays due in
# the running session. Absolute UTC times, no day-boundary semantics (timezone-free).
LEITNER_INTERVALS = {1: 0, 2: 1, 3: 3, 4: 7, 5: 21} # days per box
LEITNER_MAX_BOX = 5
PRACTICE_NEW_PER_SESSION = 10 # new cards offered per deck fetch
def leitner_step(box: int | None, correct: bool) -> tuple[int, int]:
"""(new box, interval in days). New card + correct → box 2; wrong → box 1 (due now);
correct → one box up, capped at LEITNER_MAX_BOX."""
if not correct:
new = 1
elif box is None:
new = 2
else:
new = min(box + 1, LEITNER_MAX_BOX)
return new, LEITNER_INTERVALS[new]
def _levels(n_je_level: dict[int, int]) -> list[int]: def _levels(n_je_level: dict[int, int]) -> list[int]:
return [n_je_level.get(k, 0) for k in (1, 2, 3, 4)] return [n_je_level.get(k, 0) for k in (1, 2, 3, 4)]

View File

@@ -44,6 +44,18 @@ class BlocksCardRestartRequest(BaseModel):
card_id: str = Field(min_length=1, max_length=200) card_id: str = Field(min_length=1, max_length=200)
class GuideFormatRequest(BaseModel):
topic: str = Field(min_length=1)
format: str = Field(min_length=1)
class PracticeAnswerRequest(BaseModel):
topic: str = Field(min_length=1)
block_norm: str = Field(min_length=1, max_length=300)
sub_norm: str = Field(max_length=300)
correct: bool
class GuideCardResetRequest(BaseModel): class GuideCardResetRequest(BaseModel):
topic: str = Field(min_length=1) topic: str = Field(min_length=1)
format: str = Field(min_length=1) format: str = Field(min_length=1)

View File

@@ -3,6 +3,7 @@ uvicorn[standard]
aiosqlite aiosqlite
playwright playwright
trafilatura trafilatura
pymupdf4llm
transformers transformers
# torch NICHT hier listen — sonst zieht pip die CUDA-Variante (~2,5 GB). # torch NICHT hier listen — sonst zieht pip die CUDA-Variante (~2,5 GB).
# Es wird separat als CPU-Build installiert (Dockerfile + Makefile-Target `install`). # Es wird separat als CPU-Build installiert (Dockerfile + Makefile-Target `install`).

View File

@@ -2,7 +2,7 @@ import asyncio
import json import json
import shutil import shutil
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from fastapi.responses import Response from fastapi.responses import Response
@@ -18,10 +18,12 @@ from database import (
delete_block_data, delete_block_progress, subs_per_level, subs_per_level_raw, delete_block_data, delete_block_progress, subs_per_level, subs_per_level_raw,
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,
) )
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
from board_inventory import add_research_agent, board_snapshot, requeue_dead, reset_board_from_stage, restart_artefact_card from board_inventory import add_research_agent, board_snapshot, requeue_dead, reset_board_from_stage, restart_artefact_card
from learning import block_chat, block_discussion, exam_rating, exam_rating_fast, exam_question, exam_question_variant, generate_quiz, generate_gapchoice, generate_gaptext, check_gaptext, hurdles_distractor_block, compute_score, floor_from_score, level_from_score, cap_final, cap_aktuell, freie_level, thresholds, points_delta, cap_followup from learning import block_chat, block_discussion, exam_rating, exam_rating_fast, exam_question, exam_question_variant, generate_quiz, generate_gapchoice, generate_gaptext, check_gaptext, hurdles_distractor_block, compute_score, floor_from_score, level_from_score, cap_final, cap_aktuell, freie_level, thresholds, points_delta, cap_followup, leitner_step, PRACTICE_NEW_PER_SESSION
from guide import chat_with_guide, generate_guide, guide_slot_files, block_pruefen, block_adopt, content_fuer_level from guide import chat_with_guide, generate_guide, guide_slot_files, block_pruefen, block_adopt, content_fuer_level
from pipeline import cancel_guide from pipeline import cancel_guide
from rules import FORMATE, formats_stats, guide_lock, ist_completed, load_learnstate, topic_completed from rules import FORMATE, formats_stats, guide_lock, ist_completed, load_learnstate, topic_completed
@@ -29,13 +31,14 @@ from models import (
GuideCreateRequest, GuideResponse, GuideCreateRequest, GuideResponse,
TopicCreateRequest, TopicCreateRequest,
BlocksCreateRequest, BlocksResetStageRequest, BlocksCardRestartRequest, BlocksStatusResponse, BlocksCreateRequest, BlocksResetStageRequest, BlocksCardRestartRequest, BlocksStatusResponse,
GuideCardResetRequest, GuideCardResetRequest, GuideFormatRequest,
GuideBoardResetRequest, GuideChatRequest, GuideChatResponse, GuideBoardResetRequest, GuideChatRequest, GuideChatResponse,
ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo, ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo,
FolderResponse, BlocksSourceUpdate, BlocksSourceResponse, BlockOverview, FolderResponse, BlocksSourceUpdate, BlocksSourceResponse, BlockOverview,
BlockChatRequest, BlockChatResponse, BlockChatRequest, BlockChatResponse,
BlockExamRequest, BlockExamResponse, BlockLearnStateResponse, BlockExamRequest, BlockExamResponse, BlockLearnStateResponse,
BlockPruefenRequest, BlockPruefenResponse, BlockUebernehmenRequest, BlockUebernehmenResponse, BlockPruefenRequest, BlockPruefenResponse, BlockUebernehmenRequest, BlockUebernehmenResponse,
PracticeAnswerRequest,
) )
from paths import blocks_topics, guide_content_path, project_dir, topic_dir, source_path, safe_folder from paths import blocks_topics, guide_content_path, project_dir, topic_dir, source_path, safe_folder
from fsutil import atomic_write_json from fsutil import atomic_write_json
@@ -282,6 +285,43 @@ async def update_blocks_source(req: BlocksSourceUpdate):
return data return data
@router.get("/blocks/completeness")
async def blocks_completeness(topic: str):
"""Beleg der Themen-Zerlegung: Bestand, Filter-Bilanz, Lernziele, Artefakte, Laufzeit."""
import glob as _glob
from pathlib import Path as _Path
from paths import arbeit_dir
from database import (kanban_stage_counts, list_blocks, list_subblocks, list_lernziele,
count_question_pattern_blocks, count_sub_artefakte, event_span)
counts = await kanban_stage_counts(topic)
inv = counts.get("inventory", {})
blocks = await list_blocks(topic, status="consensus")
subs = 0
for b in blocks:
subs += sum(1 for s in await list_subblocks(topic, b["title_norm"]) if s["status"] == "consensus")
ziele = await list_lernziele(topic)
dead = sum(v for board in counts.values() for s, v in board.items() if s == "dead")
degradiert = ueberstimmt = 0
for p in _glob.glob(str(arbeit_dir(topic) / "inventar-filter*.json")):
try:
d = json.loads(_Path(p).read_text(encoding="utf-8"))
degradiert += d.get("degradiert", 0)
ueberstimmt += len(d.get("ueberstimmt", []))
except Exception:
continue
status = await blocks_status(topic)
return {
"bloecke": len(blocks), "subs": subs,
"verworfen": inv.get("rejected", 0), "zusammengelegt": inv.get("grouped", 0),
"degradiert_geprueft": degradiert, "panel_gerettet": ueberstimmt,
"ziele_total": len(ziele), "ziele_covered": sum(1 for z in ziele if z["covered"]),
"frage_bloecke": await count_question_pattern_blocks(topic),
"lernkarten": await count_sub_artefakte(topic),
"dead": dead, "lauf_minuten": await event_span(topic),
"vollstaendig": bool(status.get("ready")) and dead == 0,
}
@router.get("/blocks/overview", response_model=list[BlockOverview]) @router.get("/blocks/overview", response_model=list[BlockOverview])
async def get_blocks_uebersicht(topic: str): async def get_blocks_uebersicht(topic: str):
return await load_overview(topic) return await load_overview(topic)
@@ -313,6 +353,68 @@ async def get_artefakte(topic: str, type: str | None = None):
return {"artefakte": out} return {"artefakte": out}
# --- Practice deck: Leitner flashcard pool per topic ---
async def build_practice_deck(topic: str) -> dict:
"""ONE stack per topic (spacing beats per-block mini-stacks): due cards first
(oldest due_at), then up to PRACTICE_NEW_PER_SESSION new ones. Level gate via the
block's exam score (freie_level) — locked cards are counted for transparency."""
cards = await get_sub_artefakte(topic, "flashcard")
levels = await sub_levels_norm(topic)
n_je = await subs_per_level_norm(topic)
progress = {_norm_title(p["block"]): p["good_answers"] for p in await list_block_progress(topic)}
pp = {(p["block_norm"], p["sub_norm"]): p for p in await get_practice_progress(topic)}
now = datetime.now(timezone.utc).isoformat()
due, new, future, gesperrt = [], [], [], 0
for r in cards:
bn, sn = r["block_norm"], r["sub_norm"]
n_block = n_je.get(bn)
if n_block is not None: # legacy blocks without level data pass unfiltered
if levels.get((bn, sn), 1) > freie_level(progress.get(bn, 0), n_block):
gesperrt += 1
continue
try:
data = json.loads(r["data"])
except (ValueError, TypeError):
continue
card = {"block": r["block"], "block_norm": bn, "sub_norm": sn,
"subblock": r["sub_title"], "question": data.get("question", ""),
"answer": data.get("answer", "")}
p = pp.get((bn, sn))
if p is None:
card.update(box=None, status="new")
new.append(card)
elif p["due_at"] <= now:
card.update(box=p["box"], status="due", due_at=p["due_at"])
due.append(card)
else:
future.append(p["due_at"])
due.sort(key=lambda c: c["due_at"])
new_total = len(new)
new = new[:PRACTICE_NEW_PER_SESSION]
return {"cards": due + new,
"counts": {"due": len(due), "new": len(new), "new_total": new_total,
"gesperrt": gesperrt},
"next_due_at": min(future) if future else None}
@router.get("/practice/deck")
async def practice_deck(topic: str):
return await build_practice_deck(topic)
@router.post("/practice/answer")
async def practice_answer(req: PracticeAnswerRequest):
"""Book a Leitner step. Deliberately NO existence check against sub_artefakte:
an answer during regeneration books instead of failing (worst case an orphan row)."""
pp = {(p["block_norm"], p["sub_norm"]): p for p in await get_practice_progress(req.topic)}
prev = pp.get((req.block_norm, req.sub_norm))
box, days = leitner_step(prev["box"] if prev else None, req.correct)
due_at = (datetime.now(timezone.utc) + timedelta(days=days)).isoformat()
await upsert_practice_progress(req.topic, req.block_norm, req.sub_norm, box, due_at)
return {"box": box, "due_at": due_at}
# --- Block learning: chat, exam --- # --- Block learning: chat, exam ---
@router.get("/blocks/learnstate", response_model=BlockLearnStateResponse) @router.get("/blocks/learnstate", response_model=BlockLearnStateResponse)
@@ -679,6 +781,25 @@ async def cancel(guide_id: str):
return {"ok": True} return {"ok": True}
@router.post("/guides/board/remove")
async def remove_guide_format(req: GuideFormatRequest):
"""Board-Remove: discard ALL runs of topic+format — old error rows pile up, and the
per-guide delete keeps the board cards until the LAST row is gone (measured: 8 rows)."""
doomed = [g for g in await list_guides() if g["topic"] == req.topic and g["format"] == req.format]
if any(g["status"] in ("queued", "generating") for g in doomed):
return {"ok": True, "status": "generating"}
for g in doomed:
await delete_progress(g["id"])
await delete_guide(g["id"])
await delete_guide_content(req.topic, req.format)
await delete_guide_board(req.topic, req.format)
content = guide_content_path(req.topic, req.format)
for p in guide_slot_files(content):
p.unlink(missing_ok=True)
content.unlink(missing_ok=True)
return {"ok": True, "removed": len(doomed)}
@router.delete("/guides/{guide_id}") @router.delete("/guides/{guide_id}")
async def remove(guide_id: str, slots: bool = False): async def remove(guide_id: str, slots: bool = False):
guide = await get_guide(guide_id) guide = await get_guide(guide_id)

View File

@@ -25,6 +25,9 @@ def _fake_single_slot(tmp_path):
if "-pair-" in key: if "-pair-" in key:
pairs = prompt.count("\nA: ") pairs = prompt.count("\nA: ")
out = {"pairs": {str(i + 1): "ja" for i in range(pairs)}} out = {"pairs": {str(i + 1): "ja" for i in range(pairs)}}
elif "-dedup-" in key:
pairs = prompt.count("\nA: ")
out = {"pairs": {str(i + 1): "nein" for i in range(pairs)}}
elif "-clarify-" in key: elif "-clarify-" in key:
keep = [line[2:].split("")[0] for line in prompt.splitlines() keep = [line[2:].split("")[0] for line in prompt.splitlines()
if line.startswith("- ")] if line.startswith("- ")]
@@ -57,11 +60,11 @@ async def board_env(testdb, tmp_path, monkeypatch):
return False return False
monkeypatch.setattr(bi, "_emb_ok", no_emb) monkeypatch.setattr(bi, "_emb_ok", no_emb)
async def fake_subblocks(ctx, set_p, files, entries, instructions, wipe=True, ns="", seeds=None, lbl=""): async def fake_subblocks(ctx, set_p, files, entries, instructions, wipe=True, ns="", seeds=None, lbl="", sources=None):
title = list(entries.values())[0].split("")[0] title = list(entries.values())[0].split("")[0]
return {title: ["Sub Eins", "Sub Zwei"]} return {title: ["Sub Eins", "Sub Zwei"]}
async def fake_facts(ctx, set_p, files, raw, q, folder, instructions, ns="", lbl=""): async def fake_facts(ctx, set_p, files, raw, q, folder, instructions, ns="", lbl="", sources=None):
facts = {t: {_norm_title(s): {"key_points": [f"Fakt zu {s}"], "cited_facts": []} facts = {t: {_norm_title(s): {"key_points": [f"Fakt zu {s}"], "cited_facts": []}
for s in subs} for t, subs in raw.items()} for s in subs} for t, subs in raw.items()}
return facts, {} return facts, {}
@@ -134,6 +137,7 @@ async def test_board1_full_flow(board_env):
# reader union survived the pipeline (consensus evidence on the block card) # reader union survived the pipeline (consensus evidence on the block card)
alpha = next(c for c in done if c["payload"]["title"] == "Alpha") alpha = next(c for c in done if c["payload"]["title"] == "Alpha")
assert set(alpha["payload"]["readers"]) == {"r1", "r2"} assert set(alpha["payload"]["readers"]) == {"r1", "r2"}
assert alpha["payload"]["n_size"] == 2 # LPT estimate travels with the card
# board 2: one artefact card per block ran through to done_artefact (+ outline singleton) # board 2: one artefact card per block ran through to done_artefact (+ outline singleton)
art_done = await db.kanban_cards(TOPIC, board="artefacts", stage="done_artefact") art_done = await db.kanban_cards(TOPIC, board="artefacts", stage="done_artefact")
assert len(art_done) == 5 # 4 blocks + outline card assert len(art_done) == 5 # 4 blocks + outline card
@@ -185,7 +189,7 @@ async def test_empty_subblocks_completes_without_deadletter(board_env, monkeypat
import blocks as blx import blocks as blx
db, ctx, files = board_env db, ctx, files = board_env
async def empty_subs(ctx, set_p, files, entries, instructions, wipe=True, ns="", seeds=None, lbl=""): async def empty_subs(ctx, set_p, files, entries, instructions, wipe=True, ns="", seeds=None, lbl="", sources=None):
return {} return {}
monkeypatch.setattr(ba, "_subblocks_block", empty_subs) monkeypatch.setattr(ba, "_subblocks_block", empty_subs)
await db.kanban_upsert_card(TOPIC, "artefacts", "leer", "ablock", "subblocks", await db.kanban_upsert_card(TOPIC, "artefacts", "leer", "ablock", "subblocks",
@@ -264,7 +268,7 @@ async def test_panel_confirms_demote(board_env, tmp_path, monkeypatch):
assert c1["stage"] == "rejected" assert c1["stage"] == "rejected"
assert c1["payload"]["reason"] == "fragment" assert c1["payload"]["reason"] == "fragment"
assert c1["payload"]["parent_norm"] == "codeblock" assert c1["payload"]["parent_norm"] == "codeblock"
assert (await db.kanban_get_card(TOPIC, B, "b-2"))["stage"] == "grouping" assert (await db.kanban_get_card(TOPIC, B, "b-2"))["stage"] == "dedup"
async def test_panel_overrules_single_vote(board_env, tmp_path, monkeypatch): async def test_panel_overrules_single_vote(board_env, tmp_path, monkeypatch):
@@ -278,7 +282,7 @@ async def test_panel_overrules_single_vote(board_env, tmp_path, monkeypatch):
("b-1", {"title": "Blockzitat", "description": "Zitat mit >"}), ("b-1", {"title": "Blockzitat", "description": "Zitat mit >"}),
("b-2", {"title": "Codeblock", "description": "Code mit Einrückung"}), ("b-2", {"title": "Codeblock", "description": "Code mit Einrückung"}),
]) ])
assert (await db.kanban_get_card(TOPIC, B, "b-1"))["stage"] == "grouping" assert (await db.kanban_get_card(TOPIC, B, "b-1"))["stage"] == "dedup"
journal = json.loads(next(tmp_path.glob("inventar-filter-*.json")).read_text(encoding="utf-8")) journal = json.loads(next(tmp_path.glob("inventar-filter-*.json")).read_text(encoding="utf-8"))
assert journal["ueberstimmt"] == ["Blockzitat"] assert journal["ueberstimmt"] == ["Blockzitat"]
assert journal["degradiert"] == 0 assert journal["degradiert"] == 0
@@ -329,7 +333,7 @@ async def test_floor_vetoes_structureless_demote(board_env, tmp_path, monkeypatc
("b-3", {"title": "Aufgabenlisten (Task Lists)", "description": "Checkboxen"}), ("b-3", {"title": "Aufgabenlisten (Task Lists)", "description": "Checkboxen"}),
("b-4", {"title": "Aufgabenlisten", "description": "GFM-Listen mit Checkbox"}), ("b-4", {"title": "Aufgabenlisten", "description": "GFM-Listen mit Checkbox"}),
]) ])
assert (await db.kanban_get_card(TOPIC, B, "b-1"))["stage"] == "grouping" # floor veto assert (await db.kanban_get_card(TOPIC, B, "b-1"))["stage"] == "dedup" # floor veto
assert (await db.kanban_get_card(TOPIC, B, "b-3"))["stage"] == "rejected" # containment holds assert (await db.kanban_get_card(TOPIC, B, "b-3"))["stage"] == "rejected" # containment holds
journal = json.loads(next(tmp_path.glob("inventar-filter-*.json")).read_text(encoding="utf-8")) journal = json.loads(next(tmp_path.glob("inventar-filter-*.json")).read_text(encoding="utf-8"))
assert journal["floor_veto"] == ["Blockzitat"] assert journal["floor_veto"] == ["Blockzitat"]
@@ -388,19 +392,268 @@ async def test_supplement_reopens_dead_lineage(board_env, tmp_path, monkeypatch)
assert fresh and fresh["stage"] == "ingest" and fresh["payload"]["supplement"] is True assert fresh and fresh["stage"] == "ingest" and fresh["payload"]["supplement"] is True
# ── Dedup-Stage: globaler Paar-Abgleich nach dem Naming ─────────────────────────────
def _angle_vecs(mapping):
"""Vector fake with controllable cosine: mapped substring → angle (degrees) in a
shared 2D plane; unmapped texts get their own orthogonal axis (cos 0 to everything)."""
import math as m
import numpy as np
async def fake(flow, texts):
dim = 2 + len(texts)
rows = []
for i, t in enumerate(texts):
v = np.zeros(dim)
for key, deg in mapping.items():
if key in t:
v[0], v[1] = m.cos(m.radians(deg)), m.sin(m.radians(deg))
break
else:
v[2 + i] = 1.0
rows.append(v)
return np.vstack(rows)
return fake
async def _run_dedup(db, ctx, tmp_path, cards):
"""Seed block cards into dedup and run ONE barrier pass over them."""
for cid, p in cards:
await db.kanban_upsert_card(TOPIC, B, cid, "block", "dedup", p)
rows = [{"card_id": cid, "payload": dict(p)} for cid, p in cards]
await bi._proc_dedup(ctx, _mk_flow(tmp_path), rows)
@pytest.fixture
def emb_on(monkeypatch):
async def yes(flow):
return True
monkeypatch.setattr(bi, "_emb_ok", yes)
async def test_dedup_merges_confirmed_pair(board_env, tmp_path, monkeypatch, emb_on):
"""Judge-„ja" merged: Verlierer → grouped (reason/merged_into), Champion sammelt reader."""
# Titel-Cos ~0.77 (unter Auto-0.95) → Kandidat, der Judge entscheidet
monkeypatch.setattr(bi, "_vec_rows", _angle_vecs({"satisfiability": 0, "sat": 40}))
counter = {}
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
("-dedup-", {"pairs": {"1": "ja"}}),
], counter))
await _run_dedup(db_ := board_env[0], ctx := board_env[1], tmp_path, [
("b-1", {"title": "SAT", "description": "kurz", "readers": ["r1"]}),
("b-2", {"title": "SAT (Satisfiability Problem)",
"description": "Erfüllbarkeit Boolescher Ausdrücke", "readers": ["r2"]}),
])
loser = await db_.kanban_get_card(TOPIC, B, "b-1")
champ = await db_.kanban_get_card(TOPIC, B, "b-2")
assert loser["stage"] == "grouped"
assert loser["payload"]["reason"] == "merged"
assert loser["payload"]["merged_into"] == "SAT (Satisfiability Problem)"
assert champ["stage"] == "grouping"
assert set(champ["payload"]["readers"]) == {"r1", "r2"}
journal = json.loads(next(tmp_path.glob("inventar-dedup-*.json")).read_text(encoding="utf-8"))
assert journal["merged"] == [{"dublette": "SAT", "in": "SAT (Satisfiability Problem)"}]
assert journal["paare_detail"][0]["verdict"] == "ja"
assert counter["-dedup-"] == 2 # Zwei-Judge-Panel
async def test_dedup_judge_nein_keeps_both(board_env, tmp_path, monkeypatch, emb_on):
monkeypatch.setattr(bi, "_vec_rows", _angle_vecs({"modifizierter": 0, "greedy": 40}))
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
("-dedup-", {"pairs": {"1": "nein"}}),
]))
await _run_dedup(board_env[0], board_env[1], tmp_path, [
("b-1", {"title": "Greedy-Algorithmus", "description": "Basisverfahren"}),
("b-2", {"title": "Modifizierter Greedy-Algorithmus", "description": "Variante"}),
])
assert (await board_env[0].kanban_get_card(TOPIC, B, "b-1"))["stage"] == "grouping"
assert (await board_env[0].kanban_get_card(TOPIC, B, "b-2"))["stage"] == "grouping"
async def test_dedup_relation_guard_blocks_identical_tokens(board_env, tmp_path, monkeypatch, emb_on):
"""Gleiche Tokens, andere Richtung: Judge sagt „ja", Titel-Cos 1.0 (Auto-Kante) —
der Relation-Guard blockt beides, beide Karten überleben."""
monkeypatch.setattr(bi, "_vec_rows", _angle_vecs({"hamiltonian": 0}))
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
("-dedup-", lambda key: {"pairs": {"1": "ja"}}),
]))
await _run_dedup(board_env[0], board_env[1], tmp_path, [
("b-1", {"title": "Hamiltonian Cycle ≤ Hamiltonian Path", "description": "Reduktion"}),
("b-2", {"title": "Hamiltonian Path ≤ Hamiltonian Cycle", "description": "Reduktion"}),
])
assert (await board_env[0].kanban_get_card(TOPIC, B, "b-1"))["stage"] == "grouping"
assert (await board_env[0].kanban_get_card(TOPIC, B, "b-2"))["stage"] == "grouping"
journal = json.loads(next(tmp_path.glob("inventar-dedup-*.json")).read_text(encoding="utf-8"))
assert journal["paare_detail"][0]["verdict"] == "guard_veto"
async def test_dedup_panel_disagreement_keeps_both(board_env, tmp_path, monkeypatch, emb_on):
"""Merge braucht Einstimmigkeit: j1 ja + j2 nein → beide überleben (Journal: uneinig)."""
monkeypatch.setattr(bi, "_vec_rows", _angle_vecs({"satisfiability": 0, "sat": 40}))
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
("-dedup-", lambda key: {"pairs": {"1": "ja" if "-j1" in key else "nein"}}),
]))
await _run_dedup(board_env[0], board_env[1], tmp_path, [
("b-1", {"title": "SAT", "description": "d1"}),
("b-2", {"title": "SAT (Satisfiability Problem)", "description": "d2"}),
])
assert (await board_env[0].kanban_get_card(TOPIC, B, "b-1"))["stage"] == "grouping"
assert (await board_env[0].kanban_get_card(TOPIC, B, "b-2"))["stage"] == "grouping"
journal = json.loads(next(tmp_path.glob("inventar-dedup-*.json")).read_text(encoding="utf-8"))
assert journal["paare_detail"][0]["verdict"] == "uneinig"
async def test_dedup_title_only_candidate(board_env, tmp_path, monkeypatch, emb_on):
"""Titel-Cos über dem Floor reicht als Kandidat — auch wenn der Mittelwert
(verschiedene Beschreibungs-Facetten) darunter liegt."""
# Beschreibungen fast orthogonal (0° vs 80°), Titel ähnlich (0° vs 40°)
monkeypatch.setattr(bi, "_vec_rows", _angle_vecs(
{"völlig": 0, "ganz": 80, "alpha kern": 0, "alpha zentrum": 40}))
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
("-dedup-", {"pairs": {"1": "ja"}}),
]))
await _run_dedup(board_env[0], board_env[1], tmp_path, [
("b-1", {"title": "Alpha Kern", "description": "völlig anderes"}),
("b-2", {"title": "Alpha Zentrum", "description": "ganz anders zwei"}),
])
stages = sorted([(await board_env[0].kanban_get_card(TOPIC, B, c))["stage"]
for c in ("b-1", "b-2")])
assert stages == ["grouped", "grouping"] # Paar wurde gejudged und merged
def test_canonical_key_camel_and_catalogue():
"""CamelCase-Split + Katalog-Phrasen-Strip; Varianten-Ziffern bleiben erhalten."""
from blocks import _canonical_key as k
assert k("Set Cover") == k("SetCover-Problem") != ""
assert k("Definition 6.19 (NP)") == "np"
assert k("SAT") != k("3-SAT") # Varianten-Ziffer ist Signal, keine Katalognummer
assert k("ModifiedGreedy") == k("Modified Greedy")
# englische Katalog-Phrasen gleichwertig (Quellen sind nicht immer deutsch)
assert k("Corollary 3.2 (VC)") == "vc"
assert k("Chapter 7: Vertex Cover") == k("Vertex Cover")
assert k("Section 2.1 Matching") == k("Matching")
def test_relation_guard_ignores_trailing_scaffolding():
"""Trailing „Reduktion/Transformation" ist kein Operand — sonst blockt der Guard
den korrekten Merge; Richtungs-Konflikte bleiben erkannt."""
from blocks import _relation_conflict as c
assert not c("SetCover ≤ HittingSet", "SetCover ≤ HittingSet Reduktion")
assert not c("A → B", "A → B Transformation")
assert c("Hamiltonian Cycle ≤ Hamiltonian Path", "Hamiltonian Path ≤ Hamiltonian Cycle")
def test_relation_guard_english_and_operator_suffix():
"""Englisches „Reduction" ist Scaffolding wie „Reduktion"; ein angehängtes
p/m am Operator („≤p") gehört zum Operator, nicht zum Operanden.
Beides waren Fehl-Vetos im aak-Lauf. Varianten-Konflikte bleiben."""
from blocks import _relation_conflict as c
assert not c("3-Exact Cover ≤ SubSet Sum", "Reduction 3-EXACT COVER ≤ SUBSET SUM")
assert not c("k-CLIQUE ≤ k-INDEPENDENT SET", "Reduction k-CLIQUE ≤ k-INDEPENDENT SET")
assert not c("3-SAT ≤ 3-Färbung", "3-SAT ≤p 3-Färbung")
assert c("SAT ≤ Clique", "3-SAT ≤ Clique") # Variante als Operand bleibt Konflikt
async def test_dedup_casefold_title_candidate(board_env, tmp_path, monkeypatch, emb_on):
"""GROSSSCHREIBUNG darf den Titel-Kanal nicht brechen: Titel werden casefolded
eingebettet („VERTEX COVER" vs. „Vertex Cover (VC)" lag real bei Cos 0.55)."""
monkeypatch.setattr(bi, "_vec_rows", _angle_vecs({"vertex cover": 0}))
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
("-dedup-", {"pairs": {"1": "ja"}}),
]))
await _run_dedup(board_env[0], board_env[1], tmp_path, [
("b-1", {"title": "VERTEX COVER", "description": "knapp"}),
("b-2", {"title": "Vertex Cover (VC)", "description": "Knotenüberdeckung ausführlich"}),
])
stages = sorted([(await board_env[0].kanban_get_card(TOPIC, B, c))["stage"]
for c in ("b-1", "b-2")])
assert stages == ["grouped", "grouping"]
async def test_dedup_no_embedding_passes_through(board_env, tmp_path, monkeypatch):
"""Ohne Embedding-Modell winkt die Stage durch — 0 Agent-Calls (kein n²-Fallback)."""
counter = {}
monkeypatch.setattr(bi, "run_single_slot", _slot_router([("-dedup-", {"pairs": {}})], counter))
await _run_dedup(board_env[0], board_env[1], tmp_path, [
("b-1", {"title": "SAT", "description": "d"}),
("b-2", {"title": "SAT (Satisfiability Problem)", "description": "d"}),
])
assert (await board_env[0].kanban_get_card(TOPIC, B, "b-1"))["stage"] == "grouping"
assert (await board_env[0].kanban_get_card(TOPIC, B, "b-2"))["stage"] == "grouping"
assert counter == {}
async def test_dedup_second_wave_merges_into_context(board_env, tmp_path, monkeypatch, emb_on):
"""Supplement-Welle: Neuling merged in den bestätigten Block; der bleibt unberührt."""
db, ctx, files = board_env
monkeypatch.setattr(bi, "_vec_rows", _angle_vecs({"satisfiability": 0, "sat": 40}))
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
("-dedup-", {"pairs": {"1": "ja"}}),
]))
await db.kanban_upsert_card(TOPIC, B, "b-old", "block", "done_block",
{"title": "SAT", "description": "Erfüllbarkeitsproblem",
"readers": ["r1"], "mirrored_norm": "sat"})
await db.upsert_block(TOPIC, "sat", "SAT", "Erfüllbarkeitsproblem", [])
await _run_dedup(db, ctx, tmp_path, [
("b-new", {"title": "SAT (Satisfiability Problem)", "description": "kurz", "readers": ["r9"]}),
])
new = await db.kanban_get_card(TOPIC, B, "b-new")
old = await db.kanban_get_card(TOPIC, B, "b-old")
assert new["stage"] == "grouped"
assert new["payload"]["merged_into"] == "SAT"
assert old["stage"] == "done_block" # context never demoted
assert set(old["payload"]["readers"]) == {"r1", "r9"}
async def test_dedup_resume_no_new_calls(board_env, tmp_path, monkeypatch, emb_on):
monkeypatch.setattr(bi, "_vec_rows", _angle_vecs({"satisfiability": 0, "sat": 40}))
counter = {}
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
("-dedup-", {"pairs": {"1": "nein"}}),
], counter))
cards = [("b-1", {"title": "SAT", "description": "d1"}),
("b-2", {"title": "SAT (Satisfiability Problem)", "description": "d2"})]
await _run_dedup(board_env[0], board_env[1], tmp_path, cards)
assert counter["-dedup-"] == 2 # zwei Panel-Judges
await _run_dedup(board_env[0], board_env[1], tmp_path, cards)
assert counter["-dedup-"] == 2 # judge files reused
async def test_dedup_complete_link_no_chaining(board_env, tmp_path, monkeypatch, emb_on):
"""A≈B ja, B≈C ja, AC kein Kandidat → complete-link merged nur ein Paar."""
# Winkel 0/30/60: A-B und B-C sind Kandidaten (cos .87), A-C nicht (cos .5 < Floor)
monkeypatch.setattr(bi, "_vec_rows", _angle_vecs({"alpha": 0, "beta": 30, "gamma": 60}))
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
("-dedup-", {"pairs": {"1": "ja", "2": "ja"}}),
]))
await _run_dedup(board_env[0], board_env[1], tmp_path, [
("b-1", {"title": "Konzept Alpha", "description": "a"}),
("b-2", {"title": "Konzept Beta", "description": "bb"}),
("b-3", {"title": "Konzept Gamma", "description": "c"}),
])
db = board_env[0]
stages = {cid: (await db.kanban_get_card(TOPIC, B, cid))["stage"]
for cid in ("b-1", "b-2", "b-3")}
assert sorted(stages.values()) == ["grouped", "grouping", "grouping"]
# ── Makespan: Slot-Priorität, vorgezogene Gliederung ──────────────────────────────── # ── Makespan: Slot-Priorität, vorgezogene Gliederung ────────────────────────────────
def test_agent_priority_order(): def test_agent_priority_order():
"""Board 1 zuerst; in Board 2 gewinnen späte Stages (Restarbeit vor Nachschub).""" """Board 1 zuerst; in Board 2 gewinnen späte Stages (Restarbeit vor Nachschub)."""
from agents import _agent_priority as p from agents import _agent_priority as p
t = "blocks-Markdown" t = "blocks-Markdown"
assert p(f"{t}-research-1") < p(f"{t}-filter-abc-c0") < p(f"{t}-supplement") assert p(f"{t}-research-1") < p(f"{t}-filter-abc-c0") < p(f"{t}-dedup-abc-c0") < p(f"{t}-supplement")
# Gruppierung-Keys heißen "gruppierung": ohne eigenen Eintrag fielen sie ans Ende
# und verhungerten hinter Board 2 (aak: 211 min Slot-Wartezeit)
assert p(f"{t}-dedup-abc-c0") < p(f"{t}-gruppierung-xyz-cTOP") < p(f"{t}-supplement")
assert p(f"{t}-gruppierung-xyz-cTOP") < p(f"{t}-ns-subblock-c1-r2-1")
assert (p(f"{t}-outline-judge") < p(f"{t}-ns-artifact-example-c0") assert (p(f"{t}-outline-judge") < p(f"{t}-ns-artifact-example-c0")
< p(f"{t}-ns-question-pattern-c0") < p(f"{t}-ns-relevance-final-c0") < p(f"{t}-ns-question-pattern-c0") < p(f"{t}-ns-relevance-final-c0")
< p(f"{t}-ns-level-final-c0") < p(f"{t}-ns-facts-erg-c0") < p(f"{t}-ns-level-final-c0") < p(f"{t}-ns-facts-erg-c0")
< p(f"{t}-ns-subblock-c1-r2-1")) < p(f"{t}-ns-subblock-c1-r2-1"))
assert p(f"{t}-supplement") < p(f"{t}-outline-1") assert p(f"{t}-supplement") < p(f"{t}-outline-1")
assert p("guide-t-writer-k1") == 16 # unmatched → after everything assert p("guide-t-writer-k1") == 18 # unmatched → after everything
async def test_outline_runs_before_artefacts_finish(board_env, monkeypatch): async def test_outline_runs_before_artefacts_finish(board_env, monkeypatch):
@@ -509,3 +762,5 @@ def test_per_block_functions_accept_wrapper_kwargs():
params = inspect.signature(getattr(blx, fn)).parameters params = inspect.signature(getattr(blx, fn)).parameters
assert "ns" in params and "lbl" in params, fn assert "ns" in params and "lbl" in params, fn
assert "seeds" in inspect.signature(blx._subblocks_block).parameters assert "seeds" in inspect.signature(blx._subblocks_block).parameters
for fn in ("_subblocks_block", "_facts_block"): # Board 2 reicht die Block-Quellen durch
assert "sources" in inspect.signature(getattr(blx, fn)).parameters, fn

View File

@@ -104,6 +104,13 @@ async def test_pull_prefers_bigger_blocks(testdb):
await db.kanban_upsert_card(TOPIC, "inventory", "b", "block", "s1") await db.kanban_upsert_card(TOPIC, "inventory", "b", "block", "s1")
pulled = await db.kanban_pull(TOPIC, "inventory", "s1", 10) pulled = await db.kanban_pull(TOPIC, "inventory", "s1", 10)
assert [c["card_id"] for c in pulled] == ["a", "b"] assert [c["card_id"] for c in pulled] == ["a", "b"]
# n_size (Board 1) ist der Fallback-Schätzer; subs_n behält Vorrang
await db.kanban_upsert_card(TOPIC, "inventory", "n-klein", "block", "s2", {"n_size": 2})
await db.kanban_upsert_card(TOPIC, "inventory", "n-gross", "block", "s2", {"n_size": 9})
await db.kanban_upsert_card(TOPIC, "inventory", "n-ohne", "block", "s2")
await db.kanban_upsert_card(TOPIC, "inventory", "n-subs", "block", "s2", {"subs_n": 3, "n_size": 1})
pulled = await db.kanban_pull(TOPIC, "inventory", "s2", 10)
assert [c["card_id"] for c in pulled] == ["n-gross", "n-subs", "n-klein", "n-ohne"]
async def test_learnstate_smoke(testdb): async def test_learnstate_smoke(testdb):
@@ -172,3 +179,50 @@ async def test_guide_reset_card_single(testdb):
assert await gb.reset_card(TOPIC, "Guide", "beta", 3) is True assert await gb.reset_card(TOPIC, "Guide", "beta", 3) is True
cards = {c["block_norm"]: c for c in await db.list_guide_cards(TOPIC, "Guide")} cards = {c["block_norm"]: c for c in await db.list_guide_cards(TOPIC, "Guide")}
assert cards["beta"]["stage"] == "fakten_gate" and cards["beta"]["md"] assert cards["beta"]["stage"] == "fakten_gate" and cards["beta"]["md"]
async def test_completeness_route(testdb, tmp_path, monkeypatch):
import routes, paths
db = testdb
monkeypatch.setattr(paths, "arbeit_dir", lambda t: tmp_path)
await db.upsert_block(TOPIC, "alpha", "Alpha", "d", "[]")
await db.set_block_status(TOPIC, "alpha", "consensus")
await db.upsert_subblock(TOPIC, "alpha", "s1", "Alpha", "Sub Eins")
await db.set_subblock_fields(TOPIC, "alpha", "s1", status="consensus")
await db.upsert_question_pattern(TOPIC, "alpha", "s1", "Alpha", "Sub Eins", "Frage?")
await db.put_sub_artifact(TOPIC, "alpha", "s1", "flashcard", "Alpha", "Sub Eins", "{}")
await db.put_lernziel(TOPIC, "alpha", "z1", "Ziel")
await db.set_ziel_covered(TOPIC, "alpha", "z1", True)
(tmp_path / "inventar-filter-x.json").write_text(
'{"degradiert": 3, "ueberstimmt": ["A"], "floor_veto": []}', encoding="utf-8")
res = await routes.blocks_completeness(TOPIC)
assert res["bloecke"] == 1 and res["subs"] == 1
assert res["frage_bloecke"] == 1 and res["lernkarten"] == 1
assert res["ziele_total"] == 1 and res["ziele_covered"] == 1
assert res["degradiert_geprueft"] == 3 and res["panel_gerettet"] == 1
assert res["dead"] == 0
async def test_blocks_ready_from_db(testdb, monkeypatch):
"""Regression: gesynctes Topic ohne blocks.md muss trotzdem ready sein (DB zählt)."""
import blocks as blx
db = testdb
await db.kanban_upsert_card(TOPIC, "inventory", "b-1", "block", "done_block", {"title": "Alpha"})
st = await blx.blocks_status(TOPIC)
assert st["ready"] is True and st["partial"] is False
async def test_remove_guide_format_clears_everything(testdb, monkeypatch):
"""Board-Remove räumt ALLE Läufe eines Formats + Karten (8 error-Zeilen stapelten sich)."""
import routes
from models import GuideFormatRequest
db = testdb
for i in range(3):
await db.create_guide({"id": f"g{i}", "topic": TOPIC, "format": "Guide",
"instructions": "", "status": "error", "progress": None,
"created_at": "2026-01-01", "updated_at": "2026-01-01"})
await db.upsert_guide_card(TOPIC, "Guide", "alpha", "Alpha")
res = await routes.remove_guide_format(GuideFormatRequest(topic=TOPIC, format="Guide"))
assert res["removed"] == 3
assert await db.list_guides() == [] or all(g["topic"] != TOPIC for g in await db.list_guides())
assert await db.list_guide_cards(TOPIC, "Guide") == []

View File

@@ -122,6 +122,69 @@ TOML ausführlich.""")[0]
assert "TOML ausführlich." in sec["md"] and "YAML kompakt." in sec["compact"] assert "TOML ausführlich." in sec["md"] and "YAML kompakt." in sec["compact"]
async def test_card_examples_filters_and_formats(testdb):
"""_card_examples: Norm-Matching auf die übergebenen Subs; unmatchte Beispiele nur
beim Voll-Writer/Teil 1 (include_unmatched) — nie stillschweigend weg."""
import json as _json
import guide_board as gb
from types import SimpleNamespace
db = testdb
await db.put_sub_artifact("t", "gross", "sub eins", "example",
_json.dumps({"problem": "P1", "steps": ["a", "b"], "result": "R1"}),
"Gross", "Sub Eins")
await db.put_sub_artifact("t", "gross", "verwaist", "example",
_json.dumps({"problem": "P2", "steps": ["x"], "result": "R2"}),
"Gross", "Verwaister Sub")
env = SimpleNamespace(topic="t")
subs = [{"title": "Sub Eins", "level": "beginner"}]
full = await gb._card_examples(env, "gross", subs)
assert "Sub Eins" in full and "P1" in full and "1) a 2) b" in full and "R1" in full
assert "Subbaustein unklar" in full and "P2" in full # orphan attached with hint
half = await gb._card_examples(env, "gross", subs, include_unmatched=False)
assert "P1" in half and "P2" not in half # split half: only its own subs
assert await gb._card_examples(env, "leer", subs) == ""
def test_writer_template_has_examples_placeholder():
"""Smoke: alle Platzhalter versorgt — ein fehlender Kwarg stürbe als KeyError."""
from pipeline import _prompt
text = _prompt("Guide-Writer-Board", topic="t", format_name="Guide", chapter="K1",
assignment="- B", ziele="- z", facts="F", examples="", gaps="",
spec="", out_path="/tmp/x.md", extra="")
assert "VERIFIED FACTS" in text
async def test_fakten_gate_counts_examples_as_facts(testdb, monkeypatch, tmp_path):
"""Gate-Prompt enthält die Beispiele als verifizierte Fakten — sonst fliegen
gerechnete Beispielwerte als „nicht belegt" raus."""
import json as _json
import guide_board as gb
from types import SimpleNamespace
db = testdb
await db.upsert_guide_card("t", "Guide", "gross", "Gross")
await db.put_sub_artifact("t", "gross", "sub eins", "example",
_json.dumps({"problem": "P1", "steps": ["a"], "result": "R1"}),
"Gross", "Sub Eins")
captured = {}
async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
captured["prompt"] = prompt
return "ok", []
monkeypatch.setattr(gb, "run_single_slot", fake_slot)
monkeypatch.setattr(gb, "_card_facts", lambda e, b: "FAKT X")
env = SimpleNamespace(ctx=SimpleNamespace(topic="t", provider="p", is_cancelled=lambda: False),
guide_id="g", topic="t", format="Guide", instructions="",
subs_by_title={"Gross": [{"title": "Sub Eins", "level": "beginner"}]},
spec="", slot=lambda name: tmp_path / name)
card = {"block_norm": "gross", "block": "Gross", "stage": "fakten_gate", "status": "open",
"writer_rounds": 0, "gate_info": "",
"md": "<!-- section: Gross -->\n<!-- ausführlich -->\nText."}
ok = await gb._stage_fakten_gate(env, card)
assert ok is True
assert "VERIFIED WORKED EXAMPLES" in captured["prompt"] and "P1" in captured["prompt"]
async def test_writer_splits_oversized_first_draft(testdb, monkeypatch, tmp_path): async def test_writer_splits_oversized_first_draft(testdb, monkeypatch, tmp_path):
import guide_board as gb import guide_board as gb
from types import SimpleNamespace from types import SimpleNamespace

View File

@@ -0,0 +1,71 @@
"""PDF→Text-Konvertierung: pymupdf4llm primär, pdftotext-Fallback, mtime-Cache."""
import os
import time
import fitz # PyMuPDF
import pytest
import blocks as blx
def _mini_pdf(path, text="Approximationsalgorithmen sind wichtig."):
doc = fitz.open()
page = doc.new_page()
page.insert_text((72, 72), text, fontsize=12)
doc.save(str(path))
doc.close()
def test_convert_writes_markdown_txt(tmp_path):
_mini_pdf(tmp_path / "skript.pdf")
blx._convert_pdfs(tmp_path)
out = (tmp_path / "skript.txt").read_text(encoding="utf-8")
assert "Approximationsalgorithmen" in out
def test_cache_skips_fresh_txt(tmp_path):
_mini_pdf(tmp_path / "a.pdf")
marker = tmp_path / "a.txt"
marker.write_text("MARKER", encoding="utf-8")
now = time.time() + 60
os.utime(marker, (now, now))
blx._convert_pdfs(tmp_path)
assert marker.read_text(encoding="utf-8") == "MARKER" # nicht neu konvertiert
def test_fallback_to_pdftotext(tmp_path, monkeypatch):
_mini_pdf(tmp_path / "b.pdf")
monkeypatch.setattr(blx, "_pdf_markdown", lambda p: None)
monkeypatch.setattr(blx, "_pdf_plaintext", lambda p: "fallback")
blx._convert_pdfs(tmp_path)
assert (tmp_path / "b.txt").read_text(encoding="utf-8") == "fallback"
def test_ocr_languages_from_tessdata(tmp_path, monkeypatch):
import pymupdf
monkeypatch.setattr(pymupdf, "get_tessdata", lambda: str(tmp_path))
assert blx._ocr_languages() is None # keine Sprachdaten → OCR aus
(tmp_path / "eng.traineddata").touch()
assert blx._ocr_languages() == "eng"
(tmp_path / "deu.traineddata").touch()
assert blx._ocr_languages() == "deu+eng"
monkeypatch.setattr(pymupdf, "get_tessdata", lambda: (_ for _ in ()).throw(RuntimeError()))
assert blx._ocr_languages() is None
def test_fidelity_guard_prefers_faithful_plaintext():
plain = "Definition. P = {L ⊆ Σ | A ∈ L} und ≤ sowie häufig über. " * 20
# Markdown verlor die Formeln (Symbole weg) → plain gewinnt
md_lossy = "Definition. und sowie h¨aufig ¨uber. " * 20
text, tool = blx._pick_conversion(md_lossy, plain)
assert tool == "pdftotext"
# Markdown treu (Symbole + Länge da) → md gewinnt
md_ok = "# Def\n" + plain
text, tool = blx._pick_conversion(md_ok, plain)
assert tool == "pymupdf4llm"
# nur eine Quelle verfügbar
assert blx._pick_conversion(None, plain)[1] == "pdftotext"
assert blx._pick_conversion(md_ok, None)[1] == "pymupdf4llm"
assert blx._pick_conversion(None, None) is None

View File

@@ -0,0 +1,146 @@
"""Flashcard-Übungspool: Leitner-Schritte, Deck-Bau (Level-Gate, fällig/neu), Persistenz."""
import json
from datetime import datetime, timedelta, timezone
from learning import LEITNER_MAX_BOX, PRACTICE_NEW_PER_SESSION, leitner_step
TOPIC = "t"
def _iso(days: float = 0) -> str:
return (datetime.now(timezone.utc) + timedelta(days=days)).isoformat()
async def _card(db, bn, sn, sub_title="Sub", q="Q?", block="Block"):
await db.put_sub_artifact(TOPIC, bn, sn, "flashcard",
json.dumps({"question": q, "answer": "A"}), block, sub_title)
# ── Leitner rein funktional ──────────────────────────────────────────────────────────
def test_leitner_step_transitions():
assert leitner_step(None, True) == (2, 1) # neue Karte gewusst → Box 2, morgen
assert leitner_step(None, False) == (1, 0) # neue Karte falsch → Box 1, sofort
assert leitner_step(2, True) == (3, 3)
assert leitner_step(LEITNER_MAX_BOX, True) == (LEITNER_MAX_BOX, 21) # Cap
assert leitner_step(4, False) == (1, 0) # falsch → zurück auf Anfang
# ── Persistenz ───────────────────────────────────────────────────────────────────────
async def test_progress_upsert_roundtrip(testdb):
db = testdb
await db.upsert_practice_progress(TOPIC, "b", "s", 2, _iso(1))
await db.upsert_practice_progress(TOPIC, "b", "s", 3, _iso(3))
rows = await db.get_practice_progress(TOPIC)
assert len(rows) == 1 and rows[0]["box"] == 3
async def test_progress_survives_artefakte_wipe(testdb):
db = testdb
await _card(db, "b", "s")
await db.upsert_practice_progress(TOPIC, "b", "s", 4, _iso(7))
await db.delete_sub_artefakte(TOPIC) # Regenerations-Wipe
assert (await db.get_practice_progress(TOPIC))[0]["box"] == 4
async def test_delete_topic_pipeline_clears_progress(testdb):
db = testdb
await db.upsert_practice_progress(TOPIC, "b", "s", 2, _iso(1))
await db.delete_topic_pipeline(TOPIC)
assert await db.get_practice_progress(TOPIC) == []
async def test_sub_levels_norm_and_counts(testdb):
db = testdb
await db.put_subblock(TOPIC, "b", "s1", "Block", "S1", level="beginner")
await db.put_subblock(TOPIC, "b", "s2", "Block", "S2", level="expert")
await db.put_subblock(TOPIC, "b", "s3", "Block", "S3", level="beginner", relevance="peripheral")
await db.put_subblock(TOPIC, "b", "s4", "Block", "S4", level="beginner", status="variant")
levels = await db.sub_levels_norm(TOPIC)
assert levels[("b", "s1")] == 1 and levels[("b", "s2")] == 3 and levels[("b", "s3")] == 4
assert ("b", "s4") not in levels # non-consensus ausgeschlossen
counts = await db.subs_per_level_norm(TOPIC)
assert counts["b"] == {1: 1, 2: 0, 3: 1, 4: 1}
# ── Deck-Bau ─────────────────────────────────────────────────────────────────────────
async def test_deck_level_gate_and_unlock(testdb):
from routes import build_practice_deck
db = testdb
# block_norm muss _norm_title(Roh-Titel) sein — so entsteht er auch in der Pipeline
await db.put_subblock(TOPIC, "block", "s1", "Block", "S1", level="beginner")
await db.put_subblock(TOPIC, "block", "s2", "Block", "S2", level="expert")
await _card(db, "block", "s1", "S1")
await _card(db, "block", "s2", "S2")
deck = await build_practice_deck(TOPIC)
assert [c["sub_norm"] for c in deck["cards"]] == ["s1"] # expert gesperrt
assert deck["counts"]["gesperrt"] == 1
# Score über S1+S2-Schwelle (2 Subs × 25 = 50) → expert (Level 3) frei
await db.set_block_score_and_streak(TOPIC, "Block", 50, 0)
deck = await build_practice_deck(TOPIC)
assert {c["sub_norm"] for c in deck["cards"]} == {"s1", "s2"}
async def test_deck_due_before_new_oldest_first(testdb):
from routes import build_practice_deck
db = testdb
for sn in ("s1", "s2", "s3"):
await db.put_subblock(TOPIC, "b", sn, "Block", sn.upper(), level="beginner")
await _card(db, "b", sn, sn.upper())
await db.upsert_practice_progress(TOPIC, "b", "s2", 2, _iso(-1))
await db.upsert_practice_progress(TOPIC, "b", "s3", 2, _iso(-5))
deck = await build_practice_deck(TOPIC)
assert [c["sub_norm"] for c in deck["cards"]] == ["s3", "s2", "s1"] # älteste fällige zuerst
assert [c["status"] for c in deck["cards"]] == ["due", "due", "new"]
assert deck["counts"] == {"due": 2, "new": 1, "new_total": 1, "gesperrt": 0}
async def test_deck_caps_new_and_reports_total(testdb):
from routes import build_practice_deck
db = testdb
for i in range(PRACTICE_NEW_PER_SESSION + 5):
sn = f"s{i:02d}"
await db.put_subblock(TOPIC, "b", sn, "Block", sn, level="beginner")
await _card(db, "b", sn, sn)
deck = await build_practice_deck(TOPIC)
assert deck["counts"]["new"] == PRACTICE_NEW_PER_SESSION
assert deck["counts"]["new_total"] == PRACTICE_NEW_PER_SESSION + 5
async def test_deck_future_due_sets_next_due_at(testdb):
from routes import build_practice_deck
db = testdb
await db.put_subblock(TOPIC, "b", "s1", "Block", "S1", level="beginner")
await _card(db, "b", "s1", "S1")
await db.upsert_practice_progress(TOPIC, "b", "s1", 3, _iso(3))
deck = await build_practice_deck(TOPIC)
assert deck["cards"] == [] and deck["counts"]["due"] == 0
assert deck["next_due_at"] is not None
async def test_deck_orphan_progress_and_legacy_block(testdb):
from routes import build_practice_deck
db = testdb
# Orphan: Progress ohne Karte → unschädlich, taucht nicht auf
await db.upsert_practice_progress(TOPIC, "weg", "s0", 2, _iso(-1))
# Legacy: Karte ohne subblocks-Zeilen → ungefiltert durchlassen
await _card(db, "leg", "sx", "SX")
deck = await build_practice_deck(TOPIC)
assert [c["block_norm"] for c in deck["cards"]] == ["leg"]
async def test_answer_books_without_card(testdb):
"""Antwort während Regeneration: bucht immer, kein Fehlerpfad."""
from models import PracticeAnswerRequest
from routes import practice_answer
db = testdb
res = await practice_answer(PracticeAnswerRequest(
topic=TOPIC, block_norm="b", sub_norm="s", correct=True))
assert res["box"] == 2
res = await practice_answer(PracticeAnswerRequest(
topic=TOPIC, block_norm="b", sub_norm="s", correct=False))
assert res["box"] == 1
assert (await db.get_practice_progress(TOPIC))[0]["box"] == 1

View File

@@ -62,24 +62,27 @@ def _mk_race(finder_by_agent):
for slot in slots: for slot in slots:
key, prompt = slot["key"], slot["prompt"] key, prompt = slot["key"], slot["prompt"]
prompts.append((key, prompt)) prompts.append((key, prompt))
text = None fake_race.slots_seen.append(slot)
if "-subblock-final-" in key: if "-subblock-final-" in key:
# no-tool judges reply as TEXT; the payload sink writes the j-file itself
kons = re.search(r"Konsens \(≥2 finders\):\n(.*?)\nUnsicher", prompt, re.S) kons = re.search(r"Konsens \(≥2 finders\):\n(.*?)\nUnsicher", prompt, re.S)
subs = [l[2:] for l in (kons.group(1).splitlines() if kons else []) subs = [l[2:] for l in (kons.group(1).splitlines() if kons else [])
if l.startswith("- ") and l != "- (keiner)"] if l.startswith("- ") and l != "- (keiner)"]
if subs: if subs:
text = "<!-- block: Alpha -->\n" + "\n".join(f"- {s}" for s in subs) text = "<!-- block: Alpha -->\n" + "\n".join(f"- {s}" for s in subs)
elif "-r1-" in key: outs.append(slot["payload"]((0, text, "")))
continue
if "-r1-" in key:
agent = int(key.rsplit("-", 1)[1]) agent = int(key.rsplit("-", 1)[1])
subs = finder_by_agent.get(agent) or [] subs = finder_by_agent.get(agent) or []
if subs: if subs and (m := _MD_PATH.search(prompt)):
text = "<!-- block: Alpha -->\n" + "\n".join(f"- {s}" for s in subs) text = "<!-- block: Alpha -->\n" + "\n".join(f"- {s}" for s in subs)
if text is not None and (m := _MD_PATH.search(prompt)):
with open(m.group(1), "w", encoding="utf-8") as f: with open(m.group(1), "w", encoding="utf-8") as f:
f.write(text) f.write(text)
outs.append(slot["payload"](None)) outs.append(slot["payload"](None))
outs = [o for o in outs if o] outs = [o for o in outs if o]
return outs or None return outs or None
fake_race.slots_seen = []
return fake_race, prompts return fake_race, prompts
@@ -293,3 +296,124 @@ async def test_round_cap_stops_endless_finders(sub_env, monkeypatch):
"", wipe=False, ns="x-") "", wipe=False, ns="x-")
max_round = max(int(k.split("-r")[1].split("-")[0]) for k, _ in prompts if "-r" in k and "-subblock-c" in k) max_round = max(int(k.split("-r")[1].split("-")[0]) for k, _ in prompts if "-r" in k and "-subblock-c" in k)
assert max_round == blx.SUBBLOCK_MAX_ROUNDS assert max_round == blx.SUBBLOCK_MAX_ROUNDS
# ── Inline-Evidenz für Judges (Token-Umbau) ──────────────────────────────────────────
def _corpus(tmp_path):
d = tmp_path / "korpus"
d.mkdir()
(d / "Skript.txt").write_text(
"Kapitel 1\nAlpha Grundlagen: der Kernbegriff.\nMehr Text dazu.\n\n"
"Kapitel 2\nGamma Randnotiz ohne Bezug.\n", encoding="utf-8")
(d / "Aufgaben.txt").write_text("Übung 1\nAlpha Vertiefung der Konzepte.\n", encoding="utf-8")
return d
def test_evidence_pack_selects_matching_sections(tmp_path):
d = _corpus(tmp_path)
pack = blx._evidence_pack(d, None, ["Alpha Grundlagen"])
assert "── Skript.txt" in pack and "Kernbegriff" in pack
pack2 = blx._evidence_pack(d, ["Aufgaben.txt"], ["Alpha"]) # genannte Quellen engen ein
assert "Skript.txt" not in pack2 and "Aufgaben.txt" in pack2
assert blx._evidence_pack(None, None, ["x"]) == "" # kein Korpus → Selbst-Recherche bleibt
def test_evidence_pack_budget_and_guarantee(tmp_path):
d = tmp_path / "korpus"
d.mkdir()
(d / "A.txt").write_text("Alpha wichtig. " * 50, encoding="utf-8")
(d / "B.txt").write_text("Beta anderes Thema. " * 50, encoding="utf-8")
pack = blx._evidence_pack(d, None, ["Alpha"], budget=10)
assert "Alpha" in pack # Abdeckungs-Garantie schlägt das Budget
assert "Beta" not in pack # Top-up respektiert das Budget
def test_cite_ref_parses_positions(tmp_path):
d = _corpus(tmp_path)
files = blx._corpus_files(d, None)
f, lo, hi = blx._cite_ref("Skript.txt, Übung 6.47, Z.2-3", files)
assert f.name == "Skript.txt" and (lo, hi) == (2, 3)
f2, lo2, hi2 = blx._cite_ref("Aufgaben.txt Zeile 2", files)
assert f2.name == "Aufgaben.txt" and lo2 == hi2 == 2
assert blx._cite_ref("Skript.txt, Übung 6.47", files) is None # keine Zeilenangabe
assert blx._cite_ref("Z.5 irgendwo", files) is None # keine Datei
# englische Zitierformen (Quellen sind nicht immer deutsch)
f3, lo3, hi3 = blx._cite_ref("Skript.txt, line 2", files)
assert f3.name == "Skript.txt" and lo3 == hi3 == 2
f4, lo4, hi4 = blx._cite_ref("Aufgaben.txt, lines 1-2", files)
assert f4.name == "Aufgaben.txt" and (lo4, hi4) == (1, 2)
def test_cited_evidence_lines_and_fallback(tmp_path):
d = _corpus(tmp_path)
ev = blx._cited_evidence(d, None, ["Skript.txt, Z.2"], ["Alpha"])
assert "── Skript.txt · Z." in ev and "Kernbegriff" in ev
ev2 = blx._cited_evidence(d, None, ["ohne Position"], ["Alpha Grundlagen"])
assert "Kernbegriff" in ev2 # Keyword-Fallback
def test_sink_json_writes_only_valid(tmp_path):
p = tmp_path / "level-final-c1.json"
ok = blx._sink_json((0, 'Vorab {"levels": {"1": "beginner"}} nach', ""), p,
lambda d: blx._levels_schema(d, {1}))
assert ok == {1: "beginner"}
assert json.loads(p.read_text(encoding="utf-8"))["levels"]["1"] == "beginner"
bad = blx._sink_json((0, "kein json", ""), tmp_path / "x.json", lambda d: d)
assert bad is None and not (tmp_path / "x.json").exists()
async def test_clarify_inline_evidence_no_tools(sub_env, monkeypatch, tmp_path):
"""Mit Korpus: Judges bekommen Auszüge inline und laufen ohne Tools (Text-Antwort);
die j-Datei schreibt die Engine. Finder bleiben unverändert bei capabilities=files."""
db, ctx, files = sub_env
d = _corpus(tmp_path)
monkeypatch.setattr(blx, "source_folder", lambda t: d)
monkeypatch.setattr(blx, "load_source", lambda t: {"type": "uni"})
fake, prompts = _mk_race({1: ["Alpha Grundlagen"], 2: ["Alpha Grundlagen"]})
monkeypatch.setattr(blx, "_race", fake)
raw = await blx._subblocks_block(ctx, lambda *a, **k: None, files, {1: "Alpha — Grundkonzept"},
"", wipe=False, ns="x-", sources=["Skript.txt"])
assert raw == {"Alpha": ["Alpha Grundlagen"]}
judges = [s for s in fake.slots_seen if "-subblock-final-" in s["key"]]
finders = [s for s in fake.slots_seen if "-r1-" in s["key"]]
assert judges and all(s["capabilities"] == "none" for s in judges)
assert "── Skript.txt" in judges[0]["prompt"]
assert "ls/find" not in judges[0]["prompt"] # keine Selbst-Recherche-Anweisung mehr
assert finders and all(s["capabilities"] == "files" for s in finders)
assert list(tmp_path.glob("subblock-final-*-j*.md")) # Engine persistiert die Judge-Antwort
async def test_facts_check_inline_cited_evidence(sub_env, monkeypatch, tmp_path):
"""Facts-Check: zitierte Zeilenbereiche gehen inline mit, Judge läuft ohne Tools,
die Check-Datei schreibt die Engine aus der Text-Antwort."""
db, ctx, files = sub_env
d = _corpus(tmp_path)
facts = {"facts": [{"block": "Alpha", "subblock": "Sub Eins", "key_points": ["k"],
"prerequisites": "", "hurdles": "",
"cited_facts": [{"text": "T", "source": "Skript.txt, Z.2"}],
"example_idea": ""}]}
async def fake_slot(ctx2, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
if "-facts-erg-" in key:
return blx.FAILED, None
(tmp_path / "facts-c0.json").write_text(json.dumps(facts), encoding="utf-8")
return blx.OK, None
seen = []
async def fake_agent(key, prompt, timeout, provider="", role="", capabilities="", scope=None, label="", **kw):
seen.append((key, capabilities, prompt))
return (0, '{"ok": true}', "")
monkeypatch.setattr(blx, "run_single_slot", fake_slot)
monkeypatch.setattr(blx, "run_agent", fake_agent)
res = await blx._facts_block(ctx, lambda *a, **k: None, {"arbeit": tmp_path},
{"Alpha": ["Sub Eins"]}, {"type": "uni"}, d, "", ns="x-")
assert res is not None
facts_map, discarded = res
assert "Alpha" in facts_map and not discarded
assert len(seen) == blx.FACTS_CHECK_PANEL
key, caps, prompt = seen[0]
assert caps == "none" and "── Skript.txt · Z." in prompt
assert (tmp_path / "facts-check-c0-j1.json").exists() # Engine persistiert die Antwort

View File

@@ -1,12 +1,13 @@
<script setup> <script setup>
import { ref, computed, watch, onMounted } from 'vue' import { ref, computed, watch, onMounted } from 'vue'
import { fetchGuides, fetchTopics, deleteTopic as apiDeleteTopic, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, fetchBlocksStatus, fetchActiveBlocks, createBlocks as apiCreateBausteine, resetBlocksStage as apiResetBlocksStage, addBlocksResearch as apiAddResearch, requeueBlocksDead as apiRequeueDead, resetGuideBoard as apiResetGuideBoard, restartBlocksCard as apiRestartBlocksCard, resetGuideCard as apiResetGuideCard, cancelBlocks as apiCancelBausteine, deleteBlocks as apiDeleteBausteine, fetchProviders, fetchStats, fetchTopicProgress, fetchGuideLocks, fetchFolders, updateSource as apiUpdateQuelle } from './api.js' import { fetchGuides, fetchTopics, deleteTopic as apiDeleteTopic, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, fetchBlocksStatus, fetchActiveBlocks, createBlocks as apiCreateBausteine, resetBlocksStage as apiResetBlocksStage, addBlocksResearch as apiAddResearch, requeueBlocksDead as apiRequeueDead, resetGuideBoard as apiResetGuideBoard, restartBlocksCard as apiRestartBlocksCard, resetGuideCard as apiResetGuideCard, removeGuideFormat as apiRemoveGuideFormat, cancelBlocks as apiCancelBausteine, deleteBlocks as apiDeleteBausteine, fetchProviders, fetchStats, fetchTopicProgress, fetchGuideLocks, fetchFolders, updateSource as apiUpdateQuelle } from './api.js'
import { usePolling } from './composables/usePolling.js' import { usePolling } from './composables/usePolling.js'
import TopicSidebar from './components/TopicSidebar.vue' import TopicSidebar from './components/TopicSidebar.vue'
import TopicDetail from './components/TopicDetail.vue' import TopicDetail from './components/TopicDetail.vue'
import BlocksOverview from './components/BlocksOverview.vue' import BlocksOverview from './components/BlocksOverview.vue'
import GenerationView from './components/GenerationView.vue' import GenerationView from './components/GenerationView.vue'
import GeneralExamPanel from './components/GeneralExamPanel.vue' import GeneralExamPanel from './components/GeneralExamPanel.vue'
import PracticePanel from './components/PracticePanel.vue'
const guides = ref([]) const guides = ref([])
const backendTopics = ref([]) const backendTopics = ref([])
@@ -26,10 +27,11 @@ const activeBlocks = ref([])
const provider = ref(localStorage.getItem('provider') || 'claude') const provider = ref(localStorage.getItem('provider') || 'claude')
const providers = ref([]) const providers = ref([])
const folders = ref({ projekt: [], uni: [] }) // folders for the sources picker const folders = ref({ projekt: [], uni: [] }) // folders for the sources picker
const mainView = ref('blocks') // blocks | generation | general | detail — exclusive main-area view const mainView = ref('blocks') // blocks | generation | general | practice | detail — exclusive main-area view
const guideBoardFormat = ref('Guide') const guideBoardFormat = ref('Guide')
const viewMode = ref('compact') // compact | erklärend — per topic, default compact const viewMode = ref('compact') // compact | erklärend — per topic, default compact
const levelView = ref(Number(localStorage.getItem('level')) || 4) // 1=A · 2=F · 3=E · 4=V (levels view) const _storedLevel = localStorage.getItem('level')
const levelView = ref(_storedLevel === 'auto' || !_storedLevel ? 'auto' : Number(_storedLevel) || 'auto') // 'auto' | 1-4
const stats = ref(null) const stats = ref(null)
const progress = ref({}) const progress = ref({})
const locks = ref({}) // lock reasons per format (backend = single rule source) const locks = ref({}) // lock reasons per format (backend = single rule source)
@@ -326,6 +328,17 @@ function handleOpenGeneration() {
previewGuide.value = null previewGuide.value = null
} }
async function handleRemoveGuideFormat(format) {
uiError.value = null
try {
await apiRemoveGuideFormat(selectedTopic.value, format)
} catch (e) {
uiError.value = e.message
return
}
await loadGuides()
}
async function handleRestartCard(cardId) { async function handleRestartCard(cardId) {
uiError.value = null uiError.value = null
try { try {
@@ -370,8 +383,20 @@ function handleGeneralExam() {
previewGuide.value = null previewGuide.value = null
} }
function handlePractice() {
if (!selectedTopic.value) return
mainView.value = 'practice'
previewGuide.value = null
}
async function handleDeleteGuide(guideId, slots = false) { async function handleDeleteGuide(guideId, slots = false) {
uiError.value = null
try {
await deleteGuide(guideId, slots) await deleteGuide(guideId, slots)
} catch (e) {
uiError.value = e.message
return
}
if (previewGuide.value?.id === guideId) { if (previewGuide.value?.id === guideId) {
previewGuide.value = null previewGuide.value = null
} }
@@ -444,6 +469,7 @@ onMounted(async () => {
@setAnsicht="setView" @setAnsicht="setView"
@setStufe="setLevel" @setStufe="setLevel"
@generalExam="handleGeneralExam" @generalExam="handleGeneralExam"
@practice="handlePractice"
@select="selectTopic" @select="selectTopic"
@createThema="handleCreateTopic" @createThema="handleCreateTopic"
@updateSource="handleUpdateSource" @updateSource="handleUpdateSource"
@@ -488,7 +514,7 @@ onMounted(async () => {
@startGuide="handleFormatClick" @startGuide="handleFormatClick"
@resetGuideStage="handleGuideBoardReset" @resetGuideStage="handleGuideBoardReset"
@preview="handleGuideBoardPreview" @preview="handleGuideBoardPreview"
@deleteGuide="handleDeleteGuide" @removeFormat="handleRemoveGuideFormat"
@restartCard="handleRestartCard" @restartCard="handleRestartCard"
@resetGuideCard="handleResetGuideCard" @resetGuideCard="handleResetGuideCard"
/> />
@@ -499,6 +525,11 @@ onMounted(async () => {
@progressChanged="loadStats(); loadBlocks()" @progressChanged="loadStats(); loadBlocks()"
@fokus-active="focusOpen = $event" @fokus-active="focusOpen = $event"
/> />
<PracticePanel
v-else-if="selectedTopic && mainView === 'practice'"
:key="selectedTopic"
:topic="selectedTopic"
/>
<TopicDetail <TopicDetail
v-else-if="selectedTopic" v-else-if="selectedTopic"
:previewGuide="previewGuide" :previewGuide="previewGuide"

View File

@@ -87,6 +87,15 @@ export async function restartBlocksCard(topic, cardId) {
return jsonOrThrow(res) return jsonOrThrow(res)
} }
export async function removeGuideFormat(topic, format) {
const res = await fetch(`${BASE}/guides/board/remove`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, format }),
})
return jsonOrThrow(res)
}
export async function resetGuideCard(topic, format, blockNorm, abStage) { export async function resetGuideCard(topic, format, blockNorm, abStage) {
const res = await fetch(`${BASE}/guides/board/card-reset`, { const res = await fetch(`${BASE}/guides/board/card-reset`, {
method: 'POST', method: 'POST',
@@ -193,6 +202,11 @@ export async function updateSource(topic, { type, ort = '', spec = '' }) {
return jsonOrThrow(res) return jsonOrThrow(res)
} }
export async function fetchBlocksCompleteness(topic) {
const res = await fetch(`${BASE}/blocks/completeness?topic=${encodeURIComponent(topic)}`)
return jsonOrThrow(res)
}
export async function fetchBlocksOverview(topic) { export async function fetchBlocksOverview(topic) {
const res = await fetch(`${BASE}/blocks/overview?topic=${encodeURIComponent(topic)}`) const res = await fetch(`${BASE}/blocks/overview?topic=${encodeURIComponent(topic)}`)
return jsonOrThrow(res) return jsonOrThrow(res)
@@ -212,11 +226,19 @@ export async function fetchGuideContent(id, level = 4) {
return res.json() return res.json()
} }
// Lern-Artefakte (Flashcards/Examples/Diagramme) je Thema, gruppiert nach Block-Norm. // Übungspool: fällige + neue Flashcards des Themas (Leitner, ein Stapel).
export async function fetchArtefakte(topic) { export async function fetchPracticeDeck(topic) {
const res = await fetch(`${BASE}/blocks/artefakte?topic=${encodeURIComponent(topic)}`) const res = await fetch(`${BASE}/practice/deck?topic=${encodeURIComponent(topic)}`)
if (!res.ok) return { artefakte: {} } return jsonOrThrow(res)
return res.json() }
// Leitner-Schritt buchen (correct = „Gewusst").
export async function answerPracticeCard({ topic, block_norm, sub_norm, correct }) {
const res = await fetch(`${BASE}/practice/answer`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, block_norm, sub_norm, correct }),
})
return jsonOrThrow(res)
} }
// Einen Markdown-Block on-demand gegen die Guide-Rules prüfen (Fokus, Rechtsklick). // Einen Markdown-Block on-demand gegen die Guide-Rules prüfen (Fokus, Rechtsklick).

View File

@@ -1,7 +1,5 @@
<script setup> <script setup>
import BlockPanel from './BlockPanel.vue' import BlockPanel from './BlockPanel.vue'
import FlashcardWidget from './FlashcardWidget.vue'
import WorkedExampleBlock from './WorkedExampleBlock.vue'
import { renderMarkdown, renderBlocks } from '../markdown.js' import { renderMarkdown, renderBlocks } from '../markdown.js'
import { stufeFuer, LEVELS } from '../levels.js' import { stufeFuer, LEVELS } from '../levels.js'
import { pruefeBlock, uebernehmeBlock, resetBlockProgress } from '../api.js' import { pruefeBlock, uebernehmeBlock, resetBlockProgress } from '../api.js'
@@ -10,7 +8,6 @@ import { useConfirm } from '../composables/useConfirm.js'
const props = defineProps({ const props = defineProps({
block: { type: Object, required: true }, // { title, md, num } block: { type: Object, required: true }, // { title, md, num }
artefakte: { type: Object, default: null }, // { flashcard[], example[], diagramm } for this block
topic: { type: String, required: true }, topic: { type: String, required: true },
provider: { type: String, default: 'claude' }, provider: { type: String, default: 'claude' },
status: { type: Object, default: null }, status: { type: Object, default: null },
@@ -199,10 +196,6 @@ watch(() => `${props.block.title}|${props.block.md}|${props.block.compact || ''}
</div> </div>
</template> </template>
</div> </div>
<template v-if="artefakte">
<WorkedExampleBlock :examples="artefakte.example || []" />
<FlashcardWidget :cards="artefakte.flashcard || []" />
</template>
</div> </div>
<div v-if="menu.show" class="menu-overlay" @click="closeMenu" @contextmenu.prevent="closeMenu"> <div v-if="menu.show" class="menu-overlay" @click="closeMenu" @contextmenu.prevent="closeMenu">
<div class="block-menu" :style="{ top: menu.y + 'px', left: menu.x + 'px' }" @click.stop> <div class="block-menu" :style="{ top: menu.y + 'px', left: menu.x + 'px' }" @click.stop>

View File

@@ -1,6 +1,6 @@
<script setup> <script setup>
import { ref, computed, watch, onUnmounted } from 'vue' import { ref, computed, watch, onUnmounted } from 'vue'
import { fetchBlocksOverview } from '../api.js' import { fetchBlocksOverview, fetchBlocksCompleteness } from '../api.js'
const props = defineProps({ const props = defineProps({
topic: { type: String, required: true }, topic: { type: String, required: true },
@@ -14,6 +14,16 @@ const emit = defineEmits(['close', 'openGeneration'])
const items = ref([]) const items = ref([])
const loading = ref(true) const loading = ref(true)
const error = ref(null) const error = ref(null)
const comp = ref(null) // Vollständigkeits-Beleg (nur wenn ready)
const compOpen = ref(false)
async function loadCompleteness() {
if (!props.ready) { comp.value = null; return }
try {
comp.value = await fetchBlocksCompleteness(props.topic)
} catch { comp.value = null }
}
watch(() => [props.topic, props.ready, props.generating], loadCompleteness, { immediate: true })
// Während einer Generierung wächst das Grid live nach (leichter Overview-Poll, // Während einer Generierung wächst das Grid live nach (leichter Overview-Poll,
// das Kanban-Board selbst lebt in der Generierungs-View). // das Kanban-Board selbst lebt in der Generierungs-View).
@@ -77,6 +87,26 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
Noch keine Bausteine zur Generierung Noch keine Bausteine zur Generierung
</button> </button>
<section v-if="comp" class="bk-panel" :class="{ ok: comp.vollstaendig }">
<button class="bk-panel-row" @click="compOpen = !compOpen">
<span class="bk-panel-status">{{ comp.vollstaendig ? '✓ Zerlegung vollständig' : '○ Zerlegung unvollständig' }}</span>
<span class="bk-panel-stat">{{ comp.bloecke }} Blöcke</span>
<span class="bk-panel-stat">{{ comp.subs }} Subbausteine</span>
<span v-if="comp.ziele_total" class="bk-panel-stat">Lernziele {{ comp.ziele_covered }}/{{ comp.ziele_total }}</span>
<span class="bk-panel-stat">{{ comp.frage_bloecke }}/{{ comp.bloecke }} mit Prüfungsfragen</span>
<span class="bk-panel-stat">{{ comp.lernkarten }} Lernartefakte</span>
<span v-if="comp.dead" class="bk-panel-stat warn">{{ comp.dead }} dead</span>
<span class="bk-panel-toggle">{{ compOpen ? '▴' : '▾' }}</span>
</button>
<div v-if="compOpen" class="bk-panel-detail">
<span>{{ comp.verworfen }} Kandidaten geprüft verworfen</span>
<span>{{ comp.zusammengelegt }} zusammengelegt (Dubletten/Umbrellas)</span>
<span>{{ comp.degradiert_geprueft }} Fragmente degradiert (Panel-geprüft)</span>
<span v-if="comp.panel_gerettet">{{ comp.panel_gerettet }} vom Panel gerettet</span>
<span v-if="comp.lauf_minuten">Lauf: {{ comp.lauf_minuten }} min</span>
</div>
</section>
<div v-if="loading" class="bk-empty-state">Loading</div> <div v-if="loading" class="bk-empty-state">Loading</div>
<div v-else-if="error && !generating" class="bk-empty-state">{{ error }}</div> <div v-else-if="error && !generating" class="bk-empty-state">{{ error }}</div>
<div v-else-if="!items.length" class="bk-empty-state">No blocks yet.</div> <div v-else-if="!items.length" class="bk-empty-state">No blocks yet.</div>
@@ -176,6 +206,43 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
.bk-banner.idle { border-color: var(--border-strong); color: var(--text-muted); } .bk-banner.idle { border-color: var(--border-strong); color: var(--text-muted); }
.bk-banner:hover { background: var(--panel-soft); } .bk-banner:hover { background: var(--panel-soft); }
.bk-panel {
margin: 0.85rem 2rem 0;
border: 1px solid var(--border-strong);
border-radius: 8px;
background: var(--panel);
}
.bk-panel.ok { border-color: var(--level-beginner); }
.bk-panel-row {
width: 100%;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem 1.1rem;
padding: 0.55rem 0.9rem;
border: none;
background: none;
color: var(--text);
font-size: 0.82rem;
cursor: pointer;
text-align: left;
}
.bk-panel-status { font-weight: 700; }
.bk-panel.ok .bk-panel-status { color: var(--level-beginner); }
.bk-panel-stat { color: var(--text-muted); }
.bk-panel-stat.warn { color: var(--danger); font-weight: 600; }
.bk-panel-toggle { margin-left: auto; color: var(--text-faint); }
.bk-panel-detail {
display: flex;
flex-wrap: wrap;
gap: 0.3rem 1.1rem;
padding: 0 0.9rem 0.6rem;
font-size: 0.78rem;
color: var(--text-faint);
border-top: 1px dashed var(--border);
padding-top: 0.5rem;
}
.bk-empty-state { .bk-empty-state {
flex: 1; flex: 1;
display: flex; display: flex;

View File

@@ -1,79 +1,37 @@
<script setup> <script setup>
import { ref, computed, watch } from 'vue' import { ref, watch } from 'vue'
import { renderMarkdownInline } from '../markdown.js' import { renderMarkdownInline } from '../markdown.js'
const props = defineProps({ cards: { type: Array, default: () => [] } }) // Reine Flip-Karte: der Übungspool (PracticePanel) steuert Stapel und Leitner —
// die Karte zeigt nur Frage/Antwort und meldet die Bewertung nach oben.
const props = defineProps({ card: { type: Object, required: true } }) // { question, answer }
const emit = defineEmits(['answer']) // answer(correct: boolean)
const open = ref(false)
const order = ref([])
const pos = ref(0)
const flipped = ref(false) const flipped = ref(false)
watch(() => props.card, () => { flipped.value = false })
function reset() {
order.value = props.cards.map((_, i) => i)
pos.value = 0
flipped.value = false
}
watch(() => props.cards, reset, { immediate: true })
const current = computed(() => props.cards[order.value[pos.value]] || null)
const counter = computed(() => `${Math.min(pos.value + 1, order.value.length)} / ${order.value.length}`)
function next(known) {
if (known) {
pos.value++
} else {
// "Again" → push the card to the end of the round (light spacing).
const [k] = order.value.splice(pos.value, 1)
order.value.push(k)
}
if (pos.value >= order.value.length) pos.value = 0
flipped.value = false
}
</script> </script>
<template> <template>
<div v-if="cards.length" class="flashcards"> <div class="fc-body">
<button class="art-head" @click="open = !open">
<span class="art-icon">🃏</span> Flashcards
<span class="art-count">{{ cards.length }}</span>
<span class="art-toggle">{{ open ? '▾' : '▸' }}</span>
</button>
<div v-if="open && current" class="fc-body">
<div class="fc-card" :class="{ flipped }" @click="flipped = !flipped"> <div class="fc-card" :class="{ flipped }" @click="flipped = !flipped">
<div class="fc-zaehler">{{ counter }}</div>
<div v-if="!flipped" class="fc-seite"> <div v-if="!flipped" class="fc-seite">
<span class="fc-label">Question</span> <span class="fc-label">Frage</span>
<div class="fc-text" v-html="renderMarkdownInline(current.question)"></div> <div class="fc-text" v-html="renderMarkdownInline(card.question)"></div>
<span class="fc-hint">Click to flip</span> <span class="fc-hint">Klicken zum Umdrehen</span>
</div> </div>
<div v-else class="fc-seite"> <div v-else class="fc-seite">
<span class="fc-label">Answer</span> <span class="fc-label">Antwort</span>
<div class="fc-text" v-html="renderMarkdownInline(current.answer)"></div> <div class="fc-text" v-html="renderMarkdownInline(card.answer)"></div>
</div> </div>
</div> </div>
<div v-if="flipped" class="fc-aktionen"> <div v-if="flipped" class="fc-aktionen">
<button class="fc-btn nochmal" @click="next(false)">Again</button> <button class="fc-btn nochmal" @click="emit('answer', false)">Nochmal</button>
<button class="fc-btn gewusst" @click="next(true)">Knew it</button> <button class="fc-btn gewusst" @click="emit('answer', true)">Gewusst</button>
</div>
</div> </div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.flashcards { margin-top: 0.75rem; }
.art-head {
display: flex; align-items: center; gap: 8px; width: 100%;
background: none; border: none; cursor: pointer; padding: 0.35rem 0;
font-size: 0.82rem; font-weight: 600; color: var(--text-muted);
}
.art-icon { font-size: 0.95rem; }
.art-count {
background: var(--panel-soft); border: 1px solid var(--border);
border-radius: 999px; padding: 0 0.45rem; font-size: 0.72rem;
}
.art-toggle { margin-left: auto; color: var(--text-faint); }
.fc-body { margin-top: 0.5rem; } .fc-body { margin-top: 0.5rem; }
.fc-card { .fc-card {
position: relative; min-height: 120px; cursor: pointer; position: relative; min-height: 120px; cursor: pointer;
@@ -83,10 +41,6 @@ function next(known) {
transition: border-color 0.15s; transition: border-color 0.15s;
} }
.fc-card.flipped { border-color: var(--accent); } .fc-card.flipped { border-color: var(--accent); }
.fc-zaehler {
position: absolute; top: 6px; right: 10px;
font-size: 0.68rem; color: var(--text-faint);
}
.fc-seite { display: flex; flex-direction: column; gap: 0.4rem; align-items: center; } .fc-seite { display: flex; flex-direction: column; gap: 0.4rem; align-items: center; }
.fc-label { .fc-label {
font-size: 0.66rem; text-transform: uppercase; letter-spacing: 0.05em; font-size: 0.66rem; text-transform: uppercase; letter-spacing: 0.05em;

View File

@@ -13,7 +13,7 @@ const props = defineProps({
guideFormat: { type: String, default: 'Guide' }, guideFormat: { type: String, default: 'Guide' },
}) })
const emit = defineEmits(['close', 'resetStage', 'restartAll', 'continueAll', 'addResearch', const emit = defineEmits(['close', 'resetStage', 'restartAll', 'continueAll', 'addResearch',
'requeueDead', 'removeAll', 'cancel', 'cancelGuide', 'startGuide', 'resetGuideStage', 'preview', 'deleteGuide', 'restartCard', 'resetGuideCard']) 'requeueDead', 'removeAll', 'cancel', 'cancelGuide', 'startGuide', 'resetGuideStage', 'preview', 'removeFormat', 'restartCard', 'resetGuideCard'])
// ── Blocks-Pipeline (Poll 1.2s solange generiert) ────────────────────────────── // ── Blocks-Pipeline (Poll 1.2s solange generiert) ──────────────────────────────
const board = ref(null) const board = ref(null)
@@ -65,17 +65,22 @@ function restartCard() {
const k = selCard.value const k = selCard.value
selCard.value = null selCard.value = null
confirm.value = null confirm.value = null
emit('restartCard', k.card_id) later(() => emit('restartCard', k.card_id))
} }
function arm(action, fn) { function arm(action, fn) {
if (confirm.value === action) { confirm.value = null; fn() } if (confirm.value === action) { confirm.value = null; fn() }
else confirm.value = action else confirm.value = action
} }
function later(fn) { // Aktion emitten, Board kurz danach neu laden (kein generating-Poll aktiv)
fn()
setTimeout(pollBoard, 600)
}
function resetHere(restart) { function resetHere(restart) {
const s = sel.value const s = sel.value
sel.value = null sel.value = null
confirm.value = null confirm.value = null
emit('resetStage', { board: s.board, stage: s.key, restart }) later(() => emit('resetStage', { board: s.board, stage: s.key, restart }))
} }
</script> </script>
@@ -99,7 +104,7 @@ function resetHere(restart) {
v-if="ready || partial" v-if="ready || partial"
class="gen-act danger" class="gen-act danger"
:class="{ armed: confirm === 'remove' }" :class="{ armed: confirm === 'remove' }"
@click="arm('remove', () => emit('removeAll'))" @click="arm('remove', () => later(() => emit('removeAll')))"
>{{ confirm === 'remove' ? 'Sure?' : 'Remove' }}</button> >{{ confirm === 'remove' ? 'Sure?' : 'Remove' }}</button>
</div> </div>
<div v-else class="gen-actions"> <div v-else class="gen-actions">
@@ -110,7 +115,7 @@ function resetHere(restart) {
v-if="dead.length" v-if="dead.length"
class="gen-act" class="gen-act"
:title="dead.map((d) => d.title + ': ' + d.error).join('\n')" :title="dead.map((d) => d.title + ': ' + d.error).join('\n')"
@click="emit('requeueDead')" @click="later(() => emit('requeueDead'))"
> {{ dead.length }} dead</button> > {{ dead.length }} dead</button>
</div> </div>
@@ -158,7 +163,7 @@ function resetHere(restart) {
@startGuide="(p) => emit('startGuide', p)" @startGuide="(p) => emit('startGuide', p)"
@resetStage="(p) => emit('resetGuideStage', p)" @resetStage="(p) => emit('resetGuideStage', p)"
@preview="emit('preview')" @preview="emit('preview')"
@deleteGuide="(id) => emit('deleteGuide', id)" @removeFormat="(f) => emit('removeFormat', f)"
@resetCard="(p) => emit('resetGuideCard', p)" @resetCard="(p) => emit('resetGuideCard', p)"
/> />
</section> </section>

View File

@@ -89,7 +89,7 @@ function resetHere() {
<template v-else> <template v-else>
<button class="gb-act play" @click="emit('startGuide', { format, abStep: null }); startPoll()">{{ total && done < total ? 'Fortsetzen' : total ? 'Neu generieren' : 'Generieren' }}</button> <button class="gb-act play" @click="emit('startGuide', { format, abStep: null }); startPoll()">{{ total && done < total ? 'Fortsetzen' : total ? 'Neu generieren' : 'Generieren' }}</button>
<button v-if="done === total && total" class="gb-act" @click="emit('preview')">Guide öffnen</button> <button v-if="done === total && total" class="gb-act" @click="emit('preview')">Guide öffnen</button>
<button v-if="total" class="gb-act danger" :class="{ armed: confirm === 'delete' }" @click="arm('delete', () => emit('deleteGuide', board?.guide_id))">{{ confirm === 'delete' ? 'Sure?' : 'Remove' }}</button> <button v-if="total" class="gb-act danger" :class="{ armed: confirm === 'delete' }" @click="arm('delete', () => { emit('removeFormat', format); setTimeout(poll, 600) })">{{ confirm === 'delete' ? 'Sure?' : 'Remove' }}</button>
</template> </template>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,114 @@
<script setup>
// Übungspool: EIN Leitner-Stapel pro Thema (fällige Karten zuerst, dann neue).
// Der Server wählt und ordnet die Karten (Entscheidungs-Entlastung); „Nochmal"
// schiebt die Karte ans Rundenende, gebucht wird jede Antwort sofort.
import { ref, computed, onMounted } from 'vue'
import { fetchPracticeDeck, answerPracticeCard } from '../api.js'
import FlashcardWidget from './FlashcardWidget.vue'
const props = defineProps({
topic: { type: String, required: true },
})
const deck = ref([]) // Karten in Übungsreihenfolge
const counts = ref(null) // {due, new, new_total, gesperrt}
const nextDueAt = ref(null)
const loadError = ref(null)
const loading = ref(true)
const erledigt = ref(0)
const current = computed(() => deck.value[0] || null)
const offen = computed(() => deck.value.length)
async function loadDeck() {
loading.value = true
loadError.value = null
erledigt.value = 0
try {
const d = await fetchPracticeDeck(props.topic)
deck.value = d.cards || []
counts.value = d.counts || null
nextDueAt.value = d.next_due_at
} catch (e) {
loadError.value = e.message || 'Übungsstapel nicht ladbar.'
} finally {
loading.value = false
}
}
onMounted(loadDeck)
async function onAnswer(correct) {
const card = current.value
if (!card) return
try {
await answerPracticeCard({
topic: props.topic, block_norm: card.block_norm,
sub_norm: card.sub_norm, correct,
})
} catch { /* Buchung offline fehlgeschlagen — Durchgang läuft lokal weiter */ }
deck.value.shift()
if (correct) {
erledigt.value += 1
} else {
deck.value.push(card) // Rundenende: Box 1 ist sofort wieder fällig
}
}
function naechsteFaelligkeit() {
if (!nextDueAt.value) return null
const d = new Date(nextDueAt.value)
return d.toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short' })
}
</script>
<template>
<div class="pr-panel">
<div class="pr-head">
<h2>Üben</h2>
<span v-if="counts" class="pr-counts">
{{ counts.due }} fällig · {{ counts.new }} neu<template v-if="counts.new_total > counts.new"> (von {{ counts.new_total }})</template>
</span>
<span v-if="offen" class="pr-rest">{{ offen }} übrig</span>
</div>
<p v-if="loadError" class="pr-msg">{{ loadError }}</p>
<p v-else-if="loading" class="pr-msg">Lade Stapel</p>
<div v-else-if="current" class="pr-body">
<div class="pr-kontext">{{ current.block }} · {{ current.subblock }}</div>
<FlashcardWidget :card="current" @answer="onAnswer" />
</div>
<div v-else class="pr-done">
<p class="pr-done-title">Alles erledigt </p>
<p v-if="erledigt" class="pr-msg">{{ erledigt }} Karten in dieser Runde.</p>
<p v-if="nextDueAt" class="pr-msg">Nächste Karten fällig: {{ naechsteFaelligkeit() }}</p>
<button
v-if="counts && counts.new_total > counts.new"
class="pr-mehr" @click="loadDeck"
>Weitere neue Karten üben</button>
<p v-if="counts && counts.gesperrt" class="pr-msg pr-faint">
{{ counts.gesperrt }} Karten schalten sich über Block-Prüfungen frei.
</p>
</div>
</div>
</template>
<style scoped>
.pr-panel { padding: 16px 20px; max-width: 720px; margin: 0 auto; }
.pr-head { display: flex; align-items: center; gap: 14px; margin-bottom: 12px; }
.pr-head h2 { margin: 0; font-size: 1.1rem; }
.pr-counts { color: var(--text-muted); font-weight: 600; font-variant-numeric: tabular-nums; }
.pr-rest { margin-left: auto; color: var(--text-faint); font-size: 0.85rem; font-variant-numeric: tabular-nums; }
.pr-msg { color: var(--text-muted); }
.pr-faint { color: var(--text-faint); font-size: 0.85rem; }
.pr-kontext { margin-bottom: 6px; color: var(--text-muted); font-size: 0.88rem; }
.pr-done { text-align: center; padding: 2.5rem 0; }
.pr-done-title { font-size: 1.15rem; font-weight: 700; margin-bottom: 0.6rem; }
.pr-mehr {
margin-top: 0.6rem; padding: 7px 14px; border: 1px solid var(--border-strong);
border-radius: 8px; background: var(--bg); cursor: pointer; font-weight: 600;
}
.pr-mehr:hover { color: var(--accent-hover); }
</style>

View File

@@ -1,22 +1,11 @@
<script setup> <script setup>
import { computed, reactive, ref, watch, nextTick, onMounted, onUnmounted } from 'vue' import { computed, reactive, ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { fetchGuideContent, chatGuide, fetchBlockLearnState, fetchArtefakte } from '../api.js' import { fetchGuideContent, chatGuide, fetchBlockLearnState } from '../api.js'
import { renderMarkdown } from '../markdown.js' import { renderMarkdown } from '../markdown.js'
import { stufeFuer, schwelle } from '../levels.js' import { stufeFuer, schwelle, SUB_RANK, VIEW_KURZ, VIEW_FARBE, viewLevelFuer } from '../levels.js'
import { useChat } from '../composables/useChat.js' import { useChat } from '../composables/useChat.js'
import BlockPanel from './BlockPanel.vue' import BlockPanel from './BlockPanel.vue'
import BlockFocus from './BlockFocus.vue' import BlockFocus from './BlockFocus.vue'
import FlashcardWidget from './FlashcardWidget.vue'
import WorkedExampleBlock from './WorkedExampleBlock.vue'
// Title normalization like backend _norm_title (casefold ≈ toLowerCase + ß→ss) — for
// attaching the artifacts (keyed by block_norm) to the section titles.
function normTitle(s) {
return (s || '').normalize('NFKC')
.replace(/[`'"<>„“”‚’«»*_]/g, '').replace(/[–—‐]/g, '-')
.replace(/\s+/g, ' ').trim().replace(/^[.:;]+|[.:;]+$/g, '').trim()
.toLowerCase().replace(/ß/g, 'ss')
}
const props = defineProps({ const props = defineProps({
previewGuide: { type: Object, default: null }, previewGuide: { type: Object, default: null },
@@ -25,7 +14,7 @@ const props = defineProps({
doneByFormat: { type: Object, default: () => ({}) }, // format → finished guide (topic-related) doneByFormat: { type: Object, default: () => ({}) }, // format → finished guide (topic-related)
themaAbgeschlossen: { type: Boolean, default: false }, themaAbgeschlossen: { type: Boolean, default: false },
ansichtModus: { type: String, default: 'compact' }, // compact | erklärend ansichtModus: { type: String, default: 'compact' }, // compact | erklärend
stufeAnsicht: { type: Number, default: 4 }, // 1=A · 2=F · 3=E · 4=V stufeAnsicht: { type: [Number, String], default: 'auto' }, // 'auto' | 1=A · 2=F · 3=E · 4=V
}) })
const emit = defineEmits(['progressChanged', 'setAnsicht', 'openSidebar', 'fokusActive']) const emit = defineEmits(['progressChanged', 'setAnsicht', 'openSidebar', 'fokusActive'])
@@ -38,11 +27,6 @@ const content = ref(null)
const loadError = ref(null) const loadError = ref(null)
const scrollEl = ref(null) const scrollEl = ref(null)
const learnstate = ref({}) // exam state per block title — BEFORE the immediate watch (loadContent uses it) const learnstate = ref({}) // exam state per block title — BEFORE the immediate watch (loadContent uses it)
const artifacts = ref({}) // block_norm → {flashcard[], example[], diagramm}
function artifactsFor(title) {
return artifacts.value[normTitle(title)] || null
}
// --- Lazy render + markdown cache: parse only visible sections, each only once. // --- Lazy render + markdown cache: parse only visible sections, each only once.
// Fixes the freeze on open (160× marked/highlight.js) and re-parse on every update. --- // Fixes the freeze on open (160× marked/highlight.js) and re-parse on every update. ---
@@ -50,13 +34,37 @@ const mdCache = new Map() // `${mode}:${num}` → html
const visible = reactive({}) // num → true (stays true once ever visible) const visible = reactive({}) // num → true (stays true once ever visible)
let mdObserver = null let mdObserver = null
// Auto: Ansichtsstufe des Blocks folgt dem Prüfungs-Score (Erreicht + 1); Override = global fest.
function viewLevelFor(title) {
if (props.stufeAnsicht !== 'auto') return Number(props.stufeAnsicht)
const l = learnstate.value[title]
return viewLevelFuer(l?.good_answers || 0, l?.cap || 10)
}
function htmlFor(s) { function htmlFor(s) {
const key = `${props.ansichtModus}:${s.num}` const lvl = viewLevelFor(s.title)
const key = `${props.ansichtModus}:${s.num}:${lvl}:${props.stufeAnsicht}`
let h = mdCache.get(key) let h = mdCache.get(key)
if (h === undefined) { if (h !== undefined) return h
h = renderMarkdown(props.ansichtModus === 'compact' ? (s.compact || s.md) : s.md) const compact = props.ansichtModus === 'compact'
mdCache.set(key, h) if (!s.subs || !s.subs.length) { // Legacy-Abschnitt ohne Marker → ungefiltert
h = renderMarkdown(compact ? (s.compact || s.md) : s.md)
} else {
const anchor = compact ? (s.anker_compact || '') : (s.anchor || '')
const parts = [renderMarkdown(anchor)]
for (const sub of s.subs) {
const rank = SUB_RANK[sub.level] || 1
if (rank > lvl) continue
const body = renderMarkdown(compact ? (sub.compact || sub.md) : (sub.md || sub.compact))
if (props.stufeAnsicht === 'auto' && lvl > 1 && rank === lvl) {
parts.push(`<div class="sub-neu" style="--neu-farbe:${VIEW_FARBE[rank]}"><span class="sub-neu-badge" title="Neu ab Stufe ${VIEW_KURZ[rank]}">${VIEW_KURZ[rank]}</span>${body}</div>`)
} else {
parts.push(body)
} }
}
h = parts.join('')
}
mdCache.set(key, h)
return h return h
} }
@@ -79,7 +87,7 @@ onUnmounted(() => mdObserver?.disconnect())
watch(() => props.previewGuide?.id, loadContent, { immediate: true }) watch(() => props.previewGuide?.id, loadContent, { immediate: true })
// Level view (E/M/S/F) changed → reload guide content with the matching depth filter. // Level view (E/M/S/F) changed → reload guide content with the matching depth filter.
watch(() => props.stufeAnsicht, loadContent) watch(() => props.stufeAnsicht, () => mdCache.clear())
async function loadContent() { async function loadContent() {
content.value = null content.value = null
@@ -90,7 +98,7 @@ async function loadContent() {
const g = props.previewGuide const g = props.previewGuide
if (!g || g.status !== 'done') return if (!g || g.status !== 'done') return
try { try {
content.value = await fetchGuideContent(g.id, props.stufeAnsicht) content.value = await fetchGuideContent(g.id, 4) // Stufen-Filterung passiert lokal (auto/Override)
} catch (e) { } catch (e) {
console.error('Error loading guide:', e) console.error('Error loading guide:', e)
loadError.value = 'Content unavailable — the file is missing. Regenerate the guide (▶).' loadError.value = 'Content unavailable — the file is missing. Regenerate the guide (▶).'
@@ -99,9 +107,6 @@ async function loadContent() {
try { try {
learnstate.value = (await fetchBlockLearnState(g.topic)).blocks || {} learnstate.value = (await fetchBlockLearnState(g.topic)).blocks || {}
} catch { /* offline → empty */ } } catch { /* offline → empty */ }
try {
artifacts.value = (await fetchArtefakte(g.topic)).artefakte || {}
} catch { artifacts.value = {} }
// On open, scroll to the first not-yet-mastered checkable block. // On open, scroll to the first not-yet-mastered checkable block.
await nextTick() await nextTick()
const target = blocks.value.find((s) => isCheckable(s) && levelOf(s.title)?.key !== 'master') const target = blocks.value.find((s) => isCheckable(s) && levelOf(s.title)?.key !== 'master')
@@ -297,10 +302,6 @@ function extractContext() {
</h3> </h3>
<div v-if="visible[s.num]" class="section-body markdown" v-html="htmlFor(s)"></div> <div v-if="visible[s.num]" class="section-body markdown" v-html="htmlFor(s)"></div>
<div v-else class="section-body skeleton"></div> <div v-else class="section-body skeleton"></div>
<template v-if="visible[s.num] && artifactsFor(s.title)">
<WorkedExampleBlock :examples="artifactsFor(s.title).example || []" />
<FlashcardWidget :cards="artifactsFor(s.title).flashcard || []" />
</template>
<BlockPanel <BlockPanel
v-if="isCheckable(s)" v-if="isCheckable(s)"
mode="trigger" mode="trigger"
@@ -327,7 +328,6 @@ function extractContext() {
<BlockFocus <BlockFocus
v-if="focusBlock" v-if="focusBlock"
:block="focusBlock" :block="focusBlock"
:artefakte="artifactsFor(focusBlock.title)"
:topic="previewGuide.topic" :topic="previewGuide.topic"
:guide-id="previewGuide.id" :guide-id="previewGuide.id"
:provider="provider" :provider="provider"
@@ -766,4 +766,25 @@ function extractContext() {
.chat-input button.cancel { .chat-input button.cancel {
background: var(--danger); background: var(--danger);
} }
/* Neu freigeschaltete Subbausteine (Auto-Stufe): dezenter Rand + Stufen-Badge */
.sub-neu {
position: relative;
border-left: 3px solid var(--neu-farbe);
padding-left: 0.75rem;
margin: 0.5rem 0;
border-radius: 2px;
}
.sub-neu-badge {
position: absolute;
top: 0.1rem;
right: 0;
font-size: 0.62rem;
font-weight: 700;
color: var(--neu-farbe);
border: 1px solid var(--neu-farbe);
border-radius: 4px;
padding: 0 4px;
opacity: 0.8;
}
</style> </style>

View File

@@ -25,7 +25,7 @@ const props = defineProps({
stufeAnsicht: { type: Number, default: 4 }, // 1=A · 2=F · 3=E · 4=V stufeAnsicht: { type: Number, default: 4 }, // 1=A · 2=F · 3=E · 4=V
}) })
const emit = defineEmits(['select', 'createThema', 'updateSource', 'bausteineClick', 'deleteTopic', 'dismissError', 'dismissUiError', 'preview', 'openBausteineView', 'openGuideBoard', 'openGeneration', 'togglePin', 'sidebarLeave', 'toggleDark', 'setAnsicht', 'setStufe', 'generalExam', 'setProvider']) const emit = defineEmits(['select', 'createThema', 'updateSource', 'bausteineClick', 'deleteTopic', 'dismissError', 'dismissUiError', 'preview', 'openBausteineView', 'openGuideBoard', 'openGeneration', 'togglePin', 'sidebarLeave', 'toggleDark', 'setAnsicht', 'setStufe', 'generalExam', 'practice', 'setProvider'])
// Accordion: at most one panel open. IDs: 'blocks', 'fmt-<Format>', 'topic-<Name>'. // Accordion: at most one panel open. IDs: 'blocks', 'fmt-<Format>', 'topic-<Name>'.
const openPanel = ref(null) const openPanel = ref(null)
@@ -58,6 +58,7 @@ const formats = [
// Level views of the SINGLE guide (filtered by subblock depth). // Level views of the SINGLE guide (filtered by subblock depth).
const LEVEL_VIEWS = [ const LEVEL_VIEWS = [
{ k: 'auto', label: '⟳', title: 'Auto — Stufe folgt deinem Prüfungs-Score je Block' },
{ k: 1, label: 'A', title: 'Beginner' }, { k: 1, label: 'A', title: 'Beginner' },
{ k: 2, label: 'F', title: 'Beginner + Advanced' }, { k: 2, label: 'F', title: 'Beginner + Advanced' },
{ k: 3, label: 'E', title: 'up to Expert' }, { k: 3, label: 'E', title: 'up to Expert' },
@@ -337,6 +338,11 @@ function saveSource() {
<span class="format-label">General Exam</span> <span class="format-label">General Exam</span>
</button> </button>
</div> </div>
<div class="format-row ord-practice">
<button class="format-name elements-btn" @click="emit('practice')">
<span class="format-label">Üben</span>
</button>
</div>
</div> </div>
<ul class="topic-list"> <ul class="topic-list">
@@ -665,6 +671,10 @@ function saveSource() {
order: 4; order: 4;
} }
.ord-practice {
order: 5;
}
.elements-btn { .elements-btn {
cursor: pointer; cursor: pointer;
color: var(--text); color: var(--text);

View File

@@ -1,67 +0,0 @@
<script setup>
import { ref } from 'vue'
import { renderMarkdownInline } from '../markdown.js'
defineProps({ examples: { type: Array, default: () => [] } })
const open = ref(false)
</script>
<template>
<div v-if="examples.length" class="worked">
<button class="art-head" @click="open = !open">
<span class="art-icon">📝</span> Examples
<span class="art-count">{{ examples.length }}</span>
<span class="art-toggle">{{ open ? '▾' : '▸' }}</span>
</button>
<div v-if="open" class="we-body">
<div v-for="(b, i) in examples" :key="i" class="we-card">
<div v-if="b.subblock" class="we-sub">{{ b.subblock }}</div>
<div class="we-problem" v-html="renderMarkdownInline(b.problem)"></div>
<ol class="we-steps">
<li v-for="(s, j) in b.steps" :key="j" v-html="renderMarkdownInline(s)"></li>
</ol>
<div v-if="b.result" class="we-result">
<span class="we-label">Result</span>
<span v-html="renderMarkdownInline(b.result)"></span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.worked { margin-top: 0.5rem; }
.art-head {
display: flex; align-items: center; gap: 8px; width: 100%;
background: none; border: none; cursor: pointer; padding: 0.35rem 0;
font-size: 0.82rem; font-weight: 600; color: var(--text-muted);
}
.art-icon { font-size: 0.95rem; }
.art-count {
background: var(--panel-soft); border: 1px solid var(--border);
border-radius: 999px; padding: 0 0.45rem; font-size: 0.72rem;
}
.art-toggle { margin-left: auto; color: var(--text-faint); }
.we-body { margin-top: 0.4rem; display: flex; flex-direction: column; gap: 0.6rem; }
.we-card {
border: 1px solid var(--border); border-left: 3px solid var(--accent);
border-radius: 8px; padding: 0.7rem 0.9rem; background: var(--panel-soft);
font-size: 0.92rem; line-height: 1.5;
}
.we-sub {
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em;
color: var(--text-faint); font-weight: 700; margin-bottom: 0.3rem;
}
.we-problem { font-weight: 600; margin-bottom: 0.4rem; }
.we-steps { margin: 0 0 0.4rem 1.1rem; padding: 0; }
.we-steps li { margin: 0.2rem 0; }
.we-result {
display: flex; gap: 6px; align-items: baseline;
padding-top: 0.35rem; border-top: 1px dashed var(--border);
}
.we-label {
font-size: 0.66rem; text-transform: uppercase; letter-spacing: 0.05em;
color: var(--success); font-weight: 700;
}
</style>

View File

@@ -33,3 +33,19 @@ export function malusRegel(score, cap) {
if (pct <= 0.75) return '15' if (pct <= 0.75) return '15'
return '20' return '20'
} }
// Sub-level tag (from the guide markers) → view level 1..4 (A/F/E/V).
export const SUB_RANK = { beginner: 1, advanced: 2, expert: 3, peripheral: 4, einfach: 1, mittel: 2, schwer: 3 }
export const VIEW_KURZ = { 1: 'A', 2: 'F', 3: 'E', 4: 'V' }
export const VIEW_FARBE = {
1: 'var(--level-beginner)', 2: 'var(--level-advanced)',
3: 'var(--level-expert)', 4: 'var(--level-master)',
}
// Auto view level per block: reached learning level + 1 (nothing reached → A).
// beginner → F unlocked, advanced → E, expert/master → V.
export function viewLevelFuer(score, cap) {
const s = stufeFuer(score, cap)
if (!s) return 1
return Math.min(4, LEVELS.findIndex((l) => l.key === s.key) + 2)
}

View File

@@ -17,7 +17,7 @@ Rules:
- **Conservative:** object only to what is **clearly** wrong. When in doubt, keep it. - **Conservative:** object only to what is **clearly** wrong. When in doubt, keep it.
- Give the 1-based number (`index`) of each faulty example. - Give the 1-based number (`index`) of each faulty example.
Write ONLY the JSON file to: {out_path} — one of the two: Reply with ONLY the JSON — no other text, no code fences — one of the two:
{{"ok": true}} {{"ok": true}}
{{"problems": [{{"index": 2}}, {{"index": 5}}]}} {{"problems": [{{"index": 2}}, {{"index": 5}}]}}

View File

@@ -0,0 +1,29 @@
The FINAL block inventory for the topic "{topic}" was assembled from several sources. Despite earlier filtering it can still carry duplicates: the same concept listed under two names. For EACH pair below, decide: do A and B denote the SAME block → **ja**, or TWO DIFFERENT blocks → **nein**?
THE MOST COMMON ERROR is merging a named VARIANT with its base entity. A variant is NEVER its base: "3-X" ≠ "X", "Max-X" ≠ "Max-3-X", "Modified X" ≠ "X", "k-X" ≠ "X". A digit or qualifier prefix that restricts the entity makes it a DIFFERENT entity → **nein**.
PAIRS:
{pairs}
## How to decide (per pair)
**STEP 1 — Name the CANONICAL ENTITY of each side.** Strip catalogue/reference additions („Definition 6.19", „Satz 7.8", „Kapitel 3"), parenthesized qualifiers that only restate or explain the name („X (full spelling)", „X (Problem)", „X-Problem"), spelling/spacing/hyphenation variants, translations of the same name, and genitive/apostrophe variants. „X" and „X (long form of X)" share one canonical entity.
**STEP 2 — Same entity, or different?**
- **SAME canonical entity → ja**, even when A and B emphasize DIFFERENT FACETS: formal definition vs. property vs. mechanism vs. characterization vs. naming variant. Two blocks about the same entity from different sources are duplicates.
- **DIFFERENT canonical entity → nein**, however similar the wording:
- a named **variant, special case or modification** is its own entity („X" ≠ „Modified X", „3-X" ≠ „X");
- a **relation between two entities** (reduction, implication, comparison, mapping) is individuated by BOTH operands AND the direction. Same words, swapped direction → DIFFERENT. One shared operand, other operand differs → DIFFERENT. A relation is never a duplicate of one of its operands.
- a different parameter, restriction or scope is a different entity.
**STEP 3 — „When in doubt → nein"** applies only when STEP 1 is genuinely ambiguous. Differing descriptions of the same entity are still **ja**.
## Examples (example domain: graph theory — the rules hold for any topic)
- A: „SAT" B: „SAT (Satisfiability Problem)" → same entity, naming only → **ja**
- A: „P (Definition 6.20)" B: „P (Polynomialzeit)" → both = class **P**, definition vs. characterization → **ja**
- A: „Hamiltonian Cycle ≤ Hamiltonian Path" B: „Hamiltonian Path ≤ Hamiltonian Cycle" → same words, opposite direction → **nein**
- A: „Greedy-Algorithmus" B: „Modifizierter Greedy-Algorithmus" → base vs. named variant → **nein**
Write ONLY the JSON file to: {out_path}
Format (each pair number from the list with „ja" or „nein"; no other text in the file):
{{"pairs": {{"1": "ja", "2": "nein"}}}}

View File

@@ -0,0 +1,3 @@
SOURCE EXCERPTS — selected from the learning material. WORK EXCLUSIVELY WITH THESE EXCERPTS: do not search the web, do not read files. Cite from them (file name + heading/line if given). Whatever is not backable in the excerpts counts as NOT backed by the material.
{excerpts}

View File

@@ -6,7 +6,7 @@ FACTS TO CHECK (per subblock):
{facts} {facts}
Check per subblock: Check per subblock:
1. **Evidence fidelity**: Does each `cited_facts` entry appear that way in the source (accurate in substance)? Is the source citation correct? With a source file: check against the file. Without a source: is it established standard knowledge? 1. **Evidence fidelity**: Does each `cited_facts` entry appear that way in the source (accurate in substance)? Is the source citation correct? With source material (folder or excerpts): check against it. Without a source: is it established standard knowledge?
2. **Factual correctness**: Are formulas, values, definitions, signatures technically correct? A wrong formula/value is a defect. 2. **Factual correctness**: Are formulas, values, definitions, signatures technically correct? A wrong formula/value is a defect.
3. **Fact vs. example**: Is a self-computed/invented example wrongly declared as `cited_facts`? That is a defect — it belongs in `example_idea`. 3. **Fact vs. example**: Is a self-computed/invented example wrongly declared as `cited_facts`? That is a defect — it belongs in `example_idea`.
4. Do NOT check examples (`example_idea`) for source evidence — they are generative. 4. Do NOT check examples (`example_idea`) for source evidence — they are generative.
@@ -17,7 +17,7 @@ Note only REAL defects (wrong fact, wrong source, example disguised as a fact).
- `verwerfen: true` — the subblock is substantively **not backable**: an invented claim, a bound/formula/assertion not findable in the material, or simply technically wrong. The subblock is then REMOVED. Be sure — when in doubt, `false`. - `verwerfen: true` — the subblock is substantively **not backable**: an invented claim, a bound/formula/assertion not findable in the material, or simply technically wrong. The subblock is then REMOVED. Be sure — when in doubt, `false`.
- `verwerfen: false` — the core is right, only a fact/value/source is imprecise and correctable. - `verwerfen: false` — the core is right, only a fact/value/source is imprecise and correctable.
Write ONLY the JSON file to: {out_path} Reply with ONLY the JSON — no other text, no code fences.
Format — all in order: Format — all in order:
{{"ok": true}} {{"ok": true}}

View File

@@ -9,7 +9,7 @@ SECTION — current content (subblocks are marked with `<!-- sub: … -->`):
Procedure: Procedure:
1. For EACH objective decide binary: does the text teach it well enough that a beginner could achieve the objective afterwards? Mentioning a keyword is NOT teaching — the how/why must be there. 1. For EACH objective decide binary: does the text teach it well enough that a beginner could achieve the objective afterwards? Mentioning a keyword is NOT teaching — the how/why must be there.
2. For each NOT-covered objective state precisely WHAT is missing (German, concrete — the writer will patch exactly this). 2. For each NOT-covered objective state precisely WHAT is missing (German, concrete — the writer will patch exactly this).
3. List BALLAST: passages that serve none of the objectives (digressions, redundant repetition). Shortening candidates only — never a whole subblock. 3. List BALLAST: passages that serve none of the objectives (digressions, redundant repetition). Shortening candidates only — never a whole subblock. Worked-example passages that apply a concept belonging to an objective are teaching, not ballast.
4. Judge strictly binary per objective; no partial credit. 4. Judge strictly binary per objective; no partial credit.
Write ONLY the JSON file to: {out_path} Write ONLY the JSON file to: {out_path}

View File

@@ -8,9 +8,10 @@ VERIFIED FACTS — the ONLY allowed factual basis (extract-once from the source)
Procedure: Procedure:
1. Decompose the section text into its ATOMIC factual claims (definitions, properties, numbers, procedure steps, causal statements). Ignore pure didactics (transitions, framing, mnemonic phrasing). 1. Decompose the section text into its ATOMIC factual claims (definitions, properties, numbers, procedure steps, causal statements). Ignore pure didactics (transitions, framing, mnemonic phrasing).
2. Check EACH claim INDIVIDUALLY and BINARY against the VERIFIED FACTS above: derivable from them → belegt. Not derivable, contradicting, or over-specific beyond the facts → nicht belegt. 2. Worked-example passages (a concrete problem worked through in steps to a result) are DIDACTICS when they merely APPLY or ILLUSTRATE a verified fact or a provided worked example: their concretely chosen values and computed intermediates do NOT count as over-specific. Flag an example step ONLY if it contradicts the facts or smuggles in a NEW general claim not derivable from them.
3. Do NOT search the web, do NOT use outside knowledge — a claim that is true in the world but absent from the facts is still "nicht belegt". 3. Check EACH claim INDIVIDUALLY and BINARY against the VERIFIED FACTS above: derivable from them → belegt. Not derivable, contradicting, or over-specific beyond the facts nicht belegt.
4. When in doubt → nicht belegt (the guide may only teach verified material). 4. Do NOT search the web, do NOT use outside knowledge — a claim that is true in the world but absent from the facts is still "nicht belegt".
5. When in doubt → nicht belegt (the guide may only teach verified material).
Write ONLY the JSON file to: {out_path} Write ONLY the JSON file to: {out_path}

View File

@@ -9,6 +9,7 @@ LEARNING OBJECTIVES — after reading, a beginner must be able to do exactly thi
VERIFIED FACTS per subblock — binding basis. Quote cited facts (FACT[Source]) VERBATIM, invent nothing extra, do NOT re-research (a fact gate checks every claim against this list): VERIFIED FACTS per subblock — binding basis. Quote cited facts (FACT[Source]) VERBATIM, invent nothing extra, do NOT re-research (a fact gate checks every claim against this list):
{facts} {facts}
{examples}
{gaps} {gaps}
IMPORTANT — each subblock is delimited with a marker so the guide can later be shown in stages. The label comes EXACTLY from the assignment. Despite the marker, you write FLUENTLY and interwoven — the marker is an invisible interface, NOT a visible heading. IMPORTANT — each subblock is delimited with a marker so the guide can later be shown in stages. The label comes EXACTLY from the assignment. Despite the marker, you write FLUENTLY and interwoven — the marker is an invisible interface, NOT a visible heading.
@@ -25,6 +26,7 @@ HOW TO WRITE the detailed version — ONE coherent text for a junior who is lear
- The NUMBER of subblocks is the depth signal: many subblocks → the block deserves length; few → keep it short. Never pad. - The NUMBER of subblocks is the depth signal: many subblocks → the block deserves length; few → keep it short. Never pad.
- CONCISE: every sentence carries new information. No repetition, no filler, no preamble. When in doubt, leave it out. - CONCISE: every sentence carries new information. No repetition, no filler, no preamble. When in doubt, leave it out.
- Show "how" procedures step by step. An example ONLY where it genuinely carries the understanding. - Show "how" procedures step by step. An example ONLY where it genuinely carries the understanding.
- If VERIFIED WORKED EXAMPLES are provided above, weave each into its subblock as instructed there; do NOT invent additional worked examples for subblocks that already have one.
SECTION SPECIFICATION: SECTION SPECIFICATION:
{spec} {spec}

View File

@@ -14,7 +14,7 @@ Rules:
- **`advanced` is not a fallback choice.** Don't pick the middle because the votes are scattered — decide by the criterion "when do you need this?". Hit `beginner`/`expert` clearly where they apply. - **`advanced` is not a fallback choice.** Don't pick the middle because the votes are scattered — decide by the criterion "when do you need this?". Hit `beginner`/`expert` clearly where they apply.
- Exactly one level for EACH disputed number. - Exactly one level for EACH disputed number.
Write ONLY the JSON file to: {out_path} Reply with ONLY the JSON — no other text, no code fences.
Format (no other text): Format (no other text):
{{"levels": {{"1": "beginner", "4": "expert"}}}} {{"levels": {{"1": "beginner", "4": "expert"}}}}

View File

@@ -26,7 +26,7 @@ Don't flee to the middle:
- **Differentiate within the block:** What is foundation (beginner), what is build-up (advanced), what is fine detail (expert)? Not everything is the middle. - **Differentiate within the block:** What is foundation (beginner), what is build-up (advanced), what is fine detail (expert)? Not everything is the middle.
- Hit `beginner` and `expert` clearly where they apply — no bonus for the middle. - Hit `beginner` and `expert` clearly where they apply — no bonus for the middle.
Write ONLY the JSON file to: {out_path} Reply with ONLY the JSON — no other text, no code fences.
Format (exactly one level for EACH number; no other text): Format (exactly one level for EACH number; no other text):
{{"levels": {{"1": "beginner", "2": "advanced", "3": "expert"}}}} {{"levels": {{"1": "beginner", "2": "advanced", "3": "expert"}}}}

View File

@@ -14,7 +14,7 @@ Keep good patterns unchanged. Change only what genuinely violates the criteria.
Keep every question in GERMAN (the questions are for German-speaking learners), even though these instructions are in English. Keep every question in GERMAN (the questions are for German-speaking learners), even though these instructions are in English.
Write the cleaned-up final version of ALL blocks as ONE JSON to the file {out_path} (use your write tool), EXACTLY in this format: Reply with the cleaned-up final version of ALL blocks as ONE JSON — no other text, no code fences — EXACTLY in this format:
{{"pattern": [ {{"pattern": [
{{"block": "<exact block title>", "subblock": "<exact title>", "question": "<one concrete question>"}} {{"block": "<exact block title>", "subblock": "<exact title>", "question": "<one concrete question>"}}
]}} ]}}

View File

@@ -11,7 +11,7 @@ Rules:
- Weigh the votes and decide by the criterion **core vs. peripheral within ITS OWN block**. `peripheral` is a genuine category — mark peripheral items deliberately as such. Only true core concepts/central theorems are never `peripheral`. - Weigh the votes and decide by the criterion **core vs. peripheral within ITS OWN block**. `peripheral` is a genuine category — mark peripheral items deliberately as such. Only true core concepts/central theorems are never `peripheral`.
- Exactly one value for EACH disputed number. - Exactly one value for EACH disputed number.
Write ONLY the JSON file to: {out_path} Reply with ONLY the JSON — no other text, no code fences.
Format (no other text): Format (no other text):
{{"relevance": {{"1": "relevant", "4": "peripheral"}}}} {{"relevance": {{"1": "relevant", "4": "peripheral"}}}}

View File

@@ -15,7 +15,7 @@ Rules:
- Don't bump the level up out of caution. Only **true core concepts / central theorems** are never `peripheral`. - Don't bump the level up out of caution. Only **true core concepts / central theorems** are never `peripheral`.
- Only judge — invent nothing, change no subblocks. - Only judge — invent nothing, change no subblocks.
Write ONLY the JSON file to: {out_path} Reply with ONLY the JSON — no other text, no code fences.
Format (exactly one value for EACH number; no other text): Format (exactly one value for EACH number; no other text):
{{"relevance": {{"1": "relevant", "2": "peripheral", "3": "relevant"}}}} {{"relevance": {{"1": "relevant", "2": "peripheral", "3": "relevant"}}}}

View File

@@ -10,18 +10,18 @@ The groups:
- **Uncertain (1×):** named by only one finder — **scrutinize strictly**. Include an uncertain entry ONLY if it is **clearly backed by the source AND a standalone point**. When in doubt, leave it out. - **Uncertain (1×):** named by only one finder — **scrutinize strictly**. Include an uncertain entry ONLY if it is **clearly backed by the source AND a standalone point**. When in doubt, leave it out.
Rules: Rules:
- **Evidence check (important):** Check each subblock against the source. **Discard whatever is NOT backable in the material or clearly invented** — fabricated bounds, formulas, values, or claims not actually in the source. With a source file: check against the file. Without a source (pure topic): keep only established standard knowledge, drop the doubtful/false. - **Evidence check (important):** Check each subblock against the source. **Discard whatever is NOT backable in the material or clearly invented** — fabricated bounds, formulas, values, or claims not actually in the source. With source material (folder or excerpts): check against it. Without a source (pure topic): keep only established standard knowledge, drop the doubtful/false.
- **Merge duplicates:** Subblocks that state the same point in other words are ONE. Keep the clearest, drop the rephrasings. (e.g. „Broadcast Mode verfügbar" and „Broadcast-Modus aktivieren" → one.) - **Merge duplicates:** Subblocks that state the same point in other words are ONE. Keep the clearest, drop the rephrasings. (e.g. „Broadcast Mode verfügbar" and „Broadcast-Modus aktivieren" → one.)
- Keep all technically **DISTINCT** and backable sub-points in full — leave out nothing essential. - Keep all technically **DISTINCT** and backable sub-points in full — leave out nothing essential.
- Discard whatever is too fine-grained, at the edge of the topic, or technically doubtful. - Discard whatever is too fine-grained, at the edge of the topic, or technically doubtful.
- Each point atomic (one statement). Copy kept points VERBATIM, do not rephrase, invent nothing. - Each point atomic (one statement). Copy kept points VERBATIM, do not rephrase, invent nothing.
- The count follows the difficulty: better few distinct than many redundant points. - The count follows the difficulty: better few distinct than many redundant points.
Write ONLY the file {out_path} — one block marker per block (title EXACTLY as above), with the final subblock list below it: Reply with ONLY the final lists — no other text, no code fences. One block marker per block (title EXACTLY as above), with the final subblock list below it:
<!-- block: Exact block title --> <!-- block: Exact block title -->
- Subblock - Subblock
- Subblock - Subblock
Write the marker line exactly like this. Every block must appear. No text outside the blocks. Output the marker line exactly like this. Every block must appear. No text outside the blocks.
{extra} {extra}