"""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//*.txt|md|pdf KORPUS_DIR = STORAGE_DIR / "korpus" # Snapshots: korpus//q.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 = 6_000 # lost-in-the-middle-Guard; 12k riss bei dichten # tex-Quellen das 32k-Output-Cap (aak: ø 11.7k, Spitzen # 32k → 5-min-Calls, Hedge-Zwillinge, Halbier-Kaskade) 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.65 # Definitions-Ähnlichkeit ab der ein Paar zum Panel geht # (0.75 verpasste echte Dubletten bei cos 0.68–0.71, aak; # Fehlurteile fängt das einstimmige Panel) MERGE_KANDIDAT_JACCARD = 0.3 # Schwelle im lexikalischen Fallback (misst viel niedriger als Kosinus) LUECKEN_RUNDEN_MAX = 2 # gezielte Nachextraktion pro Soll-Lücke # ── 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) NACHFUELL_CAP = 2 # Nachfüllen endet bei verworfen ≥ Cap × Ziel (Anti-Churn) # 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 (0–4 davon wahr) PRUEFUNG_SCHWELLE = 5 # 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 THEMA_SOLL_PUNKTE = 8 # Richtwert Soll-Punkte je Thema (Themen-Schnitt-Judge); # zu klein → Singleton-Kapitel, zu groß → Mammut-Kapitel # ── 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 KLAERUNG_PANEL = 3 # Stichentscheid über eskalierte falsch/luecke: Mehrheit 2/3 # (Lektion 19); läuft je Auftrag genau einmal (auftraege.geklaert) # ── 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), "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), "themen": (300, 2), "kapitel": (200, 2), "ordnung": (300, 2), "klaerung": (300, 5), "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