payparse / state_manager.py
NabilHzs's picture
Upload folder using huggingface_hub
20e9e63 verified
Raw History Blame Contribute Delete
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(",", ".")
@dataclass
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
@dataclass
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
# ------------------------------------------------------------------
@staticmethod
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)
@staticmethod
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,
)
@staticmethod
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,
)
@staticmethod
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,
}
@staticmethod
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)
@staticmethod
def _reset(state: DialogueState) -> None:
state.intent = IntentType.UNKNOWN
state.entities = TransactionEntities()
state.missing_slots = []
state.status = DialogueStatus.UNKNOWN
state.turn_count = 0