Files
sales-trainer/backend/app/services/trainee.py
Macky 3c22d88bcd feat: demo SaaS + training flow security hardening (8/8 review gate passed)
- Demo accounts: super_admin-only provisioning into isolated DEMO_ORG_ID tenant,
  30-day UTC trial on first login, revocable, one-time credential delivery via
  optional SES/webhook (never persisted). Adds boto3 dependency.
- Analytics/report/export/privacy: shared bounded scan budget across users/groups/
  sessions, tenant-consistent session/user/group joins, scalar-only CSV export
  (no nested persisted-value stringification).
- Ownership/tenant isolation: canonical owner-tenant predicate for list/read/chat;
  client sees is_owned only, never owner_user_id.
- Lifecycle/races: status transition validation, analyzing is an in-progress gate
  (no duplicate reanalysis), structured-ready publication, stale-variant revalidation.
- Auth/setup/consent/JWT/OAuth/config: fail-closed consent, bounded JWT lifetime,
  provider-subject atomic OAuth identity, repeated-secret rejection, strict Persona
  trait validation.
- Chat/session/privacy: pre-seller opener redaction, corrupt-session recovery,
  role-aware completed-chat dashboard routing.
- Frontend: Training→product→personas→practice flow, demo/role/demo guards,
  is_owned-based ownership display, 320×568 and 500×768 responsive E2E.
- 8 independent exact-five-key review scopes passed; backend 509, frontend 26,
  production build 1775 modules, isolated E2E 15.
2026-08-25 06:39:06 +07:00

235 lines
8.6 KiB
Python

