feat(chat): persona decides to buy/walk — auto-finish + tolerance (temper) + resume

The conversation now ENDS when the persona makes a decision (option C), not when the
trainee clicks a button:
- persona_id replies carry {reply, decision(none/buy/walk), mood}; when decision is
  buy/walk the session auto-finishes (won/lost) with a debrief that reveals latent
  details + per-turn 'turning points'.
- personas have a tolerance (1-5, 'temper'): impatient personas walk away fast after
  poor answers (fed via internal.misses on mood<=-1); tough 'wrong text' cases can
  still be won by a strong, gentle response (judge realism).
- trainee 'Finish' button removed; if they leave mid-chat an active session is resumed
  via /chat/resume (continue, not restart). One-shot lock still enforced once decided.
- mock/tests updated: persona deciding buy -> send auto-finishes won.
Rebuilt dist.
This commit is contained in:
Macky
2026-08-08 14:34:34 +07:00
parent bd6a7ffa32
commit aa2eb8dd37
28 changed files with 199 additions and 84 deletions

View File

@@ -48,6 +48,7 @@ export const api = {
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, scenario) => request('POST', `/api/chat/${gid}/personas/${pid}/chat/start`, scenario || {}),
chatResume: (gid, pid) => request('GET', `/api/chat/${gid}/personas/${pid}/chat/resume`),
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

@@ -37,9 +37,9 @@
<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>
<span v-if="phase === 'done'" class="badge" :class="debrief && debrief.outcome">
{{ debrief && debrief.outcome === 'won' ? i18n.t('won') : i18n.t('lost') }}
</span>
</div>
<!-- Chat thread -->
@@ -102,7 +102,6 @@ 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')
@@ -123,13 +122,33 @@ 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
// Resume: if there's an unfinished session for this persona, continue it instead of restarting.
try {
const r = await api.chatResume(gid, pid)
sessionId.value = r.session.id
messages.value = r.session.messages || []
if (messages.value.length && !r.session.debrief) {
phase.value = 'chat'
// re-derive which scenario was active
const sc = scenarios.find((s) => s.id === r.scenario)
if (sc) picked.value = r.scenario
// if session has a debrief already, it's done
} else if (r.session.debrief) {
debrief.value = r.session.debrief
messages.value = r.session.messages || []
phase.value = 'done'
}
if (messages.value.length) scrollDown()
} catch (e) {
// no active session -> show the scenario picker
phase.value = 'pick'
}
})
async function send() {
@@ -139,6 +158,10 @@ async function send() {
const res = await api.chatSend(gid, pid, text.value.trim())
messages.value = res.messages
text.value = ''
if (res.finished) {
debrief.value = res.debrief || null
phase.value = 'done'
}
scrollDown()
} catch (e) {
alert(e.message)
@@ -147,21 +170,6 @@ async function send() {
}
}
async function finish() {
if (!confirm(i18n.t('finish') + '?')) return
sending.value = true
try {
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 {
sending.value = false
}
}
const FIELD_LABELS = {
name: 'ชื่อ', tier: 'ระดับ', difficulty: 'ความยาก', profession: 'อาชีพ',
age_group: 'ช่วงอายุ', location: 'พื้นที่', income: 'รายได้', budget: 'งบประมาณ',