fix: consume strict RQ Score contract

This commit is contained in:
sai karthik
2026-07-14 15:41:48 +05:30
parent 8b36dd4e99
commit 81a845d244
4 changed files with 64 additions and 10 deletions
+2 -2
View File
@@ -89,13 +89,13 @@ class Settings(BaseSettings):
WELLFOUND_MAX_JOBS: int = 15 # blackfalcondata/wellfound-scraper — registered, OFF (US-heavy) WELLFOUND_MAX_JOBS: int = 15 # blackfalcondata/wellfound-scraper — registered, OFF (US-heavy)
WORKINDIA_MAX_JOBS: int = 15 # shahidirfan/workindia-jobs-scraper — India blue/grey-collar (niche, low yield) WORKINDIA_MAX_JOBS: int = 15 # shahidirfan/workindia-jobs-scraper — India blue/grey-collar (niche, low yield)
# QScore (competence proxy) — consumed, not computed. Dashboard Momentum/QX reads it via REST. # RQ Score (competence proxy) — consumed, not computed. Dashboard Momentum reads it via REST.
QSCORE_BASE_URL: str = "http://localhost:8004" QSCORE_BASE_URL: str = "http://localhost:8004"
QSCORE_AUTH_TOKEN: str | None = None QSCORE_AUTH_TOKEN: str | None = None
QSCORE_ORG_ID: str = "growqr" QSCORE_ORG_ID: str = "growqr"
# user-service — dashboard Day-streak reads metadata.current_streak via GET /api/state/{clerk_id} # user-service — dashboard Day-streak reads metadata.current_streak via GET /api/state/{clerk_id}
USER_SERVICE_BASE_URL: str = "http://localhost:8003" USER_SERVICE_BASE_URL: str = "http://localhost:8003"
# dashboard-service — Q-Score trend series via GET /api/qscore-history/{clerk_id} # dashboard-service — RQ Score trend series via GET /api/qscore-history/{clerk_id}
DASHBOARD_SERVICE_BASE_URL: str = "http://localhost:8005" DASHBOARD_SERVICE_BASE_URL: str = "http://localhost:8005"
A2A_OUTBOUND_KEY: str = "dev-a2a-key" # Bearer token presented to sibling services A2A_OUTBOUND_KEY: str = "dev-a2a-key" # Bearer token presented to sibling services
+2 -2
View File
@@ -4,7 +4,7 @@ Honest sources (SCOUT_UI_SPEC):
- feed stats (matches / top / range / salary / competition) ← the persisted feed - feed stats (matches / top / range / salary / competition) ← the persisted feed
- funnel + engagement rank ← opportunity_state activity (this DB) - funnel + engagement rank ← opportunity_state activity (this DB)
- Apply window (freshness urgency) ← listing posting-age - Apply window (freshness urgency) ← listing posting-age
- Momentum/QX + Readiness trend ← qscore-service (Phase 1) - Momentum/RQ Score + Readiness trend ← qscore-service (Phase 1)
- Day streak ← dashboard-service (Phase 1) - Day streak ← dashboard-service (Phase 1)
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.
@@ -109,6 +109,6 @@ async def build_scout_stats(user_id: str, *, user_uuid: str | None = None) -> di
# ── 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, # Q-Score series (dashboard-service) → Readiness-trend sparkline "qxTrend": qx_trend, # RQ Score series (qscore-service) → Readiness-trend sparkline
"dayStreak": streak, "dayStreak": streak,
} }
+5 -5
View File
@@ -1,4 +1,4 @@
"""Cross-service reads for the dashboard — QScore (Momentum/QX) + user-service (Day streak). """Cross-service reads for the dashboard — RQ Score (Momentum/QX) + user-service (Day streak).
Direct REST, Clerk-mesh-authed. Both degrade to None on any failure (timeout / 404 / service down) — 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. a missing KPI is omitted by the UI, never faked, and never crashes the dashboard.
@@ -15,7 +15,7 @@ logger = logging.getLogger(__name__)
async def fetch_qx(user_uuid: str | None) -> dict | None: async def fetch_qx(user_uuid: str | None) -> dict | None:
"""QScore for the user → {qx, quotients}. GET /v1/qscore/{uuid}?org_id (qscore-service).""" """RQ Score for the user → {qx, quotients}. GET /v1/qscore/{uuid}?org_id (qscore-service)."""
if not user_uuid: if not user_uuid:
return None return None
s = get_settings() s = get_settings()
@@ -26,7 +26,7 @@ async def fetch_qx(user_uuid: str | None) -> dict | None:
return None return None
r.raise_for_status() r.raise_for_status()
d = r.json() d = r.json()
qx = d.get("q_score") qx = d.get("rq_score")
return {"qx": round(float(qx), 1), "quotients": d.get("quotients") or {}} if qx is not None else None return {"qx": round(float(qx), 1), "quotients": d.get("quotients") or {}} if qx is not None else None
except (httpx.HTTPError, ValueError) as e: except (httpx.HTTPError, ValueError) as e:
logger.warning("fetch_qx failed (non-fatal): %s", e) logger.warning("fetch_qx failed (non-fatal): %s", e)
@@ -34,7 +34,7 @@ async def fetch_qx(user_uuid: str | None) -> dict | None:
async def fetch_qx_trend(user_uuid: str | None) -> list[float] | 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 """RQ 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). 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.""" None on failure → the UI keeps the Readiness card locked."""
if not user_uuid: if not user_uuid:
@@ -49,7 +49,7 @@ async def fetch_qx_trend(user_uuid: str | None) -> list[float] | None:
if r.status_code == 404: if r.status_code == 404:
return None return None
r.raise_for_status() 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] pts = [round(float(p["rq_score"]), 1) for p in (r.json().get("points") or []) if p.get("rq_score") is not None]
return pts if len(pts) >= 2 else None # need ≥2 points for a trend return pts if len(pts) >= 2 else None # need ≥2 points for a trend
except (httpx.HTTPError, ValueError, TypeError, KeyError) as e: except (httpx.HTTPError, ValueError, TypeError, KeyError) as e:
logger.warning("fetch_qx_trend failed (non-fatal): %s", e) logger.warning("fetch_qx_trend failed (non-fatal): %s", e)
+55 -1
View File
@@ -1,5 +1,10 @@
"""Dashboard stats — pure-helper contract tests (no DB / no network).""" """Dashboard stats — pure-helper and RQ Score client contract tests."""
import asyncio
import httpx
from app.engine import stats as S from app.engine import stats as S
from app.engine.stats import clients
from app.engine.normalize import _posted_date from app.engine.normalize import _posted_date
from app.db.repo import _activity_score from app.db.repo import _activity_score
@@ -31,3 +36,52 @@ def test_activity_score_weighting():
# applies weighted highest, then saves, views, searches # 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 assert _activity_score(0, 0, 0, 0) == 0
class _Response:
status_code = 200
def __init__(self, payload):
self._payload = payload
def raise_for_status(self):
return None
def json(self):
return self._payload
def test_fetch_qx_reads_strict_rq_score_contract(monkeypatch):
async def _get(_self, _url, **_kwargs):
return _Response({"rq_score": 68.25, "quotients": {"technical": 71}})
monkeypatch.setattr(httpx.AsyncClient, "get", _get)
assert asyncio.run(clients.fetch_qx("user-uuid")) == {
"qx": 68.2,
"quotients": {"technical": 71},
}
def test_fetch_qx_does_not_alias_legacy_q_score(monkeypatch):
async def _get(_self, _url, **_kwargs):
return _Response({"q_score": 99, "quotients": {"technical": 99}})
monkeypatch.setattr(httpx.AsyncClient, "get", _get)
assert asyncio.run(clients.fetch_qx("user-uuid")) is None
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},
]
})
monkeypatch.setattr(httpx.AsyncClient, "get", _get)
assert asyncio.run(clients.fetch_qx_trend("user-uuid")) == [35.0, 44.2]