process_cdr_call.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563
  1. #!/usr/bin/env python3
  2. import asyncio
  3. import json
  4. import sqlite3
  5. import sys
  6. import tempfile
  7. import time
  8. import urllib.request
  9. from pathlib import Path
  10. from app.config import Settings
  11. from app.threecx import ThreeCXClient
  12. from app.whisper import WhisperWorker
  13. from app.audio_processor import AudioProcessor
  14. DB = Path("data/telephony.sqlite3")
  15. OLLAMA = "http://127.0.0.1:11434/api/generate"
  16. MODEL = "qwen3:8b"
  17. def schema():
  18. return {
  19. "type": "object",
  20. "properties": {
  21. "customer": {
  22. "type": "object",
  23. "properties": {
  24. "name": {"type": ["string", "null"]},
  25. "email": {"type": ["string", "null"]},
  26. "phone": {"type": ["string", "null"]},
  27. "address": {"type": ["string", "null"]},
  28. "customer_number": {"type": ["string", "null"]},
  29. "order_number": {"type": ["string", "null"]}
  30. },
  31. "required": [
  32. "name", "email", "phone", "address",
  33. "customer_number", "order_number"
  34. ]
  35. },
  36. "products": {
  37. "type": "array",
  38. "items": {
  39. "type": "object",
  40. "properties": {
  41. "raw_text": {"type": "string"},
  42. "normalized": {"type": ["string", "null"]},
  43. "quantity": {"type": ["number", "null"]},
  44. "uncertain": {"type": "boolean"}
  45. },
  46. "required": [
  47. "raw_text", "normalized",
  48. "quantity", "uncertain"
  49. ]
  50. }
  51. },
  52. "vouchers": {
  53. "type": "array",
  54. "items": {
  55. "type": "object",
  56. "properties": {
  57. "raw_text": {"type": "string"},
  58. "amount": {"type": ["number", "null"]},
  59. "code": {"type": ["string", "null"]},
  60. "uncertain": {"type": "boolean"}
  61. },
  62. "required": [
  63. "raw_text", "amount",
  64. "code", "uncertain"
  65. ]
  66. }
  67. },
  68. "analysis": {
  69. "type": "object",
  70. "properties": {
  71. "intent": {"type": ["string", "null"]},
  72. "sentiment": {
  73. "type": ["string", "null"],
  74. "enum": [
  75. "positive", "neutral",
  76. "negative", "mixed", None
  77. ]
  78. },
  79. "summary": {"type": ["string", "null"]},
  80. "advice": {
  81. "type": "array",
  82. "items": {"type": "string"}
  83. },
  84. "follow_up": {
  85. "type": "array",
  86. "items": {"type": "string"}
  87. }
  88. },
  89. "required": [
  90. "intent", "sentiment",
  91. "summary", "advice", "follow_up"
  92. ]
  93. },
  94. "uncertainties": {
  95. "type": "array",
  96. "items": {"type": "string"}
  97. }
  98. },
  99. "required": [
  100. "customer", "products", "vouchers",
  101. "analysis", "uncertainties"
  102. ]
  103. }
  104. def text_from_transcript(data):
  105. texts = []
  106. for item in data.get("transcripts", []):
  107. for segment in item.get("segments", []):
  108. text = segment.get("text", "").strip()
  109. if text:
  110. texts.append(text)
  111. return "\n".join(texts)
  112. async def qwen_analyze(text):
  113. prompt = f"""
  114. Analysiere dieses deutschsprachige Kundentelefonat.
  115. Extrahiere ausdrücklich personenbezogene Daten, sofern sie genannt werden:
  116. Name, E-Mail, Telefonnummer, Adresse, Kundennummer, Bestellnummer
  117. und Gutscheincode.
  118. Diese Daten dienen der späteren Kunden- und Auftragszuordnung.
  119. Verwende ausschließlich Informationen aus dem Transkript.
  120. Erfinde niemals Daten.
  121. Whisper kann Wörter falsch erkennen.
  122. Korrigiere nur eindeutig erkennbare Fehler.
  123. Bei unsicheren Produktnamen, Nummern, Codes oder Namen:
  124. raw_text = tatsächlich erkannter Wortlaut
  125. normalized = null
  126. uncertain = true
  127. Keine Sprecherzuordnung erfinden.
  128. Keine neuen Zeitstempel erzeugen.
  129. TRANSKRIPT:
  130. {text}
  131. """
  132. payload = {
  133. "model": MODEL,
  134. "prompt": prompt,
  135. "stream": False,
  136. "format": schema(),
  137. "think": False,
  138. "options": {"temperature": 0}
  139. }
  140. request = urllib.request.Request(
  141. OLLAMA,
  142. data=json.dumps(payload).encode(),
  143. headers={"Content-Type": "application/json"},
  144. method="POST"
  145. )
  146. with urllib.request.urlopen(request, timeout=300) as response:
  147. result = json.load(response)
  148. return json.loads(result["response"])
  149. async def main():
  150. cdr_row_id = int(sys.argv[1]) if len(sys.argv) > 1 else None
  151. con = sqlite3.connect(DB)
  152. con.row_factory = sqlite3.Row
  153. if cdr_row_id:
  154. row = con.execute("""
  155. SELECT *
  156. FROM cdr_calls
  157. WHERE id = ?
  158. """, (cdr_row_id,)).fetchone()
  159. else:
  160. row = con.execute("""
  161. SELECT c.*
  162. FROM cdr_calls c
  163. WHERE (c.src_rec_id IS NOT NULL OR c.dst_rec_id IS NOT NULL)
  164. AND NOT EXISTS (
  165. SELECT 1
  166. FROM analyses a
  167. WHERE a.cdr_row_id = c.id
  168. )
  169. ORDER BY c.start_time ASC
  170. LIMIT 1
  171. """).fetchone()
  172. if not row:
  173. con.close()
  174. print("Kein unverarbeiteter CDR mit Recording.")
  175. return
  176. rec_id = row["src_rec_id"] or row["dst_rec_id"]
  177. print("=== CDR CALL ===")
  178. print("cdr_calls.id:", row["id"])
  179. print("Start:", row["start_time"])
  180. print("Richtung:", row["direction"])
  181. print("recId:", rec_id)
  182. # Bereits vorhandenes Transcript suchen.
  183. transcript_row = con.execute("""
  184. SELECT *
  185. FROM transcripts
  186. WHERE cdr_row_id = ?
  187. OR rec_id = ?
  188. ORDER BY id DESC
  189. LIMIT 1
  190. """, (row["id"], rec_id)).fetchone()
  191. # Bereits vollständig verarbeitet.
  192. if transcript_row:
  193. analysis_row = con.execute("""
  194. SELECT id
  195. FROM analyses
  196. WHERE transcript_id = ?
  197. OR cdr_row_id = ?
  198. ORDER BY id DESC
  199. LIMIT 1
  200. """, (transcript_row["id"], row["id"])).fetchone()
  201. if analysis_row:
  202. print("Bereits vollständig verarbeitet.")
  203. con.close()
  204. return
  205. print(
  206. f"Vorhandenes Transcript {transcript_row['id']} "
  207. "wird wiederverwendet – kein Whisper."
  208. )
  209. transcript = json.loads(
  210. transcript_row["transcript_json"]
  211. )
  212. transcript_id = transcript_row["id"]
  213. else:
  214. settings = Settings()
  215. client = ThreeCXClient(settings)
  216. audio = AudioProcessor()
  217. whisper = WhisperWorker(settings)
  218. print("Recording laden ...")
  219. content, content_type = await client.download_recording(rec_id)
  220. suffix = ".mp3" if "mpeg" in (content_type or "").lower() else ".wav"
  221. with tempfile.TemporaryDirectory(prefix="3cx-cdr-") as tmp:
  222. source = Path(tmp) / f"recording{suffix}"
  223. source.write_bytes(content)
  224. info = await audio.inspect(source)
  225. duration_text = (
  226. f"{info.duration:.1f}s"
  227. if info.duration is not None
  228. else "unbekannt"
  229. )
  230. channels_text = (
  231. str(info.channels)
  232. if info.channels is not None
  233. else "?"
  234. )
  235. sample_rate_text = (
  236. f"{info.sample_rate} Hz"
  237. if info.sample_rate is not None
  238. else "? Hz"
  239. )
  240. print(
  241. f"Audio: {duration_text} | "
  242. f"{channels_text} Kanal/Kanäle | "
  243. f"{sample_rate_text}"
  244. )
  245. paths = await audio.prepare_for_transcription(source)
  246. transcripts = []
  247. for index, path in enumerate(paths):
  248. print(f"Whisper {index + 1}/{len(paths)} ...")
  249. start = time.monotonic()
  250. result = await asyncio.to_thread(
  251. whisper.transcribe,
  252. path
  253. )
  254. print(f" {time.monotonic() - start:.1f}s")
  255. segments = (
  256. result.get("segments", [])
  257. if isinstance(result, dict)
  258. else result
  259. )
  260. transcripts.append({
  261. "channel": index if info.channels > 1 else None,
  262. "segments": segments
  263. })
  264. transcript = {
  265. "cdr_id": row["cdr_id"],
  266. "cdr_row_id": row["id"],
  267. "rec_id": rec_id,
  268. "audio": {
  269. "codec": info.codec,
  270. "channels": info.channels,
  271. "sample_rate": info.sample_rate,
  272. "duration": info.duration
  273. },
  274. "transcripts": transcripts
  275. }
  276. cur = con.execute("""
  277. INSERT INTO transcripts (
  278. call_id,
  279. cdr_row_id,
  280. rec_id,
  281. model,
  282. language,
  283. audio_codec,
  284. audio_channels,
  285. audio_sample_rate,
  286. audio_duration,
  287. transcript_json,
  288. status
  289. )
  290. VALUES (
  291. NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'completed'
  292. )
  293. """, (
  294. row["id"],
  295. rec_id,
  296. settings.whisper_model,
  297. "de",
  298. info.codec,
  299. info.channels,
  300. info.sample_rate,
  301. info.duration,
  302. json.dumps(transcript, ensure_ascii=False)
  303. ))
  304. transcript_id = cur.lastrowid
  305. con.commit()
  306. print("Transcript gespeichert:", transcript_id)
  307. text = text_from_transcript(transcript)
  308. print("Qwen3:8b ...")
  309. start = time.monotonic()
  310. analysis = await qwen_analyze(text)
  311. print(f"Qwen: {time.monotonic() - start:.1f}s")
  312. # ------------------------------------------------------------
  313. # DETERMINISTIC RESOLUTION
  314. #
  315. # CDR = harte Quelle für Telefonnummer.
  316. # Qwen = Extraktion aus dem Gespräch.
  317. # Kontor MCP = fachliche Validierung / Auflösung.
  318. # ------------------------------------------------------------
  319. import os
  320. from app.kontor_mcp import KontorMCPClient
  321. from app.kontor_resolver import (
  322. resolve_customer_number,
  323. resolve_order_number,
  324. resolve_product_with_context,
  325. unwrap_tool_result,
  326. )
  327. analysis.setdefault("resolved", {
  328. "customer": None,
  329. "customer_number": None,
  330. "order": None,
  331. "products": [],
  332. })
  333. mcp_url = os.getenv("KONTOR_MCP_URL")
  334. mcp_token = os.getenv("KONTOR_MCP_TOKEN")
  335. if mcp_url and mcp_token:
  336. try:
  337. mcp = KontorMCPClient(
  338. url=mcp_url,
  339. token=mcp_token,
  340. timeout=float(
  341. os.getenv("KONTOR_MCP_TIMEOUT", "10")
  342. ),
  343. )
  344. await mcp.initialize()
  345. # CDR-Telefonnummer niemals durch Qwen ersetzen.
  346. cdr_phone = row["source_caller_id"]
  347. if cdr_phone:
  348. raw_customer = (
  349. await mcp.find_customer_by_phone(
  350. cdr_phone
  351. )
  352. )
  353. customer = unwrap_tool_result(
  354. raw_customer
  355. )
  356. if customer:
  357. analysis["resolved"]["customer"] = customer
  358. # Qwen-Kundennummer validieren.
  359. customer_number = (
  360. analysis
  361. .get("customer", {})
  362. .get("customer_number")
  363. )
  364. if customer_number:
  365. resolution = (
  366. await resolve_customer_number(
  367. mcp,
  368. str(customer_number),
  369. )
  370. )
  371. analysis["resolved"]["customer_number"] = {
  372. "status": resolution.status,
  373. "value": resolution.value,
  374. "confidence": resolution.confidence,
  375. "data": resolution.data,
  376. }
  377. # Qwen-Bestellnummer gegen Kontor validieren.
  378. order_number = (
  379. analysis
  380. .get("customer", {})
  381. .get("order_number")
  382. )
  383. if order_number:
  384. resolution = (
  385. await resolve_order_number(
  386. mcp,
  387. str(order_number),
  388. )
  389. )
  390. analysis["resolved"]["order"] = {
  391. "status": resolution.status,
  392. "value": resolution.value,
  393. "confidence": resolution.confidence,
  394. "data": resolution.data,
  395. }
  396. # Produkte mit Bestellkontext auflösen.
  397. order_context = None
  398. resolved_order = (
  399. analysis["resolved"].get("order")
  400. )
  401. if isinstance(resolved_order, dict):
  402. order_context = resolved_order.get("data")
  403. for product in analysis.get("products", []):
  404. if not isinstance(product, dict):
  405. continue
  406. raw_text = (
  407. product.get("raw_text")
  408. or product.get("name")
  409. )
  410. if not raw_text:
  411. continue
  412. resolution = (
  413. await resolve_product_with_context(
  414. mcp,
  415. str(raw_text),
  416. order_context,
  417. )
  418. )
  419. analysis["resolved"]["products"].append({
  420. "raw_text": raw_text,
  421. "status": resolution.status,
  422. "value": resolution.value,
  423. "confidence": resolution.confidence,
  424. "source": resolution.source,
  425. "candidates": resolution.candidates,
  426. "data": resolution.data,
  427. })
  428. except Exception as exc:
  429. print(
  430. "WARNUNG: Kontor-MCP-Auflösung fehlgeschlagen:",
  431. repr(exc),
  432. )
  433. analysis.setdefault(
  434. "uncertainties",
  435. [],
  436. ).append(
  437. "Kontor-MCP-Auflösung nicht verfügbar"
  438. )
  439. else:
  440. print(
  441. "WARNUNG: Kontor MCP nicht konfiguriert "
  442. "(KONTOR_MCP_URL/TOKEN fehlen)."
  443. )
  444. con.execute("""
  445. INSERT INTO analyses (
  446. call_id,
  447. cdr_row_id,
  448. transcript_id,
  449. model,
  450. schema_version,
  451. analysis_json,
  452. status
  453. )
  454. VALUES (
  455. NULL, ?, ?, ?, '1.0', ?, 'completed'
  456. )
  457. """, (
  458. row["id"],
  459. transcript_id,
  460. MODEL,
  461. json.dumps(analysis, ensure_ascii=False)
  462. ))
  463. con.commit()
  464. con.close()
  465. print("Analyse gespeichert.")
  466. print("cdr_calls.id:", row["id"])
  467. print("recId:", rec_id)
  468. print("transcript:", transcript_id)
  469. print(json.dumps(analysis, ensure_ascii=False, indent=2))
  470. if __name__ == "__main__":
  471. asyncio.run(main())