From 8926f87185f1af4bab9dbfb9a61a38dd9b988b3e Mon Sep 17 00:00:00 2001 From: team3 Date: Mon, 13 Jul 2026 05:18:55 +0200 Subject: [PATCH] update --- backend/artefakte.py | 91 +++++++++++++++++++- backend/belege.py | 62 ++++++++++++++ backend/config.py | 11 +++ backend/db.py | 17 +++- backend/fake_agents.py | 21 ++++- backend/guide.py | 21 +++-- backend/main.py | 69 ++++++++++++++- backend/pipeline.py | 50 ++++++++++- backend/qa.py | 2 +- backend/transfer.py | 8 ++ frontend/src/api.js | 8 ++ frontend/src/components/Board.vue | 14 +++ frontend/src/components/Lesemodus.vue | 118 +++++++++++++++++++++----- frontend/src/style.css | 21 +++++ templates/Aussagen-Generate.md | 22 +++++ templates/Aussagen-Verify.md | 20 +++++ tests/test_aktualisieren.py | 80 +++++++++++++++++ tests/test_belege.py | 54 ++++++++++++ tests/test_lesemodus.py | 55 ++++++++++++ 19 files changed, 703 insertions(+), 41 deletions(-) create mode 100644 backend/belege.py create mode 100644 templates/Aussagen-Generate.md create mode 100644 templates/Aussagen-Verify.md create mode 100644 tests/test_aktualisieren.py create mode 100644 tests/test_belege.py diff --git a/backend/artefakte.py b/backend/artefakte.py index 9e6f8bd..f66dd6c 100644 --- a/backend/artefakte.py +++ b/backend/artefakte.py @@ -10,9 +10,10 @@ import re import subprocess import tempfile +import belege import db 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") @@ -39,8 +40,10 @@ def _referenziert_quelle(inhalt: dict) -> bool: def _zitate(atom_id: int) -> str: - rows = db.query("SELECT zitat FROM anker WHERE atom_id=? AND start>=0", (atom_id,)) - return "\n".join(f"> {r['zitat']}" for r in rows) or "(kein Anker)" + # Quelltext-FENSTER um die Anker (belege.py) — nur das Zitat war zu schmal: + # 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: @@ -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)) +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: """gen→gate→verify EINES Chunks verkettet — der Verify braucht nur die eigenen 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) _formen_gate(ctx.topic, [a["id"] for a in 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: @@ -295,6 +376,10 @@ def messen(ctx: llm.Kontext) -> list[dict]: if not any(k["status"] == "verifiziert" for k in karten): befunde.append({"art": "atom_ohne_flashcard", "item": str(a["id"]), "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'", (a["id"],)) for k in offen: diff --git a/backend/belege.py b/backend/belege.py new file mode 100644 index 0000000..ba53974 --- /dev/null +++ b/backend/belege.py @@ -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 42–150 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-.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] diff --git a/backend/config.py b/backend/config.py index bdd3fcf..c810357 100644 --- a/backend/config.py +++ b/backend/config.py @@ -93,6 +93,15 @@ BEISPIEL_FORMEN = ("text", "mathe", "code", "tabelle") # Worked-Example-Formen CODE_SPRACHEN = ("python", "javascript") # Syntax-Gate parse-only (Container: python3, node) ARTEFAKT_CHUNK_ATOME = 8 # Atome pro Generate-Call (ein Soll-Punkt, gestückelt) 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 (0–4 davon wahr) +PRUEFUNG_SCHWELLE = 10 # Saldo (+1 je korrektem Panel, −1 sonst) → Baustein frei # ── Ebene 3: Struktur ───────────────────────────────────────────────────────── LEVEL_RANG = {"E": 0, "M": 1, "S": 2} # Durchgänge: Grundgerüst / Standard / Feinheiten @@ -125,6 +134,8 @@ TIMEOUTS = { "soll_zuordnung": (300, 8), "luecke": (450, 0), "artefakt": (450, 20), + "aussagen_gen": (300, 15), + "aussagen_verify": (200, 10), "verify": (200, 10), "fix": (300, 15), "ziele": (300, 6), diff --git a/backend/db.py b/backend/db.py index 78e1a1f..73fe9fa 100644 --- a/backend/db.py +++ b/backend/db.py @@ -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', UNIQUE(von_atom, zu_atom, art)); 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'); CREATE INDEX IF NOT EXISTS idx_artefakte_atom ON artefakte(atom_id); 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 con.execute("UPDATE befunde SET status='veraltet'" " 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() diff --git a/backend/fake_agents.py b/backend/fake_agents.py index 4eb3002..da3434e 100644 --- a/backend/fake_agents.py +++ b/backend/fake_agents.py @@ -167,6 +167,23 @@ def _artefakt_fix(prompt: str): 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): punkt = re.search(r"Soll-Punkt: „(.+?)“", prompt) or re.search(r'Soll-Punkt: „(.+?)"', prompt) name = punkt.group(1) if punkt else "das Thema" @@ -276,7 +293,9 @@ _HANDLER = { "Atom-Luecke": _luecke, "Atom-Titel-Fix": _titel_fix, "Soll-Abgedeckt": lambda p: {"abgedeckt": True}, "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, "Kanten-Zyklus": _zyklus, "Braucht-Aufloesung": _braucht_aufloesung, "Guide-Writer": _writer, "Guide-Pruefer": _pruefer, "Guide-Fix": _fix, diff --git a/backend/guide.py b/backend/guide.py index d4f5911..8350a97 100644 --- a/backend/guide.py +++ b/backend/guide.py @@ -17,6 +17,7 @@ from pathlib import Path import db import jsonx import llm +import belege import textkit 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,)) -def _zitate(atom_id: int, max_n: int = 3) -> list[str]: - rows = db.query("SELECT zitat FROM anker WHERE atom_id=? AND start>=0 LIMIT ?", - (atom_id, max_n)) - return [r["zitat"] for r in rows] +def _zitate(atom_id: int, max_n: int = 2) -> list[str]: + # Quelltext-FENSTER um die Anker (belege.py): mit bloßen Kurz-Zitaten schrieb + # der Writer „Details sind technisch"-Prosa, weil ihm die Substanz fehlte. + return belege.fenster_liste(atom_id, max_fenster=max_n) 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 "" +def _quote(block: str) -> str: + return "\n".join(f"> {l}" for l in block.splitlines()) + + 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"]) teil = (f"MARKER (exakt so übernehmen): \n" 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: auftraege.append(alt) 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", schritt="pruefer", role="judge", n=len(atome), 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 # bleibt die Section ungeprüft (fail-closed statt fail-open). 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", schritt="qa_judge", role="judge", item=f"b{b['id']}", werte={"fakten": fakten, "text": sec["text_lang"]}, diff --git a/backend/main.py b/backend/main.py index b6b524c..cac6f4b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -18,7 +18,8 @@ import db import guide import ledger 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 logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s") @@ -52,6 +53,11 @@ class UebenAntwort(BaseModel): richtig: bool +class PruefungAntwort(BaseModel): + ids: list[int] # die 4 Aussagen des Panels + angekreuzt: list[int] # die als wahr markierten + + class AutoFlag(BaseModel): ebene: str an: bool @@ -252,6 +258,19 @@ async def ebene_entfernen(topic: str, ebene: str): 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}") async def topic_loeschen(topic: str): 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} +@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") def kennzahlen(run_id: int): return {"zeilen": ledger.kennzahlen(run_id), "verbraucht": ledger.verbraucht(run_id)} diff --git a/backend/pipeline.py b/backend/pipeline.py index b3f8931..6ae236e 100644 --- a/backend/pipeline.py +++ b/backend/pipeline.py @@ -52,6 +52,39 @@ def lauf_starten(topic: str, budget: int | None = None) -> int: 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: agents.abbrechen(f"{topic}-") task = _laeufe.get(topic) @@ -163,13 +196,19 @@ def soll_reset(topic: str) -> None: 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,)) ctx = llm.Kontext(run_id, topic, t["provider"]) + start_idx = [e[0] for e in EBENEN].index(ab) if ab else 0 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"] - if _fertig_ab(status, marke): + if not erzwingen and _fertig_ab(status, marke): continue db.update("runs", "id", run_id, ebene=name) await modul.bauen(ctx) @@ -190,7 +229,10 @@ async def _lauf(topic: str, run_id: int) -> None: gate = getattr(modul, "gate", None) if gate and (grund_gate := gate(ctx)): raise RuntimeError(f"{name}-Gate: {grund_gate}") - db.update("topics", "name", topic, status=marke) + # 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) flags = db.uj(db.one("SELECT auto FROM topics WHERE name=?", (topic,))["auto"], {}) if not flags.get(name, True) and marke != "fertig": db.update("runs", "id", run_id, status="done", beendet=db.now(), diff --git a/backend/qa.py b/backend/qa.py index e7c7ffe..59d65b3 100644 --- a/backend/qa.py +++ b/backend/qa.py @@ -25,7 +25,7 @@ GEWICHTE = { "korpus_leer": 3.0, "soll_leer": 3.0, "soll_wenig_belege": 1.5, "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, "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, "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, diff --git a/backend/transfer.py b/backend/transfer.py index 19b3c5e..cbd0f02 100644 --- a/backend/transfer.py +++ b/backend/transfer.py @@ -125,6 +125,13 @@ def importieren(d: dict) -> None: 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) + 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: alt = int(m.group(2)) 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"): if z.get(feld): z[feld] = re.sub(r"", beispiel_remap, z[feld]) # offene Fix-Aufträge nennen Atom-IDs im Klartext („Marker für Atom 123 # fehlt") — mitremappen, sonst fixt der Prüfer gegen tote IDs if z.get("befunde"): diff --git a/frontend/src/api.js b/frontend/src/api.js index adf594d..848d2e7 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -25,6 +25,9 @@ export const api = { req(`/api/topics/${e(topic)}/auto`, { method: 'PATCH', body: JSON.stringify({ ebene, an }) }), ebeneEntfernen: (topic, ebene) => 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' }), state: (topic) => req(`/api/topics/${e(topic)}/state`), guide: (topic) => req(`/api/topics/${e(topic)}/guide`), @@ -36,6 +39,11 @@ export const api = { lernstand: (topic) => req(`/api/topics/${e(topic)}/lernstand`), bausteinFertig: (topic, bausteinId) => 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`), transferInfo: () => req('/api/transfer'), transfer: (topic, richtung) => diff --git a/frontend/src/components/Board.vue b/frontend/src/components/Board.vue index d95cc8b..323c7dd 100644 --- a/frontend/src/components/Board.vue +++ b/frontend/src/components/Board.vue @@ -104,6 +104,14 @@ async function menueGenerieren() { 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() { const m = menue.value menue.value = null @@ -167,6 +175,12 @@ const k = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1000 ? `${Math.ro + + diff --git a/frontend/src/components/Lesemodus.vue b/frontend/src/components/Lesemodus.vue index 7989e23..434f917 100644 --- a/frontend/src/components/Lesemodus.vue +++ b/frontend/src/components/Lesemodus.vue @@ -6,12 +6,14 @@ import { render, lesestat } from '../markdown.js' const props = defineProps({ daten: Object, topic: String }) const emit = defineEmits(['schliessen']) +const SCHWELLE = 10 // Anzeige; die Wahrheit entscheidet der Server (PRUEFUNG_SCHWELLE) + const wurzel = ref(null) 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): -// je Kapitel ein Intro, je Baustein Fundament + Abruf, am Ende ein Abschluss. +// Screen-Liste: je Kapitel ein Intro, je Baustein Fundament → Abruf → Prüfung. const screens = computed(() => { const out = [] const kaps = props.daten?.kapitel || [] @@ -20,6 +22,7 @@ const screens = computed(() => { for (const s of kap.sections) { 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: 'pruefung', baustein: s.baustein, titel: s.titel }) } }) out.push({ typ: 'ende' }) @@ -45,26 +48,20 @@ function fundamentHtml(s) { return htmlCache.get(s.baustein) } -// ── Navigation ─────────────────────────────────────────────────────────────── -// Kein Zwang, alle Karten zu sehen: Weiter ist immer frei. Verlässt man einen -// Abruf-Screen vorwärts, gilt der Baustein als erledigt (Fortschritt persistiert). -function vor() { - if (pos.value >= screens.value.length - 1) return - if (screen.value.typ === 'abruf') markFertig(screen.value.baustein) - pos.value++ -} +// ── Navigation + Prüfungs-Gate ───────────────────────────────────────────────── +// Lesen + Abruf sind frei. Nur die Prüfung sperrt: weiter erst ab Saldo ≥ Schwelle. +// 0 Panels sperren NICHT (leerer Baustein darf den Guide nie blockieren). +const gesperrt = computed(() => + screen.value.typ === 'pruefung' && panels.value.length > 0 + && !fertig.value.has(screen.value.baustein)) +function vor() { if (!gesperrt.value && pos.value < screens.value.length - 1) pos.value++ } function zurueck() { if (pos.value > 0) pos.value-- } 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) ───────────────────────────── +// ── Abrufprüfung (Karteikarten) ──────────────────────────────────────────────── const stapel = ref([]) const zeigeAntwort = ref(false) const sende = ref(false) @@ -81,26 +78,73 @@ async function antworten(richtig) { await api.antwort(karte.id, richtig) stapel.value = richtig ? stapel.value.slice(1) : [...stapel.value.slice(1), karte] zeigeAntwort.value = false - if (!stapel.value.length) markFertig(screen.value.baustein) } 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) => { if (s.typ === 'abruf') abrufLaden(s.baustein) + else if (s.typ === 'pruefung') pruefungLaden(s.baustein) }) -// ── Fullscreen + Resume ───────────────────────────────────────────────────────── +// ── Fullscreen + Resume ──────────────────────────────────────────────────────── function schliessen() { if (document.fullscreenElement) document.exitFullscreen().catch(() => {}) emit('schliessen') } onMounted(async () => { 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(() => []) 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)) pos.value = idx >= 0 ? idx : screens.value.length - 1 }) @@ -151,6 +195,32 @@ onUnmounted(() => {
✓ Abruf durch — weiter mit →
+
+
Prüfung · {{ screen.titel }} · Saldo {{ saldo }} / {{ SCHWELLE }}
+ +
Keine Prüfungs-Aussagen für diesen Baustein — weiter mit →
+
+

Fundament abgeschlossen 🎉

Schritt 2 (Anwendung) und Schritt 3 (eigene Aufgaben) folgen.

@@ -159,7 +229,9 @@ onUnmounted(() => {
- +
diff --git a/frontend/src/style.css b/frontend/src/style.css index 8d46993..6673a10 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -323,3 +323,24 @@ table.kennzahlen th { color: var(--dim); } .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-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; +} diff --git a/templates/Aussagen-Generate.md b/templates/Aussagen-Generate.md new file mode 100644 index 0000000..4e7232a --- /dev/null +++ b/templates/Aussagen-Generate.md @@ -0,0 +1,22 @@ + +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": "…"}}] diff --git a/templates/Aussagen-Verify.md b/templates/Aussagen-Verify.md new file mode 100644 index 0000000..e3337a0 --- /dev/null +++ b/templates/Aussagen-Verify.md @@ -0,0 +1,20 @@ + +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": ""}}] diff --git a/tests/test_aktualisieren.py b/tests/test_aktualisieren.py new file mode 100644 index 0000000..d88d980 --- /dev/null +++ b/tests/test_aktualisieren.py @@ -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] diff --git a/tests/test_belege.py b/tests/test_belege.py new file mode 100644 index 0000000..cbc236f --- /dev/null +++ b/tests/test_belege.py @@ -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."] diff --git a/tests/test_lesemodus.py b/tests/test_lesemodus.py index 23b15b8..b989630 100644 --- a/tests/test_lesemodus.py +++ b/tests/test_lesemodus.py @@ -27,6 +27,61 @@ def test_baustein_karten_nur_verifizierte_flashcards(): 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(): topic = topic_anlegen("lm2") b, _ = _setup(topic)