classify_zammad.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826
  1. #!/usr/bin/env python3
  2. import json
  3. import sqlite3
  4. import time
  5. import urllib.request
  6. from concurrent.futures import ThreadPoolExecutor, as_completed
  7. DB = "data/zammad_analysis.sqlite3"
  8. URL = "http://192.168.1.101:11434/api/chat"
  9. MODEL = "qwen3:8b"
  10. WORKERS = 2
  11. PRIMARY_INTENTS = {
  12. "VERSANDSTATUS",
  13. "LIEFERVERZUG",
  14. "ADRESSÄNDERUNG",
  15. "REKLAMATION",
  16. "TRANSPORTSCHADEN",
  17. "FEHLLIEFERUNG",
  18. "RECHNUNG",
  19. "ZAHLUNG",
  20. "WIDERRUF",
  21. "RETOURE",
  22. "PFLANZENBERATUNG",
  23. "PFLANZENKRANKHEIT",
  24. "SORTENBERATUNG",
  25. "PFLEGEFRAGE",
  26. "BESTANDSANFRAGE",
  27. "VORBESTELLUNG",
  28. "B2B",
  29. "GROSSHANDEL",
  30. "RABATTANFRAGE",
  31. "SONSTIGES",
  32. }
  33. TAGS = {
  34. # Reklamation / Qualität
  35. "QUALITAETSPROBLEM",
  36. "VERWECHSLUNG",
  37. "SCHLECHTVERPACKT",
  38. "FALSCHESORTEGELIEFERT",
  39. # Pflanzenzustand / Pflanzenschäden
  40. "PFLANZE_ABGEBROCHEN",
  41. "PFLANZE_VERTROCKNET",
  42. "PFLANZE_KRANK",
  43. "PFLANZE_MICKRIG",
  44. "PFLANZE_NICHTANGEWACHSEN",
  45. "WEISSE_BLAETTER",
  46. "SCHIMMEL",
  47. "FAULIG",
  48. "PFLANZENKRANKHEIT",
  49. "PILZKRANKHEIT",
  50. "MEHLTAU",
  51. "STERNRUSSTAU",
  52. "SCHÄDLINGE",
  53. "LAUSE",
  54. "PFLANZENSCHADEN",
  55. "WACHSTUMSPROBLEM",
  56. # Mengen-/Lieferfehler
  57. "FALSCHEFARBE",
  58. "NUR_EIN_STUECK",
  59. "FALSCHE_MENGE",
  60. # Produktgruppe
  61. "PRODUKTGRUPPE_ROSE",
  62. "PRODUKTGRUPPE_CLEMATIS",
  63. # Produktform
  64. "PRODUKTFORM_CONTAINER",
  65. "PRODUKTFORM_WURZELWARE",
  66. "PRODUKTFORM_HOCHSTAMM",
  67. # Versand / Logistik
  68. "VERSAND",
  69. "VERSANDKOSTEN",
  70. "PROBLEMMITLIEFERDIENST",
  71. "LIEFERDIENST_DHL",
  72. "LIEFERDIENST_GLS",
  73. "LIEFERDIENST_UNBEKANNT",
  74. "TRANSPORTSCHADEN",
  75. # Service / sonstiger Kontext
  76. "KATALOG",
  77. "ABHOLUNG",
  78. "KUNDENSERVICE",
  79. # Retouren-Merkmale
  80. "TEILRETOURE",
  81. "VOLLRETOURE",
  82. # Rabattverhalten
  83. "RABATTANFRAGE",
  84. "RABATT_GEFORDERT",
  85. "RABATT_SELBSSTAENDIG_ABGEZOGEN",
  86. }
  87. SYSTEM = """
  88. Du analysierst historische Kundenservice-Tickets von Schmid Gartenpflanzen.
  89. Analysiere ausschließlich das tatsächliche Kundenanliegen.
  90. WICHTIG:
  91. Primärintent, Secondary-Intents und insbesondere TAGS dürfen nur
  92. aus dem tatsächlichen KUNDENBEITRAG abgeleitet werden.
  93. Der KUNDENSERVICE-KONTEXT darf NICHT dazu verwendet werden, neue
  94. Kundenprobleme, Zustände, Produktmerkmale oder Tags zu erfinden.
  95. Wenn ein Merkmal nur in der Antwort des Kundenservice vorkommt,
  96. aber nicht aus dem Kundenbeitrag hervorgeht, darf es NICHT als Tag
  97. ausgegeben werden.
  98. KUNDENBEITRAG:
  99. Die eigentliche Quelle für die Klassifikation.
  100. KUNDENSERVICE-KONTEXT:
  101. Nur ergänzender Kontext zur Interpretation bereits im Kundenbeitrag
  102. vorhandener Informationen.
  103. PRIMÄRINTENTS:
  104. VERSANDSTATUS
  105. LIEFERVERZUG
  106. ADRESSÄNDERUNG
  107. REKLAMATION
  108. TRANSPORTSCHADEN
  109. FEHLLIEFERUNG
  110. RECHNUNG
  111. ZAHLUNG
  112. WIDERRUF
  113. RETOURE
  114. PFLANZENBERATUNG
  115. SORTENBERATUNG
  116. PFLEGEFRAGE
  117. BESTANDSANFRAGE
  118. VORBESTELLUNG
  119. B2B
  120. GROSSHANDEL
  121. DÜNGER_FEHLT
  122. SONSTIGES
  123. TAGS:
  124. QUALITAETSPROBLEM
  125. VERWECHSLUNG
  126. SCHLECHTVERPACKT
  127. FALSCHESORTEGELIEFERT
  128. PROBLEMMITLIEFERDIENST
  129. LIEFERDIENST_DHL
  130. LIEFERDIENST_GLS
  131. LIEFERDIENST_UNBEKANNT
  132. TRANSPORTSCHADEN
  133. PFLANZENKRANKHEIT
  134. PFLANZENSCHADEN
  135. WACHSTUMSPROBLEM
  136. REGELN:
  137. BESTANDSANFRAGE:
  138. Jede Frage nach aktueller oder zukünftiger Verfügbarkeit,
  139. Lieferbarkeit oder Bestellbarkeit.
  140. VORBESTELLUNG:
  141. Nur wenn der Kunde ausdrücklich eine Vormerkung, Reservierung
  142. oder verbindliche Vorabbestellung für ein aktuell nicht verfügbares
  143. Produkt verlangt.
  144. Eine reine Frage nach Verfügbarkeit, Lieferbarkeit oder
  145. zukünftiger Bestellbarkeit ist IMMER BESTANDSANFRAGE.
  146. Auch Wünsche wie "ich hätte gerne welche", "ich möchte die gerne
  147. haben" oder "können Sie mir sagen, wann sie wieder da sind" sind
  148. KEINE VORBESTELLUNG, solange keine ausdrückliche Reservierung,
  149. Vormerkung oder Vorabbestellung verlangt wird.
  150. WIDERRUF:
  151. Stornierung oder Rückabwicklung einer Bestellung auf Kundenwunsch,
  152. einschließlich Stornierung vor Versand.
  153. REKLAMATION:
  154. Beanstandung eines gelieferten Produkts oder einer Leistung wegen
  155. Qualität, Zustand oder Beschädigung.
  156. TRANSPORTSCHADEN:
  157. Schaden, der offensichtlich durch Transport oder Versand entstanden ist.
  158. FEHLLIEFERUNG:
  159. Falscher Artikel, falsche Sorte oder falsches Produkt geliefert.
  160. DÜNGER_FEHLT:
  161. Bestellter Dünger fehlt.
  162. QUALITAETSPROBLEM:
  163. Tag, kein Primärintent.
  164. PFLANZENKRANKHEIT:
  165. Tag, kein Primärintent.
  166. PFLANZENSCHADEN:
  167. Tag, kein Primärintent.
  168. WACHSTUMSPROBLEM:
  169. Tag, kein Primärintent.
  170. PROBLEMMITLIEFERDIENST:
  171. Problem mit dem Paketdienst.
  172. LIEFERDIENST_DHL / LIEFERDIENST_GLS:
  173. Nur verwenden, wenn der jeweilige Dienst tatsächlich genannt wird.
  174. MULTI-INTENT:
  175. Mehrere Anliegen erfassen.
  176. TAGS DÜRFEN NIEMALS als secondary_intents ausgegeben werden.
  177. Die folgenden Begriffe sind ausschließlich TAGS:
  178. PRODUKTGRUPPE_ROSE
  179. PRODUKTGRUPPE_CLEMATIS
  180. PRODUKTFORM_CONTAINER
  181. PRODUKTFORM_WURZELWARE
  182. PRODUKTFORM_HOCHSTAMM
  183. VERSAND
  184. VERSANDKOSTEN
  185. PROBLEMMITLIEFERDIENST
  186. LIEFERDIENST_DHL
  187. LIEFERDIENST_GLS
  188. LIEFERDIENST_UNBEKANNT
  189. RETOURE
  190. KATALOG
  191. ABHOLUNG
  192. KUNDENSERVICE
  193. QUALITAETSPROBLEM
  194. VERWECHSLUNG
  195. SCHLECHTVERPACKT
  196. FALSCHESORTEGELIEFERT
  197. PFLANZE_ABGEBROCHEN
  198. PFLANZE_VERTROCKNET
  199. PFLANZE_KRANK
  200. PFLANZE_MICKRIG
  201. PFLANZE_NICHTANGEWACHSEN
  202. WEISSE_BLAETTER
  203. SCHIMMEL
  204. FAULIG
  205. PFLANZENKRANKHEIT
  206. PFLANZENSCHADEN
  207. WACHSTUMSPROBLEM
  208. FALSCHEFARBE
  209. NUR_EIN_STUECK
  210. FALSCHE_MENGE
  211. TRANSPORTSCHADEN
  212. secondary_intents darf ausschließlich einen PRIMÄRINTENT enthalten.
  213. RETOUREN:
  214. RETOURE ist ausschließlich ein PRIMÄRINTENT.
  215. RÜCKSENDUNG, RÜCKSENDEN, ZURÜCKSENDEN, RÜCKGABE,
  216. WARE ZURÜCKSCHICKEN und RETOURE sind Synonyme und
  217. werden als RETOURE klassifiziert.
  218. RÜCKSENDUNG darf NICHT als eigener Tag ausgegeben werden.
  219. TEILRETOURE:
  220. Der Kunde möchte nur einen Teil der Bestellung zurückgeben.
  221. VOLLRETOURE:
  222. Der Kunde möchte die komplette Bestellung zurückgeben.
  223. TEILRETOURE und VOLLRETOURE sind Tags.
  224. WIDERRUF und RETOURE sind zu unterscheiden:
  225. - RETOURE = Ware soll zurückgegeben/zurückgesendet werden.
  226. - WIDERRUF = Kunde erklärt den Widerruf des Kaufvertrags.
  227. Wenn ausdrücklich beides verlangt wird, kann RETOURE als
  228. secondary_intent zu WIDERRUF erscheinen.
  229. PFLANZENKRANKHEITEN / SCHÄDLINGE:
  230. PFLANZENKRANKHEIT = allgemeine oder konkrete Pflanzenkrankheit.
  231. PILZKRANKHEIT = Pilzerkrankung.
  232. MEHLTAU = konkreter Pilzkrankheitsbefund.
  233. STERNRUSSTAU = konkreter Pilzkrankheitsbefund.
  234. SCHÄDLINGE = allgemeiner Schädlingsbefall.
  235. LAUSE = konkreter Befall mit Läusen.
  236. Konkrete Merkmale dürfen zusätzlich zu PFLANZENKRANKHEIT
  237. ausgegeben werden.
  238. RABATTVERHALTEN:
  239. RABATTANFRAGE:
  240. Der Kunde fragt aktiv, ob ein Rabatt oder Preisnachlass möglich ist.
  241. RABATT_GEFORDERT:
  242. Der Kunde fordert ausdrücklich einen Rabatt, Preisnachlass oder
  243. eine bestimmte Rabatt-/Nachlasshöhe.
  244. RABATT_SELBSSTAENDIG_ABGEZOGEN:
  245. Der Kunde hat eigenmächtig einen Rabatt, Nachlass, Betrag oder
  246. Prozentsatz von einer Rechnung oder Zahlung abgezogen, ohne dass
  247. dieser Rabatt vorher vom Unternehmen gewährt wurde.
  248. Rabattmerkmale dürfen ausschließlich aus dem KUNDENBEITRAG abgeleitet
  249. werden. Ein vom Kundenservice angebotener oder gewährter Rabatt ist
  250. KEINE Rabattforderung des Kunden.
  251. Rabatt ist normalerweise kein eigener Primärintent.
  252. Beispiel:
  253. "Die Rose kam beschädigt an, dafür erwarte ich 20 % Rabatt."
  254. → primary_intent = REKLAMATION
  255. → tags = [QUALITAETSPROBLEM, RABATT_GEFORDERT]
  256. → discount.percentage = 20
  257. Beispiel:
  258. "Die Rechnung beträgt 100 Euro, ich habe nur 90 Euro überwiesen."
  259. → primary_intent = ZAHLUNG
  260. → tags = [RABATT_SELBSSTAENDIG_ABGEZOGEN]
  261. → discount.amount = 10
  262. → discount.currency = EUR
  263. → discount.self_deducted = true
  264. RABATT_WIEDERHOLT wird NICHT im einzelnen Ticket vergeben.
  265. Das ist ein späteres kundenbezogenes Aggregat.
  266. DISCOUNT-FELD:
  267. Extrahiere eine konkret genannte Rabatt-/Nachlasshöhe strukturiert.
  268. Wenn Prozent genannt:
  269. discount.percentage = Zahl
  270. Wenn ein konkreter Geldbetrag genannt:
  271. discount.amount = Zahl
  272. discount.currency = erkannte Währung
  273. Wenn keine Höhe genannt:
  274. entsprechender Wert = null
  275. discount.requested = true, wenn der Kunde aktiv Rabatt verlangt/fragt.
  276. discount.self_deducted = true, wenn der Kunde eigenmächtig gekürzt hat.
  277. Wenn kein Rabatt vorkommt:
  278. discount.requested = false
  279. discount.self_deducted = false
  280. discount.amount = null
  281. discount.percentage = null
  282. discount.currency = null
  283. primary_intent:
  284. Das wichtigste Anliegen.
  285. secondary_intents:
  286. Weitere Primärintents, falls vorhanden.
  287. tags:
  288. Nur konkrete Merkmale aus der TAG-Liste.
  289. NIEMALS Primärintents als Tags verwenden.
  290. taxonomy_fit:
  291. GOOD, wenn die Taxonomie gut passt.
  292. PARTIAL, wenn sie nur teilweise passt.
  293. POOR, wenn ein relevantes Anliegen fehlt.
  294. taxonomy_candidate:
  295. Nur bei PARTIAL oder POOR einen möglichen fehlenden Primärintent nennen.
  296. Sonst null.
  297. Keine neuen Primärintents eigenmächtig erfinden.
  298. Antworte ausschließlich mit gültigem JSON.
  299. """
  300. EXPECTED = {
  301. "primary_intent": "",
  302. "secondary_intents": [],
  303. "tags": [],
  304. "complaint_type": None,
  305. "actions": [],
  306. "summary": "",
  307. "taxonomy_fit": "",
  308. "taxonomy_candidate": None,
  309. "confidence": 0.0,
  310. "discount": {
  311. "requested": False,
  312. "self_deducted": False,
  313. "amount": None,
  314. "percentage": None,
  315. "currency": None
  316. },
  317. }
  318. def classify(row):
  319. prompt = f"""
  320. Ticket #{row["ticket_number"]}
  321. BETREFF:
  322. {row["title"] or ""}
  323. KUNDENBEITRAG:
  324. {row["customer_text"] or "(kein Kundentext vorhanden)"}
  325. KUNDENSERVICE-KONTEXT:
  326. {row["agent_text"] or "(kein Kundendiensttext vorhanden)"}
  327. WICHTIG:
  328. Auch ohne Kundentext muss das Ticket anhand von BETREFF und KUNDENSERVICE-KONTEXT klassifiziert werden.
  329. primary_intent MUSS exakt einem Wert aus der vorgegebenen Taxonomie entsprechen.
  330. Verwende KEINE selbst erfundenen Kategorien wie KUNDENSERVICE, ALLGEMEIN,
  331. KUNDENANFRAGE oder ähnliche freie Kategorien.
  332. Wenn kein spezifischer Intent sicher ableitbar ist, verwende SONSTIGES.
  333. taxonomy_fit MUSS exakt einer der Werte GOOD, PARTIAL oder POOR sein.
  334. confidence MUSS eine Zahl zwischen 0 und 1 sein.
  335. secondary_intents dürfen ausschließlich gültige Taxonomie-Intents enthalten.
  336. JSON-SCHEMA:
  337. {json.dumps(EXPECTED, ensure_ascii=False)}
  338. """
  339. payload = {
  340. "model": MODEL,
  341. "messages": [
  342. {"role": "system", "content": SYSTEM},
  343. {"role": "user", "content": prompt},
  344. ],
  345. "stream": False,
  346. "think": False,
  347. "format": "json",
  348. "options": {
  349. "temperature": 0,
  350. },
  351. }
  352. req = urllib.request.Request(
  353. URL,
  354. data=json.dumps(payload, ensure_ascii=False).encode(),
  355. headers={"Content-Type": "application/json"},
  356. )
  357. started = time.monotonic()
  358. with urllib.request.urlopen(req, timeout=300) as response:
  359. raw = json.loads(response.read())
  360. elapsed = time.monotonic() - started
  361. result = json.loads(raw["message"]["content"])
  362. # Harte Taxonomie-Grenze: Modell darf keine eigenen Kategorien erzeugen.
  363. primary = result.get("primary_intent")
  364. if not isinstance(primary, str) or primary.strip() not in PRIMARY_INTENTS:
  365. result["primary_intent"] = "SONSTIGES"
  366. else:
  367. result["primary_intent"] = primary.strip()
  368. fit = result.get("taxonomy_fit")
  369. if fit not in {"GOOD", "PARTIAL", "POOR"}:
  370. result["taxonomy_fit"] = "POOR"
  371. confidence = result.get("confidence")
  372. try:
  373. result["confidence"] = float(confidence)
  374. except (TypeError, ValueError):
  375. result["confidence"] = 0.0
  376. return row, result, elapsed
  377. def validate(result):
  378. if not isinstance(result, dict):
  379. return False, "JSON ist kein Objekt"
  380. primary = result.get("primary_intent")
  381. # Qwen darf bei fehlendem/unsinnigem Kundenanliegen
  382. # keinen leeren Primärintent zurückgeben.
  383. # Solche Fälle werden als SONSTIGES / POOR geführt.
  384. if not primary:
  385. fit = result.get("taxonomy_fit")
  386. if fit == "POOR":
  387. result["primary_intent"] = "SONSTIGES"
  388. primary = "SONSTIGES"
  389. if not result.get("taxonomy_candidate"):
  390. result["taxonomy_candidate"] = "KEINE_ANFRAGE"
  391. else:
  392. return False, "primary_intent fehlt"
  393. if primary not in PRIMARY_INTENTS:
  394. return False, f"Ungültiger primary_intent: {primary}"
  395. secondary = result.get("secondary_intents", [])
  396. tags = result.get("tags", [])
  397. if not isinstance(secondary, list):
  398. return False, "secondary_intents ist keine Liste"
  399. if not isinstance(tags, list):
  400. return False, "tags ist keine Liste"
  401. # Qwen verwechselt gelegentlich Tags mit secondary_intents.
  402. # Bekannte Tags werden automatisch in die Tag-Liste verschoben.
  403. secondary_clean = []
  404. for item in secondary:
  405. if item in TAGS:
  406. tags.append(item)
  407. elif item in PRIMARY_INTENTS:
  408. secondary_clean.append(item)
  409. else:
  410. return False, f"Ungültige secondary_intent: {item}"
  411. secondary = secondary_clean
  412. # Primärintent darf nicht zusätzlich als secondary_intent erscheinen.
  413. primary = result.get("primary_intent")
  414. secondary = [
  415. x for x in secondary
  416. if x != primary
  417. ]
  418. # Primärintents dürfen nicht als Tags erscheinen.
  419. tags = [
  420. x for x in tags
  421. if x not in PRIMARY_INTENTS
  422. ]
  423. tags = list(dict.fromkeys(tags))
  424. secondary = list(dict.fromkeys(secondary))
  425. invalid_tags = [
  426. x for x in tags
  427. if x not in TAGS
  428. ]
  429. if invalid_tags:
  430. return False, f"Ungültige Tags: {invalid_tags}"
  431. result["secondary_intents"] = secondary
  432. result["tags"] = tags
  433. fit = result.get("taxonomy_fit")
  434. if fit not in {"GOOD", "PARTIAL", "POOR"}:
  435. return False, f"Ungültiges taxonomy_fit: {fit}"
  436. # Rabattdaten validieren
  437. discount = result.get("discount", {})
  438. if not isinstance(discount, dict):
  439. return False, "discount ist kein Objekt"
  440. for key in ("requested", "self_deducted"):
  441. if key not in discount:
  442. discount[key] = False
  443. if not isinstance(discount[key], bool):
  444. return False, f"discount.{key} ist kein Boolean"
  445. for key in ("amount", "percentage"):
  446. if key not in discount:
  447. discount[key] = None
  448. if discount[key] is not None:
  449. try:
  450. discount[key] = float(discount[key])
  451. except Exception:
  452. return False, f"discount.{key} ist nicht numerisch"
  453. if discount[key] < 0:
  454. return False, f"discount.{key} ist negativ"
  455. if "currency" not in discount:
  456. discount["currency"] = None
  457. if discount["currency"] is not None:
  458. discount["currency"] = str(discount["currency"]).upper()
  459. result["discount"] = discount
  460. confidence = result.get("confidence")
  461. try:
  462. confidence = float(confidence)
  463. except Exception:
  464. return False, "confidence nicht numerisch"
  465. if not 0 <= confidence <= 1:
  466. return False, "confidence außerhalb 0..1"
  467. return True, ""
  468. def main():
  469. db = sqlite3.connect(DB)
  470. db.row_factory = sqlite3.Row
  471. # Zusätzliche Ergebnisfelder
  472. columns = {
  473. row[1]
  474. for row in db.execute("PRAGMA table_info(cases)")
  475. }
  476. fields = {
  477. "classification_json": "TEXT",
  478. "classification_error": "TEXT",
  479. "classification_seconds": "REAL",
  480. }
  481. for name, typ in fields.items():
  482. if name not in columns:
  483. db.execute(
  484. f"ALTER TABLE cases ADD COLUMN {name} {typ}"
  485. )
  486. db.commit()
  487. rows = db.execute("""
  488. SELECT
  489. ticket_id,
  490. ticket_number,
  491. title,
  492. customer_text,
  493. agent_text
  494. FROM cases
  495. WHERE analysis_status = 'ANALYZABLE'
  496. AND (
  497. classification_status IS NULL
  498. OR classification_status != 'DONE'
  499. )
  500. ORDER BY ticket_id
  501. """).fetchall()
  502. total = len(rows)
  503. print("=" * 80)
  504. print("ZAMMAD PRODUKTIONSKLASSIFIKATION")
  505. print("=" * 80)
  506. print(f"Modell: {MODEL}")
  507. print(f"Worker: {WORKERS}")
  508. print(f"Queue: {total}")
  509. print()
  510. if not rows:
  511. print("Keine offenen Tickets.")
  512. db.close()
  513. return
  514. completed = 0
  515. errors = 0
  516. started = time.monotonic()
  517. with ThreadPoolExecutor(max_workers=WORKERS) as executor:
  518. futures = {
  519. executor.submit(classify, row): row
  520. for row in rows
  521. }
  522. for future in as_completed(futures):
  523. row = futures[future]
  524. try:
  525. ticket, result, elapsed = future.result()
  526. valid, error = validate(result)
  527. if not valid:
  528. errors += 1
  529. db.execute("""
  530. UPDATE cases
  531. SET
  532. classification_status = 'ERROR',
  533. classification_error = ?,
  534. classification_seconds = ?
  535. WHERE ticket_id = ?
  536. """, (
  537. error,
  538. elapsed,
  539. ticket["ticket_id"],
  540. ))
  541. print(
  542. f"ERROR #{ticket['ticket_number']}: "
  543. f"{error}",
  544. flush=True,
  545. )
  546. else:
  547. # Normalisierte Tags
  548. tags = list(dict.fromkeys(
  549. result.get("tags", [])
  550. ))
  551. secondary = list(dict.fromkeys(
  552. result.get("secondary_intents", [])
  553. ))
  554. result["tags"] = tags
  555. result["secondary_intents"] = secondary
  556. db.execute("""
  557. UPDATE cases
  558. SET
  559. primary_intent = ?,
  560. secondary_intents = ?,
  561. tags = ?,
  562. classification_json = ?,
  563. taxonomy_fit = ?,
  564. taxonomy_candidate = ?,
  565. summary = ?,
  566. confidence = ?,
  567. classification_seconds = ?,
  568. classification_error = NULL,
  569. classification_status = 'DONE'
  570. WHERE ticket_id = ?
  571. """, (
  572. result["primary_intent"],
  573. json.dumps(
  574. secondary,
  575. ensure_ascii=False,
  576. ),
  577. json.dumps(
  578. tags,
  579. ensure_ascii=False,
  580. ),
  581. json.dumps(
  582. result,
  583. ensure_ascii=False,
  584. ),
  585. result["taxonomy_fit"],
  586. result.get("taxonomy_candidate"),
  587. result.get("summary"),
  588. float(result.get("confidence", 0)),
  589. elapsed,
  590. ticket["ticket_id"],
  591. ))
  592. completed += 1
  593. print(
  594. f"[{completed + errors}/{total}] "
  595. f"#{ticket['ticket_number']} "
  596. f"{elapsed:5.1f}s → "
  597. f"{result['primary_intent']} "
  598. f"secondary={result.get('secondary_intents') or []} "
  599. f"tags={result.get('tags') or []} "
  600. f"confidence={result.get('confidence')}",
  601. flush=True,
  602. )
  603. # Kleine Transaktionen:
  604. # Bei Abbruch bleibt praktisch alles bereits Geschriebene erhalten.
  605. db.commit()
  606. except Exception as exc:
  607. errors += 1
  608. db.execute("""
  609. UPDATE cases
  610. SET
  611. classification_status = 'ERROR',
  612. classification_error = ?
  613. WHERE ticket_id = ?
  614. """, (
  615. str(exc),
  616. row["ticket_id"],
  617. ))
  618. db.commit()
  619. print(
  620. f"ERROR #{row['ticket_number']}: {exc}",
  621. flush=True,
  622. )
  623. elapsed_total = time.monotonic() - started
  624. print()
  625. print("=" * 80)
  626. print("LAUF BEENDET")
  627. print("=" * 80)
  628. print(f"DONE: {completed}")
  629. print(f"ERROR: {errors}")
  630. print(f"Zeit: {elapsed_total / 3600:.2f} h")
  631. if elapsed_total:
  632. print(
  633. f"Durchsatz: "
  634. f"{completed / elapsed_total * 3600:.1f} Tickets/h"
  635. )
  636. db.close()
  637. if __name__ == "__main__":
  638. main()