update
This commit is contained in:
17
Makefile
17
Makefile
@@ -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."
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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?
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(items, list):
|
|
||||||
return None
|
|
||||||
for t in items:
|
|
||||||
if not isinstance(t, str):
|
if not isinstance(t, str):
|
||||||
return None
|
return None
|
||||||
total += 1
|
total += 1
|
||||||
num = _titel_aufloesen(idx, t)
|
num = _titel_aufloesen(idx, t)
|
||||||
if num is None:
|
if num is None:
|
||||||
unknown += 1
|
unknown += 1
|
||||||
elif num not in mapping:
|
elif num not in nums:
|
||||||
mapping[num] = cat
|
nums.append(num)
|
||||||
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(
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ FormatType = Literal[
|
|||||||
"FullGuide",
|
"FullGuide",
|
||||||
]
|
]
|
||||||
|
|
||||||
ProviderType = Literal["claude", "minimax"]
|
ProviderType = Literal["claude", "minimax", "lokal"]
|
||||||
|
|
||||||
|
|
||||||
class GuideCreateRequest(BaseModel):
|
class GuideCreateRequest(BaseModel):
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"}}
|
|
||||||
@@ -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"]}}
|
|
||||||
@@ -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"]}}
|
||||||
Reference in New Issue
Block a user