process_one_call.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498
  1. #!/usr/bin/env python3
  2. import asyncio
  3. import json
  4. import sqlite3
  5. import tempfile
  6. import time
  7. import urllib.request
  8. from pathlib import Path
  9. from app.config import Settings
  10. from app.threecx import ThreeCXClient
  11. from app.whisper import WhisperWorker
  12. from app.audio_processor import AudioProcessor
  13. DB = Path("data/telephony.sqlite3")
  14. def build_transcript(result):
  15. if isinstance(result, dict):
  16. segments = result.get("segments", [])
  17. else:
  18. segments = result
  19. return segments
  20. async def main():
  21. con = sqlite3.connect(DB)
  22. con.row_factory = sqlite3.Row
  23. row = con.execute("""
  24. SELECT *
  25. FROM cdr_calls
  26. WHERE src_rec_id IS NOT NULL
  27. OR dst_rec_id IS NOT NULL
  28. ORDER BY start_time DESC
  29. LIMIT 1
  30. """).fetchone()
  31. if not row:
  32. raise RuntimeError("Kein CDR mit Recording gefunden.")
  33. rec_id = row["src_rec_id"] or row["dst_rec_id"]
  34. settings = Settings()
  35. client = ThreeCXClient(settings)
  36. audio = AudioProcessor()
  37. whisper = WhisperWorker(settings)
  38. print("=== CALL ===")
  39. print("CDR:", row["cdr_id"])
  40. print("Datum:", row["start_time"])
  41. print("Richtung:", row["direction"])
  42. print("Caller:", row["source_caller_id"])
  43. print("Recording:", rec_id)
  44. content, content_type = await client.download_recording(rec_id)
  45. suffix = ".mp3" if "mpeg" in (content_type or "").lower() else ".wav"
  46. with tempfile.TemporaryDirectory(prefix="3cx-call-") as tmp:
  47. source = Path(tmp) / f"recording{suffix}"
  48. source.write_bytes(content)
  49. info = await audio.inspect(source)
  50. paths = await audio.prepare_for_transcription(source)
  51. transcripts = []
  52. for index, path in enumerate(paths):
  53. print(
  54. f"Whisper {index + 1}/{len(paths)} "
  55. f"({info.duration:.1f}s Audio) ..."
  56. )
  57. start = time.monotonic()
  58. result = await asyncio.to_thread(
  59. whisper.transcribe,
  60. path,
  61. )
  62. print(f"Whisper: {time.monotonic() - start:.1f}s")
  63. transcripts.append({
  64. "channel": index if info.channels > 1 else None,
  65. "segments": build_transcript(result),
  66. })
  67. transcript = {
  68. "cdr_id": row["cdr_id"],
  69. "rec_id": rec_id,
  70. "audio": {
  71. "codec": info.codec,
  72. "channels": info.channels,
  73. "sample_rate": info.sample_rate,
  74. "duration": info.duration,
  75. },
  76. "transcripts": transcripts,
  77. }
  78. # Bei der Speicherung verwenden wir das vorhandene cdr_calls-Objekt
  79. # als Referenz. Für call_id gibt es in der aktuellen Struktur keine
  80. # erzwungene 1:1-Verknüpfung; deshalb speichern wir die CDR-ID im JSON.
  81. con.execute("""
  82. INSERT INTO transcripts (
  83. call_id,
  84. rec_id,
  85. model,
  86. language,
  87. audio_codec,
  88. audio_channels,
  89. audio_sample_rate,
  90. audio_duration,
  91. transcript_json,
  92. status
  93. )
  94. SELECT
  95. 0,
  96. ?,
  97. ?,
  98. ?,
  99. ?,
  100. ?,
  101. ?,
  102. ?,
  103. ?,
  104. 'completed'
  105. WHERE NOT EXISTS (
  106. SELECT 1
  107. FROM transcripts
  108. WHERE rec_id = ?
  109. )
  110. """, (
  111. rec_id,
  112. settings.whisper_model,
  113. "de",
  114. info.codec,
  115. info.channels,
  116. info.sample_rate,
  117. info.duration,
  118. json.dumps(transcript, ensure_ascii=False),
  119. rec_id,
  120. ))
  121. con.commit()
  122. transcript_id = con.execute("""
  123. SELECT id
  124. FROM transcripts
  125. WHERE rec_id = ?
  126. ORDER BY id DESC
  127. LIMIT 1
  128. """, (rec_id,)).fetchone()["id"]
  129. con.close()
  130. # Für die Qwen-Analyse bauen wir nur den Text aus den Whisper-Segmenten.
  131. text_parts = []
  132. for item in transcripts:
  133. for segment in item["segments"]:
  134. if isinstance(segment, dict):
  135. text = segment.get("text", "").strip()
  136. if text:
  137. text_parts.append(text)
  138. transcript_text = "\n".join(text_parts)
  139. print()
  140. print("=== QWEN3:8B ===")
  141. print("Transkript:", len(transcript_text), "Zeichen")
  142. schema = {
  143. "type": "object",
  144. "properties": {
  145. "customer": {
  146. "type": "object",
  147. "properties": {
  148. "name": {"type": ["string", "null"]},
  149. "email": {"type": ["string", "null"]},
  150. "phone": {"type": ["string", "null"]},
  151. "address": {"type": ["string", "null"]},
  152. "customer_number": {"type": ["string", "null"]},
  153. "order_number": {"type": ["string", "null"]}
  154. },
  155. "required": [
  156. "name", "email", "phone", "address",
  157. "customer_number", "order_number"
  158. ]
  159. },
  160. "products": {
  161. "type": "array",
  162. "items": {
  163. "type": "object",
  164. "properties": {
  165. "raw_text": {"type": "string"},
  166. "normalized": {"type": ["string", "null"]},
  167. "quantity": {"type": ["number", "null"]},
  168. "uncertain": {"type": "boolean"}
  169. },
  170. "required": [
  171. "raw_text", "normalized",
  172. "quantity", "uncertain"
  173. ]
  174. }
  175. },
  176. "analysis": {
  177. "type": "object",
  178. "properties": {
  179. "intent": {"type": ["string", "null"]},
  180. "sentiment": {
  181. "type": ["string", "null"],
  182. "enum": [
  183. "positive", "neutral",
  184. "negative", "mixed", None
  185. ]
  186. },
  187. "summary": {"type": ["string", "null"]},
  188. "advice": {
  189. "type": "array",
  190. "items": {"type": "string"}
  191. },
  192. "follow_up": {
  193. "type": "array",
  194. "items": {"type": "string"}
  195. }
  196. },
  197. "required": [
  198. "intent", "sentiment", "summary",
  199. "advice", "follow_up"
  200. ]
  201. },
  202. "uncertainties": {
  203. "type": "array",
  204. "items": {"type": "string"}
  205. }
  206. },
  207. "required": [
  208. "customer",
  209. "products",
  210. "analysis",
  211. "uncertainties"
  212. ]
  213. }
  214. prompt = f"""
  215. Analysiere dieses deutschsprachige Kundentelefonat.
  216. Extrahiere personenbezogene Daten, sofern sie im Gespräch genannt werden:
  217. Name, E-Mail, Telefonnummer, Adresse, Kundennummer und Bestellnummer.
  218. Verwende ausschließlich Informationen aus dem Transkript.
  219. Erfinde niemals Daten.
  220. Whisper kann einzelne Wörter falsch erkennen.
  221. Korrigiere einen Fehler nur, wenn der Kontext die Korrektur eindeutig macht.
  222. Bei unsicheren Produktnamen, Nummern oder Codes:
  223. raw_text = tatsächlich erkannter Wortlaut,
  224. normalized = null,
  225. uncertain = true.
  226. Keine Sprecherzuordnung erzeugen.
  227. Keine neuen Zeitstempel erzeugen.
  228. TRANSKRIPT:
  229. {transcript_text}
  230. """
  231. payload = {
  232. "model": "qwen3:8b",
  233. "prompt": prompt,
  234. "stream": False,
  235. "format": schema,
  236. "think": False,
  237. "options": {
  238. "temperature": 0
  239. }
  240. }
  241. request = urllib.request.Request(
  242. "http://127.0.0.1:11434/api/generate",
  243. data=json.dumps(payload).encode(),
  244. headers={"Content-Type": "application/json"},
  245. method="POST",
  246. )
  247. start = time.monotonic()
  248. with urllib.request.urlopen(request, timeout=300) as response:
  249. result = json.load(response)
  250. print(f"Qwen: {time.monotonic() - start:.1f}s")
  251. analysis = json.loads(result["response"])
  252. # ------------------------------------------------------------
  253. # DETERMINISTIC RESOLUTION
  254. #
  255. # Harte CDR-Fakten kommen direkt aus `row`.
  256. # Qwen darf diese Fakten nicht überschreiben.
  257. # ------------------------------------------------------------
  258. from app.kontor_resolver import (
  259. resolve_customer_number,
  260. resolve_order_number,
  261. resolve_product,
  262. resolve_product_with_context,
  263. unwrap_tool_result,
  264. )
  265. from app.kontor_mcp import KontorMCPClient
  266. import os
  267. # Telefonnummer ist eine harte CDR-Information.
  268. cdr_phone = (
  269. row["source_caller_id"]
  270. if row["source_caller_id"]
  271. else None
  272. )
  273. if cdr_phone:
  274. analysis.setdefault("customer", {})["phone"] = cdr_phone
  275. analysis["resolved"] = {
  276. "customer": None,
  277. "customer_number": None,
  278. "order": None,
  279. "products": [],
  280. }
  281. mcp_url = os.getenv("KONTOR_MCP_URL")
  282. mcp_token = os.getenv("KONTOR_MCP_TOKEN")
  283. if mcp_url and mcp_token:
  284. try:
  285. mcp = KontorMCPClient(
  286. url=mcp_url,
  287. token=mcp_token,
  288. timeout=float(
  289. os.getenv("KONTOR_MCP_TIMEOUT", "10")
  290. ),
  291. )
  292. await mcp.initialize()
  293. # --------------------------------------------------------
  294. # KUNDE: CDR-Telefonnummer → Kontor
  295. # --------------------------------------------------------
  296. if cdr_phone:
  297. raw_customer = await mcp.find_customer_by_phone(
  298. cdr_phone
  299. )
  300. customer_result = unwrap_tool_result(
  301. raw_customer
  302. )
  303. if customer_result:
  304. analysis["resolved"]["customer"] = (
  305. customer_result
  306. )
  307. # --------------------------------------------------------
  308. # KUNDENNUMMER: Qwen → normalisieren → Kontor validieren
  309. # --------------------------------------------------------
  310. customer_number = (
  311. analysis
  312. .get("customer", {})
  313. .get("customer_number")
  314. )
  315. if customer_number:
  316. resolution = await resolve_customer_number(
  317. mcp,
  318. str(customer_number),
  319. )
  320. analysis["resolved"]["customer_number"] = {
  321. "status": resolution.status,
  322. "value": resolution.value,
  323. "confidence": resolution.confidence,
  324. "data": resolution.data,
  325. }
  326. # --------------------------------------------------------
  327. # BESTELLNUMMER: Qwen → normalisieren → Kontor validieren
  328. # --------------------------------------------------------
  329. order_number = (
  330. analysis
  331. .get("customer", {})
  332. .get("order_number")
  333. )
  334. if order_number:
  335. resolution = await resolve_order_number(
  336. mcp,
  337. str(order_number),
  338. )
  339. analysis["resolved"]["order"] = {
  340. "status": resolution.status,
  341. "value": resolution.value,
  342. "confidence": resolution.confidence,
  343. "data": resolution.data,
  344. }
  345. # --------------------------------------------------------
  346. # PRODUKTE: Qwen → Kontor-Suche
  347. # --------------------------------------------------------
  348. for product in analysis.get("products", []):
  349. if not isinstance(product, dict):
  350. continue
  351. raw_text = (
  352. product.get("raw_text")
  353. or product.get("name")
  354. )
  355. if not raw_text:
  356. continue
  357. # Wenn bereits eine Bestellung aufgelöst wurde,
  358. # diese als zusätzlichen fachlichen Kontext verwenden.
  359. order_context = None
  360. resolved_order = analysis["resolved"].get("order")
  361. if isinstance(resolved_order, dict):
  362. order_context = resolved_order.get("data")
  363. resolution = await resolve_product_with_context(
  364. mcp,
  365. str(raw_text),
  366. order_context,
  367. )
  368. analysis["resolved"]["products"].append({
  369. "raw_text": raw_text,
  370. "status": resolution.status,
  371. "value": resolution.value,
  372. "confidence": resolution.confidence,
  373. "source": resolution.source,
  374. "candidates": resolution.candidates,
  375. "data": resolution.data,
  376. })
  377. except Exception as exc:
  378. print(
  379. "WARNUNG: MCP-Auflösung fehlgeschlagen:",
  380. repr(exc),
  381. )
  382. analysis.setdefault(
  383. "uncertainties",
  384. [],
  385. ).append(
  386. "Kontor-MCP-Auflösung nicht verfügbar"
  387. )
  388. con = sqlite3.connect(DB)
  389. con.execute("""
  390. INSERT INTO analyses (
  391. call_id,
  392. transcript_id,
  393. model,
  394. schema_version,
  395. analysis_json,
  396. status
  397. )
  398. VALUES (?, ?, ?, '1.0', ?, 'completed')
  399. """, (
  400. 0,
  401. transcript_id,
  402. "qwen3:8b",
  403. json.dumps(analysis, ensure_ascii=False),
  404. ))
  405. con.commit()
  406. con.close()
  407. print()
  408. print("========================================")
  409. print("CALL KOMPLETT VERARBEITET")
  410. print("========================================")
  411. print("transcript_id:", transcript_id)
  412. print()
  413. print(json.dumps(
  414. analysis,
  415. ensure_ascii=False,
  416. indent=2,
  417. ))
  418. if __name__ == "__main__":
  419. asyncio.run(main())