File size: 23,584 Bytes
20e9e63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69d919f
20e9e63
69d919f
 
 
 
 
20e9e63
69d919f
 
20e9e63
 
 
 
 
69d919f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20e9e63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69d919f
20e9e63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69d919f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20e9e63
 
 
 
69d919f
 
 
 
 
 
 
 
 
 
 
 
 
20e9e63
 
 
 
69d919f
 
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
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
"""
nlu_engine.py
-------------
LLM-based NLU engine for PayParse.

- `NLUEngine`     : zero-shot Gemini Flash with strict Pydantic structured
                    output (`response_schema=NLUResult`), slang-aware prompt,
                    dialogue-context injection, retries, graceful fallback.
- `MockNLUEngine` : deterministic rule-based engine with the same interface,
                    used for offline evaluation / running without an API key.
"""

from __future__ import annotations

import logging
import os
import re
import time
from typing import Optional, Protocol

from dotenv import load_dotenv

from schema import DialogueState, IntentType, NLUResult, TransactionEntities
from ner_extractor import ner_extractor, NERResult

load_dotenv()
logger = logging.getLogger(__name__)

MODEL_NAME = os.getenv("PAYPARSE_MODEL", "gemini-flash-lite-latest")


# ---------------------------------------------------------------------------
# NER + LLM merge: NER fills gaps the LLM missed, LLM wins on conflicts.
# ---------------------------------------------------------------------------
def _merge_ner_with_llm(ner: NERResult, llm: NLUResult) -> NLUResult:
    """Merge NER pre-scan with LLM output.

    Strategy:
    - Intent: LLM wins (more context-aware). NER only used as fallback.
    - Amount: NER wins if LLM missed it (NER is deterministic for amounts).
    - Phone/recipient_phone: NER wins if LLM missed it.
    - recipient/target_kontak: LLM wins (name extraction is ambiguous).
    - customer_id: NER wins if LLM missed it.
    - provider: NER wins if LLM missed it.
    """
    e_llm = llm.entities
    fields = ner.extracted_fields

    # Amount: NER fills gap
    if "amount" in fields and e_llm.amount is None and ner.amount is not None:
        e_llm.amount = ner.amount
        logger.info("NER filled amount=%d", ner.amount)

    # Phone number: NER fills gap
    if "phone_number" in fields and e_llm.phone_number is None and ner.phone_number is not None:
        e_llm.phone_number = ner.phone_number
        logger.info("NER filled phone_number=%s", ner.phone_number)

    # Recipient phone: NER fills gap
    if "recipient_phone" in fields and e_llm.recipient_phone is None and ner.recipient_phone is not None:
        e_llm.recipient_phone = ner.recipient_phone
        logger.info("NER filled recipient_phone=%s", ner.recipient_phone)

    # Customer ID: NER fills gap
    if "customer_id" in fields and e_llm.customer_id is None and ner.customer_id is not None:
        e_llm.customer_id = ner.customer_id
        logger.info("NER filled customer_id=%s", ner.customer_id)

    # Provider: NER fills gap
    if "provider" in fields and e_llm.provider is None and ner.provider is not None:
        e_llm.provider = ner.provider
        logger.info("NER filled provider=%s", ner.provider)

    # Target kontak: NER fills gap (only if LLM didn't extract it and no phone)
    if (
        "target_kontak" in fields
        and e_llm.target_kontak is None
        and ner.target_kontak is not None
        and e_llm.phone_number is None
        and e_llm.recipient_phone is None
    ):
        e_llm.target_kontak = ner.target_kontak
        if e_llm.recipient is None and ner.recipient is not None:
            e_llm.recipient = ner.recipient
        logger.info("NER filled target_kontak=%s", ner.target_kontak)

    # Recipient: NER fills gap
    if "recipient" in fields and e_llm.recipient is None and ner.recipient is not None:
        e_llm.recipient = ner.recipient
        logger.info("NER filled recipient=%s", ner.recipient)

    # Tujuan (Gojek destination): NER fills gap
    if "tujuan" in fields and e_llm.tujuan is None and ner.tujuan is not None:
        e_llm.tujuan = ner.tujuan
        logger.info("NER filled tujuan=%s", ner.tujuan)

    # Asal (Gojek origin): NER fills gap
    if "asal" in fields and e_llm.asal is None and ner.asal is not None:
        e_llm.asal = ner.asal
        logger.info("NER filled asal=%s", ner.asal)

    # Makanan (GoFood item): NER fills gap
    if "makanan" in fields and e_llm.makanan is None and ner.makanan is not None:
        e_llm.makanan = ner.makanan
        logger.info("NER filled makanan=%s", ner.makanan)

    # Bump confidence if NER agrees with LLM
    if ner.intent is not None and ner.intent == llm.intent:
        llm.confidence = min(1.0, llm.confidence + 0.05)

    return llm

