# AI Preferences budget sync — รายงานแก้บั๊ก

วันที่ 7 ตุลาคม 2026 · เคสหลัก `เกาหลีงบ400`

## ROOT CAUSE

บั๊กหลักอยู่ใน **local extractor ของ Flutter** ไม่ใช่การเลือก budget chip และไม่ใช่ Backend ทิ้งผล Gemini

`lib/widgets/ai_preference_form.dart` ใช้ debounce 400 ms แล้วเรียก `AiMatchService.extractPreference()` ใน `_applyPromptExtraction()` โดยตรงเพื่อเติมฟอร์มขณะพิมพ์ ไม่เรียก Gemini ในขั้นตอนนี้

ใน `lib/services/ai_match_service.dart` → `_extractBudget()` regex เดิมจับ `400` หลังคำว่า `งบ` ได้ แต่เงื่อนไข `contextualValue >= 1000` ไม่ยอมคืนค่านี้ จากนั้น fallback ยังกรอง `value >= 3000` ทำให้ผลเป็น null ส่วน country extractor อ่านเกาหลีได้ตามปกติ ฟอร์มจึงอัปเดตประเทศและ rebuild แต่ข้ามการอัปเดตงบเพราะ `extraction.budget == null` ค่า `_budget` เดิม 20000 จึงยังเลือก chip ≤20,000

พบปัญหาที่เกี่ยวข้องอีกจุด: ฟอร์ม clamp งบจาก extraction เป็นขั้นต่ำ 3000 ทั้งที่กฎ Backend ใน `AutomatedMatchingService.submitPreference()` รับงบที่เป็นบวกและไม่เกิน 100,000,000 บาท ไม่มีขั้นต่ำ 3000 ตัวเลข 3000 เป็นช่วงเริ่มต้นของสไลเดอร์ใน UI จึงแยกช่วงของ control ออกจากงบจริงใน state เพื่อไม่เปลี่ยนงบ 400 เป็น 3000 อย่างเงียบ ๆ

## Trace ของ flow ปัจจุบัน

```text
ข้อความใน ai-preference-prompt-field
  → _schedulePromptExtraction() / debounce 400 ms
  → AiMatchService.extractPreference()
  → _extractBudget() → TravelPreferenceExtraction.budget
  → _applyPromptExtraction() → _budget (ถ้ายังไม่ได้แก้งบด้วยมือ)
  → _budgetBucket เลือกเพดานแรกที่ >= _budget
  → ChoiceChip.selected: _budgetBucket == ceiling
  → _submit() → AiPreferenceDraft.budget
  → SmartSearchPage._submit(draft)
      → GeminiAiClient.analyze(selectedBudget: draft.budget.round())
          → POST /api/analyze → Backend _analyzeWithGemini()
          → JSON maxBudget → SearchIntent.maxBudget
      → MatchingViewModel / MatchingRepository / MatchingApiService
          → TravelPreferenceInput(budget: draft.budget)
          → POST /api/matching/preferences/submit
```

สำคัญ: `SmartSearchPage._submit()` await Gemini แต่ไม่ได้ใช้ SearchIntent ที่คืนมาอัปเดตฟอร์มหรือเปลี่ยน draft ใน flow ปัจจุบัน ดังนั้นผล Gemini ไม่ใช่ต้นทางของการเปลี่ยน chip ขณะพิมพ์ งานนี้รักษา orchestration เดิมและแก้การ extract/sync งบในฟอร์ม ไม่เปลี่ยนให้ Gemini เป็นตัวตัดสินสมาชิกกลุ่ม

