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>
466 lines
16 KiB
Python
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(),
|
|
}
|