zammad_taxonomy_scan.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546
  1. #!/usr/bin/env python3
  2. import json
  3. import os
  4. import re
  5. import sqlite3
  6. import urllib.request
  7. from collections import Counter, defaultdict
  8. from html import unescape
  9. from pathlib import Path
  10. BASE = Path(".")
  11. ENV = BASE / ".env"
  12. DB = BASE / "data/zammad_history.sqlite3"
  13. TAXONOMY = BASE / "config/telefonie_taxonomy.json"
  14. OUT = BASE / "data/zammad_taxonomy_scan.json"
  15. OLLAMA = "http://127.0.0.1:11434/api/generate"
  16. MODEL = "qwen3:8b"
  17. def load_env():
  18. for line in ENV.read_text().splitlines():
  19. line = line.strip()
  20. if not line or line.startswith("#") or "=" not in line:
  21. continue
  22. key, value = line.split("=", 1)
  23. os.environ[key] = value.strip().strip('"').strip("'")
  24. def clean_text(text):
  25. if not text:
  26. return ""
  27. text = re.sub(
  28. r"<(script|style).*?</\1>",
  29. " ",
  30. text,
  31. flags=re.I | re.S,
  32. )
  33. text = re.sub(r"<br\s*/?>", "\n", text, flags=re.I)
  34. text = re.sub(r"</p\s*>", "\n", text, flags=re.I)
  35. text = re.sub(r"</div\s*>", "\n", text, flags=re.I)
  36. text = re.sub(r"<[^>]+>", " ", text)
  37. text = unescape(text)
  38. # E-Mail-Zitate reduzieren.
  39. text = re.sub(
  40. r"\n\s*(Am .* schrieb .*:|On .* wrote:).*",
  41. "",
  42. text,
  43. flags=re.I | re.S,
  44. )
  45. # Signaturen grob reduzieren.
  46. text = re.sub(
  47. r"\n\s*(Mit freundlichen Grüßen|Viele Grüße|Beste Grüße).*",
  48. "",
  49. text,
  50. flags=re.I | re.S,
  51. )
  52. text = re.sub(r"[ \t]+", " ", text)
  53. text = re.sub(r"\n{3,}", "\n\n", text)
  54. return text.strip()
  55. def qwen(prompt):
  56. payload = {
  57. "model": MODEL,
  58. "prompt": prompt,
  59. "stream": False,
  60. "format": "json",
  61. "options": {
  62. "temperature": 0
  63. },
  64. }
  65. req = urllib.request.Request(
  66. OLLAMA,
  67. data=json.dumps(
  68. payload,
  69. ensure_ascii=False,
  70. ).encode(),
  71. headers={
  72. "Content-Type": "application/json"
  73. },
  74. method="POST",
  75. )
  76. with urllib.request.urlopen(
  77. req,
  78. timeout=600,
  79. ) as response:
  80. return json.loads(
  81. response.read().decode()
  82. )["response"]
  83. def parse_json(text):
  84. text = text.strip()
  85. if text.startswith("```"):
  86. text = re.sub(
  87. r"^```(?:json)?",
  88. "",
  89. text,
  90. )
  91. text = re.sub(
  92. r"```$",
  93. "",
  94. text,
  95. )
  96. return json.loads(text.strip())
  97. def build_taxonomy_text(taxonomy):
  98. result = []
  99. for group, intents in taxonomy["groups"].items():
  100. result.append(
  101. f"{group}: {', '.join(intents)}"
  102. )
  103. result.append(
  104. "\nREKLAMATIONSGRÜNDE:"
  105. )
  106. for key, description in taxonomy[
  107. "complaint_types"
  108. ].items():
  109. result.append(
  110. f"- {key}: {description}"
  111. )
  112. return "\n".join(result)
  113. def main():
  114. load_env()
  115. taxonomy = json.loads(
  116. TAXONOMY.read_text()
  117. )
  118. taxonomy_text = build_taxonomy_text(
  119. taxonomy
  120. )
  121. con = sqlite3.connect(DB)
  122. con.row_factory = sqlite3.Row
  123. tickets = con.execute("""
  124. SELECT *
  125. FROM tickets
  126. ORDER BY id
  127. """).fetchall()
  128. print("=" * 80)
  129. print("ZAMMAD → TELEFONIE-TAXONOMIE SCAN")
  130. print("=" * 80)
  131. print(f"Tickets: {len(tickets)}")
  132. print(f"Taxonomie: {TAXONOMY}")
  133. print(f"Modell: {MODEL}")
  134. print()
  135. # Zunächst lokale Statistiken.
  136. groups = Counter()
  137. states = Counter()
  138. tags = Counter()
  139. article_counts = Counter()
  140. prepared = []
  141. for ticket in tickets:
  142. groups[ticket["group_name"]] += 1
  143. states[ticket["state"]] += 1
  144. try:
  145. ticket_tags = json.loads(
  146. ticket["tags_json"] or "[]"
  147. )
  148. except Exception:
  149. ticket_tags = []
  150. for tag in ticket_tags:
  151. tags[tag] += 1
  152. articles = con.execute("""
  153. SELECT *
  154. FROM articles
  155. WHERE ticket_id = ?
  156. ORDER BY created_at, id
  157. """, (ticket["id"],)).fetchall()
  158. article_counts[len(articles)] += 1
  159. # Für die KI nur externe Kommunikation.
  160. communication = []
  161. for article in articles:
  162. if article["internal"]:
  163. continue
  164. body = clean_text(
  165. article["body_text"]
  166. )
  167. if not body:
  168. continue
  169. communication.append({
  170. "sender": article["sender"],
  171. "type": article["type"],
  172. "created_at": article["created_at"],
  173. "subject": article["subject"],
  174. "body": body,
  175. })
  176. full_text = "\n\n".join(
  177. (
  178. f"[{x['sender']}] "
  179. f"{x['body']}"
  180. )
  181. for x in communication
  182. )
  183. if not full_text:
  184. continue
  185. # Extrem lange Mailverläufe begrenzen.
  186. if len(full_text) > 12000:
  187. full_text = full_text[:12000]
  188. prepared.append({
  189. "ticket_id": ticket["id"],
  190. "number": ticket["number"],
  191. "title": ticket["title"],
  192. "group": ticket["group_name"],
  193. "state": ticket["state"],
  194. "tags": ticket_tags,
  195. "order_id": ticket["customer_id"],
  196. "communication": full_text,
  197. })
  198. con.close()
  199. print("Lokale Statistik:")
  200. print("\nGruppen:")
  201. for key, value in groups.most_common(20):
  202. print(f"{value:6} {key}")
  203. print("\nTags:")
  204. for key, value in tags.most_common(30):
  205. print(f"{value:6} {key}")
  206. print("\nTickets mit Artikelanzahl:")
  207. for key, value in sorted(article_counts.items()):
  208. print(f"{key:3} Artikel: {value}")
  209. print()
  210. print(
  211. f"Tickets mit verwertbarer Kommunikation: "
  212. f"{len(prepared)}"
  213. )
  214. # ------------------------------------------------------------------
  215. # Qwen bewertet die Tickets GEGEN die bestehende Taxonomie.
  216. # Die Taxonomie darf von Qwen NICHT still verändert werden.
  217. # ------------------------------------------------------------------
  218. results = []
  219. for index, ticket in enumerate(
  220. prepared,
  221. 1,
  222. ):
  223. print(
  224. f"[{index}/{len(prepared)}] "
  225. f"Ticket #{ticket['number']}"
  226. )
  227. prompt = f"""
  228. Du bist Auditor einer bestehenden Taxonomie für
  229. Kundenservice und Telefon-KI eines Gartenpflanzen-Webshops.
  230. Die bestehende Taxonomie ist VERBINDLICH:
  231. {taxonomy_text}
  232. Du darfst keinen bestehenden Intent umbenennen.
  233. Deine Aufgabe ist:
  234. 1. Erkenne primary_intent.
  235. 2. Erkenne weitere secondary_intents.
  236. 3. Bei REKLAMATION zusätzlich complaint_type.
  237. 4. Erkenne notwendige actions.
  238. 5. Prüfe, ob die bestehende Taxonomie das Ticket ausreichend beschreibt.
  239. 6. Wenn NICHT:
  240. - schlage einen neuen Intent oder Unterintent vor.
  241. - verwende dafür einen kurzen maschinenlesbaren Namen.
  242. - erkläre konkret anhand des Tickets, warum er nötig ist.
  243. 7. Wenn die bestehende Taxonomie ausreicht:
  244. - new_taxonomy_candidate = null.
  245. WICHTIG:
  246. Nicht jede seltene Formulierung ist ein neuer Intent.
  247. Ein neuer Intent ist nur sinnvoll, wenn:
  248. - ein eigenständiger Kundenwunsch vorliegt,
  249. - er fachlich anders behandelt werden muss,
  250. - oder dafür ein anderer MCP-/Workflow-Pfad nötig wäre.
  251. Mehrere Anliegen dürfen gleichzeitig vorkommen.
  252. REKLAMATION:
  253. Die bestehenden Reklamationsgründe sind besonders wichtig.
  254. Wenn eine Reklamation vorliegt, ordne sie einem vorhandenen
  255. complaint_type zu, sofern möglich.
  256. Bestehende Reklamationsgründe dürfen NICHT durch einen
  257. neuen ähnlichen Kandidaten ersetzt werden.
  258. TICKET:
  259. {json.dumps(ticket, ensure_ascii=False, indent=2)}
  260. Antworte ausschließlich mit:
  261. {{
  262. "call_state": "HUMAN_CONVERSATION",
  263. "primary_intent": "",
  264. "secondary_intents": [],
  265. "complaint_type": null,
  266. "actions": [],
  267. "taxonomy_fit": "FIT",
  268. "new_taxonomy_candidate": null,
  269. "candidate_reason": "",
  270. "customer_goal": "",
  271. "confidence": 0.0
  272. }}
  273. taxonomy_fit muss sein:
  274. FIT
  275. PARTIAL
  276. NEW_INTENT_REQUIRED
  277. NO_BUSINESS_CONTENT
  278. """
  279. try:
  280. result = parse_json(
  281. qwen(prompt)
  282. )
  283. except Exception as exc:
  284. result = {
  285. "call_state": "UNKNOWN",
  286. "primary_intent": "SONSTIGES",
  287. "secondary_intents": [],
  288. "complaint_type": None,
  289. "actions": [],
  290. "taxonomy_fit": "UNKNOWN",
  291. "new_taxonomy_candidate": None,
  292. "candidate_reason": str(exc),
  293. "customer_goal": "",
  294. "confidence": 0,
  295. }
  296. result["_ticket_id"] = ticket[
  297. "ticket_id"
  298. ]
  299. result["_ticket_number"] = ticket[
  300. "number"
  301. ]
  302. result["_title"] = ticket[
  303. "title"
  304. ]
  305. results.append(result)
  306. # ------------------------------------------------------------------
  307. # Kandidaten zusammenfassen.
  308. # ------------------------------------------------------------------
  309. candidates = defaultdict(
  310. lambda: {
  311. "count": 0,
  312. "examples": [],
  313. "reasons": [],
  314. }
  315. )
  316. fit = Counter()
  317. primary = Counter()
  318. complaints = Counter()
  319. actions = Counter()
  320. for result in results:
  321. fit[
  322. result.get(
  323. "taxonomy_fit",
  324. "UNKNOWN",
  325. )
  326. ] += 1
  327. primary[
  328. result.get(
  329. "primary_intent",
  330. "UNKNOWN",
  331. )
  332. ] += 1
  333. if result.get("complaint_type"):
  334. complaints[
  335. result["complaint_type"]
  336. ] += 1
  337. for action in result.get(
  338. "actions",
  339. [],
  340. ):
  341. actions[action] += 1
  342. candidate = result.get(
  343. "new_taxonomy_candidate"
  344. )
  345. if candidate:
  346. key = candidate.strip().upper()
  347. entry = candidates[key]
  348. entry["count"] += 1
  349. if len(entry["examples"]) < 10:
  350. entry["examples"].append(
  351. {
  352. "ticket": result[
  353. "_ticket_number"
  354. ],
  355. "title": result[
  356. "_title"
  357. ],
  358. }
  359. )
  360. reason = result.get(
  361. "candidate_reason",
  362. "",
  363. )
  364. if (
  365. reason
  366. and reason not in entry["reasons"]
  367. and len(entry["reasons"]) < 5
  368. ):
  369. entry["reasons"].append(
  370. reason
  371. )
  372. output = {
  373. "taxonomy_version": taxonomy[
  374. "version"
  375. ],
  376. "model": MODEL,
  377. "ticket_count": len(tickets),
  378. "analyzed_count": len(results),
  379. "taxonomy_fit": dict(
  380. fit.most_common()
  381. ),
  382. "primary_intents": dict(
  383. primary.most_common()
  384. ),
  385. "complaint_types": dict(
  386. complaints.most_common()
  387. ),
  388. "actions": dict(
  389. actions.most_common()
  390. ),
  391. "taxonomy_candidates": dict(
  392. sorted(
  393. candidates.items(),
  394. key=lambda x: x[1]["count"],
  395. reverse=True,
  396. )
  397. ),
  398. "tickets": results,
  399. }
  400. OUT.write_text(
  401. json.dumps(
  402. output,
  403. ensure_ascii=False,
  404. indent=2,
  405. )
  406. )
  407. print()
  408. print("=" * 80)
  409. print("SCAN FERTIG")
  410. print("=" * 80)
  411. print("\nTAXONOMIE-PASSUNG")
  412. for key, value in fit.most_common():
  413. print(f"{value:6} {key}")
  414. print("\nPRIMARY INTENTS")
  415. for key, value in primary.most_common():
  416. print(f"{value:6} {key}")
  417. print("\nREKLAMATIONSGRÜNDE")
  418. for key, value in complaints.most_common():
  419. print(f"{value:6} {key}")
  420. print("\nNEUE TAXONOMIE-KANDIDATEN")
  421. if not candidates:
  422. print("Keine Kandidaten.")
  423. else:
  424. for key, value in sorted(
  425. candidates.items(),
  426. key=lambda x: x[1]["count"],
  427. reverse=True,
  428. ):
  429. print(
  430. f"\n{value['count']:6} {key}"
  431. )
  432. for reason in value["reasons"]:
  433. print(f" {reason}")
  434. for example in value["examples"][:5]:
  435. print(
  436. f" Ticket "
  437. f"{example['ticket']}: "
  438. f"{example['title']}"
  439. )
  440. print()
  441. print(f"Ergebnis: {OUT}")
  442. if __name__ == "__main__":
  443. main()