1034 lines
49 KiB
Python
1034 lines
49 KiB
Python
"""Guide-Generierung als Konsens-Pipeline.
|
||
|
||
Gliederung: Auswahl der Bausteine (deterministisch je Format) → 3 Vorschläge
|
||
(Grace), die Bausteine NUMMERN-basiert in Kapitel ordnen → ein Judge merged die
|
||
Vorschläge zu einer kohärenten Reihenfolge.
|
||
Schreiben: Writer je Baustein. Lese-Prüfung: Check→Fix (eine Runde),
|
||
Folgerunden prüfen nur ersetzte Sections; danach bleiben Beanstandungen stehen.
|
||
Schritt-Dateien bleiben liegen → Abbruch erhält Fortschritt, ▶ setzt am offenen Schritt fort.
|
||
"""
|
||
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
import math
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
|
||
import uuid
|
||
|
||
from agents import run_agent
|
||
from bausteine import _pdfs_konvertieren, quelle_ordner
|
||
from config import (
|
||
DEFAULT_PROVIDER, FORMAT_ZWECK, KONSENS_GRACE,
|
||
LESBARKEIT_AKTIV, TEMPLATES_DIR,
|
||
)
|
||
import lesbarkeit
|
||
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
|
||
from pipeline import (
|
||
CANCELLED, FAILED, GenContext, _claude_error, _extra,
|
||
_fail, _gather_error, _gather_fortschritt, _log, _prompt, _race,
|
||
_semaphore, _set_progress, _set_step, _timeout, clear_guide_cancelled,
|
||
is_guide_cancelled, run_single_slot,
|
||
)
|
||
from textkit import (
|
||
_eindeutige_titel, _lade_bausteine, _norm_titel, _parse_fragment, _split_chunks,
|
||
_titel, _titel_aufloesen, _titel_index,
|
||
)
|
||
|
||
log = logging.getLogger("creator.guide")
|
||
|
||
GUIDE_STEPS = ("Gliederung", "Inhalte", "Inhalts-Check", "Schreiben", "Lese-Prüfung")
|
||
|
||
# Inhalte/Inhalts-Check/Lese-Prüfung laufen in Paketen von ~GUIDE_CHUNK Bausteinen je Agent.
|
||
# Nur der Writer (Schreiben) bleibt 1 Agent je Baustein (variable Längen, kein Kürzen, keine
|
||
# Längen-Angleichung zwischen Bausteinen).
|
||
GUIDE_CHUNK = 10
|
||
|
||
# Prüf-Schritte als Panel: CHECK_PANEL Judges je Chunk, Section beanstandet bei Mehrheit.
|
||
# Ein einzelner Judge ist bias-/sampling-anfällig; ein kleines Panel ist stabiler.
|
||
CHECK_PANEL = 3
|
||
|
||
# Lese-Prüfung: nur EINE Runde (Check + Fix). Folgerunden brachten kaum Mehrwert
|
||
# (1 Agent je Baustein prüft ohnehin fein), kosten aber extra Agenten.
|
||
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 _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"], "fakten": fakten})
|
||
if out:
|
||
return out
|
||
data = _json_datei(subbausteine_path(topic))
|
||
if not isinstance(data, dict):
|
||
return {}
|
||
for titel, subs in data.items():
|
||
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 _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 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" [{_ebene_label(s)}] {s['titel']}")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _guide_files(content_path: Path) -> dict:
|
||
d, stem = content_path.parent, content_path.stem
|
||
return {
|
||
"gliederung_slots": [d / f"{stem}.gliederung-{i}.json" for i in (1, 2, 3)],
|
||
"gliederung": d / f"{stem}.gliederung.json", # Judge-Ausgabe
|
||
# chunk-/lese-check-/fix-Dateien sind dynamisch:
|
||
# {stem}.chunk-i.md, {stem}.lese-check-r{n}-{i}.json, {stem}.fix-r{n}-{i}.md
|
||
}
|
||
|
||
|
||
def guide_slot_dateien(content_path: Path) -> list[Path]:
|
||
"""Alle Schritt-Dateien eines Guides (für den Frischstart)."""
|
||
return [p for p in content_path.parent.glob(f"{content_path.stem}.*") if p != content_path]
|
||
|
||
|
||
def _fertig_path(content_path: Path) -> Path:
|
||
return content_path.parent / f"{content_path.stem}.fertig"
|
||
|
||
|
||
def guide_fertig_step(content_path: Path) -> int:
|
||
"""Höchster VOLL abgeschlossener Schritt-Index (Marker je Thema+Format). -1 = keiner.
|
||
Existiert die Content-Datei, sind alle Schritte fertig."""
|
||
if content_path.exists():
|
||
return len(GUIDE_STEPS) - 1
|
||
try:
|
||
return int(_fertig_path(content_path).read_text(encoding="utf-8").strip())
|
||
except (OSError, ValueError):
|
||
return -1
|
||
|
||
|
||
def _set_fertig(content_path: Path, step: int) -> None:
|
||
"""Marker auf `step` setzen — monoton (nur erhöhen), außer beim Re-Run-Reset (force)."""
|
||
if step > guide_fertig_step(content_path):
|
||
atomic_write_text(_fertig_path(content_path), str(step))
|
||
|
||
|
||
def _reset_fertig(content_path: Path, step: int) -> None:
|
||
"""Marker hart auf `step` setzen (für Re-Run ab Schritt; step kann sinken)."""
|
||
if step < 0:
|
||
_fertig_path(content_path).unlink(missing_ok=True)
|
||
else:
|
||
atomic_write_text(_fertig_path(content_path), str(step))
|
||
|
||
|
||
# Slot-Datei-Globs je Schritt (Index = GUIDE_STEPS). Stem-verankert, kollisionsfrei.
|
||
_STEP_GLOBS = (
|
||
("gliederung*",), # 0 Gliederung (inkl. Auswahl-Filter)
|
||
("inhalt-chunk-*", "inhalt-nach-*"), # 1 Inhalte (inkl. Nachrunde)
|
||
("inhalt-check-*", "inhalt-fix-*"), # 2 Inhalts-Check
|
||
("chunk-*",), # 3 Schreiben (chunk-* matcht auch chunk-nach-*)
|
||
("lese-check-*", "fix-r*"), # 4 Lese-Prüfung
|
||
)
|
||
|
||
|
||
def _reset_guide_ab_step(content_path: Path, step: int) -> None:
|
||
"""Re-Run ab Schritt: Content + alle Slot-Dateien der Schritte ≥ step löschen.
|
||
Frühere Schritte bleiben → der Resume baut ab `step` neu (alles darunter wiederverwendet)."""
|
||
content_path.unlink(missing_ok=True) # nicht mehr „done" → kein Frischstart-Wipe
|
||
d, stem = content_path.parent, content_path.stem
|
||
for globs in _STEP_GLOBS[step:]:
|
||
for pat in globs:
|
||
for p in d.glob(f"{stem}.{pat}"):
|
||
p.unlink(missing_ok=True)
|
||
_reset_fertig(content_path, step - 1) # Schritte < step gelten als fertig
|
||
|
||
|
||
def _lese_probleme_schema(data):
|
||
"""{"ok": true} → [] · {"probleme": [{"section", "problem"}]} → Liste · sonst None."""
|
||
if not isinstance(data, dict):
|
||
return None
|
||
if data.get("ok") is True:
|
||
return []
|
||
p = data.get("probleme")
|
||
if not isinstance(p, list) or not p:
|
||
return None
|
||
out = []
|
||
for x in p:
|
||
if not isinstance(x, dict) or not isinstance(x.get("section"), str) or not isinstance(x.get("problem"), str):
|
||
return None
|
||
out.append({"section": x["section"].strip(), "problem": x["problem"].strip()})
|
||
return out or None
|
||
|
||
|
||
def _panel_probleme(judge_paths: list[Path], geltung: set[int], idx: dict[str, int]) -> dict[int, str]:
|
||
"""Panel-Aggregation: mehrere Judge-Outputs eines Chunks → beanstandete {num: problem}.
|
||
|
||
Eine Stimme je Judge, der eine Section nennt. Beanstandet, wenn > Hälfte der
|
||
GELIEFERTEN (valid geparsten) Judges sie nennt (3→≥2, 2→≥2, 1→≥1). Robust gegen
|
||
Einzel-Ausfall: fehlende Dateien zählen nicht mit. Problem-Text vom erstnennenden Judge.
|
||
"""
|
||
outputs = [p for p in (_lese_probleme_schema(_json_datei(j)) for j in judge_paths) if p is not None]
|
||
if not outputs:
|
||
return {}
|
||
votes: dict[int, int] = {}
|
||
problem: dict[int, str] = {}
|
||
for out in outputs:
|
||
gesehen: set[int] = set()
|
||
for item in out:
|
||
num = _titel_aufloesen(idx, item["section"])
|
||
if num is None or num not in geltung or num in gesehen:
|
||
continue
|
||
gesehen.add(num)
|
||
votes[num] = votes.get(num, 0) + 1
|
||
problem.setdefault(num, item["problem"])
|
||
schwelle = len(outputs) / 2
|
||
return {num: problem[num] for num, v in votes.items() if v > schwelle}
|
||
|
||
|
||
def _resolve_gliederung(data, entries: dict[int, str], soll_min: int, soll_max: int) -> list[dict] | None:
|
||
"""{"kapitel": [{"titel", "nummern": [1, 3, 7]}]} → [{"title", "nums"}].
|
||
|
||
Nummern sind die IDs aus `entries` (1-basiert, wie dem Agenten präsentiert).
|
||
`soll_min`/`soll_max` = erlaubte Spanne gewählter Bausteine (mit kleiner Toleranz).
|
||
"""
|
||
if not isinstance(data, dict) or not isinstance(data.get("kapitel"), list):
|
||
return None
|
||
gueltig = set(entries)
|
||
chapters: list[dict] = []
|
||
seen: set[int] = set()
|
||
total = unknown = 0
|
||
for ch in data["kapitel"]:
|
||
if not isinstance(ch, dict) or not isinstance(ch.get("nummern"), list):
|
||
return None
|
||
nums = []
|
||
for t in ch["nummern"]:
|
||
total += 1
|
||
num = t if isinstance(t, int) and not isinstance(t, bool) else None
|
||
if num is None or num not in gueltig:
|
||
unknown += 1
|
||
elif num not in seen:
|
||
nums.append(num)
|
||
seen.add(num)
|
||
if nums:
|
||
chapters.append({"title": str(ch.get("titel", "")).strip() or "Kapitel", "nums": nums})
|
||
if not chapters or total == 0:
|
||
return None
|
||
if (total - unknown) / total < 0.85:
|
||
return None
|
||
if len(seen) < 0.9 * soll_min or len(seen) > 1.1 * soll_max:
|
||
return None
|
||
return chapters
|
||
|
||
|
||
def _fallback_gliederung(entries: dict[int, str]) -> list[dict]:
|
||
"""Deterministische Gliederung, wenn die Agenten keine liefern: ein Kapitel mit
|
||
allen gewählten Bausteinen in Reihenfolge. Garantiert vollständige Abdeckung."""
|
||
return [{"title": "Inhalte", "nums": list(entries)}]
|
||
|
||
|
||
def _mit_resten(plan: list[dict], entries: dict[int, str]) -> list[dict]:
|
||
"""Stellt sicher, dass JEDER gewählte Baustein im Plan steht — fehlende landen in
|
||
einem Kapitel „Weitere" (gegen Agenten/Judge, die Bausteine weglassen)."""
|
||
drin = {num for ch in plan for num in ch.get("nums", [])}
|
||
fehlen = [num for num in entries if num not in drin]
|
||
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,
|
||
content_path: Path,
|
||
) -> list[dict] | None:
|
||
def is_cancelled() -> bool:
|
||
return is_guide_cancelled(guide_id)
|
||
|
||
ctx = GenContext(topic=topic, provider=provider, is_cancelled=is_cancelled, guide_id=guide_id)
|
||
spec = (TEMPLATES_DIR / "Format" / "Section.md").read_text(encoding="utf-8")
|
||
files = _guide_files(content_path)
|
||
zweck = FORMAT_ZWECK[format_name]
|
||
|
||
# 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: 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 / FullGuide → alle Bausteine
|
||
auswahl = list(entries)
|
||
if not auswahl:
|
||
await _fail(guide_id, "Keine passenden Bausteine für dieses Format")
|
||
return None
|
||
|
||
sel_entries = {num: entries[num] for num in auswahl}
|
||
soll = len(sel_entries)
|
||
# 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. 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).
|
||
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)
|
||
vorschlaege: list[list[dict]] = []
|
||
offen = []
|
||
for i, path in enumerate(files["gliederung_slots"], 1):
|
||
res = _resolve_gliederung(_json_datei(path), sel_entries, soll, soll)
|
||
if res is not None:
|
||
vorschlaege.append(res)
|
||
else:
|
||
offen.append((i, path))
|
||
if len(vorschlaege) < 3 and offen:
|
||
slots = [
|
||
{
|
||
"key": f"{guide_id}-gliederung-{i}",
|
||
"prompt": _prompt(
|
||
"Guide-Gliederung",
|
||
topic=topic, format_name=format_name, bausteine=sel_liste,
|
||
out_path=path, extra=_extra(instructions),
|
||
),
|
||
"role": "guide", "capabilities": "files",
|
||
"payload": (lambda result, p=path: _resolve_gliederung(_json_datei(p), sel_entries, soll, soll)),
|
||
}
|
||
for i, path in offen
|
||
]
|
||
# Quorum 1: nimm, was kommt — kein Mindestzwang, kein Abbruch.
|
||
neue = await _race(
|
||
topic, "Gliederung", slots, 1, _timeout("plan", soll),
|
||
provider, cancelled=is_cancelled, grace=KONSENS_GRACE,
|
||
)
|
||
if is_cancelled():
|
||
return None
|
||
vorschlaege += neue or []
|
||
|
||
if not vorschlaege:
|
||
_log(topic, "Gliederung: kein gültiger Vorschlag — deterministischer Fallback")
|
||
plan = _fallback_gliederung(sel_entries)
|
||
elif len(vorschlaege) == 1:
|
||
plan = vorschlaege[0] # ein Vorschlag → kein Judge nötig
|
||
else:
|
||
await _set_step(guide_id, 0, "Gliederungen zusammenführen…")
|
||
bloecke = "\n\n".join(
|
||
f"### Vorschlag {i}\n"
|
||
+ "\n".join(f"KAPITEL: {ch['title']}\n Nummern: {', '.join(str(num) for num in ch['nums'])}" for ch in v)
|
||
for i, v in enumerate(vorschlaege, 1)
|
||
)
|
||
status, plan = await run_single_slot(
|
||
ctx, "Gliederungs-Judge",
|
||
key=f"{guide_id}-gliederung-judge",
|
||
prompt=_prompt(
|
||
"Guide-Gliederung-Judge",
|
||
topic=topic, format_name=format_name, zweck=zweck, n=len(vorschlaege),
|
||
bausteine=sel_liste, gliederungen=bloecke,
|
||
out_path=files["gliederung"], extra=_extra(instructions),
|
||
),
|
||
role="judge", capabilities="files",
|
||
payload=lambda result: _resolve_gliederung(_json_datei(files["gliederung"]), sel_entries, soll, soll),
|
||
timeout=_timeout("plan_judge", soll),
|
||
)
|
||
if status == CANCELLED:
|
||
return None
|
||
if status == FAILED or plan is None:
|
||
_log(topic, "Gliederung-Judge ohne Ergebnis — bester Vorschlag bleibt")
|
||
plan = vorschlaege[0]
|
||
|
||
# Garantie: jeder gewählte Baustein steht im Plan (gegen weglassende Agenten/Judges).
|
||
plan = _mit_resten(plan, sel_entries)
|
||
_set_fertig(content_path, 0) # Gliederung steht
|
||
|
||
# Grobe Chunks (~GUIDE_CHUNK Bausteine je Agent) für Inhalte, Inhalts-Check und Lese-Prüfung.
|
||
# 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: 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 / 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]
|
||
writer_count = len(chunks)
|
||
idx = _titel_index(entries)
|
||
|
||
# Schritt 2: Inhalte je Baustein identifizieren — pro Chunk ein Agent (Marker-Output, Resume).
|
||
inhalt_paths = [content_path.parent / f"{content_path.stem}.inhalt-chunk-{i}.md" for i in range(1, writer_count + 1)]
|
||
offen = [i for i, p in enumerate(inhalt_paths) if not p.exists()]
|
||
if offen:
|
||
async def melde(d, t): await _set_step(guide_id, 1, f"Sammle Inhalte {d}/{t}…")
|
||
results = await _gather_fortschritt([
|
||
run_agent(
|
||
f"{guide_id}-inhalt-{i + 1}",
|
||
_prompt(
|
||
"Guide-Inhalt",
|
||
topic=topic, zuteilung=zuteilungen[i], facts=facts,
|
||
out_path=inhalt_paths[i], extra=_extra(instructions),
|
||
),
|
||
_timeout("inhalt", chunk_sizes[i]), provider=provider, role="guide", capabilities="full",
|
||
)
|
||
for i in offen
|
||
], writer_count, melde, start=writer_count - len(offen))
|
||
if is_cancelled():
|
||
return None
|
||
if not any(p.exists() for p in inhalt_paths):
|
||
await _fail(guide_id, _gather_error("Inhalts-Fehler", list(results)))
|
||
return None
|
||
|
||
inhalt_by_num: dict[int, str] = {}
|
||
for p in inhalt_paths:
|
||
if not p.exists():
|
||
continue
|
||
for sec in _parse_fragment(p.read_text(encoding="utf-8")):
|
||
num = _titel_aufloesen(idx, sec["titel"])
|
||
if num is not None and num not in inhalt_by_num and sec["md"].strip():
|
||
inhalt_by_num[num] = sec["md"]
|
||
if not inhalt_by_num:
|
||
await _fail(guide_id, "Keine Inhalte identifiziert")
|
||
return None
|
||
|
||
# Nachrunde: fehlende Bausteine (Chunk-Ausfall oder Lazy-Output) gezielt nachziehen — eine Runde.
|
||
geplant_nums = [num for ch in plan for num in ch["nums"]]
|
||
fehlend = [num for num in geplant_nums if num not in inhalt_by_num]
|
||
if fehlend:
|
||
_log(topic, f"Inhalte: {len(fehlend)} Baustein(e) fehlen — Nachrunde…")
|
||
nach_chunks = [[{"title": "Weitere", "nums": fehlend[k:k + GUIDE_CHUNK]}] for k in range(0, len(fehlend), GUIDE_CHUNK)]
|
||
nach_paths = [content_path.parent / f"{content_path.stem}.inhalt-nach-{k}.md" for k in range(1, len(nach_chunks) + 1)]
|
||
nach_offen = [k for k, p in enumerate(nach_paths) if not p.exists()]
|
||
if nach_offen:
|
||
async def melde_n(d, t): await _set_step(guide_id, 1, f"Sammle fehlende Inhalte {d}/{t}…")
|
||
await _gather_fortschritt([
|
||
run_agent(
|
||
f"{guide_id}-inhalt-nach-{k + 1}",
|
||
_prompt(
|
||
"Guide-Inhalt",
|
||
topic=topic, zuteilung=_zuteilung_subs(nach_chunks[k], entries, subs_by_titel),
|
||
facts=facts, out_path=nach_paths[k], extra=_extra(instructions),
|
||
),
|
||
_timeout("inhalt", len(nach_chunks[k][0]["nums"])), provider=provider, role="guide", capabilities="full",
|
||
)
|
||
for k in nach_offen
|
||
], len(nach_chunks), melde_n, start=len(nach_chunks) - len(nach_offen))
|
||
if is_cancelled():
|
||
return None
|
||
for p in nach_paths:
|
||
if not p.exists():
|
||
continue
|
||
for sec in _parse_fragment(p.read_text(encoding="utf-8")):
|
||
num = _titel_aufloesen(idx, sec["titel"])
|
||
if num is not None and num not in inhalt_by_num and sec["md"].strip():
|
||
inhalt_by_num[num] = sec["md"]
|
||
|
||
if all(p.exists() for p in inhalt_paths):
|
||
_set_fertig(content_path, 1) # Inhalte vollständig
|
||
|
||
inhalt_chunk_nums = [[num for ch in chunk for num in ch["nums"] if num in inhalt_by_num] for chunk in chunks]
|
||
|
||
# Schritt 3: Inhalte prüfen — CHECK_PANEL Judges je Chunk, Mehrheit beanstandet.
|
||
# + beanstandete einmal überarbeiten. Resume: nur fehlende Judge-Dateien neu starten.
|
||
check_judge_paths = [
|
||
[content_path.parent / f"{content_path.stem}.inhalt-check-{i}-j{j}.json" for j in range(1, CHECK_PANEL + 1)]
|
||
for i in range(1, writer_count + 1)
|
||
]
|
||
offen_slots = [
|
||
(i, j) for i in range(writer_count) if inhalt_chunk_nums[i]
|
||
for j in range(CHECK_PANEL) if _lese_probleme_schema(_json_datei(check_judge_paths[i][j])) is None
|
||
]
|
||
if offen_slots:
|
||
await _set_step(guide_id, 2, "Prüfe Inhalte…")
|
||
sections_je_chunk = {
|
||
i: "\n\n".join(f"SECTION: {_titel(entries[num])}\n{inhalt_by_num[num]}" for num in inhalt_chunk_nums[i])
|
||
for i, _ in offen_slots
|
||
}
|
||
slots = [{
|
||
"key": f"{guide_id}-inhalt-check-{i + 1}-j{j + 1}",
|
||
"prompt": _prompt(
|
||
"Guide-Inhalt-Check",
|
||
topic=topic, format_name=format_name, sections=sections_je_chunk[i],
|
||
out_path=check_judge_paths[i][j], extra=_extra(instructions),
|
||
),
|
||
"role": "judge", "capabilities": "files",
|
||
"payload": (lambda result, p=check_judge_paths[i][j]: _lese_probleme_schema(_json_datei(p))),
|
||
} for i, j in offen_slots]
|
||
n_checks = len(slots)
|
||
upd = lambda n: asyncio.create_task(_set_step(guide_id, 2, f"Prüfe Inhalte {n}/{n_checks}…"))
|
||
await _race(topic, "Inhalts-Prüfung", slots, len(slots), _timeout("inhalt_check", max(chunk_sizes)), provider, on_update=upd, cancelled=is_cancelled, grace=KONSENS_GRACE)
|
||
if is_cancelled():
|
||
return None
|
||
|
||
probleme_by_num: dict[int, str] = {}
|
||
for i in range(writer_count):
|
||
if inhalt_chunk_nums[i]:
|
||
probleme_by_num.update(_panel_probleme(check_judge_paths[i], set(inhalt_chunk_nums[i]), idx))
|
||
|
||
if probleme_by_num:
|
||
_log(topic, f"Inhalts-Prüfung: {len(probleme_by_num)} Baustein(e) beanstandet")
|
||
await _set_step(guide_id, 2, f"Überarbeite {len(probleme_by_num)} Inhalt(e)…")
|
||
fix_chunks = [[num for num in nums if num in probleme_by_num] for nums in inhalt_chunk_nums]
|
||
fix_paths = [content_path.parent / f"{content_path.stem}.inhalt-fix-{i + 1}.md" for i in range(writer_count)]
|
||
fix_offen = [i for i, nums in enumerate(fix_chunks) if nums and not fix_paths[i].exists()]
|
||
results = await asyncio.gather(*[
|
||
run_agent(
|
||
f"{guide_id}-inhalt-fix-{i + 1}",
|
||
_prompt(
|
||
"Guide-Inhalt-Fix",
|
||
topic=topic, facts=facts,
|
||
auftraege="\n\n".join(
|
||
f"SECTION: {_titel(entries[num])}\nPROBLEM: {probleme_by_num[num]}\nAKTUELL:\n{inhalt_by_num[num]}"
|
||
for num in fix_chunks[i]
|
||
),
|
||
out_path=fix_paths[i], extra=_extra(instructions),
|
||
),
|
||
_timeout("inhalt", len(fix_chunks[i])), provider=provider, role="guide", capabilities="full",
|
||
)
|
||
for i in fix_offen
|
||
], return_exceptions=True)
|
||
if is_cancelled():
|
||
return None
|
||
for p in fix_paths:
|
||
if not p.exists():
|
||
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():
|
||
inhalt_by_num[num] = sec["md"]
|
||
|
||
_set_fertig(content_path, 2) # Inhalts-Check durch
|
||
|
||
# Schritt 4: Schreiben — Writer formuliert die geprüften Inhalte aus (Resume).
|
||
# FEINE Chunks: genau 1 Baustein je Writer → variable Längen, kein Budget-Rationieren.
|
||
def inhalte_text(chunk) -> str:
|
||
nums = [num for ch in chunk for num in ch["nums"] if num in inhalt_by_num]
|
||
return "\n\n".join(f"<!-- section: {_titel(entries[num])} -->\n{inhalt_by_num[num]}" for num in nums)
|
||
|
||
w_chunks = [[{"title": ch["title"], "nums": [num]}] for ch in plan for num in ch["nums"]]
|
||
w_zuteil = [_zuteilung_subs(c, entries, subs_by_titel) for c in w_chunks]
|
||
paths = [content_path.parent / f"{content_path.stem}.chunk-{i}.md" for i in range(1, len(w_chunks) + 1)]
|
||
offen = [i for i, p in enumerate(paths) if not p.exists()]
|
||
if offen:
|
||
async def melde(d, t): await _set_step(guide_id, 3, f"Schreibe Sections {d}/{t}…")
|
||
results = await _gather_fortschritt([
|
||
run_agent(
|
||
f"{guide_id}-w{i + 1}",
|
||
_prompt(
|
||
"Guide-Writer",
|
||
topic=topic, format_name=format_name, zuteilung=w_zuteil[i],
|
||
inhalte=inhalte_text(w_chunks[i]),
|
||
spec=spec, out_path=paths[i], extra=_extra(instructions),
|
||
),
|
||
_timeout("writer", 1), provider=provider, role="guide", capabilities="files",
|
||
)
|
||
for i in offen
|
||
], len(w_chunks), melde, start=len(w_chunks) - len(offen))
|
||
if is_cancelled():
|
||
return None
|
||
for i, r in zip(offen, results):
|
||
if isinstance(r, BaseException):
|
||
_log(topic, f"Writer {i + 1}: {type(r).__name__}: {r}")
|
||
elif r[0] != 0:
|
||
_log(topic, f"Writer {i + 1}: {_claude_error('Fehler', *r)}")
|
||
elif not paths[i].exists():
|
||
_log(topic, f"Writer {i + 1}: keine Ausgabedatei erstellt")
|
||
if not any(p.exists() for p in paths):
|
||
await _fail(guide_id, _gather_error("Writer-Fehler", list(results)))
|
||
return None
|
||
|
||
by_num: dict[int, dict] = {}
|
||
for p in paths:
|
||
if not p.exists():
|
||
continue
|
||
for sec in _parse_fragment(p.read_text(encoding="utf-8")):
|
||
num = _titel_aufloesen(idx, sec["titel"])
|
||
if num is None:
|
||
_log(topic, f"Writer lieferte unbekannte Section '{sec['titel'][:40]}' (ignoriert)")
|
||
elif num not in by_num:
|
||
by_num[num] = sec
|
||
if not by_num:
|
||
await _fail(guide_id, "Keine Sections in der Writer-Ausgabe gefunden")
|
||
return None
|
||
|
||
# Nachrunde: fehlende Sections (Writer-Ausfall) gezielt nachschreiben — eine Runde.
|
||
nach_fehlend = [num for num in geplant_nums if num not in by_num]
|
||
if nach_fehlend:
|
||
_log(topic, f"Schreiben: {len(nach_fehlend)} Section(s) fehlen — Nachrunde…")
|
||
nw_chunks = [[{"title": "Weitere", "nums": [num]}] for num in nach_fehlend]
|
||
nw_paths = [content_path.parent / f"{content_path.stem}.chunk-nach-{k}.md" for k in range(1, len(nw_chunks) + 1)]
|
||
nw_offen = [k for k, p in enumerate(nw_paths) if not p.exists()]
|
||
if nw_offen:
|
||
async def melde_nw(d, t): await _set_step(guide_id, 3, f"Schreibe fehlende Sections {d}/{t}…")
|
||
await _gather_fortschritt([
|
||
run_agent(
|
||
f"{guide_id}-w-nach-{k + 1}",
|
||
_prompt(
|
||
"Guide-Writer",
|
||
topic=topic, format_name=format_name, zuteilung=_zuteilung_subs(nw_chunks[k], entries, subs_by_titel),
|
||
inhalte=inhalte_text(nw_chunks[k]), spec=spec, out_path=nw_paths[k], extra=_extra(instructions),
|
||
),
|
||
_timeout("writer", 1), provider=provider, role="guide", capabilities="files",
|
||
)
|
||
for k in nw_offen
|
||
], len(nw_chunks), melde_nw, start=len(nw_chunks) - len(nw_offen))
|
||
if is_cancelled():
|
||
return None
|
||
for p in nw_paths:
|
||
if not p.exists():
|
||
continue
|
||
for sec in _parse_fragment(p.read_text(encoding="utf-8")):
|
||
num = _titel_aufloesen(idx, sec["titel"])
|
||
if num is not None and num not in by_num and sec["md"].strip():
|
||
by_num[num] = sec
|
||
|
||
if all(p.exists() for p in paths):
|
||
_set_fertig(content_path, 3) # Schreiben vollständig
|
||
|
||
# Schritt 3: Lese-Prüfungs-Loop — Check pro Writer-Paket, Fix nur für
|
||
# beanstandete Sections; Folgerunden prüfen NUR die ersetzten Sections.
|
||
# Nach dem Runden-Cap bleiben offene Beanstandungen stehen.
|
||
chunk_nums = [[num for ch in chunk for num in ch["nums"] if num in by_num] for chunk in chunks]
|
||
|
||
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])}\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
|
||
)
|
||
|
||
scope = chunk_nums
|
||
for runde in range(1, LESE_RUNDEN + 1):
|
||
# CHECK_PANEL Judges je Paket; Mehrheit beanstandet. Aggregation robust gegen Einzel-Ausfall.
|
||
check_judge_paths = [
|
||
[content_path.parent / f"{content_path.stem}.lese-check-r{runde}-{i}-j{j}.json" for j in range(1, CHECK_PANEL + 1)]
|
||
for i in range(1, writer_count + 1)
|
||
]
|
||
offen_slots = [
|
||
(i, j) for i in range(writer_count) if scope[i]
|
||
for j in range(CHECK_PANEL) if _lese_probleme_schema(_json_datei(check_judge_paths[i][j])) is None
|
||
]
|
||
if offen_slots:
|
||
await _set_step(guide_id, 4, "Prüfe Lesbarkeit…")
|
||
sections_je_chunk = {i: sections_text(scope[i]) for i, _ in offen_slots}
|
||
slots = [{
|
||
"key": f"{guide_id}-lese-check-r{runde}-{i + 1}-j{j + 1}",
|
||
"prompt": _prompt(
|
||
"Guide-Lese-Check",
|
||
topic=topic, format_name=format_name, spec=spec,
|
||
sections=sections_je_chunk[i],
|
||
out_path=check_judge_paths[i][j], extra=_extra(instructions),
|
||
),
|
||
"role": "judge", "capabilities": "files",
|
||
"payload": (lambda result, p=check_judge_paths[i][j]: _lese_probleme_schema(_json_datei(p))),
|
||
} for i, j in offen_slots]
|
||
n_checks = len(slots)
|
||
upd = lambda n: asyncio.create_task(_set_step(guide_id, 4, f"Prüfe Lesbarkeit {n}/{n_checks}…"))
|
||
res = await _race(topic, f"Lese-Prüfung r{runde}", slots, len(slots), _timeout("lese_check", max(chunk_sizes)), provider, on_update=upd, cancelled=is_cancelled, grace=KONSENS_GRACE)
|
||
if is_cancelled():
|
||
return None
|
||
if res is None:
|
||
_log(topic, f"Lese-Prüfung Runde {runde}: kein volles Quorum — vorhandene Judges aggregiert")
|
||
|
||
probleme_by_num: dict[int, str] = {}
|
||
for i in range(writer_count):
|
||
if scope[i]:
|
||
probleme_by_num.update(_panel_probleme(check_judge_paths[i], set(scope[i]), idx))
|
||
|
||
# Deterministisches Lesbarkeits-Gate: zu schwere Sections in dieselbe
|
||
# Überarbeitung einreihen (LLM-Beanstandung hat Vorrang). Gate aus → no-op.
|
||
if LESBARKEIT_AKTIV:
|
||
md_by_num = {num: by_num[num]["md"] for nums in scope for num in nums if num in by_num}
|
||
hinweise = await asyncio.to_thread(lesbarkeit.bewerte_sections, md_by_num)
|
||
if hinweise:
|
||
_log(topic, f"Lesbarkeit: {len(hinweise)} Section(s) zu schwer")
|
||
for num, hinweis in hinweise.items():
|
||
probleme_by_num.setdefault(num, hinweis)
|
||
|
||
if not probleme_by_num:
|
||
break
|
||
|
||
_log(topic, f"Lese-Prüfung Runde {runde}: {len(probleme_by_num)} Section(s) beanstandet")
|
||
await _set_step(guide_id, 4, f"Überarbeite {len(probleme_by_num)} Section(s) (Runde {runde})…")
|
||
fix_chunks = [[num for num in nums if num in probleme_by_num] for nums in chunk_nums]
|
||
fix_paths = [content_path.parent / f"{content_path.stem}.fix-r{runde}-{i + 1}.md" for i in range(writer_count)]
|
||
fix_offen = [i for i, nums in enumerate(fix_chunks) if nums and not fix_paths[i].exists()]
|
||
results = await asyncio.gather(*[
|
||
run_agent(
|
||
f"{guide_id}-fix-r{runde}-w{i + 1}",
|
||
_prompt(
|
||
"Guide-Sections-Fix",
|
||
topic=topic, format_name=format_name, facts=facts, spec=spec,
|
||
auftraege=auftraege_text(fix_chunks[i], probleme_by_num),
|
||
out_path=fix_paths[i], extra=_extra(instructions),
|
||
),
|
||
_timeout("writer", len(fix_chunks[i])), provider=provider, role="guide", capabilities="full",
|
||
)
|
||
for i in fix_offen
|
||
], return_exceptions=True)
|
||
if is_cancelled():
|
||
return None
|
||
for i, r in zip(fix_offen, results):
|
||
if isinstance(r, BaseException) or (not isinstance(r, BaseException) and r[0] != 0):
|
||
_log(topic, f"Sections-Fix {i + 1} (Runde {runde}) fehlgeschlagen — Original bleibt")
|
||
ersetzt: set[int] = set()
|
||
for p in fix_paths:
|
||
if not p.exists():
|
||
continue
|
||
for sec in _parse_fragment(p.read_text(encoding="utf-8")):
|
||
num = _titel_aufloesen(idx, sec["titel"])
|
||
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
|
||
if runde == LESE_RUNDEN:
|
||
_log(topic, f"Lese-Prüfung: 1 Runde — Überarbeitung bleibt ungeprüft")
|
||
break
|
||
scope = [[num for num in nums if num in ersetzt] for nums in chunk_nums]
|
||
_set_fertig(content_path, 4) # Lese-Prüfung durch
|
||
|
||
# Prüfbar = Format hat Prüfung UND Baustein hat ≥1 relevanten Subbaustein.
|
||
# Guide ist immer prüfbar (auch ohne Relevanz-Daten, Fallback = alles).
|
||
def _pruefbar(num):
|
||
if format_name == "Guide":
|
||
return True
|
||
if format_name == "FullGuide":
|
||
return any(isinstance(s, dict) and s.get("relevanz") == "relevant"
|
||
for s in subs_raw.get(_titel(entries[num]), []))
|
||
return False # Rest u.a. → reine Lese-Sections
|
||
|
||
await _set_progress(guide_id, "Setze zusammen…")
|
||
chapters: list[dict] = []
|
||
for ch in plan:
|
||
sections = [
|
||
{"num": num, "title": _titel(entries[num]), "md": by_num[num]["md"],
|
||
"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:
|
||
chapters.append({"title": ch["title"], "sections": sections})
|
||
geplant = {num for ch in plan for num in ch["nums"]}
|
||
missing = sorted(geplant - set(by_num))
|
||
if missing:
|
||
_log(topic, f"Sections fehlen in der Writer-Ausgabe: {[_titel(entries[n]) for n in missing]}")
|
||
if not chapters:
|
||
await _fail(guide_id, "Keine Sections in der Writer-Ausgabe gefunden")
|
||
return None
|
||
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.
|
||
|
||
Läuft beim Server-Start (nach init_db) — fängt Crashes zwischen
|
||
Datei-Write und Status-Update ab.
|
||
"""
|
||
for g in await list_guides():
|
||
if g["status"] == "done" and not guide_content_path(g["topic"], g["format"]).exists():
|
||
log.warning("[%s] Guide %s: done ohne Content-Datei — auf error gesetzt", g["topic"], g["id"])
|
||
now = datetime.now(timezone.utc).isoformat()
|
||
await update_guide(g["id"], status="error", error_msg="Inhalt fehlt — neu generieren", updated_at=now)
|
||
|
||
|
||
async def generate_guide(guide_id: str, topic: str, format_name: str, instructions: str = "", provider: str = DEFAULT_PROVIDER, ab_step: int | None = None) -> None:
|
||
async with _semaphore:
|
||
now = datetime.now(timezone.utc).isoformat()
|
||
await update_guide(guide_id, status="generating", progress="Starte…", updated_at=now)
|
||
|
||
content_path = guide_content_path(topic, format_name)
|
||
content_path.parent.mkdir(parents=True, exist_ok=True)
|
||
project = quelle_ordner(topic) # Ordner-Quelle (projekt/uni/link) → Pfad, sonst None
|
||
|
||
try:
|
||
if is_guide_cancelled(guide_id):
|
||
return
|
||
|
||
if project:
|
||
await asyncio.to_thread(_pdfs_konvertieren, project)
|
||
|
||
# Re-Run ab Schritt: Content + Slots ab `ab_step` löschen, Rest bleibt → Resume baut ab dort.
|
||
# Sonst „Neu erstellen": fertiger Guide → kompletter Frischstart.
|
||
# Sonst sind Schritt-Dateien Reste eines Abbruchs/Fehlers → Resume.
|
||
if ab_step is not None:
|
||
_reset_guide_ab_step(content_path, ab_step)
|
||
elif content_path.exists():
|
||
for p_alt in guide_slot_dateien(content_path):
|
||
p_alt.unlink(missing_ok=True)
|
||
|
||
bs = await list_bausteine(topic, status="konsens")
|
||
if bs:
|
||
alle = {i: (f"{b['titel']} — {b['beschreibung']}" if b["beschreibung"] else b["titel"])
|
||
for i, b in enumerate(bs, 1)}
|
||
else: # Fallback: bausteine.md (Alt-Themen)
|
||
bp = bausteine_path(topic)
|
||
alle = _lade_bausteine(bp.read_text(encoding="utf-8")) if bp.exists() else {}
|
||
if not alle:
|
||
await _fail(guide_id, "Keine Bausteine gefunden")
|
||
return
|
||
entries = _eindeutige_titel(alle)
|
||
facts = _prompt("Guide-Fakten-Projekt", project=project) if project else _prompt("Guide-Fakten-Thema")
|
||
chapters = await _generate_sections(
|
||
guide_id, topic, format_name, entries,
|
||
facts, instructions, provider, content_path,
|
||
)
|
||
if chapters is None or is_guide_cancelled(guide_id):
|
||
return
|
||
content = {"topic": topic, "format": format_name, "chapters": chapters}
|
||
|
||
atomic_write_json(content_path, content, indent=1) # Brücke (Resume/Fallback)
|
||
await set_guide_content(topic, format_name, json.dumps(content, ensure_ascii=False))
|
||
|
||
now = datetime.now(timezone.utc).isoformat()
|
||
await update_guide(guide_id, status="done", progress=None, step=None, updated_at=now)
|
||
|
||
except asyncio.TimeoutError:
|
||
await _fail(guide_id, "Timeout bei der Generierung")
|
||
except FileNotFoundError:
|
||
await _fail(guide_id, "Bausteine fehlen")
|
||
except Exception as e:
|
||
log.exception("[%s] Guide-Generierung fehlgeschlagen (%s)", topic, guide_id)
|
||
await _fail(guide_id, str(e)[:2000])
|
||
finally:
|
||
clear_guide_cancelled(guide_id)
|
||
|
||
|
||
# --- On-Demand: eine Section prüfen / beheben / neu schreiben (Fokus, interaktiv) ---
|
||
|
||
SECTION_PRUEFEN_TIMEOUT = 300
|
||
SECTION_FIX_TIMEOUT = 600
|
||
|
||
|
||
def _section_spec() -> str:
|
||
return (TEMPLATES_DIR / "Format" / "Section.md").read_text(encoding="utf-8")
|
||
|
||
|
||
def _section_facts(topic: str) -> str:
|
||
project = quelle_ordner(topic)
|
||
return _prompt("Guide-Fakten-Projekt", project=project) if project else _prompt("Guide-Fakten-Thema")
|
||
|
||
|
||
def _hinweis_block(hinweis: str) -> str:
|
||
hinweis = (hinweis or "").strip()
|
||
return f"HINWEIS DES NUTZERS (besonders beachten):\n{hinweis}" if hinweis else ""
|
||
|
||
|
||
async def _subs_text(topic: str, baustein: str) -> str:
|
||
"""Relevante Subbausteine eines Bausteins mit Stufe — als Checkliste für die Agenten."""
|
||
subs_raw = await _load_subbausteine(topic)
|
||
subs = [s for s in subs_raw.get(_titel(baustein), []) if s.get("relevanz") != "rand"]
|
||
if not subs:
|
||
return "(keine Subbausteine hinterlegt — 3–7 knappe Punkte abdecken)"
|
||
return "\n".join(f"- [{s['stufe']}] {s['titel']}" for s in subs)
|
||
|
||
|
||
async def _guide_content_laden(topic: str, format_name: str) -> dict | None:
|
||
js = await get_guide_content(topic, format_name)
|
||
if not js:
|
||
return None
|
||
try:
|
||
return json.loads(js)
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
def _section_finden(content: dict, baustein: str) -> dict | None:
|
||
for ch in content.get("chapters", []):
|
||
for s in ch.get("sections", []):
|
||
if s.get("title") == baustein:
|
||
return s
|
||
return None
|
||
|
||
|
||
async def block_pruefen(topic: str, format_name: str, baustein: str, stelle: str, block: str, hinweis: str = "", provider: str = DEFAULT_PROVIDER) -> str | None:
|
||
"""Einen Abschnitt (Markdown-Block) gegen die Guide-Regeln prüfen → korrigierte
|
||
Block-Version als Markdown. None = Fehler/Section fehlt."""
|
||
content = await _guide_content_laden(topic, format_name)
|
||
sec = _section_finden(content, baustein) if content else None
|
||
if sec is None:
|
||
return None
|
||
ganze = sec.get("kompakt", "") if str(stelle).startswith("kompakt") else sec.get("md", "")
|
||
prompt = _prompt(
|
||
"Block-Pruefen", topic=topic, spec=_section_spec(), facts=_section_facts(topic),
|
||
subbausteine=await _subs_text(topic, baustein), kontext=ganze, block=block, hinweis=_hinweis_block(hinweis),
|
||
)
|
||
rc, stdout, _ = await run_agent(
|
||
f"block-pruefen-{uuid.uuid4()}", prompt, SECTION_PRUEFEN_TIMEOUT,
|
||
provider=provider, role="judge", capabilities="none", lane="interactive",
|
||
)
|
||
neu = stdout.strip() if rc == 0 else ""
|
||
return neu or None
|
||
|
||
|
||
async def block_uebernehmen(topic: str, format_name: str, baustein: str, stelle: str, alt: str, neu: str) -> dict | None:
|
||
"""Einen Block (alt→neu) im Feld kompakt/ausführlich ersetzen + persistieren.
|
||
→ {kompakt, md, gefunden}; None = Section fehlt."""
|
||
content = await _guide_content_laden(topic, format_name)
|
||
sec = _section_finden(content, baustein) if content else None
|
||
if sec is None:
|
||
return None
|
||
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)
|
||
return {"kompakt": sec.get("kompakt", ""), "md": sec.get("md", ""), "gefunden": gefunden}
|