From 9cd8e02e22715850df46c75c2b1f8f90fb929406 Mon Sep 17 00:00:00 2001 From: team3 Date: Mon, 13 Jul 2026 12:31:25 +0200 Subject: [PATCH] update --- DESIGN.md | 9 +- backend/agents.py | 16 ++ backend/config.py | 10 +- backend/db.py | 13 +- backend/fake_agents.py | 39 +--- backend/guide.py | 13 +- backend/inventar.py | 136 +----------- backend/jsonx.py | 30 ++- backend/llm.py | 36 +++- backend/main.py | 6 + backend/pipeline.py | 7 + backend/qa.py | 4 +- backend/struktur.py | 295 +++++++------------------- backend/textkit.py | 165 +++++++++++++- backend/transfer.py | 9 +- frontend/src/App.vue | 7 + frontend/src/api.js | 1 + templates/Atom-Anker-Fix-Batch.md | 4 + templates/Atom-Extraktion-Aufgaben.md | 10 +- templates/Atom-Extraktion.md | 13 +- templates/Atom-Luecke.md | 4 + templates/Atom-Titel-Fix.md | 2 + templates/Baustein-Ordnung.md | 17 ++ templates/Braucht-Aufloesung.md | 23 -- templates/Kanten-Zyklus.md | 12 -- tests/test_artefakte.py | 16 +- tests/test_auftraege.py | 2 +- tests/test_bausteine.py | 105 ++++++++- tests/test_belege.py | 2 +- tests/test_budget_infra.py | 98 +++++++++ tests/test_inventar.py | 30 +-- tests/test_lesemodus.py | 2 +- tests/test_struktur.py | 208 +++++++++--------- tests/test_transfer.py | 9 +- 34 files changed, 747 insertions(+), 606 deletions(-) create mode 100644 templates/Baustein-Ordnung.md delete mode 100644 templates/Braucht-Aufloesung.md delete mode 100644 templates/Kanten-Zyklus.md diff --git a/DESIGN.md b/DESIGN.md index 1686fbe..39aaf05 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -24,9 +24,10 @@ E1 Inventar Abschnitte (~12k Zeichen) → 2 Reader je Abschnitt → Atome mit E2 Artefakte je Soll-Punkt-Gruppe: Flashcards + 1 typisiertes Beispiel pro Atom (Form: text|mathe|code|tabelle, beste Passung) → Form-Gate (Syntax/Struktur parse-only, kein Ausführen) → Verify-Panel gegen Anker → Fix -E3 Struktur Lernziele+Zuordnung aus Atomen (je Soll-Punkt) → Ziel-Check → Kanten auflösen - → Zyklen brechen (Judge) → Bausteine = Ziel-Gruppen im Band (split/merge) - → topologische Ordnung (Bausteine + Atome darin) +E3 Struktur Lernziele+Zuordnung aus Atomen (je Soll-Punkt) → Ziel-Check + → Bausteine = Ziel-Gruppen im Band (split/merge) + → Ordnung per Judge je Level (Quellpositions-Median als Prior, + Permutations-Gate, Fallback = Quellreihenfolge mit Befund) E4 Guide je Baustein: Writer (Facts inline, Atom-Marker; Ausgabe DELIMITED, nicht JSON — LaTeX-Backslashes) → det. Checks → Prüfer → Fix (Fix-Cap gegen Churn). Beispiel-Marker setzt der Code DETERMINISTISCH (nicht der Writer); @@ -45,7 +46,7 @@ Stillstand oder 10 Iterationen. Erst dann startet die nächste Ebene. - `soll(id, topic, punkt, status kandidat|bestaetigt, belege JSON [{quelle,zitat}])` - `atome(id, topic, titel, typ, definition, level, status, soll_id, ziel_id, baustein_id, ord, merged_into)` - `anker(atom_id, quelle_id, start, ende, zitat)` -- `kanten(topic, von_atom, zu_atom, art braucht|verwandt, status)` +- `kanten(topic, von_atom, zu_atom, art verwandt, status)` — Dedup-Gedächtnis - `artefakte(id, atom_id, typ flashcard|beispiel, inhalt JSON, status kandidat|verifiziert|verworfen)` — beispiel-`inhalt` trägt `form` + formspezifische Felder (text|code+sprache|tabelle); code/tabelle kommen verbatim via `` in den Guide (Assembly-Swap) diff --git a/backend/agents.py b/backend/agents.py index 5626d49..e5a74b1 100644 --- a/backend/agents.py +++ b/backend/agents.py @@ -34,6 +34,7 @@ _api_sem = asyncio.Semaphore(MAX_CONCURRENT_API_AGENTS) _active: dict[str, float] = {} # key → Startzeit (Anzeige) _prozesse: dict[str, asyncio.subprocess.Process] = {} _abgebrochen: set[str] = set() # Key-Präfixe abgebrochener Läufe +_pausiert: set[str] = set() # Key-Präfixe sanft pausierter Läufe # ── Globale 429-Bremse: Backoff pro Call reicht nicht — 28 Parallel-Calls # kollidieren nach der Wartezeit sofort wieder. Ein 429 drosselt deshalb ALLE: @@ -108,14 +109,25 @@ def abbrechen(prefix: str) -> None: _kill(p) +def pausieren(prefix: str) -> None: + """Sanfte Pause: Wartende + Neue liefern sofort „pausiert", Laufende laufen + aus und ihre Ergebnisse landen noch in der DB (Gegenstück zu abbrechen).""" + _pausiert.add(prefix) + + def abbruch_aufheben(prefix: str) -> None: _abgebrochen.discard(prefix) + _pausiert.discard(prefix) def _ist_abgebrochen(key: str) -> bool: return any(key.startswith(p) for p in _abgebrochen) +def _ist_pausiert(key: str) -> bool: + return any(key.startswith(p) for p in _pausiert) + + def provider_verfuegbar(provider: str) -> bool: cfg = PROVIDERS.get(provider) if not cfg: @@ -136,6 +148,8 @@ async def run_agent(key: str, prompt: str, timeout: int, *, provider: str, role: str = "quick", capabilities: str = "none") -> AgentErgebnis: """Ein Call. capabilities: none (nur Text) | files (Read/Write/Bash) | full (+Web). Wirft asyncio.TimeoutError bei Timeout (Infra-Behandlung macht llm.py).""" + if _ist_pausiert(key): # VOR dem Fake-Shortcut — sonst ist Pause untestbar + return AgentErgebnis(1, "", "pausiert") if os.getenv("CREATOR_FAKE_AGENTS"): import fake_agents return await fake_agents.antwort(key, prompt, capabilities) @@ -154,6 +168,8 @@ async def run_agent(key: str, prompt: str, timeout: int, *, provider: str, global _api_inflight warte_start = time.time() # Queue-Zeit getrennt ausweisen (Ledger wait_ms) async with sem: + if _ist_pausiert(key): # wartete im Semaphor, als die Pause kam + return AgentErgebnis(1, "", "pausiert") if _ist_abgebrochen(key): return AgentErgebnis(1, "", "abgebrochen") _active[key] = time.time() diff --git a/backend/config.py b/backend/config.py index d7df394..1321ace 100644 --- a/backend/config.py +++ b/backend/config.py @@ -74,7 +74,9 @@ SOLL_PUNKTE_PER_SQRT = 1.0 SOLL_PUNKTE_MIN = 5 # ── Ebene 1: Inventar ───────────────────────────────────────────────────────── -ABSCHNITT_CHARS = 12_000 # lost-in-the-middle-Guard +ABSCHNITT_CHARS = 6_000 # lost-in-the-middle-Guard; 12k riss bei dichten + # tex-Quellen das 32k-Output-Cap (aak: ø 11.7k, Spitzen + # 32k → 5-min-Calls, Hedge-Zwillinge, Halbier-Kaskade) READER_JE_ABSCHNITT = 2 # unabhängige Reader (Konsens entsteht über Dedup, nicht Union) ANKER_OVERLAP_MERGE = 0.5 # Span-Überlappung ab der zwei Atome automatisch mergen # Fuzzy-Anker-Stufe 4 (Lektion 83, Hypothes.is-Muster exakt→locker→fuzzy): @@ -84,9 +86,6 @@ MERGE_PANEL = 2 # Judges pro Merge-Kandidat — Merge nur einstimmi MERGE_KANDIDAT_COS = 0.75 # Definitions-Ähnlichkeit ab der ein Paar zum Panel geht MERGE_KANDIDAT_JACCARD = 0.3 # Schwelle im lexikalischen Fallback (misst viel niedriger als Kosinus) LUECKEN_RUNDEN_MAX = 2 # gezielte Nachextraktion pro Soll-Lücke -# braucht-Titel, die norm/kern nicht auflösen (Kompositum↔Expansion misst ~0.53 Cosinus, -# Embedding schlägt sie NIE vor — Lektion 28): Substring-Prefilter → 1 Judge (n=1, reversibel). -BRAUCHT_KANDIDATEN = 8 # Kandidaten-Cap je unaufgelöstem braucht-Titel (Lektion 34) # ── Ebene 2: Artefakte ──────────────────────────────────────────────────────── BEISPIEL_FORMEN = ("text", "mathe", "code", "tabelle") # Worked-Example-Formen @@ -131,7 +130,6 @@ TIMEOUTS = { "soll_konsens": (300, 2), "extraktion": (450, 0), "merge": (150, 8), - "braucht": (200, 0), "soll_zuordnung": (300, 8), "luecke": (450, 0), "artefakt": (450, 20), @@ -142,7 +140,7 @@ TIMEOUTS = { "ziele": (300, 6), "level": (200, 4), "kapitel": (200, 2), - "zyklus": (150, 0), + "ordnung": (300, 2), "writer": (450, 60), "pruefer": (600, 5), "pruefer_urteil": (300, 5), diff --git a/backend/db.py b/backend/db.py index e9740e0..ff3b397 100644 --- a/backend/db.py +++ b/backend/db.py @@ -72,7 +72,7 @@ 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', status TEXT NOT NULL DEFAULT 'neu', soll_id INTEGER, ziel_id INTEGER, baustein_id INTEGER, - ord INTEGER DEFAULT 0, merged_into INTEGER, braucht TEXT NOT NULL DEFAULT '[]'); + ord INTEGER DEFAULT 0, merged_into INTEGER); CREATE INDEX IF NOT EXISTS idx_atome_topic ON atome(topic); CREATE TABLE IF NOT EXISTS anker( id INTEGER PRIMARY KEY, atom_id INTEGER NOT NULL, quelle_id INTEGER NOT NULL, @@ -93,7 +93,7 @@ CREATE TABLE IF NOT EXISTS lernziele( 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, - level TEXT NOT NULL DEFAULT 'M'); + level TEXT NOT NULL DEFAULT 'M', ordnung TEXT NOT NULL DEFAULT 'judge'); 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, @@ -143,11 +143,16 @@ def _init_schema(con: sqlite3.Connection) -> None: "ALTER TABLE kapitel ADD COLUMN level TEXT NOT NULL DEFAULT 'M'", "ALTER TABLE soll ADD COLUMN geprueft TEXT NOT NULL DEFAULT '[]'", "ALTER TABLE quellen ADD COLUMN atome_stand TEXT NOT NULL DEFAULT ''", - "ALTER TABLE sections ADD COLUMN fix_versuche INTEGER NOT NULL DEFAULT 0"): + "ALTER TABLE sections ADD COLUMN fix_versuche INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE bausteine ADD COLUMN ordnung TEXT NOT NULL DEFAULT 'judge'", + # braucht-Maschinerie entfernt: Ordnung kommt vom Judge, + # nicht mehr vom (zu dünnen) Abhängigkeits-Graph + "ALTER TABLE atome DROP COLUMN braucht"): try: con.execute(zusatz) except sqlite3.OperationalError: - pass # Spalte existiert schon + pass # Spalte existiert schon (bzw. ist schon weg) + con.execute("DELETE FROM kanten WHERE art='braucht'") # tote Daten, kein Leser mehr # Status-Marker entfernter Ebenen (diagramme/fehlersuche) zurückrollen — sonst # kennt _ORDNUNG den Status nicht und der Resume fiele fälschlich auf korpus zurück. con.execute("UPDATE topics SET status='struktur_fertig'" diff --git a/backend/fake_agents.py b/backend/fake_agents.py index f0edde2..8eb4e8a 100644 --- a/backend/fake_agents.py +++ b/backend/fake_agents.py @@ -16,13 +16,11 @@ FAKE_TEXT = f"{SATZ_DFA}\n\n{SATZ_POTENZ}\n\n{SATZ_PUMPING}\n" ATOME = [ {"titel": "Deterministischer endlicher Automat", "typ": "begriff", - "definition": SATZ_DFA, "zitat": SATZ_DFA, "level": "E", "braucht": []}, + "definition": SATZ_DFA, "zitat": SATZ_DFA, "level": "E"}, {"titel": "Potenzmengenkonstruktion", "typ": "verfahren", - "definition": SATZ_POTENZ, "zitat": SATZ_POTENZ, "level": "M", - "braucht": ["Deterministischer endlicher Automat"]}, + "definition": SATZ_POTENZ, "zitat": SATZ_POTENZ, "level": "M"}, {"titel": "Pumping-Lemma", "typ": "aussage", - "definition": SATZ_PUMPING, "zitat": SATZ_PUMPING, "level": "S", - "braucht": ["Deterministischer endlicher Automat"]}, + "definition": SATZ_PUMPING, "zitat": SATZ_PUMPING, "level": "S"}, ] PUNKTE = [("Endliche Automaten", (SATZ_DFA, SATZ_POTENZ)), ("Nachweis von Nicht-Regularität", (SATZ_PUMPING,))] @@ -218,31 +216,10 @@ def _kapitel_schnitt(prompt: str): for n, g in enumerate(dict.fromkeys(grenzen))] -def _zyklus(prompt: str): - m = re.search(r"^(\d+)→(\d+):", _text_nach(prompt, "ZYKLUS:"), re.MULTILINE) - return {"von": int(m.group(1)), "zu": int(m.group(2))} if m else {} - - -def _braucht_aufloesung(prompt: str): - # je ANGABE den Kandidaten mit den meisten geteilten Tokens (Substring, ≥4 Zeichen) - # wählen, sonst null — spiegelt den Substring-Prefilter der Auflösung. - def toks(s): - return {t for t in re.findall(r"\w+", s.lower()) if len(t) >= 4} - out = [] - bloecke = re.split(r"ANGABE (\d+):", prompt)[1:] - for i in range(0, len(bloecke), 2): - n, body = int(bloecke[i]), bloecke[i + 1] - mp = re.search(r"Voraussetzung „([^“]+)", body) - ph = mp.group(1) if mp else "" - pl, pt = ph.lower(), toks(ph) - ranked = [] - for kid, ktitel in re.findall(r"- atom (\d+): (.+?) —", body): - geteilt = {t for t in toks(ktitel) if t in pl} | {t for t in pt if t in ktitel.lower()} - if geteilt: - ranked.append((-len(geteilt), len(ktitel), int(kid))) - ranked.sort() - out.append({"phrase": n, "atom": ranked[0][2] if ranked else None}) - return out +def _baustein_ordnung(prompt: str): + nummern = [int(i) for i in re.findall(r"^(\d+): ", _text_nach(prompt, "BAUSTEINE"), + re.MULTILINE)] + return nummern # Identität: Prior-Ordnung ist im Fake die Lehr-Reihenfolge def _writer(prompt: str): @@ -303,7 +280,7 @@ _HANDLER = { "Aussagen-Generate": _aussagen_generate, "Aussagen-Verify": _aussagen_verify, "Lernziele": _lernziele, "Kapitel-Intro": _kapitel_intro, "Kapitel-Schnitt": _kapitel_schnitt, "Level-Kalibrierung": _level_kalibrierung, - "Kanten-Zyklus": _zyklus, "Braucht-Aufloesung": _braucht_aufloesung, + "Baustein-Ordnung": _baustein_ordnung, "Guide-Writer": _writer, "Guide-Pruefer": _pruefer, "Guide-Pruefer-Urteil": _pruefer_urteil, "Guide-Fix": _fix, "QA-Guide-Falsch": _qa_falsch, "QA-Guide-Falsch-Check": _qa_falsch_check, diff --git a/backend/guide.py b/backend/guide.py index 71ed4f5..bf2c589 100644 --- a/backend/guide.py +++ b/backend/guide.py @@ -186,23 +186,14 @@ def _laenge_band(n_atome: int) -> tuple[int, int]: 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).""" + desselben Ziels. 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()) diff --git a/backend/inventar.py b/backend/inventar.py index 79f43c4..3bc51b8 100644 --- a/backend/inventar.py +++ b/backend/inventar.py @@ -14,7 +14,7 @@ import embedding import korpus import llm import textkit -from config import (ANKER_OVERLAP_MERGE, BRAUCHT_KANDIDATEN, LUECKEN_RUNDEN_MAX, +from config import (ANKER_OVERLAP_MERGE, LUECKEN_RUNDEN_MAX, MERGE_KANDIDAT_COS, MERGE_KANDIDAT_JACCARD, MERGE_PANEL, READER_JE_ABSCHNITT, ZIELE_CHUNK_ATOME) @@ -75,12 +75,10 @@ async def _extrahiere_quelle(ctx: llm.Kontext, quelle: dict, force: bool = False if not titel or not definition or typ not in TYPEN: continue span = textkit.finde_zitat(chunk, zitat) - braucht = [str(t).strip() for t in a.get("braucht", []) if str(t).strip()] atom_id = db.insert("atome", topic=ctx.topic, titel=titel, typ=typ, definition=definition, level=level if level in LEVELS else "M", - status="neu" if span else "ohne_anker", - braucht=db.j(braucht)) + status="neu" if span else "ohne_anker") if span: db.insert("anker", atom_id=atom_id, quelle_id=quelle["id"], start=offset + span[0], ende=offset + span[1], zitat=zitat) @@ -114,7 +112,7 @@ def _wurzel(atom_id: int) -> int: def _merge(topic: str, gewinner: int, verlierer: int) -> None: - """Anker + braucht wandern zum Gewinner; der Verlierer bleibt als gemerged sichtbar.""" + """Anker wandern zum Gewinner; der Verlierer bleibt als gemerged sichtbar.""" gewinner, verlierer = _wurzel(gewinner), _wurzel(verlierer) if gewinner == verlierer: return @@ -123,8 +121,6 @@ def _merge(topic: str, gewinner: int, verlierer: int) -> None: if not g or not v or g["status"] == "verworfen" or v["status"] == "verworfen": return db.execute("UPDATE anker SET atom_id=? WHERE atom_id=?", (gewinner, verlierer)) - braucht = sorted(set(db.uj(g["braucht"]) + db.uj(v["braucht"]))) - db.update("atome", "id", gewinner, braucht=db.j(braucht)) db.update("atome", "id", verlierer, status="gemerged", merged_into=gewinner) # Kanten einzeln umhängen: ein blindes UPDATE kollidiert mit dem UNIQUE-Index, # wenn der Gewinner dieselbe Kante schon hat; Selbstkanten entstehen beim @@ -246,121 +242,6 @@ def _erster_anker(atom_id: int) -> str: return (row["zitat"][:300] if row else "(kein Anker)") -def _kanten_aufloesen(topic: str) -> None: - """braucht-Titel → Atom-IDs. Norm-Gleichheit zuerst, Titel-Kern als zweite - Stufe (Norm allein löste nur 72–79 % auf — der Rest fiel still weg und - Ordnung/Level-Logik arbeiteten auf einem lückigen Graph).""" - atome = aktive_atome(topic) - je_norm = {textkit.norm(a["titel"]): a["id"] for a in atome} - je_kern: dict[str, int] = {} - for a in atome: - if kern := textkit.titel_kern(a["titel"]): - je_kern.setdefault(kern, a["id"]) - for a in atome: - for titel in db.uj(a["braucht"]): - ziel = je_norm.get(textkit.norm(titel)) - if not ziel and (kern := textkit.titel_kern(titel)): - ziel = je_kern.get(kern) - if ziel and ziel != a["id"]: - db.execute("INSERT OR IGNORE INTO kanten(topic, von_atom, zu_atom, art)" - " VALUES(?,?,?,'braucht')", (topic, a["id"], ziel)) - - -def _braucht_unaufgeloest(atome: list[dict]) -> list[tuple[dict, str]]: - """(Atom, braucht-Titel), die _kanten_aufloesen (norm/kern) NICHT traf. Selbe - Auflösungslogik — was sie fand, ist erledigt und bleibt außen vor.""" - je_norm = {textkit.norm(a["titel"]): a["id"] for a in atome} - je_kern: dict[str, int] = {} - for a in atome: - if kern := textkit.titel_kern(a["titel"]): - je_kern.setdefault(kern, a["id"]) - offen = [] - for a in atome: - for titel in db.uj(a["braucht"]): - ziel = je_norm.get(textkit.norm(titel)) - if not ziel and (kern := textkit.titel_kern(titel)): - ziel = je_kern.get(kern) - if not ziel or ziel == a["id"]: - offen.append((a, titel)) - return offen - - -def _trigramm(s: str) -> set[str]: - s = textkit.norm(s) - return {s[i:i + 3] for i in range(len(s) - 2)} or {s} - - -def _tri_dice(a: str, b: str) -> float: - """Zeichen-Trigramm-Dice — fuzzy Titel-Ähnlichkeit, die geteilte Wortstämme UND - Kopfnomen erfasst („Approximationsalgorithmus" ~ „Approximativer Algorithmus").""" - A, B = _trigramm(a), _trigramm(b) - return 2 * len(A & B) / (len(A) + len(B)) if A or B else 0.0 - - -def _braucht_kandidaten(titel: str, atome: list[dict], selbst_id: int, cap: int) -> list[dict]: - """Expliziter Kandidatengenerator für Kompositum↔Expansion (Lektion 28: Cosinus - ~0.53, Embedding schlägt sie nie vor). Zulassung (hohe Trefferquote): ein - signifikantes Token (≥5 Zeichen) ist Substring des anderen normierten Titels - („algorithmus" ⊂ „approximationsalgorithmus"). Rang nach Trigramm-Dice — der - ganze Titel zählt, nicht nur das Kopfnomen, so steht der Grundbegriff vor - generischen Distraktoren („Algorithmus"). Der Judge trennt dann fein.""" - nt = textkit.norm(titel) - bt = {t for t in textkit.tokens(titel) if len(t) >= 5} - treffer = [] - for a in atome: - if a["id"] == selbst_id: - continue - na = textkit.norm(a["titel"]) - at = {t for t in textkit.tokens(a["titel"]) if len(t) >= 5} - if {t for t in at if t in nt} | {t for t in bt if t in na}: - treffer.append((_tri_dice(titel, a["titel"]), a["id"], a)) - treffer.sort(key=lambda x: (-x[0], x[1])) - return [a for _, _, a in treffer[:cap]] - - -async def _braucht_fallback(ctx: llm.Kontext) -> None: - """braucht-Titel, die norm/kern nicht auflösten, per Judge auf Atome mappen. - Deterministischer Substring-Prefilter (Kandidaten) → EIN Judge (n=1) als - Präzisions-Gate. braucht-Kanten sind reversibel/billiger als Merges → kein Panel - (Lektion 78). Im Zweifel null: eine Falsch-Kante verschiebt Ordnung + Level. - Idempotent (INSERT OR IGNORE); läuft nur in bauen, damit ein Bausteine-Reset ihn - ohne Re-Extraktion mitnimmt.""" - topic = ctx.topic - atome = aktive_atome(topic) - offen = _braucht_unaufgeloest(atome) - if not offen: - return - angaben = [(a, titel, kand) for a, titel in offen - if (kand := _braucht_kandidaten(titel, atome, a["id"], BRAUCHT_KANDIDATEN))] - if not angaben: - log.info("braucht-Fallback: %d unaufgelöste Titel, keine Kandidaten", len(offen)) - return - geloest = 0 - - async def chunk_loesen(chunk: list) -> None: - nonlocal geloest - liste = "\n\n".join( - f"ANGABE {n}: Voraussetzung „{titel}“ (gebraucht von Atom {a['id']}" - f" „{a['titel']}“)\nKandidaten:\n" - + "\n".join(f" - atom {k['id']}: {k['titel']} — {k['definition']}" for k in kand) - for n, (a, titel, kand) in enumerate(chunk, 1)) - res = await llm.call(ctx, stage="braucht", template="Braucht-Aufloesung", - werte={"angaben": liste}, role="judge", - n=len(chunk), item=f"br{chunk[0][0]['id']}", erwartet=list) - wahl = {e.get("phrase"): e.get("atom") for e in res or [] if isinstance(e, dict)} - for n, (a, titel, kand) in enumerate(chunk, 1): - zid = wahl.get(n) - if zid in {k["id"] for k in kand} and zid != a["id"]: - db.execute("INSERT OR IGNORE INTO kanten(topic, von_atom, zu_atom, art)" - " VALUES(?,?,?,'braucht')", (topic, a["id"], zid)) - geloest += 1 - - chunks = [angaben[i:i + PAAR_CHUNK] for i in range(0, len(angaben), PAAR_CHUNK)] - await llm.alle(chunk_loesen(c) for c in chunks) - log.info("braucht-Fallback: %d/%d Titel aufgelöst (%d ohne Kandidaten)", - geloest, len(offen), len(offen) - len(angaben)) - - # ── Soll-Zuordnung ──────────────────────────────────────────────────────────── async def _soll_zuordnen(ctx: llm.Kontext, nur_offene: bool = True) -> None: @@ -392,7 +273,6 @@ async def bauen(ctx: llm.Kontext) -> None: await llm.alle(_extrahiere_quelle(ctx, q) for q in offene) _anker_dedup(ctx.topic) await _judge_dedup(ctx) - _kanten_aufloesen(ctx.topic) await _soll_zuordnen(ctx) @@ -403,10 +283,13 @@ _TITEL_KATALOG = re.compile(r"^(Satz|Lemma|Korollar|Bemerkung|Definition|Aufgabe def _titel_kaputt(titel: str) -> bool: - """Katalognummern-Titel (Satz 6.26 — der Leser hat kein nummeriertes Skript) - und abgerissene Fragmente (aak: „Teste für alle V 0 ⊆ V mit |").""" + """Katalognummern-Titel (Satz 6.26 — der Leser hat kein nummeriertes Skript), + abgerissene Fragmente (aak: „Teste für alle V 0 ⊆ V mit |") und LaTeX-Markup + (Titel sind Identifikatoren — Board/Dedup/Verweise rendern kein KaTeX).""" if _TITEL_KATALOG.match(titel): return True + if "$" in titel or "\\" in titel: + return True if titel.rstrip().endswith(("|", ",", ";", ":", "-", "–", "(", "{", "⊆", "=")): return True return titel.count("(") != titel.count(")") @@ -480,7 +363,6 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool: if bewegt: _anker_dedup(ctx.topic) await _judge_dedup(ctx) - _kanten_aufloesen(ctx.topic) await _soll_zuordnen(ctx) return bewegt @@ -658,7 +540,7 @@ async def _luecke_schliessen(ctx: llm.Kontext, soll_id: int) -> bool: offset = text.find(fenster) atom_id = db.insert("atome", topic=ctx.topic, titel=titel, typ=typ, definition=definition, level="M", status="neu", - soll_id=soll_id, braucht=db.j([])) + soll_id=soll_id) db.insert("anker", atom_id=atom_id, quelle_id=q["id"], start=offset + span[0], ende=offset + span[1], zitat=zitat) neu = True diff --git a/backend/jsonx.py b/backend/jsonx.py index fb4fafb..3906d98 100644 --- a/backend/jsonx.py +++ b/backend/jsonx.py @@ -13,16 +13,40 @@ _FENCE = re.compile(r"```(?:json)?\s*(.*?)```", re.DOTALL) _UNGUELTIGES_ESCAPE = re.compile(r'\\(?![\"\\/bfnrt]|u[0-9a-fA-F]{4})') +_ESCAPE_JE_ZEICHEN = {"\n": "\\n", "\r": "\\r", "\t": "\\t"} + + +def _iterativ_reparieren(s: str): + """Positionsgenaue Reparatur am ORIGINAL (das Regex-Doubling beschädigt + \\\\x-Folgen): rohe Kontrollzeichen in Strings escapen (Modelle zitieren + mehrzeilige Passagen wörtlich — Markup-Zitat-Regel), ungültige Escapes + doubeln. Iterativ an der jeweils ersten Fehlerstelle, nur im Fehlerfall.""" + for _ in range(300): + try: + return json.loads(s) + except json.JSONDecodeError as e: + if e.pos >= len(s): + return None + if "Invalid control character" in e.msg: + s = s[:e.pos] + _ESCAPE_JE_ZEICHEN.get(s[e.pos], " ") + s[e.pos + 1:] + elif "Invalid \\escape" in e.msg: + s = s[:e.pos] + "\\" + s[e.pos:] + else: + return None + return None + + def _loads(s: str): - """json.loads mit EINER Reparaturstufe für rohe LaTeX-Backslashes. Valides - JSON durchläuft unverändert (Reparatur nur im Fehlerfall).""" + """json.loads mit Reparaturstufen für rohe LaTeX-Backslashes und rohe + Kontrollzeichen in Strings. Valides JSON durchläuft unverändert + (Reparatur nur im Fehlerfall).""" try: return json.loads(s) except ValueError: try: return json.loads(_UNGUELTIGES_ESCAPE.sub(r"\\\\", s)) except ValueError: - return None + return _iterativ_reparieren(s) def parse(text: str): diff --git a/backend/llm.py b/backend/llm.py index ac906e4..a750b39 100644 --- a/backend/llm.py +++ b/backend/llm.py @@ -25,6 +25,11 @@ class LaufPause(Exception): """Infrastruktur erschöpft — Lauf pausieren, nicht weiterrechnen.""" +class ManuellePause(LaufPause): + """Nutzer-Pause: Wartende stoppen, Laufende auslaufen lassen (alle() drainiert + statt zu canceln), dann endet der Lauf als 'paused' — Resume via Start.""" + + class Kontext: """Ein Lauf: wandert durch alle Ebenen, trägt Ledger-Zuordnung.""" @@ -57,9 +62,16 @@ async def _roher_call(key: str, prompt: str, timeout: int, ctx: Kontext, role=role, capabilities=caps) haupt = asyncio.ensure_future(einer(key)) - if not schwelle: - return await haupt - fertig, _ = await asyncio.wait({haupt}, timeout=schwelle) + try: + if not schwelle: + return await haupt + fertig, _ = await asyncio.wait({haupt}, timeout=schwelle) + except asyncio.CancelledError: + # Stop cancelt nur den WARTENDEN — der ensure_future-Task liefe sonst + # verwaist weiter (aak: 10 Extraktions-Calls überlebten Stop, hielten + # API-Slots und zogen Tokens, bis der Timeout sie erlöste) + haupt.cancel() + raise if fertig: return haupt.result() # kein Zwilling gestartet → kein Hedge-Log zwilling = asyncio.ensure_future(einer(f"{key}-h")) @@ -129,6 +141,9 @@ async def call(ctx: Kontext, *, stage: str, template: str, werte: dict, tokens, err = res.tokens, res.err wait_ms = int(res.wait_s * 1000) model = res.model + if res.err == "pausiert": # Nutzer-Pause: kein Retry, Lauf sauber beenden + status = "pause" + raise ManuellePause("manuell pausiert") if res.ok: if erwartet is str: status = "ok" @@ -143,6 +158,9 @@ async def call(ctx: Kontext, *, stage: str, template: str, werte: dict, status = "ok" return daten status, err = "parse", f"unparsbar/falscher Typ: {res.text[:200]}" + elif "stop=max_tokens" in res.err: + status = "cap" # Output-Cap: gleicher Prompt → gleiches Cap, + err = res.err # Neuversuch ist deterministisch sinnlos elif _ist_infra(res.err): status = "infra" else: @@ -159,6 +177,11 @@ async def call(ctx: Kontext, *, stage: str, template: str, werte: dict, wait_ms=wait_ms, tokens=tokens, meta={"err": err[:300]} if err else None) ledger.budget_pruefen(ctx.run_id) + if status == "cap": + # sofort aufgeben — der Aufrufer reagiert strukturell (Extraktion + # halbiert den Chunk); 2 Retries verbrannten sonst ~64k Tokens je Fall + log.warning("%s: Output-Cap (stop=max_tokens) — kein Neuversuch", key) + return None if status == "infra": infra_rest -= 1 if infra_rest < 0: @@ -177,10 +200,15 @@ async def call(ctx: Kontext, *, stage: str, template: str, werte: dict, async def alle(coros) -> list: """gather-Variante, die bei der ersten Exception (LaufPause/Budget) die - Geschwister ABBRICHT — sonst brennen Hunderte laufende Repair-Tasks weiter.""" + Geschwister ABBRICHT — sonst brennen Hunderte laufende Repair-Tasks weiter. + Ausnahme ManuellePause: sanft — Laufende zu Ende laufen lassen (Ergebnisse + landen noch in der DB), Wartende liefern selbst „pausiert".""" tasks = [asyncio.ensure_future(c) for c in coros] try: return await asyncio.gather(*tasks) + except ManuellePause: + await asyncio.gather(*tasks, return_exceptions=True) + raise except BaseException: for t in tasks: if not t.done(): diff --git a/backend/main.py b/backend/main.py index cac6f4b..ce503ee 100644 --- a/backend/main.py +++ b/backend/main.py @@ -131,6 +131,12 @@ async def lauf_stop(topic: str): return {"ok": True} +@app.post("/api/topics/{topic}/pause") +async def lauf_pause(topic: str): + pipeline.lauf_pausieren(topic) + return {"ok": True} + + def _befunde_aktuell(topic: str) -> list[dict]: """Offene Befunde des jeweils jüngsten Laufs je Ebene — in zwei billigen Pässen.""" neueste = db.query( diff --git a/backend/pipeline.py b/backend/pipeline.py index 5355a6c..0ed0a1e 100644 --- a/backend/pipeline.py +++ b/backend/pipeline.py @@ -85,6 +85,13 @@ def aktualisierung_starten(topic: str, ebene: str, tief: bool = False) -> int: return run_id +def lauf_pausieren(topic: str) -> None: + """Sanfte Pause: Wartende + Neue stoppen, Laufende laufen aus. Der Lauf endet + als 'paused', sobald der nächste Call die Pause bemerkt (ManuellePause); + Start setzt normal fort (abbruch_aufheben hebt die Pause auf).""" + agents.pausieren(f"{topic}-") + + def lauf_stoppen(topic: str) -> None: agents.abbrechen(f"{topic}-") task = _laeufe.get(topic) diff --git a/backend/qa.py b/backend/qa.py index 802c74a..e829fb9 100644 --- a/backend/qa.py +++ b/backend/qa.py @@ -26,8 +26,8 @@ GEWICHTE = { "soll_kandidat_offen": 3.0, "atom_ohne_anker": 3.0, "atom_ohne_soll": 1.5, "soll_ohne_atom": 3.0, "atom_ohne_flashcard": 1.5, "atom_ohne_aussage": 1.5, "artefakt_unentschieden": 0.5, - "partition": 3.0, "band": 0.5, "vorwaerts_kante": 1.5, "zyklus": 3.0, - "level_konflikt": 3.0, "level_mix": 3.0, "kapitel_level": 3.0, + "partition": 3.0, "band": 0.5, "ordnung_fallback": 1.5, + "level_mix": 3.0, "kapitel_level": 3.0, "baustein_ohne_kapitel": 3.0, "kapitel_leer": 1.5, "kapitel_fallback": 1.5, "kapitel_zerstueckelt": 1.5, "titel_katalog": 1.5, "quelle_unvollstaendig": 3.0, "section_fehlt": 3.0, "marker_fehlend": 3.0, "marker_fremd": 3.0, diff --git a/backend/struktur.py b/backend/struktur.py index fe1d48f..10aa2bb 100644 --- a/backend/struktur.py +++ b/backend/struktur.py @@ -1,15 +1,13 @@ """Ebene 3: Struktur. Backward Design: Lernziele aus den Atomen je Soll-Punkt -(Zuordnung im selben Call — Lektion 52), dann wird Struktur RECHENBAR: -Baustein-Schnitt = Partition der Ziel-Gruppen ins Größenband, Reihenfolge = -topologische Sortierung des braucht-Graphen. Zyklen bricht ein Judge an der -schwächsten Kante (deterministischer Fallback).""" +(Zuordnung im selben Call — Lektion 52), Baustein-Schnitt = Partition der +Ziel-Gruppen ins Größenband. Reihenfolge = Judge je Level (Dozenten-Blick) +mit Quellpositions-Prior — der braucht-Graph war zu dünn (aak: 77 wirksame +von 385 Kanten) und ordnete faktisch per Zufall; die Maschinerie ist raus.""" -import asyncio import logging from collections import defaultdict import db -import inventar import llm from config import (BAUSTEIN_MAX_ATOME, BAUSTEIN_MIN_ATOME, DURCHGANG, KAPITEL_BAUSTEINE, LEVEL_RANG, ZIELE_CHUNK_ATOME) @@ -24,11 +22,6 @@ def _atome(topic: str) -> list[dict]: " ('gemerged','verworfen') ORDER BY id", (topic,)) -def _braucht_kanten(topic: str) -> list[dict]: - return db.query("SELECT * FROM kanten WHERE topic=? AND art='braucht' AND status='aktiv'", - (topic,)) - - # ── Lernziele ───────────────────────────────────────────────────────────────── async def _ziele_bilden(ctx: llm.Kontext) -> None: @@ -78,119 +71,41 @@ async def _ziele_bilden(ctx: llm.Kontext) -> None: await llm.alle(eine_gruppe(s, g) for s, g in gruppen.items()) -# ── Zyklen brechen ──────────────────────────────────────────────────────────── - -def _finde_zyklus(knoten: list[int], kanten: list[tuple[int, int]]) -> list[tuple[int, int]] | None: - """Ein Zyklus als Kantenliste, oder None (iterative DFS mit Farben).""" - nach = defaultdict(list) - for v, z in kanten: - nach[v].append(z) - farbe = {k: 0 for k in knoten} # 0 weiß, 1 grau, 2 schwarz - eltern: dict[int, int] = {} - for start in knoten: - if farbe[start]: - continue - stapel = [(start, iter(nach[start]))] - farbe[start] = 1 - while stapel: - v, it = stapel[-1] - fortschritt = False - for z in it: - if z not in farbe: - continue - if farbe[z] == 0: - farbe[z] = 1 - eltern[z] = v - stapel.append((z, iter(nach[z]))) - fortschritt = True - break - if farbe[z] == 1: # Rückkante → Zyklus rekonstruieren - pfad = [(v, z)] - k = v - while k != z: - pfad.append((eltern[k], k)) - k = eltern[k] - return pfad - if not fortschritt: - farbe[v] = 2 - stapel.pop() - return None - - -async def _zyklen_brechen(ctx: llm.Kontext) -> None: - while True: - atome = _atome(ctx.topic) - ids = [a["id"] for a in atome] - kanten = [(k["von_atom"], k["zu_atom"]) for k in _braucht_kanten(ctx.topic) - if k["von_atom"] in ids and k["zu_atom"] in ids] - zyklus = _finde_zyklus(ids, kanten) - if not zyklus: - return - je_id = {a["id"]: a for a in atome} - liste = "\n".join(f"{v}→{z}: {je_id[v]['titel']} braucht {je_id[z]['titel']}" - for v, z in zyklus) - res = await llm.call(ctx, stage="zyklus", template="Kanten-Zyklus", - werte={"kanten": liste}, role="judge", - item=f"z{zyklus[0][0]}", erwartet=dict) - wahl = None - if res: - paar = (res.get("von"), res.get("zu")) - if paar in zyklus: - wahl = paar - if wahl is None: # deterministischer Fallback: kleinste Kante - wahl = min(zyklus) - db.execute("UPDATE kanten SET status='gebrochen' WHERE topic=? AND von_atom=?" - " AND zu_atom=? AND art='braucht'", (ctx.topic, wahl[0], wahl[1])) - log.info("Zyklus gebrochen: %s→%s", wahl[0], wahl[1]) - - # ── Bausteine schneiden + ordnen ────────────────────────────────────────────── -def _topo(knoten: list[int], kanten: list[tuple[int, int]], - rang: dict[int, tuple]) -> list[int]: - """Kahn; kanten (v, z) = v braucht z ⇒ z kommt vor v. Ties nach rang (stabil). - Kanten STRIKT auf die Knotenmenge filtern — Kanten zu fremden Atomen zogen - sonst fremde Knoten in die Ausgabe und verdrängten Gruppenmitglieder - (gemessen aak: 22 Atome ohne Baustein).""" - kn = set(knoten) - vorher = defaultdict(set) - nachher = defaultdict(set) - for v, z in kanten: - if v in kn and z in kn: - vorher[v].add(z) - nachher[z].add(v) - offen = sorted([k for k in knoten if not vorher[k]], key=lambda k: rang[k]) - out = [] - erledigt: set[int] = set() - while offen: - k = offen.pop(0) - out.append(k) - erledigt.add(k) - neu = [] - for n in nachher[k]: - if not (vorher[n] - erledigt) and n not in erledigt and n not in offen and n not in neu: - neu.append(n) - offen = sorted(offen + neu, key=lambda kk: rang[kk]) - out += sorted([k for k in knoten if k not in erledigt], key=lambda k: rang[k]) - return out - - def _anker_rang(topic: str) -> dict[int, tuple]: - """Natürliche Quellreihenfolge als Tie-Break: (quelle_id, start) des ersten Ankers.""" + """Natürliche Quellreihenfolge: (quelle_id, start) des ersten Ankers, + stoff-Quellen bevorzugt — Aufgabensammlungen ordnen nicht didaktisch.""" out = {} for a in _atome(topic): - row = db.one("SELECT quelle_id, start FROM anker WHERE atom_id=? AND start>=0" - " ORDER BY quelle_id, start LIMIT 1", (a["id"],)) + row = db.one("SELECT k.quelle_id, k.start FROM anker k" + " JOIN quellen q ON q.id=k.quelle_id" + " WHERE k.atom_id=? AND k.start>=0" + " ORDER BY (q.rolle != 'stoff'), k.quelle_id, k.start LIMIT 1", + (a["id"],)) out[a["id"]] = (row["quelle_id"], row["start"]) if row else (999_999, a["id"]) return out -def _bausteine_schneiden(ctx: llm.Kontext) -> None: +def _baustein_rang(bausteine: list[dict], atome: list[dict], + rang: dict[int, tuple]) -> dict[int, tuple]: + """Quellpositions-Prior je Baustein: MEDIAN der Atom-Ränge — robust gegen + einzelne Merge-Importe mit fremdem Rang (aak: ein „Komplexitätstheorie"-Atom + von Zeichen 464 zog den ETH-Baustein per min() an Position 0).""" + b_rang = {} + for b in bausteine: + raenge = sorted(rang.get(a["id"], (9, 9)) for a in atome + if a["baustein_id"] == b["id"]) + b_rang[b["id"]] = raenge[len(raenge) // 2] if raenge else (9, 9) + return b_rang + + +async def _bausteine_schneiden(ctx: llm.Kontext) -> None: """Deterministisch, idempotent: alte Zuordnung verwerfen, neu schneiden. Gruppen sind (Ziel, Level) — je Level entstehen GETRENNTE Bausteine (E/M/S-Durchgänge). Kleine Gruppen level-intern mergen (das Atom wechselt sein Ziel, Baustein bleibt EIN Ziel + EIN Level), große entlang der - Topo-Reihenfolge splitten ("Teil n").""" + Quellreihenfolge splitten ("Teil n").""" topic = ctx.topic # Aufträge hängen an Baustein-IDs — beim Neuschnitt mitlöschen, sonst Waisen db.execute("DELETE FROM auftraege WHERE baustein_id IN" @@ -198,23 +113,14 @@ def _bausteine_schneiden(ctx: llm.Kontext) -> None: db.execute("DELETE FROM bausteine WHERE topic=?", (topic,)) atome = _atome(topic) rang = _anker_rang(topic) - kanten = [(k["von_atom"], k["zu_atom"]) for k in _braucht_kanten(topic)] gruppen: dict = defaultdict(list) for a in atome: gruppen[(a["ziel_id"], a["level"])].append(a) # kleine Gruppen mergen (kleinste zuerst), nur innerhalb des Levels. - # Partnerwahl thematisch statt per Soll-Punkt — das Soll ist seit der - # Entkopplung reine Checkliste (152 feine Punkte hätten sonst keine - # Merge-Partner): meiste braucht-Kanten zwischen den Gruppen, sonst - # Nachbar in der Quellreihenfolge. - def kanten_score(g1: list[dict], g2: list[dict]) -> int: - ids1 = {a["id"] for a in g1} - ids2 = {a["id"] for a in g2} - return sum(1 for v, z in kanten - if (v in ids1 and z in ids2) or (v in ids2 and z in ids1)) - + # Partnerwahl über Quell-Nähe — das Soll ist seit der Entkopplung reine + # Checkliste (152 feine Punkte hätten sonst keine Merge-Partner). def gruppen_rang(g: list[dict]) -> tuple: return min(rang[a["id"]] for a in g) @@ -235,8 +141,7 @@ def _bausteine_schneiden(ctx: llm.Kontext) -> None: def naehe(pp: tuple) -> tuple: r = gruppen_rang(gruppen[pp]) - return (-kanten_score(gruppen[k], gruppen[pp]), - (abs(r[0] - eigener[0]), abs(r[1] - eigener[1])), + return ((abs(r[0] - eigener[0]), abs(r[1] - eigener[1])), len(gruppen[pp])) p = min(passende, key=naehe) @@ -252,7 +157,7 @@ def _bausteine_schneiden(ctx: llm.Kontext) -> None: # Bausteine anlegen (große Gruppen splitten), Atome zuordnen ziele = {z["id"]: z for z in db.query("SELECT * FROM lernziele WHERE topic=?", (topic,))} for (ziel_id, level), gruppe in gruppen.items(): - ordnung = _topo([a["id"] for a in gruppe], kanten, rang) + ordnung = sorted((a["id"] for a in gruppe), key=lambda i: rang[i]) z = ziele.get(ziel_id, {}) titel = z.get("titel") or z.get("text", "Baustein") # Kurztitel; Zieltext nur Fallback n_teile = -(-len(gruppe) // BAUSTEIN_MAX_ATOME) @@ -273,55 +178,54 @@ def _bausteine_schneiden(ctx: llm.Kontext) -> None: for pos, atom_id in enumerate(teil_ids): db.update("atome", "id", atom_id, baustein_id=b_id, ord=pos) - _bausteine_ordnen(topic, kanten, rang) + await _bausteine_ordnen(ctx) -def _bausteine_ordnen(topic: str, kanten: list[tuple[int, int]], rang: dict) -> None: - """Baustein-DAG aus aggregierten Atom-Kanten, JE LEVEL getrennt geordnet; - ord läuft global fortlaufend E→M→S (eine Achse — Vorwärts-Logik und Kapitel- - Kontiguität bleiben level-blind korrekt). Cross-Level-Kanten zeigen nach der - Konfliktlösung immer auf tiefere Level und sind durch die Blockordnung - automatisch erfüllt. Zyklen deterministisch an der schwächsten Aggregat-Kante - gebrochen (wenigste Atom-Kanten).""" +async def _bausteine_ordnen(ctx: llm.Kontext) -> None: + """Didaktische Ordnung je Level per Judge (Dozenten-Blick); ord läuft global + fortlaufend E→M→S (eine Achse — Vorwärts-Logik und Kapitel-Kontiguität + bleiben level-blind korrekt). Eingabe ist nach Quellpositions-Prior + vorsortiert (das Skript IST vom Dozenten geordnet); der Code erzwingt eine + exakte Permutation — ungültig → 1 Retry → Prior-Ordnung mit Befund + (ordnung_fallback, Muster wie kapitel_fallback).""" + topic = ctx.topic bausteine = db.query("SELECT * FROM bausteine WHERE topic=?", (topic,)) atome = _atome(topic) - je_atom = {a["id"]: a["baustein_id"] for a in atome} - b_level = {b["id"]: b["level"] for b in bausteine} - gewicht: dict = defaultdict(int) - for v, z in kanten: # v braucht z ⇒ Baustein(z) vor Baustein(v) - bv, bz = je_atom.get(v), je_atom.get(z) - if bv and bz and bv != bz and b_level[bv] == b_level[bz]: - gewicht[(bv, bz)] += 1 - # Tie-Break: Quellposition des Bausteins (früheste Belegstelle); Kanten gewinnen - # weiter. Kapitel entstehen erst NACH der Ordnung (Entkopplung vom Soll). - b_rang = {} - for b in bausteine: - b_rang[b["id"]] = min((rang.get(a["id"], (9, 9)) for a in atome - if a["baustein_id"] == b["id"]), default=(9, 9)) + b_rang = _baustein_rang(bausteine, atome, _anker_rang(topic)) + ziele = {z["id"]: z for z in db.query("SELECT * FROM lernziele WHERE topic=?", (topic,))} offset = 0 for level in LEVEL_RANG: - b_ids = [b["id"] for b in bausteine if b["level"] == level] - if not b_ids: + folge = sorted((b for b in bausteine if b["level"] == level), + key=lambda b: b_rang[b["id"]]) + if not folge: continue - b_kanten = {k for k in gewicht if b_level[k[0]] == level} - while True: - zyklus = _finde_zyklus(b_ids, list(b_kanten)) - if not zyklus: - break - schwach = min(zyklus, key=lambda k: (gewicht[k], k)) - b_kanten.discard(schwach) - # Die Atom-Kanten hinter der geopferten Aggregat-Kante stilllegen — sonst - # meldet die QA sie ewig als Vorwärts-Kante (Baustein-Zyklen kann keine - # Ordnung vollständig rückwärts auflösen). - bv, bz = schwach - db.execute("UPDATE kanten SET status='geopfert' WHERE topic=? AND art='braucht'" - " AND status='aktiv'" - " AND von_atom IN (SELECT id FROM atome WHERE baustein_id=?)" - " AND zu_atom IN (SELECT id FROM atome WHERE baustein_id=?)", - (topic, bv, bz)) - for pos, b_id in enumerate(_topo(b_ids, list(b_kanten), b_rang)): - db.update("bausteine", "id", b_id, ord=offset + pos) - offset += len(b_ids) + liste = "\n".join(f"{i + 1}: {b['titel']} — " + f"{ziele.get(b['ziel_id'], {}).get('text', '')}" + for i, b in enumerate(folge)) + soll = list(range(1, len(folge) + 1)) + + async def ordnen(hinweis: str, tag: str) -> list[int] | None: + res = await llm.call(ctx, stage="ordnung", template="Baustein-Ordnung", + role="judge", n=len(folge), item=tag, erwartet=list, + werte={"bausteine": liste, "hinweis": hinweis, + "durchgang": DURCHGANG[level]}) + perm = [x for x in res or [] if isinstance(x, int)] + return perm if sorted(perm) == soll else None + + perm = await ordnen("", f"ordnung-{level}") + if perm is None: + perm = await ordnen( + "ACHTUNG: Der letzte Versuch war ungültig. Antworte mit GENAU" + f" den Nummern 1–{len(folge)}, jede genau einmal.", + f"ordnung-{level}-2") + art = "judge" + if perm is None: # Prior-Ordnung ist der ehrliche Fallback — mit Befund + art = "fallback" + perm = soll + for pos, nr in enumerate(perm): + db.update("bausteine", "id", folge[nr - 1]["id"], + ord=offset + pos, ordnung=art) + offset += len(folge) async def _level_kalibrieren(ctx: llm.Kontext) -> None: @@ -361,29 +265,6 @@ async def _level_kalibrieren(ctx: llm.Kontext) -> None: await llm.alle(einer(z, g) for z, g in gruppen.items()) -def _level_konflikte_loesen(topic: str) -> None: - """Deterministisch: Eine Voraussetzung darf nie über dem Level ihres Nutzers - liegen — sonst fehlt dem Durchgang sein Fundament. Auflösung durch ABSENKEN - der Voraussetzung (sie gehört ins Grundgerüst des Abhängigen); Anheben würde - Kerninhalte aus dem E-Durchgang verdrängen. Fixpunkt: Level fallen monoton, - terminiert nach max. 2 Stufen je Atom.""" - level = {a["id"]: a["level"] for a in _atome(topic)} - kanten = [(k["von_atom"], k["zu_atom"]) for k in _braucht_kanten(topic) - if k["von_atom"] in level and k["zu_atom"] in level] - gesenkt = 0 - geaendert = True - while geaendert: - geaendert = False - for v, z in kanten: # v braucht z ⇒ level(z) ≤ level(v) - if LEVEL_RANG[level[z]] > LEVEL_RANG[level[v]]: - level[z] = level[v] - db.update("atome", "id", z, level=level[v]) - gesenkt += 1 - geaendert = True - if gesenkt: - log.info("Level-Konflikte: %d Voraussetzungen abgesenkt", gesenkt) - - async def _kapitel_bilden(ctx: llm.Kontext) -> None: """Kapitel = kontiguierliche Segmente der geordneten Baustein-Folge, JE DURCHGANG (Level) geschnitten; kapitel.ord läuft global E→M→S weiter. Das @@ -469,12 +350,8 @@ async def _kapitel_bilden(ctx: llm.Kontext) -> None: async def bauen(ctx: llm.Kontext) -> None: ctx.ebene = EBENE await _ziele_bilden(ctx) - await inventar._braucht_fallback(ctx) # Kompositum↔Expansion-braucht-Titel nachziehen, - # bevor Zyklen/Level auf dem Graph rechnen - await _zyklen_brechen(ctx) await _level_kalibrieren(ctx) # VOR dem Schnitt: Level bestimmt die Bausteine - _level_konflikte_loesen(ctx.topic) - _bausteine_schneiden(ctx) + await _bausteine_schneiden(ctx) await _kapitel_bilden(ctx) @@ -503,13 +380,6 @@ def messen(ctx: llm.Kontext) -> list[dict]: befunde.append({"art": "level_mix", "item": str(a["id"]), "detail": f"Atom {a['level']} in Baustein {b['level']}" f" ({b['titel']})"}) - level_von = {a["id"]: a["level"] for a in atome} - for k in _braucht_kanten(ctx.topic): # Voraussetzung nie über dem Nutzer-Level - lv, lz = level_von.get(k["von_atom"]), level_von.get(k["zu_atom"]) - if lv and lz and LEVEL_RANG[lz] > LEVEL_RANG[lv]: - befunde.append({"art": "level_konflikt", "item": str(k["id"]), - "detail": f"Atom {k['von_atom']} ({lv}) braucht" - f" {k['zu_atom']} ({lz})"}) kaps = db.query("SELECT * FROM kapitel WHERE topic=?", (ctx.topic,)) kap_ids = {k["id"] for k in kaps} kap_level = {k["id"]: k["level"] for k in kaps} @@ -541,18 +411,11 @@ def messen(ctx: llm.Kontext) -> list[dict]: if k["ord"] != letzte: befunde.append({"art": "kapitel_zerstueckelt", "item": str(k["id"]), "detail": k["titel"]}) - ord_von = {b["id"]: b["ord"] for b in bausteine} - for k in _braucht_kanten(ctx.topic): - bv = next((a["baustein_id"] for a in atome if a["id"] == k["von_atom"]), None) - bz = next((a["baustein_id"] for a in atome if a["id"] == k["zu_atom"]), None) - if bv and bz and bv != bz and ord_von.get(bz, 0) > ord_von.get(bv, 0): - befunde.append({"art": "vorwaerts_kante", "item": str(k["id"]), - "detail": f"Atom {k['von_atom']} braucht {k['zu_atom']} (später)"}) - ids = [a["id"] for a in atome] - kanten = [(k["von_atom"], k["zu_atom"]) for k in _braucht_kanten(ctx.topic) - if k["von_atom"] in ids and k["zu_atom"] in ids] - if _finde_zyklus(ids, kanten): - befunde.append({"art": "zyklus", "item": "", "detail": "braucht-Graph hat Zyklus"}) + # stiller Ordnungs-Fallback wäre unsichtbarer Qualitätsverlust (wie kapitel_fallback) + for lv in sorted({b["level"] for b in bausteine if b["ordnung"] == "fallback"}): + befunde.append({"art": "ordnung_fallback", "item": lv, + "detail": f"Durchgang {lv}: Judge lieferte keine gültige" + f" Permutation — Quellreihenfolge übernommen"}) return befunde @@ -561,9 +424,7 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool: if not befunde: return False await _ziele_bilden(ctx) # fängt Partition-Lücken - await _zyklen_brechen(ctx) # fängt Zyklen await _level_kalibrieren(ctx) - _level_konflikte_loesen(ctx.topic) # fängt level_konflikt - _bausteine_schneiden(ctx) # Neu-Schnitt fängt Band, Vorwärts-Kanten, level_mix + await _bausteine_schneiden(ctx) # Neu-Schnitt fängt Band, level_mix, ordnung_fallback await _kapitel_bilden(ctx) # fängt Kapitel-Befunde return True diff --git a/backend/textkit.py b/backend/textkit.py index 1f0292b..5ffdc2f 100644 --- a/backend/textkit.py +++ b/backend/textkit.py @@ -47,9 +47,48 @@ def _normiert_mit_map(text: str) -> tuple[str, list[int]]: return "".join(out), mapping +# LaTeX-Kontrollwörter für den Vergleich neutralisieren: das Modell zitiert die +# GERENDERTE Lesart („k ∈ ℕ"), die tex-Quelle enthält Markup ($k \in \mathbb{N}$) +# — Anker-Identität muss unter diesem Rendering invariant sein (aak: 57/146 Atome +# ohne Anker). Griechische Befehle → Unicode-Buchstabe (überlebt die alnum-Faltung +# auf beiden Seiten wie das σ im Zitat); alle anderen Befehle fallen weg — ihre +# gerenderten Symbole (∈, ⊆, ≥) verwirft die alnum-Faltung ebenfalls. +# \begin{itemize} usw. KOMPLETT schlucken (erste Alternative) — sonst bleibt der +# Umgebungsname als Residuum in der Faltung („itemize") und Zitate über +# Listenstarts scheitern; danach normale Befehlswörter +_LATEX_WORT = re.compile(r"\\(?:begin|end)\{[^}]*\}|\\[a-zA-Z]+") +_GRIECHISCH = {g: chr(c) for g, c in ( + ("alpha", 0x3B1), ("beta", 0x3B2), ("gamma", 0x3B3), ("delta", 0x3B4), + ("epsilon", 0x3B5), ("varepsilon", 0x3B5), ("zeta", 0x3B6), ("eta", 0x3B7), + ("theta", 0x3B8), ("iota", 0x3B9), ("kappa", 0x3BA), ("lambda", 0x3BB), + ("mu", 0x3BC), ("nu", 0x3BD), ("xi", 0x3BE), ("pi", 0x3C0), ("rho", 0x3C1), + ("sigma", 0x3C3), ("tau", 0x3C4), ("phi", 0x3C6), ("varphi", 0x3C6), + ("chi", 0x3C7), ("psi", 0x3C8), ("omega", 0x3C9), + ("Gamma", 0x393), ("Delta", 0x394), ("Theta", 0x398), ("Lambda", 0x39B), + ("Xi", 0x39E), ("Pi", 0x3A0), ("Sigma", 0x3A3), ("Phi", 0x3A6), + ("Psi", 0x3A8), ("Omega", 0x3A9))} + + +def _latex_falten(text: str) -> list[tuple[str, int]]: + """→ (Zeichen, Original-Position)-Paare mit neutralisierten LaTeX-Befehlen.""" + paare: list[tuple[str, int]] = [] + pos = 0 + for m in _LATEX_WORT.finditer(text): + for i in range(pos, m.start()): + paare.append((text[i], i)) + if "{" not in m.group(0): # Umgebungen komplett schlucken + for ch in _GRIECHISCH.get(m.group(0)[1:], ""): + paare.append((ch, m.start())) + pos = m.end() + for i in range(pos, len(text)): + paare.append((text[i], i)) + return paare + + def _locker_mit_map(text: str) -> tuple[str, list[int]]: """Nur Buchstaben/Ziffern (NFKD + casefold, Kombinationszeichen raus) + Map auf - Original-Positionen. Für die dritte Matching-Stufe: PDF-Extrakte und Reader variieren + Original-Positionen, LaTeX-Befehle neutralisiert (s. o.). Für die dritte + Matching-Stufe: PDF-Extrakte und Reader variieren Interpunktion, Anführungszeichen, Dash-Typen und Trennungen — der Wortlaut bleibt gleich. NFKD statt NFKC (Lektion 82): pdftotext liefert DEKOMPONIERTE Umlaute („a"+Kombinationspunkt), das LLM-Zitat präkomponierte („ä") — pro Einzelzeichen @@ -57,7 +96,7 @@ def _locker_mit_map(text: str) -> tuple[str, list[int]]: ohne das scheiterte fast jedes deutsche Zitat (aak: 59/96 Atome ohne Anker).""" out: list[str] = [] mapping: list[int] = [] - for i, c in enumerate(text): + for c, i in _latex_falten(text): for cn in unicodedata.normalize("NFKD", c).casefold(): if cn.isalnum() and not unicodedata.combining(cn): out.append(cn) @@ -77,7 +116,8 @@ def _casefold_mit_map(text: str) -> tuple[str, list[int]]: return "".join(out), mapping -_LOCKER_MIN_ZEICHEN = 20 # kürzere Zitate sind alnum-gefaltet nicht mehr eindeutig +_LOCKER_MIN_ZEICHEN = 20 # darunter: nur bei EINDEUTIGEM Vorkommen matchen +_LOCKER_MIN_EINDEUTIG = 10 # darunter: nie (zu wenig Signal, auch wenn einmalig) def finde_zitat(text: str, zitat: str) -> tuple[int, int] | None: @@ -107,11 +147,24 @@ def finde_zitat(text: str, zitat: str) -> tuple[int, int] | None: return start, ende hay_l, map_l = _locker_mit_map(text) ndl_l, _ = _locker_mit_map(zitat) - if len(ndl_l) < _LOCKER_MIN_ZEICHEN: + if len(ndl_l) < _LOCKER_MIN_EINDEUTIG: return None + if len(ndl_l) < _LOCKER_MIN_ZEICHEN: + # Eindeutigkeit war der eigentliche Grund des Längen-Gates — direkt + # prüfen statt Proxy-Länge: kurze Formel-Zitate ($LPT(I)=\OPT(I)$) + # sind oft einmalig und damit sichere Anker. Kein Fuzzy für Kurze. + if hay_l.count(ndl_l) != 1: + return None + pos = hay_l.find(ndl_l) + return map_l[pos], map_l[min(pos + len(ndl_l) - 1, len(map_l) - 1)] + 1 pos = hay_l.find(ndl_l) if pos < 0: - return _fuzzy_span(hay_l, map_l, ndl_l) + span = _fuzzy_span(hay_l, map_l, ndl_l) + # Negations-Guard auch für Stufe 4: eine eingefügte Negation ist nur + # ~5 Zeichen Distanz — semantisch aber das Gegenteil (nie ankern) + if span and negations_menge(text[span[0]:span[1]]) != negations_menge(zitat): + span = None + return span or _wort_span(text, zitat) return map_l[pos], map_l[min(pos + len(ndl_l) - 1, len(map_l) - 1)] + 1 @@ -138,6 +191,108 @@ def _fuzzy_span(hay_l: str, map_l: list[int], ndl_l: str) -> tuple[int, int] | N return map_l[best.start], map_l[min(best.end - 1, len(map_l) - 1)] + 1 +# ── Stufe 5: Wort-Alignment ────────────────────────────────────────────────── +# Modelle zitieren semantisch, nie byte-treu: \cdot → „mal", \sqrt{} → „sqrt()", +# kleine Auslassungen. Auf Zeichen-Ebene sprengt jede dieser Abweichungen die +# 8-%-Schranke — auf WORT-Ebene ist es je 1 Edit. Echte Paraphrasen bleiben +# draußen: dort sind die Wörter selbst anders (Wort-Distanz > Schranke). + +WORT_MIN = 8 # kürzere Zitate: Wort-Matching nicht mehr eindeutig genug +WORT_FEHLERQUOTE = 0.25 # max. Wort-Edits als Anteil der Zitat-Wortzahl + + +def _wort_folge(text: str) -> tuple[list[str], list[tuple[int, int]]]: + """Gefaltete Wörter + (start, ende)-Spans im Original. Gleiche Faltung wie + Stufe 3 (LaTeX neutralisiert, NFKD-alnum), aber mit Wortgrenzen.""" + woerter: list[str] = [] + spans: list[tuple[int, int]] = [] + aktuell: list[str] = [] + w_start = w_ende = -1 + for c, i in _latex_falten(text): + emittiert = False + for cn in unicodedata.normalize("NFKD", c).casefold(): + if cn.isalnum() and not unicodedata.combining(cn): + if not aktuell: + w_start = i + aktuell.append(cn) + w_ende = i + emittiert = True + if not emittiert and aktuell: + woerter.append("".join(aktuell)) + spans.append((w_start, w_ende + 1)) + aktuell = [] + if aktuell: + woerter.append("".join(aktuell)) + spans.append((w_start, w_ende + 1)) + return woerter, spans + + +def _wort_distanz(a: list[str], b: list[str], limit: int) -> int: + """Levenshtein über Wortfolgen, mit Abbruch oberhalb von limit.""" + if abs(len(a) - len(b)) > limit: + return limit + 1 + vorher = list(range(len(b) + 1)) + for i, wa in enumerate(a, 1): + zeile = [i] + minimum = i + for j, wb in enumerate(b, 1): + d = min(vorher[j] + 1, zeile[j - 1] + 1, vorher[j - 1] + (wa != wb)) + zeile.append(d) + minimum = min(minimum, d) + if minimum > limit: + return limit + 1 + vorher = zeile + return vorher[-1] + + +def _negationen(woerter: list[str]) -> frozenset[str]: + return frozenset(w for w in woerter if w in _NEGATION or w.startswith("nicht")) + + +def _wort_span(text: str, zitat: str) -> tuple[int, int] | None: + """Bestes Fenster im Text mit minimaler WORT-Edit-Distanz zum Zitat. + Kandidaten über die seltensten Zitat-Wörter (kein Voll-Scan); Schranken: + Fehlerquote, Eindeutigkeits-Guard, Negations-Gleichheit + (Falsch-Anker >> fehlender Anker).""" + ndl, _ = _wort_folge(zitat) + if len(ndl) < WORT_MIN: + return None + hay, spans = _wort_folge(text) + limit = max(2, int(len(ndl) * WORT_FEHLERQUOTE)) + vorkommen: dict[str, list[int]] = {} + for i, w in enumerate(hay): + vorkommen.setdefault(w, []).append(i) + # seltenste Zitat-Wörter als Kandidaten-Anker (bis 3 Wörter, je ≤ 50 Stellen) + selten = sorted(((len(vorkommen.get(w, [])), idx, w) for idx, w in enumerate(ndl) + if 0 < len(vorkommen.get(w, [])) <= 50))[:3] + starts: set[int] = set() + for _, idx, w in selten: + for p in vorkommen[w]: + for versatz in (-2, -1, 0, 1, 2): + s = p - idx + versatz + if 0 <= s < len(hay): + starts.add(s) + treffer: list[tuple[int, int, int]] = [] # (dist, start, ende) + for s in starts: + for laenge in range(max(WORT_MIN, len(ndl) - 2), len(ndl) + 4): + fenster = hay[s:s + laenge] + if len(fenster) < WORT_MIN: + continue + d = _wort_distanz(ndl, fenster, limit) + if d <= limit: + treffer.append((d, s, s + len(fenster))) + if not treffer: + return None + best = min(treffer) + schranke = best[0] + max(1, int(len(ndl) * 0.08)) + for d, s, e in treffer: + if (e <= best[1] or s >= best[2]) and d <= schranke: + return None # mehrdeutig — zwei getrennte, fast gleich gute Stellen + if _negationen(ndl) != _negationen(hay[best[1]:best[2]]): + return None # Negations-Guard: nie „regulär" auf „nicht regulär" ankern + return spans[best[1]][0], spans[best[2] - 1][1] + + def titel_kern(titel: str) -> str: """Alnum-gefalteter Titel (NFKC + casefold) — gleicher Kern über Schreibvarianten hinweg (U+2011-Dashes, Spacing, Groß/Klein). Dient als diff --git a/backend/transfer.py b/backend/transfer.py index 607d337..a5f674c 100644 --- a/backend/transfer.py +++ b/backend/transfer.py @@ -106,15 +106,18 @@ def importieren(d: dict) -> None: z_map = _einfuegen("lernziele", d["lernziele"], soll_id=s_map) k_map = _einfuegen("kapitel", d["kapitel"]) b_map = _einfuegen("bausteine", d["bausteine"], ziel_id=z_map, kapitel_id=k_map) - # merged_into zeigt auf atome selbst → zweiphasig - a_map = _einfuegen("atome", [{**z, "merged_into": None} for z in d["atome"]], + # merged_into zeigt auf atome selbst → zweiphasig; braucht-Key aus + # Alt-Exporten verwerfen (Spalte existiert nicht mehr) + a_map = _einfuegen("atome", [{**{k: v for k, v in z.items() if k != "braucht"}, + "merged_into": None} for z in d["atome"]], soll_id=s_map, ziel_id=z_map, baustein_id=b_map) for z in d["atome"]: if z.get("merged_into") is not None: db.update("atome", "id", a_map[z["id"]], merged_into=a_map.get(z["merged_into"])) _einfuegen("anker", d["anker"], atom_id=a_map, quelle_id=q_map) - _einfuegen("kanten", d["kanten"], von_atom=a_map, zu_atom=a_map) + _einfuegen("kanten", [z for z in d["kanten"] if z.get("art") != "braucht"], + von_atom=a_map, zu_atom=a_map) ar_map = _einfuegen("artefakte", d["artefakte"], atom_id=a_map) for z in d["leitner"]: # PK ist artefakt_id — kein eigenes id-Feld z = dict(z) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 1009ab9..187eca0 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -100,6 +100,11 @@ async function stoppen() { catch (e) { fehler.value = String(e) } // sonst läuft der Lauf weiter, ohne Signal } +async function pausieren() { + try { fehler.value = ''; await api.pause(topic.value); await stateLaden() } + catch (e) { fehler.value = String(e) } +} + const transferOk = ref(false) // nur im lokalen Dev-Betrieb (Server: 403) const uebertragung = ref('') @@ -186,6 +191,8 @@ function runBadge(run) {