# ---------------------------------------------------------------------------
# System prompt: the heart of the zero-shot NLU
# ---------------------------------------------------------------------------
SYSTEM_PROMPT = """\
Kamu adalah mesin NLU (Natural Language Understanding) untuk asisten suara
dompet digital Indonesia (seperti GoPay). Tugasmu HANYA mengekstrak intent
dan entities dari ucapan pengguna, lalu mengembalikan JSON sesuai skema.

## INTENT yang didukung:
1. "transfer_uang" : kirim/transfer uang ke seseorang.
   Kata kunci: transfer, tf, kirim, kirimin, transferin, kasih uang.
2. "beli_pulsa"    : beli pulsa / isi ulang / top-up pulsa ke nomor HP.
   Kata kunci: pulsa, isi pulsa, top up, isiin.
3. "bayar_pln"     : bayar tagihan listrik PLN / beli token listrik.
   Kata kunci: listrik, PLN, token, tagihan listrik, tagihan pln, meteran.
4. "pesan_gojek"   : pesan ojek/ride Gojek dari asal ke tujuan.
   Kata kunci: gojek, go jek, goride, naik gojek, pesan gojek.
   Entity "asal" default "Bogor" jika tidak disebut.
   Entity "tujuan" adalah lokasi tujuan (stasiun, bandara, mall, dll).
5. "pesan_gofood"  : pesan makanan via GoFood.
   Kata kunci: gofood, go food, pesan makan, beli makan.
   Entity "makanan" adalah nama makanan (nasi goreng, ayam geprek, dll).
6. "unknown"       : semua permintaan di luar 5 intent di atas
   (contoh: tanya cuaca, ngobrol basa-basi).

## NORMALISASI SLANG UANG (WAJIB dikonversi ke integer Rupiah):
- "seceng" / "seribu"      = 1000
- "goceng"                 = 5000
- "ceban"                  = 10000
- "noban"                  = 20000
- "gocap" / "gopek ribu"?  -> "gocap" = 50000, "gopek" = 500
- "cepek"                  = 100 (uang: biasanya maksudnya "cepek ribu" = 100000
  jika konteksnya transfer/pulsa; gunakan 100000 untuk konteks transaksi)
- "seket" (Jawa)           = 50 -> "seket ewu" = 50000
- "sejuta" / "1jt" / "1 juta" = 1000000
- "50rb" / "50ribu" / "50k"   = 50000
- "2,5jt" / "2.5 juta"        = 2500000

## ATURAN PENTING:
- Pengguna sering typo ("pusla" = pulsa, "trasnfer" = transfer) dan memakai
  bahasa lisan tidak baku. Tetap pahami maksudnya.
- HANYA ekstrak entity yang DISEBUT EKSPLISIT oleh pengguna.
  JANGAN PERNAH mengarang/menebak nilai yang tidak diucapkan.
  Jika tidak disebut, biarkan null.
- "recipient" adalah nama orang penerima transfer (budi, mama, bang jono).
- "recipient_phone" adalah nomor HP penerima transfer, HANYA jika pengguna
  menyebutkan DIGIT ANGKA secara eksplisit. Jangan pernah mengarang digit.
- "phone_number" adalah nomor HP tujuan pulsa, HANYA jika pengguna menyebutkan
  DIGIT ANGKA secara eksplisit.
- "target_kontak" dipakai jika pengguna merujuk nomor HP secara TIDAK LANGSUNG:
  * lewat nama kontak: "beliin anton pulsa" -> target_kontak = "anton"
  * lewat nama kontak transfer: "tf ke budi" -> recipient = "budi", target_kontak = "budi"
  * lewat kata ganti  : "isi ke nomor ini", "nomorku" -> target_kontak = "nomor ini" / "nomorku"
  Dalam kasus ini "phone_number" dan "recipient_phone" WAJIB null β€” jangan
  pernah mengarang digit. Sebaliknya, jika digit sudah disebut,
  "target_kontak" biarkan null.

  Contoh:
  Ucapan: "beliin anton pulsa 10rb"
  -> {"intent": "beli_pulsa", "entities": {"amount": 10000,
      "phone_number": null, "target_kontak": "anton"}}
  Ucapan: "tf 50rb ke budi"
  -> {"intent": "transfer_uang", "entities": {"recipient": "budi",
      "recipient_phone": null, "target_kontak": "budi", "amount": 50000}}
  Ucapan: "transfer ke 081234567890 100rb"
  -> {"intent": "transfer_uang", "entities": {"recipient_phone": "081234567890",
      "recipient": null, "target_kontak": null, "amount": 100000}}
  Ucapan: "gojek ke stasiun"
  -> {"intent": "pesan_gojek", "entities": {"asal": null, "tujuan": "stasiun"}}
  Ucapan: "gofood nasi goreng"
  -> {"intent": "pesan_gofood", "entities": {"makanan": "nasi goreng"}}
- "customer_id" adalah nomor ID pelanggan / meteran PLN (hanya digit).
- Nomor yang diawali 08 kemungkinan besar phone_number, bukan customer_id.
- Jika pengguna sedang menjawab pertanyaan lanjutan (lihat KONTEKS DIALOG),
  jawaban singkat seperti "buat budi" atau "50rb" adalah pengisian slot untuk
  intent yang SEDANG BERJALAN β€” pertahankan intent tersebut.
- PENTING: entity yang SUDAH terisi di KONTEKS DIALOG JANGAN diulang lagi di
  output "entities" kecuali pengguna benar-benar menyebutkannya ulang di
  UCAPAN saat ini. Field "entities" HANYA berisi hal baru yang disebut di
  UCAPAN PENGGUNA sekarang, bukan salinan dari konteks.
- "normalized_text": tulis ulang ucapan dalam bahasa Indonesia baku dan rapi.
- "confidence": estimasi keyakinanmu terhadap intent (0.0 - 1.0).
"""


