2026-08-25 13:57:23 +02:00
""" Admin-configurable generate/detect endpoints. Keys stay in env, never in the DB. """
from __future__ import annotations
import json
import os
2026-09-08 12:48:25 +02:00
import uuid
2026-08-25 13:57:23 +02:00
from pathlib import Path
from urllib . parse import urlparse
from db import get_db
2026-09-09 08:34:43 +02:00
from detect_learning import get_detect_operating_mode
2026-09-08 10:02:28 +02:00
from env_loader import (
allows_remote_plaintext_detect ,
remote_plaintext_detect_reason ,
runtime_env ,
upsert_env_value ,
)
2026-08-25 13:57:23 +02:00
SEED_PATH = Path ( __file__ ) . resolve ( ) . parent / " config " / " providers.seed.json "
ROLES = ( " generate " , " detect " )
2026-09-08 12:48:25 +02:00
OLLAMA_LAN_ID = " llm-ollama-lan "
OLLAMA_LAN_URL = " http://192.168.2.144:11434/v1/chat/completions "
2026-08-25 13:57:23 +02:00
_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 " ,
2026-09-08 12:48:25 +02:00
" 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. " ,
2026-08-25 13:57:23 +02:00
} ,
}
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
2026-09-08 12:48:25 +02:00
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 " " ,
}
2026-08-25 13:57:23 +02:00
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 ,
) ,
)
2026-09-08 12:48:25 +02:00
seed_llm_profiles ( conn )
def seed_llm_profiles ( conn ) - > None :
""" Named presets. Existing rows are not overwritten. Keys never stored. """
2026-09-08 12:56:22 +02:00
ollama = conn . execute ( " SELECT * FROM llm_profiles WHERE id = ? " , ( OLLAMA_LAN_ID , ) ) . fetchone ( )
2026-09-08 12:48:25 +02:00
if not ollama :
conn . execute (
"""
INSERT INTO llm_profiles ( id , title , name , url , model , zdr , no_train )
VALUES ( ? , ? , ? , ? , ? , 1 , 1 )
""" ,
2026-09-08 12:56:22 +02:00
( 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 ) ,
2026-09-08 12:48:25 +02:00
)
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 { }
2026-08-25 13:57:23 +02:00
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
2026-09-08 12:48:25 +02:00
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 :
2026-08-25 13:57:23 +02:00
if role not in ROLES :
raise SettingsError ( " unknown_provider_role " , " Unbekannte Provider-Rolle. " )
2026-09-08 12:48:25 +02:00
url = _validate_url ( url )
2026-08-25 13:57:23 +02:00
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. " )
2026-09-08 12:48:25 +02:00
title = ( save_as_title or " " ) . strip ( )
2026-08-25 13:57:23 +02:00
with get_db ( ) as conn :
2026-09-08 12:48:25 +02:00
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 ,
)
2026-08-25 13:57:23 +02:00
conn . execute (
"""
2026-09-08 12:48:25 +02:00
INSERT INTO provider_settings ( role , name , url , model , zdr , no_train , profile_id , updated )
VALUES ( ? , ? , ? , ? , ? , ? , ? , datetime ( ' now ' ) )
2026-08-25 13:57:23 +02:00
ON CONFLICT ( role ) DO UPDATE SET
name = excluded . name ,
url = excluded . url ,
model = excluded . model ,
zdr = excluded . zdr ,
no_train = excluded . no_train ,
2026-09-08 12:48:25 +02:00
profile_id = excluded . profile_id ,
2026-08-25 13:57:23 +02:00
updated = datetime ( ' now ' )
""" ,
2026-09-08 12:48:25 +02:00
( role , name , url , model , 1 if zdr else 0 , 1 if no_train else 0 , chosen or None ) ,
2026-08-25 13:57:23 +02:00
)
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 :
2026-09-08 12:48:25 +02:00
from providers import detect_provider , generate_provider , is_local_url
2026-08-25 13:57:23 +02:00
row = get_setting ( role ) or { }
config = generate_provider ( ) if role == " generate " else detect_provider ( )
2026-09-08 12:48:25 +02:00
local = is_local_url ( row . get ( " url " ) or " " )
2026-08-25 13:57:23 +02:00
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 ) ) ,
2026-09-08 12:48:25 +02:00
" profile_id " : row . get ( " profile_id " ) or " " ,
2026-08-25 13:57:23 +02:00
" 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 ,
2026-09-08 10:02:28 +02:00
" remote_plaintext_reason " : remote_plaintext_detect_reason ( ) if role == " detect " else None ,
2026-08-25 13:57:23 +02:00
" updated " : row . get ( " updated " ) or " " ,
}
def public_status ( ) - > dict :
return {
" roles " : [ public_role_status ( role ) for role in ROLES ] ,
2026-09-08 12:48:25 +02:00
" profiles " : list_profiles ( ) ,
2026-08-25 13:57:23 +02:00
" runtime_env " : runtime_env ( ) ,
2026-09-08 12:48:25 +02:00
" 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. " ,
2026-09-08 10:02:28 +02:00
" remote_plaintext_reason " : remote_plaintext_detect_reason ( ) ,
" remote_plaintext_allowed " : allows_remote_plaintext_detect ( ) ,
2026-09-08 12:48:25 +02:00
" ollama_url " : OLLAMA_LAN_URL ,
2026-09-09 08:34:43 +02:00
" detect_operating_mode " : get_detect_operating_mode ( ) ,
2026-08-25 13:57:23 +02:00
}