Compare commits

...

4 Commits

Author SHA1 Message Date
ad6747c242 feat(profile): /api/profile + eligibility filter + inference framework (ADR-0014 steps 4-6)
Step 4 — /api/profile read-through API:
  GET  /api/profile          → { user, prefs, consents, contexts }
  PATCH /api/profile/prefs/:scope  upsert user_preferences (source='user')
  PATCH /api/profile/consents      grant / revoke consent keys
  PATCH /api/profile/contexts      create / activate / deactivate contexts
  Legacy consentGiven bit folded in as data:core fallback.

Step 5 — registry-driven eligibility filter:
  fetchRegistry() exported from agent-registry.ts.
  profile/eligibility.ts: getEligibleAgentIds(userId) — filters by required
  consents, silenced_in_contexts, and user_preferences[enabled=false].
  fetchOrchestratorTip filters agent_outputs to eligible set before calling
  ml/serving /recommend. Fail-closed: registry unavailable → empty set.

Step 6 — shared context-inference framework (#111) + time-of-day proof (#112):
  ml/agents/inference/: UserHistory, FeedbackEvent, run_inference().
  Framework: cold-start, min_history gating, error fallback, structured logs.
  TimeOfDayAgent v1.1.0: inferred_params=[preferred_hour]; also reads
  quiet_start/quiet_end from agent_prefs. agent_prefs injected by TS caller.
  AgentInput gains agent_prefs field.
  ml/serving: POST /agents/{agent_id}/infer endpoint.
  agent-outputs.ts computeAndStore: loads prefs before compute, calls /infer
  after, persists results (source='inferred'); user overrides never touched.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 11:14:25 +00:00
305eeae38b feat(agents): manifest plumbing + GET /agents/registry (ADR-0014 step 3)
Each agent now exports a module-level MANIFEST declaring id, version,
pref_schema, required_consents, ttl_sec, and silenced_in_contexts. The
registry surfaces both the agent and its manifest, and rejects on
mismatch so the two cannot drift.

ml/serving exposes GET /agents/registry; services/api proxies it as
GET /api/agents/registry with a 60s in-process cache so admin pageviews
don't hammer upstream. Failures aren't cached.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 10:55:54 +00:00
5d43339616 feat(api): unified Profile schema + consent backfill (ADR-0014 step 1-2)
Adds user_preferences, user_consents, user_contexts and the tone /
tip_kinds_json columns on users. Backfills consent_given=1 rows into
user_consents as data:core; INSERT OR IGNORE keeps it idempotent and
respects later revocations.

Migration body moves to db/migrations.ts so tests can apply it to a
fresh in-memory handle without opening the prod DB on import.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 10:28:47 +00:00
d454a0a8bf docs: ADR-0014 — unified Profile model + agent registry
Propose a shared substrate for per-user prefs, contexts, per-key
consents, and per-agent state so adding an agent stays a manifest
change. Updates CLAUDE.md, README, and architecture docs to reflect
the multi-agent pipeline (ADR-0013) and the registry direction.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 10:19:07 +00:00
39 changed files with 2452 additions and 269 deletions

View File

@@ -78,7 +78,7 @@ docs/ architecture notes, ADRs, API specs
## AI stack ## 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 | | 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. 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:** **Multi-agent tip generation pipeline (ADR-0013):**
1. `ml/features/context.py` assembles user signals → structured prompt context 1. Pre-compute agents (`ml/agents/<id>/`) run on a schedule, each emitting a snippet into `agent_outputs` with a per-agent TTL
2. `POST /generate` in `ml/serving` calls LiteLLM → returns `TipCandidate[]` 2. On request, `recommender` (TS) loads the eligible agent set (registry-driven, ADR-0014) and pulls the freshest non-expired snippets
3. Bandit policy in `ml/serving` scores + ranks candidates 3. `POST /recommend` in `ml/serving` assembles the orchestrator prompt (`v4-orchestrator`) and calls LiteLLM via the `tip-generator` alias
4. Best candidate returned as tip; reaction closes the online reward loop 4. Returned tip is logged in `tip_scores` with the contributing agent set; reaction is logged for observability (no bandit reward loop)
## Current phase ## 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`. **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): Recent completions:
- ADR-0012ε-greedy v2 promotion (profile features, D=12) — 2026-04-26 - ADR-0013multi-agent recommendation: pre-computed agent snippets + orchestrator LLM (replaces ε-greedy bandit) — 2026-05-01
- Offline sim framework + MLflow integration — shipped in M1 add-on - LLM context assembler + tip generation scaffold (#79, #88)
- Token-based admin auth for Playwright/CI — secured auth boundary - Model benchmarking for tip generation (#93, #95)
- Admin UX refinements: feedback consolidation, settings placement (#100102)
- ADR-0012 — ε-greedy v2 (D=12) — 2026-04-26 (now superseded by ADR-0013)
- ADR-0014 steps 16: 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): 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) - Signal abstraction for multi-source support (#78)
- Per-user feature freshness SLAs (#61, ADR-0011 phase B) - Per-user feature freshness SLAs (#61, ADR-0011 phase B)
- LLM context assembler + tip generation scaffold (#79, #88)
- Model benchmarking for tip generation (#93) ## ADR-0014 endpoint map (as of step 6)
- Admin UX refinements: feedback consolidation, settings placement (#100102)
| 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 ## 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 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 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 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 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. - 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.

View File

@@ -69,7 +69,7 @@ docs/ architecture, adr, api
## AI stack ## 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 ### 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) | | 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) | | 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) User signals Pre-compute agents (every 15 min)
(tasks, calendar, (ml/features/) (routing) or cloud fallback (tasks, calendar, ──▶ ml/agents/{overdue-task, momentum, ──▶ agent_outputs
patterns, time) 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 Orchestrator prompt (`v4-orchestrator`)
{content, kind, model, = global prefs + active context + snippets
prompt_version, confidence}
Bandit policy (ml/serving) LiteLLM ──▶ Ollama (local) / cloud fallback
scores + ranks candidates
Best tip shown Tip shown to user
User reaction (done / snooze / dismiss + dwell) 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. **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 ### 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. 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 infrastructure (unblock everything else):**
- [ ] `ai` compose profile — Ollama + LiteLLM for local dev; env vars `OLLAMA_URL` / `LITELLM_URL` (#86) - [ ] `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) - [ ] AI gateway — wire `ml/serving` to LiteLLM; model aliases `tip-generator` + `embedder` (#87)

File diff suppressed because one or more lines are too long

View 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.

View File

@@ -25,12 +25,37 @@ Session auth
expires_at expires_at
revoked_at? revoked_at?
Profile profile User (extended) profile ADR-0014
user_id (pk) + tone 'direct' | 'gentle' | 'motivational'
timezone + tip_kinds_json jsonb: allowed tip kinds (stable globals)
quiet_hours jsonb: [{start,end,days}]
contexts jsonb: [{name,predicate}] introduced in Phase 2 UserPreference profile ADR-0014
consents jsonb: {integration: {read,write,retain_days}} 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 Credential integrations
user_id user_id
@@ -53,10 +78,10 @@ Event events
TipInstance recommender TipInstance recommender
tip_id (ulid) tip_id (ulid)
user_id user_id
policy_name "random" | "bandit.linucb" | "remote:v3" policy_name "v4-orchestrator" (ADR-0013) | legacy bandit names retained for history
policy_version policy_version
candidate_source "todoist" | "advice.library" | ... candidate_source "todoist" | "advice.library" | "agent-orchestrator" | ...
context_snapshot jsonb: features seen at decision time context_snapshot jsonb: features + agent snippets seen at decision time
tip jsonb: {kind,title,body,source,deep_link,meta} tip jsonb: {kind,title,body,source,deep_link,meta}
created_at created_at
shown_at? set when the client reports render shown_at? set when the client reports render

View File

@@ -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). - **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`. - **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. - **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. - **k3s** as the first step beyond docker-compose — no "compose → full k8s" cliff.
## AI stack ## 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. **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) 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) ml/serving (Python)
├─► context: ml/features/context.py ├─► assemble: v4-orchestrator prompt
(tasks + reactions + time patterns → prompt) = global prefs + active context + snippets
├─► generate: LiteLLM → Ollama ├─► generate: LiteLLM → Ollama → one tip
│ → N TipCandidates {content, kind, model, prompt_version}
─► score: bandit policy scores each candidate ─► persist: tip_scores {tip, contributing agents,
prompt_version, llm_model, latency}
├─► shadows: shadow policies log picks without serving ◄─ tip
└─► persist: tip_scores {candidate, policy, features, latency}
◄─ best TipCandidate
``` ```
**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)`. No online ML reward loop (ADR-0013 §Consequences); reactions are logged in `tip_feedback` for observability and potential future supervised learning.
Feedback: `POST /feedback → events.emit(reaction)` → online bandit update + `prompt_version` tracked for A/B analysis.

View File

@@ -26,7 +26,7 @@ User taps "Delete account" in settings → hard confirm → `User.deleted_at` se
## Scope boundaries ## 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 ## Audit

View File

@@ -15,6 +15,11 @@ class AgentInput:
profile: dict[str, float | None] # profile feature values keyed by feature name 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}, …] feedback_history: list[dict] = field(default_factory=list) # [{action, dwell_ms, created_at}, …]
now: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) 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 @dataclass

View File

@@ -2,13 +2,37 @@ from __future__ import annotations
from collections import defaultdict from collections import defaultdict
from typing import ClassVar from typing import ClassVar
from .base import BaseAgent, AgentInput, AgentOutput 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): class FocusAreaAgent(BaseAgent):
"""Identifies the most congested project/area in the user's task list.""" """Identifies the most congested project/area in the user's task list."""
agent_id: ClassVar[str] = "focus-area" agent_id: ClassVar[str] = MANIFEST.id
ttl_seconds: ClassVar[int] = 43_200 # 12h ttl_seconds: ClassVar[int] = MANIFEST.ttl_sec
version: ClassVar[str] = "1.0.0" version: ClassVar[str] = MANIFEST.version
def compute(self, inp: AgentInput) -> AgentOutput: def compute(self, inp: AgentInput) -> AgentOutput:
by_project: dict[str, list[dict]] = defaultdict(list) by_project: dict[str, list[dict]] = defaultdict(list)

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

View 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

View 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
View 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
],
}

View File

@@ -1,13 +1,38 @@
from __future__ import annotations from __future__ import annotations
from typing import ClassVar from typing import ClassVar
from .base import BaseAgent, AgentInput, AgentOutput 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): class MomentumAgent(BaseAgent):
"""Characterises the user's recent engagement trend from profile features.""" """Characterises the user's recent engagement trend from profile features."""
agent_id: ClassVar[str] = "momentum" agent_id: ClassVar[str] = MANIFEST.id
ttl_seconds: ClassVar[int] = 21600 # 6h ttl_seconds: ClassVar[int] = MANIFEST.ttl_sec
version: ClassVar[str] = "1.0.0" version: ClassVar[str] = MANIFEST.version
def compute(self, inp: AgentInput) -> AgentOutput: def compute(self, inp: AgentInput) -> AgentOutput:
completion = inp.profile.get("completion_rate_30d") completion = inp.profile.get("completion_rate_30d")

View File

@@ -1,13 +1,38 @@
from __future__ import annotations from __future__ import annotations
from typing import ClassVar from typing import ClassVar
from .base import BaseAgent, AgentInput, AgentOutput 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): class OverdueTaskAgent(BaseAgent):
"""Reports the user's overdue tasks by count and age.""" """Reports the user's overdue tasks by count and age."""
agent_id: ClassVar[str] = "overdue-task" agent_id: ClassVar[str] = MANIFEST.id
ttl_seconds: ClassVar[int] = 3600 # 1h — overdue status changes infrequently ttl_seconds: ClassVar[int] = MANIFEST.ttl_sec
version: ClassVar[str] = "1.0.0" version: ClassVar[str] = MANIFEST.version
def compute(self, inp: AgentInput) -> AgentOutput: def compute(self, inp: AgentInput) -> AgentOutput:
overdue = [t for t in inp.tasks if t.get("is_overdue")] overdue = [t for t in inp.tasks if t.get("is_overdue")]

View File

@@ -3,15 +3,40 @@ from collections import Counter
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import ClassVar from typing import ClassVar
from .base import BaseAgent, AgentInput, AgentOutput from .base import BaseAgent, AgentInput, AgentOutput
from .manifest import AgentManifest
_SEVEN_DAYS_S = 7 * 86_400 _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): class RecentPatternsAgent(BaseAgent):
"""Surfaces the user's reaction pattern from the last 7 days of feedback.""" """Surfaces the user's reaction pattern from the last 7 days of feedback."""
agent_id: ClassVar[str] = "recent-patterns" agent_id: ClassVar[str] = MANIFEST.id
ttl_seconds: ClassVar[int] = 86_400 # 24h ttl_seconds: ClassVar[int] = MANIFEST.ttl_sec
version: ClassVar[str] = "1.0.0" version: ClassVar[str] = MANIFEST.version
def compute(self, inp: AgentInput) -> AgentOutput: def compute(self, inp: AgentInput) -> AgentOutput:
now_ts = inp.now.timestamp() now_ts = inp.now.timestamp()

View File

@@ -1,21 +1,41 @@
from __future__ import annotations """Agent registry — single point of registration for sub-agents (ADR-0014).
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
_AGENTS: dict[str, BaseAgent] = { Each agent module contributes:
a.agent_id: a - a `BaseAgent` subclass instance
for a in [ - a module-level `MANIFEST: AgentManifest`
OverdueTaskAgent(),
MomentumAgent(), The orchestrator, registry endpoint, and inference framework all read from
TimeOfDayAgent(), here. Adding an agent is: add a module, register it once below.
RecentPatternsAgent(), """
FocusAreaAgent(), 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: def get_agent(agent_id: str) -> BaseAgent:
@@ -26,3 +46,13 @@ def get_agent(agent_id: str) -> BaseAgent:
def all_agents() -> list[BaseAgent]: def all_agents() -> list[BaseAgent]:
return list(_AGENTS.values()) 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())

View File

@@ -153,7 +153,7 @@ class TestTimeOfDayAgent:
def test_snapshot_keys(self): def test_snapshot_keys(self):
out = self.agent.compute(_inp()) 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 ─────────────────────────────────────────────────────── # ── RecentPatternsAgent ───────────────────────────────────────────────────────

View 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

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

View File

@@ -1,44 +1,125 @@
from __future__ import annotations from __future__ import annotations
from collections import Counter
from typing import ClassVar from typing import ClassVar
from .base import BaseAgent, AgentInput, AgentOutput 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"] _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): class TimeOfDayAgent(BaseAgent):
"""Frames the current moment relative to the user's productive peak.""" """Frames the current moment relative to the user's productive peak."""
agent_id: ClassVar[str] = "time-of-day" agent_id: ClassVar[str] = MANIFEST.id
ttl_seconds: ClassVar[int] = 900 # 15m — must stay current-hour accurate ttl_seconds: ClassVar[int] = MANIFEST.ttl_sec
version: ClassVar[str] = "1.0.0" version: ClassVar[str] = MANIFEST.version
def compute(self, inp: AgentInput) -> AgentOutput: def compute(self, inp: AgentInput) -> AgentOutput:
hour = inp.now.hour hour = inp.now.hour
dow = inp.now.weekday() # 0=Monday … 6=Sunday dow = inp.now.weekday() # 0=Monday … 6=Sunday
preferred = inp.profile.get("preferred_hour")
is_weekend = dow >= 5 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)})."] parts = [f"It is {hour:02d}:00 on {_DOW_NAMES[dow]} ({self._label(hour)})."]
if is_weekend: if is_weekend:
parts.append("Weekend context — prefer personal or reflective tips over work tasks.") 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: if preferred is not None:
ph = int(preferred) delta = min(abs(hour - preferred), 24 - abs(hour - preferred))
delta = min(abs(hour - ph), 24 - abs(hour - ph)) # circular distance
if delta == 0: if delta == 0:
parts.append( parts.append(
f"This is the user's peak productivity hour ({ph:02d}:00) — " f"This is the user's peak productivity hour ({preferred:02d}:00) — "
f"a high-impact tip is appropriate." "a high-impact tip is appropriate."
) )
elif delta <= 2: 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: else:
parts.append("No preferred-hour data yet.") parts.append("No preferred-hour data yet.")
prompt = " ".join(parts) 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) 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:0007:00
return hour >= sh or hour < eh
@staticmethod @staticmethod
def _label(hour: int) -> str: def _label(hour: int) -> str:
if 5 <= hour < 12: if 5 <= hour < 12:

