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,
    }