Download state_manager.py from NabilHzs/payparse: direct link, hf CLI and curl.
- Browser
- Download file 19 kB
-
https://huggingface.co/spaces/NabilHzs/payparse/resolve/main/state_manager.py
- Command line
-
hf download hf://spaces/NabilHzs/payparse/state_manager.py
-
curl -L -o state_manager.py https://huggingface.co/spaces/NabilHzs/payparse/resolve/main/state_manager.py
19 kB
| """ | |
| state_manager.py | |
| ---------------- | |
| Slot-filling state machine for PayParse. | |
| Given the persistent `DialogueState` and a fresh `NLUResult`, this module: | |
| 1. Intercepts ya/tidak answers when a transaction awaits confirmation. | |
| 2. Merges newly extracted entities (non-destructively). | |
| 3. Handles mid-dialogue intent switches. | |
| 4. Resolves `target_kontak` (contact name / pronoun) into a real phone number | |
| BEFORE deciding which slots are still missing. | |
| 5. Computes which required slots are still missing (`REQUIRED_SLOTS`). | |
| 6. Produces the assistant reply: a follow-up question (template-based, with | |
| optional LLM paraphrase — "hybrid" mode), a confirmation summary, or the | |
| final transaction payload once the user says "ya". | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| import re | |
| from dataclasses import dataclass | |
| from typing import Optional | |
| from schema import ( | |
| REQUIRED_SLOTS, | |
| DialogueState, | |
| DialogueStatus, | |
| IntentType, | |
| NLUResult, | |
| TransactionEntities, | |
| ) | |
| logger = logging.getLogger(__name__) | |
| MAX_TURNS = 8 # guard against endless clarification loops | |
| # --------------------------------------------------------------------------- | |
| # Contact directory (stub for a real address-book / contacts service) | |
| # Keys MUST be lowercase; lookups are done on `target_kontak.lower()`. | |
| # --------------------------------------------------------------------------- | |
| DUMMY_CONTACTS: dict[str, str] = { | |
| "anton": "081222333444", | |
| "budi": "08555666777", | |
| "nomorku": "08999888777", | |
| "nomor ini": "08999888777", | |
| "nomor saya": "08999888777", | |
| } | |
| # Stub GoPay registration check — in production this would query the GoPay API. | |
| # Numbers in DUMMY_CONTACTS are "registered GoPay users"; others are not. | |
| _GOPAY_REGISTERED_NUMBERS: set[str] = set(DUMMY_CONTACTS.values()) | |
| def check_gopay_status(phone_number: str) -> str: | |
| """Return 'Terdaftar GoPay' or 'Belum terdaftar GoPay' for a number.""" | |
| if phone_number in _GOPAY_REGISTERED_NUMBERS: | |
| return "Terdaftar GoPay" | |
| return "Belum terdaftar GoPay" | |
| # Affirmative / negative tokens for the ya-tidak confirmation intercept | |
| _AFFIRMATIVE = { | |
| "ya", "iya", "y", "yes", "yoi", "yup", "oke", "ok", "okay", "sip", "siap", | |
| "lanjut", "lanjutkan", "gas", "boleh", "benar", "betul", "setuju", "bener", | |
| } | |
| _NEGATIVE = { | |
| "tidak", "ga", "gak", "engga", "enggak", "nggak", "ngga", "no", "n", | |
| "batal", "batalkan", "cancel", "jangan", "stop", "salah", "bukan", | |
| } | |
| # Filler / politeness words ignored when judging an affirmative answer, so | |
| # "ya udah" or "boleh dong" still count as a clear yes. | |
| _FILLER = { | |
| "hmm", "hm", "eh", "em", "ehm", "oh", "anu", "nya", "itu", | |
| "dong", "sih", "deh", "nih", "aja", "saja", "udah", "sudah", "langsung", | |
| "makasih", "terima", "kasih", "kak", "bro", "gan", "min", "pak", "bu", | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Follow-up question templates per (intent, slot) — natural Indonesian | |
| # --------------------------------------------------------------------------- | |
| FOLLOW_UP_TEMPLATES: dict[tuple[IntentType, str], str] = { | |
| (IntentType.TRANSFER_UANG, "recipient"): "Mau transfer ke siapa?", | |
| (IntentType.TRANSFER_UANG, "recipient_phone"): "Berapa nomor GoPay {recipient}?", | |
| (IntentType.TRANSFER_UANG, "amount"): "Berapa jumlah yang mau ditransfer?", | |
| (IntentType.BELI_PULSA, "phone_number"): "Pulsanya mau diisi ke nomor HP berapa?", | |
| (IntentType.BELI_PULSA, "amount"): "Mau beli pulsa nominal berapa?", | |
| (IntentType.BAYAR_PLN, "customer_id"): "Boleh sebutkan nomor ID pelanggan PLN-nya?", | |
| (IntentType.PESAN_GOJEK, "tujuan"): "Mau pesan Gojek ke mana tujuannya?", | |
| (IntentType.PESAN_GOFOOD, "makanan"): "Mau pesan makanan apa di GoFood?", | |
| } | |
| CONTACT_NOT_FOUND_TEMPLATE = ( | |
| "Maaf, nomor untuk {kontak} tidak ditemukan. Berapa nomor angkanya?" | |
| ) | |
| CONTACT_NOT_FOUND_GOPAY_TEMPLATE = ( | |
| "Maaf, kontak {kontak} tidak ditemukan di daftar kontakmu. " | |
| "Berapa nomor GoPay {kontak}?" | |
| ) | |
| CONFIRM_PROMPT_RETRY = ( | |
| "Maaf, aku belum paham. Balas 'ya' untuk melanjutkan atau 'tidak' untuk " | |
| "membatalkan transaksi ini." | |
| ) | |
| CANCEL_REPLY = "Oke, transaksinya dibatalkan. Ada lagi yang bisa aku bantu?" | |
| UNKNOWN_REPLY = ( | |
| "Maaf, aku belum bisa bantu untuk permintaan itu. Saat ini aku bisa bantu " | |
| "transfer uang, beli pulsa, bayar tagihan listrik PLN, pesan Gojek, atau " | |
| "pesan GoFood. Ada yang mau dibantu dari salah satunya?" | |
| ) | |
| MAX_TURNS_REPLY = ( | |
| "Sepertinya kita belum ketemu detail transaksinya. Yuk mulai lagi dari awal — " | |
| "coba sebutkan lengkap, misalnya: 'transfer 50 ribu ke Budi'." | |
| ) | |
| INTENT_LABELS = { | |
| IntentType.TRANSFER_UANG: "Transfer Uang", | |
| IntentType.BELI_PULSA: "Beli Pulsa", | |
| IntentType.BAYAR_PLN: "Bayar Listrik PLN", | |
| IntentType.PESAN_GOJEK: "Gojek", | |
| IntentType.PESAN_GOFOOD: "GoFood", | |
| } | |
| def format_rupiah(amount: int) -> str: | |
| """1500000 -> 'Rp1.500.000'""" | |
| return f"Rp{amount:,}".replace(",", ".") | |
| class TurnResult: | |
| """Outcome of processing one user turn.""" | |
| state: DialogueState | |
| reply: str | |
| is_complete: bool | |
| payload: Optional[dict] = None | |
| needs_contact_resolution: bool = False | |
| target_kontak: Optional[str] = None | |
| class _ContactResolution: | |
| """Internal result of _resolve_contact.""" | |
| reply: str | |
| needs_client_resolution: bool = False | |
| contact_name: Optional[str] = None | |
| class StateManager: | |
| """Evaluates dialogue state and drives the slot-filling loop. | |
| Parameters | |
| ---------- | |
| nlu_engine: | |
| Only needed when `use_llm_paraphrase=True`; used to rephrase | |
| templated follow-up questions into more natural language. | |
| use_llm_paraphrase: | |
| Hybrid mode switch. False (default) = deterministic templates, | |
| zero extra API calls. True = each follow-up is paraphrased by Gemini. | |
| """ | |
| def __init__(self, nlu_engine=None, use_llm_paraphrase: bool = False): | |
| self._nlu_engine = nlu_engine | |
| self._use_llm_paraphrase = use_llm_paraphrase and nlu_engine is not None | |
| # ------------------------------------------------------------------ | |
| # Main entry point | |
| # ------------------------------------------------------------------ | |
| def process( | |
| self, | |
| state: DialogueState, | |
| nlu_result: NLUResult, | |
| user_text: str, | |
| resolve_contacts_locally: bool = False, | |
| ) -> TurnResult: | |
| state.turn_count += 1 | |
| state.history.append(user_text) | |
| # --- Ya/Tidak confirmation intercept (highest priority) ------- | |
| # When a transaction is awaiting confirmation, this turn is a decision, | |
| # not a new extraction — handle it before any slot logic runs. | |
| if state.status == DialogueStatus.CONFIRMING: | |
| decision = self._classify_confirmation(user_text) | |
| if decision is True: | |
| return self._finalize(state) | |
| if decision is False: | |
| self._reset(state) | |
| return TurnResult(state, CANCEL_REPLY, is_complete=False) | |
| # Neither ya nor tidak: allow a genuine correction/new request to | |
| # fall through, otherwise re-ask for a clear decision. | |
| has_correction = ( | |
| nlu_result.intent != IntentType.UNKNOWN | |
| and bool(nlu_result.entities.filled_fields()) | |
| ) | |
| if not has_correction: | |
| return TurnResult(state, CONFIRM_PROMPT_RETRY, is_complete=False) | |
| # --- Out-of-scope handling ----------------------------------- | |
| if nlu_result.intent == IntentType.UNKNOWN: | |
| if state.intent == IntentType.UNKNOWN: | |
| # No active transaction: politely state our scope. | |
| state.status = DialogueStatus.UNKNOWN | |
| return TurnResult(state, UNKNOWN_REPLY, is_complete=False) | |
| # Active transaction, but this turn was unintelligible: | |
| # re-ask for the pending slot instead of derailing. | |
| return self._ask_next_slot(state) | |
| # --- Intent switch mid-dialogue ------------------------------ | |
| if ( | |
| state.intent != IntentType.UNKNOWN | |
| and nlu_result.intent != state.intent | |
| ): | |
| logger.info( | |
| "Intent switch: %s -> %s (resetting entities)", | |
| state.intent.value, | |
| nlu_result.intent.value, | |
| ) | |
| state.entities = TransactionEntities() | |
| state.intent = nlu_result.intent | |
| # --- Detect contact name change → clear old resolved number --- | |
| # If the user mentions a NEW target_kontak (different name from the | |
| # current one), the previously resolved phone number is stale and | |
| # must be cleared so the new contact gets its own resolution. | |
| new_kontak = nlu_result.entities.target_kontak | |
| old_kontak = state.entities.target_kontak | |
| if ( | |
| new_kontak is not None | |
| and old_kontak is not None | |
| and new_kontak.lower() != old_kontak.lower() | |
| ): | |
| logger.info( | |
| "Contact changed: '%s' -> '%s' (clearing old phone number)", | |
| old_kontak, new_kontak, | |
| ) | |
| state.entities.recipient_phone = None | |
| state.entities.phone_number = None | |
| state.entities = state.entities.merge(nlu_result.entities) | |
| # --- Contact resolution --------------------------------------- | |
| # Runs BEFORE the missing-slot check so a resolvable contact name | |
| # ("beliin anton pulsa") never triggers a redundant "nomor berapa?". | |
| unresolved = self._resolve_contact(state, resolve_contacts_locally) | |
| if unresolved is not None: | |
| if unresolved.needs_client_resolution: | |
| state.status = DialogueStatus.COLLECTING | |
| state.missing_slots = self._compute_missing(state) | |
| return TurnResult( | |
| state, | |
| unresolved.reply, | |
| is_complete=False, | |
| needs_contact_resolution=True, | |
| target_kontak=unresolved.contact_name, | |
| ) | |
| state.status = DialogueStatus.COLLECTING | |
| state.missing_slots = self._compute_missing(state) | |
| return TurnResult(state, unresolved.reply, is_complete=False) | |
| state.missing_slots = self._compute_missing(state) | |
| # --- Loop guard ---------------------------------------------- | |
| if state.missing_slots and state.turn_count >= MAX_TURNS: | |
| self._reset(state) | |
| return TurnResult(state, MAX_TURNS_REPLY, is_complete=False) | |
| # --- Ask for confirmation or keep collecting ------------------ | |
| if not state.missing_slots: | |
| return self._confirm(state) | |
| return self._ask_next_slot(state) | |
| # ------------------------------------------------------------------ | |
| # Helpers | |
| # ------------------------------------------------------------------ | |
| def _compute_missing(state: DialogueState) -> list[str]: | |
| required = REQUIRED_SLOTS[state.intent] | |
| filled = state.entities.filled_fields() | |
| return [slot for slot in required if slot not in filled] | |
| def _ask_next_slot(self, state: DialogueState) -> TurnResult: | |
| state.status = DialogueStatus.COLLECTING | |
| slot = state.missing_slots[0] | |
| question = FOLLOW_UP_TEMPLATES.get( | |
| (state.intent, slot), f"Boleh lengkapi informasi '{slot}'?" | |
| ) | |
| # Fill in template placeholders (e.g. {recipient} for GoPay number ask) | |
| if state.entities.recipient and "{recipient}" in question: | |
| question = question.format(recipient=state.entities.recipient) | |
| if self._use_llm_paraphrase: | |
| question = self._nlu_engine.paraphrase(question) | |
| return TurnResult(state, question, is_complete=False) | |
| def _resolve_contact( | |
| state: DialogueState, client_resolves: bool = False | |
| ) -> Optional["_ContactResolution"]: | |
| """Resolve `target_kontak` (a name or pronoun) into a real number. | |
| Handles both beli_pulsa (→ phone_number) and transfer_uang (→ recipient_phone). | |
| Returns None when nothing needs resolving or resolution succeeded. | |
| Otherwise returns a _ContactResolution describing the follow-up: | |
| - When client_resolves is True, signals the client to resolve | |
| on-device (needs_client_resolution=True). | |
| - When False (web/demo), tries DUMMY_CONTACTS first, then asks | |
| for raw digits if the contact is unknown. | |
| """ | |
| entities = state.entities | |
| intent = state.intent | |
| # Determine which slot needs a phone number and whether it's already filled | |
| if intent == IntentType.BELI_PULSA: | |
| phone_slot = "phone_number" | |
| current_phone = entities.phone_number | |
| not_found_template = CONTACT_NOT_FOUND_TEMPLATE | |
| elif intent == IntentType.TRANSFER_UANG: | |
| phone_slot = "recipient_phone" | |
| current_phone = entities.recipient_phone | |
| not_found_template = CONTACT_NOT_FOUND_GOPAY_TEMPLATE | |
| else: | |
| return None | |
| if entities.target_kontak is None or current_phone is not None: | |
| return None | |
| # Mobile flow: delegate to the client immediately. | |
| if client_resolves: | |
| logger.info( | |
| "Asking client to resolve contact '%s'", entities.target_kontak | |
| ) | |
| return _ContactResolution( | |
| reply=( | |
| f"Boleh pilih kontak {entities.target_kontak} dari daftar " | |
| f"kontakmu?" | |
| ), | |
| needs_client_resolution=True, | |
| contact_name=entities.target_kontak, | |
| ) | |
| # Web/demo flow: try the built-in directory first. | |
| key = entities.target_kontak.lower() | |
| number = DUMMY_CONTACTS.get(key) | |
| if number: | |
| setattr(entities, phone_slot, number) | |
| logger.info("Resolved contact '%s' -> %s (slot=%s)", key, number, phone_slot) | |
| return None | |
| logger.info("Contact '%s' not found in directory", key) | |
| return _ContactResolution( | |
| reply=not_found_template.format(kontak=entities.target_kontak), | |
| needs_client_resolution=False, | |
| ) | |
| def _classify_confirmation(user_text: str) -> Optional[bool]: | |
| """True = confirmed, False = cancelled, None = unclear. | |
| Asymmetric on purpose (fail-safe for money movement): | |
| - Any negative token cancels — over-cancelling is harmless. | |
| - An affirmative only counts if the WHOLE utterance is affirmative | |
| (after dropping fillers). Otherwise Indonesian fillers like | |
| "apa ya" / "gimana ya" would falsely confirm a transaction. | |
| """ | |
| tokens = set(re.findall(r"[a-z]+", user_text.lower())) | |
| if tokens & _NEGATIVE: | |
| return False | |
| meaningful = tokens - _FILLER | |
| if meaningful and meaningful <= _AFFIRMATIVE: | |
| return True | |
| return None | |
| def _confirm(self, state: DialogueState) -> TurnResult: | |
| """All slots filled: show the summary and WAIT for ya/tidak. | |
| The payload is withheld until the user actually confirms.""" | |
| state.status = DialogueStatus.CONFIRMING | |
| return TurnResult( | |
| state, self._build_confirmation_text(state), is_complete=False | |
| ) | |
| def _finalize(self, state: DialogueState) -> TurnResult: | |
| """User said ya: release the transaction payload.""" | |
| state.status = DialogueStatus.COMPLETE | |
| payload = self._build_payload(state) | |
| label = INTENT_LABELS[state.intent] | |
| # Gojek/GoFood: show "mencari driver" + "menunggu driver" message | |
| if state.intent == IntentType.PESAN_GOJEK: | |
| reply = ( | |
| f"Mencari driver terdekat untuk rute " | |
| f"{state.entities.asal or 'Bogor'} - {state.entities.tujuan}...\n" | |
| f"Silakan menunggu driver Budi (default). " | |
| f"Transaksi {label} berhasil diproses. Terima kasih!" | |
| ) | |
| elif state.intent == IntentType.PESAN_GOFOOD: | |
| reply = ( | |
| f"Mencari driver terdekat untuk pesanan {state.entities.makanan}...\n" | |
| f"Silakan menunggu driver Budi (default). " | |
| f"Transaksi {label} berhasil diproses. Terima kasih!" | |
| ) | |
| else: | |
| reply = f"Transaksi {label} berhasil diproses. Terima kasih!" | |
| return TurnResult( | |
| state, | |
| reply, | |
| is_complete=True, | |
| payload=payload, | |
| ) | |
| def _build_payload(state: DialogueState) -> dict: | |
| """Machine-consumable transaction payload for downstream services.""" | |
| entities = state.entities.model_dump(exclude_none=True) | |
| return { | |
| "transaction_type": state.intent.value, | |
| "entities": entities, | |
| "turns_to_complete": state.turn_count, | |
| } | |
| def _build_confirmation_text(state: DialogueState) -> str: | |
| e = state.entities | |
| label = INTENT_LABELS[state.intent] | |
| lines = [f"Siap! Konfirmasi {label}:"] | |
| if state.intent == IntentType.TRANSFER_UANG: | |
| nama = e.recipient or "(tanpa nama)" | |
| nomor = e.recipient_phone or "?" | |
| gopay = check_gopay_status(nomor) | |
| lines.append(f"- Nama : {nama}") | |
| lines.append(f"- Nomor : {nomor}") | |
| lines.append(f"- Status : {gopay}") | |
| lines.append(f"- Jumlah : {format_rupiah(e.amount)}") | |
| elif state.intent == IntentType.BELI_PULSA: | |
| nomor = e.phone_number | |
| if e.target_kontak: | |
| nomor = f"{e.phone_number} ({e.target_kontak})" | |
| lines.append(f"- Nomor HP : {nomor}") | |
| lines.append(f"- Nominal : {format_rupiah(e.amount)}") | |
| if e.provider: | |
| lines.append(f"- Provider : {e.provider}") | |
| elif state.intent == IntentType.BAYAR_PLN: | |
| lines.append(f"- ID Pelanggan : {e.customer_id}") | |
| if e.amount: | |
| lines.append(f"- Nominal : {format_rupiah(e.amount)}") | |
| elif state.intent == IntentType.PESAN_GOJEK: | |
| asal = e.asal or "Bogor" | |
| lines.append(f"- Asal : {asal}") | |
| lines.append(f"- Tujuan : {e.tujuan}") | |
| elif state.intent == IntentType.PESAN_GOFOOD: | |
| lines.append(f"- Pesanan : {e.makanan}") | |
| lines.append("Lanjutkan?") | |
| return "\n".join(lines) | |
| def _reset(state: DialogueState) -> None: | |
| state.intent = IntentType.UNKNOWN | |
| state.entities = TransactionEntities() | |
| state.missing_slots = [] | |
| state.status = DialogueStatus.UNKNOWN | |
| state.turn_count = 0 | |