View File

@@ -3,6 +3,7 @@ oO ML Serving — multi-agent orchestrator (ADR-0013).
Contract: Contract:
POST /agents/{agent_id}/compute run a sub-agent, return prompt snippet 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 /recommend orchestrate agent snippets → one tip via LiteLLM
POST /generate LLM tip candidates (legacy; kept for bench/eval) POST /generate LLM tip candidates (legacy; kept for bench/eval)
GET /health { ok, agents: [...] } GET /health { ok, agents: [...] }
@@ -38,7 +39,8 @@ if _repo_root not in sys.path:
sys.path.insert(0, _repo_root) sys.path.insert(0, _repo_root)
from ml.agents.base import AgentInput # noqa: E402 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() logging_config.configure()
@@ -123,6 +125,8 @@ class AgentComputeRequest(BaseModel):
profile: dict[str, Optional[float]] = {} profile: dict[str, Optional[float]] = {}
feedback_history: list[dict] = [] feedback_history: list[dict] = []
now_iso: Optional[str] = None # ISO 8601; defaults to utcnow 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): class AgentComputeResponse(BaseModel):
@@ -135,6 +139,18 @@ class AgentComputeResponse(BaseModel):
agent_version: str 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): class AgentOutputSnippet(BaseModel):
agent_id: str agent_id: str
prompt_text: 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 = ( _RETRY_SUFFIX = (
"\n\nYour previous response was not valid JSON. " "\n\nYour previous response was not valid JSON. "
"Reply ONLY with the JSON array — no prose, no markdown fences." "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, profile=req.profile,
feedback_history=req.feedback_history, feedback_history=req.feedback_history,
now=now, now=now,
agent_prefs=req.agent_prefs,
) )
try: try:
output = agent.compute(inp) 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) @app.post("/recommend", response_model=RecommendResponse)
async def recommend(req: RecommendRequest) -> RecommendResponse: async def recommend(req: RecommendRequest) -> RecommendResponse:
"""Orchestrator: combine pre-computed agent outputs into one tip via LLM. """Orchestrator: combine pre-computed agent outputs into one tip via LLM.

