feat(chat): scenario-based training — choice of channel/Situation + realistic per-turn evaluation

Backend:
- Channel/initiation now driven by a SCENARIO chosen at chat start, not baked into the
  persona: social (customer opens), f2f_call (seller must open, proactive), recontact
  (customer re-contacts after 1-3 months).
- /chat/start accepts {scenario}; session stores scenario + internal{turns,score}.
- persona_reply takes scenario + adapts tone; system-role transcript entries are fed to
  the persona as hidden scene notes.
- JUDGE updated for realism: good response can WIN even in hard/tough-text scenarios;
  long/no-close chats (turns >~12) lose; pushy/ignoring-need loses. Efficiency rewarded.

Frontend:
- Scenario picker before chat (choose Social / Face-to-face-call / Re-contact).
- Chat thread renders role=system as a centered time-lapse/scene note.
- Choose-scenario i18n (EN+TH).
Rebuilt dist.
This commit is contained in:
Macky
2026-08-08 11:27:05 +07:00
parent 8670addcc3
commit bd6a7ffa32
27 changed files with 314 additions and 110 deletions

View File

@@ -47,7 +47,7 @@ export const api = {
listPersonas: (gid) => request('GET', `/api/groups/${gid}/personas`),
getPersona: (gid, pid) => request('GET', `/api/groups/${gid}/personas/${pid}`),
updatePersona: (gid, pid, b) => request('PUT', `/api/groups/${gid}/personas/${pid}`, b),
chatStart: (gid, pid) => request('POST', `/api/chat/${gid}/personas/${pid}/chat/start`),
chatStart: (gid, pid, scenario) => request('POST', `/api/chat/${gid}/personas/${pid}/chat/start`, scenario || {}),
chatSend: (gid, pid, text) => request('POST', `/api/chat/${gid}/personas/${pid}/chat/send`, { text }),
chatFinish: (gid, pid) => request('POST', `/api/chat/${gid}/personas/${pid}/chat/finish`),
mySessions: () => request('GET', '/api/chat/sessions'),

View File

@@ -66,6 +66,7 @@ const messages = {
tierB: 'Tier B — Unsure',
tierC: 'Tier C — Not interested but has pain',
selectPersona: 'Select a persona to practice',
chooseScenario: 'Choose a scenario',
chatGuideTitle: 'How to practice',
chatGuideText: 'You are playing the salesperson. Chat with this customer and try to close the sale. Ask about their needs, solve their pain, and handle their objections. When you are done, press "Finish & get result" to see your score and coaching.',
chat: 'Chat',
@@ -151,6 +152,7 @@ const messages = {
tierB: 'ระดับ B — ยังไม่แน่ใจ',
tierC: 'ระดับ C — ไม่สนใจแต่มีปัญหา',
selectPersona: 'เลือกบุคคลต้นแบบเพื่อฝึก',
chooseScenario: 'เลือกสถานการณ์',
chatGuideTitle: 'วิธีฝึก',
chatGuideText: 'คุณรับบทเป็นพนักงานขาย พูดคุยกับลูกค้าคนนี้เพื่อพยายามปิดการขายให้ได้ สอบถามความต้องการ แก้ไขปัญหาของลูกค้า และรับมือกับข้อโต้แย้ง เมื่อพอใจแล้วกด "สรุปผล" เพื่อดูคะแนนและข้อเสนอแนะ',
chat: 'แชท',

View File

@@ -2,62 +2,85 @@
<div>
<router-link :to="`/groups/${gid}/personas`" class="btn-back"><ArrowLeft :size="16" :stroke-width="2" /> {{ i18n.t('personas') }}</router-link>
<!-- How-to-train guide -->
<div class="card guide">
<strong><Target :size="17" :stroke-width="1.8" style="vertical-align:-3px" /> {{ i18n.t('chatGuideTitle') }}</strong>
<p style="margin:6px 0 0;line-height:1.7">{{ i18n.t('chatGuideText') }}</p>
</div>
<div class="row" style="align-items:center;margin-bottom:12px">
<h2 style="margin:0">{{ persona ? persona.name : '...' }}</h2>
<span class="badge" :class="persona && persona.channel">{{ persona ? persona.channel : '' }}</span>
<span class="muted" v-if="persona">{{ persona.profession }} · {{ persona.age_group }}</span>
<button class="danger" style="margin-left:auto" @click="finish" :disabled="messages.length === 0 || !!debrief">
<span v-if="sending" class="spinner" style="margin-right:4px"></span>{{ i18n.t('finish') }}
<!-- Scenario picker (before chat) -->
<div v-if="phase === 'pick'" class="card">
<h3 style="margin-top:0">🎬 {{ i18n.t('chooseScenario') }}</h3>
<p class="muted" style="margin-top:0">เลอกสถานการณอยากฝกการขาย แตละแบบกำหนดวาใครทกกอน และโทนการสนทนา</p>
<div
v-for="sc in scenarios"
:key="sc.id"
class="scenario"
:class="{ sel: picked === sc.id }"
@click="picked = sc.id"
>
<div class="row" style="align-items:center">
<strong>{{ sc.emoji }} {{ sc.label }}</strong>
<span v-if="sc.init === 'customer'" class="badge line">ลูกค้าทักก่อน</span>
<span v-else class="badge facebook">ณตองทกกอน (เชงร)</span>
</div>
<div class="muted" style="margin-top:6px">{{ sc.desc }}</div>
</div>
<button class="primary" style="margin-top:16px;width:100%" :disabled="!picked" @click="begin">
{{ i18n.t('start') }}
</button>
</div>
<!-- Seller-initiated task -->
<div v-if="!started" class="card task">
<strong>📣 {{ i18n.t('sellerInitiated') }}</strong>
<div v-if="taskText">{{ taskText }}</div>
</div>
<!-- Chat thread -->
<div class="thread" v-if="started" ref="thread">
<div v-for="(m, i) in messages" :key="i" class="bubble" :class="m.role === 'seller' ? 'msg-seller' : 'msg-customer'">
{{ m.text }}
<!-- Chat / results -->
<div v-else>
<!-- How-to-train guide -->
<div class="card guide">
<strong><Target :size="17" :stroke-width="1.8" style="vertical-align:-3px" /> {{ i18n.t('chatGuideTitle') }}</strong>
<p style="margin:6px 0 0;line-height:1.7">{{ i18n.t('chatGuideText') }}</p>
</div>
<div v-if="sending" class="bubble msg-customer muted">...</div>
</div>
<!-- Input -->
<div v-if="started && !debrief" class="composer">
<input v-model="text" @keyup.enter="send" :disabled="sending" :placeholder="i18n.t('send')" />
<button class="primary" @click="send" :disabled="sending || !text.trim()">{{ i18n.t('send') }}</button>
</div>
<!-- Debrief overlay -->
<div v-if="debrief" class="card debrief">
<h3>{{ i18n.t('debrief') }}</h3>
<p><span class="badge" :class="debrief.outcome">{{ debrief.outcome === 'won' ? i18n.t('won') : i18n.t('lost') }}</span>
{{ i18n.t('score') }}: <strong>{{ debrief.score }}</strong></p>
<p><strong>{{ i18n.t('pain') }}:</strong> {{ debrief.pain || '—' }}</p>
<p><strong>{{ i18n.t('why') }}:</strong> {{ debrief.why }}</p>
<div v-if="debrief.coaching && debrief.coaching.length">
<strong>Coaching:</strong>
<ul><li v-for="(c, i) in debrief.coaching" :key="i">{{ c }}</li></ul>
<div class="row" style="align-items:center;margin-bottom:12px">
<h2 style="margin:0">{{ persona ? persona.name : '...' }}</h2>
<span class="badge" :class="persona && persona.channel">{{ persona ? persona.channel : '' }}</span>
<span class="muted" v-if="persona">{{ persona.profession }} · {{ persona.age_group }}</span>
<button class="danger" style="margin-left:auto" @click="finish" :disabled="messages.length === 0 || phase === 'done'">
<span v-if="sending" class="spinner" style="margin-right:4px"></span>{{ i18n.t('finish') }}
</button>
</div>
<details>
<summary><Search :size="15" :stroke-width="1.8" style="vertical-align:-2px" /> {{ i18n.t('reveal') }}</summary>
<div class="reveal-grid" v-if="debrief.revealed_persona">
<div v-for="(v, k) in debrief.revealed_persona" :key="k" class="rev">
<span class="rk">{{ fieldLabel(k) }}</span>
<span class="rv">{{ fmt(v) }}</span>
</div>
<!-- Chat thread -->
<div class="thread" ref="thread">
<div
v-for="(m, i) in messages"
:key="i"
class="bubble"
:class="m.role === 'seller' ? 'msg-seller' : m.role === 'system' ? 'msg-system' : 'msg-customer'"
>{{ m.text }}</div>
<div v-if="sending" class="bubble msg-customer muted">...</div>
</div>
<!-- Input -->
<div v-if="phase === 'chat'" class="composer">
<input v-model="text" @keyup.enter="send" :disabled="sending" :placeholder="i18n.t('send')" />
<button class="primary" @click="send" :disabled="sending || !text.trim()">{{ i18n.t('send') }}</button>
</div>
<!-- Debrief -->
<div v-if="phase === 'done' && debrief" class="card debrief">
<h3>{{ i18n.t('debrief') }}</h3>
<p><span class="badge" :class="debrief.outcome">{{ debrief.outcome === 'won' ? i18n.t('won') : i18n.t('lost') }}</span>
{{ i18n.t('score') }}: <strong>{{ debrief.score }}</strong></p>
<p><strong>{{ i18n.t('pain') }}:</strong> {{ debrief.pain || '—' }}</p>
<p><strong>{{ i18n.t('why') }}:</strong> {{ debrief.why }}</p>
<div v-if="debrief.coaching && debrief.coaching.length">
<strong>Coaching:</strong>
<ul><li v-for="(c, i) in debrief.coaching" :key="i">{{ c }}</li></ul>
</div>
</details>
<router-link to="/"><button class="primary" style="margin-top:12px">{{ i18n.t('dashboard') }}</button></router-link>
<details>
<summary><Search :size="15" :stroke-width="1.8" style="vertical-align:-2px" /> {{ i18n.t('reveal') }}</summary>
<div class="reveal-grid" v-if="debrief.revealed_persona">
<div v-for="(v, k) in debrief.revealed_persona" :key="k" class="rev">
<span class="rk">{{ fieldLabel(k) }}</span>
<span class="rv">{{ fmt(v) }}</span>
</div>
</div>
</details>
<router-link to="/"><button class="primary" style="margin-top:12px">{{ i18n.t('dashboard') }}</button></router-link>
</div>
</div>
</div>
</template>
@@ -74,13 +97,20 @@ const gid = route.params.gid
const pid = route.params.pid
const persona = ref(null)
const started = ref(false)
const phase = ref('pick') // 'pick' | 'chat' | 'done'
const messages = ref([])
const text = ref('')
const sending = ref(false)
const debrief = ref(null)
const taskText = ref('')
const sessionId = ref(null)
const picked = ref('social')
const scenarios = [
{ id: 'social', emoji: '💬', init: 'customer', label: 'Social Media (แชท)', desc: 'ลูกค้าทักมาหาคุณก่อน — โทนสั้น ทักๆ ตามสไตล์แชท' },
{ id: 'f2f_call', emoji: '📞', init: 'seller', label: 'พบหน้า / โทรศัพท์', desc: 'คุณต้องเป็นฝ่ายเปิดการสนทนาเชิงรุกกับลีด (ผู้ฝึกทักก่อน)' },
{ id: 'recontact', emoji: '⏳', init: 'customer', label: 'ลูกค้ากลับมาติดต่อ (1-3 เดือน)', desc: 'เคยได้รับข้อมูลไปแล้ว ตอนนี้กลับมาติดต่อ พร้อมตัดสินใจมากขึ้น' },
]
function scrollDown() {
nextTick(() => {
@@ -89,16 +119,17 @@ function scrollDown() {
}
const thread = ref(null)
async function begin() {
phase.value = 'chat'
const res = await api.chatStart(gid, pid, { scenario: picked.value })
sessionId.value = res.session.id
if (res.session.task) taskText.value = res.session.task
messages.value = res.session.messages || []
if (messages.value.length) scrollDown()
}
onMounted(async () => {
persona.value = (await api.getPersona(gid, pid)).persona
const res = await api.chatStart(gid, pid)
sessionId.value = res.session.id
if (res.session.task) {
taskText.value = res.session.task
}
messages.value = res.session.messages || []
started.value = true
if (messages.value.length) scrollDown()
})
async function send() {
@@ -123,6 +154,7 @@ async function finish() {
const res = await api.chatFinish(gid, pid)
debrief.value = res.debrief
messages.value = res.session.messages
phase.value = 'done'
} catch (e) {
alert(e.message)
} finally {
@@ -162,12 +194,15 @@ function fmt(v) {
gap: 8px;
}
.bubble { max-width: 72%; padding: 10px 14px; white-space: pre-wrap; word-break: break-word; }
.msg-system { align-self: center; background: #fef3c7; color: #92400e; font-size: 12px; max-width: 88%; border-radius: 999px; }
.composer { display: flex; gap: 8px; margin-top: 12px; }
.task { margin-bottom: 12px; background: #fff7ed; border-color: #fed7aa; }
.debrief { margin-top: 16px; }
.json { background: #0f172a; color: #9ca3af; padding: 10px; border-radius: 8px; font-size: 11px; overflow: auto; max-height: 260px; }
button.danger { background: var(--red); color: #fff; border: none; }
.guide { background: #eef2ff; border-color: #c7d2fe; margin-bottom: 12px; }
.scenario { border: 2px solid var(--border); border-radius: 12px; padding: 12px 14px; margin: 10px 0; cursor: pointer; transition: border-color .15s ease, background .15s ease; }
.scenario:hover { border-color: var(--accent); }
.scenario.sel { border-color: var(--accent); background: #eef2ff; }
.reveal-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 8px; }
.rev { display: flex; flex-direction: column; background: #f8fafc; border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; }
.rk { font-size: 12px; color: var(--muted); }