Ctrl K
ComunidadGuías…

config.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""piSynapse Configuration
Single source of truth for all settings. Reads from .env once at startup.
Every module imports from here instead of reading os.getenv() directly.
"""

import logging
import os
from pathlib import Path

from dotenv import load_dotenv

load_dotenv()

logger = logging.getLogger("piSynapse")

# HuggingFace download timeout (seconds): model-weight fetches must fail fast
# on flaky links instead of stalling chats indefinitely. The embedding layer
# treats load failure as best-effort (keyword fallbacks take over) and retries
# on a cooldown — see embedding.get_model.
os.environ.setdefault("HF_HUB_DOWNLOAD_TIMEOUT", "30")


def get(key: str, default=None):
    """Dynamically read a config value.

    Returns the current module attribute (live after sync_config()) instead of
    an import-time copy, so consumers calling this per-request pick up settings
    changes made from the UI without a server restart.
    """
    import config as _cfg
    return getattr(_cfg, key, default)


# ── API_KEY → user_id resolution ────────────────────────────────────────────
# Single-user: one API_KEY → "default". Multi-user: map each key to a user_id.
_API_KEY_USER_MAP: dict[str, str] = {}


def get_user_id_for_key(api_key: str) -> str:
    """Resolve an API key to a user_id.

    Currently single-user: the configured API_KEY maps to "default".
    For multi-user: populate _API_KEY_USER_MAP (e.g. from a DB table).
    Unknown keys get "default" as fallback (backward-compatible).
    """
    if not api_key:
        return "default"
    if api_key in _API_KEY_USER_MAP:
        return _API_KEY_USER_MAP[api_key]
    import config as _cfg
    configured_key = getattr(_cfg, "API_KEY", "")
    if configured_key and api_key == configured_key:
        return "default"
    return "default"


def _safe_int(key: str, default: int) -> int:
    """Parse an int env var with fallback and logging on invalid values."""
    raw = os.getenv(key)
    if raw is None:
        return default
    try:
        return int(raw)
    except (ValueError, TypeError):
        logger.warning(f"Invalid {key}={raw!r}, using default {default}")
        return default


def _safe_float(key: str, default: float) -> float:
    """Parse a float env var with fallback and logging on invalid values."""
    raw = os.getenv(key)
    if raw is None:
        return default
    try:
        return float(raw)
    except (ValueError, TypeError):
        logger.warning(f"Invalid {key}={raw!r}, using default {default}")
        return default

# -- Paths --
# Resolved against the repo root (not CWD): a different working directory
# must never silently fork a second DB/.env (split-brain). Explicit absolute
# ENV_PATH/DB_PATH env values are honored as-is.
_REPO_ROOT = Path(__file__).resolve().parent
ENV_PATH = Path(os.getenv("ENV_PATH", _REPO_ROOT / ".env"))
DB_PATH = os.getenv("DB_PATH", str(_REPO_ROOT / "assistant.db"))

# -- LLM Backend --
# "ollama" — Ollama server (default, http://localhost:11434)
# "litert" — LiteRT-LM server (http://localhost:9379, OpenAI-compatible)
LLM_BACKEND = os.getenv("LLM_BACKEND", "litert").lower()
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
LITERT_BASE_URL = os.getenv("LITERT_BASE_URL", "http://localhost:9379")
LLM_MODEL = os.getenv("LLM_MODEL", "gemma4-e2b")
# Single source of truth for LLM window defaults: SETTINGS_SCHEMA, _NUMERIC_KEYS,
# install.py's .env template and every inline fallback must match these values.
DEFAULT_LLM_NUM_CTX = 8192
DEFAULT_LLM_MAX_OUTPUT_TOKENS = 4096
LLM_NUM_CTX = _safe_int("LLM_NUM_CTX", DEFAULT_LLM_NUM_CTX)
LLM_NUM_BATCH = _safe_int("LLM_NUM_BATCH", 256)
LLM_TEMPERATURE = _safe_float("LLM_TEMPERATURE", 0.6)
LLM_TOP_P = _safe_float("LLM_TOP_P", 0.85)
LLM_TOP_K = _safe_int("LLM_TOP_K", 40)
LLM_MAX_TOOL_ITERATIONS = _safe_int("LLM_MAX_TOOL_ITERATIONS", 5)
LLM_KEEP_ALIVE = os.getenv("LLM_KEEP_ALIVE", "4h")
LLM_TIMEOUT = _safe_int("LLM_TIMEOUT", 600)
# Idle gap (seconds) allowed between SSE stream chunks before the stream is
# aborted. Protects against the LLM server silently hanging mid-response.
SSE_READ_IDLE_TIMEOUT = _safe_float("SSE_READ_IDLE_TIMEOUT", 300.0)
LLM_REASONING_EFFORT = os.getenv("LLM_REASONING_EFFORT", "medium").strip().lower()
# Cap on generated output tokens per response (litert-lm reads this via
# max_completion_tokens; its own default ~600 was truncating long answers).
LLM_MAX_OUTPUT_TOKENS = _safe_int("LLM_MAX_OUTPUT_TOKENS", DEFAULT_LLM_MAX_OUTPUT_TOKENS)

# -- TTS (Piper) --
TTS_VOICE = os.getenv("TTS_VOICE", "en_US-lessac-medium")

# -- TTS Engine --
# "piper"  — Piper TTS local model (~50MB per voice, fully offline)
#            Pros: consistent quality, no internet needed, privacy-safe
#            Cons: limited voice selection, requires model download
# "browser" — Web Speech API (browser built-in, sends text to cloud)
#            Pros: many voices, no model download, responsive
#            Cons: sends text to Google/Apple servers, quality varies
TTS_ENGINE = os.getenv("TTS_ENGINE", "piper")

# -- STT (Speech-to-Text) --
# "gemma4" — Gemma 4 E2B/E4B native audio via /v1/chat/completions
#           Pros: captures emotion/tone, no extra model download, fully local
#           Cons: slower (~5-15s on Pi), less accurate transcription than Whisper
# "whisper" — faster-whisper (local Whisper C++ port, tiny model ~75MB)
#           Pros: fast (~1-2s), very accurate transcription, lightweight
#           Cons: no emotion/tone, text-only output
STT_ENGINE = os.getenv("STT_ENGINE", "whisper")

# -- Voice Input Behavior --
# "on" — auto-send message after voice transcription (no manual send needed)
# "off" — show transcription in input, user presses send
AUTO_SEND_ON_VOICE = os.getenv("AUTO_SEND_ON_VOICE", "off")
# "on" — auto-speak response when input was voice (uses TTS_ENGINE setting)
# "off" — text-only response even if input was voice
AUTO_TTS_ON_VOICE = os.getenv("AUTO_TTS_ON_VOICE", "off")

LLM_TITLE_ENRICHMENT = os.getenv("LLM_TITLE_ENRICHMENT", "on")

# -- Security --
API_KEY = os.getenv("API_KEY", "")
# Open registration for new users ("on"/"off"). The admin toggles this;
# "off" makes POST /users/register return 403. First-ever user is admin.
REGISTRATION_OPEN = os.getenv("REGISTRATION_OPEN", "on")
CORS_ORIGINS = [o.strip() for o in os.getenv("CORS_ORIGINS", "").split(",") if o.strip()]
# Empty by default: main.py auto-allows this machine's local hostnames/IPs
# and logs a warning. Set TRUSTED_HOSTS explicitly (e.g. your LAN IP or
# hostname) to restrict further — see README.md.
TRUSTED_HOSTS = {h.strip() for h in os.getenv("TRUSTED_HOSTS", "").split(",") if h.strip()}
MEDIA_MAX_MB = _safe_int("MEDIA_MAX_MB", 100)

# -- Personal vs system settings (multi-user) --
# Personal keys live per-user (user_settings table, every user incl. admins);
# everything else is system-wide (.env, admins only). Precedence everywhere:
# user row -> global env value -> code default. Mirrors the Nextcloud /
# Open WebUI split: admins own the building, users own their thermostat.
PERSONAL_KEYS = frozenset({
    "UI_LANGUAGE",
    "ASSISTANT_USER",
    "DEFAULT_CITY",
    "STT_ENGINE",
    "TTS_ENGINE",
    "TTS_VOICE",
    "AUTO_SEND_ON_VOICE",
    "AUTO_TTS_ON_VOICE",
})
# -- Rate limits (requests/min per bucket; raise on bigger hardware) --
RATE_LIMIT_RPM = _safe_int("RATE_LIMIT_RPM", 30)
RATE_LIMIT_SESSION_RPM = _safe_int("RATE_LIMIT_SESSION_RPM", 20)
RATE_LIMIT_PUBLIC_RPM = _safe_int("RATE_LIMIT_PUBLIC_RPM", 120)
# Token budget per user per minute (OWASP LLM10 "Unbounded Consumption").
# RPM alone cannot bound cost: one long-context request can burn orders of
# magnitude more compute than many short ones. Counting is an estimate
# (~3 chars/token) — good enough as a cost/DoS guard, not billing-grade.
RATE_LIMIT_TPM = _safe_int("RATE_LIMIT_TPM", 200000)
# Hard budget for one-shot intent/classification LLM calls (analysis A2):
# they ride the shared client whose LLM_TIMEOUT is 600s — a stalled engine
# must not freeze the chat setup phase for minutes on a 20-token call.
INTENT_LLM_TIMEOUT = _safe_int("INTENT_LLM_TIMEOUT", 10)
# Only trust X-Forwarded-For when running behind a trusted reverse proxy.
# Enabled by default: LAN users must not be able to spoof their IP to bypass rate limits.
TRUST_X_FORWARDED_FOR = os.getenv("TRUST_X_FORWARDED_FOR", "").strip().lower() in ("1", "true", "yes", "on")
# Session cookie Secure flag: enable ONLY behind HTTPS (Caddy/domain setups).
# On plain-HTTP LAN the browser would never send a Secure cookie (lockout).
SESSION_COOKIE_SECURE = os.getenv("SESSION_COOKIE_SECURE", "").strip().lower() in ("1", "true", "yes", "on")
# Per-user credential encryption key (Fernet). Generated by the installer;
# auto-created on first use if missing (old installs).
MAIL_CREDS_KEY = os.getenv("MAIL_CREDS_KEY", "")

# -- Chat --
HISTORY_LIMIT = _safe_int("HISTORY_LIMIT", 12)
MEMORY_LIMIT = _safe_int("MEMORY_LIMIT", 10)
SUMMARY_BATCH_SIZE = _safe_int("SUMMARY_BATCH_SIZE", 5)
SUMMARY_EARLY_TRIGGER = _safe_int("SUMMARY_EARLY_TRIGGER", 6)
INTENT_LLM_FALLBACK = os.getenv("INTENT_LLM_FALLBACK", "off")

# -- Memory --
DEFAULT_USER = os.getenv("ASSISTANT_USER", "default") or "default"
MEMORY_SIMILARITY_THRESHOLD = _safe_float("MEMORY_SIMILARITY_THRESHOLD", 0.68)
# Cosine threshold for the corpus-feeder "conflict" check: when a confirmed/
# corrected trigger's nearest example of a DIFFERENT group scores at or above
# this, the corpus disagrees with the proposed group and the row is routed to
# conflict resolution instead of being added silently. Calibrated against the
# live embedding model + real audit commands (own-group 0.19-0.71 vs cross-group
# 0.16-0.65) and aligned with the intent classifier's best_sim>=0.50 decision
# boundary, so the conflict/LLM-resolution path actually fires on real data.
CONFLICT_COSINE = _safe_float("CONFLICT_COSINE", 0.50)
# Data retention (days). 0 = disabled (keep forever).
CONVERSATION_RETENTION_DAYS = _safe_int("CONVERSATION_RETENTION_DAYS", 0)
MEMORY_RETENTION_DAYS = _safe_int("MEMORY_RETENTION_DAYS", 0)
# Tool-audit DETAIL retention (days): how long per-call rows stay in the DB
# before the daily rollup exports them to CSV and replaces them with one
# summary row. Exported CSVs live independently (see AUDIT_ARCHIVE_*).
AUDIT_RETENTION_DAYS = _safe_int("AUDIT_RETENTION_DAYS", 14)
# Audit ARCHIVE retention (days) for the exported per-day files (csv +
# ndjson.gz + manifest). 0 = keep forever (default — the archive is the only
# full-detail copy left after the rollup; deleting it loses the evidence).
AUDIT_ARCHIVE_RETENTION_DAYS = _safe_int("AUDIT_ARCHIVE_RETENTION_DAYS", 0)
# Error-rate alert threshold (fraction 0..1) over the rolling 24h audit
# window. 0 disables. Fires when total calls >= AUDIT_ALERT_MIN_CALLS.
AUDIT_ERROR_RATE_ALERT = _safe_float("AUDIT_ERROR_RATE_ALERT", 0.2)
# Corpus-feeder automation (data flywheel): spawn corpus_feeder.py as a
# sandboxed subprocess every N hours. 0 = manual-only.
FEEDER_AUTO_INTERVAL_HOURS = _safe_int("FEEDER_AUTO_INTERVAL_HOURS", 168)
EMBED_MODEL = os.getenv("EMBED_MODEL", "sentence-transformers/paraphrase-multilingual-mpnet-base-v2")

# -- Weather --
DEFAULT_CITY = os.getenv("DEFAULT_CITY", "")
WEATHER_TIMEOUT = _safe_int("WEATHER_TIMEOUT", 10)

# -- Nextcloud --
NEXTCLOUD_URL = os.getenv("NEXTCLOUD_URL", "")
NEXTCLOUD_USER = os.getenv("NEXTCLOUD_USER", "")
NEXTCLOUD_PASSWORD = os.getenv("NEXTCLOUD_PASSWORD", "")
NEXTCLOUD_TIMEOUT = _safe_int("NEXTCLOUD_TIMEOUT", 10)

# -- Gmail --
GMAIL_USER = os.getenv("GMAIL_USER", "")
GMAIL_APP_PASSWORD = os.getenv("GMAIL_APP_PASSWORD", "")
IMAP_HOST = os.getenv("IMAP_HOST", "imap.gmail.com")
IMAP_PORT = _safe_int("IMAP_PORT", 993)
SMTP_HOST = os.getenv("SMTP_HOST", "smtp.gmail.com")
SMTP_PORT = _safe_int("SMTP_PORT", 465)
IMAP_TIMEOUT = _safe_int("IMAP_TIMEOUT", 20)
SMTP_TIMEOUT = _safe_int("SMTP_TIMEOUT", 20)

# -- ProtonMail --
# Empty = disabled (opt-in, matching the "privacy by default" philosophy).
MAIL_PROVIDER = os.getenv("MAIL_PROVIDER", "")
PROTON_USER = os.getenv("PROTON_USER", "")
PROTON_PASSWORD = os.getenv("PROTON_PASSWORD", "")
PROTON_IMAP_HOST = os.getenv("PROTON_IMAP_HOST", "localhost")
PROTON_IMAP_PORT = _safe_int("PROTON_IMAP_PORT", 1143)
PROTON_SMTP_HOST = os.getenv("PROTON_SMTP_HOST", "localhost")
PROTON_SMTP_PORT = _safe_int("PROTON_SMTP_PORT", 1025)

# -- Model options per backend --
# LiteRT uses dashes, Ollama uses colons in model names.
# Tried live queries first (litert /v1/models, ollama list), then fallback to these.
LITERT_MODEL_OPTIONS = [
    {"value": "gemma4-e2b", "label": {"tr": "Gemma4 E2B (gorsel, ses, tool calling)",  "en": "Gemma4 E2B (vision, audio, tool calling)"}},
    {"value": "gemma4-e4b", "label": {"tr": "Gemma4 E4B (gorsel, ses, tool calling)",  "en": "Gemma4 E4B (vision, audio, tool calling)"}},
]
OLLAMA_MODEL_OPTIONS = [
    {"value": "gemma4:e2b",      "label": {"tr": "Gemma4 E2B (gorsel, ses, tool calling)",  "en": "Gemma4 E2B (vision, audio, tool calling)"}},
    {"value": "qwen2.5:3b",     "label": {"tr": "Qwen2.5 3B (hizli, tool calling, ~2GB)",  "en": "Qwen2.5 3B (fast, tool calling, ~2GB)"}},
    {"value": "qwen3:4b",        "label": {"tr": "Qwen3 4B (hizli, 119 dil)",              "en": "Qwen3 4B (fast, 119 languages)"}},
]


# Cache for get_llm_model_options — 30s TTL to avoid hammering the backend on every settings load.
_MODEL_OPTIONS_CACHE: dict = {"data": [], "ts": 0.0, "backend": ""}


async def get_llm_model_options(backend: str | None = None) -> dict:
    """Return LLM_MODEL options dict keyed by backend (async).

    Queries the live backend (curl/ollama list) off the event loop via
    to_thread; results cached 30s. Falls back to static lists on failure.
    ``backend`` overrides the current process backend — used when a settings
    update switches LLM_BACKEND and models must be listed for the NEW daemon.
    """
    import asyncio
    target = (backend or os.getenv("LLM_BACKEND") or "litert").strip().lower()
    return await asyncio.to_thread(_query_model_options_sync, target)


def probe_backend(backend: str) -> tuple[bool, str]:
    """Reachability check against a backend's model endpoint (sync, ≤5s).

    Used as a PATCH pre-flight gate: switching LLM_BACKEND to a dead daemon
    must fail loudly INSTEAD of persisting — otherwise every chat 404s.
    Returns (ok, detail). Never raises.
    """
    import subprocess

    backend = (backend or "").strip().lower()
    url = f"{LITERT_BASE_URL}/v1/models" if backend == "litert" else f"{OLLAMA_BASE_URL}/api/tags"
    try:
        r = subprocess.run(
            ["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", "3", url],
            capture_output=True, text=True, timeout=5,
        )
        code = (r.stdout or "").strip()
        if r.returncode == 0 and code and code[0] in ("2", "3"):
            return True, ""
        return False, f"{backend} daemon not reachable at {url} (http {code or 'error'})"
    except Exception:
        return False, f"{backend} daemon not reachable at {url}"


def start_backend(backend: str, wait_s: float = 25.0) -> tuple[bool, str]:
    """Start an LLM backend daemon and wait until it answers. Never raises.

    Only 'litert' (piserve unit) and 'ollama' (ollama unit) are allowed —
    the unit name is a literal, not user input. Used when a switch request
    meets a down daemon and the user confirmed auto-start.
    """
    import subprocess
    import time as _time

    units = {"litert": "piserve", "ollama": "ollama"}
    backend = (backend or "").strip().lower()
    if backend not in units:
        return False, f"unknown backend: {backend}"
    unit = units[backend]
    try:
        subprocess.run(["sudo", "-n", "systemctl", "start", f"{unit}.service"],
                       capture_output=True, text=True, timeout=20)
    except Exception:
        return False, f"cannot start {unit}: sudo systemctl failed (start it manually)"
    deadline = _time.time() + wait_s
    while _time.time() < deadline:
        ok, _ = probe_backend(backend)
        if ok:
            return True, ""
        _time.sleep(1.0)
    return False, f"{unit}.service started but no response within {int(wait_s)}s — check: journalctl -u {unit}"


def _query_model_options_sync(backend: str) -> dict:
    """Blocking backend query — always call via get_llm_model_options()."""
    import time as _time
    now = _time.time()
    if _MODEL_OPTIONS_CACHE["backend"] == backend and (now - _MODEL_OPTIONS_CACHE["ts"]) < 30:
        return _MODEL_OPTIONS_CACHE["data"]

    result: list | None = None
    if backend == "litert":
        try:
            import subprocess
            r = subprocess.run(
                ["curl", "-s", "--max-time", "3", f"{LITERT_BASE_URL}/v1/models"],
                capture_output=True, text=True, timeout=5,
            )
            if r.returncode == 0:
                import json as _json
                data = _json.loads(r.stdout)
                ids = [m["id"] for m in data.get("data", []) if "id" in m]
                if ids:
                    result = [{"value": mid, "label": {"tr": mid, "en": mid}} for mid in ids]
        except Exception:
            pass
        if result is None:
            result = LITERT_MODEL_OPTIONS

    elif backend == "ollama":
        try:
            import subprocess
            r = subprocess.run(
                ["ollama", "list"], capture_output=True, text=True, timeout=10,
            )
            if r.returncode == 0:
                names = []
                for line in r.stdout.strip().split("\n")[1:]:
                    parts = line.split()
                    if parts:
                        names.append(parts[0])
                if names:
                    result = [{"value": n, "label": {"tr": n, "en": n}} for n in names]
        except Exception:
            pass
        if result is None:
            result = OLLAMA_MODEL_OPTIONS

    else:
        result = OLLAMA_MODEL_OPTIONS

    _MODEL_OPTIONS_CACHE["data"] = result
    _MODEL_OPTIONS_CACHE["ts"] = now
    _MODEL_OPTIONS_CACHE["backend"] = backend
    return result


# -- Settings API (for PATCH /config/settings) --
SETTINGS_SCHEMA: dict = {
    "DEFAULT_CITY":        {"type": "str",   "default": "",     "label": {"tr": "Varsayılan Şehir",          "en": "Default City"}},
    "LLM_TEMPERATURE":    {"type": "float", "default": "0.6",  "label": {"tr": "Sıcaklık (Temperature)",     "en": "Temperature"},           "min": 0.0, "max": 2.0, "step": 0.05},
    "LLM_TOP_P":          {"type": "float", "default": "0.85", "label": {"tr": "Top P",                      "en": "Top P"},                  "min": 0.1, "max": 1.0, "step": 0.05},
    "LLM_TOP_K":          {"type": "int",   "default": "40",   "label": {"tr": "Top K",                      "en": "Top K"},                  "min": 1, "max": 200, "step": 1},
    "LLM_NUM_CTX":        {"type": "int",   "default": str(DEFAULT_LLM_NUM_CTX), "label": {"tr": "Bağlam Penceresi (Tokens)",  "en": "Context Window (Tokens)"},"min": 2048, "max": 32768, "step": 512,
        "desc": {"tr": "Modelin toplam token hafızası. Sunucu tavanı modelin kapasitesine göre değişir; geçmiş kırpma bütçesi ve sunucu bağlam ayarı bu değerle belirlenir.", "en": "Total token memory of the model. Server ceiling depends on the model capacity; the history trimming budget and the server context setting follow this value."}},
    "LLM_MAX_OUTPUT_TOKENS": {"type": "int", "default": str(DEFAULT_LLM_MAX_OUTPUT_TOKENS), "label": {"tr": "Maks Çıktı (Tokens)", "en": "Max Output (Tokens)"}, "min": 256, "max": 16384, "step": 256,
        "desc": {"tr": "Asistanın tek yanıttaki maksimum üretim uzunluğu (token). Daha uzun cevaplar için artırabilirsiniz.", "en": "Maximum length of a single assistant reply (tokens). Raise for longer answers."}},
    "HISTORY_LIMIT":      {"type": "int",   "default": "12",   "label": {"tr": "Geçmiş Mesaj Sayısı",        "en": "History Message Limit"},  "min": 4, "max": 50, "step": 1},
    "MEMORY_LIMIT":       {"type": "int",   "default": "10",   "label": {"tr": "Hafıza Kartı Sayısı",        "en": "Memory Card Limit"},      "min": 1, "max": 30, "step": 1},
    "MEMORY_SIMILARITY_THRESHOLD": {"type": "float", "default": "0.68", "label": {"tr": "Bellek Benzerlik Eşiği", "en": "Memory Similarity Threshold"}, "min": 0.1, "max": 0.99, "step": 0.01},
    "CONVERSATION_RETENTION_DAYS": {"type": "int", "default": "0", "label": {"tr": "Sohbet Saklama (Gün, 0=kapalı)", "en": "Chat Retention (days, 0=off)"}, "min": 0, "max": 3650, "step": 1},
    "MEMORY_RETENTION_DAYS":       {"type": "int", "default": "0", "label": {"tr": "Bellek Saklama (Gün, 0=kapalı)", "en": "Memory Retention (days, 0=off)"}, "min": 0, "max": 3650, "step": 1},
    "AUDIT_RETENTION_DAYS":        {"type": "int", "default": "14", "label": {"tr": "Araç Kaydı Detayı Saklama (Gün)", "en": "Tool Audit Detail Retention (days)"}, "min": 1, "max": 3650, "step": 1,
        "desc": {"tr": "Tek tek araç çağrıları bu kadar gün veritabanında detay olarak kalır; sonrası günlük özet satırına sıkıştırılır ve CSV'ye dışa aktarılır. Panodaki trend bu pencereyi gösterir.", "en": "Per-call tool rows stay detailed in the DB for this many days; older ones are compressed into a daily summary row and exported to CSV. The dashboard trend window follows this value."}},
    "AUDIT_ARCHIVE_RETENTION_DAYS": {"type": "int", "default": "0", "label": {"tr": "Araç Kaydı Arşivi Saklama (Gün, 0=sonsuz)", "en": "Tool Audit Archive Retention (days, 0=forever)"}, "min": 0, "max": 3650, "step": 1,
        "desc": {"tr": "Günlük dışa aktarılan dosyalar (csv + ndjson.gz + manifest). Özet sıkıştırıldıktan sonra arşiv tek tam detay kopyasıdır; 0 = asla silinmez.", "en": "Per-day exported files (csv + ndjson.gz + manifest). After the rollup compresses a day, the archive is the only full-detail copy; 0 = never deleted."}},
    "AUDIT_ERROR_RATE_ALERT":      {"type": "float", "default": "0.2", "label": {"tr": "Hata Oranı Uyarı Eşiği (0=kapat)", "en": "Error-Rate Alert Threshold (0=off)"}, "min": 0.0, "max": 1.0, "step": 0.05,
        "desc": {"tr": "Son 24 saatte araç hata oranı bu değeri aşarsa (en az 10 çağrıda) log uyarısı ve panelde kırmızı bant görünür.", "en": "When the 24h tool error rate exceeds this fraction (min 10 calls), a log warning fires and a red banner appears on the dashboard."}},
    "FEEDER_AUTO_INTERVAL_HOURS": {"type": "int", "default": "168", "label": {"tr": "Otomatik Corpus Besleme (Saat, 0=kapalı)", "en": "Auto Corpus Feeder (hours, 0=off)"}, "min": 0, "max": 720, "step": 1,
        "desc": {"tr": "Kullanıcı düzeltme/onay sinyallerini toplayan corpus_feeder.py bu aralıkla ayrı bir süreçte otomatik çalışır (haftalık varsayılan).", "en": "corpus_feeder.py — which mines user correction/confirmation signals into intent examples — runs automatically in a separate process at this interval (weekly by default)."}},
    "SUMMARY_BATCH_SIZE": {"type": "int",   "default": "5",    "label": {"tr": "Özetleme Batch Boyutu",       "en": "Summary Batch Size"},     "min": 2, "max": 20, "step": 1},
    "SUMMARY_EARLY_TRIGGER": {"type": "int", "default": "6",   "label": {"tr": "Erken Özet Tetikleyici",      "en": "Early Summary Trigger"},    "min": 1, "max": 20, "step": 1},
    "LLM_BACKEND":        {"type": "select", "default": "litert", "label": {"tr": "LLM Motoru",                 "en": "LLM Engine"},
    "options": [
        {"value": "litert", "label": {"tr": "LiteRT (yerel, hızlı)", "en": "LiteRT (local, fast)"}},
        {"value": "ollama", "label": {"tr": "Ollama (yerel)", "en": "Ollama (local)"}},
    ]},
    # Read live by messages.get_message() — deliberately NOT restart-required.
    "UI_LANGUAGE":        {"type": "select", "default": "en", "label": {"tr": "Asistan Dili (sistem mesajları)", "en": "Assistant Language (system messages)"}, "options": [
        {"value": "tr", "label": {"tr": "Türkçe",   "en": "Turkish"}},
        {"value": "en", "label": {"tr": "İngilizce", "en": "English"}},
    ]},
    "LLM_MODEL":          {"type": "select", "default": "gemma4-e2b", "label": {"tr": "LLM Model",              "en": "LLM Model"}},
    "LLM_REASONING_EFFORT": {"type": "select", "default": "medium", "label": {"tr": "Düşünce Seviyesi (Gemma4)", "en": "Thinking Level (Gemma4)"}, "options": [
        {"value": "none",     "label": {"tr": "Kapalı (düşünme yok)",   "en": "Off (no thinking)"}},
        {"value": "minimal",  "label": {"tr": "Minimal",                "en": "Minimal"}},
        {"value": "low",      "label": {"tr": "Düşük",                  "en": "Low"}},
        {"value": "medium",   "label": {"tr": "Orta (varsayılan)",      "en": "Medium (default)"}},
        {"value": "high",     "label": {"tr": "Yüksek",                 "en": "High"}},
        {"value": "xhigh",    "label": {"tr": "En Yüksek (yavaş)",      "en": "X-High (slow)"}},
    ]},
    "LLM_KEEP_ALIVE":     {"type": "str",   "default": "4h",   "label": {"tr": "Model Saklama Süresi",       "en": "Model Keep Alive"}},
    "ASSISTANT_USER":     {"type": "str",   "default": "",     "label": {"tr": "Kullanıcı Adı",              "en": "Username"}},
    "MAIL_PROVIDER":      {"type": "select", "default": "", "label": {"tr": "E-posta Sağlayıcı", "en": "Mail Provider"}, "options": [
        {"value": "", "label": {"tr": "Kapalı (e-posta yok)", "en": "Off (no email)"}},
        {"value": "gmail", "label": {"tr": "Gmail", "en": "Gmail"}},
        {"value": "proton", "label": {"tr": "ProtonMail (ProtonBridge)", "en": "ProtonMail (ProtonBridge)"}},
        {"value": "auto", "label": {"tr": "Otomatik (Proton varsa onu kullan)", "en": "Auto (Proton if available)"}},
    ]},
    "TTS_VOICE":           {"type": "select", "default": "en_US-lessac-medium", "label": {"tr": "Ses (Piper TTS)",  "en": "Voice (Piper TTS)"}, "options": [
        {"value": "tr_TR-dfki-medium",      "label": {"tr": "Türkçe — Erkek (DFKI)",       "en": "Turkish — Male (DFKI)"}},
        {"value": "en_US-lessac-medium",     "label": {"tr": "İngilizce — Erkek (Lessac)",  "en": "English — Male (Lessac)"}},
        {"value": "en_US-amy-medium",        "label": {"tr": "İngilizce — Kadın (Amy)",     "en": "English — Female (Amy)"}},
    ]},
    "TTS_ENGINE":          {"type": "select", "default": "piper", "label": {"tr": "Ses Motoru (TTS)", "en": "Speech Engine (TTS)"}, "options": [
        {"value": "piper",   "label": {"tr": "Piper (local, offline)",           "en": "Piper (local, offline)"}},
        {"value": "browser", "label": {"tr": "Tarayıcı (online, daha birçok ses)","en": "Browser (online, more voices)"}},
    ]},
    "STT_ENGINE":          {"type": "select", "default": "whisper", "label": {"tr": "Ses Motoru (STT)", "en": "Speech Engine (STT)"}, "options": [
        {"value": "gemma4",  "label": {"tr": "Gemma4 Native (duygu: metin içeriğinden)",  "en": "Gemma4 Native (emotion from text content)"}},
        {"value": "whisper", "label": {"tr": "Whisper (hızlı, tam transkribe)",    "en": "Whisper (fast, full transcription)"}},
        {"value": "browser", "label": {"tr": "Tarayıcı (Web Speech API)",          "en": "Browser (Web Speech API)"}},
    ]},
    "AUTO_SEND_ON_VOICE":  {"type": "select", "default": "off", "label": {"tr": "Sesle Giri\u015fte Otomatik G\u00f6nder", "en": "Auto-Send on Voice Input"}, "options": [
        {"value": "off",  "label": {"tr": "Kapal\u0131", "en": "Off"}},
        {"value": "on",   "label": {"tr": "A\u00e7\u0131k (transkripsiyonu otomatik g\u00f6nder)", "en": "On (auto-send transcription)"}},
    ]},
    "AUTO_TTS_ON_VOICE":   {"type": "select", "default": "off", "label": {"tr": "Sesli Giri\u015fte Sesli Yan\u0131t", "en": "Auto-TTS on Voice Input"}, "options": [
        {"value": "off",  "label": {"tr": "Kapal\u0131", "en": "Off"}},
        {"value": "on",   "label": {"tr": "A\u00e7\u0131k (sesli giri\u015fse sesli yan\u0131t ver)", "en": "On (speak response if voice input)"}},
    ]},
    "LLM_TITLE_ENRICHMENT": {"type": "select", "default": "on", "label": {"tr": "Akıllı Başlık Oluşturma", "en": "Smart Title Generation"}, "options": [
        {"value": "on",   "label": {"tr": "Açık (anlık + arka planda LLM başlık)", "en": "On (instant + background LLM title)"}},
        {"value": "off",  "label": {"tr": "Kapalı (sadece anlık başlık)", "en": "Off (instant title only)"}},
    ]},
    "REGISTRATION_OPEN": {"type": "select", "default": "on", "label": {"tr": "Yeni Kayıtlar", "en": "Open Registration"}, "options": [
        {"value": "on",   "label": {"tr": "Açık (herkes kaydolabilir)", "en": "On (anyone can register)"}},
        {"value": "off",  "label": {"tr": "Kapalı (yalnızca davetliler)", "en": "Off (invite-only)"}},
    ]},
    "INTENT_LLM_FALLBACK": {"type": "select", "default": "off", "label": {"tr": "Niyet Tespiti LLM Kullan\u0131m\u0131", "en": "Intent LLM Fallback"}, "options": [
        {"value": "off",  "label": {"tr": "Kapal\u0131 (h\u0131zl\u0131, embedding+keywords yeterli)", "en": "Off (fast, embedding+keywords)"}},
        {"value": "on",   "label": {"tr": "A\u00e7\u0131k (daha do\u011fru ama yan\u0131t +15sn gecikir)", "en": "On (more accurate, but +15s delay)"}},
    ]},
    "NEXTCLOUD_URL":      {"type": "str",   "default": "", "label": {"tr": "Nextcloud Sunucu", "en": "Nextcloud Server"},
        "desc": {"tr": "Sunucu ayarlarından telefona pull edilen Nextcloud adresi.", "en": "Nextcloud address pulled to the phone from the server settings."}},
    "NEXTCLOUD_USER":     {"type": "str",   "default": "", "label": {"tr": "Nextcloud Kullanıcı", "en": "Nextcloud User"}},
    "NEXTCLOUD_PASSWORD": {"type": "str",   "default": "", "label": {"tr": "Nextcloud Parola", "en": "Nextcloud Password"},
        "desc": {"tr": "Sunucudaki parola yalnız telefona okunur, buradan değiştirilemez.", "en": "Password from the server is read-only here, not editable from here."}},
}

# Settings that require a server restart to take effect. Only keys settable
# via PATCH /config/settings belong here — LLM_NUM_BATCH is not exposed in
# SETTINGS_SCHEMA, so it could never be marked restart-required here.
RESTART_REQUIRED_KEYS = {"LLM_BACKEND"}

# Settings that must NEVER be changed via the API (security-sensitive).
# LLM_BACKEND is intentionally NOT here: switching engines via PATCH
# /config/settings triggers model auto-mapping (routers/config.py) so the
# stored LLM_MODEL always matches the active daemon's registry format.
PROTECTED_SETTINGS = {"OLLAMA_BASE_URL", "LITERT_BASE_URL", "API_KEY", "CORS_ORIGINS", "TRUSTED_HOSTS", "TRUST_X_FORWARDED_FOR", "MEDIA_MAX_MB", "NEXTCLOUD_URL", "NEXTCLOUD_USER", "NEXTCLOUD_PASSWORD"}

# All integer/float config keys that should be re-synced after .env updates
_NUMERIC_KEYS = {
    "LLM_NUM_CTX": (int, DEFAULT_LLM_NUM_CTX), "LLM_NUM_BATCH": (int, 256),
    "LLM_TEMPERATURE": (float, 0.6), "LLM_TOP_P": (float, 0.85), "LLM_TOP_K": (int, 40),
    "LLM_MAX_TOOL_ITERATIONS": (int, 5), "LLM_TIMEOUT": (int, 600),
    "LLM_MAX_OUTPUT_TOKENS": (int, DEFAULT_LLM_MAX_OUTPUT_TOKENS),
    "SSE_READ_IDLE_TIMEOUT": (float, 300.0),
    "HISTORY_LIMIT": (int, 12), "MEMORY_LIMIT": (int, 10),
    "SUMMARY_BATCH_SIZE": (int, 5), "SUMMARY_EARLY_TRIGGER": (int, 6),
    "WEATHER_TIMEOUT": (int, 10), "NEXTCLOUD_TIMEOUT": (int, 10),
    "IMAP_PORT": (int, 993), "SMTP_PORT": (int, 465),
    "IMAP_TIMEOUT": (int, 20), "SMTP_TIMEOUT": (int, 20),
    "PROTON_IMAP_PORT": (int, 1143), "PROTON_SMTP_PORT": (int, 1025),
    "MEMORY_SIMILARITY_THRESHOLD": (float, 0.68),
    "CONFLICT_COSINE": (float, 0.50),
    "CONVERSATION_RETENTION_DAYS": (int, 0),
    "MEMORY_RETENTION_DAYS": (int, 0),
    "AUDIT_RETENTION_DAYS": (int, 14),
    "AUDIT_ARCHIVE_RETENTION_DAYS": (int, 0),
    "AUDIT_ERROR_RATE_ALERT": (float, 0.2),
    "FEEDER_AUTO_INTERVAL_HOURS": (int, 168),
    "MEDIA_MAX_MB": (int, 100),
    "RATE_LIMIT_RPM": (int, 30),
    "RATE_LIMIT_SESSION_RPM": (int, 20),
    "RATE_LIMIT_PUBLIC_RPM": (int, 120),
    "RATE_LIMIT_TPM": (int, 200000),
    "INTENT_LLM_TIMEOUT": (int, 10),
}


def sync_config():
    """Re-read env vars into module-level globals after a .env PATCH.

    Called by the settings API after writing new values to .env so that
    running process picks up changes without a restart.
    """
    import config as _cfg
    for key, (converter, default) in _NUMERIC_KEYS.items():
        raw = os.getenv(key)
        if raw is not None:
            try:
                setattr(_cfg, key, converter(raw))
            except (ValueError, TypeError):
                logger.warning(f"sync_config: invalid {key}={raw!r}, keeping current value")

    # Sync string settings that can change at runtime (config var -> env key)
    for var, env_key in (
        ("LLM_BACKEND", "LLM_BACKEND"),
        ("LLM_MODEL", "LLM_MODEL"),
        ("LLM_KEEP_ALIVE", "LLM_KEEP_ALIVE"),
        ("LLM_REASONING_EFFORT", "LLM_REASONING_EFFORT"),
        ("STT_ENGINE", "STT_ENGINE"),
        ("TTS_ENGINE", "TTS_ENGINE"),
        ("TTS_VOICE", "TTS_VOICE"),
        ("AUTO_SEND_ON_VOICE", "AUTO_SEND_ON_VOICE"),
        ("AUTO_TTS_ON_VOICE", "AUTO_TTS_ON_VOICE"),
        ("INTENT_LLM_FALLBACK", "INTENT_LLM_FALLBACK"),
        ("LLM_TITLE_ENRICHMENT", "LLM_TITLE_ENRICHMENT"),
        ("REGISTRATION_OPEN", "REGISTRATION_OPEN"),
        ("UI_LANGUAGE", "UI_LANGUAGE"),
        ("DEFAULT_CITY", "DEFAULT_CITY"),
        ("DEFAULT_USER", "ASSISTANT_USER"),
        ("MAIL_PROVIDER", "MAIL_PROVIDER"),
    ):
        raw = os.getenv(env_key)
        if raw is not None:
            setattr(_cfg, var, raw)