bima-backend / app /progress.py
kenrockender's picture
Leaderboard: hide owner's own demo account
74c42cf
Raw
History Blame Contribute Delete
16.2 kB
"""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,
}