[verified] IP-protect pain (hide from all roles) + persona summary button + topbar user dropdown

- Backend: strip pain/painProgress/revealed_persona.pains from debrief
  serializer and remove pain/initialPainFit from admin report so the
  coaching formula never leaks to any user-facing role (persona keeps
  pains internally to drive the judge/training)
- Fix [object Object] array-of-objects rendering in Chat/SessionDetail
- Personas: trained persona shows 'สรุปผล/Summary' button -> chat page
  with past result (chat already loads finished session)
- Top bar: username dropdown containing Settings + Logout (was separate
  logout button); variant-from-base already preserves tier/difficulty
- Updated debrief allowlist tests to reflect new redaction
This commit is contained in:
Macky
2026-08-19 07:26:21 +07:00
parent 711e058b24
commit ab5fff0bd9
9 changed files with 82 additions and 46 deletions

View File

@@ -108,30 +108,19 @@ def _safe_judge_debrief(verdict: object, outcome: str, persona: dict) -> dict:
score = max(0, min(100, int(raw.get("score", 0))))
except (TypeError, ValueError):
score = 0
progress = {}
raw_progress = raw.get("painProgress")
if isinstance(raw_progress, dict):
for key, value in list(raw_progress.items())[:20]:
if not isinstance(key, str):
continue
try:
progress[key[:100]] = max(0, min(100, int(value)))
except (TypeError, ValueError):
continue
return {
"outcome": outcome,
"score": score,
"pain": _bounded_text(raw.get("pain")),
# 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.
"why": _bounded_text(raw.get("why")),
"failurePoints": _string_list(raw.get("failurePoints")),
"coaching": _string_list(raw.get("coaching")),
"painProgress": progress,
"revealed_persona": {
"pains": persona.get("pains", []),
"income": persona.get("income", ""),
"personality": persona.get("personality", ""),
"budget": persona.get("budget", ""),
"negotiation_levers": persona.get("negotiation_levers", []),
"opener": persona.get("opener", ""),
"background": persona.get("background", ""),
},

View File

@@ -51,10 +51,7 @@ def _render_sales_kit(kit: dict[str, Any], thai: bool) -> str:
ta = kit.get("targetAudience") or {}
if ta.get("segment"):
lines.append(f"**{'กลุ่มเป้าหมาย' if thai else 'Target segment'}:** {ta['segment']}")
if kit.get("initialPainFit"):
lines.append(f"**{'Pain ที่สินค้าแก้ได้เบื้องต้น' if thai else 'Initial pain-fit'}:**")
for p in kit["initialPainFit"]:
lines.append(f"- ({p.get('fit', '?')}) {p.get('pain', '')}")
# IP protection: hide pain-fit details from the admin report (the "formula").
return "\n".join(lines)
@@ -79,13 +76,6 @@ def _render_persona(p: dict[str, Any], thai: bool) -> str:
lines.append(f"- {'รายได้' if thai else 'Income'}: {p.get('income', '-')} | "
f"{'ไลฟ์สไตล์' if thai else 'Lifestyle'}: {p.get('lifestyle', '-')}")
lines.append(f"- {'นิสัย' if thai else 'Personality'}: {p.get('personality', '-')}")
if p.get("pains"):
lines.append(f"- {'Pain points (latent)' if thai else 'Pains (latent)'}:")
for pain in p.get("pains", []):
conds = "; ".join(pain.get("resolutionConditions", [])) if isinstance(pain, dict) else ""
lines.append(f" - [{pain.get('fit', '?') if isinstance(pain, dict) else '?'}] "
f"{pain.get('name', pain) if isinstance(pain, dict) else pain}"
f"{' — resolve: ' + conds if conds else ''}")
if p.get("negotiation_levers"):
levers = p.get("negotiation_levers") or []
lines.append(f"- {'ต่อรอง' if thai else 'Negotiation levers'}: " + ", ".join(str(x) for x in levers))

View File

@@ -81,9 +81,11 @@ def test_final_judge_supplies_score_and_debrief_for_automatic_buy(monkeypatch):
assert updated["status"] == "finished"
assert updated["outcome"] == "won"
assert debrief["score"] == 88
assert debrief["pain"] == "qualified pain"
assert debrief["coaching"] == ["Keep the close concise"]
assert debrief["revealed_persona"]["pains"]
# IP protection: pain is hidden from the debrief (prose + raw list).
assert "pain" not in debrief
assert "painProgress" not in debrief
assert "pains" not in debrief["revealed_persona"]
assert sessions.updates[0]["messages"]

View File

@@ -148,12 +148,14 @@ def test_judge_debrief_uses_closed_allowlist():
)
assert set(debrief) == {
"outcome", "score", "pain", "why", "failurePoints", "coaching",
"painProgress", "revealed_persona",
"outcome", "score", "why", "failurePoints", "coaching", "revealed_persona",
}
assert debrief["score"] == 100
assert debrief["failurePoints"] == ["missed discovery"]
assert debrief["painProgress"] == {"main": 100}
# IP protection: pain (prose + list) and painProgress are stripped from the debrief.
assert "pain" not in debrief
assert "painProgress" not in debrief
assert "pains" not in debrief["revealed_persona"]
assert "password_hash" not in str(debrief)
assert "provider_path" not in str(debrief)

