This commit is contained in:
team3
2026-06-27 00:58:46 +02:00
parent 2a2026c9ae
commit f018ca5048
44 changed files with 3070 additions and 379 deletions

View File

@@ -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 (13)."""
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)