From a284e03225bede2fe5568f937d18898af5fedb27 Mon Sep 17 00:00:00 2001 From: team3 Date: Sun, 12 Jul 2026 04:20:07 +0200 Subject: [PATCH] update --- Makefile | 4 +- backend/agents.py | 19 +- backend/config.py | 7 + backend/db.py | 12 +- backend/fake_agents.py | 39 +-- backend/guide.py | 206 +++++++++++++--- backend/inventar.py | 99 +++++++- backend/korpus.py | 95 ++++++-- backend/llm.py | 5 +- backend/qa.py | 7 +- backend/struktur.py | 329 +++++++++++++++++--------- backend/transfer.py | 13 + frontend/src/components/Guide.vue | 50 ++-- frontend/src/markdown.js | 50 +--- frontend/src/style.css | 8 +- templates/Artefakt-Verify.md | 2 + templates/Atom-Anker-Fix.md | 12 - templates/Atom-Extraktion-Aufgaben.md | 2 + templates/Atom-Extraktion.md | 3 + templates/Atom-Titel-Fix.md | 14 ++ templates/Guide-Writer.md | 10 +- templates/Kapitel-Intro.md | 15 +- templates/Kapitel-Schnitt.md | 13 +- templates/Korpus-Soll-Beleg.md | 15 +- templates/Level-Kalibrierung.md | 16 +- tests/test_bausteine.py | 58 ++++- tests/test_e2e.py | 16 +- tests/test_struktur.py | 69 +++++- tests/test_transfer.py | 7 + 29 files changed, 866 insertions(+), 329 deletions(-) delete mode 100644 templates/Atom-Anker-Fix.md create mode 100644 templates/Atom-Titel-Fix.md diff --git a/Makefile b/Makefile index df08abf..e92683b 100644 --- a/Makefile +++ b/Makefile @@ -18,10 +18,10 @@ stop: @echo "gestoppt." test: - cd backend && python -m pytest ../tests -x -q + cd backend && python3 -m pytest ../tests -x -q test-e2e: - cd backend && CREATOR_FAKE_AGENTS=1 python -m pytest ../tests/test_e2e.py -x -q + cd backend && CREATOR_FAKE_AGENTS=1 python3 -m pytest ../tests/test_e2e.py -x -q build: cd frontend && npm run build diff --git a/backend/agents.py b/backend/agents.py index 7660fbc..7bf9acd 100644 --- a/backend/agents.py +++ b/backend/agents.py @@ -82,11 +82,12 @@ async def _api_slot(key: str) -> None: class AgentErgebnis: - __slots__ = ("rc", "text", "err", "tokens", "wait_s") + __slots__ = ("rc", "text", "err", "tokens", "wait_s", "model") def __init__(self, rc: int, text: str, err: str, tokens: dict | None = None): self.rc, self.text, self.err, self.tokens = rc, text, err, tokens or {} self.wait_s = 0.0 # Queue-Zeit (Semaphore/Drossel) — run_agent füllt sie + self.model = "" # aufgelöstes Modell — run_agent füllt es (Ledger) @property def ok(self) -> bool: @@ -161,12 +162,13 @@ async def run_agent(key: str, prompt: str, timeout: int, *, provider: str, await _api_slot(key) wait_s = time.time() - warte_start try: - res = await _text_api(key, prompt, timeout, model) + res = await _text_api(key, prompt, timeout, model, role) finally: _api_inflight -= 1 if res.rc == 0: _erfolg_melden() res.wait_s = wait_s + res.model = model return res await _drossel_warten(key) wait_s = time.time() - warte_start @@ -175,6 +177,7 @@ async def run_agent(key: str, prompt: str, timeout: int, *, provider: str, else: res = await _claude_cli(key, prompt, timeout, model, capabilities) res.wait_s = wait_s + res.model = model return res finally: _active.pop(key, None) @@ -335,12 +338,20 @@ _API_MODEL_OPTS = { # Antwort nach bis zu 44 min — Extraktion war 53 von 63 min der Ebene). "minimax-kalt/MiniMax-M2.7-highspeed": {"temperature": 0.3, "thinking": {"type": "disabled"}}, + # native Route (judge/guide): ungedrosselt liefen Merge-Judges ins Output-Cap + # (58–71 % Parse-Fehler). role=guide (Writer) behält Thinking — _text_api + # nimmt die thinking-Option dort wieder raus. + "minimax/MiniMax-M3": {"temperature": 0.2, "thinking": {"type": "disabled"}}, } -async def _text_api(key: str, prompt: str, timeout: int, model: str) -> AgentErgebnis: +async def _text_api(key: str, prompt: str, timeout: int, model: str, + role: str = "judge") -> AgentErgebnis: + opts = dict(_API_MODEL_OPTS.get(model, {})) + if role == "guide": + opts.pop("thinking", None) # Writer braucht Denkraum — nur Judges kalt body = {"model": model.split("/", 1)[1], "max_tokens": _API_MAX_TOKENS, - "messages": [{"role": "user", "content": prompt}], **_API_MODEL_OPTS.get(model, {})} + "messages": [{"role": "user", "content": prompt}], **opts} headers = {"x-api-key": os.environ.get("MINIMAX_API_KEY", ""), "anthropic-version": "2023-06-01"} try: async with httpx.AsyncClient(timeout=httpx.Timeout(timeout, connect=30)) as client: diff --git a/backend/config.py b/backend/config.py index 6fd7f12..ea92bd9 100644 --- a/backend/config.py +++ b/backend/config.py @@ -74,6 +74,13 @@ ARTEFAKT_CHUNK_ATOME = 8 # Atome pro Generate-Call (ein Soll-Punkt, gestück VERIFY_PANEL = 2 # unabhängige Prüfer, einstimmig = verifiziert # ── Ebene 3: Struktur ───────────────────────────────────────────────────────── +LEVEL_RANG = {"E": 0, "M": 1, "S": 2} # Durchgänge: Grundgerüst / Standard / Feinheiten +DURCHGANG = { # Leser-Kontext je Durchgang (Templates: {durchgang}) + "E": "Grundgerüst, erster Durchgang — der Leser kennt noch nichts," + " alles voraussetzungsfrei erklären", + "M": "Standardstoff, zweiter Durchgang — der Leser kennt das Grundgerüst", + "S": "Feinheiten, dritter Durchgang — der Leser kennt Grundgerüst und Standardstoff", +} BAUSTEIN_MIN_ATOME = 4 BAUSTEIN_MAX_ATOME = 8 ZIELE_CHUNK_ATOME = 30 # Atome pro Lernziel-Call diff --git a/backend/db.py b/backend/db.py index 964d0c2..77c21f2 100644 --- a/backend/db.py +++ b/backend/db.py @@ -66,7 +66,7 @@ CREATE TABLE IF NOT EXISTS quellen( CREATE TABLE IF NOT EXISTS soll( id INTEGER PRIMARY KEY, topic TEXT NOT NULL, punkt TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'kandidat', belege TEXT NOT NULL DEFAULT '[]', - intro TEXT NOT NULL DEFAULT ''); + intro TEXT NOT NULL DEFAULT '', geprueft TEXT NOT NULL DEFAULT '[]'); CREATE TABLE IF NOT EXISTS atome( id INTEGER PRIMARY KEY, topic TEXT NOT NULL, titel TEXT NOT NULL, typ TEXT NOT NULL DEFAULT '', definition TEXT NOT NULL DEFAULT '', level TEXT NOT NULL DEFAULT 'M', @@ -90,11 +90,12 @@ CREATE TABLE IF NOT EXISTS lernziele( titel TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'neu'); CREATE TABLE IF NOT EXISTS bausteine( id INTEGER PRIMARY KEY, topic TEXT NOT NULL, ziel_id INTEGER NOT NULL, titel TEXT NOT NULL, - ord INTEGER DEFAULT 0, status TEXT NOT NULL DEFAULT 'neu', kapitel_id INTEGER); + ord INTEGER DEFAULT 0, status TEXT NOT NULL DEFAULT 'neu', kapitel_id INTEGER, + level TEXT NOT NULL DEFAULT 'M'); CREATE TABLE IF NOT EXISTS kapitel( id INTEGER PRIMARY KEY, topic TEXT NOT NULL, titel TEXT NOT NULL, intro TEXT NOT NULL DEFAULT '', ord INTEGER DEFAULT 0, - art TEXT NOT NULL DEFAULT 'judge'); + art TEXT NOT NULL DEFAULT 'judge', level TEXT NOT NULL DEFAULT 'M'); CREATE TABLE IF NOT EXISTS sections( baustein_id INTEGER PRIMARY KEY, stage TEXT NOT NULL DEFAULT 'writer', text_kompakt TEXT DEFAULT '', text_lang TEXT DEFAULT '', befunde TEXT DEFAULT '[]', @@ -122,7 +123,10 @@ def _init_schema(con: sqlite3.Connection) -> None: "ALTER TABLE topics ADD COLUMN auto TEXT NOT NULL DEFAULT '{}'", "ALTER TABLE topics ADD COLUMN resets TEXT NOT NULL DEFAULT '{}'", "ALTER TABLE bausteine ADD COLUMN kapitel_id INTEGER", - "ALTER TABLE kapitel ADD COLUMN art TEXT NOT NULL DEFAULT 'judge'"): + "ALTER TABLE kapitel ADD COLUMN art TEXT NOT NULL DEFAULT 'judge'", + "ALTER TABLE bausteine ADD COLUMN level TEXT NOT NULL DEFAULT 'M'", + "ALTER TABLE kapitel ADD COLUMN level TEXT NOT NULL DEFAULT 'M'", + "ALTER TABLE soll ADD COLUMN geprueft TEXT NOT NULL DEFAULT '[]'"): try: con.execute(zusatz) except sqlite3.OperationalError: diff --git a/backend/fake_agents.py b/backend/fake_agents.py index c5f84ca..0b5765f 100644 --- a/backend/fake_agents.py +++ b/backend/fake_agents.py @@ -75,10 +75,13 @@ def _soll_konsens(prompt: str): def _soll_beleg(prompt: str): text = _text_nach(prompt, "QUELLTEXT:") - for _, saetze in PUNKTE: - if saetze[0] in text and saetze[0] in prompt: - return {"zitat": saetze[0]} - return {"zitat": ""} + zeilen = re.findall(r"^(\d+): (.+)$", _text_nach(prompt, "SOLL-PUNKTE:"), re.MULTILINE) + out = [] + for i, punkt in zeilen: + for p, saetze in PUNKTE: + if p == punkt.strip() and saetze[0] in text: + out.append({"soll": int(i), "zitat": saetze[0]}) + return out def _extraktion(prompt: str): @@ -116,14 +119,6 @@ def _soll_stich(prompt: str): return {"soll": next(iter(punkte.values()), "fremd")} -def _anker_fix(prompt: str): - text = _text_nach(prompt, "QUELLTEXT:") - for a in ATOME: - if a["titel"] in prompt and a["zitat"] in text: - return {"zitat": a["zitat"]} - return {"zitat": ""} - - def _anker_fix_batch(prompt: str): text = _text_nach(prompt, "QUELLTEXT:") zeilen = re.findall(r"^(\d+): (.+?) —", _text_nach(prompt, "ATOME:"), re.MULTILINE) @@ -176,9 +171,16 @@ def _kapitel_intro(prompt: str): def _level_kalibrierung(prompt: str): - ids = _ids(_text_nach(prompt, "ATOME:")) - # deterministisch: erstes Atom wird Grundgerüst, Rest Standardstoff - return [{"atom": i, "level": "E" if n == 0 else "M"} for n, i in enumerate(ids)] + # deklariertes Level der Fake-Welt zurückgeben (Titel-Match) — der E2E-Pfad + # erzeugt so echte E/M/S-Durchgänge; Unbekanntes wird Standardstoff + zeilen = re.findall(r"^(\d+): (.+?) —", _text_nach(prompt, "ATOME:"), re.MULTILINE) + je_titel = {a["titel"]: a["level"] for a in ATOME} + return [{"atom": int(i), "level": je_titel.get(t.strip(), "M")} for i, t in zeilen] + + +def _titel_fix(prompt: str): + zeilen = re.findall(r"^(\d+): ", _text_nach(prompt, "ATOME:"), re.MULTILINE) + return [{"atom": int(i), "titel": f"Konzept {i}"} for i in zeilen] def _kapitel_schnitt(prompt: str): @@ -196,7 +198,7 @@ def _zyklus(prompt: str): def _writer(prompt: str): ziel = re.search(r"Lernziel: (.+)", prompt) - marker = re.findall(r"MARKER \(exakt so übernehmen\): ()", + marker = re.findall(r"MARKER \(exakt so übernehmen\): ()", prompt) defs = re.findall(r"Definition: (.+)", prompt) fragen = re.findall(r"^- (.+\?)$", prompt, re.MULTILINE)[:2] @@ -240,8 +242,9 @@ _HANDLER = { "Atom-Extraktion": _extraktion, "Atom-Extraktion-Aufgaben": _extraktion, "Atom-Merge": _merge, "Atom-Soll-Zuordnung": _soll_zuordnung, "Atom-Soll-Stich": _soll_stich, - "Atom-Anker-Fix": _anker_fix, "Atom-Anker-Fix-Batch": _anker_fix_batch, - "Atom-Luecke": _luecke, "Soll-Abgedeckt": lambda p: {"abgedeckt": True}, + "Atom-Anker-Fix-Batch": _anker_fix_batch, + "Atom-Luecke": _luecke, "Atom-Titel-Fix": _titel_fix, + "Soll-Abgedeckt": lambda p: {"abgedeckt": True}, "Artefakt-Generate": _artefakt_generate, "Artefakt-Verify": _artefakt_verify, "Artefakt-Fix": _artefakt_fix, "Lernziele": _lernziele, "Kapitel-Intro": _kapitel_intro, "Kapitel-Schnitt": _kapitel_schnitt, "Level-Kalibrierung": _level_kalibrierung, diff --git a/backend/guide.py b/backend/guide.py index 05c2fc2..0e7aa63 100644 --- a/backend/guide.py +++ b/backend/guide.py @@ -17,7 +17,7 @@ from pathlib import Path import db import llm import textkit -from config import SECTION_WOERTER_PRO_ATOM +from config import DURCHGANG, LEVEL_RANG, SECTION_WOERTER_PRO_ATOM log = logging.getLogger("creator2.guide") @@ -50,10 +50,11 @@ def _katex_fehler(formeln: list[dict]) -> list[str]: EBENE = "guide" _MARKER = re.compile(r"") # ganze Marker-Zeile (für Text-Checks) # Kritische Befund-Arten routen zurück auf den Prüfer, Stil-Arten nur in den Fix # (vorwaerts ist Stil: ein Halbsatz-Fix, kein Fakten-Problem) -KRITISCH = ("marker_fehlend", "ziel_ohne_anker", "fachlich_falsch") +KRITISCH = ("marker_fehlend", "marker_fremd", "ziel_ohne_anker", "fachlich_falsch") def _bausteine(topic: str) -> list[dict]: @@ -86,7 +87,7 @@ def _fragen_pool(atom_id: int, max_n: int = 2) -> list[str]: def _atom_paket(a: dict) -> str: zitate = "\n".join(f"> {z}" for z in _zitate(a["id"])) or "(kein Zitat)" beispiel = _beispiel(a["id"]) - teil = (f"MARKER (exakt so übernehmen): \n" + teil = (f"MARKER (exakt so übernehmen): \n" f"Definition: {a['definition']}\nBelege (VERBATIM zitierbar):\n{zitate}") if beispiel: teil += f"\nVerifiziertes Beispiel (Werte wörtlich übernehmen):\n{beispiel}" @@ -101,16 +102,40 @@ def _laenge_band(n_atome: int) -> tuple[int, int]: return lo * n_atome, hi * n_atome +def _anknuepf_kontext(b: dict) -> str: + """Was der Leser aus früheren Durchgängen schon kennt: tieferstufige Atome + desselben Ziels plus direkte braucht-Voraussetzungen. Der Writer knüpft an, + statt neu zu erklären (Dopplungs-Schutz zwischen den Durchgängen).""" + rang = LEVEL_RANG.get(b["level"], 1) + atome = _atome_von(b["id"]) + ids = {a["id"] for a in atome} + kontext: dict[int, dict] = {} + for a in db.query("SELECT * FROM atome WHERE ziel_id=? AND status NOT IN" + " ('gemerged','verworfen')", (b["ziel_id"],)): + if LEVEL_RANG.get(a["level"], 1) < rang: + kontext[a["id"]] = a + if ids: + marks = ",".join("?" * len(ids)) + for a in db.query(f"SELECT a.* FROM kanten k JOIN atome a ON a.id=k.zu_atom" + f" WHERE k.art='braucht' AND k.status='aktiv'" + f" AND k.von_atom IN ({marks})", tuple(ids)): + if a["id"] not in ids and LEVEL_RANG.get(a["level"], 1) < rang: + kontext[a["id"]] = a + return "\n".join(f"- {a['titel']}: {a['definition']}" for a in kontext.values()) + + # ── Stages ──────────────────────────────────────────────────────────────────── async def _stage_writer(ctx: llm.Kontext, b: dict) -> str: atome = _atome_von(b["id"]) ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],)) or {"text": b["titel"]} lo, hi = _laenge_band(len(atome)) - res = await llm.call(ctx, stage="writer", template="Guide-Writer", - werte={"titel": b["titel"], "ziel": ziel["text"], - "atome": "\n\n".join(_atom_paket(a) for a in atome), - "min_woerter": lo, "max_woerter": hi}, + werte = {"titel": b["titel"], "ziel": ziel["text"], + "atome": "\n\n".join(_atom_paket(a) for a in atome), + "min_woerter": lo, "max_woerter": hi, + "durchgang": DURCHGANG.get(b["level"], DURCHGANG["M"]), + "kontext": _anknuepf_kontext(b) or "(nichts — erster Durchgang)"} + res = await llm.call(ctx, stage="writer", template="Guide-Writer", werte=werte, role="guide", n=len(atome), item=f"b{b['id']}", erwartet=dict) if res is None: return "writer" # Stage bleibt, nächster Lauf versucht erneut @@ -119,11 +144,7 @@ async def _stage_writer(ctx: llm.Kontext, b: dict) -> str: if fehlend: # ein gezielter Zweitversuch mit explizitem Mangel res2 = await llm.call(ctx, stage="writer2", template="Guide-Writer", schritt="writer", role="guide", n=len(atome), - item=f"b{b['id']}-2", - werte={"titel": b["titel"], "ziel": ziel["text"], - "atome": "\n\n".join(_atom_paket(a) for a in atome), - "min_woerter": lo, "max_woerter": hi}, - erwartet=dict) + item=f"b{b['id']}-2", werte=werte, erwartet=dict) if res2 and not _marker_fehlend(str(res2.get("lang", "")), atome): kompakt, lang = str(res2.get("kompakt", "")), str(res2.get("lang", "")) db.update("sections", "baustein_id", b["id"], text_kompakt=kompakt, text_lang=lang) @@ -135,6 +156,46 @@ def _marker_fehlend(text: str, atome: list[dict]) -> list[int]: return [a["id"] for a in atome if a["id"] not in da] +def _marker_fremd_ids(text: str, atome: list[dict]) -> list[int]: + """Marker fremder Atome (z. B. aus dem Anknüpf-Kontext übernommen).""" + eigene = {a["id"] for a in atome} + return sorted({int(m) for m in _MARKER.findall(text)} - eigene) + + +_FLOSKEL = re.compile(r"später (mehr|noch (ausführlicher|genauer|vertieft))" + r"|späteren (Kapitel|Abschnitt|Durchgang)", re.IGNORECASE) +_LEAK = re.compile(r"\b(Satz|Lemma|Korollar|Bemerkung|Transformation|Definition)" + r"\s+[IVX]*\d+\.\d+|\bZeigen Sie\b") +_EN_STOP = frozenset("the and of is that this are with for as by be from which were" + " has have not can will or an it its when each such".split()) + + +def _stil_auftraege(text: str, wo: str) -> list[str]: + """Deterministische Lesbarkeits-Checks je Fassung: Vertröstungs-Floskeln + (der alte Vorwärts-Fix klebte „dazu später mehr“ ein statt zu erklären), + Quellen-Referenzen (Satz 6.25 — der Leser hat kein nummeriertes Skript) + und unübersetzte englische Passagen (Lernen: 13 Passagen aus EN-Quellen). + Marker-Zeilen sind Schnittstelle, kein Text — vorher strippen, sonst + erzeugen Atom-TITEL wie „Satz 6.26“ unfixierbare Aufträge (Token-Loop).""" + text = _MARKER_VOLL.sub("", text) + auftraege = [] + for m in sorted({m.group(0) for m in _FLOSKEL.finditer(text)}): + auftraege.append(f"{wo}: Vertröstungs-Floskel („{m}“) streichen — den Begriff" + f" stattdessen kurz mit Bekanntem einordnen oder die Stelle" + f" entfernen.") + for m in sorted({m.group(0) for m in _LEAK.finditer(text)}): + auftraege.append(f"{wo}: Quellen-Referenz „{m}“ — der Leser kennt kein" + f" nummeriertes Skript und keine Aufgabenstellung; Inhalt" + f" eigenständig formulieren.") + for absatz in text.split("\n\n"): + toks = re.findall(r"[a-zA-Z']+", absatz.lower()) + if len(toks) >= 12 and sum(t in _EN_STOP for t in toks) / len(toks) > 0.15: + kurz = " ".join(absatz.split())[:60] + auftraege.append(f"{wo}: englische Passage („{kurz}…“) ins Deutsche" + f" übersetzen — etablierte Fachtermini dürfen bleiben.") + return auftraege + + def _mathe_auftraege(text: str, wo: str) -> list[str]: """Deterministische Mathe-Hygiene je Fassung: $-Parität, nackte LaTeX-Befehle, Klartext-Formelreste (x_i, u_{3m}, 2^(…) — aak Lauf 19: 8 Kompakt-/6 @@ -182,21 +243,30 @@ def _mathe_auftraege(text: str, wo: str) -> list[str]: return auftraege -def _det_auftraege(topic: str, b: dict, lang: str, kompakt: str = "") -> list[str]: - """Deterministische Checks vor dem Judge: Marker, Länge, Vorwärtsverweise.""" +def _det_auftraege(topic: str, b: dict, lang: str, kompakt: str = "", + qa: bool = False) -> list[str]: + """Deterministische Checks vor dem Judge: Marker, Länge, Vorwärtsverweise, + Lesbarkeit. qa=True (Ebenen-QA): Marker/Länge/Vorwärts haben dort eigene + Befund-Arten und ein weiteres Band (Lektion 75) — nur die Stil-Checks.""" atome = _atome_von(b["id"]) - auftraege = [f"KRITISCH: Marker für Atom {i} fehlt — exakt einfügen." - for i in _marker_fehlend(lang, atome)] - lo, hi = _laenge_band(len(atome)) - woerter = len(lang.split()) - if woerter > hi: - auftraege.append(f"Kürzen auf höchstens {hi} Wörter (aktuell {woerter}).") - elif woerter < lo: - auftraege.append(f"Ausbauen auf mindestens {lo} Wörter (aktuell {woerter}).") - auftraege += [f"Vorwärtsverweis „{t}“ (kommt erst in einem späteren Kapitel): beim" - f" ersten Auftreten mit einem Halbsatz einordnen („… dazu später mehr“)" - f" oder entfernen — nie unerklärt verwenden." - for t in _vorwaertsverweise(topic, b, lang)] + auftraege = [] + if not qa: + auftraege += [f"KRITISCH: Marker für Atom {i} fehlt — exakt einfügen." + for i in _marker_fehlend(lang, atome)] + auftraege += [f"KRITISCH: Marker {i} gehört nicht zu diesem Baustein" + f" (Kontext-Atom?) — Marker-Zeile ersatzlos entfernen." + for i in _marker_fremd_ids(lang, atome)] + lo, hi = _laenge_band(len(atome)) + woerter = len(lang.split()) + if woerter > hi: + auftraege.append(f"Kürzen auf höchstens {hi} Wörter (aktuell {woerter}).") + elif woerter < lo: + auftraege.append(f"Ausbauen auf mindestens {lo} Wörter (aktuell {woerter}).") + auftraege += [f"Vorwärtsverweis „{t}“ (kommt erst in einem späteren Kapitel):" + f" beim ersten Auftreten mit bereits eingeführten Begriffen kurz" + f" erklären oder die Verwendung entfernen — nie unerklärt lassen," + f" NICHT vertrösten." + for t in _vorwaertsverweise(topic, b, lang)] zitate = sum(1 for z in lang.splitlines() if z.lstrip().startswith(">")) if zitate: # Lehren statt Abschreiben: Rohzitate gehören nicht in den Lehrtext auftraege.append(f"{zitate} Blockquote-Zeile(n) („>“) im Text: Inhalt in eigenen" @@ -204,21 +274,49 @@ def _det_auftraege(topic: str, b: dict, lang: str, kompakt: str = "") -> list[st if "6=" in lang: auftraege.append("PDF-Artefakt „6=“ im Text: gemeint ist Ungleichheit —" " durch $\\neq$ ersetzen.") + for nr, absatz in enumerate(lang.split("\n\n"), 1): + # Marker-Zeilen zählen nicht — sie stehen im selben Block wie der Absatz + zeilen = [z for z in absatz.splitlines() + if z.strip() and not z.strip().startswith("/g +// Atom-Marker sind eine unsichtbare Backend-Schnittstelle — vor der Anzeige +// ausblenden. Die Stufen-Trennung passiert seit dem Umbau über getrennte +// Bausteine je Level, nicht mehr über Marker-Filterung im Text. +const MARKER = //g // Wortzahl + Display-Formeln des sichtbaren Texts (für die Lesezeit-Schätzung) export function lesestat(text) { @@ -61,47 +63,3 @@ export function lesestat(text) { const woerter = t.split(/\s+/).filter(Boolean).length return { woerter, displayFormeln } } - -// Text an Atom-Markern in Segmente schneiden; Filter zeigt Atome bis zum gewählten Level. -export function segmente(lang) { - const out = [] - let letzt = 0 - let aktuell = { level: null, titel: '', text: '' } - for (const m of lang.matchAll(MARKER)) { - aktuell.text = lang.slice(letzt, m.index) - out.push(aktuell) - aktuell = { level: m[3], titel: m[2].trim(), text: '' } - letzt = m.index + m[0].length - } - aktuell.text = lang.slice(letzt) - out.push(aktuell) - return out -} - -export function gefiltert(lang, maxLevel) { - if (!maxLevel || maxLevel === 'S') return lang - return segmente(lang) - .filter((s) => s.level === null || RANG[s.level] <= RANG[maxLevel]) - .map((s) => s.text) - .join('\n') -} - -// Gestuftes Rendern: Segmente über dem Level fallen weg, Segmente DARUNTER -// werden gedimmt (schon gelernt) — der Blick geht auf die neuen Inhalte. -// Kernpunkte/Prüfe-dich (nach dem letzten Atom) bleiben immer ungedimmt. -export function renderGestuft(lang, maxLevel) { - if (!maxLevel || maxLevel === 'E') return render(gefiltert(lang, maxLevel)) - const seg = segmente(lang) - const letzt = seg[seg.length - 1] - const i = letzt.text.indexOf('**Kernpunkte:**') - if (i >= 0) { - seg.push({ level: null, titel: '', text: letzt.text.slice(i) }) - letzt.text = letzt.text.slice(0, i) - } - return seg.map((s) => { - if (s.level !== null && RANG[s.level] > RANG[maxLevel]) return '' - const html = render(s.text) - const gelernt = s.level !== null && RANG[s.level] < RANG[maxLevel] - return gelernt ? `
${html}
` : html - }).join('') -} diff --git a/frontend/src/style.css b/frontend/src/style.css index 4cd58bf..f6fbe16 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -203,9 +203,13 @@ body.nav-zu .hauptbereich { padding-top: 38px; } } .fokus-aus:hover { opacity: 1; } .seite.guide-text::-webkit-scrollbar { display: none; } -.markdown .gelernt { opacity: 0.45; } /* niedrigere Level = schon gelernt */ .guide-text .kapitel { scroll-margin-top: 8px; } -.guide-text .kapitel-titel { margin: 30px 0 6px; } +.guide-text .kapitel-titel { margin: 30px 0 2px; } +.guide-text .kapitel-meta { color: var(--dim); font-size: 13px; margin: 0 0 10px; } +.guide-section .antworten { margin-top: 10px; border-top: 1px solid var(--rand); padding-top: 8px; } +.guide-section .antworten summary { cursor: pointer; color: var(--dim); font-size: 14px; } +.guide-section .antworten .antwort { margin: 10px 0; } +.guide-section .antworten .frage { font-weight: 600; font-size: 14px; margin-bottom: 2px; } .kapitel-platzhalter { border-left: 2px dashed var(--rand); margin: 8px 0 8px 4px; } /* Kompakt-Ansicht: gleiche Lesetypografie wie der Fließtext, kein Grau-Kasten */ .guide-text .kompakt { diff --git a/templates/Artefakt-Verify.md b/templates/Artefakt-Verify.md index e2e0bf4..2bbc3ef 100644 --- a/templates/Artefakt-Verify.md +++ b/templates/Artefakt-Verify.md @@ -7,6 +7,8 @@ Prüfe jedes Artefakt GEGEN DIE BELEGE: - ok=false, wenn Frage oder Antwort auf Beleg, Quelle, Musterlösung oder einen Aufgabenkontext verweist — Karten müssen ohne den Quelltext selbstständig funktionieren und Wissen abfragen, nicht Textinhalte. +- ok=false, wenn Frage oder Antwort nicht auf Deutsch ist (etablierte + Fachtermini sind okay; ganze englische Sätze nicht). - Formulierungsgeschmack ist KEIN Mangel — inhaltlich korrekt in anderen Worten zählt voll. - Bei ok=false: „mangel" = ein konkreter, behebbarer Satz. - Antworte für JEDES Artefakt (id wörtlich übernehmen). diff --git a/templates/Atom-Anker-Fix.md b/templates/Atom-Anker-Fix.md deleted file mode 100644 index b35872a..0000000 --- a/templates/Atom-Anker-Fix.md +++ /dev/null @@ -1,12 +0,0 @@ - -Atom: „{titel}" — {definition} - -Suche im QUELLTEXT unten die Passage, die dieses Atom belegt, und kopiere sie -WÖRTLICH (exakt, Zeichen für Zeichen, 5–40 Wörter). Wenn der Text das Atom -NICHT enthält: leeres Zitat. Nichts paraphrasieren. - -QUELLTEXT: -{text} - -Antworte NUR mit JSON (keine Code-Fences): -{{"zitat": "…"}} diff --git a/templates/Atom-Extraktion-Aufgaben.md b/templates/Atom-Extraktion-Aufgaben.md index 269ae23..d70a197 100644 --- a/templates/Atom-Extraktion-Aufgaben.md +++ b/templates/Atom-Extraktion-Aufgaben.md @@ -17,6 +17,8 @@ Regeln: - „zitat": die Aufgabenstelle WÖRTLICH kopiert (5–40 Wörter) — sie ist der Beleg, dass dieses Konzept geübt wird. - Prüft eine Aufgabe mehrere Konzepte, wird jedes ein eigenes Atom. +- SPRACHE: „titel" und „definition" IMMER auf Deutsch (fremdsprachige Quellen + sinngemäß übersetzen; etablierte Fachtermini bleiben). Nur das „zitat" bleibt original. - Arbeite AUSSCHLIESSLICH mit dem Quelltext, nichts erfinden. - „braucht": Titel anderer Atome DIESES Auszugs, die Voraussetzung sind. Leer, wenn keine. diff --git a/templates/Atom-Extraktion.md b/templates/Atom-Extraktion.md index 4bb4d6a..620a8b2 100644 --- a/templates/Atom-Extraktion.md +++ b/templates/Atom-Extraktion.md @@ -10,6 +10,9 @@ lernbare Einheit, mit Typ: Test: Kann man es einzeln abprüfen? Dann Atom. Beispiele und Übungsaufgaben sind KEINE Atome. Regeln: - Arbeite AUSSCHLIESSLICH mit dem Quelltext. Kein externes Wissen, keine Websuche, nichts erfinden. +- SPRACHE: „titel" und „definition" IMMER auf Deutsch — auch bei fremdsprachigen + Quellen sinngemäß übersetzen. Etablierte Fachtermini ohne deutsche Entsprechung + bleiben original. Nur das „zitat" bleibt in der Quellsprache. - „titel": Konzeptname aus den Begriffen der Quelle. Katalognummern („Satz 7.13") sind KEIN Titel; kein Lehrbuch-Oberbegriff, der nicht im Text steht. - „definition": 1–2 Sätze, eigenständig verständlich (dient später dem Dubletten-Vergleich). diff --git a/templates/Atom-Titel-Fix.md b/templates/Atom-Titel-Fix.md new file mode 100644 index 0000000..5da83c3 --- /dev/null +++ b/templates/Atom-Titel-Fix.md @@ -0,0 +1,14 @@ + +Unten Lern-Atome mit kaputten Titeln (Format „id: titel — definition"): +Katalognummern („Satz 6.26") oder abgerissene Fragmente. + +Gib jedem Atom einen sauberen Titel: +- Konzeptname aus der Definition, 2–6 Wörter, auf Deutsch. +- KEINE Nummern, keine Quellen-Referenzen, keine Satzzeichen am Ende. +- Der Titel muss das Konzept eindeutig benennen (dient als Verweis-Anker). + +ATOME: +{atome} + +Antworte NUR mit JSON (keine Code-Fences): +[{{"atom": 12, "titel": "…"}}] diff --git a/templates/Guide-Writer.md b/templates/Guide-Writer.md index 2bed6ed..f0a2edf 100644 --- a/templates/Guide-Writer.md +++ b/templates/Guide-Writer.md @@ -1,6 +1,12 @@ Schreibe EINE Guide-Section für den Baustein „{titel}". Lernziel: {ziel} +DURCHGANG: {durchgang}. Der Guide hat drei getrennte Lese-Durchgänge — diese +Section muss für ihren Durchgang ALLEIN kohärent lesbar sein. + +BEREITS BEHANDELT (frühere Durchgänge — NICHT neu erklären, nur bei Bedarf mit +einem Halbsatz anknüpfen; dafür KEINE Marker setzen): +{kontext} Unten die Atome mit Marker, Definition, Belegen, ggf. Beispiel und Fragen-Pool. @@ -54,7 +60,9 @@ Regeln (hart): ein, kein Fülltext, keine Wiederholungen. 6. „kompakt": 2–3 Stichpunkte (Markdown-Liste) — Verdichtung der Section, KEINE Paraphrase, keine Marker. -7. Keine Vorgriffe auf Stoff, der nicht in den Atomen unten steht. +7. Keine Vorgriffe auf Stoff, der nicht in den Atomen unten steht — auch nicht + auf spätere Durchgänge. Unbekannte Begriffe beim ersten Auftreten kurz mit + Bekanntem einordnen, nie vertrösten („dazu später mehr" ist VERBOTEN). ATOME: {atome} diff --git a/templates/Kapitel-Intro.md b/templates/Kapitel-Intro.md index 7c18fb9..ca223b7 100644 --- a/templates/Kapitel-Intro.md +++ b/templates/Kapitel-Intro.md @@ -1,14 +1,17 @@ -Schreibe einen Advance Organizer für das Guide-Kapitel „{titel}". +Schreibe die Einleitung für das Guide-Kapitel „{titel}". +Durchgang: {durchgang}. Unten die Abschnitte des Kapitels (Titel: Lernziel). Regeln: -- 2–4 Sätze: Was kommt in diesem Kapitel, wie hängen die Abschnitte zusammen, - worauf baut es auf. -- NUR Landkarte, KEINE Inhalte vorwegnehmen — nichts erklären, nichts definieren - (das wäre Redundanz zum Kapiteltext). -- Kein Vorgeplänkel, keine Motivation-Floskeln („In diesem spannenden Kapitel…"). +- 1–2 Sätze, KONKRET: ein greifbarer Aufhänger — die zentrale Frage, das + Problem oder der Anwendungsfall, den das Kapitel löst. Wenn möglich mit + einem konkreten Beispiel oder einer vertrauten Analogie. +- VERBOTEN: Landkarten-Meta („Dieses Kapitel führt ein…", „legt das Fundament", + „Ausgehend von … werden … umrissen") — solche Vorschauen liest niemand. +- Nichts erklären, nichts definieren (das macht der Kapiteltext). +- Kein Vorgeplänkel, keine Motivations-Floskeln. ABSCHNITTE: {bausteine} diff --git a/templates/Kapitel-Schnitt.md b/templates/Kapitel-Schnitt.md index a08f146..5fd1614 100644 --- a/templates/Kapitel-Schnitt.md +++ b/templates/Kapitel-Schnitt.md @@ -1,13 +1,16 @@ -Unten die BAUSTEINE eines Lern-Guides in ihrer endgültigen Lese-Reihenfolge -(Format „id: titel — lernziel"). +Unten die BAUSTEINE eines Lern-Guide-Durchgangs in ihrer endgültigen +Lese-Reihenfolge, durchnummeriert (Format „nummer: titel — lernziel"). +Durchgang: {durchgang}. Formuliere die Kapiteltitel für diesen Durchgang. Setze Kapitelgrenzen. Regeln: - Ein Kapitel = ein zusammenhängendes Teilthema. Grenze dort, wo ein neues beginnt. -- Antworte mit EINER Zeile je Kapitel: "titel" plus "bis" = id des LETZTEN - Bausteins dieses Kapitels. Die Reihenfolge ist fix — du setzt nur Grenzen. - Das letzte "bis" muss die letzte id der Liste sein. +- Antworte mit EINER Zeile je Kapitel: "titel" plus "bis" = NUMMER des LETZTEN + Bausteins dieses Kapitels. Die Reihenfolge ist fix — du setzt nur Grenzen, + "bis" muss streng aufsteigen. Das letzte "bis" ist die letzte Nummer der Liste. - Richtwert: etwa {richtwert} Kapitel — weiche ab, wo Themenwechsel es verlangen. +- Jedes Kapitel bündelt MINDESTENS 2 Bausteine (nur das letzte darf kleiner + sein) und höchstens das Doppelte des Durchschnitts — keine Einzel-Kapitel. - „titel" = kurze Inhaltsaussage über ALLE Bausteine des Kapitels (kein Baustein-Titel-Duplikat, keine Nummerierung). {hinweis} diff --git a/templates/Korpus-Soll-Beleg.md b/templates/Korpus-Soll-Beleg.md index 25b4975..21948d0 100644 --- a/templates/Korpus-Soll-Beleg.md +++ b/templates/Korpus-Soll-Beleg.md @@ -1,12 +1,15 @@ -Soll-Punkt: „{punkt}" +Unten Soll-Punkte (Format „id: punkt") und EIN Quelltext. -Suche im QUELLTEXT unten eine Passage, die diesen Punkt belegt. Wenn es eine gibt: -kopiere sie WÖRTLICH (exakt, 5–30 Wörter). Wenn der Text den Punkt NICHT behandelt: -leeres Zitat. Nichts paraphrasieren, nichts erfinden. +Suche für JEDEN Punkt eine Passage im QUELLTEXT, die ihn belegt. Wenn es eine +gibt: kopiere sie WÖRTLICH (exakt, 5–30 Wörter). Behandelt der Text einen Punkt +NICHT: den Punkt weglassen. Nichts paraphrasieren, nichts erfinden. + +SOLL-PUNKTE: +{punkte} QUELLTEXT: {text} -Antworte NUR mit JSON (keine Code-Fences): -{{"zitat": "…"}} +Antworte NUR mit JSON (keine Code-Fences) — nur Punkte MIT Fund: +[{{"soll": 12, "zitat": "…"}}] diff --git a/templates/Level-Kalibrierung.md b/templates/Level-Kalibrierung.md index 3461fb8..5822bea 100644 --- a/templates/Level-Kalibrierung.md +++ b/templates/Level-Kalibrierung.md @@ -1,17 +1,19 @@ -Unten die Atome EINES Guide-Bausteins „{titel}" (Format „id: titel — definition"). +Unten die Atome EINES Lernziels „{titel}" (Format „id: titel — definition"). Kalibriere die Lernstufen RELATIV zueinander. Die Stufen sind Komplexitätsschichten -(nicht „leicht/schwer"): -- E = Grundgerüst: das Minimum, das man beim ERSTEN Durchgang braucht, um den - Baustein zu verstehen — Kernbegriffe, die Kernaussage. Die E-Atome allein müssen - einen kohärenten Kurzdurchgang ergeben. +(nicht „leicht/schwer") und werden zu GETRENNTEN Bausteinen und Lese-Durchgängen: +- E = Grundgerüst: das Minimum, das man beim ERSTEN Durchgang braucht, um das + Lernziel zu verstehen — Kernbegriffe, die Kernaussage. Die E-Atome allein müssen + einen kohärenten, für sich lesbaren Kurzdurchgang tragen. - M = Standardstoff: Sätze, Verfahren, Standardbeweise — der zweite Durchgang. - S = Feinheiten: Spezialfälle, Beweisdetails, Optimierungen, Randnotizen. Regeln: -- Behandelt der Baustein grundlegenden Stoff, gehört sein tragender Kern zu E. -- Rein vertiefende Bausteine (reine Beweis-/Spezialbausteine) dürfen ohne E bleiben. +- Behandelt das Ziel grundlegenden Stoff, gehört sein tragender Kern zu E. +- Voraussetzungen eines Atoms gehören auf DESSEN Stufe oder tiefer — was ein + E-Atom braucht, kann nicht M/S sein. +- Rein vertiefende Ziele (reine Beweis-/Spezialthemen) dürfen ohne E bleiben. - Jede id genau einmal, keine erfinden. ATOME: diff --git a/tests/test_bausteine.py b/tests/test_bausteine.py index 839fbf8..91b96cf 100644 --- a/tests/test_bausteine.py +++ b/tests/test_bausteine.py @@ -178,7 +178,7 @@ def test_det_auftraege_blockquote_und_artefakt(): b_id = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu") a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d", status="neu", baustein_id=b_id, braucht=db.j([])) - lang = (f"\n" + "Fließtext. " * 45 + lang = (f"\n" + "Fließtext. " * 45 + "\n> wörtliches Rohzitat aus der Quelle\nEs gilt u 6= v." + "\n\nDas Blank-Symbol $[. ist speziell und liegt in $[ \\in \\Gamma$.") kompakt = "- Definition mit x_i und Laufzeit 2^(n/2) als Klartext." @@ -189,6 +189,62 @@ def test_det_auftraege_blockquote_und_artefakt(): assert "Kompakt-Fassung" in text and "x_i" in text # Klartext-Formeln gefangen +def test_det_auftraege_neue_checks(): + import db + import guide + from conftest import topic_anlegen + topic = topic_anlegen("detcheck2") + ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv") + b_id = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu") + a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d", + status="neu", baustein_id=b_id, braucht=db.j([])) + wand = "Wort " * 130 + lang = (f"\n\n" + "Der Beweis folgt aus Satz 6.25, dazu später mehr.\n\n" + + wand + "\n\n#### Leere Überschrift\n\n#### Noch eine\nText dahinter.\n\n" + "The proof is left as an exercise for the reader and this paragraph" + " should be detected by the language check here.") + kompakt = f"\n- Punkt" + text = " ".join(guide._det_auftraege(topic, {"id": b_id, "ord": 0}, lang, kompakt)) + assert "gehört nicht zu diesem Baustein" in text # marker_fremd + assert "Vertröstungs-Floskel" in text # „später mehr" + assert "Quellen-Referenz" in text # Satz 6.25 + assert "Wörter (Regel: 40–90)" in text # Absatz-Wand + assert "ohne folgenden Text" in text # leere Überschrift + assert "englische Passage" in text + assert "Marker gehören nur" in text # Marker in Kompakt-Fassung + # qa=True: Marker/Länge/Vorwärts haben eigene Befund-Arten, Stil-Checks bleiben + qa_text = " ".join(guide._det_auftraege(topic, {"id": b_id, "ord": 0}, + lang, kompakt, qa=True)) + assert "gehört nicht zu diesem Baustein" not in qa_text + assert "Vertröstungs-Floskel" in qa_text + + +async def test_fix_fehlschlag_behaelt_befunde(monkeypatch): + import db + import fake_agents + import guide + import llm + from conftest import run_anlegen, topic_anlegen + topic = topic_anlegen("fixfail") + run = run_anlegen(topic) + ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv") + b_id = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu") + db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d", + status="neu", baustein_id=b_id, braucht=db.j([])) + db.insert("sections", baustein_id=b_id, stage="fix", + text_lang="alt", text_kompakt="", befunde=db.j(["Behebe (x): y"])) + # Fix liefert Text OHNE Marker → Marker-Invariante bricht → Fehlschlag-Pfad + monkeypatch.setitem(fake_agents._HANDLER, "Guide-Fix", + lambda p: {"kompakt": "", "lang": "kein Marker"}) + ctx = llm.Kontext(run, topic, "minimax") + ctx.ebene = "guide" + stage = await guide._stage_fix(ctx, db.one("SELECT * FROM bausteine WHERE id=?", (b_id,))) + sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b_id,)) + assert stage == "done" and sec["text_lang"] == "alt" + assert db.uj(sec["befunde"]) == ["Behebe (x): y"] # Aufträge bleiben sichtbar + + def test_katex_gate_und_markdown_hygiene(monkeypatch): import guide monkeypatch.delenv("CREATOR_FAKE_AGENTS", raising=False) # Gate echt laufen lassen diff --git a/tests/test_e2e.py b/tests/test_e2e.py index a3eebd2..3247c2f 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -36,6 +36,7 @@ def _pruefe_endzustand(topic, run_id): md = guide.guide_markdown(topic) for a in atome: assert f"" not in md and "| M -->" not in md # Marker tragen kein Level mehr # Kapitel-Struktur: H2 = Kapitel (Struktur-Ebene, mit Intro), H3 = Baustein assert "\n## " in "\n" + md and "### " in md assert "**Kernpunkte:**" in md and "**Prüfe dich:**" in md @@ -48,6 +49,17 @@ def _pruefe_endzustand(topic, run_id): folge = [b["kapitel_id"] for b in bausteine] segmente = [k for k, _ in itertools.groupby(folge)] assert len(segmente) == len(set(segmente)) == len(kaps) # kontiguierlich, lückenlos + # Stufen-Bausteine: jeder Baustein trägt genau ein Level, alle seine Atome + # dieses Level; Kapitel-Level monoton E→M→S entlang der globalen ord + rang = {"E": 0, "M": 1, "S": 2} + kap_level = {k["id"]: k["level"] for k in kaps} + for b in bausteine: + assert kap_level[b["kapitel_id"]] == b["level"] + for a in atome: + if a["baustein_id"] == b["id"]: + assert a["level"] == b["level"], (a["titel"], a["level"], b["level"]) + level_folge = [rang[b["level"]] for b in bausteine] + assert level_folge == sorted(level_folge), "Durchgänge nicht E