This commit is contained in:
Team3
2026-06-06 17:40:06 +02:00
parent c84fbbb484
commit b2486a73a1
13 changed files with 203 additions and 342 deletions

View File

@@ -1,4 +1,4 @@
.PHONY: install dev prod stop logs remove auth sync .PHONY: install dev prod stop logs remove auth sync searxng ollama
COMPOSE = docker compose COMPOSE = docker compose
@@ -47,9 +47,18 @@ remove: stop
rm -rf storage/* rm -rf storage/*
@echo "Fertig." @echo "Fertig."
searxng:
docker run -d --name searxng --restart unless-stopped -p 8888:8080 searxng/searxng
@echo "SearxNG läuft auf http://localhost:8888 (Websuche für den Lokal-Provider)."
ollama:
@which ollama >/dev/null 2>&1 || curl -fsSL https://ollama.com/install.sh | sh
ollama pull qwen3:14b
ollama pull qwen3:8b
@echo "Ollama bereit — Provider 'Lokal' ist aktiv (Modelle anpassen: backend/config.py + dev-ops/opencode.json)."
sync: sync:
@mkdir -p storage/guides storage/bausteine @mkdir -p storage/themen
rsync -avz --progress root@178.104.67.87:/var/www/creator/storage/creator.db storage/ rsync -avz --progress root@178.104.67.87:/var/www/creator/storage/creator.db storage/
rsync -avz --progress --delete root@178.104.67.87:/var/www/creator/storage/guides/ storage/guides/ rsync -avz --progress --delete root@178.104.67.87:/var/www/creator/storage/themen/ storage/themen/
rsync -avz --progress --delete root@178.104.67.87:/var/www/creator/storage/bausteine/ storage/bausteine/
@echo "Sync abgeschlossen." @echo "Sync abgeschlossen."

View File

@@ -9,6 +9,7 @@ import os
import re import re
import shutil import shutil
import tempfile import tempfile
import urllib.request
from pathlib import Path from pathlib import Path
from config import PROVIDERS, DEFAULT_PROVIDER from config import PROVIDERS, DEFAULT_PROVIDER
@@ -41,6 +42,12 @@ def provider_available(provider: str) -> bool:
env_key = cfg.get("env_key") env_key = cfg.get("env_key")
if env_key and not os.environ.get(env_key): if env_key and not os.environ.get(env_key):
return False return False
check_url = cfg.get("check_url")
if check_url:
try:
urllib.request.urlopen(check_url, timeout=1)
except Exception:
return False
return True return True
@@ -63,8 +70,8 @@ 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})"
if provider == "minimax": if PROVIDERS[provider]["cli"] == "opencode":
return await _run_opencode(agent_key, prompt, timeout, role, capabilities) return await _run_opencode(agent_key, prompt, timeout, provider, role, capabilities)
return await _run_claude_cli(agent_key, prompt, timeout, role, capabilities) return await _run_claude_cli(agent_key, prompt, timeout, role, capabilities)
@@ -104,8 +111,8 @@ async def _run_claude_cli(agent_key: str, prompt: str, timeout: int, role: str,
return await _communicate(agent_key, cmd, prompt.encode("utf-8"), timeout) return await _communicate(agent_key, cmd, prompt.encode("utf-8"), timeout)
async def _run_opencode(agent_key: str, prompt: str, timeout: int, role: str, capabilities: str) -> tuple[int, str, str]: async def _run_opencode(agent_key: str, prompt: str, timeout: int, provider: str, role: str, capabilities: str) -> tuple[int, str, str]:
cfg = PROVIDERS["minimax"] cfg = PROVIDERS[provider]
# Prompt über Tempdatei statt argv (ARG_MAX-Schutz bei großen Projekt-Prompts) # Prompt über Tempdatei statt argv (ARG_MAX-Schutz bei großen Projekt-Prompts)
with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8", dir=tempfile.gettempdir()) as f: with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8", dir=tempfile.gettempdir()) as f:
f.write(prompt) f.write(prompt)

View File

@@ -15,9 +15,7 @@ TIMEOUTS = {
"recherche": (1800, 0), # fix 30 min "recherche": (1800, 0), # fix 30 min
"auswahl": (600, 10), "auswahl": (600, 10),
"auswahl_check": (300, 2), "auswahl_check": (300, 2),
"einordnung": (300, 5), "sortierung": (300, 5),
"final": (300, 2), # verifiziert nur noch, kleiner Output
"sortierung": (300, 2),
"plan": (300, 5), "plan": (300, 5),
"writer": (600, 120), # pro Section im Chunk "writer": (600, 120), # pro Section im Chunk
"onepager_recherche": (900, 0), "onepager_recherche": (900, 0),
@@ -25,6 +23,13 @@ TIMEOUTS = {
"onepager_verify": (300, 0), "onepager_verify": (300, 0),
} }
# Welcher Anteil der sortierten Baustein-Liste in welches Format fließt: (Anteil, Mindestanzahl).
FORMAT_ANTEIL = {
"MiniGuide": (0.10, 8),
"Guide": (0.50, 20),
"FullGuide": (1.00, 0),
}
# Provider-Stacks: komplett unabhängig, einer kann jederzeit entfernt werden. # Provider-Stacks: komplett unabhängig, einer kann jederzeit entfernt werden.
# Rollen: "quick" = Massenarbeit (Recherche, Einordnung), # Rollen: "quick" = Massenarbeit (Recherche, Einordnung),
# "fast" = Urteilsaufgaben mit kleinem Output (Auswahl, Final, OnePager, Chat), # "fast" = Urteilsaufgaben mit kleinem Output (Auswahl, Final, OnePager, Chat),
@@ -35,14 +40,22 @@ PROVIDERS = {
"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", "quick": "claude-sonnet-4-6",
"env_key": None, # Auth via CLAUDE_CODE_OAUTH_TOKEN oder ~/.claude "env_key": None, # Auth via CLAUDE_CODE_OAUTH_TOKEN oder ~/.claude
}, },
"minimax": { "minimax": {
"cli": "opencode", "cli": "opencode",
"guide": "minimax/MiniMax-M3", "guide": "minimax/MiniMax-M3",
"fast": "minimax/MiniMax-M2.7-highspeed", "fast": "minimax/MiniMax-M3",
"quick": "minimax/MiniMax-M2.7-highspeed", "quick": "minimax/MiniMax-M3",
"env_key": "MINIMAX_API_KEY", "env_key": "MINIMAX_API_KEY",
}, },
"lokal": {
"cli": "opencode",
"guide": "ollama/qwen3:14b",
"fast": "ollama/qwen3:8b",
"quick": "ollama/qwen3:8b",
"env_key": None,
"check_url": "http://localhost:11434/api/tags", # Ollama erreichbar?
},
} }

View File

@@ -1,20 +1,22 @@
import asyncio import asyncio
import json import json
import math
import shutil
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 (
DEFAULT_PROVIDER, DEFAULT_PROVIDER,
FORMAT_ANTEIL,
TEMPLATES_DIR, TEMPLATES_DIR,
TIMEOUTS, TIMEOUTS,
MAX_CONCURRENT_GENERATIONS, MAX_CONCURRENT_GENERATIONS,
) )
from database import update_guide from database import update_guide
from paths import bausteine_path, guide_content_path, project_dir from paths import arbeit_dir, bausteine_path, guide_content_path, project_dir
_semaphore = asyncio.Semaphore(MAX_CONCURRENT_GENERATIONS) _semaphore = asyncio.Semaphore(MAX_CONCURRENT_GENERATIONS)
_cancelled: set[str] = set() _cancelled: set[str] = set()
@@ -113,55 +115,48 @@ def _json_datei(path: Path):
return None return None
def _resolve_kategorien(data, entries: dict[int, str], min_match: float = 0.85): def _resolve_liste(data, entries: dict[int, str], min_match: float = 0.85) -> list[int] | None:
"""{"KERN": [Titel], …} → {num: Kategorie}; None bei zu vielen unbekannten Titeln """{"reihenfolge": [Titel, …]} → [nums]; None bei zu vielen unbekannten Titeln
oder zu geringer Abdeckung der Einträge.""" oder zu geringer Abdeckung der Einträge."""
if not isinstance(data, dict): if not isinstance(data, dict) or not isinstance(data.get("reihenfolge"), list):
return None return None
idx = _titel_index(entries) idx = _titel_index(entries)
mapping: dict[int, str] = {} nums: list[int] = []
total = unknown = 0 total = unknown = 0
for cat in _CATEGORIES: for t in data["reihenfolge"]:
items = data.get(cat, []) if not isinstance(t, str):
if not isinstance(items, list):
return None return None
for t in items: total += 1
if not isinstance(t, str): num = _titel_aufloesen(idx, t)
return None if num is None:
total += 1 unknown += 1
num = _titel_aufloesen(idx, t) elif num not in nums:
if num is None: nums.append(num)
unknown += 1
elif num not in mapping:
mapping[num] = cat
if total == 0: if total == 0:
return None return None
if (total - unknown) / total < min_match or len(mapping) / len(entries) < min_match: if (total - unknown) / total < min_match or len(nums) / len(entries) < min_match:
return None return None
return mapping return nums
def _resolve_reihenfolge(data, entries: dict[int, str], min_match: float = 0.85): def _merge_sortierungen(topic: str, listen: list[list[int]], entries: dict[int, str]) -> list[int]:
"""Wie _resolve_kategorien, aber liefert die Reihenfolge: {Kategorie: [nums]}.""" """Median-Rang über mehrere Sortierungen; Bausteine ohne Stimmen ans Ende."""
mapping = _resolve_kategorien(data, entries, min_match) raenge: dict[int, list[int]] = {num: [] for num in entries}
if mapping is None: for liste in listen:
return None for rang, num in enumerate(liste):
idx = _titel_index(entries) if num in raenge:
order: dict[str, list[int]] = {c: [] for c in _CATEGORIES} raenge[num].append(rang)
for cat in _CATEGORIES: ohne = [num for num, r in raenge.items() if not r]
for t in data.get(cat, []): if ohne:
num = _titel_aufloesen(idx, t) if isinstance(t, str) else None _log(topic, f"Sortierung: keine Stimmen für {[_titel(entries[n]) for n in ohne]} → ans Ende")
if num is not None and num not in order[cat]:
order[cat].append(num)
return order
def key(num: int):
r = sorted(raenge[num])
if not r:
return (10**9, 10**9, num)
return (r[len(r) // 2], sum(r) / len(r), num)
def _kategorien_block(mapping: dict[int, str], entries: dict[int, str]) -> str: return sorted(entries, key=key)
parts = []
for cat in _CATEGORIES:
titel = [_titel(entries[n]) for n in sorted(entries) if mapping.get(n) == cat]
parts.append(f"{cat}:\n" + ("\n".join(f"- {t}" for t in titel) if titel else "(leer)"))
return "\n".join(parts)
def _timeout(step: str, n: int = 0) -> int: def _timeout(step: str, n: int = 0) -> int:
@@ -239,36 +234,31 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
await asyncio.gather(*tasks.keys(), return_exceptions=True) await asyncio.gather(*tasks.keys(), return_exceptions=True)
# --- Bausteine-Pipeline: 4x Recherche (3) → 2x Auswahl (1) → Check → 4x Einordnung (3) → Mehrheit+Verifikation → Sortierung --- # --- Bausteine-Pipeline: 4x Recherche (3) → 2x Auswahl (1) → Check → 3x Sortierung (Median-Rang) ---
_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_cancelled: set[str] = set()
_bausteine_step: dict[str, int] = {} _bausteine_step: dict[str, int] = {}
BAUSTEINE_STEPS = ("Recherche", "Auswahl", "Prüfung", "Einordnung", "Verifikation", "Sortierung") BAUSTEINE_STEPS = ("Recherche", "Auswahl", "Prüfung", "Sortierung")
_CATEGORIES = ("KERN", "WICHTIG", "REST") _CATEGORIES = ("KERN", "WICHTIG", "REST") # nur noch für den Altformat-Reader
def _bausteine_files(topic: str) -> dict: def _bausteine_files(topic: str) -> dict:
final_path = bausteine_path(topic) arbeit = arbeit_dir(topic)
stem, parent = final_path.stem, final_path.parent
return { return {
"final": final_path, "final": bausteine_path(topic),
"recherche": [parent / f"{stem}.recherche-{i}.md" for i in (1, 2, 3, 4)], "arbeit": arbeit,
"auswahl": [parent / f"{stem}.auswahl-{i}.md" for i in (1, 2)], "recherche": [arbeit / f"recherche-{i}.md" for i in (1, 2, 3, 4)],
"auswahl_check": parent / f"{stem}.auswahl-check.json", "auswahl": [arbeit / f"auswahl-{i}.md" for i in (1, 2)],
"einordnung": [parent / f"{stem}.einordnung-{i}.json" for i in (1, 2, 3, 4)], "auswahl_check": arbeit / "auswahl-check.json",
"final_check": parent / f"{stem}.final-check.json", "sortierung": [arbeit / f"sortierung-{i}.json" for i in (1, 2, 3)],
"sortierung": parent / f"{stem}.sortierung.json",
} }
def _alle_slot_dateien(files: dict) -> list[Path]: def _alle_slot_dateien(files: dict) -> list[Path]:
return [ return [*files["recherche"], *files["auswahl"], files["auswahl_check"], *files["sortierung"]]
*files["recherche"], *files["auswahl"], files["auswahl_check"],
*files["einordnung"], files["final_check"], files["sortierung"],
]
def cancel_bausteine(topic: str) -> bool: def cancel_bausteine(topic: str) -> bool:
@@ -288,11 +278,7 @@ def _resume_step(topic: str) -> int:
return 1 return 1
if not files["auswahl_check"].exists(): if not files["auswahl_check"].exists():
return 2 return 2
if sum(p.exists() for p in files["einordnung"]) < 3: return 3
return 3
if not files["final_check"].exists():
return 4
return 5
def bausteine_status(topic: str) -> dict: def bausteine_status(topic: str) -> dict:
@@ -307,8 +293,6 @@ def bausteine_status(topic: str) -> dict:
] ]
elif ready: elif ready:
states = ["done"] * len(BAUSTEINE_STEPS) states = ["done"] * len(BAUSTEINE_STEPS)
if not _bausteine_files(topic)["sortierung"].exists():
states[-1] = "pending"
else: else:
nxt = _resume_step(topic) nxt = _resume_step(topic)
partial = nxt > 0 partial = nxt > 0
@@ -330,12 +314,7 @@ def active_bausteine() -> list[dict]:
def reset_bausteine(topic: str) -> None: def reset_bausteine(topic: str) -> None:
files = _bausteine_files(topic) files = _bausteine_files(topic)
files["final"].unlink(missing_ok=True) files["final"].unlink(missing_ok=True)
for p in _alle_slot_dateien(files): shutil.rmtree(files["arbeit"], ignore_errors=True)
p.unlink(missing_ok=True)
# Altlasten früherer Formatversionen
stem, parent = files["final"].stem, files["final"].parent
for alt in parent.glob(f"{stem}.*.md"):
alt.unlink(missing_ok=True)
_bausteine_errors.pop(topic, None) _bausteine_errors.pop(topic, None)
@@ -351,7 +330,7 @@ def _build_recherche_prompt(topic: str, out_path: Path, instructions: str = "",
def _parse_auswahl(text: str) -> dict[int, str]: def _parse_auswahl(text: str) -> dict[int, str]:
"""Parst die konsolidierte Liste: `N. Titel — Kurzbeschreibung` pro Zeile.""" """Parst eine Baustein-Liste: `N. Titel — Kurzbeschreibung` pro Zeile."""
entries: dict[int, str] = {} entries: dict[int, str] = {}
last = None last = None
for line in text.splitlines(): for line in text.splitlines():
@@ -364,46 +343,30 @@ def _parse_auswahl(text: str) -> dict[int, str]:
return entries return entries
def _majority(mappings: list[dict[int, str]], entries: dict[int, str]) -> tuple[dict[int, str], list[int]]: def _parse_kategorien(text: str) -> dict[str, list[str]]:
"""Mehrheitsentscheid über die Einordnungen; ohne Mehrheit → Streitfall.""" """Altformat-Reader: finale Baustein-Datei mit ## KERN/WICHTIG/REST-Abschnitten."""
mapping: dict[int, str] = {} cats: dict[str, list[str]] = {}
disputes: list[int] = [] current = None
for num in entries: for line in text.splitlines():
votes = [m[num] for m in mappings if num in m] s = line.strip()
if not votes: m = re.match(r"#+\s*(KERN|WICHTIG|REST)\b", s, re.IGNORECASE)
disputes.append(num) if m:
current = m.group(1).upper()
cats.setdefault(current, [])
continue continue
cat, count = Counter(votes).most_common(1)[0] m = re.match(r"(\d+)[.)]\s+(.*\S)", s)
if count >= 2: if m and current:
mapping[num] = cat cats[current].append(m.group(2))
else: return cats
disputes.append(num)
return mapping, disputes
def _build_final_bausteine(topic: str, entries: dict[int, str], mapping: dict[int, str], order: dict[str, list[int]] | None = None) -> str: def _lade_bausteine(text: str) -> dict[int, str]:
"""Baut die finale Baustein-Datei aus konsolidierter Liste + finaler Zuordnung. """Lädt die finale Baustein-Datei — sortierte Liste (neu) oder Kategorien (Altformat)."""
if re.search(r"^#+\s*KERN\b", text, re.IGNORECASE | re.MULTILINE):
`order` (Kategorie → Nummern in Lernreihenfolge) sortiert innerhalb der cats = _parse_kategorien(text)
Kategorien; nicht gelistete Nummern hängen in Originalreihenfolge hinten an. texts = [t for cat in _CATEGORIES for t in cats.get(cat, [])]
""" return {i: t for i, t in enumerate(texts, 1)}
grouped: dict[str, list[int]] = {c: [] for c in _CATEGORIES} return _parse_auswahl(text)
for num in sorted(entries):
cat = mapping.get(num)
if cat is None:
_log(topic, f"Baustein {num} fehlt in finaler Einordnung → REST")
cat = "REST"
grouped[cat].append(num)
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 = []
for cat in _CATEGORIES:
lines = "\n".join(f"{i}. {entries[num]}" for i, num in enumerate(grouped[cat], 1))
parts.append(f"## {cat}\n{lines}")
return "\n\n".join(parts) + "\n"
def _file_payload(path: Path): def _file_payload(path: Path):
@@ -442,23 +405,6 @@ def _titel_aufloesen(idx: dict[str, int], t: str) -> int | None:
return idx.get(_norm_titel(t)) or idx.get(_norm_titel(_titel(t))) return idx.get(_norm_titel(t)) or idx.get(_norm_titel(_titel(t)))
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; die JSON-Datei des Agenten ist zugleich der Marker."""
out = _bausteine_files(topic)["sortierung"]
out.unlink(missing_ok=True)
slots = [{
"key": f"bausteine-{topic}-sortierung-1",
"prompt": _prompt("Bausteine-Sortierung", topic=topic, einordnung=_kategorien_block(mapping, entries), out_path=out),
"role": "quick", "capabilities": "files",
"payload": (lambda result: _resolve_reihenfolge(_json_datei(out), entries)),
}]
res = await _race(topic, "Sortierung", slots, 1, _timeout("sortierung", len(entries)), provider, cancelled=cancelled)
if res is None:
out.unlink(missing_ok=True)
return None
return res[0]
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
@@ -482,31 +428,8 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
try: try:
async with _semaphore: async with _semaphore:
# Fertig, aber ohne Sortier-Marker (ältere Version): nur die Sortierung nachholen. files["arbeit"].mkdir(parents=True, exist_ok=True)
if final_path.exists() and not files["sortierung"].exists(): # „Neu erstellen": fertige Bausteine → kompletter Frischstart.
cats = _parse_kategorien(final_path.read_text(encoding="utf-8"))
entries: dict[int, str] = {}
mapping: dict[int, str] = {}
i = 0
for cat in _CATEGORIES:
for text in cats.get(cat, []):
i += 1
entries[i] = text
mapping[i] = cat
entries = _eindeutige_titel(entries)
if entries:
set_p("Sortiere Bausteine…", step=5)
order = await _run_sortierung(topic, entries, mapping, provider, is_cancelled)
if is_cancelled():
abgebrochen()
return
if order is None:
_bausteine_errors[topic] = "Sortierung fehlgeschlagen"
return
final_path.write_text(_build_final_bausteine(topic, entries, mapping, order), encoding="utf-8")
return
# „Neu erstellen": fertige (sortierte) Bausteine → kompletter Frischstart.
# Sonst sind Slot-Dateien Reste eines Abbruchs/Fehlers → Resume. # Sonst sind Slot-Dateien Reste eines Abbruchs/Fehlers → Resume.
if final_path.exists(): if final_path.exists():
for p_alt in _alle_slot_dateien(files): for p_alt in _alle_slot_dateien(files):
@@ -612,103 +535,47 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
entries = _eindeutige_titel(entries) entries = _eindeutige_titel(entries)
bausteine_liste = "\n".join(f"- {t}" for t in entries.values()) bausteine_liste = "\n".join(f"- {t}" for t in entries.values())
# Schritt 3: 4 Einordnungs-Agenten, 3 gültige nötig (JSON-Dateien, Titel-validiert) # Schritt 3: 3 Sortier-Agenten, ALLE nötig — Merge per Median-Rang
n = len(entries) n = len(entries)
einordnungen: list[dict[int, str]] = [] sortierungen: list[list[int]] = []
offen = [] offen = []
for i, path in enumerate(files["einordnung"], 1): for i, path in enumerate(files["sortierung"], 1):
m = _resolve_kategorien(_json_datei(path), entries) liste = _resolve_liste(_json_datei(path), entries)
if m is not None and len(einordnungen) < 3: if liste is not None and len(sortierungen) < 3:
einordnungen.append(m) sortierungen.append(liste)
else: else:
path.unlink(missing_ok=True) path.unlink(missing_ok=True)
offen.append((i, path)) offen.append((i, path))
vorhanden = len(einordnungen) vorhanden = len(sortierungen)
set_p(f"Einordnung läuft ({vorhanden}/3 gültig)…", step=3) set_p(f"Sortierung läuft ({vorhanden}/3 gültig)…", step=3)
if vorhanden < 3: if vorhanden < 3:
slots = [ slots = [
{ {
"key": f"bausteine-{topic}-einordnung-{i}", "key": f"bausteine-{topic}-sortierung-{i}",
"prompt": _prompt("Bausteine-Einordnung", topic=topic, bausteine=bausteine_liste, out_path=path), "prompt": _prompt("Bausteine-Sortierung", topic=topic, bausteine=bausteine_liste, out_path=path),
"role": "quick", "capabilities": "files", "role": "quick", "capabilities": "files",
"payload": (lambda result, p=path: _resolve_kategorien(_json_datei(p), entries)), "payload": (lambda result, p=path: _resolve_liste(_json_datei(p), entries)),
} }
for i, path in offen for i, path in offen
] ]
neue = await _race( neue = await _race(
topic, "Einordnung", slots, 3 - vorhanden, _timeout("einordnung", n), provider, topic, "Sortierung", slots, 3 - vorhanden, _timeout("sortierung", n), provider,
on_update=lambda c: set_p(f"Einordnung läuft ({vorhanden + c}/3 gültig)…"), on_update=lambda c: set_p(f"Sortierung läuft ({vorhanden + c}/3 gültig)…"),
cancelled=is_cancelled, cancelled=is_cancelled,
) )
if is_cancelled(): if is_cancelled():
abgebrochen() abgebrochen()
return return
if neue is None: if neue is None:
_bausteine_errors[topic] = "Einordnung fehlgeschlagen (Quorum nicht erreicht)" _bausteine_errors[topic] = "Sortierung fehlgeschlagen (Quorum nicht erreicht)"
return return
einordnungen += neue sortierungen += neue
# Schritt 4: Python-Mehrheitsentscheid + Verifikations-Agent (antwortet nur mit Deltas, JSON) reihenfolge = _merge_sortierungen(topic, sortierungen, entries)
set_p("Verifiziere Einordnung…", step=4) final_path.write_text(
mapping, disputes = _majority(einordnungen, entries) "\n".join(f"{i}. {entries[num]}" for i, num in enumerate(reihenfolge, 1)) + "\n",
if disputes: encoding="utf-8",
_log(topic, f"Keine Mehrheit bei: {disputes}") )
def _final_schema(data):
if not isinstance(data, dict):
return None
idx = _titel_index(entries)
out: dict[int, str] = {}
for t, cat in data.items():
if not isinstance(t, str) or cat not in _CATEGORIES:
return None
num = _titel_aufloesen(idx, t)
if num is not None:
out[num] = cat
return out # leeres Dict = alles bestätigt
fc_path = files["final_check"]
overrides = _final_schema(_json_datei(fc_path))
if overrides is None:
fc_path.unlink(missing_ok=True)
streit_block = "\n".join(f"- {entries[n]}" for n in disputes) or "(keine)"
slots = [{
"key": f"bausteine-{topic}-final-1",
"prompt": _prompt(
"Bausteine-Einordnung-Final",
topic=topic, einordnung=_kategorien_block(mapping, entries),
streitfaelle=streit_block, out_path=fc_path,
),
"role": "fast", "capabilities": "files",
"payload": (lambda result: _final_schema(_json_datei(fc_path))),
}]
finals = await _race(topic, "Final", slots, 1, _timeout("final", n), provider, cancelled=is_cancelled)
if is_cancelled():
abgebrochen()
return
if finals is None:
_log(topic, "Final-Verifikation fehlgeschlagen — Mehrheitsentscheid bleibt unverändert")
overrides = {}
else:
overrides = finals[0]
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: { {_titel(entries[n]): c for n, c in korrekturen.items()} }")
mapping.update(overrides)
for num in disputes:
if num not in mapping:
_log(topic, f"Streitfall '{_titel(entries[num])}' unentschieden → REST")
mapping[num] = "REST"
# 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
if order is None:
_log(topic, "Sortierung fehlgeschlagen — Originalreihenfolge bleibt (Nachholen über ▶)")
final_path.write_text(_build_final_bausteine(topic, entries, mapping, order), 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:
@@ -720,34 +587,10 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
# --- Guide-Generierung: Bausteine → (Plan) → Writer → JSON --- # --- Guide-Generierung: Bausteine → (Plan) → Writer → JSON ---
# Welche Baustein-Kategorien jedes Format abdeckt.
FORMAT_COVERAGE = {
"MiniGuide": ("KERN",),
"Guide": ("KERN", "WICHTIG"),
"FullGuide": ("KERN", "WICHTIG", "REST"),
}
# Parallele Writer pro Format (OnePager hat einen eigenen Weg). # Parallele Writer pro Format (OnePager hat einen eigenen Weg).
WRITER_COUNT = {"MiniGuide": 1, "Guide": 2, "FullGuide": 4} WRITER_COUNT = {"MiniGuide": 1, "Guide": 2, "FullGuide": 4}
def _parse_kategorien(text: str) -> dict[str, list[str]]:
"""Parst die finale Baustein-Datei (## KERN/WICHTIG/REST mit nummerierten Einträgen)."""
cats: dict[str, list[str]] = {}
current = None
for line in text.splitlines():
s = line.strip()
m = re.match(r"#+\s*(KERN|WICHTIG|REST)\b", s, re.IGNORECASE)
if m:
current = m.group(1).upper()
cats.setdefault(current, [])
continue
m = re.match(r"(\d+)[.)]\s+(.*\S)", s)
if m and current:
cats[current].append(m.group(2))
return cats
def _resolve_gliederung(data, entries: dict[int, str]) -> list[dict] | None: def _resolve_gliederung(data, entries: dict[int, str]) -> list[dict] | None:
"""{"kapitel": [{"titel", "bausteine": [Titel]}]} → [{"title", "nums"}]; None bei Schema-/Titel-Fehlern.""" """{"kapitel": [{"titel", "bausteine": [Titel]}]} → [{"title", "nums"}]; None bei Schema-/Titel-Fehlern."""
if not isinstance(data, dict) or not isinstance(data.get("kapitel"), list): if not isinstance(data, dict) or not isinstance(data.get("kapitel"), list):
@@ -1046,6 +889,7 @@ async def generate_guide(guide_id: str, topic: str, format_name: str, instructio
await update_guide(guide_id, status="generating", progress="Starte…", 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)
content_path.parent.mkdir(parents=True, exist_ok=True)
project = project_dir(topic) if project_dir(topic).is_dir() else None project = project_dir(topic) if project_dir(topic).is_dir() else None
fragment_paths: list[Path] = [] fragment_paths: list[Path] = []
@@ -1056,13 +900,13 @@ async def generate_guide(guide_id: str, topic: str, format_name: str, instructio
if format_name == "OnePager": if format_name == "OnePager":
chapters = await _generate_onepager(guide_id, topic, instructions, provider, project, content_path, fragment_paths) 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")) alle = _lade_bausteine(bausteine_path(topic).read_text(encoding="utf-8"))
selected: list[str] = [] if not alle:
for cat in FORMAT_COVERAGE[format_name]: await _fail(guide_id, "Keine Bausteine gefunden")
selected.extend(cats.get(cat, []))
if not selected:
await _fail(guide_id, "Keine passenden Bausteine gefunden")
return return
anteil, minimum = FORMAT_ANTEIL[format_name]
k = min(len(alle), max(minimum, math.ceil(anteil * len(alle))))
selected = [text for _, text in sorted(alle.items())][:k]
entries = _eindeutige_titel({i: text for i, text in enumerate(selected, 1)}) entries = _eindeutige_titel({i: text for i, text in enumerate(selected, 1)})
facts = _prompt("Guide-Fakten-Projekt", project=project) if project else _prompt("Guide-Fakten-Thema") facts = _prompt("Guide-Fakten-Projekt", project=project) if project else _prompt("Guide-Fakten-Thema")
chapters = await _generate_sections( chapters = await _generate_sections(

View File

@@ -10,8 +10,7 @@ from routes import router
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
(STORAGE_DIR / "guides").mkdir(parents=True, exist_ok=True) (STORAGE_DIR / "themen").mkdir(parents=True, exist_ok=True)
(STORAGE_DIR / "bausteine").mkdir(parents=True, exist_ok=True)
await init_db() await init_db()
yield yield
await close_db() await close_db()

View File

@@ -8,7 +8,7 @@ FormatType = Literal[
"FullGuide", "FullGuide",
] ]
ProviderType = Literal["claude", "minimax"] ProviderType = Literal["claude", "minimax", "lokal"]
class GuideCreateRequest(BaseModel): class GuideCreateRequest(BaseModel):

View File

@@ -1,30 +1,35 @@
import re
from pathlib import Path from pathlib import Path
from config import STORAGE_DIR, PROJECTS_DIR from config import STORAGE_DIR, PROJECTS_DIR
THEMEN_DIR = STORAGE_DIR / "themen"
def _safe(name: str) -> str: def _safe(name: str) -> str:
return name.replace("/", "_").replace("\x00", "") return name.replace("/", "_").replace("\x00", "")
def guide_content_path(topic: str, format_name: str) -> Path: def topic_dir(topic: str) -> Path:
return STORAGE_DIR / "guides" / f"{_safe(topic)} - {format_name}.json" return THEMEN_DIR / _safe(topic)
def arbeit_dir(topic: str) -> Path:
return topic_dir(topic) / "arbeit"
def bausteine_path(topic: str) -> Path: def bausteine_path(topic: str) -> Path:
return STORAGE_DIR / "bausteine" / f"{_safe(topic)}.md" return topic_dir(topic) / "bausteine.md"
def guide_content_path(topic: str, format_name: str) -> Path:
return topic_dir(topic) / "guides" / f"{format_name}.json"
def bausteine_topics() -> list[str]: def bausteine_topics() -> list[str]:
"""Themen, für die eine finale Baustein-Datei existiert (ohne Zwischendateien).""" """Themen, für die ein Themen-Ordner existiert."""
bdir = STORAGE_DIR / "bausteine" if not THEMEN_DIR.is_dir():
if not bdir.is_dir():
return [] return []
return [ return [d.name for d in THEMEN_DIR.iterdir() if d.is_dir()]
p.stem for p in bdir.glob("*.md")
if not re.search(r"\.(recherche-\d+|auswahl(-\d+|-check)?|einordnung-\d+|final-check|sortierung)$", p.stem)
]
def project_dir(name: str) -> Path: def project_dir(name: str) -> Path:

View File

@@ -24,7 +24,7 @@ from models import (
GuideChatRequest, GuideChatResponse, GuideChatRequest, GuideChatResponse,
ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo, ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo,
) )
from paths import bausteine_path, bausteine_topics, guide_content_path, project_dir from paths import bausteine_path, bausteine_topics, guide_content_path, project_dir, topic_dir
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
@@ -54,6 +54,7 @@ async def add_topic(req: TopicCreateRequest):
@router.delete("/topics") @router.delete("/topics")
async def remove_topic(topic: str): async def remove_topic(topic: str):
await delete_topic(topic) await delete_topic(topic)
shutil.rmtree(topic_dir(topic), ignore_errors=True)
return {"ok": True} return {"ok": True}

View File

@@ -10,6 +10,21 @@
"name": "MiniMax M3" "name": "MiniMax M3"
} }
} }
},
"ollama": {
"npm": "@ai-sdk/openai-compatible",
"name": "Ollama (lokal)",
"options": {
"baseURL": "http://localhost:11434/v1"
},
"models": {
"qwen3:14b": {
"name": "Qwen3 14B"
},
"qwen3:8b": {
"name": "Qwen3 8B"
}
}
} }
}, },
"mcp": { "mcp": {
@@ -20,6 +35,13 @@
"MINIMAX_API_KEY": "{env:MINIMAX_API_KEY}", "MINIMAX_API_KEY": "{env:MINIMAX_API_KEY}",
"MINIMAX_API_HOST": "https://api.minimax.io" "MINIMAX_API_HOST": "https://api.minimax.io"
} }
},
"searxng": {
"type": "local",
"command": ["npx", "-y", "mcp-searxng"],
"environment": {
"SEARXNG_URL": "http://localhost:8888"
}
} }
}, },
"agent": { "agent": {
@@ -39,7 +61,8 @@
"webfetch": "deny" "webfetch": "deny"
}, },
"tools": { "tools": {
"minimax-search*": false "minimax-search*": false,
"searxng*": false
} }
}, },
"readonly": { "readonly": {
@@ -53,7 +76,8 @@
"write": false, "write": false,
"edit": false, "edit": false,
"bash": false, "bash": false,
"minimax-search*": false "minimax-search*": false,
"searxng*": false
} }
}, },
"text": { "text": {
@@ -70,7 +94,8 @@
"read": false, "read": false,
"glob": false, "glob": false,
"grep": false, "grep": false,
"minimax-search*": false "minimax-search*": false,
"searxng*": false
} }
} }
} }

View File

@@ -23,7 +23,7 @@ function providerAvailable(id) {
return p ? p.available : true return p ? p.available : true
} }
const PROVIDER_LABELS = { claude: 'Claude', minimax: 'MiniMax' } const PROVIDER_LABELS = { claude: 'Claude', minimax: 'MiniMax', lokal: 'Lokal' }
const formats = [ const formats = [
{ key: 'OnePager', label: 'OnePager' }, { key: 'OnePager', label: 'OnePager' },
@@ -34,10 +34,6 @@ 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'
@@ -214,7 +210,7 @@ function confirmDeleteProject(name) {
<template v-if="bausteineState !== 'generating'"> <template v-if="bausteineState !== 'generating'">
<button <button
class="action-btn play" class="action-btn play"
:title="bausteine.partial ? 'Fortsetzen' : bausteineUnsortiert ? 'Sortierung nachholen' : bausteine.ready ? 'Bausteine neu erstellen' : 'Bausteine erstellen'" :title="bausteine.partial ? 'Fortsetzen' : bausteine.ready ? 'Bausteine neu erstellen' : 'Bausteine erstellen'"
@click="handleBausteinePlay" @click="handleBausteinePlay"
></button> ></button>
<button <button

View File

@@ -1,23 +0,0 @@
Die Bausteine des Themas "{topic}" wurden per Mehrheitsentscheid aus drei unabhängigen Einordnungen in KERN/WICHTIG/REST kategorisiert. Verifiziere das Ergebnis.
MEHRHEITS-EINORDNUNG:
{einordnung}
STREITFÄLLE (keine Mehrheit gefunden — diese MUSST du einordnen):
{streitfaelle}
Kategorien:
- KERN: ohne diese Bausteine kann man das Thema nicht verstehen oder benutzen
- WICHTIG: in der echten Praxis nötig, aber nicht Teil des Einstiegs
- REST: Spezialfälle, Randthemen, selten Gebrauchtes
Aufgabe:
1. Ordne jeden Streitfall einer Kategorie zu.
2. Prüfe die Mehrheits-Einordnung gegen die Definitionen: nimm NUR die Bausteine auf, deren Kategorie du für falsch hältst.
Nicht aufgenommene Bausteine gelten als bestätigt. Titel EXAKT wie in den Listen verwenden. Keine neuen Bausteine.
Schreibe NUR die JSON-Datei nach: {out_path}
Format (Titel → korrekte Kategorie; nichts zu korrigieren und keine Streitfälle = leeres Objekt):
{{"Exakter Titel": "KERN", "Anderer Titel": "REST"}}

View File

@@ -1,20 +0,0 @@
Ordne die Bausteine des Themas "{topic}" in drei Kategorien ein.
BAUSTEINE:
{bausteine}
Kategorien:
- KERN: ohne diese Bausteine kann man das Thema nicht verstehen oder benutzen
- WICHTIG: in der echten Praxis nötig, aber nicht Teil des Einstiegs
- REST: Spezialfälle, Randthemen, selten Gebrauchtes
Regeln:
- JEDER Baustein landet in GENAU einer Kategorie. Keinen weglassen, keinen erfinden.
- Verwende die Titel EXAKT so, wie sie in der Liste stehen — der Titel identifiziert den Baustein.
- Die Kategoriegrößen folgen aus dem Thema — gleich große Kategorien sind verdächtig.
- Setzt ein Baustein einer Kategorie einen anderen voraus, gehört dieser in dieselbe oder eine höhere Kategorie.
Schreibe NUR die JSON-Datei nach: {out_path}
Format:
{{"KERN": ["Titel", "Titel"], "WICHTIG": ["Titel"], "REST": ["Titel"]}}

View File

@@ -1,14 +1,19 @@
Sortiere die Bausteine des Themas "{topic}" innerhalb ihrer Kategorien in Lernreihenfolge — vom Einfachen/Grundlegenden zum Komplexen/Speziellen. Sortiere die Bausteine des Themas "{topic}" in EINE Gesamtreihenfolge für einen Lern-Guide.
BAUSTEINE: BAUSTEINE:
{einordnung} {bausteine}
Kriterium — Lern-Wichtigkeit:
- Zuerst das Unverzichtbare: ohne diese Bausteine kann man das Thema nicht verstehen oder benutzen.
- Dann das Praxis-Wichtige.
- Ans Ende: Spezialfälle, Randthemen, selten Gebrauchtes.
- Voraussetzungen stehen vor dem, was auf ihnen aufbaut.
Regeln: Regeln:
- Kategorien NICHT verändern, keine Bausteine weglassen, keine erfinden — nur die Reihenfolge innerhalb jeder Kategorie ändern. - ALLE Bausteine genau einmal, keine neuen erfinden.
- Was etwas anderes voraussetzt, kommt nach seinen Voraussetzungen. - Verwende NUR den Titel, EXAKT wie in der Liste (ohne Beschreibung).
- Verwende die Titel EXAKT so, wie sie in den Listen stehen.
Schreibe NUR die JSON-Datei nach: {out_path} Schreibe NUR die JSON-Datei nach: {out_path}
Format (alle Titel jeder Kategorie in der neuen Reihenfolge): Format:
{{"KERN": ["Titel", "Titel"], "WICHTIG": ["Titel"], "REST": ["Titel"]}} {{"reihenfolge": ["Titel", "Titel", "Titel"]}}