#!/usr/bin/env python3 import json import sqlite3 import time import urllib.request from concurrent.futures import ThreadPoolExecutor, as_completed DB = "data/zammad_analysis.sqlite3" URL = "http://192.168.1.101:11434/api/chat" MODEL = "qwen3:8b" WORKERS = 2 PRIMARY_INTENTS = { "VERSANDSTATUS", "LIEFERVERZUG", "ADRESSÄNDERUNG", "REKLAMATION", "TRANSPORTSCHADEN", "FEHLLIEFERUNG", "RECHNUNG", "ZAHLUNG", "WIDERRUF", "RETOURE", "PFLANZENBERATUNG", "PFLANZENKRANKHEIT", "SORTENBERATUNG", "PFLEGEFRAGE", "BESTANDSANFRAGE", "VORBESTELLUNG", "B2B", "GROSSHANDEL", "RABATTANFRAGE", "SONSTIGES", } TAGS = { # Reklamation / Qualität "QUALITAETSPROBLEM", "VERWECHSLUNG", "SCHLECHTVERPACKT", "FALSCHESORTEGELIEFERT", # Pflanzenzustand / Pflanzenschäden "PFLANZE_ABGEBROCHEN", "PFLANZE_VERTROCKNET", "PFLANZE_KRANK", "PFLANZE_MICKRIG", "PFLANZE_NICHTANGEWACHSEN", "WEISSE_BLAETTER", "SCHIMMEL", "FAULIG", "PFLANZENKRANKHEIT", "PILZKRANKHEIT", "MEHLTAU", "STERNRUSSTAU", "SCHÄDLINGE", "LAUSE", "PFLANZENSCHADEN", "WACHSTUMSPROBLEM", # Mengen-/Lieferfehler "FALSCHEFARBE", "NUR_EIN_STUECK", "FALSCHE_MENGE", # Produktgruppe "PRODUKTGRUPPE_ROSE", "PRODUKTGRUPPE_CLEMATIS", # Produktform "PRODUKTFORM_CONTAINER", "PRODUKTFORM_WURZELWARE", "PRODUKTFORM_HOCHSTAMM", # Versand / Logistik "VERSAND", "VERSANDKOSTEN", "PROBLEMMITLIEFERDIENST", "LIEFERDIENST_DHL", "LIEFERDIENST_GLS", "LIEFERDIENST_UNBEKANNT", "TRANSPORTSCHADEN", # Service / sonstiger Kontext "KATALOG", "ABHOLUNG", "KUNDENSERVICE", # Retouren-Merkmale "TEILRETOURE", "VOLLRETOURE", # Rabattverhalten "RABATTANFRAGE", "RABATT_GEFORDERT", "RABATT_SELBSSTAENDIG_ABGEZOGEN", } SYSTEM = """ Du analysierst historische Kundenservice-Tickets von Schmid Gartenpflanzen. Analysiere ausschließlich das tatsächliche Kundenanliegen. WICHTIG: Primärintent, Secondary-Intents und insbesondere TAGS dürfen nur aus dem tatsächlichen KUNDENBEITRAG abgeleitet werden. Der KUNDENSERVICE-KONTEXT darf NICHT dazu verwendet werden, neue Kundenprobleme, Zustände, Produktmerkmale oder Tags zu erfinden. Wenn ein Merkmal nur in der Antwort des Kundenservice vorkommt, aber nicht aus dem Kundenbeitrag hervorgeht, darf es NICHT als Tag ausgegeben werden. KUNDENBEITRAG: Die eigentliche Quelle für die Klassifikation. KUNDENSERVICE-KONTEXT: Nur ergänzender Kontext zur Interpretation bereits im Kundenbeitrag vorhandener Informationen. PRIMÄRINTENTS: VERSANDSTATUS LIEFERVERZUG ADRESSÄNDERUNG REKLAMATION TRANSPORTSCHADEN FEHLLIEFERUNG RECHNUNG ZAHLUNG WIDERRUF RETOURE PFLANZENBERATUNG SORTENBERATUNG PFLEGEFRAGE BESTANDSANFRAGE VORBESTELLUNG B2B GROSSHANDEL DÜNGER_FEHLT SONSTIGES TAGS: QUALITAETSPROBLEM VERWECHSLUNG SCHLECHTVERPACKT FALSCHESORTEGELIEFERT PROBLEMMITLIEFERDIENST LIEFERDIENST_DHL LIEFERDIENST_GLS LIEFERDIENST_UNBEKANNT TRANSPORTSCHADEN PFLANZENKRANKHEIT PFLANZENSCHADEN WACHSTUMSPROBLEM REGELN: BESTANDSANFRAGE: Jede Frage nach aktueller oder zukünftiger Verfügbarkeit, Lieferbarkeit oder Bestellbarkeit. VORBESTELLUNG: Nur wenn der Kunde ausdrücklich eine Vormerkung, Reservierung oder verbindliche Vorabbestellung für ein aktuell nicht verfügbares Produkt verlangt. Eine reine Frage nach Verfügbarkeit, Lieferbarkeit oder zukünftiger Bestellbarkeit ist IMMER BESTANDSANFRAGE. Auch Wünsche wie "ich hätte gerne welche", "ich möchte die gerne haben" oder "können Sie mir sagen, wann sie wieder da sind" sind KEINE VORBESTELLUNG, solange keine ausdrückliche Reservierung, Vormerkung oder Vorabbestellung verlangt wird. WIDERRUF: Stornierung oder Rückabwicklung einer Bestellung auf Kundenwunsch, einschließlich Stornierung vor Versand. REKLAMATION: Beanstandung eines gelieferten Produkts oder einer Leistung wegen Qualität, Zustand oder Beschädigung. TRANSPORTSCHADEN: Schaden, der offensichtlich durch Transport oder Versand entstanden ist. FEHLLIEFERUNG: Falscher Artikel, falsche Sorte oder falsches Produkt geliefert. DÜNGER_FEHLT: Bestellter Dünger fehlt. QUALITAETSPROBLEM: Tag, kein Primärintent. PFLANZENKRANKHEIT: Tag, kein Primärintent. PFLANZENSCHADEN: Tag, kein Primärintent. WACHSTUMSPROBLEM: Tag, kein Primärintent. PROBLEMMITLIEFERDIENST: Problem mit dem Paketdienst. LIEFERDIENST_DHL / LIEFERDIENST_GLS: Nur verwenden, wenn der jeweilige Dienst tatsächlich genannt wird. MULTI-INTENT: Mehrere Anliegen erfassen. TAGS DÜRFEN NIEMALS als secondary_intents ausgegeben werden. Die folgenden Begriffe sind ausschließlich TAGS: PRODUKTGRUPPE_ROSE PRODUKTGRUPPE_CLEMATIS PRODUKTFORM_CONTAINER PRODUKTFORM_WURZELWARE PRODUKTFORM_HOCHSTAMM VERSAND VERSANDKOSTEN PROBLEMMITLIEFERDIENST LIEFERDIENST_DHL LIEFERDIENST_GLS LIEFERDIENST_UNBEKANNT RETOURE KATALOG ABHOLUNG KUNDENSERVICE QUALITAETSPROBLEM VERWECHSLUNG SCHLECHTVERPACKT FALSCHESORTEGELIEFERT PFLANZE_ABGEBROCHEN PFLANZE_VERTROCKNET PFLANZE_KRANK PFLANZE_MICKRIG PFLANZE_NICHTANGEWACHSEN WEISSE_BLAETTER SCHIMMEL FAULIG PFLANZENKRANKHEIT PFLANZENSCHADEN WACHSTUMSPROBLEM FALSCHEFARBE NUR_EIN_STUECK FALSCHE_MENGE TRANSPORTSCHADEN secondary_intents darf ausschließlich einen PRIMÄRINTENT enthalten. RETOUREN: RETOURE ist ausschließlich ein PRIMÄRINTENT. RÜCKSENDUNG, RÜCKSENDEN, ZURÜCKSENDEN, RÜCKGABE, WARE ZURÜCKSCHICKEN und RETOURE sind Synonyme und werden als RETOURE klassifiziert. RÜCKSENDUNG darf NICHT als eigener Tag ausgegeben werden. TEILRETOURE: Der Kunde möchte nur einen Teil der Bestellung zurückgeben. VOLLRETOURE: Der Kunde möchte die komplette Bestellung zurückgeben. TEILRETOURE und VOLLRETOURE sind Tags. WIDERRUF und RETOURE sind zu unterscheiden: - RETOURE = Ware soll zurückgegeben/zurückgesendet werden. - WIDERRUF = Kunde erklärt den Widerruf des Kaufvertrags. Wenn ausdrücklich beides verlangt wird, kann RETOURE als secondary_intent zu WIDERRUF erscheinen. PFLANZENKRANKHEITEN / SCHÄDLINGE: PFLANZENKRANKHEIT = allgemeine oder konkrete Pflanzenkrankheit. PILZKRANKHEIT = Pilzerkrankung. MEHLTAU = konkreter Pilzkrankheitsbefund. STERNRUSSTAU = konkreter Pilzkrankheitsbefund. SCHÄDLINGE = allgemeiner Schädlingsbefall. LAUSE = konkreter Befall mit Läusen. Konkrete Merkmale dürfen zusätzlich zu PFLANZENKRANKHEIT ausgegeben werden. RABATTVERHALTEN: RABATTANFRAGE: Der Kunde fragt aktiv, ob ein Rabatt oder Preisnachlass möglich ist. RABATT_GEFORDERT: Der Kunde fordert ausdrücklich einen Rabatt, Preisnachlass oder eine bestimmte Rabatt-/Nachlasshöhe. RABATT_SELBSSTAENDIG_ABGEZOGEN: Der Kunde hat eigenmächtig einen Rabatt, Nachlass, Betrag oder Prozentsatz von einer Rechnung oder Zahlung abgezogen, ohne dass dieser Rabatt vorher vom Unternehmen gewährt wurde. Rabattmerkmale dürfen ausschließlich aus dem KUNDENBEITRAG abgeleitet werden. Ein vom Kundenservice angebotener oder gewährter Rabatt ist KEINE Rabattforderung des Kunden. Rabatt ist normalerweise kein eigener Primärintent. Beispiel: "Die Rose kam beschädigt an, dafür erwarte ich 20 % Rabatt." → primary_intent = REKLAMATION → tags = [QUALITAETSPROBLEM, RABATT_GEFORDERT] → discount.percentage = 20 Beispiel: "Die Rechnung beträgt 100 Euro, ich habe nur 90 Euro überwiesen." → primary_intent = ZAHLUNG → tags = [RABATT_SELBSSTAENDIG_ABGEZOGEN] → discount.amount = 10 → discount.currency = EUR → discount.self_deducted = true RABATT_WIEDERHOLT wird NICHT im einzelnen Ticket vergeben. Das ist ein späteres kundenbezogenes Aggregat. DISCOUNT-FELD: Extrahiere eine konkret genannte Rabatt-/Nachlasshöhe strukturiert. Wenn Prozent genannt: discount.percentage = Zahl Wenn ein konkreter Geldbetrag genannt: discount.amount = Zahl discount.currency = erkannte Währung Wenn keine Höhe genannt: entsprechender Wert = null discount.requested = true, wenn der Kunde aktiv Rabatt verlangt/fragt. discount.self_deducted = true, wenn der Kunde eigenmächtig gekürzt hat. Wenn kein Rabatt vorkommt: discount.requested = false discount.self_deducted = false discount.amount = null discount.percentage = null discount.currency = null primary_intent: Das wichtigste Anliegen. secondary_intents: Weitere Primärintents, falls vorhanden. tags: Nur konkrete Merkmale aus der TAG-Liste. NIEMALS Primärintents als Tags verwenden. taxonomy_fit: GOOD, wenn die Taxonomie gut passt. PARTIAL, wenn sie nur teilweise passt. POOR, wenn ein relevantes Anliegen fehlt. taxonomy_candidate: Nur bei PARTIAL oder POOR einen möglichen fehlenden Primärintent nennen. Sonst null. Keine neuen Primärintents eigenmächtig erfinden. Antworte ausschließlich mit gültigem JSON. """ EXPECTED = { "primary_intent": "", "secondary_intents": [], "tags": [], "complaint_type": None, "actions": [], "summary": "", "taxonomy_fit": "", "taxonomy_candidate": None, "confidence": 0.0, "discount": { "requested": False, "self_deducted": False, "amount": None, "percentage": None, "currency": None }, } def classify(row): prompt = f""" Ticket #{row["ticket_number"]} BETREFF: {row["title"] or ""} KUNDENBEITRAG: {row["customer_text"] or "(kein Kundentext vorhanden)"} KUNDENSERVICE-KONTEXT: {row["agent_text"] or "(kein Kundendiensttext vorhanden)"} WICHTIG: Auch ohne Kundentext muss das Ticket anhand von BETREFF und KUNDENSERVICE-KONTEXT klassifiziert werden. primary_intent MUSS exakt einem Wert aus der vorgegebenen Taxonomie entsprechen. Verwende KEINE selbst erfundenen Kategorien wie KUNDENSERVICE, ALLGEMEIN, KUNDENANFRAGE oder ähnliche freie Kategorien. Wenn kein spezifischer Intent sicher ableitbar ist, verwende SONSTIGES. taxonomy_fit MUSS exakt einer der Werte GOOD, PARTIAL oder POOR sein. confidence MUSS eine Zahl zwischen 0 und 1 sein. secondary_intents dürfen ausschließlich gültige Taxonomie-Intents enthalten. JSON-SCHEMA: {json.dumps(EXPECTED, ensure_ascii=False)} """ payload = { "model": MODEL, "messages": [ {"role": "system", "content": SYSTEM}, {"role": "user", "content": prompt}, ], "stream": False, "think": False, "format": "json", "options": { "temperature": 0, }, } req = urllib.request.Request( URL, data=json.dumps(payload, ensure_ascii=False).encode(), headers={"Content-Type": "application/json"}, ) started = time.monotonic() with urllib.request.urlopen(req, timeout=300) as response: raw = json.loads(response.read()) elapsed = time.monotonic() - started result = json.loads(raw["message"]["content"]) # Harte Taxonomie-Grenze: Modell darf keine eigenen Kategorien erzeugen. primary = result.get("primary_intent") if not isinstance(primary, str) or primary.strip() not in PRIMARY_INTENTS: result["primary_intent"] = "SONSTIGES" else: result["primary_intent"] = primary.strip() fit = result.get("taxonomy_fit") if fit not in {"GOOD", "PARTIAL", "POOR"}: result["taxonomy_fit"] = "POOR" confidence = result.get("confidence") try: result["confidence"] = float(confidence) except (TypeError, ValueError): result["confidence"] = 0.0 return row, result, elapsed def validate(result): if not isinstance(result, dict): return False, "JSON ist kein Objekt" primary = result.get("primary_intent") # Qwen darf bei fehlendem/unsinnigem Kundenanliegen # keinen leeren Primärintent zurückgeben. # Solche Fälle werden als SONSTIGES / POOR geführt. if not primary: fit = result.get("taxonomy_fit") if fit == "POOR": result["primary_intent"] = "SONSTIGES" primary = "SONSTIGES" if not result.get("taxonomy_candidate"): result["taxonomy_candidate"] = "KEINE_ANFRAGE" else: return False, "primary_intent fehlt" if primary not in PRIMARY_INTENTS: return False, f"Ungültiger primary_intent: {primary}" secondary = result.get("secondary_intents", []) tags = result.get("tags", []) if not isinstance(secondary, list): return False, "secondary_intents ist keine Liste" if not isinstance(tags, list): return False, "tags ist keine Liste" # Qwen verwechselt gelegentlich Tags mit secondary_intents. # Bekannte Tags werden automatisch in die Tag-Liste verschoben. secondary_clean = [] for item in secondary: if item in TAGS: tags.append(item) elif item in PRIMARY_INTENTS: secondary_clean.append(item) else: return False, f"Ungültige secondary_intent: {item}" secondary = secondary_clean # Primärintent darf nicht zusätzlich als secondary_intent erscheinen. primary = result.get("primary_intent") secondary = [ x for x in secondary if x != primary ] # Primärintents dürfen nicht als Tags erscheinen. tags = [ x for x in tags if x not in PRIMARY_INTENTS ] tags = list(dict.fromkeys(tags)) secondary = list(dict.fromkeys(secondary)) invalid_tags = [ x for x in tags if x not in TAGS ] if invalid_tags: return False, f"Ungültige Tags: {invalid_tags}" result["secondary_intents"] = secondary result["tags"] = tags fit = result.get("taxonomy_fit") if fit not in {"GOOD", "PARTIAL", "POOR"}: return False, f"Ungültiges taxonomy_fit: {fit}" # Rabattdaten validieren discount = result.get("discount", {}) if not isinstance(discount, dict): return False, "discount ist kein Objekt" for key in ("requested", "self_deducted"): if key not in discount: discount[key] = False if not isinstance(discount[key], bool): return False, f"discount.{key} ist kein Boolean" for key in ("amount", "percentage"): if key not in discount: discount[key] = None if discount[key] is not None: try: discount[key] = float(discount[key]) except Exception: return False, f"discount.{key} ist nicht numerisch" if discount[key] < 0: return False, f"discount.{key} ist negativ" if "currency" not in discount: discount["currency"] = None if discount["currency"] is not None: discount["currency"] = str(discount["currency"]).upper() result["discount"] = discount confidence = result.get("confidence") try: confidence = float(confidence) except Exception: return False, "confidence nicht numerisch" if not 0 <= confidence <= 1: return False, "confidence außerhalb 0..1" return True, "" def main(): db = sqlite3.connect(DB) db.row_factory = sqlite3.Row # Zusätzliche Ergebnisfelder columns = { row[1] for row in db.execute("PRAGMA table_info(cases)") } fields = { "classification_json": "TEXT", "classification_error": "TEXT", "classification_seconds": "REAL", } for name, typ in fields.items(): if name not in columns: db.execute( f"ALTER TABLE cases ADD COLUMN {name} {typ}" ) db.commit() rows = db.execute(""" SELECT ticket_id, ticket_number, title, customer_text, agent_text FROM cases WHERE analysis_status = 'ANALYZABLE' AND ( classification_status IS NULL OR classification_status != 'DONE' ) ORDER BY ticket_id """).fetchall() total = len(rows) print("=" * 80) print("ZAMMAD PRODUKTIONSKLASSIFIKATION") print("=" * 80) print(f"Modell: {MODEL}") print(f"Worker: {WORKERS}") print(f"Queue: {total}") print() if not rows: print("Keine offenen Tickets.") db.close() return completed = 0 errors = 0 started = time.monotonic() with ThreadPoolExecutor(max_workers=WORKERS) as executor: futures = { executor.submit(classify, row): row for row in rows } for future in as_completed(futures): row = futures[future] try: ticket, result, elapsed = future.result() valid, error = validate(result) if not valid: errors += 1 db.execute(""" UPDATE cases SET classification_status = 'ERROR', classification_error = ?, classification_seconds = ? WHERE ticket_id = ? """, ( error, elapsed, ticket["ticket_id"], )) print( f"ERROR #{ticket['ticket_number']}: " f"{error}", flush=True, ) else: # Normalisierte Tags tags = list(dict.fromkeys( result.get("tags", []) )) secondary = list(dict.fromkeys( result.get("secondary_intents", []) )) result["tags"] = tags result["secondary_intents"] = secondary db.execute(""" UPDATE cases SET primary_intent = ?, secondary_intents = ?, tags = ?, classification_json = ?, taxonomy_fit = ?, taxonomy_candidate = ?, summary = ?, confidence = ?, classification_seconds = ?, classification_error = NULL, classification_status = 'DONE' WHERE ticket_id = ? """, ( result["primary_intent"], json.dumps( secondary, ensure_ascii=False, ), json.dumps( tags, ensure_ascii=False, ), json.dumps( result, ensure_ascii=False, ), result["taxonomy_fit"], result.get("taxonomy_candidate"), result.get("summary"), float(result.get("confidence", 0)), elapsed, ticket["ticket_id"], )) completed += 1 print( f"[{completed + errors}/{total}] " f"#{ticket['ticket_number']} " f"{elapsed:5.1f}s → " f"{result['primary_intent']} " f"secondary={result.get('secondary_intents') or []} " f"tags={result.get('tags') or []} " f"confidence={result.get('confidence')}", flush=True, ) # Kleine Transaktionen: # Bei Abbruch bleibt praktisch alles bereits Geschriebene erhalten. db.commit() except Exception as exc: errors += 1 db.execute(""" UPDATE cases SET classification_status = 'ERROR', classification_error = ? WHERE ticket_id = ? """, ( str(exc), row["ticket_id"], )) db.commit() print( f"ERROR #{row['ticket_number']}: {exc}", flush=True, ) elapsed_total = time.monotonic() - started print() print("=" * 80) print("LAUF BEENDET") print("=" * 80) print(f"DONE: {completed}") print(f"ERROR: {errors}") print(f"Zeit: {elapsed_total / 3600:.2f} h") if elapsed_total: print( f"Durchsatz: " f"{completed / elapsed_total * 3600:.1f} Tickets/h" ) db.close() if __name__ == "__main__": main()