"""Konstanten + .env-Loader. Regel: .env-Datei gewinnt über geerbtes Env (creator2-Lektion 12: --reload-Master pinnte sonst stale Werte).""" import os import re from pathlib import Path ROOT = Path(__file__).resolve().parent.parent def _load_env() -> None: datei = ROOT / ".env" if not datei.exists(): return for zeile in datei.read_text().splitlines(): zeile = zeile.strip() if not zeile or zeile.startswith("#") or "=" not in zeile: continue k, v = zeile.split("=", 1) v = v.split("#", 1)[0].strip() if v: os.environ[k.strip()] = v _load_env() FAKE = bool(os.getenv("CREATOR_FAKE")) LLM_LOG = bool(os.getenv("LLM_LOG")) # 1 = Prompt+Antwort je Call in call_texte DB_PFAD = Path(os.getenv("CREATOR_DB") or ROOT / "storage" / "creator3.db") KORPUS_DIR = ROOT / "storage" / "korpus" SKILLS_DIR = ROOT / "skills" PIPELINE_YAML = ROOT / "pipeline.yaml" TOPIC_NAME_RE = re.compile(r"^[a-zA-Z0-9äöüÄÖÜß][a-zA-Z0-9äöüÄÖÜß ._-]{0,63}$") # ── LLM (R3/R9) — Anbieter/Modell per env überschreibbar, nicht im Code ── LLM_ENDPOINT = os.getenv("LLM_ENDPOINT", "https://api.minimax.io/anthropic/v1/messages") LLM_MODEL = os.getenv("LLM_MODEL", "MiniMax-M3") ROLLEN = { # thinking IMMER explizit ("enabled" gibt 400; Default widersprüchlich dokumentiert) "extraktion": dict(model=LLM_MODEL, thinking="disabled", temperature=0.2), "judge": dict(model=LLM_MODEL, thinking="disabled", temperature=0.1), "writer": dict(model=LLM_MODEL, thinking="adaptive", temperature=0.7), } # Anbieter-spezifische Infra-Marker (MiniMax-Statuscodes) getrennt von generischen INFRA_MARKER = ("429", "rate limit", "rate_limit", "timeout", "timed out", "connection", "network", "overloaded", "unavailable", '"status_code":1002', '"status_code":1041', '"status_code":2045') LLM_MAX_TOKENS = 32_000 MAX_PARALLEL_LLM = int(os.getenv("MAX_PARALLEL_LLM", "12")) # M3: 200 RPM → 8-16 sicher TAKT_SEKUNDEN = float(os.getenv("TAKT_SEKUNDEN", "3")) # 1 Call-Start je Takt; 0 = Breiten-Drossel BUENDEL = {"lang": 1, "mittel": 3, "kurz": 10} # Tasks je Call nach Knoten-Klasse INFRA_MAX_RETRIES = 3 INFRA_BACKOFF_BASE = 8.0 # → 8/16/32 s, mit Jitter (Retry-After unzuverlässig) INHALT_MAX_RESTARTS = 2 DROSSEL_MIN_BREITE = 4 # 429/1002/1041/2045: Breite halbieren, 20 Erfolge = +1 DROSSEL_ERFOLGE_JE_PLUS = 20 HEDGE_NACH_S = int(os.getenv("HEDGE_NACH_S", "90")) # Zwilling nach max(90, timeout/2); 0 = aus RUN_BUDGET_TOKENS = int(os.getenv("RUN_BUDGET_TOKENS", "10000000")) # DEFAULT AN; 0 = aus PREIS_IN, PREIS_OUT = 0.60, 2.40 # $/1M konservativ (R9), real messen # Timeouts stehen als Knoten-Property in pipeline.yaml (graph.timeout_fuer); # hier nur der Default für Knoten ohne Angabe TIMEOUT_DEFAULT = (180, 0) # ── Suche (R2) ── SUCHE_TIMEOUT_S = 10 SUCHE_RETRIES = 1 CIRCUIT_BREAKER_N = 3 WIKI_MAX_PARALLEL = 3 DDGS_PAUSE_S = 1.5 SUCHE_MAX_TREFFER = 8 # ── Laden (R6) ── FETCH_CONNECT_S = 10 FETCH_READ_S = 30 DOMAIN_RATE_S = 1.0 SNAPSHOT_MIN_ZEICHEN = 500 # Privates Lese-Tool, ~20 Seiten/Lauf mit Domain-Bremse — kein robots.txt-Check # mehr (Lektion 101); Browser-UA, sonst sperren Bot-Filter trotzdem BROWSER_UA = ("Mozilla/5.0 (X11; Linux x86_64; rv:128.0) " "Gecko/20100101 Firefox/128.0") URTEIL_AUSZUG_ZEICHEN = 3_000 # Inhalts-Urteil je geladener Quelle # ── Korpus ── RECHERCHE_LENSES = ("ueberblick", "tutorial", "referenz", "praxis") # generisch! QUERIES_JE_LENS = 3 SOLL_CHUNK_CHARS = 40_000 SOLL_PUNKTE_MIN = int(os.getenv("SOLL_PUNKTE_MIN", "5")) SOLL_BAND_FAKTOR = 1.0 # Ziel ≈ √Kandidaten (creator2-Band — Lektion 90) SOLL_BAND_TOLERANZ = 1.5 # mehr als Ziel×1.5 bestätigt → nochmal falten QUELLEN_MAX = int(os.getenv("QUELLEN_MAX", "24")) # harter Korpus-Deckel EXTRAKT_FENSTER = 4_000 # Atome nur ±Fenster um Soll-Belege (belegnah) # Harte Vollständigkeits-Stops (Lektion 103): lieber PAUSE als Mini-Guide SEED_PFLICHT = True # keine Startquelle nützlich geladen → PAUSE QUELLEN_MIN = 5 # weniger geladene nützliche Quellen → PAUSE BEIFANG_MAX_QUOTE = 0.5 # >50 % Atome als Beifang verworfen → PAUSE # ── Inventar ── ABSCHNITT_CHARS = 6_000 # 12k riss das 32k-Output-Cap (creator2 empirisch) READER_JE_ABSCHNITT = 2 # Konsens entsteht über Dedup, nicht Union CHUNK_HALBIER_TIEFE = 2 ANKER_OVERLAP_MERGE = 0.5 FUZZY_MIN_ZEICHEN = 40 FUZZY_FEHLERQUOTE = 0.08 WORT_MIN = 8 WORT_FEHLERQUOTE = 0.25 ANKER_FIX_BATCH = 20 MERGE_PANEL = 2 # Merge nur einstimmig (Falsch-Merge >> Dublette) DEDUP_JACCARD = 0.3 # gemessener Paraphrasen-Floor DEDUP_TITEL_FUZZY = 85 # nur bei ≥2 Inhaltstoken (Subset-100-Falle) DEDUP_PAARE_PRO_CALL = 10 DEDUP_KANDIDATEN_CAP = 400 VERDICHTUNG_ZIEL = 8 # weiches Ziel Atome je Soll-Punkt (kein Cap!) LUECKEN_RUNDEN_MAX = 2 WEB_NACHRECHERCHE_RUNDEN = 1 LUECKE_FENSTER = 6_000 BELEG_FENSTER_VOR, BELEG_FENSTER_NACH = 300, 1200 # ── Struktur ── KAPITEL_SOLL_PUNKTE = 8 BAUSTEIN_MIN_ATOME, BAUSTEIN_MAX_ATOME = 4, 8 ZIELE_CHUNK_ATOME = 30 # ── Guide ── SECTION_WOERTER_PRO_ATOM = (40, 220) # QA-Band = ×1.25 PRUEF_PANEL = 2 KLAERUNG_PANEL = 3 FIX_MAX_VERSUCHE = 3 REDUNDANZ_JACCARD = 0.5 # Satzpaare zwischen Bausteinen (Paraphrasen!) REDUNDANZ_SHINGLE = 5 # gemeinsame 5-Wort-Folge = Kandidat REDUNDANZ_SATZ_MIN_TOKEN = 6 ZIEL_DEDUP_JACCARD = 0.5 # synonyme Lernziele deterministisch mergen ECHO_JACCARD = 0.7 # 1. Satz nach Marker ≈ Atom-Definition = Echo FAKTEN_SIGNATUR_MIN = 2 # geteilte Zahlen+Namen je Satzpaar = Kandidat # ── Engine (R8) ── POLL_SEKUNDEN = 2.0 MAX_VERSUCHE = 2 # Inhalts-Versuche je Task; Infra zählt separat STILLSTAND_N = 2 # identischer Befund-Fingerprint n× → PAUSE # Gate-Runden-Caps stehen als runden_max am Gate-Knoten in pipeline.yaml MAX_PARALLEL_GLOBAL = 24 # ── Sprache: EIN Parameter, alles referenziert ihn (Lektion 106) ── SPRACHE = os.getenv("SPRACHE", "de") SPRACHEN_SUCHE = tuple(os.getenv("SPRACHEN_SUCHE", "de,en").split(",")) STEMMER_NAME = {"de": "german", "en": "english", "fr": "french", "es": "spanish", "it": "italian"} # sonst: kein Stemming NEGATIONEN = {"de": {"nicht", "kein", "keine", "keinen", "keiner", "ohne", "nie", "niemals"}, "en": {"not", "no", "none", "never", "without"}} NEGATIONS_PRAEFIX = {"de": "nicht", "en": "non"} SUCHE_LOCALE = {"de": {"country": "DE", "search_lang": "de", "region": "de-de"}, "en": {"country": "US", "search_lang": "en", "region": "us-en"}} # Ausgabe-Literale, die im Guide landen — eine Stelle, pro Sprache tauschbar TEXTE = { "sammel_titel": "Grundlagen", "sammel_punkt": "Weitere Grundlagen zum Thema", "sammel_ziel": "Der Lernende kann Grundlagen zu '{punkt}' erklären.", "teil": "{titel} (Teil {n})", "abschnitt_verweis": "- {titel} (im Abschnitt '{abschnitt}')", } def user_agent() -> str: mail = os.getenv("KONTAKT_MAIL", "") return f"creator3/0.1 (+mailto:{mail})" if mail else "creator3/0.1"