classify_historical_intents.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306
  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_intent_classification.json")
  10. OLLAMA = "http://127.0.0.1:11434/api/generate"
  11. MODEL = "qwen3:8b"
  12. TAXONOMY = [
  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. "B2B",
  29. "GROSSHANDEL",
  30. "SONSTIGES",
  31. ]
  32. def load_json(value):
  33. try:
  34. return json.loads(value or "{}")
  35. except Exception:
  36. return {}
  37. def ollama_generate(prompt):
  38. payload = {
  39. "model": MODEL,
  40. "prompt": prompt,
  41. "stream": False,
  42. "format": "json",
  43. "options": {
  44. "temperature": 0
  45. },
  46. }
  47. request = urllib.request.Request(
  48. OLLAMA,
  49. data=json.dumps(payload).encode("utf-8"),
  50. headers={"Content-Type": "application/json"},
  51. method="POST",
  52. )
  53. with urllib.request.urlopen(request, timeout=300) as response:
  54. data = json.loads(response.read().decode("utf-8"))
  55. return data.get("response", "")
  56. def parse_model_json(text):
  57. text = text.strip()
  58. if text.startswith("```"):
  59. text = text.replace("```json", "", 1)
  60. text = text.replace("```", "")
  61. text = text.strip()
  62. return json.loads(text)
  63. async def main():
  64. con = sqlite3.connect(DB)
  65. con.row_factory = sqlite3.Row
  66. rows = con.execute("""
  67. SELECT
  68. a.id AS analysis_id,
  69. a.analysis_json,
  70. t.transcript_json,
  71. c.source_caller_id,
  72. c.destination_caller_id,
  73. c.start_time
  74. FROM analyses a
  75. JOIN transcripts t
  76. ON t.id = a.transcript_id
  77. JOIN cdr_calls c
  78. ON c.id = a.cdr_row_id
  79. WHERE a.cdr_row_id IS NOT NULL
  80. ORDER BY a.id
  81. """).fetchall()
  82. con.close()
  83. print(f"Historische Gespräche: {len(rows)}")
  84. results = []
  85. for index, row in enumerate(rows, 1):
  86. analysis = load_json(row["analysis_json"])
  87. transcript = row["transcript_json"] or ""
  88. print(
  89. f"[{index}/{len(rows)}] "
  90. f"analysis={row['analysis_id']} "
  91. f"→ Qwen"
  92. )
  93. prompt = f"""
  94. Du klassifizierst ein reales deutsches Kundentelefonat
  95. für einen Gartenpflanzen-Webshop.
  96. Wir entwickeln daraus eine Telefon-KI.
  97. Die Klassifikation muss deshalb konservativ und praxisnah sein.
  98. Wähle genau EINEN primary_intent aus dieser Taxonomie:
  99. {json.dumps(TAXONOMY, ensure_ascii=False)}
  100. Regeln:
  101. VERSANDSTATUS:
  102. Der Kunde fragt nach dem aktuellen Versand-/Paketstatus.
  103. LIEFERVERZUG:
  104. Eine erwartete Lieferung ist überfällig oder verspätet.
  105. ADRESSÄNDERUNG:
  106. Änderung einer Liefer-, Rechnungs- oder Kontaktadresse.
  107. REKLAMATION:
  108. Beanstandung eines gelieferten Produkts oder einer Leistung.
  109. TRANSPORTSCHADEN:
  110. Produkt wurde beim Transport beschädigt.
  111. FEHLLIEFERUNG:
  112. Es wurde ein falscher Artikel geliefert.
  113. RECHNUNG:
  114. Frage zur Rechnung, Rechnungsstellung oder Rechnungsdaten.
  115. ZAHLUNG:
  116. Zahlungsstatus, Zahlungsart, Zahlungseingang oder Zahlungsproblem.
  117. WIDERRUF:
  118. Widerruf eines Kaufs.
  119. RETOURE:
  120. Rückgabe/Rücksendung eines Artikels.
  121. PFLANZENBERATUNG:
  122. Allgemeine Beratung zu Pflanzen, Standort, Pflanzung oder Auswahl.
  123. SORTENBERATUNG:
  124. Konkrete Beratung zur Auswahl oder Eignung einer Sorte.
  125. PFLEGEFRAGE:
  126. Pflege, Schnitt, Düngung, Krankheiten oder Überwinterung.
  127. BESTANDSANFRAGE:
  128. Frage, ob eine konkrete Pflanze/ein Produkt verfügbar ist.
  129. VORBESTELLUNG:
  130. Vorbestellung bzw. Reservierung eines noch nicht verfügbaren Artikels.
  131. B2B:
  132. Geschäftskunde mit individuellem gewerblichen Anliegen.
  133. GROSSHANDEL:
  134. Gewerblicher Großhandel bzw. größere Mengen / Wiederverkauf.
  135. SONSTIGES:
  136. Wenn keine Kategorie ausreichend passt.
  137. WICHTIG:
  138. - Nicht aus Kundennummern, Telefonnummern oder Produktnamen raten.
  139. - Das tatsächliche Gespräch ist maßgeblich.
  140. - Bei mehreren Themen ist das Hauptanliegen des Anrufers primary_intent.
  141. - Weitere eindeutig vorhandene Anliegen gehören in secondary_intents.
  142. - Keine Kategorie nur deshalb wählen, weil sie in der bestehenden Qwen-Analyse steht.
  143. - Die bestehende Analyse ist nur Hilfskontext.
  144. - Der vollständige Transkripttext ist maßgeblich.
  145. Bestehende Qwen-Analyse:
  146. {json.dumps(analysis, ensure_ascii=False, indent=2)}
  147. Transkript:
  148. {transcript}
  149. Antworte ausschließlich als JSON:
  150. {{
  151. "primary_intent": "EIN_WERT_AUS_DER_TAXONOMIE",
  152. "secondary_intents": [],
  153. "customer_goal": "kurze präzise Beschreibung",
  154. "requires_customer_context": true,
  155. "requires_order_context": false,
  156. "requires_product_context": false,
  157. "requires_previous_contact_context": false,
  158. "requires_human_action": false,
  159. "confidence": 0.0,
  160. "reason": "kurze Begründung"
  161. }}
  162. """
  163. try:
  164. response = await asyncio.to_thread(
  165. ollama_generate,
  166. prompt,
  167. )
  168. classification = parse_model_json(response)
  169. except Exception as exc:
  170. print(f" FEHLER: {exc}")
  171. classification = {
  172. "primary_intent": "SONSTIGES",
  173. "secondary_intents": [],
  174. "customer_goal": None,
  175. "requires_customer_context": True,
  176. "requires_order_context": False,
  177. "requires_product_context": False,
  178. "requires_previous_contact_context": False,
  179. "requires_human_action": True,
  180. "confidence": 0,
  181. "reason": f"LLM error: {exc}",
  182. }
  183. if classification.get("primary_intent") not in TAXONOMY:
  184. classification["primary_intent"] = "SONSTIGES"
  185. classification["_analysis_id"] = row["analysis_id"]
  186. classification["_cdr_row_id"] = row["analysis_id"]
  187. classification["_rec_id"] = None
  188. classification["_start_time"] = row["start_time"]
  189. results.append(classification)
  190. intent_counts = Counter(
  191. r["primary_intent"]
  192. for r in results
  193. )
  194. context_counts = Counter()
  195. for r in results:
  196. for field in (
  197. "requires_customer_context",
  198. "requires_order_context",
  199. "requires_product_context",
  200. "requires_previous_contact_context",
  201. "requires_human_action",
  202. ):
  203. if r.get(field):
  204. context_counts[field] += 1
  205. output = {
  206. "model": MODEL,
  207. "count": len(results),
  208. "taxonomy": TAXONOMY,
  209. "intent_counts": dict(intent_counts.most_common()),
  210. "context_requirements": dict(context_counts),
  211. "calls": results,
  212. }
  213. OUT.write_text(
  214. json.dumps(
  215. output,
  216. ensure_ascii=False,
  217. indent=2,
  218. )
  219. )
  220. print()
  221. print("=" * 70)
  222. print("HISTORISCHE INTENT-KLASSIFIKATION FERTIG")
  223. print("=" * 70)
  224. print("\nINTENTS:")
  225. for intent, count in intent_counts.most_common():
  226. print(f"{count:3} {intent}")
  227. print("\nKONTEXTBEDARF:")
  228. for key, count in context_counts.most_common():
  229. print(f"{count:3} {key}")
  230. print(f"\nErgebnis: {OUT}")
  231. if __name__ == "__main__":
  232. asyncio.run(main())