| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352 |
- #!/usr/bin/env python3
- import hashlib
- import html
- import json
- import re
- import sqlite3
- from pathlib import Path
- from collections import Counter
- DB = Path("data/zammad_history.sqlite3")
- OUT = Path("data/zammad_cases.sqlite3")
- def clean(text):
- if not text:
- return ""
- text = html.unescape(text)
- # HTML
- text = re.sub(
- r"<(script|style).*?</\1>",
- " ",
- text,
- flags=re.I | re.S,
- )
- text = re.sub(r"<[^>]+>", " ", text)
- # quoted mail history
- text = re.sub(
- r"\n\s*(Am .* schrieb .*?:|On .* wrote:).*",
- "",
- text,
- flags=re.I | re.S,
- )
- # common signature endings
- text = re.split(
- r"\n\s*(Mit freundlichen Grüßen|"
- r"Viele Grüße|"
- r"Beste Grüße|"
- r"Freundliche Grüße)\b",
- text,
- maxsplit=1,
- flags=re.I,
- )[0]
- text = re.sub(r"[ \t]+", " ", text)
- text = re.sub(r"\n{3,}", "\n\n", text)
- return text.strip()
- def normalize_for_fingerprint(text):
- text = text.lower()
- # Telefonnummern / E-Mail-Adressen / IDs
- text = re.sub(
- r"\b[\w.+-]+@[\w.-]+\.\w+\b",
- " EMAIL ",
- text,
- )
- text = re.sub(
- r"\b\d{4,}\b",
- " NUMBER ",
- text,
- )
- # whitespace
- text = re.sub(r"\s+", " ", text)
- return text.strip()
- def fingerprint(text):
- normalized = normalize_for_fingerprint(text)
- # Wortfolge als stabile lokale Signatur.
- words = normalized.split()
- if len(words) > 120:
- words = words[:120]
- return hashlib.sha1(
- " ".join(words).encode(
- "utf-8",
- errors="ignore",
- )
- ).hexdigest()
- src = sqlite3.connect(DB)
- src.row_factory = sqlite3.Row
- # Neue Analyse-DB; die Originaldaten bleiben unangetastet.
- out = sqlite3.connect(OUT)
- out.executescript("""
- DROP TABLE IF EXISTS cases;
- CREATE TABLE cases (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- ticket_id INTEGER UNIQUE,
- ticket_number TEXT,
- title TEXT,
- group_name TEXT,
- state TEXT,
- created_at TEXT,
- updated_at TEXT,
- customer_id TEXT,
- tags_json TEXT,
- article_count INTEGER,
- customer_article_count INTEGER,
- subject_clean TEXT,
- conversation TEXT,
- fingerprint TEXT,
- content_length INTEGER,
- classification_status TEXT DEFAULT 'NEW',
- primary_intent TEXT,
- secondary_intents TEXT,
- complaint_type TEXT,
- actions TEXT,
- taxonomy_fit TEXT,
- taxonomy_candidate TEXT,
- classification_json TEXT
- );
- CREATE INDEX idx_cases_fingerprint
- ON cases(fingerprint);
- CREATE INDEX idx_cases_status
- ON cases(classification_status);
- CREATE INDEX idx_cases_group
- ON cases(group_name);
- CREATE INDEX idx_cases_created
- ON cases(created_at);
- """)
- tickets = src.execute("""
- SELECT *
- FROM tickets
- ORDER BY id
- """).fetchall()
- print("=" * 72)
- print("ZAMMAD → CASE NORMALIZER")
- print("=" * 72)
- print(f"Tickets: {len(tickets)}")
- print()
- stats = Counter()
- fingerprints = Counter()
- for pos, ticket in enumerate(tickets, 1):
- articles = src.execute("""
- SELECT *
- FROM articles
- WHERE ticket_id = ?
- ORDER BY created_at, id
- """, (ticket["id"],)).fetchall()
- customer_parts = []
- all_parts = []
- for article in articles:
- body = clean(
- article["body_text"] or ""
- )
- if not body:
- continue
- # Interne Notizen nicht in das Kundenanliegen übernehmen.
- internal = bool(article["internal"])
- if internal:
- stats["internal_articles"] += 1
- continue
- sender = article["sender"] or ""
- part = (
- f"[{sender}] {body}"
- )
- customer_parts.append(part)
- all_parts.append(part)
- conversation = "\n\n".join(
- customer_parts
- ).strip()
- if not conversation:
- stats["empty_cases"] += 1
- continue
- subject = clean(
- ticket["title"] or ""
- )
- combined = (
- subject + "\n" + conversation
- ).strip()
- fp = fingerprint(combined)
- fingerprints[fp] += 1
- try:
- tags = json.loads(
- ticket["tags_json"] or "[]"
- )
- except Exception:
- tags = []
- out.execute("""
- INSERT INTO cases (
- ticket_id,
- ticket_number,
- title,
- group_name,
- state,
- created_at,
- updated_at,
- customer_id,
- tags_json,
- article_count,
- customer_article_count,
- subject_clean,
- conversation,
- fingerprint,
- content_length
- )
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- """, (
- ticket["id"],
- ticket["number"],
- ticket["title"],
- ticket["group_name"],
- ticket["state"],
- ticket["created_at"],
- ticket["updated_at"],
- ticket["customer_id"],
- json.dumps(
- tags,
- ensure_ascii=False,
- ),
- len(articles),
- len(customer_parts),
- subject,
- conversation,
- fp,
- len(conversation),
- ))
- stats["cases"] += 1
- if len(customer_parts) > 1:
- stats["multi_article_cases"] += 1
- if pos % 250 == 0:
- out.commit()
- print(
- f"[{pos}/{len(tickets)}] "
- f"Cases: {stats['cases']}"
- )
- out.commit()
- duplicate_groups = sum(
- 1
- for count in fingerprints.values()
- if count > 1
- )
- duplicate_cases = sum(
- count - 1
- for count in fingerprints.values()
- if count > 1
- )
- print()
- print("=" * 72)
- print("NORMALISIERUNG FERTIG")
- print("=" * 72)
- for key, value in stats.most_common():
- print(
- f"{key:28} {value}"
- )
- print()
- print(
- f"Eindeutige Fingerprints: "
- f"{len(fingerprints)}"
- )
- print(
- f"Fingerprint-Gruppen >1: "
- f"{duplicate_groups}"
- )
- print(
- f"potentielle Duplikate: "
- f"{duplicate_cases}"
- )
- print()
- print("Top 20 identische/ähnliche Fingerprints:")
- for fp, count in sorted(
- fingerprints.items(),
- key=lambda x: x[1],
- reverse=True,
- )[:20]:
- if count < 2:
- break
- row = out.execute("""
- SELECT ticket_number, title
- FROM cases
- WHERE fingerprint = ?
- LIMIT 3
- """, (fp,)).fetchall()
- print(
- f"\n{count} Fälle"
- )
- for r in row:
- print(
- f" #{r[0]} {r[1]}"
- )
- print()
- print(f"Analyse-DB: {OUT}")
- src.close()
- out.close()
|