diff --git a/DESIGN.md b/DESIGN.md index 3c32870..a5d1321 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -74,6 +74,8 @@ Note = 10 · (1 − gewichtete Befundquote); 10,0 nur bei null offenen Befunden. ## Nebenläufigkeit Einfache Wellen (`asyncio.gather` + Semaphoren in agents.py), keine Streaming-Engine. +Innerhalb einer Ebene laufen unabhängige KETTEN (gen→verify je Chunk/Baustein) parallel; +Stage-Barrieren nur, wo ein Schritt global rechnet (Dedup, Level, Ordnung, Konsens). Panels = n parallele Calls + Konsensregel. Hedge: Slot ohne Ergebnis nach max(90 s, timeout/2) bekommt einen Zwilling, erstes valides Ergebnis gewinnt. Live-Board trotzdem: jeder Statuswechsel → WebSocket-Broadcast. diff --git a/backend/artefakte.py b/backend/artefakte.py index 697dd9a..9e6f8bd 100644 --- a/backend/artefakte.py +++ b/backend/artefakte.py @@ -248,12 +248,17 @@ def _beispiel_pruefen(inh: dict) -> tuple[bool, dict]: return True, inh -def _formen_gate(topic: str) -> None: +def _formen_gate(topic: str, atom_ids: list[int] | None = None) -> None: """Kandidat-Beispiele durch ihr Form-Gate: Syntax/Struktur ungültig → verworfen - (fail-closed für den Inhalt; ein fehlendes Beispiel ist erlaubt, optional).""" - for b in db.query( - "SELECT ar.* FROM artefakte ar JOIN atome a ON a.id=ar.atom_id" - " WHERE a.topic=? AND ar.typ='beispiel' AND ar.status='kandidat'", (topic,)): + (fail-closed für den Inhalt; ein fehlendes Beispiel ist erlaubt, optional). + atom_ids: optional auf einen Chunk beschränken (Pipeline pro Kette).""" + sql = ("SELECT ar.* FROM artefakte ar JOIN atome a ON a.id=ar.atom_id" + " WHERE a.topic=? AND ar.typ='beispiel' AND ar.status='kandidat'") + params: tuple = (topic,) + if atom_ids: + sql += f" AND ar.atom_id IN ({','.join('?' * len(atom_ids))})" + params += tuple(atom_ids) + for b in db.query(sql, params): inh = db.uj(b["inhalt"], {}) ok, inh_neu = _beispiel_pruefen(inh) if not ok: @@ -262,13 +267,22 @@ def _formen_gate(topic: str) -> None: db.update("artefakte", "id", b["id"], inhalt=db.j(inh_neu)) +async def _kette(ctx: llm.Kontext, chunk: list[dict]) -> None: + """gen→gate→verify EINES Chunks verkettet — der Verify braucht nur die eigenen + Kandidaten, nicht die Generierung fremder Chunks (Stage-Barriere kostete + Wall-Clock: 1 Nachzügler blockierte alle Verifies).""" + ohne = [a for a in chunk if not db.one( + "SELECT id FROM artefakte WHERE atom_id=? AND typ='flashcard'" + " AND status!='verworfen' LIMIT 1", (a["id"],))] + if ohne: + await _generieren(ctx, ohne) + _formen_gate(ctx.topic, [a["id"] for a in chunk]) + await _verifizieren(ctx, chunk) + + async def bauen(ctx: llm.Kontext) -> None: ctx.ebene = EBENE - neu = _chunks(ctx.topic, nur_ohne=True) - await llm.alle(_generieren(ctx, c) for c in neu) - _formen_gate(ctx.topic) # form-spezifische Syntax/Struktur-Gates vor dem Panel - alle = _chunks(ctx.topic, nur_ohne=False) - await llm.alle(_verifizieren(ctx, c) for c in alle) + await llm.alle(_kette(ctx, c) for c in _chunks(ctx.topic, nur_ohne=False)) def messen(ctx: llm.Kontext) -> list[dict]: diff --git a/backend/db.py b/backend/db.py index 3b36cd7..920d94a 100644 --- a/backend/db.py +++ b/backend/db.py @@ -137,6 +137,10 @@ def _init_schema(con: sqlite3.Connection) -> None: con.execute(zusatz) except sqlite3.OperationalError: pass # Spalte existiert schon + # 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'" + " WHERE status IN ('diagramme_fertig', 'fehlersuche_fertig')") con.commit() diff --git a/backend/guide.py b/backend/guide.py index 10488a5..d4f5911 100644 --- a/backend/guide.py +++ b/backend/guide.py @@ -560,8 +560,8 @@ async def bauen(ctx: llm.Kontext) -> None: offen = [b for b in _bausteine(ctx.topic) if b["status"] != "fertig" or (db.one("SELECT stage FROM sections WHERE baustein_id=?", (b["id"],)) or {"stage": "writer"})["stage"] != "done"] - await llm.alle(_karte(ctx, b) for b in offen) - await _kapitel_intros(ctx) + # Intros brauchen nur Kapitel+Ziele (keine Sections) → parallel zu den Karten + await llm.alle([*(_karte(ctx, b) for b in offen), _kapitel_intros(ctx)]) def kapitel_struktur(topic: str) -> list[dict]: diff --git a/backend/jsonx.py b/backend/jsonx.py index 623da36..fb4fafb 100644 --- a/backend/jsonx.py +++ b/backend/jsonx.py @@ -6,6 +6,24 @@ import re _FENCE = re.compile(r"```(?:json)?\s*(.*?)```", re.DOTALL) +# LaTeX in JSON-Strings ("$\in$", "\Sigma") ist ein INVALIDES Escape → json.loads +# wirft (kostete bis zu 29 % Parse-Fehler bei Mathe-Stages). Nur ungültige Escapes +# verdoppeln: gültige (\" \\ \/ \b \f \n \r \t \uXXXX) bleiben; \u ohne 4 +# Hex-Ziffern (\underline) zählt als ungültig. +_UNGUELTIGES_ESCAPE = re.compile(r'\\(?![\"\\/bfnrt]|u[0-9a-fA-F]{4})') + + +def _loads(s: str): + """json.loads mit EINER Reparaturstufe für rohe LaTeX-Backslashes. 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 + def parse(text: str): """→ Objekt oder None. Nie werfen — der Aufrufer entscheidet über Retry. @@ -54,11 +72,8 @@ def _ein_kandidat(text: str): elif c == end_ch: depth -= 1 if depth == 0: - try: - return json.loads(text[start:i + 1]) - except ValueError: - break - try: - return json.loads(text) - except ValueError: - return None + daten = _loads(text[start:i + 1]) + if daten is not None: + return daten + break + return _loads(text) diff --git a/backend/llm.py b/backend/llm.py index b6a3f7c..ac906e4 100644 --- a/backend/llm.py +++ b/backend/llm.py @@ -134,6 +134,11 @@ async def call(ctx: Kontext, *, stage: str, template: str, werte: dict, status = "ok" return res.text daten = jsonx.parse(res.text) + # Modelle lassen bei EINEM Element die Array-Klammern gern weg — + # ein valides Einzel-Dict zählt als 1-elementige Liste (sonst + # brannte jeder 1-Item-Chunk 3 Neuversuche durch). + if erwartet is list and isinstance(daten, dict) and daten: + daten = [daten] # leeres {} bleibt „falscher Typ" (= Ausfall) if daten is not None and isinstance(daten, erwartet): status = "ok" return daten diff --git a/frontend/src/components/Lesemodus.vue b/frontend/src/components/Lesemodus.vue index 1f6fa42..7989e23 100644 --- a/frontend/src/components/Lesemodus.vue +++ b/frontend/src/components/Lesemodus.vue @@ -8,7 +8,7 @@ const emit = defineEmits(['schliessen']) const wurzel = ref(null) const pos = ref(0) -const fertig = ref(new Set()) // baustein-ids mit bestandener Abrufprüfung +const fertig = ref(new Set()) // baustein-ids mit abgeschlossenem Abruf // Flache Screen-Liste aus den Guide-Daten (alle Kapitel/Level in ord): // je Kapitel ein Intro, je Baustein Fundament + Abruf, am Ende ein Abschluss. @@ -58,6 +58,11 @@ function taste(e) { if (e.key === 'ArrowRight') vor() else if (e.key === 'ArrowLeft') zurueck() } +async function markFertig(bausteinId) { + if (fertig.value.has(bausteinId)) return + fertig.value = new Set([...fertig.value, bausteinId]) + await api.bausteinFertig(props.topic, bausteinId).catch(() => {}) +} // ── Abrufprüfung (Karteikarten, Loop wie Ueben.vue) ───────────────────────────── const stapel = ref([]) @@ -67,7 +72,6 @@ const sende = ref(false) async function abrufLaden(bausteinId) { stapel.value = await api.bausteinKarten(props.topic, bausteinId) zeigeAntwort.value = false - if (!stapel.value.length) markFertig(bausteinId) // keine Karten → sofort bestanden } async function antworten(richtig) { if (sende.value || !stapel.value.length) return @@ -80,15 +84,10 @@ async function antworten(richtig) { if (!stapel.value.length) markFertig(screen.value.baustein) } finally { sende.value = false } } -async function markFertig(bausteinId) { - if (fertig.value.has(bausteinId)) return - fertig.value = new Set([...fertig.value, bausteinId]) - await api.bausteinFertig(props.topic, bausteinId).catch(() => {}) -} // Beim Betreten eines Abruf-Screens die Karten des Bausteins laden. watch(screen, (s) => { - if (s.typ === 'abruf' && !fertig.value.has(s.baustein)) abrufLaden(s.baustein) + if (s.typ === 'abruf') abrufLaden(s.baustein) }) // ── Fullscreen + Resume ───────────────────────────────────────────────────────── @@ -101,7 +100,7 @@ onMounted(async () => { try { await wurzel.value?.requestFullscreen() } catch { /* CSS-Overlay reicht als Fallback */ } const stand = await api.lernstand(props.topic).catch(() => []) fertig.value = new Set(stand.filter(s => s.status === 'fertig').map(s => s.baustein_id)) - // Resume: zum Fundament des ersten noch nicht bestandenen Bausteins springen. + // Resume: zum Fundament des ersten noch nicht erledigten Bausteins springen. const idx = screens.value.findIndex(s => s.typ === 'fundament' && !fertig.value.has(s.baustein)) pos.value = idx >= 0 ? idx : screens.value.length - 1 }) @@ -149,12 +148,12 @@ onUnmounted(() => {
Schritt 2 (Fehlersuche) und Schritt 3 (Aufgaben) folgen.
+Schritt 2 (Anwendung) und Schritt 3 (eigene Aufgaben) folgen.