update
This commit is contained in:
@@ -11,6 +11,7 @@ die Gesamtliste.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import shutil
|
||||
@@ -19,11 +20,10 @@ import time
|
||||
from pathlib import Path
|
||||
|
||||
import database as db
|
||||
from agents import kill_process, cancel_scope, clear_scope
|
||||
from agents import kill_process, cancel_scope, clear_scope, run_agent
|
||||
from config import KONSENS_GRACE, RECHERCHE_GRACE, KONSENS_MAX_RUNDEN, DEFAULT_PROVIDER, CRAWL_KEEP_PATTERNS, CRAWL_NOISE_PATTERNS, CRAWL_MIN_CHARS
|
||||
from fsutil import atomic_write_text, atomic_write_json
|
||||
from jsonio import read_json_file as _json_datei
|
||||
from lernen import FRAGETYPEN
|
||||
from paths import arbeit_dir, bausteine_path, frage_muster_path, project_dir, subbausteine_path, quelle_path, quelle_crawl_dir, safe_ordner
|
||||
from crawl import crawl
|
||||
from pipeline import (
|
||||
@@ -51,6 +51,8 @@ SUBBAUSTEIN_KAPPE = 900 # Subbaustein-Finde-Loop je Chunk (15 min)
|
||||
KONSOLIDIERUNG_CHUNK = 600 # bis hierher EIN globaler Judge (dedupt alles); darüber chunked + Merge-Pass
|
||||
# Frage-Muster-Chunks per LPT nach Sub-Last balancieren (Makespan), statt nach Baustein-Anzahl.
|
||||
FRAGE_CHUNK_SUBS = 50 # Ziel-Summe relevanter Subs je Chunk
|
||||
FAKTEN_CHUNK_SUBS = 25 # Fakten-Extraktion: kleinere Chunks (Fakten sind umfangreicher als Muster)
|
||||
FAKTEN_CHECK_PANEL = 3 # Judges je Chunk im Fakten-Check (Mehrheit beanstandet)
|
||||
|
||||
log = logging.getLogger("creator.bausteine")
|
||||
|
||||
@@ -62,10 +64,14 @@ _bausteine_step: dict[str, int] = {}
|
||||
BAUSTEINE_STEPS = (
|
||||
"Recherche", "Konsolidierung", "Klärung",
|
||||
"Subbausteine finden", "Subbausteine wählen", "Subbausteine klären",
|
||||
"Fakten finden", "Fakten prüfen", "Fakten fix",
|
||||
"Stufen finden", "Stufen wählen", "Stufen klären",
|
||||
"Relevanz finden", "Relevanz wählen", "Relevanz klären",
|
||||
"Gliederung",
|
||||
"Fragen finden", "Fragen wählen", "Fragen klären", "Fragen prüfen",
|
||||
"Karteikarten", "Beispiele", "Diagramme",
|
||||
)
|
||||
ARTEFAKT_TYPEN = ("karteikarte", "beispiel", "diagramm")
|
||||
|
||||
|
||||
def lade_quelle(topic: str) -> dict:
|
||||
@@ -93,7 +99,8 @@ def _crawl_fertig(topic: str) -> bool:
|
||||
return (quelle_crawl_dir(topic) / ".done").exists() # Marker erst bei sauberem Abschluss
|
||||
|
||||
|
||||
_STUFEN = ("einfach", "mittel", "schwer")
|
||||
# Lernpfad-Stufen (anfaenger/fortgeschritten/experte); alte Schwierigkeits-Werte abwärtskompatibel.
|
||||
_STUFEN = ("anfaenger", "fortgeschritten", "experte", "einfach", "mittel", "schwer")
|
||||
|
||||
|
||||
async def subbausteine_titel(topic: str, baustein: str) -> list[str]:
|
||||
@@ -115,19 +122,36 @@ async def lade_frage_muster(topic: str, baustein: str) -> list[dict]:
|
||||
"""Vordefinierte Frage-Muster eines Bausteins — DB-first, Fallback Sidecar (leer = Live)."""
|
||||
rows = await db.list_frage_muster(topic, _norm_titel(baustein))
|
||||
if rows:
|
||||
return [{"subbaustein": r["sub_titel"], "typ": r["typ"], "frage": r["frage"]} for r in rows if r["frage"]]
|
||||
return [{"subbaustein": r["sub_titel"], "frage": r["frage"]} for r in rows if r["frage"]]
|
||||
fm = _json_datei(frage_muster_path(topic))
|
||||
if not isinstance(fm, dict):
|
||||
return []
|
||||
return [
|
||||
{"subbaustein": str(e.get("subbaustein", "")).strip(),
|
||||
"typ": str(e.get("typ", "")).strip(),
|
||||
"frage": frage}
|
||||
{"subbaustein": str(e.get("subbaustein", "")).strip(), "frage": frage}
|
||||
for e in (fm.get(baustein) or [])
|
||||
if isinstance(e, dict) and (frage := str(e.get("frage", "")).strip())
|
||||
]
|
||||
|
||||
|
||||
async def subbausteine_frei(topic: str, baustein: str, max_ebene: int) -> list[str]:
|
||||
"""Subbaustein-Titel bis zur freigeschalteten Ebene (≤ max_ebene). Fallback ohne
|
||||
Ebenen-Wissen (Altbestand/Sidecar): alle Subbaustein-Titel."""
|
||||
rows = await db.subs_mit_ebene(topic, baustein)
|
||||
if not rows:
|
||||
return await subbausteine_titel(topic, baustein)
|
||||
return [s["titel"] for s in rows if s["ebene"] <= max_ebene and s["titel"]]
|
||||
|
||||
|
||||
async def lade_frage_muster_frei(topic: str, baustein: str, max_ebene: int) -> list[dict]:
|
||||
"""Frage-Muster, gefiltert auf Subbausteine bis zur freigeschalteten Ebene. Ohne
|
||||
Ebenen-Wissen (Altbestand/Sidecar) ungefiltert."""
|
||||
rows = await db.subs_mit_ebene(topic, baustein)
|
||||
if not rows:
|
||||
return await lade_frage_muster(topic, baustein)
|
||||
frei = {s["norm"] for s in rows if s["ebene"] <= max_ebene}
|
||||
return [m for m in await lade_frage_muster(topic, baustein) if _norm_titel(m["subbaustein"]) in frei]
|
||||
|
||||
|
||||
async def lade_uebersicht(topic: str) -> list[dict]:
|
||||
"""Strukturierte Baustein-Liste für die Übersicht — DB-first (Konsens + Subs/Stufen/Relevanz),
|
||||
Fallback bausteine.md + Sidecar (Alt-Themen)."""
|
||||
@@ -140,7 +164,7 @@ async def lade_uebersicht(topic: str) -> list[dict]:
|
||||
"num": num, "titel": b["titel"], "beschreibung": b["beschreibung"],
|
||||
"subbausteine": [
|
||||
{"titel": s["sub_titel"],
|
||||
"stufe": s["stufe"] if s["stufe"] in _STUFEN else "mittel",
|
||||
"stufe": s["stufe"] if s["stufe"] in _STUFEN else "fortgeschritten",
|
||||
"relevanz": s["relevanz"] if s["relevanz"] in ("relevant", "rand") else None}
|
||||
for s in subs if s["sub_titel"]
|
||||
],
|
||||
@@ -157,7 +181,7 @@ async def lade_uebersicht(topic: str) -> list[dict]:
|
||||
subbausteine = [
|
||||
{
|
||||
"titel": t,
|
||||
"stufe": s.get("stufe") if s.get("stufe") in _STUFEN else "mittel",
|
||||
"stufe": s.get("stufe") if s.get("stufe") in _STUFEN else "fortgeschritten",
|
||||
"relevanz": s.get("relevanz") if s.get("relevanz") in ("relevant", "rand") else None,
|
||||
}
|
||||
for s in (sidecar.get(titel) or [])
|
||||
@@ -177,9 +201,12 @@ def _bausteine_steps(topic: str) -> tuple:
|
||||
base = ("Recherche", "Konsolidierung", "Klärung")
|
||||
rest = (
|
||||
"Subbausteine finden", "Subbausteine wählen", "Subbausteine klären",
|
||||
"Fakten finden", "Fakten prüfen", "Fakten fix",
|
||||
"Stufen finden", "Stufen wählen", "Stufen klären",
|
||||
"Relevanz finden", "Relevanz wählen", "Relevanz klären",
|
||||
"Gliederung",
|
||||
"Fragen finden", "Fragen wählen", "Fragen klären", "Fragen prüfen",
|
||||
"Karteikarten", "Beispiele", "Diagramme",
|
||||
)
|
||||
mitte = base + (("Ergänzung",) if q["type"] == "projekt" else ()) + rest
|
||||
return (("Quelle aufbereiten",) if q["type"] == "link" else ()) + mitte
|
||||
@@ -203,9 +230,12 @@ PHASEN = (
|
||||
("Quelle", ("Quelle aufbereiten",)),
|
||||
("Inventar", ("Recherche", "Konsolidierung", "Klärung", "Ergänzung")),
|
||||
("Subbausteine", ("Subbausteine finden", "Subbausteine wählen", "Subbausteine klären")),
|
||||
("Fakten", ("Fakten finden", "Fakten prüfen", "Fakten fix")),
|
||||
("Stufen", ("Stufen finden", "Stufen wählen", "Stufen klären")),
|
||||
("Relevanz", ("Relevanz finden", "Relevanz wählen", "Relevanz klären")),
|
||||
("Gliederung", ("Gliederung",)),
|
||||
("Fragen", ("Fragen finden", "Fragen wählen", "Fragen klären", "Fragen prüfen")),
|
||||
("Artefakte", ("Karteikarten", "Beispiele", "Diagramme")),
|
||||
)
|
||||
|
||||
|
||||
@@ -244,22 +274,28 @@ def _bausteine_files(topic: str) -> dict:
|
||||
"mapping": {n: arbeit / f"auswahl-mapping-r{n}.json" for n in runden},
|
||||
"ergaenzung": arbeit / "ergaenzung.json",
|
||||
"sub_roh": arbeit / "subbausteine-roh.json",
|
||||
"fakten": arbeit / "subbausteine-fakten.json",
|
||||
"sidecar": subbausteine_path(topic),
|
||||
"frage_muster": frage_muster_path(topic),
|
||||
"gliederung": arbeit / "gliederung.json",
|
||||
"gliederung_slots": [arbeit / f"gliederung-{i}.json" for i in (1, 2, 3)],
|
||||
"artefakte": arbeit / "artefakte.json",
|
||||
}
|
||||
|
||||
|
||||
def _alle_slot_dateien(files: dict) -> list[Path]:
|
||||
arbeit = files["arbeit"]
|
||||
# Subbaustein-/Stufen-Slots sind pro Chunk dynamisch — per Glob einsammeln.
|
||||
dyn = (list(arbeit.glob("subbaustein-*")) + list(arbeit.glob("stufe-*")) + list(arbeit.glob("relevanz-*"))
|
||||
+ list(arbeit.glob("frage-muster-*")) + list(arbeit.glob("recherche-*")) + list(arbeit.glob("konsolidierung-*"))
|
||||
dyn = (list(arbeit.glob("subbaustein-*")) + list(arbeit.glob("fakten-*")) + list(arbeit.glob("stufe-*")) + list(arbeit.glob("relevanz-*"))
|
||||
+ list(arbeit.glob("frage-muster-*")) + list(arbeit.glob("gliederung-*")) + list(arbeit.glob("artefakt-*"))
|
||||
+ list(arbeit.glob("recherche-*")) + list(arbeit.glob("konsolidierung-*"))
|
||||
+ list(arbeit.glob("klaerung*"))) if arbeit.is_dir() else []
|
||||
return [
|
||||
*files["recherche"], files["recherche_mapping"],
|
||||
*(p for slots in files["auswahl"].values() for p in slots),
|
||||
*files["mapping"].values(), files["ergaenzung"],
|
||||
files["sub_roh"], files["sidecar"], files["frage_muster"], *dyn,
|
||||
files["sub_roh"], files["sidecar"], files["frage_muster"],
|
||||
files["fakten"], files["gliederung"], files["artefakte"], *dyn,
|
||||
]
|
||||
|
||||
|
||||
@@ -288,12 +324,21 @@ def _resume_step(topic: str) -> int:
|
||||
# Stufen fertig; nur noch Relevanz offen?
|
||||
if not _relevanz_komplett(sidecar):
|
||||
return _step_idx(topic, "Relevanz finden")
|
||||
# Relevanz fertig; nur noch Frage-Muster offen?
|
||||
# Relevanz fertig; Gliederung (Bausteine-Artefakt für den Guide) offen?
|
||||
if not _gliederung_komplett(files):
|
||||
return _step_idx(topic, "Gliederung")
|
||||
# Gliederung fertig; Frage-Muster offen?
|
||||
if not _frage_muster_komplett(topic):
|
||||
return _step_idx(topic, "Fragen finden")
|
||||
# Fragen fertig; Lern-Artefakte (Karteikarten/Beispiele/Diagramme) offen?
|
||||
if not _artefakte_komplett(files):
|
||||
return _step_idx(topic, "Karteikarten")
|
||||
return len(_bausteine_steps(topic))
|
||||
if _sub_roh_schema(_json_datei(files["sub_roh"])) is None:
|
||||
return _step_idx(topic, "Subbausteine finden")
|
||||
# Subbausteine fertig; Fakten noch offen? (Fakten kommen vor den Stufen.)
|
||||
if not _fakten_komplett(files):
|
||||
return _step_idx(topic, "Fakten finden")
|
||||
return _step_idx(topic, "Stufen finden")
|
||||
|
||||
|
||||
@@ -356,12 +401,19 @@ def _reset_ab_phase(topic: str, label: str) -> None:
|
||||
for p in arbeit.glob(pat):
|
||||
p.unlink(missing_ok=True)
|
||||
|
||||
if idx <= 5: # Fragen
|
||||
# Phasen-Index: Quelle=0 · Inventar=1 · Subbausteine=2 · Fakten=3 · Stufen=4 · Relevanz=5 · Gliederung=6 · Fragen=7 · Artefakte=8
|
||||
if idx <= 8: # Artefakte (Karteikarten/Beispiele/Diagramme)
|
||||
files["artefakte"].unlink(missing_ok=True)
|
||||
glob_del("artefakt-*")
|
||||
if idx <= 7: # Fragen
|
||||
files["frage_muster"].unlink(missing_ok=True)
|
||||
glob_del("frage-muster-*")
|
||||
if idx <= 4: # Relevanz
|
||||
if idx <= 6: # Gliederung
|
||||
files["gliederung"].unlink(missing_ok=True)
|
||||
glob_del("gliederung-*")
|
||||
if idx <= 5: # Relevanz
|
||||
glob_del("relevanz-*")
|
||||
if idx <= 3: # Stufen + Relevanz teilen die Sidecar → ab Stufen ganz neu
|
||||
if idx <= 4: # Stufen + Relevanz teilen die Sidecar → ab Stufen ganz neu
|
||||
files["sidecar"].unlink(missing_ok=True)
|
||||
glob_del("stufe-*")
|
||||
else: # ab Relevanz: Stufen behalten, nur Relevanz-Felder strippen
|
||||
@@ -372,6 +424,9 @@ def _reset_ab_phase(topic: str, label: str) -> None:
|
||||
if isinstance(s, dict):
|
||||
s.pop("relevanz", None)
|
||||
atomic_write_json(files["sidecar"], sc, indent=1)
|
||||
if idx <= 3: # Fakten (vor den Stufen) — Fakten-Map + Arbeitsdateien weg
|
||||
files["fakten"].unlink(missing_ok=True)
|
||||
glob_del("fakten-*")
|
||||
if idx <= 2: # Subbausteine
|
||||
files["sub_roh"].unlink(missing_ok=True)
|
||||
glob_del("subbaustein-*")
|
||||
@@ -474,7 +529,7 @@ def _sidecar_schema(data):
|
||||
if not isinstance(v, list) or not v:
|
||||
return None
|
||||
for s in v:
|
||||
if not isinstance(s, dict) or not str(s.get("titel", "")).strip() or s.get("stufe") not in ("einfach", "mittel", "schwer"):
|
||||
if not isinstance(s, dict) or not str(s.get("titel", "")).strip() or s.get("stufe") not in _STUFEN:
|
||||
return None
|
||||
return data
|
||||
|
||||
@@ -493,10 +548,10 @@ def _relevanz_komplett(data) -> bool:
|
||||
|
||||
|
||||
def _frage_muster_chunk_schema(data) -> list[dict] | None:
|
||||
"""{"muster": [{baustein, subbaustein, typ, frage}, …]} → Liste valider Einträge · sonst None.
|
||||
"""{"muster": [{baustein, subbaustein, frage}, …]} → Liste valider Einträge · sonst None.
|
||||
|
||||
Mit `baustein` (Zuordnung im 10er-Chunk). Ungültige Einzel-Einträge werden
|
||||
übersprungen (nicht die ganze Liste verworfen)."""
|
||||
Ein Muster je Subbaustein (kein Typ-Kreuzprodukt — die Schwierigkeit kommt erst bei der
|
||||
Prüfung aus dem Lerner-Niveau). Ungültige Einzel-Einträge werden übersprungen."""
|
||||
if not isinstance(data, dict) or not isinstance(data.get("muster"), list):
|
||||
return None
|
||||
out = []
|
||||
@@ -505,11 +560,10 @@ def _frage_muster_chunk_schema(data) -> list[dict] | None:
|
||||
continue
|
||||
bau = str(e.get("baustein", "")).strip()
|
||||
sub = str(e.get("subbaustein", "")).strip()
|
||||
typ = str(e.get("typ", "")).strip().casefold()
|
||||
frage = str(e.get("frage", "")).strip()
|
||||
if not bau or not sub or typ not in FRAGETYPEN or not frage:
|
||||
if not bau or not sub or not frage:
|
||||
continue
|
||||
out.append({"baustein": bau, "subbaustein": sub, "typ": typ, "frage": frage})
|
||||
out.append({"baustein": bau, "subbaustein": sub, "frage": frage})
|
||||
return out or None
|
||||
|
||||
|
||||
@@ -801,10 +855,10 @@ async def _stufen_block(ctx: GenContext, set_p, files: dict, roh: dict, instruct
|
||||
timeout=_timeout("stufe_check", len(strittig)),
|
||||
)
|
||||
if status == FAILED:
|
||||
_log(topic, f"Stufen-Klärung Paket {c} fehlgeschlagen — Default 'mittel'")
|
||||
_log(topic, f"Stufen-Klärung Paket {c} fehlgeschlagen — Default 'fortgeschritten'")
|
||||
entsch = entsch if isinstance(entsch, dict) else {}
|
||||
# Strittige ohne Entscheid → 'mittel'; Vote-Gewinner bleiben; Judge überschreibt.
|
||||
ergebnis = {**{k: "mittel" for k in strittig}, **ergebnis, **entsch}
|
||||
# Strittige ohne Entscheid → 'fortgeschritten'; Vote-Gewinner bleiben; Judge überschreibt.
|
||||
ergebnis = {**{k: "fortgeschritten" for k in strittig}, **ergebnis, **entsch}
|
||||
return {item_idxs[k - 1] + 1: stufe for k, stufe in ergebnis.items()}
|
||||
|
||||
parts = await _gather_fortschritt([_klaere(c, idxs) for c, idxs in enumerate(chunks, 1)], len(chunks), _melde_p(set_p, topic, "Stufen klären"))
|
||||
@@ -813,12 +867,12 @@ async def _stufen_block(ctx: GenContext, set_p, files: dict, roh: dict, instruct
|
||||
stufe_by_id: dict[int, str] = {}
|
||||
for c, part in enumerate(parts, 1):
|
||||
if not isinstance(part, dict):
|
||||
# Klärung ist nicht fatal: Vote-Ergebnis + Default 'mittel' für Strittige.
|
||||
# Klärung ist nicht fatal: Vote-Ergebnis + Default 'fortgeschritten' für Strittige.
|
||||
if isinstance(part, BaseException):
|
||||
_log(topic, f"Stufen-Klärung Paket {c}: {type(part).__name__}: {part}")
|
||||
ergebnis, strittig = vote_by_c[c]
|
||||
item_idxs = chunks[c - 1]
|
||||
merged = {**{k: "mittel" for k in strittig}, **ergebnis}
|
||||
merged = {**{k: "fortgeschritten" for k in strittig}, **ergebnis}
|
||||
part = {item_idxs[k - 1] + 1: s for k, s in merged.items()}
|
||||
stufe_by_id.update(part)
|
||||
|
||||
@@ -829,11 +883,196 @@ async def _stufen_block(ctx: GenContext, set_p, files: dict, roh: dict, instruct
|
||||
lst = []
|
||||
for sub in subs:
|
||||
gid += 1
|
||||
lst.append({"titel": sub, "stufe": stufe_by_id.get(gid, "mittel")})
|
||||
lst.append({"titel": sub, "stufe": stufe_by_id.get(gid, "fortgeschritten")})
|
||||
sidecar[titel] = lst
|
||||
return sidecar
|
||||
|
||||
|
||||
_FAKTEN_FELDER = ("kernpunkte", "voraussetzungen", "huerden", "belegte_fakten", "beispiel_idee")
|
||||
|
||||
|
||||
def _fakten_schema(data) -> list[dict] | None:
|
||||
"""{"fakten": [{baustein, subbaustein, …}]} → valide Liste · sonst None.
|
||||
Trennt belegte_fakten (mit Quelle) hart von beispiel_idee (generativ)."""
|
||||
if not isinstance(data, dict) or not isinstance(data.get("fakten"), list):
|
||||
return None
|
||||
out = []
|
||||
for e in data["fakten"]:
|
||||
if not isinstance(e, dict):
|
||||
continue
|
||||
bau = str(e.get("baustein", "")).strip()
|
||||
sub = str(e.get("subbaustein", "")).strip()
|
||||
if not bau or not sub:
|
||||
continue
|
||||
bf = [{"text": t, "quelle": str(f.get("quelle", "")).strip()}
|
||||
for f in (e.get("belegte_fakten") or []) if isinstance(f, dict) and (t := str(f.get("text", "")).strip())]
|
||||
out.append({
|
||||
"baustein": bau, "subbaustein": sub,
|
||||
"kernpunkte": [k for x in (e.get("kernpunkte") or []) if (k := str(x).strip())],
|
||||
"voraussetzungen": str(e.get("voraussetzungen", "")).strip(),
|
||||
"huerden": str(e.get("huerden", "")).strip(),
|
||||
"belegte_fakten": bf,
|
||||
"beispiel_idee": str(e.get("beispiel_idee", "")).strip(),
|
||||
})
|
||||
return out or None
|
||||
|
||||
|
||||
def _fakten_check_schema(data) -> list[str] | None:
|
||||
"""Fakten-Check → beanstandete sub_norms · {ok:true}→[] · None bei ungültig."""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
if data.get("ok") is True:
|
||||
return []
|
||||
pr = data.get("probleme")
|
||||
if not isinstance(pr, list):
|
||||
return None
|
||||
return [sn for p in pr if isinstance(p, dict) and (sn := _norm_titel(str(p.get("subbaustein", ""))))]
|
||||
|
||||
|
||||
def _fakten_zeilen(fk: dict) -> str:
|
||||
z = []
|
||||
if fk.get("kernpunkte"):
|
||||
z.append("Kernpunkte: " + " · ".join(fk["kernpunkte"]))
|
||||
if fk.get("voraussetzungen"):
|
||||
z.append("Voraussetzung: " + fk["voraussetzungen"])
|
||||
if fk.get("huerden"):
|
||||
z.append("Hürde: " + fk["huerden"])
|
||||
for bf in fk.get("belegte_fakten", []):
|
||||
z.append(f"FAKT: {bf['text']} (Quelle: {bf.get('quelle', '?')})")
|
||||
if fk.get("beispiel_idee"):
|
||||
z.append("Beispiel: " + fk["beispiel_idee"])
|
||||
return "\n".join(z)
|
||||
|
||||
|
||||
def _fakten_komplett(files: dict) -> bool:
|
||||
"""Fakten-Map existiert (Block durch)? {Baustein: {sub_norm: {...}}}."""
|
||||
d = _json_datei(files["fakten"])
|
||||
return isinstance(d, dict) and bool(d)
|
||||
|
||||
|
||||
async def _fakten_block(ctx, set_p, files: dict, roh: dict, q: dict, ordner, instructions: str) -> dict | None:
|
||||
"""Block: je Sub Quell-Fakten extrahieren (finden) → verifizieren (prüfen) → korrigieren (fix).
|
||||
Extract-once-Grounding: das Ergebnis nährt Stufe/Relevanz/Fragen/Guide.
|
||||
→ {Baustein-Titel: {sub_norm: fakten-dict}} oder None bei Abbruch/Fehler."""
|
||||
topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled
|
||||
arbeit = files["arbeit"]
|
||||
caps = "files" if ordner else "full"
|
||||
typ = q.get("type", "thema")
|
||||
source = _prompt(_QUELLE_TEMPLATE[typ], project=ordner) if typ in _QUELLE_TEMPLATE else _prompt("Bausteine-Quelle-Thema", topic=topic)
|
||||
bausteine = [(titel, [str(s).strip() for s in subs if str(s).strip()]) for titel, subs in roh.items() if subs]
|
||||
if not bausteine:
|
||||
return {}
|
||||
chunks = _lpt_chunks([len(subs) for _, subs in bausteine], FAKTEN_CHUNK_SUBS)
|
||||
|
||||
def roh_path(ci): return arbeit / f"fakten-c{ci}.json"
|
||||
def chk_path(ci, j): return arbeit / f"fakten-check-c{ci}-j{j}.json"
|
||||
def fix_path(ci): return arbeit / f"fakten-fix-c{ci}.json"
|
||||
def ctitel(idxs): return [bausteine[i][0] for i in idxs]
|
||||
def block_text(idxs):
|
||||
return "\n\n".join(
|
||||
f"BAUSTEIN: {bausteine[i][0]}\nSUBBAUSTEINE:\n" + "\n".join(f"- {s}" for s in bausteine[i][1])
|
||||
for i in idxs)
|
||||
|
||||
# Roh-Fakten eines Chunks → {Baustein: {sub_norm: {sub, …felder}}}, auf Chunk-Titel gematcht.
|
||||
def roh_map(ci, path):
|
||||
idxs = chunks[ci]
|
||||
rel_by = {bausteine[i][0]: bausteine[i][1] for i in idxs}
|
||||
ct = ctitel(idxs)
|
||||
out: dict[str, dict] = {}
|
||||
for e in _fakten_schema(_json_datei(path)) or []:
|
||||
bt = _match_sub(e["baustein"], ct)
|
||||
if bt not in rel_by:
|
||||
continue
|
||||
sub = _match_sub(e["subbaustein"], rel_by[bt])
|
||||
out.setdefault(bt, {})[_norm_titel(sub)] = {"sub": sub, **{k: e[k] for k in _FAKTEN_FELDER}}
|
||||
return out
|
||||
|
||||
# Phase „Fakten finden": 1 Generator je Chunk.
|
||||
async def _finde(ci, idxs):
|
||||
fp = roh_path(ci)
|
||||
if _fakten_schema(_json_datei(fp)):
|
||||
return True
|
||||
subs_total = sum(len(bausteine[i][1]) for i in idxs)
|
||||
status, _r = await run_single_slot(
|
||||
ctx, f"Fakten {ci}", key=f"bausteine-{topic}-fakten-c{ci}",
|
||||
prompt=_prompt("Fakten-Recherche", topic=topic, source=source, bausteine=block_text(idxs), out_path=fp, extra=_extra(instructions)),
|
||||
role="guide", capabilities=caps,
|
||||
payload=lambda result, p=fp: _fakten_schema(_json_datei(p)),
|
||||
timeout=_timeout("inhalt", subs_total))
|
||||
return status != FAILED and _fakten_schema(_json_datei(fp)) is not None
|
||||
|
||||
oks = await _gather_fortschritt([_finde(ci, idxs) for ci, idxs in enumerate(chunks)], len(chunks), _melde_p(set_p, topic, "Fakten finden"))
|
||||
if is_cancelled():
|
||||
return None
|
||||
if not any(ok is True for ok in oks):
|
||||
_bausteine_errors[topic] = "Fakten-Extraktion fehlgeschlagen"
|
||||
return None
|
||||
|
||||
# Phase „Fakten prüfen": FAKTEN_CHECK_PANEL Judges je Chunk → beanstandete sub_norms (Mehrheit).
|
||||
async def _pruefe(ci, idxs):
|
||||
per = roh_map(ci, roh_path(ci))
|
||||
if not per:
|
||||
return ci, set()
|
||||
fakten_text = "\n\n".join(f"SUBBAUSTEIN: {fk['sub']}\n{_fakten_zeilen(fk)}" for fm in per.values() for fk in fm.values())
|
||||
offen = [j for j in (1, 2, 3)[:FAKTEN_CHECK_PANEL] if _fakten_check_schema(_json_datei(chk_path(ci, j))) is None]
|
||||
await asyncio.gather(*[
|
||||
run_agent(f"bausteine-{topic}-fakten-check-c{ci}-j{j}",
|
||||
_prompt("Fakten-Check", topic=topic, source=source, fakten=fakten_text, out_path=chk_path(ci, j), extra=_extra(instructions)),
|
||||
_timeout("inhalt_check", len(per)), provider=provider, role="judge", capabilities=caps)
|
||||
for j in offen], return_exceptions=True)
|
||||
outs = [s for j in (1, 2, 3)[:FAKTEN_CHECK_PANEL] if (s := _fakten_check_schema(_json_datei(chk_path(ci, j)))) is not None]
|
||||
votes: dict[str, int] = {}
|
||||
for s in outs:
|
||||
for sn in set(s):
|
||||
votes[sn] = votes.get(sn, 0) + 1
|
||||
schwelle = len(outs) / 2 if outs else 99
|
||||
return ci, {sn for sn, v in votes.items() if v > schwelle}
|
||||
|
||||
pruef = await _gather_fortschritt([_pruefe(ci, idxs) for ci, idxs in enumerate(chunks)], len(chunks), _melde_p(set_p, topic, "Fakten prüfen"))
|
||||
if is_cancelled():
|
||||
return None
|
||||
beanstandet = {ci: subs for r in pruef if isinstance(r, tuple) for ci, subs in [r]}
|
||||
|
||||
# Phase „Fakten fix": je Chunk mit beanstandeten Subs → diese neu extrahieren (frische Quelle).
|
||||
n_problem = sum(len(s) for s in beanstandet.values())
|
||||
if n_problem:
|
||||
set_p(f"Fakten korrigieren ({n_problem})…", step=_step_idx(topic, "Fakten fix"))
|
||||
async def _fix(ci):
|
||||
subs_norm = beanstandet.get(ci, set())
|
||||
if not subs_norm or _fakten_schema(_json_datei(fix_path(ci))):
|
||||
return
|
||||
idxs = chunks[ci]
|
||||
rel_by = {bausteine[i][0]: bausteine[i][1] for i in idxs}
|
||||
# nur die beanstandeten Subs als Block
|
||||
ziel = []
|
||||
for bt, subs in rel_by.items():
|
||||
betroffen = [s for s in subs if _norm_titel(s) in subs_norm]
|
||||
if betroffen:
|
||||
ziel.append(f"BAUSTEIN: {bt}\nSUBBAUSTEINE:\n" + "\n".join(f"- {s}" for s in betroffen))
|
||||
if not ziel:
|
||||
return
|
||||
await run_single_slot(
|
||||
ctx, f"Fakten-Fix {ci}", key=f"bausteine-{topic}-fakten-fix-c{ci}",
|
||||
prompt=_prompt("Fakten-Recherche", topic=topic, source=source, bausteine="\n\n".join(ziel), out_path=fix_path(ci), extra=_extra(instructions)),
|
||||
role="guide", capabilities=caps,
|
||||
payload=lambda result, p=fix_path(ci): _fakten_schema(_json_datei(p)),
|
||||
timeout=_timeout("inhalt", len(subs_norm)))
|
||||
await _gather_fortschritt([_fix(ci) for ci in beanstandet], len(beanstandet), _melde_p(set_p, topic, "Fakten fix"))
|
||||
if is_cancelled():
|
||||
return None
|
||||
|
||||
# Zusammensetzen: roh + Fix-Overrides für beanstandete (Vollständigkeits-Guard: fehlt der Fix, bleibt roh).
|
||||
ergebnis: dict[str, dict] = {}
|
||||
for ci in range(len(chunks)):
|
||||
per = roh_map(ci, roh_path(ci))
|
||||
fix = roh_map(ci, fix_path(ci)) if fix_path(ci).exists() else {}
|
||||
for bt, fm in per.items():
|
||||
for sn, fk in fm.items():
|
||||
gewinner = fix.get(bt, {}).get(sn, fk) if sn in beanstandet.get(ci, set()) else fk
|
||||
ergebnis.setdefault(bt, {})[sn] = {k: gewinner[k] for k in _FAKTEN_FELDER}
|
||||
return ergebnis
|
||||
|
||||
|
||||
async def _relevanz_block(ctx: GenContext, set_p, files: dict, sidecar: dict, instructions: str) -> dict | None:
|
||||
"""Block D: drei Phasen mit Barriere — Finden (relevant/rand), Wählen (Vote), Klären.
|
||||
Items aus der Sidecar; lokale IDs 1..n pro Paket → globale gid.
|
||||
@@ -963,18 +1202,17 @@ async def _frage_muster_block(ctx: GenContext, set_p, files: dict, sidecar: dict
|
||||
"""Block E (10er-Chunks): Finden (1 Generator je ~10 Bausteine, parallel), Wählen (Code:
|
||||
je Baustein gruppieren + Dedup), Klären (1 Kritiker je Chunk), Prüfen (Nachrunde).
|
||||
Zuordnung je Eintrag über das `baustein`-Feld (Chunk-Datei trägt mehrere Bausteine).
|
||||
→ {Baustein-Titel: [{subbaustein, typ, frage}, …]} oder None bei Abbruch."""
|
||||
→ {Baustein-Titel: [{subbaustein, frage}, …]} oder None bei Abbruch."""
|
||||
topic, is_cancelled = ctx.topic, ctx.is_cancelled
|
||||
arbeit = files["arbeit"]
|
||||
# Je Baustein nur RELEVANTE Subbausteine (relevanz != 'rand'; None zählt als relevant).
|
||||
# ALLE Subbausteine (auch rand) bekommen ein Muster — Rand ist in der FGuide-Ebene prüfbar.
|
||||
bausteine = []
|
||||
for titel, subs in sidecar.items():
|
||||
rel = [s["titel"] for s in subs if isinstance(s, dict) and s.get("relevanz") != "rand" and str(s.get("titel", "")).strip()]
|
||||
if rel:
|
||||
bausteine.append((titel, rel))
|
||||
alle = [s["titel"] for s in subs if isinstance(s, dict) and str(s.get("titel", "")).strip()]
|
||||
if alle:
|
||||
bausteine.append((titel, alle))
|
||||
if not bausteine:
|
||||
return {}
|
||||
typen_block = "\n".join(f"- {k}: {v}" for k, v in FRAGETYPEN.items())
|
||||
chunks = _lpt_chunks([len(rel) for _, rel in bausteine], FRAGE_CHUNK_SUBS) # lastbalanciert nach Sub-Zahl
|
||||
|
||||
def roh_path(ci):
|
||||
@@ -1000,7 +1238,7 @@ async def _frage_muster_block(ctx: GenContext, set_p, files: dict, sidecar: dict
|
||||
ctx, f"Frage-Muster {ci}",
|
||||
key=f"bausteine-{topic}-frage-muster-c{ci}",
|
||||
prompt=_prompt("Frage-Muster-Recherche", topic=topic, bausteine=block,
|
||||
typen=typen_block, out_path=fp, extra=_extra(instructions)),
|
||||
out_path=fp, extra=_extra(instructions)),
|
||||
role="fast", capabilities="files",
|
||||
payload=lambda result, p=fp: _frage_muster_chunk_schema(_json_datei(p)),
|
||||
timeout=_timeout("frage_muster", subs_total),
|
||||
@@ -1027,13 +1265,12 @@ async def _frage_muster_block(ctx: GenContext, set_p, files: dict, sidecar: dict
|
||||
titel = _match_sub(e["baustein"], ctitel)
|
||||
if titel not in rel_by:
|
||||
continue # nicht zuordenbar → verwerfen
|
||||
norm = _norm_frage(e["frage"])
|
||||
sub = _match_sub(e["subbaustein"], rel_by[titel])
|
||||
seen = gesehen.setdefault(titel, set())
|
||||
if norm in seen:
|
||||
continue
|
||||
seen.add(norm)
|
||||
out.setdefault(titel, []).append(
|
||||
{"subbaustein": _match_sub(e["subbaustein"], rel_by[titel]), "typ": e["typ"], "frage": e["frage"]})
|
||||
if sub in seen:
|
||||
continue # genau ein Muster je Subbaustein
|
||||
seen.add(sub)
|
||||
out.setdefault(titel, []).append({"subbaustein": sub, "frage": e["frage"]})
|
||||
return out
|
||||
|
||||
def _waehle_all(ci_list):
|
||||
@@ -1057,7 +1294,7 @@ async def _frage_muster_block(ctx: GenContext, set_p, files: dict, sidecar: dict
|
||||
eintraege = roh_by_titel.get(t) or []
|
||||
if not eintraege:
|
||||
continue
|
||||
zeilen = "\n".join(f"- [{e['typ']}] ({e['subbaustein']}) {e['frage']}" for e in eintraege)
|
||||
zeilen = "\n".join(f"- ({e['subbaustein']}) {e['frage']}" for e in eintraege)
|
||||
bloecke.append(f"BAUSTEIN: {t}\n{zeilen}")
|
||||
if not bloecke:
|
||||
return # nichts zu klären in diesem Chunk
|
||||
@@ -1093,7 +1330,7 @@ async def _frage_muster_block(ctx: GenContext, set_p, files: dict, sidecar: dict
|
||||
if titel not in rel_by:
|
||||
continue
|
||||
out.setdefault(titel, []).append(
|
||||
{"subbaustein": _match_sub(e["subbaustein"], rel_by[titel]), "typ": e["typ"], "frage": e["frage"]})
|
||||
{"subbaustein": _match_sub(e["subbaustein"], rel_by[titel]), "frage": e["frage"]})
|
||||
return out
|
||||
|
||||
final_by_titel = _final_by_titel(range(len(chunks)))
|
||||
@@ -1415,6 +1652,226 @@ async def _klaere_inventar(ctx: GenContext, set_p, files: dict) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
# --- Gliederung (Bausteine-Artefakt: Kapitel-Struktur, vom Guide nur gelesen) ---
|
||||
|
||||
def _gliederung_komplett(files: dict) -> bool:
|
||||
"""Gliederung steht (Kapitel-Liste vorhanden)?"""
|
||||
d = _json_datei(files["gliederung"])
|
||||
return isinstance(d, dict) and isinstance(d.get("kapitel"), list) and bool(d.get("kapitel"))
|
||||
|
||||
|
||||
def _gliederung_schema(data, valid: set[int]):
|
||||
"""{"kapitel":[{titel,nummern}]} → bereinigt (valide Nummern, je genau einmal) ·
|
||||
None bei <80 % Abdeckung (Agent/Judge hat zu viel weggelassen)."""
|
||||
if not isinstance(data, dict) or not isinstance(data.get("kapitel"), list):
|
||||
return None
|
||||
out, seen = [], set()
|
||||
for ch in data["kapitel"]:
|
||||
if not isinstance(ch, dict):
|
||||
continue
|
||||
titel = str(ch.get("titel", "")).strip() or "Kapitel"
|
||||
nums = []
|
||||
for n in (ch.get("nummern") or []):
|
||||
try:
|
||||
n = int(n)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if n in valid and n not in seen:
|
||||
seen.add(n)
|
||||
nums.append(n)
|
||||
if nums:
|
||||
out.append({"titel": titel, "nummern": nums})
|
||||
if not out or len(seen) < 0.8 * len(valid):
|
||||
return None
|
||||
return {"kapitel": out}
|
||||
|
||||
|
||||
async def _gliederung_block(ctx: GenContext, set_p, files: dict, entries: dict, instructions: str) -> dict:
|
||||
"""Format-agnostische Gliederung über ALLE Bausteine — 3 Vorschläge → Judge merged.
|
||||
Bricht nie ab: 0 gültige → ein Kapitel mit allem; fehlende Bausteine landen in „Weitere".
|
||||
→ {"kapitel":[{titel,nummern}]} (auch in files["gliederung"])."""
|
||||
topic, is_cancelled = ctx.topic, ctx.is_cancelled
|
||||
valid = set(entries)
|
||||
liste = "\n".join(f"{n}. {t}" for n, t in entries.items())
|
||||
step = _step_idx(topic, "Gliederung")
|
||||
set_p("Gliederung — Vorschläge…", step=step)
|
||||
|
||||
async def _vorschlag(i, path):
|
||||
if _gliederung_schema(_json_datei(path), valid):
|
||||
return True
|
||||
await run_single_slot(
|
||||
ctx, f"Gliederung {i}", key=f"bausteine-{topic}-gliederung-{i}",
|
||||
prompt=_prompt("Guide-Gliederung", topic=topic, bausteine=liste, out_path=path, extra=_extra(instructions)),
|
||||
role="guide", capabilities="files",
|
||||
payload=lambda result, p=path: _gliederung_schema(_json_datei(p), valid),
|
||||
timeout=_timeout("plan", len(entries)))
|
||||
return _gliederung_schema(_json_datei(path), valid) is not None
|
||||
|
||||
slots = files["gliederung_slots"]
|
||||
await _gather_fortschritt([_vorschlag(i, p) for i, p in enumerate(slots, 1)], len(slots), _melde_p(set_p, topic, "Gliederung"))
|
||||
if is_cancelled():
|
||||
return {}
|
||||
vorschlaege = [v for p in slots if (v := _gliederung_schema(_json_datei(p), valid))]
|
||||
|
||||
if not vorschlaege:
|
||||
plan = {"kapitel": [{"titel": "Inhalte", "nummern": list(entries)}]}
|
||||
elif len(vorschlaege) == 1:
|
||||
plan = vorschlaege[0]
|
||||
else:
|
||||
set_p("Gliederung zusammenführen…", step=step)
|
||||
bloecke = "\n\n".join(
|
||||
f"### Vorschlag {i}\n" + "\n".join(
|
||||
f"KAPITEL: {ch['titel']}\n Nummern: {', '.join(str(n) for n in ch['nummern'])}" for ch in v["kapitel"])
|
||||
for i, v in enumerate(vorschlaege, 1))
|
||||
await run_single_slot(
|
||||
ctx, "Gliederung-Judge", key=f"bausteine-{topic}-gliederung-judge",
|
||||
prompt=_prompt("Guide-Gliederung-Judge", topic=topic, format_name="den Guide",
|
||||
zweck="alle Bausteine in einem roten Faden", n=len(vorschlaege),
|
||||
bausteine=liste, gliederungen=bloecke, out_path=files["gliederung"], extra=_extra(instructions)),
|
||||
role="judge", capabilities="files",
|
||||
payload=lambda result: _gliederung_schema(_json_datei(files["gliederung"]), valid),
|
||||
timeout=_timeout("plan_judge", len(entries)))
|
||||
plan = _gliederung_schema(_json_datei(files["gliederung"]), valid) or vorschlaege[0]
|
||||
|
||||
# Vollständigkeit: jeder Baustein kommt vor — fehlende in „Weitere" (gegen weglassende Agenten/Judge).
|
||||
drin = {n for ch in plan["kapitel"] for n in ch["nummern"]}
|
||||
fehlen = [n for n in entries if n not in drin]
|
||||
if fehlen:
|
||||
plan["kapitel"].append({"titel": "Weitere", "nummern": fehlen})
|
||||
atomic_write_json(files["gliederung"], plan, indent=1)
|
||||
return plan
|
||||
|
||||
|
||||
# --- Lern-Artefakte (Karteikarten/Beispiele/Diagramme aus den Fakten) ---
|
||||
|
||||
def _karten_schema(data):
|
||||
"""{"karten":[{baustein,subbaustein,frage,antwort}]} → Liste (auch leer) · None bei kaputt."""
|
||||
if not isinstance(data, dict) or not isinstance(data.get("karten"), list):
|
||||
return None
|
||||
out = []
|
||||
for e in data["karten"]:
|
||||
if isinstance(e, dict) and (f := str(e.get("frage", "")).strip()) and (a := str(e.get("antwort", "")).strip()):
|
||||
out.append({"baustein": str(e.get("baustein", "")).strip(), "subbaustein": str(e.get("subbaustein", "")).strip(),
|
||||
"frage": f, "antwort": a})
|
||||
return out
|
||||
|
||||
|
||||
def _beispiel_schema(data):
|
||||
"""{"beispiele":[{baustein,subbaustein,problem,schritte,ergebnis}]} → Liste (auch leer) · None bei kaputt."""
|
||||
if not isinstance(data, dict) or not isinstance(data.get("beispiele"), list):
|
||||
return None
|
||||
out = []
|
||||
for e in data["beispiele"]:
|
||||
if not isinstance(e, dict):
|
||||
continue
|
||||
problem = str(e.get("problem", "")).strip()
|
||||
schritte = [s for x in (e.get("schritte") or []) if (s := str(x).strip())]
|
||||
if problem and schritte:
|
||||
out.append({"baustein": str(e.get("baustein", "")).strip(), "subbaustein": str(e.get("subbaustein", "")).strip(),
|
||||
"problem": problem, "schritte": schritte, "ergebnis": str(e.get("ergebnis", "")).strip()})
|
||||
return out
|
||||
|
||||
|
||||
def _diagramm_schema(data):
|
||||
"""{"diagramme":[{baustein,mermaid}]} → Liste (auch leer) · None bei kaputt."""
|
||||
if not isinstance(data, dict) or not isinstance(data.get("diagramme"), list):
|
||||
return None
|
||||
out = []
|
||||
for e in data["diagramme"]:
|
||||
if isinstance(e, dict) and (m := str(e.get("mermaid", "")).strip()):
|
||||
out.append({"baustein": str(e.get("baustein", "")).strip(), "mermaid": m})
|
||||
return out
|
||||
|
||||
|
||||
_ARTEFAKT_SCHEMA = {"karteikarte": _karten_schema, "beispiel": _beispiel_schema, "diagramm": _diagramm_schema}
|
||||
_ARTEFAKT_PROMPT = {"karteikarte": "Artefakt-Karteikarte", "beispiel": "Artefakt-Beispiel", "diagramm": "Artefakt-Diagramm"}
|
||||
_ARTEFAKT_SCHRITT = {"karteikarte": "Karteikarten", "beispiel": "Beispiele", "diagramm": "Diagramme"}
|
||||
|
||||
|
||||
def _artefakte_komplett(files: dict) -> bool:
|
||||
"""Artefakt-Map steht (alle Typen erzeugt)? Werte dürfen leer sein (content-aware)."""
|
||||
d = _json_datei(files["artefakte"])
|
||||
return isinstance(d, dict) and all(t in d for t in ARTEFAKT_TYPEN)
|
||||
|
||||
|
||||
async def _artefakte_block(ctx: GenContext, set_p, files: dict, sidecar: dict, instructions: str) -> dict | None:
|
||||
"""Lern-Artefakte je Typ aus den gespeicherten Fakten erzeugen — ein Generierungs-Durchlauf
|
||||
je Typ über Chunks, keine Verifikation (Bonus-Material). → {typ: [eintraege]} (auch in files)."""
|
||||
topic, is_cancelled = ctx.topic, ctx.is_cancelled
|
||||
arbeit = files["arbeit"]
|
||||
# Bausteine mit Subs + Fakten-Zeilen als Input-Block (extract-once aus den Fakten).
|
||||
bausteine = []
|
||||
for btitel, subs in sidecar.items():
|
||||
if not isinstance(subs, list):
|
||||
continue
|
||||
zeilen = []
|
||||
for s in subs:
|
||||
if not isinstance(s, dict) or not (st := str(s.get("titel", "")).strip()):
|
||||
continue
|
||||
fk = s.get("fakten") if isinstance(s.get("fakten"), dict) else {}
|
||||
zeile = f"- {st}"
|
||||
if fk and (fk_text := _fakten_zeilen(fk)):
|
||||
zeile += "\n" + "\n".join(" " + l for l in fk_text.split("\n"))
|
||||
zeilen.append(zeile)
|
||||
if zeilen:
|
||||
bausteine.append((btitel, zeilen))
|
||||
if not bausteine:
|
||||
leer = {t: [] for t in ARTEFAKT_TYPEN}
|
||||
atomic_write_json(files["artefakte"], leer, indent=1)
|
||||
return leer
|
||||
|
||||
chunks = _lpt_chunks([len(z) for _, z in bausteine], FAKTEN_CHUNK_SUBS)
|
||||
def block_text(idxs):
|
||||
return "\n\n".join(f"BAUSTEIN: {bausteine[i][0]}\nSUBBAUSTEINE:\n" + "\n".join(bausteine[i][1]) for i in idxs)
|
||||
|
||||
ergebnis: dict[str, list] = {}
|
||||
for typ in ARTEFAKT_TYPEN:
|
||||
schema = _ARTEFAKT_SCHEMA[typ]
|
||||
def apath(ci, t=typ): return arbeit / f"artefakt-{t}-c{ci}.json"
|
||||
|
||||
async def _gen(ci, idxs, t=typ, schema=schema):
|
||||
p = apath(ci, t)
|
||||
if schema(_json_datei(p)) is not None:
|
||||
return True
|
||||
await run_single_slot(
|
||||
ctx, f"{_ARTEFAKT_SCHRITT[t]} {ci}", key=f"bausteine-{topic}-artefakt-{t}-c{ci}",
|
||||
prompt=_prompt(_ARTEFAKT_PROMPT[t], topic=topic, bausteine=block_text(idxs), out_path=p, extra=_extra(instructions)),
|
||||
role="guide", capabilities="files",
|
||||
payload=lambda result, p=p, schema=schema: schema(_json_datei(p)),
|
||||
timeout=_timeout("inhalt", sum(len(bausteine[i][1]) for i in idxs)))
|
||||
return schema(_json_datei(p)) is not None
|
||||
|
||||
await _gather_fortschritt([_gen(ci, idxs) for ci, idxs in enumerate(chunks)], len(chunks), _melde_p(set_p, topic, _ARTEFAKT_SCHRITT[typ]))
|
||||
if is_cancelled():
|
||||
return None
|
||||
eintraege: list = []
|
||||
for ci in range(len(chunks)):
|
||||
eintraege += schema(_json_datei(apath(ci))) or []
|
||||
ergebnis[typ] = eintraege
|
||||
atomic_write_json(files["artefakte"], ergebnis, indent=1)
|
||||
return ergebnis
|
||||
|
||||
|
||||
async def _mirror_artefakte_db(topic: str, sidecar: dict, artefakte: dict) -> None:
|
||||
"""Artefakte in die DB spiegeln. Karteikarte/Beispiel je Sub (sub_norm), Diagramm je Baustein (sub_norm='')."""
|
||||
await db.delete_sub_artefakte(topic)
|
||||
btitel_list = list(sidecar.keys())
|
||||
for typ in ("karteikarte", "beispiel"):
|
||||
for e in artefakte.get(typ, []):
|
||||
bt = _match_sub(e.get("baustein", ""), btitel_list)
|
||||
bnorm, sn = _norm_titel(bt), _norm_titel(e.get("subbaustein", ""))
|
||||
if not bnorm or not sn:
|
||||
continue
|
||||
daten = json.dumps({k: v for k, v in e.items() if k not in ("baustein", "subbaustein")}, ensure_ascii=False)
|
||||
await db.put_sub_artefakt(topic, bnorm, sn, typ, daten, bt, e.get("subbaustein", ""))
|
||||
for e in artefakte.get("diagramm", []):
|
||||
bt = _match_sub(e.get("baustein", ""), btitel_list)
|
||||
if not (bnorm := _norm_titel(bt)):
|
||||
continue
|
||||
daten = json.dumps({"mermaid": e.get("mermaid", "")}, ensure_ascii=False)
|
||||
await db.put_sub_artefakt(topic, bnorm, "", "diagramm", daten, bt, "")
|
||||
|
||||
|
||||
async def _mirror_sidecar_db(topic: str, sidecar: dict) -> None:
|
||||
"""Sidecar {Baustein-Titel: [{titel, stufe, relevanz}]} in die DB-Tabelle subbausteine spiegeln."""
|
||||
for btitel, subs in sidecar.items():
|
||||
@@ -1428,12 +1885,14 @@ async def _mirror_sidecar_db(topic: str, sidecar: dict) -> None:
|
||||
sn = _norm_titel(st)
|
||||
if not sn:
|
||||
continue
|
||||
fakten = json.dumps(s["fakten"], ensure_ascii=False) if isinstance(s.get("fakten"), dict) else None
|
||||
await db.put_subbaustein(topic, bnorm, sn, btitel, st,
|
||||
stufe=s.get("stufe"), relevanz=s.get("relevanz"), status="konsens")
|
||||
stufe=s.get("stufe"), relevanz=s.get("relevanz"),
|
||||
fakten=fakten, status="konsens")
|
||||
|
||||
|
||||
async def _mirror_frage_muster_db(topic: str, muster: dict) -> None:
|
||||
"""Frage-Muster {Baustein-Titel: [{subbaustein, typ, frage}]} in die DB-Tabelle frage_muster spiegeln."""
|
||||
"""Frage-Muster {Baustein-Titel: [{subbaustein, frage}]} in die DB-Tabelle frage_muster spiegeln."""
|
||||
await db.delete_frage_muster(topic)
|
||||
for btitel, eintraege in muster.items():
|
||||
bnorm = _norm_titel(btitel)
|
||||
@@ -1444,19 +1903,22 @@ async def _mirror_frage_muster_db(topic: str, muster: dict) -> None:
|
||||
continue
|
||||
sub = str(e.get("subbaustein", "")).strip()
|
||||
sn = _norm_titel(sub)
|
||||
typ = str(e.get("typ", "")).strip()
|
||||
frage = str(e.get("frage", "")).strip()
|
||||
if not (sn and typ and frage):
|
||||
if not (sn and frage):
|
||||
continue
|
||||
await db.upsert_frage_muster(topic, bnorm, sn, btitel, sub, typ, frage)
|
||||
await db.upsert_frage_muster(topic, bnorm, sn, btitel, sub, frage)
|
||||
|
||||
|
||||
async def _reset_db_ab_phase(topic: str, label: str) -> None:
|
||||
"""DB-Inhalt der Phasen ≥ `label` verwerfen (kanonische Reihenfolge Quelle…Fragen)."""
|
||||
"""DB-Inhalt der Phasen ≥ `label` verwerfen (kanonische Reihenfolge Quelle…Artefakte)."""
|
||||
idx = _phase_idx(label)
|
||||
if idx <= 5: # Fragen
|
||||
if idx <= 8: # Artefakte (Karteikarten/Beispiele/Diagramme)
|
||||
await db.delete_sub_artefakte(topic)
|
||||
if idx <= 7: # Fragen
|
||||
await db.delete_frage_muster(topic)
|
||||
if idx <= 2: # Subbausteine (Stufen/Relevanz greifen über Sidecar→Mirror)
|
||||
if idx <= 6: # Gliederung
|
||||
await db.delete_gliederung(topic)
|
||||
if idx <= 2: # Subbausteine (Fakten/Stufen/Relevanz greifen über Sidecar→Mirror)
|
||||
await db.delete_subbausteine(topic)
|
||||
if idx <= 1: # Inventar: Inventar + Recherche-Schritte — Sichtung bleibt
|
||||
await db.delete_bausteine(topic)
|
||||
@@ -1519,6 +1981,8 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
|
||||
await db.delete_subbausteine(topic)
|
||||
await db.delete_frage_muster(topic)
|
||||
await db.delete_coverage(topic)
|
||||
await db.delete_gliederung(topic)
|
||||
await db.delete_sub_artefakte(topic)
|
||||
|
||||
# Inventar (DB): Recherche-Loop → Konsolidierung → Klärung.
|
||||
if not await _recherche_batch(ctx, set_p, files, q, ordner, instructions):
|
||||
@@ -1590,12 +2054,30 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
|
||||
if roh is None:
|
||||
return # Fehler ist gesetzt
|
||||
atomic_write_json(files["sub_roh"], roh, indent=1)
|
||||
# Fakten je Sub (VOR der Stufe): Quell-Fakten extrahieren + verifizieren → fakten.json.
|
||||
# Extract-once-Grounding — Stufe/Relevanz/Fragen/Guide nähren sich daraus.
|
||||
if not _fakten_komplett(files):
|
||||
fakten_map = await _fakten_block(ctx, set_p, files, roh, q, ordner, instructions)
|
||||
if is_cancelled():
|
||||
abgebrochen()
|
||||
return
|
||||
if fakten_map is None:
|
||||
return # Fehler ist gesetzt
|
||||
atomic_write_json(files["fakten"], fakten_map, indent=1)
|
||||
sidecar = await _stufen_block(ctx, set_p, files, roh, instructions)
|
||||
if is_cancelled():
|
||||
abgebrochen()
|
||||
return
|
||||
if sidecar is None:
|
||||
return
|
||||
# Fakten in die Sidecar-Subs mergen (DB-Spiegel + Guide-Nutzung).
|
||||
fakten_map = _json_datei(files["fakten"])
|
||||
if isinstance(fakten_map, dict):
|
||||
for btitel, subs in sidecar.items():
|
||||
fm = fakten_map.get(btitel, {})
|
||||
for sub in subs:
|
||||
if (fk := fm.get(_norm_titel(sub["titel"]))):
|
||||
sub["fakten"] = fk
|
||||
atomic_write_json(files["sidecar"], sidecar, indent=1)
|
||||
|
||||
# Block D: Relevanz je Subbaustein (relevant/rand) → in die Sidecar mergen.
|
||||
@@ -1616,6 +2098,14 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
|
||||
sub["relevanz"] = relevanz_by_id.get(gid, "relevant")
|
||||
atomic_write_json(files["sidecar"], sidecar, indent=1)
|
||||
|
||||
# Block D.5: Gliederung (Bausteine-Artefakt) — Kapitel-Struktur über ALLE Bausteine,
|
||||
# vom Guide nur noch gelesen. Format-agnostisch; der Guide filtert je Format.
|
||||
if not _gliederung_komplett(files):
|
||||
await _gliederung_block(ctx, set_p, files, entries, instructions)
|
||||
if is_cancelled():
|
||||
abgebrochen()
|
||||
return
|
||||
|
||||
# Block E: Frage-Muster je relevantem Subbaustein × Typ → eigenes Sidecar.
|
||||
# Zur Prüfungszeit zieht jeder Agent ein Muster ohne Zurücklegen und formuliert
|
||||
# daraus eine Frage — distinkte Saat verhindert die Doppelfragen der Live-Generierung.
|
||||
@@ -1629,6 +2119,17 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
|
||||
return # Abbruch
|
||||
atomic_write_json(files["frage_muster"], muster, indent=1)
|
||||
|
||||
# Block F: Lern-Artefakte (Karteikarten/Beispiele/Diagramme) aus den Fakten — Bonus,
|
||||
# vom Frontend präsentiert. Bricht den Lauf nicht ab (Artefakte sind optional).
|
||||
sidecar = _json_datei(files["sidecar"])
|
||||
if _sidecar_schema(sidecar) is not None and not _artefakte_komplett(files):
|
||||
artefakte = await _artefakte_block(ctx, set_p, files, sidecar, instructions)
|
||||
if is_cancelled():
|
||||
abgebrochen()
|
||||
return
|
||||
if artefakte is None:
|
||||
return # Abbruch (Fehler/Cancel)
|
||||
|
||||
# DB-Spiegel (Brücke): finalen Sidecar- + Frage-Muster-Stand in die DB schreiben.
|
||||
sidecar = _json_datei(files["sidecar"])
|
||||
if _sidecar_schema(sidecar) is not None:
|
||||
@@ -1636,6 +2137,19 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
|
||||
muster = _json_datei(files["frage_muster"])
|
||||
if isinstance(muster, dict) and muster:
|
||||
await _mirror_frage_muster_db(topic, muster)
|
||||
# Gliederung (Titel-basiert, robust gegen Nummern-Drift) → DB.
|
||||
plan = _json_datei(files["gliederung"])
|
||||
if isinstance(plan, dict) and plan.get("kapitel"):
|
||||
kapitel = [
|
||||
{"titel": ch.get("titel", "Kapitel"),
|
||||
"bausteine": [_titel(entries[n]) for n in ch.get("nummern", []) if n in entries]}
|
||||
for ch in plan["kapitel"]
|
||||
]
|
||||
await db.set_gliederung(topic, json.dumps({"kapitel": kapitel}, ensure_ascii=False))
|
||||
# Artefakte → DB (Karteikarte/Beispiel je Sub, Diagramm je Baustein).
|
||||
artefakte = _json_datei(files["artefakte"])
|
||||
if isinstance(artefakte, dict) and _sidecar_schema(sidecar) is not None:
|
||||
await _mirror_artefakte_db(topic, sidecar, artefakte)
|
||||
except Exception as e:
|
||||
log.exception("[%s] Bausteine-Generierung fehlgeschlagen", topic)
|
||||
_bausteine_errors[topic] = str(e)[:2000]
|
||||
|
||||
@@ -103,13 +103,15 @@ CREATE TABLE IF NOT EXISTS subbausteine (
|
||||
nennungen INTEGER NOT NULL DEFAULT 1,
|
||||
stufe TEXT,
|
||||
relevanz TEXT,
|
||||
fakten TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'kandidat',
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (topic, baustein_norm, sub_norm)
|
||||
)
|
||||
"""
|
||||
|
||||
# Ein Frage-Muster je (Baustein, Subbaustein, Typ).
|
||||
# Ein Frage-Muster je (Baustein, Subbaustein). Schwierigkeit kommt erst bei der Prüfung
|
||||
# aus dem Lerner-Niveau, nicht aus dem Muster — daher kein Typ-Kreuzprodukt mehr.
|
||||
CREATE_FRAGE_MUSTER = """
|
||||
CREATE TABLE IF NOT EXISTS frage_muster (
|
||||
topic TEXT NOT NULL,
|
||||
@@ -117,10 +119,9 @@ CREATE TABLE IF NOT EXISTS frage_muster (
|
||||
sub_norm TEXT NOT NULL,
|
||||
baustein TEXT NOT NULL,
|
||||
sub_titel TEXT NOT NULL,
|
||||
typ TEXT NOT NULL,
|
||||
frage TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (topic, baustein_norm, sub_norm, typ)
|
||||
PRIMARY KEY (topic, baustein_norm, sub_norm)
|
||||
)
|
||||
"""
|
||||
|
||||
@@ -170,6 +171,33 @@ CREATE TABLE IF NOT EXISTS quelle (
|
||||
)
|
||||
"""
|
||||
|
||||
# Gliederung (Kapitel → Baustein-Nummern) je Thema als JSON. In der Bausteine-Phase erzeugt,
|
||||
# vom Guide nur noch gelesen + je Format gefiltert. Format-agnostisch (alle Bausteine).
|
||||
CREATE_GUIDE_GLIEDERUNG = """
|
||||
CREATE TABLE IF NOT EXISTS guide_gliederung (
|
||||
topic TEXT PRIMARY KEY,
|
||||
json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
|
||||
# Generischer Lern-Artefakt-Layer: je (Baustein, Subbaustein, Typ) ein Artefakt (JSON in `daten`).
|
||||
# typ: karteikarte | beispiel | diagramm. sub_norm='' = Baustein-Ebene (z. B. Diagramm).
|
||||
# In der Bausteine-Phase aus den Fakten erzeugt, vom Frontend präsentiert (Trennung Artefakt/Anzeige).
|
||||
CREATE_SUB_ARTEFAKTE = """
|
||||
CREATE TABLE IF NOT EXISTS sub_artefakte (
|
||||
topic TEXT NOT NULL,
|
||||
baustein_norm TEXT NOT NULL,
|
||||
sub_norm TEXT NOT NULL DEFAULT '',
|
||||
typ TEXT NOT NULL,
|
||||
baustein TEXT NOT NULL DEFAULT '',
|
||||
sub_titel TEXT NOT NULL DEFAULT '',
|
||||
daten TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (topic, baustein_norm, sub_norm, typ)
|
||||
)
|
||||
"""
|
||||
|
||||
_db: aiosqlite.Connection | None = None
|
||||
|
||||
|
||||
@@ -199,6 +227,8 @@ async def init_db():
|
||||
await db.execute(CREATE_PIPELINE_STATE)
|
||||
await db.execute(CREATE_GUIDE_CONTENT)
|
||||
await db.execute(CREATE_QUELLE)
|
||||
await db.execute(CREATE_GUIDE_GLIEDERUNG)
|
||||
await db.execute(CREATE_SUB_ARTEFAKTE)
|
||||
try: # Migration für Bestands-DBs ohne step-Spalte
|
||||
await db.execute("ALTER TABLE guides ADD COLUMN step INTEGER")
|
||||
except aiosqlite.OperationalError:
|
||||
@@ -226,6 +256,16 @@ async def init_db():
|
||||
await db.execute(f"ALTER TABLE baustein_progress ADD COLUMN {_spalte} {_typ}")
|
||||
except aiosqlite.OperationalError:
|
||||
pass
|
||||
# Migration: frage_muster ohne typ-Spalte (1 Muster je Sub statt Sub×Typ-Kreuzprodukt).
|
||||
# PK-Änderung → Tabelle einmalig neu bauen. Bestands-Muster gehen verloren (bewusst, kein Mapping).
|
||||
cursor = await db.execute("PRAGMA table_info(frage_muster)")
|
||||
if any(_r[1] == "typ" for _r in await cursor.fetchall()):
|
||||
await db.execute("DROP TABLE frage_muster")
|
||||
await db.execute(CREATE_FRAGE_MUSTER)
|
||||
try: # Migration: subbausteine.fakten (Quell-Fakten je Sub, JSON-Blob) — Extract-once-Grounding.
|
||||
await db.execute("ALTER TABLE subbausteine ADD COLUMN fakten TEXT") # nullable: COALESCE schützt Bestand
|
||||
except aiosqlite.OperationalError:
|
||||
pass
|
||||
# Migration: alte vertiefungen-Tabelle → baustein_texte (Bestand = lange Form, art 'deepdive')
|
||||
cursor = await db.execute("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'vertiefungen'")
|
||||
if await cursor.fetchone():
|
||||
@@ -482,6 +522,21 @@ async def set_baustein_score(topic: str, baustein: str, score: int) -> int:
|
||||
return score
|
||||
|
||||
|
||||
async def set_baustein_score_and_streak(topic: str, baustein: str, score: int, streak: int) -> tuple[int, int]:
|
||||
"""Setzt Score + Streak atomar (vom Aufrufer geclampt). Liefert (score, streak)."""
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"""INSERT INTO baustein_progress (topic, baustein, gute_antworten, streak, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(topic, baustein) DO UPDATE SET
|
||||
gute_antworten = excluded.gute_antworten, streak = excluded.streak,
|
||||
updated_at = excluded.updated_at""",
|
||||
(topic, baustein, score, streak, _now()),
|
||||
)
|
||||
await db.commit()
|
||||
return score, streak
|
||||
|
||||
|
||||
async def delete_baustein_progress(topic: str, baustein: str) -> None:
|
||||
"""Fortschritt EINES Bausteins zurücksetzen: Zeile löschen (Score/Streak/Flags/offene Frage).
|
||||
Fehlt die Zeile, liefert get_baustein_progress Defaults (0) — also voller Reset."""
|
||||
@@ -574,6 +629,75 @@ async def subs_count_alle() -> dict[tuple[str, str], int]:
|
||||
return {(t, bn): n for t, bn, n in await cursor.fetchall()}
|
||||
|
||||
|
||||
# Sub-Ebene aus den zwei orthogonalen Spalten: rand → 4 (V), sonst Stufe (Lernpfad-Position):
|
||||
# anfaenger/NULL → 1, fortgeschritten → 2, experte → 3. Alte Werte (einfach/mittel/schwer) werden
|
||||
# abwärtskompatibel mitgemappt. Steuert Guide-Ansicht A/F/E/V + cap (freigeschaltete Subs × 25).
|
||||
_EBENE_CASE = """CASE
|
||||
WHEN relevanz = 'rand' THEN 4
|
||||
WHEN stufe IN ('fortgeschritten', 'mittel') THEN 2
|
||||
WHEN stufe IN ('experte', 'schwer') THEN 3
|
||||
ELSE 1
|
||||
END"""
|
||||
|
||||
|
||||
def _leere_ebenen() -> dict[int, int]:
|
||||
return {1: 0, 2: 0, 3: 0, 4: 0}
|
||||
|
||||
|
||||
async def subs_je_ebene(topic: str, baustein: str) -> dict[int, int]:
|
||||
"""Konsens-Subbausteine eines Bausteins je Ebene 1–4. roher Baustein-Titel rein."""
|
||||
from textkit import _norm_titel
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
f"SELECT {_EBENE_CASE} AS ebene, COUNT(*) FROM subbausteine "
|
||||
"WHERE topic = ? AND baustein_norm = ? AND status = 'konsens' GROUP BY ebene",
|
||||
(topic, _norm_titel(baustein)),
|
||||
)
|
||||
out = _leere_ebenen()
|
||||
for ebene, n in await cursor.fetchall():
|
||||
out[ebene] = n
|
||||
return out
|
||||
|
||||
|
||||
async def subs_je_ebene_roh(topic: str) -> dict[str, dict[int, int]]:
|
||||
"""Subbausteine je Ebene, gruppiert nach ROHEM Baustein-Titel (= Guide-Section-Titel)."""
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
f"SELECT baustein, {_EBENE_CASE} AS ebene, COUNT(*) FROM subbausteine "
|
||||
"WHERE topic = ? AND status = 'konsens' GROUP BY baustein, ebene",
|
||||
(topic,),
|
||||
)
|
||||
out: dict[str, dict[int, int]] = {}
|
||||
for b, ebene, n in await cursor.fetchall():
|
||||
out.setdefault(b, _leere_ebenen())[ebene] = n
|
||||
return out
|
||||
|
||||
|
||||
async def subs_je_ebene_alle() -> dict[tuple[str, str], dict[int, int]]:
|
||||
"""Subbausteine je Ebene je (topic, baustein_norm) — für die themenweite Stufen-Ableitung."""
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
f"SELECT topic, baustein_norm, {_EBENE_CASE} AS ebene, COUNT(*) FROM subbausteine "
|
||||
"WHERE status = 'konsens' GROUP BY topic, baustein_norm, ebene"
|
||||
)
|
||||
out: dict[tuple[str, str], dict[int, int]] = {}
|
||||
for t, bn, ebene, n in await cursor.fetchall():
|
||||
out.setdefault((t, bn), _leere_ebenen())[ebene] = n
|
||||
return out
|
||||
|
||||
|
||||
async def subs_mit_ebene(topic: str, baustein: str) -> list[dict]:
|
||||
"""Konsens-Subbausteine eines Bausteins mit Titel, sub_norm und Ebene 1–4."""
|
||||
from textkit import _norm_titel
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
f"SELECT sub_titel, sub_norm, {_EBENE_CASE} AS ebene FROM subbausteine "
|
||||
"WHERE topic = ? AND baustein_norm = ? AND status = 'konsens'",
|
||||
(topic, _norm_titel(baustein)),
|
||||
)
|
||||
return [{"titel": t, "norm": sn, "ebene": e} for t, sn, e in await cursor.fetchall()]
|
||||
|
||||
|
||||
async def list_baustein_scores_all() -> list[tuple[str, str, int]]:
|
||||
"""(topic, baustein, gute_antworten) je Baustein — Rohdaten für die Stufen-Ableitung."""
|
||||
db = await get_db()
|
||||
@@ -653,19 +777,21 @@ async def upsert_subbaustein(topic: str, baustein_norm: str, sub_norm: str, baus
|
||||
|
||||
|
||||
async def put_subbaustein(topic: str, baustein_norm: str, sub_norm: str, baustein: str, sub_titel: str,
|
||||
stufe: str | None = None, relevanz: str | None = None, status: str = "konsens") -> None:
|
||||
"""Insert/Update OHNE Nennungszähler (Spiegel aus dem Sidecar). stufe/relevanz nur überschreiben,
|
||||
wenn ein neuer Wert übergeben wird (COALESCE schützt Bestehendes)."""
|
||||
stufe: str | None = None, relevanz: str | None = None,
|
||||
fakten: str | None = None, status: str = "konsens") -> None:
|
||||
"""Insert/Update OHNE Nennungszähler (Spiegel aus dem Sidecar). stufe/relevanz/fakten nur
|
||||
überschreiben, wenn ein neuer Wert übergeben wird (COALESCE schützt Bestehendes)."""
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"""INSERT INTO subbausteine (topic, baustein_norm, sub_norm, baustein, sub_titel, nennungen, stufe, relevanz, status, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?)
|
||||
"""INSERT INTO subbausteine (topic, baustein_norm, sub_norm, baustein, sub_titel, nennungen, stufe, relevanz, fakten, status, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(topic, baustein_norm, sub_norm) DO UPDATE SET
|
||||
baustein = excluded.baustein, sub_titel = excluded.sub_titel,
|
||||
stufe = COALESCE(excluded.stufe, subbausteine.stufe),
|
||||
relevanz = COALESCE(excluded.relevanz, subbausteine.relevanz),
|
||||
fakten = COALESCE(excluded.fakten, subbausteine.fakten),
|
||||
status = excluded.status, updated_at = excluded.updated_at""",
|
||||
(topic, baustein_norm, sub_norm, baustein, sub_titel, stufe, relevanz, status, _now()),
|
||||
(topic, baustein_norm, sub_norm, baustein, sub_titel, stufe, relevanz, fakten, status, _now()),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
@@ -700,14 +826,14 @@ async def delete_subbausteine(topic: str) -> None:
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def upsert_frage_muster(topic: str, baustein_norm: str, sub_norm: str, baustein: str, sub_titel: str, typ: str, frage: str) -> None:
|
||||
async def upsert_frage_muster(topic: str, baustein_norm: str, sub_norm: str, baustein: str, sub_titel: str, frage: str) -> None:
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"""INSERT INTO frage_muster (topic, baustein_norm, sub_norm, baustein, sub_titel, typ, frage, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(topic, baustein_norm, sub_norm, typ) DO UPDATE SET
|
||||
frage = excluded.frage, updated_at = excluded.updated_at""",
|
||||
(topic, baustein_norm, sub_norm, baustein, sub_titel, typ, frage, _now()),
|
||||
"""INSERT INTO frage_muster (topic, baustein_norm, sub_norm, baustein, sub_titel, frage, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(topic, baustein_norm, sub_norm) DO UPDATE SET
|
||||
sub_titel = excluded.sub_titel, frage = excluded.frage, updated_at = excluded.updated_at""",
|
||||
(topic, baustein_norm, sub_norm, baustein, sub_titel, frage, _now()),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
@@ -849,10 +975,90 @@ async def delete_guide_content(topic: str, format: str | None = None) -> None:
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def set_gliederung(topic: str, gliederung_json: str) -> None:
|
||||
"""Gliederung (Kapitel→Nummern, JSON) je Thema speichern — Bausteine-Artefakt für den Guide."""
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"""INSERT INTO guide_gliederung (topic, json, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(topic) DO UPDATE SET json = excluded.json, updated_at = excluded.updated_at""",
|
||||
(topic, gliederung_json, _now()),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_gliederung(topic: str) -> str | None:
|
||||
db = await get_db()
|
||||
cursor = await db.execute("SELECT json FROM guide_gliederung WHERE topic = ?", (topic,))
|
||||
row = await cursor.fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
async def delete_gliederung(topic: str) -> None:
|
||||
db = await get_db()
|
||||
await db.execute("DELETE FROM guide_gliederung WHERE topic = ?", (topic,))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def put_sub_artefakt(topic: str, baustein_norm: str, sub_norm: str, typ: str,
|
||||
daten: str, baustein: str = "", sub_titel: str = "") -> None:
|
||||
"""Ein Lern-Artefakt (karteikarte/beispiel/diagramm) als JSON in `daten` speichern."""
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"""INSERT INTO sub_artefakte (topic, baustein_norm, sub_norm, typ, baustein, sub_titel, daten, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(topic, baustein_norm, sub_norm, typ) DO UPDATE SET
|
||||
baustein = excluded.baustein, sub_titel = excluded.sub_titel,
|
||||
daten = excluded.daten, updated_at = excluded.updated_at""",
|
||||
(topic, baustein_norm, sub_norm, typ, baustein, sub_titel, daten, _now()),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_sub_artefakte(topic: str, typ: str | None = None) -> list[dict]:
|
||||
db = await get_db()
|
||||
if typ is None:
|
||||
cursor = await db.execute("SELECT * FROM sub_artefakte WHERE topic = ? ORDER BY rowid", (topic,))
|
||||
else:
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM sub_artefakte WHERE topic = ? AND typ = ? ORDER BY rowid", (topic, typ)
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [_row_to_dict(row, cursor) for row in rows]
|
||||
|
||||
|
||||
async def delete_sub_artefakte(topic: str) -> None:
|
||||
db = await get_db()
|
||||
await db.execute("DELETE FROM sub_artefakte WHERE topic = ?", (topic,))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_baustein_huerden(topic: str, baustein_norm: str) -> list[str]:
|
||||
"""Typische Irrtümer (huerden) aller Subs eines Bausteins — als Distraktor-Pool fürs Quiz.
|
||||
Liest aus subbausteine.fakten (JSON-Blob); leere/fehlende werden übersprungen."""
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
"SELECT fakten FROM subbausteine WHERE topic = ? AND baustein_norm = ?", (topic, baustein_norm)
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
huerden = []
|
||||
for (fakten,) in rows:
|
||||
if not fakten:
|
||||
continue
|
||||
try:
|
||||
fk = json.loads(fakten)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
h = (fk.get("huerden") or "").strip() if isinstance(fk, dict) else ""
|
||||
if h:
|
||||
huerden.append(h)
|
||||
return huerden
|
||||
|
||||
|
||||
async def delete_topic_pipeline(topic: str) -> None:
|
||||
"""Bausteine-Bereich eines Themas verwerfen (Inventar/Subs/Muster/Coverage/State).
|
||||
"""Bausteine-Bereich eines Themas verwerfen (Inventar/Subs/Muster/Coverage/State/Artefakte).
|
||||
NICHT die Themen-Config `quelle` — die wird separat verwaltet (delete_quelle)."""
|
||||
db = await get_db()
|
||||
for tab in ("bausteine", "subbausteine", "frage_muster", "recherche_coverage", "pipeline_state"):
|
||||
for tab in ("bausteine", "subbausteine", "frage_muster", "recherche_coverage",
|
||||
"pipeline_state", "guide_gliederung", "sub_artefakte"):
|
||||
await db.execute(f"DELETE FROM {tab} WHERE topic = ?", (topic,))
|
||||
await db.commit()
|
||||
|
||||
194
backend/guide.py
194
backend/guide.py
@@ -24,7 +24,7 @@ from config import (
|
||||
LESBARKEIT_AKTIV, TEMPLATES_DIR,
|
||||
)
|
||||
import lesbarkeit
|
||||
from database import list_guides, update_guide, list_bausteine, list_subbausteine, set_guide_content, get_guide_content
|
||||
from database import list_guides, update_guide, list_bausteine, list_subbausteine, set_guide_content, get_guide_content, get_gliederung
|
||||
from fsutil import atomic_write_json, atomic_write_text
|
||||
from jsonio import read_json_file as _json_datei, parse_json_text as _parse_json_text
|
||||
from paths import bausteine_path, guide_content_path, project_dir, subbausteine_path
|
||||
@@ -35,7 +35,7 @@ from pipeline import (
|
||||
is_guide_cancelled, run_single_slot,
|
||||
)
|
||||
from textkit import (
|
||||
_eindeutige_titel, _lade_bausteine, _parse_fragment, _split_chunks,
|
||||
_eindeutige_titel, _lade_bausteine, _norm_titel, _parse_fragment, _split_chunks,
|
||||
_titel, _titel_aufloesen, _titel_index,
|
||||
)
|
||||
|
||||
@@ -57,14 +57,22 @@ CHECK_PANEL = 3
|
||||
LESE_RUNDEN = 1
|
||||
|
||||
|
||||
# Gültige Stufen-Werte: neu (Lernpfad) + alt (Schwierigkeit) abwärtskompatibel.
|
||||
_STUFEN_OK = ("anfaenger", "fortgeschritten", "experte", "einfach", "mittel", "schwer")
|
||||
|
||||
|
||||
async def _load_subbausteine(topic: str) -> dict[str, list[dict]]:
|
||||
"""Subbausteine je Baustein — DB-first ({titel, stufe, relevanz}), Fallback Sidecar-Datei.
|
||||
Fehlt beides → {} (Guide nimmt alles)."""
|
||||
out: dict[str, list[dict]] = {}
|
||||
for r in await list_subbausteine(topic):
|
||||
if r["status"] == "konsens" and r["sub_titel"] and r["stufe"] in ("einfach", "mittel", "schwer"):
|
||||
if r["status"] == "konsens" and r["sub_titel"] and r["stufe"] in _STUFEN_OK:
|
||||
try:
|
||||
fakten = json.loads(r["fakten"]) if r.get("fakten") else {}
|
||||
except (ValueError, TypeError):
|
||||
fakten = {}
|
||||
out.setdefault(r["baustein"], []).append(
|
||||
{"titel": r["sub_titel"], "stufe": r["stufe"], "relevanz": r["relevanz"]})
|
||||
{"titel": r["sub_titel"], "stufe": r["stufe"], "relevanz": r["relevanz"], "fakten": fakten})
|
||||
if out:
|
||||
return out
|
||||
data = _json_datei(subbausteine_path(topic))
|
||||
@@ -74,21 +82,26 @@ async def _load_subbausteine(topic: str) -> dict[str, list[dict]]:
|
||||
if not isinstance(subs, list):
|
||||
continue
|
||||
gut = [s for s in subs if isinstance(s, dict) and str(s.get("titel", "")).strip()
|
||||
and s.get("stufe") in ("einfach", "mittel", "schwer")]
|
||||
and s.get("stufe") in _STUFEN_OK]
|
||||
if gut:
|
||||
out[titel] = gut
|
||||
return out
|
||||
|
||||
|
||||
def _ebene_label(s: dict) -> str:
|
||||
"""Ansichts-Ebene eines Subbausteins: rand → 'rand' (Ebene 4), sonst die Stufe (1–3)."""
|
||||
return "rand" if s.get("relevanz") == "rand" else (s.get("stufe") or "anfaenger")
|
||||
|
||||
|
||||
def _zuteilung_subs(chunk: list[dict], entries: dict[int, str], subs_by_titel: dict[str, list[dict]]) -> str:
|
||||
"""Listet je Kapitel die Bausteine, darunter ihre Subbausteine mit Stufe."""
|
||||
"""Listet je Kapitel die Bausteine, darunter ihre Subbausteine mit Ebenen-Label."""
|
||||
lines: list[str] = []
|
||||
for ch in chunk:
|
||||
lines.append(f"KAPITEL: {ch['title']}")
|
||||
for num in ch["nums"]:
|
||||
lines.append(f"- {entries[num]}")
|
||||
for s in subs_by_titel.get(_titel(entries[num]), []):
|
||||
lines.append(f" [{s['stufe']}] {s['titel']}")
|
||||
lines.append(f" [{_ebene_label(s)}] {s['titel']}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -249,6 +262,68 @@ def _mit_resten(plan: list[dict], entries: dict[int, str]) -> list[dict]:
|
||||
return [*plan, {"title": "Weitere", "nums": fehlen}] if fehlen else plan
|
||||
|
||||
|
||||
def _fakten_grounding(subs_raw: dict[str, list[dict]]) -> str:
|
||||
"""Verifizierte Sub-Fakten (extract-once aus der Bausteine-Phase) als Grounding-Block für den
|
||||
Inhalts-Agent. Leer, wenn keine Fakten gespeichert (Altbestand → Fallback auf Quelle-Hinweis)."""
|
||||
bloecke = []
|
||||
for titel, subs in subs_raw.items():
|
||||
zeilen = []
|
||||
for s in subs:
|
||||
fk = s.get("fakten") if isinstance(s.get("fakten"), dict) else None
|
||||
if not fk:
|
||||
continue
|
||||
teile = []
|
||||
if fk.get("kernpunkte"):
|
||||
teile.append("Kern: " + " · ".join(fk["kernpunkte"]))
|
||||
for bf in fk.get("belegte_fakten", []):
|
||||
teile.append(f"FAKT[{bf.get('quelle', '?')}]: {bf.get('text', '')}")
|
||||
if fk.get("voraussetzungen"):
|
||||
teile.append("Voraussetzung: " + fk["voraussetzungen"])
|
||||
if fk.get("huerden"):
|
||||
teile.append("Hürde: " + fk["huerden"])
|
||||
if fk.get("beispiel_idee"):
|
||||
teile.append("Beispiel: " + fk["beispiel_idee"])
|
||||
if teile:
|
||||
zeilen.append(f"- {s['titel']}: " + " | ".join(teile))
|
||||
if zeilen:
|
||||
bloecke.append(f"BAUSTEIN: {titel}\n" + "\n".join(zeilen))
|
||||
if not bloecke:
|
||||
return ""
|
||||
return ("VERIFIZIERTE FAKTEN je Subbaustein — verbindliche Grundlage. Belegte Fakten (FAKT[Quelle]) "
|
||||
"WÖRTLICH übernehmen, nichts dazu erfinden, NICHT neu recherchieren. Beispiele als Beispiel "
|
||||
"nutzen, nie als Fakt.\n\n" + "\n\n".join(bloecke))
|
||||
|
||||
|
||||
async def _gliederung_aus_db(topic: str, sel_entries: dict[int, str]) -> list[dict] | None:
|
||||
"""Gliederung aus dem Bausteine-Artefakt (DB) lesen und auf die gewählten Bausteine mappen.
|
||||
Titel-basiert (robust gegen Nummern-Drift): Bausteine außerhalb der Auswahl werden ignoriert
|
||||
(Format-Filter), fehlende ergänzt später _mit_resten. None → kein Artefakt (Altbestand)."""
|
||||
raw = await get_gliederung(topic)
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
kapitel = data.get("kapitel") if isinstance(data, dict) else None
|
||||
if not isinstance(kapitel, list):
|
||||
return None
|
||||
norm_to_num = {_norm_titel(_titel(t)): num for num, t in sel_entries.items()}
|
||||
plan, seen = [], set()
|
||||
for ch in kapitel:
|
||||
if not isinstance(ch, dict):
|
||||
continue
|
||||
nums = []
|
||||
for bt in ch.get("bausteine", []):
|
||||
num = norm_to_num.get(_norm_titel(str(bt)))
|
||||
if num is not None and num not in seen:
|
||||
seen.add(num)
|
||||
nums.append(num)
|
||||
if nums:
|
||||
plan.append({"title": str(ch.get("titel", "")).strip() or "Kapitel", "nums": nums})
|
||||
return plan or None
|
||||
|
||||
|
||||
async def _generate_sections(
|
||||
guide_id: str, topic: str, format_name: str, entries: dict[int, str],
|
||||
facts: str, instructions: str, provider: str,
|
||||
@@ -265,21 +340,20 @@ async def _generate_sections(
|
||||
# Subbausteine je Baustein (DB-first) — früh geladen: steuert Auswahl + Sub-Filter je Format.
|
||||
# Fehlt sie → {} (Fallback: Guide nimmt alles).
|
||||
subs_raw = await _load_subbausteine(topic)
|
||||
# Extract-once-Grounding: gespeicherte, verifizierte Fakten ersetzen den generischen
|
||||
# Quelle-Hinweis. Der Inhalts-Agent formuliert daraus, statt die Quelle neu zu lesen.
|
||||
if (fakten_block := _fakten_grounding(subs_raw)):
|
||||
facts = fakten_block
|
||||
|
||||
def _hat_relevanz(num, art):
|
||||
return any(isinstance(s, dict) and s.get("relevanz") == art for s in subs_raw.get(_titel(entries[num]), []))
|
||||
|
||||
# Auswahl (Teil von Gliederung). Deterministisch je Format:
|
||||
# Guide=relevante Bausteine · FullGuide=alle · Rest=reine Rand-Bausteine (keine im Guide).
|
||||
if format_name == "FullGuide":
|
||||
auswahl = list(entries)
|
||||
elif format_name == "Rest":
|
||||
# Auswahl: EIN Voll-Dokument mit ALLEN Bausteinen (inkl. Rand). Die Ansichten E/M/S/F
|
||||
# filtern später pro Subbaustein-Ebene. (FullGuide/Rest bleiben als Alt-Zweige bestehen.)
|
||||
if format_name == "Rest":
|
||||
auswahl = [num for num in entries if not _hat_relevanz(num, "relevant")]
|
||||
else: # Guide
|
||||
auswahl = [num for num in entries if _hat_relevanz(num, "relevant")]
|
||||
if not auswahl:
|
||||
_log(topic, "Guide: keine Relevanz-Daten — alle Bausteine genommen")
|
||||
auswahl = list(entries)
|
||||
else: # Guide / FullGuide → alle Bausteine
|
||||
auswahl = list(entries)
|
||||
if not auswahl:
|
||||
await _fail(guide_id, "Keine passenden Bausteine für dieses Format")
|
||||
return None
|
||||
@@ -289,10 +363,14 @@ async def _generate_sections(
|
||||
# Nummerierte Liste (ID = Baustein-Nummer aus entries) — Agenten/Judge ordnen per Nummer.
|
||||
sel_liste = "\n".join(f"{num}. {t}" for num, t in sel_entries.items())
|
||||
|
||||
# Schritt 0: Gliederung — bis zu 3 Vorschläge (Grace), ein Judge merged sie. Bricht NIE ab:
|
||||
# Schritt 0: Gliederung. Bevorzugt das Bausteine-Artefakt (DB) — der Guide präsentiert nur,
|
||||
# gliedert nicht mehr selbst. Fehlt es (Altbestand) → bisherige Agenten/Judge-Logik als Fallback.
|
||||
# 0 gültige → Code-Fallback, 1 → direkt, ≥2 → Judge (mit Vorschlag als Rückfall).
|
||||
# Gültiges gliederung.json überspringt den Schritt.
|
||||
plan = _resolve_gliederung(_json_datei(files["gliederung"]), sel_entries, soll, soll)
|
||||
plan = await _gliederung_aus_db(topic, sel_entries)
|
||||
if plan is not None:
|
||||
_log(topic, f"Gliederung aus Bausteine-Artefakt ({len(plan)} Kapitel)")
|
||||
if plan is None:
|
||||
plan = _resolve_gliederung(_json_datei(files["gliederung"]), sel_entries, soll, soll)
|
||||
if plan is None:
|
||||
await _set_step(guide_id, 0, "Gliederungs-Vorschläge (3 Agenten)…")
|
||||
files["gliederung"].unlink(missing_ok=True)
|
||||
@@ -366,14 +444,12 @@ async def _generate_sections(
|
||||
# Der Writer baut darunter pro Baustein (eigene feine Chunks, s.u.) → variable Längen.
|
||||
total_sections = sum(len(c["nums"]) for c in plan)
|
||||
chunks = _split_chunks(plan, max(1, math.ceil(total_sections / GUIDE_CHUNK)))
|
||||
# Subbausteine je Baustein, je Format gefiltert: Guide=relevant · FullGuide=alle · Rest=Rand.
|
||||
# Subs ohne Relevanz-Feld (Alt-Themen) zählen als „nicht Rand" (→ Guide/FullGuide).
|
||||
if format_name == "FullGuide":
|
||||
subs_by_titel = {t: list(subs) for t, subs in subs_raw.items()}
|
||||
elif format_name == "Rest":
|
||||
# Subbausteine je Baustein: Guide/FullGuide nehmen ALLE (inkl. Rand → Ebene 4 in der Ansicht);
|
||||
# nur der Alt-Zweig Rest filtert auf Rand. So trägt das eine Dokument alle Ebenen.
|
||||
if format_name == "Rest":
|
||||
subs_by_titel = {t: [s for s in subs if s.get("relevanz") == "rand"] for t, subs in subs_raw.items()}
|
||||
else: # Guide
|
||||
subs_by_titel = {t: [s for s in subs if s.get("relevanz") != "rand"] for t, subs in subs_raw.items()}
|
||||
else: # Guide / FullGuide
|
||||
subs_by_titel = {t: list(subs) for t, subs in subs_raw.items()}
|
||||
subs_by_titel = {t: subs for t, subs in subs_by_titel.items() if subs}
|
||||
zuteilungen = [_zuteilung_subs(chunk, entries, subs_by_titel) for chunk in chunks]
|
||||
chunk_sizes = [sum(len(c["nums"]) for c in chunk) for chunk in chunks]
|
||||
@@ -617,9 +693,15 @@ async def _generate_sections(
|
||||
def sections_text(nums: list[int]) -> str:
|
||||
return "\n\n".join(f"SECTION: {_titel(entries[num])}\n{by_num[num]['md']}" for num in nums)
|
||||
|
||||
def _sub_liste(num: int) -> str:
|
||||
subs = subs_by_titel.get(_titel(entries[num]), [])
|
||||
return "\n".join(f"- [{_ebene_label(s)}] {s['titel']}" for s in subs) or "(keine)"
|
||||
|
||||
def auftraege_text(nums: list[int], probleme: dict[int, str]) -> str:
|
||||
return "\n\n".join(
|
||||
f"SECTION: {_titel(entries[num])}\nPROBLEM: {probleme[num]}\nAKTUELLER INHALT:\n{by_num[num]['md']}"
|
||||
f"SECTION: {_titel(entries[num])}\n"
|
||||
f"SUBBAUSTEINE (je einen `<!-- sub: LABEL | titel -->`-Marker setzen, Label/Reihenfolge wie hier):\n{_sub_liste(num)}\n"
|
||||
f"PROBLEM: {probleme[num]}\nAKTUELLER INHALT:\n{by_num[num]['md']}"
|
||||
for num in nums
|
||||
)
|
||||
|
||||
@@ -703,9 +785,15 @@ async def _generate_sections(
|
||||
continue
|
||||
for sec in _parse_fragment(p.read_text(encoding="utf-8")):
|
||||
num = _titel_aufloesen(idx, sec["titel"])
|
||||
if num in probleme_by_num and sec["md"].strip():
|
||||
by_num[num] = sec
|
||||
ersetzt.add(num)
|
||||
if num not in probleme_by_num or not sec["md"].strip():
|
||||
continue
|
||||
# Marker-Invariante: Verliert der Fix die Sub-Marker, obwohl das Original welche
|
||||
# hatte, wird er verworfen — sonst stirbt der Stufen-Filter (E/M/S/F) still.
|
||||
if by_num[num].get("subs") and not sec.get("subs"):
|
||||
_log(topic, f"Lese-Fix für '{sec['titel']}' ohne Sub-Marker — verworfen, getaggtes Original bleibt")
|
||||
continue
|
||||
by_num[num] = sec
|
||||
ersetzt.add(num)
|
||||
_log(topic, f"Lese-Prüfung Runde {runde}: {len(ersetzt)} Section(s) überarbeitet")
|
||||
if not ersetzt:
|
||||
break
|
||||
@@ -730,7 +818,9 @@ async def _generate_sections(
|
||||
for ch in plan:
|
||||
sections = [
|
||||
{"num": num, "title": _titel(entries[num]), "md": by_num[num]["md"],
|
||||
"kompakt": by_num[num].get("kompakt", ""), "pruefbar": _pruefbar(num)}
|
||||
"kompakt": by_num[num].get("kompakt", ""),
|
||||
"anker": by_num[num].get("anker", ""), "anker_kompakt": by_num[num].get("anker_kompakt", ""),
|
||||
"subs": by_num[num].get("subs", []), "pruefbar": _pruefbar(num)}
|
||||
for num in ch["nums"] if num in by_num
|
||||
]
|
||||
if sections:
|
||||
@@ -745,6 +835,34 @@ async def _generate_sections(
|
||||
return chapters
|
||||
|
||||
|
||||
_STUFE_EBENE = {"anfaenger": 1, "fortgeschritten": 2, "experte": 3, "rand": 4,
|
||||
"einfach": 1, "mittel": 2, "schwer": 3} # alte Werte abwärtskompatibel
|
||||
|
||||
|
||||
def _section_fuer_ebene(sec: dict, ebene: int) -> dict:
|
||||
"""md/kompakt einer Section auf Subbausteine bis zur Ebene rekonstruieren (Anker bleibt)."""
|
||||
subs = sec.get("subs") or []
|
||||
if not subs:
|
||||
return sec # keine Sub-Tags (Altbestand) → unverändert sichtbar
|
||||
sichtbar = [s for s in subs if _STUFE_EBENE.get(s.get("stufe"), 1) <= ebene]
|
||||
md = "\n\n".join(t for t in [sec.get("anker", ""), *(s.get("md", "") for s in sichtbar)] if t).strip()
|
||||
kompakt = "\n".join(t for t in [sec.get("anker_kompakt", ""), *(s.get("kompakt", "") for s in sichtbar)] if t).strip()
|
||||
return {**sec, "md": md, "kompakt": kompakt, "leer": not sichtbar}
|
||||
|
||||
|
||||
def content_fuer_ebene(content: dict, ebene: int) -> dict:
|
||||
"""Guide-Content auf eine Ansichts-Ebene (1=A · 2=F · 3=E · 4=V) filtern. Ebene 4 = Vollfassung.
|
||||
Sections ohne sichtbare Subs werden ausgeblendet, leere Kapitel entfallen."""
|
||||
if not isinstance(content, dict) or ebene >= 4:
|
||||
return content
|
||||
kapitel = []
|
||||
for ch in content.get("chapters", []):
|
||||
secs = [s for s in (_section_fuer_ebene(x, ebene) for x in ch.get("sections", [])) if not s.get("leer")]
|
||||
if secs:
|
||||
kapitel.append({**ch, "sections": secs})
|
||||
return {**content, "chapters": kapitel}
|
||||
|
||||
|
||||
async def reconcile_guides() -> None:
|
||||
"""DB↔Dateisystem abgleichen: status=done ohne Content-Datei → error.
|
||||
|
||||
@@ -894,11 +1012,21 @@ async def block_uebernehmen(topic: str, format_name: str, baustein: str, stelle:
|
||||
sec = _section_finden(content, baustein) if content else None
|
||||
if sec is None:
|
||||
return None
|
||||
feld = "kompakt" if str(stelle).startswith("kompakt") else "md"
|
||||
ist_kompakt = str(stelle).startswith("kompakt")
|
||||
feld = "kompakt" if ist_kompakt else "md"
|
||||
aktuell = sec.get(feld, "") or ""
|
||||
gefunden = alt in aktuell
|
||||
if gefunden:
|
||||
sec[feld] = aktuell.replace(alt, neu, 1)
|
||||
# Auch in Anker + Subs ersetzen (Quellen der gefilterten E/M/S-Ansicht), sonst zeigt
|
||||
# die gestufte Ansicht weiter den alten Block.
|
||||
anker_feld = "anker_kompakt" if ist_kompakt else "anker"
|
||||
if alt in (sec.get(anker_feld) or ""):
|
||||
sec[anker_feld] = sec[anker_feld].replace(alt, neu, 1)
|
||||
for sub in sec.get("subs", []):
|
||||
if alt in (sub.get(feld, "") or ""):
|
||||
sub[feld] = sub[feld].replace(alt, neu, 1)
|
||||
break
|
||||
js = json.dumps(content, ensure_ascii=False)
|
||||
await set_guide_content(topic, format_name, js)
|
||||
atomic_write_json(guide_content_path(topic, format_name), content, indent=1)
|
||||
|
||||
@@ -13,7 +13,7 @@ from datetime import datetime, timezone
|
||||
|
||||
from agents import run_agent
|
||||
from config import DEFAULT_PROVIDER
|
||||
from database import create_element, list_elements
|
||||
from database import create_element, list_elements, get_baustein_huerden
|
||||
from elements import generate_element
|
||||
from jsonio import parse_json_text as _parse_json_text
|
||||
from pipeline import _prompt, _probleme_schema
|
||||
@@ -27,34 +27,68 @@ log = logging.getLogger("creator.lernen")
|
||||
STUFEN = (("anfaenger", 0.2), ("fortgeschritten", 0.4), ("experte", 0.6), ("meister", 1.0))
|
||||
|
||||
|
||||
def cap_fuer(n_subs: int) -> int:
|
||||
"""Maximal-Score eines Bausteins: 4 Punkte je relevantem Subbaustein, gedeckelt [10, 50]."""
|
||||
return max(10, min(50, 4 * n_subs))
|
||||
PUNKT_BASIS = 25 # Punkte je Subbaustein. Meister-cap = (alle Subs) × 25.
|
||||
|
||||
|
||||
def _ebenen(n_je_ebene: dict[int, int]) -> list[int]:
|
||||
return [n_je_ebene.get(k, 0) for k in (1, 2, 3, 4)]
|
||||
|
||||
|
||||
def schwellen(n_je_ebene: dict[int, int]) -> list[int]:
|
||||
"""Kumulative Ebenen-Schwellen [S_1, S_2, S_3, S_4] = (n_1+…+n_k) × 25.
|
||||
S_k ist der Score, ab dem Ebene k+1 (E→M→S→F) freigeschaltet ist; S_4 = cap_final."""
|
||||
out, akk = [], 0
|
||||
for n in _ebenen(n_je_ebene):
|
||||
akk += n
|
||||
out.append(akk * PUNKT_BASIS)
|
||||
return out
|
||||
|
||||
|
||||
def cap_final(n_je_ebene: dict[int, int]) -> int:
|
||||
"""Maximal-Score (Meister) = alle Subbausteine × 25."""
|
||||
return schwellen(n_je_ebene)[-1]
|
||||
|
||||
|
||||
def freie_ebene(score: int, n_je_ebene: dict[int, int]) -> int:
|
||||
"""Höchste freigeschaltete Sub-Ebene 1–4. Ebene k+1 frei, sobald score ≥ S_k.
|
||||
Leere Ebenen (n_k=0) werden automatisch übersprungen (S_k == S_{k-1})."""
|
||||
s = schwellen(n_je_ebene)
|
||||
e = 1
|
||||
for k in range(3): # S_1..S_3 schalten Ebene 2..4 frei
|
||||
if score >= s[k]:
|
||||
e = k + 2
|
||||
return e
|
||||
|
||||
|
||||
def cap_aktuell(score: int, n_je_ebene: dict[int, int]) -> int:
|
||||
"""Erreichbarer cap der aktuell freigeschalteten Ebene = freigeschaltete Subs × 25."""
|
||||
return schwellen(n_je_ebene)[freie_ebene(score, n_je_ebene) - 1]
|
||||
|
||||
|
||||
def _schwelle(p: float, cap: int) -> int:
|
||||
return round(p * cap)
|
||||
|
||||
|
||||
def stufe_aus_score(score: int, cap: int) -> str | None:
|
||||
"""Höchste erreichte Stufe (None unter 20 %)."""
|
||||
def stufe_aus_score(score: int, cap_final_wert: int) -> str | None:
|
||||
"""Höchste erreichte Lernstufe (None unter 20 %), relativ zum cap_final."""
|
||||
erreicht = None
|
||||
for key, p in STUFEN:
|
||||
if score >= _schwelle(p, cap):
|
||||
if score >= _schwelle(p, cap_final_wert):
|
||||
erreicht = key
|
||||
return erreicht
|
||||
|
||||
|
||||
def progressiver_malus(basis: int, cap: int) -> int:
|
||||
"""Fehler-Strafe nach Fortschritt: 0–20 %→0 · 21–40 %→−1 · 41–60 %→−2 · 61–100 %→−3."""
|
||||
pct = (basis / cap) if cap else 0.0
|
||||
if pct <= 0.2:
|
||||
return 0
|
||||
if pct <= 0.4:
|
||||
return -1
|
||||
if pct <= 0.6:
|
||||
return -2
|
||||
return -3
|
||||
def progressiver_malus(basis: int, cap_akt: int) -> int:
|
||||
"""Fehler-Strafe nach Fortschritt in der aktuellen Ebene (gegen cap_aktuell):
|
||||
≤25 %→−5 · ≤50 %→−10 · ≤75 %→−15 · >75 %→−20."""
|
||||
pct = (basis / cap_akt) if cap_akt else 0.0
|
||||
if pct <= 0.25:
|
||||
return -5
|
||||
if pct <= 0.5:
|
||||
return -10
|
||||
if pct <= 0.75:
|
||||
return -15
|
||||
return -20
|
||||
CHAT_TIMEOUT = 240
|
||||
PRUEFUNG_TIMEOUT = 120 # kurze JSON-Turns; deckelt die Serien-Latenz pro Prüfungs-Schritt
|
||||
GRUENDLICH_TIMEOUT = 600 # „Gründlich prüfen": starkes Modell (role guide) braucht länger
|
||||
@@ -70,34 +104,66 @@ FRAGETYPEN = {
|
||||
}
|
||||
|
||||
|
||||
# Antwort-Niveau (wie sehr der Kern getroffen ist) → Punkt-Delta.
|
||||
# Antwort-Niveau → Basis-Punkte (neue 25er-Skala). „kaum" = −1 ist nur das Signal für den
|
||||
# progressiven Malus (echter Wert kommt aus progressiver_malus). Positive Werte werden bei
|
||||
# Streak hochmoduliert und auf [10, 40] geklemmt.
|
||||
NIVEAUS = {
|
||||
"unbeantwortbar": 0, # Frage selbst kaputt (Randthema/Verweis auf Nichtgezeigtes) → kein Malus
|
||||
"kaum": -1, # < 25 % richtig
|
||||
"teilweise": 0, # 25–49 %
|
||||
"solide": 1, # 50–74 %
|
||||
"stark": 2, # 75–99 %
|
||||
"komplett": 3, # 100 %
|
||||
"unbeantwortbar": 0, # Frage selbst kaputt → keine Änderung
|
||||
"kaum": -1, # < 25 % richtig → Malus
|
||||
"teilweise": 0, # 25–49 % → neutral
|
||||
"solide": 16, # 50–74 %
|
||||
"stark": 24, # 75–99 % (Quiz/Lücke-Treffer)
|
||||
"komplett": 30, # 100 % (nur freies Erklären erreichbar)
|
||||
}
|
||||
|
||||
# Reihenfolge schwach→stark (für den Nachfrage-Deckel).
|
||||
_NIVEAU_RANG = ("kaum", "teilweise", "solide", "stark", "komplett")
|
||||
|
||||
def score_berechnen(basis: int, delta: int, floor: int, cap: int) -> int:
|
||||
"""Neuer Score · driftfrei aus der Basis. `delta` liefert die Route je Prüfungsform
|
||||
(Quiz +1/+3, Lückentext +1/+3, Erklären −1…+3; falsch = progressiver_malus). Auf
|
||||
[floor, cap] geklemmt. floor = höchste erreichte Stufenschwelle (kein Rückfall);
|
||||
cap = 4× relevante Subbausteine [10, 50]. Am Deckel (basis ≥ cap) eingefroren („Max")."""
|
||||
if basis >= cap:
|
||||
|
||||
def deckel_nachfrage(niveau: str, nachgefragt: bool) -> str:
|
||||
"""Mit Nachfrage (Hinweis erhalten) höchstens „solide" — kein Voll-Score erschummeln."""
|
||||
if nachgefragt and niveau in ("stark", "komplett"):
|
||||
return "solide"
|
||||
return niveau
|
||||
|
||||
|
||||
def streak_punkte(basis_delta: int, streak_basis: int) -> int:
|
||||
"""Positives Basis-Delta mit Streak hochmodulieren, auf [10, 40] geklemmt."""
|
||||
faktor = min(1.33, 1 + 0.066 * min(streak_basis, 5))
|
||||
return max(10, min(40, round(basis_delta * faktor)))
|
||||
|
||||
|
||||
def punkte_delta(niveau: str, streak_basis: int, basis: int, cap_akt: int) -> tuple[int, int]:
|
||||
"""Antwort-Niveau → (Punkt-Delta, neue Streak). Positiv: streak-moduliert, Streak +1.
|
||||
Neutral (0): keine Änderung, Streak bleibt. Negativ: progressiver Malus, Streak-Reset 0."""
|
||||
basis_delta = NIVEAUS.get(niveau, 0)
|
||||
if basis_delta > 0:
|
||||
return streak_punkte(basis_delta, streak_basis), streak_basis + 1
|
||||
if basis_delta == 0:
|
||||
return 0, streak_basis
|
||||
return progressiver_malus(basis, cap_akt), 0
|
||||
|
||||
|
||||
def score_berechnen(basis: int, delta: int, floor: int, cap_akt: int, cap_fin: int) -> int:
|
||||
"""Neuer Score · driftfrei aus der Basis. Klemmt nach oben gegen `cap_akt` (Deckel der
|
||||
aktuell freigeschalteten Ebene) und nach unten gegen `floor`. Eingefroren NUR am
|
||||
absoluten Maximum (`basis ≥ cap_fin`) — sonst würde an jeder Ebenen-Schwelle blockiert."""
|
||||
if basis >= cap_fin:
|
||||
return basis
|
||||
return max(floor, min(cap, basis + delta))
|
||||
return max(floor, min(cap_akt, basis + delta))
|
||||
|
||||
|
||||
def floor_aus_score(basis: int, cap: int) -> int:
|
||||
"""Untergrenze: höchste erreichte Stufenschwelle — kein Rückfall unter die Stufe."""
|
||||
def floor_aus_score(basis: int, cap_fin: int, s_schwellen: list[int]) -> int:
|
||||
"""Untergrenze (kein Rückfall): höchste erreichte Lernstufen-Schwelle (über cap_final)
|
||||
UND höchste erreichte Ebenen-Freischalt-Schwelle S_k. max beider Achsen."""
|
||||
floor = 0
|
||||
for _, p in STUFEN:
|
||||
s = _schwelle(p, cap)
|
||||
s = _schwelle(p, cap_fin)
|
||||
if basis >= s:
|
||||
floor = s
|
||||
floor = max(floor, s)
|
||||
for s in s_schwellen:
|
||||
if basis >= s:
|
||||
floor = max(floor, s)
|
||||
return floor
|
||||
|
||||
|
||||
@@ -199,7 +265,8 @@ def _doppelfrage_mangel(frage: str) -> str | None:
|
||||
|
||||
async def _frage_mit_kritik(
|
||||
topic: str, baustein: str, section_block: str, kompakt_block: str,
|
||||
transcript: str, vermeide_block: str, typ_block: str, fokus_block: str, provider: str,
|
||||
transcript: str, vermeide_block: str, typ_block: str, fokus_block: str,
|
||||
niveau_block: str, provider: str,
|
||||
) -> str | None:
|
||||
"""Frage generieren, vom Kritiker prüfen lassen, bei Mängeln neu (max KRITIK_MAX_RUNDEN)."""
|
||||
kritik_block = "(keine)"
|
||||
@@ -209,7 +276,7 @@ async def _frage_mit_kritik(
|
||||
"Baustein-Frage", "guide", _frage_schema, provider, lane="batch",
|
||||
topic=topic, baustein=baustein, section_block=section_block,
|
||||
kompakt_block=kompakt_block, transcript=transcript, vermeide_block=vermeide_block,
|
||||
typ_block=typ_block, fokus_block=fokus_block, kritik_block=kritik_block,
|
||||
typ_block=typ_block, fokus_block=fokus_block, niveau_block=niveau_block, kritik_block=kritik_block,
|
||||
)
|
||||
if data is None:
|
||||
return None
|
||||
@@ -277,13 +344,27 @@ def _vermeide_block(vermeide: list[str] | None) -> str:
|
||||
return "\n".join(f"- {f}" for f in eintraege) or "(keine)"
|
||||
|
||||
|
||||
# Lerner-Niveau (aus dem Score abgeleitet) → Adressaten-Rolle für die Frage. So entsteht die
|
||||
# Schwierigkeit: nicht „extra schwer machen", sondern „für einen Anfänger/Experten fragen".
|
||||
NIVEAU_ROLLE = {
|
||||
"anfaenger": "Der Lerner ist ANFÄNGER. Frage nach dem Grundverständnis — das Kernkonzept, einfach und direkt.",
|
||||
"fortgeschritten": "Der Lerner ist FORTGESCHRITTEN. Frage etwas tiefer — Zusammenhänge und die typische Anwendung.",
|
||||
"experte": "Der Lerner ist EXPERTE. Frage anspruchsvoll — Sonderfälle, Abgrenzungen, typische Tücken.",
|
||||
"meister": "Der Lerner ist auf MEISTER-Niveau. Frage sehr anspruchsvoll — Edge-Cases, präzise Begründung, Transfer auf Neues.",
|
||||
}
|
||||
|
||||
|
||||
def _niveau_block(niveau: str | None) -> str:
|
||||
return NIVEAU_ROLLE.get(niveau or "", NIVEAU_ROLLE["anfaenger"])
|
||||
|
||||
|
||||
async def pruefung_frage(
|
||||
topic: str, baustein: str, section: str, kompakt: str | None,
|
||||
messages: list[dict], subbausteine: list[str] | None = None,
|
||||
vermeide: list[str] | None = None, provider: str = DEFAULT_PROVIDER,
|
||||
vermeide: list[str] | None = None, niveau: str = "anfaenger", provider: str = DEFAULT_PROVIDER,
|
||||
) -> str | None:
|
||||
"""Aktion 'frage': eine Frage generieren — zufälliger Typ zu einem zufälligen Subbaustein,
|
||||
dann Kritiker (Generator → Kritiker sequenziell) · None bei Fehler."""
|
||||
in der Adressaten-Rolle des Niveaus, dann Kritiker (sequenziell) · None bei Fehler."""
|
||||
try:
|
||||
section_block, kompakt_block = _bloecke(section, kompakt)
|
||||
transcript = _transcript(messages) if messages else "(leer)"
|
||||
@@ -296,7 +377,7 @@ async def pruefung_frage(
|
||||
)
|
||||
return await _frage_mit_kritik(
|
||||
topic, baustein, section_block, kompakt_block, transcript,
|
||||
_vermeide_block(vermeide), typ_block, fokus_block, provider,
|
||||
_vermeide_block(vermeide), typ_block, fokus_block, _niveau_block(niveau), provider,
|
||||
)
|
||||
except Exception:
|
||||
log.warning("[%s] Frage fehlgeschlagen (%s)", topic, baustein, exc_info=True)
|
||||
@@ -305,17 +386,17 @@ async def pruefung_frage(
|
||||
|
||||
async def pruefung_frage_variante(
|
||||
topic: str, baustein: str, section: str, kompakt: str | None,
|
||||
muster: str, provider: str = DEFAULT_PROVIDER,
|
||||
muster: str, niveau: str = "anfaenger", provider: str = DEFAULT_PROVIDER,
|
||||
) -> str | None:
|
||||
"""Aktion 'frage' mit Muster: aus einem vordefinierten Muster eine konkrete, im
|
||||
Wortlaut leicht andere Frage formulieren. Kein Kritiker (Muster ist build-geprüft).
|
||||
"""Aktion 'frage' mit Muster: aus einem vordefinierten Muster eine konkrete Frage in der
|
||||
Adressaten-Rolle des Niveaus formulieren. Kein Kritiker (Muster ist build-geprüft).
|
||||
Stil-Guard bleibt als billiger Schutz gegen Doppelfragen · None bei Fehler."""
|
||||
try:
|
||||
section_block, kompakt_block = _bloecke(section, kompakt)
|
||||
data = await _gen_call(
|
||||
"Baustein-Frage-Variante", "guide", _frage_schema, provider, lane="batch",
|
||||
topic=topic, baustein=baustein, section_block=section_block,
|
||||
kompakt_block=kompakt_block, muster=muster,
|
||||
kompakt_block=kompakt_block, muster=muster, niveau_block=_niveau_block(niveau),
|
||||
)
|
||||
if data is None:
|
||||
return None
|
||||
@@ -341,17 +422,16 @@ def _optionen_schema(opts) -> list[dict] | None:
|
||||
return out
|
||||
|
||||
|
||||
def _quiz_schema(data, schwer: bool = False) -> dict | None:
|
||||
def _quiz_schema(data) -> dict | None:
|
||||
"""{"frage": str, "optionen": [{text, korrekt}]×4} → validiert · sonst None.
|
||||
leicht: genau 1 richtig (Single) · schwer: mindestens 2 richtig (mehrdeutig)."""
|
||||
Single-Choice: genau 1 richtig. Die Schwierigkeit steckt im Niveau, nicht in der Anzahl."""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
frage = str(data.get("frage", "")).strip()
|
||||
out = _optionen_schema(data.get("optionen"))
|
||||
if not frage or out is None:
|
||||
return None
|
||||
n_korrekt = sum(o["korrekt"] for o in out)
|
||||
if (n_korrekt < 2 if schwer else n_korrekt != 1):
|
||||
if sum(o["korrekt"] for o in out) != 1:
|
||||
return None
|
||||
return {"frage": frage, "optionen": out}
|
||||
|
||||
@@ -367,24 +447,35 @@ def _lueckwahl_schema(data) -> dict | None:
|
||||
return {"satz": satz, "optionen": out}
|
||||
|
||||
|
||||
async def huerden_distraktor_block(topic: str, baustein: str) -> str:
|
||||
"""Typische Irrtümer (Fakten-Hürden) des Bausteins als Distraktor-Quelle für Quiz/Lückenwahl.
|
||||
Leer, wenn keine vorhanden (Altbestand) → der Prompt-Platzhalter verschwindet rückstandslos."""
|
||||
try:
|
||||
huerden = await get_baustein_huerden(topic, _norm_titel(baustein))
|
||||
except Exception:
|
||||
return ""
|
||||
if not huerden:
|
||||
return ""
|
||||
zeilen = "\n".join(f"- {h}" for h in huerden[:8])
|
||||
return ("TYPISCHE IRRTÜMER zu diesem Baustein (nutze sie als Distraktoren, wenn sie zur Frage passen):\n"
|
||||
+ zeilen + "\n")
|
||||
|
||||
|
||||
async def quiz_generieren(
|
||||
topic: str, baustein: str, section: str, kompakt: str | None,
|
||||
muster: str, schwer: bool = False, provider: str = DEFAULT_PROVIDER,
|
||||
muster: str, niveau: str = "anfaenger", provider: str = DEFAULT_PROVIDER,
|
||||
distraktor_block: str = "",
|
||||
) -> dict | None:
|
||||
"""Aus einem Muster eine Multiple-Choice-Frage. schwer: 0–4 richtig (Multi).
|
||||
leicht: genau 1 richtig (Single). Starkes Modell (role guide) für korrekte Flags.
|
||||
→ {frage, optionen:[{text,korrekt}]} · None bei Fehler."""
|
||||
"""Aus einem Muster eine Single-Choice-Frage (genau 1 richtig), im Anspruch des Niveaus.
|
||||
Starkes Modell (role guide) für korrekte Flags. → {frage, optionen} · None bei Fehler.
|
||||
distraktor_block: optionale typische Irrtümer (aus den Fakten-Hürden) als Distraktor-Quelle."""
|
||||
try:
|
||||
section_block, kompakt_block = _bloecke(section, kompakt)
|
||||
anzahl_block = (
|
||||
"MINDESTENS ZWEI Optionen sind richtig (gern auch drei) — NIEMALS nur eine. Mische bewusst, welche."
|
||||
if schwer else
|
||||
"GENAU EINE Option ist richtig, die anderen drei sind klar falsch."
|
||||
)
|
||||
return await _gen_call(
|
||||
"Baustein-Quiz", "guide", (lambda d: _quiz_schema(d, schwer=schwer)), provider, lane="batch",
|
||||
"Baustein-Quiz", "guide", _quiz_schema, provider, lane="batch",
|
||||
topic=topic, baustein=baustein, section_block=section_block,
|
||||
kompakt_block=kompakt_block, muster=muster, anzahl_block=anzahl_block,
|
||||
kompakt_block=kompakt_block, muster=muster, niveau_block=_niveau_block(niveau),
|
||||
distraktor_block=distraktor_block,
|
||||
)
|
||||
except Exception:
|
||||
log.warning("[%s] Quiz-Frage fehlgeschlagen (%s)", topic, baustein, exc_info=True)
|
||||
@@ -393,16 +484,19 @@ async def quiz_generieren(
|
||||
|
||||
async def lueckwahl_generieren(
|
||||
topic: str, baustein: str, section: str, kompakt: str | None,
|
||||
muster: str, provider: str = DEFAULT_PROVIDER,
|
||||
muster: str, niveau: str = "anfaenger", provider: str = DEFAULT_PROVIDER,
|
||||
distraktor_block: str = "",
|
||||
) -> dict | None:
|
||||
"""Lückentext mit Auswahl (leichte Variante): Satz mit ___ + 4 Begriffe, genau 1 richtig.
|
||||
→ {satz, optionen:[{text,korrekt}]} · None bei Fehler."""
|
||||
"""Lückentext mit Auswahl: Satz mit ___ + 4 Begriffe, genau 1 richtig — im Anspruch des Niveaus.
|
||||
→ {satz, optionen:[{text,korrekt}]} · None bei Fehler.
|
||||
distraktor_block: optionale typische Irrtümer (aus den Fakten-Hürden) als Distraktor-Quelle."""
|
||||
try:
|
||||
section_block, kompakt_block = _bloecke(section, kompakt)
|
||||
return await _gen_call(
|
||||
"Baustein-Lueckwahl", "guide", _lueckwahl_schema, provider, lane="batch",
|
||||
topic=topic, baustein=baustein, section_block=section_block,
|
||||
kompakt_block=kompakt_block, muster=muster,
|
||||
kompakt_block=kompakt_block, muster=muster, niveau_block=_niveau_block(niveau),
|
||||
distraktor_block=distraktor_block,
|
||||
)
|
||||
except Exception:
|
||||
log.warning("[%s] Lückentext-Auswahl fehlgeschlagen (%s)", topic, baustein, exc_info=True)
|
||||
@@ -424,16 +518,16 @@ def _lueck_schema(data) -> dict | None:
|
||||
|
||||
async def lueckentext_generieren(
|
||||
topic: str, baustein: str, section: str, kompakt: str | None,
|
||||
muster: str, provider: str = DEFAULT_PROVIDER,
|
||||
muster: str, niveau: str = "anfaenger", provider: str = DEFAULT_PROVIDER,
|
||||
) -> dict | None:
|
||||
"""Aus einem Muster eine Lückentext-Aufgabe (Satz mit ___, Lösung, Synonyme).
|
||||
→ {satz, loesung, alternativen} · None bei Fehler."""
|
||||
"""Aus einem Muster eine Lückentext-Aufgabe (Satz mit ___, Lösung, Synonyme), im Anspruch
|
||||
des Niveaus. → {satz, loesung, alternativen} · None bei Fehler."""
|
||||
try:
|
||||
section_block, kompakt_block = _bloecke(section, kompakt)
|
||||
return await _gen_call(
|
||||
"Baustein-Lueckentext", "guide", _lueck_schema, provider, lane="batch",
|
||||
topic=topic, baustein=baustein, section_block=section_block,
|
||||
kompakt_block=kompakt_block, muster=muster,
|
||||
kompakt_block=kompakt_block, muster=muster, niveau_block=_niveau_block(niveau),
|
||||
)
|
||||
except Exception:
|
||||
log.warning("[%s] Lückentext-Frage fehlgeschlagen (%s)", topic, baustein, exc_info=True)
|
||||
|
||||
@@ -71,7 +71,7 @@ class BausteineQuelleResponse(BaseModel):
|
||||
|
||||
class SubbausteinInfo(BaseModel):
|
||||
titel: str
|
||||
stufe: Literal["einfach", "mittel", "schwer"]
|
||||
stufe: Literal["anfaenger", "fortgeschritten", "experte", "einfach", "mittel", "schwer"]
|
||||
relevanz: Literal["relevant", "rand"] | None = None
|
||||
|
||||
|
||||
@@ -230,8 +230,8 @@ class BausteinPruefungRequest(BaseModel):
|
||||
nachgefragt: bool = False # für diese Frage wurde nachgefragt → Gewinn auf +1 gedeckelt
|
||||
begruendung: str = Field(default="", max_length=2000) # „Gründlich prüfen": warum mit der Bewertung unzufrieden
|
||||
muster: str = Field(default="", max_length=2000) # gezogenes Frage-Muster (Saat); leer → Live-Generierung (Fallback)
|
||||
# Basis + cap werden serverseitig geführt (Anker bzw. 4×Subs) — Client-cap nur Hinweis.
|
||||
cap: int = Field(default=10, ge=1, le=50) # Score-Deckel = clamp(4×relevante Subbausteine, 10, 50)
|
||||
# Basis + cap werden serverseitig geführt (Anker bzw. Subs×25) — Client-cap nur Hinweis.
|
||||
cap: int = Field(default=10, ge=1, le=10000) # Score-Deckel = freigeschaltete Subbausteine × 25
|
||||
messages: list[ChatMessage] = [] # Dialog bisher; leer = erste Frage
|
||||
provider: ProviderType = "claude"
|
||||
gruendlich: bool = False # „Gründlich prüfen": Bewertung mit starkem Modell (role guide)
|
||||
@@ -253,13 +253,16 @@ class BausteinPruefungResponse(BaseModel):
|
||||
loesung: str | None = None # Lückentext: erwarteter Begriff
|
||||
alternativen: list[str] | None = None # Lückentext: akzeptierte Synonyme
|
||||
gute_antworten: int
|
||||
cap: int = 10 # Maximal-Score dieses Bausteins (4×Subs, geklemmt) — Frontend leitet die Stufe ab
|
||||
streak: int = 0 # aktuelle Serie korrekter Antworten (je Baustein)
|
||||
cap: int = 10 # cap_final = alle Subs × 25 — Frontend leitet die Lernstufe ab
|
||||
|
||||
|
||||
class BausteinLernstand(BaseModel):
|
||||
gute_antworten: int
|
||||
streak: int = 0
|
||||
cap: int = 10 # 4×relevante Subbausteine, geklemmt [10, 50]
|
||||
cap: int = 0 # cap_final = alle Subbausteine × 25
|
||||
cap_aktuell: int = 0 # erreichbarer cap der aktuell freigeschalteten Ebene
|
||||
freie_ebene: int = 1 # 1=A · 2=F · 3=E · 4=V
|
||||
|
||||
|
||||
class BausteinLernstandResponse(BaseModel):
|
||||
|
||||
@@ -133,10 +133,10 @@ def _runde_schema(data, final: bool = False):
|
||||
|
||||
|
||||
def _stufen_schema(data, ids: set[int] | None = None):
|
||||
"""{"stufen": {"1": "einfach", …}} → {id: stufe} · sonst None.
|
||||
"""{"stufen": {"1": "anfaenger", …}} → {id: stufe} · sonst None.
|
||||
|
||||
Stufe ∈ {einfach, mittel, schwer}. Sind `ids` gegeben, müssen mindestens
|
||||
diese abgedeckt sein (Extras erlaubt); der Aufrufer filtert dann auf `ids`.
|
||||
Stufe ∈ {anfaenger, fortgeschritten, experte} (alte Werte abwärtskompatibel). Sind `ids`
|
||||
gegeben, müssen mindestens diese abgedeckt sein (Extras erlaubt); der Aufrufer filtert auf `ids`.
|
||||
"""
|
||||
if not isinstance(data, dict) or not isinstance(data.get("stufen"), dict) or not data["stufen"]:
|
||||
return None
|
||||
|
||||
@@ -11,9 +11,9 @@ Query-Schleifen mehr pro Guide.
|
||||
|
||||
import json
|
||||
|
||||
from database import list_baustein_scores_all, subs_count_alle, list_guides, list_progress_all
|
||||
from database import list_baustein_scores_all, subs_je_ebene_alle, list_guides, list_progress_all
|
||||
from guide import guide_slot_dateien
|
||||
from lernen import cap_fuer, STUFEN, _schwelle
|
||||
from lernen import cap_final, STUFEN, _schwelle
|
||||
from paths import bausteine_path, guide_content_path
|
||||
from textkit import _norm_titel
|
||||
|
||||
@@ -40,12 +40,12 @@ async def lade_lernstand() -> tuple[list[dict], dict[str, set[str]], dict[str, d
|
||||
Stufe je Baustein wird aus Score + cap (4×relevante Subs) abgeleitet.
|
||||
"""
|
||||
scores = await list_baustein_scores_all()
|
||||
caps = await subs_count_alle()
|
||||
ebenen = await subs_je_ebene_alle()
|
||||
levels: dict[str, dict[str, set[str]]] = {key: {} for key, _ in STUFEN}
|
||||
for topic, baustein, score in scores:
|
||||
cap = cap_fuer(caps.get((topic, _norm_titel(baustein)), 0))
|
||||
cf = cap_final(ebenen.get((topic, _norm_titel(baustein)), {}))
|
||||
for key, p in STUFEN:
|
||||
if score >= _schwelle(p, cap):
|
||||
if cf and score >= _schwelle(p, cf):
|
||||
levels[key].setdefault(topic, set()).add(_norm_titel(baustein))
|
||||
return await list_guides(), await list_progress_all(), levels
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import json
|
||||
import shutil
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
@@ -14,13 +15,15 @@ from database import (
|
||||
list_progress, set_progress, delete_progress,
|
||||
create_element, list_elements, get_element, update_element, delete_element,
|
||||
list_baustein_progress, get_baustein_progress, set_offene_frage,
|
||||
set_baustein_score, set_baustein_absolviert, delete_baustein_daten, delete_baustein_progress, count_relevant_subs, subs_count_roh,
|
||||
set_baustein_score, set_baustein_score_and_streak, set_baustein_absolviert,
|
||||
delete_baustein_daten, delete_baustein_progress, subs_je_ebene, subs_je_ebene_roh,
|
||||
delete_topic_pipeline, delete_quelle, get_guide_content, delete_guide_content,
|
||||
get_sub_artefakte,
|
||||
)
|
||||
from bausteine import generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine, lade_quelle, lade_uebersicht, subbausteine_titel, lade_frage_muster
|
||||
from bausteine import generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine, lade_quelle, lade_uebersicht, subbausteine_titel, subbausteine_frei, lade_frage_muster, lade_frage_muster_frei
|
||||
from elements import generate_element, chat_with_guide, chat_with_element, check_element, style_element, refine_suggestion
|
||||
from lernen import NIVEAUS, cap_fuer, baustein_chat, baustein_diskussion, baustein_element_anlegen, pruefung_bewertung, pruefung_bewertung_schnell, pruefung_frage, pruefung_frage_variante, quiz_generieren, lueckwahl_generieren, lueckentext_generieren, lueckentext_pruefen, score_berechnen, floor_aus_score, progressiver_malus, stufe_aus_score
|
||||
from guide import generate_guide, guide_slot_dateien, guide_fertig_step, block_pruefen, block_uebernehmen
|
||||
from lernen import baustein_chat, baustein_diskussion, baustein_element_anlegen, pruefung_bewertung, pruefung_bewertung_schnell, pruefung_frage, pruefung_frage_variante, quiz_generieren, lueckwahl_generieren, lueckentext_generieren, lueckentext_pruefen, huerden_distraktor_block, score_berechnen, floor_aus_score, stufe_aus_score, cap_final, cap_aktuell, freie_ebene, schwellen, punkte_delta, deckel_nachfrage
|
||||
from guide import generate_guide, guide_slot_dateien, guide_fertig_step, block_pruefen, block_uebernehmen, content_fuer_ebene
|
||||
from pipeline import cancel_guide
|
||||
from regeln import FORMATE, formate_stats, guide_lock, ist_absolviert, lade_lernstand, thema_abgeschlossen
|
||||
from models import (
|
||||
@@ -221,30 +224,59 @@ async def get_bausteine_uebersicht(topic: str):
|
||||
|
||||
@router.get("/bausteine/frage-muster")
|
||||
async def get_frage_muster(topic: str, baustein: str):
|
||||
"""Vordefinierte Frage-Muster eines Bausteins (leer = Fallback auf Live-Generierung)."""
|
||||
return {"muster": await lade_frage_muster(topic, baustein)}
|
||||
"""Freigeschaltete Frage-Muster eines Bausteins (bis zur aktuellen Ebene; leer = Live)."""
|
||||
stand = await get_baustein_progress(topic, baustein)
|
||||
fe = freie_ebene(stand["gute_antworten"], await subs_je_ebene(topic, baustein))
|
||||
return {"muster": await lade_frage_muster_frei(topic, baustein, fe)}
|
||||
|
||||
|
||||
@router.get("/bausteine/artefakte")
|
||||
async def get_artefakte(topic: str, typ: str | None = None):
|
||||
"""Lern-Artefakte (Karteikarten/Beispiele/Diagramme) je Thema, gruppiert nach Baustein-Norm.
|
||||
Diagramm liegt auf Baustein-Ebene (sub_norm=''), Karteikarte/Beispiel je Subbaustein."""
|
||||
rows = await get_sub_artefakte(topic, typ)
|
||||
out: dict[str, dict] = {}
|
||||
for r in rows:
|
||||
b = out.setdefault(r["baustein_norm"], {"baustein": r["baustein"], "karteikarte": [], "beispiel": [], "diagramm": None})
|
||||
if r["baustein"] and not b["baustein"]:
|
||||
b["baustein"] = r["baustein"]
|
||||
try:
|
||||
daten = json.loads(r["daten"])
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if r["typ"] == "diagramm":
|
||||
b["diagramm"] = daten
|
||||
elif r["typ"] in ("karteikarte", "beispiel"):
|
||||
b[r["typ"]].append({"subbaustein": r["sub_titel"], **daten})
|
||||
return {"artefakte": out}
|
||||
|
||||
|
||||
# --- Baustein-Lernen: Chat, Prüfung ---
|
||||
|
||||
@router.get("/bausteine/lernstand", response_model=BausteinLernstandResponse)
|
||||
async def baustein_lernstand(topic: str):
|
||||
"""Prüfungs-Stand pro Baustein (roher Titel als Key). cap = 4×relevante Subs [10,50]
|
||||
— für ALLE Bausteine (auch noch nicht geprüfte), damit der cap sofort stimmt."""
|
||||
"""Prüfungs-Stand pro Baustein (roher Titel als Key). cap_final = alle Subs × 25;
|
||||
cap_aktuell + freie_ebene aus dem Score — für ALLE Bausteine (auch ungeprüfte)."""
|
||||
progress = {p["baustein"]: p for p in await list_baustein_progress(topic)}
|
||||
caps = await subs_count_roh(topic)
|
||||
bausteine = {
|
||||
b: {
|
||||
"gute_antworten": progress[b]["gute_antworten"] if b in progress else 0,
|
||||
"streak": progress[b]["streak"] if b in progress else 0,
|
||||
"cap": cap_fuer(n),
|
||||
ebenen = await subs_je_ebene_roh(topic)
|
||||
|
||||
def _stand(score: int, streak: int, n_je_ebene: dict[int, int]) -> dict:
|
||||
return {
|
||||
"gute_antworten": score, "streak": streak,
|
||||
"cap": cap_final(n_je_ebene),
|
||||
"cap_aktuell": cap_aktuell(score, n_je_ebene),
|
||||
"freie_ebene": freie_ebene(score, n_je_ebene),
|
||||
}
|
||||
for b, n in caps.items()
|
||||
|
||||
bausteine = {
|
||||
b: _stand(progress[b]["gute_antworten"] if b in progress else 0,
|
||||
progress[b]["streak"] if b in progress else 0, n)
|
||||
for b, n in ebenen.items()
|
||||
}
|
||||
# Altbestand-Bausteine mit Prüfung, aber ohne (relevante) Subs → Default-cap.
|
||||
# Altbestand-Bausteine mit Prüfung, aber ohne Subs → leere Ebenen (cap 0).
|
||||
for b, p in progress.items():
|
||||
if b not in bausteine:
|
||||
bausteine[b] = {"gute_antworten": p["gute_antworten"], "streak": p["streak"], "cap": 10}
|
||||
bausteine[b] = _stand(p["gute_antworten"], p["streak"], {})
|
||||
return {"bausteine": bausteine}
|
||||
|
||||
|
||||
@@ -282,54 +314,52 @@ def _farbe(punkte: int) -> str:
|
||||
return "gut" if punkte > 0 else ("neutral" if punkte == 0 else "schlecht")
|
||||
|
||||
|
||||
def _erklaeren_delta(niveau: str, nachgefragt: bool) -> int:
|
||||
"""Erklären-Form: Punkt-Delta aus dem Niveau (−1…+3). Mit Nachfrage höchstens +1."""
|
||||
delta = NIVEAUS.get(niveau, 0)
|
||||
return min(delta, 1) if nachgefragt else delta
|
||||
|
||||
|
||||
async def _cap_fuer(topic: str, baustein: str) -> int:
|
||||
"""Maximal-Score des Bausteins = clamp(4×relevante Subbausteine, 10, 50)."""
|
||||
return cap_fuer(await count_relevant_subs(topic, baustein))
|
||||
|
||||
|
||||
async def _buche(req, frage: str, delta: int, cap: int) -> dict:
|
||||
"""Score driftfrei buchen (Lock + offene_frage-Anker). Fehler (delta<0) → progressiver
|
||||
Malus nach Fortschritt. Element einmalig anlegen, sobald Anfänger (20 %) erreicht."""
|
||||
async def _buche(req, frage: str, niveau: str, n_je_ebene: dict[int, int]) -> dict:
|
||||
"""Score+Streak driftfrei buchen (Lock + offene_frage/offene_streak-Anker). Niveau →
|
||||
Punkt-Delta (streak-moduliert) bzw. progressiver Malus bei Fehler. cap_aktuell wird aus
|
||||
der Basis abgeleitet (verzögerte Freischaltung an der Ebenen-Schwelle); Element einmalig
|
||||
ab Anfänger-Stufe. Re-Bewertung derselben Frage nutzt den offenen Streak-Anker → idempotent."""
|
||||
async with _pruef_lock(req.topic, req.baustein):
|
||||
stand = await get_baustein_progress(req.topic, req.baustein)
|
||||
war_stufe = stand["absolviert"] is not None # Element-Guard: schon je angelegt?
|
||||
basis, re_bewertung = _basis(stand, frage)
|
||||
streak_basis = stand["offene_streak"] if re_bewertung else stand["streak"]
|
||||
if not re_bewertung:
|
||||
await set_offene_frage(req.topic, req.baustein, frage, basis, 0)
|
||||
floor = floor_aus_score(basis, cap)
|
||||
d = progressiver_malus(basis, cap) if delta < 0 else delta
|
||||
score = score_berechnen(basis, d, floor, cap)
|
||||
await set_offene_frage(req.topic, req.baustein, frage, basis, stand["streak"])
|
||||
s = schwellen(n_je_ebene)
|
||||
cf = s[-1]
|
||||
ca = cap_aktuell(basis, n_je_ebene)
|
||||
floor = floor_aus_score(basis, cf, s)
|
||||
d, neue_streak = punkte_delta(niveau, streak_basis, basis, ca)
|
||||
score = score_berechnen(basis, d, floor, ca, cf)
|
||||
punkte = score - basis
|
||||
gute = await set_baustein_score(req.topic, req.baustein, score)
|
||||
gute, streak = await set_baustein_score_and_streak(req.topic, req.baustein, score, neue_streak)
|
||||
# Lern-Element einmalig anlegen, sobald die erste Stufe (Anfänger) erreicht ist.
|
||||
if not war_stufe and stufe_aus_score(score, cap) is not None:
|
||||
if not war_stufe and stufe_aus_score(score, cf) is not None:
|
||||
if await set_baustein_absolviert(req.topic, req.baustein):
|
||||
asyncio.create_task(baustein_element_anlegen(req.topic, req.baustein, req.section, req.provider))
|
||||
return {"punkte": punkte, "bewertung": _farbe(punkte), "gute_antworten": gute, "cap": cap}
|
||||
return {"punkte": punkte, "bewertung": _farbe(punkte), "gute_antworten": gute, "streak": streak, "cap": cf}
|
||||
|
||||
|
||||
@router.post("/bausteine/pruefung", response_model=BausteinPruefungResponse)
|
||||
async def baustein_pruefung_route(req: BausteinPruefungRequest):
|
||||
stand = await get_baustein_progress(req.topic, req.baustein)
|
||||
gute = stand["gute_antworten"]
|
||||
cap = await _cap_fuer(req.topic, req.baustein)
|
||||
n_je_ebene = await subs_je_ebene(req.topic, req.baustein)
|
||||
cap = cap_final(n_je_ebene)
|
||||
niveau = stufe_aus_score(gute, cap) or "anfaenger" # Adressaten-Rolle der Frage
|
||||
fe = freie_ebene(gute, n_je_ebene) # nur freigeschaltete Subs prüfen
|
||||
kompakt = req.section_kompakt
|
||||
msgs = [m.model_dump() for m in req.messages]
|
||||
|
||||
if req.aktion == "frage":
|
||||
if req.muster.strip():
|
||||
# Aus gezogenem Muster eine konkrete, leicht andere Frage formulieren (kein Dedup nötig).
|
||||
frage = await pruefung_frage_variante(req.topic, req.baustein, req.section, kompakt, req.muster, provider=req.provider)
|
||||
# Aus gezogenem Muster eine konkrete Frage im Niveau formulieren (kein Dedup nötig).
|
||||
frage = await pruefung_frage_variante(req.topic, req.baustein, req.section, kompakt, req.muster, niveau=niveau, provider=req.provider)
|
||||
else:
|
||||
# Fallback (kein Muster-Sidecar): Live-Generierung mit zufälligem Typ/Subbaustein.
|
||||
subs = await subbausteine_titel(req.topic, req.baustein) # Fokus-Kandidaten (zufällig gewählt)
|
||||
frage = await pruefung_frage(req.topic, req.baustein, req.section, kompakt, msgs, subbausteine=subs, vermeide=req.vermeide, provider=req.provider)
|
||||
# Fallback (kein Muster-Sidecar): Live-Generierung, Fokus nur auf freigeschaltete Subs.
|
||||
subs = await subbausteine_frei(req.topic, req.baustein, fe)
|
||||
frage = await pruefung_frage(req.topic, req.baustein, req.section, kompakt, msgs, subbausteine=subs, vermeide=req.vermeide, niveau=niveau, provider=req.provider)
|
||||
if frage is None:
|
||||
raise HTTPException(502, "Frage fehlgeschlagen — bitte erneut versuchen")
|
||||
return {"frage": frage, "gute_antworten": gute, "cap": cap}
|
||||
@@ -349,7 +379,8 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest):
|
||||
if req.aktion == "quiz_frage":
|
||||
if not req.muster.strip():
|
||||
raise HTTPException(400, "Quiz braucht ein Muster")
|
||||
quiz = await quiz_generieren(req.topic, req.baustein, req.section, kompakt, req.muster, schwer=req.schwer, provider=req.provider)
|
||||
distraktoren = await huerden_distraktor_block(req.topic, req.baustein)
|
||||
quiz = await quiz_generieren(req.topic, req.baustein, req.section, kompakt, req.muster, niveau=niveau, provider=req.provider, distraktor_block=distraktoren)
|
||||
if quiz is None:
|
||||
raise HTTPException(502, "Quiz-Frage fehlgeschlagen — bitte erneut versuchen")
|
||||
return {"frage": quiz["frage"], "optionen": quiz["optionen"],
|
||||
@@ -359,8 +390,7 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest):
|
||||
if not req.frage.strip():
|
||||
raise HTTPException(400, "Quiz-Antwort braucht eine Frage")
|
||||
getroffen = set(req.auswahl) == set(req.korrekt) # exakt die richtige Menge
|
||||
delta = (3 if req.schwer else 1) if getroffen else -1
|
||||
res = await _buche(req, req.frage, delta, cap)
|
||||
res = await _buche(req, req.frage, "stark" if getroffen else "kaum", n_je_ebene)
|
||||
res["feedback"] = "Richtig — alle korrekten getroffen." if getroffen else "Nicht ganz — die markierten waren richtig."
|
||||
return res
|
||||
|
||||
@@ -369,12 +399,13 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest):
|
||||
if not req.muster.strip():
|
||||
raise HTTPException(400, "Lückentext braucht ein Muster")
|
||||
if req.schwer:
|
||||
lt = await lueckentext_generieren(req.topic, req.baustein, req.section, kompakt, req.muster, provider=req.provider)
|
||||
lt = await lueckentext_generieren(req.topic, req.baustein, req.section, kompakt, req.muster, niveau=niveau, provider=req.provider)
|
||||
if lt is None:
|
||||
raise HTTPException(502, "Lückentext fehlgeschlagen — bitte erneut versuchen")
|
||||
return {"satz": lt["satz"], "loesung": lt["loesung"], "alternativen": lt["alternativen"],
|
||||
"gute_antworten": gute, "cap": cap}
|
||||
lw = await lueckwahl_generieren(req.topic, req.baustein, req.section, kompakt, req.muster, provider=req.provider)
|
||||
distraktoren = await huerden_distraktor_block(req.topic, req.baustein)
|
||||
lw = await lueckwahl_generieren(req.topic, req.baustein, req.section, kompakt, req.muster, niveau=niveau, provider=req.provider, distraktor_block=distraktoren)
|
||||
if lw is None:
|
||||
raise HTTPException(502, "Lückentext fehlgeschlagen — bitte erneut versuchen")
|
||||
return {"satz": lw["satz"], "optionen": lw["optionen"],
|
||||
@@ -389,7 +420,7 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest):
|
||||
else: # Begriff aus 4 gewählt → deterministisch
|
||||
ok = set(req.auswahl) == set(req.korrekt)
|
||||
feedback = "Richtig!" if ok else "Nicht ganz — der markierte Begriff war richtig."
|
||||
res = await _buche(req, req.frage, (3 if req.schwer else 1) if ok else -1, cap)
|
||||
res = await _buche(req, req.frage, "stark" if ok else "kaum", n_je_ebene)
|
||||
res["feedback"] = feedback
|
||||
return res
|
||||
|
||||
@@ -406,11 +437,14 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest):
|
||||
)
|
||||
if data is None:
|
||||
raise HTTPException(502, "Bewertung fehlgeschlagen — bitte erneut versuchen")
|
||||
basis, _ = _basis(stand, req.frage)
|
||||
floor = floor_aus_score(basis, cap)
|
||||
roh = _erklaeren_delta(data["niveau"], req.nachgefragt)
|
||||
d = progressiver_malus(basis, cap) if roh < 0 else roh
|
||||
score = score_berechnen(basis, d, floor, cap)
|
||||
basis, re_bew = _basis(stand, req.frage)
|
||||
streak_basis = stand["offene_streak"] if re_bew else stand["streak"]
|
||||
s = schwellen(n_je_ebene)
|
||||
ca = cap_aktuell(basis, n_je_ebene)
|
||||
floor = floor_aus_score(basis, s[-1], s)
|
||||
niveau = deckel_nachfrage(data["niveau"], req.nachgefragt)
|
||||
d, _ = punkte_delta(niveau, streak_basis, basis, ca)
|
||||
score = score_berechnen(basis, d, floor, ca, s[-1])
|
||||
punkte = score - basis
|
||||
return {"feedback": data["feedback"], "punkte": punkte, "bewertung": _farbe(punkte),
|
||||
"gute_antworten": gute, "cap": cap}
|
||||
@@ -424,8 +458,8 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest):
|
||||
)
|
||||
if data is None:
|
||||
raise HTTPException(502, "Bewertung fehlgeschlagen — bitte erneut versuchen")
|
||||
roh = _erklaeren_delta(data["niveau"], req.nachgefragt)
|
||||
res = await _buche(req, req.frage, roh, cap) # kurzer Lock: Basis driftfrei über offene_frage-Anker
|
||||
niveau = deckel_nachfrage(data["niveau"], req.nachgefragt)
|
||||
res = await _buche(req, req.frage, niveau, n_je_ebene) # kurzer Lock: Basis driftfrei über Anker
|
||||
res["feedback"] = data["feedback"]
|
||||
return res
|
||||
|
||||
@@ -483,19 +517,26 @@ async def get_one(guide_id: str):
|
||||
|
||||
|
||||
@router.get("/guides/{guide_id}/content")
|
||||
async def guide_content(guide_id: str):
|
||||
async def guide_content(guide_id: str, ebene: int = 4):
|
||||
"""Guide-Inhalt. `ebene` (1=A · 2=F · 3=E · 4=V) filtert auf Subbausteine bis zu dieser
|
||||
Ebene; 4 = Vollfassung (roh, unverändert)."""
|
||||
guide = await get_guide(guide_id)
|
||||
if guide is None:
|
||||
raise HTTPException(404, "Guide nicht gefunden")
|
||||
if guide["status"] != "done":
|
||||
raise HTTPException(404, "Inhalt nicht verfügbar")
|
||||
stored = await get_guide_content(guide["topic"], guide["format"]) # DB-first
|
||||
if stored is not None:
|
||||
if stored is None:
|
||||
path = guide_content_path(guide["topic"], guide["format"]) # Fallback: Datei (Alt-Themen)
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Datei nicht gefunden")
|
||||
stored = path.read_text(encoding="utf-8")
|
||||
if ebene >= 4:
|
||||
return Response(content=stored, media_type="application/json") # Vollfassung roh
|
||||
try:
|
||||
return content_fuer_ebene(json.loads(stored), ebene)
|
||||
except ValueError:
|
||||
return Response(content=stored, media_type="application/json")
|
||||
path = guide_content_path(guide["topic"], guide["format"]) # Fallback: Datei (Alt-Themen)
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Datei nicht gefunden")
|
||||
return FileResponse(path, media_type="application/json")
|
||||
|
||||
|
||||
@router.post("/guides/{guide_id}/chat", response_model=GuideChatResponse)
|
||||
|
||||
@@ -102,16 +102,18 @@ _FRAGMENT_BAUSTEIN_RE = re.compile(r"<!--\s*baustein\s*:\s*(.*?)\s*-->", re.IGNO
|
||||
# Zwei Lese-Schichten je Section: kompakt (Merksätze) + ausführlich (Erklärung).
|
||||
_FRAGMENT_KOMPAKT_RE = re.compile(r"<!--\s*kompakt\s*-->", re.IGNORECASE)
|
||||
_FRAGMENT_AUSF_RE = re.compile(r"<!--\s*ausf(?:ü|ue)hrlich\s*-->", re.IGNORECASE)
|
||||
_STUFEN = ("einfach", "mittel", "schwer")
|
||||
# Lernpfad-Stufen + Rand; alte Schwierigkeits-Werte abwärtskompatibel akzeptiert.
|
||||
_STUFEN = ("anfaenger", "fortgeschritten", "experte", "rand", "einfach", "mittel", "schwer")
|
||||
|
||||
|
||||
def _parse_fragment(text: str) -> list[dict]:
|
||||
"""Parst eine Writer-Datei → [{"kapitel", "titel", "md", "kompakt", "subs"}] in Reihenfolge.
|
||||
"""Parst eine Writer-Datei → [{kapitel, titel, md, kompakt, anker, anker_kompakt, subs}].
|
||||
|
||||
Zwei optionale Lese-Schichten je Section über `<!-- kompakt -->` / `<!-- ausführlich -->`:
|
||||
Zeilen nach `kompakt` landen in `sec["kompakt"]`, sonst in `sec["md"]` (= ausführlich,
|
||||
auch der Default ohne Marker → rückwärtskompatibel). Optionale `<!-- sub: -->`-Marker
|
||||
sammeln sich weiter in `sec["subs"]`.
|
||||
Zwei Lese-Schichten je Section über `<!-- kompakt -->` / `<!-- ausführlich -->`. Innerhalb
|
||||
beider markieren `<!-- sub: stufe | titel -->`-Marker je Subbaustein einen Block; gleicher
|
||||
Sub-Titel in beiden Schichten wird gemergt → `sec["subs"] = [{stufe, titel, md, kompakt}]`.
|
||||
Text VOR dem ersten Sub-Marker ist der Anker (Einordnung) → `anker`/`anker_kompakt`.
|
||||
`md`/`kompakt` bleiben die VOLLE Fassung (Anker + alle Subs) — rückwärtskompatibel.
|
||||
"""
|
||||
sections: list[dict] = []
|
||||
kapitel = None
|
||||
@@ -128,7 +130,8 @@ def _parse_fragment(text: str) -> list[dict]:
|
||||
continue
|
||||
m = _FRAGMENT_SECTION_RE.match(s)
|
||||
if m:
|
||||
current = {"kapitel": kapitel, "titel": m.group(1), "md": [], "kompakt": [], "subs": []}
|
||||
current = {"kapitel": kapitel, "titel": m.group(1), "md": [], "kompakt": [],
|
||||
"anker_md": [], "anker_kompakt": [], "_submap": {}, "_suborder": []}
|
||||
cur_sub = None
|
||||
cur_layer = "md"
|
||||
sections.append(current)
|
||||
@@ -145,24 +148,40 @@ def _parse_fragment(text: str) -> list[dict]:
|
||||
if m and current is not None:
|
||||
teil = m.group(1).split("|", 1)
|
||||
stufe = teil[0].strip().casefold()
|
||||
cur_sub = {
|
||||
"stufe": stufe if stufe in _STUFEN else "einfach",
|
||||
"titel": teil[1].strip() if len(teil) == 2 else "",
|
||||
"md": [],
|
||||
}
|
||||
current["subs"].append(cur_sub)
|
||||
titel = teil[1].strip() if len(teil) == 2 else ""
|
||||
key = titel.casefold() or f"_pos{len(current['_suborder'])}"
|
||||
cur_sub = current["_submap"].get(key)
|
||||
if cur_sub is None:
|
||||
cur_sub = {"stufe": stufe if stufe in _STUFEN else "anfaenger", "titel": titel, "md": [], "kompakt": []}
|
||||
current["_submap"][key] = cur_sub
|
||||
current["_suborder"].append(key)
|
||||
elif stufe in _STUFEN:
|
||||
cur_sub["stufe"] = stufe
|
||||
continue
|
||||
if current is not None:
|
||||
current[cur_layer].append(line)
|
||||
if cur_sub is not None and cur_layer == "md":
|
||||
cur_sub["md"].append(line)
|
||||
if cur_sub is not None:
|
||||
cur_sub[cur_layer].append(line)
|
||||
else:
|
||||
current["anker_" + cur_layer].append(line)
|
||||
out: list[dict] = []
|
||||
for sec in sections:
|
||||
sec["md"] = "\n".join(sec["md"]).strip()
|
||||
sec["kompakt"] = "\n".join(sec["kompakt"]).strip()
|
||||
for sub in sec["subs"]:
|
||||
subs = []
|
||||
for key in sec["_suborder"]:
|
||||
sub = sec["_submap"][key]
|
||||
sub["md"] = "\n".join(sub["md"]).strip()
|
||||
sec["subs"] = [sub for sub in sec["subs"] if sub["md"]]
|
||||
return sections
|
||||
sub["kompakt"] = "\n".join(sub["kompakt"]).strip()
|
||||
if sub["md"] or sub["kompakt"]:
|
||||
subs.append(sub)
|
||||
out.append({
|
||||
"kapitel": sec["kapitel"], "titel": sec["titel"],
|
||||
"md": "\n".join(sec["md"]).strip(),
|
||||
"kompakt": "\n".join(sec["kompakt"]).strip(),
|
||||
"anker": "\n".join(sec["anker_md"]).strip(),
|
||||
"anker_kompakt": "\n".join(sec["anker_kompakt"]).strip(),
|
||||
"subs": subs,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _parse_subbausteine(text: str) -> dict[str, list[str]]:
|
||||
|
||||
Reference in New Issue
Block a user