classify_historical_multi_intent.py 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  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. DB = "data/telephony.sqlite3"
  9. OUT = Path("data/historical_multi_intent.json")
  10. OLLAMA = "http://127.0.0.1:11434/api/generate"
  11. MODEL = "qwen3:8b"
  12. INTENTS = [
  13. "VERSANDSTATUS",
  14. "LIEFERVERZUG",
  15. "ADRESSÄNDERUNG",
  16. "REKLAMATION",
  17. "TRANSPORTSCHADEN",
  18. "FEHLLIEFERUNG",
  19. "RECHNUNG",
  20. "ZAHLUNG",
  21. "WIDERRUF",
  22. "RETOURE",
  23. "PFLANZENBERATUNG",
  24. "SORTENBERATUNG",
  25. "PFLEGEFRAGE",
  26. "BESTANDSANFRAGE",
  27. "VORBESTELLUNG",
  28. "BESTELLUNG",
  29. "BESTELLÄNDERUNG",
  30. "STORNIERUNG",
  31. "B2B",
  32. "GROSSHANDEL",
  33. "SONSTIGES",
  34. ]
  35. ACTIONS = [
  36. "KEINE_AKTION",
  37. "KUNDENKONTEXT_PRÜFEN",
  38. "BESTELLUNG_PRÜFEN",
  39. "BESTELLUNG_ÄNDERN",
  40. "BESTELLUNG_STORNIEREN",
  41. "BESTELLUNGEN_ZUSAMMENFÜHREN",
  42. "VERSANDSTATUS_PRÜFEN",
  43. "LIEFERTERMIN_PRÜFEN",
  44. "ADRESSE_ÄNDERN",
  45. "REKLAMATION_ERFASSEN",
  46. "TRANSPORTSCHADEN_ERFASSEN",
  47. "FEHLLIEFERUNG_PRÜFEN",
  48. "RECHNUNG_PRÜFEN",
  49. "RECHNUNG_KORRIGIEREN",
  50. "ZAHLUNG_PRÜFEN",
  51. "GUTSCHRIFT_ERSTELLEN",
  52. "RETOURE_ERFASSEN",
  53. "PRODUKT_IDENTIFIZIEREN",
  54. "BESTAND_PRÜFEN",
  55. "SORTEN_ALTERNATIVE_PRÜFEN",
  56. "PFLEGEHINWEIS_GEBEN",
  57. "BILD_ANFORDERN",
  58. "MENSCHLICHE_BEARBEITUNG_ERFORDERLICH",
  59. "RÜCKRUF",
  60. "E-MAIL_SENDEN",
  61. ]
  62. def load_json(value):
  63. try:
  64. return json.loads(value or "{}")
  65. except Exception:
  66. return {}
  67. def ask_qwen(prompt):
  68. payload = {
  69. "model": MODEL,
  70. "prompt": prompt,
  71. "stream": False,
  72. "format": "json",
  73. "options": {"temperature": 0},
  74. }
  75. req = urllib.request.Request(
  76. OLLAMA,
  77. data=json.dumps(payload).encode(),
  78. headers={"Content-Type": "application/json"},
  79. method="POST",
  80. )
  81. with urllib.request.urlopen(req, timeout=300) as r:
  82. return json.loads(r.read().decode()).get("response", "")
  83. def parse_json(text):
  84. text = text.strip()
  85. if text.startswith("```"):
  86. text = text.replace("```json", "", 1)
  87. text = text.replace("```", "").strip()
  88. return json.loads(text)
  89. async def main():
  90. con = sqlite3.connect(DB)
  91. con.row_factory = sqlite3.Row
  92. rows = con.execute("""
  93. SELECT
  94. a.id AS analysis_id,
  95. a.analysis_json,
  96. t.transcript_json,
  97. c.source_caller_id,
  98. c.destination_caller_id,
  99. c.start_time
  100. FROM analyses a
  101. JOIN transcripts t ON t.id = a.transcript_id
  102. JOIN cdr_calls c ON c.id = a.cdr_row_id
  103. WHERE a.cdr_row_id IS NOT NULL
  104. ORDER BY a.id
  105. """).fetchall()
  106. con.close()
  107. print(f"Historische Gespräche: {len(rows)}")
  108. results = []
  109. for n, row in enumerate(rows, 1):
  110. existing = load_json(row["analysis_json"])
  111. transcript = row["transcript_json"] or ""
  112. print(
  113. f"[{n}/{len(rows)}] "
  114. f"analysis={row['analysis_id']}"
  115. )
  116. prompt = f"""
  117. Du analysierst ein echtes deutsches Kundentelefonat eines
  118. Gartenpflanzen-Webshops für eine Telefon-KI.
  119. WICHTIG:
  120. Ein Gespräch kann MEHRERE Anliegen enthalten.
  121. ZUERST muss der Gesprächszustand bestimmt werden:
  122. - HUMAN_CONVERSATION:
  123. tatsächliches Gespräch mit einem Kunden/Mitarbeiter und verwertbarem Inhalt
  124. - NO_CONTENT:
  125. leeres/fehlerhaftes Transkript, keine verwertbaren Gesprächsinhalte
  126. - VOICEMAIL:
  127. automatische Mailbox/Ansage ohne echtes Kundengespräch
  128. - UNKNOWN:
  129. Inhalt vorhanden, aber nicht zuverlässig klassifizierbar
  130. Nur bei HUMAN_CONVERSATION werden Intents klassifiziert.
  131. Ermittle:
  132. 1. call_state
  133. 2. primary_intent = wichtigstes tatsächliches Anliegen
  134. 3. secondary_intents = ALLE weiteren tatsächlich behandelten Anliegen
  135. 4. entities = konkret genannte Bestellungen und Produkte
  136. 5. actions = tatsächlich notwendige oder vereinbarte Aktionen
  137. SEHR WICHTIG:
  138. SONSTIGES ist der LETZTE FALLBACK.
  139. Wenn das Gespräch beispielsweise:
  140. - eine Pflegefrage enthält → PFLEGEFRAGE
  141. - eine Rechnungskorrektur enthält → RECHNUNG
  142. - eine Beanstandung enthält → REKLAMATION
  143. - eine beschädigte Lieferung enthält → TRANSPORTSCHADEN
  144. - eine Bestellung verändert → BESTELLÄNDERUNG
  145. - mehrere Aufträge/Bestellungen behandelt → BESTELLUNG bzw. BESTELLÄNDERUNG
  146. - Verfügbarkeit eines Artikels behandelt → BESTANDSANFRAGE
  147. darf NICHT SONSTIGES gewählt werden.
  148. Wenn mehrere dieser Themen vorkommen, müssen sie als
  149. primary_intent + secondary_intents abgebildet werden.
  150. Beispiel:
  151. Bestelländerung + Versandstatus + Lieferproblem
  152. → primary_intent = BESTELLÄNDERUNG
  153. → secondary_intents = [VERSANDSTATUS, LIEFERVERZUG]
  154. Beispiel:
  155. Rechnung falsch + Gutschrift
  156. → primary_intent = RECHNUNG
  157. → secondary_intents = [REKLAMATION]
  158. Beispiel:
  159. Beschädigte Pflanzen + Qualitätsbeanstandung
  160. → primary_intent = REKLAMATION
  161. → secondary_intents = [TRANSPORTSCHADEN]
  162. SONSTIGES darf nur verwendet werden, wenn nach Prüfung
  163. aller definierten Kategorien wirklich keine passt.
  164. Niemals mehrere Anliegen in einen einzigen Intent quetschen.
  165. INTENTS:
  166. {json.dumps(INTENTS, ensure_ascii=False)}
  167. AKTIONEN:
  168. {json.dumps(ACTIONS, ensure_ascii=False)}
  169. Regeln:
  170. - Nur tatsächlich aus dem Gespräch ableiten.
  171. - Nicht aus Vermutungen ergänzen.
  172. - BESTELLUNG verwenden, wenn eine Bestellung aufgegeben,
  173. ergänzt oder konkret bearbeitet wird.
  174. - BESTELLÄNDERUNG verwenden, wenn eine bestehende Bestellung
  175. verändert wird.
  176. - STORNIERUNG verwenden, wenn eine Bestellung aufgehoben werden soll.
  177. - VERSANDSTATUS und LIEFERVERZUG unterscheiden.
  178. - REKLAMATION ist eine Beanstandung.
  179. - TRANSPORTSCHADEN ist eine durch Transport verursachte Beschädigung.
  180. - PFLANZENBERATUNG = allgemeine Beratung.
  181. - SORTENBERATUNG = Auswahl zwischen konkreten Sorten.
  182. - PFLEGEFRAGE = Pflege/Gesundheit/Schnitt/Düngung etc.
  183. - BESTANDSANFRAGE = Verfügbarkeit.
  184. - SONSTIGES nur, wenn keine andere Kategorie passt.
  185. Ein bereits vorhandenes Qwen-Ergebnis ist nur Hilfskontext.
  186. Das Transkript ist maßgeblich.
  187. BESTEHENDE ANALYSE:
  188. {json.dumps(existing, ensure_ascii=False, indent=2)}
  189. TRANSKRIPT:
  190. {transcript}
  191. Antworte ausschließlich als JSON:
  192. {{
  193. "call_state": "HUMAN_CONVERSATION",
  194. "primary_intent": "INTENT",
  195. "secondary_intents": [],
  196. "customer_goal": "",
  197. "entities": {{
  198. "orders": [],
  199. "products": []
  200. }},
  201. "actions": [],
  202. "requires_customer_context": true,
  203. "requires_order_context": false,
  204. "requires_product_context": false,
  205. "requires_previous_contact_context": false,
  206. "requires_human_action": false,
  207. "confidence": 0.0,
  208. "reason": ""
  209. }}
  210. """
  211. try:
  212. raw = await asyncio.to_thread(ask_qwen, prompt)
  213. result = parse_json(raw)
  214. except Exception as exc:
  215. print(f" FEHLER: {exc}")
  216. result = {
  217. "primary_intent": "SONSTIGES",
  218. "secondary_intents": [],
  219. "customer_goal": "",
  220. "entities": {"orders": [], "products": []},
  221. "actions": ["MENSCHLICHE_BEARBEITUNG_ERFORDERLICH"],
  222. "requires_customer_context": True,
  223. "requires_order_context": False,
  224. "requires_product_context": False,
  225. "requires_previous_contact_context": False,
  226. "requires_human_action": True,
  227. "confidence": 0,
  228. "reason": str(exc),
  229. }
  230. if result.get("primary_intent") not in INTENTS:
  231. result["primary_intent"] = "SONSTIGES"
  232. result["secondary_intents"] = [
  233. x for x in result.get("secondary_intents", [])
  234. if x in INTENTS and x != result["primary_intent"]
  235. ]
  236. result["actions"] = [
  237. x for x in result.get("actions", [])
  238. if x in ACTIONS
  239. ]
  240. result["_analysis_id"] = row["analysis_id"]
  241. result["_cdr_row_id"] = row["analysis_id"]
  242. result["_start_time"] = row["start_time"]
  243. results.append(result)
  244. primary = Counter()
  245. secondary = Counter()
  246. actions = Counter()
  247. for r in results:
  248. primary[r["primary_intent"]] += 1
  249. secondary.update(r["secondary_intents"])
  250. actions.update(r["actions"])
  251. output = {
  252. "model": MODEL,
  253. "count": len(results),
  254. "primary_intents": dict(primary.most_common()),
  255. "secondary_intents": dict(secondary.most_common()),
  256. "actions": dict(actions.most_common()),
  257. "calls": results,
  258. }
  259. OUT.write_text(
  260. json.dumps(output, ensure_ascii=False, indent=2)
  261. )
  262. print("\n" + "=" * 72)
  263. print("MULTI-INTENT ANALYSE FERTIG")
  264. print("=" * 72)
  265. print("\nPRIMARY INTENTS")
  266. for k, v in primary.most_common():
  267. print(f"{v:3} {k}")
  268. print("\nSECONDARY INTENTS")
  269. for k, v in secondary.most_common():
  270. print(f"{v:3} {k}")
  271. print("\nAKTIONEN")
  272. for k, v in actions.most_common():
  273. print(f"{v:3} {k}")
  274. print(f"\nGespeichert: {OUT}")
  275. if __name__ == "__main__":
  276. asyncio.run(main())