| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436 |
- from __future__ import annotations
- import json
- import re
- from dataclasses import dataclass
- from typing import Any
- from app.kontor_mcp import KontorMCPClient
- @dataclass
- class Resolution:
- status: str
- value: str | None = None
- source: str | None = None
- confidence: float | None = None
- candidates: list[Any] | None = None
- data: dict[str, Any] | None = None
- _NUMBER_WORDS = {
- "null": "0",
- "eins": "1",
- "ein": "1",
- "zwei": "2",
- "drei": "3",
- "vier": "4",
- "fünf": "5",
- "fuenf": "5",
- "sechs": "6",
- "sieben": "7",
- "acht": "8",
- "neun": "9",
- }
- def unwrap_tool_result(result: Any) -> dict[str, Any]:
- """
- MCP tools/call liefert:
- structuredContent.result = JSON-String
- Fallback:
- content[0].text = JSON-String
- """
- if not isinstance(result, dict):
- return {}
- structured = result.get("structuredContent")
- if isinstance(structured, dict):
- raw = structured.get("result")
- if isinstance(raw, dict):
- return raw
- if isinstance(raw, str):
- try:
- data = json.loads(raw)
- if isinstance(data, dict):
- return data
- except json.JSONDecodeError:
- pass
- content = result.get("content")
- if isinstance(content, list):
- for item in content:
- if not isinstance(item, dict):
- continue
- raw = item.get("text")
- if not isinstance(raw, str):
- continue
- try:
- data = json.loads(raw)
- if isinstance(data, dict):
- return data
- except json.JSONDecodeError:
- continue
- return {}
- def normalize_number_candidate(value: str) -> str | None:
- """
- Normalisiert mögliche Kunden-/Bestellnummern.
- Wichtig:
- Eine beliebige Textfolge wird NICHT automatisch numerisch.
- """
- text = value.strip().lower()
- if not text:
- return None
- # Reine Ziffern
- if re.fullmatch(r"\d{3,20}", text):
- return text
- # Zahlen mit typischen gesprochenen Trennzeichen
- compact = re.sub(r"[\s.,/-]+", "", text)
- if re.fullmatch(r"\d{3,20}", compact):
- return compact
- # Gesprochene einzelne Ziffern
- tokens = re.findall(r"[a-zäöü]+", text)
- if not tokens:
- return None
- digits: list[str] = []
- for token in tokens:
- digit = _NUMBER_WORDS.get(token)
- if digit is None:
- return None
- digits.append(digit)
- result = "".join(digits)
- if 3 <= len(result) <= 20:
- return result
- return None
- async def resolve_customer_number(
- client: KontorMCPClient,
- raw_value: str,
- ) -> Resolution:
- number = normalize_number_candidate(raw_value)
- if not number:
- return Resolution(
- status="invalid",
- source="normalizer",
- )
- raw = await client.find_customer_by_number(number)
- result = unwrap_tool_result(raw)
- return Resolution(
- status=result.get("status", "unknown"),
- value=number,
- source="kontor_mcp",
- confidence=result.get("confidence"),
- candidates=result.get("candidates", []),
- data=result,
- )
- async def resolve_order_number(
- client: KontorMCPClient,
- raw_value: str,
- ) -> Resolution:
- number = normalize_number_candidate(raw_value)
- if not number:
- return Resolution(
- status="invalid",
- source="normalizer",
- )
- raw = await client.get_order_context(number)
- result = unwrap_tool_result(raw)
- status = result.get("status", "unknown")
- if result.get("found") is False:
- status = "not_found"
- return Resolution(
- status=status,
- value=number,
- source="kontor_mcp",
- confidence=result.get("confidence"),
- data=result,
- )
- async def resolve_product(
- client: KontorMCPClient,
- raw_value: str,
- ) -> Resolution:
- text = raw_value.strip()
- if not text:
- return Resolution(
- status="invalid",
- source="normalizer",
- )
- raw = await client.search_products(text)
- result = unwrap_tool_result(raw)
- status = result.get("status", "unknown")
- candidates = result.get("matches", [])
- value = None
- confidence = None
- if status in {"exact", "high_confidence"} and candidates:
- first = candidates[0]
- if isinstance(first, dict):
- value = first.get("name")
- confidence = first.get("score")
- return Resolution(
- status=status,
- value=value,
- source="kontor_mcp",
- confidence=confidence,
- candidates=candidates,
- data=result,
- )
- async def resolve_product_with_context(
- client: KontorMCPClient,
- raw_value: str,
- order_data: dict[str, Any] | None = None,
- ) -> Resolution:
- """
- Produktauflösung mit Bestellkontext.
- Priorität:
- 1. exakter Produkt-ID-Match
- 2. konservativer Namensmatch gegen Bestellartikel
- 3. globales MCP-Ergebnis
- Ein Bestellartikel darf einen globalen Treffer ersetzen,
- wenn der Gesprächsbegriff eindeutig auf genau einen Artikel
- der bekannten Bestellung passt.
- """
- global_resolution = await resolve_product(
- client,
- raw_value,
- )
- if not order_data:
- return global_resolution
- order = order_data.get("order", {})
- items = order.get("items", [])
- if not isinstance(items, list):
- return global_resolution
- candidates = global_resolution.candidates or []
- # ------------------------------------------------------------
- # 1. Harte Produkt-ID-Übereinstimmung
- # ------------------------------------------------------------
- candidate_ids = {
- item.get("product_id")
- for item in candidates
- if isinstance(item, dict)
- and item.get("product_id") is not None
- }
- id_matches = [
- item
- for item in items
- if isinstance(item, dict)
- and item.get("product_id") in candidate_ids
- ]
- if len(id_matches) == 1:
- item = id_matches[0]
- return Resolution(
- status="high_confidence",
- value=item.get("name"),
- source="kontor_mcp_order_context",
- confidence=0.98,
- candidates=[item],
- data={
- "resolution": "order_context_product_id",
- "order_item": item,
- "global_candidates": candidates,
- },
- )
- # ------------------------------------------------------------
- # 2. Konservativer Textmatch
- # ------------------------------------------------------------
- def tokens(value: str) -> set[str]:
- value = value.lower()
- value = re.sub(
- r"[·•|,/()\-]+",
- " ",
- value,
- )
- ignored = {
- "cl",
- "container",
- "wurzelnackt",
- "wurzel",
- "topf",
- "liter",
- "l",
- "stk",
- "stück",
- }
- return {
- token
- for token in re.sub(
- r"[^a-z0-9äöüß]+",
- " ",
- value,
- ).split()
- if len(token) >= 4
- and token not in ignored
- }
- query_tokens = tokens(raw_value)
- # Allgemeine Begriffe reichen niemals alleine für eine
- # kontextuelle automatische Zuordnung.
- generic = {
- "rose",
- "rosen",
- "clematis",
- "dünger",
- "duenger",
- "erde",
- "sack",
- "pflanze",
- }
- meaningful = query_tokens - generic
- if meaningful:
- from difflib import SequenceMatcher
- matches: list[tuple[float, dict[str, Any]]] = []
- for item in items:
- if not isinstance(item, dict):
- continue
- name = str(item.get("name") or "")
- item_tokens = tokens(name)
- if not item_tokens:
- continue
- total = 0.0
- valid = True
- for query_token in meaningful:
- best = 0.0
- for item_token in item_tokens:
- if query_token == item_token:
- best = 1.0
- break
- # einfache Flexionsvariante:
- # Royal ↔ Royale
- if (
- query_token.rstrip("e")
- == item_token.rstrip("e")
- and len(query_token) >= 5
- ):
- best = max(best, 0.95)
- continue
- best = max(
- best,
- SequenceMatcher(
- None,
- query_token,
- item_token,
- ).ratio(),
- )
- if best < 0.86:
- valid = False
- break
- total += best
- if valid:
- matches.append(
- (
- total / len(meaningful),
- item,
- )
- )
- # Nur genau einen Artikel automatisch übernehmen.
- unique: dict[tuple[Any, Any], tuple[float, dict[str, Any]]] = {}
- for score, item in matches:
- key = (
- item.get("product_id"),
- item.get("name"),
- )
- unique[key] = (score, item)
- if len(unique) == 1:
- score, item = next(
- iter(unique.values())
- )
- return Resolution(
- status="high_confidence",
- value=item.get("name"),
- source="kontor_mcp_order_context_name",
- confidence=min(0.96, max(0.90, score)),
- candidates=[item],
- data={
- "resolution": "order_context_name",
- "order_item": item,
- "global_candidates": candidates,
- "match_score": score,
- },
- )
- # ------------------------------------------------------------
- # 3. Keine sichere Kontextauflösung:
- # globales Ergebnis unverändert zurückgeben.
- # ------------------------------------------------------------
- return global_resolution
|