"""Trainee loop: win/lose board, weak-area analysis, user-generated personas.
A user never re-chats a persona. To keep training, they generate new personas —
either auto from their weak areas ("lock") or from a manual form. Generated
personas are private to the user.
"""
from __future__ import annotations
import datetime
import math
from collections import Counter
from pathlib import Path
from typing import Any
from ..storage.store import JsonStore, new_id
from .store import ensure_persona_shape
class MyPersonaStore:
def __init__(self, data_dir: Path) -> None:
self.personas = JsonStore(data_dir / "my_personas")
def _path_key(self, user_id: str, pid: str) -> str:
return f"{user_id}__{pid}"
def create(self, *, user_id: str, persona: dict[str, Any]) -> dict[str, Any]:
p = ensure_persona_shape(persona)
if "id" not in p or not p["id"]:
p["id"] = new_id("myp")
record = {
"key": self._path_key(user_id, p["id"]),
"user_id": user_id,
"persona": p,
"created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
}
return self.personas.create(record, key=record["key"])
def list_for(self, user_id: str) -> list[dict[str, Any]]:
return [
r.get("persona")
for r in self.personas.where(lambda x: x.get("user_id") == user_id)
]
_WEAK_DIMENSIONS = {
"pain_discovery": (
"pain discovery",
("pain", "need", "discov", "question", "qualif", "ask deeper", "ความต้องการ", "ปัญหา"),
),
"objection_handling": (
"objection handling",
("objection", "concern", "expensive", "price objection", "ข้อโต้แย้ง", "กังวล", "แพง"),
),
"negotiation": (
"negotiation",
("negotiat", "discount", "budget", "payment", "ต่อรอง", "ส่วนลด", "งบประมาณ"),
),
"trust_building": (
"trust building",
("trust", "proof", "credib", "relationship", "ความน่าเชื่อถือ", "ความไว้ใจ"),
),
"close_attempt": (
"close attempt",
("clos", "commit", "next step", "order", "ปิดการขาย", "ตัดสินใจ", "ขั้นตอนถัดไป"),
),
}
def _is_finite_number(value: object) -> bool:
if isinstance(value, bool) or not isinstance(value, (int, float)):
return False
try:
return math.isfinite(value)
except (OverflowError, TypeError, ValueError):
return False
def _finished_trainee_sessions(sessions: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Only completed non-preview sessions are evidence for a trainee."""
return [
session
for session in sessions
if isinstance(session, dict)
if (session.get("mode") is None or session.get("mode") == "trainee")
and session.get("status") == "finished"
and session.get("outcome") in ("won", "lost")
]
def _session_evidence(session: dict[str, Any]) -> str:
raw_debrief = session.get("debrief")
debrief: dict[str, Any] = raw_debrief if isinstance(raw_debrief, dict) else {}
pieces: list[str] = []
for field in ("failurePoints", "coaching"):
values = debrief.get(field)
if isinstance(values, list):
pieces.extend(str(value) for value in values if isinstance(value, str))
why = debrief.get("why")
if isinstance(why, str):
pieces.append(why)
progress = debrief.get("painProgress")
if isinstance(progress, dict):
pieces.extend(
f"pain progress {key} {value}"
for key, value in progress.items()
if (
isinstance(key, str)
and _is_finite_number(value)
and value < 100
)
)
return " ".join(pieces).lower()
def _dimension_evidence(sessions: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
dimensions = {
key: {"label": label, "evidence_count": 0, "session_ids": []}
for key, (label, _patterns) in _WEAK_DIMENSIONS.items()
}
for session in sessions:
evidence = _session_evidence(session)
if not evidence:
continue
for key, (_label, patterns) in _WEAK_DIMENSIONS.items():
if any(pattern in evidence for pattern in patterns):
dimensions[key]["evidence_count"] += 1
sid = session.get("id")
if isinstance(sid, str) and sid not in dimensions[key]["session_ids"]:
dimensions[key]["session_ids"].append(sid)
return dimensions
def _completed_dimensions(sessions: list[dict[str, Any]]) -> dict[str, Any]:
"""Shared weak-area computation over an already-finished/scoped session list."""
completed = _finished_trainee_sessions(sessions)
losses = [session for session in completed if session.get("outcome") == "lost"]
wins = [session for session in completed if session.get("outcome") == "won"]
def safe_score(value: object) -> int | float:
if isinstance(value, bool) or not isinstance(value, (int, float)):
return 0
try:
if not math.isfinite(value):
return 0
return max(0, min(100, value))
except (OverflowError, TypeError, ValueError):
return 0
def tally(key: str) -> list[dict[str, Any]]:
counter = Counter()
for loss in losses:
raw_meta = loss.get("persona_meta")
meta = raw_meta if isinstance(raw_meta, dict) else {}
value = meta.get(key)
if isinstance(value, str):
value = value.strip() or None
elif isinstance(value, bool):
value = str(value)
elif isinstance(value, int):
pass
elif isinstance(value, float):
value = value if math.isfinite(value) else None
else:
value = None
if value is not None:
counter[value] += 1
return [{"value": value, "losses": count} for value, count in counter.most_common(3)]
top_loss_personas = []
for loss in sorted(
losses,
key=lambda row: (
safe_score((row.get("debrief") or {}).get("score", 0))
if isinstance(row.get("debrief"), dict) else 0
),
)[:5]:
raw_debrief = loss.get("debrief")
debrief: dict[str, Any] = raw_debrief if isinstance(raw_debrief, dict) else {}
top_loss_personas.append({
"persona_id": loss.get("persona_id"),
"persona_name": loss.get("persona_name"),
"score": safe_score(debrief.get("score", 0)),
})
return {
"total_sessions": len(completed),
"wins": len(wins),
"losses": len(losses),
"by_tier": tally("tier"),
"by_initiation": tally("initiation_mode"),
"by_channel": tally("channel"),
"dimensions": _dimension_evidence(completed),
"top_loss_personas": top_loss_personas,
}
def analyze_weak_areas(
sessions: list[dict[str, Any]],
*,
user_id: str,
org_id: str,
) -> dict[str, Any]:
"""Summarize judge evidence after enforcing the caller's tenant/user scope."""
if not isinstance(user_id, str) or not user_id.strip():
raise ValueError("user scope is invalid")
if not isinstance(org_id, str) or not org_id.strip() or org_id != org_id.strip():
raise ValueError("organization scope is invalid")
scoped_sessions = [
session
for session in sessions
if isinstance(session, dict)
and session.get("user_id") == user_id
and session.get("org_id") == org_id
]
return _completed_dimensions(scoped_sessions)
def analyze_team_weak_areas(
sessions: list[dict[str, Any]],
*,
org_id: str,
) -> dict[str, Any]:
"""Team-wide weak-area summary across every org trainee's finished sessions.
Reuses the same dimension analysis as the per-user path (B4). The ``sessions``
argument is expected to already be the caller's fully scoped set (finished
trainee sessions for the relevant tenant/window); ``org_id`` is validated for
tenant hygiene but the given sessions are analyzed as-is.
"""
if not isinstance(org_id, str) or not org_id.strip() or org_id != org_id.strip():
raise ValueError("organization scope is invalid")
# Analyze the caller-provided (already tenant-scoped) sessions as-is. The
# super_admin team view spans orgs, so no additional per-org filter here.
return _completed_dimensions(sessions)