feat(debrief): surface buyer pains as pain insight instead of the persona reveal grid

This commit is contained in:
Macky
2026-10-03 11:58:31 +07:00
parent d18fbfa608
commit a95b7d3c74
5 changed files with 94 additions and 42 deletions

View File

@@ -204,6 +204,22 @@ def _safe_revealed_persona(persona: dict) -> dict:
return safe
def _safe_pain_list(value: object) -> list[str]:
"""Normalize a persona pains list to bounded strings (list items may be dicts)."""
out: list[str] = []
if isinstance(value, list):
for item in value[:20]:
if isinstance(item, str):
out.append(item.strip()[:1000])
elif isinstance(item, dict):
for key in ("description", "name", "text"):
candidate = item.get(key)
if isinstance(candidate, str) and candidate.strip():
out.append(candidate.strip()[:1000])
break
return [item for item in out if item]
def _safe_judge_debrief(
verdict: object,
outcome: str,
@@ -222,12 +238,17 @@ def _safe_judge_debrief(
public_debrief = raw.get("_public_debrief")
public = public_debrief if isinstance(public_debrief, dict) else {}
score = _bounded_int(raw.get("score", 0), default=0, minimum=0, maximum=100)
safe_persona = persona if isinstance(persona, dict) else {}
# Pains surface as the customer's real pain points (owner decision 2026-10-03):
# on finalize the fresh persona is authoritative; on re-read the stored debrief
# carries them (the session row has no persona payload).
pains = _safe_pain_list(safe_persona.get("pains"))
if not pains:
pains = _safe_pain_list(raw.get("pains"))
return {
"outcome": outcome,
"score": score,
# IP protection: do NOT surface the customer's pain (prose or raw list) to any
# user-facing role. Pain drives the judge/training internally but is the core
# "formula" of the coaching product, so it must not leak through the debrief.
"pains": pains,
"why": _bounded_text(public.get("why")),
"failurePoints": _string_list(public.get("failurePoints")),
"coaching": _string_list(public.get("coaching")),

View File

@@ -117,7 +117,9 @@ def test_final_judge_supplies_score_and_debrief_for_automatic_buy(monkeypatch):
assert updated["outcome"] == "won"
assert debrief["score"] == 88
assert debrief["coaching"] == ["Keep the close concise"]
# IP protection: pain is hidden from the debrief (prose + raw list).
# The customer's pain points surface as the debrief's pains insight
# (owner decision 2026-10-03); the raw persona fields stay in revealed_persona.
assert debrief["pains"] == ["pain"]
assert "pain" not in debrief
assert "painProgress" not in debrief
assert "pains" not in debrief["revealed_persona"]
@@ -157,9 +159,12 @@ def test_public_debrief_does_not_use_hidden_judge_prose(monkeypatch):
assert debrief["why"] == "The close was concise and direct"
assert debrief["coaching"] == ["Keep the close concise"]
# Pains are the sanctioned pain-point insight (owner decision 2026-10-03);
# everything else stays hidden.
assert debrief["pains"] == ["secret pain"]
assert "secret opener" not in str(debrief)
assert "secret budget" not in str(debrief)
assert "secret pain" not in str(debrief)
assert "secret lever" not in str(debrief)
assert "secret formula" not in str(debrief)
@@ -187,8 +192,9 @@ def test_public_debrief_failure_never_falls_back_to_hidden_judge_prose(monkeypat
assert debrief["why"] == ""
assert debrief["failurePoints"] == []
assert debrief["coaching"] == []
# Even when coaching fails closed, the pain insight still surfaces from the persona.
assert debrief["pains"] == ["secret pain"]
assert "secret hidden persona explanation" not in str(debrief)
assert "secret pain" not in str(debrief)
def test_debrief_never_returns_secret_persona_fields(monkeypatch):

View File

@@ -152,12 +152,15 @@ def test_judge_debrief_uses_closed_allowlist():
},
)
# pains is the sanctioned pain-point insight (owner decision 2026-10-03);
# all other latent fields stay out of the debrief.
assert set(debrief) == {
"outcome", "score", "why", "failurePoints", "coaching", "revealed_persona",
"outcome", "score", "pains", "why", "failurePoints", "coaching", "revealed_persona",
}
assert debrief["score"] == 100
assert debrief["failurePoints"] == ["missed discovery"]
# IP protection: pain (prose + list) and painProgress are stripped from the debrief.
assert debrief["pains"] == ["customer pain"]
# IP protection: pain (prose) and painProgress are stripped from the debrief.
assert "pain" not in debrief
assert "painProgress" not in debrief
assert "pains" not in debrief["revealed_persona"]
@@ -214,6 +217,39 @@ def test_session_serializer_omits_hidden_internal_state():
assert "/private" not in str(view)
def test_debrief_pains_survive_session_reread():
# Owner decision 2026-10-03: the buyer's pain points are the debrief's
# pain-point insight, so they must round-trip through the persisted
# debrief (the session row has no separate persona payload).
view = serialize_session(
{
"id": "session-pains",
"user_id": "user-1",
"group_id": "group-1",
"persona_id": "persona-1",
"persona_name": "Customer",
"status": "finished",
"outcome": "lost",
"persona_meta": {"scenario": "social", "locale": "th"},
"messages": [],
"internal": {},
"debrief": {
"outcome": "lost",
"score": 40,
"pains": ["กลัวโดนกดดันจนปิดไม่ลง"],
"why": "ตอบข้อมูลไม่ตรงจุด",
"failurePoints": [],
"coaching": [],
"revealed_persona": {},
},
}
)
assert view["debrief"]["pains"] == ["กลัวโดนกดดันจนปิดไม่ลง"]
assert "objections" not in str(view["debrief"])
assert "levers" not in str(view["debrief"])
def test_session_serializer_keeps_only_public_scene_notes():
view = serialize_session(
{

View File

@@ -219,6 +219,7 @@ const messages = {
pain: 'Pain',
why: 'Reason',
reveal: 'Revealed Persona details',
revealPains: 'What the buyer really struggles with',
generatePersona: 'Generate my Persona',
myPersonas: 'My Personas',
mySessions: 'My sessions',
@@ -455,6 +456,7 @@ const messages = {
pain: 'ปัญหาของลูกค้า',
why: 'เหตุผล',
reveal: 'รายละเอียด Persona ที่ถูกซ่อนไว้',
revealPains: 'ปัญหาที่ลูกค้าต้องเจอจริง ๆ',
generatePersona: 'สร้าง Personaเพิ่มเติม',
myPersonas: 'Personaของฉัน',
mySessions: 'ประวัติการฝึกของฉัน',

View File

@@ -91,11 +91,10 @@
</ul>
</div>
<details class="reveal">
<summary><Search :size="15" :stroke-width="1.8" /> {{ 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>
<summary>{{ i18n.t('revealPains') }}</summary>
<div class="reveal-grid">
<div v-for="(pain, idx) in pains" :key="`pain-${idx}`" class="rev">
<span class="rv">{{ pain }}</span>
</div>
</div>
</details>
@@ -108,7 +107,7 @@
<script setup>
import { computed, onMounted, nextTick, ref } from 'vue'
import { useRoute } from 'vue-router'
import { Target, ArrowLeft, Search, MessageCircle, Phone, Play, Send, UserRound, Sparkles } from 'lucide-vue-next'
import { Target, ArrowLeft, MessageCircle, Phone, Play, Send, UserRound, Sparkles } from 'lucide-vue-next'
import { api } from '../api'
import { i18n } from '../i18n'
import { auth } from '../store/auth'
@@ -224,34 +223,22 @@ async function send() {
}
}
const FIELD_LABELS = {
name: 'ชื่อ', tier: 'ระดับ', difficulty: 'ความยาก', profession: 'อาชีพ',
age_group: 'ช่วงอายุ', location: 'พื้นที่', income: 'รายได้', budget: 'งบประมาณ',
lifestyle: 'ไลฟ์สไตล์', background: 'ภูมิหลัง', personality: 'บุคลิก',
communication_style: 'สไตล์การสื่อสาร', goal: 'เป้าหมาย', decision_timeline: 'กรอบตัดสินใจ',
opener: 'บทเปิดบทสนทนา', channel: 'ช่องทาง', initiation_mode: 'ใครเริ่มก่อน', product_context: 'บริบทสินค้า',
}
function fieldLabel(k) {
return FIELD_LABELS[k] || k
}
function fmt(v) {
if (Array.isArray(v)) {
// Never let join() stringify an object into "[object Object]".
return v
.map((item) => {
if (item && typeof item === 'object') {
const readable = item.description ?? item.title ?? item.text
return readable && typeof readable === 'string' && readable.trim()
? readable.trim()
: JSON.stringify(item)
}
return String(item)
})
.join(', ')
}
if (v && typeof v === 'object') return JSON.stringify(v)
return String(v == null ? '—' : v)
}
// The debrief reveal shows the buyer's real pain points (the insight to take
// away) — NOT the persona's sales recipe (objections/levers/opener), which is
// the IP to protect. pains arrives server-side on the debrief (already
// normalized to strings); older sessions may lack it, so fall back to the
// loaded persona payload (objects like {description} or {name, description}).
const pains = computed(() => {
const fromDebrief = Array.isArray(debrief.value?.pains) ? debrief.value.pains : []
const raw = fromDebrief.length
? fromDebrief
: (persona.value && Array.isArray(persona.value.pains)) ? persona.value.pains : []
return raw
.map((item) => (item && typeof item === 'object')
? String(item.description ?? item.name ?? item.text ?? '').trim()
: String(item).trim())
.filter(Boolean)
})
</script>
<style scoped>