classify_historical_v2.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496
  1. #!/usr/bin/env python3
  2. import asyncio
  3. import json
  4. import sqlite3
  5. import urllib.request
  6. from collections import Counter
  7. from pathlib import Path
  8. BASE = Path(".")
  9. DB = BASE / "data/telephony.sqlite3"
  10. TAXONOMY_FILE = BASE / "config/telefonie_taxonomy.json"
  11. OUT = BASE / "data/historical_intent_v2.json"
  12. OLLAMA = "http://127.0.0.1:11434/api/generate"
  13. MODEL = "qwen3:8b"
  14. def ask_qwen(prompt):
  15. payload = {
  16. "model": MODEL,
  17. "prompt": prompt,
  18. "stream": False,
  19. "format": "json",
  20. "options": {
  21. "temperature": 0
  22. }
  23. }
  24. req = urllib.request.Request(
  25. OLLAMA,
  26. data=json.dumps(
  27. payload,
  28. ensure_ascii=False
  29. ).encode("utf-8"),
  30. headers={
  31. "Content-Type": "application/json"
  32. },
  33. method="POST"
  34. )
  35. with urllib.request.urlopen(req, timeout=300) as response:
  36. data = json.loads(
  37. response.read().decode("utf-8")
  38. )
  39. return data["response"]
  40. def parse_json(text):
  41. text = text.strip()
  42. if text.startswith("```"):
  43. text = text.replace("```json", "", 1)
  44. text = text.replace("```", "")
  45. text = text.strip()
  46. return json.loads(text)
  47. def load_json(value):
  48. try:
  49. return json.loads(value or "{}")
  50. except Exception:
  51. return {}
  52. async def main():
  53. taxonomy = json.loads(
  54. TAXONOMY_FILE.read_text()
  55. )
  56. groups = taxonomy["groups"]
  57. intents = []
  58. for values in groups.values():
  59. intents.extend(values)
  60. complaint_types = list(
  61. taxonomy["complaint_types"].keys()
  62. )
  63. call_states = taxonomy["call_states"]
  64. actions = [
  65. "KEINE_AKTION",
  66. "KUNDENKONTEXT_PRÜFEN",
  67. "BESTELLUNG_PRÜFEN",
  68. "BESTELLUNG_ÄNDERN",
  69. "BESTELLUNG_STORNIEREN",
  70. "BESTELLUNGEN_ZUSAMMENFÜHREN",
  71. "VERSANDSTATUS_PRÜFEN",
  72. "LIEFERTERMIN_PRÜFEN",
  73. "ADRESSE_ÄNDERN",
  74. "REKLAMATION_ERFASSEN",
  75. "TRANSPORTSCHADEN_ERFASSEN",
  76. "FEHLLIEFERUNG_PRÜFEN",
  77. "RECHNUNG_PRÜFEN",
  78. "RECHNUNG_KORRIGIEREN",
  79. "ZAHLUNG_PRÜFEN",
  80. "GUTSCHRIFT_ERSTELLEN",
  81. "RETOURE_ERFASSEN",
  82. "PRODUKT_IDENTIFIZIEREN",
  83. "BESTAND_PRÜFEN",
  84. "SORTEN_ALTERNATIVE_PRÜFEN",
  85. "PFLEGEHINWEIS_GEBEN",
  86. "BILD_ANFORDERN",
  87. "MENSCHLICHE_BEARBEITUNG_ERFORDERLICH",
  88. "RÜCKRUF",
  89. "E_MAIL_SENDEN",
  90. ]
  91. con = sqlite3.connect(DB)
  92. con.row_factory = sqlite3.Row
  93. rows = con.execute("""
  94. SELECT
  95. a.id AS analysis_id,
  96. a.cdr_row_id,
  97. a.analysis_json,
  98. t.transcript_json,
  99. c.source_caller_id,
  100. c.destination_caller_id,
  101. c.start_time
  102. FROM analyses a
  103. JOIN transcripts t
  104. ON t.id = a.transcript_id
  105. JOIN cdr_calls c
  106. ON c.id = a.cdr_row_id
  107. ORDER BY a.id
  108. """).fetchall()
  109. con.close()
  110. print("=" * 72)
  111. print("HISTORISCHE TELEFON-KI V2")
  112. print("=" * 72)
  113. print(f"Gespräche: {len(rows)}")
  114. print(f"Modell: {MODEL}")
  115. print(f"Taxonomie: {TAXONOMY_FILE}")
  116. results = []
  117. for number, row in enumerate(rows, 1):
  118. existing = load_json(
  119. row["analysis_json"]
  120. )
  121. transcript = row["transcript_json"] or ""
  122. old_analysis = existing.get(
  123. "analysis",
  124. {}
  125. )
  126. customer = existing.get(
  127. "customer",
  128. {}
  129. )
  130. products = existing.get(
  131. "products",
  132. []
  133. )
  134. evidence = {
  135. "existing_intent": old_analysis.get(
  136. "intent"
  137. ),
  138. "summary": old_analysis.get(
  139. "summary"
  140. ),
  141. "advice": old_analysis.get(
  142. "advice"
  143. ),
  144. "follow_up": old_analysis.get(
  145. "follow_up"
  146. ),
  147. "customer": customer,
  148. "products": products,
  149. "uncertainties": existing.get(
  150. "uncertainties",
  151. []
  152. )
  153. }
  154. print(
  155. f"[{number:02}/{len(rows):02}] "
  156. f"analysis={row['analysis_id']}"
  157. )
  158. prompt = f"""
  159. Du bist die Klassifikations-KI einer Telefon-Middleware
  160. für einen deutschen Gartenpflanzen-Webshop.
  161. Du analysierst ein bereits transkribiertes Kundentelefonat.
  162. Die zentrale Taxonomie ist verbindlich.
  163. TAXONOMIE:
  164. {json.dumps(groups, ensure_ascii=False, indent=2)}
  165. REKLAMATIONSGRÜNDE:
  166. {json.dumps(taxonomy["complaint_types"], ensure_ascii=False, indent=2)}
  167. CALL STATES:
  168. {json.dumps(call_states, ensure_ascii=False)}
  169. AKTIONEN:
  170. {json.dumps(actions, ensure_ascii=False)}
  171. WICHTIG:
  172. Ein Gespräch kann mehrere Intents enthalten.
  173. Zuerst CALL STATE bestimmen.
  174. HUMAN_CONVERSATION:
  175. Es findet ein echtes Gespräch mit verwertbarem Inhalt statt.
  176. NO_CONTENT:
  177. Kein verwertbarer Gesprächsinhalt.
  178. AUTOMATED_MESSAGE:
  179. Mailbox, automatische Ansage, Systemansage oder ähnliche Aufnahme.
  180. VOICEMAIL:
  181. Nachricht auf einer Mailbox.
  182. UNKNOWN:
  183. Inhalt vorhanden, aber nicht zuverlässig einzuordnen.
  184. Nur bei HUMAN_CONVERSATION werden geschäftliche Intents vergeben.
  185. DOKUMENTE ist ein echter Intent.
  186. Ein Anliegen zu Kaufvertrag, Rechnungskopie, Dokumentenkopie,
  187. Unterlagen usw. darf NICHT SONSTIGES sein.
  188. REKLAMATION ist ein echter Hauptintent.
  189. Wenn eine Reklamation vorliegt, prüfe zusätzlich
  190. complaint_type.
  191. Mögliche complaint_type:
  192. {json.dumps(complaint_types, ensure_ascii=False)}
  193. Beispiele:
  194. "Die Erde war verunreinigt."
  195. → primary_intent = REKLAMATION
  196. → complaint_type = PRODUKTQUALITÄT
  197. "Der Dünger war nicht dabei."
  198. → primary_intent = REKLAMATION
  199. → complaint_type = DUENGER_FEHLT
  200. "Die Pflanzen waren schlecht verpackt."
  201. → primary_intent = REKLAMATION
  202. → complaint_type = SCHLECHTVERPACKT
  203. "Ich habe eine andere Rosensorte bekommen."
  204. → primary_intent = REKLAMATION
  205. → complaint_type = FALSCHESORTEGELIEFERT
  206. "Die Pflanze wurde auf dem Transport beschädigt."
  207. → primary_intent = REKLAMATION
  208. → complaint_type = TRANSPORTSCHADEN
  209. "Der falsche Artikel wurde geliefert."
  210. → primary_intent = REKLAMATION
  211. → complaint_type = FEHLLIEFERUNG
  212. MEHRERE INTENTS:
  213. Wenn ein Gespräch mehrere Anliegen enthält,
  214. müssen diese getrennt werden.
  215. Beispiel:
  216. Bestellung ändern + Versandstatus prüfen
  217. primary_intent:
  218. BESTELLÄNDERUNG
  219. secondary_intents:
  220. ["VERSANDSTATUS"]
  221. Beispiel:
  222. Rechnung falsch + Gutschrift
  223. primary_intent:
  224. RECHNUNG
  225. actions:
  226. ["RECHNUNG_PRÜFEN", "RECHNUNG_KORRIGIEREN",
  227. "GUTSCHRIFT_ERSTELLEN"]
  228. SONSTIGES IST DER LETZTE FALLBACK.
  229. Wenn eine passende Kategorie existiert,
  230. darf SONSTIGES NICHT verwendet werden.
  231. BESTEHENDE QWEN-ANALYSE:
  232. {json.dumps(evidence, ensure_ascii=False, indent=2)}
  233. TRANSKRIPT:
  234. {transcript}
  235. Antworte ausschließlich mit diesem JSON:
  236. {{
  237. "call_state": "HUMAN_CONVERSATION",
  238. "primary_intent": "INTENT",
  239. "secondary_intents": [],
  240. "complaint_type": null,
  241. "customer_goal": "",
  242. "actions": [],
  243. "requires_customer_context": false,
  244. "requires_order_context": false,
  245. "requires_product_context": false,
  246. "requires_previous_contact_context": false,
  247. "requires_human_action": false,
  248. "confidence": 0.0,
  249. "reason": ""
  250. }}
  251. """
  252. try:
  253. raw = await asyncio.to_thread(
  254. ask_qwen,
  255. prompt
  256. )
  257. result = parse_json(raw)
  258. except Exception as exc:
  259. result = {
  260. "call_state": "UNKNOWN",
  261. "primary_intent": "SONSTIGES",
  262. "secondary_intents": [],
  263. "complaint_type": None,
  264. "customer_goal": "",
  265. "actions": [
  266. "MENSCHLICHE_BEARBEITUNG_ERFORDERLICH"
  267. ],
  268. "requires_human_action": True,
  269. "confidence": 0,
  270. "reason": str(exc)
  271. }
  272. if result.get("call_state") not in call_states:
  273. result["call_state"] = "UNKNOWN"
  274. if result.get("primary_intent") not in intents:
  275. result["primary_intent"] = "SONSTIGES"
  276. result["secondary_intents"] = [
  277. x
  278. for x in result.get(
  279. "secondary_intents",
  280. []
  281. )
  282. if x in intents
  283. and x != result["primary_intent"]
  284. ]
  285. complaint = result.get(
  286. "complaint_type"
  287. )
  288. if complaint not in complaint_types:
  289. result["complaint_type"] = None
  290. result["actions"] = [
  291. x
  292. for x in result.get(
  293. "actions",
  294. []
  295. )
  296. if x in actions
  297. ]
  298. result["_analysis_id"] = row[
  299. "analysis_id"
  300. ]
  301. result["_cdr_row_id"] = row[
  302. "cdr_row_id"
  303. ]
  304. result["_start_time"] = row[
  305. "start_time"
  306. ]
  307. results.append(result)
  308. primary = Counter()
  309. secondary = Counter()
  310. complaints = Counter()
  311. states = Counter()
  312. action_counts = Counter()
  313. for result in results:
  314. states[
  315. result["call_state"]
  316. ] += 1
  317. if result["call_state"] == "HUMAN_CONVERSATION":
  318. primary[
  319. result["primary_intent"]
  320. ] += 1
  321. secondary.update(
  322. result["secondary_intents"]
  323. )
  324. if result.get(
  325. "complaint_type"
  326. ):
  327. complaints[
  328. result["complaint_type"]
  329. ] += 1
  330. action_counts.update(
  331. result["actions"]
  332. )
  333. output = {
  334. "version": taxonomy["version"],
  335. "model": MODEL,
  336. "taxonomy_file": str(
  337. TAXONOMY_FILE
  338. ),
  339. "count": len(results),
  340. "call_states": dict(
  341. states.most_common()
  342. ),
  343. "primary_intents": dict(
  344. primary.most_common()
  345. ),
  346. "secondary_intents": dict(
  347. secondary.most_common()
  348. ),
  349. "complaint_types": dict(
  350. complaints.most_common()
  351. ),
  352. "actions": dict(
  353. action_counts.most_common()
  354. ),
  355. "calls": results
  356. }
  357. OUT.write_text(
  358. json.dumps(
  359. output,
  360. ensure_ascii=False,
  361. indent=2
  362. )
  363. )
  364. print()
  365. print("=" * 72)
  366. print("ERGEBNIS")
  367. print("=" * 72)
  368. print("\nCALL STATES")
  369. for k, v in states.most_common():
  370. print(f"{v:3} {k}")
  371. print("\nPRIMARY INTENTS")
  372. for k, v in primary.most_common():
  373. print(f"{v:3} {k}")
  374. print("\nSECONDARY INTENTS")
  375. for k, v in secondary.most_common():
  376. print(f"{v:3} {k}")
  377. print("\nREKLAMATIONSGRÜNDE")
  378. for k, v in complaints.most_common():
  379. print(f"{v:3} {k}")
  380. print("\nAKTIONEN")
  381. for k, v in action_counts.most_common():
  382. print(f"{v:3} {k}")
  383. print()
  384. print(f"Gespeichert: {OUT}")
  385. if __name__ == "__main__":
  386. asyncio.run(main())