Просмотр исходного кода

Initial live middleware snapshot

Telefonie Middleware недель назад: 2
Сommit
e8930d435d
62 измененных файлов с 14229 добавлено и 0 удалено
  1. 15 0
      .env.example
  2. 43 0
      .gitignore
  3. 37 0
      README.md
  4. 35 0
      SPEC.md
  5. 346 0
      TAXONOMY_3.2_DRAFT.md
  6. 0 0
      app/__init__.py
  7. 6 0
      app/__main__.py
  8. 117 0
      app/audio_processor.py
  9. 233 0
      app/call_context.py
  10. 146 0
      app/call_context_repository.py
  11. 35 0
      app/calls.py
  12. 64 0
      app/config.py
  13. 483 0
      app/kontor_mcp.py
  14. 435 0
      app/kontor_resolver.py
  15. 683 0
      app/main.py
  16. 208 0
      app/media.py
  17. 239 0
      app/media_manager.py
  18. 288 0
      app/media_session.py
  19. 196 0
      app/phone.py
  20. 567 0
      app/repository.py
  21. 388 0
      app/service.py
  22. 288 0
      app/threecx.py
  23. 67 0
      app/whisper.py
  24. 100 0
      config/telefonie_taxonomy.json
  25. 250 0
      docs/rabattverhalten.md
  26. 9 0
      requirements.txt
  27. 17 0
      systemd/3cx-telefonie.service
  28. 138 0
      tools/3cx_abandoned.py
  29. 347 0
      tools/benchmark_zammad_taxonomy.py
  30. 355 0
      tools/build_zammad_analysis.py
  31. 305 0
      tools/classify_historical_intents.py
  32. 334 0
      tools/classify_historical_multi_intent.py
  33. 495 0
      tools/classify_historical_v2.py
  34. 825 0
      tools/classify_zammad.py
  35. 264 0
      tools/classify_zammad_cases.py
  36. 214 0
      tools/classify_zammad_unknown.py
  37. 165 0
      tools/clean_zammad_quotes.py
  38. 106 0
      tools/filter_transcripts.py
  39. 90 0
      tools/filter_uncertain.py
  40. 224 0
      tools/fix_and_resume_batch.py
  41. 117 0
      tools/fix_call_relations.py
  42. 334 0
      tools/import_zammad_history.py
  43. 227 0
      tools/index_recordings.py
  44. 96 0
      tools/match_calls_by_time.py
  45. 101 0
      tools/migrate_call_analysis.py
  46. 110 0
      tools/prepare_historical_batch.py
  47. 351 0
      tools/prepare_zammad_cases.py
  48. 123 0
      tools/process_all_recordings.py
  49. 562 0
      tools/process_cdr_call.py
  50. 497 0
      tools/process_one_call.py
  51. 218 0
      tools/rebuild_zammad_cases.py
  52. 296 0
      tools/reclassify_historical_from_analysis.py
  53. 73 0
      tools/repair_and_check_batch.py
  54. 286 0
      tools/replay_historical_analysis.py
  55. 121 0
      tools/review_zammad_unknown.py
  56. 90 0
      tools/screen_transcripts.py
  57. 82 0
      tools/status.sh
  58. 285 0
      tools/sync_cdr.py
  59. 100 0
      tools/test_routepoint90_media.py
  60. 167 0
      tools/test_zammad_ollama.py
  61. 291 0
      tools/transcribe_parallel.py
  62. 545 0
      tools/zammad_taxonomy_scan.py

+ 15 - 0
.env.example

@@ -0,0 +1,15 @@
+THREECX_BASE_URL=https://192.168.1.118
+THREECX_CLIENT_ID=3cxtelefone
+THREECX_API_KEY=/opt/3cx/3cx.key
+THREECX_QUEUE_DN=84
+THREECX_MONITORED_EXTENSIONS=10,11,13,42
+THREECX_VERIFY_TLS=false
+
+APP_HOST=127.0.0.1
+APP_PORT=8095
+DATABASE_PATH=./data/telephony.sqlite3
+LOG_LEVEL=INFO
+
+RABBITMQ_ENABLED=false
+RABBITMQ_URL=amqp://guest:guest@127.0.0.1:5672/
+RABBITMQ_EXCHANGE=telephony

+ 43 - 0
.gitignore

@@ -0,0 +1,43 @@
+# Python
+__pycache__/
+*.py[cod]
+*.so
+.venv/
+venv/
+env/
+
+# Secrets / credentials
+.env
+.env.*
+!.env.example
+*.key
+*.pem
+*.p12
+*.pfx
+
+# Runtime / generated data
+logs/
+recordings/
+data/
+*.sqlite
+*.sqlite3
+*.db
+
+# OS / editor
+.DS_Store
+.vscode/
+.idea/
+
+# Temporary / backup files
+*.bak
+*.tmp
+*.swp
+
+# Historical backup files
+*.bak
+*.bak-*
+*.before-*
+*.before_*
+
+# Timestamped backup files
+*.bak.*

+ 37 - 0
README.md

@@ -0,0 +1,37 @@
+# 3CX Telefonie Middleware V0.1
+
+Pragmatische Middleware auf dem AI-Server.
+
+## V0.1
+- 3CX Queue 84 beobachten
+- Call-Control-WebSocket als Event-Trigger
+- REST `/callcontrol/84` als aktueller Zustand
+- verpasste Anrufe erkennen
+- gleiche Rufnummern bündeln
+- kleine HTTP-API für spätere ExtJS/Electron-Anbindung
+
+3CX: V20 Update 9 / Build 995.
+Interne Adresse: https://192.168.1.118
+
+## Sprache
+Python 3.11+.
+
+Python ist hier sinnvoll, weil der Dienst dauerhaft async laufen kann und später Whisper/Ollama direkt auf demselben AI-Server integrierbar sind.
+
+## Start
+```bash
+cd /opt/3cx-middleware
+python3 -m venv .venv
+source .venv/bin/activate
+pip install -r requirements.txt
+cp .env.example .env
+chmod 600 .env
+python -m app
+```
+
+API:
+- GET /health
+- GET /missed-calls
+
+## Wichtig
+Die genaue Queue->Agent-Lifecycle-Semantik muss noch mit einem angenommenen Testanruf validiert werden. V0.1 ist deshalb bewusst konservativ.

+ 35 - 0
SPEC.md

@@ -0,0 +1,35 @@
+# V0.1 – Verpasste Anrufe
+
+## Ziel
+Eingehende Anrufe über 3CX Queue 84 außerhalb von 3CX gruppiert als eigene Liste erfassen.
+
+## Darstellung
+Nicht 15 einzelne Einträge für 15 Versuche derselben Nummer, sondern ein Eintrag:
+- Rufnummer
+- später Kunde
+- letzter Anruf
+- Anzahl Versuche
+- Status offen/erledigt
+
+## V0.1-Erkennung
+3CX-WebSocket meldet Änderungen.
+Danach wird `/callcontrol/84` abgefragt.
+Die REST-Antwort ist der aktuelle Zustand.
+
+Ein Teilnehmer, der auf Queue 84 erscheint und später verschwindet, wird zunächst als verpasst markiert.
+
+**Diese Logik muss bei einem angenommenen Anruf während der Geschäftszeit validiert werden.**
+
+## Spätere Erweiterungen
+- Kunde aus ERP
+- Auftrag
+- RabbitMQ
+- ExtJS/Electron Toast
+- Rückruf
+- Click-to-Call
+- Recording
+- Audio-Stream
+- Whisper
+- Ollama
+- Gesprächsnotiz-Vorschlag
+- Zammad

+ 346 - 0
TAXONOMY_3.2_DRAFT.md

@@ -0,0 +1,346 @@
+# Zammad Taxonomie 3.2 – Entwurf
+
+Status: DRAFT
+Basis: Taxonomie 3.2.1 + Ergebnisse des 4.259-Ticket-Laufs
+
+---
+
+## 1. Ziel
+
+Die Taxonomie soll nicht nur das primäre Kundenanliegen erkennen, sondern
+zusätzlich:
+
+- weitere gleichzeitig vorhandene Anliegen
+- konkrete Sachverhalte und Problemarten
+- positive bzw. abgeschlossene Zustände
+- relevante Produkt-/Versand-/Zahlungsinformationen
+
+erfassen.
+
+Grundregel:
+
+PRIMARY INTENT = Was ist das hauptsächliche Anliegen des Kunden?
+
+SECONDARY INTENTS = Welche weiteren eigenständigen Anliegen liegen vor?
+
+TAGS = Konkrete Fakten, Zustände, Ursachen, Produkteigenschaften oder
+Vorgangsstatus.
+
+---
+
+# 2. PRIMARY INTENTS
+
+VERSANDSTATUS
+LIEFERVERZUG
+ADRESSÄNDERUNG
+REKLAMATION
+TRANSPORTSCHADEN
+FEHLLIEFERUNG
+RECHNUNG
+ZAHLUNG
+WIDERRUF
+RETOURE
+PFLANZENBERATUNG
+SORTENBERATUNG
+PFLEGEFRAGE
+BESTANDSANFRAGE
+VORBESTELLUNG
+B2B
+GROSSHANDEL
+SONSTIGES
+
+Zusätzliche Primary Intents aus der Auswertung:
+- nur aufnehmen, wenn sich aus realen Tickets ein eigenständiger Bedarf ergibt
+- keine freien Modellkategorien wie KUNDENSERVICE, ALLGEMEIN,
+  KUNDENANFRAGE etc.
+
+---
+
+# 3. SECONDARY INTENTS
+
+Secondary Intents werden nur vergeben, wenn ein tatsächlich eigenständiges
+zweites Anliegen vorhanden ist.
+
+Beispiele:
+
+REKLAMATION + FEHLLIEFERUNG
+REKLAMATION + PFLANZENBERATUNG
+REKLAMATION + TRANSPORTSCHADEN
+RECHNUNG + ZAHLUNG
+VERSANDSTATUS + LIEFERVERZUG
+VERSANDSTATUS + WIDERRUF
+WIDERRUF + RETOURE
+BESTANDSANFRAGE + SORTENBERATUNG
+
+Kein Secondary Intent nur aufgrund eines beiläufig erwähnten Begriffs.
+
+---
+
+# 4. NEGATIVE / PROBLEM-TAGS
+
+## Versand / Lieferung
+
+LIEFERVERZUG
+LIEFERDIENST_GLS
+LIEFERDIENST_DHL
+LIEFERDIENST_PROBLEM
+PAKET_VERLOREN
+PAKET_BESCHAEDIGT
+SCHLECHTVERPACKT
+ZUSTELLPROBLEM
+
+## Bestellung
+
+BESTELLUNG_STORNIERT
+BESTELLUNG_FALSCH
+BESTELLUNG_UNVOLLSTAENDIG
+
+## Reklamation
+
+QUALITAETSPROBLEM
+FALSCHLIEFERUNG
+FEHLMENGE
+BESCHAEDIGT
+PFLANZE_EINGEGANGEN_BESCHAEDIGT
+
+## Pflanzen
+
+PFLANZENKRANKHEIT
+MEHLTAU
+STERNRUSSTAU
+PILZKRANKHEIT
+SCHÄDLINGE
+LAUSE
+DUENGER_FEHLT
+
+## Zahlung / Rechnung
+
+ZAHLUNGSPROBLEM
+ZAHLUNG_FEHLGESCHLAGEN
+RECHNUNG_FEHLT
+RECHNUNG_FALSCH
+GUTSCHRIFT_FEHLT
+
+---
+
+# 5. POSITIVE / ABGESCHLOSSENE TAGS
+
+Neu ausdrücklich in 3.2:
+
+ZAHLUNG_ERFOLGT
+RECHNUNG_BEZAHLT
+BESTELLUNG_BESTAETIGT
+LIEFERUNG_ERFOLGT
+LIEFERUNG_ZUGESTELLT
+RETOURE_ANGEKOMMEN
+GUTSCHRIFT_ERFOLGT
+ERSATZ_GELIEFERT
+PROBLEM_GELÖST
+ADRESSE_KORREKT
+STORNIERUNG_BESTAETIGT
+WIDERRUF_BESTAETIGT
+ERSTATTUNG_ERFOLGT
+KUNDENANTWORT_POSITIV
+
+Diese Tags dürfen auch zusammen mit einem Problem-Intent vorkommen.
+
+Beispiel:
+
+PRIMARY:
+REKLAMATION
+
+SECONDARY:
+FEHLLIEFERUNG
+
+TAGS:
+FALSCHLIEFERUNG
+ERSATZ_GELIEFERT
+PROBLEM_GELÖST
+
+---
+
+# 6. Produkt-/Sortiments-TAGS
+
+PRODUKTGRUPPE_ROSE
+PRODUKTGRUPPE_CLEMATIS
+PRODUKTFORM_WURZELWARE
+PRODUKTFORM_CONTAINER
+PRODUKTFORM_TOPF
+
+Weitere Produkt-Tags nach Auswertung des Produktionslaufs ergänzen.
+
+---
+
+# 7. Rabatt-TAGS
+
+RABATTANFRAGE
+RABATT_GEFORDERT
+RABATT_SELBSSTAENDIG_ABGEZOGEN
+
+RABATT_WIEDERHOLT wird NICHT als einzelner Ticket-Tag vergeben,
+sondern nur als Kontext-/Historieninformation verwendet.
+
+---
+
+# 8. RETOURE-TAGS
+
+TEILRETOURE
+VOLLRETOURE
+RETOURE_ANGEKUENDIGT
+RETOURE_UNTERWEGS
+RETOURE_ANGEKOMMEN
+ERSTATTUNG_ERFOLGT
+
+---
+
+# 9. TAG-REGELN
+
+Ein Tag beschreibt einen konkreten Sachverhalt.
+
+Nicht aus jedem erwähnten Wort einen Tag erzeugen.
+
+Beispiel:
+
+"Vielen Dank, die Ersatzpflanze ist angekommen und alles ist jetzt in Ordnung."
+
+Primary:
+REKLAMATION
+
+Tags:
+ERSATZ_GELIEFERT
+PROBLEM_GELÖST
+
+---
+
+# 10. POSITIVE ZUSTÄNDE
+
+Positive Zustände sind eigenständige Informationen und dürfen nicht
+automatisch durch den ursprünglichen Problem-Intent verdrängt werden.
+
+Beispiel:
+
+"Die Lieferung ist angekommen, danke."
+
+Primary:
+VERSANDSTATUS
+
+Tags:
+LIEFERUNG_ZUGESTELLT
+
+---
+
+# 11. WIDERRUF
+
+Widerrufssignale umfassen insbesondere:
+
+- Widerruf
+- widerrufen
+- vom Kauf zurücktreten
+- Bestellung stornieren
+- Bestellung abbrechen
+- Bestellung nicht mehr wünschen
+- Auftrag stornieren
+- Bestellung rückgängig machen
+
+Nicht jedes "storniert" ist automatisch WIDERRUF.
+Der Kontext entscheidet.
+
+---
+
+# 12. UNBEKANNTE TAGS
+
+Das Modell darf keine eigenen Kategorien erfinden.
+
+Beispiele für problematische freie Tags:
+
+KUNDENSERVICE
+ALLGEMEIN
+KUNDENANFRAGE
+
+Diese sind keine gültigen Tags, sofern sie nicht ausdrücklich in dieser
+Taxonomie definiert sind.
+
+Unbekannte Tags werden nicht stillschweigend als neue Taxonomieelemente
+akzeptiert.
+
+---
+
+# 13. UMLAUT / UNICODE
+
+Die Taxonomie verwendet eine kanonische Schreibweise.
+
+Beispiel:
+
+DÜNGER_FEHLT
+
+DUENGER_FEHLT ist nur eine mögliche Eingabe-/Normalisierungsvariante,
+aber kein zweiter Tag.
+
+Die Validierung muss Unicode-normalisiert erfolgen und insbesondere
+Ä/Ö/Ü/ä/ö/ü/ß korrekt behandeln.
+
+---
+
+# 14. TAXONOMY_FIT
+
+Erlaubte Werte:
+
+GOOD
+PARTIAL
+POOR
+
+Keine freien Werte.
+
+---
+
+# 15. CONFIDENCE
+
+confidence ist numerisch und liegt zwischen 0 und 1.
+
+Ungültige Modellwerte dürfen nicht als gültige Klassifikation gespeichert
+werden.
+
+---
+
+# 16. WICHTIGE REGEL
+
+Primary Intent niemals durch eine frei erfundene Modellkategorie ersetzen.
+
+Wenn kein passender Intent existiert:
+
+SONSTIGES
+
+---
+
+# 17. OFFENE PUNKTE FÜR FINAL 3.2
+
+Nach Abschluss des aktuellen Produktionslaufs prüfen:
+
+[ ] reale Secondary-Intent-Quote
+[ ] reale Tag-Verteilung
+[ ] häufigste unbekannte Tags
+[ ] positive Zustände
+[ ] weitere positive Tags
+[ ] weitere Versand-Tags
+[ ] weitere Zahlungs-Tags
+[ ] weitere Reklamations-Tags
+[ ] Pflanzenkrankheiten / Schädlinge
+[ ] Produkt-/Form-Tags
+[ ] Umlaut-/Normalisierungsfälle
+[ ] WIDERRUF-Falschklassifikationen
+[ ] SONSTIGES-Quote
+[ ] ungültige Modell-Intents
+[ ] ungültige Modell-Tags
+
+---
+
+# 18. VERSIONIERUNG
+
+3.2-DRAFT
+    ↓
+3.2-CANDIDATE
+    ↓
+3.2-FINAL
+
+Die laufende Klassifikation wird NICHT rückwirkend verändert,
+bevor 3.2-FINAL fachlich geprüft und freigegeben wurde.

+ 0 - 0
app/__init__.py


+ 6 - 0
app/__main__.py

@@ -0,0 +1,6 @@
+import uvicorn
+from .config import Settings
+
+if __name__ == "__main__":
+    s = Settings()
+    uvicorn.run("app.main:app", host=s.app_host, port=s.app_port)

+ 117 - 0
app/audio_processor.py

@@ -0,0 +1,117 @@
+from __future__ import annotations
+
+import asyncio
+import json
+import subprocess
+import tempfile
+from dataclasses import dataclass
+from pathlib import Path
+
+
+@dataclass(frozen=True)
+class AudioInfo:
+    path: Path
+    codec: str | None
+    sample_rate: int | None
+    channels: int
+    channel_layout: str | None
+    duration: float | None
+
+
+class AudioProcessor:
+
+    async def inspect(self, path: str | Path) -> AudioInfo:
+        path = Path(path)
+
+        if not path.is_file():
+            raise FileNotFoundError(path)
+
+        return await asyncio.to_thread(
+            self._inspect_sync,
+            path,
+        )
+
+    @staticmethod
+    def _inspect_sync(path: Path) -> AudioInfo:
+        result = subprocess.run(
+            [
+                "ffprobe",
+                "-v", "error",
+                "-select_streams", "a:0",
+                "-show_entries",
+                "stream=codec_name,sample_rate,channels,channel_layout",
+                "-show_entries",
+                "format=duration",
+                "-of", "json",
+                str(path),
+            ],
+            capture_output=True,
+            text=True,
+            check=True,
+        )
+
+        data = json.loads(result.stdout)
+        stream = (data.get("streams") or [{}])[0]
+        fmt = data.get("format") or {}
+
+        return AudioInfo(
+            path=path,
+            codec=stream.get("codec_name"),
+            sample_rate=(
+                int(stream["sample_rate"])
+                if stream.get("sample_rate")
+                else None
+            ),
+            channels=int(stream.get("channels") or 1),
+            channel_layout=stream.get("channel_layout"),
+            duration=(
+                float(fmt["duration"])
+                if fmt.get("duration")
+                else None
+            ),
+        )
+
+    async def prepare_for_transcription(
+        self,
+        path: str | Path,
+    ) -> list[Path]:
+
+        info = await self.inspect(path)
+
+        if info.channels <= 1:
+            return [Path(path)]
+
+        return await asyncio.to_thread(
+            self._split_stereo_sync,
+            info.path,
+        )
+
+    @staticmethod
+    def _split_stereo_sync(path: Path) -> list[Path]:
+        tmp = Path(
+            tempfile.mkdtemp(prefix="3cx-audio-")
+        )
+
+        outputs = []
+
+        for channel in (0, 1):
+            output = tmp / f"channel-{channel}.wav"
+
+            subprocess.run(
+                [
+                    "ffmpeg",
+                    "-hide_banner",
+                    "-loglevel", "error",
+                    "-i", str(path),
+                    "-map_channel", f"0.0.{channel}",
+                    "-ar", "16000",
+                    "-ac", "1",
+                    "-c:a", "pcm_s16le",
+                    str(output),
+                ],
+                check=True,
+            )
+
+            outputs.append(output)
+
+        return outputs

+ 233 - 0
app/call_context.py

@@ -0,0 +1,233 @@
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+from typing import Any
+
+from app.kontor_mcp import KontorMCPClient
+
+logger = logging.getLogger(__name__)
+
+
+class CallContextService:
+
+    def __init__(self, mcp_client: KontorMCPClient):
+        self.mcp = mcp_client
+
+        # E.164 -> context
+        self._cache: dict[str, dict[str, Any]] = {}
+
+        # E.164 -> currently running lookup
+        self._tasks: dict[str, asyncio.Task] = {}
+
+        self._lock = asyncio.Lock()
+
+    async def enrich_phone(
+        self,
+        e164: str,
+    ) -> dict[str, Any]:
+
+        if not e164:
+            return self._empty("invalid")
+
+        async with self._lock:
+
+            cached = self._cache.get(e164)
+
+            if cached is not None:
+                return cached
+
+            task = self._tasks.get(e164)
+
+            if task is None:
+                task = asyncio.create_task(
+                    self._lookup(e164)
+                )
+                self._tasks[e164] = task
+
+        try:
+            return await task
+
+        finally:
+            async with self._lock:
+                if self._tasks.get(e164) is task:
+                    self._tasks.pop(e164, None)
+
+    async def _lookup(
+        self,
+        e164: str,
+    ) -> dict[str, Any]:
+
+        try:
+
+            raw = await self.mcp.get_customer_context(
+                phone=e164
+            )
+
+            result = self._extract_result(raw)
+
+            if result is None:
+                context = self._empty("unavailable")
+            else:
+                context = self._normalize_context(
+                    result
+                )
+
+            async with self._lock:
+                self._cache[e164] = context
+
+            return context
+
+        except Exception as exc:
+
+            logger.warning(
+                "MCP customer context failed for %s: %s",
+                e164,
+                exc,
+            )
+
+            return {
+                "status": "unavailable",
+                "matched_on": [],
+                "customer": None,
+                "open_orders": [],
+                "recent_orders": [],
+                "hints": [],
+                "source": {
+                    "system": "kontor-mcp",
+                    "error": type(exc).__name__,
+                },
+            }
+
+    @staticmethod
+    def _extract_result(
+        raw: Any,
+    ) -> dict[str, Any] | None:
+
+        if not isinstance(raw, dict):
+            return None
+
+        if raw.get("isError") is True:
+            return None
+
+        structured = raw.get(
+            "structuredContent"
+        )
+
+        if isinstance(structured, dict):
+
+            result = structured.get(
+                "result"
+            )
+
+            if isinstance(result, dict):
+                return result
+
+            if isinstance(result, str):
+
+                try:
+                    parsed = json.loads(result)
+
+                    if isinstance(parsed, dict):
+                        return parsed
+
+                except json.JSONDecodeError:
+                    pass
+
+        content = raw.get("content")
+
+        if isinstance(content, list):
+
+            for item in content:
+
+                if not isinstance(item, dict):
+                    continue
+
+                text = item.get("text")
+
+                if not isinstance(text, str):
+                    continue
+
+                try:
+                    parsed = json.loads(text)
+
+                    if isinstance(parsed, dict):
+                        return parsed
+
+                except json.JSONDecodeError:
+                    continue
+
+        return None
+
+    @staticmethod
+    def _normalize_context(
+        result: dict[str, Any],
+    ) -> dict[str, Any]:
+
+        customer = result.get(
+            "customer"
+        )
+
+        matched_on = result.get(
+            "matched_on"
+        )
+
+        if not matched_on and isinstance(
+            customer,
+            dict,
+        ):
+            matched_on = customer.get(
+                "matched_on",
+                [],
+            )
+
+        return {
+            "status": result.get(
+                "status",
+                "unknown",
+            ),
+
+            "matched_on": matched_on or [],
+
+            "customer": customer,
+
+            "open_orders": result.get(
+                "open_orders",
+                [],
+            ),
+
+            "recent_orders": result.get(
+                "recent_orders",
+                [],
+            ),
+
+            "hints": result.get(
+                "hints",
+                [],
+            ),
+
+            "source": {
+                "system": "kontor-mcp",
+                "request_id": result.get(
+                    "request_id"
+                ),
+            },
+        }
+
+    @staticmethod
+    def _empty(
+        status: str,
+    ) -> dict[str, Any]:
+
+        return {
+            "status": status,
+            "matched_on": [],
+            "customer": None,
+            "open_orders": [],
+            "recent_orders": [],
+            "hints": [],
+            "source": {
+                "system": "kontor-mcp",
+            },
+        }

+ 146 - 0
app/call_context_repository.py

@@ -0,0 +1,146 @@
+from __future__ import annotations
+
+import json
+import sqlite3
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+
+DB_PATH = Path(
+    "/opt/3cx-middleware/3cx-telefonie-middleware/data/telephony.sqlite3"
+)
+
+
+class CallContextRepository:
+
+    def __init__(self, db_path: str | Path = DB_PATH):
+        self.db_path = str(db_path)
+        self._ensure_schema()
+
+    def _connect(self):
+        con = sqlite3.connect(
+            self.db_path,
+            timeout=10,
+        )
+        con.row_factory = sqlite3.Row
+        return con
+
+    def _ensure_schema(self):
+        with self._connect() as con:
+            con.execute(
+                """
+                CREATE TABLE IF NOT EXISTS call_context (
+                    id INTEGER PRIMARY KEY AUTOINCREMENT,
+                    callid TEXT NOT NULL,
+                    legid TEXT,
+                    phone_e164 TEXT NOT NULL,
+                    context_json TEXT NOT NULL,
+                    created_at TEXT NOT NULL,
+                    updated_at TEXT NOT NULL,
+                    UNIQUE(callid, legid)
+                )
+                """
+            )
+
+            con.execute(
+                """
+                CREATE INDEX IF NOT EXISTS
+                idx_call_context_phone
+                ON call_context(phone_e164)
+                """
+            )
+
+            con.commit()
+
+    def upsert(
+        self,
+        *,
+        callid: str,
+        legid: str | None,
+        phone_e164: str,
+        context: dict[str, Any],
+    ) -> None:
+
+        now = datetime.now(
+            timezone.utc
+        ).isoformat()
+
+        payload = json.dumps(
+            context,
+            ensure_ascii=False,
+            separators=(",", ":"),
+            default=str,
+        )
+
+        with self._connect() as con:
+            con.execute(
+                """
+                INSERT INTO call_context (
+                    callid,
+                    legid,
+                    phone_e164,
+                    context_json,
+                    created_at,
+                    updated_at
+                )
+                VALUES (?, ?, ?, ?, ?, ?)
+                ON CONFLICT(callid, legid)
+                DO UPDATE SET
+                    phone_e164 = excluded.phone_e164,
+                    context_json = excluded.context_json,
+                    updated_at = excluded.updated_at
+                """,
+                (
+                    str(callid),
+                    str(legid) if legid is not None else None,
+                    phone_e164,
+                    payload,
+                    now,
+                    now,
+                ),
+            )
+            con.commit()
+
+    def get(
+        self,
+        *,
+        callid: str,
+        legid: str | None = None,
+    ) -> dict[str, Any] | None:
+
+        with self._connect() as con:
+            if legid is None:
+                row = con.execute(
+                    """
+                    SELECT *
+                    FROM call_context
+                    WHERE callid = ?
+                    ORDER BY id DESC
+                    LIMIT 1
+                    """,
+                    (str(callid),),
+                ).fetchone()
+            else:
+                row = con.execute(
+                    """
+                    SELECT *
+                    FROM call_context
+                    WHERE callid = ?
+                      AND legid = ?
+                    LIMIT 1
+                    """,
+                    (str(callid), str(legid)),
+                ).fetchone()
+
+        if row is None:
+            return None
+
+        return {
+            "callid": row["callid"],
+            "legid": row["legid"],
+            "phone_e164": row["phone_e164"],
+            "context": json.loads(row["context_json"]),
+            "created_at": row["created_at"],
+            "updated_at": row["updated_at"],
+        }

+ 35 - 0
app/calls.py

@@ -0,0 +1,35 @@
+from datetime import datetime, timezone
+
+from .phone import normalize
+
+
+def normalize_call(participant: dict, queue_dn: str) -> dict:
+    raw = participant.get("party_caller_id")
+    normalized = normalize(raw, "DE") if raw else {
+        "e164": None,
+        "valid": False,
+    }
+
+    return {
+        "callId": participant.get("callid"),
+        "legId": participant.get("legid"),
+        "queue": queue_dn,
+        "direction": (
+            "inbound"
+            if participant.get("party_dn_type") == "Wexternalline"
+            else "internal"
+        ),
+        "status": (participant.get("status") or "unknown").lower(),
+        "caller": {
+            "raw": raw,
+            "e164": normalized.get("e164"),
+        },
+        "agent": None,
+        "observedAt": datetime.now(timezone.utc).isoformat(),
+        "source": {
+            "partyDn": participant.get("party_dn"),
+            "partyDnType": participant.get("party_dn_type"),
+            "deviceId": participant.get("device_id"),
+            "directControl": participant.get("direct_control"),
+        },
+    }

+ 64 - 0
app/config.py

@@ -0,0 +1,64 @@
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+class Settings(BaseSettings):
+    threecx_base_url: str = "https://192.168.1.118"
+    threecx_client_id: str = "3cxtelefone"
+    threecx_api_key: str = "/opt/3cx/3cx.key"
+    threecx_queue_dn: str = "84"
+    threecx_monitored_extensions: str = "10,11,13,42"
+    threecx_verify_tls: bool = False
+
+    # Dedicated 3CX RoutePoint / AI Media configuration
+    threecx_media_client_id: str = "90"
+    threecx_media_api_key: str = "/opt/3cx-middleware/3cx-telefonie-middleware/90-api.key"
+    threecx_media_routepoint_dn: str = "90"
+
+    # Whisper
+    whisper_model: str = "small"
+    whisper_device: str = "cpu"
+    whisper_compute_type: str = "int8"
+    whisper_language: str = "de"
+
+    # Media
+    media_sample_rate: int = 8000
+    media_channels: int = 1
+    media_sample_width: int = 2
+    media_output_sample_rate: int = 16000
+    media_recording_enabled: bool = True
+    media_recording_dir: str = "./recordings"
+
+    app_host: str = "127.0.0.1"
+    app_port: int = 8095
+    database_path: str = "./data/telephony.sqlite3"
+    log_level: str = "INFO"
+    middleware_api_token: str = ""
+
+    # Kontor MCP
+    kontor_mcp_url: str = "http://sgpdev:8090/mcp"
+    kontor_mcp_token: str = ""
+    kontor_mcp_timeout: float = 10.0
+    phone_default_region: str = "DE"
+    cors_origins: str = "http://localhost:1841"
+
+    rabbitmq_enabled: bool = False
+    rabbitmq_url: str = "amqp://guest:guest@127.0.0.1:5672/"
+    rabbitmq_exchange: str = "telephony"
+
+    model_config = SettingsConfigDict(
+        env_file=".env",
+        env_file_encoding="utf-8",
+        extra="ignore",
+    )
+
+
+    threecx_outbound_device_hints: dict[str, str] = {}
+
+    @property
+    def outbound_device_hints(self) -> dict[str, str]:
+        return self.threecx_outbound_device_hints
+
+    @property
+    def monitored_dns(self) -> list[str]:
+        values = [self.threecx_queue_dn]
+        values.extend(x.strip() for x in self.threecx_monitored_extensions.split(",") if x.strip())
+        return list(dict.fromkeys(values))

+ 483 - 0
app/kontor_mcp.py

@@ -0,0 +1,483 @@
+from __future__ import annotations
+
+import json
+import uuid
+from typing import Any
+
+import httpx
+
+
+class KontorMCPError(RuntimeError):
+    """Fehler bei der Kommunikation mit dem Kontor MCP Server."""
+
+
+class KontorMCPClient:
+    """
+    MCP Streamable-HTTP Client für die 3CX-Telefonie-Middleware.
+
+    Der Client kapselt die MCP-Kommunikation vollständig.
+    Die Telefonie-Middleware kennt keine mssRest-Details.
+    """
+
+    def __init__(
+        self,
+        url: str,
+        token: str,
+        timeout: float = 10.0,
+    ) -> None:
+        self.url = url.rstrip("/")
+        self.token = token
+        self.timeout = timeout
+        self.session_id: str | None = None
+        self._request_id = 0
+
+    def _next_id(self) -> int:
+        self._request_id += 1
+        return self._request_id
+
+    def _headers(self) -> dict[str, str]:
+        headers = {
+            "Authorization": f"Bearer {self.token}",
+            "Content-Type": "application/json",
+            "Accept": "application/json, text/event-stream",
+            "X-Request-ID": str(uuid.uuid4()),
+        }
+
+        if self.session_id:
+            headers["Mcp-Session-Id"] = self.session_id
+
+        return headers
+
+    async def _post(
+        self,
+        payload: dict[str, Any],
+    ) -> httpx.Response:
+        try:
+            async with httpx.AsyncClient(
+                timeout=self.timeout,
+                follow_redirects=True,
+            ) as client:
+                response = await client.post(
+                    self.url,
+                    headers=self._headers(),
+                    json=payload,
+                )
+        except httpx.TimeoutException as exc:
+            raise KontorMCPError(
+                "Kontor MCP timeout"
+            ) from exc
+        except httpx.HTTPError as exc:
+            raise KontorMCPError(
+                f"Kontor MCP HTTP error: {exc}"
+            ) from exc
+
+        # MCP Streamable HTTP liefert die Session-ID beim Initialize.
+        session_id = response.headers.get("mcp-session-id")
+        if session_id:
+            self.session_id = session_id
+
+        if response.status_code >= 400:
+            raise KontorMCPError(
+                f"Kontor MCP HTTP {response.status_code}: "
+                f"{response.text[:500]}"
+            )
+
+        return response
+
+    @staticmethod
+    def _extract_result(response: httpx.Response) -> Any:
+        content_type = response.headers.get(
+            "content-type",
+            "",
+        ).lower()
+
+        if "application/json" in content_type:
+            data = response.json()
+
+            if "error" in data:
+                error = data["error"]
+                raise KontorMCPError(
+                    f"MCP error {error.get('code')}: "
+                    f"{error.get('message')}"
+                )
+
+            return data.get("result")
+
+        # Streamable HTTP / SSE
+        for line in response.text.splitlines():
+            line = line.strip()
+
+            if not line.startswith("data:"):
+                continue
+
+            raw = line[5:].strip()
+
+            if not raw:
+                continue
+
+            try:
+                data = json.loads(raw)
+            except json.JSONDecodeError:
+                continue
+
+            if "error" in data:
+                error = data["error"]
+                raise KontorMCPError(
+                    f"MCP error {error.get('code')}: "
+                    f"{error.get('message')}"
+                )
+
+            if "result" in data:
+                return data["result"]
+
+        # Manche MCP-Server beantworten Notifications mit 202/leer.
+        if not response.text.strip():
+            return None
+
+        raise KontorMCPError(
+            "Kontor MCP lieferte keine verwertbare Antwort"
+        )
+
+    async def initialize(self) -> dict[str, Any]:
+        """
+        MCP Handshake.
+
+        Wichtig:
+        1. initialize ohne Session
+        2. Session-ID aus Response übernehmen
+        3. notifications/initialized mit Session senden
+        """
+
+        payload = {
+            "jsonrpc": "2.0",
+            "id": self._next_id(),
+            "method": "initialize",
+            "params": {
+                "protocolVersion": "2025-03-26",
+                "capabilities": {},
+                "clientInfo": {
+                    "name": "3cx-telefonie-middleware",
+                    "version": "1.0.0",
+                },
+            },
+        }
+
+        response = await self._post(payload)
+
+        if not self.session_id:
+            raise KontorMCPError(
+                "MCP Server hat keine Session-ID geliefert"
+            )
+
+        result = self._extract_result(response)
+
+        # MCP Notification: keine id, keine Antwort erforderlich.
+        notification = {
+            "jsonrpc": "2.0",
+            "method": "notifications/initialized",
+        }
+
+        await self._post(notification)
+
+        return result or {}
+
+    async def list_tools(self) -> list[dict[str, Any]]:
+        result = await self._request(
+            "tools/list",
+            {},
+        )
+
+        if not result:
+            return []
+
+        return result.get("tools", [])
+
+    async def _request(
+        self,
+        method: str,
+        params: dict[str, Any] | None = None,
+    ) -> Any:
+        payload: dict[str, Any] = {
+            "jsonrpc": "2.0",
+            "id": self._next_id(),
+            "method": method,
+        }
+
+        if params is not None:
+            payload["params"] = params
+
+        response = await self._post(payload)
+
+        return self._extract_result(response)
+
+    async def call_tool(
+        self,
+        name: str,
+        arguments: dict[str, Any] | None = None,
+    ) -> Any:
+        return await self._request(
+            "tools/call",
+            {
+                "name": name,
+                "arguments": arguments or {},
+            },
+        )
+
+    async def find_customer_by_phone(
+        self,
+        phone: str,
+    ) -> Any:
+        """
+        Kundensuche über mehrere deterministische Telefonformate.
+
+        Kontor kann Telefonnummern je nach Datenbestand unterschiedlich
+        gespeichert haben. Wir versuchen deshalb E.164, national,
+        ohne führende 0 und ohne Plus/Formatierung.
+
+        Es wird niemals fuzzy über Telefonnummern gesucht.
+        """
+
+        variants = self._phone_variants(phone)
+
+        matches: list[tuple[str, Any]] = []
+
+        for variant in variants:
+            result = await self.call_tool(
+                "find_customer_by_phone",
+                {"phone": variant},
+            )
+
+            # Nur tatsächlich gefundene Treffer sammeln.
+            if self._phone_result_found(result):
+                matches.append((variant, result))
+
+        if not matches:
+            return {
+                "status": "not_found",
+                "input": phone,
+                "variants_tried": variants,
+            }
+
+        # Doppelte Treffer desselben Kunden zusammenführen.
+        unique: dict[str, tuple[str, Any]] = {}
+
+        for variant, result in matches:
+            customer_id = self._customer_identity(result)
+
+            if customer_id is None:
+                # Ohne ID nicht künstlich zusammenführen.
+                unique[f"{variant}:{len(unique)}"] = (
+                    variant,
+                    result,
+                )
+            else:
+                unique[str(customer_id)] = (
+                    variant,
+                    result,
+                )
+
+        if len(unique) > 1:
+            return {
+                "status": "ambiguous",
+                "input": phone,
+                "variants_tried": variants,
+                "matches": [
+                    {
+                        "matched_input": variant,
+                        "result": result,
+                    }
+                    for variant, result in unique.values()
+                ],
+            }
+
+        variant, result = next(iter(unique.values()))
+
+        # Bestehenden MCP-Response ergänzen, statt ihn zu verlieren.
+        if isinstance(result, dict):
+            result = dict(result)
+            result["matched_input"] = variant
+            result["input"] = phone
+            result["phone_variants_tried"] = variants
+
+        return result
+
+    @staticmethod
+    def _phone_variants(phone: str) -> list[str]:
+        import re
+
+        raw = str(phone or "").strip()
+
+        if not raw:
+            return []
+
+        digits = re.sub(r"\D", "", raw)
+
+        if not digits:
+            return []
+
+        variants: list[str] = []
+
+        def add(value: str) -> None:
+            if value and value not in variants:
+                variants.append(value)
+
+        # Bereits vorhandene Schreibweise zuerst.
+        add(raw)
+
+        # Deutsche Mobil-/Festnetznummern.
+        if digits.startswith("49"):
+            national = "0" + digits[2:]
+            subscriber = digits[2:]
+
+            add("+" + digits)
+            add(digits)
+            add(national)
+            add(subscriber)
+
+        elif digits.startswith("0"):
+            subscriber = digits[1:]
+
+            add(digits)
+            add("+49" + subscriber)
+            add("49" + subscriber)
+            add(subscriber)
+
+        else:
+            # Für bereits international gespeicherte Nummern
+            # ohne +.
+            add("+49" + digits)
+            add("49" + digits)
+            add("0" + digits)
+            add(digits)
+
+        return variants
+
+    @staticmethod
+    def _phone_result_found(result: Any) -> bool:
+        if not isinstance(result, dict):
+            return False
+
+        status = result.get("status")
+
+        if status in {
+            "exact",
+            "high_confidence",
+        }:
+            return True
+
+        if result.get("found") is True:
+            return True
+
+        # Manche MCP-Responses liefern den Kunden direkt.
+        return bool(
+            result.get("customer_id")
+            or result.get("customer_number")
+        )
+
+    @staticmethod
+    def _customer_identity(result: Any) -> Any:
+        if not isinstance(result, dict):
+            return None
+
+        return (
+            result.get("customer_id")
+            or result.get("customer_number")
+            or result.get("id")
+        )
+
+    async def find_customer_by_email(
+        self,
+        email: str,
+    ) -> Any:
+        return await self.call_tool(
+            "find_customer_by_email",
+            {"email": email},
+        )
+
+    async def find_customer_by_number(
+        self,
+        customer_number: str,
+    ) -> Any:
+        return await self.call_tool(
+            "find_customer_by_number",
+            {"customer_number": customer_number},
+        )
+
+    async def search_products(
+        self,
+        query: str,
+    ) -> Any:
+        return await self.call_tool(
+            "search_products",
+            {"query": query},
+        )
+
+    async def get_product(
+        self,
+        product_id: str,
+    ) -> Any:
+        return await self.call_tool(
+            "get_product",
+            {"product_id": product_id},
+        )
+
+    async def get_product_categories(self) -> Any:
+        return await self.call_tool(
+            "get_product_categories",
+            {},
+        )
+
+    async def get_order(
+        self,
+        order_number: str,
+    ) -> Any:
+        return await self.call_tool(
+            "get_order",
+            {"order_number": order_number},
+        )
+
+    async def get_customer_context(
+        self,
+        *,
+        phone: str | None = None,
+        customer_number: str | None = None,
+    ) -> Any:
+        arguments: dict[str, Any] = {}
+
+        if phone:
+            arguments["phone"] = phone
+
+        if customer_number:
+            arguments["customer_number"] = customer_number
+
+        return await self.call_tool(
+            "get_customer_context",
+            arguments,
+        )
+
+    async def get_order_context(
+        self,
+        order_number: str,
+    ) -> Any:
+        return await self.call_tool(
+            "get_order_context",
+            {"order_number": order_number},
+        )
+
+    async def get_product_context(
+        self,
+        query: str | None = None,
+        product_id: str | None = None,
+    ) -> Any:
+        arguments: dict[str, Any] = {}
+
+        if query:
+            arguments["query"] = query
+
+        if product_id:
+            arguments["product_id"] = product_id
+
+        return await self.call_tool(
+            "get_product_context",
+            arguments,
+        )

+ 435 - 0
app/kontor_resolver.py

@@ -0,0 +1,435 @@
+from __future__ import annotations
+
+import json
+import re
+from dataclasses import dataclass
+from typing import Any
+
+from app.kontor_mcp import KontorMCPClient
+
+
+@dataclass
+class Resolution:
+    status: str
+    value: str | None = None
+    source: str | None = None
+    confidence: float | None = None
+    candidates: list[Any] | None = None
+    data: dict[str, Any] | None = None
+
+
+_NUMBER_WORDS = {
+    "null": "0",
+    "eins": "1",
+    "ein": "1",
+    "zwei": "2",
+    "drei": "3",
+    "vier": "4",
+    "fünf": "5",
+    "fuenf": "5",
+    "sechs": "6",
+    "sieben": "7",
+    "acht": "8",
+    "neun": "9",
+}
+
+
+def unwrap_tool_result(result: Any) -> dict[str, Any]:
+    """
+    MCP tools/call liefert:
+      structuredContent.result = JSON-String
+
+    Fallback:
+      content[0].text = JSON-String
+    """
+    if not isinstance(result, dict):
+        return {}
+
+    structured = result.get("structuredContent")
+
+    if isinstance(structured, dict):
+        raw = structured.get("result")
+
+        if isinstance(raw, dict):
+            return raw
+
+        if isinstance(raw, str):
+            try:
+                data = json.loads(raw)
+                if isinstance(data, dict):
+                    return data
+            except json.JSONDecodeError:
+                pass
+
+    content = result.get("content")
+
+    if isinstance(content, list):
+        for item in content:
+            if not isinstance(item, dict):
+                continue
+
+            raw = item.get("text")
+
+            if not isinstance(raw, str):
+                continue
+
+            try:
+                data = json.loads(raw)
+                if isinstance(data, dict):
+                    return data
+            except json.JSONDecodeError:
+                continue
+
+    return {}
+
+
+def normalize_number_candidate(value: str) -> str | None:
+    """
+    Normalisiert mögliche Kunden-/Bestellnummern.
+
+    Wichtig:
+    Eine beliebige Textfolge wird NICHT automatisch numerisch.
+    """
+
+    text = value.strip().lower()
+
+    if not text:
+        return None
+
+    # Reine Ziffern
+    if re.fullmatch(r"\d{3,20}", text):
+        return text
+
+    # Zahlen mit typischen gesprochenen Trennzeichen
+    compact = re.sub(r"[\s.,/-]+", "", text)
+
+    if re.fullmatch(r"\d{3,20}", compact):
+        return compact
+
+    # Gesprochene einzelne Ziffern
+    tokens = re.findall(r"[a-zäöü]+", text)
+
+    if not tokens:
+        return None
+
+    digits: list[str] = []
+
+    for token in tokens:
+        digit = _NUMBER_WORDS.get(token)
+
+        if digit is None:
+            return None
+
+        digits.append(digit)
+
+    result = "".join(digits)
+
+    if 3 <= len(result) <= 20:
+        return result
+
+    return None
+
+
+async def resolve_customer_number(
+    client: KontorMCPClient,
+    raw_value: str,
+) -> Resolution:
+    number = normalize_number_candidate(raw_value)
+
+    if not number:
+        return Resolution(
+            status="invalid",
+            source="normalizer",
+        )
+
+    raw = await client.find_customer_by_number(number)
+    result = unwrap_tool_result(raw)
+
+    return Resolution(
+        status=result.get("status", "unknown"),
+        value=number,
+        source="kontor_mcp",
+        confidence=result.get("confidence"),
+        candidates=result.get("candidates", []),
+        data=result,
+    )
+
+
+async def resolve_order_number(
+    client: KontorMCPClient,
+    raw_value: str,
+) -> Resolution:
+    number = normalize_number_candidate(raw_value)
+
+    if not number:
+        return Resolution(
+            status="invalid",
+            source="normalizer",
+        )
+
+    raw = await client.get_order_context(number)
+    result = unwrap_tool_result(raw)
+
+    status = result.get("status", "unknown")
+
+    if result.get("found") is False:
+        status = "not_found"
+
+    return Resolution(
+        status=status,
+        value=number,
+        source="kontor_mcp",
+        confidence=result.get("confidence"),
+        data=result,
+    )
+
+
+async def resolve_product(
+    client: KontorMCPClient,
+    raw_value: str,
+) -> Resolution:
+    text = raw_value.strip()
+
+    if not text:
+        return Resolution(
+            status="invalid",
+            source="normalizer",
+        )
+
+    raw = await client.search_products(text)
+    result = unwrap_tool_result(raw)
+
+    status = result.get("status", "unknown")
+    candidates = result.get("matches", [])
+
+    value = None
+    confidence = None
+
+    if status in {"exact", "high_confidence"} and candidates:
+        first = candidates[0]
+
+        if isinstance(first, dict):
+            value = first.get("name")
+            confidence = first.get("score")
+
+    return Resolution(
+        status=status,
+        value=value,
+        source="kontor_mcp",
+        confidence=confidence,
+        candidates=candidates,
+        data=result,
+    )
+
+async def resolve_product_with_context(
+    client: KontorMCPClient,
+    raw_value: str,
+    order_data: dict[str, Any] | None = None,
+) -> Resolution:
+    """
+    Produktauflösung mit Bestellkontext.
+
+    Priorität:
+      1. exakter Produkt-ID-Match
+      2. konservativer Namensmatch gegen Bestellartikel
+      3. globales MCP-Ergebnis
+
+    Ein Bestellartikel darf einen globalen Treffer ersetzen,
+    wenn der Gesprächsbegriff eindeutig auf genau einen Artikel
+    der bekannten Bestellung passt.
+    """
+
+    global_resolution = await resolve_product(
+        client,
+        raw_value,
+    )
+
+    if not order_data:
+        return global_resolution
+
+    order = order_data.get("order", {})
+    items = order.get("items", [])
+
+    if not isinstance(items, list):
+        return global_resolution
+
+    candidates = global_resolution.candidates or []
+
+    # ------------------------------------------------------------
+    # 1. Harte Produkt-ID-Übereinstimmung
+    # ------------------------------------------------------------
+    candidate_ids = {
+        item.get("product_id")
+        for item in candidates
+        if isinstance(item, dict)
+        and item.get("product_id") is not None
+    }
+
+    id_matches = [
+        item
+        for item in items
+        if isinstance(item, dict)
+        and item.get("product_id") in candidate_ids
+    ]
+
+    if len(id_matches) == 1:
+        item = id_matches[0]
+
+        return Resolution(
+            status="high_confidence",
+            value=item.get("name"),
+            source="kontor_mcp_order_context",
+            confidence=0.98,
+            candidates=[item],
+            data={
+                "resolution": "order_context_product_id",
+                "order_item": item,
+                "global_candidates": candidates,
+            },
+        )
+
+    # ------------------------------------------------------------
+    # 2. Konservativer Textmatch
+    # ------------------------------------------------------------
+    def tokens(value: str) -> set[str]:
+        value = value.lower()
+
+        value = re.sub(
+            r"[·•|,/()\-]+",
+            " ",
+            value,
+        )
+
+        ignored = {
+            "cl",
+            "container",
+            "wurzelnackt",
+            "wurzel",
+            "topf",
+            "liter",
+            "l",
+            "stk",
+            "stück",
+        }
+
+        return {
+            token
+            for token in re.sub(
+                r"[^a-z0-9äöüß]+",
+                " ",
+                value,
+            ).split()
+            if len(token) >= 4
+            and token not in ignored
+        }
+
+    query_tokens = tokens(raw_value)
+
+    # Allgemeine Begriffe reichen niemals alleine für eine
+    # kontextuelle automatische Zuordnung.
+    generic = {
+        "rose",
+        "rosen",
+        "clematis",
+        "dünger",
+        "duenger",
+        "erde",
+        "sack",
+        "pflanze",
+    }
+
+    meaningful = query_tokens - generic
+
+    if meaningful:
+        from difflib import SequenceMatcher
+
+        matches: list[tuple[float, dict[str, Any]]] = []
+
+        for item in items:
+            if not isinstance(item, dict):
+                continue
+
+            name = str(item.get("name") or "")
+            item_tokens = tokens(name)
+
+            if not item_tokens:
+                continue
+
+            total = 0.0
+            valid = True
+
+            for query_token in meaningful:
+                best = 0.0
+
+                for item_token in item_tokens:
+                    if query_token == item_token:
+                        best = 1.0
+                        break
+
+                    # einfache Flexionsvariante:
+                    # Royal ↔ Royale
+                    if (
+                        query_token.rstrip("e")
+                        == item_token.rstrip("e")
+                        and len(query_token) >= 5
+                    ):
+                        best = max(best, 0.95)
+                        continue
+
+                    best = max(
+                        best,
+                        SequenceMatcher(
+                            None,
+                            query_token,
+                            item_token,
+                        ).ratio(),
+                    )
+
+                if best < 0.86:
+                    valid = False
+                    break
+
+                total += best
+
+            if valid:
+                matches.append(
+                    (
+                        total / len(meaningful),
+                        item,
+                    )
+                )
+
+        # Nur genau einen Artikel automatisch übernehmen.
+        unique: dict[tuple[Any, Any], tuple[float, dict[str, Any]]] = {}
+
+        for score, item in matches:
+            key = (
+                item.get("product_id"),
+                item.get("name"),
+            )
+            unique[key] = (score, item)
+
+        if len(unique) == 1:
+            score, item = next(
+                iter(unique.values())
+            )
+
+            return Resolution(
+                status="high_confidence",
+                value=item.get("name"),
+                source="kontor_mcp_order_context_name",
+                confidence=min(0.96, max(0.90, score)),
+                candidates=[item],
+                data={
+                    "resolution": "order_context_name",
+                    "order_item": item,
+                    "global_candidates": candidates,
+                    "match_score": score,
+                },
+            )
+
+    # ------------------------------------------------------------
+    # 3. Keine sichere Kontextauflösung:
+    #    globales Ergebnis unverändert zurückgeben.
+    # ------------------------------------------------------------
+    return global_resolution

+ 683 - 0
app/main.py

@@ -0,0 +1,683 @@
+from datetime import datetime, timezone
+import hashlib
+import httpx
+import logging
+import secrets
+import json
+
+from contextlib import asynccontextmanager
+from fastapi import FastAPI, Header, HTTPException, Query
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import JSONResponse
+from pydantic import BaseModel
+from fastapi import Query
+from .phone import normalize
+from .calls import normalize_call
+
+from .config import Settings
+from .service import TelephonyService
+
+settings = Settings()
+service = TelephonyService(settings)
+
+
+class ResolveRequest(BaseModel):
+    until: str
+    by: str
+    note: str | None = None
+
+
+class ReopenRequest(BaseModel):
+    by: str
+
+
+def problem(status, title, detail, field=None, retryable=False):
+    body = {
+        "type": "about:blank",
+        "title": title,
+        "status": status,
+        "detail": detail,
+        "retryable": retryable,
+    }
+    if field:
+        body["field"] = field
+
+    return JSONResponse(
+        status_code=status,
+        content=body,
+        media_type="application/problem+json",
+    )
+
+
+def authorized(authorization):
+    if not authorization:
+        return False
+
+    scheme, _, token = authorization.partition(" ")
+
+    return (
+        scheme.lower() == "bearer"
+        and secrets.compare_digest(
+            token,
+            settings.middleware_api_token,
+        )
+    )
+
+
+@asynccontextmanager
+async def lifespan(app):
+    logging.basicConfig(
+        level=getattr(
+            logging,
+            settings.log_level.upper(),
+            logging.INFO,
+        ),
+        format="%(asctime)s %(levelname)s %(name)s: %(message)s",
+    )
+
+    await service.start()
+    yield
+
+
+class OutboundCallRequest(BaseModel):
+    sourceDn: str
+    destination: str
+    timeoutSec: int = 30
+
+
+
+app = FastAPI(
+    title="3CX Telefonie Middleware",
+    version="0.2.0",
+    lifespan=lifespan,
+)
+
+app.add_middleware(
+    CORSMiddleware,
+    allow_origins=[
+        x.strip()
+        for x in settings.cors_origins.split(",")
+        if x.strip()
+    ],
+    allow_credentials=False,
+    allow_methods=["GET", "POST", "OPTIONS"],
+    allow_headers=[
+        "Authorization",
+        "Content-Type",
+        "Idempotency-Key",
+    ],
+)
+
+
+@app.get("/phone/normalize")
+async def phone_normalize(
+    number: str = Query(..., min_length=1),
+    country: str = Query("DE", min_length=2, max_length=2),
+):
+    return normalize(number, country.upper())
+
+
+
+@app.get("/calls/active")
+async def active_calls():
+    queue_dn = settings.threecx_queue_dn
+    participants = service.get_active_participants()
+
+    data = [
+        normalize_call(participant, queue_dn)
+        for participant in participants
+        if participant.get("party_dn_type") == "Wexternalline"
+    ]
+
+    return {
+        "data": data,
+        "meta": {
+            "totalItems": len(data)
+        }
+    }
+
+
+
+
+@app.post("/calls/outbound")
+async def outbound_call(
+    request: OutboundCallRequest,
+    authorization: str | None = Header(default=None),
+    idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
+):
+    if not authorized(authorization):
+        return problem(
+            401,
+            "Unauthorized",
+            "Bearer-Token fehlt oder ist ungültig.",
+            retryable=False,
+        )
+
+    if not idempotency_key or not idempotency_key.strip():
+        return problem(
+            400,
+            "Idempotency-Key fehlt",
+            "Für ausgehende Anrufe ist ein Idempotency-Key erforderlich.",
+            retryable=False,
+        )
+
+    idempotency_key = idempotency_key.strip()
+
+    if len(idempotency_key) > 200:
+        return problem(
+            400,
+            "Idempotency-Key ungültig",
+            "Der Idempotency-Key darf maximal 200 Zeichen lang sein.",
+            retryable=False,
+        )
+
+    try:
+        dn = request.sourceDn.strip()
+
+        if not dn.isdigit():
+            return problem(
+                422,
+                "Ungültige Nebenstelle",
+                "sourceDn muss eine numerische 3CX-Nebenstelle sein.",
+                retryable=False,
+            )
+
+        if dn not in settings.monitored_dns:
+            return problem(
+                403,
+                "Nebenstelle nicht freigegeben",
+                f"Die Nebenstelle {dn} ist für ausgehende API-Anrufe nicht freigegeben.",
+                retryable=False,
+            )
+
+        raw_destination = request.destination.strip()
+
+        # 1-4-stellige numerische Ziele sind interne 3CX-Nebenstellen.
+        if raw_destination.isdigit() and 1 <= len(raw_destination) <= 4:
+            normalized = {
+                "raw": raw_destination,
+                "e164": raw_destination,
+                "valid": True,
+                "type": "EXTENSION",
+            }
+        else:
+            normalized = normalize(
+                raw_destination,
+                settings.phone_default_region,
+            )
+
+            if not normalized.get("valid"):
+                return problem(
+                    422,
+                    "Ungültige Rufnummer",
+                    "Die Zielrufnummer konnte nicht gültig normalisiert werden.",
+                    retryable=False,
+                )
+
+        request_fingerprint = hashlib.sha256(
+            json.dumps(
+                {
+                    "sourceDn": dn,
+                    "destination": normalized.get("e164"),
+                    "timeoutSec": request.timeoutSec,
+                },
+                sort_keys=True,
+                separators=(",", ":"),
+            ).encode("utf-8")
+        ).hexdigest()
+
+        existing = await service.repo.get_outbound_idempotency(idempotency_key)
+
+        if existing:
+            if existing["request_hash"] != request_fingerprint:
+                return problem(
+                    409,
+                    "Idempotency-Key bereits verwendet",
+                    "Der Idempotency-Key wurde bereits für eine andere Anfrage verwendet.",
+                    retryable=False,
+                )
+
+            if existing["status"] == "completed" and existing["response_json"]:
+                return json.loads(existing["response_json"])
+
+            return problem(
+                409,
+                "Anruf wird bereits verarbeitet",
+                "Für diesen Idempotency-Key läuft bereits ein Anrufvorgang.",
+                retryable=True,
+            )
+
+        claimed = await service.repo.claim_outbound_idempotency(
+            idempotency_key,
+            request_fingerprint,
+            datetime.now(timezone.utc).isoformat(),
+        )
+
+        if not claimed:
+            existing = await service.repo.get_outbound_idempotency(idempotency_key)
+
+            if existing and existing["request_hash"] != request_fingerprint:
+                return problem(
+                    409,
+                    "Idempotency-Key bereits verwendet",
+                    "Der Idempotency-Key wurde bereits für eine andere Anfrage verwendet.",
+                    retryable=False,
+                )
+
+            return problem(
+                409,
+                "Anruf wird bereits verarbeitet",
+                "Für diesen Idempotency-Key läuft bereits ein Anrufvorgang.",
+                retryable=True,
+            )
+
+        dn_data = await service.cx.get_dn(dn)
+        devices = dn_data.get("devices") or []
+
+        if len(devices) == 0:
+            return problem(
+                409,
+                "Kein Gerät gefunden",
+                f"Für Nebenstelle {dn} wurde kein 3CX-Gerät gefunden.",
+                retryable=True,
+            )
+
+        hint = settings.outbound_device_hints.get(dn)
+
+        if not hint:
+            return problem(
+                409,
+                "Kein Hardware-Gerät konfiguriert",
+                f"Für Nebenstelle {dn} ist kein Hardware-Telefon für ausgehende API-Anrufe konfiguriert.",
+                retryable=False,
+            )
+
+        matching_devices = [
+            device
+            for device in devices
+            if hint.lower() in str(device.get("user_agent", "")).lower()
+        ]
+
+        if len(matching_devices) == 0:
+            return problem(
+                409,
+                "Hardware-Gerät nicht gefunden",
+                f"Für Nebenstelle {dn} wurde kein 3CX-Gerät passend zu '{hint}' gefunden.",
+                retryable=True,
+            )
+
+        if len(matching_devices) > 1:
+            return problem(
+                409,
+                "Hardware-Gerät nicht eindeutig",
+                f"Für Nebenstelle {dn} wurden mehrere Geräte passend zu '{hint}' gefunden.",
+                retryable=False,
+            )
+
+        device = matching_devices[0]
+        device_id = (
+            device.get("device_id")
+            or device.get("deviceId")
+            or device.get("id")
+        )
+
+        if not device_id:
+            return problem(
+                502,
+                "Ungültige 3CX-Geräteantwort",
+                "3CX hat kein verwendbares Gerätekennzeichen geliefert.",
+                retryable=True,
+            )
+
+        result = await service.cx.make_call(
+            dn=dn,
+            device_id=str(device_id),
+            destination=normalized["e164"],
+            timeout_sec=request.timeoutSec,
+        )
+
+        response = {
+            "status": "accepted",
+            "sourceDn": dn,
+            "deviceId": str(device_id),
+            "destination": {
+                "raw": normalized.get("raw"),
+                "e164": normalized.get("e164"),
+                "valid": normalized.get("valid"),
+                "type": normalized.get("type"),
+            },
+            "threecx": result,
+        }
+
+        threecx_result = result.get("result") or result
+        threecx_callid = threecx_result.get("callid")
+        threecx_legid = threecx_result.get("legid")
+
+        if threecx_callid is not None:
+            started_at = datetime.now(timezone.utc).isoformat()
+
+            await service.repo.insert_outbound_call(
+                callid=threecx_callid,
+                legid=threecx_legid,
+                source_dn=dn,
+                destination=normalized.get("e164"),
+                phone=(
+                    normalized.get("e164")
+                    if normalized.get("type") != "EXTENSION"
+                    else None
+                ),
+                raw=normalized.get("raw"),
+                started_at=started_at,
+            )
+
+        await service.repo.finish_outbound_idempotency(
+            idempotency_key,
+            json.dumps(response, ensure_ascii=False, separators=(",", ":")),
+        )
+
+        return response
+
+    except httpx.HTTPStatusError as exc:
+        await service.repo.release_outbound_idempotency(idempotency_key)
+        status = exc.response.status_code
+
+        if status in (401, 403):
+            return problem(
+                502,
+                "3CX-Authentifizierung fehlgeschlagen",
+                "Die Middleware konnte den 3CX-Anruf nicht authentifizieren.",
+                retryable=False,
+            )
+
+        if status == 404:
+            return problem(
+                502,
+                "3CX-Gerät oder Ziel nicht gefunden",
+                "3CX hat die angeforderte Ressource nicht gefunden.",
+                retryable=False,
+            )
+
+        return problem(
+            502,
+            "3CX-Anruf fehlgeschlagen",
+            f"3CX hat HTTP {status} zurückgegeben.",
+            retryable=True,
+        )
+
+    except Exception:
+        await service.repo.release_outbound_idempotency(idempotency_key)
+        log.exception("Outbound call failed")
+        return problem(
+            502,
+            "Ausgehender Anruf fehlgeschlagen",
+            "Der Anruf konnte über 3CX nicht ausgelöst werden.",
+            retryable=True,
+        )
+
+@app.get("/calls/cdr")
+async def calls_cdr(
+    from_: str = Query(..., alias="from"),
+    to: str = Query(...),
+    limit: int = Query(500, ge=1, le=1000),
+    offset: int = Query(0, ge=0),
+    authorization: str | None = Header(default=None),
+):
+    if not authorized(authorization):
+        return problem(
+            401,
+            "Unauthorized",
+            "Bearer-Token fehlt oder ist ungültig.",
+            retryable=False,
+        )
+
+    try:
+        data = await service.cx.get_call_log(
+            period_from=from_,
+            period_to=to,
+            top=limit,
+            skip=offset,
+        )
+
+        return {
+            "data": data.get("value", []),
+            "meta": {
+                "totalItems": data.get("@odata.count"),
+                "limit": limit,
+                "offset": offset,
+            },
+        }
+
+    except Exception:
+        log.exception("3CX CDR query failed")
+        return problem(
+            502,
+            "3CX CDR-Abfrage fehlgeschlagen",
+            "Die Call-History konnte von 3CX nicht abgerufen werden.",
+            retryable=True,
+        )
+
+@app.get("/calls/recording/{rec_id}")
+async def calls_recording(
+    rec_id: int,
+    authorization: str | None = Header(default=None),
+):
+    if not authorized(authorization):
+        return problem(
+            401,
+            "Unauthorized",
+            "Bearer-Token fehlt oder ist ungültig.",
+            retryable=False,
+        )
+
+    try:
+        from fastapi.responses import Response
+
+        content, content_type = await service.cx.download_recording(rec_id)
+
+        return Response(
+            content=content,
+            media_type=content_type.split(";", 1)[0],
+            headers={
+                "Content-Disposition": (
+                    f'attachment; filename="recording-{rec_id}.wav"'
+                )
+            },
+        )
+
+    except Exception:
+        log.exception(
+            "3CX recording download failed: rec_id=%s",
+            rec_id,
+        )
+        return problem(
+            502,
+            "Recording konnte nicht geladen werden",
+            "Das 3CX-Recording konnte nicht abgerufen werden.",
+            retryable=True,
+        )
+
+@app.get("/calls/{call_id}")
+async def call_detail(call_id: int):
+    row = await service.repo.get_call(call_id)
+
+    if row is None:
+        return problem(
+            404,
+            "Call nicht gefunden",
+            f"Call {call_id} wurde nicht gefunden.",
+            retryable=False,
+        )
+
+    return row
+
+@app.get("/calls")
+async def calls_history(
+    limit: int = Query(100, ge=1, le=500),
+    offset: int = Query(0, ge=0),
+    status: str | None = Query(None),
+):
+    rows, total = await service.repo.list_calls(
+        limit=limit,
+        offset=offset,
+        status=status,
+    )
+
+    return {
+        "data": rows,
+        "meta": {
+            "totalItems": total,
+            "limit": limit,
+            "offset": offset,
+        }
+    }
+
+@app.get("/health")
+async def health():
+    return {
+        "status": "ok",
+        "queue": settings.threecx_queue_dn,
+        "version": "0.2.0",
+    }
+
+
+@app.get("/missed-calls")
+async def missed_calls():
+    return await service.repo.list_groups()
+
+
+@app.get("/missed-calls/groups")
+async def missed_call_groups():
+    groups = await service.repo.list_groups(open_only=True)
+
+    return {
+        "data": groups,
+        "meta": {
+            "totalItems": len(groups),
+        },
+    }
+
+
+@app.get("/missed-calls/groups/{e164}")
+async def missed_call_group(e164: str):
+    group = await service.repo.get_group(e164)
+
+    if not group:
+        return problem(
+            404,
+            "Not found",
+            "Die Rufnummer wurde nicht gefunden.",
+        )
+
+    return group
+
+
+@app.post("/missed-calls/groups/{e164}/resolve")
+async def resolve_group(
+    e164: str,
+    payload: ResolveRequest,
+    authorization: str | None = Header(default=None),
+    idempotency_key: str | None = Header(default=None),
+):
+    if not authorized(authorization):
+        return problem(
+            401,
+            "Unauthorized",
+            "Bearer-Token fehlt oder ist ungültig.",
+        )
+
+    if not idempotency_key:
+        return problem(
+            400,
+            "Idempotency-Key required",
+            "Idempotency-Key ist erforderlich.",
+            field="Idempotency-Key",
+        )
+
+    previous = await service.repo.get_idempotency(idempotency_key, "resolve")
+    if previous:
+        return JSONResponse(
+            content=json.loads(previous),
+            status_code=200,
+        )
+
+    group = await service.repo.get_group(e164)
+
+    if not group:
+        return problem(
+            404,
+            "Not found",
+            "Die Rufnummer wurde nicht gefunden.",
+        )
+
+    result = await service.repo.resolve(
+        e164,
+        payload.until,
+        payload.by,
+        payload.note,
+    )
+
+    response_json = json.dumps(result)
+    await service.repo.save_idempotency(
+        idempotency_key,
+        "resolve",
+        response_json,
+    )
+
+    return result
+
+
+@app.post("/missed-calls/groups/{e164}/reopen")
+async def reopen_group(
+    e164: str,
+    payload: ReopenRequest,
+    authorization: str | None = Header(default=None),
+    idempotency_key: str | None = Header(default=None),
+):
+    if not authorized(authorization):
+        return problem(
+            401,
+            "Unauthorized",
+            "Bearer-Token fehlt oder ist ungültig.",
+        )
+
+    if not idempotency_key:
+        return problem(
+            400,
+            "Idempotency-Key required",
+            "Idempotency-Key ist erforderlich.",
+            field="Idempotency-Key",
+        )
+
+    previous = await service.repo.get_idempotency(
+        idempotency_key,
+        "reopen",
+    )
+
+    if previous:
+        return JSONResponse(
+            content=json.loads(previous),
+            status_code=200,
+        )
+
+    group = await service.repo.get_group(e164)
+
+    if not group:
+        return problem(
+            404,
+            "Not found",
+            "Die Rufnummer wurde nicht gefunden.",
+        )
+
+    result = await service.repo.reopen(
+        e164,
+        payload.by,
+    )
+
+    response_json = json.dumps(result)
+    await service.repo.save_idempotency(
+        idempotency_key,
+        "reopen",
+        response_json,
+    )
+
+    return result

+ 208 - 0
app/media.py

@@ -0,0 +1,208 @@
+from __future__ import annotations
+
+import time
+from pathlib import Path
+from typing import Any
+
+import httpx
+
+from .config import Settings
+
+
+class ThreeCXMediaClient:
+    """
+    Separater 3CX-Client für Media/RoutePoint.
+
+    Token wird gecacht, bei HTTP 401 aber sofort verworfen
+    und einmalig neu bezogen.
+    """
+
+    def __init__(self, settings: Settings):
+        self.s = settings
+        self._token: str | None = None
+        self._expires_at = 0.0
+
+    async def token(self, force: bool = False) -> str:
+        now = time.time()
+
+        if (
+            not force
+            and self._token
+            and now < self._expires_at - 60
+        ):
+            return self._token
+
+        key = Path(
+            self.s.threecx_media_api_key
+        ).read_text().strip()
+
+        async with httpx.AsyncClient(
+            verify=self.s.threecx_verify_tls,
+            timeout=15,
+        ) as client:
+            response = await client.post(
+                f"{self.s.threecx_base_url}/connect/token",
+                data={
+                    "client_id": self.s.threecx_media_client_id,
+                    "client_secret": key,
+                    "grant_type": "client_credentials",
+                },
+            )
+
+        response.raise_for_status()
+
+        data = response.json()
+
+        self._token = data["access_token"]
+
+        # Nicht blind auf 3600 Sekunden verlassen.
+        expires_in = int(data.get("expires_in", 300))
+
+        self._expires_at = (
+            time.time() + max(30, expires_in)
+        )
+
+        return self._token
+
+    def invalidate_token(self) -> None:
+        self._token = None
+        self._expires_at = 0.0
+
+    async def participants(
+        self,
+        dn: str | None = None,
+    ) -> list[dict[str, Any]]:
+
+        dn = (
+            dn
+            or self.s.threecx_media_routepoint_dn
+        )
+
+        for attempt in range(2):
+            token = await self.token(
+                force=(attempt == 1)
+            )
+
+            async with httpx.AsyncClient(
+                verify=self.s.threecx_verify_tls,
+                timeout=15,
+            ) as client:
+
+                response = await client.get(
+                    f"{self.s.threecx_base_url}"
+                    f"/callcontrol/{dn}/participants",
+                    headers={
+                        "Authorization": f"Bearer {token}",
+                        "Accept": "application/json",
+                    },
+                )
+
+            if response.status_code == 401:
+                self.invalidate_token()
+                continue
+
+            response.raise_for_status()
+
+            data = response.json()
+
+            if isinstance(data, list):
+                return data
+
+            return data.get(
+                "participants",
+                [],
+            )
+
+        raise RuntimeError(
+            "3CX participants: "
+            "Authentication nach Token-Refresh "
+            "weiterhin abgelehnt"
+        )
+
+    async def stream(
+        self,
+        participant_id: int | str,
+        output: Path,
+        dn: str | None = None,
+        max_bytes: int | None = None,
+    ) -> int:
+
+        dn = (
+            dn
+            or self.s.threecx_media_routepoint_dn
+        )
+
+        output.parent.mkdir(
+            parents=True,
+            exist_ok=True,
+        )
+
+        for attempt in range(2):
+            token = await self.token(
+                force=(attempt == 1)
+            )
+
+            received = 0
+
+            try:
+                async with httpx.AsyncClient(
+                    verify=self.s.threecx_verify_tls,
+                    timeout=None,
+                ) as client:
+
+                    async with client.stream(
+                        "GET",
+                        (
+                            f"{self.s.threecx_base_url}"
+                            f"/callcontrol/{dn}"
+                            f"/participants/{participant_id}"
+                            f"/stream"
+                        ),
+                        headers={
+                            "Authorization":
+                                f"Bearer {token}",
+                            "Accept":
+                                "application/octet-stream",
+                        },
+                    ) as response:
+
+                        if response.status_code == 401:
+                            self.invalidate_token()
+
+                            # Stream wurde noch nicht
+                            # verarbeitet → einmal neu versuchen.
+                            if attempt == 0:
+                                continue
+
+                        response.raise_for_status()
+
+                        with output.open("wb") as file:
+                            async for chunk in response.aiter_bytes(
+                                8192
+                            ):
+                                file.write(chunk)
+                                received += len(chunk)
+
+                                if (
+                                    max_bytes is not None
+                                    and received >= max_bytes
+                                ):
+                                    return received
+
+                return received
+
+            except httpx.HTTPStatusError as exc:
+                if (
+                    exc.response.status_code == 401
+                    and attempt == 0
+                ):
+                    self.invalidate_token()
+                    continue
+
+                raise
+
+        raise RuntimeError(
+            "3CX Media Stream: "
+            "Authentication nach Token-Refresh "
+            "weiterhin abgelehnt"
+        )

+ 239 - 0
app/media_manager.py

@@ -0,0 +1,239 @@
+from __future__ import annotations
+
+import asyncio
+import logging
+from typing import Any
+
+from .config import Settings
+from .media import ThreeCXMediaClient
+from .media_session import MediaSession
+from .whisper import WhisperWorker
+
+
+logger = logging.getLogger(__name__)
+
+
+class MediaManager:
+
+    def __init__(self, settings: Settings):
+        self.settings = settings
+
+        self.media = ThreeCXMediaClient(settings)
+        self.whisper = WhisperWorker(settings)
+
+        self.session = MediaSession(
+            settings,
+            self.media,
+            self.whisper,
+            on_transcript=self._on_transcript,
+        )
+
+        self._running = False
+        self._poll_task: asyncio.Task | None = None
+
+        self._active: dict[str, asyncio.Task] = {}
+        self._completed: set[str] = set()
+
+        # 3CX hat aktuell 8 SC.
+        # Maximal 8 MediaSessions dürfen gleichzeitig laufen.
+        self._session_semaphore = asyncio.Semaphore(8)
+
+    async def start(self) -> None:
+        if self._running:
+            return
+
+        self._running = True
+
+        logger.info(
+            "MediaManager startet für RoutePoint %s",
+            self.settings.threecx_media_routepoint_dn,
+        )
+
+        logger.info("Lade Whisper-Modell ...")
+
+        await asyncio.to_thread(
+            self.whisper.start
+        )
+
+        logger.info("Whisper-Modell geladen")
+
+        self._poll_task = asyncio.create_task(
+            self._poll_loop()
+        )
+
+    async def stop(self) -> None:
+        self._running = False
+
+        if self._poll_task:
+            self._poll_task.cancel()
+
+            try:
+                await self._poll_task
+            except asyncio.CancelledError:
+                pass
+
+            self._poll_task = None
+
+        tasks = list(self._active.values())
+
+        if tasks:
+            await asyncio.gather(
+                *tasks,
+                return_exceptions=True,
+            )
+
+        self._active.clear()
+        self._completed.clear()
+
+        logger.info("MediaManager gestoppt")
+
+    async def _poll_loop(self) -> None:
+        while self._running:
+            try:
+                await self._poll_once()
+
+            except asyncio.CancelledError:
+                raise
+
+            except Exception:
+                logger.exception(
+                    "Fehler im MediaManager-Poll"
+                )
+
+            await asyncio.sleep(1)
+
+    async def _poll_once(self) -> None:
+        participants = await self.media.participants()
+
+        current_keys: set[str] = set()
+
+        for participant in participants:
+            key = self._participant_key(participant)
+            current_keys.add(key)
+
+            if not self._is_processable(participant):
+                continue
+
+            if key in self._active:
+                continue
+
+            if key in self._completed:
+                continue
+
+            logger.info(
+                "Neuer Media-Participant: "
+                "call=%s leg=%s participant=%s",
+                participant.get("callid"),
+                participant.get("legid"),
+                participant.get("id"),
+            )
+
+            task = asyncio.create_task(
+                self._run_session(
+                    participant,
+                    key,
+                )
+            )
+
+            self._active[key] = task
+
+            task.add_done_callback(
+                lambda t, k=key:
+                    self._session_done(k, t)
+            )
+
+        self._completed.intersection_update(
+            current_keys
+        )
+
+    @staticmethod
+    def _is_processable(
+        participant: dict[str, Any],
+    ) -> bool:
+
+        status = str(
+            participant.get("status", "")
+        ).lower()
+
+        return (
+            status == "connected"
+            and participant.get("id") is not None
+            and participant.get("callid") is not None
+        )
+
+    @staticmethod
+    def _participant_key(
+        participant: dict[str, Any],
+    ) -> str:
+
+        return (
+            f"{participant.get('callid')}:"
+            f"{participant.get('legid')}:"
+            f"{participant.get('id')}"
+        )
+
+    async def _run_session(
+        self,
+        participant: dict[str, Any],
+        key: str,
+    ) -> None:
+
+        try:
+            async with self._session_semaphore:
+                result = await self.session.process_participant(
+                    participant,
+                )
+
+            logger.info(
+                "MediaSession abgeschlossen: "
+                "call=%s participant=%s "
+                "audio=%s bytes segments=%s",
+                result["callid"],
+                result["participant_id"],
+                result["audio_bytes"],
+                len(result["segments"]),
+            )
+
+            self._completed.add(key)
+
+        except Exception:
+            logger.exception(
+                "MediaSession fehlgeschlagen: %s",
+                key,
+            )
+
+    async def _on_transcript(
+        self,
+        transcript: dict[str, Any],
+    ) -> None:
+
+        logger.info(
+            "TRANSCRIPT "
+            "call=%s leg=%s participant=%s "
+            "segment=%s text=%r",
+            transcript.get("callid"),
+            transcript.get("legid"),
+            transcript.get("participant_id"),
+            transcript.get("segment"),
+            transcript.get("text"),
+        )
+
+    def _session_done(
+        self,
+        key: str,
+        task: asyncio.Task,
+    ) -> None:
+
+        self._active.pop(key, None)
+
+        try:
+            task.result()
+
+        except asyncio.CancelledError:
+            pass
+
+        except Exception:
+            logger.exception(
+                "MediaSession Task beendet mit Fehler: %s",
+                key,
+            )

+ 288 - 0
app/media_session.py

@@ -0,0 +1,288 @@
+from __future__ import annotations
+
+import asyncio
+import subprocess
+from pathlib import Path
+import logging
+from typing import Any, Awaitable, Callable
+
+from .media import ThreeCXMediaClient
+from .whisper import WhisperWorker
+
+
+logger = logging.getLogger(__name__)
+
+
+TranscriptCallback = Callable[
+    [dict[str, Any]],
+    Awaitable[None],
+]
+
+
+class MediaSession:
+    """
+    Kontinuierliche MediaSession.
+
+    Ein 3CX-Stream bleibt geöffnet.
+    Das PCM wird in Segmente zerlegt und jedes
+    Segment an Whisper übergeben.
+    """
+
+    def __init__(
+        self,
+        settings,
+        media_client: ThreeCXMediaClient,
+        whisper: WhisperWorker,
+        on_transcript: TranscriptCallback | None = None,
+    ):
+        self.settings = settings
+        self.media_client = media_client
+        self.whisper = whisper
+        self.on_transcript = on_transcript
+
+        self.segment_seconds = 5
+
+    async def process_participant(
+        self,
+        participant: dict[str, Any],
+        max_bytes: int | None = None,
+    ) -> dict[str, Any]:
+
+        participant_id = participant["id"]
+        call_id = participant.get("callid")
+        leg_id = participant.get("legid")
+
+        recording_dir = Path(
+            self.settings.media_recording_dir
+        )
+
+        recording_dir.mkdir(
+            parents=True,
+            exist_ok=True,
+        )
+
+        bytes_per_second = (
+            self.settings.media_sample_rate
+            * self.settings.media_channels
+            * 2
+        )
+
+        segment_bytes = int(
+            bytes_per_second
+            * self.segment_seconds
+        )
+
+        result = {
+            "callid": call_id,
+            "legid": leg_id,
+            "participant_id": participant_id,
+            "audio_bytes": 0,
+            "segments": [],
+        }
+
+        # Der 3CX-Client schreibt hier zunächst weiterhin
+        # einen einzelnen PCM-Stream.
+        # Für den kontinuierlichen Betrieb verwenden wir
+        # einen temporären Stream-Puffer.
+        pcm_path = (
+            recording_dir
+            / f"media-{call_id}-{leg_id}-{participant_id}.pcm"
+        )
+
+        token = await self.media_client.token()
+
+        url = (
+            f"{self.settings.threecx_base_url}"
+            f"/callcontrol/"
+            f"{self.settings.threecx_media_routepoint_dn}"
+            f"/participants/{participant_id}/stream"
+        )
+
+        received = 0
+        buffer = bytearray()
+        segment_no = 0
+
+        import httpx
+
+        async with httpx.AsyncClient(
+            verify=self.settings.threecx_verify_tls,
+            timeout=None,
+        ) as client:
+
+            async with client.stream(
+                "GET",
+                url,
+                headers={
+                    "Authorization": f"Bearer {token}",
+                    "Accept": "application/octet-stream",
+                },
+            ) as response:
+
+                response.raise_for_status()
+
+                try:
+                    async for chunk in response.aiter_bytes(8192):
+
+                        buffer.extend(chunk)
+                        received += len(chunk)
+
+                        if max_bytes is not None:
+                            remaining = max_bytes - received
+
+                            if remaining <= 0:
+                                break
+
+                        while len(buffer) >= segment_bytes:
+
+                            segment = bytes(
+                                buffer[:segment_bytes]
+                            )
+
+                            del buffer[:segment_bytes]
+
+                            segment_no += 1
+
+                            transcript = await self._process_segment(
+                                recording_dir,
+                                call_id,
+                                leg_id,
+                                participant_id,
+                                segment_no,
+                                segment,
+                            )
+
+                            result["segments"].append(
+                                transcript
+                            )
+
+                            if self.on_transcript:
+                                await self.on_transcript(
+                                    {
+                                        **transcript,
+                                        "callid": call_id,
+                                        "legid": leg_id,
+                                        "participant_id": participant_id,
+                                    }
+                                )
+
+                        if (
+                            max_bytes is not None
+                            and received >= max_bytes
+                        ):
+                            break
+
+                except httpx.RemoteProtocolError:
+                    # 3CX kann den HTTP-Stream beim Gesprächsende
+                    # ohne vollständige HTTP-Antwort schließen.
+                    # Bereits empfangenes Audio bleibt erhalten.
+                    logger.info(
+                        "3CX Media Stream beendet: "
+                        "call=%s participant=%s bytes=%s",
+                        call_id,
+                        participant_id,
+                        received,
+                    )
+
+        # Restsegment verarbeiten, falls ausreichend Audio
+        # vorhanden ist.
+        if buffer:
+            segment_no += 1
+
+            transcript = await self._process_segment(
+                recording_dir,
+                call_id,
+                leg_id,
+                participant_id,
+                segment_no,
+                bytes(buffer),
+            )
+
+            result["segments"].append(
+                transcript
+            )
+
+        result["audio_bytes"] = received
+
+        return result
+
+    async def _process_segment(
+        self,
+        recording_dir: Path,
+        call_id: Any,
+        leg_id: Any,
+        participant_id: Any,
+        segment_no: int,
+        pcm_data: bytes,
+    ) -> dict[str, Any]:
+
+        pcm_path = (
+            recording_dir
+            / (
+                f"segment-{call_id}-"
+                f"{leg_id}-{participant_id}-"
+                f"{segment_no}.pcm"
+            )
+        )
+
+        wav_path = pcm_path.with_suffix(".wav")
+
+        await asyncio.to_thread(
+            pcm_path.write_bytes,
+            pcm_data,
+        )
+
+        await self._pcm_to_wav(
+            pcm_path,
+            wav_path,
+        )
+
+        transcription = await asyncio.to_thread(
+            self.whisper.transcribe,
+            wav_path,
+        )
+
+        return {
+            "segment": segment_no,
+            "audio_bytes": len(pcm_data),
+            "audio_file": str(wav_path),
+            "text": transcription["text"],
+            "language": transcription["language"],
+            "language_probability":
+                transcription["language_probability"],
+        }
+
+    async def _pcm_to_wav(
+        self,
+        pcm_path: Path,
+        wav_path: Path,
+    ) -> None:
+
+        process = await asyncio.create_subprocess_exec(
+            "ffmpeg",
+            "-y",
+            "-f",
+            "s16le",
+            "-ar",
+            str(self.settings.media_sample_rate),
+            "-ac",
+            str(self.settings.media_channels),
+            "-i",
+            str(pcm_path),
+            "-ar",
+            str(self.settings.media_output_sample_rate),
+            "-ac",
+            "1",
+            "-c:a",
+            "pcm_s16le",
+            str(wav_path),
+            stdout=asyncio.subprocess.DEVNULL,
+            stderr=asyncio.subprocess.PIPE,
+        )
+
+        _, stderr = await process.communicate()
+
+        if process.returncode != 0:
+            raise RuntimeError(
+                "ffmpeg Fehler: "
+                + stderr.decode(errors="replace")
+            )

+ 196 - 0
app/phone.py

@@ -0,0 +1,196 @@
+import re
+
+import phonenumbers
+from phonenumbers import (
+    NumberParseException,
+    PhoneNumberFormat,
+    PhoneNumberType,
+)
+
+
+# Zeichen, die in einer normal formatierten Telefonnummer vorkommen dürfen.
+PHONE_CHARS = re.compile(r"[0-9+()\s./-]")
+
+
+def _extract_phone_candidate(raw: str) -> str | None:
+    """
+    Extrahiert eine Telefonnummer verlustfrei aus einem Rohwert.
+
+    Regeln:
+    - Ziffern werden niemals verworfen.
+    - Übliche Formatierungszeichen werden entfernt.
+    - Ungewöhnliche Zeichen mitten in der Nummer führen NICHT zum
+      vorzeitigen Abbruch, solange danach noch Ziffern folgen.
+    - Alphabetischer Text beendet die Telefonnummer.
+    - Die Originalnummer bleibt in ``raw`` erhalten.
+    """
+    if raw is None:
+        return None
+
+    value = str(raw).strip()
+
+    if not value:
+        return None
+
+    if not value[0].isdigit() and value[0] != "+":
+        return None
+
+    chars = []
+
+    for char in value:
+        if char.isdigit() or char == "+":
+            chars.append(char)
+        elif char in " ()/.-":
+            # Normale Formatierung: ignorieren.
+            continue
+        elif char.isalpha():
+            # Eindeutiger Beginn von Text.
+            break
+        else:
+            # Ungewöhnliches Zeichen wie ^, ´, ` etc.
+            #
+            # NICHT abbrechen!
+            # Solche Zeichen können Tipp-/Formatierungsfehler sein.
+            # Ziffern danach müssen erhalten bleiben.
+            continue
+
+    candidate = "".join(chars)
+
+    if candidate.startswith("+"):
+        candidate = "+" + candidate[1:].replace("+", "")
+    else:
+        candidate = candidate.replace("+", "")
+
+    if not any(char.isdigit() for char in candidate):
+        return None
+
+    return candidate
+
+
+def _looks_international_without_plus(candidate: str) -> bool:
+    """
+    Erkennt eine bereits internationale Nummer ohne führendes '+'.
+
+    Beispiel:
+        4915159207553 -> True
+
+    Lokale deutsche Nummern mit führender 0 bleiben unverändert.
+    """
+    if not candidate or candidate.startswith("+"):
+        return False
+
+    if candidate.startswith("00"):
+        return True
+
+    if candidate.startswith("0"):
+        return False
+
+    # Bekannte internationale Landesvorwahlen aus libphonenumber.
+    try:
+        country_codes = {
+            str(code)
+            for code in phonenumbers.COUNTRY_CODE_TO_REGION_CODE
+        }
+    except AttributeError:
+        country_codes = set()
+
+    return any(
+        candidate.startswith(code)
+        for code in sorted(country_codes, key=len, reverse=True)
+    )
+
+def normalize(raw: str, country: str = "DE") -> dict:
+    country = (country or "DE").upper()
+
+    candidate = _extract_phone_candidate(raw)
+
+    if not candidate:
+        return {
+            "raw": raw,
+            "e164": None,
+            "country": country,
+            "valid": False,
+            "type": "UNKNOWN",
+        }
+
+    try:
+        parse_candidate = candidate
+
+        # Bereits international geschriebene Nummer ohne '+'.
+        # 4915159207553 -> +4915159207553
+        if _looks_international_without_plus(candidate):
+            if candidate.startswith("00"):
+                parse_candidate = "+" + candidate[2:]
+            else:
+                parse_candidate = "+" + candidate
+
+        number = phonenumbers.parse(
+            parse_candidate,
+            None if parse_candidate.startswith("+") else country,
+        )
+
+        # E.164 erlaubt maximal 15 Ziffern inklusive
+        # Landesvorwahl. Eine längere Nummer darf niemals
+        # als gültig zurückgegeben werden.
+        e164_digits = str(number.country_code) + str(
+            number.national_number
+        )
+
+        e164_length_valid = len(e164_digits) <= 15
+
+        valid = (
+            e164_length_valid
+            and phonenumbers.is_possible_number(number)
+            and phonenumbers.is_valid_number(number)
+        )
+
+        e164 = (
+            phonenumbers.format_number(
+                number,
+                PhoneNumberFormat.E164,
+            )
+            if valid
+            else None
+        )
+
+        number_type = phonenumbers.number_type(number)
+
+        # Bei internationaler Schreibweise stammt das Land aus
+        # der tatsächlich erkannten Landesvorwahl und NICHT aus
+        # dem Default-Land der Funktion.
+        detected_region = phonenumbers.region_code_for_number(number)
+
+        result_country = (
+            detected_region
+            or country
+        )
+
+        types = {
+            PhoneNumberType.FIXED_LINE: "FIXED_LINE",
+            PhoneNumberType.MOBILE: "MOBILE",
+            PhoneNumberType.FIXED_LINE_OR_MOBILE:
+                "FIXED_LINE_OR_MOBILE",
+            PhoneNumberType.VOIP: "VOIP",
+            PhoneNumberType.PREMIUM_RATE: "PREMIUM_RATE",
+            PhoneNumberType.TOLL_FREE: "TOLL_FREE",
+        }
+
+        return {
+            "raw": raw,
+            "e164": e164,
+            "country": result_country,
+            "valid": valid,
+            "type": types.get(
+                number_type,
+                "UNKNOWN",
+            ),
+        }
+
+    except NumberParseException:
+        return {
+            "raw": raw,
+            "e164": None,
+            "country": country,
+            "valid": False,
+            "type": "UNKNOWN",
+        }

+ 567 - 0
app/repository.py

@@ -0,0 +1,567 @@
+import aiosqlite
+from .phone import normalize
+from datetime import datetime, timezone, timedelta
+
+
+def now_iso():
+    return datetime.now(timezone.utc).isoformat()
+
+
+class Repository:
+    def __init__(self, path):
+        self.path = path
+
+    async def init(self):
+        async with aiosqlite.connect(self.path) as db:
+            await db.executescript("""
+            CREATE TABLE IF NOT EXISTS calls (
+                id INTEGER PRIMARY KEY AUTOINCREMENT,
+                callid INTEGER NOT NULL,
+                legid INTEGER,
+                phone TEXT,
+                queue_dn TEXT NOT NULL,
+                first_seen_at TEXT NOT NULL,
+                last_seen_at TEXT NOT NULL,
+                status TEXT NOT NULL DEFAULT 'active',
+                answered_by_dn TEXT,
+                UNIQUE(queue_dn, callid)
+            );
+
+            CREATE TABLE IF NOT EXISTS missed_call_groups (
+                phone TEXT PRIMARY KEY,
+                first_call_at TEXT NOT NULL,
+                last_call_at TEXT NOT NULL,
+                attempt_count INTEGER NOT NULL DEFAULT 0,
+                status TEXT NOT NULL DEFAULT 'open',
+                customer_id TEXT,
+                customer_label TEXT,
+                resolved_until TEXT,
+                resolved_by TEXT,
+                resolved_at TEXT,
+                resolved_note TEXT
+            );
+
+            CREATE TABLE IF NOT EXISTS idempotency_keys (
+                idem_key TEXT PRIMARY KEY,
+                operation TEXT NOT NULL,
+                response_json TEXT NOT NULL,
+                created_at TEXT NOT NULL
+            );
+            """)
+            await db.commit()
+
+    def normalize_phone(self, raw):
+        """
+        Zentrale Telefonnummern-Normalisierung.
+
+        Die eigentliche Logik liegt in app.phone.normalize().
+        Dadurch verwendet die Repository-/Queue-Verarbeitung
+        dieselbe Normalisierung wie die /phone/normalize API.
+        """
+        result = normalize(raw, "DE")
+
+        if not result.get("valid"):
+            return None
+
+        return result.get("e164")
+
+    async def observe_queue(self, participants, queue_dn, agent_dns=None):
+        active = set()
+
+        async with aiosqlite.connect(self.path) as db:
+            for p in participants:
+                if p.get("callid") is None:
+                    continue
+
+                callid = int(p["callid"])
+                active.add(callid)
+
+                raw = p.get("party_caller_id")
+                is_external = p.get("party_dn_type") == "Wexternalline"
+                e164 = self.normalize_phone(raw) if is_external else None
+                now = now_iso()
+
+                status_raw = str(p.get("status") or "").lower()
+
+                participant_dn = str(
+                    p.get("partyDn") or p.get("party_dn") or ""
+                ).strip()
+
+                agent_dns_set = {
+                    str(x).strip()
+                    for x in (agent_dns or [])
+                    if str(x).strip()
+                }
+
+                if (
+                    status_raw in ("connected", "talking", "answered")
+                    and participant_dn in agent_dns_set
+                ):
+                    current_status = "answered"
+                else:
+                    current_status = "active"
+
+                await db.execute("""
+                    INSERT INTO calls
+                    (callid, legid, phone, raw, queue_dn,
+                     first_seen_at, last_seen_at, status,
+                     direction, started_at, answered_at)
+                    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+                    ON CONFLICT(queue_dn, callid) DO UPDATE SET
+                      legid=excluded.legid,
+                      phone=excluded.phone,
+                      raw=excluded.raw,
+                      last_seen_at=excluded.last_seen_at,
+                      status=excluded.status,
+                      answered_at=CASE
+                        WHEN calls.answered_at IS NOT NULL
+                          THEN calls.answered_at
+                        WHEN excluded.status='answered'
+                          THEN excluded.last_seen_at
+                        ELSE NULL
+                      END
+                """, (
+                    callid,
+                    p.get("legid"),
+                    e164,
+                    raw,
+                    queue_dn,
+                    now,
+                    now,
+                    current_status,
+                    "inbound" if is_external else "internal",
+                    now,
+                    now if current_status == "answered" else None,
+                ))
+
+            await db.commit()
+
+        return active
+
+    async def finalize_disappeared(self, queue_dn, active):
+        async with aiosqlite.connect(self.path) as db:
+            cur = await db.execute("""
+                SELECT id, callid, phone, raw, last_seen_at
+                FROM calls
+                WHERE queue_dn=? AND status IN ('active', 'answered')
+            """, (queue_dn,))
+
+            for row_id, callid, phone, raw, last_seen_at in await cur.fetchall():
+                if callid in active:
+                    continue
+
+                last_seen = datetime.fromisoformat(last_seen_at)
+
+                if datetime.now(timezone.utc) - last_seen < timedelta(seconds=10):
+                    continue
+
+                now = now_iso()
+
+                cur_state = await db.execute(
+                    "SELECT answered_at, started_at FROM calls WHERE id=?",
+                    (row_id,),
+                )
+                state_row = await cur_state.fetchone()
+
+                answered_at = state_row[0] if state_row else None
+                started_at = state_row[1] if state_row else None
+
+                final_status = "ended" if answered_at else "missed"
+
+                duration_seconds = None
+                if started_at:
+                    started_dt = datetime.fromisoformat(started_at)
+                    ended_dt = datetime.fromisoformat(now)
+                    duration_seconds = max(
+                        0,
+                        int((ended_dt - started_dt).total_seconds())
+                    )
+
+                await db.execute(
+                    """
+                    UPDATE calls
+                    SET status=?,
+                        ended_at=?,
+                        last_seen_at=?,
+                        duration_seconds=?
+                    WHERE id=?
+                    """,
+                    (
+                        final_status,
+                        now,
+                        now,
+                        duration_seconds,
+                        row_id,
+                    ),
+                )
+
+                if final_status == "missed" and phone:
+                    await db.execute("""
+                        INSERT INTO missed_call_groups
+                        (phone, raw, first_call_at, last_call_at,
+                         attempt_count, status)
+                        VALUES (?, ?, ?, ?, 1, 'open')
+                        ON CONFLICT(phone) DO UPDATE SET
+                          raw=excluded.raw,
+                          last_call_at=excluded.last_call_at,
+                          attempt_count=missed_call_groups.attempt_count+1
+                    """, (phone, raw, last_seen_at, now))
+
+            await db.commit()
+
+
+
+
+    async def get_outbound_idempotency(self, key):
+        async with aiosqlite.connect(self.path) as db:
+            db.row_factory = aiosqlite.Row
+
+            cur = await db.execute("""
+                SELECT idempotency_key, request_hash, status, response_json, created_at
+                FROM outbound_idempotency
+                WHERE idempotency_key=?
+            """, (key,))
+
+            row = await cur.fetchone()
+            return dict(row) if row else None
+
+    async def claim_outbound_idempotency(self, key, request_hash, created_at):
+        async with aiosqlite.connect(self.path) as db:
+            try:
+                await db.execute("BEGIN IMMEDIATE")
+
+                cur = await db.execute("""
+                    INSERT OR IGNORE INTO outbound_idempotency
+                    (idempotency_key, request_hash, status, response_json, created_at)
+                    VALUES (?, ?, 'processing', NULL, ?)
+                """, (key, request_hash, created_at))
+
+                inserted = cur.rowcount == 1
+                await db.commit()
+                return inserted
+
+            except Exception:
+                await db.rollback()
+                raise
+
+    async def finish_outbound_idempotency(self, key, response_json):
+        async with aiosqlite.connect(self.path) as db:
+            await db.execute("""
+                UPDATE outbound_idempotency
+                SET status='completed', response_json=?
+                WHERE idempotency_key=?
+            """, (response_json, key))
+            await db.commit()
+
+    async def release_outbound_idempotency(self, key):
+        async with aiosqlite.connect(self.path) as db:
+            await db.execute("""
+                DELETE FROM outbound_idempotency
+                WHERE idempotency_key=? AND status='processing'
+            """, (key,))
+            await db.commit()
+
+
+    async def insert_outbound_call(
+        self,
+        callid,
+        legid,
+        source_dn,
+        destination,
+        phone,
+        raw,
+        started_at,
+    ):
+        async with aiosqlite.connect(self.path) as db:
+            await db.execute("""
+                INSERT INTO calls (
+                    callid,
+                    legid,
+                    phone,
+                    queue_dn,
+                    first_seen_at,
+                    last_seen_at,
+                    status,
+                    raw,
+                    direction,
+                    started_at,
+                    source_dn,
+                    destination
+                )
+                VALUES (?, ?, ?, NULL, ?, ?, 'dialing', ?, 'outbound', ?, ?, ?)
+            """, (
+                callid,
+                legid,
+                phone,
+                started_at,
+                started_at,
+                raw,
+                started_at,
+                source_dn,
+                destination,
+            ))
+
+            await db.commit()
+
+
+    async def list_active_outbound_calls(self):
+        async with aiosqlite.connect(self.path) as db:
+            db.row_factory = aiosqlite.Row
+
+            cur = await db.execute("""
+                SELECT
+                    id,
+                    callid,
+                    legid,
+                    source_dn,
+                    destination,
+                    status,
+                    started_at,
+                    answered_at,
+                    ended_at,
+                    duration_seconds
+                FROM calls
+                WHERE direction='outbound'
+                  AND ended_at IS NULL
+                ORDER BY id
+            """)
+
+            return [dict(row) for row in await cur.fetchall()]
+
+    async def update_outbound_connected(
+        self,
+        call_id,
+        answered_at,
+        last_seen_at,
+    ):
+        async with aiosqlite.connect(self.path) as db:
+            await db.execute("""
+                UPDATE calls
+                SET
+                    status='connected',
+                    answered_at=COALESCE(answered_at, ?),
+                    last_seen_at=?
+                WHERE id=?
+                  AND direction='outbound'
+                  AND ended_at IS NULL
+            """, (
+                answered_at,
+                last_seen_at,
+                call_id,
+            ))
+
+            await db.commit()
+
+    async def update_outbound_seen(
+        self,
+        call_id,
+        status,
+        last_seen_at,
+    ):
+        async with aiosqlite.connect(self.path) as db:
+            await db.execute("""
+                UPDATE calls
+                SET
+                    status=?,
+                    last_seen_at=?
+                WHERE id=?
+                  AND direction='outbound'
+                  AND ended_at IS NULL
+            """, (
+                status,
+                last_seen_at,
+                call_id,
+            ))
+
+            await db.commit()
+
+    async def finalize_outbound_call(
+        self,
+        call_id,
+        ended_at,
+        status,
+        duration_seconds,
+    ):
+        async with aiosqlite.connect(self.path) as db:
+            await db.execute("""
+                UPDATE calls
+                SET
+                    status=?,
+                    ended_at=?,
+                    duration_seconds=?,
+                    last_seen_at=?
+                WHERE id=?
+                  AND direction='outbound'
+                  AND ended_at IS NULL
+            """, (
+                status,
+                ended_at,
+                duration_seconds,
+                ended_at,
+                call_id,
+            ))
+
+            await db.commit()
+
+    async def get_call(self, call_id):
+        async with aiosqlite.connect(self.path) as db:
+            db.row_factory = aiosqlite.Row
+
+            cur = await db.execute("""
+                SELECT
+                    id,
+                    callid,
+                    legid,
+                    phone,
+                    raw,
+                    queue_dn,
+                    status,
+                    direction,
+                    first_seen_at,
+                    last_seen_at,
+                    started_at,
+                    answered_at,
+                    ended_at,
+                    duration_seconds,
+                    agent_dn,
+                    agent_name
+                FROM calls
+                WHERE id=?
+                LIMIT 1
+            """, (call_id,))
+
+            row = await cur.fetchone()
+            return dict(row) if row else None
+
+    async def list_calls(self, limit=100, offset=0, status=None):
+        async with aiosqlite.connect(self.path) as db:
+            db.row_factory = aiosqlite.Row
+
+            where = ""
+            params = []
+
+            if status:
+                where = "WHERE status=?"
+                params.append(status)
+
+            cur = await db.execute(
+                f"""
+                SELECT
+                    id,
+                    callid,
+                    legid,
+                    phone,
+                    raw,
+                    queue_dn,
+                    status,
+                    direction,
+                    first_seen_at,
+                    last_seen_at,
+                    started_at,
+                    answered_at,
+                    ended_at,
+                    duration_seconds,
+                    agent_dn,
+                    agent_name
+                FROM calls
+                {where}
+                ORDER BY COALESCE(started_at, first_seen_at) DESC
+                LIMIT ? OFFSET ?
+                """,
+                (*params, limit, offset),
+            )
+
+            rows = [dict(row) for row in await cur.fetchall()]
+
+            cur = await db.execute(
+                f"SELECT COUNT(*) FROM calls {where}",
+                tuple(params),
+            )
+            total = (await cur.fetchone())[0]
+
+            return rows, total
+
+    def _decorate(self, row):
+        last_call = row["last_call_at"]
+        resolved_until = row["resolved_until"]
+        row["e164"] = row["phone"]
+        row["status"] = (
+            "resolved"
+            if resolved_until and resolved_until >= last_call
+            else "open"
+        )
+        return row
+
+    async def list_groups(self, open_only=False):
+        async with aiosqlite.connect(self.path) as db:
+            db.row_factory = aiosqlite.Row
+            cur = await db.execute(
+                "SELECT * FROM missed_call_groups ORDER BY last_call_at DESC"
+            )
+            rows = [dict(x) for x in await cur.fetchall()]
+
+        rows = [self._decorate(x) for x in rows]
+
+        if open_only:
+            rows = [x for x in rows if x["status"] == "open"]
+
+        return rows
+
+    async def get_group(self, e164):
+        async with aiosqlite.connect(self.path) as db:
+            db.row_factory = aiosqlite.Row
+            cur = await db.execute(
+                "SELECT * FROM missed_call_groups WHERE phone=?",
+                (e164,),
+            )
+            row = await cur.fetchone()
+
+        return self._decorate(dict(row)) if row else None
+
+    async def get_idempotency(self, key, operation):
+        async with aiosqlite.connect(self.path) as db:
+            cur = await db.execute(
+                """SELECT response_json
+                   FROM idempotency_keys
+                   WHERE idem_key=? AND operation=?""",
+                (key, operation),
+            )
+            row = await cur.fetchone()
+            return row[0] if row else None
+
+    async def save_idempotency(self, key, operation, response_json):
+        async with aiosqlite.connect(self.path) as db:
+            await db.execute("""
+                INSERT OR IGNORE INTO idempotency_keys
+                (idem_key, operation, response_json, created_at)
+                VALUES (?, ?, ?, ?)
+            """, (key, operation, response_json, now_iso()))
+            await db.commit()
+
+    async def resolve(self, e164, until, by, note):
+        async with aiosqlite.connect(self.path) as db:
+            await db.execute("""
+                UPDATE missed_call_groups
+                SET resolved_until=?,
+                    resolved_by=?,
+                    resolved_at=?,
+                    resolved_note=?
+                WHERE phone=?
+            """, (until, by, now_iso(), note, e164))
+            await db.commit()
+
+        return await self.get_group(e164)
+
+    async def reopen(self, e164, by):
+        async with aiosqlite.connect(self.path) as db:
+            await db.execute("""
+                UPDATE missed_call_groups
+                SET resolved_until=NULL,
+                    resolved_by=?,
+                    resolved_at=?,
+                    resolved_note=NULL
+                WHERE phone=?
+            """, (by, now_iso(), e164))
+            await db.commit()
+
+        return await self.get_group(e164)

+ 388 - 0
app/service.py

@@ -0,0 +1,388 @@
+import asyncio
+import json
+import logging
+from datetime import datetime, timezone
+import logging
+from app.call_context import CallContextService
+from app.call_context_repository import CallContextRepository
+from app.kontor_mcp import KontorMCPClient
+
+logger = logging.getLogger(__name__)
+
+from .config import Settings
+from .repository import Repository
+from .threecx import ThreeCXClient
+
+log = logging.getLogger(__name__)
+
+class TelephonyService:
+    def __init__(self, settings: Settings):
+        self.settings = settings
+
+        self.repo = Repository(
+            settings.database_path
+        )
+
+        self.cx = ThreeCXClient(
+            settings
+        )
+
+        # Long-lived Kontor MCP client.
+        self.kontor_mcp = KontorMCPClient(
+            url=settings.kontor_mcp_url,
+            token=settings.kontor_mcp_token,
+            timeout=settings.kontor_mcp_timeout,
+        )
+
+        # Generic customer/business context service.
+        self.call_context = CallContextService(
+            self.kontor_mcp
+        )
+
+        # Persisted enrichment snapshots.
+        self._call_context_repository = (
+            CallContextRepository()
+        )
+
+        # One enrichment task per (callid, legid).
+        self._call_context_tasks = {}
+
+        self._active_participants = []
+        self._active_observed_at = None
+        self._last_logged_active_state = None
+
+    async def start(self):
+        await self.repo.init()
+
+        # Initialize the long-lived MCP session once.
+        await self.kontor_mcp.initialize()
+
+        asyncio.create_task(
+            self._snapshot_loop()
+        )
+
+        asyncio.create_task(
+            self.cx.run_websocket(
+                self._on_event
+            )
+        )
+
+    async def _on_event(self, event):
+        log.debug("3CX event: %s", event)
+        await self._process_queue()
+
+    async def _snapshot_loop(self):
+        while True:
+            try:
+                await self._process_queue()
+            except Exception:
+                log.exception("Queue snapshot failed")
+
+            try:
+                await self._process_outbound()
+            except Exception:
+                log.exception("Outbound tracking failed")
+
+            await asyncio.sleep(2)
+
+    def get_active_participants(self):
+        return list(self._active_participants)
+
+
+    async def _process_outbound(self):
+        """
+        Verfolgt ausschließlich von der Outbound-API gestartete Calls.
+
+        Ein einzelner Participant-Request wird pro source_dn ausgeführt.
+        Fehler bei 3CX werden NICHT als Call-Ende interpretiert.
+        """
+
+        calls = await self.repo.list_active_outbound_calls()
+
+        if not calls:
+            return
+
+        now = datetime.now(timezone.utc)
+        now_iso = now.isoformat()
+
+        # Nur die tatsächlich benötigten DNs abfragen.
+        dns = sorted({
+            str(call["source_dn"])
+            for call in calls
+            if call.get("source_dn")
+        })
+
+        participants_by_dn = {}
+
+        for dn in dns:
+            try:
+                participants_by_dn[dn] = await self.cx.get_participants(dn)
+            except Exception:
+                # Ganz wichtig:
+                # Bei einem 3CX-/Netzwerkfehler niemals den Call
+                # fälschlich als beendet markieren.
+                log.exception(
+                    "Outbound participant lookup failed for DN %s",
+                    dn,
+                )
+
+        for call in calls:
+            source_dn = str(call["source_dn"])
+            participants = participants_by_dn.get(source_dn)
+
+            # Wenn die Abfrage für diesen DN fehlgeschlagen ist,
+            # bleibt der Call unverändert.
+            if participants is None:
+                continue
+
+            callid = str(call["callid"])
+            legid = str(call["legid"]) if call["legid"] is not None else None
+
+            participant = None
+
+            for p in participants:
+                if str(p.get("callid")) != callid:
+                    continue
+
+                if legid is not None and str(p.get("legid")) != legid:
+                    continue
+
+                participant = p
+                break
+
+            if participant is None:
+                # Call ist nicht mehr bei 3CX aktiv.
+                started = None
+                answered = None
+
+                try:
+                    if call.get("started_at"):
+                        started = datetime.fromisoformat(
+                            call["started_at"].replace("Z", "+00:00")
+                        )
+
+                    if call.get("answered_at"):
+                        answered = datetime.fromisoformat(
+                            call["answered_at"].replace("Z", "+00:00")
+                        )
+                except Exception:
+                    log.exception(
+                        "Could not parse timestamps for outbound call %s",
+                        call["id"],
+                    )
+
+                duration = None
+
+                if answered:
+                    duration = max(
+                        0,
+                        int((now - answered).total_seconds()),
+                    )
+
+                final_status = "ended" if answered else "failed"
+
+                await self.repo.finalize_outbound_call(
+                    call_id=call["id"],
+                    ended_at=now_iso,
+                    status=final_status,
+                    duration_seconds=duration,
+                )
+
+                log.info(
+                    "OUTBOUND_CALL_ENDED callid=%s legid=%s status=%s duration=%s",
+                    callid,
+                    legid,
+                    final_status,
+                    duration,
+                )
+
+                continue
+
+            status = str(participant.get("status") or "").lower()
+
+            if status == "connected":
+                if not call.get("answered_at"):
+                    await self.repo.update_outbound_connected(
+                        call_id=call["id"],
+                        answered_at=now_iso,
+                        last_seen_at=now_iso,
+                    )
+
+                    log.info(
+                        "OUTBOUND_CALL_CONNECTED callid=%s legid=%s source=%s destination=%s",
+                        callid,
+                        legid,
+                        source_dn,
+                        call.get("destination"),
+                    )
+                else:
+                    await self.repo.update_outbound_seen(
+                        call_id=call["id"],
+                        status="connected",
+                        last_seen_at=now_iso,
+                    )
+
+            elif status:
+                await self.repo.update_outbound_seen(
+                    call_id=call["id"],
+                    status=status,
+                    last_seen_at=now_iso,
+                )
+
+    async def _enrich_and_persist_call_context(
+        self,
+        participant,
+    ):
+        """
+        Enrich one external inbound call with Kontor MCP context
+        and persist the resulting snapshot.
+
+        This task is deliberately detached from the 3CX event loop.
+        """
+
+        callid = participant.get("callid")
+        legid = participant.get("legid")
+        raw_phone = participant.get("party_caller_id")
+
+        if not callid or not raw_phone:
+            return
+
+        key = (
+            str(callid),
+            str(legid) if legid is not None else None,
+        )
+
+        try:
+            # Existing middleware phone normalization.
+            e164 = self.repo.normalize_phone(
+                raw_phone
+            )
+
+            if not e164:
+                logger.warning(
+                    "CALL_CONTEXT invalid phone callid=%s legid=%s raw=%s",
+                    callid,
+                    legid,
+                    raw_phone,
+                )
+                return
+
+            context = await self.call_context.enrich_phone(
+                e164
+            )
+
+            self._call_context_repository.upsert(
+                callid=str(callid),
+                legid=(
+                    str(legid)
+                    if legid is not None
+                    else None
+                ),
+                phone_e164=e164,
+                context=context,
+            )
+
+            logger.info(
+                "CALL_CONTEXT persisted callid=%s legid=%s phone=%s status=%s",
+                callid,
+                legid,
+                e164,
+                context.get("status"),
+            )
+
+        except Exception:
+            logger.exception(
+                "CALL_CONTEXT enrichment failed callid=%s legid=%s",
+                callid,
+                legid,
+            )
+
+        finally:
+            self._call_context_tasks.pop(
+                key,
+                None,
+            )
+
+    async def _process_queue(self):
+        data = await self.cx.get_dn(self.settings.threecx_queue_dn)
+        participants = data.get("participants", [])
+        self._active_participants = participants
+        self._active_observed_at = asyncio.get_running_loop().time()
+
+        external = [
+            p for p in participants
+            if p.get("party_dn_type") == "Wexternalline"
+        ]
+
+        # Enrich each new external call exactly once.
+        for participant in external:
+            callid = participant.get("callid")
+            legid = participant.get("legid")
+
+            if not callid:
+                continue
+
+            key = (
+                str(callid),
+                str(legid) if legid is not None else None,
+            )
+
+            if key in self._call_context_tasks:
+                continue
+
+            task = asyncio.create_task(
+                self._enrich_and_persist_call_context(
+                    participant
+                )
+            )
+
+            self._call_context_tasks[key] = task
+
+        if external:
+            state = []
+            for p in external:
+                state.append({
+                    "callId": p.get("callid"),
+                    "legId": p.get("legid"),
+                    "status": p.get("status"),
+                    "caller": p.get("party_caller_id"),
+                    "partyDn": p.get("party_dn"),
+                    "deviceId": p.get("device_id"),
+                })
+
+            state.sort(
+                key=lambda x: (
+                    str(x.get("callId")),
+                    str(x.get("legId")),
+                )
+            )
+
+            fingerprint = json.dumps(
+                state,
+                sort_keys=True,
+                ensure_ascii=False,
+                separators=(",", ":"),
+            )
+
+            if fingerprint != self._last_logged_active_state:
+                logger.info(
+                    "CALL_STATE %s",
+                    json.dumps(
+                        {
+                            "queue": self.settings.threecx_queue_dn,
+                            "calls": state,
+                        },
+                        ensure_ascii=False,
+                        separators=(",", ":"),
+                    ),
+                )
+                self._last_logged_active_state = fingerprint
+        else:
+            self._last_logged_active_state = None
+
+        active = await self.repo.observe_queue(
+            participants,
+            self.settings.threecx_queue_dn,
+            self.settings.threecx_monitored_extensions,
+        )
+        await self.repo.finalize_disappeared(self.settings.threecx_queue_dn, active)

+ 288 - 0
app/threecx.py

@@ -0,0 +1,288 @@
+import asyncio
+import json
+import logging
+import ssl
+import time
+from pathlib import Path
+from typing import Any, Awaitable, Callable
+
+import httpx
+import websockets
+
+from .config import Settings
+
+log = logging.getLogger(__name__)
+
+class ThreeCXClient:
+    def __init__(self, settings: Settings):
+        self.s = settings
+        self._token = None
+        self._expires_at = 0.0
+        self._token_lock = asyncio.Lock()
+
+    async def token(self) -> str:
+        if self._token and time.time() < self._expires_at - 60:
+            return self._token
+
+        async with self._token_lock:
+            if self._token and time.time() < self._expires_at - 60:
+                return self._token
+
+            secret = Path(self.s.threecx_api_key).read_text().strip()
+            async with httpx.AsyncClient(
+                verify=self.s.threecx_verify_tls,
+                timeout=15,
+            ) as client:
+                response = await client.post(
+                    f"{self.s.threecx_base_url}/connect/token",
+                    data={
+                        "client_id": self.s.threecx_client_id,
+                        "client_secret": secret,
+                        "grant_type": "client_credentials",
+                    },
+                )
+                response.raise_for_status()
+                data = response.json()
+
+            self._token = data["access_token"]
+            self._expires_at = time.time() + int(data.get("expires_in", 3600))
+            return self._token
+
+    async def invalidate_token(self) -> None:
+        async with self._token_lock:
+            self._token = None
+            self._expires_at = 0.0
+
+    async def get_dn(self, dn: str) -> dict[str, Any]:
+        for attempt in range(2):
+            token = await self.token()
+
+            async with httpx.AsyncClient(
+                verify=self.s.threecx_verify_tls,
+                timeout=15,
+            ) as client:
+                response = await client.get(
+                    f"{self.s.threecx_base_url}/callcontrol/{dn}",
+                    headers={"Authorization": f"Bearer {token}"},
+                )
+
+            if response.status_code == 401 and attempt == 0:
+                log.warning("3CX token rejected for get_dn(%s); refreshing token", dn)
+                await self.invalidate_token()
+                continue
+
+            response.raise_for_status()
+            return response.json()
+
+        raise RuntimeError("3CX get_dn failed after token refresh")
+
+    async def get_participants(self, dn: str) -> list[dict[str, Any]]:
+        for attempt in range(2):
+            token = await self.token()
+
+            async with httpx.AsyncClient(
+                verify=self.s.threecx_verify_tls,
+                timeout=15,
+            ) as client:
+                response = await client.get(
+                    f"{self.s.threecx_base_url}/callcontrol/{dn}/participants",
+                    headers={"Authorization": f"Bearer {token}"},
+                )
+
+            if response.status_code == 401 and attempt == 0:
+                log.warning(
+                    "3CX token rejected for get_participants(%s); refreshing token",
+                    dn,
+                )
+                await self.invalidate_token()
+                continue
+
+            response.raise_for_status()
+            data = response.json()
+
+            if isinstance(data, list):
+                return data
+
+            return data.get("participants", [])
+
+        raise RuntimeError("3CX get_participants failed after token refresh")
+
+    async def make_call(
+        self,
+        dn: str,
+        device_id: str,
+        destination: str,
+        timeout_sec: int = 30,
+    ) -> dict[str, Any]:
+        for attempt in range(2):
+            token = await self.token()
+
+            async with httpx.AsyncClient(
+                verify=self.s.threecx_verify_tls,
+                timeout=15,
+            ) as client:
+                response = await client.post(
+                    f"{self.s.threecx_base_url}/callcontrol/{dn}/devices/{device_id}/makecall",
+                    headers={"Authorization": f"Bearer {token}"},
+                    json={
+                        "destination": destination,
+                        "timeoutSec": timeout_sec,
+                    },
+                )
+
+            if response.status_code == 401 and attempt == 0:
+                log.warning(
+                    "3CX token rejected for make_call(%s -> %s); refreshing token",
+                    dn,
+                    destination,
+                )
+                await self.invalidate_token()
+                continue
+
+            response.raise_for_status()
+            return response.json()
+
+        raise RuntimeError("3CX make_call failed after token refresh")
+
+    async def get_call_log(
+        self,
+        period_from: str,
+        period_to: str,
+        top: int = 500,
+        skip: int = 0,
+    ) -> dict[str, Any]:
+        """Read current 3CX CDR/CallLog data via ReportCallLogData."""
+        for attempt in range(2):
+            token = await self.token()
+
+            params = (
+                f"periodFrom={period_from},"
+                f"periodTo={period_to},"
+                "sourceType=0,"
+                "sourceFilter='',"
+                "destinationType=0,"
+                "destinationFilter='',"
+                "callsType=0,"
+                "callTimeFilterType=0,"
+                "callTimeFilterFrom='0:00:0',"
+                "callTimeFilterTo='0:00:0',"
+                "hidePcalls=true"
+            )
+
+            url = (
+                f"{self.s.threecx_base_url}/xapi/v1/"
+                f"ReportCallLogData/Pbx.GetCallLogData({params})"
+                f"?$top={top}&$skip={skip}&$count=true"
+            )
+
+            async with httpx.AsyncClient(
+                verify=self.s.threecx_verify_tls,
+                timeout=120,
+            ) as client:
+                response = await client.get(
+                    url,
+                    headers={
+                        "Authorization": f"Bearer {token}",
+                        "Accept": "application/json",
+                    },
+                )
+
+            if response.status_code == 401 and attempt == 0:
+                log.warning("3CX token rejected for get_call_log; refreshing token")
+                await self.invalidate_token()
+                continue
+
+            response.raise_for_status()
+            return response.json()
+
+        raise RuntimeError("3CX get_call_log failed after token refresh")
+
+    async def download_recording(self, rec_id: int) -> tuple[bytes, str]:
+        """Download a historical 3CX recording by recording ID."""
+        token = await self.token()
+
+        url = (
+            f"{self.s.threecx_base_url}/xapi/v1/"
+            f"Recordings/Pbx.DownloadRecording(recId={int(rec_id)})"
+        )
+
+        async with httpx.AsyncClient(
+            verify=self.s.threecx_verify_tls,
+            timeout=120,
+        ) as client:
+            response = await client.get(
+                url,
+                headers={
+                    "Authorization": f"Bearer {token}",
+                    "Accept": "audio/x-wav,*/*",
+                },
+            )
+
+        if response.status_code == 401:
+            await self.invalidate_token()
+            token = await self.token()
+
+            async with httpx.AsyncClient(
+                verify=self.s.threecx_verify_tls,
+                timeout=120,
+            ) as client:
+                response = await client.get(
+                    url,
+                    headers={
+                        "Authorization": f"Bearer {token}",
+                        "Accept": "audio/x-wav,*/*",
+                    },
+                )
+
+        response.raise_for_status()
+
+        content_type = response.headers.get(
+            "content-type",
+            "audio/x-wav",
+        )
+
+        return response.content, content_type
+
+    async def websocket(self, on_event: Callable[[dict[str, Any]], Awaitable[None]]):
+        token = await self.token()
+        uri = self.s.threecx_base_url.replace("https://", "wss://").replace("http://", "ws://")
+        uri += "/callcontrol/ws"
+
+        ssl_context = None
+        if uri.startswith("wss://") and not self.s.threecx_verify_tls:
+            ssl_context = ssl.create_default_context()
+            ssl_context.check_hostname = False
+            ssl_context.verify_mode = ssl.CERT_NONE
+
+        async with websockets.connect(
+            uri,
+            additional_headers={"Authorization": f"Bearer {token}"},
+            ssl=ssl_context,
+            ping_interval=20,
+            ping_timeout=20,
+        ) as ws:
+            await ws.send(json.dumps({
+                "RequestID": "telephony-middleware",
+                "Path": "/callcontrol",
+            }))
+            log.info("3CX WebSocket connected")
+
+            async for raw in ws:
+                try:
+                    message = json.loads(raw)
+                except json.JSONDecodeError:
+                    continue
+                await on_event(message)
+
+    async def run_websocket(self, on_event):
+        delay = 2
+        while True:
+            try:
+                await self.websocket(on_event)
+                delay = 2
+            except asyncio.CancelledError:
+                raise
+            except Exception:
+                log.exception("3CX WebSocket failed; reconnect in %ss", delay)
+                await asyncio.sleep(delay)
+                delay = min(delay * 2, 60)

+ 67 - 0
app/whisper.py

@@ -0,0 +1,67 @@
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any
+
+
+class WhisperWorker:
+    """
+    Langlebiger Whisper-Worker.
+
+    Das Modell wird genau einmal geladen.
+    """
+
+    def __init__(self, settings):
+        self.settings = settings
+        self.model = None
+
+    def start(self):
+        from faster_whisper import WhisperModel
+
+        self.model = WhisperModel(
+            self.settings.whisper_model,
+            device=self.settings.whisper_device,
+            compute_type=self.settings.whisper_compute_type,
+        )
+
+    def transcribe(
+        self,
+        audio: str | Path,
+    ) -> dict[str, Any]:
+
+        if self.model is None:
+            self.start()
+
+        segments, info = self.model.transcribe(
+            str(audio),
+            language=self.settings.whisper_language,
+            vad_filter=True,
+        )
+
+        result_segments = []
+
+        for segment in segments:
+            text = segment.text.strip()
+
+            if not text:
+                continue
+
+            result_segments.append(
+                {
+                    "start": segment.start,
+                    "end": segment.end,
+                    "text": text,
+                }
+            )
+
+        text = " ".join(
+            item["text"]
+            for item in result_segments
+        )
+
+        return {
+            "language": info.language,
+            "language_probability": info.language_probability,
+            "text": text,
+            "segments": result_segments,
+        }

+ 100 - 0
config/telefonie_taxonomy.json

@@ -0,0 +1,100 @@
+{
+  "version": "1.0",
+  "description": "Zentrale Taxonomie der lokalen Telefon-KI",
+  "groups": {
+    "VERSAND": [
+      "VERSANDSTATUS",
+      "LIEFERVERZUG",
+      "LIEFERTERMIN"
+    ],
+
+    "BESTELLUNG": [
+      "BESTELLUNG",
+      "BESTELLÄNDERUNG",
+      "STORNIERUNG",
+      "VORBESTELLUNG"
+    ],
+
+    "REKLAMATION": [
+      "PRODUKTQUALITÄT",
+      "DUENGER_FEHLT",
+      "SCHLECHTVERPACKT",
+      "FALSCHESORTEGELIEFERT",
+      "FEHLLIEFERUNG",
+      "TRANSPORTSCHADEN",
+      "BESCHÄDIGT",
+      "MANGELHAFT",
+      "SONSTIGE_REKLAMATION"
+    ],
+
+    "BERATUNG": [
+      "PFLANZENBERATUNG",
+      "SORTENBERATUNG",
+      "PFLEGEFRAGE"
+    ],
+
+    "KAUF_VERTRIEB": [
+      "BESTANDSANFRAGE",
+      "ANGEBOT",
+      "B2B",
+      "GROSSHANDEL"
+    ],
+
+    "FINANZEN": [
+      "RECHNUNG",
+      "ZAHLUNG"
+    ],
+
+    "RETOURE_RECHT": [
+      "RETOURE",
+      "WIDERRUF"
+    ],
+
+    "DOKUMENTE": [
+      "DOKUMENTE"
+    ],
+
+    "KONTAKT": [
+      "RUECKRUF",
+      "E_MAIL",
+      "ALLGEMEINE_ANFRAGE"
+    ],
+
+    "SONSTIGES": [
+      "SONSTIGER_GESCHÄFTSFALL"
+    ]
+  },
+
+  "complaint_types": {
+    "PRODUKTQUALITÄT": "Qualitätsproblem am gelieferten Produkt oder an der Pflanze",
+    "DUENGER_FEHLT": "Mitbestellter oder zugesagter Dünger fehlt in der Lieferung",
+    "SCHLECHTVERPACKT": "Produkt/Pflanze wurde unzureichend oder schlecht verpackt",
+    "FALSCHESORTEGELIEFERT": "Richtige Produktart, aber falsche Sorte geliefert",
+    "FEHLLIEFERUNG": "Falscher Artikel bzw. falsches Produkt geliefert",
+    "TRANSPORTSCHADEN": "Schaden durch Transport oder Zustellung",
+    "BESCHÄDIGT": "Produkt beschädigt, Ursache noch nicht eindeutig",
+    "MANGELHAFT": "Konkreter Sach- oder Produktmangel",
+    "SONSTIGE_REKLAMATION": "Reklamation ohne passende spezifische Ursache"
+  },
+
+  "call_states": [
+    "HUMAN_CONVERSATION",
+    "VOICEMAIL",
+    "AUTOMATED_MESSAGE",
+    "NO_CONTENT",
+    "UNKNOWN"
+  ],
+
+  "context_types": [
+    "CUSTOMER",
+    "ORDER",
+    "PRODUCT",
+    "SHIPPING",
+    "INVOICE",
+    "PAYMENT",
+    "PREVIOUS_CONTACT",
+    "DOCUMENT",
+    "COMPLAINT",
+    "KNOWLEDGE"
+  ]
+}

+ 250 - 0
docs/rabattverhalten.md

@@ -0,0 +1,250 @@
+# Rabattverhalten von Kunden
+
+## Zweck
+
+Rabattverhalten soll bei der historischen Ticketklassifikation erkannt
+werden, damit später auf Kundenebene wiederholte Rabattforderungen und
+insbesondere eigenmächtige Kürzungen erkannt werden können.
+
+Die Klassifikation eines einzelnen Tickets trifft KEINE Entscheidung
+darüber, ob ein Kunde künftig beliefert werden soll.
+
+Die Belieferungsentscheidung erfolgt erst in einer separaten,
+kundenbezogenen Auswertung.
+
+---
+
+## 1. Rabattverhalten im einzelnen Ticket
+
+Folgende Merkmale werden als Tags erfasst:
+
+### RABATTANFRAGE
+
+Der Kunde fragt aktiv, ob ein Rabatt bzw. Preisnachlass möglich ist.
+
+Beispiele:
+
+- "Können Sie mir noch Rabatt geben?"
+- "Gibt es bei dieser Bestellung einen Nachlass?"
+- "Können Sie beim Preis noch etwas machen?"
+
+### RABATT_GEFORDERT
+
+Der Kunde fordert einen Rabatt ausdrücklich oder macht den Rabatt
+zur Bedingung bzw. Erwartung.
+
+Beispiele:
+
+- "Ich möchte 20 % Rabatt."
+- "Bei dem Zustand erwarte ich mindestens 15 % Nachlass."
+- "Ohne Rabatt nehme ich die Lieferung nicht."
+
+### RABATT_SELBSSTAENDIG_ABGEZOGEN
+
+Der Kunde zieht einen Rabatt/Nachlass eigenmächtig von einer Rechnung,
+Forderung oder Zahlung ab, ohne dass dieser Rabatt vorher vom Unternehmen
+gewährt wurde.
+
+Beispiele:
+
+- Rechnung 100 EUR, Kunde überweist nur 90 EUR.
+- Kunde zieht selbst 10 % vom Rechnungsbetrag ab.
+- Kunde zieht selbst Versandkosten oder einen behaupteten Rabatt ab.
+
+Dieses Merkmal ist besonders relevant für die spätere Kundenbewertung.
+
+---
+
+## 2. Rabatt-Höhe
+
+Die Höhe eines gewünschten oder eigenmächtig abgezogenen Rabatts soll
+nicht als feste Taxonomie-Tags gespeichert werden.
+
+Keine Tags wie:
+
+- RABATT_5_PROZENT
+- RABATT_10_PROZENT
+- RABATT_20_PROZENT
+
+Stattdessen werden strukturierte Werte erfasst:
+
+discount:
+- requested: boolean
+- self_deducted: boolean
+- amount: numeric|null
+- percentage: numeric|null
+- currency: string|null
+
+Beispiele:
+
+10 % Rabatt:
+
+{
+  "discount": {
+    "requested": true,
+    "self_deducted": false,
+    "amount": null,
+    "percentage": 10,
+    "currency": null
+  }
+}
+
+15 EUR eigenmächtig abgezogen:
+
+{
+  "discount": {
+    "requested": false,
+    "self_deducted": true,
+    "amount": 15,
+    "percentage": null,
+    "currency": "EUR"
+  }
+}
+
+---
+
+## 3. Rabatt ist nicht zwingend der Primärintent
+
+Rabatt kann Bestandteil eines anderen Anliegens sein.
+
+Beispiel:
+
+"Die Rose kam beschädigt an. Dafür erwarte ich 20 % Rabatt."
+
+Klassifikation:
+
+primary_intent:
+REKLAMATION
+
+secondary_intents:
+[]
+
+tags:
+QUALITAETSPROBLEM
+RABATT_GEFORDERT
+
+discount:
+requested = true
+percentage = 20
+
+---
+
+## 4. Eigenmächtige Kürzung
+
+Beispiel:
+
+"Die Rechnung beträgt 100 Euro, ich habe aber nur 90 Euro überwiesen."
+
+Klassifikation:
+
+primary_intent:
+ZAHLUNG
+
+tags:
+RABATT_SELBSSTAENDIG_ABGEZOGEN
+
+discount:
+self_deducted = true
+amount = 10
+currency = EUR
+
+---
+
+## 5. Wiederholtes Rabattverhalten
+
+RABATT_WIEDERHOLT wird NICHT vom Ticketklassifikator vergeben.
+
+Es ist ein kundenbezogenes Aggregat und wird später aus der
+Ticket-Historie berechnet.
+
+Beispiel:
+
+Kunde:
+- 1 Rabattanfrage → unauffällig
+- 3 Rabattanfragen → wiederholtes Rabattverhalten
+- 7 Rabattanfragen → stark rabattorientiert
+- 2 eigenmächtige Kürzungen → besonders kritisch
+
+---
+
+## 6. Vorgeschlagenes Kunden-Scoring
+
+Das Scoring ist eine spätere Auswertung und NICHT Bestandteil
+der KI-Entscheidung im einzelnen Ticket.
+
+Vorschlag:
+
+RABATTANFRAGE:
++1 Punkt
+
+RABATT_GEFORDERT:
++3 Punkte
+
+RABATT_SELBSSTAENDIG_ABGEZOGEN:
++8 Punkte
+
+mehrfache eigenmächtige Kürzung:
+zusätzliche +15 Punkte ab dem zweiten Vorfall
+
+---
+
+## 7. Kundenbezogene Auswertung
+
+Später soll aus allen klassifizierten Tickets beispielsweise entstehen:
+
+discount_behavior:
+- request_count
+- demanded_count
+- self_deducted_count
+- total_requested_amount
+- total_self_deducted_amount
+- average_requested_percentage
+- last_request_at
+- last_self_deduction_at
+- score
+- status
+
+Beispiel:
+
+{
+  "request_count": 7,
+  "demanded_count": 4,
+  "self_deducted_count": 2,
+  "total_requested_amount": 0,
+  "total_self_deducted_amount": 37.50,
+  "average_requested_percentage": 12.5,
+  "score": 39,
+  "status": "MANUELL_PRUEFEN"
+}
+
+---
+
+## 8. Keine automatische Sperrentscheidung
+
+Das Scoring erzeugt lediglich einen Kandidaten für eine spätere
+geschäftliche Entscheidung.
+
+Mögliche spätere Statuswerte:
+
+- NORMAL
+- BEOBACHTEN
+- RABATT_AUFFAELLIG
+- MANUELL_PRUEFEN
+- GESCHAEFTSENTSCHEIDUNG
+
+Die KI darf NICHT selbstständig entscheiden, dass ein Kunde nicht
+mehr beliefert wird.
+
+---
+
+## 9. Wichtig für die historische Klassifikation
+
+Rabattmerkmale dürfen nur aus dem tatsächlichen Kundenbeitrag
+abgeleitet werden.
+
+Eine Rabattgewährung oder ein Rabattangebot des Kundenservice darf
+NICHT als Kundenforderung klassifiziert werden.
+
+Der Kundenservice-Kontext darf nur zur Interpretation bereits im
+Kundenbeitrag vorhandener Informationen dienen.
+

+ 9 - 0
requirements.txt

@@ -0,0 +1,9 @@
+fastapi>=0.115,<1
+uvicorn[standard]>=0.34,<1
+httpx>=0.28,<1
+websockets>=15,<16
+aiosqlite>=0.21,<1
+aio-pika>=9.5,<10
+pydantic-settings>=2.8,<3
+
+phonenumbers>=9,<10

+ 17 - 0
systemd/3cx-telefonie.service

@@ -0,0 +1,17 @@
+[Unit]
+Description=3CX Telefonie Middleware
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+WorkingDirectory=/opt/3cx-middleware
+EnvironmentFile=/opt/3cx-middleware/.env
+ExecStart=/opt/3cx-middleware/.venv/bin/python -m app
+Restart=always
+RestartSec=5
+User=telephony
+Group=telephony
+
+[Install]
+WantedBy=multi-user.target

+ 138 - 0
tools/3cx_abandoned.py

@@ -0,0 +1,138 @@
+#!/usr/bin/env python3
+import base64
+import re
+import requests
+from dataclasses import dataclass
+
+URL = "https://schmid-gartenpflanzen.on3cx.de/MyPhone/MPWebService.asmx"
+SESSION = "b7a74b60-7987-d1ae-9189-49dd1c48803a"
+
+@dataclass
+class AbandonedCall:
+    call_id: int
+    number: str | None
+    queue: str | None
+    raw: bytes
+
+def read_varint(data, pos):
+    value = 0
+    shift = 0
+    while pos < len(data):
+        b = data[pos]
+        pos += 1
+        value |= (b & 0x7f) << shift
+        if not b & 0x80:
+            return value, pos
+        shift += 7
+    raise ValueError("unterminated varint")
+
+def fields(data):
+    pos = 0
+    while pos < len(data):
+        key, pos = read_varint(data, pos)
+        field_no = key >> 3
+        wire = key & 7
+
+        if wire == 0:
+            value, pos = read_varint(data, pos)
+        elif wire == 2:
+            length, pos = read_varint(data, pos)
+            value = data[pos:pos + length]
+            pos += length
+        elif wire == 1:
+            value = data[pos:pos + 8]
+            pos += 8
+        elif wire == 5:
+            value = data[pos:pos + 4]
+            pos += 4
+        else:
+            raise ValueError(f"unsupported wire type {wire}")
+
+        yield field_no, wire, value
+
+def strings(data):
+    out = []
+    for field_no, wire, value in fields(data):
+        if wire == 2:
+            try:
+                text = value.decode("utf-8")
+                if text.isprintable() and len(text) >= 2:
+                    out.append((field_no, text))
+            except UnicodeDecodeError:
+                pass
+    return out
+
+def find_call_messages(data):
+    """
+    Command 145 liefert eine Liste verschachtelter Call-Messages.
+    Wir suchen rekursiv nach Messages, die eine E.164-Nummer enthalten.
+    """
+    results = []
+
+    def walk(blob):
+        try:
+            fs = list(fields(blob))
+        except Exception:
+            return
+
+        text = strings(blob)
+        numbers = [
+            value for _, value in text
+            if re.fullmatch(r"\+\d{6,20}", value)
+        ]
+
+        if numbers:
+            # bekannte Struktur: irgendwo in derselben Message
+            # liegt die numerische Call-ID als varint.
+            varints = [
+                value for _, wire, value in fs
+                if wire == 0 and isinstance(value, int)
+            ]
+
+            # IDs wie 11626/11627 sind hier besonders interessant.
+            ids = [v for v in varints if 1000 <= v <= 10000000]
+
+            if ids:
+                results.append((ids[0], numbers[0], blob))
+
+        for _, wire, value in fs:
+            if wire == 2 and isinstance(value, bytes) and len(value) > 2:
+                walk(value)
+
+    walk(data)
+    return results
+
+def main():
+    # Command 145, beobachteter Request:
+    # 08 91 01 8a 09 06 10 50 18 00 22 00
+    payload = bytes.fromhex("08 91 01 8a 09 06 10 50 18 00 22 00")
+
+    r = requests.post(
+        URL,
+        headers={
+            "Accept": "application/octet-stream",
+            "Content-Type": "application/octet-stream",
+            "MyPhoneSession": SESSION,
+            "Origin": "https://schmid-gartenpflanzen.on3cx.de",
+        },
+        data=payload,
+        verify=False,
+        timeout=15,
+    )
+    r.raise_for_status()
+
+    print(f"HTTP: {r.status_code}")
+    print(f"Response: {len(r.content)} bytes")
+
+    calls = find_call_messages(r.content)
+
+    seen = set()
+    for call_id, number, raw in calls:
+        key = (call_id, number)
+        if key in seen:
+            continue
+        seen.add(key)
+        print(f"{call_id:>8}  {number}")
+
+if __name__ == "__main__":
+    main()

+ 347 - 0
tools/benchmark_zammad_taxonomy.py

@@ -0,0 +1,347 @@
+#!/usr/bin/env python3
+
+import json
+import sqlite3
+import time
+import urllib.request
+from concurrent.futures import ThreadPoolExecutor, as_completed
+
+DB = "data/zammad_analysis.sqlite3"
+URL = "http://127.0.0.1:11434/api/chat"
+MODEL = "qwen3:8b"
+
+WORKERS = 2
+TEST_LIMIT = 10
+
+TAXONOMY = """
+PRIMÄRINTENTS
+
+VERSANDSTATUS
+LIEFERVERZUG
+ADRESSÄNDERUNG
+REKLAMATION
+TRANSPORTSCHADEN
+FEHLLIEFERUNG
+RECHNUNG
+ZAHLUNG
+WIDERRUF
+RETOURE
+PFLANZENBERATUNG
+SORTENBERATUNG
+PFLEGEFRAGE
+BESTANDSANFRAGE
+VORBESTELLUNG
+B2B
+GROSSHANDEL
+DÜNGER_FEHLT
+SONSTIGES
+
+
+TAGS / MERKMALE
+
+QUALITAETSPROBLEM
+VERWECHSLUNG
+SCHLECHTVERPACKT
+FALSCHESORTEGELIEFERT
+PROBLEMMITLIEFERDIENST
+LIEFERDIENST_DHL
+LIEFERDIENST_GLS
+LIEFERDIENST_UNBEKANNT
+TRANSPORTSCHADEN
+PFLANZENKRANKHEIT
+PFLANZENSCHADEN
+WACHSTUMSPROBLEM
+"""
+
+SYSTEM = f"""
+Du analysierst historische Kundenservice-Tickets von Schmid Gartenpflanzen.
+
+Bestimme das tatsächliche Kundenanliegen anhand des KUNDENBEITRAGS.
+Die Antwort des Kundenservice dient nur als Kontext.
+
+{TAXONOMY}
+
+WICHTIGE KLASSIFIKATIONSREGELN:
+
+1. BESTANDSANFRAGE
+
+Jede Frage nach aktueller oder zukünftiger Verfügbarkeit,
+Lieferbarkeit oder Bestellbarkeit eines Produkts.
+
+Beispiele:
+- "Wann ist die Rose wieder verfügbar?"
+- "Ab wann wieder bestellbar?"
+- "Ist die Sorte im Herbst wieder lieferbar?"
+- "Können Sie mich informieren, sobald sie wieder verfügbar ist?"
+- "Kann ich die Rose im Juli bekommen?"
+
+Alle diese Fälle sind BESTANDSANFRAGE.
+
+2. VORBESTELLUNG
+
+VORBESTELLUNG nur verwenden, wenn der Kunde tatsächlich eine
+Vormerkung, Reservierung oder verbindliche Vorabbestellung verlangt.
+
+Eine reine Frage nach der zukünftigen Verfügbarkeit ist KEINE
+VORBESTELLUNG.
+
+3. WIDERRUF
+
+Stornierung oder Rückabwicklung einer Bestellung auf Kundenwunsch,
+einschließlich Stornierung vor Versand.
+
+4. REKLAMATION
+
+Beanstandung eines gelieferten Produkts oder einer erbrachten
+Leistung wegen Qualität, Zustand oder Beschädigung.
+
+5. TRANSPORTSCHADEN
+
+Wenn ein Schaden offensichtlich durch Transport oder Versand
+entstanden ist. Kann zusätzlich als Tag TRANSPORTSCHADEN erscheinen.
+
+6. FEHLLIEFERUNG
+
+Falscher Artikel, falsche Sorte oder falsches Produkt geliefert.
+
+7. DÜNGER_FEHLT
+
+Bestellter Dünger fehlt in der Lieferung.
+
+8. QUALITÄT
+
+QUALITAETSPROBLEM ist kein eigener Primärintent.
+Es ist ein Tag und kann zusammen mit REKLAMATION verwendet werden.
+
+PFLANZENKRANKHEIT, PFLANZENSCHADEN und WACHSTUMSPROBLEM sind
+ebenfalls Merkmale/Tags.
+
+9. LIEFERDIENST
+
+PROBLEMMITLIEFERDIENST ist ein Problem-/Sekundärmerkmal.
+
+Wenn DHL ausdrücklich genannt wird:
+LIEFERDIENST_DHL
+
+Wenn GLS ausdrücklich genannt wird:
+LIEFERDIENST_GLS
+
+Wenn ein Paketdienstproblem vorliegt, aber der Dienst unbekannt ist:
+LIEFERDIENST_UNBEKANNT
+
+10. MULTI-INTENT
+
+Mehrere gleichzeitig vorhandene Kundenanliegen müssen erfasst werden.
+
+primary_intent enthält das wichtigste Anliegen.
+secondary_intents enthält weitere relevante Primärintents.
+tags enthält konkrete Problem- und Kontextmerkmale.
+
+11. TAXONOMIE-LÜCKEN
+
+Keine neuen Primärintents eigenmächtig erfinden.
+
+Wenn kein Primärintent sinnvoll passt:
+primary_intent = SONSTIGES
+taxonomy_fit = POOR oder PARTIAL
+taxonomy_candidate = möglicher fehlender Intent.
+
+Antworte ausschließlich als gültiges JSON.
+"""
+
+OUTPUT_SCHEMA = """
+{
+  "primary_intent": "...",
+  "secondary_intents": [],
+  "tags": [],
+  "complaint_type": null,
+  "actions": [],
+  "summary": "...",
+  "taxonomy_fit": "GOOD|PARTIAL|POOR",
+  "taxonomy_candidate": null,
+  "confidence": 0.0
+}
+"""
+
+SYSTEM += "\nErwartetes JSON-Format:\n" + OUTPUT_SCHEMA
+
+
+def classify(row):
+
+    prompt = f"""
+Ticket #{row["ticket_number"]}
+
+Betreff:
+{row["title"] or ""}
+
+KUNDENBEITRAG:
+{row["customer_text"] or ""}
+
+KUNDENSERVICE-KONTEXT:
+{row["agent_text"] or ""}
+"""
+
+    payload = {
+        "model": MODEL,
+        "messages": [
+            {
+                "role": "system",
+                "content": SYSTEM,
+            },
+            {
+                "role": "user",
+                "content": prompt,
+            },
+        ],
+        "stream": False,
+        "format": "json",
+        "options": {
+            "temperature": 0,
+        },
+    }
+
+    request = urllib.request.Request(
+        URL,
+        data=json.dumps(payload).encode(),
+        headers={
+            "Content-Type": "application/json",
+        },
+    )
+
+    started = time.monotonic()
+
+    with urllib.request.urlopen(
+        request,
+        timeout=300,
+    ) as response:
+        result = json.loads(response.read())
+
+    elapsed = time.monotonic() - started
+
+    content = result["message"]["content"]
+    parsed = json.loads(content)
+
+    return row, elapsed, parsed
+
+
+def main():
+
+    db = sqlite3.connect(DB)
+    db.row_factory = sqlite3.Row
+
+    rows = db.execute("""
+        SELECT
+            ticket_id,
+            ticket_number,
+            title,
+            customer_text,
+            agent_text
+        FROM cases
+        WHERE analysis_status = 'ANALYZABLE'
+        ORDER BY ticket_id
+        LIMIT ?
+    """, (TEST_LIMIT,)).fetchall()
+
+    db.close()
+
+    print("=" * 80)
+    print("ZAMMAD TAXONOMIE V2 – PARALLEL BENCHMARK")
+    print("=" * 80)
+    print(f"Modell:   {MODEL}")
+    print(f"Tickets:  {len(rows)}")
+    print(f"Worker:   {WORKERS}")
+    print()
+
+    started = time.monotonic()
+    results = []
+
+    with ThreadPoolExecutor(max_workers=WORKERS) as executor:
+
+        futures = [
+            executor.submit(classify, row)
+            for row in rows
+        ]
+
+        for i, future in enumerate(
+            as_completed(futures),
+            1,
+        ):
+
+            try:
+                row, elapsed, result = future.result()
+
+                results.append(
+                    (
+                        row,
+                        elapsed,
+                        result,
+                    )
+                )
+
+                print(
+                    f"[{i:02}/{len(rows)}] "
+                    f"#{row['ticket_number']} "
+                    f"{elapsed:6.1f}s → "
+                    f"{result.get('primary_intent')} / "
+                    f"{result.get('taxonomy_fit')} / "
+                    f"{result.get('confidence')}"
+                )
+
+                if result.get("secondary_intents"):
+                    print(
+                        f"    secondary: "
+                        f"{result['secondary_intents']}"
+                    )
+
+                if result.get("tags"):
+                    print(
+                        f"    tags: "
+                        f"{result['tags']}"
+                    )
+
+                if result.get("taxonomy_candidate"):
+                    print(
+                        f"    candidate: "
+                        f"{result['taxonomy_candidate']}"
+                    )
+
+            except Exception as exc:
+                print(
+                    f"[{i:02}/{len(rows)}] ERROR: {exc}"
+                )
+
+    total = time.monotonic() - started
+
+    print()
+    print("=" * 80)
+    print("BENCHMARK")
+    print("=" * 80)
+
+    if results:
+
+        print(f"Erfolgreich:       {len(results)}")
+        print(f"Gesamtzeit:        {total:.1f}s")
+        print(
+            f"Ø Einzelrequest:   "
+            f"{sum(x[1] for x in results) / len(results):.1f}s"
+        )
+        print(
+            f"Durchsatz:         "
+            f"{len(results) / total * 3600:.1f} Tickets/Stunde"
+        )
+
+        throughput = len(results) / total * 3600
+
+        print(
+            f"Hochrechnung 4404: "
+            f"{4404 / throughput:.1f} Stunden"
+        )
+
+        print(
+            f"Hochrechnung Tage: "
+            f"{4404 / throughput / 24:.2f}"
+        )
+
+
+if __name__ == "__main__":
+    main()

+ 355 - 0
tools/build_zammad_analysis.py

@@ -0,0 +1,355 @@
+#!/usr/bin/env python3
+
+import html
+import re
+import sqlite3
+from pathlib import Path
+
+SRC = Path("data/zammad_history.sqlite3")
+DST = Path("data/zammad_analysis.sqlite3")
+
+
+def clean_body(text):
+    if not text:
+        return ""
+
+    text = html.unescape(text)
+
+    # HTML entfernen
+    text = re.sub(
+        r"<(script|style).*?</\1>",
+        " ",
+        text,
+        flags=re.I | re.S,
+    )
+    text = re.sub(r"<br\s*/?>", "\n", text, flags=re.I)
+    text = re.sub(r"<[^>]+>", " ", text)
+
+    # typische Mail-Header in Zitaten
+    text = re.sub(
+        r"\n\s*(Am .*? schrieb .*?:|"
+        r"On .*? wrote:|"
+        r"Gesendet: .*?\n|"
+        r"Von: .*?\n|"
+        r"From: .*?\n|"
+        r"Betreff: .*?\n|"
+        r"Subject: .*?\n)",
+        "\n",
+        text,
+        flags=re.I,
+    )
+
+    # klassische Signaturen nur am Ende abschneiden
+    text = re.split(
+        r"\n\s*(Mit freundlichen Grüßen|"
+        r"Viele Grüße|"
+        r"Beste Grüße|"
+        r"Freundliche Grüße)\b",
+        text,
+        maxsplit=1,
+        flags=re.I,
+    )[0]
+
+    # Zitatzeilen
+    lines = []
+    for line in text.splitlines():
+        if line.lstrip().startswith(">"):
+            continue
+        lines.append(line)
+
+    text = "\n".join(lines)
+
+    text = re.sub(r"[ \t]+", " ", text)
+    text = re.sub(r"\n{3,}", "\n\n", text)
+
+    return text.strip()
+
+
+def role(sender, internal):
+    if internal:
+        return "INTERNAL"
+
+    sender = (sender or "").lower()
+
+    if sender == "customer":
+        return "CUSTOMER"
+
+    if sender == "agent":
+        return "AGENT"
+
+    if sender == "system":
+        return "UNKNOWN"
+
+    return "UNKNOWN"
+
+
+# ------------------------------------------------------------
+
+src = sqlite3.connect(SRC)
+src.row_factory = sqlite3.Row
+
+if DST.exists():
+    DST.unlink()
+
+dst = sqlite3.connect(DST)
+
+dst.executescript("""
+CREATE TABLE articles (
+    id INTEGER PRIMARY KEY,
+    ticket_id INTEGER NOT NULL,
+
+    sender TEXT,
+    role TEXT NOT NULL,
+    internal INTEGER NOT NULL DEFAULT 0,
+    type TEXT,
+
+    subject TEXT,
+
+    raw_body TEXT,
+    clean_body TEXT,
+
+    created_at TEXT
+);
+
+CREATE INDEX idx_articles_ticket
+    ON articles(ticket_id);
+
+CREATE INDEX idx_articles_role
+    ON articles(role);
+
+CREATE TABLE cases (
+    ticket_id INTEGER PRIMARY KEY,
+
+    ticket_number TEXT,
+    title TEXT,
+
+    group_name TEXT,
+    state TEXT,
+
+    created_at TEXT,
+    updated_at TEXT,
+
+    article_count INTEGER,
+    customer_count INTEGER,
+    agent_count INTEGER,
+    unknown_count INTEGER,
+    internal_count INTEGER,
+
+    customer_text TEXT,
+    agent_text TEXT,
+    unknown_text TEXT,
+
+    conversation TEXT,
+
+    classification_status TEXT DEFAULT 'NEW',
+
+    communication_classification TEXT,
+    primary_intent TEXT,
+    secondary_intents TEXT,
+    complaint_type TEXT,
+    actions TEXT,
+
+    taxonomy_fit TEXT,
+    taxonomy_candidate TEXT,
+
+    summary TEXT,
+    classification_json TEXT
+);
+
+CREATE INDEX idx_cases_status
+    ON cases(classification_status);
+""")
+
+tickets = src.execute("""
+    SELECT *
+    FROM tickets
+    ORDER BY id
+""").fetchall()
+
+print("=" * 72)
+print("ZAMMAD ANALYSE-DATENBANK")
+print("=" * 72)
+print(f"Tickets: {len(tickets)}")
+print()
+
+article_total = 0
+
+for pos, ticket in enumerate(tickets, 1):
+
+    articles = src.execute("""
+        SELECT *
+        FROM articles
+        WHERE ticket_id = ?
+        ORDER BY created_at, id
+    """, (ticket["id"],)).fetchall()
+
+    customer = []
+    agent = []
+    unknown = []
+    conversation = []
+
+    counts = {
+        "CUSTOMER": 0,
+        "AGENT": 0,
+        "UNKNOWN": 0,
+        "INTERNAL": 0,
+    }
+
+    for a in articles:
+
+        raw = a["body_text"] or ""
+        clean = clean_body(raw)
+
+        r = role(
+            a["sender"],
+            a["internal"],
+        )
+
+        counts[r] += 1
+        article_total += 1
+
+        dst.execute("""
+            INSERT INTO articles (
+                id,
+                ticket_id,
+                sender,
+                role,
+                internal,
+                type,
+                subject,
+                raw_body,
+                clean_body,
+                created_at
+            )
+            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+        """, (
+            a["id"],
+            a["ticket_id"],
+            a["sender"],
+            r,
+            int(a["internal"] or 0),
+            a["type"],
+            a["subject"],
+            raw,
+            clean,
+            a["created_at"],
+        ))
+
+        if not clean:
+            continue
+
+        if r == "CUSTOMER":
+            customer.append(clean)
+            conversation.append(
+                "[CUSTOMER]\n" + clean
+            )
+
+        elif r == "AGENT":
+            agent.append(clean)
+            conversation.append(
+                "[AGENT]\n" + clean
+            )
+
+        elif r == "UNKNOWN":
+            unknown.append(clean)
+            conversation.append(
+                "[UNKNOWN]\n" + clean
+            )
+
+        # INTERNAL bewusst nicht in conversation
+
+    dst.execute("""
+        INSERT INTO cases (
+            ticket_id,
+            ticket_number,
+            title,
+            group_name,
+            state,
+            created_at,
+            updated_at,
+
+            article_count,
+            customer_count,
+            agent_count,
+            unknown_count,
+            internal_count,
+
+            customer_text,
+            agent_text,
+            unknown_text,
+            conversation
+        )
+        VALUES (
+            ?, ?, ?, ?, ?, ?, ?,
+            ?, ?, ?, ?, ?,
+            ?, ?, ?, ?
+        )
+    """, (
+        ticket["id"],
+        ticket["number"],
+        ticket["title"],
+        ticket["group_name"],
+        ticket["state"],
+        ticket["created_at"],
+        ticket["updated_at"],
+
+        len(articles),
+        counts["CUSTOMER"],
+        counts["AGENT"],
+        counts["UNKNOWN"],
+        counts["INTERNAL"],
+
+        "\n\n".join(customer),
+        "\n\n".join(agent),
+        "\n\n".join(unknown),
+        "\n\n".join(conversation),
+    ))
+
+    if pos % 500 == 0:
+        dst.commit()
+
+        print(
+            f"[{pos:4}/{len(tickets)}] "
+            f"Artikel: {article_total}"
+        )
+
+dst.commit()
+
+print()
+print("=" * 72)
+print("FERTIG")
+print("=" * 72)
+
+for row in dst.execute("""
+    SELECT role, COUNT(*)
+    FROM articles
+    GROUP BY role
+    ORDER BY COUNT(*) DESC
+"""):
+    print(
+        f"Artikel {row[0]:10} {row[1]}"
+    )
+
+print()
+
+row = dst.execute("""
+    SELECT
+        COUNT(*),
+        SUM(customer_count > 0),
+        SUM(agent_count > 0),
+        SUM(unknown_count > 0),
+        SUM(internal_count > 0)
+    FROM cases
+""").fetchone()
+
+print(f"Cases:              {row[0]}")
+print(f"mit Kundenbeitrag:  {row[1]}")
+print(f"mit Agentantwort:   {row[2]}")
+print(f"mit UNKNOWN:        {row[3]}")
+print(f"mit internen Notiz: {row[4]}")
+
+print()
+print(f"DB: {DST}")
+
+src.close()
+dst.close()

+ 305 - 0
tools/classify_historical_intents.py

@@ -0,0 +1,305 @@
+#!/usr/bin/env python3
+
+import asyncio
+import json
+import sqlite3
+import urllib.request
+from collections import Counter
+from pathlib import Path
+
+
+DB = "data/telephony.sqlite3"
+OUT = Path("data/historical_intent_classification.json")
+
+OLLAMA = "http://127.0.0.1:11434/api/generate"
+MODEL = "qwen3:8b"
+
+TAXONOMY = [
+    "VERSANDSTATUS",
+    "LIEFERVERZUG",
+    "ADRESSÄNDERUNG",
+    "REKLAMATION",
+    "TRANSPORTSCHADEN",
+    "FEHLLIEFERUNG",
+    "RECHNUNG",
+    "ZAHLUNG",
+    "WIDERRUF",
+    "RETOURE",
+    "PFLANZENBERATUNG",
+    "SORTENBERATUNG",
+    "PFLEGEFRAGE",
+    "BESTANDSANFRAGE",
+    "VORBESTELLUNG",
+    "B2B",
+    "GROSSHANDEL",
+    "SONSTIGES",
+]
+
+
+def load_json(value):
+    try:
+        return json.loads(value or "{}")
+    except Exception:
+        return {}
+
+
+def ollama_generate(prompt):
+    payload = {
+        "model": MODEL,
+        "prompt": prompt,
+        "stream": False,
+        "format": "json",
+        "options": {
+            "temperature": 0
+        },
+    }
+
+    request = urllib.request.Request(
+        OLLAMA,
+        data=json.dumps(payload).encode("utf-8"),
+        headers={"Content-Type": "application/json"},
+        method="POST",
+    )
+
+    with urllib.request.urlopen(request, timeout=300) as response:
+        data = json.loads(response.read().decode("utf-8"))
+
+    return data.get("response", "")
+
+
+def parse_model_json(text):
+    text = text.strip()
+
+    if text.startswith("```"):
+        text = text.replace("```json", "", 1)
+        text = text.replace("```", "")
+        text = text.strip()
+
+    return json.loads(text)
+
+
+async def main():
+
+    con = sqlite3.connect(DB)
+    con.row_factory = sqlite3.Row
+
+    rows = con.execute("""
+        SELECT
+            a.id AS analysis_id,
+            a.analysis_json,
+            t.transcript_json,
+            c.source_caller_id,
+            c.destination_caller_id,
+            c.start_time
+        FROM analyses a
+        JOIN transcripts t
+          ON t.id = a.transcript_id
+        JOIN cdr_calls c
+          ON c.id = a.cdr_row_id
+        WHERE a.cdr_row_id IS NOT NULL
+        ORDER BY a.id
+    """).fetchall()
+
+    con.close()
+
+    print(f"Historische Gespräche: {len(rows)}")
+
+    results = []
+
+    for index, row in enumerate(rows, 1):
+
+        analysis = load_json(row["analysis_json"])
+
+        transcript = row["transcript_json"] or ""
+
+        print(
+            f"[{index}/{len(rows)}] "
+            f"analysis={row['analysis_id']} "
+            f"→ Qwen"
+        )
+
+        prompt = f"""
+Du klassifizierst ein reales deutsches Kundentelefonat
+für einen Gartenpflanzen-Webshop.
+
+Wir entwickeln daraus eine Telefon-KI.
+Die Klassifikation muss deshalb konservativ und praxisnah sein.
+
+Wähle genau EINEN primary_intent aus dieser Taxonomie:
+
+{json.dumps(TAXONOMY, ensure_ascii=False)}
+
+Regeln:
+
+VERSANDSTATUS:
+Der Kunde fragt nach dem aktuellen Versand-/Paketstatus.
+
+LIEFERVERZUG:
+Eine erwartete Lieferung ist überfällig oder verspätet.
+
+ADRESSÄNDERUNG:
+Änderung einer Liefer-, Rechnungs- oder Kontaktadresse.
+
+REKLAMATION:
+Beanstandung eines gelieferten Produkts oder einer Leistung.
+
+TRANSPORTSCHADEN:
+Produkt wurde beim Transport beschädigt.
+
+FEHLLIEFERUNG:
+Es wurde ein falscher Artikel geliefert.
+
+RECHNUNG:
+Frage zur Rechnung, Rechnungsstellung oder Rechnungsdaten.
+
+ZAHLUNG:
+Zahlungsstatus, Zahlungsart, Zahlungseingang oder Zahlungsproblem.
+
+WIDERRUF:
+Widerruf eines Kaufs.
+
+RETOURE:
+Rückgabe/Rücksendung eines Artikels.
+
+PFLANZENBERATUNG:
+Allgemeine Beratung zu Pflanzen, Standort, Pflanzung oder Auswahl.
+
+SORTENBERATUNG:
+Konkrete Beratung zur Auswahl oder Eignung einer Sorte.
+
+PFLEGEFRAGE:
+Pflege, Schnitt, Düngung, Krankheiten oder Überwinterung.
+
+BESTANDSANFRAGE:
+Frage, ob eine konkrete Pflanze/ein Produkt verfügbar ist.
+
+VORBESTELLUNG:
+Vorbestellung bzw. Reservierung eines noch nicht verfügbaren Artikels.
+
+B2B:
+Geschäftskunde mit individuellem gewerblichen Anliegen.
+
+GROSSHANDEL:
+Gewerblicher Großhandel bzw. größere Mengen / Wiederverkauf.
+
+SONSTIGES:
+Wenn keine Kategorie ausreichend passt.
+
+WICHTIG:
+
+- Nicht aus Kundennummern, Telefonnummern oder Produktnamen raten.
+- Das tatsächliche Gespräch ist maßgeblich.
+- Bei mehreren Themen ist das Hauptanliegen des Anrufers primary_intent.
+- Weitere eindeutig vorhandene Anliegen gehören in secondary_intents.
+- Keine Kategorie nur deshalb wählen, weil sie in der bestehenden Qwen-Analyse steht.
+- Die bestehende Analyse ist nur Hilfskontext.
+- Der vollständige Transkripttext ist maßgeblich.
+
+Bestehende Qwen-Analyse:
+{json.dumps(analysis, ensure_ascii=False, indent=2)}
+
+Transkript:
+{transcript}
+
+Antworte ausschließlich als JSON:
+
+{{
+  "primary_intent": "EIN_WERT_AUS_DER_TAXONOMIE",
+  "secondary_intents": [],
+  "customer_goal": "kurze präzise Beschreibung",
+  "requires_customer_context": true,
+  "requires_order_context": false,
+  "requires_product_context": false,
+  "requires_previous_contact_context": false,
+  "requires_human_action": false,
+  "confidence": 0.0,
+  "reason": "kurze Begründung"
+}}
+"""
+
+        try:
+            response = await asyncio.to_thread(
+                ollama_generate,
+                prompt,
+            )
+
+            classification = parse_model_json(response)
+
+        except Exception as exc:
+            print(f"  FEHLER: {exc}")
+            classification = {
+                "primary_intent": "SONSTIGES",
+                "secondary_intents": [],
+                "customer_goal": None,
+                "requires_customer_context": True,
+                "requires_order_context": False,
+                "requires_product_context": False,
+                "requires_previous_contact_context": False,
+                "requires_human_action": True,
+                "confidence": 0,
+                "reason": f"LLM error: {exc}",
+            }
+
+        if classification.get("primary_intent") not in TAXONOMY:
+            classification["primary_intent"] = "SONSTIGES"
+
+        classification["_analysis_id"] = row["analysis_id"]
+        classification["_cdr_row_id"] = row["analysis_id"]
+        classification["_rec_id"] = None
+        classification["_start_time"] = row["start_time"]
+
+        results.append(classification)
+
+    intent_counts = Counter(
+        r["primary_intent"]
+        for r in results
+    )
+
+    context_counts = Counter()
+
+    for r in results:
+        for field in (
+            "requires_customer_context",
+            "requires_order_context",
+            "requires_product_context",
+            "requires_previous_contact_context",
+            "requires_human_action",
+        ):
+            if r.get(field):
+                context_counts[field] += 1
+
+    output = {
+        "model": MODEL,
+        "count": len(results),
+        "taxonomy": TAXONOMY,
+        "intent_counts": dict(intent_counts.most_common()),
+        "context_requirements": dict(context_counts),
+        "calls": results,
+    }
+
+    OUT.write_text(
+        json.dumps(
+            output,
+            ensure_ascii=False,
+            indent=2,
+        )
+    )
+
+    print()
+    print("=" * 70)
+    print("HISTORISCHE INTENT-KLASSIFIKATION FERTIG")
+    print("=" * 70)
+
+    print("\nINTENTS:")
+    for intent, count in intent_counts.most_common():
+        print(f"{count:3}  {intent}")
+
+    print("\nKONTEXTBEDARF:")
+    for key, count in context_counts.most_common():
+        print(f"{count:3}  {key}")
+
+    print(f"\nErgebnis: {OUT}")
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 334 - 0
tools/classify_historical_multi_intent.py

@@ -0,0 +1,334 @@
+#!/usr/bin/env python3
+
+import asyncio
+import json
+import sqlite3
+import urllib.request
+from collections import Counter
+from pathlib import Path
+
+DB = "data/telephony.sqlite3"
+OUT = Path("data/historical_multi_intent.json")
+OLLAMA = "http://127.0.0.1:11434/api/generate"
+MODEL = "qwen3:8b"
+
+INTENTS = [
+    "VERSANDSTATUS",
+    "LIEFERVERZUG",
+    "ADRESSÄNDERUNG",
+    "REKLAMATION",
+    "TRANSPORTSCHADEN",
+    "FEHLLIEFERUNG",
+    "RECHNUNG",
+    "ZAHLUNG",
+    "WIDERRUF",
+    "RETOURE",
+    "PFLANZENBERATUNG",
+    "SORTENBERATUNG",
+    "PFLEGEFRAGE",
+    "BESTANDSANFRAGE",
+    "VORBESTELLUNG",
+    "BESTELLUNG",
+    "BESTELLÄNDERUNG",
+    "STORNIERUNG",
+    "B2B",
+    "GROSSHANDEL",
+    "SONSTIGES",
+]
+
+ACTIONS = [
+    "KEINE_AKTION",
+    "KUNDENKONTEXT_PRÜFEN",
+    "BESTELLUNG_PRÜFEN",
+    "BESTELLUNG_ÄNDERN",
+    "BESTELLUNG_STORNIEREN",
+    "BESTELLUNGEN_ZUSAMMENFÜHREN",
+    "VERSANDSTATUS_PRÜFEN",
+    "LIEFERTERMIN_PRÜFEN",
+    "ADRESSE_ÄNDERN",
+    "REKLAMATION_ERFASSEN",
+    "TRANSPORTSCHADEN_ERFASSEN",
+    "FEHLLIEFERUNG_PRÜFEN",
+    "RECHNUNG_PRÜFEN",
+    "RECHNUNG_KORRIGIEREN",
+    "ZAHLUNG_PRÜFEN",
+    "GUTSCHRIFT_ERSTELLEN",
+    "RETOURE_ERFASSEN",
+    "PRODUKT_IDENTIFIZIEREN",
+    "BESTAND_PRÜFEN",
+    "SORTEN_ALTERNATIVE_PRÜFEN",
+    "PFLEGEHINWEIS_GEBEN",
+    "BILD_ANFORDERN",
+    "MENSCHLICHE_BEARBEITUNG_ERFORDERLICH",
+    "RÜCKRUF",
+    "E-MAIL_SENDEN",
+]
+
+def load_json(value):
+    try:
+        return json.loads(value or "{}")
+    except Exception:
+        return {}
+
+def ask_qwen(prompt):
+    payload = {
+        "model": MODEL,
+        "prompt": prompt,
+        "stream": False,
+        "format": "json",
+        "options": {"temperature": 0},
+    }
+
+    req = urllib.request.Request(
+        OLLAMA,
+        data=json.dumps(payload).encode(),
+        headers={"Content-Type": "application/json"},
+        method="POST",
+    )
+
+    with urllib.request.urlopen(req, timeout=300) as r:
+        return json.loads(r.read().decode()).get("response", "")
+
+def parse_json(text):
+    text = text.strip()
+    if text.startswith("```"):
+        text = text.replace("```json", "", 1)
+        text = text.replace("```", "").strip()
+    return json.loads(text)
+
+async def main():
+    con = sqlite3.connect(DB)
+    con.row_factory = sqlite3.Row
+
+    rows = con.execute("""
+        SELECT
+            a.id AS analysis_id,
+            a.analysis_json,
+            t.transcript_json,
+            c.source_caller_id,
+            c.destination_caller_id,
+            c.start_time
+        FROM analyses a
+        JOIN transcripts t ON t.id = a.transcript_id
+        JOIN cdr_calls c ON c.id = a.cdr_row_id
+        WHERE a.cdr_row_id IS NOT NULL
+        ORDER BY a.id
+    """).fetchall()
+
+    con.close()
+
+    print(f"Historische Gespräche: {len(rows)}")
+
+    results = []
+
+    for n, row in enumerate(rows, 1):
+        existing = load_json(row["analysis_json"])
+        transcript = row["transcript_json"] or ""
+
+        print(
+            f"[{n}/{len(rows)}] "
+            f"analysis={row['analysis_id']}"
+        )
+
+        prompt = f"""
+Du analysierst ein echtes deutsches Kundentelefonat eines
+Gartenpflanzen-Webshops für eine Telefon-KI.
+
+WICHTIG:
+Ein Gespräch kann MEHRERE Anliegen enthalten.
+
+ZUERST muss der Gesprächszustand bestimmt werden:
+
+- HUMAN_CONVERSATION:
+  tatsächliches Gespräch mit einem Kunden/Mitarbeiter und verwertbarem Inhalt
+- NO_CONTENT:
+  leeres/fehlerhaftes Transkript, keine verwertbaren Gesprächsinhalte
+- VOICEMAIL:
+  automatische Mailbox/Ansage ohne echtes Kundengespräch
+- UNKNOWN:
+  Inhalt vorhanden, aber nicht zuverlässig klassifizierbar
+
+Nur bei HUMAN_CONVERSATION werden Intents klassifiziert.
+
+Ermittle:
+1. call_state
+2. primary_intent = wichtigstes tatsächliches Anliegen
+3. secondary_intents = ALLE weiteren tatsächlich behandelten Anliegen
+4. entities = konkret genannte Bestellungen und Produkte
+5. actions = tatsächlich notwendige oder vereinbarte Aktionen
+
+SEHR WICHTIG:
+SONSTIGES ist der LETZTE FALLBACK.
+
+Wenn das Gespräch beispielsweise:
+- eine Pflegefrage enthält → PFLEGEFRAGE
+- eine Rechnungskorrektur enthält → RECHNUNG
+- eine Beanstandung enthält → REKLAMATION
+- eine beschädigte Lieferung enthält → TRANSPORTSCHADEN
+- eine Bestellung verändert → BESTELLÄNDERUNG
+- mehrere Aufträge/Bestellungen behandelt → BESTELLUNG bzw. BESTELLÄNDERUNG
+- Verfügbarkeit eines Artikels behandelt → BESTANDSANFRAGE
+
+darf NICHT SONSTIGES gewählt werden.
+
+Wenn mehrere dieser Themen vorkommen, müssen sie als
+primary_intent + secondary_intents abgebildet werden.
+
+Beispiel:
+Bestelländerung + Versandstatus + Lieferproblem
+
+→ primary_intent = BESTELLÄNDERUNG
+→ secondary_intents = [VERSANDSTATUS, LIEFERVERZUG]
+
+Beispiel:
+Rechnung falsch + Gutschrift
+
+→ primary_intent = RECHNUNG
+→ secondary_intents = [REKLAMATION]
+
+Beispiel:
+Beschädigte Pflanzen + Qualitätsbeanstandung
+
+→ primary_intent = REKLAMATION
+→ secondary_intents = [TRANSPORTSCHADEN]
+
+SONSTIGES darf nur verwendet werden, wenn nach Prüfung
+aller definierten Kategorien wirklich keine passt.
+
+Niemals mehrere Anliegen in einen einzigen Intent quetschen.
+
+INTENTS:
+{json.dumps(INTENTS, ensure_ascii=False)}
+
+AKTIONEN:
+{json.dumps(ACTIONS, ensure_ascii=False)}
+
+Regeln:
+- Nur tatsächlich aus dem Gespräch ableiten.
+- Nicht aus Vermutungen ergänzen.
+- BESTELLUNG verwenden, wenn eine Bestellung aufgegeben,
+  ergänzt oder konkret bearbeitet wird.
+- BESTELLÄNDERUNG verwenden, wenn eine bestehende Bestellung
+  verändert wird.
+- STORNIERUNG verwenden, wenn eine Bestellung aufgehoben werden soll.
+- VERSANDSTATUS und LIEFERVERZUG unterscheiden.
+- REKLAMATION ist eine Beanstandung.
+- TRANSPORTSCHADEN ist eine durch Transport verursachte Beschädigung.
+- PFLANZENBERATUNG = allgemeine Beratung.
+- SORTENBERATUNG = Auswahl zwischen konkreten Sorten.
+- PFLEGEFRAGE = Pflege/Gesundheit/Schnitt/Düngung etc.
+- BESTANDSANFRAGE = Verfügbarkeit.
+- SONSTIGES nur, wenn keine andere Kategorie passt.
+
+Ein bereits vorhandenes Qwen-Ergebnis ist nur Hilfskontext.
+Das Transkript ist maßgeblich.
+
+BESTEHENDE ANALYSE:
+{json.dumps(existing, ensure_ascii=False, indent=2)}
+
+TRANSKRIPT:
+{transcript}
+
+Antworte ausschließlich als JSON:
+
+{{
+  "call_state": "HUMAN_CONVERSATION",
+  "primary_intent": "INTENT",
+  "secondary_intents": [],
+  "customer_goal": "",
+  "entities": {{
+    "orders": [],
+    "products": []
+  }},
+  "actions": [],
+  "requires_customer_context": true,
+  "requires_order_context": false,
+  "requires_product_context": false,
+  "requires_previous_contact_context": false,
+  "requires_human_action": false,
+  "confidence": 0.0,
+  "reason": ""
+}}
+"""
+
+        try:
+            raw = await asyncio.to_thread(ask_qwen, prompt)
+            result = parse_json(raw)
+        except Exception as exc:
+            print(f"  FEHLER: {exc}")
+            result = {
+                "primary_intent": "SONSTIGES",
+                "secondary_intents": [],
+                "customer_goal": "",
+                "entities": {"orders": [], "products": []},
+                "actions": ["MENSCHLICHE_BEARBEITUNG_ERFORDERLICH"],
+                "requires_customer_context": True,
+                "requires_order_context": False,
+                "requires_product_context": False,
+                "requires_previous_contact_context": False,
+                "requires_human_action": True,
+                "confidence": 0,
+                "reason": str(exc),
+            }
+
+        if result.get("primary_intent") not in INTENTS:
+            result["primary_intent"] = "SONSTIGES"
+
+        result["secondary_intents"] = [
+            x for x in result.get("secondary_intents", [])
+            if x in INTENTS and x != result["primary_intent"]
+        ]
+
+        result["actions"] = [
+            x for x in result.get("actions", [])
+            if x in ACTIONS
+        ]
+
+        result["_analysis_id"] = row["analysis_id"]
+        result["_cdr_row_id"] = row["analysis_id"]
+        result["_start_time"] = row["start_time"]
+
+        results.append(result)
+
+    primary = Counter()
+    secondary = Counter()
+    actions = Counter()
+
+    for r in results:
+        primary[r["primary_intent"]] += 1
+        secondary.update(r["secondary_intents"])
+        actions.update(r["actions"])
+
+    output = {
+        "model": MODEL,
+        "count": len(results),
+        "primary_intents": dict(primary.most_common()),
+        "secondary_intents": dict(secondary.most_common()),
+        "actions": dict(actions.most_common()),
+        "calls": results,
+    }
+
+    OUT.write_text(
+        json.dumps(output, ensure_ascii=False, indent=2)
+    )
+
+    print("\n" + "=" * 72)
+    print("MULTI-INTENT ANALYSE FERTIG")
+    print("=" * 72)
+
+    print("\nPRIMARY INTENTS")
+    for k, v in primary.most_common():
+        print(f"{v:3}  {k}")
+
+    print("\nSECONDARY INTENTS")
+    for k, v in secondary.most_common():
+        print(f"{v:3}  {k}")
+
+    print("\nAKTIONEN")
+    for k, v in actions.most_common():
+        print(f"{v:3}  {k}")
+
+    print(f"\nGespeichert: {OUT}")
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 495 - 0
tools/classify_historical_v2.py

@@ -0,0 +1,495 @@
+#!/usr/bin/env python3
+
+import asyncio
+import json
+import sqlite3
+import urllib.request
+from collections import Counter
+from pathlib import Path
+
+BASE = Path(".")
+DB = BASE / "data/telephony.sqlite3"
+TAXONOMY_FILE = BASE / "config/telefonie_taxonomy.json"
+OUT = BASE / "data/historical_intent_v2.json"
+
+OLLAMA = "http://127.0.0.1:11434/api/generate"
+MODEL = "qwen3:8b"
+
+
+def ask_qwen(prompt):
+    payload = {
+        "model": MODEL,
+        "prompt": prompt,
+        "stream": False,
+        "format": "json",
+        "options": {
+            "temperature": 0
+        }
+    }
+
+    req = urllib.request.Request(
+        OLLAMA,
+        data=json.dumps(
+            payload,
+            ensure_ascii=False
+        ).encode("utf-8"),
+        headers={
+            "Content-Type": "application/json"
+        },
+        method="POST"
+    )
+
+    with urllib.request.urlopen(req, timeout=300) as response:
+        data = json.loads(
+            response.read().decode("utf-8")
+        )
+
+    return data["response"]
+
+
+def parse_json(text):
+    text = text.strip()
+
+    if text.startswith("```"):
+        text = text.replace("```json", "", 1)
+        text = text.replace("```", "")
+        text = text.strip()
+
+    return json.loads(text)
+
+
+def load_json(value):
+    try:
+        return json.loads(value or "{}")
+    except Exception:
+        return {}
+
+
+async def main():
+
+    taxonomy = json.loads(
+        TAXONOMY_FILE.read_text()
+    )
+
+    groups = taxonomy["groups"]
+
+    intents = []
+    for values in groups.values():
+        intents.extend(values)
+
+    complaint_types = list(
+        taxonomy["complaint_types"].keys()
+    )
+
+    call_states = taxonomy["call_states"]
+
+    actions = [
+        "KEINE_AKTION",
+        "KUNDENKONTEXT_PRÜFEN",
+        "BESTELLUNG_PRÜFEN",
+        "BESTELLUNG_ÄNDERN",
+        "BESTELLUNG_STORNIEREN",
+        "BESTELLUNGEN_ZUSAMMENFÜHREN",
+        "VERSANDSTATUS_PRÜFEN",
+        "LIEFERTERMIN_PRÜFEN",
+        "ADRESSE_ÄNDERN",
+        "REKLAMATION_ERFASSEN",
+        "TRANSPORTSCHADEN_ERFASSEN",
+        "FEHLLIEFERUNG_PRÜFEN",
+        "RECHNUNG_PRÜFEN",
+        "RECHNUNG_KORRIGIEREN",
+        "ZAHLUNG_PRÜFEN",
+        "GUTSCHRIFT_ERSTELLEN",
+        "RETOURE_ERFASSEN",
+        "PRODUKT_IDENTIFIZIEREN",
+        "BESTAND_PRÜFEN",
+        "SORTEN_ALTERNATIVE_PRÜFEN",
+        "PFLEGEHINWEIS_GEBEN",
+        "BILD_ANFORDERN",
+        "MENSCHLICHE_BEARBEITUNG_ERFORDERLICH",
+        "RÜCKRUF",
+        "E_MAIL_SENDEN",
+    ]
+
+    con = sqlite3.connect(DB)
+    con.row_factory = sqlite3.Row
+
+    rows = con.execute("""
+        SELECT
+            a.id AS analysis_id,
+            a.cdr_row_id,
+            a.analysis_json,
+            t.transcript_json,
+            c.source_caller_id,
+            c.destination_caller_id,
+            c.start_time
+        FROM analyses a
+        JOIN transcripts t
+          ON t.id = a.transcript_id
+        JOIN cdr_calls c
+          ON c.id = a.cdr_row_id
+        ORDER BY a.id
+    """).fetchall()
+
+    con.close()
+
+    print("=" * 72)
+    print("HISTORISCHE TELEFON-KI V2")
+    print("=" * 72)
+    print(f"Gespräche: {len(rows)}")
+    print(f"Modell:    {MODEL}")
+    print(f"Taxonomie: {TAXONOMY_FILE}")
+
+    results = []
+
+    for number, row in enumerate(rows, 1):
+
+        existing = load_json(
+            row["analysis_json"]
+        )
+
+        transcript = row["transcript_json"] or ""
+
+        old_analysis = existing.get(
+            "analysis",
+            {}
+        )
+
+        customer = existing.get(
+            "customer",
+            {}
+        )
+
+        products = existing.get(
+            "products",
+            []
+        )
+
+        evidence = {
+            "existing_intent": old_analysis.get(
+                "intent"
+            ),
+            "summary": old_analysis.get(
+                "summary"
+            ),
+            "advice": old_analysis.get(
+                "advice"
+            ),
+            "follow_up": old_analysis.get(
+                "follow_up"
+            ),
+            "customer": customer,
+            "products": products,
+            "uncertainties": existing.get(
+                "uncertainties",
+                []
+            )
+        }
+
+        print(
+            f"[{number:02}/{len(rows):02}] "
+            f"analysis={row['analysis_id']}"
+        )
+
+        prompt = f"""
+Du bist die Klassifikations-KI einer Telefon-Middleware
+für einen deutschen Gartenpflanzen-Webshop.
+
+Du analysierst ein bereits transkribiertes Kundentelefonat.
+
+Die zentrale Taxonomie ist verbindlich.
+
+TAXONOMIE:
+{json.dumps(groups, ensure_ascii=False, indent=2)}
+
+REKLAMATIONSGRÜNDE:
+{json.dumps(taxonomy["complaint_types"], ensure_ascii=False, indent=2)}
+
+CALL STATES:
+{json.dumps(call_states, ensure_ascii=False)}
+
+AKTIONEN:
+{json.dumps(actions, ensure_ascii=False)}
+
+WICHTIG:
+Ein Gespräch kann mehrere Intents enthalten.
+
+Zuerst CALL STATE bestimmen.
+
+HUMAN_CONVERSATION:
+Es findet ein echtes Gespräch mit verwertbarem Inhalt statt.
+
+NO_CONTENT:
+Kein verwertbarer Gesprächsinhalt.
+
+AUTOMATED_MESSAGE:
+Mailbox, automatische Ansage, Systemansage oder ähnliche Aufnahme.
+
+VOICEMAIL:
+Nachricht auf einer Mailbox.
+
+UNKNOWN:
+Inhalt vorhanden, aber nicht zuverlässig einzuordnen.
+
+Nur bei HUMAN_CONVERSATION werden geschäftliche Intents vergeben.
+
+DOKUMENTE ist ein echter Intent.
+Ein Anliegen zu Kaufvertrag, Rechnungskopie, Dokumentenkopie,
+Unterlagen usw. darf NICHT SONSTIGES sein.
+
+REKLAMATION ist ein echter Hauptintent.
+
+Wenn eine Reklamation vorliegt, prüfe zusätzlich
+complaint_type.
+
+Mögliche complaint_type:
+
+{json.dumps(complaint_types, ensure_ascii=False)}
+
+Beispiele:
+
+"Die Erde war verunreinigt."
+→ primary_intent = REKLAMATION
+→ complaint_type = PRODUKTQUALITÄT
+
+"Der Dünger war nicht dabei."
+→ primary_intent = REKLAMATION
+→ complaint_type = DUENGER_FEHLT
+
+"Die Pflanzen waren schlecht verpackt."
+→ primary_intent = REKLAMATION
+→ complaint_type = SCHLECHTVERPACKT
+
+"Ich habe eine andere Rosensorte bekommen."
+→ primary_intent = REKLAMATION
+→ complaint_type = FALSCHESORTEGELIEFERT
+
+"Die Pflanze wurde auf dem Transport beschädigt."
+→ primary_intent = REKLAMATION
+→ complaint_type = TRANSPORTSCHADEN
+
+"Der falsche Artikel wurde geliefert."
+→ primary_intent = REKLAMATION
+→ complaint_type = FEHLLIEFERUNG
+
+MEHRERE INTENTS:
+
+Wenn ein Gespräch mehrere Anliegen enthält,
+müssen diese getrennt werden.
+
+Beispiel:
+
+Bestellung ändern + Versandstatus prüfen
+
+primary_intent:
+BESTELLÄNDERUNG
+
+secondary_intents:
+["VERSANDSTATUS"]
+
+Beispiel:
+
+Rechnung falsch + Gutschrift
+
+primary_intent:
+RECHNUNG
+
+actions:
+["RECHNUNG_PRÜFEN", "RECHNUNG_KORRIGIEREN",
+ "GUTSCHRIFT_ERSTELLEN"]
+
+SONSTIGES IST DER LETZTE FALLBACK.
+
+Wenn eine passende Kategorie existiert,
+darf SONSTIGES NICHT verwendet werden.
+
+BESTEHENDE QWEN-ANALYSE:
+{json.dumps(evidence, ensure_ascii=False, indent=2)}
+
+TRANSKRIPT:
+{transcript}
+
+Antworte ausschließlich mit diesem JSON:
+
+{{
+  "call_state": "HUMAN_CONVERSATION",
+  "primary_intent": "INTENT",
+  "secondary_intents": [],
+  "complaint_type": null,
+  "customer_goal": "",
+  "actions": [],
+  "requires_customer_context": false,
+  "requires_order_context": false,
+  "requires_product_context": false,
+  "requires_previous_contact_context": false,
+  "requires_human_action": false,
+  "confidence": 0.0,
+  "reason": ""
+}}
+"""
+
+        try:
+            raw = await asyncio.to_thread(
+                ask_qwen,
+                prompt
+            )
+
+            result = parse_json(raw)
+
+        except Exception as exc:
+            result = {
+                "call_state": "UNKNOWN",
+                "primary_intent": "SONSTIGES",
+                "secondary_intents": [],
+                "complaint_type": None,
+                "customer_goal": "",
+                "actions": [
+                    "MENSCHLICHE_BEARBEITUNG_ERFORDERLICH"
+                ],
+                "requires_human_action": True,
+                "confidence": 0,
+                "reason": str(exc)
+            }
+
+        if result.get("call_state") not in call_states:
+            result["call_state"] = "UNKNOWN"
+
+        if result.get("primary_intent") not in intents:
+            result["primary_intent"] = "SONSTIGES"
+
+        result["secondary_intents"] = [
+            x
+            for x in result.get(
+                "secondary_intents",
+                []
+            )
+            if x in intents
+            and x != result["primary_intent"]
+        ]
+
+        complaint = result.get(
+            "complaint_type"
+        )
+
+        if complaint not in complaint_types:
+            result["complaint_type"] = None
+
+        result["actions"] = [
+            x
+            for x in result.get(
+                "actions",
+                []
+            )
+            if x in actions
+        ]
+
+        result["_analysis_id"] = row[
+            "analysis_id"
+        ]
+
+        result["_cdr_row_id"] = row[
+            "cdr_row_id"
+        ]
+
+        result["_start_time"] = row[
+            "start_time"
+        ]
+
+        results.append(result)
+
+    primary = Counter()
+    secondary = Counter()
+    complaints = Counter()
+    states = Counter()
+    action_counts = Counter()
+
+    for result in results:
+
+        states[
+            result["call_state"]
+        ] += 1
+
+        if result["call_state"] == "HUMAN_CONVERSATION":
+            primary[
+                result["primary_intent"]
+            ] += 1
+
+            secondary.update(
+                result["secondary_intents"]
+            )
+
+            if result.get(
+                "complaint_type"
+            ):
+                complaints[
+                    result["complaint_type"]
+                ] += 1
+
+            action_counts.update(
+                result["actions"]
+            )
+
+    output = {
+        "version": taxonomy["version"],
+        "model": MODEL,
+        "taxonomy_file": str(
+            TAXONOMY_FILE
+        ),
+        "count": len(results),
+        "call_states": dict(
+            states.most_common()
+        ),
+        "primary_intents": dict(
+            primary.most_common()
+        ),
+        "secondary_intents": dict(
+            secondary.most_common()
+        ),
+        "complaint_types": dict(
+            complaints.most_common()
+        ),
+        "actions": dict(
+            action_counts.most_common()
+        ),
+        "calls": results
+    }
+
+    OUT.write_text(
+        json.dumps(
+            output,
+            ensure_ascii=False,
+            indent=2
+        )
+    )
+
+    print()
+    print("=" * 72)
+    print("ERGEBNIS")
+    print("=" * 72)
+
+    print("\nCALL STATES")
+    for k, v in states.most_common():
+        print(f"{v:3}  {k}")
+
+    print("\nPRIMARY INTENTS")
+    for k, v in primary.most_common():
+        print(f"{v:3}  {k}")
+
+    print("\nSECONDARY INTENTS")
+    for k, v in secondary.most_common():
+        print(f"{v:3}  {k}")
+
+    print("\nREKLAMATIONSGRÜNDE")
+    for k, v in complaints.most_common():
+        print(f"{v:3}  {k}")
+
+    print("\nAKTIONEN")
+    for k, v in action_counts.most_common():
+        print(f"{v:3}  {k}")
+
+    print()
+    print(f"Gespeichert: {OUT}")
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 825 - 0
tools/classify_zammad.py

@@ -0,0 +1,825 @@
+#!/usr/bin/env python3
+
+import json
+import sqlite3
+import time
+import urllib.request
+from concurrent.futures import ThreadPoolExecutor, as_completed
+
+DB = "data/zammad_analysis.sqlite3"
+URL = "http://192.168.1.101:11434/api/chat"
+MODEL = "qwen3:8b"
+
+WORKERS = 2
+
+PRIMARY_INTENTS = {
+    "VERSANDSTATUS",
+    "LIEFERVERZUG",
+    "ADRESSÄNDERUNG",
+    "REKLAMATION",
+    "TRANSPORTSCHADEN",
+    "FEHLLIEFERUNG",
+    "RECHNUNG",
+    "ZAHLUNG",
+    "WIDERRUF",
+    "RETOURE",
+    "PFLANZENBERATUNG",
+    "PFLANZENKRANKHEIT",
+    "SORTENBERATUNG",
+    "PFLEGEFRAGE",
+    "BESTANDSANFRAGE",
+    "VORBESTELLUNG",
+    "B2B",
+    "GROSSHANDEL",
+    "RABATTANFRAGE",
+    "SONSTIGES",
+}
+
+TAGS = {
+    # Reklamation / Qualität
+    "QUALITAETSPROBLEM",
+    "VERWECHSLUNG",
+    "SCHLECHTVERPACKT",
+    "FALSCHESORTEGELIEFERT",
+
+    # Pflanzenzustand / Pflanzenschäden
+    "PFLANZE_ABGEBROCHEN",
+    "PFLANZE_VERTROCKNET",
+    "PFLANZE_KRANK",
+    "PFLANZE_MICKRIG",
+    "PFLANZE_NICHTANGEWACHSEN",
+    "WEISSE_BLAETTER",
+    "SCHIMMEL",
+    "FAULIG",
+    "PFLANZENKRANKHEIT",
+    "PILZKRANKHEIT",
+    "MEHLTAU",
+    "STERNRUSSTAU",
+    "SCHÄDLINGE",
+    "LAUSE",
+    "PFLANZENSCHADEN",
+    "WACHSTUMSPROBLEM",
+
+    # Mengen-/Lieferfehler
+    "FALSCHEFARBE",
+    "NUR_EIN_STUECK",
+    "FALSCHE_MENGE",
+
+    # Produktgruppe
+    "PRODUKTGRUPPE_ROSE",
+    "PRODUKTGRUPPE_CLEMATIS",
+
+    # Produktform
+    "PRODUKTFORM_CONTAINER",
+    "PRODUKTFORM_WURZELWARE",
+    "PRODUKTFORM_HOCHSTAMM",
+
+    # Versand / Logistik
+    "VERSAND",
+    "VERSANDKOSTEN",
+    "PROBLEMMITLIEFERDIENST",
+    "LIEFERDIENST_DHL",
+    "LIEFERDIENST_GLS",
+    "LIEFERDIENST_UNBEKANNT",
+    "TRANSPORTSCHADEN",
+
+    # Service / sonstiger Kontext
+    "KATALOG",
+    "ABHOLUNG",
+    "KUNDENSERVICE",
+
+    # Retouren-Merkmale
+    "TEILRETOURE",
+    "VOLLRETOURE",
+
+    # Rabattverhalten
+    "RABATTANFRAGE",
+    "RABATT_GEFORDERT",
+    "RABATT_SELBSSTAENDIG_ABGEZOGEN",
+}
+
+
+SYSTEM = """
+Du analysierst historische Kundenservice-Tickets von Schmid Gartenpflanzen.
+
+Analysiere ausschließlich das tatsächliche Kundenanliegen.
+
+WICHTIG:
+Primärintent, Secondary-Intents und insbesondere TAGS dürfen nur
+aus dem tatsächlichen KUNDENBEITRAG abgeleitet werden.
+
+Der KUNDENSERVICE-KONTEXT darf NICHT dazu verwendet werden, neue
+Kundenprobleme, Zustände, Produktmerkmale oder Tags zu erfinden.
+
+Wenn ein Merkmal nur in der Antwort des Kundenservice vorkommt,
+aber nicht aus dem Kundenbeitrag hervorgeht, darf es NICHT als Tag
+ausgegeben werden.
+
+KUNDENBEITRAG:
+Die eigentliche Quelle für die Klassifikation.
+
+KUNDENSERVICE-KONTEXT:
+Nur ergänzender Kontext zur Interpretation bereits im Kundenbeitrag
+vorhandener Informationen.
+
+PRIMÄRINTENTS:
+
+VERSANDSTATUS
+LIEFERVERZUG
+ADRESSÄNDERUNG
+REKLAMATION
+TRANSPORTSCHADEN
+FEHLLIEFERUNG
+RECHNUNG
+ZAHLUNG
+WIDERRUF
+RETOURE
+PFLANZENBERATUNG
+SORTENBERATUNG
+PFLEGEFRAGE
+BESTANDSANFRAGE
+VORBESTELLUNG
+B2B
+GROSSHANDEL
+DÜNGER_FEHLT
+SONSTIGES
+
+TAGS:
+
+QUALITAETSPROBLEM
+VERWECHSLUNG
+SCHLECHTVERPACKT
+FALSCHESORTEGELIEFERT
+PROBLEMMITLIEFERDIENST
+LIEFERDIENST_DHL
+LIEFERDIENST_GLS
+LIEFERDIENST_UNBEKANNT
+TRANSPORTSCHADEN
+PFLANZENKRANKHEIT
+PFLANZENSCHADEN
+WACHSTUMSPROBLEM
+
+REGELN:
+
+BESTANDSANFRAGE:
+Jede Frage nach aktueller oder zukünftiger Verfügbarkeit,
+Lieferbarkeit oder Bestellbarkeit.
+
+VORBESTELLUNG:
+Nur wenn der Kunde ausdrücklich eine Vormerkung, Reservierung
+oder verbindliche Vorabbestellung für ein aktuell nicht verfügbares
+Produkt verlangt.
+
+Eine reine Frage nach Verfügbarkeit, Lieferbarkeit oder
+zukünftiger Bestellbarkeit ist IMMER BESTANDSANFRAGE.
+
+Auch Wünsche wie "ich hätte gerne welche", "ich möchte die gerne
+haben" oder "können Sie mir sagen, wann sie wieder da sind" sind
+KEINE VORBESTELLUNG, solange keine ausdrückliche Reservierung,
+Vormerkung oder Vorabbestellung verlangt wird.
+
+WIDERRUF:
+Stornierung oder Rückabwicklung einer Bestellung auf Kundenwunsch,
+einschließlich Stornierung vor Versand.
+
+REKLAMATION:
+Beanstandung eines gelieferten Produkts oder einer Leistung wegen
+Qualität, Zustand oder Beschädigung.
+
+TRANSPORTSCHADEN:
+Schaden, der offensichtlich durch Transport oder Versand entstanden ist.
+
+FEHLLIEFERUNG:
+Falscher Artikel, falsche Sorte oder falsches Produkt geliefert.
+
+DÜNGER_FEHLT:
+Bestellter Dünger fehlt.
+
+QUALITAETSPROBLEM:
+Tag, kein Primärintent.
+
+PFLANZENKRANKHEIT:
+Tag, kein Primärintent.
+
+PFLANZENSCHADEN:
+Tag, kein Primärintent.
+
+WACHSTUMSPROBLEM:
+Tag, kein Primärintent.
+
+PROBLEMMITLIEFERDIENST:
+Problem mit dem Paketdienst.
+
+LIEFERDIENST_DHL / LIEFERDIENST_GLS:
+Nur verwenden, wenn der jeweilige Dienst tatsächlich genannt wird.
+
+MULTI-INTENT:
+Mehrere Anliegen erfassen.
+
+TAGS DÜRFEN NIEMALS als secondary_intents ausgegeben werden.
+
+Die folgenden Begriffe sind ausschließlich TAGS:
+PRODUKTGRUPPE_ROSE
+PRODUKTGRUPPE_CLEMATIS
+PRODUKTFORM_CONTAINER
+PRODUKTFORM_WURZELWARE
+PRODUKTFORM_HOCHSTAMM
+VERSAND
+VERSANDKOSTEN
+PROBLEMMITLIEFERDIENST
+LIEFERDIENST_DHL
+LIEFERDIENST_GLS
+LIEFERDIENST_UNBEKANNT
+RETOURE
+KATALOG
+ABHOLUNG
+KUNDENSERVICE
+QUALITAETSPROBLEM
+VERWECHSLUNG
+SCHLECHTVERPACKT
+FALSCHESORTEGELIEFERT
+PFLANZE_ABGEBROCHEN
+PFLANZE_VERTROCKNET
+PFLANZE_KRANK
+PFLANZE_MICKRIG
+PFLANZE_NICHTANGEWACHSEN
+WEISSE_BLAETTER
+SCHIMMEL
+FAULIG
+PFLANZENKRANKHEIT
+PFLANZENSCHADEN
+WACHSTUMSPROBLEM
+FALSCHEFARBE
+NUR_EIN_STUECK
+FALSCHE_MENGE
+TRANSPORTSCHADEN
+
+secondary_intents darf ausschließlich einen PRIMÄRINTENT enthalten.
+
+RETOUREN:
+
+RETOURE ist ausschließlich ein PRIMÄRINTENT.
+
+RÜCKSENDUNG, RÜCKSENDEN, ZURÜCKSENDEN, RÜCKGABE,
+WARE ZURÜCKSCHICKEN und RETOURE sind Synonyme und
+werden als RETOURE klassifiziert.
+
+RÜCKSENDUNG darf NICHT als eigener Tag ausgegeben werden.
+
+TEILRETOURE:
+Der Kunde möchte nur einen Teil der Bestellung zurückgeben.
+
+VOLLRETOURE:
+Der Kunde möchte die komplette Bestellung zurückgeben.
+
+TEILRETOURE und VOLLRETOURE sind Tags.
+
+WIDERRUF und RETOURE sind zu unterscheiden:
+- RETOURE = Ware soll zurückgegeben/zurückgesendet werden.
+- WIDERRUF = Kunde erklärt den Widerruf des Kaufvertrags.
+
+Wenn ausdrücklich beides verlangt wird, kann RETOURE als
+secondary_intent zu WIDERRUF erscheinen.
+
+PFLANZENKRANKHEITEN / SCHÄDLINGE:
+
+PFLANZENKRANKHEIT = allgemeine oder konkrete Pflanzenkrankheit.
+
+PILZKRANKHEIT = Pilzerkrankung.
+
+MEHLTAU = konkreter Pilzkrankheitsbefund.
+
+STERNRUSSTAU = konkreter Pilzkrankheitsbefund.
+
+SCHÄDLINGE = allgemeiner Schädlingsbefall.
+
+LAUSE = konkreter Befall mit Läusen.
+
+Konkrete Merkmale dürfen zusätzlich zu PFLANZENKRANKHEIT
+ausgegeben werden.
+
+RABATTVERHALTEN:
+
+RABATTANFRAGE:
+Der Kunde fragt aktiv, ob ein Rabatt oder Preisnachlass möglich ist.
+
+RABATT_GEFORDERT:
+Der Kunde fordert ausdrücklich einen Rabatt, Preisnachlass oder
+eine bestimmte Rabatt-/Nachlasshöhe.
+
+RABATT_SELBSSTAENDIG_ABGEZOGEN:
+Der Kunde hat eigenmächtig einen Rabatt, Nachlass, Betrag oder
+Prozentsatz von einer Rechnung oder Zahlung abgezogen, ohne dass
+dieser Rabatt vorher vom Unternehmen gewährt wurde.
+
+Rabattmerkmale dürfen ausschließlich aus dem KUNDENBEITRAG abgeleitet
+werden. Ein vom Kundenservice angebotener oder gewährter Rabatt ist
+KEINE Rabattforderung des Kunden.
+
+Rabatt ist normalerweise kein eigener Primärintent.
+
+Beispiel:
+"Die Rose kam beschädigt an, dafür erwarte ich 20 % Rabatt."
+
+→ primary_intent = REKLAMATION
+→ tags = [QUALITAETSPROBLEM, RABATT_GEFORDERT]
+→ discount.percentage = 20
+
+Beispiel:
+"Die Rechnung beträgt 100 Euro, ich habe nur 90 Euro überwiesen."
+
+→ primary_intent = ZAHLUNG
+→ tags = [RABATT_SELBSSTAENDIG_ABGEZOGEN]
+→ discount.amount = 10
+→ discount.currency = EUR
+→ discount.self_deducted = true
+
+RABATT_WIEDERHOLT wird NICHT im einzelnen Ticket vergeben.
+Das ist ein späteres kundenbezogenes Aggregat.
+
+DISCOUNT-FELD:
+Extrahiere eine konkret genannte Rabatt-/Nachlasshöhe strukturiert.
+
+Wenn Prozent genannt:
+discount.percentage = Zahl
+
+Wenn ein konkreter Geldbetrag genannt:
+discount.amount = Zahl
+discount.currency = erkannte Währung
+
+Wenn keine Höhe genannt:
+entsprechender Wert = null
+
+discount.requested = true, wenn der Kunde aktiv Rabatt verlangt/fragt.
+discount.self_deducted = true, wenn der Kunde eigenmächtig gekürzt hat.
+
+Wenn kein Rabatt vorkommt:
+discount.requested = false
+discount.self_deducted = false
+discount.amount = null
+discount.percentage = null
+discount.currency = null
+
+primary_intent:
+Das wichtigste Anliegen.
+
+secondary_intents:
+Weitere Primärintents, falls vorhanden.
+
+tags:
+Nur konkrete Merkmale aus der TAG-Liste.
+NIEMALS Primärintents als Tags verwenden.
+
+taxonomy_fit:
+GOOD, wenn die Taxonomie gut passt.
+PARTIAL, wenn sie nur teilweise passt.
+POOR, wenn ein relevantes Anliegen fehlt.
+
+taxonomy_candidate:
+Nur bei PARTIAL oder POOR einen möglichen fehlenden Primärintent nennen.
+Sonst null.
+
+Keine neuen Primärintents eigenmächtig erfinden.
+
+Antworte ausschließlich mit gültigem JSON.
+"""
+
+EXPECTED = {
+    "primary_intent": "",
+    "secondary_intents": [],
+    "tags": [],
+    "complaint_type": None,
+    "actions": [],
+    "summary": "",
+    "taxonomy_fit": "",
+    "taxonomy_candidate": None,
+    "confidence": 0.0,
+    "discount": {
+        "requested": False,
+        "self_deducted": False,
+        "amount": None,
+        "percentage": None,
+        "currency": None
+    },
+}
+
+
+def classify(row):
+    prompt = f"""
+Ticket #{row["ticket_number"]}
+
+BETREFF:
+{row["title"] or ""}
+
+KUNDENBEITRAG:
+{row["customer_text"] or "(kein Kundentext vorhanden)"}
+
+KUNDENSERVICE-KONTEXT:
+{row["agent_text"] or "(kein Kundendiensttext vorhanden)"}
+
+WICHTIG:
+Auch ohne Kundentext muss das Ticket anhand von BETREFF und KUNDENSERVICE-KONTEXT klassifiziert werden.
+
+primary_intent MUSS exakt einem Wert aus der vorgegebenen Taxonomie entsprechen.
+Verwende KEINE selbst erfundenen Kategorien wie KUNDENSERVICE, ALLGEMEIN,
+KUNDENANFRAGE oder ähnliche freie Kategorien.
+Wenn kein spezifischer Intent sicher ableitbar ist, verwende SONSTIGES.
+
+taxonomy_fit MUSS exakt einer der Werte GOOD, PARTIAL oder POOR sein.
+confidence MUSS eine Zahl zwischen 0 und 1 sein.
+secondary_intents dürfen ausschließlich gültige Taxonomie-Intents enthalten.
+
+JSON-SCHEMA:
+{json.dumps(EXPECTED, ensure_ascii=False)}
+"""
+
+    payload = {
+        "model": MODEL,
+        "messages": [
+            {"role": "system", "content": SYSTEM},
+            {"role": "user", "content": prompt},
+        ],
+        "stream": False,
+        "think": False,
+        "format": "json",
+        "options": {
+            "temperature": 0,
+        },
+    }
+
+    req = urllib.request.Request(
+        URL,
+        data=json.dumps(payload, ensure_ascii=False).encode(),
+        headers={"Content-Type": "application/json"},
+    )
+
+    started = time.monotonic()
+
+    with urllib.request.urlopen(req, timeout=300) as response:
+        raw = json.loads(response.read())
+
+    elapsed = time.monotonic() - started
+
+    result = json.loads(raw["message"]["content"])
+
+    # Harte Taxonomie-Grenze: Modell darf keine eigenen Kategorien erzeugen.
+    primary = result.get("primary_intent")
+    if not isinstance(primary, str) or primary.strip() not in PRIMARY_INTENTS:
+        result["primary_intent"] = "SONSTIGES"
+    else:
+        result["primary_intent"] = primary.strip()
+
+    fit = result.get("taxonomy_fit")
+    if fit not in {"GOOD", "PARTIAL", "POOR"}:
+        result["taxonomy_fit"] = "POOR"
+
+    confidence = result.get("confidence")
+    try:
+        result["confidence"] = float(confidence)
+    except (TypeError, ValueError):
+        result["confidence"] = 0.0
+
+
+    return row, result, elapsed
+
+
+def validate(result):
+
+    if not isinstance(result, dict):
+        return False, "JSON ist kein Objekt"
+
+    primary = result.get("primary_intent")
+
+    # Qwen darf bei fehlendem/unsinnigem Kundenanliegen
+    # keinen leeren Primärintent zurückgeben.
+    # Solche Fälle werden als SONSTIGES / POOR geführt.
+    if not primary:
+        fit = result.get("taxonomy_fit")
+
+        if fit == "POOR":
+            result["primary_intent"] = "SONSTIGES"
+            primary = "SONSTIGES"
+
+            if not result.get("taxonomy_candidate"):
+                result["taxonomy_candidate"] = "KEINE_ANFRAGE"
+
+        else:
+            return False, "primary_intent fehlt"
+
+    if primary not in PRIMARY_INTENTS:
+        return False, f"Ungültiger primary_intent: {primary}"
+
+    secondary = result.get("secondary_intents", [])
+    tags = result.get("tags", [])
+
+    if not isinstance(secondary, list):
+        return False, "secondary_intents ist keine Liste"
+
+    if not isinstance(tags, list):
+        return False, "tags ist keine Liste"
+
+    # Qwen verwechselt gelegentlich Tags mit secondary_intents.
+    # Bekannte Tags werden automatisch in die Tag-Liste verschoben.
+    secondary_clean = []
+
+    for item in secondary:
+        if item in TAGS:
+            tags.append(item)
+        elif item in PRIMARY_INTENTS:
+            secondary_clean.append(item)
+        else:
+            return False, f"Ungültige secondary_intent: {item}"
+
+    secondary = secondary_clean
+
+    # Primärintent darf nicht zusätzlich als secondary_intent erscheinen.
+    primary = result.get("primary_intent")
+
+    secondary = [
+        x for x in secondary
+        if x != primary
+    ]
+
+    # Primärintents dürfen nicht als Tags erscheinen.
+    tags = [
+        x for x in tags
+        if x not in PRIMARY_INTENTS
+    ]
+
+    tags = list(dict.fromkeys(tags))
+    secondary = list(dict.fromkeys(secondary))
+
+    invalid_tags = [
+        x for x in tags
+        if x not in TAGS
+    ]
+
+    if invalid_tags:
+        return False, f"Ungültige Tags: {invalid_tags}"
+
+    result["secondary_intents"] = secondary
+    result["tags"] = tags
+
+    fit = result.get("taxonomy_fit")
+
+    if fit not in {"GOOD", "PARTIAL", "POOR"}:
+        return False, f"Ungültiges taxonomy_fit: {fit}"
+
+    # Rabattdaten validieren
+    discount = result.get("discount", {})
+
+    if not isinstance(discount, dict):
+        return False, "discount ist kein Objekt"
+
+    for key in ("requested", "self_deducted"):
+        if key not in discount:
+            discount[key] = False
+
+        if not isinstance(discount[key], bool):
+            return False, f"discount.{key} ist kein Boolean"
+
+    for key in ("amount", "percentage"):
+        if key not in discount:
+            discount[key] = None
+
+        if discount[key] is not None:
+            try:
+                discount[key] = float(discount[key])
+            except Exception:
+                return False, f"discount.{key} ist nicht numerisch"
+
+            if discount[key] < 0:
+                return False, f"discount.{key} ist negativ"
+
+    if "currency" not in discount:
+        discount["currency"] = None
+
+    if discount["currency"] is not None:
+        discount["currency"] = str(discount["currency"]).upper()
+
+    result["discount"] = discount
+
+    confidence = result.get("confidence")
+
+    try:
+        confidence = float(confidence)
+    except Exception:
+        return False, "confidence nicht numerisch"
+
+    if not 0 <= confidence <= 1:
+        return False, "confidence außerhalb 0..1"
+
+    return True, ""
+
+
+def main():
+
+    db = sqlite3.connect(DB)
+    db.row_factory = sqlite3.Row
+
+    # Zusätzliche Ergebnisfelder
+    columns = {
+        row[1]
+        for row in db.execute("PRAGMA table_info(cases)")
+    }
+
+    fields = {
+        "classification_json": "TEXT",
+        "classification_error": "TEXT",
+        "classification_seconds": "REAL",
+    }
+
+    for name, typ in fields.items():
+        if name not in columns:
+            db.execute(
+                f"ALTER TABLE cases ADD COLUMN {name} {typ}"
+            )
+
+    db.commit()
+
+    rows = db.execute("""
+        SELECT
+            ticket_id,
+            ticket_number,
+            title,
+            customer_text,
+            agent_text
+        FROM cases
+        WHERE analysis_status = 'ANALYZABLE'
+          AND (
+              classification_status IS NULL
+              OR classification_status != 'DONE'
+          )
+        ORDER BY ticket_id
+    """).fetchall()
+
+    total = len(rows)
+
+    print("=" * 80)
+    print("ZAMMAD PRODUKTIONSKLASSIFIKATION")
+    print("=" * 80)
+    print(f"Modell:       {MODEL}")
+    print(f"Worker:       {WORKERS}")
+    print(f"Queue:        {total}")
+    print()
+
+    if not rows:
+        print("Keine offenen Tickets.")
+        db.close()
+        return
+
+    completed = 0
+    errors = 0
+    started = time.monotonic()
+
+    with ThreadPoolExecutor(max_workers=WORKERS) as executor:
+
+        futures = {
+            executor.submit(classify, row): row
+            for row in rows
+        }
+
+        for future in as_completed(futures):
+
+            row = futures[future]
+
+            try:
+
+                ticket, result, elapsed = future.result()
+
+                valid, error = validate(result)
+
+                if not valid:
+
+                    errors += 1
+
+                    db.execute("""
+                        UPDATE cases
+                        SET
+                            classification_status = 'ERROR',
+                            classification_error = ?,
+                            classification_seconds = ?
+                        WHERE ticket_id = ?
+                    """, (
+                        error,
+                        elapsed,
+                        ticket["ticket_id"],
+                    ))
+
+                    print(
+                        f"ERROR #{ticket['ticket_number']}: "
+                        f"{error}",
+                        flush=True,
+                    )
+
+                else:
+
+                    # Normalisierte Tags
+                    tags = list(dict.fromkeys(
+                        result.get("tags", [])
+                    ))
+
+                    secondary = list(dict.fromkeys(
+                        result.get("secondary_intents", [])
+                    ))
+
+                    result["tags"] = tags
+                    result["secondary_intents"] = secondary
+
+                    db.execute("""
+                        UPDATE cases
+                        SET
+                            primary_intent = ?,
+                            secondary_intents = ?,
+                            tags = ?,
+                            classification_json = ?,
+                            taxonomy_fit = ?,
+                            taxonomy_candidate = ?,
+                            summary = ?,
+                            confidence = ?,
+                            classification_seconds = ?,
+                            classification_error = NULL,
+                            classification_status = 'DONE'
+                        WHERE ticket_id = ?
+                    """, (
+                        result["primary_intent"],
+                        json.dumps(
+                            secondary,
+                            ensure_ascii=False,
+                        ),
+                        json.dumps(
+                            tags,
+                            ensure_ascii=False,
+                        ),
+                        json.dumps(
+                            result,
+                            ensure_ascii=False,
+                        ),
+                        result["taxonomy_fit"],
+                        result.get("taxonomy_candidate"),
+                        result.get("summary"),
+                        float(result.get("confidence", 0)),
+                        elapsed,
+                        ticket["ticket_id"],
+                    ))
+
+                    completed += 1
+
+                    print(
+                        f"[{completed + errors}/{total}] "
+                        f"#{ticket['ticket_number']} "
+                        f"{elapsed:5.1f}s → "
+                        f"{result['primary_intent']} "
+                        f"secondary={result.get('secondary_intents') or []} "
+                        f"tags={result.get('tags') or []} "
+                        f"confidence={result.get('confidence')}",
+                        flush=True,
+                    )
+
+                # Kleine Transaktionen:
+                # Bei Abbruch bleibt praktisch alles bereits Geschriebene erhalten.
+                db.commit()
+
+            except Exception as exc:
+
+                errors += 1
+
+                db.execute("""
+                    UPDATE cases
+                    SET
+                        classification_status = 'ERROR',
+                        classification_error = ?
+                    WHERE ticket_id = ?
+                """, (
+                    str(exc),
+                    row["ticket_id"],
+                ))
+
+                db.commit()
+
+                print(
+                    f"ERROR #{row['ticket_number']}: {exc}",
+                    flush=True,
+                )
+
+    elapsed_total = time.monotonic() - started
+
+    print()
+    print("=" * 80)
+    print("LAUF BEENDET")
+    print("=" * 80)
+    print(f"DONE:       {completed}")
+    print(f"ERROR:      {errors}")
+    print(f"Zeit:       {elapsed_total / 3600:.2f} h")
+
+    if elapsed_total:
+        print(
+            f"Durchsatz:  "
+            f"{completed / elapsed_total * 3600:.1f} Tickets/h"
+        )
+
+    db.close()
+
+
+if __name__ == "__main__":
+    main()

+ 264 - 0
tools/classify_zammad_cases.py

@@ -0,0 +1,264 @@
+#!/usr/bin/env python3
+
+import json
+import re
+import sqlite3
+import subprocess
+import time
+
+DB = "data/zammad_analysis.sqlite3"
+MODEL = "qwen3:8b"
+
+TAXONOMY = """
+VERSANDSTATUS
+LIEFERVERZUG
+ADRESSÄNDERUNG
+REKLAMATION
+TRANSPORTSCHADEN
+FEHLLIEFERUNG
+RECHNUNG
+ZAHLUNG
+WIDERRUF
+RETOURE
+PFLANZENBERATUNG
+SORTENBERATUNG
+PFLEGEFRAGE
+BESTANDSANFRAGE
+VORBESTELLUNG
+B2B
+GROSSHANDEL
+DÜNGER_FEHLT
+SONSTIGES
+
+Regeln:
+- PRODUKTQUALITÄT gehört unter REKLAMATION oder PFLANZENBERATUNG.
+- SCHLECHTVERPACKT gehört unter REKLAMATION bzw. TRANSPORTSCHADEN, wenn ein Transportschaden vorliegt.
+- FALSCHESORTEGELIEFERT gehört unter FEHLLIEFERUNG.
+- DÜNGER_FEHLT ist ein eigener wichtiger Intent.
+- Mehrere gleichzeitig vorhandene Anliegen müssen als Multi-Intent erfasst werden.
+- primary_intent ist das wichtigste Kundenanliegen.
+- secondary_intents enthält weitere relevante Anliegen.
+- Erfinde keinen neuen Primärintent.
+- Wenn kein bestehender Intent passt, nutze SONSTIGES und schlage taxonomy_candidate vor.
+"""
+
+SYSTEM = f"""
+Du analysierst historische Kundenservice-Tickets von Schmid Gartenpflanzen.
+
+Bewerte das tatsächliche Kundenanliegen, nicht nur die Antwort des Mitarbeiters.
+
+Bestehende Taxonomie:
+{TAXONOMY}
+
+Gib ausschließlich gültiges JSON zurück:
+
+{{
+  "primary_intent": "...",
+  "secondary_intents": [],
+  "complaint_type": null,
+  "actions": [],
+  "summary": "...",
+  "taxonomy_fit": "GOOD|PARTIAL|POOR",
+  "taxonomy_candidate": null,
+  "confidence": 0.0
+}}
+
+Bei REKLAMATION oder FEHLLIEFERUNG soll complaint_type möglichst konkret sein.
+
+actions beschreibt, was der Kundenservice getan oder als Lösung angeboten hat.
+"""
+
+
+def parse_json(text):
+    text = text.strip()
+
+    # Markdown-Codeblock entfernen
+    text = re.sub(
+        r"^```(?:json)?\s*",
+        "",
+        text,
+        flags=re.I,
+    )
+    text = re.sub(
+        r"\s*```$",
+        "",
+        text,
+    )
+
+    start = text.find("{")
+    end = text.rfind("}")
+
+    if start < 0 or end <= start:
+        raise ValueError("Kein JSON gefunden")
+
+    return json.loads(
+        text[start:end + 1]
+    )
+
+
+def run_qwen(case_text):
+
+    prompt = f"""
+{SYSTEM}
+
+TICKET:
+{case_text}
+"""
+
+    result = subprocess.run(
+        [
+            "ollama",
+            "run",
+            MODEL,
+            prompt,
+        ],
+        capture_output=True,
+        text=True,
+        timeout=600,
+    )
+
+    if result.returncode != 0:
+        raise RuntimeError(
+            result.stderr[-2000:]
+        )
+
+    return parse_json(result.stdout)
+
+
+db = sqlite3.connect(DB)
+db.row_factory = sqlite3.Row
+
+rows = db.execute("""
+    SELECT
+        ticket_id,
+        ticket_number,
+        title,
+        customer_text,
+        agent_text,
+        unknown_text
+    FROM cases
+    WHERE classification_status = 'NEW'
+    ORDER BY ticket_id
+""").fetchall()
+
+print("=" * 72)
+print("ZAMMAD TAXONOMIE – QWEN")
+print("=" * 72)
+print(f"Cases offen: {len(rows)}")
+print(f"Modell:      {MODEL}")
+print("Worker:      1")
+print()
+
+started = time.time()
+done = 0
+errors = 0
+
+for pos, row in enumerate(rows, 1):
+
+    case_text = f"""
+Ticket #{row['ticket_number']}
+Betreff: {row['title'] or ''}
+
+KUNDENBEITRÄGE:
+{row['customer_text'] or ''}
+
+ANTWORTEN DES KUNDENSERVICE:
+{row['agent_text'] or ''}
+
+SONSTIGE FACHLICHE SYSTEMANTWORTEN:
+{row['unknown_text'] or ''}
+"""
+
+    try:
+
+        result = run_qwen(case_text)
+
+        db.execute("""
+            UPDATE cases
+            SET classification_status = 'CLASSIFIED',
+                primary_intent = ?,
+                secondary_intents = ?,
+                complaint_type = ?,
+                actions = ?,
+                summary = ?,
+                taxonomy_fit = ?,
+                taxonomy_candidate = ?,
+                classification_json = ?
+            WHERE ticket_id = ?
+        """, (
+            result.get("primary_intent"),
+            json.dumps(
+                result.get("secondary_intents", []),
+                ensure_ascii=False,
+            ),
+            result.get("complaint_type"),
+            json.dumps(
+                result.get("actions", []),
+                ensure_ascii=False,
+            ),
+            result.get("summary"),
+            result.get("taxonomy_fit"),
+            result.get("taxonomy_candidate"),
+            json.dumps(
+                result,
+                ensure_ascii=False,
+            ),
+            row["ticket_id"],
+        ))
+
+        db.commit()
+
+        done += 1
+
+        elapsed = time.time() - started
+        rate = done / elapsed if elapsed else 0
+        remaining = (
+            (len(rows) - done) / rate
+            if rate
+            else 0
+        )
+
+        print(
+            f"[{pos}/{len(rows)}] "
+            f"#{row['ticket_number']} "
+            f"→ {result.get('primary_intent')} "
+            f"({result.get('confidence', 0)}) "
+            f"| noch ~{remaining / 60:.0f} min",
+            flush=True,
+        )
+
+    except Exception as exc:
+
+        errors += 1
+
+        db.execute("""
+            UPDATE cases
+            SET classification_status = 'ERROR'
+            WHERE ticket_id = ?
+        """, (row["ticket_id"],))
+
+        db.commit()
+
+        print(
+            f"[{pos}/{len(rows)}] "
+            f"#{row['ticket_number']} "
+            f"ERROR: {exc}",
+            flush=True,
+        )
+
+print()
+print("=" * 72)
+print("QWEN-LAUF BEENDET")
+print("=" * 72)
+print(f"Erfolgreich: {done}")
+print(f"Fehler:      {errors}")
+
+for status, count in db.execute("""
+    SELECT classification_status, COUNT(*)
+    FROM cases
+    GROUP BY classification_status
+    ORDER BY classification_status
+"""):
+    print(f"{status:20} {count}")
+
+db.close()

+ 214 - 0
tools/classify_zammad_unknown.py

@@ -0,0 +1,214 @@
+#!/usr/bin/env python3
+
+import re
+import sqlite3
+from collections import Counter, defaultdict
+
+DB = "data/zammad_analysis.sqlite3"
+
+AUTO_PATTERNS = [
+    r"wir haben ihre anfrage erhalten",
+    r"ihre anfrage wurde",
+    r"ticket.{0,30}(erstellt|angelegt|eröffnet)",
+    r"wurde.{0,30}(zugewiesen|weitergeleitet)",
+    r"wird.{0,80}(bearbeitet|geprüft)",
+    r"antworten sie bitte direkt auf diese e-mail",
+    r"dies ist eine automatische",
+    r"automatisch erstellt",
+    r"automatische nachricht",
+    r"vielen dank für ihre anfrage",
+]
+
+AGENT_PATTERNS = [
+    r"wie besprochen",
+    r"wie vereinbart",
+    r"wie telefonisch",
+    r"ich habe",
+    r"wir haben.{0,80}(erstellt|angepasst|geändert|veranlasst)",
+    r"wir senden",
+    r"wir schicken",
+    r"wir erstatten",
+    r"wir werden ihnen",
+    r"ich lasse ihnen",
+    r"ich habe ihnen",
+    r"gutschein",
+    r"gutscheincode",
+    r"kulanz",
+    r"nachforschungsauftrag",
+    r"nachlieferung",
+    r"ersatzpflanze",
+    r"erstattung",
+    r"rückerstattung",
+    r"stornorechnung",
+    r"rücksendelabel",
+    r"retourenlabel",
+    r"paketnummer",
+    r"sendungsnummer",
+    r"tracking",
+    r"mit freundlichen grüßen",
+    r"viele grüße",
+    r"freundliche grüße",
+]
+
+auto_re = [re.compile(x, re.I | re.S) for x in AUTO_PATTERNS]
+agent_re = [re.compile(x, re.I | re.S) for x in AGENT_PATTERNS]
+
+db = sqlite3.connect(DB)
+
+# Spalten nur anlegen, wenn sie noch nicht existieren.
+columns = {
+    row[1]
+    for row in db.execute("PRAGMA table_info(articles)")
+}
+
+if "content_role" not in columns:
+    db.execute("""
+        ALTER TABLE articles
+        ADD COLUMN content_role TEXT
+    """)
+
+if "classification_score" not in columns:
+    db.execute("""
+        ALTER TABLE articles
+        ADD COLUMN classification_score INTEGER
+    """)
+
+if "classification_reason" not in columns:
+    db.execute("""
+        ALTER TABLE articles
+        ADD COLUMN classification_reason TEXT
+    """)
+
+db.commit()
+
+rows = db.execute("""
+    SELECT
+        id,
+        ticket_id,
+        sender,
+        clean_body
+    FROM articles
+    WHERE role = 'UNKNOWN'
+      AND clean_body IS NOT NULL
+      AND clean_body != ''
+""").fetchall()
+
+print("=" * 72)
+print("UNKNOWN → AGENT / AUTOMATION VORSORTIERUNG")
+print("=" * 72)
+print(f"UNKNOWN-Artikel: {len(rows)}")
+print()
+
+stats = Counter()
+examples = defaultdict(list)
+
+for article_id, ticket_id, sender, text in rows:
+
+    text = text or ""
+
+    auto_hits = [
+        p.pattern
+        for p in auto_re
+        if p.search(text)
+    ]
+
+    agent_hits = [
+        p.pattern
+        for p in agent_re
+        if p.search(text)
+    ]
+
+    score = 0
+
+    # Automationssignale
+    score -= min(len(auto_hits) * 3, 9)
+
+    # Signale für individuell formulierte Agentenantwort
+    score += min(len(agent_hits) * 2, 12)
+
+    # Individuelle Antworten sind häufig etwas länger.
+    if len(text) > 500:
+        score += 1
+
+    if len(text) > 1200:
+        score += 1
+
+    if score >= 3:
+        content_role = "AGENT_RESPONSE"
+        stats[content_role] += 1
+
+    elif score <= -3:
+        content_role = "AUTOMATION"
+        stats[content_role] += 1
+
+    else:
+        content_role = "UNKNOWN_REVIEW"
+        stats[content_role] += 1
+
+    reason = (
+        f"agent_hits={len(agent_hits)};"
+        f"auto_hits={len(auto_hits)};"
+        f"score={score}"
+    )
+
+    db.execute("""
+        UPDATE articles
+        SET content_role = ?,
+            classification_score = ?,
+            classification_reason = ?
+        WHERE id = ?
+    """, (
+        content_role,
+        score,
+        reason,
+        article_id,
+    ))
+
+    if len(examples[content_role]) < 12:
+        examples[content_role].append((
+            ticket_id,
+            score,
+            text[:800],
+        ))
+
+db.commit()
+
+print("ERGEBNIS")
+print("-" * 72)
+
+for role, count in stats.most_common():
+    print(f"{role:20} {count:6}")
+
+for role in (
+    "AGENT_RESPONSE",
+    "AUTOMATION",
+    "UNKNOWN_REVIEW",
+):
+    print()
+    print("=" * 72)
+    print(role)
+    print("=" * 72)
+
+    for ticket_id, score, text in examples[role]:
+        print(
+            f"\nTicket #{ticket_id} "
+            f"score={score}"
+        )
+        print(text)
+
+print()
+print("=" * 72)
+print("STATUSVERTEILUNG")
+print("=" * 72)
+
+for row in db.execute("""
+    SELECT
+        COALESCE(content_role, role),
+        COUNT(*)
+    FROM articles
+    GROUP BY COALESCE(content_role, role)
+    ORDER BY COUNT(*) DESC
+"""):
+    print(f"{row[0]:20} {row[1]:6}")
+
+db.close()

+ 165 - 0
tools/clean_zammad_quotes.py

@@ -0,0 +1,165 @@
+#!/usr/bin/env python3
+
+import re
+import sqlite3
+
+DB = "data/zammad_analysis.sqlite3"
+
+db = sqlite3.connect(DB)
+db.row_factory = sqlite3.Row
+
+cols = {r[1] for r in db.execute("PRAGMA table_info(articles)")}
+
+if "analysis_body" not in cols:
+    db.execute("""
+        ALTER TABLE articles
+        ADD COLUMN analysis_body TEXT
+    """)
+
+db.commit()
+
+
+def clean_quotes(text):
+    if not text:
+        return ""
+
+    text = text.replace("\r\n", "\n").replace("\r", "\n")
+
+    # Klassische Antwortmarker.
+    markers = [
+        r"^\s*Am\s+.+?\bschrieb\s+.+?:\s*$",
+        r"^\s*On\s+.+?\bwrote:\s*$",
+        r"^\s*-----Original[- ]Nachricht-----\s*$",
+        r"^\s*-----Ursprüngliche Nachricht-----\s*$",
+        r"^\s*-----Original Message-----\s*$",
+        r"^\s*Von:\s+.+$",
+        r"^\s*From:\s+.+$",
+        r"^\s*Gesendet:\s+.+$",
+        r"^\s*Sent:\s+.+$",
+        r"^\s*Datum:\s+.+$",
+        r"^\s*Date:\s+.+$",
+    ]
+
+    lines = text.splitlines()
+
+    cut = None
+
+    for i, line in enumerate(lines):
+        stripped = line.strip()
+
+        for pattern in markers:
+            if re.match(pattern, stripped, re.I):
+                cut = i
+                break
+
+        if cut is not None:
+            break
+
+    if cut is not None:
+        lines = lines[:cut]
+
+    # Klassische >-Zitatblöcke entfernen.
+    result = []
+
+    for line in lines:
+        if re.match(r"^\s*>", line):
+            continue
+
+        result.append(line)
+
+    text = "\n".join(result)
+
+    # Automatisch zitierte Bestellformulare entfernen.
+    # Diese enthalten keinen neuen Kunden-/Agenteninhalt.
+    order_markers = [
+        r"\bwir haben nachfolgende bestellung soeben erhalten\b",
+        r"\bguten tag\s*,?\s*wir haben nachfolgende bestellung",
+    ]
+
+    for marker in order_markers:
+        m = re.search(marker, text, re.I)
+        if m:
+            text = text[:m.start()].rstrip()
+            break
+
+    # Signaturen nur am Ende.
+    text = re.split(
+        r"\n\s*(Mit freundlichen Grüßen|"
+        r"Viele Grüße|"
+        r"Beste Grüße|"
+        r"Freundliche Grüße|"
+        r"Kind regards|"
+        r"Best regards)\b",
+        text,
+        maxsplit=1,
+        flags=re.I,
+    )[0]
+
+    text = re.sub(r"[ \t]+", " ", text)
+    text = re.sub(r"\n{3,}", "\n\n", text)
+
+    return text.strip()
+
+
+rows = db.execute("""
+    SELECT id, ticket_id, role, clean_body
+    FROM articles
+    WHERE clean_body IS NOT NULL
+""").fetchall()
+
+changed = 0
+empty = 0
+
+for row in rows:
+
+    original = row["clean_body"] or ""
+    cleaned = clean_quotes(original)
+
+    db.execute("""
+        UPDATE articles
+        SET analysis_body = ?
+        WHERE id = ?
+    """, (
+        cleaned,
+        row["id"],
+    ))
+
+    if cleaned != original:
+        changed += 1
+
+    if not cleaned:
+        empty += 1
+
+db.commit()
+
+print("=" * 72)
+print("ZAMMAD ZITATBEREINIGUNG")
+print("=" * 72)
+print(f"Artikel:             {len(rows)}")
+print(f"Verändert:           {changed}")
+print(f"Leer nach Bereinigung: {empty}")
+
+print()
+print("BEISPIELE")
+print("-" * 72)
+
+examples = db.execute("""
+    SELECT
+        ticket_id,
+        role,
+        clean_body,
+        analysis_body
+    FROM articles
+    WHERE clean_body != analysis_body
+    ORDER BY id
+    LIMIT 20
+""").fetchall()
+
+for row in examples:
+    print(f"\nTicket #{row['ticket_id']} [{row['role']}]")
+    print("VORHER:")
+    print((row["clean_body"] or "")[:500])
+    print("NACHHER:")
+    print((row["analysis_body"] or "")[:500])
+
+db.close()

+ 106 - 0
tools/filter_transcripts.py

@@ -0,0 +1,106 @@
+import json, sqlite3, time
+from pathlib import Path
+from concurrent.futures import ThreadPoolExecutor, as_completed
+import requests
+
+BASE = Path("/opt/3cx-middleware/3cx-telefonie-middleware")
+TRANSCRIPTS = BASE / "data/transcripts"
+OUT = BASE / "data/transcript_relevance.jsonl"
+
+OLLAMA = "http://192.168.1.101:11434/api/chat"
+MODEL = "qwen3:4b"
+WORKERS = 2
+
+SYSTEM = """Du entscheidest ausschließlich, ob ein Telefontranskript für
+einen Gartenpflanzen-/Onlineshop-Kundenservice relevant ist.
+
+RELEVANT:
+Ein echtes Kunden-, Lieferanten- oder Geschäftsgespräch mit verwertbarem
+Inhalt.
+
+IRRELEVANT:
+Mailbox, reine Ansage, verpasster Anruf ohne Gespräch, Werbung, technische
+Ansage oder praktisch kein Gespräch.
+
+UNCERTAIN:
+Nicht eindeutig entscheidbar.
+
+Antworte ausschließlich als JSON:
+{"relevance":"RELEVANT|IRRELEVANT|UNCERTAIN","confidence":0.0,"reason":"kurze Begründung"}
+
+Keine Intent-Klassifizierung durchführen.
+"""
+
+def classify(item):
+    path = item
+    text = path.read_text(encoding="utf-8", errors="replace").strip()
+
+    payload = {
+        "model": MODEL,
+        "messages": [
+            {"role": "system", "content": SYSTEM},
+            {"role": "user", "content": text[:12000]}
+        ],
+        "stream": False,
+        "think": False,
+        "format": "json",
+        "options": {"temperature": 0}
+    }
+
+    start = time.time()
+    r = requests.post(OLLAMA, json=payload, timeout=180)
+    r.raise_for_status()
+
+    result = json.loads(r.json()["message"]["content"])
+    return {
+        "file": path.name,
+        "relevance": result.get("relevance", "UNCERTAIN"),
+        "confidence": result.get("confidence", 0),
+        "reason": result.get("reason", ""),
+        "seconds": round(time.time() - start, 1)
+    }
+
+files = sorted(TRANSCRIPTS.glob("*.txt"))
+
+# offensichtliche Kurz-/Leer-/reine Ansagetexte nicht erneut an Qwen schicken
+candidates = []
+for p in files:
+    text = p.read_text(encoding="utf-8", errors="replace").strip()
+    words = len(text.split())
+
+    if not text or words < 12:
+        continue
+
+    candidates.append(p)
+
+print(f"Transkripte gesamt: {len(files)}")
+print(f"Qwen-Kandidaten:     {len(candidates)}")
+
+OUT.parent.mkdir(parents=True, exist_ok=True)
+
+with OUT.open("w", encoding="utf-8") as f:
+    with ThreadPoolExecutor(max_workers=WORKERS) as pool:
+        futures = {pool.submit(classify, p): p for p in candidates}
+
+        done = 0
+        for future in as_completed(futures):
+            p = futures[future]
+            done += 1
+
+            try:
+                result = future.result()
+                f.write(json.dumps(result, ensure_ascii=False) + "\n")
+                f.flush()
+
+                print(
+                    f"[{done}/{len(candidates)}] "
+                    f"{p.name} {result['seconds']}s → "
+                    f"{result['relevance']} "
+                    f"{result['confidence']}",
+                    flush=True
+                )
+
+            except Exception as e:
+                print(f"ERROR {p.name}: {e}", flush=True)
+
+print(f"\nErgebnis: {OUT}")

+ 90 - 0
tools/filter_uncertain.py

@@ -0,0 +1,90 @@
+import json, time, requests
+from pathlib import Path
+from concurrent.futures import ThreadPoolExecutor, as_completed
+
+BASE = Path("/opt/3cx-middleware/3cx-telefonie-middleware")
+SRC = BASE / "data/transcripts"
+SCREEN = BASE / "data/transcript_screening.jsonl"
+OUT = BASE / "data/transcript_relevance_final.jsonl"
+
+URL = "http://192.168.1.101:11434/api/chat"
+MODEL = "llama3.2:3b"
+WORKERS = 1
+
+SYSTEM = """Du klassifizierst ein Telefontranskript ausschließlich nach Relevanz.
+
+RELEVANT:
+Ein echter Dialog mit Kunde, Interessent, Lieferant oder Geschäftspartner.
+
+IRRELEVANT:
+reine Telefonansage, Mailbox, Warteschleife, Fehlanruf ohne Gespräch,
+Werbung oder praktisch kein Gespräch.
+
+UNCERTAIN:
+nicht eindeutig.
+
+Antworte ausschließlich mit JSON:
+{"relevance":"RELEVANT","confidence":0.0}
+oder
+{"relevance":"IRRELEVANT","confidence":0.0}
+oder
+{"relevance":"UNCERTAIN","confidence":0.0}
+"""
+
+items = []
+for line in SCREEN.read_text(encoding="utf-8").splitlines():
+    r = json.loads(line)
+    if r["relevance"] == "UNCERTAIN":
+        items.append(r["file"])
+
+print(f"UNCERTAIN: {len(items)}")
+
+def run(filename):
+    text = (SRC / filename).read_text(
+        encoding="utf-8", errors="replace"
+    ).strip()
+
+    payload = {
+        "model": MODEL,
+        "messages": [
+            {"role": "system", "content": SYSTEM},
+            {"role": "user", "content": text[:5000]}
+        ],
+        "stream": False,
+        "think": False,
+        "format": "json",
+        "options": {"temperature": 0}
+    }
+
+    start = time.time()
+    r = requests.post(URL, json=payload, timeout=120)
+    r.raise_for_status()
+
+    result = json.loads(r.json()["message"]["content"])
+
+    return {
+        "file": filename,
+        "relevance": result.get("relevance", "UNCERTAIN"),
+        "confidence": result.get("confidence", 0),
+        "seconds": round(time.time() - start, 1)
+    }
+
+with OUT.open("w", encoding="utf-8") as f:
+    with ThreadPoolExecutor(max_workers=WORKERS) as pool:
+        futures = [pool.submit(run, x) for x in items]
+
+        for i, future in enumerate(as_completed(futures), 1):
+            try:
+                r = future.result()
+                f.write(json.dumps(r, ensure_ascii=False) + "\n")
+                f.flush()
+                print(
+                    f"[{i}/{len(items)}] {r['file']} "
+                    f"{r['seconds']}s → {r['relevance']} "
+                    f"{r['confidence']}",
+                    flush=True
+                )
+            except Exception as e:
+                print("ERROR:", e, flush=True)
+
+print("\nFertig:", OUT)

+ 224 - 0
tools/fix_and_resume_batch.py

@@ -0,0 +1,224 @@
+import asyncio
+import json
+import sqlite3
+import subprocess
+import sys
+import urllib.request
+from pathlib import Path
+
+DB = Path("data/telephony.sqlite3")
+MODEL = "qwen3:8b"
+OLLAMA = "http://127.0.0.1:11434/api/generate"
+
+
+def schema():
+    return {
+        "type": "object",
+        "properties": {
+            "customer": {
+                "type": "object",
+                "properties": {
+                    "name": {"type": ["string", "null"]},
+                    "email": {"type": ["string", "null"]},
+                    "phone": {"type": ["string", "null"]},
+                    "address": {"type": ["string", "null"]},
+                    "customer_number": {"type": ["string", "null"]},
+                    "order_number": {"type": ["string", "null"]}
+                },
+                "required": [
+                    "name", "email", "phone", "address",
+                    "customer_number", "order_number"
+                ]
+            },
+            "products": {
+                "type": "array",
+                "items": {
+                    "type": "object",
+                    "properties": {
+                        "raw_text": {"type": "string"},
+                        "normalized": {"type": ["string", "null"]},
+                        "quantity": {"type": ["number", "null"]},
+                        "uncertain": {"type": "boolean"}
+                    },
+                    "required": [
+                        "raw_text", "normalized",
+                        "quantity", "uncertain"
+                    ]
+                }
+            },
+            "vouchers": {
+                "type": "array",
+                "items": {
+                    "type": "object",
+                    "properties": {
+                        "raw_text": {"type": "string"},
+                        "amount": {"type": ["number", "null"]},
+                        "code": {"type": ["string", "null"]},
+                        "uncertain": {"type": "boolean"}
+                    },
+                    "required": [
+                        "raw_text", "amount",
+                        "code", "uncertain"
+                    ]
+                }
+            },
+            "analysis": {
+                "type": "object",
+                "properties": {
+                    "intent": {"type": ["string", "null"]},
+                    "sentiment": {
+                        "type": ["string", "null"],
+                        "enum": ["positive", "neutral", "negative", "mixed", None]
+                    },
+                    "summary": {"type": ["string", "null"]},
+                    "advice": {
+                        "type": "array",
+                        "items": {"type": "string"}
+                    },
+                    "follow_up": {
+                        "type": "array",
+                        "items": {"type": "string"}
+                    }
+                },
+                "required": [
+                    "intent", "sentiment", "summary",
+                    "advice", "follow_up"
+                ]
+            },
+            "uncertainties": {
+                "type": "array",
+                "items": {"type": "string"}
+            }
+        },
+        "required": [
+            "customer",
+            "products",
+            "vouchers",
+            "analysis",
+            "uncertainties"
+        ]
+    }
+
+
+def transcript_text(data):
+    texts = []
+
+    for item in data.get("transcripts", []):
+        for segment in item.get("segments", []):
+            text = segment.get("text", "").strip()
+            if text:
+                texts.append(text)
+
+    return "\n".join(texts)
+
+
+def analyze(text):
+    prompt = f"""
+Analysiere dieses deutschsprachige Kundentelefonat.
+
+Extrahiere personenbezogene Daten, sofern sie im Gespräch genannt werden:
+Name, E-Mail, Telefonnummer, Adresse, Kundennummer, Bestellnummer
+und Gutscheincode.
+
+Diese Daten dienen der späteren Kunden- und Auftragszuordnung.
+
+Verwende ausschließlich Informationen aus dem Transkript.
+Erfinde niemals Daten.
+
+Whisper kann Wörter falsch erkennen.
+Korrigiere einen Fehler nur, wenn der Kontext die Korrektur eindeutig macht.
+
+Bei unsicheren Produktnamen, Nummern, Codes oder Namen:
+raw_text = tatsächlich erkannter Wortlaut
+normalized = null
+uncertain = true
+
+Keine Sprecherzuordnung erfinden.
+Keine neuen Zeitstempel erzeugen.
+
+TRANSKRIPT:
+
+{text}
+"""
+
+    payload = {
+        "model": MODEL,
+        "prompt": prompt,
+        "stream": False,
+        "format": schema(),
+        "think": False,
+        "options": {"temperature": 0}
+    }
+
+    request = urllib.request.Request(
+        OLLAMA,
+        data=json.dumps(payload).encode(),
+        headers={"Content-Type": "application/json"},
+        method="POST"
+    )
+
+    with urllib.request.urlopen(request, timeout=300) as response:
+        return json.loads(json.load(response)["response"])
+
+
+async def main():
+    con = sqlite3.connect(DB)
+    con.row_factory = sqlite3.Row
+
+    t = con.execute("""
+        SELECT *
+        FROM transcripts
+        WHERE rec_id = 4618
+        ORDER BY id DESC
+        LIMIT 1
+    """).fetchone()
+
+    if not t:
+        raise RuntimeError("Transcript 4618 nicht gefunden.")
+
+    print(f"Vorhandenes Transcript: {t['id']}")
+
+    data = json.loads(t["transcript_json"])
+    text = transcript_text(data)
+
+    print(f"Transkript: {len(text)} Zeichen")
+    print("Qwen3:8b analysiert vorhandenes Transkript ...")
+
+    analysis = await asyncio.to_thread(analyze, text)
+
+    con.execute("""
+        INSERT INTO analyses (
+            call_id,
+            cdr_row_id,
+            transcript_id,
+            model,
+            schema_version,
+            analysis_json,
+            status
+        )
+        VALUES (
+            NULL,
+            ?,
+            ?,
+            ?,
+            '1.0',
+            ?,
+            'completed'
+        )
+    """, (
+        t["cdr_row_id"],
+        t["id"],
+        MODEL,
+        json.dumps(analysis, ensure_ascii=False)
+    ))
+
+    con.commit()
+
+    print()
+    print("Analyse gespeichert.")
+    print(json.dumps(analysis, ensure_ascii=False, indent=2))
+
+    con.close()
+
+
+asyncio.run(main())

+ 117 - 0
tools/fix_call_relations.py

@@ -0,0 +1,117 @@
+import sqlite3
+from pathlib import Path
+
+DB = Path("data/telephony.sqlite3")
+
+con = sqlite3.connect(DB)
+con.execute("PRAGMA foreign_keys = ON")
+
+# Neue direkte Beziehung zum echten 3CX-CDR-Datensatz.
+cols = {
+    row[1]
+    for row in con.execute("PRAGMA table_info(transcripts)")
+}
+
+if "cdr_row_id" not in cols:
+    con.execute("""
+        ALTER TABLE transcripts
+        ADD COLUMN cdr_row_id INTEGER
+    """)
+
+# call_id darf künftig NULL sein.
+# SQLite erlaubt keine direkte Änderung von NOT NULL.
+# Bestehende 0 wird deshalb als "nicht verknüpft" behandelt.
+# Die echte Beziehung läuft über cdr_row_id.
+
+analysis_cols = {
+    row[1]
+    for row in con.execute("PRAGMA table_info(analyses)")
+}
+
+if "cdr_row_id" not in analysis_cols:
+    con.execute("""
+        ALTER TABLE analyses
+        ADD COLUMN cdr_row_id INTEGER
+    """)
+
+con.execute("""
+    CREATE INDEX IF NOT EXISTS idx_transcripts_cdr_row_id
+    ON transcripts(cdr_row_id)
+""")
+
+con.execute("""
+    CREATE INDEX IF NOT EXISTS idx_analyses_cdr_row_id
+    ON analyses(cdr_row_id)
+""")
+
+# Das bereits analysierte Recording 4648 eindeutig zuordnen.
+cdr = con.execute("""
+    SELECT id
+    FROM cdr_calls
+    WHERE src_rec_id = 4648
+       OR dst_rec_id = 4648
+    ORDER BY id
+    LIMIT 1
+""").fetchone()
+
+if cdr:
+    cdr_row_id = cdr[0]
+
+    con.execute("""
+        UPDATE transcripts
+        SET cdr_row_id = ?
+        WHERE rec_id = 4648
+    """, (cdr_row_id,))
+
+    con.execute("""
+        UPDATE analyses
+        SET cdr_row_id = ?
+        WHERE transcript_id IN (
+            SELECT id
+            FROM transcripts
+            WHERE rec_id = 4648
+        )
+    """, (cdr_row_id,))
+
+    print(f"recId 4648 → cdr_calls.id {cdr_row_id}")
+else:
+    print("WARNUNG: CDR für recId 4648 nicht gefunden.")
+
+con.commit()
+
+print()
+print("=== VERKNÜPFUNG ===")
+
+row = con.execute("""
+    SELECT
+        t.id AS transcript_id,
+        t.cdr_row_id,
+        t.rec_id,
+        a.id AS analysis_id,
+        c.cdr_id,
+        c.start_time,
+        c.source_caller_id,
+        c.src_rec_id
+    FROM transcripts t
+    LEFT JOIN analyses a
+        ON a.transcript_id = t.id
+    LEFT JOIN cdr_calls c
+        ON c.id = t.cdr_row_id
+    WHERE t.rec_id = 4648
+    ORDER BY t.id DESC
+    LIMIT 1
+""").fetchone()
+
+if row:
+    print(
+        "transcript_id =", row[0],
+        "| cdr_row_id =", row[1],
+        "| rec_id =", row[2],
+        "| analysis_id =", row[3],
+        "| cdr_id =", row[4],
+        "| start =", row[5],
+        "| caller =", row[6],
+        "| recording =", row[7],
+    )
+
+con.close()

+ 334 - 0
tools/import_zammad_history.py

@@ -0,0 +1,334 @@
+#!/usr/bin/env python3
+
+import json
+import os
+import re
+import sqlite3
+import time
+from html import unescape
+from pathlib import Path
+from urllib.parse import urljoin
+
+import requests
+
+
+BASE = Path(".")
+ENV = BASE / ".env"
+DB = BASE / "data/zammad_history.sqlite3"
+
+PER_PAGE = 100
+
+
+def load_env():
+    if not ENV.exists():
+        raise SystemExit(".env fehlt")
+
+    for line in ENV.read_text().splitlines():
+        line = line.strip()
+
+        if not line or line.startswith("#") or "=" not in line:
+            continue
+
+        key, value = line.split("=", 1)
+        value = value.strip().strip('"').strip("'")
+        os.environ.setdefault(key, value)
+
+
+def clean_html(value):
+    if not value:
+        return ""
+
+    value = re.sub(
+        r"<(script|style).*?</\1>",
+        " ",
+        value,
+        flags=re.I | re.S,
+    )
+
+    value = re.sub(r"<br\s*/?>", "\n", value, flags=re.I)
+    value = re.sub(r"</p\s*>", "\n", value, flags=re.I)
+    value = re.sub(r"</div\s*>", "\n", value, flags=re.I)
+    value = re.sub(r"<[^>]+>", " ", value)
+
+    value = unescape(value)
+
+    value = re.sub(r"[ \t]+", " ", value)
+    value = re.sub(r"\n\s*\n+", "\n\n", value)
+
+    return value.strip()
+
+
+def api(session, url, params=None):
+    for attempt in range(5):
+        response = session.get(
+            url,
+            params=params,
+            timeout=60,
+        )
+
+        if response.status_code == 429:
+            wait = int(
+                response.headers.get(
+                    "Retry-After",
+                    "5",
+                )
+            )
+            print(f"Rate limit – warte {wait}s")
+            time.sleep(wait)
+            continue
+
+        response.raise_for_status()
+        return response.json()
+
+    raise RuntimeError(f"API nicht erreichbar: {url}")
+
+
+def init_db(con):
+    con.executescript("""
+    CREATE TABLE IF NOT EXISTS tickets (
+        id INTEGER PRIMARY KEY,
+        number TEXT,
+        title TEXT,
+        group_name TEXT,
+        state TEXT,
+        state_id INTEGER,
+        priority TEXT,
+        priority_id INTEGER,
+        customer_id INTEGER,
+        customer_email TEXT,
+        owner_id INTEGER,
+        organization_id INTEGER,
+        created_at TEXT,
+        updated_at TEXT,
+        close_at TEXT,
+        tags_json TEXT,
+        custom_fields_json TEXT,
+        raw_json TEXT,
+        imported_at TEXT DEFAULT CURRENT_TIMESTAMP
+    );
+
+    CREATE TABLE IF NOT EXISTS articles (
+        id INTEGER PRIMARY KEY,
+        ticket_id INTEGER NOT NULL,
+        type TEXT,
+        sender TEXT,
+        sender_id INTEGER,
+        from_address TEXT,
+        to_address TEXT,
+        subject TEXT,
+        internal INTEGER,
+        created_at TEXT,
+        body_html TEXT,
+        body_text TEXT,
+        content_type TEXT,
+        attachments_json TEXT,
+        raw_json TEXT,
+        imported_at TEXT DEFAULT CURRENT_TIMESTAMP
+    );
+
+    CREATE INDEX IF NOT EXISTS idx_articles_ticket
+        ON articles(ticket_id);
+
+    CREATE INDEX IF NOT EXISTS idx_tickets_updated
+        ON tickets(updated_at);
+    """)
+
+
+def main():
+    load_env()
+
+    base_url = os.environ["ZAMMAD_URL"].rstrip("/")
+    user = os.environ["ZAMMAD_USER"]
+    password = os.environ["ZAMMAD_PASSWORD"]
+
+    session = requests.Session()
+    session.auth = (user, password)
+    session.headers.update({
+        "Accept": "application/json",
+        "Content-Type": "application/json",
+        "User-Agent": "Schmid-Telefonie-Taxonomy-Importer/1.0",
+    })
+
+    DB.parent.mkdir(parents=True, exist_ok=True)
+
+    con = sqlite3.connect(DB)
+    init_db(con)
+
+    print("=" * 72)
+    print("ZAMMAD HISTORIENIMPORT")
+    print("=" * 72)
+    print(f"Server: {base_url}")
+    print(f"Ziel:   {DB}")
+    print()
+
+    page = 1
+    total_tickets = 0
+    total_articles = 0
+
+    while True:
+        tickets = api(
+            session,
+            f"{base_url}/api/v1/tickets",
+            {
+                "page": page,
+                "per_page": PER_PAGE,
+                "order_by": "id",
+                "order_direction": "asc",
+            },
+        )
+
+        if not tickets:
+            break
+
+        print(
+            f"Seite {page}: "
+            f"{len(tickets)} Tickets"
+        )
+
+        for ticket in tickets:
+            ticket_id = ticket["id"]
+
+            con.execute("""
+                INSERT OR REPLACE INTO tickets (
+                    id,
+                    number,
+                    title,
+                    group_name,
+                    state,
+                    state_id,
+                    priority,
+                    priority_id,
+                    customer_id,
+                    customer_email,
+                    owner_id,
+                    organization_id,
+                    created_at,
+                    updated_at,
+                    close_at,
+                    tags_json,
+                    custom_fields_json,
+                    raw_json
+                )
+                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+            """, (
+                ticket_id,
+                ticket.get("number"),
+                ticket.get("title"),
+                ticket.get("group"),
+                ticket.get("state"),
+                ticket.get("state_id"),
+                ticket.get("priority"),
+                ticket.get("priority_id"),
+                ticket.get("customer_id"),
+                ticket.get("customer"),
+                ticket.get("owner_id"),
+                ticket.get("organization_id"),
+                ticket.get("created_at"),
+                ticket.get("updated_at"),
+                ticket.get("close_at"),
+                json.dumps(
+                    ticket.get("tags", []),
+                    ensure_ascii=False,
+                ),
+                json.dumps(
+                    ticket.get("preferences", {}),
+                    ensure_ascii=False,
+                ),
+                json.dumps(
+                    ticket,
+                    ensure_ascii=False,
+                ),
+            ))
+
+            articles = api(
+                session,
+                f"{base_url}/api/v1/ticket_articles/by_ticket/{ticket_id}",
+            )
+
+            for article in articles:
+                body = article.get("body") or ""
+
+                con.execute("""
+                    INSERT OR REPLACE INTO articles (
+                        id,
+                        ticket_id,
+                        type,
+                        sender,
+                        sender_id,
+                        from_address,
+                        to_address,
+                        subject,
+                        internal,
+                        created_at,
+                        body_html,
+                        body_text,
+                        content_type,
+                        attachments_json,
+                        raw_json
+                    )
+                    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+                """, (
+                    article["id"],
+                    ticket_id,
+                    article.get("type"),
+                    article.get("sender"),
+                    article.get("sender_id"),
+                    article.get("from"),
+                    article.get("to"),
+                    article.get("subject"),
+                    int(bool(article.get("internal"))),
+                    article.get("created_at"),
+                    body,
+                    clean_html(body),
+                    article.get("content_type"),
+                    json.dumps(
+                        article.get("attachments", []),
+                        ensure_ascii=False,
+                    ),
+                    json.dumps(
+                        article,
+                        ensure_ascii=False,
+                    ),
+                ))
+
+                total_articles += 1
+
+            total_tickets += 1
+
+            if total_tickets % 100 == 0:
+                con.commit()
+                print(
+                    f"  {total_tickets} Tickets / "
+                    f"{total_articles} Artikel"
+                )
+
+        con.commit()
+
+        if len(tickets) < PER_PAGE:
+            break
+
+        page += 1
+
+    con.commit()
+
+    ticket_count = con.execute(
+        "SELECT COUNT(*) FROM tickets"
+    ).fetchone()[0]
+
+    article_count = con.execute(
+        "SELECT COUNT(*) FROM articles"
+    ).fetchone()[0]
+
+    con.close()
+
+    print()
+    print("=" * 72)
+    print("IMPORT FERTIG")
+    print("=" * 72)
+    print(f"Tickets: {ticket_count}")
+    print(f"Artikel: {article_count}")
+    print(f"DB:     {DB}")
+
+
+if __name__ == "__main__":
+    main()

+ 227 - 0
tools/index_recordings.py

@@ -0,0 +1,227 @@
+#!/usr/bin/env python3
+
+import hashlib
+import re
+import sqlite3
+import subprocess
+from pathlib import Path
+
+ROOT = Path("data/recordings")
+DB = Path("data/recording_index.sqlite3")
+
+PATTERN = re.compile(
+    r"^\[(?P<name>.*?)\]_(?P<ext>\d+)-(?P<number>.*?)_"
+    r"(?P<date>\d{14})\((?P<id>\d+)\)\.wav$"
+)
+
+con = sqlite3.connect(DB)
+
+con.executescript("""
+CREATE TABLE IF NOT EXISTS recordings (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    path TEXT UNIQUE NOT NULL,
+    filename TEXT NOT NULL,
+    extension TEXT,
+    caller_name TEXT,
+    phone_number TEXT,
+    recorded_at TEXT,
+    recording_id TEXT,
+    size INTEGER,
+    sha256 TEXT,
+    duration REAL,
+    status TEXT DEFAULT 'NEW',
+    transcript_path TEXT,
+    analysis_path TEXT,
+    taxonomy_version TEXT,
+    created_at TEXT DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX IF NOT EXISTS idx_recording_id
+    ON recordings(recording_id);
+
+CREATE INDEX IF NOT EXISTS idx_status
+    ON recordings(status);
+
+CREATE INDEX IF NOT EXISTS idx_recorded_at
+    ON recordings(recorded_at);
+""")
+
+
+def duration(path):
+    try:
+        result = subprocess.run(
+            [
+                "ffprobe",
+                "-v", "error",
+                "-show_entries",
+                "format=duration",
+                "-of", "default=noprint_wrappers=1:nokey=1",
+                str(path),
+            ],
+            capture_output=True,
+            text=True,
+            timeout=15,
+        )
+
+        return float(result.stdout.strip())
+
+    except Exception:
+        return None
+
+
+def sha256(path):
+    h = hashlib.sha256()
+
+    with path.open("rb") as f:
+        while chunk := f.read(1024 * 1024):
+            h.update(chunk)
+
+    return h.hexdigest()
+
+
+files = sorted(
+    ROOT.rglob("*.wav")
+)
+
+print(f"Recordings gefunden: {len(files)}")
+
+new = 0
+updated = 0
+short = 0
+unknown = 0
+
+for index, path in enumerate(files, 1):
+
+    filename = path.name
+    match = PATTERN.match(filename)
+
+    if not match:
+        unknown += 1
+        continue
+
+    data = match.groupdict()
+
+    recorded_at = (
+        f"{data['date'][:4]}-"
+        f"{data['date'][4:6]}-"
+        f"{data['date'][6:8]} "
+        f"{data['date'][8:10]}:"
+        f"{data['date'][10:12]}:"
+        f"{data['date'][12:14]}"
+    )
+
+    rel = str(path)
+
+    existing = con.execute(
+        "SELECT id, sha256 FROM recordings WHERE path = ?",
+        (rel,),
+    ).fetchone()
+
+    size = path.stat().st_size
+
+    if existing and existing[1]:
+        con.execute("""
+            UPDATE recordings
+            SET size = ?
+            WHERE id = ?
+        """, (size, existing[0]))
+
+        updated += 1
+        continue
+
+    print(
+        f"[{index}/{len(files)}] "
+        f"{filename}"
+    )
+
+    d = duration(path)
+    digest = sha256(path)
+
+    status = "NEW"
+
+    if d is not None and d < 2:
+        status = "TOO_SHORT"
+        short += 1
+
+    con.execute("""
+        INSERT OR REPLACE INTO recordings (
+            path,
+            filename,
+            extension,
+            caller_name,
+            phone_number,
+            recorded_at,
+            recording_id,
+            size,
+            sha256,
+            duration,
+            status
+        )
+        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+    """, (
+        rel,
+        filename,
+        data["ext"],
+        data["name"],
+        data["number"],
+        recorded_at,
+        data["id"],
+        size,
+        digest,
+        d,
+        status,
+    ))
+
+    new += 1
+
+    if index % 50 == 0:
+        con.commit()
+
+con.commit()
+
+total = con.execute(
+    "SELECT COUNT(*) FROM recordings"
+).fetchone()[0]
+
+new_count = con.execute(
+    "SELECT COUNT(*) FROM recordings WHERE status='NEW'"
+).fetchone()[0]
+
+short_count = con.execute(
+    "SELECT COUNT(*) FROM recordings WHERE status='TOO_SHORT'"
+).fetchone()[0]
+
+print()
+print("=" * 70)
+print("RECORDING-INDEX FERTIG")
+print("=" * 70)
+print(f"Dateien:          {total}")
+print(f"Neu indexiert:    {new}")
+print(f"Bereits bekannt:  {updated}")
+print(f"Unbekanntes Format:{unknown}")
+print(f"< 2 Sekunden:     {short_count}")
+print(f"Analyse-Kandidaten:{new_count}")
+print(f"DB:               {DB}")
+
+print()
+print("Dauerverteilung:")
+
+for row in con.execute("""
+    SELECT
+        CASE
+            WHEN duration < 2 THEN '<2s'
+            WHEN duration < 10 THEN '2-10s'
+            WHEN duration < 30 THEN '10-30s'
+            WHEN duration < 60 THEN '30-60s'
+            WHEN duration < 180 THEN '1-3min'
+            WHEN duration < 600 THEN '3-10min'
+            ELSE '>10min'
+        END bucket,
+        COUNT(*)
+    FROM recordings
+    GROUP BY bucket
+    ORDER BY MIN(duration)
+"""):
+    print(f"{row[0]:10} {row[1]}")
+
+con.close()

+ 96 - 0
tools/match_calls_by_time.py

@@ -0,0 +1,96 @@
+import sqlite3
+from datetime import datetime
+
+DB = "data/telephony.sqlite3"
+
+def parse(value):
+    if not value:
+        return None
+    return datetime.fromisoformat(value.replace("Z", "+00:00"))
+
+def norm_phone(value):
+    if not value:
+        return None
+    return "".join(c for c in value if c.isdigit() or c == "+")
+
+con = sqlite3.connect(DB)
+con.row_factory = sqlite3.Row
+
+calls = con.execute("""
+    SELECT *
+    FROM calls
+    WHERE started_at IS NOT NULL
+    ORDER BY started_at
+""").fetchall()
+
+cdrs = con.execute("""
+    SELECT *
+    FROM cdr_calls
+    WHERE start_time IS NOT NULL
+    ORDER BY start_time
+""").fetchall()
+
+print("=== ZEITLICHE CALL-ZUORDNUNG ===")
+print()
+
+for call in calls:
+    ct = parse(call["started_at"])
+
+    candidates = []
+
+    for cdr in cdrs:
+        dt = parse(cdr["start_time"])
+        if not dt:
+            continue
+
+        delta = abs((ct - dt).total_seconds())
+
+        if delta <= 120:
+            score = delta
+
+            call_phone = norm_phone(call["phone"])
+            cdr_phone = norm_phone(cdr["source_caller_id"])
+
+            # Gleiche Rufnummer stark bevorzugen.
+            if call_phone and cdr_phone:
+                if call_phone == cdr_phone:
+                    score -= 60
+                else:
+                    score += 30
+
+            candidates.append((score, delta, cdr))
+
+    candidates.sort(key=lambda x: x[0])
+
+    print(
+        f"CALL {call['id']:>3} | "
+        f"{call['started_at']} | "
+        f"phone={call['phone']} | "
+        f"duration={call['duration_seconds']}"
+    )
+
+    if not candidates:
+        print("   -> KEIN CDR innerhalb 120s")
+        continue
+
+    for rank, (score, delta, cdr) in enumerate(candidates[:3], 1):
+        print(
+            f"   {rank}. "
+            f"CDR={cdr['id']} "
+            f"delta={delta:.2f}s "
+            f"phone={cdr['source_caller_id']} "
+            f"recId={cdr['src_rec_id'] or cdr['dst_rec_id']} "
+            f"cdr={cdr['cdr_id']}"
+        )
+
+    best = candidates[0]
+
+    print(
+        f"   => BEST MATCH: cdr_calls.id={best[2]['id']} "
+        f"(Abweichung {best[1]:.2f}s)"
+    )
+
+print()
+print("=== FERTIG ===")
+
+con.close()

+ 101 - 0
tools/migrate_call_analysis.py

@@ -0,0 +1,101 @@
+from pathlib import Path
+import sqlite3
+
+DB = Path("data/telephony.sqlite3")
+DB.parent.mkdir(parents=True, exist_ok=True)
+
+con = sqlite3.connect(DB)
+con.execute("PRAGMA foreign_keys = ON")
+
+# Bestehende calls-Tabelle NICHT verändern.
+# Die historische Recording-ID wird im Transcript-Datensatz gespeichert.
+
+con.executescript("""
+CREATE TABLE IF NOT EXISTS transcripts (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+
+    call_id INTEGER NOT NULL,
+    rec_id INTEGER,
+
+    model TEXT NOT NULL,
+    language TEXT,
+
+    audio_codec TEXT,
+    audio_channels INTEGER,
+    audio_sample_rate INTEGER,
+    audio_duration REAL,
+
+    transcript_json TEXT NOT NULL,
+
+    status TEXT NOT NULL DEFAULT 'completed',
+
+    created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+    FOREIGN KEY(call_id)
+        REFERENCES calls(id)
+        ON DELETE CASCADE
+);
+
+CREATE INDEX IF NOT EXISTS idx_transcripts_call_id
+    ON transcripts(call_id);
+
+CREATE INDEX IF NOT EXISTS idx_transcripts_rec_id
+    ON transcripts(rec_id);
+
+
+CREATE TABLE IF NOT EXISTS analyses (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+
+    call_id INTEGER NOT NULL,
+    transcript_id INTEGER NOT NULL,
+
+    model TEXT NOT NULL,
+    schema_version TEXT NOT NULL DEFAULT '1.0',
+
+    analysis_json TEXT NOT NULL,
+
+    status TEXT NOT NULL DEFAULT 'completed',
+
+    created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+    FOREIGN KEY(call_id)
+        REFERENCES calls(id)
+        ON DELETE CASCADE,
+
+    FOREIGN KEY(transcript_id)
+        REFERENCES transcripts(id)
+        ON DELETE CASCADE
+);
+
+CREATE INDEX IF NOT EXISTS idx_analyses_call_id
+    ON analyses(call_id);
+
+CREATE INDEX IF NOT EXISTS idx_analyses_transcript_id
+    ON analyses(transcript_id);
+""")
+
+con.commit()
+
+print("Migration erfolgreich.")
+print()
+
+print("Tabellen:")
+
+for row in con.execute("""
+    SELECT name
+    FROM sqlite_master
+    WHERE type = 'table'
+    ORDER BY name
+"""):
+    print(" ", row[0])
+
+print()
+print("Bestehende Calls:", end=" ")
+
+count = con.execute(
+    "SELECT COUNT(*) FROM calls"
+).fetchone()[0]
+
+print(count)
+
+con.close()

+ 110 - 0
tools/prepare_historical_batch.py

@@ -0,0 +1,110 @@
+import sqlite3
+from pathlib import Path
+
+DB = Path("data/telephony.sqlite3")
+
+con = sqlite3.connect(DB)
+con.row_factory = sqlite3.Row
+
+# SQLite kann NOT NULL bei einer bestehenden Spalte nicht einfach entfernen.
+# Für die CDR-basierte Historie ist call_id fachlich nicht erforderlich.
+# Wir bauen transcripts sauber neu auf Basis des bestehenden Schemas um.
+cols = [r["name"] for r in con.execute("PRAGMA table_info(transcripts)")]
+
+if "call_id" in cols:
+    con.execute("PRAGMA foreign_keys=OFF")
+
+    con.executescript("""
+        CREATE TABLE transcripts_new (
+            id INTEGER PRIMARY KEY AUTOINCREMENT,
+            call_id INTEGER,
+            cdr_row_id INTEGER,
+            rec_id INTEGER,
+
+            model TEXT NOT NULL,
+            language TEXT,
+
+            audio_codec TEXT,
+            audio_channels INTEGER,
+            audio_sample_rate INTEGER,
+            audio_duration REAL,
+
+            transcript_json TEXT NOT NULL,
+            status TEXT NOT NULL DEFAULT 'completed',
+
+            created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+            FOREIGN KEY(call_id)
+                REFERENCES calls(id)
+                ON DELETE CASCADE
+        );
+
+        INSERT INTO transcripts_new (
+            id, call_id, cdr_row_id, rec_id,
+            model, language,
+            audio_codec, audio_channels,
+            audio_sample_rate, audio_duration,
+            transcript_json, status, created_at
+        )
+        SELECT
+            id, NULL, cdr_row_id, rec_id,
+            model, language,
+            audio_codec, audio_channels,
+            audio_sample_rate, audio_duration,
+            transcript_json, status, created_at
+        FROM transcripts;
+
+        DROP TABLE transcripts;
+        ALTER TABLE transcripts_new RENAME TO transcripts;
+
+        CREATE INDEX IF NOT EXISTS idx_transcripts_cdr_row_id
+            ON transcripts(cdr_row_id);
+
+        CREATE INDEX IF NOT EXISTS idx_transcripts_rec_id
+            ON transcripts(rec_id);
+    """)
+
+    con.execute("PRAGMA foreign_keys=ON")
+
+con.commit()
+
+# Bestand exakt feststellen.
+summary = con.execute("""
+    SELECT
+        COUNT(*) AS cdr_rows,
+        COUNT(DISTINCT main_call_history_id) AS call_histories,
+        COUNT(DISTINCT COALESCE(src_rec_id, dst_rec_id)) AS recordings,
+        MIN(start_time) AS first_call,
+        MAX(start_time) AS last_call
+    FROM cdr_calls
+    WHERE src_rec_id IS NOT NULL
+       OR dst_rec_id IS NOT NULL
+""").fetchone()
+
+processed = con.execute("""
+    SELECT COUNT(DISTINCT rec_id)
+    FROM transcripts
+    WHERE rec_id IS NOT NULL
+""").fetchone()[0]
+
+analysed = con.execute("""
+    SELECT COUNT(DISTINCT t.rec_id)
+    FROM transcripts t
+    JOIN analyses a ON a.transcript_id = t.id
+    WHERE t.rec_id IS NOT NULL
+""").fetchone()[0]
+
+print()
+print("========================================")
+print("HISTORISCHER RECORDING-BESTAND")
+print("========================================")
+print(f"CDR-Zeilen mit Recording : {summary['cdr_rows']}")
+print(f"Eindeutige Call-History  : {summary['call_histories']}")
+print(f"Eindeutige Recordings    : {summary['recordings']}")
+print(f"Bereits transkribiert    : {processed}")
+print(f"Bereits analysiert       : {analysed}")
+print(f"Noch zu verarbeiten      : {summary['recordings'] - analysed}")
+print(f"Ältester Call            : {summary['first_call']}")
+print(f"Neuester Call            : {summary['last_call']}")
+
+con.close()

+ 351 - 0
tools/prepare_zammad_cases.py

@@ -0,0 +1,351 @@
+#!/usr/bin/env python3
+
+import hashlib
+import html
+import json
+import re
+import sqlite3
+from pathlib import Path
+from collections import Counter
+
+DB = Path("data/zammad_history.sqlite3")
+OUT = Path("data/zammad_cases.sqlite3")
+
+
+def clean(text):
+    if not text:
+        return ""
+
+    text = html.unescape(text)
+
+    # HTML
+    text = re.sub(
+        r"<(script|style).*?</\1>",
+        " ",
+        text,
+        flags=re.I | re.S,
+    )
+    text = re.sub(r"<[^>]+>", " ", text)
+
+    # quoted mail history
+    text = re.sub(
+        r"\n\s*(Am .* schrieb .*?:|On .* wrote:).*",
+        "",
+        text,
+        flags=re.I | re.S,
+    )
+
+    # common signature endings
+    text = re.split(
+        r"\n\s*(Mit freundlichen Grüßen|"
+        r"Viele Grüße|"
+        r"Beste Grüße|"
+        r"Freundliche Grüße)\b",
+        text,
+        maxsplit=1,
+        flags=re.I,
+    )[0]
+
+    text = re.sub(r"[ \t]+", " ", text)
+    text = re.sub(r"\n{3,}", "\n\n", text)
+
+    return text.strip()
+
+
+def normalize_for_fingerprint(text):
+    text = text.lower()
+
+    # Telefonnummern / E-Mail-Adressen / IDs
+    text = re.sub(
+        r"\b[\w.+-]+@[\w.-]+\.\w+\b",
+        " EMAIL ",
+        text,
+    )
+    text = re.sub(
+        r"\b\d{4,}\b",
+        " NUMBER ",
+        text,
+    )
+
+    # whitespace
+    text = re.sub(r"\s+", " ", text)
+
+    return text.strip()
+
+
+def fingerprint(text):
+    normalized = normalize_for_fingerprint(text)
+
+    # Wortfolge als stabile lokale Signatur.
+    words = normalized.split()
+
+    if len(words) > 120:
+        words = words[:120]
+
+    return hashlib.sha1(
+        " ".join(words).encode(
+            "utf-8",
+            errors="ignore",
+        )
+    ).hexdigest()
+
+
+src = sqlite3.connect(DB)
+src.row_factory = sqlite3.Row
+
+# Neue Analyse-DB; die Originaldaten bleiben unangetastet.
+out = sqlite3.connect(OUT)
+
+out.executescript("""
+DROP TABLE IF EXISTS cases;
+
+CREATE TABLE cases (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    ticket_id INTEGER UNIQUE,
+    ticket_number TEXT,
+    title TEXT,
+    group_name TEXT,
+    state TEXT,
+    created_at TEXT,
+    updated_at TEXT,
+
+    customer_id TEXT,
+
+    tags_json TEXT,
+
+    article_count INTEGER,
+    customer_article_count INTEGER,
+
+    subject_clean TEXT,
+    conversation TEXT,
+
+    fingerprint TEXT,
+
+    content_length INTEGER,
+
+    classification_status TEXT DEFAULT 'NEW',
+    primary_intent TEXT,
+    secondary_intents TEXT,
+    complaint_type TEXT,
+    actions TEXT,
+
+    taxonomy_fit TEXT,
+    taxonomy_candidate TEXT,
+
+    classification_json TEXT
+);
+
+CREATE INDEX idx_cases_fingerprint
+    ON cases(fingerprint);
+
+CREATE INDEX idx_cases_status
+    ON cases(classification_status);
+
+CREATE INDEX idx_cases_group
+    ON cases(group_name);
+
+CREATE INDEX idx_cases_created
+    ON cases(created_at);
+""")
+
+tickets = src.execute("""
+    SELECT *
+    FROM tickets
+    ORDER BY id
+""").fetchall()
+
+print("=" * 72)
+print("ZAMMAD → CASE NORMALIZER")
+print("=" * 72)
+print(f"Tickets: {len(tickets)}")
+print()
+
+stats = Counter()
+fingerprints = Counter()
+
+for pos, ticket in enumerate(tickets, 1):
+
+    articles = src.execute("""
+        SELECT *
+        FROM articles
+        WHERE ticket_id = ?
+        ORDER BY created_at, id
+    """, (ticket["id"],)).fetchall()
+
+    customer_parts = []
+    all_parts = []
+
+    for article in articles:
+
+        body = clean(
+            article["body_text"] or ""
+        )
+
+        if not body:
+            continue
+
+        # Interne Notizen nicht in das Kundenanliegen übernehmen.
+        internal = bool(article["internal"])
+
+        if internal:
+            stats["internal_articles"] += 1
+            continue
+
+        sender = article["sender"] or ""
+
+        part = (
+            f"[{sender}] {body}"
+        )
+
+        customer_parts.append(part)
+        all_parts.append(part)
+
+    conversation = "\n\n".join(
+        customer_parts
+    ).strip()
+
+    if not conversation:
+        stats["empty_cases"] += 1
+        continue
+
+    subject = clean(
+        ticket["title"] or ""
+    )
+
+    combined = (
+        subject + "\n" + conversation
+    ).strip()
+
+    fp = fingerprint(combined)
+    fingerprints[fp] += 1
+
+    try:
+        tags = json.loads(
+            ticket["tags_json"] or "[]"
+        )
+    except Exception:
+        tags = []
+
+    out.execute("""
+        INSERT INTO cases (
+            ticket_id,
+            ticket_number,
+            title,
+            group_name,
+            state,
+            created_at,
+            updated_at,
+            customer_id,
+            tags_json,
+            article_count,
+            customer_article_count,
+            subject_clean,
+            conversation,
+            fingerprint,
+            content_length
+        )
+        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+    """, (
+        ticket["id"],
+        ticket["number"],
+        ticket["title"],
+        ticket["group_name"],
+        ticket["state"],
+        ticket["created_at"],
+        ticket["updated_at"],
+        ticket["customer_id"],
+        json.dumps(
+            tags,
+            ensure_ascii=False,
+        ),
+        len(articles),
+        len(customer_parts),
+        subject,
+        conversation,
+        fp,
+        len(conversation),
+    ))
+
+    stats["cases"] += 1
+
+    if len(customer_parts) > 1:
+        stats["multi_article_cases"] += 1
+
+    if pos % 250 == 0:
+        out.commit()
+        print(
+            f"[{pos}/{len(tickets)}] "
+            f"Cases: {stats['cases']}"
+        )
+
+out.commit()
+
+duplicate_groups = sum(
+    1
+    for count in fingerprints.values()
+    if count > 1
+)
+
+duplicate_cases = sum(
+    count - 1
+    for count in fingerprints.values()
+    if count > 1
+)
+
+print()
+print("=" * 72)
+print("NORMALISIERUNG FERTIG")
+print("=" * 72)
+
+for key, value in stats.most_common():
+    print(
+        f"{key:28} {value}"
+    )
+
+print()
+print(
+    f"Eindeutige Fingerprints: "
+    f"{len(fingerprints)}"
+)
+print(
+    f"Fingerprint-Gruppen >1: "
+    f"{duplicate_groups}"
+)
+print(
+    f"potentielle Duplikate: "
+    f"{duplicate_cases}"
+)
+
+print()
+print("Top 20 identische/ähnliche Fingerprints:")
+
+for fp, count in sorted(
+    fingerprints.items(),
+    key=lambda x: x[1],
+    reverse=True,
+)[:20]:
+
+    if count < 2:
+        break
+
+    row = out.execute("""
+        SELECT ticket_number, title
+        FROM cases
+        WHERE fingerprint = ?
+        LIMIT 3
+    """, (fp,)).fetchall()
+
+    print(
+        f"\n{count} Fälle"
+    )
+
+    for r in row:
+        print(
+            f"  #{r[0]} {r[1]}"
+        )
+
+print()
+print(f"Analyse-DB: {OUT}")
+
+src.close()
+out.close()

+ 123 - 0
tools/process_all_recordings.py

@@ -0,0 +1,123 @@
+#!/usr/bin/env python3
+
+import asyncio
+import json
+import sqlite3
+import subprocess
+import sys
+from pathlib import Path
+
+DB = Path("data/telephony.sqlite3")
+WORKER = Path("tools/process_cdr_call.py")
+
+
+def get_jobs():
+    con = sqlite3.connect(DB)
+    con.row_factory = sqlite3.Row
+
+    rows = con.execute("""
+        SELECT
+            c.id,
+            c.start_time,
+            c.source_caller_id,
+            c.direction,
+            COALESCE(c.src_rec_id, c.dst_rec_id) AS rec_id,
+            t.id AS transcript_id,
+            a.id AS analysis_id
+        FROM cdr_calls c
+        LEFT JOIN transcripts t
+            ON t.cdr_row_id = c.id
+        LEFT JOIN analyses a
+            ON a.transcript_id = t.id
+        WHERE c.src_rec_id IS NOT NULL
+           OR c.dst_rec_id IS NOT NULL
+        ORDER BY c.start_time ASC
+    """).fetchall()
+
+    con.close()
+    return rows
+
+
+async def main():
+    rows = get_jobs()
+
+    total = len(rows)
+    done = 0
+    skipped = 0
+    failed = 0
+
+    print(f"Recordings insgesamt: {total}")
+    print()
+
+    for n, row in enumerate(rows, 1):
+        rec_id = row["rec_id"]
+
+        if row["transcript_id"] and row["analysis_id"]:
+            skipped += 1
+            print(
+                f"[{n}/{total}] SKIP "
+                f"recId={rec_id} "
+                f"analysis={row['analysis_id']}"
+            )
+            continue
+
+        print()
+        print("=" * 60)
+        print(
+            f"[{n}/{total}] VERARBEITE "
+            f"recId={rec_id} | "
+            f"{row['start_time']} | "
+            f"{row['source_caller_id']}"
+        )
+        print("=" * 60)
+
+        try:
+            process = await asyncio.create_subprocess_exec(
+                sys.executable,
+                str(WORKER),
+                str(row["id"]),
+                stdout=asyncio.subprocess.PIPE,
+                stderr=asyncio.subprocess.STDOUT,
+            )
+
+            while True:
+                line = await process.stdout.readline()
+
+                if not line:
+                    break
+
+                print(
+                    line.decode(
+                        "utf-8",
+                        errors="replace"
+                    ).rstrip()
+                )
+
+            rc = await process.wait()
+
+            if rc == 0:
+                done += 1
+            else:
+                failed += 1
+                print(
+                    f"FEHLER: Worker beendet mit Exit-Code {rc}"
+                )
+
+        except Exception as exc:
+            failed += 1
+            print(
+                f"FEHLER bei recId={rec_id}: {exc}"
+            )
+
+    print()
+    print("=" * 60)
+    print("VERARBEITUNG ABGESCHLOSSEN")
+    print("=" * 60)
+    print(f"Gesamt:       {total}")
+    print(f"Neu verarbeitet: {done}")
+    print(f"Übersprungen: {skipped}")
+    print(f"Fehler:       {failed}")
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 562 - 0
tools/process_cdr_call.py

@@ -0,0 +1,562 @@
+#!/usr/bin/env python3
+
+import asyncio
+import json
+import sqlite3
+import sys
+import tempfile
+import time
+import urllib.request
+from pathlib import Path
+
+from app.config import Settings
+from app.threecx import ThreeCXClient
+from app.whisper import WhisperWorker
+from app.audio_processor import AudioProcessor
+
+DB = Path("data/telephony.sqlite3")
+OLLAMA = "http://127.0.0.1:11434/api/generate"
+MODEL = "qwen3:8b"
+
+
+def schema():
+    return {
+        "type": "object",
+        "properties": {
+            "customer": {
+                "type": "object",
+                "properties": {
+                    "name": {"type": ["string", "null"]},
+                    "email": {"type": ["string", "null"]},
+                    "phone": {"type": ["string", "null"]},
+                    "address": {"type": ["string", "null"]},
+                    "customer_number": {"type": ["string", "null"]},
+                    "order_number": {"type": ["string", "null"]}
+                },
+                "required": [
+                    "name", "email", "phone", "address",
+                    "customer_number", "order_number"
+                ]
+            },
+            "products": {
+                "type": "array",
+                "items": {
+                    "type": "object",
+                    "properties": {
+                        "raw_text": {"type": "string"},
+                        "normalized": {"type": ["string", "null"]},
+                        "quantity": {"type": ["number", "null"]},
+                        "uncertain": {"type": "boolean"}
+                    },
+                    "required": [
+                        "raw_text", "normalized",
+                        "quantity", "uncertain"
+                    ]
+                }
+            },
+            "vouchers": {
+                "type": "array",
+                "items": {
+                    "type": "object",
+                    "properties": {
+                        "raw_text": {"type": "string"},
+                        "amount": {"type": ["number", "null"]},
+                        "code": {"type": ["string", "null"]},
+                        "uncertain": {"type": "boolean"}
+                    },
+                    "required": [
+                        "raw_text", "amount",
+                        "code", "uncertain"
+                    ]
+                }
+            },
+            "analysis": {
+                "type": "object",
+                "properties": {
+                    "intent": {"type": ["string", "null"]},
+                    "sentiment": {
+                        "type": ["string", "null"],
+                        "enum": [
+                            "positive", "neutral",
+                            "negative", "mixed", None
+                        ]
+                    },
+                    "summary": {"type": ["string", "null"]},
+                    "advice": {
+                        "type": "array",
+                        "items": {"type": "string"}
+                    },
+                    "follow_up": {
+                        "type": "array",
+                        "items": {"type": "string"}
+                    }
+                },
+                "required": [
+                    "intent", "sentiment",
+                    "summary", "advice", "follow_up"
+                ]
+            },
+            "uncertainties": {
+                "type": "array",
+                "items": {"type": "string"}
+            }
+        },
+        "required": [
+            "customer", "products", "vouchers",
+            "analysis", "uncertainties"
+        ]
+    }
+
+
+def text_from_transcript(data):
+    texts = []
+
+    for item in data.get("transcripts", []):
+        for segment in item.get("segments", []):
+            text = segment.get("text", "").strip()
+            if text:
+                texts.append(text)
+
+    return "\n".join(texts)
+
+
+async def qwen_analyze(text):
+    prompt = f"""
+Analysiere dieses deutschsprachige Kundentelefonat.
+
+Extrahiere ausdrücklich personenbezogene Daten, sofern sie genannt werden:
+Name, E-Mail, Telefonnummer, Adresse, Kundennummer, Bestellnummer
+und Gutscheincode.
+
+Diese Daten dienen der späteren Kunden- und Auftragszuordnung.
+
+Verwende ausschließlich Informationen aus dem Transkript.
+Erfinde niemals Daten.
+
+Whisper kann Wörter falsch erkennen.
+Korrigiere nur eindeutig erkennbare Fehler.
+
+Bei unsicheren Produktnamen, Nummern, Codes oder Namen:
+raw_text = tatsächlich erkannter Wortlaut
+normalized = null
+uncertain = true
+
+Keine Sprecherzuordnung erfinden.
+Keine neuen Zeitstempel erzeugen.
+
+TRANSKRIPT:
+
+{text}
+"""
+
+    payload = {
+        "model": MODEL,
+        "prompt": prompt,
+        "stream": False,
+        "format": schema(),
+        "think": False,
+        "options": {"temperature": 0}
+    }
+
+    request = urllib.request.Request(
+        OLLAMA,
+        data=json.dumps(payload).encode(),
+        headers={"Content-Type": "application/json"},
+        method="POST"
+    )
+
+    with urllib.request.urlopen(request, timeout=300) as response:
+        result = json.load(response)
+
+    return json.loads(result["response"])
+
+
+async def main():
+    cdr_row_id = int(sys.argv[1]) if len(sys.argv) > 1 else None
+
+    con = sqlite3.connect(DB)
+    con.row_factory = sqlite3.Row
+
+    if cdr_row_id:
+        row = con.execute("""
+            SELECT *
+            FROM cdr_calls
+            WHERE id = ?
+        """, (cdr_row_id,)).fetchone()
+    else:
+        row = con.execute("""
+            SELECT c.*
+            FROM cdr_calls c
+            WHERE (c.src_rec_id IS NOT NULL OR c.dst_rec_id IS NOT NULL)
+              AND NOT EXISTS (
+                  SELECT 1
+                  FROM analyses a
+                  WHERE a.cdr_row_id = c.id
+              )
+            ORDER BY c.start_time ASC
+            LIMIT 1
+        """).fetchone()
+
+    if not row:
+        con.close()
+        print("Kein unverarbeiteter CDR mit Recording.")
+        return
+
+    rec_id = row["src_rec_id"] or row["dst_rec_id"]
+
+    print("=== CDR CALL ===")
+    print("cdr_calls.id:", row["id"])
+    print("Start:", row["start_time"])
+    print("Richtung:", row["direction"])
+    print("recId:", rec_id)
+
+    # Bereits vorhandenes Transcript suchen.
+    transcript_row = con.execute("""
+        SELECT *
+        FROM transcripts
+        WHERE cdr_row_id = ?
+           OR rec_id = ?
+        ORDER BY id DESC
+        LIMIT 1
+    """, (row["id"], rec_id)).fetchone()
+
+    # Bereits vollständig verarbeitet.
+    if transcript_row:
+        analysis_row = con.execute("""
+            SELECT id
+            FROM analyses
+            WHERE transcript_id = ?
+               OR cdr_row_id = ?
+            ORDER BY id DESC
+            LIMIT 1
+        """, (transcript_row["id"], row["id"])).fetchone()
+
+        if analysis_row:
+            print("Bereits vollständig verarbeitet.")
+            con.close()
+            return
+
+        print(
+            f"Vorhandenes Transcript {transcript_row['id']} "
+            "wird wiederverwendet – kein Whisper."
+        )
+
+        transcript = json.loads(
+            transcript_row["transcript_json"]
+        )
+        transcript_id = transcript_row["id"]
+
+    else:
+        settings = Settings()
+        client = ThreeCXClient(settings)
+        audio = AudioProcessor()
+        whisper = WhisperWorker(settings)
+
+        print("Recording laden ...")
+
+        content, content_type = await client.download_recording(rec_id)
+
+        suffix = ".mp3" if "mpeg" in (content_type or "").lower() else ".wav"
+
+        with tempfile.TemporaryDirectory(prefix="3cx-cdr-") as tmp:
+            source = Path(tmp) / f"recording{suffix}"
+            source.write_bytes(content)
+
+            info = await audio.inspect(source)
+
+            duration_text = (
+                f"{info.duration:.1f}s"
+                if info.duration is not None
+                else "unbekannt"
+            )
+
+            channels_text = (
+                str(info.channels)
+                if info.channels is not None
+                else "?"
+            )
+
+            sample_rate_text = (
+                f"{info.sample_rate} Hz"
+                if info.sample_rate is not None
+                else "? Hz"
+            )
+
+            print(
+                f"Audio: {duration_text} | "
+                f"{channels_text} Kanal/Kanäle | "
+                f"{sample_rate_text}"
+            )
+
+            paths = await audio.prepare_for_transcription(source)
+            transcripts = []
+
+            for index, path in enumerate(paths):
+                print(f"Whisper {index + 1}/{len(paths)} ...")
+                start = time.monotonic()
+
+                result = await asyncio.to_thread(
+                    whisper.transcribe,
+                    path
+                )
+
+                print(f"  {time.monotonic() - start:.1f}s")
+
+                segments = (
+                    result.get("segments", [])
+                    if isinstance(result, dict)
+                    else result
+                )
+
+                transcripts.append({
+                    "channel": index if info.channels > 1 else None,
+                    "segments": segments
+                })
+
+        transcript = {
+            "cdr_id": row["cdr_id"],
+            "cdr_row_id": row["id"],
+            "rec_id": rec_id,
+            "audio": {
+                "codec": info.codec,
+                "channels": info.channels,
+                "sample_rate": info.sample_rate,
+                "duration": info.duration
+            },
+            "transcripts": transcripts
+        }
+
+        cur = con.execute("""
+            INSERT INTO transcripts (
+                call_id,
+                cdr_row_id,
+                rec_id,
+                model,
+                language,
+                audio_codec,
+                audio_channels,
+                audio_sample_rate,
+                audio_duration,
+                transcript_json,
+                status
+            )
+            VALUES (
+                NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'completed'
+            )
+        """, (
+            row["id"],
+            rec_id,
+            settings.whisper_model,
+            "de",
+            info.codec,
+            info.channels,
+            info.sample_rate,
+            info.duration,
+            json.dumps(transcript, ensure_ascii=False)
+        ))
+
+        transcript_id = cur.lastrowid
+        con.commit()
+
+        print("Transcript gespeichert:", transcript_id)
+
+    text = text_from_transcript(transcript)
+
+    print("Qwen3:8b ...")
+    start = time.monotonic()
+
+    analysis = await qwen_analyze(text)
+
+    print(f"Qwen: {time.monotonic() - start:.1f}s")
+
+    # ------------------------------------------------------------
+    # DETERMINISTIC RESOLUTION
+    #
+    # CDR = harte Quelle für Telefonnummer.
+    # Qwen = Extraktion aus dem Gespräch.
+    # Kontor MCP = fachliche Validierung / Auflösung.
+    # ------------------------------------------------------------
+    import os
+
+    from app.kontor_mcp import KontorMCPClient
+    from app.kontor_resolver import (
+        resolve_customer_number,
+        resolve_order_number,
+        resolve_product_with_context,
+        unwrap_tool_result,
+    )
+
+    analysis.setdefault("resolved", {
+        "customer": None,
+        "customer_number": None,
+        "order": None,
+        "products": [],
+    })
+
+    mcp_url = os.getenv("KONTOR_MCP_URL")
+    mcp_token = os.getenv("KONTOR_MCP_TOKEN")
+
+    if mcp_url and mcp_token:
+        try:
+            mcp = KontorMCPClient(
+                url=mcp_url,
+                token=mcp_token,
+                timeout=float(
+                    os.getenv("KONTOR_MCP_TIMEOUT", "10")
+                ),
+            )
+
+            await mcp.initialize()
+
+            # CDR-Telefonnummer niemals durch Qwen ersetzen.
+            cdr_phone = row["source_caller_id"]
+
+            if cdr_phone:
+                raw_customer = (
+                    await mcp.find_customer_by_phone(
+                        cdr_phone
+                    )
+                )
+
+                customer = unwrap_tool_result(
+                    raw_customer
+                )
+
+                if customer:
+                    analysis["resolved"]["customer"] = customer
+
+            # Qwen-Kundennummer validieren.
+            customer_number = (
+                analysis
+                .get("customer", {})
+                .get("customer_number")
+            )
+
+            if customer_number:
+                resolution = (
+                    await resolve_customer_number(
+                        mcp,
+                        str(customer_number),
+                    )
+                )
+
+                analysis["resolved"]["customer_number"] = {
+                    "status": resolution.status,
+                    "value": resolution.value,
+                    "confidence": resolution.confidence,
+                    "data": resolution.data,
+                }
+
+            # Qwen-Bestellnummer gegen Kontor validieren.
+            order_number = (
+                analysis
+                .get("customer", {})
+                .get("order_number")
+            )
+
+            if order_number:
+                resolution = (
+                    await resolve_order_number(
+                        mcp,
+                        str(order_number),
+                    )
+                )
+
+                analysis["resolved"]["order"] = {
+                    "status": resolution.status,
+                    "value": resolution.value,
+                    "confidence": resolution.confidence,
+                    "data": resolution.data,
+                }
+
+            # Produkte mit Bestellkontext auflösen.
+            order_context = None
+
+            resolved_order = (
+                analysis["resolved"].get("order")
+            )
+
+            if isinstance(resolved_order, dict):
+                order_context = resolved_order.get("data")
+
+            for product in analysis.get("products", []):
+                if not isinstance(product, dict):
+                    continue
+
+                raw_text = (
+                    product.get("raw_text")
+                    or product.get("name")
+                )
+
+                if not raw_text:
+                    continue
+
+                resolution = (
+                    await resolve_product_with_context(
+                        mcp,
+                        str(raw_text),
+                        order_context,
+                    )
+                )
+
+                analysis["resolved"]["products"].append({
+                    "raw_text": raw_text,
+                    "status": resolution.status,
+                    "value": resolution.value,
+                    "confidence": resolution.confidence,
+                    "source": resolution.source,
+                    "candidates": resolution.candidates,
+                    "data": resolution.data,
+                })
+
+        except Exception as exc:
+            print(
+                "WARNUNG: Kontor-MCP-Auflösung fehlgeschlagen:",
+                repr(exc),
+            )
+
+            analysis.setdefault(
+                "uncertainties",
+                [],
+            ).append(
+                "Kontor-MCP-Auflösung nicht verfügbar"
+            )
+
+    else:
+        print(
+            "WARNUNG: Kontor MCP nicht konfiguriert "
+            "(KONTOR_MCP_URL/TOKEN fehlen)."
+        )
+
+    con.execute("""
+        INSERT INTO analyses (
+            call_id,
+            cdr_row_id,
+            transcript_id,
+            model,
+            schema_version,
+            analysis_json,
+            status
+        )
+        VALUES (
+            NULL, ?, ?, ?, '1.0', ?, 'completed'
+        )
+    """, (
+        row["id"],
+        transcript_id,
+        MODEL,
+        json.dumps(analysis, ensure_ascii=False)
+    ))
+
+    con.commit()
+    con.close()
+
+    print("Analyse gespeichert.")
+    print("cdr_calls.id:", row["id"])
+    print("recId:", rec_id)
+    print("transcript:", transcript_id)
+    print(json.dumps(analysis, ensure_ascii=False, indent=2))
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 497 - 0
tools/process_one_call.py

@@ -0,0 +1,497 @@
+#!/usr/bin/env python3
+
+import asyncio
+import json
+import sqlite3
+import tempfile
+import time
+import urllib.request
+from pathlib import Path
+
+from app.config import Settings
+from app.threecx import ThreeCXClient
+from app.whisper import WhisperWorker
+from app.audio_processor import AudioProcessor
+
+DB = Path("data/telephony.sqlite3")
+
+
+def build_transcript(result):
+    if isinstance(result, dict):
+        segments = result.get("segments", [])
+    else:
+        segments = result
+
+    return segments
+
+
+async def main():
+    con = sqlite3.connect(DB)
+    con.row_factory = sqlite3.Row
+
+    row = con.execute("""
+        SELECT *
+        FROM cdr_calls
+        WHERE src_rec_id IS NOT NULL
+           OR dst_rec_id IS NOT NULL
+        ORDER BY start_time DESC
+        LIMIT 1
+    """).fetchone()
+
+    if not row:
+        raise RuntimeError("Kein CDR mit Recording gefunden.")
+
+    rec_id = row["src_rec_id"] or row["dst_rec_id"]
+
+    settings = Settings()
+    client = ThreeCXClient(settings)
+    audio = AudioProcessor()
+    whisper = WhisperWorker(settings)
+
+    print("=== CALL ===")
+    print("CDR:", row["cdr_id"])
+    print("Datum:", row["start_time"])
+    print("Richtung:", row["direction"])
+    print("Caller:", row["source_caller_id"])
+    print("Recording:", rec_id)
+
+    content, content_type = await client.download_recording(rec_id)
+
+    suffix = ".mp3" if "mpeg" in (content_type or "").lower() else ".wav"
+
+    with tempfile.TemporaryDirectory(prefix="3cx-call-") as tmp:
+        source = Path(tmp) / f"recording{suffix}"
+        source.write_bytes(content)
+
+        info = await audio.inspect(source)
+        paths = await audio.prepare_for_transcription(source)
+
+        transcripts = []
+
+        for index, path in enumerate(paths):
+            print(
+                f"Whisper {index + 1}/{len(paths)} "
+                f"({info.duration:.1f}s Audio) ..."
+            )
+
+            start = time.monotonic()
+
+            result = await asyncio.to_thread(
+                whisper.transcribe,
+                path,
+            )
+
+            print(f"Whisper: {time.monotonic() - start:.1f}s")
+
+            transcripts.append({
+                "channel": index if info.channels > 1 else None,
+                "segments": build_transcript(result),
+            })
+
+    transcript = {
+        "cdr_id": row["cdr_id"],
+        "rec_id": rec_id,
+        "audio": {
+            "codec": info.codec,
+            "channels": info.channels,
+            "sample_rate": info.sample_rate,
+            "duration": info.duration,
+        },
+        "transcripts": transcripts,
+    }
+
+    # Bei der Speicherung verwenden wir das vorhandene cdr_calls-Objekt
+    # als Referenz. Für call_id gibt es in der aktuellen Struktur keine
+    # erzwungene 1:1-Verknüpfung; deshalb speichern wir die CDR-ID im JSON.
+    con.execute("""
+        INSERT INTO transcripts (
+            call_id,
+            rec_id,
+            model,
+            language,
+            audio_codec,
+            audio_channels,
+            audio_sample_rate,
+            audio_duration,
+            transcript_json,
+            status
+        )
+        SELECT
+            0,
+            ?,
+            ?,
+            ?,
+            ?,
+            ?,
+            ?,
+            ?,
+            ?,
+            'completed'
+        WHERE NOT EXISTS (
+            SELECT 1
+            FROM transcripts
+            WHERE rec_id = ?
+        )
+    """, (
+        rec_id,
+        settings.whisper_model,
+        "de",
+        info.codec,
+        info.channels,
+        info.sample_rate,
+        info.duration,
+        json.dumps(transcript, ensure_ascii=False),
+        rec_id,
+    ))
+
+    con.commit()
+
+    transcript_id = con.execute("""
+        SELECT id
+        FROM transcripts
+        WHERE rec_id = ?
+        ORDER BY id DESC
+        LIMIT 1
+    """, (rec_id,)).fetchone()["id"]
+
+    con.close()
+
+    # Für die Qwen-Analyse bauen wir nur den Text aus den Whisper-Segmenten.
+    text_parts = []
+
+    for item in transcripts:
+        for segment in item["segments"]:
+            if isinstance(segment, dict):
+                text = segment.get("text", "").strip()
+                if text:
+                    text_parts.append(text)
+
+    transcript_text = "\n".join(text_parts)
+
+    print()
+    print("=== QWEN3:8B ===")
+    print("Transkript:", len(transcript_text), "Zeichen")
+
+    schema = {
+        "type": "object",
+        "properties": {
+            "customer": {
+                "type": "object",
+                "properties": {
+                    "name": {"type": ["string", "null"]},
+                    "email": {"type": ["string", "null"]},
+                    "phone": {"type": ["string", "null"]},
+                    "address": {"type": ["string", "null"]},
+                    "customer_number": {"type": ["string", "null"]},
+                    "order_number": {"type": ["string", "null"]}
+                },
+                "required": [
+                    "name", "email", "phone", "address",
+                    "customer_number", "order_number"
+                ]
+            },
+            "products": {
+                "type": "array",
+                "items": {
+                    "type": "object",
+                    "properties": {
+                        "raw_text": {"type": "string"},
+                        "normalized": {"type": ["string", "null"]},
+                        "quantity": {"type": ["number", "null"]},
+                        "uncertain": {"type": "boolean"}
+                    },
+                    "required": [
+                        "raw_text", "normalized",
+                        "quantity", "uncertain"
+                    ]
+                }
+            },
+            "analysis": {
+                "type": "object",
+                "properties": {
+                    "intent": {"type": ["string", "null"]},
+                    "sentiment": {
+                        "type": ["string", "null"],
+                        "enum": [
+                            "positive", "neutral",
+                            "negative", "mixed", None
+                        ]
+                    },
+                    "summary": {"type": ["string", "null"]},
+                    "advice": {
+                        "type": "array",
+                        "items": {"type": "string"}
+                    },
+                    "follow_up": {
+                        "type": "array",
+                        "items": {"type": "string"}
+                    }
+                },
+                "required": [
+                    "intent", "sentiment", "summary",
+                    "advice", "follow_up"
+                ]
+            },
+            "uncertainties": {
+                "type": "array",
+                "items": {"type": "string"}
+            }
+        },
+        "required": [
+            "customer",
+            "products",
+            "analysis",
+            "uncertainties"
+        ]
+    }
+
+    prompt = f"""
+Analysiere dieses deutschsprachige Kundentelefonat.
+
+Extrahiere personenbezogene Daten, sofern sie im Gespräch genannt werden:
+Name, E-Mail, Telefonnummer, Adresse, Kundennummer und Bestellnummer.
+
+Verwende ausschließlich Informationen aus dem Transkript.
+Erfinde niemals Daten.
+
+Whisper kann einzelne Wörter falsch erkennen.
+Korrigiere einen Fehler nur, wenn der Kontext die Korrektur eindeutig macht.
+Bei unsicheren Produktnamen, Nummern oder Codes:
+raw_text = tatsächlich erkannter Wortlaut,
+normalized = null,
+uncertain = true.
+
+Keine Sprecherzuordnung erzeugen.
+Keine neuen Zeitstempel erzeugen.
+
+TRANSKRIPT:
+
+{transcript_text}
+"""
+
+    payload = {
+        "model": "qwen3:8b",
+        "prompt": prompt,
+        "stream": False,
+        "format": schema,
+        "think": False,
+        "options": {
+            "temperature": 0
+        }
+    }
+
+    request = urllib.request.Request(
+        "http://127.0.0.1:11434/api/generate",
+        data=json.dumps(payload).encode(),
+        headers={"Content-Type": "application/json"},
+        method="POST",
+    )
+
+    start = time.monotonic()
+
+    with urllib.request.urlopen(request, timeout=300) as response:
+        result = json.load(response)
+
+    print(f"Qwen: {time.monotonic() - start:.1f}s")
+
+    analysis = json.loads(result["response"])
+
+    # ------------------------------------------------------------
+    # DETERMINISTIC RESOLUTION
+    #
+    # Harte CDR-Fakten kommen direkt aus `row`.
+    # Qwen darf diese Fakten nicht überschreiben.
+    # ------------------------------------------------------------
+    from app.kontor_resolver import (
+        resolve_customer_number,
+        resolve_order_number,
+        resolve_product,
+        resolve_product_with_context,
+        unwrap_tool_result,
+    )
+    from app.kontor_mcp import KontorMCPClient
+    import os
+
+    # Telefonnummer ist eine harte CDR-Information.
+    cdr_phone = (
+        row["source_caller_id"]
+        if row["source_caller_id"]
+        else None
+    )
+
+    if cdr_phone:
+        analysis.setdefault("customer", {})["phone"] = cdr_phone
+
+    analysis["resolved"] = {
+        "customer": None,
+        "customer_number": None,
+        "order": None,
+        "products": [],
+    }
+
+    mcp_url = os.getenv("KONTOR_MCP_URL")
+    mcp_token = os.getenv("KONTOR_MCP_TOKEN")
+
+    if mcp_url and mcp_token:
+        try:
+            mcp = KontorMCPClient(
+                url=mcp_url,
+                token=mcp_token,
+                timeout=float(
+                    os.getenv("KONTOR_MCP_TIMEOUT", "10")
+                ),
+            )
+
+            await mcp.initialize()
+
+            # --------------------------------------------------------
+            # KUNDE: CDR-Telefonnummer → Kontor
+            # --------------------------------------------------------
+            if cdr_phone:
+                raw_customer = await mcp.find_customer_by_phone(
+                    cdr_phone
+                )
+
+                customer_result = unwrap_tool_result(
+                    raw_customer
+                )
+
+                if customer_result:
+                    analysis["resolved"]["customer"] = (
+                        customer_result
+                    )
+
+            # --------------------------------------------------------
+            # KUNDENNUMMER: Qwen → normalisieren → Kontor validieren
+            # --------------------------------------------------------
+            customer_number = (
+                analysis
+                .get("customer", {})
+                .get("customer_number")
+            )
+
+            if customer_number:
+                resolution = await resolve_customer_number(
+                    mcp,
+                    str(customer_number),
+                )
+
+                analysis["resolved"]["customer_number"] = {
+                    "status": resolution.status,
+                    "value": resolution.value,
+                    "confidence": resolution.confidence,
+                    "data": resolution.data,
+                }
+
+            # --------------------------------------------------------
+            # BESTELLNUMMER: Qwen → normalisieren → Kontor validieren
+            # --------------------------------------------------------
+            order_number = (
+                analysis
+                .get("customer", {})
+                .get("order_number")
+            )
+
+            if order_number:
+                resolution = await resolve_order_number(
+                    mcp,
+                    str(order_number),
+                )
+
+                analysis["resolved"]["order"] = {
+                    "status": resolution.status,
+                    "value": resolution.value,
+                    "confidence": resolution.confidence,
+                    "data": resolution.data,
+                }
+
+            # --------------------------------------------------------
+            # PRODUKTE: Qwen → Kontor-Suche
+            # --------------------------------------------------------
+            for product in analysis.get("products", []):
+                if not isinstance(product, dict):
+                    continue
+
+                raw_text = (
+                    product.get("raw_text")
+                    or product.get("name")
+                )
+
+                if not raw_text:
+                    continue
+
+                # Wenn bereits eine Bestellung aufgelöst wurde,
+                # diese als zusätzlichen fachlichen Kontext verwenden.
+                order_context = None
+
+                resolved_order = analysis["resolved"].get("order")
+
+                if isinstance(resolved_order, dict):
+                    order_context = resolved_order.get("data")
+
+                resolution = await resolve_product_with_context(
+                    mcp,
+                    str(raw_text),
+                    order_context,
+                )
+
+                analysis["resolved"]["products"].append({
+                    "raw_text": raw_text,
+                    "status": resolution.status,
+                    "value": resolution.value,
+                    "confidence": resolution.confidence,
+                    "source": resolution.source,
+                    "candidates": resolution.candidates,
+                    "data": resolution.data,
+                })
+
+        except Exception as exc:
+            print(
+                "WARNUNG: MCP-Auflösung fehlgeschlagen:",
+                repr(exc),
+            )
+
+            analysis.setdefault(
+                "uncertainties",
+                [],
+            ).append(
+                "Kontor-MCP-Auflösung nicht verfügbar"
+            )
+
+    con = sqlite3.connect(DB)
+
+    con.execute("""
+        INSERT INTO analyses (
+            call_id,
+            transcript_id,
+            model,
+            schema_version,
+            analysis_json,
+            status
+        )
+        VALUES (?, ?, ?, '1.0', ?, 'completed')
+    """, (
+        0,
+        transcript_id,
+        "qwen3:8b",
+        json.dumps(analysis, ensure_ascii=False),
+    ))
+
+    con.commit()
+    con.close()
+
+    print()
+    print("========================================")
+    print("CALL KOMPLETT VERARBEITET")
+    print("========================================")
+    print("transcript_id:", transcript_id)
+    print()
+    print(json.dumps(
+        analysis,
+        ensure_ascii=False,
+        indent=2,
+    ))
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 218 - 0
tools/rebuild_zammad_cases.py

@@ -0,0 +1,218 @@
+#!/usr/bin/env python3
+
+import re
+import sqlite3
+
+DB = "data/zammad_analysis.sqlite3"
+
+db = sqlite3.connect(DB)
+db.row_factory = sqlite3.Row
+
+# Felder bei Bedarf anlegen
+columns = {
+    row[1]
+    for row in db.execute("PRAGMA table_info(cases)")
+}
+
+if "analysis_status" not in columns:
+    db.execute("""
+        ALTER TABLE cases
+        ADD COLUMN analysis_status TEXT
+    """)
+
+if "analysis_reason" not in columns:
+    db.execute("""
+        ALTER TABLE cases
+        ADD COLUMN analysis_reason TEXT
+    """)
+
+db.commit()
+
+# Eindeutig automatische/spamartige Inhalte
+spam_patterns = [
+    r"\bspam\b",
+    r"\bwerbe[- ]?mail\b",
+    r"\bmarketing[- ]?mail\b",
+    r"\bunsubscribe\b",
+    r"\bnewsletter\b",
+]
+
+auto_patterns = [
+    r"wir haben ihre anfrage erhalten",
+    r"ihre anfrage wurde.{0,80}(erhalten|erstellt|angelegt)",
+    r"ticket.{0,80}(erstellt|angelegt|eröffnet)",
+    r"ticket.{0,80}(verfolgen|verfolgung)",
+    r"link.{0,80}(ticket|anfrage)",
+    r"dies ist eine automatische",
+    r"automatische nachricht",
+    r"vielen dank für ihre anfrage",
+]
+
+spam_re = [re.compile(x, re.I | re.S) for x in spam_patterns]
+auto_re = [re.compile(x, re.I | re.S) for x in auto_patterns]
+
+tickets = db.execute("""
+    SELECT ticket_id, ticket_number, title
+    FROM cases
+    ORDER BY ticket_id
+""").fetchall()
+
+stats = {}
+
+def count(status):
+    stats[status] = stats.get(status, 0) + 1
+
+
+for pos, ticket in enumerate(tickets, 1):
+
+    articles = db.execute("""
+        SELECT
+            role,
+            content_role,
+            analysis_body
+        FROM articles
+        WHERE ticket_id = ?
+        ORDER BY id
+    """, (ticket["ticket_id"],)).fetchall()
+
+    customer = []
+    agent = []
+
+    for article in articles:
+
+        text = (article["analysis_body"] or "").strip()
+
+        if not text:
+            continue
+
+        if article["role"] == "CUSTOMER":
+            customer.append(text)
+
+        elif article["role"] == "AGENT":
+            agent.append(text)
+
+        elif article["content_role"] == "AGENT_RESPONSE":
+            agent.append(text)
+
+        # AUTOMATION / INTERNAL / sonstige UNKNOWN:
+        # nicht in die Analyse übernehmen.
+
+    customer_text = "\n\n".join(customer).strip()
+    agent_text = "\n\n".join(agent).strip()
+
+    # --------------------------------------------------------
+    # Status bestimmen
+    # --------------------------------------------------------
+
+    if not customer_text:
+        status = "AUTOMATION_ONLY"
+        reason = "Kein verwertbarer Kundenbeitrag"
+
+    elif (
+        len(customer_text) < 30
+        and not agent_text
+    ):
+        status = "NON_ANALYZABLE"
+        reason = "Kundenbeitrag zu kurz"
+
+    elif (
+        any(p.search(customer_text) for p in auto_re)
+        and len(customer_text) < 250
+    ):
+        status = "AUTOMATION_ONLY"
+        reason = "Automatische Ticketnachricht"
+
+    else:
+        status = "ANALYZABLE"
+        reason = "Verwertbarer Kundenfall"
+
+    count(status)
+
+    classification_status = (
+        "NEW"
+        if status == "ANALYZABLE"
+        else "SKIP"
+    )
+
+    db.execute("""
+        UPDATE cases
+        SET
+            customer_text = ?,
+            agent_text = ?,
+            unknown_text = '',
+            analysis_status = ?,
+            analysis_reason = ?,
+            classification_status = ?
+        WHERE ticket_id = ?
+    """, (
+        customer_text,
+        agent_text,
+        status,
+        reason,
+        classification_status,
+        ticket["ticket_id"],
+    ))
+
+    if pos % 500 == 0:
+        db.commit()
+        print(
+            f"[{pos}/{len(tickets)}] "
+            f"ANALYZABLE={stats.get('ANALYZABLE', 0)} "
+            f"SKIP={pos - stats.get('ANALYZABLE', 0)}",
+            flush=True,
+        )
+
+db.commit()
+
+print()
+print("=" * 72)
+print("ZAMMAD CASE REBUILD FERTIG")
+print("=" * 72)
+
+for status, number in sorted(
+    stats.items(),
+    key=lambda x: x[1],
+    reverse=True,
+):
+    print(f"{status:24} {number:6}")
+
+print()
+
+row = db.execute("""
+    SELECT COUNT(*)
+    FROM cases
+    WHERE analysis_status = 'ANALYZABLE'
+""").fetchone()
+
+print(
+    f"Für Qwen vorgesehen: {row[0]}"
+)
+
+print()
+print("Kontrolle #91005 / #91006")
+print("-" * 72)
+
+for row in db.execute("""
+    SELECT
+        ticket_number,
+        title,
+        analysis_status,
+        analysis_reason,
+        customer_text
+    FROM cases
+    WHERE ticket_number IN ('91005', '91006')
+    ORDER BY ticket_number
+"""):
+    print(
+        f"#{row['ticket_number']} "
+        f"{row['analysis_status']} "
+        f"({row['analysis_reason']})"
+    )
+    print(
+        f"  {row['title'] or ''}"
+    )
+    print(
+        f"  {(row['customer_text'] or '')[:300]}"
+    )
+
+db.close()

+ 296 - 0
tools/reclassify_historical_from_analysis.py

@@ -0,0 +1,296 @@
+#!/usr/bin/env python3
+
+import asyncio
+import json
+import sqlite3
+import urllib.request
+from collections import Counter
+from pathlib import Path
+
+DB = "data/telephony.sqlite3"
+OUT = Path("data/historical_multi_intent.json")
+OLLAMA = "http://127.0.0.1:11434/api/generate"
+MODEL = "qwen3:8b"
+
+INTENTS = [
+    "VERSANDSTATUS",
+    "LIEFERVERZUG",
+    "ADRESSÄNDERUNG",
+    "REKLAMATION",
+    "TRANSPORTSCHADEN",
+    "FEHLLIEFERUNG",
+    "RECHNUNG",
+    "ZAHLUNG",
+    "WIDERRUF",
+    "RETOURE",
+    "PFLANZENBERATUNG",
+    "SORTENBERATUNG",
+    "PFLEGEFRAGE",
+    "BESTANDSANFRAGE",
+    "VORBESTELLUNG",
+    "BESTELLUNG",
+    "BESTELLÄNDERUNG",
+    "STORNIERUNG",
+    "B2B",
+    "GROSSHANDEL",
+    "SONSTIGES",
+]
+
+ACTIONS = [
+    "KEINE_AKTION",
+    "KUNDENKONTEXT_PRÜFEN",
+    "BESTELLUNG_PRÜFEN",
+    "BESTELLUNG_ÄNDERN",
+    "BESTELLUNG_STORNIEREN",
+    "BESTELLUNGEN_ZUSAMMENFÜHREN",
+    "VERSANDSTATUS_PRÜFEN",
+    "LIEFERTERMIN_PRÜFEN",
+    "ADRESSE_ÄNDERN",
+    "REKLAMATION_ERFASSEN",
+    "TRANSPORTSCHADEN_ERFASSEN",
+    "FEHLLIEFERUNG_PRÜFEN",
+    "RECHNUNG_PRÜFEN",
+    "RECHNUNG_KORRIGIEREN",
+    "ZAHLUNG_PRÜFEN",
+    "GUTSCHRIFT_ERSTELLEN",
+    "RETOURE_ERFASSEN",
+    "PRODUKT_IDENTIFIZIEREN",
+    "BESTAND_PRÜFEN",
+    "SORTEN_ALTERNATIVE_PRÜFEN",
+    "PFLEGEHINWEIS_GEBEN",
+    "BILD_ANFORDERN",
+    "MENSCHLICHE_BEARBEITUNG_ERFORDERLICH",
+    "RÜCKRUF",
+    "E-MAIL_SENDEN",
+]
+
+def ask(prompt):
+    payload = {
+        "model": MODEL,
+        "prompt": prompt,
+        "stream": False,
+        "format": "json",
+        "options": {"temperature": 0},
+    }
+
+    req = urllib.request.Request(
+        OLLAMA,
+        data=json.dumps(payload, ensure_ascii=False).encode(),
+        headers={"Content-Type": "application/json"},
+        method="POST",
+    )
+
+    with urllib.request.urlopen(req, timeout=300) as r:
+        return json.loads(r.read().decode())["response"]
+
+def parse(text):
+    text = text.strip()
+    if text.startswith("```"):
+        text = text.replace("```json", "", 1)
+        text = text.replace("```", "").strip()
+    return json.loads(text)
+
+async def main():
+    con = sqlite3.connect(DB)
+    con.row_factory = sqlite3.Row
+
+    rows = con.execute("""
+        SELECT id AS analysis_id, analysis_json
+        FROM analyses
+        WHERE cdr_row_id IS NOT NULL
+        ORDER BY id
+    """).fetchall()
+
+    con.close()
+
+    results = []
+
+    for n, row in enumerate(rows, 1):
+        old = json.loads(row["analysis_json"] or "{}")
+        a = old.get("analysis") or {}
+        customer = old.get("customer") or {}
+        products = old.get("products") or []
+
+        print(f"[{n}/{len(rows)}] analysis={row['analysis_id']}")
+
+        evidence = {
+            "old_intent": a.get("intent"),
+            "summary": a.get("summary"),
+            "advice": a.get("advice"),
+            "follow_up": a.get("follow_up"),
+            "customer_number": customer.get("customer_number"),
+            "order_number": customer.get("order_number"),
+            "products": products,
+            "uncertainties": old.get("uncertainties"),
+        }
+
+        prompt = f"""
+Du bist der Intent- und Aktionsklassifikator einer Telefon-KI.
+
+Analysiere die folgende bereits erstellte Gesprächsanalyse.
+
+WICHTIG:
+Ein Gespräch kann mehrere Anliegen enthalten.
+Verwende deshalb primary_intent UND secondary_intents.
+
+Die vorhandene Analyse wurde bereits aus dem echten Telefonat
+erzeugt. Nutze sie als Tatsachengrundlage. Erfinde nichts.
+
+INTENTS:
+{json.dumps(INTENTS, ensure_ascii=False)}
+
+AKTIONEN:
+{json.dumps(ACTIONS, ensure_ascii=False)}
+
+KLASSIFIKATIONSREGELN:
+
+- Eine konkrete Bestellung bearbeiten → BESTELLUNG
+- Eine bestehende Bestellung ergänzen/verändern → BESTELLÄNDERUNG
+- Bestellung aufheben → STORNIERUNG
+- Verfügbarkeit → BESTANDSANFRAGE
+- Lieferung verfolgen → VERSANDSTATUS
+- verspätete Lieferung → LIEFERVERZUG
+- Rechnung/Gutschrift/Rechnungsfehler → RECHNUNG
+- Zahlung → ZAHLUNG
+- beschädigte Lieferung → TRANSPORTSCHADEN
+- falsche Lieferung → FEHLLIEFERUNG
+- Qualitätsbeanstandung → REKLAMATION
+- Pflanzenauswahl/Standort → PFLANZENBERATUNG
+- konkrete Sortenauswahl → SORTENBERATUNG
+- Pflege/Krankheit/Schnitt/Düngung → PFLEGEFRAGE
+- Rückgabe → RETOURE
+- Rücktritt vom Kauf → WIDERRUF
+
+MEHRERE THEMEN:
+Wenn z.B. eine Bestellung ergänzt UND gleichzeitig der
+Versandstatus einer anderen Bestellung besprochen wird:
+
+primary_intent = BESTELLÄNDERUNG
+secondary_intents = [BESTELLUNG, VERSANDSTATUS]
+
+Wenn eine Reklamation wegen beschädigter Pflanzen besprochen wird:
+
+primary_intent = REKLAMATION
+secondary_intents = [TRANSPORTSCHADEN]
+
+Wenn eine Rechnung korrigiert und eine Gutschrift erstellt werden soll:
+
+primary_intent = RECHNUNG
+actions = [RECHNUNG_PRÜFEN, RECHNUNG_KORRIGIEREN, GUTSCHRIFT_ERSTELLEN]
+
+SONSTIGES:
+Nur verwenden, wenn wirklich kein Intent aus der Liste passt.
+
+Wenn die vorhandene Analyse einen eindeutigen Geschäftsfall
+beschreibt, DARF primary_intent NICHT SONSTIGES sein.
+
+EVIDENZ:
+{json.dumps(evidence, ensure_ascii=False, indent=2)}
+
+Antworte ausschließlich als JSON:
+
+{{
+  "call_state": "HUMAN_CONVERSATION",
+  "primary_intent": "INTENT",
+  "secondary_intents": [],
+  "customer_goal": "",
+  "actions": [],
+  "requires_customer_context": true,
+  "requires_order_context": false,
+  "requires_product_context": false,
+  "requires_previous_contact_context": false,
+  "requires_human_action": false,
+  "confidence": 0.0,
+  "reason": ""
+}}
+"""
+
+        try:
+            result = parse(
+                await asyncio.to_thread(ask, prompt)
+            )
+        except Exception as exc:
+            result = {
+                "call_state": "UNKNOWN",
+                "primary_intent": "SONSTIGES",
+                "secondary_intents": [],
+                "customer_goal": "",
+                "actions": [
+                    "MENSCHLICHE_BEARBEITUNG_ERFORDERLICH"
+                ],
+                "confidence": 0,
+                "reason": str(exc),
+            }
+
+        if result.get("primary_intent") not in INTENTS:
+            result["primary_intent"] = "SONSTIGES"
+
+        result["secondary_intents"] = [
+            x for x in result.get("secondary_intents", [])
+            if x in INTENTS and x != result["primary_intent"]
+        ]
+
+        result["actions"] = [
+            x for x in result.get("actions", [])
+            if x in ACTIONS
+        ]
+
+        result["_analysis_id"] = row["analysis_id"]
+
+        results.append(result)
+
+    primary = Counter(r["primary_intent"] for r in results)
+    secondary = Counter(
+        x
+        for r in results
+        for x in r["secondary_intents"]
+    )
+    actions = Counter(
+        x
+        for r in results
+        for x in r["actions"]
+    )
+    states = Counter(
+        r.get("call_state", "UNKNOWN")
+        for r in results
+    )
+
+    output = {
+        "model": MODEL,
+        "source": "existing_analysis_json",
+        "count": len(results),
+        "call_states": dict(states.most_common()),
+        "primary_intents": dict(primary.most_common()),
+        "secondary_intents": dict(secondary.most_common()),
+        "actions": dict(actions.most_common()),
+        "calls": results,
+    }
+
+    OUT.write_text(
+        json.dumps(output, ensure_ascii=False, indent=2)
+    )
+
+    print("\n" + "=" * 72)
+    print("RE-KLASSIFIKATION FERTIG")
+    print("=" * 72)
+
+    print("\nCALL STATES")
+    for k, v in states.most_common():
+        print(f"{v:3}  {k}")
+
+    print("\nPRIMARY")
+    for k, v in primary.most_common():
+        print(f"{v:3}  {k}")
+
+    print("\nSECONDARY")
+    for k, v in secondary.most_common():
+        print(f"{v:3}  {k}")
+
+    print("\nACTIONS")
+    for k, v in actions.most_common():
+        print(f"{v:3}  {k}")
+
+    print(f"\nGespeichert: {OUT}")
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 73 - 0
tools/repair_and_check_batch.py

@@ -0,0 +1,73 @@
+import sqlite3
+from pathlib import Path
+
+DB = Path("data/telephony.sqlite3")
+
+con = sqlite3.connect(DB)
+con.row_factory = sqlite3.Row
+
+# 1. Bereits vorhandene Transkripte anhand rec_id mit cdr_calls verbinden.
+con.execute("""
+    UPDATE transcripts
+    SET cdr_row_id = (
+        SELECT c.id
+        FROM cdr_calls c
+        WHERE c.src_rec_id = transcripts.rec_id
+           OR c.dst_rec_id = transcripts.rec_id
+        ORDER BY c.id
+        LIMIT 1
+    )
+    WHERE cdr_row_id IS NULL
+      AND rec_id IS NOT NULL
+""")
+
+# 2. Analysen ebenfalls mit dem CDR verbinden.
+con.execute("""
+    UPDATE analyses
+    SET cdr_row_id = (
+        SELECT t.cdr_row_id
+        FROM transcripts t
+        WHERE t.id = analyses.transcript_id
+    )
+    WHERE cdr_row_id IS NULL
+""")
+
+con.commit()
+
+print("=== STATUS ===")
+
+r = con.execute("""
+    SELECT
+        COUNT(DISTINCT c.id) AS recordings,
+        COUNT(DISTINCT t.rec_id) AS transcripts,
+        COUNT(DISTINCT a.cdr_row_id) AS analyses
+    FROM cdr_calls c
+    LEFT JOIN transcripts t ON t.cdr_row_id = c.id
+    LEFT JOIN analyses a ON a.cdr_row_id = c.id
+    WHERE c.src_rec_id IS NOT NULL
+       OR c.dst_rec_id IS NOT NULL
+""").fetchone()
+
+print(dict(r))
+
+print()
+print("=== BEREITS VERARBEITET ===")
+
+for r in con.execute("""
+    SELECT
+        c.id AS cdr_row_id,
+        COALESCE(c.src_rec_id, c.dst_rec_id) AS rec_id,
+        c.start_time,
+        t.id AS transcript_id,
+        a.id AS analysis_id
+    FROM cdr_calls c
+    LEFT JOIN transcripts t ON t.cdr_row_id = c.id
+    LEFT JOIN analyses a ON a.cdr_row_id = c.id
+    WHERE c.src_rec_id IS NOT NULL
+       OR c.dst_rec_id IS NOT NULL
+    ORDER BY c.start_time
+"""):
+    if r["transcript_id"] or r["analysis_id"]:
+        print(dict(r))
+
+con.close()

+ 286 - 0
tools/replay_historical_analysis.py

@@ -0,0 +1,286 @@
+#!/usr/bin/env python3
+
+import asyncio
+import json
+import os
+import sqlite3
+from pathlib import Path
+from collections import Counter
+
+from app.kontor_mcp import KontorMCPClient
+from app.kontor_resolver import (
+    resolve_customer_number,
+    resolve_order_number,
+    resolve_product_with_context,
+)
+
+
+DB = "data/telephony.sqlite3"
+OUTPUT = Path("data/historical_mcp_replay.json")
+
+
+def extract_analysis(row):
+    try:
+        return json.loads(row["analysis_json"])
+    except Exception:
+        return {}
+
+
+async def main():
+    con = sqlite3.connect(DB)
+    con.row_factory = sqlite3.Row
+
+    rows = con.execute("""
+        SELECT
+            a.id AS analysis_id,
+            a.cdr_row_id,
+            a.transcript_id,
+            a.analysis_json,
+            t.rec_id,
+            t.transcript_json,
+            c.source_caller_id,
+            c.destination_caller_id,
+            c.start_time
+        FROM analyses a
+        JOIN transcripts t
+          ON t.id = a.transcript_id
+        JOIN cdr_calls c
+          ON c.id = a.cdr_row_id
+        WHERE a.cdr_row_id IS NOT NULL
+        ORDER BY a.id
+    """).fetchall()
+
+    con.close()
+
+    print(f"Historische CDR-Analysen: {len(rows)}")
+
+    if not rows:
+        raise SystemExit("Keine historischen Analysen gefunden.")
+
+    client = KontorMCPClient(
+        os.environ["KONTOR_MCP_URL"],
+        os.environ["KONTOR_MCP_TOKEN"],
+        float(os.getenv("KONTOR_MCP_TIMEOUT", "10")),
+    )
+
+    await client.initialize()
+
+    results = []
+    stats = Counter()
+
+    for index, row in enumerate(rows, 1):
+        print(
+            f"\n[{index}/{len(rows)}] "
+            f"analysis={row['analysis_id']} "
+            f"cdr={row['cdr_row_id']} "
+            f"rec={row['rec_id']}"
+        )
+
+        analysis = extract_analysis(row)
+
+        result = {
+            "analysis_id": row["analysis_id"],
+            "cdr_row_id": row["cdr_row_id"],
+            "transcript_id": row["transcript_id"],
+            "rec_id": row["rec_id"],
+            "start_time": row["start_time"],
+            "caller": row["source_caller_id"],
+            "destination": row["destination_caller_id"],
+            "resolved": {
+                "customer": None,
+                "customer_number": None,
+                "order": None,
+                "products": [],
+            },
+            "errors": [],
+        }
+
+        # ---------------------------------------------------------
+        # 1. Kunde anhand der CDR-Telefonnummer
+        # ---------------------------------------------------------
+        try:
+            phone = row["source_caller_id"]
+
+            if phone:
+                raw = await client.find_customer_by_phone(phone)
+                customer = raw
+
+                result["resolved"]["customer"] = customer
+
+                if customer:
+                    stats["customer_found"] += 1
+                else:
+                    stats["customer_not_found"] += 1
+            else:
+                stats["customer_no_phone"] += 1
+
+        except Exception as exc:
+            stats["customer_error"] += 1
+            result["errors"].append(
+                f"customer: {exc!r}"
+            )
+
+        # ---------------------------------------------------------
+        # 2. Kundennummer aus bestehender Qwen-Analyse validieren
+        # ---------------------------------------------------------
+        customer_data = analysis.get("customer") or {}
+
+        customer_number = (
+            customer_data.get("customer_number")
+        )
+
+        if customer_number:
+            try:
+                resolution = await resolve_customer_number(
+                    client,
+                    str(customer_number),
+                )
+
+                result["resolved"]["customer_number"] = {
+                    "status": resolution.status,
+                    "value": resolution.value,
+                    "confidence": resolution.confidence,
+                    "source": resolution.source,
+                    "data": resolution.data,
+                }
+
+                stats[
+                    f"customer_number_{resolution.status}"
+                ] += 1
+
+            except Exception as exc:
+                stats["customer_number_error"] += 1
+                result["errors"].append(
+                    f"customer_number: {exc!r}"
+                )
+
+        # ---------------------------------------------------------
+        # 3. Bestellnummer validieren
+        # ---------------------------------------------------------
+        order_number = (
+            customer_data.get("order_number")
+            or analysis.get("order_number")
+        )
+
+        if order_number:
+            try:
+                resolution = await resolve_order_number(
+                    client,
+                    str(order_number),
+                )
+
+                result["resolved"]["order"] = {
+                    "status": resolution.status,
+                    "value": resolution.value,
+                    "confidence": resolution.confidence,
+                    "source": resolution.source,
+                    "data": resolution.data,
+                }
+
+                stats[
+                    f"order_{resolution.status}"
+                ] += 1
+
+            except Exception as exc:
+                stats["order_error"] += 1
+                result["errors"].append(
+                    f"order: {exc!r}"
+                )
+
+        # ---------------------------------------------------------
+        # 4. Produkte mit Bestellkontext auflösen
+        # ---------------------------------------------------------
+        order_context = None
+
+        if result["resolved"]["order"]:
+            order_context = (
+                result["resolved"]["order"]
+                .get("data")
+            )
+
+        products = analysis.get("products") or []
+
+        for product in products:
+            if not isinstance(product, dict):
+                continue
+
+            raw_product = (
+                product.get("raw_text")
+                or product.get("name")
+            )
+
+            if not raw_product:
+                continue
+
+            try:
+                resolution = (
+                    await resolve_product_with_context(
+                        client,
+                        str(raw_product),
+                        order_context,
+                    )
+                )
+
+                result["resolved"]["products"].append({
+                    "raw_text": raw_product,
+                    "status": resolution.status,
+                    "value": resolution.value,
+                    "confidence": resolution.confidence,
+                    "source": resolution.source,
+                    "candidates": resolution.candidates,
+                    "data": resolution.data,
+                })
+
+                stats[
+                    f"product_{resolution.status}"
+                ] += 1
+
+            except Exception as exc:
+                stats["product_error"] += 1
+                result["errors"].append(
+                    f"product {raw_product!r}: {exc!r}"
+                )
+
+        results.append(result)
+
+        print(
+            "  Kunde:",
+            "FOUND"
+            if result["resolved"]["customer"]
+            else "NOT FOUND",
+            "| Produkte:",
+            len(result["resolved"]["products"]),
+            "| Fehler:",
+            len(result["errors"]),
+        )
+
+    payload = {
+        "generated_at": __import__("datetime").datetime.now().isoformat(),
+        "database": DB,
+        "count": len(results),
+        "statistics": dict(sorted(stats.items())),
+        "results": results,
+    }
+
+    OUTPUT.write_text(
+        json.dumps(
+            payload,
+            ensure_ascii=False,
+            indent=2,
+        )
+    )
+
+    print("\n" + "=" * 70)
+    print("HISTORICAL MCP REPLAY FERTIG")
+    print("=" * 70)
+
+    print(f"Calls: {len(results)}")
+    print(f"Output: {OUTPUT}")
+    print()
+
+    for key, value in sorted(stats.items()):
+        print(f"{key:35} {value}")
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 121 - 0
tools/review_zammad_unknown.py

@@ -0,0 +1,121 @@
+#!/usr/bin/env python3
+
+import json
+import sqlite3
+import subprocess
+
+DB = "data/zammad_analysis.sqlite3"
+MODEL = "qwen3:8b"
+
+SYSTEM = """Du analysierst historische Zammad-Artikel eines Kundenservice-Systems.
+
+Entscheide ausschließlich zwischen:
+AGENT_RESPONSE = individuell formulierte fachliche Antwort eines Mitarbeiters
+AUTOMATION = automatisch erzeugte System-/Ticketnachricht
+
+Eine fachliche Antwort kann auch von Zammad als System/email geführt werden.
+
+Antworte ausschließlich als JSON:
+{
+  "role": "AGENT_RESPONSE|AUTOMATION",
+  "confidence": 0.0,
+  "reason": "kurze Begründung"
+}
+"""
+
+db = sqlite3.connect(DB)
+
+rows = db.execute("""
+    SELECT id, ticket_id, subject, clean_body
+    FROM articles
+    WHERE content_role = 'UNKNOWN_REVIEW'
+    ORDER BY id
+""").fetchall()
+
+print("=" * 72)
+print("QWEN – UNKNOWN REVIEW")
+print("=" * 72)
+print(f"Fälle: {len(rows)}")
+print(f"Modell: {MODEL}")
+print()
+
+for article_id, ticket_id, subject, body in rows:
+
+    prompt = f"""{SYSTEM}
+
+Betreff:
+{subject or ""}
+
+Artikel:
+{body or ""}
+"""
+
+    result = subprocess.run(
+        [
+            "ollama",
+            "run",
+            MODEL,
+            prompt,
+        ],
+        capture_output=True,
+        text=True,
+        timeout=300,
+    )
+
+    output = result.stdout.strip()
+
+    # JSON aus möglichem Markdown-Codeblock extrahieren
+    if "{" in output and "}" in output:
+        output = output[
+            output.find("{"):
+            output.rfind("}") + 1
+        ]
+
+    try:
+        data = json.loads(output)
+
+        role = data.get("role")
+        confidence = float(
+            data.get("confidence", 0)
+        )
+        reason = data.get("reason", "")
+
+        if role not in (
+            "AGENT_RESPONSE",
+            "AUTOMATION",
+        ):
+            raise ValueError(
+                f"Ungültige Rolle: {role}"
+            )
+
+    except Exception as exc:
+        print(
+            f"Ticket #{ticket_id}: "
+            f"JSON-Fehler: {exc}"
+        )
+        print(output[:500])
+        continue
+
+    db.execute("""
+        UPDATE articles
+        SET content_role = ?,
+            classification_score = ?,
+            classification_reason = ?
+        WHERE id = ?
+    """, (
+        role,
+        confidence,
+        "QWEN: " + reason,
+        article_id,
+    ))
+
+    db.commit()
+
+    print(
+        f"Ticket #{ticket_id}: "
+        f"{role:16} "
+        f"{confidence:.2f} "
+        f"{reason}"
+    )
+
+db.close()

+ 90 - 0
tools/screen_transcripts.py

@@ -0,0 +1,90 @@
+from pathlib import Path
+import re
+import json
+
+BASE = Path("/opt/3cx-middleware/3cx-telefonie-middleware")
+SRC = BASE / "data/transcripts"
+OUT = BASE / "data/transcript_screening.jsonl"
+
+AUTO = re.compile(
+    r"(herzlich willkommen|willkommen bei|"
+    r"sie sind verbunden mit|"
+    r"ihr gewünschter gesprächspartner ist|"
+    r"ist zurzeit nicht erreichbar|"
+    r"ist im moment nicht erreichbar|"
+    r"leider.*nicht.*erreichbar|"
+    r"bitte hinterlassen sie.*nachricht|"
+    r"hinterlassen sie.*nach.*signal|"
+    r"nach dem signalton|"
+    r"außerhalb.*geschäftszeiten|"
+    r"Vielen Dank und auf Wiederhören)",
+    re.I,
+)
+
+BUSINESS = re.compile(
+    r"(bestellung|bestellt|rechnung|zahlung|lieferung|lieferstatus|"
+    r"versand|paket|dhl|gls|retoure|widerruf|storn|reklamation|"
+    r"rose|rosen|clematis|pflanze|pflanzen|wurzel|container|"
+    r"lieferzeit|preis|angebot|gutschein|dünger|schädling|"
+    r"krank|rückruf|kunden|auftrag)",
+    re.I,
+)
+
+OUT.parent.mkdir(exist_ok=True)
+
+stats = {
+    "RELEVANT": 0,
+    "IRRELEVANT": 0,
+    "UNCERTAIN": 0,
+}
+
+with OUT.open("w", encoding="utf-8") as out:
+    for path in sorted(SRC.glob("*.txt")):
+        text = path.read_text(encoding="utf-8", errors="replace").strip()
+        words = text.split()
+
+        if not text:
+            relevance = "IRRELEVANT"
+            reason = "leer"
+
+        elif len(words) < 8:
+            relevance = "IRRELEVANT"
+            reason = "extrem kurz"
+
+        else:
+            auto_hits = len(AUTO.findall(text))
+            business_hits = len(BUSINESS.findall(text))
+
+            # reine automatische Ansage
+            if auto_hits >= 1 and business_hits == 0 and len(words) < 80:
+                relevance = "IRRELEVANT"
+                reason = "automatische Ansage/Mailbox"
+
+            # echter Geschäftskontext vorhanden
+            elif business_hits >= 1:
+                relevance = "RELEVANT"
+                reason = f"Geschäftskontext ({business_hits} Treffer)"
+
+            # längerer Text ohne klare Geschäftssignale
+            elif len(words) >= 80:
+                relevance = "UNCERTAIN"
+                reason = "langer Text ohne eindeutigen Geschäftskontext"
+
+            else:
+                relevance = "UNCERTAIN"
+                reason = "nicht eindeutig"
+
+        stats[relevance] += 1
+
+        out.write(json.dumps({
+            "file": path.name,
+            "relevance": relevance,
+            "reason": reason,
+            "words": len(words),
+        }, ensure_ascii=False) + "\n")
+
+print("=== SCREENING ===")
+for k, v in stats.items():
+    print(f"{k:10} {v}")
+
+print(f"\nDatei: {OUT}")

+ 82 - 0
tools/status.sh

@@ -0,0 +1,82 @@
+#!/bin/bash
+
+set -u
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$ROOT"
+
+echo "============================================================"
+echo "3CX TELEFONIE-MIDDLEWARE – STATUS"
+echo "============================================================"
+echo "Projekt: $ROOT"
+echo
+
+echo "=== DATEIEN ==="
+find app config tools -maxdepth 2 -type f \
+    2>/dev/null | sort
+echo
+
+echo "=== PYTHON ==="
+"$ROOT/.venv/bin/python" --version 2>/dev/null || python3 --version
+echo
+
+echo "=== WICHTIGE PYTHON-PAKETE ==="
+"$ROOT/.venv/bin/pip" list 2>/dev/null |
+    grep -Ei 'fastapi|uvicorn|httpx|whisper|torch|ffmpeg|pydub|ollama' || true
+echo
+
+echo "=== WHISPER ==="
+if [ -f app/whisper.py ]; then
+    sed -n '1,320p' app/whisper.py
+else
+    echo "app/whisper.py NICHT GEFUNDEN"
+fi
+echo
+
+echo "=== API ROUTEN ==="
+grep -RniE '@(app|router)\.(get|post|put|delete|patch)' \
+    app --exclude-dir=__pycache__ 2>/dev/null || true
+echo
+
+echo "=== 3CX / CDR / RECORDING ==="
+grep -RniE '3cx|cdr|call.?history|recording|audio' \
+    app config tools \
+    --exclude-dir=__pycache__ 2>/dev/null || true
+echo
+
+echo "=== OLLAMA ==="
+grep -RniE 'ollama|qwen3|gemma|llama|11434' \
+    app config tools \
+    --exclude-dir=__pycache__ 2>/dev/null || true
+echo
+
+echo "=== KONFIGURATION ==="
+find config -maxdepth 2 -type f -print \
+    -exec sh -c 'echo "--- $1"; sed -n "1,240p" "$1"' _ {} \; \
+    2>/dev/null
+echo
+
+echo "=== RECORDINGS / TESTDATEIEN ==="
+for d in recordings data media tmp; do
+    if [ -d "$d" ]; then
+        echo "--- $d"
+        du -sh "$d"
+        find "$d" -type f -printf '%p %s bytes\n' 2>/dev/null |
+            sort -k2 -nr | head -20
+    fi
+done
+echo
+
+echo "=== 3CX ERREICHBARKEIT AUS KONFIGURATION ==="
+grep -RniE 'https?://|host:|base.?url|api.?url' \
+    config app \
+    --exclude-dir=__pycache__ 2>/dev/null || true
+echo
+
+echo "=== LAUFENDE DIENSTE ==="
+ps aux | grep -E '[u]vicorn|[g]unicorn|3cx-telefonie|python' || true
+echo
+
+echo "============================================================"
+echo "ENDE STATUS"
+echo "============================================================"

+ 285 - 0
tools/sync_cdr.py

@@ -0,0 +1,285 @@
+#!/usr/bin/env python3
+
+import asyncio
+import json
+import sqlite3
+from datetime import datetime, timedelta
+from pathlib import Path
+
+from app.config import Settings
+from app.threecx import ThreeCXClient
+
+
+DB = Path("data/telephony.sqlite3")
+
+
+def find_cdr_rows(value):
+    """Findet rekursiv alle CDR-Objekte im API-Response."""
+    rows = []
+
+    if isinstance(value, dict):
+        if "CdrId" in value:
+            rows.append(value)
+        else:
+            for v in value.values():
+                rows.extend(find_cdr_rows(v))
+
+    elif isinstance(value, list):
+        for v in value:
+            rows.extend(find_cdr_rows(v))
+
+    return rows
+
+
+def duration_seconds(value):
+    if not value or not isinstance(value, str):
+        return None
+
+    # PT4M14.546215S / PT15.262243S
+    try:
+        value = value.removeprefix("PT")
+        minutes = 0
+        seconds = 0.0
+
+        if "M" in value:
+            m, value = value.split("M", 1)
+            minutes = int(m)
+
+        if value.endswith("S"):
+            seconds = float(value[:-1])
+
+        return int(round(minutes * 60 + seconds))
+    except Exception:
+        return None
+
+
+async def main():
+    settings = Settings()
+    client = ThreeCXClient(settings)
+
+    # Letzte 7 Tage – bei Bedarf später als CLI-Parameter.
+    today = datetime.now().date()
+    date_to = today.isoformat()
+    date_from = (today - timedelta(days=7)).isoformat()
+
+    print(f"CDR: {date_from} -> {date_to}")
+
+    result = await client.get_call_log(date_from, date_to)
+    rows = find_cdr_rows(result)
+
+    print(f"CDR-Datensätze gefunden: {len(rows)}")
+
+    con = sqlite3.connect(DB)
+    con.execute("PRAGMA foreign_keys = ON")
+
+    con.execute("""
+        CREATE TABLE IF NOT EXISTS cdr_calls (
+            id INTEGER PRIMARY KEY AUTOINCREMENT,
+
+            cdr_id TEXT NOT NULL UNIQUE,
+            main_call_history_id TEXT,
+            call_history_id TEXT,
+
+            call_id INTEGER,
+            segment_id INTEGER,
+
+            start_time TEXT,
+
+            source_dn TEXT,
+            source_caller_id TEXT,
+            source_display_name TEXT,
+
+            destination_dn TEXT,
+            destination_caller_id TEXT,
+            destination_display_name TEXT,
+
+            direction TEXT,
+            call_type TEXT,
+            status TEXT,
+
+            ringing_duration TEXT,
+            talking_duration TEXT,
+            duration_seconds INTEGER,
+
+            answered INTEGER,
+
+            recording_url TEXT,
+            src_rec_id INTEGER,
+            dst_rec_id INTEGER,
+
+            reason TEXT,
+
+            raw_json TEXT NOT NULL,
+
+            synced_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+        )
+    """)
+
+    con.execute("""
+        CREATE INDEX IF NOT EXISTS idx_cdr_calls_start_time
+        ON cdr_calls(start_time)
+    """)
+
+    con.execute("""
+        CREATE INDEX IF NOT EXISTS idx_cdr_calls_source_phone
+        ON cdr_calls(source_caller_id)
+    """)
+
+    con.execute("""
+        CREATE INDEX IF NOT EXISTS idx_cdr_calls_destination_phone
+        ON cdr_calls(destination_caller_id)
+    """)
+
+    con.execute("""
+        CREATE INDEX IF NOT EXISTS idx_cdr_calls_src_rec_id
+        ON cdr_calls(src_rec_id)
+    """)
+
+    con.execute("""
+        CREATE INDEX IF NOT EXISTS idx_cdr_calls_dst_rec_id
+        ON cdr_calls(dst_rec_id)
+    """)
+
+    sql = """
+        INSERT INTO cdr_calls (
+            cdr_id,
+            main_call_history_id,
+            call_history_id,
+            call_id,
+            segment_id,
+            start_time,
+            source_dn,
+            source_caller_id,
+            source_display_name,
+            destination_dn,
+            destination_caller_id,
+            destination_display_name,
+            direction,
+            call_type,
+            status,
+            ringing_duration,
+            talking_duration,
+            duration_seconds,
+            answered,
+            recording_url,
+            src_rec_id,
+            dst_rec_id,
+            reason,
+            raw_json,
+            synced_at
+        )
+        VALUES (
+            ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
+            ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP
+        )
+        ON CONFLICT(cdr_id) DO UPDATE SET
+            main_call_history_id=excluded.main_call_history_id,
+            call_history_id=excluded.call_history_id,
+            call_id=excluded.call_id,
+            segment_id=excluded.segment_id,
+            start_time=excluded.start_time,
+            source_dn=excluded.source_dn,
+            source_caller_id=excluded.source_caller_id,
+            source_display_name=excluded.source_display_name,
+            destination_dn=excluded.destination_dn,
+            destination_caller_id=excluded.destination_caller_id,
+            destination_display_name=excluded.destination_display_name,
+            direction=excluded.direction,
+            call_type=excluded.call_type,
+            status=excluded.status,
+            ringing_duration=excluded.ringing_duration,
+            talking_duration=excluded.talking_duration,
+            duration_seconds=excluded.duration_seconds,
+            answered=excluded.answered,
+            recording_url=excluded.recording_url,
+            src_rec_id=excluded.src_rec_id,
+            dst_rec_id=excluded.dst_rec_id,
+            reason=excluded.reason,
+            raw_json=excluded.raw_json,
+            synced_at=CURRENT_TIMESTAMP
+    """
+
+    for row in rows:
+        src_rec = row.get("SrcRecId")
+        dst_rec = row.get("DstRecId")
+
+        # Nur echte Recording-IDs speichern.
+        try:
+            src_rec = int(src_rec) if src_rec is not None else None
+        except (TypeError, ValueError):
+            src_rec = None
+
+        try:
+            dst_rec = int(dst_rec) if dst_rec is not None else None
+        except (TypeError, ValueError):
+            dst_rec = None
+
+        con.execute(sql, (
+            row.get("CdrId"),
+            row.get("MainCallHistoryId"),
+            row.get("CallHistoryId"),
+            row.get("CallId"),
+            row.get("SegmentId"),
+            row.get("StartTime"),
+            row.get("SourceDn"),
+            row.get("SourceCallerId"),
+            row.get("SourceDisplayName"),
+            row.get("DestinationDn"),
+            row.get("DestinationCallerId"),
+            row.get("DestinationDisplayName"),
+            row.get("Direction"),
+            row.get("CallType"),
+            row.get("Status"),
+            row.get("RingingDuration"),
+            row.get("TalkingDuration"),
+            duration_seconds(row.get("TalkingDuration")),
+            1 if row.get("Answered") else 0,
+            row.get("RecordingUrl"),
+            src_rec,
+            dst_rec,
+            row.get("Reason"),
+            json.dumps(row, ensure_ascii=False),
+        ))
+
+    con.commit()
+
+    count = con.execute(
+        "SELECT COUNT(*) FROM cdr_calls"
+    ).fetchone()[0]
+
+    recordings = con.execute("""
+        SELECT COUNT(*)
+        FROM cdr_calls
+        WHERE src_rec_id IS NOT NULL
+           OR dst_rec_id IS NOT NULL
+    """).fetchone()[0]
+
+    print()
+    print("=== CDR SYNCHRONISIERT ===")
+    print("Gespeicherte CDRs:", count)
+    print("Mit Recording:", recordings)
+
+    print()
+    print("=== LETZTE RECORDINGS ===")
+
+    for row in con.execute("""
+        SELECT
+            start_time,
+            source_caller_id,
+            destination_caller_id,
+            direction,
+            status,
+            src_rec_id,
+            recording_url
+        FROM cdr_calls
+        WHERE recording_url IS NOT NULL
+        ORDER BY start_time DESC
+        LIMIT 10
+    """):
+        print(row)
+
+    con.close()
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 100 - 0
tools/test_routepoint90_media.py

@@ -0,0 +1,100 @@
+#!/usr/bin/env python3
+
+import asyncio
+import json
+import sys
+
+from app.config import Settings
+from app.media import ThreeCXMediaClient
+from app.media_session import MediaSession
+from app.whisper import WhisperWorker
+
+
+async def main():
+    settings = Settings()
+    media = ThreeCXMediaClient(settings)
+
+    print("=== ROUTEPOINT 90 MEDIA TEST ===")
+    print("RoutePoint:", settings.threecx_media_routepoint_dn)
+    print()
+    print("Suche aktiven Participant ...")
+
+    participants = await media.participants()
+
+    print("Participants:", len(participants))
+
+    if not participants:
+        print()
+        print("KEIN AKTIVER PARTICIPANT.")
+        print("Jetzt DN 90 anrufen und den Anruf annehmen.")
+        return 2
+
+    print(json.dumps(
+        participants,
+        indent=2,
+        ensure_ascii=False,
+    ))
+
+    connected = [
+        p for p in participants
+        if str(p.get("status", "")).lower() == "connected"
+    ]
+
+    if not connected:
+        print()
+        print("Kein CONNECTED Participant gefunden.")
+        return 3
+
+    participant = connected[0]
+
+    print()
+    print("=== VERWENDE PARTICIPANT ===")
+    print("ID:", participant.get("id"))
+    print("Call-ID:", participant.get("callid"))
+    print("Leg-ID:", participant.get("legid"))
+    print("Status:", participant.get("status"))
+    print()
+
+    whisper = WhisperWorker(settings)
+
+    session = MediaSession(
+        settings,
+        media,
+        whisper,
+    )
+
+    print("Starte MediaSession ...")
+    print("Jetzt ca. 10 Sekunden sprechen.")
+    print()
+
+    result = await session.process_participant(
+        participant,
+        max_bytes=160000,
+    )
+
+    print()
+    print("=== MEDIA ERGEBNIS ===")
+    print("Call-ID:", result["callid"])
+    print("Leg-ID:", result["legid"])
+    print("Participant:", result["participant_id"])
+    print("Audio:", result["audio_bytes"], "Bytes")
+    print("WAV:", result["audio_file"])
+    print()
+
+    transcription = result["transcription"]
+
+    print("=== WHISPER ===")
+    print("Sprache:", transcription["language"])
+    print(
+        "Wahrscheinlichkeit:",
+        transcription["language_probability"],
+    )
+    print("Text:", transcription["text"])
+    print()
+    print("Segmente:", len(transcription["segments"]))
+
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(asyncio.run(main()))

+ 167 - 0
tools/test_zammad_ollama.py

@@ -0,0 +1,167 @@
+#!/usr/bin/env python3
+
+import json
+import sqlite3
+import time
+import urllib.request
+
+DB = "data/zammad_analysis.sqlite3"
+URL = "http://127.0.0.1:11434/api/chat"
+MODEL = "qwen3:8b"
+
+TAXONOMY = """
+VERSANDSTATUS
+LIEFERVERZUG
+ADRESSÄNDERUNG
+REKLAMATION
+TRANSPORTSCHADEN
+FEHLLIEFERUNG
+RECHNUNG
+ZAHLUNG
+WIDERRUF
+RETOURE
+PFLANZENBERATUNG
+SORTENBERATUNG
+PFLEGEFRAGE
+BESTANDSANFRAGE
+VORBESTELLUNG
+B2B
+GROSSHANDEL
+DÜNGER_FEHLT
+SONSTIGES
+"""
+
+SYSTEM = f"""
+Du analysierst Kundenservice-Tickets von Schmid Gartenpflanzen.
+
+Klassifiziere das tatsächliche Kundenanliegen.
+
+Bestehende Primärtaxonomie:
+{TAXONOMY}
+
+Wichtige Regeln:
+- PRODUKTQUALITÄT unter REKLAMATION oder PFLANZENBERATUNG.
+- SCHLECHTVERPACKT unter REKLAMATION bzw. TRANSPORTSCHADEN.
+- FALSCHESORTEGELIEFERT unter FEHLLIEFERUNG.
+- DÜNGER_FEHLT ist ein eigener Intent.
+- Mehrere Anliegen gleichzeitig müssen als Multi-Intent erfasst werden.
+- Keine neuen Primärintents erfinden.
+
+Antworte ausschließlich als JSON:
+{{
+  "primary_intent": "...",
+  "secondary_intents": [],
+  "complaint_type": null,
+  "actions": [],
+  "summary": "...",
+  "taxonomy_fit": "GOOD|PARTIAL|POOR",
+  "taxonomy_candidate": null,
+  "confidence": 0.0
+}}
+"""
+
+
+def ask(case):
+
+    prompt = f"""
+Ticket #{case['ticket_number']}
+Betreff:
+{case['title'] or ''}
+
+KUNDEN:
+{case['customer_text'] or ''}
+
+KUNDENSERVICE:
+{case['agent_text'] or ''}
+
+SONSTIGE FACHLICHE ANTWORTEN:
+{case['unknown_text'] or ''}
+"""
+
+    payload = {
+        "model": MODEL,
+        "messages": [
+            {
+                "role": "system",
+                "content": SYSTEM,
+            },
+            {
+                "role": "user",
+                "content": prompt,
+            },
+        ],
+        "stream": False,
+        "format": "json",
+        "options": {
+            "temperature": 0,
+        },
+    }
+
+    request = urllib.request.Request(
+        URL,
+        data=json.dumps(payload).encode(),
+        headers={
+            "Content-Type": "application/json",
+        },
+    )
+
+    started = time.time()
+
+    with urllib.request.urlopen(
+        request,
+        timeout=300,
+    ) as response:
+        result = json.loads(
+            response.read()
+        )
+
+    elapsed = time.time() - started
+
+    content = result["message"]["content"]
+
+    parsed = json.loads(content)
+
+    return elapsed, parsed
+
+
+db = sqlite3.connect(DB)
+db.row_factory = sqlite3.Row
+
+rows = db.execute("""
+    SELECT
+        ticket_id,
+        ticket_number,
+        title,
+        customer_text,
+        agent_text,
+        unknown_text
+    FROM cases
+    WHERE classification_status = 'NEW'
+    ORDER BY ticket_id
+    LIMIT 10
+""").fetchall()
+
+print("=" * 72)
+print("OLLAMA API TEST")
+print("=" * 72)
+print(f"Tickets: {len(rows)}")
+print(f"Modell:  {MODEL}")
+print()
+
+for row in rows:
+
+    elapsed, result = ask(row)
+
+    print(
+        f"#{row['ticket_number']} "
+        f"{elapsed:.1f}s "
+        f"→ {result.get('primary_intent')} "
+        f"secondary={result.get('secondary_intents')} "
+        f"confidence={result.get('confidence')}"
+    )
+
+    print(
+        f"  {result.get('summary', '')[:250]}"
+    )
+
+db.close()

+ 291 - 0
tools/transcribe_parallel.py

@@ -0,0 +1,291 @@
+#!/usr/bin/env python3
+
+import argparse
+import multiprocessing as mp
+import os
+import sqlite3
+import time
+from pathlib import Path
+
+DB = Path("data/recording_index.sqlite3")
+OUT = Path("data/transcripts")
+MODEL = os.environ.get("WHISPER_MODEL", "small")
+
+OUT.mkdir(parents=True, exist_ok=True)
+
+
+def worker(worker_id, jobs):
+    from faster_whisper import WhisperModel
+
+    print(
+        f"[Worker {worker_id}] "
+        f"starte Whisper {MODEL}",
+        flush=True,
+    )
+
+    model = WhisperModel(
+        MODEL,
+        device="cuda",
+        compute_type="int8",
+        num_workers=1,
+    )
+
+    con = sqlite3.connect(DB)
+
+    for job_id, audio_path, output_path in jobs:
+
+        try:
+            # Andere Worker können denselben Job nicht übernehmen,
+            # solange wir ihn atomar auf RUNNING setzen.
+            cur = con.execute("""
+                UPDATE recordings
+                SET status = 'TRANSCRIBING'
+                WHERE id = ?
+                  AND status = 'NEW'
+            """, (job_id,))
+
+            con.commit()
+
+            if cur.rowcount != 1:
+                continue
+
+            print(
+                f"[W{worker_id}] "
+                f"{audio_path}",
+                flush=True,
+            )
+
+            segments, info = model.transcribe(
+                audio_path,
+                language="de",
+                beam_size=5,
+                vad_filter=True,
+                vad_parameters={
+                    "min_silence_duration_ms": 500,
+                },
+            )
+
+            text_parts = []
+
+            for segment in segments:
+                text = segment.text.strip()
+                if text:
+                    text_parts.append(text)
+
+            text = "\n".join(text_parts).strip()
+
+            output = Path(output_path)
+            output.parent.mkdir(
+                parents=True,
+                exist_ok=True,
+            )
+
+            output.write_text(
+                text,
+                encoding="utf-8",
+            )
+
+            status = (
+                "TRANSCRIBED"
+                if text
+                else "NO_SPEECH"
+            )
+
+            con.execute("""
+                UPDATE recordings
+                SET status = ?,
+                    transcript_path = ?
+                WHERE id = ?
+            """, (
+                status,
+                str(output),
+                job_id,
+            ))
+
+            con.commit()
+
+        except Exception as exc:
+
+            print(
+                f"[W{worker_id}] FEHLER "
+                f"{audio_path}: {exc}",
+                flush=True,
+            )
+
+            con.execute("""
+                UPDATE recordings
+                SET status = 'TRANSCRIPTION_ERROR'
+                WHERE id = ?
+            """, (job_id,))
+
+            con.commit()
+
+    con.close()
+
+    print(
+        f"[Worker {worker_id}] fertig",
+        flush=True,
+    )
+
+
+def main():
+
+    parser = argparse.ArgumentParser()
+    parser.add_argument(
+        "--workers",
+        type=int,
+        default=2,
+    )
+    args = parser.parse_args()
+
+    if args.workers < 1:
+        raise SystemExit(
+            "--workers muss >= 1 sein"
+        )
+
+    con = sqlite3.connect(DB)
+    con.row_factory = sqlite3.Row
+
+    # Persönliche/interne Aufzeichnungen ausschließen.
+    con.execute("""
+        UPDATE recordings
+        SET status = 'EXCLUDED_INTERNAL'
+        WHERE extension = '42'
+          AND status IN ('NEW', 'TRANSCRIBING')
+    """)
+
+    # Sehr kurze Aufnahmen ausschließen.
+    con.execute("""
+        UPDATE recordings
+        SET status = 'TOO_SHORT'
+        WHERE duration IS NOT NULL
+          AND duration < 2
+          AND status IN ('NEW', 'TRANSCRIBING')
+    """)
+
+    con.commit()
+
+    rows = con.execute("""
+        SELECT id, path, recording_id
+        FROM recordings
+        WHERE status = 'NEW'
+        ORDER BY recorded_at, id
+    """).fetchall()
+
+    con.close()
+
+    print("=" * 72)
+    print("PARALLELER WHISPER-LAUF")
+    print("=" * 72)
+    print(f"Modell:       {MODEL}")
+    print(f"Worker:       {args.workers}")
+    print(f"Kandidaten:   {len(rows)}")
+    print("Device:       CUDA")
+    print("Compute:      int8")
+    print()
+
+    if not rows:
+        print("Keine neuen Aufnahmen.")
+        return
+
+    # Jobs deterministisch auf Worker verteilen.
+    jobs = [[] for _ in range(args.workers)]
+
+    for index, row in enumerate(rows):
+        output = (
+            OUT /
+            f"{row['id']}_{row['recording_id']}.txt"
+        )
+
+        jobs[index % args.workers].append(
+            (
+                row["id"],
+                row["path"],
+                str(output),
+            )
+        )
+
+    processes = []
+
+    started = time.time()
+
+    for worker_id, worker_jobs in enumerate(
+        jobs,
+        1,
+    ):
+        if not worker_jobs:
+            continue
+
+        process = mp.Process(
+            target=worker,
+            args=(
+                worker_id,
+                worker_jobs,
+            ),
+        )
+
+        process.start()
+        processes.append(process)
+
+    try:
+        for process in processes:
+            process.join()
+    except KeyboardInterrupt:
+        print("\nAbbruch angefordert – Worker werden beendet ...", flush=True)
+
+        for process in processes:
+            if process.is_alive():
+                process.terminate()
+
+        for process in processes:
+            process.join(timeout=10)
+
+        for process in processes:
+            if process.is_alive():
+                process.kill()
+
+        # Jobs, die beim Abbruch noch liefen, wieder freigeben.
+        cleanup = sqlite3.connect(DB)
+        cleanup.execute("""
+            UPDATE recordings
+            SET status = 'NEW'
+            WHERE status = 'TRANSCRIBING'
+        """)
+        cleanup.commit()
+        cleanup.close()
+
+        raise
+
+    elapsed = time.time() - started
+
+    con = sqlite3.connect(DB)
+
+    print()
+    print("=" * 72)
+    print("WHISPER-LAUF BEENDET")
+    print("=" * 72)
+
+    for status, count in con.execute("""
+        SELECT status, COUNT(*)
+        FROM recordings
+        GROUP BY status
+        ORDER BY status
+    """):
+        print(
+            f"{status:24} {count}"
+        )
+
+    print(
+        f"\nLaufzeit: "
+        f"{elapsed / 60:.1f} Minuten"
+    )
+
+    con.close()
+
+
+if __name__ == "__main__":
+    mp.set_start_method(
+        "spawn",
+        force=True,
+    )
+    main()

+ 545 - 0
tools/zammad_taxonomy_scan.py

@@ -0,0 +1,545 @@
+#!/usr/bin/env python3
+
+import json
+import os
+import re
+import sqlite3
+import urllib.request
+from collections import Counter, defaultdict
+from html import unescape
+from pathlib import Path
+
+BASE = Path(".")
+ENV = BASE / ".env"
+DB = BASE / "data/zammad_history.sqlite3"
+TAXONOMY = BASE / "config/telefonie_taxonomy.json"
+OUT = BASE / "data/zammad_taxonomy_scan.json"
+
+OLLAMA = "http://127.0.0.1:11434/api/generate"
+MODEL = "qwen3:8b"
+
+
+def load_env():
+    for line in ENV.read_text().splitlines():
+        line = line.strip()
+        if not line or line.startswith("#") or "=" not in line:
+            continue
+        key, value = line.split("=", 1)
+        os.environ[key] = value.strip().strip('"').strip("'")
+
+
+def clean_text(text):
+    if not text:
+        return ""
+
+    text = re.sub(
+        r"<(script|style).*?</\1>",
+        " ",
+        text,
+        flags=re.I | re.S,
+    )
+    text = re.sub(r"<br\s*/?>", "\n", text, flags=re.I)
+    text = re.sub(r"</p\s*>", "\n", text, flags=re.I)
+    text = re.sub(r"</div\s*>", "\n", text, flags=re.I)
+    text = re.sub(r"<[^>]+>", " ", text)
+    text = unescape(text)
+
+    # E-Mail-Zitate reduzieren.
+    text = re.sub(
+        r"\n\s*(Am .* schrieb .*:|On .* wrote:).*",
+        "",
+        text,
+        flags=re.I | re.S,
+    )
+
+    # Signaturen grob reduzieren.
+    text = re.sub(
+        r"\n\s*(Mit freundlichen Grüßen|Viele Grüße|Beste Grüße).*",
+        "",
+        text,
+        flags=re.I | re.S,
+    )
+
+    text = re.sub(r"[ \t]+", " ", text)
+    text = re.sub(r"\n{3,}", "\n\n", text)
+
+    return text.strip()
+
+
+def qwen(prompt):
+    payload = {
+        "model": MODEL,
+        "prompt": prompt,
+        "stream": False,
+        "format": "json",
+        "options": {
+            "temperature": 0
+        },
+    }
+
+    req = urllib.request.Request(
+        OLLAMA,
+        data=json.dumps(
+            payload,
+            ensure_ascii=False,
+        ).encode(),
+        headers={
+            "Content-Type": "application/json"
+        },
+        method="POST",
+    )
+
+    with urllib.request.urlopen(
+        req,
+        timeout=600,
+    ) as response:
+        return json.loads(
+            response.read().decode()
+        )["response"]
+
+
+def parse_json(text):
+    text = text.strip()
+
+    if text.startswith("```"):
+        text = re.sub(
+            r"^```(?:json)?",
+            "",
+            text,
+        )
+        text = re.sub(
+            r"```$",
+            "",
+            text,
+        )
+
+    return json.loads(text.strip())
+
+
+def build_taxonomy_text(taxonomy):
+    result = []
+
+    for group, intents in taxonomy["groups"].items():
+        result.append(
+            f"{group}: {', '.join(intents)}"
+        )
+
+    result.append(
+        "\nREKLAMATIONSGRÜNDE:"
+    )
+
+    for key, description in taxonomy[
+        "complaint_types"
+    ].items():
+        result.append(
+            f"- {key}: {description}"
+        )
+
+    return "\n".join(result)
+
+
+def main():
+    load_env()
+
+    taxonomy = json.loads(
+        TAXONOMY.read_text()
+    )
+
+    taxonomy_text = build_taxonomy_text(
+        taxonomy
+    )
+
+    con = sqlite3.connect(DB)
+    con.row_factory = sqlite3.Row
+
+    tickets = con.execute("""
+        SELECT *
+        FROM tickets
+        ORDER BY id
+    """).fetchall()
+
+    print("=" * 80)
+    print("ZAMMAD → TELEFONIE-TAXONOMIE SCAN")
+    print("=" * 80)
+    print(f"Tickets:   {len(tickets)}")
+    print(f"Taxonomie: {TAXONOMY}")
+    print(f"Modell:    {MODEL}")
+    print()
+
+    # Zunächst lokale Statistiken.
+    groups = Counter()
+    states = Counter()
+    tags = Counter()
+    article_counts = Counter()
+
+    prepared = []
+
+    for ticket in tickets:
+        groups[ticket["group_name"]] += 1
+        states[ticket["state"]] += 1
+
+        try:
+            ticket_tags = json.loads(
+                ticket["tags_json"] or "[]"
+            )
+        except Exception:
+            ticket_tags = []
+
+        for tag in ticket_tags:
+            tags[tag] += 1
+
+        articles = con.execute("""
+            SELECT *
+            FROM articles
+            WHERE ticket_id = ?
+            ORDER BY created_at, id
+        """, (ticket["id"],)).fetchall()
+
+        article_counts[len(articles)] += 1
+
+        # Für die KI nur externe Kommunikation.
+        communication = []
+
+        for article in articles:
+            if article["internal"]:
+                continue
+
+            body = clean_text(
+                article["body_text"]
+            )
+
+            if not body:
+                continue
+
+            communication.append({
+                "sender": article["sender"],
+                "type": article["type"],
+                "created_at": article["created_at"],
+                "subject": article["subject"],
+                "body": body,
+            })
+
+        full_text = "\n\n".join(
+            (
+                f"[{x['sender']}] "
+                f"{x['body']}"
+            )
+            for x in communication
+        )
+
+        if not full_text:
+            continue
+
+        # Extrem lange Mailverläufe begrenzen.
+        if len(full_text) > 12000:
+            full_text = full_text[:12000]
+
+        prepared.append({
+            "ticket_id": ticket["id"],
+            "number": ticket["number"],
+            "title": ticket["title"],
+            "group": ticket["group_name"],
+            "state": ticket["state"],
+            "tags": ticket_tags,
+            "order_id": ticket["customer_id"],
+            "communication": full_text,
+        })
+
+    con.close()
+
+    print("Lokale Statistik:")
+    print("\nGruppen:")
+    for key, value in groups.most_common(20):
+        print(f"{value:6}  {key}")
+
+    print("\nTags:")
+    for key, value in tags.most_common(30):
+        print(f"{value:6}  {key}")
+
+    print("\nTickets mit Artikelanzahl:")
+    for key, value in sorted(article_counts.items()):
+        print(f"{key:3} Artikel: {value}")
+
+    print()
+    print(
+        f"Tickets mit verwertbarer Kommunikation: "
+        f"{len(prepared)}"
+    )
+
+    # ------------------------------------------------------------------
+    # Qwen bewertet die Tickets GEGEN die bestehende Taxonomie.
+    # Die Taxonomie darf von Qwen NICHT still verändert werden.
+    # ------------------------------------------------------------------
+
+    results = []
+
+    for index, ticket in enumerate(
+        prepared,
+        1,
+    ):
+        print(
+            f"[{index}/{len(prepared)}] "
+            f"Ticket #{ticket['number']}"
+        )
+
+        prompt = f"""
+Du bist Auditor einer bestehenden Taxonomie für
+Kundenservice und Telefon-KI eines Gartenpflanzen-Webshops.
+
+Die bestehende Taxonomie ist VERBINDLICH:
+
+{taxonomy_text}
+
+Du darfst keinen bestehenden Intent umbenennen.
+
+Deine Aufgabe ist:
+
+1. Erkenne primary_intent.
+2. Erkenne weitere secondary_intents.
+3. Bei REKLAMATION zusätzlich complaint_type.
+4. Erkenne notwendige actions.
+5. Prüfe, ob die bestehende Taxonomie das Ticket ausreichend beschreibt.
+6. Wenn NICHT:
+   - schlage einen neuen Intent oder Unterintent vor.
+   - verwende dafür einen kurzen maschinenlesbaren Namen.
+   - erkläre konkret anhand des Tickets, warum er nötig ist.
+7. Wenn die bestehende Taxonomie ausreicht:
+   - new_taxonomy_candidate = null.
+
+WICHTIG:
+
+Nicht jede seltene Formulierung ist ein neuer Intent.
+
+Ein neuer Intent ist nur sinnvoll, wenn:
+- ein eigenständiger Kundenwunsch vorliegt,
+- er fachlich anders behandelt werden muss,
+- oder dafür ein anderer MCP-/Workflow-Pfad nötig wäre.
+
+Mehrere Anliegen dürfen gleichzeitig vorkommen.
+
+REKLAMATION:
+Die bestehenden Reklamationsgründe sind besonders wichtig.
+Wenn eine Reklamation vorliegt, ordne sie einem vorhandenen
+complaint_type zu, sofern möglich.
+
+Bestehende Reklamationsgründe dürfen NICHT durch einen
+neuen ähnlichen Kandidaten ersetzt werden.
+
+TICKET:
+
+{json.dumps(ticket, ensure_ascii=False, indent=2)}
+
+Antworte ausschließlich mit:
+
+{{
+  "call_state": "HUMAN_CONVERSATION",
+  "primary_intent": "",
+  "secondary_intents": [],
+  "complaint_type": null,
+  "actions": [],
+  "taxonomy_fit": "FIT",
+  "new_taxonomy_candidate": null,
+  "candidate_reason": "",
+  "customer_goal": "",
+  "confidence": 0.0
+}}
+
+taxonomy_fit muss sein:
+FIT
+PARTIAL
+NEW_INTENT_REQUIRED
+NO_BUSINESS_CONTENT
+"""
+
+        try:
+            result = parse_json(
+                qwen(prompt)
+            )
+        except Exception as exc:
+            result = {
+                "call_state": "UNKNOWN",
+                "primary_intent": "SONSTIGES",
+                "secondary_intents": [],
+                "complaint_type": None,
+                "actions": [],
+                "taxonomy_fit": "UNKNOWN",
+                "new_taxonomy_candidate": None,
+                "candidate_reason": str(exc),
+                "customer_goal": "",
+                "confidence": 0,
+            }
+
+        result["_ticket_id"] = ticket[
+            "ticket_id"
+        ]
+        result["_ticket_number"] = ticket[
+            "number"
+        ]
+        result["_title"] = ticket[
+            "title"
+        ]
+
+        results.append(result)
+
+    # ------------------------------------------------------------------
+    # Kandidaten zusammenfassen.
+    # ------------------------------------------------------------------
+
+    candidates = defaultdict(
+        lambda: {
+            "count": 0,
+            "examples": [],
+            "reasons": [],
+        }
+    )
+
+    fit = Counter()
+    primary = Counter()
+    complaints = Counter()
+    actions = Counter()
+
+    for result in results:
+        fit[
+            result.get(
+                "taxonomy_fit",
+                "UNKNOWN",
+            )
+        ] += 1
+
+        primary[
+            result.get(
+                "primary_intent",
+                "UNKNOWN",
+            )
+        ] += 1
+
+        if result.get("complaint_type"):
+            complaints[
+                result["complaint_type"]
+            ] += 1
+
+        for action in result.get(
+            "actions",
+            [],
+        ):
+            actions[action] += 1
+
+        candidate = result.get(
+            "new_taxonomy_candidate"
+        )
+
+        if candidate:
+            key = candidate.strip().upper()
+
+            entry = candidates[key]
+            entry["count"] += 1
+
+            if len(entry["examples"]) < 10:
+                entry["examples"].append(
+                    {
+                        "ticket": result[
+                            "_ticket_number"
+                        ],
+                        "title": result[
+                            "_title"
+                        ],
+                    }
+                )
+
+            reason = result.get(
+                "candidate_reason",
+                "",
+            )
+
+            if (
+                reason
+                and reason not in entry["reasons"]
+                and len(entry["reasons"]) < 5
+            ):
+                entry["reasons"].append(
+                    reason
+                )
+
+    output = {
+        "taxonomy_version": taxonomy[
+            "version"
+        ],
+        "model": MODEL,
+        "ticket_count": len(tickets),
+        "analyzed_count": len(results),
+        "taxonomy_fit": dict(
+            fit.most_common()
+        ),
+        "primary_intents": dict(
+            primary.most_common()
+        ),
+        "complaint_types": dict(
+            complaints.most_common()
+        ),
+        "actions": dict(
+            actions.most_common()
+        ),
+        "taxonomy_candidates": dict(
+            sorted(
+                candidates.items(),
+                key=lambda x: x[1]["count"],
+                reverse=True,
+            )
+        ),
+        "tickets": results,
+    }
+
+    OUT.write_text(
+        json.dumps(
+            output,
+            ensure_ascii=False,
+            indent=2,
+        )
+    )
+
+    print()
+    print("=" * 80)
+    print("SCAN FERTIG")
+    print("=" * 80)
+
+    print("\nTAXONOMIE-PASSUNG")
+    for key, value in fit.most_common():
+        print(f"{value:6}  {key}")
+
+    print("\nPRIMARY INTENTS")
+    for key, value in primary.most_common():
+        print(f"{value:6}  {key}")
+
+    print("\nREKLAMATIONSGRÜNDE")
+    for key, value in complaints.most_common():
+        print(f"{value:6}  {key}")
+
+    print("\nNEUE TAXONOMIE-KANDIDATEN")
+    if not candidates:
+        print("Keine Kandidaten.")
+    else:
+        for key, value in sorted(
+            candidates.items(),
+            key=lambda x: x[1]["count"],
+            reverse=True,
+        ):
+            print(
+                f"\n{value['count']:6}  {key}"
+            )
+
+            for reason in value["reasons"]:
+                print(f"       {reason}")
+
+            for example in value["examples"][:5]:
+                print(
+                    f"       Ticket "
+                    f"{example['ticket']}: "
+                    f"{example['title']}"
+                )
+
+    print()
+    print(f"Ergebnis: {OUT}")
+
+
+if __name__ == "__main__":
+    main()