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) {
+
diff --git a/frontend/src/api.js b/frontend/src/api.js
index 848d2e7..a2897da 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -19,6 +19,7 @@ export const api = {
start: (topic, budget) =>
req(`/api/topics/${e(topic)}/start`, { method: 'POST', body: JSON.stringify({ budget }) }),
stop: (topic) => req(`/api/topics/${e(topic)}/stop`, { method: 'POST', body: '{}' }),
+ pause: (topic) => req(`/api/topics/${e(topic)}/pause`, { method: 'POST', body: '{}' }),
sollReset: (topic) => req(`/api/topics/${e(topic)}/soll-reset`, { method: 'POST', body: '{}' }),
vollReset: (topic) => req(`/api/topics/${e(topic)}/voll-reset`, { method: 'POST', body: '{}' }),
setAuto: (topic, ebene, an) =>
diff --git a/templates/Atom-Anker-Fix-Batch.md b/templates/Atom-Anker-Fix-Batch.md
index 088a087..0f93255 100644
--- a/templates/Atom-Anker-Fix-Batch.md
+++ b/templates/Atom-Anker-Fix-Batch.md
@@ -6,6 +6,10 @@ WÖRTLICH (exakt, Zeichen für Zeichen, 5–40 Wörter). Regeln:
- Nur melden, wenn die Passage das Atom wirklich belegt. Steht ein Atom nicht in
diesem Abschnitt: weglassen.
- Nichts paraphrasieren, nichts erfinden. Ids wörtlich übernehmen.
+- Zitat-Disziplin: auch LaTeX-Markup unverändert mitkopieren ($…$, \textsc{{…}},
+ \item) — NICHT in Symbole übersetzen. Bei Listen/mehrschrittigen Verfahren
+ GENAU EINEN zusammenhängenden Ausschnitt zitieren (Einleitungssatz oder ein
+ Listenelement) — niemals mehrere Stellen verbinden oder zusammenfassen.
ATOME:
{atome}
diff --git a/templates/Atom-Extraktion-Aufgaben.md b/templates/Atom-Extraktion-Aufgaben.md
index d70a197..fc2b44f 100644
--- a/templates/Atom-Extraktion-Aufgaben.md
+++ b/templates/Atom-Extraktion-Aufgaben.md
@@ -9,6 +9,7 @@ Regeln:
Aufgabe (konkrete Zahlen, erfundene Namen, Story-Verkleidungen, Punktzahlen).
Beispiel: Die Aufgabe „Zeigen Sie, dass Problem X mit deg ≥ 42 einen Hamiltonkreis…"
ergibt das Atom „NP-Vollständigkeit des Hamiltonkreis-Problems" — nie „X mit deg ≥ 42".
+ KEIN LaTeX-Markup im Titel — Formeln verbal benennen.
- Aufgabeninstanzen, Aufgabennummern, Punktzahlen sind KEINE Atome und gehören in
kein Feld außer dem Zitat.
- „typ": begriff | aussage | verfahren.
@@ -16,15 +17,18 @@ Regeln:
| S = Feinheiten/Spezialfälle. Relativ zum restlichen Stoff einstufen.
- „zitat": die Aufgabenstelle WÖRTLICH kopiert (5–40 Wörter) — sie ist der Beleg,
dass dieses Konzept geübt wird.
+- Zitat-Disziplin: auch LaTeX-Markup unverändert mitkopieren ($…$, \textsc{{…}},
+ \item) — NICHT in Symbole übersetzen. Bei Listen/mehrschrittigen Verfahren
+ GENAU EINEN zusammenhängenden Ausschnitt zitieren (Einleitungssatz oder ein
+ Listenelement) — niemals mehrere Stellen verbinden oder zusammenfassen.
- 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.
QUELLTEXT:
{text}
Antworte NUR mit JSON (keine Code-Fences, keine Prosa, KEINE Datei):
-[{{"titel": "…", "typ": "aussage", "definition": "…", "zitat": "…", "level": "E", "braucht": []}},
- {{"titel": "…", "typ": "verfahren", "definition": "…", "zitat": "…", "level": "M", "braucht": []}}]
+[{{"titel": "…", "typ": "aussage", "definition": "…", "zitat": "…", "level": "E"}},
+ {{"titel": "…", "typ": "verfahren", "definition": "…", "zitat": "…", "level": "M"}}]
diff --git a/templates/Atom-Extraktion.md b/templates/Atom-Extraktion.md
index 620a8b2..7705bf5 100644
--- a/templates/Atom-Extraktion.md
+++ b/templates/Atom-Extraktion.md
@@ -14,22 +14,25 @@ KEINE Atome. Regeln:
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.
+ KEIN Titel; kein Lehrbuch-Oberbegriff, der nicht im Text steht. KEIN
+ LaTeX-Markup im Titel — Formeln verbal benennen.
- „definition": 1–2 Sätze, eigenständig verständlich (dient später dem Dubletten-Vergleich).
- „zitat": die Kernpassage WÖRTLICH aus dem Quelltext kopiert (exakt, Zeichen für Zeichen,
5–40 Wörter). Ohne wörtliches Zitat kein Atom.
+- Zitat-Disziplin: auch LaTeX-Markup unverändert mitkopieren ($…$, \textsc{{…}},
+ \item) — NICHT in Symbole übersetzen. Bei Listen/mehrschrittigen Verfahren
+ GENAU EINEN zusammenhängenden Ausschnitt zitieren (Einleitungssatz oder ein
+ Listenelement) — niemals mehrere Stellen verbinden oder zusammenfassen.
- „level": E = Grundgerüst (Kernbegriffe/Kernaussagen, die man beim ERSTEN Durchgang
durchs Thema braucht) | M = Standardstoff (Sätze, Verfahren, Standardbeweise)
| S = Feinheiten (Spezialfälle, Beweisdetails, Optimierungen). Relativ zum
restlichen Stoff des Auszugs einstufen — Stufen sind Komplexitätsschichten,
keine Bauchgefühl-Schwierigkeit.
-- „braucht": Titel anderer Atome DIESES Auszugs, die man vorher verstanden haben muss
- (nur echte Voraussetzungen, keine bloße Verwandtschaft). Leer, wenn keine.
- Vollständigkeit zählt: JEDES lernbare Konzept des Auszugs wird ein Atom.
QUELLTEXT:
{text}
Antworte NUR mit JSON (keine Code-Fences, keine Prosa, KEINE Datei):
-[{{"titel": "…", "typ": "begriff", "definition": "…", "zitat": "…", "level": "E", "braucht": []}},
- {{"titel": "…", "typ": "aussage", "definition": "…", "zitat": "…", "level": "M", "braucht": ["…"]}}]
+[{{"titel": "…", "typ": "begriff", "definition": "…", "zitat": "…", "level": "E"}},
+ {{"titel": "…", "typ": "aussage", "definition": "…", "zitat": "…", "level": "M"}}]
diff --git a/templates/Atom-Luecke.md b/templates/Atom-Luecke.md
index df8d9db..dbe319a 100644
--- a/templates/Atom-Luecke.md
+++ b/templates/Atom-Luecke.md
@@ -5,6 +5,10 @@ Der QUELLTEXT unten enthält die Belegstelle dieses Punkts. Extrahiere die Atome
(kleinste lernbare Einheiten) GENAU ZU DIESEM PUNKT. Gleiche Regeln wie immer:
- typ: begriff | aussage | verfahren. Beispiele/Aufgaben sind keine Atome.
- „zitat": Kernpassage WÖRTLICH kopiert (5–40 Wörter). Ohne wörtliches Zitat kein Atom.
+- Zitat-Disziplin: auch LaTeX-Markup unverändert mitkopieren ($…$, \textsc{{…}},
+ \item) — NICHT in Symbole übersetzen. Bei Listen/mehrschrittigen Verfahren
+ GENAU EINEN zusammenhängenden Ausschnitt zitieren (Einleitungssatz oder ein
+ Listenelement) — niemals mehrere Stellen verbinden oder zusammenfassen.
- Nur was im Text steht, nichts erfinden. Keine Katalognummern als Titel.
QUELLTEXT:
diff --git a/templates/Atom-Titel-Fix.md b/templates/Atom-Titel-Fix.md
index 5da83c3..3b9b828 100644
--- a/templates/Atom-Titel-Fix.md
+++ b/templates/Atom-Titel-Fix.md
@@ -5,6 +5,8 @@ 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.
+- KEIN LaTeX-Markup ($…$, Backslash-Befehle) — Formeln verbal benennen
+ („Variablen in $\alpha_k$" wird „alpha-Variablen der Kodierung").
- Der Titel muss das Konzept eindeutig benennen (dient als Verweis-Anker).
ATOME:
diff --git a/templates/Baustein-Ordnung.md b/templates/Baustein-Ordnung.md
new file mode 100644
index 0000000..7b33051
--- /dev/null
+++ b/templates/Baustein-Ordnung.md
@@ -0,0 +1,17 @@
+
+Du bist Dozent und legst die Lehr-Reihenfolge dieser Lernbausteine fest.
+Durchgang: {durchgang}
+
+BAUSTEINE (aktuell in der Reihenfolge des Quellmaterials):
+{bausteine}
+
+Regeln:
+- Grundbegriffe und Definitionen VOR ihrer ersten Verwendung.
+- Was ein Baustein inhaltlich voraussetzt, muss vorher behandelt sein.
+- Die Quellreihenfolge ist ein guter Ausgangspunkt — weiche nur mit
+ didaktischem Grund ab.
+{hinweis}
+
+Antworte NUR mit JSON (keine Code-Fences): ALLE Nummern in Lehr-Reihenfolge,
+jede genau einmal.
+[1, 2, 3]
diff --git a/templates/Braucht-Aufloesung.md b/templates/Braucht-Aufloesung.md
deleted file mode 100644
index 74feafc..0000000
--- a/templates/Braucht-Aufloesung.md
+++ /dev/null
@@ -1,23 +0,0 @@
-
-Unten Voraussetzungs-Angaben aus Lern-Atomen. Jede nennt eine PHRASE (ein
-Konzept, das ein Atom voraussetzt) und eine Liste KANDIDATEN-Atome (id, Titel,
-Definition), die dieses Konzept meinen KÖNNTEN.
-
-Entscheide je Angabe: Welches Kandidaten-Atom DEFINIERT genau das Konzept, das die
-Phrase bezeichnet? Antworte mit dessen `atom`-id — oder `null`, wenn keines eindeutig
-passt.
-
-Regeln:
-- Es geht um KONZEPT-Gleichheit, nicht Wortgleichheit. „Approximationsalgorithmus"
- (Kompositum) und „Approximativer Algorithmus" (Phrase) sind dasselbe Konzept.
-- Wähle das Atom, das die Phrase als eigenen GEGENSTAND definiert — nicht ein Atom,
- das sie nur benutzt oder einen Spezialfall behandelt.
-- Genau EIN Treffer je Phrase. Passt keiner klar, oder passen mehrere gleich gut →
- `null`. Eine falsche Kante verschiebt Reihenfolge und Level; im Zweifel `null`.
-- Antworte für JEDE Angabe.
-
-ANGABEN:
-{angaben}
-
-Antworte NUR mit JSON (keine Code-Fences):
-[{{"phrase": 1, "atom": 2065}}, {{"phrase": 2, "atom": null}}]
diff --git a/templates/Kanten-Zyklus.md b/templates/Kanten-Zyklus.md
deleted file mode 100644
index 441c541..0000000
--- a/templates/Kanten-Zyklus.md
+++ /dev/null
@@ -1,12 +0,0 @@
-
-Der Voraussetzungs-Graph enthält einen Zyklus (unten die Kanten, Format
-„von→zu: X braucht Y").
-
-Wähle die SCHWÄCHSTE Kante — die, deren Voraussetzungs-Behauptung am wenigsten
-zwingend ist (eher Verwandtschaft als echte Voraussetzung). Genau eine.
-
-ZYKLUS:
-{kanten}
-
-Antworte NUR mit JSON (keine Code-Fences), mit den ids der gewählten Kante:
-{{"von": 12, "zu": 7}}
diff --git a/tests/test_artefakte.py b/tests/test_artefakte.py
index aec8dfb..2f3f576 100644
--- a/tests/test_artefakte.py
+++ b/tests/test_artefakte.py
@@ -31,9 +31,9 @@ async def test_nur_ohne_sieht_fehlende_flashcard():
"""Ein lebendes Beispiel darf die Flashcard-Nachgenerierung nicht blockieren."""
topic = topic_anlegen("fcfehlt")
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
- status="neu", soll_id=1, braucht=db.j([]))
+ status="neu", soll_id=1)
b = db.insert("atome", topic=topic, titel="Y", typ="begriff", definition="d",
- status="neu", soll_id=1, braucht=db.j([]))
+ status="neu", soll_id=1)
# a: nur ein Beispiel, keine Flashcard → muss nachgeneriert werden
db.insert("artefakte", atom_id=a, typ="beispiel", status="verifiziert",
inhalt=db.j({"text": "Beispiel"}))
@@ -50,7 +50,7 @@ async def test_reverify_ausfall_laesst_kandidat(monkeypatch):
topic = topic_anlegen("reverify")
run = run_anlegen(topic)
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
- status="neu", braucht=db.j([]))
+ status="neu")
k = db.insert("artefakte", atom_id=a, typ="flashcard", status="kandidat",
inhalt=db.j({"frage": "Was ist X?", "antwort": "Y", "text": ""}))
# Fix liefert saubere Karte; Re-Verify antwortet ohne Eintrag zur id → kein Urteil
@@ -68,7 +68,7 @@ async def test_guard_verwirft_kandidat_und_repair_kann_nachlegen():
topic = topic_anlegen("guard")
run = run_anlegen(topic)
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
- status="neu", braucht=db.j([]))
+ status="neu")
k = db.insert("artefakte", atom_id=a, typ="flashcard", status="kandidat",
inhalt=db.j({"frage": "Was steht im Beleg?", "antwort": "x", "text": ""}))
ctx = llm.Kontext(run, topic, "minimax")
@@ -103,7 +103,7 @@ def test_referenziert_quelle_ignoriert_code():
def test_formen_gate_verwirft_kaputte_tabelle():
topic = topic_anlegen("formgate")
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
- status="neu", braucht=db.j([]))
+ status="neu")
gut = db.insert("artefakte", atom_id=a, typ="beispiel", status="kandidat",
inhalt=db.j({"form": "tabelle", "tabelle": "| A | B |\n|---|---|\n| 1 | 2 |"}))
schlecht = db.insert("artefakte", atom_id=a, typ="beispiel", status="kandidat",
@@ -118,7 +118,7 @@ def test_beispiel_einsetzen_tabelle():
topic = topic_anlegen("bsp-tab")
b = db.insert("bausteine", topic=topic, ziel_id=1, titel="B", ord=0, status="neu")
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
- status="neu", baustein_id=b, ord=0, braucht=db.j([]))
+ status="neu", baustein_id=b, ord=0)
md = "| A | B |\n|---|---|\n| 1 | 2 |"
art = db.insert("artefakte", atom_id=a, typ="beispiel", status="verifiziert",
inhalt=db.j({"form": "tabelle", "tabelle": md}))
@@ -141,7 +141,7 @@ def test_code_syntax_gate():
def test_formen_gate_verwirft_kaputten_code():
topic = topic_anlegen("codegate")
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
- status="neu", braucht=db.j([]))
+ status="neu")
gut = db.insert("artefakte", atom_id=a, typ="beispiel", status="kandidat",
inhalt=db.j({"form": "code", "sprache": "python", "code": "print(1+1)"}))
schlecht = db.insert("artefakte", atom_id=a, typ="beispiel", status="kandidat",
@@ -156,7 +156,7 @@ async def test_guide_messen_toter_beispiel_marker():
topic = topic_anlegen("bspqa")
b = db.insert("bausteine", topic=topic, ziel_id=1, titel="B", ord=0, status="neu")
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
- status="neu", baustein_id=b, ord=0, braucht=db.j([]))
+ status="neu", baustein_id=b, ord=0)
db.insert("lernziele", topic=topic, id=1, text="Z", status="aktiv")
db.insert("sections", baustein_id=b, stage="done", text_kompakt="- k",
text_lang=f"\n\n" + "Wort " * 60)
diff --git a/tests/test_auftraege.py b/tests/test_auftraege.py
index 25a3d69..492b065 100644
--- a/tests/test_auftraege.py
+++ b/tests/test_auftraege.py
@@ -14,7 +14,7 @@ def _baustein(topic, lang_extra=""):
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([]))
+ status="neu", baustein_id=b_id)
lang = f"\n" + ("Wort " * 60).strip() + lang_extra
db.insert("sections", baustein_id=b_id, stage="pruefer", text_lang=lang,
text_kompakt="- p")
diff --git a/tests/test_bausteine.py b/tests/test_bausteine.py
index a307b4f..04fead0 100644
--- a/tests/test_bausteine.py
+++ b/tests/test_bausteine.py
@@ -177,7 +177,7 @@ def test_det_auftraege_blockquote_und_artefakt():
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([]))
+ status="neu", baustein_id=b_id)
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$.")
@@ -197,7 +197,7 @@ def test_det_auftraege_neue_checks():
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([]))
+ status="neu", baustein_id=b_id)
wand = "Wort " * 130
lang = (f"\n\n"
"Der Beweis folgt aus Satz 6.25, dazu später mehr.\n\n"
@@ -231,7 +231,7 @@ async def test_fix_fehlschlag_behaelt_auftraege(monkeypatch):
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([]))
+ status="neu", baustein_id=b_id)
db.insert("sections", baustein_id=b_id, stage="fix", text_lang="alt", text_kompakt="")
au = db.insert("auftraege", baustein_id=b_id, art="falsch", detail="y")
# Fix liefert Text OHNE Marker → Marker-Invariante bricht → Fehlschlag-Pfad
@@ -256,9 +256,9 @@ def test_marker_titel_erzeugen_keine_auftraege():
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")
a1 = db.insert("atome", topic=topic, titel="Folge a_n", typ="begriff", definition="d",
- status="neu", baustein_id=b_id, braucht=db.j([]))
+ status="neu", baustein_id=b_id)
a2 = db.insert("atome", topic=topic, titel="A*-Suche", typ="begriff", definition="d",
- status="neu", baustein_id=b_id, braucht=db.j([]))
+ status="neu", baustein_id=b_id)
lang = (f"\n\n"
+ "Sauberer Fließtext ohne Formeln. " * 12)
joined = " ".join(guide._det_auftraege(topic, {"id": b_id, "ord": 0}, lang, ""))
@@ -293,7 +293,7 @@ async def test_auftrag_offen_in_messen():
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([]))
+ status="neu", baustein_id=b_id)
lang = f"\n" + "Wort " * 60
db.insert("sections", baustein_id=b_id, stage="done", text_lang=lang, text_kompakt="- p")
db.insert("auftraege", baustein_id=b_id, art="luecke", detail="Atom-Inhalt fehlt")
@@ -320,7 +320,7 @@ async def test_reparieren_bewegt_false_bei_identischem_text(monkeypatch):
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([]))
+ status="neu", baustein_id=b_id)
lang = f"\n" + ("Wort " * 60).strip() # ohne Trailing-Space
db.insert("sections", baustein_id=b_id, stage="fix", text_lang=lang,
text_kompakt="- p")
@@ -413,7 +413,7 @@ async def test_fix_cap_friert_section_ein(monkeypatch):
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
b = 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, braucht=db.j([]))
+ status="neu", baustein_id=b)
lang = f"\n" + "Wort " * 60
db.insert("sections", baustein_id=b, stage="fix", text_lang=lang, text_kompakt="- p",
fix_versuche=guide.FIX_MAX_VERSUCHE) # schon am Cap
@@ -433,7 +433,7 @@ async def test_marker_platzieren_deterministisch():
topic = topic_anlegen("platz")
b = db.insert("bausteine", topic=topic, ziel_id=1, titel="B", ord=0, status="neu")
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
- status="neu", baustein_id=b, ord=0, braucht=db.j([]))
+ status="neu", baustein_id=b, ord=0)
art = db.insert("artefakte", atom_id=a, typ="beispiel", status="verifiziert",
inhalt=db.j({"form": "tabelle", "tabelle": "| A | B |\n|---|---|\n| 1 | 2 |"}))
lang = f"#### X\n\nErklärung von X.\n\nWeiterer Absatz zum Thema."
@@ -444,3 +444,90 @@ async def test_marker_platzieren_deterministisch():
# idempotent: zweiter Lauf dupliziert nicht
assert guide._marker_platzieren(db.one("SELECT * FROM bausteine WHERE id=?", (b,)),
out).count("