update
This commit is contained in:
@@ -63,7 +63,6 @@ async def run_agent(
|
|||||||
return 1, "", f"Unbekannter Provider: {provider}"
|
return 1, "", f"Unbekannter Provider: {provider}"
|
||||||
if shutil.which(PROVIDERS[provider]["cli"]) is None:
|
if shutil.which(PROVIDERS[provider]["cli"]) is None:
|
||||||
return 1, "", f"CLI '{PROVIDERS[provider]['cli']}' nicht installiert (Provider: {provider})"
|
return 1, "", f"CLI '{PROVIDERS[provider]['cli']}' nicht installiert (Provider: {provider})"
|
||||||
timeout = int(timeout * PROVIDERS[provider].get("timeout_factor", 1))
|
|
||||||
if provider == "minimax":
|
if provider == "minimax":
|
||||||
return await _run_opencode(agent_key, prompt, timeout, role, capabilities)
|
return await _run_opencode(agent_key, prompt, timeout, role, capabilities)
|
||||||
return await _run_claude_cli(agent_key, prompt, timeout, role, capabilities)
|
return await _run_claude_cli(agent_key, prompt, timeout, role, capabilities)
|
||||||
|
|||||||
@@ -7,26 +7,42 @@ FRONTEND_DIST = PROJECT_ROOT / "frontend" / "dist"
|
|||||||
DB_PATH = STORAGE_DIR / "creator.db"
|
DB_PATH = STORAGE_DIR / "creator.db"
|
||||||
PROJECTS_DIR = PROJECT_ROOT / "projects"
|
PROJECTS_DIR = PROJECT_ROOT / "projects"
|
||||||
|
|
||||||
AGENT_TIMEOUT = 3600
|
|
||||||
|
|
||||||
MAX_CONCURRENT_GENERATIONS = 10
|
MAX_CONCURRENT_GENERATIONS = 10
|
||||||
|
|
||||||
|
# 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),
|
||||||
|
"einordnung": (300, 5),
|
||||||
|
"final": (300, 2), # verifiziert nur noch, kleiner Output
|
||||||
|
"sortierung": (300, 2),
|
||||||
|
"plan": (300, 5),
|
||||||
|
"writer": (600, 120), # pro Section im Chunk
|
||||||
|
"onepager_recherche": (900, 0),
|
||||||
|
"onepager_bauen": (300, 0),
|
||||||
|
"onepager_verify": (300, 0),
|
||||||
|
}
|
||||||
|
|
||||||
# Provider-Stacks: komplett unabhängig, einer kann jederzeit entfernt werden.
|
# Provider-Stacks: komplett unabhängig, einer kann jederzeit entfernt werden.
|
||||||
# Rollen: "guide" = große Generierung, "fast" = Baustein-Recherche/Chat.
|
# Rollen: "quick" = Massenarbeit (Recherche, Einordnung),
|
||||||
|
# "fast" = Urteilsaufgaben mit kleinem Output (Auswahl, Final, OnePager, Chat),
|
||||||
|
# "guide" = große Generierung (Plan, 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",
|
||||||
|
"quick": "claude-haiku-4-5",
|
||||||
"env_key": None, # Auth via CLAUDE_CODE_OAUTH_TOKEN oder ~/.claude
|
"env_key": None, # Auth via CLAUDE_CODE_OAUTH_TOKEN oder ~/.claude
|
||||||
"timeout_factor": 1,
|
|
||||||
},
|
},
|
||||||
"minimax": {
|
"minimax": {
|
||||||
"cli": "opencode",
|
"cli": "opencode",
|
||||||
"guide": "minimax/MiniMax-M3",
|
"guide": "minimax/MiniMax-M3",
|
||||||
"fast": "minimax/MiniMax-M3",
|
"fast": "minimax/MiniMax-M2.7-highspeed",
|
||||||
|
"quick": "minimax/MiniMax-M2.7-highspeed",
|
||||||
"env_key": "MINIMAX_API_KEY",
|
"env_key": "MINIMAX_API_KEY",
|
||||||
"timeout_factor": 3, # M3 ist bei großen Dokumenten deutlich langsamer
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ CREATE TABLE IF NOT EXISTS guide_progress (
|
|||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
CREATE_TOPICS = """
|
||||||
|
CREATE TABLE IF NOT EXISTS topics (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
_db: aiosqlite.Connection | None = None
|
_db: aiosqlite.Connection | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -39,6 +46,7 @@ async def init_db():
|
|||||||
db = await get_db()
|
db = await get_db()
|
||||||
await db.execute(CREATE_GUIDES)
|
await db.execute(CREATE_GUIDES)
|
||||||
await db.execute(CREATE_PROGRESS)
|
await db.execute(CREATE_PROGRESS)
|
||||||
|
await db.execute(CREATE_TOPICS)
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"UPDATE guides SET status = 'error', progress = NULL, error_msg = 'Server-Neustart' "
|
"UPDATE guides SET status = 'error', progress = NULL, error_msg = 'Server-Neustart' "
|
||||||
"WHERE status IN ('queued', 'generating')"
|
"WHERE status IN ('queued', 'generating')"
|
||||||
@@ -100,6 +108,31 @@ async def delete_guide(guide_id: str) -> bool:
|
|||||||
return cursor.rowcount > 0
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
# --- Themen ---
|
||||||
|
|
||||||
|
async def create_topic(name: str) -> None:
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
db = await get_db()
|
||||||
|
await db.execute(
|
||||||
|
"INSERT OR IGNORE INTO topics (name, created_at) VALUES (?, ?)",
|
||||||
|
(name, datetime.now(timezone.utc).isoformat()),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def list_topics() -> list[str]:
|
||||||
|
db = await get_db()
|
||||||
|
cursor = await db.execute("SELECT name FROM topics ORDER BY created_at DESC")
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [row[0] for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_topic(name: str) -> None:
|
||||||
|
db = await get_db()
|
||||||
|
await db.execute("DELETE FROM topics WHERE name = ?", (name,))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
# --- Kapitel-Fortschritt ---
|
# --- Kapitel-Fortschritt ---
|
||||||
|
|
||||||
async def list_progress(guide_id: str) -> list[str]:
|
async def list_progress(guide_id: str) -> list[str]:
|
||||||
|
|||||||
@@ -2,14 +2,15 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
|
from collections import Counter
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from agents import run_agent, kill_process
|
from agents import run_agent, kill_process
|
||||||
from config import (
|
from config import (
|
||||||
AGENT_TIMEOUT,
|
|
||||||
DEFAULT_PROVIDER,
|
DEFAULT_PROVIDER,
|
||||||
TEMPLATES_DIR,
|
TEMPLATES_DIR,
|
||||||
|
TIMEOUTS,
|
||||||
MAX_CONCURRENT_GENERATIONS,
|
MAX_CONCURRENT_GENERATIONS,
|
||||||
)
|
)
|
||||||
from database import update_guide
|
from database import update_guide
|
||||||
@@ -70,20 +71,148 @@ async def _fail(guide_id: str, msg: str) -> None:
|
|||||||
await update_guide(guide_id, status="error", progress=None, error_msg=msg, updated_at=now)
|
await update_guide(guide_id, status="error", progress=None, error_msg=msg, updated_at=now)
|
||||||
|
|
||||||
|
|
||||||
# --- Bausteine-Pipeline: 3x Recherche → Auswahl → 2x Einordnung → finale Einordnung ---
|
def _timeout(step: str, n: int = 0) -> int:
|
||||||
|
base, per = TIMEOUTS[step]
|
||||||
|
return base + per * n
|
||||||
|
|
||||||
|
|
||||||
|
_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:
|
||||||
|
"""Startet alle Slots parallel und sammelt `quorum` gültige Ergebnisse.
|
||||||
|
|
||||||
|
Slot-Spec: {key, prompt, role, capabilities, payload}. `payload(result)`
|
||||||
|
prüft die Gültigkeit und liefert das Slot-Ergebnis oder None.
|
||||||
|
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).
|
||||||
|
"""
|
||||||
|
attempts = {i: 0 for i in range(len(slots))}
|
||||||
|
tasks: dict[asyncio.Task, int] = {}
|
||||||
|
|
||||||
|
def spawn(i: int) -> None:
|
||||||
|
slot = slots[i]
|
||||||
|
task = asyncio.create_task(run_agent(
|
||||||
|
slot["key"], slot["prompt"], timeout,
|
||||||
|
provider=provider, role=slot["role"], capabilities=slot["capabilities"],
|
||||||
|
))
|
||||||
|
tasks[task] = i
|
||||||
|
|
||||||
|
for i in range(len(slots)):
|
||||||
|
spawn(i)
|
||||||
|
|
||||||
|
results: list = []
|
||||||
|
try:
|
||||||
|
while tasks:
|
||||||
|
if cancelled and cancelled():
|
||||||
|
return None
|
||||||
|
done, _ = await asyncio.wait(tasks.keys(), return_when=asyncio.FIRST_COMPLETED)
|
||||||
|
for task in done:
|
||||||
|
i = tasks.pop(task)
|
||||||
|
payload, err = None, None
|
||||||
|
try:
|
||||||
|
result = task.result()
|
||||||
|
if result[0] != 0:
|
||||||
|
err = _claude_error("Fehler", *result)
|
||||||
|
else:
|
||||||
|
payload = slots[i]["payload"](result)
|
||||||
|
if payload is None:
|
||||||
|
err = "Ergebnis ungültig/nicht parsebar"
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
err = f"Timeout nach {timeout}s"
|
||||||
|
except Exception as e:
|
||||||
|
err = f"{type(e).__name__}: {e}"
|
||||||
|
|
||||||
|
if payload is not None:
|
||||||
|
results.append(payload)
|
||||||
|
if on_update:
|
||||||
|
on_update(len(results))
|
||||||
|
if len(results) >= quorum:
|
||||||
|
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()):
|
||||||
|
spawn(i)
|
||||||
|
_log(topic, f"{label}: Quorum {quorum} nicht erreicht ({len(results)} gültig)")
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
for task, i in tasks.items():
|
||||||
|
kill_process(slots[i]["key"])
|
||||||
|
task.cancel()
|
||||||
|
if tasks:
|
||||||
|
await asyncio.gather(*tasks.keys(), return_exceptions=True)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Bausteine-Pipeline: 4x Recherche (3 nötig) → 2x Auswahl (1) → 4x Einordnung (3) → 2x Final (1) ---
|
||||||
|
|
||||||
_bausteine_progress: dict[str, str] = {}
|
_bausteine_progress: dict[str, str] = {}
|
||||||
_bausteine_errors: dict[str, str] = {}
|
_bausteine_errors: dict[str, str] = {}
|
||||||
|
_bausteine_cancelled: set[str] = set()
|
||||||
|
_bausteine_step: dict[str, int] = {}
|
||||||
|
|
||||||
|
BAUSTEINE_STEPS = ("Recherche", "Auswahl", "Prüfung", "Einordnung", "Verifikation", "Sortierung")
|
||||||
|
|
||||||
|
|
||||||
|
def cancel_bausteine(topic: str) -> bool:
|
||||||
|
if topic not in _bausteine_progress:
|
||||||
|
return False
|
||||||
|
_bausteine_cancelled.add(topic)
|
||||||
|
kill_process(f"bausteine-{topic}-")
|
||||||
|
return True
|
||||||
|
|
||||||
_CATEGORIES = ("KERN", "WICHTIG", "REST")
|
_CATEGORIES = ("KERN", "WICHTIG", "REST")
|
||||||
|
|
||||||
|
|
||||||
|
def _resume_step(topic: str) -> int:
|
||||||
|
"""Erster noch offener Schritt anhand der persistierten Zwischendateien."""
|
||||||
|
final_path = bausteine_path(topic)
|
||||||
|
stem, parent = final_path.stem, final_path.parent
|
||||||
|
if sum((parent / f"{stem}.recherche-{i}.md").exists() for i in (1, 2, 3, 4)) < 3:
|
||||||
|
return 0
|
||||||
|
if not any((parent / f"{stem}.auswahl-{i}.md").exists() for i in (1, 2)):
|
||||||
|
return 1
|
||||||
|
if not (parent / f"{stem}.auswahl-check.md").exists():
|
||||||
|
return 2
|
||||||
|
if sum((parent / f"{stem}.einordnung-{i}.md").exists() for i in (1, 2, 3)) < 3:
|
||||||
|
return 3
|
||||||
|
if not (parent / f"{stem}.final-check.md").exists():
|
||||||
|
return 4
|
||||||
|
return 5
|
||||||
|
|
||||||
|
|
||||||
|
def _sortierung_path(topic: str):
|
||||||
|
final_path = bausteine_path(topic)
|
||||||
|
return final_path.parent / f"{final_path.stem}.sortierung.md"
|
||||||
|
|
||||||
|
|
||||||
def bausteine_status(topic: str) -> dict:
|
def bausteine_status(topic: str) -> dict:
|
||||||
|
ready = bausteine_path(topic).exists()
|
||||||
|
generating = topic in _bausteine_progress
|
||||||
|
partial = False
|
||||||
|
if generating:
|
||||||
|
current = _bausteine_step.get(topic)
|
||||||
|
states = [
|
||||||
|
"pending" if current is None else "done" if i < current else "active" if i == current else "pending"
|
||||||
|
for i in range(len(BAUSTEINE_STEPS))
|
||||||
|
]
|
||||||
|
elif ready:
|
||||||
|
states = ["done"] * len(BAUSTEINE_STEPS)
|
||||||
|
if not _sortierung_path(topic).exists():
|
||||||
|
states[-1] = "pending"
|
||||||
|
else:
|
||||||
|
nxt = _resume_step(topic)
|
||||||
|
partial = nxt > 0
|
||||||
|
states = ["done" if i < nxt else "pending" for i in range(len(BAUSTEINE_STEPS))]
|
||||||
return {
|
return {
|
||||||
"ready": bausteine_path(topic).exists(),
|
"ready": ready,
|
||||||
"generating": topic in _bausteine_progress,
|
"generating": generating,
|
||||||
"progress": _bausteine_progress.get(topic),
|
"progress": _bausteine_progress.get(topic),
|
||||||
"error": _bausteine_errors.get(topic),
|
"error": _bausteine_errors.get(topic),
|
||||||
|
"partial": partial,
|
||||||
|
"steps": [{"label": label, "state": s} for label, s in zip(BAUSTEINE_STEPS, states)],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -92,7 +221,16 @@ def active_bausteine() -> list[dict]:
|
|||||||
|
|
||||||
|
|
||||||
def reset_bausteine(topic: str) -> None:
|
def reset_bausteine(topic: str) -> None:
|
||||||
bausteine_path(topic).unlink(missing_ok=True)
|
final_path = bausteine_path(topic)
|
||||||
|
final_path.unlink(missing_ok=True)
|
||||||
|
for i in (1, 2, 3, 4):
|
||||||
|
(final_path.parent / f"{final_path.stem}.recherche-{i}.md").unlink(missing_ok=True)
|
||||||
|
(final_path.parent / f"{final_path.stem}.einordnung-{i}.md").unlink(missing_ok=True)
|
||||||
|
for i in (1, 2):
|
||||||
|
(final_path.parent / f"{final_path.stem}.auswahl-{i}.md").unlink(missing_ok=True)
|
||||||
|
(final_path.parent / f"{final_path.stem}.auswahl-check.md").unlink(missing_ok=True)
|
||||||
|
(final_path.parent / f"{final_path.stem}.final-check.md").unlink(missing_ok=True)
|
||||||
|
(final_path.parent / f"{final_path.stem}.sortierung.md").unlink(missing_ok=True)
|
||||||
_bausteine_errors.pop(topic, None)
|
_bausteine_errors.pop(topic, None)
|
||||||
|
|
||||||
|
|
||||||
@@ -142,25 +280,125 @@ def _parse_einordnung(text: str) -> dict[int, str]:
|
|||||||
return mapping
|
return mapping
|
||||||
|
|
||||||
|
|
||||||
def _build_final_bausteine(topic: str, entries: dict[int, str], mapping: dict[int, str]) -> str:
|
def _build_final_bausteine(topic: str, entries: dict[int, str], mapping: dict[int, str], order: dict[str, list[int]] | None = None) -> str:
|
||||||
"""Baut die finale Baustein-Datei aus konsolidierter Liste + finaler Zuordnung."""
|
"""Baut die finale Baustein-Datei aus konsolidierter Liste + finaler Zuordnung.
|
||||||
grouped: dict[str, list[str]] = {c: [] for c in _CATEGORIES}
|
|
||||||
|
`order` (Kategorie → Nummern in Lernreihenfolge) sortiert innerhalb der
|
||||||
|
Kategorien; nicht gelistete Nummern hängen in Originalreihenfolge hinten an.
|
||||||
|
"""
|
||||||
|
grouped: dict[str, list[int]] = {c: [] for c in _CATEGORIES}
|
||||||
for num in sorted(entries):
|
for num in sorted(entries):
|
||||||
cat = mapping.get(num)
|
cat = mapping.get(num)
|
||||||
if cat is None:
|
if cat is None:
|
||||||
_log(topic, f"Baustein {num} fehlt in finaler Einordnung → REST")
|
_log(topic, f"Baustein {num} fehlt in finaler Einordnung → REST")
|
||||||
cat = "REST"
|
cat = "REST"
|
||||||
grouped[cat].append(entries[num])
|
grouped[cat].append(num)
|
||||||
unknown = sorted(set(mapping) - set(entries))
|
unknown = sorted(set(mapping) - set(entries))
|
||||||
if unknown:
|
if unknown:
|
||||||
_log(topic, f"finale Einordnung enthält unbekannte Nummern (ignoriert): {unknown}")
|
_log(topic, f"finale Einordnung enthält unbekannte Nummern (ignoriert): {unknown}")
|
||||||
|
if order:
|
||||||
|
for cat in _CATEGORIES:
|
||||||
|
wanted = set(grouped[cat])
|
||||||
|
seq = [n for n in order.get(cat, []) if n in wanted]
|
||||||
|
grouped[cat] = seq + [n for n in grouped[cat] if n not in seq]
|
||||||
parts = []
|
parts = []
|
||||||
for cat in _CATEGORIES:
|
for cat in _CATEGORIES:
|
||||||
lines = "\n".join(f"{i}. {text}" for i, text in enumerate(grouped[cat], 1))
|
lines = "\n".join(f"{i}. {entries[num]}" for i, num in enumerate(grouped[cat], 1))
|
||||||
parts.append(f"## {cat}\n{lines}")
|
parts.append(f"## {cat}\n{lines}")
|
||||||
return "\n\n".join(parts) + "\n"
|
return "\n\n".join(parts) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _file_payload(path: Path):
|
||||||
|
"""Gültig, wenn die Slot-Datei existiert und nummerierte Einträge enthält."""
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
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 _parse_auswahl_check(text: str):
|
||||||
|
"""Parst die Auswahl-Prüfung: NACHTRÄGE (neue Einträge) + STREICHEN (Nummern)."""
|
||||||
|
additions: list[str] = []
|
||||||
|
removals: set[int] = set()
|
||||||
|
mode = None
|
||||||
|
seen_marker = False
|
||||||
|
for line in text.splitlines():
|
||||||
|
s = line.strip().lstrip("-*# ").strip()
|
||||||
|
if not s:
|
||||||
|
continue
|
||||||
|
u = s.upper().rstrip(":")
|
||||||
|
if u.startswith("NACHTR"):
|
||||||
|
mode = "add"
|
||||||
|
seen_marker = True
|
||||||
|
continue
|
||||||
|
if u.startswith("STREICH"):
|
||||||
|
mode = "del"
|
||||||
|
seen_marker = True
|
||||||
|
continue
|
||||||
|
if u == "OK":
|
||||||
|
seen_marker = True
|
||||||
|
continue
|
||||||
|
if mode == "add":
|
||||||
|
additions.append(s)
|
||||||
|
elif mode == "del":
|
||||||
|
m = re.match(r"(\d+)\b", s)
|
||||||
|
if m:
|
||||||
|
removals.add(int(m.group(1)))
|
||||||
|
if not seen_marker:
|
||||||
|
return None # Antwort hat das Format nicht getroffen
|
||||||
|
return {"add": additions, "remove": removals}
|
||||||
|
|
||||||
|
|
||||||
|
def _majority(mappings: list[dict[int, str]], entries: dict[int, str]) -> tuple[dict[int, str], list[int]]:
|
||||||
|
"""Mehrheitsentscheid über die Einordnungen; ohne Mehrheit → Streitfall."""
|
||||||
|
mapping: dict[int, str] = {}
|
||||||
|
disputes: list[int] = []
|
||||||
|
for num in entries:
|
||||||
|
votes = [m[num] for m in mappings if num in m]
|
||||||
|
if not votes:
|
||||||
|
disputes.append(num)
|
||||||
|
continue
|
||||||
|
cat, count = Counter(votes).most_common(1)[0]
|
||||||
|
if count >= 2:
|
||||||
|
mapping[num] = cat
|
||||||
|
else:
|
||||||
|
disputes.append(num)
|
||||||
|
return mapping, disputes
|
||||||
|
|
||||||
|
|
||||||
|
def _einordnung_block(mapping: dict[int, str], entries: dict[int, str]) -> str:
|
||||||
|
parts = []
|
||||||
|
for cat in _CATEGORIES:
|
||||||
|
nums = [n for n in sorted(entries) if mapping.get(n) == cat]
|
||||||
|
lines = "\n".join(f"{n} {_titel(entries[n])}" for n in nums)
|
||||||
|
parts.append(f"{cat}:\n{lines}" if lines else f"{cat}:")
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_sortierung(topic: str, entries: dict[int, str], mapping: dict[int, str], provider: str, cancelled) -> dict[str, list[int]] | None:
|
||||||
|
"""Sortiert innerhalb der Kategorien; schreibt bei Erfolg den Marker und liefert die Reihenfolge."""
|
||||||
|
slots = [{
|
||||||
|
"key": f"bausteine-{topic}-sortierung-1",
|
||||||
|
"prompt": _prompt("Bausteine-Sortierung", topic=topic, einordnung=_einordnung_block(mapping, entries)),
|
||||||
|
"role": "quick", "capabilities": "none",
|
||||||
|
"payload": (lambda result: (result[1].strip(), _parse_einordnung(result[1])) if _parse_einordnung(result[1]) else None),
|
||||||
|
}]
|
||||||
|
res = await _race(topic, "Sortierung", slots, 1, _timeout("sortierung", len(entries)), provider, cancelled=cancelled)
|
||||||
|
if res is None:
|
||||||
|
return None
|
||||||
|
raw, sort_mapping = res[0]
|
||||||
|
_sortierung_path(topic).write_text(raw, encoding="utf-8")
|
||||||
|
return {cat: [num for num, c in sort_mapping.items() if c == cat] for cat in _CATEGORIES}
|
||||||
|
|
||||||
|
|
||||||
async def generate_bausteine(topic: str, instructions: str = "", provider: str = DEFAULT_PROVIDER) -> None:
|
async def generate_bausteine(topic: str, instructions: str = "", provider: str = DEFAULT_PROVIDER) -> None:
|
||||||
if topic in _bausteine_progress:
|
if topic in _bausteine_progress:
|
||||||
return
|
return
|
||||||
@@ -170,107 +408,252 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
|
|||||||
final_path = bausteine_path(topic)
|
final_path = bausteine_path(topic)
|
||||||
project = project_dir(topic) if project_dir(topic).is_dir() else None
|
project = project_dir(topic) if project_dir(topic).is_dir() else None
|
||||||
stem = final_path.stem
|
stem = final_path.stem
|
||||||
recherche_paths = [final_path.parent / f"{stem}.recherche-{i}.md" for i in (1, 2, 3)]
|
recherche_paths = [final_path.parent / f"{stem}.recherche-{i}.md" for i in (1, 2, 3, 4)]
|
||||||
auswahl_path = final_path.parent / f"{stem}.auswahl.md"
|
auswahl_paths = [final_path.parent / f"{stem}.auswahl-{i}.md" for i in (1, 2)]
|
||||||
|
einordnung_paths = [final_path.parent / f"{stem}.einordnung-{i}.md" for i in (1, 2, 3)]
|
||||||
|
auswahl_check_path = final_path.parent / f"{stem}.auswahl-check.md"
|
||||||
|
final_check_path = final_path.parent / f"{stem}.final-check.md"
|
||||||
|
sortierung_path = _sortierung_path(topic)
|
||||||
|
slot_files = [*recherche_paths, *auswahl_paths, *einordnung_paths, auswahl_check_path, final_check_path, sortierung_path]
|
||||||
|
|
||||||
|
def set_p(msg: str, step: int | None = None) -> None:
|
||||||
|
_bausteine_progress[topic] = msg
|
||||||
|
if step is not None:
|
||||||
|
_bausteine_step[topic] = step
|
||||||
|
|
||||||
|
def is_cancelled() -> bool:
|
||||||
|
return topic in _bausteine_cancelled
|
||||||
|
|
||||||
|
def abgebrochen() -> None:
|
||||||
|
_bausteine_errors[topic] = "Abgebrochen — Fortschritt bleibt erhalten"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with _semaphore:
|
async with _semaphore:
|
||||||
# Schritt 1: 3 Recherche-Agenten parallel (Thema: Websuche, Projekt: Dateien lesen)
|
# Fertig, aber ohne Sortier-Marker (ältere Pipeline-Version): nur die Sortierung nachholen.
|
||||||
_bausteine_progress[topic] = "Recherche läuft (3 Agenten)…"
|
if final_path.exists() and not sortierung_path.exists():
|
||||||
caps = "files" if project else "full"
|
cats = _parse_kategorien(final_path.read_text(encoding="utf-8"))
|
||||||
results = await asyncio.gather(*[
|
entries, mapping = {}, {}
|
||||||
run_agent(
|
i = 0
|
||||||
f"bausteine-{topic}-recherche-{i}",
|
for cat in _CATEGORIES:
|
||||||
_build_recherche_prompt(topic, path, instructions, project),
|
for text in cats.get(cat, []):
|
||||||
AGENT_TIMEOUT, provider=provider, role="fast", capabilities=caps,
|
i += 1
|
||||||
)
|
entries[i] = text
|
||||||
for i, path in enumerate(recherche_paths, 1)
|
mapping[i] = cat
|
||||||
], return_exceptions=True)
|
if entries:
|
||||||
for i, (r, p) in enumerate(zip(results, recherche_paths), 1):
|
set_p("Sortiere Bausteine…", step=5)
|
||||||
if isinstance(r, BaseException):
|
order = await _run_sortierung(topic, entries, mapping, provider, is_cancelled)
|
||||||
_log(topic, f"Recherche {i}: {type(r).__name__}: {r}")
|
if is_cancelled():
|
||||||
elif r[0] != 0:
|
abgebrochen()
|
||||||
_log(topic, f"Recherche {i}: {_claude_error('Fehler', *r)}")
|
return
|
||||||
elif not p.exists():
|
if order is None:
|
||||||
_log(topic, f"Recherche {i}: keine Ausgabedatei erstellt")
|
_bausteine_errors[topic] = "Sortierung fehlgeschlagen"
|
||||||
recherchen = [p.read_text(encoding="utf-8") for p in recherche_paths if p.exists()]
|
return
|
||||||
if not recherchen:
|
final_path.write_text(_build_final_bausteine(topic, entries, mapping, order), encoding="utf-8")
|
||||||
_bausteine_errors[topic] = _gather_error("Recherche-Fehler", results)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Schritt 2: Auswahl-Agent konsolidiert die Ergebnisse (ohne Quellen)
|
# „Neu erstellen": fertige (sortierte) Bausteine → kompletter Frischstart.
|
||||||
_bausteine_progress[topic] = f"Konsolidiere Recherche ({len(recherchen)}/3 erfolgreich)…"
|
# Sonst sind Slot-Dateien Reste eines Abbruchs/Fehlers → Resume, fertige Schritte überspringen.
|
||||||
results_block = "\n\n".join(f"### Recherche {i}\n\n{text}" for i, text in enumerate(recherchen, 1))
|
if final_path.exists():
|
||||||
returncode, stdout, stderr = await run_agent(
|
for p_alt in slot_files:
|
||||||
f"bausteine-{topic}-auswahl",
|
p_alt.unlink(missing_ok=True)
|
||||||
_prompt("Bausteine-Auswahl", topic=topic, results=results_block, out_path=auswahl_path),
|
|
||||||
AGENT_TIMEOUT, provider=provider, role="fast", capabilities="files",
|
|
||||||
)
|
|
||||||
if returncode != 0 or not auswahl_path.exists():
|
|
||||||
_bausteine_errors[topic] = _claude_error("Auswahl-Fehler", returncode, stdout, stderr)
|
|
||||||
return
|
|
||||||
flat = auswahl_path.read_text(encoding="utf-8")
|
|
||||||
entries = _parse_auswahl(flat)
|
|
||||||
if not entries:
|
|
||||||
_bausteine_errors[topic] = "Auswahl-Liste nicht parsebar"
|
|
||||||
return
|
|
||||||
|
|
||||||
# Schritt 3: 2 Einordnungs-Agenten parallel (antworten nur mit Nummer+Titel je Kategorie)
|
# Schritt 1: 4 Recherche-Agenten, 3 gültige nötig — vorhandene Slot-Dateien zählen
|
||||||
_bausteine_progress[topic] = "Einordnung läuft (2 Agenten)…"
|
recherchen = []
|
||||||
results = await asyncio.gather(*[
|
offen = []
|
||||||
run_agent(
|
for i, path in enumerate(recherche_paths, 1):
|
||||||
f"bausteine-{topic}-einordnung-{i}",
|
text = _file_payload(path)
|
||||||
_prompt("Bausteine-Einordnung", topic=topic, bausteine=flat),
|
if text is not None and len(recherchen) < 3:
|
||||||
AGENT_TIMEOUT, provider=provider, role="fast", capabilities="none",
|
recherchen.append(text)
|
||||||
)
|
|
||||||
for i in (1, 2)
|
|
||||||
], return_exceptions=True)
|
|
||||||
einordnungen = []
|
|
||||||
for i, r in enumerate(results, 1):
|
|
||||||
if isinstance(r, BaseException):
|
|
||||||
_log(topic, f"Einordnung {i}: {type(r).__name__}: {r}")
|
|
||||||
elif r[0] != 0:
|
|
||||||
_log(topic, f"Einordnung {i}: {_claude_error('Fehler', *r)}")
|
|
||||||
elif not _parse_einordnung(r[1]):
|
|
||||||
_log(topic, f"Einordnung {i}: Antwort nicht parsebar")
|
|
||||||
else:
|
else:
|
||||||
einordnungen.append(r[1].strip())
|
offen.append((i, path))
|
||||||
if not einordnungen:
|
vorhanden = len(recherchen)
|
||||||
_bausteine_errors[topic] = _gather_error("Einordnungs-Fehler", results)
|
set_p(f"Recherche läuft ({vorhanden}/3 gültig)…", step=0)
|
||||||
return
|
if vorhanden < 3:
|
||||||
|
caps = "files" if project else "full"
|
||||||
|
slots = [
|
||||||
|
{
|
||||||
|
"key": f"bausteine-{topic}-recherche-{i}",
|
||||||
|
"prompt": _build_recherche_prompt(topic, path, instructions, project),
|
||||||
|
"role": "quick", "capabilities": caps,
|
||||||
|
"payload": (lambda result, p=path: _file_payload(p)),
|
||||||
|
}
|
||||||
|
for i, path in offen
|
||||||
|
]
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
if is_cancelled():
|
||||||
|
abgebrochen()
|
||||||
|
return
|
||||||
|
if neue is None:
|
||||||
|
_bausteine_errors[topic] = "Recherche fehlgeschlagen (Quorum nicht erreicht)"
|
||||||
|
return
|
||||||
|
recherchen += neue
|
||||||
|
|
||||||
# Schritt 4: finale Einordnung — Python validiert und baut die Datei
|
# Schritt 2: 2 Auswahl-Agenten, der erste gewinnt — vorhandene gültige Datei wird übernommen
|
||||||
_bausteine_progress[topic] = f"Finale Einordnung ({len(einordnungen)}/2 erfolgreich)…"
|
n_est = max(len(_parse_auswahl(t)) for t in recherchen)
|
||||||
returncode, stdout, stderr = await run_agent(
|
results_block = "\n\n".join(f"### Recherche {i}\n\n{text}" for i, text in enumerate(recherchen, 1))
|
||||||
f"bausteine-{topic}-final",
|
bestehende = next((res for p in auswahl_paths if (res := _auswahl_payload(p)) is not None), None)
|
||||||
_prompt(
|
if bestehende is not None:
|
||||||
|
flat, entries = bestehende
|
||||||
|
else:
|
||||||
|
set_p("Konsolidiere Recherche…", step=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(auswahl_paths, 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 (nicht fatal) — gespeicherte Antwort wird erneut angewendet
|
||||||
|
set_p("Prüfe Auswahl…", step=2)
|
||||||
|
raw_check = auswahl_check_path.read_text(encoding="utf-8") if auswahl_check_path.exists() else None
|
||||||
|
patch = _parse_auswahl_check(raw_check) if raw_check is not None else None
|
||||||
|
if patch is None:
|
||||||
|
slots = [{
|
||||||
|
"key": f"bausteine-{topic}-auswahlcheck-1",
|
||||||
|
"prompt": _prompt("Bausteine-Auswahl-Check", topic=topic, results=results_block, auswahl=flat),
|
||||||
|
"role": "fast", "capabilities": "none",
|
||||||
|
"payload": (lambda result: (result[1].strip(), _parse_auswahl_check(result[1])) if _parse_auswahl_check(result[1]) is not None else None),
|
||||||
|
}]
|
||||||
|
checks = await _race(topic, "Auswahl-Check", slots, 1, _timeout("auswahl_check", len(entries)), provider, cancelled=is_cancelled)
|
||||||
|
if is_cancelled():
|
||||||
|
abgebrochen()
|
||||||
|
return
|
||||||
|
if checks is None:
|
||||||
|
_log(topic, "Auswahl-Check fehlgeschlagen — fahre ohne Korrekturen fort")
|
||||||
|
else:
|
||||||
|
raw_check, patch = checks[0]
|
||||||
|
auswahl_check_path.write_text(raw_check, encoding="utf-8")
|
||||||
|
if patch is not None:
|
||||||
|
if patch["remove"]:
|
||||||
|
_log(topic, f"Auswahl-Check streicht Duplikate: {sorted(patch['remove'])}")
|
||||||
|
entries = {n: t for n, t in entries.items() if n not in patch["remove"]}
|
||||||
|
if patch["add"]:
|
||||||
|
_log(topic, f"Auswahl-Check ergänzt {len(patch['add'])} Bausteine")
|
||||||
|
if patch["remove"] or patch["add"]:
|
||||||
|
texts = [t for _, t in sorted(entries.items())] + patch["add"]
|
||||||
|
entries = {i: t for i, t in enumerate(texts, 1)}
|
||||||
|
flat = "\n".join(f"{i}. {t}" for i, t in entries.items())
|
||||||
|
|
||||||
|
# Schritt 3: 4 Einordnungs-Agenten, 3 gültige nötig — gespeicherte Stimmen einlesen
|
||||||
|
n = len(entries)
|
||||||
|
einordnungen = []
|
||||||
|
for path in einordnung_paths:
|
||||||
|
if path.exists():
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
parsed = _parse_einordnung(text)
|
||||||
|
if parsed:
|
||||||
|
einordnungen.append((text, parsed))
|
||||||
|
einordnungen = einordnungen[:3]
|
||||||
|
vorhanden = len(einordnungen)
|
||||||
|
set_p(f"Einordnung läuft ({vorhanden}/3 gültig)…", step=3)
|
||||||
|
if vorhanden < 3:
|
||||||
|
slots = [
|
||||||
|
{
|
||||||
|
"key": f"bausteine-{topic}-einordnung-{i}",
|
||||||
|
"prompt": _prompt("Bausteine-Einordnung", topic=topic, bausteine=flat),
|
||||||
|
"role": "quick", "capabilities": "none",
|
||||||
|
"payload": (lambda result: (result[1].strip(), _parse_einordnung(result[1])) if _parse_einordnung(result[1]) else None),
|
||||||
|
}
|
||||||
|
for i in range(vorhanden + 1, 5)
|
||||||
|
]
|
||||||
|
neue = await _race(
|
||||||
|
topic, "Einordnung", slots, 3 - vorhanden, _timeout("einordnung", n), provider,
|
||||||
|
on_update=lambda c: set_p(f"Einordnung läuft ({vorhanden + c}/3 gültig)…"),
|
||||||
|
cancelled=is_cancelled,
|
||||||
|
)
|
||||||
|
if is_cancelled():
|
||||||
|
abgebrochen()
|
||||||
|
return
|
||||||
|
if neue is None:
|
||||||
|
_bausteine_errors[topic] = "Einordnung fehlgeschlagen (Quorum nicht erreicht)"
|
||||||
|
return
|
||||||
|
for path, (text, _) in zip(einordnung_paths[vorhanden:], neue):
|
||||||
|
path.write_text(text, encoding="utf-8")
|
||||||
|
einordnungen += neue
|
||||||
|
|
||||||
|
# Schritt 4: Python-Mehrheitsentscheid + Verifikations-Agent — gespeicherte Antwort wird erneut angewendet
|
||||||
|
set_p("Verifiziere Einordnung…", step=4)
|
||||||
|
mapping, disputes = _majority([m for _, m in einordnungen], entries)
|
||||||
|
if disputes:
|
||||||
|
_log(topic, f"Keine Mehrheit bei: {disputes}")
|
||||||
|
raw_final = final_check_path.read_text(encoding="utf-8") if final_check_path.exists() else None
|
||||||
|
if raw_final is not None and not (_parse_einordnung(raw_final) or "OK" in raw_final.upper()):
|
||||||
|
raw_final = None
|
||||||
|
if raw_final is None:
|
||||||
|
streit_block = "\n".join(f"{num} {entries[num]}" for num in disputes) or "(keine)"
|
||||||
|
final_prompt = _prompt(
|
||||||
"Bausteine-Einordnung-Final",
|
"Bausteine-Einordnung-Final",
|
||||||
topic=topic, bausteine=flat,
|
topic=topic,
|
||||||
einordnung_1=einordnungen[0], einordnung_2=einordnungen[-1],
|
einordnung=_einordnung_block(mapping, entries),
|
||||||
),
|
streitfaelle=streit_block,
|
||||||
AGENT_TIMEOUT, provider=provider, role="fast", capabilities="none",
|
)
|
||||||
)
|
slots = [
|
||||||
if returncode != 0:
|
{
|
||||||
_bausteine_errors[topic] = _claude_error("Finale-Einordnungs-Fehler", returncode, stdout, stderr)
|
"key": f"bausteine-{topic}-final-{i}",
|
||||||
|
"prompt": final_prompt,
|
||||||
|
"role": "fast", "capabilities": "none",
|
||||||
|
"payload": (lambda result: result[1].strip() if (_parse_einordnung(result[1]) or "OK" in result[1].upper()) else None),
|
||||||
|
}
|
||||||
|
for i in (1, 2)
|
||||||
|
]
|
||||||
|
finals = await _race(topic, "Final", slots, 1, _timeout("final", n), provider, cancelled=is_cancelled)
|
||||||
|
if is_cancelled():
|
||||||
|
abgebrochen()
|
||||||
|
return
|
||||||
|
if finals is None:
|
||||||
|
_log(topic, "Final-Verifikation fehlgeschlagen — Mehrheitsentscheid bleibt unverändert")
|
||||||
|
else:
|
||||||
|
raw_final = finals[0]
|
||||||
|
final_check_path.write_text(raw_final, encoding="utf-8")
|
||||||
|
if raw_final is not None:
|
||||||
|
overrides = {num: cat for num, cat in _parse_einordnung(raw_final).items() if num in entries}
|
||||||
|
korrekturen = {num: cat for num, cat in overrides.items() if mapping.get(num) != cat and num not in disputes}
|
||||||
|
if korrekturen:
|
||||||
|
_log(topic, f"Final-Verifikation korrigiert: {korrekturen}")
|
||||||
|
mapping.update(overrides)
|
||||||
|
for num in disputes:
|
||||||
|
if num not in mapping:
|
||||||
|
_log(topic, f"Streitfall {num} unentschieden → WICHTIG")
|
||||||
|
mapping[num] = "WICHTIG"
|
||||||
|
|
||||||
|
# Schritt 5: Sortierung innerhalb der Kategorien (einfach → komplex, nicht fatal)
|
||||||
|
set_p("Sortiere Bausteine…", step=5)
|
||||||
|
order = await _run_sortierung(topic, entries, mapping, provider, is_cancelled)
|
||||||
|
if is_cancelled():
|
||||||
|
abgebrochen()
|
||||||
return
|
return
|
||||||
mapping = _parse_einordnung(stdout)
|
if order is None:
|
||||||
if not mapping:
|
_log(topic, "Sortierung fehlgeschlagen — Originalreihenfolge bleibt (Nachholen über ▶)")
|
||||||
_bausteine_errors[topic] = "Finale Einordnung nicht parsebar"
|
final_path.write_text(_build_final_bausteine(topic, entries, mapping, order), encoding="utf-8")
|
||||||
return
|
|
||||||
final_path.write_text(_build_final_bausteine(topic, entries, mapping), encoding="utf-8")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_bausteine_errors[topic] = str(e)[:2000]
|
_bausteine_errors[topic] = str(e)[:2000]
|
||||||
finally:
|
finally:
|
||||||
|
# Kein Datei-Cleanup: Zwischendateien bleiben für Resume bzw. Nachvollziehbarkeit.
|
||||||
|
# Aufräumen passiert nur explizit über reset_bausteine().
|
||||||
_bausteine_progress.pop(topic, None)
|
_bausteine_progress.pop(topic, None)
|
||||||
for p in [*recherche_paths, auswahl_path]:
|
_bausteine_step.pop(topic, None)
|
||||||
p.unlink(missing_ok=True)
|
_bausteine_cancelled.discard(topic)
|
||||||
|
|
||||||
|
|
||||||
# --- Guide-Generierung: Bausteine → (Plan) → Writer → JSON ---
|
# --- Guide-Generierung: Bausteine → (Plan) → Writer → JSON ---
|
||||||
|
|
||||||
# Welche Baustein-Kategorien jedes Format abdeckt.
|
# Welche Baustein-Kategorien jedes Format abdeckt.
|
||||||
FORMAT_COVERAGE = {
|
FORMAT_COVERAGE = {
|
||||||
"OnePager": ("KERN",),
|
|
||||||
"MiniGuide": ("KERN",),
|
"MiniGuide": ("KERN",),
|
||||||
"Guide": ("KERN", "WICHTIG"),
|
"Guide": ("KERN", "WICHTIG"),
|
||||||
"FullGuide": ("KERN", "WICHTIG", "REST"),
|
"FullGuide": ("KERN", "WICHTIG", "REST"),
|
||||||
@@ -388,34 +771,76 @@ def _section_json(sec: dict, entries: dict[int, str]) -> dict:
|
|||||||
return {"num": sec["num"], "title": sec["title"] or _titel(entries[sec["num"]]), "md": sec["md"]}
|
return {"num": sec["num"], "title": sec["title"] or _titel(entries[sec["num"]]), "md": sec["md"]}
|
||||||
|
|
||||||
|
|
||||||
async def _generate_onepager(guide_id: str, topic: str, entries: dict[int, str], instructions: str, provider: str) -> list[dict] | None:
|
async def _generate_onepager(
|
||||||
await _set_progress(guide_id, "Generiere OnePager…")
|
guide_id: str, topic: str, instructions: str, provider: str,
|
||||||
bausteine_block = "\n".join(f"{i}. {t}" for i, t in entries.items())
|
project: Path | None, content_path: Path, fragment_paths: list[Path],
|
||||||
returncode, stdout, stderr = await run_agent(
|
) -> list[dict] | None:
|
||||||
f"{guide_id}-onepager",
|
def is_cancelled() -> bool:
|
||||||
_prompt("OnePager", topic=topic, bausteine=bausteine_block, extra=_extra(instructions)),
|
return guide_id in _cancelled
|
||||||
AGENT_TIMEOUT, provider=provider, role="fast", capabilities="none",
|
|
||||||
)
|
# Schritt 1: Recherche — eigene Faktenbasis, unabhängig von den Bausteinen
|
||||||
if guide_id in _cancelled:
|
await _set_progress(guide_id, "Recherchiere…")
|
||||||
|
recherche_path = content_path.parent / f"{content_path.stem}.recherche.md"
|
||||||
|
fragment_paths.append(recherche_path)
|
||||||
|
recherche_path.unlink(missing_ok=True)
|
||||||
|
if project:
|
||||||
|
source = _prompt("OnePager-Quelle-Projekt", project=project)
|
||||||
|
else:
|
||||||
|
source = _prompt("OnePager-Quelle-Thema", topic=topic)
|
||||||
|
slots = [{
|
||||||
|
"key": f"{guide_id}-recherche",
|
||||||
|
"prompt": _prompt("OnePager-Recherche", topic=topic, source=source, out_path=recherche_path, extra=_extra(instructions)),
|
||||||
|
"role": "quick", "capabilities": "files" if project else "full",
|
||||||
|
"payload": (lambda result: recherche_path.read_text(encoding="utf-8") if recherche_path.exists() else None),
|
||||||
|
}]
|
||||||
|
res = await _race(topic, "OnePager-Recherche", slots, 1, _timeout("onepager_recherche"), provider, cancelled=is_cancelled)
|
||||||
|
if is_cancelled():
|
||||||
return None
|
return None
|
||||||
if returncode != 0:
|
if res is None:
|
||||||
await _fail(guide_id, _claude_error("OnePager-Fehler", returncode, stdout, stderr))
|
await _fail(guide_id, "OnePager-Recherche fehlgeschlagen")
|
||||||
return None
|
return None
|
||||||
merksaetze: dict[int, str] = {}
|
recherche = res[0]
|
||||||
for line in stdout.splitlines():
|
|
||||||
m = re.match(r"\s*(\d+)\s*[:.\-–—]\s*(.*\S)", line)
|
# Schritt 2: Bauen — Karten nur aus der Faktenbasis
|
||||||
if m:
|
await _set_progress(guide_id, "Baue OnePager…")
|
||||||
merksaetze.setdefault(int(m.group(1)), m.group(2))
|
slots = [{
|
||||||
sections = []
|
"key": f"{guide_id}-bauen",
|
||||||
for num, entry in entries.items():
|
"prompt": _prompt("OnePager-Bauen", topic=topic, recherche=recherche, extra=_extra(instructions)),
|
||||||
md = merksaetze.get(num)
|
"role": "fast", "capabilities": "none",
|
||||||
if md is None:
|
"payload": (lambda result: _parse_auswahl(result[1]) or None),
|
||||||
_log(topic, f"OnePager: Merksatz für Baustein {num} fehlt")
|
}]
|
||||||
continue
|
res = await _race(topic, "OnePager-Bauen", slots, 1, _timeout("onepager_bauen"), provider, cancelled=is_cancelled)
|
||||||
sections.append({"num": num, "title": _titel(entry), "md": md})
|
if is_cancelled():
|
||||||
if not sections:
|
|
||||||
await _fail(guide_id, "OnePager-Antwort nicht parsebar")
|
|
||||||
return None
|
return None
|
||||||
|
if res is None:
|
||||||
|
await _fail(guide_id, "OnePager-Bau fehlgeschlagen")
|
||||||
|
return None
|
||||||
|
cards = res[0]
|
||||||
|
|
||||||
|
# Schritt 3: Verifizieren — OK oder vollständig korrigierte Liste (nicht fatal)
|
||||||
|
await _set_progress(guide_id, "Verifiziere OnePager…")
|
||||||
|
karten_block = "\n".join(f"{i}. {t}" for i, t in cards.items())
|
||||||
|
slots = [{
|
||||||
|
"key": f"{guide_id}-verify",
|
||||||
|
"prompt": _prompt("OnePager-Verifikation", topic=topic, recherche=recherche, karten=karten_block),
|
||||||
|
"role": "fast", "capabilities": "none",
|
||||||
|
"payload": (lambda result: result[1].strip() if (_parse_auswahl(result[1]) or "OK" in result[1].upper()) else None),
|
||||||
|
}]
|
||||||
|
res = await _race(topic, "OnePager-Verifikation", slots, 1, _timeout("onepager_verify"), provider, cancelled=is_cancelled)
|
||||||
|
if is_cancelled():
|
||||||
|
return None
|
||||||
|
if res is None:
|
||||||
|
_log(topic, "OnePager-Verifikation fehlgeschlagen — ungeprüfte Version wird verwendet")
|
||||||
|
else:
|
||||||
|
corrected = _parse_auswahl(res[0])
|
||||||
|
if corrected:
|
||||||
|
_log(topic, "OnePager-Verifikation hat Korrekturen geliefert")
|
||||||
|
cards = corrected
|
||||||
|
|
||||||
|
sections = [
|
||||||
|
{"num": i, "title": _titel(text), "md": text.split(" — ", 1)[1].strip() if " — " in text else text}
|
||||||
|
for i, text in cards.items()
|
||||||
|
]
|
||||||
return [{"title": topic, "sections": sections}]
|
return [{"title": topic, "sections": sections}]
|
||||||
|
|
||||||
|
|
||||||
@@ -431,12 +856,13 @@ async def _generate_sections(
|
|||||||
# Ein Writer, gliedert selbst in Kapitel
|
# Ein Writer, gliedert selbst in Kapitel
|
||||||
plan = None
|
plan = None
|
||||||
zuteilungen = [bausteine_block]
|
zuteilungen = [bausteine_block]
|
||||||
|
chunk_sizes = [len(entries)]
|
||||||
else:
|
else:
|
||||||
await _set_progress(guide_id, "Plane Gliederung…")
|
await _set_progress(guide_id, "Plane Gliederung…")
|
||||||
returncode, stdout, stderr = await run_agent(
|
returncode, stdout, stderr = await run_agent(
|
||||||
f"{guide_id}-plan",
|
f"{guide_id}-plan",
|
||||||
_prompt("Guide-Plan", topic=topic, format_name=format_name, bausteine=bausteine_block, extra=_extra(instructions)),
|
_prompt("Guide-Plan", topic=topic, format_name=format_name, bausteine=bausteine_block, extra=_extra(instructions)),
|
||||||
AGENT_TIMEOUT, provider=provider, role="fast", capabilities="none",
|
_timeout("plan", len(entries)), provider=provider, role="guide", capabilities="none",
|
||||||
)
|
)
|
||||||
if guide_id in _cancelled:
|
if guide_id in _cancelled:
|
||||||
return None
|
return None
|
||||||
@@ -449,6 +875,7 @@ async def _generate_sections(
|
|||||||
return None
|
return None
|
||||||
chunks = _split_chunks(plan, WRITER_COUNT[format_name])
|
chunks = _split_chunks(plan, WRITER_COUNT[format_name])
|
||||||
zuteilungen = [_zuteilung_text(chunk, entries) for chunk in chunks]
|
zuteilungen = [_zuteilung_text(chunk, entries) for chunk in chunks]
|
||||||
|
chunk_sizes = [sum(len(c["nums"]) for c in chunk) for chunk in chunks]
|
||||||
|
|
||||||
writer_count = len(zuteilungen)
|
writer_count = len(zuteilungen)
|
||||||
await _set_progress(guide_id, f"Schreibe Sections ({writer_count} Writer)…" if writer_count > 1 else "Schreibe Sections…")
|
await _set_progress(guide_id, f"Schreibe Sections ({writer_count} Writer)…" if writer_count > 1 else "Schreibe Sections…")
|
||||||
@@ -462,9 +889,9 @@ async def _generate_sections(
|
|||||||
topic=topic, format_name=format_name, zuteilung=zuteilung,
|
topic=topic, format_name=format_name, zuteilung=zuteilung,
|
||||||
facts=facts, spec=spec, out_path=path, extra=_extra(instructions),
|
facts=facts, spec=spec, out_path=path, extra=_extra(instructions),
|
||||||
),
|
),
|
||||||
AGENT_TIMEOUT, provider=provider, role="guide", capabilities="full",
|
_timeout("writer", size), provider=provider, role="guide", capabilities="full",
|
||||||
)
|
)
|
||||||
for i, (zuteilung, path) in enumerate(zip(zuteilungen, paths), 1)
|
for i, (zuteilung, path, size) in enumerate(zip(zuteilungen, paths, chunk_sizes), 1)
|
||||||
], return_exceptions=True)
|
], return_exceptions=True)
|
||||||
if guide_id in _cancelled:
|
if guide_id in _cancelled:
|
||||||
return None
|
return None
|
||||||
@@ -515,7 +942,7 @@ async def _generate_sections(
|
|||||||
async def generate_guide(guide_id: str, topic: str, format_name: str, instructions: str = "", provider: str = DEFAULT_PROVIDER) -> None:
|
async def generate_guide(guide_id: str, topic: str, format_name: str, instructions: str = "", provider: str = DEFAULT_PROVIDER) -> None:
|
||||||
async with _semaphore:
|
async with _semaphore:
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
await update_guide(guide_id, status="generating", progress="Lese Bausteine…", updated_at=now)
|
await update_guide(guide_id, status="generating", progress="Starte…", updated_at=now)
|
||||||
|
|
||||||
content_path = guide_content_path(topic, format_name)
|
content_path = guide_content_path(topic, format_name)
|
||||||
project = project_dir(topic) if project_dir(topic).is_dir() else None
|
project = project_dir(topic) if project_dir(topic).is_dir() else None
|
||||||
@@ -525,18 +952,17 @@ async def generate_guide(guide_id: str, topic: str, format_name: str, instructio
|
|||||||
if guide_id in _cancelled:
|
if guide_id in _cancelled:
|
||||||
return
|
return
|
||||||
|
|
||||||
cats = _parse_kategorien(bausteine_path(topic).read_text(encoding="utf-8"))
|
|
||||||
selected: list[str] = []
|
|
||||||
for cat in FORMAT_COVERAGE[format_name]:
|
|
||||||
selected.extend(cats.get(cat, []))
|
|
||||||
if not selected:
|
|
||||||
await _fail(guide_id, "Keine passenden Bausteine gefunden")
|
|
||||||
return
|
|
||||||
entries = {i: text for i, text in enumerate(selected, 1)}
|
|
||||||
|
|
||||||
if format_name == "OnePager":
|
if format_name == "OnePager":
|
||||||
chapters = await _generate_onepager(guide_id, topic, entries, instructions, provider)
|
chapters = await _generate_onepager(guide_id, topic, instructions, provider, project, content_path, fragment_paths)
|
||||||
else:
|
else:
|
||||||
|
cats = _parse_kategorien(bausteine_path(topic).read_text(encoding="utf-8"))
|
||||||
|
selected: list[str] = []
|
||||||
|
for cat in FORMAT_COVERAGE[format_name]:
|
||||||
|
selected.extend(cats.get(cat, []))
|
||||||
|
if not selected:
|
||||||
|
await _fail(guide_id, "Keine passenden Bausteine gefunden")
|
||||||
|
return
|
||||||
|
entries = {i: text for i, text in enumerate(selected, 1)}
|
||||||
facts = _prompt("Guide-Fakten-Projekt", project=project) if project else _prompt("Guide-Fakten-Thema")
|
facts = _prompt("Guide-Fakten-Projekt", project=project) if project else _prompt("Guide-Fakten-Thema")
|
||||||
chapters = await _generate_sections(
|
chapters = await _generate_sections(
|
||||||
guide_id, topic, format_name, entries,
|
guide_id, topic, format_name, entries,
|
||||||
@@ -554,7 +980,7 @@ async def generate_guide(guide_id: str, topic: str, format_name: str, instructio
|
|||||||
await update_guide(guide_id, status="done", progress=None, updated_at=now)
|
await update_guide(guide_id, status="done", progress=None, updated_at=now)
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
await _fail(guide_id, f"Timeout bei Generierung nach {AGENT_TIMEOUT}s")
|
await _fail(guide_id, "Timeout bei der Generierung")
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
await _fail(guide_id, "Bausteine fehlen")
|
await _fail(guide_id, "Bausteine fehlen")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -18,17 +18,28 @@ class GuideCreateRequest(BaseModel):
|
|||||||
provider: ProviderType = "claude"
|
provider: ProviderType = "claude"
|
||||||
|
|
||||||
|
|
||||||
|
class TopicCreateRequest(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
class BausteineCreateRequest(BaseModel):
|
class BausteineCreateRequest(BaseModel):
|
||||||
topic: str = Field(min_length=1, max_length=100)
|
topic: str = Field(min_length=1, max_length=100)
|
||||||
instructions: str = Field(default="", max_length=2000)
|
instructions: str = Field(default="", max_length=2000)
|
||||||
provider: ProviderType = "claude"
|
provider: ProviderType = "claude"
|
||||||
|
|
||||||
|
|
||||||
|
class BausteineStep(BaseModel):
|
||||||
|
label: str
|
||||||
|
state: Literal["done", "active", "pending"]
|
||||||
|
|
||||||
|
|
||||||
class BausteineStatusResponse(BaseModel):
|
class BausteineStatusResponse(BaseModel):
|
||||||
ready: bool
|
ready: bool
|
||||||
generating: bool
|
generating: bool
|
||||||
progress: str | None = None
|
progress: str | None = None
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
|
partial: bool = False
|
||||||
|
steps: list[BausteineStep] = []
|
||||||
|
|
||||||
|
|
||||||
class ProjectResponse(BaseModel):
|
class ProjectResponse(BaseModel):
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ def bausteine_topics() -> list[str]:
|
|||||||
return []
|
return []
|
||||||
return [
|
return [
|
||||||
p.stem for p in bdir.glob("*.md")
|
p.stem for p in bdir.glob("*.md")
|
||||||
if not re.search(r"\.(recherche-\d+|auswahl)$", p.stem)
|
if not re.search(r"\.(recherche-\d+|auswahl(-\d+|-check)?|einordnung-\d+|final-check|sortierung)$", p.stem)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,14 +10,16 @@ from agents import provider_available
|
|||||||
from config import PROJECTS_DIR, PROVIDERS
|
from config import PROJECTS_DIR, PROVIDERS
|
||||||
from database import (
|
from database import (
|
||||||
create_guide, delete_guide, get_guide, list_guides,
|
create_guide, delete_guide, get_guide, list_guides,
|
||||||
|
create_topic, list_topics as db_list_topics, delete_topic,
|
||||||
list_progress, set_progress, delete_progress,
|
list_progress, set_progress, delete_progress,
|
||||||
)
|
)
|
||||||
from generator import (
|
from generator import (
|
||||||
generate_guide, cancel_guide, chat_with_guide,
|
generate_guide, cancel_guide, chat_with_guide,
|
||||||
generate_bausteine, bausteine_status, active_bausteine, reset_bausteine,
|
generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine,
|
||||||
)
|
)
|
||||||
from models import (
|
from models import (
|
||||||
GuideCreateRequest, GuideResponse,
|
GuideCreateRequest, GuideResponse,
|
||||||
|
TopicCreateRequest,
|
||||||
BausteineCreateRequest, BausteineStatusResponse,
|
BausteineCreateRequest, BausteineStatusResponse,
|
||||||
GuideChatRequest, GuideChatResponse,
|
GuideChatRequest, GuideChatResponse,
|
||||||
ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo,
|
ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo,
|
||||||
@@ -33,12 +35,26 @@ async def get_providers():
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/topics")
|
@router.get("/topics")
|
||||||
async def list_topics():
|
async def get_topics():
|
||||||
|
db_topics = await db_list_topics()
|
||||||
guides = await list_guides()
|
guides = await list_guides()
|
||||||
topics = {g["topic"] for g in guides}
|
derived = {g["topic"] for g in guides}
|
||||||
topics.update(bausteine_topics())
|
derived.update(bausteine_topics())
|
||||||
topics.update(job["topic"] for job in active_bausteine())
|
derived.update(job["topic"] for job in active_bausteine())
|
||||||
return sorted(topics)
|
# DB ist führend (Reihenfolge: neueste zuerst); Abgeleitetes ohne DB-Eintrag hinten anhängen
|
||||||
|
return db_topics + sorted(derived - set(db_topics))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/topics")
|
||||||
|
async def add_topic(req: TopicCreateRequest):
|
||||||
|
await create_topic(req.name.strip())
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/topics")
|
||||||
|
async def remove_topic(topic: str):
|
||||||
|
await delete_topic(topic)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
def _safe_project_name(name: str) -> str:
|
def _safe_project_name(name: str) -> str:
|
||||||
@@ -81,10 +97,18 @@ async def create_bausteine(req: BausteineCreateRequest):
|
|||||||
topic = req.topic.strip()
|
topic = req.topic.strip()
|
||||||
if bausteine_status(topic)["generating"]:
|
if bausteine_status(topic)["generating"]:
|
||||||
return {"ok": True, "status": "already_generating"}
|
return {"ok": True, "status": "already_generating"}
|
||||||
|
await create_topic(topic)
|
||||||
asyncio.create_task(generate_bausteine(topic, req.instructions.strip(), req.provider))
|
asyncio.create_task(generate_bausteine(topic, req.instructions.strip(), req.provider))
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bausteine/cancel")
|
||||||
|
async def cancel_bausteine_route(topic: str):
|
||||||
|
if not cancel_bausteine(topic):
|
||||||
|
raise HTTPException(404, "Keine laufende Generierung")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/bausteine")
|
@router.delete("/bausteine")
|
||||||
async def remove_bausteine(topic: str):
|
async def remove_bausteine(topic: str):
|
||||||
reset_bausteine(topic)
|
reset_bausteine(topic)
|
||||||
@@ -95,8 +119,9 @@ async def remove_bausteine(topic: str):
|
|||||||
|
|
||||||
@router.post("/guides", response_model=GuideResponse)
|
@router.post("/guides", response_model=GuideResponse)
|
||||||
async def create(req: GuideCreateRequest):
|
async def create(req: GuideCreateRequest):
|
||||||
if not bausteine_path(req.topic.strip()).exists():
|
if req.format != "OnePager" and not bausteine_path(req.topic.strip()).exists():
|
||||||
raise HTTPException(400, "Erst Bausteine erstellen")
|
raise HTTPException(400, "Erst Bausteine erstellen")
|
||||||
|
await create_topic(req.topic.strip())
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
guide = {
|
guide = {
|
||||||
"id": str(uuid.uuid4()),
|
"id": str(uuid.uuid4()),
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
import { fetchGuides, fetchTopics, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, fetchBausteineStatus, fetchActiveBausteine, createBausteine as apiCreateBausteine, deleteBausteine as apiDeleteBausteine, fetchProjects, deleteProject as apiDeleteProject, fetchProviders } from './api.js'
|
import { fetchGuides, fetchTopics, createTopic as apiCreateTopic, deleteTopic as apiDeleteTopic, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, fetchBausteineStatus, fetchActiveBausteine, createBausteine as apiCreateBausteine, cancelBausteine as apiCancelBausteine, deleteBausteine as apiDeleteBausteine, fetchProjects, deleteProject as apiDeleteProject, fetchProviders } from './api.js'
|
||||||
import TopicSidebar from './components/TopicSidebar.vue'
|
import TopicSidebar from './components/TopicSidebar.vue'
|
||||||
import TopicDetail from './components/TopicDetail.vue'
|
import TopicDetail from './components/TopicDetail.vue'
|
||||||
|
|
||||||
const guides = ref([])
|
const guides = ref([])
|
||||||
const projects = ref([])
|
const projects = ref([])
|
||||||
const manualTopics = ref(JSON.parse(localStorage.getItem('manualTopics') || '[]'))
|
|
||||||
const backendTopics = ref([])
|
const backendTopics = ref([])
|
||||||
|
|
||||||
function persistManualTopics() {
|
|
||||||
localStorage.setItem('manualTopics', JSON.stringify(manualTopics.value))
|
|
||||||
}
|
|
||||||
const selectedTopic = ref(null)
|
const selectedTopic = ref(null)
|
||||||
const previewGuide = ref(null)
|
const previewGuide = ref(null)
|
||||||
const sidebarPinned = ref(localStorage.getItem('sidebarPinned') !== 'false')
|
const sidebarPinned = ref(localStorage.getItem('sidebarPinned') !== 'false')
|
||||||
@@ -21,7 +16,7 @@ const darkMode = ref(
|
|||||||
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
: localStorage.getItem('darkMode') === 'true',
|
: localStorage.getItem('darkMode') === 'true',
|
||||||
)
|
)
|
||||||
const EMPTY_BAUSTEINE = { ready: false, generating: false, progress: null, error: null }
|
const EMPTY_BAUSTEINE = { ready: false, generating: false, progress: null, error: null, partial: false, steps: [] }
|
||||||
const bausteine = ref({ ...EMPTY_BAUSTEINE })
|
const bausteine = ref({ ...EMPTY_BAUSTEINE })
|
||||||
const activeBausteine = ref([])
|
const activeBausteine = ref([])
|
||||||
const provider = ref(localStorage.getItem('provider') || 'claude')
|
const provider = ref(localStorage.getItem('provider') || 'claude')
|
||||||
@@ -76,18 +71,7 @@ const projectNames = computed(() => projects.value.map((p) => p.name))
|
|||||||
|
|
||||||
const topics = computed(() => {
|
const topics = computed(() => {
|
||||||
const isProject = new Set(projectNames.value)
|
const isProject = new Set(projectNames.value)
|
||||||
const topicDates = {}
|
return backendTopics.value.filter((t) => !isProject.has(t))
|
||||||
for (const g of guides.value) {
|
|
||||||
if (isProject.has(g.topic)) continue
|
|
||||||
if (!topicDates[g.topic] || g.created_at > topicDates[g.topic]) {
|
|
||||||
topicDates[g.topic] = g.created_at
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const t of [...backendTopics.value, ...manualTopics.value]) {
|
|
||||||
if (isProject.has(t)) continue
|
|
||||||
if (!topicDates[t]) topicDates[t] = ''
|
|
||||||
}
|
|
||||||
return Object.keys(topicDates).sort((a, b) => topicDates[b].localeCompare(topicDates[a]))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const doneByFormat = computed(() => {
|
const doneByFormat = computed(() => {
|
||||||
@@ -176,16 +160,26 @@ function selectTopic(topic) {
|
|||||||
nextTick(autoPreview)
|
nextTick(autoPreview)
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTopic(topic) {
|
async function createTopic(topic) {
|
||||||
if (!manualTopics.value.includes(topic)) {
|
await apiCreateTopic(topic)
|
||||||
manualTopics.value.push(topic)
|
await loadTopics()
|
||||||
persistManualTopics()
|
|
||||||
}
|
|
||||||
selectedTopic.value = topic
|
selectedTopic.value = topic
|
||||||
previewGuide.value = null
|
previewGuide.value = null
|
||||||
loadBausteine()
|
loadBausteine()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCancelBausteine() {
|
||||||
|
if (!selectedTopic.value) return
|
||||||
|
await apiCancelBausteine(selectedTopic.value)
|
||||||
|
await loadBausteine()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResetBausteine() {
|
||||||
|
if (!selectedTopic.value) return
|
||||||
|
await apiDeleteBausteine(selectedTopic.value)
|
||||||
|
await loadBausteine()
|
||||||
|
}
|
||||||
|
|
||||||
async function handleBausteineClick({ instructions }) {
|
async function handleBausteineClick({ instructions }) {
|
||||||
if (!selectedTopic.value) return
|
if (!selectedTopic.value) return
|
||||||
await apiCreateBausteine(selectedTopic.value, instructions, provider.value)
|
await apiCreateBausteine(selectedTopic.value, instructions, provider.value)
|
||||||
@@ -247,8 +241,7 @@ async function handleDeleteTopic(topic) {
|
|||||||
await deleteGuide(g.id)
|
await deleteGuide(g.id)
|
||||||
}
|
}
|
||||||
await apiDeleteBausteine(topic)
|
await apiDeleteBausteine(topic)
|
||||||
manualTopics.value = manualTopics.value.filter((t) => t !== topic)
|
await apiDeleteTopic(topic)
|
||||||
persistManualTopics()
|
|
||||||
await loadTopics()
|
await loadTopics()
|
||||||
if (selectedTopic.value === topic) {
|
if (selectedTopic.value === topic) {
|
||||||
selectedTopic.value = null
|
selectedTopic.value = null
|
||||||
@@ -304,6 +297,8 @@ onUnmounted(() => {
|
|||||||
@create="createTopic"
|
@create="createTopic"
|
||||||
@formatClick="handleFormatClick"
|
@formatClick="handleFormatClick"
|
||||||
@bausteineClick="handleBausteineClick"
|
@bausteineClick="handleBausteineClick"
|
||||||
|
@cancelBausteine="handleCancelBausteine"
|
||||||
|
@resetBausteine="handleResetBausteine"
|
||||||
@deleteTopic="handleDeleteTopic"
|
@deleteTopic="handleDeleteTopic"
|
||||||
@deleteProject="handleDeleteProject"
|
@deleteProject="handleDeleteProject"
|
||||||
@cancelGuide="handleCancel"
|
@cancelGuide="handleCancel"
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ export async function createBausteine(topic, instructions = '', provider = 'clau
|
|||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function cancelBausteine(topic) {
|
||||||
|
await fetch(`${BASE}/bausteine/cancel?topic=${encodeURIComponent(topic)}`, { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteBausteine(topic) {
|
export async function deleteBausteine(topic) {
|
||||||
await fetch(`${BASE}/bausteine?topic=${encodeURIComponent(topic)}`, { method: 'DELETE' })
|
await fetch(`${BASE}/bausteine?topic=${encodeURIComponent(topic)}`, { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
@@ -70,6 +74,18 @@ export async function fetchTopics() {
|
|||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createTopic(name) {
|
||||||
|
await fetch(`${BASE}/topics`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTopic(name) {
|
||||||
|
await fetch(`${BASE}/topics?topic=${encodeURIComponent(name)}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchProgress(id) {
|
export async function fetchProgress(id) {
|
||||||
const res = await fetch(`${BASE}/guides/${id}/progress`)
|
const res = await fetch(`${BASE}/guides/${id}/progress`)
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const props = defineProps({
|
|||||||
providers: { type: Array, default: () => [] },
|
providers: { type: Array, default: () => [] },
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['select', 'create', 'formatClick', 'bausteineClick', 'deleteTopic', 'deleteProject', 'cancelGuide', 'deleteGuide', 'preview', 'togglePin', 'sidebarLeave', 'toggleDark', 'setProvider'])
|
const emit = defineEmits(['select', 'create', 'formatClick', 'bausteineClick', 'cancelBausteine', 'resetBausteine', 'deleteTopic', 'deleteProject', 'cancelGuide', 'deleteGuide', 'preview', 'togglePin', 'sidebarLeave', 'toggleDark', 'setProvider'])
|
||||||
|
|
||||||
function providerAvailable(id) {
|
function providerAvailable(id) {
|
||||||
const p = props.providers.find((x) => x.id === id)
|
const p = props.providers.find((x) => x.id === id)
|
||||||
@@ -34,6 +34,10 @@ const formats = [
|
|||||||
|
|
||||||
const BAUSTEINE_KEY = '__bausteine__'
|
const BAUSTEINE_KEY = '__bausteine__'
|
||||||
|
|
||||||
|
const bausteineUnsortiert = computed(
|
||||||
|
() => props.bausteine.ready && props.bausteine.steps?.at(-1)?.state === 'pending',
|
||||||
|
)
|
||||||
|
|
||||||
const bausteineState = computed(() => {
|
const bausteineState = computed(() => {
|
||||||
if (props.bausteine.generating) return 'generating'
|
if (props.bausteine.generating) return 'generating'
|
||||||
return props.bausteine.ready ? 'done' : 'none'
|
return props.bausteine.ready ? 'done' : 'none'
|
||||||
@@ -49,6 +53,16 @@ const activeGenerations = computed(() => {
|
|||||||
return [...bausteinLines, ...guideLines]
|
return [...bausteinLines, ...guideLines]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function confirmCancelBausteine() {
|
||||||
|
if (!confirm('Aktuellen Schritt abbrechen? Bisheriger Fortschritt bleibt erhalten.')) return
|
||||||
|
emit('cancelBausteine')
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmResetBausteine() {
|
||||||
|
if (!confirm('Gespeicherten Bausteine-Fortschritt löschen?')) return
|
||||||
|
emit('resetBausteine')
|
||||||
|
}
|
||||||
|
|
||||||
function handleBausteinePlay() {
|
function handleBausteinePlay() {
|
||||||
if (bausteineState.value === 'generating') return
|
if (bausteineState.value === 'generating') return
|
||||||
const text = activeInput.value === BAUSTEINE_KEY ? inputText.value.trim() : ''
|
const text = activeInput.value === BAUSTEINE_KEY ? inputText.value.trim() : ''
|
||||||
@@ -171,15 +185,36 @@ function confirmDeleteProject(name) {
|
|||||||
<div class="progress-info" v-if="activeGenerations.length">
|
<div class="progress-info" v-if="activeGenerations.length">
|
||||||
<div v-for="(line, i) in activeGenerations" :key="i">{{ line }}</div>
|
<div v-for="(line, i) in activeGenerations" :key="i">{{ line }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div :class="['format-row', 'bausteine-row', 'fmt-' + bausteineState]">
|
<div class="format-row bausteine-row">
|
||||||
<button class="format-name">
|
<div class="format-name bausteine-name">
|
||||||
<span class="format-label">Bausteine</span>
|
<span class="format-label">Bausteine</span>
|
||||||
</button>
|
<span class="step-dots">
|
||||||
|
<span
|
||||||
|
v-for="s in (bausteine.steps || [])"
|
||||||
|
:key="s.label"
|
||||||
|
class="step-dot"
|
||||||
|
:class="s.state"
|
||||||
|
:title="s.state === 'active' ? (bausteine.progress || s.label) : s.label"
|
||||||
|
></span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="bausteineState === 'generating'"
|
||||||
|
class="format-x"
|
||||||
|
title="Aktuellen Schritt abbrechen (Fortschritt bleibt)"
|
||||||
|
@click.stop="confirmCancelBausteine"
|
||||||
|
>×</span>
|
||||||
|
<span
|
||||||
|
v-else-if="bausteine.partial"
|
||||||
|
class="format-x"
|
||||||
|
title="Fortschritt löschen"
|
||||||
|
@click.stop="confirmResetBausteine"
|
||||||
|
>×</span>
|
||||||
|
</div>
|
||||||
<div class="format-actions">
|
<div class="format-actions">
|
||||||
<template v-if="bausteineState !== 'generating'">
|
<template v-if="bausteineState !== 'generating'">
|
||||||
<button
|
<button
|
||||||
class="action-btn play"
|
class="action-btn play"
|
||||||
:title="bausteine.ready ? 'Bausteine neu erstellen' : 'Bausteine erstellen'"
|
:title="bausteine.partial ? 'Fortsetzen' : bausteineUnsortiert ? 'Sortierung nachholen' : bausteine.ready ? 'Bausteine neu erstellen' : 'Bausteine erstellen'"
|
||||||
@click="handleBausteinePlay"
|
@click="handleBausteinePlay"
|
||||||
>▶</button>
|
>▶</button>
|
||||||
<button
|
<button
|
||||||
@@ -216,8 +251,8 @@ function confirmDeleteProject(name) {
|
|||||||
<template v-if="guideStatus(f.key) !== 'generating' && guideStatus(f.key) !== 'queued'">
|
<template v-if="guideStatus(f.key) !== 'generating' && guideStatus(f.key) !== 'queued'">
|
||||||
<button
|
<button
|
||||||
class="action-btn play"
|
class="action-btn play"
|
||||||
:title="bausteine.ready ? 'Generieren' : 'Erst Bausteine erstellen'"
|
:title="f.key === 'OnePager' || bausteine.ready ? 'Generieren' : 'Erst Bausteine erstellen'"
|
||||||
:disabled="!bausteine.ready"
|
:disabled="f.key !== 'OnePager' && !bausteine.ready"
|
||||||
@click="handlePlay(f.key)"
|
@click="handlePlay(f.key)"
|
||||||
>▶</button>
|
>▶</button>
|
||||||
<button
|
<button
|
||||||
@@ -452,6 +487,41 @@ function confirmDeleteProject(name) {
|
|||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bausteine-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-dots {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 5px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--border-strong);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-dot.done {
|
||||||
|
background: var(--success-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-dot.active {
|
||||||
|
background: var(--warning-border);
|
||||||
|
animation: dot-pulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dot-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.35; }
|
||||||
|
}
|
||||||
|
|
||||||
.action-btn:disabled {
|
.action-btn:disabled {
|
||||||
opacity: 0.35;
|
opacity: 0.35;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
|||||||
19
templates/Prompt/Bausteine-Auswahl-Check.md
Normal file
19
templates/Prompt/Bausteine-Auswahl-Check.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
Eine konsolidierte Baustein-Liste zum Thema "{topic}" wurde aus drei Recherchen erstellt. Prüfe sie auf Verluste und Duplikate.
|
||||||
|
|
||||||
|
RECHERCHEN:
|
||||||
|
{results}
|
||||||
|
|
||||||
|
KONSOLIDIERTE LISTE:
|
||||||
|
{auswahl}
|
||||||
|
|
||||||
|
Prüfe genau zwei Dinge:
|
||||||
|
1. FEHLT etwas? Bausteine, die in mindestens einer Recherche belegt sind, aber in der konsolidierten Liste nicht vorkommen — auch nicht unter anderem Titel oder in einem Sammeleintrag.
|
||||||
|
2. DOPPELT? Einträge der Liste, die dasselbe Konzept beschreiben. Der beste bleibt, die übrigen werden gestrichen.
|
||||||
|
|
||||||
|
Antworte AUSSCHLIESSLICH in diesem Format. Leere Abschnitte weglassen; ist nichts zu tun, antworte nur mit OK:
|
||||||
|
NACHTRÄGE:
|
||||||
|
Titel — Kurzbeschreibung (max. ~12 Wörter)
|
||||||
|
Titel — Kurzbeschreibung
|
||||||
|
STREICHEN:
|
||||||
|
12 Titel
|
||||||
|
17 Titel
|
||||||
@@ -1,28 +1,26 @@
|
|||||||
Zwei Agenten haben die Bausteine des Themas "{topic}" unabhängig voneinander in KERN/WICHTIG/REST eingeordnet. Triff die finale Einordnung.
|
Die Bausteine des Themas "{topic}" wurden per Mehrheitsentscheid aus drei unabhängigen Einordnungen in KERN/WICHTIG/REST kategorisiert. Verifiziere das Ergebnis.
|
||||||
|
|
||||||
BAUSTEINE:
|
MEHRHEITS-EINORDNUNG:
|
||||||
{bausteine}
|
{einordnung}
|
||||||
|
|
||||||
EINORDNUNG 1:
|
STREITFÄLLE (keine Mehrheit gefunden — diese MUSST du einordnen):
|
||||||
{einordnung_1}
|
{streitfaelle}
|
||||||
|
|
||||||
EINORDNUNG 2:
|
|
||||||
{einordnung_2}
|
|
||||||
|
|
||||||
Kategorien:
|
Kategorien:
|
||||||
- KERN: ohne diese Bausteine kann man das Thema nicht verstehen oder benutzen
|
- KERN: ohne diese Bausteine kann man das Thema nicht verstehen oder benutzen
|
||||||
- WICHTIG: in der echten Praxis nötig, aber nicht Teil des Einstiegs
|
- WICHTIG: in der echten Praxis nötig, aber nicht Teil des Einstiegs
|
||||||
- REST: Spezialfälle, Randthemen, selten Gebrauchtes
|
- REST: Spezialfälle, Randthemen, selten Gebrauchtes
|
||||||
|
|
||||||
Regeln:
|
Aufgabe:
|
||||||
- Bei Übereinstimmung: Einordnung übernehmen. Bei Abweichung: entscheide selbst anhand der Kategorien-Definitionen.
|
1. Ordne jeden Streitfall einer Kategorie zu.
|
||||||
- Jeder Baustein landet in GENAU einer Kategorie. Keinen weglassen, keinen neuen erfinden.
|
2. Prüfe die Mehrheits-Einordnung gegen die Definitionen: liste NUR die Nummern, deren Kategorie du für falsch hältst, unter der korrekten Kategorie.
|
||||||
|
|
||||||
Antworte AUSSCHLIESSLICH in diesem Format — pro Zeile Nummer und Titel des Bausteins, kein weiterer Text davor oder danach:
|
Nicht gelistete Nummern gelten als bestätigt. Keine neuen Bausteine erfinden.
|
||||||
|
|
||||||
|
Antworte AUSSCHLIESSLICH in diesem Format. Gibt es weder Streitfälle noch Korrekturen, antworte nur mit OK:
|
||||||
KERN:
|
KERN:
|
||||||
1 Titel
|
17 Titel
|
||||||
4 Titel
|
|
||||||
WICHTIG:
|
WICHTIG:
|
||||||
2 Titel
|
42 Titel
|
||||||
REST:
|
REST:
|
||||||
3 Titel
|
8 Titel
|
||||||
17
templates/Prompt/Bausteine-Sortierung.md
Normal file
17
templates/Prompt/Bausteine-Sortierung.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
Sortiere die Bausteine des Themas "{topic}" innerhalb ihrer Kategorien in Lernreihenfolge — vom Einfachen/Grundlegenden zum Komplexen/Speziellen.
|
||||||
|
|
||||||
|
BAUSTEINE:
|
||||||
|
{einordnung}
|
||||||
|
|
||||||
|
Regeln:
|
||||||
|
- Kategorien NICHT verändern, keine Bausteine weglassen, keine erfinden — nur die Reihenfolge innerhalb jeder Kategorie ändern.
|
||||||
|
- Was etwas anderes voraussetzt, kommt nach seinen Voraussetzungen.
|
||||||
|
|
||||||
|
Antworte AUSSCHLIESSLICH in diesem Format — alle Nummern jeder Kategorie in der neuen Reihenfolge:
|
||||||
|
KERN:
|
||||||
|
3 Titel
|
||||||
|
1 Titel
|
||||||
|
WICHTIG:
|
||||||
|
7 Titel
|
||||||
|
REST:
|
||||||
|
2 Titel
|
||||||
14
templates/Prompt/OnePager-Bauen.md
Normal file
14
templates/Prompt/OnePager-Bauen.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
Baue aus der Faktenbasis einen OnePager zum Thema "{topic}" — die wichtigsten Informationen, die auf eine Seite passen.
|
||||||
|
|
||||||
|
FAKTENBASIS (alleinige Quelle, nichts hinzuerfinden):
|
||||||
|
{recherche}
|
||||||
|
|
||||||
|
Regeln:
|
||||||
|
- Wähle die wichtigsten Punkte aus — was man über "{topic}" wissen MUSS. Eine Bildschirmseite, also etwa 10–18 Karten.
|
||||||
|
- Pro Karte GENAU ein prägnanter Merksatz: maximal ~15 Wörter, `inline-code` wo es hilft.
|
||||||
|
- Reihenfolge: vom Grundlegenden zum Speziellen.
|
||||||
|
|
||||||
|
Antworte AUSSCHLIESSLICH in diesem Format, eine Zeile pro Karte, kein weiterer Text:
|
||||||
|
1. Titel — Merksatz
|
||||||
|
2. Titel — Merksatz
|
||||||
|
{extra}
|
||||||
1
templates/Prompt/OnePager-Quelle-Projekt.md
Normal file
1
templates/Prompt/OnePager-Quelle-Projekt.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Das Thema ist das Projekt unter {project}. Verschaffe dir mit Bash (ls/find) einen Überblick und lies README, Doku und den relevanten Quellcode mit dem Read-Tool. Erfasse Zweck, Architektur und die wichtigsten Konzepte — nichts Erfundenes.
|
||||||
1
templates/Prompt/OnePager-Quelle-Thema.md
Normal file
1
templates/Prompt/OnePager-Quelle-Thema.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Recherchiere per Websuche: aktuelle Version, die Kernkonzepte und die wichtigsten Fakten zu "{topic}". Nimm nur auf, was du in der Recherche belegt hast.
|
||||||
8
templates/Prompt/OnePager-Recherche.md
Normal file
8
templates/Prompt/OnePager-Recherche.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
Sammle die Faktenbasis für einen OnePager — die kompakteste Übersicht — zum Thema "{topic}".
|
||||||
|
|
||||||
|
{source}
|
||||||
|
|
||||||
|
Schreibe NUR die Markdown-Datei nach: {out_path}
|
||||||
|
|
||||||
|
Inhalt der Datei: die wichtigsten Punkte des Themas (Kernkonzepte, zentrale Fakten, Versionen, typische Anwendung) — kompakt, faktenorientiert, mit Quelle (URL bzw. Dateipfad) pro Punkt. Keine Fließtexte, keine Einleitung. Die Datei ist die alleinige Faktenbasis für den OnePager.
|
||||||
|
{extra}
|
||||||
17
templates/Prompt/OnePager-Verifikation.md
Normal file
17
templates/Prompt/OnePager-Verifikation.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
Verifiziere einen OnePager zum Thema "{topic}" gegen seine Faktenbasis.
|
||||||
|
|
||||||
|
FAKTENBASIS:
|
||||||
|
{recherche}
|
||||||
|
|
||||||
|
ONEPAGER-KARTEN:
|
||||||
|
{karten}
|
||||||
|
|
||||||
|
Prüfe:
|
||||||
|
1. Stimmen alle Aussagen mit der Faktenbasis überein? Nichts Erfundenes?
|
||||||
|
2. Fehlt ein Punkt, der für "{topic}" unverzichtbar ist und in der Faktenbasis steht?
|
||||||
|
3. Sind die Merksätze prägnant (max. ~15 Wörter) und vom Grundlegenden zum Speziellen geordnet?
|
||||||
|
|
||||||
|
Ist alles in Ordnung, antworte NUR mit: OK
|
||||||
|
Sonst antworte AUSSCHLIESSLICH mit der vollständigen, korrigierten Karten-Liste im selben Format:
|
||||||
|
1. Titel — Merksatz
|
||||||
|
2. Titel — Merksatz
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
Erstelle einen OnePager — die kompakteste Übersicht — zum Thema "{topic}".
|
|
||||||
|
|
||||||
BAUSTEINE (der Kern des Themas):
|
|
||||||
{bausteine}
|
|
||||||
|
|
||||||
Aufgabe: GENAU ein prägnanter Merksatz pro Baustein — das eine, was man im Kopf behalten muss. Maximal ~15 Wörter, `inline-code` wo es hilft. Den Baustein-Titel NICHT wiederholen, er steht schon fest.
|
|
||||||
|
|
||||||
Antworte AUSSCHLIESSLICH in diesem Format, eine Zeile pro Baustein, kein weiterer Text:
|
|
||||||
3: Merksatz
|
|
||||||
7: Merksatz
|
|
||||||
{extra}
|
|
||||||
Reference in New Issue
Block a user