Compare commits
103 Commits
7f173f88d3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ac1226c367 | |||
| 2159d4cbd1 | |||
| 522454ab61 | |||
| be8c006a4d | |||
| 8474468614 | |||
| ad43a8f06a | |||
| 56fda0d737 | |||
| b1bd3d465f | |||
| 8fd08379d7 | |||
| 85a332b22b | |||
| 772bb6e194 | |||
| 34925310cf | |||
| f66f337779 | |||
| f6b89fc849 | |||
| 12c956b588 | |||
| d12f11d29d | |||
| 9ddeea6cac | |||
| 08d08ad7b0 | |||
| 1ca2351488 | |||
| 4e9210fcef | |||
| 59c493323f | |||
| d4b40e2590 | |||
| a0a069c525 | |||
| d1f28666b0 | |||
| 161e654027 | |||
| afacc34969 | |||
| c124ff4d24 | |||
| 95e1b342b4 | |||
| c43dbaf23d | |||
| 488a764519 | |||
| c67f2b14c4 | |||
| 17b9516903 | |||
| a75be0d832 | |||
| 26fc67776f | |||
| 336644a90a | |||
| 1d9a395591 | |||
| bc71dc203d | |||
| 4cade4868b | |||
| 04212ff318 | |||
| 35257b7756 | |||
| ed1705cb5d | |||
| afb0e9b0cb | |||
| ad6747c242 | |||
| 305eeae38b | |||
| 5d43339616 | |||
| d454a0a8bf | |||
| 41302d9f36 | |||
| 05f748159b | |||
| 8e9718e8ba | |||
| c65bedcf68 | |||
| 7e958a779d | |||
| 37aec4fee1 | |||
| b3cf588f2f | |||
| f8d66aa01f | |||
| ce1c8bde57 | |||
| c1f5fcb561 | |||
| 9bd60a9835 | |||
| 4267e6ac68 | |||
| 0474ad4deb | |||
| 556019b060 | |||
| e40dfdcbb0 | |||
| bad1bb2cba | |||
| e96ceb7ee1 | |||
| b554970032 | |||
| c4960d0601 | |||
| 7281af83a4 | |||
| cba3f1a184 | |||
| 352469162d | |||
| 45416000f9 | |||
| bd3ea1b8b1 | |||
| 377373a95d | |||
| d539fde0c1 | |||
| f48b5a7646 | |||
| 4652e4b582 | |||
| 2d7cf217a9 | |||
| b8113d4bda | |||
| ee4eb15022 | |||
| 4a42a6aabf | |||
| 9e96540bcc | |||
| 7d4c29e137 | |||
| 430804e9a5 | |||
| aa4bdd8f09 | |||
| 75d0e89906 | |||
| d4205a00cf | |||
| d7a2423940 | |||
| bb879c5f0f | |||
| 5b52c6bf40 | |||
| 2a7380933c | |||
| e3ca3ba733 | |||
| 46dee7377e | |||
| 4c8ef9ad86 | |||
| ffdf70733f | |||
| 85367aeaa0 | |||
| faf44c18fc | |||
| c5ea18ec6e | |||
| e62c726ea4 | |||
| 2402a140e9 | |||
| c7edd92e15 | |||
| 08dfa1d8c9 | |||
| f6c890213b | |||
| 888f8b9a99 | |||
| 3123cb73fb | |||
| 65218762be |
18
.dockerignore
Normal file
18
.dockerignore
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
**/node_modules
|
||||||
|
**/.next
|
||||||
|
**/dist
|
||||||
|
**/coverage
|
||||||
|
**/.vitest-cache
|
||||||
|
**/.turbo
|
||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.github
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
**/.env
|
||||||
|
**/.env.local
|
||||||
|
**/*.log
|
||||||
|
infra/docker/data
|
||||||
|
**/__tests__
|
||||||
|
**/*.test.ts
|
||||||
|
**/*.test.tsx
|
||||||
62
.env.example
Normal file
62
.env.example
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# Copy to .env.local and fill in values — never commit .env.local
|
||||||
|
|
||||||
|
# API
|
||||||
|
SESSION_SECRET=change-me-to-a-random-32-char-string
|
||||||
|
PORT=3078
|
||||||
|
NODE_ENV=development
|
||||||
|
DATABASE_PATH=./data/oo.db
|
||||||
|
# API_BASE_URL = public origin only, no path suffix (used to build OAuth redirect URIs)
|
||||||
|
API_BASE_URL=http://localhost:3078
|
||||||
|
WEB_BASE_URL=http://localhost:3000
|
||||||
|
ML_SERVING_URL=http://localhost:8000
|
||||||
|
|
||||||
|
# MLflow (mlops profile) — http://localhost:5000/mlflow in dev, https://o.alogins.net/mlflow in prod.
|
||||||
|
# MLFLOW_ADMIN_PASSWORD seeds the admin account on first boot (changing it after first run
|
||||||
|
# requires the MLflow UI or API — see infra/mlflow/basic_auth.ini).
|
||||||
|
MLFLOW_URL=http://localhost:5000
|
||||||
|
MLFLOW_ADMIN_PASSWORD=change-me
|
||||||
|
# Public URL shown as link in the admin sidebar (must be NEXT_PUBLIC_ to reach the browser).
|
||||||
|
NEXT_PUBLIC_MLFLOW_URL=http://localhost:5000
|
||||||
|
|
||||||
|
# Shared secret for internal API callbacks. Generate: openssl rand -hex 32
|
||||||
|
INTERNAL_API_TOKEN=
|
||||||
|
|
||||||
|
# Static token for automated/service access to the admin panel (e.g. Playwright tests).
|
||||||
|
# Leave empty to disable token-based login. Generate: openssl rand -hex 32
|
||||||
|
ADMIN_TOKEN=
|
||||||
|
|
||||||
|
# AI stack — shared Agap services (ollama + litellm + langfuse). Not run from oO.
|
||||||
|
# Prod: https://llm.alogins.net | Dev: http://host.docker.internal:4000 from containers,
|
||||||
|
# http://localhost:4000 from host. Ollama: http://host.docker.internal:11434 / :11434.
|
||||||
|
LITELLM_URL=https://llm.alogins.net
|
||||||
|
LITELLM_MASTER_KEY=sk-oo-dev
|
||||||
|
OLLAMA_URL=http://host.docker.internal:11434
|
||||||
|
|
||||||
|
# Google OAuth — https://console.cloud.google.com/
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# VAPID (Web Push) — generate: node -e "const wp=require('web-push');console.log(JSON.stringify(wp.generateVAPIDKeys()))"
|
||||||
|
VAPID_PUBLIC_KEY=
|
||||||
|
VAPID_PRIVATE_KEY=
|
||||||
|
VAPID_SUBJECT=mailto:you@example.com
|
||||||
|
|
||||||
|
# Todoist OAuth — https://developer.todoist.com/appconsole.html
|
||||||
|
TODOIST_CLIENT_ID=
|
||||||
|
TODOIST_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Event bus — leave NATS_URL empty for in-process bus only (no JetStream bridge).
|
||||||
|
# Set to nats://nats:4222 (compose service name) or nats://localhost:4222 (host)
|
||||||
|
# to mirror every publish to durable JetStream streams (signals.>, feedback.>).
|
||||||
|
# Start the broker with: docker compose --profile events up nats
|
||||||
|
NATS_URL=
|
||||||
|
# How often the background scheduler refreshes Todoist tasks per active user (ms).
|
||||||
|
TODOIST_SYNC_INTERVAL_MS=900000
|
||||||
|
|
||||||
|
# Tip prompt selection — empty = use ml/serving default (v1).
|
||||||
|
# Pin a single variant: "v2-mentor"
|
||||||
|
# Rotate uniformly across variants: "v1,v2-mentor,v3-few-shot"
|
||||||
|
# Buckets show up in the admin reward-analytics dashboard (#92).
|
||||||
|
TIP_PROMPT_VERSION=
|
||||||
|
# Default version on the Python side when the API doesn't specify one.
|
||||||
|
DEFAULT_PROMPT_VERSION=v1
|
||||||
37
.gitea/workflows/buf-check.yaml
Normal file
37
.gitea/workflows/buf-check.yaml
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
name: buf-check
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'packages/shared-types/events/**'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'packages/shared-types/events/**'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
buf:
|
||||||
|
name: Lint & breaking-change check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install buf
|
||||||
|
run: |
|
||||||
|
BUF_VERSION=1.50.0
|
||||||
|
curl -sSfL \
|
||||||
|
"https://github.com/bufbuild/buf/releases/download/v${BUF_VERSION}/buf-Linux-x86_64" \
|
||||||
|
-o /usr/local/bin/buf
|
||||||
|
chmod +x /usr/local/bin/buf
|
||||||
|
buf --version
|
||||||
|
|
||||||
|
- name: buf lint
|
||||||
|
run: buf lint packages/shared-types/events
|
||||||
|
|
||||||
|
- name: buf breaking
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
run: |
|
||||||
|
buf breaking packages/shared-types/events \
|
||||||
|
--against ".git#branch=${{ github.base_ref }},subdir=packages/shared-types/events"
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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/
|
||||||
|
|||||||
2
.playwright-mcp/page-2026-04-14T14-06-03-068Z.yml
Normal file
2
.playwright-mcp/page-2026-04-14T14-06-03-068Z.yml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
- main [ref=e2] [cursor=pointer]:
|
||||||
|
- generic [ref=e3]: ···
|
||||||
BIN
.playwright-mcp/page-2026-04-14T14-06-13-022Z.png
Normal file
BIN
.playwright-mcp/page-2026-04-14T14-06-13-022Z.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
119
.playwright-mcp/page-2026-04-14T14-06-35-398Z.yml
Normal file
119
.playwright-mcp/page-2026-04-14T14-06-35-398Z.yml
Normal 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 "o‘zbek"
|
||||||
|
- 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
|
||||||
16
.playwright-mcp/page-2026-04-14T14-12-07-158Z.yml
Normal file
16
.playwright-mcp/page-2026-04-14T14-12-07-158Z.yml
Normal 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: .
|
||||||
2
.playwright-mcp/page-2026-04-14T14-12-17-872Z.yml
Normal file
2
.playwright-mcp/page-2026-04-14T14-12-17-872Z.yml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
- main [ref=e2] [cursor=pointer]:
|
||||||
|
- generic [ref=e3]: ···
|
||||||
16
.playwright-mcp/page-2026-04-14T14-14-53-788Z.yml
Normal file
16
.playwright-mcp/page-2026-04-14T14-14-53-788Z.yml
Normal 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: .
|
||||||
119
.playwright-mcp/page-2026-04-14T14-26-57-754Z.yml
Normal file
119
.playwright-mcp/page-2026-04-14T14-26-57-754Z.yml
Normal 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 "o‘zbek"
|
||||||
|
- 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
|
||||||
16
.playwright-mcp/page-2026-04-15T07-41-23-867Z.yml
Normal file
16
.playwright-mcp/page-2026-04-15T07-41-23-867Z.yml
Normal 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: .
|
||||||
16
.playwright-mcp/page-2026-04-15T08-09-40-643Z.yml
Normal file
16
.playwright-mcp/page-2026-04-15T08-09-40-643Z.yml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
- main [ref=e2]:
|
||||||
|
- generic [ref=e3]:
|
||||||
|
- heading "oO" [level=1] [ref=e4]
|
||||||
|
- paragraph [ref=e5]: one tip. right now.
|
||||||
|
- link "Continue with Google" [ref=e7] [cursor=pointer]:
|
||||||
|
- /url: /api/auth/login?redirectTo=/connect
|
||||||
|
- img [ref=e8]
|
||||||
|
- text: Continue with Google
|
||||||
|
- paragraph [ref=e13]:
|
||||||
|
- text: By continuing you agree to our
|
||||||
|
- link "Terms" [ref=e14] [cursor=pointer]:
|
||||||
|
- /url: /legal/terms
|
||||||
|
- text: and
|
||||||
|
- link "Privacy Policy" [ref=e15] [cursor=pointer]:
|
||||||
|
- /url: /legal/privacy
|
||||||
|
- text: .
|
||||||
186
CLAUDE.md
186
CLAUDE.md
@@ -42,7 +42,7 @@ packages/ shared libraries (importable across services + apps)
|
|||||||
ml/ Python — separate deployable from day one
|
ml/ Python — separate deployable from day one
|
||||||
serving/ online scorer (FastAPI), called by recommender
|
serving/ online scorer (FastAPI), called by recommender
|
||||||
features/ feature definitions + store adapter
|
features/ feature definitions + store adapter
|
||||||
pipelines/ batch feature + training DAGs (Prefect/Airflow)
|
pipelines/ batch feature + training scripts
|
||||||
registry/ MLflow model registry integration
|
registry/ MLflow model registry integration
|
||||||
experiments/ assignment + A/B + bandit policies
|
experiments/ assignment + A/B + bandit policies
|
||||||
notebooks/ research only; never imported by production code
|
notebooks/ research only; never imported by production code
|
||||||
@@ -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. Proto schemas (ADR-0005) live in `packages/shared-types/events/oo/events/v1/`; `buf lint` + `buf breaking` run in CI on every PR touching those files (`.gitea/workflows/buf-check.yaml`).
|
||||||
- Do not redefine types per module. Regenerate from `shared-types`.
|
- Do not redefine types per module. Regenerate from `shared-types`.
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
@@ -65,7 +65,18 @@ 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 + nats), `mlops` (adds MLflow), `ai` (adds Ollama + LiteLLM). Mix as needed. Always pass `--profile <name>` to `build`/`up` — without a profile, no services are selected and builds silently do nothing.
|
||||||
|
- Docker rebuild: use `--force-recreate` on `up` when only env vars changed (no image rebuild needed); new env vars in `.env.local` are not picked up by a running container until it is recreated.
|
||||||
|
- Docker rebuild gotchas:
|
||||||
|
- **Never run two `docker compose up --build` at once** — both grab the same `--mount=type=cache,id=pnpm` and deadlock on the API's `pnpm --prod deploy` step. Symptom: build sits silent for hours on `[api builder 8/8]`. Before starting any build, check `ps aux | grep "docker compose"` and kill any prior `up --build` (`kill -9 <pid>` — the wrapper bash and the docker compose binary are separate PIDs; kill the docker compose one).
|
||||||
|
- **Don't add `--offline` to `pnpm --prod deploy`** — pnpm's metadata cache (`/root/.cache/pnpm/`) is not in the `/pnpm/store` cache mount, so `--offline` fails with `ERR_PNPM_NO_OFFLINE_META` for transitive devDeps (e.g. vite via vitest). Leave the deploy step network-on; it works.
|
||||||
|
- **All TS Dockerfiles need `python3 make g++`** in the base stage — `better-sqlite3` rebuilds natively on install. Missing from `Dockerfile.admin` historically caused `gyp ERR! find Python` failures.
|
||||||
|
- **`Dockerfile.ml` needs `build-essential`** (not just `gcc`) — `pyswisseph` (stars agent) compiles C from source and fails with `fatal error: math.h: No such file or directory` if only `gcc` is installed; it needs `libc-dev` too, easiest via `build-essential`.
|
||||||
|
- **`Dockerfile.web` builder stage needs root `package.json` + `pnpm-workspace.yaml` + `pnpm-lock.yaml`** copied in. Without them, `pnpm --filter @oo/shared-types build` fails with `[ERR_PNPM_NO_PKG_MANIFEST] No package.json found in /app`. The deps stage has them but the builder is a fresh layer; selective copies must include them.
|
||||||
|
- **A clean build of `--profile core` takes ~3 min total** when the buildx cache is warm. If it's been silent for >10 min, check for the parallel-build deadlock above before assuming "still going".
|
||||||
|
- Run Python agent tests: `python3 -m pytest ml/agents/tests/ -x -q` (tests add repo root to `sys.path` themselves).
|
||||||
|
- Run Python feature tests: `python3 -m pytest ml/features/ -x -q`
|
||||||
|
- `ml/features/` files are Python mirrors of TS registries — TS is source of truth. Tests parse `registry.ts` with regex to detect drift; follow the same pattern whenever a new field is added to `ProfileFeature`.
|
||||||
|
|
||||||
## Definition of done (per feature)
|
## Definition of done (per feature)
|
||||||
|
|
||||||
@@ -76,15 +87,176 @@ 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 through a multi-agent pipeline (ADR-0013): pre-compute agents emit prompt snippets, an orchestrator LLM assembles them into one tip. All LLM calls route through **LiteLLM** at `llm.alogins.net` using model aliases — swapping models is a config change, not a code change.
|
||||||
|
|
||||||
|
| Alias | Model | Used by |
|
||||||
|
|-------|-------|---------|
|
||||||
|
| `tip-generator` | qwen2.5:1.5b (default) | `ml/serving` tip generation |
|
||||||
|
| `embedder` | nomic-embed-text | task clustering (after LLM enrichment), dedup |
|
||||||
|
| `judge` | claude-haiku-4-5 (cloud, eval only) | offline sim |
|
||||||
|
|
||||||
|
Env vars: `LITELLM_URL` (prod `https://llm.alogins.net`), `OLLAMA_URL` (Agap host, `http://host.docker.internal:11434` from containers).
|
||||||
|
|
||||||
|
Ollama and LiteLLM are **shared Agap services**, not oO services — they live in `agap_git/openai/docker-compose.yml` along with langfuse (observability). oO never starts them; ml-serving just calls the alias.
|
||||||
|
|
||||||
|
All `httpx` calls in `ml/` must use `trust_env=False` to bypass the system proxy — same rule as `bw` and curl. Pattern: `httpx.Client(trust_env=False, timeout=N)`.
|
||||||
|
|
||||||
|
MLflow container-to-container calls: always pass `host_header="localhost"` to `MLflowClient` — MLflow's `--allowed-hosts` rejects `Host: mlflow` (the container DNS name) with 403. Auth credential is `MLFLOW_ADMIN_PASSWORD`. MLflow REST API lives at the origin root, not under the `/mlflow` UI prefix.
|
||||||
|
|
||||||
|
### MLflow API versions — runs vs traces
|
||||||
|
|
||||||
|
MLflow uses **two API versions** — use the right one or you'll get 405:
|
||||||
|
|
||||||
|
| What | API prefix | Example |
|
||||||
|
|------|-----------|---------|
|
||||||
|
| Runs, experiments, metrics | `/api/2.0/mlflow/` | `runs/search`, `experiments/list` |
|
||||||
|
| Traces (LLM observability) | `/api/3.0/mlflow/traces/` | `traces/{trace_id}` |
|
||||||
|
|
||||||
|
**Experiment IDs:** `3` = oO/serving. Artifacts stored as run tags prefixed `artifact:<path>`.
|
||||||
|
|
||||||
|
### Querying from the host shell
|
||||||
|
|
||||||
|
Always strip the proxy and pass `Host: localhost` (no port — `localhost:5000` fails the DNS-rebinding check).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Search recent runs (experiment 3)
|
||||||
|
env -u HTTPS_PROXY -u HTTP_PROXY -u ALL_PROXY -u https_proxy -u http_proxy -u all_proxy \
|
||||||
|
curl -s -H "Host: localhost" -u "admin:${MLFLOW_ADMIN_PASSWORD}" \
|
||||||
|
-X POST http://localhost:5000/api/2.0/mlflow/runs/search \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"experiment_ids":["3"],"max_results":5,"order_by":["start_time DESC"]}'
|
||||||
|
|
||||||
|
# Get a trace by ID (note: /api/3.0/, not /api/2.0/)
|
||||||
|
env -u HTTPS_PROXY -u HTTP_PROXY -u ALL_PROXY -u https_proxy -u http_proxy -u all_proxy \
|
||||||
|
curl -s -H "Host: localhost" -u "admin:${MLFLOW_ADMIN_PASSWORD}" \
|
||||||
|
http://localhost:5000/api/3.0/mlflow/traces/tr-<trace_id> | python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
The trace response includes `trace_metadata.mlflow.traceInputs/Outputs`, `trace_metadata.mlflow.trace.sizeStats` (num_spans), and `tags.mlflow.traceName`.
|
||||||
|
|
||||||
|
### Getting spans (Python client from inside the container)
|
||||||
|
|
||||||
|
The REST API has **no endpoint for spans** — `/api/3.0/mlflow/traces/{id}/spans` returns 404. Use the Python client inside `oo-ml-serving-1`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec oo-ml-serving-1 python3 -c "
|
||||||
|
import mlflow, json, os
|
||||||
|
mlflow.set_tracking_uri('http://mlflow:5000')
|
||||||
|
os.environ['MLFLOW_TRACKING_USERNAME'] = 'admin'
|
||||||
|
os.environ['MLFLOW_TRACKING_PASSWORD'] = os.environ.get('MLFLOW_ADMIN_PASSWORD', '')
|
||||||
|
|
||||||
|
client = mlflow.tracking.MlflowClient()
|
||||||
|
trace = client.get_trace('tr-<trace_id>')
|
||||||
|
for span in trace.data.spans:
|
||||||
|
print(span.name, '| parent:', span.parent_id, '| status:', span.status)
|
||||||
|
print(' inputs:', json.dumps(span.inputs)[:200])
|
||||||
|
print(' outputs:', json.dumps(span.outputs)[:200])
|
||||||
|
print(' attrs:', span.attributes)
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Span structure for a tip generation trace
|
||||||
|
|
||||||
|
A healthy `recommend` trace has 3 spans:
|
||||||
|
|
||||||
|
| Span | Type | Parent | Key attributes |
|
||||||
|
|------|------|--------|---------------|
|
||||||
|
| `recommend` | CHAIN | (root) | `agent_count`, `latency_ms`; inputs include `agent_ids` list |
|
||||||
|
| `build_context` | TOOL | recommend | `agent_count`, `task_count`, `science_destiny` |
|
||||||
|
| `llm_orchestrator` | LLM | recommend | `prompt_tokens`, `completion_tokens`, `model`, `attempts` |
|
||||||
|
|
||||||
|
### Diagnosing "no agents in trace"
|
||||||
|
|
||||||
|
If the trace shows `agent_ids: []` and `agent_count: 0` in the root span, and the orchestrator prompt says *"No pre-computed agent context available"*, it means the recommender found zero eligible snippets at request time. Causes:
|
||||||
|
|
||||||
|
1. **Agent compute hasn't run** — no `agent_outputs` rows for this user yet
|
||||||
|
2. **Snippets expired** — TTL elapsed since last compute
|
||||||
|
3. **Eligibility filter dropped all agents** — none passed the manifest-driven check
|
||||||
|
|
||||||
|
Diagnose with:
|
||||||
|
```bash
|
||||||
|
docker exec oo-api-1 psql "$DATABASE_URL" -c \
|
||||||
|
"SELECT agent_id, computed_at, expires_at FROM agent_outputs WHERE user_id='<uid>' ORDER BY computed_at DESC LIMIT 10;"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Multi-agent tip generation pipeline (ADR-0013):**
|
||||||
|
1. Pre-compute agents (`ml/agents/<id>/`) run on a schedule, each emitting a snippet into `agent_outputs` with a per-agent TTL
|
||||||
|
2. On request, `recommender` (TS) loads the eligible agent set (registry-driven, ADR-0014) and pulls the freshest non-expired snippets
|
||||||
|
3. `POST /recommend` in `ml/serving` assembles the orchestrator prompt (`v4-orchestrator`) and calls LiteLLM via the `tip-generator` alias
|
||||||
|
4. Returned tip is logged in `tip_scores` with the contributing agent set; reaction is logged for observability (no bandit reward loop)
|
||||||
|
|
||||||
## Current phase
|
## Current phase
|
||||||
|
|
||||||
**Phase 0 — Prototype.** See `README.md` for the phase roadmap and `docs/architecture/` for diagrams. Work is tracked as Gitea milestones + issues on `alvis/oO`.
|
**M1 shipped (core + admin). M2 (AI tips) in progress.** See `README.md` for the phase roadmap and `docs/architecture/` for diagrams. Work is tracked as Gitea milestones + issues on `alvis/oO`.
|
||||||
|
|
||||||
|
Recent completions:
|
||||||
|
- ADR-0013 — multi-agent recommendation: pre-computed agent snippets + orchestrator LLM (replaces ε-greedy bandit) — 2026-05-01
|
||||||
|
- LLM context assembler + tip generation scaffold (#79, #88)
|
||||||
|
- Model benchmarking for tip generation (#93, #95)
|
||||||
|
- Admin UX refinements: feedback consolidation, settings placement (#100–102)
|
||||||
|
- ADR-0012 — ε-greedy v2 (D=12) — 2026-04-26 (now superseded by ADR-0013)
|
||||||
|
- ADR-0014 complete: unified Profile schema + backfill, manifest plumbing, `/api/profile` read-through, registry-driven eligibility filter, inference framework + per-agent inference, legacy consent column drop — 2026-05-05
|
||||||
|
- Rich per-agent inference for all four active agents (#112, #114, #115, #116) — 2026-05-06: quiet/peak hours (time-of-day), z-score baseline (momentum), p50 lateness + project realness (overdue-task), adaptive lookback + weekly/daily cycles (recent-patterns)
|
||||||
|
- Semantic task clustering via nomic-embed-text + LLM enrichment (#97, #113, #129) — 2026-05-12: `ml/agents/clustering.py`; titles expanded via `tip-generator` before embedding; persistent cache in `task_enrichments` table; recompute gated on task-list hash change; focus-area v3.0.0 outputs all clusters with enriched descriptions
|
||||||
|
|
||||||
|
- Per-user feature freshness SLAs (#61) — 2026-05-06: `invalidated_by` mirrored into `ProfileFeature`; drift-detection test added
|
||||||
|
- MLflow tracing added to `ml/serving` for all agent calls — 2026-05-06: `ml/serving/mlflow_client.py`; activated by `MLFLOW_TRACKING_URI=http://mlflow:5000` (default in compose `full` profile); requires `--profile mlops` for the MLflow container. Issue #118 (M4) tracks removal from production critical path.
|
||||||
|
|
||||||
|
Active work (M2): *(all M2 items complete — see README for M3 planning)*
|
||||||
|
|
||||||
|
## ADR-0014 endpoint map (as of step 6)
|
||||||
|
|
||||||
|
| Endpoint | Purpose |
|
||||||
|
|----------|---------|
|
||||||
|
| `GET /api/profile` | Read-through: user globals + prefs (by scope) + consents + contexts |
|
||||||
|
| `PATCH /api/profile/prefs/:scope` | Upsert user_preferences rows (source='user') |
|
||||||
|
| `PATCH /api/profile/consents` | Grant / revoke consent keys |
|
||||||
|
| `PATCH /api/profile/contexts` | Create / activate / deactivate named contexts |
|
||||||
|
| `GET /api/agents/registry` | Manifest list (proxy to ml/serving; 60 s cache) |
|
||||||
|
| `POST /api/agents/:agentId/compute` | Internal: run agent compute for (user, agent) |
|
||||||
|
| `POST /agents/{agent_id}/infer` *(ml/serving)* | Run inference framework → `{inferred_prefs}` |
|
||||||
|
|
||||||
|
## Inference framework (ADR-0014 §3)
|
||||||
|
|
||||||
|
Lives in `ml/agents/inference/`. `run_inference(manifest, history)` evaluates all `InferredParam` entries in the manifest and returns `{key: value}`. Rules:
|
||||||
|
- Below `min_history` → emit `cold_start_default`
|
||||||
|
- `infer()` error → emit `cold_start_default` (never crashes)
|
||||||
|
- Results written to `user_preferences` with `source='inferred'`; keys with `source='user'` are never overwritten
|
||||||
|
|
||||||
|
Per-agent inferred params (all live in `ml/agents/<name>.py`):
|
||||||
|
|
||||||
|
| Agent | Inferred params | Notes |
|
||||||
|
|-------|----------------|-------|
|
||||||
|
| `time-of-day` | `preferred_hour`, `quiet_start`, `quiet_end`, `peak_hours`, `tz` | Quiet window = longest below-baseline hour run; peak = top-quartile done hours; tz cold-start only (from auth provider) |
|
||||||
|
| `momentum` | `engagement_trend`, `baseline_completions_per_day`, `stdev` | Baseline = 28d rolling mean done/day; snippet uses z-score language |
|
||||||
|
| `overdue-task` | `lateness_tolerance_days`, `project_realness` | Tolerance = p50 lateness from TaskCompletion history; realness = project median vs global median |
|
||||||
|
| `recent-patterns` | `lookback_days`, `weekly_cycle`, `daily_cycle` | Lookback sized to ≥30 done events; cycles use peak-to-mean ratio; snippet hints when strength > 0.5 |
|
||||||
|
| `focus-area` | *(none)* | No inferred params. Clusters tasks via LLM-enriched embeddings and outputs all areas with expanded descriptions. Recomputes only when task list changes (hash-gated). |
|
||||||
|
|
||||||
|
`UserHistory` carries both `events: list[FeedbackEvent]` and `task_completions: list[TaskCompletion]`. `AgentInferRequest` (ml/serving) accepts `task_completions: list[dict]` alongside `feedback_history`.
|
||||||
|
|
||||||
|
`min_history` is checked against `len(history.events)` (feedback events), **not** `task_completions`. Agents that infer from completions should set `min_history=0` and guard inside `infer()`.
|
||||||
|
|
||||||
## What NOT to do
|
## 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 (multi-agent orchestrator today, future LLM/hybrid variants), keep contract.
|
||||||
|
- Don't hardcode the agent list. The orchestrator is registry-driven (ADR-0014); adding/removing an agent is a manifest change in `ml/agents/<id>/`, never a recommender edit.
|
||||||
- Don't replace a policy in one step. New policies deploy shadow-first; promoted only after offline + online agreement with the incumbent (ADR-0002).
|
- Don't replace a policy in one step. New policies deploy shadow-first; promoted only after offline + online agreement with the incumbent (ADR-0002).
|
||||||
- Don't 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/OpenWebUI in the admin panel. They are external services; link out to them. The admin shell links to `o.alogins.net/mlflow`, `ai.alogins.net`.
|
||||||
|
- Don't `nats.publish()` directly from feature code. All publishes go through the in-process `Bus` (`services/api/src/events/bus.ts`); the NATS adapter (`events/nats.ts`) bridges every publish to JetStream when `NATS_URL` is set. This keeps subscribers, the ring-buffer tail used by the admin event viewer, and JetStream all in lockstep.
|
||||||
|
|
||||||
|
## Admin app
|
||||||
|
|
||||||
|
`apps/admin` rewrites `/api/*` → `$NEXT_PUBLIC_API_URL/api/*` via `next.config.ts`. So `apiFetch('/admin/stats')` in `apps/admin/src/lib/api.ts` hits the Express backend, not a Next.js route.
|
||||||
|
|
||||||
|
Running `tsc --noEmit -p apps/admin/tsconfig.json` always reports `Cannot find module 'next'` errors — expected outside the Next.js build context; use `next build` for real type errors.
|
||||||
|
|
||||||
|
## Auth / session pattern
|
||||||
|
|
||||||
|
Sessions use an `sid` cookie. Admin routes stack `requireAuth` (sets `req.userId`) then `requireAdmin` (checks `role = 'admin'` in DB). Token-based admin auth: `POST /api/auth/token` with `{ token }` matching `ADMIN_TOKEN` env var sets the `sid` cookie — used by Playwright and CI.
|
||||||
|
|||||||
111
README.md
111
README.md
@@ -67,68 +67,85 @@ docs/ architecture, adr, api
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## AI stack
|
||||||
|
|
||||||
|
oO is AI-native. Domain-specialized agents pre-compute snippets describing the user's state from one angle each; an orchestrator LLM reasons over the assembled snippets and produces one tip (ADR-0013). The orchestrator iterates a registry, not a hardcoded list (ADR-0014) — adding an agent is a manifest change, nothing else.
|
||||||
|
|
||||||
|
### Three-tier layout
|
||||||
|
|
||||||
|
| Tier | Service | Purpose | Where |
|
||||||
|
|------|---------|---------|-------|
|
||||||
|
| Inference | **Ollama** | Local LLM + embedding; no data leaves the host | `localhost:11434` |
|
||||||
|
| Routing | **LiteLLM** | Unified OpenAI-compatible API; model aliases; cloud fallback | `llm.alogins.net` (Agap shared) |
|
||||||
|
| Testing | **OpenWebUI** | Prompt iteration, model comparison, manual evals | `ai.alogins.net` (Agap shared) |
|
||||||
|
|
||||||
|
### Tip generation pipeline (ADR-0013, M2)
|
||||||
|
|
||||||
|
```
|
||||||
|
User signals Pre-compute agents (every 15 min)
|
||||||
|
(tasks, calendar, ──▶ ml/agents/{overdue-task, momentum, ──▶ agent_outputs
|
||||||
|
patterns, time) time-of-day, recent-patterns, (per-agent TTL)
|
||||||
|
focus-area, ...}
|
||||||
|
│
|
||||||
|
Eligibility filter: required consents + │
|
||||||
|
active context + per-user prefs (ADR-0014) ◀──┘
|
||||||
|
▼
|
||||||
|
Orchestrator prompt (`v4-orchestrator`)
|
||||||
|
= global prefs + active context + snippets
|
||||||
|
▼
|
||||||
|
LiteLLM ──▶ Ollama (local) / cloud fallback
|
||||||
|
▼
|
||||||
|
Tip shown to user
|
||||||
|
▼
|
||||||
|
User reaction (done / snooze / dismiss + dwell)
|
||||||
|
▼
|
||||||
|
Logged to tip_feedback for observability
|
||||||
|
(no online ML reward loop — see ADR-0013)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why LiteLLM as gateway:** All LLM calls use a single `LITELLM_URL` env var. Swapping from qwen2.5 to llama3.2, or routing a fraction to Claude for A/B, is a config change in LiteLLM — zero code change in oO. The model name in `tip_scores` tells you exactly which model produced each tip.
|
||||||
|
|
||||||
|
**Why Ollama first:** Tips contain personal context. Local inference means no user data leaves the host for the inference path. Cloud models (Anthropic, OpenAI) are opt-in fallbacks for evaluation and simulation only, gated behind `ANTHROPIC_API_KEY`.
|
||||||
|
|
||||||
|
### Models (planned; routes through LiteLLM)
|
||||||
|
|
||||||
|
| Alias | Model | Task |
|
||||||
|
|-------|-------|------|
|
||||||
|
| `tip-generator` | qwen2.5:1.5b (default) | Generate typed tip candidates from user context; local-first via Ollama |
|
||||||
|
| `embedder` | nomic-embed-text | Task clustering, semantic similarity for dedup; local via Ollama |
|
||||||
|
| `judge` | claude-haiku-4-5 (cloud, eval-only) | Offline sim judge; rates tip quality for A/B (requires `ANTHROPIC_API_KEY`) |
|
||||||
|
|
||||||
|
All model calls route through **LiteLLM** at `llm.alogins.net` (or `LITELLM_URL` env var) using model aliases. This decouples tip generation from model selection — swap the backend model in LiteLLM config without code changes. See ADR-0008.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
### Phase 0 — Walking skeleton *(M0)*
|
Issues and open work are tracked in [Gitea milestones](http://localhost:3000/alvis/oO/milestones). Pick an issue, check its milestone (= phase), read the service's `README.md`, ship.
|
||||||
Goal: a single user signs in with Google, connects Todoist, and sees one random Todoist task on a black page. Deletion works.
|
|
||||||
- [ ] Monorepo scaffold, CI skeleton, docker-compose dev env with `core`/`full` profiles
|
|
||||||
- [ ] `auth` on Auth.js with Google provider; OIDC-shaped boundary (ADR-0004)
|
|
||||||
- [ ] `integrations/todoist` OAuth2 flow + encrypted token vault + provider-side revocation
|
|
||||||
- [ ] `recommender` with `RandomPolicy`; stable `POST /recommend` contract
|
|
||||||
- [ ] `apps/web` — three pages (sign-in, connect, tip); PWA manifest; offline reaction queue
|
|
||||||
- [ ] ToS + Privacy Policy + consent capture on first sign-in
|
|
||||||
- [ ] Account-deletion endpoint: revokes providers, purges credentials, soft-deletes profile
|
|
||||||
- [ ] Metrics baseline: activation, first-tip reaction rate, dwell, retention (see `docs/architecture/metrics.md`)
|
|
||||||
- [ ] Deploy modular monolith + `ml/serving` stub to a single VM via docker-compose + Caddy
|
|
||||||
|
|
||||||
### Phase 1 — Real signal + in-the-moment delivery *(M1)*
|
### Phase 0 — Walking skeleton *(M0)* ✓ shipped
|
||||||
Goal: tips are picked, not drawn from a hat — and they arrive at the right moment on the web.
|
Single user signs in with Google, connects Todoist, sees one random task on a black page. Deletion works. Auth, integrations, recommender stub, PWA, feedback loop, ToS/privacy, metrics baseline.
|
||||||
- [ ] Event bus (NATS JetStream) with protobuf schemas (ADR-0005) + schema-registry CI gate
|
|
||||||
- [ ] Todoist event-driven sync (emit `signals.task.*`)
|
|
||||||
- [ ] Feature store skeleton + first five features (hour-of-day, overdue count, task age, priority, project)
|
|
||||||
- [ ] `ml/serving` FastAPI scorer; `RemotePolicy` wrapper in recommender
|
|
||||||
- [ ] **Global-then-personalize bandit**: pooled LinUCB over shared features, per-user residual when data allows
|
|
||||||
- [ ] Shadow-deploy infra: every new policy logs what it *would* have picked; promotion requires reward-parity
|
|
||||||
- [ ] Feedback loop: reactions → rewards; delayed rewards for tasks completed in Todoist directly
|
|
||||||
- [ ] **Web Push notifications** (VAPID) so the "magic" shows up without opening the app
|
|
||||||
- [ ] `notifier` (lite): web-push delivery, quiet-hours honoured, dedupe
|
|
||||||
- [ ] Apple OAuth added (deferred from M0)
|
|
||||||
|
|
||||||
### Phase 2 — Multi-source profile & trust *(M2)*
|
### Phase 1 — Real signal + in-the-moment delivery *(M1)* ✓ shipped
|
||||||
Goal: oO knows more than tasks, and users can see/control what we know.
|
Tips are picked, not drawn from a hat. Event bus, Todoist sync, task features, ε-greedy policy (v1 + v2), web push, NATS JetStream bridge, shadow-policy registry, offline sim framework, per-user profile features, admin + ML ops console (`apps/admin`).
|
||||||
- [ ] Integrations: Google Calendar, Apple Health (web import), generic webhook ingress
|
|
||||||
- [ ] Unified `Profile` model (identity, preferences, contexts, consents)
|
### Phase 2 — AI tips + multi-source signals *(M2)* ✓ shipped
|
||||||
- [ ] Timing signals (Page Visibility, Idle Detection, coarse location) — opt-in, transparent
|
Tips are AI-generated from user context. Multi-agent pipeline (ADR-0013): five pre-compute agents (`overdue-task`, `momentum`, `time-of-day`, `recent-patterns`, `focus-area`) emit prompt snippets; orchestrator LLM produces one tip. Unified Profile + agent registry + auto-inference framework (ADR-0014). LLM output validation + fallback. LiteLLM gateway, model benchmarking, prompt research, MLflow tracing.
|
||||||
- [ ] Advice library + mixing policy (todo vs advice vs ambient)
|
|
||||||
- [ ] User-facing data dashboard: what's stored, what's computed, export, delete-by-category
|
|
||||||
- [ ] Cost/usage observability
|
|
||||||
|
|
||||||
### Phase 3 — Native mobile *(M3)*
|
### Phase 3 — Native mobile *(M3)*
|
||||||
- [ ] iOS app (SwiftUI) with APNs push
|
iOS (SwiftUI + APNs) and Android (Compose + FCM). `notifier` service gains APNs + FCM channels. Auth migrated from Auth.js to dedicated OIDC provider. Decide-and-deliver scheduler. See [M3 milestone](http://localhost:3000/alvis/oO/milestone/3).
|
||||||
- [ ] Android app (Compose) with FCM push
|
|
||||||
- [ ] `notifier` gains APNs + FCM channels, per-device rate limits
|
|
||||||
- [ ] Migrate auth from Auth.js to dedicated OIDC provider (trigger from ADR-0004)
|
|
||||||
- [ ] Decide-and-deliver scheduler: per-user "is this tip worth interrupting now?" threshold
|
|
||||||
|
|
||||||
### Phase 4 — MLOps at scale *(M4)*
|
### Phase 4 — MLOps at scale *(M4)*
|
||||||
- [ ] Prefect/Airflow for batch feature materialization + retraining
|
Retraining pipeline, feature-to-prompt batch jobs, prompt optimization loop, LLM fine-tuning on reaction signals, modular-monolith import-boundary lint, online experiments framework, drift monitoring. See [M4 milestone](http://localhost:3000/alvis/oO/milestone/4).
|
||||||
- [ ] MLflow registry; shadow → A/B → launch pipeline as first-class
|
|
||||||
- [ ] Online experiments framework: deterministic assignment + bandit policies alongside fixed-split A/B
|
|
||||||
- [ ] Cross-user collaborative features (opt-in only); cohort slicing; fairness checks
|
|
||||||
- [ ] Drift monitoring (feature drift, prediction drift, reward drift); model cards per version
|
|
||||||
|
|
||||||
### Phase 5 — Production hardening *(M5)*
|
### Phase 5 — Production hardening *(M5)*
|
||||||
- [ ] Audit logging, rotation of provider tokens + internal signing keys
|
Audit logging, key rotation, k3s → k8s, multi-region, public integration SDK, billing. See [M5 milestone](http://localhost:3000/alvis/oO/milestone/5).
|
||||||
- [ ] **k3s** on existing VM, then k8s + HPA once multi-node justified (no cliff)
|
|
||||||
- [ ] Multi-region failover, Postgres PITR, event-bus mirroring
|
|
||||||
- [ ] Public integration SDK; sandbox tenancy for third-party connectors
|
|
||||||
- [ ] Billing + subscription tiers
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
This repo is split into independent modules; most tickets belong to exactly one. Pick an issue, check its milestone (= phase), read the service's `README.md`, ship.
|
This repo is split into independent modules; most tickets belong to exactly one. Pick an issue from [Gitea](http://localhost:3000/alvis/oO/issues), read the service's `README.md`, ship.
|
||||||
|
|
||||||
Conventions and per-service guidance live in [`CLAUDE.md`](CLAUDE.md).
|
Conventions and per-service guidance live in [`CLAUDE.md`](CLAUDE.md).
|
||||||
|
|
||||||
|
|||||||
56
apps/admin/README.md
Normal file
56
apps/admin/README.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# apps/admin — oO Admin Console
|
||||||
|
|
||||||
|
Next.js 15 app. Deployed at `admin.o.alogins.net` (dev: `http://localhost:3080`).
|
||||||
|
|
||||||
|
## Contract
|
||||||
|
|
||||||
|
- All routes are admin-only. The Next.js middleware calls `GET /api/user/me` on every request
|
||||||
|
and checks `role === 'admin'`. First admin is seeded via `ADMIN_SEED_EMAIL` env var at API startup.
|
||||||
|
- Admin write actions are appended to the `admin_actions` audit log in the DB.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Two ways to sign in:
|
||||||
|
|
||||||
|
| Method | How |
|
||||||
|
|--------|-----|
|
||||||
|
| Google OAuth | Click "Sign in with Google" on the login page |
|
||||||
|
| Token | `POST /api/auth/token` with `{ token }` matching `ADMIN_TOKEN` env var; sets `sid` cookie valid for 24 h. Used by Playwright tests and CI automation. |
|
||||||
|
|
||||||
|
## Pages
|
||||||
|
|
||||||
|
| Route | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `/` | Overview: DAU/WAU KPI cards, tips served, reaction breakdown, activation funnel |
|
||||||
|
| `/users` | User list (paginated, searchable) |
|
||||||
|
| `/users/:id` | User detail: identity, consents, integrations, profile features (completion rate, dismiss rate, dwell, preferred hour, tip volume), tip stats, reward history; revoke-integration + reset-bandit + rebuild-profile actions |
|
||||||
|
| `/audit` | Admin action audit log with timestamps and descriptions |
|
||||||
|
| `/events` | Live event stream viewer with filters by subject/user/time; tail of `signals.*` from ring buffer or NATS JetStream |
|
||||||
|
| `/features` | Feature store browser: features sent to `ml/serving` per scoring call; freshness status; per-feature SLA tracking |
|
||||||
|
| `/tips` | Served tips explorer: tip content, score, policy, model, feedback reactions; per-user timeline |
|
||||||
|
| `/reward-analytics` | Reaction distribution + per-policy / per-model / per-prompt-version breakdowns with avg reward; time-series and cohort slicing |
|
||||||
|
| `/data-quality` | Missing-feature rate heatmap, stale-token rate, daily completeness, per-feature freshness SLA status |
|
||||||
|
| `/health` | System health rollup: api, ml/serving, SQLite, event-bus, MLflow with 15s auto-refresh |
|
||||||
|
| `/sql` | Read-only SQL runner against SQLite; saved queries support; sunsets to Superset in M4 |
|
||||||
|
| `/simulate` | Offline simulation runner: launch `ml/experiments/sim`, track runs, judge selection, policy comparison |
|
||||||
|
| `/docs` | Admin documentation and ops runbooks inline |
|
||||||
|
| `/ops` | Operational dashboard (deprecation candidate; pending UX refinement #107) |
|
||||||
|
|
||||||
|
## Dev
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @oo/admin dev # starts on :3080
|
||||||
|
# also run the API: pnpm --filter @oo/api dev (port 3078)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Extraction criteria
|
||||||
|
|
||||||
|
Stays as a Next.js app in the monorepo permanently — it's not a candidate for extraction.
|
||||||
|
It gets richer (more pages, embedded MLflow/Grafana) but not split.
|
||||||
|
|
||||||
|
## Known issues & pending improvements
|
||||||
|
|
||||||
|
- `@tremor/react 3.x` declares a peer dep on React 18; the workspace uses React 19.
|
||||||
|
Works in practice. Will resolve naturally when Tremor ships React 19 support or when
|
||||||
|
we switch to Tremor v4 (which targets React 18+).
|
||||||
|
- UX refinements pending (#100–102): feedback options consolidation, config page UI migration, settings UI placement
|
||||||
18
apps/admin/next.config.ts
Normal file
18
apps/admin/next.config.ts
Normal 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
32
apps/admin/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
apps/admin/postcss.config.js
Normal file
6
apps/admin/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
12
apps/admin/src/app/audit/page.tsx
Normal file
12
apps/admin/src/app/audit/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
apps/admin/src/app/data-quality/page.tsx
Normal file
141
apps/admin/src/app/data-quality/page.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { AdminShell } from '@/components/AdminShell';
|
||||||
|
import { getDataQuality } from '@/lib/api';
|
||||||
|
|
||||||
|
function Pct({ value }: { value: number }) {
|
||||||
|
const pct = (value * 100).toFixed(1);
|
||||||
|
const color = value < 0.05 ? 'text-green-400' : value < 0.2 ? 'text-yellow-400' : 'text-red-400';
|
||||||
|
return <span className={color}>{pct}%</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PctGood({ value }: { value: number }) {
|
||||||
|
const pct = (value * 100).toFixed(1);
|
||||||
|
const color = value > 0.95 ? 'text-green-400' : value > 0.8 ? 'text-yellow-400' : 'text-red-400';
|
||||||
|
return <span className={color}>{pct}%</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTtl(sec: number): string {
|
||||||
|
if (sec < 60) return `${sec}s`;
|
||||||
|
if (sec < 3600) return `${Math.round(sec / 60)}m`;
|
||||||
|
if (sec < 86400) return `${Math.round(sec / 3600)}h`;
|
||||||
|
return `${Math.round(sec / 86400)}d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DataQualityPage() {
|
||||||
|
const [data, setData] = useState<Awaited<ReturnType<typeof getDataQuality>> | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getDataQuality()
|
||||||
|
.then(setData)
|
||||||
|
.catch((e) => setError(e.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminShell>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="text-xl font-semibold">Data quality</h1>
|
||||||
|
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||||
|
{loading && <p className="text-gray-500 text-sm">Loading…</p>}
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||||
|
<div className="bg-gray-900 border border-gray-800 rounded p-4">
|
||||||
|
<div className="text-xs text-gray-500 mb-1">Scoring calls (30d)</div>
|
||||||
|
<div className="text-2xl font-semibold">{data.scoringCallsLast30d}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900 border border-gray-800 rounded p-4">
|
||||||
|
<div className="text-xs text-gray-500 mb-1">Missing feature rate</div>
|
||||||
|
<div className="text-2xl font-semibold"><Pct value={data.missingFeatureRate} /></div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900 border border-gray-800 rounded p-4">
|
||||||
|
<div className="text-xs text-gray-500 mb-1">Integration tokens</div>
|
||||||
|
<div className="text-2xl font-semibold">{data.totalTokens}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900 border border-gray-800 rounded p-4">
|
||||||
|
<div className="text-xs text-gray-500 mb-1">Stale token rate (>7d)</div>
|
||||||
|
<div className="text-2xl font-semibold"><Pct value={data.staleTokenRate} /></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Profile freshness — #81 phase B.4 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h2 className="text-sm font-medium text-gray-400">Profile feature freshness</h2>
|
||||||
|
<p className="text-xs text-gray-600">
|
||||||
|
Eligible = users with any tip activity in the last 30 days. Stale = stored row past its TTL. Missing = no row computed yet.
|
||||||
|
</p>
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-800 text-gray-500 text-left">
|
||||||
|
<th className="py-2 pr-4">Feature</th>
|
||||||
|
<th className="py-2 pr-4">TTL</th>
|
||||||
|
<th className="py-2 pr-4">Eligible</th>
|
||||||
|
<th className="py-2 pr-4">Missing</th>
|
||||||
|
<th className="py-2 pr-4">Stale</th>
|
||||||
|
<th className="py-2">Coverage</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.profileFreshness.map((r) => {
|
||||||
|
const fresh = r.totalEligible - r.missing - r.stale;
|
||||||
|
const coverage = r.totalEligible > 0 ? fresh / r.totalEligible : 0;
|
||||||
|
return (
|
||||||
|
<tr key={r.feature} className="border-b border-gray-800/50">
|
||||||
|
<td className="py-1.5 pr-4 font-mono text-gray-400">{r.feature}</td>
|
||||||
|
<td className="py-1.5 pr-4 text-gray-500 tabular-nums">{formatTtl(r.ttlSec)}</td>
|
||||||
|
<td className="py-1.5 pr-4 text-gray-300 tabular-nums">{r.totalEligible}</td>
|
||||||
|
<td className={`py-1.5 pr-4 tabular-nums ${r.missing > 0 ? 'text-orange-400' : 'text-gray-500'}`}>{r.missing}</td>
|
||||||
|
<td className={`py-1.5 pr-4 tabular-nums ${r.stale > 0 ? 'text-yellow-400' : 'text-gray-500'}`}>{r.stale}</td>
|
||||||
|
<td className="py-1.5"><PctGood value={coverage} /></td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{data.profileFreshness.length === 0 && (
|
||||||
|
<tr><td colSpan={6} className="py-4 text-center text-gray-600">No features registered</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h2 className="text-sm font-medium text-gray-400">Daily feature completeness (14d)</h2>
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-800 text-gray-500 text-left">
|
||||||
|
<th className="py-2 pr-4">Date</th>
|
||||||
|
<th className="py-2 pr-4">Scoring calls</th>
|
||||||
|
<th className="py-2 pr-4">With features</th>
|
||||||
|
<th className="py-2 pr-4">Coverage</th>
|
||||||
|
<th className="py-2">Avg candidates</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.dailyQuality.map((row) => {
|
||||||
|
const coverage = row.total > 0 ? row.withFeatures / row.total : 0;
|
||||||
|
return (
|
||||||
|
<tr key={row.date} className="border-b border-gray-800/50">
|
||||||
|
<td className="py-1.5 pr-4 font-mono text-gray-500">{row.date}</td>
|
||||||
|
<td className="py-1.5 pr-4 text-gray-300">{row.total}</td>
|
||||||
|
<td className="py-1.5 pr-4 text-gray-300">{row.withFeatures}</td>
|
||||||
|
<td className="py-1.5 pr-4"><Pct value={coverage} /></td>
|
||||||
|
<td className="py-1.5 text-gray-300">{row.avgCandidates?.toFixed(1) ?? '—'}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{data.dailyQuality.length === 0 && (
|
||||||
|
<tr><td colSpan={5} className="py-4 text-center text-gray-600">No data yet</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AdminShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
apps/admin/src/app/docs/[category]/[slug]/page.tsx
Normal file
74
apps/admin/src/app/docs/[category]/[slug]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
apps/admin/src/app/docs/page.tsx
Normal file
82
apps/admin/src/app/docs/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
apps/admin/src/app/events/page.tsx
Normal file
93
apps/admin/src/app/events/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
apps/admin/src/app/features/page.tsx
Normal file
98
apps/admin/src/app/features/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
apps/admin/src/app/forbidden/page.tsx
Normal file
10
apps/admin/src/app/forbidden/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
apps/admin/src/app/globals.css
Normal file
123
apps/admin/src/app/globals.css
Normal 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; }
|
||||||
71
apps/admin/src/app/health/page.tsx
Normal file
71
apps/admin/src/app/health/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
apps/admin/src/app/layout.tsx
Normal file
15
apps/admin/src/app/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
apps/admin/src/app/login/page.tsx
Normal file
68
apps/admin/src/app/login/page.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [token, setToken] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleTokenLogin(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/token', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
setError((data as { error?: string }).error ?? 'Invalid token');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push('/');
|
||||||
|
} catch {
|
||||||
|
setError('Request failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<div className="text-center space-y-6 w-72">
|
||||||
|
<h1 className="text-2xl font-semibold">oO Admin</h1>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/sign-in"
|
||||||
|
className="inline-block px-4 py-2 bg-white text-black rounded text-sm font-medium hover:bg-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
Sign in with Google
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<form onSubmit={handleTokenLogin} className="space-y-3">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Admin token"
|
||||||
|
value={token}
|
||||||
|
onChange={(e) => setToken(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded text-sm focus:outline-none focus:border-gray-500"
|
||||||
|
/>
|
||||||
|
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !token}
|
||||||
|
className="w-full px-4 py-2 bg-gray-700 text-white rounded text-sm font-medium hover:bg-gray-600 disabled:opacity-40 transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? 'Signing in…' : 'Sign in with token'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
apps/admin/src/app/ops/page.tsx
Normal file
76
apps/admin/src/app/ops/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { AdminShell } from '@/components/AdminShell';
|
||||||
|
import { replaySignal } from '@/lib/api';
|
||||||
|
|
||||||
|
const VALID_SUBJECTS = ['signals.tip.served', 'signals.tip.feedback', 'signals.task.synced'];
|
||||||
|
|
||||||
|
export default function OpsPage() {
|
||||||
|
const [replaySubject, setReplaySubject] = useState(VALID_SUBJECTS[0]);
|
||||||
|
const [replayPayload, setReplayPayload] = useState('{\n "userId": "",\n "tipId": ""\n}');
|
||||||
|
const [msg, setMsg] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleReplay = async () => {
|
||||||
|
let payload: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(replayPayload);
|
||||||
|
} catch {
|
||||||
|
setError('Invalid JSON payload');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await replaySignal(replaySubject, payload);
|
||||||
|
setMsg(`Signal replayed: ${replaySubject}`);
|
||||||
|
setError('');
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminShell>
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold">Ops</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Live system controls — replay past signals for backfill or debugging, and find
|
||||||
|
per-user actions (token revoke) on the{' '}
|
||||||
|
<a href="/users" className="text-indigo-400 hover:underline">Users page</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{msg && <p className="text-green-400 text-sm">{msg}</p>}
|
||||||
|
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||||
|
|
||||||
|
{/* Replay signal */}
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-base font-medium text-gray-300">Replay signal</h2>
|
||||||
|
<p className="text-sm text-gray-500">Re-emit a past event on the in-process bus. Useful for backfill and testing.</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<select
|
||||||
|
value={replaySubject}
|
||||||
|
onChange={(e) => setReplaySubject(e.target.value)}
|
||||||
|
className="bg-gray-900 border border-gray-700 rounded px-3 py-1.5 text-sm text-gray-300 w-full max-w-sm"
|
||||||
|
>
|
||||||
|
{VALID_SUBJECTS.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||||
|
</select>
|
||||||
|
<textarea
|
||||||
|
value={replayPayload}
|
||||||
|
onChange={(e) => setReplayPayload(e.target.value)}
|
||||||
|
rows={6}
|
||||||
|
className="w-full max-w-xl bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm font-mono text-gray-300"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleReplay}
|
||||||
|
className="bg-indigo-600 hover:bg-indigo-500 text-white rounded px-4 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
Replay
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</AdminShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
apps/admin/src/app/page.tsx
Normal file
12
apps/admin/src/app/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
215
apps/admin/src/app/reward-analytics/page.tsx
Normal file
215
apps/admin/src/app/reward-analytics/page.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { AdminShell } from '@/components/AdminShell';
|
||||||
|
import { getRewardAnalytics, type QualityBreakdownRow } from '@/lib/api';
|
||||||
|
|
||||||
|
const ACTION_COLORS: Record<string, string> = {
|
||||||
|
done: 'bg-green-500',
|
||||||
|
helpful: 'bg-teal-500',
|
||||||
|
snooze: 'bg-yellow-500',
|
||||||
|
not_helpful: 'bg-orange-500',
|
||||||
|
dismiss: 'bg-red-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
function QualityBreakdown({ title, dimension, rows, emptyLabel }: {
|
||||||
|
title: string;
|
||||||
|
dimension: string;
|
||||||
|
rows: QualityBreakdownRow[];
|
||||||
|
emptyLabel: string; // shown when a row's key is null (e.g. bandit-only tips have no llm_model)
|
||||||
|
}) {
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
const totalServed = rows.reduce((sum, r) => sum + r.served, 0);
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h2 className="text-sm font-medium text-gray-400">{title}</h2>
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-800 text-gray-500 text-left">
|
||||||
|
<th className="py-2 pr-4">{dimension}</th>
|
||||||
|
<th className="py-2 pr-4">served</th>
|
||||||
|
<th className="py-2 pr-4">reaction rate</th>
|
||||||
|
<th className="py-2 pr-4">avg reward</th>
|
||||||
|
{['done', 'helpful', 'snooze', 'not_helpful', 'dismiss'].map((a) => (
|
||||||
|
<th key={a} className="py-2 pr-4">{a}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r) => {
|
||||||
|
const reacted = r.done + r.snooze + r.dismiss + r.helpful + r.not_helpful;
|
||||||
|
const reactionRate = r.served > 0 ? (reacted / r.served) * 100 : 0;
|
||||||
|
const avgReward = r.avgRewardMilli == null ? null : r.avgRewardMilli / 1000;
|
||||||
|
return (
|
||||||
|
<tr key={r.key ?? '__null__'} className="border-b border-gray-800/50">
|
||||||
|
<td className="py-2 pr-4 font-medium text-indigo-300">{r.key ?? <span className="text-gray-500 italic">{emptyLabel}</span>}</td>
|
||||||
|
<td className="py-2 pr-4 text-gray-300">{r.served}</td>
|
||||||
|
<td className="py-2 pr-4 text-gray-300">{reactionRate.toFixed(1)}%</td>
|
||||||
|
<td className="py-2 pr-4 text-gray-300">{avgReward == null ? '—' : avgReward.toFixed(2)}</td>
|
||||||
|
{(['done', 'helpful', 'snooze', 'not_helpful', 'dismiss'] as const).map((a) => (
|
||||||
|
<td key={a} className="py-2 pr-4 text-gray-300">{r[a]}</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p className="text-xs text-gray-600">{totalServed} tips served total.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RewardAnalyticsPage() {
|
||||||
|
const [days, setDays] = useState(30);
|
||||||
|
const [data, setData] = useState<Awaited<ReturnType<typeof getRewardAnalytics>> | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
getRewardAnalytics(days)
|
||||||
|
.then(setData)
|
||||||
|
.catch((e) => setError(e.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [days]);
|
||||||
|
|
||||||
|
// Aggregate totals per action across all days
|
||||||
|
const totals: Record<string, number> = {};
|
||||||
|
for (const row of data?.daily ?? []) {
|
||||||
|
totals[row.action] = (totals[row.action] ?? 0) + Number(row.count);
|
||||||
|
}
|
||||||
|
const grandTotal = Object.values(totals).reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
// Aggregate per policy
|
||||||
|
const policyMap: Record<string, Record<string, number>> = {};
|
||||||
|
for (const row of data?.byPolicy ?? []) {
|
||||||
|
if (!row.policy) continue;
|
||||||
|
policyMap[row.policy] ??= {};
|
||||||
|
if (row.action) policyMap[row.policy][row.action] = (policyMap[row.policy][row.action] ?? 0) + Number(row.count);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminShell>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<h1 className="text-xl font-semibold">Reward analytics</h1>
|
||||||
|
<select value={days} onChange={(e) => setDays(Number(e.target.value))} className="bg-gray-900 border border-gray-700 rounded px-2 py-1 text-sm text-gray-300">
|
||||||
|
<option value={7}>Last 7 days</option>
|
||||||
|
<option value={30}>Last 30 days</option>
|
||||||
|
<option value={90}>Last 90 days</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||||
|
{loading && <p className="text-gray-500 text-sm">Loading…</p>}
|
||||||
|
|
||||||
|
{/* Reaction breakdown bar */}
|
||||||
|
{grandTotal > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h2 className="text-sm font-medium text-gray-400">Reaction distribution ({grandTotal} total)</h2>
|
||||||
|
<div className="flex rounded overflow-hidden h-6">
|
||||||
|
{Object.entries(totals).map(([action, count]) => (
|
||||||
|
<div
|
||||||
|
key={action}
|
||||||
|
title={`${action}: ${count} (${((count / grandTotal) * 100).toFixed(1)}%)`}
|
||||||
|
className={`${ACTION_COLORS[action] ?? 'bg-gray-500'} transition-all`}
|
||||||
|
style={{ width: `${(count / grandTotal) * 100}%` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3 text-xs text-gray-400">
|
||||||
|
{Object.entries(totals).map(([action, count]) => (
|
||||||
|
<span key={action} className="flex items-center gap-1">
|
||||||
|
<span className={`inline-block w-2 h-2 rounded-full ${ACTION_COLORS[action] ?? 'bg-gray-500'}`} />
|
||||||
|
{action}: {count} ({((count / grandTotal) * 100).toFixed(1)}%)
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Per-policy table */}
|
||||||
|
{Object.keys(policyMap).length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h2 className="text-sm font-medium text-gray-400">Per-policy reactions</h2>
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-800 text-gray-500 text-left">
|
||||||
|
<th className="py-2 pr-4">Policy</th>
|
||||||
|
{['done', 'helpful', 'snooze', 'not_helpful', 'dismiss'].map((a) => (
|
||||||
|
<th key={a} className="py-2 pr-4">{a}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{Object.entries(policyMap).map(([policy, actions]) => (
|
||||||
|
<tr key={policy} className="border-b border-gray-800/50">
|
||||||
|
<td className="py-2 pr-4 font-medium text-indigo-300">{policy}</td>
|
||||||
|
{['done', 'helpful', 'snooze', 'not_helpful', 'dismiss'].map((a) => (
|
||||||
|
<td key={a} className="py-2 pr-4 text-gray-300">{actions[a] ?? 0}</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* LLM quality breakdowns (#92) */}
|
||||||
|
{data && (
|
||||||
|
<>
|
||||||
|
<QualityBreakdown
|
||||||
|
title="Per LLM model"
|
||||||
|
dimension="llm_model"
|
||||||
|
rows={data.byModel ?? []}
|
||||||
|
emptyLabel="(bandit-only)"
|
||||||
|
/>
|
||||||
|
<QualityBreakdown
|
||||||
|
title="Per prompt version"
|
||||||
|
dimension="prompt_version"
|
||||||
|
rows={data.byPromptVersion ?? []}
|
||||||
|
emptyLabel="(unset)"
|
||||||
|
/>
|
||||||
|
<QualityBreakdown
|
||||||
|
title="Per tip kind"
|
||||||
|
dimension="tip_kind"
|
||||||
|
rows={data.byKind ?? []}
|
||||||
|
emptyLabel="(unset)"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Daily table */}
|
||||||
|
{(data?.daily?.length ?? 0) > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h2 className="text-sm font-medium text-gray-400">Daily breakdown</h2>
|
||||||
|
<div className="overflow-x-auto max-h-80">
|
||||||
|
<table className="w-full text-xs font-mono">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-800 text-gray-500 text-left">
|
||||||
|
<th className="py-1.5 pr-4">Date</th>
|
||||||
|
<th className="py-1.5 pr-4">Action</th>
|
||||||
|
<th className="py-1.5">Count</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data!.daily.map((row, i) => (
|
||||||
|
<tr key={i} className="border-b border-gray-800/40">
|
||||||
|
<td className="py-1 pr-4 text-gray-500">{row.date}</td>
|
||||||
|
<td className="py-1 pr-4 text-gray-300">{row.action}</td>
|
||||||
|
<td className="py-1 text-gray-300">{row.count}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && grandTotal === 0 && (
|
||||||
|
<p className="text-gray-500 text-sm">No reaction data in this period.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AdminShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
apps/admin/src/app/simulate/page.tsx
Normal file
111
apps/admin/src/app/simulate/page.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { AdminShell } from '@/components/AdminShell';
|
||||||
|
import { getSimulationRuns, SimRun } from '@/lib/api';
|
||||||
|
|
||||||
|
const mlflowBase = process.env.NEXT_PUBLIC_MLFLOW_URL ?? '/mlflow';
|
||||||
|
|
||||||
|
function mlflowRunUrl(runId: string) {
|
||||||
|
return `${mlflowBase}/#/experiments/1/runs/${runId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: string }) {
|
||||||
|
const cls: Record<string, string> = {
|
||||||
|
running: 'bg-blue-900 text-blue-300 border-blue-800',
|
||||||
|
done: 'bg-green-900 text-green-300 border-green-800',
|
||||||
|
failed: 'bg-red-900 text-red-300 border-red-800',
|
||||||
|
pending: 'bg-gray-800 text-gray-400 border-gray-700',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded border ${cls[status] ?? cls.pending}`}>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryRow({ run }: { run: SimRun }) {
|
||||||
|
const summary = run.summaryJson ? JSON.parse(run.summaryJson) as Record<string, { total_reward: number; mean_reward: number; n_pulls: number }> : null;
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900 border border-gray-800 rounded p-4 space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-xs text-gray-500">{run.id}</span>
|
||||||
|
<StatusBadge status={run.status} />
|
||||||
|
{run.winner && <span className="text-xs text-indigo-400">winner: {run.winner}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600">
|
||||||
|
{run.nUsers}u × {run.nRounds}r × {run.tasksPerRound}t/r — {run.judgeMode} judge
|
||||||
|
{' · '}{new Date(run.createdAt).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{run.mlflowRunId && (
|
||||||
|
<a href={mlflowRunUrl(run.mlflowRunId)} target="_blank" rel="noreferrer"
|
||||||
|
className="text-xs text-indigo-400 hover:underline">MLflow ↗</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{summary && (
|
||||||
|
<div className="grid grid-cols-2 gap-2 pt-1 lg:grid-cols-3">
|
||||||
|
{Object.entries(summary).map(([policy, s]) => (
|
||||||
|
<div key={policy} className={`rounded border p-2 text-xs ${policy === run.winner ? 'border-indigo-700 bg-indigo-950' : 'border-gray-800'}`}>
|
||||||
|
<div className="font-mono font-medium text-gray-300 mb-1">{policy}</div>
|
||||||
|
<div className="text-gray-500 space-y-0.5">
|
||||||
|
<div>total <span className="text-gray-300">{s.total_reward.toFixed(2)}</span></div>
|
||||||
|
<div>mean <span className="text-gray-300">{s.mean_reward.toFixed(4)}</span></div>
|
||||||
|
<div>pulls <span className="text-gray-300">{s.n_pulls}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SimulatePage() {
|
||||||
|
const [runs, setRuns] = useState<SimRun[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const refresh = () =>
|
||||||
|
getSimulationRuns()
|
||||||
|
.then((r) => setRuns(r.runs))
|
||||||
|
.catch((e) => setError(e.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
const t = setInterval(refresh, 8_000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminShell>
|
||||||
|
<div className="space-y-6 max-w-4xl">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold">Simulations</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Offline policy comparisons — trigger via the admin API or CLI. Results are logged to{' '}
|
||||||
|
<a href={mlflowBase} target="_blank" rel="noreferrer" className="text-indigo-400 hover:underline">MLflow ↗</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-xs text-gray-500 uppercase tracking-widest font-medium">
|
||||||
|
Run history
|
||||||
|
{loading && <span className="text-gray-600 ml-2 normal-case">loading…</span>}
|
||||||
|
</h2>
|
||||||
|
{runs.length === 0 && !loading && (
|
||||||
|
<p className="text-gray-600 text-sm">No simulation runs yet.</p>
|
||||||
|
)}
|
||||||
|
{runs.map((r) => <SummaryRow key={r.id} run={r} />)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</AdminShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
152
apps/admin/src/app/sql/page.tsx
Normal file
152
apps/admin/src/app/sql/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
apps/admin/src/app/tips/page.tsx
Normal file
97
apps/admin/src/app/tips/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
apps/admin/src/app/users/[id]/page.tsx
Normal file
17
apps/admin/src/app/users/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
apps/admin/src/app/users/page.tsx
Normal file
12
apps/admin/src/app/users/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
apps/admin/src/components/AdminShell.tsx
Normal file
145
apps/admin/src/components/AdminShell.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const mlflowUrl = process.env.NEXT_PUBLIC_MLFLOW_URL ?? '/mlflow';
|
||||||
|
|
||||||
|
type NavItem = {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
external?: boolean;
|
||||||
|
svcName?: string; // key in the health services map
|
||||||
|
};
|
||||||
|
|
||||||
|
type NavSection = {
|
||||||
|
label?: string;
|
||||||
|
items: NavItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const NAV: NavSection[] = [
|
||||||
|
{
|
||||||
|
items: [{ href: '/', label: 'Overview' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Signals',
|
||||||
|
items: [
|
||||||
|
{ href: '/users', label: 'Users' },
|
||||||
|
{ href: '/events', label: 'Events' },
|
||||||
|
{ href: '/features', label: 'Features' },
|
||||||
|
{ href: '/data-quality', label: 'Data quality' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Recommender',
|
||||||
|
items: [
|
||||||
|
{ href: '/tips', label: 'Tips' },
|
||||||
|
{ href: '/reward-analytics', label: 'Rewards' },
|
||||||
|
{ href: '/simulate', label: 'Simulations' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Operations',
|
||||||
|
items: [
|
||||||
|
{ href: '/health', label: 'Health' },
|
||||||
|
{ href: '/ops', label: 'Ops' },
|
||||||
|
{ href: '/sql', label: 'SQL runner' },
|
||||||
|
{ href: '/audit', label: 'Audit log' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Resources',
|
||||||
|
items: [
|
||||||
|
{ href: '/docs', label: 'Docs' },
|
||||||
|
{ href: mlflowUrl, label: 'MLflow ↗', external: true, svcName: 'mlflow' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUS_DOT: Record<string, string> = {
|
||||||
|
ok: 'bg-green-500',
|
||||||
|
degraded: 'bg-yellow-400',
|
||||||
|
down: 'bg-red-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AdminShell({ children }: { children: React.ReactNode }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [svcStatus, setSvcStatus] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/admin/health', { credentials: 'include' })
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: { services?: { name: string; status: string }[] }) => {
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
for (const s of data.services ?? []) map[s.name] = s.status;
|
||||||
|
setSvcStatus(map);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className="w-52 flex-shrink-0 border-r border-gray-800 bg-gray-950 flex flex-col">
|
||||||
|
<div className="px-5 py-4 border-b border-gray-800">
|
||||||
|
<span className="text-lg font-bold tracking-tight">oO</span>
|
||||||
|
<span className="ml-2 text-xs text-gray-500 font-medium uppercase tracking-widest">
|
||||||
|
Admin
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<nav className="flex-1 px-2 py-3 overflow-y-auto">
|
||||||
|
{NAV.map((section, sectionIdx) => (
|
||||||
|
<div key={section.label ?? `top-${sectionIdx}`} className={sectionIdx === 0 ? '' : 'pt-3'}>
|
||||||
|
{section.label && (
|
||||||
|
<div className="pb-1 px-3">
|
||||||
|
<span className="text-xs text-gray-600 uppercase tracking-wider font-medium">
|
||||||
|
{section.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{section.items.map((item) => {
|
||||||
|
const active =
|
||||||
|
!item.external &&
|
||||||
|
(item.href === '/' ? pathname === '/' : pathname.startsWith(item.href));
|
||||||
|
const className = `flex items-center gap-2 px-3 py-2 rounded text-sm transition-colors ${
|
||||||
|
active
|
||||||
|
? 'bg-gray-800 text-white font-medium'
|
||||||
|
: item.external
|
||||||
|
? 'text-gray-500 hover:text-white hover:bg-gray-900'
|
||||||
|
: 'text-gray-400 hover:text-white hover:bg-gray-900'
|
||||||
|
}`;
|
||||||
|
const dot = item.svcName
|
||||||
|
? svcStatus[item.svcName]
|
||||||
|
? <span className={`inline-block w-1.5 h-1.5 rounded-full flex-shrink-0 ${STATUS_DOT[svcStatus[item.svcName]] ?? STATUS_DOT.down}`} />
|
||||||
|
: <span className="inline-block w-1.5 h-1.5 rounded-full flex-shrink-0 bg-gray-700" />
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return item.external ? (
|
||||||
|
<a
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{dot}
|
||||||
|
{item.label}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<Link key={item.href} href={item.href} className={className}>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
{/* Main content */}
|
||||||
|
<main className="flex-1 overflow-auto p-6">{children}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
apps/admin/src/components/AuditLog.tsx
Normal file
112
apps/admin/src/components/AuditLog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
165
apps/admin/src/components/OverviewDashboard.tsx
Normal file
165
apps/admin/src/components/OverviewDashboard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
231
apps/admin/src/components/UserDetail.tsx
Normal file
231
apps/admin/src/components/UserDetail.tsx
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
getUserDetail,
|
||||||
|
revokeIntegration,
|
||||||
|
resetBandit,
|
||||||
|
rebuildUserProfile,
|
||||||
|
type AdminUserDetail,
|
||||||
|
type ProfileFeatureView,
|
||||||
|
} from '@/lib/api';
|
||||||
|
|
||||||
|
export function UserDetail({ userId }: { userId: string }) {
|
||||||
|
const [data, setData] = useState<AdminUserDetail | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState<string | null>(null); // which action is running
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getUserDetail(userId)
|
||||||
|
.then(setData)
|
||||||
|
.catch((e) => setError(String(e.message)));
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
async function handleRevoke(provider: string) {
|
||||||
|
if (!confirm(`Revoke ${provider} for this user?`)) return;
|
||||||
|
setBusy(`revoke:${provider}`);
|
||||||
|
try {
|
||||||
|
await revokeIntegration(userId, provider);
|
||||||
|
setData((d) =>
|
||||||
|
d
|
||||||
|
? { ...d, integrations: d.integrations.filter((i) => i.provider !== provider) }
|
||||||
|
: d,
|
||||||
|
);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
alert(`Failed: ${(e as Error).message}`);
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResetBandit() {
|
||||||
|
if (!confirm("Reset this user's LinUCB model? Their personalization will start over.")) return;
|
||||||
|
setBusy('bandit');
|
||||||
|
try {
|
||||||
|
await resetBandit(userId);
|
||||||
|
alert('Bandit reset.');
|
||||||
|
} catch (e: unknown) {
|
||||||
|
alert(`Failed: ${(e as Error).message}`);
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRebuildProfile() {
|
||||||
|
setBusy('profile');
|
||||||
|
try {
|
||||||
|
const { profile } = await rebuildUserProfile(userId);
|
||||||
|
setData((d) => (d ? { ...d, profile } : d));
|
||||||
|
} catch (e: unknown) {
|
||||||
|
alert(`Failed: ${(e as Error).message}`);
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) return <p className="text-red-400 text-sm">Error: {error}</p>;
|
||||||
|
if (!data) return <p className="text-gray-500 text-sm">Loading…</p>;
|
||||||
|
|
||||||
|
const { user, integrations, tipsServed, lastTipAt, recentFeedback, profile } = data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-2xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold">{user.name ?? user.email}</h1>
|
||||||
|
<p className="text-sm text-gray-400 mt-0.5">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleResetBandit}
|
||||||
|
disabled={busy === 'bandit'}
|
||||||
|
className="px-3 py-1.5 text-xs rounded border border-gray-700 hover:border-red-600 hover:text-red-400 transition-colors disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{busy === 'bandit' ? 'Resetting…' : 'Reset bandit'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Identity */}
|
||||||
|
<Section title="Identity">
|
||||||
|
<Row label="ID" value={user.id} mono />
|
||||||
|
<Row label="Role" value={user.role} />
|
||||||
|
<Row label="Consent" value={user.consentGiven ? `yes (${user.consentAt?.slice(0, 10)})` : 'no'} />
|
||||||
|
<Row label="Joined" value={user.createdAt.slice(0, 10)} />
|
||||||
|
{user.deletedAt && <Row label="Deleted" value={user.deletedAt.slice(0, 10)} />}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Integrations */}
|
||||||
|
<Section title="Integrations">
|
||||||
|
{integrations.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500">No integrations connected.</p>
|
||||||
|
) : (
|
||||||
|
integrations.map((i) => (
|
||||||
|
<div key={i.provider} className="flex items-center justify-between py-1">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm capitalize">{i.provider}</span>
|
||||||
|
<span className="ml-2 text-xs text-gray-500">
|
||||||
|
connected {i.connectedAt.slice(0, 10)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRevoke(i.provider)}
|
||||||
|
disabled={busy === `revoke:${i.provider}`}
|
||||||
|
className="text-xs text-red-500 hover:text-red-400 transition-colors disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{busy === `revoke:${i.provider}` ? 'Revoking…' : 'Revoke'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Profile features (#81 phase B) */}
|
||||||
|
<Section
|
||||||
|
title="Profile features"
|
||||||
|
action={
|
||||||
|
<button
|
||||||
|
onClick={handleRebuildProfile}
|
||||||
|
disabled={busy === 'profile'}
|
||||||
|
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{busy === 'profile' ? 'Rebuilding…' : 'Rebuild'}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ProfileTable rows={profile} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Tip stats */}
|
||||||
|
<Section title="Tip activity">
|
||||||
|
<Row label="Tips served (all time)" value={String(tipsServed)} />
|
||||||
|
<Row label="Last tip" value={lastTipAt?.slice(0, 19).replace('T', ' ') ?? '—'} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Feedback history */}
|
||||||
|
<Section title="Recent feedback">
|
||||||
|
{recentFeedback.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500">No feedback recorded.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{recentFeedback.map((f) => (
|
||||||
|
<div key={f.id} className="flex items-center gap-4 text-sm">
|
||||||
|
<span
|
||||||
|
className={`w-16 text-xs font-medium ${
|
||||||
|
f.action === 'done'
|
||||||
|
? 'text-emerald-400'
|
||||||
|
: f.action === 'snooze'
|
||||||
|
? 'text-yellow-400'
|
||||||
|
: 'text-red-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{f.action}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-500 text-xs tabular-nums">
|
||||||
|
{f.createdAt.slice(0, 19).replace('T', ' ')}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-600 text-xs font-mono truncate">{f.tipId}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-gray-800 bg-gray-900 px-5 py-4 space-y-2">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-widest font-medium">{title}</p>
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProfileTable({ rows }: { rows: ProfileFeatureView[] }) {
|
||||||
|
if (rows.length === 0) return <p className="text-sm text-gray-500">No profile features registered.</p>;
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{rows.map((r) => (
|
||||||
|
<div key={r.name} className="flex items-baseline gap-3 text-sm">
|
||||||
|
<span className="w-44 flex-shrink-0 text-gray-500 font-mono text-xs" title={r.description}>
|
||||||
|
{r.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-200 tabular-nums w-24">{formatValue(r)}</span>
|
||||||
|
<span className="text-xs text-gray-500 tabular-nums">{formatAge(r)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(r: ProfileFeatureView): string {
|
||||||
|
if (r.value == null) return '—';
|
||||||
|
if (r.dtype === 'numeric') {
|
||||||
|
const n = Number(r.value);
|
||||||
|
return Math.abs(n) < 10 ? n.toFixed(3) : n.toFixed(0);
|
||||||
|
}
|
||||||
|
return String(r.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAge(r: ProfileFeatureView): string {
|
||||||
|
if (r.ageSec == null) return 'never computed';
|
||||||
|
const mins = r.ageSec / 60;
|
||||||
|
const ageLabel = mins < 60 ? `${mins.toFixed(0)}m` : mins < 1440 ? `${(mins / 60).toFixed(1)}h` : `${(mins / 1440).toFixed(1)}d`;
|
||||||
|
const tag = r.fresh ? 'fresh' : 'stale';
|
||||||
|
return `${ageLabel} (${tag})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-baseline gap-3 text-sm">
|
||||||
|
<span className="w-36 flex-shrink-0 text-gray-500">{label}</span>
|
||||||
|
<span className={mono ? 'font-mono text-xs text-gray-300' : 'text-gray-200'}>{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
apps/admin/src/components/UsersTable.tsx
Normal file
137
apps/admin/src/components/UsersTable.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { getUsers, type AdminUser } from '@/lib/api';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
export function UsersTable() {
|
||||||
|
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
getUsers(PAGE_SIZE, offset)
|
||||||
|
.then(({ users, total }) => {
|
||||||
|
setUsers(users);
|
||||||
|
setTotal(total);
|
||||||
|
})
|
||||||
|
.catch((e) => setError(String(e.message)))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [offset]);
|
||||||
|
|
||||||
|
if (error) return <p className="text-red-400 text-sm">Error: {error}</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-semibold">Users</h1>
|
||||||
|
<span className="text-sm text-gray-500">{total} total</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-gray-800 overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-900 border-b border-gray-800">
|
||||||
|
<tr>
|
||||||
|
{['ID', 'Email', 'Name', 'Role', 'Consent', 'Joined', 'Status'].map((h) => (
|
||||||
|
<th
|
||||||
|
key={h}
|
||||||
|
className="text-left px-4 py-2.5 text-xs text-gray-500 font-medium uppercase tracking-wide"
|
||||||
|
>
|
||||||
|
{h}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-800">
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-4 py-6 text-center text-gray-500">
|
||||||
|
Loading…
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : users.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-4 py-6 text-center text-gray-500">
|
||||||
|
No users yet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
users.map((u) => (
|
||||||
|
<tr
|
||||||
|
key={u.id}
|
||||||
|
className="hover:bg-gray-900 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-2.5 text-gray-500 text-xs font-mono tabular-nums">
|
||||||
|
{u.id.slice(0, 8)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<Link href={`/users/${u.id}`} className="hover:underline text-indigo-400">
|
||||||
|
{u.email}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-gray-300">{u.name ?? '—'}</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<span
|
||||||
|
className={`px-1.5 py-0.5 rounded text-xs font-medium ${
|
||||||
|
u.role === 'admin'
|
||||||
|
? 'bg-indigo-900 text-indigo-300'
|
||||||
|
: 'bg-gray-800 text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{u.role}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
{u.consentGiven ? (
|
||||||
|
<span className="text-emerald-400 text-xs">yes</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-600 text-xs">no</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-gray-400 text-xs tabular-nums">
|
||||||
|
{u.createdAt.slice(0, 10)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
{u.deletedAt ? (
|
||||||
|
<span className="text-red-500 text-xs">deleted</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-emerald-500 text-xs">active</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{total > PAGE_SIZE && (
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<button
|
||||||
|
disabled={offset === 0}
|
||||||
|
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
|
||||||
|
className="px-3 py-1.5 rounded border border-gray-700 disabled:opacity-30 hover:border-gray-500 transition-colors"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span className="text-gray-500">
|
||||||
|
{offset + 1}–{Math.min(offset + PAGE_SIZE, total)} of {total}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
disabled={offset + PAGE_SIZE >= total}
|
||||||
|
onClick={() => setOffset(offset + PAGE_SIZE)}
|
||||||
|
className="px-3 py-1.5 rounded border border-gray-700 disabled:opacity-30 hover:border-gray-500 transition-colors"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
295
apps/admin/src/lib/api.ts
Normal file
295
apps/admin/src/lib/api.ts
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
const API = '/api';
|
||||||
|
|
||||||
|
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(`${API}${path}`, {
|
||||||
|
credentials: 'include',
|
||||||
|
...init,
|
||||||
|
headers: { 'Content-Type': 'application/json', ...init?.headers },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||||
|
throw Object.assign(new Error(err.error ?? 'API error'), { status: res.status });
|
||||||
|
}
|
||||||
|
return res.json() as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface AdminStats {
|
||||||
|
dau: number;
|
||||||
|
wau: number;
|
||||||
|
tipsServedLast7d: number;
|
||||||
|
reactionsLast7d: Record<string, number>;
|
||||||
|
totalUsers: number;
|
||||||
|
activatedUsers: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string | null;
|
||||||
|
image: string | null;
|
||||||
|
role: string;
|
||||||
|
consentGiven: boolean;
|
||||||
|
consentAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileFeatureView {
|
||||||
|
name: string;
|
||||||
|
value: number | string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
ageSec: number | null;
|
||||||
|
fresh: boolean;
|
||||||
|
ttlSec: number;
|
||||||
|
dtype: 'numeric' | 'categorical';
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminUserDetail {
|
||||||
|
user: AdminUser;
|
||||||
|
integrations: { provider: string; connectedAt: string }[];
|
||||||
|
tipsServed: number;
|
||||||
|
lastTipAt: string | null;
|
||||||
|
recentFeedback: { id: string; action: string; createdAt: string; tipId: string }[];
|
||||||
|
profile: ProfileFeatureView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditAction {
|
||||||
|
id: string;
|
||||||
|
adminId: string;
|
||||||
|
action: string;
|
||||||
|
targetType: string | null;
|
||||||
|
targetId: string | null;
|
||||||
|
detail: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoredEvent {
|
||||||
|
id: number;
|
||||||
|
subject: string;
|
||||||
|
payload: unknown;
|
||||||
|
ts: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TipScore {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
tipId: string;
|
||||||
|
policy: string;
|
||||||
|
mlScore: number | null;
|
||||||
|
featuresJson: string | null;
|
||||||
|
candidateCount: number | null;
|
||||||
|
latencyMs: number | null;
|
||||||
|
servedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HealthStatus {
|
||||||
|
ok: boolean;
|
||||||
|
checkedAt: string;
|
||||||
|
services: { name: string; status: string; latencyMs: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface SavedQuery {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
sql: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BanditStats {
|
||||||
|
user_id: string;
|
||||||
|
pulls: number;
|
||||||
|
reward_count: number;
|
||||||
|
cumulative_reward: number;
|
||||||
|
estimated_mean_reward: number;
|
||||||
|
theta: number[];
|
||||||
|
last_updated: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeatureHistory {
|
||||||
|
user_id: string;
|
||||||
|
history: { ts: string; features: Record<string, unknown>; score: number; tip_id: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fetchers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function getStats() {
|
||||||
|
return apiFetch<AdminStats>('/admin/stats');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUsers(limit = 50, offset = 0) {
|
||||||
|
return apiFetch<{ users: AdminUser[]; total: number }>(
|
||||||
|
`/admin/users?limit=${limit}&offset=${offset}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserDetail(id: string) {
|
||||||
|
return apiFetch<AdminUserDetail>(`/admin/users/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revokeIntegration(userId: string, provider: string) {
|
||||||
|
return apiFetch<{ ok: boolean }>(`/admin/users/${userId}/revoke-integration`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ provider }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetBandit(userId: string) {
|
||||||
|
return apiFetch<{ ok: boolean }>(`/admin/users/${userId}/reset-bandit`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rebuildUserProfile(userId: string) {
|
||||||
|
return apiFetch<{ ok: boolean; profile: ProfileFeatureView[] }>(
|
||||||
|
`/admin/users/${userId}/profile/rebuild`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuditLog(limit = 50, offset = 0) {
|
||||||
|
return apiFetch<{ actions: AuditAction[]; total: number }>(
|
||||||
|
`/admin/audit?limit=${limit}&offset=${offset}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEvents(params: { subject?: string; userId?: string; limit?: number; since?: number } = {}) {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (params.subject) q.set('subject', params.subject);
|
||||||
|
if (params.userId) q.set('userId', params.userId);
|
||||||
|
if (params.limit) q.set('limit', String(params.limit));
|
||||||
|
if (params.since) q.set('since', String(params.since));
|
||||||
|
return apiFetch<{ events: StoredEvent[]; nextSince: number }>(`/admin/events?${q}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTips(params: { limit?: number; offset?: number; userId?: string } = {}) {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (params.limit) q.set('limit', String(params.limit));
|
||||||
|
if (params.offset) q.set('offset', String(params.offset));
|
||||||
|
if (params.userId) q.set('userId', params.userId);
|
||||||
|
return apiFetch<{ tips: TipScore[]; total: number }>(`/admin/tips?${q}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type QualityBreakdownRow = {
|
||||||
|
key: string | null;
|
||||||
|
served: number;
|
||||||
|
done: number;
|
||||||
|
snooze: number;
|
||||||
|
dismiss: number;
|
||||||
|
helpful: number;
|
||||||
|
not_helpful: number;
|
||||||
|
avgRewardMilli: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getRewardAnalytics(days = 30) {
|
||||||
|
return apiFetch<{
|
||||||
|
daily: { date: string; action: string; count: number }[];
|
||||||
|
byPolicy: { policy: string; action: string; count: number }[];
|
||||||
|
byHour: { action: string; count: number; avgHour: number }[];
|
||||||
|
byModel: QualityBreakdownRow[];
|
||||||
|
byPromptVersion: QualityBreakdownRow[];
|
||||||
|
byKind: QualityBreakdownRow[];
|
||||||
|
}>(`/admin/reward-analytics?days=${days}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeatureFreshnessRow {
|
||||||
|
feature: string;
|
||||||
|
ttlSec: number;
|
||||||
|
totalEligible: number;
|
||||||
|
missing: number;
|
||||||
|
stale: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDataQuality() {
|
||||||
|
return apiFetch<{
|
||||||
|
scoringCallsLast30d: number;
|
||||||
|
missingFeatureRate: number;
|
||||||
|
staleTokenRate: number;
|
||||||
|
totalTokens: number;
|
||||||
|
staleTokens: number;
|
||||||
|
dailyQuality: { date: string; total: number; withFeatures: number; avgCandidates: number }[];
|
||||||
|
profileFreshness: FeatureFreshnessRow[];
|
||||||
|
}>('/admin/data-quality');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHealth() {
|
||||||
|
return apiFetch<HealthStatus>('/admin/health');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function replaySignal(subject: string, payload: Record<string, unknown>) {
|
||||||
|
return apiFetch<{ ok: boolean }>('/admin/replay-signal', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ subject, payload }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runSql(query: string) {
|
||||||
|
return apiFetch<{ rows: unknown[]; rowCount: number }>('/admin/sql', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ query }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSavedQueries() {
|
||||||
|
return apiFetch<{ queries: SavedQuery[] }>('/admin/saved-queries');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveQuery(name: string, querySql: string) {
|
||||||
|
return apiFetch<{ id: string }>('/admin/saved-queries', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name, querySql }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteSavedQuery(id: string) {
|
||||||
|
return apiFetch<{ ok: boolean }>(`/admin/saved-queries/${id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Simulations ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface SimRun {
|
||||||
|
id: string;
|
||||||
|
policyA: string;
|
||||||
|
policyB: string;
|
||||||
|
nUsers: number;
|
||||||
|
nRounds: number;
|
||||||
|
tasksPerRound: number;
|
||||||
|
judgeMode: string;
|
||||||
|
nPolicies: number;
|
||||||
|
status: 'pending' | 'running' | 'done' | 'failed';
|
||||||
|
summaryJson: string | null;
|
||||||
|
winner: string | null;
|
||||||
|
personaBreakdownJson: string | null;
|
||||||
|
mlflowRunId: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
finishedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimStartRequest {
|
||||||
|
nUsers?: number;
|
||||||
|
nRounds?: number;
|
||||||
|
tasksPerRound?: number;
|
||||||
|
judgeMode?: 'rule' | 'llm';
|
||||||
|
policies?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startSimulation(req: SimStartRequest) {
|
||||||
|
return apiFetch<{ id: string; status: string }>(
|
||||||
|
'/admin/simulate/start',
|
||||||
|
{ method: 'POST', body: JSON.stringify(req) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSimulationRuns() {
|
||||||
|
return apiFetch<{ runs: SimRun[] }>('/admin/simulate/runs');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSimulationRun(id: string) {
|
||||||
|
return apiFetch<{ run: SimRun & { isRunning: boolean }; events: unknown[] }>(
|
||||||
|
`/admin/simulate/${id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
122
apps/admin/src/lib/docs.ts
Normal file
122
apps/admin/src/lib/docs.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* Server-side utilities for reading project documentation from the monorepo
|
||||||
|
* `docs/` directory and rendering it as HTML.
|
||||||
|
*
|
||||||
|
* All functions are async and must only be called from server components or
|
||||||
|
* server actions (no 'use client' imports of this module).
|
||||||
|
*
|
||||||
|
* Directory layout relative to monorepo root:
|
||||||
|
* docs/adr/ — Architecture Decision Records (NNN-title.md)
|
||||||
|
* docs/architecture/ — longer architecture notes (topic.md)
|
||||||
|
*/
|
||||||
|
import { readdir, readFile } from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
import { marked } from 'marked';
|
||||||
|
|
||||||
|
// In development: process.cwd() = apps/admin/, so ../../docs = monorepo root docs/.
|
||||||
|
// In Docker standalone: CWD = /app, so ../../docs is wrong. Set DOCS_ROOT in the
|
||||||
|
// container to the absolute path where docs/ is copied (e.g. /app/docs).
|
||||||
|
const DOCS_ROOT =
|
||||||
|
process.env.DOCS_ROOT ?? path.resolve(process.cwd(), '../../docs');
|
||||||
|
|
||||||
|
export type DocCategory = 'adr' | 'architecture';
|
||||||
|
|
||||||
|
export interface DocMeta {
|
||||||
|
category: DocCategory;
|
||||||
|
slug: string; // filename without .md
|
||||||
|
title: string; // first H1 from the file, fallback = slug
|
||||||
|
href: string; // /docs/adr/0001-monorepo-polyglot
|
||||||
|
status?: string; // for ADRs: "Accepted", "Proposed", …
|
||||||
|
date?: string; // for ADRs: date after em-dash on Status line
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocPage extends DocMeta {
|
||||||
|
html: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// internal helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Extract the first # heading from markdown. */
|
||||||
|
function extractTitle(md: string): string {
|
||||||
|
const m = md.match(/^#\s+(.+)$/m);
|
||||||
|
return m ? m[1].trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract status + date from "## Status\nAccepted — 2026-04-13" pattern. */
|
||||||
|
function extractStatus(md: string): { status?: string; date?: string } {
|
||||||
|
const block = md.match(/##\s+Status\s*\n+([^\n#]+)/);
|
||||||
|
if (!block) return {};
|
||||||
|
const line = block[1].trim();
|
||||||
|
// "Accepted — 2026-04-13" or "Proposed"
|
||||||
|
const parts = line.split(/\s*[–—]\s*/);
|
||||||
|
return { status: parts[0]?.trim(), date: parts[1]?.trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugFromFile(filename: string): string {
|
||||||
|
return filename.replace(/\.md$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** List all docs in a category, sorted by filename. */
|
||||||
|
export async function listDocs(category: DocCategory): Promise<DocMeta[]> {
|
||||||
|
const dir = path.join(DOCS_ROOT, category);
|
||||||
|
let files: string[];
|
||||||
|
try {
|
||||||
|
files = (await readdir(dir)).filter((f) => f.endsWith('.md')).sort();
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
files.map(async (file) => {
|
||||||
|
const slug = slugFromFile(file);
|
||||||
|
const md = await readFile(path.join(dir, file), 'utf8');
|
||||||
|
const title = extractTitle(md) || slug;
|
||||||
|
const { status, date } = extractStatus(md);
|
||||||
|
return {
|
||||||
|
category,
|
||||||
|
slug,
|
||||||
|
title,
|
||||||
|
href: `/docs/${category}/${slug}`,
|
||||||
|
status,
|
||||||
|
date,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List all docs across all categories. */
|
||||||
|
export async function listAllDocs(): Promise<Record<DocCategory, DocMeta[]>> {
|
||||||
|
const [adr, architecture] = await Promise.all([listDocs('adr'), listDocs('architecture')]);
|
||||||
|
return { adr, architecture };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read and render a single doc to HTML. */
|
||||||
|
export async function getDoc(category: DocCategory, slug: string): Promise<DocPage | null> {
|
||||||
|
const file = path.join(DOCS_ROOT, category, `${slug}.md`);
|
||||||
|
let md: string;
|
||||||
|
try {
|
||||||
|
md = await readFile(file, 'utf8');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = extractTitle(md) || slug;
|
||||||
|
const { status, date } = extractStatus(md);
|
||||||
|
const html = await marked(md, { gfm: true });
|
||||||
|
|
||||||
|
return {
|
||||||
|
category,
|
||||||
|
slug,
|
||||||
|
title,
|
||||||
|
href: `/docs/${category}/${slug}`,
|
||||||
|
status,
|
||||||
|
date,
|
||||||
|
html,
|
||||||
|
};
|
||||||
|
}
|
||||||
49
apps/admin/src/middleware.ts
Normal file
49
apps/admin/src/middleware.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
export async function middleware(req: NextRequest) {
|
||||||
|
const { pathname } = req.nextUrl;
|
||||||
|
|
||||||
|
// Pass through the login page, forbidden page, and API calls
|
||||||
|
if (pathname.startsWith('/login') || pathname.startsWith('/forbidden') || pathname.startsWith('/api/')) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const sid = req.cookies.get('sid')?.value;
|
||||||
|
if (!sid) {
|
||||||
|
const url = req.nextUrl.clone();
|
||||||
|
url.pathname = '/login';
|
||||||
|
return NextResponse.redirect(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify admin role via API. INTERNAL_API_URL (e.g. http://api:3078) is preferred
|
||||||
|
// when set — it points to the API service on the internal Docker network, avoiding
|
||||||
|
// a Caddy round-trip. Falls back to NEXT_PUBLIC_API_URL for dev, or localhost.
|
||||||
|
const apiBase =
|
||||||
|
process.env.INTERNAL_API_URL ||
|
||||||
|
process.env.NEXT_PUBLIC_API_URL ||
|
||||||
|
'http://localhost:3078';
|
||||||
|
try {
|
||||||
|
const profile = await fetch(`${apiBase}/api/user/me`, {
|
||||||
|
headers: { cookie: `sid=${sid}` },
|
||||||
|
next: { revalidate: 0 },
|
||||||
|
});
|
||||||
|
if (!profile.ok) throw new Error('not ok');
|
||||||
|
const data = (await profile.json()) as { role?: string };
|
||||||
|
if (data.role !== 'admin') {
|
||||||
|
const url = req.nextUrl.clone();
|
||||||
|
url.pathname = '/forbidden';
|
||||||
|
return NextResponse.redirect(url);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
const url = req.nextUrl.clone();
|
||||||
|
url.pathname = '/login';
|
||||||
|
return NextResponse.redirect(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/', '/((?!_next/static|_next/image|favicon.ico).*)'],
|
||||||
|
};
|
||||||
12
apps/admin/tailwind.config.ts
Normal file
12
apps/admin/tailwind.config.ts
Normal 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
23
apps/admin/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
1
apps/admin/tsconfig.tsbuildinfo
Normal file
1
apps/admin/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
11
apps/web/e2e/sign-in.spec.ts
Normal file
11
apps/web/e2e/sign-in.spec.ts
Normal 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
6
apps/web/next-env.d.ts
vendored
Normal 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
18
apps/web/next.config.ts
Normal 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
37
apps/web/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
24
apps/web/playwright.config.ts
Normal file
24
apps/web/playwright.config.ts
Normal 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
BIN
apps/web/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 82 B |
BIN
apps/web/public/icon-192.png
Normal file
BIN
apps/web/public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 187 B |
BIN
apps/web/public/icon-512.png
Normal file
BIN
apps/web/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 842 B |
21
apps/web/public/manifest.json
Normal file
21
apps/web/public/manifest.json
Normal 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
25
apps/web/public/sw.js
Normal 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');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
169
apps/web/src/app/config/page.tsx
Normal file
169
apps/web/src/app/config/page.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { getVapidPublicKey, subscribePush, getOrchestatorPrefs, updateOrchestratorPref } from '@/lib/api';
|
||||||
|
|
||||||
|
type PushState = 'idle' | 'subscribed' | 'denied';
|
||||||
|
|
||||||
|
export default function ConfigPage() {
|
||||||
|
const [pushState, setPushState] = useState<PushState>('idle');
|
||||||
|
const [scienceDestiny, setScienceDestiny] = useState(50);
|
||||||
|
const [prefSaving, setPrefSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getOrchestatorPrefs().then((prefs) => {
|
||||||
|
if (typeof prefs.science_destiny === 'number') setScienceDestiny(prefs.science_destiny);
|
||||||
|
}).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleScienceDestinyChange = useCallback(async (value: number) => {
|
||||||
|
setScienceDestiny(value);
|
||||||
|
setPrefSaving(true);
|
||||||
|
try { await updateOrchestratorPref('science_destiny', value); }
|
||||||
|
finally { setPrefSaving(false); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof Notification !== 'undefined') {
|
||||||
|
if (Notification.permission === 'granted') setPushState('subscribed');
|
||||||
|
else if (Notification.permission === 'denied') setPushState('denied');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const requestPush = useCallback(async () => {
|
||||||
|
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
|
||||||
|
const permission = await Notification.requestPermission();
|
||||||
|
if (permission !== 'granted') { setPushState('denied'); return; }
|
||||||
|
try {
|
||||||
|
const reg = await navigator.serviceWorker.register('/sw.js');
|
||||||
|
const vapidKey = await getVapidPublicKey();
|
||||||
|
const sub = await reg.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: vapidKey,
|
||||||
|
});
|
||||||
|
await subscribePush(sub.toJSON());
|
||||||
|
setPushState('subscribed');
|
||||||
|
} catch { setPushState('denied'); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main style={{ minHeight: '100vh', padding: '4rem 2rem', maxWidth: '480px', margin: '0 auto' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '3rem' }}>
|
||||||
|
<a
|
||||||
|
href="/tip"
|
||||||
|
style={{ color: 'rgba(255,255,255,0.35)', fontSize: '0.85rem', textDecoration: 'none' }}
|
||||||
|
>
|
||||||
|
← back
|
||||||
|
</a>
|
||||||
|
<h2 style={{ fontSize: '1.5rem', fontWeight: 300, margin: 0, letterSpacing: '-0.02em' }}>
|
||||||
|
Settings
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notifications */}
|
||||||
|
<section style={{ marginBottom: '2.5rem' }}>
|
||||||
|
<h3 style={{ fontSize: '0.75rem', letterSpacing: '0.12em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.35)', marginBottom: '1rem', fontWeight: 400 }}>
|
||||||
|
Notifications
|
||||||
|
</h3>
|
||||||
|
<div style={{
|
||||||
|
border: '1px solid rgba(255,255,255,0.1)',
|
||||||
|
borderRadius: '0.75rem',
|
||||||
|
padding: '1.25rem 1.5rem',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 400, fontSize: '0.9rem' }}>Push notifications</div>
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.35)', fontSize: '0.75rem', marginTop: '0.2rem' }}>
|
||||||
|
{pushState === 'subscribed' ? 'Enabled' : pushState === 'denied' ? 'Blocked by browser' : 'Get notified when a tip is ready'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{pushState === 'idle' && (
|
||||||
|
<button
|
||||||
|
onClick={requestPush}
|
||||||
|
style={{
|
||||||
|
background: 'var(--white)',
|
||||||
|
color: 'var(--black)',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
padding: '0.375rem 0.875rem',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Enable
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{pushState === 'subscribed' && (
|
||||||
|
<span style={{ color: 'rgba(255,255,255,0.35)', fontSize: '0.8rem' }}>✓</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Tip style */}
|
||||||
|
<section style={{ marginBottom: '2.5rem' }}>
|
||||||
|
<h3 style={{ fontSize: '0.75rem', letterSpacing: '0.12em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.35)', marginBottom: '1rem', fontWeight: 400 }}>
|
||||||
|
Tip style
|
||||||
|
</h3>
|
||||||
|
<div style={{
|
||||||
|
border: '1px solid rgba(255,255,255,0.1)',
|
||||||
|
borderRadius: '0.75rem',
|
||||||
|
padding: '1.25rem 1.5rem',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '0.875rem' }}>
|
||||||
|
<span style={{ fontSize: '0.85rem', fontWeight: 500 }}>Science</span>
|
||||||
|
<span style={{ fontSize: '0.7rem', color: 'rgba(255,255,255,0.25)' }}>
|
||||||
|
{prefSaving ? 'saving…' : scienceDestiny === 50 ? 'balanced' : scienceDestiny < 50 ? 'data-driven' : 'intuitive'}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: '0.85rem', fontWeight: 500 }}>Destiny</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={scienceDestiny}
|
||||||
|
onChange={(e) => handleScienceDestinyChange(Number(e.target.value))}
|
||||||
|
style={{ width: '100%', accentColor: 'var(--white)', cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.3)', fontSize: '0.7rem', marginTop: '0.75rem' }}>
|
||||||
|
{scienceDestiny < 30
|
||||||
|
? 'Tips lean on patterns and data'
|
||||||
|
: scienceDestiny > 70
|
||||||
|
? 'Tips lean on intuition and meaning'
|
||||||
|
: 'Tips balance logic and intuition'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Integrations */}
|
||||||
|
<section>
|
||||||
|
<h3 style={{ fontSize: '0.75rem', letterSpacing: '0.12em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.35)', marginBottom: '1rem', fontWeight: 400 }}>
|
||||||
|
Integrations
|
||||||
|
</h3>
|
||||||
|
<a
|
||||||
|
href="/connect"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
border: '1px solid rgba(255,255,255,0.1)',
|
||||||
|
borderRadius: '0.75rem',
|
||||||
|
padding: '1.25rem 1.5rem',
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: 'var(--white)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 400, fontSize: '0.9rem' }}>Connected apps</div>
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.35)', fontSize: '0.75rem', marginTop: '0.2rem' }}>
|
||||||
|
Manage Todoist and other sources
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span style={{ color: 'rgba(255,255,255,0.35)', fontSize: '0.85rem' }}>→</span>
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
238
apps/web/src/app/connect/page.tsx
Normal file
238
apps/web/src/app/connect/page.tsx
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import { getIntegrations, disconnectIntegration, deleteAccount, logout } from '@/lib/api';
|
||||||
|
import type { Integration } from '@oo/shared-types';
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
|
function ConnectPageInner() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [integrations, setIntegrations] = useState<Integration[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [disconnecting, setDisconnecting] = useState<string | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const { integrations: list } = await getIntegrations();
|
||||||
|
setIntegrations(list);
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
// Show banner if just connected
|
||||||
|
const justConnected = searchParams.get('connected');
|
||||||
|
|
||||||
|
const isConnected = (provider: string) =>
|
||||||
|
integrations.some((i) => i.provider === provider && i.status === 'connected');
|
||||||
|
|
||||||
|
const handleDeleteAccount = async () => {
|
||||||
|
if (!confirm('Delete your account? This cannot be undone.')) return;
|
||||||
|
setDeleting(true);
|
||||||
|
await deleteAccount();
|
||||||
|
await logout();
|
||||||
|
window.location.href = '/sign-in';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisconnect = async (provider: string) => {
|
||||||
|
setDisconnecting(provider);
|
||||||
|
await disconnectIntegration(provider);
|
||||||
|
await load();
|
||||||
|
setDisconnecting(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<main style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<div style={{ color: 'var(--gray)', fontSize: '0.875rem' }}>Loading…</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const todoistConnected = isConnected('todoist');
|
||||||
|
const googleHealthConnected = isConnected('google-health');
|
||||||
|
const anyConnected = todoistConnected || googleHealthConnected;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main style={{ minHeight: '100vh', padding: '4rem 2rem', maxWidth: '480px', margin: '0 auto' }}>
|
||||||
|
<h2 style={{ fontSize: '1.5rem', fontWeight: 300, marginBottom: '0.5rem', letterSpacing: '-0.02em' }}>
|
||||||
|
Connect your apps
|
||||||
|
</h2>
|
||||||
|
<p style={{ color: 'var(--gray)', fontSize: '0.875rem', marginBottom: '3rem' }}>
|
||||||
|
oO reads what you need, when you need it.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{justConnected && (
|
||||||
|
<div style={{
|
||||||
|
background: 'rgba(255,255,255,0.06)',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
marginBottom: '1.5rem',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
color: 'var(--white)',
|
||||||
|
}}>
|
||||||
|
{justConnected} connected.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Todoist card */}
|
||||||
|
<div style={{
|
||||||
|
border: '1px solid rgba(255,255,255,0.1)',
|
||||||
|
borderRadius: '0.75rem',
|
||||||
|
padding: '1.25rem 1.5rem',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.875rem' }}>
|
||||||
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" aria-label="Todoist">
|
||||||
|
<rect width="24" height="24" rx="6" fill="#DB4035"/>
|
||||||
|
<path d="M6 8.5L11 13l7-7" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500, fontSize: '0.9rem' }}>Todoist</div>
|
||||||
|
<div style={{ color: 'var(--gray)', fontSize: '0.75rem', marginTop: '0.1rem' }}>
|
||||||
|
{todoistConnected ? 'Connected' : 'Tasks & to-dos'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{todoistConnected ? (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDisconnect('todoist')}
|
||||||
|
disabled={disconnecting === 'todoist'}
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1px solid rgba(255,255,255,0.15)',
|
||||||
|
color: 'var(--gray)',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
padding: '0.375rem 0.875rem',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{disconnecting === 'todoist' ? '…' : 'Disconnect'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
href="/api/integrations/todoist/connect?redirectTo=/connect"
|
||||||
|
style={{
|
||||||
|
background: 'var(--white)',
|
||||||
|
color: 'var(--black)',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
padding: '0.375rem 0.875rem',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Connect
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Google Health card */}
|
||||||
|
<div style={{
|
||||||
|
border: '1px solid rgba(255,255,255,0.1)',
|
||||||
|
borderRadius: '0.75rem',
|
||||||
|
padding: '1.25rem 1.5rem',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.875rem' }}>
|
||||||
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" aria-label="Google Health">
|
||||||
|
<rect width="24" height="24" rx="6" fill="#EA4335"/>
|
||||||
|
<path d="M12 6.5c0-1.1.9-2 2-2s2 .9 2 2-.9 2-2 2-2-.9-2-2z" fill="#fff"/>
|
||||||
|
<path d="M8 10.5c0-1.1.9-2 2-2s2 .9 2 2-.9 2-2 2-2-.9-2-2z" fill="#fff" opacity=".7"/>
|
||||||
|
<path d="M12 14.5c0 2.2-1.8 4-4 4s-4-1.8-4-4 1.8-4 4-4 4 1.8 4 4z" fill="#fff" opacity=".4"/>
|
||||||
|
<path d="M13 13.5c.5-1 1.5-1.7 2.5-1.7 1.7 0 3 1.3 3 3s-1.3 3-3 3c-1 0-1.9-.5-2.5-1.3" stroke="#fff" strokeWidth="1.5" strokeLinecap="round" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500, fontSize: '0.9rem' }}>Google Health</div>
|
||||||
|
<div style={{ color: 'var(--gray)', fontSize: '0.75rem', marginTop: '0.1rem' }}>
|
||||||
|
{googleHealthConnected ? 'Connected' : 'Steps, sleep & activity'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{googleHealthConnected ? (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDisconnect('google-health')}
|
||||||
|
disabled={disconnecting === 'google-health'}
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1px solid rgba(255,255,255,0.15)',
|
||||||
|
color: 'var(--gray)',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
padding: '0.375rem 0.875rem',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{disconnecting === 'google-health' ? '…' : 'Disconnect'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
href="/api/integrations/google-health/connect?redirectTo=/connect"
|
||||||
|
style={{
|
||||||
|
background: 'var(--white)',
|
||||||
|
color: 'var(--black)',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
padding: '0.375rem 0.875rem',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Connect
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{anyConnected && (
|
||||||
|
<div style={{ marginTop: '3rem' }}>
|
||||||
|
<a
|
||||||
|
href="/tip"
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
textAlign: 'center',
|
||||||
|
background: 'var(--white)',
|
||||||
|
color: 'var(--black)',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
padding: '0.875rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
See my tip →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginTop: '4rem', borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: '2rem' }}>
|
||||||
|
<button
|
||||||
|
onClick={handleDeleteAccount}
|
||||||
|
disabled={deleting}
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
color: 'rgba(255,255,255,0.2)',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{deleting ? 'Deleting…' : 'Delete account'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConnectPage() {
|
||||||
|
return (
|
||||||
|
<Suspense>
|
||||||
|
<ConnectPageInner />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
apps/web/src/app/globals.css
Normal file
31
apps/web/src/app/globals.css
Normal 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;
|
||||||
|
}
|
||||||
21
apps/web/src/app/layout.tsx
Normal file
21
apps/web/src/app/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
apps/web/src/app/legal/privacy/page.tsx
Normal file
41
apps/web/src/app/legal/privacy/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
apps/web/src/app/legal/terms/page.tsx
Normal file
39
apps/web/src/app/legal/terms/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
apps/web/src/app/page.tsx
Normal file
6
apps/web/src/app/page.tsx
Normal 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');
|
||||||
|
}
|
||||||
58
apps/web/src/app/sign-in/page.tsx
Normal file
58
apps/web/src/app/sign-in/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
361
apps/web/src/app/tip/page.tsx
Normal file
361
apps/web/src/app/tip/page.tsx
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||||
|
import { getRecommendation, sendFeedback } from '@/lib/api';
|
||||||
|
import type { Tip } from '@oo/shared-types';
|
||||||
|
|
||||||
|
type State = 'loading' | 'tip' | 'empty' | 'actions' | 'done';
|
||||||
|
|
||||||
|
function Fade({ visible, children, style }: {
|
||||||
|
visible: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
opacity: visible ? 1 : 0,
|
||||||
|
transition: visible ? 'opacity 3.5s ease' : 'opacity 0.3s ease',
|
||||||
|
pointerEvents: visible ? 'auto' : 'none',
|
||||||
|
...style,
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TipPage() {
|
||||||
|
const [tip, setTip] = useState<Tip | null>(null);
|
||||||
|
const [state, setState] = useState<State>('loading');
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const holdTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const [pressed, setPressed] = useState(false);
|
||||||
|
const [showReasoning, setShowReasoning] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state === 'loading' || state === 'done') {
|
||||||
|
setVisible(false);
|
||||||
|
} else {
|
||||||
|
const t = setTimeout(() => setVisible(true), 30);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
const loadTip = useCallback(async (recentTip?: string) => {
|
||||||
|
setVisible(false);
|
||||||
|
setState('loading');
|
||||||
|
try {
|
||||||
|
const rec = await getRecommendation(recentTip);
|
||||||
|
if (!rec) {
|
||||||
|
setState('empty');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTip(rec.tip);
|
||||||
|
setShowReasoning(false);
|
||||||
|
setState('tip');
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[tip] loadTip error', err?.status, err?.message);
|
||||||
|
setState('empty');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { loadTip(); }, [loadTip]);
|
||||||
|
|
||||||
|
const react = async (action: 'done' | 'dismiss' | 'snooze') => {
|
||||||
|
if (!tip) return;
|
||||||
|
const snoozedContent = action === 'snooze' ? tip.content : undefined;
|
||||||
|
setVisible(false);
|
||||||
|
setState('done');
|
||||||
|
await sendFeedback(tip.id, { action });
|
||||||
|
setTimeout(() => loadTip(snoozedContent), 700);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerDown = () => {
|
||||||
|
if (state !== 'tip') return;
|
||||||
|
setPressed(true);
|
||||||
|
holdTimer.current = setTimeout(() => {
|
||||||
|
setState('actions');
|
||||||
|
setVisible(true);
|
||||||
|
setPressed(false);
|
||||||
|
}, 600);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerUp = () => {
|
||||||
|
setPressed(false);
|
||||||
|
if (holdTimer.current) {
|
||||||
|
clearTimeout(holdTimer.current);
|
||||||
|
holdTimer.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style>{`
|
||||||
|
@keyframes breathe {
|
||||||
|
0%, 100% { opacity: 0.3; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
<main
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
onPointerLeave={onPointerUp}
|
||||||
|
style={{
|
||||||
|
height: '100dvh',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
userSelect: 'none',
|
||||||
|
WebkitUserSelect: 'none',
|
||||||
|
cursor: state === 'tip' ? 'default' : 'auto',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Ambient glow */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
background: 'radial-gradient(ellipse at center, rgba(255,255,255,0.06) 0%, transparent 65%)',
|
||||||
|
animation: state === 'loading' ? 'breathe 4s ease-in-out infinite' : undefined,
|
||||||
|
opacity: state === 'loading' ? undefined : pressed ? 0.3 : 0,
|
||||||
|
transition: state !== 'loading' ? 'opacity 0.4s ease' : undefined,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* Loading label */}
|
||||||
|
{(state === 'loading' || state === 'done') && (
|
||||||
|
<p style={{
|
||||||
|
margin: 0,
|
||||||
|
color: 'rgba(255,255,255,0.55)',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
letterSpacing: '0.18em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
animation: 'breathe 4s ease-in-out infinite',
|
||||||
|
}}>
|
||||||
|
reading you…
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tip */}
|
||||||
|
{(state === 'tip' || state === 'actions') && tip && (
|
||||||
|
<Fade visible={visible && state !== 'actions'} style={{ textAlign: 'center', maxWidth: '420px', padding: '0 2rem' }}>
|
||||||
|
<p style={{
|
||||||
|
fontSize: 'clamp(1.25rem, 4vw, 1.75rem)',
|
||||||
|
fontWeight: 300,
|
||||||
|
lineHeight: 1.45,
|
||||||
|
letterSpacing: '-0.01em',
|
||||||
|
color: 'rgba(255,255,255,1)',
|
||||||
|
transition: 'opacity 0.2s ease',
|
||||||
|
opacity: pressed ? 0.5 : 1,
|
||||||
|
}}>
|
||||||
|
{tip.content}
|
||||||
|
</p>
|
||||||
|
<p style={{
|
||||||
|
marginTop: '2rem',
|
||||||
|
color: 'rgba(255,255,255,0.18)',
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
letterSpacing: '0.12em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}>
|
||||||
|
hold to act
|
||||||
|
</p>
|
||||||
|
</Fade>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty */}
|
||||||
|
{state === 'empty' && (
|
||||||
|
<Fade visible={visible} style={{ textAlign: 'center' }}>
|
||||||
|
<p style={{ fontSize: '1.1rem', fontWeight: 300, color: 'rgba(255,255,255,0.35)' }}>
|
||||||
|
All clear.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => loadTip()}
|
||||||
|
style={{
|
||||||
|
marginTop: '2rem',
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1px solid rgba(255,255,255,0.1)',
|
||||||
|
color: 'rgba(255,255,255,0.35)',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Check again
|
||||||
|
</button>
|
||||||
|
</Fade>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action sheet */}
|
||||||
|
{state === 'actions' && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
onClick={() => { setState('tip'); }}
|
||||||
|
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)' }}
|
||||||
|
/>
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
background: '#111',
|
||||||
|
borderRadius: '1rem 1rem 0 0',
|
||||||
|
padding: '1.5rem 1.5rem 2.5rem',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.75rem',
|
||||||
|
}}>
|
||||||
|
{tip && (
|
||||||
|
<p style={{
|
||||||
|
color: 'rgba(255,255,255,0.35)',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
lineHeight: 1.4,
|
||||||
|
}}>
|
||||||
|
{tip.content}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<ActionButton label="Done ✓" onClick={() => react('done')} primary />
|
||||||
|
<ActionButton label="Snooze" onClick={() => react('snooze')} />
|
||||||
|
<ActionButton label="Dismiss" onClick={() => react('dismiss')} />
|
||||||
|
<button
|
||||||
|
onClick={() => setState('tip')}
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
color: 'rgba(255,255,255,0.25)',
|
||||||
|
padding: '0.5rem',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
marginTop: '0.25rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reasoning overlay */}
|
||||||
|
{showReasoning && tip?.rationale && (
|
||||||
|
<div
|
||||||
|
onClick={(e) => { e.stopPropagation(); setShowReasoning(false); }}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 20,
|
||||||
|
padding: '0 0 5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
background: 'rgba(20,20,20,0.96)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.08)',
|
||||||
|
borderRadius: '0.875rem',
|
||||||
|
padding: '1.25rem 1.5rem',
|
||||||
|
maxWidth: '360px',
|
||||||
|
width: 'calc(100% - 3rem)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
letterSpacing: '0.1em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: 'rgba(255,255,255,0.3)',
|
||||||
|
marginBottom: '0.625rem',
|
||||||
|
}}>
|
||||||
|
Why this tip
|
||||||
|
</p>
|
||||||
|
<p style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
fontWeight: 300,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
color: 'rgba(255,255,255,0.75)',
|
||||||
|
}}>
|
||||||
|
{tip.rationale}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ? button — bottom left, shows reasoning */}
|
||||||
|
{(state === 'tip' || state === 'actions') && tip?.rationale && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setShowReasoning((v) => !v); }}
|
||||||
|
aria-label="Why this tip"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: '1.5rem',
|
||||||
|
left: '1.5rem',
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
color: showReasoning ? 'rgba(255,255,255,0.5)' : 'rgba(255,255,255,0.15)',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
fontWeight: 400,
|
||||||
|
lineHeight: 1,
|
||||||
|
padding: '0.5rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
zIndex: 10,
|
||||||
|
transition: 'color 0.2s ease',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
?
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Settings gear — bottom right */}
|
||||||
|
<a
|
||||||
|
href="/config"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
aria-label="Settings"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: '1.5rem',
|
||||||
|
right: '1.5rem',
|
||||||
|
color: 'rgba(255,255,255,0.15)',
|
||||||
|
fontSize: '1.1rem',
|
||||||
|
lineHeight: 1,
|
||||||
|
textDecoration: 'none',
|
||||||
|
padding: '0.5rem',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⚙
|
||||||
|
</a>
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionButton({ label, onClick, primary }: { label: string; onClick: () => void; primary?: boolean }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
background: primary ? 'var(--white)' : 'rgba(255,255,255,0.06)',
|
||||||
|
color: primary ? 'var(--black)' : 'var(--white)',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '0.625rem',
|
||||||
|
padding: '1rem',
|
||||||
|
fontSize: '0.95rem',
|
||||||
|
fontWeight: primary ? 500 : 400,
|
||||||
|
width: '100%',
|
||||||
|
textAlign: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
144
apps/web/src/components/__tests__/TipPage.test.tsx
Normal file
144
apps/web/src/components/__tests__/TipPage.test.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, waitFor, act, fireEvent } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
|
// Mock the API module — we test UI behaviour, not network calls
|
||||||
|
vi.mock('@/lib/api', () => ({
|
||||||
|
getRecommendation: vi.fn(),
|
||||||
|
sendFeedback: vi.fn().mockResolvedValue(undefined),
|
||||||
|
getVapidPublicKey: vi.fn(),
|
||||||
|
subscribePush: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { getRecommendation, sendFeedback } from '@/lib/api';
|
||||||
|
import TipPage from '@/app/tip/page';
|
||||||
|
|
||||||
|
// jsdom doesn't support full anchor navigation — just verify the link exists
|
||||||
|
|
||||||
|
const mockGetRec = getRecommendation as ReturnType<typeof vi.fn>;
|
||||||
|
const mockSendFeedback = sendFeedback as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TipPage — empty / error states', () => {
|
||||||
|
it('shows "All clear." when no tip is returned', async () => {
|
||||||
|
mockGetRec.mockResolvedValue(null);
|
||||||
|
render(<TipPage />);
|
||||||
|
await waitFor(() => expect(screen.getByText('All clear.')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "All clear." when getRecommendation throws', async () => {
|
||||||
|
mockGetRec.mockRejectedValue(Object.assign(new Error('Network error'), { status: 503 }));
|
||||||
|
render(<TipPage />);
|
||||||
|
await waitFor(() => expect(screen.getByText('All clear.')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"Check again" button re-calls getRecommendation', async () => {
|
||||||
|
mockGetRec.mockResolvedValue(null);
|
||||||
|
render(<TipPage />);
|
||||||
|
await waitFor(() => screen.getByText('Check again'));
|
||||||
|
|
||||||
|
mockGetRec.mockResolvedValue({
|
||||||
|
tip: { id: 'todoist:2', content: 'New tip', source: 'todoist', createdAt: '' },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByText('Check again'));
|
||||||
|
await waitFor(() => expect(mockGetRec).toHaveBeenCalledTimes(2));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TipPage — tip display', () => {
|
||||||
|
it('renders tip content after loading', async () => {
|
||||||
|
mockGetRec.mockResolvedValue({
|
||||||
|
tip: { id: 'todoist:1', content: 'Write the test', source: 'todoist', createdAt: '' },
|
||||||
|
});
|
||||||
|
render(<TipPage />);
|
||||||
|
await waitFor(() => expect(screen.getByText('Write the test')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "hold to act" hint when tip is displayed', async () => {
|
||||||
|
mockGetRec.mockResolvedValue({
|
||||||
|
tip: { id: 'todoist:3', content: 'Do the thing', source: 'todoist', createdAt: '' },
|
||||||
|
});
|
||||||
|
render(<TipPage />);
|
||||||
|
await waitFor(() => expect(screen.getByText(/hold to act/i)).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "reading you…" while loading', async () => {
|
||||||
|
// Never resolves during this assertion
|
||||||
|
mockGetRec.mockReturnValue(new Promise(() => {}));
|
||||||
|
render(<TipPage />);
|
||||||
|
expect(screen.getByText(/reading you/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TipPage — action sheet', () => {
|
||||||
|
// Render with real timers, THEN switch to fake for hold simulation
|
||||||
|
async function renderTipAndHold(id: string, content: string) {
|
||||||
|
mockGetRec.mockResolvedValue({ tip: { id, content, source: 'todoist', createdAt: '' } });
|
||||||
|
render(<TipPage />);
|
||||||
|
// Wait for tip to appear (real timers — no deadlock)
|
||||||
|
await screen.findByText(content);
|
||||||
|
const main = screen.getByRole('main');
|
||||||
|
|
||||||
|
// Switch to fake timers now that the component is fully loaded
|
||||||
|
vi.useFakeTimers();
|
||||||
|
act(() => { main.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })); });
|
||||||
|
act(() => { vi.advanceTimersByTime(650); });
|
||||||
|
vi.useRealTimers();
|
||||||
|
|
||||||
|
// Wait for action sheet
|
||||||
|
await screen.findByText('Done ✓');
|
||||||
|
return main;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('action sheet appears after a long press (600 ms)', async () => {
|
||||||
|
await renderTipAndHold('tip:lp', 'Hold me');
|
||||||
|
expect(screen.getByText('Done ✓')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('action sheet does not appear on short press (<600 ms)', async () => {
|
||||||
|
mockGetRec.mockResolvedValue({ tip: { id: 'tip:sp', content: 'Short press', source: 'todoist', createdAt: '' } });
|
||||||
|
render(<TipPage />);
|
||||||
|
await screen.findByText('Short press');
|
||||||
|
const main = screen.getByRole('main');
|
||||||
|
|
||||||
|
vi.useFakeTimers();
|
||||||
|
act(() => { main.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })); });
|
||||||
|
act(() => { vi.advanceTimersByTime(200); });
|
||||||
|
act(() => { main.dispatchEvent(new PointerEvent('pointerup', { bubbles: true })); });
|
||||||
|
vi.useRealTimers();
|
||||||
|
|
||||||
|
expect(screen.queryByText('Done ✓')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking "Done ✓" calls sendFeedback with action=done', async () => {
|
||||||
|
await renderTipAndHold('tip:d', 'Do it');
|
||||||
|
await act(async () => { fireEvent.click(screen.getByText('Done ✓')); });
|
||||||
|
expect(mockSendFeedback).toHaveBeenCalledWith('tip:d', { action: 'done' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking "Dismiss" calls sendFeedback with action=dismiss', async () => {
|
||||||
|
await renderTipAndHold('tip:dis', 'Dismiss me');
|
||||||
|
await act(async () => { fireEvent.click(screen.getByText('Dismiss')); });
|
||||||
|
expect(mockSendFeedback).toHaveBeenCalledWith('tip:dis', { action: 'dismiss' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('action sheet has exactly Done, Snooze, Dismiss — no Helpful/Not helpful', async () => {
|
||||||
|
await renderTipAndHold('tip:actions', 'Check actions');
|
||||||
|
expect(screen.getByText('Done ✓')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Snooze')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Dismiss')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Helpful')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Not helpful')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('settings gear link is present on tip page', async () => {
|
||||||
|
mockGetRec.mockResolvedValue({ tip: { id: 'tip:g', content: 'Gear test', source: 'todoist', createdAt: '' } });
|
||||||
|
render(<TipPage />);
|
||||||
|
await screen.findByText('Gear test');
|
||||||
|
const link = screen.getByRole('link', { name: /settings/i });
|
||||||
|
expect(link).toHaveAttribute('href', '/config');
|
||||||
|
});
|
||||||
|
});
|
||||||
98
apps/web/src/lib/api.ts
Normal file
98
apps/web/src/lib/api.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import type { RecommendResponse, TipFeedback, IntegrationsResponse, UserProfile } from '@oo/shared-types';
|
||||||
|
|
||||||
|
const API = '/api';
|
||||||
|
|
||||||
|
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(`${API}${path}`, {
|
||||||
|
credentials: 'include',
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...init?.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||||
|
throw Object.assign(new Error(err.error ?? 'API error'), { status: res.status });
|
||||||
|
}
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
|
return res.json() as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSession() {
|
||||||
|
return apiFetch<{ user: { id: string; email: string; name?: string; image?: string } | null }>('/auth/session');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRecommendation(recentTip?: string): Promise<RecommendResponse | null> {
|
||||||
|
try {
|
||||||
|
return await apiFetch<RecommendResponse>('/recommend', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(recentTip ? { recent_tip: recentTip } : {}),
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.status === 204 || e.status === 422) return null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendFeedback(tipId: string, feedback: TipFeedback) {
|
||||||
|
return apiFetch<{ ok: boolean }>(`/tip/${encodeURIComponent(tipId)}/feedback`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(feedback),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getIntegrations(): Promise<IntegrationsResponse> {
|
||||||
|
return apiFetch<IntegrationsResponse>('/integrations');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disconnectIntegration(provider: string) {
|
||||||
|
return apiFetch<{ ok: boolean }>(`/integrations/${provider}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProfile(): Promise<UserProfile> {
|
||||||
|
return apiFetch<UserProfile>('/user/me');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function giveConsent() {
|
||||||
|
return apiFetch<{ ok: boolean }>('/user/consent', { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAccount() {
|
||||||
|
return apiFetch<{ ok: boolean }>('/user/me', { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout() {
|
||||||
|
return apiFetch<{ ok: boolean }>('/auth/logout', { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getVapidPublicKey(): Promise<string> {
|
||||||
|
const { key } = await apiFetch<{ key: string }>('/push/vapid-public-key');
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function subscribePush(subscription: PushSubscriptionJSON) {
|
||||||
|
return apiFetch<{ ok: boolean }>('/push/subscribe', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(subscription),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unsubscribePush(endpoint: string) {
|
||||||
|
return apiFetch<{ ok: boolean }>('/push/subscribe', {
|
||||||
|
method: 'DELETE',
|
||||||
|
body: JSON.stringify({ endpoint }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrchestatorPrefs(): Promise<Record<string, unknown>> {
|
||||||
|
const data = await apiFetch<{ prefs: Record<string, Record<string, unknown>> }>('/profile');
|
||||||
|
return data.prefs?.orchestrator ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateOrchestratorPref(key: string, value: unknown) {
|
||||||
|
return apiFetch<{ ok: boolean }>('/profile/prefs/orchestrator', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ [key]: value }),
|
||||||
|
});
|
||||||
|
}
|
||||||
34
apps/web/src/middleware.ts
Normal file
34
apps/web/src/middleware.ts
Normal 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).*)'],
|
||||||
|
};
|
||||||
1
apps/web/src/test/setup.ts
Normal file
1
apps/web/src/test/setup.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import '@testing-library/jest-dom';
|
||||||
23
apps/web/tsconfig.json
Normal file
23
apps/web/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
1
apps/web/tsconfig.tsbuildinfo
Normal file
1
apps/web/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
23
apps/web/vitest.config.ts
Normal file
23
apps/web/vitest.config.ts
Normal 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'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
61
docs/adr/0006-admin-console-framework.md
Normal file
61
docs/adr/0006-admin-console-framework.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# ADR-0006: Admin console framework — Next.js 15 + Tremor + shadcn/ui + embed specialist tools
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Accepted — 2026-04-15
|
||||||
|
|
||||||
|
## Context
|
||||||
|
M1 ships a bandit-driven recommender, an event bus, and a live feedback loop. Without a cockpit to observe these systems, every model change ships blind. An admin console is needed to:
|
||||||
|
|
||||||
|
1. **Observe** — DAU/WAU, tip outcomes, reaction rates, LinUCB arm stats, feature distributions
|
||||||
|
2. **Inspect** — per-user identity, consents, integrations, reward history
|
||||||
|
3. **Act** — revoke tokens, replay signals, reset a per-user bandit, promote a policy
|
||||||
|
4. **Audit** — every operator action is logged
|
||||||
|
|
||||||
|
The team is two people. The stack is TypeScript/React/Tailwind. Any framework that forks the stack creates a context-switch tax and a second deployment surface.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### App shell — `apps/admin`, Next.js 15, App Router
|
||||||
|
|
||||||
|
Same stack as `apps/web`. Reuses `packages/shared-types`, the Auth.js session cookie, and the API rewrite convention. Deployed at `admin.o.alogins.net` behind Caddy, port 3080 in dev.
|
||||||
|
|
||||||
|
### UI libraries
|
||||||
|
|
||||||
|
| Layer | Library | Reason |
|
||||||
|
|-------|---------|--------|
|
||||||
|
| Charts / KPI | **Tremor** | Analytics-first React + Tailwind components (KPI cards, time-series, bar lists). Designed for dashboards, not bolted on. |
|
||||||
|
| CRUD primitives | **shadcn/ui** | Copy-paste Radix components; forms, dialogs, command palette. No version lock-in — code lives in-repo. |
|
||||||
|
| Heavy grids | **TanStack Table v8** | Sortable / paginated / virtualized tables for events, users, tips. |
|
||||||
|
| Extra charts | **Recharts** | Fallback where Tremor falls short (histograms, distributions). |
|
||||||
|
|
||||||
|
### Link out, don't embed
|
||||||
|
|
||||||
|
Specialized MLOps tooling runs as **separate external services** with their own auth, linked from the admin shell — not embedded or reimplemented:
|
||||||
|
|
||||||
|
- **MLflow** → `https://o.alogins.net/mlflow` — experiment tracking, model registry, artifact browser; own basic-auth for now; see M3 for SSO consolidation
|
||||||
|
- **Grafana panels** → `/admin/infra` (iframed panels) — infra metrics
|
||||||
|
- **Marimo notebooks** → launch-out link from admin
|
||||||
|
|
||||||
|
The admin shell links to these services; clicking them opens a new tab.
|
||||||
|
|
||||||
|
### AuthZ
|
||||||
|
|
||||||
|
`profile.role` column on the `users` table (values: `'user'` | `'admin'`). First admin seeded via `ADMIN_SEED_EMAIL` env var at startup. Admin-only gate in Next.js middleware checks the session and the role returned by `GET /api/user/me`. Every write action through the admin API is appended to an `admin_actions` audit log.
|
||||||
|
|
||||||
|
### Rejected alternatives
|
||||||
|
|
||||||
|
| Option | Rejected because |
|
||||||
|
|--------|-----------------|
|
||||||
|
| Retool / AppSmith | Admin logic leaves the repo; weak analytics affordances |
|
||||||
|
| Streamlit / Gradio | Python-first; splits the frontend stack; thin RBAC |
|
||||||
|
| React-admin / Refine.dev | Strong CRUD scaffolding, analytics views feel bolted on |
|
||||||
|
| Superset / Metabase as the admin surface | Excellent BI, poor operational writes; plan: adopt Superset in M4 for BI alongside batch pipelines |
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- One more Next.js app in the monorepo. Build/dev added to Turborepo.
|
||||||
|
- Tremor + shadcn/ui are added as dependencies. shadcn components are copied into `apps/admin/src/components/ui/` — no runtime version coupling.
|
||||||
|
- MLflow (`o.alogins.net/mlflow*` → port 5000) is a path-based route in the existing `o.alogins.net` Caddy block, started via `docker compose --profile mlops up`.
|
||||||
|
- MLflow manages its own auth (built-in basic-auth). M3 will consolidate behind the shared OIDC provider.
|
||||||
|
- The `NEXT_PUBLIC_MLFLOW_URL` build arg in `Dockerfile.admin` defaults to the production URL; override for dev builds.
|
||||||
|
- `admin_actions` audit log grows unboundedly — needs a retention policy before M4.
|
||||||
47
docs/adr/0007-egreedy-v1-active-policy.md
Normal file
47
docs/adr/0007-egreedy-v1-active-policy.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# ADR-0007: ε-greedy v1 as the active recommendation policy
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Superseded by ADR-0013 — 2026-05-01
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
M1 shipped LinUCB (d=5, α=1.0) as the first learned policy via `ml/serving /score`. After the M1 admin console landed, we ran an offline simulation to compare LinUCB against a new ε-greedy ridge-regression policy before deciding which to keep live.
|
||||||
|
|
||||||
|
**ε-greedy v1 design:**
|
||||||
|
- Ridge regression estimator, θ updated online (equivalent to LinUCB without the UCB bonus).
|
||||||
|
- d=7 feature vector: base 5 (is\_overdue, task\_age\_days, priority, hour\_of\_day, bias) + sin/cos encoding of day\_of\_week.
|
||||||
|
- ε=0.10 random exploration; 90% argmax(θ·x).
|
||||||
|
- Separate per-user state files (`{user}_egreedy.json`), independent of LinUCB state.
|
||||||
|
|
||||||
|
**Simulation setup (rule judge, seed=42):**
|
||||||
|
- 5 synthetic personas × 20 rounds × 8 tasks/round = 100 judgments per policy.
|
||||||
|
- Reward inferred from dwell-time (same `inferReward` logic as production): dismiss=−1, snooze=+0.1, done<15 s=−0.3, done 15 s–2 min=+1.0, done 2–10 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 15s–2min 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.
|
||||||
41
docs/adr/0008-litellm-ai-gateway.md
Normal file
41
docs/adr/0008-litellm-ai-gateway.md
Normal 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).
|
||||||
53
docs/adr/0009-signal-normalization.md
Normal file
53
docs/adr/0009-signal-normalization.md
Normal 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.
|
||||||
59
docs/adr/0010-nats-bridge-and-background-sync.md
Normal file
59
docs/adr/0010-nats-bridge-and-background-sync.md
Normal 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.
|
||||||
89
docs/adr/0011-user-profile-features.md
Normal file
89
docs/adr/0011-user-profile-features.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# ADR-0011 — User-profile feature registry
|
||||||
|
|
||||||
|
**Status:** Accepted (phase A)
|
||||||
|
**Date:** 2026-04-25
|
||||||
|
**Issue:** #81
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The bandit and LLM tip generator only saw per-candidate features (`is_overdue`,
|
||||||
|
`task_age_days`, `priority`) plus contextual time signals. There was no notion
|
||||||
|
of a *user-level* profile — completion rate, dismiss rate, preferred hour, tip
|
||||||
|
volume — even though all the raw data already lives in `tip_views`,
|
||||||
|
`tip_feedback`, and `tip_scores`.
|
||||||
|
|
||||||
|
#81 originally proposed putting the feature registry in `ml/features/` (Python).
|
||||||
|
We're choosing differently for the data-locality reason: the aggregations are
|
||||||
|
SQL queries against tables owned by `services/api`. Computing them in Python
|
||||||
|
means a network round-trip per recommendation for queries that are sub-ms in TS.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Two-sided design with one source of truth:
|
||||||
|
|
||||||
|
- **`services/api/src/profile/registry.ts`** — *source of truth*. Each
|
||||||
|
`FeatureDefinition` declares `{ name, dtype, ttlSec, description, compute }`.
|
||||||
|
`compute(userId, sqlite)` runs the aggregation SQL directly via the raw
|
||||||
|
better-sqlite3 client.
|
||||||
|
- **`services/api/src/profile/builder.ts`** — `getProfile(userId)` returns the
|
||||||
|
full feature dict, lazily recomputing any entry whose stored row is past its
|
||||||
|
`ttlSec`. `rebuildProfile(userId)` force-refreshes everything.
|
||||||
|
- **`user_profile_features` table** — KV per `(user_id, name)` with `value`
|
||||||
|
(REAL) for numeric and `value_text` (TEXT) for categorical. Phase A
|
||||||
|
ships only numeric features.
|
||||||
|
- **`ml/features/profile_schema.py`** — *contract mirror*. Names, dtypes, and
|
||||||
|
descriptions only — no compute. A test reads the TS file and asserts the
|
||||||
|
name sets match, catching drift.
|
||||||
|
- **`POST /score` and `POST /generate`** in `ml/serving` accept an optional
|
||||||
|
`profile_features: dict | None`. Stored on the request object but **not
|
||||||
|
consumed by the bandit yet** — extending the feature vector changes `D` and
|
||||||
|
resets every user's learned state. That's a deliberate phase-B decision.
|
||||||
|
|
||||||
|
Initial features: `completion_rate_30d`, `dismiss_rate_30d`,
|
||||||
|
`mean_dwell_ms_30d`, `preferred_hour`, `tip_volume_30d`.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Good:**
|
||||||
|
- Adding a feature = one entry in `registry.ts` + one mirror line in
|
||||||
|
`profile_schema.py`. No DB migration required (KV table).
|
||||||
|
- TTL keeps recommendation latency bounded: every recommend call refreshes at
|
||||||
|
most 5 features, each a single indexed query against an already-warm DB.
|
||||||
|
- Profile data is now visible to ml/serving via the request payload — eval
|
||||||
|
harnesses and the LLM tip generator can use it without a DB round-trip.
|
||||||
|
|
||||||
|
**Trade-offs:**
|
||||||
|
- TS owns compute → ml-side changes that need new features still require a
|
||||||
|
TS PR. Acceptable while the modular monolith holds; if `ml/serving`
|
||||||
|
becomes the system of record for any feature, it should own its own table.
|
||||||
|
- TTL-based refresh has up-to-`ttlSec` lag on user-visible behavior change.
|
||||||
|
Phase B replaces this with event-driven incremental updates subscribing to
|
||||||
|
`signals.tip.feedback`.
|
||||||
|
|
||||||
|
## Phase B
|
||||||
|
|
||||||
|
- ✅ **B.1** — Per-user profile view + rebuild action in `/admin/users/:id`.
|
||||||
|
- ✅ **B.2** — Event-driven invalidation: features declare `invalidatedBy`
|
||||||
|
subjects in the registry; `profile/subscriber.ts` deletes the affected stored
|
||||||
|
rows on publish so the next `getProfile` call recomputes immediately rather
|
||||||
|
than waiting up to `ttlSec`. TTL stays as a safety net for clock drift /
|
||||||
|
dropped events.
|
||||||
|
- ✅ **B.4** — Staleness panel in `/admin/data-quality` (counts missing + stale
|
||||||
|
per feature across eligible users).
|
||||||
|
- ⏳ **B.3** — Extend the bandit feature vector to include profile features
|
||||||
|
(deliberate `D` change with state-migration plan + shadow rollout per ADR-0002).
|
||||||
|
Tracked separately as #99 since it's a multi-step initiative, not an
|
||||||
|
incremental phase.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
**Registry in Python (per the original issue text)** — rejected: the
|
||||||
|
aggregations live in TS-owned tables; round-tripping per recommend adds
|
||||||
|
latency for no architectural gain.
|
||||||
|
|
||||||
|
**Compute in the recommender route inline** — rejected: features would be
|
||||||
|
recomputed on every recommendation with no cache or staleness semantics.
|
||||||
|
|
||||||
|
**Use `tip_scores.featuresJson` as the profile store** — rejected: that
|
||||||
|
column is per-tip explainability, not per-user state. Mixing them complicates
|
||||||
|
both reads.
|
||||||
124
docs/adr/0012-egreedy-v2-profile-features.md
Normal file
124
docs/adr/0012-egreedy-v2-profile-features.md
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
# ADR-0012 — ε-greedy v2: profile features in the bandit (D=7→12)
|
||||||
|
|
||||||
|
**Status:** Superseded by ADR-0013 — 2026-05-01
|
||||||
|
**Date:** 2026-04-25 (accepted) / 2026-04-26 (promoted)
|
||||||
|
**Issue:** #99
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR-0011 shipped a 5-feature user-profile registry (completion rate, dismiss rate,
|
||||||
|
mean dwell, preferred hour, tip volume). `POST /score` and `POST /score/egreedy`
|
||||||
|
already receive a `profile_features` dict on every call but **ignore it** — the
|
||||||
|
comment in `ml/serving/main.py` explains why: extending the feature vector changes
|
||||||
|
`D`, which resets every user's learned `A`/`b` matrices and discards accumulated
|
||||||
|
signal. That loss requires a deliberate shadow-first rollout per ADR-0002, not an
|
||||||
|
in-place update.
|
||||||
|
|
||||||
|
This ADR authorises `egreedy-v2`, which extends the active `egreedy-v1` (D=7) with
|
||||||
|
the 5 profile features (D=12) and defines how it ships safely.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### New policy: egreedy-v2 (D=12)
|
||||||
|
|
||||||
|
Feature vector layout:
|
||||||
|
|
||||||
|
| idx | name | encoding |
|
||||||
|
|-----|------|----------|
|
||||||
|
| 0–1 | hour_sin, hour_cos | cyclical, current hour |
|
||||||
|
| 2 | is_overdue | 0/1 |
|
||||||
|
| 3 | task_age_norm | age_days / 30, clipped 0–1 |
|
||||||
|
| 4 | priority_norm | (p − 1) / 3 |
|
||||||
|
| 5–6 | dow_sin, dow_cos | cyclical, day of week |
|
||||||
|
| 7 | completion_rate_30d | raw (already 0–1); null → 0 |
|
||||||
|
| 8 | dismiss_rate_30d | raw (already 0–1); null → 0 |
|
||||||
|
| 9 | mean_dwell_norm | dwell_ms / 600_000, clipped 0–1; null → 0 |
|
||||||
|
| 10 | preferred_hour_alignment | `(cos(2π(pref − now)/24) + 1) / 2`; null → 0.5 (neutral) |
|
||||||
|
| 11 | tip_volume_norm | `log1p(n) / log1p(100)`, clipped 0–1; null → 0 |
|
||||||
|
|
||||||
|
**Normalization rationale:**
|
||||||
|
- Rates are already in [0, 1]; no transform needed.
|
||||||
|
- Dwell clips at 10 min — anything beyond that carries diminishing signal.
|
||||||
|
- `preferred_hour` needs circular continuity; one-dimension approximation using
|
||||||
|
cosine alignment with the current hour. At null (no established peak) we use
|
||||||
|
0.5 (the midpoint/neutral) rather than 0 (misleading "polar-opposite hour").
|
||||||
|
- `tip_volume` uses log-scale because engagement counts are heavy-tailed.
|
||||||
|
|
||||||
|
### Rollout sequence (per ADR-0002)
|
||||||
|
|
||||||
|
1. **Shadow** (this ADR) — `egreedy-v2-shadow` registered in the recommender's
|
||||||
|
shadow-policy map (disabled by default). Admin enables via `/admin/policies`.
|
||||||
|
- Calls `/score/egreedy/v2` fire-and-forget alongside the active `egreedy-v1` call.
|
||||||
|
- Publishes `signals.tip.served` with `policy: shadow:egreedy-v2-shadow` for logging.
|
||||||
|
- **No reward delivery to shadow** — live shadow collects decision-agreement
|
||||||
|
exposure only; reward measurement uses offline simulation.
|
||||||
|
- State files: `{user}_egreedy_v2.json` — isolated from v1's `{user}_egreedy.json`.
|
||||||
|
|
||||||
|
2. **Offline sim** — run `runner.py --policies egreedy-v1 egreedy-v2 --n-rounds 20`
|
||||||
|
using the `rule` judge and persona-level profile features (synthetic values in
|
||||||
|
`personas.py`). Gate: v2 mean reward ≥ v1 mean reward.
|
||||||
|
|
||||||
|
3. **Promote** — if sim gate passes, change the `remotePolicy()` call in
|
||||||
|
`recommender.ts` from `/score/egreedy` to `/score/egreedy/v2` and change reward
|
||||||
|
delivery to `/reward/egreedy/v2`. No DB migration; old per-user v1 state files
|
||||||
|
are left on disk (available for rollback; clean up after 30 days).
|
||||||
|
|
||||||
|
### State-file migration
|
||||||
|
|
||||||
|
No migration of `A`/`b` matrices from v1 → v2. A D×D→D'×D' transform would
|
||||||
|
require assumptions about the new dimensions that we cannot justify without data.
|
||||||
|
v2 starts from the identity prior and learns from scratch in shadow/sim. The reward
|
||||||
|
penalty from cold-start is the correct price for the dimension extension.
|
||||||
|
|
||||||
|
### Admin control
|
||||||
|
|
||||||
|
`GET /api/admin/policies` surfaces `egreedy-v2-shadow` with `active: false`.
|
||||||
|
Toggle via `POST /api/admin/policies/egreedy-v2-shadow/toggle`.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Good:**
|
||||||
|
- Profile features (preferred hour, completion/dismiss rates, volume) allow the
|
||||||
|
bandit to personalise timing recommendations beyond what the candidate-level
|
||||||
|
features encode.
|
||||||
|
- Normalization is deterministic, bounded [0, 1], and numerically stable; no
|
||||||
|
scaling artefacts as the population grows.
|
||||||
|
- Shadow-first rollout protects real users from a cold-start regression.
|
||||||
|
|
||||||
|
**Trade-offs:**
|
||||||
|
- Cold-start: v2 state files begin from the identity prior. During shadow,
|
||||||
|
v2 makes random-ish decisions for early users. This is expected and intentional.
|
||||||
|
- Synthetic persona profiles in `personas.py` approximate real user distributions;
|
||||||
|
the offline sim is evidence, not proof. The promotion gate requires the sim to
|
||||||
|
run after v2 has accumulated enough behavioral data (suggest ≥100 shadow calls
|
||||||
|
per policy per user before running the final sim).
|
||||||
|
- The one-dim preferred-hour encoding loses some circular information compared to
|
||||||
|
two-dim sin/cos. If preferred-hour alignment becomes a dominant signal, revisit
|
||||||
|
with D=13 in a follow-up ADR.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
**Warm-start via projection** — project v1's 7-dim theta into D=12 by padding
|
||||||
|
with zeros. Rejected: zero initialization for the profile dims is equivalent, and
|
||||||
|
projecting theta without the corresponding `A` matrix cannot be done correctly.
|
||||||
|
|
||||||
|
**D=13 with two preferred-hour dims** — cleaner circular encoding, but contradicts
|
||||||
|
the D=12 target in the issue spec and complicates the sim comparison. Deferred.
|
||||||
|
|
||||||
|
**In-place v1 promotion without shadow** — violates ADR-0002.
|
||||||
|
|
||||||
|
## Promotion record (2026-04-26)
|
||||||
|
|
||||||
|
Offline sim (`runner.py --policies egreedy-v1 egreedy-v2 --judge rule --n-users 5 --n-rounds 20 --seed 42`):
|
||||||
|
|
||||||
|
| policy | total reward | mean reward | pulls |
|
||||||
|
|--------|-------------|-------------|-------|
|
||||||
|
| egreedy-v1 | −64.20 | −0.6420 | 100 |
|
||||||
|
| egreedy-v2 | −62.90 | −0.6290 | 100 |
|
||||||
|
|
||||||
|
**Gate passed** (v2 mean ≥ v1 mean). Per-persona: v2 wins deadline-driven, evening-relaxed, low-priority-first; v1 wins consistent-responder, overdue-ignorer.
|
||||||
|
|
||||||
|
Changes applied:
|
||||||
|
- `recommender.ts` `remotePolicy()`: `/score/egreedy` → `/score/egreedy/v2`
|
||||||
|
- `recommender.ts` `sendRewardWithRetry()`: `/reward/egreedy` → `/reward/egreedy/v2`, added `profile_features` to payload
|
||||||
|
- Shadow entry `egreedy-v2-shadow` left in registry (`active: false`) for rollback.
|
||||||
106
docs/adr/0013-multi-agent-recommendation.md
Normal file
106
docs/adr/0013-multi-agent-recommendation.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# ADR-0013 — Multi-agent recommendation: pre-computed agent snippets + orchestrator LLM
|
||||||
|
|
||||||
|
**Status:** Accepted
|
||||||
|
**Date:** 2026-05-01
|
||||||
|
**Supersedes:** ADR-0007, ADR-0012
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The ε-greedy bandit (ADR-0007, promoted to v2 in ADR-0012) was the first recommendation
|
||||||
|
policy. It served adequately during early M1 testing but carries structural problems that
|
||||||
|
become more acute as the user base grows:
|
||||||
|
|
||||||
|
- **Training signal sparsity.** The median user generates fewer than 5 reward signals per
|
||||||
|
week. Ridge regression on a 12-dimensional feature vector needs far more signal than
|
||||||
|
that to converge to a meaningful θ before the user loses interest.
|
||||||
|
- **Cold-start cost.** Every new user starts with an uninformed identity matrix. Early tips
|
||||||
|
are essentially random for the first weeks of use — precisely when first impressions
|
||||||
|
matter most.
|
||||||
|
- **Opacity.** The bandit cannot explain why it chose a tip. An orchestrator that reasons
|
||||||
|
explicitly over named agent outputs ("3 overdue tasks + peak hour approaching") is
|
||||||
|
interpretable by design.
|
||||||
|
- **Coupling of generation and selection.** The current pipeline generates candidates, then
|
||||||
|
scores them; the scoring is decoupled from the LLM reasoning. Giving the LLM the full
|
||||||
|
pre-computed context directly is a simpler and more capable design.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Replace the RL bandit with a **multi-agent pipeline**:
|
||||||
|
|
||||||
|
### Sub-agents (async, pre-computed)
|
||||||
|
|
||||||
|
Multiple domain-specialized Python agents each analyze user state from one angle and
|
||||||
|
produce a **prompt snippet** — a short natural-language paragraph describing what they
|
||||||
|
found. They do not produce tips. They run periodically (every 15 minutes) and store
|
||||||
|
results in the new `agent_outputs` table with per-agent TTLs.
|
||||||
|
|
||||||
|
Initial agent set:
|
||||||
|
|
||||||
|
| Agent | ID | TTL |
|
||||||
|
|---|---|---|
|
||||||
|
| OverdueTaskAgent | `overdue-task` | 1h |
|
||||||
|
| MomentumAgent | `momentum` | 6h |
|
||||||
|
| TimeOfDayAgent | `time-of-day` | 15m |
|
||||||
|
| RecentPatternsAgent | `recent-patterns` | 24h |
|
||||||
|
| FocusAreaAgent | `focus-area` | 12h |
|
||||||
|
|
||||||
|
### Orchestrator agent (real-time)
|
||||||
|
|
||||||
|
When a user requests a tip, the TypeScript recommender:
|
||||||
|
1. Fetches all non-expired `agent_outputs` rows for the user.
|
||||||
|
2. Calls `POST /recommend` on `ml/serving` with the snippet list.
|
||||||
|
3. `ml/serving` assembles a single orchestrator prompt (template `v4-orchestrator`)
|
||||||
|
that concatenates all snippets, then calls LiteLLM via the existing `tip-generator`
|
||||||
|
alias to produce one tip.
|
||||||
|
|
||||||
|
No bandit scoring. No reward delivery to an ML model. The LLM receives full context and
|
||||||
|
generates the tip in one call.
|
||||||
|
|
||||||
|
### Feedback
|
||||||
|
|
||||||
|
`tipFeedback` rows are still written on every user reaction. `inferReward()` still runs
|
||||||
|
and `rewardMilli` is logged for observability and potential future supervised learning.
|
||||||
|
Reactions are not delivered to an ML endpoint.
|
||||||
|
|
||||||
|
## New data model
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE agent_outputs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
agent_id TEXT NOT NULL, -- e.g. 'overdue-task'
|
||||||
|
prompt_text TEXT NOT NULL, -- snippet produced by the agent
|
||||||
|
signals_snapshot TEXT, -- JSON: inputs the agent consumed
|
||||||
|
computed_at TEXT NOT NULL, -- ISO 8601
|
||||||
|
expires_at TEXT NOT NULL, -- ISO 8601 = computed_at + TTL
|
||||||
|
agent_version TEXT NOT NULL -- bump to invalidate cached outputs on logic changes
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_agent_outputs_user_agent_exp
|
||||||
|
ON agent_outputs(user_id, agent_id, expires_at DESC);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
- Tips are explainable: `featuresJson` in `tipScores` records which agents contributed.
|
||||||
|
- Cold-start is eliminated: the orchestrator reasons from signals immediately, no warm-up.
|
||||||
|
- Adding or removing an agent is a self-contained change in `ml/agents/`.
|
||||||
|
- Swapping LLM models remains a config change (LiteLLM alias unchanged).
|
||||||
|
|
||||||
|
### Negative / risks
|
||||||
|
- **No automatic exploration.** The bandit would discover that a user prefers certain tip
|
||||||
|
types without being told. The orchestrator only knows what the agents tell it.
|
||||||
|
Mitigation: agents can evolve to encode richer signals; offline evaluation via the
|
||||||
|
existing bench scripts remain available.
|
||||||
|
- **Scheduler dependency.** If the pre-compute job falls behind, agent outputs go
|
||||||
|
stale. Mitigation: the orchestrator falls back to raw signal prompt when no outputs
|
||||||
|
exist; `TimeOfDayAgent` recomputes every 15 min to stay fresh.
|
||||||
|
- **Higher per-request token cost.** The orchestrator prompt is longer than the old bandit
|
||||||
|
prompt. Mitigation: the `tip-generator` alias points to a small local model; token cost
|
||||||
|
is negligible at current scale.
|
||||||
|
|
||||||
|
## Migration sequence
|
||||||
|
|
||||||
|
See plan document in conversation context. 10 steps; each independently deployable and
|
||||||
|
rollback-able. Cutover is Step 6 (single TypeScript PR). Bandit endpoints removed in
|
||||||
|
Step 7 after 48h clean traffic.
|
||||||
230
docs/adr/0014-unified-profile-and-agent-registry.md
Normal file
230
docs/adr/0014-unified-profile-and-agent-registry.md
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
# ADR-0014 — Unified Profile model + agent registry
|
||||||
|
|
||||||
|
**Status:** Proposed
|
||||||
|
**Date:** 2026-05-05
|
||||||
|
**Issues:** #30, #111, #112, #113, #114, #115, #116
|
||||||
|
**Supersedes (data model):** ADR-0013 (the agent set stands; this ADR replaces the implicit assumption that prefs/contexts/consents are hardcoded on `users`).
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR-0013 introduced the multi-agent pipeline: N pre-compute agents emit
|
||||||
|
prompt snippets, an orchestrator LLM assembles them into a tip. The ADR
|
||||||
|
specified the `agent_outputs` table and the orchestrator contract, but
|
||||||
|
left several questions open:
|
||||||
|
|
||||||
|
1. **Where do user preferences live?** `users.consentGiven` is a single
|
||||||
|
boolean. There is no place for quiet hours, tone, allowed tip kinds,
|
||||||
|
or per-integration consent. Each new preference would mean another
|
||||||
|
typed column on `users` — and worse, every new agent needs its own
|
||||||
|
tunable parameters (focus areas, momentum baseline, lateness tolerance)
|
||||||
|
that are clearly per-agent state, not global user state.
|
||||||
|
2. **How are agents discovered?** The orchestrator currently iterates a
|
||||||
|
hardcoded list. Adding an agent means touching the recommender, the
|
||||||
|
admin UI, and the prefs schema in three places.
|
||||||
|
3. **How does context (work / home / vacation) interact with agents?**
|
||||||
|
Some agents should be silenced in some contexts. There is no model.
|
||||||
|
4. **How is per-user agent configuration learned?** Issues #112–#116
|
||||||
|
each want to auto-infer parameters (quiet hours, focus areas, etc.)
|
||||||
|
from history. Without a shared substrate they each reinvent storage,
|
||||||
|
recompute cadence, and cold-start fallback.
|
||||||
|
|
||||||
|
The current ADR-0013 design works for five agents. It will not work for
|
||||||
|
twenty without becoming a tangle.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Three changes, designed to compose:
|
||||||
|
|
||||||
|
### 1. Agents are plugins with declared schemas
|
||||||
|
|
||||||
|
Every agent ships a manifest (Python, lives next to its code in
|
||||||
|
`ml/agents/<id>/manifest.py`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
class AgentManifest:
|
||||||
|
id: str # 'time-of-day'
|
||||||
|
version: str # bump invalidates cached outputs + inferences
|
||||||
|
pref_schema: dict # JSON Schema for user-tunable knobs
|
||||||
|
context_schema: list[str] # signals it reads, e.g. ['todoist.tasks']
|
||||||
|
required_consents: list[str] # ['data:todoist', 'agent:time-of-day']
|
||||||
|
output_contract: dict # snippet shape (free text + optional tags)
|
||||||
|
ttl_sec: int # snippet freshness for agent_outputs
|
||||||
|
inferred_params: list[InferredParam] # see §3
|
||||||
|
```
|
||||||
|
|
||||||
|
The manifest is the **single point of registration**. The orchestrator,
|
||||||
|
admin UI, and inference framework all read from it. Adding an agent is
|
||||||
|
adding one directory in `ml/agents/` — no edits elsewhere.
|
||||||
|
|
||||||
|
A `GET /api/agents/registry` endpoint (TS recommender → Python proxy)
|
||||||
|
exposes manifests so the admin app can auto-render configuration UI from
|
||||||
|
each `pref_schema`.
|
||||||
|
|
||||||
|
### 2. Unified Profile data model
|
||||||
|
|
||||||
|
Three new tables replace the implicit "fields-on-users" pattern.
|
||||||
|
`users.consentGiven` collapses into `user_consents` (one row,
|
||||||
|
`consent_key='data:core'`); existing data migrates in a single
|
||||||
|
backfill.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Hybrid: typed columns where stable, KV where open-ended.
|
||||||
|
-- Stable globals stay on users (added in this ADR):
|
||||||
|
ALTER TABLE users ADD COLUMN tone TEXT; -- 'direct'|'gentle'|'motivational'
|
||||||
|
ALTER TABLE users ADD COLUMN tip_kinds_json TEXT; -- JSON: allowed tip kinds
|
||||||
|
|
||||||
|
-- Open-ended per-agent prefs land here:
|
||||||
|
CREATE TABLE user_preferences (
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
scope TEXT NOT NULL, -- 'orchestrator' | 'agent:<id>'
|
||||||
|
key TEXT NOT NULL, -- e.g. 'quietStart', 'focusAreas'
|
||||||
|
value_json TEXT NOT NULL, -- agent validates against its pref_schema on read
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
source TEXT NOT NULL DEFAULT 'user', -- 'user' | 'inferred'
|
||||||
|
PRIMARY KEY (user_id, scope, key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE user_consents (
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
consent_key TEXT NOT NULL, -- 'data:todoist' | 'data:calendar' | 'agent:focus-area'
|
||||||
|
granted_at TEXT NOT NULL,
|
||||||
|
revoked_at TEXT, -- null = currently active
|
||||||
|
PRIMARY KEY (user_id, consent_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE user_contexts (
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
name TEXT NOT NULL, -- 'work' | 'home' | 'vacation' | user-named
|
||||||
|
active INTEGER NOT NULL DEFAULT 0, -- boolean
|
||||||
|
schedule_json TEXT, -- optional: when this context is active
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, name)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Why hybrid (typed for stable globals, KV for per-agent):
|
||||||
|
|
||||||
|
- `tone` and allowed tip kinds are referenced by every recommendation —
|
||||||
|
putting them in JSON imposes a parse on every read.
|
||||||
|
- Per-agent prefs are open-ended (each agent declares its own keys) and
|
||||||
|
validated on read against the agent's `pref_schema`, so KV is correct.
|
||||||
|
|
||||||
|
`user_preferences.source = 'user' | 'inferred'` keeps explicit user
|
||||||
|
overrides distinguishable from inferred values (the inference framework
|
||||||
|
never overwrites a `source='user'` row).
|
||||||
|
|
||||||
|
`user_contexts` ships in this ADR with **manual toggle only**.
|
||||||
|
Auto-inference per agent type is tracked in #112–#116; cross-agent
|
||||||
|
calendar/geo inference is out of scope.
|
||||||
|
|
||||||
|
### 3. Shared context-inference framework
|
||||||
|
|
||||||
|
Each `InferredParam` in a manifest declares:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class InferredParam:
|
||||||
|
key: str # 'quietStart'
|
||||||
|
ttl_sec: int # how often to recompute
|
||||||
|
cold_start_default: Any # value used until enough history exists
|
||||||
|
min_history: int # event count threshold
|
||||||
|
infer: Callable[[UserHistory], Any] # pure function
|
||||||
|
```
|
||||||
|
|
||||||
|
The framework (`ml/agents/inference/`) owns:
|
||||||
|
|
||||||
|
- Scheduling (recomputes per-param via the existing pre-compute scheduler).
|
||||||
|
- Reading history from `tip_views` / `tip_feedback` / `agent_outputs`.
|
||||||
|
- Writing results to `user_preferences` with `source='inferred'`.
|
||||||
|
- Cold-start: returns `cold_start_default` until `min_history` is met.
|
||||||
|
- Versioning: bumping `agent.version` invalidates inferred rows for that agent.
|
||||||
|
- Observability: structured log per recompute (window size, output diff, latency).
|
||||||
|
|
||||||
|
Each per-agent issue (#112–#116) implements only its `infer()` functions;
|
||||||
|
everything else is the framework.
|
||||||
|
|
||||||
|
## Read-through API
|
||||||
|
|
||||||
|
Stays small as N grows because every endpoint is registry-driven:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/profile → { user, prefs (grouped by scope), contexts, consents, agents[] }
|
||||||
|
PATCH /api/profile/prefs/:scope → upserts user_preferences rows (source='user')
|
||||||
|
PATCH /api/profile/consents → grant/revoke
|
||||||
|
PATCH /api/profile/contexts → activate/deactivate / create
|
||||||
|
GET /api/agents/registry → manifests; admin UI auto-renders forms from pref_schema
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET /api/profile` is the read-through used by `ml/serving` and the web
|
||||||
|
client; it's the single endpoint each consumer calls instead of reading
|
||||||
|
the DB directly.
|
||||||
|
|
||||||
|
## Orchestrator flow under this ADR
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Load Profile = { user, prefs, active context, consents } via /api/profile.
|
||||||
|
2. From agent registry, filter eligible agents:
|
||||||
|
- required consents granted
|
||||||
|
- not silenced by active context (declared per-agent)
|
||||||
|
- enabled in user_preferences (default: enabled)
|
||||||
|
3. Pull latest non-expired agent_outputs for the eligible set.
|
||||||
|
4. Build orchestrator prompt:
|
||||||
|
- global prefs (tone, allowed tip kinds)
|
||||||
|
- active context name as hint
|
||||||
|
- agent snippets in eligibility order
|
||||||
|
5. LLM → tip.
|
||||||
|
```
|
||||||
|
|
||||||
|
No hardcoded agent list anywhere in the recommender. The orchestrator
|
||||||
|
prompt template (`v4-orchestrator`) iterates whatever it was handed.
|
||||||
|
|
||||||
|
## Migration plan
|
||||||
|
|
||||||
|
One PR per step; each independently deployable.
|
||||||
|
|
||||||
|
1. **Schema** — add the three tables; add `tone` and `tip_kinds_json` to `users`.
|
||||||
|
2. **Backfill** — write `users.consentGiven` rows into `user_consents` as `data:core`. Keep the column for one release, then drop.
|
||||||
|
3. **Manifest plumbing** — `ml/agents/<id>/manifest.py` for the existing five; `GET /api/agents/registry` proxy.
|
||||||
|
4. **Read-through API** — `/api/profile` + sub-endpoints.
|
||||||
|
5. **Orchestrator cutover** — registry-driven eligibility filter.
|
||||||
|
6. **Inference framework** (#111) — land it; migrate `time-of-day` (#112) as the proof.
|
||||||
|
7. **Per-agent inference** — #113–#116 land independently against the framework.
|
||||||
|
8. **Drop `users.consentGiven`** after one release.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
- Adding an agent = one directory. Admin UI, prefs storage, consent
|
||||||
|
storage, and inference all auto-pick-up.
|
||||||
|
- Per-agent state lives next to the agent code; nothing global to edit.
|
||||||
|
- User-controlled prefs and inferred prefs use the same storage but stay
|
||||||
|
distinguishable (`source` column).
|
||||||
|
- Consent revocation is row-level and time-stamped; aligns with the
|
||||||
|
privacy stance in CLAUDE.md ("privacy is a feature, not a phase").
|
||||||
|
- Sets up cleanly for #27 (Calendar) and #28 (Health) — they register
|
||||||
|
their own consent keys without schema changes.
|
||||||
|
|
||||||
|
### Negative / risks
|
||||||
|
|
||||||
|
- **JSON validation on read** for per-agent prefs is later than column
|
||||||
|
typing. Mitigated by validating in the manifest's load function and
|
||||||
|
failing closed (use cold-start default if invalid).
|
||||||
|
- **Two-table reads** for the orchestrator (registry + profile + outputs)
|
||||||
|
add latency. Cached profile read keeps it sub-ms in practice.
|
||||||
|
- **Migration window** during which `users.consentGiven` and
|
||||||
|
`user_consents` both exist. Reads must consult both for one release;
|
||||||
|
writes go to `user_consents` only.
|
||||||
|
- **Auto-inference can mislead.** A wrong-but-confident inferred quiet
|
||||||
|
window silences the user when they want pings. Mitigation: every
|
||||||
|
inferred param is overrideable in admin/settings (`source='user'`
|
||||||
|
takes precedence), and inferences only kick in past their
|
||||||
|
`min_history` threshold.
|
||||||
|
|
||||||
|
## What this does NOT change
|
||||||
|
|
||||||
|
- ADR-0013's agent set, snippet contract, or `agent_outputs` table.
|
||||||
|
- ADR-0011's `userProfileFeatures` (ML-derived features, not user prefs).
|
||||||
|
- ADR-0008's LiteLLM gateway pattern.
|
||||||
|
- The orchestrator prompt template name (`v4-orchestrator`); the assembly
|
||||||
|
rule changes, the contract does not.
|
||||||
44
docs/adr/0015-data-source-consents.md
Normal file
44
docs/adr/0015-data-source-consents.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# ADR-0015 — Data-source consents only; drop per-agent consent gate
|
||||||
|
|
||||||
|
**Date:** 2026-05-11
|
||||||
|
**Status:** Accepted
|
||||||
|
**Supersedes:** ADR-0014 §3 (consent model)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR-0014 introduced `required_consents` on agent manifests. In practice two
|
||||||
|
unrelated concepts were mixed into that field:
|
||||||
|
|
||||||
|
- `data:<source>` — which data source the agent reads.
|
||||||
|
- `agent:<id>` — whether the user opted into this specific agent.
|
||||||
|
|
||||||
|
No UI ever granted `agent:<id>` consents, so the eligibility filter at
|
||||||
|
`services/api/src/profile/eligibility.ts` dropped every agent for every real
|
||||||
|
user. The symptom was confirmed by MLflow trace
|
||||||
|
`tr-591449ea8a72af8e81b6a585234a86ab`: user `ODGp4Gkr7JWemMsqcMLMn` had five
|
||||||
|
fresh `agent_outputs` rows but the orchestrator received `agent_ids: []`.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Collapse to a single consent dimension: **data source**.
|
||||||
|
|
||||||
|
1. `required_consents` entries must all start with `data:`. Agent manifests no
|
||||||
|
longer list `agent:<id>` entries.
|
||||||
|
2. Connecting a data source via the OAuth flow automatically grants
|
||||||
|
`data:<provider>` in `user_consents`. Disconnecting sets `revoked_at`.
|
||||||
|
3. `data:core` continues to be auto-granted on signup.
|
||||||
|
4. Per-agent control becomes a **preference** (`user_preferences[scope='agent:<id>', key='enabled']`), not a consent. The eligibility filter already honours this — the only change is removing the `agent:*` consent check that was always failing.
|
||||||
|
5. Eligibility rule (final): an agent is eligible iff every `data:*` it
|
||||||
|
declares is granted and not revoked, no active context is in
|
||||||
|
`silenced_in_contexts`, and the `enabled` preference is not `false`.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Agents that only require `data:core` (time-of-day, momentum, recent-patterns)
|
||||||
|
become eligible immediately after signup.
|
||||||
|
- Agents requiring `data:todoist` or `data:google-health` become eligible as
|
||||||
|
soon as the user connects the integration — no extra consent step.
|
||||||
|
- A backfill migration grants `data:<provider>` for every existing active
|
||||||
|
`integration_tokens` row, unblocking users who connected before this change.
|
||||||
|
- `ml/agents/tests/test_manifest.py` asserts all `required_consents` start
|
||||||
|
with `data:`, preventing regression.
|
||||||
@@ -25,12 +25,37 @@ Session auth
|
|||||||
expires_at
|
expires_at
|
||||||
revoked_at?
|
revoked_at?
|
||||||
|
|
||||||
Profile profile
|
User (extended) profile ADR-0014
|
||||||
user_id (pk)
|
+ tone 'direct' | 'gentle' | 'motivational'
|
||||||
timezone
|
+ tip_kinds_json jsonb: allowed tip kinds (stable globals)
|
||||||
quiet_hours jsonb: [{start,end,days}]
|
|
||||||
contexts jsonb: [{name,predicate}] introduced in Phase 2
|
UserPreference profile ADR-0014
|
||||||
consents jsonb: {integration: {read,write,retain_days}}
|
user_id, scope, key (pk)
|
||||||
|
scope 'orchestrator' | 'agent:<id>'
|
||||||
|
value_json open-ended; agent validates against its pref_schema on read
|
||||||
|
source 'user' | 'inferred' (inferred never overwrites user)
|
||||||
|
updated_at
|
||||||
|
|
||||||
|
UserConsent profile ADR-0014
|
||||||
|
user_id, consent_key (pk)
|
||||||
|
consent_key 'data:todoist' | 'data:calendar' | 'agent:focus-area' | ...
|
||||||
|
granted_at
|
||||||
|
revoked_at? null = currently active
|
||||||
|
|
||||||
|
UserContext profile ADR-0014
|
||||||
|
user_id, name (pk) 'work' | 'home' | 'vacation' | user-named
|
||||||
|
active manual toggle in M2; auto-inference per agent in #112-#116
|
||||||
|
schedule_json? optional: when this context is active
|
||||||
|
created_at
|
||||||
|
|
||||||
|
AgentOutput recommender ADR-0013
|
||||||
|
id (pk)
|
||||||
|
user_id
|
||||||
|
agent_id e.g. 'overdue-task' (matches a manifest)
|
||||||
|
prompt_text snippet for the orchestrator prompt
|
||||||
|
signals_snapshot jsonb: inputs the agent consumed
|
||||||
|
computed_at, expires_at computed_at + manifest.ttl_sec
|
||||||
|
agent_version bump to invalidate cached outputs on logic changes
|
||||||
|
|
||||||
Credential integrations
|
Credential integrations
|
||||||
user_id
|
user_id
|
||||||
@@ -53,10 +78,10 @@ Event events
|
|||||||
TipInstance recommender
|
TipInstance recommender
|
||||||
tip_id (ulid)
|
tip_id (ulid)
|
||||||
user_id
|
user_id
|
||||||
policy_name "random" | "bandit.linucb" | "remote:v3"
|
policy_name "v4-orchestrator" (ADR-0013) | legacy bandit names retained for history
|
||||||
policy_version
|
policy_version
|
||||||
candidate_source "todoist" | "advice.library" | ...
|
candidate_source "todoist" | "advice.library" | "agent-orchestrator" | ...
|
||||||
context_snapshot jsonb: features seen at decision time
|
context_snapshot jsonb: features + agent snippets seen at decision time
|
||||||
tip jsonb: {kind,title,body,source,deep_link,meta}
|
tip jsonb: {kind,title,body,source,deep_link,meta}
|
||||||
created_at
|
created_at
|
||||||
shown_at? set when the client reports render
|
shown_at? set when the client reports render
|
||||||
|
|||||||
@@ -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,58 @@ User reactions (done / snooze / dismiss) are events too. They close the loop as
|
|||||||
- **Protobuf** for event schemas with a schema registry (ADR-0005) — train/serve parity depends on this.
|
- **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`.
|
||||||
- **Auth.js** embedded behind an OIDC-shaped boundary (ADR-0004). Swap to a standalone OIDC provider when mobile ships.
|
- **Auth.js** embedded behind an OIDC-shaped boundary (ADR-0004). Swap to a standalone OIDC provider when mobile ships.
|
||||||
|
- **Multi-agent recommendation** (ADR-0013) — pre-compute agents emit prompt snippets, an orchestrator LLM produces the tip. Replaced the ε-greedy bandit (ADR-0007/0012) for explainability, cold-start, and decoupling generation from selection.
|
||||||
|
- **Registry-driven agents + unified Profile** (ADR-0014) — agents are plugins with declared manifests; per-user prefs, contexts, and per-key consents live in shared tables; auto-inferred parameters share a common framework. Adding an agent is a manifest change.
|
||||||
- **k3s** as the first step beyond docker-compose — no "compose → full k8s" cliff.
|
- **k3s** as the first step beyond docker-compose — no "compose → full k8s" cliff.
|
||||||
|
|
||||||
## Decision flow for a new tip
|
## AI stack
|
||||||
|
|
||||||
|
All LLM inference routes through **LiteLLM** (`llm.alogins.net`) backed by **Ollama** (local, `localhost:11434`). This means:
|
||||||
|
- Model aliases (`tip-generator`, `embedder`, `judge`) decouple code from model names.
|
||||||
|
- Swapping qwen2.5 → llama3.2 = one-line config change in LiteLLM, zero code change in oO.
|
||||||
|
- Cloud fallback (Anthropic) is opt-in and gated behind `ANTHROPIC_API_KEY` — used only in offline simulation.
|
||||||
|
|
||||||
|
**OpenWebUI** (`ai.alogins.net`) is the human-facing interface for prompt iteration and model testing during development.
|
||||||
|
|
||||||
|
## Decision flow for a new tip (M2, ADR-0013 + ADR-0014)
|
||||||
|
|
||||||
```
|
```
|
||||||
client ─► gateway ─► recommender
|
┌────────────────────────────────────────────────┐
|
||||||
│
|
│ Pre-compute (every 15 min, per registered agent) │
|
||||||
├─► candidates: integrations.fetchCandidates(user) + advice.library
|
│ ml/agents/<id> → prompt snippet → agent_outputs │
|
||||||
├─► context: FeatureAssembler(user, request)
|
│ TTL per manifest; agent_version invalidates │
|
||||||
├─► policy: PolicyRegistry.get(policyName).pick(candidates, context)
|
└────────────────────────────────────────────────┘
|
||||||
├─► shadows: run shadow policies in parallel, log their picks
|
|
||||||
└─► persist: TipInstance{context_snapshot, policy, tip}
|
client ─► gateway ─► recommender (TS)
|
||||||
◄─ tip
|
│
|
||||||
|
├─► profile: GET /api/profile
|
||||||
|
│ (user, prefs, active context, consents)
|
||||||
|
│
|
||||||
|
├─► registry: GET /api/agents/registry
|
||||||
|
│ (manifests; eligibility filter inputs)
|
||||||
|
│
|
||||||
|
├─► outputs: pull freshest non-expired agent_outputs
|
||||||
|
│ for eligible agents (consents granted,
|
||||||
|
│ not silenced by active context, enabled)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
ml/serving (Python)
|
||||||
|
│
|
||||||
|
├─► assemble: v4-orchestrator prompt
|
||||||
|
│ = global prefs + active context + snippets
|
||||||
|
│
|
||||||
|
├─► generate: LiteLLM → Ollama → one tip
|
||||||
|
│
|
||||||
|
└─► persist: tip_scores {tip, contributing agents,
|
||||||
|
prompt_version, llm_model, latency}
|
||||||
|
◄─ tip
|
||||||
```
|
```
|
||||||
|
|
||||||
Feedback travels back the same path: `POST /feedback → events.emit(feedback.reaction)` → pipelines consume → bandit/model updated on next retrain.
|
**Evolution:**
|
||||||
|
- **Phase 1 (M1):** candidates from Todoist; ε-greedy bandit scored tasks directly (ADR-0007, ADR-0012). Superseded.
|
||||||
|
- **Phase 2 early (M2):** LLM-generated candidates ranked by bandit. Superseded mid-milestone.
|
||||||
|
- **Phase 2 current (M2):** multi-agent pipeline (ADR-0013), registry-driven and registry-extensible (ADR-0014). No bandit; the orchestrator LLM reasons over named agent snippets.
|
||||||
|
|
||||||
|
Feedback: `POST /feedback → events.emit(reaction)`. No online ML reward loop (ADR-0013 §Consequences); reactions are logged in `tip_feedback` for observability and potential future supervised learning.
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ User taps "Delete account" in settings → hard confirm → `User.deleted_at` se
|
|||||||
|
|
||||||
## Scope boundaries
|
## Scope boundaries
|
||||||
|
|
||||||
Each integration declares the scopes it requests and the features it derives. The `Profile.consents` column is the source of truth; a scope removed from consent short-circuits derived-feature computation at the feature store.
|
Each integration and each agent declares the consent keys it requires (`data:todoist`, `agent:focus-area`, ...) in its manifest. The `user_consents` table is the source of truth (per-key rows, revocation is a `revoked_at` write — never a delete, so audits stay clean). A revoked consent short-circuits derived-feature computation at the feature store and removes the dependent agent from the orchestrator's eligible set on the next tip. See ADR-0014.
|
||||||
|
|
||||||
## Audit
|
## Audit
|
||||||
|
|
||||||
|
|||||||
63
infra/ci/ci.yml
Normal file
63
infra/ci/ci.yml
Normal 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
|
||||||
33
infra/docker/Dockerfile.admin
Normal file
33
infra/docker/Dockerfile.admin
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
|
||||||
|
FROM node:22-slim AS base
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
python3 make g++ ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& npm install -g pnpm
|
||||||
|
ENV CI=true \
|
||||||
|
PNPM_HOME=/pnpm \
|
||||||
|
PATH=/pnpm:$PATH
|
||||||
|
RUN pnpm config set store-dir /pnpm/store
|
||||||
|
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY pnpm-lock.yaml ./
|
||||||
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm fetch
|
||||||
|
COPY . .
|
||||||
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||||
|
pnpm install --frozen-lockfile --offline \
|
||||||
|
--filter @oo/admin... --filter @oo/shared-types
|
||||||
|
RUN pnpm --filter @oo/shared-types build
|
||||||
|
ARG NEXT_PUBLIC_MLFLOW_URL=/mlflow
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1 \
|
||||||
|
NEXT_PUBLIC_MLFLOW_URL=$NEXT_PUBLIC_MLFLOW_URL
|
||||||
|
RUN pnpm --filter @oo/admin build
|
||||||
|
|
||||||
|
FROM node:22-slim AS runner
|
||||||
|
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=3080 DOCS_ROOT=/app/docs
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /app/apps/admin/.next/standalone ./
|
||||||
|
COPY --from=builder /app/apps/admin/.next/static ./apps/admin/.next/static
|
||||||
|
COPY --from=builder /app/docs ./docs
|
||||||
|
CMD ["node", "apps/admin/server.js"]
|
||||||
35
infra/docker/Dockerfile.api
Normal file
35
infra/docker/Dockerfile.api
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
|
||||||
|
FROM node:22-slim AS base
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
python3 make g++ ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& npm install -g pnpm
|
||||||
|
ENV CI=true \
|
||||||
|
PNPM_HOME=/pnpm \
|
||||||
|
PATH=/pnpm:$PATH
|
||||||
|
RUN pnpm config set store-dir /pnpm/store
|
||||||
|
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY pnpm-lock.yaml ./
|
||||||
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm fetch
|
||||||
|
COPY . .
|
||||||
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||||
|
pnpm install --frozen-lockfile \
|
||||||
|
--filter @oo/api... --filter @oo/shared-types
|
||||||
|
RUN pnpm --filter @oo/shared-types build
|
||||||
|
RUN pnpm --filter @oo/api build
|
||||||
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||||
|
pnpm --filter @oo/api --prod deploy --legacy /deploy \
|
||||||
|
&& cp -r services/api/dist /deploy/dist \
|
||||||
|
&& rm -rf /deploy/node_modules/@oo/shared-types/src \
|
||||||
|
&& cp -r packages/shared-types/dist /deploy/node_modules/@oo/shared-types/dist
|
||||||
|
|
||||||
|
FROM node:22-slim AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
COPY --from=builder /deploy/package.json ./
|
||||||
|
COPY --from=builder /deploy/node_modules ./node_modules
|
||||||
|
COPY --from=builder /deploy/dist ./dist
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
11
infra/docker/Dockerfile.ml
Normal file
11
infra/docker/Dockerfile.ml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /app/ml/serving
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends build-essential \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY ml/serving/requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
COPY ml/ /app/ml/
|
||||||
|
# PYTHONPATH=/app lets 'import ml.agents.*' resolve from /app/ml/agents/
|
||||||
|
ENV PYTHONPATH=/app
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
30
infra/docker/Dockerfile.web
Normal file
30
infra/docker/Dockerfile.web
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
FROM node:22-alpine AS base
|
||||||
|
RUN npm install -g pnpm
|
||||||
|
|
||||||
|
FROM base AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./
|
||||||
|
COPY packages/shared-types/package.json ./packages/shared-types/
|
||||||
|
COPY apps/web/package.json ./apps/web/
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY --from=deps /app/packages/shared-types/node_modules ./packages/shared-types/node_modules
|
||||||
|
COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules
|
||||||
|
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||||
|
COPY tsconfig.base.json ./
|
||||||
|
COPY packages/shared-types ./packages/shared-types
|
||||||
|
COPY apps/web ./apps/web
|
||||||
|
RUN pnpm --filter @oo/shared-types build
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
RUN pnpm --filter @oo/web build
|
||||||
|
|
||||||
|
FROM node:22-alpine AS runner
|
||||||
|
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /app/apps/web/.next/standalone ./
|
||||||
|
COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static
|
||||||
|
COPY --from=builder /app/apps/web/public ./apps/web/public
|
||||||
|
CMD ["node", "apps/web/server.js"]
|
||||||
174
infra/docker/docker-compose.yml
Normal file
174
infra/docker/docker-compose.yml
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
name: oo
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ── core profile ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: infra/docker/Dockerfile.api
|
||||||
|
profiles: [core, full]
|
||||||
|
env_file: ../../.env.local
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
ML_SERVING_URL: "http://ml-serving:8000"
|
||||||
|
MLFLOW_URL: "http://mlflow:5000"
|
||||||
|
INTERNAL_API_TOKEN: "${INTERNAL_API_TOKEN:-}"
|
||||||
|
volumes:
|
||||||
|
- /mnt/ssd/dbs/oo:/mnt/ssd/dbs/oo
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3078:3078"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "-e", "fetch('http://localhost:3078/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: infra/docker/Dockerfile.web
|
||||||
|
profiles: [core, full]
|
||||||
|
env_file: ../../.env.local
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: "3079"
|
||||||
|
HOSTNAME: "0.0.0.0"
|
||||||
|
NEXT_PUBLIC_API_URL: "" # Caddy routes /api/* directly to the API in prod
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3079:3079"
|
||||||
|
depends_on:
|
||||||
|
api:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
admin:
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: infra/docker/Dockerfile.admin
|
||||||
|
profiles: [core, full]
|
||||||
|
env_file: ../../.env.local
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: "3080"
|
||||||
|
HOSTNAME: "0.0.0.0"
|
||||||
|
NEXT_PUBLIC_API_URL: ""
|
||||||
|
NEXT_PUBLIC_MLFLOW_URL: "/mlflow"
|
||||||
|
INTERNAL_API_URL: "http://api:3078"
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3080:3080"
|
||||||
|
depends_on:
|
||||||
|
api:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
# ── full profile ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
ml-serving:
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: infra/docker/Dockerfile.ml
|
||||||
|
profiles: [full]
|
||||||
|
env_file: ../../.env.local
|
||||||
|
environment:
|
||||||
|
LITELLM_URL: ${LITELLM_URL:-http://host.docker.internal:4000}
|
||||||
|
OLLAMA_URL: ${OLLAMA_URL:-http://host.docker.internal:11434}
|
||||||
|
MLFLOW_TRACKING_URI: ${MLFLOW_TRACKING_URI:-http://mlflow:5000}
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8000:8000"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/health',timeout=3).status==200 else 1)"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# ── ai profile — Ollama + LiteLLM for local dev ──────────────────────────
|
||||||
|
# Start: docker compose --profile ai up
|
||||||
|
# Use when the Agap shared Ollama/LiteLLM services are not available locally.
|
||||||
|
# Set LITELLM_URL=http://localhost:4000 and OLLAMA_URL=http://localhost:11434
|
||||||
|
# in .env.local to point ml-serving at these containers instead of Agap.
|
||||||
|
|
||||||
|
ollama:
|
||||||
|
image: ollama/ollama:latest
|
||||||
|
profiles: [ai]
|
||||||
|
volumes:
|
||||||
|
- ollama-models:/root/.ollama
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:11434:11434"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-sf", "http://localhost:11434/api/tags"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
litellm:
|
||||||
|
image: ghcr.io/berriai/litellm:main-latest
|
||||||
|
profiles: [ai]
|
||||||
|
environment:
|
||||||
|
LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY:-sk-local-dev}
|
||||||
|
command: >
|
||||||
|
--model ollama/qwen2.5:1.5b
|
||||||
|
--model ollama/nomic-embed-text
|
||||||
|
--api_base http://ollama:11434
|
||||||
|
--port 4000
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:4000:4000"
|
||||||
|
depends_on:
|
||||||
|
ollama:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-sf", "http://localhost:4000/health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# ── mlops profile — MLflow ────────────────────────────────────────────────
|
||||||
|
# Start: docker compose --profile mlops up
|
||||||
|
# MLflow UI: http://localhost:5000 or https://o.alogins.net/mlflow
|
||||||
|
|
||||||
|
# ── events profile — NATS JetStream ─────────────────────────────────────
|
||||||
|
# Start: docker compose --profile events up
|
||||||
|
# NATS monitoring: http://localhost:8222
|
||||||
|
# Enable in the API by setting NATS_URL=nats://nats:4222 in .env.local
|
||||||
|
|
||||||
|
nats:
|
||||||
|
image: nats:2.10-alpine
|
||||||
|
profiles: [events, full]
|
||||||
|
command: ["-js", "-sd", "/data", "-m", "8222"]
|
||||||
|
volumes:
|
||||||
|
- /mnt/ssd/dbs/oo/nats:/data
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:4222:4222" # client connections
|
||||||
|
- "127.0.0.1:8222:8222" # HTTP monitoring
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8222/healthz"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
mlflow:
|
||||||
|
image: ghcr.io/mlflow/mlflow:v3.11.1
|
||||||
|
profiles: [mlops]
|
||||||
|
command: >
|
||||||
|
mlflow server
|
||||||
|
--backend-store-uri sqlite:////mlflow/mlflow.db
|
||||||
|
--artifacts-destination /mlflow/artifacts
|
||||||
|
--serve-artifacts
|
||||||
|
--default-artifact-root mlflow-artifacts:/
|
||||||
|
--host 0.0.0.0
|
||||||
|
--port 5000
|
||||||
|
--static-prefix /mlflow
|
||||||
|
--allowed-hosts o.alogins.net,localhost,localhost:5000,mlflow,mlflow:5000
|
||||||
|
--cors-allowed-origins https://o.alogins.net
|
||||||
|
volumes:
|
||||||
|
- /mnt/ssd/dbs/oo/mlflow:/mlflow
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:5000:5000"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:5000/mlflow/health',timeout=3).status==200 else 1)"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
ollama-models:
|
||||||
6
infra/mlflow/basic_auth.ini
Normal file
6
infra/mlflow/basic_auth.ini
Normal 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
|
||||||
29
ml/README.md
29
ml/README.md
@@ -4,9 +4,9 @@ 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 + prompt registry (`prompts.py`) + JetStream consumers for `signals.>` / `feedback.>`, called by `recommender` | 1–2 |
|
||||||
| `features/` | feature definitions + store adapter (Feast later) | 1 |
|
| `features/` | context assembler (`context.py`): signals → `PromptContext`; profile-feature schema mirror (`profile_schema.py`); Feast adapter later | 2 |
|
||||||
| `pipelines/` | batch feature + training DAGs (Prefect/Airflow) | 4 |
|
| `pipelines/` | batch feature + training scripts | 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 |
|
||||||
| `notebooks/` | research; never imported by production code | — |
|
| `notebooks/` | research; never imported by production code | — |
|
||||||
@@ -17,3 +17,26 @@ Python. Owns models, features, training, online scoring.
|
|||||||
- Online inference must be stateless and < 50ms p99.
|
- Online inference must be stateless and < 50ms p99.
|
||||||
- Training reads from the offline feature store; serving reads from the online feature store; definitions are shared (no train/serve skew).
|
- Training reads from the offline feature store; serving reads from the online feature store; definitions are shared (no train/serve skew).
|
||||||
- Shadow deploys before any policy change that affects real users.
|
- Shadow deploys before any policy change that affects real users.
|
||||||
|
|
||||||
|
## Feature contract
|
||||||
|
|
||||||
|
### Profile features (batched)
|
||||||
|
|
||||||
|
User-level features (completion rate, preferred hour, tip volume…) are computed
|
||||||
|
by the TypeScript recommender and shipped to `ml/serving` on every `/score` and
|
||||||
|
`/generate` call as `profile_features: dict | None`. The Python mirror in
|
||||||
|
`features/profile_schema.py` documents each feature's name, dtype, TTL, source,
|
||||||
|
and null fallback — keep it in sync with `services/api/src/profile/registry.ts`
|
||||||
|
(a CI-style test asserts names and `ttlSec` values match). See ADR-0011.
|
||||||
|
|
||||||
|
### Context features (JIT)
|
||||||
|
|
||||||
|
Request-time signals assembled by `features/context.py` (`hour_of_day`,
|
||||||
|
`day_of_week`, task list). These are never cached — they are derived from the
|
||||||
|
system clock and the live Todoist feed at the moment of the score call.
|
||||||
|
`CONTEXT_FEATURES` in `context.py` declares freshness, source, and fallback for
|
||||||
|
each field (issue #61).
|
||||||
|
|
||||||
|
## Prompt registry
|
||||||
|
|
||||||
|
`serving/prompts.py` keys tip-generation prompts by stable version string. Adding a new variant means adding an entry — no caller changes. Selection precedence: `POST /generate` body's `prompt_version` field → env `DEFAULT_PROMPT_VERSION` → `"v1"`. The TypeScript recommender drives selection via `TIP_PROMPT_VERSION` (single value or comma-separated rotation); the version actually used flows back in the response and is persisted to `tip_scores.prompt_version` so the admin reward-analytics dashboard can bucket reactions per variant.
|
||||||
|
|||||||
0
ml/__init__.py
Normal file
0
ml/__init__.py
Normal file
4
ml/agents/__init__.py
Normal file
4
ml/agents/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from .base import BaseAgent, AgentInput, AgentOutput
|
||||||
|
from .registry import get_agent, all_agents
|
||||||
|
|
||||||
|
__all__ = ["BaseAgent", "AgentInput", "AgentOutput", "get_agent", "all_agents"]
|
||||||
61
ml/agents/base.py
Normal file
61
ml/agents/base.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"""Base class and shared data structures for all recommendation sub-agents."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import ClassVar
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AgentInput:
|
||||||
|
"""Everything an agent may need to produce its prompt snippet."""
|
||||||
|
user_id: str
|
||||||
|
tasks: list[dict] # task signal dicts (content, priority, is_overdue, …)
|
||||||
|
profile: dict[str, float | None] # profile feature values keyed by feature name
|
||||||
|
feedback_history: list[dict] = field(default_factory=list) # [{action, dwell_ms, created_at}, …]
|
||||||
|
now: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
# Per-agent inferred/user prefs loaded from user_preferences (ADR-0014 §3).
|
||||||
|
# Keys match the agent's pref_schema + inferred_params. 'user' source takes
|
||||||
|
# precedence over 'inferred' source; the caller resolves priority before
|
||||||
|
# passing this dict in.
|
||||||
|
agent_prefs: dict = field(default_factory=dict)
|
||||||
|
# Pre-fetched enrichment cache: {content_hash -> description}. Populated by
|
||||||
|
# the TS caller from the task_enrichments DB table to avoid redundant LLM calls.
|
||||||
|
enrichment_cache: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AgentOutput:
|
||||||
|
"""Result produced by an agent; persisted to agent_outputs table."""
|
||||||
|
user_id: str
|
||||||
|
agent_id: str
|
||||||
|
prompt_text: str # snippet passed to the orchestrator
|
||||||
|
signals_snapshot: dict # inputs consumed (for explainability / debugging)
|
||||||
|
computed_at: str # ISO 8601
|
||||||
|
expires_at: str # ISO 8601
|
||||||
|
agent_version: str
|
||||||
|
|
||||||
|
|
||||||
|
class BaseAgent(ABC):
|
||||||
|
agent_id: ClassVar[str]
|
||||||
|
ttl_seconds: ClassVar[int]
|
||||||
|
version: ClassVar[str]
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def compute(self, inp: AgentInput) -> AgentOutput:
|
||||||
|
"""Analyse inp and return a prompt snippet describing what was found."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def _make_output(self, inp: AgentInput, prompt_text: str, snapshot: dict) -> AgentOutput:
|
||||||
|
computed_at = inp.now.astimezone(timezone.utc).isoformat()
|
||||||
|
expires_at = (inp.now.astimezone(timezone.utc) + timedelta(seconds=self.ttl_seconds)).isoformat()
|
||||||
|
return AgentOutput(
|
||||||
|
user_id=inp.user_id,
|
||||||
|
agent_id=self.agent_id,
|
||||||
|
prompt_text=prompt_text,
|
||||||
|
signals_snapshot=snapshot,
|
||||||
|
computed_at=computed_at,
|
||||||
|
expires_at=expires_at,
|
||||||
|
agent_version=self.version,
|
||||||
|
)
|
||||||
290
ml/agents/clustering.py
Normal file
290
ml/agents/clustering.py
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
"""Semantic task clustering via nomic-embed-text (issue #97, #129).
|
||||||
|
|
||||||
|
Public API:
|
||||||
|
cluster_tasks(tasks) -> list[Cluster]
|
||||||
|
|
||||||
|
Each task dict must have a "content" key. Tasks without content are placed in a
|
||||||
|
fallback "other" bucket. If the embedding service is unreachable, falls back to
|
||||||
|
grouping by project_id so compute() always returns something useful.
|
||||||
|
|
||||||
|
Pipeline (ported from taskpile experiments/clustering_eval, prompt v1):
|
||||||
|
1. Expand each raw title via LiteLLM `tip-generator` (qwen2.5:1.5b) into a
|
||||||
|
3-sentence description. Cached in-memory by content hash within a compute
|
||||||
|
cycle so duplicate titles cost one LLM call.
|
||||||
|
2. Prefix the expanded text with "clustering: " (nomic-embed-text task prefix).
|
||||||
|
3. Batch-embed via LiteLLM `embedder` (nomic-embed-text).
|
||||||
|
Falls back to embedding raw titles when LLM expansion fails, and to
|
||||||
|
project-based grouping when embeddings are unavailable.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Cosine similarity threshold for merging tasks into the same cluster.
|
||||||
|
_SIM_THRESHOLD = 0.72
|
||||||
|
# Never produce more than this many clusters regardless of task count.
|
||||||
|
_MAX_CLUSTERS = 6
|
||||||
|
_EMBED_TIMEOUT = 15.0
|
||||||
|
_ENRICH_TIMEOUT = 30.0
|
||||||
|
|
||||||
|
_ENRICH_PROMPT_V1 = (
|
||||||
|
"You are helping categorize a personal task. "
|
||||||
|
"Write exactly 3 sentences in English describing what the task likely involves, "
|
||||||
|
"what context or skills it needs, and why it might matter. "
|
||||||
|
"Be concise and specific. Do not use bullet points or numbering.\n"
|
||||||
|
"Task: {title}\n"
|
||||||
|
"Description:"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Cluster:
|
||||||
|
label: str # representative task content (shortest, most central)
|
||||||
|
tasks: list[dict] = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def task_count(self) -> int:
|
||||||
|
return len(self.tasks)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def overdue_count(self) -> int:
|
||||||
|
return sum(1 for t in self.tasks if t.get("is_overdue"))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# LLM enrichment
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _content_hash(text: str) -> str:
|
||||||
|
return hashlib.md5(text.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _enrich_title(title: str, litellm_url: str) -> str | None:
|
||||||
|
"""Expand a terse task title into a 3-sentence description via LiteLLM."""
|
||||||
|
try:
|
||||||
|
with httpx.Client(trust_env=False, timeout=_ENRICH_TIMEOUT) as c:
|
||||||
|
r = c.post(
|
||||||
|
f"{litellm_url}/chat/completions",
|
||||||
|
json={
|
||||||
|
"model": "tip-generator",
|
||||||
|
"messages": [{"role": "user", "content": _ENRICH_PROMPT_V1.format(title=title)}],
|
||||||
|
"max_tokens": 120,
|
||||||
|
"temperature": 0.3,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()["choices"][0]["message"]["content"].strip()
|
||||||
|
except Exception as exc:
|
||||||
|
log.debug("enrich_failed title=%r error=%s", title[:40], exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _enrich_batch(
|
||||||
|
titles: list[str],
|
||||||
|
persistent_cache: dict[str, str] | None = None,
|
||||||
|
) -> tuple[list[str], dict[str, str]]:
|
||||||
|
"""Return (descriptions, new_entries) for each title.
|
||||||
|
|
||||||
|
Checks persistent_cache (pre-fetched from DB) first, then falls back to
|
||||||
|
calling LiteLLM. new_entries contains only hashes generated this call —
|
||||||
|
the caller should persist these to the DB.
|
||||||
|
"""
|
||||||
|
litellm_url = os.getenv("LITELLM_URL")
|
||||||
|
if not litellm_url:
|
||||||
|
log.debug("enrich_batch: no LITELLM_URL, skipping enrichment")
|
||||||
|
return titles, {}
|
||||||
|
|
||||||
|
db_cache = persistent_cache or {}
|
||||||
|
session_cache: dict[str, str] = {} # dedup within this call
|
||||||
|
new_entries: dict[str, str] = {}
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for title in titles:
|
||||||
|
h = _content_hash(title)
|
||||||
|
if h in db_cache:
|
||||||
|
results.append(db_cache[h])
|
||||||
|
elif h in session_cache:
|
||||||
|
results.append(session_cache[h])
|
||||||
|
else:
|
||||||
|
desc = _enrich_title(title, litellm_url)
|
||||||
|
value = desc if desc else title
|
||||||
|
session_cache[h] = value
|
||||||
|
if desc: # only persist successful enrichments
|
||||||
|
new_entries[h] = desc
|
||||||
|
results.append(value)
|
||||||
|
|
||||||
|
return results, new_entries
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Embedding
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _embed_via_litellm(texts: list[str], litellm_url: str) -> list[list[float]] | None:
|
||||||
|
"""Batch embed via LiteLLM OpenAI-compatible /embeddings endpoint."""
|
||||||
|
try:
|
||||||
|
with httpx.Client(trust_env=False, timeout=_EMBED_TIMEOUT) as c:
|
||||||
|
r = c.post(
|
||||||
|
f"{litellm_url}/embeddings",
|
||||||
|
json={"model": "embedder", "input": texts},
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json().get("data", [])
|
||||||
|
ordered = sorted(data, key=lambda x: x["index"])
|
||||||
|
return [item["embedding"] for item in ordered]
|
||||||
|
except Exception as exc:
|
||||||
|
log.debug("litellm_embed_failed error=%s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _embed_via_ollama(texts: list[str], ollama_url: str) -> list[list[float]] | None:
|
||||||
|
"""Batch embed via Ollama /api/embed endpoint."""
|
||||||
|
try:
|
||||||
|
results = []
|
||||||
|
with httpx.Client(trust_env=False, timeout=_EMBED_TIMEOUT) as c:
|
||||||
|
for text in texts:
|
||||||
|
r = c.post(
|
||||||
|
f"{ollama_url}/api/embed",
|
||||||
|
json={"model": "nomic-embed-text", "input": text},
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
body = r.json()
|
||||||
|
# /api/embed returns {"embeddings": [[...]]}
|
||||||
|
embeddings = body.get("embeddings")
|
||||||
|
if not embeddings:
|
||||||
|
return None
|
||||||
|
results.append(embeddings[0])
|
||||||
|
return results
|
||||||
|
except Exception as exc:
|
||||||
|
log.debug("ollama_embed_failed error=%s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _embed_batch(texts: list[str]) -> list[list[float]] | None:
|
||||||
|
"""Embed a list of texts, preferring LiteLLM over direct Ollama."""
|
||||||
|
litellm_url = os.getenv("LITELLM_URL")
|
||||||
|
if litellm_url:
|
||||||
|
vecs = _embed_via_litellm(texts, litellm_url)
|
||||||
|
if vecs is not None:
|
||||||
|
return vecs
|
||||||
|
log.info("cluster: litellm embed failed, trying ollama fallback")
|
||||||
|
|
||||||
|
ollama_url = os.getenv("OLLAMA_URL", "http://host.docker.internal:11434")
|
||||||
|
return _embed_via_ollama(texts, ollama_url)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Clustering
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _cosine(a: list[float], b: list[float]) -> float:
|
||||||
|
dot = sum(x * y for x, y in zip(a, b))
|
||||||
|
na = math.sqrt(sum(x * x for x in a))
|
||||||
|
nb = math.sqrt(sum(x * x for x in b))
|
||||||
|
if na == 0 or nb == 0:
|
||||||
|
return 0.0
|
||||||
|
return dot / (na * nb)
|
||||||
|
|
||||||
|
|
||||||
|
def _greedy_cluster(items: list[tuple[dict, list[float]]]) -> list[Cluster]:
|
||||||
|
"""Single-pass greedy clustering: each item joins the first existing cluster
|
||||||
|
whose centroid is above _SIM_THRESHOLD, else starts a new one."""
|
||||||
|
clusters: list[tuple[list[float], Cluster]] = [] # (centroid, cluster)
|
||||||
|
|
||||||
|
for task, vec in items:
|
||||||
|
best_idx = -1
|
||||||
|
best_sim = _SIM_THRESHOLD - 1e-9
|
||||||
|
for i, (centroid, _) in enumerate(clusters):
|
||||||
|
sim = _cosine(centroid, vec)
|
||||||
|
if sim > best_sim:
|
||||||
|
best_sim = sim
|
||||||
|
best_idx = i
|
||||||
|
|
||||||
|
if best_idx >= 0 and len(clusters) < _MAX_CLUSTERS:
|
||||||
|
centroid, cluster = clusters[best_idx]
|
||||||
|
cluster.tasks.append(task)
|
||||||
|
# Update centroid as running mean.
|
||||||
|
n = len(cluster.tasks)
|
||||||
|
new_centroid = [(c * (n - 1) + v) / n for c, v in zip(centroid, vec)]
|
||||||
|
clusters[best_idx] = (new_centroid, cluster)
|
||||||
|
elif len(clusters) < _MAX_CLUSTERS:
|
||||||
|
label = task.get("content", "Tasks")[:60]
|
||||||
|
cluster = Cluster(label=label, tasks=[task])
|
||||||
|
clusters.append((vec, cluster))
|
||||||
|
else:
|
||||||
|
# Overflow: append to closest cluster even below threshold.
|
||||||
|
best_i = max(range(len(clusters)), key=lambda i: _cosine(clusters[i][0], vec))
|
||||||
|
clusters[best_i][1].tasks.append(task)
|
||||||
|
|
||||||
|
return [c for _, c in clusters]
|
||||||
|
|
||||||
|
|
||||||
|
def _fallback_by_project(tasks: list[dict]) -> list[Cluster]:
|
||||||
|
"""Group by project_id when embeddings are unavailable."""
|
||||||
|
buckets: dict[str, Cluster] = {}
|
||||||
|
for task in tasks:
|
||||||
|
pid = task.get("project_id") or task.get("project") or "default"
|
||||||
|
if pid not in buckets:
|
||||||
|
label = pid if pid != "default" else "Tasks"
|
||||||
|
buckets[pid] = Cluster(label=label)
|
||||||
|
buckets[pid].tasks.append(task)
|
||||||
|
return list(buckets.values())
|
||||||
|
|
||||||
|
|
||||||
|
def cluster_tasks(
|
||||||
|
tasks: list[dict],
|
||||||
|
ollama_url: str | None = None, # kept for test compatibility; env vars take precedence
|
||||||
|
enrichment_cache: dict[str, str] | None = None,
|
||||||
|
) -> tuple[list[Cluster], dict[str, str]]:
|
||||||
|
"""Cluster tasks by semantic similarity.
|
||||||
|
|
||||||
|
Returns (clusters, new_enrichments). new_enrichments contains LLM-generated
|
||||||
|
descriptions produced this call that were not in the persistent cache — the
|
||||||
|
caller should persist these. Falls back to project-based grouping if the
|
||||||
|
embedding service is unavailable or tasks have no content.
|
||||||
|
"""
|
||||||
|
if not tasks:
|
||||||
|
return [], {}
|
||||||
|
|
||||||
|
# Separate tasks with usable content from those without.
|
||||||
|
with_content = [(t, t.get("content", "").strip()) for t in tasks]
|
||||||
|
embeddable = [(t, c) for t, c in with_content if c]
|
||||||
|
no_content = [t for t, c in with_content if not c]
|
||||||
|
|
||||||
|
if not embeddable:
|
||||||
|
return _fallback_by_project(tasks), {}
|
||||||
|
|
||||||
|
task_objs = [t for t, _ in embeddable]
|
||||||
|
raw_titles = [c for _, c in embeddable]
|
||||||
|
|
||||||
|
# Step 1: LLM-enrich titles → richer semantic signal before embedding.
|
||||||
|
descriptions, new_enrichments = _enrich_batch(raw_titles, persistent_cache=enrichment_cache)
|
||||||
|
|
||||||
|
# Attach enriched description to each task dict so consumers (e.g. focus-area)
|
||||||
|
# can show the expanded text instead of the terse raw title.
|
||||||
|
for task, desc in zip(task_objs, descriptions):
|
||||||
|
task["enriched_description"] = desc
|
||||||
|
|
||||||
|
# Step 2: Prefix with nomic-embed-text task prefix, then batch-embed.
|
||||||
|
prefixed = [f"clustering: {d}" for d in descriptions]
|
||||||
|
vecs = _embed_batch(prefixed)
|
||||||
|
|
||||||
|
if vecs is None or len(vecs) != len(prefixed):
|
||||||
|
log.info("cluster_tasks: embedding unavailable, falling back to project grouping")
|
||||||
|
return _fallback_by_project(tasks), new_enrichments
|
||||||
|
|
||||||
|
embedded = list(zip(task_objs, vecs))
|
||||||
|
clusters = _greedy_cluster(embedded)
|
||||||
|
|
||||||
|
if no_content:
|
||||||
|
clusters.append(Cluster(label="Other tasks", tasks=no_content))
|
||||||
|
|
||||||
|
return clusters, new_enrichments
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user