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

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)
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 (04 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),

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',
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()

View File

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

View File

@@ -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): <!-- atom: {a['id']} | {a['titel']} -->\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"]},

View File

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

View File

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

View File

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

View File

@@ -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"<!--\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
# fehlt") — mitremappen, sonst fixt der Prüfer gegen tote IDs
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 }) }),
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) =>

View File

@@ -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
<button :disabled="!generierenErlaubt(menue.idx)" @click.stop="menueGenerieren">
Generieren
</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">
{{ menue.titel }}" + folgende Ebenen entfernen
</button>

View File

@@ -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(() => {
<div v-else class="lm-bestanden"> Abruf durch weiter mit </div>
</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">
<h1>Fundament abgeschlossen 🎉</h1>
<p class="lm-introtext">Schritt 2 (Anwendung) und Schritt 3 (eigene Aufgaben) folgen.</p>
@@ -159,7 +229,9 @@ onUnmounted(() => {
<footer class="lm-fuss">
<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>
</div>
</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-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;
}

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