From 81a845d2445c6aa39867488f0093c4f4780303b9 Mon Sep 17 00:00:00 2001 From: sai karthik Date: Tue, 14 Jul 2026 15:41:48 +0530 Subject: [PATCH 1/2] fix: consume strict RQ Score contract --- app/config.py | 4 +-- app/engine/stats/__init__.py | 4 +-- app/engine/stats/clients.py | 10 +++---- tests/test_stats.py | 56 +++++++++++++++++++++++++++++++++++- 4 files changed, 64 insertions(+), 10 deletions(-) diff --git a/app/config.py b/app/config.py index 2409830..0bc137d 100644 --- a/app/config.py +++ b/app/config.py @@ -89,13 +89,13 @@ class Settings(BaseSettings): 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) - # 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_AUTH_TOKEN: str | None = None QSCORE_ORG_ID: str = "growqr" # user-service — dashboard Day-streak reads metadata.current_streak via GET /api/state/{clerk_id} 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" A2A_OUTBOUND_KEY: str = "dev-a2a-key" # Bearer token presented to sibling services diff --git a/app/engine/stats/__init__.py b/app/engine/stats/__init__.py index c75183c..5571dd8 100644 --- a/app/engine/stats/__init__.py +++ b/app/engine/stats/__init__.py @@ -4,7 +4,7 @@ Honest sources (SCOUT_UI_SPEC): - feed stats (matches / top / range / salary / competition) ← the persisted feed - funnel + engagement rank ← opportunity_state activity (this DB) - 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) 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) ── "qxNow": (qx or {}).get("qx"), "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, } diff --git a/app/engine/stats/clients.py b/app/engine/stats/clients.py index c12e0c4..95a818d 100644 --- a/app/engine/stats/clients.py +++ b/app/engine/stats/clients.py @@ -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) — 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: - """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: return None s = get_settings() @@ -26,7 +26,7 @@ async def fetch_qx(user_uuid: str | None) -> dict | None: return None r.raise_for_status() 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 except (httpx.HTTPError, ValueError) as 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: - """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). None on failure → the UI keeps the Readiness card locked.""" 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: 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] + 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 except (httpx.HTTPError, ValueError, TypeError, KeyError) as e: logger.warning("fetch_qx_trend failed (non-fatal): %s", e) diff --git a/tests/test_stats.py b/tests/test_stats.py index bf6e1b1..2626d3c 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -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.stats import clients from app.engine.normalize import _posted_date from app.db.repo import _activity_score @@ -31,3 +36,52 @@ 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, 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] From f3c0775e5990a5385d24c90ccee84b2f7f3bb530 Mon Sep 17 00:00:00 2001 From: -Puter <22245429+puterhimself@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:20:06 +0530 Subject: [PATCH 2/2] fix: harden RQ Score response parsing --- app/engine/stats/clients.py | 43 +++++++++++++++++++++++++++++++------ tests/test_stats.py | 18 ++++++++++++++++ 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/app/engine/stats/clients.py b/app/engine/stats/clients.py index 95a818d..15069f9 100644 --- a/app/engine/stats/clients.py +++ b/app/engine/stats/clients.py @@ -6,6 +6,8 @@ a missing KPI is omitted by the UI, never faked, and never crashes the dashboard from __future__ import annotations import logging +import math +from collections.abc import Mapping import httpx @@ -14,6 +16,17 @@ from app.config import get_settings logger = logging.getLogger(__name__) +def _score_value(value: object) -> float | None: + """Parse one canonical RQ Score value; malformed values are absent.""" + if isinstance(value, bool) or value is None: + return None + try: + score = float(value) + except (TypeError, ValueError): + return None + return score if math.isfinite(score) else None + + async def fetch_qx(user_uuid: str | None) -> dict | None: """RQ Score for the user → {qx, quotients}. GET /v1/qscore/{uuid}?org_id (qscore-service).""" if not user_uuid: @@ -25,10 +38,15 @@ async def fetch_qx(user_uuid: str | None) -> dict | None: if r.status_code == 404: # no score computed yet — honest absence return None r.raise_for_status() - d = r.json() - qx = d.get("rq_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: + payload = r.json() + if not isinstance(payload, Mapping): + return None + qx = _score_value(payload.get("rq_score")) + if qx is None: + return None + quotients = payload.get("quotients") + return {"qx": round(qx, 1), "quotients": quotients if isinstance(quotients, Mapping) else {}} + except (httpx.HTTPError, ValueError, TypeError) as e: logger.warning("fetch_qx failed (non-fatal): %s", e) return None @@ -48,10 +66,21 @@ async def fetch_qx_trend(user_uuid: str | None) -> list[float] | None: ) if r.status_code == 404: return None - r.raise_for_status() - pts = [round(float(p["rq_score"]), 1) for p in (r.json().get("points") or []) if p.get("rq_score") is not None] + payload = r.json() + if not isinstance(payload, Mapping): + return None + points = payload.get("points") + if not isinstance(points, list): + return None + pts = [] + for point in points: + if not isinstance(point, Mapping): + continue + score = _score_value(point.get("rq_score")) + if score is not None: + pts.append(round(score, 1)) 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) as e: logger.warning("fetch_qx_trend failed (non-fatal): %s", e) return None diff --git a/tests/test_stats.py b/tests/test_stats.py index 2626d3c..2ff8231 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -72,6 +72,24 @@ def test_fetch_qx_does_not_alias_legacy_q_score(monkeypatch): assert asyncio.run(clients.fetch_qx("user-uuid")) is None +def test_fetch_qx_treats_malformed_payload_as_absent(monkeypatch): + async def _get(_self, _url, **_kwargs): + return _Response([{"rq_score": 88}]) + + monkeypatch.setattr(httpx.AsyncClient, "get", _get) + + assert asyncio.run(clients.fetch_qx("user-uuid")) is None + + +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}]}) + + monkeypatch.setattr(httpx.AsyncClient, "get", _get) + + assert asyncio.run(clients.fetch_qx_trend("user-uuid")) == [35.0, 40.0] + + def test_fetch_qx_trend_reads_strict_rq_score_points(monkeypatch): async def _get(_self, _url, **_kwargs): return _Response({