| ประเด็นที่ต้องแยก | หลักฐานจาก source และ tests |
| --- | --- |
| 1. Gemini ไม่ extract 400 | ไม่ใช่สาเหตุของ chip ตอนพิมพ์ เพราะไม่มี Gemini request ในขั้นนี้ ไม่ได้เรียก Gemini จริงเพื่ออ้างว่าโมเดลคืนอะไร |
| 2. Backend/extractor ทิ้งค่าต่ำกว่า threshold | **local `_extractBudget()` ทิ้ง 400 จริง** ด้วย 1000/3000 thresholds; Backend Gemini เปลี่ยนเพียง maxBudget 0 เป็น null และ schema ขั้นต่ำ 0 ส่วน matching validation รับค่าบวก |
| 3. JSON mapping ไม่อ่าน budget | `GeminiAiClient` อ่าน field `maxBudget` และรับ positive integer 400 ได้ ยืนยันด้วย mock HTTP tests |
| 4. normalization ผิดช่วง | `_budgetBucket` ใช้ first ceiling >= amount ถูกแล้ว แต่ clamp ขั้นต่ำ 3000 ใน form state ต้องแยกออกจากงบจริง |
| 5. Flutter ได้ค่าแล้วไม่ update | ก่อนแก้ได้ budget null จึงข้าม assignment เมื่อเป็นค่าบวกและไม่ manual override state อัปเดตได้ |
| 6. selected chip ใช้ตัวแปรคนละตัว | ไม่พบ: selected อ้าง `_budgetBucket` ที่คำนวณจาก `_budget` ตัวเดียว ยืนยันโดย widget tests ตรวจทุก chip |

ตัวเลือกจริงใน source: `[10000, 20000, 30000, 50000, 100000]` ไม่เพิ่มหรือเปลี่ยน tiers

## FILES CHANGED

ไฟล์ production 2 ไฟล์:

- `lib/services/ai_match_service.dart` — รับงบระบุชัดทุกจำนวนเต็มบวก รวมค่าต่ำกว่า 1000 และอ่านหน่วย k/พัน/หมื่น/แสนหลังจำนวน; คง fallback สำหรับตัวเลขไม่มีบริบทเพื่อไม่อ่านปี/วันที่เป็นงบโดยง่าย
- `lib/widgets/ai_preference_form.dart` — เก็บงบ 400 ใน state/draft; clamp เฉพาะค่าที่แสดงบนสไลเดอร์ให้ตรงช่วง control เดิม 3000–100000 และแสดงคำอธิบายเมื่อใช้งบต่ำกว่าช่วงสไลเดอร์ การเลือก tier และ manual override ใช้กฎเดิม

ไฟล์ tests 4 ไฟล์:

- `test/ai_match_service_test.dart` — extracted numeric budget ทั้ง 9 ข้อความ, ค่าบวกเล็ก, ศูนย์ และปี/วันที่
- `test/widgets/ai_preference_form_test.dart` — normalized tier, selected ของทุก chip, exact amount, slider bounds, submitted draft และ manual override
- `test/services/gemini_ai_client_test.dart` (ใหม่) — mock API maxBudget ทั้ง 9 กรณีเพื่อพิสูจน์ JSON mapping
- `test/widgets/ai_preference_budget_flow_test.dart` (ใหม่) — หน้า SmartSearchPage จริงพร้อม services/repository จริงและ mock HTTP ยืนยัน 400 ตลอด form → Gemini request → preference submission

เอกสารเพิ่มเติมคือรายงานนี้ ไม่มี temporary diagnostic/log ที่ต้องลบ และไม่ได้แก้ Matching Engine, Backend, schema หรือข้อมูลธุรกิจ

## BEFORE / AFTER

### Extractor

ก่อน: ตัวเลขหลังคำว่า `งบ` ต้องมีอย่างน้อยสามหลักและ >=1000 มิฉะนั้นลอง fallback ที่ต้อง >=3000

หลัง: ตัวเลขหลังคำงบที่รู้จักมีอย่างน้อยหนึ่งหลักและ >0 อ่านหน่วยตาม multiplier ถ้ามี ส่วน fallback ของตัวเลขไม่มีบริบทคงเดิม เพื่อจำกัด scope

```diff
- if (contextualValue != null && contextualValue >= 1000) {
-   return contextualValue;
+ if (contextualValue != null && contextualValue > 0) {
+   final multiplier = switch (contextual?.group(2)) {
+     'k' || 'พัน' => 1000,
+     'หมื่น' => 10000,
+     'แสน' => 100000,
+     _ => 1,
+   };
+   return contextualValue * multiplier;
  }
```

`20k` และ `2 หมื่น` เดิม local parser ไม่รองรับ (unit tests ก่อนแก้คืน null) งานนี้เพิ่มการรองรับและยืนยันค่า 20000 ไม่อาศัยค่า default ที่บังเอิญเท่ากัน Widget tests ของงบ 20000 เปลี่ยนฟอร์มเป็น 45000 ก่อน เพื่อให้จับกรณี extraction ไม่เกิดขึ้นได้

