diff --git a/backend/app/api/chat_routes.py b/backend/app/api/chat_routes.py index d732fd3..b825e69 100644 --- a/backend/app/api/chat_routes.py +++ b/backend/app/api/chat_routes.py @@ -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")), diff --git a/backend/tests/test_final_judge.py b/backend/tests/test_final_judge.py index 6fa358f..efec2f3 100644 --- a/backend/tests/test_final_judge.py +++ b/backend/tests/test_final_judge.py @@ -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): diff --git a/backend/tests/test_sprint1_review_findings.py b/backend/tests/test_sprint1_review_findings.py index 043896f..7ab3e71 100644 --- a/backend/tests/test_sprint1_review_findings.py +++ b/backend/tests/test_sprint1_review_findings.py @@ -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( { diff --git a/frontend/src/i18n/index.js b/frontend/src/i18n/index.js index 9625754..4736f6d 100644 --- a/frontend/src/i18n/index.js +++ b/frontend/src/i18n/index.js @@ -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: 'ประวัติการฝึกของฉัน', diff --git a/frontend/src/views/Chat.vue b/frontend/src/views/Chat.vue index e2489a8..f1e6a87 100644 --- a/frontend/src/views/Chat.vue +++ b/frontend/src/views/Chat.vue @@ -91,11 +91,10 @@
- {{ i18n.t('reveal') }} -
-
- {{ fieldLabel(k) }} - {{ fmt(v) }} + {{ i18n.t('revealPains') }} +
+
+ {{ pain }}
@@ -108,7 +107,7 @@