Merge pull request 'Consume strict RQ Score contract' (#1) from fix/rq-score-contract into staging
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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,8 +16,19 @@ 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:
|
||||
"""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()
|
||||
@@ -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
|
||||
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:
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
@@ -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["q_score"]), 1) for p in (r.json().get("points") or []) if p.get("q_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
|
||||
|
||||
|
||||
@@ -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,70 @@ 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_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]
|
||||
|
||||
Reference in New Issue
Block a user