| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563 |
- #!/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())
|