diff --git a/app.py b/app.py
index 4362325..5514dd2 100644
--- a/app.py
+++ b/app.py
@@ -5,6 +5,7 @@ import email.mime.multipart
import email.mime.text
import json
import os
+import re
import smtplib
import sqlite3
import threading
@@ -19,10 +20,10 @@ from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.responses import HTMLResponse, PlainTextResponse, Response, JSONResponse, RedirectResponse
API_BASE = os.getenv("API_BASE", "http://gx10.aquantico.lan:8093").rstrip("/")
-OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://gx10.aquantico.lan:11434").rstrip("/")
-OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen3.5:9b")
-OLLAMA_NUM_PREDICT = int(os.getenv("OLLAMA_NUM_PREDICT", "16384"))
-OLLAMA_THINK = os.getenv("OLLAMA_THINK", "true").lower() in ("1", "true", "yes")
+# Kept for backward-compat seeding of the initial provider on first start
+_SEED_PROVIDER_URL = os.getenv("OLLAMA_BASE_URL", "http://gx10.aquantico.lan:11434").rstrip("/")
+_SEED_MODEL = os.getenv("OLLAMA_MODEL", "qwen3.5:9b")
+_SEED_NUM_PREDICT = int(os.getenv("OLLAMA_NUM_PREDICT", "16384"))
DB_PATH = os.getenv("DB_PATH", "/data/ui.db")
app = FastAPI(title="Diarization UI")
@@ -156,6 +157,35 @@ def init_db():
except Exception:
pass
+ try:
+ c.execute(
+ """
+ CREATE TABLE IF NOT EXISTS providers (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ base_url TEXT NOT NULL,
+ api_key TEXT,
+ is_default INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL
+ )
+ """
+ )
+ except Exception:
+ pass
+
+ try:
+ c.execute("ALTER TABLE prompts ADD COLUMN provider_id INTEGER REFERENCES providers(id)")
+ except Exception:
+ pass
+
+ # Seed default provider from env if table is empty
+ count = c.execute("SELECT COUNT(*) FROM providers").fetchone()[0]
+ if count == 0:
+ c.execute(
+ "INSERT INTO providers(name, base_url, api_key, is_default, created_at) VALUES (?,?,?,1,?)",
+ ("Ollama (Standard)", _SEED_PROVIDER_URL, None, now_iso()),
+ )
+
try:
c.execute(
"""
@@ -203,6 +233,32 @@ def set_setting(key: str, value: str):
c.execute("INSERT OR REPLACE INTO settings(key, value) VALUES (?,?)", (key, value))
+def _get_default_provider() -> dict | None:
+ with db() as c:
+ row = c.execute("SELECT * FROM providers WHERE is_default=1 LIMIT 1").fetchone()
+ if not row:
+ row = c.execute("SELECT * FROM providers ORDER BY id LIMIT 1").fetchone()
+ return dict(row) if row else None
+
+
+def _get_provider(provider_id: int | None) -> dict | None:
+ if not provider_id:
+ return _get_default_provider()
+ with db() as c:
+ row = c.execute("SELECT * FROM providers WHERE id=?", (provider_id,)).fetchone()
+ return dict(row) if row else _get_default_provider()
+
+
+def _split_thinking(text: str) -> tuple[str, str]:
+ """Split … prefix from the rest of the content."""
+ if text.startswith(""):
+ end = text.find("")
+ if end == -1:
+ return text[7:], ""
+ return text[7:end], text[end + 8:].lstrip("\n")
+ return "", text
+
+
def _send_email(to_addrs: list[str], subject: str, body_md: str, attachment_name: str):
host = get_setting("smtp_host", "")
if not host:
@@ -480,68 +536,98 @@ def _process_analysis_job(job_id: int):
if not (doc["content_md"] or "").strip():
raise RuntimeError("Dokument hat keinen Inhalt – bitte zuerst das Transkript prüfen")
- user_extra = (j.get("user_prompt") or "").strip()
- llm_prompt = (
- "Du bist ein präziser Assistent. Antworte auf Deutsch.\\n"
- f"AUFTRAG:\\n{prm['prompt']}\\n"
- + (f"\\nZUSATZINFOS:\\n{user_extra}\\n" if user_extra else "")
- + f"\\nTEXT:\\n{doc['content_md']}\\n"
- )
-
use_default = int(prm["llm_use_default"] if prm["llm_use_default"] is not None else 1)
if use_default:
- model = get_setting("ollama_model", OLLAMA_MODEL)
- think = get_setting("ollama_think", "true" if OLLAMA_THINK else "false") == "true"
- num_ctx_setting = get_setting("ollama_num_ctx", "auto")
- num_predict = int(get_setting("ollama_num_predict", str(OLLAMA_NUM_PREDICT)))
- repeat_penalty = float(get_setting("ollama_repeat_penalty", "1.15"))
- repeat_last_n = int(get_setting("ollama_repeat_last_n", "128"))
+ provider = _get_default_provider()
+ model = get_setting("llm_model", _SEED_MODEL)
+ num_ctx_setting = get_setting("llm_num_ctx", "auto")
+ num_predict = int(get_setting("llm_num_predict", str(_SEED_NUM_PREDICT)))
+ repeat_penalty = float(get_setting("llm_repeat_penalty", "1.15"))
+ repeat_last_n = int(get_setting("llm_repeat_last_n", "128"))
else:
- model = prm["llm_model"] or get_setting("ollama_model", OLLAMA_MODEL)
- think = (prm["llm_think"] or get_setting("ollama_think", "true" if OLLAMA_THINK else "false")) == "true"
- num_ctx_setting = prm["llm_num_ctx"] or get_setting("ollama_num_ctx", "auto")
- num_predict = int(prm["llm_num_predict"] or get_setting("ollama_num_predict", str(OLLAMA_NUM_PREDICT)))
- repeat_penalty = float(prm["llm_repeat_penalty"] or get_setting("ollama_repeat_penalty", "1.15"))
- repeat_last_n = int(prm["llm_repeat_last_n"] or get_setting("ollama_repeat_last_n", "128"))
- num_ctx = _estimate_num_ctx(llm_prompt) if num_ctx_setting == "auto" else int(num_ctx_setting)
- _job_set(job_id, llm_prompt=f"[num_ctx={num_ctx}]\n\n{llm_prompt}")
+ provider = _get_provider(prm.get("provider_id"))
+ model = prm["llm_model"] or get_setting("llm_model", _SEED_MODEL)
+ num_ctx_setting = prm["llm_num_ctx"] or get_setting("llm_num_ctx", "auto")
+ num_predict = int(prm["llm_num_predict"] or get_setting("llm_num_predict", str(_SEED_NUM_PREDICT)))
+ repeat_penalty = float(prm["llm_repeat_penalty"] or get_setting("llm_repeat_penalty", "1.15"))
+ repeat_last_n = int(prm["llm_repeat_last_n"] or get_setting("llm_repeat_last_n", "128"))
+
+ if not provider:
+ raise RuntimeError("Kein LLM-Anbieter konfiguriert")
+
+ user_extra = (j.get("user_prompt") or "").strip()
+ system_msg = "Du bist ein präziser Assistent. Antworte auf Deutsch."
+ user_msg = (
+ f"AUFTRAG:\n{prm['prompt']}\n"
+ + (f"\nZUSATZINFOS:\n{user_extra}\n" if user_extra else "")
+ + f"\nTEXT:\n{doc['content_md']}\n"
+ )
+
+ ctx_hint = f"[num_ctx=auto]" if num_ctx_setting == "auto" else f"[num_ctx={num_ctx_setting}]"
+ _job_set(job_id, llm_prompt=f"{ctx_hint} [Anbieter: {provider['name']} | Modell: {model}]\n\nSYSTEM:\n{system_msg}\n\nUSER:\n{user_msg}")
+
with _JOB_STREAM_LOCK:
_JOB_STREAMS[job_id] = {"thinking": "", "response": ""}
- r = requests.post(
- f"{OLLAMA_BASE_URL}/api/generate",
- json={"model": model, "prompt": llm_prompt, "stream": True, "think": think, "options": {
- "num_ctx": num_ctx,
+
+ headers = {"Content-Type": "application/json"}
+ if provider.get("api_key"):
+ headers["Authorization"] = f"Bearer {provider['api_key']}"
+
+ req_body: dict = {
+ "model": model,
+ "messages": [
+ {"role": "system", "content": system_msg},
+ {"role": "user", "content": user_msg},
+ ],
+ "stream": True,
+ "max_tokens": num_predict,
+ }
+ if num_ctx_setting != "auto":
+ req_body["options"] = {
+ "num_ctx": int(num_ctx_setting),
"num_predict": num_predict,
"repeat_penalty": repeat_penalty,
"repeat_last_n": repeat_last_n,
- }},
+ }
+
+ r = requests.post(
+ f"{provider['base_url'].rstrip('/')}/v1/chat/completions",
+ json=req_body,
+ headers=headers,
stream=True,
timeout=1200,
)
r.raise_for_status()
- acc_thinking = ""
- acc_response = ""
- ollama_final = {}
+ acc = ""
chunk_count = 0
+ finish_reason = None
for line in r.iter_lines():
if not line:
continue
- chunk = json.loads(line)
- acc_thinking += chunk.get("thinking") or ""
- acc_response += chunk.get("response") or ""
+ line_str = line.decode() if isinstance(line, bytes) else line
+ if not line_str.startswith("data: "):
+ continue
+ data_str = line_str[6:]
+ if data_str.strip() == "[DONE]":
+ break
+ try:
+ chunk = json.loads(data_str)
+ except Exception:
+ continue
+ choice = (chunk.get("choices") or [{}])[0]
+ acc += choice.get("delta", {}).get("content", "") or ""
+ finish_reason = choice.get("finish_reason") or finish_reason
+ t_part, r_part = _split_thinking(acc)
with _JOB_STREAM_LOCK:
- _JOB_STREAMS[job_id] = {"thinking": acc_thinking, "response": acc_response}
+ _JOB_STREAMS[job_id] = {"thinking": t_part, "response": r_part}
chunk_count += 1
if chunk_count % 100 == 0:
j_check = _job_get(job_id)
if not j_check or j_check["status"] == "cancelled":
return
- if chunk.get("done"):
- ollama_final = chunk
- break
- answer = acc_response
+ acc_thinking, answer = _split_thinking(acc)
j = _job_get(job_id)
if not j or j["status"] == "cancelled":
@@ -560,7 +646,7 @@ def _process_analysis_job(job_id: int):
answer,
doc["id"],
prm["id"],
- json.dumps({"ollama_response": ollama_final}, ensure_ascii=False),
+ json.dumps({"provider": provider["name"], "model": model, "finish_reason": finish_reason}, ensure_ascii=False),
now_iso(),
),
)
@@ -667,15 +753,22 @@ self.addEventListener('fetch', (event) => {
@app.get("/healthz")
def healthz():
- return {"ok": True, "api_base": API_BASE, "ollama_base_url": OLLAMA_BASE_URL, "ollama_model": OLLAMA_MODEL, "db_path": DB_PATH}
+ return {"ok": True, "api_base": API_BASE, "db_path": DB_PATH}
@app.get("/settings/models")
-def settings_models():
+def settings_models(provider_id: Optional[int] = None):
+ provider = _get_provider(provider_id)
+ if not provider:
+ return JSONResponse({"ok": False, "error": "Kein Anbieter konfiguriert", "models": []})
+ headers = {}
+ if provider.get("api_key"):
+ headers["Authorization"] = f"Bearer {provider['api_key']}"
try:
- r = requests.get(f"{OLLAMA_BASE_URL}/api/tags", timeout=5)
+ r = requests.get(f"{provider['base_url'].rstrip('/')}/v1/models", headers=headers, timeout=5)
r.raise_for_status()
- names = sorted(m["name"] for m in r.json().get("models", []))
+ data = r.json()
+ names = sorted(m["id"] for m in data.get("data", []))
return JSONResponse({"ok": True, "models": names})
except Exception as e:
return JSONResponse({"ok": False, "error": str(e), "models": []})
@@ -683,12 +776,11 @@ def settings_models():
@app.get("/settings", response_class=HTMLResponse)
def settings_page(msg: str = ""):
- current_model = get_setting("ollama_model", OLLAMA_MODEL)
- current_think = get_setting("ollama_think", "true" if OLLAMA_THINK else "false") == "true"
- current_num_ctx = get_setting("ollama_num_ctx", "auto")
- current_num_predict = get_setting("ollama_num_predict", str(OLLAMA_NUM_PREDICT))
- current_repeat_penalty = get_setting("ollama_repeat_penalty", "1.15")
- current_repeat_last_n = get_setting("ollama_repeat_last_n", "128")
+ current_model = get_setting("llm_model", _SEED_MODEL)
+ current_num_ctx = get_setting("llm_num_ctx", "auto")
+ current_num_predict = get_setting("llm_num_predict", str(_SEED_NUM_PREDICT))
+ current_repeat_penalty = get_setting("llm_repeat_penalty", "1.15")
+ current_repeat_last_n = get_setting("llm_repeat_last_n", "128")
smtp_host = get_setting("smtp_host", "")
smtp_port = get_setting("smtp_port", "587")
@@ -699,8 +791,25 @@ def settings_page(msg: str = ""):
smtp_tls_checked = "checked" if smtp_tls else ""
with db() as c:
+ providers = c.execute("SELECT * FROM providers ORDER BY name").fetchall()
email_addrs = c.execute("SELECT id, label, email FROM email_addresses ORDER BY label").fetchall()
+ provider_rows = "".join(
+ f"""
+ | {p["name"]} |
+ {p["base_url"]} |
+ |
+ {'Standard' if p["is_default"] else f''} |
+
+
+ |
+
"""
+ for p in providers
+ )
+
email_addr_rows = "".join(
f"""
| {a["label"]} |
@@ -715,7 +824,9 @@ def settings_page(msg: str = ""):
for a in email_addrs
)
- think_checked = "checked" if current_think else ""
+ provider_id_opts = "".join(
+ f"" for p in providers
+ )
ctx_options = [("auto", "Automatisch (empfohlen)"), ("4096", "4 096"), ("8192", "8 192"),
("16384", "16 384"), ("32768", "32 768"), ("65536", "65 536"), ("131072", "131 072")]
@@ -729,16 +840,48 @@ def settings_page(msg: str = ""):
Einstellungen
{f"{msg}
" if msg else ""}
-
-
Ollama / LLM
+
+
+
LLM-Anbieter
+
OpenAI-kompatible Endpunkte (Ollama, OpenAI, Groq, LM Studio, …)
+ {"
| Name | Base-URL | API-Key | Standard | |
" + provider_rows + "
" if providers else "
Noch kein Anbieter konfiguriert.
"}
+
+
+
+
+
Standard-LLM-Parameter
+
Gelten für alle Prompts ohne eigene Einstellungen. Anbieter = markierter Standard-Anbieter oben.