Spaces:
Running
Running
| """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, | |
| } | |