classify_zammad_cases.py 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. #!/usr/bin/env python3
  2. import json
  3. import re
  4. import sqlite3
  5. import subprocess
  6. import time
  7. DB = "data/zammad_analysis.sqlite3"
  8. MODEL = "qwen3:8b"
  9. TAXONOMY = """
  10. VERSANDSTATUS
  11. LIEFERVERZUG
  12. ADRESSÄNDERUNG
  13. REKLAMATION
  14. TRANSPORTSCHADEN
  15. FEHLLIEFERUNG
  16. RECHNUNG
  17. ZAHLUNG
  18. WIDERRUF
  19. RETOURE
  20. PFLANZENBERATUNG
  21. SORTENBERATUNG
  22. PFLEGEFRAGE
  23. BESTANDSANFRAGE
  24. VORBESTELLUNG
  25. B2B
  26. GROSSHANDEL
  27. DÜNGER_FEHLT
  28. SONSTIGES
  29. Regeln:
  30. - PRODUKTQUALITÄT gehört unter REKLAMATION oder PFLANZENBERATUNG.
  31. - SCHLECHTVERPACKT gehört unter REKLAMATION bzw. TRANSPORTSCHADEN, wenn ein Transportschaden vorliegt.
  32. - FALSCHESORTEGELIEFERT gehört unter FEHLLIEFERUNG.
  33. - DÜNGER_FEHLT ist ein eigener wichtiger Intent.
  34. - Mehrere gleichzeitig vorhandene Anliegen müssen als Multi-Intent erfasst werden.
  35. - primary_intent ist das wichtigste Kundenanliegen.
  36. - secondary_intents enthält weitere relevante Anliegen.
  37. - Erfinde keinen neuen Primärintent.
  38. - Wenn kein bestehender Intent passt, nutze SONSTIGES und schlage taxonomy_candidate vor.
  39. """
  40. SYSTEM = f"""
  41. Du analysierst historische Kundenservice-Tickets von Schmid Gartenpflanzen.
  42. Bewerte das tatsächliche Kundenanliegen, nicht nur die Antwort des Mitarbeiters.
  43. Bestehende Taxonomie:
  44. {TAXONOMY}
  45. Gib ausschließlich gültiges JSON zurück:
  46. {{
  47. "primary_intent": "...",
  48. "secondary_intents": [],
  49. "complaint_type": null,
  50. "actions": [],
  51. "summary": "...",
  52. "taxonomy_fit": "GOOD|PARTIAL|POOR",
  53. "taxonomy_candidate": null,
  54. "confidence": 0.0
  55. }}
  56. Bei REKLAMATION oder FEHLLIEFERUNG soll complaint_type möglichst konkret sein.
  57. actions beschreibt, was der Kundenservice getan oder als Lösung angeboten hat.
  58. """
  59. def parse_json(text):
  60. text = text.strip()
  61. # Markdown-Codeblock entfernen
  62. text = re.sub(
  63. r"^```(?:json)?\s*",
  64. "",
  65. text,
  66. flags=re.I,
  67. )
  68. text = re.sub(
  69. r"\s*```$",
  70. "",
  71. text,
  72. )
  73. start = text.find("{")
  74. end = text.rfind("}")
  75. if start < 0 or end <= start:
  76. raise ValueError("Kein JSON gefunden")
  77. return json.loads(
  78. text[start:end + 1]
  79. )
  80. def run_qwen(case_text):
  81. prompt = f"""
  82. {SYSTEM}
  83. TICKET:
  84. {case_text}
  85. """
  86. result = subprocess.run(
  87. [
  88. "ollama",
  89. "run",
  90. MODEL,
  91. prompt,
  92. ],
  93. capture_output=True,
  94. text=True,
  95. timeout=600,
  96. )
  97. if result.returncode != 0:
  98. raise RuntimeError(
  99. result.stderr[-2000:]
  100. )
  101. return parse_json(result.stdout)
  102. db = sqlite3.connect(DB)
  103. db.row_factory = sqlite3.Row
  104. rows = db.execute("""
  105. SELECT
  106. ticket_id,
  107. ticket_number,
  108. title,
  109. customer_text,
  110. agent_text,
  111. unknown_text
  112. FROM cases
  113. WHERE classification_status = 'NEW'
  114. ORDER BY ticket_id
  115. """).fetchall()
  116. print("=" * 72)
  117. print("ZAMMAD TAXONOMIE – QWEN")
  118. print("=" * 72)
  119. print(f"Cases offen: {len(rows)}")
  120. print(f"Modell: {MODEL}")
  121. print("Worker: 1")
  122. print()
  123. started = time.time()
  124. done = 0
  125. errors = 0
  126. for pos, row in enumerate(rows, 1):
  127. case_text = f"""
  128. Ticket #{row['ticket_number']}
  129. Betreff: {row['title'] or ''}
  130. KUNDENBEITRÄGE:
  131. {row['customer_text'] or ''}
  132. ANTWORTEN DES KUNDENSERVICE:
  133. {row['agent_text'] or ''}
  134. SONSTIGE FACHLICHE SYSTEMANTWORTEN:
  135. {row['unknown_text'] or ''}
  136. """
  137. try:
  138. result = run_qwen(case_text)
  139. db.execute("""
  140. UPDATE cases
  141. SET classification_status = 'CLASSIFIED',
  142. primary_intent = ?,
  143. secondary_intents = ?,
  144. complaint_type = ?,
  145. actions = ?,
  146. summary = ?,
  147. taxonomy_fit = ?,
  148. taxonomy_candidate = ?,
  149. classification_json = ?
  150. WHERE ticket_id = ?
  151. """, (
  152. result.get("primary_intent"),
  153. json.dumps(
  154. result.get("secondary_intents", []),
  155. ensure_ascii=False,
  156. ),
  157. result.get("complaint_type"),
  158. json.dumps(
  159. result.get("actions", []),
  160. ensure_ascii=False,
  161. ),
  162. result.get("summary"),
  163. result.get("taxonomy_fit"),
  164. result.get("taxonomy_candidate"),
  165. json.dumps(
  166. result,
  167. ensure_ascii=False,
  168. ),
  169. row["ticket_id"],
  170. ))
  171. db.commit()
  172. done += 1
  173. elapsed = time.time() - started
  174. rate = done / elapsed if elapsed else 0
  175. remaining = (
  176. (len(rows) - done) / rate
  177. if rate
  178. else 0
  179. )
  180. print(
  181. f"[{pos}/{len(rows)}] "
  182. f"#{row['ticket_number']} "
  183. f"→ {result.get('primary_intent')} "
  184. f"({result.get('confidence', 0)}) "
  185. f"| noch ~{remaining / 60:.0f} min",
  186. flush=True,
  187. )
  188. except Exception as exc:
  189. errors += 1
  190. db.execute("""
  191. UPDATE cases
  192. SET classification_status = 'ERROR'
  193. WHERE ticket_id = ?
  194. """, (row["ticket_id"],))
  195. db.commit()
  196. print(
  197. f"[{pos}/{len(rows)}] "
  198. f"#{row['ticket_number']} "
  199. f"ERROR: {exc}",
  200. flush=True,
  201. )
  202. print()
  203. print("=" * 72)
  204. print("QWEN-LAUF BEENDET")
  205. print("=" * 72)
  206. print(f"Erfolgreich: {done}")
  207. print(f"Fehler: {errors}")
  208. for status, count in db.execute("""
  209. SELECT classification_status, COUNT(*)
  210. FROM cases
  211. GROUP BY classification_status
  212. ORDER BY classification_status
  213. """):
  214. print(f"{status:20} {count}")
  215. db.close()