update
This commit is contained in:
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)
|
||||
|
||||
Reference in New Issue
Block a user