diff --git a/app/engine/stats/__init__.py b/app/engine/stats/__init__.py index 82552da..9a3fea2 100644 --- a/app/engine/stats/__init__.py +++ b/app/engine/stats/__init__.py @@ -9,12 +9,11 @@ Honest sources (SCOUT_UI_SPEC): Anything without a real source is omitted — the UI cuts or locks it, never fakes it. """ - from __future__ import annotations import asyncio -import math import logging +import math from datetime import datetime, timezone from app.db import repo as _repo @@ -32,9 +31,7 @@ def _match_scores(opps: list[dict]) -> list[int]: return out -def _histogram( - scores: list[int], lo: int = 60, hi: int = 100, bins: int = 11 -) -> list[int]: +def _histogram(scores: list[int], lo: int = 60, hi: int = 100, bins: int = 11) -> list[int]: h = [0] * bins span = (hi - lo) / bins for s in scores: @@ -61,14 +58,9 @@ def _apply_window(opps: list[dict]) -> dict: return {"median_age_days": None, "fresh_share": None, "urgency": None} ages.sort() median = ages[len(ages) // 2] - fresh = sum(1 for a in ages if a <= 3) / len(ages) # posted within 3 days + fresh = sum(1 for a in ages if a <= 3) / len(ages) # posted within 3 days urgency = "high" if median <= 3 else "medium" if median <= 7 else "low" - return { - "median_age_days": median, - "fresh_share": round(fresh, 2), - "urgency": urgency, - "dated": len(ages), - } + return {"median_age_days": median, "fresh_share": round(fresh, 2), "urgency": urgency, "dated": len(ages)} async def build_scout_stats(user_id: str, *, user_uuid: str | None = None) -> dict: @@ -112,34 +104,22 @@ async def build_scout_stats(user_id: str, *, user_uuid: str | None = None) -> di # salary band = average of each deck's peak salary, accumulated across decks (₹L) "salaryMaxL": salary_band, "avgApplicants": round(sum(apps) / len(apps)) if apps else None, - "activeWindow": active - if active.get("samples") - else None, # WHEN the user works their search + "activeWindow": active if active.get("samples") else None, # WHEN the user works their search # ── activity-derived (real, this DB) — funnel is all-time so the stages are consistent ── "funnel": [ - { - "label": "Matches", - "value": max(act["matches"], matches), - "tracked": True, - }, + {"label": "Matches", "value": max(act["matches"], matches), "tracked": True}, {"label": "Viewed", "value": act["viewed"], "tracked": True}, {"label": "Shortlisted", "value": act["saved"], "tracked": True}, {"label": "Applied", "value": act["applied"], "tracked": True}, ], "tailoredResumes": act["tailored"], "searches": act["searches"], - "percentile": act[ - "percentile" - ], # cohort engagement percentile (top 100-percentile %) + "percentile": act["percentile"], # cohort engagement percentile (top 100-percentile %) "cohortSize": act["cohort_size"], - "searchStage": "Applying" - if act["applied"] - else "Reviewing" - if act["saved"] - else "Scanning", + "searchStage": "Applying" if act["applied"] else "Reviewing" if act["saved"] else "Scanning", # ── cross-service (real; None → the UI omits the card, never fakes it) ── "qxNow": (qx or {}).get("qx"), "quotients": (qx or {}).get("quotients"), - "qxTrend": qx_trend, # RQ Score series (qscore-service) → Readiness-trend sparkline + "qxTrend": qx_trend, # RQ Score series (qscore-service) → Readiness-trend sparkline "dayStreak": streak, } diff --git a/tests/test_stats.py b/tests/test_stats.py index ea42719..f98e15d 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -1,5 +1,4 @@ """Dashboard stats — pure-helper and RQ Score client contract tests.""" - import asyncio import httpx @@ -11,71 +10,34 @@ from app.db.repo import _activity_score def test_posted_date_per_board(): - assert ( - _posted_date({"createdDate": "2026-06-18T10:37:17Z"}) == "2026-06-18T10:37:17Z" - ) # Naukri - assert ( - _posted_date({"postedDate": "2026-06-18T12:46:39Z"}) == "2026-06-18T12:46:39Z" - ) # LinkedIn - assert ( - _posted_date({"date_posted": "2026-06-17T17:38:14Z"}) == "2026-06-17T17:38:14Z" - ) # Foundit - assert ( - _posted_date({"jobDetails": {"createdDate": "2026-06-18T00:00:00Z"}}) - is not None - ) # nested + assert _posted_date({"createdDate": "2026-06-18T10:37:17Z"}) == "2026-06-18T10:37:17Z" # Naukri + assert _posted_date({"postedDate": "2026-06-18T12:46:39Z"}) == "2026-06-18T12:46:39Z" # LinkedIn + assert _posted_date({"date_posted": "2026-06-17T17:38:14Z"}) == "2026-06-17T17:38:14Z" # Foundit + assert _posted_date({"jobDetails": {"createdDate": "2026-06-18T00:00:00Z"}}) is not None # nested assert _posted_date({"nope": "x"}) is None def test_match_scores_and_histogram(): opps = [{"match": {"score": 84}}, {"matchScore": 72}, {"match": {"score": 0}}, {}] scores = S._match_scores(opps) - assert sorted(scores) == [72, 84] # 0 and missing dropped + assert sorted(scores) == [72, 84] # 0 and missing dropped h = S._histogram(scores) assert sum(h) == 2 and len(h) == 11 def test_scout_stats_accepts_numeric_applicant_strings(monkeypatch): - feed = { - "opportunities": [ - {"applicants": "10"}, - {"applicants": 30}, - {"applicants": "unknown"}, - ] - } + feed = {"opportunities": [{"applicants": "10"}, {"applicants": 30}, {"applicants": "unknown"}]} activity = { - "matches": 3, - "viewed": 0, - "saved": 0, - "applied": 0, - "tailored": 0, - "searches": 1, - "percentile": None, - "cohort_size": 1, + "matches": 3, "viewed": 0, "saved": 0, "applied": 0, "tailored": 0, + "searches": 1, "percentile": None, "cohort_size": 1, } - monkeypatch.setattr( - S._repo, "get_feed", lambda _user_id: asyncio.sleep(0, result=feed) - ) - monkeypatch.setattr( - S._repo, - "get_activity_stats", - lambda _user_id: asyncio.sleep(0, result=activity), - ) - monkeypatch.setattr( - S._repo, "get_active_window", lambda _user_id: asyncio.sleep(0, result={}) - ) - monkeypatch.setattr( - S._repo, "get_salary_band", lambda _user_id: asyncio.sleep(0, result=None) - ) - monkeypatch.setattr( - S._clients, "fetch_qx", lambda _user_uuid: asyncio.sleep(0, result=None) - ) - monkeypatch.setattr( - S._clients, "fetch_qx_trend", lambda _user_uuid: asyncio.sleep(0, result=None) - ) - monkeypatch.setattr( - S._clients, "fetch_streak", lambda _user_id: asyncio.sleep(0, result=None) - ) + monkeypatch.setattr(S._repo, "get_feed", lambda _user_id: asyncio.sleep(0, result=feed)) + monkeypatch.setattr(S._repo, "get_activity_stats", lambda _user_id: asyncio.sleep(0, result=activity)) + monkeypatch.setattr(S._repo, "get_active_window", lambda _user_id: asyncio.sleep(0, result={})) + monkeypatch.setattr(S._repo, "get_salary_band", lambda _user_id: asyncio.sleep(0, result=None)) + monkeypatch.setattr(S._clients, "fetch_qx", lambda _user_uuid: asyncio.sleep(0, result=None)) + monkeypatch.setattr(S._clients, "fetch_qx_trend", lambda _user_uuid: asyncio.sleep(0, result=None)) + monkeypatch.setattr(S._clients, "fetch_streak", lambda _user_id: asyncio.sleep(0, result=None)) result = asyncio.run(S.build_scout_stats("user-1")) @@ -85,26 +47,13 @@ def test_scout_stats_accepts_numeric_applicant_strings(monkeypatch): def test_apply_window_freshness(): # all None when no posting dates (honest — never fabricated) assert S._apply_window([{}, {"posted_date": None}])["urgency"] is None - w = S._apply_window( - [ - {"posted_date": "2026-06-19T00:00:00Z"}, - {"posted_date": "2026-06-15T00:00:00Z"}, - ] - ) - assert ( - w["dated"] == 2 - and w["median_age_days"] is not None - and w["urgency"] in ("high", "medium", "low") - ) + w = S._apply_window([{"posted_date": "2026-06-19T00:00:00Z"}, {"posted_date": "2026-06-15T00:00:00Z"}]) + assert w["dated"] == 2 and w["median_age_days"] is not None and w["urgency"] in ("high", "medium", "low") def test_activity_score_weighting(): # applies weighted highest, then saves, views, searches - assert ( - _activity_score(0, 0, 1, 0) - > _activity_score(0, 1, 0, 0) - > _activity_score(1, 0, 0, 0) - ) + assert _activity_score(0, 0, 1, 0) > _activity_score(0, 1, 0, 0) > _activity_score(1, 0, 0, 0) assert _activity_score(0, 0, 0, 0) == 0 @@ -153,9 +102,7 @@ def test_fetch_qx_treats_malformed_payload_as_absent(monkeypatch): def test_fetch_qx_trend_ignores_malformed_and_absent_points(monkeypatch): async def _get(_self, _url, **_kwargs): - return _Response( - {"points": [None, {"rq_score": "bad"}, {"rq_score": 35}, {"rq_score": 40}]} - ) + return _Response({"points": [None, {"rq_score": "bad"}, {"rq_score": 35}, {"rq_score": 40}]}) monkeypatch.setattr(httpx.AsyncClient, "get", _get) @@ -164,15 +111,13 @@ def test_fetch_qx_trend_ignores_malformed_and_absent_points(monkeypatch): def test_fetch_qx_trend_reads_strict_rq_score_points(monkeypatch): async def _get(_self, _url, **_kwargs): - return _Response( - { - "points": [ - {"rq_score": 35, "q_score": 91}, - {"rq_score": 44.16, "q_score": 92}, - {"q_score": 93}, - ] - } - ) + return _Response({ + "points": [ + {"rq_score": 35, "q_score": 91}, + {"rq_score": 44.16, "q_score": 92}, + {"q_score": 93}, + ] + }) monkeypatch.setattr(httpx.AsyncClient, "get", _get)