This commit is contained in:
Team3
2026-06-06 17:04:06 +02:00
parent 4aa3130807
commit c84fbbb484
11 changed files with 419 additions and 325 deletions

View File

@@ -71,6 +71,99 @@ async def _fail(guide_id: str, msg: str) -> None:
await update_guide(guide_id, status="error", progress=None, error_msg=msg, updated_at=now) await update_guide(guide_id, status="error", progress=None, error_msg=msg, updated_at=now)
def _norm_titel(s: str) -> str:
"""Normalisiert einen Titel für den Schlüssel-Vergleich."""
s = re.sub(r"[`'\"<>]", "", s)
return re.sub(r"\s+", " ", s).strip().lower()
def _titel(entry: str) -> str:
return entry.split("")[0].strip() or entry
def _eindeutige_titel(entries: dict[int, str]) -> dict[int, str]:
"""Macht Titel eindeutig (Suffix " (2)", " (3)" …), damit sie als Schlüssel taugen."""
seen: dict[str, int] = {}
out: dict[int, str] = {}
for num, text in entries.items():
titel = _titel(text)
key = _norm_titel(titel)
seen[key] = seen.get(key, 0) + 1
if seen[key] > 1:
rest = text.split("", 1)
text = f"{titel} ({seen[key]})" + (f"{rest[1]}" if len(rest) == 2 else "")
# zweiter Durchlauf nicht nötig: Suffixe kollidieren praktisch nicht
out[num] = text
return out
def _titel_index(entries: dict[int, str]) -> dict[str, int]:
return {_norm_titel(_titel(text)): num for num, text in entries.items()}
def _json_datei(path: Path):
"""Liest eine JSON-Datei (Code-Fences tolerant); None bei fehlend/ungültig."""
if not path.exists():
return None
try:
text = path.read_text(encoding="utf-8").strip()
text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text)
return json.loads(text)
except Exception:
return None
def _resolve_kategorien(data, entries: dict[int, str], min_match: float = 0.85):
"""{"KERN": [Titel], …} → {num: Kategorie}; None bei zu vielen unbekannten Titeln
oder zu geringer Abdeckung der Einträge."""
if not isinstance(data, dict):
return None
idx = _titel_index(entries)
mapping: dict[int, str] = {}
total = unknown = 0
for cat in _CATEGORIES:
items = data.get(cat, [])
if not isinstance(items, list):
return None
for t in items:
if not isinstance(t, str):
return None
total += 1
num = _titel_aufloesen(idx, t)
if num is None:
unknown += 1
elif num not in mapping:
mapping[num] = cat
if total == 0:
return None
if (total - unknown) / total < min_match or len(mapping) / len(entries) < min_match:
return None
return mapping
def _resolve_reihenfolge(data, entries: dict[int, str], min_match: float = 0.85):
"""Wie _resolve_kategorien, aber liefert die Reihenfolge: {Kategorie: [nums]}."""
mapping = _resolve_kategorien(data, entries, min_match)
if mapping is None:
return None
idx = _titel_index(entries)
order: dict[str, list[int]] = {c: [] for c in _CATEGORIES}
for cat in _CATEGORIES:
for t in data.get(cat, []):
num = _titel_aufloesen(idx, t) if isinstance(t, str) else None
if num is not None and num not in order[cat]:
order[cat].append(num)
return order
def _kategorien_block(mapping: dict[int, str], entries: dict[int, str]) -> str:
parts = []
for cat in _CATEGORIES:
titel = [_titel(entries[n]) for n in sorted(entries) if mapping.get(n) == cat]
parts.append(f"{cat}:\n" + ("\n".join(f"- {t}" for t in titel) if titel else "(leer)"))
return "\n".join(parts)
def _timeout(step: str, n: int = 0) -> int: def _timeout(step: str, n: int = 0) -> int:
base, per = TIMEOUTS[step] base, per = TIMEOUTS[step]
return base + per * n return base + per * n
@@ -146,7 +239,7 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
await asyncio.gather(*tasks.keys(), return_exceptions=True) await asyncio.gather(*tasks.keys(), return_exceptions=True)
# --- Bausteine-Pipeline: 4x Recherche (3 nötig) → 2x Auswahl (1) → 4x Einordnung (3) → 2x Final (1) --- # --- Bausteine-Pipeline: 4x Recherche (3) → 2x Auswahl (1) → Check → 4x Einordnung (3) → Mehrheit+Verifikation → Sortierung ---
_bausteine_progress: dict[str, str] = {} _bausteine_progress: dict[str, str] = {}
_bausteine_errors: dict[str, str] = {} _bausteine_errors: dict[str, str] = {}
@@ -154,6 +247,28 @@ _bausteine_cancelled: set[str] = set()
_bausteine_step: dict[str, int] = {} _bausteine_step: dict[str, int] = {}
BAUSTEINE_STEPS = ("Recherche", "Auswahl", "Prüfung", "Einordnung", "Verifikation", "Sortierung") BAUSTEINE_STEPS = ("Recherche", "Auswahl", "Prüfung", "Einordnung", "Verifikation", "Sortierung")
_CATEGORIES = ("KERN", "WICHTIG", "REST")
def _bausteine_files(topic: str) -> dict:
final_path = bausteine_path(topic)
stem, parent = final_path.stem, final_path.parent
return {
"final": final_path,
"recherche": [parent / f"{stem}.recherche-{i}.md" for i in (1, 2, 3, 4)],
"auswahl": [parent / f"{stem}.auswahl-{i}.md" for i in (1, 2)],
"auswahl_check": parent / f"{stem}.auswahl-check.json",
"einordnung": [parent / f"{stem}.einordnung-{i}.json" for i in (1, 2, 3, 4)],
"final_check": parent / f"{stem}.final-check.json",
"sortierung": parent / f"{stem}.sortierung.json",
}
def _alle_slot_dateien(files: dict) -> list[Path]:
return [
*files["recherche"], *files["auswahl"], files["auswahl_check"],
*files["einordnung"], files["final_check"], files["sortierung"],
]
def cancel_bausteine(topic: str) -> bool: def cancel_bausteine(topic: str) -> bool:
@@ -163,31 +278,23 @@ def cancel_bausteine(topic: str) -> bool:
kill_process(f"bausteine-{topic}-") kill_process(f"bausteine-{topic}-")
return True return True
_CATEGORIES = ("KERN", "WICHTIG", "REST")
def _resume_step(topic: str) -> int: def _resume_step(topic: str) -> int:
"""Erster noch offener Schritt anhand der persistierten Zwischendateien.""" """Erster noch offener Schritt anhand der persistierten Zwischendateien."""
final_path = bausteine_path(topic) files = _bausteine_files(topic)
stem, parent = final_path.stem, final_path.parent if sum(p.exists() for p in files["recherche"]) < 3:
if sum((parent / f"{stem}.recherche-{i}.md").exists() for i in (1, 2, 3, 4)) < 3:
return 0 return 0
if not any((parent / f"{stem}.auswahl-{i}.md").exists() for i in (1, 2)): if not any(p.exists() for p in files["auswahl"]):
return 1 return 1
if not (parent / f"{stem}.auswahl-check.md").exists(): if not files["auswahl_check"].exists():
return 2 return 2
if sum((parent / f"{stem}.einordnung-{i}.md").exists() for i in (1, 2, 3)) < 3: if sum(p.exists() for p in files["einordnung"]) < 3:
return 3 return 3
if not (parent / f"{stem}.final-check.md").exists(): if not files["final_check"].exists():
return 4 return 4
return 5 return 5
def _sortierung_path(topic: str):
final_path = bausteine_path(topic)
return final_path.parent / f"{final_path.stem}.sortierung.md"
def bausteine_status(topic: str) -> dict: def bausteine_status(topic: str) -> dict:
ready = bausteine_path(topic).exists() ready = bausteine_path(topic).exists()
generating = topic in _bausteine_progress generating = topic in _bausteine_progress
@@ -200,7 +307,7 @@ def bausteine_status(topic: str) -> dict:
] ]
elif ready: elif ready:
states = ["done"] * len(BAUSTEINE_STEPS) states = ["done"] * len(BAUSTEINE_STEPS)
if not _sortierung_path(topic).exists(): if not _bausteine_files(topic)["sortierung"].exists():
states[-1] = "pending" states[-1] = "pending"
else: else:
nxt = _resume_step(topic) nxt = _resume_step(topic)
@@ -221,16 +328,14 @@ def active_bausteine() -> list[dict]:
def reset_bausteine(topic: str) -> None: def reset_bausteine(topic: str) -> None:
final_path = bausteine_path(topic) files = _bausteine_files(topic)
final_path.unlink(missing_ok=True) files["final"].unlink(missing_ok=True)
for i in (1, 2, 3, 4): for p in _alle_slot_dateien(files):
(final_path.parent / f"{final_path.stem}.recherche-{i}.md").unlink(missing_ok=True) p.unlink(missing_ok=True)
(final_path.parent / f"{final_path.stem}.einordnung-{i}.md").unlink(missing_ok=True) # Altlasten früherer Formatversionen
for i in (1, 2): stem, parent = files["final"].stem, files["final"].parent
(final_path.parent / f"{final_path.stem}.auswahl-{i}.md").unlink(missing_ok=True) for alt in parent.glob(f"{stem}.*.md"):
(final_path.parent / f"{final_path.stem}.auswahl-check.md").unlink(missing_ok=True) alt.unlink(missing_ok=True)
(final_path.parent / f"{final_path.stem}.final-check.md").unlink(missing_ok=True)
(final_path.parent / f"{final_path.stem}.sortierung.md").unlink(missing_ok=True)
_bausteine_errors.pop(topic, None) _bausteine_errors.pop(topic, None)
@@ -259,25 +364,21 @@ def _parse_auswahl(text: str) -> dict[int, str]:
return entries return entries
def _parse_einordnung(text: str) -> dict[int, str]: def _majority(mappings: list[dict[int, str]], entries: dict[int, str]) -> tuple[dict[int, str], list[int]]:
"""Parst eine Einordnung (`KERN:` gefolgt von `N Titel`-Zeilen) zu Nummer→Kategorie.""" """Mehrheitsentscheid über die Einordnungen; ohne Mehrheit → Streitfall."""
mapping: dict[int, str] = {} mapping: dict[int, str] = {}
current = None disputes: list[int] = []
for line in text.splitlines(): for num in entries:
s = line.strip().lstrip("-*# ").strip() votes = [m[num] for m in mappings if num in m]
if not s: if not votes:
disputes.append(num)
continue continue
m = re.match(r"(KERN|WICHTIG|REST)\b[:\s]*(.*)$", s, re.IGNORECASE) cat, count = Counter(votes).most_common(1)[0]
if m: if count >= 2:
current = m.group(1).upper() mapping[num] = cat
for num in re.findall(r"\b\d+\b", m.group(2)): else:
mapping.setdefault(int(num), current) disputes.append(num)
continue return mapping, disputes
if current:
m = re.match(r"(\d+)\b", s)
if m:
mapping.setdefault(int(m.group(1)), current)
return mapping
def _build_final_bausteine(topic: str, entries: dict[int, str], mapping: dict[int, str], order: dict[str, list[int]] | None = None) -> str: def _build_final_bausteine(topic: str, entries: dict[int, str], mapping: dict[int, str], order: dict[str, list[int]] | None = None) -> str:
@@ -293,9 +394,6 @@ def _build_final_bausteine(topic: str, entries: dict[int, str], mapping: dict[in
_log(topic, f"Baustein {num} fehlt in finaler Einordnung → REST") _log(topic, f"Baustein {num} fehlt in finaler Einordnung → REST")
cat = "REST" cat = "REST"
grouped[cat].append(num) grouped[cat].append(num)
unknown = sorted(set(mapping) - set(entries))
if unknown:
_log(topic, f"finale Einordnung enthält unbekannte Nummern (ignoriert): {unknown}")
if order: if order:
for cat in _CATEGORIES: for cat in _CATEGORIES:
wanted = set(grouped[cat]) wanted = set(grouped[cat])
@@ -324,79 +422,41 @@ def _auswahl_payload(path: Path):
return (text, entries) if entries else None return (text, entries) if entries else None
def _parse_auswahl_check(text: str): def _auswahl_check_schema(data):
"""Parst die Auswahl-Prüfung: NACHTRÄGE (neue Einträge) + STREICHEN (Nummern).""" """{"nachtraege": [...], "streichen": [...]} — None bei Schema-Verstoß."""
additions: list[str] = [] if not isinstance(data, dict):
removals: set[int] = set() return None
mode = None nach = data.get("nachtraege", [])
seen_marker = False streich = data.get("streichen", [])
for line in text.splitlines(): if not isinstance(nach, list) or not isinstance(streich, list):
s = line.strip().lstrip("-*# ").strip() return None
if not s: if not all(isinstance(x, str) for x in [*nach, *streich]):
continue return None
u = s.upper().rstrip(":") return {"nachtraege": nach, "streichen": streich}
if u.startswith("NACHTR"):
mode = "add"
seen_marker = True
continue
if u.startswith("STREICH"):
mode = "del"
seen_marker = True
continue
if u == "OK":
seen_marker = True
continue
if mode == "add":
additions.append(s)
elif mode == "del":
m = re.match(r"(\d+)\b", s)
if m:
removals.add(int(m.group(1)))
if not seen_marker:
return None # Antwort hat das Format nicht getroffen
return {"add": additions, "remove": removals}
def _majority(mappings: list[dict[int, str]], entries: dict[int, str]) -> tuple[dict[int, str], list[int]]: def _titel_aufloesen(idx: dict[str, int], t: str) -> int | None:
"""Mehrheitsentscheid über die Einordnungen; ohne Mehrheit → Streitfall.""" """Titel → Nummer; toleriert mitgeschleppte Beschreibungen ("Titel — …")."""
mapping: dict[int, str] = {} if not isinstance(t, str):
disputes: list[int] = [] return None
for num in entries: return idx.get(_norm_titel(t)) or idx.get(_norm_titel(_titel(t)))
votes = [m[num] for m in mappings if num in m]
if not votes:
disputes.append(num)
continue
cat, count = Counter(votes).most_common(1)[0]
if count >= 2:
mapping[num] = cat
else:
disputes.append(num)
return mapping, disputes
def _einordnung_block(mapping: dict[int, str], entries: dict[int, str]) -> str:
parts = []
for cat in _CATEGORIES:
nums = [n for n in sorted(entries) if mapping.get(n) == cat]
lines = "\n".join(f"{n} {_titel(entries[n])}" for n in nums)
parts.append(f"{cat}:\n{lines}" if lines else f"{cat}:")
return "\n".join(parts)
async def _run_sortierung(topic: str, entries: dict[int, str], mapping: dict[int, str], provider: str, cancelled) -> dict[str, list[int]] | None: async def _run_sortierung(topic: str, entries: dict[int, str], mapping: dict[int, str], provider: str, cancelled) -> dict[str, list[int]] | None:
"""Sortiert innerhalb der Kategorien; schreibt bei Erfolg den Marker und liefert die Reihenfolge.""" """Sortiert innerhalb der Kategorien; die JSON-Datei des Agenten ist zugleich der Marker."""
out = _bausteine_files(topic)["sortierung"]
out.unlink(missing_ok=True)
slots = [{ slots = [{
"key": f"bausteine-{topic}-sortierung-1", "key": f"bausteine-{topic}-sortierung-1",
"prompt": _prompt("Bausteine-Sortierung", topic=topic, einordnung=_einordnung_block(mapping, entries)), "prompt": _prompt("Bausteine-Sortierung", topic=topic, einordnung=_kategorien_block(mapping, entries), out_path=out),
"role": "quick", "capabilities": "none", "role": "quick", "capabilities": "files",
"payload": (lambda result: (result[1].strip(), _parse_einordnung(result[1])) if _parse_einordnung(result[1]) else None), "payload": (lambda result: _resolve_reihenfolge(_json_datei(out), entries)),
}] }]
res = await _race(topic, "Sortierung", slots, 1, _timeout("sortierung", len(entries)), provider, cancelled=cancelled) res = await _race(topic, "Sortierung", slots, 1, _timeout("sortierung", len(entries)), provider, cancelled=cancelled)
if res is None: if res is None:
out.unlink(missing_ok=True)
return None return None
raw, sort_mapping = res[0] return res[0]
_sortierung_path(topic).write_text(raw, encoding="utf-8")
return {cat: [num for num, c in sort_mapping.items() if c == cat] for cat in _CATEGORIES}
async def generate_bausteine(topic: str, instructions: str = "", provider: str = DEFAULT_PROVIDER) -> None: async def generate_bausteine(topic: str, instructions: str = "", provider: str = DEFAULT_PROVIDER) -> None:
@@ -405,16 +465,9 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
_bausteine_progress[topic] = "Wartend…" _bausteine_progress[topic] = "Wartend…"
_bausteine_errors.pop(topic, None) _bausteine_errors.pop(topic, None)
final_path = bausteine_path(topic) files = _bausteine_files(topic)
final_path = files["final"]
project = project_dir(topic) if project_dir(topic).is_dir() else None project = project_dir(topic) if project_dir(topic).is_dir() else None
stem = final_path.stem
recherche_paths = [final_path.parent / f"{stem}.recherche-{i}.md" for i in (1, 2, 3, 4)]
auswahl_paths = [final_path.parent / f"{stem}.auswahl-{i}.md" for i in (1, 2)]
einordnung_paths = [final_path.parent / f"{stem}.einordnung-{i}.md" for i in (1, 2, 3)]
auswahl_check_path = final_path.parent / f"{stem}.auswahl-check.md"
final_check_path = final_path.parent / f"{stem}.final-check.md"
sortierung_path = _sortierung_path(topic)
slot_files = [*recherche_paths, *auswahl_paths, *einordnung_paths, auswahl_check_path, final_check_path, sortierung_path]
def set_p(msg: str, step: int | None = None) -> None: def set_p(msg: str, step: int | None = None) -> None:
_bausteine_progress[topic] = msg _bausteine_progress[topic] = msg
@@ -429,16 +482,18 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
try: try:
async with _semaphore: async with _semaphore:
# Fertig, aber ohne Sortier-Marker (ältere Pipeline-Version): nur die Sortierung nachholen. # Fertig, aber ohne Sortier-Marker (ältere Version): nur die Sortierung nachholen.
if final_path.exists() and not sortierung_path.exists(): if final_path.exists() and not files["sortierung"].exists():
cats = _parse_kategorien(final_path.read_text(encoding="utf-8")) cats = _parse_kategorien(final_path.read_text(encoding="utf-8"))
entries, mapping = {}, {} entries: dict[int, str] = {}
mapping: dict[int, str] = {}
i = 0 i = 0
for cat in _CATEGORIES: for cat in _CATEGORIES:
for text in cats.get(cat, []): for text in cats.get(cat, []):
i += 1 i += 1
entries[i] = text entries[i] = text
mapping[i] = cat mapping[i] = cat
entries = _eindeutige_titel(entries)
if entries: if entries:
set_p("Sortiere Bausteine…", step=5) set_p("Sortiere Bausteine…", step=5)
order = await _run_sortierung(topic, entries, mapping, provider, is_cancelled) order = await _run_sortierung(topic, entries, mapping, provider, is_cancelled)
@@ -452,15 +507,15 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
return return
# „Neu erstellen": fertige (sortierte) Bausteine → kompletter Frischstart. # „Neu erstellen": fertige (sortierte) Bausteine → kompletter Frischstart.
# Sonst sind Slot-Dateien Reste eines Abbruchs/Fehlers → Resume, fertige Schritte überspringen. # Sonst sind Slot-Dateien Reste eines Abbruchs/Fehlers → Resume.
if final_path.exists(): if final_path.exists():
for p_alt in slot_files: for p_alt in _alle_slot_dateien(files):
p_alt.unlink(missing_ok=True) p_alt.unlink(missing_ok=True)
# Schritt 1: 4 Recherche-Agenten, 3 gültige nötig — vorhandene Slot-Dateien zählen # Schritt 1: 4 Recherche-Agenten, 3 gültige nötig — vorhandene Slot-Dateien zählen
recherchen = [] recherchen: list[str] = []
offen = [] offen = []
for i, path in enumerate(recherche_paths, 1): for i, path in enumerate(files["recherche"], 1):
text = _file_payload(path) text = _file_payload(path)
if text is not None and len(recherchen) < 3: if text is not None and len(recherchen) < 3:
recherchen.append(text) recherchen.append(text)
@@ -494,12 +549,12 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
# Schritt 2: 2 Auswahl-Agenten, der erste gewinnt — vorhandene gültige Datei wird übernommen # Schritt 2: 2 Auswahl-Agenten, der erste gewinnt — vorhandene gültige Datei wird übernommen
n_est = max(len(_parse_auswahl(t)) for t in recherchen) n_est = max(len(_parse_auswahl(t)) for t in recherchen)
results_block = "\n\n".join(f"### Recherche {i}\n\n{text}" for i, text in enumerate(recherchen, 1)) bestehende = next((res for p in files["auswahl"] if (res := _auswahl_payload(p)) is not None), None)
bestehende = next((res for p in auswahl_paths if (res := _auswahl_payload(p)) is not None), None)
if bestehende is not None: if bestehende is not None:
flat, entries = bestehende flat, entries = bestehende
else: else:
set_p("Konsolidiere Recherche…", step=1) set_p("Konsolidiere Recherche…", step=1)
results_block = "\n\n".join(f"### Recherche {i}\n\n{text}" for i, text in enumerate(recherchen, 1))
slots = [ slots = [
{ {
"key": f"bausteine-{topic}-auswahl-{i}", "key": f"bausteine-{topic}-auswahl-{i}",
@@ -507,7 +562,7 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
"role": "fast", "capabilities": "files", "role": "fast", "capabilities": "files",
"payload": (lambda result, p=path: _auswahl_payload(p)), "payload": (lambda result, p=path: _auswahl_payload(p)),
} }
for i, path in enumerate(auswahl_paths, 1) for i, path in enumerate(files["auswahl"], 1)
] ]
auswahl = await _race(topic, "Auswahl", slots, 1, _timeout("auswahl", n_est), provider, cancelled=is_cancelled) auswahl = await _race(topic, "Auswahl", slots, 1, _timeout("auswahl", n_est), provider, cancelled=is_cancelled)
if is_cancelled(): if is_cancelled():
@@ -518,16 +573,21 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
return return
flat, entries = auswahl[0] flat, entries = auswahl[0]
# Schritt 2b: Auswahl-Prüfung (nicht fatal) — gespeicherte Antwort wird erneut angewendet # Schritt 2b: Auswahl-Prüfung gegen die Recherche-Titel (JSON, nicht fatal)
set_p("Prüfe Auswahl…", step=2) set_p("Prüfe Auswahl…", step=2)
raw_check = auswahl_check_path.read_text(encoding="utf-8") if auswahl_check_path.exists() else None check_path = files["auswahl_check"]
patch = _parse_auswahl_check(raw_check) if raw_check is not None else None patch = _auswahl_check_schema(_json_datei(check_path))
if patch is None: if patch is None:
check_path.unlink(missing_ok=True)
titel_listen = "\n\n".join(
f"### Recherche {i}\n" + "\n".join(f"- {_titel(t)}" for t in _parse_auswahl(text).values())
for i, text in enumerate(recherchen, 1)
)
slots = [{ slots = [{
"key": f"bausteine-{topic}-auswahlcheck-1", "key": f"bausteine-{topic}-auswahlcheck-1",
"prompt": _prompt("Bausteine-Auswahl-Check", topic=topic, results=results_block, auswahl=flat), "prompt": _prompt("Bausteine-Auswahl-Check", topic=topic, results=titel_listen, auswahl=flat, out_path=check_path),
"role": "fast", "capabilities": "none", "role": "fast", "capabilities": "files",
"payload": (lambda result: (result[1].strip(), _parse_auswahl_check(result[1])) if _parse_auswahl_check(result[1]) is not None else None), "payload": (lambda result: _auswahl_check_schema(_json_datei(check_path))),
}] }]
checks = await _race(topic, "Auswahl-Check", slots, 1, _timeout("auswahl_check", len(entries)), provider, cancelled=is_cancelled) checks = await _race(topic, "Auswahl-Check", slots, 1, _timeout("auswahl_check", len(entries)), provider, cancelled=is_cancelled)
if is_cancelled(): if is_cancelled():
@@ -536,40 +596,44 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
if checks is None: if checks is None:
_log(topic, "Auswahl-Check fehlgeschlagen — fahre ohne Korrekturen fort") _log(topic, "Auswahl-Check fehlgeschlagen — fahre ohne Korrekturen fort")
else: else:
raw_check, patch = checks[0] patch = checks[0]
auswahl_check_path.write_text(raw_check, encoding="utf-8") if patch is not None and (patch["streichen"] or patch["nachtraege"]):
if patch is not None: idx = _titel_index(entries)
if patch["remove"]: weg = {num for t in patch["streichen"] if (num := _titel_aufloesen(idx, t)) is not None}
_log(topic, f"Auswahl-Check streicht Duplikate: {sorted(patch['remove'])}") if weg:
entries = {n: t for n, t in entries.items() if n not in patch["remove"]} _log(topic, f"Auswahl-Check streicht Duplikate: {sorted(weg)}")
if patch["add"]: entries = {n: t for n, t in entries.items() if n not in weg}
_log(topic, f"Auswahl-Check ergänzt {len(patch['add'])} Bausteine") if patch["nachtraege"]:
if patch["remove"] or patch["add"]: _log(topic, f"Auswahl-Check ergänzt {len(patch['nachtraege'])} Bausteine")
texts = [t for _, t in sorted(entries.items())] + patch["add"] texts = [t for _, t in sorted(entries.items())] + list(patch["nachtraege"])
entries = {i: t for i, t in enumerate(texts, 1)} entries = {i: t for i, t in enumerate(texts, 1)}
flat = "\n".join(f"{i}. {t}" for i, t in entries.items())
# Schritt 3: 4 Einordnungs-Agenten, 3 gültige nötig — gespeicherte Stimmen einlesen # Ab hier ist der Titel der Schlüssel — eindeutig machen
entries = _eindeutige_titel(entries)
bausteine_liste = "\n".join(f"- {t}" for t in entries.values())
# Schritt 3: 4 Einordnungs-Agenten, 3 gültige nötig (JSON-Dateien, Titel-validiert)
n = len(entries) n = len(entries)
einordnungen = [] einordnungen: list[dict[int, str]] = []
for path in einordnung_paths: offen = []
if path.exists(): for i, path in enumerate(files["einordnung"], 1):
text = path.read_text(encoding="utf-8") m = _resolve_kategorien(_json_datei(path), entries)
parsed = _parse_einordnung(text) if m is not None and len(einordnungen) < 3:
if parsed: einordnungen.append(m)
einordnungen.append((text, parsed)) else:
einordnungen = einordnungen[:3] path.unlink(missing_ok=True)
offen.append((i, path))
vorhanden = len(einordnungen) vorhanden = len(einordnungen)
set_p(f"Einordnung läuft ({vorhanden}/3 gültig)…", step=3) set_p(f"Einordnung läuft ({vorhanden}/3 gültig)…", step=3)
if vorhanden < 3: if vorhanden < 3:
slots = [ slots = [
{ {
"key": f"bausteine-{topic}-einordnung-{i}", "key": f"bausteine-{topic}-einordnung-{i}",
"prompt": _prompt("Bausteine-Einordnung", topic=topic, bausteine=flat), "prompt": _prompt("Bausteine-Einordnung", topic=topic, bausteine=bausteine_liste, out_path=path),
"role": "quick", "capabilities": "none", "role": "quick", "capabilities": "files",
"payload": (lambda result: (result[1].strip(), _parse_einordnung(result[1])) if _parse_einordnung(result[1]) else None), "payload": (lambda result, p=path: _resolve_kategorien(_json_datei(p), entries)),
} }
for i in range(vorhanden + 1, 5) for i, path in offen
] ]
neue = await _race( neue = await _race(
topic, "Einordnung", slots, 3 - vorhanden, _timeout("einordnung", n), provider, topic, "Einordnung", slots, 3 - vorhanden, _timeout("einordnung", n), provider,
@@ -582,54 +646,59 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
if neue is None: if neue is None:
_bausteine_errors[topic] = "Einordnung fehlgeschlagen (Quorum nicht erreicht)" _bausteine_errors[topic] = "Einordnung fehlgeschlagen (Quorum nicht erreicht)"
return return
for path, (text, _) in zip(einordnung_paths[vorhanden:], neue):
path.write_text(text, encoding="utf-8")
einordnungen += neue einordnungen += neue
# Schritt 4: Python-Mehrheitsentscheid + Verifikations-Agent — gespeicherte Antwort wird erneut angewendet # Schritt 4: Python-Mehrheitsentscheid + Verifikations-Agent (antwortet nur mit Deltas, JSON)
set_p("Verifiziere Einordnung…", step=4) set_p("Verifiziere Einordnung…", step=4)
mapping, disputes = _majority([m for _, m in einordnungen], entries) mapping, disputes = _majority(einordnungen, entries)
if disputes: if disputes:
_log(topic, f"Keine Mehrheit bei: {disputes}") _log(topic, f"Keine Mehrheit bei: {disputes}")
raw_final = final_check_path.read_text(encoding="utf-8") if final_check_path.exists() else None
if raw_final is not None and not (_parse_einordnung(raw_final) or "OK" in raw_final.upper()): def _final_schema(data):
raw_final = None if not isinstance(data, dict):
if raw_final is None: return None
streit_block = "\n".join(f"{num} {entries[num]}" for num in disputes) or "(keine)" idx = _titel_index(entries)
final_prompt = _prompt( out: dict[int, str] = {}
"Bausteine-Einordnung-Final", for t, cat in data.items():
topic=topic, if not isinstance(t, str) or cat not in _CATEGORIES:
einordnung=_einordnung_block(mapping, entries), return None
streitfaelle=streit_block, num = _titel_aufloesen(idx, t)
) if num is not None:
slots = [ out[num] = cat
{ return out # leeres Dict = alles bestätigt
"key": f"bausteine-{topic}-final-{i}",
"prompt": final_prompt, fc_path = files["final_check"]
"role": "fast", "capabilities": "none", overrides = _final_schema(_json_datei(fc_path))
"payload": (lambda result: result[1].strip() if (_parse_einordnung(result[1]) or "OK" in result[1].upper()) else None), if overrides is None:
} fc_path.unlink(missing_ok=True)
for i in (1, 2) streit_block = "\n".join(f"- {entries[n]}" for n in disputes) or "(keine)"
] slots = [{
"key": f"bausteine-{topic}-final-1",
"prompt": _prompt(
"Bausteine-Einordnung-Final",
topic=topic, einordnung=_kategorien_block(mapping, entries),
streitfaelle=streit_block, out_path=fc_path,
),
"role": "fast", "capabilities": "files",
"payload": (lambda result: _final_schema(_json_datei(fc_path))),
}]
finals = await _race(topic, "Final", slots, 1, _timeout("final", n), provider, cancelled=is_cancelled) finals = await _race(topic, "Final", slots, 1, _timeout("final", n), provider, cancelled=is_cancelled)
if is_cancelled(): if is_cancelled():
abgebrochen() abgebrochen()
return return
if finals is None: if finals is None:
_log(topic, "Final-Verifikation fehlgeschlagen — Mehrheitsentscheid bleibt unverändert") _log(topic, "Final-Verifikation fehlgeschlagen — Mehrheitsentscheid bleibt unverändert")
overrides = {}
else: else:
raw_final = finals[0] overrides = finals[0]
final_check_path.write_text(raw_final, encoding="utf-8") korrekturen = {num: cat for num, cat in overrides.items() if mapping.get(num) != cat and num not in disputes}
if raw_final is not None: if korrekturen:
overrides = {num: cat for num, cat in _parse_einordnung(raw_final).items() if num in entries} _log(topic, f"Final-Verifikation korrigiert: { {_titel(entries[n]): c for n, c in korrekturen.items()} }")
korrekturen = {num: cat for num, cat in overrides.items() if mapping.get(num) != cat and num not in disputes} mapping.update(overrides)
if korrekturen:
_log(topic, f"Final-Verifikation korrigiert: {korrekturen}")
mapping.update(overrides)
for num in disputes: for num in disputes:
if num not in mapping: if num not in mapping:
_log(topic, f"Streitfall {num} unentschieden → WICHTIG") _log(topic, f"Streitfall '{_titel(entries[num])}' unentschieden → REST")
mapping[num] = "WICHTIG" mapping[num] = "REST"
# Schritt 5: Sortierung innerhalb der Kategorien (einfach → komplex, nicht fatal) # Schritt 5: Sortierung innerhalb der Kategorien (einfach → komplex, nicht fatal)
set_p("Sortiere Bausteine…", step=5) set_p("Sortiere Bausteine…", step=5)
@@ -644,7 +713,6 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
_bausteine_errors[topic] = str(e)[:2000] _bausteine_errors[topic] = str(e)[:2000]
finally: finally:
# Kein Datei-Cleanup: Zwischendateien bleiben für Resume bzw. Nachvollziehbarkeit. # Kein Datei-Cleanup: Zwischendateien bleiben für Resume bzw. Nachvollziehbarkeit.
# Aufräumen passiert nur explizit über reset_bausteine().
_bausteine_progress.pop(topic, None) _bausteine_progress.pop(topic, None)
_bausteine_step.pop(topic, None) _bausteine_step.pop(topic, None)
_bausteine_cancelled.discard(topic) _bausteine_cancelled.discard(topic)
@@ -680,33 +748,36 @@ def _parse_kategorien(text: str) -> dict[str, list[str]]:
return cats return cats
def _titel(entry: str) -> str: def _resolve_gliederung(data, entries: dict[int, str]) -> list[dict] | None:
return entry.split("")[0].strip() or entry """{"kapitel": [{"titel", "bausteine": [Titel]}]} → [{"title", "nums"}]; None bei Schema-/Titel-Fehlern."""
if not isinstance(data, dict) or not isinstance(data.get("kapitel"), list):
return None
def _parse_gliederung(text: str, valid: set[int], topic: str) -> list[dict]: idx = _titel_index(entries)
"""Parst die Gliederung (`KAPITEL: Titel` + `N Titel`-Zeilen) → [{"title", "nums"}]."""
chapters: list[dict] = [] chapters: list[dict] = []
seen: set[int] = set() seen: set[int] = set()
for line in text.splitlines(): total = unknown = 0
s = line.strip().lstrip("-*# ").strip() for ch in data["kapitel"]:
if not s: if not isinstance(ch, dict) or not isinstance(ch.get("bausteine"), list):
continue return None
m = re.match(r"KAPITEL\s*:\s*(.+)", s, re.IGNORECASE) nums = []
if m: for t in ch["bausteine"]:
chapters.append({"title": m.group(1).strip(), "nums": []}) total += 1
continue num = _titel_aufloesen(idx, t) if isinstance(t, str) else None
m = re.match(r"(\d+)\b", s) if num is None:
if m and chapters: unknown += 1
num = int(m.group(1)) elif num not in seen:
if num in valid and num not in seen: nums.append(num)
chapters[-1]["nums"].append(num)
seen.add(num) seen.add(num)
missing = sorted(valid - seen) 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 or len(seen) / len(entries) < 0.85:
return None
missing = sorted(set(entries) - seen)
if missing: if missing:
_log(topic, f"Gliederung: Bausteine {missing} fehlen → Kapitel 'Weitere Themen'")
chapters.append({"title": "Weitere Themen", "nums": missing}) chapters.append({"title": "Weitere Themen", "nums": missing})
return [c for c in chapters if c["nums"]] return chapters
def _split_chunks(chapters: list[dict], n: int) -> list[list[dict]]: def _split_chunks(chapters: list[dict], n: int) -> list[list[dict]]:
@@ -735,16 +806,16 @@ def _zuteilung_text(chunk: list[dict], entries: dict[int, str]) -> str:
lines = [] lines = []
for ch in chunk: for ch in chunk:
lines.append(f"KAPITEL: {ch['title']}") lines.append(f"KAPITEL: {ch['title']}")
lines.extend(f"{num} {entries[num]}" for num in ch["nums"]) lines.extend(f"- {entries[num]}" for num in ch["nums"])
return "\n".join(lines) return "\n".join(lines)
_FRAGMENT_KAPITEL_RE = re.compile(r"<!--\s*kapitel\s*:\s*(.*?)\s*-->", re.IGNORECASE) _FRAGMENT_KAPITEL_RE = re.compile(r"<!--\s*kapitel\s*:\s*(.*?)\s*-->", re.IGNORECASE)
_FRAGMENT_SECTION_RE = re.compile(r"<!--\s*section\s*:\s*(\d+)\s*(?:\|\s*(.*?))?\s*-->", re.IGNORECASE) _FRAGMENT_SECTION_RE = re.compile(r"<!--\s*section\s*:\s*(.*?)\s*-->", re.IGNORECASE)
def _parse_fragment(text: str) -> list[dict]: def _parse_fragment(text: str) -> list[dict]:
"""Parst eine Writer-Datei → [{"kapitel", "num", "title", "md"}] in Datei-Reihenfolge.""" """Parst eine Writer-Datei → [{"kapitel", "titel", "md"}] in Datei-Reihenfolge."""
sections: list[dict] = [] sections: list[dict] = []
kapitel = None kapitel = None
current = None current = None
@@ -757,7 +828,7 @@ def _parse_fragment(text: str) -> list[dict]:
continue continue
m = _FRAGMENT_SECTION_RE.match(s) m = _FRAGMENT_SECTION_RE.match(s)
if m: if m:
current = {"kapitel": kapitel, "num": int(m.group(1)), "title": (m.group(2) or "").strip(), "md": []} current = {"kapitel": kapitel, "titel": m.group(1), "md": []}
sections.append(current) sections.append(current)
continue continue
if current is not None: if current is not None:
@@ -767,10 +838,6 @@ def _parse_fragment(text: str) -> list[dict]:
return sections return sections
def _section_json(sec: dict, entries: dict[int, str]) -> dict:
return {"num": sec["num"], "title": sec["title"] or _titel(entries[sec["num"]]), "md": sec["md"]}
async def _generate_onepager( async def _generate_onepager(
guide_id: str, topic: str, instructions: str, provider: str, guide_id: str, topic: str, instructions: str, provider: str,
project: Path | None, content_path: Path, fragment_paths: list[Path], project: Path | None, content_path: Path, fragment_paths: list[Path],
@@ -778,6 +845,21 @@ async def _generate_onepager(
def is_cancelled() -> bool: def is_cancelled() -> bool:
return guide_id in _cancelled return guide_id in _cancelled
def karten_schema(data):
if not isinstance(data, dict):
return None
if data.get("ok") is True:
return "ok"
karten = data.get("karten")
if not isinstance(karten, list) or not karten:
return None
out = []
for k in karten:
if not isinstance(k, dict) or not isinstance(k.get("titel"), str) or not isinstance(k.get("merksatz"), str):
return None
out.append({"titel": k["titel"].strip(), "merksatz": k["merksatz"].strip()})
return out
# Schritt 1: Recherche — eigene Faktenbasis, unabhängig von den Bausteinen # Schritt 1: Recherche — eigene Faktenbasis, unabhängig von den Bausteinen
await _set_progress(guide_id, "Recherchiere…") await _set_progress(guide_id, "Recherchiere…")
recherche_path = content_path.parent / f"{content_path.stem}.recherche.md" recherche_path = content_path.parent / f"{content_path.stem}.recherche.md"
@@ -801,13 +883,16 @@ async def _generate_onepager(
return None return None
recherche = res[0] recherche = res[0]
# Schritt 2: Bauen — Karten nur aus der Faktenbasis # Schritt 2: Bauen — Karten nur aus der Faktenbasis (JSON)
await _set_progress(guide_id, "Baue OnePager…") await _set_progress(guide_id, "Baue OnePager…")
karten_path = content_path.parent / f"{content_path.stem}.karten.json"
fragment_paths.append(karten_path)
karten_path.unlink(missing_ok=True)
slots = [{ slots = [{
"key": f"{guide_id}-bauen", "key": f"{guide_id}-bauen",
"prompt": _prompt("OnePager-Bauen", topic=topic, recherche=recherche, extra=_extra(instructions)), "prompt": _prompt("OnePager-Bauen", topic=topic, recherche=recherche, out_path=karten_path, extra=_extra(instructions)),
"role": "fast", "capabilities": "none", "role": "fast", "capabilities": "files",
"payload": (lambda result: _parse_auswahl(result[1]) or None), "payload": (lambda result: (k if isinstance(k := karten_schema(_json_datei(karten_path)), list) else None)),
}] }]
res = await _race(topic, "OnePager-Bauen", slots, 1, _timeout("onepager_bauen"), provider, cancelled=is_cancelled) res = await _race(topic, "OnePager-Bauen", slots, 1, _timeout("onepager_bauen"), provider, cancelled=is_cancelled)
if is_cancelled(): if is_cancelled():
@@ -815,31 +900,32 @@ async def _generate_onepager(
if res is None: if res is None:
await _fail(guide_id, "OnePager-Bau fehlgeschlagen") await _fail(guide_id, "OnePager-Bau fehlgeschlagen")
return None return None
cards = res[0] karten = res[0]
# Schritt 3: Verifizieren — OK oder vollständig korrigierte Liste (nicht fatal) # Schritt 3: Verifizieren — {"ok": true} oder vollständig korrigierte Liste (nicht fatal)
await _set_progress(guide_id, "Verifiziere OnePager…") await _set_progress(guide_id, "Verifiziere OnePager…")
karten_block = "\n".join(f"{i}. {t}" for i, t in cards.items()) check_path = content_path.parent / f"{content_path.stem}.onepager-check.json"
fragment_paths.append(check_path)
check_path.unlink(missing_ok=True)
karten_block = "\n".join(f"- {k['titel']}{k['merksatz']}" for k in karten)
slots = [{ slots = [{
"key": f"{guide_id}-verify", "key": f"{guide_id}-verify",
"prompt": _prompt("OnePager-Verifikation", topic=topic, recherche=recherche, karten=karten_block), "prompt": _prompt("OnePager-Verifikation", topic=topic, recherche=recherche, karten=karten_block, out_path=check_path),
"role": "fast", "capabilities": "none", "role": "fast", "capabilities": "files",
"payload": (lambda result: result[1].strip() if (_parse_auswahl(result[1]) or "OK" in result[1].upper()) else None), "payload": (lambda result: karten_schema(_json_datei(check_path))),
}] }]
res = await _race(topic, "OnePager-Verifikation", slots, 1, _timeout("onepager_verify"), provider, cancelled=is_cancelled) res = await _race(topic, "OnePager-Verifikation", slots, 1, _timeout("onepager_verify"), provider, cancelled=is_cancelled)
if is_cancelled(): if is_cancelled():
return None return None
if res is None: if res is None:
_log(topic, "OnePager-Verifikation fehlgeschlagen — ungeprüfte Version wird verwendet") _log(topic, "OnePager-Verifikation fehlgeschlagen — ungeprüfte Version wird verwendet")
else: elif isinstance(res[0], list):
corrected = _parse_auswahl(res[0]) _log(topic, "OnePager-Verifikation hat Korrekturen geliefert")
if corrected: karten = res[0]
_log(topic, "OnePager-Verifikation hat Korrekturen geliefert")
cards = corrected
sections = [ sections = [
{"num": i, "title": _titel(text), "md": text.split("", 1)[1].strip() if "" in text else text} {"num": i, "title": k["titel"], "md": k["merksatz"]}
for i, text in cards.items() for i, k in enumerate(karten, 1)
] ]
return [{"title": topic, "sections": sections}] return [{"title": topic, "sections": sections}]
@@ -849,30 +935,35 @@ async def _generate_sections(
facts: str, instructions: str, provider: str, facts: str, instructions: str, provider: str,
content_path: Path, fragment_paths: list[Path], content_path: Path, fragment_paths: list[Path],
) -> list[dict] | None: ) -> list[dict] | None:
def is_cancelled() -> bool:
return guide_id in _cancelled
spec = (TEMPLATES_DIR / "Format" / "Section.md").read_text(encoding="utf-8") spec = (TEMPLATES_DIR / "Format" / "Section.md").read_text(encoding="utf-8")
bausteine_block = "\n".join(f"{i}. {t}" for i, t in entries.items()) bausteine_liste = "\n".join(f"- {t}" for t in entries.values())
if format_name == "MiniGuide": if format_name == "MiniGuide":
# Ein Writer, gliedert selbst in Kapitel # Ein Writer, gliedert selbst in Kapitel
plan = None plan = None
zuteilungen = [bausteine_block] zuteilungen = [bausteine_liste]
chunk_sizes = [len(entries)] chunk_sizes = [len(entries)]
else: else:
await _set_progress(guide_id, "Plane Gliederung…") await _set_progress(guide_id, "Plane Gliederung…")
returncode, stdout, stderr = await run_agent( plan_path = content_path.parent / f"{content_path.stem}.gliederung.json"
f"{guide_id}-plan", fragment_paths.append(plan_path)
_prompt("Guide-Plan", topic=topic, format_name=format_name, bausteine=bausteine_block, extra=_extra(instructions)), plan_path.unlink(missing_ok=True)
_timeout("plan", len(entries)), provider=provider, role="guide", capabilities="none", slots = [{
) "key": f"{guide_id}-plan",
if guide_id in _cancelled: "prompt": _prompt("Guide-Plan", topic=topic, format_name=format_name, bausteine=bausteine_liste, out_path=plan_path, extra=_extra(instructions)),
"role": "guide", "capabilities": "files",
"payload": (lambda result: _resolve_gliederung(_json_datei(plan_path), entries)),
}]
res = await _race(topic, "Gliederung", slots, 1, _timeout("plan", len(entries)), provider, cancelled=is_cancelled)
if is_cancelled():
return None return None
if returncode != 0: if res is None:
await _fail(guide_id, _claude_error("Plan-Fehler", returncode, stdout, stderr)) await _fail(guide_id, "Gliederung fehlgeschlagen")
return None
plan = _parse_gliederung(stdout, set(entries), topic)
if not plan:
await _fail(guide_id, "Gliederung nicht parsebar")
return None return None
plan = res[0]
chunks = _split_chunks(plan, WRITER_COUNT[format_name]) chunks = _split_chunks(plan, WRITER_COUNT[format_name])
zuteilungen = [_zuteilung_text(chunk, entries) for chunk in chunks] zuteilungen = [_zuteilung_text(chunk, entries) for chunk in chunks]
chunk_sizes = [sum(len(c["nums"]) for c in chunk) for chunk in chunks] chunk_sizes = [sum(len(c["nums"]) for c in chunk) for chunk in chunks]
@@ -893,7 +984,7 @@ async def _generate_sections(
) )
for i, (zuteilung, path, size) in enumerate(zip(zuteilungen, paths, chunk_sizes), 1) for i, (zuteilung, path, size) in enumerate(zip(zuteilungen, paths, chunk_sizes), 1)
], return_exceptions=True) ], return_exceptions=True)
if guide_id in _cancelled: if is_cancelled():
return None return None
for i, (r, p) in enumerate(zip(results, paths), 1): for i, (r, p) in enumerate(zip(results, paths), 1):
if isinstance(r, BaseException): if isinstance(r, BaseException):
@@ -911,28 +1002,38 @@ async def _generate_sections(
return None return None
await _set_progress(guide_id, "Setze zusammen…") await _set_progress(guide_id, "Setze zusammen…")
idx = _titel_index(entries)
by_num: dict[int, dict] = {}
fragment_order: list[int] = []
for sec in fragments:
num = _titel_aufloesen(idx, sec["titel"])
if num is None:
_log(topic, f"Writer lieferte unbekannte Section '{sec['titel'][:40]}' (ignoriert)")
continue
if num not in by_num:
by_num[num] = sec
fragment_order.append(num)
def section_json(num: int) -> dict:
sec = by_num[num]
return {"num": num, "title": _titel(entries[num]), "md": sec["md"]}
chapters: list[dict] = [] chapters: list[dict] = []
if plan is None: if plan is None:
# MiniGuide: Kapitel aus den Fragment-Markern in Datei-Reihenfolge # MiniGuide: Kapitel aus den Fragment-Markern in Datei-Reihenfolge
seen: set[int] = set() for num in fragment_order:
for sec in fragments: title = by_num[num]["kapitel"] or topic
if sec["num"] not in entries or sec["num"] in seen:
continue
seen.add(sec["num"])
title = sec["kapitel"] or topic
if not chapters or chapters[-1]["title"] != title: if not chapters or chapters[-1]["title"] != title:
chapters.append({"title": title, "sections": []}) chapters.append({"title": title, "sections": []})
chapters[-1]["sections"].append(_section_json(sec, entries)) chapters[-1]["sections"].append(section_json(num))
missing = sorted(set(entries) - seen)
else: else:
by_num = {sec["num"]: sec for sec in fragments if sec["num"] in entries}
for ch in plan: for ch in plan:
sections = [_section_json(by_num[num], entries) for num in ch["nums"] if num in by_num] sections = [section_json(num) for num in ch["nums"] if num in by_num]
if sections: if sections:
chapters.append({"title": ch["title"], "sections": sections}) chapters.append({"title": ch["title"], "sections": sections})
missing = sorted(set(entries) - set(by_num)) missing = sorted(set(entries) - set(by_num))
if missing: if missing:
_log(topic, f"Sections fehlen in der Writer-Ausgabe: {missing}") _log(topic, f"Sections fehlen in der Writer-Ausgabe: {[_titel(entries[n]) for n in missing]}")
if not chapters: if not chapters:
await _fail(guide_id, "Keine Sections in der Writer-Ausgabe gefunden") await _fail(guide_id, "Keine Sections in der Writer-Ausgabe gefunden")
return None return None
@@ -962,7 +1063,7 @@ async def generate_guide(guide_id: str, topic: str, format_name: str, instructio
if not selected: if not selected:
await _fail(guide_id, "Keine passenden Bausteine gefunden") await _fail(guide_id, "Keine passenden Bausteine gefunden")
return return
entries = {i: text for i, text in enumerate(selected, 1)} entries = _eindeutige_titel({i: text for i, text in enumerate(selected, 1)})
facts = _prompt("Guide-Fakten-Projekt", project=project) if project else _prompt("Guide-Fakten-Thema") facts = _prompt("Guide-Fakten-Projekt", project=project) if project else _prompt("Guide-Fakten-Thema")
chapters = await _generate_sections( chapters = await _generate_sections(
guide_id, topic, format_name, entries, guide_id, topic, format_name, entries,

View File

@@ -1,19 +1,16 @@
Eine konsolidierte Baustein-Liste zum Thema "{topic}" wurde aus drei Recherchen erstellt. Prüfe sie auf Verluste und Duplikate. Eine konsolidierte Baustein-Liste zum Thema "{topic}" wurde aus drei Recherchen erstellt. Prüfe sie auf Verluste und Duplikate.
RECHERCHEN: TITEL DER RECHERCHEN:
{results} {results}
KONSOLIDIERTE LISTE: KONSOLIDIERTE LISTE:
{auswahl} {auswahl}
Prüfe genau zwei Dinge: Prüfe genau zwei Dinge:
1. FEHLT etwas? Bausteine, die in mindestens einer Recherche belegt sind, aber in der konsolidierten Liste nicht vorkommen — auch nicht unter anderem Titel oder in einem Sammeleintrag. 1. FEHLT ein Konzept, das in den Recherchen vorkommt, aber in der konsolidierten Liste nicht enthalten ist — auch nicht unter anderem Titel oder in einem Sammeleintrag?
2. DOPPELT? Einträge der Liste, die dasselbe Konzept beschreiben. Der beste bleibt, die übrigen werden gestrichen. 2. Beschreiben mehrere Einträge der Liste DASSELBE Konzept? Der beste bleibt, die übrigen werden gestrichen.
Antworte AUSSCHLIESSLICH in diesem Format. Leere Abschnitte weglassen; ist nichts zu tun, antworte nur mit OK: Schreibe NUR die JSON-Datei nach: {out_path}
NACHTRÄGE:
Titel — Kurzbeschreibung (max. ~12 Wörter) Format (Titel EXAKT wie in der konsolidierten Liste; nichts zu tun = leere Listen):
Titel — Kurzbeschreibung {{"nachtraege": ["Titel — Kurzbeschreibung"], "streichen": ["Exakter Titel aus der Liste"]}}
STREICHEN:
12 Titel
17 Titel

View File

@@ -4,9 +4,10 @@ Drei Recherche-Agenten haben unabhängig voneinander die Bausteine des Themas "{
Regeln: Regeln:
- Vereinige die Listen: erkenne gleiche Konzepte unter verschiedenen Titeln und führe sie zu einem Baustein zusammen. - Vereinige die Listen: erkenne gleiche Konzepte unter verschiedenen Titeln und führe sie zu einem Baustein zusammen.
- KONSOLIDIERE Referenz-Aufzählungen zu Konzepten: einzelne Attribute, Parameter, Varianten oder Untertypen werden zu EINEM Baustein ihres Eltern-Konzepts zusammengefasst (falsch: je ein Eintrag für `height`, `width`, `src`; richtig: ein Baustein "Bild-Attribute").
- Verwirf Bausteine ohne Quelle oder die erfunden wirken. Behalte im Zweifel, was mindestens eine Recherche belegt. - Verwirf Bausteine ohne Quelle oder die erfunden wirken. Behalte im Zweifel, was mindestens eine Recherche belegt.
- KEINE Kategorien, KEINE Bewertung — eine flache, durchnummerierte Liste. - KEINE Kategorien, KEINE Bewertung — eine flache, durchnummerierte Liste.
- Lass die Quellen weg: pro Baustein nur Titel und Kurzbeschreibung (max. ~12 Wörter). - Lass die Quellen weg. Titel und Kurzbeschreibung (max. ~12 Wörter) auf DEUTSCH (Code-Bezeichner bleiben original). Jeder Titel muss EINDEUTIG sein.
Schreibe NUR die Markdown-Datei nach: {out_path} Schreibe NUR die Markdown-Datei nach: {out_path}

View File

@@ -13,14 +13,11 @@ Kategorien:
Aufgabe: Aufgabe:
1. Ordne jeden Streitfall einer Kategorie zu. 1. Ordne jeden Streitfall einer Kategorie zu.
2. Prüfe die Mehrheits-Einordnung gegen die Definitionen: liste NUR die Nummern, deren Kategorie du für falsch hältst, unter der korrekten Kategorie. 2. Prüfe die Mehrheits-Einordnung gegen die Definitionen: nimm NUR die Bausteine auf, deren Kategorie du für falsch hältst.
Nicht gelistete Nummern gelten als bestätigt. Keine neuen Bausteine erfinden. Nicht aufgenommene Bausteine gelten als bestätigt. Titel EXAKT wie in den Listen verwenden. Keine neuen Bausteine.
Antworte AUSSCHLIESSLICH in diesem Format. Gibt es weder Streitfälle noch Korrekturen, antworte nur mit OK: Schreibe NUR die JSON-Datei nach: {out_path}
KERN:
17 Titel Format (Titel → korrekte Kategorie; nichts zu korrigieren und keine Streitfälle = leeres Objekt):
WICHTIG: {{"Exakter Titel": "KERN", "Anderer Titel": "REST"}}
42 Titel
REST:
8 Titel

View File

@@ -9,15 +9,12 @@ Kategorien:
- REST: Spezialfälle, Randthemen, selten Gebrauchtes - REST: Spezialfälle, Randthemen, selten Gebrauchtes
Regeln: Regeln:
- Jeder Baustein landet in GENAU einer Kategorie. Keinen weglassen, keinen neuen erfinden. - JEDER Baustein landet in GENAU einer Kategorie. Keinen weglassen, keinen erfinden.
- Verwende die Titel EXAKT so, wie sie in der Liste stehen — der Titel identifiziert den Baustein.
- Die Kategoriegrößen folgen aus dem Thema — gleich große Kategorien sind verdächtig. - Die Kategoriegrößen folgen aus dem Thema — gleich große Kategorien sind verdächtig.
- Setzt ein Baustein einer Kategorie einen anderen voraus, gehört dieser in dieselbe oder eine höhere Kategorie. - Setzt ein Baustein einer Kategorie einen anderen voraus, gehört dieser in dieselbe oder eine höhere Kategorie.
Antworte AUSSCHLIESSLICH in diesem Format — pro Zeile Nummer und Titel des Bausteins, kein weiterer Text davor oder danach: Schreibe NUR die JSON-Datei nach: {out_path}
KERN:
1 Titel Format:
4 Titel {{"KERN": ["Titel", "Titel"], "WICHTIG": ["Titel"], "REST": ["Titel"]}}
WICHTIG:
2 Titel
REST:
3 Titel

View File

@@ -3,11 +3,12 @@ Ermittle ALLE Bausteine (Konzepte/Funktionen/Features) des Themas "{topic}" für
{source} {source}
Regeln: Regeln:
- Ein Baustein = ein LEHRBARES KONZEPT, keine Referenz-Aufzählung. Attribute, Parameter, Varianten und Untertypen gehören in den Baustein ihres Eltern-Konzepts — niemals als eigene Einträge (falsch: 21 Einträge für jeden input-Typ; richtig: ein Baustein "Eingabefelder und ihre Typen").
- KEINE Kategorien, KEINE Bewertung, KEINE Reihenfolge nach Wichtigkeit — nur eine flache, durchnummerierte Liste. - KEINE Kategorien, KEINE Bewertung, KEINE Reihenfolge nach Wichtigkeit — nur eine flache, durchnummerierte Liste.
- Es gibt KEINE Ziel-Anzahl. Ein kleines Thema hat wenige Bausteine, ein großes viele. Höre erst auf, wenn die Recherche nichts Neues mehr hergibt. - Es gibt KEINE Ziel-Anzahl. Höre erst auf, wenn die Recherche nichts Neues mehr hergibt.
- Erfinde nichts: nimm nur Bausteine auf, die du in der Recherche belegt hast. Notiere pro Baustein die Quelle (URL bzw. Dateipfad). - Erfinde nichts: nimm nur Bausteine auf, die du in der Recherche belegt hast. Notiere pro Baustein die Quelle (URL bzw. Dateipfad).
- Nicht künstlich splitten, nicht zusammenfassen: ein Baustein = ein zusammenhängendes Konzept. - Schreibe Titel und Beschreibung auf DEUTSCH (Fachbegriffe/Code-Bezeichner bleiben original).
- Halte die Beschreibung kurz: maximal ~12 Wörter. - Beschreibung maximal ~12 Wörter.
Schreibe NUR die Markdown-Datei nach: {bausteine_path} Schreibe NUR die Markdown-Datei nach: {bausteine_path}

View File

@@ -6,12 +6,9 @@ BAUSTEINE:
Regeln: Regeln:
- Kategorien NICHT verändern, keine Bausteine weglassen, keine erfinden — nur die Reihenfolge innerhalb jeder Kategorie ändern. - Kategorien NICHT verändern, keine Bausteine weglassen, keine erfinden — nur die Reihenfolge innerhalb jeder Kategorie ändern.
- Was etwas anderes voraussetzt, kommt nach seinen Voraussetzungen. - Was etwas anderes voraussetzt, kommt nach seinen Voraussetzungen.
- Verwende die Titel EXAKT so, wie sie in den Listen stehen.
Antworte AUSSCHLIESSLICH in diesem Format — alle Nummern jeder Kategorie in der neuen Reihenfolge: Schreibe NUR die JSON-Datei nach: {out_path}
KERN:
3 Titel Format (alle Titel jeder Kategorie in der neuen Reihenfolge):
1 Titel {{"KERN": ["Titel", "Titel"], "WICHTIG": ["Titel"], "REST": ["Titel"]}}
WICHTIG:
7 Titel
REST:
2 Titel

View File

@@ -7,14 +7,13 @@ Aufgabe: Gruppiere ALLE Bausteine in Kapitel und bringe sie in eine sinnvolle Le
Regeln: Regeln:
- Jeder Baustein landet in GENAU einem Kapitel. Keinen weglassen, keinen erfinden. - Jeder Baustein landet in GENAU einem Kapitel. Keinen weglassen, keinen erfinden.
- Verwende die Titel EXAKT so, wie sie in der Liste stehen.
- Die Reihenfolge innerhalb der Kapitel ist die Lese-Reihenfolge. - Die Reihenfolge innerhalb der Kapitel ist die Lese-Reihenfolge.
- 310 Bausteine pro Kapitel sind ein guter Rahmen; die Kapitelzahl folgt aus dem Thema. - 310 Bausteine pro Kapitel sind ein guter Rahmen; die Kapitelzahl folgt aus dem Thema.
- Kapiteltitel kurz und konkret. - Kapiteltitel kurz und konkret.
Antworte AUSSCHLIESSLICH in diesem Format (Nummer und Titel je Baustein-Zeile, kein weiterer Text): Schreibe NUR die JSON-Datei nach: {out_path}
KAPITEL: Titel
3 Baustein-Titel Format:
7 Baustein-Titel {{"kapitel": [{{"titel": "Grundlagen", "bausteine": ["Exakter Titel", "Exakter Titel"]}}]}}
KAPITEL: Titel
1 Baustein-Titel
{extra} {extra}

View File

@@ -8,10 +8,10 @@ Dir zugeteilt sind folgende Kapitel und Bausteine — verbindlich: jede zugeteil
SECTION-SPEZIFIKATION: SECTION-SPEZIFIKATION:
{spec} {spec}
Schreibe NUR die Datei {out_path} in GENAU diesem Format — pro Kapitel ein kapitel-Marker, pro Baustein ein section-Marker, darunter der Markdown-Body: Schreibe NUR die Datei {out_path} in GENAU diesem Format — pro Kapitel ein kapitel-Marker, pro Baustein ein section-Marker (Titel EXAKT aus der Zuteilung), darunter der Markdown-Body:
<!-- kapitel: Kapiteltitel --> <!-- kapitel: Kapiteltitel -->
<!-- section: 3 | Baustein-Titel --> <!-- section: Exakter Baustein-Titel -->
Beschreibung… Beschreibung…
### Beispiel ### Beispiel
@@ -19,5 +19,5 @@ Beschreibung…
``` ```
Die Marker-Zeilen exakt so schreiben (Nummer = Baustein-Nummer aus der Zuteilung). Kein Text außerhalb der Sections, kein Dokument-Titel, kein Inhaltsverzeichnis. Die Marker-Zeilen exakt so schreiben. Kein Text außerhalb der Sections, kein Dokument-Titel, kein Inhaltsverzeichnis.
{extra} {extra}

View File

@@ -6,9 +6,11 @@ FAKTENBASIS (alleinige Quelle, nichts hinzuerfinden):
Regeln: Regeln:
- Wähle die wichtigsten Punkte aus — was man über "{topic}" wissen MUSS. Eine Bildschirmseite, also etwa 1018 Karten. - Wähle die wichtigsten Punkte aus — was man über "{topic}" wissen MUSS. Eine Bildschirmseite, also etwa 1018 Karten.
- Pro Karte GENAU ein prägnanter Merksatz: maximal ~15 Wörter, `inline-code` wo es hilft. - Pro Karte GENAU ein prägnanter Merksatz: maximal ~15 Wörter, `inline-code` wo es hilft.
- Titel und Merksätze auf DEUTSCH (Code-Bezeichner bleiben original).
- Reihenfolge: vom Grundlegenden zum Speziellen. - Reihenfolge: vom Grundlegenden zum Speziellen.
Antworte AUSSCHLIESSLICH in diesem Format, eine Zeile pro Karte, kein weiterer Text: Schreibe NUR die JSON-Datei nach: {out_path}
1. Titel — Merksatz
2. Titel — Merksatz Format:
{{"karten": [{{"titel": "…", "merksatz": "…"}}]}}
{extra} {extra}

View File

@@ -9,9 +9,11 @@ ONEPAGER-KARTEN:
Prüfe: Prüfe:
1. Stimmen alle Aussagen mit der Faktenbasis überein? Nichts Erfundenes? 1. Stimmen alle Aussagen mit der Faktenbasis überein? Nichts Erfundenes?
2. Fehlt ein Punkt, der für "{topic}" unverzichtbar ist und in der Faktenbasis steht? 2. Fehlt ein Punkt, der für "{topic}" unverzichtbar ist und in der Faktenbasis steht?
3. Sind die Merksätze prägnant (max. ~15 Wörter) und vom Grundlegenden zum Speziellen geordnet? 3. Sind die Merksätze prägnant (max. ~15 Wörter), deutsch und vom Grundlegenden zum Speziellen geordnet?
Ist alles in Ordnung, antworte NUR mit: OK Schreibe NUR die JSON-Datei nach: {out_path}
Sonst antworte AUSSCHLIESSLICH mit der vollständigen, korrigierten Karten-Liste im selben Format:
1. Titel — Merksatz Format — alles in Ordnung:
2. Titel — Merksatz {{"ok": true}}
Sonst die vollständige, korrigierte Karten-Liste:
{{"karten": [{{"titel": "…", "merksatz": "…"}}]}}