This commit is contained in:
team3
2026-07-13 16:11:00 +02:00
parent 9cd8e02e22
commit a8e8e077bd
14 changed files with 454 additions and 62 deletions

View File

@@ -13,7 +13,8 @@ import tempfile
import belege import belege
import db import db
import llm import llm
from config import ARTEFAKT_CHUNK_ATOME, AUSSAGEN_JE_ATOM, BEISPIEL_FORMEN, VERIFY_PANEL from config import (ARTEFAKT_CHUNK_ATOME, AUSSAGEN_JE_ATOM, BEISPIEL_FORMEN,
NACHFUELL_CAP, VERIFY_PANEL)
log = logging.getLogger("creator2.artefakte") log = logging.getLogger("creator2.artefakte")
@@ -51,17 +52,78 @@ def _atom_block(a: dict) -> str:
f"Definition: {a['definition']}\nBelege:\n{_zitate(a['id'])}") f"Definition: {a['definition']}\nBelege:\n{_zitate(a['id'])}")
FC_ZIEL = 2 # Flashcards je Atom; Beispiel-Ziel ist 1, Aussagen-Ziel AUSSAGEN_JE_ATOM Paare
# Zeilen-Ziel je Typ (Aussagen: 2 Zeilen pro Paar) — Basis für den Nachfüll-Cap
ZIEL_ZEILEN = {"flashcard": FC_ZIEL, "beispiel": 1, "aussage": 2 * AUSSAGEN_JE_ATOM}
def _lebende(atom_id: int, typ: str) -> list[dict]:
return db.query("SELECT * FROM artefakte WHERE atom_id=? AND typ=?"
" AND status!='verworfen'", (atom_id, typ))
def _aufgegeben(atom_id: int, typ: str) -> bool:
"""Anti-Churn: ab NACHFUELL_CAP × Ziel Verwürfen nicht mehr nachfüllen —
messen meldet dann `artefakt_aufgegeben` (sichtbar, aber kein Repair)."""
n = db.one("SELECT COUNT(*) AS n FROM artefakte WHERE atom_id=? AND typ=?"
" AND status='verworfen'", (atom_id, typ))["n"]
return n >= NACHFUELL_CAP * ZIEL_ZEILEN[typ]
def _gen_bedarf(atom_id: int) -> bool:
"""Braucht das Atom einen Artefakt-Generate-Call? (Flashcards unterm Ziel
oder Beispiel fehlt — jeweils solange nicht aufgegeben.)"""
if (len(_lebende(atom_id, "flashcard")) < FC_ZIEL
and not _aufgegeben(atom_id, "flashcard")):
return True
return not _lebende(atom_id, "beispiel") and not _aufgegeben(atom_id, "beispiel")
def _paar_lage(atom_id: int) -> tuple[int, int, int]:
"""→ (komplette lebende Paare, einseitig lebende Paare, höchster Paar-Index).
Der Index zählt über ALLE Zeilen (auch verworfene) — neue Paare dürfen alte
`paar`-Keys nie wiederverwenden."""
hoechster = -1
seiten: dict[str, set[bool]] = {}
for k in db.query("SELECT inhalt, status FROM artefakte WHERE atom_id=?"
" AND typ='aussage'", (atom_id,)):
inh = db.uj(k["inhalt"], {})
paar = str(inh.get("paar", ""))
try:
hoechster = max(hoechster, int(paar.rsplit("-", 1)[1]))
except (IndexError, ValueError):
pass
if k["status"] != "verworfen":
seiten.setdefault(paar, set()).add(bool(inh.get("wahr")))
komplett = sum(1 for s in seiten.values() if s == {True, False})
return komplett, len(seiten) - komplett, hoechster
def _paare_bereinigen(atom_ids: list[int]) -> None:
"""Unvollständige Paar-Gruppen verwerfen: die lebenden Seiten müssen genau
{wahr, falsch} ergeben. Fängt halbe Paare UND Deformierte (fehlender
paar-Key, doppelt gleiche Seite). Nachfüllen erzeugt nur KOMPLETTE Paare."""
for atom_id in atom_ids:
gruppen: dict[str, list[dict]] = {}
for k in db.query("SELECT * FROM artefakte WHERE atom_id=? AND typ='aussage'",
(atom_id,)):
paar = str(db.uj(k["inhalt"], {}).get("paar", ""))
gruppen.setdefault(paar, []).append(k)
for zeilen in gruppen.values():
lebend = [z for z in zeilen if z["status"] != "verworfen"]
seiten = {bool(db.uj(z["inhalt"], {}).get("wahr")) for z in lebend}
if lebend and seiten != {True, False}:
for z in lebend:
db.update("artefakte", "id", z["id"], status="verworfen")
def _chunks(topic: str, nur_ohne: bool) -> list[list[dict]]: def _chunks(topic: str, nur_ohne: bool) -> list[list[dict]]:
"""Atome je Soll-Punkt gruppiert, gestückelt. nur_ohne: nur Atome ohne Artefakte.""" """Atome je Soll-Punkt gruppiert, gestückelt. nur_ohne: nur Atome mit Bedarf."""
atome = db.query("SELECT * FROM atome WHERE topic=? AND status NOT IN" atome = db.query("SELECT * FROM atome WHERE topic=? AND status NOT IN"
" ('gemerged','verworfen') ORDER BY soll_id, id", (topic,)) " ('gemerged','verworfen') ORDER BY soll_id, id", (topic,))
if nur_ohne: if nur_ohne:
# Die echte Invariante ist „≥1 lebende FLASHCARD" (Beispiel zählt nicht). atome = [a for a in atome if _gen_bedarf(a["id"])]
# Prüfte man „irgendein lebendes Artefakt", blockierte ein überlebendes
# Beispiel die Flashcard-Nachgenerierung dauerhaft (Lauf 8: 5 Atome ohne).
atome = [a for a in atome if not db.one(
"SELECT id FROM artefakte WHERE atom_id=? AND typ='flashcard'"
" AND status!='verworfen' LIMIT 1", (a["id"],))]
gruppen: dict = {} gruppen: dict = {}
for a in atome: for a in atome:
gruppen.setdefault(a["soll_id"], []).append(a) gruppen.setdefault(a["soll_id"], []).append(a)
@@ -91,6 +153,9 @@ async def _generieren(ctx: llm.Kontext, chunk: list[dict]) -> None:
werte={"atome": bloecke}, role="guide", werte={"atome": bloecke}, role="guide",
n=len(chunk), item=f"g{chunk[0]['id']}", erwartet=list) n=len(chunk), item=f"g{chunk[0]['id']}", erwartet=list)
gueltig = {a["id"] for a in chunk} gueltig = {a["id"] for a in chunk}
# Nachfüllen bis zum Ziel, nie darüber: das Template liefert immer voll
# (2 Karten + Beispiel), der Guard nimmt nur, was dem Atom noch fehlt.
fc_frei = {a["id"]: FC_ZIEL - len(_lebende(a["id"], "flashcard")) for a in chunk}
for e in res or []: for e in res or []:
atom_id = e.get("atom") atom_id = e.get("atom")
typ = str(e.get("typ", "")).strip() typ = str(e.get("typ", "")).strip()
@@ -103,12 +168,14 @@ async def _generieren(ctx: llm.Kontext, chunk: list[dict]) -> None:
if _referenziert_quelle(inhalt): if _referenziert_quelle(inhalt):
log.info("Artefakt zu Atom %s nicht übernommen: referenziert die Quelle", atom_id) log.info("Artefakt zu Atom %s nicht übernommen: referenziert die Quelle", atom_id)
continue continue
# Nachgenerierung (nur_ohne) läuft auch, wenn nur die Flashcard fehlt —
# dann kein zweites Beispiel anlegen (Verify-Tokens/Leitner-Dubletten).
if typ == "beispiel" and db.one( if typ == "beispiel" and db.one(
"SELECT id FROM artefakte WHERE atom_id=? AND typ='beispiel'" "SELECT id FROM artefakte WHERE atom_id=? AND typ='beispiel'"
" AND status!='verworfen' LIMIT 1", (atom_id,)): " AND status!='verworfen' LIMIT 1", (atom_id,)):
continue continue
if typ == "flashcard":
if fc_frei.get(atom_id, 0) <= 0:
continue
fc_frei[atom_id] -= 1
db.insert("artefakte", atom_id=atom_id, typ=typ, inhalt=db.j(inhalt), status="kandidat") db.insert("artefakte", atom_id=atom_id, typ=typ, inhalt=db.j(inhalt), status="kandidat")
@@ -117,8 +184,10 @@ async def _verifizieren(ctx: llm.Kontext, chunk: list[dict]) -> None:
destruktiv → nur nach Fix + Re-Verify (im Zweifel behalten, Lektion 67).""" destruktiv → nur nach Fix + Re-Verify (im Zweifel behalten, Lektion 67)."""
kandidaten = [] kandidaten = []
for a in chunk: for a in chunk:
for k in db.query("SELECT * FROM artefakte WHERE atom_id=? AND status='kandidat'", # Aussagen haben ihr EIGENES Panel (+Paar-Logik) — landeten sie hier,
(a["id"],)): # überschrieb _fixen ihren Inhalt mit dem Flashcard-Schema (leere Hülle)
for k in db.query("SELECT * FROM artefakte WHERE atom_id=? AND status='kandidat'"
" AND typ!='aussage'", (a["id"],)):
if _referenziert_quelle(db.uj(k["inhalt"])): # deterministisch, vor dem Panel if _referenziert_quelle(db.uj(k["inhalt"])): # deterministisch, vor dem Panel
db.update("artefakte", "id", k["id"], status="verworfen") db.update("artefakte", "id", k["id"], status="verworfen")
else: else:
@@ -280,7 +349,14 @@ async def _aussagen_generieren(ctx: llm.Kontext, chunk: list[dict]) -> None:
role="guide", n=len(chunk), item=f"as{chunk[0]['id']}", role="guide", n=len(chunk), item=f"as{chunk[0]['id']}",
erwartet=list) erwartet=list)
gueltig = {a["id"] for a in chunk} gueltig = {a["id"] for a in chunk}
zaehler: dict[int, int] = {} # Nachfüllen: nur fehlende Paare übernehmen; Index hinter ALLEN bestehenden
# Zeilen fortsetzen (alte `paar`-Keys — auch verworfene — nie wiederverwenden)
bedarf: dict[int, int] = {}
naechster: dict[int, int] = {}
for a in chunk:
komplett, _, hoechster = _paar_lage(a["id"])
bedarf[a["id"]] = max(0, AUSSAGEN_JE_ATOM - komplett)
naechster[a["id"]] = hoechster + 1
for e in res or []: for e in res or []:
if not isinstance(e, dict) or e.get("atom") not in gueltig: if not isinstance(e, dict) or e.get("atom") not in gueltig:
continue continue
@@ -290,8 +366,11 @@ async def _aussagen_generieren(ctx: llm.Kontext, chunk: list[dict]) -> None:
continue continue
if _referenziert_quelle({"text": f"{wahr} {falsch}"}): if _referenziert_quelle({"text": f"{wahr} {falsch}"}):
continue continue
i = zaehler.get(atom_id, 0) if bedarf.get(atom_id, 0) <= 0:
zaehler[atom_id] = i + 1 continue
bedarf[atom_id] -= 1
i = naechster[atom_id]
naechster[atom_id] = i + 1
paar = f"{atom_id}-{i}" paar = f"{atom_id}-{i}"
db.insert("artefakte", atom_id=atom_id, typ="aussage", status="kandidat", db.insert("artefakte", atom_id=atom_id, typ="aussage", status="kandidat",
inhalt=db.j({"text": wahr, "wahr": True, "paar": paar, inhalt=db.j({"text": wahr, "wahr": True, "paar": paar,
@@ -334,28 +413,37 @@ async def _aussagen_verifizieren(ctx: llm.Kontext, chunk: list[dict]) -> None:
return None return None
return urteil return urteil
entschieden: dict[int, str] = {}
for k in kandidaten: for k in kandidaten:
votes = [u for s in stimmen if (u := urteil_fuer(k["id"])(s)) is not None] votes = [u for s in stimmen if (u := urteil_fuer(k["id"])(s)) is not None]
if len(votes) < VERIFY_PANEL: if len(votes) < VERIFY_PANEL:
continue continue
db.update("artefakte", "id", k["id"], entschieden[k["id"]] = "verifiziert" if all(votes) else "verworfen"
status="verifiziert" if all(votes) else "verworfen") # Paare fallen GANZ: kippt eine Seite, fällt auch die andere (frisch
# verifiziert oder unentschieden) — ein halbes Paar prüft schief.
paar_von = {k["id"]: str(db.uj(k["inhalt"], {}).get("paar", "")) for k in kandidaten}
gefallen = {paar_von[kid] for kid, st in entschieden.items() if st == "verworfen"}
for k in kandidaten:
status = entschieden.get(k["id"])
if paar_von[k["id"]] in gefallen:
status = "verworfen"
if status:
db.update("artefakte", "id", k["id"], status=status)
async def _kette(ctx: llm.Kontext, chunk: list[dict]) -> None: async def _kette(ctx: llm.Kontext, chunk: list[dict]) -> None:
"""gen→gate→verify EINES Chunks verkettet — der Verify braucht nur die eigenen """gen→gate→verify EINES Chunks verkettet — der Verify braucht nur die eigenen
Kandidaten, nicht die Generierung fremder Chunks (Stage-Barriere kostete Kandidaten, nicht die Generierung fremder Chunks (Stage-Barriere kostete
Wall-Clock: 1 Nachzügler blockierte alle Verifies).""" Wall-Clock: 1 Nachzügler blockierte alle Verifies)."""
ohne = [a for a in chunk if not db.one( ohne = [a for a in chunk if _gen_bedarf(a["id"])]
"SELECT id FROM artefakte WHERE atom_id=? AND typ='flashcard'"
" AND status!='verworfen' LIMIT 1", (a["id"],))]
if ohne: if ohne:
await _generieren(ctx, ohne) await _generieren(ctx, ohne)
_formen_gate(ctx.topic, [a["id"] for a in chunk]) _formen_gate(ctx.topic, [a["id"] for a in chunk])
await _verifizieren(ctx, chunk) await _verifizieren(ctx, chunk)
ohne_aussage = [a for a in chunk if not db.one( _paare_bereinigen([a["id"] for a in chunk])
"SELECT id FROM artefakte WHERE atom_id=? AND typ='aussage'" ohne_aussage = [a for a in chunk
" AND status!='verworfen' LIMIT 1", (a["id"],))] if _paar_lage(a["id"])[0] < AUSSAGEN_JE_ATOM
and not _aufgegeben(a["id"], "aussage")]
if ohne_aussage: if ohne_aussage:
await _aussagen_generieren(ctx, ohne_aussage) await _aussagen_generieren(ctx, ohne_aussage)
await _aussagen_verifizieren(ctx, chunk) await _aussagen_verifizieren(ctx, chunk)
@@ -367,19 +455,37 @@ async def bauen(ctx: llm.Kontext) -> None:
def messen(ctx: llm.Kontext) -> list[dict]: def messen(ctx: llm.Kontext) -> list[dict]:
"""Vollständigkeit lebend-basiert (kandidat zählt mit — der ist unterwegs und
wird als `artefakt_unentschieden` separat gemeldet). Ziel: 2 Flashcards,
1 Beispiel, AUSSAGEN_JE_ATOM komplette Paare je Atom; am Cap stattdessen
`artefakt_aufgegeben` (sichtbar, aber reparieren fasst es nicht mehr an)."""
befunde = [] befunde = []
def fehlt(atom: dict, typ: str, art: str) -> None:
if _aufgegeben(atom["id"], typ):
befunde.append({"art": "artefakt_aufgegeben", "item": f"{atom['id']}-{typ}",
"detail": atom["titel"]})
else:
befunde.append({"art": art, "item": str(atom["id"]), "detail": atom["titel"]})
atome = db.query("SELECT * FROM atome WHERE topic=? AND status NOT IN" atome = db.query("SELECT * FROM atome WHERE topic=? AND status NOT IN"
" ('gemerged','verworfen')", (ctx.topic,)) " ('gemerged','verworfen')", (ctx.topic,))
for a in atome: for a in atome:
karten = db.query("SELECT status FROM artefakte WHERE atom_id=? AND typ='flashcard'", fc = len(_lebende(a["id"], "flashcard"))
(a["id"],)) if fc < FC_ZIEL:
if not any(k["status"] == "verifiziert" for k in karten): fehlt(a, "flashcard",
befunde.append({"art": "atom_ohne_flashcard", "item": str(a["id"]), "atom_ohne_flashcard" if fc == 0 else "flashcard_unvollstaendig")
"detail": a["titel"]}) if not _lebende(a["id"], "beispiel"):
if not db.one("SELECT id FROM artefakte WHERE atom_id=? AND typ='aussage'" fehlt(a, "beispiel", "atom_ohne_beispiel")
" AND status='verifiziert' LIMIT 1", (a["id"],)): komplett, einseitig, _ = _paar_lage(a["id"])
befunde.append({"art": "atom_ohne_aussage", "item": str(a["id"]), if einseitig:
# immer reparierbar: _paare_bereinigen ist deterministisch (kein LLM),
# der Cap begrenzt nur die NACHgenerierung (ohne_aussage-Filter in _kette)
befunde.append({"art": "paar_unvollstaendig", "item": str(a["id"]),
"detail": a["titel"]}) "detail": a["titel"]})
elif komplett < AUSSAGEN_JE_ATOM:
fehlt(a, "aussage",
"atom_ohne_aussage" if komplett == 0 else "paar_unvollstaendig")
offen = db.query("SELECT id FROM artefakte WHERE atom_id=? AND status='kandidat'", offen = db.query("SELECT id FROM artefakte WHERE atom_id=? AND status='kandidat'",
(a["id"],)) (a["id"],))
for k in offen: for k in offen:
@@ -388,9 +494,15 @@ def messen(ctx: llm.Kontext) -> list[dict]:
return befunde return befunde
# Arten, bei denen bauen() etwas bewegen kann. aufgegeben (Cap erreicht) und
# unentschieden (Panel-Ausfall) allein → kein Lauf, sonst churnt der Auto-Loop leer.
_REPARIERBAR = {"atom_ohne_flashcard", "flashcard_unvollstaendig", "atom_ohne_beispiel",
"atom_ohne_aussage", "paar_unvollstaendig"}
async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool: async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
ctx.ebene = EBENE ctx.ebene = EBENE
if not befunde: if not any(b["art"] in _REPARIERBAR for b in befunde):
return False return False
await bauen(ctx) # idempotent: generiert Fehlendes nach, prüft Offenes await bauen(ctx) # idempotent: generiert Fehlendes nach, prüft Offenes
return True return True

View File

@@ -83,7 +83,9 @@ ANKER_OVERLAP_MERGE = 0.5 # Span-Überlappung ab der zwei Atome automatisch m
FUZZY_MIN_ZEICHEN = 40 # kürzere Zitate nie fuzzy (alnum-gefaltet nicht mehr eindeutig) FUZZY_MIN_ZEICHEN = 40 # kürzere Zitate nie fuzzy (alnum-gefaltet nicht mehr eindeutig)
FUZZY_FEHLERQUOTE = 0.08 # max. Edit-Distanz als Anteil der Zitatlänge FUZZY_FEHLERQUOTE = 0.08 # max. Edit-Distanz als Anteil der Zitatlänge
MERGE_PANEL = 2 # Judges pro Merge-Kandidat — Merge nur einstimmig MERGE_PANEL = 2 # Judges pro Merge-Kandidat — Merge nur einstimmig
MERGE_KANDIDAT_COS = 0.75 # Definitions-Ähnlichkeit ab der ein Paar zum Panel geht MERGE_KANDIDAT_COS = 0.65 # Definitions-Ähnlichkeit ab der ein Paar zum Panel geht
# (0.75 verpasste echte Dubletten bei cos 0.680.71, aak;
# Fehlurteile fängt das einstimmige Panel)
MERGE_KANDIDAT_JACCARD = 0.3 # Schwelle im lexikalischen Fallback (misst viel niedriger als Kosinus) MERGE_KANDIDAT_JACCARD = 0.3 # Schwelle im lexikalischen Fallback (misst viel niedriger als Kosinus)
LUECKEN_RUNDEN_MAX = 2 # gezielte Nachextraktion pro Soll-Lücke LUECKEN_RUNDEN_MAX = 2 # gezielte Nachextraktion pro Soll-Lücke
@@ -93,6 +95,7 @@ CODE_SPRACHEN = ("python", "javascript") # Syntax-Gate parse-only (Container: p
ARTEFAKT_CHUNK_ATOME = 8 # Atome pro Generate-Call (ein Soll-Punkt, gestückelt) ARTEFAKT_CHUNK_ATOME = 8 # Atome pro Generate-Call (ein Soll-Punkt, gestückelt)
VERIFY_PANEL = 2 # unabhängige Prüfer, einstimmig = verifiziert VERIFY_PANEL = 2 # unabhängige Prüfer, einstimmig = verifiziert
AUSSAGEN_JE_ATOM = 2 # Prüfungs-PAARE je Atom (je Paar: 1 wahre + 1 falsche Aussage) AUSSAGEN_JE_ATOM = 2 # Prüfungs-PAARE je Atom (je Paar: 1 wahre + 1 falsche Aussage)
NACHFUELL_CAP = 2 # Nachfüllen endet bei verworfen ≥ Cap × Ziel (Anti-Churn)
# Faktenbasis = Quelltext-FENSTER um den Anker (nicht nur das Zitat): der Inhalt # Faktenbasis = Quelltext-FENSTER um den Anker (nicht nur das Zitat): der Inhalt
# eines Verfahrens folgt meist NACH seiner Nennung (Pseudo-Code, Ablauf). # eines Verfahrens folgt meist NACH seiner Nennung (Pseudo-Code, Ablauf).
BELEG_FENSTER_VOR = 300 # Zeichen vor dem Anker-Span BELEG_FENSTER_VOR = 300 # Zeichen vor dem Anker-Span

View File

@@ -90,10 +90,22 @@ _TEX_UMLAUTE = {"a": "ä", "o": "ö", "u": "ü", "A": "Ä", "O": "Ö", "U": "Ü"
# \ss ist ein Kontrollwort und frisst das Folge-Leerzeichen: hei\ss t → heißt # \ss ist ein Kontrollwort und frisst das Folge-Leerzeichen: hei\ss t → heißt
_TEX_SZ = re.compile(r"\\ss(?![a-zA-Z])(?:\{\})?[ \t]?") _TEX_SZ = re.compile(r"\\ss(?![a-zA-Z])(?:\{\})?[ \t]?")
# Parameterlose Textmakros aus der Präambel: \newcommand{\PP}{\mathrm{P}}.
# Ohne Expansion bleibt \PP roh im Rumpf stehen — der Reader las daraus die
# „Klasse PP" (aak: P-Definition falsch betitelt, falsches Level, falscher
# Baustein). Eine Klammer-Schachtelungsebene reicht für \mathrm{P}-Fälle.
_TEX_MAKRO = re.compile(
r"\\(?:re)?newcommand\*?\{?\\([A-Za-z]+)\}?\{((?:[^{}]|\{[^{}]*\})*)\}")
def _tex_normalisieren(inhalt: str) -> str: def _tex_normalisieren(inhalt: str) -> str:
_, sep, rumpf = inhalt.partition(r"\begin{document}") praeambel, sep, rumpf = inhalt.partition(r"\begin{document}")
if sep: if sep:
makros = {m.group(1): m.group(2) for m in _TEX_MAKRO.finditer(praeambel)
if "#" not in m.group(2)} # nur parameterlos
for name in sorted(makros, key=len, reverse=True): # \PP vor \P
rumpf = re.sub(rf"\\{name}(?![a-zA-Z])(?:\{{\}})?",
makros[name].replace("\\", "\\\\"), rumpf)
inhalt = rumpf inhalt = rumpf
inhalt = inhalt.replace(r"\end{document}", "") inhalt = inhalt.replace(r"\end{document}", "")
inhalt = _TEX_UMLAUT.sub(lambda m: _TEX_UMLAUTE[m.group(1)], inhalt) inhalt = _TEX_UMLAUT.sub(lambda m: _TEX_UMLAUTE[m.group(1)], inhalt)

View File

@@ -14,6 +14,7 @@ from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel from pydantic import BaseModel
import agents import agents
import artefakte
import db import db
import guide import guide
import ledger import ledger
@@ -156,11 +157,17 @@ def state(topic: str):
raise HTTPException(404, "unbekanntes Topic") raise HTTPException(404, "unbekanntes Topic")
run = db.one("SELECT * FROM runs WHERE topic=? ORDER BY id DESC", (topic,)) run = db.one("SELECT * FROM runs WHERE topic=? ORDER BY id DESC", (topic,))
atome = db.query("SELECT * FROM atome WHERE topic=? ORDER BY id", (topic,)) atome = db.query("SELECT * FROM atome WHERE topic=? ORDER BY id", (topic,))
# Nenner = SOLL je Atom (2 Karten + 1 Beispiel + 4 Aussagen), Zähler =
# Verifizierte, je Typ aufs Ziel gekappt — so liest sich die Karte als
# Fortschritt Richtung Vollständigkeit, nicht als Zeilenzählung.
ziel_gesamt = sum(artefakte.ZIEL_ZEILEN.values())
fuer_atom = {} fuer_atom = {}
for a in db.query( for a in db.query(
"SELECT atom_id, SUM(status='verifiziert') AS ok, COUNT(*) AS n FROM artefakte" "SELECT atom_id, typ, SUM(status='verifiziert') AS ok FROM artefakte"
" WHERE atom_id IN (SELECT id FROM atome WHERE topic=?) GROUP BY atom_id", (topic,)): " WHERE atom_id IN (SELECT id FROM atome WHERE topic=?)"
fuer_atom[a["atom_id"]] = {"verifiziert": a["ok"], "gesamt": a["n"]} " GROUP BY atom_id, typ", (topic,)):
e = fuer_atom.setdefault(a["atom_id"], {"verifiziert": 0, "gesamt": ziel_gesamt})
e["verifiziert"] += min(a["ok"], artefakte.ZIEL_ZEILEN.get(a["typ"], 0))
return { return {
"topic": t, "run": run, "topic": t, "run": run,
"ebenen": _ebenen_info(t, run), "ebenen": _ebenen_info(t, run),
@@ -171,7 +178,8 @@ def state(topic: str):
"soll": db.query("SELECT * FROM soll WHERE topic=? AND status='bestaetigt'" "soll": db.query("SELECT * FROM soll WHERE topic=? AND status='bestaetigt'"
" ORDER BY id", (topic,)) " ORDER BY id", (topic,))
or db.query("SELECT * FROM soll WHERE topic=? ORDER BY id", (topic,)), or db.query("SELECT * FROM soll WHERE topic=? ORDER BY id", (topic,)),
"atome": [{**a, "artefakte": fuer_atom.get(a["id"], {"verifiziert": 0, "gesamt": 0})} "atome": [{**a, "artefakte": fuer_atom.get(a["id"],
{"verifiziert": 0, "gesamt": ziel_gesamt})}
for a in atome], for a in atome],
"lernziele": db.query("SELECT * FROM lernziele WHERE topic=? ORDER BY id", (topic,)), "lernziele": db.query("SELECT * FROM lernziele WHERE topic=? ORDER BY id", (topic,)),
"bausteine": db.query( "bausteine": db.query(

View File

@@ -26,6 +26,8 @@ GEWICHTE = {
"soll_kandidat_offen": 3.0, "soll_kandidat_offen": 3.0,
"atom_ohne_anker": 3.0, "atom_ohne_soll": 1.5, "soll_ohne_atom": 3.0, "atom_ohne_anker": 3.0, "atom_ohne_soll": 1.5, "soll_ohne_atom": 3.0,
"atom_ohne_flashcard": 1.5, "atom_ohne_aussage": 1.5, "artefakt_unentschieden": 0.5, "atom_ohne_flashcard": 1.5, "atom_ohne_aussage": 1.5, "artefakt_unentschieden": 0.5,
"flashcard_unvollstaendig": 1.5, "atom_ohne_beispiel": 1.5,
"paar_unvollstaendig": 1.5, "artefakt_aufgegeben": 0.5,
"partition": 3.0, "band": 0.5, "ordnung_fallback": 1.5, "partition": 3.0, "band": 0.5, "ordnung_fallback": 1.5,
"level_mix": 3.0, "kapitel_level": 3.0, "level_mix": 3.0, "kapitel_level": 3.0,
"baustein_ohne_kapitel": 3.0, "kapitel_leer": 1.5, "kapitel_fallback": 1.5, "baustein_ohne_kapitel": 3.0, "kapitel_leer": 1.5, "kapitel_fallback": 1.5,

View File

@@ -14,6 +14,7 @@ const neuName = ref('')
const neuArt = ref('thema') const neuArt = ref('thema')
const fehler = ref('') const fehler = ref('')
const theme = ref(localStorage.getItem('theme') || 'auto') const theme = ref(localStorage.getItem('theme') || 'auto')
const guideRef = ref(null) // Header-Play-Button startet den Lernmodus im Guide
// mobil (≤768px) startet die Navigation eingeklappt, sofern nie manuell gesetzt // mobil (≤768px) startet die Navigation eingeklappt, sofern nie manuell gesetzt
const navAuf = ref(localStorage.getItem('navAuf') !== null const navAuf = ref(localStorage.getItem('navAuf') !== null
? localStorage.getItem('navAuf') !== '0' ? localStorage.getItem('navAuf') !== '0'
@@ -209,6 +210,8 @@ function runBadge(run) {
<span v-if="state?.run?.grund" class="badge gelb">{{ state.run.grund }}</span> <span v-if="state?.run?.grund" class="badge gelb">{{ state.run.grund }}</span>
<span v-if="fehler" class="badge rot">{{ fehler }}</span> <span v-if="fehler" class="badge rot">{{ fehler }}</span>
<span style="flex: 1"></span> <span style="flex: 1"></span>
<button v-if="tab === 'guide' && state" class="lm-start" title="Lernmodus (Vollbild)"
@click="guideRef?.lesemodusStarten()"></button>
<select v-model="theme" title="Farbschema"> <select v-model="theme" title="Farbschema">
<option value="hell">hell</option> <option value="hell">hell</option>
<option value="dunkel">dunkel</option> <option value="dunkel">dunkel</option>
@@ -219,7 +222,7 @@ function runBadge(run) {
<div class="inhalt"> <div class="inhalt">
<Board v-if="tab === 'board'" :state="state" :topic="topic" @reload="stateLaden" <Board v-if="tab === 'board'" :state="state" :topic="topic" @reload="stateLaden"
@fehler="(e) => (fehler = e)" /> @fehler="(e) => (fehler = e)" />
<Guide v-else-if="tab === 'guide'" :topic="topic" :state="state" /> <Guide v-else-if="tab === 'guide'" ref="guideRef" :topic="topic" :state="state" />
<Ueben v-else-if="tab === 'üben'" :topic="topic" /> <Ueben v-else-if="tab === 'üben'" :topic="topic" />
<Kennzahlen v-else :run="state?.run" /> <Kennzahlen v-else :run="state?.run" />
</div> </div>

View File

@@ -55,6 +55,16 @@ const info = (key) => props.state?.ebenen?.find((e) => e.name === key) || {}
const aktiveAtome = computed(() => const aktiveAtome = computed(() =>
(props.state?.atome || []).filter((a) => !['gemerged', 'verworfen'].includes(a.status))) (props.state?.atome || []).filter((a) => !['gemerged', 'verworfen'].includes(a.status)))
// Spaltenkopf Artefakte: Gesamtfortschritt = Summe verifiziert / Summe Soll
const artefakteSumme = computed(() => {
let ok = 0, ziel = 0
for (const a of aktiveAtome.value) {
ok += a.artefakte?.verifiziert || 0
ziel += a.artefakte?.gesamt || 0
}
return { ok, ziel }
})
function karten(key) { function karten(key) {
const s = props.state const s = props.state
if (!s) return [] if (!s) return []
@@ -146,7 +156,11 @@ const k = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1000 ? `${Math.ro
<div class="spaltekopf"> <div class="spaltekopf">
<div class="zeile"> <div class="zeile">
<span class="titel">{{ e.titel }} <span class="titel">{{ e.titel }}
<span class="badge">{{ karten(e.key).length }}</span></span> <span class="badge">{{ karten(e.key).length }}</span>
<span v-if="e.key === 'artefakte' && artefakteSumme.ziel" class="badge"
:class="artefakteSumme.ok >= artefakteSumme.ziel ? 'gruen' : ''">
{{ artefakteSumme.ok }}/{{ artefakteSumme.ziel }} verifiziert
</span></span>
<span class="badge" :class="info(e.key).laeuft ? 'blau' : info(e.key).fertig ? 'gruen' : ''"> <span class="badge" :class="info(e.key).laeuft ? 'blau' : info(e.key).fertig ? 'gruen' : ''">
{{ info(e.key).laeuft ? 'läuft' : info(e.key).fertig ? 'fertig' : 'offen' }} {{ info(e.key).laeuft ? 'läuft' : info(e.key).fertig ? 'fertig' : 'offen' }}
</span> </span>

View File

@@ -6,6 +6,8 @@ import Lesemodus from './Lesemodus.vue'
const props = defineProps({ topic: String, state: Object }) const props = defineProps({ topic: String, state: Object })
const lesemodus = ref(false) // Vollbild-Lernmodus (Fundament + Abrufprüfung) const lesemodus = ref(false) // Vollbild-Lernmodus (Fundament + Abrufprüfung)
// Header-Play-Button (App.vue) startet den Lernmodus von außen
defineExpose({ lesemodusStarten: () => { lesemodus.value = true } })
const daten = ref(null) const daten = ref(null)
const level = ref('E') // Durchgang-Wahl: jede Stufe ist ein eigener Lese-Durchgang const level = ref('E') // Durchgang-Wahl: jede Stufe ist ein eigener Lese-Durchgang
const ansicht = ref('erklaerend') // erklaerend = Fließtext | kompakt = Stichpunkte const ansicht = ref('erklaerend') // erklaerend = Fließtext | kompakt = Stichpunkte
@@ -158,8 +160,6 @@ function onScroll(e) {
</button> </button>
<button class="schalter-solo" title="Fokus-Modus (ablenkungsfrei lesen)" <button class="schalter-solo" title="Fokus-Modus (ablenkungsfrei lesen)"
@click="fokus = true"></button> @click="fokus = true"></button>
<button v-if="daten" class="schalter-solo lm-start" title="Lernmodus (Vollbild)"
@click="lesemodus = true"></button>
</div> </div>
<div class="fortschritt"> <div class="fortschritt">
<div>Kapitel {{ aktiv + 1 }} / {{ anzeige.length }}</div> <div>Kapitel {{ aktiv + 1 }} / {{ anzeige.length }}</div>

View File

@@ -61,6 +61,24 @@ function taste(e) {
else if (e.key === 'ArrowLeft') zurueck() else if (e.key === 'ArrowLeft') zurueck()
} }
// Wisch-Navigation (mobil): klar horizontal + kurz genug = blättern.
// Vertikales Scrollen und Text-Markieren bleiben unberührt (dy-Vergleich).
let wisch = null
function wischStart(e) {
const t = e.touches[0]
wisch = { x: t.clientX, y: t.clientY }
}
function wischEnde(e) {
if (!wisch) return
const t = e.changedTouches[0]
const dx = t.clientX - wisch.x
const dy = t.clientY - wisch.y
wisch = null
if (Math.abs(dx) < 60 || Math.abs(dx) < 2 * Math.abs(dy)) return
if (dx < 0) vor()
else zurueck()
}
// ── Abrufprüfung (Karteikarten) ──────────────────────────────────────────────── // ── Abrufprüfung (Karteikarten) ────────────────────────────────────────────────
const stapel = ref([]) const stapel = ref([])
const zeigeAntwort = ref(false) const zeigeAntwort = ref(false)
@@ -155,7 +173,8 @@ onUnmounted(() => {
</script> </script>
<template> <template>
<div ref="wurzel" class="lesemodus"> <div ref="wurzel" class="lesemodus"
@touchstart.passive="wischStart" @touchend.passive="wischEnde">
<header class="lm-kopf"> <header class="lm-kopf">
<span class="lm-fort">{{ fortschrittText }}</span> <span class="lm-fort">{{ fortschrittText }}</span>
<button class="lm-x" title="Schließen" @click="schliessen"></button> <button class="lm-x" title="Schließen" @click="schliessen"></button>
@@ -205,7 +224,6 @@ onUnmounted(() => {
'pf-wahr': urteil(x.id) === true, 'pf-wahr': urteil(x.id) === true,
'pf-falsch2': urteil(x.id) === false }" 'pf-falsch2': urteil(x.id) === false }"
@click.prevent="toggle(x.id)"> @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> <span class="markdown" v-html="render(x.text)"></span>
</label> </label>
</div> </div>

View File

@@ -332,8 +332,12 @@ table.kennzahlen th { color: var(--dim); }
padding: 12px 14px; border: 1px solid var(--rand); border-radius: 10px; padding: 12px 14px; border: 1px solid var(--rand); border-radius: 10px;
background: var(--karte); background: var(--karte);
} }
.pf-aussage input { margin-top: 5px; flex: 0 0 auto; } /* Auswahl ohne Checkbox: die ganze Karte ist der Klickbereich */
.pf-aussage.gewaehlt { border-color: var(--akzent); } .pf-aussage.gewaehlt {
border-color: var(--akzent);
background: color-mix(in srgb, var(--akzent) 10%, var(--karte));
box-shadow: inset 3px 0 0 var(--akzent);
}
.pf-aussage.pf-wahr { border-color: #2f7d32; background: color-mix(in srgb, #2f7d32 14%, var(--karte)); } .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-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-feedback { margin-top: 14px; padding: 10px 14px; border-radius: 8px; font-size: 15px; }

View File

@@ -4,23 +4,23 @@ Unten Atome eines Lernthemas (Titel, Definition, Beleg-Zitate).
Erzeuge pro Atom: Erzeuge pro Atom:
- 2 Flashcards (typ "flashcard"): eine Frage + präzise Antwort. Die Antwort stützt sich - 2 Flashcards (typ "flashcard"): eine Frage + präzise Antwort. Die Antwort stützt sich
NUR auf Definition und Belege — nichts erfinden, nicht neu recherchieren. NUR auf Definition und Belege — nichts erfinden, nicht neu recherchieren.
- Ggf. 1 Worked Example (typ "beispiel"): die kleinste Instanz, die das Atom klärt. - GENAU 1 Worked Example (typ "beispiel"): die kleinste Instanz, die das Atom klärt.
Alle Werte/Aussagen aus den Belegen ableitbar. Concreteness Fading: erst konkret, Die Instanz darfst du selbst konstruieren (kleine Zahlen, Mini-Graph) — die
dann kurz verallgemeinern. Auswahl EINDEUTIG und MINIMAL — keine Oberflächen- METHODE und alle Behauptungen müssen den Belegen folgen, die Rechnung an der
Instanz muss korrekt sein. Concreteness Fading: erst konkret, dann kurz
verallgemeinern. Auswahl EINDEUTIG und MINIMAL — keine Oberflächen-
Überlappung zwischen Aufgabe und Lösung (sonst Fehlvorstellungen). Überlappung zwischen Aufgabe und Lösung (sonst Fehlvorstellungen).
OB und WELCHE Form richtet sich nach dem Atom-Typ [in eckigen Klammern]: Die Form richtet sich nach dem Atom-Typ [in eckigen Klammern]:
- [verfahren] → FAST IMMER ein Beispiel. Form "code" (Felder "sprache" ∈ - [verfahren] → Form "code" (Felder "sprache" ∈ {{python, javascript}}, "code")
{{python, javascript}}, "code") bei Programmier-/Algorithmus-Konzepten, sonst bei Programmier-/Algorithmus-Konzepten, sonst "text" als durchgerechneter
"text" als durchgerechneter Schritt-für-Schritt-Ablauf. Schritt-für-Schritt-Ablauf.
- [begriff] → NUR wenn abstrakt/schwer greifbar. Dann "text"/"mathe" (konkrete - [begriff] → "text"/"mathe" (konkrete Instanz) oder "tabelle" (Fälle/Vergleich).
Instanz) oder "tabelle" (Fälle/Vergleich). Simpler, selbsterklärender Begriff → KEIN Beispiel. - [aussage] → "mathe"/"text" (Anwendung an einer Mini-Instanz).
- [aussage] → NUR wenn nicht trivial. Dann "mathe"/"text" (Anwendung an Mini-Instanz). Nur EINE Form. Code selbsterklärend, ohne I/O-Tricks.
Nur EINE Form. Im Zweifel WEGLASSEN — ein schwaches/redundantes Beispiel schadet
(Forschung: die Auswahl ist kritisch). Code selbsterklärend, ohne I/O-Tricks.
Regeln: Regeln:
- Atom-ids wörtlich übernehmen, keine erfinden, keine Atome auslassen (außer Beispiel s. o.). - Atom-ids wörtlich übernehmen, keine erfinden, keine Atome auslassen.
- Fragen müssen aus der Antwort heraus fair beantwortbar sein (keine Fangfragen, - Fragen müssen aus der Antwort heraus fair beantwortbar sein (keine Fangfragen,
keine Rezitation exakter Formulierungen verlangen). keine Rezitation exakter Formulierungen verlangen).
- SELBSTSTÄNDIGKEIT: Jede Karte muss ohne die Quelle funktionieren. Wörter wie - SELBSTSTÄNDIGKEIT: Jede Karte muss ohne die Quelle funktionieren. Wörter wie

View File

@@ -3,6 +3,10 @@ Unten Artefakte (Flashcards/Beispiele) mit den Beleg-Zitaten ihres Atoms.
Prüfe jedes Artefakt GEGEN DIE BELEGE: Prüfe jedes Artefakt GEGEN DIE BELEGE:
- ok=false, wenn eine Aussage den Belegen widerspricht oder nicht aus ihnen ableitbar ist. - ok=false, wenn eine Aussage den Belegen widerspricht oder nicht aus ihnen ableitbar ist.
- AUSNAHME für Beispiele: eine selbst konstruierte Mini-Instanz (erfundene Zahlen,
kleiner Graph, kurze Formel) ist KEIN Mangel — solange die METHODE den Belegen
folgt und die Rechnung an der Instanz korrekt durchgeführt ist. ok=false nur,
wenn Methode oder Ergebnis den Belegen widerspricht oder die Rechnung falsch ist.
- ok=false, wenn die Frage aus der Antwort heraus nicht fair beantwortbar ist. - ok=false, wenn die Frage aus der Antwort heraus nicht fair beantwortbar ist.
- ok=false, wenn Frage oder Antwort auf Beleg, Quelle, Musterlösung oder einen - ok=false, wenn Frage oder Antwort auf Beleg, Quelle, Musterlösung oder einen
Aufgabenkontext verweist — Karten müssen ohne den Quelltext selbstständig Aufgabenkontext verweist — Karten müssen ohne den Quelltext selbstständig

View File

@@ -27,6 +27,15 @@ def test_referenz_muster_trifft_nur_referenzen():
assert not any(artefakte._referenziert_quelle(k) for k in gut) assert not any(artefakte._referenziert_quelle(k) for k in gut)
def _voll_versorgen(atom_id: int) -> None:
"""2 Flashcards + 1 Beispiel — Atom ohne Generate-Bedarf."""
for i in range(artefakte.FC_ZIEL):
db.insert("artefakte", atom_id=atom_id, typ="flashcard", status="verifiziert",
inhalt=db.j({"frage": f"F{i}?", "antwort": "A"}))
db.insert("artefakte", atom_id=atom_id, typ="beispiel", status="verifiziert",
inhalt=db.j({"form": "text", "text": "B"}))
async def test_nur_ohne_sieht_fehlende_flashcard(): async def test_nur_ohne_sieht_fehlende_flashcard():
"""Ein lebendes Beispiel darf die Flashcard-Nachgenerierung nicht blockieren.""" """Ein lebendes Beispiel darf die Flashcard-Nachgenerierung nicht blockieren."""
topic = topic_anlegen("fcfehlt") topic = topic_anlegen("fcfehlt")
@@ -37,13 +46,68 @@ async def test_nur_ohne_sieht_fehlende_flashcard():
# a: nur ein Beispiel, keine Flashcard → muss nachgeneriert werden # a: nur ein Beispiel, keine Flashcard → muss nachgeneriert werden
db.insert("artefakte", atom_id=a, typ="beispiel", status="verifiziert", db.insert("artefakte", atom_id=a, typ="beispiel", status="verifiziert",
inhalt=db.j({"text": "Beispiel"})) inhalt=db.j({"text": "Beispiel"}))
# b: verifizierte Flashcard → versorgt, nicht mehr in nur_ohne # b: voll versorgt (2 Karten + Beispiel) → kein Bedarf
db.insert("artefakte", atom_id=b, typ="flashcard", status="verifiziert", _voll_versorgen(b)
inhalt=db.j({"frage": "F", "antwort": "A"}))
ids = [x["id"] for c in artefakte._chunks(topic, nur_ohne=True) for x in c] ids = [x["id"] for c in artefakte._chunks(topic, nur_ohne=True) for x in c]
assert ids == [a] assert ids == [a]
async def test_nachfuellen_bei_einer_lebenden_flashcard():
"""1 überlebende Karte blockierte früher die zweite für immer (Ziel: 2)."""
topic = topic_anlegen("fcauffuell")
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
status="neu", soll_id=1)
db.insert("artefakte", atom_id=a, typ="flashcard", status="verifiziert",
inhalt=db.j({"frage": "F?", "antwort": "A"}))
db.insert("artefakte", atom_id=a, typ="beispiel", status="verifiziert",
inhalt=db.j({"form": "text", "text": "B"}))
assert artefakte._gen_bedarf(a)
run = run_anlegen(topic)
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "artefakte"
atom = db.one("SELECT * FROM atome WHERE id=?", (a,))
await artefakte._generieren(ctx, [atom]) # Fake liefert 2 Karten + Beispiel
fc = artefakte._lebende(a, "flashcard")
assert len(fc) == artefakte.FC_ZIEL # Guard nimmt nur die EINE fehlende
assert len(artefakte._lebende(a, "beispiel")) == 1 # kein zweites Beispiel
async def test_beispiel_refill_nach_verwurf():
"""Ein verworfenes Beispiel wurde nie ersetzt — jetzt Pflicht je Atom."""
topic = topic_anlegen("bsprefill")
a = db.insert("atome", topic=topic, titel="X", typ="verfahren", definition="d",
status="neu", soll_id=1)
for i in range(artefakte.FC_ZIEL):
db.insert("artefakte", atom_id=a, typ="flashcard", status="verifiziert",
inhalt=db.j({"frage": f"F{i}?", "antwort": "A"}))
db.insert("artefakte", atom_id=a, typ="beispiel", status="verworfen",
inhalt=db.j({"form": "text", "text": "kaputt"}))
assert artefakte._gen_bedarf(a)
run = run_anlegen(topic)
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "artefakte"
await artefakte._generieren(ctx, [db.one("SELECT * FROM atome WHERE id=?", (a,))])
assert len(artefakte._lebende(a, "beispiel")) == 1
assert len(artefakte._lebende(a, "flashcard")) == artefakte.FC_ZIEL
def test_cap_stoppt_nachfuellen():
"""Ab NACHFUELL_CAP × Ziel Verwürfen gibt das Atom auf (Anti-Churn)."""
from config import NACHFUELL_CAP
topic = topic_anlegen("cap")
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
status="neu", soll_id=1)
_voll_versorgen(a)
for _ in range(NACHFUELL_CAP * 1): # Beispiel-Ziel = 1
db.insert("artefakte", atom_id=a, typ="beispiel", status="verworfen",
inhalt=db.j({"form": "text", "text": "x"}))
# lebendes Beispiel löschen → fehlt, aber aufgegeben
db.execute("DELETE FROM artefakte WHERE atom_id=? AND typ='beispiel'"
" AND status='verifiziert'", (a,))
assert artefakte._aufgegeben(a, "beispiel")
assert not artefakte._gen_bedarf(a)
async def test_reverify_ausfall_laesst_kandidat(monkeypatch): async def test_reverify_ausfall_laesst_kandidat(monkeypatch):
"""Re-Verify-Ausfall (kein Urteil zur id) → Kandidat bleibt, nicht verworfen.""" """Re-Verify-Ausfall (kein Urteil zur id) → Kandidat bleibt, nicht verworfen."""
import fake_agents import fake_agents
@@ -81,6 +145,139 @@ async def test_guard_verwirft_kandidat_und_repair_kann_nachlegen():
assert [x["id"] for c in chunks for x in c] == [a] assert [x["id"] for c in chunks for x in c] == [a]
def _paar_einfuegen(atom_id: int, i: int, status_wahr: str, status_falsch: str) -> tuple[int, int]:
paar = f"{atom_id}-{i}"
w = db.insert("artefakte", atom_id=atom_id, typ="aussage", status=status_wahr,
inhalt=db.j({"text": f"wahr {i}", "wahr": True, "paar": paar,
"erklaerung": "e"}))
f = db.insert("artefakte", atom_id=atom_id, typ="aussage", status=status_falsch,
inhalt=db.j({"text": f"falsch {i}", "wahr": False, "paar": paar,
"erklaerung": "e"}))
return w, f
def test_paare_bereinigen_verwirft_restseite():
topic = topic_anlegen("paarrest")
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
status="neu", soll_id=1)
w1, f1 = _paar_einfuegen(a, 0, "verifiziert", "verworfen") # einseitig → fällt
w2, f2 = _paar_einfuegen(a, 1, "verifiziert", "verifiziert") # komplett → bleibt
artefakte._paare_bereinigen([a])
assert db.one("SELECT status FROM artefakte WHERE id=?", (w1,))["status"] == "verworfen"
assert db.one("SELECT status FROM artefakte WHERE id=?", (w2,))["status"] == "verifiziert"
assert db.one("SELECT status FROM artefakte WHERE id=?", (f2,))["status"] == "verifiziert"
def test_paare_bereinigen_verwirft_deformierte_gruppe():
"""Gruppen ohne {wahr,falsch}-Seiten fallen KOMPLETT — auch 2 gleiche Seiten
ohne paar-Key (Altlast des fehlenden Typ-Filters in _verifizieren)."""
topic = topic_anlegen("paardeform")
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
status="neu", soll_id=1)
k1 = db.insert("artefakte", atom_id=a, typ="aussage", status="verifiziert",
inhalt=db.j({"text": "f1", "wahr": False})) # kein paar-Key
k2 = db.insert("artefakte", atom_id=a, typ="aussage", status="verifiziert",
inhalt=db.j({"text": "f2", "wahr": False}))
artefakte._paare_bereinigen([a])
for k in (k1, k2):
assert db.one("SELECT status FROM artefakte WHERE id=?", (k,))["status"] == "verworfen"
async def test_verifizieren_ignoriert_aussage_kandidaten():
"""Aussage-Kandidaten gehören NUR ins Aussagen-Panel — das Flashcard-Verify
überschrieb ihren Inhalt sonst mit dem frage/antwort-Schema (leere Hülle)."""
topic = topic_anlegen("typfilter")
run = run_anlegen(topic)
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
status="neu", soll_id=1)
k = db.insert("artefakte", atom_id=a, typ="aussage", status="kandidat",
inhalt=db.j({"text": "t", "wahr": True, "paar": f"{a}-0",
"erklaerung": "t"}))
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "artefakte"
atom = db.one("SELECT * FROM atome WHERE id=?", (a,))
await artefakte._verifizieren(ctx, [atom])
zeile = db.one("SELECT * FROM artefakte WHERE id=?", (k,))
assert zeile["status"] == "kandidat" # unangetastet
assert db.uj(zeile["inhalt"])["paar"] == f"{a}-0" # Inhalt intakt
async def test_aussagen_verify_verwirft_paarweise(monkeypatch):
"""Kippt eine Seite im Panel, fällt auch die frisch bestätigte Partnerseite."""
import fake_agents
topic = topic_anlegen("paarvote")
run = run_anlegen(topic)
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
status="neu", soll_id=1)
w, f = _paar_einfuegen(a, 0, "kandidat", "kandidat")
def verify(prompt):
import re
ids = [int(m) for m in re.findall(r"AUSSAGE (\d+) \[", prompt)]
return [{"aussage": i, "ok": i != f} for i in ids] # falsche Seite fällt
monkeypatch.setitem(fake_agents._HANDLER, "Aussagen-Verify", verify)
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "artefakte"
atom = db.one("SELECT * FROM atome WHERE id=?", (a,))
await artefakte._aussagen_verifizieren(ctx, [atom])
assert db.one("SELECT status FROM artefakte WHERE id=?", (w,))["status"] == "verworfen"
assert db.one("SELECT status FROM artefakte WHERE id=?", (f,))["status"] == "verworfen"
async def test_aussagen_nachfuellen_ohne_paar_kollision():
"""Neue Paare setzen den Index HINTER allen bestehenden Zeilen fort."""
from config import AUSSAGEN_JE_ATOM
topic = topic_anlegen("paaridx")
run = run_anlegen(topic)
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
status="neu", soll_id=1)
_paar_einfuegen(a, 0, "verworfen", "verworfen") # alter Index 0 bleibt belegt
_paar_einfuegen(a, 1, "verifiziert", "verifiziert")
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "artefakte"
atom = db.one("SELECT * FROM atome WHERE id=?", (a,))
await artefakte._aussagen_generieren(ctx, [atom]) # Bedarf: 1 Paar
lebend = artefakte._lebende(a, "aussage")
paare = {db.uj(k["inhalt"])["paar"] for k in lebend}
assert f"{a}-0" not in paare # kein Recycling des alten Keys
komplett, einseitig, _ = artefakte._paar_lage(a)
assert (komplett, einseitig) == (AUSSAGEN_JE_ATOM, 0)
def test_messen_meldet_vollstaendigkeit_und_cap():
from config import NACHFUELL_CAP
topic = topic_anlegen("messneu")
run = run_anlegen(topic)
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
status="neu", soll_id=1)
db.insert("artefakte", atom_id=a, typ="flashcard", status="verifiziert",
inhalt=db.j({"frage": "F?", "antwort": "A"})) # 1 von 2
_paar_einfuegen(a, 0, "verifiziert", "verworfen") # einseitig
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "artefakte"
arten = {b["art"] for b in artefakte.messen(ctx)}
assert {"flashcard_unvollstaendig", "atom_ohne_beispiel",
"paar_unvollstaendig"} <= arten
# Cap: genug Beispiel-Verwürfe → aufgegeben statt fehlt
for _ in range(NACHFUELL_CAP):
db.insert("artefakte", atom_id=a, typ="beispiel", status="verworfen",
inhalt=db.j({"form": "text", "text": "x"}))
arten = {b["art"] for b in artefakte.messen(ctx)}
assert "artefakt_aufgegeben" in arten and "atom_ohne_beispiel" not in arten
async def test_reparieren_false_bei_nur_aufgegeben():
topic = topic_anlegen("repfalse")
run = run_anlegen(topic)
ctx = llm.Kontext(run, topic, "minimax")
befunde = [{"art": "artefakt_aufgegeben", "item": "1-beispiel", "detail": "X"},
{"art": "artefakt_unentschieden", "item": "2", "detail": "Y"}]
assert await artefakte.reparieren(ctx, befunde) is False
befunde.append({"art": "atom_ohne_beispiel", "item": "1", "detail": "X"})
assert await artefakte.reparieren(ctx, befunde) is True
def test_tabelle_struktur_check(): def test_tabelle_struktur_check():
# wohlgeformt # wohlgeformt
ok = "| A | B |\n|---|---|\n| 1 | 2 |" ok = "| A | B |\n|---|---|\n| 1 | 2 |"

View File

@@ -176,6 +176,21 @@ def test_tex_normalisieren():
assert korpus._tex_normalisieren("\\ssname bleibt") == "\\ssname bleibt" assert korpus._tex_normalisieren("\\ssname bleibt") == "\\ssname bleibt"
def test_tex_makro_expansion():
"""Parameterlose Präambel-Makros werden im Rumpf ersetzt (aak: \\PP → P)."""
tex = ("\\newcommand{\\PP}{\\mathrm{P}}\n\\newcommand{\\P}{\\mathbf{P}}\n"
"\\newcommand{\\half}[1]{\\frac{#1}{2}}\n\\begin{document}\n"
"Klasse $\\PP$ und $\\P$ sowie $\\PP=\\NP$? Und \\half{x} bleibt.\n"
"\\end{document}\n")
e = korpus._tex_normalisieren(tex)
assert "$\\mathrm{P}$" in e # \PP expandiert
assert "$\\mathbf{P}$" in e # \P getrennt (längster Name zuerst)
assert "\\mathrm{P}=\\NP" in e # \NP unbekannt → bleibt
assert "\\half{x}" in e # Makro mit Parameter: unangetastet
# ohne \begin{document} keine Expansion (kein Präambel-Kontext)
assert korpus._tex_normalisieren("\\PP pur") == "\\PP pur"
async def test_uni_quellen_vorrang_tex(tmp_path): async def test_uni_quellen_vorrang_tex(tmp_path):
topic = topic_anlegen("k-tex", art="uni") topic = topic_anlegen("k-tex", art="uni")
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax") ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")