Merge pull request 'Consume strict RQ Score contract' (#1) from fix/rq-score-contract into staging

This commit was merged in pull request #1.
This commit is contained in:
2026-07-14 14:14:13 +00:00
4 changed files with 116 additions and 15 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,
} }
+39 -10
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.
@@ -6,6 +6,8 @@ a missing KPI is omitted by the UI, never faked, and never crashes the dashboard
from __future__ import annotations from __future__ import annotations
import logging import logging
import math
from collections.abc import Mapping
import httpx import httpx
@@ -14,8 +16,19 @@ from app.config import get_settings
logger = logging.getLogger(__name__) 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: 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()
@@ -25,16 +38,21 @@ async def fetch_qx(user_uuid: str | None) -> dict | None:
if r.status_code == 404: # no score computed yet — honest absence if r.status_code == 404: # no score computed yet — honest absence
return None return None
r.raise_for_status() r.raise_for_status()
d = r.json() payload = r.json()
qx = d.get("q_score") if not isinstance(payload, Mapping):
return {"qx": round(float(qx), 1), "quotients": d.get("quotients") or {}} if qx is not None else None return None
except (httpx.HTTPError, ValueError) as e: 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) logger.warning("fetch_qx failed (non-fatal): %s", e)
return None return 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:
@@ -48,10 +66,21 @@ 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() payload = r.json()
pts = [round(float(p["q_score"]), 1) for p in (r.json().get("points") or []) if p.get("q_score") is not None] 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 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) logger.warning("fetch_qx_trend failed (non-fatal): %s", e)
return None return None
+73 -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,70 @@ 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_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({
"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]