Files
creator/backend/guide.py
2026-06-27 00:58:46 +02:00

1034 lines
49 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 (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 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 — 37 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}