File size: 9,171 Bytes
20e9e63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
"""
schema.py
---------
Pydantic data contracts for the PayParse NLU pipeline.

Defines:
- Supported intents (`IntentType`)
- Extracted transaction entities (`TransactionEntities`)
- The strict NLU output schema enforced on Gemini (`NLUResult`)
- The multi-turn dialogue state (`DialogueState`)
- FastAPI request/response models (`ChatRequest`, `ChatResponse`)
- The slot-filling contract (`REQUIRED_SLOTS`)
"""

from __future__ import annotations

import re
from enum import Enum
from typing import Optional

from pydantic import BaseModel, Field, field_validator


# ---------------------------------------------------------------------------
# Intents
# ---------------------------------------------------------------------------
class IntentType(str, Enum):
    """Supported user intents (GoPay-style financial actions)."""

    TRANSFER_UANG = "transfer_uang"   # e.g. "tf goceng ke budi"
    BELI_PULSA = "beli_pulsa"         # e.g. "beliin pulsa 25rb ke 0812..."
    BAYAR_PLN = "bayar_pln"           # e.g. "bayar listrik id 4512..."
    PESAN_GOJEK = "pesan_gojek"       # e.g. "gojek ke stasiun"
    PESAN_GOFOOD = "pesan_gofood"     # e.g. "gofood nasi goreng"
    UNKNOWN = "unknown"               # out-of-scope / unintelligible


# Slot-filling contract: which entity fields MUST be present per intent
# before the transaction payload can be confirmed.
REQUIRED_SLOTS: dict[IntentType, list[str]] = {
    IntentType.TRANSFER_UANG: ["recipient", "recipient_phone", "amount"],
    IntentType.BELI_PULSA: ["phone_number", "amount"],
    IntentType.BAYAR_PLN: ["customer_id"],
    IntentType.PESAN_GOJEK: ["tujuan"],
    IntentType.PESAN_GOFOOD: ["makanan"],
    IntentType.UNKNOWN: [],
}


# ---------------------------------------------------------------------------
# Entities
# ---------------------------------------------------------------------------
class TransactionEntities(BaseModel):
    """Entities extracted from a user utterance. All fields optional —
    the state machine decides what is still missing."""

    recipient: Optional[str] = Field(
        default=None,
        description="Name of the transfer recipient, e.g. 'Budi', 'mama'.",
    )
    recipient_phone: Optional[str] = Field(
        default=None,
        description=(
            "Phone number of the transfer recipient, digits only. "
            "Filled by the state manager after resolving target_kontak, "
            "or directly if the user dictates digits."
        ),
    )
    amount: Optional[int] = Field(
        default=None,
        description=(
            "Amount in Indonesian Rupiah as an integer. "
            "Slang must be normalized: goceng=5000, ceban=10000, "
            "gocap=50000, cepek=100000, seceng=1000, '50rb'=50000, "
            "'sejuta'=1000000."
        ),
    )
    phone_number: Optional[str] = Field(
        default=None,
        description="Destination phone number for pulsa top-up, digits only.",
    )
    target_kontak: Optional[str] = Field(
        default=None,
        description=(
            "Contact name or pronoun used INSTEAD of raw digits when the user "
            "refers to a phone number indirectly, e.g. 'Anton', 'nomorku', "
            "'nomor ini'. Resolved to an actual number by the state manager."
        ),
    )
    customer_id: Optional[str] = Field(
        default=None,
        description="PLN customer ID / meter number, digits only.",
    )
    provider: Optional[str] = Field(
        default=None,
        description="Telco provider if mentioned, e.g. 'Telkomsel', 'XL'.",
    )
    asal: Optional[str] = Field(
        default=None,
        description=(
            "Origin location for Gojek ride booking. "
            "Defaults to 'Bogor' if not specified by the user."
        ),
    )
    tujuan: Optional[str] = Field(
        default=None,
        description=(
            "Destination location for Gojek ride booking, "
            "e.g. 'stasiun', 'bandara', 'mall botani'."
        ),
    )
    makanan: Optional[str] = Field(
        default=None,
        description=(
            "Food item ordered via GoFood, "
            "e.g. 'nasi goreng', 'ayam geprek', 'mie ayam'."
        ),
    )

    @field_validator("amount")
    @classmethod
    def amount_must_be_positive(cls, v: Optional[int]) -> Optional[int]:
        if v is not None and v <= 0:
            return None  # discard nonsense amounts instead of erroring
        return v

    @field_validator("phone_number", "customer_id", "recipient_phone")
    @classmethod
    def digits_only(cls, v: Optional[str]) -> Optional[str]:
        """Normalize '0812-3456 7890' -> '081234567890'; drop empty results."""
        if v is None:
            return None
        digits = re.sub(r"\D", "", v)
        return digits or None

    @field_validator("target_kontak")
    @classmethod
    def clean_contact(cls, v: Optional[str]) -> Optional[str]:
        """Trim whitespace; drop empty strings so lookups stay predictable."""
        return v.strip() or None if v else None

    def filled_fields(self) -> set[str]:
        """Names of fields that currently hold a value."""
        return {k for k, v in self.model_dump().items() if v is not None}

    def merge(self, new: "TransactionEntities") -> "TransactionEntities":
        """Non-destructive merge: new values fill gaps or override,
        but never erase an existing value with None."""
        data = self.model_dump()
        for key, value in new.model_dump().items():
            if value is not None:
                data[key] = value
        return TransactionEntities(**data)


