- get_scout_stats aggregator (app/engine/stats): assembles the REAL Summary metrics —
funnel + cohort engagement rank + active-window (activity timestamps by hour) from our DB,
accumulating salary band (avg of each deck's peak, ₹L), match/competition stats from the
feed, Momentum/QX + Q-Score trend (qscore-service), day streak (user-service). Honest:
unsourced cards return None so the UI omits/locks them, never faked. posted_date extractor.
- Activity tracking: viewed/saved flags + search_count → funnel (Matches→Viewed→Shortlisted→
Applied) + engagement percentile. matchesFound = all-time count.
- Per-board search cursors {board: page} (replaces the single cursor): only boards that truly
paginate (LinkedIn) get one; cursor = LAST page fetched (1st search of a new query → 1).
Resets on query change OR >24h (boards refresh ~daily). Dropped Naukri incremental/stateKey
(opaque, exhausting, cross-account dedup state) — dedup is the PER-USER seen-net only.
- tests: stats helpers (posting-age, histogram, active-window, engagement score).
76 lines
3.0 KiB
Python
76 lines
3.0 KiB
Python
"""Cross-service reads for the dashboard — QScore (Momentum/QX) + user-service (Day streak).
|
|
|
|
Direct REST, Clerk-mesh-authed. Both degrade to None on any failure (timeout / 404 / service down) —
|
|
a missing KPI is omitted by the UI, never faked, and never crashes the dashboard.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
import httpx
|
|
|
|
from app.config import get_settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
async def fetch_qx(user_uuid: str | None) -> dict | None:
|
|
"""QScore for the user → {qx, quotients}. GET /v1/qscore/{uuid}?org_id (qscore-service)."""
|
|
if not user_uuid:
|
|
return None
|
|
s = get_settings()
|
|
try:
|
|
async with httpx.AsyncClient(timeout=4.0) as c:
|
|
r = await c.get(f"{s.QSCORE_BASE_URL}/v1/qscore/{user_uuid}", params={"org_id": s.QSCORE_ORG_ID})
|
|
if r.status_code == 404: # no score computed yet — honest absence
|
|
return None
|
|
r.raise_for_status()
|
|
d = r.json()
|
|
qx = d.get("q_score")
|
|
return {"qx": round(float(qx), 1), "quotients": d.get("quotients") or {}} if qx is not None else None
|
|
except (httpx.HTTPError, ValueError) as e:
|
|
logger.warning("fetch_qx failed (non-fatal): %s", e)
|
|
return None
|
|
|
|
|
|
async def fetch_qx_trend(user_uuid: str | None) -> list[float] | None:
|
|
"""Q-Score trend (oldest→newest) ← qscore-service GET /v1/qscore/{uuid}/history. Same source as
|
|
the live QX, so the trend's latest point matches the Momentum value (consistent, not stale).
|
|
None on failure → the UI keeps the Readiness card locked."""
|
|
if not user_uuid:
|
|
return None
|
|
s = get_settings()
|
|
try:
|
|
async with httpx.AsyncClient(timeout=4.0) as c:
|
|
r = await c.get(
|
|
f"{s.QSCORE_BASE_URL}/v1/qscore/{user_uuid}/history",
|
|
params={"org_id": s.QSCORE_ORG_ID, "points": 10},
|
|
)
|
|
if r.status_code == 404:
|
|
return None
|
|
r.raise_for_status()
|
|
pts = [round(float(p["q_score"]), 1) for p in (r.json().get("points") or []) if p.get("q_score") is not None]
|
|
return pts if len(pts) >= 2 else None # need ≥2 points for a trend
|
|
except (httpx.HTTPError, ValueError, TypeError, KeyError) as e:
|
|
logger.warning("fetch_qx_trend failed (non-fatal): %s", e)
|
|
return None
|
|
|
|
|
|
async def fetch_streak(clerk_id: str | None) -> int | None:
|
|
"""Day streak ← user-service metadata.current_streak. GET /api/state/{clerk_id} (Bearer A2A)."""
|
|
if not clerk_id:
|
|
return None
|
|
s = get_settings()
|
|
try:
|
|
async with httpx.AsyncClient(timeout=4.0) as c:
|
|
r = await c.get(
|
|
f"{s.USER_SERVICE_BASE_URL}/api/state/{clerk_id}",
|
|
headers={"Authorization": f"Bearer {s.A2A_OUTBOUND_KEY}"},
|
|
)
|
|
r.raise_for_status()
|
|
streak = (r.json().get("metadata") or {}).get("current_streak")
|
|
return int(streak) if streak is not None else None
|
|
except (httpx.HTTPError, ValueError, TypeError) as e:
|
|
logger.warning("fetch_streak failed (non-fatal): %s", e)
|
|
return None
|