### Form state และ slider

```diff
- _budget = extraction.budget!.clamp(3000, 100000).toDouble();
+ _budget = extraction.budget!.clamp(1, 100000).toDouble();

  // ค่า control เท่านั้น: state/draft ยังเก็บงบจริง
- value: _budget,
+ value: _budget.clamp(3000, 100000).toDouble(),
```

ยังรักษาเพดาน UI 100000 เดิม สำหรับงบจากข้อความ 400 แสดง exact budget ฿400 และ chip ≤฿10,000 ส่วน thumb อยู่ที่ขอบล่างของช่วง manual slider พร้อมข้อความว่า “ใช้งบจากข้อความ ฿400 หากปรับสไลเดอร์จะเริ่มที่ ฿3,000” ถ้าผู้ใช้ปรับ slider/chip ด้วยมือ ให้ถือเป็นการ override ตามพฤติกรรมเดิม

## TESTS

ก่อนแก้เพิ่ม regression tests แล้วรันเพื่อยืนยันบั๊ก: `เกาหลีงบ400` คืน null และ widget ยังอยู่ tier เดิม; `20k`/`2 หมื่น` คืน null; ค่าบวกเล็กถูกทิ้ง รวมผลรันช่วงแรก 25 ผ่าน / 5 ล้มเหลว

หลังแก้รันจาก `travelin_app`:

```powershell
flutter test test/ai_match_service_test.dart test/widgets/ai_preference_form_test.dart test/services/gemini_ai_client_test.dart test/widgets/ai_preference_budget_flow_test.dart test/services/matching_api_service_test.dart test/matching/matching_engine_test.dart --reporter expanded
flutter analyze
```

ผล: **57 tests ผ่านทั้งหมด** (40 tests ของ extractor/form/Gemini mapping/page flow และ 17 tests เดิมของ Matching API/Engine) และ **No issues found** จาก analyzer

| ข้อความ | Extracted budget | Selected tier | งบใน draft | ผล |
| --- | ---: | --- | ---: | --- |
| เกาหลีงบ400 | 400 | ≤ ฿10,000 | 400 | ผ่าน |
| เกาหลี งบ 9000 | 9000 | ≤ ฿10,000 | 9000 | ผ่าน |
| เกาหลี งบ 10000 | 10000 | ≤ ฿10,000 | 10000 | ผ่าน |
| เกาหลี งบ 15000 | 15000 | ≤ ฿20,000 | 15000 | ผ่าน |
| เกาหลี งบ 20000 | 20000 | ≤ ฿20,000 | 20000 | ผ่าน |
| เกาหลี งบ 25000 | 25000 | ≤ ฿30,000 | 25000 | ผ่าน |
| เกาหลี งบ 40000 | 40000 | ≤ ฿50,000 | 40000 | ผ่าน |
| เกาหลี งบ 20k | 20000 | ≤ ฿20,000 | 20000 | ผ่าน |
| เกาหลี งบ 2 หมื่น | 20000 | ≤ ฿20,000 | 20000 | ผ่าน |

Page-flow test ตรวจว่าไม่มี HTTP request ขณะพิมพ์ จากนั้นเมื่อ submit จะมี `/api/analyze` และ `/api/matching/preferences/submit` ตามลำดับ คำขอทั้งสองส่ง budget 400 และ country เกาหลีใต้ ไม่ได้สร้างข้อมูลฐานจริง Tests HTTP ใช้ mock response จึงพิสูจน์ client mapping/request และ UI flow แต่ไม่อ้างผลการตอบของ Gemini live หรือการจับกลุ่มกับฐานข้อมูลจริง

## ผลของเคส `เกาหลีงบ400`

ก่อนแก้: ประเทศเกาหลีใต้ถูกต้อง แต่ extracted budget null → state คง 20000 → chip ≤20,000

หลังแก้: ประเทศเกาหลีใต้ → extracted budget 400 → state/draft 400 → chip **≤฿10,000** → คำขอ Gemini budget 400 และคำขอบันทึก preference budget 400 ไม่มีการแทนงบจริงด้วยค่าเพดาน tier
