This commit is contained in:
team3
2026-07-13 01:45:45 +02:00
parent 8cedcc87ec
commit 698bba6ccf
8 changed files with 82 additions and 31 deletions

View File

@@ -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.

View File

@@ -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]:

View File

@@ -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()

View File

@@ -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]:

View File

@@ -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)

View File

@@ -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

View File

@@ -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(() => {
</div>
<div class="lm-rest">noch {{ stapel.length }} · Box {{ stapel[0].box }}</div>
</div>
<div v-else class="lm-bestanden"> Abruf bestanden weiter mit </div>
<div v-else class="lm-bestanden"> Abruf durch weiter mit </div>
</section>
<section v-else class="lm-screen lm-ende">
<h1>Fundament abgeschlossen 🎉</h1>
<p class="lm-introtext">Schritt 2 (Fehlersuche) und Schritt 3 (Aufgaben) folgen.</p>
<p class="lm-introtext">Schritt 2 (Anwendung) und Schritt 3 (eigene Aufgaben) folgen.</p>
</section>
</main>

View File

@@ -366,6 +366,18 @@ def test_split_writer_delimited_und_fallback():
assert guide._split_writer("nur text")[1] == "nur text"
def test_jsonx_repariert_latex_escapes():
# rohes LaTeX im JSON-String (\i = ungültiges Escape) → Reparatur parst
assert jsonx.parse('[{"a": "$\\in \\Sigma$"}]') == [{"a": "$\\in \\Sigma$"}]
# \u ohne 4 Hex-Ziffern (\underline) zählt als ungültig → repariert
assert jsonx.parse('{"m": "\\underline{x}"}') == {"m": "\\underline{x}"}
# gültige Escapes bleiben unberührt (Reparatur nur im Fehlerfall)
assert jsonx.parse('{"x": "a\\nb"}') == {"x": "a\nb"}
assert jsonx.parse('{"u": "\\u00e4"}') == {"u": "ä"}
# echter Müll bleibt None
assert jsonx.parse("kein json hier") is None
def test_writer_saeubern_terminator():
import guide
# geleakter `=====`-Terminator (Setext-H1 unter Bullet) + </s> raus