feat(debrief): surface buyer pains as pain insight instead of the persona reveal grid
This commit is contained in:
@@ -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")),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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: 'ประวัติการฝึกของฉัน',
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user