6.5 KiB
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:
- 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_replyfallback could return that non-protocol-looking-but-still-JSON text verbatim as the bubble. - 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. - 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
tryin addition tobuy/walk/pending. _special_instrforwrong_text: opener is a normal greeting; ONLY the first reply to the seller's opening may cool off (wrong chat / never mind).
- Hardened
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_messagenow auto-closes onbuy/walk/try. Ontry(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, thefinishingstate, and thefinish()function; chat now auto-closes/summarizes viachatSend→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-levelchat/sendwith a stubbed simulator assertingtry/buy/walkall auto-close (finished=true,status=finished, debrief present) and thattryinserts 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/finishendpoint and thefinish/finishing/finishConfirmi18n 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
activeand 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.
Deployment follow-up (same day)
After the commit+push (ffb7cdd), a live check showed the deployed site still served the old
bundle: the frontend index-*.js hash was unchanged and still contained the OLD guide text
("พอใจแล้วกด "สรุปผล"") and the old finish button, with none of the new strings. The Gitea→EasyPanel
deploy webhook (hook id 3, active:true, push event) is configured, but the push did not propagate
within ~10+ min. The exact reported leak payload was re-run through the NEW parser and extracts a clean
reply (no leak), so the code fix is correct — the leak seen was the stale deploy. Action: force a
redeploy (re-push to re-trigger the webhook, or redeploy from the EasyPanel console) and re-verify the
live bundle contains the new auto-close guide text.
Root cause of non-boot (same day)
The re-triggered deploy DID build, but the new container crash-loops on boot:
RuntimeError: JWT_SECRET must be configured with at least 32 characters in
Config.validate_runtime_security() (backend/app/config.py:111, production default, workers exit
code 3). The old live image ran pre-strict-check code, so it never enforced this. The UX fix commits
sit on top of the strict security code (S4.x), which fails closed in production without a valid
JWT_SECRET. Fix is env config, not code: set JWT_SECRET (≥32 chars, random) in the EasyPanel
service environment, then Redeploy. BOOTSTRAP_ADMIN_PASSWORD (≥12) is only required for first-run
admin init; log shows users already exist (require_bootstrap=False), so JWT_SECRET alone unblocks boot.