+
diff --git a/frontend/src/style.css b/frontend/src/style.css
index 6673a10..1447967 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -332,8 +332,12 @@ table.kennzahlen th { color: var(--dim); }
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); }
+/* Auswahl ohne Checkbox: die ganze Karte ist der Klickbereich */
+.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-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; }
diff --git a/templates/Artefakt-Generate.md b/templates/Artefakt-Generate.md
index aebdb6e..351ad88 100644
--- a/templates/Artefakt-Generate.md
+++ b/templates/Artefakt-Generate.md
@@ -4,23 +4,23 @@ Unten Atome eines Lernthemas (Titel, Definition, Beleg-Zitate).
Erzeuge pro Atom:
- 2 Flashcards (typ "flashcard"): eine Frage + präzise Antwort. Die Antwort stützt sich
NUR auf Definition und Belege — nichts erfinden, nicht neu recherchieren.
-- Ggf. 1 Worked Example (typ "beispiel"): die kleinste Instanz, die das Atom klärt.
- Alle Werte/Aussagen aus den Belegen ableitbar. Concreteness Fading: erst konkret,
- dann kurz verallgemeinern. Auswahl EINDEUTIG und MINIMAL — keine Oberflächen-
+- GENAU 1 Worked Example (typ "beispiel"): die kleinste Instanz, die das Atom klärt.
+ Die Instanz darfst du selbst konstruieren (kleine Zahlen, Mini-Graph) — die
+ 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).
- OB und WELCHE Form richtet sich nach dem Atom-Typ [in eckigen Klammern]:
- - [verfahren] → FAST IMMER ein Beispiel. Form "code" (Felder "sprache" ∈
- {{python, javascript}}, "code") bei Programmier-/Algorithmus-Konzepten, sonst
- "text" als durchgerechneter Schritt-für-Schritt-Ablauf.
- - [begriff] → NUR wenn abstrakt/schwer greifbar. Dann "text"/"mathe" (konkrete
- Instanz) oder "tabelle" (Fälle/Vergleich). Simpler, selbsterklärender Begriff → KEIN Beispiel.
- - [aussage] → NUR wenn nicht trivial. Dann "mathe"/"text" (Anwendung an Mini-Instanz).
- Nur EINE Form. Im Zweifel WEGLASSEN — ein schwaches/redundantes Beispiel schadet
- (Forschung: die Auswahl ist kritisch). Code selbsterklärend, ohne I/O-Tricks.
+ Die Form richtet sich nach dem Atom-Typ [in eckigen Klammern]:
+ - [verfahren] → Form "code" (Felder "sprache" ∈ {{python, javascript}}, "code")
+ bei Programmier-/Algorithmus-Konzepten, sonst "text" als durchgerechneter
+ Schritt-für-Schritt-Ablauf.
+ - [begriff] → "text"/"mathe" (konkrete Instanz) oder "tabelle" (Fälle/Vergleich).
+ - [aussage] → "mathe"/"text" (Anwendung an einer Mini-Instanz).
+ Nur EINE Form. Code selbsterklärend, ohne I/O-Tricks.
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,
keine Rezitation exakter Formulierungen verlangen).
- SELBSTSTÄNDIGKEIT: Jede Karte muss ohne die Quelle funktionieren. Wörter wie
diff --git a/templates/Artefakt-Verify.md b/templates/Artefakt-Verify.md
index 2bbc3ef..cc2ac4d 100644
--- a/templates/Artefakt-Verify.md
+++ b/templates/Artefakt-Verify.md
@@ -3,6 +3,10 @@ Unten Artefakte (Flashcards/Beispiele) mit den Beleg-Zitaten ihres Atoms.
Prüfe jedes Artefakt GEGEN DIE BELEGE:
- 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 Frage oder Antwort auf Beleg, Quelle, Musterlösung oder einen
Aufgabenkontext verweist — Karten müssen ohne den Quelltext selbstständig
diff --git a/tests/test_artefakte.py b/tests/test_artefakte.py
index 2f3f576..1bc838d 100644
--- a/tests/test_artefakte.py
+++ b/tests/test_artefakte.py
@@ -27,6 +27,15 @@ def test_referenz_muster_trifft_nur_referenzen():
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():
"""Ein lebendes Beispiel darf die Flashcard-Nachgenerierung nicht blockieren."""
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
db.insert("artefakte", atom_id=a, typ="beispiel", status="verifiziert",
inhalt=db.j({"text": "Beispiel"}))
- # b: verifizierte Flashcard → versorgt, nicht mehr in nur_ohne
- db.insert("artefakte", atom_id=b, typ="flashcard", status="verifiziert",
- inhalt=db.j({"frage": "F", "antwort": "A"}))
+ # b: voll versorgt (2 Karten + Beispiel) → kein Bedarf
+ _voll_versorgen(b)
ids = [x["id"] for c in artefakte._chunks(topic, nur_ohne=True) for x in c]
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):
"""Re-Verify-Ausfall (kein Urteil zur id) → Kandidat bleibt, nicht verworfen."""
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]
+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():
# wohlgeformt
ok = "| A | B |\n|---|---|\n| 1 | 2 |"
diff --git a/tests/test_korpus.py b/tests/test_korpus.py
index 1983687..3db4e1d 100644
--- a/tests/test_korpus.py
+++ b/tests/test_korpus.py
@@ -176,6 +176,21 @@ def test_tex_normalisieren():
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):
topic = topic_anlegen("k-tex", art="uni")
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")