This commit is contained in:
team3
2026-07-13 05:18:55 +02:00
parent 7ee425f7fa
commit 8926f87185
19 changed files with 703 additions and 41 deletions

View File

@@ -10,9 +10,10 @@ import re
import subprocess import subprocess
import tempfile import tempfile
import belege
import db import db
import llm import llm
from config import ARTEFAKT_CHUNK_ATOME, BEISPIEL_FORMEN, VERIFY_PANEL from config import ARTEFAKT_CHUNK_ATOME, AUSSAGEN_JE_ATOM, BEISPIEL_FORMEN, VERIFY_PANEL
log = logging.getLogger("creator2.artefakte") log = logging.getLogger("creator2.artefakte")
@@ -39,8 +40,10 @@ def _referenziert_quelle(inhalt: dict) -> bool:
def _zitate(atom_id: int) -> str: def _zitate(atom_id: int) -> str:
rows = db.query("SELECT zitat FROM anker WHERE atom_id=? AND start>=0", (atom_id,)) # Quelltext-FENSTER um die Anker (belege.py) — nur das Zitat war zu schmal:
return "\n".join(f"> {r['zitat']}" for r in rows) or "(kein Anker)" # inhaltliche Karten zu Verfahren galten als unbelegt und churnten endlos.
bloecke = belege.fenster_liste(atom_id)
return "\n---\n".join(bloecke) or "(kein Anker)"
def _atom_block(a: dict) -> str: def _atom_block(a: dict) -> str:
@@ -267,6 +270,78 @@ def _formen_gate(topic: str, atom_ids: list[int] | None = None) -> None:
db.update("artefakte", "id", b["id"], inhalt=db.j(inh_neu)) db.update("artefakte", "id", b["id"], inhalt=db.j(inh_neu))
async def _aussagen_generieren(ctx: llm.Kontext, chunk: list[dict]) -> None:
"""Prüfungs-PAARE je Atom: wahre Aussage (ankerprüfbare Paraphrase) + falsche
(GENAU EIN Aspekt gekippt — Mutations-Muster auf Satzebene). Der Code baut je
Paar 2 Zeilen; die wahre Aussage ist gratis die Feedback-Erklärung der falschen."""
bloecke = "\n\n".join(_atom_block(a) for a in chunk)
res = await llm.call(ctx, stage="aussagen_gen", template="Aussagen-Generate",
werte={"atome": bloecke, "paare": AUSSAGEN_JE_ATOM},
role="guide", n=len(chunk), item=f"as{chunk[0]['id']}",
erwartet=list)
gueltig = {a["id"] for a in chunk}
zaehler: dict[int, int] = {}
for e in res or []:
if not isinstance(e, dict) or e.get("atom") not in gueltig:
continue
atom_id = e["atom"]
wahr, falsch = str(e.get("wahr", "")).strip(), str(e.get("falsch", "")).strip()
if not wahr or not falsch or wahr == falsch:
continue
if _referenziert_quelle({"text": f"{wahr} {falsch}"}):
continue
i = zaehler.get(atom_id, 0)
zaehler[atom_id] = i + 1
paar = f"{atom_id}-{i}"
db.insert("artefakte", atom_id=atom_id, typ="aussage", status="kandidat",
inhalt=db.j({"text": wahr, "wahr": True, "paar": paar,
"erklaerung": wahr}))
db.insert("artefakte", atom_id=atom_id, typ="aussage", status="kandidat",
inhalt=db.j({"text": falsch, "wahr": False, "paar": paar,
"erklaerung": wahr}))
async def _aussagen_verifizieren(ctx: llm.Kontext, chunk: list[dict]) -> None:
"""Getrennter Maßstab je SOLL (Lehre aus der Fehlersuche): wahre müssen aus den
Belegen folgen, falsche EINDEUTIG widersprechen. Panel einstimmig → verifiziert;
unvollständiges Panel → kandidat lassen (Lektion 20)."""
bloecke, kandidaten = [], []
for a in chunk:
rows = db.query("SELECT * FROM artefakte WHERE atom_id=? AND typ='aussage'"
" AND status='kandidat'", (a["id"],))
if not rows:
continue
zeilen = [f"ATOM {a['id']} Belege:\n{_zitate(a['id'])}"]
for k in rows:
inh = db.uj(k["inhalt"], {})
soll = "wahr" if inh.get("wahr") else "falsch"
zeilen.append(f"AUSSAGE {k['id']} [SOLL: {soll}]: {inh.get('text', '')}")
kandidaten.append(k)
bloecke.append("\n".join(zeilen))
if not kandidaten:
return
stimmen = await llm.panel(ctx, VERIFY_PANEL, stage="aussagen_verify",
template="Aussagen-Verify", role="judge",
werte={"aussagen": "\n\n".join(bloecke)},
n=len(kandidaten), item=f"asv{chunk[0]['id']}",
erwartet=list)
def urteil_fuer(kid: int):
def urteil(stimme):
for e in stimme:
if isinstance(e, dict) and e.get("aussage") == kid:
return bool(e.get("ok"))
return None
return urteil
for k in kandidaten:
votes = [u for s in stimmen if (u := urteil_fuer(k["id"])(s)) is not None]
if len(votes) < VERIFY_PANEL:
continue
db.update("artefakte", "id", k["id"],
status="verifiziert" if all(votes) else "verworfen")
async def _kette(ctx: llm.Kontext, chunk: list[dict]) -> None: async def _kette(ctx: llm.Kontext, chunk: list[dict]) -> None:
"""gen→gate→verify EINES Chunks verkettet — der Verify braucht nur die eigenen """gen→gate→verify EINES Chunks verkettet — der Verify braucht nur die eigenen
Kandidaten, nicht die Generierung fremder Chunks (Stage-Barriere kostete Kandidaten, nicht die Generierung fremder Chunks (Stage-Barriere kostete
@@ -278,6 +353,12 @@ async def _kette(ctx: llm.Kontext, chunk: list[dict]) -> None:
await _generieren(ctx, ohne) await _generieren(ctx, ohne)
_formen_gate(ctx.topic, [a["id"] for a in chunk]) _formen_gate(ctx.topic, [a["id"] for a in chunk])
await _verifizieren(ctx, chunk) await _verifizieren(ctx, chunk)
ohne_aussage = [a for a in chunk if not db.one(
"SELECT id FROM artefakte WHERE atom_id=? AND typ='aussage'"
" AND status!='verworfen' LIMIT 1", (a["id"],))]
if ohne_aussage:
await _aussagen_generieren(ctx, ohne_aussage)
await _aussagen_verifizieren(ctx, chunk)
async def bauen(ctx: llm.Kontext) -> None: async def bauen(ctx: llm.Kontext) -> None:
@@ -295,6 +376,10 @@ def messen(ctx: llm.Kontext) -> list[dict]:
if not any(k["status"] == "verifiziert" for k in karten): if not any(k["status"] == "verifiziert" for k in karten):
befunde.append({"art": "atom_ohne_flashcard", "item": str(a["id"]), befunde.append({"art": "atom_ohne_flashcard", "item": str(a["id"]),
"detail": a["titel"]}) "detail": a["titel"]})
if not db.one("SELECT id FROM artefakte WHERE atom_id=? AND typ='aussage'"
" AND status='verifiziert' LIMIT 1", (a["id"],)):
befunde.append({"art": "atom_ohne_aussage", "item": str(a["id"]),
"detail": a["titel"]})
offen = db.query("SELECT id FROM artefakte WHERE atom_id=? AND status='kandidat'", offen = db.query("SELECT id FROM artefakte WHERE atom_id=? AND status='kandidat'",
(a["id"],)) (a["id"],))
for k in offen: for k in offen:

62
backend/belege.py Normal file
View File

@@ -0,0 +1,62 @@
"""Faktenbasis eines Atoms = Quelltext-FENSTER um seine Anker, nicht nur das Zitat.
Rollen-Trennung: Der Anker identifiziert die Stelle (kurz, verbatim, fuzzy-matchbar);
die Substanz eines Verfahrens (Pseudo-Code, Ablauf) steht in der Quelle DANEBEN.
Nur gegen das Zitat geprüft war jede inhaltliche Karte formal „unbelegt" — die
Top-Verworfen-Atome (GA 73×, LPT 33×) hatten 42150 Zeichen Beleg."""
from functools import lru_cache
import db
from config import BELEG_FENSTER_NACH, BELEG_FENSTER_VOR
@lru_cache(maxsize=64)
def _snapshot(pfad: str) -> str:
# Pfade sind hash-benannt (q-<hash>.md) — neuer Korpus = neuer Pfad, Cache safe
try:
return open(pfad, encoding="utf-8").read()
except OSError:
return ""
def _wortgrenzen(text: str, a: int, b: int) -> str:
aus = text[a:b]
if a > 0 and " " in aus[:80]:
aus = aus.split(" ", 1)[1]
if b < len(text) and " " in aus[-80:]:
aus = aus.rsplit(" ", 1)[0]
return aus.strip()
def fenster_liste(atom_id: int, max_fenster: int = 2) -> list[str]:
"""Beleg-Blöcke des Atoms: Quellumgebung je Anker (überlappende gemerged),
gedeckelt auf max_fenster. Anker ohne Span oder ohne lesbaren Snapshot
fallen auf ihr Zitat zurück — nie schlechter als vorher."""
anker = db.query(
"SELECT k.start, k.ende, k.zitat, q.snapshot FROM anker k"
" JOIN quellen q ON q.id=k.quelle_id WHERE k.atom_id=?", (atom_id,))
spans: dict[str, list[tuple[int, int]]] = {}
fallback: list[str] = []
for k in anker:
text = _snapshot(k["snapshot"]) if k["start"] >= 0 else ""
if not text:
fallback.append(k["zitat"])
continue
a = max(0, k["start"] - BELEG_FENSTER_VOR)
b = min(len(text), k["ende"] + BELEG_FENSTER_NACH)
spans.setdefault(k["snapshot"], []).append((a, b))
bloecke: list[str] = []
for snap, sp in spans.items():
text = _snapshot(snap)
sp.sort()
merged: list[list[int]] = []
for a, b in sp:
if merged and a <= merged[-1][1]:
merged[-1][1] = max(merged[-1][1], b)
else:
merged.append([a, b])
bloecke += [_wortgrenzen(text, a, b) for a, b in merged]
out = bloecke[:max_fenster]
if not out: # kein Span verwertbar → wenigstens die Zitate
out = fallback
return [b for b in out if b]

View File

@@ -93,6 +93,15 @@ BEISPIEL_FORMEN = ("text", "mathe", "code", "tabelle") # Worked-Example-Formen
CODE_SPRACHEN = ("python", "javascript") # Syntax-Gate parse-only (Container: python3, node) CODE_SPRACHEN = ("python", "javascript") # Syntax-Gate parse-only (Container: python3, node)
ARTEFAKT_CHUNK_ATOME = 8 # Atome pro Generate-Call (ein Soll-Punkt, gestückelt) ARTEFAKT_CHUNK_ATOME = 8 # Atome pro Generate-Call (ein Soll-Punkt, gestückelt)
VERIFY_PANEL = 2 # unabhängige Prüfer, einstimmig = verifiziert VERIFY_PANEL = 2 # unabhängige Prüfer, einstimmig = verifiziert
AUSSAGEN_JE_ATOM = 2 # Prüfungs-PAARE je Atom (je Paar: 1 wahre + 1 falsche Aussage)
# Faktenbasis = Quelltext-FENSTER um den Anker (nicht nur das Zitat): der Inhalt
# eines Verfahrens folgt meist NACH seiner Nennung (Pseudo-Code, Ablauf).
BELEG_FENSTER_VOR = 300 # Zeichen vor dem Anker-Span
BELEG_FENSTER_NACH = 1200 # Zeichen nach dem Anker-Span
# ── Prüfung (Lesemodus: 4er-Panels, exakte Teilmenge ankreuzen) ───────────────
PRUEFUNG_PANEL = 4 # Aussagen je Panel (04 davon wahr)
PRUEFUNG_SCHWELLE = 10 # Saldo (+1 je korrektem Panel, 1 sonst) → Baustein frei
# ── Ebene 3: Struktur ───────────────────────────────────────────────────────── # ── Ebene 3: Struktur ─────────────────────────────────────────────────────────
LEVEL_RANG = {"E": 0, "M": 1, "S": 2} # Durchgänge: Grundgerüst / Standard / Feinheiten LEVEL_RANG = {"E": 0, "M": 1, "S": 2} # Durchgänge: Grundgerüst / Standard / Feinheiten
@@ -125,6 +134,8 @@ TIMEOUTS = {
"soll_zuordnung": (300, 8), "soll_zuordnung": (300, 8),
"luecke": (450, 0), "luecke": (450, 0),
"artefakt": (450, 20), "artefakt": (450, 20),
"aussagen_gen": (300, 15),
"aussagen_verify": (200, 10),
"verify": (200, 10), "verify": (200, 10),
"fix": (300, 15), "fix": (300, 15),
"ziele": (300, 6), "ziele": (300, 6),

View File

@@ -82,7 +82,8 @@ CREATE TABLE IF NOT EXISTS kanten(
art TEXT NOT NULL CHECK(art IN ('braucht','verwandt')), status TEXT NOT NULL DEFAULT 'aktiv', art TEXT NOT NULL CHECK(art IN ('braucht','verwandt')), status TEXT NOT NULL DEFAULT 'aktiv',
UNIQUE(von_atom, zu_atom, art)); UNIQUE(von_atom, zu_atom, art));
CREATE TABLE IF NOT EXISTS artefakte( CREATE TABLE IF NOT EXISTS artefakte(
id INTEGER PRIMARY KEY, atom_id INTEGER NOT NULL, typ TEXT NOT NULL CHECK(typ IN ('flashcard','beispiel')), id INTEGER PRIMARY KEY, atom_id INTEGER NOT NULL,
typ TEXT NOT NULL CHECK(typ IN ('flashcard','beispiel','aussage')),
inhalt TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'kandidat'); inhalt TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'kandidat');
CREATE INDEX IF NOT EXISTS idx_artefakte_atom ON artefakte(atom_id); CREATE INDEX IF NOT EXISTS idx_artefakte_atom ON artefakte(atom_id);
CREATE TABLE IF NOT EXISTS lernziele( CREATE TABLE IF NOT EXISTS lernziele(
@@ -145,6 +146,20 @@ def _init_schema(con: sqlite3.Connection) -> None:
# Befunde entfernter Ebenen schließen — sie würden sonst ewig als „offen" im Board stehen # Befunde entfernter Ebenen schließen — sie würden sonst ewig als „offen" im Board stehen
con.execute("UPDATE befunde SET status='veraltet'" con.execute("UPDATE befunde SET status='veraltet'"
" WHERE status='offen' AND ebene IN ('diagramme', 'fehlersuche')") " WHERE status='offen' AND ebene IN ('diagramme', 'fehlersuche')")
# CHECK-Migration: Bestands-DBs kennen typ='aussage' nicht (SQLite kann CHECKs
# nicht ändern) → Tabelle einmalig neu aufbauen. Erkennung über sqlite_master.
alt = con.execute("SELECT sql FROM sqlite_master WHERE type='table'"
" AND name='artefakte'").fetchone()
if alt and "'aussage'" not in alt[0]:
con.executescript(
"ALTER TABLE artefakte RENAME TO artefakte_alt;"
"CREATE TABLE artefakte("
" id INTEGER PRIMARY KEY, atom_id INTEGER NOT NULL,"
" typ TEXT NOT NULL CHECK(typ IN ('flashcard','beispiel','aussage')),"
" inhalt TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'kandidat');"
"INSERT INTO artefakte SELECT * FROM artefakte_alt;"
"DROP TABLE artefakte_alt;"
"CREATE INDEX IF NOT EXISTS idx_artefakte_atom ON artefakte(atom_id);")
con.commit() con.commit()

View File

@@ -167,6 +167,23 @@ def _artefakt_fix(prompt: str):
return {"frage": "Fix-Frage?", "antwort": "Fix-Antwort.", "text": "Fix-Beispiel."} return {"frage": "Fix-Frage?", "antwort": "Fix-Antwort.", "text": "Fix-Beispiel."}
def _aussagen_generate(prompt: str):
from config import AUSSAGEN_JE_ATOM
ids = [int(m) for m in re.findall(r"^ATOM (\d+):", _text_nach(prompt, "ATOME:"),
re.MULTILINE)]
out = []
for i in ids:
for p in range(AUSSAGEN_JE_ATOM):
out.append({"atom": i, "wahr": f"Atom {i} gilt (Aspekt {p}).",
"falsch": f"Atom {i} gilt nie (Aspekt {p})."})
return out
def _aussagen_verify(prompt: str):
ids = [int(m) for m in re.findall(r"AUSSAGE (\d+) \[", prompt)]
return [{"aussage": i, "ok": True} for i in ids]
def _lernziele(prompt: str): def _lernziele(prompt: str):
punkt = re.search(r"Soll-Punkt: „(.+?)“", prompt) or re.search(r'Soll-Punkt: „(.+?)"', prompt) punkt = re.search(r"Soll-Punkt: „(.+?)“", prompt) or re.search(r'Soll-Punkt: „(.+?)"', prompt)
name = punkt.group(1) if punkt else "das Thema" name = punkt.group(1) if punkt else "das Thema"
@@ -276,7 +293,9 @@ _HANDLER = {
"Atom-Luecke": _luecke, "Atom-Titel-Fix": _titel_fix, "Atom-Luecke": _luecke, "Atom-Titel-Fix": _titel_fix,
"Soll-Abgedeckt": lambda p: {"abgedeckt": True}, "Soll-Abgedeckt": lambda p: {"abgedeckt": True},
"Artefakt-Generate": _artefakt_generate, "Artefakt-Verify": _artefakt_verify, "Artefakt-Generate": _artefakt_generate, "Artefakt-Verify": _artefakt_verify,
"Artefakt-Fix": _artefakt_fix, "Lernziele": _lernziele, "Kapitel-Intro": _kapitel_intro, "Artefakt-Fix": _artefakt_fix,
"Aussagen-Generate": _aussagen_generate, "Aussagen-Verify": _aussagen_verify,
"Lernziele": _lernziele, "Kapitel-Intro": _kapitel_intro,
"Kapitel-Schnitt": _kapitel_schnitt, "Level-Kalibrierung": _level_kalibrierung, "Kapitel-Schnitt": _kapitel_schnitt, "Level-Kalibrierung": _level_kalibrierung,
"Kanten-Zyklus": _zyklus, "Braucht-Aufloesung": _braucht_aufloesung, "Kanten-Zyklus": _zyklus, "Braucht-Aufloesung": _braucht_aufloesung,
"Guide-Writer": _writer, "Guide-Pruefer": _pruefer, "Guide-Fix": _fix, "Guide-Writer": _writer, "Guide-Pruefer": _pruefer, "Guide-Fix": _fix,

View File

@@ -17,6 +17,7 @@ from pathlib import Path
import db import db
import jsonx import jsonx
import llm import llm
import belege
import textkit import textkit
from config import DURCHGANG, FIX_MAX_VERSUCHE, LEVEL_RANG, SECTION_WOERTER_PRO_ATOM from config import DURCHGANG, FIX_MAX_VERSUCHE, LEVEL_RANG, SECTION_WOERTER_PRO_ATOM
@@ -146,10 +147,10 @@ def _atome_von(baustein_id: int) -> list[dict]:
" ('gemerged','verworfen') ORDER BY ord", (baustein_id,)) " ('gemerged','verworfen') ORDER BY ord", (baustein_id,))
def _zitate(atom_id: int, max_n: int = 3) -> list[str]: def _zitate(atom_id: int, max_n: int = 2) -> list[str]:
rows = db.query("SELECT zitat FROM anker WHERE atom_id=? AND start>=0 LIMIT ?", # Quelltext-FENSTER um die Anker (belege.py): mit bloßen Kurz-Zitaten schrieb
(atom_id, max_n)) # der Writer „Details sind technisch"-Prosa, weil ihm die Substanz fehlte.
return [r["zitat"] for r in rows] return belege.fenster_liste(atom_id, max_fenster=max_n)
def _beispiel(atom_id: int) -> str: def _beispiel(atom_id: int) -> str:
@@ -163,8 +164,12 @@ def _beispiel(atom_id: int) -> str:
return inh.get("text", "") if inh.get("form", "text") in ("text", "mathe") else "" return inh.get("text", "") if inh.get("form", "text") in ("text", "mathe") else ""
def _quote(block: str) -> str:
return "\n".join(f"> {l}" for l in block.splitlines())
def _atom_paket(a: dict) -> str: def _atom_paket(a: dict) -> str:
zitate = "\n".join(f"> {z}" for z in _zitate(a["id"])) or "(kein Zitat)" zitate = "\n".join(_quote(z) for z in _zitate(a["id"])) or "(kein Zitat)"
beispiel = _beispiel(a["id"]) beispiel = _beispiel(a["id"])
teil = (f"MARKER (exakt so übernehmen): <!-- atom: {a['id']} | {a['titel']} -->\n" teil = (f"MARKER (exakt so übernehmen): <!-- atom: {a['id']} | {a['titel']} -->\n"
f"Definition: {a['definition']}\nBelege (VERBATIM zitierbar):\n{zitate}") f"Definition: {a['definition']}\nBelege (VERBATIM zitierbar):\n{zitate}")
@@ -464,7 +469,7 @@ async def _stage_pruefer(ctx: llm.Kontext, b: dict, tag: str = "") -> str:
if str(alt).startswith("KRITISCH") and alt not in auftraege: if str(alt).startswith("KRITISCH") and alt not in auftraege:
auftraege.append(alt) auftraege.append(alt)
fakten = "\n\n".join(f"Atom {a['id']} ({a['titel']}): {a['definition']}\n" fakten = "\n\n".join(f"Atom {a['id']} ({a['titel']}): {a['definition']}\n"
+ "\n".join(f"> {z}" for z in _zitate(a["id"])) for a in atome) + "\n".join(_quote(z) for z in _zitate(a["id"])) for a in atome)
res = await llm.call(ctx, stage=f"pruefer{tag}", template="Guide-Pruefer", res = await llm.call(ctx, stage=f"pruefer{tag}", template="Guide-Pruefer",
schritt="pruefer", role="judge", n=len(atome), schritt="pruefer", role="judge", n=len(atome),
item=f"b{b['id']}{tag}", item=f"b{b['id']}{tag}",
@@ -701,7 +706,9 @@ async def messen_llm(ctx: llm.Kontext) -> list[dict]:
# qa_hash ERST nach vollständiger Prüfung setzen — bei Call-/Panel-Ausfall # qa_hash ERST nach vollständiger Prüfung setzen — bei Call-/Panel-Ausfall
# bleibt die Section ungeprüft (fail-closed statt fail-open). # bleibt die Section ungeprüft (fail-closed statt fail-open).
atome = _atome_von(b["id"]) atome = _atome_von(b["id"])
fakten = "\n".join(f"- {a['titel']}: " + " | ".join(_zitate(a["id"], 2)) for a in atome) fakten = "\n".join(f"- {a['titel']}: "
+ " | ".join(z.replace("\n", " ") for z in _zitate(a["id"], 2))
for a in atome)
verdacht = await llm.call(ctx, stage="qa_falsch", template="QA-Guide-Falsch", verdacht = await llm.call(ctx, stage="qa_falsch", template="QA-Guide-Falsch",
schritt="qa_judge", role="judge", item=f"b{b['id']}", schritt="qa_judge", role="judge", item=f"b{b['id']}",
werte={"fakten": fakten, "text": sec["text_lang"]}, werte={"fakten": fakten, "text": sec["text_lang"]},

View File

@@ -18,7 +18,8 @@ import db
import guide import guide
import ledger import ledger
import pipeline import pipeline
from config import FRONTEND_DIST, PROJECT_ROOT, topic_name_ok from config import (FRONTEND_DIST, PROJECT_ROOT, PRUEFUNG_PANEL, PRUEFUNG_SCHWELLE,
topic_name_ok)
from ws import hub from ws import hub
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
@@ -52,6 +53,11 @@ class UebenAntwort(BaseModel):
richtig: bool richtig: bool
class PruefungAntwort(BaseModel):
ids: list[int] # die 4 Aussagen des Panels
angekreuzt: list[int] # die als wahr markierten
class AutoFlag(BaseModel): class AutoFlag(BaseModel):
ebene: str ebene: str
an: bool an: bool
@@ -252,6 +258,19 @@ async def ebene_entfernen(topic: str, ebene: str):
return {"ok": True} return {"ok": True}
@app.post("/api/topics/{topic}/ebene/{ebene}/aktualisieren")
async def ebene_aktualisieren(topic: str, ebene: str, tief: bool = False):
if not db.one("SELECT name FROM topics WHERE name=?", (topic,)):
raise HTTPException(404, "unbekanntes Topic")
try:
run_id = pipeline.aktualisierung_starten(topic, ebene, tief=tief)
except ValueError as e:
raise HTTPException(400, str(e))
except RuntimeError as e:
raise HTTPException(409, str(e))
return {"run_id": run_id}
@app.delete("/api/topics/{topic}") @app.delete("/api/topics/{topic}")
async def topic_loeschen(topic: str): async def topic_loeschen(topic: str):
if not db.one("SELECT name FROM topics WHERE name=?", (topic,)): if not db.one("SELECT name FROM topics WHERE name=?", (topic,)):
@@ -315,6 +334,54 @@ def baustein_fertig(topic: str, baustein_id: int):
return {"ok": True} return {"ok": True}
@app.get("/api/topics/{topic}/baustein/{baustein_id}/pruefung")
def pruefung_holen(topic: str, baustein_id: int):
"""4er-Panels aus verifizierten Aussagen der Baustein-Atome. OHNE Wahrheitswert
(Anti-Cheat) und ohne Paar-Partner im selben Panel (verraten sich gegenseitig)."""
import random
rows = db.query(
"SELECT ar.id, ar.inhalt FROM artefakte ar JOIN atome a ON a.id=ar.atom_id"
" WHERE a.topic=? AND a.baustein_id=? AND ar.typ='aussage'"
" AND ar.status='verifiziert'", (topic, baustein_id))
aussagen = [{"id": r["id"], "text": db.uj(r["inhalt"], {}).get("text", ""),
"paar": db.uj(r["inhalt"], {}).get("paar", "")} for r in rows]
random.shuffle(aussagen)
panels: list[list[dict]] = []
for a in aussagen: # greedy: erstes Panel ohne Paar-Konflikt mit Platz
for p in panels:
if len(p) < PRUEFUNG_PANEL and all(x["paar"] != a["paar"] for x in p):
p.append(a)
break
else:
panels.append([a])
return [{"panel": [{"id": x["id"], "text": x["text"]} for x in p]}
for p in panels if len(p) == PRUEFUNG_PANEL]
@app.post("/api/topics/{topic}/pruefung/antwort")
def pruefung_antwort(topic: str, a: PruefungAntwort):
rows = db.query(f"SELECT ar.id, ar.inhalt, at.baustein_id FROM artefakte ar"
f" JOIN atome at ON at.id=ar.atom_id"
f" WHERE ar.id IN ({','.join('?' * len(a.ids))})", tuple(a.ids))
if len(rows) != len(a.ids):
raise HTTPException(404, "unbekannte Aussage")
wahre = {r["id"] for r in rows if db.uj(r["inhalt"], {}).get("wahr")}
richtig = set(a.angekreuzt) == wahre # exakte Teilmenge, sonst falsch
baustein_id = rows[0]["baustein_id"]
row = db.one("SELECT xp FROM lernstand WHERE topic=? AND baustein_id=?",
(topic, baustein_id))
xp = max(0, (row["xp"] if row else 0) + (1 if richtig else -1))
status = "fertig" if xp >= PRUEFUNG_SCHWELLE else "aktiv"
db.execute("INSERT INTO lernstand(topic, baustein_id, status, xp) VALUES(?,?,?,?)"
" ON CONFLICT(topic, baustein_id) DO UPDATE SET xp=excluded.xp,"
" status=excluded.status", (topic, baustein_id, status, xp))
aufloesung = [{"id": r["id"], "wahr": db.uj(r["inhalt"], {}).get("wahr", False),
"erklaerung": db.uj(r["inhalt"], {}).get("erklaerung", "")}
for r in rows]
return {"richtig": richtig, "aufloesung": aufloesung, "xp": xp,
"fertig": xp >= PRUEFUNG_SCHWELLE}
@app.get("/api/runs/{run_id}/kennzahlen") @app.get("/api/runs/{run_id}/kennzahlen")
def kennzahlen(run_id: int): def kennzahlen(run_id: int):
return {"zeilen": ledger.kennzahlen(run_id), "verbraucht": ledger.verbraucht(run_id)} return {"zeilen": ledger.kennzahlen(run_id), "verbraucht": ledger.verbraucht(run_id)}

View File

@@ -52,6 +52,39 @@ def lauf_starten(topic: str, budget: int | None = None) -> int:
return run_id return run_id
def _tief_zuruecksetzen(topic: str, ebene: str) -> None:
"""tief=Re-Verify: Verifiziertes auf 'kandidat' zurückstufen — der BESTEHENDE
Verify-Weg beurteilt dann alles neu (verifiziert oder verworfen = Entfernen).
Nur wo es einen LLM-Verify gibt; korpus (Soll-Freeze) bleibt bewusst außen vor,
inventar/struktur prüfen ohnehin deterministisch alles bei jedem messen."""
if ebene == "artefakte":
db.execute("UPDATE artefakte SET status='kandidat' WHERE status='verifiziert'"
" AND atom_id IN (SELECT id FROM atome WHERE topic=?)", (topic,))
elif ebene == "guide":
db.execute("UPDATE sections SET stage='pruefer', qa_hash='', fix_versuche=0"
" WHERE baustein_id IN (SELECT id FROM bausteine WHERE topic=?)",
(topic,))
def aktualisierung_starten(topic: str, ebene: str, tief: bool = False) -> int:
"""Eine Ebene (und bei Auto-Flags die folgenden) erneut fahren — ohne Reset,
ohne Status-Rückschritt. bauen füllt Fehlendes, messen+Repair fixt Kaputtes.
tief=True: zusätzlich alles Verifizierte erneut durchs Panel (teuer)."""
if ebene not in [e[0] for e in EBENEN]:
raise ValueError(f"unbekannte Ebene: {ebene}")
if topic in _laeufe and not _laeufe[topic].done():
raise RuntimeError("Lauf läuft bereits")
agents.abbruch_aufheben(f"{topic}-")
if tief:
_tief_zuruecksetzen(topic, ebene)
git_hash, dirty = ledger.git_stand()
run_id = db.insert("runs", topic=topic, status="running", git_hash=git_hash,
git_dirty=int(dirty), budget_tokens=RUN_BUDGET_TOKENS,
grund=f"aktualisieren:{ebene}" + (":tief" if tief else ""))
_laeufe[topic] = asyncio.ensure_future(_lauf(topic, run_id, ab=ebene, erzwingen=True))
return run_id
def lauf_stoppen(topic: str) -> None: def lauf_stoppen(topic: str) -> None:
agents.abbrechen(f"{topic}-") agents.abbrechen(f"{topic}-")
task = _laeufe.get(topic) task = _laeufe.get(topic)
@@ -163,13 +196,19 @@ def soll_reset(topic: str) -> None:
db.update("topics", "name", topic, status="korpus") db.update("topics", "name", topic, status="korpus")
async def _lauf(topic: str, run_id: int) -> None: async def _lauf(topic: str, run_id: int, ab: str | None = None,
erzwingen: bool = False) -> None:
"""Normaler Lauf ODER Aktualisierung (ab=Ebene, erzwingen=True): Aktualisieren
fährt fertige Ebenen erneut — bauen füllt Fehlendes, messen prüft Form, Repair
repariert. Nötig, weil der Resume-Skip fertige Ebenen sonst nie wieder anfasst
(Altbestands-Lücke: Katalog-Titel, Aussagen-Nachrüstung, Beleg-Fenster)."""
t = db.one("SELECT * FROM topics WHERE name=?", (topic,)) t = db.one("SELECT * FROM topics WHERE name=?", (topic,))
ctx = llm.Kontext(run_id, topic, t["provider"]) ctx = llm.Kontext(run_id, topic, t["provider"])
start_idx = [e[0] for e in EBENEN].index(ab) if ab else 0
try: try:
for name, modul, marke in EBENEN: for name, modul, marke in EBENEN[start_idx:]:
status = db.one("SELECT status FROM topics WHERE name=?", (topic,))["status"] status = db.one("SELECT status FROM topics WHERE name=?", (topic,))["status"]
if _fertig_ab(status, marke): if not erzwingen and _fertig_ab(status, marke):
continue continue
db.update("runs", "id", run_id, ebene=name) db.update("runs", "id", run_id, ebene=name)
await modul.bauen(ctx) await modul.bauen(ctx)
@@ -190,6 +229,9 @@ async def _lauf(topic: str, run_id: int) -> None:
gate = getattr(modul, "gate", None) gate = getattr(modul, "gate", None)
if gate and (grund_gate := gate(ctx)): if gate and (grund_gate := gate(ctx)):
raise RuntimeError(f"{name}-Gate: {grund_gate}") raise RuntimeError(f"{name}-Gate: {grund_gate}")
# Marker-Schutz: nie zurückdrehen — Aktualisieren einer frühen Ebene
# darf z. B. 'fertig' nicht auf 'artefakte_fertig' zurückstufen.
if not _fertig_ab(status, marke):
db.update("topics", "name", topic, status=marke) db.update("topics", "name", topic, status=marke)
flags = db.uj(db.one("SELECT auto FROM topics WHERE name=?", (topic,))["auto"], {}) flags = db.uj(db.one("SELECT auto FROM topics WHERE name=?", (topic,))["auto"], {})
if not flags.get(name, True) and marke != "fertig": if not flags.get(name, True) and marke != "fertig":

View File

@@ -25,7 +25,7 @@ GEWICHTE = {
"korpus_leer": 3.0, "soll_leer": 3.0, "soll_wenig_belege": 1.5, "korpus_leer": 3.0, "soll_leer": 3.0, "soll_wenig_belege": 1.5,
"soll_kandidat_offen": 3.0, "soll_kandidat_offen": 3.0,
"atom_ohne_anker": 3.0, "atom_ohne_soll": 1.5, "soll_ohne_atom": 3.0, "atom_ohne_anker": 3.0, "atom_ohne_soll": 1.5, "soll_ohne_atom": 3.0,
"atom_ohne_flashcard": 1.5, "artefakt_unentschieden": 0.5, "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, "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, "level_konflikt": 3.0, "level_mix": 3.0, "kapitel_level": 3.0,
"baustein_ohne_kapitel": 3.0, "kapitel_leer": 1.5, "kapitel_fallback": 1.5, "baustein_ohne_kapitel": 3.0, "kapitel_leer": 1.5, "kapitel_fallback": 1.5,

View File

@@ -125,6 +125,13 @@ def importieren(d: dict) -> None:
alt = int(m.group(1)) alt = int(m.group(1))
return m.group(0).replace(m.group(1), str(a_map[alt]), 1) if alt in a_map else m.group(0) return m.group(0).replace(m.group(1), str(a_map[alt]), 1) if alt in a_map else m.group(0)
def beispiel_remap(m: re.Match) -> str:
# Beispiel-Marker tragen ARTEFAKT-ids — ohne Rewrite zeigt der Marker nach
# dem Import auf eine fremde Zeile (latent: fiel erst auf, als zusätzliche
# Artefakt-Typen die id-Sequenz verschoben)
alt = int(m.group(1))
return m.group(0).replace(m.group(1), str(ar_map[alt]), 1) if alt in ar_map else m.group(0)
def auftrag_remap(m: re.Match) -> str: def auftrag_remap(m: re.Match) -> str:
alt = int(m.group(2)) alt = int(m.group(2))
return f"{m.group(1)}{a_map[alt]}" if alt in a_map else m.group(0) return f"{m.group(1)}{a_map[alt]}" if alt in a_map else m.group(0)
@@ -135,6 +142,7 @@ def importieren(d: dict) -> None:
for feld in ("text_lang", "text_kompakt"): for feld in ("text_lang", "text_kompakt"):
if z.get(feld): if z.get(feld):
z[feld] = re.sub(r"<!--\s*atom:\s*(\d+)\s*\|", marker_remap, z[feld]) z[feld] = re.sub(r"<!--\s*atom:\s*(\d+)\s*\|", marker_remap, z[feld])
z[feld] = re.sub(r"<!--\s*beispiel:\s*(\d+)\s*-->", beispiel_remap, z[feld])
# offene Fix-Aufträge nennen Atom-IDs im Klartext („Marker für Atom 123 # offene Fix-Aufträge nennen Atom-IDs im Klartext („Marker für Atom 123
# fehlt") — mitremappen, sonst fixt der Prüfer gegen tote IDs # fehlt") — mitremappen, sonst fixt der Prüfer gegen tote IDs
if z.get("befunde"): if z.get("befunde"):

View File

@@ -25,6 +25,9 @@ export const api = {
req(`/api/topics/${e(topic)}/auto`, { method: 'PATCH', body: JSON.stringify({ ebene, an }) }), req(`/api/topics/${e(topic)}/auto`, { method: 'PATCH', body: JSON.stringify({ ebene, an }) }),
ebeneEntfernen: (topic, ebene) => ebeneEntfernen: (topic, ebene) =>
req(`/api/topics/${e(topic)}/ebene/${e(ebene)}/entfernen`, { method: 'POST', body: '{}' }), req(`/api/topics/${e(topic)}/ebene/${e(ebene)}/entfernen`, { method: 'POST', body: '{}' }),
ebeneAktualisieren: (topic, ebene, tief = false) =>
req(`/api/topics/${e(topic)}/ebene/${e(ebene)}/aktualisieren${tief ? '?tief=1' : ''}`,
{ method: 'POST', body: '{}' }),
topicLoeschen: (topic) => req(`/api/topics/${e(topic)}`, { method: 'DELETE' }), topicLoeschen: (topic) => req(`/api/topics/${e(topic)}`, { method: 'DELETE' }),
state: (topic) => req(`/api/topics/${e(topic)}/state`), state: (topic) => req(`/api/topics/${e(topic)}/state`),
guide: (topic) => req(`/api/topics/${e(topic)}/guide`), guide: (topic) => req(`/api/topics/${e(topic)}/guide`),
@@ -36,6 +39,11 @@ export const api = {
lernstand: (topic) => req(`/api/topics/${e(topic)}/lernstand`), lernstand: (topic) => req(`/api/topics/${e(topic)}/lernstand`),
bausteinFertig: (topic, bausteinId) => bausteinFertig: (topic, bausteinId) =>
req(`/api/topics/${e(topic)}/baustein/${e(bausteinId)}/fertig`, { method: 'POST', body: '{}' }), req(`/api/topics/${e(topic)}/baustein/${e(bausteinId)}/fertig`, { method: 'POST', body: '{}' }),
pruefung: (topic, bausteinId) =>
req(`/api/topics/${e(topic)}/baustein/${e(bausteinId)}/pruefung`),
pruefungAntwort: (topic, ids, angekreuzt) =>
req(`/api/topics/${e(topic)}/pruefung/antwort`,
{ method: 'POST', body: JSON.stringify({ ids, angekreuzt }) }),
kennzahlen: (runId) => req(`/api/runs/${e(runId)}/kennzahlen`), kennzahlen: (runId) => req(`/api/runs/${e(runId)}/kennzahlen`),
transferInfo: () => req('/api/transfer'), transferInfo: () => req('/api/transfer'),
transfer: (topic, richtung) => transfer: (topic, richtung) =>

View File

@@ -104,6 +104,14 @@ async function menueGenerieren() {
catch (e) { emit('fehler', String(e)) } // sonst schlägt der Start still fehl catch (e) { emit('fehler', String(e)) } // sonst schlägt der Start still fehl
} }
async function menueAktualisieren(tief = false) {
const m = menue.value
menue.value = null
if (!m || props.state?.run?.status === 'running') return
try { await api.ebeneAktualisieren(props.topic, m.key, tief); emit('reload') }
catch (e) { emit('fehler', String(e)) }
}
async function menueEntfernen() { async function menueEntfernen() {
const m = menue.value const m = menue.value
menue.value = null menue.value = null
@@ -167,6 +175,12 @@ const k = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1000 ? `${Math.ro
<button :disabled="!generierenErlaubt(menue.idx)" @click.stop="menueGenerieren"> <button :disabled="!generierenErlaubt(menue.idx)" @click.stop="menueGenerieren">
Generieren Generieren
</button> </button>
<button :disabled="state?.run?.status === 'running'" @click.stop="menueAktualisieren(false)">
Aktualisieren (prüfen + auffüllen)
</button>
<button :disabled="state?.run?.status === 'running'" @click.stop="menueAktualisieren(true)">
Tief aktualisieren (alles neu verifizieren)
</button>
<button class="rot" @click.stop="menueEntfernen"> <button class="rot" @click.stop="menueEntfernen">
{{ menue.titel }}" + folgende Ebenen entfernen {{ menue.titel }}" + folgende Ebenen entfernen
</button> </button>

View File

@@ -6,12 +6,14 @@ import { render, lesestat } from '../markdown.js'
const props = defineProps({ daten: Object, topic: String }) const props = defineProps({ daten: Object, topic: String })
const emit = defineEmits(['schliessen']) const emit = defineEmits(['schliessen'])
const SCHWELLE = 10 // Anzeige; die Wahrheit entscheidet der Server (PRUEFUNG_SCHWELLE)
const wurzel = ref(null) const wurzel = ref(null)
const pos = ref(0) const pos = ref(0)
const fertig = ref(new Set()) // baustein-ids mit abgeschlossenem Abruf const fertig = ref(new Set()) // baustein-ids mit bestandener Prüfung
const xpMap = ref({}) // baustein-id → Saldo
// Flache Screen-Liste aus den Guide-Daten (alle Kapitel/Level in ord): // Screen-Liste: je Kapitel ein Intro, je Baustein Fundament → Abruf → Prüfung.
// je Kapitel ein Intro, je Baustein Fundament + Abruf, am Ende ein Abschluss.
const screens = computed(() => { const screens = computed(() => {
const out = [] const out = []
const kaps = props.daten?.kapitel || [] const kaps = props.daten?.kapitel || []
@@ -20,6 +22,7 @@ const screens = computed(() => {
for (const s of kap.sections) { for (const s of kap.sections) {
out.push({ typ: 'fundament', baustein: s.baustein, titel: s.titel, lang: s.lang }) out.push({ typ: 'fundament', baustein: s.baustein, titel: s.titel, lang: s.lang })
out.push({ typ: 'abruf', baustein: s.baustein, titel: s.titel }) out.push({ typ: 'abruf', baustein: s.baustein, titel: s.titel })
out.push({ typ: 'pruefung', baustein: s.baustein, titel: s.titel })
} }
}) })
out.push({ typ: 'ende' }) out.push({ typ: 'ende' })
@@ -45,26 +48,20 @@ function fundamentHtml(s) {
return htmlCache.get(s.baustein) return htmlCache.get(s.baustein)
} }
// ── Navigation ─────────────────────────────────────────────────────────────── // ── Navigation + Prüfungs-Gate ─────────────────────────────────────────────────
// Kein Zwang, alle Karten zu sehen: Weiter ist immer frei. Verlässt man einen // Lesen + Abruf sind frei. Nur die Prüfung sperrt: weiter erst ab Saldo ≥ Schwelle.
// Abruf-Screen vorwärts, gilt der Baustein als erledigt (Fortschritt persistiert). // 0 Panels sperren NICHT (leerer Baustein darf den Guide nie blockieren).
function vor() { const gesperrt = computed(() =>
if (pos.value >= screens.value.length - 1) return screen.value.typ === 'pruefung' && panels.value.length > 0
if (screen.value.typ === 'abruf') markFertig(screen.value.baustein) && !fertig.value.has(screen.value.baustein))
pos.value++ function vor() { if (!gesperrt.value && pos.value < screens.value.length - 1) pos.value++ }
}
function zurueck() { if (pos.value > 0) pos.value-- } function zurueck() { if (pos.value > 0) pos.value-- }
function taste(e) { function taste(e) {
if (e.key === 'ArrowRight') vor() if (e.key === 'ArrowRight') vor()
else if (e.key === 'ArrowLeft') zurueck() 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) ───────────────────────────── // ── Abrufprüfung (Karteikarten) ────────────────────────────────────────────────
const stapel = ref([]) const stapel = ref([])
const zeigeAntwort = ref(false) const zeigeAntwort = ref(false)
const sende = ref(false) const sende = ref(false)
@@ -81,26 +78,73 @@ async function antworten(richtig) {
await api.antwort(karte.id, richtig) await api.antwort(karte.id, richtig)
stapel.value = richtig ? stapel.value.slice(1) : [...stapel.value.slice(1), karte] stapel.value = richtig ? stapel.value.slice(1) : [...stapel.value.slice(1), karte]
zeigeAntwort.value = false zeigeAntwort.value = false
if (!stapel.value.length) markFertig(screen.value.baustein)
} finally { sende.value = false } } finally { sende.value = false }
} }
// Beim Betreten eines Abruf-Screens die Karten des Bausteins laden. // ── Prüfung (4er-Panels, exakte Teilmenge ankreuzen) ───────────────────────────
const panels = ref([])
const panelIdx = ref(0)
const auswahl = ref(new Set())
const aufloesung = ref(null) // nach dem Prüfen: [{id, wahr, erklaerung}]
const warRichtig = ref(false)
const saldo = ref(0)
const panel = computed(() => panels.value[panelIdx.value]?.panel || [])
async function pruefungLaden(bausteinId) {
panels.value = await api.pruefung(props.topic, bausteinId)
panelIdx.value = 0; auswahl.value = new Set(); aufloesung.value = null
saldo.value = xpMap.value[bausteinId] || 0
if (!panels.value.length && !fertig.value.has(bausteinId)) {
// keine Aussagen generiert → Baustein nicht blockieren, als erledigt werten
fertig.value = new Set([...fertig.value, bausteinId])
await api.bausteinFertig(props.topic, bausteinId).catch(() => {})
}
}
function toggle(id) {
if (aufloesung.value) return
const s = new Set(auswahl.value)
s.has(id) ? s.delete(id) : s.add(id)
auswahl.value = s
}
async function pruefen() {
if (aufloesung.value || sende.value) return
sende.value = true
try {
const ids = panel.value.map(x => x.id)
const r = await api.pruefungAntwort(props.topic, ids, [...auswahl.value])
aufloesung.value = r.aufloesung
warRichtig.value = r.richtig
saldo.value = r.xp
xpMap.value = { ...xpMap.value, [screen.value.baustein]: r.xp }
if (r.fertig) fertig.value = new Set([...fertig.value, screen.value.baustein])
} finally { sende.value = false }
}
async function naechstesPanel() {
aufloesung.value = null; auswahl.value = new Set()
if (panelIdx.value < panels.value.length - 1) panelIdx.value++
else await pruefungLaden(screen.value.baustein) // neu mischen, Nachschub
}
function urteil(id) {
const a = (aufloesung.value || []).find(x => x.id === id)
return a ? a.wahr : null
}
watch(screen, (s) => { watch(screen, (s) => {
if (s.typ === 'abruf') abrufLaden(s.baustein) if (s.typ === 'abruf') abrufLaden(s.baustein)
else if (s.typ === 'pruefung') pruefungLaden(s.baustein)
}) })
// ── Fullscreen + Resume ──────────────────────────────────────────────────────── // ── Fullscreen + Resume ────────────────────────────────────────────────────────
function schliessen() { function schliessen() {
if (document.fullscreenElement) document.exitFullscreen().catch(() => {}) if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
emit('schliessen') emit('schliessen')
} }
onMounted(async () => { onMounted(async () => {
window.addEventListener('keydown', taste) window.addEventListener('keydown', taste)
try { await wurzel.value?.requestFullscreen() } catch { /* CSS-Overlay reicht als Fallback */ } try { await wurzel.value?.requestFullscreen() } catch { /* CSS-Overlay reicht */ }
const stand = await api.lernstand(props.topic).catch(() => []) const stand = await api.lernstand(props.topic).catch(() => [])
fertig.value = new Set(stand.filter(s => s.status === 'fertig').map(s => s.baustein_id)) fertig.value = new Set(stand.filter(s => s.status === 'fertig').map(s => s.baustein_id))
// Resume: zum Fundament des ersten noch nicht erledigten Bausteins springen. xpMap.value = Object.fromEntries(stand.map(s => [s.baustein_id, s.xp]))
const idx = screens.value.findIndex(s => s.typ === 'fundament' && !fertig.value.has(s.baustein)) const idx = screens.value.findIndex(s => s.typ === 'fundament' && !fertig.value.has(s.baustein))
pos.value = idx >= 0 ? idx : screens.value.length - 1 pos.value = idx >= 0 ? idx : screens.value.length - 1
}) })
@@ -151,6 +195,32 @@ onUnmounted(() => {
<div v-else class="lm-bestanden"> Abruf durch weiter mit </div> <div v-else class="lm-bestanden"> Abruf durch weiter mit </div>
</section> </section>
<section v-else-if="screen.typ === 'pruefung'" class="lm-screen lm-pruefung">
<div class="lm-label">Prüfung · {{ screen.titel }} · Saldo {{ saldo }} / {{ SCHWELLE }}</div>
<template v-if="panel.length">
<h3 class="pf-frage">Was gilt? <span class="pf-hinweis">(0{{ panel.length }} Aussagen können wahr sein)</span></h3>
<div class="pf-panel">
<label v-for="x in panel" :key="x.id" class="pf-aussage"
:class="{ gewaehlt: auswahl.has(x.id),
'pf-wahr': urteil(x.id) === true,
'pf-falsch2': urteil(x.id) === false }"
@click.prevent="toggle(x.id)">
<input type="checkbox" :checked="auswahl.has(x.id)" @click.stop="toggle(x.id)" />
<span class="markdown" v-html="render(x.text)"></span>
</label>
</div>
<div v-if="aufloesung" class="pf-feedback" :class="warRichtig ? 'pf-fb-ok' : 'pf-fb-bad'">
{{ warRichtig ? ' Panel korrekt (+1)' : ' Nicht exakt (1)' }}
</div>
<div class="pf-akt">
<button v-if="!aufloesung" @click="pruefen">Prüfen</button>
<button v-else @click="naechstesPanel">Nächstes Panel </button>
<span v-if="fertig.has(screen.baustein)" class="lm-bestanden" style="margin: 0"> bestanden</span>
</div>
</template>
<div v-else class="lm-bestanden">Keine Prüfungs-Aussagen für diesen Baustein weiter mit </div>
</section>
<section v-else class="lm-screen lm-ende"> <section v-else class="lm-screen lm-ende">
<h1>Fundament abgeschlossen 🎉</h1> <h1>Fundament abgeschlossen 🎉</h1>
<p class="lm-introtext">Schritt 2 (Anwendung) und Schritt 3 (eigene Aufgaben) folgen.</p> <p class="lm-introtext">Schritt 2 (Anwendung) und Schritt 3 (eigene Aufgaben) folgen.</p>
@@ -159,7 +229,9 @@ onUnmounted(() => {
<footer class="lm-fuss"> <footer class="lm-fuss">
<button :disabled="pos === 0" @click="zurueck"> Zurück</button> <button :disabled="pos === 0" @click="zurueck"> Zurück</button>
<button :disabled="pos >= screens.length - 1" @click="vor">Weiter </button> <button :disabled="gesperrt || pos >= screens.length - 1" @click="vor">
{{ gesperrt ? `Saldo ${saldo} / ${SCHWELLE} weiter prüfen` : 'Weiter ' }}
</button>
</footer> </footer>
</div> </div>
</template> </template>

View File

@@ -323,3 +323,24 @@ table.kennzahlen th { color: var(--dim); }
.lm-rest { margin-top: 16px; color: var(--dim); font-size: 13px; } .lm-rest { margin-top: 16px; color: var(--dim); font-size: 13px; }
.lm-bestanden { margin-top: 8vh; font-size: 18px; color: var(--akzent); text-align: center; } .lm-bestanden { margin-top: 8vh; font-size: 18px; color: var(--akzent); text-align: center; }
.lm-ende { text-align: center; padding-top: 14vh; } .lm-ende { text-align: center; padding-top: 14vh; }
/* Prüfung (4er-Panels) */
.pf-frage { margin: 4vh 0 14px; }
.pf-hinweis { font-size: 14px; color: var(--dim); font-weight: 400; }
.pf-panel { display: flex; flex-direction: column; gap: 10px; }
.pf-aussage {
display: flex; gap: 12px; align-items: flex-start; cursor: pointer;
padding: 12px 14px; border: 1px solid var(--rand); border-radius: 10px;
background: var(--karte);
}
.pf-aussage input { margin-top: 5px; flex: 0 0 auto; }
.pf-aussage.gewaehlt { border-color: var(--akzent); }
.pf-aussage.pf-wahr { border-color: #2f7d32; background: color-mix(in srgb, #2f7d32 14%, var(--karte)); }
.pf-aussage.pf-falsch2 { border-color: #a23b3b; background: color-mix(in srgb, #a23b3b 12%, var(--karte)); }
.pf-feedback { margin-top: 14px; padding: 10px 14px; border-radius: 8px; font-size: 15px; }
.pf-fb-ok { background: color-mix(in srgb, #2f7d32 18%, transparent); }
.pf-fb-bad { background: color-mix(in srgb, #a23b3b 18%, transparent); }
.pf-akt { display: flex; gap: 14px; align-items: center; margin-top: 16px; }
.pf-akt button {
padding: 10px 22px; border: 1px solid var(--rand); border-radius: 8px;
background: var(--panel); color: var(--text); cursor: pointer; font-size: 15px;
}

View File

@@ -0,0 +1,22 @@
<!-- template:Aussagen-Generate -->
Unten Atome eines Lernthemas (Titel, Definition, Beleg-Zitate). Erzeuge pro Atom
{paare} PAARE von Prüfungs-Aussagen: je eine WAHRE und eine FALSCHE.
Regeln:
- WAHRE Aussage: eine präzise, eigenständig lesbare Paraphrase aus Definition/Belegen.
Jedes Wort muss aus der Faktenbasis ableitbar sein — nichts erfinden.
- FALSCHE Aussage: kippe GENAU EINEN Aspekt der wahren (Quantor, Richtung einer
Beziehung, Schranke/Wert, Komplexitätsklasse, Bedingung). Sie muss eine PLAUSIBLE
Fehlvorstellung sein — kein offensichtlicher Strohmann, keine bloße Verneinung
(„nicht"), kein Themenwechsel. Der Rest des Satzes bleibt korrekt.
- Beide Aussagen stehen für sich: keine Verweise auf „Beleg/Quelle/Skript/Aufgabe",
kein „laut Text". Mathe in $…$ (KaTeX).
- Die Paare eines Atoms decken VERSCHIEDENE Aspekte ab (nicht zweimal dieselbe Stelle).
- Atom-ids wörtlich übernehmen, kein Atom auslassen.
ATOME:
{atome}
Antworte NUR mit JSON (keine Code-Fences, keine Prosa):
[{{"atom": 12, "wahr": "…", "falsch": "…"}},
{{"atom": 12, "wahr": "…", "falsch": "…"}}]

View File

@@ -0,0 +1,20 @@
<!-- template:Aussagen-Verify -->
Unten Prüfungs-Aussagen mit den Beleg-Zitaten ihres Atoms. Jede Aussage trägt ihr
SOLL: wahr oder falsch. Der Lernende muss später entscheiden — die Ground-Truth
muss deshalb eindeutig stimmen.
Prüfe jede Aussage GEGEN DIE BELEGE:
- SOLL wahr: ok=true nur, wenn die Aussage aus den Belegen folgt (Paraphrase zählt).
ok=false, wenn sie den Belegen widerspricht, übertreibt oder Unbelegtes ergänzt.
- SOLL falsch: ok=true nur, wenn die Aussage den Belegen EINDEUTIG widerspricht.
ok=false, wenn sie versehentlich doch wahr, unentscheidbar oder nur vage ist.
- ok=false, wenn die Aussage auf Beleg/Quelle/Aufgabe verweist oder ohne den
Quelltext unverständlich ist.
- Formulierungsgeschmack ist KEIN Mangel.
- Antworte für JEDE Aussage (id wörtlich übernehmen).
AUSSAGEN:
{aussagen}
Antworte NUR mit JSON (keine Code-Fences):
[{{"aussage": 5, "ok": true, "mangel": ""}}]

View File

@@ -0,0 +1,80 @@
"""Spalten-Aktualisierung: Ebene erneut fahren ohne Reset, ohne Status-Rückschritt."""
import db
import pipeline
import pytest
from conftest import topic_anlegen
async def _voll_lauf(topic):
pipeline.lauf_starten(topic)
await pipeline._laeufe[topic]
assert db.one("SELECT status FROM topics WHERE name=?", (topic,))["status"] == "fertig"
async def test_aktualisieren_fuellt_nach_ohne_statusaenderung():
topic = topic_anlegen("akt1")
await _voll_lauf(topic)
# eine verifizierte Flashcard „verlieren" + Kaskade nach artefakte abschalten
opfer = db.one("SELECT ar.id FROM artefakte ar JOIN atome a ON a.id=ar.atom_id"
" WHERE a.topic=? AND ar.typ='flashcard' AND ar.status='verifiziert'",
(topic,))
db.execute("DELETE FROM artefakte WHERE id=?", (opfer["id"],))
db.update("topics", "name", topic, auto=db.j({"artefakte": False}))
run_id = pipeline.aktualisierung_starten(topic, "artefakte")
await pipeline._laeufe[topic]
run = db.one("SELECT * FROM runs WHERE id=?", (run_id,))
assert run["status"] == "done" and "Auto-Stopp nach artefakte" in run["grund"]
# nachgefüllt + Status NICHT zurückgedreht
atom = db.one("SELECT atom_id FROM artefakte WHERE id=?", (opfer["id"],)) or {}
fehlt = db.query("SELECT a.id FROM atome a WHERE a.topic=? AND a.status NOT IN"
" ('gemerged','verworfen') AND NOT EXISTS (SELECT 1 FROM artefakte ar"
" WHERE ar.atom_id=a.id AND ar.typ='flashcard' AND ar.status='verifiziert')",
(topic,))
assert fehlt == []
assert db.one("SELECT status FROM topics WHERE name=?", (topic,))["status"] == "fertig"
async def test_aktualisieren_kaskadiert_mit_auto():
topic = topic_anlegen("akt2")
await _voll_lauf(topic)
run_id = pipeline.aktualisierung_starten(topic, "struktur")
await pipeline._laeufe[topic]
run = db.one("SELECT * FROM runs WHERE id=?", (run_id,))
assert run["status"] == "done" and not (run["grund"] or "").startswith("Auto-Stopp")
assert db.one("SELECT status FROM topics WHERE name=?", (topic,))["status"] == "fertig"
letzte = db.one("SELECT ebene FROM runs WHERE id=?", (run_id,))["ebene"]
assert letzte == "guide" # bis zur letzten Ebene durchgelaufen
async def test_tief_reverifiziert_artefakte_und_guide():
topic = topic_anlegen("akt4")
await _voll_lauf(topic)
vorher = db.one("SELECT COUNT(*) n FROM artefakte ar JOIN atome a ON a.id=ar.atom_id"
" WHERE a.topic=? AND ar.status='verifiziert'", (topic,))["n"]
assert vorher > 0
pipeline._tief_zuruecksetzen(topic, "artefakte")
offen = db.one("SELECT COUNT(*) n FROM artefakte ar JOIN atome a ON a.id=ar.atom_id"
" WHERE a.topic=? AND ar.status='kandidat'", (topic,))["n"]
assert offen == vorher # alles zurück in die Prüfung
pipeline._tief_zuruecksetzen(topic, "guide")
assert db.one("SELECT COUNT(*) n FROM sections s JOIN bausteine b ON b.id=s.baustein_id"
" WHERE b.topic=? AND s.stage='done'", (topic,))["n"] == 0
# tief-Lauf beurteilt alles neu (Fake-Panel: ok) → wieder verifiziert, Status bleibt
db.update("topics", "name", topic, auto=db.j({"artefakte": False}))
pipeline.aktualisierung_starten(topic, "artefakte", tief=True)
await pipeline._laeufe[topic]
nachher = db.one("SELECT COUNT(*) n FROM artefakte ar JOIN atome a ON a.id=ar.atom_id"
" WHERE a.topic=? AND ar.status='verifiziert'", (topic,))["n"]
assert nachher >= vorher
assert db.one("SELECT status FROM topics WHERE name=?", (topic,))["status"] == "fertig"
async def test_aktualisieren_guards():
topic = topic_anlegen("akt3")
with pytest.raises(ValueError):
pipeline.aktualisierung_starten(topic, "gibtsnicht")
pipeline.lauf_starten(topic)
with pytest.raises(RuntimeError):
pipeline.aktualisierung_starten(topic, "artefakte")
await pipeline._laeufe[topic]

54
tests/test_belege.py Normal file
View File

@@ -0,0 +1,54 @@
"""Beleg-Fenster: Faktenbasis = Quellumgebung des Ankers (Rollen-Trennung)."""
import belege
import db
from conftest import topic_anlegen
def _quelle(topic, tmp_path, text, name="q.md"):
p = tmp_path / name
p.write_text(text, encoding="utf-8")
return db.insert("quellen", topic=topic, art="datei", titel=name,
snapshot=str(p), hash=name + str(tmp_path), status="atome")
def _atom(topic):
return db.insert("atome", topic=topic, titel="T", typ="verfahren",
definition="d", status="neu", braucht=db.j([]))
def test_fenster_umgibt_anker(tmp_path):
topic = topic_anlegen("bel1")
text = "vorher " * 100 + "ANKERZITAT HIER" + " nachher" * 300
q = _quelle(topic, tmp_path, text)
a = _atom(topic)
start = text.index("ANKERZITAT")
db.insert("anker", atom_id=a, quelle_id=q, start=start,
ende=start + len("ANKERZITAT HIER"), zitat="ANKERZITAT HIER")
(f,) = belege.fenster_liste(a)
assert "ANKERZITAT HIER" in f
assert "vorher" in f and "nachher" in f # Umgebung beidseitig dabei
assert len(f) <= 300 + 1200 + 20 # Fenster-Deckel greift
def test_merge_fallback_und_fehlende_datei(tmp_path):
topic = topic_anlegen("bel2")
text = "start " * 50 + "ZITAT-A und gleich daneben ZITAT-B" + " ende" * 50
q = _quelle(topic, tmp_path, text)
a = _atom(topic)
ia, ib = text.index("ZITAT-A"), text.index("ZITAT-B")
db.insert("anker", atom_id=a, quelle_id=q, start=ia, ende=ia + 7, zitat="ZITAT-A")
db.insert("anker", atom_id=a, quelle_id=q, start=ib, ende=ib + 7, zitat="ZITAT-B")
bloecke = belege.fenster_liste(a)
assert len(bloecke) == 1 # überlappende Fenster gemerged
assert "ZITAT-A" in bloecke[0] and "ZITAT-B" in bloecke[0]
# Anker ohne Span → Zitat-Fallback
b = _atom(topic)
db.insert("anker", atom_id=b, quelle_id=q, start=-1, ende=-1, zitat="Nur das Zitat.")
assert belege.fenster_liste(b) == ["Nur das Zitat."]
# Snapshot-Datei fehlt → Zitat-Fallback (nie schlechter als vorher)
c = _atom(topic)
q2 = db.insert("quellen", topic=topic, art="datei", titel="tot",
snapshot=str(tmp_path / "gibts-nicht.md"), hash="tot", status="atome")
db.insert("anker", atom_id=c, quelle_id=q2, start=5, ende=10, zitat="Fallback-Zitat.")
assert belege.fenster_liste(c) == ["Fallback-Zitat."]

View File

@@ -27,6 +27,61 @@ def test_baustein_karten_nur_verifizierte_flashcards():
assert karten[0]["box"] == 1 # Default ohne Leitner-Eintrag assert karten[0]["box"] == 1 # Default ohne Leitner-Eintrag
def _aussage(topic, atom_id, paar, wahr, text):
return db.insert("artefakte", atom_id=atom_id, typ="aussage", status="verifiziert",
inhalt=db.j({"text": text, "wahr": wahr, "paar": paar,
"erklaerung": "die wahre Fassung"}))
def test_pruefung_panels_anti_cheat_und_paar_regel():
topic = topic_anlegen("pf1")
b, a = _setup(topic)
for p in range(4): # 4 Paare = 8 Aussagen → mind. 1 volles Panel möglich
_aussage(topic, a, f"{a}-{p}", True, f"wahr {p}")
_aussage(topic, a, f"{a}-{p}", False, f"falsch {p}")
panels = main.pruefung_holen(topic, b)
assert panels and all(len(p["panel"]) == 4 for p in panels)
for p in panels:
for x in p["panel"]:
assert set(x) == {"id", "text"} # kein wahr/erklaerung (Anti-Cheat)
paare = [db.uj(db.one("SELECT inhalt FROM artefakte WHERE id=?", (x["id"],))
["inhalt"], {}).get("paar") for x in p["panel"]]
assert len(set(paare)) == 4 # kein Paar-Partner im selben Panel
def test_pruefung_scoring_exakte_teilmenge():
topic = topic_anlegen("pf2")
b, a = _setup(topic)
ids_wahr = [_aussage(topic, a, f"{a}-{p}", True, f"w{p}") for p in range(2)]
ids_falsch = [_aussage(topic, a, f"{a}-{p+2}", False, f"f{p}") for p in range(2)]
ids = ids_wahr + ids_falsch
r = main.pruefung_antwort(topic, main.PruefungAntwort(ids=ids, angekreuzt=ids_wahr))
assert r["richtig"] and r["xp"] == 1
assert all(set(x) == {"id", "wahr", "erklaerung"} for x in r["aufloesung"])
# eine falsche angekreuzt → 1, Boden 0
r = main.pruefung_antwort(topic, main.PruefungAntwort(
ids=ids, angekreuzt=ids_wahr + ids_falsch[:1]))
assert not r["richtig"] and r["xp"] == 0
# Schwelle → fertig + lernstand-Status
for _ in range(10):
r = main.pruefung_antwort(topic, main.PruefungAntwort(ids=ids, angekreuzt=ids_wahr))
assert r["fertig"]
stand = [s for s in main.lernstand_holen(topic) if s["baustein_id"] == b][0]
assert stand["status"] == "fertig"
def test_check_migration_erlaubt_aussage():
import sqlite3
con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE artefakte(id INTEGER PRIMARY KEY, atom_id INTEGER NOT NULL,"
" typ TEXT NOT NULL CHECK(typ IN ('flashcard','beispiel')),"
" inhalt TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'kandidat')")
con.execute("INSERT INTO artefakte(atom_id, typ, inhalt) VALUES(1,'flashcard','{}')")
db._init_schema(con) # muss den CHECK erweitern und Daten erhalten
con.execute("INSERT INTO artefakte(atom_id, typ, inhalt) VALUES(1,'aussage','{}')")
assert con.execute("SELECT COUNT(*) FROM artefakte").fetchone()[0] == 2
def test_lernstand_und_fertig_idempotent(): def test_lernstand_und_fertig_idempotent():
topic = topic_anlegen("lm2") topic = topic_anlegen("lm2")
b, _ = _setup(topic) b, _ = _setup(topic)