The llama.cpp endpoint writes the model's hidden reasoning INLINE into
content unless enable_thinking=true is sent (which relocates it to the
separate reasoning_content field). The app sent the field absent, so:
- complete_json got corrupted JSON -> live 500 'Unterminated string char 143'
- persona JSON generation failed (same root cause, earlier incident)
- persona replies were bloated with the reasoning chain
Send the flag on all three call paths (complete/complete_json/
complete_conversation). thinking param retained for API compat, now a
no-op on this provider (always separated — the only correct mode here).
- tests/test_llm_enable_thinking.py pins the flag on all three paths
- full suite: 543 passed; independent reviewer PASS (22 focused passed,
single OpenAI client in the app, both fixed sites confirmed, no stale
tests)
- cleanup stale comments that encoded the backwards understanding
(endpoint 'ignores enable_thinking' — it honors it; that misconception
is what caused this incident)
The Qwen3.8 GGUF reasons internally before answering and the llama.cpp
endpoint ignores enable_thinking — hidden reasoning consumed the whole
400-token persona_reply budget (HTTP 200, content='') -> LLMError -> live
500 'LLM service unavailable'. The 800-token judge JSON had the same
starve and silently degraded to 'pending', so customers who decided to
buy never closed the session.
- persona_reply 400 -> 1200, evaluate_turn 800 -> 1600
- tests/test_llm_budgets.py pins the floors so a regression to the old
values fails loudly
- full suite: 540 passed; independent review PASS (truncation-safe:
partial protocol JSON is rejected, never rendered)
- llm.py: _create() wrapper retries APIConnectionError (incl. timeout) up to
LLM_MAX_ATTEMPTS=3 with LLM_RETRY_DELAY_SECONDS=5s backoff; 4xx still fails
fast. The local LLM server is shared with Hermes and serializes requests, so
a request can be dropped by the client timeout while queued — chat/send was
500ing on that.
- helpers.py: internal_error() now logs full exception detail server-side
(was only the type name), so 500s are diagnosable; client message unchanged.
- config.py: LLM_MAX_ATTEMPTS / LLM_RETRY_DELAY_SECONDS env knobs.
- tests: test_llm_retry_queue (transient retried, 4xx not retried, no retry on
success), test_internal_error_logging (detail in log, hidden from client,
order-independent via explicit handler + logger re-enable).
- social trial: silent/gone follow-up outcomes with localized notice
variants; gone is always a loss with possibility-framed debrief
- gone marker persists across judge outages; retries stay on the
deterministic loss path (no roleplay reopen)
- bounded analysis_progress persisted during persona generation,
exposed via safe serializer, polled + rendered as accessible UI
- LLM HTTP timeout 105s -> 180s for Qwen 3.8 (enable_thinking off)
- demo-account test clock made relative to current UTC
- regression coverage: follow-up branches, judge-failure retry,
progress callback, timeout contract, progress serializer
- independent review finding verified stale against final tree
Tests: 532 backend, 29 frontend, prod build green
`try` (customer will trial first) no longer auto-closes the session.
A random follow-up event is rolled so the trainee practices the follow-up:
1. customer goes silent (~2 days) — trainee must ทัก first, then rolls:
gone for good (instant loss + judge debrief) or hesitant (re-engages);
2. customer comes back on their own with a post-trial message.
Only buy/walk/gone-for-good ever close the session. New notes/returners are
in _NOTES and added to _SAFE_SYSTEM_MESSAGES; the followup phase is stripped
from LLM prompts by the _safe_roleplay_internal allowlist (no latent leak).
Tests: 6 new (try->silent, silent->gone, silent->hesitant, try->returned,
buy closes, walk closes) with deterministic StubRandom + LLM-call counters
asserting follow-up turns skip persona_reply/evaluate_turn.
Revert the over-redaction from 3c22d88: a customer-initiated
session (social / lead who messages in) now greets with the
persona's generated opener — a realistic 'สวัสดี สนใจ ขอถามราคา'
line — instead of the static 'ลูกค้าเริ่มต้นบทสนทนาแล้ว'
placeholder. The opener is spoken public dialogue by design
(OPENER RULE keeps pains/budget/personality out of it), so
remove the redact_initial_customer strip from serialize_session.
Phone/f2f (seller-initiated) is unchanged: no customer line,
the trainee opens the call. Untrusted system-note filtering and
internal-state/provider-path stripping are untouched.
Tests: 5 redaction regressions flipped to the new contract.
517 passed (3 pre-existing demo-date failures, unrelated).
Proves DELETE /api/groups/<gid> removes the product, its personas (stored on the
group), its own sessions, and uploads, while sessions of an unrelated product/
org survive. Guards the cascade against future regressions.
The realistic sales simulation must not offer a "Finish practice / จบการฝึก"
button, because the chat is meant to auto-close on buy/walk/try (as originally
decided in ffb7cdd). The finish button and its chatFinish handler were
reintroduced in 3c22d88 and are now removed; the composer holds only Send and the
debrief renders automatically once the session finishes. Regression test asserts
no finish/summary button is rendered.
Use "Persona"/"Personas" (capital P) as the feature term across the English and
Thai UI instead of lowercase "persona". Thai now reads "Persona" with a space
separator in mixed Thai-English sentences (e.g. "เลือก Persona เพื่อฝึก",
"จัดการ Persona", "บันทึก Persona"). Remove .toLowerCase() on the persona count
label in Training so it renders "Personas"/"Persona" consistently.
Feature-word 'persona'/'personas' now reads 'Persona'/'Personas' (capital P)
across the current review checkpoint docs, matching the UI terminology. Code
identifiers, API paths, and schema fields stay lowercase.
Records written before the visibility field existed carry none; the new
fail-closed authorization treated missing visibility as invalid, making every
legacy group unlistable and unreadable. Add resolved_visibility(group) that
derives effective visibility for legacy records only (owner present => private,
absent => public), leaves explicit-malformed visibility fail-closed (None), and
never derives demo/hidden. Apply it at every list, authorization, chat, and
analytics boundary while keeping demo and hidden-preview paths raw and
owner_user_id-based private isolation intact. No persisted data is rewritten.
Backend full suite passes 517; frontend 26/26; production build passes.
Public social signup into OAUTH_DEFAULT_ORG (role user, seat-checked);
email-match links existing active user instead of duplicating. Server-side
provider token validation via stdlib urllib only (no new dep): Google
tokeninfo (aud + email_verified) and Facebook app/debug-token/me (is_valid,
app_id, me.id==user_id). Fail-closed when creds unconfigured, rate-limited
per-IP + per-email, /oauth/config leaks no secrets. Frontend: login buttons
(only enabled providers), GSI + FB SDK on-demand, monochrome glyphs, TH/EN.
Login page shows social buttons only when backend reports provider enabled.
348 backend tests pass (337 + 11 new OAuth), frontend build + 4/4 unit
clean, manual security review PASS. Not pushed (push auto-deploys).
- persona_prompts OPENER RULE: openers must be a direct question OR declare
interest ('สนใจ/สอบถาม'); explicitly forbid 'ลองดู / ลอง / มาลอง / แค่มาดู /
กดดู / แวะดู' (trying/browsing) phrasing which Thai customers don't use
- fix_openers.py: rewrite browse/try openers into a polite natural interest
opener ('สวัสดีครับ/ค่ะ สนใจสอบถามสินค้าครับ ขอรายละเอียดได้ไหม'), leaving
openers that already ask a question or say สนใจ untouched
- persona_prompts OPENER RULE: never open with dots/ellipsis and never end
an opener by dismissing/belittling the product (but ไม่คิดจะซื้อ, แค่มาดูเล่น,
ก็แค่มาเมิน, ไม่ได้จะซื้อ...) — Thai openers stay politely interested
- fix_openers.py: strip leading dots("..") and remove trailing dismissive
clauses ('ตามไม่คิดจะซื้อหรอกนะ' etc.) so existing personas are normalized
to a natural interest opener; update docstring
Deterministic, conservative: only rewrites openers that lead with a casual
interjection (เฮ้/เอ้/เอ่อ/เอ๊ะ/อืม/เออ/นี่ๆ/ว่าไง/hey/hi/hiya/yo) or a bare
politeness word (ครับ/ค่ะ). Leaves natural Thai and English openers untouched.
Prints before -> after. Run in the container console:
cd /app/backend && python3 scripts/fix_openers.py
- persona_prompts OPENER RULE: mandate polite Thai openers that start
with สวัสดีครับ/ค่ะ and go straight to the question or 'สนใจ/สอบถาม';
explicitly forbid casual English fillers (เฮ้/เอ้/เอ่อ/hey) and
singsong slang for Thai
- chat_routes: default opener fallback is now locale-aware - Thai locale
uses 'สวัสดีครับ สนใจสินค้าของคุณครับ...' instead of the English one
- listPersonas: compute per-user my_outcome for ALL roles; always set the
key (default 'not_tried') so the all-roles UI never sees undefined
(fixes global super_admin without org showing every persona as trained)
- start_session/mode lookups (send/finish/resume/authorize): no preview
mode; every role creates one-shot 'trainee' sessions. The one-shot lock
(1 persona chat per user; many users per persona) now applies to all roles
- Chat.vue: always start in trainee mode, remove preview label
- Personas.vue: unified branches - show 'แชท' when untrained, 'สรุปผล'+variant
when trained, for all roles
- Rewrite test_admin_preview.py to assert the new no-preview one-shot behavior
- Backend: strip pain/painProgress/revealed_persona.pains from debrief
serializer and remove pain/initialPainFit from admin report so the
coaching formula never leaks to any user-facing role (persona keeps
pains internally to drive the judge/training)
- Fix [object Object] array-of-objects rendering in Chat/SessionDetail
- Personas: trained persona shows 'สรุปผล/Summary' button -> chat page
with past result (chat already loads finished session)
- Top bar: username dropdown containing Settings + Logout (was separate
logout button); variant-from-base already preserves tier/difficulty
- Updated debrief allowlist tests to reflect new redaction
- formatValue(): render arrays of objects (e.g. pains) as readable text
instead of '[object Object], [object Object]'
- SessionDetail: new 'สร้างบุคคลต้นแบบจากต้นแบบนี้' button calls the
existing group persona-variant endpoint so trainees/orders can regenerate
a persona based on the revealed one after a session
Re-verified staged increment from a clean requirements.lock.txt venv:
- 330 backend tests pass (17/17 in new error_handlers + json_import tests)
- compileall + frontend npm build clean
- git diff --check clean; no secrets in diff
- importer CLI dry-run bootstrap works
Includes JSON HTTPException handler under /api/* and parse-safe static 404
via abort. JSON stores remain runtime-authoritative; production operation
still gated behind operator approval.
Removed the fixed-value text detector. persona_reply no longer forces JSON meta; instead a
per-turn evaluate_turn() calls the judge LLM after every customer reply to read the persona's
current mood + whether it has decided (buy/walk/pending) + score_delta + reason. send_message
consumes that context-based decision to (a) end the chat as won/lost and (b) move the score.
This is what the user asked: the system evaluates EVERY turn and decides at the moment it's
truly committed — not keyword matching (so 'ซื้อไม่ไหว แต่ว่ามีผ่อนไหม?' stays pending).
Mock updated: judge returns buy on first send (keeps E2E deterministic). 11/11 suites pass.
- start_session now RESUMES an existing ACTIVE session for the persona instead of creating
a new one / forcing re-pick of the scenario (keep original scenario+messages).
- send_message falls back to a text-based decision detector (_detect_customer_decision)
because real LLMs rarely emit structured meta.decision — so a customer who says
'ซื้อไม่ไหว'/'no thanks' now actually ENDS the chat as lost (was stuck active forever).
Fragments handled in TH + EN; buy + walk.
All 11 backend suites pass. Rebuilt dist.
Each persona is one-shot (chat once = win/lose locked). To keep training repeatable:
- New endpoint POST /api/groups/<gid>/personas/<pid>/variant creates a NEW persona that is
a fresh incarnation of the source: LOCKS pain points, objections, negotiation levers,
tolerance, special/recontact, goal, budget, difficulty, tier, product_context — but VARYS
name/profession/age/location/background/personality/opener so it isn't an identical copy.
- Added to the same group as a distinct persona (fresh not_tried, so chat-able again).
- UI: on the Personas page, a finished (won/lost) persona gets a
'สร้างบุคคลต้นแบบจากต้นแบบนี้' button; reload shows the variant.
All 10 backend suites pass. Rebuilt dist.
- recontact persona now opens as a NORMAL customer (no 'I asked before' in the opening);
the mid-chat time-lapse system note (turn 2) makes them re-engage warmer instead.
- channel default changed facebook->social everywhere (create/analyze/store/simulator/me).
- 15 personas auto-generated (TARGET=15); removed the 'สร้างบุคคลต้นแบบเพิ่มเติม' button/guide.
All 9 backend suites pass. Rebuilt dist.
Live test found: deepseek returned 14/15 personas -> whole analyze 500'd, breaking the
'กดสร้าง -> auto-analyze' flow. Now generate() retries up to 3x with a nudge, and accepts
a short result (>=8 personas) instead of crashing — admin can top up the rest with
'สร้างบุคคลต้นแบบเพิ่มเติม'. Also default tolerance/recontact on generated personas.
All backend suites pass.
- llm.complete_conversation now maps internal roles (customer->assistant, seller->user,
system->system) before the API call — fixes 'Unknown role: customer' (501).
- Re-contact personality: the customer now chats normally, at turn 2 goes quiet and a
system time-lapse note is shown ('⏳ ผ่านไป 2-3 สัปดาห์...'), then re-engages warmer —
instead of 'pretending you asked before' at start. Driven by persona.recontact trait.
All 9 backend suites pass. Rebuilt dist.
- Scenario picker now has only social + f2f_call (removed recontact) in backend
_scenarios, start_session validation, and Chat.vue picker.
- 'ลูกค้ากลับมาติดต่อ' is no longer a scenario: it's now a PERSONA trait. Persona prompt
generates ~1-in-4 personas with recontact=true (asked before, now returns warmer/ready);
store shape gets recontact field; simulator injects a re-contact note into the persona's
system prompt so it plays as a returning customer naturally.
- test_scenario updated: unknown scenario defaults to social; recontact shown as a
generated persona trait.
All 9 backend suites pass. Rebuilt dist.
- list_groups returns lightweight summaries (id/title/status/persona_count + product) —
never the full personas array, so Training no longer shows a long JSON blob.
- PersonaForm toLines extracts .description/.text from pain/objection objects (fixes
'[object Object]'); save re-wraps pains as {description}.
- GroupEdit persona cards get a 'แชท' (MessageSquare) button so you can start chatting
right where you land after creating (chat was only on Personas page before).
- channel: default neutral 'social', removed facebook/line validation + removed from
revealable_view (scenario now sets the tone, not fb/line).
All 9 backend suites pass. Rebuilt dist.
- Removed channel + initiation displays: Training card, Personas card, GroupEdit card,
Chat header, and the channel select in PersonaForm (scenario now determines who opens).
- Clarified the start-training flow: GroupEdit guide now says go to the Training tab ->
pick product -> pick a customer -> Chat -> choose scenario -> talk; Personas guide
spells out the Chat + scenario step.
Rebuilt dist.