[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:
@@ -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", ""),
|
||||
},
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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: 'ลูกค้าจะติดต่อเข้ามาก่อน',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user