Compare commits

...

21 Commits

Author SHA1 Message Date
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
144 changed files with 16797 additions and 53 deletions

39
.env.example Normal file
View File

@@ -0,0 +1,39 @@
# 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
# 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

2
.gitignore vendored
View File

@@ -11,6 +11,7 @@ build/
__pycache__/ __pycache__/
*.pyc *.pyc
.venv/ .venv/
__pycache__/
.mypy_cache/ .mypy_cache/
.pytest_cache/ .pytest_cache/
.ruff_cache/ .ruff_cache/
@@ -19,3 +20,4 @@ coverage/
*.sqlite *.sqlite
.idea/ .idea/
.vscode/ .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: .

View File

@@ -56,7 +56,7 @@ docs/ architecture notes, ADRs, API specs
## Contracts between modules ## Contracts between modules
- **HTTP** (OpenAPI, in `packages/shared-types/http/`) — synchronous request/response. In-process today; over the network once extracted. Signatures are identical. - **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. Schema registry enforced in CI when #54 lands; until then payloads are JSON envelopes (ADR-0005).
- Do not redefine types per module. Regenerate from `shared-types`. - Do not redefine types per module. Regenerate from `shared-types`.
## Conventions ## Conventions
@@ -65,7 +65,7 @@ docs/ architecture notes, ADRs, API specs
- One PR = one concern. Conventional-commit prefixes (`feat:`, `fix:`, `chore:`, `docs:`, `refactor:`). - 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. - 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). - 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), `mlops` (adds MLflow + Airflow), `ai` (adds Ollama + LiteLLM). Mix as needed.
## Definition of done (per feature) ## Definition of done (per feature)
@@ -76,15 +76,39 @@ docs/ architecture notes, ADRs, API specs
5. Deployable via `docker compose up` locally. 5. Deployable via `docker compose up` locally.
6. If it touches user data → a deletion path exists and is tested. 6. If it touches user data → a deletion path exists and is tested.
## AI stack
oO generates tips with an LLM and ranks them with a bandit. All LLM calls route through **LiteLLM** at `llm.alogins.net` using model aliases — swapping models is a config change, not a code change.
| Alias | Model | Used by |
|-------|-------|---------|
| `tip-generator` | qwen2.5:1.5b (default) | `ml/serving` tip generation |
| `embedder` | nomic-embed-text | task clustering, 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.
**LLM tip generation pipeline:**
1. `ml/features/context.py` assembles user signals → structured prompt context
2. `POST /generate` in `ml/serving` calls LiteLLM → returns `TipCandidate[]`
3. Bandit policy in `ml/serving` scores + ranks candidates
4. Best candidate returned as tip; reaction closes the online reward loop
## Current phase ## 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. 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`.
Active work: AI tip generation pipeline — issues #86#93 in M2 milestone.
## What NOT to do ## What NOT to do
- Don't copy Todoist's data into our DB. Store the OAuth token + computed features/derivatives we need, fetch raw on demand. - Don't copy Todoist's data into our DB. Store the OAuth token + computed features/derivatives we need, fetch raw on demand.
- Don't implement auth by hand. Phase 0 uses **Auth.js** behind an OIDC-shaped boundary (ADR-0004); swap to a dedicated OIDC provider only when mobile ships. - Don't implement auth by hand. Auth.js behind an OIDC-shaped boundary (ADR-0004); swap to a dedicated OIDC provider only when mobile ships.
- Don't hardwire a recommender. The "random todo" v0 must live behind the same interface the real ML model will implement (`POST /recommend``{tip}`). Swap internals, keep contract. - Don't hardwire a recommender. The contract is `POST /recommend → {tip}`. Swap internals (bandit, LLM, hybrid), keep contract.
- Don't replace a policy in one step. New policies deploy shadow-first; promoted only after offline + online agreement with the incumbent (ADR-0002). - Don't replace a policy in one step. New policies deploy shadow-first; promoted only after offline + online agreement with the incumbent (ADR-0002).
- Don't 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 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/Airflow/OpenWebUI in the admin panel. They are external services; link out to them. The admin shell links to `o.alogins.net/mlflow`, `/airflow`, `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.

201
README.md
View File

