update
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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.68–0.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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 |"
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user