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)
_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
_CLAUDE_TOOLS = {
"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)
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()
process = await asyncio.create_subprocess_exec(
*cmd,
stdin=asyncio.subprocess.PIPE if stdin_data is not None else asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
async def spawn():
return await asyncio.create_subprocess_exec(
*cmd,
stdin=asyncio.subprocess.PIPE if stdin_data is not None else asyncio.subprocess.DEVNULL,
stdout=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
try:
try:
@@ -154,7 +169,7 @@ async def _run_opencode(agent_key: str, prompt: str, timeout: int, provider: str
"-f", str(prompt_path),
]
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
finally:
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 logging
@@ -7,15 +17,15 @@ import subprocess
from pathlib import Path
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 jsonio import read_json_file as _json_datei
from paths import arbeit_dir, bausteine_path, project_dir
from pipeline import (
CANCELLED, FAILED, GenContext, _extra, _log, _prompt, _race,
_semaphore, _timeout, run_single_slot,
CANCELLED, FAILED, GenContext, _extra, _log, _prompt, _race, _rest_schema,
_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")
@@ -24,11 +34,11 @@ _bausteine_errors: dict[str, str] = {}
_bausteine_cancelled: set[str] = set()
_bausteine_step: dict[str, int] = {}
BAUSTEINE_STEPS = ("Recherche", "Auswahl", "Prüfung")
BAUSTEINE_STEPS = ("Recherche", "Konsolidierung", "Klärung")
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():
return BAUSTEINE_STEPS + ("Ergänzung",)
return BAUSTEINE_STEPS
@@ -36,18 +46,24 @@ def _bausteine_steps(topic: str) -> tuple:
def _bausteine_files(topic: str) -> dict:
arbeit = arbeit_dir(topic)
runden = range(1, KONSENS_MAX_RUNDEN + 1)
return {
"final": bausteine_path(topic),
"arbeit": arbeit,
"recherche": [arbeit / f"recherche-{i}.md" for i in (1, 2, 3, 4)],
"auswahl": [arbeit / f"auswahl-{i}.md" for i in (1, 2)],
"auswahl_check": arbeit / "auswahl-check.json",
"recherche": [arbeit / f"recherche-{i}.md" for i in (1, 2, 3, 4, 5)],
"recherche_mapping": arbeit / "recherche-mapping.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",
}
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:
@@ -63,9 +79,14 @@ def _resume_step(topic: str) -> int:
files = _bausteine_files(topic)
if sum(p.exists() for p in files["recherche"]) < 3:
return 0
if not any(p.exists() for p in files["auswahl"]):
if not files["recherche_mapping"].exists():
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
if project_dir(topic).is_dir() and not files["ergaenzung"].exists():
return 3
@@ -168,25 +189,15 @@ def _file_payload(path: Path):
return text if _parse_auswahl(text) else None
def _auswahl_payload(path: Path):
if not path.exists():
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ß."""
def _mapping_schema(data):
"""{"bausteine": [str, ≥1], "rest": [str]} → (bausteine, rest) · sonst None."""
if not isinstance(data, dict):
return None
nach = data.get("nachtraege", [])
streich = data.get("streichen", [])
if not isinstance(nach, list) or not isinstance(streich, list):
bausteine = _str_liste(data.get("bausteine"))
rest = _str_liste(data.get("rest"))
if not bausteine or rest is None:
return None
if not all(isinstance(x, str) for x in [*nach, *streich]):
return None
return {"nachtraege": nach, "streichen": streich}
return bausteine, rest
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):
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] = []
offen = []
for i, path in enumerate(files["recherche"], 1):
text = _file_payload(path)
if text is not None and len(recherchen) < 3:
if text is not None:
recherchen.append(text)
else:
offen.append((i, path))
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:
caps = "files" if project else "full"
slots = [
@@ -247,80 +259,136 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
]
neue = await _race(
topic, "Recherche", slots, 3 - vorhanden, _timeout("recherche"), provider,
on_update=lambda c: set_p(f"Recherche läuft ({vorhanden + c}/3 gültig)…"),
cancelled=is_cancelled,
on_update=lambda c: set_p(f"Recherche läuft ({vorhanden + c} gültig, min. 3)…"),
cancelled=is_cancelled, grace=KONSENS_GRACE,
)
if is_cancelled():
abgebrochen()
return
if neue is None:
_bausteine_errors[topic] = "Recherche fehlgeschlagen (Quorum nicht erreicht)"
_bausteine_errors[topic] = "Recherche fehlgeschlagen (Minimum nicht erreicht)"
return
recherchen += neue
# Schritt 2: 2 Auswahl-Agenten, der erste gewinnt — vorhandene gültige Datei wird übernommen
n_est = max(len(_parse_auswahl(t)) for t in recherchen)
bestehende = next((res for p in files["auswahl"] if (res := _auswahl_payload(p)) is not None), None)
if bestehende is not None:
flat, entries = bestehende
else:
# Schritt 2: Recherche-Mapping — Code-Vormerge (exakte Titel) + 1 Agent
# für semantische Dubletten und Konsens/Rest-Teilung (fatal)
mapping = _mapping_schema(_json_datei(files["recherche_mapping"]))
if mapping is None:
set_p("Konsolidiere Recherche…", step=1)
results_block = "\n\n".join(f"### Recherche {i}\n\n{text}" for i, text in enumerate(recherchen, 1))
slots = [
{
"key": f"bausteine-{topic}-auswahl-{i}",
"prompt": _prompt("Bausteine-Auswahl", topic=topic, results=results_block, out_path=path),
"role": "fast", "capabilities": "files",
"payload": (lambda result, p=path: _auswahl_payload(p)),
}
for i, path in enumerate(files["auswahl"], 1)
]
auswahl = await _race(topic, "Auswahl", slots, 1, _timeout("auswahl", n_est), provider, cancelled=is_cancelled)
if is_cancelled():
abgebrochen()
return
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)),
files["recherche_mapping"].unlink(missing_ok=True)
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))
status, mapping = await run_single_slot(
ctx, "Recherche-Mapping",
key=f"bausteine-{topic}-recherche-mapping",
prompt=_prompt(
"Bausteine-Recherche-Mapping",
topic=topic, n=len(recherchen), eintraege=eintraege,
out_path=files["recherche_mapping"],
),
role="judge", capabilities="files",
payload=lambda result: _mapping_schema(_json_datei(files["recherche_mapping"])),
timeout=_timeout("recherche_mapping", len(gemergt)),
)
if status == CANCELLED:
abgebrochen()
return
if status == FAILED:
_log(topic, "Auswahl-Check fehlgeschlagen — fahre ohne Korrekturen fort")
else:
patch = check
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)}
_bausteine_errors[topic] = "Recherche-Mapping fehlgeschlagen"
return
konsens, rest = mapping
# 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].
if project:
set_p("Ergänze Themenfeld…", step=3)

View File

@@ -15,20 +15,33 @@ MAX_CONCURRENT_GENERATIONS = 10
MAX_CONCURRENT_AGENTS = 12
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).
# Gilt für alle Provider gleich — wer zu langsam ist, wird neu gestartet bzw. überholt.
TIMEOUTS = {
"recherche": (1800, 0), # fix 30 min
"auswahl": (600, 10),
"auswahl_check": (300, 2),
"recherche_mapping": (600, 3), # n = vorgemergte Einträge
"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)
"guide_auswahl": (300, 5), # pro Baustein im Inventar
"guide_check": (300, 2), # Auswahl-/Gliederungs-Prüfung (nur Titellisten)
"plan": (300, 5),
"plan_judge": (600, 5), # Judge liest bis zu 5 Gliederungen, n = Sections
"writer": (600, 120), # pro Section im Chunk
"lese_check": (300, 10), # pro Section im Paket
"onepager_recherche": (900, 0),
"onepager_mapping": (600, 0), # Konsolidierung der Recherchen
"onepager_bauen": (300, 0),
"onepager_judge": (600, 0), # Judge über die Karten-Sätze
"onepager_verify": (300, 0),
}
@@ -41,31 +54,27 @@ FORMAT_ANTEIL = {
# Provider-Stacks: komplett unabhängig, einer kann jederzeit entfernt werden.
# Rollen: "quick" = Massenarbeit (Recherche, Einordnung),
# "fast" = Urteilsaufgaben mit kleinem Output (Auswahl, Final, OnePager, Chat),
# "guide" = große Generierung (Plan, Writer).
# "fast" = Interaktion + Voten (Chat, Prüfung, Klärung, Elemente),
# "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"
PROVIDERS = {
"claude": {
"cli": "claude",
"guide": "claude-opus-4-8[1m]",
"fast": "claude-sonnet-4-6",
"judge": "claude-sonnet-4-6", # CLI kennt keine Temperature
"quick": "claude-sonnet-4-6",
"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": {
"cli": "opencode",
"guide": "minimax/MiniMax-M3",
"fast": "minimax/MiniMax-M2.7-highspeed",
"quick": "minimax/MiniMax-M2.7-highspeed",
"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",
"fast": "minimax-kalt/MiniMax-M2.7-highspeed",
"judge": "minimax-kalt/MiniMax-M3",
"quick": "minimax/MiniMax-M2.7-highspeed",
"env_key": "MINIMAX_API_KEY",
},
@@ -73,6 +82,7 @@ PROVIDERS = {
"cli": "opencode",
"guide": "ollama/qwen3.6:27b",
"fast": "ollama/qwen3.5:9b",
"judge": "ollama/qwen3.5:9b",
"quick": "ollama/qwen3.5:9b",
"env_key": None,
"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
@@ -67,6 +89,8 @@ async def init_db():
await db.execute(CREATE_PROGRESS)
await db.execute(CREATE_TOPICS)
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
await db.execute("ALTER TABLE guides ADD COLUMN step INTEGER")
except aiosqlite.OperationalError:
@@ -255,3 +279,104 @@ async def delete_progress(guide_id: str) -> None:
db = await get_db()
await db.execute("DELETE FROM guide_progress WHERE guide_id = ?", (guide_id,))
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)"
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."""
fallback = {"title": hint.strip() or "Neues Element", "description": "", "examples": [], "hints": []}
try:
context = _topic_context(topic)
if extra_context.strip():
context = (extra_context.strip() + "\n\n" + context)[:12000]
prompt = _prompt(
"Element-Create",
topic=topic, hint=hint.strip() or "(keins — wähle selbst ein Kernkonzept)",
context=_topic_context(topic),
context=context,
)
returncode, stdout, _ = await run_agent(
"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.
"""
import asyncio
import json
import logging
import math
from datetime import datetime, timezone
@@ -13,17 +16,20 @@ from pathlib import Path
from agents import run_agent
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 fsutil import atomic_write_json, atomic_write_text
from fsutil import atomic_write_json
from jsonio import read_json_file as _json_datei
from onepager import _generate_onepager
from paths import bausteine_path, guide_content_path, project_dir
from pipeline import (
CANCELLED, FAILED, GenContext, _check_then_fix, _claude_error, _extra,
_fail, _gather_error, _log, _prompt, _race, _semaphore, _set_progress,
_set_step, _timeout, clear_guide_cancelled, is_guide_cancelled,
run_single_slot,
CANCELLED, FAILED, GenContext, _claude_error, _extra,
_fail, _gather_error, _log, _prompt, _race, _rest_schema, _runde_schema,
_semaphore, _set_progress, _set_step, _timeout, clear_guide_cancelled,
is_guide_cancelled, run_single_slot,
)
from textkit import (
_eindeutige_titel, _lade_bausteine, _parse_fragment, _split_chunks,
@@ -32,7 +38,7 @@ from textkit import (
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).
# 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:
d, stem = content_path.parent, content_path.stem
runden = range(1, KONSENS_MAX_RUNDEN + 1)
return {
"auswahl": d / f"{stem}.auswahl.json",
"auswahl_check": d / f"{stem}.auswahl-check.json",
"gliederung": d / f"{stem}.gliederung.json",
"gliederung_check": d / f"{stem}.gliederung-check.json",
# chunk-/lese-check-/fix-Dateien sind dynamisch: {stem}.chunk-i.md usw.
# Runde 1: 5 volle Auswahl-Vorschläge; Runden 2+: 3 Klärungs-Voten
"auswahl_slots": {
n: [d / f"{stem}.auswahl-r{n}-{i}.json" for i in range(1, (5 if n == 1 else 3) + 1)]
for n in runden
},
"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
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(
guide_id: str, topic: str, format_name: str, entries: dict[int, 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."
)
# Schritt 1: Auswahl — vorhandene gültige Datei wird übernommen (Resume)
auswahl = _resolve_auswahl(_json_datei(files["auswahl"]), entries, k_min, k_max)
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
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()),
# Schritt 0: Auswahl-Konsens (5 Agenten → Voting → Mapping → Klärungs-Loop)
auswahl = await _konsens_auswahl(
ctx, files, entries, k_min, k_max, auswahl_auftrag, format_name,
bausteine_liste, instructions,
)
if status == CANCELLED:
if auswahl is None:
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}
soll = len(sel_entries)
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)
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)
vorschlaege: list[list[dict]] = []
offen = []
for i, path in enumerate(files["gliederung_slots"], 1):
res = _resolve_gliederung(_json_datei(path), sel_entries, soll, soll)
if res is not None:
vorschlaege.append(res)
else:
offen.append((i, path))
if len(vorschlaege) < 3:
slots = [
{
"key": f"{guide_id}-gliederung-{i}",
"prompt": _prompt(
"Guide-Gliederung",
topic=topic, format_name=format_name, bausteine=sel_liste,
out_path=path, extra=_extra(instructions),
),
"role": "guide", "capabilities": "files",
"payload": (lambda result, p=path: _resolve_gliederung(_json_datei(p), sel_entries, soll, soll)),
}
for i, path in offen
]
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, "Gliederung",
key=f"{guide_id}-gliederung",
ctx, "Gliederungs-Judge",
key=f"{guide_id}-gliederung-judge",
prompt=_prompt(
"Guide-Gliederung",
topic=topic, format_name=format_name, bausteine=sel_liste,
"Guide-Gliederung-Judge",
topic=topic, format_name=format_name, zweck=zweck, n=len(vorschlaege),
bausteine=sel_liste, gliederungen=bloecke,
out_path=files["gliederung"], extra=_extra(instructions),
),
role="guide", capabilities="files",
role="judge", capabilities="files",
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:
return None
if status == FAILED:
await _fail(guide_id, "Gliederung fehlgeschlagen")
await _fail(guide_id, "Gliederung fehlgeschlagen (Judge ohne gültiges Ergebnis)")
return None
def gliederung_text() -> str:
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)
# Schritt 2: Schreiben — vorhandene Chunk-Dateien werden übernommen (Resume)
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))))
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)]
offen = [i for i, p in enumerate(paths) if not p.exists()]
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(*[
run_agent(
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")
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]
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:
return "\n\n".join(f"SECTION: {_titel(entries[num])}\n{by_num[num]['md']}" for num in nums)
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)
slots = [{
"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
def auftraege_text(nums: list[int], probleme: dict[int, str]) -> str:
return "\n\n".join(
f"SECTION: {_titel(entries[num])}\nPROBLEM: {probleme[num]}\nAKTUELLER INHALT:\n{by_num[num]['md']}"
for num in nums
)
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"]
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
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)…")
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_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(
f"SECTION: {_titel(entries[num])}\nPROBLEM: {probleme_by_num[num]}\nAKTUELLER INHALT:\n{by_num[num]['md']}"
for num in nums
)
fix_paths = [content_path.parent / f"{content_path.stem}.fix-r{runde}-{i + 1}.md" for i in range(writer_count)]
fix_offen = [i for i, nums in enumerate(fix_chunks) if nums and not fix_paths[i].exists()]
results = await asyncio.gather(*[
run_agent(
f"{guide_id}-fix-w{i + 1}",
f"{guide_id}-fix-r{runde}-w{i + 1}",
_prompt(
"Guide-Sections-Fix",
topic=topic, format_name=format_name, facts=facts, spec=spec,
auftraege=auftraege_text(fix_chunks[i]),
auftraege=auftraege_text(fix_chunks[i], probleme_by_num),
out_path=fix_paths[i], extra=_extra(instructions),
),
_timeout("writer", len(fix_chunks[i])), provider=provider, role="guide", capabilities="full",
@@ -394,17 +578,23 @@ async def _generate_sections(
return None
for i, r in zip(fix_offen, results):
if isinstance(r, BaseException) or (not isinstance(r, BaseException) and r[0] != 0):
_log(topic, f"Sections-Fix {i + 1} fehlgeschlagen — Original bleibt")
ersetzt = 0
for i in fix_offen:
if not fix_paths[i].exists():
_log(topic, f"Sections-Fix {i + 1} (Runde {runde}) fehlgeschlagen — Original bleibt")
ersetzt: set[int] = set()
for p in fix_paths:
if not p.exists():
continue
for sec in _parse_fragment(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"])
if num in probleme_by_num and sec["md"].strip():
by_num[num] = sec
ersetzt += 1
_log(topic, f"Lese-Prüfung: {ersetzt} Section(s) überarbeitet")
ersetzt.add(num)
_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…")
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",
]
ProviderType = Literal["claude", "minimax", "minimax-direkt", "lokal"]
ProviderType = Literal["claude", "minimax", "lokal"]
class GuideCreateRequest(BaseModel):
@@ -156,3 +156,53 @@ class ProgressUpdate(BaseModel):
class ProgressResponse(BaseModel):
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 fsutil import atomic_write_json
from config import KONSENS_GRACE, KONSENS_MAX_RUNDEN
from jsonio import read_json_file as _json_datei
from pipeline import (
CANCELLED, FAILED, GenContext, _check_then_fix, _extra, _fail,
_prompt, _set_step, _timeout, is_guide_cancelled, run_single_slot,
CANCELLED, FAILED, GenContext, _extra, _fail, _log, _probleme_schema,
_prompt, _race, _set_step, _timeout, is_guide_cancelled, run_single_slot,
)
ONEPAGER_STEPS = ("Recherche", "Bauen", "Prüfung")
async def _generate_onepager(
guide_id: str, topic: str, instructions: str, provider: str,
project: Path | None, content_path: Path,
) -> 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)
KARTEN_KEYS = ("info", "eigenschaften", "beispiel", "zusammenhaenge", "voraussetzungen", "modern", "veraltet")
@@ -38,116 +48,193 @@ async def _generate_onepager(
return out
d, stem = content_path.parent, content_path.stem
recherche_path = d / f"{stem}.recherche.md"
recherche_check_path = d / f"{stem}.recherche-check.json"
karten_path = d / f"{stem}.karten.json"
check_path = d / f"{stem}.onepager-check.json"
recherche_slots = [d / f"{stem}.recherche-{i}.md" for i in (1, 2, 3)]
recherche_path = d / f"{stem}.recherche.md" # konsolidierte Faktenbasis
karten_slots = [d / f"{stem}.karten-{i}.json" for i in (1, 2, 3)]
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
# (Version, Lizenz, Alternativen) laufen dort ins Leere.
if project:
source = _prompt("OnePager-Quelle-Projekt", project=project)
recherche_template = "OnePager-Recherche-Projekt"
recherche_check_template = "OnePager-Recherche-Check-Projekt"
else:
source = _prompt("OnePager-Quelle-Thema", topic=topic)
recherche_template = "OnePager-Recherche"
recherche_check_template = "OnePager-Recherche-Check"
def recherche_payload(result=None):
if not recherche_path.exists():
def text_payload(path: Path):
if not path.exists():
return None
text = recherche_path.read_text(encoding="utf-8").strip()
text = path.read_text(encoding="utf-8").strip()
return text or None
# Schritt 1: Recherche — vorhandene Datei wird übernommen (Resume)
recherche = recherche_payload()
# Schritt 0: Recherche — 3 Agenten (min. 2, Grace), Mapping konsolidiert.
# Eine gültige recherche.md (auch aus Altläufen) überspringt den Schritt.
recherche = text_payload(recherche_path)
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(
ctx, "OnePager-Recherche",
key=f"{guide_id}-recherche",
prompt=_prompt(recherche_template, topic=topic, source=source, out_path=recherche_path, extra=_extra(instructions)),
role="quick", capabilities="files" if project else "full",
payload=recherche_payload, timeout=_timeout("onepager_recherche"),
ctx, "Recherche-Mapping",
key=f"{guide_id}-recherche-mapping",
prompt=_prompt(
"OnePager-Recherche-Mapping",
topic=topic, n=len(recherchen), recherchen=recherchen_block, out_path=recherche_path,
),
role="judge", capabilities="files",
payload=lambda result: text_payload(recherche_path),
timeout=_timeout("onepager_mapping"),
)
if status == CANCELLED:
return None
if status == FAILED:
await _fail(guide_id, "OnePager-Recherche fehlgeschlagen")
await _fail(guide_id, "Recherche-Konsolidierung 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"),
fix_role="quick", fix_caps="files" if project else "full",
)
if status == CANCELLED:
return None
if status == FAILED:
await _fail(guide_id, "Recherche-Prüfung fehlgeschlagen")
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))
if karten is None:
await _set_step(guide_id, 2, "Baue OnePager…")
karten_path.unlink(missing_ok=True)
await _set_step(guide_id, 1, "Baue OnePager (3 Entwürfe)")
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(
ctx, "OnePager-Bauen",
key=f"{guide_id}-bauen",
prompt=_prompt("OnePager-Bauen", topic=topic, recherche=recherche, out_path=karten_path, extra=_extra(instructions)),
role="fast", capabilities="files",
ctx, "Bauen-Judge",
key=f"{guide_id}-bauen-judge",
prompt=_prompt(
"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)),
timeout=_timeout("onepager_bauen"),
timeout=_timeout("onepager_judge"),
)
if status == CANCELLED:
return None
if status == FAILED:
await _fail(guide_id, "OnePager-Bau fehlgeschlagen")
await _fail(guide_id, "OnePager-Bau fehlgeschlagen (Judge ohne gültiges Ergebnis)")
return None
def karten_block() -> str:
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
status, fixed = await _check_then_fix(
ctx, name="OnePager", step=3,
check_key=f"{guide_id}-verify",
check_prompt=_prompt("OnePager-Verifikation", topic=topic, recherche=recherche, karten=karten_block(), out_path=check_path),
check_path=check_path, check_timeout=_timeout("onepager_verify"),
fix_key=f"{guide_id}-karten-fix",
build_fix_prompt=lambda probleme: _prompt(
"OnePager-Fix",
topic=topic, recherche=recherche, karten=karten_block(),
probleme="\n".join(f"- {p}" for p in probleme),
out_path=karten_path, extra=_extra(instructions),
),
fix_payload=lambda result: karten_schema(_json_datei(karten_path)),
fix_timeout=_timeout("onepager_bauen"),
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:
return None
if status == FAILED:
await _fail(guide_id, "OnePager-Prüfung fehlgeschlagen")
return None
if fixed is not None:
# Schritt 2: Prüf-Loop — Verify notiert Probleme, Fix behebt; max. Runden-Cap.
# Runde 1 ist fatal (wie früher der Einzel-Check), danach bleibt bei Fehlern
# die letzte gültige Version stehen.
for runde in range(1, KONSENS_MAX_RUNDEN + 1):
probleme = _probleme_schema(_json_datei(verify_paths[runde]))
if probleme is None:
await _set_step(guide_id, 2, f"Prüfe OnePager (Runde {runde}/{KONSENS_MAX_RUNDEN})…")
verify_paths[runde].unlink(missing_ok=True)
status, probleme = await run_single_slot(
ctx, f"OnePager-Prüfung r{runde}",
key=f"{guide_id}-verify-r{runde}",
prompt=_prompt("OnePager-Verifikation", topic=topic, recherche=recherche, karten=karten_block(), out_path=verify_paths[runde]),
role="judge", capabilities="files",
payload=lambda result, p=verify_paths[runde]: _probleme_schema(_json_datei(p)),
timeout=_timeout("onepager_verify"),
)
if status == CANCELLED:
return None
if status == FAILED:
if runde == 1:
await _fail(guide_id, "OnePager-Prüfung fehlgeschlagen")
return 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
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).
Zugriff auf das Cancel-Set NUR über die Funktionen hier — kopierte Referenzen
@@ -105,10 +105,39 @@ def _probleme_schema(data):
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
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.
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
Quorum steht, werden die übrigen Agenten gekillt. None = Quorum verfehlt.
`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))}
tasks: dict[asyncio.Task, int] = {}
loop = asyncio.get_running_loop()
deadline: float | None = None
def spawn(i: int) -> None:
slot = slots[i]
@@ -136,7 +172,15 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
while tasks:
if cancelled and cancelled():
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:
i = tasks.pop(task)
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:
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:
on_update(len(results))
if len(results) >= quorum:
if len(results) >= quorum and (grace is None or loop.time() >= deadline):
return results
continue
_log(topic, f"{label} {i + 1} (Versuch {attempts[i] + 1}): {err}")
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)
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)")
return None
finally:
@@ -184,7 +236,7 @@ class GenContext:
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"
@@ -205,47 +257,3 @@ async def run_single_slot(
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):
- 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
- 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
Query-Schleifen mehr pro Guide.
"""
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 paths import bausteine_path, guide_content_path
from textkit import _norm_titel
MAX_OFFENE_GUIDES = 3
VORSTUFE = {"Guide": "MiniGuide", "FullGuide": "Guide"}
FORMATE = ("MiniGuide", "Guide", "FullGuide")
async def lade_lernstand() -> tuple[list[dict], dict[str, set[str]]]:
"""Guides + kompletter Kapitel-Fortschritt in zwei Queries."""
return await list_guides(), await list_progress_all()
async def lade_lernstand() -> tuple[list[dict], dict[str, set[str]], dict[str, set[str]]]:
"""Guides + Kapitel-Fortschritt + absolvierte Bausteine in drei Queries.
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)
if not path.exists():
return None
try:
chapters = json.loads(path.read_text(encoding="utf-8")).get("chapters", [])
return json.loads(path.read_text(encoding="utf-8"))
except ValueError:
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]:
@@ -44,28 +73,31 @@ def _neueste_done(guides: list[dict], fmt: str) -> dict[str, dict]:
return neueste
def _guide_absolviert(g: dict, progress: dict[str, set[str]]) -> bool:
titles = _kapitel_titel(g["topic"], g["format"])
return bool(titles) and titles <= progress.get(g["id"], set())
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"])
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:
"""Alle Kapitel des neuesten fertigen Guides (Thema+Format) abgehakt?"""
def ist_absolviert(topic: str, fmt: str, guides: list[dict], progress: dict[str, set[str]], bausteine_done: dict[str, set[str]]) -> bool:
"""Alle Bausteine des neuesten fertigen Guides (Thema+Format) per Prüfung absolviert?"""
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."""
formate = {}
for fmt in FORMATE:
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}
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.
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)
if fmt != "OnePager" and not content.exists() and not guide_slot_dateien(content):
vorstufe = VORSTUFE.get(fmt)
if vorstufe and not ist_absolviert(topic, vorstufe, guides, progress):
return f"Erst den {vorstufe} dieses Themas absolvieren"
stat = formate_stats(guides, progress).get(fmt, {"erstellt": 0, "absolviert": 0})
if vorstufe and not ist_absolviert(topic, vorstufe, guides, progress, bausteine_done):
return f"Erst den {vorstufe} dieses Themas absolvieren (alle Bausteine prüfen)"
stat = formate_stats(guides, progress, bausteine_done).get(fmt, {"erstellt": 0, "absolviert": 0})
offen = stat["erstellt"] - stat["absolviert"]
if offen >= MAX_OFFENE_GUIDES:
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,
list_progress, set_progress, delete_progress,
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 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 pipeline import cancel_guide
from regeln import FORMATE, formate_stats, guide_lock, ist_absolviert, lade_lernstand
@@ -28,6 +31,9 @@ from models import (
ElementUpdateRequest, ElementCheckRequest, ElementCheckResponse, ElementStyleResponse,
ElementRefineRequest, ElementRefineResponse,
ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo,
VertiefungRequest, VertiefungResponse,
BausteinChatRequest, BausteinChatResponse,
BausteinPruefungRequest, BausteinPruefungResponse, BausteinLernstandResponse,
)
from paths import bausteine_topics, guide_content_path, project_dir, topic_dir
@@ -53,18 +59,18 @@ async def get_topics():
@router.get("/stats")
async def get_stats():
"""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())
if PROJECTS_DIR.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")
async def topic_fortschritt(topic: str):
"""Absolviert-Status pro Format — fürs Freischalten der nächsten Ausbaustufe."""
guides, progress = await lade_lernstand()
return {fmt: ist_absolviert(topic, fmt, guides, progress) for fmt in FORMATE}
guides, progress, bausteine_done = await lade_lernstand()
return {fmt: ist_absolviert(topic, fmt, guides, progress, bausteine_done) for fmt in FORMATE}
@router.post("/topics")
@@ -76,6 +82,7 @@ async def add_topic(req: TopicCreateRequest):
@router.delete("/topics")
async def remove_topic(topic: str):
await delete_topic(topic)
await delete_baustein_daten(topic)
shutil.rmtree(topic_dir(topic), ignore_errors=True)
return {"ok": True}
@@ -138,12 +145,85 @@ async def remove_bausteine(topic: str):
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 ---
@router.post("/guides", response_model=GuideResponse)
async def create(req: GuideCreateRequest):
guides, progress = await lade_lernstand()
grund = guide_lock(req.topic.strip(), req.format, guides, progress)
guides, progress, bausteine_done = await lade_lernstand()
grund = guide_lock(req.topic.strip(), req.format, guides, progress, bausteine_done)
if grund:
raise HTTPException(400 if grund == "Erst Bausteine erstellen" else 409, grund)
await create_topic(req.topic.strip())
@@ -171,8 +251,8 @@ async def list_all():
@router.get("/guides/locks")
async def guide_locks(topic: str):
"""Sperr-Gründe pro Format für den ▶-Button — None = erstellbar."""
guides, progress = await lade_lernstand()
return {fmt: guide_lock(topic, fmt, guides, progress) for fmt in ("OnePager", *FORMATE)}
guides, progress, bausteine_done = await lade_lernstand()
return {fmt: guide_lock(topic, fmt, guides, progress, bausteine_done) for fmt in ("OnePager", *FORMATE)}
@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
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]:
return {_norm_titel(_titel(text)): num for num, text in entries.items()}