@@ -67,55 +67,192 @@ docs/ architecture, adr, api
--- ---
## AI stack
oO is AI-native: the recommender's job is to **rank**, not to write. An LLM generates candidate tips from the user's context; the bandit picks the best one.
### 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 (Phase 2 target)
```
User signals ──▶ Context assembler ──▶ LiteLLM ──▶ Ollama (local)
(tasks, calendar, (ml/features/) (routing) or cloud fallback
patterns, time)
N typed TipCandidates
{content, kind, model,
prompt_version, confidence}
Bandit policy (ml/serving)
scores + ranks candidates
Best tip shown
User reaction (done / snooze / dismiss + dwell)
Online bandit update + prompt_version tracking
```
**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)
| Alias | Model | Task |
|-------|-------|------|
| `tip-generator` | qwen2.5:7b (default) | Generate typed tip candidates from user context |
| `embedder` | nomic-embed-text | Task clustering, semantic similarity for dedup |
| `judge` | claude-haiku-4-5 (cloud, eval-only) | Offline sim judge; rates tip quality for A/B |
---
## Roadmap ## Roadmap
### Phase 0 — Walking skeleton *(M0)* ### Phase 0 — Walking skeleton *(M0)* ✓ shipped
Goal: a single user signs in with Google, connects Todoist, and sees one random Todoist task on a black page. Deletion works. 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 - [x] Monorepo scaffold, docker-compose dev env
- [ ] `auth` on Auth.js with Google provider; OIDC-shaped boundary (ADR-0004) - [x] `auth` — Google OAuth2/PKCE via openid-client v6; session cookie; Next.js middleware guard
- [ ] `integrations/todoist` OAuth2 flow + encrypted token vault + provider-side revocation - [x] `integrations/todoist` OAuth2 flow, token stored in DB, disconnect supported
- [ ] `recommender` with `RandomPolicy`; stable `POST /recommend` contract - [x] `recommender` with `RandomPolicy`; stable `POST /recommend` contract; 30s task cache
- [ ] `apps/web`three pages (sign-in, connect, tip); PWA manifest; offline reaction queue - [x] `apps/web` — sign-in, connect, tip pages; PWA manifest + icons
- [ ] ToS + Privacy Policy + consent capture on first sign-in - [x] Feedback: `done / snooze / dismiss`; reward inferred from dwell-time (`inferReward`); marks task complete in Todoist
- [ ] Account-deletion endpoint: revokes providers, purges credentials, soft-deletes profile - [x] Deploy modular monolith to Agap VM via Caddy at `o.alogins.net`
- [ ] Metrics baseline: activation, first-tip reaction rate, dwell, retention (see `docs/architecture/metrics.md`) - [x] ToS + Privacy Policy pages (`/legal/terms`, `/legal/privacy`); implicit consent on sign-in
- [ ] Deploy modular monolith + `ml/serving` stub to a single VM via docker-compose + Caddy - [x] Account deletion: revokes tokens, purges data, soft-deletes profile; button on /connect
- [x] Metrics baseline: `tip_views` table (tip served) + `tip_feedback` (reactions) — activation + reaction rate queryable
### Phase 1 — Real signal + in-the-moment delivery *(M1)* ### Phase 1 — Real signal + in-the-moment delivery *(M1)* ✓ shipped
Goal: tips are picked, not drawn from a hat — and they arrive at the right moment on the web. 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 - [x] Event bus scaffold: typed in-process EventEmitter with 500-event ring buffer; subjects match future NATS JetStream — swap is mechanical
- [ ] Todoist event-driven sync (emit `signals.task.*`) - [x] Todoist sync emits `signals.task.synced`; tip served/feedback emit `signals.tip.*`
- [ ] Feature store skeleton + first five features (hour-of-day, overdue count, task age, priority, project) - [x] Features extracted per task: `is_overdue`, `task_age_days`, `priority`; context: `hour_of_day`, `day_of_week`
- [ ] `ml/serving` FastAPI scorer; `RemotePolicy` wrapper in recommender - [x] `ml/serving` LinUCB (d=5) + **ε-greedy v1** (d=7, ε=0.10, day-of-week sin/cos features); per-user state persisted to disk
- [ ] **Global-then-personalize bandit**: pooled LinUCB over shared features, per-user residual when data allows - [x] `RemotePolicy` in recommender: calls ml/serving, falls back to RandomPolicy on timeout/error; logs explainability to `tip_scores`
- [ ] Shadow-deploy infra: every new policy logs what it *would* have picked; promotion requires reward-parity - [x] Feedback loop: dwell-time inferred reward (`inferReward`) → online model update; `done` in 15 s2 min = +1.0 (magic zone)
- [ ] Feedback loop: reactions → rewards; delayed rewards for tasks completed in Todoist directly - [x] Offline simulation framework (`ml/experiments/sim`): rule/LLM/claude-code judges, two-policy comparison, results persisted to `sim_runs` + `sim_events`
- [ ] **Web Push notifications** (VAPID) so the "magic" shows up without opening the app - [x] **ε-greedy v1 promoted to active policy** (ADR-0007) — +10.7% mean reward vs LinUCB in offline sim
- [ ] `notifier` (lite): web-push delivery, quiet-hours honoured, dedupe - [x] **Web Push** (VAPID): SW, subscribe/unsubscribe API, "notify me" button on tip page
- [ ] Apple OAuth added (deferred from M0) - [x] Shadow-policy registry: run N shadow policies per request, log picks without serving them (#56)
- [ ] Quiet-hours + dedupe for push delivery
- [ ] Delayed rewards: tasks completed directly in Todoist (requires webhook from Todoist)
- [x] NATS JetStream bridge — durable `signals.>` and `feedback.>` streams; in-process bus stays the source of truth, every publish bridges out (#21, shipped)
### Phase 2 — Multi-source profile & trust *(M2)* #### M1 add-on — Admin & ML Ops Console *(fully shipped)*
Goal: oO knows more than tasks, and users can see/control what we know.
- [ ] Integrations: Google Calendar, Apple Health (web import), generic webhook ingress oO is ML-heavy. Without a cockpit, every model change ships blind. This console is the team's single pane for users, signals, features, models, experiments, and tip outcomes — with the ability to *act* on them (revoke a token, replay an event, promote a model, reset a bandit).
- [ ] Unified `Profile` model (identity, preferences, contexts, consents)
- [ ] Timing signals (Page Visibility, Idle Detection, coarse location) — opt-in, transparent **Framework pick — `apps/admin` on Next.js 15 + Tremor + shadcn/ui.** Analytics-first UI for an analytics-first product, stays on our existing TS/React/Tailwind stack, reuses `packages/shared-types`, `sdk-js`, and the Auth.js session. Specialized ML tooling (MLflow, Airflow) runs as **separate external services** linked from the admin shell; Grafana panels are embedded.
- [ ] Advice library + mixing policy (todo vs advice vs ambient)
- [ ] User-facing data dashboard: what's stored, what's computed, export, delete-by-category | Layer | Tool | Why |
- [ ] Cost/usage observability |-------|------|-----|
| App shell | **Next.js 15** (new `apps/admin`) | Same stack as `apps/web`; reuses auth, types, SDK |
| Dashboards / charts | **[Tremor](https://tremor.so)** | Analytics-first React + Tailwind — KPI cards, time-series, categorical, heatmaps |
| CRUD primitives | **[shadcn/ui](https://ui.shadcn.com)** | Copy-paste Radix components; forms, dialogs, command palette |
| Heavy grids | **[TanStack Table v8](https://tanstack.com/table)** | Sortable / paginated / virtualized tables (events, users, tips) |
| Extra charts | **[Recharts](https://recharts.org)** / **[visx](https://airbnb.io/visx)** | Fallbacks where Tremor falls short (e.g. force graphs, Sankey) |
| Model registry / experiments | **[MLflow](https://mlflow.org)** *(external — `o.alogins.net/mlflow`)* | Experiment tracking, artifact browser, model registry; own basic-auth |
| Pipeline orchestration | **[Airflow](https://airflow.apache.org)** *(external — `o.alogins.net/airflow`)* | Batch feature + retraining DAGs; own web-auth |
| Infra metrics | **[Grafana](https://grafana.com)** *(embedded panels)* | One ops source of truth |
| Ad-hoc analysis | **[Marimo](https://marimo.io)** reactive notebooks | Python-native for the ML side; launch-out link |
| AuthZ | `profile.role='admin'` + Next.js middleware | Reuses existing session; no new auth surface |
**Rejected alternatives (so we don't re-litigate):**
- *Retool / AppSmith* — low-code speed, but admin logic leaves our repo; weak analytics affordances for an analytics product
- *Streamlit / Gradio / Dash* — Python-first; thin RBAC and routing; splits our frontend stack in two
- *React-admin / Refine.dev* — strong CRUD scaffolding, but analytics/ML views feel bolted on; we'd rebuild Tremor-style dashboards ourselves
- *Superset / Metabase as the admin surface* — excellent for BI, poor for operational **writes** (revoke, replay, promote). Plan: **adopt Superset in M4** for BI alongside batch pipelines; ship a read-only SQL widget inside admin for now
**Build sequence (plan, not code):**
1. [x] **ADR-0006** — record the framework choice + "embed, don't rebuild" rule for MLflow/Grafana
2. [x] **Scaffold**`apps/admin` with Next.js 15, Tailwind, Tremor; deploy behind Caddy at `admin.o.alogins.net`
3. [x] **RBAC**`role` column on `users`; admin-only Next.js middleware; seed first admin via `ADMIN_SEED_EMAIL` env; `admin_actions` audit-log table
4. [x] **Overview dashboard** — DAU/WAU KPI cards, tips served, reaction breakdown, activation funnel
5. [x] **User explorer** — list + detail page: identity, consents, integrations, last tip, reward history; revoke-integration + reset-bandit actions
6. [x] **Event stream viewer** — live tail of `signals.*` with filters by subject/user/time; same UI when the bus swaps to NATS
7. [x] **Feature store browser** — features sent to `ml/serving` per scoring call; diff across time for a user
8. [x] **Model registry panel**`/admin/models` links out to MLflow (`mlflow.o.alogins.net`); experiment tracking and dataset management in MLflow + Airflow
9. [x] **MLOps hub**`/admin/experiments` links to MLflow experiments/models and Airflow DAGs/datasets; bandit reset on Users page
10. [x] **Recommendation log (explainability)** — per served tip: `(user, features, policy, score, feedback, latency)`; `tip_scores` table, 30-day retention
11. [x] **Reward analytics** — reaction distribution over time; per-policy compare; slice by `hour_of_day`, `priority`, cohort
12. [x] **Data quality widget** — missing-feature rate, stale-token rate, daily completeness heatmap
13. [x] **Ops actions** — revoke token (Users page), replay signal, disable/promote shadow policy; every action audit-logged
14. [x] **Read-only SQL runner** — SELECT-only runner against SQLite + saved queries (sunsets to Superset in M4)
15. [x] **Health rollup**`/admin/health` surfaces api, ml/serving, SQLite, event-bus; auto-refreshes every 15s
16. [ ] **Docs**`apps/admin/README.md`, runbook for common ops actions, ADR-0006 merged
- [ ] Apple OAuth (deferred to M2)
### Phase 2 — AI tips + multi-source signals *(M2)*
Goal: tips are AI-generated from user context, not just raw Todoist tasks. Multiple signal sources feed a generalized pipeline. Research-intensive milestone.
**AI infrastructure (unblock everything else):**
- [ ] `ai` compose profile — Ollama + LiteLLM for local dev; env vars `OLLAMA_URL` / `LITELLM_URL` (#86)
- [ ] AI gateway — wire `ml/serving` to LiteLLM; model aliases `tip-generator` + `embedder` (#87)
**AI tip generation pipeline:**
- [ ] Context assembler — user signals + feature store → structured prompt context (`ml/features/context.py`) (#88)
- [ ] Tip generator endpoint — `POST /generate` in `ml/serving`; LLM → N typed `TipCandidate` objects (#79)
- [ ] `TipCandidate` shared schema — `{content, kind, source, model, prompt_version, confidence}`; update recommender pipeline (#89)
- [ ] LLM output validation + retry — JSON schema gate, clarification retry (2×), fallback to task-based (#90)
- [ ] Prompt versioning — `prompt_version` + `model` columns in `tip_scores`; content-hash invalidation (#91)
- [ ] LLM tip quality dashboard — reaction breakdown by model / prompt_version in `/admin/reward-analytics` (#92)
**Evaluation & model selection:**
- [ ] Model benchmark — compare qwen2.5:7b / llama3.2:3b / gemma3:4b via offline sim + LLM judge (#93)
- [ ] LLM prompt research — persona design, context injection strategies, few-shot examples (#84)
**Pipeline architecture:**
- [ ] Signal source abstraction — `SignalSource` interface generalizing beyond Todoist (#78)
- [ ] Generalized recommendation pipeline — candidate → rank → render stages (#80)
- [ ] Feature registry + user profile builder — centralized features, persistent profiles (#81)
- [ ] Tip kind system — task, advice, insight, reminder with kind-aware UI + rewards (#82)
**Policy research:**
- [ ] Next-gen policies — Thompson sampling, neural bandits, hybrid transfer learning (#83)
**Integrations & infra (carried from M1):**
- [ ] Apple OAuth (#7)
- [x] NATS JetStream replacing in-process bus (#21) — adapter ships in `services/api/src/events/nats.ts`; in-proc bus is the producer, JetStream is the durable mirror
- [x] Todoist sync via events (#22) — background scheduler in `services/api/src/signals/scheduler.ts` emits `signals.task.synced` every `TODOIST_SYNC_INTERVAL_MS`; on-demand fetch remains as freshness fallback
- [ ] Event schema registry + protobuf CI gate (#54)
- [ ] Per-user freshness SLAs for features (#61)
- [ ] CI skeleton (#3), observability (#18), E2E tests (#20)
**Bugs (fix before new features):**
- [ ] TipFeedback type mismatch (#73)
- [ ] Todoist token refresh (#74)
- [ ] Reward fire-and-forget (#75)
- [ ] Data retention purge (#76)
- [ ] Port mismatch (#77)
### Phase 3 — Native mobile *(M3)* ### Phase 3 — Native mobile *(M3)*
- [ ] iOS app (SwiftUI) with APNs push - [ ] iOS app (SwiftUI) with APNs push
- [ ] Android app (Compose) with FCM push - [ ] Android app (Compose) with FCM push
- [ ] `notifier` gains APNs + FCM channels, per-device rate limits - [ ] `notifier` gains APNs + FCM channels, per-device rate limits
- [ ] Migrate auth from Auth.js to dedicated OIDC provider (trigger from ADR-0004) - [ ] Migrate auth from Auth.js to dedicated OIDC provider (trigger from ADR-0004)
- [ ] Consolidate MLflow + Airflow behind shared OIDC (SSO for all internal services)
- [ ] Decide-and-deliver scheduler: per-user "is this tip worth interrupting now?" threshold - [ ] Decide-and-deliver scheduler: per-user "is this tip worth interrupting now?" threshold
### Phase 4 — MLOps at scale *(M4)* ### Phase 4 — MLOps at scale *(M4)*
- [ ] Prefect/Airflow for batch feature materialization + retraining - [x] Airflow + MLflow deployed as external services (`mlops` compose profile); each with own auth
- [ ] MLflow registry; shadow → A/B → launch pipeline as first-class - [ ] Write first retraining DAG (Airflow) + first MLflow experiment logging from `ml/serving`
- [ ] Feature-to-prompt pipeline — nightly Airflow DAG materializes context for LLM; cuts inline latency (#94)
- [ ] Prompt optimization loop — sim A/B → MLflow experiment → human-approved promotion (#95)
- [ ] LLM fine-tuning — tip reactions as training signal; LoRA on base model; MLflow tracks runs (#96)
- [ ] Embedding-based task clustering — `nomic-embed-text` for dedup + user pattern features (#97)
- [ ] Consolidate MLflow + Airflow auth into shared OIDC provider (tracked as M3 issue #85)
- [ ] Shadow → A/B → launch pipeline as first-class in MLflow
- [ ] Online experiments framework: deterministic assignment + bandit policies alongside fixed-split A/B - [ ] Online experiments framework: deterministic assignment + bandit policies alongside fixed-split A/B
- [ ] Cross-user collaborative features (opt-in only); cohort slicing; fairness checks - [ ] Cross-user collaborative features (opt-in only); cohort slicing; fairness checks
- [ ] Drift monitoring (feature drift, prediction drift, reward drift); model cards per version - [ ] Drift monitoring (feature + prediction + reward drift); model cards per LLM version
### Phase 5 — Production hardening *(M5)* ### Phase 5 — Production hardening *(M5)*
- [ ] Audit logging, rotation of provider tokens + internal signing keys - [ ] Audit logging, rotation of provider tokens + internal signing keys

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

@@ -0,0 +1,37 @@
# 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.
## Pages
| Route | Description |
|-------|-------------|
| `/` | Overview: DAU/WAU KPI cards, tips served, reaction breakdown, activation funnel |
| `/users` | User list (paginated) |
| `/users/:id` | User detail: identity, consents, integrations, tip stats, reward history; revoke-integration + reset-bandit actions |
| `/audit` | Admin action audit log |
| `/events` | Event stream viewer (stub — pending API history endpoint) |
## 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
- `@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+).

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,89 @@
'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>;
}
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>
<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,16 @@
export default function LoginPage() {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center space-y-4">
<h1 className="text-2xl font-semibold">oO Admin</h1>
<p className="text-gray-400 text-sm">Sign in via the main app first, then return here.</p>
<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>
</div>
</div>
);
}

View File

@@ -0,0 +1,114 @@
'use client';
import { useEffect, useState } from 'react';
import { AdminShell } from '@/components/AdminShell';
import { getPolicies, togglePolicy, replaySignal, PolicyInfo } from '@/lib/api';
const VALID_SUBJECTS = ['signals.tip.served', 'signals.tip.feedback', 'signals.task.synced'];
export default function OpsPage() {
const [policies, setPolicies] = useState<PolicyInfo[]>([]);
const [replaySubject, setReplaySubject] = useState(VALID_SUBJECTS[0]);
const [replayPayload, setReplayPayload] = useState('{\n "userId": "",\n "tipId": ""\n}');
const [msg, setMsg] = useState('');
const [error, setError] = useState('');
useEffect(() => {
getPolicies().then((r) => setPolicies(r.policies)).catch(() => {});
}, []);
const handleToggle = async (name: string, active: boolean) => {
try {
await togglePolicy(name, active);
setPolicies((prev) => prev.map((p) => p.name === name ? { ...p, active } : p));
setMsg(`Policy "${name}" ${active ? 'enabled' : 'disabled'}.`);
} catch (e: any) {
setError(e.message);
}
};
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">
<h1 className="text-xl font-semibold">Ops actions</h1>
{msg && <p className="text-green-400 text-sm">{msg}</p>}
{error && <p className="text-red-400 text-sm">{error}</p>}
{/* Policy toggles */}
<section className="space-y-3">
<h2 className="text-base font-medium text-gray-300">Policies</h2>
{policies.length === 0 ? (
<p className="text-gray-500 text-sm">No shadow policies registered. Shadow policies can be added to the recommender source.</p>
) : (
<div className="space-y-2">
{policies.map((p) => (
<div key={p.name} className="flex items-center justify-between bg-gray-900 border border-gray-800 rounded p-3">
<span className="text-sm text-gray-300 font-mono">{p.name}</span>
<button
onClick={() => handleToggle(p.name, !p.active)}
className={`px-3 py-1 rounded text-xs ${p.active ? 'bg-green-800 text-green-200' : 'bg-gray-800 text-gray-400'}`}
>
{p.active ? 'Active' : 'Disabled'}
</button>
</div>
))}
</div>
)}
</section>
{/* 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>
{/* User-level ops */}
<section className="space-y-3">
<h2 className="text-base font-medium text-gray-300">User-level actions</h2>
<p className="text-sm text-gray-500">
Revoke integration tokens and reset bandit state are available on the{' '}
<a href="/users" className="text-indigo-400 hover:underline">Users page</a> navigate to a user detail view.
</p>
</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,144 @@
'use client';
import { useEffect, useState } from 'react';
import { AdminShell } from '@/components/AdminShell';
import { getRewardAnalytics } 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',
};
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>
)}
{/* 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,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,118 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
const mlflowUrl = process.env.NEXT_PUBLIC_MLFLOW_URL ?? '/mlflow';
const airflowUrl = process.env.NEXT_PUBLIC_AIRFLOW_URL ?? '/airflow';
type NavItem = {
href: string;
label: string;
external?: boolean;
};
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 status',
items: [
{ href: '/tips', label: 'Tips' },
{ href: '/reward-analytics', label: 'Rewards' },
],
},
{
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 },
{ href: airflowUrl, label: 'Airflow ↗', external: true },
],
},
];
export function AdminShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
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 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'
}`;
return item.external ? (
<a
key={item.href}
href={item.href}
target="_blank"
rel="noreferrer"
className={className}
>
{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,159 @@
'use client';
import { useEffect, useState } from 'react';
import { getUserDetail, revokeIntegration, resetBandit, type AdminUserDetail } 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);
}
}
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 } = 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>
{/* 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 }: { title: string; children: React.ReactNode }) {
return (
<div className="rounded-lg border border-gray-800 bg-gray-900 px-5 py-4 space-y-2">
<p className="text-xs text-gray-500 uppercase tracking-widest font-medium mb-3">{title}</p>
{children}
</div>
);
}
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,134 @@
'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>
{['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={6} className="px-4 py-6 text-center text-gray-500">
Loading
</td>
</tr>
) : users.length === 0 ? (
<tr>
<td colSpan={6} 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">
<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>
);
}

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

@@ -0,0 +1,222 @@
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 AdminUserDetail {
user: AdminUser;
integrations: { provider: string; connectedAt: string }[];
tipsServed: number;
lastTipAt: string | null;
recentFeedback: { id: string; action: string; createdAt: string; tipId: string }[];
}
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 PolicyInfo {
name: string;
active: boolean;
}
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 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 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 }[];
}>(`/admin/reward-analytics?days=${days}`);
}
export function getDataQuality() {
return apiFetch<{
scoringCallsLast30d: number;
missingFeatureRate: number;
staleTokenRate: number;
totalTokens: number;
staleTokens: number;
dailyQuality: { date: string; total: number; withFeatures: number; avgCandidates: number }[];
}>('/admin/data-quality');
}
export function getHealth() {
return apiFetch<HealthStatus>('/admin/health');
}
export function getPolicies() {
return apiFetch<{ policies: PolicyInfo[] }>('/admin/policies');
}
export function togglePolicy(name: string, active: boolean) {
return apiFetch<{ ok: boolean }>(`/admin/policies/${name}/toggle`, {
method: 'POST',
body: JSON.stringify({ active }),
});
}
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' });
}

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

@@ -0,0 +1,119 @@
/**
* 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';
// apps/admin sits two levels below the monorepo root.
const 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 and API calls
if (pathname.startsWith('/login') || 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,179 @@
'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');
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' }}>
{/* Todoist logomark */}
<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>
{todoistConnected && (
<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,323 @@
'use client';
import { useEffect, useState, useRef, useCallback } from 'react';
import { getRecommendation, sendFeedback, getVapidPublicKey, subscribePush } from '@/lib/api';
import type { Tip } from '@oo/shared-types';
type State = 'loading' | 'tip' | 'empty' | 'actions' | 'done';
// Fade wrapper — children fade in when `visible`, fade out when not
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 [pushState, setPushState] = useState<'idle' | 'subscribed' | 'denied'>('idle');
// Fade in after state change settles
useEffect(() => {
if (state === 'loading' || state === 'done') {
setVisible(false);
} else {
const t = setTimeout(() => setVisible(true), 30);
return () => clearTimeout(t);
}
}, [state]);
const loadTip = useCallback(async () => {
setVisible(false);
setState('loading');
try {
const rec = await getRecommendation();
if (!rec) {
setState('empty');
return;
}
setTip(rec.tip);
setState('tip');
} catch (err: any) {
console.error('[tip] loadTip error', err?.status, err?.message);
setState('empty');
}
}, []);
useEffect(() => { loadTip(); }, [loadTip]);
// Check existing push permission on mount
useEffect(() => {
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
setPushState('subscribed');
} else if (typeof Notification !== 'undefined' && 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'); }
}, []);
const react = async (action: 'done' | 'dismiss' | 'snooze' | 'helpful' | 'not_helpful') => {
if (!tip) return;
const isNavigating = ['done', 'dismiss', 'snooze'].includes(action);
if (isNavigating) {
setVisible(false);
setState('done');
} else {
setState('tip');
}
await sendFeedback(tip.id, { action });
if (isNavigating) setTimeout(() => loadTip(), 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 — breathes while loading */}
<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>
{pushState === 'idle' && (
<button
onClick={(e) => { e.stopPropagation(); requestPush(); }}
style={{
marginTop: '2.5rem',
background: 'transparent',
border: 'none',
color: 'rgba(255,255,255,0.18)',
fontSize: '0.65rem',
letterSpacing: '0.12em',
textTransform: 'uppercase',
cursor: 'pointer',
padding: 0,
}}
>
notify me
</button>
)}
</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)',
animation: 'none',
}}
/>
<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',
transform: 'translateY(0)',
transition: 'transform 0.3s ease',
}}>
{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="Helpful" onClick={() => react('helpful')} />
<ActionButton label="Not helpful" onClick={() => react('not_helpful')} />
<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>
</>
)}
</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,131 @@
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';
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('clicking "Helpful" calls sendFeedback with action=helpful (non-navigating)', async () => {
await renderTipAndHold('tip:help', 'Helpful tip');
await act(async () => { fireEvent.click(screen.getByText('Helpful')); });
expect(mockSendFeedback).toHaveBeenCalledWith('tip:help', { action: 'helpful' });
});
});

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

@@ -0,0 +1,83 @@
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(): Promise<RecommendResponse | null> {
try {
return await apiFetch<RecommendResponse>('/recommend', { method: 'POST' });
} 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 }),
});
}

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,62 @@
# 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
- **Airflow** → `https://o.alogins.net/airflow` — batch pipeline orchestration, dataset management; own web-auth for now
- **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. The `/experiments` and `/models` admin pages are hub pages with direct links to the relevant MLflow/Airflow views.
### 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) and Airflow (`o.alogins.net/airflow*` → port 8080) are path-based routes in the existing `o.alogins.net` Caddy block, started via `docker compose --profile mlops up`.
- Each service manages its own auth (MLflow: built-in basic-auth; Airflow: built-in web UI auth). M3 will consolidate both behind the shared OIDC provider.
- The `NEXT_PUBLIC_MLFLOW_URL` and `NEXT_PUBLIC_AIRFLOW_URL` build args in `Dockerfile.admin` default to the production URLs; 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
Accepted — 2026-04-16
## 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

@@ -15,7 +15,7 @@
| `auth` | TS | OAuth (Google; Apple in M1), sessions, JWT | identities, sessions | Node monolith | | `auth` | TS | OAuth (Google; Apple in M1), sessions, JWT | identities, sessions | Node monolith |
| `profile` | TS | user profile, preferences, consents | profiles | Node monolith | | `profile` | TS | user profile, preferences, consents | profiles | Node monolith |
| `integrations` | TS | third-party connectors, token vault, signal fetch | credentials, cursors | 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 | | `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) | | `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** | | `ml/serving` | Python | online scoring for policies/models | — (stateless) | **separate process** |
@@ -46,21 +46,44 @@ 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. - **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. - **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). - **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`.
- **Airflow** for batch pipelines; deployed at `o.alogins.net/airflow`.
- **Auth.js** embedded behind an OIDC-shaped boundary (ADR-0004). Swap to a standalone OIDC provider when mobile ships. - **Auth.js** embedded behind an OIDC-shaped boundary (ADR-0004). Swap to a standalone OIDC provider when mobile ships.
- **k3s** as the first step beyond docker-compose — no "compose → full k8s" cliff. - **k3s** as the first step beyond docker-compose — no "compose → full k8s" cliff.
## 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 (Phase 2 target)
``` ```
client ─► gateway ─► recommender client ─► gateway ─► recommender (TS)
├─► candidates: integrations.fetchCandidates(user) + advice.library
├─► context: FeatureAssembler(user, request) ml/serving (Python)
├─► policy: PolicyRegistry.get(policyName).pick(candidates, context)
├─► shadows: run shadow policies in parallel, log their picks ├─► context: ml/features/context.py
└─► persist: TipInstance{context_snapshot, policy, tip} (tasks + reactions + time patterns → prompt)
◄─ tip
├─► generate: LiteLLM → Ollama
│ → N TipCandidates {content, kind, model, prompt_version}
├─► score: bandit policy scores each candidate
├─► shadows: shadow policies log picks without serving
└─► persist: tip_scores {candidate, policy, features, latency}
◄─ best TipCandidate
``` ```
Feedback travels back the same path: `POST /feedback → events.emit(feedback.reaction)` → pipelines consume → bandit/model updated on next retrain. **Phase 1 (shipped M1):** candidates come from Todoist task list, no LLM. The bandit scores tasks directly.
**Phase 2 (shipped M2):** LLM candidates are generated in parallel with Todoist fetch. Both pools are merged, scored by the bandit, and the winner served. `tip_scores` tracks `prompt_version`, `llm_model`, and `tip_kind` for every row.
Feedback: `POST /feedback → events.emit(reaction)` → online bandit update + `prompt_version` tracked for A/B analysis.

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,32 @@
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/admin/package.json ./apps/admin/
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/admin/node_modules ./apps/admin/node_modules
COPY tsconfig.base.json ./
COPY packages/shared-types ./packages/shared-types
COPY apps/admin ./apps/admin
RUN pnpm --filter @oo/shared-types build
ARG NEXT_PUBLIC_MLFLOW_URL=/mlflow
ARG NEXT_PUBLIC_AIRFLOW_URL=/airflow
ENV NEXT_TELEMETRY_DISABLED=1 \
NEXT_PUBLIC_MLFLOW_URL=$NEXT_PUBLIC_MLFLOW_URL \
NEXT_PUBLIC_AIRFLOW_URL=$NEXT_PUBLIC_AIRFLOW_URL
RUN pnpm --filter @oo/admin build
FROM node:22-alpine AS runner
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=3080
WORKDIR /app
COPY --from=builder /app/apps/admin/.next/standalone ./
COPY --from=builder /app/apps/admin/.next/static ./apps/admin/.next/static
CMD ["node", "apps/admin/server.js"]

View File

@@ -0,0 +1,32 @@
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 services/api/package.json ./services/api/
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/services/api/node_modules ./services/api/node_modules
COPY tsconfig.base.json ./
COPY packages/shared-types ./packages/shared-types
COPY services/api ./services/api
RUN pnpm --filter @oo/shared-types build
RUN pnpm --filter @oo/api build
FROM node:22-alpine AS runner
WORKDIR /app
RUN npm install -g pnpm
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./
COPY packages/shared-types/package.json ./packages/shared-types/
COPY services/api/package.json ./services/api/
RUN pnpm install --prod --frozen-lockfile
COPY --from=builder /app/packages/shared-types/dist ./packages/shared-types/dist
COPY --from=builder /app/services/api/dist ./services/api/dist
WORKDIR /app/services/api
CMD ["node", "dist/index.js"]

View File

@@ -0,0 +1,6 @@
FROM python:3.12-slim
WORKDIR /app
COPY ml/serving/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY ml/serving/main.py .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -0,0 +1,29 @@
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 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,206 @@
name: oo
services:
# ── core profile ──────────────────────────────────────────────────────────
api:
build:
context: ../..
dockerfile: infra/docker/Dockerfile.api
profiles: [core, full]
env_file: ../../.env.local
environment:
NODE_ENV: production
volumes:
- /mnt/ssd/dbs/oo:/mnt/ssd/dbs/oo
ports:
- "127.0.0.1:3078:3078"
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3078/health"]
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: ""
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}
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
# ── mlops profile — MLflow + Airflow ──────────────────────────────────────
# Start: docker compose --profile mlops up
# MLflow UI: http://localhost:5000 or https://o.alogins.net/mlflow (admin / password — change via basic_auth.ini)
# Airflow UI: http://localhost:8080/airflow or https://o.alogins.net/airflow (admin / AIRFLOW_ADMIN_PASSWORD)
# Caddy routes /mlflow* and /airflow* inside the o.alogins.net block
airflow-db:
image: postgres:16-alpine
profiles: [mlops]
environment:
POSTGRES_DB: airflow
POSTGRES_USER: airflow
POSTGRES_PASSWORD: ${AIRFLOW_DB_PASSWORD:-airflow}
volumes:
- /mnt/ssd/dbs/oo/airflow-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U airflow"]
interval: 10s
timeout: 5s
retries: 5
airflow-init:
image: apache/airflow:2.9.3
profiles: [mlops]
entrypoint: /bin/bash
command:
- -c
- |
airflow db migrate
airflow users create \
--username admin \
--firstname Admin \
--lastname User \
--role Admin \
--email admin@oo.local \
--password "$${AIRFLOW_ADMIN_PASSWORD:-admin}"
environment:
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://airflow:${AIRFLOW_DB_PASSWORD:-airflow}@airflow-db/airflow
AIRFLOW__CORE__EXECUTOR: LocalExecutor
AIRFLOW__WEBSERVER__SECRET_KEY: ${AIRFLOW_SECRET_KEY:-change-me-in-prod}
AIRFLOW__WEBSERVER__BASE_URL: ${AIRFLOW_BASE_URL:-https://o.alogins.net/airflow}
depends_on:
airflow-db:
condition: service_healthy
restart: "no"
airflow-webserver:
image: apache/airflow:2.9.3
profiles: [mlops]
command: webserver
environment:
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://airflow:${AIRFLOW_DB_PASSWORD:-airflow}@airflow-db/airflow
AIRFLOW__CORE__EXECUTOR: LocalExecutor
AIRFLOW__WEBSERVER__SECRET_KEY: ${AIRFLOW_SECRET_KEY:-change-me-in-prod}
AIRFLOW__CORE__FERNET_KEY: ${AIRFLOW_FERNET_KEY:-}
AIRFLOW__WEBSERVER__BASE_URL: ${AIRFLOW_BASE_URL:-https://o.alogins.net/airflow}
volumes:
- ../../ml/pipelines:/opt/airflow/dags:ro
ports:
- "127.0.0.1:8080:8080"
depends_on:
airflow-init:
condition: service_completed_successfully
healthcheck:
test: ["CMD", "curl", "--fail", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
airflow-scheduler:
image: apache/airflow:2.9.3
profiles: [mlops]
command: scheduler
environment:
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://airflow:${AIRFLOW_DB_PASSWORD:-airflow}@airflow-db/airflow
AIRFLOW__CORE__EXECUTOR: LocalExecutor
AIRFLOW__CORE__FERNET_KEY: ${AIRFLOW_FERNET_KEY:-}
volumes:
- ../../ml/pipelines:/opt/airflow/dags:ro
depends_on:
airflow-init:
condition: service_completed_successfully
# ── 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:v2.14.3
profiles: [mlops]
command: >
mlflow server
--backend-store-uri sqlite:////mlflow/mlflow.db
--default-artifact-root /mlflow/artifacts
--host 0.0.0.0
--port 5000
--app-name basic-auth
--static-prefix /mlflow
environment:
MLFLOW_AUTH_CONFIG_PATH: /mlflow/basic_auth.ini
volumes:
- /mnt/ssd/dbs/oo/mlflow:/mlflow
- ../../infra/mlflow/basic_auth.ini:/mlflow/basic_auth.ini:ro
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/health',timeout=3).status==200 else 1)"]
interval: 10s
timeout: 5s
retries: 5

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,8 +4,8 @@ Python. Owns models, features, training, online scoring.
| Dir | Role | Phase | | Dir | Role | Phase |
|---|---|---| |---|---|---|
| `serving/` | FastAPI online scorer (`/score`), called by `recommender` | 1 | | `serving/` | FastAPI online scorer (`/score`, `/generate`) + LiteLLM gateway, called by `recommender` | 12 |
| `features/` | feature definitions + store adapter (Feast later) | 1 | | `features/` | context assembler (`context.py`): signals → `PromptContext`; Feast adapter later | 2 |
| `pipelines/` | batch feature + training DAGs (Prefect/Airflow) | 4 | | `pipelines/` | batch feature + training DAGs (Prefect/Airflow) | 4 |
| `registry/` | MLflow-backed model registry integration | 4 | | `registry/` | MLflow-backed model registry integration | 4 |
| `experiments/` | A/B assignment + multi-armed bandit policies | 4 | | `experiments/` | A/B assignment + multi-armed bandit policies | 4 |

View File

@@ -0,0 +1,204 @@
"""
LLM-based user reaction judge.
Uses Claude Haiku when ANTHROPIC_API_KEY is set; falls back to a
deterministic persona-based rule when it is not.
"""
from __future__ import annotations
import os
import random
from personas import Persona
ACTIONS = ["done", "snooze", "dismiss"]
# Reward is NOT a fixed map anymore — it depends on action + simulated dwell time.
# Use infer_reward() to compute the final reward after simulating dwell.
_BASE_REWARDS: dict[str, float] = {
"done": 1.0, # placeholder; real reward computed from dwell
"snooze": 0.1,
"dismiss": -1.0,
}
def infer_reward(action: str, dwell_ms: int) -> float:
"""Mirror of production inferReward() in recommender.ts."""
if action == "dismiss":
return -1.0
if action == "snooze":
return 0.1
# done — dwell-based
if dwell_ms < 15_000:
return -0.3 # stale / reflex done
if dwell_ms < 120_000:
return 1.0 # magic zone
if dwell_ms < 600_000:
return 0.6 # good
return 0.3 # eventually done
_HOUR_PERIODS = {
(5, 10): "morning",
(10, 14): "midday",
(14, 18): "afternoon",
(18, 22): "evening",
}
def _period(hour: int) -> str:
for (lo, hi), name in _HOUR_PERIODS.items():
if lo <= hour < hi:
return name
return "night"
# ── Deterministic judge ────────────────────────────────────────────────────
def _engagement_score(persona: Persona, tip: dict, hour: int) -> float:
"""01 score of how well this tip fits this persona right now."""
features = tip.get("features", {})
priority = features.get("priority", 1)
is_overdue = features.get("is_overdue", False)
p = 0.35
priority_norm = (priority - 1) / 3.0
p += (priority_norm - 0.5) * persona.prefers_high_priority * 0.4
if is_overdue:
p += (persona.prefers_overdue - 0.5) * 0.3
is_morning = 5 <= hour < 10
is_evening = 18 <= hour < 22
if persona.morning_active and is_morning:
p += 0.15
elif persona.evening_active and is_evening:
p += 0.15
elif persona.morning_active and not is_morning and not is_evening:
p -= 0.10
elif persona.evening_active and not is_evening and not is_morning:
p -= 0.10
return max(0.05, min(0.90, p))
def _simulate_dwell_ms(engagement: float, rng: random.Random) -> int:
"""
Simulate how many milliseconds the user takes to act on a tip.
High engagement → quick action (magic zone, 15s2min).
Medium engagement → slower (210min).
Low engagement → very slow (>10min) — tip helped eventually but not 'magic'.
For snooze/dismiss the dwell doesn't affect reward; return a short value.
"""
if engagement >= 0.70:
# Strong match — magic zone: 15s90s
return rng.randint(15_000, 90_000)
elif engagement >= 0.50:
# Moderate match — good zone: 28min
return rng.randint(120_000, 480_000)
else:
# Weak match but still done — eventually: 1030min
return rng.randint(600_000, 1_800_000)
def _rule_judge(persona: Persona, tip: dict, hour: int, rng: random.Random) -> tuple[str, int]:
"""Return (action, dwell_ms) based on persona preferences and task features."""
engagement = _engagement_score(persona, tip, hour)
r = rng.random()
if r < engagement * 0.55:
# done — dwell depends on engagement
dwell = _simulate_dwell_ms(engagement, rng)
return "done", dwell
elif r < engagement:
return "snooze", rng.randint(3_000, 20_000)
else:
return "dismiss", rng.randint(1_000, 5_000)
# ── LLM judge ─────────────────────────────────────────────────────────────
_anthropic_client = None
def _get_client():
global _anthropic_client
if _anthropic_client is None:
try:
import anthropic # type: ignore
key = os.environ.get("ANTHROPIC_API_KEY", "")
if key:
_anthropic_client = anthropic.Anthropic(api_key=key)
except ImportError:
pass
return _anthropic_client
def _llm_judge(
persona: Persona, tip: dict, hour: int, day_of_week: int, rng: random.Random,
) -> tuple[str, int]:
client = _get_client()
if client is None:
return _rule_judge(persona, tip, hour, rng)
features = tip.get("features", {})
priority = features.get("priority", 1)
is_overdue = features.get("is_overdue", False)
age_days = features.get("task_age_days", 0)
priority_label = {1: "low", 2: "normal", 3: "high", 4: "urgent"}.get(priority, "normal")
overdue_str = f", overdue by {age_days:.0f} day(s)" if is_overdue else ""
days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
day_str = days[day_of_week % 7]
prompt = (
f"You are simulating how a specific user reacts to a task recommendation app.\n\n"
f"User persona: {persona.name}\n"
f"Persona: {persona.description}\n\n"
f'Recommended task: "{tip.get("content", "Unknown task")}"\n'
f"Task: priority={priority_label}{overdue_str}\n"
f"Current time: {_period(hour)} ({hour}:00, {day_str})\n\n"
f"How does this user react? Reply with exactly one word: done | snooze | dismiss\n\n"
f"- done: acts on this tip (marks task complete)\n"
f"- snooze: acknowledges but not now\n"
f"- dismiss: ignores or rejects it"
)
try:
message = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=10,
messages=[{"role": "user", "content": prompt}],
)
raw = message.content[0].text.strip().lower().split()[0]
action = raw if raw in ACTIONS else _rule_judge(persona, tip, hour, rng)[0]
except Exception:
action, _ = _rule_judge(persona, tip, hour, rng)
# Simulate dwell based on engagement level
engagement = _engagement_score(persona, tip, hour)
dwell = _simulate_dwell_ms(engagement, rng) if action == "done" else rng.randint(2_000, 15_000)
return action, dwell
# ── Public API ─────────────────────────────────────────────────────────────
def judge(
persona: Persona,
tip: dict,
hour: int,
day_of_week: int,
rng: random.Random,
use_llm: bool = True,
) -> tuple[str, int, float]:
"""Return (action, dwell_ms, reward).
action — 'done' | 'snooze' | 'dismiss'
dwell_ms — simulated milliseconds between tip appearance and user action
reward — inferred from action + dwell_ms via infer_reward()
"""
if use_llm and os.environ.get("ANTHROPIC_API_KEY"):
action, dwell_ms = _llm_judge(persona, tip, hour, day_of_week, rng)
else:
action, dwell_ms = _rule_judge(persona, tip, hour, rng)
return action, dwell_ms, infer_reward(action, dwell_ms)

View File

@@ -0,0 +1,79 @@
"""Synthetic user personas for simulation."""
from dataclasses import dataclass
@dataclass
class Persona:
name: str
description: str
# Feature preference weights — used by deterministic judge
prefers_high_priority: float # 01: scales response to priority
prefers_overdue: float # 01: scales response to overdue tasks
morning_active: bool # higher engagement hours 610
evening_active: bool # higher engagement hours 1822
recency_bias: float # 01: prefers recently-due tasks
PERSONAS: list[Persona] = [
Persona(
name="deadline-driven",
description=(
"Responds urgently to overdue and high-priority tasks. "
"Most active in the morning. Dismisses low-priority tips."
),
prefers_high_priority=0.9,
prefers_overdue=0.85,
morning_active=True,
evening_active=False,
recency_bias=0.3,
),
Persona(
name="evening-relaxed",
description=(
"Reviews tasks in the evenings. Neutral on priority. "
"Snoozes morning recommendations."
),
prefers_high_priority=0.5,
prefers_overdue=0.4,
morning_active=False,
evening_active=True,
recency_bias=0.5,
),
Persona(
name="low-priority-first",
description=(
"Clears small tasks first. Snoozes urgent items until deadline. "
"Morning person."
),
prefers_high_priority=0.2,
prefers_overdue=0.6,
morning_active=True,
evening_active=False,
recency_bias=0.7,
),
Persona(
name="consistent-responder",
description=(
"Engages consistently across hours and days. "
"Acts on helpful tips regardless of priority."
),
prefers_high_priority=0.6,
prefers_overdue=0.6,
morning_active=True,
evening_active=True,
recency_bias=0.5,
),
Persona(
name="overdue-ignorer",
description=(
"Avoids overdue tasks (stress avoidance). "
"Focuses on future-due, high-priority items. Evening person."
),
prefers_high_priority=0.8,
prefers_overdue=0.1,
morning_active=False,
evening_active=True,
recency_bias=0.2,
),
]

View File

@@ -0,0 +1,527 @@
"""
oO simulation runner — compares two recommendation policies.
Judge modes:
rule Deterministic persona-based rules (default, no external deps)
llm Claude Haiku via Anthropic API (requires ANTHROPIC_API_KEY)
claude-code Two-phase: Claude Code acts as the judge (you are the judge)
Usage — rule/llm (single pass):
python runner.py --n-users 5 --n-rounds 10 --no-llm
python runner.py --n-users 5 --n-rounds 10
Usage — claude-code judge (two phases):
# Phase 1: score candidates, write judgment requests
python runner.py --judge claude-code --phase score \\
--n-users 5 --n-rounds 10 --out /tmp/oo-cc-sim.json
# (Claude Code reads /tmp/oo-cc-sim-requests.json and writes /tmp/oo-cc-sim-responses.json)
# Phase 2: apply responses, run rewards, produce results
python runner.py --judge claude-code --phase reward --plan /tmp/oo-cc-sim-plan.json \\
--out /tmp/oo-cc-sim.json
"""
from __future__ import annotations
import argparse
import json
import random
import sys
import time
import uuid
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
import httpx
from llm_judge import ACTIONS, infer_reward, judge
from personas import PERSONAS, Persona
from task_generator import generate_task_pool
POLICY_SCORE_ENDPOINTS: dict[str, str] = {
"linucb-v1": "/score",
"egreedy-v1": "/score/egreedy",
}
POLICY_REWARD_ENDPOINTS: dict[str, str] = {
"linucb-v1": "/reward",
"egreedy-v1": "/reward/egreedy",
}
def _call_score(
client: httpx.Client, ml_url: str, policy: str,
user_id: str, tasks: list[dict], hour: int, dow: int,
) -> dict | None:
endpoint = POLICY_SCORE_ENDPOINTS.get(policy, "/score")
body = {
"user_id": user_id,
"candidates": [
{
"id": t["id"], "content": t["content"], "source": t["source"],
"source_id": None,
"features": {
"hour_of_day": hour,
"is_overdue": t["features"]["is_overdue"],
"task_age_days": t["features"]["task_age_days"],
"priority": t["features"]["priority"],
},
}
for t in tasks
],
"context": {"hour_of_day": hour, "day_of_week": dow},
}
try:
r = client.post(f"{ml_url}{endpoint}", json=body, timeout=5.0)
r.raise_for_status()
return r.json()
except Exception as e:
print(f" [warn] score {policy}: {e}", file=sys.stderr)
return None
def _call_reward(
client: httpx.Client, ml_url: str, policy: str,
user_id: str, tip_id: str, reward: float, features: dict,
day_of_week: int = 0,
) -> None:
endpoint = POLICY_REWARD_ENDPOINTS.get(policy, "/reward")
try:
client.post(
f"{ml_url}{endpoint}",
json={"user_id": user_id, "tip_id": tip_id, "reward": reward,
"features": features, "day_of_week": day_of_week},
timeout=5.0,
)
except Exception as e:
print(f" [warn] reward {policy}: {e}", file=sys.stderr)
# ── Standard single-pass runner (rule / llm modes) ─────────────────────────
def run_simulation(
n_users: int, n_rounds: int, tasks_per_round: int,
ml_url: str, policies: list[str], use_llm: bool, seed: int,
) -> dict:
rng = random.Random(seed)
run_id = str(uuid.uuid4())[:8]
started_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
user_personas = [
(f"sim-{run_id}-u{i}", PERSONAS[i % len(PERSONAS)])
for i in range(n_users)
]
acc: dict[str, dict] = {
p: {
"total_reward": 0.0, "n_pulls": 0,
"cumulative_rewards": [],
"action_counts": {a: 0 for a in ACTIONS},
}
for p in policies
}
events: list[dict] = []
with httpx.Client(trust_env=False) as client:
for rnd in range(n_rounds):
hour = rng.randint(6, 22)
dow = rng.randint(0, 6)
round_rewards = {p: 0.0 for p in policies}
for user_id, persona in user_personas:
seed_tasks = rnd * 997 + abs(hash(user_id)) % 997
tasks = generate_task_pool(n=tasks_per_round, seed=seed_tasks)
for policy in policies:
p_user = f"{user_id}-{policy}"
scored = _call_score(client, ml_url, policy, p_user, tasks, hour, dow)
if not scored:
continue
tip_id = scored.get("tip_id")
tip = next((t for t in tasks if t["id"] == tip_id), None)
if not tip:
continue
action, dwell_ms, reward = judge(persona, tip, hour, dow, rng, use_llm=use_llm)
_call_reward(client, ml_url, policy, p_user, tip_id, reward, {
"hour_of_day": hour,
"is_overdue": tip["features"]["is_overdue"],
"task_age_days": tip["features"]["task_age_days"],
"priority": tip["features"]["priority"],
}, day_of_week=dow)
acc[policy]["total_reward"] += reward
acc[policy]["n_pulls"] += 1
acc[policy]["action_counts"][action] += 1
round_rewards[policy] += reward
events.append({
"round": rnd, "user_id": user_id, "persona": persona.name,
"policy": policy, "tip_content": tip["content"],
"priority": tip["features"]["priority"],
"is_overdue": tip["features"]["is_overdue"],
"action": action, "dwell_ms": dwell_ms, "reward": reward,
"hour": hour, "day_of_week": dow,
})
for p in policies:
prev = acc[p]["cumulative_rewards"][-1] if acc[p]["cumulative_rewards"] else 0.0
acc[p]["cumulative_rewards"].append(prev + round_rewards[p])
mode = "llm" if use_llm else "rule"
print(f" Round {rnd+1:>3}/{n_rounds} [{mode}] " + " ".join(
f"{p}={acc[p]['cumulative_rewards'][-1]:+.2f}" for p in policies
))
return _build_result(run_id, started_at, policies, acc, events,
n_users, n_rounds, tasks_per_round, use_llm, seed)
# ── Claude Code judge — phase 1: score ─────────────────────────────────────
def run_score_phase(
n_users: int, n_rounds: int, tasks_per_round: int,
ml_url: str, policies: list[str], seed: int, out_path: str,
) -> None:
"""Score all candidates and write judgment requests for Claude Code."""
rng = random.Random(seed)
run_id = str(uuid.uuid4())[:8]
started_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
user_personas = [
(f"sim-{run_id}-u{i}", PERSONAS[i % len(PERSONAS)])
for i in range(n_users)
]
plan_rounds: list[dict] = []
judgment_requests: list[dict] = []
print(f"[Phase 1] Scoring {n_rounds} rounds × {n_users} users × {len(policies)} policies…")
with httpx.Client(trust_env=False) as client:
for rnd in range(n_rounds):
hour = rng.randint(6, 22)
dow = rng.randint(0, 6)
round_sessions: list[dict] = []
for user_id, persona in user_personas:
seed_tasks = rnd * 997 + abs(hash(user_id)) % 997
tasks = generate_task_pool(n=tasks_per_round, seed=seed_tasks)
for policy in policies:
p_user = f"{user_id}-{policy}"
scored = _call_score(client, ml_url, policy, p_user, tasks, hour, dow)
if not scored:
continue
tip_id = scored.get("tip_id")
tip = next((t for t in tasks if t["id"] == tip_id), None)
if not tip:
continue
req_id = f"r{rnd}_{user_id.split('-')[-1]}_{policy}"
round_sessions.append({
"req_id": req_id,
"p_user": p_user,
"policy": policy,
"user_id": user_id,
"persona_name": persona.name,
"tip_id": tip_id,
"tip_features": tip["features"],
"tip_content": tip["content"],
"ml_score": scored.get("score"),
})
judgment_requests.append({
"id": req_id,
"round": rnd,
"hour": hour,
"day_of_week": dow,
"policy": policy,
"persona_name": persona.name,
"persona_description": persona.description,
"tip_content": tip["content"],
"priority": tip["features"]["priority"],
"is_overdue": tip["features"]["is_overdue"],
"age_days": tip["features"]["task_age_days"],
"ml_score": scored.get("score"),
})
plan_rounds.append({
"round": rnd, "hour": hour, "dow": dow,
"sessions": round_sessions,
})
print(f" Round {rnd+1:>3}/{n_rounds}: {len(round_sessions)} sessions scored")
plan = {
"run_id": run_id,
"started_at": started_at,
"config": {
"n_users": n_users, "n_rounds": n_rounds,
"tasks_per_round": tasks_per_round, "policies": policies,
"use_llm": False, "seed": seed,
},
"user_personas": [
{"user_id": uid, "persona_name": p.name, "persona_description": p.description}
for uid, p in user_personas
],
"rounds": plan_rounds,
}
base = out_path.replace(".json", "")
plan_path = f"{base}-plan.json"
requests_path = f"{base}-requests.json"
responses_path = f"{base}-responses.json"
Path(plan_path).write_text(json.dumps(plan, indent=2))
Path(requests_path).write_text(json.dumps(judgment_requests, indent=2))
print()
print("=" * 60)
print(f"Phase 1 complete — {len(judgment_requests)} judgment requests.")
print()
print(f" Requests : {requests_path}")
print(f" Plan : {plan_path}")
print()
print('Claude Code: read the requests file, judge each tip for the persona,')
print(f'then write your responses to: {responses_path}')
print()
print('Response format: { "<id>": "<action>" | { "action": "<action>", "dwell_ms": <int> } }')
print('Valid actions: done | snooze | dismiss')
print()
print('For "done", optionally specify dwell_ms (ms between tip appearing and user acting):')
print(' { "r0_u0_linucb-v1": { "action": "done", "dwell_ms": 45000 } } # magic zone')
print(' { "r0_u0_linucb-v1": "snooze" } # plain string also ok (uses default 60s dwell for done)')
print()
print('Reward is inferred from action + dwell_ms:')
print(' dismiss → -1.0')
print(' snooze → 0.1')
print(' done < 15s → -0.3 (stale task)')
print(' done 15s2min → 1.0 (magic!)')
print(' done 210min → 0.6 (good)')
print(' done > 10min → 0.3 (eventually)')
print()
print('Then run Phase 2:')
print(f' python runner.py --judge claude-code --phase reward \\')
print(f' --plan {plan_path} --out {out_path}')
# ── Claude Code judge — phase 2: reward ────────────────────────────────────
def run_reward_phase(plan_path: str, out_path: str, ml_url: str) -> dict:
"""Apply Claude Code judgments, send reward signals, compute metrics."""
plan = json.loads(Path(plan_path).read_text())
base = plan_path.replace("-plan.json", "")
responses_path = f"{base}-responses.json"
if not Path(responses_path).exists():
print(f"ERROR: responses file not found: {responses_path}", file=sys.stderr)
sys.exit(1)
raw_responses = json.loads(Path(responses_path).read_text())
# Responses can be either { id: "action" } or { id: { action, dwell_ms } }
def _parse_response(v) -> tuple[str, int]:
if isinstance(v, dict):
return v["action"], int(v.get("dwell_ms", 60_000))
return str(v), 60_000 # plain string → assume 60s dwell for "done"
responses: dict[str, tuple[str, int]] = {k: _parse_response(v) for k, v in raw_responses.items()}
invalid = {k: v[0] for k, v in responses.items() if v[0] not in ACTIONS}
if invalid:
print(f"ERROR: invalid actions in responses: {invalid}", file=sys.stderr)
sys.exit(1)
policies: list[str] = plan["config"]["policies"]
acc: dict[str, dict] = {
p: {
"total_reward": 0.0, "n_pulls": 0,
"cumulative_rewards": [],
"action_counts": {a: 0 for a in ACTIONS},
}
for p in policies
}
events: list[dict] = []
persona_map = {u["user_id"]: u["persona_name"] for u in plan["user_personas"]}
missing_responses = 0
print(f"[Phase 2] Applying {len(responses)} judgments → reward calls…")
with httpx.Client(trust_env=False) as client:
for rnd_data in plan["rounds"]:
rnd = rnd_data["round"]
round_rewards = {p: 0.0 for p in policies}
for session in rnd_data["sessions"]:
req_id = session["req_id"]
resp = responses.get(req_id)
if not resp:
print(f" [warn] no response for {req_id}, defaulting to snooze")
action, dwell_ms = "snooze", 10_000
missing_responses += 1
else:
action, dwell_ms = resp
reward = infer_reward(action, dwell_ms)
_call_reward(
client, ml_url, session["policy"], session["p_user"],
session["tip_id"], reward,
{"hour_of_day": rnd_data["hour"], **session["tip_features"]},
day_of_week=rnd_data["dow"],
)
p = session["policy"]
acc[p]["total_reward"] += reward
acc[p]["n_pulls"] += 1
acc[p]["action_counts"][action] += 1
round_rewards[p] += reward
events.append({
"round": rnd,
"user_id": session["user_id"],
"persona": persona_map.get(session["user_id"], "?"),
"policy": p,
"tip_content": session["tip_content"],
"priority": session["tip_features"]["priority"],
"is_overdue": session["tip_features"]["is_overdue"],
"action": action,
"dwell_ms": dwell_ms,
"reward": reward,
"hour": rnd_data["hour"],
"day_of_week": rnd_data["dow"],
})
for p in policies:
prev = acc[p]["cumulative_rewards"][-1] if acc[p]["cumulative_rewards"] else 0.0
acc[p]["cumulative_rewards"].append(prev + round_rewards[p])
print(f" Round {rnd+1:>3}/{plan['config']['n_rounds']} [cc] " + " ".join(
f"{p}={acc[p]['cumulative_rewards'][-1]:+.2f}" for p in policies
))
if missing_responses:
print(f" [warn] {missing_responses} requests had no response (defaulted to snooze)")
cfg = plan["config"]
result = _build_result(
plan["run_id"], plan["started_at"], policies, acc, events,
cfg["n_users"], cfg["n_rounds"], cfg["tasks_per_round"],
use_llm=False, seed=cfg["seed"],
)
result["judge_mode"] = "claude-code"
Path(out_path).write_text(json.dumps(result, indent=2))
return result
# ── Shared result builder ───────────────────────────────────────────────────
def _build_result(
run_id: str, started_at: str, policies: list[str],
acc: dict, events: list[dict],
n_users: int, n_rounds: int, tasks_per_round: int,
use_llm: bool, seed: int,
) -> dict:
summary = {
p: {
"total_reward": acc[p]["total_reward"],
"mean_reward": (
acc[p]["total_reward"] / acc[p]["n_pulls"]
if acc[p]["n_pulls"] > 0 else 0.0
),
"n_pulls": acc[p]["n_pulls"],
"cumulative_rewards": acc[p]["cumulative_rewards"],
"action_counts": acc[p]["action_counts"],
}
for p in policies
}
winner = max(policies, key=lambda p: summary[p]["total_reward"])
persona_breakdown: dict[str, dict] = {}
for ev in events:
pname = ev["persona"]
pol = ev["policy"]
persona_breakdown.setdefault(pname, {}).setdefault(pol, {"reward": 0.0, "n": 0})
persona_breakdown[pname][pol]["reward"] += ev["reward"]
persona_breakdown[pname][pol]["n"] += 1
return {
"run_id": run_id,
"started_at": started_at,
"finished_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"config": {
"n_users": n_users, "n_rounds": n_rounds,
"tasks_per_round": tasks_per_round, "policies": policies,
"use_llm": use_llm, "seed": seed,
},
"summary": summary,
"winner": winner,
"persona_breakdown": persona_breakdown,
"events": events,
}
# ── CLI ─────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="oO simulation runner")
parser.add_argument("--judge", choices=["rule", "llm", "claude-code"], default="rule")
parser.add_argument("--phase", choices=["score", "reward"], default=None,
help="For --judge claude-code only")
parser.add_argument("--plan", default=None,
help="Plan file path (for --judge claude-code --phase reward)")
parser.add_argument("--n-users", type=int, default=5)
parser.add_argument("--n-rounds", type=int, default=20)
parser.add_argument("--tasks-per-round", type=int, default=8)
parser.add_argument("--ml-url", default="http://localhost:5001")
parser.add_argument("--policies", nargs="+", default=["linucb-v1", "egreedy-v1"])
parser.add_argument("--no-llm", action="store_true",
help="Alias for --judge rule (backwards compat)")
parser.add_argument("--seed", type=int, default=42)
parser.add_argument("--out", default=None)
args = parser.parse_args()
if args.no_llm:
args.judge = "rule"
out_path = args.out or f"/tmp/oo-sim-{int(time.time())}.json"
if args.judge == "claude-code":
if args.phase == "score":
run_score_phase(
n_users=args.n_users, n_rounds=args.n_rounds,
tasks_per_round=args.tasks_per_round, ml_url=args.ml_url,
policies=args.policies, seed=args.seed, out_path=out_path,
)
elif args.phase == "reward":
if not args.plan:
print("ERROR: --plan is required for --phase reward", file=sys.stderr)
sys.exit(1)
result = run_reward_phase(args.plan, out_path, args.ml_url)
print()
print(f"Winner : {result['winner']}")
for p, s in result["summary"].items():
print(f" {p:20s} total={s['total_reward']:+.2f} mean={s['mean_reward']:+.4f} pulls={s['n_pulls']}")
print(f"Results: {out_path}")
else:
print("ERROR: --judge claude-code requires --phase score or --phase reward",
file=sys.stderr)
sys.exit(1)
else:
use_llm = (args.judge == "llm")
print(f"oO simulation: {args.n_users} users × {args.n_rounds} rounds")
print(f"Policies : {args.policies}")
print(f"ML URL : {args.ml_url}")
print(f"Judge : {args.judge}")
print()
result = run_simulation(
n_users=args.n_users, n_rounds=args.n_rounds,
tasks_per_round=args.tasks_per_round, ml_url=args.ml_url,
policies=args.policies, use_llm=use_llm, seed=args.seed,
)
Path(out_path).write_text(json.dumps(result, indent=2))
print()
print(f"Winner : {result['winner']}")
for p, s in result["summary"].items():
print(f" {p:20s} total={s['total_reward']:+.2f} mean={s['mean_reward']:+.4f} pulls={s['n_pulls']}")
print(f"Results: {out_path}")

View File

@@ -0,0 +1,62 @@
"""Generate synthetic task pools for simulation."""
from __future__ import annotations
import random
_TEMPLATES = [
"Send weekly report to team",
"Review pull request #{n}",
"Schedule meeting with {name}",
"Update project documentation",
"Fix bug in authentication module",
"Prepare presentation for stakeholders",
"Call back {name}",
"Submit expense report",
"Review quarterly goals",
"Clean up inbox",
"Follow up on proposal to {name}",
"Complete onboarding checklist",
"Write tests for feature #{n}",
"Deploy hotfix to production",
"Respond to support ticket #{n}",
"Draft release notes",
"Update dependencies",
"Review design mockups",
"Archive old tickets",
"Check in with {name}",
]
_NAMES = ["Alice", "Bob", "Carol", "David", "Eve", "Frank", "Grace"]
def generate_task_pool(n: int = 10, seed: int | None = None) -> list[dict]:
"""Return n synthetic tasks with randomly sampled features."""
rng = random.Random(seed)
tasks = []
for i in range(n):
priority = rng.choices([1, 2, 3, 4], weights=[0.3, 0.3, 0.25, 0.15])[0]
# age_days: most tasks fresh, a few stale
age_days = rng.choices(
[0.0, 0.5, 1.0, 3.0, 7.0, 14.0],
weights=[0.35, 0.20, 0.20, 0.12, 0.08, 0.05],
)[0] + rng.random() * 0.5
# is_overdue only meaningful when age > 0
is_overdue = age_days > 0.5 and rng.random() < 0.65
template = rng.choice(_TEMPLATES)
content = template.format(n=rng.randint(100, 999), name=rng.choice(_NAMES))
tasks.append({
"id": f"sim:{i}",
"content": content,
"source": "sim",
"features": {
"is_overdue": is_overdue,
"task_age_days": age_days if is_overdue else 0.0,
"priority": priority,
},
})
return tasks

3
ml/features/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
from .context import build_context, PromptContext, TaskSignal
__all__ = ["build_context", "PromptContext", "TaskSignal"]

63
ml/features/context.py Normal file
View File

@@ -0,0 +1,63 @@
"""
Context assembler — converts raw user signals into a PromptContext for LLM tip generation.
Usage:
from ml.features.context import build_context
ctx = build_context(tasks, hour_of_day=9, day_of_week=2)
"""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class TaskSignal:
id: str
content: str
priority: int = 1 # 14 (Todoist scale)
is_overdue: bool = False
task_age_days: float = 0.0
due_date: str | None = None
@dataclass
class PromptContext:
tasks: list[dict] = field(default_factory=list)
hour_of_day: int = 12
day_of_week: int = 0
extra: dict = field(default_factory=dict)
def build_context(
tasks: list[TaskSignal],
hour_of_day: int = 12,
day_of_week: int = 0,
extra: dict | None = None,
) -> PromptContext:
"""
Assemble user signals into a PromptContext.
Signals are sorted so overdue + high-priority tasks appear first,
giving the LLM the most actionable context at the top of the prompt.
"""
sorted_tasks = sorted(
tasks,
key=lambda t: (not t.is_overdue, -t.priority, -t.task_age_days),
)
task_dicts = [
{
"id": t.id,
"content": t.content,
"priority": t.priority,
"is_overdue": t.is_overdue,
"task_age_days": round(t.task_age_days, 1),
"due_date": t.due_date,
}
for t in sorted_tasks
]
return PromptContext(
tasks=task_dicts,
hour_of_day=hour_of_day,
day_of_week=day_of_week,
extra=extra or {},
)

View File

@@ -0,0 +1,64 @@
"""Tests for ml/features/context.py"""
import pytest
import sys, os; sys.path.insert(0, os.path.dirname(__file__))
from context import build_context, TaskSignal, PromptContext
def test_empty_tasks():
ctx = build_context([], hour_of_day=9, day_of_week=1)
assert ctx.tasks == []
assert ctx.hour_of_day == 9
assert ctx.day_of_week == 1
def test_overdue_tasks_sorted_first():
tasks = [
TaskSignal(id="a", content="Normal task", priority=1, is_overdue=False),
TaskSignal(id="b", content="Overdue task", priority=2, is_overdue=True, task_age_days=3.0),
]
ctx = build_context(tasks)
assert ctx.tasks[0]["id"] == "b"
def test_high_priority_within_non_overdue():
tasks = [
TaskSignal(id="lo", content="Low prio", priority=1, is_overdue=False),
TaskSignal(id="hi", content="High prio", priority=4, is_overdue=False),
]
ctx = build_context(tasks)
assert ctx.tasks[0]["id"] == "hi"
def test_extra_fields_passed_through():
ctx = build_context([], extra={"mood": "focused"})
assert ctx.extra["mood"] == "focused"
def test_task_age_rounded():
tasks = [TaskSignal(id="x", content="Task", task_age_days=1.23456)]
ctx = build_context(tasks)
assert ctx.tasks[0]["task_age_days"] == 1.2
def test_overdue_sorted_by_priority():
tasks = [
TaskSignal(id="lo", content="Low", priority=1, is_overdue=True),
TaskSignal(id="hi", content="High", priority=4, is_overdue=True),
]
ctx = build_context(tasks)
assert ctx.tasks[0]["id"] == "hi"
def test_overdue_same_priority_sorted_by_age():
tasks = [
TaskSignal(id="new", content="New", priority=2, is_overdue=True, task_age_days=1.0),
TaskSignal(id="old", content="Old", priority=2, is_overdue=True, task_age_days=5.0),
]
ctx = build_context(tasks)
assert ctx.tasks[0]["id"] == "old"
def test_due_date_none_preserved():
tasks = [TaskSignal(id="x", content="No due", due_date=None)]
ctx = build_context(tasks)
assert ctx.tasks[0]["due_date"] is None

556
ml/serving/main.py Normal file
View File

@@ -0,0 +1,556 @@
"""
oO ML Serving — Phase 1: LinUCB contextual bandit.
Contract:
POST /score { user_id, candidates, context } → { tip_id, score, policy }
POST /reward { user_id, tip_id, reward, features } → { ok }
POST /reset/{user_id}{ ok }
GET /stats/{user_id}{ pulls, cumulative_reward, estimated_mean, last_updated }
GET /features/{user_id}{ history: [{ ts, features, score }] }
GET /health → { ok }
Features (d=5):
hour_sin, hour_cos — cyclical time-of-day encoding
is_overdue — 0 or 1
task_age_days — days since due date (clipped 030, normalised 01)
priority_norm — Todoist priority 14, normalised to 01
"""
from __future__ import annotations
import json
import math
import os
import time
from collections import deque
from pathlib import Path
from typing import Optional, Deque
import httpx
import numpy as np
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI(title="oO ML Serving", version="1.0.0")
LITELLM_URL = os.getenv("LITELLM_URL", "http://localhost:4000")
LITELLM_MASTER_KEY = os.getenv("LITELLM_MASTER_KEY", "sk-oo-dev")
STATE_DIR = Path(os.getenv("STATE_DIR", "/tmp/oo-bandit-state"))
STATE_DIR.mkdir(parents=True, exist_ok=True)
ALPHA = 1.0 # LinUCB exploration coefficient
D = 5 # LinUCB feature dimension
D7 = 7 # ε-greedy feature dimension (adds day-of-week cyclical encoding)
EPSILON = 0.1 # ε-greedy exploration rate
FEATURE_HISTORY_SIZE = 100 # per-user ring buffer
# ── Per-user in-memory feature history ────────────────────────────────────
_feature_history: dict[str, deque] = {}
def get_feature_history(user_id: str) -> deque:
if user_id not in _feature_history:
_feature_history[user_id] = deque(maxlen=FEATURE_HISTORY_SIZE)
return _feature_history[user_id]
# ── Feature helpers ────────────────────────────────────────────────────────
def build_feature_vector(features: dict) -> np.ndarray:
hour = features.get("hour_of_day", 12)
hour_sin = math.sin(2 * math.pi * hour / 24)
hour_cos = math.cos(2 * math.pi * hour / 24)
is_overdue = float(bool(features.get("is_overdue", False)))
age = min(float(features.get("task_age_days", 0)), 30.0) / 30.0
priority = (float(features.get("priority", 1)) - 1.0) / 3.0
return np.array([hour_sin, hour_cos, is_overdue, age, priority], dtype=np.float64)
# ── Per-user bandit state (disjoint LinUCB, global arm) ───────────────────
# ── LinUCB state helpers ───────────────────────────────────────────────────
def state_path(user_id: str) -> Path:
safe = "".join(c if c.isalnum() else "_" for c in user_id)
return STATE_DIR / f"{safe}.json"
def load_state(user_id: str) -> tuple[np.ndarray, np.ndarray, dict]:
"""Returns (A, b, meta). A is DxD, b is D-vector."""
p = state_path(user_id)
if p.exists():
raw = json.loads(p.read_text())
A = np.array(raw["A"], dtype=np.float64)
b = np.array(raw["b"], dtype=np.float64)
meta = raw.get("meta", {})
return A, b, meta
return np.identity(D, dtype=np.float64), np.zeros(D, dtype=np.float64), {}
def save_state(user_id: str, A: np.ndarray, b: np.ndarray, meta: dict) -> None:
p = state_path(user_id)
p.write_text(json.dumps({"A": A.tolist(), "b": b.tolist(), "meta": meta}))
# ── ε-greedy state helpers (d=7, extended features) ───────────────────────
def build_feature_vector_7(features: dict, day_of_week: int = 0) -> np.ndarray:
"""d=7: base 5 features + day-of-week cyclical encoding."""
base = build_feature_vector(features)
dow_sin = math.sin(2 * math.pi * day_of_week / 7)
dow_cos = math.cos(2 * math.pi * day_of_week / 7)
return np.append(base, [dow_sin, dow_cos])
def state7_path(user_id: str) -> Path:
safe = "".join(c if c.isalnum() else "_" for c in user_id)
return STATE_DIR / f"{safe}_egreedy.json"
def load_state7(user_id: str) -> tuple[np.ndarray, np.ndarray, dict]:
"""Returns (A, b, meta) for ε-greedy d=7 policy."""
p = state7_path(user_id)
if p.exists():
raw = json.loads(p.read_text())
A = np.array(raw["A"], dtype=np.float64)
b = np.array(raw["b"], dtype=np.float64)
return A, b, raw.get("meta", {})
return np.identity(D7, dtype=np.float64), np.zeros(D7, dtype=np.float64), {}
def save_state7(user_id: str, A: np.ndarray, b: np.ndarray, meta: dict) -> None:
p = state7_path(user_id)
p.write_text(json.dumps({"A": A.tolist(), "b": b.tolist(), "meta": meta}))
# ── API models ─────────────────────────────────────────────────────────────
class CandidateFeatures(BaseModel):
hour_of_day: int = 12
is_overdue: bool = False
task_age_days: float = 0.0
priority: int = 1
class Candidate(BaseModel):
id: str
content: str
source: str
source_id: Optional[str] = None
features: CandidateFeatures = CandidateFeatures()
class Context(BaseModel):
hour_of_day: int = 12
day_of_week: int = 0
class ScoreRequest(BaseModel):
user_id: str
candidates: list[Candidate]
context: Context = Context()
class ScoreResponse(BaseModel):
tip_id: str
score: float
policy: str
class RewardRequest(BaseModel):
user_id: str
tip_id: str
reward: float # +1 done, +0.5 helpful, 0 snooze, -0.5 not_helpful, -1 dismiss
features: CandidateFeatures
day_of_week: int = 0 # included so egreedy can train dow features correctly
class RewardResponse(BaseModel):
ok: bool
class PromptContext(BaseModel):
tasks: list[dict] = []
hour_of_day: int = 12
day_of_week: int = 0
extra: dict = {}
class GenerateRequest(BaseModel):
user_id: str
context: PromptContext = PromptContext()
n: int = 3
class TipCandidate(BaseModel):
id: str
content: str
source: str = "llm"
rationale: Optional[str] = None
class GenerateResponse(BaseModel):
candidates: list[TipCandidate]
model: str
prompt_tokens: int = 0
completion_tokens: int = 0
_GENERATE_SYSTEM = (
"You are a personal productivity coach. "
"Given the user's current context, generate actionable, specific tips. "
"Respond ONLY with a JSON array of objects, each with keys: "
'"id" (short slug), "content" (the tip, ≤2 sentences), "rationale" (why now, ≤1 sentence). '
"No markdown, no prose outside the JSON array."
)
def _build_prompt(ctx: PromptContext, n: int) -> str:
lines = [f"Time: {ctx.hour_of_day:02d}:00, day_of_week={ctx.day_of_week}"]
if ctx.tasks:
overdue = [t for t in ctx.tasks if t.get("is_overdue")]
lines.append(f"Tasks: {len(ctx.tasks)} total, {len(overdue)} overdue")
for t in ctx.tasks[:5]:
due = t.get("due_date", "no due date")
lines.append(f" - [{t.get('priority','?')}] {t.get('content','?')} (due: {due})")
for k, v in ctx.extra.items():
lines.append(f"{k}: {v}")
lines.append(f"\nGenerate {n} tips as a JSON array.")
return "\n".join(lines)
# ── Endpoints ──────────────────────────────────────────────────────────────
@app.get("/health")
def health():
return {"ok": True}
_RETRY_SUFFIX = (
"\n\nYour previous response was not valid JSON. "
"Reply ONLY with the JSON array — no prose, no markdown fences."
)
_MAX_GENERATE_RETRIES = 2
def _parse_llm_json(raw: str) -> list[dict]:
"""Strip markdown fences and parse JSON array. Raises ValueError on failure."""
text = raw.strip()
if text.startswith("```"):
parts = text.split("```")
text = parts[1] if len(parts) > 1 else text
if text.startswith("json"):
text = text[4:]
return json.loads(text)
@app.post("/generate", response_model=GenerateResponse)
async def generate(req: GenerateRequest) -> GenerateResponse:
"""Generate tip candidates via LiteLLM → tip-generator alias.
Retries up to _MAX_GENERATE_RETRIES times on malformed JSON, appending
a correction hint to the conversation so the model can self-correct.
"""
prompt = _build_prompt(req.context, req.n)
messages: list[dict] = [
{"role": "system", "content": _GENERATE_SYSTEM},
{"role": "user", "content": prompt},
]
headers = {"Authorization": f"Bearer {LITELLM_MASTER_KEY}"}
last_parse_error: str = ""
last_raw: str = ""
total_usage: dict = {"prompt_tokens": 0, "completion_tokens": 0}
model_used = "tip-generator"
async with httpx.AsyncClient(timeout=30.0) as client:
for attempt in range(1 + _MAX_GENERATE_RETRIES):
payload = {"model": "tip-generator", "messages": messages, "temperature": 0.7}
try:
resp = await client.post(
f"{LITELLM_URL}/chat/completions",
json=payload,
headers=headers,
)
resp.raise_for_status()
except httpx.HTTPStatusError as e:
raise HTTPException(status_code=502, detail=f"LiteLLM error: {e.response.text}")
except httpx.RequestError as e:
raise HTTPException(status_code=503, detail=f"LiteLLM unreachable: {e}")
data = resp.json()
usage = data.get("usage", {})
total_usage["prompt_tokens"] += usage.get("prompt_tokens", 0)
total_usage["completion_tokens"] += usage.get("completion_tokens", 0)
model_used = data.get("model", "tip-generator")
last_raw = data["choices"][0]["message"]["content"]
try:
items = _parse_llm_json(last_raw)
break
except (json.JSONDecodeError, ValueError) as e:
last_parse_error = str(e)
# Feed the bad reply back so the model can self-correct
messages.append({"role": "assistant", "content": last_raw})
messages.append({"role": "user", "content": _RETRY_SUFFIX})
else:
raise HTTPException(
status_code=502,
detail=f"LLM returned invalid JSON after {_MAX_GENERATE_RETRIES} retries: "
f"{last_parse_error}\n{last_raw[:200]}",
)
candidates = [
TipCandidate(
id=item.get("id", f"tip-{i}"),
content=item.get("content", ""),
rationale=item.get("rationale"),
)
for i, item in enumerate(items)
]
return GenerateResponse(
candidates=candidates,
model=model_used,
prompt_tokens=total_usage["prompt_tokens"],
completion_tokens=total_usage["completion_tokens"],
)
@app.post("/score", response_model=ScoreResponse)
def score(req: ScoreRequest) -> ScoreResponse:
if not req.candidates:
raise HTTPException(status_code=422, detail="No candidates")
A, b, meta = load_state(req.user_id)
try:
A_inv = np.linalg.inv(A)
except np.linalg.LinAlgError:
A_inv = np.identity(D, dtype=np.float64)
theta = A_inv @ b
best_id = None
best_score = -float("inf")
best_features: dict = {}
for candidate in req.candidates:
feat_dict = {
"hour_of_day": req.context.hour_of_day,
"is_overdue": candidate.features.is_overdue,
"task_age_days": candidate.features.task_age_days,
"priority": candidate.features.priority,
}
x = build_feature_vector(feat_dict)
exploit = float(theta @ x)
explore = ALPHA * math.sqrt(float(x @ A_inv @ x))
ucb = exploit + explore
if ucb > best_score:
best_score = ucb
best_id = candidate.id
best_features = feat_dict
# Log to feature history ring buffer
history = get_feature_history(req.user_id)
history.append({
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"features": best_features,
"score": best_score,
"tip_id": best_id,
})
# Update meta stats
meta["pulls"] = meta.get("pulls", 0) + 1
meta["last_updated"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
save_state(req.user_id, A, b, meta)
return ScoreResponse(tip_id=best_id, score=best_score, policy="linucb-v1")
@app.post("/reward", response_model=RewardResponse)
def reward(req: RewardRequest) -> RewardResponse:
A, b, meta = load_state(req.user_id)
feat_dict = {
"hour_of_day": req.features.hour_of_day,
"is_overdue": req.features.is_overdue,
"task_age_days": req.features.task_age_days,
"priority": req.features.priority,
}
x = build_feature_vector(feat_dict)
A += np.outer(x, x)
b += req.reward * x
# Track cumulative reward in meta
meta["cumulative_reward"] = meta.get("cumulative_reward", 0.0) + req.reward
meta["reward_count"] = meta.get("reward_count", 0) + 1
meta["last_updated"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
save_state(req.user_id, A, b, meta)
return RewardResponse(ok=True)
@app.post("/score/egreedy", response_model=ScoreResponse)
def score_egreedy(req: ScoreRequest) -> ScoreResponse:
"""ε-greedy policy with d=7 features (adds day-of-week encoding).
Exploration: pick uniformly at random with probability ε.
Exploitation: pick argmax of linear payoff estimate θ·x.
Differs from LinUCB in: no UCB bonus, richer feature space.
"""
if not req.candidates:
raise HTTPException(status_code=422, detail="No candidates")
A, b, meta = load_state7(req.user_id)
try:
A_inv = np.linalg.inv(A)
except np.linalg.LinAlgError:
A_inv = np.identity(D7, dtype=np.float64)
theta = A_inv @ b
dow = req.context.day_of_week
exploring = np.random.random() < EPSILON
if exploring:
chosen = req.candidates[np.random.randint(len(req.candidates))]
feat_dict = {
"hour_of_day": req.context.hour_of_day,
"is_overdue": chosen.features.is_overdue,
"task_age_days": chosen.features.task_age_days,
"priority": chosen.features.priority,
}
x = build_feature_vector_7(feat_dict, dow)
best_score = float(theta @ x)
best_id = chosen.id
else:
best_id = None
best_score = -float("inf")
feat_dict = {}
for candidate in req.candidates:
fd = {
"hour_of_day": req.context.hour_of_day,
"is_overdue": candidate.features.is_overdue,
"task_age_days": candidate.features.task_age_days,
"priority": candidate.features.priority,
}
x = build_feature_vector_7(fd, dow)
s = float(theta @ x)
if s > best_score:
best_score = s
best_id = candidate.id
feat_dict = fd
history = get_feature_history(req.user_id)
history.append({
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"features": {**feat_dict, "day_of_week": dow, "exploring": exploring},
"score": best_score,
"tip_id": best_id,
"policy": "egreedy-v1",
})
meta["pulls"] = meta.get("pulls", 0) + 1
meta["explore_count"] = meta.get("explore_count", 0) + int(exploring)
meta["last_updated"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
save_state7(req.user_id, A, b, meta)
return ScoreResponse(tip_id=best_id, score=best_score, policy="egreedy-v1")
@app.post("/reward/egreedy", response_model=RewardResponse)
def reward_egreedy(req: RewardRequest) -> RewardResponse:
"""Update ε-greedy ridge estimator with observed reward."""
A, b, meta = load_state7(req.user_id)
feat_dict = {
"hour_of_day": req.features.hour_of_day,
"is_overdue": req.features.is_overdue,
"task_age_days": req.features.task_age_days,
"priority": req.features.priority,
}
x = build_feature_vector_7(feat_dict, day_of_week=req.day_of_week)
A += np.outer(x, x)
b += req.reward * x
meta["cumulative_reward"] = meta.get("cumulative_reward", 0.0) + req.reward
meta["reward_count"] = meta.get("reward_count", 0) + 1
meta["last_updated"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
save_state7(req.user_id, A, b, meta)
return RewardResponse(ok=True)
@app.get("/stats/egreedy/{user_id}")
def stats_egreedy(user_id: str):
"""ε-greedy policy stats — pulls, cumulative reward, θ vector."""
A, b, meta = load_state7(user_id)
try:
theta = (np.linalg.inv(A) @ b).tolist()
except np.linalg.LinAlgError:
theta = [0.0] * D7
pulls = meta.get("pulls", 0)
cumulative_reward = meta.get("cumulative_reward", 0.0)
reward_count = meta.get("reward_count", 0)
explore_count = meta.get("explore_count", 0)
return {
"user_id": user_id,
"policy": "egreedy-v1",
"pulls": pulls,
"reward_count": reward_count,
"cumulative_reward": cumulative_reward,
"estimated_mean_reward": cumulative_reward / reward_count if reward_count > 0 else 0.0,
"exploration_rate": explore_count / pulls if pulls > 0 else 0.0,
"theta": theta,
"feature_labels": ["hour_sin", "hour_cos", "is_overdue", "task_age", "priority", "dow_sin", "dow_cos"],
"last_updated": meta.get("last_updated"),
}
@app.post("/reset/{user_id}", response_model=RewardResponse)
def reset(user_id: str) -> RewardResponse:
"""Reset per-user bandit state (admin action)."""
p = state_path(user_id)
if p.exists():
p.unlink()
p7 = state7_path(user_id)
if p7.exists():
p7.unlink()
if user_id in _feature_history:
_feature_history[user_id].clear()
return RewardResponse(ok=True)
@app.get("/stats/{user_id}")
def stats(user_id: str):
"""Return current LinUCB state summary for a user."""
A, b, meta = load_state(user_id)
try:
A_inv = np.linalg.inv(A)
theta = (A_inv @ b).tolist()
except np.linalg.LinAlgError:
theta = [0.0] * D
pulls = meta.get("pulls", 0)
cumulative_reward = meta.get("cumulative_reward", 0.0)
reward_count = meta.get("reward_count", 0)
estimated_mean = cumulative_reward / reward_count if reward_count > 0 else 0.0
return {
"user_id": user_id,
"pulls": pulls,
"reward_count": reward_count,
"cumulative_reward": cumulative_reward,
"estimated_mean_reward": estimated_mean,
"theta": theta,
"last_updated": meta.get("last_updated"),
}
@app.get("/features/{user_id}")
def features(user_id: str):
"""Return recent feature vectors logged at scoring time."""
history = get_feature_history(user_id)
return {
"user_id": user_id,
"history": list(history),
}

10
ml/serving/package.json Normal file
View File

@@ -0,0 +1,10 @@
{
"name": "@oo/ml-serving",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": ".venv/bin/uvicorn main:app --reload --port 8000",
"start": ".venv/bin/uvicorn main:app --port 8000",
"test": ".venv/bin/python -m pytest tests/ -v"
}
}

View File

@@ -0,0 +1,4 @@
-r requirements.txt
pytest==8.3.5
pytest-asyncio==0.24.0
httpx==0.28.1

View File

@@ -0,0 +1,6 @@
fastapi==0.115.6
uvicorn[standard]==0.32.1
pydantic==2.10.4
numpy>=1.26.0
httpx>=0.27.0
anthropic>=0.40.0

View File

View File

@@ -0,0 +1,225 @@
"""
Tests for POST /generate — LiteLLM gateway.
LiteLLM is mocked; no real network calls.
"""
import json
import pytest
import httpx
from unittest.mock import AsyncMock, patch
from httpx import AsyncClient, ASGITransport, Response
from main import app, _build_prompt, PromptContext
def _litellm_response(candidates: list[dict]) -> Response:
import httpx
body = {
"model": "tip-generator",
"choices": [{"message": {"content": json.dumps(candidates)}}],
"usage": {"prompt_tokens": 10, "completion_tokens": 20},
}
req = httpx.Request("POST", "http://litellm/chat/completions")
return Response(200, json=body, request=req)
@pytest.mark.anyio
async def test_generate_returns_candidates():
fake_items = [
{"id": "tip-1", "content": "Do the overdue task now.", "rationale": "It's been waiting."},
{"id": "tip-2", "content": "Take a 5-minute break.", "rationale": "You've been working long."},
]
mock_resp = _litellm_response(fake_items)
with patch("main.httpx.AsyncClient") as MockClient:
instance = AsyncMock()
instance.post = AsyncMock(return_value=mock_resp)
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
MockClient.return_value = instance
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.post("/generate", json={"user_id": "u1", "n": 2})
assert resp.status_code == 200
data = resp.json()
assert len(data["candidates"]) == 2
assert data["candidates"][0]["id"] == "tip-1"
assert data["model"] == "tip-generator"
@pytest.mark.anyio
async def test_generate_strips_markdown_fence():
fake_items = [{"id": "tip-a", "content": "Focus.", "rationale": "Now."}]
fenced = "```json\n" + json.dumps(fake_items) + "\n```"
body = {
"model": "tip-generator",
"choices": [{"message": {"content": fenced}}],
"usage": {},
}
req = httpx.Request("POST", "http://litellm/chat/completions")
mock_resp = Response(200, json=body, request=req)
with patch("main.httpx.AsyncClient") as MockClient:
instance = AsyncMock()
instance.post = AsyncMock(return_value=mock_resp)
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
MockClient.return_value = instance
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.post("/generate", json={"user_id": "u1"})
assert resp.status_code == 200
assert resp.json()["candidates"][0]["id"] == "tip-a"
@pytest.mark.anyio
async def test_generate_503_on_unreachable():
import httpx as _httpx
with patch("main.httpx.AsyncClient") as MockClient:
instance = AsyncMock()
instance.post = AsyncMock(side_effect=_httpx.ConnectError("refused"))
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
MockClient.return_value = instance
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.post("/generate", json={"user_id": "u1"})
assert resp.status_code == 503
def test_build_prompt_includes_tasks():
ctx = PromptContext(
tasks=[{"content": "Write report", "priority": 4, "is_overdue": True, "due_date": "2026-04-15"}],
hour_of_day=9,
day_of_week=2,
)
prompt = _build_prompt(ctx, n=3)
assert "Write report" in prompt
assert "09:00" in prompt
assert "Generate 3 tips" in prompt
def test_build_prompt_truncates_at_five():
tasks = [{"content": f"Task {i}", "priority": 1, "is_overdue": False, "due_date": None} for i in range(8)]
ctx = PromptContext(tasks=tasks, hour_of_day=12)
prompt = _build_prompt(ctx, n=2)
assert "Task 4" in prompt
assert "Task 5" not in prompt
def test_build_prompt_extra_fields():
ctx = PromptContext(tasks=[], hour_of_day=8, extra={"mood": "focused", "energy": "high"})
prompt = _build_prompt(ctx, n=1)
assert "mood: focused" in prompt
assert "energy: high" in prompt
def test_build_prompt_empty_tasks_no_task_line():
ctx = PromptContext(tasks=[], hour_of_day=10)
prompt = _build_prompt(ctx, n=2)
assert "Tasks:" not in prompt
assert "Generate 2 tips" in prompt
@pytest.mark.anyio
async def test_generate_retry_succeeds_on_second_attempt():
"""First response is invalid JSON; second is valid. Should return 200."""
valid_items = [{"id": "tip-ok", "content": "Retry worked.", "rationale": "Second try."}]
bad_req = httpx.Request("POST", "http://litellm/chat/completions")
bad_resp = Response(200, json={
"model": "tip-generator",
"choices": [{"message": {"content": "this is not json"}}],
"usage": {},
}, request=bad_req)
good_resp = _litellm_response(valid_items)
with patch("main.httpx.AsyncClient") as MockClient:
instance = AsyncMock()
instance.post = AsyncMock(side_effect=[bad_resp, good_resp])
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
MockClient.return_value = instance
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.post("/generate", json={"user_id": "u1", "n": 1})
assert resp.status_code == 200
assert resp.json()["candidates"][0]["id"] == "tip-ok"
assert instance.post.call_count == 2
# Retry message should include the correction suffix
second_call_messages = instance.post.call_args_list[1][1]["json"]["messages"]
assert any("not valid JSON" in m["content"] for m in second_call_messages)
@pytest.mark.anyio
async def test_generate_502_after_all_retries_exhausted():
"""All attempts return invalid JSON → 502."""
bad_req = httpx.Request("POST", "http://litellm/chat/completions")
def _bad_resp():
return Response(200, json={
"model": "tip-generator",
"choices": [{"message": {"content": "not json at all"}}],
"usage": {},
}, request=bad_req)
from main import _MAX_GENERATE_RETRIES
responses = [_bad_resp() for _ in range(1 + _MAX_GENERATE_RETRIES)]
with patch("main.httpx.AsyncClient") as MockClient:
instance = AsyncMock()
instance.post = AsyncMock(side_effect=responses)
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
MockClient.return_value = instance
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.post("/generate", json={"user_id": "u1"})
assert resp.status_code == 502
assert "retries" in resp.json()["detail"]
@pytest.mark.anyio
async def test_generate_502_on_upstream_http_error():
"""LiteLLM returns 500 → HTTPStatusError → 502."""
err_req = httpx.Request("POST", "http://litellm/chat/completions")
err_resp = Response(500, text="internal error", request=err_req)
with patch("main.httpx.AsyncClient") as MockClient:
instance = AsyncMock()
instance.post = AsyncMock(side_effect=httpx.HTTPStatusError(
"500", request=err_req, response=err_resp
))
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
MockClient.return_value = instance
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.post("/generate", json={"user_id": "u1"})
assert resp.status_code == 502
assert "LiteLLM error" in resp.json()["detail"]
def test_parse_llm_json_bare_fence():
from main import _parse_llm_json
raw = "```\n[{\"id\":\"x\",\"content\":\"hi\"}]\n```"
items = _parse_llm_json(raw)
assert items[0]["id"] == "x"
def test_parse_llm_json_no_fence():
from main import _parse_llm_json
raw = '[{"id":"plain","content":"no fence"}]'
items = _parse_llm_json(raw)
assert items[0]["id"] == "plain"
def test_parse_llm_json_raises_on_invalid():
from main import _parse_llm_json
with pytest.raises((ValueError, Exception)):
_parse_llm_json("this is not json")

View File

@@ -0,0 +1,261 @@
"""
Unit tests for ml/serving — feature building and scoring contract.
Run with: pytest ml/serving/tests/
"""
import math
import pytest
from httpx import AsyncClient, ASGITransport
from main import app, build_feature_vector
class TestFeatureVector:
def test_shape(self):
v = build_feature_vector({"hour_of_day": 8, "is_overdue": True, "task_age_days": 3, "priority": 3})
assert v.shape == (5,)
def test_hour_encoding_noon(self):
v = build_feature_vector({"hour_of_day": 12})
# sin(2π * 12/24) = sin(π) ≈ 0
assert abs(v[0]) < 1e-10
# cos(2π * 12/24) = cos(π) = -1
assert abs(v[1] - (-1.0)) < 1e-10
def test_hour_encoding_midnight(self):
v = build_feature_vector({"hour_of_day": 0})
# sin(0) = 0
assert abs(v[0]) < 1e-10
# cos(0) = 1
assert abs(v[1] - 1.0) < 1e-10
def test_hour_encoding_6am(self):
v = build_feature_vector({"hour_of_day": 6})
# sin(2π * 6/24) = sin(π/2) = 1
assert abs(v[0] - 1.0) < 1e-10
# cos(π/2) = 0
assert abs(v[1]) < 1e-10
def test_age_clipped_at_30(self):
v_long = build_feature_vector({"task_age_days": 100})
v_cap = build_feature_vector({"task_age_days": 30})
assert v_long[3] == v_cap[3] == 1.0
def test_age_zero(self):
v = build_feature_vector({"task_age_days": 0})
assert v[3] == pytest.approx(0.0)
def test_age_15_days_normalised(self):
v = build_feature_vector({"task_age_days": 15})
assert v[3] == pytest.approx(0.5)
def test_priority_normalised(self):
v1 = build_feature_vector({"priority": 1})
v4 = build_feature_vector({"priority": 4})
assert v1[4] == pytest.approx(0.0)
assert v4[4] == pytest.approx(1.0)
def test_priority_2_and_3(self):
v2 = build_feature_vector({"priority": 2})
v3 = build_feature_vector({"priority": 3})
assert v2[4] == pytest.approx(1 / 3)
assert v3[4] == pytest.approx(2 / 3)
def test_is_overdue_true(self):
v = build_feature_vector({"is_overdue": True})
assert v[2] == 1.0
def test_is_overdue_false(self):
v = build_feature_vector({"is_overdue": False})
assert v[2] == 0.0
def test_defaults_when_no_keys(self):
v = build_feature_vector({})
# hour=12 → sin(π)≈0, cos(π)=-1
assert abs(v[0]) < 1e-10
assert abs(v[1] - (-1.0)) < 1e-10
assert v[2] == 0.0 # is_overdue=False
assert v[3] == 0.0 # task_age_days=0
assert v[4] == 0.0 # priority=1 → (1-1)/3=0
@pytest.mark.asyncio
async def test_health():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
r = await client.get("/health")
assert r.status_code == 200
assert r.json()["ok"] is True
@pytest.mark.asyncio
async def test_score_returns_a_candidate():
payload = {
"user_id": "test-user",
"candidates": [
{"id": "t:1", "content": "Task A", "source": "todoist", "source_id": "1",
"features": {"is_overdue": True, "task_age_days": 2, "priority": 3}},
{"id": "t:2", "content": "Task B", "source": "todoist", "source_id": "2",
"features": {"is_overdue": False, "task_age_days": 0, "priority": 1}},
],
"context": {"hour_of_day": 9, "day_of_week": 1},
}
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
r = await client.post("/score", json=payload)
assert r.status_code == 200
body = r.json()
assert body["tip_id"] in {"t:1", "t:2"}
assert "policy" in body
assert body["policy"] == "linucb-v1"
assert isinstance(body["score"], float)
@pytest.mark.asyncio
async def test_score_single_candidate_always_selected():
"""With a single candidate there is no choice — it must be returned."""
payload = {
"user_id": "solo-user",
"candidates": [
{"id": "only:1", "content": "Only task", "source": "todoist",
"features": {"is_overdue": False, "task_age_days": 0, "priority": 1}},
],
"context": {"hour_of_day": 10, "day_of_week": 0},
}
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
r = await client.post("/score", json=payload)
assert r.status_code == 200
assert r.json()["tip_id"] == "only:1"
@pytest.mark.asyncio
async def test_score_empty_candidates_returns_422():
payload = {"user_id": "u", "candidates": [], "context": {"hour_of_day": 9, "day_of_week": 1}}
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
r = await client.post("/score", json=payload)
assert r.status_code == 422
@pytest.mark.asyncio
async def test_reward_accepted():
payload = {
"user_id": "reward-user",
"tip_id": "t:1",
"reward": 1.0,
"features": {"hour_of_day": 9, "is_overdue": True, "task_age_days": 2, "priority": 3},
}
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
r = await client.post("/reward", json=payload)
assert r.status_code == 200
assert r.json()["ok"] is True
@pytest.mark.asyncio
async def test_reward_updates_stats():
"""Posting a reward should increase cumulative_reward in /stats."""
user_id = "reward-stats-user"
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
r0 = await client.get(f"/stats/{user_id}")
before = r0.json()["cumulative_reward"]
await client.post("/reward", json={
"user_id": user_id,
"tip_id": "tip:x",
"reward": 1.0,
"features": {"hour_of_day": 8, "is_overdue": False, "task_age_days": 0, "priority": 2},
})
r1 = await client.get(f"/stats/{user_id}")
assert r1.json()["cumulative_reward"] == pytest.approx(before + 1.0)
@pytest.mark.asyncio
async def test_score_increments_pulls():
user_id = "pull-counter-user"
payload = {
"user_id": user_id,
"candidates": [
{"id": "t:p1", "content": "Pull task", "source": "todoist",
"features": {"is_overdue": False, "task_age_days": 1, "priority": 2}},
],
"context": {"hour_of_day": 10, "day_of_week": 2},
}
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
r0 = await client.get(f"/stats/{user_id}")
pulls_before = r0.json()["pulls"]
await client.post("/score", json=payload)
await client.post("/score", json=payload)
r1 = await client.get(f"/stats/{user_id}")
assert r1.json()["pulls"] == pulls_before + 2
@pytest.mark.asyncio
async def test_reset_clears_state():
user_id = "reset-user"
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
# Score once to build state
await client.post("/score", json={
"user_id": user_id,
"candidates": [
{"id": "t:r", "content": "Reset task", "source": "todoist",
"features": {"is_overdue": True, "task_age_days": 5, "priority": 4}},
],
"context": {"hour_of_day": 14, "day_of_week": 3},
})
r_reset = await client.post(f"/reset/{user_id}")
assert r_reset.json()["ok"] is True
r_stats = await client.get(f"/stats/{user_id}")
assert r_stats.json()["pulls"] == 0
@pytest.mark.asyncio
async def test_features_endpoint_returns_history():
user_id = "features-user"
payload = {
"user_id": user_id,
"candidates": [
{"id": "t:f1", "content": "Feature task", "source": "todoist",
"features": {"is_overdue": False, "task_age_days": 0, "priority": 1}},
],
"context": {"hour_of_day": 7, "day_of_week": 0},
}
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
await client.post("/score", json=payload)
r = await client.get(f"/features/{user_id}")
body = r.json()
assert r.status_code == 200
assert "history" in body
assert len(body["history"]) >= 1
entry = body["history"][-1]
assert "ts" in entry
assert "score" in entry
assert "tip_id" in entry
@pytest.mark.asyncio
async def test_stats_for_fresh_user():
"""A user with no history should return zero/default stats without error."""
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
r = await client.get("/stats/brand-new-user-xyz-abc")
body = r.json()
assert r.status_code == 200
assert body["pulls"] == 0
assert body["cumulative_reward"] == 0.0
assert body["estimated_mean_reward"] == 0.0
@pytest.mark.asyncio
async def test_reward_negative_value():
"""Dismissing a tip should decrease cumulative_reward."""
user_id = "dismiss-user-neg"
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
r0 = await client.get(f"/stats/{user_id}")
before = r0.json()["cumulative_reward"]
await client.post("/reward", json={
"user_id": user_id,
"tip_id": "t:neg",
"reward": -1.0,
"features": {"hour_of_day": 20, "is_overdue": False, "task_age_days": 0, "priority": 1},
})
r1 = await client.get(f"/stats/{user_id}")
assert r1.json()["cumulative_reward"] == pytest.approx(before - 1.0)

22
package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "oo-monorepo",
"private": true,
"version": "0.0.0",
"packageManager": "pnpm@10.33.0",
"scripts": {
"build": "turbo build",
"dev": "turbo dev",
"lint": "turbo lint",
"type-check": "turbo type-check",
"test": "turbo test",
"clean": "turbo clean"
},
"pnpm": {
"onlyBuiltDependencies": ["better-sqlite3", "esbuild", "sharp"]
},
"devDependencies": {
"turbo": "^2.3.3",
"typescript": "^5.7.3",
"@types/node": "^22.10.5"
}
}

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