This commit is contained in:
team3
2026-06-12 17:18:42 +02:00
parent cfc666055c
commit 78d5833fe4
38 changed files with 1854 additions and 740 deletions

View File

@@ -26,6 +26,12 @@ _active_processes: dict[str, asyncio.subprocess.Process] = {}
_batch_sem = asyncio.Semaphore(MAX_CONCURRENT_AGENTS) _batch_sem = asyncio.Semaphore(MAX_CONCURRENT_AGENTS)
_interactive_sem = asyncio.Semaphore(MAX_CONCURRENT_INTERACTIVE) _interactive_sem = asyncio.Semaphore(MAX_CONCURRENT_INTERACTIVE)
# OpenCode-Starts serialisieren: gleichzeitig startende Prozesse kollidieren an
# der internen Session-DB ("database is locked", Exit nach <1s). Der kurze
# Versatz entzerrt die Starts; danach laufen die Prozesse normal parallel.
_opencode_start_lock = asyncio.Lock()
_OPENCODE_START_DELAY = 1.0
# Capability → Claude --allowedTools # Capability → Claude --allowedTools
_CLAUDE_TOOLS = { _CLAUDE_TOOLS = {
"full": "Write,Bash,Read,WebSearch,WebFetch", "full": "Write,Bash,Read,WebSearch,WebFetch",
@@ -92,14 +98,23 @@ async def run_agent(
return await _run_claude_cli(agent_key, prompt, timeout, role, capabilities) return await _run_claude_cli(agent_key, prompt, timeout, role, capabilities)
async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None, timeout: int) -> tuple[int, str, str]: async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None, timeout: int, stagger: bool = False) -> tuple[int, str, str]:
start = time.monotonic() start = time.monotonic()
process = await asyncio.create_subprocess_exec(
async def spawn():
return await asyncio.create_subprocess_exec(
*cmd, *cmd,
stdin=asyncio.subprocess.PIPE if stdin_data is not None else asyncio.subprocess.DEVNULL, stdin=asyncio.subprocess.PIPE if stdin_data is not None else asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
) )
if stagger:
async with _opencode_start_lock:
process = await spawn()
await asyncio.sleep(_OPENCODE_START_DELAY)
else:
process = await spawn()
_active_processes[agent_key] = process _active_processes[agent_key] = process
try: try:
try: try:
@@ -154,7 +169,7 @@ async def _run_opencode(agent_key: str, prompt: str, timeout: int, provider: str
"-f", str(prompt_path), "-f", str(prompt_path),
] ]
try: try:
rc, stdout, stderr = await _communicate(agent_key, cmd, None, timeout) rc, stdout, stderr = await _communicate(agent_key, cmd, None, timeout, stagger=True)
return rc, _clean_opencode_output(stdout), stderr return rc, _clean_opencode_output(stdout), stderr
finally: finally:
prompt_path.unlink(missing_ok=True) prompt_path.unlink(missing_ok=True)

View File

@@ -1,4 +1,14 @@
"""Bausteine-Pipeline: 4x Recherche (3 nötig) → 2x Auswahl (1) → Prüfung — reines Inventar, unsortiert.""" """Bausteine-Pipeline: Recherche-Konsens + Klärungs-Loop — reines Inventar, unsortiert.
5x Recherche (min. 3, Grace) → Mapping (Konsens/Rest) → Klärungs-Loop (max.
KONSENS_MAX_RUNDEN Runden): 3 Auswahl-Agenten (min. 2, Grace) entscheiden
über den strittigen Rest, ein Mapping-Agent sortiert in aufnehmen/verwerfen/
weiter strittig. Leerer Rest beendet den Loop; die letzte Runde muss alles
entscheiden. Races nutzen ein Grace-Fenster statt „erste N gewinnen": Nach dem
ersten gültigen Ergebnis dürfen die übrigen Agenten KONSENS_GRACE Sekunden
fertig werden. Der Konsens wird im Code akkumuliert — kein Agent re-emittiert
die Gesamtliste.
"""
import asyncio import asyncio
import logging import logging
@@ -7,15 +17,15 @@ import subprocess
from pathlib import Path from pathlib import Path
from agents import kill_process from agents import kill_process
from config import DEFAULT_PROVIDER from config import KONSENS_GRACE, KONSENS_MAX_RUNDEN, DEFAULT_PROVIDER
from fsutil import atomic_write_text from fsutil import atomic_write_text
from jsonio import read_json_file as _json_datei from jsonio import read_json_file as _json_datei
from paths import arbeit_dir, bausteine_path, project_dir from paths import arbeit_dir, bausteine_path, project_dir
from pipeline import ( from pipeline import (
CANCELLED, FAILED, GenContext, _extra, _log, _prompt, _race, CANCELLED, FAILED, GenContext, _extra, _log, _prompt, _race, _rest_schema,
_semaphore, _timeout, run_single_slot, _runde_schema, _semaphore, _str_liste, _timeout, run_single_slot,
) )
from textkit import _eindeutige_titel, _parse_auswahl, _titel, _titel_aufloesen, _titel_index from textkit import _eindeutige_titel, _parse_auswahl, _titel_aufloesen, _titel_index, _vormerge
log = logging.getLogger("creator.bausteine") log = logging.getLogger("creator.bausteine")
@@ -24,11 +34,11 @@ _bausteine_errors: dict[str, str] = {}
_bausteine_cancelled: set[str] = set() _bausteine_cancelled: set[str] = set()
_bausteine_step: dict[str, int] = {} _bausteine_step: dict[str, int] = {}
BAUSTEINE_STEPS = ("Recherche", "Auswahl", "Prüfung") BAUSTEINE_STEPS = ("Recherche", "Konsolidierung", "Klärung")
def _bausteine_steps(topic: str) -> tuple: def _bausteine_steps(topic: str) -> tuple:
"""Projekte haben einen 4. Schritt: Themenfeld-Ergänzung per Web-Recherche.""" """Projekte haben einen zusätzlichen Schritt: Themenfeld-Ergänzung per Web-Recherche."""
if project_dir(topic).is_dir(): if project_dir(topic).is_dir():
return BAUSTEINE_STEPS + ("Ergänzung",) return BAUSTEINE_STEPS + ("Ergänzung",)
return BAUSTEINE_STEPS return BAUSTEINE_STEPS
@@ -36,18 +46,24 @@ def _bausteine_steps(topic: str) -> tuple:
def _bausteine_files(topic: str) -> dict: def _bausteine_files(topic: str) -> dict:
arbeit = arbeit_dir(topic) arbeit = arbeit_dir(topic)
runden = range(1, KONSENS_MAX_RUNDEN + 1)
return { return {
"final": bausteine_path(topic), "final": bausteine_path(topic),
"arbeit": arbeit, "arbeit": arbeit,
"recherche": [arbeit / f"recherche-{i}.md" for i in (1, 2, 3, 4)], "recherche": [arbeit / f"recherche-{i}.md" for i in (1, 2, 3, 4, 5)],
"auswahl": [arbeit / f"auswahl-{i}.md" for i in (1, 2)], "recherche_mapping": arbeit / "recherche-mapping.json",
"auswahl_check": arbeit / "auswahl-check.json", "auswahl": {n: [arbeit / f"auswahl-r{n}-{i}.json" for i in (1, 2, 3)] for n in runden},
"mapping": {n: arbeit / f"auswahl-mapping-r{n}.json" for n in runden},
"ergaenzung": arbeit / "ergaenzung.json", "ergaenzung": arbeit / "ergaenzung.json",
} }
def _alle_slot_dateien(files: dict) -> list[Path]: def _alle_slot_dateien(files: dict) -> list[Path]:
return [*files["recherche"], *files["auswahl"], files["auswahl_check"], files["ergaenzung"]] return [
*files["recherche"], files["recherche_mapping"],
*(p for slots in files["auswahl"].values() for p in slots),
*files["mapping"].values(), files["ergaenzung"],
]
def cancel_bausteine(topic: str) -> bool: def cancel_bausteine(topic: str) -> bool:
@@ -63,9 +79,14 @@ def _resume_step(topic: str) -> int:
files = _bausteine_files(topic) files = _bausteine_files(topic)
if sum(p.exists() for p in files["recherche"]) < 3: if sum(p.exists() for p in files["recherche"]) < 3:
return 0 return 0
if not any(p.exists() for p in files["auswahl"]): if not files["recherche_mapping"].exists():
return 1 return 1
if not files["auswahl_check"].exists(): mapping = _mapping_schema(_json_datei(files["recherche_mapping"]))
geklaert = mapping is not None and (
not mapping[1] # kein strittiger Rest
or any((r := _runde_schema(_json_datei(p))) is not None and not r[1] for p in files["mapping"].values())
)
if not geklaert:
return 2 return 2
if project_dir(topic).is_dir() and not files["ergaenzung"].exists(): if project_dir(topic).is_dir() and not files["ergaenzung"].exists():
return 3 return 3
@@ -168,25 +189,15 @@ def _file_payload(path: Path):
return text if _parse_auswahl(text) else None return text if _parse_auswahl(text) else None
def _auswahl_payload(path: Path): def _mapping_schema(data):
if not path.exists(): """{"bausteine": [str, ≥1], "rest": [str]} → (bausteine, rest) · sonst None."""
return None
text = path.read_text(encoding="utf-8")
entries = _parse_auswahl(text)
return (text, entries) if entries else None
def _auswahl_check_schema(data):
"""{"nachtraege": [...], "streichen": [...]} — None bei Schema-Verstoß."""
if not isinstance(data, dict): if not isinstance(data, dict):
return None return None
nach = data.get("nachtraege", []) bausteine = _str_liste(data.get("bausteine"))
streich = data.get("streichen", []) rest = _str_liste(data.get("rest"))
if not isinstance(nach, list) or not isinstance(streich, list): if not bausteine or rest is None:
return None return None
if not all(isinstance(x, str) for x in [*nach, *streich]): return bausteine, rest
return None
return {"nachtraege": nach, "streichen": streich}
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:
@@ -223,17 +234,18 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
for p_alt in _alle_slot_dateien(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: 5 Recherche-Agenten, min. 3 mit Grace-Fenster — alle gültigen
# Slot-Dateien fließen ins Mapping (kein Kappen mehr bei 3)
recherchen: list[str] = [] recherchen: list[str] = []
offen = [] offen = []
for i, path in enumerate(files["recherche"], 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:
recherchen.append(text) recherchen.append(text)
else: else:
offen.append((i, path)) offen.append((i, path))
vorhanden = len(recherchen) vorhanden = len(recherchen)
set_p(f"Recherche läuft ({vorhanden}/3 gültig)…", step=0) set_p(f"Recherche läuft ({vorhanden} gültig, min. 3)…", step=0)
if vorhanden < 3: if vorhanden < 3:
caps = "files" if project else "full" caps = "files" if project else "full"
slots = [ slots = [
@@ -247,80 +259,136 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
] ]
neue = await _race( neue = await _race(
topic, "Recherche", slots, 3 - vorhanden, _timeout("recherche"), provider, topic, "Recherche", slots, 3 - vorhanden, _timeout("recherche"), provider,
on_update=lambda c: set_p(f"Recherche läuft ({vorhanden + c}/3 gültig)…"), on_update=lambda c: set_p(f"Recherche läuft ({vorhanden + c} gültig, min. 3)…"),
cancelled=is_cancelled, cancelled=is_cancelled, grace=KONSENS_GRACE,
) )
if is_cancelled(): if is_cancelled():
abgebrochen() abgebrochen()
return return
if neue is None: if neue is None:
_bausteine_errors[topic] = "Recherche fehlgeschlagen (Quorum nicht erreicht)" _bausteine_errors[topic] = "Recherche fehlgeschlagen (Minimum nicht erreicht)"
return return
recherchen += neue recherchen += neue
# Schritt 2: 2 Auswahl-Agenten, der erste gewinnt — vorhandene gültige Datei wird übernommen # Schritt 2: Recherche-Mapping — Code-Vormerge (exakte Titel) + 1 Agent
n_est = max(len(_parse_auswahl(t)) for t in recherchen) # für semantische Dubletten und Konsens/Rest-Teilung (fatal)
bestehende = next((res for p in files["auswahl"] if (res := _auswahl_payload(p)) is not None), None) mapping = _mapping_schema(_json_datei(files["recherche_mapping"]))
if bestehende is not None: if mapping is None:
flat, entries = bestehende
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)) files["recherche_mapping"].unlink(missing_ok=True)
slots = [ gemergt = _vormerge([_parse_auswahl(t) for t in recherchen])
{ eintraege = "\n".join(f"{i}. {text} ({n}× genannt)" for i, (text, n) in enumerate(gemergt, 1))
"key": f"bausteine-{topic}-auswahl-{i}", status, mapping = await run_single_slot(
"prompt": _prompt("Bausteine-Auswahl", topic=topic, results=results_block, out_path=path), ctx, "Recherche-Mapping",
"role": "fast", "capabilities": "files", key=f"bausteine-{topic}-recherche-mapping",
"payload": (lambda result, p=path: _auswahl_payload(p)), prompt=_prompt(
} "Bausteine-Recherche-Mapping",
for i, path in enumerate(files["auswahl"], 1) topic=topic, n=len(recherchen), eintraege=eintraege,
] out_path=files["recherche_mapping"],
auswahl = await _race(topic, "Auswahl", slots, 1, _timeout("auswahl", n_est), provider, cancelled=is_cancelled) ),
if is_cancelled(): role="judge", capabilities="files",
abgebrochen() payload=lambda result: _mapping_schema(_json_datei(files["recherche_mapping"])),
return timeout=_timeout("recherche_mapping", len(gemergt)),
if auswahl is None:
_bausteine_errors[topic] = "Auswahl fehlgeschlagen (kein gültiges Ergebnis)"
return
flat, entries = auswahl[0]
# Schritt 2b: Auswahl-Prüfung gegen die Recherche-Titel (JSON, nicht fatal)
set_p("Prüfe Auswahl…", step=2)
check_path = files["auswahl_check"]
patch = _auswahl_check_schema(_json_datei(check_path))
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)
)
status, check = await run_single_slot(
ctx, "Auswahl-Check",
key=f"bausteine-{topic}-auswahlcheck-1",
prompt=_prompt("Bausteine-Auswahl-Check", topic=topic, results=titel_listen, auswahl=flat, out_path=check_path),
role="fast", capabilities="files",
payload=lambda result: _auswahl_check_schema(_json_datei(check_path)),
timeout=_timeout("auswahl_check", len(entries)),
) )
if status == CANCELLED: if status == CANCELLED:
abgebrochen() abgebrochen()
return return
if status == FAILED: if status == FAILED:
_log(topic, "Auswahl-Check fehlgeschlagen — fahre ohne Korrekturen fort") _bausteine_errors[topic] = "Recherche-Mapping fehlgeschlagen"
else: return
patch = check konsens, rest = mapping
if patch is not None and (patch["streichen"] or patch["nachtraege"]):
idx = _titel_index(entries)
weg = {num for t in patch["streichen"] if (num := _titel_aufloesen(idx, t)) is not None}
if weg:
_log(topic, f"Auswahl-Check streicht Duplikate: {sorted(weg)}")
entries = {n: t for n, t in entries.items() if n not in weg}
if patch["nachtraege"]:
_log(topic, f"Auswahl-Check ergänzt {len(patch['nachtraege'])} Bausteine")
texts = [t for _, t in sorted(entries.items())] + list(patch["nachtraege"])
entries = {i: t for i, t in enumerate(texts, 1)}
# Schritt 4 (nur Projekte): Themenfeld-Ergänzung — Skript/Projekt ist ein Ausschnitt, # Klärungs-Loop: 3 Auswahl-Agenten entscheiden über den Rest, ein
# Mapping-Agent sortiert in aufnehmen/verwerfen/weiter strittig.
# Leerer Rest beendet den Loop; Runde KONSENS_MAX_RUNDEN muss
# alles entscheiden. Der Konsens wächst nur hier im Code.
runde = 0
while rest and runde < KONSENS_MAX_RUNDEN:
runde += 1
final_runde = runde == KONSENS_MAX_RUNDEN
set_p(f"Klärung läuft (Runde {runde}/{KONSENS_MAX_RUNDEN})…", step=2)
mapping_path = files["mapping"][runde]
# Resume: fertiges Runden-Mapping wird direkt übernommen
ergebnis = _runde_schema(_json_datei(mapping_path), final=final_runde)
if ergebnis is None:
mapping_path.unlink(missing_ok=True)
konsens_block = "\n".join(f"- {t}" for t in konsens)
rest_block = "\n".join(f"- {t}" for t in rest)
# 3 Auswahl-Agenten, min. 2 mit Grace-Fenster
entscheidungen = []
offen = []
for i, path in enumerate(files["auswahl"][runde], 1):
res = _rest_schema(_json_datei(path))
if res is not None:
entscheidungen.append(res)
else:
offen.append((i, path))
if len(entscheidungen) < 2:
slots = [
{
"key": f"bausteine-{topic}-auswahl-r{runde}-{i}",
"prompt": _prompt(
"Bausteine-Auswahl",
topic=topic, konsens=konsens_block, rest=rest_block, out_path=path,
),
"role": "fast", "capabilities": "files",
"payload": (lambda result, p=path: _rest_schema(_json_datei(p))),
}
for i, path in offen
]
neue = await _race(
topic, f"Auswahl r{runde}", slots, 2 - len(entscheidungen),
_timeout("auswahl", len(rest)), provider,
cancelled=is_cancelled, grace=KONSENS_GRACE,
)
if is_cancelled():
abgebrochen()
return
if neue is None:
_bausteine_errors[topic] = f"Auswahl fehlgeschlagen (Runde {runde}, Minimum nicht erreicht)"
return
entscheidungen += neue
# Votum pro Rest-Eintrag deterministisch zählen
indizes = [_titel_index(dict(enumerate(e, 1))) for e in entscheidungen]
voten = "\n".join(
f"{i}. {text} (von {sum(1 for idx in indizes if _titel_aufloesen(idx, text) is not None)}"
f"/{len(entscheidungen)} Agenten übernommen)"
for i, text in enumerate(rest, 1)
)
final_zusatz = (
"\n- LETZTE RUNDE: Es gibt keine weitere Runde. `rest` MUSS leer sein"
" — entscheide JEDEN Eintrag selbst: aufnehmen oder verwerfen."
if final_runde else ""
)
status, ergebnis = await run_single_slot(
ctx, f"Auswahl-Mapping r{runde}",
key=f"bausteine-{topic}-auswahl-mapping-r{runde}",
prompt=_prompt(
"Bausteine-Auswahl-Mapping",
topic=topic, n=len(entscheidungen), konsens=konsens_block,
rest=voten, final=final_zusatz, out_path=mapping_path,
),
role="judge", capabilities="files",
payload=lambda result, p=mapping_path, f=final_runde: _runde_schema(_json_datei(p), final=f),
timeout=_timeout("auswahl_mapping", len(rest)),
)
if status == CANCELLED:
abgebrochen()
return
if status == FAILED:
_bausteine_errors[topic] = f"Auswahl-Mapping fehlgeschlagen (Runde {runde})"
return
aufnehmen, rest = ergebnis
_log(topic, f"Klärung Runde {runde}: {len(aufnehmen)} aufgenommen, {len(rest)} weiter strittig")
konsens = konsens + aufnehmen
entries = {i: t for i, t in enumerate(konsens, 1)}
# Nur Projekte: Themenfeld-Ergänzung — Skript/Projekt ist ein Ausschnitt,
# ein Web-Agent ergänzt kanonisch fehlende Bausteine, markiert mit [Ergänzung]. # ein Web-Agent ergänzt kanonisch fehlende Bausteine, markiert mit [Ergänzung].
if project: if project:
set_p("Ergänze Themenfeld…", step=3) set_p("Ergänze Themenfeld…", step=3)

View File