# ---------------------------------------------------------------------------
# NLU output (strict schema enforced on Gemini structured output)
# ---------------------------------------------------------------------------
class NLUResult(BaseModel):
    """Single-turn NLU extraction result returned by the LLM."""

    intent: IntentType = Field(description="The classified user intent.")
    entities: TransactionEntities = Field(
        default_factory=TransactionEntities,
        description="Entities explicitly stated in the utterance. "
        "Never invent values that were not said.",
    )
    confidence: float = Field(
        default=0.0,
        ge=0.0,
        le=1.0,
        description="Model self-estimated confidence in the intent (0-1).",
    )
    normalized_text: str = Field(
        default="",
        description="The utterance rewritten in clean formal Indonesian "
        "with slang and typos resolved.",
    )


# ---------------------------------------------------------------------------
# Dialogue state (multi-turn)
# ---------------------------------------------------------------------------
class DialogueStatus(str, Enum):
    COLLECTING = "collecting"   # waiting for missing slots
    CONFIRMING = "confirming"   # all slots filled, awaiting user's ya/tidak
    COMPLETE = "complete"       # confirmed, payload released
    UNKNOWN = "unknown"         # out-of-scope query


class DialogueState(BaseModel):
    """Persistent state of one conversation session."""

    session_id: str
    intent: IntentType = IntentType.UNKNOWN
    entities: TransactionEntities = Field(default_factory=TransactionEntities)
    missing_slots: list[str] = Field(default_factory=list)
    status: DialogueStatus = DialogueStatus.UNKNOWN
    turn_count: int = 0
    history: list[str] = Field(
        default_factory=list,
        description="Raw user utterances in order, for LLM context.",
    )


# ---------------------------------------------------------------------------
# API models
# ---------------------------------------------------------------------------
class ChatRequest(BaseModel):
    session_id: str = Field(min_length=1, description="Client-chosen session key.")
    message: str = Field(min_length=1, description="Raw user utterance.")
    resolve_contacts_locally: bool = Field(
        default=False,
        description=(
            "When true (mobile client), the server will NOT look up its own "
            "contact directory and instead signal the client to resolve "
            "target_kontak into a phone number on-device."
        ),
    )


class ChatResponse(BaseModel):
    session_id: str
    reply: str = Field(description="Assistant reply (follow-up question or confirmation).")
    intent: IntentType
    is_complete: bool
    missing_slots: list[str]
    payload: Optional[dict] = Field(
        default=None,
        description="Final transaction payload, present only when complete.",
    )
    needs_contact_resolution: bool = Field(
        default=False,
        description=(
            "True when the server asks the client to resolve target_kontak "
            "into a phone number on-device (mobile flow). The client should "
            "open a contact picker, find the number, then send it back as "
            "the next message."
        ),
    )
    target_kontak: Optional[str] = Field(
        default=None,
        description=(
            "The contact name/pronoun the client should resolve, present "
            "only when needs_contact_resolution is true."
        ),
    )