def _build_context_block(state: Optional[DialogueState]) -> str:
    """Render the current dialogue state so short follow-up answers
    ("buat budi", "50rb") are resolved against the ongoing intent."""
    if state is None or state.intent == IntentType.UNKNOWN:
        return ""
    filled = {
        k: v for k, v in state.entities.model_dump().items() if v is not None
    }
    return (
        "\n## KONTEKS DIALOG (percakapan sedang berjalan):\n"
        f"- Intent aktif: {state.intent.value}\n"
        f"- Entity yang sudah terisi: {filled or 'belum ada'}\n"
        f"- Slot yang masih ditanyakan: {state.missing_slots}\n"
        "Ucapan berikut kemungkinan adalah jawaban untuk slot yang ditanyakan.\n"
    )


class BaseNLUEngine(Protocol):
    """Common interface so the API/eval can swap live and mock engines."""

    def extract(self, text: str, state: Optional[DialogueState] = None) -> NLUResult:
        ...


# ---------------------------------------------------------------------------
# Live Gemini engine
# ---------------------------------------------------------------------------
class NLUEngine:
    """Gemini-backed NLU with strict structured output.

    Supports two SDKs:
    - google-genai (new SDK, preferred β€” has response_schema for Pydantic)
    - google-generativeai (old SDK, fallback β€” needed for HF Spaces where
      google-genai's websockets>=13 conflicts with gradio-client's websockets<13)
    """

    def __init__(self, api_key: Optional[str] = None, max_retries: int = 2):
        api_key = api_key or os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
        if not api_key:
            raise ValueError(
                "GEMINI_API_KEY not set. Export it or add it to a .env file."
            )
        self._max_retries = max_retries
        self._sdk = None
        self._client = None
        self._model = None

        # Try new SDK (google-genai) first
        try:
            from google import genai
            self._client = genai.Client(api_key=api_key)
            self._sdk = "genai"
            logger.info("Using google-genai SDK")
        except ImportError:
            pass

        # Fall back to old SDK (google-generativeai)
        if self._sdk is None:
            try:
                import google.generativeai as genai
                # Old SDK needs GOOGLE_API_KEY env var or configure()
                os.environ["GOOGLE_API_KEY"] = api_key
                genai.configure(api_key=api_key)
                self._model = genai.GenerativeModel(
                    MODEL_NAME, system_instruction=SYSTEM_PROMPT
                )
                self._sdk = "generativeai"
                logger.info("Using google-generativeai SDK")
            except ImportError:
                raise ImportError(
                    "Neither google-genai nor google-generativeai is installed. "
                    "Install one: pip install google-genai OR pip install google-generativeai"
                )

    def extract(self, text: str, state: Optional[DialogueState] = None) -> NLUResult:
        """Run zero-shot extraction with NER pre-scan.

        Pipeline: NER pre-scan β†’ LLM extraction β†’ merge (NER fills gaps
        the LLM missed, LLM wins on conflicts for ambiguous fields).

        Returns an `unknown` NLUResult on failure so the pipeline degrades
        gracefully instead of crashing.
        """
        # --- NER pre-scan (rule-based, zero latency) ---
        ner = ner_extractor.extract(text)
        logger.info("NER pre-scan: %s", ner.extracted_fields)

        prompt = _build_context_block(state) + f'\n## UCAPAN PENGGUNA:\n"{text}"'

        for attempt in range(self._max_retries + 1):
            try:
                llm_result = self._call_llm(prompt)
                return _merge_ner_with_llm(ner, llm_result)
            except Exception as exc:  # network, rate limit, malformed JSON
                logger.warning("NLU attempt %d failed: %s", attempt + 1, exc)
                if attempt < self._max_retries:
                    time.sleep(2 ** attempt)  # 1s, 2s backoff

        # --- Fallback: use NER result if LLM fails entirely ---
        if ner.intent is not None:
            logger.info("LLM failed β€” falling back to NER result.")
            return NLUResult(
                intent=ner.intent,
                entities=ner.to_entities(),
                confidence=ner.confidence,
                normalized_text=text,
            )

        logger.error("NLU extraction failed after retries; returning unknown.")
        return NLUResult(intent=IntentType.UNKNOWN, normalized_text=text)

    def _call_llm(self, prompt: str) -> NLUResult:
        """Call the LLM using whichever SDK is available."""
        if self._sdk == "genai":
            return self._call_genai(prompt)
        else:
            return self._call_generativeai(prompt)

    def _call_genai(self, prompt: str) -> NLUResult:
        """Call via google-genai (new SDK with response_schema)."""
        from google.genai import types
        config = types.GenerateContentConfig(
            system_instruction=SYSTEM_PROMPT,
            response_mime_type="application/json",
            response_schema=NLUResult,
            temperature=0.0,
        )
        response = self._client.models.generate_content(
            model=MODEL_NAME, contents=prompt, config=config
        )
        return response.parsed or NLUResult.model_validate_json(response.text)

    def _call_generativeai(self, prompt: str) -> NLUResult:
        """Call via google-generativeai (old SDK, manual JSON parse)."""
        import google.generativeai as genai
        response = self._model.generate_content(
            prompt,
            generation_config=genai.GenerationConfig(
                temperature=0.0,
                response_mime_type="application/json",
            ),
        )
        return NLUResult.model_validate_json(response.text)

    def paraphrase(self, question: str) -> str:
        """Optionally rephrase a templated follow-up question into a more
        natural, friendly sentence (hybrid follow-up generation)."""
        try:
            if self._sdk == "genai":
                response = self._client.models.generate_content(
                    model=MODEL_NAME,
                    contents=(
                        "Tulis ulang pertanyaan asisten dompet digital berikut agar "
                        "terdengar ramah dan natural dalam bahasa Indonesia santai. "
                        "Balas HANYA dengan satu kalimat pertanyaannya saja.\n"
                        f"Pertanyaan: {question}"
                    ),
                )
                return (response.text or question).strip()
            else:
                response = self._model.generate_content(
                    "Tulis ulang pertanyaan asisten dompet digital berikut agar "
                    "terdengar ramah dan natural dalam bahasa Indonesia santai. "
                    "Balas HANYA dengan satu kalimat pertanyaannya saja.\n"
                    f"Pertanyaan: {question}"
                )
                return (response.text or question).strip()
        except Exception as exc:
            logger.warning("Paraphrase failed, using template: %s", exc)
            return question


