Files
creator2/backend/config.py
2026-07-13 06:07:09 +02:00

203 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Zentrale Konfiguration. Tuning-Konstanten leben hier, QA-Messlatten in qa.py
(die Messlatte darf nie Teil eines Suchraums werden)."""
import os
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
TEMPLATES_DIR = PROJECT_ROOT / "templates"
STORAGE_DIR = PROJECT_ROOT / "storage"
TOPICS_DIR = PROJECT_ROOT / "topics" # Uni-Quelldateien: topics/<name>/*.txt|md|pdf
KORPUS_DIR = STORAGE_DIR / "korpus" # Snapshots: korpus/<topic>/q<id>.md
FRONTEND_DIST = PROJECT_ROOT / "frontend" / "dist"
def _load_env(path: Path) -> None:
"""Mini-.env-Loader. Die DATEI gewinnt über geerbtes Env — ein --reload-Master
behält sonst sein Start-Environment und pinnt stille Altwerte über .env-Edits."""
try:
text = path.read_text(encoding="utf-8")
except OSError:
return
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key, value = key.strip(), value.strip().strip('"').strip("'")
if key:
os.environ[key] = value
_load_env(PROJECT_ROOT / ".env")
# NACH _load_env lesen — sonst ist ein in .env gesetztes CREATOR_DB wirkungslos
# (der Loader-Kommentar verspricht „die Datei gewinnt").
DB_PATH = STORAGE_DIR / os.getenv("CREATOR_DB", "creator2.db") # Override für Smoke-Läufe
# ── Topic-Namen ───────────────────────────────────────────────────────────────
import re as _re
# Erstes Zeichen alphanumerisch (kein führendes „-" = rsync/CLI-Option), danach
# nur Wortzeichen/Umlaute/- /_. Kein „.", kein „/" → sicher als Pfadkomponente
# (rmtree, Snapshot-Ordner) UND als Shell-Argument (Transfer-Makefile).
TOPIC_NAME_RE = _re.compile(r"^[A-Za-z0-9äöüÄÖÜß][\wäöüÄÖÜß-]{0,63}$")
def topic_name_ok(name: str) -> bool:
return bool(name) and TOPIC_NAME_RE.match(name) is not None
# ── Nebenläufigkeit ───────────────────────────────────────────────────────────
MAX_CONCURRENT_AGENTS = int(os.getenv("MAX_CONCURRENT_AGENTS", "16")) # CLI-Prozesse (~310 MB RSS)
MAX_CONCURRENT_API_AGENTS = int(os.getenv("MAX_CONCURRENT_API_AGENTS", "28")) # direkte API, ~0 RAM
RAM_MIN_FREE_PCT = int(os.getenv("RAM_MIN_FREE_PCT", "20")) # unter x % frei warten CLI-Spawns
OPENCODE_START_DELAY = float(os.getenv("OPENCODE_START_DELAY", "0.5")) # Session-DB-Kollisionen
# ── Infra-Fehler: nie fail-open ───────────────────────────────────────────────
INFRA_MAX_RETRIES = 3 # 429/Timeout/Netz: Retries pro Call, dann Lauf-PAUSE
INFRA_BACKOFF_BASE = 8.0 # Pause = base · 2^(n-1) → 8/16/32 s
HEDGE_NACH_S = 90 # Slot ohne Ergebnis nach max(dies, timeout/2) → Zwilling; 0 = aus
# ── Budget ────────────────────────────────────────────────────────────────────
RUN_BUDGET_TOKENS = int(os.getenv("RUN_BUDGET_TOKENS", "0")) # in+out pro Lauf; 0 = aus
# ── Ebene 0: Korpus & Soll ────────────────────────────────────────────────────
RECHERCHE_LENSES = ("curriculum", "lehrbuch", "syllabus", "uebungen") # 1 Agent je Lens/Runde
RECHERCHE_RUNDEN_MAX = 3 # Sättigungsloop-Cap (Themen)
SOLL_MIN_BELEGE = 2 # Punkt bestätigt ab so vielen unabhängigen Quellen
SOLL_CHUNK_CHARS = 40_000 # Quelltext pro Soll-Extraktions-Call
# Themenband der Soll-Punkte: k = c·√(Kandidaten), weiches Band [0.7k, 1.3k] (Lektion 80).
# Gemessen (aak): ohne Band faltete der Konsens 254 Atome auf 7 Punkte → Kapitel ohne
# Leitstruktur, Grundlagen mischten sich in Spezialthemen.
SOLL_PUNKTE_PER_SQRT = 1.0
SOLL_PUNKTE_MIN = 5
# ── Ebene 1: Inventar ─────────────────────────────────────────────────────────
ABSCHNITT_CHARS = 12_000 # lost-in-the-middle-Guard
READER_JE_ABSCHNITT = 2 # unabhängige Reader (Konsens entsteht über Dedup, nicht Union)
ANKER_OVERLAP_MERGE = 0.5 # Span-Überlappung ab der zwei Atome automatisch mergen
# Fuzzy-Anker-Stufe 4 (Lektion 83, Hypothes.is-Muster exakt→locker→fuzzy):
FUZZY_MIN_ZEICHEN = 40 # kürzere Zitate nie fuzzy (alnum-gefaltet nicht mehr eindeutig)
FUZZY_FEHLERQUOTE = 0.08 # max. Edit-Distanz als Anteil der Zitatlänge
MERGE_PANEL = 2 # Judges pro Merge-Kandidat — Merge nur einstimmig
MERGE_KANDIDAT_COS = 0.75 # Definitions-Ähnlichkeit ab der ein Paar zum Panel geht
MERGE_KANDIDAT_JACCARD = 0.3 # Schwelle im lexikalischen Fallback (misst viel niedriger als Kosinus)
LUECKEN_RUNDEN_MAX = 2 # gezielte Nachextraktion pro Soll-Lücke
# braucht-Titel, die norm/kern nicht auflösen (Kompositum↔Expansion misst ~0.53 Cosinus,
# Embedding schlägt sie NIE vor — Lektion 28): Substring-Prefilter → 1 Judge (n=1, reversibel).
BRAUCHT_KANDIDATEN = 8 # Kandidaten-Cap je unaufgelöstem braucht-Titel (Lektion 34)
# ── Ebene 2: Artefakte ────────────────────────────────────────────────────────
BEISPIEL_FORMEN = ("text", "mathe", "code", "tabelle") # Worked-Example-Formen
CODE_SPRACHEN = ("python", "javascript") # Syntax-Gate parse-only (Container: python3, node)
ARTEFAKT_CHUNK_ATOME = 8 # Atome pro Generate-Call (ein Soll-Punkt, gestückelt)
VERIFY_PANEL = 2 # unabhängige Prüfer, einstimmig = verifiziert
AUSSAGEN_JE_ATOM = 2 # Prüfungs-PAARE je Atom (je Paar: 1 wahre + 1 falsche Aussage)
# Faktenbasis = Quelltext-FENSTER um den Anker (nicht nur das Zitat): der Inhalt
# eines Verfahrens folgt meist NACH seiner Nennung (Pseudo-Code, Ablauf).
BELEG_FENSTER_VOR = 300 # Zeichen vor dem Anker-Span
BELEG_FENSTER_NACH = 1200 # Zeichen nach dem Anker-Span
# ── Prüfung (Lesemodus: 4er-Panels, exakte Teilmenge ankreuzen) ───────────────
PRUEFUNG_PANEL = 4 # Aussagen je Panel (04 davon wahr)
PRUEFUNG_SCHWELLE = 10 # Saldo (+1 je korrektem Panel, 1 sonst) → Baustein frei
# ── Ebene 3: Struktur ─────────────────────────────────────────────────────────
LEVEL_RANG = {"E": 0, "M": 1, "S": 2} # Durchgänge: Grundgerüst / Standard / Feinheiten
DURCHGANG = { # Leser-Kontext je Durchgang (Templates: {durchgang})
"E": "Grundgerüst, erster Durchgang — der Leser kennt noch nichts,"
" alles voraussetzungsfrei erklären",
"M": "Standardstoff, zweiter Durchgang — der Leser kennt das Grundgerüst",
"S": "Feinheiten, dritter Durchgang — der Leser kennt Grundgerüst und Standardstoff",
}
BAUSTEIN_MIN_ATOME = 4
BAUSTEIN_MAX_ATOME = 8
ZIELE_CHUNK_ATOME = 30 # Atome pro Lernziel-Call
KAPITEL_BAUSTEINE = 5 # Richtwert Bausteine je Kapitel (Kapitel-Schnitt-Judge)
# ── Ebene 4: Guide ────────────────────────────────────────────────────────────
SECTION_WOERTER_PRO_ATOM = (40, 220) # Längenband ausführlicher Teil je Atom
# ── Auto-Loop ─────────────────────────────────────────────────────────────────
LOOP_MAX_ITER = 10
FIX_MAX_VERSUCHE = 3 # Section einfrieren nach so vielen Fixes OHNE Textänderung
AUFTRAG_RUNDEN_MAX = 4 # Auftrag nach so vielen Urteilsrunden ohne Abschluss → eskaliert
# ── Timeouts je Schritt: (Basis-Sekunden, Sekunden pro Item) ─────────────────
TIMEOUTS = {
"recherche": (900, 0),
"soll": (300, 0),
"soll_konsens": (300, 2),
"extraktion": (450, 0),
"merge": (150, 8),
"braucht": (200, 0),
"soll_zuordnung": (300, 8),
"luecke": (450, 0),
"artefakt": (450, 20),
"aussagen_gen": (300, 15),
"aussagen_verify": (200, 10),
"verify": (200, 10),
"fix": (300, 15),
"ziele": (300, 6),
"level": (200, 4),
"kapitel": (200, 2),
"zyklus": (150, 0),
"writer": (450, 60),
"pruefer": (600, 5),
"pruefer_urteil": (300, 5),
"qa_judge": (600, 0),
}
def timeout_fuer(step: str, n: int = 0) -> int:
base, per = TIMEOUTS[step]
return int(base + per * n)
# ── Provider-Stacks (unabhängig; jeder kann jederzeit fehlen) ─────────────────
# Rollen: quick = Massenarbeit (Extraktion), judge = kalte Urteile,
# guide = große Generierung (Writer), fast = Interaktion/Leichtes.
DEFAULT_PROVIDER = os.getenv("DEFAULT_PROVIDER", "")
PROVIDERS = {
"claude": {
"cli": "claude",
"guide": "claude-opus-4-8[1m]",
"fast": "claude-sonnet-4-6",
"judge": "claude-sonnet-4-6",
"quick": "claude-sonnet-4-6",
"env_key": None,
},
"minimax": {
"cli": "opencode",
"guide": "minimax/MiniMax-M3",
"fast": "minimax-kalt/MiniMax-M2.7-highspeed",
# native Route für Judges: der kalt-Endpoint stallte 20 % der Judge-Calls (Vorgänger, 2026-07-04)
"judge": "minimax/MiniMax-M3",
"quick": "minimax-kalt/MiniMax-M2.7-highspeed",
"env_key": "MINIMAX_API_KEY",
},
"lokal": {
"cli": "opencode",
"guide": "ollama/qwen3.6:27b",
"fast": "ollama/qwen3.5:9b",
"judge": "ollama/qwen3.5:9b",
"quick": "ollama/qwen3.5:9b",
"env_key": None,
"check_url": "http://localhost:11434/api/tags",
},
}
ROLE_ROUTING = {r: os.getenv(f"ROLE_{r.upper()}", "") for r in ("quick", "judge", "guide", "fast")}
def resolve_role(run_provider: str, role: str) -> tuple[str, str]:
"""→ (provider, model). Reines Routing; Verfügbarkeits-Fallback macht agents.run_agent."""
target = ROLE_ROUTING.get(role, "") or run_provider
provider, _, model = target.partition(":")
if provider not in PROVIDERS:
provider, model = run_provider, ""
if not model:
model = PROVIDERS.get(provider, {}).get(role, "")
return provider, model