@@ -15,20 +15,33 @@ MAX_CONCURRENT_GENERATIONS = 10
MAX_CONCURRENT_AGENTS = 12 MAX_CONCURRENT_AGENTS = 12
MAX_CONCURRENT_INTERACTIVE = 4 MAX_CONCURRENT_INTERACTIVE = 4
# Grace-Fenster der Konsens-Races (Bausteine, Guide, OnePager): Nach dem ersten
# gültigen Ergebnis dürfen die übrigen Agenten noch so viele Sekunden fertig
# werden (Kill nur, wenn das Minimum schon steht).
KONSENS_GRACE = 300
# Cap der Klärungs- und Prüf-Loops: maximale Runden, bis alles entschieden sein
# muss. In der letzten Runde MUSS der Mapping-Agent jeden Eintrag entscheiden;
# Prüf-Loops lassen Rest-Beanstandungen danach stehen.
KONSENS_MAX_RUNDEN = 3
# Timeouts pro Agenten-Schritt: (Basis-Sekunden, Sekunden pro Baustein/Section). # Timeouts pro Agenten-Schritt: (Basis-Sekunden, Sekunden pro Baustein/Section).
# Gilt für alle Provider gleich — wer zu langsam ist, wird neu gestartet bzw. überholt. # Gilt für alle Provider gleich — wer zu langsam ist, wird neu gestartet bzw. überholt.
TIMEOUTS = { TIMEOUTS = {
"recherche": (1800, 0), # fix 30 min "recherche": (1800, 0), # fix 30 min
"auswahl": (600, 10), "recherche_mapping": (600, 3), # n = vorgemergte Einträge
"auswahl_check": (300, 2), "auswahl": (300, 2), # Rest-Prüfung im Klärungs-Loop, n = Rest-Einträge
"auswahl_mapping": (600, 2), # n = Rest-Einträge
"ergaenzung": (900, 0), # Themenfeld-Ergänzung bei Projekten (Web-Recherche) "ergaenzung": (900, 0), # Themenfeld-Ergänzung bei Projekten (Web-Recherche)
"guide_auswahl": (300, 5), # pro Baustein im Inventar "guide_auswahl": (300, 5), # pro Baustein im Inventar
"guide_check": (300, 2), # Auswahl-/Gliederungs-Prüfung (nur Titellisten)
"plan": (300, 5), "plan": (300, 5),
"plan_judge": (600, 5), # Judge liest bis zu 5 Gliederungen, n = Sections
"writer": (600, 120), # pro Section im Chunk "writer": (600, 120), # pro Section im Chunk
"lese_check": (300, 10), # pro Section im Paket "lese_check": (300, 10), # pro Section im Paket
"onepager_recherche": (900, 0), "onepager_recherche": (900, 0),
"onepager_mapping": (600, 0), # Konsolidierung der Recherchen
"onepager_bauen": (300, 0), "onepager_bauen": (300, 0),
"onepager_judge": (600, 0), # Judge über die Karten-Sätze
"onepager_verify": (300, 0), "onepager_verify": (300, 0),
} }
@@ -41,31 +54,27 @@ FORMAT_ANTEIL = {
# Provider-Stacks: komplett unabhängig, einer kann jederzeit entfernt werden. # Provider-Stacks: komplett unabhängig, einer kann jederzeit entfernt werden.
# Rollen: "quick" = Massenarbeit (Recherche, Einordnung), # Rollen: "quick" = Massenarbeit (Recherche, Einordnung),
# "fast" = Urteilsaufgaben mit kleinem Output (Auswahl, Final, OnePager, Chat), # "fast" = Interaktion + Voten (Chat, Prüfung, Klärung, Elemente),
# "guide" = große Generierung (Plan, Writer). # "judge" = Mapping-/Judge-/Prüf-Agenten — kalt (niedrige Temperature,
# ohne Thinking) für stabile Urteile; Claude/Lokal mappen auf "fast",
# "guide" = große Generierung (Vorschläge, Writer).
DEFAULT_PROVIDER = "claude" DEFAULT_PROVIDER = "claude"
PROVIDERS = { PROVIDERS = {
"claude": { "claude": {
"cli": "claude", "cli": "claude",
"guide": "claude-opus-4-8[1m]", "guide": "claude-opus-4-8[1m]",
"fast": "claude-sonnet-4-6", "fast": "claude-sonnet-4-6",
"judge": "claude-sonnet-4-6", # CLI kennt keine Temperature
"quick": "claude-sonnet-4-6", "quick": "claude-sonnet-4-6",
"env_key": None, # Auth via CLAUDE_CODE_OAUTH_TOKEN oder ~/.claude "env_key": None, # Auth via CLAUDE_CODE_OAUTH_TOKEN oder ~/.claude
}, },
# "minimax-kalt/…" ist KEIN eigener Stack, nur ein opencode-Provider-Eintrag
# (dev-ops/opencode.json) mit niedriger Temperature; M3 dort ohne Thinking.
"minimax": { "minimax": {
"cli": "opencode", "cli": "opencode",
"guide": "minimax/MiniMax-M3", "guide": "minimax/MiniMax-M3",
"fast": "minimax/MiniMax-M2.7-highspeed", "fast": "minimax-kalt/MiniMax-M2.7-highspeed",
"quick": "minimax/MiniMax-M2.7-highspeed", "judge": "minimax-kalt/MiniMax-M3",
"env_key": "MINIMAX_API_KEY",
},
# Wie "minimax", aber Chat/Elemente (Rolle "fast") laufen auf M3 OHNE Thinking.
# M2.x kann Thinking nicht abschalten — nur M3 respektiert thinking:disabled.
# guide/quick bleiben identisch zur Thinking-Variante.
"minimax-direkt": {
"cli": "opencode",
"guide": "minimax/MiniMax-M3",
"fast": "minimax-direkt/MiniMax-M3",
"quick": "minimax/MiniMax-M2.7-highspeed", "quick": "minimax/MiniMax-M2.7-highspeed",
"env_key": "MINIMAX_API_KEY", "env_key": "MINIMAX_API_KEY",
}, },
@@ -73,6 +82,7 @@ PROVIDERS = {
"cli": "opencode", "cli": "opencode",
"guide": "ollama/qwen3.6:27b", "guide": "ollama/qwen3.6:27b",
"fast": "ollama/qwen3.5:9b", "fast": "ollama/qwen3.5:9b",
"judge": "ollama/qwen3.5:9b",
"quick": "ollama/qwen3.5:9b", "quick": "ollama/qwen3.5:9b",
"env_key": None, "env_key": None,
"check_url": "http://localhost:11434/api/tags", # Ollama erreichbar? "check_url": "http://localhost:11434/api/tags", # Ollama erreichbar?

View File

@@ -47,6 +47,28 @@ CREATE TABLE IF NOT EXISTS elements (
) )
""" """
CREATE_VERTIEFUNGEN = """
CREATE TABLE IF NOT EXISTS vertiefungen (
topic TEXT NOT NULL,
baustein TEXT NOT NULL,
md TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (topic, baustein)
)
"""
CREATE_BAUSTEIN_PROGRESS = """
CREATE TABLE IF NOT EXISTS baustein_progress (
topic TEXT NOT NULL,
baustein TEXT NOT NULL,
gute_antworten INTEGER NOT NULL DEFAULT 0,
absolviert TEXT,
updated_at TEXT NOT NULL,
PRIMARY KEY (topic, baustein)
)
"""
_db: aiosqlite.Connection | None = None _db: aiosqlite.Connection | None = None
@@ -67,6 +89,8 @@ async def init_db():
await db.execute(CREATE_PROGRESS) await db.execute(CREATE_PROGRESS)
await db.execute(CREATE_TOPICS) await db.execute(CREATE_TOPICS)
await db.execute(CREATE_ELEMENTS) await db.execute(CREATE_ELEMENTS)
await db.execute(CREATE_VERTIEFUNGEN)
await db.execute(CREATE_BAUSTEIN_PROGRESS)
try: # Migration für Bestands-DBs ohne step-Spalte try: # Migration für Bestands-DBs ohne step-Spalte
await db.execute("ALTER TABLE guides ADD COLUMN step INTEGER") await db.execute("ALTER TABLE guides ADD COLUMN step INTEGER")
except aiosqlite.OperationalError: except aiosqlite.OperationalError:
@@ -255,3 +279,104 @@ async def delete_progress(guide_id: str) -> None:
db = await get_db() db = await get_db()
await db.execute("DELETE FROM guide_progress WHERE guide_id = ?", (guide_id,)) await db.execute("DELETE FROM guide_progress WHERE guide_id = ?", (guide_id,))
await db.commit() await db.commit()
# --- Baustein-Lernen: Vertiefungen + Prüfungs-Fortschritt ---
def _now() -> str:
from datetime import datetime, timezone
return datetime.now(timezone.utc).isoformat()
async def get_vertiefung(topic: str, baustein: str) -> str | None:
db = await get_db()
cursor = await db.execute(
"SELECT md FROM vertiefungen WHERE topic = ? AND baustein = ?", (topic, baustein)
)
row = await cursor.fetchone()
return row[0] if row else None
async def set_vertiefung(topic: str, baustein: str, md: str) -> None:
db = await get_db()
now = _now()
await db.execute(
"""INSERT INTO vertiefungen (topic, baustein, md, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(topic, baustein) DO UPDATE SET md = excluded.md, updated_at = excluded.updated_at""",
(topic, baustein, md, now, now),
)
await db.commit()
async def list_vertiefungen(topic: str) -> set[str]:
"""Baustein-Titel, zu denen eine Vertiefung existiert."""
db = await get_db()
cursor = await db.execute("SELECT baustein FROM vertiefungen WHERE topic = ?", (topic,))
rows = await cursor.fetchall()
return {row[0] for row in rows}
async def list_baustein_progress(topic: str) -> list[dict]:
db = await get_db()
cursor = await db.execute(
"SELECT baustein, gute_antworten, absolviert FROM baustein_progress WHERE topic = ?", (topic,)
)
rows = await cursor.fetchall()
return [{"baustein": b, "gute_antworten": n, "absolviert": a} for b, n, a in rows]
async def add_gute_antwort(topic: str, baustein: str) -> int:
"""Zählt eine gut bewertete Antwort und liefert den neuen Stand."""
db = await get_db()
await db.execute(
"""INSERT INTO baustein_progress (topic, baustein, gute_antworten, updated_at)
VALUES (?, ?, 1, ?)
ON CONFLICT(topic, baustein) DO UPDATE SET
gute_antworten = gute_antworten + 1, updated_at = excluded.updated_at""",
(topic, baustein, _now()),
)
await db.commit()
cursor = await db.execute(
"SELECT gute_antworten FROM baustein_progress WHERE topic = ? AND baustein = ?",
(topic, baustein),
)
row = await cursor.fetchone()
return row[0] if row else 0
async def set_baustein_absolviert(topic: str, baustein: str) -> bool:
"""Markiert absolviert; True nur beim ersten Mal (steuert den Element-Task)."""
db = await get_db()
now = _now()
await db.execute(
"INSERT OR IGNORE INTO baustein_progress (topic, baustein, gute_antworten, updated_at) VALUES (?, ?, 0, ?)",
(topic, baustein, now),
)
cursor = await db.execute(
"UPDATE baustein_progress SET absolviert = ?, updated_at = ? "
"WHERE topic = ? AND baustein = ? AND absolviert IS NULL",
(now, now, topic, baustein),
)
await db.commit()
return cursor.rowcount > 0
async def list_baustein_absolviert_all() -> dict[str, set[str]]:
"""Alle absolvierten Bausteine in einem Query: topic → Baustein-Titel."""
db = await get_db()
cursor = await db.execute(
"SELECT topic, baustein FROM baustein_progress WHERE absolviert IS NOT NULL"
)
rows = await cursor.fetchall()
out: dict[str, set[str]] = {}
for topic, baustein in rows:
out.setdefault(topic, set()).add(baustein)
return out
async def delete_baustein_daten(topic: str) -> None:
db = await get_db()
await db.execute("DELETE FROM vertiefungen WHERE topic = ?", (topic,))
await db.execute("DELETE FROM baustein_progress WHERE topic = ?", (topic,))
await db.commit()

View File

@@ -82,14 +82,17 @@ def _topic_context(topic: str, limit: int = 12000) -> str:
return text[:limit] if text else "(kein Material vorhanden)" return text[:limit] if text else "(kein Material vorhanden)"
async def generate_element(topic: str, hint: str, provider: str = DEFAULT_PROVIDER) -> dict: async def generate_element(topic: str, hint: str, provider: str = DEFAULT_PROVIDER, extra_context: str = "") -> dict:
"""Erstellt Element-Felder per KI. Fallback: nur Titel aus dem Stichwort.""" """Erstellt Element-Felder per KI. Fallback: nur Titel aus dem Stichwort."""
fallback = {"title": hint.strip() or "Neues Element", "description": "", "examples": [], "hints": []} fallback = {"title": hint.strip() or "Neues Element", "description": "", "examples": [], "hints": []}
try: try:
context = _topic_context(topic)
if extra_context.strip():
context = (extra_context.strip() + "\n\n" + context)[:12000]
prompt = _prompt( prompt = _prompt(
"Element-Create", "Element-Create",
topic=topic, hint=hint.strip() or "(keins — wähle selbst ein Kernkonzept)", topic=topic, hint=hint.strip() or "(keins — wähle selbst ein Kernkonzept)",
context=_topic_context(topic), context=context,
) )
returncode, stdout, _ = await run_agent( returncode, stdout, _ = await run_agent(
"element-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive" "element-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"

View File

@@ -1,11 +1,14 @@
"""Guide-Generierung: 6 Schritte mit Prüfung nach jeder Phase (OnePager hat einen eigenen Weg). """Guide-Generierung als Konsens-Pipeline (OnePager hat einen eigenen Weg).
Prüf-Agenten notieren nur Probleme; das Anpassen übernimmt der jeweilige Erzeuger-Typ. Auswahl: 5 Agenten (min. 3, Grace) → Code-Voting (Mehrheit = Konsens) →
Mapping-Agent sortiert Strittiges → Klärungs-Loop (max. KONSENS_MAX_RUNDEN).
Gliederung: 5 Vorschläge (min. 3, Grace) → ein Judge wählt und kombiniert.
Schreiben: Writer pro Chunk. Lese-Prüfung: Check→Fix-Loop (max. Runden-Cap),
Folgerunden prüfen nur ersetzte Sections; danach bleiben Beanstandungen stehen.
Schritt-Dateien bleiben liegen → Abbruch erhält Fortschritt, ▶ setzt am offenen Schritt fort. Schritt-Dateien bleiben liegen → Abbruch erhält Fortschritt, ▶ setzt am offenen Schritt fort.
""" """
import asyncio import asyncio
import json
import logging import logging
import math import math
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -13,17 +16,20 @@ from pathlib import Path
from agents import run_agent from agents import run_agent
from bausteine import _pdfs_konvertieren from bausteine import _pdfs_konvertieren
from config import DEFAULT_PROVIDER, FORMAT_ANTEIL, TEMPLATES_DIR from config import (
DEFAULT_PROVIDER, FORMAT_ANTEIL, KONSENS_GRACE, KONSENS_MAX_RUNDEN,
TEMPLATES_DIR,
)
from database import list_guides, update_guide from database import list_guides, update_guide
from fsutil import atomic_write_json, atomic_write_text from fsutil import atomic_write_json
from jsonio import read_json_file as _json_datei from jsonio import read_json_file as _json_datei
from onepager import _generate_onepager from onepager import _generate_onepager
from paths import bausteine_path, guide_content_path, project_dir from paths import bausteine_path, guide_content_path, project_dir
from pipeline import ( from pipeline import (
CANCELLED, FAILED, GenContext, _check_then_fix, _claude_error, _extra, CANCELLED, FAILED, GenContext, _claude_error, _extra,
_fail, _gather_error, _log, _prompt, _race, _semaphore, _set_progress, _fail, _gather_error, _log, _prompt, _race, _rest_schema, _runde_schema,
_set_step, _timeout, clear_guide_cancelled, is_guide_cancelled, _semaphore, _set_progress, _set_step, _timeout, clear_guide_cancelled,
run_single_slot, is_guide_cancelled, run_single_slot,
) )
from textkit import ( from textkit import (
_eindeutige_titel, _lade_bausteine, _parse_fragment, _split_chunks, _eindeutige_titel, _lade_bausteine, _parse_fragment, _split_chunks,
@@ -32,7 +38,7 @@ from textkit import (
log = logging.getLogger("creator.guide") log = logging.getLogger("creator.guide")
GUIDE_STEPS = ("Auswahl", "Auswahl-Prüfung", "Gliederung", "Gliederungs-Prüfung", "Schreiben", "Lese-Prüfung") GUIDE_STEPS = ("Auswahl", "Gliederung", "Schreiben", "Lese-Prüfung")
# Writer skalieren mit der Section-Zahl: 1 Writer je ~30 Sections (gedeckelt). # Writer skalieren mit der Section-Zahl: 1 Writer je ~30 Sections (gedeckelt).
# Kleine Pakete vermeiden Lazy-Output bei langen Listen und begrenzen den Schaden # Kleine Pakete vermeiden Lazy-Output bei langen Listen und begrenzen den Schaden
@@ -43,12 +49,18 @@ WRITER_MAX = 20
def _guide_files(content_path: Path) -> dict: def _guide_files(content_path: Path) -> dict:
d, stem = content_path.parent, content_path.stem d, stem = content_path.parent, content_path.stem
runden = range(1, KONSENS_MAX_RUNDEN + 1)
return { return {
"auswahl": d / f"{stem}.auswahl.json", # Runde 1: 5 volle Auswahl-Vorschläge; Runden 2+: 3 Klärungs-Voten
"auswahl_check": d / f"{stem}.auswahl-check.json", "auswahl_slots": {
"gliederung": d / f"{stem}.gliederung.json", n: [d / f"{stem}.auswahl-r{n}-{i}.json" for i in range(1, (5 if n == 1 else 3) + 1)]
"gliederung_check": d / f"{stem}.gliederung-check.json", for n in runden
# chunk-/lese-check-/fix-Dateien sind dynamisch: {stem}.chunk-i.md usw. },
"auswahl_mapping": {n: d / f"{stem}.auswahl-mapping-r{n}.json" for n in runden},
"gliederung_slots": [d / f"{stem}.gliederung-{i}.json" for i in (1, 2, 3, 4, 5)],
"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
} }
@@ -131,6 +143,221 @@ def _resolve_gliederung(data, entries: dict[int, str], soll_min: int, soll_max:
return chapters return chapters
def _voting(stimmen: list[list[int]]) -> tuple[list[int], dict[int, int]]:
"""Mehrheit (> Hälfte der Stimmen) → Konsens; ≥1 Stimme → Rest mit Votenzahl."""
zaehler: dict[int, int] = {}
for stimme in stimmen:
for num in stimme:
zaehler[num] = zaehler.get(num, 0) + 1
konsens = sorted(num for num, v in zaehler.items() if v > len(stimmen) / 2)
rest = {num: v for num, v in sorted(zaehler.items()) if v <= len(stimmen) / 2}
return konsens, rest
def _resolve_uebernehmen(data, entries: dict[int, str]) -> list[int] | None:
"""{"uebernehmen": [Titel]} → Nummern; leer gültig; >15 % unauflösbar → None."""
titel = _rest_schema(data)
if titel is None:
return None
if not titel:
return []
idx = _titel_index(entries)
nums: list[int] = []
seen: set[int] = set()
unknown = 0
for t in titel:
num = _titel_aufloesen(idx, t)
if num is None:
unknown += 1
elif num not in seen:
seen.add(num)
nums.append(num)
if unknown / len(titel) > 0.15:
return None
return nums
def _resolve_runde(data, entries: dict[int, str], konsens: list[int], k_min: int, k_max: int, final: bool) -> tuple[list[int], list[int]] | None:
"""Auswahl-Mapping-Runde auflösen — erzwingt die Zielgrößen-Grenzen schema-seitig.
Immer: Konsens + Aufnehmen + Rest muss 0.9*k_min erreichen können (sonst
wäre die Mindestgröße in späteren Runden unerreichbar). Aufnehmen über
1.1*k_max hinaus ist ungültig; final erzwingt zusätzlich leeren Rest und
die Mindestgröße. Ein bereits zu großer Konsens allein ist kein Fehler —
der Agent kann dann nichts mehr aufnehmen.
"""
res = _runde_schema(data, final=final)
if res is None:
return None
idx = _titel_index(entries)
bekannt = set(konsens)
listen: list[list[int]] = []
for titel_liste in res:
nums: list[int] = []
unknown = 0
for t in titel_liste:
num = _titel_aufloesen(idx, t)
if num is None:
unknown += 1
elif num not in bekannt:
bekannt.add(num)
nums.append(num)
if titel_liste and unknown / len(titel_liste) > 0.15:
return None
listen.append(nums)
aufnehmen, rest = listen
gesamt = len(konsens) + len(aufnehmen)
if aufnehmen and gesamt > 1.1 * k_max:
return None
if gesamt + len(rest) < 0.9 * k_min:
return None
if (final or not rest) and gesamt < 0.9 * k_min:
return None
return aufnehmen, rest
async def _konsens_auswahl(
ctx: GenContext, files: dict, entries: dict[int, str],
k_min: int, k_max: int, auswahl_auftrag: str, format_name: str,
bausteine_liste: str, instructions: str,
) -> list[int] | None:
"""Schritt 0: 5 Auswahl-Agenten → Code-Voting → Mapping → Klärungs-Loop.
Rückgabe: finale Baustein-Nummern; None = Fehler/Abbruch (bereits gemeldet).
"""
guide_id, topic, provider = ctx.guide_id, ctx.topic, ctx.provider
is_cancelled = ctx.is_cancelled
n = len(entries)
def titel_liste(nums) -> str:
return "\n".join(f"- {_titel(entries[num])}" for num in nums)
konsens: list[int] = []
rest: list[int] = []
runde = 0
while True:
runde += 1
final_runde = runde == KONSENS_MAX_RUNDEN
# Voten der Runde einsammeln — Slot-Dateien zuerst (Resume), Rest per Race
if runde == 1:
await _set_step(guide_id, 0, "Wähle Bausteine (5 Vorschläge)…")
stimmen: list[list[int]] = []
offen = []
for i, path in enumerate(files["auswahl_slots"][1], 1):
res = _resolve_auswahl(_json_datei(path), entries, k_min, k_max)
if res is not None:
stimmen.append(res)
else:
offen.append((i, path))
if len(stimmen) < 3:
slots = [
{
"key": f"{guide_id}-auswahl-r1-{i}",
"prompt": _prompt(
"Guide-Auswahl",
topic=topic, format_name=format_name, bausteine=bausteine_liste,
auswahl_auftrag=auswahl_auftrag, out_path=path, extra=_extra(instructions),
),
"role": "guide", "capabilities": "files",
"payload": (lambda result, p=path: _resolve_auswahl(_json_datei(p), entries, k_min, k_max)),
}
for i, path in offen
]
neue = await _race(
topic, "Guide-Auswahl", slots, 3 - len(stimmen), _timeout("guide_auswahl", n),
provider, cancelled=is_cancelled, grace=KONSENS_GRACE,
)
if is_cancelled():
return None
if neue is None:
await _fail(guide_id, "Auswahl fehlgeschlagen (Minimum nicht erreicht)")
return None
stimmen += neue
konsens, voten = _voting(stimmen)
rest = list(voten)
stimmen_n = len(stimmen)
else:
await _set_step(guide_id, 0, f"Kläre strittige Bausteine (Runde {runde}/{KONSENS_MAX_RUNDEN})…")
entscheidungen: list[list[int]] = []
offen = []
for i, path in enumerate(files["auswahl_slots"][runde], 1):
res = _resolve_uebernehmen(_json_datei(path), entries)
if res is not None:
entscheidungen.append(res)
else:
offen.append((i, path))
if len(entscheidungen) < 2:
slots = [
{
"key": f"{guide_id}-auswahl-r{runde}-{i}",
"prompt": _prompt(
"Guide-Klaerung",
topic=topic, format_name=format_name, auswahl_auftrag=auswahl_auftrag,
konsens=titel_liste(konsens) or "- (leer)", rest=titel_liste(rest),
out_path=path, extra=_extra(instructions),
),
"role": "fast", "capabilities": "files",
"payload": (lambda result, p=path: _resolve_uebernehmen(_json_datei(p), entries)),
}
for i, path in offen
]
neue = await _race(
topic, f"Guide-Klärung r{runde}", slots, 2 - len(entscheidungen),
_timeout("auswahl", len(rest)), provider, cancelled=is_cancelled, grace=KONSENS_GRACE,
)
if is_cancelled():
return None
if neue is None:
await _fail(guide_id, f"Auswahl fehlgeschlagen (Runde {runde}, Minimum nicht erreicht)")
return None
entscheidungen += neue
voten = {num: sum(1 for e in entscheidungen if num in e) for num in rest}
stimmen_n = len(entscheidungen)
# Mapping-Agent sortiert die strittigen Voten — gültige Datei = Resume
mapping_path = files["auswahl_mapping"][runde]
ergebnis = _resolve_runde(_json_datei(mapping_path), entries, konsens, k_min, k_max, final_runde)
if ergebnis is None:
mapping_path.unlink(missing_ok=True)
voten_block = "\n".join(
f"{i}. {_titel(entries[num])} (von {voten[num]}/{stimmen_n} Agenten gewählt)"
for i, num in enumerate(rest, 1)
) or "- (keine)"
final_zusatz = (
"\n- LETZTE RUNDE: Es gibt keine weitere Runde. `rest` MUSS leer sein"
" — entscheide JEDEN Eintrag selbst: aufnehmen oder verwerfen."
if final_runde else ""
)
status, ergebnis = await run_single_slot(
ctx, f"Auswahl-Mapping r{runde}",
key=f"{guide_id}-auswahl-mapping-r{runde}",
prompt=_prompt(
"Guide-Auswahl-Mapping",
topic=topic, format_name=format_name, n=stimmen_n,
auswahl_auftrag=auswahl_auftrag, konsens_n=len(konsens),
k_min=k_min, k_max=k_max,
konsens=titel_liste(konsens) or "- (leer)", rest=voten_block,
final=final_zusatz, out_path=mapping_path,
),
role="judge", capabilities="files",
payload=lambda result, p=mapping_path, k=tuple(konsens), f=final_runde:
_resolve_runde(_json_datei(p), entries, list(k), k_min, k_max, f),
timeout=_timeout("auswahl_mapping", len(konsens) + len(rest)),
)
if status == CANCELLED:
return None
if status == FAILED:
await _fail(guide_id, f"Auswahl-Mapping fehlgeschlagen (Runde {runde})")
return None
aufnehmen, rest = ergebnis
konsens = konsens + aufnehmen
_log(topic, f"Auswahl Runde {runde}: {len(aufnehmen)} aufgenommen, {len(rest)} strittig, Konsens {len(konsens)}")
if not rest or final_runde:
return konsens
async def _generate_sections( async def _generate_sections(
guide_id: str, topic: str, format_name: str, entries: dict[int, str], guide_id: str, topic: str, format_name: str, entries: dict[int, str],
facts: str, instructions: str, provider: str, facts: str, instructions: str, provider: str,
@@ -152,134 +379,83 @@ async def _generate_sections(
"Wähle, was diesem Zweck dient — lass weg, was dafür nicht nötig ist." "Wähle, was diesem Zweck dient — lass weg, was dafür nicht nötig ist."
) )
# Schritt 1: Auswahl — vorhandene gültige Datei wird übernommen (Resume) # Schritt 0: Auswahl-Konsens (5 Agenten → Voting → Mapping → Klärungs-Loop)
auswahl = _resolve_auswahl(_json_datei(files["auswahl"]), entries, k_min, k_max) auswahl = await _konsens_auswahl(
ctx, files, entries, k_min, k_max, auswahl_auftrag, format_name,
bausteine_liste, instructions,
)
if auswahl is None: if auswahl is None:
await _set_step(guide_id, 0, "Wähle Bausteine…")
files["auswahl"].unlink(missing_ok=True)
status, auswahl = await run_single_slot(
ctx, "Guide-Auswahl",
key=f"{guide_id}-auswahl",
prompt=_prompt(
"Guide-Auswahl",
topic=topic, format_name=format_name, bausteine=bausteine_liste,
auswahl_auftrag=auswahl_auftrag, out_path=files["auswahl"], extra=_extra(instructions),
),
role="guide", capabilities="files",
payload=lambda result: _resolve_auswahl(_json_datei(files["auswahl"]), entries, k_min, k_max),
timeout=_timeout("guide_auswahl", n),
)
if status == CANCELLED:
return None return None
if status == FAILED:
await _fail(guide_id, "Auswahl fehlgeschlagen")
return None
def auswahl_titel() -> str:
return "\n".join(f"- {_titel(entries[num])}" for num in auswahl)
def auswahl_json() -> str:
return json.dumps({"bausteine": [_titel(entries[num]) for num in auswahl]}, ensure_ascii=False)
# Schritt 2: Auswahl-Prüfung — notiert Probleme; Anpassung macht ein Auswahl-Agent
status, fixed = await _check_then_fix(
ctx, name="Auswahl", step=1,
check_key=f"{guide_id}-auswahl-check",
check_prompt=_prompt(
"Guide-Auswahl-Check",
topic=topic, format_name=format_name, auswahl_auftrag=auswahl_auftrag,
bausteine=bausteine_liste, auswahl=auswahl_titel(),
out_path=files["auswahl_check"], extra=_extra(instructions),
),
check_path=files["auswahl_check"], check_timeout=_timeout("guide_check", len(auswahl)),
fix_key=f"{guide_id}-auswahl-fix",
build_fix_prompt=lambda probleme: _prompt(
"Guide-Auswahl-Fix",
topic=topic, format_name=format_name, auswahl_auftrag=auswahl_auftrag,
bausteine=bausteine_liste, auswahl=auswahl_titel(),
probleme="\n".join(f"- {p}" for p in probleme),
out_path=files["auswahl"], extra=_extra(instructions),
),
fix_payload=lambda result: _resolve_auswahl(_json_datei(files["auswahl"]), entries, k_min, k_max),
fix_timeout=_timeout("guide_auswahl", n), fix_role="guide",
on_fix_invalid=lambda: atomic_write_text(files["auswahl"], auswahl_json()),
)
if status == CANCELLED:
return None
if status == FAILED:
await _fail(guide_id, "Auswahl-Prüfung fehlgeschlagen")
return None
if fixed is not None:
auswahl = fixed
sel_entries = {num: entries[num] for num in auswahl} sel_entries = {num: entries[num] for num in auswahl}
soll = len(sel_entries) soll = len(sel_entries)
sel_liste = "\n".join(f"- {t}" for t in sel_entries.values()) sel_liste = "\n".join(f"- {t}" for t in sel_entries.values())
# Schritt 3: Gliederung der festen Auswahl # Schritt 1: Gliederung — 5 Vorschläge (min. 3, Grace), ein Judge wählt.
# Gültiges gliederung.json (auch aus Altläufen) überspringt den Schritt.
plan = _resolve_gliederung(_json_datei(files["gliederung"]), sel_entries, soll, soll) plan = _resolve_gliederung(_json_datei(files["gliederung"]), sel_entries, soll, soll)
if plan is None: if plan is None:
await _set_step(guide_id, 2, "Plane Gliederung…") await _set_step(guide_id, 1, "Gliederungs-Vorschläge (5 Agenten)")
files["gliederung"].unlink(missing_ok=True) files["gliederung"].unlink(missing_ok=True)
status, plan = await run_single_slot( vorschlaege: list[list[dict]] = []
ctx, "Gliederung", offen = []
key=f"{guide_id}-gliederung", for i, path in enumerate(files["gliederung_slots"], 1):
prompt=_prompt( 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:
slots = [
{
"key": f"{guide_id}-gliederung-{i}",
"prompt": _prompt(
"Guide-Gliederung", "Guide-Gliederung",
topic=topic, format_name=format_name, bausteine=sel_liste, 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
]
neue = await _race(
topic, "Gliederung", slots, 3 - len(vorschlaege), _timeout("plan", soll),
provider, cancelled=is_cancelled, grace=KONSENS_GRACE,
)
if is_cancelled():
return None
if neue is None:
await _fail(guide_id, "Gliederung fehlgeschlagen (Minimum nicht erreicht)")
return None
vorschlaege += neue
await _set_step(guide_id, 1, "Wähle beste Gliederung…")
bloecke = "\n\n".join(
f"### Vorschlag {i}\n"
+ "\n".join(_zuteilung_text([ch], {num: _titel(entries[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), out_path=files["gliederung"], extra=_extra(instructions),
), ),
role="guide", capabilities="files", role="judge", capabilities="files",
payload=lambda result: _resolve_gliederung(_json_datei(files["gliederung"]), sel_entries, soll, soll), payload=lambda result: _resolve_gliederung(_json_datei(files["gliederung"]), sel_entries, soll, soll),
timeout=_timeout("plan", soll), timeout=_timeout("plan_judge", soll),
) )
if status == CANCELLED: if status == CANCELLED:
return None return None
if status == FAILED: if status == FAILED:
await _fail(guide_id, "Gliederung fehlgeschlagen") await _fail(guide_id, "Gliederung fehlgeschlagen (Judge ohne gültiges Ergebnis)")
return None return None
def gliederung_text() -> str: # Schritt 2: Schreiben — vorhandene Chunk-Dateien werden übernommen (Resume)
return "\n".join(_zuteilung_text([ch], {num: _titel(entries[num]) for num in ch["nums"]}) for ch in plan)
def gliederung_json() -> str:
return json.dumps(
{"kapitel": [{"titel": ch["title"], "bausteine": [_titel(entries[num]) for num in ch["nums"]]} for ch in plan]},
ensure_ascii=False,
)
# Schritt 4: Gliederungs-Prüfung
status, fixed = await _check_then_fix(
ctx, name="Gliederung", step=3,
check_key=f"{guide_id}-gliederung-check",
check_prompt=_prompt(
"Guide-Gliederung-Check",
topic=topic, format_name=format_name, zweck=zweck,
auswahl=auswahl_titel(), gliederung=gliederung_text(),
out_path=files["gliederung_check"], extra=_extra(instructions),
),
check_path=files["gliederung_check"], check_timeout=_timeout("guide_check", soll),
fix_key=f"{guide_id}-gliederung-fix",
build_fix_prompt=lambda probleme: _prompt(
"Guide-Gliederung-Fix",
topic=topic, format_name=format_name,
auswahl=auswahl_titel(), gliederung=gliederung_text(),
probleme="\n".join(f"- {p}" for p in probleme),
out_path=files["gliederung"], extra=_extra(instructions),
),
fix_payload=lambda result: _resolve_gliederung(_json_datei(files["gliederung"]), sel_entries, soll, soll),
fix_timeout=_timeout("plan", soll), fix_role="guide",
on_fix_invalid=lambda: atomic_write_text(files["gliederung"], gliederung_json()),
)
if status == CANCELLED:
return None
if status == FAILED:
await _fail(guide_id, "Gliederungs-Prüfung fehlgeschlagen")
return None
if fixed is not None:
plan = fixed
# Schritt 5: Schreiben — vorhandene Chunk-Dateien werden übernommen (Resume)
total_sections = sum(len(c["nums"]) for c in plan) total_sections = sum(len(c["nums"]) for c in plan)
chunks = _split_chunks(plan, min(WRITER_MAX, max(1, math.ceil(total_sections / WRITER_SECTIONS)))) chunks = _split_chunks(plan, min(WRITER_MAX, max(1, math.ceil(total_sections / WRITER_SECTIONS))))
zuteilungen = [_zuteilung_text(chunk, entries) for chunk in chunks] zuteilungen = [_zuteilung_text(chunk, entries) for chunk in chunks]
@@ -288,7 +464,7 @@ async def _generate_sections(
paths = [content_path.parent / f"{content_path.stem}.chunk-{i}.md" for i in range(1, writer_count + 1)] paths = [content_path.parent / f"{content_path.stem}.chunk-{i}.md" for i in range(1, writer_count + 1)]
offen = [i for i, p in enumerate(paths) if not p.exists()] offen = [i for i, p in enumerate(paths) if not p.exists()]
if offen: if offen:
await _set_step(guide_id, 4, f"Schreibe Sections ({writer_count} Writer)…" if writer_count > 1 else "Schreibe Sections…") await _set_step(guide_id, 2, f"Schreibe Sections ({writer_count} Writer)…" if writer_count > 1 else "Schreibe Sections…")
results = await asyncio.gather(*[ results = await asyncio.gather(*[
run_agent( run_agent(
f"{guide_id}-w{i + 1}", f"{guide_id}-w{i + 1}",
@@ -329,61 +505,69 @@ async def _generate_sections(
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
# Schritt 6: Lese-Prüfung pro Writer-Paket Fix beauftragt Writer nur mit beanstandeten Sections # 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] chunk_nums = [[num for ch in chunk for num in ch["nums"] if num in by_num] for chunk in chunks]
check_paths = [content_path.parent / f"{content_path.stem}.lese-check-{i}.json" for i in range(1, writer_count + 1)]
offen_checks = [i for i, p in enumerate(check_paths) if _lese_probleme_schema(_json_datei(p)) is None and chunk_nums[i]]
if offen_checks:
await _set_step(guide_id, 5, f"Prüfe Lesbarkeit ({len(offen_checks)} Prüfer)…" if len(offen_checks) > 1 else "Prüfe Lesbarkeit…")
def sections_text(nums: list[int]) -> str: 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) return "\n\n".join(f"SECTION: {_titel(entries[num])}\n{by_num[num]['md']}" for num in nums)
slots = [{ def auftraege_text(nums: list[int], probleme: dict[int, str]) -> str:
"key": f"{guide_id}-lese-check-{i + 1}",
"prompt": _prompt(
"Guide-Lese-Check",
topic=topic, format_name=format_name, spec=spec,
sections=sections_text(chunk_nums[i]),
out_path=check_paths[i], extra=_extra(instructions),
),
"role": "fast", "capabilities": "files",
"payload": (lambda result, p=check_paths[i]: _lese_probleme_schema(_json_datei(p))),
} for i in offen_checks]
res = await _race(topic, "Lese-Prüfung", slots, len(slots), _timeout("lese_check", max(chunk_sizes)), provider, cancelled=is_cancelled)
if is_cancelled():
return None
if res is None:
await _fail(guide_id, "Lese-Prüfung fehlgeschlagen")
return None
probleme_by_num: dict[int, str] = {}
for p in check_paths:
for item in (_lese_probleme_schema(_json_datei(p)) or []):
num = _titel_aufloesen(idx, item["section"])
if num in by_num and num not in probleme_by_num:
probleme_by_num[num] = item["problem"]
if probleme_by_num:
_log(topic, f"Lese-Prüfung: {len(probleme_by_num)} Section(s) beanstandet")
await _set_step(guide_id, 5, f"Überarbeite {len(probleme_by_num)} Section(s)…")
fix_chunks = [[num for num in nums if num in probleme_by_num] for nums in chunk_nums]
fix_offen = [i for i, nums in enumerate(fix_chunks) if nums]
fix_paths = [content_path.parent / f"{content_path.stem}.fix-{i + 1}.md" for i in range(writer_count)]
def auftraege_text(nums: list[int]) -> str:
return "\n\n".join( return "\n\n".join(
f"SECTION: {_titel(entries[num])}\nPROBLEM: {probleme_by_num[num]}\nAKTUELLER INHALT:\n{by_num[num]['md']}" f"SECTION: {_titel(entries[num])}\nPROBLEM: {probleme[num]}\nAKTUELLER INHALT:\n{by_num[num]['md']}"
for num in nums for num in nums
) )
scope = chunk_nums
for runde in range(1, KONSENS_MAX_RUNDEN + 1):
check_paths = [content_path.parent / f"{content_path.stem}.lese-check-r{runde}-{i}.json" for i in range(1, writer_count + 1)]
offen_checks = [i for i, p in enumerate(check_paths) if scope[i] and _lese_probleme_schema(_json_datei(p)) is None]
if offen_checks:
await _set_step(guide_id, 3, f"Prüfe Lesbarkeit (Runde {runde}/{KONSENS_MAX_RUNDEN})…")
slots = [{
"key": f"{guide_id}-lese-check-r{runde}-{i + 1}",
"prompt": _prompt(
"Guide-Lese-Check",
topic=topic, format_name=format_name, spec=spec,
sections=sections_text(scope[i]),
out_path=check_paths[i], extra=_extra(instructions),
),
"role": "judge", "capabilities": "files",
"payload": (lambda result, p=check_paths[i]: _lese_probleme_schema(_json_datei(p))),
} for i in offen_checks]
res = await _race(topic, f"Lese-Prüfung r{runde}", slots, len(slots), _timeout("lese_check", max(chunk_sizes)), provider, cancelled=is_cancelled)
if is_cancelled():
return None
if res is None:
if runde == 1:
await _fail(guide_id, "Lese-Prüfung fehlgeschlagen")
return None
_log(topic, f"Lese-Prüfung Runde {runde} fehlgeschlagen — Stand bleibt")
break
probleme_by_num: dict[int, str] = {}
for i, p in enumerate(check_paths):
geltung = set(scope[i])
for item in (_lese_probleme_schema(_json_datei(p)) or []):
num = _titel_aufloesen(idx, item["section"])
if num in geltung and num in by_num and num not in probleme_by_num:
probleme_by_num[num] = item["problem"]
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, 3, 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(*[ results = await asyncio.gather(*[
run_agent( run_agent(
f"{guide_id}-fix-w{i + 1}", f"{guide_id}-fix-r{runde}-w{i + 1}",
_prompt( _prompt(
"Guide-Sections-Fix", "Guide-Sections-Fix",
topic=topic, format_name=format_name, facts=facts, spec=spec, topic=topic, format_name=format_name, facts=facts, spec=spec,
auftraege=auftraege_text(fix_chunks[i]), auftraege=auftraege_text(fix_chunks[i], probleme_by_num),
out_path=fix_paths[i], extra=_extra(instructions), out_path=fix_paths[i], extra=_extra(instructions),
), ),
_timeout("writer", len(fix_chunks[i])), provider=provider, role="guide", capabilities="full", _timeout("writer", len(fix_chunks[i])), provider=provider, role="guide", capabilities="full",
@@ -394,17 +578,23 @@ async def _generate_sections(
return None return None
for i, r in zip(fix_offen, results): for i, r in zip(fix_offen, results):
if isinstance(r, BaseException) or (not isinstance(r, BaseException) and r[0] != 0): if isinstance(r, BaseException) or (not isinstance(r, BaseException) and r[0] != 0):
_log(topic, f"Sections-Fix {i + 1} fehlgeschlagen — Original bleibt") _log(topic, f"Sections-Fix {i + 1} (Runde {runde}) fehlgeschlagen — Original bleibt")
ersetzt = 0 ersetzt: set[int] = set()
for i in fix_offen: for p in fix_paths:
if not fix_paths[i].exists(): if not p.exists():
continue continue
for sec in _parse_fragment(fix_paths[i].read_text(encoding="utf-8")): for sec in _parse_fragment(p.read_text(encoding="utf-8")):
num = _titel_aufloesen(idx, sec["titel"]) num = _titel_aufloesen(idx, sec["titel"])
if num in probleme_by_num and sec["md"].strip(): if num in probleme_by_num and sec["md"].strip():
by_num[num] = sec by_num[num] = sec
ersetzt += 1 ersetzt.add(num)
_log(topic, f"Lese-Prüfung: {ersetzt} Section(s) überarbeitet") _log(topic, f"Lese-Prüfung Runde {runde}: {len(ersetzt)} Section(s) überarbeitet")
if not ersetzt:
break
if runde == KONSENS_MAX_RUNDEN:
_log(topic, f"Lese-Prüfung: Cap erreicht — letzte Überarbeitung bleibt ungeprüft")
break
scope = [[num for num in nums if num in ersetzt] for nums in chunk_nums]
await _set_progress(guide_id, "Setze zusammen…") await _set_progress(guide_id, "Setze zusammen…")
chapters: list[dict] = [] chapters: list[dict] = []

132
backend/lernen.py Normal file
View File

@@ -0,0 +1,132 @@
"""Baustein-Lernen: Vertiefung, Bausteinchat und Prüfung zu einzelnen Guide-Sections.
Alle Aufrufe sind interaktiv (stdout-Antwort, lane "interactive") und stateless —
der Chat-/Prüfungs-Verlauf kommt vom Frontend, persistiert wird nur der
Prüfungs-Zähler (DB) und die Vertiefung (DB).
"""
import logging
import uuid
from datetime import datetime, timezone
from agents import run_agent
from config import DEFAULT_PROVIDER
from database import create_element, list_elements
from elements import generate_element
from jsonio import parse_json_text as _parse_json_text
from pipeline import _prompt
from textkit import _norm_titel
log = logging.getLogger("creator.lernen")
NOETIG = 3 # gute Antworten bis "absolviert"
VERTIEFUNG_TIMEOUT = 600
CHAT_TIMEOUT = 240
def _transcript(messages: list[dict]) -> str:
return "\n".join(
f"{'Nutzer' if m.get('role') == 'user' else 'Assistent'}: {m.get('content', '')}"
for m in messages
) or "(leer)"
async def vertiefung_generieren(topic: str, baustein: str, section: str, provider: str = DEFAULT_PROVIDER) -> str | None:
"""Ausführliche Fassung des Bausteins als Markdown · None bei Fehler."""
try:
prompt = _prompt(
"Baustein-Vertiefung",
topic=topic, baustein=baustein,
section_block=section.strip() or "(keine Guide-Fassung übergeben)",
)
returncode, stdout, _ = await run_agent(
"vertiefung-" + str(uuid.uuid4()), prompt, VERTIEFUNG_TIMEOUT,
provider=provider, role="fast", capabilities="none", lane="interactive",
)
if returncode != 0:
return None
return stdout.strip() or None
except Exception:
log.warning("[%s] Vertiefung fehlgeschlagen (%s)", topic, baustein, exc_info=True)
return None
async def baustein_chat(topic: str, baustein: str, section: str, vertiefung: str | None, messages: list[dict], provider: str = DEFAULT_PROVIDER) -> str:
try:
prompt = _prompt(
"Baustein-Chat",
topic=topic, baustein=baustein,
section_block=section.strip() or "(keine Guide-Fassung übergeben)",
vertiefung_block=(vertiefung or "").strip() or "(keine)",
transcript=_transcript(messages),
)
returncode, stdout, _ = await run_agent(
"bausteinchat-" + str(uuid.uuid4()), prompt, CHAT_TIMEOUT,
provider=provider, role="fast", capabilities="none", lane="interactive",
)
if returncode != 0:
return "Entschuldigung, das hat nicht geklappt. Bitte versuche es erneut."
reply = stdout.strip()
return reply or "Entschuldigung, ich habe keine Antwort erhalten."
except Exception:
log.warning("[%s] Baustein-Chat fehlgeschlagen (%s)", topic, baustein, exc_info=True)
return "Entschuldigung, das hat nicht geklappt. Bitte versuche es erneut."
def _pruefung_schema(data) -> dict | None:
"""{"reply": str, "bewertung": "gut"|"schlecht"|None, "bestanden": bool} · sonst None."""
if not isinstance(data, dict):
return None
reply = str(data.get("reply", "")).strip()
bewertung = data.get("bewertung")
if not reply or bewertung not in ("gut", "schlecht", None):
return None
return {"reply": reply, "bewertung": bewertung, "bestanden": data.get("bestanden") is True}
async def baustein_pruefung(
topic: str, baustein: str, section: str, vertiefung: str | None,
messages: list[dict], gute_antworten: int, provider: str = DEFAULT_PROVIDER,
) -> dict | None:
"""Ein Prüfungs-Turn: Frage stellen bzw. letzte Antwort bewerten · None bei Fehler."""
try:
prompt = _prompt(
"Baustein-Pruefung",
topic=topic, baustein=baustein,
section_block=section.strip() or "(keine Guide-Fassung übergeben)",
vertiefung_block=(vertiefung or "").strip() or "(keine)",
transcript=_transcript(messages) if messages else "(leer)",
gute_antworten=gute_antworten, noetig=NOETIG,
)
# role "judge": Bewertungen brauchen das starke, kalte Modell —
# M2.7 hat in der Praxis gegen die eigene Referenz halluziniert.
returncode, stdout, _ = await run_agent(
"pruefung-" + str(uuid.uuid4()), prompt, CHAT_TIMEOUT,
provider=provider, role="judge", capabilities="none", lane="interactive",
)
if returncode != 0:
return None
return _pruefung_schema(_parse_json_text(stdout))
except Exception:
log.warning("[%s] Prüfung fehlgeschlagen (%s)", topic, baustein, exc_info=True)
return None
async def baustein_element_anlegen(topic: str, baustein: str, section: str, provider: str = DEFAULT_PROVIDER) -> None:
"""Hintergrund-Task nach dem Absolvieren: Baustein als Element anlegen.
Dedup über normalisierte Titel — existiert schon ein Element zum Baustein,
passiert nichts. Darf nie eine Exception nach außen werfen.
"""
try:
vorhanden = {_norm_titel(e["title"]) for e in await list_elements(topic)}
if _norm_titel(baustein) in vorhanden:
return
fields = await generate_element(topic, hint=baustein, provider=provider, extra_context=section)
if _norm_titel(fields["title"]) in vorhanden:
return
now = datetime.now(timezone.utc).isoformat()
await create_element({"id": str(uuid.uuid4()), "topic": topic, **fields, "created_at": now, "updated_at": now})
log.info("[%s] Baustein als Element angelegt: %s", topic, fields["title"])
except Exception:
log.warning("[%s] Element-Anlage nach Prüfung fehlgeschlagen (%s)", topic, baustein, exc_info=True)

View File

@@ -8,7 +8,7 @@ FormatType = Literal[
"FullGuide", "FullGuide",
] ]
ProviderType = Literal["claude", "minimax", "minimax-direkt", "lokal"] ProviderType = Literal["claude", "minimax", "lokal"]
class GuideCreateRequest(BaseModel): class GuideCreateRequest(BaseModel):
@@ -156,3 +156,53 @@ class ProgressUpdate(BaseModel):
class ProgressResponse(BaseModel): class ProgressResponse(BaseModel):
chapters: list[str] chapters: list[str]
# --- Baustein-Lernen ---
class VertiefungRequest(BaseModel):
topic: str = Field(min_length=1, max_length=100)
baustein: str = Field(min_length=1, max_length=200)
section: str = Field(default="", max_length=20000)
provider: ProviderType = "claude"
class VertiefungResponse(BaseModel):
md: str
class BausteinChatRequest(BaseModel):
topic: str = Field(min_length=1, max_length=100)
baustein: str = Field(min_length=1, max_length=200)
section: str = Field(default="", max_length=20000)
messages: list[ChatMessage] = Field(min_length=1)
provider: ProviderType = "claude"
class BausteinChatResponse(BaseModel):
reply: str
class BausteinPruefungRequest(BaseModel):
topic: str = Field(min_length=1, max_length=100)
baustein: str = Field(min_length=1, max_length=200)
section: str = Field(default="", max_length=20000)
messages: list[ChatMessage] = [] # leer = KI stellt die erste Frage
provider: ProviderType = "claude"
class BausteinPruefungResponse(BaseModel):
reply: str
bewertung: Literal["gut", "schlecht"] | None = None
gute_antworten: int
absolviert: bool
class BausteinLernstand(BaseModel):
gute_antworten: int
absolviert: bool
vertiefung: bool
class BausteinLernstandResponse(BaseModel):
bausteine: dict[str, BausteinLernstand]

View File

@@ -1,20 +1,30 @@
"""OnePager-Pipeline: Recherche → Recherche-Prüfung → Bauen → Prüfung (7 Karten im 3×3-Raster).""" """OnePager-Pipeline als Konsens-Kette (7 Karten im 3×3-Raster).
Recherche: 3 Agenten (min. 2, Grace) → Mapping konsolidiert zu EINER Faktenbasis.
Bauen: 3 Agenten bauen je einen Karten-Satz → ein Judge wählt und kombiniert.
Prüfung: Verify→Fix-Loop (max. KONSENS_MAX_RUNDEN); Runde 1 ist fatal, danach
bleibt bei Fehlern die letzte gültige Version. Schritt-Dateien bleiben liegen →
Abbruch erhält Fortschritt, ▶ setzt am offenen Schritt fort.
"""
from pathlib import Path from pathlib import Path
from fsutil import atomic_write_json from config import KONSENS_GRACE, KONSENS_MAX_RUNDEN
from jsonio import read_json_file as _json_datei from jsonio import read_json_file as _json_datei
from pipeline import ( from pipeline import (
CANCELLED, FAILED, GenContext, _check_then_fix, _extra, _fail, CANCELLED, FAILED, GenContext, _extra, _fail, _log, _probleme_schema,
_prompt, _set_step, _timeout, is_guide_cancelled, run_single_slot, _prompt, _race, _set_step, _timeout, is_guide_cancelled, run_single_slot,
) )
ONEPAGER_STEPS = ("Recherche", "Bauen", "Prüfung")
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, project: Path | None, content_path: Path,
) -> list[dict] | None: ) -> list[dict] | None:
ctx = GenContext(topic=topic, provider=provider, is_cancelled=lambda: is_guide_cancelled(guide_id), guide_id=guide_id) is_cancelled = lambda: is_guide_cancelled(guide_id)
ctx = GenContext(topic=topic, provider=provider, is_cancelled=is_cancelled, guide_id=guide_id)
# 3×3-Raster: 7 Karten mit festen Schlüsseln (Reihenfolge = Lesereihenfolge mobil) # 3×3-Raster: 7 Karten mit festen Schlüsseln (Reihenfolge = Lesereihenfolge mobil)
KARTEN_KEYS = ("info", "eigenschaften", "beispiel", "zusammenhaenge", "voraussetzungen", "modern", "veraltet") KARTEN_KEYS = ("info", "eigenschaften", "beispiel", "zusammenhaenge", "voraussetzungen", "modern", "veraltet")
@@ -38,116 +48,193 @@ async def _generate_onepager(
return out return out
d, stem = content_path.parent, content_path.stem d, stem = content_path.parent, content_path.stem
recherche_path = d / f"{stem}.recherche.md" recherche_slots = [d / f"{stem}.recherche-{i}.md" for i in (1, 2, 3)]
recherche_check_path = d / f"{stem}.recherche-check.json" recherche_path = d / f"{stem}.recherche.md" # konsolidierte Faktenbasis
karten_path = d / f"{stem}.karten.json" karten_slots = [d / f"{stem}.karten-{i}.json" for i in (1, 2, 3)]
check_path = d / f"{stem}.onepager-check.json" karten_path = d / f"{stem}.karten.json" # Judge-Ausgabe
verify_paths = {n: d / f"{stem}.verify-r{n}.json" for n in range(1, KONSENS_MAX_RUNDEN + 1)}
fix_paths = {n: d / f"{stem}.karten-fix-r{n}.json" for n in range(1, KONSENS_MAX_RUNDEN + 1)}
# Projekte bekommen eigene Recherche-Dimensionen — Produkt-Fragen # Projekte bekommen eigene Recherche-Dimensionen — Produkt-Fragen
# (Version, Lizenz, Alternativen) laufen dort ins Leere. # (Version, Lizenz, Alternativen) laufen dort ins Leere.
if project: if project:
source = _prompt("OnePager-Quelle-Projekt", project=project) source = _prompt("OnePager-Quelle-Projekt", project=project)
recherche_template = "OnePager-Recherche-Projekt" recherche_template = "OnePager-Recherche-Projekt"
recherche_check_template = "OnePager-Recherche-Check-Projekt"
else: else:
source = _prompt("OnePager-Quelle-Thema", topic=topic) source = _prompt("OnePager-Quelle-Thema", topic=topic)
recherche_template = "OnePager-Recherche" recherche_template = "OnePager-Recherche"
recherche_check_template = "OnePager-Recherche-Check"
def recherche_payload(result=None): def text_payload(path: Path):
if not recherche_path.exists(): if not path.exists():
return None return None
text = recherche_path.read_text(encoding="utf-8").strip() text = path.read_text(encoding="utf-8").strip()
return text or None return text or None
# Schritt 1: Recherche — vorhandene Datei wird übernommen (Resume) # Schritt 0: Recherche — 3 Agenten (min. 2, Grace), Mapping konsolidiert.
recherche = recherche_payload() # Eine gültige recherche.md (auch aus Altläufen) überspringt den Schritt.
recherche = text_payload(recherche_path)
if recherche is None: if recherche is None:
await _set_step(guide_id, 0, "Recherchiere…") await _set_step(guide_id, 0, "Recherchiere (3 Agenten)")
recherchen = []
offen = []
for i, path in enumerate(recherche_slots, 1):
text = text_payload(path)
if text is not None:
recherchen.append(text)
else:
offen.append((i, path))
if len(recherchen) < 2:
slots = [
{
"key": f"{guide_id}-recherche-{i}",
"prompt": _prompt(recherche_template, topic=topic, source=source, out_path=path, extra=_extra(instructions)),
"role": "quick", "capabilities": "files" if project else "full",
"payload": (lambda result, p=path: text_payload(p)),
}
for i, path in offen
]
neue = await _race(
topic, "OnePager-Recherche", slots, 2 - len(recherchen),
_timeout("onepager_recherche"), provider, cancelled=is_cancelled, grace=KONSENS_GRACE,
)
if is_cancelled():
return None
if neue is None:
await _fail(guide_id, "OnePager-Recherche fehlgeschlagen (Minimum nicht erreicht)")
return None
recherchen += neue
await _set_step(guide_id, 0, "Konsolidiere Recherche…")
recherchen_block = "\n\n".join(f"### Recherche {i}\n\n{text}" for i, text in enumerate(recherchen, 1))
status, recherche = await run_single_slot( status, recherche = await run_single_slot(
ctx, "OnePager-Recherche", ctx, "Recherche-Mapping",
key=f"{guide_id}-recherche", key=f"{guide_id}-recherche-mapping",
prompt=_prompt(recherche_template, topic=topic, source=source, out_path=recherche_path, extra=_extra(instructions)), prompt=_prompt(
role="quick", capabilities="files" if project else "full", "OnePager-Recherche-Mapping",
payload=recherche_payload, timeout=_timeout("onepager_recherche"), topic=topic, n=len(recherchen), recherchen=recherchen_block, out_path=recherche_path,
)
if status == CANCELLED:
return None
if status == FAILED:
await _fail(guide_id, "OnePager-Recherche fehlgeschlagen")
return None
# Schritt 2: Recherche-Prüfung — notiert Probleme; Anpassung macht ein Recherche-Agent
status, fixed = await _check_then_fix(
ctx, name="Recherche", step=1,
check_key=f"{guide_id}-recherche-check",
check_prompt=_prompt(recherche_check_template, topic=topic, recherche=recherche, out_path=recherche_check_path),
check_path=recherche_check_path, check_timeout=_timeout("onepager_verify"),
fix_key=f"{guide_id}-recherche-fix",
build_fix_prompt=lambda probleme: _prompt(
"OnePager-Recherche-Fix",
topic=topic, source=source, recherche=recherche,
probleme="\n".join(f"- {p}" for p in probleme),
out_path=recherche_path, extra=_extra(instructions),
), ),
fix_payload=recherche_payload, fix_timeout=_timeout("onepager_recherche"), role="judge", capabilities="files",
fix_role="quick", fix_caps="files" if project else "full", payload=lambda result: text_payload(recherche_path),
timeout=_timeout("onepager_mapping"),
) )
if status == CANCELLED: if status == CANCELLED:
return None return None
if status == FAILED: if status == FAILED:
await _fail(guide_id, "Recherche-Prüfung fehlgeschlagen") await _fail(guide_id, "Recherche-Konsolidierung fehlgeschlagen")
return None return None
if fixed is not None:
recherche = fixed
# Schritt 3: Bauen — Karten nur aus der Faktenbasis (Resume: gültige Datei wird übernommen) # Schritt 1: Bauen — 3 Entwürfe (min. 2, Grace), ein Judge kombiniert.
# Gültiges karten.json (auch aus Altläufen) überspringt den Schritt.
karten = karten_schema(_json_datei(karten_path)) karten = karten_schema(_json_datei(karten_path))
if karten is None: if karten is None:
await _set_step(guide_id, 2, "Baue OnePager…") await _set_step(guide_id, 1, "Baue OnePager (3 Entwürfe)")
karten_path.unlink(missing_ok=True) entwuerfe = []
offen = []
for i, path in enumerate(karten_slots, 1):
res = karten_schema(_json_datei(path))
if res is not None:
entwuerfe.append(res)
else:
offen.append((i, path))
if len(entwuerfe) < 2:
slots = [
{
"key": f"{guide_id}-bauen-{i}",
"prompt": _prompt("OnePager-Bauen", topic=topic, recherche=recherche, out_path=path, extra=_extra(instructions)),
"role": "fast", "capabilities": "files",
"payload": (lambda result, p=path: karten_schema(_json_datei(p))),
}
for i, path in offen
]
neue = await _race(
topic, "OnePager-Bauen", slots, 2 - len(entwuerfe),
_timeout("onepager_bauen"), provider, cancelled=is_cancelled, grace=KONSENS_GRACE,
)
if is_cancelled():
return None
if neue is None:
await _fail(guide_id, "OnePager-Bau fehlgeschlagen (Minimum nicht erreicht)")
return None
entwuerfe += neue
await _set_step(guide_id, 1, "Wähle besten Entwurf…")
saetze_block = "\n\n".join(
f"## Entwurf {i}\n\n" + "\n\n".join(f"### {k['titel']} [{k['key']}]\n{k['md']}" for k in satz)
for i, satz in enumerate(entwuerfe, 1)
)
status, karten = await run_single_slot( status, karten = await run_single_slot(
ctx, "OnePager-Bauen", ctx, "Bauen-Judge",
key=f"{guide_id}-bauen", key=f"{guide_id}-bauen-judge",
prompt=_prompt("OnePager-Bauen", topic=topic, recherche=recherche, out_path=karten_path, extra=_extra(instructions)), prompt=_prompt(
role="fast", capabilities="files", "OnePager-Bauen-Judge",
topic=topic, n=len(entwuerfe), recherche=recherche, kartensaetze=saetze_block,
out_path=karten_path, extra=_extra(instructions),
),
role="judge", capabilities="files",
payload=lambda result: karten_schema(_json_datei(karten_path)), payload=lambda result: karten_schema(_json_datei(karten_path)),
timeout=_timeout("onepager_bauen"), timeout=_timeout("onepager_judge"),
) )
if status == CANCELLED: if status == CANCELLED:
return None return None
if status == FAILED: if status == FAILED:
await _fail(guide_id, "OnePager-Bau fehlgeschlagen") await _fail(guide_id, "OnePager-Bau fehlgeschlagen (Judge ohne gültiges Ergebnis)")
return None return None
def karten_block() -> str: def karten_block() -> str:
return "\n\n".join(f"### {k['titel']} [{k['key']}]\n{k['md']}" for k in karten) return "\n\n".join(f"### {k['titel']} [{k['key']}]\n{k['md']}" for k in karten)
# Schritt 4: Prüfung — notiert Probleme; Anpassung macht ein Bauen-Agent # Schritt 2: Prüf-Loop — Verify notiert Probleme, Fix behebt; max. Runden-Cap.
status, fixed = await _check_then_fix( # Runde 1 ist fatal (wie früher der Einzel-Check), danach bleibt bei Fehlern
ctx, name="OnePager", step=3, # die letzte gültige Version stehen.
check_key=f"{guide_id}-verify", for runde in range(1, KONSENS_MAX_RUNDEN + 1):
check_prompt=_prompt("OnePager-Verifikation", topic=topic, recherche=recherche, karten=karten_block(), out_path=check_path), probleme = _probleme_schema(_json_datei(verify_paths[runde]))
check_path=check_path, check_timeout=_timeout("onepager_verify"), if probleme is None:
fix_key=f"{guide_id}-karten-fix", await _set_step(guide_id, 2, f"Prüfe OnePager (Runde {runde}/{KONSENS_MAX_RUNDEN})…")
build_fix_prompt=lambda probleme: _prompt( verify_paths[runde].unlink(missing_ok=True)
"OnePager-Fix", status, probleme = await run_single_slot(
topic=topic, recherche=recherche, karten=karten_block(), ctx, f"OnePager-Prüfung r{runde}",
probleme="\n".join(f"- {p}" for p in probleme), key=f"{guide_id}-verify-r{runde}",
out_path=karten_path, extra=_extra(instructions), prompt=_prompt("OnePager-Verifikation", topic=topic, recherche=recherche, karten=karten_block(), out_path=verify_paths[runde]),
), role="judge", capabilities="files",
fix_payload=lambda result: karten_schema(_json_datei(karten_path)), payload=lambda result, p=verify_paths[runde]: _probleme_schema(_json_datei(p)),
fix_timeout=_timeout("onepager_bauen"), timeout=_timeout("onepager_verify"),
on_fix_invalid=lambda: atomic_write_json(
karten_path, {"karten": {k["key"]: {"titel": k["titel"], "md": k["md"]} for k in karten}},
),
) )
if status == CANCELLED: if status == CANCELLED:
return None return None
if status == FAILED: if status == FAILED:
if runde == 1:
await _fail(guide_id, "OnePager-Prüfung fehlgeschlagen") await _fail(guide_id, "OnePager-Prüfung fehlgeschlagen")
return None return None
if fixed is not None: _log(topic, f"OnePager-Prüfung Runde {runde} fehlgeschlagen — letzte gültige Version bleibt")
break
if not probleme:
break
if runde == KONSENS_MAX_RUNDEN:
_log(topic, f"OnePager-Prüfung: {len(probleme)} Problem(e) bleiben nach Runde {runde} stehen")
break
_log(topic, f"OnePager-Prüfung Runde {runde}: {len(probleme)} Problem(e) notiert")
fixed = karten_schema(_json_datei(fix_paths[runde])) # Resume
if fixed is None:
await _set_step(guide_id, 2, f"Überarbeite OnePager (Runde {runde})…")
status, fixed = await run_single_slot(
ctx, f"OnePager-Fix r{runde}",
key=f"{guide_id}-karten-fix-r{runde}",
prompt=_prompt(
"OnePager-Fix",
topic=topic, recherche=recherche, karten=karten_block(),
probleme="\n".join(f"- {p}" for p in probleme),
out_path=fix_paths[runde], extra=_extra(instructions),
),
role="fast", capabilities="files",
payload=lambda result, p=fix_paths[runde]: karten_schema(_json_datei(p)),
timeout=_timeout("onepager_bauen"),
)
if status == CANCELLED:
return None
if status == FAILED:
_log(topic, f"OnePager-Fix Runde {runde} ungültig — letzte gültige Version bleibt")
break
karten = fixed karten = fixed
sections = [ sections = [

View File

@@ -1,4 +1,4 @@
"""Pipeline-Grundbausteine: Agent-Races, Single-Slot, Check→Fix, Prompts, Guide-Status. """Pipeline-Grundbausteine: Agent-Races (mit Grace), Single-Slot, Schemata, Prompts, Guide-Status.
Hält den mutablen Pipeline-Zustand (Generierungs-Semaphore, Cancel-Set). Hält den mutablen Pipeline-Zustand (Generierungs-Semaphore, Cancel-Set).
Zugriff auf das Cancel-Set NUR über die Funktionen hier — kopierte Referenzen Zugriff auf das Cancel-Set NUR über die Funktionen hier — kopierte Referenzen
@@ -105,10 +105,39 @@ def _probleme_schema(data):
return out or None return out or None
def _str_liste(val) -> list[str] | None:
"""Liste nicht-leerer Strings → gestrippte Liste (leer erlaubt) · sonst None."""
if not isinstance(val, list) or not all(isinstance(x, str) for x in val):
return None
out = [x.strip() for x in val]
return None if any(not x for x in out) else out
def _rest_schema(data):
"""{"uebernehmen": [str]} → Liste (leer erlaubt) · sonst None."""
if not isinstance(data, dict):
return None
return _str_liste(data.get("uebernehmen"))
def _runde_schema(data, final: bool = False):
"""{"aufnehmen": [str], "rest": [str]} → (aufnehmen, rest) · sonst None.
final=True: letzte Klärungs-Runde — ein nicht-leerer Rest ist ungültig.
"""
if not isinstance(data, dict):
return None
aufnehmen = _str_liste(data.get("aufnehmen"))
rest = _str_liste(data.get("rest"))
if aufnehmen is None or rest is None or (final and rest):
return None
return aufnehmen, rest
_MAX_RESTARTS = 2 _MAX_RESTARTS = 2
async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout: int, provider: str, on_update=None, cancelled=None) -> list | None: async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout: int, provider: str, on_update=None, cancelled=None, *, grace: int | None = None) -> list | None:
"""Startet alle Slots parallel und sammelt `quorum` gültige Ergebnisse. """Startet alle Slots parallel und sammelt `quorum` gültige Ergebnisse.
Slot-Spec: {key, prompt, role, capabilities, payload}. `payload(result)` Slot-Spec: {key, prompt, role, capabilities, payload}. `payload(result)`
@@ -116,9 +145,16 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
Fehler/Timeout/ungültig → Slot-Neustart (max. _MAX_RESTARTS). Sobald das Fehler/Timeout/ungültig → Slot-Neustart (max. _MAX_RESTARTS). Sobald das
Quorum steht, werden die übrigen Agenten gekillt. None = Quorum verfehlt. Quorum steht, werden die übrigen Agenten gekillt. None = Quorum verfehlt.
`cancelled()` → True bricht ab (keine Restarts, Rückgabe None). `cancelled()` → True bricht ab (keine Restarts, Rückgabe None).
Mit `grace` wird `quorum` zum Minimum: Das erste gültige Ergebnis startet
einen Timer von `grace` Sekunden. Nach dessen Ablauf werden laufende
Agenten nur gekillt, wenn das Minimum steht — sonst läuft das Race samt
Restarts weiter, bis es steht. Rückgabe: `quorum` bis `len(slots)` Ergebnisse.
""" """
attempts = {i: 0 for i in range(len(slots))} attempts = {i: 0 for i in range(len(slots))}
tasks: dict[asyncio.Task, int] = {} tasks: dict[asyncio.Task, int] = {}
loop = asyncio.get_running_loop()
deadline: float | None = None
def spawn(i: int) -> None: def spawn(i: int) -> None:
slot = slots[i] slot = slots[i]
@@ -136,7 +172,15 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
while tasks: while tasks:
if cancelled and cancelled(): if cancelled and cancelled():
return None return None
done, _ = await asyncio.wait(tasks.keys(), return_when=asyncio.FIRST_COMPLETED) if deadline is not None and len(results) >= quorum and loop.time() >= deadline:
return results
# Grace gesetzt und Minimum erreicht → nur bis zum Deadline-Rest warten
wait_timeout = None
if deadline is not None and len(results) >= quorum:
wait_timeout = max(0.0, deadline - loop.time())
done, _ = await asyncio.wait(tasks.keys(), return_when=asyncio.FIRST_COMPLETED, timeout=wait_timeout)
if not done:
continue
for task in done: for task in done:
i = tasks.pop(task) i = tasks.pop(task)
payload, err = None, None payload, err = None, None
@@ -155,16 +199,24 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
if payload is not None: if payload is not None:
results.append(payload) results.append(payload)
if grace is not None and deadline is None:
deadline = loop.time() + grace
_log(topic, f"{label}: erstes Ergebnis — Grace {grace}s läuft")
if on_update: if on_update:
on_update(len(results)) on_update(len(results))
if len(results) >= quorum: if len(results) >= quorum and (grace is None or loop.time() >= deadline):
return results return results
continue continue
_log(topic, f"{label} {i + 1} (Versuch {attempts[i] + 1}): {err}") _log(topic, f"{label} {i + 1} (Versuch {attempts[i] + 1}): {err}")
attempts[i] += 1 attempts[i] += 1
if attempts[i] <= _MAX_RESTARTS and not (cancelled and cancelled()): # Steht das Minimum schon, sind Restarts sinnlos — der Neustart
# würde am Grace-Ende ohnehin gekillt.
satt = grace is not None and len(results) >= quorum
if attempts[i] <= _MAX_RESTARTS and not satt and not (cancelled and cancelled()):
spawn(i) spawn(i)
if len(results) >= quorum: # alle Slots durch, Minimum steht (nur mit grace erreichbar)
return results
_log(topic, f"{label}: Quorum {quorum} nicht erreicht ({len(results)} gültig)") _log(topic, f"{label}: Quorum {quorum} nicht erreicht ({len(results)} gültig)")
return None return None
finally: finally:
@@ -184,7 +236,7 @@ class GenContext:
guide_id: str | None = None guide_id: str | None = None
# Ergebnis-Status von run_single_slot/_check_then_fix # Ergebnis-Status von run_single_slot
OK, CANCELLED, FAILED = "ok", "cancelled", "failed" OK, CANCELLED, FAILED = "ok", "cancelled", "failed"
@@ -205,47 +257,3 @@ async def run_single_slot(
return OK, res[0] return OK, res[0]
async def _check_then_fix(
ctx: GenContext, *, name: str, step: int,
check_key: str, check_prompt: str, check_path: Path, check_timeout: int,
fix_key: str, build_fix_prompt, fix_payload, fix_timeout: int,
fix_role: str = "fast", fix_caps: str = "files",
on_fix_invalid=None,
) -> tuple[str, object]:
"""Check→Fix-Muster: Prüf-Agent notiert Probleme (JSON), Fix-Agent behebt sie.
Resume: existierende Check-Datei überspringt den ganzen Schritt.
Check ist fatal (FAILED), Fix nicht — Original bleibt; on_fix_invalid kann
das kanonische Original zurückschreiben, falls der Fix-Agent die
Artefakt-Datei zerschrieben hat.
Lese-Check (Multi-Slot, Section-genau) und Bausteine-Auswahl-Check
(Patch-Semantik) passen bewusst NICHT in dieses Muster.
→ (OK, neues_artefakt | None=unverändert) | (CANCELLED, None) | (FAILED, None)
"""
if check_path.exists():
return OK, None
await _set_step(ctx.guide_id, step, f"Prüfe {name}")
status, probleme = await run_single_slot(
ctx, f"{name}-Prüfung", key=check_key, prompt=check_prompt,
role="fast", capabilities="files",
payload=lambda result: _probleme_schema(_json_datei(check_path)),
timeout=check_timeout,
)
if status != OK:
return status, None
if not probleme:
return OK, None
_log(ctx.topic, f"{name}-Prüfung: {len(probleme)} Problem(e) notiert")
await _set_step(ctx.guide_id, step, f"Passe {name} an…")
status, fixed = await run_single_slot(
ctx, f"{name}-Fix", key=fix_key, prompt=build_fix_prompt(probleme),
role=fix_role, capabilities=fix_caps, payload=fix_payload, timeout=fix_timeout,
)
if status == CANCELLED:
return CANCELLED, None
if status == FAILED:
_log(ctx.topic, f"{name}-Fix ungültig — Original bleibt")
if on_fix_invalid:
on_fix_invalid()
return OK, None
return OK, fixed

View File

@@ -3,35 +3,64 @@
Regeln (nur Neu-Erstellungen; Themen, Bausteine, OnePager unbegrenzt): Regeln (nur Neu-Erstellungen; Themen, Bausteine, OnePager unbegrenzt):
- JE Format (MiniGuide/Guide/FullGuide) höchstens 3 erstellte, nicht absolvierte Guides - JE Format (MiniGuide/Guide/FullGuide) höchstens 3 erstellte, nicht absolvierte Guides
- Progression pro Thema: Guide erst nach absolviertem MiniGuide, FullGuide erst nach absolviertem Guide - Progression pro Thema: Guide erst nach absolviertem MiniGuide, FullGuide erst nach absolviertem Guide
- Absolviert (Mini/Guide/FullGuide): ALLE Bausteine (Section-Titel) des neuesten
fertigen Guides haben eine bestandene Prüfung (baustein_progress). Das
Kapitel-Häkchen ist nur noch eine Lese-Markierung. OnePager: Kapitel-Häkchen.
Alle Funktionen arbeiten auf einmal geladenen Daten (lade_lernstand) — keine Alle Funktionen arbeiten auf einmal geladenen Daten (lade_lernstand) — keine
Query-Schleifen mehr pro Guide. Query-Schleifen mehr pro Guide.
""" """
import json import json
from database import list_guides, list_progress_all from database import list_baustein_absolviert_all, list_guides, list_progress_all
from guide import guide_slot_dateien from guide import guide_slot_dateien
from paths import bausteine_path, guide_content_path from paths import bausteine_path, guide_content_path
from textkit import _norm_titel
MAX_OFFENE_GUIDES = 3 MAX_OFFENE_GUIDES = 3
VORSTUFE = {"Guide": "MiniGuide", "FullGuide": "Guide"} VORSTUFE = {"Guide": "MiniGuide", "FullGuide": "Guide"}
FORMATE = ("MiniGuide", "Guide", "FullGuide") FORMATE = ("MiniGuide", "Guide", "FullGuide")
async def lade_lernstand() -> tuple[list[dict], dict[str, set[str]]]: async def lade_lernstand() -> tuple[list[dict], dict[str, set[str]], dict[str, set[str]]]:
"""Guides + kompletter Kapitel-Fortschritt in zwei Queries.""" """Guides + Kapitel-Fortschritt + absolvierte Bausteine in drei Queries.
return await list_guides(), await list_progress_all()
bausteine_done: topic → normalisierte Titel der Bausteine mit bestandener Prüfung.
"""
bausteine_done = {
topic: {_norm_titel(b) for b in titel}
for topic, titel in (await list_baustein_absolviert_all()).items()
}
return await list_guides(), await list_progress_all(), bausteine_done
def _kapitel_titel(topic: str, fmt: str) -> set[str] | None: def _content_json(topic: str, fmt: str) -> dict | None:
path = guide_content_path(topic, fmt) path = guide_content_path(topic, fmt)
if not path.exists(): if not path.exists():
return None return None
try: try:
chapters = json.loads(path.read_text(encoding="utf-8")).get("chapters", []) return json.loads(path.read_text(encoding="utf-8"))
except ValueError: except ValueError:
return None return None
return {c.get("title") for c in chapters}
def _kapitel_titel(topic: str, fmt: str) -> set[str] | None:
content = _content_json(topic, fmt)
if content is None:
return None
return {c.get("title") for c in content.get("chapters", [])}
def _section_titel(topic: str, fmt: str) -> set[str] | None:
"""Normalisierte Baustein-Titel (Sections) aus dem Guide-Content."""
content = _content_json(topic, fmt)
if content is None:
return None
return {
_norm_titel(s.get("title", ""))
for ch in content.get("chapters", [])
for s in ch.get("sections", [])
}
def _neueste_done(guides: list[dict], fmt: str) -> dict[str, dict]: def _neueste_done(guides: list[dict], fmt: str) -> dict[str, dict]:
@@ -44,28 +73,31 @@ def _neueste_done(guides: list[dict], fmt: str) -> dict[str, dict]:
return neueste return neueste
def _guide_absolviert(g: dict, progress: dict[str, set[str]]) -> bool: def _guide_absolviert(g: dict, progress: dict[str, set[str]], bausteine_done: dict[str, set[str]]) -> bool:
if g["format"] == "OnePager":
titles = _kapitel_titel(g["topic"], g["format"]) titles = _kapitel_titel(g["topic"], g["format"])
return bool(titles) and titles <= progress.get(g["id"], set()) return bool(titles) and titles <= progress.get(g["id"], set())
sections = _section_titel(g["topic"], g["format"])
return bool(sections) and sections <= bausteine_done.get(g["topic"], set())
def ist_absolviert(topic: str, fmt: str, guides: list[dict], progress: dict[str, set[str]]) -> bool: def ist_absolviert(topic: str, fmt: str, guides: list[dict], progress: dict[str, set[str]], bausteine_done: dict[str, set[str]]) -> bool:
"""Alle Kapitel des neuesten fertigen Guides (Thema+Format) abgehakt?""" """Alle Bausteine des neuesten fertigen Guides (Thema+Format) per Prüfung absolviert?"""
g = _neueste_done(guides, fmt).get(topic) g = _neueste_done(guides, fmt).get(topic)
return g is not None and _guide_absolviert(g, progress) return g is not None and _guide_absolviert(g, progress, bausteine_done)
def formate_stats(guides: list[dict], progress: dict[str, set[str]]) -> dict: def formate_stats(guides: list[dict], progress: dict[str, set[str]], bausteine_done: dict[str, set[str]]) -> dict:
"""Pro Format erstellt/absolviert — pro Thema zählt nur der neueste fertige Guide.""" """Pro Format erstellt/absolviert — pro Thema zählt nur der neueste fertige Guide."""
formate = {} formate = {}
for fmt in FORMATE: for fmt in FORMATE:
neueste = _neueste_done(guides, fmt) neueste = _neueste_done(guides, fmt)
absolviert = sum(1 for g in neueste.values() if _guide_absolviert(g, progress)) absolviert = sum(1 for g in neueste.values() if _guide_absolviert(g, progress, bausteine_done))
formate[fmt] = {"erstellt": len(neueste), "absolviert": absolviert} formate[fmt] = {"erstellt": len(neueste), "absolviert": absolviert}
return formate return formate
def guide_lock(topic: str, fmt: str, guides: list[dict], progress: dict[str, set[str]]) -> str | None: def guide_lock(topic: str, fmt: str, guides: list[dict], progress: dict[str, set[str]], bausteine_done: dict[str, set[str]]) -> str | None:
"""Grund, warum ein Neu-Start für Thema+Format gesperrt ist — None = erlaubt. """Grund, warum ein Neu-Start für Thema+Format gesperrt ist — None = erlaubt.
Exakt die Regeln aus POST /guides: Bausteine nötig, kein Duplikat-Start, Exakt die Regeln aus POST /guides: Bausteine nötig, kein Duplikat-Start,
@@ -79,9 +111,9 @@ def guide_lock(topic: str, fmt: str, guides: list[dict], progress: dict[str, set
content = guide_content_path(topic, fmt) content = guide_content_path(topic, fmt)
if fmt != "OnePager" and not content.exists() and not guide_slot_dateien(content): if fmt != "OnePager" and not content.exists() and not guide_slot_dateien(content):
vorstufe = VORSTUFE.get(fmt) vorstufe = VORSTUFE.get(fmt)
if vorstufe and not ist_absolviert(topic, vorstufe, guides, progress): if vorstufe and not ist_absolviert(topic, vorstufe, guides, progress, bausteine_done):
return f"Erst den {vorstufe} dieses Themas absolvieren" return f"Erst den {vorstufe} dieses Themas absolvieren (alle Bausteine prüfen)"
stat = formate_stats(guides, progress).get(fmt, {"erstellt": 0, "absolviert": 0}) stat = formate_stats(guides, progress, bausteine_done).get(fmt, {"erstellt": 0, "absolviert": 0})
offen = stat["erstellt"] - stat["absolviert"] offen = stat["erstellt"] - stat["absolviert"]
if offen >= MAX_OFFENE_GUIDES: if offen >= MAX_OFFENE_GUIDES:
return f"Erst {fmt}s absolvieren — maximal {MAX_OFFENE_GUIDES} offene erlaubt ({offen} offen)" return f"Erst {fmt}s absolvieren — maximal {MAX_OFFENE_GUIDES} offene erlaubt ({offen} offen)"

View File

@@ -13,9 +13,12 @@ from database import (
create_topic, list_topics as db_list_topics, delete_topic, create_topic, list_topics as db_list_topics, delete_topic,
list_progress, set_progress, delete_progress, list_progress, set_progress, delete_progress,
create_element, list_elements, get_element, update_element, delete_element, create_element, list_elements, get_element, update_element, delete_element,
get_vertiefung, set_vertiefung, list_vertiefungen,
list_baustein_progress, add_gute_antwort, set_baustein_absolviert, delete_baustein_daten,
) )
from bausteine import generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine from bausteine import generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine
from elements import generate_element, chat_with_guide, chat_with_element, check_element, style_element, refine_suggestion from elements import generate_element, chat_with_guide, chat_with_element, check_element, style_element, refine_suggestion
from lernen import NOETIG, baustein_chat, baustein_element_anlegen, baustein_pruefung, vertiefung_generieren
from guide import generate_guide, guide_slot_dateien from guide import generate_guide, guide_slot_dateien
from pipeline import cancel_guide from pipeline import cancel_guide
from regeln import FORMATE, formate_stats, guide_lock, ist_absolviert, lade_lernstand from regeln import FORMATE, formate_stats, guide_lock, ist_absolviert, lade_lernstand
@@ -28,6 +31,9 @@ from models import (
ElementUpdateRequest, ElementCheckRequest, ElementCheckResponse, ElementStyleResponse, ElementUpdateRequest, ElementCheckRequest, ElementCheckResponse, ElementStyleResponse,
ElementRefineRequest, ElementRefineResponse, ElementRefineRequest, ElementRefineResponse,
ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo, ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo,
VertiefungRequest, VertiefungResponse,
BausteinChatRequest, BausteinChatResponse,
BausteinPruefungRequest, BausteinPruefungResponse, BausteinLernstandResponse,
) )
from paths import bausteine_topics, guide_content_path, project_dir, topic_dir from paths import bausteine_topics, guide_content_path, project_dir, topic_dir
@@ -53,18 +59,18 @@ async def get_topics():
@router.get("/stats") @router.get("/stats")
async def get_stats(): async def get_stats():
"""Tracker: Themen-Anzahl + pro Format erstellt/absolviert.""" """Tracker: Themen-Anzahl + pro Format erstellt/absolviert."""
guides, progress = await lade_lernstand() guides, progress, bausteine_done = await lade_lernstand()
themen = set(await db_list_topics()) | {g["topic"] for g in guides} | set(bausteine_topics()) themen = set(await db_list_topics()) | {g["topic"] for g in guides} | set(bausteine_topics())
if PROJECTS_DIR.is_dir(): if PROJECTS_DIR.is_dir():
themen |= {e.name for e in PROJECTS_DIR.iterdir() if e.is_dir()} themen |= {e.name for e in PROJECTS_DIR.iterdir() if e.is_dir()}
return {"themen": len(themen), "formate": formate_stats(guides, progress)} return {"themen": len(themen), "formate": formate_stats(guides, progress, bausteine_done)}
@router.get("/topics/fortschritt") @router.get("/topics/fortschritt")
async def topic_fortschritt(topic: str): async def topic_fortschritt(topic: str):
"""Absolviert-Status pro Format — fürs Freischalten der nächsten Ausbaustufe.""" """Absolviert-Status pro Format — fürs Freischalten der nächsten Ausbaustufe."""
guides, progress = await lade_lernstand() guides, progress, bausteine_done = await lade_lernstand()
return {fmt: ist_absolviert(topic, fmt, guides, progress) for fmt in FORMATE} return {fmt: ist_absolviert(topic, fmt, guides, progress, bausteine_done) for fmt in FORMATE}
@router.post("/topics") @router.post("/topics")
@@ -76,6 +82,7 @@ async def add_topic(req: TopicCreateRequest):
@router.delete("/topics") @router.delete("/topics")
async def remove_topic(topic: str): async def remove_topic(topic: str):
await delete_topic(topic) await delete_topic(topic)
await delete_baustein_daten(topic)
shutil.rmtree(topic_dir(topic), ignore_errors=True) shutil.rmtree(topic_dir(topic), ignore_errors=True)
return {"ok": True} return {"ok": True}
@@ -138,12 +145,85 @@ async def remove_bausteine(topic: str):
return {"ok": True} return {"ok": True}
# --- Baustein-Lernen: Vertiefung, Chat, Prüfung ---
@router.get("/bausteine/lernstand", response_model=BausteinLernstandResponse)
async def baustein_lernstand(topic: str):
"""Prüfungs-Stand + Vertiefungs-Existenz pro Baustein (roher Titel als Key)."""
progress = await list_baustein_progress(topic)
mit_vertiefung = await list_vertiefungen(topic)
bausteine = {
p["baustein"]: {
"gute_antworten": p["gute_antworten"],
"absolviert": p["absolviert"] is not None,
"vertiefung": p["baustein"] in mit_vertiefung,
}
for p in progress
}
for b in mit_vertiefung - set(bausteine):
bausteine[b] = {"gute_antworten": 0, "absolviert": False, "vertiefung": True}
return {"bausteine": bausteine}
@router.get("/bausteine/vertiefung", response_model=VertiefungResponse)
async def get_baustein_vertiefung(topic: str, baustein: str):
md = await get_vertiefung(topic, baustein)
if md is None:
raise HTTPException(404, "Keine Vertiefung vorhanden")
return {"md": md}
@router.post("/bausteine/vertiefung", response_model=VertiefungResponse)
async def create_baustein_vertiefung(req: VertiefungRequest):
md = await vertiefung_generieren(req.topic, req.baustein, req.section, provider=req.provider)
if md is None:
raise HTTPException(502, "Vertiefung fehlgeschlagen — bitte erneut versuchen")
await set_vertiefung(req.topic, req.baustein, md)
return {"md": md}
@router.post("/bausteine/chat", response_model=BausteinChatResponse)
async def baustein_chat_route(req: BausteinChatRequest):
vertiefung = await get_vertiefung(req.topic, req.baustein)
reply = await baustein_chat(
req.topic, req.baustein, req.section, vertiefung,
[m.model_dump() for m in req.messages], provider=req.provider,
)
return {"reply": reply}
@router.post("/bausteine/pruefung", response_model=BausteinPruefungResponse)
async def baustein_pruefung_route(req: BausteinPruefungRequest):
stand = next(
(p for p in await list_baustein_progress(req.topic) if p["baustein"] == req.baustein),
{"gute_antworten": 0, "absolviert": None},
)
vertiefung = await get_vertiefung(req.topic, req.baustein)
data = await baustein_pruefung(
req.topic, req.baustein, req.section, vertiefung,
[m.model_dump() for m in req.messages], stand["gute_antworten"], provider=req.provider,
)
if data is None:
raise HTTPException(502, "Prüfung fehlgeschlagen — bitte erneut versuchen")
gute = stand["gute_antworten"]
if data["bewertung"] == "gut":
gute = await add_gute_antwort(req.topic, req.baustein)
absolviert = stand["absolviert"] is not None
if gute >= NOETIG or data["bestanden"]:
frisch = await set_baustein_absolviert(req.topic, req.baustein)
absolviert = True
if frisch:
asyncio.create_task(baustein_element_anlegen(req.topic, req.baustein, req.section, req.provider))
return {"reply": data["reply"], "bewertung": data["bewertung"], "gute_antworten": gute, "absolviert": absolviert}
# --- Guides --- # --- Guides ---
@router.post("/guides", response_model=GuideResponse) @router.post("/guides", response_model=GuideResponse)
async def create(req: GuideCreateRequest): async def create(req: GuideCreateRequest):
guides, progress = await lade_lernstand() guides, progress, bausteine_done = await lade_lernstand()
grund = guide_lock(req.topic.strip(), req.format, guides, progress) grund = guide_lock(req.topic.strip(), req.format, guides, progress, bausteine_done)
if grund: if grund:
raise HTTPException(400 if grund == "Erst Bausteine erstellen" else 409, grund) raise HTTPException(400 if grund == "Erst Bausteine erstellen" else 409, grund)
await create_topic(req.topic.strip()) await create_topic(req.topic.strip())
@@ -171,8 +251,8 @@ async def list_all():
@router.get("/guides/locks") @router.get("/guides/locks")
async def guide_locks(topic: str): async def guide_locks(topic: str):
"""Sperr-Gründe pro Format für den ▶-Button — None = erstellbar.""" """Sperr-Gründe pro Format für den ▶-Button — None = erstellbar."""
guides, progress = await lade_lernstand() guides, progress, bausteine_done = await lade_lernstand()
return {fmt: guide_lock(topic, fmt, guides, progress) for fmt in ("OnePager", *FORMATE)} return {fmt: guide_lock(topic, fmt, guides, progress, bausteine_done) for fmt in ("OnePager", *FORMATE)}
@router.get("/guides/{guide_id}", response_model=GuideResponse) @router.get("/guides/{guide_id}", response_model=GuideResponse)

View File

@@ -42,6 +42,30 @@ def _eindeutige_titel(entries: dict[int, str]) -> dict[int, str]:
return out return out
def _vormerge(listen: list[dict[int, str]]) -> list[tuple[str, int]]:
"""Vereinigt Baustein-Listen: exakte Titel-Dubletten (per _norm_titel) zusammenführen.
→ [("Titel — Beschreibung", nennungen)] in Erstnennungs-Reihenfolge.
nennungen = Anzahl der Listen, die den Titel nennen (Dublette innerhalb
einer Liste zählt nicht doppelt). Repräsentant ist die Erstnennung; ein
drittes ""-Segment (Quelle) wird verworfen.
"""
merged: dict[str, tuple[str, int]] = {}
for liste in listen:
gesehen: set[str] = set()
for text in liste.values():
key = _norm_titel(_titel(text))
if key in gesehen:
continue
gesehen.add(key)
if key in merged:
repr_text, n = merged[key]
merged[key] = (repr_text, n + 1)
else:
merged[key] = ("".join(text.split("")[:2]), 1)
return list(merged.values())
def _titel_index(entries: dict[int, str]) -> dict[str, int]: def _titel_index(entries: dict[int, str]) -> dict[str, int]:
return {_norm_titel(_titel(text)): num for num, text in entries.items()} return {_norm_titel(_titel(text)): num for num, text in entries.items()}

View File

@@ -11,19 +11,26 @@
} }
} }
}, },
"minimax-direkt": { "minimax-kalt": {
"npm": "@ai-sdk/anthropic", "npm": "@ai-sdk/anthropic",
"name": "MiniMax (ohne Thinking)", "name": "MiniMax (kalt — niedrige Temperature, ohne Thinking)",
"options": { "options": {
"baseURL": "https://api.minimax.io/anthropic/v1", "baseURL": "https://api.minimax.io/anthropic/v1",
"apiKey": "{env:MINIMAX_API_KEY}" "apiKey": "{env:MINIMAX_API_KEY}"
}, },
"models": { "models": {
"MiniMax-M3": { "MiniMax-M3": {
"name": "MiniMax M3 (ohne Thinking)", "name": "MiniMax M3 (kalt)",
"options": { "options": {
"temperature": 0.2,
"thinking": { "type": "disabled" } "thinking": { "type": "disabled" }
} }
},
"MiniMax-M2.7-highspeed": {
"name": "MiniMax M2.7 highspeed (kalt)",
"options": {
"temperature": 0.3
}
} }
} }
}, },

View File

@@ -393,7 +393,6 @@ onMounted(async () => {
:provider="provider" :provider="provider"
:elementsOpen="elementsOpen" :elementsOpen="elementsOpen"
@progressChanged="loadStats(); loadBausteine()" @progressChanged="loadStats(); loadBausteine()"
@openElements="elementsOpen = true"
/> />
<div v-else class="empty-main"> <div v-else class="empty-main">
<p>Thema in der Sidebar anlegen oder auswählen.</p> <p>Thema in der Sidebar anlegen oder auswählen.</p>

View File

@@ -59,6 +59,47 @@ export async function deleteBausteine(topic) {
await fetch(`${BASE}/bausteine?topic=${encodeURIComponent(topic)}`, { method: 'DELETE' }) await fetch(`${BASE}/bausteine?topic=${encodeURIComponent(topic)}`, { method: 'DELETE' })
} }
// --- Baustein-Lernen: Vertiefung, Chat, Prüfung ---
export async function fetchBausteinLernstand(topic) {
const res = await fetch(`${BASE}/bausteine/lernstand?topic=${encodeURIComponent(topic)}`)
return jsonOrThrow(res)
}
export async function fetchVertiefung(topic, baustein) {
const res = await fetch(
`${BASE}/bausteine/vertiefung?topic=${encodeURIComponent(topic)}&baustein=${encodeURIComponent(baustein)}`
)
return jsonOrThrow(res)
}
export async function createVertiefung({ topic, baustein, section, provider }) {
const res = await fetch(`${BASE}/bausteine/vertiefung`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, baustein, section, provider }),
})
return jsonOrThrow(res)
}
export async function chatBaustein({ topic, baustein, section, messages, provider }) {
const res = await fetch(`${BASE}/bausteine/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, baustein, section, messages, provider }),
})
return jsonOrThrow(res)
}
export async function pruefeBaustein({ topic, baustein, section, messages, provider }) {
const res = await fetch(`${BASE}/bausteine/pruefung`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, baustein, section, messages, provider }),
})
return jsonOrThrow(res)
}
export async function fetchTopicFortschritt(topic) { export async function fetchTopicFortschritt(topic) {
const res = await fetch(`${BASE}/topics/fortschritt?topic=${encodeURIComponent(topic)}`) const res = await fetch(`${BASE}/topics/fortschritt?topic=${encodeURIComponent(topic)}`)
return res.json() return res.json()
@@ -114,20 +155,6 @@ export async function deleteTopic(name) {
await fetch(`${BASE}/topics?topic=${encodeURIComponent(name)}`, { method: 'DELETE' }) await fetch(`${BASE}/topics?topic=${encodeURIComponent(name)}`, { method: 'DELETE' })
} }
export async function fetchProgress(id) {
const res = await fetch(`${BASE}/guides/${id}/progress`)
return res.json()
}
export async function setProgress(id, chapter, done) {
const res = await fetch(`${BASE}/guides/${id}/progress`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chapter, done }),
})
return res.json()
}
export async function chatGuide(id, { section, outline, messages, provider = 'claude' }) { export async function chatGuide(id, { section, outline, messages, provider = 'claude' }) {
const res = await fetch(`${BASE}/guides/${id}/chat`, { const res = await fetch(`${BASE}/guides/${id}/chat`, {
method: 'POST', method: 'POST',

View File

@@ -0,0 +1,277 @@
<script setup>
import { computed, nextTick, ref } from 'vue'
import { chatBaustein, createVertiefung, fetchVertiefung, pruefeBaustein } from '../api.js'
import { renderMarkdown } from '../markdown.js'
import { useChat } from '../composables/useChat.js'
const props = defineProps({
topic: { type: String, required: true },
baustein: { type: String, required: true },
section: { type: String, default: '' },
provider: { type: String, default: 'claude' },
status: { type: Object, default: null }, // {gute_antworten, absolviert, vertiefung}
})
const emit = defineEmits(['statusChanged'])
const NOETIG = 3
const st = computed(() => props.status || { gute_antworten: 0, absolviert: false, vertiefung: false })
// --- Toggle-Bereich ---
const activeTab = ref(null) // null | 'vertiefung' | 'chat' | 'pruefung'
function toggle(tab) {
activeTab.value = activeTab.value === tab ? null : tab
if (activeTab.value === 'vertiefung') openVertiefung()
if (activeTab.value === 'pruefung') startPruefung()
}
// --- Vertiefung (persistiert) ---
const vert = ref(null)
const vertLoading = ref(false)
const vertError = ref('')
async function openVertiefung() {
if (vert.value !== null || vertLoading.value || !st.value.vertiefung) return
vertLoading.value = true
vertError.value = ''
try {
vert.value = (await fetchVertiefung(props.topic, props.baustein)).md
} catch (e) {
vertError.value = e.message
} finally {
vertLoading.value = false
}
}
async function generateVertiefung() {
vertLoading.value = true
vertError.value = ''
try {
vert.value = (await createVertiefung({
topic: props.topic, baustein: props.baustein, section: props.section, provider: props.provider,
})).md
emit('statusChanged', { ...st.value, vertiefung: true })
} catch (e) {
vertError.value = e.message
} finally {
vertLoading.value = false
}
}
// --- Bausteinchat (flüchtig) ---
const chat = useChat((msgs) => chatBaustein({
topic: props.topic, baustein: props.baustein, section: props.section,
messages: msgs, provider: props.provider,
}))
// --- Prüfung (Verlauf flüchtig, Zähler serverseitig) ---
const pruefung = useChat(async (msgs) => {
const res = await pruefeBaustein({
topic: props.topic, baustein: props.baustein, section: props.section,
messages: msgs, provider: props.provider,
})
applyPruefung(res)
return res
})
const startLoading = ref(false)
function applyPruefung(res) {
emit('statusChanged', {
...st.value,
gute_antworten: res.gute_antworten,
absolviert: res.absolviert,
})
}
async function startPruefung() {
if (pruefung.messages.value.length || startLoading.value) return
startLoading.value = true
try {
const res = await pruefeBaustein({
topic: props.topic, baustein: props.baustein, section: props.section,
messages: [], provider: props.provider,
})
pruefung.messages.value.push({ role: 'assistant', content: res.reply })
applyPruefung(res)
nextTick(() => pruefung.inputEl.value?.focus())
} catch {
pruefung.messages.value.push({ role: 'assistant', content: 'Fehler beim Start der Prüfung — Tab erneut öffnen.' })
} finally {
startLoading.value = false
}
}
</script>
<template>
<div class="bp">
<div class="bp-toggles">
<button :class="{ active: activeTab === 'vertiefung' }" @click="toggle('vertiefung')">
Vertiefung
</button>
<button :class="{ active: activeTab === 'chat' }" @click="toggle('chat')">
Chat
</button>
<button :class="{ active: activeTab === 'pruefung' }" @click="toggle('pruefung')">
Prüfung
<span v-if="st.absolviert" class="bp-chip done"></span>
<span v-else-if="st.gute_antworten" class="bp-chip">{{ Math.min(st.gute_antworten, NOETIG) }}/{{ NOETIG }}</span>
</button>
</div>
<div v-if="activeTab" class="bp-panel">
<!-- Vertiefung -->
<div v-if="activeTab === 'vertiefung'">
<p v-if="vertLoading" class="bp-hint">{{ vert === null ? 'Generiere Vertiefung' : 'Lade' }}</p>
<template v-else-if="vert">
<div class="markdown" v-html="renderMarkdown(vert)"></div>
<button class="bp-action" @click="generateVertiefung">Neu generieren</button>
</template>
<template v-else>
<p class="bp-hint">Noch keine Vertiefung zu diesem Baustein.</p>
<button class="bp-action" @click="generateVertiefung">Vertiefung generieren</button>
</template>
<p v-if="vertError" class="bp-error">{{ vertError }}</p>
</div>
<!-- Bausteinchat -->
<div v-else-if="activeTab === 'chat'">
<div :ref="chat.messagesEl" class="bp-messages">
<p v-if="!chat.messages.value.length" class="bp-hint">Frag etwas zu diesem Baustein. Der Verlauf wird nicht gespeichert.</p>
<template v-for="(m, i) in chat.messages.value" :key="i">
<div v-if="m.role === 'assistant'" class="bp-msg assistant markdown" v-html="renderMarkdown(m.content)"></div>
<div v-else class="bp-msg user">{{ m.content }}</div>
</template>
<div v-if="chat.loading.value" class="bp-msg assistant bp-typing">Denkt</div>
</div>
<div class="bp-input">
<textarea
:ref="chat.inputEl"
v-model="chat.input.value"
rows="2"
placeholder="Frage zum Baustein…"
@keydown.enter.exact.prevent="chat.send"
></textarea>
<button :disabled="!chat.input.value.trim() && !chat.loading.value" :class="{ cancel: chat.loading.value }" @click="chat.send">
{{ chat.loading.value ? '' : '' }}
</button>
</div>
</div>
<!-- Prüfung -->
<div v-else>
<p class="bp-hint">
<template v-if="st.absolviert"> Absolviert du kannst dich weiter prüfen lassen.</template>
<template v-else>{{ Math.min(st.gute_antworten, NOETIG) }}/{{ NOETIG }} guten Antworten. Erkläre in eigenen Worten das Material darfst du nutzen.</template>
</p>
<div :ref="pruefung.messagesEl" class="bp-messages">
<div v-if="startLoading" class="bp-msg assistant bp-typing">Erste Frage kommt</div>
<template v-for="(m, i) in pruefung.messages.value" :key="i">
<div v-if="m.role === 'assistant'" class="bp-msg assistant markdown" v-html="renderMarkdown(m.content)"></div>
<div v-else class="bp-msg user">{{ m.content }}</div>
</template>
<div v-if="pruefung.loading.value" class="bp-msg assistant bp-typing">Bewertet</div>
</div>
<div class="bp-input">
<textarea
:ref="pruefung.inputEl"
v-model="pruefung.input.value"
rows="2"
placeholder="Deine Erklärung…"
@keydown.enter.exact.prevent="pruefung.send"
></textarea>
<button :disabled="!pruefung.input.value.trim() && !pruefung.loading.value" :class="{ cancel: pruefung.loading.value }" @click="pruefung.send">
{{ pruefung.loading.value ? '' : '' }}
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.bp { margin-top: 0.75rem; }
.bp-toggles { display: flex; gap: 0.4rem; }
.bp-toggles button {
display: inline-flex; align-items: center; gap: 0.35rem;
padding: 0.25rem 0.7rem;
font-size: 0.8rem;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--panel-soft);
color: var(--text-muted);
cursor: pointer;
}
.bp-toggles button:hover { border-color: var(--border-strong); color: var(--text); }
.bp-toggles button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.bp-chip {
font-size: 0.7rem;
padding: 0 0.35rem;
border-radius: 999px;
background: var(--panel);
border: 1px solid var(--border);
color: var(--text-muted);
}
.bp-chip.done { background: var(--success-soft); border-color: var(--success-border); color: var(--success); }
.bp-panel {
margin-top: 0.6rem;
padding: 0.75rem 0.9rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel-soft);
}
.bp-hint { font-size: 0.85rem; color: var(--text-muted); margin: 0 0 0.5rem; }
.bp-error { font-size: 0.85rem; color: var(--danger); margin: 0.5rem 0 0; }
.bp-action {
margin-top: 0.5rem;
padding: 0.3rem 0.8rem;
font-size: 0.8rem;
border: 1px solid var(--border-strong);
border-radius: 6px;
background: var(--panel);
color: var(--text);
cursor: pointer;
}
.bp-action:hover { border-color: var(--accent); }
.bp-messages { display: flex; flex-direction: column; gap: 0.4rem; max-height: 320px; overflow-y: auto; }
.bp-msg {
max-width: 88%;
padding: 0.4rem 0.65rem;
border-radius: 10px;
font-size: 0.88rem;
line-height: 1.45;
overflow-wrap: anywhere;
}
.bp-msg.user { align-self: flex-end; background: var(--accent); color: var(--on-accent); white-space: pre-wrap; }
.bp-msg.assistant { align-self: flex-start; background: var(--panel); border: 1px solid var(--border); }
.bp-typing { color: var(--text-faint); font-style: italic; }
.bp-input { display: flex; gap: 0.4rem; margin-top: 0.55rem; align-items: flex-end; }
.bp-input textarea {
flex: 1;
resize: none;
padding: 0.45rem 0.6rem;
font: inherit;
font-size: 0.88rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel);
color: var(--text);
}
.bp-input button {
padding: 0.45rem 0.7rem;
border: none;
border-radius: 8px;
background: var(--accent);
color: var(--on-accent);
cursor: pointer;
}
.bp-input button:disabled { opacity: 0.5; cursor: default; }
.bp-input button.cancel { background: var(--danger); }
</style>

View File

@@ -1,8 +1,9 @@
<script setup> <script setup>
import { computed, ref, watch, nextTick, onMounted, onUnmounted } from 'vue' import { computed, ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { fetchGuideContent, chatGuide, fetchProgress, setProgress } from '../api.js' import { fetchGuideContent, chatGuide, fetchBausteinLernstand } from '../api.js'
import { renderMarkdown } from '../markdown.js' import { renderMarkdown } from '../markdown.js'
import { useChat } from '../composables/useChat.js' import { useChat } from '../composables/useChat.js'
import BausteinPanel from './BausteinPanel.vue'
const props = defineProps({ const props = defineProps({
previewGuide: { type: Object, default: null }, previewGuide: { type: Object, default: null },
@@ -11,7 +12,7 @@ const props = defineProps({
elementsOpen: { type: Boolean, default: false }, // Element-Sidebar offen → Chat nach links elementsOpen: { type: Boolean, default: false }, // Element-Sidebar offen → Chat nach links
}) })
const emit = defineEmits(['progressChanged', 'openElements']) const emit = defineEmits(['progressChanged'])
const isOnePager = computed(() => props.previewGuide?.format === 'OnePager') const isOnePager = computed(() => props.previewGuide?.format === 'OnePager')
@@ -21,7 +22,6 @@ const CH_COLORS = ['#3b82f6', '#8b5cf6', '#14b8a6', '#f59e0b', '#22c55e', '#6366
// --- Inhalt laden --- // --- Inhalt laden ---
const content = ref(null) const content = ref(null)
const loadError = ref(null) const loadError = ref(null)
const doneChapters = ref(new Set())
const scrollEl = ref(null) const scrollEl = ref(null)
watch(() => props.previewGuide?.id, loadContent, { immediate: true }) watch(() => props.previewGuide?.id, loadContent, { immediate: true })
@@ -29,7 +29,7 @@ watch(() => props.previewGuide?.id, loadContent, { immediate: true })
async function loadContent() { async function loadContent() {
content.value = null content.value = null
loadError.value = null loadError.value = null
doneChapters.value = new Set() lernstand.value = {}
const g = props.previewGuide const g = props.previewGuide
if (!g || g.status !== 'done') return if (!g || g.status !== 'done') return
try { try {
@@ -39,38 +39,20 @@ async function loadContent() {
loadError.value = 'Inhalt nicht verfügbar — die Datei fehlt. Guide neu generieren (▶).' loadError.value = 'Inhalt nicht verfügbar — die Datei fehlt. Guide neu generieren (▶).'
return return
} }
if (g.format !== 'OnePager') {
try { try {
const res = await fetchProgress(g.id) lernstand.value = (await fetchBausteinLernstand(g.topic)).bausteine || {}
doneChapters.value = new Set(res.chapters || [])
} catch { /* offline → leer */ } } catch { /* offline → leer */ }
nextTick(scrollToFirstOpen) }
} }
// Zum ersten noch offenen Kapitel springen — aber nur, wenn schon etwas erledigt ist. // --- Baustein-Lernen: Prüfungs-Stand pro Baustein-Titel ---
function scrollToFirstOpen() { const lernstand = ref({})
if (!doneChapters.value.size || !content.value) return
const chapters = Array.from(scrollEl.value?.querySelectorAll('section.chapter') || [])
const firstOpen = chapters.find((el) => !el.classList.contains('ch-complete'))
if (firstOpen && firstOpen !== chapters[0]) firstOpen.scrollIntoView({ block: 'start' })
}
// --- Kapitel-Fortschritt --- function onBausteinStatus(baustein, status) {
async function toggleChapter(title) { const warAbsolviert = lernstand.value[baustein]?.absolviert
const newState = !doneChapters.value.has(title) lernstand.value = { ...lernstand.value, [baustein]: status }
const optimistic = new Set(doneChapters.value) if (status.absolviert && !warAbsolviert) emit('progressChanged') // Locks/Stats neu laden
if (newState) optimistic.add(title)
else optimistic.delete(title)
doneChapters.value = optimistic
try {
const res = await setProgress(props.previewGuide.id, title, newState)
doneChapters.value = new Set(res.chapters || [])
emit('progressChanged')
} catch {
const rollback = new Set(doneChapters.value)
if (newState) rollback.delete(title)
else rollback.add(title)
doneChapters.value = rollback
}
} }
// --- Chat (Mechanik in useChat; Kontext-Extraktion bleibt hier) --- // --- Chat (Mechanik in useChat; Kontext-Extraktion bleibt hier) ---
@@ -164,7 +146,6 @@ function extractContext() {
v-for="(ch, ci) in content.chapters" v-for="(ch, ci) in content.chapters"
:key="ch.title" :key="ch.title"
class="chapter" class="chapter"
:class="{ 'ch-complete': doneChapters.has(ch.title) }"
:style="{ '--ch-accent': CH_COLORS[ci % CH_COLORS.length] }" :style="{ '--ch-accent': CH_COLORS[ci % CH_COLORS.length] }"
> >
<h2 class="chapter-title"><span class="ch-num">{{ ci + 1 }}</span>{{ ch.title }}</h2> <h2 class="chapter-title"><span class="ch-num">{{ ci + 1 }}</span>{{ ch.title }}</h2>
@@ -175,16 +156,22 @@ function extractContext() {
:class="['section-card', isOnePager && s.key ? 'op-card op-' + s.key : '']" :class="['section-card', isOnePager && s.key ? 'op-card op-' + s.key : '']"
:style="isOnePager && s.key ? { gridArea: s.key } : null" :style="isOnePager && s.key ? { gridArea: s.key } : null"
> >
<h3>{{ s.title }}</h3> <h3>
{{ s.title }}
<span v-if="lernstand[s.title]?.absolviert" class="baustein-done" title="Baustein absolviert"></span>
</h3>
<div class="section-body markdown" v-html="renderMarkdown(s.md)"></div> <div class="section-body markdown" v-html="renderMarkdown(s.md)"></div>
<BausteinPanel
v-if="!isOnePager"
:topic="previewGuide.topic"
:baustein="s.title"
:section="s.md"
:provider="provider"
:status="lernstand[s.title]"
@status-changed="(st) => onBausteinStatus(s.title, st)"
/>
</article> </article>
</div> </div>
<button
v-if="!isOnePager"
class="ch-toggle"
:class="{ 'is-done': doneChapters.has(ch.title) }"
@click="toggleChapter(ch.title)"
>{{ doneChapters.has(ch.title) ? '✓ Erledigt rückgängig' : 'Kapitel als erledigt markieren' }}</button>
</section> </section>
</div> </div>
</div> </div>
@@ -198,7 +185,6 @@ function extractContext() {
</div> </div>
<button v-if="previewGuide && !chatOpen" class="chat-fab" :class="{ shifted: elementsOpen }" title="Fragen zum Guide" @click="openChat">💬</button> <button v-if="previewGuide && !chatOpen" class="chat-fab" :class="{ shifted: elementsOpen }" title="Fragen zum Guide" @click="openChat">💬</button>
<button v-if="previewGuide && !chatOpen && !elementsOpen" class="chat-fab elements-fab" title="Elemente öffnen" @click="emit('openElements')">🗂</button>
<div v-if="previewGuide && chatOpen" ref="panelEl" class="chat-panel" :class="{ shifted: elementsOpen }"> <div v-if="previewGuide && chatOpen" ref="panelEl" class="chat-panel" :class="{ shifted: elementsOpen }">
<header class="chat-header"> <header class="chat-header">
@@ -310,10 +296,6 @@ function extractContext() {
font-weight: 700; font-weight: 700;
} }
.chapter.ch-complete .sections {
opacity: 0.4;
}
.section-card { .section-card {
background: var(--panel); background: var(--panel);
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -322,6 +304,12 @@ function extractContext() {
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
.baustein-done {
margin-left: 0.35rem;
font-size: 0.85em;
color: var(--success);
}
/* Guides: Karten tragen die Kapitel-Akzentfarbe (OnePager hat eigene op-card-Farben) */ /* Guides: Karten tragen die Kapitel-Akzentfarbe (OnePager hat eigene op-card-Farben) */
.guide-content:not(.onepager) .section-card { .guide-content:not(.onepager) .section-card {
border-top: 3px solid color-mix(in srgb, var(--ch-accent, var(--accent)) 65%, transparent); border-top: 3px solid color-mix(in srgb, var(--ch-accent, var(--accent)) 65%, transparent);
@@ -438,35 +426,6 @@ function extractContext() {
} }
} }
.ch-toggle {
display: block;
width: 100%;
margin-top: 0.5rem;
padding: 0.8rem 1rem;
border: 1.5px dashed var(--border-strong);
border-radius: 10px;
background: var(--panel-soft);
color: var(--text-muted);
font: 600 0.9rem/1.2 inherit;
font-family: inherit;
text-align: center;
cursor: pointer;
transition: all 0.12s;
&:hover {
border-color: var(--accent);
color: var(--accent);
background: transparent;
}
&.is-done {
border-style: solid;
border-color: var(--success-border);
background: var(--success-soft);
color: var(--success);
}
}
.empty-preview { .empty-preview {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -533,10 +492,6 @@ function extractContext() {
background: var(--accent-hover); background: var(--accent-hover);
} }
.elements-fab {
right: 5.25rem;
}
/* Element-Sidebar (320px) offen → Chat links daneben anzeigen */ /* Element-Sidebar (320px) offen → Chat links daneben anzeigen */
.chat-fab.shifted { .chat-fab.shifted {
right: calc(1.5rem + 320px); right: calc(1.5rem + 320px);

View File

@@ -29,7 +29,7 @@ function providerAvailable(id) {
return p ? p.available : true return p ? p.available : true
} }
const PROVIDER_LABELS = { claude: 'Claude', minimax: 'MiniMax', 'minimax-direkt': 'MiniMax direkt', lokal: 'Lokal' } const PROVIDER_LABELS = { claude: 'Claude', minimax: 'MiniMax', lokal: 'Lokal' }
// Tracker oben in der Navigation: Themen gesamt, pro Format erstellt/absolviert // Tracker oben in der Navigation: Themen gesamt, pro Format erstellt/absolviert
const trackerItems = computed(() => { const trackerItems = computed(() => {
@@ -93,8 +93,8 @@ function guideStatus(format) {
} }
// Schritt-Kugeln der Guide-Pipelines // Schritt-Kugeln der Guide-Pipelines
const GUIDE_STEPS = ['Auswahl', 'Auswahl-Prüfung', 'Gliederung', 'Gliederungs-Prüfung', 'Schreiben', 'Lese-Prüfung'] const GUIDE_STEPS = ['Auswahl', 'Gliederung', 'Schreiben', 'Lese-Prüfung']
const ONEPAGER_STEPS = ['Recherche', 'Recherche-Prüfung', 'Bauen', 'Prüfung'] const ONEPAGER_STEPS = ['Recherche', 'Bauen', 'Prüfung']
// Kugeln werden wie bei den Bausteinen immer angezeigt: // Kugeln werden wie bei den Bausteinen immer angezeigt:
// fertig = alle grün, laufend = live, abgebrochen = Teilfortschritt, sonst grau // fertig = alle grün, laufend = live, abgebrochen = Teilfortschritt, sonst grau
@@ -102,7 +102,8 @@ function guideSteps(format) {
const labels = format === 'OnePager' ? ONEPAGER_STEPS : GUIDE_STEPS const labels = format === 'OnePager' ? ONEPAGER_STEPS : GUIDE_STEPS
const st = guideStatus(format) const st = guideStatus(format)
if (st === 'generating' || st === 'queued') { if (st === 'generating' || st === 'queued') {
const step = props.latestByFormat[format]?.step ?? -1 // Clamp: alte DB-Läufe können step-Werte oberhalb der neuen Listen haben
const step = Math.min(props.latestByFormat[format]?.step ?? -1, labels.length - 1)
return labels.map((label, i) => ({ return labels.map((label, i) => ({
label, label,
state: i < step ? 'done' : i === step ? 'active' : 'pending', state: i < step ? 'done' : i === step ? 'active' : 'pending',
@@ -112,7 +113,7 @@ function guideSteps(format) {
return labels.map((label) => ({ label, state: 'done' })) return labels.map((label) => ({ label, state: 'done' }))
} }
if (abgebrochen(format)) { if (abgebrochen(format)) {
const step = props.latestByFormat[format]?.step ?? 0 const step = Math.min(props.latestByFormat[format]?.step ?? 0, labels.length)
return labels.map((label, i) => ({ label, state: i < step ? 'done' : 'pending' })) return labels.map((label, i) => ({ label, state: i < step ? 'done' : 'pending' }))
} }
return labels.map((label) => ({ label, state: 'pending' })) return labels.map((label) => ({ label, state: 'pending' }))

View File

@@ -0,0 +1,19 @@
Du bist ein hilfreicher Tutor für den Baustein "{baustein}" aus dem Lern-Guide zum Thema "{topic}". Ein Leser stellt dir Fragen zu genau diesem Baustein.
BAUSTEIN AUS DEM GUIDE:
{section_block}
VERTIEFUNG (falls vorhanden):
{vertiefung_block}
BISHERIGER CHAT-VERLAUF:
{transcript}
Antworte als Assistent auf die letzte Nutzer-Nachricht.
WICHTIG Antwortstil:
- KURZ und EINFACH: 13 Sätze, klare Sprache.
- Keine Einleitung, keine Wiederholung der Frage, kein Markdown-Drumherum.
- Bleib beim Baustein; nutze Guide-Fassung und Vertiefung als Kontext.
Gib NUR die Antwort aus, kein Präfix wie "Assistent:".

View File

@@ -0,0 +1,28 @@
Du prüfst das Verständnis eines Lerners zum Baustein "{baustein}" aus dem Lern-Guide zum Thema "{topic}". Der Lerner sieht das Material während der Prüfung — stelle deshalb VERSTÄNDNIS- und TRANSFERFRAGEN, keine Reproduktionsfragen (nichts, was sich ablesen lässt).
BAUSTEIN AUS DEM GUIDE:
{section_block}
VERTIEFUNG (falls vorhanden):
{vertiefung_block}
STAND: {gute_antworten} von {noetig} Antworten waren bisher gut. Bei {noetig} guten Antworten ist der Baustein absolviert.
BISHERIGER PRÜFUNGS-VERLAUF:
{transcript}
FACHLICHE REFERENZ — WICHTIG:
- Die Guide-Fassung und die Vertiefung oben sind die fachliche Referenz. Deine Fragen und Bewertungen dürfen ihnen NIE widersprechen.
- Behaupte nichts, was nicht aus dem Material folgt. Erfinde keine Zusatzannahmen (z. B. fehlende Eingaben, geänderte Definitionen).
- Widerspricht dir der Lerner mit Bezug aufs Material: Prüfe ZUERST deine eigene Annahme gegen die Referenz. Hat der Lerner recht, gib es offen zu und bewerte die Antwort als "gut".
Deine Aufgabe:
- Ist der Verlauf leer: Stelle die erste Frage. `bewertung` ist dann null.
- Sonst: Bewerte die LETZTE Nutzer-Antwort als "gut" oder "schlecht". Gut = die Erklärung zeigt echtes Verständnis in eigenen Worten. Schlecht = falsch, oberflächlich, abgelesen oder eine bloße Wiederholung einer früheren Antwort.
- Gib kurzes Feedback (12 Sätze) und stelle die nächste Frage — beides zusammen in `reply`.
- Gute Fragen: Warum-Fragen, Anwendung auf ein neues Beispiel, Abgrenzung zu Nachbarkonzepten, Fehler in einem Beispiel finden, Konsequenzen erklären.
- `bestanden`: true NUR, wenn du schon vor Erreichen der {noetig} guten Antworten überzeugt bist, dass der Lerner den Baustein sicher verstanden hat. Im Zweifel false.
- Sprich den Lerner direkt an, klares Deutsch, keine Floskeln.
Gib NUR dieses JSON aus (kein weiterer Text):
{{"reply": "Feedback und nächste Frage", "bewertung": "gut" | "schlecht" | null, "bestanden": false}}

View File

@@ -0,0 +1,18 @@
Schreibe eine Vertiefung zum Baustein "{baustein}" aus dem Lern-Guide zum Thema "{topic}". Der Leser kennt die kompakte Fassung und will tiefer einsteigen.
KOMPAKTE FASSUNG AUS DEM GUIDE:
{section_block}
Inhalt der Vertiefung:
- Erkläre das Konzept gründlicher: das Warum hinter den Regeln, nicht nur das Wie.
- Mehr und reichere Beispiele als im Guide — Varianten, Grenzfälle, ein realistischer Anwendungsfall.
- Typische Fehler und Missverständnisse, jeweils mit Korrektur.
- Abgrenzung zu verwandten Konzepten, wo Verwechslungsgefahr besteht.
- Baue auf der kompakten Fassung auf, wiederhole sie nicht.
Stil:
- Klares Deutsch, direkt, praxisorientiert. Fachbegriffe beim ersten Auftreten kurz erklären.
- Markdown: Absätze, `inline-code`, Codeblöcke mit Sprachangabe nur für Code, ### für Zwischenüberschriften, **fett** sparsam.
- Umfang: deutlich ausführlicher als die Guide-Fassung, aber kein Roman — etwa 300600 Wörter plus Beispiele.
Gib NUR das Markdown der Vertiefung aus — keine Einleitung wie "Hier ist…", kein Code-Fence um das Ganze, kein JSON.

View File

@@ -1,16 +0,0 @@
Eine konsolidierte Baustein-Liste zum Thema "{topic}" wurde aus drei Recherchen erstellt. Prüfe sie auf Verluste und Duplikate.
TITEL DER RECHERCHEN:
{results}
KONSOLIDIERTE LISTE:
{auswahl}
Prüfe genau zwei Dinge:
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? Die Zusammenfassung mehrerer Mikro-Einträge zu einer Lerneinheit ist KEIN Verlust — fehlend ist ein Konzept nur, wenn es nirgends, auch nicht innerhalb eines zusammengefassten Bausteins, enthalten ist.
2. Beschreiben mehrere Einträge der Liste DASSELBE Konzept? Der beste bleibt, die übrigen werden gestrichen.
Schreibe NUR die JSON-Datei nach: {out_path}
Format (Titel EXAKT wie in der konsolidierten Liste; nichts zu tun = leere Listen):
{{"nachtraege": ["Titel — Kurzbeschreibung"], "streichen": ["Exakter Titel aus der Liste"]}}

View File

@@ -0,0 +1,19 @@
Zum Thema "{topic}" haben {n} Agenten unabhängig über strittige Baustein-Einträge entschieden. Die Zahl in Klammern sagt, wie viele Agenten den Eintrag übernehmen wollen. Sortiere die Einträge.
BESCHLOSSENER KONSENS (nur Kontext — nicht ändern):
{konsens}
STRITTIGE EINTRÄGE MIT VOTUM:
{rest}
Regeln:
- Von der MEHRHEIT der {n} Agenten übernommen und keine Dublette zum Konsens → `aufnehmen`.
- Von NIEMANDEM übernommen oder Dublette zum Konsens → verwerfen (in keine Liste).
- Uneindeutig (Minderheits-Votum, fachlich unklar) → `rest`. Diese Einträge gehen in eine weitere Runde.{final}
- Übernimm Einträge wörtlich ("Titel — Kurzbeschreibung"), nicht umformulieren.
- Sind alle Einträge entschieden, ist `rest` LEER — das ist ein gültiges Ergebnis.
Schreibe NUR die JSON-Datei nach: {out_path}
Format (kein weiterer Text in der Datei):
{{"aufnehmen": ["Titel — Kurzbeschreibung"], "rest": ["Titel — Kurzbeschreibung"]}}

View File

@@ -1,16 +1,19 @@
Drei Recherche-Agenten haben unabhängig voneinander die Bausteine des Themas "{topic}" ermittelt. Konsolidiere ihre Ergebnisse zur endgültigen Baustein-Liste. Bei der Auswahl der Bausteine zum Thema "{topic}" sind einige Einträge strittig. Entscheide für jeden strittigen Eintrag: aufnehmen oder verwerfen.
{results} BESCHLOSSENER KONSENS (nur Kontext — nicht ändern):
{konsens}
STRITTIGE EINTRÄGE:
{rest}
Regeln: Regeln:
- Vereinige die Listen: erkenne gleiche Konzepte unter verschiedenen Titeln und führe sie zu einem Baustein zusammen. - AUFNEHMEN, wenn der Eintrag eine eigenständige Lerneinheit des Themas ist, die der Konsens nicht abdeckt — auch nicht unter anderem Titel oder innerhalb eines Sammeleintrags.
- Ein Baustein löst GENAU EIN PROBLEM. Einträge, die Varianten derselben Lösung sind, werden zu EINEM Baustein zusammengefasst (richtig: ein Baustein `<input>` für alle Typen, ein Baustein "Modalverben" für alle Modalverben; falsch: je ein Eintrag pro input-Typ oder pro Verb, aber auch Sammeleinträge, die mehrere Probleme mischen). - VERWERFEN, wenn er eine Dublette zum Konsens ist, nur eine Variante/Vertiefung eines vorhandenen Bausteins, Mikro-Granularität oder fachlich zweifelhaft.
- Ein Baustein ist ATOMAR: genau eine Idee, vollständig in sich. Test: Man kann nichts entfernen, ohne ihn unvollständig zu machen — und es fehlt nichts, um ihn zu verstehen. - Übernimm aufgenommene Einträge wörtlich ("Titel — Kurzbeschreibung"), nicht umformulieren.
- KONSOLIDIERE die Granularität: ein Baustein ist eine LERNEINHEIT, kein Lexikon-Eintrag. Liefern die Recherchen dutzende Mikro-Einträge derselben Sorte (eine CSS-Eigenschaft, ein Verb, eine Geste pro Eintrag), fasse sie nach Problem zusammen (richtig: "Flexbox-Ausrichtung" statt sechs Einträge für justify-content, align-items, …). Mehr als ~150 Bausteine sind fast immer ein Granularitäts-Problem — prüfe dann gezielt auf solche Serien. - Nichts Aufnehmenswertes dabei → leere Liste. Das ist ein gültiges Ergebnis.
- Verwirf Bausteine, die erfunden wirken. Eine fehlende Quelle allein ist kein Streichgrund, wenn mindestens zwei Recherchen den Baustein unabhängig nennen. Behalte im Zweifel, was mindestens eine Recherche belegt.
- KEINE Kategorien, KEINE Bewertung — eine flache, durchnummerierte Liste.
- 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 JSON-Datei nach: {out_path}
Format: GENAU eine Zeile pro Baustein: `N. Titel — Kurzbeschreibung`. Kein weiterer Text in der Datei. Format (kein weiterer Text in der Datei):
{{"uebernehmen": ["Titel — Kurzbeschreibung"]}}
Nichts aufnehmen: {{"uebernehmen": []}}

View File

@@ -0,0 +1,16 @@
{n} Recherche-Agenten haben unabhängig voneinander die Bausteine des Themas "{topic}" ermittelt. Exakt gleiche Titel wurden bereits zusammengeführt; die Zahl in Klammern sagt, wie viele Recherchen den Baustein nennen. Konsolidiere die Liste.
{eintraege}
Regeln:
- Erkenne GLEICHE Konzepte unter verschiedenen Titeln und führe sie zu einem Baustein zusammen. Die Nennungszahlen der zusammengeführten Einträge addieren sich dabei (pro Recherche zählt ein Konzept nur einmal).
- Ein Baustein löst GENAU EIN PROBLEM. Einträge, die Varianten derselben Lösung sind, werden zu EINEM Baustein zusammengefasst (richtig: ein Baustein `<input>` für alle Typen, ein Baustein "Modalverben" für alle Modalverben; falsch: je ein Eintrag pro input-Typ oder pro Verb, aber auch Sammeleinträge, die mehrere Probleme mischen).
- Ein Baustein ist ATOMAR: genau eine Idee, vollständig in sich. Test: Man kann nichts entfernen, ohne ihn unvollständig zu machen — und es fehlt nichts, um ihn zu verstehen.
- KONSOLIDIERE die Granularität: ein Baustein ist eine LERNEINHEIT, kein Lexikon-Eintrag. Liefern die Recherchen dutzende Mikro-Einträge derselben Sorte (eine CSS-Eigenschaft, ein Verb, eine Geste pro Eintrag), fasse sie nach Problem zusammen (richtig: "Flexbox-Ausrichtung" statt sechs Einträge für justify-content, align-items, …). Mehr als ~150 Bausteine sind fast immer ein Granularitäts-Problem — prüfe dann gezielt auf solche Serien.
- Teile danach in zwei Listen: Bausteine, die (nach dem Zusammenführen) von MINDESTENS ZWEI Recherchen genannt werden → `bausteine`. Nur einmal Genanntes oder fachlich Zweifelhaftes → `rest`. Verwirf nur, was offensichtlich erfunden ist.
- 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 JSON-Datei nach: {out_path}
Format (jeder Eintrag ein String "Titel — Kurzbeschreibung"; kein weiterer Text in der Datei):
{{"bausteine": ["Titel — Kurzbeschreibung"], "rest": ["Titel — Kurzbeschreibung"]}}

View File

@@ -1,24 +0,0 @@
Prüfe die Baustein-Auswahl für einen Lern-Guide zum Thema "{topic}" (Format: {format_name}).
Der Auftrag an die Auswahl war: {auswahl_auftrag}
INVENTAR (alle verfügbaren Bausteine):
{bausteine}
GETROFFENE AUSWAHL:
{auswahl}
Prüfe:
1. Fehlt etwas, das der Leser für diesen Zweck zwingend braucht?
2. Ist etwas drin, das dem Zweck nicht dient — Interna, Nischenfälle, Doppelungen (mehrere Lösungen fürs selbe Problem)?
3. Passt der Umfang zum Auftrag?
Du PRÜFST nur und notierst Probleme — du änderst die Auswahl nicht.
Schreibe NUR die JSON-Datei nach: {out_path}
Format — Auswahl in Ordnung:
{{"ok": true}}
Sonst (kurz und konkret, maximal 10 Punkte, Baustein-Titel exakt nennen):
{{"probleme": ["…", "…"]}}
{extra}

View File

@@ -1,21 +0,0 @@
Korrigiere die Baustein-Auswahl für einen Lern-Guide zum Thema "{topic}" (Format: {format_name}).
Der Auftrag an die Auswahl war: {auswahl_auftrag}
INVENTAR (alle verfügbaren Bausteine):
{bausteine}
BISHERIGE AUSWAHL:
{auswahl}
NOTIERTE PROBLEME (von der Prüfung):
{probleme}
Behebe NUR die notierten Probleme — alles andere bleibt unverändert.
Verwende die Titel EXAKT so, wie sie im Inventar stehen. Keine neuen erfinden.
Schreibe NUR die vollständige, korrigierte JSON-Datei nach: {out_path}
Format:
{{"bausteine": ["Exakter Titel", "Exakter Titel"]}}
{extra}

View File

@@ -0,0 +1,22 @@
{n} Agenten haben unabhängig Bausteine für {format_name} zum Thema "{topic}" gewählt. Der Konsens (Mehrheit) steht fest und ist nur Kontext. Sortiere die strittigen Einträge.
AUFTRAG DER AUSWAHL: {auswahl_auftrag}
BESCHLOSSENER KONSENS ({konsens_n} Bausteine — nicht ändern):
{konsens}
STRITTIGE EINTRÄGE MIT VOTUM:
{rest}
Regeln:
- Von der Mehrheit der {n} Agenten gewählt und dem Auftrag dienlich → `aufnehmen`.
- Von kaum jemandem gewählt oder für den Auftrag entbehrlich → verwerfen (in keine Liste).
- Uneindeutig → `rest`. Diese Einträge gehen in eine weitere Runde.{final}
- Zielgröße: Der Konsens plus `aufnehmen` muss am Ende in der Spanne {k_min}{k_max} Bausteine landen. Plane mit `rest` entsprechend.
- Übernimm Titel EXAKT wie gelistet, nicht umformulieren.
- Sind alle Einträge entschieden, ist `rest` LEER — das ist ein gültiges Ergebnis.
Schreibe NUR die JSON-Datei nach: {out_path}
Format (kein weiterer Text in der Datei):
{{"aufnehmen": ["Titel"], "rest": ["Titel"]}}

View File

@@ -1,24 +0,0 @@
Prüfe die Gliederung eines Lern-Guides zum Thema "{topic}" (Format: {format_name}).
Zielgruppe: Anfänger. Zweck: {zweck}.
GEWÄHLTE BAUSTEINE (müssen alle vorkommen):
{auswahl}
GLIEDERUNG:
{gliederung}
Prüfe:
1. Kommt jeder gewählte Baustein in GENAU einem Kapitel vor (nichts fehlt, nichts doppelt, nichts erfunden)?
2. Führt Kapitel 1 zum schnellsten sichtbaren Ergebnis — oder beginnt es mit Theorie/Interna?
3. Stehen Voraussetzungen vor dem, was auf ihnen aufbaut? Konkretes vor Abstraktem?
4. Kapitelgrößen 37, Kapiteltitel kurz und konkret?
Du PRÜFST nur und notierst Probleme — du änderst die Gliederung nicht.
Schreibe NUR die JSON-Datei nach: {out_path}
Format — Gliederung in Ordnung:
{{"ok": true}}
Sonst (kurz und konkret, maximal 10 Punkte):
{{"probleme": ["…", "…"]}}
{extra}

View File

@@ -1,20 +0,0 @@
Korrigiere die Gliederung eines Lern-Guides zum Thema "{topic}" (Format: {format_name}).
GEWÄHLTE BAUSTEINE (müssen alle vorkommen):
{auswahl}
BISHERIGE GLIEDERUNG:
{gliederung}
NOTIERTE PROBLEME (von der Prüfung):
{probleme}
Behebe NUR die notierten Probleme — alles andere bleibt unverändert.
- JEDER gewählte Baustein landet in GENAU einem Kapitel.
- Verwende die Titel EXAKT so, wie sie in der Liste stehen.
Schreibe NUR die vollständige, korrigierte JSON-Datei nach: {out_path}
Format:
{{"kapitel": [{{"titel": "Grundlagen", "bausteine": ["Exakter Titel", "Exakter Titel"]}}]}}
{extra}

View File

@@ -0,0 +1,19 @@
{n} Agenten haben die feste Baustein-Auswahl für {format_name} zum Thema "{topic}" unabhängig gegliedert ({zweck}). Wähle die beste Gliederung und finalisiere sie.
GEWÄHLTE BAUSTEINE (die Auswahl steht fest):
{bausteine}
DIE VORSCHLÄGE:
{gliederungen}
Vorgehen:
- Wähle den überzeugendsten Vorschlag als Basis: bester roter Faden, vom Bekannten zum Unbekannten, erster Erfolg vor Theorie.
- Übernimm überzeugende Ideen der anderen Vorschläge: besserer Kapitelschnitt, bessere Reihenfolge, klarere Kapiteltitel.
- Es gelten die Gliederungs-Regeln: JEDER Baustein in GENAU einem Kapitel, Titel EXAKT wie gelistet, 37 Bausteine pro Kapitel, Kapiteltitel kurz und konkret.
- Erfinde nichts Neues — du kombinierst und entscheidest, du gliederst nicht von vorn.
Schreibe NUR die JSON-Datei nach: {out_path}
Format:
{{"kapitel": [{{"titel": "Grundlagen", "bausteine": ["Exakter Titel", "Exakter Titel"]}}]}}
{extra}

View File

@@ -0,0 +1,23 @@
Bei der Baustein-Auswahl für {format_name} zum Thema "{topic}" sind Einträge strittig geblieben. Entscheide für jeden strittigen Eintrag: übernehmen oder weglassen.
AUFTRAG DER AUSWAHL: {auswahl_auftrag}
BESCHLOSSENER KONSENS (nur Kontext — nicht ändern):
{konsens}
STRITTIGE EINTRÄGE:
{rest}
Regeln:
- ÜBERNEHMEN, wenn der Baustein dem Auftrag dient und der Konsens das Thema dort nicht schon abdeckt.
- WEGLASSEN, wenn er für den Zweck entbehrlich ist, zu speziell oder vom Konsens abgedeckt.
- Einziges Kriterium ist der Auftrag — nicht persönliche Vollständigkeit.
- Übernimm Titel EXAKT wie gelistet, nicht umformulieren.
- Nichts Übernehmenswertes dabei → leere Liste. Das ist ein gültiges Ergebnis.
Schreibe NUR die JSON-Datei nach: {out_path}
Format (kein weiterer Text in der Datei):
{{"uebernehmen": ["Titel"]}}
Nichts übernehmen: {{"uebernehmen": []}}
{extra}

View File

@@ -0,0 +1,19 @@
{n} Agenten haben aus derselben Faktenbasis je einen vollständigen 7-Karten-Satz für den OnePager zum Thema "{topic}" gebaut. Wähle pro Karte die beste Fassung und kombiniere sie zum finalen Satz.
FAKTENBASIS (alleinige Quelle — Maßstab für Faktentreue):
{recherche}
DIE ENTWÜRFE:
{kartensaetze}
Regeln:
- Pro Karte (Schlüssel in eckigen Klammern) die beste Fassung wählen: faktentreu zur Faktenbasis, kompakt, klarste Stichpunkte.
- Karten dürfen wörtlich übernommen werden. Kleine Verbesserungen beim Kombinieren sind erlaubt, nichts hinzuerfinden.
- Kompaktheit gilt: max. 5 Stichpunkte pro Karte, je max. ~8 Wörter; keine Tabellen, keine Einleitungssätze.
- Alle 7 Karten müssen vorhanden sein (JSON-Schlüssel exakt): info, eigenschaften, beispiel, zusammenhaenge, voraussetzungen, modern, veraltet.
Schreibe NUR die JSON-Datei nach: {out_path}
Format:
{{"karten": {{"info": {{"titel": "{topic}", "md": "…"}}, "eigenschaften": {{"titel": "Kerneigenschaften", "md": "…"}}, "beispiel": {{"titel": "Beispiel", "md": "…"}}, "zusammenhaenge": {{"titel": "Zusammenhänge", "md": "…"}}, "voraussetzungen": {{"titel": "Voraussetzungen", "md": "…"}}, "modern": {{"titel": "Neu & aktuell", "md": "…"}}, "veraltet": {{"titel": "Veraltet & überholt", "md": "…"}}}}}}
{extra}

View File

@@ -1,27 +0,0 @@
Prüfe die Faktenbasis für einen OnePager zum Projekt "{topic}".
FAKTENBASIS:
{recherche}
Sie muss diese Dimensionen abdecken:
1. Kurzbeschreibung (Art des Projekts, Gegenstand)
2. Technische Daten (Technologie/Format, Umfang, Stand/Aktualität)
3. Inhaltsübersicht (was einen im Projekt erwartet)
4. Ein typisches Beispiel aus dem Projekt
5. Zusammenhänge mit ANDEREN Themen (Nachbarthemen außerhalb des Projektinhalts)
6. Voraussetzungen (vorher zu bearbeitende Themen)
7. Neuerungen der letzten Jahre vs. nicht mehr Verwendetes (oder die ausdrückliche Feststellung, dass es jeweils nichts gibt)
Prüfe:
1. Ist jede Dimension mit konkreten Fakten aus den Projektdateien belegt (Namen, Zahlen — nicht vage)?
2. Hat jeder Punkt einen Dateipfad als Quelle?
3. Wirkt etwas erfunden — also nicht aus dem Projekt belegbar?
Du PRÜFST nur und notierst Probleme — du änderst nichts.
Schreibe NUR die JSON-Datei nach: {out_path}
Format — alles in Ordnung:
{{"ok": true}}
Sonst (kurz und konkret, maximal 10 Punkte):
{{"probleme": ["…", "…"]}}

View File

@@ -1,27 +0,0 @@
Prüfe die Faktenbasis für einen OnePager zum Thema "{topic}".
FAKTENBASIS:
{recherche}
Sie muss diese Dimensionen abdecken:
1. Kurzbeschreibung (12 Sätze)
2. Eckdaten (Art/Typ; bei Software: Version, Lizenz/Kosten; bei Sprachen, Methoden, Theorien: Ursprung/Urheber, heutiger Stand, Anwendungsfelder)
3. Inhaltsübersicht (was einen im Thema erwartet)
4. Ein typisches Beispiel im themengerechten Format (Code, Beispielsätze oder Mini-Szenario)
5. Zusammenhänge mit ANDEREN Themen (Nachbarthemen, nicht Inhalte des Themas selbst)
6. Voraussetzungen (vorher zu bearbeitende Themen)
7. Neuerungen der letzten Jahre vs. nicht mehr Verwendetes (oder die ausdrückliche Feststellung, dass es jeweils nichts gibt)
Prüfe:
1. Ist jede Dimension mit konkreten Fakten belegt (Namen, Zahlen, Versionen bzw. Urheber/Jahreszahlen — nicht vage)?
2. Hat jeder Punkt eine Quelle?
3. Wirkt etwas erfunden oder widersprüchlich?
Du PRÜFST nur und notierst Probleme — du änderst nichts.
Schreibe NUR die JSON-Datei nach: {out_path}
Format — alles in Ordnung:
{{"ok": true}}
Sonst (kurz und konkret, maximal 10 Punkte):
{{"probleme": ["…", "…"]}}

View File

@@ -1,16 +0,0 @@
Überarbeite die Faktenbasis für einen OnePager zum Thema "{topic}".
{source}
BISHERIGE FAKTENBASIS:
{recherche}
NOTIERTE PROBLEME (von der Prüfung):
{probleme}
Behebe NUR die notierten Probleme — Fehlendes anhand der oben genannten Quelle ergänzen, Vages konkretisieren, Unbelegtes belegen oder streichen. Alles andere bleibt erhalten.
Schreibe die VOLLSTÄNDIGE, überarbeitete Markdown-Datei nach: {out_path}
Kompakt, faktenorientiert, mit Quelle pro Punkt.
{extra}

View File

@@ -0,0 +1,13 @@
{n} Agenten haben unabhängig die Faktenbasis für einen OnePager zum Thema "{topic}" recherchiert. Konsolidiere ihre Ergebnisse zu EINER Faktenbasis.
{recherchen}
Regeln:
- Behalte die Struktur der 7 Dimensionen (Kurzbeschreibung, Eckdaten, Inhaltsübersicht, Beispiel, Zusammenhänge, Voraussetzungen, Neuerungen vs. Veraltetes).
- Mehrfach belegte Fakten haben Vorrang. Dubletten zusammenführen, nicht wiederholen.
- Widersprüche zugunsten der besser belegten bzw. aktuelleren Version auflösen.
- Nur einmal Genanntes übernehmen, wenn es eine Quelle hat und plausibel ist — sonst weglassen.
- Quellenangaben pro Punkt behalten. Nichts hinzuerfinden.
- Wähle das beste Beispiel der Recherchen, nicht mehrere.
Schreibe NUR die Markdown-Datei nach: {out_path}