Kansho/backend/provider_settings.py
Lars fa1c11c33e
Some checks failed
Deploy Development / deploy (push) Successful in 57s
Test Suite / pytest-backend (push) Failing after 2m57s
Test Suite / smoke-dev (push) Successful in 0s
Test Suite / frontend-build (push) Successful in 16s
Add transitional learning detect with in-dialog mask review.
Senses grow from confirmations instead of a word list, so a later local pipeline can decide homonyms on a short passage. Default stays semantic. Local detect waits longer, and the reverse-proxy timeout is documented so Dev does not 504 first.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 08:34:43 +02:00

466 lines
16 KiB
Python

"""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(),
}