"""Kategorisierung: Transkript -> strukturierte Felder. Zwei Backends (Auswahl siehe pipe.modellwahl): - "ollama" (Default): lokal, erzwungenes JSON-Schema, Denk-Tokens landen separat im "thinking"-Feld und werden ignoriert. - "openrouter": Cloud, zum Testen ob ein staerkeres Modell besser extrahiert. Transkript verlaesst dabei den Rechner - nur zum Testen. """ from __future__ import annotations import json import time import urllib.error import urllib.request from functools import lru_cache from . import config, modellwahl KATEGORIEN = [ "termin", "rezept", "ueberweisung", "befund", "verwaltung", "beschwerden", "notfall", "rueckruf", "sonstiges", ] DRINGLICHKEITEN = ["niedrig", "normal", "hoch", "notfall"] # JSON-Schema, das Ollama dem Modell als Ausgabeformat aufzwingt. _SCHEMA = { "type": "object", "properties": { "kategorie": {"type": "string", "enum": KATEGORIEN}, "dringlichkeit": {"type": "string", "enum": DRINGLICHKEITEN}, "anrufer_name": {"type": ["string", "null"]}, "rueckrufnummer": {"type": ["string", "null"]}, "rueckruf_gewuenscht": {"type": "boolean"}, "sprache": {"type": "string"}, "anliegen_kurz": {"type": "string"}, "stichworte": {"type": "array", "items": {"type": "string"}}, }, # Alle Felder erzwingen: was hier fehlt, lässt das Modell einfach weg statt # null zu liefern - Name und Rückrufnummer gingen so still verloren, obwohl # sie im Transkript standen. Der Typ erlaubt weiterhin null, wenn nichts da ist. "required": [ "kategorie", "dringlichkeit", "anrufer_name", "rueckrufnummer", "rueckruf_gewuenscht", "sprache", "anliegen_kurz", "stichworte", ], } @lru_cache(maxsize=1) def _prompt() -> str: return config.PROMPT_DATEI.read_text(encoding="utf-8") class KategorisierungsFehler(RuntimeError): pass def kategorisiere(transkript: str) -> tuple[dict, dict]: """Ordnet ein Transkript ein. Wirft KategorisierungsFehler bei Problemen. Gibt (auswertung, meta) zurueck - meta fuers Live-Log im Leitstand: backend, modell, dauer_s, tokens, kosten_usd (letzteres nur OpenRouter, bei :free-Modellen 0.0). Backend + Modell kommen aus pipe.modellwahl (per Leitstand-Dropdown umschaltbar, kein Neustart noetig) - Fallback ist config.py, wenn dort keine Auswahl getroffen wurde. """ if not transkript.strip(): raise KategorisierungsFehler("Leeres Transkript kann nicht kategorisiert werden.") backend, modell = modellwahl.backend_und_modell() start = time.monotonic() if backend == "openrouter": ergebnis, meta = _mit_openrouter(transkript, modell) else: ergebnis, meta = _mit_ollama(transkript, modell) meta["backend"] = backend meta["modell"] = modell meta["dauer_s"] = round(time.monotonic() - start, 2) return _bereinige(ergebnis), meta def _mit_ollama(transkript: str, modell: str) -> tuple[dict, dict]: anfrage = { "model": modell, "messages": [ {"role": "system", "content": _prompt()}, {"role": "user", "content": f"Transkript der Sprachnachricht:\n\n{transkript}"}, ], "stream": False, "think": False, # Klassifikation braucht kein Reasoning -> deutlich schneller "format": _SCHEMA, "options": {"temperature": 0}, } daten = json.dumps(anfrage).encode("utf-8") req = urllib.request.Request( f"{config.OLLAMA_URL}/api/chat", data=daten, headers={"Content-Type": "application/json"}, method="POST", ) try: with urllib.request.urlopen(req, timeout=120) as antwort: roh = json.loads(antwort.read().decode("utf-8")) except urllib.error.URLError as fehler: raise KategorisierungsFehler( f"Ollama nicht erreichbar unter {config.OLLAMA_URL}: {fehler}" ) from fehler inhalt = roh.get("message", {}).get("content", "").strip() if not inhalt: raise KategorisierungsFehler("Ollama lieferte keine Antwort.") # eval_count/prompt_eval_count sind Ollamas Token-Zaehler - kein Standard- # OpenAI-Feldname, aber inhaltlich dasselbe (Antwort-/Prompt-Tokens). tokens = roh.get("prompt_eval_count", 0) + roh.get("eval_count", 0) or None try: return json.loads(inhalt), {"tokens": tokens, "kosten_usd": None} except json.JSONDecodeError as fehler: raise KategorisierungsFehler(f"Antwort war kein gültiges JSON: {inhalt[:200]}") from fehler def _mit_openrouter(transkript: str, modell: str) -> tuple[dict, dict]: if not config.OPENROUTER_KEY: raise KategorisierungsFehler("OPENROUTER_KEY fehlt in .env.") # OpenAI-kompatible Chat-API. json_object statt json_schema, weil nicht # jedes ueber OpenRouter geroutete Modell striktes Schema unterstuetzt - # der Systemprompt beschreibt die Felder bereits explizit in Prosa. # usage.include=true ist eine OpenRouter-Erweiterung: liefert zusaetzlich # die tatsaechlichen Kosten (USD) in der Antwort mit, nicht nur Tokens. anfrage = { "model": modell, "messages": [ {"role": "system", "content": _prompt()}, {"role": "user", "content": f"Transkript der Sprachnachricht:\n\n{transkript}"}, ], "temperature": 0, "response_format": {"type": "json_object"}, "usage": {"include": True}, } daten = json.dumps(anfrage).encode("utf-8") req = urllib.request.Request( "https://openrouter.ai/api/v1/chat/completions", data=daten, headers={ "Content-Type": "application/json", "Authorization": f"Bearer {config.OPENROUTER_KEY}", }, method="POST", ) try: with urllib.request.urlopen(req, timeout=120) as antwort: roh = json.loads(antwort.read().decode("utf-8")) except urllib.error.HTTPError as fehler: detail = fehler.read().decode("utf-8", errors="replace")[:200] raise KategorisierungsFehler(f"OpenRouter-Fehler {fehler.code}: {detail}") from fehler except urllib.error.URLError as fehler: raise KategorisierungsFehler(f"OpenRouter nicht erreichbar: {fehler}") from fehler try: inhalt = roh["choices"][0]["message"]["content"].strip() except (KeyError, IndexError) as fehler: raise KategorisierungsFehler(f"Unerwartete OpenRouter-Antwort: {roh}") from fehler if not inhalt: raise KategorisierungsFehler("OpenRouter lieferte keine Antwort.") nutzung = roh.get("usage") or {} meta = {"tokens": nutzung.get("total_tokens"), "kosten_usd": nutzung.get("cost")} try: return json.loads(inhalt), meta except json.JSONDecodeError as fehler: raise KategorisierungsFehler(f"Antwort war kein gültiges JSON: {inhalt[:200]}") from fehler def _bereinige(d: dict) -> dict: """Absichern gegen Ausreißer trotz Schema (defensiv).""" if d.get("kategorie") not in KATEGORIEN: d["kategorie"] = "sonstiges" if d.get("dringlichkeit") not in DRINGLICHKEITEN: d["dringlichkeit"] = "normal" # Das Modell vergibt gelegentlich die Kategorie "notfall" und stuft die # Dringlichkeit trotzdem nur auf "hoch" ein. Das ist widersprüchlich und # führt zu einer harmloseren Farbe auf dem Board, als der Anruf verdient. # In der Praxis eskaliert man im Zweifel, statt abzuschwächen. if d["kategorie"] == "notfall": d["dringlichkeit"] = "notfall" d.setdefault("anrufer_name", None) d.setdefault("rueckrufnummer", None) d.setdefault("rueckruf_gewuenscht", False) d.setdefault("sprache", "de") d.setdefault("anliegen_kurz", "") d.setdefault("stichworte", []) if not isinstance(d["stichworte"], list): d["stichworte"] = [] return d