"""Admin-configurable generate/detect endpoints. Keys stay in env, never in the DB.""" from __future__ import annotations import json import os import uuid from pathlib import Path from urllib.parse import urlparse from db import get_db from detect_learning import get_detect_operating_mode from env_loader import ( allows_remote_plaintext_detect, remote_plaintext_detect_reason, runtime_env, upsert_env_value, ) SEED_PATH = Path(__file__).resolve().parent / "config" / "providers.seed.json" ROLES = ("generate", "detect") OLLAMA_LAN_ID = "llm-ollama-lan" OLLAMA_LAN_URL = "http://192.168.2.144:11434/v1/chat/completions" _KEY_ENV = { "generate": "KANSHO_PROVIDER_KEY", "detect": "KANSHO_DETECT_PROVIDER_KEY", } # Interne Rollen bleiben generate/detect. Fachlich: Sprachmodell vs. Maskierung. ROLE_META = { "generate": { "title": "Sprachmodell", "task": "Dialogzug und Journalentwurf. Nur maskierter Kontext. Wählt die Operation im Dialogzug mit.", }, "detect": { "title": "Maskierung", "task": "Eigener Detect-Endpunkt und eigenes Modell. Vollständige semantische Detection des persönlichen Egress. Ziel: lokales Detect-Modell (Ollama). Übergang: externes Klartext-Detect nur mit Operator-Freigabe. Kein Pattern-Fallback.", }, } class SettingsError(Exception): def __init__(self, code: str, message: str, status_code: int = 400): super().__init__(message) self.code = code self.message = message self.status_code = status_code def _validate_url(url: str) -> str: url = (url or "").strip() if not url: return "" parsed = urlparse(url) if parsed.scheme not in {"http", "https"} or not parsed.netloc: raise SettingsError("provider_url_invalid", "URL muss http(s) sein.") return url def _profile_row(conn, profile_id: str) -> dict | None: row = conn.execute("SELECT * FROM llm_profiles WHERE id = ?", (profile_id,)).fetchone() return dict(row) if row else None def _public_profile(row: dict) -> dict: from providers import is_local_url url = row.get("url") or "" return { "id": row.get("id") or "", "title": row.get("title") or "", "name": row.get("name") or "", "url": url, "model": row.get("model") or "", "zdr": bool(row.get("zdr", 1)), "no_train": bool(row.get("no_train", 1)), "local": is_local_url(url), "updated": row.get("updated") or "", } def seed_provider_settings(conn) -> None: items = json.loads(SEED_PATH.read_text(encoding="utf-8")) for item in items: role = item["role"] if role not in ROLES: continue existing = conn.execute("SELECT role FROM provider_settings WHERE role = ?", (role,)).fetchone() if existing: continue conn.execute( """ INSERT INTO provider_settings (role, name, url, model, zdr, no_train) VALUES (?, ?, ?, ?, ?, ?) """, ( role, item.get("name") or "", item.get("url") or "", item.get("model") or "", 1 if item.get("zdr", True) else 0, 1 if item.get("no_train", True) else 0, ), ) seed_llm_profiles(conn) def seed_llm_profiles(conn) -> None: """Named presets. Existing rows are not overwritten. Keys never stored.""" ollama = conn.execute("SELECT * FROM llm_profiles WHERE id = ?", (OLLAMA_LAN_ID,)).fetchone() if not ollama: conn.execute( """ INSERT INTO llm_profiles (id, title, name, url, model, zdr, no_train) VALUES (?, ?, ?, ?, ?, 1, 1) """, (OLLAMA_LAN_ID, "Ollama (LAN)", "ollama", OLLAMA_LAN_URL, "phi3:mini"), ) elif not (dict(ollama).get("model") or "").strip(): conn.execute( "UPDATE llm_profiles SET model = ?, updated = datetime('now') WHERE id = ?", ("phi3:mini", OLLAMA_LAN_ID), ) for role in ROLES: row = conn.execute("SELECT * FROM provider_settings WHERE role = ?", (role,)).fetchone() if not row: continue data = dict(row) profile_id = (data.get("profile_id") or "").strip() if profile_id and _profile_row(conn, profile_id): continue url = (data.get("url") or "").strip() model = (data.get("model") or "").strip() if not url: continue match = conn.execute( "SELECT id FROM llm_profiles WHERE url = ? AND model = ? ORDER BY created LIMIT 1", (url, model), ).fetchone() if match: chosen = match["id"] else: chosen = f"llm-{role}-current" if _profile_row(conn, chosen): chosen = str(uuid.uuid4()) title = "Sprachmodell (aktuell)" if role == "generate" else "Maskierung (aktuell)" conn.execute( """ INSERT INTO llm_profiles (id, title, name, url, model, zdr, no_train) VALUES (?, ?, ?, ?, ?, ?, ?) """, ( chosen, title, data.get("name") or "", url, model, 1 if data.get("zdr", 1) else 0, 1 if data.get("no_train", 1) else 0, ), ) conn.execute( "UPDATE provider_settings SET profile_id = ? WHERE role = ?", (chosen, role), ) def list_profiles() -> list[dict]: with get_db() as conn: rows = conn.execute("SELECT * FROM llm_profiles ORDER BY title, created").fetchall() return [_public_profile(dict(row)) for row in rows] def get_profile(profile_id: str) -> dict | None: with get_db() as conn: row = _profile_row(conn, profile_id) return _public_profile(row) if row else None def _write_profile( conn, profile_id: str, *, title: str, name: str, url: str, model: str, zdr: bool, no_train: bool, ) -> dict: title = (title or "").strip() if not title: raise SettingsError("profile_title_required", "Ein Profil braucht einen Namen.") url = _validate_url(url) if not url: raise SettingsError("provider_url_required", "Ein Profil braucht eine URL.") model = (model or "").strip() name = (name or "").strip() existing = _profile_row(conn, profile_id) if existing: conn.execute( """ UPDATE llm_profiles SET title = ?, name = ?, url = ?, model = ?, zdr = ?, no_train = ?, updated = datetime('now') WHERE id = ? """, (title, name, url, model, 1 if zdr else 0, 1 if no_train else 0, profile_id), ) else: conn.execute( """ INSERT INTO llm_profiles (id, title, name, url, model, zdr, no_train) VALUES (?, ?, ?, ?, ?, ?, ?) """, (profile_id, title, name, url, model, 1 if zdr else 0, 1 if no_train else 0), ) for role in ROLES: active = conn.execute( "SELECT role FROM provider_settings WHERE role = ? AND profile_id = ?", (role, profile_id), ).fetchone() if not active: continue conn.execute( """ UPDATE provider_settings SET name = ?, url = ?, model = ?, zdr = ?, no_train = ?, updated = datetime('now') WHERE role = ? """, (name, url, model, 1 if zdr else 0, 1 if no_train else 0, role), ) row = _profile_row(conn, profile_id) return _public_profile(row or {}) def create_profile(*, title: str, name: str, url: str, model: str, zdr: bool, no_train: bool) -> dict: profile_id = str(uuid.uuid4()) with get_db() as conn: return _write_profile( conn, profile_id, title=title, name=name, url=url, model=model, zdr=zdr, no_train=no_train, ) def update_profile( profile_id: str, *, title: str, name: str, url: str, model: str, zdr: bool, no_train: bool, ) -> dict: with get_db() as conn: if not _profile_row(conn, profile_id): raise SettingsError("profile_missing", "Profil fehlt.", 404) return _write_profile( conn, profile_id, title=title, name=name, url=url, model=model, zdr=zdr, no_train=no_train, ) def delete_profile(profile_id: str) -> None: with get_db() as conn: if not _profile_row(conn, profile_id): raise SettingsError("profile_missing", "Profil fehlt.", 404) used = conn.execute( "SELECT role FROM provider_settings WHERE profile_id = ?", (profile_id,), ).fetchall() if used: roles = ", ".join(row["role"] for row in used) raise SettingsError( "profile_in_use", f"Profil ist noch der Stufe {roles} zugeordnet. Zuerst ein anderes Profil wählen.", ) conn.execute("DELETE FROM llm_profiles WHERE id = ?", (profile_id,)) def activate_profile(role: str, profile_id: str) -> dict: if role not in ROLES: raise SettingsError("unknown_provider_role", "Unbekannte Provider-Rolle.") with get_db() as conn: row = _profile_row(conn, profile_id) if not row: raise SettingsError("profile_missing", "Profil fehlt.", 404) url = (row.get("url") or "").strip() model = (row.get("model") or "").strip() if role == "generate" and not url: raise SettingsError("provider_url_required", "Das Sprachmodell braucht eine URL.") if role == "detect" and not url: raise SettingsError("provider_url_required", "Die Maskierung braucht eine URL.") if not model: raise SettingsError("provider_model_required", "Das Profil braucht ein Modell.") conn.execute( """ INSERT INTO provider_settings (role, name, url, model, zdr, no_train, profile_id, updated) VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now')) ON CONFLICT(role) DO UPDATE SET name = excluded.name, url = excluded.url, model = excluded.model, zdr = excluded.zdr, no_train = excluded.no_train, profile_id = excluded.profile_id, updated = datetime('now') """, ( role, row.get("name") or "", url, model, 1 if row.get("zdr", 1) else 0, 1 if row.get("no_train", 1) else 0, profile_id, ), ) return get_setting(role) or {} def get_setting(role: str) -> dict | None: if role not in ROLES: return None with get_db() as conn: row = conn.execute("SELECT * FROM provider_settings WHERE role = ?", (role,)).fetchone() return dict(row) if row else None def upsert_setting( role: str, *, name: str, url: str, model: str, zdr: bool, no_train: bool, profile_id: str | None = None, save_as_title: str | None = None, ) -> dict: if role not in ROLES: raise SettingsError("unknown_provider_role", "Unbekannte Provider-Rolle.") url = _validate_url(url) model = (model or "").strip() name = (name or "").strip() if role == "generate" and not url: raise SettingsError("provider_url_required", "Das Sprachmodell braucht eine URL.") if role == "generate" and not model: raise SettingsError("provider_model_required", "Das Sprachmodell braucht ein Modell.") title = (save_as_title or "").strip() with get_db() as conn: current = conn.execute("SELECT * FROM provider_settings WHERE role = ?", (role,)).fetchone() current_id = (dict(current).get("profile_id") if current else "") or "" chosen = (profile_id or "").strip() or current_id if title: chosen = str(uuid.uuid4()) _write_profile( conn, chosen, title=title, name=name, url=url, model=model, zdr=zdr, no_train=no_train, ) elif chosen: existing = _profile_row(conn, chosen) if not existing: raise SettingsError("profile_missing", "Profil fehlt.", 404) _write_profile( conn, chosen, title=existing.get("title") or title or role, name=name, url=url, model=model, zdr=zdr, no_train=no_train, ) conn.execute( """ INSERT INTO provider_settings (role, name, url, model, zdr, no_train, profile_id, updated) VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now')) ON CONFLICT(role) DO UPDATE SET name = excluded.name, url = excluded.url, model = excluded.model, zdr = excluded.zdr, no_train = excluded.no_train, profile_id = excluded.profile_id, updated = datetime('now') """, (role, name, url, model, 1 if zdr else 0, 1 if no_train else 0, chosen or None), ) return get_setting(role) or {} def set_role_key(role: str, key: str) -> None: if role not in ROLES: raise SettingsError("unknown_provider_role", "Unbekannte Provider-Rolle.") value = (key or "").strip() if not value: raise SettingsError("provider_key_empty", "Leerer Key wird nicht gespeichert.") upsert_env_value(_KEY_ENV[role], value) def public_role_status(role: str) -> dict: from providers import detect_provider, generate_provider, is_local_url row = get_setting(role) or {} config = generate_provider() if role == "generate" else detect_provider() local = is_local_url(row.get("url") or "") own_key = bool((os.environ.get(_KEY_ENV[role]) or "").strip()) generate_key = bool((os.environ.get(_KEY_ENV["generate"]) or "").strip()) if own_key: key_source = "own" elif role == "detect" and generate_key and not local: key_source = "generate" else: key_source = "none" meta = ROLE_META.get(role, {}) return { "role": role, "title": meta.get("title") or role, "task": meta.get("task") or "", "name": row.get("name") or (config.name if config else ""), "url": row.get("url") or "", "model": row.get("model") or "", "zdr": bool(row.get("zdr", 1)), "no_train": bool(row.get("no_train", 1)), "profile_id": row.get("profile_id") or "", "local": local, "key_present": key_source != "none", "key_source": key_source, "ready": config is not None, "mode": config.mode if config else "unconfigured", "remote_plaintext_detect": bool(config and not config.local and config.mode == "http" and role == "detect"), "remote_plaintext_allowed": allows_remote_plaintext_detect() if role == "detect" else None, "remote_plaintext_reason": remote_plaintext_detect_reason() if role == "detect" else None, "updated": row.get("updated") or "", } def public_status() -> dict: return { "roles": [public_role_status(role) for role in ROLES], "profiles": list_profiles(), "runtime_env": runtime_env(), "note": "LLM-Profile speichern URL und Modell je Endpunkt. Maskierung und Sprachmodell wählen je ein Profil. Semantische Detection ist Pflicht vor Generate. LAN-Ollama gilt als lokal. Keys nur in .env.", "remote_plaintext_reason": remote_plaintext_detect_reason(), "remote_plaintext_allowed": allows_remote_plaintext_detect(), "ollama_url": OLLAMA_LAN_URL, "detect_operating_mode": get_detect_operating_mode(), }