update
This commit is contained in:
4
Makefile
4
Makefile
@@ -18,10 +18,10 @@ stop:
|
|||||||
@echo "gestoppt."
|
@echo "gestoppt."
|
||||||
|
|
||||||
test:
|
test:
|
||||||
cd backend && python -m pytest ../tests -x -q
|
cd backend && python3 -m pytest ../tests -x -q
|
||||||
|
|
||||||
test-e2e:
|
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:
|
build:
|
||||||
cd frontend && npm run build
|
cd frontend && npm run build
|
||||||
|
|||||||
@@ -82,11 +82,12 @@ async def _api_slot(key: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
class AgentErgebnis:
|
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):
|
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.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.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
|
@property
|
||||||
def ok(self) -> bool:
|
def ok(self) -> bool:
|
||||||
@@ -161,12 +162,13 @@ async def run_agent(key: str, prompt: str, timeout: int, *, provider: str,
|
|||||||
await _api_slot(key)
|
await _api_slot(key)
|
||||||
wait_s = time.time() - warte_start
|
wait_s = time.time() - warte_start
|
||||||
try:
|
try:
|
||||||
res = await _text_api(key, prompt, timeout, model)
|
res = await _text_api(key, prompt, timeout, model, role)
|
||||||
finally:
|
finally:
|
||||||
_api_inflight -= 1
|
_api_inflight -= 1
|
||||||
if res.rc == 0:
|
if res.rc == 0:
|
||||||
_erfolg_melden()
|
_erfolg_melden()
|
||||||
res.wait_s = wait_s
|
res.wait_s = wait_s
|
||||||
|
res.model = model
|
||||||
return res
|
return res
|
||||||
await _drossel_warten(key)
|
await _drossel_warten(key)
|
||||||
wait_s = time.time() - warte_start
|
wait_s = time.time() - warte_start
|
||||||
@@ -175,6 +177,7 @@ async def run_agent(key: str, prompt: str, timeout: int, *, provider: str,
|
|||||||
else:
|
else:
|
||||||
res = await _claude_cli(key, prompt, timeout, model, capabilities)
|
res = await _claude_cli(key, prompt, timeout, model, capabilities)
|
||||||
res.wait_s = wait_s
|
res.wait_s = wait_s
|
||||||
|
res.model = model
|
||||||
return res
|
return res
|
||||||
finally:
|
finally:
|
||||||
_active.pop(key, None)
|
_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).
|
# Antwort nach bis zu 44 min — Extraktion war 53 von 63 min der Ebene).
|
||||||
"minimax-kalt/MiniMax-M2.7-highspeed": {"temperature": 0.3,
|
"minimax-kalt/MiniMax-M2.7-highspeed": {"temperature": 0.3,
|
||||||
"thinking": {"type": "disabled"}},
|
"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,
|
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"}
|
headers = {"x-api-key": os.environ.get("MINIMAX_API_KEY", ""), "anthropic-version": "2023-06-01"}
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout, connect=30)) as client:
|
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
|
VERIFY_PANEL = 2 # unabhängige Prüfer, einstimmig = verifiziert
|
||||||
|
|
||||||
# ── Ebene 3: Struktur ─────────────────────────────────────────────────────────
|
# ── 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_MIN_ATOME = 4
|
||||||
BAUSTEIN_MAX_ATOME = 8
|
BAUSTEIN_MAX_ATOME = 8
|
||||||
ZIELE_CHUNK_ATOME = 30 # Atome pro Lernziel-Call
|
ZIELE_CHUNK_ATOME = 30 # Atome pro Lernziel-Call
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ CREATE TABLE IF NOT EXISTS quellen(
|
|||||||
CREATE TABLE IF NOT EXISTS soll(
|
CREATE TABLE IF NOT EXISTS soll(
|
||||||
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, punkt TEXT NOT NULL,
|
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, punkt TEXT NOT NULL,
|
||||||
status TEXT NOT NULL DEFAULT 'kandidat', belege TEXT NOT NULL DEFAULT '[]',
|
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(
|
CREATE TABLE IF NOT EXISTS atome(
|
||||||
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, titel TEXT NOT NULL, typ TEXT NOT NULL DEFAULT '',
|
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',
|
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');
|
titel TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'neu');
|
||||||
CREATE TABLE IF NOT EXISTS bausteine(
|
CREATE TABLE IF NOT EXISTS bausteine(
|
||||||
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, ziel_id INTEGER NOT NULL, titel TEXT NOT NULL,
|
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(
|
CREATE TABLE IF NOT EXISTS kapitel(
|
||||||
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, titel TEXT NOT NULL,
|
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, titel TEXT NOT NULL,
|
||||||
intro TEXT NOT NULL DEFAULT '', ord INTEGER DEFAULT 0,
|
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(
|
CREATE TABLE IF NOT EXISTS sections(
|
||||||
baustein_id INTEGER PRIMARY KEY, stage TEXT NOT NULL DEFAULT 'writer',
|
baustein_id INTEGER PRIMARY KEY, stage TEXT NOT NULL DEFAULT 'writer',
|
||||||
text_kompakt TEXT DEFAULT '', text_lang TEXT DEFAULT '', befunde TEXT DEFAULT '[]',
|
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 auto TEXT NOT NULL DEFAULT '{}'",
|
||||||
"ALTER TABLE topics ADD COLUMN resets TEXT NOT NULL DEFAULT '{}'",
|
"ALTER TABLE topics ADD COLUMN resets TEXT NOT NULL DEFAULT '{}'",
|
||||||
"ALTER TABLE bausteine ADD COLUMN kapitel_id INTEGER",
|
"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:
|
try:
|
||||||
con.execute(zusatz)
|
con.execute(zusatz)
|
||||||
except sqlite3.OperationalError:
|
except sqlite3.OperationalError:
|
||||||
|
|||||||
@@ -75,10 +75,13 @@ def _soll_konsens(prompt: str):
|
|||||||
|
|
||||||
def _soll_beleg(prompt: str):
|
def _soll_beleg(prompt: str):
|
||||||
text = _text_nach(prompt, "QUELLTEXT:")
|
text = _text_nach(prompt, "QUELLTEXT:")
|
||||||
for _, saetze in PUNKTE:
|
zeilen = re.findall(r"^(\d+): (.+)$", _text_nach(prompt, "SOLL-PUNKTE:"), re.MULTILINE)
|
||||||
if saetze[0] in text and saetze[0] in prompt:
|
out = []
|
||||||
return {"zitat": saetze[0]}
|
for i, punkt in zeilen:
|
||||||
return {"zitat": ""}
|
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):
|
def _extraktion(prompt: str):
|
||||||
@@ -116,14 +119,6 @@ def _soll_stich(prompt: str):
|
|||||||
return {"soll": next(iter(punkte.values()), "fremd")}
|
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):
|
def _anker_fix_batch(prompt: str):
|
||||||
text = _text_nach(prompt, "QUELLTEXT:")
|
text = _text_nach(prompt, "QUELLTEXT:")
|
||||||
zeilen = re.findall(r"^(\d+): (.+?) —", _text_nach(prompt, "ATOME:"), re.MULTILINE)
|
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):
|
def _level_kalibrierung(prompt: str):
|
||||||
ids = _ids(_text_nach(prompt, "ATOME:"))
|
# deklariertes Level der Fake-Welt zurückgeben (Titel-Match) — der E2E-Pfad
|
||||||
# deterministisch: erstes Atom wird Grundgerüst, Rest Standardstoff
|
# erzeugt so echte E/M/S-Durchgänge; Unbekanntes wird Standardstoff
|
||||||
return [{"atom": i, "level": "E" if n == 0 else "M"} for n, i in enumerate(ids)]
|
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):
|
def _kapitel_schnitt(prompt: str):
|
||||||
@@ -196,7 +198,7 @@ def _zyklus(prompt: str):
|
|||||||
|
|
||||||
def _writer(prompt: str):
|
def _writer(prompt: str):
|
||||||
ziel = re.search(r"Lernziel: (.+)", prompt)
|
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)
|
prompt)
|
||||||
defs = re.findall(r"Definition: (.+)", prompt)
|
defs = re.findall(r"Definition: (.+)", prompt)
|
||||||
fragen = re.findall(r"^- (.+\?)$", prompt, re.MULTILINE)[:2]
|
fragen = re.findall(r"^- (.+\?)$", prompt, re.MULTILINE)[:2]
|
||||||
@@ -240,8 +242,9 @@ _HANDLER = {
|
|||||||
"Atom-Extraktion": _extraktion, "Atom-Extraktion-Aufgaben": _extraktion,
|
"Atom-Extraktion": _extraktion, "Atom-Extraktion-Aufgaben": _extraktion,
|
||||||
"Atom-Merge": _merge,
|
"Atom-Merge": _merge,
|
||||||
"Atom-Soll-Zuordnung": _soll_zuordnung, "Atom-Soll-Stich": _soll_stich,
|
"Atom-Soll-Zuordnung": _soll_zuordnung, "Atom-Soll-Stich": _soll_stich,
|
||||||
"Atom-Anker-Fix": _anker_fix, "Atom-Anker-Fix-Batch": _anker_fix_batch,
|
"Atom-Anker-Fix-Batch": _anker_fix_batch,
|
||||||
"Atom-Luecke": _luecke, "Soll-Abgedeckt": lambda p: {"abgedeckt": True},
|
"Atom-Luecke": _luecke, "Atom-Titel-Fix": _titel_fix,
|
||||||
|
"Soll-Abgedeckt": lambda p: {"abgedeckt": True},
|
||||||
"Artefakt-Generate": _artefakt_generate, "Artefakt-Verify": _artefakt_verify,
|
"Artefakt-Generate": _artefakt_generate, "Artefakt-Verify": _artefakt_verify,
|
||||||
"Artefakt-Fix": _artefakt_fix, "Lernziele": _lernziele, "Kapitel-Intro": _kapitel_intro,
|
"Artefakt-Fix": _artefakt_fix, "Lernziele": _lernziele, "Kapitel-Intro": _kapitel_intro,
|
||||||
"Kapitel-Schnitt": _kapitel_schnitt, "Level-Kalibrierung": _level_kalibrierung,
|
"Kapitel-Schnitt": _kapitel_schnitt, "Level-Kalibrierung": _level_kalibrierung,
|
||||||
|
|||||||
206
backend/guide.py
206
backend/guide.py
@@ -17,7 +17,7 @@ from pathlib import Path
|
|||||||
import db
|
import db
|
||||||
import llm
|
import llm
|
||||||
import textkit
|
import textkit
|
||||||
from config import SECTION_WOERTER_PRO_ATOM
|
from config import DURCHGANG, LEVEL_RANG, SECTION_WOERTER_PRO_ATOM
|
||||||
|
|
||||||
log = logging.getLogger("creator2.guide")
|
log = logging.getLogger("creator2.guide")
|
||||||
|
|
||||||
@@ -50,10 +50,11 @@ def _katex_fehler(formeln: list[dict]) -> list[str]:
|
|||||||
|
|
||||||
EBENE = "guide"
|
EBENE = "guide"
|
||||||
_MARKER = re.compile(r"<!--\s*atom:\s*(\d+)\s*\|")
|
_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
|
# 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)
|
# (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]:
|
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:
|
def _atom_paket(a: dict) -> str:
|
||||||
zitate = "\n".join(f"> {z}" for z in _zitate(a["id"])) or "(kein Zitat)"
|
zitate = "\n".join(f"> {z}" for z in _zitate(a["id"])) or "(kein Zitat)"
|
||||||
beispiel = _beispiel(a["id"])
|
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}")
|
f"Definition: {a['definition']}\nBelege (VERBATIM zitierbar):\n{zitate}")
|
||||||
if beispiel:
|
if beispiel:
|
||||||
teil += f"\nVerifiziertes Beispiel (Werte wörtlich übernehmen):\n{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
|
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 ────────────────────────────────────────────────────────────────────
|
# ── Stages ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def _stage_writer(ctx: llm.Kontext, b: dict) -> str:
|
async def _stage_writer(ctx: llm.Kontext, b: dict) -> str:
|
||||||
atome = _atome_von(b["id"])
|
atome = _atome_von(b["id"])
|
||||||
ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],)) or {"text": b["titel"]}
|
ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],)) or {"text": b["titel"]}
|
||||||
lo, hi = _laenge_band(len(atome))
|
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),
|
||||||
"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)
|
role="guide", n=len(atome), item=f"b{b['id']}", erwartet=dict)
|
||||||
if res is None:
|
if res is None:
|
||||||
return "writer" # Stage bleibt, nächster Lauf versucht erneut
|
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
|
if fehlend: # ein gezielter Zweitversuch mit explizitem Mangel
|
||||||
res2 = await llm.call(ctx, stage="writer2", template="Guide-Writer",
|
res2 = await llm.call(ctx, stage="writer2", template="Guide-Writer",
|
||||||
schritt="writer", role="guide", n=len(atome),
|
schritt="writer", role="guide", n=len(atome),
|
||||||
item=f"b{b['id']}-2",
|
item=f"b{b['id']}-2", werte=werte, erwartet=dict)
|
||||||
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)
|
|
||||||
if res2 and not _marker_fehlend(str(res2.get("lang", "")), atome):
|
if res2 and not _marker_fehlend(str(res2.get("lang", "")), atome):
|
||||||
kompakt, lang = str(res2.get("kompakt", "")), str(res2.get("lang", ""))
|
kompakt, lang = str(res2.get("kompakt", "")), str(res2.get("lang", ""))
|
||||||
db.update("sections", "baustein_id", b["id"], text_kompakt=kompakt, text_lang=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]
|
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]:
|
def _mathe_auftraege(text: str, wo: str) -> list[str]:
|
||||||
"""Deterministische Mathe-Hygiene je Fassung: $-Parität, nackte LaTeX-Befehle,
|
"""Deterministische Mathe-Hygiene je Fassung: $-Parität, nackte LaTeX-Befehle,
|
||||||
Klartext-Formelreste (x_i, u_{3m}, 2^(…) — aak Lauf 19: 8 Kompakt-/6
|
Klartext-Formelreste (x_i, u_{3m}, 2^(…) — aak Lauf 19: 8 Kompakt-/6
|
||||||
@@ -182,21 +243,30 @@ def _mathe_auftraege(text: str, wo: str) -> list[str]:
|
|||||||
return auftraege
|
return auftraege
|
||||||
|
|
||||||
|
|
||||||
def _det_auftraege(topic: str, b: dict, lang: str, kompakt: str = "") -> list[str]:
|
def _det_auftraege(topic: str, b: dict, lang: str, kompakt: str = "",
|
||||||
"""Deterministische Checks vor dem Judge: Marker, Länge, Vorwärtsverweise."""
|
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"])
|
atome = _atome_von(b["id"])
|
||||||
auftraege = [f"KRITISCH: Marker für Atom {i} fehlt — exakt einfügen."
|
auftraege = []
|
||||||
for i in _marker_fehlend(lang, atome)]
|
if not qa:
|
||||||
lo, hi = _laenge_band(len(atome))
|
auftraege += [f"KRITISCH: Marker für Atom {i} fehlt — exakt einfügen."
|
||||||
woerter = len(lang.split())
|
for i in _marker_fehlend(lang, atome)]
|
||||||
if woerter > hi:
|
auftraege += [f"KRITISCH: Marker {i} gehört nicht zu diesem Baustein"
|
||||||
auftraege.append(f"Kürzen auf höchstens {hi} Wörter (aktuell {woerter}).")
|
f" (Kontext-Atom?) — Marker-Zeile ersatzlos entfernen."
|
||||||
elif woerter < lo:
|
for i in _marker_fremd_ids(lang, atome)]
|
||||||
auftraege.append(f"Ausbauen auf mindestens {lo} Wörter (aktuell {woerter}).")
|
lo, hi = _laenge_band(len(atome))
|
||||||
auftraege += [f"Vorwärtsverweis „{t}“ (kommt erst in einem späteren Kapitel): beim"
|
woerter = len(lang.split())
|
||||||
f" ersten Auftreten mit einem Halbsatz einordnen („… dazu später mehr“)"
|
if woerter > hi:
|
||||||
f" oder entfernen — nie unerklärt verwenden."
|
auftraege.append(f"Kürzen auf höchstens {hi} Wörter (aktuell {woerter}).")
|
||||||
for t in _vorwaertsverweise(topic, b, lang)]
|
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):"
|
||||||
|
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(">"))
|
zitate = sum(1 for z in lang.splitlines() if z.lstrip().startswith(">"))
|
||||||
if zitate: # Lehren statt Abschreiben: Rohzitate gehören nicht in den Lehrtext
|
if zitate: # Lehren statt Abschreiben: Rohzitate gehören nicht in den Lehrtext
|
||||||
auftraege.append(f"{zitate} Blockquote-Zeile(n) („>“) im Text: Inhalt in eigenen"
|
auftraege.append(f"{zitate} Blockquote-Zeile(n) („>“) im Text: Inhalt in eigenen"
|
||||||
@@ -204,21 +274,49 @@ def _det_auftraege(topic: str, b: dict, lang: str, kompakt: str = "") -> list[st
|
|||||||
if "6=" in lang:
|
if "6=" in lang:
|
||||||
auftraege.append("PDF-Artefakt „6=“ im Text: gemeint ist Ungleichheit —"
|
auftraege.append("PDF-Artefakt „6=“ im Text: gemeint ist Ungleichheit —"
|
||||||
" durch $\\neq$ ersetzen.")
|
" 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 += _mathe_auftraege(lang, "Langtext")
|
||||||
|
auftraege += _stil_auftraege(lang, "Langtext")
|
||||||
if kompakt:
|
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 += _mathe_auftraege(kompakt, "Kompakt-Fassung")
|
||||||
|
auftraege += _stil_auftraege(kompakt, "Kompakt-Fassung")
|
||||||
return auftraege
|
return auftraege
|
||||||
|
|
||||||
|
|
||||||
def _vorwaertsverweise(topic: str, b: dict, lang: str) -> list[str]:
|
def _vorwaertsverweise(topic: str, b: dict, lang: str) -> list[str]:
|
||||||
"""Titel von Atomen SPÄTERER KAPITEL im Text (innerhalb eines Kapitels sind
|
"""Titel von Atomen SPÄTERER KAPITEL im Text (innerhalb eines Kapitels sind
|
||||||
Verweise normal — nur Kapitel-Sprünge stören den Lesefluss). Nur signifikante
|
Verweise normal — nur Kapitel-Sprünge stören den Lesefluss). Die Kapitel-ord
|
||||||
Titel (≥2 Tokens oder ≥6 Zeichen — Lektion 35), Ganzwort, ohne Marker-Zeilen."""
|
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
|
kap_ord = {k["id"]: k["ord"] for k in
|
||||||
db.query("SELECT * FROM kapitel WHERE topic=?", (topic,))}
|
db.query("SELECT * FROM kapitel WHERE topic=?", (topic,))}
|
||||||
je_baustein = {bb["id"]: kap_ord.get(bb["kapitel_id"], 0) for bb in _bausteine(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)
|
mein_kapitel = je_baustein.get(b["id"], 0)
|
||||||
text = textkit.norm(_MARKER.sub("", lang))
|
text = textkit.norm(_MARKER_VOLL.sub("", lang))
|
||||||
treffer = []
|
treffer = []
|
||||||
for a in db.query(
|
for a in db.query(
|
||||||
"SELECT a.titel, bb.id AS bid FROM atome a JOIN bausteine bb ON a.baustein_id=bb.id"
|
"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
|
if kritisch: # genau EIN Re-Check; Rest fängt die Ebenen-QA
|
||||||
await _stage_pruefer(ctx, b, tag="-re")
|
await _stage_pruefer(ctx, b, tag="-re")
|
||||||
return "done"
|
return "done"
|
||||||
db.update("sections", "baustein_id", b["id"], befunde=db.j([]))
|
# Fix fehlgeschlagen → Original behalten, aber die Aufträge NICHT löschen:
|
||||||
return "done" # Fix fehlgeschlagen → Original behalten (im Zweifel behalten)
|
# 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}
|
_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', '')}"
|
liste = "\n".join(f"- {b['titel']}: {ziele.get(b['ziel_id'], {}).get('text', '')}"
|
||||||
for b in bausteine)
|
for b in bausteine)
|
||||||
res = await llm.call(ctx, stage="kapitel", template="Kapitel-Intro",
|
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']}",
|
role="judge", n=len(bausteine), item=f"k{kap['id']}",
|
||||||
erwartet=dict)
|
erwartet=dict)
|
||||||
intro = str((res or {}).get("intro", "")).strip()
|
intro = str((res or {}).get("intro", "")).strip()
|
||||||
@@ -333,6 +435,22 @@ async def bauen(ctx: llm.Kontext) -> None:
|
|||||||
await _kapitel_intros(ctx)
|
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]:
|
def kapitel_struktur(topic: str) -> list[dict]:
|
||||||
"""Kapitel (Struktur-Ebene) entlang der Baustein-Ordnung; die Segmente sind
|
"""Kapitel (Struktur-Ebene) entlang der Baustein-Ordnung; die Segmente sind
|
||||||
kontiguierlich, also wechselt H2 genau an den Kapitel-Grenzen."""
|
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:
|
if not kapitel or kapitel[-1]["kapitel_id"] != k_id:
|
||||||
k = kaps.get(k_id, {})
|
k = kaps.get(k_id, {})
|
||||||
kapitel.append({"kapitel_id": k_id, "titel": k.get("titel", "Weitere Themen"),
|
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({
|
kapitel[-1]["sections"].append({
|
||||||
"baustein": b["id"], "titel": b["titel"],
|
"baustein": b["id"], "titel": b["titel"],
|
||||||
"ziel": ziele.get(b["ziel_id"], {}).get("text", ""),
|
"ziel": ziele.get(b["ziel_id"], {}).get("text", ""),
|
||||||
"kompakt": sec["text_kompakt"] if sec else "",
|
"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
|
return kapitel
|
||||||
|
|
||||||
|
|
||||||
@@ -388,6 +509,12 @@ def messen(ctx: llm.Kontext) -> list[dict]:
|
|||||||
for i in _marker_fehlend(lang, atome):
|
for i in _marker_fehlend(lang, atome):
|
||||||
befunde.append({"art": "marker_fehlend", "item": str(b["id"]),
|
befunde.append({"art": "marker_fehlend", "item": str(b["id"]),
|
||||||
"detail": f"Atom {i}"})
|
"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"],))
|
ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],))
|
||||||
if ziel:
|
if ziel:
|
||||||
noetig = _stopfrei(ziel["text"])
|
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);
|
"""Kritische Befunde → zurück auf pruefer (voller Kontext + Re-Check);
|
||||||
Stil-Befunde → direkt in den Fix (genau ein Rewrite)."""
|
Stil-Befunde → direkt in den Fix (genau ein Rewrite)."""
|
||||||
ctx.ebene = EBENE
|
ctx.ebene = EBENE
|
||||||
betroffen: dict[int, list[str]] = {}
|
betroffen: dict[int, list[dict]] = {}
|
||||||
for b in befunde:
|
for b in befunde:
|
||||||
try:
|
try:
|
||||||
b_id = int(b["item"])
|
b_id = int(b["item"])
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
continue
|
continue
|
||||||
betroffen.setdefault(b_id, []).append(b["art"])
|
betroffen.setdefault(b_id, []).append(b)
|
||||||
for b_id, arten in betroffen.items():
|
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):
|
if any(a in KRITISCH or a == "section_fehlt" for a in arten):
|
||||||
stage = "writer" if "section_fehlt" in arten else "pruefer"
|
stage = "writer" if "section_fehlt" in arten else "pruefer"
|
||||||
db.execute("UPDATE sections SET stage=? WHERE baustein_id=?", (stage, b_id))
|
db.execute("UPDATE sections SET stage=? WHERE baustein_id=?", (stage, b_id))
|
||||||
else:
|
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("sections", "baustein_id", b_id, stage="fix", befunde=db.j(auftraege))
|
||||||
db.update("bausteine", "id", b_id, status="repair")
|
db.update("bausteine", "id", b_id, status="repair")
|
||||||
if betroffen:
|
if betroffen:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ Danach Soll-Zuordnung je Atom; Soll-Punkte ohne Atom lösen gezielte Nachextrakt
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
import db
|
import db
|
||||||
import embedding
|
import embedding
|
||||||
@@ -22,7 +23,7 @@ log = logging.getLogger("creator2.inventar")
|
|||||||
EBENE = "inventar"
|
EBENE = "inventar"
|
||||||
TYPEN = ("begriff", "aussage", "verfahren")
|
TYPEN = ("begriff", "aussage", "verfahren")
|
||||||
LEVELS = ("E", "M", "S")
|
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]:
|
def aktive_atome(topic: str) -> list[dict]:
|
||||||
@@ -32,25 +33,39 @@ def aktive_atome(topic: str) -> list[dict]:
|
|||||||
|
|
||||||
# ── Extraktion ────────────────────────────────────────────────────────────────
|
# ── 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),
|
# 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(
|
schon = db.one(
|
||||||
"SELECT a.id FROM atome a JOIN anker k ON k.atom_id=a.id"
|
"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')"
|
" WHERE a.topic=? AND k.quelle_id=? AND a.status NOT IN ('gemerged','verworfen')"
|
||||||
" LIMIT 1", (ctx.topic, quelle["id"]))
|
" LIMIT 1", (ctx.topic, quelle["id"]))
|
||||||
if schon:
|
if schon and not force:
|
||||||
db.update("quellen", "id", quelle["id"], status="atome")
|
db.update("quellen", "id", quelle["id"], status="atome")
|
||||||
return
|
return
|
||||||
text = korpus.quelltext(quelle)
|
text = korpus.quelltext(quelle)
|
||||||
# aufgaben-Quellen: das GEÜBTE Konzept destillieren, nie die Aufgabeninstanz
|
# aufgaben-Quellen: das GEÜBTE Konzept destillieren, nie die Aufgabeninstanz
|
||||||
template = ("Atom-Extraktion-Aufgaben" if quelle.get("rolle") == "aufgaben"
|
template = ("Atom-Extraktion-Aufgaben" if quelle.get("rolle") == "aufgaben"
|
||||||
else "Atom-Extraktion")
|
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,
|
res = await llm.call(ctx, stage="extraktion", template=template,
|
||||||
werte={"topic": ctx.topic, "quelle": quelle["titel"], "text": chunk},
|
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 []:
|
for a in res or []:
|
||||||
titel = str(a.get("titel", "")).strip()
|
titel = str(a.get("titel", "")).strip()
|
||||||
definition = str(a.get("definition", "")).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)
|
await asyncio.gather(*(reader(off, chunk, r)
|
||||||
for off, chunk in stuecke
|
for off, chunk in stuecke
|
||||||
for r in range(READER_JE_ABSCHNITT)))
|
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 ─────────────────────────────────────────────────────────────────────
|
# ── Dedup ─────────────────────────────────────────────────────────────────────
|
||||||
@@ -202,7 +219,11 @@ async def _judge_dedup(ctx: llm.Kontext) -> None:
|
|||||||
if isinstance(e, dict) and e.get("paar") == n:
|
if isinstance(e, dict) and e.get("paar") == n:
|
||||||
urteile.append(bool(e.get("gleich")))
|
urteile.append(bool(e.get("gleich")))
|
||||||
break
|
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"])
|
gew, ver = ((a["id"], b["id"]) if len(a["definition"]) >= len(b["definition"])
|
||||||
else (b["id"], a["id"]))
|
else (b["id"], a["id"]))
|
||||||
_merge(topic, gew, ver)
|
_merge(topic, gew, ver)
|
||||||
@@ -221,12 +242,20 @@ def _erster_anker(atom_id: int) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _kanten_aufloesen(topic: str) -> None:
|
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)
|
atome = aktive_atome(topic)
|
||||||
je_norm = {textkit.norm(a["titel"]): a["id"] for a in atome}
|
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 a in atome:
|
||||||
for titel in db.uj(a["braucht"]):
|
for titel in db.uj(a["braucht"]):
|
||||||
ziel = je_norm.get(textkit.norm(titel))
|
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"]:
|
if ziel and ziel != a["id"]:
|
||||||
db.execute("INSERT OR IGNORE INTO kanten(topic, von_atom, zu_atom, art)"
|
db.execute("INSERT OR IGNORE INTO kanten(topic, von_atom, zu_atom, art)"
|
||||||
" VALUES(?,?,?,'braucht')", (topic, a["id"], ziel))
|
" VALUES(?,?,?,'braucht')", (topic, a["id"], ziel))
|
||||||
@@ -269,6 +298,20 @@ async def bauen(ctx: llm.Kontext) -> None:
|
|||||||
|
|
||||||
# ── QA + Repair ───────────────────────────────────────────────────────────────
|
# ── 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]:
|
def messen(ctx: llm.Kontext) -> list[dict]:
|
||||||
befunde = []
|
befunde = []
|
||||||
atome = aktive_atome(ctx.topic)
|
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"]})
|
befunde.append({"art": "atom_ohne_soll", "item": str(a["id"]), "detail": a["titel"]})
|
||||||
else:
|
else:
|
||||||
belegte_soll.add(a["soll_id"])
|
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,)):
|
for p in db.query("SELECT * FROM soll WHERE topic=? AND status='bestaetigt'", (ctx.topic,)):
|
||||||
if p["id"] not in belegte_soll:
|
if p["id"] not in belegte_soll:
|
||||||
befunde.append({"art": "soll_ohne_atom", "item": str(p["id"]), "detail": p["punkt"]})
|
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
|
return befunde
|
||||||
|
|
||||||
|
|
||||||
@@ -299,6 +348,11 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
|
|||||||
return await _soll_stichentscheid(ctx, item)
|
return await _soll_stichentscheid(ctx, item)
|
||||||
if b["art"] == "soll_ohne_atom":
|
if b["art"] == "soll_ohne_atom":
|
||||||
return await _luecke_schliessen(ctx, item)
|
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
|
return False
|
||||||
|
|
||||||
# Anker-Fixes gebündelt (Batch je Quelle statt Atom×Quelle — war 83 % der
|
# 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
|
anker_ids = [int(b["item"]) for b in befunde
|
||||||
if b["art"] == "atom_ohne_anker" and str(b["item"]).isdigit()]
|
if b["art"] == "atom_ohne_anker" and str(b["item"]).isdigit()]
|
||||||
bewegt = await _anker_fixen_batch(ctx, anker_ids) if anker_ids else False
|
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))
|
bewegt |= any(await llm.alle(einer(b) for b in rest))
|
||||||
if bewegt:
|
if bewegt:
|
||||||
_anker_dedup(ctx.topic)
|
_anker_dedup(ctx.topic)
|
||||||
@@ -316,6 +374,27 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
|
|||||||
return bewegt
|
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)
|
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()
|
inhalt = str(q.get("inhalt", "")).strip()
|
||||||
if len(inhalt) < 500: # leere/dünne Funde sind keine Quelle
|
if len(inhalt) < 500: # leere/dünne Funde sind keine Quelle
|
||||||
continue
|
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)
|
snap = _snapshot_schreiben(ctx.topic, inhalt)
|
||||||
if snap is None:
|
if snap is None:
|
||||||
continue
|
continue
|
||||||
titel = str(q.get("titel", ""))[:200]
|
titel = str(q.get("titel", ""))[:200]
|
||||||
db.insert("quellen", topic=ctx.topic, art="web", titel=titel,
|
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))
|
runde=runde, status="neu", rolle=_rolle(titel, lens))
|
||||||
neu += 1
|
neu += 1
|
||||||
return neu
|
return neu
|
||||||
@@ -331,38 +338,76 @@ def messen(ctx: llm.Kontext) -> list[dict]:
|
|||||||
return befunde
|
return befunde
|
||||||
|
|
||||||
|
|
||||||
async def _beleg_nachsuchen(ctx: llm.Kontext, soll_id: int) -> bool:
|
async def _belege_nachsuchen(ctx: llm.Kontext, soll_ids: list[int]) -> bool:
|
||||||
"""Gezielter Beleg-Judge in noch nicht zitierten Quellen; ein bestätigter
|
"""Gezielte Beleg-Judges, gebündelt: EIN Call je Quelle mit ALLEN dort noch
|
||||||
Punkt ohne Fund wird zum Kandidaten zurückgestuft (Konsens-Regel bleibt
|
offenen Punkten — Punkt×Quelle einzeln waren 3072 Calls / 27 % der
|
||||||
hart). → True nur, wenn sich etwas geändert hat."""
|
Themen-Tokens (Lernen). Negativ-Cache soll.geprueft: erfolglos geprüfte
|
||||||
p = db.one("SELECT * FROM soll WHERE id=?", (soll_id,))
|
(Punkt, Quelle)-Paare werden über Repair-Iterationen nie wiederholt.
|
||||||
if not p:
|
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
|
return False
|
||||||
belegte = {bl["quelle"] for bl in db.uj(p["belege"])}
|
quellen = db.query("SELECT * FROM quellen WHERE topic=?", (ctx.topic,))
|
||||||
andere = [q for q in db.query("SELECT * FROM quellen WHERE topic=?", (ctx.topic,))
|
bewegt = False
|
||||||
if q["id"] not in belegte]
|
|
||||||
for q in andere:
|
async def eine_quelle(q: dict) -> None:
|
||||||
|
nonlocal bewegt
|
||||||
|
faellig = []
|
||||||
|
for p in punkte:
|
||||||
|
belegte = {bl["quelle"] for bl in db.uj(p["belege"])}
|
||||||
|
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",
|
res = await llm.call(ctx, stage="soll_beleg", template="Korpus-Soll-Beleg",
|
||||||
schritt="soll", role="judge", item=f"s{p['id']}-q{q['id']}",
|
schritt="soll", role="judge", item=f"q{q['id']}",
|
||||||
werte={"punkt": p["punkt"],
|
n=len(faellig),
|
||||||
"text": quelltext(q)[:SOLL_CHUNK_CHARS]},
|
werte={"punkte": liste, "text": text[:SOLL_CHUNK_CHARS]},
|
||||||
erwartet=dict)
|
erwartet=list)
|
||||||
zitat = str((res or {}).get("zitat", "")).strip()
|
if res is None:
|
||||||
if zitat and textkit.finde_zitat(quelltext(q), zitat) is not None:
|
return # Ausfall: nicht als „geprüft" verbuchen
|
||||||
belege = db.uj(p["belege"]) + [{"quelle": q["id"], "zitat": zitat}]
|
gefunden: dict[int, str] = {}
|
||||||
db.update("soll", "id", p["id"], belege=db.j(belege))
|
for e in res:
|
||||||
return True
|
if not isinstance(e, dict):
|
||||||
if p["status"] == "bestaetigt":
|
continue
|
||||||
db.update("soll", "id", p["id"], status="kandidat")
|
zitat = str(e.get("zitat", "")).strip()
|
||||||
return True
|
if zitat and e.get("soll") in {p["id"] for p in faellig} \
|
||||||
return False
|
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))
|
||||||
|
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")
|
||||||
|
bewegt = True
|
||||||
|
return bewegt
|
||||||
|
|
||||||
|
|
||||||
async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
|
async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
|
||||||
ctx.ebene = EBENE
|
ctx.ebene = EBENE
|
||||||
punkte = [int(b["item"]) for b in befunde if b["art"] == "soll_wenig_belege"]
|
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"]
|
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
|
if offene: # neu falten: die Nachrunden im Konsens erzwingen die Zuordnung
|
||||||
await _konsens(ctx)
|
await _konsens(ctx)
|
||||||
noch = db.query("SELECT id FROM soll WHERE topic=? AND status='kandidat'",
|
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
|
versuch += 1
|
||||||
key = f"{ctx.topic}-{ctx.ebene}-{stage}-{item or 'x'}-{versuch}"
|
key = f"{ctx.topic}-{ctx.ebene}-{stage}-{item or 'x'}-{versuch}"
|
||||||
start = time.monotonic()
|
start = time.monotonic()
|
||||||
status, tokens, err, wait_ms = "error", None, "", 0
|
status, tokens, err, wait_ms, model = "error", None, "", 0, ""
|
||||||
try:
|
try:
|
||||||
res = await _roher_call(key, prompt, timeout, ctx, role, caps)
|
res = await _roher_call(key, prompt, timeout, ctx, role, caps)
|
||||||
tokens, err = res.tokens, res.err
|
tokens, err = res.tokens, res.err
|
||||||
wait_ms = int(res.wait_s * 1000)
|
wait_ms = int(res.wait_s * 1000)
|
||||||
|
model = res.model
|
||||||
if res.ok:
|
if res.ok:
|
||||||
if erwartet is str:
|
if erwartet is str:
|
||||||
status = "ok"
|
status = "ok"
|
||||||
@@ -112,7 +113,7 @@ async def call(ctx: Kontext, *, stage: str, template: str, werte: dict,
|
|||||||
finally:
|
finally:
|
||||||
ledger.log_call(ctx.run_id, ebene=ctx.ebene, stage=stage, item=item,
|
ledger.log_call(ctx.run_id, ebene=ctx.ebene, stage=stage, item=item,
|
||||||
template=template, template_hash=thash, role=role,
|
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),
|
dur_ms=int((time.monotonic() - start) * 1000),
|
||||||
wait_ms=wait_ms,
|
wait_ms=wait_ms,
|
||||||
tokens=tokens, meta={"err": err[:300]} if err else None)
|
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_anker": 3.0, "atom_ohne_soll": 1.5, "soll_ohne_atom": 3.0,
|
||||||
"atom_ohne_flashcard": 1.5, "artefakt_unentschieden": 0.5,
|
"atom_ohne_flashcard": 1.5, "artefakt_unentschieden": 0.5,
|
||||||
"partition": 3.0, "band": 0.5, "vorwaerts_kante": 1.5, "zyklus": 3.0,
|
"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,
|
"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,
|
"kapitel_zerstueckelt": 1.5, "titel_katalog": 1.5, "quelle_unvollstaendig": 3.0,
|
||||||
"fachlich_falsch": 3.0, "laenge": 0.5, "vorwaerts": 0.5,
|
"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 db
|
||||||
import llm
|
import llm
|
||||||
from config import (BAUSTEIN_MAX_ATOME, BAUSTEIN_MIN_ATOME, KAPITEL_BAUSTEINE,
|
from config import (BAUSTEIN_MAX_ATOME, BAUSTEIN_MIN_ATOME, DURCHGANG,
|
||||||
ZIELE_CHUNK_ATOME)
|
KAPITEL_BAUSTEINE, LEVEL_RANG, ZIELE_CHUNK_ATOME)
|
||||||
|
|
||||||
log = logging.getLogger("creator2.struktur")
|
log = logging.getLogger("creator2.struktur")
|
||||||
|
|
||||||
@@ -186,8 +186,9 @@ def _anker_rang(topic: str) -> dict[int, tuple]:
|
|||||||
|
|
||||||
def _bausteine_schneiden(ctx: llm.Kontext) -> None:
|
def _bausteine_schneiden(ctx: llm.Kontext) -> None:
|
||||||
"""Deterministisch, idempotent: alte Zuordnung verwerfen, neu schneiden.
|
"""Deterministisch, idempotent: alte Zuordnung verwerfen, neu schneiden.
|
||||||
Ziel-Gruppen ins Band bringen — kleine Ziele desselben Soll-Punkts mergen
|
Gruppen sind (Ziel, Level) — je Level entstehen GETRENNTE Bausteine
|
||||||
(das Atom wechselt sein Ziel, Baustein bleibt EIN Ziel), große entlang der
|
(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")."""
|
Topo-Reihenfolge splitten ("Teil n")."""
|
||||||
topic = ctx.topic
|
topic = ctx.topic
|
||||||
db.execute("DELETE FROM bausteine WHERE topic=?", (topic,))
|
db.execute("DELETE FROM bausteine WHERE topic=?", (topic,))
|
||||||
@@ -197,12 +198,13 @@ def _bausteine_schneiden(ctx: llm.Kontext) -> None:
|
|||||||
|
|
||||||
gruppen: dict = defaultdict(list)
|
gruppen: dict = defaultdict(list)
|
||||||
for a in atome:
|
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
|
# kleine Gruppen mergen (kleinste zuerst), nur innerhalb des Levels.
|
||||||
# Soll-Punkt — das Soll ist seit der Entkopplung reine Checkliste (152 feine
|
# Partnerwahl thematisch statt per Soll-Punkt — das Soll ist seit der
|
||||||
# Punkte hätten sonst keine Merge-Partner): meiste braucht-Kanten zwischen
|
# Entkopplung reine Checkliste (152 feine Punkte hätten sonst keine
|
||||||
# den Gruppen, sonst Nachbar in der Quellreihenfolge.
|
# Merge-Partner): meiste braucht-Kanten zwischen den Gruppen, sonst
|
||||||
|
# Nachbar in der Quellreihenfolge.
|
||||||
def kanten_score(g1: list[dict], g2: list[dict]) -> int:
|
def kanten_score(g1: list[dict], g2: list[dict]) -> int:
|
||||||
ids1 = {a["id"] for a in g1}
|
ids1 = {a["id"] for a in g1}
|
||||||
ids2 = {a["id"] for a in g2}
|
ids2 = {a["id"] for a in g2}
|
||||||
@@ -215,32 +217,34 @@ def _bausteine_schneiden(ctx: llm.Kontext) -> None:
|
|||||||
geaendert = True
|
geaendert = True
|
||||||
while geaendert:
|
while geaendert:
|
||||||
geaendert = False
|
geaendert = False
|
||||||
kleine = sorted([z for z, g in gruppen.items() if len(g) < BAUSTEIN_MIN_ATOME],
|
kleine = sorted([k for k, g in gruppen.items() if len(g) < BAUSTEIN_MIN_ATOME],
|
||||||
key=lambda z: len(gruppen[z]))
|
key=lambda k: len(gruppen[k]))
|
||||||
for z in kleine:
|
for k in kleine:
|
||||||
passende = [p for p in gruppen if p != z
|
passende = [p for p in gruppen if p != k and p[1] == k[1]
|
||||||
and len(gruppen[p]) + len(gruppen[z]) <= BAUSTEIN_MAX_ATOME]
|
and len(gruppen[p]) + len(gruppen[k]) <= BAUSTEIN_MAX_ATOME]
|
||||||
if not passende:
|
if not passende:
|
||||||
continue
|
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])
|
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])),
|
(abs(r[0] - eigener[0]), abs(r[1] - eigener[1])),
|
||||||
len(gruppen[pp]))
|
len(gruppen[pp]))
|
||||||
|
|
||||||
p = min(passende, key=naehe)
|
p = min(passende, key=naehe)
|
||||||
for a in gruppen[z]:
|
for a in gruppen[k]:
|
||||||
db.update("atome", "id", a["id"], ziel_id=p)
|
db.update("atome", "id", a["id"], ziel_id=p[0])
|
||||||
gruppen[p] += gruppen.pop(z)
|
gruppen[p] += gruppen.pop(k)
|
||||||
db.update("lernziele", "id", z, status="gemerged")
|
# 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
|
geaendert = True
|
||||||
break
|
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,))}
|
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)
|
ordnung = _topo([a["id"] for a in gruppe], kanten, rang)
|
||||||
z = ziele.get(ziel_id, {})
|
z = ziele.get(ziel_id, {})
|
||||||
titel = z.get("titel") or z.get("text", "Baustein") # Kurztitel; Zieltext nur Fallback
|
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
|
continue
|
||||||
b_titel = titel if n_teile == 1 else f"{titel} (Teil {teil + 1})"
|
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,
|
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):
|
for pos, atom_id in enumerate(teil_ids):
|
||||||
db.update("atome", "id", atom_id, baustein_id=b_id, ord=pos)
|
db.update("atome", "id", atom_id, baustein_id=b_id, ord=pos)
|
||||||
|
|
||||||
@@ -260,62 +264,77 @@ def _bausteine_schneiden(ctx: llm.Kontext) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _bausteine_ordnen(topic: str, kanten: list[tuple[int, int]], rang: dict) -> None:
|
def _bausteine_ordnen(topic: str, kanten: list[tuple[int, int]], rang: dict) -> None:
|
||||||
"""Baustein-DAG aus aggregierten Atom-Kanten; Zyklen deterministisch an der
|
"""Baustein-DAG aus aggregierten Atom-Kanten, JE LEVEL getrennt geordnet;
|
||||||
schwächsten Aggregat-Kante gebrochen (wenigste Atom-Kanten)."""
|
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,))
|
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)
|
gewicht: dict = defaultdict(int)
|
||||||
for v, z in kanten: # v braucht z ⇒ Baustein(z) vor Baustein(v)
|
for v, z in kanten: # v braucht z ⇒ Baustein(z) vor Baustein(v)
|
||||||
bv, bz = je_atom.get(v), je_atom.get(z)
|
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
|
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
|
# Tie-Break: Quellposition des Bausteins (früheste Belegstelle); Kanten gewinnen
|
||||||
# weiter. Kapitel entstehen erst NACH der Ordnung (Entkopplung vom Soll).
|
# weiter. Kapitel entstehen erst NACH der Ordnung (Entkopplung vom Soll).
|
||||||
atome = _atome(topic)
|
|
||||||
b_rang = {}
|
b_rang = {}
|
||||||
for b in bausteine:
|
for b in bausteine:
|
||||||
b_rang[b["id"]] = min((rang.get(a["id"], (9, 9)) for a in atome
|
b_rang[b["id"]] = min((rang.get(a["id"], (9, 9)) for a in atome
|
||||||
if a["baustein_id"] == b["id"]), default=(9, 9))
|
if a["baustein_id"] == b["id"]), default=(9, 9))
|
||||||
while True:
|
offset = 0
|
||||||
zyklus = _finde_zyklus(b_ids, list(b_kanten))
|
for level in LEVEL_RANG:
|
||||||
if not zyklus:
|
b_ids = [b["id"] for b in bausteine if b["level"] == level]
|
||||||
break
|
if not b_ids:
|
||||||
schwach = min(zyklus, key=lambda k: (gewicht[k], k))
|
continue
|
||||||
b_kanten.discard(schwach)
|
b_kanten = {k for k in gewicht if b_level[k[0]] == level}
|
||||||
# Die Atom-Kanten hinter der geopferten Aggregat-Kante stilllegen — sonst
|
while True:
|
||||||
# meldet die QA sie ewig als Vorwärts-Kante (Baustein-Zyklen kann keine
|
zyklus = _finde_zyklus(b_ids, list(b_kanten))
|
||||||
# Ordnung vollständig rückwärts auflösen).
|
if not zyklus:
|
||||||
bv, bz = schwach
|
break
|
||||||
db.execute("UPDATE kanten SET status='geopfert' WHERE topic=? AND art='braucht'"
|
schwach = min(zyklus, key=lambda k: (gewicht[k], k))
|
||||||
" AND status='aktiv'"
|
b_kanten.discard(schwach)
|
||||||
" AND von_atom IN (SELECT id FROM atome WHERE baustein_id=?)"
|
# Die Atom-Kanten hinter der geopferten Aggregat-Kante stilllegen — sonst
|
||||||
" AND zu_atom IN (SELECT id FROM atome WHERE baustein_id=?)",
|
# meldet die QA sie ewig als Vorwärts-Kante (Baustein-Zyklen kann keine
|
||||||
(topic, bv, bz))
|
# Ordnung vollständig rückwärts auflösen).
|
||||||
for pos, b_id in enumerate(_topo(b_ids, list(b_kanten), b_rang)):
|
bv, bz = schwach
|
||||||
db.update("bausteine", "id", b_id, ord=pos)
|
db.execute("UPDATE kanten SET status='geopfert' WHERE topic=? AND art='braucht'"
|
||||||
|
" AND status='aktiv'"
|
||||||
|
" AND von_atom IN (SELECT id FROM atome WHERE baustein_id=?)"
|
||||||
|
" 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=offset + pos)
|
||||||
|
offset += len(b_ids)
|
||||||
|
|
||||||
|
|
||||||
async def _level_kalibrieren(ctx: llm.Kontext) -> None:
|
async def _level_kalibrieren(ctx: llm.Kontext) -> None:
|
||||||
"""Stufen als Komplexitätsschichten (4C/ID): Die Extraktion sieht nur ihren
|
"""Stufen als Komplexitätsschichten (4C/ID): Die Extraktion sieht nur ihren
|
||||||
Chunk und stuft absolut (aak: 13 % E → Einstiegs-Durchgang war ein
|
Chunk und stuft absolut (aak: 13 % E → Einstiegs-Durchgang war ein
|
||||||
Lücken-Gerippe). Hier stuft ein Judge je Baustein RELATIV nach — er sieht
|
Lücken-Gerippe). Hier stuft ein Judge je LERNZIEL relativ nach — VOR dem
|
||||||
alle Atome nebeneinander. E-Atome müssen allein einen kohärenten
|
Schnitt, denn je Level entstehen getrennte Bausteine. Die E-Atome eines
|
||||||
Kurzdurchgang tragen; rein vertiefende Bausteine bleiben ohne E."""
|
Ziels müssen allein einen kohärenten Kurzdurchgang tragen; rein
|
||||||
bausteine = db.query("SELECT * FROM bausteine WHERE topic=?", (ctx.topic,))
|
vertiefende Ziele bleiben ohne E."""
|
||||||
atome = _atome(ctx.topic)
|
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:
|
async def einer(ziel_id: int, eigene: list[dict]) -> None:
|
||||||
eigene = [a for a in atome if a["baustein_id"] == b["id"]]
|
z = ziele.get(ziel_id, {})
|
||||||
if not eigene:
|
titel = z.get("titel") or z.get("text") or ctx.topic
|
||||||
return
|
|
||||||
# bewusst OHNE bisheriges Level: der Extraktions-Prior ist verzerrt
|
# bewusst OHNE bisheriges Level: der Extraktions-Prior ist verzerrt
|
||||||
# (Chunk-blind, M-Anchoring) und würde die Neubewertung ankern
|
# (Chunk-blind, M-Anchoring) und würde die Neubewertung ankern
|
||||||
liste = "\n".join(f"{a['id']}: {a['titel']} — {a['definition']}" for a in eigene)
|
liste = "\n".join(f"{a['id']}: {a['titel']} — {a['definition']}" for a in eigene)
|
||||||
res = await llm.call(ctx, stage="level", template="Level-Kalibrierung",
|
res = await llm.call(ctx, stage="level", template="Level-Kalibrierung",
|
||||||
werte={"titel": b["titel"], "atome": liste},
|
werte={"titel": titel, "atome": liste},
|
||||||
role="judge", n=len(eigene), item=f"lv{b['id']}",
|
role="judge", n=len(eigene), item=f"lvz{ziel_id}",
|
||||||
erwartet=list)
|
erwartet=list)
|
||||||
gueltig = {a["id"]: a["level"] for a in eigene}
|
gueltig = {a["id"]: a["level"] for a in eigene}
|
||||||
for e in res or []:
|
for e in res or []:
|
||||||
@@ -326,73 +345,121 @@ async def _level_kalibrieren(ctx: llm.Kontext) -> None:
|
|||||||
if atom_id in gueltig and level in ("E", "M", "S") and level != gueltig[atom_id]:
|
if atom_id in gueltig and level in ("E", "M", "S") and level != gueltig[atom_id]:
|
||||||
db.update("atome", "id", atom_id, level=level)
|
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:
|
async def _kapitel_bilden(ctx: llm.Kontext) -> None:
|
||||||
"""Kapitel = kontiguierliche Segmente der geordneten Baustein-Folge. Das Soll
|
"""Kapitel = kontiguierliche Segmente der geordneten Baustein-Folge, JE
|
||||||
ist seit der Entkopplung reine Checkliste — Kapitel sind Struktur. Der Judge
|
DURCHGANG (Level) geschnitten; kapitel.ord läuft global E→M→S weiter. Das
|
||||||
liefert nur GRENZEN (titel + bis-id) — id-Listen schrieb er unvollständig
|
Soll ist seit der Entkopplung reine Checkliste — Kapitel sind Struktur. Der
|
||||||
(aak Lauf 17: 516 Tokens für 68 Bausteine → alles verworfen). Der Code
|
Judge liefert nur GRENZEN (titel + bis-id) — id-Listen schrieb er
|
||||||
erzwingt Reihenfolge + Lückenlosigkeit; ein ungültiges Ergebnis bekommt
|
unvollständig (aak Lauf 17: 516 Tokens für 68 Bausteine → alles verworfen).
|
||||||
EINEN Retry, danach √n-Fallback mit Befund (kapitel_fallback). Idempotent."""
|
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
|
topic = ctx.topic
|
||||||
bausteine = db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (topic,))
|
alle = db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (topic,))
|
||||||
if not bausteine:
|
if not alle:
|
||||||
return
|
return
|
||||||
ziele = {z["id"]: z for z in db.query("SELECT * FROM lernziele WHERE topic=?", (topic,))}
|
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)
|
|
||||||
richtwert = max(3, round(len(bausteine) / KAPITEL_BAUSTEINE))
|
|
||||||
folge = [b["id"] for b in bausteine]
|
|
||||||
pos_von = {bid: i for i, bid in enumerate(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})
|
|
||||||
segmente: list[tuple[str, list[int]]] = []
|
|
||||||
start = 0
|
|
||||||
for gruppe in res or []:
|
|
||||||
if not isinstance(gruppe, dict):
|
|
||||||
return None
|
|
||||||
titel = str(gruppe.get("titel", "")).strip()
|
|
||||||
ende = pos_von.get(gruppe.get("bis"))
|
|
||||||
if not titel or ende is None or ende < start:
|
|
||||||
return None
|
|
||||||
segmente.append((titel, folge[start:ende + 1]))
|
|
||||||
start = ende + 1
|
|
||||||
return segmente if segmente and start == len(folge) else None
|
|
||||||
|
|
||||||
segmente = await schneiden("", "kapitel")
|
|
||||||
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")
|
|
||||||
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))
|
|
||||||
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,))
|
db.execute("DELETE FROM kapitel WHERE topic=?", (topic,))
|
||||||
for pos, (titel, ids) in enumerate(segmente):
|
pos_global = 0
|
||||||
k_id = db.insert("kapitel", topic=topic, titel=titel[:120], ord=pos, art=art)
|
for level in LEVEL_RANG:
|
||||||
for b_id in ids:
|
bausteine = [b for b in alle if b["level"] == level]
|
||||||
db.update("bausteine", "id", b_id, kapitel_id=k_id)
|
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 = {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),
|
||||||
|
"durchgang": DURCHGANG[level], "hinweis": hinweis})
|
||||||
|
segmente: list[tuple[str, list[int]]] = []
|
||||||
|
start = 0
|
||||||
|
for gruppe in res or []:
|
||||||
|
if not isinstance(gruppe, dict):
|
||||||
|
return None
|
||||||
|
titel = str(gruppe.get("titel", "")).strip()
|
||||||
|
ende = pos_von.get(gruppe.get("bis"))
|
||||||
|
if not titel or ende is None or ende < start:
|
||||||
|
return None
|
||||||
|
segmente.append((titel, folge[start:ende + 1]))
|
||||||
|
start = ende + 1
|
||||||
|
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("", f"kapitel-{level}")
|
||||||
|
if segmente is None:
|
||||||
|
segmente = await schneiden(
|
||||||
|
"ACHTUNG: Der letzte Versuch war ungültig. Jede Zeile braucht"
|
||||||
|
" \"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(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])]
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
async def bauen(ctx: llm.Kontext) -> None:
|
async def bauen(ctx: llm.Kontext) -> None:
|
||||||
ctx.ebene = EBENE
|
ctx.ebene = EBENE
|
||||||
await _ziele_bilden(ctx)
|
await _ziele_bilden(ctx)
|
||||||
await _zyklen_brechen(ctx)
|
await _zyklen_brechen(ctx)
|
||||||
|
await _level_kalibrieren(ctx) # VOR dem Schnitt: Level bestimmt die Bausteine
|
||||||
|
_level_konflikte_loesen(ctx.topic)
|
||||||
_bausteine_schneiden(ctx)
|
_bausteine_schneiden(ctx)
|
||||||
await _level_kalibrieren(ctx)
|
|
||||||
await _kapitel_bilden(ctx)
|
await _kapitel_bilden(ctx)
|
||||||
|
|
||||||
|
|
||||||
@@ -411,19 +478,41 @@ def messen(ctx: llm.Kontext) -> list[dict]:
|
|||||||
# pendelt nur die Note (Lektion 75).
|
# pendelt nur die Note (Lektion 75).
|
||||||
for b in bausteine:
|
for b in bausteine:
|
||||||
n = len(je_baustein[b["id"]])
|
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
|
and len(je_baustein[bb["id"]]) + n <= BAUSTEIN_MAX_ATOME
|
||||||
for bb in bausteine)
|
for bb in bausteine)
|
||||||
if n > BAUSTEIN_MAX_ATOME or (n < BAUSTEIN_MIN_ATOME and partner):
|
if n > BAUSTEIN_MAX_ATOME or (n < BAUSTEIN_MIN_ATOME and partner):
|
||||||
befunde.append({"art": "band", "item": str(b["id"]),
|
befunde.append({"art": "band", "item": str(b["id"]),
|
||||||
"detail": f"{b['titel']}: {n} Atome"})
|
"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,))
|
kaps = db.query("SELECT * FROM kapitel WHERE topic=?", (ctx.topic,))
|
||||||
kap_ids = {k["id"] for k in kaps}
|
kap_ids = {k["id"] for k in kaps}
|
||||||
|
kap_level = {k["id"]: k["level"] for k in kaps}
|
||||||
for b in bausteine:
|
for b in bausteine:
|
||||||
if b["kapitel_id"] not in kap_ids:
|
if b["kapitel_id"] not in kap_ids:
|
||||||
befunde.append({"art": "baustein_ohne_kapitel", "item": str(b["id"]),
|
befunde.append({"art": "baustein_ohne_kapitel", "item": str(b["id"]),
|
||||||
"detail": b["titel"]})
|
"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}
|
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:
|
for k in kaps:
|
||||||
if k["id"] not in belegt:
|
if k["id"] not in belegt:
|
||||||
befunde.append({"art": "kapitel_leer", "item": str(k["id"]),
|
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
|
if k["art"] == "fallback": # stiller Fallback wäre unsichtbarer Qualitätsverlust
|
||||||
befunde.append({"art": "kapitel_fallback", "item": str(k["id"]),
|
befunde.append({"art": "kapitel_fallback", "item": str(k["id"]),
|
||||||
"detail": k["titel"]})
|
"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}
|
ord_von = {b["id"]: b["ord"] for b in bausteine}
|
||||||
for k in _braucht_kanten(ctx.topic):
|
for k in _braucht_kanten(ctx.topic):
|
||||||
bv = next((a["baustein_id"] for a in atome if a["id"] == k["von_atom"]), None)
|
bv = next((a["baustein_id"] for a in atome if a["id"] == k["von_atom"]), None)
|
||||||
@@ -450,9 +546,10 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
|
|||||||
ctx.ebene = EBENE
|
ctx.ebene = EBENE
|
||||||
if not befunde:
|
if not befunde:
|
||||||
return False
|
return False
|
||||||
await _ziele_bilden(ctx) # fängt Partition-Lücken
|
await _ziele_bilden(ctx) # fängt Partition-Lücken
|
||||||
await _zyklen_brechen(ctx) # fängt Zyklen
|
await _zyklen_brechen(ctx) # fängt Zyklen
|
||||||
_bausteine_schneiden(ctx) # Neu-Schnitt fängt Band + Vorwärts-Kanten
|
|
||||||
await _level_kalibrieren(ctx)
|
await _level_kalibrieren(ctx)
|
||||||
await _kapitel_bilden(ctx) # fängt Kapitel-Befunde
|
_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
|
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)."""
|
Makefile per rsync NACH dem Import (topic_loeschen räumt den Korpus-Ordner)."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -89,6 +90,8 @@ def importieren(d: dict) -> None:
|
|||||||
if b.get("quelle") in q_map:
|
if b.get("quelle") in q_map:
|
||||||
b["quelle"] = q_map[b["quelle"]]
|
b["quelle"] = q_map[b["quelle"]]
|
||||||
z["belege"] = db.j(belege)
|
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)
|
soll_zeilen.append(z)
|
||||||
s_map = _einfuegen("soll", soll_zeilen)
|
s_map = _einfuegen("soll", soll_zeilen)
|
||||||
|
|
||||||
@@ -109,9 +112,19 @@ def importieren(d: dict) -> None:
|
|||||||
z = dict(z)
|
z = dict(z)
|
||||||
z["artefakt_id"] = ar_map.get(z["artefakt_id"])
|
z["artefakt_id"] = ar_map.get(z["artefakt_id"])
|
||||||
db.insert("leitner", **z)
|
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
|
for z in d["sections"]: # PK ist baustein_id
|
||||||
z = dict(z)
|
z = dict(z)
|
||||||
z["baustein_id"] = b_map.get(z["baustein_id"])
|
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)
|
db.insert("sections", **z)
|
||||||
r_map = _einfuegen("runs", d["runs"])
|
r_map = _einfuegen("runs", d["runs"])
|
||||||
_einfuegen("befunde", d["befunde"], run_id=r_map)
|
_einfuegen("befunde", d["befunde"], run_id=r_map)
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||||
import { api } from '../api.js'
|
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 props = defineProps({ topic: String, state: Object })
|
||||||
const daten = ref(null)
|
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 ansicht = ref('erklaerend') // erklaerend = Fließtext | kompakt = Stichpunkte
|
||||||
const LEVEL_ICON = { E: '●○○', M: '●●○', S: '●●●' }
|
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
|
const fokus = ref(false) // Fokus: Vollbild — App-Chrome + Navigation weg, Karten aufgelöst
|
||||||
watch(fokus, (f) => document.body.classList.toggle('vollbild', f))
|
watch(fokus, (f) => document.body.classList.toggle('vollbild', f))
|
||||||
onUnmounted(() => document.body.classList.remove('vollbild'))
|
onUnmounted(() => document.body.classList.remove('vollbild'))
|
||||||
@@ -28,10 +29,9 @@ const htmlCache = new Map()
|
|||||||
watch(daten, () => htmlCache.clear())
|
watch(daten, () => htmlCache.clear())
|
||||||
|
|
||||||
function html(s) {
|
function html(s) {
|
||||||
const key = `${ansicht.value}|${level.value}|${s.baustein}`
|
const key = `${ansicht.value}|${s.baustein}`
|
||||||
if (!htmlCache.has(key)) {
|
if (!htmlCache.has(key)) {
|
||||||
htmlCache.set(key, ansicht.value === 'kompakt'
|
htmlCache.set(key, render(ansicht.value === 'kompakt' ? s.kompakt : s.lang))
|
||||||
? render(s.kompakt || s.lang) : renderGestuft(s.lang, level.value))
|
|
||||||
}
|
}
|
||||||
return htmlCache.get(key)
|
return htmlCache.get(key)
|
||||||
}
|
}
|
||||||
@@ -47,23 +47,29 @@ function hoehe(i) {
|
|||||||
|
|
||||||
function setAnsicht(a) {
|
function setAnsicht(a) {
|
||||||
ansicht.value = 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() {
|
function levelToggle() {
|
||||||
const folge = ['E', 'M', 'S']
|
const folge = levels.value
|
||||||
level.value = folge[(folge.indexOf(level.value) + 1) % 3]
|
level.value = folge[(folge.indexOf(level.value) + 1) % folge.length]
|
||||||
laden()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nur Kapitel/Bausteine mit Inhalt im gewählten Umfang anzeigen — der
|
// Der gewählte Durchgang: nur seine Kapitel, nur Sections mit Inhalt
|
||||||
// Level-Filter ließe sonst leere Titel-Hüllen stehen (36/66 Bausteine ohne E)
|
|
||||||
const anzeige = computed(() => {
|
const anzeige = computed(() => {
|
||||||
const out = []
|
const out = []
|
||||||
for (const kap of daten.value?.kapitel || []) {
|
for (const kap of daten.value?.kapitel || []) {
|
||||||
const sections = kap.sections.filter((s) => ansicht.value === 'kompakt'
|
if (kap.level !== level.value) continue
|
||||||
? (s.kompakt || s.lang || '').trim()
|
const sections = kap.sections.filter((s) =>
|
||||||
: lesestat(gefiltert(s.lang, level.value)).woerter >= 10)
|
((ansicht.value === 'kompakt' ? s.kompakt : s.lang) || '').trim())
|
||||||
if (sections.length) out.push({ ...kap, sections })
|
if (sections.length) out.push({ ...kap, sections })
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
@@ -77,8 +83,7 @@ const stats = computed(() => {
|
|||||||
let woerter = ansicht.value === 'kompakt' ? 0 : lesestat(kap.intro).woerter
|
let woerter = ansicht.value === 'kompakt' ? 0 : lesestat(kap.intro).woerter
|
||||||
let formeln = 0
|
let formeln = 0
|
||||||
for (const s of kap.sections) {
|
for (const s of kap.sections) {
|
||||||
const st = lesestat(ansicht.value === 'kompakt' ? (s.kompakt || s.lang)
|
const st = lesestat(ansicht.value === 'kompakt' ? s.kompakt : s.lang)
|
||||||
: gefiltert(s.lang, level.value))
|
|
||||||
woerter += st.woerter
|
woerter += st.woerter
|
||||||
formeln += st.displayFormeln
|
formeln += st.displayFormeln
|
||||||
}
|
}
|
||||||
@@ -156,12 +161,23 @@ function onScroll(e) {
|
|||||||
<div v-for="(kap, i) in anzeige" :key="kap.kapitel_id" class="kapitel"
|
<div v-for="(kap, i) in anzeige" :key="kap.kapitel_id" class="kapitel"
|
||||||
:ref="(el) => setKapEl(i, el)">
|
:ref="(el) => setKapEl(i, el)">
|
||||||
<h2 class="kapitel-titel">{{ i + 1 }}. {{ kap.titel }}</h2>
|
<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)">
|
<template v-if="sichtbar(i)">
|
||||||
<p v-if="kap.intro && ansicht === 'erklaerend'"
|
<p v-if="kap.intro && ansicht === 'erklaerend'"
|
||||||
style="color: var(--dim); margin-top: 0">{{ kap.intro }}</p>
|
style="color: var(--dim); margin-top: 0">{{ kap.intro }}</p>
|
||||||
<div v-for="s in kap.sections" :key="s.baustein" class="guide-section">
|
<div v-for="s in kap.sections" :key="s.baustein" class="guide-section">
|
||||||
<h3 style="margin-top: 0">{{ s.titel }}</h3>
|
<h3 style="margin-top: 0">{{ s.titel }}</h3>
|
||||||
<div :class="ansicht === 'kompakt' ? 'kompakt' : 'markdown'" v-html="html(s)"></div>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-else class="kapitel-platzhalter" :style="{ height: hoehe(i) + 'px' }"></div>
|
<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 || '')))
|
return DOMPurify.sanitize(marked.parse(mathe(text || '')))
|
||||||
}
|
}
|
||||||
|
|
||||||
const RANG = { E: 0, M: 1, S: 2 }
|
// Atom-Marker sind eine unsichtbare Backend-Schnittstelle — vor der Anzeige
|
||||||
const MARKER = /<!--\s*atom:\s*(\d+)\s*\|\s*([^|]*)\|\s*([EMS])\s*-->/g
|
// 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)
|
// Wortzahl + Display-Formeln des sichtbaren Texts (für die Lesezeit-Schätzung)
|
||||||
export function lesestat(text) {
|
export function lesestat(text) {
|
||||||
@@ -61,47 +63,3 @@ export function lesestat(text) {
|
|||||||
const woerter = t.split(/\s+/).filter(Boolean).length
|
const woerter = t.split(/\s+/).filter(Boolean).length
|
||||||
return { woerter, displayFormeln }
|
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; }
|
.fokus-aus:hover { opacity: 1; }
|
||||||
.seite.guide-text::-webkit-scrollbar { display: none; }
|
.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 { 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; }
|
.kapitel-platzhalter { border-left: 2px dashed var(--rand); margin: 8px 0 8px 4px; }
|
||||||
/* Kompakt-Ansicht: gleiche Lesetypografie wie der Fließtext, kein Grau-Kasten */
|
/* Kompakt-Ansicht: gleiche Lesetypografie wie der Fließtext, kein Grau-Kasten */
|
||||||
.guide-text .kompakt {
|
.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
|
- ok=false, wenn Frage oder Antwort auf Beleg, Quelle, Musterlösung oder einen
|
||||||
Aufgabenkontext verweist — Karten müssen ohne den Quelltext selbstständig
|
Aufgabenkontext verweist — Karten müssen ohne den Quelltext selbstständig
|
||||||
funktionieren und Wissen abfragen, nicht Textinhalte.
|
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.
|
- Formulierungsgeschmack ist KEIN Mangel — inhaltlich korrekt in anderen Worten zählt voll.
|
||||||
- Bei ok=false: „mangel" = ein konkreter, behebbarer Satz.
|
- Bei ok=false: „mangel" = ein konkreter, behebbarer Satz.
|
||||||
- Antworte für JEDES Artefakt (id wörtlich übernehmen).
|
- 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,
|
- „zitat": die Aufgabenstelle WÖRTLICH kopiert (5–40 Wörter) — sie ist der Beleg,
|
||||||
dass dieses Konzept geübt wird.
|
dass dieses Konzept geübt wird.
|
||||||
- Prüft eine Aufgabe mehrere Konzepte, wird jedes ein eigenes Atom.
|
- 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.
|
- Arbeite AUSSCHLIESSLICH mit dem Quelltext, nichts erfinden.
|
||||||
- „braucht": Titel anderer Atome DIESES Auszugs, die Voraussetzung sind. Leer, wenn keine.
|
- „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
|
Test: Kann man es einzeln abprüfen? Dann Atom. Beispiele und Übungsaufgaben sind
|
||||||
KEINE Atome. Regeln:
|
KEINE Atome. Regeln:
|
||||||
- Arbeite AUSSCHLIESSLICH mit dem Quelltext. Kein externes Wissen, keine Websuche, nichts erfinden.
|
- 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
|
- „titel": Konzeptname aus den Begriffen der Quelle. Katalognummern („Satz 7.13") sind
|
||||||
KEIN Titel; kein Lehrbuch-Oberbegriff, der nicht im Text steht.
|
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).
|
- „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 -->
|
<!-- template:Guide-Writer -->
|
||||||
Schreibe EINE Guide-Section für den Baustein „{titel}".
|
Schreibe EINE Guide-Section für den Baustein „{titel}".
|
||||||
Lernziel: {ziel}
|
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.
|
Unten die Atome mit Marker, Definition, Belegen, ggf. Beispiel und Fragen-Pool.
|
||||||
|
|
||||||
@@ -54,7 +60,9 @@ Regeln (hart):
|
|||||||
ein, kein Fülltext, keine Wiederholungen.
|
ein, kein Fülltext, keine Wiederholungen.
|
||||||
6. „kompakt": 2–3 Stichpunkte (Markdown-Liste) — Verdichtung der Section, KEINE
|
6. „kompakt": 2–3 Stichpunkte (Markdown-Liste) — Verdichtung der Section, KEINE
|
||||||
Paraphrase, keine Marker.
|
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:
|
||||||
{atome}
|
{atome}
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
<!-- template:Kapitel-Intro -->
|
<!-- 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).
|
Unten die Abschnitte des Kapitels (Titel: Lernziel).
|
||||||
|
|
||||||
Regeln:
|
Regeln:
|
||||||
- 2–4 Sätze: Was kommt in diesem Kapitel, wie hängen die Abschnitte zusammen,
|
- 1–2 Sätze, KONKRET: ein greifbarer Aufhänger — die zentrale Frage, das
|
||||||
worauf baut es auf.
|
Problem oder der Anwendungsfall, den das Kapitel löst. Wenn möglich mit
|
||||||
- NUR Landkarte, KEINE Inhalte vorwegnehmen — nichts erklären, nichts definieren
|
einem konkreten Beispiel oder einer vertrauten Analogie.
|
||||||
(das wäre Redundanz zum Kapiteltext).
|
- VERBOTEN: Landkarten-Meta („Dieses Kapitel führt ein…", „legt das Fundament",
|
||||||
- Kein Vorgeplänkel, keine Motivation-Floskeln („In diesem spannenden Kapitel…").
|
„Ausgehend von … werden … umrissen") — solche Vorschauen liest niemand.
|
||||||
|
- Nichts erklären, nichts definieren (das macht der Kapiteltext).
|
||||||
|
- Kein Vorgeplänkel, keine Motivations-Floskeln.
|
||||||
|
|
||||||
ABSCHNITTE:
|
ABSCHNITTE:
|
||||||
{bausteine}
|
{bausteine}
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
<!-- template:Kapitel-Schnitt -->
|
<!-- template:Kapitel-Schnitt -->
|
||||||
Unten die BAUSTEINE eines Lern-Guides in ihrer endgültigen Lese-Reihenfolge
|
Unten die BAUSTEINE eines Lern-Guide-Durchgangs in ihrer endgültigen
|
||||||
(Format „id: titel — lernziel").
|
Lese-Reihenfolge, durchnummeriert (Format „nummer: titel — lernziel").
|
||||||
|
Durchgang: {durchgang}. Formuliere die Kapiteltitel für diesen Durchgang.
|
||||||
|
|
||||||
Setze Kapitelgrenzen. Regeln:
|
Setze Kapitelgrenzen. Regeln:
|
||||||
- Ein Kapitel = ein zusammenhängendes Teilthema. Grenze dort, wo ein neues beginnt.
|
- Ein Kapitel = ein zusammenhängendes Teilthema. Grenze dort, wo ein neues beginnt.
|
||||||
- Antworte mit EINER Zeile je Kapitel: "titel" plus "bis" = id des LETZTEN
|
- Antworte mit EINER Zeile je Kapitel: "titel" plus "bis" = NUMMER des LETZTEN
|
||||||
Bausteins dieses Kapitels. Die Reihenfolge ist fix — du setzt nur Grenzen.
|
Bausteins dieses Kapitels. Die Reihenfolge ist fix — du setzt nur Grenzen,
|
||||||
Das letzte "bis" muss die letzte id der Liste sein.
|
"bis" muss streng aufsteigen. Das letzte "bis" ist die letzte Nummer der Liste.
|
||||||
- Richtwert: etwa {richtwert} Kapitel — weiche ab, wo Themenwechsel es verlangen.
|
- 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
|
- „titel" = kurze Inhaltsaussage über ALLE Bausteine des Kapitels (kein
|
||||||
Baustein-Titel-Duplikat, keine Nummerierung).
|
Baustein-Titel-Duplikat, keine Nummerierung).
|
||||||
{hinweis}
|
{hinweis}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
<!-- template:Korpus-Soll-Beleg -->
|
<!-- 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:
|
Suche für JEDEN Punkt eine Passage im QUELLTEXT, die ihn belegt. Wenn es eine
|
||||||
kopiere sie WÖRTLICH (exakt, 5–30 Wörter). Wenn der Text den Punkt NICHT behandelt:
|
gibt: kopiere sie WÖRTLICH (exakt, 5–30 Wörter). Behandelt der Text einen Punkt
|
||||||
leeres Zitat. Nichts paraphrasieren, nichts erfinden.
|
NICHT: den Punkt weglassen. Nichts paraphrasieren, nichts erfinden.
|
||||||
|
|
||||||
|
SOLL-PUNKTE:
|
||||||
|
{punkte}
|
||||||
|
|
||||||
QUELLTEXT:
|
QUELLTEXT:
|
||||||
{text}
|
{text}
|
||||||
|
|
||||||
Antworte NUR mit JSON (keine Code-Fences):
|
Antworte NUR mit JSON (keine Code-Fences) — nur Punkte MIT Fund:
|
||||||
{{"zitat": "…"}}
|
[{{"soll": 12, "zitat": "…"}}]
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
<!-- template:Level-Kalibrierung -->
|
<!-- 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
|
Kalibriere die Lernstufen RELATIV zueinander. Die Stufen sind Komplexitätsschichten
|
||||||
(nicht „leicht/schwer"):
|
(nicht „leicht/schwer") und werden zu GETRENNTEN Bausteinen und Lese-Durchgängen:
|
||||||
- E = Grundgerüst: das Minimum, das man beim ERSTEN Durchgang braucht, um den
|
- E = Grundgerüst: das Minimum, das man beim ERSTEN Durchgang braucht, um das
|
||||||
Baustein zu verstehen — Kernbegriffe, die Kernaussage. Die E-Atome allein müssen
|
Lernziel zu verstehen — Kernbegriffe, die Kernaussage. Die E-Atome allein müssen
|
||||||
einen kohärenten Kurzdurchgang ergeben.
|
einen kohärenten, für sich lesbaren Kurzdurchgang tragen.
|
||||||
- M = Standardstoff: Sätze, Verfahren, Standardbeweise — der zweite Durchgang.
|
- M = Standardstoff: Sätze, Verfahren, Standardbeweise — der zweite Durchgang.
|
||||||
- S = Feinheiten: Spezialfälle, Beweisdetails, Optimierungen, Randnotizen.
|
- S = Feinheiten: Spezialfälle, Beweisdetails, Optimierungen, Randnotizen.
|
||||||
|
|
||||||
Regeln:
|
Regeln:
|
||||||
- Behandelt der Baustein grundlegenden Stoff, gehört sein tragender Kern zu E.
|
- Behandelt das Ziel grundlegenden Stoff, gehört sein tragender Kern zu E.
|
||||||
- Rein vertiefende Bausteine (reine Beweis-/Spezialbausteine) dürfen ohne E bleiben.
|
- 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.
|
- Jede id genau einmal, keine erfinden.
|
||||||
|
|
||||||
ATOME:
|
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")
|
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",
|
a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d",
|
||||||
status="neu", baustein_id=b_id, braucht=db.j([]))
|
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> wörtliches Rohzitat aus der Quelle\nEs gilt u 6= v."
|
||||||
+ "\n\nDas Blank-Symbol $[. ist speziell und liegt in $[ \\in \\Gamma$.")
|
+ "\n\nDas Blank-Symbol $[. ist speziell und liegt in $[ \\in \\Gamma$.")
|
||||||
kompakt = "- Definition mit x_i und Laufzeit 2^(n/2) als Klartext."
|
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
|
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):
|
def test_katex_gate_und_markdown_hygiene(monkeypatch):
|
||||||
import guide
|
import guide
|
||||||
monkeypatch.delenv("CREATOR_FAKE_AGENTS", raising=False) # Gate echt laufen lassen
|
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)
|
md = guide.guide_markdown(topic)
|
||||||
for a in atome:
|
for a in atome:
|
||||||
assert f"<!-- atom: {a['id']} |" in md
|
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
|
# Kapitel-Struktur: H2 = Kapitel (Struktur-Ebene, mit Intro), H3 = Baustein
|
||||||
assert "\n## " in "\n" + md and "### " in md
|
assert "\n## " in "\n" + md and "### " in md
|
||||||
assert "**Kernpunkte:**" in md and "**Prüfe dich:**" 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]
|
folge = [b["kapitel_id"] for b in bausteine]
|
||||||
segmente = [k for k, _ in itertools.groupby(folge)]
|
segmente = [k for k, _ in itertools.groupby(folge)]
|
||||||
assert len(segmente) == len(set(segmente)) == len(kaps) # kontiguierlich, lückenlos
|
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,)):
|
for b in db.query("SELECT titel FROM bausteine WHERE topic=?", (topic,)):
|
||||||
assert not b["titel"].startswith("Kann"), b["titel"] # Kurztitel, kein Ziel-Satz
|
assert not b["titel"].startswith("Kann"), b["titel"] # Kurztitel, kein Ziel-Satz
|
||||||
events = db.query("SELECT * FROM events WHERE run_id=?", (run_id,))
|
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")
|
topic = topic_anlegen("fake-thema", art="thema")
|
||||||
run_id = await _lauf_komplett(topic)
|
run_id = await _lauf_komplett(topic)
|
||||||
_pruefe_endzustand(topic, run_id)
|
_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,))
|
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):
|
async def test_e2e_uni(tmp_path):
|
||||||
|
|||||||
@@ -132,11 +132,10 @@ async def test_level_kalibrierung(monkeypatch):
|
|||||||
topic = topic_anlegen("level")
|
topic = topic_anlegen("level")
|
||||||
run = run_anlegen(topic)
|
run = run_anlegen(topic)
|
||||||
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
|
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",
|
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",
|
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):
|
def judge(prompt):
|
||||||
return [{"atom": a1, "level": "E"}, {"atom": a2, "level": "S"},
|
return [{"atom": a1, "level": "E"}, {"atom": a2, "level": "S"},
|
||||||
@@ -147,3 +146,67 @@ async def test_level_kalibrierung(monkeypatch):
|
|||||||
await struktur._level_kalibrieren(ctx)
|
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=?", (a1,))["level"] == "E"
|
||||||
assert db.one("SELECT level FROM atome WHERE id=?", (a2,))["level"] == "S"
|
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,)):
|
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 id FROM kapitel WHERE id=?", (b["kapitel_id"],))
|
||||||
assert db.one("SELECT baustein_id FROM sections WHERE baustein_id=?", (b["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