This commit is contained in:
team3
2026-07-13 05:18:55 +02:00
parent 7ee425f7fa
commit 8926f87185
19 changed files with 703 additions and 41 deletions

View File

@@ -10,9 +10,10 @@ import re
import subprocess
import tempfile
import belege
import db
import llm
from config import ARTEFAKT_CHUNK_ATOME, BEISPIEL_FORMEN, VERIFY_PANEL
from config import ARTEFAKT_CHUNK_ATOME, AUSSAGEN_JE_ATOM, BEISPIEL_FORMEN, VERIFY_PANEL
log = logging.getLogger("creator2.artefakte")
@@ -39,8 +40,10 @@ def _referenziert_quelle(inhalt: dict) -> bool:
def _zitate(atom_id: int) -> str:
rows = db.query("SELECT zitat FROM anker WHERE atom_id=? AND start>=0", (atom_id,))
return "\n".join(f"> {r['zitat']}" for r in rows) or "(kein Anker)"
# Quelltext-FENSTER um die Anker (belege.py) — nur das Zitat war zu schmal:
# inhaltliche Karten zu Verfahren galten als unbelegt und churnten endlos.
bloecke = belege.fenster_liste(atom_id)
return "\n---\n".join(bloecke) or "(kein Anker)"
def _atom_block(a: dict) -> str:
@@ -267,6 +270,78 @@ def _formen_gate(topic: str, atom_ids: list[int] | None = None) -> None:
db.update("artefakte", "id", b["id"], inhalt=db.j(inh_neu))
async def _aussagen_generieren(ctx: llm.Kontext, chunk: list[dict]) -> None:
"""Prüfungs-PAARE je Atom: wahre Aussage (ankerprüfbare Paraphrase) + falsche
(GENAU EIN Aspekt gekippt — Mutations-Muster auf Satzebene). Der Code baut je
Paar 2 Zeilen; die wahre Aussage ist gratis die Feedback-Erklärung der falschen."""
bloecke = "\n\n".join(_atom_block(a) for a in chunk)
res = await llm.call(ctx, stage="aussagen_gen", template="Aussagen-Generate",
werte={"atome": bloecke, "paare": AUSSAGEN_JE_ATOM},
role="guide", n=len(chunk), item=f"as{chunk[0]['id']}",
erwartet=list)
gueltig = {a["id"] for a in chunk}
zaehler: dict[int, int] = {}
for e in res or []:
if not isinstance(e, dict) or e.get("atom") not in gueltig:
continue
atom_id = e["atom"]
wahr, falsch = str(e.get("wahr", "")).strip(), str(e.get("falsch", "")).strip()
if not wahr or not falsch or wahr == falsch:
continue
if _referenziert_quelle({"text": f"{wahr} {falsch}"}):
continue
i = zaehler.get(atom_id, 0)
zaehler[atom_id] = i + 1
paar = f"{atom_id}-{i}"
db.insert("artefakte", atom_id=atom_id, typ="aussage", status="kandidat",
inhalt=db.j({"text": wahr, "wahr": True, "paar": paar,
"erklaerung": wahr}))
db.insert("artefakte", atom_id=atom_id, typ="aussage", status="kandidat",
inhalt=db.j({"text": falsch, "wahr": False, "paar": paar,
"erklaerung": wahr}))
async def _aussagen_verifizieren(ctx: llm.Kontext, chunk: list[dict]) -> None:
"""Getrennter Maßstab je SOLL (Lehre aus der Fehlersuche): wahre müssen aus den
Belegen folgen, falsche EINDEUTIG widersprechen. Panel einstimmig → verifiziert;
unvollständiges Panel → kandidat lassen (Lektion 20)."""
bloecke, kandidaten = [], []
for a in chunk:
rows = db.query("SELECT * FROM artefakte WHERE atom_id=? AND typ='aussage'"
" AND status='kandidat'", (a["id"],))
if not rows:
continue
zeilen = [f"ATOM {a['id']} Belege:\n{_zitate(a['id'])}"]
for k in rows:
inh = db.uj(k["inhalt"], {})
soll = "wahr" if inh.get("wahr") else "falsch"
zeilen.append(f"AUSSAGE {k['id']} [SOLL: {soll}]: {inh.get('text', '')}")
kandidaten.append(k)
bloecke.append("\n".join(zeilen))
if not kandidaten:
return
stimmen = await llm.panel(ctx, VERIFY_PANEL, stage="aussagen_verify",
template="Aussagen-Verify", role="judge",
werte={"aussagen": "\n\n".join(bloecke)},
n=len(kandidaten), item=f"asv{chunk[0]['id']}",
erwartet=list)
def urteil_fuer(kid: int):
def urteil(stimme):
for e in stimme:
if isinstance(e, dict) and e.get("aussage") == kid:
return bool(e.get("ok"))
return None
return urteil
for k in kandidaten:
votes = [u for s in stimmen if (u := urteil_fuer(k["id"])(s)) is not None]
if len(votes) < VERIFY_PANEL:
continue
db.update("artefakte", "id", k["id"],
status="verifiziert" if all(votes) else "verworfen")
async def _kette(ctx: llm.Kontext, chunk: list[dict]) -> None:
"""gen→gate→verify EINES Chunks verkettet — der Verify braucht nur die eigenen
Kandidaten, nicht die Generierung fremder Chunks (Stage-Barriere kostete
@@ -278,6 +353,12 @@ async def _kette(ctx: llm.Kontext, chunk: list[dict]) -> None:
await _generieren(ctx, ohne)
_formen_gate(ctx.topic, [a["id"] for a in chunk])
await _verifizieren(ctx, chunk)
ohne_aussage = [a for a in chunk if not db.one(
"SELECT id FROM artefakte WHERE atom_id=? AND typ='aussage'"
" AND status!='verworfen' LIMIT 1", (a["id"],))]
if ohne_aussage:
await _aussagen_generieren(ctx, ohne_aussage)
await _aussagen_verifizieren(ctx, chunk)
async def bauen(ctx: llm.Kontext) -> None:
@@ -295,6 +376,10 @@ def messen(ctx: llm.Kontext) -> list[dict]:
if not any(k["status"] == "verifiziert" for k in karten):
befunde.append({"art": "atom_ohne_flashcard", "item": str(a["id"]),
"detail": a["titel"]})
if not db.one("SELECT id FROM artefakte WHERE atom_id=? AND typ='aussage'"
" AND status='verifiziert' LIMIT 1", (a["id"],)):
befunde.append({"art": "atom_ohne_aussage", "item": str(a["id"]),
"detail": a["titel"]})
offen = db.query("SELECT id FROM artefakte WHERE atom_id=? AND status='kandidat'",
(a["id"],))
for k in offen:

62
backend/belege.py Normal file
View File

@@ -0,0 +1,62 @@
"""Faktenbasis eines Atoms = Quelltext-FENSTER um seine Anker, nicht nur das Zitat.
Rollen-Trennung: Der Anker identifiziert die Stelle (kurz, verbatim, fuzzy-matchbar);
die Substanz eines Verfahrens (Pseudo-Code, Ablauf) steht in der Quelle DANEBEN.
Nur gegen das Zitat geprüft war jede inhaltliche Karte formal „unbelegt" — die
Top-Verworfen-Atome (GA 73×, LPT 33×) hatten 42150 Zeichen Beleg."""
from functools import lru_cache
import db
from config import BELEG_FENSTER_NACH, BELEG_FENSTER_VOR
@lru_cache(maxsize=64)
def _snapshot(pfad: str) -> str:
# Pfade sind hash-benannt (q-<hash>.md) — neuer Korpus = neuer Pfad, Cache safe
try:
return open(pfad, encoding="utf-8").read()
except OSError:
return ""
def _wortgrenzen(text: str, a: int, b: int) -> str:
aus = text[a:b]
if a > 0 and " " in aus[:80]:
aus = aus.split(" ", 1)[1]
if b < len(text) and " " in aus[-80:]:
aus = aus.rsplit(" ", 1)[0]
return aus.strip()
def fenster_liste(atom_id: int, max_fenster: int = 2) -> list[str]:
"""Beleg-Blöcke des Atoms: Quellumgebung je Anker (überlappende gemerged),
gedeckelt auf max_fenster. Anker ohne Span oder ohne lesbaren Snapshot
fallen auf ihr Zitat zurück — nie schlechter als vorher."""
anker = db.query(
"SELECT k.start, k.ende, k.zitat, q.snapshot FROM anker k"
" JOIN quellen q ON q.id=k.quelle_id WHERE k.atom_id=?", (atom_id,))
spans: dict[str, list[tuple[int, int]]] = {}
fallback: list[str] = []
for k in anker:
text = _snapshot(k["snapshot"]) if k["start"] >= 0 else ""
if not text:
fallback.append(k["zitat"])
continue
a = max(0, k["start"] - BELEG_FENSTER_VOR)
b = min(len(text), k["ende"] + BELEG_FENSTER_NACH)
spans.setdefault(k["snapshot"], []).append((a, b))
bloecke: list[str] = []
for snap, sp in spans.items():
text = _snapshot(snap)
sp.sort()
merged: list[list[int]] = []
for a, b in sp:
if merged and a <= merged[-1][1]:
merged[-1][1] = max(merged[-1][1], b)
else:
merged.append([a, b])
bloecke += [_wortgrenzen(text, a, b) for a, b in merged]
out = bloecke[:max_fenster]
if not out: # kein Span verwertbar → wenigstens die Zitate
out = fallback
return [b for b in out if b]

View File

@@ -93,6 +93,15 @@ BEISPIEL_FORMEN = ("text", "mathe", "code", "tabelle") # 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
AUSSAGEN_JE_ATOM = 2 # Prüfungs-PAARE je Atom (je Paar: 1 wahre + 1 falsche Aussage)
# Faktenbasis = Quelltext-FENSTER um den Anker (nicht nur das Zitat): der Inhalt
# eines Verfahrens folgt meist NACH seiner Nennung (Pseudo-Code, Ablauf).
BELEG_FENSTER_VOR = 300 # Zeichen vor dem Anker-Span
BELEG_FENSTER_NACH = 1200 # Zeichen nach dem Anker-Span
# ── Prüfung (Lesemodus: 4er-Panels, exakte Teilmenge ankreuzen) ───────────────
PRUEFUNG_PANEL = 4 # Aussagen je Panel (04 davon wahr)
PRUEFUNG_SCHWELLE = 10 # Saldo (+1 je korrektem Panel, 1 sonst) → Baustein frei
# ── Ebene 3: Struktur ─────────────────────────────────────────────────────────
LEVEL_RANG = {"E": 0, "M": 1, "S": 2} # Durchgänge: Grundgerüst / Standard / Feinheiten
@@ -125,6 +134,8 @@ TIMEOUTS = {
"soll_zuordnung": (300, 8),
"luecke": (450, 0),
"artefakt": (450, 20),
"aussagen_gen": (300, 15),
"aussagen_verify": (200, 10),
"verify": (200, 10),
"fix": (300, 15),
"ziele": (300, 6),

View File

@@ -82,7 +82,8 @@ CREATE TABLE IF NOT EXISTS kanten(
art TEXT NOT NULL CHECK(art IN ('braucht','verwandt')), status TEXT NOT NULL DEFAULT 'aktiv',
UNIQUE(von_atom, zu_atom, art));
CREATE TABLE IF NOT EXISTS artefakte(
id INTEGER PRIMARY KEY, atom_id INTEGER NOT NULL, typ TEXT NOT NULL CHECK(typ IN ('flashcard','beispiel')),
id INTEGER PRIMARY KEY, atom_id INTEGER NOT NULL,
typ TEXT NOT NULL CHECK(typ IN ('flashcard','beispiel','aussage')),
inhalt TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'kandidat');
CREATE INDEX IF NOT EXISTS idx_artefakte_atom ON artefakte(atom_id);
CREATE TABLE IF NOT EXISTS lernziele(
@@ -145,6 +146,20 @@ def _init_schema(con: sqlite3.Connection) -> None:
# Befunde entfernter Ebenen schließen — sie würden sonst ewig als „offen" im Board stehen
con.execute("UPDATE befunde SET status='veraltet'"
" WHERE status='offen' AND ebene IN ('diagramme', 'fehlersuche')")
# CHECK-Migration: Bestands-DBs kennen typ='aussage' nicht (SQLite kann CHECKs
# nicht ändern) → Tabelle einmalig neu aufbauen. Erkennung über sqlite_master.
alt = con.execute("SELECT sql FROM sqlite_master WHERE type='table'"
" AND name='artefakte'").fetchone()
if alt and "'aussage'" not in alt[0]:
con.executescript(
"ALTER TABLE artefakte RENAME TO artefakte_alt;"
"CREATE TABLE artefakte("
" id INTEGER PRIMARY KEY, atom_id INTEGER NOT NULL,"
" typ TEXT NOT NULL CHECK(typ IN ('flashcard','beispiel','aussage')),"
" inhalt TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'kandidat');"
"INSERT INTO artefakte SELECT * FROM artefakte_alt;"
"DROP TABLE artefakte_alt;"
"CREATE INDEX IF NOT EXISTS idx_artefakte_atom ON artefakte(atom_id);")
con.commit()

View File

@@ -167,6 +167,23 @@ def _artefakt_fix(prompt: str):
return {"frage": "Fix-Frage?", "antwort": "Fix-Antwort.", "text": "Fix-Beispiel."}
def _aussagen_generate(prompt: str):
from config import AUSSAGEN_JE_ATOM
ids = [int(m) for m in re.findall(r"^ATOM (\d+):", _text_nach(prompt, "ATOME:"),
re.MULTILINE)]
out = []
for i in ids:
for p in range(AUSSAGEN_JE_ATOM):
out.append({"atom": i, "wahr": f"Atom {i} gilt (Aspekt {p}).",
"falsch": f"Atom {i} gilt nie (Aspekt {p})."})
return out
def _aussagen_verify(prompt: str):
ids = [int(m) for m in re.findall(r"AUSSAGE (\d+) \[", prompt)]
return [{"aussage": i, "ok": True} for i in ids]
def _lernziele(prompt: str):
punkt = re.search(r"Soll-Punkt: „(.+?)“", prompt) or re.search(r'Soll-Punkt: „(.+?)"', prompt)
name = punkt.group(1) if punkt else "das Thema"
@@ -276,7 +293,9 @@ _HANDLER = {
"Atom-Luecke": _luecke, "Atom-Titel-Fix": _titel_fix,
"Soll-Abgedeckt": lambda p: {"abgedeckt": True},
"Artefakt-Generate": _artefakt_generate, "Artefakt-Verify": _artefakt_verify,
"Artefakt-Fix": _artefakt_fix, "Lernziele": _lernziele, "Kapitel-Intro": _kapitel_intro,
"Artefakt-Fix": _artefakt_fix,
"Aussagen-Generate": _aussagen_generate, "Aussagen-Verify": _aussagen_verify,
"Lernziele": _lernziele, "Kapitel-Intro": _kapitel_intro,
"Kapitel-Schnitt": _kapitel_schnitt, "Level-Kalibrierung": _level_kalibrierung,
"Kanten-Zyklus": _zyklus, "Braucht-Aufloesung": _braucht_aufloesung,
"Guide-Writer": _writer, "Guide-Pruefer": _pruefer, "Guide-Fix": _fix,

View File

@@ -17,6 +17,7 @@ from pathlib import Path
import db
import jsonx
import llm
import belege
import textkit
from config import DURCHGANG, FIX_MAX_VERSUCHE, LEVEL_RANG, SECTION_WOERTER_PRO_ATOM
@@ -146,10 +147,10 @@ def _atome_von(baustein_id: int) -> list[dict]:
" ('gemerged','verworfen') ORDER BY ord", (baustein_id,))
def _zitate(atom_id: int, max_n: int = 3) -> list[str]:
rows = db.query("SELECT zitat FROM anker WHERE atom_id=? AND start>=0 LIMIT ?",
(atom_id, max_n))
return [r["zitat"] for r in rows]
def _zitate(atom_id: int, max_n: int = 2) -> list[str]:
# Quelltext-FENSTER um die Anker (belege.py): mit bloßen Kurz-Zitaten schrieb
# der Writer „Details sind technisch"-Prosa, weil ihm die Substanz fehlte.
return belege.fenster_liste(atom_id, max_fenster=max_n)
def _beispiel(atom_id: int) -> str:
@@ -163,8 +164,12 @@ def _beispiel(atom_id: int) -> str:
return inh.get("text", "") if inh.get("form", "text") in ("text", "mathe") else ""
def _quote(block: str) -> str:
return "\n".join(f"> {l}" for l in block.splitlines())
def _atom_paket(a: dict) -> str:
zitate = "\n".join(f"> {z}" for z in _zitate(a["id"])) or "(kein Zitat)"
zitate = "\n".join(_quote(z) for z in _zitate(a["id"])) or "(kein Zitat)"
beispiel = _beispiel(a["id"])
teil = (f"MARKER (exakt so übernehmen): <!-- atom: {a['id']} | {a['titel']} -->\n"
f"Definition: {a['definition']}\nBelege (VERBATIM zitierbar):\n{zitate}")
@@ -464,7 +469,7 @@ async def _stage_pruefer(ctx: llm.Kontext, b: dict, tag: str = "") -> str:
if str(alt).startswith("KRITISCH") and alt not in auftraege:
auftraege.append(alt)
fakten = "\n\n".join(f"Atom {a['id']} ({a['titel']}): {a['definition']}\n"
+ "\n".join(f"> {z}" for z in _zitate(a["id"])) for a in atome)
+ "\n".join(_quote(z) for z in _zitate(a["id"])) for a in atome)
res = await llm.call(ctx, stage=f"pruefer{tag}", template="Guide-Pruefer",
schritt="pruefer", role="judge", n=len(atome),
item=f"b{b['id']}{tag}",
@@ -701,7 +706,9 @@ async def messen_llm(ctx: llm.Kontext) -> list[dict]:
# qa_hash ERST nach vollständiger Prüfung setzen — bei Call-/Panel-Ausfall
# bleibt die Section ungeprüft (fail-closed statt fail-open).
atome = _atome_von(b["id"])
fakten = "\n".join(f"- {a['titel']}: " + " | ".join(_zitate(a["id"], 2)) for a in atome)
fakten = "\n".join(f"- {a['titel']}: "
+ " | ".join(z.replace("\n", " ") for z in _zitate(a["id"], 2))
for a in atome)
verdacht = await llm.call(ctx, stage="qa_falsch", template="QA-Guide-Falsch",
schritt="qa_judge", role="judge", item=f"b{b['id']}",
werte={"fakten": fakten, "text": sec["text_lang"]},

View File

@@ -18,7 +18,8 @@ import db
import guide
import ledger
import pipeline
from config import FRONTEND_DIST, PROJECT_ROOT, topic_name_ok
from config import (FRONTEND_DIST, PROJECT_ROOT, PRUEFUNG_PANEL, PRUEFUNG_SCHWELLE,
topic_name_ok)
from ws import hub
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
@@ -52,6 +53,11 @@ class UebenAntwort(BaseModel):
richtig: bool
class PruefungAntwort(BaseModel):
ids: list[int] # die 4 Aussagen des Panels
angekreuzt: list[int] # die als wahr markierten
class AutoFlag(BaseModel):
ebene: str
an: bool
@@ -252,6 +258,19 @@ async def ebene_entfernen(topic: str, ebene: str):
return {"ok": True}
@app.post("/api/topics/{topic}/ebene/{ebene}/aktualisieren")
async def ebene_aktualisieren(topic: str, ebene: str, tief: bool = False):
if not db.one("SELECT name FROM topics WHERE name=?", (topic,)):
raise HTTPException(404, "unbekanntes Topic")
try:
run_id = pipeline.aktualisierung_starten(topic, ebene, tief=tief)
except ValueError as e:
raise HTTPException(400, str(e))
except RuntimeError as e:
raise HTTPException(409, str(e))
return {"run_id": run_id}
@app.delete("/api/topics/{topic}")
async def topic_loeschen(topic: str):
if not db.one("SELECT name FROM topics WHERE name=?", (topic,)):
@@ -315,6 +334,54 @@ def baustein_fertig(topic: str, baustein_id: int):
return {"ok": True}
@app.get("/api/topics/{topic}/baustein/{baustein_id}/pruefung")
def pruefung_holen(topic: str, baustein_id: int):
"""4er-Panels aus verifizierten Aussagen der Baustein-Atome. OHNE Wahrheitswert
(Anti-Cheat) und ohne Paar-Partner im selben Panel (verraten sich gegenseitig)."""
import random
rows = db.query(
"SELECT ar.id, ar.inhalt FROM artefakte ar JOIN atome a ON a.id=ar.atom_id"
" WHERE a.topic=? AND a.baustein_id=? AND ar.typ='aussage'"
" AND ar.status='verifiziert'", (topic, baustein_id))
aussagen = [{"id": r["id"], "text": db.uj(r["inhalt"], {}).get("text", ""),
"paar": db.uj(r["inhalt"], {}).get("paar", "")} for r in rows]
random.shuffle(aussagen)
panels: list[list[dict]] = []
for a in aussagen: # greedy: erstes Panel ohne Paar-Konflikt mit Platz
for p in panels:
if len(p) < PRUEFUNG_PANEL and all(x["paar"] != a["paar"] for x in p):
p.append(a)
break
else:
panels.append([a])
return [{"panel": [{"id": x["id"], "text": x["text"]} for x in p]}
for p in panels if len(p) == PRUEFUNG_PANEL]
@app.post("/api/topics/{topic}/pruefung/antwort")
def pruefung_antwort(topic: str, a: PruefungAntwort):
rows = db.query(f"SELECT ar.id, ar.inhalt, at.baustein_id FROM artefakte ar"
f" JOIN atome at ON at.id=ar.atom_id"
f" WHERE ar.id IN ({','.join('?' * len(a.ids))})", tuple(a.ids))
if len(rows) != len(a.ids):
raise HTTPException(404, "unbekannte Aussage")
wahre = {r["id"] for r in rows if db.uj(r["inhalt"], {}).get("wahr")}
richtig = set(a.angekreuzt) == wahre # exakte Teilmenge, sonst falsch
baustein_id = rows[0]["baustein_id"]
row = db.one("SELECT xp FROM lernstand WHERE topic=? AND baustein_id=?",
(topic, baustein_id))
xp = max(0, (row["xp"] if row else 0) + (1 if richtig else -1))
status = "fertig" if xp >= PRUEFUNG_SCHWELLE else "aktiv"
db.execute("INSERT INTO lernstand(topic, baustein_id, status, xp) VALUES(?,?,?,?)"
" ON CONFLICT(topic, baustein_id) DO UPDATE SET xp=excluded.xp,"
" status=excluded.status", (topic, baustein_id, status, xp))
aufloesung = [{"id": r["id"], "wahr": db.uj(r["inhalt"], {}).get("wahr", False),
"erklaerung": db.uj(r["inhalt"], {}).get("erklaerung", "")}
for r in rows]
return {"richtig": richtig, "aufloesung": aufloesung, "xp": xp,
"fertig": xp >= PRUEFUNG_SCHWELLE}
@app.get("/api/runs/{run_id}/kennzahlen")
def kennzahlen(run_id: int):
return {"zeilen": ledger.kennzahlen(run_id), "verbraucht": ledger.verbraucht(run_id)}

View File

@@ -52,6 +52,39 @@ def lauf_starten(topic: str, budget: int | None = None) -> int:
return run_id
def _tief_zuruecksetzen(topic: str, ebene: str) -> None:
"""tief=Re-Verify: Verifiziertes auf 'kandidat' zurückstufen — der BESTEHENDE
Verify-Weg beurteilt dann alles neu (verifiziert oder verworfen = Entfernen).
Nur wo es einen LLM-Verify gibt; korpus (Soll-Freeze) bleibt bewusst außen vor,
inventar/struktur prüfen ohnehin deterministisch alles bei jedem messen."""
if ebene == "artefakte":
db.execute("UPDATE artefakte SET status='kandidat' WHERE status='verifiziert'"
" AND atom_id IN (SELECT id FROM atome WHERE topic=?)", (topic,))
elif ebene == "guide":
db.execute("UPDATE sections SET stage='pruefer', qa_hash='', fix_versuche=0"
" WHERE baustein_id IN (SELECT id FROM bausteine WHERE topic=?)",
(topic,))
def aktualisierung_starten(topic: str, ebene: str, tief: bool = False) -> int:
"""Eine Ebene (und bei Auto-Flags die folgenden) erneut fahren — ohne Reset,
ohne Status-Rückschritt. bauen füllt Fehlendes, messen+Repair fixt Kaputtes.
tief=True: zusätzlich alles Verifizierte erneut durchs Panel (teuer)."""
if ebene not in [e[0] for e in EBENEN]:
raise ValueError(f"unbekannte Ebene: {ebene}")
if topic in _laeufe and not _laeufe[topic].done():
raise RuntimeError("Lauf läuft bereits")
agents.abbruch_aufheben(f"{topic}-")
if tief:
_tief_zuruecksetzen(topic, ebene)
git_hash, dirty = ledger.git_stand()
run_id = db.insert("runs", topic=topic, status="running", git_hash=git_hash,
git_dirty=int(dirty), budget_tokens=RUN_BUDGET_TOKENS,
grund=f"aktualisieren:{ebene}" + (":tief" if tief else ""))
_laeufe[topic] = asyncio.ensure_future(_lauf(topic, run_id, ab=ebene, erzwingen=True))
return run_id
def lauf_stoppen(topic: str) -> None:
agents.abbrechen(f"{topic}-")
task = _laeufe.get(topic)
@@ -163,13 +196,19 @@ def soll_reset(topic: str) -> None:
db.update("topics", "name", topic, status="korpus")
async def _lauf(topic: str, run_id: int) -> None:
async def _lauf(topic: str, run_id: int, ab: str | None = None,
erzwingen: bool = False) -> None:
"""Normaler Lauf ODER Aktualisierung (ab=Ebene, erzwingen=True): Aktualisieren
fährt fertige Ebenen erneut — bauen füllt Fehlendes, messen prüft Form, Repair
repariert. Nötig, weil der Resume-Skip fertige Ebenen sonst nie wieder anfasst
(Altbestands-Lücke: Katalog-Titel, Aussagen-Nachrüstung, Beleg-Fenster)."""
t = db.one("SELECT * FROM topics WHERE name=?", (topic,))
ctx = llm.Kontext(run_id, topic, t["provider"])
start_idx = [e[0] for e in EBENEN].index(ab) if ab else 0
try:
for name, modul, marke in EBENEN:
for name, modul, marke in EBENEN[start_idx:]:
status = db.one("SELECT status FROM topics WHERE name=?", (topic,))["status"]
if _fertig_ab(status, marke):
if not erzwingen and _fertig_ab(status, marke):
continue
db.update("runs", "id", run_id, ebene=name)
await modul.bauen(ctx)
@@ -190,7 +229,10 @@ async def _lauf(topic: str, run_id: int) -> None:
gate = getattr(modul, "gate", None)
if gate and (grund_gate := gate(ctx)):
raise RuntimeError(f"{name}-Gate: {grund_gate}")
db.update("topics", "name", topic, status=marke)
# Marker-Schutz: nie zurückdrehen — Aktualisieren einer frühen Ebene
# darf z. B. 'fertig' nicht auf 'artefakte_fertig' zurückstufen.
if not _fertig_ab(status, marke):
db.update("topics", "name", topic, status=marke)
flags = db.uj(db.one("SELECT auto FROM topics WHERE name=?", (topic,))["auto"], {})
if not flags.get(name, True) and marke != "fertig":
db.update("runs", "id", run_id, status="done", beendet=db.now(),

View File

@@ -25,7 +25,7 @@ GEWICHTE = {
"korpus_leer": 3.0, "soll_leer": 3.0, "soll_wenig_belege": 1.5,
"soll_kandidat_offen": 3.0,
"atom_ohne_anker": 3.0, "atom_ohne_soll": 1.5, "soll_ohne_atom": 3.0,
"atom_ohne_flashcard": 1.5, "artefakt_unentschieden": 0.5,
"atom_ohne_flashcard": 1.5, "atom_ohne_aussage": 1.5, "artefakt_unentschieden": 0.5,
"partition": 3.0, "band": 0.5, "vorwaerts_kante": 1.5, "zyklus": 3.0,
"level_konflikt": 3.0, "level_mix": 3.0, "kapitel_level": 3.0,
"baustein_ohne_kapitel": 3.0, "kapitel_leer": 1.5, "kapitel_fallback": 1.5,

View File

@@ -125,6 +125,13 @@ def importieren(d: dict) -> None:
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)
def beispiel_remap(m: re.Match) -> str:
# Beispiel-Marker tragen ARTEFAKT-ids — ohne Rewrite zeigt der Marker nach
# dem Import auf eine fremde Zeile (latent: fiel erst auf, als zusätzliche
# Artefakt-Typen die id-Sequenz verschoben)
alt = int(m.group(1))
return m.group(0).replace(m.group(1), str(ar_map[alt]), 1) if alt in ar_map else m.group(0)
def auftrag_remap(m: re.Match) -> str:
alt = int(m.group(2))
return f"{m.group(1)}{a_map[alt]}" if alt in a_map else m.group(0)
@@ -135,6 +142,7 @@ def importieren(d: dict) -> None:
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])
z[feld] = re.sub(r"<!--\s*beispiel:\s*(\d+)\s*-->", beispiel_remap, z[feld])
# offene Fix-Aufträge nennen Atom-IDs im Klartext („Marker für Atom 123
# fehlt") — mitremappen, sonst fixt der Prüfer gegen tote IDs
if z.get("befunde"):