update
This commit is contained in:
@@ -3,13 +3,17 @@ Soll-Punkt-Gruppen (Kontext teilen spart Tokens — Lektion 52), verifiziert von
|
||||
2er-Panel GEGEN DIE ANKER-ZITATE (inline, Lektion 48). Einstimmig ok → verifiziert;
|
||||
sonst ein Fix + Re-Verify durch einen Judge; danach verifiziert oder verworfen."""
|
||||
|
||||
import ast
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
import db
|
||||
import diagramme
|
||||
import llm
|
||||
from config import ARTEFAKT_CHUNK_ATOME, VERIFY_PANEL
|
||||
from config import ARTEFAKT_CHUNK_ATOME, BEISPIEL_FORMEN, VERIFY_PANEL
|
||||
|
||||
log = logging.getLogger("creator2.artefakte")
|
||||
|
||||
@@ -25,8 +29,14 @@ _QUELLEN_REFERENZ = re.compile(
|
||||
re.IGNORECASE)
|
||||
|
||||
|
||||
def _prosa_teile(inhalt: dict) -> str:
|
||||
"""Nur menschenlesbare Prosa fürs Quellen-Referenz-Gate — NICHT Code/Mermaid/
|
||||
Tabelle (dort matchen Bezeichner/Kommentare den Regex falsch, z. B. „blatt 3")."""
|
||||
return " ".join(str(inhalt.get(k, "")) for k in ("frage", "antwort", "text"))
|
||||
|
||||
|
||||
def _referenziert_quelle(inhalt: dict) -> bool:
|
||||
return _QUELLEN_REFERENZ.search(" ".join(str(v) for v in inhalt.values())) is not None
|
||||
return _QUELLEN_REFERENZ.search(_prosa_teile(inhalt)) is not None
|
||||
|
||||
|
||||
def _zitate(atom_id: int) -> str:
|
||||
@@ -60,6 +70,21 @@ def _chunks(topic: str, nur_ohne: bool) -> list[list[dict]]:
|
||||
return out
|
||||
|
||||
|
||||
def _beispiel_inhalt(e: dict) -> dict:
|
||||
"""Typisiertes Beispiel: die Form lebt im JSON `inhalt` (keine DB-Migration).
|
||||
Nur relevante Felder gefüllt; unbekannte Form → Default text."""
|
||||
form = str(e.get("form", "text")).strip().lower()
|
||||
if form not in BEISPIEL_FORMEN:
|
||||
form = "text"
|
||||
return {"form": form,
|
||||
"text": str(e.get("text", "")),
|
||||
"code": str(e.get("code", "")),
|
||||
"sprache": str(e.get("sprache", "")).strip().lower(),
|
||||
"tabelle": str(e.get("tabelle", "")),
|
||||
"spec": e.get("spec") if isinstance(e.get("spec"), dict) else {},
|
||||
"mermaid": ""}
|
||||
|
||||
|
||||
async def _generieren(ctx: llm.Kontext, chunk: list[dict]) -> None:
|
||||
bloecke = "\n\n".join(_atom_block(a) for a in chunk)
|
||||
res = await llm.call(ctx, stage="artefakt", template="Artefakt-Generate",
|
||||
@@ -71,7 +96,10 @@ async def _generieren(ctx: llm.Kontext, chunk: list[dict]) -> None:
|
||||
typ = str(e.get("typ", "")).strip()
|
||||
if atom_id not in gueltig or typ not in ("flashcard", "beispiel"):
|
||||
continue
|
||||
inhalt = {k: str(e.get(k, "")) for k in ("frage", "antwort", "text")}
|
||||
if typ == "beispiel":
|
||||
inhalt = _beispiel_inhalt(e)
|
||||
else:
|
||||
inhalt = {k: str(e.get(k, "")) for k in ("frage", "antwort", "text")}
|
||||
if _referenziert_quelle(inhalt):
|
||||
log.info("Artefakt zu Atom %s nicht übernommen: referenziert die Quelle", atom_id)
|
||||
continue
|
||||
@@ -129,7 +157,16 @@ async def _fixen(ctx: llm.Kontext, k: dict, maengel: list[str], atom: dict) -> N
|
||||
"belege": _zitate(k["atom_id"])},
|
||||
erwartet=dict)
|
||||
if res:
|
||||
inhalt = {kk: str(res.get(kk, "")) for kk in ("frage", "antwort", "text")}
|
||||
if k["typ"] == "beispiel":
|
||||
# Form behalten (aus Alt-inhalt), Felder aus dem Fix übernehmen, DANN
|
||||
# form-spezifisch neu gaten — ein kaputter Fix darf nicht durchrutschen.
|
||||
inhalt = _beispiel_inhalt({**db.uj(k["inhalt"], {}), **res})
|
||||
ok, inhalt = _beispiel_pruefen(inhalt)
|
||||
if not ok:
|
||||
db.update("artefakte", "id", k["id"], status="verworfen")
|
||||
return
|
||||
else:
|
||||
inhalt = {kk: str(res.get(kk, "")) for kk in ("frage", "antwort")}
|
||||
if _referenziert_quelle(inhalt):
|
||||
db.update("artefakte", "id", k["id"], status="verworfen")
|
||||
return
|
||||
@@ -151,10 +188,100 @@ async def _fixen(ctx: llm.Kontext, k: dict, maengel: list[str], atom: dict) -> N
|
||||
status="verifiziert" if eintrag.get("ok") else "verworfen")
|
||||
|
||||
|
||||
_VERBATIM_FORMEN = ("code", "tabelle", "diagramm") # kommen verbatim in den Guide
|
||||
|
||||
|
||||
def _tabelle_ok(md: str) -> bool:
|
||||
"""Wohlgeformte Markdown-Tabelle: Kopf, Trenner (---), ≥1 Datenzeile, konsistente
|
||||
Spaltenzahl. Deterministisch, kein LLM."""
|
||||
zeilen = [z for z in md.strip().splitlines() if z.strip()]
|
||||
if len(zeilen) < 3 or not all("|" in z for z in zeilen):
|
||||
return False
|
||||
if not re.fullmatch(r"[\s|:\-]+", zeilen[1]) or "-" not in zeilen[1]:
|
||||
return False
|
||||
spalten = zeilen[0].count("|")
|
||||
return spalten >= 2 and all(z.count("|") == spalten for z in zeilen)
|
||||
|
||||
|
||||
_node_gewarnt = False
|
||||
|
||||
|
||||
def _node_check(code: str) -> bool:
|
||||
"""`node --check` parst JS OHNE es auszuführen. fail-open, wenn node fehlt."""
|
||||
global _node_gewarnt
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".js", delete=True) as f:
|
||||
f.write(code)
|
||||
f.flush()
|
||||
res = subprocess.run(["node", "--check", f.name],
|
||||
capture_output=True, text=True, timeout=15)
|
||||
return res.returncode == 0
|
||||
except Exception as e: # node fehlt → nicht verwerfen (nur Judge prüft dann)
|
||||
if not _node_gewarnt:
|
||||
_node_gewarnt = True
|
||||
log.warning("node-Syntax-Gate nicht verfügbar (%s) — JS-Beispiele ungeprüft", e)
|
||||
return True
|
||||
|
||||
|
||||
def _code_ok(sprache: str, code: str) -> bool:
|
||||
"""Syntax-Gate, PARSE-ONLY (führt NIE aus — keine Sandbox nötig). Python via
|
||||
ast.parse in-process, JavaScript via `node --check`. Unbekannte Sprache → kein
|
||||
Gate (nur der Judge prüft)."""
|
||||
if not code.strip():
|
||||
return False
|
||||
if sprache == "python":
|
||||
try:
|
||||
ast.parse(code)
|
||||
return True
|
||||
except SyntaxError:
|
||||
return False
|
||||
if sprache == "javascript":
|
||||
return _node_check(code)
|
||||
return True
|
||||
|
||||
|
||||
def _beispiel_pruefen(inh: dict) -> tuple[bool, dict]:
|
||||
"""Form-spezifisches Gate (deterministisch, parse-only — nie Ausführung).
|
||||
→ (ok, inhalt); der Diagramm-Zweig füllt `mermaid`. Unbekannte/woven Formen
|
||||
passieren (der Judge prüft inhaltlich)."""
|
||||
form = inh.get("form", "text")
|
||||
if form == "tabelle":
|
||||
return _tabelle_ok(inh.get("tabelle", "")), inh
|
||||
if form == "code":
|
||||
return _code_ok(inh.get("sprache", ""), inh.get("code", "")), inh
|
||||
if form == "diagramm":
|
||||
# Struktur-JSON → Mermaid (deterministisch) → Parse-Gate; wiederverwendet die
|
||||
# Diagramm-Ebene. Der LLM schreibt NIE rohes Mermaid — nur die Spec.
|
||||
spec = diagramme._spec_normalisieren(inh.get("spec") or {},
|
||||
(inh.get("spec") or {}).get("typ", "flow"))
|
||||
if not spec["knoten"]:
|
||||
return False, inh
|
||||
mermaid = diagramme._spec_zu_mermaid(spec)
|
||||
if not mermaid or 0 in diagramme._mermaid_fehler([{"id": 0, "mermaid": mermaid}]):
|
||||
return False, inh
|
||||
return True, {**inh, "spec": spec, "mermaid": mermaid}
|
||||
return True, inh
|
||||
|
||||
|
||||
def _formen_gate(topic: str) -> None:
|
||||
"""Kandidat-Beispiele durch ihr Form-Gate: Syntax/Struktur ungültig → verworfen
|
||||
(fail-closed für den Inhalt; ein fehlendes Beispiel ist erlaubt, optional)."""
|
||||
for b in db.query(
|
||||
"SELECT ar.* FROM artefakte ar JOIN atome a ON a.id=ar.atom_id"
|
||||
" WHERE a.topic=? AND ar.typ='beispiel' AND ar.status='kandidat'", (topic,)):
|
||||
inh = db.uj(b["inhalt"], {})
|
||||
ok, inh_neu = _beispiel_pruefen(inh)
|
||||
if not ok:
|
||||
db.update("artefakte", "id", b["id"], status="verworfen")
|
||||
elif inh_neu is not inh:
|
||||
db.update("artefakte", "id", b["id"], inhalt=db.j(inh_neu))
|
||||
|
||||
|
||||
async def bauen(ctx: llm.Kontext) -> None:
|
||||
ctx.ebene = EBENE
|
||||
neu = _chunks(ctx.topic, nur_ohne=True)
|
||||
await llm.alle(_generieren(ctx, c) for c in neu)
|
||||
_formen_gate(ctx.topic) # form-spezifische Syntax/Struktur-Gates vor dem Panel
|
||||
alle = _chunks(ctx.topic, nur_ohne=False)
|
||||
await llm.alle(_verifizieren(ctx, c) for c in alle)
|
||||
|
||||
|
||||
@@ -84,8 +84,13 @@ MERGE_PANEL = 2 # Judges pro Merge-Kandidat — Merge nur einstimmi
|
||||
MERGE_KANDIDAT_COS = 0.75 # Definitions-Ähnlichkeit ab der ein Paar zum Panel geht
|
||||
MERGE_KANDIDAT_JACCARD = 0.3 # Schwelle im lexikalischen Fallback (misst viel niedriger als Kosinus)
|
||||
LUECKEN_RUNDEN_MAX = 2 # gezielte Nachextraktion pro Soll-Lücke
|
||||
# braucht-Titel, die norm/kern nicht auflösen (Kompositum↔Expansion misst ~0.53 Cosinus,
|
||||
# Embedding schlägt sie NIE vor — Lektion 28): Substring-Prefilter → 1 Judge (n=1, reversibel).
|
||||
BRAUCHT_KANDIDATEN = 8 # Kandidaten-Cap je unaufgelöstem braucht-Titel (Lektion 34)
|
||||
|
||||
# ── Ebene 2: Artefakte ────────────────────────────────────────────────────────
|
||||
BEISPIEL_FORMEN = ("text", "mathe", "code", "tabelle", "diagramm") # Worked-Example-Formen
|
||||
CODE_SPRACHEN = ("python", "javascript") # Syntax-Gate parse-only (Container: python3, node)
|
||||
ARTEFAKT_CHUNK_ATOME = 8 # Atome pro Generate-Call (ein Soll-Punkt, gestückelt)
|
||||
VERIFY_PANEL = 2 # unabhängige Prüfer, einstimmig = verifiziert
|
||||
|
||||
@@ -102,15 +107,15 @@ BAUSTEIN_MAX_ATOME = 8
|
||||
ZIELE_CHUNK_ATOME = 30 # Atome pro Lernziel-Call
|
||||
KAPITEL_BAUSTEINE = 5 # Richtwert Bausteine je Kapitel (Kapitel-Schnitt-Judge)
|
||||
|
||||
# ── Ebene 3.5: Diagramme ──────────────────────────────────────────────────────
|
||||
DIAGRAMM_MIN_KANTEN = 3 # ab so vielen internen braucht-Kanten → Abhängigkeitsdiagramm
|
||||
DIAGRAMM_TYPEN = ("dependency", "state", "flow", "tree")
|
||||
# ── Ebene 3.5: Diagramme (nur LLM: Automat/Ablauf/Baum — Term-DAGs waren nutzlos) ──
|
||||
DIAGRAMM_TYPEN = ("state", "flow", "tree")
|
||||
|
||||
# ── Ebene 4: Guide ────────────────────────────────────────────────────────────
|
||||
SECTION_WOERTER_PRO_ATOM = (40, 220) # Längenband ausführlicher Teil je Atom
|
||||
|
||||
# ── Auto-Loop ─────────────────────────────────────────────────────────────────
|
||||
LOOP_MAX_ITER = 10
|
||||
FIX_MAX_VERSUCHE = 3 # Section einfrieren nach so vielen Fixes OHNE Textänderung
|
||||
|
||||
# ── Timeouts je Schritt: (Basis-Sekunden, Sekunden pro Item) ─────────────────
|
||||
TIMEOUTS = {
|
||||
@@ -119,6 +124,7 @@ TIMEOUTS = {
|
||||
"soll_konsens": (300, 2),
|
||||
"extraktion": (450, 0),
|
||||
"merge": (150, 8),
|
||||
"braucht": (200, 0),
|
||||
"soll_zuordnung": (300, 8),
|
||||
"luecke": (450, 0),
|
||||
"artefakt": (450, 20),
|
||||
|
||||
@@ -105,7 +105,7 @@ CREATE INDEX IF NOT EXISTS idx_diagramme_baustein ON diagramme(baustein_id);
|
||||
CREATE TABLE IF NOT EXISTS sections(
|
||||
baustein_id INTEGER PRIMARY KEY, stage TEXT NOT NULL DEFAULT 'writer',
|
||||
text_kompakt TEXT DEFAULT '', text_lang TEXT DEFAULT '', befunde TEXT DEFAULT '[]',
|
||||
qa_hash TEXT DEFAULT '');
|
||||
qa_hash TEXT DEFAULT '', fix_versuche INTEGER NOT NULL DEFAULT 0);
|
||||
CREATE TABLE IF NOT EXISTS leitner(
|
||||
artefakt_id INTEGER PRIMARY KEY, box INTEGER NOT NULL DEFAULT 1,
|
||||
faellig TEXT DEFAULT (date('now')), historie TEXT DEFAULT '[]');
|
||||
@@ -133,7 +133,8 @@ def _init_schema(con: sqlite3.Connection) -> None:
|
||||
"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 '[]'",
|
||||
"ALTER TABLE quellen ADD COLUMN atome_stand TEXT NOT NULL DEFAULT ''"):
|
||||
"ALTER TABLE quellen ADD COLUMN atome_stand TEXT NOT NULL DEFAULT ''",
|
||||
"ALTER TABLE sections ADD COLUMN fix_versuche INTEGER NOT NULL DEFAULT 0"):
|
||||
try:
|
||||
con.execute(zusatz)
|
||||
except sqlite3.OperationalError:
|
||||
|
||||
@@ -18,7 +18,7 @@ from pathlib import Path
|
||||
|
||||
import db
|
||||
import llm
|
||||
from config import DIAGRAMM_MIN_KANTEN, DIAGRAMM_TYPEN, DURCHGANG, VERIFY_PANEL
|
||||
from config import DIAGRAMM_TYPEN, DURCHGANG, VERIFY_PANEL
|
||||
|
||||
log = logging.getLogger("creator2.diagramme")
|
||||
|
||||
@@ -61,26 +61,6 @@ def _atome_von(baustein_id: int) -> list[dict]:
|
||||
" ('gemerged','verworfen') ORDER BY ord", (baustein_id,))
|
||||
|
||||
|
||||
def _interne_kanten(topic: str, ids: set[int]) -> list[dict]:
|
||||
"""aktive braucht-Kanten, deren BEIDE Enden im Baustein liegen."""
|
||||
if len(ids) < 2:
|
||||
return []
|
||||
return [k for k in db.query(
|
||||
"SELECT * FROM kanten WHERE topic=? AND art='braucht' AND status='aktiv'", (topic,))
|
||||
if k["von_atom"] in ids and k["zu_atom"] in ids]
|
||||
|
||||
|
||||
# ── Spec (JSON-Zwischenrepräsentation) ────────────────────────────────────────
|
||||
|
||||
def _dag_spec(atome: list[dict], kanten: list[dict]) -> dict:
|
||||
"""Abhängigkeitsdiagramm direkt aus dem braucht-Graph. `von braucht zu` ⇒ Pfeil
|
||||
zu→von (Voraussetzung zeigt auf das, was sie ermöglicht). Kein LLM."""
|
||||
knoten = [{"id": str(a["id"]), "label": a["titel"]} for a in atome]
|
||||
kanten_spec = [{"von": str(k["zu_atom"]), "zu": str(k["von_atom"]), "label": ""}
|
||||
for k in kanten]
|
||||
return {"typ": "dependency", "knoten": knoten, "kanten": kanten_spec}
|
||||
|
||||
|
||||
# ── Kompilieren (deterministisch, kein LLM) ───────────────────────────────────
|
||||
|
||||
def _sid(raw) -> str:
|
||||
@@ -129,21 +109,6 @@ def _kompilieren(topic: str) -> None:
|
||||
|
||||
# ── Planung (welche + Typ) ────────────────────────────────────────────────────
|
||||
|
||||
def _planen(topic: str) -> None:
|
||||
"""Deterministischer Zweig: Baustein mit ≥DIAGRAMM_MIN_KANTEN internen
|
||||
braucht-Kanten → Abhängigkeitsdiagramm aus dem DAG. Idempotent: Bausteine mit
|
||||
bestehender Diagramm-Zeile werden übersprungen. Kein LLM, null Halluzination."""
|
||||
for b in _bausteine(topic):
|
||||
if db.one("SELECT id FROM diagramme WHERE baustein_id=?", (b["id"],)):
|
||||
continue
|
||||
atome = _atome_von(b["id"])
|
||||
ids = {a["id"] for a in atome}
|
||||
kanten = _interne_kanten(topic, ids)
|
||||
if len(kanten) >= DIAGRAMM_MIN_KANTEN:
|
||||
db.insert("diagramme", topic=topic, baustein_id=b["id"], typ="dependency",
|
||||
quelle="dag", spec=db.j(_dag_spec(atome, kanten)), status="geplant")
|
||||
|
||||
|
||||
def _atom_liste(atome: list[dict]) -> str:
|
||||
return "\n".join(f"{a['id']}: {a['titel']} — {a['definition']}" for a in atome)
|
||||
|
||||
@@ -175,7 +140,7 @@ async def _plan_judge(ctx: llm.Kontext) -> None:
|
||||
if res is None:
|
||||
return # Ausfall → nächste Runde erneut (kein 'kein'-Eintrag)
|
||||
typ = str(res.get("typ", "")).strip()
|
||||
if res.get("noetig") and typ in DIAGRAMM_TYPEN and typ != "dependency":
|
||||
if res.get("noetig") and typ in DIAGRAMM_TYPEN:
|
||||
db.insert("diagramme", topic=ctx.topic, baustein_id=b["id"], typ=typ,
|
||||
quelle="llm", status="geplant")
|
||||
else:
|
||||
@@ -254,16 +219,11 @@ async def _verifizieren(ctx: llm.Kontext) -> None:
|
||||
|
||||
async def bauen(ctx: llm.Kontext) -> None:
|
||||
ctx.ebene = EBENE
|
||||
_planen(ctx.topic) # welche/Typ: deterministisch (DAG)
|
||||
await _plan_judge(ctx) # welche/Typ: Judge (Automaten/Abläufe/Bäume)
|
||||
await _plan_judge(ctx) # welche/Typ: Judge (Automaten/Abläufe/Bäume) — kein DAG mehr
|
||||
await _spec_bauen(ctx) # LLM → Struktur-JSON (kein rohes Mermaid)
|
||||
_kompilieren(ctx.topic) # Spec → Mermaid (deterministisch)
|
||||
_gate_kandidaten(ctx.topic) # Parse-Gate: ungültige Mermaid-Quelle → verworfen
|
||||
# dag-Diagramme sind per Konstruktion gegroundet → nach bestandenem Gate verifiziert
|
||||
for d in db.query("SELECT id FROM diagramme WHERE topic=? AND status='kandidat'"
|
||||
" AND quelle='dag'", (ctx.topic,)):
|
||||
db.update("diagramme", "id", d["id"], status="verifiziert")
|
||||
await _verifizieren(ctx) # LLM-Diagramme: Grounding-Panel gegen die Atome
|
||||
await _verifizieren(ctx) # Grounding-Panel gegen die Atome
|
||||
|
||||
|
||||
def _gate_kandidaten(topic: str) -> None:
|
||||
|
||||
@@ -137,14 +137,24 @@ def _luecke(prompt: str):
|
||||
|
||||
|
||||
def _artefakt_generate(prompt: str):
|
||||
ids = [int(i) for i in re.findall(r"ATOM (\d+):", prompt)]
|
||||
# Form je Atom-Typ [in Klammern] wählen (wie die typgesteuerte Regel im Prompt)
|
||||
paare = re.findall(r"ATOM (\d+):[^\[\n]*\[(\w+)\]", prompt)
|
||||
out = []
|
||||
for i in ids:
|
||||
for raw, typ in paare:
|
||||
i = int(raw)
|
||||
out += [{"atom": i, "typ": "flashcard", "frage": f"Was besagt Atom {i}?",
|
||||
"antwort": "Siehe Beleg — deterministische Fake-Antwort."},
|
||||
{"atom": i, "typ": "flashcard", "frage": f"Wozu dient Atom {i}?",
|
||||
"antwort": "Zum Lernen — deterministische Fake-Antwort."},
|
||||
{"atom": i, "typ": "beispiel", "text": f"Beispiel zu Atom {i}: Schritt 1, Schritt 2."}]
|
||||
"antwort": "Zum Lernen — deterministische Fake-Antwort."}]
|
||||
if typ == "verfahren":
|
||||
out.append({"atom": i, "typ": "beispiel", "form": "code",
|
||||
"sprache": "python", "code": f"print({i} + 1)"})
|
||||
elif typ == "begriff":
|
||||
out.append({"atom": i, "typ": "beispiel", "form": "tabelle",
|
||||
"tabelle": f"| Fall | Ergebnis |\n|---|---|\n| Atom {i} | passt |"})
|
||||
else: # aussage → Text-Beispiel
|
||||
out.append({"atom": i, "typ": "beispiel", "form": "text",
|
||||
"text": f"Beispiel zu Atom {i}: Schritt 1, Schritt 2."})
|
||||
return out
|
||||
|
||||
|
||||
@@ -196,24 +206,40 @@ def _zyklus(prompt: str):
|
||||
return {"von": int(m.group(1)), "zu": int(m.group(2))} if m else {}
|
||||
|
||||
|
||||
def _braucht_aufloesung(prompt: str):
|
||||
# je ANGABE den Kandidaten mit den meisten geteilten Tokens (Substring, ≥4 Zeichen)
|
||||
# wählen, sonst null — spiegelt den Substring-Prefilter der Auflösung.
|
||||
def toks(s):
|
||||
return {t for t in re.findall(r"\w+", s.lower()) if len(t) >= 4}
|
||||
out = []
|
||||
bloecke = re.split(r"ANGABE (\d+):", prompt)[1:]
|
||||
for i in range(0, len(bloecke), 2):
|
||||
n, body = int(bloecke[i]), bloecke[i + 1]
|
||||
mp = re.search(r"Voraussetzung „([^“]+)", body)
|
||||
ph = mp.group(1) if mp else ""
|
||||
pl, pt = ph.lower(), toks(ph)
|
||||
ranked = []
|
||||
for kid, ktitel in re.findall(r"- atom (\d+): (.+?) —", body):
|
||||
geteilt = {t for t in toks(ktitel) if t in pl} | {t for t in pt if t in ktitel.lower()}
|
||||
if geteilt:
|
||||
ranked.append((-len(geteilt), len(ktitel), int(kid)))
|
||||
ranked.sort()
|
||||
out.append({"phrase": n, "atom": ranked[0][2] if ranked else None})
|
||||
return out
|
||||
|
||||
|
||||
def _writer(prompt: str):
|
||||
ziel = re.search(r"Lernziel: (.+)", prompt)
|
||||
marker = re.findall(r"MARKER \(exakt so übernehmen\): (<!-- atom: \d+ \| (.+?) -->)",
|
||||
prompt)
|
||||
defs = re.findall(r"Definition: (.+)", prompt)
|
||||
fragen = re.findall(r"^- (.+\?)$", prompt, re.MULTILINE)[:2]
|
||||
lo = int(re.search(r"hat (\d+)–", prompt).group(1)) if re.search(r"hat (\d+)–", prompt) else 40
|
||||
absaetze = [f"Ziel dieser Einheit: {ziel.group(1) if ziel else 'Lernen'}. Formal: $n \\ge 0$."]
|
||||
fuellung = "Daran anknüpfend gehen wir das Schritt für Schritt am Beispiel durch. " * 5
|
||||
diag = re.findall(r"(<!-- diagramm: \d+ -->)", prompt) # angebotene Diagramme
|
||||
for n, ((mk, titel), d) in enumerate(zip(marker, defs)):
|
||||
block = f"#### {titel} verstehen\n{mk}\n{d} {fuellung}"
|
||||
if n == 0 and diag: # erstes Diagramm beim ersten Absatz platzieren
|
||||
block += f"\n\n{diag[0]}"
|
||||
absaetze.append(block)
|
||||
absaetze.append("**Kernpunkte:**\n" + "\n".join(f"- {t} sitzt" for _, t in marker))
|
||||
if fragen:
|
||||
absaetze.append("**Prüfe dich:**\n" + "\n".join(f"- {f}" for f in fragen))
|
||||
for (mk, titel), d in zip(marker, defs):
|
||||
# Beispiel-/Diagramm-Marker setzt jetzt _marker_platzieren deterministisch,
|
||||
# nicht mehr der Writer.
|
||||
absaetze.append(f"#### {titel} verstehen\n{mk}\n{d} {fuellung}")
|
||||
lang = "\n\n".join(absaetze)
|
||||
while len(lang.split()) < lo:
|
||||
lang += "\nNoch ein Übungssatz zur Vertiefung des Gelernten."
|
||||
@@ -222,8 +248,8 @@ def _writer(prompt: str):
|
||||
|
||||
|
||||
def _diagramm_plan(prompt: str):
|
||||
# deterministisch: die Fake-Welt braucht kein LLM-Diagramm (DAG-Pfad wird
|
||||
# getestet). Unit-Tests für den LLM-Zweig monkeypatchen diesen Handler.
|
||||
# deterministisch: die Fake-Welt braucht kein Diagramm. Unit-Tests für den
|
||||
# LLM-Diagramm-Zweig monkeypatchen diesen Handler auf noetig=True.
|
||||
return {"noetig": False, "typ": ""}
|
||||
|
||||
|
||||
@@ -269,7 +295,7 @@ _HANDLER = {
|
||||
"Artefakt-Generate": _artefakt_generate, "Artefakt-Verify": _artefakt_verify,
|
||||
"Artefakt-Fix": _artefakt_fix, "Lernziele": _lernziele, "Kapitel-Intro": _kapitel_intro,
|
||||
"Kapitel-Schnitt": _kapitel_schnitt, "Level-Kalibrierung": _level_kalibrierung,
|
||||
"Kanten-Zyklus": _zyklus,
|
||||
"Kanten-Zyklus": _zyklus, "Braucht-Aufloesung": _braucht_aufloesung,
|
||||
"Guide-Writer": _writer, "Guide-Pruefer": _pruefer, "Guide-Fix": _fix,
|
||||
"QA-Guide-Falsch": _qa_falsch, "QA-Guide-Falsch-Check": _qa_falsch_check,
|
||||
"Diagramm-Plan": _diagramm_plan, "Diagramm-Spec": _diagramm_spec,
|
||||
|
||||
207
backend/guide.py
207
backend/guide.py
@@ -15,9 +15,10 @@ import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import db
|
||||
import jsonx
|
||||
import llm
|
||||
import textkit
|
||||
from config import DURCHGANG, LEVEL_RANG, SECTION_WOERTER_PRO_ATOM
|
||||
from config import DURCHGANG, FIX_MAX_VERSUCHE, LEVEL_RANG, SECTION_WOERTER_PRO_ATOM
|
||||
|
||||
log = logging.getLogger("creator2.guide")
|
||||
|
||||
@@ -55,11 +56,15 @@ _MARKER = re.compile(r"<!--\s*atom:\s*(\d+)\s*\|")
|
||||
# einzeilig, also kein DOTALL). Muster spiegelt frontend/src/markdown.js.
|
||||
_MARKER_VOLL = re.compile(r"<!--\s*atom:.*?-->")
|
||||
_MARKER_DIAGRAMM = re.compile(r"<!--\s*diagramm:\s*(\d+)\s*-->")
|
||||
_MARKER_BEISPIEL = re.compile(r"<!--\s*beispiel:\s*(\d+)\s*-->")
|
||||
|
||||
# Kurzbeschreibung je Diagramm-Typ (Writer-Angebot)
|
||||
_DIAGRAMM_BESCHR = {"dependency": "Abhängigkeitsgraph der Begriffe dieses Bausteins",
|
||||
"state": "Zustandsdiagramm/Automat", "flow": "Ablaufdiagramm",
|
||||
"tree": "Baumstruktur"}
|
||||
_BEISPIEL_BESCHR = {"code": "Code-Beispiel", "tabelle": "Tabelle/Vergleich",
|
||||
"diagramm": "Diagramm-Beispiel"}
|
||||
_VERBATIM_FORMEN = ("code", "tabelle", "diagramm")
|
||||
|
||||
|
||||
def _ohne_marker(text: str) -> str:
|
||||
@@ -67,8 +72,77 @@ def _ohne_marker(text: str) -> str:
|
||||
ALLE Text-Checks (Stil, Mathe, Länge, Vorwärtsverweise) laufen auf dem
|
||||
gestrippten Text — sonst erzeugen Atom-TITEL wie „a_n" oder „Satz 6.26"
|
||||
unfixierbare Aufträge (der Fix darf Marker nie ändern → Endlos-Loop).
|
||||
Diagramm-Marker gehören ebenso raus."""
|
||||
return _MARKER_DIAGRAMM.sub("", _MARKER_VOLL.sub("", text))
|
||||
Diagramm- und Beispiel-Marker gehören ebenso raus."""
|
||||
return _MARKER_BEISPIEL.sub("", _MARKER_DIAGRAMM.sub("", _MARKER_VOLL.sub("", text)))
|
||||
|
||||
|
||||
def _beispiel_verbatim(atom_id: int) -> list[tuple[int, dict]]:
|
||||
"""Verifizierte Beispiele des Atoms in VERBATIM-Form (code/tabelle/diagramm) —
|
||||
(artefakt_id, inhalt). Woven-Formen (text/mathe) gehen über `_beispiel`."""
|
||||
out = []
|
||||
for r in db.query("SELECT id, inhalt FROM artefakte WHERE atom_id=? AND typ='beispiel'"
|
||||
" AND status='verifiziert'", (atom_id,)):
|
||||
inh = db.uj(r["inhalt"], {})
|
||||
if inh.get("form") in _VERBATIM_FORMEN:
|
||||
out.append((r["id"], inh))
|
||||
return out
|
||||
|
||||
|
||||
def _beispiel_render(inh: dict) -> str:
|
||||
"""Verbatim-Beispiel → Markdown (Code-Fence / Tabelle / Mermaid-Fence)."""
|
||||
form = inh.get("form")
|
||||
if form == "code" and inh.get("code"):
|
||||
return f"```{inh.get('sprache', '')}\n{inh['code']}\n```"
|
||||
if form == "tabelle" and inh.get("tabelle"):
|
||||
return inh["tabelle"]
|
||||
if form == "diagramm" and inh.get("mermaid"):
|
||||
return f"```mermaid\n{inh['mermaid']}\n```"
|
||||
return ""
|
||||
|
||||
|
||||
def _marker_platzieren(b: dict, lang: str) -> str:
|
||||
"""Verbatim-Marker DETERMINISTISCH setzen (der Writer tut es nicht mehr — er
|
||||
ignorierte die Regel, 2/13 platziert). Beispiel-Marker hinter den Atom-Absatz
|
||||
(atom-genau via Atom-Marker); Diagramme (baustein-scoped) ans Section-Ende.
|
||||
Idempotent: alte Verbatim-Marker erst raus."""
|
||||
lang = _MARKER_BEISPIEL.sub("", _MARKER_DIAGRAMM.sub("", lang))
|
||||
je_atom = {}
|
||||
for a in _atome_von(b["id"]):
|
||||
vs = _beispiel_verbatim(a["id"])
|
||||
if vs:
|
||||
je_atom[a["id"]] = vs[0][0] # erstes Verbatim-Beispiel des Atoms
|
||||
if je_atom:
|
||||
out, pending = [], None
|
||||
for z in lang.split("\n"):
|
||||
out.append(z)
|
||||
m = _MARKER.search(z)
|
||||
if m and int(m.group(1)) in je_atom:
|
||||
pending = je_atom.pop(int(m.group(1)))
|
||||
elif pending is not None and z.strip() == "": # Absatzende → Marker als Block
|
||||
out += [f"<!-- beispiel: {pending} -->", ""]
|
||||
pending = None
|
||||
if pending is not None:
|
||||
out += ["", f"<!-- beispiel: {pending} -->"]
|
||||
lang = "\n".join(out)
|
||||
diags = _diagramme_von(b["id"])
|
||||
if diags:
|
||||
block = "\n".join(f"<!-- diagramm: {d['id']} -->" for d in diags)
|
||||
lang = lang.rstrip() + "\n\n" + block
|
||||
return lang
|
||||
|
||||
|
||||
def _beispiel_einsetzen(baustein_id: int, lang: str) -> str:
|
||||
"""Assembly: `<!-- beispiel: id -->` → gerendertes Verbatim-Beispiel; tote Marker weg."""
|
||||
if "<!-- beispiel:" not in lang and "<!--beispiel:" not in lang:
|
||||
return lang
|
||||
je_id = {aid: inh for a in _atome_von(baustein_id)
|
||||
for aid, inh in _beispiel_verbatim(a["id"])}
|
||||
|
||||
def sub(m: re.Match) -> str:
|
||||
inh = je_id.get(int(m.group(1)))
|
||||
return _beispiel_render(inh) if inh else ""
|
||||
|
||||
return _MARKER_BEISPIEL.sub(sub, lang)
|
||||
|
||||
|
||||
def _diagramme_von(baustein_id: int) -> list[dict]:
|
||||
@@ -76,13 +150,6 @@ def _diagramme_von(baustein_id: int) -> list[dict]:
|
||||
" ORDER BY id", (baustein_id,))
|
||||
|
||||
|
||||
def _diagramm_angebot(baustein_id: int) -> str:
|
||||
"""Verfügbare Diagramme für den Writer — er platziert den Marker beim Absatz."""
|
||||
zeilen = [f"<!-- diagramm: {d['id']} --> — {_DIAGRAMM_BESCHR.get(d['typ'], d['typ'])}"
|
||||
for d in _diagramme_von(baustein_id)]
|
||||
return "\n".join(zeilen) or "(keine)"
|
||||
|
||||
|
||||
def _diagramm_einsetzen(baustein_id: int, lang: str) -> str:
|
||||
"""Assembly: `<!-- diagramm: id -->` → ```mermaid-Fence des verifizierten
|
||||
Diagramms. Tote/unverifizierte Marker fallen ersatzlos weg."""
|
||||
@@ -117,15 +184,14 @@ def _zitate(atom_id: int, max_n: int = 3) -> list[str]:
|
||||
|
||||
|
||||
def _beispiel(atom_id: int) -> str:
|
||||
"""Nur die WOVEN-Formen (text/mathe) als Prosa für den Writer. Verbatim-Formen
|
||||
(code/tabelle/diagramm) kommen über den `<!-- beispiel: id -->`-Marker rein."""
|
||||
row = db.one("SELECT inhalt FROM artefakte WHERE atom_id=? AND typ='beispiel'"
|
||||
" AND status='verifiziert' LIMIT 1", (atom_id,))
|
||||
return db.uj(row["inhalt"], {}).get("text", "") if row else ""
|
||||
|
||||
|
||||
def _fragen_pool(atom_id: int, max_n: int = 2) -> list[str]:
|
||||
rows = db.query("SELECT inhalt FROM artefakte WHERE atom_id=? AND typ='flashcard'"
|
||||
" AND status='verifiziert' LIMIT ?", (atom_id, max_n))
|
||||
return [f for r in rows if (f := db.uj(r["inhalt"], {}).get("frage", "").strip())]
|
||||
if not row:
|
||||
return ""
|
||||
inh = db.uj(row["inhalt"], {})
|
||||
return inh.get("text", "") if inh.get("form", "text") in ("text", "mathe") else ""
|
||||
|
||||
|
||||
def _atom_paket(a: dict) -> str:
|
||||
@@ -135,9 +201,6 @@ def _atom_paket(a: dict) -> str:
|
||||
f"Definition: {a['definition']}\nBelege (VERBATIM zitierbar):\n{zitate}")
|
||||
if beispiel:
|
||||
teil += f"\nVerifiziertes Beispiel (Werte wörtlich übernehmen):\n{beispiel}"
|
||||
fragen = _fragen_pool(a["id"])
|
||||
if fragen:
|
||||
teil += "\nFRAGEN-POOL:\n" + "\n".join(f"- {f}" for f in fragen)
|
||||
return teil
|
||||
|
||||
|
||||
@@ -170,6 +233,25 @@ def _anknuepf_kontext(b: dict) -> str:
|
||||
|
||||
# ── Stages ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _split_writer(text: str) -> tuple[str, str]:
|
||||
"""Delimited-Ausgabe des Writers/Fix parsen: ===LANG===… / ===KOMPAKT===….
|
||||
Grund: rohe LaTeX-Backslashes im Text sind invalides JSON (34 % Parse-Fehler).
|
||||
Robust: Blöcke unabhängig von der Reihenfolge; fehlt der Delimiter, jsonx-Fallback
|
||||
(alt-JSON), sonst ganzer Text = lang."""
|
||||
def block(name: str) -> str | None:
|
||||
m = re.search(rf"===\s*{name}\s*===\s*(.*?)(?=\n===\s*[A-ZÄÖÜ]+\s*===|$)",
|
||||
text or "", re.S)
|
||||
return m.group(1).strip() if m else None
|
||||
|
||||
lang, kompakt = block("LANG"), block("KOMPAKT")
|
||||
if lang is not None or kompakt is not None:
|
||||
return kompakt or "", lang or ""
|
||||
daten = jsonx.parse(text or "") # Fallback: altes JSON-Format
|
||||
if isinstance(daten, dict):
|
||||
return str(daten.get("kompakt", "")), str(daten.get("lang", ""))
|
||||
return "", (text or "").strip()
|
||||
|
||||
|
||||
async def _stage_writer(ctx: llm.Kontext, b: dict) -> str:
|
||||
atome = _atome_von(b["id"])
|
||||
ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],)) or {"text": b["titel"]}
|
||||
@@ -178,20 +260,22 @@ async def _stage_writer(ctx: llm.Kontext, b: dict) -> str:
|
||||
"atome": "\n\n".join(_atom_paket(a) for a in atome),
|
||||
"min_woerter": lo, "max_woerter": hi,
|
||||
"durchgang": DURCHGANG.get(b["level"], DURCHGANG["M"]),
|
||||
"kontext": _anknuepf_kontext(b) or "(nichts — erster Durchgang)",
|
||||
"diagramme": _diagramm_angebot(b["id"])}
|
||||
"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=str)
|
||||
if res is None:
|
||||
return "writer" # Stage bleibt, nächster Lauf versucht erneut
|
||||
kompakt, lang = str(res.get("kompakt", "")), str(res.get("lang", ""))
|
||||
kompakt, lang = _split_writer(res)
|
||||
fehlend = _marker_fehlend(lang, atome)
|
||||
if fehlend: # ein gezielter Zweitversuch mit explizitem Mangel
|
||||
res2 = await llm.call(ctx, stage="writer2", template="Guide-Writer",
|
||||
schritt="writer", role="guide", n=len(atome),
|
||||
item=f"b{b['id']}-2", werte=werte, erwartet=dict)
|
||||
if res2 and not _marker_fehlend(str(res2.get("lang", "")), atome):
|
||||
kompakt, lang = str(res2.get("kompakt", "")), str(res2.get("lang", ""))
|
||||
item=f"b{b['id']}-2", werte=werte, erwartet=str)
|
||||
if res2:
|
||||
k2, l2 = _split_writer(res2)
|
||||
if not _marker_fehlend(l2, atome):
|
||||
kompakt, lang = k2, l2
|
||||
lang = _marker_platzieren(b, lang) # Beispiel-/Diagramm-Marker deterministisch setzen
|
||||
db.update("sections", "baustein_id", b["id"], text_kompakt=kompakt, text_lang=lang)
|
||||
return "pruefer"
|
||||
|
||||
@@ -378,7 +462,9 @@ def _vorwaertsverweise(topic: str, b: dict, lang: str) -> list[str]:
|
||||
if je_baustein.get(a["bid"], 0) <= mein_kapitel:
|
||||
continue
|
||||
t = textkit.norm(a["titel"])
|
||||
if len(t) < 6 and len(t.split()) < 2:
|
||||
# nur SIGNIFIKANTE Titel flaggen: ≥2 Token UND ≥8 Zeichen. Kurze Fachwörter
|
||||
# (oft Homonyme/gängige Wörter) produzierten unschließbare Dauer-Flags.
|
||||
if len(t.split()) < 2 or len(t) < 8:
|
||||
continue
|
||||
if re.search(rf"(?<![a-zäöüß0-9]){re.escape(t)}(?![a-zäöüß0-9])", text):
|
||||
treffer.append(a["titel"])
|
||||
@@ -428,13 +514,14 @@ async def _stage_fix(ctx: llm.Kontext, b: dict) -> str:
|
||||
werte={"kompakt": sec["text_kompakt"], "lang": sec["text_lang"],
|
||||
"auftraege": "\n".join(f"- {a}" for a in auftraege),
|
||||
"fakten": fakten},
|
||||
erwartet=dict)
|
||||
erwartet=str)
|
||||
kritisch = any(a.startswith("KRITISCH") for a in auftraege)
|
||||
if res:
|
||||
lang = str(res.get("lang", ""))
|
||||
kompakt, lang = _split_writer(res)
|
||||
if not _marker_fehlend(lang, atome): # Fix darf die Marker-Invariante nie brechen
|
||||
lang = _marker_platzieren(b, lang) # Marker nach dem Rewrite neu setzen
|
||||
db.update("sections", "baustein_id", b["id"],
|
||||
text_kompakt=str(res.get("kompakt", "")), text_lang=lang, befunde=db.j([]))
|
||||
text_kompakt=kompakt, text_lang=lang, befunde=db.j([]))
|
||||
if kritisch: # genau EIN Re-Check; Rest fängt die Ebenen-QA
|
||||
await _stage_pruefer(ctx, b, tag="-re")
|
||||
return "done"
|
||||
@@ -496,23 +583,6 @@ async def bauen(ctx: llm.Kontext) -> None:
|
||||
await _kapitel_intros(ctx)
|
||||
|
||||
|
||||
def _pruefe_dich(baustein_id: int, lang: str) -> list[dict]:
|
||||
"""Antworten zu den „Prüfe dich"-Fragen: die Fragen stammen wörtlich aus dem
|
||||
Flashcard-Pool — Matching gegen den Langtext liefert die passenden Karten
|
||||
(Retrieval mit Feedback wirkt ~doppelt so stark wie ohne)."""
|
||||
out = []
|
||||
lang_norm = textkit.norm(lang) # Case/Whitespace-tolerant — ein Stil-Fix an der
|
||||
for a in _atome_von(baustein_id): # Frage ließ das rohe Substring-Matching scheitern
|
||||
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 textkit.norm(frage) in lang_norm:
|
||||
out.append({"frage": frage, "antwort": antwort})
|
||||
return out
|
||||
|
||||
|
||||
def kapitel_struktur(topic: str) -> list[dict]:
|
||||
"""Kapitel (Struktur-Ebene) entlang der Baustein-Ordnung; die Segmente sind
|
||||
kontiguierlich, also wechselt H2 genau an den Kapitel-Grenzen."""
|
||||
@@ -534,15 +604,16 @@ def kapitel_struktur(topic: str) -> list[dict]:
|
||||
"titel": k.get("titel", "Weitere Themen"),
|
||||
"intro": k.get("intro", ""),
|
||||
"level": k.get("level", b["level"]), "sections": []})
|
||||
# Diagramm-Marker gegen den ```mermaid-Fence tauschen (Assembly-Schritt);
|
||||
# Diagramm- und Beispiel-Marker gegen ihre Fences/Tabellen tauschen (Assembly);
|
||||
# Atom-Marker bleiben — die strippt erst das Frontend.
|
||||
lang = _diagramm_einsetzen(b["id"], sec["text_lang"]) if sec else ""
|
||||
lang = ""
|
||||
if sec:
|
||||
lang = _beispiel_einsetzen(b["id"], _diagramm_einsetzen(b["id"], sec["text_lang"]))
|
||||
kapitel[-1]["sections"].append({
|
||||
"baustein": b["id"], "titel": b["titel"],
|
||||
"ziel": ziele.get(b["ziel_id"], {}).get("text", ""),
|
||||
"kompakt": sec["text_kompakt"] if sec else "",
|
||||
"lang": lang,
|
||||
"fragen": _pruefe_dich(b["id"], lang) if lang else []})
|
||||
"lang": lang})
|
||||
# NULL-kapitel_id durch eindeutige synthetische Keys ersetzen — mehrere
|
||||
# kapitellose Gruppen kollidierten sonst im Vue-:key (null == null).
|
||||
for i, kap in enumerate(kapitel):
|
||||
@@ -593,6 +664,12 @@ def messen(ctx: llm.Kontext) -> list[dict]:
|
||||
befunde.append({"art": "diagramm_marker_tot", "item": str(b["id"]),
|
||||
"detail": f"Stray Diagramm-Marker <!-- diagramm: {m} -->"
|
||||
f" entfernen (kein solches verifiziertes Diagramm)"})
|
||||
gueltige_b = {aid for a in atome for aid, _ in _beispiel_verbatim(a["id"])}
|
||||
for m in _MARKER_BEISPIEL.findall(lang):
|
||||
if int(m) not in gueltige_b: # Marker ohne verifiziertes Verbatim-Beispiel
|
||||
befunde.append({"art": "beispiel_marker_tot", "item": str(b["id"]),
|
||||
"detail": f"Stray Beispiel-Marker <!-- beispiel: {m} -->"
|
||||
f" entfernen (kein solches verifiziertes Beispiel)"})
|
||||
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]})
|
||||
@@ -611,7 +688,7 @@ def messen(ctx: llm.Kontext) -> list[dict]:
|
||||
befunde.append({"art": "ziel_ohne_anker", "item": str(b["id"]),
|
||||
"detail": ziel["text"][:120]})
|
||||
lo, hi = _laenge_band(len(atome))
|
||||
w = len(lang.split())
|
||||
w = len(_ohne_marker(lang).split()) # marker-frei — konsistent mit dem Fix-Band
|
||||
if not lo <= w <= hi * 1.25: # QA-Band weiter als das Fix-Band (Lektion 75)
|
||||
befunde.append({"art": "laenge", "item": str(b["id"]), "detail": f"{w} Wörter"})
|
||||
for t in _vorwaertsverweise(ctx.topic, b, lang):
|
||||
@@ -703,12 +780,21 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
|
||||
betroffen.setdefault(b_id, []).append(b)
|
||||
if not betroffen:
|
||||
return False
|
||||
geroutet: list[int] = []
|
||||
for b_id, liste in betroffen.items():
|
||||
arten = [x["art"] for x in liste]
|
||||
# ein fix_offen-Detail mit KRITISCH-Präfix ist ebenfalls kritisch
|
||||
kritisch_detail = any(x["art"] == "fix_offen"
|
||||
and str(x["detail"]).startswith("KRITISCH") for x in liste)
|
||||
if any(a in KRITISCH or a == "section_fehlt" for a in arten) or kritisch_detail:
|
||||
kritisch = any(a in KRITISCH or a == "section_fehlt" for a in arten) or kritisch_detail
|
||||
if not kritisch:
|
||||
# Fix-Cap: eine Section, deren Fix K-mal nichts geändert hat, wird
|
||||
# eingefroren — kein weiteres Routing der Stil-/Längen-/vorwaerts-
|
||||
# Befunde. Rest-Schuld (Gewicht 0.5) akzeptiert; bremst den 617-Churn.
|
||||
sec = db.one("SELECT fix_versuche FROM sections WHERE baustein_id=?", (b_id,))
|
||||
if sec and sec["fix_versuche"] >= FIX_MAX_VERSUCHE:
|
||||
continue
|
||||
if kritisch:
|
||||
if "section_fehlt" in arten:
|
||||
db.execute("UPDATE sections SET stage='writer' WHERE baustein_id=?", (b_id,))
|
||||
else:
|
||||
@@ -730,6 +816,15 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
|
||||
auftraege = list(dict.fromkeys(vorhanden + neu))
|
||||
db.update("sections", "baustein_id", b_id, stage="fix", befunde=db.j(auftraege))
|
||||
db.update("bausteine", "id", b_id, status="repair")
|
||||
vorher = {b_id: _text_sig(b_id) for b_id in betroffen}
|
||||
geroutet.append(b_id)
|
||||
vorher = {b_id: _text_sig(b_id) for b_id in geroutet}
|
||||
await bauen(ctx)
|
||||
return any(vorher[b_id] != _text_sig(b_id) for b_id in betroffen)
|
||||
bewegt = False
|
||||
for b_id in geroutet: # Fix-Cap-Zähler pflegen: Änderung → reset, sonst +1
|
||||
if _text_sig(b_id) != vorher[b_id]:
|
||||
db.execute("UPDATE sections SET fix_versuche=0 WHERE baustein_id=?", (b_id,))
|
||||
bewegt = True
|
||||
else:
|
||||
db.execute("UPDATE sections SET fix_versuche=fix_versuche+1 WHERE baustein_id=?",
|
||||
(b_id,))
|
||||
return bewegt
|
||||
|
||||
@@ -14,8 +14,8 @@ import embedding
|
||||
import korpus
|
||||
import llm
|
||||
import textkit
|
||||
from config import (ANKER_OVERLAP_MERGE, LUECKEN_RUNDEN_MAX, MERGE_KANDIDAT_COS,
|
||||
MERGE_KANDIDAT_JACCARD,
|
||||
from config import (ANKER_OVERLAP_MERGE, BRAUCHT_KANDIDATEN, LUECKEN_RUNDEN_MAX,
|
||||
MERGE_KANDIDAT_COS, MERGE_KANDIDAT_JACCARD,
|
||||
MERGE_PANEL, READER_JE_ABSCHNITT, ZIELE_CHUNK_ATOME)
|
||||
|
||||
log = logging.getLogger("creator2.inventar")
|
||||
@@ -266,6 +266,101 @@ def _kanten_aufloesen(topic: str) -> None:
|
||||
" VALUES(?,?,?,'braucht')", (topic, a["id"], ziel))
|
||||
|
||||
|
||||
def _braucht_unaufgeloest(atome: list[dict]) -> list[tuple[dict, str]]:
|
||||
"""(Atom, braucht-Titel), die _kanten_aufloesen (norm/kern) NICHT traf. Selbe
|
||||
Auflösungslogik — was sie fand, ist erledigt und bleibt außen vor."""
|
||||
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"])
|
||||
offen = []
|
||||
for a in atome:
|
||||
for titel in db.uj(a["braucht"]):
|
||||
ziel = je_norm.get(textkit.norm(titel))
|
||||
if not ziel and (kern := textkit.titel_kern(titel)):
|
||||
ziel = je_kern.get(kern)
|
||||
if not ziel or ziel == a["id"]:
|
||||
offen.append((a, titel))
|
||||
return offen
|
||||
|
||||
|
||||
def _trigramm(s: str) -> set[str]:
|
||||
s = textkit.norm(s)
|
||||
return {s[i:i + 3] for i in range(len(s) - 2)} or {s}
|
||||
|
||||
|
||||
def _tri_dice(a: str, b: str) -> float:
|
||||
"""Zeichen-Trigramm-Dice — fuzzy Titel-Ähnlichkeit, die geteilte Wortstämme UND
|
||||
Kopfnomen erfasst („Approximationsalgorithmus" ~ „Approximativer Algorithmus")."""
|
||||
A, B = _trigramm(a), _trigramm(b)
|
||||
return 2 * len(A & B) / (len(A) + len(B)) if A or B else 0.0
|
||||
|
||||
|
||||
def _braucht_kandidaten(titel: str, atome: list[dict], selbst_id: int, cap: int) -> list[dict]:
|
||||
"""Expliziter Kandidatengenerator für Kompositum↔Expansion (Lektion 28: Cosinus
|
||||
~0.53, Embedding schlägt sie nie vor). Zulassung (hohe Trefferquote): ein
|
||||
signifikantes Token (≥5 Zeichen) ist Substring des anderen normierten Titels
|
||||
(„algorithmus" ⊂ „approximationsalgorithmus"). Rang nach Trigramm-Dice — der
|
||||
ganze Titel zählt, nicht nur das Kopfnomen, so steht der Grundbegriff vor
|
||||
generischen Distraktoren („Algorithmus"). Der Judge trennt dann fein."""
|
||||
nt = textkit.norm(titel)
|
||||
bt = {t for t in textkit.tokens(titel) if len(t) >= 5}
|
||||
treffer = []
|
||||
for a in atome:
|
||||
if a["id"] == selbst_id:
|
||||
continue
|
||||
na = textkit.norm(a["titel"])
|
||||
at = {t for t in textkit.tokens(a["titel"]) if len(t) >= 5}
|
||||
if {t for t in at if t in nt} | {t for t in bt if t in na}:
|
||||
treffer.append((_tri_dice(titel, a["titel"]), a["id"], a))
|
||||
treffer.sort(key=lambda x: (-x[0], x[1]))
|
||||
return [a for _, _, a in treffer[:cap]]
|
||||
|
||||
|
||||
async def _braucht_fallback(ctx: llm.Kontext) -> None:
|
||||
"""braucht-Titel, die norm/kern nicht auflösten, per Judge auf Atome mappen.
|
||||
Deterministischer Substring-Prefilter (Kandidaten) → EIN Judge (n=1) als
|
||||
Präzisions-Gate. braucht-Kanten sind reversibel/billiger als Merges → kein Panel
|
||||
(Lektion 78). Im Zweifel null: eine Falsch-Kante verschiebt Ordnung + Level.
|
||||
Idempotent (INSERT OR IGNORE); läuft nur in bauen, damit ein Bausteine-Reset ihn
|
||||
ohne Re-Extraktion mitnimmt."""
|
||||
topic = ctx.topic
|
||||
atome = aktive_atome(topic)
|
||||
offen = _braucht_unaufgeloest(atome)
|
||||
if not offen:
|
||||
return
|
||||
angaben = [(a, titel, kand) for a, titel in offen
|
||||
if (kand := _braucht_kandidaten(titel, atome, a["id"], BRAUCHT_KANDIDATEN))]
|
||||
if not angaben:
|
||||
log.info("braucht-Fallback: %d unaufgelöste Titel, keine Kandidaten", len(offen))
|
||||
return
|
||||
geloest = 0
|
||||
|
||||
async def chunk_loesen(chunk: list) -> None:
|
||||
nonlocal geloest
|
||||
liste = "\n\n".join(
|
||||
f"ANGABE {n}: Voraussetzung „{titel}“ (gebraucht von Atom {a['id']}"
|
||||
f" „{a['titel']}“)\nKandidaten:\n"
|
||||
+ "\n".join(f" - atom {k['id']}: {k['titel']} — {k['definition']}" for k in kand)
|
||||
for n, (a, titel, kand) in enumerate(chunk, 1))
|
||||
res = await llm.call(ctx, stage="braucht", template="Braucht-Aufloesung",
|
||||
werte={"angaben": liste}, role="judge",
|
||||
n=len(chunk), item=f"br{chunk[0][0]['id']}", erwartet=list)
|
||||
wahl = {e.get("phrase"): e.get("atom") for e in res or [] if isinstance(e, dict)}
|
||||
for n, (a, titel, kand) in enumerate(chunk, 1):
|
||||
zid = wahl.get(n)
|
||||
if zid in {k["id"] for k in kand} and zid != a["id"]:
|
||||
db.execute("INSERT OR IGNORE INTO kanten(topic, von_atom, zu_atom, art)"
|
||||
" VALUES(?,?,?,'braucht')", (topic, a["id"], zid))
|
||||
geloest += 1
|
||||
|
||||
chunks = [angaben[i:i + PAAR_CHUNK] for i in range(0, len(angaben), PAAR_CHUNK)]
|
||||
await llm.alle(chunk_loesen(c) for c in chunks)
|
||||
log.info("braucht-Fallback: %d/%d Titel aufgelöst (%d ohne Kandidaten)",
|
||||
geloest, len(offen), len(offen) - len(angaben))
|
||||
|
||||
|
||||
# ── Soll-Zuordnung ────────────────────────────────────────────────────────────
|
||||
|
||||
async def _soll_zuordnen(ctx: llm.Kontext, nur_offene: bool = True) -> None:
|
||||
|
||||
@@ -36,6 +36,7 @@ GEWICHTE = {
|
||||
"det_check": 0.5, "fix_offen": 1.5,
|
||||
"diagramm_parse_fehler": 3.0, "diagramm_ungegroundet": 3.0,
|
||||
"diagramm_unverifiziert": 0.5, "diagramm_marker_tot": 3.0,
|
||||
"beispiel_marker_tot": 3.0,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import logging
|
||||
from collections import defaultdict
|
||||
|
||||
import db
|
||||
import inventar
|
||||
import llm
|
||||
from config import (BAUSTEIN_MAX_ATOME, BAUSTEIN_MIN_ATOME, DURCHGANG,
|
||||
KAPITEL_BAUSTEINE, LEVEL_RANG, ZIELE_CHUNK_ATOME)
|
||||
@@ -465,6 +466,8 @@ async def _kapitel_bilden(ctx: llm.Kontext) -> None:
|
||||
async def bauen(ctx: llm.Kontext) -> None:
|
||||
ctx.ebene = EBENE
|
||||
await _ziele_bilden(ctx)
|
||||
await inventar._braucht_fallback(ctx) # Kompositum↔Expansion-braucht-Titel nachziehen,
|
||||
# bevor Zyklen/Level auf dem Graph rechnen
|
||||
await _zyklen_brechen(ctx)
|
||||
await _level_kalibrieren(ctx) # VOR dem Schnitt: Level bestimmt die Bausteine
|
||||
_level_konflikte_loesen(ctx.topic)
|
||||
|
||||
Reference in New Issue
Block a user