Spaces:
Sleeping
Sleeping
| """ | |
| Intent classification + slot validation. | |
| Thin layer over GeminiClient that: | |
| 1. Calls Gemini for raw classification | |
| 2. Normalizes slot values (kabupaten name → kab_id, commodity name → code) | |
| 3. Returns a structured Intent object that handlers can consume safely | |
| """ | |
| from __future__ import annotations | |
| from dataclasses import dataclass, field | |
| from typing import Any, Dict, Optional | |
| from .gemini_client import GeminiClient | |
| # ============================================================================= | |
| # INTENT TYPES | |
| # ============================================================================= | |
| INTENT_HARGA_LOOKUP = "harga_lookup" | |
| INTENT_CARI_PEMBELI = "cari_pembeli" | |
| INTENT_CARI_PENJUAL = "cari_penjual" | |
| INTENT_FORECAST = "forecast" | |
| INTENT_ANOMALI = "anomali" | |
| INTENT_FALLBACK = "fallback" | |
| VALID_INTENTS = { | |
| INTENT_HARGA_LOOKUP, INTENT_CARI_PEMBELI, | |
| INTENT_CARI_PENJUAL, INTENT_FORECAST, | |
| INTENT_ANOMALI, INTENT_FALLBACK, | |
| } | |
| class Intent: | |
| name: str | |
| slots: Dict[str, Any] = field(default_factory=dict) | |
| raw_message: str = "" | |
| def commodity(self) -> Optional[str]: | |
| """Normalized commodity code (e.g. 'cabai_merah') or None.""" | |
| return self.slots.get("commodity") | |
| def commodity_raw(self) -> Optional[str]: | |
| """ | |
| What the user called the commodity when it resolved to nothing. | |
| Set only when a commodity WAS named but falls outside the loaded | |
| dataset, so a handler can say "kentang is not covered" instead of | |
| asking for a commodity name the user already gave. | |
| """ | |
| return self.slots.get("commodity_raw") | |
| def kabupaten_id(self) -> Optional[str]: | |
| """Normalized kabupaten id (e.g. '3578') for whichever slot is relevant.""" | |
| return self.slots.get("kabupaten_id") | |
| def kabupaten_name(self) -> Optional[str]: | |
| return self.slots.get("kabupaten_name") | |
| def volume_tons(self) -> Optional[float]: | |
| return self.slots.get("volume_tons") | |
| # ============================================================================= | |
| # NORMALIZATION | |
| # ============================================================================= | |
| def _normalize_kabupaten( | |
| raw_name: Optional[str], | |
| kabupaten_lookup: Dict[str, Any], # kab_id → Kabupaten | |
| ) -> tuple[Optional[str], Optional[str]]: | |
| """ | |
| Resolve a free-text kabupaten name to (kab_id, canonical_name). | |
| Tolerates 'Kota Malang' / 'Malang' / 'malang'. | |
| """ | |
| if not raw_name: | |
| return None, None | |
| needle = raw_name.strip().lower() | |
| # Strip common prefixes | |
| for prefix in ("kabupaten ", "kab ", "kota "): | |
| if needle.startswith(prefix): | |
| needle = needle[len(prefix):] | |
| # Resolve ambiguity: when the user types "Kediri", both "Kab Kediri" and | |
| # "Kota Kediri" contain it. Prefer the kabupaten (non-Kota) unless the | |
| # user explicitly typed "Kota". Fall back to first match if nothing wins. | |
| user_wants_kota = raw_name.strip().lower().startswith("kota ") | |
| candidates: list[tuple[str, str, bool]] = [] # (id, nama, is_kota) | |
| for kab_id, kab in kabupaten_lookup.items(): | |
| kname = kab.nama.lower() | |
| is_kota = kname.startswith("kota ") | |
| for prefix in ("kabupaten ", "kab ", "kota "): | |
| if kname.startswith(prefix): | |
| kname = kname[len(prefix):] | |
| if needle == kname or needle in kname or kname in needle: | |
| candidates.append((kab_id, kab.nama, is_kota)) | |
| if not candidates: | |
| return None, None | |
| # Prefer match aligned with user's stated form (Kota vs Kabupaten) | |
| aligned = [c for c in candidates if c[2] == user_wants_kota] | |
| chosen = aligned[0] if aligned else candidates[0] | |
| return chosen[0], chosen[1] | |
| def _normalize_commodity( | |
| raw_code: Optional[str], | |
| commodity_lookup: Dict[str, Any], # code → Commodity | |
| ) -> Optional[str]: | |
| """Validate that LLM-extracted commodity code actually exists.""" | |
| if not raw_code: | |
| return None | |
| needle = raw_code.strip().lower() | |
| if needle in commodity_lookup: | |
| return needle | |
| # Fuzzy fallback — match by partial nama | |
| for code, commodity in commodity_lookup.items(): | |
| if needle in code or needle in commodity.nama.lower(): | |
| return code | |
| return None | |
| # ============================================================================= | |
| # CLASSIFY + NORMALIZE PIPELINE | |
| # ============================================================================= | |
| def classify( | |
| message: str, | |
| gemini: GeminiClient, | |
| kabupaten_lookup: Dict[str, Any], | |
| commodity_lookup: Dict[str, Any], | |
| ) -> Intent: | |
| """End-to-end: raw message → validated Intent with normalized slots.""" | |
| raw = gemini.classify_intent(message) | |
| name = raw.get("intent", INTENT_FALLBACK) | |
| if name not in VALID_INTENTS: | |
| name = INTENT_FALLBACK | |
| slots = dict(raw.get("slots") or {}) | |
| # Normalize commodity → code | |
| if "commodity" in slots: | |
| raw_commodity = slots["commodity"] | |
| slots["commodity"] = _normalize_commodity(raw_commodity, commodity_lookup) | |
| # Keep the original wording when it resolves to nothing. The dataset | |
| # covers 6 commodities, so "kentang" is a routine miss, and the two | |
| # cases need different replies: one asks for a missing slot, the other | |
| # states what the platform actually covers. | |
| if raw_commodity and not slots["commodity"]: | |
| slots["commodity_raw"] = str(raw_commodity).strip() | |
| # Pick whichever kabupaten slot the intent uses + normalize it | |
| raw_kab = ( | |
| slots.pop("kabupaten", None) | |
| or slots.pop("kabupaten_origin", None) | |
| or slots.pop("kabupaten_dest", None) | |
| ) | |
| kab_id, kab_name = _normalize_kabupaten(raw_kab, kabupaten_lookup) | |
| slots["kabupaten_id"] = kab_id | |
| slots["kabupaten_name"] = kab_name | |
| # Coerce volume to float if present | |
| if "volume_tons" in slots and slots["volume_tons"] is not None: | |
| try: | |
| slots["volume_tons"] = float(slots["volume_tons"]) | |
| except (TypeError, ValueError): | |
| slots["volume_tons"] = None | |
| return Intent(name=name, slots=slots, raw_message=message) | |