feat(personas): 'create persona from this persona' (variant) — practice the same challenge, new identity

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.
This commit is contained in:
Macky
2026-08-09 12:43:00 +07:00
parent 8771438aef
commit 10c7d01236
29 changed files with 217 additions and 30 deletions

View File

@@ -47,6 +47,7 @@ export const api = {
analyzeGroup: (id, opts = {}) => request('POST', `/api/groups/${id}/analyze${opts.append ? '?append=true' : ''}`),
listPersonas: (gid) => request('GET', `/api/groups/${gid}/personas`),
getPersona: (gid, pid) => request('GET', `/api/groups/${gid}/personas/${pid}`),
createPersonaVariant: (gid, pid) => request('POST', `/api/groups/${gid}/personas/${pid}/variant`),
updatePersona: (gid, pid, b) => request('PUT', `/api/groups/${gid}/personas/${pid}`, b),
chatStart: (gid, pid, scenario, locale) => request('POST', `/api/chat/${gid}/personas/${pid}/chat/start`, { scenario, locale }),
chatResume: (gid, pid) => request('GET', `/api/chat/${gid}/personas/${pid}/chat/resume`),

View File

@@ -48,7 +48,12 @@
<router-link v-if="p.my_outcome === 'not_tried'" :to="`/groups/${gid}/chat/${p.id}`" style="margin-top:auto">
<button class="primary" style="width:100%">{{ i18n.t('chat') }}</button>
</router-link>
<div v-else class="muted" style="margin-top:auto;font-size:12px"> {{ i18n.t('trained') }} ({{ outcomeLabel(p.my_outcome) }})</div>
<template v-else>
<div class="muted" style="margin-top:auto;font-size:12px"> {{ i18n.t('trained') }} ({{ outcomeLabel(p.my_outcome) }})</div>
<button class="soft" style="width:100%;margin-top:8px" @click="makeVariant(p)" :disabled="p._busy">
{{ p._busy ? 'กำลังสร้าง…' : 'สร้างบุคคลต้นแบบจากต้นแบบนี้' }}
</button>
</template>
</template>
</div>
</div>
@@ -76,7 +81,19 @@ async function load() {
function byTier(t) { return personas.value.filter((p) => p.tier === t) }
function tierLabel(t) { return i18n.t(t === 'A' ? 'tierA' : t === 'B' ? 'tierB' : 'tierC') }
function outcomeLabel(o) {
return o === 'won' ? i18n.t('won') : o === 'lost' ? i18n.t('lost') : i18n.t('notTried')
return o === 'won' ? i18n.t('won') : o === 'lost' ? i18n.t('lost') : o === 'not_tried' ? i18n.t('notTried') : o || '-'
}
async function makeVariant(p) {
p._busy = true
try {
await api.createPersonaVariant(gid, p.id)
await load() // reload so the new variant shows up in the list
} catch (e) {
alert(e.message)
} finally {
p._busy = false
}
}
onMounted(load)
</script>