"""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