Compare commits

...

103 Commits

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 15:45:08 +00:00
b1bd3d465f docs(readme): replace inline issue checklists with Gitea milestone links
Roadmap phase sections now show shipped summaries only; open work lives
in Gitea milestones. Eliminates duplicate source-of-truth between README
and issue tracker.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 15:34:45 +00:00
8fd08379d7 chore(m2): close out remaining loose ends (#80, #86, #90)
- Add `ai` compose profile — Ollama + LiteLLM containers for local dev
  when Agap shared services are unavailable; use with LITELLM_URL /
  OLLAMA_URL env vars pointing ml-serving at localhost
- Mark #90 done (LLM schema validation + fallback shipped in 85a332b)
- Mark #80 superseded by ADR-0013 (multi-agent orchestrator is the pipeline)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 15:31:25 +00:00
85a332b22b feat(recommender): LLM schema validation + hardcoded fallback tips on AI failure (#90)
Python (ml/serving):
- Validate tip item after JSON parse: non-empty content, valid kind
- Retry on schema failure with a targeted clarification prompt, same 2× retry budget
- JSON parse failures keep the existing retry suffix

TypeScript (recommender):
- Add TipSource 'fallback' to shared-types
- FALLBACK_TIPS: 12 general-purpose life tips (hardcoded, no DB read)
- fetchOrchestratorTip returns {ok} discriminated union instead of null
- On !res.ok or fetch error: serve a random fallback tip with rationale 'AI service issues'
- Update tests: 204 path removed; both failure cases now expect source='fallback'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 15:21:03 +00:00
772bb6e194 feat(consents): auto-grant data:<provider> on connect; remove agent: consents (ADR-0015)
- integrations.ts: grant data:<provider> on OAuth callback, revoke on disconnect
- Backfill migration: INSERT OR IGNORE data:<provider> for all active tokens
- Agent manifests: drop agent:<id> from required_consents (momentum, time-of-day,
  overdue-task, recent-patterns, health-vitals) — per-agent control is a preference
- eligibility.ts: update comment to reflect data:-only consent model
- test_manifest.py: assert no agent: consents remain in any manifest
- migrations.test.ts: backfill idempotency tests for issue #127
- Dockerfile.api: drop --offline flag (fixes ERR_PNPM_NO_OFFLINE_META)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 15:09:58 +00:00
34925310cf docs: update focus-area manifest description and CLAUDE.md
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 15:00:06 +00:00
f66f337779 feat(focus-area): use enriched descriptions in cluster output
cluster_tasks now attaches enriched_description to each task dict.
focus-area reads enriched_description (falling back to raw content) when
building the area summary, so the orchestrator sees the expanded 3-sentence
descriptions instead of terse raw titles.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 14:58:31 +00:00
f6b89fc849 refactor(focus-area): output all clusters as context; remove scoring and preferred_areas
The agent no longer picks a winner — it summarises every cluster so the
orchestrator can decide what's relevant. Scoring by overdue count overlapped
with the overdue-task agent. preferred_areas (project-ID based, broken label
matching) removed entirely.

Output format: numbered list of areas with task titles included.
Snapshot: {cluster_count, clusters: [{label, task_count, tasks}]}.
Version bumped to 3.0.0; inferred_params cleared.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 14:57:04 +00:00
12c956b588 fix(clustering): drop TTL check from isUpToDate; task hash is the only signal
If tasks haven't changed, the output is valid forever. If they changed,
always recompute regardless of age. TTL on focus-area restored to 24h —
it only controls recommender eligibility, not recompute frequency.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 14:46:43 +00:00
d12f11d29d feat(clustering): 1h TTL + skip recompute when tasks unchanged
focus-area now recomputes at most once per hour, and only if the task list
actually changed since the last compute.

- focus-area TTL: 43200s → 3600s; version bumped to 2.1.0
- computeAndStore hashes sorted task contents (MD5) and checks the stored
  _task_hash in the existing snapshot; skips the ml-serving call when the
  hash matches and the output isn't expired
- ml-serving injects _task_hash into the snapshot so the next cycle can compare

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 14:45:15 +00:00
9ddeea6cac feat(clustering): persistent enrichment cache in task_enrichments table
Each unique task title is now enriched by LiteLLM once and cached in the DB.
Subsequent agent compute cycles (every 12h) fetch the cache before calling
ml-serving; only new titles hit the tip-generator.

- DB: task_enrichments(content_hash PK, description, model, created_at)
- TS: fetchEnrichmentCache / persistEnrichments helpers in agent-outputs.ts;
  enrichment_cache passed in compute request, new_enrichments persisted from response
- Python: AgentComputeRequest.enrichment_cache / AgentComputeResponse.new_enrichments;
  AgentInput.enrichment_cache; _enrich_batch returns (descriptions, new_entries);
  cluster_tasks returns (clusters, new_enrichments)
- FocusAreaAgent stashes new_enrichments in signals_snapshot under _new_enrichments;
  compute_agent endpoint pops it before storing the snapshot

Closes part of #129

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 14:39:35 +00:00
08d08ad7b0 feat(clustering): LLM-enrichment before embedding (port from taskpile #129)
Ported from taskpile experiments/clustering_eval (prompt v1, qwen2.5:1.5b).
The experiment showed ARI 0.22→0.77 and AUROC 0.76→0.91 on synthetic tasks
when embedding LLM-expanded descriptions instead of raw titles.

- Expand each task title via LiteLLM tip-generator before embedding
- Prefix with "clustering: " (nomic-embed-text task instruction prefix)
- Cache expansions in-memory by content hash within a compute cycle
- Falls back to raw title if enrichment fails; no change to fallback behaviour

Fixes #129

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 14:20:48 +00:00
1ca2351488 fix(clustering): route embeddings through LiteLLM instead of Ollama directly
The old code called Ollama's /api/embeddings one task at a time, which caused
silent fallback to project-based grouping when host.docker.internal:11434 was
unreachable from the ml-serving container.

- Switch to LiteLLM /embeddings (model alias "embedder") as primary path
- Batch all task contents in one request instead of N serial calls
- Fall back to Ollama /api/embed (updated to current API) when LITELLM_URL is absent
- Update tests to mock _embed_batch instead of the removed _embed

Fixes #123

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 13:42:53 +00:00
4e9210fcef fix(web): wrap loadTip in arrow fn to satisfy MouseEventHandler type 2026-05-12 13:34:46 +00:00
59c493323f fix(recommender): remove Todoist fallback on orchestrator failure; add snooze exclusion
When fetchOrchestratorTip returned null (LiteLLM timeout, bad JSON, etc.)
the recommender silently fell back to randomPolicy, serving a raw Todoist
task with no rationale — explaining both reported symptoms.

- Remove randomPolicy/signalToCandidate; return 204 when orchestrator fails
  so the UI shows "All clear" instead of a confusing Todoist task
- Pass recent_tip through the stack (frontend → POST /recommend →
  fetchOrchestratorTip → ml/serving RecommendRequest → build_orchestrator_messages)
  so after snooze the LLM is instructed not to repeat the snoozed content

Fixes #122

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 13:28:32 +00:00
d4b40e2590 docs: document MLflow trace API, span inspection, and no-agent diagnosis
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 11:23:13 +00:00
a0a069c525 fix(admin): break redirect loop on /forbidden for non-admin users
The middleware was redirecting non-admins to /forbidden but /forbidden
wasn't excluded from the matcher, so the middleware ran again on that
page, saw a non-admin, and redirected again — infinite loop. Added
/forbidden to the pass-through list alongside /login.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 11:12:16 +00:00
d1f28666b0 feat(integrations): add Google Health (Fit) integration with full permissions
OAuth2 flow with all 11 Google Fitness scopes (activity, body, sleep,
heart rate, nutrition, location, blood glucose/pressure/temperature,
oxygen saturation, reproductive health). Stores access + refresh tokens;
auto-refreshes on expiry.

GoogleHealthSignalSource fetches steps, sleep sessions, active minutes,
calories, and heart rate from the Fit aggregate + sessions APIs. Signals
flow into both the tip orchestrator and the health-vitals pre-compute
agent, which generates prompt snippets about step progress, sleep
deficit, sedentary time, and elevated heart rate.

Signal.kind extended with 'health'; IntegrationProvider extended with
'google-health'. Agent compute signal mapping enriched to include source,
kind, and all features so health-vitals can filter its own signals.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 11:12:11 +00:00
161e654027 feat(serving): replace MLflow run logging with native trace spans
Convert ml-serving from isolated MLflow runs to nested traces using
mlflow.start_span_no_context(). The recommend endpoint now emits a full
span tree: recommend (CHAIN) → build_context (TOOL), agent:* (AGENT) ×N,
llm_orchestrator (LLM). Compute and infer endpoints each emit a single span.

Supporting changes:
- mlflow-skinny>=3.1.0 added to requirements
- MLflow configured with --serve-artifacts + mlflow-artifacts:/ default root
  for cross-container artifact proxy (spans now persist from ml-serving)
- --allowed-hosts extended to include mlflow:5000 (SDK includes port in Host)
- science_destiny slider wired through prompts.py and recommend endpoint
- Config page exposes science/destiny slider (0=data-driven, 100=intuitive)
- Tip page shows rationale inline on tap

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 08:26:05 +00:00
afacc34969 fix(agents): instruct orchestrator to output tip in English
Small models (qwen2.5:1.5b) mirror the language of task title content
in the prompt. Adding an explicit English note to snippets that embed
raw task titles (focus-area, overdue-task) prevents language bleed.
Also added the instruction to the orchestrator system prompt and user
message as belt-and-suspenders.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 11:53:21 +00:00
c124ff4d24 docs: update CLAUDE.md with session learnings (#118 tracing, compose gotchas)
- Clarify compose profile requirement for build/up (silent no-op without --profile)
- Add --force-recreate pattern for env-var-only changes
- Document MLflow host_header and auth gotchas for container-to-container calls
- Record MLflow tracing addition and #118 M4 tracking issue

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 10:41:57 +00:00
95e1b342b4 fix(serving): wire MLflow auth and Host header for container-to-container calls
- Pass MLFLOW_ADMIN_PASSWORD as fallback password credential
- Set host_header='localhost' to satisfy MLflow's --allowed-hosts check
  (MLflow rejects Host: mlflow but accepts Host: localhost)
- Default MLFLOW_TRACKING_URI to http://mlflow:5000 in compose so the
  env_file value is not silently overridden to empty

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 10:39:08 +00:00
c43dbaf23d feat(serving): add MLflow tracing to ml-serving for all agent calls
Logs one MLflow run per /recommend (params, token metrics, latency,
full prompt + tip as artifacts) and per /agents/{id}/compute and
/infer call (signals snapshot, inferred prefs, latency).

Tracing is a no-op when MLFLOW_TRACKING_URI is unset; ml-serving
starts and serves tips correctly without MLflow configured.

Refs #118 (M4: remove from production / move off critical path).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 10:30:24 +00:00
488a764519 docs: mark M2 complete in README
All M2 items shipped: ADR-0014 (unified profile + inference framework),
per-agent auto-inference, tip generator, TipCandidate schema, prompt
versioning, model benchmark, task clustering, UX refinements.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 08:02:44 +00:00
c67f2b14c4 docs: update CLAUDE.md with #61 completion and feature test patterns
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 07:45:40 +00:00
17b9516903 feat(features): mirror invalidatedBy into Python ProfileFeature (#61)
Adds invalidated_by: tuple[str, ...] to ProfileFeature, mirroring the
invalidatedBy bus subjects from registry.ts. Adds a test that parses the
TS source and asserts Python stays in sync — same drift-detection pattern
used for names and ttlSec.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 07:10:36 +00:00
a75be0d832 docs: update CLAUDE.md with session learnings (#97, #113)
- focus-area v2.0.0 completion in recent completions; remove from active work
- Update focus-area inferred params table row
- min_history gotcha: checked against events, not task_completions
- httpx trust_env=False rule for ml/ code
- Agent test command

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 06:56:17 +00:00
26fc67776f feat(agents): semantic task clustering + focus-area inferred preferred_areas (#97, #113)
- New ml/agents/clustering.py: embed task content via nomic-embed-text
  (Ollama), greedy cosine clustering (threshold 0.72, max 6 clusters),
  graceful fallback to project-id grouping when Ollama is unreachable
- focus_area v2.0.0: compute() uses semantic clusters as focus areas;
  adds preferred_areas InferredParam inferred from top-2 projects by
  task_completion count
- 135 tests, all passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 06:54:46 +00:00
336644a90a docs: update CLAUDE.md with rich per-agent inference completions (#112–#116)
- Inference framework table updated: all agents at v1.2.0 with full param list
- Documents UserHistory.task_completions and AgentInferRequest.task_completions
- Marks #112/114/115/116 complete in recent completions
- Active work updated: #78 closed, #61 and #97/#113 as next priorities

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 06:28:30 +00:00
1d9a395591 feat(agents): quiet window + peak hours + tz prefs for time-of-day agent (#112)
Adds four InferredParams (all TTL=24h, min_history=50 except preferred_hour=10):
- quiet_start / quiet_end: longest contiguous below-baseline hour run (HH:MM)
- peak_hours: top-quartile done-event hours, sorted ascending
- tz: cold-start only ("UTC"); populated from auth provider, no inference function

compute() updated:
- in_quiet check (quiet window) takes precedence over peak hours
- in_peak emits "peak productivity hour" language when current hour is in peak_hours
- approaching peak (within 2h) surfaces for orchestrator timing
- tz surfaced in snippet header when not UTC
- snapshot adds peak_hours, in_quiet, in_peak, tz

- Agent bumped to v1.2.0
- 21 new tests: night-owl, early-bird, shift-worker, quiet/peak snippet rendering
- Fixed test_snapshot_keys in test_agents.py to include new snapshot fields

Closes #112

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 06:05:51 +00:00
bc71dc203d feat(agents): adaptive lookback + weekly/daily cycle detection for recent-patterns (#116)
Replaces the coarse density-bucket window_days with three InferredParams (all TTL=24h):
- lookback_days: min window containing ≥30 done events, capped at 30d (min_history=5)
- weekly_cycle: per-DOW peak-to-mean strength list (min_history=21, ≥3 weeks of signal)
- daily_cycle: per-hour peak-to-mean strength list (min_history=14)

compute() renders cycle hints when strength > 0.5:
  "User tends to complete tips on Tuesdays and Saturdays."
  "User is most active around 8pm."
Legacy window_days pref key still accepted as a fallback.

- window_days pref renamed lookback_days; backward-compat fallback in compute()
- Agent bumped to v1.2.0
- 19 new tests: weekend-warrior, weekday-only, evening-person, no-pattern,
  legacy compat, snippet rendering with strong/weak signals

Closes #116

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 05:51:45 +00:00
4cade4868b feat(agents): per-user baseline + stdev inference for momentum agent (#114)
Adds two InferredParams (TTL=7d) computed from 28-day rolling daily done counts:
- baseline_completions_per_day: mean done events/day over the window
- stdev: stdev of daily counts (floored at 0.1 to avoid division by zero)

MomentumAgent.compute() now calculates a z-score from recent done events in
inp.feedback_history vs the inferred baseline. Snippet language switches to
z-score framing ("above your usual pace", "slowing down") when |z| >= 1.0,
falling back to engagement_trend labels when in the normal range.

- engagement_trend InferredParam preserved for backward compatibility
- momentum_window pref added (default 7, user-overridable)
- 14 new tests covering power user, casual user, returning-from-break, and
  relative stdev comparison; engagement_trend tests updated for z-score priority
- Agent bumped to v1.2.0

Closes #114

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 05:18:29 +00:00
04212ff318 feat(agents): p50-lateness tolerance + per-project realness for overdue-task (#115)
Replaces snooze-rate heuristic with p50 of actual task lateness (completedAt − dueAt).
Adds project_realness inference: projects with chronic lateness get realness < 1 and
the agent softens its snippet language from "overdue" to "past target date".

- TaskCompletion added to UserHistory with lateness_days computed property
- _infer_lateness_tolerance: p50 of task_completions, clipped at 0, float
- _infer_project_realness: per-project median lateness normalised by global median
- Both InferredParams use 7d TTL; cold_start = 0.0 / {}
- AgentInferRequest accepts task_completions; endpoint wires them through
- 12 new tests covering punctual/chronic/mixed users and language softening
- Agent bumped to v1.2.0

Closes #115

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 05:14:04 +00:00
35257b7756 docs: mark ADR-0014 complete in CLAUDE.md
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 11:50:42 +00:00
ed1705cb5d feat(db): drop users.consentGiven/consentAt (ADR-0014 step 8)
Backfills consent_given=1 rows into user_consents as data:core before
dropping the legacy columns. auth.ts now writes user_consents on signup;
POST /consent writes user_consents; admin/user routes cleaned of the old
fields. Migration is idempotent — DROP COLUMN is wrapped in try/catch so
it no-ops on fresh DBs that never had the columns.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 11:50:27 +00:00
afb0e9b0cb feat(agents): per-agent inference — momentum, overdue-task, recent-patterns, focus-area (ADR-0014 step 7)
All four agents bumped to v1.1.0.

momentum (#114): infers engagement_trend ('up'|'stable'|'down') by comparing
done-rate in the last 7 days vs the prior 7 days. Agent surfaces the trend
in its snippet ("trending up — build on the momentum").

overdue-task (#115): infers lateness_tolerance_days (0/1/2) from snooze rate.
Agent now filters tasks against the tolerance so low-urgency users aren't
nagged about tasks that are only hours overdue.

recent-patterns (#116): infers window_days (7/14/30) from feedback event
density — sparse users get a wider window so the snippet isn't always empty.

focus-area (#113): no inferred params (project-level feedback linkage needed,
tracked under #78). preferred_areas pref was declared but ignored; agent now
honours it as a tiebreaker and mentions it in the snippet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 11:21:10 +00:00
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
41302d9f36 fix: repair Docker build — TS errors and missing docs in image
- Remove unused `httpx` import from bench.ts (package does not exist)
- Add explicit `IRouter` type on `router` in agent-outputs.ts and bench.ts
  to resolve TS2742 portable-type errors
- Remove `docs` from .dockerignore so Dockerfile.admin can copy it into
  the runner image (DOCS_ROOT=/app/docs is read at runtime by the admin)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 10:52:27 +00:00
05f748159b chore: remove shadow policy machinery (ADR-0013 step 10)
Deletes shadowPolicies map, getShadowPolicies, setPolicyActive from
recommender.ts; removes /api/admin/policies routes from admin.ts; removes
getPolicies, togglePolicy, PolicyInfo from admin api.ts; removes the
policy toggle section from the ops page.

168 API tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 10:45:32 +00:00
8e9718e8ba chore(ml): remove bandit endpoints + helpers (ADR-0013 step 9)
Deletes all LinUCB and ε-greedy code from ml/serving: score, reward,
stats, reset, features endpoints; feature vector builders; per-user state
file helpers; related Pydantic models; numpy/math/time imports.

Removes test_score.py (pure bandit unit tests). 40 remaining tests pass.
STATE_DIR kept — nats_consumer still writes sync metadata there.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 10:41:58 +00:00
c65bedcf68 feat(api): orchestrator cutover — replace bandit with multi-agent pipeline (ADR-0013 step 6)
POST /recommend now calls ml/serving /recommend with pre-computed agent
snippets + task context instead of /generate + /score/egreedy/v2. Falls
back to a random signal candidate when ml/serving is unavailable.

Removes: remotePolicy, fetchLlmCandidates, sendRewardWithRetry,
candidateCache, pickPromptVersion. Feedback handler keeps inferReward +
tipFeedback writes for observability; reward delivery to the bandit is gone.
tipScores.policy is now 'orchestrator'; promptVersion is 'v4-orchestrator'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 10:37:15 +00:00
7e958a779d feat(api): agent pre-compute scheduler (ADR-0013 step 5)
Extracts computeAndStore() from the /agents/:agentId/compute route so it
can be called without an HTTP round-trip. startAgentPrecomputeScheduler()
runs every 15 min: fetches active users (tip view in 48h), runs all agents
in parallel per user, then purges outputs expired >24h. Agent IDs are
resolved from ml/serving /health at startup with a fallback hardcoded list.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 10:29:50 +00:00
37aec4fee1 chore: ADR-0007/0012 superseded status + admin users ID column
ADR-0007 and ADR-0012 both superseded by ADR-0013 as of 2026-05-01.
UsersTable gains a truncated ID column for quick user identification.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 10:20:44 +00:00
b3cf588f2f feat(ml): multi-agent context framework + v4 orchestrator prompt
Adds ml/agents/ — five specialised sub-agents (overdue_task, momentum,
time_of_day, recent_patterns, focus_area) each producing a prompt snippet
from user signals. A registry wires them up; the orchestrator prompt in
ml/serving/prompts.py synthesises their outputs into one tip via LiteLLM.

Also wires /api/agents route in the API and updates the Dockerfile to copy
the full ml/ tree with PYTHONPATH=/app so agent imports resolve correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 10:20:05 +00:00
f8d66aa01f chore: remove Airflow completely from the stack
Drop all four Airflow containers (db, init, webserver, scheduler) from the
mlops compose profile, leaving MLflow as the sole mlops service. Remove
AIRFLOW_* env vars, config fields, health-check entries, DAG trigger code
in admin/bench routes, the airflow_dag_run_id schema column, Airflow nav
links and DAG-run links in the admin UI, the two Airflow DAG files
(bench_dag.py, sim_dag.py), and all related docs/ADR references.
Simulations now run exclusively via the subprocess path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 16:38:46 +00:00
ce1c8bde57 fix(admin): simulations view-only + docs path in Docker (#109 #110)
- simulate/page.tsx: remove launch form — simulations are triggered via
  Airflow DAG, not the admin UI. Page now shows run history + links to
  Airflow and MLflow only (#109)
- docs.ts: use DOCS_ROOT env var (fallback: ../../docs for local dev) so
  the path works in Docker standalone where CWD is /app (#110)
- Dockerfile.admin: copy docs/ into the runner image at /app/docs and set
  DOCS_ROOT=/app/docs so listAllDocs() finds the files at runtime (#110)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 13:55:50 +00:00
c1f5fcb561 fix(admin): ops page — add section description, remove redundant footer (#107)
Adds a one-line purpose description under the Ops heading so it is clear
what the section is for (shadow policy toggles, signal replay, per-user
actions). Removes the duplicate "User-level actions" subsection whose
content is now covered by the header description.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 13:53:35 +00:00
9bd60a9835 feat(web): action sheet cleanup + settings page (#100 #101 #102)
- Remove "Helpful"/"Not helpful" from action sheet — reward is inferred
  from done/snooze/dismiss + dwell time; explicit sentiment buttons were
  redundant and cluttered the UI (#100)
- Move "notify me" push subscription button to new /config page (#101)
- Add settings gear icon (bottom-right, fixed) on tip page linking to /config (#102)
- New /config page: push notification toggle + link to /connect integrations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 13:52:45 +00:00
4267e6ac68 feat(ml/serving): inject profile features + sort tasks in tip prompt (#79)
- prompts.py: sort tasks overdue-first → priority desc → age desc before
  rendering into the LLM prompt (same ordering as ml/features/context.py)
- prompts.py: render User profile summary line (completion_rate, dismiss_rate,
  preferred_hour) when profile_features are present
- main.py: add profile_features field to PromptContext; plumb from
  GenerateRequest into the prompt builder via model_copy
- logging_config.py: drop add_logger_name processor (incompatible with
  PrintLoggerFactory — caused test ordering failures)
- test_generate.py: 6 new tests covering sort order, profile rendering,
  partial fields, empty profile, and end-to-end plumbing through /generate

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 13:46:16 +00:00
0474ad4deb feat(airflow): integrate bench harness into bench_collect DAG
New DAG (`ml/pipelines/bench_dag.py`) with three linked tasks:
1. collect.py — generates candidates, logs to MLflow
2. export_for_judge — exports pending runs for Claude Code scoring
3. compare — generates leaderboard by (model, prompt) cell

Config via dag_run.conf supports all collect.py options (models, prompts,
n_tips, n_scenarios, temperature, experiment name, max_model_b).

New admin API endpoints (`services/api/src/routes/bench.ts`):
- GET /api/bench/experiments — list tip-bench-* experiments
- POST /api/bench/run — trigger DAG with custom config
- GET /api/bench/runs/:experiment — list runs in experiment
- GET /api/bench/leaderboard/:experiment — leaderboard by (model, prompt)

All endpoints require admin auth. Human judge (Claude Code) scores are
applied manually post-export; future enhancement: add webhook to DAG.

Admin UI can now trigger and monitor benchmarks from a dashboard panel.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 11:54:30 +00:00
556019b060 feat(bench): MLflow-based tip-generation benchmark harness (#93, #95)
Combines model evaluation (#93) and prompt A/B testing (#95) into one
experiment. Evaluates all (model × prompt × scenario) cells on the same
fixed contexts so quality differences are attributable.

Architecture:
- Phase A (collect.py): generates candidates per cell, logs to MLflow
  with judge_pending=true. Rejects models >4B, uses keep_alive=0 for
  RAM safety (no concurrent model weights in VRAM).
- Phase B (judge_cli.py): exports pending runs as JSON for Claude Code
  to score per the rubric, then applies scores back to MLflow.
- Phase C (compare.py): leaderboard by (model, prompt) cell.

Rubric (tip-v1) defines 1–5 scales for relevance, actionability, tone,
plus format_ok and overlong flags. Composite = rel + act + tone +
2×format_ok − overlong. Rubric is self-describing and persisted in every
run so judges use consistent criteria across sessions.

Artifacts (prompts, candidates, raw responses) stored as MLflow tags
because the server uses a file:// backend not accessible via REST. Full
artifacts accessible in MLflow UI → run → Tags section.

Tested end-to-end on local machine:
- 4 models (qwen2.5:0.5b/1.5b, gemma3:1b, llama3.2:3b) ≤4B
- 3 prompts (v1, v2-mentor, v3-few-shot)
- 4 scenarios (4 personas × 2 time-slots)
- 48 cells total, all judged and ranked

Winner: qwen2.5:1.5b × v3-few-shot (composite=12.75).

Ready for integration into Airflow prompt_ab_eval DAG and admin UI.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 11:48:59 +00:00
e40dfdcbb0 chore(infra): wire MLflow/Airflow env vars, fix healthcheck, add .dockerignore
Some checks failed
buf-check / Lint & breaking-change check (push) Has been cancelled
- docker-compose: pass ML_SERVING_URL, MLFLOW_URL, AIRFLOW_URL + creds to api service
- docker-compose: pass NEXT_PUBLIC_MLFLOW_URL/AIRFLOW_URL to admin service
- docker-compose: replace wget healthcheck with node fetch (wget not in node image)
- docker-compose: enable Airflow basic_auth API backend; add MLflow pip dep for DAGs
- Dockerfiles: tighten layer caching, add .dockerignore

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 12:08:43 +00:00
bad1bb2cba feat(simulate): MLflow tracking, Airflow DAG integration, health checks for mlflow/airflow
- sim_runs schema: add judge_mode, n_policies, airflow_dag_run_id, mlflow_run_id columns
- admin health endpoint: add mlflow + airflow checks (Basic auth for Airflow API)
- admin nav: add Simulations page link; rename section label
- runner.py: optional MLflow experiment tracking; multi-policy support
- sim_dag.py: Airflow DAG for offline sim pipeline
- admin simulate page + API client methods for sim runs
- shared-types tsconfig: exclude test files from build

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 12:08:36 +00:00
e96ceb7ee1 feat(auth): token-based admin authentication for Playwright/CI (#105)
Add POST /api/auth/token — validates ADMIN_TOKEN env var, creates a 24h
session and sets the sid cookie so automated tools can access the admin
panel without Google OAuth. Admin login page gains a token input form.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 12:07:43 +00:00
b554970032 docs(observability): add services/api README; update ml/serving + recommender docs (#18)
- services/api/README.md: new — contract, middleware stack, background
  tasks, config table (LOG_LEVEL, SENTRY_DSN), health story, extraction
  criteria
- ml/serving/README.md: add Observability section (structlog JSON,
  traceparent → trace_id binding), add SENTRY_DSN + ENV to config table
- services/recommender/README.md: fix policy table — egreedy-v2 is
  active (#99), egreedy-v1 is shadow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 03:41:39 +00:00
c4960d0601 feat(observability): structured logs, W3C trace IDs, Sentry hooks (#18)
- TS: pino + pino-http; every HTTP request log includes traceId from
  W3C traceparent header (generated if absent); forwarded to ml/serving
  on all /score, /generate, /reward, and /api/ml proxy calls
- Python: structlog JSON; FastAPI middleware binds trace_id via
  contextvars so every log line within a request carries it
- Sentry: optional SENTRY_DSN init in both runtimes (no-op if unset)
- Replace all console.* calls across services/api with pino logger
- Update tests to spy on logger instead of console

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 03:37:28 +00:00
7281af83a4 feat(bandit): promote egreedy-v2 (D=12, profile features) as active policy (#99)
Offline sim gate passed — egreedy-v2 mean reward −0.629 vs egreedy-v1 −0.642
(5 users × 20 rounds, rule judge, seed 42). v2 wins 3/5 personas.

- recommender.ts: switch remotePolicy() to /score/egreedy/v2
- recommender.ts: switch sendRewardWithRetry() to /reward/egreedy/v2 with
  profile_features payload so the ridge update uses the full D=12 vector
- recommender.ts: re-fetch profile at feedback time (TTL-cached, near-instant)
- ADR-0012: status Accepted → Promoted, promotion record appended

Shadow entry egreedy-v2-shadow kept in registry (active: false) for rollback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 03:08:28 +00:00
cba3f1a184 docs(services): update integrations + recommender READMEs for signal abstraction (#78)
integrations/README — replace stale Connector interface and fictional
libsodium vault with the actual SignalSource pattern, SQLite token table,
and real OAuth routes.

recommender/README — document the SignalAggregator pipeline, current
policy registry, and actual /recommend + /feedback contract shapes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 17:17:38 +00:00
352469162d fix(signals): add missing source field to TaskSyncedEvent (#78)
TaskSyncedPayload in shared-types and ml/serving schemas both require
source, but TaskSyncedEvent in bus.ts and the todoist publish call both
omitted it — causing the JetStream consumer to nak every task.synced
message on validation failure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 17:15:32 +00:00
45416000f9 feat(features): per-feature freshness spec — JIT vs batched (#61)
Each ml/features/*.py now declares freshness, source, and fallback per
feature. ProfileFeature gains ttl_sec (mirrored from registry.ts),
freshness="batched", source, and fallback. context.py adds
ContextFeatureSpec + CONTEXT_FEATURES for the three JIT features
(hour_of_day, day_of_week, tasks). CI test parses ttlSec from registry.ts
to catch drift. ml/README updated with split JIT/batched feature contract.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 17:02:55 +00:00
bd3ea1b8b1 docs(schema): update docs for #54 — proto registry + buf CI gate
- packages/shared-types/README.md: new — documents HTTP vs event surfaces,
  proto file layout, schema evolution rules, and how to run buf locally
- ml/serving/README.md: note pydantic payload validation in consumer section
- CLAUDE.md: replace "schema registry enforced when #54 lands" with
  the actual state; remove #54 from active-work list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:53:20 +00:00
377373a95d test(schema): unit tests for schemas.py and nats_consumer._handle (#54)
17 tests covering: pydantic model validation (all payload types, optional
fields, invalid enum values, missing required fields), _handle write path
for task_synced, validation errors surfaced through _make_handler causing
nak instead of ack.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:51:15 +00:00
d539fde0c1 feat(schema): protobuf event registry + buf CI gate (#54)
- Add proto schemas in packages/shared-types/events/ (oo.events.v1):
  envelope.proto, signals.proto, integration.proto
- buf.yaml with STANDARD lint + FILE breaking-change rules
- .gitea/workflows/buf-check.yaml: lint + breaking check on every PR
  touching events/ (needs a Gitea Actions runner to execute)
- scripts/buf-check.sh: local equivalent of the CI check
- NormalizedEvent TS envelope gains eventId, schemaVersion, producer
  to align with the proto Envelope message
- ml/serving/schemas.py: pydantic models mirroring the v1 proto types
- nats_consumer.py: validate payloads via pydantic instead of raw .get()

A field-rename PR will now fail buf breaking with exit code 100 and
show the offending messages. To make a breaking change: keep the old
field reserved, add the new one, bump schema_version to v2.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:48:24 +00:00
f48b5a7646 docs(ml): serving README + update ml/README and CLAUDE.md for #98
- ml/serving/README.md: new — contract, JetStream consumer docs, config,
  health story, extraction criteria, state file reference
- ml/README.md: note JetStream consumers in serving/ row
- CLAUDE.md: update active work to reflect #98 shipped, #99 still pending

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 10:21:40 +00:00
4652e4b582 feat(ml): JetStream durable consumers in ml/serving (#98)
Adds a NATS JetStream consumer to ml/serving so the feature pipeline
can react to events without the API triggering every read.

- nats_consumer.py: durable push consumers for signals.> and feedback.>
  streams; acks on success, naks for redeliver, up to NATS_MAX_DELIVER
  attempts; per-consumer health state (last_msg_ts, processed, errors)
- main.py: FastAPI lifespan wires start/stop; /health exposes nats state
- requirements.txt: adds nats-py>=2.9.0
- Dockerfile.ml: copy all *.py from ml/serving (was missing prompts.py)

Handled subjects:
  signals.task.synced   → writes per-user sync metadata to STATE_DIR
  signals.tip.feedback  → logged for observability (reward via HTTP path)

Config: NATS_URL (empty = disabled), NATS_DURABLE_PREFIX, NATS_MAX_DELIVER

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 10:19:47 +00:00
2d7cf217a9 feat(ml): egreedy-v2 shadow policy — D=12 with profile features (#99)
Ship the scaffolding for #99 (phase B.3 of #81):

- ml/serving: add /score/egreedy/v2, /reward/egreedy/v2, /stats/egreedy/v2
  endpoints (D=12). New feature dims: completion/dismiss rates, mean dwell
  (clipped 10min), preferred-hour alignment (cosine, 1-dim), tip volume (log).
  Separate state file per user (_egreedy_v2.json). /reset clears v2 state too.
- ADR-0012: documents D=7→12 dimension change, normalization choices, shadow
  rollout protocol, and promotion gate (offline sim win per ADR-0002).
- recommender.ts: register egreedy-v2-shadow in shadow-policy map (disabled by
  default). When enabled, calls /score/egreedy/v2 fire-and-forget and publishes
  shadow:egreedy-v2-shadow serve signal. No reward to shadow — sim is the gate.
- sim runner/personas: personas carry synthetic profile_features per persona;
  _call_score/_call_reward thread profile_features through (None-safe for v1/linucb).
- 18 new Python tests; all 56 Python + 170 TS tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 10:00:38 +00:00
b8113d4bda docs(adr-0011): point B.3 at new issue #99
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 00:41:20 +00:00
ee4eb15022 feat(profile): event-driven invalidation (#81 phase B.2)
Features now declare invalidatedBy subjects in the registry; the new
profile/subscriber.ts subscribes to each unique subject and drops
matching stored rows for the userId in the payload. Next getProfile
call recomputes from current data instead of waiting up to ttlSec.

Wiring:
  completion_rate_30d, dismiss_rate_30d, mean_dwell_ms_30d,
  preferred_hour  ← signals.tip.feedback
  tip_volume_30d  ← signals.tip.served

TTL stays as a safety net for clock drift and dropped events.
Registration validates each declared subject against KNOWN_SUBJECTS
(mirror of EventMap) so typos throw at startup, not silently.

ADR-0011 updated.

Refs #81.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 00:38:45 +00:00
4a42a6aabf feat(admin): profile freshness panel in data-quality (#81 phase B.4)
Adds a per-feature freshness summary to /admin/data-quality so the admin
can spot features that are systematically stale or never computed:

  totalEligible — distinct users with tip_views in the last 30 days
  missing       — eligible users with no row stored for the feature
  stale         — eligible users whose stored row is past its TTL

Backend exposes summarizeProfileFreshness() in profile/builder.ts; one
query per feature joins eligible users LEFT JOIN profile rows.
Coverage = (eligible − missing − stale) / eligible, colored
green/yellow/red via the new PctGood helper (high-is-good, opposite of
the existing Pct used for missing-feature/stale-token rates).

Refs #81.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 00:34:46 +00:00
9e96540bcc feat(admin): per-user profile view + rebuild action (#81 phase B.1)
Surfaces phase A's profile features in /admin/users/:id so we can verify
they're actually computing useful values before investing in bandit
consumption. The detail GET now includes profile rows joined with registry
metadata (name, value, age, fresh badge, ttlSec, description). Read does
NOT trigger compute — staleness must be visible. A new POST
.../profile/rebuild button force-recomputes and is audit-logged like
reset-bandit.

Refs #81.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 00:27:08 +00:00
7d4c29e137 feat(profile): user-profile feature registry + builder (phase A)
Centralizes user-level features (completion_rate_30d, dismiss_rate_30d,
mean_dwell_ms_30d, preferred_hour, tip_volume_30d) in a TS registry that
owns both definition and SQL aggregation, since the data lives in the
TS-owned SQLite tables (tip_views/tip_feedback). Lazy TTL refresh keeps
recommend latency bounded; values persist in user_profile_features (KV).

ml/serving accepts profile_features on /score + /generate but does not
yet consume them — extending the bandit feature vector changes D and
resets every user's learned state, so that's a deliberate phase-B step.

Includes ml/features/profile_schema.py as a contract mirror with a sync
test that diffs name sets against registry.ts.

ADR-0011 records the data-locality reasoning (registry in TS, not Python
as the issue originally suggested).

Phase B (deferred): event-driven incremental updates, bandit consumption
with state migration, admin per-user profile page, staleness alerts.

Refs #81.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 00:22:22 +00:00
430804e9a5 feat(ml): prompt registry + per-request variant selection
Replaces the hardcoded "v1" label with a real prompt registry:

  ml/serving/prompts.py       — keyed by version: v1 (baseline),
                                v2-mentor (calm/specific persona),
                                v3-few-shot (v1 persona + curated examples)
  ml/serving/main.py          — POST /generate accepts optional prompt_version,
                                422 on unknown, echoes the version actually used
                                back in the response
  services/api/src/config.ts  — TIP_PROMPT_VERSION: empty / single / comma-list
                                (uniform random per request)
  services/api/src/routes/recommender.ts
                              — pickPromptVersion() drives selection; the
                                response's prompt_version (not a stale TS
                                constant) is what lands in tip_scores so the
                                #92 reward-analytics dashboard shows real
                                per-variant reaction rates

Closes #84.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 15:44:04 +00:00
aa4bdd8f09 feat(admin): LLM tip quality dashboard — per-model/prompt/kind breakdowns
/admin/reward-analytics now surfaces served count, reaction rate, and avg
reward grouped by llm_model, prompt_version, and tip_kind — closing the
loop so model/prompt iterations in M2 are legible next to the bandit
policy view. Data comes from the tip_scores columns added in ffdf707 and
tip_feedback.reward_milli; bandit-only tips show as "(bandit-only)".

Closes #92.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 15:24:52 +00:00
75d0e89906 fix(infra): ml-serving LITELLM_URL default → host.docker.internal:4000
Inside the container, llm.alogins.net times out (public-DNS route, not the
loopback path Caddy listens on). host.docker.internal:4000 reaches the Agap
LiteLLM directly and is equivalent for dev. Prod deploys override via env.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 12:20:41 +00:00
d4205a00cf refactor(infra): drop ai profile; ollama + litellm move to Agap
Ollama and LiteLLM are shared Agap services (agap_git/openai/docker-compose.yml);
oO never starts them. Removes the ai profile, the litellm config, and the
--profile ai runbook; points ml-serving at https://llm.alogins.net by default
and adds host.docker.internal host-gateway so the container can hit Agap ollama
on the host.

Also updates the tip-generator model alias to qwen2.5:1.5b to match the model
actually pulled on Agap ollama (7b is ~4.7 GB and would blow VRAM budget).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 12:16:21 +00:00
d7a2423940 fix(infra): mlflow image tag + python-based healthchecks for ml-serving/mlflow
- Corrects mlflow image tag (2.14.3 → v2.14.3); the former tag does not exist
  on ghcr.io/mlflow/mlflow and caused a manifest-unknown error on pull.
- Replaces wget/curl healthchecks with inline python urllib calls — the
  python:3.12-slim (ml-serving) and ghcr.io/mlflow/mlflow images ship
  neither wget nor curl, so both containers reported unhealthy despite
  /health returning 200.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 15:04:18 +00:00
bb879c5f0f refactor(admin): drop simulations/experiments/models pages; group nav into sections
Removes the in-shell MLOps pages (experiments, models, simulations) and their
client API helpers in favour of external MLflow/Airflow links. Nav is regrouped
into Signals / Recommender status / Ops sections for clarity.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 14:41:17 +00:00
5b52c6bf40 test: cover NATS bridge + Todoist scheduler; ADR-0010
- bus.test.ts: 4 cases for the new onPublish hook contract
- nats.test.ts: stream creation idempotency + JSON publish bridge
- scheduler.test.ts: startup delay, fan-out, per-user failure isolation
- ADR-0010 documents the bridge-don't-replace decision and the
  Todoist scheduler isolation, plus open follow-ups (#98 ml/serving
  consumer, #54 protobuf migration, graceful shutdown, metrics)
- README/overview/services README reflect the bridged event substrate
- CLAUDE.md gains a "don't nats.publish() directly" rule
- .env.example documents NATS_URL + TODOIST_SYNC_INTERVAL_MS

Verified in deployment 2026-04-18: api -> nats bridge connects on
boot, signals + feedback streams created, scheduler tick logs
"todoist sync: 1 ok, 0 failed (1 users)" within 10s. Closes #21, #22.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 07:55:25 +00:00
2a7380933c feat: NATS JetStream + Todoist background sync (#21, #22)
Issue 21 — event infrastructure:
- NormalizedEvent<T> + payload types in packages/shared-types/src/events/
- Bus.onPublish() hook for side-effect bridges
- NATS JetStream adapter (services/api/src/events/nats.ts): connects when
  NATS_URL is set, creates signals.> and feedback.> streams, bridges all
  in-process bus publishes to JetStream — no-ops gracefully when NATS is absent
- NATS service added to docker-compose (profile: events|full, port 4222/8222)

Issue 22 — Todoist background sync:
- services/api/src/signals/scheduler.ts: queries all active-token users every
  15 min (TODOIST_SYNC_INTERVAL_MS), fan-out via todoistSource.fetchSignals()
  which emits signals.task.synced; on-demand fetch remains as freshness fallback
- NATS_URL + TODOIST_SYNC_INTERVAL_MS added to config

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 01:18:51 +00:00
e3ca3ba733 feat: SignalSource abstraction — generalize signal ingestion beyond Todoist (#78)
- Add Signal + SignalSource interfaces to packages/shared-types
- TipCandidate.features widened to Record<string,number|boolean> to match Signal
- TodoistSignalSource: encapsulates fetch, cache, 401 handling, bus events, and act()
- SignalAggregator: parallel fan-out across sources with per-source failure isolation
- Recommender refactored to consume Signal[] via aggregator; source action dispatch via aggregator.act()
- ADR-0009: signal normalization strategy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 01:11:56 +00:00
46dee7377e fix: api healthcheck + port mapping corrected to 3078
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 14:17:52 +00:00
4c8ef9ad86 fix: consentGiven boolean in test fixture (was number, broke docker build)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 14:14:07 +00:00
ffdf70733f feat: M2 AI tips — LiteLLM gateway, context assembler, end-to-end generation pipeline
Issues closed: #86, #87, #88, #89, #90, #91, #79, #80, #82

infra:
- docker-compose `ai` profile: Ollama + LiteLLM services
- infra/litellm/litellm_config.yaml: tip-generator / embedder / judge aliases
- .env.example: LITELLM_URL, LITELLM_MASTER_KEY, OLLAMA_URL

ml/serving:
- POST /generate: calls LiteLLM tip-generator alias, returns TipCandidate[]
- JSON retry loop (2 retries with correction prompt on malformed response)
- _parse_llm_json strips markdown fences

ml/features:
- context.py: build_context() assembles user signals → PromptContext
  (sorts overdue/high-priority tasks first for LLM prompt quality)

shared-types:
- TipKind, TipSource, TipCandidate types
- Tip gains kind + rationale fields

services/api:
- recommender: 3-stage pipeline (assemble → score → serve)
  Stage 1: Todoist tasks + LLM candidates fetched in parallel
  Stage 2: egreedy bandit scores merged candidate pool
  Stage 3: serve + log with prompt_version, llm_model, tip_kind
- tip_scores: prompt_version, llm_model, tip_kind columns + migrations
- config: LITELLM_URL added
- integrations: surface token_status in /integrations response

tests:
- ml/serving/tests/test_generate.py: 13 tests (retry, 502/503, fence variants)
- ml/features/test_context.py: 9 tests (sorting, edge cases)
- services/api recommender.unit.test.ts: 16 pure-function tests (inferReward, dueAgeDays)
- services/api recommender.test.ts: 4 integration tests (tip_scores columns, LLM fallback)
- shared-types: TipCandidate, rationale, full TipFeedback action set

docs:
- ADR-0008: LiteLLM AI gateway decision
- overview.md: M2 pipeline description updated
- ml/README.md: serving + features roles updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 14:09:02 +00:00
85367aeaa0 feat: MLOps external services, AI stack planning, admin MLOps hub
Infrastructure:
- Add `mlops` compose profile: MLflow (basic-auth, /mlflow path) + Airflow (LocalExecutor, /airflow path) + airflow-db
- infra/mlflow/basic_auth.ini for MLflow auth config
- Caddy routes /mlflow* and /airflow* inside existing o.alogins.net block (see agap_git)
- Dockerfile.admin: NEXT_PUBLIC_MLFLOW_URL / NEXT_PUBLIC_AIRFLOW_URL build args (default /mlflow, /airflow)

Admin panel:
- /admin/models: replace MLflow iframe with external link cards
- /admin/experiments: replace LinUCB stats with MLOps hub (links to MLflow experiments/models + Airflow DAGs/datasets)
- AdminShell: external nav links for MLflow ↗ and Airflow ↗ under MLOps section

Docs & planning:
- README: new AI stack section (Ollama/LiteLLM/OpenWebUI three-tier, tip generation pipeline, model aliases)
- README: Phase 2 expanded with AI infra issues (#86-#93) and granular pipeline breakdown
- README: Phase 4 expanded with LLM MLOps items (#94-#97)
- CLAUDE.md: AI stack section, updated current phase (M1 shipped / M2 in progress), compose profiles, updated What NOT to do
- docs/architecture/overview.md: AI stack section, updated decision flow diagram for Phase 2 LLM pipeline
- ADR-0006: updated to reflect external services (path-based, not embedded)
- Gitea issues #86-#97 created (M2: AI infra + pipeline; M4: LLM MLOps)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 08:20:44 +00:00
faf44c18fc feat: ε-greedy v1 as active policy; dwell-time reward inference; offline sim framework
- Promote egreedy-v1 to active serving policy (ADR-0007): /score/egreedy + /reward/egreedy
  replaces linucb-v1 endpoints after offline sim shows +10.7% mean reward (−0.548 vs −0.606)
- Replace explicit helpful/not_helpful feedback with dwell-time inferred reward (inferReward):
  dismiss=−1.0, snooze=+0.1, done<15s=−0.3, done 15s–2min=+1.0, done 2–10min=+0.6, done>10min=+0.3
- Add ml/serving ε-greedy endpoints: /score/egreedy, /reward/egreedy, /stats/egreedy/{user_id}
  with d=7 feature vector (base 5 + sin/cos day-of-week encoding)
- Add offline simulation framework (ml/experiments/sim): rule/LLM/claude-code judges,
  two-phase score+reward, synthetic personas, task generator; results stored in sim_runs/sim_events
- Add /admin/simulations page: start runs, live-poll status, reward curve SVG, action/persona tables
- Fix egreedy day_of_week training skew: reward endpoint now uses actual dow instead of hardcoded 0
- Fix runner.py proxy bypass: httpx.Client(trust_env=False) for localhost ML calls
- Add dwellMs to TipFeedbackEvent contract and bus.test.ts fixture
- Schema: sim_runs, sim_events tables; tip_feedback gains dwell_ms, reward_milli columns
- ADR-0006: admin console framework; ADR-0007: egreedy-v1 policy selection rationale

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 07:44:37 +00:00
c5ea18ec6e docs: mark M1 fully shipped in roadmap
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:57:29 +00:00
e62c726ea4 feat: M1 admin console — all 10 remaining pages + signal/quality/ops infrastructure
Admin console (issues #63–72):
- Event stream viewer: live-tail ring buffer (500 events) with subject/user filters
- Feature store browser: per-user feature vector history from ml/serving
- Model registry panel: MLflow embed at /admin/models
- Experiment dashboard: LinUCB per-user stats (pulls, reward, θ) + bandit reset
- Recommendation log: per-tip explainability (policy, score, features, latency)
- Reward analytics: daily reaction breakdown + per-policy compare
- Data quality widget: missing-feature rate, stale-token rate, daily completeness
- Ops actions: replay-signal, policy enable/disable; user actions link to Users page
- SQL runner: read-only SELECT runner with saved queries
- Health rollup: fan-out to api/ml/sqlite/event-bus with auto-refresh

Backend:
- tip_scores table: logs features+policy+score+latency at every scoring call (#67)
- saved_queries table: per-admin saved SQL (#71)
- Event bus: 500-event ring buffer + tail() API (#63)
- Admin routes: /events, /tips, /reward-analytics, /data-quality, /health,
  /policies, /replay-signal, /sql, /saved-queries endpoints
- /api/ml/* admin-gated proxy to ml/serving (#64, #66)
- Shadow-policy registry in recommender (#56)

ML serving:
- /reset/{user_id}: clear bandit state + feature history (#66)
- /stats/{user_id}: pulls, cumulative reward, estimated mean, θ (#66)
- /features/{user_id}: last 100 feature vectors logged at scoring time (#64)
- Meta (pulls, rewards) persisted alongside A/b matrices

Web:
- Tip action sheet adds Helpful / Not helpful buttons (#62)
- TipFeedback type extended with helpful/not_helpful actions
- Rewards mapped: helpful=+0.5, not_helpful=−0.5

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:56:48 +00:00
2402a140e9 docs: mark M1 shipped in roadmap
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 14:08:20 +00:00
c7edd92e15 feat: M1 — LinUCB bandit, RemotePolicy, Web Push, event bus
ML serving:
- LinUCB contextual bandit (disjoint, d=5 features: hour_sin/cos, is_overdue, task_age, priority)
- /score endpoint replaces stub random; /reward endpoint for online learning
- Per-user model state persisted to disk as JSON (survives restarts)
- venv at ml/serving/.venv; start with pnpm dev from ml/serving

Recommender:
- Todoist fetch now extracts features (is_overdue, task_age_days, priority)
- RemotePolicy calls ml/serving with 3s timeout; falls back to RandomPolicy
- Reward sent to /reward on feedback (done=+1, snooze=0, dismiss=-1)

Web Push:
- VAPID keys in config; push_subscriptions table in DB
- POST/DELETE /api/push/subscribe; GET /api/push/vapid-public-key
- Service worker (public/sw.js): push → showNotification, notificationclick → focus/open
- "notify me" button on tip page; registers SW + subscribes on permission grant

Event bus:
- services/api/src/events/bus.ts: typed EventEmitter wrapper
- Subjects: signals.tip.served, signals.tip.feedback, signals.task.synced
- Same publish/subscribe API NATS JetStream will implement — swap is mechanical

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 14:08:00 +00:00
08dfa1d8c9 chore: gitignore playwright artifacts; mark M0 fully complete in README
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 09:09:26 +00:00
f6c890213b feat: complete M0 — legal pages, consent, tip_views metrics, account deletion UI
- /legal/terms and /legal/privacy pages (linked from sign-in)
- Consent (consentGiven=true) recorded on first Google sign-in
- tip_views table: one row per tip served — enables activation + reaction rate queries
- tip_views purged on account deletion
- Delete account button on /connect (confirm → revoke tokens → purge data → sign out)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 09:09:08 +00:00
888f8b9a99 docs: mark Phase 0 shipped in roadmap, note remaining M0 items
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 08:53:56 +00:00
3123cb73fb feat: Phase 0 walking skeleton — auth, Todoist integration, tip page
- Google OAuth2/PKCE flow via openid-client v6; session cookie (30-day)
- Next.js middleware auth guard — redirects before any client render
- Todoist OAuth2 connect/disconnect; REST v1 task fetch (today|overdue)
- RandomPolicy recommender behind stable POST /recommend contract
- Feedback endpoint (done/dismiss/snooze); marks task complete in Todoist
- 30s in-memory task cache per user (~1ms recommend on cache hit)
- Tip page: pure opacity fade-in (3.5s), fast fade-out (0.3s), no motion
- "reading you…" loading text with breathe animation
- PWA icons + manifest
- Ports pinned: API=3078, web=3079; Caddy at o.alogins.net

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 08:53:38 +00:00
65218762be feat: Phase 0 walking skeleton — monorepo, API, web, ML stub
Sets up the full Phase 0 foundation:

- pnpm workspaces + turbo build graph; native module build approval
- packages/shared-types: HTTP contracts (Tip, Auth, Integrations, User)
- services/api: Express modular monolith with better-sqlite3/drizzle
  - auth: Google OAuth2 + PKCE via openid-client v6, cookie sessions
  - integrations: Todoist OAuth2 connect/disconnect, token vault
  - recommender: RandomPolicy over Todoist tasks, feedback sink
  - user: profile, consent capture, full account deletion (GDPR)
- apps/web: Next.js 15, three pages (sign-in → connect → tip)
  - tip page: black canvas, hold-to-act gesture, action sheet
  - PWA manifest + theme
- ml/serving: FastAPI stub implementing the POST /score contract
- infra: docker-compose (core/full profiles), Dockerfiles, CI skeleton
- .env.example with all required vars documented

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 12:41:24 +00:00
225 changed files with 29081 additions and 115 deletions

18
.dockerignore Normal file
View File

@@ -0,0 +1,18 @@
**/node_modules
**/.next
**/dist
**/coverage
**/.vitest-cache
**/.turbo
.git
.gitea
.github
.vscode
.idea
**/.env
**/.env.local
**/*.log
infra/docker/data
**/__tests__
**/*.test.ts
**/*.test.tsx

62
.env.example Normal file
View File

@@ -0,0 +1,62 @@
# Copy to .env.local and fill in values — never commit .env.local
# API
SESSION_SECRET=change-me-to-a-random-32-char-string
PORT=3078
NODE_ENV=development
DATABASE_PATH=./data/oo.db
# API_BASE_URL = public origin only, no path suffix (used to build OAuth redirect URIs)
API_BASE_URL=http://localhost:3078
WEB_BASE_URL=http://localhost:3000
ML_SERVING_URL=http://localhost:8000
# MLflow (mlops profile) — http://localhost:5000/mlflow in dev, https://o.alogins.net/mlflow in prod.
# MLFLOW_ADMIN_PASSWORD seeds the admin account on first boot (changing it after first run
# requires the MLflow UI or API — see infra/mlflow/basic_auth.ini).
MLFLOW_URL=http://localhost:5000
MLFLOW_ADMIN_PASSWORD=change-me
# Public URL shown as link in the admin sidebar (must be NEXT_PUBLIC_ to reach the browser).
NEXT_PUBLIC_MLFLOW_URL=http://localhost:5000
# Shared secret for internal API callbacks. Generate: openssl rand -hex 32
INTERNAL_API_TOKEN=
# Static token for automated/service access to the admin panel (e.g. Playwright tests).
# Leave empty to disable token-based login. Generate: openssl rand -hex 32
ADMIN_TOKEN=
# AI stack — shared Agap services (ollama + litellm + langfuse). Not run from oO.
# Prod: https://llm.alogins.net | Dev: http://host.docker.internal:4000 from containers,
# http://localhost:4000 from host. Ollama: http://host.docker.internal:11434 / :11434.
LITELLM_URL=https://llm.alogins.net
LITELLM_MASTER_KEY=sk-oo-dev
OLLAMA_URL=http://host.docker.internal:11434
# Google OAuth — https://console.cloud.google.com/
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# VAPID (Web Push) — generate: node -e "const wp=require('web-push');console.log(JSON.stringify(wp.generateVAPIDKeys()))"
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:you@example.com
# Todoist OAuth — https://developer.todoist.com/appconsole.html
TODOIST_CLIENT_ID=
TODOIST_CLIENT_SECRET=
# Event bus — leave NATS_URL empty for in-process bus only (no JetStream bridge).
# Set to nats://nats:4222 (compose service name) or nats://localhost:4222 (host)
# to mirror every publish to durable JetStream streams (signals.>, feedback.>).
# Start the broker with: docker compose --profile events up nats
NATS_URL=
# How often the background scheduler refreshes Todoist tasks per active user (ms).
TODOIST_SYNC_INTERVAL_MS=900000
# Tip prompt selection — empty = use ml/serving default (v1).
# Pin a single variant: "v2-mentor"
# Rotate uniformly across variants: "v1,v2-mentor,v3-few-shot"
# Buckets show up in the admin reward-analytics dashboard (#92).
TIP_PROMPT_VERSION=
# Default version on the Python side when the API doesn't specify one.
DEFAULT_PROMPT_VERSION=v1

View File

@@ -0,0 +1,37 @@
name: buf-check
on:
push:
branches: [main]
paths:
- 'packages/shared-types/events/**'
pull_request:
paths:
- 'packages/shared-types/events/**'
jobs:
buf:
name: Lint & breaking-change check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install buf
run: |
BUF_VERSION=1.50.0
curl -sSfL \
"https://github.com/bufbuild/buf/releases/download/v${BUF_VERSION}/buf-Linux-x86_64" \
-o /usr/local/bin/buf
chmod +x /usr/local/bin/buf
buf --version
- name: buf lint
run: buf lint packages/shared-types/events
- name: buf breaking
if: github.event_name == 'pull_request'
run: |
buf breaking packages/shared-types/events \
--against ".git#branch=${{ github.base_ref }},subdir=packages/shared-types/events"

2
.gitignore vendored
View File

@@ -11,6 +11,7 @@ build/
__pycache__/
*.pyc
.venv/
__pycache__/
.mypy_cache/
.pytest_cache/
.ruff_cache/
@@ -19,3 +20,4 @@ coverage/
*.sqlite
.idea/
.vscode/
.playwright-mcp/

View File

@@ -0,0 +1,2 @@
- main [ref=e2] [cursor=pointer]:
- generic [ref=e3]: ···

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,119 @@
- generic [ref=e3]:
- generic [ref=e5]:
- generic [ref=e8]:
- img "Google" [ref=e10]
- generic [ref=e11]: Sign in with Google
- generic [ref=e12]:
- generic [ref=e14]:
- heading "Sign in" [level=1] [ref=e15]
- paragraph [ref=e16]:
- text: to continue to
- button "alogins.net" [ref=e17] [cursor=pointer]
- generic [ref=e18]:
- generic [ref=e21]:
- generic [ref=e26]:
- textbox "Email or phone" [active] [ref=e27]
- generic:
- generic: Email or phone
- paragraph [ref=e28]:
- link "Forgot email?" [ref=e29] [cursor=pointer]:
- /url: /signin/v2/usernamerecovery?app_domain=https://o.alogins.net&client_id=225787180325-cesuikl95j1qg3aijh4j10n8n7usf65p.apps.googleusercontent.com&code_challenge=oJlG0iJD7GEnZ_p0tMbi56r9QGirq6lY0KHNKh-A7sI&code_challenge_method=S256&continue=https://accounts.google.com/signin/oauth/legacy/consent?authuser%3Dunknown%26part%3DAJi8hAP_s0wcqLhX3B4DTcLADDDkyjYYD_dTsSJST_xu2o-PkobLfbuk1XhXVndyBN6gkOnR0zh89fZIeMovlOB4w8i9PC5L8hRlhSDi_OSh3ki0cAZ8RRH7CMPl0NmSZbAb7253b7Sq7Uj7dN8TmsRtXQbgjYmbhyQtpViFok9UeZM7XROiV83I_xwzDvOMQD1WQkkCPL_ZjRyGIpPgmLbBXxUWwGnWs7x0CLlb2wOYMM4diy8wjIIVzACtLq0g_hnnf0y_mxrQYevSjMx1y6vLEeMZrKz4zXxSwX11OP6adWx8v9lVviJvM53GJLq4oV46GDTGcxuXLkt9W6FBgRJqMoh1oulT8tHVf1O4VG6FuQkvXnppbkH8b9OMEnqTaVlL1DVwPrvaEytsqZ79DQ74hKi9NiK3RE9wkvTaSElEARWCxILbbRjXR-GKwwybfKjQwGUZ9t2sYcJdZK3ygn8vZIYDrPIKQM8g27tJTfNj5yjjYJZsv-EMRSSvheUtOMdNBzy7fqqBKvxGQEO3iDfgy88NdOrw1AktvipSuf5E5uVlJ5KPLTq5J8TuKHO9TQcmBu9Fn1U1_NwlvMkvWbPJ2zbkshNe2q200XCnKF6Wz1bLG7sukKzEoyJkmc3x7poz1z9JQ4f7%26flowName%3DGeneralOAuthFlow%26as%3DS356240196%253A1776175595013373%26client_id%3D225787180325-cesuikl95j1qg3aijh4j10n8n7usf65p.apps.googleusercontent.com%23&dsh=S356240196:1776175595013373&flowName=GeneralOAuthLite&o2v=2&opparams=%253F&rart=ANgoxceHU_HzI34dur-e3VKjuSW_62nNyS0F-NAT8vowVRgzLCH8LgeU-dR_jmgEfEI3TjNPYuzeB6EBCMum_GguMnKcbkqUwFjfpff8YscBkT3joDYra5Q&redirect_uri=https://o.alogins.net/api/auth/callback&response_type=code&scope=openid+email+profile&service=lso&state=dfjtgpxO8h1eS83EqakZj
- generic [ref=e31]:
- button "Next" [ref=e33]
- link "Create account" [ref=e35] [cursor=pointer]:
- /url: /lifecycle/flows/signup?app_domain=https://o.alogins.net&client_id=225787180325-cesuikl95j1qg3aijh4j10n8n7usf65p.apps.googleusercontent.com&code_challenge=oJlG0iJD7GEnZ_p0tMbi56r9QGirq6lY0KHNKh-A7sI&code_challenge_method=S256&continue=https://accounts.google.com/signin/oauth/legacy/consent?authuser%3Dunknown%26part%3DAJi8hAP_s0wcqLhX3B4DTcLADDDkyjYYD_dTsSJST_xu2o-PkobLfbuk1XhXVndyBN6gkOnR0zh89fZIeMovlOB4w8i9PC5L8hRlhSDi_OSh3ki0cAZ8RRH7CMPl0NmSZbAb7253b7Sq7Uj7dN8TmsRtXQbgjYmbhyQtpViFok9UeZM7XROiV83I_xwzDvOMQD1WQkkCPL_ZjRyGIpPgmLbBXxUWwGnWs7x0CLlb2wOYMM4diy8wjIIVzACtLq0g_hnnf0y_mxrQYevSjMx1y6vLEeMZrKz4zXxSwX11OP6adWx8v9lVviJvM53GJLq4oV46GDTGcxuXLkt9W6FBgRJqMoh1oulT8tHVf1O4VG6FuQkvXnppbkH8b9OMEnqTaVlL1DVwPrvaEytsqZ79DQ74hKi9NiK3RE9wkvTaSElEARWCxILbbRjXR-GKwwybfKjQwGUZ9t2sYcJdZK3ygn8vZIYDrPIKQM8g27tJTfNj5yjjYJZsv-EMRSSvheUtOMdNBzy7fqqBKvxGQEO3iDfgy88NdOrw1AktvipSuf5E5uVlJ5KPLTq5J8TuKHO9TQcmBu9Fn1U1_NwlvMkvWbPJ2zbkshNe2q200XCnKF6Wz1bLG7sukKzEoyJkmc3x7poz1z9JQ4f7%26flowName%3DGeneralOAuthFlow%26as%3DS356240196%253A1776175595013373%26client_id%3D225787180325-cesuikl95j1qg3aijh4j10n8n7usf65p.apps.googleusercontent.com%23&dsh=S356240196:1776175595013373&flowEntry=SignUp&flowName=GlifWebSignIn&o2v=2&opparams=%253F&rart=ANgoxceHU_HzI34dur-e3VKjuSW_62nNyS0F-NAT8vowVRgzLCH8LgeU-dR_jmgEfEI3TjNPYuzeB6EBCMum_GguMnKcbkqUwFjfpff8YscBkT3joDYra5Q&redirect_uri=https://o.alogins.net/api/auth/callback&response_type=code&scope=openid+email+profile&service=lso&signInUrl=https://accounts.google.com/signin/oauth?app_domain%3Dhttps://o.alogins.net%26client_id%3D225787180325-cesuikl95j1qg3aijh4j10n8n7usf65p.apps.googleusercontent.com%26code_challenge%3DoJlG0iJD7GEnZ_p0tMbi56r9QGirq6lY0KHNKh-A7sI%26code_challenge_method%3DS256%26continue%3Dhttps://accounts.google.com/signin/oauth/legacy/consent?authuser%253Dunknown%2526part%253DAJi8hAP_s0wcqLhX3B4DTcLADDDkyjYYD_dTsSJST_xu2o-PkobLfbuk1XhXVndyBN6gkOnR0zh89fZIeMovlOB4w8i9PC5L8hRlhSDi_OSh3ki0cAZ8RRH7CMPl0NmSZbAb7253b7Sq7Uj7dN8TmsRtXQbgjYmbhyQtpViFok9UeZM7XROiV83I_xwzDvOMQD1WQkkCPL_ZjRyGIpPgmLbBXxUWwGnWs7x0CLlb2wOYMM4diy8wjIIVzACtLq0g_hnnf0y_mxrQYevSjMx1y6vLEeMZrKz4zXxSwX11OP6adWx8v9lVviJvM53GJLq4oV46GDTGcxuXLkt9W6FBgRJqMoh1oulT8tHVf1O4VG6FuQkvXnppbkH8b9OMEnqTaVlL1DVwPrvaEytsqZ79DQ74hKi9NiK3RE9wkvTaSElEARWCxILbbRjXR-GKwwybfKjQwGUZ9t2sYcJdZK3ygn8vZIYDrPIKQM8g27tJTfNj5yjjYJZsv-EMRSSvheUtOMdNBzy7fqqBKvxGQEO3iDfgy88NdOrw1AktvipSuf5E5uVlJ5KPLTq5J8TuKHO9TQcmBu9Fn1U1_NwlvMkvWbPJ2zbkshNe2q200XCnKF6Wz1bLG7sukKzEoyJkmc3x7poz1z9JQ4f7%2526flowName%253DGeneralOAuthFlow%2526as%253DS356240196%25253A1776175595013373%2526client_id%253D225787180325-cesuikl95j1qg3aijh4j10n8n7usf65p.apps.googleusercontent.com%2523%26dsh%3DS356240196:1776175595013373%26flowName%3DGeneralOAuthLite%26o2v%3D2%26opparams%3D%25253F%26rart%3DANgoxceHU_HzI34dur-e3VKjuSW_62nNyS0F-NAT8vowVRgzLCH8LgeU-dR_jmgEfEI3TjNPYuzeB6EBCMum_GguMnKcbkqUwFjfpff8YscBkT3joDYra5Q%26redirect_uri%3Dhttps://o.alogins.net/api/auth/callback%26response_type%3Dcode%26scope%3Dopenid%2Bemail%2Bprofile%26service%3Dlso%26state%3DdfjtgpxO8h1eS83EqakZj&state=dfjtgpxO8h1eS83EqakZj
- contentinfo [ref=e36]:
- combobox [ref=e39] [cursor=pointer]:
- option "Afrikaans"
- option "azərbaycan"
- option "bosanski"
- option "català"
- option "Čeština"
- option "Cymraeg"
- option "Dansk"
- option "Deutsch"
- option "eesti"
- option "English (United Kingdom)"
- option "English (United States)" [selected]
- option "Español (España)"
- option "Español (Latinoamérica)"
- option "euskara"
- option "Filipino"
- option "Français (Canada)"
- option "Français (France)"
- option "Gaeilge"
- option "galego"
- option "Hrvatski"
- option "Indonesia"
- option "isiZulu"
- option "íslenska"
- option "Italiano"
- option "Kiswahili"
- option "latviešu"
- option "lietuvių"
- option "magyar"
- option "Melayu"
- option "Nederlands"
- option "norsk"
- option "ozbek"
- option "polski"
- option "Português (Brasil)"
- option "Português (Portugal)"
- option "română"
- option "shqip"
- option "Slovenčina"
- option "slovenščina"
- option "srpski (latinica)"
- option "Suomi"
- option "Svenska"
- option "Tiếng Việt"
- option "Türkçe"
- option "Ελληνικά"
- option "беларуская"
- option "български"
- option "кыргызча"
- option "қазақ тілі"
- option "македонски"
- option "монгол"
- option "Русский"
- option "српски (ћирилица)"
- option "Українська"
- option "ქართული"
- option "հայերեն"
- option "‫עברית‬‎"
- option "‫اردو‬‎"
- option "‫العربية‬‎"
- option "‫فارسی‬‎"
- option "አማርኛ"
- option "नेपाली"
- option "मराठी"
- option "हिन्दी"
- option "অসমীয়া"
- option "বাংলা"
- option "ਪੰਜਾਬੀ"
- option "ગુજરાતી"
- option "ଓଡ଼ିଆ"
- option "தமிழ்"
- option "తెలుగు"
- option "ಕನ್ನಡ"
- option "മലയാളം"
- option "සිංහල"
- option "ไทย"
- option "ລາວ"
- option "မြန်မာ"
- option "ខ្មែរ"
- option "한국어"
- option "中文(香港)"
- option "日本語"
- option "简体中文"
- option "繁體中文"
- list [ref=e40]:
- listitem [ref=e41]:
- link "Help" [ref=e42] [cursor=pointer]:
- /url: https://support.google.com/accounts?hl=en-US&p=account_iph
- listitem [ref=e43]:
- link "Privacy" [ref=e44] [cursor=pointer]:
- /url: https://accounts.google.com/TOS?loc=LV&hl=en-US&privacy=true
- listitem [ref=e45]:
- link "Terms" [ref=e46] [cursor=pointer]:
- /url: https://accounts.google.com/TOS?loc=LV&hl=en-US

View File

@@ -0,0 +1,16 @@
- main [ref=e2]:
- generic [ref=e3]:
- heading "oO" [level=1] [ref=e4]
- paragraph [ref=e5]: one tip. right now.
- link "Continue with Google" [ref=e7] [cursor=pointer]:
- /url: /api/auth/login?redirectTo=/connect
- img [ref=e8]
- text: Continue with Google
- paragraph [ref=e13]:
- text: By continuing you agree to our
- link "Terms" [ref=e14] [cursor=pointer]:
- /url: /legal/terms
- text: and
- link "Privacy Policy" [ref=e15] [cursor=pointer]:
- /url: /legal/privacy
- text: .

View File

@@ -0,0 +1,2 @@
- main [ref=e2] [cursor=pointer]:
- generic [ref=e3]: ···

View File

@@ -0,0 +1,16 @@
- main [ref=e2]:
- generic [ref=e3]:
- heading "oO" [level=1] [ref=e4]
- paragraph [ref=e5]: one tip. right now.
- link "Continue with Google" [ref=e7] [cursor=pointer]:
- /url: /api/auth/login?redirectTo=/connect
- img [ref=e8]
- text: Continue with Google
- paragraph [ref=e13]:
- text: By continuing you agree to our
- link "Terms" [ref=e14] [cursor=pointer]:
- /url: /legal/terms
- text: and
- link "Privacy Policy" [ref=e15] [cursor=pointer]:
- /url: /legal/privacy
- text: .

View File

@@ -0,0 +1,119 @@
- generic [ref=e3]:
- generic [ref=e5]:
- generic [ref=e8]:
- img "Google" [ref=e10]
- generic [ref=e11]: Sign in with Google
- generic [ref=e12]:
- generic [ref=e14]:
- heading "Sign in" [level=1] [ref=e15]
- paragraph [ref=e16]:
- text: to continue to
- button "alogins.net" [ref=e17] [cursor=pointer]
- generic [ref=e18]:
- generic [ref=e21]:
- generic [ref=e26]:
- textbox "Email or phone" [active] [ref=e27]
- generic:
- generic: Email or phone
- paragraph [ref=e28]:
- link "Forgot email?" [ref=e29] [cursor=pointer]:
- /url: /signin/v2/usernamerecovery?app_domain=https://o.alogins.net&client_id=225787180325-cesuikl95j1qg3aijh4j10n8n7usf65p.apps.googleusercontent.com&code_challenge=-BqTqiuiRm7bqR18AN6dGJza3m1LQ-rUHm5iCKVp4L0&code_challenge_method=S256&continue=https://accounts.google.com/signin/oauth/legacy/consent?authuser%3Dunknown%26part%3DAJi8hAMZa_8Uw5oeDBf5xu6LwYAzWw0IfUPVe1s-RdkhKrryVpbj5DP1e4mWD5shlmUM9MHWJjmo0221X4Itv9m_QVhq6U9AE3ixMmFjYikcxLUbYpSC0ZK2Gpk6iXyS0bmfDCxbgrB-XxcJUfHUxxHIFAIPwGNH1HXRh9xm5nfY7qXSpWvjqH2SjPsvABBxBfb4_gYQVYDpFH6xY9tvo9ivQm607DUHzOGvM91l2PAzDK2xfzP1ly9SqBaA342VjnFJmEA5mKtYKfGXiThy7J62jQMM-NK8pjUTysf6PXNOLLyUCtl7VjQPs23EBAwe_22hjTKkVo9DRb9dlv8VNmIdVK-BVUoalJmfsRs3BVet5yibkeKV20QKQlCDgtPLCorYZyu3gnFh5taleK1WBbKLyRtscF3rVnhCkIp4Y4y3m8nHtTr_lKRi0jyIfZA9CJwxJnFJa50DFwjmiOYWosdQsuu6HCZ5CfNfCorlKIYB3zjmMRcFHdwYxPvydfXXR6OPui9JQfcE4KEjnE4sDQbB6B70TD9R-o7lqt5V9Qh0cOWwKpO0-m167gCqNjS67_qgdM4UK0vGResYyPoAGae9OtyQmCAj_dcU-62wi6Keit4fc9kh4tW_Sg3z-_uWIv3YDam93LJE%26flowName%3DGeneralOAuthFlow%26as%3DS-1308179881%253A1776176817375272%26client_id%3D225787180325-cesuikl95j1qg3aijh4j10n8n7usf65p.apps.googleusercontent.com%23&dsh=S-1308179881:1776176817375272&flowName=GeneralOAuthLite&o2v=2&opparams=%253F&rart=ANgoxceTKJUOKU3v1dqvsh5ic8iCtLsW59TMJ-d2ocODSbnDkgr7QsEsYbgBLhyzyxlCKvCeKxeWXE4oh50Ic8Zii472KjuAo007j0Fg5aey109I_iJ5RME&redirect_uri=https://o.alogins.net/api/auth/callback&response_type=code&scope=openid+email+profile&service=lso&state=8jVx7mQ1bRb8HljdpHf35
- generic [ref=e31]:
- button "Next" [ref=e33]
- link "Create account" [ref=e35] [cursor=pointer]:
- /url: /lifecycle/flows/signup?app_domain=https://o.alogins.net&client_id=225787180325-cesuikl95j1qg3aijh4j10n8n7usf65p.apps.googleusercontent.com&code_challenge=-BqTqiuiRm7bqR18AN6dGJza3m1LQ-rUHm5iCKVp4L0&code_challenge_method=S256&continue=https://accounts.google.com/signin/oauth/legacy/consent?authuser%3Dunknown%26part%3DAJi8hAMZa_8Uw5oeDBf5xu6LwYAzWw0IfUPVe1s-RdkhKrryVpbj5DP1e4mWD5shlmUM9MHWJjmo0221X4Itv9m_QVhq6U9AE3ixMmFjYikcxLUbYpSC0ZK2Gpk6iXyS0bmfDCxbgrB-XxcJUfHUxxHIFAIPwGNH1HXRh9xm5nfY7qXSpWvjqH2SjPsvABBxBfb4_gYQVYDpFH6xY9tvo9ivQm607DUHzOGvM91l2PAzDK2xfzP1ly9SqBaA342VjnFJmEA5mKtYKfGXiThy7J62jQMM-NK8pjUTysf6PXNOLLyUCtl7VjQPs23EBAwe_22hjTKkVo9DRb9dlv8VNmIdVK-BVUoalJmfsRs3BVet5yibkeKV20QKQlCDgtPLCorYZyu3gnFh5taleK1WBbKLyRtscF3rVnhCkIp4Y4y3m8nHtTr_lKRi0jyIfZA9CJwxJnFJa50DFwjmiOYWosdQsuu6HCZ5CfNfCorlKIYB3zjmMRcFHdwYxPvydfXXR6OPui9JQfcE4KEjnE4sDQbB6B70TD9R-o7lqt5V9Qh0cOWwKpO0-m167gCqNjS67_qgdM4UK0vGResYyPoAGae9OtyQmCAj_dcU-62wi6Keit4fc9kh4tW_Sg3z-_uWIv3YDam93LJE%26flowName%3DGeneralOAuthFlow%26as%3DS-1308179881%253A1776176817375272%26client_id%3D225787180325-cesuikl95j1qg3aijh4j10n8n7usf65p.apps.googleusercontent.com%23&dsh=S-1308179881:1776176817375272&flowEntry=SignUp&flowName=GlifWebSignIn&o2v=2&opparams=%253F&rart=ANgoxceTKJUOKU3v1dqvsh5ic8iCtLsW59TMJ-d2ocODSbnDkgr7QsEsYbgBLhyzyxlCKvCeKxeWXE4oh50Ic8Zii472KjuAo007j0Fg5aey109I_iJ5RME&redirect_uri=https://o.alogins.net/api/auth/callback&response_type=code&scope=openid+email+profile&service=lso&signInUrl=https://accounts.google.com/signin/oauth?app_domain%3Dhttps://o.alogins.net%26client_id%3D225787180325-cesuikl95j1qg3aijh4j10n8n7usf65p.apps.googleusercontent.com%26code_challenge%3D-BqTqiuiRm7bqR18AN6dGJza3m1LQ-rUHm5iCKVp4L0%26code_challenge_method%3DS256%26continue%3Dhttps://accounts.google.com/signin/oauth/legacy/consent?authuser%253Dunknown%2526part%253DAJi8hAMZa_8Uw5oeDBf5xu6LwYAzWw0IfUPVe1s-RdkhKrryVpbj5DP1e4mWD5shlmUM9MHWJjmo0221X4Itv9m_QVhq6U9AE3ixMmFjYikcxLUbYpSC0ZK2Gpk6iXyS0bmfDCxbgrB-XxcJUfHUxxHIFAIPwGNH1HXRh9xm5nfY7qXSpWvjqH2SjPsvABBxBfb4_gYQVYDpFH6xY9tvo9ivQm607DUHzOGvM91l2PAzDK2xfzP1ly9SqBaA342VjnFJmEA5mKtYKfGXiThy7J62jQMM-NK8pjUTysf6PXNOLLyUCtl7VjQPs23EBAwe_22hjTKkVo9DRb9dlv8VNmIdVK-BVUoalJmfsRs3BVet5yibkeKV20QKQlCDgtPLCorYZyu3gnFh5taleK1WBbKLyRtscF3rVnhCkIp4Y4y3m8nHtTr_lKRi0jyIfZA9CJwxJnFJa50DFwjmiOYWosdQsuu6HCZ5CfNfCorlKIYB3zjmMRcFHdwYxPvydfXXR6OPui9JQfcE4KEjnE4sDQbB6B70TD9R-o7lqt5V9Qh0cOWwKpO0-m167gCqNjS67_qgdM4UK0vGResYyPoAGae9OtyQmCAj_dcU-62wi6Keit4fc9kh4tW_Sg3z-_uWIv3YDam93LJE%2526flowName%253DGeneralOAuthFlow%2526as%253DS-1308179881%25253A1776176817375272%2526client_id%253D225787180325-cesuikl95j1qg3aijh4j10n8n7usf65p.apps.googleusercontent.com%2523%26dsh%3DS-1308179881:1776176817375272%26flowName%3DGeneralOAuthLite%26o2v%3D2%26opparams%3D%25253F%26rart%3DANgoxceTKJUOKU3v1dqvsh5ic8iCtLsW59TMJ-d2ocODSbnDkgr7QsEsYbgBLhyzyxlCKvCeKxeWXE4oh50Ic8Zii472KjuAo007j0Fg5aey109I_iJ5RME%26redirect_uri%3Dhttps://o.alogins.net/api/auth/callback%26response_type%3Dcode%26scope%3Dopenid%2Bemail%2Bprofile%26service%3Dlso%26state%3D8jVx7mQ1bRb8HljdpHf35&state=8jVx7mQ1bRb8HljdpHf35
- contentinfo [ref=e36]:
- combobox [ref=e39] [cursor=pointer]:
- option "Afrikaans"
- option "azərbaycan"
- option "bosanski"
- option "català"
- option "Čeština"
- option "Cymraeg"
- option "Dansk"
- option "Deutsch"
- option "eesti"
- option "English (United Kingdom)"
- option "English (United States)" [selected]
- option "Español (España)"
- option "Español (Latinoamérica)"
- option "euskara"
- option "Filipino"
- option "Français (Canada)"
- option "Français (France)"
- option "Gaeilge"
- option "galego"
- option "Hrvatski"
- option "Indonesia"
- option "isiZulu"
- option "íslenska"
- option "Italiano"
- option "Kiswahili"
- option "latviešu"
- option "lietuvių"
- option "magyar"
- option "Melayu"
- option "Nederlands"
- option "norsk"
- option "ozbek"
- option "polski"
- option "Português (Brasil)"
- option "Português (Portugal)"
- option "română"
- option "shqip"
- option "Slovenčina"
- option "slovenščina"
- option "srpski (latinica)"
- option "Suomi"
- option "Svenska"
- option "Tiếng Việt"
- option "Türkçe"
- option "Ελληνικά"
- option "беларуская"
- option "български"
- option "кыргызча"
- option "қазақ тілі"
- option "македонски"
- option "монгол"
- option "Русский"
- option "српски (ћирилица)"
- option "Українська"
- option "ქართული"
- option "հայերեն"
- option "‫עברית‬‎"
- option "‫اردو‬‎"
- option "‫العربية‬‎"
- option "‫فارسی‬‎"
- option "አማርኛ"
- option "नेपाली"
- option "मराठी"
- option "हिन्दी"
- option "অসমীয়া"
- option "বাংলা"
- option "ਪੰਜਾਬੀ"
- option "ગુજરાતી"
- option "ଓଡ଼ିଆ"
- option "தமிழ்"
- option "తెలుగు"
- option "ಕನ್ನಡ"
- option "മലയാളം"
- option "සිංහල"
- option "ไทย"
- option "ລາວ"
- option "မြန်မာ"
- option "ខ្មែរ"
- option "한국어"
- option "中文(香港)"
- option "日本語"
- option "简体中文"
- option "繁體中文"
- list [ref=e40]:
- listitem [ref=e41]:
- link "Help" [ref=e42] [cursor=pointer]:
- /url: https://support.google.com/accounts?hl=en-US&p=account_iph
- listitem [ref=e43]:
- link "Privacy" [ref=e44] [cursor=pointer]:
- /url: https://accounts.google.com/TOS?loc=LV&hl=en-US&privacy=true
- listitem [ref=e45]:
- link "Terms" [ref=e46] [cursor=pointer]:
- /url: https://accounts.google.com/TOS?loc=LV&hl=en-US

View File

@@ -0,0 +1,16 @@
- main [ref=e2]:
- generic [ref=e3]:
- heading "oO" [level=1] [ref=e4]
- paragraph [ref=e5]: one tip. right now.
- link "Continue with Google" [ref=e7] [cursor=pointer]:
- /url: /api/auth/login?redirectTo=/connect
- img [ref=e8]
- text: Continue with Google
- paragraph [ref=e13]:
- text: By continuing you agree to our
- link "Terms" [ref=e14] [cursor=pointer]:
- /url: /legal/terms
- text: and
- link "Privacy Policy" [ref=e15] [cursor=pointer]:
- /url: /legal/privacy
- text: .

View File

@@ -0,0 +1,16 @@
- main [ref=e2]:
- generic [ref=e3]:
- heading "oO" [level=1] [ref=e4]
- paragraph [ref=e5]: one tip. right now.
- link "Continue with Google" [ref=e7] [cursor=pointer]:
- /url: /api/auth/login?redirectTo=/connect
- img [ref=e8]
- text: Continue with Google
- paragraph [ref=e13]:
- text: By continuing you agree to our
- link "Terms" [ref=e14] [cursor=pointer]:
- /url: /legal/terms
- text: and
- link "Privacy Policy" [ref=e15] [cursor=pointer]:
- /url: /legal/privacy
- text: .

186
CLAUDE.md
View File

@@ -42,7 +42,7 @@ packages/ shared libraries (importable across services + apps)
ml/ Python — separate deployable from day one
serving/ online scorer (FastAPI), called by recommender
features/ feature definitions + store adapter
pipelines/ batch feature + training DAGs (Prefect/Airflow)
pipelines/ batch feature + training scripts
registry/ MLflow model registry integration
experiments/ assignment + A/B + bandit policies
notebooks/ research only; never imported by production code
@@ -56,7 +56,7 @@ docs/ architecture notes, ADRs, API specs
## Contracts between modules
- **HTTP** (OpenAPI, in `packages/shared-types/http/`) — synchronous request/response. In-process today; over the network once extracted. Signatures are identical.
- **Events** (Protocol Buffers, in `packages/shared-types/events/`) — durable signals + feedback. Today: in-process event emitter. Tomorrow: NATS JetStream. Schema registry enforced in CI (ADR-0005).
- **Events** (Protocol Buffers, in `packages/shared-types/events/`) — durable signals + feedback. Today: in-process `Bus` with a `onPublish` bridge to NATS JetStream when `NATS_URL` is set (ADR-0010). The in-proc bus stays the source of truth — JetStream is the durable mirror that cross-process consumers (`ml/serving`, future feature pipelines) tail. Proto schemas (ADR-0005) live in `packages/shared-types/events/oo/events/v1/`; `buf lint` + `buf breaking` run in CI on every PR touching those files (`.gitea/workflows/buf-check.yaml`).
- Do not redefine types per module. Regenerate from `shared-types`.
## Conventions
@@ -65,7 +65,18 @@ docs/ architecture notes, ADRs, API specs
- One PR = one concern. Conventional-commit prefixes (`feat:`, `fix:`, `chore:`, `docs:`, `refactor:`).
- ADRs go in `docs/adr/NNNN-title.md` for any decision that constrains future work.
- No secrets in repo. Local dev via `.env.local` (gitignored), prod via the server's secret store (Vaultwarden now; k8s secrets later).
- Compose profiles (`core`, `full`) so devs can run a subset without 16 GB of RAM.
- Compose profiles: `core` (api + web + admin), `full` (adds ml-serving + nats), `mlops` (adds MLflow), `ai` (adds Ollama + LiteLLM). Mix as needed. Always pass `--profile <name>` to `build`/`up` — without a profile, no services are selected and builds silently do nothing.
- Docker rebuild: use `--force-recreate` on `up` when only env vars changed (no image rebuild needed); new env vars in `.env.local` are not picked up by a running container until it is recreated.
- Docker rebuild gotchas:
- **Never run two `docker compose up --build` at once** — both grab the same `--mount=type=cache,id=pnpm` and deadlock on the API's `pnpm --prod deploy` step. Symptom: build sits silent for hours on `[api builder 8/8]`. Before starting any build, check `ps aux | grep "docker compose"` and kill any prior `up --build` (`kill -9 <pid>` — the wrapper bash and the docker compose binary are separate PIDs; kill the docker compose one).
- **Don't add `--offline` to `pnpm --prod deploy`** — pnpm's metadata cache (`/root/.cache/pnpm/`) is not in the `/pnpm/store` cache mount, so `--offline` fails with `ERR_PNPM_NO_OFFLINE_META` for transitive devDeps (e.g. vite via vitest). Leave the deploy step network-on; it works.
- **All TS Dockerfiles need `python3 make g++`** in the base stage — `better-sqlite3` rebuilds natively on install. Missing from `Dockerfile.admin` historically caused `gyp ERR! find Python` failures.
- **`Dockerfile.ml` needs `build-essential`** (not just `gcc`) — `pyswisseph` (stars agent) compiles C from source and fails with `fatal error: math.h: No such file or directory` if only `gcc` is installed; it needs `libc-dev` too, easiest via `build-essential`.
- **`Dockerfile.web` builder stage needs root `package.json` + `pnpm-workspace.yaml` + `pnpm-lock.yaml`** copied in. Without them, `pnpm --filter @oo/shared-types build` fails with `[ERR_PNPM_NO_PKG_MANIFEST] No package.json found in /app`. The deps stage has them but the builder is a fresh layer; selective copies must include them.
- **A clean build of `--profile core` takes ~3 min total** when the buildx cache is warm. If it's been silent for >10 min, check for the parallel-build deadlock above before assuming "still going".
- Run Python agent tests: `python3 -m pytest ml/agents/tests/ -x -q` (tests add repo root to `sys.path` themselves).
- Run Python feature tests: `python3 -m pytest ml/features/ -x -q`
- `ml/features/` files are Python mirrors of TS registries — TS is source of truth. Tests parse `registry.ts` with regex to detect drift; follow the same pattern whenever a new field is added to `ProfileFeature`.
## Definition of done (per feature)
@@ -76,15 +87,176 @@ docs/ architecture notes, ADRs, API specs
5. Deployable via `docker compose up` locally.
6. If it touches user data → a deletion path exists and is tested.
## AI stack
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 |
|-------|-------|---------|
| `tip-generator` | qwen2.5:1.5b (default) | `ml/serving` tip generation |
| `embedder` | nomic-embed-text | task clustering (after LLM enrichment), dedup |
| `judge` | claude-haiku-4-5 (cloud, eval only) | offline sim |
Env vars: `LITELLM_URL` (prod `https://llm.alogins.net`), `OLLAMA_URL` (Agap host, `http://host.docker.internal:11434` from containers).
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.
All `httpx` calls in `ml/` must use `trust_env=False` to bypass the system proxy — same rule as `bw` and curl. Pattern: `httpx.Client(trust_env=False, timeout=N)`.
MLflow container-to-container calls: always pass `host_header="localhost"` to `MLflowClient` — MLflow's `--allowed-hosts` rejects `Host: mlflow` (the container DNS name) with 403. Auth credential is `MLFLOW_ADMIN_PASSWORD`. MLflow REST API lives at the origin root, not under the `/mlflow` UI prefix.
### MLflow API versions — runs vs traces
MLflow uses **two API versions** — use the right one or you'll get 405:
| What | API prefix | Example |
|------|-----------|---------|
| Runs, experiments, metrics | `/api/2.0/mlflow/` | `runs/search`, `experiments/list` |
| Traces (LLM observability) | `/api/3.0/mlflow/traces/` | `traces/{trace_id}` |
**Experiment IDs:** `3` = oO/serving. Artifacts stored as run tags prefixed `artifact:<path>`.
### Querying from the host shell
Always strip the proxy and pass `Host: localhost` (no port — `localhost:5000` fails the DNS-rebinding check).
```bash
# Search recent runs (experiment 3)
env -u HTTPS_PROXY -u HTTP_PROXY -u ALL_PROXY -u https_proxy -u http_proxy -u all_proxy \
curl -s -H "Host: localhost" -u "admin:${MLFLOW_ADMIN_PASSWORD}" \
-X POST http://localhost:5000/api/2.0/mlflow/runs/search \
-H "Content-Type: application/json" \
-d '{"experiment_ids":["3"],"max_results":5,"order_by":["start_time DESC"]}'
# Get a trace by ID (note: /api/3.0/, not /api/2.0/)
env -u HTTPS_PROXY -u HTTP_PROXY -u ALL_PROXY -u https_proxy -u http_proxy -u all_proxy \
curl -s -H "Host: localhost" -u "admin:${MLFLOW_ADMIN_PASSWORD}" \
http://localhost:5000/api/3.0/mlflow/traces/tr-<trace_id> | python3 -m json.tool
```
The trace response includes `trace_metadata.mlflow.traceInputs/Outputs`, `trace_metadata.mlflow.trace.sizeStats` (num_spans), and `tags.mlflow.traceName`.
### Getting spans (Python client from inside the container)
The REST API has **no endpoint for spans**`/api/3.0/mlflow/traces/{id}/spans` returns 404. Use the Python client inside `oo-ml-serving-1`:
```bash
docker exec oo-ml-serving-1 python3 -c "
import mlflow, json, os
mlflow.set_tracking_uri('http://mlflow:5000')
os.environ['MLFLOW_TRACKING_USERNAME'] = 'admin'
os.environ['MLFLOW_TRACKING_PASSWORD'] = os.environ.get('MLFLOW_ADMIN_PASSWORD', '')
client = mlflow.tracking.MlflowClient()
trace = client.get_trace('tr-<trace_id>')
for span in trace.data.spans:
print(span.name, '| parent:', span.parent_id, '| status:', span.status)
print(' inputs:', json.dumps(span.inputs)[:200])
print(' outputs:', json.dumps(span.outputs)[:200])
print(' attrs:', span.attributes)
"
```
### Span structure for a tip generation trace
A healthy `recommend` trace has 3 spans:
| Span | Type | Parent | Key attributes |
|------|------|--------|---------------|
| `recommend` | CHAIN | (root) | `agent_count`, `latency_ms`; inputs include `agent_ids` list |
| `build_context` | TOOL | recommend | `agent_count`, `task_count`, `science_destiny` |
| `llm_orchestrator` | LLM | recommend | `prompt_tokens`, `completion_tokens`, `model`, `attempts` |
### Diagnosing "no agents in trace"
If the trace shows `agent_ids: []` and `agent_count: 0` in the root span, and the orchestrator prompt says *"No pre-computed agent context available"*, it means the recommender found zero eligible snippets at request time. Causes:
1. **Agent compute hasn't run** — no `agent_outputs` rows for this user yet
2. **Snippets expired** — TTL elapsed since last compute
3. **Eligibility filter dropped all agents** — none passed the manifest-driven check
Diagnose with:
```bash
docker exec oo-api-1 psql "$DATABASE_URL" -c \
"SELECT agent_id, computed_at, expires_at FROM agent_outputs WHERE user_id='<uid>' ORDER BY computed_at DESC LIMIT 10;"
```
**Multi-agent tip generation pipeline (ADR-0013):**
1. Pre-compute agents (`ml/agents/<id>/`) run on a schedule, each emitting a snippet into `agent_outputs` with a per-agent TTL
2. On request, `recommender` (TS) loads the eligible agent set (registry-driven, ADR-0014) and pulls the freshest non-expired snippets
3. `POST /recommend` in `ml/serving` assembles the orchestrator prompt (`v4-orchestrator`) and calls LiteLLM via the `tip-generator` alias
4. Returned tip is logged in `tip_scores` with the contributing agent set; reaction is logged for observability (no bandit reward loop)
## Current phase
**Phase 0 — Prototype.** 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:
- ADR-0013 — multi-agent recommendation: pre-computed agent snippets + orchestrator LLM (replaces ε-greedy bandit) — 2026-05-01
- LLM context assembler + tip generation scaffold (#79, #88)
- Model benchmarking for tip generation (#93, #95)
- Admin UX refinements: feedback consolidation, settings placement (#100102)
- ADR-0012 — ε-greedy v2 (D=12) — 2026-04-26 (now superseded by ADR-0013)
- ADR-0014 complete: unified Profile schema + backfill, manifest plumbing, `/api/profile` read-through, registry-driven eligibility filter, inference framework + per-agent inference, legacy consent column drop — 2026-05-05
- Rich per-agent inference for all four active agents (#112, #114, #115, #116) — 2026-05-06: quiet/peak hours (time-of-day), z-score baseline (momentum), p50 lateness + project realness (overdue-task), adaptive lookback + weekly/daily cycles (recent-patterns)
- Semantic task clustering via nomic-embed-text + LLM enrichment (#97, #113, #129) — 2026-05-12: `ml/agents/clustering.py`; titles expanded via `tip-generator` before embedding; persistent cache in `task_enrichments` table; recompute gated on task-list hash change; focus-area v3.0.0 outputs all clusters with enriched descriptions
- Per-user feature freshness SLAs (#61) — 2026-05-06: `invalidated_by` mirrored into `ProfileFeature`; drift-detection test added
- MLflow tracing added to `ml/serving` for all agent calls — 2026-05-06: `ml/serving/mlflow_client.py`; activated by `MLFLOW_TRACKING_URI=http://mlflow:5000` (default in compose `full` profile); requires `--profile mlops` for the MLflow container. Issue #118 (M4) tracks removal from production critical path.
Active work (M2): *(all M2 items complete — see README for M3 planning)*
## ADR-0014 endpoint map (as of step 6)
| Endpoint | Purpose |
|----------|---------|
| `GET /api/profile` | Read-through: user globals + prefs (by scope) + consents + contexts |
| `PATCH /api/profile/prefs/:scope` | Upsert user_preferences rows (source='user') |
| `PATCH /api/profile/consents` | Grant / revoke consent keys |
| `PATCH /api/profile/contexts` | Create / activate / deactivate named contexts |
| `GET /api/agents/registry` | Manifest list (proxy to ml/serving; 60 s cache) |
| `POST /api/agents/:agentId/compute` | Internal: run agent compute for (user, agent) |
| `POST /agents/{agent_id}/infer` *(ml/serving)* | Run inference framework → `{inferred_prefs}` |
## Inference framework (ADR-0014 §3)
Lives in `ml/agents/inference/`. `run_inference(manifest, history)` evaluates all `InferredParam` entries in the manifest and returns `{key: value}`. Rules:
- Below `min_history` → emit `cold_start_default`
- `infer()` error → emit `cold_start_default` (never crashes)
- Results written to `user_preferences` with `source='inferred'`; keys with `source='user'` are never overwritten
Per-agent inferred params (all live in `ml/agents/<name>.py`):
| Agent | Inferred params | Notes |
|-------|----------------|-------|
| `time-of-day` | `preferred_hour`, `quiet_start`, `quiet_end`, `peak_hours`, `tz` | Quiet window = longest below-baseline hour run; peak = top-quartile done hours; tz cold-start only (from auth provider) |
| `momentum` | `engagement_trend`, `baseline_completions_per_day`, `stdev` | Baseline = 28d rolling mean done/day; snippet uses z-score language |
| `overdue-task` | `lateness_tolerance_days`, `project_realness` | Tolerance = p50 lateness from TaskCompletion history; realness = project median vs global median |
| `recent-patterns` | `lookback_days`, `weekly_cycle`, `daily_cycle` | Lookback sized to ≥30 done events; cycles use peak-to-mean ratio; snippet hints when strength > 0.5 |
| `focus-area` | *(none)* | No inferred params. Clusters tasks via LLM-enriched embeddings and outputs all areas with expanded descriptions. Recomputes only when task list changes (hash-gated). |
`UserHistory` carries both `events: list[FeedbackEvent]` and `task_completions: list[TaskCompletion]`. `AgentInferRequest` (ml/serving) accepts `task_completions: list[dict]` alongside `feedback_history`.
`min_history` is checked against `len(history.events)` (feedback events), **not** `task_completions`. Agents that infer from completions should set `min_history=0` and guard inside `infer()`.
## What NOT to do
- Don't copy Todoist's data into our DB. Store the OAuth token + computed features/derivatives we need, fetch raw on demand.
- Don't implement auth by hand. Phase 0 uses **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 "random todo" v0 must live behind the same interface the real ML model will implement (`POST /recommend``{tip}`). Swap internals, keep contract.
- 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 (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 build an admin UI before the user-facing black page is polished.
- 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 embed MLflow/OpenWebUI in the admin panel. They are external services; link out to them. The admin shell links to `o.alogins.net/mlflow`, `ai.alogins.net`.
- Don't `nats.publish()` directly from feature code. All publishes go through the in-process `Bus` (`services/api/src/events/bus.ts`); the NATS adapter (`events/nats.ts`) bridges every publish to JetStream when `NATS_URL` is set. This keeps subscribers, the ring-buffer tail used by the admin event viewer, and JetStream all in lockstep.
## Admin app
`apps/admin` rewrites `/api/*``$NEXT_PUBLIC_API_URL/api/*` via `next.config.ts`. So `apiFetch('/admin/stats')` in `apps/admin/src/lib/api.ts` hits the Express backend, not a Next.js route.
Running `tsc --noEmit -p apps/admin/tsconfig.json` always reports `Cannot find module 'next'` errors — expected outside the Next.js build context; use `next build` for real type errors.
## Auth / session pattern
Sessions use an `sid` cookie. Admin routes stack `requireAuth` (sets `req.userId`) then `requireAdmin` (checks `role = 'admin'` in DB). Token-based admin auth: `POST /api/auth/token` with `{ token }` matching `ADMIN_TOKEN` env var sets the `sid` cookie — used by Playwright and CI.

111
README.md
View File

@@ -67,68 +67,85 @@ docs/ architecture, adr, api
---
## AI stack
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
| Tier | Service | Purpose | Where |
|------|---------|---------|-------|
| Inference | **Ollama** | Local LLM + embedding; no data leaves the host | `localhost:11434` |
| Routing | **LiteLLM** | Unified OpenAI-compatible API; model aliases; cloud fallback | `llm.alogins.net` (Agap shared) |
| Testing | **OpenWebUI** | Prompt iteration, model comparison, manual evals | `ai.alogins.net` (Agap shared) |
### Tip generation pipeline (ADR-0013, M2)
```
User signals Pre-compute agents (every 15 min)
(tasks, calendar, ──▶ ml/agents/{overdue-task, momentum, ──▶ agent_outputs
patterns, time) time-of-day, recent-patterns, (per-agent TTL)
focus-area, ...}
Eligibility filter: required consents + │
active context + per-user prefs (ADR-0014) ◀──┘
Orchestrator prompt (`v4-orchestrator`)
= global prefs + active context + snippets
LiteLLM ──▶ Ollama (local) / cloud fallback
Tip shown to user
User reaction (done / snooze / dismiss + dwell)
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 Ollama first:** Tips contain personal context. Local inference means no user data leaves the host for the inference path. Cloud models (Anthropic, OpenAI) are opt-in fallbacks for evaluation and simulation only, gated behind `ANTHROPIC_API_KEY`.
### Models (planned; routes through LiteLLM)
| Alias | Model | Task |
|-------|-------|------|
| `tip-generator` | qwen2.5:1.5b (default) | Generate typed tip candidates from user context; local-first via Ollama |
| `embedder` | nomic-embed-text | Task clustering, semantic similarity for dedup; local via Ollama |
| `judge` | claude-haiku-4-5 (cloud, eval-only) | Offline sim judge; rates tip quality for A/B (requires `ANTHROPIC_API_KEY`) |
All model calls route through **LiteLLM** at `llm.alogins.net` (or `LITELLM_URL` env var) using model aliases. This decouples tip generation from model selection — swap the backend model in LiteLLM config without code changes. See ADR-0008.
---
## Roadmap
### Phase 0 — Walking skeleton *(M0)*
Goal: a single user signs in with Google, connects Todoist, and sees one random Todoist task on a black page. Deletion works.
- [ ] Monorepo scaffold, CI skeleton, docker-compose dev env with `core`/`full` profiles
- [ ] `auth` on Auth.js with Google provider; OIDC-shaped boundary (ADR-0004)
- [ ] `integrations/todoist` OAuth2 flow + encrypted token vault + provider-side revocation
- [ ] `recommender` with `RandomPolicy`; stable `POST /recommend` contract
- [ ] `apps/web` — three pages (sign-in, connect, tip); PWA manifest; offline reaction queue
- [ ] ToS + Privacy Policy + consent capture on first sign-in
- [ ] Account-deletion endpoint: revokes providers, purges credentials, soft-deletes profile
- [ ] Metrics baseline: activation, first-tip reaction rate, dwell, retention (see `docs/architecture/metrics.md`)
- [ ] Deploy modular monolith + `ml/serving` stub to a single VM via docker-compose + Caddy
Issues and open work are tracked in [Gitea milestones](http://localhost:3000/alvis/oO/milestones). Pick an issue, check its milestone (= phase), read the service's `README.md`, ship.
### Phase 1Real signal + in-the-moment delivery *(M1)*
Goal: tips are picked, not drawn from a hat — and they arrive at the right moment on the web.
- [ ] Event bus (NATS JetStream) with protobuf schemas (ADR-0005) + schema-registry CI gate
- [ ] Todoist event-driven sync (emit `signals.task.*`)
- [ ] Feature store skeleton + first five features (hour-of-day, overdue count, task age, priority, project)
- [ ] `ml/serving` FastAPI scorer; `RemotePolicy` wrapper in recommender
- [ ] **Global-then-personalize bandit**: pooled LinUCB over shared features, per-user residual when data allows
- [ ] Shadow-deploy infra: every new policy logs what it *would* have picked; promotion requires reward-parity
- [ ] Feedback loop: reactions → rewards; delayed rewards for tasks completed in Todoist directly
- [ ] **Web Push notifications** (VAPID) so the "magic" shows up without opening the app
- [ ] `notifier` (lite): web-push delivery, quiet-hours honoured, dedupe
- [ ] Apple OAuth added (deferred from M0)
### Phase 0Walking skeleton *(M0)* ✓ shipped
Single user signs in with Google, connects Todoist, sees one random task on a black page. Deletion works. Auth, integrations, recommender stub, PWA, feedback loop, ToS/privacy, metrics baseline.
### Phase 2Multi-source profile & trust *(M2)*
Goal: oO knows more than tasks, and users can see/control what we know.
- [ ] Integrations: Google Calendar, Apple Health (web import), generic webhook ingress
- [ ] Unified `Profile` model (identity, preferences, contexts, consents)
- [ ] Timing signals (Page Visibility, Idle Detection, coarse location) — opt-in, transparent
- [ ] Advice library + mixing policy (todo vs advice vs ambient)
- [ ] User-facing data dashboard: what's stored, what's computed, export, delete-by-category
- [ ] Cost/usage observability
### Phase 1Real signal + in-the-moment delivery *(M1)* ✓ shipped
Tips are picked, not drawn from a hat. Event bus, Todoist sync, task features, ε-greedy policy (v1 + v2), web push, NATS JetStream bridge, shadow-policy registry, offline sim framework, per-user profile features, admin + ML ops console (`apps/admin`).
### Phase 2 — AI tips + multi-source signals *(M2)* ✓ shipped
Tips are AI-generated from user context. Multi-agent pipeline (ADR-0013): five pre-compute agents (`overdue-task`, `momentum`, `time-of-day`, `recent-patterns`, `focus-area`) emit prompt snippets; orchestrator LLM produces one tip. Unified Profile + agent registry + auto-inference framework (ADR-0014). LLM output validation + fallback. LiteLLM gateway, model benchmarking, prompt research, MLflow tracing.
### Phase 3 — Native mobile *(M3)*
- [ ] iOS app (SwiftUI) with APNs push
- [ ] Android app (Compose) with FCM push
- [ ] `notifier` gains APNs + FCM channels, per-device rate limits
- [ ] Migrate auth from Auth.js to dedicated OIDC provider (trigger from ADR-0004)
- [ ] Decide-and-deliver scheduler: per-user "is this tip worth interrupting now?" threshold
iOS (SwiftUI + APNs) and Android (Compose + FCM). `notifier` service gains APNs + FCM channels. Auth migrated from Auth.js to dedicated OIDC provider. Decide-and-deliver scheduler. See [M3 milestone](http://localhost:3000/alvis/oO/milestone/3).
### Phase 4 — MLOps at scale *(M4)*
- [ ] Prefect/Airflow for batch feature materialization + retraining
- [ ] MLflow registry; shadow → A/B → launch pipeline as first-class
- [ ] Online experiments framework: deterministic assignment + bandit policies alongside fixed-split A/B
- [ ] Cross-user collaborative features (opt-in only); cohort slicing; fairness checks
- [ ] Drift monitoring (feature drift, prediction drift, reward drift); model cards per version
Retraining pipeline, feature-to-prompt batch jobs, prompt optimization loop, LLM fine-tuning on reaction signals, modular-monolith import-boundary lint, online experiments framework, drift monitoring. See [M4 milestone](http://localhost:3000/alvis/oO/milestone/4).
### Phase 5 — Production hardening *(M5)*
- [ ] Audit logging, rotation of provider tokens + internal signing keys
- [ ] **k3s** on existing VM, then k8s + HPA once multi-node justified (no cliff)
- [ ] Multi-region failover, Postgres PITR, event-bus mirroring
- [ ] Public integration SDK; sandbox tenancy for third-party connectors
- [ ] Billing + subscription tiers
Audit logging, key rotation, k3s → k8s, multi-region, public integration SDK, billing. See [M5 milestone](http://localhost:3000/alvis/oO/milestone/5).
---
## Contributing
This repo is split into independent modules; most tickets belong to exactly one. Pick an issue, check its milestone (= phase), read the service's `README.md`, ship.
This repo is split into independent modules; most tickets belong to exactly one. Pick an issue from [Gitea](http://localhost:3000/alvis/oO/issues), read the service's `README.md`, ship.
Conventions and per-service guidance live in [`CLAUDE.md`](CLAUDE.md).

56
apps/admin/README.md Normal file
View File

@@ -0,0 +1,56 @@
# apps/admin — oO Admin Console
Next.js 15 app. Deployed at `admin.o.alogins.net` (dev: `http://localhost:3080`).
## Contract
- All routes are admin-only. The Next.js middleware calls `GET /api/user/me` on every request
and checks `role === 'admin'`. First admin is seeded via `ADMIN_SEED_EMAIL` env var at API startup.
- Admin write actions are appended to the `admin_actions` audit log in the DB.
## Authentication
Two ways to sign in:
| Method | How |
|--------|-----|
| Google OAuth | Click "Sign in with Google" on the login page |
| Token | `POST /api/auth/token` with `{ token }` matching `ADMIN_TOKEN` env var; sets `sid` cookie valid for 24 h. Used by Playwright tests and CI automation. |
## Pages
| Route | Description |
|-------|-------------|
| `/` | Overview: DAU/WAU KPI cards, tips served, reaction breakdown, activation funnel |
| `/users` | User list (paginated, searchable) |
| `/users/:id` | User detail: identity, consents, integrations, profile features (completion rate, dismiss rate, dwell, preferred hour, tip volume), tip stats, reward history; revoke-integration + reset-bandit + rebuild-profile actions |
| `/audit` | Admin action audit log with timestamps and descriptions |
| `/events` | Live event stream viewer with filters by subject/user/time; tail of `signals.*` from ring buffer or NATS JetStream |
| `/features` | Feature store browser: features sent to `ml/serving` per scoring call; freshness status; per-feature SLA tracking |
| `/tips` | Served tips explorer: tip content, score, policy, model, feedback reactions; per-user timeline |
| `/reward-analytics` | Reaction distribution + per-policy / per-model / per-prompt-version breakdowns with avg reward; time-series and cohort slicing |
| `/data-quality` | Missing-feature rate heatmap, stale-token rate, daily completeness, per-feature freshness SLA status |
| `/health` | System health rollup: api, ml/serving, SQLite, event-bus, MLflow with 15s auto-refresh |
| `/sql` | Read-only SQL runner against SQLite; saved queries support; sunsets to Superset in M4 |
| `/simulate` | Offline simulation runner: launch `ml/experiments/sim`, track runs, judge selection, policy comparison |
| `/docs` | Admin documentation and ops runbooks inline |
| `/ops` | Operational dashboard (deprecation candidate; pending UX refinement #107) |
## Dev
```bash
pnpm --filter @oo/admin dev # starts on :3080
# also run the API: pnpm --filter @oo/api dev (port 3078)
```
## Extraction criteria
Stays as a Next.js app in the monorepo permanently — it's not a candidate for extraction.
It gets richer (more pages, embedded MLflow/Grafana) but not split.
## Known issues & pending improvements
- `@tremor/react 3.x` declares a peer dep on React 18; the workspace uses React 19.
Works in practice. Will resolve naturally when Tremor ships React 19 support or when
we switch to Tremor v4 (which targets React 18+).
- UX refinements pending (#100102): feedback options consolidation, config page UI migration, settings UI placement

18
apps/admin/next.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import type { NextConfig } from 'next';
import path from 'node:path';
const nextConfig: NextConfig = {
output: 'standalone',
outputFileTracingRoot: path.join(__dirname, '../../'),
basePath: '/admin',
async rewrites() {
return [
{
source: '/api/:path*',
destination: `${process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3078'}/api/:path*`,
},
];
},
};
export default nextConfig;

32
apps/admin/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "@oo/admin",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "next dev -p 3080",
"build": "next build",
"start": "next start -p 3080",
"lint": "next lint",
"type-check": "tsc --noEmit",
"clean": "rm -rf .next"
},
"dependencies": {
"@oo/shared-types": "workspace:*",
"@tremor/react": "^3.18.3",
"@tanstack/react-table": "^8.20.5",
"next": "^15.1.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^2.15.3",
"marked": "^14.1.4"
},
"devDependencies": {
"@types/node": "^22.10.5",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"tailwindcss": "^3.4.17",
"autoprefixer": "^10.4.20",
"postcss": "^8.5.1",
"typescript": "^5.7.3"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,12 @@
import { AdminShell } from '@/components/AdminShell';
import { AuditLog } from '@/components/AuditLog';
export const dynamic = 'force-dynamic';
export default function AuditPage() {
return (
<AdminShell>
<AuditLog />
</AdminShell>
);
}

View File

@@ -0,0 +1,141 @@
'use client';
import { useEffect, useState } from 'react';
import { AdminShell } from '@/components/AdminShell';
import { getDataQuality } from '@/lib/api';
function Pct({ value }: { value: number }) {
const pct = (value * 100).toFixed(1);
const color = value < 0.05 ? 'text-green-400' : value < 0.2 ? 'text-yellow-400' : 'text-red-400';
return <span className={color}>{pct}%</span>;
}
function PctGood({ value }: { value: number }) {
const pct = (value * 100).toFixed(1);
const color = value > 0.95 ? 'text-green-400' : value > 0.8 ? 'text-yellow-400' : 'text-red-400';
return <span className={color}>{pct}%</span>;
}
function formatTtl(sec: number): string {
if (sec < 60) return `${sec}s`;
if (sec < 3600) return `${Math.round(sec / 60)}m`;
if (sec < 86400) return `${Math.round(sec / 3600)}h`;
return `${Math.round(sec / 86400)}d`;
}
export default function DataQualityPage() {
const [data, setData] = useState<Awaited<ReturnType<typeof getDataQuality>> | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
getDataQuality()
.then(setData)
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, []);
return (
<AdminShell>
<div className="space-y-6">
<h1 className="text-xl font-semibold">Data quality</h1>
{error && <p className="text-red-400 text-sm">{error}</p>}
{loading && <p className="text-gray-500 text-sm">Loading</p>}
{data && (
<>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<div className="bg-gray-900 border border-gray-800 rounded p-4">
<div className="text-xs text-gray-500 mb-1">Scoring calls (30d)</div>
<div className="text-2xl font-semibold">{data.scoringCallsLast30d}</div>
</div>
<div className="bg-gray-900 border border-gray-800 rounded p-4">
<div className="text-xs text-gray-500 mb-1">Missing feature rate</div>
<div className="text-2xl font-semibold"><Pct value={data.missingFeatureRate} /></div>
</div>
<div className="bg-gray-900 border border-gray-800 rounded p-4">
<div className="text-xs text-gray-500 mb-1">Integration tokens</div>
<div className="text-2xl font-semibold">{data.totalTokens}</div>
</div>
<div className="bg-gray-900 border border-gray-800 rounded p-4">
<div className="text-xs text-gray-500 mb-1">Stale token rate (&gt;7d)</div>
<div className="text-2xl font-semibold"><Pct value={data.staleTokenRate} /></div>
</div>
</div>
{/* Profile freshness — #81 phase B.4 */}
<div className="space-y-2">
<h2 className="text-sm font-medium text-gray-400">Profile feature freshness</h2>
<p className="text-xs text-gray-600">
Eligible = users with any tip activity in the last 30 days. Stale = stored row past its TTL. Missing = no row computed yet.
</p>
<table className="w-full text-xs">
<thead>
<tr className="border-b border-gray-800 text-gray-500 text-left">
<th className="py-2 pr-4">Feature</th>
<th className="py-2 pr-4">TTL</th>
<th className="py-2 pr-4">Eligible</th>
<th className="py-2 pr-4">Missing</th>
<th className="py-2 pr-4">Stale</th>
<th className="py-2">Coverage</th>
</tr>
</thead>
<tbody>
{data.profileFreshness.map((r) => {
const fresh = r.totalEligible - r.missing - r.stale;
const coverage = r.totalEligible > 0 ? fresh / r.totalEligible : 0;
return (
<tr key={r.feature} className="border-b border-gray-800/50">
<td className="py-1.5 pr-4 font-mono text-gray-400">{r.feature}</td>
<td className="py-1.5 pr-4 text-gray-500 tabular-nums">{formatTtl(r.ttlSec)}</td>
<td className="py-1.5 pr-4 text-gray-300 tabular-nums">{r.totalEligible}</td>
<td className={`py-1.5 pr-4 tabular-nums ${r.missing > 0 ? 'text-orange-400' : 'text-gray-500'}`}>{r.missing}</td>
<td className={`py-1.5 pr-4 tabular-nums ${r.stale > 0 ? 'text-yellow-400' : 'text-gray-500'}`}>{r.stale}</td>
<td className="py-1.5"><PctGood value={coverage} /></td>
</tr>
);
})}
{data.profileFreshness.length === 0 && (
<tr><td colSpan={6} className="py-4 text-center text-gray-600">No features registered</td></tr>
)}
</tbody>
</table>
</div>
<div className="space-y-2">
<h2 className="text-sm font-medium text-gray-400">Daily feature completeness (14d)</h2>
<table className="w-full text-xs">
<thead>
<tr className="border-b border-gray-800 text-gray-500 text-left">
<th className="py-2 pr-4">Date</th>
<th className="py-2 pr-4">Scoring calls</th>
<th className="py-2 pr-4">With features</th>
<th className="py-2 pr-4">Coverage</th>
<th className="py-2">Avg candidates</th>
</tr>
</thead>
<tbody>
{data.dailyQuality.map((row) => {
const coverage = row.total > 0 ? row.withFeatures / row.total : 0;
return (
<tr key={row.date} className="border-b border-gray-800/50">
<td className="py-1.5 pr-4 font-mono text-gray-500">{row.date}</td>
<td className="py-1.5 pr-4 text-gray-300">{row.total}</td>
<td className="py-1.5 pr-4 text-gray-300">{row.withFeatures}</td>
<td className="py-1.5 pr-4"><Pct value={coverage} /></td>
<td className="py-1.5 text-gray-300">{row.avgCandidates?.toFixed(1) ?? '—'}</td>
</tr>
);
})}
{data.dailyQuality.length === 0 && (
<tr><td colSpan={5} className="py-4 text-center text-gray-600">No data yet</td></tr>
)}
</tbody>
</table>
</div>
</>
)}
</div>
</AdminShell>
);
}

View File

@@ -0,0 +1,74 @@
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { AdminShell } from '@/components/AdminShell';
import { getDoc, type DocCategory } from '@/lib/docs';
export const dynamic = 'force-dynamic';
const CATEGORY_LABELS: Record<DocCategory, string> = {
adr: 'ADR',
architecture: 'Architecture',
};
function isDocCategory(value: string): value is DocCategory {
return value === 'adr' || value === 'architecture';
}
export default async function DocDetailPage({
params,
}: {
params: Promise<{ category: string; slug: string }>;
}) {
const { category, slug } = await params;
if (!isDocCategory(category)) notFound();
const doc = await getDoc(category, slug);
if (!doc) notFound();
const categoryLabel = CATEGORY_LABELS[category];
return (
<AdminShell>
<div className="max-w-3xl space-y-6">
{/* Breadcrumb */}
<nav className="flex items-center gap-2 text-xs text-gray-500">
<Link href="/docs" className="hover:text-gray-300 transition-colors">
Docs
</Link>
<span>/</span>
<span className="text-gray-400">{categoryLabel}</span>
<span>/</span>
<span className="text-gray-300 truncate">{doc.slug}</span>
</nav>
{/* Meta bar */}
{(doc.status || doc.date) && (
<div className="flex items-center gap-3 text-xs text-gray-500">
{doc.status && (
<span
className={`px-1.5 py-0.5 rounded font-medium ${
doc.status === 'Accepted'
? 'bg-emerald-900 text-emerald-300'
: doc.status === 'Proposed'
? 'bg-yellow-900 text-yellow-300'
: doc.status === 'Deprecated'
? 'bg-red-900 text-red-400'
: 'bg-gray-800 text-gray-400'
}`}
>
{doc.status}
</span>
)}
{doc.date && <span>{doc.date}</span>}
</div>
)}
{/* Markdown body */}
<article
className="prose-doc"
dangerouslySetInnerHTML={{ __html: doc.html }}
/>
</div>
</AdminShell>
);
}

View File

@@ -0,0 +1,82 @@
import Link from 'next/link';
import { AdminShell } from '@/components/AdminShell';
import { listAllDocs, type DocMeta } from '@/lib/docs';
export const dynamic = 'force-dynamic';
function StatusBadge({ status }: { status?: string }) {
if (!status) return null;
const color =
status === 'Accepted'
? 'bg-emerald-900 text-emerald-300'
: status === 'Proposed'
? 'bg-yellow-900 text-yellow-300'
: status === 'Deprecated'
? 'bg-red-900 text-red-400'
: 'bg-gray-800 text-gray-400';
return (
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${color}`}>{status}</span>
);
}
function DocList({ docs, emptyText }: { docs: DocMeta[]; emptyText: string }) {
if (docs.length === 0) {
return <p className="text-sm text-gray-500">{emptyText}</p>;
}
return (
<ul className="divide-y divide-gray-800">
{docs.map((doc) => (
<li key={doc.href}>
<Link
href={doc.href}
className="flex items-center gap-4 px-4 py-3 hover:bg-gray-800 transition-colors rounded"
>
<span className="flex-1 text-sm text-gray-200 leading-snug">{doc.title}</span>
<StatusBadge status={doc.status} />
{doc.date && (
<span className="text-xs text-gray-600 tabular-nums w-24 text-right">
{doc.date}
</span>
)}
</Link>
</li>
))}
</ul>
);
}
export default async function DocsPage() {
const { adr, architecture } = await listAllDocs();
return (
<AdminShell>
<div className="space-y-8 max-w-3xl">
<div>
<h1 className="text-xl font-semibold">Docs</h1>
<p className="text-sm text-gray-500 mt-0.5">
Architecture Decision Records and design notes from{' '}
<code className="text-xs bg-gray-800 px-1 py-0.5 rounded">docs/</code>
</p>
</div>
<section className="space-y-2">
<h2 className="text-xs text-gray-500 uppercase tracking-widest font-medium px-1">
Architecture Decision Records
</h2>
<div className="rounded-lg border border-gray-800 bg-gray-900 overflow-hidden">
<DocList docs={adr} emptyText="No ADRs found." />
</div>
</section>
<section className="space-y-2">
<h2 className="text-xs text-gray-500 uppercase tracking-widest font-medium px-1">
Architecture notes
</h2>
<div className="rounded-lg border border-gray-800 bg-gray-900 overflow-hidden">
<DocList docs={architecture} emptyText="No architecture docs found." />
</div>
</section>
</div>
</AdminShell>
);
}

View File

@@ -0,0 +1,93 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { AdminShell } from '@/components/AdminShell';
import { getEvents, StoredEvent } from '@/lib/api';
const SUBJECTS = ['', 'signals.tip', 'signals.task', 'signals.tip.served', 'signals.tip.feedback', 'signals.task.synced'];
export default function EventsPage() {
const [events, setEvents] = useState<StoredEvent[]>([]);
const [subject, setSubject] = useState('');
const [userId, setUserId] = useState('');
const [live, setLive] = useState(true);
const [error, setError] = useState('');
const sinceRef = useRef(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const fetchEvents = async (reset = false) => {
try {
const since = reset ? 0 : sinceRef.current;
const res = await getEvents({ subject: subject || undefined, userId: userId || undefined, limit: 100, since });
sinceRef.current = res.nextSince;
setEvents((prev) => {
const next = reset ? res.events : [...prev, ...res.events];
return next.slice(-500); // keep last 500
});
setError('');
} catch (e: any) {
setError(e.message);
}
};
useEffect(() => {
sinceRef.current = 0;
fetchEvents(true);
}, [subject, userId]);
useEffect(() => {
if (live) {
timerRef.current = setInterval(() => fetchEvents(false), 2000);
} else if (timerRef.current) {
clearInterval(timerRef.current);
}
return () => { if (timerRef.current) clearInterval(timerRef.current); };
}, [live, subject, userId]);
return (
<AdminShell>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Event stream</h1>
<div className="flex items-center gap-2">
<label className="flex items-center gap-1.5 text-sm text-gray-400 cursor-pointer">
<input type="checkbox" checked={live} onChange={(e) => setLive(e.target.checked)} className="accent-indigo-500" />
Live
</label>
<button onClick={() => { sinceRef.current = 0; fetchEvents(true); }} className="text-xs text-gray-400 hover:text-white border border-gray-700 rounded px-2 py-1">
Refresh
</button>
</div>
</div>
<div className="flex gap-3">
<select value={subject} onChange={(e) => setSubject(e.target.value)} className="bg-gray-900 border border-gray-700 rounded px-2 py-1 text-sm text-gray-300">
{SUBJECTS.map((s) => <option key={s} value={s}>{s || 'All subjects'}</option>)}
</select>
<input
value={userId}
onChange={(e) => setUserId(e.target.value)}
placeholder="Filter by user ID"
className="bg-gray-900 border border-gray-700 rounded px-2 py-1 text-sm text-gray-300 w-64"
/>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
<div className="font-mono text-xs space-y-1 max-h-[70vh] overflow-y-auto">
{events.length === 0 && (
<p className="text-gray-500 text-sm">No events yet. Waiting</p>
)}
{[...events].reverse().map((e) => (
<div key={e.id} className="flex gap-3 border-b border-gray-800 pb-1">
<span className="text-gray-600 w-12 flex-shrink-0">{e.id}</span>
<span className="text-gray-500 w-24 flex-shrink-0">{e.ts.slice(11, 19)}</span>
<span className="text-indigo-400 w-40 flex-shrink-0">{e.subject}</span>
<span className="text-gray-300 break-all">{JSON.stringify(e.payload)}</span>
</div>
))}
</div>
</div>
</AdminShell>
);
}

View File

@@ -0,0 +1,98 @@
'use client';
import { useEffect, useState } from 'react';
import { AdminShell } from '@/components/AdminShell';
interface FeatureEntry {
ts: string;
features: Record<string, unknown>;
score: number;
tip_id: string;
}
const FEATURE_NAMES = ['hour_of_day', 'is_overdue', 'task_age_days', 'priority'];
export default function FeaturesPage() {
const [userId, setUserId] = useState('');
const [history, setHistory] = useState<FeatureEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const fetch_ = async () => {
if (!userId.trim()) return;
setLoading(true);
setError('');
try {
const res = await fetch(`/api/ml/features/${encodeURIComponent(userId.trim())}`, { credentials: 'include' });
if (!res.ok) throw new Error(res.statusText);
const data = await res.json();
setHistory(data.history ?? []);
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
};
return (
<AdminShell>
<div className="space-y-4">
<h1 className="text-xl font-semibold">Feature store browser</h1>
<p className="text-sm text-gray-500">
Features sent to ml/serving per scoring call for a user. Shows last 100 entries.
</p>
<div className="flex gap-2">
<input
value={userId}
onChange={(e) => setUserId(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && fetch_()}
placeholder="User ID"
className="bg-gray-900 border border-gray-700 rounded px-3 py-1.5 text-sm text-gray-300 w-80"
/>
<button onClick={fetch_} className="bg-indigo-600 hover:bg-indigo-500 text-white rounded px-4 py-1.5 text-sm">
Load
</button>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
{loading && <p className="text-gray-500 text-sm">Loading</p>}
{history.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-xs font-mono">
<thead>
<tr className="border-b border-gray-800 text-gray-500">
<th className="text-left py-2 pr-4">Time</th>
<th className="text-left py-2 pr-4">Score</th>
{FEATURE_NAMES.map((f) => (
<th key={f} className="text-left py-2 pr-4">{f}</th>
))}
<th className="text-left py-2">Tip ID</th>
</tr>
</thead>
<tbody>
{[...history].reverse().map((entry, i) => (
<tr key={i} className="border-b border-gray-800/50">
<td className="py-1.5 pr-4 text-gray-500">{entry.ts.slice(11, 19)}</td>
<td className="py-1.5 pr-4 text-indigo-300">{entry.score.toFixed(4)}</td>
{FEATURE_NAMES.map((f) => (
<td key={f} className="py-1.5 pr-4 text-gray-300">
{String(entry.features[f] ?? '—')}
</td>
))}
<td className="py-1.5 text-gray-500 truncate max-w-xs">{entry.tip_id}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{history.length === 0 && !loading && userId && (
<p className="text-gray-500 text-sm">No scoring history for this user yet.</p>
)}
</div>
</AdminShell>
);
}

View File

@@ -0,0 +1,10 @@
export default function ForbiddenPage() {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center space-y-2">
<h1 className="text-2xl font-semibold">403 Forbidden</h1>
<p className="text-gray-400 text-sm">Your account does not have admin access.</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,123 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
body {
background: var(--background);
color: var(--foreground);
font-family: system-ui, -apple-system, sans-serif;
}
/* -------------------------------------------------------------------------
Markdown prose — used on /docs pages via the .prose-doc class.
Purposely minimal: dark theme, respects the existing gray palette.
------------------------------------------------------------------------- */
.prose-doc {
color: #d1d5db; /* gray-300 */
line-height: 1.7;
font-size: 0.9375rem;
}
.prose-doc h1,
.prose-doc h2,
.prose-doc h3,
.prose-doc h4 {
color: #f3f4f6; /* gray-100 */
font-weight: 600;
margin-top: 1.75em;
margin-bottom: 0.5em;
line-height: 1.3;
}
.prose-doc h1 { font-size: 1.5rem; margin-top: 0; border-bottom: 1px solid #374151; padding-bottom: 0.4em; }
.prose-doc h2 { font-size: 1.2rem; }
.prose-doc h3 { font-size: 1.05rem; }
.prose-doc h4 { font-size: 0.95rem; }
.prose-doc p { margin-top: 0.75em; margin-bottom: 0.75em; }
.prose-doc p:first-child { margin-top: 0; }
.prose-doc a {
color: #818cf8; /* indigo-400 */
text-decoration: underline;
text-underline-offset: 2px;
}
.prose-doc a:hover { color: #a5b4fc; }
.prose-doc code {
background: #1f2937; /* gray-800 */
border-radius: 3px;
padding: 0.15em 0.4em;
font-size: 0.85em;
color: #e5e7eb;
font-family: ui-monospace, 'Cascadia Code', 'Fira Mono', monospace;
}
.prose-doc pre {
background: #111827; /* gray-900 */
border: 1px solid #374151;
border-radius: 6px;
padding: 1em 1.25em;
overflow-x: auto;
margin: 1.25em 0;
}
.prose-doc pre code {
background: none;
padding: 0;
font-size: 0.85em;
color: #d1d5db;
}
.prose-doc ul,
.prose-doc ol {
padding-left: 1.5em;
margin: 0.75em 0;
}
.prose-doc li { margin: 0.25em 0; }
.prose-doc ul { list-style-type: disc; }
.prose-doc ol { list-style-type: decimal; }
.prose-doc blockquote {
border-left: 3px solid #4b5563;
margin: 1em 0;
padding: 0.25em 1em;
color: #9ca3af;
font-style: italic;
}
.prose-doc table {
width: 100%;
border-collapse: collapse;
margin: 1.25em 0;
font-size: 0.875rem;
}
.prose-doc th {
text-align: left;
padding: 0.5em 0.75em;
background: #1f2937;
color: #9ca3af;
font-weight: 500;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid #374151;
}
.prose-doc td {
padding: 0.5em 0.75em;
border-bottom: 1px solid #1f2937;
vertical-align: top;
}
.prose-doc tr:last-child td { border-bottom: none; }
.prose-doc hr {
border: none;
border-top: 1px solid #374151;
margin: 2em 0;
}
.prose-doc strong { color: #f3f4f6; font-weight: 600; }
.prose-doc em { color: #d1d5db; }

View File

@@ -0,0 +1,71 @@
'use client';
import { useEffect, useState } from 'react';
import { AdminShell } from '@/components/AdminShell';
import { getHealth, HealthStatus } from '@/lib/api';
const STATUS_STYLES: Record<string, string> = {
ok: 'bg-green-900 text-green-300 border-green-800',
degraded: 'bg-yellow-900 text-yellow-300 border-yellow-800',
down: 'bg-red-900 text-red-300 border-red-800',
};
export default function HealthPage() {
const [health, setHealth] = useState<HealthStatus | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const refresh = () => {
setLoading(true);
getHealth()
.then(setHealth)
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
};
useEffect(() => {
refresh();
const t = setInterval(refresh, 15_000);
return () => clearInterval(t);
}, []);
return (
<AdminShell>
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Health</h1>
<div className="flex items-center gap-3">
{health && (
<span className={`text-xs px-2 py-1 rounded border ${health.ok ? 'bg-green-900 text-green-300 border-green-800' : 'bg-red-900 text-red-300 border-red-800'}`}>
{health.ok ? 'All systems operational' : 'Degraded'}
</span>
)}
<button onClick={refresh} className="text-xs text-gray-400 hover:text-white border border-gray-700 rounded px-2 py-1">
Refresh
</button>
</div>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
{loading && !health && <p className="text-gray-500 text-sm">Checking</p>}
{health && (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4">
{health.services.map((svc) => (
<div key={svc.name} className={`rounded border p-4 ${STATUS_STYLES[svc.status] ?? STATUS_STYLES.down}`}>
<div className="text-xs font-medium uppercase tracking-wide mb-1">{svc.name}</div>
<div className="text-lg font-semibold capitalize">{svc.status}</div>
{svc.latencyMs > 0 && (
<div className="text-xs opacity-70 mt-1">{svc.latencyMs}ms</div>
)}
</div>
))}
</div>
<p className="text-xs text-gray-600">Last checked: {health.checkedAt} · auto-refreshes every 15s</p>
</>
)}
</div>
</AdminShell>
);
}

View File

@@ -0,0 +1,15 @@
import type { Metadata } from 'next';
import './globals.css';
export const metadata: Metadata = {
title: 'oO Admin',
description: 'oO admin console',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className="dark">
<body className="min-h-screen bg-gray-950 text-gray-100">{children}</body>
</html>
);
}

View File

@@ -0,0 +1,68 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export default function LoginPage() {
const router = useRouter();
const [token, setToken] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
async function handleTokenLogin(e: React.FormEvent) {
e.preventDefault();
setError('');
setLoading(true);
try {
const res = await fetch('/api/auth/token', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError((data as { error?: string }).error ?? 'Invalid token');
return;
}
router.push('/');
} catch {
setError('Request failed');
} finally {
setLoading(false);
}
}
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center space-y-6 w-72">
<h1 className="text-2xl font-semibold">oO Admin</h1>
<a
href="/sign-in"
className="inline-block px-4 py-2 bg-white text-black rounded text-sm font-medium hover:bg-gray-200 transition-colors"
>
Sign in with Google
</a>
<form onSubmit={handleTokenLogin} className="space-y-3">
<input
type="password"
placeholder="Admin token"
value={token}
onChange={(e) => setToken(e.target.value)}
className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded text-sm focus:outline-none focus:border-gray-500"
/>
{error && <p className="text-red-400 text-xs">{error}</p>}
<button
type="submit"
disabled={loading || !token}
className="w-full px-4 py-2 bg-gray-700 text-white rounded text-sm font-medium hover:bg-gray-600 disabled:opacity-40 transition-colors"
>
{loading ? 'Signing in…' : 'Sign in with token'}
</button>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,76 @@
'use client';
import { useState } from 'react';
import { AdminShell } from '@/components/AdminShell';
import { replaySignal } from '@/lib/api';
const VALID_SUBJECTS = ['signals.tip.served', 'signals.tip.feedback', 'signals.task.synced'];
export default function OpsPage() {
const [replaySubject, setReplaySubject] = useState(VALID_SUBJECTS[0]);
const [replayPayload, setReplayPayload] = useState('{\n "userId": "",\n "tipId": ""\n}');
const [msg, setMsg] = useState('');
const [error, setError] = useState('');
const handleReplay = async () => {
let payload: Record<string, unknown>;
try {
payload = JSON.parse(replayPayload);
} catch {
setError('Invalid JSON payload');
return;
}
try {
await replaySignal(replaySubject, payload);
setMsg(`Signal replayed: ${replaySubject}`);
setError('');
} catch (e: any) {
setError(e.message);
}
};
return (
<AdminShell>
<div className="space-y-8">
<div>
<h1 className="text-xl font-semibold">Ops</h1>
<p className="text-sm text-gray-500 mt-1">
Live system controls replay past signals for backfill or debugging, and find
per-user actions (token revoke) on the{' '}
<a href="/users" className="text-indigo-400 hover:underline">Users page</a>.
</p>
</div>
{msg && <p className="text-green-400 text-sm">{msg}</p>}
{error && <p className="text-red-400 text-sm">{error}</p>}
{/* Replay signal */}
<section className="space-y-3">
<h2 className="text-base font-medium text-gray-300">Replay signal</h2>
<p className="text-sm text-gray-500">Re-emit a past event on the in-process bus. Useful for backfill and testing.</p>
<div className="space-y-2">
<select
value={replaySubject}
onChange={(e) => setReplaySubject(e.target.value)}
className="bg-gray-900 border border-gray-700 rounded px-3 py-1.5 text-sm text-gray-300 w-full max-w-sm"
>
{VALID_SUBJECTS.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<textarea
value={replayPayload}
onChange={(e) => setReplayPayload(e.target.value)}
rows={6}
className="w-full max-w-xl bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm font-mono text-gray-300"
/>
<button
onClick={handleReplay}
className="bg-indigo-600 hover:bg-indigo-500 text-white rounded px-4 py-1.5 text-sm"
>
Replay
</button>
</div>
</section>
</div>
</AdminShell>
);
}

View File

@@ -0,0 +1,12 @@
import { AdminShell } from '@/components/AdminShell';
import { OverviewDashboard } from '@/components/OverviewDashboard';
export const dynamic = 'force-dynamic';
export default function OverviewPage() {
return (
<AdminShell>
<OverviewDashboard />
</AdminShell>
);
}

View File

@@ -0,0 +1,215 @@
'use client';
import { useEffect, useState } from 'react';
import { AdminShell } from '@/components/AdminShell';
import { getRewardAnalytics, type QualityBreakdownRow } from '@/lib/api';
const ACTION_COLORS: Record<string, string> = {
done: 'bg-green-500',
helpful: 'bg-teal-500',
snooze: 'bg-yellow-500',
not_helpful: 'bg-orange-500',
dismiss: 'bg-red-500',
};
function QualityBreakdown({ title, dimension, rows, emptyLabel }: {
title: string;
dimension: string;
rows: QualityBreakdownRow[];
emptyLabel: string; // shown when a row's key is null (e.g. bandit-only tips have no llm_model)
}) {
if (rows.length === 0) return null;
const totalServed = rows.reduce((sum, r) => sum + r.served, 0);
return (
<div className="space-y-2">
<h2 className="text-sm font-medium text-gray-400">{title}</h2>
<table className="w-full text-xs">
<thead>
<tr className="border-b border-gray-800 text-gray-500 text-left">
<th className="py-2 pr-4">{dimension}</th>
<th className="py-2 pr-4">served</th>
<th className="py-2 pr-4">reaction rate</th>
<th className="py-2 pr-4">avg reward</th>
{['done', 'helpful', 'snooze', 'not_helpful', 'dismiss'].map((a) => (
<th key={a} className="py-2 pr-4">{a}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((r) => {
const reacted = r.done + r.snooze + r.dismiss + r.helpful + r.not_helpful;
const reactionRate = r.served > 0 ? (reacted / r.served) * 100 : 0;
const avgReward = r.avgRewardMilli == null ? null : r.avgRewardMilli / 1000;
return (
<tr key={r.key ?? '__null__'} className="border-b border-gray-800/50">
<td className="py-2 pr-4 font-medium text-indigo-300">{r.key ?? <span className="text-gray-500 italic">{emptyLabel}</span>}</td>
<td className="py-2 pr-4 text-gray-300">{r.served}</td>
<td className="py-2 pr-4 text-gray-300">{reactionRate.toFixed(1)}%</td>
<td className="py-2 pr-4 text-gray-300">{avgReward == null ? '—' : avgReward.toFixed(2)}</td>
{(['done', 'helpful', 'snooze', 'not_helpful', 'dismiss'] as const).map((a) => (
<td key={a} className="py-2 pr-4 text-gray-300">{r[a]}</td>
))}
</tr>
);
})}
</tbody>
</table>
<p className="text-xs text-gray-600">{totalServed} tips served total.</p>
</div>
);
}
export default function RewardAnalyticsPage() {
const [days, setDays] = useState(30);
const [data, setData] = useState<Awaited<ReturnType<typeof getRewardAnalytics>> | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
setLoading(true);
getRewardAnalytics(days)
.then(setData)
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, [days]);
// Aggregate totals per action across all days
const totals: Record<string, number> = {};
for (const row of data?.daily ?? []) {
totals[row.action] = (totals[row.action] ?? 0) + Number(row.count);
}
const grandTotal = Object.values(totals).reduce((a, b) => a + b, 0);
// Aggregate per policy
const policyMap: Record<string, Record<string, number>> = {};
for (const row of data?.byPolicy ?? []) {
if (!row.policy) continue;
policyMap[row.policy] ??= {};
if (row.action) policyMap[row.policy][row.action] = (policyMap[row.policy][row.action] ?? 0) + Number(row.count);
}
return (
<AdminShell>
<div className="space-y-6">
<div className="flex items-center gap-4">
<h1 className="text-xl font-semibold">Reward analytics</h1>
<select value={days} onChange={(e) => setDays(Number(e.target.value))} className="bg-gray-900 border border-gray-700 rounded px-2 py-1 text-sm text-gray-300">
<option value={7}>Last 7 days</option>
<option value={30}>Last 30 days</option>
<option value={90}>Last 90 days</option>
</select>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
{loading && <p className="text-gray-500 text-sm">Loading</p>}
{/* Reaction breakdown bar */}
{grandTotal > 0 && (
<div className="space-y-2">
<h2 className="text-sm font-medium text-gray-400">Reaction distribution ({grandTotal} total)</h2>
<div className="flex rounded overflow-hidden h-6">
{Object.entries(totals).map(([action, count]) => (
<div
key={action}
title={`${action}: ${count} (${((count / grandTotal) * 100).toFixed(1)}%)`}
className={`${ACTION_COLORS[action] ?? 'bg-gray-500'} transition-all`}
style={{ width: `${(count / grandTotal) * 100}%` }}
/>
))}
</div>
<div className="flex flex-wrap gap-3 text-xs text-gray-400">
{Object.entries(totals).map(([action, count]) => (
<span key={action} className="flex items-center gap-1">
<span className={`inline-block w-2 h-2 rounded-full ${ACTION_COLORS[action] ?? 'bg-gray-500'}`} />
{action}: {count} ({((count / grandTotal) * 100).toFixed(1)}%)
</span>
))}
</div>
</div>
)}
{/* Per-policy table */}
{Object.keys(policyMap).length > 0 && (
<div className="space-y-2">
<h2 className="text-sm font-medium text-gray-400">Per-policy reactions</h2>
<table className="w-full text-xs">
<thead>
<tr className="border-b border-gray-800 text-gray-500 text-left">
<th className="py-2 pr-4">Policy</th>
{['done', 'helpful', 'snooze', 'not_helpful', 'dismiss'].map((a) => (
<th key={a} className="py-2 pr-4">{a}</th>
))}
</tr>
</thead>
<tbody>
{Object.entries(policyMap).map(([policy, actions]) => (
<tr key={policy} className="border-b border-gray-800/50">
<td className="py-2 pr-4 font-medium text-indigo-300">{policy}</td>
{['done', 'helpful', 'snooze', 'not_helpful', 'dismiss'].map((a) => (
<td key={a} className="py-2 pr-4 text-gray-300">{actions[a] ?? 0}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
{/* LLM quality breakdowns (#92) */}
{data && (
<>
<QualityBreakdown
title="Per LLM model"
dimension="llm_model"
rows={data.byModel ?? []}
emptyLabel="(bandit-only)"
/>
<QualityBreakdown
title="Per prompt version"
dimension="prompt_version"
rows={data.byPromptVersion ?? []}
emptyLabel="(unset)"
/>
<QualityBreakdown
title="Per tip kind"
dimension="tip_kind"
rows={data.byKind ?? []}
emptyLabel="(unset)"
/>
</>
)}
{/* Daily table */}
{(data?.daily?.length ?? 0) > 0 && (
<div className="space-y-2">
<h2 className="text-sm font-medium text-gray-400">Daily breakdown</h2>
<div className="overflow-x-auto max-h-80">
<table className="w-full text-xs font-mono">
<thead>
<tr className="border-b border-gray-800 text-gray-500 text-left">
<th className="py-1.5 pr-4">Date</th>
<th className="py-1.5 pr-4">Action</th>
<th className="py-1.5">Count</th>
</tr>
</thead>
<tbody>
{data!.daily.map((row, i) => (
<tr key={i} className="border-b border-gray-800/40">
<td className="py-1 pr-4 text-gray-500">{row.date}</td>
<td className="py-1 pr-4 text-gray-300">{row.action}</td>
<td className="py-1 text-gray-300">{row.count}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{!loading && grandTotal === 0 && (
<p className="text-gray-500 text-sm">No reaction data in this period.</p>
)}
</div>
</AdminShell>
);
}

View File

@@ -0,0 +1,111 @@
'use client';
import { useEffect, useState } from 'react';
import { AdminShell } from '@/components/AdminShell';
import { getSimulationRuns, SimRun } from '@/lib/api';
const mlflowBase = process.env.NEXT_PUBLIC_MLFLOW_URL ?? '/mlflow';
function mlflowRunUrl(runId: string) {
return `${mlflowBase}/#/experiments/1/runs/${runId}`;
}
function StatusBadge({ status }: { status: string }) {
const cls: Record<string, string> = {
running: 'bg-blue-900 text-blue-300 border-blue-800',
done: 'bg-green-900 text-green-300 border-green-800',
failed: 'bg-red-900 text-red-300 border-red-800',
pending: 'bg-gray-800 text-gray-400 border-gray-700',
};
return (
<span className={`text-xs px-2 py-0.5 rounded border ${cls[status] ?? cls.pending}`}>
{status}
</span>
);
}
function SummaryRow({ run }: { run: SimRun }) {
const summary = run.summaryJson ? JSON.parse(run.summaryJson) as Record<string, { total_reward: number; mean_reward: number; n_pulls: number }> : null;
return (
<div className="bg-gray-900 border border-gray-800 rounded p-4 space-y-2">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-gray-500">{run.id}</span>
<StatusBadge status={run.status} />
{run.winner && <span className="text-xs text-indigo-400">winner: {run.winner}</span>}
</div>
<div className="text-xs text-gray-600">
{run.nUsers}u × {run.nRounds}r × {run.tasksPerRound}t/r {run.judgeMode} judge
{' · '}{new Date(run.createdAt).toLocaleString()}
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{run.mlflowRunId && (
<a href={mlflowRunUrl(run.mlflowRunId)} target="_blank" rel="noreferrer"
className="text-xs text-indigo-400 hover:underline">MLflow </a>
)}
</div>
</div>
{summary && (
<div className="grid grid-cols-2 gap-2 pt-1 lg:grid-cols-3">
{Object.entries(summary).map(([policy, s]) => (
<div key={policy} className={`rounded border p-2 text-xs ${policy === run.winner ? 'border-indigo-700 bg-indigo-950' : 'border-gray-800'}`}>
<div className="font-mono font-medium text-gray-300 mb-1">{policy}</div>
<div className="text-gray-500 space-y-0.5">
<div>total <span className="text-gray-300">{s.total_reward.toFixed(2)}</span></div>
<div>mean <span className="text-gray-300">{s.mean_reward.toFixed(4)}</span></div>
<div>pulls <span className="text-gray-300">{s.n_pulls}</span></div>
</div>
</div>
))}
</div>
)}
</div>
);
}
export default function SimulatePage() {
const [runs, setRuns] = useState<SimRun[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const refresh = () =>
getSimulationRuns()
.then((r) => setRuns(r.runs))
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
useEffect(() => {
refresh();
const t = setInterval(refresh, 8_000);
return () => clearInterval(t);
}, []);
return (
<AdminShell>
<div className="space-y-6 max-w-4xl">
<div>
<h1 className="text-xl font-semibold">Simulations</h1>
<p className="text-sm text-gray-500 mt-1">
Offline policy comparisons trigger via the admin API or CLI. Results are logged to{' '}
<a href={mlflowBase} target="_blank" rel="noreferrer" className="text-indigo-400 hover:underline">MLflow </a>.
</p>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
<section className="space-y-3">
<h2 className="text-xs text-gray-500 uppercase tracking-widest font-medium">
Run history
{loading && <span className="text-gray-600 ml-2 normal-case">loading</span>}
</h2>
{runs.length === 0 && !loading && (
<p className="text-gray-600 text-sm">No simulation runs yet.</p>
)}
{runs.map((r) => <SummaryRow key={r.id} run={r} />)}
</section>
</div>
</AdminShell>
);
}

View File

@@ -0,0 +1,152 @@
'use client';
import { useEffect, useState } from 'react';
import { AdminShell } from '@/components/AdminShell';
import { runSql, getSavedQueries, saveQuery, deleteSavedQuery, SavedQuery } from '@/lib/api';
const EXAMPLE_QUERIES = [
'SELECT * FROM users ORDER BY created_at DESC LIMIT 20',
'SELECT action, count(*) as cnt FROM tip_feedback GROUP BY action',
'SELECT policy, count(*) as cnt FROM tip_scores GROUP BY policy',
'SELECT date(served_at) as day, count(*) as tips FROM tip_views GROUP BY day ORDER BY day DESC LIMIT 14',
];
export default function SqlPage() {
const [query, setQuery] = useState(EXAMPLE_QUERIES[0]);
const [rows, setRows] = useState<unknown[]>([]);
const [cols, setCols] = useState<string[]>([]);
const [rowCount, setRowCount] = useState<number | null>(null);
const [running, setRunning] = useState(false);
const [error, setError] = useState('');
const [savedQueries, setSavedQueries] = useState<SavedQuery[]>([]);
const [saveName, setSaveName] = useState('');
useEffect(() => {
getSavedQueries().then((r) => setSavedQueries(r.queries)).catch(() => {});
}, []);
const run = async () => {
if (!query.trim()) return;
setRunning(true);
setError('');
try {
const res = await runSql(query);
const r = res.rows as Record<string, unknown>[];
setRows(r);
setRowCount(res.rowCount);
setCols(r.length > 0 ? Object.keys(r[0] as object) : []);
} catch (e: any) {
setError(e.message);
setRows([]);
setRowCount(null);
} finally {
setRunning(false);
}
};
const handleSave = async () => {
if (!saveName.trim()) return;
await saveQuery(saveName, query);
const res = await getSavedQueries();
setSavedQueries(res.queries);
setSaveName('');
};
const handleDelete = async (id: string) => {
await deleteSavedQuery(id);
setSavedQueries((prev) => prev.filter((q) => q.id !== id));
};
return (
<AdminShell>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">SQL runner</h1>
<span className="text-xs text-gray-500">Read-only · SELECT only · sunsets in M4</span>
</div>
<div className="flex gap-4">
{/* Editor */}
<div className="flex-1 space-y-2">
<textarea
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') run(); }}
rows={6}
spellCheck={false}
className="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm font-mono text-gray-200 focus:outline-none focus:border-indigo-500"
placeholder="SELECT ..."
/>
<div className="flex items-center gap-2">
<button
onClick={run}
disabled={running}
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded px-4 py-1.5 text-sm"
>
{running ? 'Running…' : 'Run (⌘↵)'}
</button>
<input
value={saveName}
onChange={(e) => setSaveName(e.target.value)}
placeholder="Save as…"
className="bg-gray-900 border border-gray-700 rounded px-3 py-1.5 text-sm text-gray-300 w-40"
/>
<button onClick={handleSave} className="text-sm text-gray-400 hover:text-white border border-gray-700 rounded px-3 py-1.5">
Save
</button>
</div>
</div>
{/* Saved / examples */}
<div className="w-56 space-y-2 flex-shrink-0">
<p className="text-xs text-gray-500 font-medium uppercase tracking-wide">Saved queries</p>
{savedQueries.length === 0 && (
<p className="text-xs text-gray-600">None saved yet</p>
)}
{savedQueries.map((q) => (
<div key={q.id} className="flex items-start justify-between gap-1">
<button onClick={() => setQuery(q.sql)} className="text-xs text-indigo-400 hover:text-indigo-300 text-left">{q.name}</button>
<button onClick={() => handleDelete(q.id)} className="text-xs text-gray-600 hover:text-red-400"></button>
</div>
))}
<p className="text-xs text-gray-500 font-medium uppercase tracking-wide pt-2">Examples</p>
{EXAMPLE_QUERIES.map((q, i) => (
<button key={i} onClick={() => setQuery(q)} className="block text-xs text-gray-500 hover:text-gray-300 text-left truncate w-full">
{q.slice(0, 40)}
</button>
))}
</div>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
{rowCount !== null && (
<p className="text-xs text-gray-500">{rowCount} rows returned</p>
)}
{cols.length > 0 && (
<div className="overflow-auto max-h-[50vh] border border-gray-800 rounded">
<table className="w-full text-xs font-mono">
<thead className="sticky top-0 bg-gray-950">
<tr className="border-b border-gray-800">
{cols.map((c) => (
<th key={c} className="text-left py-2 px-3 text-gray-500 font-medium">{c}</th>
))}
</tr>
</thead>
<tbody>
{(rows as Record<string, unknown>[]).map((row, i) => (
<tr key={i} className="border-b border-gray-800/40 hover:bg-gray-900/40">
{cols.map((c) => (
<td key={c} className="py-1.5 px-3 text-gray-300">{String(row[c] ?? '')}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</AdminShell>
);
}

View File

@@ -0,0 +1,97 @@
'use client';
import { useEffect, useState } from 'react';
import { AdminShell } from '@/components/AdminShell';
import { getTips, TipScore } from '@/lib/api';
export default function TipsPage() {
const [tips, setTips] = useState<TipScore[]>([]);
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [userId, setUserId] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const LIMIT = 50;
const fetch_ = async (off = 0) => {
setLoading(true);
try {
const res = await getTips({ limit: LIMIT, offset: off, userId: userId || undefined });
setTips(res.tips);
setTotal(res.total);
setOffset(off);
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
};
useEffect(() => { fetch_(0); }, [userId]);
return (
<AdminShell>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Recommendation log</h1>
<span className="text-sm text-gray-500">{total} total</span>
</div>
<div className="flex gap-2">
<input
value={userId}
onChange={(e) => setUserId(e.target.value)}
placeholder="Filter by user ID"
className="bg-gray-900 border border-gray-700 rounded px-3 py-1.5 text-sm text-gray-300 w-72"
/>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-gray-800 text-gray-500 text-left">
<th className="py-2 pr-3">Served at</th>
<th className="py-2 pr-3">User</th>
<th className="py-2 pr-3">Policy</th>
<th className="py-2 pr-3">Score</th>
<th className="py-2 pr-3">Candidates</th>
<th className="py-2 pr-3">Latency</th>
<th className="py-2">Features</th>
</tr>
</thead>
<tbody>
{tips.map((t) => {
const feats = t.featuresJson ? JSON.parse(t.featuresJson) : null;
return (
<tr key={t.id} className="border-b border-gray-800/50 hover:bg-gray-900/50">
<td className="py-1.5 pr-3 text-gray-500 font-mono">{t.servedAt.slice(0, 19)}</td>
<td className="py-1.5 pr-3 text-gray-400 font-mono truncate max-w-[120px]">{t.userId.slice(0, 8)}</td>
<td className="py-1.5 pr-3">
<span className={`px-1.5 py-0.5 rounded text-xs ${t.policy === 'random' ? 'bg-gray-800 text-gray-400' : 'bg-indigo-900 text-indigo-300'}`}>
{t.policy}
</span>
</td>
<td className="py-1.5 pr-3 font-mono text-gray-300">{t.mlScore != null ? (t.mlScore / 1000).toFixed(3) : '—'}</td>
<td className="py-1.5 pr-3 text-gray-400">{t.candidateCount ?? '—'}</td>
<td className="py-1.5 pr-3 text-gray-400">{t.latencyMs != null ? `${t.latencyMs}ms` : '—'}</td>
<td className="py-1.5 text-gray-500 font-mono text-xs">
{feats ? `p${feats.priority} ${feats.is_overdue ? '⚠' : ''}` : '—'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="flex gap-3 items-center text-sm">
<button onClick={() => fetch_(offset - LIMIT)} disabled={offset === 0 || loading} className="text-gray-400 hover:text-white disabled:opacity-30"> Prev</button>
<span className="text-gray-600">{offset + 1}{Math.min(offset + LIMIT, total)} of {total}</span>
<button onClick={() => fetch_(offset + LIMIT)} disabled={offset + LIMIT >= total || loading} className="text-gray-400 hover:text-white disabled:opacity-30">Next </button>
</div>
</div>
</AdminShell>
);
}

View File

@@ -0,0 +1,17 @@
import { AdminShell } from '@/components/AdminShell';
import { UserDetail } from '@/components/UserDetail';
export const dynamic = 'force-dynamic';
export default async function UserDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return (
<AdminShell>
<UserDetail userId={id} />
</AdminShell>
);
}

View File

@@ -0,0 +1,12 @@
import { AdminShell } from '@/components/AdminShell';
import { UsersTable } from '@/components/UsersTable';
export const dynamic = 'force-dynamic';
export default function UsersPage() {
return (
<AdminShell>
<UsersTable />
</AdminShell>
);
}

View File

@@ -0,0 +1,145 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react';
const mlflowUrl = process.env.NEXT_PUBLIC_MLFLOW_URL ?? '/mlflow';
type NavItem = {
href: string;
label: string;
external?: boolean;
svcName?: string; // key in the health services map
};
type NavSection = {
label?: string;
items: NavItem[];
};
const NAV: NavSection[] = [
{
items: [{ href: '/', label: 'Overview' }],
},
{
label: 'Signals',
items: [
{ href: '/users', label: 'Users' },
{ href: '/events', label: 'Events' },
{ href: '/features', label: 'Features' },
{ href: '/data-quality', label: 'Data quality' },
],
},
{
label: 'Recommender',
items: [
{ href: '/tips', label: 'Tips' },
{ href: '/reward-analytics', label: 'Rewards' },
{ href: '/simulate', label: 'Simulations' },
],
},
{
label: 'Operations',
items: [
{ href: '/health', label: 'Health' },
{ href: '/ops', label: 'Ops' },
{ href: '/sql', label: 'SQL runner' },
{ href: '/audit', label: 'Audit log' },
],
},
{
label: 'Resources',
items: [
{ href: '/docs', label: 'Docs' },
{ href: mlflowUrl, label: 'MLflow ↗', external: true, svcName: 'mlflow' },
],
},
];
const STATUS_DOT: Record<string, string> = {
ok: 'bg-green-500',
degraded: 'bg-yellow-400',
down: 'bg-red-500',
};
export function AdminShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const [svcStatus, setSvcStatus] = useState<Record<string, string>>({});
useEffect(() => {
fetch('/api/admin/health', { credentials: 'include' })
.then((r) => r.json())
.then((data: { services?: { name: string; status: string }[] }) => {
const map: Record<string, string> = {};
for (const s of data.services ?? []) map[s.name] = s.status;
setSvcStatus(map);
})
.catch(() => {});
}, []);
return (
<div className="flex min-h-screen">
{/* Sidebar */}
<aside className="w-52 flex-shrink-0 border-r border-gray-800 bg-gray-950 flex flex-col">
<div className="px-5 py-4 border-b border-gray-800">
<span className="text-lg font-bold tracking-tight">oO</span>
<span className="ml-2 text-xs text-gray-500 font-medium uppercase tracking-widest">
Admin
</span>
</div>
<nav className="flex-1 px-2 py-3 overflow-y-auto">
{NAV.map((section, sectionIdx) => (
<div key={section.label ?? `top-${sectionIdx}`} className={sectionIdx === 0 ? '' : 'pt-3'}>
{section.label && (
<div className="pb-1 px-3">
<span className="text-xs text-gray-600 uppercase tracking-wider font-medium">
{section.label}
</span>
</div>
)}
<div className="space-y-0.5">
{section.items.map((item) => {
const active =
!item.external &&
(item.href === '/' ? pathname === '/' : pathname.startsWith(item.href));
const className = `flex items-center gap-2 px-3 py-2 rounded text-sm transition-colors ${
active
? 'bg-gray-800 text-white font-medium'
: item.external
? 'text-gray-500 hover:text-white hover:bg-gray-900'
: 'text-gray-400 hover:text-white hover:bg-gray-900'
}`;
const dot = item.svcName
? svcStatus[item.svcName]
? <span className={`inline-block w-1.5 h-1.5 rounded-full flex-shrink-0 ${STATUS_DOT[svcStatus[item.svcName]] ?? STATUS_DOT.down}`} />
: <span className="inline-block w-1.5 h-1.5 rounded-full flex-shrink-0 bg-gray-700" />
: null;
return item.external ? (
<a
key={item.href}
href={item.href}
target="_blank"
rel="noreferrer"
className={className}
>
{dot}
{item.label}
</a>
) : (
<Link key={item.href} href={item.href} className={className}>
{item.label}
</Link>
);
})}
</div>
</div>
))}
</nav>
</aside>
{/* Main content */}
<main className="flex-1 overflow-auto p-6">{children}</main>
</div>
);
}

View File

@@ -0,0 +1,112 @@
'use client';
import { useEffect, useState } from 'react';
import { getAuditLog, type AuditAction } from '@/lib/api';
const PAGE_SIZE = 50;
export function AuditLog() {
const [rows, setRows] = useState<AuditAction[]>([]);
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
getAuditLog(PAGE_SIZE, offset)
.then(({ actions, total }) => {
setRows(actions);
setTotal(total);
})
.catch((e) => setError(String(e.message)))
.finally(() => setLoading(false));
}, [offset]);
if (error) return <p className="text-red-400 text-sm">Error: {error}</p>;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Audit log</h1>
<span className="text-sm text-gray-500">{total} entries</span>
</div>
<div className="rounded-lg border border-gray-800 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-900 border-b border-gray-800">
<tr>
{['Time', 'Admin', 'Action', 'Target'].map((h) => (
<th
key={h}
className="text-left px-4 py-2.5 text-xs text-gray-500 font-medium uppercase tracking-wide"
>
{h}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{loading ? (
<tr>
<td colSpan={4} className="px-4 py-6 text-center text-gray-500">
Loading
</td>
</tr>
) : rows.length === 0 ? (
<tr>
<td colSpan={4} className="px-4 py-6 text-center text-gray-500">
No actions logged yet.
</td>
</tr>
) : (
rows.map((a) => (
<tr key={a.id} className="hover:bg-gray-900 transition-colors">
<td className="px-4 py-2.5 text-xs tabular-nums text-gray-400">
{a.createdAt.slice(0, 19).replace('T', ' ')}
</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-300 truncate max-w-[8rem]">
{a.adminId.slice(0, 8)}
</td>
<td className="px-4 py-2.5">
<span className="px-1.5 py-0.5 rounded bg-gray-800 text-xs text-gray-200 font-mono">
{a.action}
</span>
</td>
<td className="px-4 py-2.5 text-xs text-gray-400">
{a.targetType && (
<span className="text-gray-500">{a.targetType}: </span>
)}
<span className="font-mono">{a.targetId?.slice(0, 12) ?? '—'}</span>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{total > PAGE_SIZE && (
<div className="flex items-center gap-3 text-sm">
<button
disabled={offset === 0}
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
className="px-3 py-1.5 rounded border border-gray-700 disabled:opacity-30 hover:border-gray-500 transition-colors"
>
Previous
</button>
<span className="text-gray-500">
{offset + 1}{Math.min(offset + PAGE_SIZE, total)} of {total}
</span>
<button
disabled={offset + PAGE_SIZE >= total}
onClick={() => setOffset(offset + PAGE_SIZE)}
className="px-3 py-1.5 rounded border border-gray-700 disabled:opacity-30 hover:border-gray-500 transition-colors"
>
Next
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,165 @@
'use client';
import { useEffect, useState } from 'react';
import { getStats, type AdminStats } from '@/lib/api';
function KpiCard({
title,
value,
sub,
}: {
title: string;
value: string | number;
sub?: string;
}) {
return (
<div className="rounded-lg border border-gray-800 bg-gray-900 px-5 py-4 space-y-1">
<p className="text-xs text-gray-500 uppercase tracking-widest font-medium">{title}</p>
<p className="text-3xl font-bold tabular-nums">{value}</p>
{sub && <p className="text-xs text-gray-500">{sub}</p>}
</div>
);
}
function ReactionBar({ reactions }: { reactions: Record<string, number> }) {
const total = Object.values(reactions).reduce((a, b) => a + b, 0);
if (total === 0) return <p className="text-sm text-gray-500">No reactions yet.</p>;
const COLORS: Record<string, string> = {
done: 'bg-emerald-500',
snooze: 'bg-yellow-400',
dismiss: 'bg-red-500',
};
return (
<div className="space-y-2">
{Object.entries(reactions).map(([action, count]) => (
<div key={action} className="flex items-center gap-3">
<span className="w-16 text-xs text-gray-400 capitalize">{action}</span>
<div className="flex-1 bg-gray-800 rounded-full h-2">
<div
className={`${COLORS[action] ?? 'bg-gray-500'} h-2 rounded-full transition-all`}
style={{ width: `${((count / total) * 100).toFixed(1)}%` }}
/>
</div>
<span className="w-8 text-right text-xs tabular-nums text-gray-400">{count}</span>
</div>
))}
</div>
);
}
export function OverviewDashboard() {
const [stats, setStats] = useState<AdminStats | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
getStats()
.then(setStats)
.catch((e) => setError(String(e.message)));
}, []);
if (error) {
return <p className="text-red-400 text-sm">Failed to load stats: {error}</p>;
}
const activationPct =
stats && stats.totalUsers > 0
? ((stats.activatedUsers / stats.totalUsers) * 100).toFixed(1)
: null;
return (
<div className="space-y-8">
<div>
<h1 className="text-xl font-semibold">Overview</h1>
<p className="text-sm text-gray-500 mt-0.5">Last 7 days unless noted</p>
</div>
{/* KPI grid */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<KpiCard title="DAU" value={stats?.dau ?? '—'} sub="unique users today" />
<KpiCard title="WAU" value={stats?.wau ?? '—'} sub="unique users last 7 d" />
<KpiCard
title="Tips served"
value={stats?.tipsServedLast7d ?? '—'}
sub="last 7 days"
/>
<KpiCard
title="Activation"
value={activationPct != null ? `${activationPct}%` : '—'}
sub={
stats
? `${stats.activatedUsers} of ${stats.totalUsers} users`
: 'users who saw ≥1 tip'
}
/>
</div>
{/* Reactions */}
<div className="rounded-lg border border-gray-800 bg-gray-900 px-5 py-4 max-w-sm">
<p className="text-xs text-gray-500 uppercase tracking-widest font-medium mb-3">
Reactions last 7 days
</p>
{stats ? (
<ReactionBar reactions={stats.reactionsLast7d} />
) : (
<p className="text-sm text-gray-500">Loading</p>
)}
</div>
{/* Activation funnel */}
<div className="rounded-lg border border-gray-800 bg-gray-900 px-5 py-4 max-w-sm">
<p className="text-xs text-gray-500 uppercase tracking-widest font-medium mb-3">
Activation funnel
</p>
{stats ? (
<div className="space-y-2 text-sm">
<FunnelRow label="Total users" value={stats.totalUsers} max={stats.totalUsers} />
<FunnelRow
label="Saw ≥1 tip"
value={stats.activatedUsers}
max={stats.totalUsers}
/>
<FunnelRow
label="Reacted to tip"
value={Object.values(stats.reactionsLast7d).reduce((a, b) => a + b, 0)}
max={stats.tipsServedLast7d}
dimMax
/>
</div>
) : (
<p className="text-sm text-gray-500">Loading</p>
)}
</div>
</div>
);
}
function FunnelRow({
label,
value,
max,
dimMax,
}: {
label: string;
value: number;
max: number;
dimMax?: boolean;
}) {
const pct = max > 0 ? (value / max) * 100 : 0;
return (
<div className="flex items-center gap-3">
<span className="w-32 text-gray-400 text-xs">{label}</span>
<div className="flex-1 bg-gray-800 rounded-full h-1.5">
<div
className="bg-indigo-500 h-1.5 rounded-full transition-all"
style={{ width: `${pct.toFixed(1)}%` }}
/>
</div>
<span className="w-8 text-right text-xs tabular-nums text-gray-300">{value}</span>
{!dimMax && max > 0 && (
<span className="text-xs text-gray-600 tabular-nums">{pct.toFixed(0)}%</span>
)}
</div>
);
}

View File

@@ -0,0 +1,231 @@
'use client';
import { useEffect, useState } from 'react';
import {
getUserDetail,
revokeIntegration,
resetBandit,
rebuildUserProfile,
type AdminUserDetail,
type ProfileFeatureView,
} from '@/lib/api';
export function UserDetail({ userId }: { userId: string }) {
const [data, setData] = useState<AdminUserDetail | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null); // which action is running
useEffect(() => {
getUserDetail(userId)
.then(setData)
.catch((e) => setError(String(e.message)));
}, [userId]);
async function handleRevoke(provider: string) {
if (!confirm(`Revoke ${provider} for this user?`)) return;
setBusy(`revoke:${provider}`);
try {
await revokeIntegration(userId, provider);
setData((d) =>
d
? { ...d, integrations: d.integrations.filter((i) => i.provider !== provider) }
: d,
);
} catch (e: unknown) {
alert(`Failed: ${(e as Error).message}`);
} finally {
setBusy(null);
}
}
async function handleResetBandit() {
if (!confirm("Reset this user's LinUCB model? Their personalization will start over.")) return;
setBusy('bandit');
try {
await resetBandit(userId);
alert('Bandit reset.');
} catch (e: unknown) {
alert(`Failed: ${(e as Error).message}`);
} finally {
setBusy(null);
}
}
async function handleRebuildProfile() {
setBusy('profile');
try {
const { profile } = await rebuildUserProfile(userId);
setData((d) => (d ? { ...d, profile } : d));
} catch (e: unknown) {
alert(`Failed: ${(e as Error).message}`);
} finally {
setBusy(null);
}
}
if (error) return <p className="text-red-400 text-sm">Error: {error}</p>;
if (!data) return <p className="text-gray-500 text-sm">Loading</p>;
const { user, integrations, tipsServed, lastTipAt, recentFeedback, profile } = data;
return (
<div className="space-y-6 max-w-2xl">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<h1 className="text-xl font-semibold">{user.name ?? user.email}</h1>
<p className="text-sm text-gray-400 mt-0.5">{user.email}</p>
</div>
<div className="flex gap-2">
<button
onClick={handleResetBandit}
disabled={busy === 'bandit'}
className="px-3 py-1.5 text-xs rounded border border-gray-700 hover:border-red-600 hover:text-red-400 transition-colors disabled:opacity-40"
>
{busy === 'bandit' ? 'Resetting…' : 'Reset bandit'}
</button>
</div>
</div>
{/* Identity */}
<Section title="Identity">
<Row label="ID" value={user.id} mono />
<Row label="Role" value={user.role} />
<Row label="Consent" value={user.consentGiven ? `yes (${user.consentAt?.slice(0, 10)})` : 'no'} />
<Row label="Joined" value={user.createdAt.slice(0, 10)} />
{user.deletedAt && <Row label="Deleted" value={user.deletedAt.slice(0, 10)} />}
</Section>
{/* Integrations */}
<Section title="Integrations">
{integrations.length === 0 ? (
<p className="text-sm text-gray-500">No integrations connected.</p>
) : (
integrations.map((i) => (
<div key={i.provider} className="flex items-center justify-between py-1">
<div>
<span className="text-sm capitalize">{i.provider}</span>
<span className="ml-2 text-xs text-gray-500">
connected {i.connectedAt.slice(0, 10)}
</span>
</div>
<button
onClick={() => handleRevoke(i.provider)}
disabled={busy === `revoke:${i.provider}`}
className="text-xs text-red-500 hover:text-red-400 transition-colors disabled:opacity-40"
>
{busy === `revoke:${i.provider}` ? 'Revoking…' : 'Revoke'}
</button>
</div>
))
)}
</Section>
{/* Profile features (#81 phase B) */}
<Section
title="Profile features"
action={
<button
onClick={handleRebuildProfile}
disabled={busy === 'profile'}
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors disabled:opacity-40"
>
{busy === 'profile' ? 'Rebuilding…' : 'Rebuild'}
</button>
}
>
<ProfileTable rows={profile} />
</Section>
{/* Tip stats */}
<Section title="Tip activity">
<Row label="Tips served (all time)" value={String(tipsServed)} />
<Row label="Last tip" value={lastTipAt?.slice(0, 19).replace('T', ' ') ?? '—'} />
</Section>
{/* Feedback history */}
<Section title="Recent feedback">
{recentFeedback.length === 0 ? (
<p className="text-sm text-gray-500">No feedback recorded.</p>
) : (
<div className="space-y-1">
{recentFeedback.map((f) => (
<div key={f.id} className="flex items-center gap-4 text-sm">
<span
className={`w-16 text-xs font-medium ${
f.action === 'done'
? 'text-emerald-400'
: f.action === 'snooze'
? 'text-yellow-400'
: 'text-red-400'
}`}
>
{f.action}
</span>
<span className="text-gray-500 text-xs tabular-nums">
{f.createdAt.slice(0, 19).replace('T', ' ')}
</span>
<span className="text-gray-600 text-xs font-mono truncate">{f.tipId}</span>
</div>
))}
</div>
)}
</Section>
</div>
);
}
function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) {
return (
<div className="rounded-lg border border-gray-800 bg-gray-900 px-5 py-4 space-y-2">
<div className="flex items-center justify-between mb-3">
<p className="text-xs text-gray-500 uppercase tracking-widest font-medium">{title}</p>
{action}
</div>
{children}
</div>
);
}
function ProfileTable({ rows }: { rows: ProfileFeatureView[] }) {
if (rows.length === 0) return <p className="text-sm text-gray-500">No profile features registered.</p>;
return (
<div className="space-y-1">
{rows.map((r) => (
<div key={r.name} className="flex items-baseline gap-3 text-sm">
<span className="w-44 flex-shrink-0 text-gray-500 font-mono text-xs" title={r.description}>
{r.name}
</span>
<span className="text-gray-200 tabular-nums w-24">{formatValue(r)}</span>
<span className="text-xs text-gray-500 tabular-nums">{formatAge(r)}</span>
</div>
))}
</div>
);
}
function formatValue(r: ProfileFeatureView): string {
if (r.value == null) return '—';
if (r.dtype === 'numeric') {
const n = Number(r.value);
return Math.abs(n) < 10 ? n.toFixed(3) : n.toFixed(0);
}
return String(r.value);
}
function formatAge(r: ProfileFeatureView): string {
if (r.ageSec == null) return 'never computed';
const mins = r.ageSec / 60;
const ageLabel = mins < 60 ? `${mins.toFixed(0)}m` : mins < 1440 ? `${(mins / 60).toFixed(1)}h` : `${(mins / 1440).toFixed(1)}d`;
const tag = r.fresh ? 'fresh' : 'stale';
return `${ageLabel} (${tag})`;
}
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex items-baseline gap-3 text-sm">
<span className="w-36 flex-shrink-0 text-gray-500">{label}</span>
<span className={mono ? 'font-mono text-xs text-gray-300' : 'text-gray-200'}>{value}</span>
</div>
);
}

View File

@@ -0,0 +1,137 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { getUsers, type AdminUser } from '@/lib/api';
const PAGE_SIZE = 50;
export function UsersTable() {
const [users, setUsers] = useState<AdminUser[]>([]);
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
getUsers(PAGE_SIZE, offset)
.then(({ users, total }) => {
setUsers(users);
setTotal(total);
})
.catch((e) => setError(String(e.message)))
.finally(() => setLoading(false));
}, [offset]);
if (error) return <p className="text-red-400 text-sm">Error: {error}</p>;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Users</h1>
<span className="text-sm text-gray-500">{total} total</span>
</div>
<div className="rounded-lg border border-gray-800 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-900 border-b border-gray-800">
<tr>
{['ID', 'Email', 'Name', 'Role', 'Consent', 'Joined', 'Status'].map((h) => (
<th
key={h}
className="text-left px-4 py-2.5 text-xs text-gray-500 font-medium uppercase tracking-wide"
>
{h}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{loading ? (
<tr>
<td colSpan={7} className="px-4 py-6 text-center text-gray-500">
Loading
</td>
</tr>
) : users.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-6 text-center text-gray-500">
No users yet.
</td>
</tr>
) : (
users.map((u) => (
<tr
key={u.id}
className="hover:bg-gray-900 transition-colors cursor-pointer"
>
<td className="px-4 py-2.5 text-gray-500 text-xs font-mono tabular-nums">
{u.id.slice(0, 8)}
</td>
<td className="px-4 py-2.5">
<Link href={`/users/${u.id}`} className="hover:underline text-indigo-400">
{u.email}
</Link>
</td>
<td className="px-4 py-2.5 text-gray-300">{u.name ?? '—'}</td>
<td className="px-4 py-2.5">
<span
className={`px-1.5 py-0.5 rounded text-xs font-medium ${
u.role === 'admin'
? 'bg-indigo-900 text-indigo-300'
: 'bg-gray-800 text-gray-400'
}`}
>
{u.role}
</span>
</td>
<td className="px-4 py-2.5">
{u.consentGiven ? (
<span className="text-emerald-400 text-xs">yes</span>
) : (
<span className="text-gray-600 text-xs">no</span>
)}
</td>
<td className="px-4 py-2.5 text-gray-400 text-xs tabular-nums">
{u.createdAt.slice(0, 10)}
</td>
<td className="px-4 py-2.5">
{u.deletedAt ? (
<span className="text-red-500 text-xs">deleted</span>
) : (
<span className="text-emerald-500 text-xs">active</span>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{total > PAGE_SIZE && (
<div className="flex items-center gap-3 text-sm">
<button
disabled={offset === 0}
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
className="px-3 py-1.5 rounded border border-gray-700 disabled:opacity-30 hover:border-gray-500 transition-colors"
>
Previous
</button>
<span className="text-gray-500">
{offset + 1}{Math.min(offset + PAGE_SIZE, total)} of {total}
</span>
<button
disabled={offset + PAGE_SIZE >= total}
onClick={() => setOffset(offset + PAGE_SIZE)}
className="px-3 py-1.5 rounded border border-gray-700 disabled:opacity-30 hover:border-gray-500 transition-colors"
>
Next
</button>
</div>
)}
</div>
);
}

295
apps/admin/src/lib/api.ts Normal file
View File

@@ -0,0 +1,295 @@
const API = '/api';
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API}${path}`, {
credentials: 'include',
...init,
headers: { 'Content-Type': 'application/json', ...init?.headers },
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw Object.assign(new Error(err.error ?? 'API error'), { status: res.status });
}
return res.json() as T;
}
// ── Types ──────────────────────────────────────────────────────────────────
export interface AdminStats {
dau: number;
wau: number;
tipsServedLast7d: number;
reactionsLast7d: Record<string, number>;
totalUsers: number;
activatedUsers: number;
}
export interface AdminUser {
id: string;
email: string;
name: string | null;
image: string | null;
role: string;
consentGiven: boolean;
consentAt: string | null;
createdAt: string;
deletedAt: string | null;
}
export interface ProfileFeatureView {
name: string;
value: number | string | null;
updatedAt: string | null;
ageSec: number | null;
fresh: boolean;
ttlSec: number;
dtype: 'numeric' | 'categorical';
description: string;
}
export interface AdminUserDetail {
user: AdminUser;
integrations: { provider: string; connectedAt: string }[];
tipsServed: number;
lastTipAt: string | null;
recentFeedback: { id: string; action: string; createdAt: string; tipId: string }[];
profile: ProfileFeatureView[];
}
export interface AuditAction {
id: string;
adminId: string;
action: string;
targetType: string | null;
targetId: string | null;
detail: string | null;
createdAt: string;
}
export interface StoredEvent {
id: number;
subject: string;
payload: unknown;
ts: string;
}
export interface TipScore {
id: string;
userId: string;
tipId: string;
policy: string;
mlScore: number | null;
featuresJson: string | null;
candidateCount: number | null;
latencyMs: number | null;
servedAt: string;
}
export interface HealthStatus {
ok: boolean;
checkedAt: string;
services: { name: string; status: string; latencyMs: number }[];
}
export interface SavedQuery {
id: string;
name: string;
sql: string;
createdAt: string;
}
export interface BanditStats {
user_id: string;
pulls: number;
reward_count: number;
cumulative_reward: number;
estimated_mean_reward: number;
theta: number[];
last_updated: string | null;
}
export interface FeatureHistory {
user_id: string;
history: { ts: string; features: Record<string, unknown>; score: number; tip_id: string }[];
}
// ── Fetchers ───────────────────────────────────────────────────────────────
export function getStats() {
return apiFetch<AdminStats>('/admin/stats');
}
export function getUsers(limit = 50, offset = 0) {
return apiFetch<{ users: AdminUser[]; total: number }>(
`/admin/users?limit=${limit}&offset=${offset}`,
);
}
export function getUserDetail(id: string) {
return apiFetch<AdminUserDetail>(`/admin/users/${id}`);
}
export function revokeIntegration(userId: string, provider: string) {
return apiFetch<{ ok: boolean }>(`/admin/users/${userId}/revoke-integration`, {
method: 'POST',
body: JSON.stringify({ provider }),
});
}
export function resetBandit(userId: string) {
return apiFetch<{ ok: boolean }>(`/admin/users/${userId}/reset-bandit`, {
method: 'POST',
});
}
export function rebuildUserProfile(userId: string) {
return apiFetch<{ ok: boolean; profile: ProfileFeatureView[] }>(
`/admin/users/${userId}/profile/rebuild`,
{ method: 'POST' },
);
}
export function getAuditLog(limit = 50, offset = 0) {
return apiFetch<{ actions: AuditAction[]; total: number }>(
`/admin/audit?limit=${limit}&offset=${offset}`,
);
}
export function getEvents(params: { subject?: string; userId?: string; limit?: number; since?: number } = {}) {
const q = new URLSearchParams();
if (params.subject) q.set('subject', params.subject);
if (params.userId) q.set('userId', params.userId);
if (params.limit) q.set('limit', String(params.limit));
if (params.since) q.set('since', String(params.since));
return apiFetch<{ events: StoredEvent[]; nextSince: number }>(`/admin/events?${q}`);
}
export function getTips(params: { limit?: number; offset?: number; userId?: string } = {}) {
const q = new URLSearchParams();
if (params.limit) q.set('limit', String(params.limit));
if (params.offset) q.set('offset', String(params.offset));
if (params.userId) q.set('userId', params.userId);
return apiFetch<{ tips: TipScore[]; total: number }>(`/admin/tips?${q}`);
}
export type QualityBreakdownRow = {
key: string | null;
served: number;
done: number;
snooze: number;
dismiss: number;
helpful: number;
not_helpful: number;
avgRewardMilli: number | null;
};
export function getRewardAnalytics(days = 30) {
return apiFetch<{
daily: { date: string; action: string; count: number }[];
byPolicy: { policy: string; action: string; count: number }[];
byHour: { action: string; count: number; avgHour: number }[];
byModel: QualityBreakdownRow[];
byPromptVersion: QualityBreakdownRow[];
byKind: QualityBreakdownRow[];
}>(`/admin/reward-analytics?days=${days}`);
}
export interface FeatureFreshnessRow {
feature: string;
ttlSec: number;
totalEligible: number;
missing: number;
stale: number;
}
export function getDataQuality() {
return apiFetch<{
scoringCallsLast30d: number;
missingFeatureRate: number;
staleTokenRate: number;
totalTokens: number;
staleTokens: number;
dailyQuality: { date: string; total: number; withFeatures: number; avgCandidates: number }[];
profileFreshness: FeatureFreshnessRow[];
}>('/admin/data-quality');
}
export function getHealth() {
return apiFetch<HealthStatus>('/admin/health');
}
export function replaySignal(subject: string, payload: Record<string, unknown>) {
return apiFetch<{ ok: boolean }>('/admin/replay-signal', {
method: 'POST',
body: JSON.stringify({ subject, payload }),
});
}
export function runSql(query: string) {
return apiFetch<{ rows: unknown[]; rowCount: number }>('/admin/sql', {
method: 'POST',
body: JSON.stringify({ query }),
});
}
export function getSavedQueries() {
return apiFetch<{ queries: SavedQuery[] }>('/admin/saved-queries');
}
export function saveQuery(name: string, querySql: string) {
return apiFetch<{ id: string }>('/admin/saved-queries', {
method: 'POST',
body: JSON.stringify({ name, querySql }),
});
}
export function deleteSavedQuery(id: string) {
return apiFetch<{ ok: boolean }>(`/admin/saved-queries/${id}`, { method: 'DELETE' });
}
// ── Simulations ────────────────────────────────────────────────────────────
export interface SimRun {
id: string;
policyA: string;
policyB: string;
nUsers: number;
nRounds: number;
tasksPerRound: number;
judgeMode: string;
nPolicies: number;
status: 'pending' | 'running' | 'done' | 'failed';
summaryJson: string | null;
winner: string | null;
personaBreakdownJson: string | null;
mlflowRunId: string | null;
createdAt: string;
finishedAt: string | null;
}
export interface SimStartRequest {
nUsers?: number;
nRounds?: number;
tasksPerRound?: number;
judgeMode?: 'rule' | 'llm';
policies?: string[];
}
export function startSimulation(req: SimStartRequest) {
return apiFetch<{ id: string; status: string }>(
'/admin/simulate/start',
{ method: 'POST', body: JSON.stringify(req) },
);
}
export function getSimulationRuns() {
return apiFetch<{ runs: SimRun[] }>('/admin/simulate/runs');
}
export function getSimulationRun(id: string) {
return apiFetch<{ run: SimRun & { isRunning: boolean }; events: unknown[] }>(
`/admin/simulate/${id}`,
);
}

122
apps/admin/src/lib/docs.ts Normal file
View File

@@ -0,0 +1,122 @@
/**
* Server-side utilities for reading project documentation from the monorepo
* `docs/` directory and rendering it as HTML.
*
* All functions are async and must only be called from server components or
* server actions (no 'use client' imports of this module).
*
* Directory layout relative to monorepo root:
* docs/adr/ — Architecture Decision Records (NNN-title.md)
* docs/architecture/ — longer architecture notes (topic.md)
*/
import { readdir, readFile } from 'fs/promises';
import path from 'path';
import { marked } from 'marked';
// In development: process.cwd() = apps/admin/, so ../../docs = monorepo root docs/.
// In Docker standalone: CWD = /app, so ../../docs is wrong. Set DOCS_ROOT in the
// container to the absolute path where docs/ is copied (e.g. /app/docs).
const DOCS_ROOT =
process.env.DOCS_ROOT ?? path.resolve(process.cwd(), '../../docs');
export type DocCategory = 'adr' | 'architecture';
export interface DocMeta {
category: DocCategory;
slug: string; // filename without .md
title: string; // first H1 from the file, fallback = slug
href: string; // /docs/adr/0001-monorepo-polyglot
status?: string; // for ADRs: "Accepted", "Proposed", …
date?: string; // for ADRs: date after em-dash on Status line
}
export interface DocPage extends DocMeta {
html: string;
}
// ---------------------------------------------------------------------------
// internal helpers
// ---------------------------------------------------------------------------
/** Extract the first # heading from markdown. */
function extractTitle(md: string): string {
const m = md.match(/^#\s+(.+)$/m);
return m ? m[1].trim() : '';
}
/** Extract status + date from "## Status\nAccepted — 2026-04-13" pattern. */
function extractStatus(md: string): { status?: string; date?: string } {
const block = md.match(/##\s+Status\s*\n+([^\n#]+)/);
if (!block) return {};
const line = block[1].trim();
// "Accepted — 2026-04-13" or "Proposed"
const parts = line.split(/\s*[–—]\s*/);
return { status: parts[0]?.trim(), date: parts[1]?.trim() };
}
function slugFromFile(filename: string): string {
return filename.replace(/\.md$/, '');
}
// ---------------------------------------------------------------------------
// public API
// ---------------------------------------------------------------------------
/** List all docs in a category, sorted by filename. */
export async function listDocs(category: DocCategory): Promise<DocMeta[]> {
const dir = path.join(DOCS_ROOT, category);
let files: string[];
try {
files = (await readdir(dir)).filter((f) => f.endsWith('.md')).sort();
} catch {
return [];
}
return Promise.all(
files.map(async (file) => {
const slug = slugFromFile(file);
const md = await readFile(path.join(dir, file), 'utf8');
const title = extractTitle(md) || slug;
const { status, date } = extractStatus(md);
return {
category,
slug,
title,
href: `/docs/${category}/${slug}`,
status,
date,
};
}),
);
}
/** List all docs across all categories. */
export async function listAllDocs(): Promise<Record<DocCategory, DocMeta[]>> {
const [adr, architecture] = await Promise.all([listDocs('adr'), listDocs('architecture')]);
return { adr, architecture };
}
/** Read and render a single doc to HTML. */
export async function getDoc(category: DocCategory, slug: string): Promise<DocPage | null> {
const file = path.join(DOCS_ROOT, category, `${slug}.md`);
let md: string;
try {
md = await readFile(file, 'utf8');
} catch {
return null;
}
const title = extractTitle(md) || slug;
const { status, date } = extractStatus(md);
const html = await marked(md, { gfm: true });
return {
category,
slug,
title,
href: `/docs/${category}/${slug}`,
status,
date,
html,
};
}

View File

@@ -0,0 +1,49 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
// Pass through the login page, forbidden page, and API calls
if (pathname.startsWith('/login') || pathname.startsWith('/forbidden') || pathname.startsWith('/api/')) {
return NextResponse.next();
}
const sid = req.cookies.get('sid')?.value;
if (!sid) {
const url = req.nextUrl.clone();
url.pathname = '/login';
return NextResponse.redirect(url);
}
// Verify admin role via API. INTERNAL_API_URL (e.g. http://api:3078) is preferred
// when set — it points to the API service on the internal Docker network, avoiding
// a Caddy round-trip. Falls back to NEXT_PUBLIC_API_URL for dev, or localhost.
const apiBase =
process.env.INTERNAL_API_URL ||
process.env.NEXT_PUBLIC_API_URL ||
'http://localhost:3078';
try {
const profile = await fetch(`${apiBase}/api/user/me`, {
headers: { cookie: `sid=${sid}` },
next: { revalidate: 0 },
});
if (!profile.ok) throw new Error('not ok');
const data = (await profile.json()) as { role?: string };
if (data.role !== 'admin') {
const url = req.nextUrl.clone();
url.pathname = '/forbidden';
return NextResponse.redirect(url);
}
} catch {
const url = req.nextUrl.clone();
url.pathname = '/login';
return NextResponse.redirect(url);
}
return NextResponse.next();
}
export const config = {
matcher: ['/', '/((?!_next/static|_next/image|favicon.ico).*)'],
};

View File

@@ -0,0 +1,12 @@
import type { Config } from 'tailwindcss';
const config: Config = {
content: [
'./src/**/*.{ts,tsx}',
'./node_modules/@tremor/**/*.{js,jsx,ts,tsx}',
],
theme: { extend: {} },
plugins: [],
};
export default config;

23
apps/admin/tsconfig.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,11 @@
import { test, expect } from '@playwright/test';
test('sign-in page loads and shows Google button', async ({ page }) => {
await page.goto('/sign-in');
await expect(page.getByRole('link', { name: /google/i })).toBeVisible();
});
test('unauthenticated root redirects to sign-in', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveURL(/sign-in/);
});

6
apps/web/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

18
apps/web/next.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import type { NextConfig } from 'next';
import path from 'node:path';
const nextConfig: NextConfig = {
output: 'standalone',
outputFileTracingRoot: path.join(__dirname, '../../'),
async rewrites() {
return [
{
source: '/api/:path*',
destination: `${process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3078'}/api/:path*`,
// In production, Caddy routes /api/* directly to the API — this rewrite only fires in dev
},
];
},
};
export default nextConfig;

37
apps/web/package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "@oo/web",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "next dev -p 3079",
"build": "next build",
"start": "next start -p 3079",
"lint": "next lint",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"type-check": "tsc --noEmit",
"clean": "rm -rf .next"
},
"dependencies": {
"@oo/shared-types": "workspace:*",
"next": "^15.1.6",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@playwright/test": "^1.59.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^22.10.5",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.4",
"jsdom": "^29.0.2",
"typescript": "^5.7.3",
"vitest": "^4.1.4"
}
}

View File

@@ -0,0 +1,24 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: 'html',
use: {
baseURL: process.env.BASE_URL ?? 'http://localhost:3079',
trace: 'on-first-retry',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
// Start dev server automatically in CI; locally, run `pnpm dev` first
webServer: process.env.CI
? {
command: 'pnpm build && pnpm start',
url: 'http://localhost:3079',
reuseExistingServer: false,
}
: undefined,
});

BIN
apps/web/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 842 B

View File

@@ -0,0 +1,21 @@
{
"name": "oO",
"short_name": "oO",
"description": "One tip. Right now.",
"start_url": "/tip",
"display": "standalone",
"background_color": "#000000",
"theme_color": "#000000",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

25
apps/web/public/sw.js Normal file
View File

@@ -0,0 +1,25 @@
self.addEventListener('push', (event) => {
const data = event.data?.json() ?? {};
event.waitUntil(
self.registration.showNotification(data.title ?? 'oO', {
body: data.body ?? '',
icon: '/icon-192.png',
badge: '/icon-192.png',
data: { url: data.url ?? '/tip' },
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((list) => {
for (const client of list) {
if (client.url.includes(self.location.origin) && 'focus' in client) {
return client.focus();
}
}
return clients.openWindow(event.notification.data?.url ?? '/tip');
})
);
});

View File

@@ -0,0 +1,169 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { getVapidPublicKey, subscribePush, getOrchestatorPrefs, updateOrchestratorPref } from '@/lib/api';
type PushState = 'idle' | 'subscribed' | 'denied';
export default function ConfigPage() {
const [pushState, setPushState] = useState<PushState>('idle');
const [scienceDestiny, setScienceDestiny] = useState(50);
const [prefSaving, setPrefSaving] = useState(false);
useEffect(() => {
getOrchestatorPrefs().then((prefs) => {
if (typeof prefs.science_destiny === 'number') setScienceDestiny(prefs.science_destiny);
}).catch(() => {});
}, []);
const handleScienceDestinyChange = useCallback(async (value: number) => {
setScienceDestiny(value);
setPrefSaving(true);
try { await updateOrchestratorPref('science_destiny', value); }
finally { setPrefSaving(false); }
}, []);
useEffect(() => {
if (typeof Notification !== 'undefined') {
if (Notification.permission === 'granted') setPushState('subscribed');
else if (Notification.permission === 'denied') setPushState('denied');
}
}, []);
const requestPush = useCallback(async () => {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
const permission = await Notification.requestPermission();
if (permission !== 'granted') { setPushState('denied'); return; }
try {
const reg = await navigator.serviceWorker.register('/sw.js');
const vapidKey = await getVapidPublicKey();
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: vapidKey,
});
await subscribePush(sub.toJSON());
setPushState('subscribed');
} catch { setPushState('denied'); }
}, []);
return (
<main style={{ minHeight: '100vh', padding: '4rem 2rem', maxWidth: '480px', margin: '0 auto' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '3rem' }}>
<a
href="/tip"
style={{ color: 'rgba(255,255,255,0.35)', fontSize: '0.85rem', textDecoration: 'none' }}
>
back
</a>
<h2 style={{ fontSize: '1.5rem', fontWeight: 300, margin: 0, letterSpacing: '-0.02em' }}>
Settings
</h2>
</div>
{/* Notifications */}
<section style={{ marginBottom: '2.5rem' }}>
<h3 style={{ fontSize: '0.75rem', letterSpacing: '0.12em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.35)', marginBottom: '1rem', fontWeight: 400 }}>
Notifications
</h3>
<div style={{
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '0.75rem',
padding: '1.25rem 1.5rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}>
<div>
<div style={{ fontWeight: 400, fontSize: '0.9rem' }}>Push notifications</div>
<div style={{ color: 'rgba(255,255,255,0.35)', fontSize: '0.75rem', marginTop: '0.2rem' }}>
{pushState === 'subscribed' ? 'Enabled' : pushState === 'denied' ? 'Blocked by browser' : 'Get notified when a tip is ready'}
</div>
</div>
{pushState === 'idle' && (
<button
onClick={requestPush}
style={{
background: 'var(--white)',
color: 'var(--black)',
border: 'none',
borderRadius: '0.375rem',
padding: '0.375rem 0.875rem',
fontSize: '0.8rem',
fontWeight: 500,
cursor: 'pointer',
}}
>
Enable
</button>
)}
{pushState === 'subscribed' && (
<span style={{ color: 'rgba(255,255,255,0.35)', fontSize: '0.8rem' }}></span>
)}
</div>
</section>
{/* Tip style */}
<section style={{ marginBottom: '2.5rem' }}>
<h3 style={{ fontSize: '0.75rem', letterSpacing: '0.12em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.35)', marginBottom: '1rem', fontWeight: 400 }}>
Tip style
</h3>
<div style={{
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '0.75rem',
padding: '1.25rem 1.5rem',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '0.875rem' }}>
<span style={{ fontSize: '0.85rem', fontWeight: 500 }}>Science</span>
<span style={{ fontSize: '0.7rem', color: 'rgba(255,255,255,0.25)' }}>
{prefSaving ? 'saving…' : scienceDestiny === 50 ? 'balanced' : scienceDestiny < 50 ? 'data-driven' : 'intuitive'}
</span>
<span style={{ fontSize: '0.85rem', fontWeight: 500 }}>Destiny</span>
</div>
<input
type="range"
min={0}
max={100}
value={scienceDestiny}
onChange={(e) => handleScienceDestinyChange(Number(e.target.value))}
style={{ width: '100%', accentColor: 'var(--white)', cursor: 'pointer' }}
/>
<div style={{ color: 'rgba(255,255,255,0.3)', fontSize: '0.7rem', marginTop: '0.75rem' }}>
{scienceDestiny < 30
? 'Tips lean on patterns and data'
: scienceDestiny > 70
? 'Tips lean on intuition and meaning'
: 'Tips balance logic and intuition'}
</div>
</div>
</section>
{/* Integrations */}
<section>
<h3 style={{ fontSize: '0.75rem', letterSpacing: '0.12em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.35)', marginBottom: '1rem', fontWeight: 400 }}>
Integrations
</h3>
<a
href="/connect"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '0.75rem',
padding: '1.25rem 1.5rem',
textDecoration: 'none',
color: 'var(--white)',
}}
>
<div>
<div style={{ fontWeight: 400, fontSize: '0.9rem' }}>Connected apps</div>
<div style={{ color: 'rgba(255,255,255,0.35)', fontSize: '0.75rem', marginTop: '0.2rem' }}>
Manage Todoist and other sources
</div>
</div>
<span style={{ color: 'rgba(255,255,255,0.35)', fontSize: '0.85rem' }}></span>
</a>
</section>
</main>
);
}

View File

@@ -0,0 +1,238 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { useSearchParams } from 'next/navigation';
import { getIntegrations, disconnectIntegration, deleteAccount, logout } from '@/lib/api';
import type { Integration } from '@oo/shared-types';
import { Suspense } from 'react';
function ConnectPageInner() {
const searchParams = useSearchParams();
const [integrations, setIntegrations] = useState<Integration[]>([]);
const [loading, setLoading] = useState(true);
const [disconnecting, setDisconnecting] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const load = useCallback(async () => {
const { integrations: list } = await getIntegrations();
setIntegrations(list);
setLoading(false);
}, []);
useEffect(() => { load(); }, [load]);
// Show banner if just connected
const justConnected = searchParams.get('connected');
const isConnected = (provider: string) =>
integrations.some((i) => i.provider === provider && i.status === 'connected');
const handleDeleteAccount = async () => {
if (!confirm('Delete your account? This cannot be undone.')) return;
setDeleting(true);
await deleteAccount();
await logout();
window.location.href = '/sign-in';
};
const handleDisconnect = async (provider: string) => {
setDisconnecting(provider);
await disconnectIntegration(provider);
await load();
setDisconnecting(null);
};
if (loading) {
return (
<main style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ color: 'var(--gray)', fontSize: '0.875rem' }}>Loading</div>
</main>
);
}
const todoistConnected = isConnected('todoist');
const googleHealthConnected = isConnected('google-health');
const anyConnected = todoistConnected || googleHealthConnected;
return (
<main style={{ minHeight: '100vh', padding: '4rem 2rem', maxWidth: '480px', margin: '0 auto' }}>
<h2 style={{ fontSize: '1.5rem', fontWeight: 300, marginBottom: '0.5rem', letterSpacing: '-0.02em' }}>
Connect your apps
</h2>
<p style={{ color: 'var(--gray)', fontSize: '0.875rem', marginBottom: '3rem' }}>
oO reads what you need, when you need it.
</p>
{justConnected && (
<div style={{
background: 'rgba(255,255,255,0.06)',
borderRadius: '0.5rem',
padding: '0.75rem 1rem',
marginBottom: '1.5rem',
fontSize: '0.875rem',
color: 'var(--white)',
}}>
{justConnected} connected.
</div>
)}
{/* Todoist card */}
<div style={{
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '0.75rem',
padding: '1.25rem 1.5rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '1rem',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.875rem' }}>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" aria-label="Todoist">
<rect width="24" height="24" rx="6" fill="#DB4035"/>
<path d="M6 8.5L11 13l7-7" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
<div>
<div style={{ fontWeight: 500, fontSize: '0.9rem' }}>Todoist</div>
<div style={{ color: 'var(--gray)', fontSize: '0.75rem', marginTop: '0.1rem' }}>
{todoistConnected ? 'Connected' : 'Tasks & to-dos'}
</div>
</div>
</div>
{todoistConnected ? (
<button
onClick={() => handleDisconnect('todoist')}
disabled={disconnecting === 'todoist'}
style={{
background: 'transparent',
border: '1px solid rgba(255,255,255,0.15)',
color: 'var(--gray)',
borderRadius: '0.375rem',
padding: '0.375rem 0.875rem',
fontSize: '0.8rem',
}}
>
{disconnecting === 'todoist' ? '…' : 'Disconnect'}
</button>
) : (
<a
href="/api/integrations/todoist/connect?redirectTo=/connect"
style={{
background: 'var(--white)',
color: 'var(--black)',
borderRadius: '0.375rem',
padding: '0.375rem 0.875rem',
fontSize: '0.8rem',
fontWeight: 500,
}}
>
Connect
</a>
)}
</div>
{/* Google Health card */}
<div style={{
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '0.75rem',
padding: '1.25rem 1.5rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '1rem',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.875rem' }}>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" aria-label="Google Health">
<rect width="24" height="24" rx="6" fill="#EA4335"/>
<path d="M12 6.5c0-1.1.9-2 2-2s2 .9 2 2-.9 2-2 2-2-.9-2-2z" fill="#fff"/>
<path d="M8 10.5c0-1.1.9-2 2-2s2 .9 2 2-.9 2-2 2-2-.9-2-2z" fill="#fff" opacity=".7"/>
<path d="M12 14.5c0 2.2-1.8 4-4 4s-4-1.8-4-4 1.8-4 4-4 4 1.8 4 4z" fill="#fff" opacity=".4"/>
<path d="M13 13.5c.5-1 1.5-1.7 2.5-1.7 1.7 0 3 1.3 3 3s-1.3 3-3 3c-1 0-1.9-.5-2.5-1.3" stroke="#fff" strokeWidth="1.5" strokeLinecap="round" fill="none"/>
</svg>
<div>
<div style={{ fontWeight: 500, fontSize: '0.9rem' }}>Google Health</div>
<div style={{ color: 'var(--gray)', fontSize: '0.75rem', marginTop: '0.1rem' }}>
{googleHealthConnected ? 'Connected' : 'Steps, sleep & activity'}
</div>
</div>
</div>
{googleHealthConnected ? (
<button
onClick={() => handleDisconnect('google-health')}
disabled={disconnecting === 'google-health'}
style={{
background: 'transparent',
border: '1px solid rgba(255,255,255,0.15)',
color: 'var(--gray)',
borderRadius: '0.375rem',
padding: '0.375rem 0.875rem',
fontSize: '0.8rem',
}}
>
{disconnecting === 'google-health' ? '…' : 'Disconnect'}
</button>
) : (
<a
href="/api/integrations/google-health/connect?redirectTo=/connect"
style={{
background: 'var(--white)',
color: 'var(--black)',
borderRadius: '0.375rem',
padding: '0.375rem 0.875rem',
fontSize: '0.8rem',
fontWeight: 500,
}}
>
Connect
</a>
)}
</div>
{anyConnected && (
<div style={{ marginTop: '3rem' }}>
<a
href="/tip"
style={{
display: 'block',
textAlign: 'center',
background: 'var(--white)',
color: 'var(--black)',
borderRadius: '0.5rem',
padding: '0.875rem',
fontWeight: 500,
fontSize: '0.9rem',
}}
>
See my tip
</a>
</div>
)}
<div style={{ marginTop: '4rem', borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: '2rem' }}>
<button
onClick={handleDeleteAccount}
disabled={deleting}
style={{
background: 'transparent',
border: 'none',
color: 'rgba(255,255,255,0.2)',
fontSize: '0.8rem',
cursor: 'pointer',
padding: 0,
}}
>
{deleting ? 'Deleting…' : 'Delete account'}
</button>
</div>
</main>
);
}
export default function ConnectPage() {
return (
<Suspense>
<ConnectPageInner />
</Suspense>
);
}

View File

@@ -0,0 +1,31 @@
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--black: #000;
--white: #fff;
--gray: #888;
--dim: rgba(255,255,255,0.08);
--font: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', sans-serif;
}
html, body {
height: 100%;
background: var(--black);
color: var(--white);
font-family: var(--font);
-webkit-font-smoothing: antialiased;
}
a {
color: inherit;
text-decoration: none;
}
button {
cursor: pointer;
font-family: inherit;
}

View File

@@ -0,0 +1,21 @@
import type { Metadata, Viewport } from 'next';
import './globals.css';
export const metadata: Metadata = {
title: 'oO',
description: 'One tip. Right now.',
manifest: '/manifest.json',
appleWebApp: { capable: true, statusBarStyle: 'black', title: 'oO' },
};
export const viewport: Viewport = {
themeColor: '#000000',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}

View File

@@ -0,0 +1,41 @@
export default function Privacy() {
return (
<main style={{ maxWidth: '640px', margin: '0 auto', padding: '4rem 2rem', lineHeight: 1.7 }}>
<h1 style={{ fontSize: '1.5rem', fontWeight: 300, marginBottom: '2rem', letterSpacing: '-0.02em' }}>Privacy Policy</h1>
<p style={{ color: 'rgba(255,255,255,0.5)', fontSize: '0.8rem', marginBottom: '2.5rem' }}>Effective: 1 April 2026</p>
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 500, marginBottom: '0.75rem' }}>What we collect</h2>
<ul style={{ color: 'rgba(255,255,255,0.7)', fontSize: '0.9rem', paddingLeft: '1.25rem' }}>
<li style={{ marginBottom: '0.5rem' }}>Your Google account email, name, and profile picture to identify you.</li>
<li style={{ marginBottom: '0.5rem' }}>OAuth tokens for integrations you explicitly connect.</li>
<li style={{ marginBottom: '0.5rem' }}>Your reactions to tips (done / snooze / dismiss) to improve recommendations.</li>
</ul>
</section>
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 500, marginBottom: '0.75rem' }}>What we don't collect</h2>
<p style={{ color: 'rgba(255,255,255,0.7)', fontSize: '0.9rem' }}>
We do not copy your tasks, calendar events, or any third-party app content into our database. Data is fetched on demand and held in memory for at most 30 seconds.
</p>
</section>
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 500, marginBottom: '0.75rem' }}>How we use it</h2>
<p style={{ color: 'rgba(255,255,255,0.7)', fontSize: '0.9rem' }}>
Solely to operate the recommendation engine. We do not sell data, share it with third parties, or use it for advertising.
</p>
</section>
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 500, marginBottom: '0.75rem' }}>Your rights</h2>
<p style={{ color: 'rgba(255,255,255,0.7)', fontSize: '0.9rem' }}>
You can disconnect any integration at any time from the Connect page. You can delete your account, which permanently purges all stored data. Contact the owner for data export requests.
</p>
</section>
<a href="/sign-in" style={{ color: 'rgba(255,255,255,0.35)', fontSize: '0.8rem' }}> Back</a>
</main>
);
}

View File

@@ -0,0 +1,39 @@
export default function Terms() {
return (
<main style={{ maxWidth: '640px', margin: '0 auto', padding: '4rem 2rem', lineHeight: 1.7 }}>
<h1 style={{ fontSize: '1.5rem', fontWeight: 300, marginBottom: '2rem', letterSpacing: '-0.02em' }}>Terms of Service</h1>
<p style={{ color: 'rgba(255,255,255,0.5)', fontSize: '0.8rem', marginBottom: '2.5rem' }}>Effective: 1 April 2026</p>
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 500, marginBottom: '0.75rem' }}>1. The service</h2>
<p style={{ color: 'rgba(255,255,255,0.7)', fontSize: '0.9rem' }}>
oO is a personal recommendation system. It reads signals from apps you connect and surfaces one tip at a time. The service is provided as-is during the prototype phase.
</p>
</section>
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 500, marginBottom: '0.75rem' }}>2. Your data</h2>
<p style={{ color: 'rgba(255,255,255,0.7)', fontSize: '0.9rem' }}>
We store OAuth tokens for integrations you connect. We fetch your tasks on demand we do not copy or cache raw data beyond a 30-second in-memory buffer. You can revoke access or delete your account at any time.
</p>
</section>
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 500, marginBottom: '0.75rem' }}>3. Account deletion</h2>
<p style={{ color: 'rgba(255,255,255,0.7)', fontSize: '0.9rem' }}>
Deleting your account revokes all integration tokens, purges your feedback history, and anonymises your identity record. No data is retained in identifiable form.
</p>
</section>
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 500, marginBottom: '0.75rem' }}>4. Limitations</h2>
<p style={{ color: 'rgba(255,255,255,0.7)', fontSize: '0.9rem' }}>
This is a prototype. We make no uptime guarantees. The service may change or be discontinued with reasonable notice.
</p>
</section>
<a href="/sign-in" style={{ color: 'rgba(255,255,255,0.35)', fontSize: '0.8rem' }}> Back</a>
</main>
);
}

View File

@@ -0,0 +1,6 @@
// Root redirect: send users to /tip (auth guard lives there)
import { redirect } from 'next/navigation';
export default function Home() {
redirect('/tip');
}

View File

@@ -0,0 +1,58 @@
'use client';
// Auth redirect is handled by middleware — no client-side session check needed here.
export default function SignIn() {
return (
<main style={{
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '2rem',
gap: '3rem',
}}>
<div style={{ textAlign: 'center' }}>
<h1 style={{ fontSize: '4rem', fontWeight: 200, letterSpacing: '-0.05em', marginBottom: '0.5rem' }}>oO</h1>
<p style={{ color: 'var(--gray)', fontSize: '1rem', fontWeight: 300 }}>
one tip. right now.
</p>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', width: '100%', maxWidth: '320px' }}>
<a
href="/api/auth/login?redirectTo=/connect"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.75rem',
padding: '0.875rem 1.5rem',
background: 'var(--white)',
color: 'var(--black)',
borderRadius: '0.5rem',
fontSize: '0.9rem',
fontWeight: 500,
letterSpacing: '0.01em',
}}
>
<svg width="18" height="18" viewBox="0 0 18 18" aria-hidden>
<path fill="#4285F4" d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.717v2.258h2.908c1.702-1.567 2.684-3.874 2.684-6.615z"/>
<path fill="#34A853" d="M9 18c2.43 0 4.467-.806 5.956-2.184l-2.908-2.258c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 0 0 9 18z"/>
<path fill="#FBBC05" d="M3.964 10.707A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.707V4.961H.957A8.996 8.996 0 0 0 0 9c0 1.452.348 2.827.957 4.039l3.007-2.332z"/>
<path fill="#EA4335" d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 0 0 .957 4.96l3.007 2.332C4.672 5.163 6.656 3.58 9 3.58z"/>
</svg>
Continue with Google
</a>
</div>
<p style={{ color: 'var(--gray)', fontSize: '0.75rem', textAlign: 'center', maxWidth: '280px', lineHeight: 1.6 }}>
By continuing you agree to our{' '}
<a href="/legal/terms" style={{ textDecoration: 'underline' }}>Terms</a>
{' '}and{' '}
<a href="/legal/privacy" style={{ textDecoration: 'underline' }}>Privacy Policy</a>.
</p>
</main>
);
}

View File

@@ -0,0 +1,361 @@
'use client';
import { useEffect, useState, useRef, useCallback } from 'react';
import { getRecommendation, sendFeedback } from '@/lib/api';
import type { Tip } from '@oo/shared-types';
type State = 'loading' | 'tip' | 'empty' | 'actions' | 'done';
function Fade({ visible, children, style }: {
visible: boolean;
children: React.ReactNode;
style?: React.CSSProperties;
}) {
return (
<div style={{
opacity: visible ? 1 : 0,
transition: visible ? 'opacity 3.5s ease' : 'opacity 0.3s ease',
pointerEvents: visible ? 'auto' : 'none',
...style,
}}>
{children}
</div>
);
}
export default function TipPage() {
const [tip, setTip] = useState<Tip | null>(null);
const [state, setState] = useState<State>('loading');
const [visible, setVisible] = useState(false);
const holdTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [pressed, setPressed] = useState(false);
const [showReasoning, setShowReasoning] = useState(false);
useEffect(() => {
if (state === 'loading' || state === 'done') {
setVisible(false);
} else {
const t = setTimeout(() => setVisible(true), 30);
return () => clearTimeout(t);
}
}, [state]);
const loadTip = useCallback(async (recentTip?: string) => {
setVisible(false);
setState('loading');
try {
const rec = await getRecommendation(recentTip);
if (!rec) {
setState('empty');
return;
}
setTip(rec.tip);
setShowReasoning(false);
setState('tip');
} catch (err: any) {
console.error('[tip] loadTip error', err?.status, err?.message);
setState('empty');
}
}, []);
useEffect(() => { loadTip(); }, [loadTip]);
const react = async (action: 'done' | 'dismiss' | 'snooze') => {
if (!tip) return;
const snoozedContent = action === 'snooze' ? tip.content : undefined;
setVisible(false);
setState('done');
await sendFeedback(tip.id, { action });
setTimeout(() => loadTip(snoozedContent), 700);
};
const onPointerDown = () => {
if (state !== 'tip') return;
setPressed(true);
holdTimer.current = setTimeout(() => {
setState('actions');
setVisible(true);
setPressed(false);
}, 600);
};
const onPointerUp = () => {
setPressed(false);
if (holdTimer.current) {
clearTimeout(holdTimer.current);
holdTimer.current = null;
}
};
return (
<>
<style>{`
@keyframes breathe {
0%, 100% { opacity: 0.3; }
50% { opacity: 1; }
}
`}</style>
<main
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
onPointerLeave={onPointerUp}
style={{
height: '100dvh',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
userSelect: 'none',
WebkitUserSelect: 'none',
cursor: state === 'tip' ? 'default' : 'auto',
position: 'relative',
overflow: 'hidden',
}}
>
{/* Ambient glow */}
<div style={{
position: 'absolute',
inset: 0,
background: 'radial-gradient(ellipse at center, rgba(255,255,255,0.06) 0%, transparent 65%)',
animation: state === 'loading' ? 'breathe 4s ease-in-out infinite' : undefined,
opacity: state === 'loading' ? undefined : pressed ? 0.3 : 0,
transition: state !== 'loading' ? 'opacity 0.4s ease' : undefined,
pointerEvents: 'none',
}} />
{/* Loading label */}
{(state === 'loading' || state === 'done') && (
<p style={{
margin: 0,
color: 'rgba(255,255,255,0.55)',
fontSize: '0.7rem',
letterSpacing: '0.18em',
textTransform: 'uppercase',
animation: 'breathe 4s ease-in-out infinite',
}}>
reading you
</p>
)}
{/* Tip */}
{(state === 'tip' || state === 'actions') && tip && (
<Fade visible={visible && state !== 'actions'} style={{ textAlign: 'center', maxWidth: '420px', padding: '0 2rem' }}>
<p style={{
fontSize: 'clamp(1.25rem, 4vw, 1.75rem)',
fontWeight: 300,
lineHeight: 1.45,
letterSpacing: '-0.01em',
color: 'rgba(255,255,255,1)',
transition: 'opacity 0.2s ease',
opacity: pressed ? 0.5 : 1,
}}>
{tip.content}
</p>
<p style={{
marginTop: '2rem',
color: 'rgba(255,255,255,0.18)',
fontSize: '0.65rem',
letterSpacing: '0.12em',
textTransform: 'uppercase',
}}>
hold to act
</p>
</Fade>
)}
{/* Empty */}
{state === 'empty' && (
<Fade visible={visible} style={{ textAlign: 'center' }}>
<p style={{ fontSize: '1.1rem', fontWeight: 300, color: 'rgba(255,255,255,0.35)' }}>
All clear.
</p>
<button
onClick={() => loadTip()}
style={{
marginTop: '2rem',
background: 'transparent',
border: '1px solid rgba(255,255,255,0.1)',
color: 'rgba(255,255,255,0.35)',
borderRadius: '0.375rem',
padding: '0.5rem 1rem',
fontSize: '0.8rem',
cursor: 'pointer',
}}
>
Check again
</button>
</Fade>
)}
{/* Action sheet */}
{state === 'actions' && (
<>
<div
onClick={() => { setState('tip'); }}
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)' }}
/>
<div style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
background: '#111',
borderRadius: '1rem 1rem 0 0',
padding: '1.5rem 1.5rem 2.5rem',
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
}}>
{tip && (
<p style={{
color: 'rgba(255,255,255,0.35)',
fontSize: '0.8rem',
marginBottom: '0.5rem',
lineHeight: 1.4,
}}>
{tip.content}
</p>
)}
<ActionButton label="Done ✓" onClick={() => react('done')} primary />
<ActionButton label="Snooze" onClick={() => react('snooze')} />
<ActionButton label="Dismiss" onClick={() => react('dismiss')} />
<button
onClick={() => setState('tip')}
style={{
background: 'transparent',
border: 'none',
color: 'rgba(255,255,255,0.25)',
padding: '0.5rem',
fontSize: '0.8rem',
cursor: 'pointer',
marginTop: '0.25rem',
}}
>
Cancel
</button>
</div>
</>
)}
{/* Reasoning overlay */}
{showReasoning && tip?.rationale && (
<div
onClick={(e) => { e.stopPropagation(); setShowReasoning(false); }}
style={{
position: 'fixed',
inset: 0,
display: 'flex',
alignItems: 'flex-end',
justifyContent: 'center',
zIndex: 20,
padding: '0 0 5rem',
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
background: 'rgba(20,20,20,0.96)',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: '0.875rem',
padding: '1.25rem 1.5rem',
maxWidth: '360px',
width: 'calc(100% - 3rem)',
}}
>
<p style={{
margin: 0,
fontSize: '0.7rem',
letterSpacing: '0.1em',
textTransform: 'uppercase',
color: 'rgba(255,255,255,0.3)',
marginBottom: '0.625rem',
}}>
Why this tip
</p>
<p style={{
margin: 0,
fontSize: '0.9rem',
fontWeight: 300,
lineHeight: 1.5,
color: 'rgba(255,255,255,0.75)',
}}>
{tip.rationale}
</p>
</div>
</div>
)}
{/* ? button — bottom left, shows reasoning */}
{(state === 'tip' || state === 'actions') && tip?.rationale && (
<button
onClick={(e) => { e.stopPropagation(); setShowReasoning((v) => !v); }}
aria-label="Why this tip"
style={{
position: 'fixed',
bottom: '1.5rem',
left: '1.5rem',
background: 'transparent',
border: 'none',
color: showReasoning ? 'rgba(255,255,255,0.5)' : 'rgba(255,255,255,0.15)',
fontSize: '0.85rem',
fontWeight: 400,
lineHeight: 1,
padding: '0.5rem',
cursor: 'pointer',
pointerEvents: 'auto',
zIndex: 10,
transition: 'color 0.2s ease',
fontFamily: 'inherit',
}}
>
?
</button>
)}
{/* Settings gear — bottom right */}
<a
href="/config"
onClick={(e) => e.stopPropagation()}
aria-label="Settings"
style={{
position: 'fixed',
bottom: '1.5rem',
right: '1.5rem',
color: 'rgba(255,255,255,0.15)',
fontSize: '1.1rem',
lineHeight: 1,
textDecoration: 'none',
padding: '0.5rem',
pointerEvents: 'auto',
zIndex: 10,
}}
>
</a>
</main>
</>
);
}
function ActionButton({ label, onClick, primary }: { label: string; onClick: () => void; primary?: boolean }) {
return (
<button
onClick={onClick}
style={{
background: primary ? 'var(--white)' : 'rgba(255,255,255,0.06)',
color: primary ? 'var(--black)' : 'var(--white)',
border: 'none',
borderRadius: '0.625rem',
padding: '1rem',
fontSize: '0.95rem',
fontWeight: primary ? 500 : 400,
width: '100%',
textAlign: 'center',
cursor: 'pointer',
}}
>
{label}
</button>
);
}

View File

@@ -0,0 +1,144 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, act, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
// Mock the API module — we test UI behaviour, not network calls
vi.mock('@/lib/api', () => ({
getRecommendation: vi.fn(),
sendFeedback: vi.fn().mockResolvedValue(undefined),
getVapidPublicKey: vi.fn(),
subscribePush: vi.fn(),
}));
import { getRecommendation, sendFeedback } from '@/lib/api';
import TipPage from '@/app/tip/page';
// jsdom doesn't support full anchor navigation — just verify the link exists
const mockGetRec = getRecommendation as ReturnType<typeof vi.fn>;
const mockSendFeedback = sendFeedback as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
});
describe('TipPage — empty / error states', () => {
it('shows "All clear." when no tip is returned', async () => {
mockGetRec.mockResolvedValue(null);
render(<TipPage />);
await waitFor(() => expect(screen.getByText('All clear.')).toBeInTheDocument());
});
it('shows "All clear." when getRecommendation throws', async () => {
mockGetRec.mockRejectedValue(Object.assign(new Error('Network error'), { status: 503 }));
render(<TipPage />);
await waitFor(() => expect(screen.getByText('All clear.')).toBeInTheDocument());
});
it('"Check again" button re-calls getRecommendation', async () => {
mockGetRec.mockResolvedValue(null);
render(<TipPage />);
await waitFor(() => screen.getByText('Check again'));
mockGetRec.mockResolvedValue({
tip: { id: 'todoist:2', content: 'New tip', source: 'todoist', createdAt: '' },
});
fireEvent.click(screen.getByText('Check again'));
await waitFor(() => expect(mockGetRec).toHaveBeenCalledTimes(2));
});
});
describe('TipPage — tip display', () => {
it('renders tip content after loading', async () => {
mockGetRec.mockResolvedValue({
tip: { id: 'todoist:1', content: 'Write the test', source: 'todoist', createdAt: '' },
});
render(<TipPage />);
await waitFor(() => expect(screen.getByText('Write the test')).toBeInTheDocument());
});
it('shows "hold to act" hint when tip is displayed', async () => {
mockGetRec.mockResolvedValue({
tip: { id: 'todoist:3', content: 'Do the thing', source: 'todoist', createdAt: '' },
});
render(<TipPage />);
await waitFor(() => expect(screen.getByText(/hold to act/i)).toBeInTheDocument());
});
it('shows "reading you…" while loading', async () => {
// Never resolves during this assertion
mockGetRec.mockReturnValue(new Promise(() => {}));
render(<TipPage />);
expect(screen.getByText(/reading you/i)).toBeInTheDocument();
});
});
describe('TipPage — action sheet', () => {
// Render with real timers, THEN switch to fake for hold simulation
async function renderTipAndHold(id: string, content: string) {
mockGetRec.mockResolvedValue({ tip: { id, content, source: 'todoist', createdAt: '' } });
render(<TipPage />);
// Wait for tip to appear (real timers — no deadlock)
await screen.findByText(content);
const main = screen.getByRole('main');
// Switch to fake timers now that the component is fully loaded
vi.useFakeTimers();
act(() => { main.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })); });
act(() => { vi.advanceTimersByTime(650); });
vi.useRealTimers();
// Wait for action sheet
await screen.findByText('Done ✓');
return main;
}
it('action sheet appears after a long press (600 ms)', async () => {
await renderTipAndHold('tip:lp', 'Hold me');
expect(screen.getByText('Done ✓')).toBeInTheDocument();
});
it('action sheet does not appear on short press (<600 ms)', async () => {
mockGetRec.mockResolvedValue({ tip: { id: 'tip:sp', content: 'Short press', source: 'todoist', createdAt: '' } });
render(<TipPage />);
await screen.findByText('Short press');
const main = screen.getByRole('main');
vi.useFakeTimers();
act(() => { main.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })); });
act(() => { vi.advanceTimersByTime(200); });
act(() => { main.dispatchEvent(new PointerEvent('pointerup', { bubbles: true })); });
vi.useRealTimers();
expect(screen.queryByText('Done ✓')).not.toBeInTheDocument();
});
it('clicking "Done ✓" calls sendFeedback with action=done', async () => {
await renderTipAndHold('tip:d', 'Do it');
await act(async () => { fireEvent.click(screen.getByText('Done ✓')); });
expect(mockSendFeedback).toHaveBeenCalledWith('tip:d', { action: 'done' });
});
it('clicking "Dismiss" calls sendFeedback with action=dismiss', async () => {
await renderTipAndHold('tip:dis', 'Dismiss me');
await act(async () => { fireEvent.click(screen.getByText('Dismiss')); });
expect(mockSendFeedback).toHaveBeenCalledWith('tip:dis', { action: 'dismiss' });
});
it('action sheet has exactly Done, Snooze, Dismiss — no Helpful/Not helpful', async () => {
await renderTipAndHold('tip:actions', 'Check actions');
expect(screen.getByText('Done ✓')).toBeInTheDocument();
expect(screen.getByText('Snooze')).toBeInTheDocument();
expect(screen.getByText('Dismiss')).toBeInTheDocument();
expect(screen.queryByText('Helpful')).not.toBeInTheDocument();
expect(screen.queryByText('Not helpful')).not.toBeInTheDocument();
});
it('settings gear link is present on tip page', async () => {
mockGetRec.mockResolvedValue({ tip: { id: 'tip:g', content: 'Gear test', source: 'todoist', createdAt: '' } });
render(<TipPage />);
await screen.findByText('Gear test');
const link = screen.getByRole('link', { name: /settings/i });
expect(link).toHaveAttribute('href', '/config');
});
});

98
apps/web/src/lib/api.ts Normal file
View File

@@ -0,0 +1,98 @@
import type { RecommendResponse, TipFeedback, IntegrationsResponse, UserProfile } from '@oo/shared-types';
const API = '/api';
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API}${path}`, {
credentials: 'include',
...init,
headers: {
'Content-Type': 'application/json',
...init?.headers,
},
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw Object.assign(new Error(err.error ?? 'API error'), { status: res.status });
}
if (res.status === 204) return undefined as T;
return res.json() as T;
}
export async function getSession() {
return apiFetch<{ user: { id: string; email: string; name?: string; image?: string } | null }>('/auth/session');
}
export async function getRecommendation(recentTip?: string): Promise<RecommendResponse | null> {
try {
return await apiFetch<RecommendResponse>('/recommend', {
method: 'POST',
body: JSON.stringify(recentTip ? { recent_tip: recentTip } : {}),
});
} catch (e: any) {
if (e.status === 204 || e.status === 422) return null;
throw e;
}
}
export async function sendFeedback(tipId: string, feedback: TipFeedback) {
return apiFetch<{ ok: boolean }>(`/tip/${encodeURIComponent(tipId)}/feedback`, {
method: 'POST',
body: JSON.stringify(feedback),
});
}
export async function getIntegrations(): Promise<IntegrationsResponse> {
return apiFetch<IntegrationsResponse>('/integrations');
}
export async function disconnectIntegration(provider: string) {
return apiFetch<{ ok: boolean }>(`/integrations/${provider}`, { method: 'DELETE' });
}
export async function getProfile(): Promise<UserProfile> {
return apiFetch<UserProfile>('/user/me');
}
export async function giveConsent() {
return apiFetch<{ ok: boolean }>('/user/consent', { method: 'POST' });
}
export async function deleteAccount() {
return apiFetch<{ ok: boolean }>('/user/me', { method: 'DELETE' });
}
export async function logout() {
return apiFetch<{ ok: boolean }>('/auth/logout', { method: 'POST' });
}
export async function getVapidPublicKey(): Promise<string> {
const { key } = await apiFetch<{ key: string }>('/push/vapid-public-key');
return key;
}
export async function subscribePush(subscription: PushSubscriptionJSON) {
return apiFetch<{ ok: boolean }>('/push/subscribe', {
method: 'POST',
body: JSON.stringify(subscription),
});
}
export async function unsubscribePush(endpoint: string) {
return apiFetch<{ ok: boolean }>('/push/subscribe', {
method: 'DELETE',
body: JSON.stringify({ endpoint }),
});
}
export async function getOrchestatorPrefs(): Promise<Record<string, unknown>> {
const data = await apiFetch<{ prefs: Record<string, Record<string, unknown>> }>('/profile');
return data.prefs?.orchestrator ?? {};
}
export async function updateOrchestratorPref(key: string, value: unknown) {
return apiFetch<{ ok: boolean }>('/profile/prefs/orchestrator', {
method: 'PATCH',
body: JSON.stringify({ [key]: value }),
});
}

View File

@@ -0,0 +1,34 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const PUBLIC = ['/sign-in', '/legal'];
export function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
const hasCookie = req.cookies.has('sid');
// Already on a public page with no session — allow through
if (!hasCookie && PUBLIC.some((p) => pathname.startsWith(p))) {
return NextResponse.next();
}
// No session — redirect to sign-in
if (!hasCookie) {
const url = req.nextUrl.clone();
url.pathname = '/sign-in';
return NextResponse.redirect(url);
}
// Has session but hitting sign-in — send to tip
if (hasCookie && pathname.startsWith('/sign-in')) {
const url = req.nextUrl.clone();
url.pathname = '/tip';
return NextResponse.redirect(url);
}
return NextResponse.next();
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico|icon-.*\\.png|manifest\\.json).*)'],
};

View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom';

23
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

File diff suppressed because one or more lines are too long

23
apps/web/vitest.config.ts Normal file
View File

@@ -0,0 +1,23 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
exclude: ['e2e/**', 'node_modules/**'],
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
include: ['src/**'],
},
},
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
});

View File

@@ -0,0 +1,61 @@
# ADR-0006: Admin console framework — Next.js 15 + Tremor + shadcn/ui + embed specialist tools
## Status
Accepted — 2026-04-15
## Context
M1 ships a bandit-driven recommender, an event bus, and a live feedback loop. Without a cockpit to observe these systems, every model change ships blind. An admin console is needed to:
1. **Observe** — DAU/WAU, tip outcomes, reaction rates, LinUCB arm stats, feature distributions
2. **Inspect** — per-user identity, consents, integrations, reward history
3. **Act** — revoke tokens, replay signals, reset a per-user bandit, promote a policy
4. **Audit** — every operator action is logged
The team is two people. The stack is TypeScript/React/Tailwind. Any framework that forks the stack creates a context-switch tax and a second deployment surface.
## Decision
### App shell — `apps/admin`, Next.js 15, App Router
Same stack as `apps/web`. Reuses `packages/shared-types`, the Auth.js session cookie, and the API rewrite convention. Deployed at `admin.o.alogins.net` behind Caddy, port 3080 in dev.
### UI libraries
| Layer | Library | Reason |
|-------|---------|--------|
| Charts / KPI | **Tremor** | Analytics-first React + Tailwind components (KPI cards, time-series, bar lists). Designed for dashboards, not bolted on. |
| CRUD primitives | **shadcn/ui** | Copy-paste Radix components; forms, dialogs, command palette. No version lock-in — code lives in-repo. |
| Heavy grids | **TanStack Table v8** | Sortable / paginated / virtualized tables for events, users, tips. |
| Extra charts | **Recharts** | Fallback where Tremor falls short (histograms, distributions). |
### Link out, don't embed
Specialized MLOps tooling runs as **separate external services** with their own auth, linked from the admin shell — not embedded or reimplemented:
- **MLflow** → `https://o.alogins.net/mlflow` — experiment tracking, model registry, artifact browser; own basic-auth for now; see M3 for SSO consolidation
- **Grafana panels** → `/admin/infra` (iframed panels) — infra metrics
- **Marimo notebooks** → launch-out link from admin
The admin shell links to these services; clicking them opens a new tab.
### AuthZ
`profile.role` column on the `users` table (values: `'user'` | `'admin'`). First admin seeded via `ADMIN_SEED_EMAIL` env var at startup. Admin-only gate in Next.js middleware checks the session and the role returned by `GET /api/user/me`. Every write action through the admin API is appended to an `admin_actions` audit log.
### Rejected alternatives
| Option | Rejected because |
|--------|-----------------|
| Retool / AppSmith | Admin logic leaves the repo; weak analytics affordances |
| Streamlit / Gradio | Python-first; splits the frontend stack; thin RBAC |
| React-admin / Refine.dev | Strong CRUD scaffolding, analytics views feel bolted on |
| Superset / Metabase as the admin surface | Excellent BI, poor operational writes; plan: adopt Superset in M4 for BI alongside batch pipelines |
## Consequences
- One more Next.js app in the monorepo. Build/dev added to Turborepo.
- Tremor + shadcn/ui are added as dependencies. shadcn components are copied into `apps/admin/src/components/ui/` — no runtime version coupling.
- MLflow (`o.alogins.net/mlflow*` → port 5000) is a path-based route in the existing `o.alogins.net` Caddy block, started via `docker compose --profile mlops up`.
- MLflow manages its own auth (built-in basic-auth). M3 will consolidate behind the shared OIDC provider.
- The `NEXT_PUBLIC_MLFLOW_URL` build arg in `Dockerfile.admin` defaults to the production URL; override for dev builds.
- `admin_actions` audit log grows unboundedly — needs a retention policy before M4.

View File

@@ -0,0 +1,47 @@
# ADR-0007: ε-greedy v1 as the active recommendation policy
## Status
Superseded by ADR-0013 — 2026-05-01
## Context
M1 shipped LinUCB (d=5, α=1.0) as the first learned policy via `ml/serving /score`. After the M1 admin console landed, we ran an offline simulation to compare LinUCB against a new ε-greedy ridge-regression policy before deciding which to keep live.
**ε-greedy v1 design:**
- Ridge regression estimator, θ updated online (equivalent to LinUCB without the UCB bonus).
- d=7 feature vector: base 5 (is\_overdue, task\_age\_days, priority, hour\_of\_day, bias) + sin/cos encoding of day\_of\_week.
- ε=0.10 random exploration; 90% argmax(θ·x).
- Separate per-user state files (`{user}_egreedy.json`), independent of LinUCB state.
**Simulation setup (rule judge, seed=42):**
- 5 synthetic personas × 20 rounds × 8 tasks/round = 100 judgments per policy.
- Reward inferred from dwell-time (same `inferReward` logic as production): dismiss=1, snooze=+0.1, done<15 s=0.3, done 15 s2 min=+1.0, done 210 min=+0.6, done>10 min=+0.3.
- Both policies started from blank state (no warm-up).
**Results:**
| Policy | Total reward | Mean reward/pull | Pulls |
|--------|-------------|-----------------|-------|
| egreedy-v1 | 54.80 | 0.548 | 100 |
| linucb-v1 | 60.60 | 0.606 | 100 |
Winner: **egreedy-v1** (+10.7% mean reward).
Both policies produce negative mean rewards under the dwell-time model — expected: most simulated users don't act in the 15s2min magic zone on cold models. The gap widens from round 8 onward, consistent with LinUCB's UCB exploration bonus over-favouring high-uncertainty dimensions (is\_overdue, task\_age\_days) regardless of persona fit.
## Decision
Promote **egreedy-v1** to the active serving policy:
- `POST /recommend` calls `/score/egreedy` instead of `/score`.
- Feedback loop calls `/reward/egreedy`.
- LinUCB (`/score`, `/reward`) remains deployed in `ml/serving` as a shadow-eligible fallback.
The simulation does not replace online A/B testing; it is evidence that egreedy-v1 is worth promoting before collecting real-user signal. A future milestone will run live A/B once we have enough daily active users for statistical power.
## Consequences
- Recommendation calls and reward updates now hit the egreedy endpoints only.
- LinUCB state is preserved on disk; re-activation is a one-line change.
- `tip_scores.policy` will log `egreedy-v1` for new serves; historical rows remain `linucb-v1` or `random`.
- The dwell-time reward model (`inferReward`) is now the canonical feedback signal for both online updates and simulation. Explicit helpful/not\_helpful signals are removed.
- Next evaluation gate: once ≥500 real tips served with egreedy-v1, compare reward distribution to the LinUCB historical baseline in the admin Reward Analytics page before deciding on next policy iteration.

View File

@@ -0,0 +1,41 @@
# ADR-0008 — LiteLLM as AI gateway; model aliases decouple code from model names
**Status:** Accepted
**Date:** 2026-04-17
**Milestone:** M2
## Context
M2 requires LLM inference for tip generation (`ml/serving POST /generate`). We need a way to:
- Run locally during development without cloud API keys.
- Switch models (qwen2.5 → llama3.2, or cloud fallback) without touching application code.
- Share the LLM infrastructure with other local services on Agap.
## Decision
Route all LLM calls through **LiteLLM** (`http://localhost:4000` in dev, `llm.alogins.net` in prod) backed by **Ollama** for local inference.
Application code references model aliases — never bare model names:
| Alias | Default model | Used by |
|-------|--------------|---------|
| `tip-generator` | `qwen2.5:7b` | `ml/serving POST /generate` |
| `embedder` | `nomic-embed-text` | task clustering, dedup (M4) |
| `judge` | `claude-haiku-4-5` | offline simulation only |
Config is in `infra/litellm/litellm_config.yaml`. Swapping a model = one YAML change, zero code change.
`ml/serving` reads `LITELLM_URL` and `LITELLM_MASTER_KEY` from env. TypeScript services never call LLM endpoints directly — all inference flows through `ml/serving`.
## Consequences
- **Local dev:** `docker compose --profile ai up` starts Ollama + LiteLLM. First run pulls models (~4 GB for qwen2.5:7b).
- **Prod:** both are shared Agap services; set `LITELLM_URL=http://llm.alogins.net` in `.env.local`.
- **Offline sim:** `judge` alias points at `claude-haiku-4-5` (cloud) — requires `ANTHROPIC_API_KEY`; simulation is opt-in.
- **Vendor lock-in:** none at the code level. LiteLLM translates the OpenAI-compatible API to whatever backend.
- **Observability:** LiteLLM logs all requests; `tip_scores.llm_model` + `tip_scores.prompt_version` track which model + prompt generated each served tip.
## Alternatives considered
- **Call Ollama directly:** cheaper in latency, but ties code to Ollama's API format and makes cloud fallback a code change.
- **Call Anthropic directly from TS:** violates the rule that TS services never hold model names (CLAUDE.md prime directive 3).

View File

@@ -0,0 +1,53 @@
# ADR-0009 — Signal normalization strategy
**Status:** Accepted
**Date:** 2026-04-18
**Issue:** #78
## Context
The recommender was hard-wired to Todoist: task fetch, cache, and feature extraction lived inside `recommender.ts` with no abstraction boundary. Adding Google Calendar, Apple Health, or manual input sources would have required forking the pipeline per source.
## Decision
Introduce two abstractions in `packages/shared-types`:
```typescript
interface Signal {
id: string;
source: string;
kind: 'task' | 'event' | 'habit' | 'insight';
content: string;
metadata: Record<string, unknown>; // raw source fields, not used by bandit
features: Record<string, number | boolean>; // bandit-ready features
timestamp: string;
}
interface SignalSource {
readonly id: string;
fetchSignals(userId: string): Promise<Signal[]>;
act?(userId: string, signalId: string, action: string): Promise<void>;
}
```
`SignalAggregator` calls all registered sources in parallel, isolating failures per source.
`TodoistSignalSource` moves all Todoist-specific logic (fetch, 401 handling, cache, bus events) out of the recommender route.
The recommender maps `Signal[]``TipCandidate[]` via a thin adapter and registers action dispatch through the aggregator.
## Consequences
**Good:**
- Adding a new signal source is a single `aggregator.register(new MySource())` call.
- `TipCandidate.features` is now `Record<string, number | boolean>`, matching `Signal.features`. Sources control their own feature names; the bandit serialises them as-is.
- Source failures are isolated: a broken Google Calendar connector does not prevent Todoist signals from reaching the bandit.
- `act()` on the aggregator routes actions back to the owning source (e.g. marking a Todoist task done), replacing ad-hoc source-specific logic in the feedback handler.
**Trade-offs:**
- Feature names are no longer compile-time typed. Convention: sources document their feature keys in their class JSDoc. The Python bandit already treated features as an opaque dict.
- Each source is responsible for its own token lookup (DB access injected via module-level `db`). This is acceptable in a modular monolith; extract to a token vault interface if sources move to separate processes.
## Alternatives considered
**Typed feature schema per source kind** — rejected: would require union types across all sources and a discriminant on every consumer. The bandit doesn't benefit from TypeScript types at runtime.
**Aggregator holds tokens, passes to sources** — rejected: leaks auth concerns into the aggregator. Sources know their own auth requirements.

View File

@@ -0,0 +1,59 @@
# ADR-0010: NATS bridge over the in-process bus, and Todoist background sync
## Status
Accepted — 2026-04-18
## Context
ADR-0005 set protobuf + JetStream as the long-term event substrate. M1 shipped
an in-process `EventEmitter`-based bus with the right subjects (`signals.*`,
`feedback.*`) so the swap would be mechanical.
Two pressures pulled forward:
1. **ml/serving** and future feature pipelines need to consume signals across
process boundaries — the in-proc emitter cannot do that.
2. **Todoist** signals were only fetched on the recommend path. Cold-cache hits
added latency and a single 401/429 stalled the request that triggered it.
## Decision
### 1. Bridge, do not replace
The `Bus` stays the producer. A new `Bus.onPublish(hook)` hook fires on every
`publish`. When `NATS_URL` is set, `connectNats()` registers a hook that
JSON-encodes the payload and `js.publish(subject, data)`s it to JetStream.
- Streams are created on startup and are idempotent: `signals` (`signals.>`,
7-day file storage, 500k msgs) and `feedback` (`feedback.>`, 30-day, 200k).
- JetStream publish errors are caught inside the hook so an unhealthy broker
cannot crash the in-process publisher or its subscribers.
- When `NATS_URL` is unset, `connectNats` is a no-op — local dev keeps working.
This preserves the existing `bus.subscribe()` contract for in-process consumers
(reward inference, ring-buffer tail for the admin event viewer) while making
events durably consumable across processes.
### 2. Schedule Todoist, keep on-demand as the SLA fallback
A 15-minute background scheduler (`TODOIST_SYNC_INTERVAL_MS`) walks every
user with `tokenStatus = 'active'` and calls `todoistSource.fetchSignals(uid)`,
which in turn emits `signals.task.synced`. The per-request fetch in
`recommender` stays — when the cache is colder than 30 s it still goes to
Todoist inline, so freshness on the user's first hit of the day is unchanged.
Per-user failures are isolated with `Promise.allSettled`; one expired token
cannot stop the rest of the cohort. The whole tick is wrapped so a transient
SQLite error logs and skips, never crashes the API.
## Consequences
- ml/serving (and any future Python consumer) can durably tail
`signals.task.synced`, `signals.tip.served`, `signals.tip.feedback` from
JetStream without coupling to the API process.
- Local dev still runs without NATS; the bridge is opt-in via env.
- Wire format is JSON today (envelope per ADR-0005 not enforced yet) — see
Open follow-ups.
## Open follow-ups
- A ml/serving JetStream consumer for the feature pipeline (today nothing
reads from JetStream — the API only writes).
- Move the wire payload to the protobuf envelope from ADR-0005 once the
schema-registry CI gate (#54) lands.
- Graceful shutdown of the scheduler timer on `SIGTERM`.
- Per-publish failure metrics exported to the admin health view.

View File

@@ -0,0 +1,89 @@
# ADR-0011 — User-profile feature registry
**Status:** Accepted (phase A)
**Date:** 2026-04-25
**Issue:** #81
## Context
The bandit and LLM tip generator only saw per-candidate features (`is_overdue`,
`task_age_days`, `priority`) plus contextual time signals. There was no notion
of a *user-level* profile — completion rate, dismiss rate, preferred hour, tip
volume — even though all the raw data already lives in `tip_views`,
`tip_feedback`, and `tip_scores`.
#81 originally proposed putting the feature registry in `ml/features/` (Python).
We're choosing differently for the data-locality reason: the aggregations are
SQL queries against tables owned by `services/api`. Computing them in Python
means a network round-trip per recommendation for queries that are sub-ms in TS.
## Decision
Two-sided design with one source of truth:
- **`services/api/src/profile/registry.ts`** — *source of truth*. Each
`FeatureDefinition` declares `{ name, dtype, ttlSec, description, compute }`.
`compute(userId, sqlite)` runs the aggregation SQL directly via the raw
better-sqlite3 client.
- **`services/api/src/profile/builder.ts`** — `getProfile(userId)` returns the
full feature dict, lazily recomputing any entry whose stored row is past its
`ttlSec`. `rebuildProfile(userId)` force-refreshes everything.
- **`user_profile_features` table** — KV per `(user_id, name)` with `value`
(REAL) for numeric and `value_text` (TEXT) for categorical. Phase A
ships only numeric features.
- **`ml/features/profile_schema.py`** — *contract mirror*. Names, dtypes, and
descriptions only — no compute. A test reads the TS file and asserts the
name sets match, catching drift.
- **`POST /score` and `POST /generate`** in `ml/serving` accept an optional
`profile_features: dict | None`. Stored on the request object but **not
consumed by the bandit yet** — extending the feature vector changes `D` and
resets every user's learned state. That's a deliberate phase-B decision.
Initial features: `completion_rate_30d`, `dismiss_rate_30d`,
`mean_dwell_ms_30d`, `preferred_hour`, `tip_volume_30d`.
## Consequences
**Good:**
- Adding a feature = one entry in `registry.ts` + one mirror line in
`profile_schema.py`. No DB migration required (KV table).
- TTL keeps recommendation latency bounded: every recommend call refreshes at
most 5 features, each a single indexed query against an already-warm DB.
- Profile data is now visible to ml/serving via the request payload — eval
harnesses and the LLM tip generator can use it without a DB round-trip.
**Trade-offs:**
- TS owns compute → ml-side changes that need new features still require a
TS PR. Acceptable while the modular monolith holds; if `ml/serving`
becomes the system of record for any feature, it should own its own table.
- TTL-based refresh has up-to-`ttlSec` lag on user-visible behavior change.
Phase B replaces this with event-driven incremental updates subscribing to
`signals.tip.feedback`.
## Phase B
-**B.1** — Per-user profile view + rebuild action in `/admin/users/:id`.
-**B.2** — Event-driven invalidation: features declare `invalidatedBy`
subjects in the registry; `profile/subscriber.ts` deletes the affected stored
rows on publish so the next `getProfile` call recomputes immediately rather
than waiting up to `ttlSec`. TTL stays as a safety net for clock drift /
dropped events.
-**B.4** — Staleness panel in `/admin/data-quality` (counts missing + stale
per feature across eligible users).
-**B.3** — Extend the bandit feature vector to include profile features
(deliberate `D` change with state-migration plan + shadow rollout per ADR-0002).
Tracked separately as #99 since it's a multi-step initiative, not an
incremental phase.
## Alternatives considered
**Registry in Python (per the original issue text)** — rejected: the
aggregations live in TS-owned tables; round-tripping per recommend adds
latency for no architectural gain.
**Compute in the recommender route inline** — rejected: features would be
recomputed on every recommendation with no cache or staleness semantics.
**Use `tip_scores.featuresJson` as the profile store** — rejected: that
column is per-tip explainability, not per-user state. Mixing them complicates
both reads.

View File

@@ -0,0 +1,124 @@
# ADR-0012 — ε-greedy v2: profile features in the bandit (D=7→12)
**Status:** Superseded by ADR-0013 — 2026-05-01
**Date:** 2026-04-25 (accepted) / 2026-04-26 (promoted)
**Issue:** #99
## Context
ADR-0011 shipped a 5-feature user-profile registry (completion rate, dismiss rate,
mean dwell, preferred hour, tip volume). `POST /score` and `POST /score/egreedy`
already receive a `profile_features` dict on every call but **ignore it** — the
comment in `ml/serving/main.py` explains why: extending the feature vector changes
`D`, which resets every user's learned `A`/`b` matrices and discards accumulated
signal. That loss requires a deliberate shadow-first rollout per ADR-0002, not an
in-place update.
This ADR authorises `egreedy-v2`, which extends the active `egreedy-v1` (D=7) with
the 5 profile features (D=12) and defines how it ships safely.
## Decision
### New policy: egreedy-v2 (D=12)
Feature vector layout:
| idx | name | encoding |
|-----|------|----------|
| 01 | hour_sin, hour_cos | cyclical, current hour |
| 2 | is_overdue | 0/1 |
| 3 | task_age_norm | age_days / 30, clipped 01 |
| 4 | priority_norm | (p 1) / 3 |
| 56 | dow_sin, dow_cos | cyclical, day of week |
| 7 | completion_rate_30d | raw (already 01); null → 0 |
| 8 | dismiss_rate_30d | raw (already 01); null → 0 |
| 9 | mean_dwell_norm | dwell_ms / 600_000, clipped 01; null → 0 |
| 10 | preferred_hour_alignment | `(cos(2π(pref now)/24) + 1) / 2`; null → 0.5 (neutral) |
| 11 | tip_volume_norm | `log1p(n) / log1p(100)`, clipped 01; null → 0 |
**Normalization rationale:**
- Rates are already in [0, 1]; no transform needed.
- Dwell clips at 10 min — anything beyond that carries diminishing signal.
- `preferred_hour` needs circular continuity; one-dimension approximation using
cosine alignment with the current hour. At null (no established peak) we use
0.5 (the midpoint/neutral) rather than 0 (misleading "polar-opposite hour").
- `tip_volume` uses log-scale because engagement counts are heavy-tailed.
### Rollout sequence (per ADR-0002)
1. **Shadow** (this ADR) — `egreedy-v2-shadow` registered in the recommender's
shadow-policy map (disabled by default). Admin enables via `/admin/policies`.
- Calls `/score/egreedy/v2` fire-and-forget alongside the active `egreedy-v1` call.
- Publishes `signals.tip.served` with `policy: shadow:egreedy-v2-shadow` for logging.
- **No reward delivery to shadow** — live shadow collects decision-agreement
exposure only; reward measurement uses offline simulation.
- State files: `{user}_egreedy_v2.json` — isolated from v1's `{user}_egreedy.json`.
2. **Offline sim** — run `runner.py --policies egreedy-v1 egreedy-v2 --n-rounds 20`
using the `rule` judge and persona-level profile features (synthetic values in
`personas.py`). Gate: v2 mean reward ≥ v1 mean reward.
3. **Promote** — if sim gate passes, change the `remotePolicy()` call in
`recommender.ts` from `/score/egreedy` to `/score/egreedy/v2` and change reward
delivery to `/reward/egreedy/v2`. No DB migration; old per-user v1 state files
are left on disk (available for rollback; clean up after 30 days).
### State-file migration
No migration of `A`/`b` matrices from v1 → v2. A D×D→D'×D' transform would
require assumptions about the new dimensions that we cannot justify without data.
v2 starts from the identity prior and learns from scratch in shadow/sim. The reward
penalty from cold-start is the correct price for the dimension extension.
### Admin control
`GET /api/admin/policies` surfaces `egreedy-v2-shadow` with `active: false`.
Toggle via `POST /api/admin/policies/egreedy-v2-shadow/toggle`.
## Consequences
**Good:**
- Profile features (preferred hour, completion/dismiss rates, volume) allow the
bandit to personalise timing recommendations beyond what the candidate-level
features encode.
- Normalization is deterministic, bounded [0, 1], and numerically stable; no
scaling artefacts as the population grows.
- Shadow-first rollout protects real users from a cold-start regression.
**Trade-offs:**
- Cold-start: v2 state files begin from the identity prior. During shadow,
v2 makes random-ish decisions for early users. This is expected and intentional.
- Synthetic persona profiles in `personas.py` approximate real user distributions;
the offline sim is evidence, not proof. The promotion gate requires the sim to
run after v2 has accumulated enough behavioral data (suggest ≥100 shadow calls
per policy per user before running the final sim).
- The one-dim preferred-hour encoding loses some circular information compared to
two-dim sin/cos. If preferred-hour alignment becomes a dominant signal, revisit
with D=13 in a follow-up ADR.
## Alternatives considered
**Warm-start via projection** — project v1's 7-dim theta into D=12 by padding
with zeros. Rejected: zero initialization for the profile dims is equivalent, and
projecting theta without the corresponding `A` matrix cannot be done correctly.
**D=13 with two preferred-hour dims** — cleaner circular encoding, but contradicts
the D=12 target in the issue spec and complicates the sim comparison. Deferred.
**In-place v1 promotion without shadow** — violates ADR-0002.
## Promotion record (2026-04-26)
Offline sim (`runner.py --policies egreedy-v1 egreedy-v2 --judge rule --n-users 5 --n-rounds 20 --seed 42`):
| policy | total reward | mean reward | pulls |
|--------|-------------|-------------|-------|
| egreedy-v1 | 64.20 | 0.6420 | 100 |
| egreedy-v2 | 62.90 | 0.6290 | 100 |
**Gate passed** (v2 mean ≥ v1 mean). Per-persona: v2 wins deadline-driven, evening-relaxed, low-priority-first; v1 wins consistent-responder, overdue-ignorer.
Changes applied:
- `recommender.ts` `remotePolicy()`: `/score/egreedy``/score/egreedy/v2`
- `recommender.ts` `sendRewardWithRetry()`: `/reward/egreedy``/reward/egreedy/v2`, added `profile_features` to payload
- Shadow entry `egreedy-v2-shadow` left in registry (`active: false`) for rollback.

View File

@@ -0,0 +1,106 @@
# ADR-0013 — Multi-agent recommendation: pre-computed agent snippets + orchestrator LLM
**Status:** Accepted
**Date:** 2026-05-01
**Supersedes:** ADR-0007, ADR-0012
## Context
The ε-greedy bandit (ADR-0007, promoted to v2 in ADR-0012) was the first recommendation
policy. It served adequately during early M1 testing but carries structural problems that
become more acute as the user base grows:
- **Training signal sparsity.** The median user generates fewer than 5 reward signals per
week. Ridge regression on a 12-dimensional feature vector needs far more signal than
that to converge to a meaningful θ before the user loses interest.
- **Cold-start cost.** Every new user starts with an uninformed identity matrix. Early tips
are essentially random for the first weeks of use — precisely when first impressions
matter most.
- **Opacity.** The bandit cannot explain why it chose a tip. An orchestrator that reasons
explicitly over named agent outputs ("3 overdue tasks + peak hour approaching") is
interpretable by design.
- **Coupling of generation and selection.** The current pipeline generates candidates, then
scores them; the scoring is decoupled from the LLM reasoning. Giving the LLM the full
pre-computed context directly is a simpler and more capable design.
## Decision
Replace the RL bandit with a **multi-agent pipeline**:
### Sub-agents (async, pre-computed)
Multiple domain-specialized Python agents each analyze user state from one angle and
produce a **prompt snippet** — a short natural-language paragraph describing what they
found. They do not produce tips. They run periodically (every 15 minutes) and store
results in the new `agent_outputs` table with per-agent TTLs.
Initial agent set:
| Agent | ID | TTL |
|---|---|---|
| OverdueTaskAgent | `overdue-task` | 1h |
| MomentumAgent | `momentum` | 6h |
| TimeOfDayAgent | `time-of-day` | 15m |
| RecentPatternsAgent | `recent-patterns` | 24h |
| FocusAreaAgent | `focus-area` | 12h |
### Orchestrator agent (real-time)
When a user requests a tip, the TypeScript recommender:
1. Fetches all non-expired `agent_outputs` rows for the user.
2. Calls `POST /recommend` on `ml/serving` with the snippet list.
3. `ml/serving` assembles a single orchestrator prompt (template `v4-orchestrator`)
that concatenates all snippets, then calls LiteLLM via the existing `tip-generator`
alias to produce one tip.
No bandit scoring. No reward delivery to an ML model. The LLM receives full context and
generates the tip in one call.
### Feedback
`tipFeedback` rows are still written on every user reaction. `inferReward()` still runs
and `rewardMilli` is logged for observability and potential future supervised learning.
Reactions are not delivered to an ML endpoint.
## New data model
```sql
CREATE TABLE agent_outputs (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
agent_id TEXT NOT NULL, -- e.g. 'overdue-task'
prompt_text TEXT NOT NULL, -- snippet produced by the agent
signals_snapshot TEXT, -- JSON: inputs the agent consumed
computed_at TEXT NOT NULL, -- ISO 8601
expires_at TEXT NOT NULL, -- ISO 8601 = computed_at + TTL
agent_version TEXT NOT NULL -- bump to invalidate cached outputs on logic changes
);
CREATE INDEX idx_agent_outputs_user_agent_exp
ON agent_outputs(user_id, agent_id, expires_at DESC);
```
## Consequences
### Positive
- Tips are explainable: `featuresJson` in `tipScores` records which agents contributed.
- Cold-start is eliminated: the orchestrator reasons from signals immediately, no warm-up.
- Adding or removing an agent is a self-contained change in `ml/agents/`.
- Swapping LLM models remains a config change (LiteLLM alias unchanged).
### Negative / risks
- **No automatic exploration.** The bandit would discover that a user prefers certain tip
types without being told. The orchestrator only knows what the agents tell it.
Mitigation: agents can evolve to encode richer signals; offline evaluation via the
existing bench scripts remain available.
- **Scheduler dependency.** If the pre-compute job falls behind, agent outputs go
stale. Mitigation: the orchestrator falls back to raw signal prompt when no outputs
exist; `TimeOfDayAgent` recomputes every 15 min to stay fresh.
- **Higher per-request token cost.** The orchestrator prompt is longer than the old bandit
prompt. Mitigation: the `tip-generator` alias points to a small local model; token cost
is negligible at current scale.
## Migration sequence
See plan document in conversation context. 10 steps; each independently deployable and
rollback-able. Cutover is Step 6 (single TypeScript PR). Bandit endpoints removed in
Step 7 after 48h clean traffic.

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

@@ -0,0 +1,44 @@
# ADR-0015 — Data-source consents only; drop per-agent consent gate
**Date:** 2026-05-11
**Status:** Accepted
**Supersedes:** ADR-0014 §3 (consent model)
## Context
ADR-0014 introduced `required_consents` on agent manifests. In practice two
unrelated concepts were mixed into that field:
- `data:<source>` — which data source the agent reads.
- `agent:<id>` — whether the user opted into this specific agent.
No UI ever granted `agent:<id>` consents, so the eligibility filter at
`services/api/src/profile/eligibility.ts` dropped every agent for every real
user. The symptom was confirmed by MLflow trace
`tr-591449ea8a72af8e81b6a585234a86ab`: user `ODGp4Gkr7JWemMsqcMLMn` had five
fresh `agent_outputs` rows but the orchestrator received `agent_ids: []`.
## Decision
Collapse to a single consent dimension: **data source**.
1. `required_consents` entries must all start with `data:`. Agent manifests no
longer list `agent:<id>` entries.
2. Connecting a data source via the OAuth flow automatically grants
`data:<provider>` in `user_consents`. Disconnecting sets `revoked_at`.
3. `data:core` continues to be auto-granted on signup.
4. Per-agent control becomes a **preference** (`user_preferences[scope='agent:<id>', key='enabled']`), not a consent. The eligibility filter already honours this — the only change is removing the `agent:*` consent check that was always failing.
5. Eligibility rule (final): an agent is eligible iff every `data:*` it
declares is granted and not revoked, no active context is in
`silenced_in_contexts`, and the `enabled` preference is not `false`.
## Consequences
- Agents that only require `data:core` (time-of-day, momentum, recent-patterns)
become eligible immediately after signup.
- Agents requiring `data:todoist` or `data:google-health` become eligible as
soon as the user connects the integration — no extra consent step.
- A backfill migration grants `data:<provider>` for every existing active
`integration_tokens` row, unblocking users who connected before this change.
- `ml/agents/tests/test_manifest.py` asserts all `required_consents` start
with `data:`, preventing regression.

View File

@@ -25,12 +25,37 @@ Session auth
expires_at
revoked_at?
Profile profile
user_id (pk)
timezone
quiet_hours jsonb: [{start,end,days}]
contexts jsonb: [{name,predicate}] introduced in Phase 2
consents jsonb: {integration: {read,write,retain_days}}
User (extended) profile ADR-0014
+ tone 'direct' | 'gentle' | 'motivational'
+ tip_kinds_json jsonb: allowed tip kinds (stable globals)
UserPreference profile ADR-0014
user_id, scope, key (pk)
scope 'orchestrator' | 'agent:<id>'
value_json open-ended; agent validates against its pref_schema on read
source 'user' | 'inferred' (inferred never overwrites user)
updated_at
UserConsent profile ADR-0014
user_id, consent_key (pk)
consent_key 'data:todoist' | 'data:calendar' | 'agent:focus-area' | ...
granted_at
revoked_at? null = currently active
UserContext profile ADR-0014
user_id, name (pk) 'work' | 'home' | 'vacation' | user-named
active manual toggle in M2; auto-inference per agent in #112-#116
schedule_json? optional: when this context is active
created_at
AgentOutput recommender ADR-0013
id (pk)
user_id
agent_id e.g. 'overdue-task' (matches a manifest)
prompt_text snippet for the orchestrator prompt
signals_snapshot jsonb: inputs the agent consumed
computed_at, expires_at computed_at + manifest.ttl_sec
agent_version bump to invalidate cached outputs on logic changes
Credential integrations
user_id
@@ -53,10 +78,10 @@ Event events
TipInstance recommender
tip_id (ulid)
user_id
policy_name "random" | "bandit.linucb" | "remote:v3"
policy_name "v4-orchestrator" (ADR-0013) | legacy bandit names retained for history
policy_version
candidate_source "todoist" | "advice.library" | ...
context_snapshot jsonb: features seen at decision time
candidate_source "todoist" | "advice.library" | "agent-orchestrator" | ...
context_snapshot jsonb: features + agent snippets seen at decision time
tip jsonb: {kind,title,body,source,deep_link,meta}
created_at
shown_at? set when the client reports render

View File

@@ -15,7 +15,7 @@
| `auth` | TS | OAuth (Google; Apple in M1), sessions, JWT | identities, sessions | Node monolith |
| `profile` | TS | user profile, preferences, consents | profiles | Node monolith |
| `integrations` | TS | third-party connectors, token vault, signal fetch | credentials, cursors | Node monolith |
| `events` | TS | event-bus abstraction + durable log (M1) | signal store | Node monolith (in-proc emitter) |
| `events` | TS | event-bus abstraction + durable log | signal store | Node monolith (in-proc emitter, bridges to NATS JetStream when `NATS_URL` set) |
| `recommender` | TS | orchestration: candidates → policy → tip; feedback sink | tip history | Node monolith |
| `notifier` | TS | push/email delivery, quiet hours, dedupe | delivery log | Node monolith (web push in M1) |
| `ml/serving` | Python | online scoring for policies/models | — (stateless) | **separate process** |
@@ -46,21 +46,58 @@ User reactions (done / snooze / dismiss) are events too. They close the loop as
- **Protobuf** for event schemas with a schema registry (ADR-0005) — train/serve parity depends on this.
- **OpenAPI** for HTTP; TS client auto-generated; Python pydantic hand-written while consumers are few.
- **Feast** for feature store when we get there; homegrown adapter until then (Phase 1 seam).
- **MLflow** for model registry; artifacts in MinIO/S3.
- **MLflow** for model registry and experiment tracking; deployed at `o.alogins.net/mlflow`.
- **Auth.js** embedded behind an OIDC-shaped boundary (ADR-0004). Swap to a standalone OIDC provider when mobile ships.
- **Multi-agent recommendation** (ADR-0013) — pre-compute agents emit prompt snippets, an orchestrator LLM produces the tip. Replaced the ε-greedy bandit (ADR-0007/0012) for explainability, cold-start, and decoupling generation from selection.
- **Registry-driven agents + unified Profile** (ADR-0014) — agents are plugins with declared manifests; per-user prefs, contexts, and per-key consents live in shared tables; auto-inferred parameters share a common framework. Adding an agent is a manifest change.
- **k3s** as the first step beyond docker-compose — no "compose → full k8s" cliff.
## Decision flow for a new tip
## AI stack
All LLM inference routes through **LiteLLM** (`llm.alogins.net`) backed by **Ollama** (local, `localhost:11434`). This means:
- Model aliases (`tip-generator`, `embedder`, `judge`) decouple code from model names.
- Swapping qwen2.5 → llama3.2 = one-line config change in LiteLLM, zero code change in oO.
- Cloud fallback (Anthropic) is opt-in and gated behind `ANTHROPIC_API_KEY` — used only in offline simulation.
**OpenWebUI** (`ai.alogins.net`) is the human-facing interface for prompt iteration and model testing during development.
## Decision flow for a new tip (M2, ADR-0013 + ADR-0014)
```
client ─► gateway ─► recommender
┌────────────────────────────────────────────────┐
│ 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)
├─► candidates: integrations.fetchCandidates(user) + advice.library
├─► context: FeatureAssembler(user, request)
├─► policy: PolicyRegistry.get(policyName).pick(candidates, context)
├─► shadows: run shadow policies in parallel, log their picks
└─► persist: TipInstance{context_snapshot, policy, tip}
├─► 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)
├─► assemble: v4-orchestrator prompt
│ = global prefs + active context + snippets
├─► generate: LiteLLM → Ollama → one tip
└─► persist: tip_scores {tip, contributing agents,
prompt_version, llm_model, latency}
◄─ tip
```
Feedback travels back the same path: `POST /feedback → events.emit(feedback.reaction)` → pipelines consume → bandit/model updated on next retrain.
**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.
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.

View File

@@ -26,7 +26,7 @@ User taps "Delete account" in settings → hard confirm → `User.deleted_at` se
## Scope boundaries
Each integration declares the scopes it requests and the features it derives. The `Profile.consents` column is the source of truth; a scope removed from consent short-circuits derived-feature computation at the feature store.
Each integration and each agent declares the consent keys it requires (`data:todoist`, `agent:focus-area`, ...) in its manifest. The `user_consents` table is the source of truth (per-key rows, revocation is a `revoked_at` write — never a delete, so audits stay clean). A revoked consent short-circuits derived-feature computation at the feature store and removes the dependent agent from the orchestrator's eligible set on the next tip. See ADR-0014.
## Audit

63
infra/ci/ci.yml Normal file
View File

@@ -0,0 +1,63 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
type-check-and-lint:
name: Type-check & lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build --filter=@oo/shared-types
- run: pnpm type-check
test:
name: Unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build --filter=@oo/shared-types
- run: pnpm test
ml-lint:
name: Python lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: pip install ruff
- run: ruff check ml/serving/
ml-test:
name: Python tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: python -m venv ml/serving/.venv
- run: ml/serving/.venv/bin/pip install -r ml/serving/requirements-dev.txt
- run: ml/serving/.venv/bin/python -m pytest ml/serving/tests/ -v

View File

@@ -0,0 +1,33 @@
# syntax=docker/dockerfile:1.7
FROM node:22-slim AS base
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& npm install -g pnpm
ENV CI=true \
PNPM_HOME=/pnpm \
PATH=/pnpm:$PATH
RUN pnpm config set store-dir /pnpm/store
FROM base AS builder
WORKDIR /app
COPY pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm fetch
COPY . .
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile --offline \
--filter @oo/admin... --filter @oo/shared-types
RUN pnpm --filter @oo/shared-types build
ARG NEXT_PUBLIC_MLFLOW_URL=/mlflow
ENV NEXT_TELEMETRY_DISABLED=1 \
NEXT_PUBLIC_MLFLOW_URL=$NEXT_PUBLIC_MLFLOW_URL
RUN pnpm --filter @oo/admin build
FROM node:22-slim AS runner
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=3080 DOCS_ROOT=/app/docs
WORKDIR /app
COPY --from=builder /app/apps/admin/.next/standalone ./
COPY --from=builder /app/apps/admin/.next/static ./apps/admin/.next/static
COPY --from=builder /app/docs ./docs
CMD ["node", "apps/admin/server.js"]

View File

@@ -0,0 +1,35 @@
# syntax=docker/dockerfile:1.7
FROM node:22-slim AS base
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& npm install -g pnpm
ENV CI=true \
PNPM_HOME=/pnpm \
PATH=/pnpm:$PATH
RUN pnpm config set store-dir /pnpm/store
FROM base AS builder
WORKDIR /app
COPY pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm fetch
COPY . .
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile \
--filter @oo/api... --filter @oo/shared-types
RUN pnpm --filter @oo/shared-types build
RUN pnpm --filter @oo/api build
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm --filter @oo/api --prod deploy --legacy /deploy \
&& cp -r services/api/dist /deploy/dist \
&& rm -rf /deploy/node_modules/@oo/shared-types/src \
&& cp -r packages/shared-types/dist /deploy/node_modules/@oo/shared-types/dist
FROM node:22-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /deploy/package.json ./
COPY --from=builder /deploy/node_modules ./node_modules
COPY --from=builder /deploy/dist ./dist
CMD ["node", "dist/index.js"]

View File

@@ -0,0 +1,11 @@
FROM python:3.12-slim
WORKDIR /app/ml/serving
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential \
&& rm -rf /var/lib/apt/lists/*
COPY ml/serving/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY ml/ /app/ml/
# PYTHONPATH=/app lets 'import ml.agents.*' resolve from /app/ml/agents/
ENV PYTHONPATH=/app
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -0,0 +1,30 @@
FROM node:22-alpine AS base
RUN npm install -g pnpm
FROM base AS deps
WORKDIR /app
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./
COPY packages/shared-types/package.json ./packages/shared-types/
COPY apps/web/package.json ./apps/web/
RUN pnpm install --frozen-lockfile
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/packages/shared-types/node_modules ./packages/shared-types/node_modules
COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
COPY tsconfig.base.json ./
COPY packages/shared-types ./packages/shared-types
COPY apps/web ./apps/web
RUN pnpm --filter @oo/shared-types build
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm --filter @oo/web build
FROM node:22-alpine AS runner
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1
WORKDIR /app
COPY --from=builder /app/apps/web/.next/standalone ./
COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=builder /app/apps/web/public ./apps/web/public
CMD ["node", "apps/web/server.js"]

View File

@@ -0,0 +1,174 @@
name: oo
services:
# ── core profile ──────────────────────────────────────────────────────────
api:
build:
context: ../..
dockerfile: infra/docker/Dockerfile.api
profiles: [core, full]
env_file: ../../.env.local
environment:
NODE_ENV: production
ML_SERVING_URL: "http://ml-serving:8000"
MLFLOW_URL: "http://mlflow:5000"
INTERNAL_API_TOKEN: "${INTERNAL_API_TOKEN:-}"
volumes:
- /mnt/ssd/dbs/oo:/mnt/ssd/dbs/oo
ports:
- "127.0.0.1:3078:3078"
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:3078/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 10s
timeout: 5s
retries: 5
web:
build:
context: ../..
dockerfile: infra/docker/Dockerfile.web
profiles: [core, full]
env_file: ../../.env.local
environment:
NODE_ENV: production
PORT: "3079"
HOSTNAME: "0.0.0.0"
NEXT_PUBLIC_API_URL: "" # Caddy routes /api/* directly to the API in prod
ports:
- "127.0.0.1:3079:3079"
depends_on:
api:
condition: service_healthy
admin:
build:
context: ../..
dockerfile: infra/docker/Dockerfile.admin
profiles: [core, full]
env_file: ../../.env.local
environment:
NODE_ENV: production
PORT: "3080"
HOSTNAME: "0.0.0.0"
NEXT_PUBLIC_API_URL: ""
NEXT_PUBLIC_MLFLOW_URL: "/mlflow"
INTERNAL_API_URL: "http://api:3078"
ports:
- "127.0.0.1:3080:3080"
depends_on:
api:
condition: service_healthy
# ── full profile ──────────────────────────────────────────────────────────
ml-serving:
build:
context: ../..
dockerfile: infra/docker/Dockerfile.ml
profiles: [full]
env_file: ../../.env.local
environment:
LITELLM_URL: ${LITELLM_URL:-http://host.docker.internal:4000}
OLLAMA_URL: ${OLLAMA_URL:-http://host.docker.internal:11434}
MLFLOW_TRACKING_URI: ${MLFLOW_TRACKING_URI:-http://mlflow:5000}
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "127.0.0.1:8000:8000"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/health',timeout=3).status==200 else 1)"]
interval: 10s
timeout: 5s
retries: 5
# ── ai profile — Ollama + LiteLLM for local dev ──────────────────────────
# Start: docker compose --profile ai up
# Use when the Agap shared Ollama/LiteLLM services are not available locally.
# Set LITELLM_URL=http://localhost:4000 and OLLAMA_URL=http://localhost:11434
# in .env.local to point ml-serving at these containers instead of Agap.
ollama:
image: ollama/ollama:latest
profiles: [ai]
volumes:
- ollama-models:/root/.ollama
ports:
- "127.0.0.1:11434:11434"
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:11434/api/tags"]
interval: 15s
timeout: 5s
retries: 10
litellm:
image: ghcr.io/berriai/litellm:main-latest
profiles: [ai]
environment:
LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY:-sk-local-dev}
command: >
--model ollama/qwen2.5:1.5b
--model ollama/nomic-embed-text
--api_base http://ollama:11434
--port 4000
ports:
- "127.0.0.1:4000:4000"
depends_on:
ollama:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:4000/health"]
interval: 10s
timeout: 5s
retries: 5
# ── mlops profile — MLflow ────────────────────────────────────────────────
# Start: docker compose --profile mlops up
# MLflow UI: http://localhost:5000 or https://o.alogins.net/mlflow
# ── events profile — NATS JetStream ─────────────────────────────────────
# Start: docker compose --profile events up
# NATS monitoring: http://localhost:8222
# Enable in the API by setting NATS_URL=nats://nats:4222 in .env.local
nats:
image: nats:2.10-alpine
profiles: [events, full]
command: ["-js", "-sd", "/data", "-m", "8222"]
volumes:
- /mnt/ssd/dbs/oo/nats:/data
ports:
- "127.0.0.1:4222:4222" # client connections
- "127.0.0.1:8222:8222" # HTTP monitoring
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8222/healthz"]
interval: 10s
timeout: 5s
retries: 5
mlflow:
image: ghcr.io/mlflow/mlflow:v3.11.1
profiles: [mlops]
command: >
mlflow server
--backend-store-uri sqlite:////mlflow/mlflow.db
--artifacts-destination /mlflow/artifacts
--serve-artifacts
--default-artifact-root mlflow-artifacts:/
--host 0.0.0.0
--port 5000
--static-prefix /mlflow
--allowed-hosts o.alogins.net,localhost,localhost:5000,mlflow,mlflow:5000
--cors-allowed-origins https://o.alogins.net
volumes:
- /mnt/ssd/dbs/oo/mlflow:/mlflow
ports:
- "127.0.0.1:5000:5000"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:5000/mlflow/health',timeout=3).status==200 else 1)"]
interval: 10s
timeout: 5s
retries: 5
volumes:
ollama-models:

View File

@@ -0,0 +1,6 @@
[mlflow]
default_permission = NO_PERMISSIONS
database_uri = sqlite:////mlflow/basic_auth.db
admin_username = admin
# Change this before deploying — the admin can reset other users' passwords via the MLflow UI
admin_password = password

View File

@@ -4,9 +4,9 @@ Python. Owns models, features, training, online scoring.
| Dir | Role | Phase |
|---|---|---|
| `serving/` | FastAPI online scorer (`/score`), called by `recommender` | 1 |
| `features/` | feature definitions + store adapter (Feast later) | 1 |
| `pipelines/` | batch feature + training DAGs (Prefect/Airflow) | 4 |
| `serving/` | FastAPI online scorer (`/score`, `/generate`) + LiteLLM gateway + prompt registry (`prompts.py`) + JetStream consumers for `signals.>` / `feedback.>`, called by `recommender` | 12 |
| `features/` | context assembler (`context.py`): signals → `PromptContext`; profile-feature schema mirror (`profile_schema.py`); Feast adapter later | 2 |
| `pipelines/` | batch feature + training scripts | 4 |
| `registry/` | MLflow-backed model registry integration | 4 |
| `experiments/` | A/B assignment + multi-armed bandit policies | 4 |
| `notebooks/` | research; never imported by production code | — |
@@ -17,3 +17,26 @@ Python. Owns models, features, training, online scoring.
- Online inference must be stateless and < 50ms p99.
- Training reads from the offline feature store; serving reads from the online feature store; definitions are shared (no train/serve skew).
- Shadow deploys before any policy change that affects real users.
## Feature contract
### Profile features (batched)
User-level features (completion rate, preferred hour, tip volume…) are computed
by the TypeScript recommender and shipped to `ml/serving` on every `/score` and
`/generate` call as `profile_features: dict | None`. The Python mirror in
`features/profile_schema.py` documents each feature's name, dtype, TTL, source,
and null fallback — keep it in sync with `services/api/src/profile/registry.ts`
(a CI-style test asserts names and `ttlSec` values match). See ADR-0011.
### Context features (JIT)
Request-time signals assembled by `features/context.py` (`hour_of_day`,
`day_of_week`, task list). These are never cached — they are derived from the
system clock and the live Todoist feed at the moment of the score call.
`CONTEXT_FEATURES` in `context.py` declares freshness, source, and fallback for
each field (issue #61).
## Prompt registry
`serving/prompts.py` keys tip-generation prompts by stable version string. Adding a new variant means adding an entry — no caller changes. Selection precedence: `POST /generate` body's `prompt_version` field → env `DEFAULT_PROMPT_VERSION``"v1"`. The TypeScript recommender drives selection via `TIP_PROMPT_VERSION` (single value or comma-separated rotation); the version actually used flows back in the response and is persisted to `tip_scores.prompt_version` so the admin reward-analytics dashboard can bucket reactions per variant.

0
ml/__init__.py Normal file
View File

4
ml/agents/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
from .base import BaseAgent, AgentInput, AgentOutput
from .registry import get_agent, all_agents
__all__ = ["BaseAgent", "AgentInput", "AgentOutput", "get_agent", "all_agents"]

61
ml/agents/base.py Normal file
View File

@@ -0,0 +1,61 @@
"""Base class and shared data structures for all recommendation sub-agents."""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import ClassVar
@dataclass
class AgentInput:
"""Everything an agent may need to produce its prompt snippet."""
user_id: str
tasks: list[dict] # task signal dicts (content, priority, is_overdue, …)
profile: dict[str, float | None] # profile feature values keyed by feature name
feedback_history: list[dict] = field(default_factory=list) # [{action, dwell_ms, created_at}, …]
now: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
# Per-agent inferred/user prefs loaded from user_preferences (ADR-0014 §3).
# Keys match the agent's pref_schema + inferred_params. 'user' source takes
# precedence over 'inferred' source; the caller resolves priority before
# passing this dict in.
agent_prefs: dict = field(default_factory=dict)
# Pre-fetched enrichment cache: {content_hash -> description}. Populated by
# the TS caller from the task_enrichments DB table to avoid redundant LLM calls.
enrichment_cache: dict = field(default_factory=dict)
@dataclass
class AgentOutput:
"""Result produced by an agent; persisted to agent_outputs table."""
user_id: str
agent_id: str
prompt_text: str # snippet passed to the orchestrator
signals_snapshot: dict # inputs consumed (for explainability / debugging)
computed_at: str # ISO 8601
expires_at: str # ISO 8601
agent_version: str
class BaseAgent(ABC):
agent_id: ClassVar[str]
ttl_seconds: ClassVar[int]
version: ClassVar[str]
@abstractmethod
def compute(self, inp: AgentInput) -> AgentOutput:
"""Analyse inp and return a prompt snippet describing what was found."""
...
def _make_output(self, inp: AgentInput, prompt_text: str, snapshot: dict) -> AgentOutput:
computed_at = inp.now.astimezone(timezone.utc).isoformat()
expires_at = (inp.now.astimezone(timezone.utc) + timedelta(seconds=self.ttl_seconds)).isoformat()
return AgentOutput(
user_id=inp.user_id,
agent_id=self.agent_id,
prompt_text=prompt_text,
signals_snapshot=snapshot,
computed_at=computed_at,
expires_at=expires_at,
agent_version=self.version,
)

290
ml/agents/clustering.py Normal file
View File

@@ -0,0 +1,290 @@
"""Semantic task clustering via nomic-embed-text (issue #97, #129).
Public API:
cluster_tasks(tasks) -> list[Cluster]
Each task dict must have a "content" key. Tasks without content are placed in a
fallback "other" bucket. If the embedding service is unreachable, falls back to
grouping by project_id so compute() always returns something useful.
Pipeline (ported from taskpile experiments/clustering_eval, prompt v1):
1. Expand each raw title via LiteLLM `tip-generator` (qwen2.5:1.5b) into a
3-sentence description. Cached in-memory by content hash within a compute
cycle so duplicate titles cost one LLM call.
2. Prefix the expanded text with "clustering: " (nomic-embed-text task prefix).
3. Batch-embed via LiteLLM `embedder` (nomic-embed-text).
Falls back to embedding raw titles when LLM expansion fails, and to
project-based grouping when embeddings are unavailable.
"""
from __future__ import annotations
import hashlib
import logging
import math
import os
from dataclasses import dataclass, field
import httpx
log = logging.getLogger(__name__)
# Cosine similarity threshold for merging tasks into the same cluster.
_SIM_THRESHOLD = 0.72
# Never produce more than this many clusters regardless of task count.
_MAX_CLUSTERS = 6
_EMBED_TIMEOUT = 15.0
_ENRICH_TIMEOUT = 30.0
_ENRICH_PROMPT_V1 = (
"You are helping categorize a personal task. "
"Write exactly 3 sentences in English describing what the task likely involves, "
"what context or skills it needs, and why it might matter. "
"Be concise and specific. Do not use bullet points or numbering.\n"
"Task: {title}\n"
"Description:"
)
@dataclass
class Cluster:
label: str # representative task content (shortest, most central)
tasks: list[dict] = field(default_factory=list)
@property
def task_count(self) -> int:
return len(self.tasks)
@property
def overdue_count(self) -> int:
return sum(1 for t in self.tasks if t.get("is_overdue"))
# ---------------------------------------------------------------------------
# LLM enrichment
# ---------------------------------------------------------------------------
def _content_hash(text: str) -> str:
return hashlib.md5(text.encode()).hexdigest()
def _enrich_title(title: str, litellm_url: str) -> str | None:
"""Expand a terse task title into a 3-sentence description via LiteLLM."""
try:
with httpx.Client(trust_env=False, timeout=_ENRICH_TIMEOUT) as c:
r = c.post(
f"{litellm_url}/chat/completions",
json={
"model": "tip-generator",
"messages": [{"role": "user", "content": _ENRICH_PROMPT_V1.format(title=title)}],
"max_tokens": 120,
"temperature": 0.3,
},
)
r.raise_for_status()
return r.json()["choices"][0]["message"]["content"].strip()
except Exception as exc:
log.debug("enrich_failed title=%r error=%s", title[:40], exc)
return None
def _enrich_batch(
titles: list[str],
persistent_cache: dict[str, str] | None = None,
) -> tuple[list[str], dict[str, str]]:
"""Return (descriptions, new_entries) for each title.
Checks persistent_cache (pre-fetched from DB) first, then falls back to
calling LiteLLM. new_entries contains only hashes generated this call —
the caller should persist these to the DB.
"""
litellm_url = os.getenv("LITELLM_URL")
if not litellm_url:
log.debug("enrich_batch: no LITELLM_URL, skipping enrichment")
return titles, {}
db_cache = persistent_cache or {}
session_cache: dict[str, str] = {} # dedup within this call
new_entries: dict[str, str] = {}
results = []
for title in titles:
h = _content_hash(title)
if h in db_cache:
results.append(db_cache[h])
elif h in session_cache:
results.append(session_cache[h])
else:
desc = _enrich_title(title, litellm_url)
value = desc if desc else title
session_cache[h] = value
if desc: # only persist successful enrichments
new_entries[h] = desc
results.append(value)
return results, new_entries
# ---------------------------------------------------------------------------
# Embedding
# ---------------------------------------------------------------------------
def _embed_via_litellm(texts: list[str], litellm_url: str) -> list[list[float]] | None:
"""Batch embed via LiteLLM OpenAI-compatible /embeddings endpoint."""
try:
with httpx.Client(trust_env=False, timeout=_EMBED_TIMEOUT) as c:
r = c.post(
f"{litellm_url}/embeddings",
json={"model": "embedder", "input": texts},
)
r.raise_for_status()
data = r.json().get("data", [])
ordered = sorted(data, key=lambda x: x["index"])
return [item["embedding"] for item in ordered]
except Exception as exc:
log.debug("litellm_embed_failed error=%s", exc)
return None
def _embed_via_ollama(texts: list[str], ollama_url: str) -> list[list[float]] | None:
"""Batch embed via Ollama /api/embed endpoint."""
try:
results = []
with httpx.Client(trust_env=False, timeout=_EMBED_TIMEOUT) as c:
for text in texts:
r = c.post(
f"{ollama_url}/api/embed",
json={"model": "nomic-embed-text", "input": text},
)
r.raise_for_status()
body = r.json()
# /api/embed returns {"embeddings": [[...]]}
embeddings = body.get("embeddings")
if not embeddings:
return None
results.append(embeddings[0])
return results
except Exception as exc:
log.debug("ollama_embed_failed error=%s", exc)
return None
def _embed_batch(texts: list[str]) -> list[list[float]] | None:
"""Embed a list of texts, preferring LiteLLM over direct Ollama."""
litellm_url = os.getenv("LITELLM_URL")
if litellm_url:
vecs = _embed_via_litellm(texts, litellm_url)
if vecs is not None:
return vecs
log.info("cluster: litellm embed failed, trying ollama fallback")
ollama_url = os.getenv("OLLAMA_URL", "http://host.docker.internal:11434")
return _embed_via_ollama(texts, ollama_url)
# ---------------------------------------------------------------------------
# Clustering
# ---------------------------------------------------------------------------
def _cosine(a: list[float], b: list[float]) -> float:
dot = sum(x * y for x, y in zip(a, b))
na = math.sqrt(sum(x * x for x in a))
nb = math.sqrt(sum(x * x for x in b))
if na == 0 or nb == 0:
return 0.0
return dot / (na * nb)
def _greedy_cluster(items: list[tuple[dict, list[float]]]) -> list[Cluster]:
"""Single-pass greedy clustering: each item joins the first existing cluster
whose centroid is above _SIM_THRESHOLD, else starts a new one."""
clusters: list[tuple[list[float], Cluster]] = [] # (centroid, cluster)
for task, vec in items:
best_idx = -1
best_sim = _SIM_THRESHOLD - 1e-9
for i, (centroid, _) in enumerate(clusters):
sim = _cosine(centroid, vec)
if sim > best_sim:
best_sim = sim
best_idx = i
if best_idx >= 0 and len(clusters) < _MAX_CLUSTERS:
centroid, cluster = clusters[best_idx]
cluster.tasks.append(task)
# Update centroid as running mean.
n = len(cluster.tasks)
new_centroid = [(c * (n - 1) + v) / n for c, v in zip(centroid, vec)]
clusters[best_idx] = (new_centroid, cluster)
elif len(clusters) < _MAX_CLUSTERS:
label = task.get("content", "Tasks")[:60]
cluster = Cluster(label=label, tasks=[task])
clusters.append((vec, cluster))
else:
# Overflow: append to closest cluster even below threshold.
best_i = max(range(len(clusters)), key=lambda i: _cosine(clusters[i][0], vec))
clusters[best_i][1].tasks.append(task)
return [c for _, c in clusters]
def _fallback_by_project(tasks: list[dict]) -> list[Cluster]:
"""Group by project_id when embeddings are unavailable."""
buckets: dict[str, Cluster] = {}
for task in tasks:
pid = task.get("project_id") or task.get("project") or "default"
if pid not in buckets:
label = pid if pid != "default" else "Tasks"
buckets[pid] = Cluster(label=label)
buckets[pid].tasks.append(task)
return list(buckets.values())
def cluster_tasks(
tasks: list[dict],
ollama_url: str | None = None, # kept for test compatibility; env vars take precedence
enrichment_cache: dict[str, str] | None = None,
) -> tuple[list[Cluster], dict[str, str]]:
"""Cluster tasks by semantic similarity.
Returns (clusters, new_enrichments). new_enrichments contains LLM-generated
descriptions produced this call that were not in the persistent cache — the
caller should persist these. Falls back to project-based grouping if the
embedding service is unavailable or tasks have no content.
"""
if not tasks:
return [], {}
# Separate tasks with usable content from those without.
with_content = [(t, t.get("content", "").strip()) for t in tasks]
embeddable = [(t, c) for t, c in with_content if c]
no_content = [t for t, c in with_content if not c]
if not embeddable:
return _fallback_by_project(tasks), {}
task_objs = [t for t, _ in embeddable]
raw_titles = [c for _, c in embeddable]
# Step 1: LLM-enrich titles → richer semantic signal before embedding.
descriptions, new_enrichments = _enrich_batch(raw_titles, persistent_cache=enrichment_cache)
# Attach enriched description to each task dict so consumers (e.g. focus-area)
# can show the expanded text instead of the terse raw title.
for task, desc in zip(task_objs, descriptions):
task["enriched_description"] = desc
# Step 2: Prefix with nomic-embed-text task prefix, then batch-embed.
prefixed = [f"clustering: {d}" for d in descriptions]
vecs = _embed_batch(prefixed)
if vecs is None or len(vecs) != len(prefixed):
log.info("cluster_tasks: embedding unavailable, falling back to project grouping")
return _fallback_by_project(tasks), new_enrichments
embedded = list(zip(task_objs, vecs))
clusters = _greedy_cluster(embedded)
if no_content:
clusters.append(Cluster(label="Other tasks", tasks=no_content))
return clusters, new_enrichments

Some files were not shown because too many files have changed in this diff Show More