update
This commit is contained in:
@@ -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
62
backend/belege.py
Normal 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 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-<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]
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]},
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
22
templates/Aussagen-Generate.md
Normal file
22
templates/Aussagen-Generate.md
Normal 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": "…"}}]
|
||||
20
templates/Aussagen-Verify.md
Normal file
20
templates/Aussagen-Verify.md
Normal 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": ""}}]
|
||||
80
tests/test_aktualisieren.py
Normal file
80
tests/test_aktualisieren.py
Normal 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
54
tests/test_belege.py
Normal 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."]
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user