View File

@@ -15,10 +15,19 @@
</div>
<div class="nav-right">
<button @click="toggleLang" class="lang">{{ i18n.locale === 'th' ? 'EN' : 'TH' }}</button>
<router-link to="/settings" class="icon-btn" :title="i18n.t('settings')">
<SettingsIcon :size="20" :stroke-width="1.8" />
</router-link>
<button @click="logout" class="logout-btn"><LogOut :size="18" :stroke-width="1.8" /> <span class="txt">{{ i18n.t('logout') }}</span></button>
<div class="user-menu" ref="menuRef">
<button @click="menuOpen = !menuOpen" class="user-btn" :class="{ open: menuOpen }">
<UserCircle :size="20" :stroke-width="1.8" />
<span class="txt">{{ auth.user.username || auth.user.email || auth.user.name || auth.user.id }}</span>
<ChevronDown :size="14" :stroke-width="2" class="chev" :class="{ up: menuOpen }" />
</button>
<div v-if="menuOpen" class="dropdown">
<router-link to="/settings" class="item" @click="menuOpen = false">
<SettingsIcon :size="16" :stroke-width="1.8" /> {{ i18n.t('settings') }}
</router-link>
<button class="item" @click="logout"><LogOut :size="16" :stroke-width="1.8" /> {{ i18n.t('logout') }}</button>
</div>
</div>
</div>
</nav>
<main class="main">
@@ -29,24 +38,32 @@
</template>
<script setup>
import { onMounted } from 'vue'
import { onMounted, onBeforeUnmount, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Settings as SettingsIcon, LogOut } from 'lucide-vue-next'
import { Settings as SettingsIcon, LogOut, UserCircle, ChevronDown } from 'lucide-vue-next'
import { auth } from './store/auth'
import { i18n } from './i18n'
const router = useRouter()
const menuOpen = ref(false)
const menuRef = ref(null)
function toggleLang() {
i18n.set(i18n.locale === 'th' ? 'en' : 'th')
}
function logout() {
menuOpen.value = false
auth.logout()
router.push('/login')
}
function onDocClick(e) {
if (menuRef.value && !menuRef.value.contains(e.target)) menuOpen.value = false
}
onMounted(() => {
document.addEventListener('click', onDocClick)
if (auth.token && !auth.user) auth.load()
})
onBeforeUnmount(() => document.removeEventListener('click', onDocClick))
</script>
<style scoped>
@@ -77,8 +94,28 @@ onMounted(() => {
.tab:hover { background: #f1f3f9; color: var(--ink); }
.tab.active { background: linear-gradient(135deg, var(--accent), var(--accent-2)); color: #fff; }
.nav-right { display: flex; align-items: center; gap: 8px; }
.lang, .logout-btn { padding: 8px 12px; }
.logout-btn { display: inline-flex; align-items: center; gap: 6px; }
.lang { padding: 8px 12px; }
.user-menu { position: relative; }
.user-btn {
display: inline-flex; align-items: center; gap: 7px;
padding: 8px 12px; border-radius: 8px; font-weight: 600; color: var(--ink);
}
.user-btn:hover, .user-btn.open { background: #f1f3f9; }
.chev { transition: transform .15s ease; }
.chev.up { transform: rotate(180deg); }
.dropdown {
position: absolute; right: 0; top: calc(100% + 6px); z-index: 30;
background: #fff; border: 1px solid var(--border); border-radius: 10px;
box-shadow: 0 8px 24px rgba(15,23,42,.10); min-width: 190px; overflow: hidden;
}
.dropdown .item {
display: flex; align-items: center; gap: 8px; width: 100%;
padding: 10px 14px; border-radius: 0; background: transparent; color: var(--ink);
text-align: left; font-size: 14px;
}
.dropdown .item:hover { background: #f1f3f9; }
.dropdown .item + .item { border-top: 1px solid var(--border); }
.dropdown a.item { text-decoration: none; }
.icon-btn { text-decoration: none; display: inline-flex; align-items: center; padding: 6px 10px; border-radius: 8px; color: var(--ink); }
.icon-btn:hover { background: #f1f3f9; }
.main { max-width: 1080px; margin: 0 auto; padding: 24px; }
@@ -89,8 +126,8 @@ onMounted(() => {
.tab { padding: 6px 10px; font-size: 12px; flex: 1; text-align: center; }
.nav-right { margin-left: auto; }
.lang { padding: 6px 8px; font-size: 12px; }
.logout-btn { padding: 6px 8px; }
.logout-btn .txt { display: none; }
.user-btn { padding: 6px 8px; }
.user-btn .txt { max-width: 90px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
}
@media (max-width: 380px) {
.brand { display: none; }

View File

@@ -172,6 +172,7 @@ const messages = {
inProgress: 'In progress',
resume: 'Resume',
viewDetails: 'View details',
personaSummary: 'Summary',
openSaleTask: 'The customer did NOT message first. You must open the sale.',
sellerInitiated: 'You must open the sale (outbound)',
customerInitiated: 'The customer will message you first',
@@ -346,6 +347,7 @@ const messages = {
inProgress: 'กำลังฝึก',
resume: 'ฝึกต่อ',
viewDetails: 'ดูรายละเอียด',
personaSummary: 'สรุปผล',
openSaleTask: 'ลูกค้ายังไม่ได้ทักเข้ามา คุณต้องเป็นฝ่ายเริ่มบทสนทนาการขายเอง',
sellerInitiated: 'คุณต้องเริ่มการขายในเชิงรุก',
customerInitiated: 'ลูกค้าจะติดต่อเข้ามาก่อน',

View File

@@ -67,7 +67,6 @@
<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>
@@ -201,14 +200,26 @@ const FIELD_LABELS = {
age_group: 'ช่วงอายุ', location: 'พื้นที่', income: 'รายได้', budget: 'งบประมาณ',
lifestyle: 'ไลฟ์สไตล์', background: 'ภูมิหลัง', personality: 'บุคลิก',
communication_style: 'สไตล์การสื่อสาร', goal: 'เป้าหมาย', decision_timeline: 'กรอบตัดสินใจ',
pains: 'ปัญหา (Pain)', objections: 'ข้อโต้แย้ง', negotiation_levers: 'สิ่งที่ใช้ต่อรอง',
opener: 'บทเปิดบทสนทนา', channel: 'ช่องทาง', initiation_mode: 'ใครเริ่มก่อน', product_context: 'บริบทสินค้า',
}
function fieldLabel(k) {
return FIELD_LABELS[k] || k
}
function fmt(v) {
if (Array.isArray(v)) return v.join(', ')
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)
}

View File

@@ -50,6 +50,10 @@
</router-link>
<template v-else>
<div class="muted trained" style="margin-top:auto;font-size:12px"><Check :size="14" :stroke-width="1.8" /> {{ i18n.t('trained') }} ({{ outcomeLabel(p.my_outcome) }})</div>
<!-- Trained persona: open the chat page to view the past conversation + result -->
<router-link :to="`/groups/${gid}/chat/${p.id}`" style="margin-top:8px">
<button class="primary" style="width:100%">{{ i18n.t('personaSummary') }}</button>
</router-link>
<button class="soft" style="width:100%;margin-top:8px" @click="makeVariant(p)" :disabled="p._busy">
{{ p._busy ? i18n.t('creating') : i18n.t('createVariant') }}
</button>

View File

@@ -26,7 +26,6 @@
<h3 style="margin-top:0">{{ i18n.t('debrief') }}</h3>
<p><span class="badge" :class="session.debrief.outcome">{{ outcomeLabel(session.debrief.outcome) }}</span>
{{ i18n.t('score') }}: <strong>{{ session.debrief.score }}</strong></p>
<p><strong>{{ i18n.t('pain') }}:</strong> {{ session.debrief.pain || '—' }}</p>
<p><strong>{{ i18n.t('why') }}:</strong> {{ session.debrief.why || '—' }}</p>
<div v-if="(session.debrief.failurePoints || []).length">
<strong>{{ i18n.t('failurePoints') }}</strong>
@@ -87,8 +86,8 @@ function outcomeLabel(outcome) {
}
function fieldLabel(key) {
const labels = {
pains: 'ปัญหา (Pain)', income: 'รายได้', personality: 'บุคลิก', budget: 'งบประมาณ',
negotiation_levers: 'สิ่งที่ใช้ต่อรอง', opener: 'บทเปิดบทสนทนา', background: 'ภูมิหลัง',
income: 'รายได้', personality: 'บุคลิก', budget: 'งบประมาณ',
opener: 'บทเปิดบทสนทนา', background: 'ภูมิหลัง',
}
return labels[key] || key
}