prepare_zammad_cases.py 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  1. #!/usr/bin/env python3
  2. import hashlib
  3. import html
  4. import json
  5. import re
  6. import sqlite3
  7. from pathlib import Path
  8. from collections import Counter
  9. DB = Path("data/zammad_history.sqlite3")
  10. OUT = Path("data/zammad_cases.sqlite3")
  11. def clean(text):
  12. if not text:
  13. return ""
  14. text = html.unescape(text)
  15. # HTML
  16. text = re.sub(
  17. r"<(script|style).*?</\1>",
  18. " ",
  19. text,
  20. flags=re.I | re.S,
  21. )
  22. text = re.sub(r"<[^>]+>", " ", text)
  23. # quoted mail history
  24. text = re.sub(
  25. r"\n\s*(Am .* schrieb .*?:|On .* wrote:).*",
  26. "",
  27. text,
  28. flags=re.I | re.S,
  29. )
  30. # common signature endings
  31. text = re.split(
  32. r"\n\s*(Mit freundlichen Grüßen|"
  33. r"Viele Grüße|"
  34. r"Beste Grüße|"
  35. r"Freundliche Grüße)\b",
  36. text,
  37. maxsplit=1,
  38. flags=re.I,
  39. )[0]
  40. text = re.sub(r"[ \t]+", " ", text)
  41. text = re.sub(r"\n{3,}", "\n\n", text)
  42. return text.strip()
  43. def normalize_for_fingerprint(text):
  44. text = text.lower()
  45. # Telefonnummern / E-Mail-Adressen / IDs
  46. text = re.sub(
  47. r"\b[\w.+-]+@[\w.-]+\.\w+\b",
  48. " EMAIL ",
  49. text,
  50. )
  51. text = re.sub(
  52. r"\b\d{4,}\b",
  53. " NUMBER ",
  54. text,
  55. )
  56. # whitespace
  57. text = re.sub(r"\s+", " ", text)
  58. return text.strip()
  59. def fingerprint(text):
  60. normalized = normalize_for_fingerprint(text)
  61. # Wortfolge als stabile lokale Signatur.
  62. words = normalized.split()
  63. if len(words) > 120:
  64. words = words[:120]
  65. return hashlib.sha1(
  66. " ".join(words).encode(
  67. "utf-8",
  68. errors="ignore",
  69. )
  70. ).hexdigest()
  71. src = sqlite3.connect(DB)
  72. src.row_factory = sqlite3.Row
  73. # Neue Analyse-DB; die Originaldaten bleiben unangetastet.
  74. out = sqlite3.connect(OUT)
  75. out.executescript("""
  76. DROP TABLE IF EXISTS cases;
  77. CREATE TABLE cases (
  78. id INTEGER PRIMARY KEY AUTOINCREMENT,
  79. ticket_id INTEGER UNIQUE,
  80. ticket_number TEXT,
  81. title TEXT,
  82. group_name TEXT,
  83. state TEXT,
  84. created_at TEXT,
  85. updated_at TEXT,
  86. customer_id TEXT,
  87. tags_json TEXT,
  88. article_count INTEGER,
  89. customer_article_count INTEGER,
  90. subject_clean TEXT,
  91. conversation TEXT,
  92. fingerprint TEXT,
  93. content_length INTEGER,
  94. classification_status TEXT DEFAULT 'NEW',
  95. primary_intent TEXT,
  96. secondary_intents TEXT,
  97. complaint_type TEXT,
  98. actions TEXT,
  99. taxonomy_fit TEXT,
  100. taxonomy_candidate TEXT,
  101. classification_json TEXT
  102. );
  103. CREATE INDEX idx_cases_fingerprint
  104. ON cases(fingerprint);
  105. CREATE INDEX idx_cases_status
  106. ON cases(classification_status);
  107. CREATE INDEX idx_cases_group
  108. ON cases(group_name);
  109. CREATE INDEX idx_cases_created
  110. ON cases(created_at);
  111. """)
  112. tickets = src.execute("""
  113. SELECT *
  114. FROM tickets
  115. ORDER BY id
  116. """).fetchall()
  117. print("=" * 72)
  118. print("ZAMMAD → CASE NORMALIZER")
  119. print("=" * 72)
  120. print(f"Tickets: {len(tickets)}")
  121. print()
  122. stats = Counter()
  123. fingerprints = Counter()
  124. for pos, ticket in enumerate(tickets, 1):
  125. articles = src.execute("""
  126. SELECT *
  127. FROM articles
  128. WHERE ticket_id = ?
  129. ORDER BY created_at, id
  130. """, (ticket["id"],)).fetchall()
  131. customer_parts = []
  132. all_parts = []
  133. for article in articles:
  134. body = clean(
  135. article["body_text"] or ""
  136. )
  137. if not body:
  138. continue
  139. # Interne Notizen nicht in das Kundenanliegen übernehmen.
  140. internal = bool(article["internal"])
  141. if internal:
  142. stats["internal_articles"] += 1
  143. continue
  144. sender = article["sender"] or ""
  145. part = (
  146. f"[{sender}] {body}"
  147. )
  148. customer_parts.append(part)
  149. all_parts.append(part)
  150. conversation = "\n\n".join(
  151. customer_parts
  152. ).strip()
  153. if not conversation:
  154. stats["empty_cases"] += 1
  155. continue
  156. subject = clean(
  157. ticket["title"] or ""
  158. )
  159. combined = (
  160. subject + "\n" + conversation
  161. ).strip()
  162. fp = fingerprint(combined)
  163. fingerprints[fp] += 1
  164. try:
  165. tags = json.loads(
  166. ticket["tags_json"] or "[]"
  167. )
  168. except Exception:
  169. tags = []
  170. out.execute("""
  171. INSERT INTO cases (
  172. ticket_id,
  173. ticket_number,
  174. title,
  175. group_name,
  176. state,
  177. created_at,
  178. updated_at,
  179. customer_id,
  180. tags_json,
  181. article_count,
  182. customer_article_count,
  183. subject_clean,
  184. conversation,
  185. fingerprint,
  186. content_length
  187. )
  188. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  189. """, (
  190. ticket["id"],
  191. ticket["number"],
  192. ticket["title"],
  193. ticket["group_name"],
  194. ticket["state"],
  195. ticket["created_at"],
  196. ticket["updated_at"],
  197. ticket["customer_id"],
  198. json.dumps(
  199. tags,
  200. ensure_ascii=False,
  201. ),
  202. len(articles),
  203. len(customer_parts),
  204. subject,
  205. conversation,
  206. fp,
  207. len(conversation),
  208. ))
  209. stats["cases"] += 1
  210. if len(customer_parts) > 1:
  211. stats["multi_article_cases"] += 1
  212. if pos % 250 == 0:
  213. out.commit()
  214. print(
  215. f"[{pos}/{len(tickets)}] "
  216. f"Cases: {stats['cases']}"
  217. )
  218. out.commit()
  219. duplicate_groups = sum(
  220. 1
  221. for count in fingerprints.values()
  222. if count > 1
  223. )
  224. duplicate_cases = sum(
  225. count - 1
  226. for count in fingerprints.values()
  227. if count > 1
  228. )
  229. print()
  230. print("=" * 72)
  231. print("NORMALISIERUNG FERTIG")
  232. print("=" * 72)
  233. for key, value in stats.most_common():
  234. print(
  235. f"{key:28} {value}"
  236. )
  237. print()
  238. print(
  239. f"Eindeutige Fingerprints: "
  240. f"{len(fingerprints)}"
  241. )
  242. print(
  243. f"Fingerprint-Gruppen >1: "
  244. f"{duplicate_groups}"
  245. )
  246. print(
  247. f"potentielle Duplikate: "
  248. f"{duplicate_cases}"
  249. )
  250. print()
  251. print("Top 20 identische/ähnliche Fingerprints:")
  252. for fp, count in sorted(
  253. fingerprints.items(),
  254. key=lambda x: x[1],
  255. reverse=True,
  256. )[:20]:
  257. if count < 2:
  258. break
  259. row = out.execute("""
  260. SELECT ticket_number, title
  261. FROM cases
  262. WHERE fingerprint = ?
  263. LIMIT 3
  264. """, (fp,)).fetchall()
  265. print(
  266. f"\n{count} Fälle"
  267. )
  268. for r in row:
  269. print(
  270. f" #{r[0]} {r[1]}"
  271. )
  272. print()
  273. print(f"Analyse-DB: {OUT}")
  274. src.close()
  275. out.close()