update
This commit is contained in:
4
Makefile
4
Makefile
@@ -18,10 +18,10 @@ stop:
|
||||
@echo "gestoppt."
|
||||
|
||||
test:
|
||||
cd backend && python -m pytest ../tests -x -q
|
||||
cd backend && python3 -m pytest ../tests -x -q
|
||||
|
||||
test-e2e:
|
||||
cd backend && CREATOR_FAKE_AGENTS=1 python -m pytest ../tests/test_e2e.py -x -q
|
||||
cd backend && CREATOR_FAKE_AGENTS=1 python3 -m pytest ../tests/test_e2e.py -x -q
|
||||
|
||||
build:
|
||||
cd frontend && npm run build
|
||||
|
||||
@@ -82,11 +82,12 @@ async def _api_slot(key: str) -> None:
|
||||
|
||||
|
||||
class AgentErgebnis:
|
||||
__slots__ = ("rc", "text", "err", "tokens", "wait_s")
|
||||
__slots__ = ("rc", "text", "err", "tokens", "wait_s", "model")
|
||||
|
||||
def __init__(self, rc: int, text: str, err: str, tokens: dict | None = None):
|
||||
self.rc, self.text, self.err, self.tokens = rc, text, err, tokens or {}
|
||||
self.wait_s = 0.0 # Queue-Zeit (Semaphore/Drossel) — run_agent füllt sie
|
||||
self.model = "" # aufgelöstes Modell — run_agent füllt es (Ledger)
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
@@ -161,12 +162,13 @@ async def run_agent(key: str, prompt: str, timeout: int, *, provider: str,
|
||||
await _api_slot(key)
|
||||
wait_s = time.time() - warte_start
|
||||
try:
|
||||
res = await _text_api(key, prompt, timeout, model)
|
||||
res = await _text_api(key, prompt, timeout, model, role)
|
||||
finally:
|
||||
_api_inflight -= 1
|
||||
if res.rc == 0:
|
||||
_erfolg_melden()
|
||||
res.wait_s = wait_s
|
||||
res.model = model
|
||||
return res
|
||||
await _drossel_warten(key)
|
||||
wait_s = time.time() - warte_start
|
||||
@@ -175,6 +177,7 @@ async def run_agent(key: str, prompt: str, timeout: int, *, provider: str,
|
||||
else:
|
||||
res = await _claude_cli(key, prompt, timeout, model, capabilities)
|
||||
res.wait_s = wait_s
|
||||
res.model = model
|
||||
return res
|
||||
finally:
|
||||
_active.pop(key, None)
|
||||
@@ -335,12 +338,20 @@ _API_MODEL_OPTS = {
|
||||
# Antwort nach bis zu 44 min — Extraktion war 53 von 63 min der Ebene).
|
||||
"minimax-kalt/MiniMax-M2.7-highspeed": {"temperature": 0.3,
|
||||
"thinking": {"type": "disabled"}},
|
||||
# native Route (judge/guide): ungedrosselt liefen Merge-Judges ins Output-Cap
|
||||
# (58–71 % Parse-Fehler). role=guide (Writer) behält Thinking — _text_api
|
||||
# nimmt die thinking-Option dort wieder raus.
|
||||
"minimax/MiniMax-M3": {"temperature": 0.2, "thinking": {"type": "disabled"}},
|
||||
}
|
||||
|
||||
|
||||
async def _text_api(key: str, prompt: str, timeout: int, model: str) -> AgentErgebnis:
|
||||
async def _text_api(key: str, prompt: str, timeout: int, model: str,
|
||||
role: str = "judge") -> AgentErgebnis:
|
||||
opts = dict(_API_MODEL_OPTS.get(model, {}))
|
||||
if role == "guide":
|
||||
opts.pop("thinking", None) # Writer braucht Denkraum — nur Judges kalt
|
||||
body = {"model": model.split("/", 1)[1], "max_tokens": _API_MAX_TOKENS,
|
||||
"messages": [{"role": "user", "content": prompt}], **_API_MODEL_OPTS.get(model, {})}
|
||||
"messages": [{"role": "user", "content": prompt}], **opts}
|
||||
headers = {"x-api-key": os.environ.get("MINIMAX_API_KEY", ""), "anthropic-version": "2023-06-01"}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout, connect=30)) as client:
|
||||
|
||||
@@ -74,6 +74,13 @@ ARTEFAKT_CHUNK_ATOME = 8 # Atome pro Generate-Call (ein Soll-Punkt, gestück
|
||||
VERIFY_PANEL = 2 # unabhängige Prüfer, einstimmig = verifiziert
|
||||
|
||||
# ── Ebene 3: Struktur ─────────────────────────────────────────────────────────
|
||||
LEVEL_RANG = {"E": 0, "M": 1, "S": 2} # Durchgänge: Grundgerüst / Standard / Feinheiten
|
||||
DURCHGANG = { # Leser-Kontext je Durchgang (Templates: {durchgang})
|
||||
"E": "Grundgerüst, erster Durchgang — der Leser kennt noch nichts,"
|
||||
" alles voraussetzungsfrei erklären",
|
||||
"M": "Standardstoff, zweiter Durchgang — der Leser kennt das Grundgerüst",
|
||||
"S": "Feinheiten, dritter Durchgang — der Leser kennt Grundgerüst und Standardstoff",
|
||||
}
|
||||
BAUSTEIN_MIN_ATOME = 4
|
||||
BAUSTEIN_MAX_ATOME = 8
|
||||
ZIELE_CHUNK_ATOME = 30 # Atome pro Lernziel-Call
|
||||
|
||||
@@ -66,7 +66,7 @@ CREATE TABLE IF NOT EXISTS quellen(
|
||||
CREATE TABLE IF NOT EXISTS soll(
|
||||
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, punkt TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'kandidat', belege TEXT NOT NULL DEFAULT '[]',
|
||||
intro TEXT NOT NULL DEFAULT '');
|
||||
intro TEXT NOT NULL DEFAULT '', geprueft TEXT NOT NULL DEFAULT '[]');
|
||||
CREATE TABLE IF NOT EXISTS atome(
|
||||
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, titel TEXT NOT NULL, typ TEXT NOT NULL DEFAULT '',
|
||||
definition TEXT NOT NULL DEFAULT '', level TEXT NOT NULL DEFAULT 'M',
|
||||
@@ -90,11 +90,12 @@ CREATE TABLE IF NOT EXISTS lernziele(
|
||||
titel TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'neu');
|
||||
CREATE TABLE IF NOT EXISTS bausteine(
|
||||
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, ziel_id INTEGER NOT NULL, titel TEXT NOT NULL,
|
||||
ord INTEGER DEFAULT 0, status TEXT NOT NULL DEFAULT 'neu', kapitel_id INTEGER);
|
||||
ord INTEGER DEFAULT 0, status TEXT NOT NULL DEFAULT 'neu', kapitel_id INTEGER,
|
||||
level TEXT NOT NULL DEFAULT 'M');
|
||||
CREATE TABLE IF NOT EXISTS kapitel(
|
||||
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, titel TEXT NOT NULL,
|
||||
intro TEXT NOT NULL DEFAULT '', ord INTEGER DEFAULT 0,
|
||||
art TEXT NOT NULL DEFAULT 'judge');
|
||||
art TEXT NOT NULL DEFAULT 'judge', level TEXT NOT NULL DEFAULT 'M');
|
||||
CREATE TABLE IF NOT EXISTS sections(
|
||||
baustein_id INTEGER PRIMARY KEY, stage TEXT NOT NULL DEFAULT 'writer',
|
||||
text_kompakt TEXT DEFAULT '', text_lang TEXT DEFAULT '', befunde TEXT DEFAULT '[]',
|
||||
@@ -122,7 +123,10 @@ def _init_schema(con: sqlite3.Connection) -> None:
|
||||
"ALTER TABLE topics ADD COLUMN auto TEXT NOT NULL DEFAULT '{}'",
|
||||
"ALTER TABLE topics ADD COLUMN resets TEXT NOT NULL DEFAULT '{}'",
|
||||
"ALTER TABLE bausteine ADD COLUMN kapitel_id INTEGER",
|
||||
"ALTER TABLE kapitel ADD COLUMN art TEXT NOT NULL DEFAULT 'judge'"):
|
||||
"ALTER TABLE kapitel ADD COLUMN art TEXT NOT NULL DEFAULT 'judge'",
|
||||
"ALTER TABLE bausteine ADD COLUMN level TEXT NOT NULL DEFAULT 'M'",
|
||||
"ALTER TABLE kapitel ADD COLUMN level TEXT NOT NULL DEFAULT 'M'",
|
||||
"ALTER TABLE soll ADD COLUMN geprueft TEXT NOT NULL DEFAULT '[]'"):
|
||||
try:
|
||||
con.execute(zusatz)
|
||||
except sqlite3.OperationalError:
|
||||
|
||||
@@ -75,10 +75,13 @@ def _soll_konsens(prompt: str):
|
||||
|
||||
def _soll_beleg(prompt: str):
|
||||
text = _text_nach(prompt, "QUELLTEXT:")
|
||||
for _, saetze in PUNKTE:
|
||||
if saetze[0] in text and saetze[0] in prompt:
|
||||
return {"zitat": saetze[0]}
|
||||
return {"zitat": ""}
|
||||
zeilen = re.findall(r"^(\d+): (.+)$", _text_nach(prompt, "SOLL-PUNKTE:"), re.MULTILINE)
|
||||
out = []
|
||||
for i, punkt in zeilen:
|
||||
for p, saetze in PUNKTE:
|
||||
if p == punkt.strip() and saetze[0] in text:
|
||||
out.append({"soll": int(i), "zitat": saetze[0]})
|
||||
return out
|
||||
|
||||
|
||||
def _extraktion(prompt: str):
|
||||
@@ -116,14 +119,6 @@ def _soll_stich(prompt: str):
|
||||
return {"soll": next(iter(punkte.values()), "fremd")}
|
||||
|
||||
|
||||
def _anker_fix(prompt: str):
|
||||
text = _text_nach(prompt, "QUELLTEXT:")
|
||||
for a in ATOME:
|
||||
if a["titel"] in prompt and a["zitat"] in text:
|
||||
return {"zitat": a["zitat"]}
|
||||
return {"zitat": ""}
|
||||
|
||||
|
||||
def _anker_fix_batch(prompt: str):
|
||||
text = _text_nach(prompt, "QUELLTEXT:")
|
||||
zeilen = re.findall(r"^(\d+): (.+?) —", _text_nach(prompt, "ATOME:"), re.MULTILINE)
|
||||
@@ -176,9 +171,16 @@ def _kapitel_intro(prompt: str):
|
||||
|
||||
|
||||
def _level_kalibrierung(prompt: str):
|
||||
ids = _ids(_text_nach(prompt, "ATOME:"))
|
||||
# deterministisch: erstes Atom wird Grundgerüst, Rest Standardstoff
|
||||
return [{"atom": i, "level": "E" if n == 0 else "M"} for n, i in enumerate(ids)]
|
||||
# deklariertes Level der Fake-Welt zurückgeben (Titel-Match) — der E2E-Pfad
|
||||
# erzeugt so echte E/M/S-Durchgänge; Unbekanntes wird Standardstoff
|
||||
zeilen = re.findall(r"^(\d+): (.+?) —", _text_nach(prompt, "ATOME:"), re.MULTILINE)
|
||||
je_titel = {a["titel"]: a["level"] for a in ATOME}
|
||||
return [{"atom": int(i), "level": je_titel.get(t.strip(), "M")} for i, t in zeilen]
|
||||
|
||||
|
||||
def _titel_fix(prompt: str):
|
||||
zeilen = re.findall(r"^(\d+): ", _text_nach(prompt, "ATOME:"), re.MULTILINE)
|
||||
return [{"atom": int(i), "titel": f"Konzept {i}"} for i in zeilen]
|
||||
|
||||
|
||||
def _kapitel_schnitt(prompt: str):
|
||||
@@ -196,7 +198,7 @@ def _zyklus(prompt: str):
|
||||
|
||||
def _writer(prompt: str):
|
||||
ziel = re.search(r"Lernziel: (.+)", prompt)
|
||||
marker = re.findall(r"MARKER \(exakt so übernehmen\): (<!-- atom: \d+ \| (.+?) \| [EMS] -->)",
|
||||
marker = re.findall(r"MARKER \(exakt so übernehmen\): (<!-- atom: \d+ \| (.+?) -->)",
|
||||
prompt)
|
||||
defs = re.findall(r"Definition: (.+)", prompt)
|
||||
fragen = re.findall(r"^- (.+\?)$", prompt, re.MULTILINE)[:2]
|
||||
@@ -240,8 +242,9 @@ _HANDLER = {
|
||||
"Atom-Extraktion": _extraktion, "Atom-Extraktion-Aufgaben": _extraktion,
|
||||
"Atom-Merge": _merge,
|
||||
"Atom-Soll-Zuordnung": _soll_zuordnung, "Atom-Soll-Stich": _soll_stich,
|
||||
"Atom-Anker-Fix": _anker_fix, "Atom-Anker-Fix-Batch": _anker_fix_batch,
|
||||
"Atom-Luecke": _luecke, "Soll-Abgedeckt": lambda p: {"abgedeckt": True},
|
||||
"Atom-Anker-Fix-Batch": _anker_fix_batch,
|
||||
"Atom-Luecke": _luecke, "Atom-Titel-Fix": _titel_fix,
|
||||
"Soll-Abgedeckt": lambda p: {"abgedeckt": True},
|
||||
"Artefakt-Generate": _artefakt_generate, "Artefakt-Verify": _artefakt_verify,
|
||||
"Artefakt-Fix": _artefakt_fix, "Lernziele": _lernziele, "Kapitel-Intro": _kapitel_intro,
|
||||
"Kapitel-Schnitt": _kapitel_schnitt, "Level-Kalibrierung": _level_kalibrierung,
|
||||
|
||||
188
backend/guide.py
188
backend/guide.py
@@ -17,7 +17,7 @@ from pathlib import Path
|
||||
import db
|
||||
import llm
|
||||
import textkit
|
||||
from config import SECTION_WOERTER_PRO_ATOM
|
||||
from config import DURCHGANG, LEVEL_RANG, SECTION_WOERTER_PRO_ATOM
|
||||
|
||||
log = logging.getLogger("creator2.guide")
|
||||
|
||||
@@ -50,10 +50,11 @@ def _katex_fehler(formeln: list[dict]) -> list[str]:
|
||||
|
||||
EBENE = "guide"
|
||||
_MARKER = re.compile(r"<!--\s*atom:\s*(\d+)\s*\|")
|
||||
_MARKER_VOLL = re.compile(r"<!--\s*atom:[^>]*-->") # ganze Marker-Zeile (für Text-Checks)
|
||||
|
||||
# Kritische Befund-Arten routen zurück auf den Prüfer, Stil-Arten nur in den Fix
|
||||
# (vorwaerts ist Stil: ein Halbsatz-Fix, kein Fakten-Problem)
|
||||
KRITISCH = ("marker_fehlend", "ziel_ohne_anker", "fachlich_falsch")
|
||||
KRITISCH = ("marker_fehlend", "marker_fremd", "ziel_ohne_anker", "fachlich_falsch")
|
||||
|
||||
|
||||
def _bausteine(topic: str) -> list[dict]:
|
||||
@@ -86,7 +87,7 @@ def _fragen_pool(atom_id: int, max_n: int = 2) -> list[str]:
|
||||
def _atom_paket(a: dict) -> str:
|
||||
zitate = "\n".join(f"> {z}" for z in _zitate(a["id"])) or "(kein Zitat)"
|
||||
beispiel = _beispiel(a["id"])
|
||||
teil = (f"MARKER (exakt so übernehmen): <!-- atom: {a['id']} | {a['titel']} | {a['level']} -->\n"
|
||||
teil = (f"MARKER (exakt so übernehmen): <!-- atom: {a['id']} | {a['titel']} -->\n"
|
||||
f"Definition: {a['definition']}\nBelege (VERBATIM zitierbar):\n{zitate}")
|
||||
if beispiel:
|
||||
teil += f"\nVerifiziertes Beispiel (Werte wörtlich übernehmen):\n{beispiel}"
|
||||
@@ -101,16 +102,40 @@ def _laenge_band(n_atome: int) -> tuple[int, int]:
|
||||
return lo * n_atome, hi * n_atome
|
||||
|
||||
|
||||
def _anknuepf_kontext(b: dict) -> str:
|
||||
"""Was der Leser aus früheren Durchgängen schon kennt: tieferstufige Atome
|
||||
desselben Ziels plus direkte braucht-Voraussetzungen. Der Writer knüpft an,
|
||||
statt neu zu erklären (Dopplungs-Schutz zwischen den Durchgängen)."""
|
||||
rang = LEVEL_RANG.get(b["level"], 1)
|
||||
atome = _atome_von(b["id"])
|
||||
ids = {a["id"] for a in atome}
|
||||
kontext: dict[int, dict] = {}
|
||||
for a in db.query("SELECT * FROM atome WHERE ziel_id=? AND status NOT IN"
|
||||
" ('gemerged','verworfen')", (b["ziel_id"],)):
|
||||
if LEVEL_RANG.get(a["level"], 1) < rang:
|
||||
kontext[a["id"]] = a
|
||||
if ids:
|
||||
marks = ",".join("?" * len(ids))
|
||||
for a in db.query(f"SELECT a.* FROM kanten k JOIN atome a ON a.id=k.zu_atom"
|
||||
f" WHERE k.art='braucht' AND k.status='aktiv'"
|
||||
f" AND k.von_atom IN ({marks})", tuple(ids)):
|
||||
if a["id"] not in ids and LEVEL_RANG.get(a["level"], 1) < rang:
|
||||
kontext[a["id"]] = a
|
||||
return "\n".join(f"- {a['titel']}: {a['definition']}" for a in kontext.values())
|
||||
|
||||
|
||||
# ── Stages ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _stage_writer(ctx: llm.Kontext, b: dict) -> str:
|
||||
atome = _atome_von(b["id"])
|
||||
ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],)) or {"text": b["titel"]}
|
||||
lo, hi = _laenge_band(len(atome))
|
||||
res = await llm.call(ctx, stage="writer", template="Guide-Writer",
|
||||
werte={"titel": b["titel"], "ziel": ziel["text"],
|
||||
werte = {"titel": b["titel"], "ziel": ziel["text"],
|
||||
"atome": "\n\n".join(_atom_paket(a) for a in atome),
|
||||
"min_woerter": lo, "max_woerter": hi},
|
||||
"min_woerter": lo, "max_woerter": hi,
|
||||
"durchgang": DURCHGANG.get(b["level"], DURCHGANG["M"]),
|
||||
"kontext": _anknuepf_kontext(b) or "(nichts — erster Durchgang)"}
|
||||
res = await llm.call(ctx, stage="writer", template="Guide-Writer", werte=werte,
|
||||
role="guide", n=len(atome), item=f"b{b['id']}", erwartet=dict)
|
||||
if res is None:
|
||||
return "writer" # Stage bleibt, nächster Lauf versucht erneut
|
||||
@@ -119,11 +144,7 @@ async def _stage_writer(ctx: llm.Kontext, b: dict) -> str:
|
||||
if fehlend: # ein gezielter Zweitversuch mit explizitem Mangel
|
||||
res2 = await llm.call(ctx, stage="writer2", template="Guide-Writer",
|
||||
schritt="writer", role="guide", n=len(atome),
|
||||
item=f"b{b['id']}-2",
|
||||
werte={"titel": b["titel"], "ziel": ziel["text"],
|
||||
"atome": "\n\n".join(_atom_paket(a) for a in atome),
|
||||
"min_woerter": lo, "max_woerter": hi},
|
||||
erwartet=dict)
|
||||
item=f"b{b['id']}-2", werte=werte, erwartet=dict)
|
||||
if res2 and not _marker_fehlend(str(res2.get("lang", "")), atome):
|
||||
kompakt, lang = str(res2.get("kompakt", "")), str(res2.get("lang", ""))
|
||||
db.update("sections", "baustein_id", b["id"], text_kompakt=kompakt, text_lang=lang)
|
||||
@@ -135,6 +156,46 @@ def _marker_fehlend(text: str, atome: list[dict]) -> list[int]:
|
||||
return [a["id"] for a in atome if a["id"] not in da]
|
||||
|
||||
|
||||
def _marker_fremd_ids(text: str, atome: list[dict]) -> list[int]:
|
||||
"""Marker fremder Atome (z. B. aus dem Anknüpf-Kontext übernommen)."""
|
||||
eigene = {a["id"] for a in atome}
|
||||
return sorted({int(m) for m in _MARKER.findall(text)} - eigene)
|
||||
|
||||
|
||||
_FLOSKEL = re.compile(r"später (mehr|noch (ausführlicher|genauer|vertieft))"
|
||||
r"|späteren (Kapitel|Abschnitt|Durchgang)", re.IGNORECASE)
|
||||
_LEAK = re.compile(r"\b(Satz|Lemma|Korollar|Bemerkung|Transformation|Definition)"
|
||||
r"\s+[IVX]*\d+\.\d+|\bZeigen Sie\b")
|
||||
_EN_STOP = frozenset("the and of is that this are with for as by be from which were"
|
||||
" has have not can will or an it its when each such".split())
|
||||
|
||||
|
||||
def _stil_auftraege(text: str, wo: str) -> list[str]:
|
||||
"""Deterministische Lesbarkeits-Checks je Fassung: Vertröstungs-Floskeln
|
||||
(der alte Vorwärts-Fix klebte „dazu später mehr“ ein statt zu erklären),
|
||||
Quellen-Referenzen (Satz 6.25 — der Leser hat kein nummeriertes Skript)
|
||||
und unübersetzte englische Passagen (Lernen: 13 Passagen aus EN-Quellen).
|
||||
Marker-Zeilen sind Schnittstelle, kein Text — vorher strippen, sonst
|
||||
erzeugen Atom-TITEL wie „Satz 6.26“ unfixierbare Aufträge (Token-Loop)."""
|
||||
text = _MARKER_VOLL.sub("", text)
|
||||
auftraege = []
|
||||
for m in sorted({m.group(0) for m in _FLOSKEL.finditer(text)}):
|
||||
auftraege.append(f"{wo}: Vertröstungs-Floskel („{m}“) streichen — den Begriff"
|
||||
f" stattdessen kurz mit Bekanntem einordnen oder die Stelle"
|
||||
f" entfernen.")
|
||||
for m in sorted({m.group(0) for m in _LEAK.finditer(text)}):
|
||||
auftraege.append(f"{wo}: Quellen-Referenz „{m}“ — der Leser kennt kein"
|
||||
f" nummeriertes Skript und keine Aufgabenstellung; Inhalt"
|
||||
f" eigenständig formulieren.")
|
||||
for absatz in text.split("\n\n"):
|
||||
toks = re.findall(r"[a-zA-Z']+", absatz.lower())
|
||||
if len(toks) >= 12 and sum(t in _EN_STOP for t in toks) / len(toks) > 0.15:
|
||||
kurz = " ".join(absatz.split())[:60]
|
||||
auftraege.append(f"{wo}: englische Passage („{kurz}…“) ins Deutsche"
|
||||
f" übersetzen — etablierte Fachtermini dürfen bleiben.")
|
||||
return auftraege
|
||||
|
||||
|
||||
def _mathe_auftraege(text: str, wo: str) -> list[str]:
|
||||
"""Deterministische Mathe-Hygiene je Fassung: $-Parität, nackte LaTeX-Befehle,
|
||||
Klartext-Formelreste (x_i, u_{3m}, 2^(…) — aak Lauf 19: 8 Kompakt-/6
|
||||
@@ -182,20 +243,29 @@ def _mathe_auftraege(text: str, wo: str) -> list[str]:
|
||||
return auftraege
|
||||
|
||||
|
||||
def _det_auftraege(topic: str, b: dict, lang: str, kompakt: str = "") -> list[str]:
|
||||
"""Deterministische Checks vor dem Judge: Marker, Länge, Vorwärtsverweise."""
|
||||
def _det_auftraege(topic: str, b: dict, lang: str, kompakt: str = "",
|
||||
qa: bool = False) -> list[str]:
|
||||
"""Deterministische Checks vor dem Judge: Marker, Länge, Vorwärtsverweise,
|
||||
Lesbarkeit. qa=True (Ebenen-QA): Marker/Länge/Vorwärts haben dort eigene
|
||||
Befund-Arten und ein weiteres Band (Lektion 75) — nur die Stil-Checks."""
|
||||
atome = _atome_von(b["id"])
|
||||
auftraege = [f"KRITISCH: Marker für Atom {i} fehlt — exakt einfügen."
|
||||
auftraege = []
|
||||
if not qa:
|
||||
auftraege += [f"KRITISCH: Marker für Atom {i} fehlt — exakt einfügen."
|
||||
for i in _marker_fehlend(lang, atome)]
|
||||
auftraege += [f"KRITISCH: Marker {i} gehört nicht zu diesem Baustein"
|
||||
f" (Kontext-Atom?) — Marker-Zeile ersatzlos entfernen."
|
||||
for i in _marker_fremd_ids(lang, atome)]
|
||||
lo, hi = _laenge_band(len(atome))
|
||||
woerter = len(lang.split())
|
||||
if woerter > hi:
|
||||
auftraege.append(f"Kürzen auf höchstens {hi} Wörter (aktuell {woerter}).")
|
||||
elif woerter < lo:
|
||||
auftraege.append(f"Ausbauen auf mindestens {lo} Wörter (aktuell {woerter}).")
|
||||
auftraege += [f"Vorwärtsverweis „{t}“ (kommt erst in einem späteren Kapitel): beim"
|
||||
f" ersten Auftreten mit einem Halbsatz einordnen („… dazu später mehr“)"
|
||||
f" oder entfernen — nie unerklärt verwenden."
|
||||
auftraege += [f"Vorwärtsverweis „{t}“ (kommt erst in einem späteren Kapitel):"
|
||||
f" beim ersten Auftreten mit bereits eingeführten Begriffen kurz"
|
||||
f" erklären oder die Verwendung entfernen — nie unerklärt lassen,"
|
||||
f" NICHT vertrösten."
|
||||
for t in _vorwaertsverweise(topic, b, lang)]
|
||||
zitate = sum(1 for z in lang.splitlines() if z.lstrip().startswith(">"))
|
||||
if zitate: # Lehren statt Abschreiben: Rohzitate gehören nicht in den Lehrtext
|
||||
@@ -204,21 +274,49 @@ def _det_auftraege(topic: str, b: dict, lang: str, kompakt: str = "") -> list[st
|
||||
if "6=" in lang:
|
||||
auftraege.append("PDF-Artefakt „6=“ im Text: gemeint ist Ungleichheit —"
|
||||
" durch $\\neq$ ersetzen.")
|
||||
for nr, absatz in enumerate(lang.split("\n\n"), 1):
|
||||
# Marker-Zeilen zählen nicht — sie stehen im selben Block wie der Absatz
|
||||
zeilen = [z for z in absatz.splitlines()
|
||||
if z.strip() and not z.strip().startswith("<!--")]
|
||||
if not zeilen or any(z.lstrip().startswith(("-", "*", "#", "$$", ">"))
|
||||
for z in zeilen):
|
||||
continue # Listen/Überschriften/Display-Formeln sind kein Fließtext
|
||||
w = sum(len(z.split()) for z in zeilen)
|
||||
if w > 110: # Writer-Regel 40–90, hart <110 — Textwände töten den Lesefluss
|
||||
auftraege.append(f"Langtext, Absatz {nr} hat {w} Wörter (Regel: 40–90):"
|
||||
f" in kürzere Absätze teilen.")
|
||||
inhalt = [z.strip() for z in lang.splitlines()
|
||||
if z.strip() and not z.strip().startswith("<!--")]
|
||||
for i, z in enumerate(inhalt):
|
||||
if z.startswith("####") and (not z.lstrip("# ").strip() or i + 1 == len(inhalt)
|
||||
or inhalt[i + 1].startswith("#")):
|
||||
auftraege.append(f"Überschrift „{z[:50]}“ ohne folgenden Text: entfernen"
|
||||
f" oder den zugehörigen Absatz ergänzen.")
|
||||
auftraege += _mathe_auftraege(lang, "Langtext")
|
||||
auftraege += _stil_auftraege(lang, "Langtext")
|
||||
if kompakt:
|
||||
if _MARKER.search(kompakt):
|
||||
auftraege.append("Atom-Marker in der Kompakt-Fassung: Marker gehören nur"
|
||||
" in den Langtext — aus der Kompakt-Fassung entfernen.")
|
||||
if any(z.lstrip().startswith(">") for z in kompakt.splitlines()):
|
||||
auftraege.append("Blockquote („>“) in der Kompakt-Fassung: Inhalt in"
|
||||
" eigene Stichpunkte umformulieren.")
|
||||
auftraege += _mathe_auftraege(kompakt, "Kompakt-Fassung")
|
||||
auftraege += _stil_auftraege(kompakt, "Kompakt-Fassung")
|
||||
return auftraege
|
||||
|
||||
|
||||
def _vorwaertsverweise(topic: str, b: dict, lang: str) -> list[str]:
|
||||
"""Titel von Atomen SPÄTERER KAPITEL im Text (innerhalb eines Kapitels sind
|
||||
Verweise normal — nur Kapitel-Sprünge stören den Lesefluss). Nur signifikante
|
||||
Titel (≥2 Tokens oder ≥6 Zeichen — Lektion 35), Ganzwort, ohne Marker-Zeilen."""
|
||||
Verweise normal — nur Kapitel-Sprünge stören den Lesefluss). Die Kapitel-ord
|
||||
läuft global über die Durchgänge (E<M<S) — frühere Durchgänge gelten damit
|
||||
automatisch als bekannt, spätere als Vorgriff. Nur signifikante Titel
|
||||
(≥2 Tokens oder ≥6 Zeichen — Lektion 35), Ganzwort, ohne Marker-Zeilen."""
|
||||
kap_ord = {k["id"]: k["ord"] for k in
|
||||
db.query("SELECT * FROM kapitel WHERE topic=?", (topic,))}
|
||||
je_baustein = {bb["id"]: kap_ord.get(bb["kapitel_id"], 0) for bb in _bausteine(topic)}
|
||||
mein_kapitel = je_baustein.get(b["id"], 0)
|
||||
text = textkit.norm(_MARKER.sub("", lang))
|
||||
text = textkit.norm(_MARKER_VOLL.sub("", lang))
|
||||
treffer = []
|
||||
for a in db.query(
|
||||
"SELECT a.titel, bb.id AS bid FROM atome a JOIN bausteine bb ON a.baustein_id=bb.id"
|
||||
@@ -279,8 +377,10 @@ async def _stage_fix(ctx: llm.Kontext, b: dict) -> str:
|
||||
if kritisch: # genau EIN Re-Check; Rest fängt die Ebenen-QA
|
||||
await _stage_pruefer(ctx, b, tag="-re")
|
||||
return "done"
|
||||
db.update("sections", "baustein_id", b["id"], befunde=db.j([]))
|
||||
return "done" # Fix fehlgeschlagen → Original behalten (im Zweifel behalten)
|
||||
# Fix fehlgeschlagen → Original behalten, aber die Aufträge NICHT löschen:
|
||||
# unsichtbar gescheiterte Fixes hießen „done“ (aak: 23 Sections mit 60
|
||||
# offenen Aufträgen, darunter KRITISCH) — die Ebenen-QA muss sie sehen.
|
||||
return "done"
|
||||
|
||||
|
||||
_STAGES = {"writer": _stage_writer, "pruefer": _stage_pruefer, "fix": _stage_fix}
|
||||
@@ -313,7 +413,9 @@ async def _kapitel_intros(ctx: llm.Kontext) -> None:
|
||||
liste = "\n".join(f"- {b['titel']}: {ziele.get(b['ziel_id'], {}).get('text', '')}"
|
||||
for b in bausteine)
|
||||
res = await llm.call(ctx, stage="kapitel", template="Kapitel-Intro",
|
||||
werte={"titel": kap["titel"], "bausteine": liste},
|
||||
werte={"titel": kap["titel"], "bausteine": liste,
|
||||
"durchgang": DURCHGANG.get(kap["level"],
|
||||
DURCHGANG["M"])},
|
||||
role="judge", n=len(bausteine), item=f"k{kap['id']}",
|
||||
erwartet=dict)
|
||||
intro = str((res or {}).get("intro", "")).strip()
|
||||
@@ -333,6 +435,22 @@ async def bauen(ctx: llm.Kontext) -> None:
|
||||
await _kapitel_intros(ctx)
|
||||
|
||||
|
||||
def _pruefe_dich(baustein_id: int, lang: str) -> list[dict]:
|
||||
"""Antworten zu den „Prüfe dich"-Fragen: die Fragen stammen wörtlich aus dem
|
||||
Flashcard-Pool — Matching gegen den Langtext liefert die passenden Karten
|
||||
(Retrieval mit Feedback wirkt ~doppelt so stark wie ohne)."""
|
||||
out = []
|
||||
for a in _atome_von(baustein_id):
|
||||
for r in db.query("SELECT inhalt FROM artefakte WHERE atom_id=? AND"
|
||||
" typ='flashcard' AND status='verifiziert'", (a["id"],)):
|
||||
fc = db.uj(r["inhalt"], {})
|
||||
frage = str(fc.get("frage", "")).strip()
|
||||
antwort = str(fc.get("antwort", "")).strip()
|
||||
if frage and antwort and frage in lang:
|
||||
out.append({"frage": frage, "antwort": antwort})
|
||||
return out
|
||||
|
||||
|
||||
def kapitel_struktur(topic: str) -> list[dict]:
|
||||
"""Kapitel (Struktur-Ebene) entlang der Baustein-Ordnung; die Segmente sind
|
||||
kontiguierlich, also wechselt H2 genau an den Kapitel-Grenzen."""
|
||||
@@ -346,12 +464,15 @@ def kapitel_struktur(topic: str) -> list[dict]:
|
||||
if not kapitel or kapitel[-1]["kapitel_id"] != k_id:
|
||||
k = kaps.get(k_id, {})
|
||||
kapitel.append({"kapitel_id": k_id, "titel": k.get("titel", "Weitere Themen"),
|
||||
"intro": k.get("intro", ""), "sections": []})
|
||||
"intro": k.get("intro", ""),
|
||||
"level": k.get("level", b["level"]), "sections": []})
|
||||
lang = sec["text_lang"] if sec else ""
|
||||
kapitel[-1]["sections"].append({
|
||||
"baustein": b["id"], "titel": b["titel"],
|
||||
"ziel": ziele.get(b["ziel_id"], {}).get("text", ""),
|
||||
"kompakt": sec["text_kompakt"] if sec else "",
|
||||
"lang": sec["text_lang"] if sec else ""})
|
||||
"lang": lang,
|
||||
"fragen": _pruefe_dich(b["id"], lang) if lang else []})
|
||||
return kapitel
|
||||
|
||||
|
||||
@@ -388,6 +509,12 @@ def messen(ctx: llm.Kontext) -> list[dict]:
|
||||
for i in _marker_fehlend(lang, atome):
|
||||
befunde.append({"art": "marker_fehlend", "item": str(b["id"]),
|
||||
"detail": f"Atom {i}"})
|
||||
for i in _marker_fremd_ids(lang, atome):
|
||||
befunde.append({"art": "marker_fremd", "item": str(b["id"]),
|
||||
"detail": f"Atom {i}"})
|
||||
for auftrag in _det_auftraege(ctx.topic, b, lang, sec["text_kompakt"], qa=True):
|
||||
befunde.append({"art": "det_check", "item": str(b["id"]),
|
||||
"detail": auftrag[:300]})
|
||||
ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],))
|
||||
if ziel:
|
||||
noetig = _stopfrei(ziel["text"])
|
||||
@@ -457,19 +584,22 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
|
||||
"""Kritische Befunde → zurück auf pruefer (voller Kontext + Re-Check);
|
||||
Stil-Befunde → direkt in den Fix (genau ein Rewrite)."""
|
||||
ctx.ebene = EBENE
|
||||
betroffen: dict[int, list[str]] = {}
|
||||
betroffen: dict[int, list[dict]] = {}
|
||||
for b in befunde:
|
||||
try:
|
||||
b_id = int(b["item"])
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
betroffen.setdefault(b_id, []).append(b["art"])
|
||||
for b_id, arten in betroffen.items():
|
||||
betroffen.setdefault(b_id, []).append(b)
|
||||
for b_id, liste in betroffen.items():
|
||||
arten = [x["art"] for x in liste]
|
||||
if any(a in KRITISCH or a == "section_fehlt" for a in arten):
|
||||
stage = "writer" if "section_fehlt" in arten else "pruefer"
|
||||
db.execute("UPDATE sections SET stage=? WHERE baustein_id=?", (stage, b_id))
|
||||
else:
|
||||
auftraege = [f"Behebe: {a}" for a in arten]
|
||||
# Detail mitgeben — „Behebe: vorwaerts“ ohne den Begriff ließ den Fix
|
||||
# raten (aak: 371 kumulierte vorwaerts-Befunde über 10 Iterationen)
|
||||
auftraege = [f"Behebe ({x['art']}): {x['detail']}" for x in liste]
|
||||
db.update("sections", "baustein_id", b_id, stage="fix", befunde=db.j(auftraege))
|
||||
db.update("bausteine", "id", b_id, status="repair")
|
||||
if betroffen:
|
||||
|
||||
@@ -7,6 +7,7 @@ Danach Soll-Zuordnung je Atom; Soll-Punkte ohne Atom lösen gezielte Nachextrakt
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
|
||||
import db
|
||||
import embedding
|
||||
@@ -22,7 +23,7 @@ log = logging.getLogger("creator2.inventar")
|
||||
EBENE = "inventar"
|
||||
TYPEN = ("begriff", "aussage", "verfahren")
|
||||
LEVELS = ("E", "M", "S")
|
||||
PAAR_CHUNK = 40 # Merge-Paare pro Judge-Call (Lektion 34/55)
|
||||
PAAR_CHUNK = 10 # Merge-Paare pro Judge-Call (40 riss das Output-Cap: 58–71 % Parse-Fehler)
|
||||
|
||||
|
||||
def aktive_atome(topic: str) -> list[dict]:
|
||||
@@ -32,25 +33,39 @@ def aktive_atome(topic: str) -> list[dict]:
|
||||
|
||||
# ── Extraktion ────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _extrahiere_quelle(ctx: llm.Kontext, quelle: dict) -> None:
|
||||
async def _extrahiere_quelle(ctx: llm.Kontext, quelle: dict, force: bool = False) -> None:
|
||||
# Idempotenz-Guard: hat die Quelle schon verankerte Atome (z. B. nach Soll-Reset),
|
||||
# wird sie nicht erneut gelesen — sonst Doppel-Extraktion.
|
||||
# wird sie nicht erneut gelesen — sonst Doppel-Extraktion. force=True (Repair
|
||||
# quelle_unvollstaendig) liest trotzdem; Doppelte fängt der Anker-Dedup.
|
||||
schon = db.one(
|
||||
"SELECT a.id FROM atome a JOIN anker k ON k.atom_id=a.id"
|
||||
" WHERE a.topic=? AND k.quelle_id=? AND a.status NOT IN ('gemerged','verworfen')"
|
||||
" LIMIT 1", (ctx.topic, quelle["id"]))
|
||||
if schon:
|
||||
if schon and not force:
|
||||
db.update("quellen", "id", quelle["id"], status="atome")
|
||||
return
|
||||
text = korpus.quelltext(quelle)
|
||||
# aufgaben-Quellen: das GEÜBTE Konzept destillieren, nie die Aufgabeninstanz
|
||||
template = ("Atom-Extraktion-Aufgaben" if quelle.get("rolle") == "aufgaben"
|
||||
else "Atom-Extraktion")
|
||||
fehl: list[int] = []
|
||||
|
||||
async def reader(offset: int, chunk: str, r: int) -> None:
|
||||
async def reader(offset: int, chunk: str, r: int, tiefe: int = 0) -> None:
|
||||
res = await llm.call(ctx, stage="extraktion", template=template,
|
||||
werte={"topic": ctx.topic, "quelle": quelle["titel"], "text": chunk},
|
||||
role="quick", item=f"q{quelle['id']}-o{offset}-r{r}", erwartet=list)
|
||||
role="quick",
|
||||
item=f"q{quelle['id']}-o{offset}-r{r}" + "-h" * tiefe,
|
||||
erwartet=list)
|
||||
if res is None:
|
||||
# Leerantwort/Parse-Fehler ist meist das Output-Cap (stop=max_tokens,
|
||||
# deterministisch — stumpfes Wiederholen hilft nie): Chunk halbieren.
|
||||
if tiefe < 2 and len(chunk) > 2000:
|
||||
mitte = len(chunk) // 2
|
||||
await asyncio.gather(reader(offset, chunk[:mitte], r, tiefe + 1),
|
||||
reader(offset + mitte, chunk[mitte:], r, tiefe + 1))
|
||||
else:
|
||||
fehl.append(offset) # nicht still: Quelle bleibt unvollständig
|
||||
return
|
||||
for a in res or []:
|
||||
titel = str(a.get("titel", "")).strip()
|
||||
definition = str(a.get("definition", "")).strip()
|
||||
@@ -79,7 +94,9 @@ async def _extrahiere_quelle(ctx: llm.Kontext, quelle: dict) -> None:
|
||||
await asyncio.gather(*(reader(off, chunk, r)
|
||||
for off, chunk in stuecke
|
||||
for r in range(READER_JE_ABSCHNITT)))
|
||||
db.update("quellen", "id", quelle["id"], status="atome")
|
||||
if fehl: # stiller Chunk-Verlust hieß bisher trotzdem „atome" (Lernen: 41 Calls leer)
|
||||
log.warning("Quelle %s: %d Abschnitt(e) ohne Extraktion", quelle["id"], len(fehl))
|
||||
db.update("quellen", "id", quelle["id"], status="teilweise" if fehl else "atome")
|
||||
|
||||
|
||||
# ── Dedup ─────────────────────────────────────────────────────────────────────
|
||||
@@ -202,7 +219,11 @@ async def _judge_dedup(ctx: llm.Kontext) -> None:
|
||||
if isinstance(e, dict) and e.get("paar") == n:
|
||||
urteile.append(bool(e.get("gleich")))
|
||||
break
|
||||
if len(urteile) == MERGE_PANEL and all(urteile):
|
||||
if len(urteile) < MERGE_PANEL:
|
||||
continue # Panel unvollständig (Parse-/Infra-Ausfall): Paar bleibt
|
||||
# offen für die nächste Runde — ein dauerhaftes „nein" ließ
|
||||
# Dubletten systematisch überleben (aak: 42/73 Calls unparsbar)
|
||||
if all(urteile):
|
||||
gew, ver = ((a["id"], b["id"]) if len(a["definition"]) >= len(b["definition"])
|
||||
else (b["id"], a["id"]))
|
||||
_merge(topic, gew, ver)
|
||||
@@ -221,12 +242,20 @@ def _erster_anker(atom_id: int) -> str:
|
||||
|
||||
|
||||
def _kanten_aufloesen(topic: str) -> None:
|
||||
"""braucht-Titel → Atom-IDs (Norm-Gleichheit; Unauflösbares fällt still weg)."""
|
||||
"""braucht-Titel → Atom-IDs. Norm-Gleichheit zuerst, Titel-Kern als zweite
|
||||
Stufe (Norm allein löste nur 72–79 % auf — der Rest fiel still weg und
|
||||
Ordnung/Level-Logik arbeiteten auf einem lückigen Graph)."""
|
||||
atome = aktive_atome(topic)
|
||||
je_norm = {textkit.norm(a["titel"]): a["id"] for a in atome}
|
||||
je_kern: dict[str, int] = {}
|
||||
for a in atome:
|
||||
if kern := textkit.titel_kern(a["titel"]):
|
||||
je_kern.setdefault(kern, a["id"])
|
||||
for a in atome:
|
||||
for titel in db.uj(a["braucht"]):
|
||||
ziel = je_norm.get(textkit.norm(titel))
|
||||
if not ziel and (kern := textkit.titel_kern(titel)):
|
||||
ziel = je_kern.get(kern)
|
||||
if ziel and ziel != a["id"]:
|
||||
db.execute("INSERT OR IGNORE INTO kanten(topic, von_atom, zu_atom, art)"
|
||||
" VALUES(?,?,?,'braucht')", (topic, a["id"], ziel))
|
||||
@@ -269,6 +298,20 @@ async def bauen(ctx: llm.Kontext) -> None:
|
||||
|
||||
# ── QA + Repair ───────────────────────────────────────────────────────────────
|
||||
|
||||
_TITEL_KATALOG = re.compile(r"^(Satz|Lemma|Korollar|Bemerkung|Definition|Aufgabe"
|
||||
r"|Übung|Transformation|Theorem)\s+[IVX]*\d", re.IGNORECASE)
|
||||
|
||||
|
||||
def _titel_kaputt(titel: str) -> bool:
|
||||
"""Katalognummern-Titel (Satz 6.26 — der Leser hat kein nummeriertes Skript)
|
||||
und abgerissene Fragmente (aak: „Teste für alle V 0 ⊆ V mit |")."""
|
||||
if _TITEL_KATALOG.match(titel):
|
||||
return True
|
||||
if titel.rstrip().endswith(("|", ",", ";", ":", "-", "–", "(", "{", "⊆", "=")):
|
||||
return True
|
||||
return titel.count("(") != titel.count(")")
|
||||
|
||||
|
||||
def messen(ctx: llm.Kontext) -> list[dict]:
|
||||
befunde = []
|
||||
atome = aktive_atome(ctx.topic)
|
||||
@@ -281,9 +324,15 @@ def messen(ctx: llm.Kontext) -> list[dict]:
|
||||
befunde.append({"art": "atom_ohne_soll", "item": str(a["id"]), "detail": a["titel"]})
|
||||
else:
|
||||
belegte_soll.add(a["soll_id"])
|
||||
if _titel_kaputt(a["titel"]):
|
||||
befunde.append({"art": "titel_katalog", "item": str(a["id"]), "detail": a["titel"]})
|
||||
for p in db.query("SELECT * FROM soll WHERE topic=? AND status='bestaetigt'", (ctx.topic,)):
|
||||
if p["id"] not in belegte_soll:
|
||||
befunde.append({"art": "soll_ohne_atom", "item": str(p["id"]), "detail": p["punkt"]})
|
||||
for q in db.query("SELECT * FROM quellen WHERE topic=? AND status='teilweise'",
|
||||
(ctx.topic,)):
|
||||
befunde.append({"art": "quelle_unvollstaendig", "item": str(q["id"]),
|
||||
"detail": q["titel"]})
|
||||
return befunde
|
||||
|
||||
|
||||
@@ -299,6 +348,11 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
|
||||
return await _soll_stichentscheid(ctx, item)
|
||||
if b["art"] == "soll_ohne_atom":
|
||||
return await _luecke_schliessen(ctx, item)
|
||||
if b["art"] == "quelle_unvollstaendig":
|
||||
q = db.one("SELECT * FROM quellen WHERE id=?", (item,))
|
||||
if q:
|
||||
await _extrahiere_quelle(ctx, q, force=True)
|
||||
return True
|
||||
return False
|
||||
|
||||
# Anker-Fixes gebündelt (Batch je Quelle statt Atom×Quelle — war 83 % der
|
||||
@@ -306,7 +360,11 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
|
||||
anker_ids = [int(b["item"]) for b in befunde
|
||||
if b["art"] == "atom_ohne_anker" and str(b["item"]).isdigit()]
|
||||
bewegt = await _anker_fixen_batch(ctx, anker_ids) if anker_ids else False
|
||||
rest = [b for b in befunde if b["art"] != "atom_ohne_anker"]
|
||||
titel_ids = [int(b["item"]) for b in befunde
|
||||
if b["art"] == "titel_katalog" and str(b["item"]).isdigit()]
|
||||
if titel_ids:
|
||||
bewegt |= await _titel_fixen(ctx, titel_ids)
|
||||
rest = [b for b in befunde if b["art"] not in ("atom_ohne_anker", "titel_katalog")]
|
||||
bewegt |= any(await llm.alle(einer(b) for b in rest))
|
||||
if bewegt:
|
||||
_anker_dedup(ctx.topic)
|
||||
@@ -316,6 +374,27 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
|
||||
return bewegt
|
||||
|
||||
|
||||
async def _titel_fixen(ctx: llm.Kontext, atom_ids: list[int]) -> bool:
|
||||
"""Kaputte Titel (Katalognummern, Fragmente) aus der Definition neu benennen."""
|
||||
atome = [a for i in atom_ids if (a := db.one("SELECT * FROM atome WHERE id=?", (i,)))
|
||||
and a["status"] not in ("gemerged", "verworfen")]
|
||||
if not atome:
|
||||
return False
|
||||
liste = "\n".join(f"{a['id']}: {a['titel']} — {a['definition']}" for a in atome)
|
||||
res = await llm.call(ctx, stage="titel_fix", template="Atom-Titel-Fix",
|
||||
schritt="fix", werte={"atome": liste}, role="judge",
|
||||
n=len(atome), item=f"t{atome[0]['id']}", erwartet=list)
|
||||
gueltig = {a["id"] for a in atome}
|
||||
bewegt = False
|
||||
for e in res or []:
|
||||
if isinstance(e, dict) and e.get("atom") in gueltig:
|
||||
titel = str(e.get("titel", "")).strip()
|
||||
if titel and not _titel_kaputt(titel):
|
||||
db.update("atome", "id", e["atom"], titel=titel)
|
||||
bewegt = True
|
||||
return bewegt
|
||||
|
||||
|
||||
ANKER_FIX_CHUNK = 20 # Atome pro Batch-Call (ein Quelltext-Abschnitt trägt viele Fixes)
|
||||
|
||||
|
||||
|
||||
@@ -136,12 +136,19 @@ async def _recherche_runde(ctx: llm.Kontext, runde: int) -> int:
|
||||
inhalt = str(q.get("inhalt", "")).strip()
|
||||
if len(inhalt) < 500: # leere/dünne Funde sind keine Quelle
|
||||
continue
|
||||
url = str(q.get("url", ""))[:500]
|
||||
# URL-Dedup: dieselbe Seite über Runden erneut geholt liefert leicht
|
||||
# abweichende Extrakte (anderer Content-Hash) und zählte dann als
|
||||
# „unabhängiger" Beleg im ≥2-Quellen-Konsens (Lernen: eine URL 4×).
|
||||
if url and db.one("SELECT id FROM quellen WHERE topic=? AND url=?",
|
||||
(ctx.topic, url)):
|
||||
continue
|
||||
snap = _snapshot_schreiben(ctx.topic, inhalt)
|
||||
if snap is None:
|
||||
continue
|
||||
titel = str(q.get("titel", ""))[:200]
|
||||
db.insert("quellen", topic=ctx.topic, art="web", titel=titel,
|
||||
url=str(q.get("url", ""))[:500], snapshot=snap[0], hash=snap[1],
|
||||
url=url, snapshot=snap[0], hash=snap[1],
|
||||
runde=runde, status="neu", rolle=_rolle(titel, lens))
|
||||
neu += 1
|
||||
return neu
|
||||
@@ -331,38 +338,76 @@ def messen(ctx: llm.Kontext) -> list[dict]:
|
||||
return befunde
|
||||
|
||||
|
||||
async def _beleg_nachsuchen(ctx: llm.Kontext, soll_id: int) -> bool:
|
||||
"""Gezielter Beleg-Judge in noch nicht zitierten Quellen; ein bestätigter
|
||||
Punkt ohne Fund wird zum Kandidaten zurückgestuft (Konsens-Regel bleibt
|
||||
hart). → True nur, wenn sich etwas geändert hat."""
|
||||
p = db.one("SELECT * FROM soll WHERE id=?", (soll_id,))
|
||||
if not p:
|
||||
async def _belege_nachsuchen(ctx: llm.Kontext, soll_ids: list[int]) -> bool:
|
||||
"""Gezielte Beleg-Judges, gebündelt: EIN Call je Quelle mit ALLEN dort noch
|
||||
offenen Punkten — Punkt×Quelle einzeln waren 3072 Calls / 27 % der
|
||||
Themen-Tokens (Lernen). Negativ-Cache soll.geprueft: erfolglos geprüfte
|
||||
(Punkt, Quelle)-Paare werden über Repair-Iterationen nie wiederholt.
|
||||
Bestätigte Punkte ohne Fund in irgendeiner Quelle → zurück zu Kandidat."""
|
||||
punkte = [p for s in dict.fromkeys(soll_ids)
|
||||
if (p := db.one("SELECT * FROM soll WHERE id=?", (s,)))]
|
||||
if not punkte:
|
||||
return False
|
||||
quellen = db.query("SELECT * FROM quellen WHERE topic=?", (ctx.topic,))
|
||||
bewegt = False
|
||||
|
||||
async def eine_quelle(q: dict) -> None:
|
||||
nonlocal bewegt
|
||||
faellig = []
|
||||
for p in punkte:
|
||||
belegte = {bl["quelle"] for bl in db.uj(p["belege"])}
|
||||
andere = [q for q in db.query("SELECT * FROM quellen WHERE topic=?", (ctx.topic,))
|
||||
if q["id"] not in belegte]
|
||||
for q in andere:
|
||||
if q["id"] not in belegte and q["id"] not in db.uj(p["geprueft"]):
|
||||
faellig.append(p)
|
||||
if not faellig:
|
||||
return
|
||||
text = quelltext(q)
|
||||
liste = "\n".join(f"{p['id']}: {p['punkt']}" for p in faellig)
|
||||
res = await llm.call(ctx, stage="soll_beleg", template="Korpus-Soll-Beleg",
|
||||
schritt="soll", role="judge", item=f"s{p['id']}-q{q['id']}",
|
||||
werte={"punkt": p["punkt"],
|
||||
"text": quelltext(q)[:SOLL_CHUNK_CHARS]},
|
||||
erwartet=dict)
|
||||
zitat = str((res or {}).get("zitat", "")).strip()
|
||||
if zitat and textkit.finde_zitat(quelltext(q), zitat) is not None:
|
||||
belege = db.uj(p["belege"]) + [{"quelle": q["id"], "zitat": zitat}]
|
||||
schritt="soll", role="judge", item=f"q{q['id']}",
|
||||
n=len(faellig),
|
||||
werte={"punkte": liste, "text": text[:SOLL_CHUNK_CHARS]},
|
||||
erwartet=list)
|
||||
if res is None:
|
||||
return # Ausfall: nicht als „geprüft" verbuchen
|
||||
gefunden: dict[int, str] = {}
|
||||
for e in res:
|
||||
if not isinstance(e, dict):
|
||||
continue
|
||||
zitat = str(e.get("zitat", "")).strip()
|
||||
if zitat and e.get("soll") in {p["id"] for p in faellig} \
|
||||
and textkit.finde_zitat(text, zitat) is not None:
|
||||
gefunden[e["soll"]] = zitat
|
||||
for p in faellig:
|
||||
frisch = db.one("SELECT * FROM soll WHERE id=?", (p["id"],))
|
||||
if p["id"] in gefunden:
|
||||
belege = db.uj(frisch["belege"]) + [{"quelle": q["id"],
|
||||
"zitat": gefunden[p["id"]]}]
|
||||
db.update("soll", "id", p["id"], belege=db.j(belege))
|
||||
return True
|
||||
if p["status"] == "bestaetigt":
|
||||
bewegt = True
|
||||
else:
|
||||
geprueft = sorted(set(db.uj(frisch["geprueft"])) | {q["id"]})
|
||||
db.update("soll", "id", p["id"], geprueft=db.j(geprueft))
|
||||
|
||||
await llm.alle(eine_quelle(q) for q in quellen)
|
||||
for p in punkte:
|
||||
frisch = db.one("SELECT * FROM soll WHERE id=?", (p["id"],))
|
||||
if frisch["status"] != "bestaetigt" \
|
||||
or len(db.uj(frisch["belege"])) > len(db.uj(p["belege"])):
|
||||
continue
|
||||
belegte = {bl["quelle"] for bl in db.uj(frisch["belege"])}
|
||||
rest = [q for q in quellen if q["id"] not in belegte
|
||||
and q["id"] not in db.uj(frisch["geprueft"])]
|
||||
if not rest: # wirklich überall erfolglos gesucht — Konsens-Regel bleibt hart
|
||||
db.update("soll", "id", p["id"], status="kandidat")
|
||||
return True
|
||||
return False
|
||||
bewegt = True
|
||||
return bewegt
|
||||
|
||||
|
||||
async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
|
||||
ctx.ebene = EBENE
|
||||
punkte = [int(b["item"]) for b in befunde if b["art"] == "soll_wenig_belege"]
|
||||
offene = [int(b["item"]) for b in befunde if b["art"] == "soll_kandidat_offen"]
|
||||
bewegt = any(await llm.alle(_beleg_nachsuchen(ctx, s) for s in punkte + offene))
|
||||
bewegt = await _belege_nachsuchen(ctx, punkte + offene)
|
||||
if offene: # neu falten: die Nachrunden im Konsens erzwingen die Zuordnung
|
||||
await _konsens(ctx)
|
||||
noch = db.query("SELECT id FROM soll WHERE topic=? AND status='kandidat'",
|
||||
|
||||
@@ -87,11 +87,12 @@ async def call(ctx: Kontext, *, stage: str, template: str, werte: dict,
|
||||
versuch += 1
|
||||
key = f"{ctx.topic}-{ctx.ebene}-{stage}-{item or 'x'}-{versuch}"
|
||||
start = time.monotonic()
|
||||
status, tokens, err, wait_ms = "error", None, "", 0
|
||||
status, tokens, err, wait_ms, model = "error", None, "", 0, ""
|
||||
try:
|
||||
res = await _roher_call(key, prompt, timeout, ctx, role, caps)
|
||||
tokens, err = res.tokens, res.err
|
||||
wait_ms = int(res.wait_s * 1000)
|
||||
model = res.model
|
||||
if res.ok:
|
||||
if erwartet is str:
|
||||
status = "ok"
|
||||
@@ -112,7 +113,7 @@ async def call(ctx: Kontext, *, stage: str, template: str, werte: dict,
|
||||
finally:
|
||||
ledger.log_call(ctx.run_id, ebene=ctx.ebene, stage=stage, item=item,
|
||||
template=template, template_hash=thash, role=role,
|
||||
provider=ctx.provider, status=status,
|
||||
provider=ctx.provider, model=model, status=status,
|
||||
dur_ms=int((time.monotonic() - start) * 1000),
|
||||
wait_ms=wait_ms,
|
||||
tokens=tokens, meta={"err": err[:300]} if err else None)
|
||||
|
||||
@@ -27,9 +27,12 @@ GEWICHTE = {
|
||||
"atom_ohne_anker": 3.0, "atom_ohne_soll": 1.5, "soll_ohne_atom": 3.0,
|
||||
"atom_ohne_flashcard": 1.5, "artefakt_unentschieden": 0.5,
|
||||
"partition": 3.0, "band": 0.5, "vorwaerts_kante": 1.5, "zyklus": 3.0,
|
||||
"level_konflikt": 3.0, "level_mix": 3.0, "kapitel_level": 3.0,
|
||||
"baustein_ohne_kapitel": 3.0, "kapitel_leer": 1.5, "kapitel_fallback": 1.5,
|
||||
"section_fehlt": 3.0, "marker_fehlend": 3.0, "ziel_ohne_anker": 3.0,
|
||||
"fachlich_falsch": 3.0, "laenge": 0.5, "vorwaerts": 0.5,
|
||||
"kapitel_zerstueckelt": 1.5, "titel_katalog": 1.5, "quelle_unvollstaendig": 3.0,
|
||||
"section_fehlt": 3.0, "marker_fehlend": 3.0, "marker_fremd": 3.0,
|
||||
"ziel_ohne_anker": 3.0, "fachlich_falsch": 3.0, "laenge": 0.5, "vorwaerts": 0.5,
|
||||
"det_check": 0.5,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ from collections import defaultdict
|
||||
|
||||
import db
|
||||
import llm
|
||||
from config import (BAUSTEIN_MAX_ATOME, BAUSTEIN_MIN_ATOME, KAPITEL_BAUSTEINE,
|
||||
ZIELE_CHUNK_ATOME)
|
||||
from config import (BAUSTEIN_MAX_ATOME, BAUSTEIN_MIN_ATOME, DURCHGANG,
|
||||
KAPITEL_BAUSTEINE, LEVEL_RANG, ZIELE_CHUNK_ATOME)
|
||||
|
||||
log = logging.getLogger("creator2.struktur")
|
||||
|
||||
@@ -186,8 +186,9 @@ def _anker_rang(topic: str) -> dict[int, tuple]:
|
||||
|
||||
def _bausteine_schneiden(ctx: llm.Kontext) -> None:
|
||||
"""Deterministisch, idempotent: alte Zuordnung verwerfen, neu schneiden.
|
||||
Ziel-Gruppen ins Band bringen — kleine Ziele desselben Soll-Punkts mergen
|
||||
(das Atom wechselt sein Ziel, Baustein bleibt EIN Ziel), große entlang der
|
||||
Gruppen sind (Ziel, Level) — je Level entstehen GETRENNTE Bausteine
|
||||
(E/M/S-Durchgänge). Kleine Gruppen level-intern mergen (das Atom wechselt
|
||||
sein Ziel, Baustein bleibt EIN Ziel + EIN Level), große entlang der
|
||||
Topo-Reihenfolge splitten ("Teil n")."""
|
||||
topic = ctx.topic
|
||||
db.execute("DELETE FROM bausteine WHERE topic=?", (topic,))
|
||||
@@ -197,12 +198,13 @@ def _bausteine_schneiden(ctx: llm.Kontext) -> None:
|
||||
|
||||
gruppen: dict = defaultdict(list)
|
||||
for a in atome:
|
||||
gruppen[a["ziel_id"]].append(a)
|
||||
gruppen[(a["ziel_id"], a["level"])].append(a)
|
||||
|
||||
# kleine Ziele mergen (kleinste zuerst). Partnerwahl thematisch statt per
|
||||
# Soll-Punkt — das Soll ist seit der Entkopplung reine Checkliste (152 feine
|
||||
# Punkte hätten sonst keine Merge-Partner): meiste braucht-Kanten zwischen
|
||||
# den Gruppen, sonst Nachbar in der Quellreihenfolge.
|
||||
# kleine Gruppen mergen (kleinste zuerst), nur innerhalb des Levels.
|
||||
# Partnerwahl thematisch statt per Soll-Punkt — das Soll ist seit der
|
||||
# Entkopplung reine Checkliste (152 feine Punkte hätten sonst keine
|
||||
# Merge-Partner): meiste braucht-Kanten zwischen den Gruppen, sonst
|
||||
# Nachbar in der Quellreihenfolge.
|
||||
def kanten_score(g1: list[dict], g2: list[dict]) -> int:
|
||||
ids1 = {a["id"] for a in g1}
|
||||
ids2 = {a["id"] for a in g2}
|
||||
@@ -215,32 +217,34 @@ def _bausteine_schneiden(ctx: llm.Kontext) -> None:
|
||||
geaendert = True
|
||||
while geaendert:
|
||||
geaendert = False
|
||||
kleine = sorted([z for z, g in gruppen.items() if len(g) < BAUSTEIN_MIN_ATOME],
|
||||
key=lambda z: len(gruppen[z]))
|
||||
for z in kleine:
|
||||
passende = [p for p in gruppen if p != z
|
||||
and len(gruppen[p]) + len(gruppen[z]) <= BAUSTEIN_MAX_ATOME]
|
||||
kleine = sorted([k for k, g in gruppen.items() if len(g) < BAUSTEIN_MIN_ATOME],
|
||||
key=lambda k: len(gruppen[k]))
|
||||
for k in kleine:
|
||||
passende = [p for p in gruppen if p != k and p[1] == k[1]
|
||||
and len(gruppen[p]) + len(gruppen[k]) <= BAUSTEIN_MAX_ATOME]
|
||||
if not passende:
|
||||
continue
|
||||
eigener = gruppen_rang(gruppen[z])
|
||||
eigener = gruppen_rang(gruppen[k])
|
||||
|
||||
def naehe(pp: int) -> tuple:
|
||||
def naehe(pp: tuple) -> tuple:
|
||||
r = gruppen_rang(gruppen[pp])
|
||||
return (-kanten_score(gruppen[z], gruppen[pp]),
|
||||
return (-kanten_score(gruppen[k], gruppen[pp]),
|
||||
(abs(r[0] - eigener[0]), abs(r[1] - eigener[1])),
|
||||
len(gruppen[pp]))
|
||||
|
||||
p = min(passende, key=naehe)
|
||||
for a in gruppen[z]:
|
||||
db.update("atome", "id", a["id"], ziel_id=p)
|
||||
gruppen[p] += gruppen.pop(z)
|
||||
db.update("lernziele", "id", z, status="gemerged")
|
||||
for a in gruppen[k]:
|
||||
db.update("atome", "id", a["id"], ziel_id=p[0])
|
||||
gruppen[p] += gruppen.pop(k)
|
||||
# Ziel erst still legen, wenn KEIN Level mehr darauf zeigt
|
||||
if not any(kk[0] == k[0] for kk in gruppen):
|
||||
db.update("lernziele", "id", k[0], status="gemerged")
|
||||
geaendert = True
|
||||
break
|
||||
|
||||
# Bausteine anlegen (große Ziele splitten), Atome zuordnen
|
||||
# Bausteine anlegen (große Gruppen splitten), Atome zuordnen
|
||||
ziele = {z["id"]: z for z in db.query("SELECT * FROM lernziele WHERE topic=?", (topic,))}
|
||||
for ziel_id, gruppe in gruppen.items():
|
||||
for (ziel_id, level), gruppe in gruppen.items():
|
||||
ordnung = _topo([a["id"] for a in gruppe], kanten, rang)
|
||||
z = ziele.get(ziel_id, {})
|
||||
titel = z.get("titel") or z.get("text", "Baustein") # Kurztitel; Zieltext nur Fallback
|
||||
@@ -252,7 +256,7 @@ def _bausteine_schneiden(ctx: llm.Kontext) -> None:
|
||||
continue
|
||||
b_titel = titel if n_teile == 1 else f"{titel} (Teil {teil + 1})"
|
||||
b_id = db.insert("bausteine", topic=topic, ziel_id=ziel_id, titel=b_titel,
|
||||
status="neu")
|
||||
status="neu", level=level)
|
||||
for pos, atom_id in enumerate(teil_ids):
|
||||
db.update("atome", "id", atom_id, baustein_id=b_id, ord=pos)
|
||||
|
||||
@@ -260,24 +264,33 @@ def _bausteine_schneiden(ctx: llm.Kontext) -> None:
|
||||
|
||||
|
||||
def _bausteine_ordnen(topic: str, kanten: list[tuple[int, int]], rang: dict) -> None:
|
||||
"""Baustein-DAG aus aggregierten Atom-Kanten; Zyklen deterministisch an der
|
||||
schwächsten Aggregat-Kante gebrochen (wenigste Atom-Kanten)."""
|
||||
"""Baustein-DAG aus aggregierten Atom-Kanten, JE LEVEL getrennt geordnet;
|
||||
ord läuft global fortlaufend E→M→S (eine Achse — Vorwärts-Logik und Kapitel-
|
||||
Kontiguität bleiben level-blind korrekt). Cross-Level-Kanten zeigen nach der
|
||||
Konfliktlösung immer auf tiefere Level und sind durch die Blockordnung
|
||||
automatisch erfüllt. Zyklen deterministisch an der schwächsten Aggregat-Kante
|
||||
gebrochen (wenigste Atom-Kanten)."""
|
||||
bausteine = db.query("SELECT * FROM bausteine WHERE topic=?", (topic,))
|
||||
je_atom = {a["id"]: a["baustein_id"] for a in _atome(topic)}
|
||||
atome = _atome(topic)
|
||||
je_atom = {a["id"]: a["baustein_id"] for a in atome}
|
||||
b_level = {b["id"]: b["level"] for b in bausteine}
|
||||
gewicht: dict = defaultdict(int)
|
||||
for v, z in kanten: # v braucht z ⇒ Baustein(z) vor Baustein(v)
|
||||
bv, bz = je_atom.get(v), je_atom.get(z)
|
||||
if bv and bz and bv != bz:
|
||||
if bv and bz and bv != bz and b_level[bv] == b_level[bz]:
|
||||
gewicht[(bv, bz)] += 1
|
||||
b_kanten = set(gewicht)
|
||||
b_ids = [b["id"] for b in bausteine]
|
||||
# Tie-Break: Quellposition des Bausteins (früheste Belegstelle); Kanten gewinnen
|
||||
# weiter. Kapitel entstehen erst NACH der Ordnung (Entkopplung vom Soll).
|
||||
atome = _atome(topic)
|
||||
b_rang = {}
|
||||
for b in bausteine:
|
||||
b_rang[b["id"]] = min((rang.get(a["id"], (9, 9)) for a in atome
|
||||
if a["baustein_id"] == b["id"]), default=(9, 9))
|
||||
offset = 0
|
||||
for level in LEVEL_RANG:
|
||||
b_ids = [b["id"] for b in bausteine if b["level"] == level]
|
||||
if not b_ids:
|
||||
continue
|
||||
b_kanten = {k for k in gewicht if b_level[k[0]] == level}
|
||||
while True:
|
||||
zyklus = _finde_zyklus(b_ids, list(b_kanten))
|
||||
if not zyklus:
|
||||
@@ -294,28 +307,34 @@ def _bausteine_ordnen(topic: str, kanten: list[tuple[int, int]], rang: dict) ->
|
||||
" AND zu_atom IN (SELECT id FROM atome WHERE baustein_id=?)",
|
||||
(topic, bv, bz))
|
||||
for pos, b_id in enumerate(_topo(b_ids, list(b_kanten), b_rang)):
|
||||
db.update("bausteine", "id", b_id, ord=pos)
|
||||
db.update("bausteine", "id", b_id, ord=offset + pos)
|
||||
offset += len(b_ids)
|
||||
|
||||
|
||||
async def _level_kalibrieren(ctx: llm.Kontext) -> None:
|
||||
"""Stufen als Komplexitätsschichten (4C/ID): Die Extraktion sieht nur ihren
|
||||
Chunk und stuft absolut (aak: 13 % E → Einstiegs-Durchgang war ein
|
||||
Lücken-Gerippe). Hier stuft ein Judge je Baustein RELATIV nach — er sieht
|
||||
alle Atome nebeneinander. E-Atome müssen allein einen kohärenten
|
||||
Kurzdurchgang tragen; rein vertiefende Bausteine bleiben ohne E."""
|
||||
bausteine = db.query("SELECT * FROM bausteine WHERE topic=?", (ctx.topic,))
|
||||
Lücken-Gerippe). Hier stuft ein Judge je LERNZIEL relativ nach — VOR dem
|
||||
Schnitt, denn je Level entstehen getrennte Bausteine. Die E-Atome eines
|
||||
Ziels müssen allein einen kohärenten Kurzdurchgang tragen; rein
|
||||
vertiefende Ziele bleiben ohne E."""
|
||||
atome = _atome(ctx.topic)
|
||||
ziele = {z["id"]: z for z in db.query("SELECT * FROM lernziele WHERE topic=?",
|
||||
(ctx.topic,))}
|
||||
gruppen: dict = defaultdict(list)
|
||||
for a in atome:
|
||||
if a["ziel_id"]:
|
||||
gruppen[a["ziel_id"]].append(a)
|
||||
|
||||
async def einer(b: dict) -> None:
|
||||
eigene = [a for a in atome if a["baustein_id"] == b["id"]]
|
||||
if not eigene:
|
||||
return
|
||||
async def einer(ziel_id: int, eigene: list[dict]) -> None:
|
||||
z = ziele.get(ziel_id, {})
|
||||
titel = z.get("titel") or z.get("text") or ctx.topic
|
||||
# bewusst OHNE bisheriges Level: der Extraktions-Prior ist verzerrt
|
||||
# (Chunk-blind, M-Anchoring) und würde die Neubewertung ankern
|
||||
liste = "\n".join(f"{a['id']}: {a['titel']} — {a['definition']}" for a in eigene)
|
||||
res = await llm.call(ctx, stage="level", template="Level-Kalibrierung",
|
||||
werte={"titel": b["titel"], "atome": liste},
|
||||
role="judge", n=len(eigene), item=f"lv{b['id']}",
|
||||
werte={"titel": titel, "atome": liste},
|
||||
role="judge", n=len(eigene), item=f"lvz{ziel_id}",
|
||||
erwartet=list)
|
||||
gueltig = {a["id"]: a["level"] for a in eigene}
|
||||
for e in res or []:
|
||||
@@ -326,33 +345,69 @@ async def _level_kalibrieren(ctx: llm.Kontext) -> None:
|
||||
if atom_id in gueltig and level in ("E", "M", "S") and level != gueltig[atom_id]:
|
||||
db.update("atome", "id", atom_id, level=level)
|
||||
|
||||
await llm.alle(einer(b) for b in bausteine)
|
||||
await llm.alle(einer(z, g) for z, g in gruppen.items())
|
||||
|
||||
|
||||
def _level_konflikte_loesen(topic: str) -> None:
|
||||
"""Deterministisch: Eine Voraussetzung darf nie über dem Level ihres Nutzers
|
||||
liegen — sonst fehlt dem Durchgang sein Fundament. Auflösung durch ABSENKEN
|
||||
der Voraussetzung (sie gehört ins Grundgerüst des Abhängigen); Anheben würde
|
||||
Kerninhalte aus dem E-Durchgang verdrängen. Fixpunkt: Level fallen monoton,
|
||||
terminiert nach max. 2 Stufen je Atom."""
|
||||
level = {a["id"]: a["level"] for a in _atome(topic)}
|
||||
kanten = [(k["von_atom"], k["zu_atom"]) for k in _braucht_kanten(topic)
|
||||
if k["von_atom"] in level and k["zu_atom"] in level]
|
||||
gesenkt = 0
|
||||
geaendert = True
|
||||
while geaendert:
|
||||
geaendert = False
|
||||
for v, z in kanten: # v braucht z ⇒ level(z) ≤ level(v)
|
||||
if LEVEL_RANG[level[z]] > LEVEL_RANG[level[v]]:
|
||||
level[z] = level[v]
|
||||
db.update("atome", "id", z, level=level[v])
|
||||
gesenkt += 1
|
||||
geaendert = True
|
||||
if gesenkt:
|
||||
log.info("Level-Konflikte: %d Voraussetzungen abgesenkt", gesenkt)
|
||||
|
||||
|
||||
async def _kapitel_bilden(ctx: llm.Kontext) -> None:
|
||||
"""Kapitel = kontiguierliche Segmente der geordneten Baustein-Folge. Das Soll
|
||||
ist seit der Entkopplung reine Checkliste — Kapitel sind Struktur. Der Judge
|
||||
liefert nur GRENZEN (titel + bis-id) — id-Listen schrieb er unvollständig
|
||||
(aak Lauf 17: 516 Tokens für 68 Bausteine → alles verworfen). Der Code
|
||||
erzwingt Reihenfolge + Lückenlosigkeit; ein ungültiges Ergebnis bekommt
|
||||
EINEN Retry, danach √n-Fallback mit Befund (kapitel_fallback). Idempotent."""
|
||||
"""Kapitel = kontiguierliche Segmente der geordneten Baustein-Folge, JE
|
||||
DURCHGANG (Level) geschnitten; kapitel.ord läuft global E→M→S weiter. Das
|
||||
Soll ist seit der Entkopplung reine Checkliste — Kapitel sind Struktur. Der
|
||||
Judge liefert nur GRENZEN (titel + bis-id) — id-Listen schrieb er
|
||||
unvollständig (aak Lauf 17: 516 Tokens für 68 Bausteine → alles verworfen).
|
||||
Der Code erzwingt Reihenfolge, Lückenlosigkeit UND Größe (≥2 Bausteine außer
|
||||
im letzten, ≤2×Richtwert-Band — Lernen: 68/73 Singleton-Kapitel); ungültig
|
||||
bekommt EINEN Retry, danach √n-Fallback mit Befund (kapitel_fallback)."""
|
||||
topic = ctx.topic
|
||||
bausteine = db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (topic,))
|
||||
if not bausteine:
|
||||
alle = db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (topic,))
|
||||
if not alle:
|
||||
return
|
||||
ziele = {z["id"]: z for z in db.query("SELECT * FROM lernziele WHERE topic=?", (topic,))}
|
||||
liste = "\n".join(f"{b['id']}: {b['titel']} — {ziele.get(b['ziel_id'], {}).get('text', '')}"
|
||||
for b in bausteine)
|
||||
db.execute("DELETE FROM kapitel WHERE topic=?", (topic,))
|
||||
pos_global = 0
|
||||
for level in LEVEL_RANG:
|
||||
bausteine = [b for b in alle if b["level"] == level]
|
||||
if not bausteine:
|
||||
continue
|
||||
# Positionsnummern statt DB-IDs: nach dem Level-Split sind die IDs
|
||||
# nicht mehr monoton entlang der Lesefolge — der Judge verlor die
|
||||
# Reihenfolge und lieferte 0 gültige Schnitte (aak Run 46: 15/15 Fallback)
|
||||
liste = "\n".join(f"{i + 1}: {b['titel']} — "
|
||||
f"{ziele.get(b['ziel_id'], {}).get('text', '')}"
|
||||
for i, b in enumerate(bausteine))
|
||||
richtwert = max(3, round(len(bausteine) / KAPITEL_BAUSTEINE))
|
||||
max_groesse = 2 * KAPITEL_BAUSTEINE
|
||||
folge = [b["id"] for b in bausteine]
|
||||
pos_von = {bid: i for i, bid in enumerate(folge)}
|
||||
pos_von = {i + 1: i for i in range(len(folge))}
|
||||
|
||||
async def schneiden(hinweis: str, tag: str) -> list[tuple[str, list[int]]] | None:
|
||||
res = await llm.call(ctx, stage="kapitel_schnitt", template="Kapitel-Schnitt",
|
||||
schritt="kapitel", role="judge", n=len(bausteine),
|
||||
item=tag, erwartet=list,
|
||||
werte={"bausteine": liste, "richtwert": str(richtwert),
|
||||
"hinweis": hinweis})
|
||||
"durchgang": DURCHGANG[level], "hinweis": hinweis})
|
||||
segmente: list[tuple[str, list[int]]] = []
|
||||
start = 0
|
||||
for gruppe in res or []:
|
||||
@@ -364,25 +419,36 @@ async def _kapitel_bilden(ctx: llm.Kontext) -> None:
|
||||
return None
|
||||
segmente.append((titel, folge[start:ende + 1]))
|
||||
start = ende + 1
|
||||
return segmente if segmente and start == len(folge) else None
|
||||
if not segmente or start != len(folge):
|
||||
return None
|
||||
for i, (_, ids) in enumerate(segmente): # Größen-Gate
|
||||
if len(ids) > max_groesse:
|
||||
return None
|
||||
if len(ids) < 2 and len(folge) > 1 and i < len(segmente) - 1:
|
||||
return None
|
||||
return segmente
|
||||
|
||||
segmente = await schneiden("", "kapitel")
|
||||
segmente = await schneiden("", f"kapitel-{level}")
|
||||
if segmente is None:
|
||||
segmente = await schneiden(
|
||||
"ACHTUNG: Der letzte Versuch war ungültig. Jede Zeile braucht"
|
||||
" \"titel\" und \"bis\" (eine id aus der Liste, streng aufsteigend);"
|
||||
" das letzte \"bis\" MUSS die letzte id der Liste sein.", "kapitel-2")
|
||||
" \"titel\" und \"bis\" (eine NUMMER aus der Liste, streng"
|
||||
" aufsteigend); das letzte \"bis\" MUSS die letzte Nummer der Liste"
|
||||
f" sein. Jedes Kapitel bündelt 2–{max_groesse} Bausteine (Richtwert"
|
||||
f" {richtwert} Kapitel); nur das letzte darf kleiner sein.",
|
||||
f"kapitel-{level}-2")
|
||||
art = "judge"
|
||||
if segmente is None: # deterministischer Fallback: gleichmäßige √n-Segmente
|
||||
art = "fallback"
|
||||
je_id = {b["id"]: b for b in bausteine}
|
||||
n = max(3, round(len(folge) ** 0.5))
|
||||
n = max(1, round(len(folge) ** 0.5))
|
||||
groesse = -(-len(folge) // n)
|
||||
segmente = [(je_id[teil[0]]["titel"], teil)
|
||||
for i in range(n) if (teil := folge[i * groesse:(i + 1) * groesse])]
|
||||
db.execute("DELETE FROM kapitel WHERE topic=?", (topic,))
|
||||
for pos, (titel, ids) in enumerate(segmente):
|
||||
k_id = db.insert("kapitel", topic=topic, titel=titel[:120], ord=pos, art=art)
|
||||
for titel, ids in segmente:
|
||||
k_id = db.insert("kapitel", topic=topic, titel=titel[:120], ord=pos_global,
|
||||
art=art, level=level)
|
||||
pos_global += 1
|
||||
for b_id in ids:
|
||||
db.update("bausteine", "id", b_id, kapitel_id=k_id)
|
||||
|
||||
@@ -391,8 +457,9 @@ async def bauen(ctx: llm.Kontext) -> None:
|
||||
ctx.ebene = EBENE
|
||||
await _ziele_bilden(ctx)
|
||||
await _zyklen_brechen(ctx)
|
||||
await _level_kalibrieren(ctx) # VOR dem Schnitt: Level bestimmt die Bausteine
|
||||
_level_konflikte_loesen(ctx.topic)
|
||||
_bausteine_schneiden(ctx)
|
||||
await _level_kalibrieren(ctx)
|
||||
await _kapitel_bilden(ctx)
|
||||
|
||||
|
||||
@@ -411,19 +478,41 @@ def messen(ctx: llm.Kontext) -> list[dict]:
|
||||
# pendelt nur die Note (Lektion 75).
|
||||
for b in bausteine:
|
||||
n = len(je_baustein[b["id"]])
|
||||
partner = any(bb["id"] != b["id"]
|
||||
partner = any(bb["id"] != b["id"] and bb["level"] == b["level"]
|
||||
and len(je_baustein[bb["id"]]) + n <= BAUSTEIN_MAX_ATOME
|
||||
for bb in bausteine)
|
||||
if n > BAUSTEIN_MAX_ATOME or (n < BAUSTEIN_MIN_ATOME and partner):
|
||||
befunde.append({"art": "band", "item": str(b["id"]),
|
||||
"detail": f"{b['titel']}: {n} Atome"})
|
||||
for a in je_baustein[b["id"]]: # ein Baustein = genau ein Level
|
||||
if a["level"] != b["level"]:
|
||||
befunde.append({"art": "level_mix", "item": str(a["id"]),
|
||||
"detail": f"Atom {a['level']} in Baustein {b['level']}"
|
||||
f" ({b['titel']})"})
|
||||
level_von = {a["id"]: a["level"] for a in atome}
|
||||
for k in _braucht_kanten(ctx.topic): # Voraussetzung nie über dem Nutzer-Level
|
||||
lv, lz = level_von.get(k["von_atom"]), level_von.get(k["zu_atom"])
|
||||
if lv and lz and LEVEL_RANG[lz] > LEVEL_RANG[lv]:
|
||||
befunde.append({"art": "level_konflikt", "item": str(k["id"]),
|
||||
"detail": f"Atom {k['von_atom']} ({lv}) braucht"
|
||||
f" {k['zu_atom']} ({lz})"})
|
||||
kaps = db.query("SELECT * FROM kapitel WHERE topic=?", (ctx.topic,))
|
||||
kap_ids = {k["id"] for k in kaps}
|
||||
kap_level = {k["id"]: k["level"] for k in kaps}
|
||||
for b in bausteine:
|
||||
if b["kapitel_id"] not in kap_ids:
|
||||
befunde.append({"art": "baustein_ohne_kapitel", "item": str(b["id"]),
|
||||
"detail": b["titel"]})
|
||||
elif kap_level.get(b["kapitel_id"]) != b["level"]:
|
||||
befunde.append({"art": "kapitel_level", "item": str(b["id"]),
|
||||
"detail": f"{b['titel']} ({b['level']}) in Kapitel"
|
||||
f" {kap_level.get(b['kapitel_id'])}"})
|
||||
belegt = {b["kapitel_id"] for b in bausteine}
|
||||
je_kapitel: dict = defaultdict(int)
|
||||
je_level: dict = defaultdict(int)
|
||||
for b in bausteine:
|
||||
je_kapitel[b["kapitel_id"]] += 1
|
||||
je_level[b["level"]] += 1
|
||||
for k in kaps:
|
||||
if k["id"] not in belegt:
|
||||
befunde.append({"art": "kapitel_leer", "item": str(k["id"]),
|
||||
@@ -431,6 +520,13 @@ def messen(ctx: llm.Kontext) -> list[dict]:
|
||||
if k["art"] == "fallback": # stiller Fallback wäre unsichtbarer Qualitätsverlust
|
||||
befunde.append({"art": "kapitel_fallback", "item": str(k["id"]),
|
||||
"detail": k["titel"]})
|
||||
# Singleton-Kapitel zerstückeln die Navigation (Lernen: 68/73) — erlaubt
|
||||
# nur, wenn der ganze Durchgang bloß einen Baustein hat.
|
||||
if je_kapitel[k["id"]] == 1 and je_level[k["level"]] > 1:
|
||||
letzte = max((kk["ord"] for kk in kaps if kk["level"] == k["level"]))
|
||||
if k["ord"] != letzte:
|
||||
befunde.append({"art": "kapitel_zerstueckelt", "item": str(k["id"]),
|
||||
"detail": k["titel"]})
|
||||
ord_von = {b["id"]: b["ord"] for b in bausteine}
|
||||
for k in _braucht_kanten(ctx.topic):
|
||||
bv = next((a["baustein_id"] for a in atome if a["id"] == k["von_atom"]), None)
|
||||
@@ -452,7 +548,8 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
|
||||
return False
|
||||
await _ziele_bilden(ctx) # fängt Partition-Lücken
|
||||
await _zyklen_brechen(ctx) # fängt Zyklen
|
||||
_bausteine_schneiden(ctx) # Neu-Schnitt fängt Band + Vorwärts-Kanten
|
||||
await _level_kalibrieren(ctx)
|
||||
_level_konflikte_loesen(ctx.topic) # fängt level_konflikt
|
||||
_bausteine_schneiden(ctx) # Neu-Schnitt fängt Band, Vorwärts-Kanten, level_mix
|
||||
await _kapitel_bilden(ctx) # fängt Kapitel-Befunde
|
||||
return True
|
||||
|
||||
@@ -7,6 +7,7 @@ alte→neue Maps nachgezogen. Die Dateien (storage/korpus/, topics/) trägt das
|
||||
Makefile per rsync NACH dem Import (topic_loeschen räumt den Korpus-Ordner)."""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -89,6 +90,8 @@ def importieren(d: dict) -> None:
|
||||
if b.get("quelle") in q_map:
|
||||
b["quelle"] = q_map[b["quelle"]]
|
||||
z["belege"] = db.j(belege)
|
||||
z["geprueft"] = db.j([q_map[q] for q in db.uj(z.get("geprueft") or "[]")
|
||||
if q in q_map])
|
||||
soll_zeilen.append(z)
|
||||
s_map = _einfuegen("soll", soll_zeilen)
|
||||
|
||||
@@ -109,9 +112,19 @@ def importieren(d: dict) -> None:
|
||||
z = dict(z)
|
||||
z["artefakt_id"] = ar_map.get(z["artefakt_id"])
|
||||
db.insert("leitner", **z)
|
||||
# Atom-Marker im Text remappen — ohne Rewrite zeigten nach dem Import ALLE
|
||||
# Marker auf nicht existierende Atom-IDs (aak: 421/421 tot; Beispiel-/
|
||||
# Fragen-Verknüpfung lief ins Leere)
|
||||
def marker_remap(m: re.Match) -> str:
|
||||
alt = int(m.group(1))
|
||||
return m.group(0).replace(m.group(1), str(a_map[alt]), 1) if alt in a_map else m.group(0)
|
||||
|
||||
for z in d["sections"]: # PK ist baustein_id
|
||||
z = dict(z)
|
||||
z["baustein_id"] = b_map.get(z["baustein_id"])
|
||||
for feld in ("text_lang", "text_kompakt"):
|
||||
if z.get(feld):
|
||||
z[feld] = re.sub(r"<!--\s*atom:\s*(\d+)\s*\|", marker_remap, z[feld])
|
||||
db.insert("sections", **z)
|
||||
r_map = _einfuegen("runs", d["runs"])
|
||||
_einfuegen("befunde", d["befunde"], run_id=r_map)
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||
import { api } from '../api.js'
|
||||
import { render, gefiltert, lesestat, renderGestuft } from '../markdown.js'
|
||||
import { render, lesestat } from '../markdown.js'
|
||||
|
||||
const props = defineProps({ topic: String, state: Object })
|
||||
const daten = ref(null)
|
||||
const level = ref('E') // Default: Einstieg — Umfang wächst per Toggle (E→M→S)
|
||||
const level = ref('E') // Durchgang-Wahl: jede Stufe ist ein eigener Lese-Durchgang
|
||||
const ansicht = ref('erklaerend') // erklaerend = Fließtext | kompakt = Stichpunkte
|
||||
const LEVEL_ICON = { E: '●○○', M: '●●○', S: '●●●' }
|
||||
const LEVEL_NAME = { E: 'Umfang: Grundgerüst', M: 'Umfang: + Standardstoff', S: 'Umfang: alles' }
|
||||
const LEVEL_NAME = { E: 'Durchgang 1: Grundgerüst', M: 'Durchgang 2: Standardstoff',
|
||||
S: 'Durchgang 3: Feinheiten' }
|
||||
const fokus = ref(false) // Fokus: Vollbild — App-Chrome + Navigation weg, Karten aufgelöst
|
||||
watch(fokus, (f) => document.body.classList.toggle('vollbild', f))
|
||||
onUnmounted(() => document.body.classList.remove('vollbild'))
|
||||
@@ -28,10 +29,9 @@ const htmlCache = new Map()
|
||||
watch(daten, () => htmlCache.clear())
|
||||
|
||||
function html(s) {
|
||||
const key = `${ansicht.value}|${level.value}|${s.baustein}`
|
||||
const key = `${ansicht.value}|${s.baustein}`
|
||||
if (!htmlCache.has(key)) {
|
||||
htmlCache.set(key, ansicht.value === 'kompakt'
|
||||
? render(s.kompakt || s.lang) : renderGestuft(s.lang, level.value))
|
||||
htmlCache.set(key, render(ansicht.value === 'kompakt' ? s.kompakt : s.lang))
|
||||
}
|
||||
return htmlCache.get(key)
|
||||
}
|
||||
@@ -47,23 +47,29 @@ function hoehe(i) {
|
||||
|
||||
function setAnsicht(a) {
|
||||
ansicht.value = a
|
||||
laden()
|
||||
}
|
||||
|
||||
// Nur Durchgänge anbieten, die es in diesem Thema gibt (ohne E- oder
|
||||
// S-Bausteine überspringt der Toggle die Stufe)
|
||||
const levels = computed(() => {
|
||||
const da = new Set((daten.value?.kapitel || []).map((k) => k.level))
|
||||
const folge = ['E', 'M', 'S'].filter((l) => da.has(l))
|
||||
return folge.length ? folge : ['E']
|
||||
})
|
||||
watch(levels, (l) => { if (!l.includes(level.value)) level.value = l[0] })
|
||||
|
||||
function levelToggle() {
|
||||
const folge = ['E', 'M', 'S']
|
||||
level.value = folge[(folge.indexOf(level.value) + 1) % 3]
|
||||
laden()
|
||||
const folge = levels.value
|
||||
level.value = folge[(folge.indexOf(level.value) + 1) % folge.length]
|
||||
}
|
||||
|
||||
// Nur Kapitel/Bausteine mit Inhalt im gewählten Umfang anzeigen — der
|
||||
// Level-Filter ließe sonst leere Titel-Hüllen stehen (36/66 Bausteine ohne E)
|
||||
// Der gewählte Durchgang: nur seine Kapitel, nur Sections mit Inhalt
|
||||
const anzeige = computed(() => {
|
||||
const out = []
|
||||
for (const kap of daten.value?.kapitel || []) {
|
||||
const sections = kap.sections.filter((s) => ansicht.value === 'kompakt'
|
||||
? (s.kompakt || s.lang || '').trim()
|
||||
: lesestat(gefiltert(s.lang, level.value)).woerter >= 10)
|
||||
if (kap.level !== level.value) continue
|
||||
const sections = kap.sections.filter((s) =>
|
||||
((ansicht.value === 'kompakt' ? s.kompakt : s.lang) || '').trim())
|
||||
if (sections.length) out.push({ ...kap, sections })
|
||||
}
|
||||
return out
|
||||
@@ -77,8 +83,7 @@ const stats = computed(() => {
|
||||
let woerter = ansicht.value === 'kompakt' ? 0 : lesestat(kap.intro).woerter
|
||||
let formeln = 0
|
||||
for (const s of kap.sections) {
|
||||
const st = lesestat(ansicht.value === 'kompakt' ? (s.kompakt || s.lang)
|
||||
: gefiltert(s.lang, level.value))
|
||||
const st = lesestat(ansicht.value === 'kompakt' ? s.kompakt : s.lang)
|
||||
woerter += st.woerter
|
||||
formeln += st.displayFormeln
|
||||
}
|
||||
@@ -156,12 +161,23 @@ function onScroll(e) {
|
||||
<div v-for="(kap, i) in anzeige" :key="kap.kapitel_id" class="kapitel"
|
||||
:ref="(el) => setKapEl(i, el)">
|
||||
<h2 class="kapitel-titel">{{ i + 1 }}. {{ kap.titel }}</h2>
|
||||
<div class="kapitel-meta">
|
||||
{{ (stats.je[i]?.woerter || 0).toLocaleString('de-DE') }} Wörter ·
|
||||
{{ zeit(stats.je[i]?.minuten || 0) }}
|
||||
</div>
|
||||
<template v-if="sichtbar(i)">
|
||||
<p v-if="kap.intro && ansicht === 'erklaerend'"
|
||||
style="color: var(--dim); margin-top: 0">{{ kap.intro }}</p>
|
||||
<div v-for="s in kap.sections" :key="s.baustein" class="guide-section">
|
||||
<h3 style="margin-top: 0">{{ s.titel }}</h3>
|
||||
<div :class="ansicht === 'kompakt' ? 'kompakt' : 'markdown'" v-html="html(s)"></div>
|
||||
<details v-if="ansicht === 'erklaerend' && s.fragen?.length" class="antworten">
|
||||
<summary>Antworten zu „Prüfe dich"</summary>
|
||||
<div v-for="(f, fi) in s.fragen" :key="fi" class="antwort">
|
||||
<div class="frage">{{ f.frage }}</div>
|
||||
<div v-html="render(f.antwort)"></div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="kapitel-platzhalter" :style="{ height: hoehe(i) + 'px' }"></div>
|
||||
|
||||
@@ -48,8 +48,10 @@ export function render(text) {
|
||||
return DOMPurify.sanitize(marked.parse(mathe(text || '')))
|
||||
}
|
||||
|
||||
const RANG = { E: 0, M: 1, S: 2 }
|
||||
const MARKER = /<!--\s*atom:\s*(\d+)\s*\|\s*([^|]*)\|\s*([EMS])\s*-->/g
|
||||
// Atom-Marker sind eine unsichtbare Backend-Schnittstelle — vor der Anzeige
|
||||
// ausblenden. Die Stufen-Trennung passiert seit dem Umbau über getrennte
|
||||
// Bausteine je Level, nicht mehr über Marker-Filterung im Text.
|
||||
const MARKER = /<!--\s*atom:[^>]*-->/g
|
||||
|
||||
// Wortzahl + Display-Formeln des sichtbaren Texts (für die Lesezeit-Schätzung)
|
||||
export function lesestat(text) {
|
||||
@@ -61,47 +63,3 @@ export function lesestat(text) {
|
||||
const woerter = t.split(/\s+/).filter(Boolean).length
|
||||
return { woerter, displayFormeln }
|
||||
}
|
||||
|
||||
// Text an Atom-Markern in Segmente schneiden; Filter zeigt Atome bis zum gewählten Level.
|
||||
export function segmente(lang) {
|
||||
const out = []
|
||||
let letzt = 0
|
||||
let aktuell = { level: null, titel: '', text: '' }
|
||||
for (const m of lang.matchAll(MARKER)) {
|
||||
aktuell.text = lang.slice(letzt, m.index)
|
||||
out.push(aktuell)
|
||||
aktuell = { level: m[3], titel: m[2].trim(), text: '' }
|
||||
letzt = m.index + m[0].length
|
||||
}
|
||||
aktuell.text = lang.slice(letzt)
|
||||
out.push(aktuell)
|
||||
return out
|
||||
}
|
||||
|
||||
export function gefiltert(lang, maxLevel) {
|
||||
if (!maxLevel || maxLevel === 'S') return lang
|
||||
return segmente(lang)
|
||||
.filter((s) => s.level === null || RANG[s.level] <= RANG[maxLevel])
|
||||
.map((s) => s.text)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
// Gestuftes Rendern: Segmente über dem Level fallen weg, Segmente DARUNTER
|
||||
// werden gedimmt (schon gelernt) — der Blick geht auf die neuen Inhalte.
|
||||
// Kernpunkte/Prüfe-dich (nach dem letzten Atom) bleiben immer ungedimmt.
|
||||
export function renderGestuft(lang, maxLevel) {
|
||||
if (!maxLevel || maxLevel === 'E') return render(gefiltert(lang, maxLevel))
|
||||
const seg = segmente(lang)
|
||||
const letzt = seg[seg.length - 1]
|
||||
const i = letzt.text.indexOf('**Kernpunkte:**')
|
||||
if (i >= 0) {
|
||||
seg.push({ level: null, titel: '', text: letzt.text.slice(i) })
|
||||
letzt.text = letzt.text.slice(0, i)
|
||||
}
|
||||
return seg.map((s) => {
|
||||
if (s.level !== null && RANG[s.level] > RANG[maxLevel]) return ''
|
||||
const html = render(s.text)
|
||||
const gelernt = s.level !== null && RANG[s.level] < RANG[maxLevel]
|
||||
return gelernt ? `<div class="gelernt">${html}</div>` : html
|
||||
}).join('')
|
||||
}
|
||||
|
||||
@@ -203,9 +203,13 @@ body.nav-zu .hauptbereich { padding-top: 38px; }
|
||||
}
|
||||
.fokus-aus:hover { opacity: 1; }
|
||||
.seite.guide-text::-webkit-scrollbar { display: none; }
|
||||
.markdown .gelernt { opacity: 0.45; } /* niedrigere Level = schon gelernt */
|
||||
.guide-text .kapitel { scroll-margin-top: 8px; }
|
||||
.guide-text .kapitel-titel { margin: 30px 0 6px; }
|
||||
.guide-text .kapitel-titel { margin: 30px 0 2px; }
|
||||
.guide-text .kapitel-meta { color: var(--dim); font-size: 13px; margin: 0 0 10px; }
|
||||
.guide-section .antworten { margin-top: 10px; border-top: 1px solid var(--rand); padding-top: 8px; }
|
||||
.guide-section .antworten summary { cursor: pointer; color: var(--dim); font-size: 14px; }
|
||||
.guide-section .antworten .antwort { margin: 10px 0; }
|
||||
.guide-section .antworten .frage { font-weight: 600; font-size: 14px; margin-bottom: 2px; }
|
||||
.kapitel-platzhalter { border-left: 2px dashed var(--rand); margin: 8px 0 8px 4px; }
|
||||
/* Kompakt-Ansicht: gleiche Lesetypografie wie der Fließtext, kein Grau-Kasten */
|
||||
.guide-text .kompakt {
|
||||
|
||||
@@ -7,6 +7,8 @@ Prüfe jedes Artefakt GEGEN DIE BELEGE:
|
||||
- ok=false, wenn Frage oder Antwort auf Beleg, Quelle, Musterlösung oder einen
|
||||
Aufgabenkontext verweist — Karten müssen ohne den Quelltext selbstständig
|
||||
funktionieren und Wissen abfragen, nicht Textinhalte.
|
||||
- ok=false, wenn Frage oder Antwort nicht auf Deutsch ist (etablierte
|
||||
Fachtermini sind okay; ganze englische Sätze nicht).
|
||||
- Formulierungsgeschmack ist KEIN Mangel — inhaltlich korrekt in anderen Worten zählt voll.
|
||||
- Bei ok=false: „mangel" = ein konkreter, behebbarer Satz.
|
||||
- Antworte für JEDES Artefakt (id wörtlich übernehmen).
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
<!-- template:Atom-Anker-Fix -->
|
||||
Atom: „{titel}" — {definition}
|
||||
|
||||
Suche im QUELLTEXT unten die Passage, die dieses Atom belegt, und kopiere sie
|
||||
WÖRTLICH (exakt, Zeichen für Zeichen, 5–40 Wörter). Wenn der Text das Atom
|
||||
NICHT enthält: leeres Zitat. Nichts paraphrasieren.
|
||||
|
||||
QUELLTEXT:
|
||||
{text}
|
||||
|
||||
Antworte NUR mit JSON (keine Code-Fences):
|
||||
{{"zitat": "…"}}
|
||||
@@ -17,6 +17,8 @@ Regeln:
|
||||
- „zitat": die Aufgabenstelle WÖRTLICH kopiert (5–40 Wörter) — sie ist der Beleg,
|
||||
dass dieses Konzept geübt wird.
|
||||
- Prüft eine Aufgabe mehrere Konzepte, wird jedes ein eigenes Atom.
|
||||
- SPRACHE: „titel" und „definition" IMMER auf Deutsch (fremdsprachige Quellen
|
||||
sinngemäß übersetzen; etablierte Fachtermini bleiben). Nur das „zitat" bleibt original.
|
||||
- Arbeite AUSSCHLIESSLICH mit dem Quelltext, nichts erfinden.
|
||||
- „braucht": Titel anderer Atome DIESES Auszugs, die Voraussetzung sind. Leer, wenn keine.
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ lernbare Einheit, mit Typ:
|
||||
Test: Kann man es einzeln abprüfen? Dann Atom. Beispiele und Übungsaufgaben sind
|
||||
KEINE Atome. Regeln:
|
||||
- Arbeite AUSSCHLIESSLICH mit dem Quelltext. Kein externes Wissen, keine Websuche, nichts erfinden.
|
||||
- SPRACHE: „titel" und „definition" IMMER auf Deutsch — auch bei fremdsprachigen
|
||||
Quellen sinngemäß übersetzen. Etablierte Fachtermini ohne deutsche Entsprechung
|
||||
bleiben original. Nur das „zitat" bleibt in der Quellsprache.
|
||||
- „titel": Konzeptname aus den Begriffen der Quelle. Katalognummern („Satz 7.13") sind
|
||||
KEIN Titel; kein Lehrbuch-Oberbegriff, der nicht im Text steht.
|
||||
- „definition": 1–2 Sätze, eigenständig verständlich (dient später dem Dubletten-Vergleich).
|
||||
|
||||
14
templates/Atom-Titel-Fix.md
Normal file
14
templates/Atom-Titel-Fix.md
Normal file
@@ -0,0 +1,14 @@
|
||||
<!-- template:Atom-Titel-Fix -->
|
||||
Unten Lern-Atome mit kaputten Titeln (Format „id: titel — definition"):
|
||||
Katalognummern („Satz 6.26") oder abgerissene Fragmente.
|
||||
|
||||
Gib jedem Atom einen sauberen Titel:
|
||||
- Konzeptname aus der Definition, 2–6 Wörter, auf Deutsch.
|
||||
- KEINE Nummern, keine Quellen-Referenzen, keine Satzzeichen am Ende.
|
||||
- Der Titel muss das Konzept eindeutig benennen (dient als Verweis-Anker).
|
||||
|
||||
ATOME:
|
||||
{atome}
|
||||
|
||||
Antworte NUR mit JSON (keine Code-Fences):
|
||||
[{{"atom": 12, "titel": "…"}}]
|
||||
@@ -1,6 +1,12 @@
|
||||
<!-- template:Guide-Writer -->
|
||||
Schreibe EINE Guide-Section für den Baustein „{titel}".
|
||||
Lernziel: {ziel}
|
||||
DURCHGANG: {durchgang}. Der Guide hat drei getrennte Lese-Durchgänge — diese
|
||||
Section muss für ihren Durchgang ALLEIN kohärent lesbar sein.
|
||||
|
||||
BEREITS BEHANDELT (frühere Durchgänge — NICHT neu erklären, nur bei Bedarf mit
|
||||
einem Halbsatz anknüpfen; dafür KEINE Marker setzen):
|
||||
{kontext}
|
||||
|
||||
Unten die Atome mit Marker, Definition, Belegen, ggf. Beispiel und Fragen-Pool.
|
||||
|
||||
@@ -54,7 +60,9 @@ Regeln (hart):
|
||||
ein, kein Fülltext, keine Wiederholungen.
|
||||
6. „kompakt": 2–3 Stichpunkte (Markdown-Liste) — Verdichtung der Section, KEINE
|
||||
Paraphrase, keine Marker.
|
||||
7. Keine Vorgriffe auf Stoff, der nicht in den Atomen unten steht.
|
||||
7. Keine Vorgriffe auf Stoff, der nicht in den Atomen unten steht — auch nicht
|
||||
auf spätere Durchgänge. Unbekannte Begriffe beim ersten Auftreten kurz mit
|
||||
Bekanntem einordnen, nie vertrösten („dazu später mehr" ist VERBOTEN).
|
||||
|
||||
ATOME:
|
||||
{atome}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
<!-- template:Kapitel-Intro -->
|
||||
Schreibe einen Advance Organizer für das Guide-Kapitel „{titel}".
|
||||
Schreibe die Einleitung für das Guide-Kapitel „{titel}".
|
||||
Durchgang: {durchgang}.
|
||||
|
||||
Unten die Abschnitte des Kapitels (Titel: Lernziel).
|
||||
|
||||
Regeln:
|
||||
- 2–4 Sätze: Was kommt in diesem Kapitel, wie hängen die Abschnitte zusammen,
|
||||
worauf baut es auf.
|
||||
- NUR Landkarte, KEINE Inhalte vorwegnehmen — nichts erklären, nichts definieren
|
||||
(das wäre Redundanz zum Kapiteltext).
|
||||
- Kein Vorgeplänkel, keine Motivation-Floskeln („In diesem spannenden Kapitel…").
|
||||
- 1–2 Sätze, KONKRET: ein greifbarer Aufhänger — die zentrale Frage, das
|
||||
Problem oder der Anwendungsfall, den das Kapitel löst. Wenn möglich mit
|
||||
einem konkreten Beispiel oder einer vertrauten Analogie.
|
||||
- VERBOTEN: Landkarten-Meta („Dieses Kapitel führt ein…", „legt das Fundament",
|
||||
„Ausgehend von … werden … umrissen") — solche Vorschauen liest niemand.
|
||||
- Nichts erklären, nichts definieren (das macht der Kapiteltext).
|
||||
- Kein Vorgeplänkel, keine Motivations-Floskeln.
|
||||
|
||||
ABSCHNITTE:
|
||||
{bausteine}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<!-- template:Kapitel-Schnitt -->
|
||||
Unten die BAUSTEINE eines Lern-Guides in ihrer endgültigen Lese-Reihenfolge
|
||||
(Format „id: titel — lernziel").
|
||||
Unten die BAUSTEINE eines Lern-Guide-Durchgangs in ihrer endgültigen
|
||||
Lese-Reihenfolge, durchnummeriert (Format „nummer: titel — lernziel").
|
||||
Durchgang: {durchgang}. Formuliere die Kapiteltitel für diesen Durchgang.
|
||||
|
||||
Setze Kapitelgrenzen. Regeln:
|
||||
- Ein Kapitel = ein zusammenhängendes Teilthema. Grenze dort, wo ein neues beginnt.
|
||||
- Antworte mit EINER Zeile je Kapitel: "titel" plus "bis" = id des LETZTEN
|
||||
Bausteins dieses Kapitels. Die Reihenfolge ist fix — du setzt nur Grenzen.
|
||||
Das letzte "bis" muss die letzte id der Liste sein.
|
||||
- Antworte mit EINER Zeile je Kapitel: "titel" plus "bis" = NUMMER des LETZTEN
|
||||
Bausteins dieses Kapitels. Die Reihenfolge ist fix — du setzt nur Grenzen,
|
||||
"bis" muss streng aufsteigen. Das letzte "bis" ist die letzte Nummer der Liste.
|
||||
- Richtwert: etwa {richtwert} Kapitel — weiche ab, wo Themenwechsel es verlangen.
|
||||
- Jedes Kapitel bündelt MINDESTENS 2 Bausteine (nur das letzte darf kleiner
|
||||
sein) und höchstens das Doppelte des Durchschnitts — keine Einzel-Kapitel.
|
||||
- „titel" = kurze Inhaltsaussage über ALLE Bausteine des Kapitels (kein
|
||||
Baustein-Titel-Duplikat, keine Nummerierung).
|
||||
{hinweis}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
<!-- template:Korpus-Soll-Beleg -->
|
||||
Soll-Punkt: „{punkt}"
|
||||
Unten Soll-Punkte (Format „id: punkt") und EIN Quelltext.
|
||||
|
||||
Suche im QUELLTEXT unten eine Passage, die diesen Punkt belegt. Wenn es eine gibt:
|
||||
kopiere sie WÖRTLICH (exakt, 5–30 Wörter). Wenn der Text den Punkt NICHT behandelt:
|
||||
leeres Zitat. Nichts paraphrasieren, nichts erfinden.
|
||||
Suche für JEDEN Punkt eine Passage im QUELLTEXT, die ihn belegt. Wenn es eine
|
||||
gibt: kopiere sie WÖRTLICH (exakt, 5–30 Wörter). Behandelt der Text einen Punkt
|
||||
NICHT: den Punkt weglassen. Nichts paraphrasieren, nichts erfinden.
|
||||
|
||||
SOLL-PUNKTE:
|
||||
{punkte}
|
||||
|
||||
QUELLTEXT:
|
||||
{text}
|
||||
|
||||
Antworte NUR mit JSON (keine Code-Fences):
|
||||
{{"zitat": "…"}}
|
||||
Antworte NUR mit JSON (keine Code-Fences) — nur Punkte MIT Fund:
|
||||
[{{"soll": 12, "zitat": "…"}}]
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
<!-- template:Level-Kalibrierung -->
|
||||
Unten die Atome EINES Guide-Bausteins „{titel}" (Format „id: titel — definition").
|
||||
Unten die Atome EINES Lernziels „{titel}" (Format „id: titel — definition").
|
||||
|
||||
Kalibriere die Lernstufen RELATIV zueinander. Die Stufen sind Komplexitätsschichten
|
||||
(nicht „leicht/schwer"):
|
||||
- E = Grundgerüst: das Minimum, das man beim ERSTEN Durchgang braucht, um den
|
||||
Baustein zu verstehen — Kernbegriffe, die Kernaussage. Die E-Atome allein müssen
|
||||
einen kohärenten Kurzdurchgang ergeben.
|
||||
(nicht „leicht/schwer") und werden zu GETRENNTEN Bausteinen und Lese-Durchgängen:
|
||||
- E = Grundgerüst: das Minimum, das man beim ERSTEN Durchgang braucht, um das
|
||||
Lernziel zu verstehen — Kernbegriffe, die Kernaussage. Die E-Atome allein müssen
|
||||
einen kohärenten, für sich lesbaren Kurzdurchgang tragen.
|
||||
- M = Standardstoff: Sätze, Verfahren, Standardbeweise — der zweite Durchgang.
|
||||
- S = Feinheiten: Spezialfälle, Beweisdetails, Optimierungen, Randnotizen.
|
||||
|
||||
Regeln:
|
||||
- Behandelt der Baustein grundlegenden Stoff, gehört sein tragender Kern zu E.
|
||||
- Rein vertiefende Bausteine (reine Beweis-/Spezialbausteine) dürfen ohne E bleiben.
|
||||
- Behandelt das Ziel grundlegenden Stoff, gehört sein tragender Kern zu E.
|
||||
- Voraussetzungen eines Atoms gehören auf DESSEN Stufe oder tiefer — was ein
|
||||
E-Atom braucht, kann nicht M/S sein.
|
||||
- Rein vertiefende Ziele (reine Beweis-/Spezialthemen) dürfen ohne E bleiben.
|
||||
- Jede id genau einmal, keine erfinden.
|
||||
|
||||
ATOME:
|
||||
|
||||
@@ -178,7 +178,7 @@ def test_det_auftraege_blockquote_und_artefakt():
|
||||
b_id = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu")
|
||||
a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d",
|
||||
status="neu", baustein_id=b_id, braucht=db.j([]))
|
||||
lang = (f"<!-- atom: {a} | A | E -->\n" + "Fließtext. " * 45
|
||||
lang = (f"<!-- atom: {a} | A -->\n" + "Fließtext. " * 45
|
||||
+ "\n> wörtliches Rohzitat aus der Quelle\nEs gilt u 6= v."
|
||||
+ "\n\nDas Blank-Symbol $[. ist speziell und liegt in $[ \\in \\Gamma$.")
|
||||
kompakt = "- Definition mit x_i und Laufzeit 2^(n/2) als Klartext."
|
||||
@@ -189,6 +189,62 @@ def test_det_auftraege_blockquote_und_artefakt():
|
||||
assert "Kompakt-Fassung" in text and "x_i" in text # Klartext-Formeln gefangen
|
||||
|
||||
|
||||
def test_det_auftraege_neue_checks():
|
||||
import db
|
||||
import guide
|
||||
from conftest import topic_anlegen
|
||||
topic = topic_anlegen("detcheck2")
|
||||
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
|
||||
b_id = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu")
|
||||
a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d",
|
||||
status="neu", baustein_id=b_id, braucht=db.j([]))
|
||||
wand = "Wort " * 130
|
||||
lang = (f"<!-- atom: {a} | A -->\n<!-- atom: 99999 | Fremd -->\n"
|
||||
"Der Beweis folgt aus Satz 6.25, dazu später mehr.\n\n"
|
||||
+ wand + "\n\n#### Leere Überschrift\n\n#### Noch eine\nText dahinter.\n\n"
|
||||
"The proof is left as an exercise for the reader and this paragraph"
|
||||
" should be detected by the language check here.")
|
||||
kompakt = f"<!-- atom: {a} | A -->\n- Punkt"
|
||||
text = " ".join(guide._det_auftraege(topic, {"id": b_id, "ord": 0}, lang, kompakt))
|
||||
assert "gehört nicht zu diesem Baustein" in text # marker_fremd
|
||||
assert "Vertröstungs-Floskel" in text # „später mehr"
|
||||
assert "Quellen-Referenz" in text # Satz 6.25
|
||||
assert "Wörter (Regel: 40–90)" in text # Absatz-Wand
|
||||
assert "ohne folgenden Text" in text # leere Überschrift
|
||||
assert "englische Passage" in text
|
||||
assert "Marker gehören nur" in text # Marker in Kompakt-Fassung
|
||||
# qa=True: Marker/Länge/Vorwärts haben eigene Befund-Arten, Stil-Checks bleiben
|
||||
qa_text = " ".join(guide._det_auftraege(topic, {"id": b_id, "ord": 0},
|
||||
lang, kompakt, qa=True))
|
||||
assert "gehört nicht zu diesem Baustein" not in qa_text
|
||||
assert "Vertröstungs-Floskel" in qa_text
|
||||
|
||||
|
||||
async def test_fix_fehlschlag_behaelt_befunde(monkeypatch):
|
||||
import db
|
||||
import fake_agents
|
||||
import guide
|
||||
import llm
|
||||
from conftest import run_anlegen, topic_anlegen
|
||||
topic = topic_anlegen("fixfail")
|
||||
run = run_anlegen(topic)
|
||||
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
|
||||
b_id = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu")
|
||||
db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d",
|
||||
status="neu", baustein_id=b_id, braucht=db.j([]))
|
||||
db.insert("sections", baustein_id=b_id, stage="fix",
|
||||
text_lang="alt", text_kompakt="", befunde=db.j(["Behebe (x): y"]))
|
||||
# Fix liefert Text OHNE Marker → Marker-Invariante bricht → Fehlschlag-Pfad
|
||||
monkeypatch.setitem(fake_agents._HANDLER, "Guide-Fix",
|
||||
lambda p: {"kompakt": "", "lang": "kein Marker"})
|
||||
ctx = llm.Kontext(run, topic, "minimax")
|
||||
ctx.ebene = "guide"
|
||||
stage = await guide._stage_fix(ctx, db.one("SELECT * FROM bausteine WHERE id=?", (b_id,)))
|
||||
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b_id,))
|
||||
assert stage == "done" and sec["text_lang"] == "alt"
|
||||
assert db.uj(sec["befunde"]) == ["Behebe (x): y"] # Aufträge bleiben sichtbar
|
||||
|
||||
|
||||
def test_katex_gate_und_markdown_hygiene(monkeypatch):
|
||||
import guide
|
||||
monkeypatch.delenv("CREATOR_FAKE_AGENTS", raising=False) # Gate echt laufen lassen
|
||||
|
||||
@@ -36,6 +36,7 @@ def _pruefe_endzustand(topic, run_id):
|
||||
md = guide.guide_markdown(topic)
|
||||
for a in atome:
|
||||
assert f"<!-- atom: {a['id']} |" in md
|
||||
assert "| E -->" not in md and "| M -->" not in md # Marker tragen kein Level mehr
|
||||
# Kapitel-Struktur: H2 = Kapitel (Struktur-Ebene, mit Intro), H3 = Baustein
|
||||
assert "\n## " in "\n" + md and "### " in md
|
||||
assert "**Kernpunkte:**" in md and "**Prüfe dich:**" in md
|
||||
@@ -48,6 +49,17 @@ def _pruefe_endzustand(topic, run_id):
|
||||
folge = [b["kapitel_id"] for b in bausteine]
|
||||
segmente = [k for k, _ in itertools.groupby(folge)]
|
||||
assert len(segmente) == len(set(segmente)) == len(kaps) # kontiguierlich, lückenlos
|
||||
# Stufen-Bausteine: jeder Baustein trägt genau ein Level, alle seine Atome
|
||||
# dieses Level; Kapitel-Level monoton E→M→S entlang der globalen ord
|
||||
rang = {"E": 0, "M": 1, "S": 2}
|
||||
kap_level = {k["id"]: k["level"] for k in kaps}
|
||||
for b in bausteine:
|
||||
assert kap_level[b["kapitel_id"]] == b["level"]
|
||||
for a in atome:
|
||||
if a["baustein_id"] == b["id"]:
|
||||
assert a["level"] == b["level"], (a["titel"], a["level"], b["level"])
|
||||
level_folge = [rang[b["level"]] for b in bausteine]
|
||||
assert level_folge == sorted(level_folge), "Durchgänge nicht E<M<S geordnet"
|
||||
for b in db.query("SELECT titel FROM bausteine WHERE topic=?", (topic,)):
|
||||
assert not b["titel"].startswith("Kann"), b["titel"] # Kurztitel, kein Ziel-Satz
|
||||
events = db.query("SELECT * FROM events WHERE run_id=?", (run_id,))
|
||||
@@ -58,9 +70,9 @@ async def test_e2e_thema():
|
||||
topic = topic_anlegen("fake-thema", art="thema")
|
||||
run_id = await _lauf_komplett(topic)
|
||||
_pruefe_endzustand(topic, run_id)
|
||||
# zwei kleine Ziele → Merge über Soll-Grenzen (Entkopplung): EIN Baustein
|
||||
# Fake-Welt: 3 Atome mit Level E/M/S → je Level ein eigener Baustein-Durchgang
|
||||
bausteine = db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (topic,))
|
||||
assert len(bausteine) == 1
|
||||
assert [b["level"] for b in bausteine] == ["E", "M", "S"]
|
||||
|
||||
|
||||
async def test_e2e_uni(tmp_path):
|
||||
|
||||
@@ -132,11 +132,10 @@ async def test_level_kalibrierung(monkeypatch):
|
||||
topic = topic_anlegen("level")
|
||||
run = run_anlegen(topic)
|
||||
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
|
||||
b_id = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu")
|
||||
a1 = db.insert("atome", topic=topic, titel="Kern", typ="begriff", definition="d",
|
||||
level="M", status="neu", baustein_id=b_id, braucht=db.j([]))
|
||||
level="M", status="neu", ziel_id=ziel, braucht=db.j([]))
|
||||
a2 = db.insert("atome", topic=topic, titel="Detail", typ="aussage", definition="d",
|
||||
level="M", status="neu", baustein_id=b_id, braucht=db.j([]))
|
||||
level="M", status="neu", ziel_id=ziel, braucht=db.j([]))
|
||||
|
||||
def judge(prompt):
|
||||
return [{"atom": a1, "level": "E"}, {"atom": a2, "level": "S"},
|
||||
@@ -147,3 +146,67 @@ async def test_level_kalibrierung(monkeypatch):
|
||||
await struktur._level_kalibrieren(ctx)
|
||||
assert db.one("SELECT level FROM atome WHERE id=?", (a1,))["level"] == "E"
|
||||
assert db.one("SELECT level FROM atome WHERE id=?", (a2,))["level"] == "S"
|
||||
|
||||
|
||||
def test_level_konflikt_absenkung():
|
||||
# v(E) braucht z(S), z braucht w(M) → Kaskade senkt z UND w auf E
|
||||
topic = topic_anlegen("konflikt")
|
||||
run = run_anlegen(topic)
|
||||
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
|
||||
def atom(titel, level):
|
||||
return db.insert("atome", topic=topic, titel=titel, typ="begriff",
|
||||
definition="d", level=level, status="neu", ziel_id=ziel,
|
||||
braucht=db.j([]))
|
||||
v, z, w = atom("V", "E"), atom("Z", "S"), atom("W", "M")
|
||||
for von, zu in ((v, z), (z, w)):
|
||||
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art)"
|
||||
" VALUES(?,?,?,'braucht')", (topic, von, zu))
|
||||
struktur._level_konflikte_loesen(topic)
|
||||
assert db.one("SELECT level FROM atome WHERE id=?", (z,))["level"] == "E"
|
||||
assert db.one("SELECT level FROM atome WHERE id=?", (w,))["level"] == "E"
|
||||
ctx = llm.Kontext(run, topic, "minimax")
|
||||
assert not [b for b in struktur.messen(ctx) if b["art"] == "level_konflikt"]
|
||||
|
||||
|
||||
async def test_level_split_ordnung_kapitel():
|
||||
# Ein Ziel mit 4×E + 4×M → zwei Bausteine mit level, E-ord < M-ord,
|
||||
# Kapitel je Durchgang, kein level_mix/kapitel_level
|
||||
topic = topic_anlegen("split")
|
||||
run = run_anlegen(topic)
|
||||
soll = db.insert("soll", topic=topic, punkt="P", status="bestaetigt", belege=db.j([]))
|
||||
ziel = db.insert("lernziele", topic=topic, text="Kann P", soll_id=soll, status="aktiv")
|
||||
for i in range(4):
|
||||
db.insert("atome", topic=topic, titel=f"E{i}", typ="begriff", definition="d",
|
||||
level="E", status="neu", soll_id=soll, ziel_id=ziel, braucht=db.j([]))
|
||||
db.insert("atome", topic=topic, titel=f"M{i}", typ="begriff", definition="d",
|
||||
level="M", status="neu", soll_id=soll, ziel_id=ziel, braucht=db.j([]))
|
||||
ctx = llm.Kontext(run, topic, "minimax")
|
||||
ctx.ebene = "struktur"
|
||||
struktur._bausteine_schneiden(ctx)
|
||||
bausteine = db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (topic,))
|
||||
assert [b["level"] for b in bausteine] == ["E", "M"]
|
||||
await struktur._kapitel_bilden(ctx)
|
||||
kaps = db.query("SELECT * FROM kapitel WHERE topic=? ORDER BY ord", (topic,))
|
||||
assert [k["level"] for k in kaps] == ["E", "M"]
|
||||
arten = {b["art"] for b in struktur.messen(ctx)}
|
||||
assert not arten & {"level_mix", "kapitel_level", "band", "partition"}
|
||||
|
||||
|
||||
def test_kein_merge_ueber_level():
|
||||
# kleine E-Gruppe darf NICHT mit kleiner M-Gruppe mergen
|
||||
topic = topic_anlegen("levelmerge")
|
||||
run = run_anlegen(topic)
|
||||
soll = db.insert("soll", topic=topic, punkt="P", status="bestaetigt", belege=db.j([]))
|
||||
z1 = db.insert("lernziele", topic=topic, text="Kann P1", soll_id=soll, status="aktiv")
|
||||
z2 = db.insert("lernziele", topic=topic, text="Kann P2", soll_id=soll, status="aktiv")
|
||||
for i in range(2):
|
||||
db.insert("atome", topic=topic, titel=f"E{i}", typ="begriff", definition="d",
|
||||
level="E", status="neu", soll_id=soll, ziel_id=z1, braucht=db.j([]))
|
||||
db.insert("atome", topic=topic, titel=f"M{i}", typ="begriff", definition="d",
|
||||
level="M", status="neu", soll_id=soll, ziel_id=z2, braucht=db.j([]))
|
||||
ctx = llm.Kontext(run, topic, "minimax")
|
||||
struktur._bausteine_schneiden(ctx)
|
||||
for b in db.query("SELECT * FROM bausteine WHERE topic=?", (topic,)):
|
||||
levels = {a["level"] for a in struktur._atome(topic)
|
||||
if a["baustein_id"] == b["id"]}
|
||||
assert levels == {b["level"]}
|
||||
|
||||
@@ -42,3 +42,10 @@ async def test_export_import_roundtrip():
|
||||
for b in db.query("SELECT * FROM bausteine WHERE topic=?", (topic,)):
|
||||
assert db.one("SELECT id FROM kapitel WHERE id=?", (b["kapitel_id"],))
|
||||
assert db.one("SELECT baustein_id FROM sections WHERE baustein_id=?", (b["id"],))
|
||||
# Marker-Remap: jede Marker-id im importierten Text existiert (aak: 421/421 tot)
|
||||
atom_ids = {a["id"] for a in db.query("SELECT id FROM atome WHERE topic=?", (topic,))}
|
||||
for s in db.query("SELECT s.* FROM sections s JOIN bausteine b ON b.id=s.baustein_id"
|
||||
" WHERE b.topic=?", (topic,)):
|
||||
for feld in ("text_lang", "text_kompakt"):
|
||||
for m in re.findall(r"<!--\s*atom:\s*(\d+)\s*\|", s[feld] or ""):
|
||||
assert int(m) in atom_ids, f"toter Marker {m}"
|
||||
|
||||
Reference in New Issue
Block a user