Spaces:
Running
Running
File size: 16,191 Bytes
2330921 02dc9bf 2330921 74c42cf 02dc9bf 2330921 02dc9bf 340c40c 2330921 02dc9bf 1523a75 02dc9bf 2330921 340c40c 2330921 02dc9bf c9d771b 02dc9bf 2330921 1523a75 2330921 1523a75 2330921 0727ec3 1523a75 2330921 340c40c c9d771b 340c40c 74c42cf ceff007 fec964e 340c40c fec964e 74c42cf fec964e 340c40c fec964e 340c40c ceff007 340c40c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 | """Progress tracking, gamification, and adaptive coaching.
Everything here is derived from the `attempts` table — a single source of
truth. Streaks, XP, levels, and badges are computed on read so there's no
denormalized state to keep in sync.
FAs are identified by an anonymous per-device id (sent as the X-FA-Id header),
so no login is required — progress simply follows the browser.
"""
import logging
import uuid
from datetime import date, datetime, timedelta, timezone
from typing import Dict, List, Optional
from .config import settings
from .firebase import fs
log = logging.getLogger("bima.progress")
DIMENSIONS = [
"rapport",
"discovery",
"product_knowledge",
"objection_handling",
"closing",
]
# Which persona best stretches each skill. Used by recommend_next() to turn the
# weakest dimension into a concrete "go practice with X" suggestion.
DIMENSION_TO_PERSONA = {
"rapport": "cautious_mom",
"discovery": "skeptical_owner",
"product_knowledge": "young_executive",
"objection_handling": "skeptical_owner",
"closing": "young_executive",
}
XP_PER_LEVEL = 120
# -----------------------------------------------------------------------------
# Write
# -----------------------------------------------------------------------------
def _xp_for(report: Dict) -> int:
"""XP rewards both quality (overall score) and effort (turns taken)."""
overall = int(report.get("overall_score", 0) or 0)
turns = int(report.get("turn_count", 0) or 0)
return overall * 10 + min(turns, 10) * 2
def _attempts_col(fa_id: str):
return fs().collection("users").document(fa_id).collection("attempts")
def _user_doc(fa_id: str):
return fs().collection("users").document(fa_id)
def _update_summary(fa_id: str, profile: Optional[Dict], stats: Dict) -> None:
"""Maintain a denormalized users/{uid} summary so the leaderboard and the
manager dashboard can read one collection instead of scanning every
attempt. Refreshed after each finished session."""
data = {
"uid": fa_id,
"total_sessions": stats["total_sessions"],
"total_xp": stats["total_xp"],
"level": stats["level"],
"averages": stats["averages"],
"last_overall": stats["last_overall"],
"weakest_dimension": stats["weakest_dimension"],
"updated_at": datetime.now(timezone.utc).isoformat(),
}
if profile:
if profile.get("name"):
data["name"] = profile["name"]
if profile.get("email"):
data["email"] = profile["email"]
if profile.get("picture"):
data["picture"] = profile["picture"]
_user_doc(fa_id).set(data, merge=True)
def save_attempt(
fa_id: str,
report: Dict,
transcript: List[Dict],
profile: Optional[Dict] = None,
) -> Dict:
"""Persist a finished session. Returns gamification deltas the UI can
celebrate: xp earned, new streak, and any freshly unlocked badges."""
scores = report.get("scores", {}) or {}
xp = _xp_for(report)
aid = uuid.uuid4().hex
badges_before = {b["id"] for b in _earned_badges(fa_id)}
_attempts_col(fa_id).document(aid).set({
"id": aid,
"persona_id": report.get("persona", {}).get("id", "unknown"),
"persona_name": report.get("persona", {}).get("name", "—"),
"drill_id": report.get("drill_id"),
"module_id": report.get("module_id"),
"focus_dimension": report.get("focus_dimension"),
"rapport": int(scores.get("rapport", 0) or 0),
"discovery": int(scores.get("discovery", 0) or 0),
"product_knowledge": int(scores.get("product_knowledge", 0) or 0),
"objection_handling": int(scores.get("objection_handling", 0) or 0),
"closing": int(scores.get("closing", 0) or 0),
"overall_score": int(report.get("overall_score", 0) or 0),
"strengths": report.get("strengths", []) or [],
"improvements": report.get("improvements", []) or [],
"next_focus": report.get("next_focus", "") or "",
"transcript": transcript,
"turn_count": int(report.get("turn_count", 0) or 0),
"xp_earned": xp,
# ISO 8601 UTC string keeps date parsing and lexical ordering simple.
"created_at": datetime.now(timezone.utc).isoformat(),
})
badges_after = _earned_badges(fa_id)
new_badges = [b for b in badges_after if b["id"] not in badges_before]
stats = get_stats(fa_id)
try:
_update_summary(fa_id, profile, stats)
except Exception as e:
log.warning("failed to update user summary for %s: %s", fa_id, e)
return {
"xp_earned": xp,
"streak": stats["streak"],
"level": stats["level"],
"total_sessions": stats["total_sessions"],
"new_badges": new_badges,
}
# -----------------------------------------------------------------------------
# Read
# -----------------------------------------------------------------------------
def _rows(fa_id: str) -> List[Dict]:
docs = _attempts_col(fa_id).order_by("created_at").stream()
out = []
for doc in (docs or []):
d = doc.to_dict() or {}
d.setdefault("id", doc.id)
out.append(d)
return out
def _attempt_dates(rows: List[Dict]) -> List[date]:
out = []
for r in rows:
try:
out.append(datetime.fromisoformat(r["created_at"]).date())
except Exception:
pass
return out
def _streak(rows: List[Dict]) -> int:
"""Consecutive days (ending today or yesterday) with at least one attempt."""
days = set(_attempt_dates(rows))
if not days:
return 0
today = date.today()
# A streak is still "alive" if the last practice was today or yesterday.
cursor = today if today in days else today - timedelta(days=1)
if cursor not in days:
return 0
streak = 0
while cursor in days:
streak += 1
cursor -= timedelta(days=1)
return streak
def _averages(rows: List[Dict]) -> Dict[str, float]:
if not rows:
return {d: 0.0 for d in DIMENSIONS}
return {
d: round(sum(int(r[d]) for r in rows) / len(rows), 1) for d in DIMENSIONS
}
def _earned_badges(fa_id: str) -> List[Dict]:
rows = _rows(fa_id)
if not rows:
return []
n = len(rows)
streak = _streak(rows)
personas_done = {r["persona_id"] for r in rows}
best = {d: max(int(r[d]) for r in rows) for d in DIMENSIONS}
best_overall = max(int(r["overall_score"]) for r in rows)
# Graduate = every learning-path module mastered. Derived from the same rows.
from . import curriculum
graduated = curriculum.build_path(rows)["completed"]
catalog = [
("first_pitch", "First Pitch", "Completed your first roleplay", n >= 1),
("regular", "Regular", "Completed 10 roleplays", n >= 10),
("veteran", "Veteran", "Completed 25 roleplays", n >= 25),
("streak_3", "On a Roll", "3-day practice streak", streak >= 3),
("streak_7", "Unstoppable", "7-day practice streak", streak >= 7),
("tough_crowd", "Tough Crowd", "Survived Pak Budi", "skeptical_owner" in personas_done),
("high_roller", "High Roller", "Closed Pak Hendra's profile", "legacy_planner" in personas_done),
("closer", "Closer", "Scored 8+ on closing", best["closing"] >= 8),
("listener", "Deep Listener", "Scored 9+ on discovery", best["discovery"] >= 9),
("ace", "Ace", "Scored 9+ overall", best_overall >= 9),
("graduate", "Graduate", "Mastered the full learning path", graduated),
]
return [
{"id": bid, "name": name, "description": desc}
for bid, name, desc, earned in catalog
if earned
]
def get_stats(fa_id: str) -> Dict:
rows = _rows(fa_id)
n = len(rows)
averages = _averages(rows)
total_xp = sum(int(r["xp_earned"]) for r in rows)
level = total_xp // XP_PER_LEVEL + 1
xp_into_level = total_xp - (level - 1) * XP_PER_LEVEL
today = date.today()
done_today = sum(1 for d in _attempt_dates(rows) if d == today)
# Per-dimension trend: most recent 8 scores, for sparklines.
recent = rows[-8:]
trend = {d: [int(r[d]) for r in recent] for d in DIMENSIONS}
weakest = min(DIMENSIONS, key=lambda d: averages[d]) if n else None
strongest = max(DIMENSIONS, key=lambda d: averages[d]) if n else None
return {
"fa_id": fa_id,
"total_sessions": n,
"total_xp": total_xp,
"level": level,
"xp_into_level": xp_into_level,
"xp_per_level": XP_PER_LEVEL,
"streak": _streak(rows),
"daily_goal": 1,
"done_today": done_today,
"averages": averages,
"trend": trend,
"weakest_dimension": weakest,
"strongest_dimension": strongest,
"badges": _earned_badges(fa_id),
"last_overall": int(rows[-1]["overall_score"]) if n else 0,
}
def get_history(fa_id: str, limit: int = 20) -> List[Dict]:
rows = _rows(fa_id)
rows = list(reversed(rows))[:limit]
out = []
for r in rows:
out.append({
"id": r["id"],
"persona_id": r["persona_id"],
"persona_name": r["persona_name"],
"overall_score": int(r["overall_score"]),
"scores": {d: int(r[d]) for d in DIMENSIONS},
"turn_count": int(r["turn_count"]),
"xp_earned": int(r["xp_earned"]),
"next_focus": r["next_focus"],
"created_at": r["created_at"],
})
return out
def get_attempt(fa_id: str, attempt_id: str) -> Optional[Dict]:
"""Return a single attempt with its full transcript."""
doc = _attempts_col(fa_id).document(attempt_id).get()
if not doc.exists:
return None
d = doc.to_dict() or {}
d.setdefault("id", doc.id)
return {
"id": d["id"],
"persona_id": d.get("persona_id", ""),
"persona_name": d.get("persona_name", ""),
"overall_score": int(d.get("overall_score", 0) or 0),
"scores": {dim: int(d.get(dim, 0) or 0) for dim in DIMENSIONS},
"strengths": d.get("strengths", []),
"improvements": d.get("improvements", []),
"next_focus": d.get("next_focus", ""),
"turn_count": int(d.get("turn_count", 0) or 0),
"xp_earned": int(d.get("xp_earned", 0) or 0),
"transcript": d.get("transcript", []),
"created_at": d.get("created_at", ""),
}
def get_curriculum(fa_id: str) -> Dict:
"""Learning-path status for one FA: per-module lock/pass state derived from
their attempts. Imported here to avoid a circular import at module load."""
from . import curriculum
return curriculum.build_path(_rows(fa_id))
def recommend_next(fa_id: str) -> Optional[Dict]:
"""Turn the weakest dimension into a concrete next-session suggestion."""
stats = get_stats(fa_id)
if not stats["total_sessions"]:
return None
dim = stats["weakest_dimension"]
persona_id = DIMENSION_TO_PERSONA.get(dim, "cautious_mom")
return {
"dimension": dim,
"persona_id": persona_id,
"average": stats["averages"].get(dim, 0),
}
# -----------------------------------------------------------------------------
# Team views (leaderboard + manager dashboard) — read the users summary
# collection maintained by _update_summary, so no per-attempt scanning.
# -----------------------------------------------------------------------------
def _users_summaries() -> List[Dict]:
docs = fs().collection("users").stream()
out = []
for doc in (docs or []):
d = doc.to_dict() or {}
d.setdefault("uid", doc.id)
out.append(d)
return out
def _display_name(s: Dict) -> str:
return s.get("name") or (s.get("email") or "").split("@")[0] or "FA"
def _is_hidden(s: Dict) -> bool:
"""True if this FA is on the leaderboard hide-list (matched by display name
or email, case-insensitive). Used to keep the owner's own demo account off
the public ranking without deleting their data."""
hidden = settings.leaderboard_hidden_set()
if not hidden:
return False
return (
_display_name(s).lower() in hidden
or (s.get("email") or "").lower() in hidden
)
# A signature "title" per FA derived from their strongest dimension — turns a
# bare XP ranking into a bit of personality on the leaderboard. Zero extra cost:
# it's computed from the averages already stored in the user summary.
_DIMENSION_TITLE = {
"rapport": "People Person",
"discovery": "Deep Listener",
"product_knowledge": "Product Pro",
"objection_handling": "Objection Master",
"closing": "Closer",
}
def _signature_title(s: Dict) -> Optional[Dict]:
"""The FA's strongest dimension as a badge, once they have enough sessions
to make it meaningful."""
if int(s.get("total_sessions", 0) or 0) < 3:
return None
avgs = s.get("averages", {}) or {}
scored = {d: float(avgs.get(d, 0) or 0) for d in DIMENSIONS}
if not any(scored.values()):
return None
best = max(DIMENSIONS, key=lambda d: scored[d])
return {
"dimension": best,
"label": _DIMENSION_TITLE.get(best, best),
"score": round(scored[best], 1),
}
def _avg_score(s: Dict) -> float:
"""Mean of the five dimension averages — an FA's overall practice quality."""
avgs = s.get("averages", {}) or {}
vals = [float(avgs.get(d, 0) or 0) for d in DIMENSIONS]
return round(sum(vals) / len(vals), 1) if vals else 0.0
def leaderboard(current_uid: str, limit: int = 20) -> Dict:
"""Top FAs by average score (quality), with sessions as a tie-break and
supporting stat. Flags the caller's own row so the UI can highlight it."""
summaries = [
s for s in _users_summaries()
if int(s.get("total_sessions", 0) or 0) > 0 and not _is_hidden(s)
]
summaries.sort(
key=lambda s: (_avg_score(s), int(s.get("total_sessions", 0) or 0)),
reverse=True,
)
entries = []
me = None
for i, s in enumerate(summaries):
row = {
"rank": i + 1,
"uid": s.get("uid"),
"name": _display_name(s),
"picture": s.get("picture", ""),
"avg_score": _avg_score(s),
"total_sessions": int(s.get("total_sessions", 0) or 0),
"title": _signature_title(s),
"is_me": s.get("uid") == current_uid,
}
if row["is_me"]:
me = row
entries.append(row)
return {"entries": entries[:limit], "me": me, "total_players": len(summaries)}
def team_overview() -> Dict:
"""Aggregate progress across all FAs for the manager dashboard."""
summaries = [s for s in _users_summaries() if int(s.get("total_sessions", 0) or 0) > 0]
members = []
dim_totals = {d: 0.0 for d in DIMENSIONS}
sessions_total = 0
for s in summaries:
avgs = s.get("averages", {}) or {}
members.append({
"uid": s.get("uid"),
"name": _display_name(s),
"email": s.get("email", ""),
"picture": s.get("picture", ""),
"total_sessions": int(s.get("total_sessions", 0) or 0),
"level": int(s.get("level", 1) or 1),
"total_xp": int(s.get("total_xp", 0) or 0),
"averages": {d: round(float(avgs.get(d, 0) or 0), 1) for d in DIMENSIONS},
"weakest_dimension": s.get("weakest_dimension"),
"last_overall": int(s.get("last_overall", 0) or 0),
"updated_at": s.get("updated_at", ""),
})
sessions_total += int(s.get("total_sessions", 0) or 0)
for d in DIMENSIONS:
dim_totals[d] += float(avgs.get(d, 0) or 0)
n = len(members)
team_avg = {d: round(dim_totals[d] / n, 1) if n else 0.0 for d in DIMENSIONS}
members.sort(key=lambda m: m["total_xp"], reverse=True)
weakest = min(DIMENSIONS, key=lambda d: team_avg[d]) if n else None
return {
"member_count": n,
"sessions_total": sessions_total,
"team_averages": team_avg,
"team_weakest_dimension": weakest,
"members": members,
}
|