update
This commit is contained in:
@@ -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
62
backend/belege.py
Normal 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 42–150 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]
|
||||
@@ -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 (0–4 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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]},
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"):
|
||||
|
||||
Reference in New Issue
Block a user