update
This commit is contained in:
@@ -13,7 +13,8 @@ import tempfile
|
||||
import belege
|
||||
import db
|
||||
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")
|
||||
|
||||
@@ -51,17 +52,78 @@ def _atom_block(a: dict) -> str:
|
||||
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]]:
|
||||
"""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"
|
||||
" ('gemerged','verworfen') ORDER BY soll_id, id", (topic,))
|
||||
if nur_ohne:
|
||||
# Die echte Invariante ist „≥1 lebende FLASHCARD" (Beispiel zählt nicht).
|
||||
# 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"],))]
|
||||
atome = [a for a in atome if _gen_bedarf(a["id"])]
|
||||
gruppen: dict = {}
|
||||
for a in atome:
|
||||
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",
|
||||
n=len(chunk), item=f"g{chunk[0]['id']}", erwartet=list)
|
||||
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 []:
|
||||
atom_id = e.get("atom")
|
||||
typ = str(e.get("typ", "")).strip()
|
||||
@@ -103,12 +168,14 @@ async def _generieren(ctx: llm.Kontext, chunk: list[dict]) -> None:
|
||||
if _referenziert_quelle(inhalt):
|
||||
log.info("Artefakt zu Atom %s nicht übernommen: referenziert die Quelle", atom_id)
|
||||
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(
|
||||
"SELECT id FROM artefakte WHERE atom_id=? AND typ='beispiel'"
|
||||
" AND status!='verworfen' LIMIT 1", (atom_id,)):
|
||||
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")
|
||||
|
||||
|
||||
@@ -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)."""
|
||||
kandidaten = []
|
||||
for a in chunk:
|
||||
for k in db.query("SELECT * FROM artefakte WHERE atom_id=? AND status='kandidat'",
|
||||
(a["id"],)):
|
||||
# Aussagen haben ihr EIGENES Panel (+Paar-Logik) — landeten sie hier,
|
||||
# ü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
|
||||
db.update("artefakte", "id", k["id"], status="verworfen")
|
||||
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']}",
|
||||
erwartet=list)
|
||||
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 []:
|
||||
if not isinstance(e, dict) or e.get("atom") not in gueltig:
|
||||
continue
|
||||
@@ -290,8 +366,11 @@ async def _aussagen_generieren(ctx: llm.Kontext, chunk: list[dict]) -> None:
|
||||
continue
|
||||
if _referenziert_quelle({"text": f"{wahr} {falsch}"}):
|
||||
continue
|
||||
i = zaehler.get(atom_id, 0)
|
||||
zaehler[atom_id] = i + 1
|
||||
if bedarf.get(atom_id, 0) <= 0:
|
||||
continue
|
||||
bedarf[atom_id] -= 1
|
||||
i = naechster[atom_id]
|
||||
naechster[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,
|
||||
@@ -334,28 +413,37 @@ async def _aussagen_verifizieren(ctx: llm.Kontext, chunk: list[dict]) -> None:
|
||||
return None
|
||||
return urteil
|
||||
|
||||
entschieden: dict[int, str] = {}
|
||||
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")
|
||||
entschieden[k["id"]] = "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:
|
||||
"""gen→gate→verify EINES Chunks verkettet — der Verify braucht nur die eigenen
|
||||
Kandidaten, nicht die Generierung fremder Chunks (Stage-Barriere kostete
|
||||
Wall-Clock: 1 Nachzügler blockierte alle Verifies)."""
|
||||
ohne = [a for a in chunk if not db.one(
|
||||
"SELECT id FROM artefakte WHERE atom_id=? AND typ='flashcard'"
|
||||
" AND status!='verworfen' LIMIT 1", (a["id"],))]
|
||||
ohne = [a for a in chunk if _gen_bedarf(a["id"])]
|
||||
if ohne:
|
||||
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"],))]
|
||||
_paare_bereinigen([a["id"] for a in chunk])
|
||||
ohne_aussage = [a for a in chunk
|
||||
if _paar_lage(a["id"])[0] < AUSSAGEN_JE_ATOM
|
||||
and not _aufgegeben(a["id"], "aussage")]
|
||||
if ohne_aussage:
|
||||
await _aussagen_generieren(ctx, ohne_aussage)
|
||||
await _aussagen_verifizieren(ctx, chunk)
|
||||
@@ -367,19 +455,37 @@ async def bauen(ctx: llm.Kontext) -> None:
|
||||
|
||||
|
||||
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 = []
|
||||
|
||||
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"
|
||||
" ('gemerged','verworfen')", (ctx.topic,))
|
||||
for a in atome:
|
||||
karten = db.query("SELECT status FROM artefakte WHERE atom_id=? AND typ='flashcard'",
|
||||
(a["id"],))
|
||||
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"]),
|
||||
fc = len(_lebende(a["id"], "flashcard"))
|
||||
if fc < FC_ZIEL:
|
||||
fehlt(a, "flashcard",
|
||||
"atom_ohne_flashcard" if fc == 0 else "flashcard_unvollstaendig")
|
||||
if not _lebende(a["id"], "beispiel"):
|
||||
fehlt(a, "beispiel", "atom_ohne_beispiel")
|
||||
komplett, einseitig, _ = _paar_lage(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"]})
|
||||
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'",
|
||||
(a["id"],))
|
||||
for k in offen:
|
||||
@@ -388,9 +494,15 @@ def messen(ctx: llm.Kontext) -> list[dict]:
|
||||
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:
|
||||
ctx.ebene = EBENE
|
||||
if not befunde:
|
||||
if not any(b["art"] in _REPARIERBAR for b in befunde):
|
||||
return False
|
||||
await bauen(ctx) # idempotent: generiert Fehlendes nach, prüft Offenes
|
||||
return True
|
||||
|
||||
@@ -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_FEHLERQUOTE = 0.08 # max. Edit-Distanz als Anteil der Zitatlänge
|
||||
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.68–0.71, aak;
|
||||
# Fehlurteile fängt das einstimmige Panel)
|
||||
MERGE_KANDIDAT_JACCARD = 0.3 # Schwelle im lexikalischen Fallback (misst viel niedriger als Kosinus)
|
||||
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)
|
||||
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)
|
||||
NACHFUELL_CAP = 2 # Nachfüllen endet bei verworfen ≥ Cap × Ziel (Anti-Churn)
|
||||
# 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
|
||||
|
||||
@@ -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
|
||||
_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:
|
||||
_, sep, rumpf = inhalt.partition(r"\begin{document}")
|
||||
praeambel, sep, rumpf = inhalt.partition(r"\begin{document}")
|
||||
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 = inhalt.replace(r"\end{document}", "")
|
||||
inhalt = _TEX_UMLAUT.sub(lambda m: _TEX_UMLAUTE[m.group(1)], inhalt)
|
||||
|
||||
@@ -14,6 +14,7 @@ from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
|
||||
import agents
|
||||
import artefakte
|
||||
import db
|
||||
import guide
|
||||
import ledger
|
||||
@@ -156,11 +157,17 @@ def state(topic: str):
|
||||
raise HTTPException(404, "unbekanntes 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,))
|
||||
# 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 = {}
|
||||
for a in db.query(
|
||||
"SELECT atom_id, SUM(status='verifiziert') AS ok, COUNT(*) AS n FROM artefakte"
|
||||
" WHERE atom_id IN (SELECT id FROM atome WHERE topic=?) GROUP BY atom_id", (topic,)):
|
||||
fuer_atom[a["atom_id"]] = {"verifiziert": a["ok"], "gesamt": a["n"]}
|
||||
"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, 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 {
|
||||
"topic": t, "run": 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'"
|
||||
" 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],
|
||||
"lernziele": db.query("SELECT * FROM lernziele WHERE topic=? ORDER BY id", (topic,)),
|
||||
"bausteine": db.query(
|
||||
|
||||
@@ -26,6 +26,8 @@ GEWICHTE = {
|
||||
"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, "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,
|
||||
"level_mix": 3.0, "kapitel_level": 3.0,
|
||||
"baustein_ohne_kapitel": 3.0, "kapitel_leer": 1.5, "kapitel_fallback": 1.5,
|
||||
|
||||
@@ -14,6 +14,7 @@ const neuName = ref('')
|
||||
const neuArt = ref('thema')
|
||||
const fehler = ref('')
|
||||
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
|
||||
const navAuf = ref(localStorage.getItem('navAuf') !== null
|
||||
? 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="fehler" class="badge rot">{{ fehler }}</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">
|
||||
<option value="hell">hell</option>
|
||||
<option value="dunkel">dunkel</option>
|
||||
@@ -219,7 +222,7 @@ function runBadge(run) {
|
||||
<div class="inhalt">
|
||||
<Board v-if="tab === 'board'" :state="state" :topic="topic" @reload="stateLaden"
|
||||
@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" />
|
||||
<Kennzahlen v-else :run="state?.run" />
|
||||
</div>
|
||||
|
||||
@@ -55,6 +55,16 @@ const info = (key) => props.state?.ebenen?.find((e) => e.name === key) || {}
|
||||
const aktiveAtome = computed(() =>
|
||||
(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) {
|
||||
const s = props.state
|
||||
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="zeile">
|
||||
<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' : ''">
|
||||
{{ info(e.key).laeuft ? 'läuft' : info(e.key).fertig ? 'fertig' : 'offen' }}
|
||||
</span>
|
||||
|
||||
@@ -6,6 +6,8 @@ import Lesemodus from './Lesemodus.vue'
|
||||
|
||||
const props = defineProps({ topic: String, state: Object })
|
||||
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 level = ref('E') // Durchgang-Wahl: jede Stufe ist ein eigener Lese-Durchgang
|
||||
const ansicht = ref('erklaerend') // erklaerend = Fließtext | kompakt = Stichpunkte
|
||||
@@ -158,8 +160,6 @@ function onScroll(e) {
|
||||
</button>
|
||||
<button class="schalter-solo" title="Fokus-Modus (ablenkungsfrei lesen)"
|
||||
@click="fokus = true">⛶</button>
|
||||
<button v-if="daten" class="schalter-solo lm-start" title="Lernmodus (Vollbild)"
|
||||
@click="lesemodus = true">▶</button>
|
||||
</div>
|
||||
<div class="fortschritt">
|
||||
<div>Kapitel {{ aktiv + 1 }} / {{ anzeige.length }}</div>
|
||||
|
||||
@@ -61,6 +61,24 @@ function taste(e) {
|
||||
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) ────────────────────────────────────────────────
|
||||
const stapel = ref([])
|
||||
const zeigeAntwort = ref(false)
|
||||
@@ -155,7 +173,8 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="wurzel" class="lesemodus">
|
||||
<div ref="wurzel" class="lesemodus"
|
||||
@touchstart.passive="wischStart" @touchend.passive="wischEnde">
|
||||
<header class="lm-kopf">
|
||||
<span class="lm-fort">{{ fortschrittText }}</span>
|
||||
<button class="lm-x" title="Schließen" @click="schliessen">✕</button>
|
||||
@@ -205,7 +224,6 @@ onUnmounted(() => {
|
||||
'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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |"
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user