Compare commits

...

7 Commits

Author SHA1 Message Date
ac1226c367 feat(integrations): migrate google-health from Fit REST to Google Health API v4
Google Fit REST API was closed to new sign-ups on 2024-05-01 and shuts down
end of 2026, surfacing as "Access blocked: this app's request is invalid"
when starting the OAuth flow.

- Swap the 10 fitness.* OAuth scopes for the 3 googlehealth.*.readonly
  scopes (activity_and_fitness, health_metrics_and_measurements, sleep).
- Replace fitness/v1 dataset:aggregate + sessions calls with
  health.googleapis.com/v4/users/me/dataTypes/{steps,total-calories,
  heart-rate,sleep}/dataPoints, filtered to today's window.
- Read the v4 DataPoint union defensively (the per-type schema is sparsely
  documented) and log the first raw sample at debug so we can refine field
  paths after the first real OAuth.
- Output Signal contract is unchanged — agents and downstream consumers
  see the same steps/activity/heart_rate/sleep signals.

Cloud Console still needs: enable Google Health API, add the 3 scopes to
the consent screen, add test user (all googlehealth scopes are Restricted).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 05:42:05 +00:00
2159d4cbd1 fix(infra): unblock docker builds for stars agent and web
- Dockerfile.ml: install build-essential so pyswisseph (stars agent) compiles
- Dockerfile.web: copy root package.json + pnpm-workspace.yaml + pnpm-lock.yaml into builder stage so pnpm --filter resolves the workspace
- CLAUDE.md: record both gotchas alongside the existing Docker rebuild notes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 04:46:20 +00:00
522454ab61 feat(agents): stars agent — astrological transits via pyswisseph (#121)
Computes natal chart (Sun/Moon/Mercury/Venus/Mars/Jupiter/Saturn) from
birth_date and finds active transits (conjunction/sextile/square/trine/
opposition) between today's sky and the user's natal positions. Top 3
most-exact transits are passed to the orchestrator as interpretive themes
to colour the tip — grounded and actionable, not predictive.

Birth date sourced from agent_prefs (populated by a connected Google
data source); requires data:google-health consent. Agent self-silences
when birth_date is absent. pyswisseph added to ml/serving/requirements.txt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:59:10 +00:00
be8c006a4d feat(agents): tarot agent — daily three-card draw (situation/action/outcome) (#120)
Draws 3 Major Arcana cards from a daily seed (user_id + date) so the
reading is stable within a day and unique per user. Card meanings and
action hints are precomputed in the agent; the orchestrator receives a
structured prompt snippet and is instructed to weave the themes into a
grounded, practical tip without explaining the cards.

No inferred params, no external data — requires only data:core consent.
TTL 6 h (refreshes at most twice daily).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:52:55 +00:00
8474468614 feat(integrations): add Google Health card to connect page (#119)
The OAuth backend (signal source, /connect and /callback routes, token
refresh, consent grant) was already complete. This adds the missing UI:
a Google Health card in /connect with Connect/Disconnect actions, and
broadens the "See my tip →" CTA to appear when any integration is
connected (not only Todoist).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 10:28:14 +00:00
ad43a8f06a fix(recommender): serve fallback tips to users with no integrations (#117)
The integration-token gate returned 422 for users with no connected
sources, blocking them from any tip. Users with no integrations now go
through the full orchestrator pipeline; if it fails (or returns nothing
because agent outputs are also empty), randomFallbackTip() fires and
serves a generic advice tip instead of an error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 09:54:54 +00:00
56fda0d737 chore(scheduler): skip agents whose data sources aren't granted (#128)
Check getEligibleAgentIds per user in runCycle before calling
computeAndStore — agents without consented data sources, silenced by
active context, or disabled via preference are skipped rather than
computed unconditionally. Eligibility check failure skips the whole
user (fail-closed). Skipped count added to cycle-complete log line.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 15:45:08 +00:00
14 changed files with 825 additions and 167 deletions

View File

@@ -71,6 +71,8 @@ docs/ architecture notes, ADRs, API specs
- **Never run two `docker compose up --build` at once** — both grab the same `--mount=type=cache,id=pnpm` and deadlock on the API's `pnpm --prod deploy` step. Symptom: build sits silent for hours on `[api builder 8/8]`. Before starting any build, check `ps aux | grep "docker compose"` and kill any prior `up --build` (`kill -9 <pid>` — the wrapper bash and the docker compose binary are separate PIDs; kill the docker compose one). - **Never run two `docker compose up --build` at once** — both grab the same `--mount=type=cache,id=pnpm` and deadlock on the API's `pnpm --prod deploy` step. Symptom: build sits silent for hours on `[api builder 8/8]`. Before starting any build, check `ps aux | grep "docker compose"` and kill any prior `up --build` (`kill -9 <pid>` — the wrapper bash and the docker compose binary are separate PIDs; kill the docker compose one).
- **Don't add `--offline` to `pnpm --prod deploy`** — pnpm's metadata cache (`/root/.cache/pnpm/`) is not in the `/pnpm/store` cache mount, so `--offline` fails with `ERR_PNPM_NO_OFFLINE_META` for transitive devDeps (e.g. vite via vitest). Leave the deploy step network-on; it works. - **Don't add `--offline` to `pnpm --prod deploy`** — pnpm's metadata cache (`/root/.cache/pnpm/`) is not in the `/pnpm/store` cache mount, so `--offline` fails with `ERR_PNPM_NO_OFFLINE_META` for transitive devDeps (e.g. vite via vitest). Leave the deploy step network-on; it works.
- **All TS Dockerfiles need `python3 make g++`** in the base stage — `better-sqlite3` rebuilds natively on install. Missing from `Dockerfile.admin` historically caused `gyp ERR! find Python` failures. - **All TS Dockerfiles need `python3 make g++`** in the base stage — `better-sqlite3` rebuilds natively on install. Missing from `Dockerfile.admin` historically caused `gyp ERR! find Python` failures.
- **`Dockerfile.ml` needs `build-essential`** (not just `gcc`) — `pyswisseph` (stars agent) compiles C from source and fails with `fatal error: math.h: No such file or directory` if only `gcc` is installed; it needs `libc-dev` too, easiest via `build-essential`.
- **`Dockerfile.web` builder stage needs root `package.json` + `pnpm-workspace.yaml` + `pnpm-lock.yaml`** copied in. Without them, `pnpm --filter @oo/shared-types build` fails with `[ERR_PNPM_NO_PKG_MANIFEST] No package.json found in /app`. The deps stage has them but the builder is a fresh layer; selective copies must include them.
- **A clean build of `--profile core` takes ~3 min total** when the buildx cache is warm. If it's been silent for >10 min, check for the parallel-build deadlock above before assuming "still going". - **A clean build of `--profile core` takes ~3 min total** when the buildx cache is warm. If it's been silent for >10 min, check for the parallel-build deadlock above before assuming "still going".
- Run Python agent tests: `python3 -m pytest ml/agents/tests/ -x -q` (tests add repo root to `sys.path` themselves). - Run Python agent tests: `python3 -m pytest ml/agents/tests/ -x -q` (tests add repo root to `sys.path` themselves).
- Run Python feature tests: `python3 -m pytest ml/features/ -x -q` - Run Python feature tests: `python3 -m pytest ml/features/ -x -q`

View File

@@ -51,6 +51,8 @@ function ConnectPageInner() {
} }
const todoistConnected = isConnected('todoist'); const todoistConnected = isConnected('todoist');
const googleHealthConnected = isConnected('google-health');
const anyConnected = todoistConnected || googleHealthConnected;
return ( return (
<main style={{ minHeight: '100vh', padding: '4rem 2rem', maxWidth: '480px', margin: '0 auto' }}> <main style={{ minHeight: '100vh', padding: '4rem 2rem', maxWidth: '480px', margin: '0 auto' }}>
@@ -85,7 +87,6 @@ function ConnectPageInner() {
marginBottom: '1rem', marginBottom: '1rem',
}}> }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.875rem' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.875rem' }}>
{/* Todoist logomark */}
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" aria-label="Todoist"> <svg width="28" height="28" viewBox="0 0 24 24" fill="none" aria-label="Todoist">
<rect width="24" height="24" rx="6" fill="#DB4035"/> <rect width="24" height="24" rx="6" fill="#DB4035"/>
<path d="M6 8.5L11 13l7-7" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/> <path d="M6 8.5L11 13l7-7" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
@@ -130,7 +131,65 @@ function ConnectPageInner() {
)} )}
</div> </div>
{todoistConnected && ( {/* Google Health card */}
<div style={{
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '0.75rem',
padding: '1.25rem 1.5rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '1rem',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.875rem' }}>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" aria-label="Google Health">
<rect width="24" height="24" rx="6" fill="#EA4335"/>
<path d="M12 6.5c0-1.1.9-2 2-2s2 .9 2 2-.9 2-2 2-2-.9-2-2z" fill="#fff"/>
<path d="M8 10.5c0-1.1.9-2 2-2s2 .9 2 2-.9 2-2 2-2-.9-2-2z" fill="#fff" opacity=".7"/>
<path d="M12 14.5c0 2.2-1.8 4-4 4s-4-1.8-4-4 1.8-4 4-4 4 1.8 4 4z" fill="#fff" opacity=".4"/>
<path d="M13 13.5c.5-1 1.5-1.7 2.5-1.7 1.7 0 3 1.3 3 3s-1.3 3-3 3c-1 0-1.9-.5-2.5-1.3" stroke="#fff" strokeWidth="1.5" strokeLinecap="round" fill="none"/>
</svg>
<div>
<div style={{ fontWeight: 500, fontSize: '0.9rem' }}>Google Health</div>
<div style={{ color: 'var(--gray)', fontSize: '0.75rem', marginTop: '0.1rem' }}>
{googleHealthConnected ? 'Connected' : 'Steps, sleep & activity'}
</div>
</div>
</div>
{googleHealthConnected ? (
<button
onClick={() => handleDisconnect('google-health')}
disabled={disconnecting === 'google-health'}
style={{
background: 'transparent',
border: '1px solid rgba(255,255,255,0.15)',
color: 'var(--gray)',
borderRadius: '0.375rem',
padding: '0.375rem 0.875rem',
fontSize: '0.8rem',
}}
>
{disconnecting === 'google-health' ? '…' : 'Disconnect'}
</button>
) : (
<a
href="/api/integrations/google-health/connect?redirectTo=/connect"
style={{
background: 'var(--white)',
color: 'var(--black)',
borderRadius: '0.375rem',
padding: '0.375rem 0.875rem',
fontSize: '0.8rem',
fontWeight: 500,
}}
>
Connect
</a>
)}
</div>
{anyConnected && (
<div style={{ marginTop: '3rem' }}> <div style={{ marginTop: '3rem' }}>
<a <a
href="/tip" href="/tip"

View File

@@ -1,5 +1,8 @@
FROM python:3.12-slim FROM python:3.12-slim
WORKDIR /app/ml/serving WORKDIR /app/ml/serving
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential \
&& rm -rf /var/lib/apt/lists/*
COPY ml/serving/requirements.txt . COPY ml/serving/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY ml/ /app/ml/ COPY ml/ /app/ml/

View File

@@ -13,6 +13,7 @@ WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/packages/shared-types/node_modules ./packages/shared-types/node_modules COPY --from=deps /app/packages/shared-types/node_modules ./packages/shared-types/node_modules
COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
COPY tsconfig.base.json ./ COPY tsconfig.base.json ./
COPY packages/shared-types ./packages/shared-types COPY packages/shared-types ./packages/shared-types
COPY apps/web ./apps/web COPY apps/web ./apps/web

View File

@@ -17,6 +17,8 @@ from .time_of_day import TimeOfDayAgent, MANIFEST as TIME_OF_DAY_MANIFEST
from .recent_patterns import RecentPatternsAgent, MANIFEST as RECENT_PATTERNS_MANIFEST from .recent_patterns import RecentPatternsAgent, MANIFEST as RECENT_PATTERNS_MANIFEST
from .focus_area import FocusAreaAgent, MANIFEST as FOCUS_AREA_MANIFEST from .focus_area import FocusAreaAgent, MANIFEST as FOCUS_AREA_MANIFEST
from .health_vitals import HealthVitalsAgent, MANIFEST as HEALTH_VITALS_MANIFEST from .health_vitals import HealthVitalsAgent, MANIFEST as HEALTH_VITALS_MANIFEST
from .tarot import TarotAgent, MANIFEST as TAROT_MANIFEST
from .stars import StarsAgent, MANIFEST as STARS_MANIFEST
_REGISTERED: list[tuple[BaseAgent, AgentManifest]] = [ _REGISTERED: list[tuple[BaseAgent, AgentManifest]] = [
(OverdueTaskAgent(), OVERDUE_TASK_MANIFEST), (OverdueTaskAgent(), OVERDUE_TASK_MANIFEST),
@@ -25,6 +27,8 @@ _REGISTERED: list[tuple[BaseAgent, AgentManifest]] = [
(RecentPatternsAgent(), RECENT_PATTERNS_MANIFEST), (RecentPatternsAgent(), RECENT_PATTERNS_MANIFEST),
(FocusAreaAgent(), FOCUS_AREA_MANIFEST), (FocusAreaAgent(), FOCUS_AREA_MANIFEST),
(HealthVitalsAgent(), HEALTH_VITALS_MANIFEST), (HealthVitalsAgent(), HEALTH_VITALS_MANIFEST),
(TarotAgent(), TAROT_MANIFEST),
(StarsAgent(), STARS_MANIFEST),
] ]
# Sanity check — agent_id and manifest.id must agree, otherwise the registry # Sanity check — agent_id and manifest.id must agree, otherwise the registry

233
ml/agents/stars.py Normal file
View File

@@ -0,0 +1,233 @@
"""Stars agent — astrological transit predictions via pyswisseph.
Requires birth_date in agent_prefs (ISO 8601 date string, e.g. '1990-06-15').
Populated from a connected data source (Google profile / Google Health).
If birth_date is absent the agent returns a no-data snippet and the
eligibility filter will silence it once the consent / pref check catches up.
Computes today's Sun, Moon, Mercury, Venus, Mars, Jupiter, Saturn positions
and finds notable transits (conjunctions, oppositions, squares, trines, sextiles)
between today's sky and the user's natal chart. Passes a concise prediction
+ interpretation to the orchestrator.
"""
from __future__ import annotations
import math
from datetime import date, datetime, timezone
from typing import ClassVar
from .base import BaseAgent, AgentInput, AgentOutput
from .manifest import AgentManifest, InferredParam
try:
import swisseph as swe # type: ignore
_SWE_AVAILABLE = True
except ImportError: # pragma: no cover — present in container, absent in dev
_SWE_AVAILABLE = False
# ---------------------------------------------------------------------------
# Planet catalogue
# ---------------------------------------------------------------------------
_PLANETS: list[tuple[int, str]] = []
if _SWE_AVAILABLE:
_PLANETS = [
(swe.SUN, "Sun"),
(swe.MOON, "Moon"),
(swe.MERCURY, "Mercury"),
(swe.VENUS, "Venus"),
(swe.MARS, "Mars"),
(swe.JUPITER, "Jupiter"),
(swe.SATURN, "Saturn"),
]
# Aspect definitions: (angle, orb, name, nature)
_ASPECTS: list[tuple[float, float, str, str]] = [
(0.0, 8.0, "conjunction", "intensifying"),
(60.0, 6.0, "sextile", "harmonious"),
(90.0, 7.0, "square", "challenging"),
(120.0, 8.0, "trine", "flowing"),
(180.0, 8.0, "opposition", "tension"),
]
_ZODIAC = [
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
]
# Interpretive keywords per planet for transit readings
_PLANET_THEMES: dict[str, str] = {
"Sun": "identity, vitality, core purpose",
"Moon": "emotions, intuition, comfort needs",
"Mercury": "communication, thinking, decisions",
"Venus": "relationships, values, pleasure",
"Mars": "energy, drive, conflict",
"Jupiter": "growth, opportunity, expansion",
"Saturn": "discipline, responsibility, long-term structure",
}
def _zodiac_sign(lon: float) -> str:
return _ZODIAC[int(lon / 30) % 12]
def _jd_from_date(d: date) -> float:
"""Julian Day Number for noon UTC on the given date."""
assert _SWE_AVAILABLE
return swe.julday(d.year, d.month, d.day, 12.0)
def _planet_positions(jd: float) -> dict[str, float]:
assert _SWE_AVAILABLE
positions: dict[str, float] = {}
for pid, name in _PLANETS:
result, _ = swe.calc_ut(jd, pid)
positions[name] = result[0] # ecliptic longitude
return positions
def _angular_diff(a: float, b: float) -> float:
"""Smallest angle between two ecliptic longitudes (0180)."""
diff = abs(a - b) % 360
return diff if diff <= 180 else 360 - diff
def _find_transits(natal: dict[str, float], today: dict[str, float]) -> list[dict]:
"""Return list of active transits between today's sky and natal chart."""
transits: list[dict] = []
for t_name, t_lon in today.items():
for n_name, n_lon in natal.items():
diff = _angular_diff(t_lon, n_lon)
for angle, orb, aspect_name, nature in _ASPECTS:
if abs(diff - angle) <= orb:
transits.append({
"transit_planet": t_name,
"natal_planet": n_name,
"aspect": aspect_name,
"nature": nature,
"orb": round(abs(diff - angle), 2),
})
# Sort by tightness of orb
transits.sort(key=lambda x: x["orb"])
return transits
def _format_transit(t: dict) -> str:
tp, np, asp, nat = t["transit_planet"], t["natal_planet"], t["aspect"], t["nature"]
tp_theme = _PLANET_THEMES.get(tp, "")
np_theme = _PLANET_THEMES.get(np, "")
return (
f"Transiting {tp} ({tp_theme}) {asp} natal {np} ({np_theme}) "
f"— a {nat} influence"
)
# ---------------------------------------------------------------------------
# Manifest
# ---------------------------------------------------------------------------
MANIFEST = AgentManifest(
id="stars",
version="1.0.0",
description="Astrological transit predictions based on the user's birth date and today's planetary positions.",
pref_schema={
"type": "object",
"additionalProperties": False,
"properties": {
"birth_date": {
"type": "string",
"pattern": r"^\d{4}-\d{2}-\d{2}$",
"description": "ISO 8601 birth date (YYYY-MM-DD). Populated from connected data source.",
},
},
},
context_schema=["profile.birth_date"],
# Requires a connected Google source that supplies birth date.
# data:google-health is the current carrier; when Google profile is a
# separate consent key, add it here.
required_consents=["data:core", "data:google-health"],
output_contract={"type": "snippet", "format": "free_text"},
ttl_sec=3_600 * 6, # planetary positions change slowly — 6 h is fine
silenced_in_contexts=[],
inferred_params=[
InferredParam(
key="birth_date",
ttl_sec=365 * 86_400, # effectively permanent once known
cold_start_default=None,
min_history=999_999, # never inferred from events — sourced externally
infer=None,
),
],
)
class StarsAgent(BaseAgent):
"""Produces astrological transit predictions for the user's birth chart."""
agent_id: ClassVar[str] = MANIFEST.id
ttl_seconds: ClassVar[int] = MANIFEST.ttl_sec
version: ClassVar[str] = MANIFEST.version
def compute(self, inp: AgentInput) -> AgentOutput:
birth_date_str: str | None = inp.agent_prefs.get("birth_date")
if not birth_date_str:
prompt = (
"Birth date is not available — astrological reading skipped. "
"(Always write the tip in English.)"
)
return self._make_output(inp, prompt, {"no_birth_date": True})
if not _SWE_AVAILABLE:
prompt = (
"Astrological library unavailable — reading skipped. "
"(Always write the tip in English.)"
)
return self._make_output(inp, prompt, {"swe_unavailable": True})
try:
birth_date = date.fromisoformat(birth_date_str)
except ValueError:
prompt = "Birth date format invalid — astrological reading skipped."
return self._make_output(inp, prompt, {"invalid_birth_date": birth_date_str})
today_date = inp.now.date()
natal_jd = _jd_from_date(birth_date)
today_jd = _jd_from_date(today_date)
natal_pos = _planet_positions(natal_jd)
today_pos = _planet_positions(today_jd)
transits = _find_transits(natal_pos, today_pos)
top = transits[:3] # most exact transits only
today_sun_sign = _zodiac_sign(today_pos["Sun"])
natal_sun_sign = _zodiac_sign(natal_pos["Sun"])
natal_moon_sign = _zodiac_sign(natal_pos["Moon"])
snapshot = {
"birth_date": birth_date_str,
"today": today_date.isoformat(),
"natal_sun": natal_sun_sign,
"natal_moon": natal_moon_sign,
"today_sun": today_sun_sign,
"active_transits": transits[:5],
}
if not top:
prompt = (
f"Natal chart: Sun in {natal_sun_sign}, Moon in {natal_moon_sign}. "
f"Today's Sun is in {today_sun_sign}. "
"No exact transits today — a quiet, stable day energetically. "
"(Always write the tip in English.)"
)
else:
transit_lines = "; ".join(_format_transit(t) for t in top)
prompt = (
f"Natal chart: Sun in {natal_sun_sign}, Moon in {natal_moon_sign}. "
f"Today's Sun is in {today_sun_sign}. "
f"Active transits: {transit_lines}. "
"Use these planetary themes to colour the tip — "
"keep it grounded and actionable, not predictive or fatalistic. "
"(Always write the tip in English.)"
)
return self._make_output(inp, prompt, snapshot)

110
ml/agents/tarot.py Normal file
View File

@@ -0,0 +1,110 @@
"""TAROT agent — three-card draw (situation / action / outcome).
Draws cards deterministically from a daily seed so the reading stays
stable for the day (same cards whether the agent runs at 08:00 or 14:00).
Card meanings are precomputed here and passed as a structured snippet to
the orchestrator, which weaves them into a grounded, actionable tip.
"""
from __future__ import annotations
import hashlib
from typing import ClassVar
from .base import BaseAgent, AgentInput, AgentOutput
from .manifest import AgentManifest
# ---------------------------------------------------------------------------
# Card definitions — Major Arcana only (22 cards, indices 021)
# Each entry: (name, upright_meaning, action_hint)
# ---------------------------------------------------------------------------
_CARDS: list[tuple[str, str, str]] = [
("The Fool", "new beginnings, spontaneity, a leap of faith", "start something without overthinking"),
("The Magician", "skill, willpower, resourcefulness", "use what you already have"),
("The High Priestess","intuition, inner knowing, patience", "listen to what you already sense is true"),
("The Empress", "abundance, creativity, nurturing", "invest energy in something generative"),
("The Emperor", "structure, authority, discipline", "set a boundary or impose order"),
("The Hierophant", "tradition, guidance, shared values", "seek or offer mentorship"),
("The Lovers", "alignment, choice, commitment", "make a decision you have been avoiding"),
("The Chariot", "determination, focus, forward motion", "push through the resistance"),
("Strength", "inner courage, patience, gentle persistence", "stay the course with compassion"),
("The Hermit", "solitude, reflection, inner guidance", "step back and think before acting"),
("Wheel of Fortune", "cycles, turning points, inevitable change", "acknowledge what is shifting around you"),
("Justice", "fairness, truth, cause and effect", "audit a recent decision for its real consequences"),
("The Hanged Man", "pause, surrender, new perspective", "release your grip on the outcome"),
("Death", "endings, transformation, release", "let go of what no longer serves you"),
("Temperance", "balance, moderation, patience", "blend two competing demands"),
("The Devil", "attachment, habit, shadow patterns", "name a loop you are stuck in"),
("The Tower", "sudden disruption, revelation, necessary collapse", "accept the thing that already broke"),
("The Star", "hope, renewal, calm after the storm", "trust that recovery is already underway"),
("The Moon", "uncertainty, illusion, the unconscious", "sit with ambiguity rather than forcing clarity"),
("The Sun", "clarity, vitality, success", "act from your most energised self"),
("Judgement", "reflection, reckoning, a call to rise", "respond to a long-deferred summons"),
("The World", "completion, integration, a cycle closing", "acknowledge what you have finished"),
]
_POSITIONS = ("situation", "action", "outcome")
def _daily_draw(user_id: str, date_str: str) -> list[int]:
"""Return three distinct card indices seeded by (user_id, date)."""
seed = hashlib.sha256(f"{user_id}:{date_str}".encode()).digest()
indices: list[int] = []
offset = 0
while len(indices) < 3:
val = int.from_bytes(seed[offset:offset + 2], "big") % len(_CARDS)
if val not in indices:
indices.append(val)
offset = (offset + 2) % (len(seed) - 1)
return indices
MANIFEST = AgentManifest(
id="tarot",
version="1.0.0",
description="Daily three-card draw (situation/action/outcome) that frames the tip as a symbolic reflection.",
pref_schema={
"type": "object",
"additionalProperties": False,
"properties": {
"enabled": {
"type": "boolean",
"default": True,
"description": "Set false to disable the tarot agent for this user.",
},
},
},
context_schema=[],
required_consents=["data:core"],
output_contract={"type": "snippet", "format": "free_text"},
ttl_sec=3_600 * 6, # stable for 6 h; refreshes mid-day at most twice
silenced_in_contexts=[],
inferred_params=[],
)
class TarotAgent(BaseAgent):
"""Produces a three-card reading as a prompt snippet."""
agent_id: ClassVar[str] = MANIFEST.id
ttl_seconds: ClassVar[int] = MANIFEST.ttl_sec
version: ClassVar[str] = MANIFEST.version
def compute(self, inp: AgentInput) -> AgentOutput:
date_str = inp.now.strftime("%Y-%m-%d")
indices = _daily_draw(inp.user_id, date_str)
reading: list[dict] = []
parts: list[str] = [f"Today's tarot reading ({date_str}):"]
for pos, idx in zip(_POSITIONS, indices):
name, meaning, hint = _CARDS[idx]
reading.append({"position": pos, "card": name, "meaning": meaning, "hint": hint})
parts.append(f" {pos.capitalize()}{name}: {meaning}. Hint: {hint}.")
parts.append(
"Weave these symbolic themes lightly into the tip — "
"ground them in practical, specific action. "
"Do not explain the cards; let their meaning shape the advice."
)
prompt = "\n".join(parts)
snapshot = {"date": date_str, "reading": reading}
return self._make_output(inp, prompt, snapshot)

View File

@@ -13,6 +13,8 @@ from ml.agents.momentum import MomentumAgent
from ml.agents.time_of_day import TimeOfDayAgent from ml.agents.time_of_day import TimeOfDayAgent
from ml.agents.recent_patterns import RecentPatternsAgent from ml.agents.recent_patterns import RecentPatternsAgent
from ml.agents.focus_area import FocusAreaAgent from ml.agents.focus_area import FocusAreaAgent
from ml.agents.tarot import TarotAgent, _daily_draw, _CARDS, _POSITIONS
from ml.agents.stars import StarsAgent, _SWE_AVAILABLE
from ml.agents.registry import get_agent, all_agents from ml.agents.registry import get_agent, all_agents
_NOW = datetime(2026, 5, 1, 9, 0, 0, tzinfo=timezone.utc) # Thursday 09:00 UTC _NOW = datetime(2026, 5, 1, 9, 0, 0, tzinfo=timezone.utc) # Thursday 09:00 UTC
@@ -250,13 +252,102 @@ class TestFocusAreaAgent:
assert all("label" in c and "task_count" in c and "tasks" in c for c in clusters) assert all("label" in c and "task_count" in c and "tasks" in c for c in clusters)
# ── TarotAgent ────────────────────────────────────────────────────────────────
class TestTarotAgent:
agent = TarotAgent()
def test_basic_output(self):
out = self.agent.compute(_inp())
_check_output(out, self.agent)
assert "situation" in out.prompt_text.lower()
assert "action" in out.prompt_text.lower()
assert "outcome" in out.prompt_text.lower()
assert out.signals_snapshot["date"] == "2026-05-01"
assert len(out.signals_snapshot["reading"]) == 3
def test_three_distinct_cards(self):
out = self.agent.compute(_inp())
cards = [r["card"] for r in out.signals_snapshot["reading"]]
assert len(set(cards)) == 3
def test_positions_labelled(self):
out = self.agent.compute(_inp())
positions = [r["position"] for r in out.signals_snapshot["reading"]]
assert positions == list(_POSITIONS)
def test_daily_stability(self):
out1 = self.agent.compute(_inp(now=datetime(2026, 5, 1, 8, 0, 0, tzinfo=timezone.utc)))
out2 = self.agent.compute(_inp(now=datetime(2026, 5, 1, 20, 0, 0, tzinfo=timezone.utc)))
assert out1.signals_snapshot["reading"] == out2.signals_snapshot["reading"]
def test_different_days_different_draw(self):
out1 = self.agent.compute(_inp(now=datetime(2026, 5, 1, 9, 0, 0, tzinfo=timezone.utc)))
out2 = self.agent.compute(_inp(now=datetime(2026, 5, 2, 9, 0, 0, tzinfo=timezone.utc)))
assert out1.signals_snapshot["reading"] != out2.signals_snapshot["reading"]
def test_different_users_different_draw(self):
out1 = self.agent.compute(_inp(user_id="user-A"))
out2 = self.agent.compute(_inp(user_id="user-B"))
assert out1.signals_snapshot["reading"] != out2.signals_snapshot["reading"]
def test_daily_draw_returns_valid_indices(self):
indices = _daily_draw("u1", "2026-05-01")
assert len(indices) == 3
assert len(set(indices)) == 3
assert all(0 <= i < len(_CARDS) for i in indices)
# ── StarsAgent ────────────────────────────────────────────────────────────────
class TestStarsAgent:
agent = StarsAgent()
def test_no_birth_date(self):
out = self.agent.compute(_inp())
_check_output(out, self.agent)
assert out.signals_snapshot.get("no_birth_date") is True
assert "birth date" in out.prompt_text.lower()
@pytest.mark.skipif(not _SWE_AVAILABLE, reason="pyswisseph not installed")
def test_invalid_birth_date(self):
out = self.agent.compute(_inp(agent_prefs={"birth_date": "not-a-date"}))
_check_output(out, self.agent)
assert out.signals_snapshot.get("invalid_birth_date") == "not-a-date"
@pytest.mark.skipif(not _SWE_AVAILABLE, reason="pyswisseph not installed")
def test_with_birth_date(self):
out = self.agent.compute(_inp(agent_prefs={"birth_date": "1990-06-15"}))
_check_output(out, self.agent)
assert "natal" in out.prompt_text.lower()
assert out.signals_snapshot["birth_date"] == "1990-06-15"
assert "natal_sun" in out.signals_snapshot
assert "natal_moon" in out.signals_snapshot
@pytest.mark.skipif(not _SWE_AVAILABLE, reason="pyswisseph not installed")
def test_transit_snapshot_structure(self):
out = self.agent.compute(_inp(agent_prefs={"birth_date": "1985-03-21"}))
snap = out.signals_snapshot
assert "active_transits" in snap
for t in snap["active_transits"]:
assert {"transit_planet", "natal_planet", "aspect", "nature", "orb"} <= t.keys()
def test_swe_unavailable_path(self, monkeypatch):
import ml.agents.stars as stars_mod
monkeypatch.setattr(stars_mod, "_SWE_AVAILABLE", False)
agent = StarsAgent()
out = agent.compute(_inp(agent_prefs={"birth_date": "1990-06-15"}))
_check_output(out, agent)
assert out.signals_snapshot.get("swe_unavailable") is True
# ── Registry ───────────────────────────────────────────────────────────────── # ── Registry ─────────────────────────────────────────────────────────────────
class TestRegistry: class TestRegistry:
def test_all_agents_present(self): def test_all_agents_present(self):
agents = all_agents() agents = all_agents()
ids = {a.agent_id for a in agents} ids = {a.agent_id for a in agents}
assert ids == {"overdue-task", "momentum", "time-of-day", "recent-patterns", "focus-area", "health-vitals"} assert ids == {"overdue-task", "momentum", "time-of-day", "recent-patterns", "focus-area", "health-vitals", "tarot", "stars"}
def test_get_agent(self): def test_get_agent(self):
a = get_agent("momentum") a = get_agent("momentum")

View File

@@ -8,3 +8,4 @@ nats-py>=2.9.0
structlog>=24.1.0 structlog>=24.1.0
sentry-sdk>=2.0.0 sentry-sdk>=2.0.0
mlflow-skinny>=3.1.0 mlflow-skinny>=3.1.0
pyswisseph>=2.10.3.2

View File

@@ -17,17 +17,9 @@ const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
const GOOGLE_REVOKE_URL = 'https://oauth2.googleapis.com/revoke'; const GOOGLE_REVOKE_URL = 'https://oauth2.googleapis.com/revoke';
const GOOGLE_HEALTH_SCOPES = [ const GOOGLE_HEALTH_SCOPES = [
'https://www.googleapis.com/auth/fitness.activity.read', 'https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly',
'https://www.googleapis.com/auth/fitness.body.read', 'https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly',
'https://www.googleapis.com/auth/fitness.sleep.read', 'https://www.googleapis.com/auth/googlehealth.sleep.readonly',
'https://www.googleapis.com/auth/fitness.heart_rate.read',
'https://www.googleapis.com/auth/fitness.nutrition.read',
'https://www.googleapis.com/auth/fitness.location.read',
'https://www.googleapis.com/auth/fitness.blood_glucose.read',
'https://www.googleapis.com/auth/fitness.blood_pressure.read',
'https://www.googleapis.com/auth/fitness.body_temperature.read',
'https://www.googleapis.com/auth/fitness.oxygen_saturation.read',
'https://www.googleapis.com/auth/fitness.reproductive_health.read',
].join(' '); ].join(' ');
// In-memory CSRF state store // In-memory CSRF state store

View File

@@ -2,7 +2,7 @@ import { type Router as ExpressRouter, Router, Response } from 'express';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { logger } from '../logger.js'; import { logger } from '../logger.js';
import { db } from '../db/index.js'; import { db } from '../db/index.js';
import { integrationTokens, tipFeedback, tipViews, tipScores, userPreferences } from '../db/schema.js'; import { tipFeedback, tipViews, tipScores, userPreferences } from '../db/schema.js';
import { eq, and, desc } from 'drizzle-orm'; import { eq, and, desc } from 'drizzle-orm';
import { requireAuth, AuthenticatedRequest } from '../middleware/session.js'; import { requireAuth, AuthenticatedRequest } from '../middleware/session.js';
import { config } from '../config.js'; import { config } from '../config.js';
@@ -144,17 +144,6 @@ router.post('/recommend', requireAuth, async (req: AuthenticatedRequest, res: Re
const dayOfWeek = new Date().getDay(); const dayOfWeek = new Date().getDay();
const { recent_tip: recentTip } = req.body as { recent_tip?: string }; const { recent_tip: recentTip } = req.body as { recent_tip?: string };
const anyToken = await db
.select({ id: integrationTokens.id })
.from(integrationTokens)
.where(eq(integrationTokens.userId, req.userId!))
.limit(1);
if (!anyToken.length) {
res.status(422).json({ error: 'No integrations connected' });
return;
}
const signals = await aggregator.fetchAll(req.userId!); const signals = await aggregator.fetchAll(req.userId!);
const t0 = Date.now(); const t0 = Date.now();

View File

@@ -0,0 +1,166 @@
/**
* Tests for the agent pre-compute scheduler (signals/agent-scheduler.ts).
*
* Key behaviour under test: runCycle calls getEligibleAgentIds per user and
* skips computeAndStore for agents the user hasn't consented to.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
vi.mock('../../logger.js', () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() },
}));
import { logger } from '../../logger.js';
// ── active-user query: db.selectDistinct(...).from(...).where(...) ──────────
let activeUsers: { userId: string }[] = [];
const userWhereMock = vi.fn(async () => activeUsers);
const userFromMock = vi.fn(() => ({ where: userWhereMock }));
const selectDistinctMock = vi.fn(() => ({ from: userFromMock }));
// ── purge: db.delete(...).where(...) ────────────────────────────────────────
const deleteWhereMock = vi.fn(async () => ({}));
const deleteMock = vi.fn(() => ({ where: deleteWhereMock }));
vi.mock('../../db/index.js', () => ({
db: { selectDistinct: selectDistinctMock, delete: deleteMock },
}));
vi.mock('../../db/schema.js', () => ({
agentOutputs: { expiresAt: 'expires_at' },
tipViews: { userId: 'user_id', servedAt: 'served_at' },
}));
vi.mock('drizzle-orm', () => ({
gt: vi.fn(),
lt: vi.fn(),
and: vi.fn(),
eq: vi.fn(),
isNull: vi.fn(),
}));
vi.mock('../../config.js', () => ({ config: { ML_SERVING_URL: 'http://ml' } }));
// ── computeAndStore — tracks which (user, agent) pairs were computed ────────
const computeAndStoreMock = vi.fn(async () => {});
vi.mock('../../routes/agent-outputs.js', () => ({
computeAndStore: computeAndStoreMock,
}));
// ── eligibility — replaceable per test ─────────────────────────────────────
let eligibleIds: Set<string> = new Set();
const getEligibleAgentIdsMock = vi.fn(async (_userId: string) => eligibleIds);
vi.mock('../../profile/eligibility.js', () => ({
getEligibleAgentIds: getEligibleAgentIdsMock,
}));
// ml-serving /health — return a fixed agent list
global.fetch = vi.fn(async () => ({
ok: true,
json: async () => ({ agents: ['overdue-task', 'momentum', 'time-of-day'] }),
})) as unknown as typeof fetch;
beforeEach(() => {
activeUsers = [];
eligibleIds = new Set();
computeAndStoreMock.mockClear();
getEligibleAgentIdsMock.mockClear();
userWhereMock.mockClear();
deleteWhereMock.mockClear();
vi.clearAllMocks();
vi.useFakeTimers();
// restore default mocks after clearAllMocks
userWhereMock.mockImplementation(async () => activeUsers);
getEligibleAgentIdsMock.mockImplementation(async () => eligibleIds);
computeAndStoreMock.mockResolvedValue(undefined);
deleteWhereMock.mockResolvedValue({});
global.fetch = vi.fn(async () => ({
ok: true,
json: async () => ({ agents: ['overdue-task', 'momentum', 'time-of-day'] }),
})) as unknown as typeof fetch;
});
afterEach(() => {
vi.useRealTimers();
});
describe('startAgentPrecomputeScheduler', () => {
it('skips computeAndStore for agents not in the eligibility set', async () => {
activeUsers = [{ userId: 'alice' }];
eligibleIds = new Set(['momentum']); // only momentum consented
const { startAgentPrecomputeScheduler } = await import('../agent-scheduler.js');
startAgentPrecomputeScheduler(60_000);
await vi.advanceTimersByTimeAsync(16_000);
await Promise.resolve();
const computed = computeAndStoreMock.mock.calls.map((c) => c[1]);
expect(computed).toEqual(['momentum']);
expect(computed).not.toContain('overdue-task');
expect(computed).not.toContain('time-of-day');
});
it('skips all agents when eligibility set is empty', async () => {
activeUsers = [{ userId: 'bob' }];
eligibleIds = new Set(); // no consents
const { startAgentPrecomputeScheduler } = await import('../agent-scheduler.js');
startAgentPrecomputeScheduler(60_000);
await vi.advanceTimersByTimeAsync(16_000);
await Promise.resolve();
expect(computeAndStoreMock).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(
expect.objectContaining({ skipped: 3, ok: 0 }),
'agent-scheduler: cycle complete',
);
});
it('computes all agents when all are eligible', async () => {
activeUsers = [{ userId: 'carol' }];
eligibleIds = new Set(['overdue-task', 'momentum', 'time-of-day']);
const { startAgentPrecomputeScheduler } = await import('../agent-scheduler.js');
startAgentPrecomputeScheduler(60_000);
await vi.advanceTimersByTimeAsync(16_000);
await Promise.resolve();
expect(computeAndStoreMock).toHaveBeenCalledTimes(3);
expect(logger.info).toHaveBeenCalledWith(
expect.objectContaining({ ok: 3, skipped: 0 }),
'agent-scheduler: cycle complete',
);
});
it('skips entire user when eligibility check throws', async () => {
activeUsers = [{ userId: 'dave' }];
getEligibleAgentIdsMock.mockRejectedValueOnce(new Error('db timeout'));
const { startAgentPrecomputeScheduler } = await import('../agent-scheduler.js');
startAgentPrecomputeScheduler(60_000);
await vi.advanceTimersByTimeAsync(16_000);
await Promise.resolve();
expect(computeAndStoreMock).not.toHaveBeenCalled();
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.anything(), userId: 'dave' }),
'agent-scheduler: eligibility check failed, skipping user',
);
});
it('checks eligibility independently per user', async () => {
activeUsers = [{ userId: 'u1' }, { userId: 'u2' }];
getEligibleAgentIdsMock.mockImplementation(async (userId: string) =>
userId === 'u1' ? new Set(['momentum']) : new Set(['overdue-task', 'time-of-day']),
);
const { startAgentPrecomputeScheduler } = await import('../agent-scheduler.js');
startAgentPrecomputeScheduler(60_000);
await vi.advanceTimersByTimeAsync(16_000);
await Promise.resolve();
const u1Calls = computeAndStoreMock.mock.calls.filter((c) => c[0] === 'u1').map((c) => c[1]);
const u2Calls = computeAndStoreMock.mock.calls.filter((c) => c[0] === 'u2').map((c) => c[1]);
expect(u1Calls).toEqual(['momentum']);
expect(u2Calls.sort()).toEqual(['overdue-task', 'time-of-day']);
});
});

View File

@@ -15,6 +15,7 @@ import { gt, lt } from 'drizzle-orm';
import { logger } from '../logger.js'; import { logger } from '../logger.js';
import { config } from '../config.js'; import { config } from '../config.js';
import { computeAndStore } from '../routes/agent-outputs.js'; import { computeAndStore } from '../routes/agent-outputs.js';
import { getEligibleAgentIds } from '../profile/eligibility.js';
const FALLBACK_AGENT_IDS = [ const FALLBACK_AGENT_IDS = [
'overdue-task', 'overdue-task',
@@ -67,8 +68,22 @@ async function runCycle(agentIds: string[]): Promise<void> {
let ok = 0; let ok = 0;
let failed = 0; let failed = 0;
let skipped = 0;
for (const userId of userIds) { for (const userId of userIds) {
let eligible: Set<string>;
try {
eligible = await getEligibleAgentIds(userId);
} catch (err: any) {
logger.error({ err, userId }, 'agent-scheduler: eligibility check failed, skipping user');
skipped += agentIds.length;
continue;
}
for (const agentId of agentIds) { for (const agentId of agentIds) {
if (!eligible.has(agentId)) {
skipped++;
continue;
}
try { try {
await computeAndStore(userId, agentId); await computeAndStore(userId, agentId);
ok++; ok++;
@@ -86,7 +101,7 @@ async function runCycle(agentIds: string[]): Promise<void> {
} }
logger.info( logger.info(
{ ok, failed, users: userIds.length, agents: agentIds.length }, { ok, failed, skipped, users: userIds.length, agents: agentIds.length },
'agent-scheduler: cycle complete', 'agent-scheduler: cycle complete',
); );
} }

View File

@@ -7,33 +7,20 @@ import { config } from '../config.js';
import { logger } from '../logger.js'; import { logger } from '../logger.js';
const CACHE_TTL_MS = 5 * 60_000; const CACHE_TTL_MS = 5 * 60_000;
const FIT_AGGREGATE_URL = 'https://www.googleapis.com/fitness/v1/users/me/dataset:aggregate'; const HEALTH_API_BASE = 'https://health.googleapis.com/v4/users/me/dataTypes';
const FIT_SESSIONS_URL = 'https://www.googleapis.com/fitness/v1/users/me/sessions';
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'; const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
const STEP_DAILY_GOAL = 7_000; const STEP_DAILY_GOAL = 7_000;
const SLEEP_GOAL_HOURS = 7; const SLEEP_GOAL_HOURS = 7;
interface FitBucket { // v4 DataPoint shape is a union keyed by data type; we read defensively.
dataset: Array<{ interface DataPoint {
dataSourceId: string; [key: string]: unknown;
point: Array<{ value: Array<{ intVal?: number; fpVal?: number }> }>;
}>;
} }
interface FitAggregateResponse { interface DataPointsResponse {
bucket?: FitBucket[]; dataPoints?: DataPoint[];
} nextPageToken?: string;
interface FitSession {
name: string;
startTimeMillis: string;
endTimeMillis: string;
activityType: number;
}
interface FitSessionsResponse {
session?: FitSession[];
} }
async function refreshGoogleToken( async function refreshGoogleToken(
@@ -66,81 +53,62 @@ async function refreshGoogleToken(
return data.access_token; return data.access_token;
} }
function todayMidnightMs(): number { function todayMidnightIso(): string {
const d = new Date(); const d = new Date();
d.setHours(0, 0, 0, 0); d.setHours(0, 0, 0, 0);
return d.getTime(); return d.toISOString();
} }
function yesterdayIso(): string { function yesterdayIso(): string {
return new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); return new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
} }
async function fetchAggregates( async function fetchDataPoints(
token: string, token: string,
startMs: number, dataType: string,
endMs: number, filter: string,
): Promise<FitAggregateResponse> { ): Promise<DataPoint[]> {
const res = await fetch(FIT_AGGREGATE_URL, { const url = new URL(`${HEALTH_API_BASE}/${dataType}/dataPoints`);
method: 'POST', url.searchParams.set('filter', filter);
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
aggregateBy: [
{ dataTypeName: 'com.google.step_count.delta' },
{ dataTypeName: 'com.google.calories.expended' },
{ dataTypeName: 'com.google.active_minutes' },
{ dataTypeName: 'com.google.heart_rate.bpm' },
],
bucketByTime: { durationMillis: endMs - startMs },
startTimeMillis: String(startMs),
endTimeMillis: String(endMs),
}),
});
if (!res.ok) throw new Error(`Fit aggregate: ${res.status}`);
return res.json() as Promise<FitAggregateResponse>;
}
async function fetchSleepSessions(token: string): Promise<FitSessionsResponse> {
const url = new URL(FIT_SESSIONS_URL);
url.searchParams.set('activityType', '72');
url.searchParams.set('startTime', yesterdayIso());
url.searchParams.set('endTime', new Date().toISOString());
const res = await fetch(url.toString(), { const res = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}); });
if (!res.ok) throw new Error(`Fit sessions: ${res.status}`); if (!res.ok) throw new Error(`health ${dataType}: ${res.status}`);
return res.json() as Promise<FitSessionsResponse>; const data = (await res.json()) as DataPointsResponse;
return data.dataPoints ?? [];
} }
function extractMetric( // Defensive numeric reader — probes likely field names in a v4 DataPoint payload.
bucket: FitBucket, function readNumber(point: DataPoint, paths: string[][]): number {
dataTypeName: string, for (const path of paths) {
valueKey: 'intVal' | 'fpVal', let cur: unknown = point;
): number { for (const key of path) {
for (const ds of bucket.dataset) { if (cur && typeof cur === 'object' && key in (cur as object)) {
if (!ds.dataSourceId.includes(dataTypeName.replace('com.google.', '').replace('.', '_'))) continue; cur = (cur as Record<string, unknown>)[key];
for (const pt of ds.point) { } else {
const v = pt.value[0]; cur = undefined;
if (v) return valueKey === 'intVal' ? (v.intVal ?? 0) : (v.fpVal ?? 0); break;
}
} }
if (typeof cur === 'number') return cur;
} }
return 0; return 0;
} }
function extractAnyMetric( function readString(point: DataPoint, paths: string[][]): string | undefined {
bucket: FitBucket, for (const path of paths) {
typeSuffix: string, let cur: unknown = point;
valueKey: 'intVal' | 'fpVal', for (const key of path) {
): number { if (cur && typeof cur === 'object' && key in (cur as object)) {
for (const ds of bucket.dataset) { cur = (cur as Record<string, unknown>)[key];
if (!ds.dataSourceId.includes(typeSuffix)) continue; } else {
const pt = ds.point[0]; cur = undefined;
if (pt?.value[0]) { break;
const v = pt.value[0]; }
return valueKey === 'intVal' ? (v.intVal ?? 0) : (v.fpVal ?? 0);
} }
if (typeof cur === 'string') return cur;
} }
return 0; return undefined;
} }
export class GoogleHealthSignalSource implements SignalSource { export class GoogleHealthSignalSource implements SignalSource {
@@ -187,57 +155,76 @@ export class GoogleHealthSignalSource implements SignalSource {
} }
try { try {
const startMs = todayMidnightMs(); const dayStartIso = todayMidnightIso();
const endMs = Date.now(); const dayEndIso = new Date().toISOString();
const yIso = yesterdayIso();
const [aggData, sleepData] = await Promise.all([ const stepsFilter = `steps.interval.start_time >= "${dayStartIso}" AND steps.interval.start_time < "${dayEndIso}"`;
fetchAggregates(token, startMs, endMs), const caloriesFilter = `total_calories.interval.start_time >= "${dayStartIso}" AND total_calories.interval.start_time < "${dayEndIso}"`;
fetchSleepSessions(token), const hrFilter = `heart_rate.sample_time.physical_time >= "${dayStartIso}" AND heart_rate.sample_time.physical_time < "${dayEndIso}"`;
const sleepFilter = `sleep.interval.start_time >= "${yIso}" AND sleep.interval.start_time < "${dayEndIso}"`;
const [stepsPts, caloriesPts, hrPts, sleepPts] = await Promise.all([
fetchDataPoints(token, 'steps', stepsFilter),
fetchDataPoints(token, 'total-calories', caloriesFilter),
fetchDataPoints(token, 'heart-rate', hrFilter),
fetchDataPoints(token, 'sleep', sleepFilter),
]); ]);
const bucket = aggData.bucket?.[0]; // One-time peek at raw shape so we can refine field paths after first real OAuth.
logger.debug(
{ userId, samples: { stepsPts: stepsPts.slice(0, 1), caloriesPts: caloriesPts.slice(0, 1), hrPts: hrPts.slice(0, 1), sleepPts: sleepPts.slice(0, 1) } },
'google-health: v4 dataPoints sample',
);
const signals: Signal[] = []; const signals: Signal[] = [];
const now = new Date().toISOString(); const now = new Date().toISOString();
if (bucket) { const steps = stepsPts.reduce(
// Steps (sum, p) => sum + readNumber(p, [['steps', 'count'], ['count']]),
const steps = extractAnyMetric(bucket, 'step_count', 'intVal'); 0,
const stepGoalPct = Math.round((steps / STEP_DAILY_GOAL) * 100); );
signals.push({ const stepGoalPct = Math.round((steps / STEP_DAILY_GOAL) * 100);
id: `google-health:steps`, signals.push({
source: 'google-health', id: `google-health:steps`,
kind: 'health', source: 'google-health',
content: `${steps.toLocaleString()} steps today (${stepGoalPct}% of ${STEP_DAILY_GOAL.toLocaleString()} goal)`, kind: 'health',
metadata: { dataType: 'steps' }, content: `${steps.toLocaleString()} steps today (${stepGoalPct}% of ${STEP_DAILY_GOAL.toLocaleString()} goal)`,
features: { metadata: { dataType: 'steps' },
step_count: steps, features: {
step_goal_pct: stepGoalPct, step_count: steps,
step_goal: STEP_DAILY_GOAL, step_goal_pct: stepGoalPct,
below_step_goal: steps < STEP_DAILY_GOAL, step_goal: STEP_DAILY_GOAL,
}, below_step_goal: steps < STEP_DAILY_GOAL,
timestamp: now, },
}); timestamp: now,
});
// Calories + active minutes const calories = Math.round(
const calories = Math.round(extractAnyMetric(bucket, 'calories', 'fpVal')); caloriesPts.reduce(
const activeMinutes = extractAnyMetric(bucket, 'active_minutes', 'intVal'); (sum, p) =>
signals.push({ sum + readNumber(p, [['totalCalories', 'kilocalories'], ['kilocalories'], ['energy', 'kilocalories']]),
id: `google-health:activity`, 0,
source: 'google-health', ),
kind: 'health', );
content: `${activeMinutes} active minutes, ${calories} calories burned today`, signals.push({
metadata: { dataType: 'activity' }, id: `google-health:activity`,
features: { source: 'google-health',
active_minutes: activeMinutes, kind: 'health',
calories_burned: calories, content: `${calories} calories burned today`,
sedentary: activeMinutes < 20, metadata: { dataType: 'activity' },
}, features: {
timestamp: now, calories_burned: calories,
}); },
timestamp: now,
});
// Heart rate if (hrPts.length > 0) {
const bpm = Math.round(extractAnyMetric(bucket, 'heart_rate', 'fpVal')); const hrValues = hrPts
if (bpm > 0) { .map((p) => readNumber(p, [['heartRate', 'beatsPerMinute'], ['beatsPerMinute']]))
.filter((v) => v > 0);
if (hrValues.length > 0) {
const bpm = Math.round(hrValues.reduce((a, b) => a + b, 0) / hrValues.length);
signals.push({ signals.push({
id: `google-health:heart_rate`, id: `google-health:heart_rate`,
source: 'google-health', source: 'google-health',
@@ -250,29 +237,34 @@ export class GoogleHealthSignalSource implements SignalSource {
} }
} }
// Sleep — find the most recent sleep session if (sleepPts.length > 0) {
if (sleepData.session?.length) { const sleepSessions = sleepPts
const sorted = [...sleepData.session].sort( .map((p) => ({
(a, b) => Number(b.endTimeMillis) - Number(a.endTimeMillis), start: readString(p, [['sleep', 'interval', 'startTime'], ['interval', 'startTime'], ['startTime']]),
); end: readString(p, [['sleep', 'interval', 'endTime'], ['interval', 'endTime'], ['endTime']]),
const last = sorted[0]!; }))
const durationMs = Number(last.endTimeMillis) - Number(last.startTimeMillis); .filter((s): s is { start: string; end: string } => !!s.start && !!s.end)
const sleepHours = Math.round((durationMs / 3_600_000) * 10) / 10; .sort((a, b) => Date.parse(b.end) - Date.parse(a.end));
const belowGoal = sleepHours < SLEEP_GOAL_HOURS; const last = sleepSessions[0];
signals.push({ if (last) {
id: `google-health:sleep`, const durationMs = Date.parse(last.end) - Date.parse(last.start);
source: 'google-health', const sleepHours = Math.round((durationMs / 3_600_000) * 10) / 10;
kind: 'health', const belowGoal = sleepHours < SLEEP_GOAL_HOURS;
content: `${sleepHours}h sleep last night (${belowGoal ? 'below' : 'meets'} ${SLEEP_GOAL_HOURS}h goal)`, signals.push({
metadata: { dataType: 'sleep', sessionName: last.name }, id: `google-health:sleep`,
features: { source: 'google-health',
sleep_hours: sleepHours, kind: 'health',
sleep_goal_hours: SLEEP_GOAL_HOURS, content: `${sleepHours}h sleep last night (${belowGoal ? 'below' : 'meets'} ${SLEEP_GOAL_HOURS}h goal)`,
sleep_deficit_hours: Math.max(0, SLEEP_GOAL_HOURS - sleepHours), metadata: { dataType: 'sleep' },
below_sleep_goal: belowGoal, features: {
}, sleep_hours: sleepHours,
timestamp: now, sleep_goal_hours: SLEEP_GOAL_HOURS,
}); sleep_deficit_hours: Math.max(0, SLEEP_GOAL_HOURS - sleepHours),
below_sleep_goal: belowGoal,
},
timestamp: now,
});
}
} }
this.cache.set(userId, { signals, fetchedAt: Date.now() }); this.cache.set(userId, { signals, fetchedAt: Date.now() });