feat(model): wählbares LLM-Modell pro User & pro Job

Mac-Worker:
- /api/summarize, /api/protocol, /api/preload akzeptieren optionales
  model-Feld → überschreibt den .env-Default pro Call, kein Restart nötig
- /api/models listet die lokal verfügbaren Ollama-Modelle + Default

LXC:
- User.default_model + Job.model (Migration für SQLite)
- /api/auth/me liefert default_model, PATCH speichert ihn sofort
- /api/mac/models proxiet die Liste an die UI
- Pipeline reicht job.model an Mac-Worker durch; leer = Worker-Default
- create_job & reprocess akzeptieren model-Override
- Snapshot-Meta nutzt job.model (genauer als /health-Snapshot)

UI:
- Header-Dropdown "Modell:" neben "Profil:" — sofort-speichert User-Default
- Jeder Job zeigt sein Modell als Tag (lila); leer bedeutet Worker-Default
- Reprocess öffnet einen Modal-Picker (statt simplem confirm), Auswahl
  aller verfügbaren Modelle inkl. "Worker-Default"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-05-16 16:48:11 +00:00
co-authored by Claude Opus 4.7
parent dbf7bca6c7
commit c6164d946c
13 changed files with 386 additions and 54 deletions
+41 -9
View File
@@ -52,7 +52,15 @@ def render(template: str, **vars: str) -> str:
return out
async def _chat(prompt: str) -> str:
def _resolve_model(model: str | None) -> str:
"""Wählt das tatsächlich zu verwendende Ollama-Modell.
Leerer oder fehlender `model`-Parameter → Worker-Default aus .env.
"""
return (model or "").strip() or settings.ollama_model
async def _chat(prompt: str, model: str | None = None) -> str:
"""Ruft Ollama im Streaming-Modus auf.
Streaming hat zwei wichtige Vorteile:
@@ -64,15 +72,16 @@ async def _chat(prompt: str) -> str:
`keep_alive`: das Modell bleibt 30 min im RAM zwischen Calls,
damit nachfolgende Anfragen nicht erneut die Ladezeit zahlen.
"""
use_model = _resolve_model(model)
url = f"{settings.ollama_url.rstrip('/')}/api/generate"
payload = {
"model": settings.ollama_model,
"model": use_model,
"prompt": prompt,
"stream": True,
"keep_alive": "30m",
"options": {"temperature": 0.2},
}
log.info("Ollama generate model=%s prompt_len=%d (streaming)", settings.ollama_model, len(prompt))
log.info("Ollama generate model=%s prompt_len=%d (streaming)", use_model, len(prompt))
timeout = httpx.Timeout(
connect=15.0,
read=settings.ollama_timeout, # pro Chunk — großzügig wegen initial Modell-Load
@@ -100,15 +109,17 @@ async def _chat(prompt: str) -> str:
return "".join(chunks)
async def summarize(transcript: str, title: str = "", profile_name: str | None = None) -> dict[str, Any]:
async def summarize(
transcript: str, title: str = "", profile_name: str | None = None, model: str | None = None,
) -> dict[str, Any]:
profile = profiles.get(profile_name)
prompt = render(
profile.summary_prompt,
title=title or "(ohne Titel)",
transcript=_truncate(transcript),
)
log.info("Summarize profile=%s", profile.name)
raw = await _chat(prompt)
log.info("Summarize profile=%s model=%s", profile.name, _resolve_model(model))
raw = await _chat(prompt, model=model)
try:
return _parse_json(raw)
except Exception: # noqa: BLE001
@@ -117,7 +128,8 @@ async def summarize(transcript: str, title: str = "", profile_name: str | None =
async def make_protocol(
transcript: str, summary: dict, title: str = "", profile_name: str | None = None
transcript: str, summary: dict, title: str = "", profile_name: str | None = None,
model: str | None = None,
) -> dict:
profile = profiles.get(profile_name)
prompt = render(
@@ -126,8 +138,8 @@ async def make_protocol(
summary_json=json.dumps(summary, ensure_ascii=False),
transcript=_truncate(transcript),
)
log.info("Protocol profile=%s", profile.name)
raw = await _chat(prompt)
log.info("Protocol profile=%s model=%s", profile.name, _resolve_model(model))
raw = await _chat(prompt, model=model)
try:
return _parse_json(raw)
except Exception: # noqa: BLE001
@@ -143,3 +155,23 @@ async def health() -> dict:
r = await c.get(url)
r.raise_for_status()
return r.json()
async def list_models() -> list[dict]:
"""Liefert die lokal gepullten Ollama-Modelle inkl. Größe und Mod-Zeit.
Antwort-Form pro Eintrag: {"name", "size", "modified_at"} (Größe in Bytes).
"""
url = f"{settings.ollama_url.rstrip('/')}/api/tags"
async with httpx.AsyncClient(timeout=10) as c:
r = await c.get(url)
r.raise_for_status()
data = r.json()
out: list[dict] = []
for m in data.get("models", []):
out.append({
"name": m.get("name") or m.get("model") or "",
"size": m.get("size", 0),
"modified_at": m.get("modified_at", ""),
})
return [m for m in out if m["name"]]