# ---------------------------------------------------------------------------
# Offline mock engine (rule-based)
# ---------------------------------------------------------------------------
_SLANG_AMOUNTS = {
    "seceng": 1_000,
    "seribu": 1_000,
    "goceng": 5_000,
    "ceban": 10_000,
    "noban": 20_000,
    "gocap": 50_000,
    "cepek": 100_000,   # transactional context
    "sejuta": 1_000_000,
}

_INTENT_KEYWORDS = {
    IntentType.BELI_PULSA: ["pulsa", "pusla", "top up", "topup", "isi ulang", "isiin"],
    IntentType.BAYAR_PLN: ["listrik", "pln", "token", "meteran", "tagihan pln", "tagihan listrik"],
    IntentType.TRANSFER_UANG: [
        "transfer", "trasnfer", "tf", "kirim", "kirimin", "transferin", "kasih", "beri"
    ],
    IntentType.PESAN_GOJEK: ["gojek", "go jek", "goride", "go ride"],
    IntentType.PESAN_GOFOOD: ["gofood", "go food", "pesan makan", "beli makan"],
}


class MockNLUEngine:
    """Deterministic keyword/regex NLU with the same interface as `NLUEngine`.
    Good enough to exercise the state machine and run tests offline β€”
    NOT a substitute for the LLM's robustness."""

    def extract(self, text: str, state: Optional[DialogueState] = None) -> NLUResult:
        lowered = f" {text.lower()} "
        intent = self._classify(lowered, state)
        entities = self._extract_entities(lowered, intent)
        return NLUResult(
            intent=intent,
            entities=entities,
            confidence=0.5 if intent == IntentType.UNKNOWN else 0.9,
            normalized_text=text,
        )

    def paraphrase(self, question: str) -> str:
        return question  # mock: templates pass through unchanged

    def _classify(self, lowered: str, state: Optional[DialogueState]) -> IntentType:
        for intent, keywords in _INTENT_KEYWORDS.items():
            if any(f" {kw} " in lowered or lowered.strip().startswith(kw)
                   for kw in keywords):
                return intent
        # Short answers during slot filling keep the active intent
        if state is not None and state.intent != IntentType.UNKNOWN:
            return state.intent
        return IntentType.UNKNOWN

    def _extract_entities(
        self, lowered: str, intent: IntentType
    ) -> TransactionEntities:
        entities = TransactionEntities()
        if intent == IntentType.UNKNOWN:
            return entities

        entities.amount = self._parse_amount(lowered)

        # Phone numbers (start with 08, 9-13 digits) vs PLN customer IDs
        numbers = re.findall(r"\b(\d[\d\-\s]{7,15}\d)\b", lowered)
        for raw in numbers:
            digits = re.sub(r"\D", "", raw)
            if digits.startswith("08") and 9 <= len(digits) <= 13:
                entities.phone_number = digits
            elif intent == IntentType.BAYAR_PLN:
                entities.customer_id = digits

        if intent == IntentType.TRANSFER_UANG:
            # If raw digits were mentioned, fill recipient_phone
            if entities.phone_number is not None:
                entities.recipient_phone = entities.phone_number
                entities.phone_number = None
            # Extract recipient name
            m = re.search(
                r"\b(?:ke|buat|untuk)\s+(?:(?:si|bang|mbak|pak|bu)\s+)?([a-z]+)",
                lowered,
            )
            if m and m.group(1) not in {"nomor", "rekening", "hp"}:
                entities.recipient = m.group(1).capitalize()
                # If no raw digits, fill target_kontak for resolution
                if entities.recipient_phone is None:
                    entities.target_kontak = entities.recipient

        # Indirect phone reference: contact name or pronoun instead of digits
        if intent == IntentType.BELI_PULSA and entities.phone_number is None:
            entities.target_kontak = self._parse_contact(lowered)

        # --- Gojek: extract tujuan ---
        if intent == IntentType.PESAN_GOJEK:
            m = re.search(r"\bgojek\s+(?:ke|buat|untuk)\s+(.+?)(?:\s+dari\s|$)", lowered)
            if m:
                dest = m.group(1).strip()
                # Strip filler words
                dest = re.sub(r"\s+(?:dong|sih|deh|nih|aja|ya|yah)$", "", dest).strip()
                if dest:
                    entities.tujuan = dest.capitalize()
            else:
                m = re.search(r"\bgojek\s+([a-z][a-z\s]+)", lowered)
                if m and m.group(1).strip() not in {"ke", "dari", "pesan"}:
                    dest = m.group(1).strip()
                    dest = re.sub(r"\s+(?:dong|sih|deh|nih|aja|ya|yah)$", "", dest).strip()
                    if dest:
                        entities.tujuan = dest.capitalize()
            # Asal: "dari X" or default Bogor
            m = re.search(r"\bdari\s+([a-z][a-z\s]+?)(?:\s+ke\s|$)", lowered)
            if m:
                entities.asal = m.group(1).strip().capitalize()

        # --- GoFood: extract makanan ---
        if intent == IntentType.PESAN_GOFOOD:
            m = re.search(r"\bgofood\s+(.+?)(?:\s*$)", lowered)
            if m:
                food = m.group(1).strip()
                if food not in {"pesan", "order", "beli", "mau"}:
                    entities.makanan = food.capitalize()
            else:
                m = re.search(r"\b(?:beli|pesan)\s+makan(?:an)?\s+(.+?)(?:\s*$)", lowered)
                if m:
                    entities.makanan = m.group(1).strip().capitalize()

        return entities

    @staticmethod
    def _parse_contact(lowered: str) -> Optional[str]:
        """Detect pronouns ('nomorku', 'nomor ini') or a contact name."""
        for pronoun in ("nomor ini", "nomer ini", "nomorku", "nomerku", "nomor saya"):
            if pronoun in lowered:
                return pronoun
        # "isi ke anton", "beliin anton pulsa"
        m = re.search(r"\b(?:ke|buat|untuk)\s+([a-z]+)", lowered)
        if m and m.group(1) not in {"nomor", "nomer", "hp", "pulsa", "aku", "saya"}:
            return m.group(1)
        m = re.search(r"\b(?:beliin|isiin|isi|beli)\s+([a-z]+)\s+pulsa", lowered)
        if m and m.group(1) not in {"pulsa", "nomor", "nomer"}:
            return m.group(1)
        return None

    @staticmethod
    def _parse_amount(lowered: str) -> Optional[int]:
        for slang, value in _SLANG_AMOUNTS.items():
            if slang in lowered:
                return value
        m = re.search(r"(\d+(?:[.,]\d+)?)\s*(rb|ribu|k|jt|juta)\b", lowered)
        if m:
            base = float(m.group(1).replace(",", "."))
            mult = 1_000 if m.group(2) in {"rb", "ribu", "k"} else 1_000_000
            return int(base * mult)
        m = re.search(r"\b(\d{4,9})\b(?!\s*(?:rb|ribu|k|jt|juta))", lowered)
        if m and not m.group(1).startswith("08"):
            value = int(m.group(1))
            if 500 <= value <= 100_000_000:
                return value
        return None


def create_engine(offline: bool = False) -> "BaseNLUEngine":
    """Factory: live Gemini engine, or mock when offline / no key present."""
    if offline:
        return MockNLUEngine()
    try:
        return NLUEngine()
    except ValueError:
        logger.warning("No GEMINI_API_KEY found β€” falling back to MockNLUEngine.")
        return MockNLUEngine()