| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546 |
- #!/usr/bin/env python3
- import json
- import os
- import re
- import sqlite3
- import urllib.request
- from collections import Counter, defaultdict
- from html import unescape
- from pathlib import Path
- BASE = Path(".")
- ENV = BASE / ".env"
- DB = BASE / "data/zammad_history.sqlite3"
- TAXONOMY = BASE / "config/telefonie_taxonomy.json"
- OUT = BASE / "data/zammad_taxonomy_scan.json"
- OLLAMA = "http://127.0.0.1:11434/api/generate"
- MODEL = "qwen3:8b"
- def load_env():
- for line in ENV.read_text().splitlines():
- line = line.strip()
- if not line or line.startswith("#") or "=" not in line:
- continue
- key, value = line.split("=", 1)
- os.environ[key] = value.strip().strip('"').strip("'")
- def clean_text(text):
- if not text:
- return ""
- text = re.sub(
- r"<(script|style).*?</\1>",
- " ",
- text,
- flags=re.I | re.S,
- )
- text = re.sub(r"<br\s*/?>", "\n", text, flags=re.I)
- text = re.sub(r"</p\s*>", "\n", text, flags=re.I)
- text = re.sub(r"</div\s*>", "\n", text, flags=re.I)
- text = re.sub(r"<[^>]+>", " ", text)
- text = unescape(text)
- # E-Mail-Zitate reduzieren.
- text = re.sub(
- r"\n\s*(Am .* schrieb .*:|On .* wrote:).*",
- "",
- text,
- flags=re.I | re.S,
- )
- # Signaturen grob reduzieren.
- text = re.sub(
- r"\n\s*(Mit freundlichen Grüßen|Viele Grüße|Beste Grüße).*",
- "",
- text,
- flags=re.I | re.S,
- )
- text = re.sub(r"[ \t]+", " ", text)
- text = re.sub(r"\n{3,}", "\n\n", text)
- return text.strip()
- def qwen(prompt):
- payload = {
- "model": MODEL,
- "prompt": prompt,
- "stream": False,
- "format": "json",
- "options": {
- "temperature": 0
- },
- }
- req = urllib.request.Request(
- OLLAMA,
- data=json.dumps(
- payload,
- ensure_ascii=False,
- ).encode(),
- headers={
- "Content-Type": "application/json"
- },
- method="POST",
- )
- with urllib.request.urlopen(
- req,
- timeout=600,
- ) as response:
- return json.loads(
- response.read().decode()
- )["response"]
- def parse_json(text):
- text = text.strip()
- if text.startswith("```"):
- text = re.sub(
- r"^```(?:json)?",
- "",
- text,
- )
- text = re.sub(
- r"```$",
- "",
- text,
- )
- return json.loads(text.strip())
- def build_taxonomy_text(taxonomy):
- result = []
- for group, intents in taxonomy["groups"].items():
- result.append(
- f"{group}: {', '.join(intents)}"
- )
- result.append(
- "\nREKLAMATIONSGRÜNDE:"
- )
- for key, description in taxonomy[
- "complaint_types"
- ].items():
- result.append(
- f"- {key}: {description}"
- )
- return "\n".join(result)
- def main():
- load_env()
- taxonomy = json.loads(
- TAXONOMY.read_text()
- )
- taxonomy_text = build_taxonomy_text(
- taxonomy
- )
- con = sqlite3.connect(DB)
- con.row_factory = sqlite3.Row
- tickets = con.execute("""
- SELECT *
- FROM tickets
- ORDER BY id
- """).fetchall()
- print("=" * 80)
- print("ZAMMAD → TELEFONIE-TAXONOMIE SCAN")
- print("=" * 80)
- print(f"Tickets: {len(tickets)}")
- print(f"Taxonomie: {TAXONOMY}")
- print(f"Modell: {MODEL}")
- print()
- # Zunächst lokale Statistiken.
- groups = Counter()
- states = Counter()
- tags = Counter()
- article_counts = Counter()
- prepared = []
- for ticket in tickets:
- groups[ticket["group_name"]] += 1
- states[ticket["state"]] += 1
- try:
- ticket_tags = json.loads(
- ticket["tags_json"] or "[]"
- )
- except Exception:
- ticket_tags = []
- for tag in ticket_tags:
- tags[tag] += 1
- articles = con.execute("""
- SELECT *
- FROM articles
- WHERE ticket_id = ?
- ORDER BY created_at, id
- """, (ticket["id"],)).fetchall()
- article_counts[len(articles)] += 1
- # Für die KI nur externe Kommunikation.
- communication = []
- for article in articles:
- if article["internal"]:
- continue
- body = clean_text(
- article["body_text"]
- )
- if not body:
- continue
- communication.append({
- "sender": article["sender"],
- "type": article["type"],
- "created_at": article["created_at"],
- "subject": article["subject"],
- "body": body,
- })
- full_text = "\n\n".join(
- (
- f"[{x['sender']}] "
- f"{x['body']}"
- )
- for x in communication
- )
- if not full_text:
- continue
- # Extrem lange Mailverläufe begrenzen.
- if len(full_text) > 12000:
- full_text = full_text[:12000]
- prepared.append({
- "ticket_id": ticket["id"],
- "number": ticket["number"],
- "title": ticket["title"],
- "group": ticket["group_name"],
- "state": ticket["state"],
- "tags": ticket_tags,
- "order_id": ticket["customer_id"],
- "communication": full_text,
- })
- con.close()
- print("Lokale Statistik:")
- print("\nGruppen:")
- for key, value in groups.most_common(20):
- print(f"{value:6} {key}")
- print("\nTags:")
- for key, value in tags.most_common(30):
- print(f"{value:6} {key}")
- print("\nTickets mit Artikelanzahl:")
- for key, value in sorted(article_counts.items()):
- print(f"{key:3} Artikel: {value}")
- print()
- print(
- f"Tickets mit verwertbarer Kommunikation: "
- f"{len(prepared)}"
- )
- # ------------------------------------------------------------------
- # Qwen bewertet die Tickets GEGEN die bestehende Taxonomie.
- # Die Taxonomie darf von Qwen NICHT still verändert werden.
- # ------------------------------------------------------------------
- results = []
- for index, ticket in enumerate(
- prepared,
- 1,
- ):
- print(
- f"[{index}/{len(prepared)}] "
- f"Ticket #{ticket['number']}"
- )
- prompt = f"""
- Du bist Auditor einer bestehenden Taxonomie für
- Kundenservice und Telefon-KI eines Gartenpflanzen-Webshops.
- Die bestehende Taxonomie ist VERBINDLICH:
- {taxonomy_text}
- Du darfst keinen bestehenden Intent umbenennen.
- Deine Aufgabe ist:
- 1. Erkenne primary_intent.
- 2. Erkenne weitere secondary_intents.
- 3. Bei REKLAMATION zusätzlich complaint_type.
- 4. Erkenne notwendige actions.
- 5. Prüfe, ob die bestehende Taxonomie das Ticket ausreichend beschreibt.
- 6. Wenn NICHT:
- - schlage einen neuen Intent oder Unterintent vor.
- - verwende dafür einen kurzen maschinenlesbaren Namen.
- - erkläre konkret anhand des Tickets, warum er nötig ist.
- 7. Wenn die bestehende Taxonomie ausreicht:
- - new_taxonomy_candidate = null.
- WICHTIG:
- Nicht jede seltene Formulierung ist ein neuer Intent.
- Ein neuer Intent ist nur sinnvoll, wenn:
- - ein eigenständiger Kundenwunsch vorliegt,
- - er fachlich anders behandelt werden muss,
- - oder dafür ein anderer MCP-/Workflow-Pfad nötig wäre.
- Mehrere Anliegen dürfen gleichzeitig vorkommen.
- REKLAMATION:
- Die bestehenden Reklamationsgründe sind besonders wichtig.
- Wenn eine Reklamation vorliegt, ordne sie einem vorhandenen
- complaint_type zu, sofern möglich.
- Bestehende Reklamationsgründe dürfen NICHT durch einen
- neuen ähnlichen Kandidaten ersetzt werden.
- TICKET:
- {json.dumps(ticket, ensure_ascii=False, indent=2)}
- Antworte ausschließlich mit:
- {{
- "call_state": "HUMAN_CONVERSATION",
- "primary_intent": "",
- "secondary_intents": [],
- "complaint_type": null,
- "actions": [],
- "taxonomy_fit": "FIT",
- "new_taxonomy_candidate": null,
- "candidate_reason": "",
- "customer_goal": "",
- "confidence": 0.0
- }}
- taxonomy_fit muss sein:
- FIT
- PARTIAL
- NEW_INTENT_REQUIRED
- NO_BUSINESS_CONTENT
- """
- try:
- result = parse_json(
- qwen(prompt)
- )
- except Exception as exc:
- result = {
- "call_state": "UNKNOWN",
- "primary_intent": "SONSTIGES",
- "secondary_intents": [],
- "complaint_type": None,
- "actions": [],
- "taxonomy_fit": "UNKNOWN",
- "new_taxonomy_candidate": None,
- "candidate_reason": str(exc),
- "customer_goal": "",
- "confidence": 0,
- }
- result["_ticket_id"] = ticket[
- "ticket_id"
- ]
- result["_ticket_number"] = ticket[
- "number"
- ]
- result["_title"] = ticket[
- "title"
- ]
- results.append(result)
- # ------------------------------------------------------------------
- # Kandidaten zusammenfassen.
- # ------------------------------------------------------------------
- candidates = defaultdict(
- lambda: {
- "count": 0,
- "examples": [],
- "reasons": [],
- }
- )
- fit = Counter()
- primary = Counter()
- complaints = Counter()
- actions = Counter()
- for result in results:
- fit[
- result.get(
- "taxonomy_fit",
- "UNKNOWN",
- )
- ] += 1
- primary[
- result.get(
- "primary_intent",
- "UNKNOWN",
- )
- ] += 1
- if result.get("complaint_type"):
- complaints[
- result["complaint_type"]
- ] += 1
- for action in result.get(
- "actions",
- [],
- ):
- actions[action] += 1
- candidate = result.get(
- "new_taxonomy_candidate"
- )
- if candidate:
- key = candidate.strip().upper()
- entry = candidates[key]
- entry["count"] += 1
- if len(entry["examples"]) < 10:
- entry["examples"].append(
- {
- "ticket": result[
- "_ticket_number"
- ],
- "title": result[
- "_title"
- ],
- }
- )
- reason = result.get(
- "candidate_reason",
- "",
- )
- if (
- reason
- and reason not in entry["reasons"]
- and len(entry["reasons"]) < 5
- ):
- entry["reasons"].append(
- reason
- )
- output = {
- "taxonomy_version": taxonomy[
- "version"
- ],
- "model": MODEL,
- "ticket_count": len(tickets),
- "analyzed_count": len(results),
- "taxonomy_fit": dict(
- fit.most_common()
- ),
- "primary_intents": dict(
- primary.most_common()
- ),
- "complaint_types": dict(
- complaints.most_common()
- ),
- "actions": dict(
- actions.most_common()
- ),
- "taxonomy_candidates": dict(
- sorted(
- candidates.items(),
- key=lambda x: x[1]["count"],
- reverse=True,
- )
- ),
- "tickets": results,
- }
- OUT.write_text(
- json.dumps(
- output,
- ensure_ascii=False,
- indent=2,
- )
- )
- print()
- print("=" * 80)
- print("SCAN FERTIG")
- print("=" * 80)
- print("\nTAXONOMIE-PASSUNG")
- for key, value in fit.most_common():
- print(f"{value:6} {key}")
- print("\nPRIMARY INTENTS")
- for key, value in primary.most_common():
- print(f"{value:6} {key}")
- print("\nREKLAMATIONSGRÜNDE")
- for key, value in complaints.most_common():
- print(f"{value:6} {key}")
- print("\nNEUE TAXONOMIE-KANDIDATEN")
- if not candidates:
- print("Keine Kandidaten.")
- else:
- for key, value in sorted(
- candidates.items(),
- key=lambda x: x[1]["count"],
- reverse=True,
- ):
- print(
- f"\n{value['count']:6} {key}"
- )
- for reason in value["reasons"]:
- print(f" {reason}")
- for example in value["examples"][:5]:
- print(
- f" Ticket "
- f"{example['ticket']}: "
- f"{example['title']}"
- )
- print()
- print(f"Ergebnis: {OUT}")
- if __name__ == "__main__":
- main()
|