- 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.
235 lines
8.6 KiB
Python
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)
|