View 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

View 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

View 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');
});
});

View File

@@ -2,6 +2,7 @@ import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3'; import { drizzle } from 'drizzle-orm/better-sqlite3';
import * as schema from './schema.js'; import * as schema from './schema.js';
import { config } from '../config.js'; import { config } from '../config.js';
import { runMigrations as runMigrationsImpl } from './migrations.js';
const sqlite = new Database(config.DATABASE_PATH); const sqlite = new Database(config.DATABASE_PATH);
sqlite.pragma('journal_mode = WAL'); sqlite.pragma('journal_mode = WAL');
@@ -13,172 +14,5 @@ export const db = drizzle(sqlite, { schema });
export const rawSqlite: any = sqlite; export const rawSqlite: any = sqlite;
export function runMigrations() { export function runMigrations() {
sqlite.exec(` runMigrationsImpl(sqlite);
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);
}
} }

View 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);
}
}

View File

@@ -7,12 +7,50 @@ export const users = sqliteTable('users', {
image: text('image'), image: text('image'),
googleId: text('google_id').unique(), googleId: text('google_id').unique(),
role: text('role').notNull().default('user'), // 'user' | 'admin' 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), consentGiven: integer('consent_given', { mode: 'boolean' }).notNull().default(false),
consentAt: text('consent_at'), 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(), createdAt: text('created_at').notNull(),
deletedAt: text('deleted_at'), 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', { export const integrationTokens = sqliteTable('integration_tokens', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id), userId: text('user_id').notNull().references(() => users.id),

View File

@@ -18,6 +18,8 @@ import { pushRouter } from './routes/push.js';
import { adminRouter, adminInternalRouter } from './routes/admin.js'; import { adminRouter, adminInternalRouter } from './routes/admin.js';
import benchRouter from './routes/bench.js'; import benchRouter from './routes/bench.js';
import agentOutputsRouter from './routes/agent-outputs.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 { mkdir } from 'fs/promises';
import { dirname } from 'path'; import { dirname } from 'path';
import { requireAuth } from './middleware/session.js'; import { requireAuth } from './middleware/session.js';
@@ -70,7 +72,10 @@ app.use('/api/push', pushRouter);
app.use('/api/admin', adminRouter); app.use('/api/admin', adminRouter);
app.use('/api/admin', adminInternalRouter); app.use('/api/admin', adminInternalRouter);
app.use('/api/bench', requireAuth as any, requireAdmin as any, benchRouter); 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/agents', agentOutputsRouter);
app.use('/api/profile', profileRouter);
app.use('/api/ml', requireAuth as any, requireAdmin as any, async (req: Request, res: Response) => { app.use('/api/ml', requireAuth as any, requireAdmin as any, async (req: Request, res: Response) => {
const mlUrl = config.ML_SERVING_URL; const mlUrl = config.ML_SERVING_URL;

View 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);
});
});

View 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;
}

View 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);
});
});

View 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);
});
});

View File

@@ -13,7 +13,8 @@ import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest';
import express from 'express'; import express from 'express';
import * as http from 'http'; import * as http from 'http';
import { makeTestDb } from '../../test/db.js'; 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(); const testDb = makeTestDb();
@@ -155,4 +156,77 @@ describe('POST /recommend integration', () => {
expect(row.promptVersion).toBeNull(); expect(row.promptVersion).toBeNull();
expect(row.llmModel).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']);
});
}); });

View File

@@ -1,7 +1,7 @@
import { Router, type Request, type Response, type IRouter } from 'express'; import { Router, type Request, type Response, type IRouter } from 'express';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { db } from '../db/index.js'; 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 { eq, and, gt, lt } from 'drizzle-orm';
import { config } from '../config.js'; import { config } from '../config.js';
import { getProfile, type Profile } from '../profile/builder.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) ─────────────────────────── // ── 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> { export async function computeAndStore(userId: string, agentId: string): Promise<void> {
let tasks: object[] = []; let tasks: object[] = [];
try { try {
@@ -111,10 +159,13 @@ export async function computeAndStore(userId: string, agentId: string): Promise<
created_at: f.createdAt, 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`, { const mlResp = await fetch(`${config.ML_SERVING_URL}/agents/${agentId}/compute`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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), signal: AbortSignal.timeout(15_000),
}); });
@@ -129,6 +180,23 @@ export async function computeAndStore(userId: string, agentId: string): Promise<
}; };
await storeAgentOutput(output); 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 ───────────────────────────────────────── // ── POST /api/agents/:agentId/compute ─────────────────────────────────────────

