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:
@@ -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"]]
|
||||
|
||||
+27
-5
@@ -68,6 +68,7 @@ class SummarizeIn(BaseModel):
|
||||
transcript: str
|
||||
title: str = ""
|
||||
profile: str | None = None
|
||||
model: str | None = None
|
||||
|
||||
|
||||
class ProtocolIn(BaseModel):
|
||||
@@ -75,6 +76,7 @@ class ProtocolIn(BaseModel):
|
||||
summary: dict
|
||||
title: str = ""
|
||||
profile: str | None = None
|
||||
model: str | None = None
|
||||
|
||||
|
||||
class DocxIn(BaseModel):
|
||||
@@ -89,6 +91,7 @@ class PdfIn(BaseModel):
|
||||
|
||||
class PreloadIn(BaseModel):
|
||||
profile: str | None = None
|
||||
model: str | None = None
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
@@ -114,6 +117,21 @@ def list_profiles():
|
||||
return {"profiles": profiles.list_all()}
|
||||
|
||||
|
||||
@app.get("/api/models")
|
||||
async def list_models():
|
||||
"""Liste der lokal verfügbaren Ollama-Modelle + Default aus der Worker-.env.
|
||||
|
||||
Das LXC-Frontend zeigt davon ein Dropdown — leerer Wert beim Job-Start =
|
||||
der Default hier wird genommen.
|
||||
"""
|
||||
try:
|
||||
models = await ollama_client.list_models()
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning("list_models: Ollama nicht erreichbar: %s", e)
|
||||
return {"models": [], "default": settings.ollama_model, "error": str(e)}
|
||||
return {"models": models, "default": settings.ollama_model}
|
||||
|
||||
|
||||
@app.post("/api/transcribe")
|
||||
async def transcribe(audio: UploadFile = File(...), language: str = Form("de")):
|
||||
if not audio.filename:
|
||||
@@ -135,7 +153,9 @@ async def transcribe(audio: UploadFile = File(...), language: str = Form("de")):
|
||||
async def summarize(payload: SummarizeIn):
|
||||
if not payload.transcript.strip():
|
||||
raise HTTPException(400, "Leerer Transkript")
|
||||
return await ollama_client.summarize(payload.transcript, title=payload.title, profile_name=payload.profile)
|
||||
return await ollama_client.summarize(
|
||||
payload.transcript, title=payload.title, profile_name=payload.profile, model=payload.model
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/protocol")
|
||||
@@ -143,7 +163,8 @@ async def protocol(payload: ProtocolIn):
|
||||
if not payload.transcript.strip():
|
||||
raise HTTPException(400, "Leerer Transkript")
|
||||
return await ollama_client.make_protocol(
|
||||
payload.transcript, payload.summary, title=payload.title, profile_name=payload.profile
|
||||
payload.transcript, payload.summary, title=payload.title, profile_name=payload.profile,
|
||||
model=payload.model,
|
||||
)
|
||||
|
||||
|
||||
@@ -176,10 +197,11 @@ async def preload(payload: PreloadIn):
|
||||
(häufige Ursache von 'Server disconnected without sending a response').
|
||||
"""
|
||||
profile_obj = profiles.get(payload.profile)
|
||||
log.info("preload model=%s profile=%s", settings.ollama_model, profile_obj.name)
|
||||
use_model = (payload.model or "").strip() or settings.ollama_model
|
||||
log.info("preload model=%s profile=%s", use_model, profile_obj.name)
|
||||
url = f"{settings.ollama_url.rstrip('/')}/api/generate"
|
||||
body = {
|
||||
"model": settings.ollama_model,
|
||||
"model": use_model,
|
||||
"prompt": "ping",
|
||||
"stream": False,
|
||||
"keep_alive": "30m",
|
||||
@@ -195,7 +217,7 @@ async def preload(payload: PreloadIn):
|
||||
log.warning("preload failed: %s", e)
|
||||
raise HTTPException(503, f"Ollama preload fehlgeschlagen: {e}") from e
|
||||
log.info("preload ok load_duration=%dms", int(data.get("load_duration", 0) / 1_000_000))
|
||||
return {"ok": True, "model": settings.ollama_model, "load_duration_ns": data.get("load_duration", 0)}
|
||||
return {"ok": True, "model": use_model, "load_duration_ns": data.get("load_duration", 0)}
|
||||
|
||||
|
||||
@app.get("/api/jobs/{job_id}/log")
|
||||
|
||||
Reference in New Issue
Block a user