Compare commits
4 Commits
41302d9f36
...
ad6747c242
| Author | SHA1 | Date | |
|---|---|---|---|
| ad6747c242 | |||
| 305eeae38b | |||
| 5d43339616 | |||
| d454a0a8bf |
52
CLAUDE.md
52
CLAUDE.md
@@ -78,7 +78,7 @@ docs/ architecture notes, ADRs, API specs
|
||||
|
||||
## AI stack
|
||||
|
||||
oO generates tips with an LLM and ranks them with a bandit. All LLM calls route through **LiteLLM** at `llm.alogins.net` using model aliases — swapping models is a config change, not a code change.
|
||||
oO generates tips through a multi-agent pipeline (ADR-0013): pre-compute agents emit prompt snippets, an orchestrator LLM assembles them into one tip. All LLM calls route through **LiteLLM** at `llm.alogins.net` using model aliases — swapping models is a config change, not a code change.
|
||||
|
||||
| Alias | Model | Used by |
|
||||
|-------|-------|---------|
|
||||
@@ -90,33 +90,57 @@ Env vars: `LITELLM_URL` (prod `https://llm.alogins.net`), `OLLAMA_URL` (Agap hos
|
||||
|
||||
Ollama and LiteLLM are **shared Agap services**, not oO services — they live in `agap_git/openai/docker-compose.yml` along with langfuse (observability). oO never starts them; ml-serving just calls the alias.
|
||||
|
||||
**LLM tip generation pipeline:**
|
||||
1. `ml/features/context.py` assembles user signals → structured prompt context
|
||||
2. `POST /generate` in `ml/serving` calls LiteLLM → returns `TipCandidate[]`
|
||||
3. Bandit policy in `ml/serving` scores + ranks candidates
|
||||
4. Best candidate returned as tip; reaction closes the online reward loop
|
||||
**Multi-agent tip generation pipeline (ADR-0013):**
|
||||
1. Pre-compute agents (`ml/agents/<id>/`) run on a schedule, each emitting a snippet into `agent_outputs` with a per-agent TTL
|
||||
2. On request, `recommender` (TS) loads the eligible agent set (registry-driven, ADR-0014) and pulls the freshest non-expired snippets
|
||||
3. `POST /recommend` in `ml/serving` assembles the orchestrator prompt (`v4-orchestrator`) and calls LiteLLM via the `tip-generator` alias
|
||||
4. Returned tip is logged in `tip_scores` with the contributing agent set; reaction is logged for observability (no bandit reward loop)
|
||||
|
||||
## Current phase
|
||||
|
||||
**M1 shipped (core + admin). M2 (AI tips) in progress.** See `README.md` for the phase roadmap and `docs/architecture/` for diagrams. Work is tracked as Gitea milestones + issues on `alvis/oO`.
|
||||
|
||||
Recent completions (M1 add-on):
|
||||
- ADR-0012 — ε-greedy v2 promotion (profile features, D=12) — 2026-04-26
|
||||
- Offline sim framework + MLflow integration — shipped in M1 add-on
|
||||
- Token-based admin auth for Playwright/CI — secured auth boundary
|
||||
Recent completions:
|
||||
- ADR-0013 — multi-agent recommendation: pre-computed agent snippets + orchestrator LLM (replaces ε-greedy bandit) — 2026-05-01
|
||||
- LLM context assembler + tip generation scaffold (#79, #88)
|
||||
- Model benchmarking for tip generation (#93, #95)
|
||||
- Admin UX refinements: feedback consolidation, settings placement (#100–102)
|
||||
- ADR-0012 — ε-greedy v2 (D=12) — 2026-04-26 (now superseded by ADR-0013)
|
||||
- ADR-0014 steps 1–6: unified Profile schema + backfill, manifest plumbing, `/api/profile` read-through, registry-driven eligibility filter, inference framework + time-of-day migration — 2026-05-05
|
||||
|
||||
Active work (M2):
|
||||
- ADR-0014 step 7 — per-agent inference: focus-area (#113), momentum (#114), overdue-task (#115), recent-patterns (#116)
|
||||
- ADR-0014 step 8 — drop `users.consentGiven` column
|
||||
- Signal abstraction for multi-source support (#78)
|
||||
- Per-user feature freshness SLAs (#61, ADR-0011 phase B)
|
||||
- LLM context assembler + tip generation scaffold (#79, #88)
|
||||
- Model benchmarking for tip generation (#93)
|
||||
- Admin UX refinements: feedback consolidation, settings placement (#100–102)
|
||||
|
||||
## ADR-0014 endpoint map (as of step 6)
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|----------|---------|
|
||||
| `GET /api/profile` | Read-through: user globals + prefs (by scope) + consents + contexts |
|
||||
| `PATCH /api/profile/prefs/:scope` | Upsert user_preferences rows (source='user') |
|
||||
| `PATCH /api/profile/consents` | Grant / revoke consent keys |
|
||||
| `PATCH /api/profile/contexts` | Create / activate / deactivate named contexts |
|
||||
| `GET /api/agents/registry` | Manifest list (proxy to ml/serving; 60 s cache) |
|
||||
| `POST /api/agents/:agentId/compute` | Internal: run agent compute for (user, agent) |
|
||||
| `POST /agents/{agent_id}/infer` *(ml/serving)* | Run inference framework → `{inferred_prefs}` |
|
||||
|
||||
## Inference framework (ADR-0014 §3)
|
||||
|
||||
Lives in `ml/agents/inference/`. `run_inference(manifest, history)` evaluates all `InferredParam` entries in the manifest and returns `{key: value}`. Rules:
|
||||
- Below `min_history` → emit `cold_start_default`
|
||||
- `infer()` error → emit `cold_start_default` (never crashes)
|
||||
- Results written to `user_preferences` with `source='inferred'`; keys with `source='user'` are never overwritten
|
||||
|
||||
Time-of-day agent (`1.1.0`) is the proof agent (#112): infers `preferred_hour` (mode done-hour) and reads `quiet_start`/`quiet_end` from prefs.
|
||||
|
||||
## What NOT to do
|
||||
|
||||
- Don't copy Todoist's data into our DB. Store the OAuth token + computed features/derivatives we need, fetch raw on demand.
|
||||
- Don't implement auth by hand. Auth.js behind an OIDC-shaped boundary (ADR-0004); swap to a dedicated OIDC provider only when mobile ships.
|
||||
- Don't hardwire a recommender. The contract is `POST /recommend → {tip}`. Swap internals (bandit, LLM, hybrid), keep contract.
|
||||
- Don't hardwire a recommender. The contract is `POST /recommend → {tip}`. Swap internals (multi-agent orchestrator today, future LLM/hybrid variants), keep contract.
|
||||
- Don't hardcode the agent list. The orchestrator is registry-driven (ADR-0014); adding/removing an agent is a manifest change in `ml/agents/<id>/`, never a recommender edit.
|
||||
- Don't replace a policy in one step. New policies deploy shadow-first; promoted only after offline + online agreement with the incumbent (ADR-0002).
|
||||
- Don't over-split processes. Extract a service when pressure demands it, not in anticipation (ADR-0003).
|
||||
- Don't call LLMs directly from application code. All LLM calls go through `ml/serving` (Python) via `LITELLM_URL`. The TS recommender never holds a model name.
|
||||
|
||||
41
README.md
41
README.md
@@ -69,7 +69,7 @@ docs/ architecture, adr, api
|
||||
|
||||
## AI stack
|
||||
|
||||
oO is AI-native: the recommender's job is to **rank**, not to write. An LLM generates candidate tips from the user's context; the bandit picks the best one.
|
||||
oO is AI-native. Domain-specialized agents pre-compute snippets describing the user's state from one angle each; an orchestrator LLM reasons over the assembled snippets and produces one tip (ADR-0013). The orchestrator iterates a registry, not a hardcoded list (ADR-0014) — adding an agent is a manifest change, nothing else.
|
||||
|
||||
### Three-tier layout
|
||||
|
||||
@@ -79,25 +79,28 @@ oO is AI-native: the recommender's job is to **rank**, not to write. An LLM gene
|
||||
| Routing | **LiteLLM** | Unified OpenAI-compatible API; model aliases; cloud fallback | `llm.alogins.net` (Agap shared) |
|
||||
| Testing | **OpenWebUI** | Prompt iteration, model comparison, manual evals | `ai.alogins.net` (Agap shared) |
|
||||
|
||||
### Tip generation pipeline (Phase 2 target)
|
||||
### Tip generation pipeline (ADR-0013, M2)
|
||||
|
||||
```
|
||||
User signals ──▶ Context assembler ──▶ LiteLLM ──▶ Ollama (local)
|
||||
(tasks, calendar, (ml/features/) (routing) or cloud fallback
|
||||
patterns, time)
|
||||
User signals Pre-compute agents (every 15 min)
|
||||
(tasks, calendar, ──▶ ml/agents/{overdue-task, momentum, ──▶ agent_outputs
|
||||
patterns, time) time-of-day, recent-patterns, (per-agent TTL)
|
||||
focus-area, ...}
|
||||
│
|
||||
Eligibility filter: required consents + │
|
||||
active context + per-user prefs (ADR-0014) ◀──┘
|
||||
▼
|
||||
N typed TipCandidates
|
||||
{content, kind, model,
|
||||
prompt_version, confidence}
|
||||
Orchestrator prompt (`v4-orchestrator`)
|
||||
= global prefs + active context + snippets
|
||||
▼
|
||||
Bandit policy (ml/serving)
|
||||
scores + ranks candidates
|
||||
LiteLLM ──▶ Ollama (local) / cloud fallback
|
||||
▼
|
||||
Best tip shown
|
||||
Tip shown to user
|
||||
▼
|
||||
User reaction (done / snooze / dismiss + dwell)
|
||||
▼
|
||||
Online bandit update + prompt_version tracking
|
||||
Logged to tip_feedback for observability
|
||||
(no online ML reward loop — see ADR-0013)
|
||||
```
|
||||
|
||||
**Why LiteLLM as gateway:** All LLM calls use a single `LITELLM_URL` env var. Swapping from qwen2.5 to llama3.2, or routing a fraction to Claude for A/B, is a config change in LiteLLM — zero code change in oO. The model name in `tip_scores` tells you exactly which model produced each tip.
|
||||
@@ -194,6 +197,20 @@ oO is ML-heavy. Without a cockpit, every model change ships blind. This console
|
||||
### Phase 2 — AI tips + multi-source signals *(M2)* in progress
|
||||
Goal: tips are AI-generated from user context, not just raw Todoist tasks. Multiple signal sources feed a generalized pipeline. Research-intensive milestone.
|
||||
|
||||
**Architectural shift (mid-M2):** the bandit-ranks-LLM-candidates design from earlier in M2 was replaced with a multi-agent pipeline (ADR-0013): pre-compute agents emit prompt snippets, an orchestrator LLM produces the tip directly. ADR-0014 layers a unified Profile + agent registry + auto-inference framework on top so the system generalizes cleanly to N agents.
|
||||
|
||||
**Multi-agent recommendation (ADR-0013, shipped):**
|
||||
- [x] `agent_outputs` table + per-agent TTL caching
|
||||
- [x] Five initial agents: `overdue-task`, `momentum`, `time-of-day`, `recent-patterns`, `focus-area`
|
||||
- [x] Agent pre-compute scheduler
|
||||
- [x] Orchestrator cutover — recommender calls `ml/serving` with snippet list, no bandit scoring
|
||||
- [x] Bandit endpoints + shadow policy machinery removed
|
||||
|
||||
**Unified Profile + agent registry (ADR-0014, in progress):**
|
||||
- [ ] Unified Profile model: prefs, contexts, consents + manifest plumbing + orchestrator cutover (#30)
|
||||
- [ ] Shared context-inference framework (#111)
|
||||
- [ ] Per-agent auto-inference: `time-of-day` (#112), `focus-area` (#113), `momentum` (#114), `overdue-task` (#115), `recent-patterns` (#116)
|
||||
|
||||
**AI infrastructure (unblock everything else):**
|
||||
- [ ] `ai` compose profile — Ollama + LiteLLM for local dev; env vars `OLLAMA_URL` / `LITELLM_URL` (#86)
|
||||
- [ ] AI gateway — wire `ml/serving` to LiteLLM; model aliases `tip-generator` + `embedder` (#87)
|
||||
|
||||
File diff suppressed because one or more lines are too long
230
docs/adr/0014-unified-profile-and-agent-registry.md
Normal file
230
docs/adr/0014-unified-profile-and-agent-registry.md
Normal file
@@ -0,0 +1,230 @@
|
||||
# ADR-0014 — Unified Profile model + agent registry
|
||||
|
||||
**Status:** Proposed
|
||||
**Date:** 2026-05-05
|
||||
**Issues:** #30, #111, #112, #113, #114, #115, #116
|
||||
**Supersedes (data model):** ADR-0013 (the agent set stands; this ADR replaces the implicit assumption that prefs/contexts/consents are hardcoded on `users`).
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0013 introduced the multi-agent pipeline: N pre-compute agents emit
|
||||
prompt snippets, an orchestrator LLM assembles them into a tip. The ADR
|
||||
specified the `agent_outputs` table and the orchestrator contract, but
|
||||
left several questions open:
|
||||
|
||||
1. **Where do user preferences live?** `users.consentGiven` is a single
|
||||
boolean. There is no place for quiet hours, tone, allowed tip kinds,
|
||||
or per-integration consent. Each new preference would mean another
|
||||
typed column on `users` — and worse, every new agent needs its own
|
||||
tunable parameters (focus areas, momentum baseline, lateness tolerance)
|
||||
that are clearly per-agent state, not global user state.
|
||||
2. **How are agents discovered?** The orchestrator currently iterates a
|
||||
hardcoded list. Adding an agent means touching the recommender, the
|
||||
admin UI, and the prefs schema in three places.
|
||||
3. **How does context (work / home / vacation) interact with agents?**
|
||||
Some agents should be silenced in some contexts. There is no model.
|
||||
4. **How is per-user agent configuration learned?** Issues #112–#116
|
||||
each want to auto-infer parameters (quiet hours, focus areas, etc.)
|
||||
from history. Without a shared substrate they each reinvent storage,
|
||||
recompute cadence, and cold-start fallback.
|
||||
|
||||
The current ADR-0013 design works for five agents. It will not work for
|
||||
twenty without becoming a tangle.
|
||||
|
||||
## Decision
|
||||
|
||||
Three changes, designed to compose:
|
||||
|
||||
### 1. Agents are plugins with declared schemas
|
||||
|
||||
Every agent ships a manifest (Python, lives next to its code in
|
||||
`ml/agents/<id>/manifest.py`):
|
||||
|
||||
```python
|
||||
class AgentManifest:
|
||||
id: str # 'time-of-day'
|
||||
version: str # bump invalidates cached outputs + inferences
|
||||
pref_schema: dict # JSON Schema for user-tunable knobs
|
||||
context_schema: list[str] # signals it reads, e.g. ['todoist.tasks']
|
||||
required_consents: list[str] # ['data:todoist', 'agent:time-of-day']
|
||||
output_contract: dict # snippet shape (free text + optional tags)
|
||||
ttl_sec: int # snippet freshness for agent_outputs
|
||||
inferred_params: list[InferredParam] # see §3
|
||||
```
|
||||
|
||||
The manifest is the **single point of registration**. The orchestrator,
|
||||
admin UI, and inference framework all read from it. Adding an agent is
|
||||
adding one directory in `ml/agents/` — no edits elsewhere.
|
||||
|
||||
A `GET /api/agents/registry` endpoint (TS recommender → Python proxy)
|
||||
exposes manifests so the admin app can auto-render configuration UI from
|
||||
each `pref_schema`.
|
||||
|
||||
### 2. Unified Profile data model
|
||||
|
||||
Three new tables replace the implicit "fields-on-users" pattern.
|
||||
`users.consentGiven` collapses into `user_consents` (one row,
|
||||
`consent_key='data:core'`); existing data migrates in a single
|
||||
backfill.
|
||||
|
||||
```sql
|
||||
-- Hybrid: typed columns where stable, KV where open-ended.
|
||||
-- Stable globals stay on users (added in this ADR):
|
||||
ALTER TABLE users ADD COLUMN tone TEXT; -- 'direct'|'gentle'|'motivational'
|
||||
ALTER TABLE users ADD COLUMN tip_kinds_json TEXT; -- JSON: allowed tip kinds
|
||||
|
||||
-- Open-ended per-agent prefs land here:
|
||||
CREATE TABLE user_preferences (
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
scope TEXT NOT NULL, -- 'orchestrator' | 'agent:<id>'
|
||||
key TEXT NOT NULL, -- e.g. 'quietStart', 'focusAreas'
|
||||
value_json TEXT NOT NULL, -- agent validates against its pref_schema on read
|
||||
updated_at TEXT NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT 'user', -- 'user' | 'inferred'
|
||||
PRIMARY KEY (user_id, scope, key)
|
||||
);
|
||||
|
||||
CREATE TABLE user_consents (
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
consent_key TEXT NOT NULL, -- 'data:todoist' | 'data:calendar' | 'agent:focus-area'
|
||||
granted_at TEXT NOT NULL,
|
||||
revoked_at TEXT, -- null = currently active
|
||||
PRIMARY KEY (user_id, consent_key)
|
||||
);
|
||||
|
||||
CREATE TABLE user_contexts (
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
name TEXT NOT NULL, -- 'work' | 'home' | 'vacation' | user-named
|
||||
active INTEGER NOT NULL DEFAULT 0, -- boolean
|
||||
schedule_json TEXT, -- optional: when this context is active
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, name)
|
||||
);
|
||||
```
|
||||
|
||||
Why hybrid (typed for stable globals, KV for per-agent):
|
||||
|
||||
- `tone` and allowed tip kinds are referenced by every recommendation —
|
||||
putting them in JSON imposes a parse on every read.
|
||||
- Per-agent prefs are open-ended (each agent declares its own keys) and
|
||||
validated on read against the agent's `pref_schema`, so KV is correct.
|
||||
|
||||
`user_preferences.source = 'user' | 'inferred'` keeps explicit user
|
||||
overrides distinguishable from inferred values (the inference framework
|
||||
never overwrites a `source='user'` row).
|
||||
|
||||
`user_contexts` ships in this ADR with **manual toggle only**.
|
||||
Auto-inference per agent type is tracked in #112–#116; cross-agent
|
||||
calendar/geo inference is out of scope.
|
||||
|
||||
### 3. Shared context-inference framework
|
||||
|
||||
Each `InferredParam` in a manifest declares:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class InferredParam:
|
||||
key: str # 'quietStart'
|
||||
ttl_sec: int # how often to recompute
|
||||
cold_start_default: Any # value used until enough history exists
|
||||
min_history: int # event count threshold
|
||||
infer: Callable[[UserHistory], Any] # pure function
|
||||
```
|
||||
|
||||
The framework (`ml/agents/inference/`) owns:
|
||||
|
||||
- Scheduling (recomputes per-param via the existing pre-compute scheduler).
|
||||
- Reading history from `tip_views` / `tip_feedback` / `agent_outputs`.
|
||||
- Writing results to `user_preferences` with `source='inferred'`.
|
||||
- Cold-start: returns `cold_start_default` until `min_history` is met.
|
||||
- Versioning: bumping `agent.version` invalidates inferred rows for that agent.
|
||||
- Observability: structured log per recompute (window size, output diff, latency).
|
||||
|
||||
Each per-agent issue (#112–#116) implements only its `infer()` functions;
|
||||
everything else is the framework.
|
||||
|
||||
## Read-through API
|
||||
|
||||
Stays small as N grows because every endpoint is registry-driven:
|
||||
|
||||
```
|
||||
GET /api/profile → { user, prefs (grouped by scope), contexts, consents, agents[] }
|
||||
PATCH /api/profile/prefs/:scope → upserts user_preferences rows (source='user')
|
||||
PATCH /api/profile/consents → grant/revoke
|
||||
PATCH /api/profile/contexts → activate/deactivate / create
|
||||
GET /api/agents/registry → manifests; admin UI auto-renders forms from pref_schema
|
||||
```
|
||||
|
||||
`GET /api/profile` is the read-through used by `ml/serving` and the web
|
||||
client; it's the single endpoint each consumer calls instead of reading
|
||||
the DB directly.
|
||||
|
||||
## Orchestrator flow under this ADR
|
||||
|
||||
```
|
||||
1. Load Profile = { user, prefs, active context, consents } via /api/profile.
|
||||
2. From agent registry, filter eligible agents:
|
||||
- required consents granted
|
||||
- not silenced by active context (declared per-agent)
|
||||
- enabled in user_preferences (default: enabled)
|
||||
3. Pull latest non-expired agent_outputs for the eligible set.
|
||||
4. Build orchestrator prompt:
|
||||
- global prefs (tone, allowed tip kinds)
|
||||
- active context name as hint
|
||||
- agent snippets in eligibility order
|
||||
5. LLM → tip.
|
||||
```
|
||||
|
||||
No hardcoded agent list anywhere in the recommender. The orchestrator
|
||||
prompt template (`v4-orchestrator`) iterates whatever it was handed.
|
||||
|
||||
## Migration plan
|
||||
|
||||
One PR per step; each independently deployable.
|
||||
|
||||
1. **Schema** — add the three tables; add `tone` and `tip_kinds_json` to `users`.
|
||||
2. **Backfill** — write `users.consentGiven` rows into `user_consents` as `data:core`. Keep the column for one release, then drop.
|
||||
3. **Manifest plumbing** — `ml/agents/<id>/manifest.py` for the existing five; `GET /api/agents/registry` proxy.
|
||||
4. **Read-through API** — `/api/profile` + sub-endpoints.
|
||||
5. **Orchestrator cutover** — registry-driven eligibility filter.
|
||||
6. **Inference framework** (#111) — land it; migrate `time-of-day` (#112) as the proof.
|
||||
7. **Per-agent inference** — #113–#116 land independently against the framework.
|
||||
8. **Drop `users.consentGiven`** after one release.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Adding an agent = one directory. Admin UI, prefs storage, consent
|
||||
storage, and inference all auto-pick-up.
|
||||
- Per-agent state lives next to the agent code; nothing global to edit.
|
||||
- User-controlled prefs and inferred prefs use the same storage but stay
|
||||
distinguishable (`source` column).
|
||||
- Consent revocation is row-level and time-stamped; aligns with the
|
||||
privacy stance in CLAUDE.md ("privacy is a feature, not a phase").
|
||||
- Sets up cleanly for #27 (Calendar) and #28 (Health) — they register
|
||||
their own consent keys without schema changes.
|
||||
|
||||
### Negative / risks
|
||||
|
||||
- **JSON validation on read** for per-agent prefs is later than column
|
||||
typing. Mitigated by validating in the manifest's load function and
|
||||
failing closed (use cold-start default if invalid).
|
||||
- **Two-table reads** for the orchestrator (registry + profile + outputs)
|
||||
add latency. Cached profile read keeps it sub-ms in practice.
|
||||
- **Migration window** during which `users.consentGiven` and
|
||||
`user_consents` both exist. Reads must consult both for one release;
|
||||
writes go to `user_consents` only.
|
||||
- **Auto-inference can mislead.** A wrong-but-confident inferred quiet
|
||||
window silences the user when they want pings. Mitigation: every
|
||||
inferred param is overrideable in admin/settings (`source='user'`
|
||||
takes precedence), and inferences only kick in past their
|
||||
`min_history` threshold.
|
||||
|
||||
## What this does NOT change
|
||||
|
||||
- ADR-0013's agent set, snippet contract, or `agent_outputs` table.
|
||||
- ADR-0011's `userProfileFeatures` (ML-derived features, not user prefs).
|
||||
- ADR-0008's LiteLLM gateway pattern.
|
||||
- The orchestrator prompt template name (`v4-orchestrator`); the assembly
|
||||
rule changes, the contract does not.
|
||||
@@ -25,12 +25,37 @@ Session auth
|
||||
expires_at
|
||||
revoked_at?
|
||||
|
||||
Profile profile
|
||||
user_id (pk)
|
||||
timezone
|
||||
quiet_hours jsonb: [{start,end,days}]
|
||||
contexts jsonb: [{name,predicate}] introduced in Phase 2
|
||||
consents jsonb: {integration: {read,write,retain_days}}
|
||||
User (extended) profile ADR-0014
|
||||
+ tone 'direct' | 'gentle' | 'motivational'
|
||||
+ tip_kinds_json jsonb: allowed tip kinds (stable globals)
|
||||
|
||||
UserPreference profile ADR-0014
|
||||
user_id, scope, key (pk)
|
||||
scope 'orchestrator' | 'agent:<id>'
|
||||
value_json open-ended; agent validates against its pref_schema on read
|
||||
source 'user' | 'inferred' (inferred never overwrites user)
|
||||
updated_at
|
||||
|
||||
UserConsent profile ADR-0014
|
||||
user_id, consent_key (pk)
|
||||
consent_key 'data:todoist' | 'data:calendar' | 'agent:focus-area' | ...
|
||||
granted_at
|
||||
revoked_at? null = currently active
|
||||
|
||||
UserContext profile ADR-0014
|
||||
user_id, name (pk) 'work' | 'home' | 'vacation' | user-named
|
||||
active manual toggle in M2; auto-inference per agent in #112-#116
|
||||
schedule_json? optional: when this context is active
|
||||
created_at
|
||||
|
||||
AgentOutput recommender ADR-0013
|
||||
id (pk)
|
||||
user_id
|
||||
agent_id e.g. 'overdue-task' (matches a manifest)
|
||||
prompt_text snippet for the orchestrator prompt
|
||||
signals_snapshot jsonb: inputs the agent consumed
|
||||
computed_at, expires_at computed_at + manifest.ttl_sec
|
||||
agent_version bump to invalidate cached outputs on logic changes
|
||||
|
||||
Credential integrations
|
||||
user_id
|
||||
@@ -53,10 +78,10 @@ Event events
|
||||
TipInstance recommender
|
||||
tip_id (ulid)
|
||||
user_id
|
||||
policy_name "random" | "bandit.linucb" | "remote:v3"
|
||||
policy_name "v4-orchestrator" (ADR-0013) | legacy bandit names retained for history
|
||||
policy_version
|
||||
candidate_source "todoist" | "advice.library" | ...
|
||||
context_snapshot jsonb: features seen at decision time
|
||||
candidate_source "todoist" | "advice.library" | "agent-orchestrator" | ...
|
||||
context_snapshot jsonb: features + agent snippets seen at decision time
|
||||
tip jsonb: {kind,title,body,source,deep_link,meta}
|
||||
created_at
|
||||
shown_at? set when the client reports render
|
||||
|
||||
@@ -48,6 +48,8 @@ User reactions (done / snooze / dismiss) are events too. They close the loop as
|
||||
- **Feast** for feature store when we get there; homegrown adapter until then (Phase 1 seam).
|
||||
- **MLflow** for model registry and experiment tracking; deployed at `o.alogins.net/mlflow`.
|
||||
- **Auth.js** embedded behind an OIDC-shaped boundary (ADR-0004). Swap to a standalone OIDC provider when mobile ships.
|
||||
- **Multi-agent recommendation** (ADR-0013) — pre-compute agents emit prompt snippets, an orchestrator LLM produces the tip. Replaced the ε-greedy bandit (ADR-0007/0012) for explainability, cold-start, and decoupling generation from selection.
|
||||
- **Registry-driven agents + unified Profile** (ADR-0014) — agents are plugins with declared manifests; per-user prefs, contexts, and per-key consents live in shared tables; auto-inferred parameters share a common framework. Adding an agent is a manifest change.
|
||||
- **k3s** as the first step beyond docker-compose — no "compose → full k8s" cliff.
|
||||
|
||||
## AI stack
|
||||
@@ -59,30 +61,43 @@ All LLM inference routes through **LiteLLM** (`llm.alogins.net`) backed by **Oll
|
||||
|
||||
**OpenWebUI** (`ai.alogins.net`) is the human-facing interface for prompt iteration and model testing during development.
|
||||
|
||||
## Decision flow for a new tip (Phase 2 target)
|
||||
## Decision flow for a new tip (M2, ADR-0013 + ADR-0014)
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────┐
|
||||
│ Pre-compute (every 15 min, per registered agent) │
|
||||
│ ml/agents/<id> → prompt snippet → agent_outputs │
|
||||
│ TTL per manifest; agent_version invalidates │
|
||||
└────────────────────────────────────────────────┘
|
||||
|
||||
client ─► gateway ─► recommender (TS)
|
||||
│
|
||||
├─► profile: GET /api/profile
|
||||
│ (user, prefs, active context, consents)
|
||||
│
|
||||
├─► registry: GET /api/agents/registry
|
||||
│ (manifests; eligibility filter inputs)
|
||||
│
|
||||
├─► outputs: pull freshest non-expired agent_outputs
|
||||
│ for eligible agents (consents granted,
|
||||
│ not silenced by active context, enabled)
|
||||
│
|
||||
▼
|
||||
ml/serving (Python)
|
||||
│
|
||||
├─► context: ml/features/context.py
|
||||
│ (tasks + reactions + time patterns → prompt)
|
||||
├─► assemble: v4-orchestrator prompt
|
||||
│ = global prefs + active context + snippets
|
||||
│
|
||||
├─► generate: LiteLLM → Ollama
|
||||
│ → N TipCandidates {content, kind, model, prompt_version}
|
||||
├─► generate: LiteLLM → Ollama → one tip
|
||||
│
|
||||
├─► score: bandit policy scores each candidate
|
||||
│
|
||||
├─► shadows: shadow policies log picks without serving
|
||||
│
|
||||
└─► persist: tip_scores {candidate, policy, features, latency}
|
||||
◄─ best TipCandidate
|
||||
└─► persist: tip_scores {tip, contributing agents,
|
||||
prompt_version, llm_model, latency}
|
||||
◄─ tip
|
||||
```
|
||||
|
||||
**Phase 1 (shipped M1):** candidates come from Todoist task list, no LLM. The bandit scores tasks directly.
|
||||
**Evolution:**
|
||||
- **Phase 1 (M1):** candidates from Todoist; ε-greedy bandit scored tasks directly (ADR-0007, ADR-0012). Superseded.
|
||||
- **Phase 2 early (M2):** LLM-generated candidates ranked by bandit. Superseded mid-milestone.
|
||||
- **Phase 2 current (M2):** multi-agent pipeline (ADR-0013), registry-driven and registry-extensible (ADR-0014). No bandit; the orchestrator LLM reasons over named agent snippets.
|
||||
|
||||
**Phase 2 (shipped M2):** LLM candidates are generated in parallel with Todoist fetch. Both pools are merged, scored by the bandit, and the winner served. `tip_scores` tracks `prompt_version`, `llm_model`, and `tip_kind` for every row.
|
||||
|
||||
Feedback: `POST /feedback → events.emit(reaction)` → online bandit update + `prompt_version` tracked for A/B analysis.
|
||||
Feedback: `POST /feedback → events.emit(reaction)`. No online ML reward loop (ADR-0013 §Consequences); reactions are logged in `tip_feedback` for observability and potential future supervised learning.
|
||||
|
||||
@@ -26,7 +26,7 @@ User taps "Delete account" in settings → hard confirm → `User.deleted_at` se
|
||||
|
||||
## Scope boundaries
|
||||
|
||||
Each integration declares the scopes it requests and the features it derives. The `Profile.consents` column is the source of truth; a scope removed from consent short-circuits derived-feature computation at the feature store.
|
||||
Each integration and each agent declares the consent keys it requires (`data:todoist`, `agent:focus-area`, ...) in its manifest. The `user_consents` table is the source of truth (per-key rows, revocation is a `revoked_at` write — never a delete, so audits stay clean). A revoked consent short-circuits derived-feature computation at the feature store and removes the dependent agent from the orchestrator's eligible set on the next tip. See ADR-0014.
|
||||
|
||||
## Audit
|
||||
|
||||
|
||||
@@ -15,6 +15,11 @@ class AgentInput:
|
||||
profile: dict[str, float | None] # profile feature values keyed by feature name
|
||||
feedback_history: list[dict] = field(default_factory=list) # [{action, dwell_ms, created_at}, …]
|
||||
now: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
# Per-agent inferred/user prefs loaded from user_preferences (ADR-0014 §3).
|
||||
# Keys match the agent's pref_schema + inferred_params. 'user' source takes
|
||||
# precedence over 'inferred' source; the caller resolves priority before
|
||||
# passing this dict in.
|
||||
agent_prefs: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -2,13 +2,37 @@ from __future__ import annotations
|
||||
from collections import defaultdict
|
||||
from typing import ClassVar
|
||||
from .base import BaseAgent, AgentInput, AgentOutput
|
||||
from .manifest import AgentManifest
|
||||
|
||||
|
||||
MANIFEST = AgentManifest(
|
||||
id="focus-area",
|
||||
version="1.0.0",
|
||||
description="Identifies the most congested project/area in the user's task list.",
|
||||
pref_schema={
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"preferred_areas": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"default": [],
|
||||
"description": "Project / label names to prioritise when multiple areas tie.",
|
||||
},
|
||||
},
|
||||
},
|
||||
context_schema=["todoist.tasks"],
|
||||
required_consents=["data:core", "data:todoist", "agent:focus-area"],
|
||||
output_contract={"type": "snippet", "format": "free_text"},
|
||||
ttl_sec=43_200,
|
||||
)
|
||||
|
||||
|
||||
class FocusAreaAgent(BaseAgent):
|
||||
"""Identifies the most congested project/area in the user's task list."""
|
||||
agent_id: ClassVar[str] = "focus-area"
|
||||
ttl_seconds: ClassVar[int] = 43_200 # 12h
|
||||
version: ClassVar[str] = "1.0.0"
|
||||
agent_id: ClassVar[str] = MANIFEST.id
|
||||
ttl_seconds: ClassVar[int] = MANIFEST.ttl_sec
|
||||
version: ClassVar[str] = MANIFEST.version
|
||||
|
||||
def compute(self, inp: AgentInput) -> AgentOutput:
|
||||
by_project: dict[str, list[dict]] = defaultdict(list)
|
||||
|
||||
9
ml/agents/inference/__init__.py
Normal file
9
ml/agents/inference/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""Shared context-inference framework (ADR-0014 §3, issue #111).
|
||||
|
||||
Each agent's manifest declares InferredParams; this package owns the
|
||||
scheduling contract, history data model, and write path to user_preferences.
|
||||
"""
|
||||
from .framework import run_inference
|
||||
from .history import FeedbackEvent, UserHistory
|
||||
|
||||
__all__ = ["run_inference", "FeedbackEvent", "UserHistory"]
|
||||
59
ml/agents/inference/framework.py
Normal file
59
ml/agents/inference/framework.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""run_inference — core of the context-inference framework (ADR-0014 §3).
|
||||
|
||||
Contract:
|
||||
run_inference(manifest, history) → dict[key, value]
|
||||
|
||||
Semantics:
|
||||
- For each InferredParam in manifest.inferred_params:
|
||||
- If len(history.events) < param.min_history → emit cold_start_default.
|
||||
- Otherwise → call param.infer(history) and emit the result.
|
||||
- Returns {key: value} ready for the caller to persist to user_preferences
|
||||
with source='inferred'.
|
||||
- User overrides (source='user') are handled by the caller's upsert logic;
|
||||
this function has no DB access.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from ..manifest import AgentManifest
|
||||
from .history import UserHistory
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_inference(manifest: AgentManifest, history: UserHistory) -> dict[str, Any]:
|
||||
"""Evaluate all InferredParams for an agent and return {key: inferred_value}."""
|
||||
result: dict[str, Any] = {}
|
||||
n = len(history.events)
|
||||
|
||||
for param in manifest.inferred_params:
|
||||
t0 = time.monotonic()
|
||||
if param.infer is None:
|
||||
result[param.key] = param.cold_start_default
|
||||
continue
|
||||
if n < param.min_history:
|
||||
value = param.cold_start_default
|
||||
source = "cold_start"
|
||||
else:
|
||||
try:
|
||||
value = param.infer(history)
|
||||
source = "inferred"
|
||||
except Exception as exc:
|
||||
log.warning(
|
||||
"inference_error agent=%s param=%s error=%s — using cold_start_default",
|
||||
manifest.id, param.key, exc,
|
||||
)
|
||||
value = param.cold_start_default
|
||||
source = "error_fallback"
|
||||
|
||||
latency_ms = round((time.monotonic() - t0) * 1000, 1)
|
||||
log.info(
|
||||
"inference_param agent=%s param=%s source=%s value=%r history_len=%d latency_ms=%s",
|
||||
manifest.id, param.key, source, value, n, latency_ms,
|
||||
)
|
||||
result[param.key] = value
|
||||
|
||||
return result
|
||||
29
ml/agents/inference/history.py
Normal file
29
ml/agents/inference/history.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""UserHistory — normalised view of a user's feedback events for inference."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
@dataclass
|
||||
class FeedbackEvent:
|
||||
action: str # 'done' | 'dismiss' | 'snooze' | 'helpful' | 'not_helpful'
|
||||
dwell_ms: int | None
|
||||
created_at: str # ISO 8601
|
||||
|
||||
@property
|
||||
def hour(self) -> int:
|
||||
"""Hour of day (0-23) when the feedback was recorded."""
|
||||
try:
|
||||
dt = datetime.fromisoformat(self.created_at.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return 12
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.hour
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserHistory:
|
||||
user_id: str
|
||||
events: list[FeedbackEvent] = field(default_factory=list)
|
||||
70
ml/agents/manifest.py
Normal file
70
ml/agents/manifest.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Agent manifest dataclass (ADR-0014).
|
||||
|
||||
A manifest is the single point of registration for an agent. The orchestrator,
|
||||
admin UI, registry endpoint, and inference framework all read from it. Adding
|
||||
an agent is adding a manifest + agent class — never editing a list elsewhere.
|
||||
|
||||
The manifest lives next to the agent code (each agent module in ml/agents/
|
||||
exposes a module-level `MANIFEST` constant). The registry surfaces both the
|
||||
agent instance and its manifest.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InferredParam:
|
||||
"""One auto-inferred preference key (#111-#116).
|
||||
|
||||
The inference framework owns scheduling, history reads, persistence, and
|
||||
cold-start. Each agent's `inferred_params` list declares what to infer and
|
||||
how, leaving each agent to implement just `infer()`.
|
||||
"""
|
||||
key: str # e.g. 'quietStart'
|
||||
ttl_sec: int # how often to recompute
|
||||
cold_start_default: Any # value used until min_history is met
|
||||
min_history: int # event count threshold
|
||||
# Pure function: given a UserHistory snapshot, return the inferred value.
|
||||
# Typed as a generic callable here; concrete signature lives in the framework.
|
||||
infer: Callable[[Any], Any] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentManifest:
|
||||
"""Declarative description of an agent — see ADR-0014 §1."""
|
||||
id: str # 'time-of-day'
|
||||
version: str # bump invalidates cached outputs + inferences
|
||||
description: str # one-line human summary for admin UI
|
||||
pref_schema: dict # JSON Schema for user-tunable knobs
|
||||
context_schema: list[str] # signals it reads, e.g. ['todoist.tasks']
|
||||
required_consents: list[str] # ['data:todoist', 'agent:time-of-day']
|
||||
output_contract: dict # snippet shape (free text + optional tags)
|
||||
ttl_sec: int # snippet freshness for agent_outputs
|
||||
silenced_in_contexts: list[str] = field(default_factory=list) # active context names that suppress this agent
|
||||
inferred_params: list[InferredParam] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Serialise for the registry endpoint. `inferred_params` drops `infer`
|
||||
(callable) since the wire format only carries metadata."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"version": self.version,
|
||||
"description": self.description,
|
||||
"pref_schema": self.pref_schema,
|
||||
"context_schema": self.context_schema,
|
||||
"required_consents": self.required_consents,
|
||||
"output_contract": self.output_contract,
|
||||
"ttl_sec": self.ttl_sec,
|
||||
"silenced_in_contexts": list(self.silenced_in_contexts),
|
||||
"inferred_params": [
|
||||
{
|
||||
"key": p.key,
|
||||
"ttl_sec": p.ttl_sec,
|
||||
"cold_start_default": p.cold_start_default,
|
||||
"min_history": p.min_history,
|
||||
}
|
||||
for p in self.inferred_params
|
||||
],
|
||||
}
|
||||
@@ -1,13 +1,38 @@
|
||||
from __future__ import annotations
|
||||
from typing import ClassVar
|
||||
from .base import BaseAgent, AgentInput, AgentOutput
|
||||
from .manifest import AgentManifest
|
||||
|
||||
|
||||
MANIFEST = AgentManifest(
|
||||
id="momentum",
|
||||
version="1.0.0",
|
||||
description="Characterises the user's recent engagement trend from profile features.",
|
||||
pref_schema={
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"low_engagement_threshold_pct": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 100,
|
||||
"default": 25,
|
||||
"description": "Completion rate below which momentum hints at low engagement.",
|
||||
},
|
||||
},
|
||||
},
|
||||
context_schema=["profile.features"],
|
||||
required_consents=["data:core", "agent:momentum"],
|
||||
output_contract={"type": "snippet", "format": "free_text"},
|
||||
ttl_sec=21_600,
|
||||
)
|
||||
|
||||
|
||||
class MomentumAgent(BaseAgent):
|
||||
"""Characterises the user's recent engagement trend from profile features."""
|
||||
agent_id: ClassVar[str] = "momentum"
|
||||
ttl_seconds: ClassVar[int] = 21600 # 6h
|
||||
version: ClassVar[str] = "1.0.0"
|
||||
agent_id: ClassVar[str] = MANIFEST.id
|
||||
ttl_seconds: ClassVar[int] = MANIFEST.ttl_sec
|
||||
version: ClassVar[str] = MANIFEST.version
|
||||
|
||||
def compute(self, inp: AgentInput) -> AgentOutput:
|
||||
completion = inp.profile.get("completion_rate_30d")
|
||||
|
||||
@@ -1,13 +1,38 @@
|
||||
from __future__ import annotations
|
||||
from typing import ClassVar
|
||||
from .base import BaseAgent, AgentInput, AgentOutput
|
||||
from .manifest import AgentManifest
|
||||
|
||||
|
||||
MANIFEST = AgentManifest(
|
||||
id="overdue-task",
|
||||
version="1.0.0",
|
||||
description="Reports the user's overdue tasks by count and age.",
|
||||
pref_schema={
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"lateness_tolerance_days": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"default": 0,
|
||||
"description": "Days past due before a task is considered overdue. 0 = the moment it's late.",
|
||||
},
|
||||
},
|
||||
},
|
||||
context_schema=["todoist.tasks"],
|
||||
required_consents=["data:core", "data:todoist", "agent:overdue-task"],
|
||||
output_contract={"type": "snippet", "format": "free_text"},
|
||||
ttl_sec=3600,
|
||||
silenced_in_contexts=["vacation"],
|
||||
)
|
||||
|
||||
|
||||
class OverdueTaskAgent(BaseAgent):
|
||||
"""Reports the user's overdue tasks by count and age."""
|
||||
agent_id: ClassVar[str] = "overdue-task"
|
||||
ttl_seconds: ClassVar[int] = 3600 # 1h — overdue status changes infrequently
|
||||
version: ClassVar[str] = "1.0.0"
|
||||
agent_id: ClassVar[str] = MANIFEST.id
|
||||
ttl_seconds: ClassVar[int] = MANIFEST.ttl_sec
|
||||
version: ClassVar[str] = MANIFEST.version
|
||||
|
||||
def compute(self, inp: AgentInput) -> AgentOutput:
|
||||
overdue = [t for t in inp.tasks if t.get("is_overdue")]
|
||||
|
||||
@@ -3,15 +3,40 @@ from collections import Counter
|
||||
from datetime import datetime, timezone
|
||||
from typing import ClassVar
|
||||
from .base import BaseAgent, AgentInput, AgentOutput
|
||||
from .manifest import AgentManifest
|
||||
|
||||
_SEVEN_DAYS_S = 7 * 86_400
|
||||
|
||||
|
||||
MANIFEST = AgentManifest(
|
||||
id="recent-patterns",
|
||||
version="1.0.0",
|
||||
description="Surfaces the user's reaction pattern from the last 7 days of feedback.",
|
||||
pref_schema={
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"window_days": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 30,
|
||||
"default": 7,
|
||||
"description": "Lookback window for pattern analysis.",
|
||||
},
|
||||
},
|
||||
},
|
||||
context_schema=["tip_feedback", "profile.features"],
|
||||
required_consents=["data:core", "agent:recent-patterns"],
|
||||
output_contract={"type": "snippet", "format": "free_text"},
|
||||
ttl_sec=86_400,
|
||||
)
|
||||
|
||||
|
||||
class RecentPatternsAgent(BaseAgent):
|
||||
"""Surfaces the user's reaction pattern from the last 7 days of feedback."""
|
||||
agent_id: ClassVar[str] = "recent-patterns"
|
||||
ttl_seconds: ClassVar[int] = 86_400 # 24h
|
||||
version: ClassVar[str] = "1.0.0"
|
||||
agent_id: ClassVar[str] = MANIFEST.id
|
||||
ttl_seconds: ClassVar[int] = MANIFEST.ttl_sec
|
||||
version: ClassVar[str] = MANIFEST.version
|
||||
|
||||
def compute(self, inp: AgentInput) -> AgentOutput:
|
||||
now_ts = inp.now.timestamp()
|
||||
|
||||
@@ -1,21 +1,41 @@
|
||||
from __future__ import annotations
|
||||
from .base import BaseAgent
|
||||
from .overdue_task import OverdueTaskAgent
|
||||
from .momentum import MomentumAgent
|
||||
from .time_of_day import TimeOfDayAgent
|
||||
from .recent_patterns import RecentPatternsAgent
|
||||
from .focus_area import FocusAreaAgent
|
||||
"""Agent registry — single point of registration for sub-agents (ADR-0014).
|
||||
|
||||
_AGENTS: dict[str, BaseAgent] = {
|
||||
a.agent_id: a
|
||||
for a in [
|
||||
OverdueTaskAgent(),
|
||||
MomentumAgent(),
|
||||
TimeOfDayAgent(),
|
||||
RecentPatternsAgent(),
|
||||
FocusAreaAgent(),
|
||||
]
|
||||
}
|
||||
Each agent module contributes:
|
||||
- a `BaseAgent` subclass instance
|
||||
- a module-level `MANIFEST: AgentManifest`
|
||||
|
||||
The orchestrator, registry endpoint, and inference framework all read from
|
||||
here. Adding an agent is: add a module, register it once below.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import BaseAgent
|
||||
from .manifest import AgentManifest
|
||||
from .overdue_task import OverdueTaskAgent, MANIFEST as OVERDUE_TASK_MANIFEST
|
||||
from .momentum import MomentumAgent, MANIFEST as MOMENTUM_MANIFEST
|
||||
from .time_of_day import TimeOfDayAgent, MANIFEST as TIME_OF_DAY_MANIFEST
|
||||
from .recent_patterns import RecentPatternsAgent, MANIFEST as RECENT_PATTERNS_MANIFEST
|
||||
from .focus_area import FocusAreaAgent, MANIFEST as FOCUS_AREA_MANIFEST
|
||||
|
||||
_REGISTERED: list[tuple[BaseAgent, AgentManifest]] = [
|
||||
(OverdueTaskAgent(), OVERDUE_TASK_MANIFEST),
|
||||
(MomentumAgent(), MOMENTUM_MANIFEST),
|
||||
(TimeOfDayAgent(), TIME_OF_DAY_MANIFEST),
|
||||
(RecentPatternsAgent(), RECENT_PATTERNS_MANIFEST),
|
||||
(FocusAreaAgent(), FOCUS_AREA_MANIFEST),
|
||||
]
|
||||
|
||||
# Sanity check — agent_id and manifest.id must agree, otherwise the registry
|
||||
# becomes inconsistent across endpoints.
|
||||
for _agent, _manifest in _REGISTERED:
|
||||
if _agent.agent_id != _manifest.id:
|
||||
raise RuntimeError(
|
||||
f"Manifest mismatch: {_agent.__class__.__name__}.agent_id={_agent.agent_id!r} "
|
||||
f"≠ MANIFEST.id={_manifest.id!r}"
|
||||
)
|
||||
|
||||
_AGENTS: dict[str, BaseAgent] = {a.agent_id: a for a, _ in _REGISTERED}
|
||||
_MANIFESTS: dict[str, AgentManifest] = {m.id: m for _, m in _REGISTERED}
|
||||
|
||||
|
||||
def get_agent(agent_id: str) -> BaseAgent:
|
||||
@@ -26,3 +46,13 @@ def get_agent(agent_id: str) -> BaseAgent:
|
||||
|
||||
def all_agents() -> list[BaseAgent]:
|
||||
return list(_AGENTS.values())
|
||||
|
||||
|
||||
def get_manifest(agent_id: str) -> AgentManifest:
|
||||
if agent_id not in _MANIFESTS:
|
||||
raise KeyError(f"Unknown agent: {agent_id!r}. Known: {sorted(_MANIFESTS)}")
|
||||
return _MANIFESTS[agent_id]
|
||||
|
||||
|
||||
def all_manifests() -> list[AgentManifest]:
|
||||
return list(_MANIFESTS.values())
|
||||
|
||||
@@ -153,7 +153,7 @@ class TestTimeOfDayAgent:
|
||||
|
||||
def test_snapshot_keys(self):
|
||||
out = self.agent.compute(_inp())
|
||||
assert {"hour", "day_of_week", "preferred_hour"} == set(out.signals_snapshot)
|
||||
assert {"hour", "day_of_week", "preferred_hour", "quiet_start", "quiet_end"} == set(out.signals_snapshot)
|
||||
|
||||
|
||||
# ── RecentPatternsAgent ───────────────────────────────────────────────────────
|
||||
|
||||
120
ml/agents/tests/test_inference.py
Normal file
120
ml/agents/tests/test_inference.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""Tests for the inference framework and time-of-day #112 proof."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ml.agents.inference.history import FeedbackEvent, UserHistory
|
||||
from ml.agents.inference.framework import run_inference
|
||||
from ml.agents.time_of_day import TimeOfDayAgent, MANIFEST as TOD_MANIFEST, MANIFEST
|
||||
from ml.agents.base import AgentInput
|
||||
|
||||
|
||||
_NOW = datetime(2026, 5, 1, 14, 0, 0, tzinfo=timezone.utc) # Thursday 14:00
|
||||
|
||||
|
||||
def _inp(**kwargs) -> AgentInput:
|
||||
defaults = dict(user_id="u1", tasks=[], profile={}, now=_NOW, agent_prefs={})
|
||||
defaults.update(kwargs)
|
||||
return AgentInput(**defaults)
|
||||
|
||||
|
||||
def _event(action: str, hour: int) -> FeedbackEvent:
|
||||
ts = f"2026-05-01T{hour:02d}:00:00+00:00"
|
||||
return FeedbackEvent(action=action, dwell_ms=60_000 if action == "done" else 500, created_at=ts)
|
||||
|
||||
|
||||
class TestRunInference:
|
||||
def test_cold_start_when_below_min_history(self):
|
||||
history = UserHistory(user_id="u1", events=[_event("done", 9)] * 5) # only 5 < 10
|
||||
result = run_inference(TOD_MANIFEST, history)
|
||||
assert result["preferred_hour"] is None # cold_start_default
|
||||
|
||||
def test_infers_preferred_hour_as_mode(self):
|
||||
# 7 events at 09:00, 3 at 17:00 → preferred_hour should be 9
|
||||
events = [_event("done", 9)] * 7 + [_event("done", 17)] * 3
|
||||
history = UserHistory(user_id="u1", events=events)
|
||||
result = run_inference(TOD_MANIFEST, history)
|
||||
assert result["preferred_hour"] == 9
|
||||
|
||||
def test_infers_preferred_hour_from_majority_hour(self):
|
||||
events = [_event("done", 20)] * 6 + [_event("done", 8)] * 4
|
||||
history = UserHistory(user_id="u1", events=events)
|
||||
result = run_inference(TOD_MANIFEST, history)
|
||||
assert result["preferred_hour"] == 20
|
||||
|
||||
def test_no_inferred_params_returns_empty(self):
|
||||
from ml.agents.manifest import AgentManifest
|
||||
bare = AgentManifest(
|
||||
id="bare", version="1.0.0", description="", pref_schema={},
|
||||
context_schema=[], required_consents=[], output_contract={}, ttl_sec=300,
|
||||
)
|
||||
history = UserHistory(user_id="u1", events=[_event("done", 9)] * 20)
|
||||
result = run_inference(bare, history)
|
||||
assert result == {}
|
||||
|
||||
def test_cold_start_fallback_on_infer_error(self):
|
||||
"""infer() raising should fall back to cold_start_default, not crash."""
|
||||
from ml.agents.manifest import InferredParam, AgentManifest
|
||||
|
||||
def _bad_infer(h):
|
||||
raise RuntimeError("oops")
|
||||
|
||||
m = AgentManifest(
|
||||
id="boom", version="1.0.0", description="", pref_schema={},
|
||||
context_schema=[], required_consents=[], output_contract={}, ttl_sec=300,
|
||||
inferred_params=[InferredParam(key="x", ttl_sec=60, cold_start_default=42, min_history=1, infer=_bad_infer)],
|
||||
)
|
||||
history = UserHistory(user_id="u1", events=[_event("done", 9)] * 5)
|
||||
result = run_inference(m, history)
|
||||
assert result["x"] == 42
|
||||
|
||||
|
||||
class TestTimeOfDayAgentWithInference:
|
||||
agent = TimeOfDayAgent()
|
||||
|
||||
def test_uses_preferred_hour_from_agent_prefs(self):
|
||||
inp = _inp(agent_prefs={"preferred_hour": 9}, now=datetime(2026, 5, 1, 9, 0, 0, tzinfo=timezone.utc))
|
||||
out = self.agent.compute(inp)
|
||||
assert "peak productivity hour" in out.prompt_text.lower() or "peak" in out.prompt_text
|
||||
|
||||
def test_quiet_window_noon_suppressed(self):
|
||||
inp = _inp(
|
||||
agent_prefs={"quiet_start": "22:00", "quiet_end": "07:00"},
|
||||
now=datetime(2026, 5, 1, 23, 0, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
out = self.agent.compute(inp)
|
||||
assert "quiet window" in out.prompt_text
|
||||
|
||||
def test_quiet_window_not_in_window(self):
|
||||
inp = _inp(
|
||||
agent_prefs={"quiet_start": "22:00", "quiet_end": "07:00"},
|
||||
now=datetime(2026, 5, 1, 14, 0, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
out = self.agent.compute(inp)
|
||||
assert "quiet window" not in out.prompt_text
|
||||
|
||||
def test_agent_prefs_override_profile(self):
|
||||
# agent_prefs.preferred_hour wins over profile.preferred_hour
|
||||
inp = _inp(
|
||||
profile={"preferred_hour": 8},
|
||||
agent_prefs={"preferred_hour": 14},
|
||||
now=datetime(2026, 5, 1, 14, 0, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
out = self.agent.compute(inp)
|
||||
assert "peak productivity hour (14:00)" in out.prompt_text
|
||||
|
||||
def test_no_prefs_falls_back_to_profile(self):
|
||||
inp = _inp(profile={"preferred_hour": 10}, now=datetime(2026, 5, 1, 10, 0, 0, tzinfo=timezone.utc))
|
||||
out = self.agent.compute(inp)
|
||||
assert "peak" in out.prompt_text
|
||||
|
||||
def test_version_bumped(self):
|
||||
assert MANIFEST.version == "1.1.0"
|
||||
|
||||
def test_manifest_has_preferred_hour_param(self):
|
||||
keys = {p.key for p in MANIFEST.inferred_params}
|
||||
assert "preferred_hour" in keys
|
||||
67
ml/agents/tests/test_manifest.py
Normal file
67
ml/agents/tests/test_manifest.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Manifest registry tests (ADR-0014).
|
||||
|
||||
Each agent module exports a `MANIFEST: AgentManifest` whose id and version
|
||||
must agree with the agent class. The registry exposes both, and `to_dict()`
|
||||
must drop the `infer` callable so the wire payload is JSON-serialisable.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
from ml.agents.manifest import AgentManifest, InferredParam # noqa: E402
|
||||
from ml.agents.registry import ( # noqa: E402
|
||||
all_agents,
|
||||
all_manifests,
|
||||
get_agent,
|
||||
get_manifest,
|
||||
)
|
||||
|
||||
|
||||
def test_every_agent_has_a_matching_manifest():
|
||||
agents = {a.agent_id: a for a in all_agents()}
|
||||
manifests = {m.id: m for m in all_manifests()}
|
||||
assert agents.keys() == manifests.keys(), "agent / manifest registries diverged"
|
||||
for aid in agents:
|
||||
assert agents[aid].version == manifests[aid].version, (
|
||||
f"version mismatch for {aid}: agent={agents[aid].version!r} "
|
||||
f"manifest={manifests[aid].version!r}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("agent_id", [
|
||||
"overdue-task", "momentum", "time-of-day", "recent-patterns", "focus-area",
|
||||
])
|
||||
def test_manifest_required_fields(agent_id: str):
|
||||
m = get_manifest(agent_id)
|
||||
assert m.id == agent_id
|
||||
assert m.version
|
||||
assert m.description
|
||||
assert isinstance(m.pref_schema, dict) and m.pref_schema.get("type") == "object"
|
||||
assert isinstance(m.required_consents, list) and m.required_consents
|
||||
assert "data:core" in m.required_consents, "every agent should require data:core"
|
||||
assert m.ttl_sec == get_agent(agent_id).ttl_seconds, "ttl divergence"
|
||||
|
||||
|
||||
def test_to_dict_is_json_serialisable_and_drops_infer_callable():
|
||||
m = AgentManifest(
|
||||
id="x", version="1.0.0", description="d",
|
||||
pref_schema={"type": "object"}, context_schema=[], required_consents=["data:core"],
|
||||
output_contract={"type": "snippet"}, ttl_sec=60,
|
||||
inferred_params=[InferredParam(key="k", ttl_sec=60, cold_start_default=0, min_history=10, infer=lambda h: 0)],
|
||||
)
|
||||
payload = m.to_dict()
|
||||
# Round-trip through json to confirm no callables / non-JSON types leaked.
|
||||
data = json.loads(json.dumps(payload))
|
||||
assert data["inferred_params"][0]["key"] == "k"
|
||||
assert "infer" not in data["inferred_params"][0]
|
||||
|
||||
|
||||
def test_get_manifest_unknown_raises():
|
||||
with pytest.raises(KeyError):
|
||||
get_manifest("not-an-agent")
|
||||
@@ -1,44 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from typing import ClassVar
|
||||
|
||||
from .base import BaseAgent, AgentInput, AgentOutput
|
||||
from .inference.history import UserHistory
|
||||
from .manifest import AgentManifest, InferredParam
|
||||
|
||||
_DOW_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
||||
|
||||
|
||||
def _infer_preferred_hour(history: UserHistory) -> int:
|
||||
"""Mode hour of day across all 'done' feedback events; falls back to 9."""
|
||||
done_hours = [e.hour for e in history.events if e.action == "done"]
|
||||
if not done_hours:
|
||||
return 9
|
||||
return Counter(done_hours).most_common(1)[0][0]
|
||||
|
||||
|
||||
MANIFEST = AgentManifest(
|
||||
id="time-of-day",
|
||||
version="1.1.0", # bumped: inferred_params added (ADR-0014 §3, #112)
|
||||
description="Frames the current moment relative to the user's productive peak and quiet hours.",
|
||||
pref_schema={
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"quiet_start": {
|
||||
"type": "string",
|
||||
"pattern": "^([01][0-9]|2[0-3]):[0-5][0-9]$",
|
||||
"description": "HH:MM start of quiet hours (24h, user's local TZ).",
|
||||
},
|
||||
"quiet_end": {
|
||||
"type": "string",
|
||||
"pattern": "^([01][0-9]|2[0-3]):[0-5][0-9]$",
|
||||
"description": "HH:MM end of quiet hours.",
|
||||
},
|
||||
},
|
||||
},
|
||||
context_schema=["profile.features"],
|
||||
required_consents=["data:core", "agent:time-of-day"],
|
||||
output_contract={"type": "snippet", "format": "free_text"},
|
||||
ttl_sec=900,
|
||||
inferred_params=[
|
||||
InferredParam(
|
||||
key="preferred_hour",
|
||||
ttl_sec=3_600, # recompute hourly
|
||||
cold_start_default=None,
|
||||
min_history=10, # need at least 10 feedback events to be meaningful
|
||||
infer=_infer_preferred_hour,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class TimeOfDayAgent(BaseAgent):
|
||||
"""Frames the current moment relative to the user's productive peak."""
|
||||
agent_id: ClassVar[str] = "time-of-day"
|
||||
ttl_seconds: ClassVar[int] = 900 # 15m — must stay current-hour accurate
|
||||
version: ClassVar[str] = "1.0.0"
|
||||
agent_id: ClassVar[str] = MANIFEST.id
|
||||
ttl_seconds: ClassVar[int] = MANIFEST.ttl_sec
|
||||
version: ClassVar[str] = MANIFEST.version
|
||||
|
||||
def compute(self, inp: AgentInput) -> AgentOutput:
|
||||
hour = inp.now.hour
|
||||
dow = inp.now.weekday() # 0=Monday … 6=Sunday
|
||||
preferred = inp.profile.get("preferred_hour")
|
||||
is_weekend = dow >= 5
|
||||
|
||||
# agent_prefs (inferred or user-set) take precedence over ML profile features.
|
||||
preferred_raw = inp.agent_prefs.get("preferred_hour", inp.profile.get("preferred_hour"))
|
||||
preferred = int(preferred_raw) if preferred_raw is not None else None
|
||||
|
||||
quiet_start: str | None = inp.agent_prefs.get("quiet_start")
|
||||
quiet_end: str | None = inp.agent_prefs.get("quiet_end")
|
||||
in_quiet = self._in_quiet_window(hour, quiet_start, quiet_end)
|
||||
|
||||
parts = [f"It is {hour:02d}:00 on {_DOW_NAMES[dow]} ({self._label(hour)})."]
|
||||
|
||||
if is_weekend:
|
||||
parts.append("Weekend context — prefer personal or reflective tips over work tasks.")
|
||||
|
||||
if in_quiet:
|
||||
parts.append(
|
||||
f"User is in their quiet window ({quiet_start}–{quiet_end}) — "
|
||||
"avoid urgent or demanding tips."
|
||||
)
|
||||
|
||||
if preferred is not None:
|
||||
ph = int(preferred)
|
||||
delta = min(abs(hour - ph), 24 - abs(hour - ph)) # circular distance
|
||||
delta = min(abs(hour - preferred), 24 - abs(hour - preferred))
|
||||
if delta == 0:
|
||||
parts.append(
|
||||
f"This is the user's peak productivity hour ({ph:02d}:00) — "
|
||||
f"a high-impact tip is appropriate."
|
||||
f"This is the user's peak productivity hour ({preferred:02d}:00) — "
|
||||
"a high-impact tip is appropriate."
|
||||
)
|
||||
elif delta <= 2:
|
||||
parts.append(f"Approaching the user's peak productivity window ({ph:02d}:00).")
|
||||
parts.append(f"Approaching the user's peak productivity window ({preferred:02d}:00).")
|
||||
else:
|
||||
parts.append("No preferred-hour data yet.")
|
||||
|
||||
prompt = " ".join(parts)
|
||||
snapshot = {"hour": hour, "day_of_week": dow, "preferred_hour": preferred}
|
||||
snapshot = {
|
||||
"hour": hour,
|
||||
"day_of_week": dow,
|
||||
"preferred_hour": preferred,
|
||||
"quiet_start": quiet_start,
|
||||
"quiet_end": quiet_end,
|
||||
}
|
||||
return self._make_output(inp, prompt, snapshot)
|
||||
|
||||
@staticmethod
|
||||
def _in_quiet_window(hour: int, start: str | None, end: str | None) -> bool:
|
||||
if not start or not end:
|
||||
return False
|
||||
try:
|
||||
sh = int(start.split(":")[0])
|
||||
eh = int(end.split(":")[0])
|
||||
except (ValueError, IndexError):
|
||||
return False
|
||||
if sh <= eh:
|
||||
return sh <= hour < eh
|
||||
# wraps midnight e.g. 22:00–07:00
|
||||
return hour >= sh or hour < eh
|
||||
|
||||
@staticmethod
|
||||
def _label(hour: int) -> str:
|
||||
if 5 <= hour < 12:
|
||||
|
||||
@@ -3,6 +3,7 @@ oO ML Serving — multi-agent orchestrator (ADR-0013).
|
||||
|
||||
Contract:
|
||||
POST /agents/{agent_id}/compute run a sub-agent, return prompt snippet
|
||||
POST /agents/{agent_id}/infer run inference framework for a user, return inferred prefs
|
||||
POST /recommend orchestrate agent snippets → one tip via LiteLLM
|
||||
POST /generate LLM tip candidates (legacy; kept for bench/eval)
|
||||
GET /health { ok, agents: [...] }
|
||||
@@ -38,7 +39,8 @@ if _repo_root not in sys.path:
|
||||
sys.path.insert(0, _repo_root)
|
||||
|
||||
from ml.agents.base import AgentInput # noqa: E402
|
||||
from ml.agents.registry import get_agent, all_agents # noqa: E402
|
||||
from ml.agents.registry import get_agent, all_agents, all_manifests, get_manifest # noqa: E402
|
||||
from ml.agents.inference import run_inference, FeedbackEvent, UserHistory # noqa: E402
|
||||
|
||||
logging_config.configure()
|
||||
|
||||
@@ -123,6 +125,8 @@ class AgentComputeRequest(BaseModel):
|
||||
profile: dict[str, Optional[float]] = {}
|
||||
feedback_history: list[dict] = []
|
||||
now_iso: Optional[str] = None # ISO 8601; defaults to utcnow
|
||||
# Per-agent prefs from user_preferences (merged: user source overrides inferred).
|
||||
agent_prefs: dict = {}
|
||||
|
||||
|
||||
class AgentComputeResponse(BaseModel):
|
||||
@@ -135,6 +139,18 @@ class AgentComputeResponse(BaseModel):
|
||||
agent_version: str
|
||||
|
||||
|
||||
class AgentInferRequest(BaseModel):
|
||||
user_id: str
|
||||
feedback_history: list[dict] = [] # [{action, dwell_ms, created_at}, …]
|
||||
|
||||
|
||||
class AgentInferResponse(BaseModel):
|
||||
user_id: str
|
||||
agent_id: str
|
||||
# {key: inferred_value} — caller persists to user_preferences with source='inferred'
|
||||
inferred_prefs: dict
|
||||
|
||||
|
||||
class AgentOutputSnippet(BaseModel):
|
||||
agent_id: str
|
||||
prompt_text: str
|
||||
@@ -177,6 +193,16 @@ def health():
|
||||
}
|
||||
|
||||
|
||||
@app.get("/agents/registry")
|
||||
def agents_registry():
|
||||
"""Manifest list for every registered agent (ADR-0014).
|
||||
|
||||
Consumers: TS recommender (eligibility filter), admin UI (auto-rendered
|
||||
pref forms), inference framework (#111). Static at process boot.
|
||||
"""
|
||||
return {"agents": [m.to_dict() for m in all_manifests()]}
|
||||
|
||||
|
||||
_RETRY_SUFFIX = (
|
||||
"\n\nYour previous response was not valid JSON. "
|
||||
"Reply ONLY with the JSON array — no prose, no markdown fences."
|
||||
@@ -215,6 +241,7 @@ async def compute_agent(agent_id: str, req: AgentComputeRequest) -> AgentCompute
|
||||
profile=req.profile,
|
||||
feedback_history=req.feedback_history,
|
||||
now=now,
|
||||
agent_prefs=req.agent_prefs,
|
||||
)
|
||||
try:
|
||||
output = agent.compute(inp)
|
||||
@@ -234,6 +261,46 @@ async def compute_agent(agent_id: str, req: AgentComputeRequest) -> AgentCompute
|
||||
)
|
||||
|
||||
|
||||
@app.post("/agents/{agent_id}/infer", response_model=AgentInferResponse)
|
||||
async def infer_agent(agent_id: str, req: AgentInferRequest) -> AgentInferResponse:
|
||||
"""Run the inference framework for one agent and return inferred preference values.
|
||||
|
||||
The caller (TS agent-outputs.ts) persists results to user_preferences
|
||||
with source='inferred', skipping keys where source='user' already exists.
|
||||
"""
|
||||
try:
|
||||
manifest = get_manifest(agent_id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown agent: {agent_id!r}")
|
||||
|
||||
if not manifest.inferred_params:
|
||||
return AgentInferResponse(user_id=req.user_id, agent_id=agent_id, inferred_prefs={})
|
||||
|
||||
events = [
|
||||
FeedbackEvent(
|
||||
action=e.get("action", ""),
|
||||
dwell_ms=e.get("dwell_ms"),
|
||||
created_at=e.get("created_at", ""),
|
||||
)
|
||||
for e in req.feedback_history
|
||||
]
|
||||
history = UserHistory(user_id=req.user_id, events=events)
|
||||
|
||||
t0 = __import__("time").monotonic()
|
||||
inferred = run_inference(manifest, history)
|
||||
latency_ms = round((__import__("time").monotonic() - t0) * 1000, 1)
|
||||
|
||||
log.info(
|
||||
"inference_run",
|
||||
agent_id=agent_id,
|
||||
user_id=req.user_id,
|
||||
n_params=len(inferred),
|
||||
history_len=len(events),
|
||||
latency_ms=latency_ms,
|
||||
)
|
||||
return AgentInferResponse(user_id=req.user_id, agent_id=agent_id, inferred_prefs=inferred)
|
||||
|
||||
|
||||
@app.post("/recommend", response_model=RecommendResponse)
|
||||
async def recommend(req: RecommendRequest) -> RecommendResponse:
|
||||
"""Orchestrator: combine pre-computed agent outputs into one tip via LLM.
|
||||
|
||||
52
ml/serving/tests/test_infer_endpoint.py
Normal file
52
ml/serving/tests/test_infer_endpoint.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""POST /agents/{agent_id}/infer — inference framework endpoint."""
|
||||
import pytest
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
from main import app
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_infer_time_of_day_cold_start():
|
||||
"""Fewer than min_history events → cold_start_default for preferred_hour."""
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.post("/agents/time-of-day/infer", json={
|
||||
"user_id": "u1",
|
||||
"feedback_history": [
|
||||
{"action": "done", "dwell_ms": 60000, "created_at": "2026-05-01T09:00:00+00:00"},
|
||||
] * 5, # 5 < min_history=10
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["agent_id"] == "time-of-day"
|
||||
assert body["inferred_prefs"]["preferred_hour"] is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_infer_time_of_day_enough_history():
|
||||
"""10+ events → preferred_hour is inferred as the mode done-hour."""
|
||||
events = [{"action": "done", "dwell_ms": 60000, "created_at": "2026-05-01T09:00:00+00:00"}] * 10
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.post("/agents/time-of-day/infer", json={"user_id": "u1", "feedback_history": events})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["inferred_prefs"]["preferred_hour"] == 9
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_infer_agent_with_no_inferred_params():
|
||||
"""Agents with no inferred_params return an empty dict."""
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.post("/agents/overdue-task/infer", json={"user_id": "u1", "feedback_history": []})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["inferred_prefs"] == {}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_infer_unknown_agent_404():
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.post("/agents/ghost/infer", json={"user_id": "u1", "feedback_history": []})
|
||||
assert resp.status_code == 404
|
||||
21
ml/serving/tests/test_registry_endpoint.py
Normal file
21
ml/serving/tests/test_registry_endpoint.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""GET /agents/registry — manifests are exposed in JSON-serialisable form."""
|
||||
import pytest
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
from main import app
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_registry_returns_all_agents():
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.get("/agents/registry")
|
||||
|
||||
assert resp.status_code == 200
|
||||
payload = resp.json()
|
||||
ids = {a["id"] for a in payload["agents"]}
|
||||
assert ids == {"overdue-task", "momentum", "time-of-day", "recent-patterns", "focus-area"}
|
||||
|
||||
sample = payload["agents"][0]
|
||||
for key in ("id", "version", "description", "pref_schema", "required_consents", "ttl_sec"):
|
||||
assert key in sample
|
||||
123
services/api/src/db/__tests__/migrations.test.ts
Normal file
123
services/api/src/db/__tests__/migrations.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Migration tests — apply runMigrations() to a fresh in-memory SQLite handle
|
||||
* and verify schema, idempotency, and the consent_given → user_consents backfill.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { runMigrations } from '../migrations.js';
|
||||
|
||||
function freshDb() {
|
||||
const sqlite = new Database(':memory:');
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
return sqlite;
|
||||
}
|
||||
|
||||
describe('runMigrations — fresh DB', () => {
|
||||
it('creates the ADR-0014 tables and adds tone / tip_kinds_json on users', () => {
|
||||
const sqlite = freshDb();
|
||||
runMigrations(sqlite);
|
||||
|
||||
const tables = (sqlite
|
||||
.prepare(`SELECT name FROM sqlite_master WHERE type='table'`)
|
||||
.all() as { name: string }[]).map((r) => r.name);
|
||||
expect(tables).toEqual(expect.arrayContaining(['user_preferences', 'user_consents', 'user_contexts']));
|
||||
|
||||
const userCols = sqlite.prepare(`PRAGMA table_info(users)`).all() as { name: string }[];
|
||||
const colNames = userCols.map((c) => c.name);
|
||||
expect(colNames).toContain('tone');
|
||||
expect(colNames).toContain('tip_kinds_json');
|
||||
});
|
||||
|
||||
it('declares the expected composite primary keys', () => {
|
||||
const sqlite = freshDb();
|
||||
runMigrations(sqlite);
|
||||
|
||||
type ColInfo = { name: string; pk: number };
|
||||
const pkCols = (table: string): string[] =>
|
||||
(sqlite.prepare(`PRAGMA table_info(${table})`).all() as ColInfo[])
|
||||
.filter((c) => c.pk > 0)
|
||||
.sort((a, b) => a.pk - b.pk)
|
||||
.map((c) => c.name);
|
||||
|
||||
expect(pkCols('user_preferences')).toEqual(['user_id', 'scope', 'key']);
|
||||
expect(pkCols('user_consents')).toEqual(['user_id', 'consent_key']);
|
||||
expect(pkCols('user_contexts')).toEqual(['user_id', 'name']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runMigrations — idempotency', () => {
|
||||
it('is safe to re-run on an already-migrated DB', () => {
|
||||
const sqlite = freshDb();
|
||||
runMigrations(sqlite);
|
||||
expect(() => runMigrations(sqlite)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('runMigrations — consent backfill', () => {
|
||||
it('backfills users with consent_given=1 into user_consents (data:core)', () => {
|
||||
const sqlite = freshDb();
|
||||
runMigrations(sqlite);
|
||||
|
||||
sqlite.prepare(
|
||||
`INSERT INTO users (id, email, role, consent_given, consent_at, created_at)
|
||||
VALUES (?, ?, 'user', 1, ?, ?)`,
|
||||
).run('u1', 'u1@test.com', '2026-04-01T00:00:00Z', '2026-03-01T00:00:00Z');
|
||||
sqlite.prepare(
|
||||
`INSERT INTO users (id, email, role, consent_given, consent_at, created_at)
|
||||
VALUES (?, ?, 'user', 0, NULL, ?)`,
|
||||
).run('u2', 'u2@test.com', '2026-03-02T00:00:00Z');
|
||||
|
||||
// Re-run migrations to trigger the backfill (the first call ran before users existed).
|
||||
runMigrations(sqlite);
|
||||
|
||||
const rows = sqlite
|
||||
.prepare(`SELECT user_id, consent_key, granted_at, revoked_at FROM user_consents`)
|
||||
.all() as { user_id: string; consent_key: string; granted_at: string; revoked_at: string | null }[];
|
||||
expect(rows).toEqual([
|
||||
{ user_id: 'u1', consent_key: 'data:core', granted_at: '2026-04-01T00:00:00Z', revoked_at: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to created_at when consent_at is null', () => {
|
||||
const sqlite = freshDb();
|
||||
runMigrations(sqlite);
|
||||
|
||||
sqlite.prepare(
|
||||
`INSERT INTO users (id, email, role, consent_given, consent_at, created_at)
|
||||
VALUES (?, ?, 'user', 1, NULL, ?)`,
|
||||
).run('u3', 'u3@test.com', '2026-02-15T00:00:00Z');
|
||||
|
||||
runMigrations(sqlite);
|
||||
|
||||
const granted = sqlite
|
||||
.prepare(`SELECT granted_at FROM user_consents WHERE user_id = 'u3'`)
|
||||
.get() as { granted_at: string };
|
||||
expect(granted.granted_at).toBe('2026-02-15T00:00:00Z');
|
||||
});
|
||||
|
||||
it('does not overwrite an existing user_consents row on subsequent runs', () => {
|
||||
const sqlite = freshDb();
|
||||
runMigrations(sqlite);
|
||||
|
||||
sqlite.prepare(
|
||||
`INSERT INTO users (id, email, role, consent_given, consent_at, created_at)
|
||||
VALUES (?, ?, 'user', 1, ?, ?)`,
|
||||
).run('u4', 'u4@test.com', '2026-04-01T00:00:00Z', '2026-03-01T00:00:00Z');
|
||||
|
||||
runMigrations(sqlite);
|
||||
|
||||
// Simulate user revoking core consent later via the new code path.
|
||||
sqlite.prepare(
|
||||
`UPDATE user_consents SET revoked_at = ? WHERE user_id = 'u4' AND consent_key = 'data:core'`,
|
||||
).run('2026-04-15T00:00:00Z');
|
||||
|
||||
// Re-running migrations must not resurrect the consent (i.e. must not overwrite revoked_at).
|
||||
runMigrations(sqlite);
|
||||
|
||||
const row = sqlite
|
||||
.prepare(`SELECT granted_at, revoked_at FROM user_consents WHERE user_id = 'u4' AND consent_key = 'data:core'`)
|
||||
.get() as { granted_at: string; revoked_at: string | null };
|
||||
expect(row.revoked_at).toBe('2026-04-15T00:00:00Z');
|
||||
expect(row.granted_at).toBe('2026-04-01T00:00:00Z');
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import Database from 'better-sqlite3';
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import * as schema from './schema.js';
|
||||
import { config } from '../config.js';
|
||||
import { runMigrations as runMigrationsImpl } from './migrations.js';
|
||||
|
||||
const sqlite = new Database(config.DATABASE_PATH);
|
||||
sqlite.pragma('journal_mode = WAL');
|
||||
@@ -13,172 +14,5 @@ export const db = drizzle(sqlite, { schema });
|
||||
export const rawSqlite: any = sqlite;
|
||||
|
||||
export function runMigrations() {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
name TEXT,
|
||||
image TEXT,
|
||||
google_id TEXT UNIQUE,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
consent_given INTEGER NOT NULL DEFAULT 0,
|
||||
consent_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
deleted_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS integration_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
provider TEXT NOT NULL,
|
||||
access_token TEXT NOT NULL,
|
||||
refresh_token TEXT,
|
||||
expires_at TEXT,
|
||||
connected_at TEXT NOT NULL,
|
||||
UNIQUE(user_id, provider)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tip_feedback (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
tip_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
source_id TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tip_views (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
tip_id TEXT NOT NULL,
|
||||
served_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admin_actions (
|
||||
id TEXT PRIMARY KEY,
|
||||
admin_id TEXT NOT NULL REFERENCES users(id),
|
||||
action TEXT NOT NULL,
|
||||
target_type TEXT,
|
||||
target_id TEXT,
|
||||
detail TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tip_scores (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
tip_id TEXT NOT NULL,
|
||||
policy TEXT NOT NULL,
|
||||
ml_score INTEGER,
|
||||
features_json TEXT,
|
||||
candidate_count INTEGER,
|
||||
latency_ms INTEGER,
|
||||
served_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS saved_queries (
|
||||
id TEXT PRIMARY KEY,
|
||||
admin_id TEXT NOT NULL REFERENCES users(id),
|
||||
name TEXT NOT NULL,
|
||||
sql TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_profile_features (
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
name TEXT NOT NULL,
|
||||
value REAL,
|
||||
value_text TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
ttl_sec INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sim_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
policy_a TEXT NOT NULL,
|
||||
policy_b TEXT NOT NULL,
|
||||
n_users INTEGER NOT NULL,
|
||||
n_rounds INTEGER NOT NULL,
|
||||
tasks_per_round INTEGER NOT NULL DEFAULT 8,
|
||||
use_llm INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
summary_json TEXT,
|
||||
winner TEXT,
|
||||
persona_breakdown_json TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
finished_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sim_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
run_id TEXT NOT NULL REFERENCES sim_runs(id),
|
||||
round INTEGER NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
persona TEXT NOT NULL,
|
||||
policy TEXT NOT NULL,
|
||||
tip_content TEXT NOT NULL,
|
||||
priority INTEGER NOT NULL,
|
||||
is_overdue INTEGER NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
dwell_ms INTEGER,
|
||||
reward_milli INTEGER NOT NULL,
|
||||
hour INTEGER NOT NULL,
|
||||
day_of_week INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_outputs (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
agent_id TEXT NOT NULL,
|
||||
prompt_text TEXT NOT NULL,
|
||||
signals_snapshot TEXT,
|
||||
computed_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
agent_version TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_outputs_user_agent_exp
|
||||
ON agent_outputs(user_id, agent_id, expires_at DESC);
|
||||
`);
|
||||
|
||||
// Additive column migrations — safe to run on existing DBs.
|
||||
// SQLite doesn't support IF NOT EXISTS on ALTER TABLE; we ignore the error if already present.
|
||||
for (const stmt of [
|
||||
`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'`,
|
||||
`ALTER TABLE push_subscriptions ADD COLUMN created_at TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE tip_feedback ADD COLUMN dwell_ms INTEGER`,
|
||||
`ALTER TABLE tip_feedback ADD COLUMN reward_milli INTEGER`,
|
||||
`ALTER TABLE integration_tokens ADD COLUMN token_status TEXT NOT NULL DEFAULT 'active'`,
|
||||
`ALTER TABLE tip_scores ADD COLUMN prompt_version TEXT`,
|
||||
`ALTER TABLE tip_scores ADD COLUMN llm_model TEXT`,
|
||||
`ALTER TABLE tip_scores ADD COLUMN tip_kind TEXT`,
|
||||
`ALTER TABLE sim_runs ADD COLUMN mlflow_run_id TEXT`,
|
||||
`ALTER TABLE sim_runs ADD COLUMN judge_mode TEXT NOT NULL DEFAULT 'rule'`,
|
||||
`ALTER TABLE sim_runs ADD COLUMN n_policies INTEGER NOT NULL DEFAULT 2`,
|
||||
]) {
|
||||
try { sqlite.exec(stmt); } catch { /* column already exists */ }
|
||||
}
|
||||
|
||||
// Seed first admin from env (ADMIN_SEED_EMAIL).
|
||||
const seedEmail = process.env.ADMIN_SEED_EMAIL;
|
||||
if (seedEmail) {
|
||||
sqlite.prepare(`UPDATE users SET role = 'admin' WHERE email = ? AND role = 'user'`).run(seedEmail);
|
||||
}
|
||||
runMigrationsImpl(sqlite);
|
||||
}
|
||||
|
||||
218
services/api/src/db/migrations.ts
Normal file
218
services/api/src/db/migrations.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Schema migrations and one-shot backfills for the API DB.
|
||||
*
|
||||
* Kept separate from db/index.ts so tests can apply migrations to an in-memory
|
||||
* SQLite handle without triggering the singleton DB connection at import time.
|
||||
*/
|
||||
import type { Database as BetterSqlite3Database } from 'better-sqlite3';
|
||||
|
||||
export function runMigrations(handle: BetterSqlite3Database) {
|
||||
handle.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
name TEXT,
|
||||
image TEXT,
|
||||
google_id TEXT UNIQUE,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
consent_given INTEGER NOT NULL DEFAULT 0,
|
||||
consent_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
deleted_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS integration_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
provider TEXT NOT NULL,
|
||||
access_token TEXT NOT NULL,
|
||||
refresh_token TEXT,
|
||||
expires_at TEXT,
|
||||
connected_at TEXT NOT NULL,
|
||||
UNIQUE(user_id, provider)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tip_feedback (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
tip_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
source_id TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tip_views (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
tip_id TEXT NOT NULL,
|
||||
served_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admin_actions (
|
||||
id TEXT PRIMARY KEY,
|
||||
admin_id TEXT NOT NULL REFERENCES users(id),
|
||||
action TEXT NOT NULL,
|
||||
target_type TEXT,
|
||||
target_id TEXT,
|
||||
detail TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tip_scores (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
tip_id TEXT NOT NULL,
|
||||
policy TEXT NOT NULL,
|
||||
ml_score INTEGER,
|
||||
features_json TEXT,
|
||||
candidate_count INTEGER,
|
||||
latency_ms INTEGER,
|
||||
served_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS saved_queries (
|
||||
id TEXT PRIMARY KEY,
|
||||
admin_id TEXT NOT NULL REFERENCES users(id),
|
||||
name TEXT NOT NULL,
|
||||
sql TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_profile_features (
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
name TEXT NOT NULL,
|
||||
value REAL,
|
||||
value_text TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
ttl_sec INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sim_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
policy_a TEXT NOT NULL,
|
||||
policy_b TEXT NOT NULL,
|
||||
n_users INTEGER NOT NULL,
|
||||
n_rounds INTEGER NOT NULL,
|
||||
tasks_per_round INTEGER NOT NULL DEFAULT 8,
|
||||
use_llm INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
summary_json TEXT,
|
||||
winner TEXT,
|
||||
persona_breakdown_json TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
finished_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sim_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
run_id TEXT NOT NULL REFERENCES sim_runs(id),
|
||||
round INTEGER NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
persona TEXT NOT NULL,
|
||||
policy TEXT NOT NULL,
|
||||
tip_content TEXT NOT NULL,
|
||||
priority INTEGER NOT NULL,
|
||||
is_overdue INTEGER NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
dwell_ms INTEGER,
|
||||
reward_milli INTEGER NOT NULL,
|
||||
hour INTEGER NOT NULL,
|
||||
day_of_week INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_outputs (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
agent_id TEXT NOT NULL,
|
||||
prompt_text TEXT NOT NULL,
|
||||
signals_snapshot TEXT,
|
||||
computed_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
agent_version TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_outputs_user_agent_exp
|
||||
ON agent_outputs(user_id, agent_id, expires_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
scope TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value_json TEXT NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT 'user',
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, scope, key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_consents (
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
consent_key TEXT NOT NULL,
|
||||
granted_at TEXT NOT NULL,
|
||||
revoked_at TEXT,
|
||||
PRIMARY KEY (user_id, consent_key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_contexts (
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
name TEXT NOT NULL,
|
||||
active INTEGER NOT NULL DEFAULT 0,
|
||||
schedule_json TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, name)
|
||||
);
|
||||
`);
|
||||
|
||||
// Additive column migrations — safe to run on existing DBs.
|
||||
// SQLite doesn't support IF NOT EXISTS on ALTER TABLE; we ignore the error if already present.
|
||||
for (const stmt of [
|
||||
`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'`,
|
||||
`ALTER TABLE push_subscriptions ADD COLUMN created_at TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE tip_feedback ADD COLUMN dwell_ms INTEGER`,
|
||||
`ALTER TABLE tip_feedback ADD COLUMN reward_milli INTEGER`,
|
||||
`ALTER TABLE integration_tokens ADD COLUMN token_status TEXT NOT NULL DEFAULT 'active'`,
|
||||
`ALTER TABLE tip_scores ADD COLUMN prompt_version TEXT`,
|
||||
`ALTER TABLE tip_scores ADD COLUMN llm_model TEXT`,
|
||||
`ALTER TABLE tip_scores ADD COLUMN tip_kind TEXT`,
|
||||
`ALTER TABLE sim_runs ADD COLUMN mlflow_run_id TEXT`,
|
||||
`ALTER TABLE sim_runs ADD COLUMN judge_mode TEXT NOT NULL DEFAULT 'rule'`,
|
||||
`ALTER TABLE sim_runs ADD COLUMN n_policies INTEGER NOT NULL DEFAULT 2`,
|
||||
`ALTER TABLE users ADD COLUMN tone TEXT`,
|
||||
`ALTER TABLE users ADD COLUMN tip_kinds_json TEXT`,
|
||||
]) {
|
||||
try { handle.exec(stmt); } catch { /* column already exists */ }
|
||||
}
|
||||
|
||||
// Backfill: ADR-0014 collapses users.consent_given into user_consents
|
||||
// (consent_key='data:core'). Idempotent — INSERT OR IGNORE on the
|
||||
// composite PK skips users already migrated. Stays in place until the
|
||||
// column is dropped (PR 6 of the migration plan).
|
||||
handle.exec(`
|
||||
INSERT OR IGNORE INTO user_consents (user_id, consent_key, granted_at)
|
||||
SELECT id, 'data:core', COALESCE(consent_at, created_at)
|
||||
FROM users
|
||||
WHERE consent_given = 1
|
||||
`);
|
||||
|
||||
// Seed first admin from env (ADMIN_SEED_EMAIL).
|
||||
const seedEmail = process.env.ADMIN_SEED_EMAIL;
|
||||
if (seedEmail) {
|
||||
handle.prepare(`UPDATE users SET role = 'admin' WHERE email = ? AND role = 'user'`).run(seedEmail);
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,50 @@ export const users = sqliteTable('users', {
|
||||
image: text('image'),
|
||||
googleId: text('google_id').unique(),
|
||||
role: text('role').notNull().default('user'), // 'user' | 'admin'
|
||||
// Legacy single-bit consent. Superseded by user_consents (consent_key='data:core').
|
||||
// Kept for one release per ADR-0014 migration plan; reads consult both, writes go to user_consents only.
|
||||
consentGiven: integer('consent_given', { mode: 'boolean' }).notNull().default(false),
|
||||
consentAt: text('consent_at'),
|
||||
// Stable globals (ADR-0014). Per-agent prefs land in user_preferences instead.
|
||||
tone: text('tone'), // 'direct' | 'gentle' | 'motivational'
|
||||
tipKindsJson: text('tip_kinds_json'), // JSON array of allowed tip kinds; null = all
|
||||
createdAt: text('created_at').notNull(),
|
||||
deletedAt: text('deleted_at'),
|
||||
});
|
||||
|
||||
// ── Unified Profile model (ADR-0014) ────────────────────────────────────────
|
||||
// Open-ended per-scope preferences. `scope` is 'orchestrator' or 'agent:<id>';
|
||||
// the agent's pref_schema (from its manifest) validates value_json on read.
|
||||
// `source='inferred'` is written by the inference framework (#111); never
|
||||
// overwrites a `source='user'` row.
|
||||
export const userPreferences = sqliteTable('user_preferences', {
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
scope: text('scope').notNull(), // 'orchestrator' | 'agent:<id>'
|
||||
key: text('key').notNull(),
|
||||
valueJson: text('value_json').notNull(),
|
||||
source: text('source').notNull().default('user'), // 'user' | 'inferred'
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
});
|
||||
|
||||
// Per-key consent. Revocation writes `revoked_at`; rows are never deleted
|
||||
// so audits stay clean. `revoked_at IS NULL` = currently active.
|
||||
export const userConsents = sqliteTable('user_consents', {
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
consentKey: text('consent_key').notNull(), // 'data:core' | 'data:todoist' | 'agent:<id>' | …
|
||||
grantedAt: text('granted_at').notNull(),
|
||||
revokedAt: text('revoked_at'),
|
||||
});
|
||||
|
||||
// User-named contexts (work / home / vacation). M2 ships manual toggle only;
|
||||
// auto-inference is per-agent (#112–#116).
|
||||
export const userContexts = sqliteTable('user_contexts', {
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
name: text('name').notNull(),
|
||||
active: integer('active', { mode: 'boolean' }).notNull().default(false),
|
||||
scheduleJson: text('schedule_json'), // optional: when active
|
||||
createdAt: text('created_at').notNull(),
|
||||
});
|
||||
|
||||
export const integrationTokens = sqliteTable('integration_tokens', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
|
||||
@@ -18,6 +18,8 @@ import { pushRouter } from './routes/push.js';
|
||||
import { adminRouter, adminInternalRouter } from './routes/admin.js';
|
||||
import benchRouter from './routes/bench.js';
|
||||
import agentOutputsRouter from './routes/agent-outputs.js';
|
||||
import agentRegistryRouter from './routes/agent-registry.js';
|
||||
import profileRouter from './routes/profile.js';
|
||||
import { mkdir } from 'fs/promises';
|
||||
import { dirname } from 'path';
|
||||
import { requireAuth } from './middleware/session.js';
|
||||
@@ -70,7 +72,10 @@ app.use('/api/push', pushRouter);
|
||||
app.use('/api/admin', adminRouter);
|
||||
app.use('/api/admin', adminInternalRouter);
|
||||
app.use('/api/bench', requireAuth as any, requireAdmin as any, benchRouter);
|
||||
// agent-registry mounts first so /registry beats agent-outputs' /:userId pattern.
|
||||
app.use('/api/agents', agentRegistryRouter);
|
||||
app.use('/api/agents', agentOutputsRouter);
|
||||
app.use('/api/profile', profileRouter);
|
||||
|
||||
app.use('/api/ml', requireAuth as any, requireAdmin as any, async (req: Request, res: Response) => {
|
||||
const mlUrl = config.ML_SERVING_URL;
|
||||
|
||||
130
services/api/src/profile/__tests__/eligibility.test.ts
Normal file
130
services/api/src/profile/__tests__/eligibility.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Unit tests for getEligibleAgentIds (ADR-0014 step 5).
|
||||
* DB is mocked via in-memory SQLite; fetchRegistry is mocked per scenario.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest';
|
||||
import { makeTestDb } from '../../test/db.js';
|
||||
import { users, userConsents, userPreferences, userContexts } from '../../db/schema.js';
|
||||
|
||||
const testDb = makeTestDb();
|
||||
vi.mock('../../db/index.js', () => ({ db: testDb, rawSqlite: testDb.rawSqlite }));
|
||||
|
||||
// Registry mock — overridden per test.
|
||||
const mockFetchRegistry = vi.fn();
|
||||
vi.mock('../../routes/agent-registry.js', () => ({
|
||||
fetchRegistry: (...args: unknown[]) => mockFetchRegistry(...args),
|
||||
_resetRegistryCache: vi.fn(),
|
||||
}));
|
||||
|
||||
const { getEligibleAgentIds } = await import('../eligibility.js');
|
||||
|
||||
const NOW = new Date().toISOString();
|
||||
const MANIFEST_DEFAULTS = {
|
||||
version: '1.0.0',
|
||||
description: '',
|
||||
pref_schema: {},
|
||||
context_schema: [],
|
||||
output_contract: {},
|
||||
ttl_sec: 300,
|
||||
};
|
||||
|
||||
const AGENT_A = { ...MANIFEST_DEFAULTS, id: 'agent-a', required_consents: ['data:core'], silenced_in_contexts: [] };
|
||||
const AGENT_B = { ...MANIFEST_DEFAULTS, id: 'agent-b', required_consents: ['data:core', 'data:todoist'], silenced_in_contexts: [] };
|
||||
const AGENT_C = { ...MANIFEST_DEFAULTS, id: 'agent-c', required_consents: ['data:core'], silenced_in_contexts: ['vacation'] };
|
||||
|
||||
beforeAll(async () => {
|
||||
await testDb.insert(users).values({
|
||||
id: 'u1', email: 'u@test.com', name: null, image: null, role: 'user',
|
||||
consentGiven: false, createdAt: NOW,
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetchRegistry.mockReset();
|
||||
});
|
||||
|
||||
describe('getEligibleAgentIds', () => {
|
||||
it('returns empty set when registry is unavailable', async () => {
|
||||
mockFetchRegistry.mockRejectedValue(new Error('network'));
|
||||
const ids = await getEligibleAgentIds('u1');
|
||||
expect(ids.size).toBe(0);
|
||||
});
|
||||
|
||||
it('excludes agents whose required consents are not granted', async () => {
|
||||
mockFetchRegistry.mockResolvedValue({ agents: [AGENT_A, AGENT_B] });
|
||||
// only data:core granted
|
||||
await testDb.insert(userConsents).values({ userId: 'u1', consentKey: 'data:core', grantedAt: NOW, revokedAt: null });
|
||||
|
||||
const ids = await getEligibleAgentIds('u1');
|
||||
expect(ids.has('agent-a')).toBe(true);
|
||||
expect(ids.has('agent-b')).toBe(false);
|
||||
});
|
||||
|
||||
it('excludes agents when a required consent is revoked', async () => {
|
||||
mockFetchRegistry.mockResolvedValue({ agents: [AGENT_B] });
|
||||
// grant then revoke data:todoist
|
||||
await testDb.insert(userConsents).values([
|
||||
{ userId: 'u1', consentKey: 'data:todoist', grantedAt: NOW, revokedAt: NOW },
|
||||
]).onConflictDoUpdate({
|
||||
target: [userConsents.userId, userConsents.consentKey],
|
||||
set: { revokedAt: NOW },
|
||||
});
|
||||
|
||||
const ids = await getEligibleAgentIds('u1');
|
||||
expect(ids.has('agent-b')).toBe(false);
|
||||
});
|
||||
|
||||
it('respects legacy consentGiven bit as data:core', async () => {
|
||||
mockFetchRegistry.mockResolvedValue({ agents: [AGENT_A] });
|
||||
// no consent rows, but legacy bit set
|
||||
await testDb.update(users).set({ consentGiven: true });
|
||||
|
||||
const ids = await getEligibleAgentIds('u1');
|
||||
expect(ids.has('agent-a')).toBe(true);
|
||||
|
||||
await testDb.update(users).set({ consentGiven: false });
|
||||
});
|
||||
|
||||
it('silences agents whose silenced_in_contexts intersects active contexts', async () => {
|
||||
mockFetchRegistry.mockResolvedValue({ agents: [AGENT_A, AGENT_C] });
|
||||
// ensure data:core granted
|
||||
await testDb.insert(userConsents).values({ userId: 'u1', consentKey: 'data:core', grantedAt: NOW, revokedAt: null })
|
||||
.onConflictDoUpdate({ target: [userConsents.userId, userConsents.consentKey], set: { revokedAt: null } });
|
||||
// activate vacation context
|
||||
await testDb.insert(userContexts).values({ userId: 'u1', name: 'vacation', active: true, scheduleJson: null, createdAt: NOW });
|
||||
|
||||
const ids = await getEligibleAgentIds('u1');
|
||||
expect(ids.has('agent-a')).toBe(true);
|
||||
expect(ids.has('agent-c')).toBe(false);
|
||||
});
|
||||
|
||||
it('excludes agents explicitly disabled via user_preferences', async () => {
|
||||
mockFetchRegistry.mockResolvedValue({ agents: [AGENT_A] });
|
||||
await testDb.insert(userConsents).values({ userId: 'u1', consentKey: 'data:core', grantedAt: NOW, revokedAt: null })
|
||||
.onConflictDoUpdate({ target: [userConsents.userId, userConsents.consentKey], set: { revokedAt: null } });
|
||||
await testDb.insert(userPreferences).values({
|
||||
userId: 'u1', scope: 'agent:agent-a', key: 'enabled', valueJson: 'false', source: 'user', updatedAt: NOW,
|
||||
}).onConflictDoUpdate({
|
||||
target: [userPreferences.userId, userPreferences.scope, userPreferences.key],
|
||||
set: { valueJson: 'false' },
|
||||
});
|
||||
|
||||
const ids = await getEligibleAgentIds('u1');
|
||||
expect(ids.has('agent-a')).toBe(false);
|
||||
});
|
||||
|
||||
it('includes agents when enabled pref is true (or absent)', async () => {
|
||||
mockFetchRegistry.mockResolvedValue({ agents: [AGENT_A] });
|
||||
await testDb.insert(userConsents).values({ userId: 'u1', consentKey: 'data:core', grantedAt: NOW, revokedAt: null })
|
||||
.onConflictDoUpdate({ target: [userConsents.userId, userConsents.consentKey], set: { revokedAt: null } });
|
||||
await testDb.insert(userPreferences).values({
|
||||
userId: 'u1', scope: 'agent:agent-a', key: 'enabled', valueJson: 'true', source: 'user', updatedAt: NOW,
|
||||
}).onConflictDoUpdate({
|
||||
target: [userPreferences.userId, userPreferences.scope, userPreferences.key],
|
||||
set: { valueJson: 'true' },
|
||||
});
|
||||
|
||||
const ids = await getEligibleAgentIds('u1');
|
||||
expect(ids.has('agent-a')).toBe(true);
|
||||
});
|
||||
});
|
||||
88
services/api/src/profile/eligibility.ts
Normal file
88
services/api/src/profile/eligibility.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Registry-driven agent eligibility filter (ADR-0014 step 5).
|
||||
*
|
||||
* Rules (all must pass for an agent to be eligible):
|
||||
* 1. All required_consents are granted and not revoked.
|
||||
* 2. No silenced_in_contexts entry matches an active context.
|
||||
* 3. user_preferences[scope='agent:<id>', key='enabled'] is not false.
|
||||
*
|
||||
* Fail-closed: if the registry is unavailable, returns an empty set so the
|
||||
* orchestrator falls back to the random policy rather than proceeding without
|
||||
* consent checks.
|
||||
*/
|
||||
import { db } from '../db/index.js';
|
||||
import { users, userConsents, userPreferences, userContexts } from '../db/schema.js';
|
||||
import { eq, and, isNull } from 'drizzle-orm';
|
||||
import { fetchRegistry } from '../routes/agent-registry.js';
|
||||
|
||||
export interface AgentManifestWire {
|
||||
id: string;
|
||||
required_consents: string[];
|
||||
silenced_in_contexts: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface RegistryPayload {
|
||||
agents: AgentManifestWire[];
|
||||
}
|
||||
|
||||
export async function getEligibleAgentIds(userId: string): Promise<Set<string>> {
|
||||
let registry: RegistryPayload;
|
||||
try {
|
||||
registry = (await fetchRegistry()) as RegistryPayload;
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
const [consentRows, prefRows, contextRows, userRow] = await Promise.all([
|
||||
db
|
||||
.select({ consentKey: userConsents.consentKey })
|
||||
.from(userConsents)
|
||||
.where(and(eq(userConsents.userId, userId), isNull(userConsents.revokedAt))),
|
||||
db
|
||||
.select({ scope: userPreferences.scope, key: userPreferences.key, valueJson: userPreferences.valueJson })
|
||||
.from(userPreferences)
|
||||
.where(eq(userPreferences.userId, userId)),
|
||||
db
|
||||
.select({ name: userContexts.name, active: userContexts.active })
|
||||
.from(userContexts)
|
||||
.where(and(eq(userContexts.userId, userId), eq(userContexts.active, true))),
|
||||
db
|
||||
.select({ consentGiven: users.consentGiven })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1),
|
||||
]);
|
||||
|
||||
// Active consents (granted + not revoked)
|
||||
const activeConsents = new Set(consentRows.map((r) => r.consentKey));
|
||||
// Legacy fallback: consentGiven bit counts as data:core
|
||||
if (!activeConsents.has('data:core') && userRow[0]?.consentGiven) {
|
||||
activeConsents.add('data:core');
|
||||
}
|
||||
|
||||
// Active context names
|
||||
const activeContextNames = new Set(contextRows.map((r) => r.name));
|
||||
|
||||
// Per-agent enabled flag from user_preferences
|
||||
const agentEnabled: Record<string, boolean> = {};
|
||||
for (const p of prefRows) {
|
||||
if (!p.scope.startsWith('agent:')) continue;
|
||||
if (p.key !== 'enabled') continue;
|
||||
try {
|
||||
agentEnabled[p.scope] = JSON.parse(p.valueJson) as boolean;
|
||||
} catch {
|
||||
// ignore malformed
|
||||
}
|
||||
}
|
||||
|
||||
const eligible = new Set<string>();
|
||||
for (const manifest of registry.agents) {
|
||||
if (!manifest.required_consents.every((c) => activeConsents.has(c))) continue;
|
||||
if (manifest.silenced_in_contexts.some((ctx) => activeContextNames.has(ctx))) continue;
|
||||
const enabledPref = agentEnabled[`agent:${manifest.id}`];
|
||||
if (enabledPref === false) continue;
|
||||
eligible.add(manifest.id);
|
||||
}
|
||||
return eligible;
|
||||
}
|
||||
108
services/api/src/routes/__tests__/agent-registry.test.ts
Normal file
108
services/api/src/routes/__tests__/agent-registry.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* GET /api/agents/registry — proxies ml/serving's manifest list with a short
|
||||
* in-process cache. Tests stub global fetch and verify caching + 502 fallback.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, afterEach, beforeEach } from 'vitest';
|
||||
import express from 'express';
|
||||
import * as http from 'http';
|
||||
|
||||
vi.mock('../../middleware/session.js', () => ({
|
||||
sessionMiddleware: (_req: express.Request, _res: express.Response, next: express.NextFunction) => next(),
|
||||
requireAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
|
||||
(req as any).userId = 'user-1';
|
||||
next();
|
||||
},
|
||||
}));
|
||||
|
||||
const REGISTRY_PAYLOAD = {
|
||||
agents: [
|
||||
{ id: 'overdue-task', version: '1.0.0', pref_schema: { type: 'object' } },
|
||||
{ id: 'momentum', version: '1.0.0', pref_schema: { type: 'object' } },
|
||||
],
|
||||
};
|
||||
|
||||
function get(url: string): Promise<{ status: number; body: any }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const u = new URL(url);
|
||||
http.get({ hostname: u.hostname, port: Number(u.port), path: u.pathname }, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (c) => { data += c; });
|
||||
res.on('end', () => {
|
||||
try { resolve({ status: res.statusCode ?? 0, body: data ? JSON.parse(data) : null }); }
|
||||
catch { resolve({ status: res.statusCode ?? 0, body: data }); }
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
describe('GET /api/agents/registry', () => {
|
||||
let server: http.Server;
|
||||
let baseUrl: string;
|
||||
let savedFetch: typeof globalThis.fetch;
|
||||
let resetCache: () => void;
|
||||
|
||||
beforeAll(async () => {
|
||||
const mod = await import('../agent-registry.js');
|
||||
const router = mod.default;
|
||||
resetCache = mod._resetRegistryCache;
|
||||
const app = express();
|
||||
app.use('/api/agents', router);
|
||||
server = await new Promise<http.Server>((resolve) => {
|
||||
const s = app.listen(0, () => resolve(s));
|
||||
});
|
||||
const addr = server.address() as { port: number };
|
||||
baseUrl = `http://localhost:${addr.port}`;
|
||||
savedFetch = globalThis.fetch;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = savedFetch;
|
||||
});
|
||||
|
||||
it('proxies ml/serving manifests', async () => {
|
||||
const fetchMock = vi.fn(async () =>
|
||||
new Response(JSON.stringify(REGISTRY_PAYLOAD), { status: 200 }),
|
||||
);
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const r = await get(`${baseUrl}/api/agents/registry`);
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body).toEqual(REGISTRY_PAYLOAD);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('caches across calls within the TTL', async () => {
|
||||
const fetchMock = vi.fn(async () =>
|
||||
new Response(JSON.stringify(REGISTRY_PAYLOAD), { status: 200 }),
|
||||
);
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
await get(`${baseUrl}/api/agents/registry`);
|
||||
await get(`${baseUrl}/api/agents/registry`);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns 502 when ml/serving fails', async () => {
|
||||
globalThis.fetch = vi.fn(async () => new Response('boom', { status: 500 })) as unknown as typeof fetch;
|
||||
const r = await get(`${baseUrl}/api/agents/registry`);
|
||||
expect(r.status).toBe(502);
|
||||
expect(r.body.error).toBe('ml/serving unavailable');
|
||||
});
|
||||
|
||||
it('does not cache failures', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(new Response('boom', { status: 500 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(REGISTRY_PAYLOAD), { status: 200 }));
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const first = await get(`${baseUrl}/api/agents/registry`);
|
||||
expect(first.status).toBe(502);
|
||||
const second = await get(`${baseUrl}/api/agents/registry`);
|
||||
expect(second.status).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
201
services/api/src/routes/__tests__/profile.test.ts
Normal file
201
services/api/src/routes/__tests__/profile.test.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Integration tests for GET/PATCH /api/profile (ADR-0014 step 4).
|
||||
* Real in-memory SQLite; auth middleware mocked so requests arrive as 'user-1'.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
|
||||
import express from 'express';
|
||||
import * as http from 'http';
|
||||
import { makeTestDb } from '../../test/db.js';
|
||||
import { users, userPreferences, userConsents, userContexts } from '../../db/schema.js';
|
||||
|
||||
const testDb = makeTestDb();
|
||||
|
||||
vi.mock('../../db/index.js', () => ({ db: testDb, rawSqlite: testDb.rawSqlite }));
|
||||
|
||||
vi.mock('../../middleware/session.js', () => ({
|
||||
sessionMiddleware: (_req: express.Request, _res: express.Response, next: express.NextFunction) =>
|
||||
next(),
|
||||
requireAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
|
||||
(req as any).userId = 'user-1';
|
||||
next();
|
||||
},
|
||||
}));
|
||||
|
||||
function call(
|
||||
server: http.Server,
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<{ status: number; body: unknown }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { port } = server.address() as { port: number };
|
||||
const req = http.request(
|
||||
{ method, hostname: '127.0.0.1', port, path, headers: { 'Content-Type': 'application/json' } },
|
||||
(res) => {
|
||||
let data = '';
|
||||
res.on('data', (c) => (data += c));
|
||||
res.on('end', () => {
|
||||
try { resolve({ status: res.statusCode!, body: JSON.parse(data) }); }
|
||||
catch { resolve({ status: res.statusCode!, body: data }); }
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
if (body !== undefined) req.write(JSON.stringify(body));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function startServer(app: express.Application): Promise<{ server: http.Server; call: (method: string, path: string, body?: unknown) => ReturnType<typeof call> }> {
|
||||
return new Promise((resolve) => {
|
||||
const server = http.createServer(app);
|
||||
server.listen(0, () =>
|
||||
resolve({ server, call: (m, p, b) => call(server, m, p, b) }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const profileRouter = (await import('../profile.js')).default;
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/profile', profileRouter);
|
||||
|
||||
const { server, call: c } = await startServer(app);
|
||||
afterAll(() => server.close());
|
||||
|
||||
const NOW = new Date().toISOString();
|
||||
|
||||
beforeAll(async () => {
|
||||
await testDb.insert(users).values({
|
||||
id: 'user-1',
|
||||
email: 'a@example.com',
|
||||
name: 'Alice',
|
||||
image: null,
|
||||
role: 'user',
|
||||
consentGiven: false,
|
||||
tone: 'direct',
|
||||
tipKindsJson: JSON.stringify(['task', 'advice']),
|
||||
createdAt: NOW,
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/profile', () => {
|
||||
it('returns user globals with empty prefs/consents/contexts', async () => {
|
||||
const res = await c('GET', '/api/profile');
|
||||
expect(res.status).toBe(200);
|
||||
const body = res.body as any;
|
||||
expect(body.user).toMatchObject({ id: 'user-1', tone: 'direct', tipKinds: ['task', 'advice'] });
|
||||
expect(body.prefs).toEqual({});
|
||||
expect(body.consents).toEqual({});
|
||||
expect(body.contexts).toEqual([]);
|
||||
});
|
||||
|
||||
it('surfaces legacy consentGiven as data:core when no consent row exists', async () => {
|
||||
await testDb.update(users).set({ consentGiven: true, consentAt: NOW });
|
||||
const res = await c('GET', '/api/profile');
|
||||
expect((res.body as any).consents['data:core']).toMatchObject({ revokedAt: null });
|
||||
await testDb.update(users).set({ consentGiven: false });
|
||||
});
|
||||
|
||||
it('includes prefs grouped by scope', async () => {
|
||||
await testDb.insert(userPreferences).values([
|
||||
{ userId: 'user-1', scope: 'orchestrator', key: 'quietHours', valueJson: '"22:00-07:00"', source: 'user', updatedAt: NOW },
|
||||
{ userId: 'user-1', scope: 'agent:focus-area', key: 'areas', valueJson: '["work","health"]', source: 'inferred', updatedAt: NOW },
|
||||
]);
|
||||
const res = await c('GET', '/api/profile');
|
||||
const body = res.body as any;
|
||||
expect(body.prefs['orchestrator']).toMatchObject({ quietHours: '22:00-07:00' });
|
||||
expect(body.prefs['agent:focus-area']).toMatchObject({ areas: ['work', 'health'] });
|
||||
});
|
||||
|
||||
it('includes consents', async () => {
|
||||
await testDb.insert(userConsents).values([
|
||||
{ userId: 'user-1', consentKey: 'data:core', grantedAt: NOW, revokedAt: null },
|
||||
{ userId: 'user-1', consentKey: 'data:todoist', grantedAt: NOW, revokedAt: NOW },
|
||||
]);
|
||||
const body = (await c('GET', '/api/profile')).body as any;
|
||||
expect(body.consents['data:core'].revokedAt).toBeNull();
|
||||
expect(body.consents['data:todoist'].revokedAt).toBe(NOW);
|
||||
});
|
||||
|
||||
it('includes contexts', async () => {
|
||||
await testDb.insert(userContexts).values({
|
||||
userId: 'user-1', name: 'work', active: true, scheduleJson: null, createdAt: NOW,
|
||||
});
|
||||
const body = (await c('GET', '/api/profile')).body as any;
|
||||
expect(body.contexts).toContainEqual(expect.objectContaining({ name: 'work', active: true }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/profile/prefs/:scope', () => {
|
||||
it('upserts preference keys with source=user', async () => {
|
||||
const res = await c('PATCH', '/api/profile/prefs/orchestrator', { tone: 'gentle' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ ok: true });
|
||||
|
||||
const body = (await c('GET', '/api/profile')).body as any;
|
||||
expect(body.prefs['orchestrator']['tone']).toBe('gentle');
|
||||
});
|
||||
|
||||
it('overwrites an inferred value with user source', async () => {
|
||||
await testDb.insert(userPreferences).values({
|
||||
userId: 'user-1', scope: 'agent:momentum', key: 'enabled', valueJson: 'false',
|
||||
source: 'inferred', updatedAt: NOW,
|
||||
}).onConflictDoUpdate({
|
||||
target: [userPreferences.userId, userPreferences.scope, userPreferences.key],
|
||||
set: { valueJson: 'false', source: 'inferred', updatedAt: NOW },
|
||||
});
|
||||
|
||||
await c('PATCH', '/api/profile/prefs/agent:momentum', { enabled: true });
|
||||
const body = (await c('GET', '/api/profile')).body as any;
|
||||
expect(body.prefs['agent:momentum']['enabled']).toBe(true);
|
||||
});
|
||||
|
||||
it('returns 400 for non-object body', async () => {
|
||||
const res = await c('PATCH', '/api/profile/prefs/orchestrator', [1, 2]);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/profile/consents', () => {
|
||||
it('grants a new consent key', async () => {
|
||||
const res = await c('PATCH', '/api/profile/consents', { grant: ['data:calendar'] });
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await c('GET', '/api/profile')).body as any;
|
||||
expect(body.consents['data:calendar'].revokedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('revokes an existing active consent', async () => {
|
||||
await c('PATCH', '/api/profile/consents', { grant: ['agent:overdue-task'] });
|
||||
await c('PATCH', '/api/profile/consents', { revoke: ['agent:overdue-task'] });
|
||||
const body = (await c('GET', '/api/profile')).body as any;
|
||||
expect(body.consents['agent:overdue-task'].revokedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it('returns 400 when grant is not an array', async () => {
|
||||
const res = await c('PATCH', '/api/profile/consents', { grant: 'data:core' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/profile/contexts', () => {
|
||||
it('creates a new context', async () => {
|
||||
const res = await c('PATCH', '/api/profile/contexts', { name: 'vacation', active: false });
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await c('GET', '/api/profile')).body as any;
|
||||
expect(body.contexts).toContainEqual(expect.objectContaining({ name: 'vacation', active: false }));
|
||||
});
|
||||
|
||||
it('toggles active on existing context', async () => {
|
||||
await c('PATCH', '/api/profile/contexts', { name: 'home', active: false });
|
||||
await c('PATCH', '/api/profile/contexts', { name: 'home', active: true });
|
||||
const body = (await c('GET', '/api/profile')).body as any;
|
||||
const ctx = (body.contexts as any[]).find((x) => x.name === 'home');
|
||||
expect(ctx?.active).toBe(true);
|
||||
});
|
||||
|
||||
it('returns 400 when name is missing', async () => {
|
||||
const res = await c('PATCH', '/api/profile/contexts', { active: true });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,8 @@ import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest';
|
||||
import express from 'express';
|
||||
import * as http from 'http';
|
||||
import { makeTestDb } from '../../test/db.js';
|
||||
import { users, integrationTokens, tipScores } from '../../db/schema.js';
|
||||
import { users, integrationTokens, tipScores, agentOutputs, userConsents } from '../../db/schema.js';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
const testDb = makeTestDb();
|
||||
|
||||
@@ -155,4 +156,77 @@ describe('POST /recommend integration', () => {
|
||||
expect(row.promptVersion).toBeNull();
|
||||
expect(row.llmModel).toBeNull();
|
||||
});
|
||||
|
||||
it('eligibility filter: only passes consented agent outputs to ml/serving', async () => {
|
||||
const NOW = new Date().toISOString();
|
||||
const FUTURE = new Date(Date.now() + 60_000).toISOString();
|
||||
|
||||
// Grant data:core only — not data:todoist
|
||||
await testDb.insert(userConsents).values([
|
||||
{ userId: 'user-1', consentKey: 'data:core', grantedAt: NOW, revokedAt: null },
|
||||
]).onConflictDoUpdate({
|
||||
target: [userConsents.userId, userConsents.consentKey],
|
||||
set: { revokedAt: null },
|
||||
});
|
||||
|
||||
// Two agent outputs: time-of-day (needs data:core only) and overdue-task (needs data:todoist too)
|
||||
await testDb.insert(agentOutputs).values([
|
||||
{
|
||||
id: nanoid(), userId: 'user-1', agentId: 'time-of-day',
|
||||
promptText: 'It is morning.',
|
||||
computedAt: NOW, expiresAt: FUTURE, agentVersion: '1.0.0',
|
||||
},
|
||||
{
|
||||
id: nanoid(), userId: 'user-1', agentId: 'overdue-task',
|
||||
promptText: 'You have overdue tasks.',
|
||||
computedAt: NOW, expiresAt: FUTURE, agentVersion: '1.0.0',
|
||||
},
|
||||
]);
|
||||
|
||||
// Manifest: time-of-day requires ['data:core'], overdue-task requires ['data:core','data:todoist']
|
||||
const registry = {
|
||||
agents: [
|
||||
{ id: 'time-of-day', required_consents: ['data:core'], silenced_in_contexts: [], version: '1.0.0', description: '', pref_schema: {}, context_schema: [], output_contract: {}, ttl_sec: 300, inferred_params: [] },
|
||||
{ id: 'overdue-task', required_consents: ['data:core', 'data:todoist'], silenced_in_contexts: [], version: '1.0.0', description: '', pref_schema: {}, context_schema: [], output_contract: {}, ttl_sec: 300, inferred_params: [] },
|
||||
],
|
||||
};
|
||||
|
||||
let capturedAgentOutputs: { agent_id: string }[] = [];
|
||||
globalThis.fetch = vi.fn().mockImplementation((url: string) => {
|
||||
const u = String(url);
|
||||
if (u.includes('todoist.com')) {
|
||||
return Promise.resolve({ ok: true, status: 200, json: async () => ({ results: [] }) } as any);
|
||||
}
|
||||
if (u.includes('/agents/registry')) {
|
||||
return Promise.resolve({ ok: true, status: 200, json: async () => registry } as any);
|
||||
}
|
||||
if (u.includes('/recommend')) {
|
||||
return Promise.resolve({
|
||||
ok: true, status: 200,
|
||||
json: async (req?: Request) => {
|
||||
// The body has already been sent; capture via the mock call args instead
|
||||
return { tip: { id: 'tip-x', content: 'Stay focused.' }, model: 'tip-generator' };
|
||||
},
|
||||
} as any);
|
||||
}
|
||||
return Promise.resolve({ ok: false, status: 500 } as any);
|
||||
});
|
||||
|
||||
// Intercept the /recommend body to inspect what agent_outputs were sent
|
||||
const origFetch = globalThis.fetch as ReturnType<typeof vi.fn>;
|
||||
const wrappedFetch = vi.fn().mockImplementation(async (url: string, init?: RequestInit) => {
|
||||
if (String(url).includes('/recommend') && init?.body) {
|
||||
const body = JSON.parse(init.body as string);
|
||||
capturedAgentOutputs = body.agent_outputs ?? [];
|
||||
}
|
||||
return origFetch(url, init);
|
||||
});
|
||||
globalThis.fetch = wrappedFetch;
|
||||
|
||||
const { status } = await post(`${baseUrl}/api/recommend`);
|
||||
expect(status).toBe(200);
|
||||
|
||||
// Only time-of-day should have been passed; overdue-task is blocked (missing data:todoist)
|
||||
expect(capturedAgentOutputs.map((a) => a.agent_id)).toEqual(['time-of-day']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Router, type Request, type Response, type IRouter } from 'express';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { db } from '../db/index.js';
|
||||
import { agentOutputs, tipFeedback, tipViews } from '../db/schema.js';
|
||||
import { agentOutputs, tipFeedback, tipViews, userPreferences } from '../db/schema.js';
|
||||
import { eq, and, gt, lt } from 'drizzle-orm';
|
||||
import { config } from '../config.js';
|
||||
import { getProfile, type Profile } from '../profile/builder.js';
|
||||
@@ -78,6 +78,54 @@ router.get('/active-users', async (req: Request, res: Response) => {
|
||||
|
||||
// ── Core compute logic (used by route + scheduler) ───────────────────────────
|
||||
|
||||
/** Load agent prefs for a user from user_preferences, merging user+inferred.
|
||||
* User source wins: if both exist, the 'user' row is returned. */
|
||||
async function loadAgentPrefs(userId: string, agentId: string): Promise<Record<string, unknown>> {
|
||||
const scope = `agent:${agentId}`;
|
||||
const rows = await db
|
||||
.select({ key: userPreferences.key, valueJson: userPreferences.valueJson, source: userPreferences.source })
|
||||
.from(userPreferences)
|
||||
.where(and(eq(userPreferences.userId, userId), eq(userPreferences.scope, scope)));
|
||||
|
||||
// Build merged dict: 'user' source takes precedence over 'inferred'
|
||||
const merged: Record<string, { value: unknown; source: string }> = {};
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const value = JSON.parse(row.valueJson);
|
||||
const existing = merged[row.key];
|
||||
if (!existing || row.source === 'user') {
|
||||
merged[row.key] = { value, source: row.source };
|
||||
}
|
||||
} catch {
|
||||
// skip malformed
|
||||
}
|
||||
}
|
||||
return Object.fromEntries(Object.entries(merged).map(([k, v]) => [k, v.value]));
|
||||
}
|
||||
|
||||
/** Persist inferred prefs to user_preferences, skipping keys the user has explicitly set. */
|
||||
async function persistInferredPrefs(
|
||||
userId: string,
|
||||
agentId: string,
|
||||
inferredPrefs: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
if (!Object.keys(inferredPrefs).length) return;
|
||||
const scope = `agent:${agentId}`;
|
||||
const now = new Date().toISOString();
|
||||
for (const [key, value] of Object.entries(inferredPrefs)) {
|
||||
const valueJson = JSON.stringify(value);
|
||||
await db
|
||||
.insert(userPreferences)
|
||||
.values({ userId, scope, key, valueJson, source: 'inferred', updatedAt: now })
|
||||
.onConflictDoUpdate({
|
||||
target: [userPreferences.userId, userPreferences.scope, userPreferences.key],
|
||||
set: { valueJson, updatedAt: now },
|
||||
// Only overwrite rows already marked inferred; user overrides are untouched.
|
||||
setWhere: eq(userPreferences.source, 'inferred'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function computeAndStore(userId: string, agentId: string): Promise<void> {
|
||||
let tasks: object[] = [];
|
||||
try {
|
||||
@@ -111,10 +159,13 @@ export async function computeAndStore(userId: string, agentId: string): Promise<
|
||||
created_at: f.createdAt,
|
||||
}));
|
||||
|
||||
// Load agent prefs (user overrides + previous inferences) to inject into the compute call.
|
||||
const agentPrefs = await loadAgentPrefs(userId, agentId);
|
||||
|
||||
const mlResp = await fetch(`${config.ML_SERVING_URL}/agents/${agentId}/compute`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_id: userId, tasks, profile, feedback_history: feedbackHistory }),
|
||||
body: JSON.stringify({ user_id: userId, tasks, profile, feedback_history: feedbackHistory, agent_prefs: agentPrefs }),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
|
||||
@@ -129,6 +180,23 @@ export async function computeAndStore(userId: string, agentId: string): Promise<
|
||||
};
|
||||
|
||||
await storeAgentOutput(output);
|
||||
|
||||
// Run inference framework for this agent and persist results.
|
||||
// Failures are non-fatal — the compute result is already stored.
|
||||
try {
|
||||
const inferResp = await fetch(`${config.ML_SERVING_URL}/agents/${agentId}/infer`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_id: userId, feedback_history: feedbackHistory }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (inferResp.ok) {
|
||||
const inferResult = await inferResp.json() as { inferred_prefs: Record<string, unknown> };
|
||||
await persistInferredPrefs(userId, agentId, inferResult.inferred_prefs);
|
||||
}
|
||||
} catch {
|
||||
// inference failure is non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
// ── POST /api/agents/:agentId/compute ─────────────────────────────────────────
|
||||
|
||||
42
services/api/src/routes/agent-registry.ts
Normal file
42
services/api/src/routes/agent-registry.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Router, type Request, type Response, type IRouter } from 'express';
|
||||
import { config } from '../config.js';
|
||||
import { requireAuth } from '../middleware/session.js';
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
// Manifests change only on ml/serving restart, so a small in-process cache
|
||||
// avoids hammering the upstream on every admin pageview / profile fetch.
|
||||
const CACHE_TTL_MS = 60_000;
|
||||
let _cache: { fetchedAt: number; payload: unknown } | null = null;
|
||||
|
||||
export function _resetRegistryCache() {
|
||||
_cache = null;
|
||||
}
|
||||
|
||||
export async function fetchRegistry(): Promise<unknown> {
|
||||
if (_cache && Date.now() - _cache.fetchedAt < CACHE_TTL_MS) return _cache.payload;
|
||||
const upstream = await fetch(`${config.ML_SERVING_URL}/agents/registry`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!upstream.ok) {
|
||||
throw new Error(`ml/serving /agents/registry returned ${upstream.status}`);
|
||||
}
|
||||
const payload = await upstream.json();
|
||||
_cache = { fetchedAt: Date.now(), payload };
|
||||
return payload;
|
||||
}
|
||||
|
||||
// ── GET /api/agents/registry ─────────────────────────────────────────────────
|
||||
// Manifest list for every registered agent (ADR-0014). Auth-gated: manifests
|
||||
// drive admin UI form rendering and feed the orchestrator eligibility filter.
|
||||
|
||||
router.get('/registry', requireAuth as any, async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const payload = await fetchRegistry();
|
||||
res.json(payload);
|
||||
} catch (err: any) {
|
||||
res.status(502).json({ error: 'ml/serving unavailable', detail: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
202
services/api/src/routes/profile.ts
Normal file
202
services/api/src/routes/profile.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* GET /api/profile — read-through: user globals + prefs + contexts + consents
|
||||
* PATCH /api/profile/prefs/:scope — upsert user_preferences rows (source='user')
|
||||
* PATCH /api/profile/consents — grant or revoke consent keys
|
||||
* PATCH /api/profile/contexts — activate/deactivate or create user contexts
|
||||
*
|
||||
* ADR-0014 step 4.
|
||||
*/
|
||||
import { Router, type Response, type IRouter } from 'express';
|
||||
import { db } from '../db/index.js';
|
||||
import {
|
||||
users,
|
||||
userPreferences,
|
||||
userConsents,
|
||||
userContexts,
|
||||
} from '../db/schema.js';
|
||||
import { eq, and, isNull } from 'drizzle-orm';
|
||||
import { requireAuth, type AuthenticatedRequest } from '../middleware/session.js';
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
// ── GET /api/profile ─────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/', requireAuth as any, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const userId = req.userId!;
|
||||
|
||||
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
|
||||
if (!user || user.deletedAt) {
|
||||
res.status(404).json({ error: 'User not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const [prefs, consents, contexts] = await Promise.all([
|
||||
db.select().from(userPreferences).where(eq(userPreferences.userId, userId)),
|
||||
db.select().from(userConsents).where(eq(userConsents.userId, userId)),
|
||||
db.select().from(userContexts).where(eq(userContexts.userId, userId)),
|
||||
]);
|
||||
|
||||
// Group prefs by scope: { 'orchestrator': { key: value_json, … }, 'agent:foo': { … } }
|
||||
const prefsByScope: Record<string, Record<string, unknown>> = {};
|
||||
for (const p of prefs) {
|
||||
if (!prefsByScope[p.scope]) prefsByScope[p.scope] = {};
|
||||
try {
|
||||
prefsByScope[p.scope][p.key] = JSON.parse(p.valueJson);
|
||||
} catch {
|
||||
prefsByScope[p.scope][p.key] = p.valueJson;
|
||||
}
|
||||
}
|
||||
|
||||
// Consents: include both active and revoked (callers can filter on revokedAt)
|
||||
// Also fold in the legacy consentGiven bit if no user_consents row exists yet.
|
||||
const consentMap: Record<string, { grantedAt: string; revokedAt: string | null }> = {};
|
||||
for (const c of consents) {
|
||||
consentMap[c.consentKey] = { grantedAt: c.grantedAt, revokedAt: c.revokedAt ?? null };
|
||||
}
|
||||
// Legacy fallback: if data:core is missing and the old bit is set, synthesise it.
|
||||
if (!consentMap['data:core'] && user.consentGiven) {
|
||||
consentMap['data:core'] = { grantedAt: user.consentAt ?? user.createdAt, revokedAt: null };
|
||||
}
|
||||
|
||||
res.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
image: user.image,
|
||||
tone: user.tone ?? null,
|
||||
tipKinds: user.tipKindsJson ? JSON.parse(user.tipKindsJson) : null,
|
||||
},
|
||||
prefs: prefsByScope,
|
||||
consents: consentMap,
|
||||
contexts: contexts.map((c) => ({
|
||||
name: c.name,
|
||||
active: c.active,
|
||||
schedule: c.scheduleJson ? JSON.parse(c.scheduleJson) : null,
|
||||
createdAt: c.createdAt,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
// ── PATCH /api/profile/prefs/:scope ──────────────────────────────────────────
|
||||
// Body: { [key]: value } — each key is upserted as source='user'.
|
||||
|
||||
router.patch('/prefs/:scope', requireAuth as any, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const userId = req.userId!;
|
||||
const { scope } = req.params;
|
||||
const body = req.body as Record<string, unknown>;
|
||||
|
||||
if (!scope || typeof scope !== 'string') {
|
||||
res.status(400).json({ error: 'scope is required' });
|
||||
return;
|
||||
}
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||||
res.status(400).json({ error: 'body must be a JSON object' });
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
const valueJson = JSON.stringify(value);
|
||||
await db
|
||||
.insert(userPreferences)
|
||||
.values({ userId, scope, key, valueJson, source: 'user', updatedAt: now })
|
||||
.onConflictDoUpdate({
|
||||
target: [userPreferences.userId, userPreferences.scope, userPreferences.key],
|
||||
set: { valueJson, source: 'user', updatedAt: now },
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── PATCH /api/profile/consents ───────────────────────────────────────────────
|
||||
// Body: { grant?: string[], revoke?: string[] }
|
||||
|
||||
router.patch('/consents', requireAuth as any, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const userId = req.userId!;
|
||||
const { grant = [], revoke = [] } = req.body as { grant?: string[]; revoke?: string[] };
|
||||
|
||||
if (!Array.isArray(grant) || !Array.isArray(revoke)) {
|
||||
res.status(400).json({ error: 'grant and revoke must be arrays' });
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
for (const key of grant) {
|
||||
await db
|
||||
.insert(userConsents)
|
||||
.values({ userId, consentKey: key, grantedAt: now, revokedAt: null })
|
||||
.onConflictDoUpdate({
|
||||
target: [userConsents.userId, userConsents.consentKey],
|
||||
set: { grantedAt: now, revokedAt: null },
|
||||
});
|
||||
}
|
||||
|
||||
for (const key of revoke) {
|
||||
await db
|
||||
.update(userConsents)
|
||||
.set({ revokedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(userConsents.userId, userId),
|
||||
eq(userConsents.consentKey, key),
|
||||
isNull(userConsents.revokedAt),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── PATCH /api/profile/contexts ───────────────────────────────────────────────
|
||||
// Body: { name: string, active?: boolean, schedule?: object|null }
|
||||
// Creates the row if it doesn't exist; toggles active / updates schedule.
|
||||
|
||||
router.patch('/contexts', requireAuth as any, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const userId = req.userId!;
|
||||
const { name, active, schedule } = req.body as {
|
||||
name?: string;
|
||||
active?: boolean;
|
||||
schedule?: unknown;
|
||||
};
|
||||
|
||||
if (!name || typeof name !== 'string') {
|
||||
res.status(400).json({ error: 'name is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const scheduleJson = schedule !== undefined ? JSON.stringify(schedule) : undefined;
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(userContexts)
|
||||
.where(and(eq(userContexts.userId, userId), eq(userContexts.name, name)))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length === 0) {
|
||||
await db.insert(userContexts).values({
|
||||
userId,
|
||||
name,
|
||||
active: active ?? false,
|
||||
scheduleJson: scheduleJson ?? null,
|
||||
createdAt: now,
|
||||
});
|
||||
} else {
|
||||
const set: Partial<typeof userContexts.$inferInsert> = {};
|
||||
if (active !== undefined) set.active = active;
|
||||
if (scheduleJson !== undefined) set.scheduleJson = scheduleJson;
|
||||
if (Object.keys(set).length > 0) {
|
||||
await db
|
||||
.update(userContexts)
|
||||
.set(set)
|
||||
.where(and(eq(userContexts.userId, userId), eq(userContexts.name, name)));
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -12,6 +12,7 @@ import { todoistSource, dueAgeDays } from '../signals/todoist.js';
|
||||
export { dueAgeDays };
|
||||
import { SignalAggregator } from '../signals/aggregator.js';
|
||||
import { getActiveAgentOutputs } from './agent-outputs.js';
|
||||
import { getEligibleAgentIds } from '../profile/eligibility.js';
|
||||
|
||||
const router: ExpressRouter = Router();
|
||||
|
||||
@@ -58,11 +59,13 @@ async function fetchOrchestratorTip(
|
||||
dayOfWeek: number,
|
||||
traceparent?: string,
|
||||
): Promise<OrchestratorResult | null> {
|
||||
const agentRows = await getActiveAgentOutputs(userId);
|
||||
const agentOutputs = agentRows.map((r) => ({
|
||||
agent_id: r.agentId,
|
||||
prompt_text: r.promptText,
|
||||
}));
|
||||
const [allAgentRows, eligibleIds] = await Promise.all([
|
||||
getActiveAgentOutputs(userId),
|
||||
getEligibleAgentIds(userId),
|
||||
]);
|
||||
const agentOutputs = allAgentRows
|
||||
.filter((r) => eligibleIds.has(r.agentId))
|
||||
.map((r) => ({ agent_id: r.agentId, prompt_text: r.promptText }));
|
||||
|
||||
const tasks = signals.slice(0, 10).map((s) => ({
|
||||
content: s.content,
|
||||
|
||||
@@ -22,6 +22,8 @@ export function makeTestDb(): DrizzleDb & { rawSqlite: BetterSqlite3Database } {
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
consent_given INTEGER NOT NULL DEFAULT 0,
|
||||
consent_at TEXT,
|
||||
tone TEXT,
|
||||
tip_kinds_json TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
deleted_at TEXT
|
||||
);
|
||||
@@ -142,6 +144,33 @@ export function makeTestDb(): DrizzleDb & { rawSqlite: BetterSqlite3Database } {
|
||||
agent_version TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
scope TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value_json TEXT NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT 'user',
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, scope, key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_consents (
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
consent_key TEXT NOT NULL,
|
||||
granted_at TEXT NOT NULL,
|
||||
revoked_at TEXT,
|
||||
PRIMARY KEY (user_id, consent_key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_contexts (
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
name TEXT NOT NULL,
|
||||
active INTEGER NOT NULL DEFAULT 0,
|
||||
schedule_json TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sim_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
run_id TEXT NOT NULL REFERENCES sim_runs(id),
|
||||
|
||||
Reference in New Issue
Block a user