View 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;

View 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;

View File

@@ -12,6 +12,7 @@ import { todoistSource, dueAgeDays } from '../signals/todoist.js';
export { dueAgeDays }; export { dueAgeDays };
import { SignalAggregator } from '../signals/aggregator.js'; import { SignalAggregator } from '../signals/aggregator.js';
import { getActiveAgentOutputs } from './agent-outputs.js'; import { getActiveAgentOutputs } from './agent-outputs.js';
import { getEligibleAgentIds } from '../profile/eligibility.js';
const router: ExpressRouter = Router(); const router: ExpressRouter = Router();
@@ -58,11 +59,13 @@ async function fetchOrchestratorTip(
dayOfWeek: number, dayOfWeek: number,
traceparent?: string, traceparent?: string,
): Promise<OrchestratorResult | null> { ): Promise<OrchestratorResult | null> {
const agentRows = await getActiveAgentOutputs(userId); const [allAgentRows, eligibleIds] = await Promise.all([
const agentOutputs = agentRows.map((r) => ({ getActiveAgentOutputs(userId),
agent_id: r.agentId, getEligibleAgentIds(userId),
prompt_text: r.promptText, ]);
})); 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) => ({ const tasks = signals.slice(0, 10).map((s) => ({
content: s.content, content: s.content,

View File

@@ -22,6 +22,8 @@ export function makeTestDb(): DrizzleDb & { rawSqlite: BetterSqlite3Database } {
role TEXT NOT NULL DEFAULT 'user', role TEXT NOT NULL DEFAULT 'user',
consent_given INTEGER NOT NULL DEFAULT 0, consent_given INTEGER NOT NULL DEFAULT 0,
consent_at TEXT, consent_at TEXT,
tone TEXT,
tip_kinds_json TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
deleted_at TEXT deleted_at TEXT
); );
@@ -142,6 +144,33 @@ export function makeTestDb(): DrizzleDb & { rawSqlite: BetterSqlite3Database } {
agent_version TEXT NOT NULL 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 ( CREATE TABLE IF NOT EXISTS sim_events (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
run_id TEXT NOT NULL REFERENCES sim_runs(id), run_id TEXT NOT NULL REFERENCES sim_runs(id),