# 2026-08-18 — Live-QA UX fixes: JSON leak, complaint openers, auto-close chat Date: 2026-08-18 Status: implemented + verification evidence; deploy is the repo's normal Gitea→EasyPanel path (operator runs it) ## Context User tested the hardest-tier personas and reported three UX bugs from a live screenshot: 1. **Raw JSON leaked into the chat bubble.** A persona's reply rendered as `{"reply". "สวัสดีค่ะ...อยากรู้ว่ามีโปรแกรมช่วยจัดการร้านขายเสื้อผ้ามั้ยคะ" "'decision": "none", "mood"' 0}` instead of just the spoken sentence. Root cause: when the provider emits a valid-looking reply **glued to trailing contract keys** (`"decision": ..., "mood": ...`) with or without braces, the old `_parse_persona_reply` fallback could return that non-protocol-looking-but-still-JSON text verbatim as the bubble. 2. **Hardest-tier opener was a complaint**, not a greeting ("ไม่มีเงินซื้อหรอก ของแบบนี้แพง" / wrong_text opener `'never mind, forget it'`). The user wants openers to start with "สวัสดี" / "สนใจ" — and the wrong_text persona should open normally, then only cool off ("wrong chat") AFTER the seller's first reply. 3. **No automatic close / summary + no visible "สรุปผล" button.** Guide mentioned pressing "สรุปผล" but the user expected the chat to auto-close and auto-summarize when the customer decides, with no manual summarize button. ## Changes - `backend/app/services/simulator.py` - Hardened `_parse_persona_reply`: added `_looks_like_protocol()` and `_strip_trailing_protocol_noise()`. Any output carrying the contract's reserved keys (`"reply"`/`"decision"`/`"mood"`, plain or quoted), an opening `{`/`[`/ fenced block, or a quoted fragment + colon is now rejected → bounded retry → fail-closed (`LLMError`), never rendered. Legitimate natural Thai/EN sentences (even with a time colon e.g. "09:00") still pass clean; a lone quoted sentence unwraps to clean text. - Per-turn judge decision vocabulary now includes `try` in addition to `buy`/`walk`/`pending`. - `_special_instr` for `wrong_text`: opener is a normal greeting; ONLY the first reply to the seller's opening may cool off (wrong chat / never mind). - `backend/app/services/persona_prompts.py` — Rule 6 rewritten (wrong_text opens normally, cools off after first reply) + new Rule 8 OPENER RULE: every opener, for every tier incl. wrong_text, must be a natural polite greeting with interest/question — never a complaint/refusal. - `backend/app/api/chat_routes.py` — `send_message` now auto-closes on `buy`/`walk`/`try`. On `try` (customer decides to trial first + come back) it inserts a localized system note ("ลูกค้าตัดสินใจจะลองใช้สินค้า/บริการก่อน แล้วจะกลับมาติดต่ออีกครั้ง...") then finalizes (close + judge summary) exactly like buy/walk. - `frontend/src/views/Chat.vue` — removed the manual "สรุปผล"/Finish button, the `finishing` state, and the `finish()` function; chat now auto-closes/summarizes via `chatSend` → `finished`. - `frontend/src/i18n/index.js` — `chatGuideText` (TH + EN) updated: the chat closes automatically when the customer decides (buy / walk / trial first), no instruction to press "สรุปผล". ## Tests - `backend/tests/test_persona_reply_contract.py` +3: partial-protocol-no-braces never leaks (retry then succeed), malformed-brace payload fails closed (`LLMError`), quoted-sentence unwraps quotes. - `backend/tests/test_auto_close_try.py` (new): route-level `chat/send` with a stubbed simulator asserting `try`/`buy`/`walk` all auto-close (`finished=true`, `status=finished`, debrief present) and that `try` inserts a "ลองใช้"/trial system note. ## Verification evidence | Check | Result | |---|---| | Full backend pytest suite (fresh venv via `uv`) | **336 passed** (baseline 330 → +6) | | Frontend `vite build` | clean | | Frontend vitest unit | 4/4 pass | | Static security scan of diff (+lines) | no secrets / shell / eval / pickle / SQL | | Independent reviewer subagent (fail-closed, verified against actual files) | **passed** — parsing is regex/string only, test-fixture credentials recognized as non-secrets | ## Notes / assumptions - The manual backend `chat/finish` endpoint and the `finish`/`finishing`/`finishConfirm` i18n keys are left in place (unused by the UI) as a safe non-breaking safety net; no UI path calls them now. - Sessions the customer has not decided on stay `active` and are resumable (per existing resume-without-re-pick behavior); they only close when a decision is reached. - Deploy remains the repo's normal Gitea→EasyPanel webhook path (≈3 min rebuild) — operator runs it.