kontor_resolver.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
  1. from __future__ import annotations
  2. import json
  3. import re
  4. from dataclasses import dataclass
  5. from typing import Any
  6. from app.kontor_mcp import KontorMCPClient
  7. @dataclass
  8. class Resolution:
  9. status: str
  10. value: str | None = None
  11. source: str | None = None
  12. confidence: float | None = None
  13. candidates: list[Any] | None = None
  14. data: dict[str, Any] | None = None
  15. _NUMBER_WORDS = {
  16. "null": "0",
  17. "eins": "1",
  18. "ein": "1",
  19. "zwei": "2",
  20. "drei": "3",
  21. "vier": "4",
  22. "fünf": "5",
  23. "fuenf": "5",
  24. "sechs": "6",
  25. "sieben": "7",
  26. "acht": "8",
  27. "neun": "9",
  28. }
  29. def unwrap_tool_result(result: Any) -> dict[str, Any]:
  30. """
  31. MCP tools/call liefert:
  32. structuredContent.result = JSON-String
  33. Fallback:
  34. content[0].text = JSON-String
  35. """
  36. if not isinstance(result, dict):
  37. return {}
  38. structured = result.get("structuredContent")
  39. if isinstance(structured, dict):
  40. raw = structured.get("result")
  41. if isinstance(raw, dict):
  42. return raw
  43. if isinstance(raw, str):
  44. try:
  45. data = json.loads(raw)
  46. if isinstance(data, dict):
  47. return data
  48. except json.JSONDecodeError:
  49. pass
  50. content = result.get("content")
  51. if isinstance(content, list):
  52. for item in content:
  53. if not isinstance(item, dict):
  54. continue
  55. raw = item.get("text")
  56. if not isinstance(raw, str):
  57. continue
  58. try:
  59. data = json.loads(raw)
  60. if isinstance(data, dict):
  61. return data
  62. except json.JSONDecodeError:
  63. continue
  64. return {}
  65. def normalize_number_candidate(value: str) -> str | None:
  66. """
  67. Normalisiert mögliche Kunden-/Bestellnummern.
  68. Wichtig:
  69. Eine beliebige Textfolge wird NICHT automatisch numerisch.
  70. """
  71. text = value.strip().lower()
  72. if not text:
  73. return None
  74. # Reine Ziffern
  75. if re.fullmatch(r"\d{3,20}", text):
  76. return text
  77. # Zahlen mit typischen gesprochenen Trennzeichen
  78. compact = re.sub(r"[\s.,/-]+", "", text)
  79. if re.fullmatch(r"\d{3,20}", compact):
  80. return compact
  81. # Gesprochene einzelne Ziffern
  82. tokens = re.findall(r"[a-zäöü]+", text)
  83. if not tokens:
  84. return None
  85. digits: list[str] = []
  86. for token in tokens:
  87. digit = _NUMBER_WORDS.get(token)
  88. if digit is None:
  89. return None
  90. digits.append(digit)
  91. result = "".join(digits)
  92. if 3 <= len(result) <= 20:
  93. return result
  94. return None
  95. async def resolve_customer_number(
  96. client: KontorMCPClient,
  97. raw_value: str,
  98. ) -> Resolution:
  99. number = normalize_number_candidate(raw_value)
  100. if not number:
  101. return Resolution(
  102. status="invalid",
  103. source="normalizer",
  104. )
  105. raw = await client.find_customer_by_number(number)
  106. result = unwrap_tool_result(raw)
  107. return Resolution(
  108. status=result.get("status", "unknown"),
  109. value=number,
  110. source="kontor_mcp",
  111. confidence=result.get("confidence"),
  112. candidates=result.get("candidates", []),
  113. data=result,
  114. )
  115. async def resolve_order_number(
  116. client: KontorMCPClient,
  117. raw_value: str,
  118. ) -> Resolution:
  119. number = normalize_number_candidate(raw_value)
  120. if not number:
  121. return Resolution(
  122. status="invalid",
  123. source="normalizer",
  124. )
  125. raw = await client.get_order_context(number)
  126. result = unwrap_tool_result(raw)
  127. status = result.get("status", "unknown")
  128. if result.get("found") is False:
  129. status = "not_found"
  130. return Resolution(
  131. status=status,
  132. value=number,
  133. source="kontor_mcp",
  134. confidence=result.get("confidence"),
  135. data=result,
  136. )
  137. async def resolve_product(
  138. client: KontorMCPClient,
  139. raw_value: str,
  140. ) -> Resolution:
  141. text = raw_value.strip()
  142. if not text:
  143. return Resolution(
  144. status="invalid",
  145. source="normalizer",
  146. )
  147. raw = await client.search_products(text)
  148. result = unwrap_tool_result(raw)
  149. status = result.get("status", "unknown")
  150. candidates = result.get("matches", [])
  151. value = None
  152. confidence = None
  153. if status in {"exact", "high_confidence"} and candidates:
  154. first = candidates[0]
  155. if isinstance(first, dict):
  156. value = first.get("name")
  157. confidence = first.get("score")
  158. return Resolution(
  159. status=status,
  160. value=value,
  161. source="kontor_mcp",
  162. confidence=confidence,
  163. candidates=candidates,
  164. data=result,
  165. )
  166. async def resolve_product_with_context(
  167. client: KontorMCPClient,
  168. raw_value: str,
  169. order_data: dict[str, Any] | None = None,
  170. ) -> Resolution:
  171. """
  172. Produktauflösung mit Bestellkontext.
  173. Priorität:
  174. 1. exakter Produkt-ID-Match
  175. 2. konservativer Namensmatch gegen Bestellartikel
  176. 3. globales MCP-Ergebnis
  177. Ein Bestellartikel darf einen globalen Treffer ersetzen,
  178. wenn der Gesprächsbegriff eindeutig auf genau einen Artikel
  179. der bekannten Bestellung passt.
  180. """
  181. global_resolution = await resolve_product(
  182. client,
  183. raw_value,
  184. )
  185. if not order_data:
  186. return global_resolution
  187. order = order_data.get("order", {})
  188. items = order.get("items", [])
  189. if not isinstance(items, list):
  190. return global_resolution
  191. candidates = global_resolution.candidates or []
  192. # ------------------------------------------------------------
  193. # 1. Harte Produkt-ID-Übereinstimmung
  194. # ------------------------------------------------------------
  195. candidate_ids = {
  196. item.get("product_id")
  197. for item in candidates
  198. if isinstance(item, dict)
  199. and item.get("product_id") is not None
  200. }
  201. id_matches = [
  202. item
  203. for item in items
  204. if isinstance(item, dict)
  205. and item.get("product_id") in candidate_ids
  206. ]
  207. if len(id_matches) == 1:
  208. item = id_matches[0]
  209. return Resolution(
  210. status="high_confidence",
  211. value=item.get("name"),
  212. source="kontor_mcp_order_context",
  213. confidence=0.98,
  214. candidates=[item],
  215. data={
  216. "resolution": "order_context_product_id",
  217. "order_item": item,
  218. "global_candidates": candidates,
  219. },
  220. )
  221. # ------------------------------------------------------------
  222. # 2. Konservativer Textmatch
  223. # ------------------------------------------------------------
  224. def tokens(value: str) -> set[str]:
  225. value = value.lower()
  226. value = re.sub(
  227. r"[·•|,/()\-]+",
  228. " ",
  229. value,
  230. )
  231. ignored = {
  232. "cl",
  233. "container",
  234. "wurzelnackt",
  235. "wurzel",
  236. "topf",
  237. "liter",
  238. "l",
  239. "stk",
  240. "stück",
  241. }
  242. return {
  243. token
  244. for token in re.sub(
  245. r"[^a-z0-9äöüß]+",
  246. " ",
  247. value,
  248. ).split()
  249. if len(token) >= 4
  250. and token not in ignored
  251. }
  252. query_tokens = tokens(raw_value)
  253. # Allgemeine Begriffe reichen niemals alleine für eine
  254. # kontextuelle automatische Zuordnung.
  255. generic = {
  256. "rose",
  257. "rosen",
  258. "clematis",
  259. "dünger",
  260. "duenger",
  261. "erde",
  262. "sack",
  263. "pflanze",
  264. }
  265. meaningful = query_tokens - generic
  266. if meaningful:
  267. from difflib import SequenceMatcher
  268. matches: list[tuple[float, dict[str, Any]]] = []
  269. for item in items:
  270. if not isinstance(item, dict):
  271. continue
  272. name = str(item.get("name") or "")
  273. item_tokens = tokens(name)
  274. if not item_tokens:
  275. continue
  276. total = 0.0
  277. valid = True
  278. for query_token in meaningful:
  279. best = 0.0
  280. for item_token in item_tokens:
  281. if query_token == item_token:
  282. best = 1.0
  283. break
  284. # einfache Flexionsvariante:
  285. # Royal ↔ Royale
  286. if (
  287. query_token.rstrip("e")
  288. == item_token.rstrip("e")
  289. and len(query_token) >= 5
  290. ):
  291. best = max(best, 0.95)
  292. continue
  293. best = max(
  294. best,
  295. SequenceMatcher(
  296. None,
  297. query_token,
  298. item_token,
  299. ).ratio(),
  300. )
  301. if best < 0.86:
  302. valid = False
  303. break
  304. total += best
  305. if valid:
  306. matches.append(
  307. (
  308. total / len(meaningful),
  309. item,
  310. )
  311. )
  312. # Nur genau einen Artikel automatisch übernehmen.
  313. unique: dict[tuple[Any, Any], tuple[float, dict[str, Any]]] = {}
  314. for score, item in matches:
  315. key = (
  316. item.get("product_id"),
  317. item.get("name"),
  318. )
  319. unique[key] = (score, item)
  320. if len(unique) == 1:
  321. score, item = next(
  322. iter(unique.values())
  323. )
  324. return Resolution(
  325. status="high_confidence",
  326. value=item.get("name"),
  327. source="kontor_mcp_order_context_name",
  328. confidence=min(0.96, max(0.90, score)),
  329. candidates=[item],
  330. data={
  331. "resolution": "order_context_name",
  332. "order_item": item,
  333. "global_candidates": candidates,
  334. "match_score": score,
  335. },
  336. )
  337. # ------------------------------------------------------------
  338. # 3. Keine sichere Kontextauflösung:
  339. # globales Ergebnis unverändert zurückgeben.
  340. # ------------------------------------------------------------
  341. return global_resolution