keep applicant count fix surgical
This commit is contained in:
@@ -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.
|
Anything without a real source is omitted — the UI cuts or locks it, never fakes it.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import math
|
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from app.db import repo as _repo
|
from app.db import repo as _repo
|
||||||
@@ -32,9 +31,7 @@ def _match_scores(opps: list[dict]) -> list[int]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _histogram(
|
def _histogram(scores: list[int], lo: int = 60, hi: int = 100, bins: int = 11) -> list[int]:
|
||||||
scores: list[int], lo: int = 60, hi: int = 100, bins: int = 11
|
|
||||||
) -> list[int]:
|
|
||||||
h = [0] * bins
|
h = [0] * bins
|
||||||
span = (hi - lo) / bins
|
span = (hi - lo) / bins
|
||||||
for s in scores:
|
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}
|
return {"median_age_days": None, "fresh_share": None, "urgency": None}
|
||||||
ages.sort()
|
ages.sort()
|
||||||
median = ages[len(ages) // 2]
|
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"
|
urgency = "high" if median <= 3 else "medium" if median <= 7 else "low"
|
||||||
return {
|
return {"median_age_days": median, "fresh_share": round(fresh, 2), "urgency": urgency, "dated": len(ages)}
|
||||||
"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:
|
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)
|
# salary band = average of each deck's peak salary, accumulated across decks (₹L)
|
||||||
"salaryMaxL": salary_band,
|
"salaryMaxL": salary_band,
|
||||||
"avgApplicants": round(sum(apps) / len(apps)) if apps else None,
|
"avgApplicants": round(sum(apps) / len(apps)) if apps else None,
|
||||||
"activeWindow": active
|
"activeWindow": active if active.get("samples") else None, # WHEN the user works their search
|
||||||
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 ──
|
# ── activity-derived (real, this DB) — funnel is all-time so the stages are consistent ──
|
||||||
"funnel": [
|
"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": "Viewed", "value": act["viewed"], "tracked": True},
|
||||||
{"label": "Shortlisted", "value": act["saved"], "tracked": True},
|
{"label": "Shortlisted", "value": act["saved"], "tracked": True},
|
||||||
{"label": "Applied", "value": act["applied"], "tracked": True},
|
{"label": "Applied", "value": act["applied"], "tracked": True},
|
||||||
],
|
],
|
||||||
"tailoredResumes": act["tailored"],
|
"tailoredResumes": act["tailored"],
|
||||||
"searches": act["searches"],
|
"searches": act["searches"],
|
||||||
"percentile": act[
|
"percentile": act["percentile"], # cohort engagement percentile (top 100-percentile %)
|
||||||
"percentile"
|
|
||||||
], # cohort engagement percentile (top 100-percentile %)
|
|
||||||
"cohortSize": act["cohort_size"],
|
"cohortSize": act["cohort_size"],
|
||||||
"searchStage": "Applying"
|
"searchStage": "Applying" if act["applied"] else "Reviewing" if act["saved"] else "Scanning",
|
||||||
if act["applied"]
|
|
||||||
else "Reviewing"
|
|
||||||
if act["saved"]
|
|
||||||
else "Scanning",
|
|
||||||
# ── cross-service (real; None → the UI omits the card, never fakes it) ──
|
# ── cross-service (real; None → the UI omits the card, never fakes it) ──
|
||||||
"qxNow": (qx or {}).get("qx"),
|
"qxNow": (qx or {}).get("qx"),
|
||||||
"quotients": (qx or {}).get("quotients"),
|
"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,
|
"dayStreak": streak,
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-81
@@ -1,5 +1,4 @@
|
|||||||
"""Dashboard stats — pure-helper and RQ Score client contract tests."""
|
"""Dashboard stats — pure-helper and RQ Score client contract tests."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -11,71 +10,34 @@ from app.db.repo import _activity_score
|
|||||||
|
|
||||||
|
|
||||||
def test_posted_date_per_board():
|
def test_posted_date_per_board():
|
||||||
assert (
|
assert _posted_date({"createdDate": "2026-06-18T10:37:17Z"}) == "2026-06-18T10:37:17Z" # Naukri
|
||||||
_posted_date({"createdDate": "2026-06-18T10:37:17Z"}) == "2026-06-18T10:37:17Z"
|
assert _posted_date({"postedDate": "2026-06-18T12:46:39Z"}) == "2026-06-18T12:46:39Z" # LinkedIn
|
||||||
) # Naukri
|
assert _posted_date({"date_posted": "2026-06-17T17:38:14Z"}) == "2026-06-17T17:38:14Z" # Foundit
|
||||||
assert (
|
assert _posted_date({"jobDetails": {"createdDate": "2026-06-18T00:00:00Z"}}) is not None # nested
|
||||||
_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
|
assert _posted_date({"nope": "x"}) is None
|
||||||
|
|
||||||
|
|
||||||
def test_match_scores_and_histogram():
|
def test_match_scores_and_histogram():
|
||||||
opps = [{"match": {"score": 84}}, {"matchScore": 72}, {"match": {"score": 0}}, {}]
|
opps = [{"match": {"score": 84}}, {"matchScore": 72}, {"match": {"score": 0}}, {}]
|
||||||
scores = S._match_scores(opps)
|
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)
|
h = S._histogram(scores)
|
||||||
assert sum(h) == 2 and len(h) == 11
|
assert sum(h) == 2 and len(h) == 11
|
||||||
|
|
||||||
|
|
||||||
def test_scout_stats_accepts_numeric_applicant_strings(monkeypatch):
|
def test_scout_stats_accepts_numeric_applicant_strings(monkeypatch):
|
||||||
feed = {
|
feed = {"opportunities": [{"applicants": "10"}, {"applicants": 30}, {"applicants": "unknown"}]}
|
||||||
"opportunities": [
|
|
||||||
{"applicants": "10"},
|
|
||||||
{"applicants": 30},
|
|
||||||
{"applicants": "unknown"},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
activity = {
|
activity = {
|
||||||
"matches": 3,
|
"matches": 3, "viewed": 0, "saved": 0, "applied": 0, "tailored": 0,
|
||||||
"viewed": 0,
|
"searches": 1, "percentile": None, "cohort_size": 1,
|
||||||
"saved": 0,
|
|
||||||
"applied": 0,
|
|
||||||
"tailored": 0,
|
|
||||||
"searches": 1,
|
|
||||||
"percentile": None,
|
|
||||||
"cohort_size": 1,
|
|
||||||
}
|
}
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(S._repo, "get_feed", lambda _user_id: asyncio.sleep(0, result=feed))
|
||||||
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(
|
monkeypatch.setattr(S._repo, "get_salary_band", lambda _user_id: asyncio.sleep(0, result=None))
|
||||||
S._repo,
|
monkeypatch.setattr(S._clients, "fetch_qx", lambda _user_uuid: asyncio.sleep(0, result=None))
|
||||||
"get_activity_stats",
|
monkeypatch.setattr(S._clients, "fetch_qx_trend", lambda _user_uuid: asyncio.sleep(0, result=None))
|
||||||
lambda _user_id: asyncio.sleep(0, result=activity),
|
monkeypatch.setattr(S._clients, "fetch_streak", lambda _user_id: asyncio.sleep(0, result=None))
|
||||||
)
|
|
||||||
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"))
|
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():
|
def test_apply_window_freshness():
|
||||||
# all None when no posting dates (honest — never fabricated)
|
# all None when no posting dates (honest — never fabricated)
|
||||||
assert S._apply_window([{}, {"posted_date": None}])["urgency"] is None
|
assert S._apply_window([{}, {"posted_date": None}])["urgency"] is None
|
||||||
w = S._apply_window(
|
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")
|
||||||
{"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():
|
def test_activity_score_weighting():
|
||||||
# applies weighted highest, then saves, views, searches
|
# applies weighted highest, then saves, views, searches
|
||||||
assert (
|
assert _activity_score(0, 0, 1, 0) > _activity_score(0, 1, 0, 0) > _activity_score(1, 0, 0, 0)
|
||||||
_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
|
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):
|
def test_fetch_qx_trend_ignores_malformed_and_absent_points(monkeypatch):
|
||||||
async def _get(_self, _url, **_kwargs):
|
async def _get(_self, _url, **_kwargs):
|
||||||
return _Response(
|
return _Response({"points": [None, {"rq_score": "bad"}, {"rq_score": 35}, {"rq_score": 40}]})
|
||||||
{"points": [None, {"rq_score": "bad"}, {"rq_score": 35}, {"rq_score": 40}]}
|
|
||||||
)
|
|
||||||
|
|
||||||
monkeypatch.setattr(httpx.AsyncClient, "get", _get)
|
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):
|
def test_fetch_qx_trend_reads_strict_rq_score_points(monkeypatch):
|
||||||
async def _get(_self, _url, **_kwargs):
|
async def _get(_self, _url, **_kwargs):
|
||||||
return _Response(
|
return _Response({
|
||||||
{
|
"points": [
|
||||||
"points": [
|
{"rq_score": 35, "q_score": 91},
|
||||||
{"rq_score": 35, "q_score": 91},
|
{"rq_score": 44.16, "q_score": 92},
|
||||||
{"rq_score": 44.16, "q_score": 92},
|
{"q_score": 93},
|
||||||
{"q_score": 93},
|
]
|
||||||
]
|
})
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
monkeypatch.setattr(httpx.AsyncClient, "get", _get)
|
monkeypatch.setattr(httpx.AsyncClient, "get", _get)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user