Compare commits
21 Commits
7f173f88d3
...
75d0e89906
| Author | SHA1 | Date | |
|---|---|---|---|
| 75d0e89906 | |||
| d4205a00cf | |||
| d7a2423940 | |||
| bb879c5f0f | |||
| 5b52c6bf40 | |||
| 2a7380933c | |||
| e3ca3ba733 | |||
| 46dee7377e | |||
| 4c8ef9ad86 | |||
| ffdf70733f | |||
| 85367aeaa0 | |||
| faf44c18fc | |||
| c5ea18ec6e | |||
| e62c726ea4 | |||
| 2402a140e9 | |||
| c7edd92e15 | |||
| 08dfa1d8c9 | |||
| f6c890213b | |||
| 888f8b9a99 | |||
| 3123cb73fb | |||
| 65218762be |
39
.env.example
Normal file
39
.env.example
Normal file
@@ -0,0 +1,39 @@
|
||||
# Copy to .env.local and fill in values — never commit .env.local
|
||||
|
||||
# API
|
||||
SESSION_SECRET=change-me-to-a-random-32-char-string
|
||||
PORT=3078
|
||||
NODE_ENV=development
|
||||
DATABASE_PATH=./data/oo.db
|
||||
# API_BASE_URL = public origin only, no path suffix (used to build OAuth redirect URIs)
|
||||
API_BASE_URL=http://localhost:3078
|
||||
WEB_BASE_URL=http://localhost:3000
|
||||
ML_SERVING_URL=http://localhost:8000
|
||||
|
||||
# AI stack — shared Agap services (ollama + litellm + langfuse). Not run from oO.
|
||||
# Prod: https://llm.alogins.net | Dev: http://host.docker.internal:4000 from containers,
|
||||
# http://localhost:4000 from host. Ollama: http://host.docker.internal:11434 / :11434.
|
||||
LITELLM_URL=https://llm.alogins.net
|
||||
LITELLM_MASTER_KEY=sk-oo-dev
|
||||
OLLAMA_URL=http://host.docker.internal:11434
|
||||
|
||||
# Google OAuth — https://console.cloud.google.com/
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
# VAPID (Web Push) — generate: node -e "const wp=require('web-push');console.log(JSON.stringify(wp.generateVAPIDKeys()))"
|
||||
VAPID_PUBLIC_KEY=
|
||||
VAPID_PRIVATE_KEY=
|
||||
VAPID_SUBJECT=mailto:you@example.com
|
||||
|
||||
# Todoist OAuth — https://developer.todoist.com/appconsole.html
|
||||
TODOIST_CLIENT_ID=
|
||||
TODOIST_CLIENT_SECRET=
|
||||
|
||||
# Event bus — leave NATS_URL empty for in-process bus only (no JetStream bridge).
|
||||
# Set to nats://nats:4222 (compose service name) or nats://localhost:4222 (host)
|
||||
# to mirror every publish to durable JetStream streams (signals.>, feedback.>).
|
||||
# Start the broker with: docker compose --profile events up nats
|
||||
NATS_URL=
|
||||
# How often the background scheduler refreshes Todoist tasks per active user (ms).
|
||||
TODOIST_SYNC_INTERVAL_MS=900000
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -11,6 +11,7 @@ build/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
__pycache__/
|
||||
.mypy_cache/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
@@ -19,3 +20,4 @@ coverage/
|
||||
*.sqlite
|
||||
.idea/
|
||||
.vscode/
|
||||
.playwright-mcp/
|
||||
|
||||
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: .
|
||||
36
CLAUDE.md
36
CLAUDE.md
@@ -56,7 +56,7 @@ docs/ architecture notes, ADRs, API specs
|
||||
## Contracts between modules
|
||||
|
||||
- **HTTP** (OpenAPI, in `packages/shared-types/http/`) — synchronous request/response. In-process today; over the network once extracted. Signatures are identical.
|
||||
- **Events** (Protocol Buffers, in `packages/shared-types/events/`) — durable signals + feedback. Today: in-process event emitter. Tomorrow: NATS JetStream. Schema registry enforced in CI (ADR-0005).
|
||||
- **Events** (Protocol Buffers, in `packages/shared-types/events/`) — durable signals + feedback. Today: in-process `Bus` with a `onPublish` bridge to NATS JetStream when `NATS_URL` is set (ADR-0010). The in-proc bus stays the source of truth — JetStream is the durable mirror that cross-process consumers (`ml/serving`, future feature pipelines) tail. Schema registry enforced in CI when #54 lands; until then payloads are JSON envelopes (ADR-0005).
|
||||
- Do not redefine types per module. Regenerate from `shared-types`.
|
||||
|
||||
## Conventions
|
||||
@@ -65,7 +65,7 @@ docs/ architecture notes, ADRs, API specs
|
||||
- One PR = one concern. Conventional-commit prefixes (`feat:`, `fix:`, `chore:`, `docs:`, `refactor:`).
|
||||
- ADRs go in `docs/adr/NNNN-title.md` for any decision that constrains future work.
|
||||
- No secrets in repo. Local dev via `.env.local` (gitignored), prod via the server's secret store (Vaultwarden now; k8s secrets later).
|
||||
- Compose profiles (`core`, `full`) so devs can run a subset without 16 GB of RAM.
|
||||
- Compose profiles: `core` (api + web + admin), `full` (adds ml-serving), `mlops` (adds MLflow + Airflow), `ai` (adds Ollama + LiteLLM). Mix as needed.
|
||||
|
||||
## Definition of done (per feature)
|
||||
|
||||
@@ -76,15 +76,39 @@ docs/ architecture notes, ADRs, API specs
|
||||
5. Deployable via `docker compose up` locally.
|
||||
6. If it touches user data → a deletion path exists and is tested.
|
||||
|
||||
## AI stack
|
||||
|
||||
oO generates tips with an LLM and ranks them with a bandit. All LLM calls route through **LiteLLM** at `llm.alogins.net` using model aliases — swapping models is a config change, not a code change.
|
||||
|
||||
| Alias | Model | Used by |
|
||||
|-------|-------|---------|
|
||||
| `tip-generator` | qwen2.5:1.5b (default) | `ml/serving` tip generation |
|
||||
| `embedder` | nomic-embed-text | task clustering, dedup |
|
||||
| `judge` | claude-haiku-4-5 (cloud, eval only) | offline sim |
|
||||
|
||||
Env vars: `LITELLM_URL` (prod `https://llm.alogins.net`), `OLLAMA_URL` (Agap host, `http://host.docker.internal:11434` from containers).
|
||||
|
||||
Ollama and LiteLLM are **shared Agap services**, not oO services — they live in `agap_git/openai/docker-compose.yml` along with langfuse (observability). oO never starts them; ml-serving just calls the alias.
|
||||
|
||||
**LLM tip generation pipeline:**
|
||||
1. `ml/features/context.py` assembles user signals → structured prompt context
|
||||
2. `POST /generate` in `ml/serving` calls LiteLLM → returns `TipCandidate[]`
|
||||
3. Bandit policy in `ml/serving` scores + ranks candidates
|
||||
4. Best candidate returned as tip; reaction closes the online reward loop
|
||||
|
||||
## Current phase
|
||||
|
||||
**Phase 0 — Prototype.** See `README.md` for the phase roadmap and `docs/architecture/` for diagrams. Work is tracked as Gitea milestones + issues on `alvis/oO`.
|
||||
**M1 shipped. M2 (AI tips) in progress.** See `README.md` for the phase roadmap and `docs/architecture/` for diagrams. Work is tracked as Gitea milestones + issues on `alvis/oO`.
|
||||
|
||||
Active work: AI tip generation pipeline — issues #86–#93 in M2 milestone.
|
||||
|
||||
## What NOT to do
|
||||
|
||||
- Don't copy Todoist's data into our DB. Store the OAuth token + computed features/derivatives we need, fetch raw on demand.
|
||||
- Don't implement auth by hand. Phase 0 uses **Auth.js** behind an OIDC-shaped boundary (ADR-0004); swap to a dedicated OIDC provider only when mobile ships.
|
||||
- Don't hardwire a recommender. The "random todo" v0 must live behind the same interface the real ML model will implement (`POST /recommend` → `{tip}`). Swap internals, keep contract.
|
||||
- Don't implement auth by hand. Auth.js behind an OIDC-shaped boundary (ADR-0004); swap to a dedicated OIDC provider only when mobile ships.
|
||||
- Don't hardwire a recommender. The contract is `POST /recommend → {tip}`. Swap internals (bandit, LLM, hybrid), keep contract.
|
||||
- Don't replace a policy in one step. New policies deploy shadow-first; promoted only after offline + online agreement with the incumbent (ADR-0002).
|
||||
- Don't build an admin UI before the user-facing black page is polished.
|
||||
- Don't over-split processes. Extract a service when pressure demands it, not in anticipation (ADR-0003).
|
||||
- Don't call LLMs directly from application code. All LLM calls go through `ml/serving` (Python) via `LITELLM_URL`. The TS recommender never holds a model name.
|
||||
- Don't embed MLflow/Airflow/OpenWebUI in the admin panel. They are external services; link out to them. The admin shell links to `o.alogins.net/mlflow`, `/airflow`, `ai.alogins.net`.
|
||||
- Don't `nats.publish()` directly from feature code. All publishes go through the in-process `Bus` (`services/api/src/events/bus.ts`); the NATS adapter (`events/nats.ts`) bridges every publish to JetStream when `NATS_URL` is set. This keeps subscribers, the ring-buffer tail used by the admin event viewer, and JetStream all in lockstep.
|
||||
|
||||
201
README.md
201
README.md
@@ -67,55 +67,192 @@ docs/ architecture, adr, api
|
||||
|
||||
---
|
||||
|
||||
## AI stack
|
||||
|
||||
oO is AI-native: the recommender's job is to **rank**, not to write. An LLM generates candidate tips from the user's context; the bandit picks the best one.
|
||||
|
||||
### Three-tier layout
|
||||
|
||||
| Tier | Service | Purpose | Where |
|
||||
|------|---------|---------|-------|
|
||||
| Inference | **Ollama** | Local LLM + embedding; no data leaves the host | `localhost:11434` |
|
||||
| Routing | **LiteLLM** | Unified OpenAI-compatible API; model aliases; cloud fallback | `llm.alogins.net` (Agap shared) |
|
||||
| Testing | **OpenWebUI** | Prompt iteration, model comparison, manual evals | `ai.alogins.net` (Agap shared) |
|
||||
|
||||
### Tip generation pipeline (Phase 2 target)
|
||||
|
||||
```
|
||||
User signals ──▶ Context assembler ──▶ LiteLLM ──▶ Ollama (local)
|
||||
(tasks, calendar, (ml/features/) (routing) or cloud fallback
|
||||
patterns, time)
|
||||
▼
|
||||
N typed TipCandidates
|
||||
{content, kind, model,
|
||||
prompt_version, confidence}
|
||||
▼
|
||||
Bandit policy (ml/serving)
|
||||
scores + ranks candidates
|
||||
▼
|
||||
Best tip shown
|
||||
▼
|
||||
User reaction (done / snooze / dismiss + dwell)
|
||||
▼
|
||||
Online bandit update + prompt_version tracking
|
||||
```
|
||||
|
||||
**Why LiteLLM as gateway:** All LLM calls use a single `LITELLM_URL` env var. Swapping from qwen2.5 to llama3.2, or routing a fraction to Claude for A/B, is a config change in LiteLLM — zero code change in oO. The model name in `tip_scores` tells you exactly which model produced each tip.
|
||||
|
||||
**Why Ollama first:** Tips contain personal context. Local inference means no user data leaves the host for the inference path. Cloud models (Anthropic, OpenAI) are opt-in fallbacks for evaluation and simulation only, gated behind `ANTHROPIC_API_KEY`.
|
||||
|
||||
### Models (planned)
|
||||
|
||||
| Alias | Model | Task |
|
||||
|-------|-------|------|
|
||||
| `tip-generator` | qwen2.5:7b (default) | Generate typed tip candidates from user context |
|
||||
| `embedder` | nomic-embed-text | Task clustering, semantic similarity for dedup |
|
||||
| `judge` | claude-haiku-4-5 (cloud, eval-only) | Offline sim judge; rates tip quality for A/B |
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Phase 0 — Walking skeleton *(M0)*
|
||||
### Phase 0 — Walking skeleton *(M0)* ✓ shipped
|
||||
Goal: a single user signs in with Google, connects Todoist, and sees one random Todoist task on a black page. Deletion works.
|
||||
- [ ] 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
|
||||
- [x] Monorepo scaffold, docker-compose dev env
|
||||
- [x] `auth` — Google OAuth2/PKCE via openid-client v6; session cookie; Next.js middleware guard
|
||||
- [x] `integrations/todoist` — OAuth2 flow, token stored in DB, disconnect supported
|
||||
- [x] `recommender` with `RandomPolicy`; stable `POST /recommend` contract; 30s task cache
|
||||
- [x] `apps/web` — sign-in, connect, tip pages; PWA manifest + icons
|
||||
- [x] Feedback: `done / snooze / dismiss`; reward inferred from dwell-time (`inferReward`); marks task complete in Todoist
|
||||
- [x] Deploy modular monolith to Agap VM via Caddy at `o.alogins.net`
|
||||
- [x] ToS + Privacy Policy pages (`/legal/terms`, `/legal/privacy`); implicit consent on sign-in
|
||||
- [x] Account deletion: revokes tokens, purges data, soft-deletes profile; button on /connect
|
||||
- [x] Metrics baseline: `tip_views` table (tip served) + `tip_feedback` (reactions) — activation + reaction rate queryable
|
||||
|
||||
### Phase 1 — Real signal + in-the-moment delivery *(M1)*
|
||||
### Phase 1 — Real signal + in-the-moment delivery *(M1)* ✓ shipped
|
||||
Goal: tips are picked, not drawn from a hat — and they arrive at the right moment on the web.
|
||||
- [ ] 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)
|
||||
- [x] Event bus scaffold: typed in-process EventEmitter with 500-event ring buffer; subjects match future NATS JetStream — swap is mechanical
|
||||
- [x] Todoist sync emits `signals.task.synced`; tip served/feedback emit `signals.tip.*`
|
||||
- [x] Features extracted per task: `is_overdue`, `task_age_days`, `priority`; context: `hour_of_day`, `day_of_week`
|
||||
- [x] `ml/serving` LinUCB (d=5) + **ε-greedy v1** (d=7, ε=0.10, day-of-week sin/cos features); per-user state persisted to disk
|
||||
- [x] `RemotePolicy` in recommender: calls ml/serving, falls back to RandomPolicy on timeout/error; logs explainability to `tip_scores`
|
||||
- [x] Feedback loop: dwell-time inferred reward (`inferReward`) → online model update; `done` in 15 s–2 min = +1.0 (magic zone)
|
||||
- [x] Offline simulation framework (`ml/experiments/sim`): rule/LLM/claude-code judges, two-policy comparison, results persisted to `sim_runs` + `sim_events`
|
||||
- [x] **ε-greedy v1 promoted to active policy** (ADR-0007) — +10.7% mean reward vs LinUCB in offline sim
|
||||
- [x] **Web Push** (VAPID): SW, subscribe/unsubscribe API, "notify me" button on tip page
|
||||
- [x] Shadow-policy registry: run N shadow policies per request, log picks without serving them (#56)
|
||||
- [ ] Quiet-hours + dedupe for push delivery
|
||||
- [ ] Delayed rewards: tasks completed directly in Todoist (requires webhook from Todoist)
|
||||
- [x] NATS JetStream bridge — durable `signals.>` and `feedback.>` streams; in-process bus stays the source of truth, every publish bridges out (#21, shipped)
|
||||
|
||||
### Phase 2 — Multi-source profile & trust *(M2)*
|
||||
Goal: oO knows more than tasks, and users can see/control what we know.
|
||||
- [ ] Integrations: Google Calendar, Apple Health (web import), generic webhook ingress
|
||||
- [ ] Unified `Profile` model (identity, preferences, contexts, consents)
|
||||
- [ ] Timing signals (Page Visibility, Idle Detection, coarse location) — opt-in, transparent
|
||||
- [ ] Advice library + mixing policy (todo vs advice vs ambient)
|
||||
- [ ] User-facing data dashboard: what's stored, what's computed, export, delete-by-category
|
||||
- [ ] Cost/usage observability
|
||||
#### M1 add-on — Admin & ML Ops Console *(fully shipped)*
|
||||
|
||||
oO is ML-heavy. Without a cockpit, every model change ships blind. This console is the team's single pane for users, signals, features, models, experiments, and tip outcomes — with the ability to *act* on them (revoke a token, replay an event, promote a model, reset a bandit).
|
||||
|
||||
**Framework pick — `apps/admin` on Next.js 15 + Tremor + shadcn/ui.** Analytics-first UI for an analytics-first product, stays on our existing TS/React/Tailwind stack, reuses `packages/shared-types`, `sdk-js`, and the Auth.js session. Specialized ML tooling (MLflow, Airflow) runs as **separate external services** linked from the admin shell; Grafana panels are embedded.
|
||||
|
||||
| Layer | Tool | Why |
|
||||
|-------|------|-----|
|
||||
| App shell | **Next.js 15** (new `apps/admin`) | Same stack as `apps/web`; reuses auth, types, SDK |
|
||||
| Dashboards / charts | **[Tremor](https://tremor.so)** | Analytics-first React + Tailwind — KPI cards, time-series, categorical, heatmaps |
|
||||
| CRUD primitives | **[shadcn/ui](https://ui.shadcn.com)** | Copy-paste Radix components; forms, dialogs, command palette |
|
||||
| Heavy grids | **[TanStack Table v8](https://tanstack.com/table)** | Sortable / paginated / virtualized tables (events, users, tips) |
|
||||
| Extra charts | **[Recharts](https://recharts.org)** / **[visx](https://airbnb.io/visx)** | Fallbacks where Tremor falls short (e.g. force graphs, Sankey) |
|
||||
| Model registry / experiments | **[MLflow](https://mlflow.org)** *(external — `o.alogins.net/mlflow`)* | Experiment tracking, artifact browser, model registry; own basic-auth |
|
||||
| Pipeline orchestration | **[Airflow](https://airflow.apache.org)** *(external — `o.alogins.net/airflow`)* | Batch feature + retraining DAGs; own web-auth |
|
||||
| Infra metrics | **[Grafana](https://grafana.com)** *(embedded panels)* | One ops source of truth |
|
||||
| Ad-hoc analysis | **[Marimo](https://marimo.io)** reactive notebooks | Python-native for the ML side; launch-out link |
|
||||
| AuthZ | `profile.role='admin'` + Next.js middleware | Reuses existing session; no new auth surface |
|
||||
|
||||
**Rejected alternatives (so we don't re-litigate):**
|
||||
- *Retool / AppSmith* — low-code speed, but admin logic leaves our repo; weak analytics affordances for an analytics product
|
||||
- *Streamlit / Gradio / Dash* — Python-first; thin RBAC and routing; splits our frontend stack in two
|
||||
- *React-admin / Refine.dev* — strong CRUD scaffolding, but analytics/ML views feel bolted on; we'd rebuild Tremor-style dashboards ourselves
|
||||
- *Superset / Metabase as the admin surface* — excellent for BI, poor for operational **writes** (revoke, replay, promote). Plan: **adopt Superset in M4** for BI alongside batch pipelines; ship a read-only SQL widget inside admin for now
|
||||
|
||||
**Build sequence (plan, not code):**
|
||||
1. [x] **ADR-0006** — record the framework choice + "embed, don't rebuild" rule for MLflow/Grafana
|
||||
2. [x] **Scaffold** — `apps/admin` with Next.js 15, Tailwind, Tremor; deploy behind Caddy at `admin.o.alogins.net`
|
||||
3. [x] **RBAC** — `role` column on `users`; admin-only Next.js middleware; seed first admin via `ADMIN_SEED_EMAIL` env; `admin_actions` audit-log table
|
||||
4. [x] **Overview dashboard** — DAU/WAU KPI cards, tips served, reaction breakdown, activation funnel
|
||||
5. [x] **User explorer** — list + detail page: identity, consents, integrations, last tip, reward history; revoke-integration + reset-bandit actions
|
||||
6. [x] **Event stream viewer** — live tail of `signals.*` with filters by subject/user/time; same UI when the bus swaps to NATS
|
||||
7. [x] **Feature store browser** — features sent to `ml/serving` per scoring call; diff across time for a user
|
||||
8. [x] **Model registry panel** — `/admin/models` links out to MLflow (`mlflow.o.alogins.net`); experiment tracking and dataset management in MLflow + Airflow
|
||||
9. [x] **MLOps hub** — `/admin/experiments` links to MLflow experiments/models and Airflow DAGs/datasets; bandit reset on Users page
|
||||
10. [x] **Recommendation log (explainability)** — per served tip: `(user, features, policy, score, feedback, latency)`; `tip_scores` table, 30-day retention
|
||||
11. [x] **Reward analytics** — reaction distribution over time; per-policy compare; slice by `hour_of_day`, `priority`, cohort
|
||||
12. [x] **Data quality widget** — missing-feature rate, stale-token rate, daily completeness heatmap
|
||||
13. [x] **Ops actions** — revoke token (Users page), replay signal, disable/promote shadow policy; every action audit-logged
|
||||
14. [x] **Read-only SQL runner** — SELECT-only runner against SQLite + saved queries (sunsets to Superset in M4)
|
||||
15. [x] **Health rollup** — `/admin/health` surfaces api, ml/serving, SQLite, event-bus; auto-refreshes every 15s
|
||||
16. [ ] **Docs** — `apps/admin/README.md`, runbook for common ops actions, ADR-0006 merged
|
||||
|
||||
- [ ] Apple OAuth (deferred to M2)
|
||||
|
||||
### Phase 2 — AI tips + multi-source signals *(M2)*
|
||||
Goal: tips are AI-generated from user context, not just raw Todoist tasks. Multiple signal sources feed a generalized pipeline. Research-intensive milestone.
|
||||
|
||||
**AI infrastructure (unblock everything else):**
|
||||
- [ ] `ai` compose profile — Ollama + LiteLLM for local dev; env vars `OLLAMA_URL` / `LITELLM_URL` (#86)
|
||||
- [ ] AI gateway — wire `ml/serving` to LiteLLM; model aliases `tip-generator` + `embedder` (#87)
|
||||
|
||||
**AI tip generation pipeline:**
|
||||
- [ ] Context assembler — user signals + feature store → structured prompt context (`ml/features/context.py`) (#88)
|
||||
- [ ] Tip generator endpoint — `POST /generate` in `ml/serving`; LLM → N typed `TipCandidate` objects (#79)
|
||||
- [ ] `TipCandidate` shared schema — `{content, kind, source, model, prompt_version, confidence}`; update recommender pipeline (#89)
|
||||
- [ ] LLM output validation + retry — JSON schema gate, clarification retry (2×), fallback to task-based (#90)
|
||||
- [ ] Prompt versioning — `prompt_version` + `model` columns in `tip_scores`; content-hash invalidation (#91)
|
||||
- [ ] LLM tip quality dashboard — reaction breakdown by model / prompt_version in `/admin/reward-analytics` (#92)
|
||||
|
||||
**Evaluation & model selection:**
|
||||
- [ ] Model benchmark — compare qwen2.5:7b / llama3.2:3b / gemma3:4b via offline sim + LLM judge (#93)
|
||||
- [ ] LLM prompt research — persona design, context injection strategies, few-shot examples (#84)
|
||||
|
||||
**Pipeline architecture:**
|
||||
- [ ] Signal source abstraction — `SignalSource` interface generalizing beyond Todoist (#78)
|
||||
- [ ] Generalized recommendation pipeline — candidate → rank → render stages (#80)
|
||||
- [ ] Feature registry + user profile builder — centralized features, persistent profiles (#81)
|
||||
- [ ] Tip kind system — task, advice, insight, reminder with kind-aware UI + rewards (#82)
|
||||
|
||||
**Policy research:**
|
||||
- [ ] Next-gen policies — Thompson sampling, neural bandits, hybrid transfer learning (#83)
|
||||
|
||||
**Integrations & infra (carried from M1):**
|
||||
- [ ] Apple OAuth (#7)
|
||||
- [x] NATS JetStream replacing in-process bus (#21) — adapter ships in `services/api/src/events/nats.ts`; in-proc bus is the producer, JetStream is the durable mirror
|
||||
- [x] Todoist sync via events (#22) — background scheduler in `services/api/src/signals/scheduler.ts` emits `signals.task.synced` every `TODOIST_SYNC_INTERVAL_MS`; on-demand fetch remains as freshness fallback
|
||||
- [ ] Event schema registry + protobuf CI gate (#54)
|
||||
- [ ] Per-user freshness SLAs for features (#61)
|
||||
- [ ] CI skeleton (#3), observability (#18), E2E tests (#20)
|
||||
|
||||
**Bugs (fix before new features):**
|
||||
- [ ] TipFeedback type mismatch (#73)
|
||||
- [ ] Todoist token refresh (#74)
|
||||
- [ ] Reward fire-and-forget (#75)
|
||||
- [ ] Data retention purge (#76)
|
||||
- [ ] Port mismatch (#77)
|
||||
|
||||
### Phase 3 — Native mobile *(M3)*
|
||||
- [ ] iOS app (SwiftUI) with APNs push
|
||||
- [ ] Android app (Compose) with FCM push
|
||||
- [ ] `notifier` gains APNs + FCM channels, per-device rate limits
|
||||
- [ ] Migrate auth from Auth.js to dedicated OIDC provider (trigger from ADR-0004)
|
||||
- [ ] Consolidate MLflow + Airflow behind shared OIDC (SSO for all internal services)
|
||||
- [ ] Decide-and-deliver scheduler: per-user "is this tip worth interrupting now?" threshold
|
||||
|
||||
### Phase 4 — MLOps at scale *(M4)*
|
||||
- [ ] Prefect/Airflow for batch feature materialization + retraining
|
||||
- [ ] MLflow registry; shadow → A/B → launch pipeline as first-class
|
||||
- [x] Airflow + MLflow deployed as external services (`mlops` compose profile); each with own auth
|
||||
- [ ] Write first retraining DAG (Airflow) + first MLflow experiment logging from `ml/serving`
|
||||
- [ ] Feature-to-prompt pipeline — nightly Airflow DAG materializes context for LLM; cuts inline latency (#94)
|
||||
- [ ] Prompt optimization loop — sim A/B → MLflow experiment → human-approved promotion (#95)
|
||||
- [ ] LLM fine-tuning — tip reactions as training signal; LoRA on base model; MLflow tracks runs (#96)
|
||||
- [ ] Embedding-based task clustering — `nomic-embed-text` for dedup + user pattern features (#97)
|
||||
- [ ] Consolidate MLflow + Airflow auth into shared OIDC provider (tracked as M3 issue #85)
|
||||
- [ ] Shadow → A/B → launch pipeline as first-class in MLflow
|
||||
- [ ] Online experiments framework: deterministic assignment + bandit policies alongside fixed-split A/B
|
||||
- [ ] Cross-user collaborative features (opt-in only); cohort slicing; fairness checks
|
||||
- [ ] Drift monitoring (feature drift, prediction drift, reward drift); model cards per version
|
||||
- [ ] Drift monitoring (feature + prediction + reward drift); model cards per LLM version
|
||||
|
||||
### Phase 5 — Production hardening *(M5)*
|
||||
- [ ] Audit logging, rotation of provider tokens + internal signing keys
|
||||
|
||||
37
apps/admin/README.md
Normal file
37
apps/admin/README.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# apps/admin — oO Admin Console
|
||||
|
||||
Next.js 15 app. Deployed at `admin.o.alogins.net` (dev: `http://localhost:3080`).
|
||||
|
||||
## Contract
|
||||
|
||||
- All routes are admin-only. The Next.js middleware calls `GET /api/user/me` on every request
|
||||
and checks `role === 'admin'`. First admin is seeded via `ADMIN_SEED_EMAIL` env var at API startup.
|
||||
- Admin write actions are appended to the `admin_actions` audit log in the DB.
|
||||
|
||||
## Pages
|
||||
|
||||
| Route | Description |
|
||||
|-------|-------------|
|
||||
| `/` | Overview: DAU/WAU KPI cards, tips served, reaction breakdown, activation funnel |
|
||||
| `/users` | User list (paginated) |
|
||||
| `/users/:id` | User detail: identity, consents, integrations, tip stats, reward history; revoke-integration + reset-bandit actions |
|
||||
| `/audit` | Admin action audit log |
|
||||
| `/events` | Event stream viewer (stub — pending API history endpoint) |
|
||||
|
||||
## Dev
|
||||
|
||||
```bash
|
||||
pnpm --filter @oo/admin dev # starts on :3080
|
||||
# also run the API: pnpm --filter @oo/api dev (port 3078)
|
||||
```
|
||||
|
||||
## Extraction criteria
|
||||
|
||||
Stays as a Next.js app in the monorepo permanently — it's not a candidate for extraction.
|
||||
It gets richer (more pages, embedded MLflow/Grafana) but not split.
|
||||
|
||||
## Known issues
|
||||
|
||||
- `@tremor/react 3.x` declares a peer dep on React 18; the workspace uses React 19.
|
||||
Works in practice. Will resolve naturally when Tremor ships React 19 support or when
|
||||
we switch to Tremor v4 (which targets React 18+).
|
||||
18
apps/admin/next.config.ts
Normal file
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>
|
||||
);
|
||||
}
|
||||
89
apps/admin/src/app/data-quality/page.tsx
Normal file
89
apps/admin/src/app/data-quality/page.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AdminShell } from '@/components/AdminShell';
|
||||
import { getDataQuality } from '@/lib/api';
|
||||
|
||||
function Pct({ value }: { value: number }) {
|
||||
const pct = (value * 100).toFixed(1);
|
||||
const color = value < 0.05 ? 'text-green-400' : value < 0.2 ? 'text-yellow-400' : 'text-red-400';
|
||||
return <span className={color}>{pct}%</span>;
|
||||
}
|
||||
|
||||
export default function DataQualityPage() {
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getDataQuality>> | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
getDataQuality()
|
||||
.then(setData)
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AdminShell>
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-xl font-semibold">Data quality</h1>
|
||||
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||
{loading && <p className="text-gray-500 text-sm">Loading…</p>}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<div className="bg-gray-900 border border-gray-800 rounded p-4">
|
||||
<div className="text-xs text-gray-500 mb-1">Scoring calls (30d)</div>
|
||||
<div className="text-2xl font-semibold">{data.scoringCallsLast30d}</div>
|
||||
</div>
|
||||
<div className="bg-gray-900 border border-gray-800 rounded p-4">
|
||||
<div className="text-xs text-gray-500 mb-1">Missing feature rate</div>
|
||||
<div className="text-2xl font-semibold"><Pct value={data.missingFeatureRate} /></div>
|
||||
</div>
|
||||
<div className="bg-gray-900 border border-gray-800 rounded p-4">
|
||||
<div className="text-xs text-gray-500 mb-1">Integration tokens</div>
|
||||
<div className="text-2xl font-semibold">{data.totalTokens}</div>
|
||||
</div>
|
||||
<div className="bg-gray-900 border border-gray-800 rounded p-4">
|
||||
<div className="text-xs text-gray-500 mb-1">Stale token rate (>7d)</div>
|
||||
<div className="text-2xl font-semibold"><Pct value={data.staleTokenRate} /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-sm font-medium text-gray-400">Daily feature completeness (14d)</h2>
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-800 text-gray-500 text-left">
|
||||
<th className="py-2 pr-4">Date</th>
|
||||
<th className="py-2 pr-4">Scoring calls</th>
|
||||
<th className="py-2 pr-4">With features</th>
|
||||
<th className="py-2 pr-4">Coverage</th>
|
||||
<th className="py-2">Avg candidates</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.dailyQuality.map((row) => {
|
||||
const coverage = row.total > 0 ? row.withFeatures / row.total : 0;
|
||||
return (
|
||||
<tr key={row.date} className="border-b border-gray-800/50">
|
||||
<td className="py-1.5 pr-4 font-mono text-gray-500">{row.date}</td>
|
||||
<td className="py-1.5 pr-4 text-gray-300">{row.total}</td>
|
||||
<td className="py-1.5 pr-4 text-gray-300">{row.withFeatures}</td>
|
||||
<td className="py-1.5 pr-4"><Pct value={coverage} /></td>
|
||||
<td className="py-1.5 text-gray-300">{row.avgCandidates?.toFixed(1) ?? '—'}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{data.dailyQuality.length === 0 && (
|
||||
<tr><td colSpan={5} className="py-4 text-center text-gray-600">No data yet</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
16
apps/admin/src/app/login/page.tsx
Normal file
16
apps/admin/src/app/login/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-center space-y-4">
|
||||
<h1 className="text-2xl font-semibold">oO Admin</h1>
|
||||
<p className="text-gray-400 text-sm">Sign in via the main app first, then return here.</p>
|
||||
<a
|
||||
href="/sign-in"
|
||||
className="inline-block px-4 py-2 bg-white text-black rounded text-sm font-medium hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Sign in with Google
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
apps/admin/src/app/ops/page.tsx
Normal file
114
apps/admin/src/app/ops/page.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AdminShell } from '@/components/AdminShell';
|
||||
import { getPolicies, togglePolicy, replaySignal, PolicyInfo } from '@/lib/api';
|
||||
|
||||
const VALID_SUBJECTS = ['signals.tip.served', 'signals.tip.feedback', 'signals.task.synced'];
|
||||
|
||||
export default function OpsPage() {
|
||||
const [policies, setPolicies] = useState<PolicyInfo[]>([]);
|
||||
const [replaySubject, setReplaySubject] = useState(VALID_SUBJECTS[0]);
|
||||
const [replayPayload, setReplayPayload] = useState('{\n "userId": "",\n "tipId": ""\n}');
|
||||
const [msg, setMsg] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
getPolicies().then((r) => setPolicies(r.policies)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleToggle = async (name: string, active: boolean) => {
|
||||
try {
|
||||
await togglePolicy(name, active);
|
||||
setPolicies((prev) => prev.map((p) => p.name === name ? { ...p, active } : p));
|
||||
setMsg(`Policy "${name}" ${active ? 'enabled' : 'disabled'}.`);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReplay = async () => {
|
||||
let payload: Record<string, unknown>;
|
||||
try {
|
||||
payload = JSON.parse(replayPayload);
|
||||
} catch {
|
||||
setError('Invalid JSON payload');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await replaySignal(replaySubject, payload);
|
||||
setMsg(`Signal replayed: ${replaySubject}`);
|
||||
setError('');
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminShell>
|
||||
<div className="space-y-8">
|
||||
<h1 className="text-xl font-semibold">Ops actions</h1>
|
||||
{msg && <p className="text-green-400 text-sm">{msg}</p>}
|
||||
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||
|
||||
{/* Policy toggles */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-base font-medium text-gray-300">Policies</h2>
|
||||
{policies.length === 0 ? (
|
||||
<p className="text-gray-500 text-sm">No shadow policies registered. Shadow policies can be added to the recommender source.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{policies.map((p) => (
|
||||
<div key={p.name} className="flex items-center justify-between bg-gray-900 border border-gray-800 rounded p-3">
|
||||
<span className="text-sm text-gray-300 font-mono">{p.name}</span>
|
||||
<button
|
||||
onClick={() => handleToggle(p.name, !p.active)}
|
||||
className={`px-3 py-1 rounded text-xs ${p.active ? 'bg-green-800 text-green-200' : 'bg-gray-800 text-gray-400'}`}
|
||||
>
|
||||
{p.active ? 'Active' : 'Disabled'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Replay signal */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-base font-medium text-gray-300">Replay signal</h2>
|
||||
<p className="text-sm text-gray-500">Re-emit a past event on the in-process bus. Useful for backfill and testing.</p>
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
value={replaySubject}
|
||||
onChange={(e) => setReplaySubject(e.target.value)}
|
||||
className="bg-gray-900 border border-gray-700 rounded px-3 py-1.5 text-sm text-gray-300 w-full max-w-sm"
|
||||
>
|
||||
{VALID_SUBJECTS.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<textarea
|
||||
value={replayPayload}
|
||||
onChange={(e) => setReplayPayload(e.target.value)}
|
||||
rows={6}
|
||||
className="w-full max-w-xl bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm font-mono text-gray-300"
|
||||
/>
|
||||
<button
|
||||
onClick={handleReplay}
|
||||
className="bg-indigo-600 hover:bg-indigo-500 text-white rounded px-4 py-1.5 text-sm"
|
||||
>
|
||||
Replay
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* User-level ops */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-base font-medium text-gray-300">User-level actions</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
Revoke integration tokens and reset bandit state are available on the{' '}
|
||||
<a href="/users" className="text-indigo-400 hover:underline">Users page</a> — navigate to a user detail view.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
144
apps/admin/src/app/reward-analytics/page.tsx
Normal file
144
apps/admin/src/app/reward-analytics/page.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AdminShell } from '@/components/AdminShell';
|
||||
import { getRewardAnalytics } from '@/lib/api';
|
||||
|
||||
const ACTION_COLORS: Record<string, string> = {
|
||||
done: 'bg-green-500',
|
||||
helpful: 'bg-teal-500',
|
||||
snooze: 'bg-yellow-500',
|
||||
not_helpful: 'bg-orange-500',
|
||||
dismiss: 'bg-red-500',
|
||||
};
|
||||
|
||||
export default function RewardAnalyticsPage() {
|
||||
const [days, setDays] = useState(30);
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getRewardAnalytics>> | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
getRewardAnalytics(days)
|
||||
.then(setData)
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [days]);
|
||||
|
||||
// Aggregate totals per action across all days
|
||||
const totals: Record<string, number> = {};
|
||||
for (const row of data?.daily ?? []) {
|
||||
totals[row.action] = (totals[row.action] ?? 0) + Number(row.count);
|
||||
}
|
||||
const grandTotal = Object.values(totals).reduce((a, b) => a + b, 0);
|
||||
|
||||
// Aggregate per policy
|
||||
const policyMap: Record<string, Record<string, number>> = {};
|
||||
for (const row of data?.byPolicy ?? []) {
|
||||
if (!row.policy) continue;
|
||||
policyMap[row.policy] ??= {};
|
||||
if (row.action) policyMap[row.policy][row.action] = (policyMap[row.policy][row.action] ?? 0) + Number(row.count);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminShell>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-xl font-semibold">Reward analytics</h1>
|
||||
<select value={days} onChange={(e) => setDays(Number(e.target.value))} className="bg-gray-900 border border-gray-700 rounded px-2 py-1 text-sm text-gray-300">
|
||||
<option value={7}>Last 7 days</option>
|
||||
<option value={30}>Last 30 days</option>
|
||||
<option value={90}>Last 90 days</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||
{loading && <p className="text-gray-500 text-sm">Loading…</p>}
|
||||
|
||||
{/* Reaction breakdown bar */}
|
||||
{grandTotal > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-sm font-medium text-gray-400">Reaction distribution ({grandTotal} total)</h2>
|
||||
<div className="flex rounded overflow-hidden h-6">
|
||||
{Object.entries(totals).map(([action, count]) => (
|
||||
<div
|
||||
key={action}
|
||||
title={`${action}: ${count} (${((count / grandTotal) * 100).toFixed(1)}%)`}
|
||||
className={`${ACTION_COLORS[action] ?? 'bg-gray-500'} transition-all`}
|
||||
style={{ width: `${(count / grandTotal) * 100}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3 text-xs text-gray-400">
|
||||
{Object.entries(totals).map(([action, count]) => (
|
||||
<span key={action} className="flex items-center gap-1">
|
||||
<span className={`inline-block w-2 h-2 rounded-full ${ACTION_COLORS[action] ?? 'bg-gray-500'}`} />
|
||||
{action}: {count} ({((count / grandTotal) * 100).toFixed(1)}%)
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Per-policy table */}
|
||||
{Object.keys(policyMap).length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-sm font-medium text-gray-400">Per-policy reactions</h2>
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-800 text-gray-500 text-left">
|
||||
<th className="py-2 pr-4">Policy</th>
|
||||
{['done', 'helpful', 'snooze', 'not_helpful', 'dismiss'].map((a) => (
|
||||
<th key={a} className="py-2 pr-4">{a}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(policyMap).map(([policy, actions]) => (
|
||||
<tr key={policy} className="border-b border-gray-800/50">
|
||||
<td className="py-2 pr-4 font-medium text-indigo-300">{policy}</td>
|
||||
{['done', 'helpful', 'snooze', 'not_helpful', 'dismiss'].map((a) => (
|
||||
<td key={a} className="py-2 pr-4 text-gray-300">{actions[a] ?? 0}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Daily table */}
|
||||
{(data?.daily?.length ?? 0) > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-sm font-medium text-gray-400">Daily breakdown</h2>
|
||||
<div className="overflow-x-auto max-h-80">
|
||||
<table className="w-full text-xs font-mono">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-800 text-gray-500 text-left">
|
||||
<th className="py-1.5 pr-4">Date</th>
|
||||
<th className="py-1.5 pr-4">Action</th>
|
||||
<th className="py-1.5">Count</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data!.daily.map((row, i) => (
|
||||
<tr key={i} className="border-b border-gray-800/40">
|
||||
<td className="py-1 pr-4 text-gray-500">{row.date}</td>
|
||||
<td className="py-1 pr-4 text-gray-300">{row.action}</td>
|
||||
<td className="py-1 text-gray-300">{row.count}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && grandTotal === 0 && (
|
||||
<p className="text-gray-500 text-sm">No reaction data in this period.</p>
|
||||
)}
|
||||
</div>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
118
apps/admin/src/components/AdminShell.tsx
Normal file
118
apps/admin/src/components/AdminShell.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
const mlflowUrl = process.env.NEXT_PUBLIC_MLFLOW_URL ?? '/mlflow';
|
||||
const airflowUrl = process.env.NEXT_PUBLIC_AIRFLOW_URL ?? '/airflow';
|
||||
|
||||
type NavItem = {
|
||||
href: string;
|
||||
label: string;
|
||||
external?: boolean;
|
||||
};
|
||||
|
||||
type NavSection = {
|
||||
label?: string;
|
||||
items: NavItem[];
|
||||
};
|
||||
|
||||
const NAV: NavSection[] = [
|
||||
{
|
||||
items: [{ href: '/', label: 'Overview' }],
|
||||
},
|
||||
{
|
||||
label: 'Signals',
|
||||
items: [
|
||||
{ href: '/users', label: 'Users' },
|
||||
{ href: '/events', label: 'Events' },
|
||||
{ href: '/features', label: 'Features' },
|
||||
{ href: '/data-quality', label: 'Data quality' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Recommender status',
|
||||
items: [
|
||||
{ href: '/tips', label: 'Tips' },
|
||||
{ href: '/reward-analytics', label: 'Rewards' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Operations',
|
||||
items: [
|
||||
{ href: '/health', label: 'Health' },
|
||||
{ href: '/ops', label: 'Ops' },
|
||||
{ href: '/sql', label: 'SQL runner' },
|
||||
{ href: '/audit', label: 'Audit log' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Resources',
|
||||
items: [
|
||||
{ href: '/docs', label: 'Docs' },
|
||||
{ href: mlflowUrl, label: 'MLflow ↗', external: true },
|
||||
{ href: airflowUrl, label: 'Airflow ↗', external: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function AdminShell({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-52 flex-shrink-0 border-r border-gray-800 bg-gray-950 flex flex-col">
|
||||
<div className="px-5 py-4 border-b border-gray-800">
|
||||
<span className="text-lg font-bold tracking-tight">oO</span>
|
||||
<span className="ml-2 text-xs text-gray-500 font-medium uppercase tracking-widest">
|
||||
Admin
|
||||
</span>
|
||||
</div>
|
||||
<nav className="flex-1 px-2 py-3 overflow-y-auto">
|
||||
{NAV.map((section, sectionIdx) => (
|
||||
<div key={section.label ?? `top-${sectionIdx}`} className={sectionIdx === 0 ? '' : 'pt-3'}>
|
||||
{section.label && (
|
||||
<div className="pb-1 px-3">
|
||||
<span className="text-xs text-gray-600 uppercase tracking-wider font-medium">
|
||||
{section.label}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-0.5">
|
||||
{section.items.map((item) => {
|
||||
const active =
|
||||
!item.external &&
|
||||
(item.href === '/' ? pathname === '/' : pathname.startsWith(item.href));
|
||||
const className = `flex items-center px-3 py-2 rounded text-sm transition-colors ${
|
||||
active
|
||||
? 'bg-gray-800 text-white font-medium'
|
||||
: item.external
|
||||
? 'text-gray-500 hover:text-white hover:bg-gray-900'
|
||||
: 'text-gray-400 hover:text-white hover:bg-gray-900'
|
||||
}`;
|
||||
return item.external ? (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={className}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
) : (
|
||||
<Link key={item.href} href={item.href} className={className}>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-auto p-6">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
159
apps/admin/src/components/UserDetail.tsx
Normal file
159
apps/admin/src/components/UserDetail.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getUserDetail, revokeIntegration, resetBandit, type AdminUserDetail } from '@/lib/api';
|
||||
|
||||
export function UserDetail({ userId }: { userId: string }) {
|
||||
const [data, setData] = useState<AdminUserDetail | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null); // which action is running
|
||||
|
||||
useEffect(() => {
|
||||
getUserDetail(userId)
|
||||
.then(setData)
|
||||
.catch((e) => setError(String(e.message)));
|
||||
}, [userId]);
|
||||
|
||||
async function handleRevoke(provider: string) {
|
||||
if (!confirm(`Revoke ${provider} for this user?`)) return;
|
||||
setBusy(`revoke:${provider}`);
|
||||
try {
|
||||
await revokeIntegration(userId, provider);
|
||||
setData((d) =>
|
||||
d
|
||||
? { ...d, integrations: d.integrations.filter((i) => i.provider !== provider) }
|
||||
: d,
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
alert(`Failed: ${(e as Error).message}`);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResetBandit() {
|
||||
if (!confirm("Reset this user's LinUCB model? Their personalization will start over.")) return;
|
||||
setBusy('bandit');
|
||||
try {
|
||||
await resetBandit(userId);
|
||||
alert('Bandit reset.');
|
||||
} catch (e: unknown) {
|
||||
alert(`Failed: ${(e as Error).message}`);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (error) return <p className="text-red-400 text-sm">Error: {error}</p>;
|
||||
if (!data) return <p className="text-gray-500 text-sm">Loading…</p>;
|
||||
|
||||
const { user, integrations, tipsServed, lastTipAt, recentFeedback } = data;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">{user.name ?? user.email}</h1>
|
||||
<p className="text-sm text-gray-400 mt-0.5">{user.email}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleResetBandit}
|
||||
disabled={busy === 'bandit'}
|
||||
className="px-3 py-1.5 text-xs rounded border border-gray-700 hover:border-red-600 hover:text-red-400 transition-colors disabled:opacity-40"
|
||||
>
|
||||
{busy === 'bandit' ? 'Resetting…' : 'Reset bandit'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Identity */}
|
||||
<Section title="Identity">
|
||||
<Row label="ID" value={user.id} mono />
|
||||
<Row label="Role" value={user.role} />
|
||||
<Row label="Consent" value={user.consentGiven ? `yes (${user.consentAt?.slice(0, 10)})` : 'no'} />
|
||||
<Row label="Joined" value={user.createdAt.slice(0, 10)} />
|
||||
{user.deletedAt && <Row label="Deleted" value={user.deletedAt.slice(0, 10)} />}
|
||||
</Section>
|
||||
|
||||
{/* Integrations */}
|
||||
<Section title="Integrations">
|
||||
{integrations.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">No integrations connected.</p>
|
||||
) : (
|
||||
integrations.map((i) => (
|
||||
<div key={i.provider} className="flex items-center justify-between py-1">
|
||||
<div>
|
||||
<span className="text-sm capitalize">{i.provider}</span>
|
||||
<span className="ml-2 text-xs text-gray-500">
|
||||
connected {i.connectedAt.slice(0, 10)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRevoke(i.provider)}
|
||||
disabled={busy === `revoke:${i.provider}`}
|
||||
className="text-xs text-red-500 hover:text-red-400 transition-colors disabled:opacity-40"
|
||||
>
|
||||
{busy === `revoke:${i.provider}` ? 'Revoking…' : 'Revoke'}
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Tip stats */}
|
||||
<Section title="Tip activity">
|
||||
<Row label="Tips served (all time)" value={String(tipsServed)} />
|
||||
<Row label="Last tip" value={lastTipAt?.slice(0, 19).replace('T', ' ') ?? '—'} />
|
||||
</Section>
|
||||
|
||||
{/* Feedback history */}
|
||||
<Section title="Recent feedback">
|
||||
{recentFeedback.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">No feedback recorded.</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{recentFeedback.map((f) => (
|
||||
<div key={f.id} className="flex items-center gap-4 text-sm">
|
||||
<span
|
||||
className={`w-16 text-xs font-medium ${
|
||||
f.action === 'done'
|
||||
? 'text-emerald-400'
|
||||
: f.action === 'snooze'
|
||||
? 'text-yellow-400'
|
||||
: 'text-red-400'
|
||||
}`}
|
||||
>
|
||||
{f.action}
|
||||
</span>
|
||||
<span className="text-gray-500 text-xs tabular-nums">
|
||||
{f.createdAt.slice(0, 19).replace('T', ' ')}
|
||||
</span>
|
||||
<span className="text-gray-600 text-xs font-mono truncate">{f.tipId}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-800 bg-gray-900 px-5 py-4 space-y-2">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-widest font-medium mb-3">{title}</p>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-baseline gap-3 text-sm">
|
||||
<span className="w-36 flex-shrink-0 text-gray-500">{label}</span>
|
||||
<span className={mono ? 'font-mono text-xs text-gray-300' : 'text-gray-200'}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
134
apps/admin/src/components/UsersTable.tsx
Normal file
134
apps/admin/src/components/UsersTable.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { getUsers, type AdminUser } from '@/lib/api';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
export function UsersTable() {
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
getUsers(PAGE_SIZE, offset)
|
||||
.then(({ users, total }) => {
|
||||
setUsers(users);
|
||||
setTotal(total);
|
||||
})
|
||||
.catch((e) => setError(String(e.message)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [offset]);
|
||||
|
||||
if (error) return <p className="text-red-400 text-sm">Error: {error}</p>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">Users</h1>
|
||||
<span className="text-sm text-gray-500">{total} total</span>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-800 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-900 border-b border-gray-800">
|
||||
<tr>
|
||||
{['Email', 'Name', 'Role', 'Consent', 'Joined', 'Status'].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
className="text-left px-4 py-2.5 text-xs text-gray-500 font-medium uppercase tracking-wide"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-800">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-6 text-center text-gray-500">
|
||||
Loading…
|
||||
</td>
|
||||
</tr>
|
||||
) : users.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-6 text-center text-gray-500">
|
||||
No users yet.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
users.map((u) => (
|
||||
<tr
|
||||
key={u.id}
|
||||
className="hover:bg-gray-900 transition-colors cursor-pointer"
|
||||
>
|
||||
<td className="px-4 py-2.5">
|
||||
<Link href={`/users/${u.id}`} className="hover:underline text-indigo-400">
|
||||
{u.email}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-300">{u.name ?? '—'}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span
|
||||
className={`px-1.5 py-0.5 rounded text-xs font-medium ${
|
||||
u.role === 'admin'
|
||||
? 'bg-indigo-900 text-indigo-300'
|
||||
: 'bg-gray-800 text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{u.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{u.consentGiven ? (
|
||||
<span className="text-emerald-400 text-xs">yes</span>
|
||||
) : (
|
||||
<span className="text-gray-600 text-xs">no</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-400 text-xs tabular-nums">
|
||||
{u.createdAt.slice(0, 10)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{u.deletedAt ? (
|
||||
<span className="text-red-500 text-xs">deleted</span>
|
||||
) : (
|
||||
<span className="text-emerald-500 text-xs">active</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{total > PAGE_SIZE && (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<button
|
||||
disabled={offset === 0}
|
||||
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
|
||||
className="px-3 py-1.5 rounded border border-gray-700 disabled:opacity-30 hover:border-gray-500 transition-colors"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-gray-500">
|
||||
{offset + 1}–{Math.min(offset + PAGE_SIZE, total)} of {total}
|
||||
</span>
|
||||
<button
|
||||
disabled={offset + PAGE_SIZE >= total}
|
||||
onClick={() => setOffset(offset + PAGE_SIZE)}
|
||||
className="px-3 py-1.5 rounded border border-gray-700 disabled:opacity-30 hover:border-gray-500 transition-colors"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
222
apps/admin/src/lib/api.ts
Normal file
222
apps/admin/src/lib/api.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
const API = '/api';
|
||||
|
||||
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${API}${path}`, {
|
||||
credentials: 'include',
|
||||
...init,
|
||||
headers: { 'Content-Type': 'application/json', ...init?.headers },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw Object.assign(new Error(err.error ?? 'API error'), { status: res.status });
|
||||
}
|
||||
return res.json() as T;
|
||||
}
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AdminStats {
|
||||
dau: number;
|
||||
wau: number;
|
||||
tipsServedLast7d: number;
|
||||
reactionsLast7d: Record<string, number>;
|
||||
totalUsers: number;
|
||||
activatedUsers: number;
|
||||
}
|
||||
|
||||
export interface AdminUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
image: string | null;
|
||||
role: string;
|
||||
consentGiven: boolean;
|
||||
consentAt: string | null;
|
||||
createdAt: string;
|
||||
deletedAt: string | null;
|
||||
}
|
||||
|
||||
export interface AdminUserDetail {
|
||||
user: AdminUser;
|
||||
integrations: { provider: string; connectedAt: string }[];
|
||||
tipsServed: number;
|
||||
lastTipAt: string | null;
|
||||
recentFeedback: { id: string; action: string; createdAt: string; tipId: string }[];
|
||||
}
|
||||
|
||||
export interface AuditAction {
|
||||
id: string;
|
||||
adminId: string;
|
||||
action: string;
|
||||
targetType: string | null;
|
||||
targetId: string | null;
|
||||
detail: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface StoredEvent {
|
||||
id: number;
|
||||
subject: string;
|
||||
payload: unknown;
|
||||
ts: string;
|
||||
}
|
||||
|
||||
export interface TipScore {
|
||||
id: string;
|
||||
userId: string;
|
||||
tipId: string;
|
||||
policy: string;
|
||||
mlScore: number | null;
|
||||
featuresJson: string | null;
|
||||
candidateCount: number | null;
|
||||
latencyMs: number | null;
|
||||
servedAt: string;
|
||||
}
|
||||
|
||||
export interface HealthStatus {
|
||||
ok: boolean;
|
||||
checkedAt: string;
|
||||
services: { name: string; status: string; latencyMs: number }[];
|
||||
}
|
||||
|
||||
export interface PolicyInfo {
|
||||
name: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface SavedQuery {
|
||||
id: string;
|
||||
name: string;
|
||||
sql: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface BanditStats {
|
||||
user_id: string;
|
||||
pulls: number;
|
||||
reward_count: number;
|
||||
cumulative_reward: number;
|
||||
estimated_mean_reward: number;
|
||||
theta: number[];
|
||||
last_updated: string | null;
|
||||
}
|
||||
|
||||
export interface FeatureHistory {
|
||||
user_id: string;
|
||||
history: { ts: string; features: Record<string, unknown>; score: number; tip_id: string }[];
|
||||
}
|
||||
|
||||
// ── Fetchers ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function getStats() {
|
||||
return apiFetch<AdminStats>('/admin/stats');
|
||||
}
|
||||
|
||||
export function getUsers(limit = 50, offset = 0) {
|
||||
return apiFetch<{ users: AdminUser[]; total: number }>(
|
||||
`/admin/users?limit=${limit}&offset=${offset}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function getUserDetail(id: string) {
|
||||
return apiFetch<AdminUserDetail>(`/admin/users/${id}`);
|
||||
}
|
||||
|
||||
export function revokeIntegration(userId: string, provider: string) {
|
||||
return apiFetch<{ ok: boolean }>(`/admin/users/${userId}/revoke-integration`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ provider }),
|
||||
});
|
||||
}
|
||||
|
||||
export function resetBandit(userId: string) {
|
||||
return apiFetch<{ ok: boolean }>(`/admin/users/${userId}/reset-bandit`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function getAuditLog(limit = 50, offset = 0) {
|
||||
return apiFetch<{ actions: AuditAction[]; total: number }>(
|
||||
`/admin/audit?limit=${limit}&offset=${offset}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function getEvents(params: { subject?: string; userId?: string; limit?: number; since?: number } = {}) {
|
||||
const q = new URLSearchParams();
|
||||
if (params.subject) q.set('subject', params.subject);
|
||||
if (params.userId) q.set('userId', params.userId);
|
||||
if (params.limit) q.set('limit', String(params.limit));
|
||||
if (params.since) q.set('since', String(params.since));
|
||||
return apiFetch<{ events: StoredEvent[]; nextSince: number }>(`/admin/events?${q}`);
|
||||
}
|
||||
|
||||
export function getTips(params: { limit?: number; offset?: number; userId?: string } = {}) {
|
||||
const q = new URLSearchParams();
|
||||
if (params.limit) q.set('limit', String(params.limit));
|
||||
if (params.offset) q.set('offset', String(params.offset));
|
||||
if (params.userId) q.set('userId', params.userId);
|
||||
return apiFetch<{ tips: TipScore[]; total: number }>(`/admin/tips?${q}`);
|
||||
}
|
||||
|
||||
export function getRewardAnalytics(days = 30) {
|
||||
return apiFetch<{
|
||||
daily: { date: string; action: string; count: number }[];
|
||||
byPolicy: { policy: string; action: string; count: number }[];
|
||||
byHour: { action: string; count: number; avgHour: number }[];
|
||||
}>(`/admin/reward-analytics?days=${days}`);
|
||||
}
|
||||
|
||||
export function getDataQuality() {
|
||||
return apiFetch<{
|
||||
scoringCallsLast30d: number;
|
||||
missingFeatureRate: number;
|
||||
staleTokenRate: number;
|
||||
totalTokens: number;
|
||||
staleTokens: number;
|
||||
dailyQuality: { date: string; total: number; withFeatures: number; avgCandidates: number }[];
|
||||
}>('/admin/data-quality');
|
||||
}
|
||||
|
||||
export function getHealth() {
|
||||
return apiFetch<HealthStatus>('/admin/health');
|
||||
}
|
||||
|
||||
export function getPolicies() {
|
||||
return apiFetch<{ policies: PolicyInfo[] }>('/admin/policies');
|
||||
}
|
||||
|
||||
export function togglePolicy(name: string, active: boolean) {
|
||||
return apiFetch<{ ok: boolean }>(`/admin/policies/${name}/toggle`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ active }),
|
||||
});
|
||||
}
|
||||
|
||||
export function replaySignal(subject: string, payload: Record<string, unknown>) {
|
||||
return apiFetch<{ ok: boolean }>('/admin/replay-signal', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ subject, payload }),
|
||||
});
|
||||
}
|
||||
|
||||
export function runSql(query: string) {
|
||||
return apiFetch<{ rows: unknown[]; rowCount: number }>('/admin/sql', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ query }),
|
||||
});
|
||||
}
|
||||
|
||||
export function getSavedQueries() {
|
||||
return apiFetch<{ queries: SavedQuery[] }>('/admin/saved-queries');
|
||||
}
|
||||
|
||||
export function saveQuery(name: string, querySql: string) {
|
||||
return apiFetch<{ id: string }>('/admin/saved-queries', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, querySql }),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteSavedQuery(id: string) {
|
||||
return apiFetch<{ ok: boolean }>(`/admin/saved-queries/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
119
apps/admin/src/lib/docs.ts
Normal file
119
apps/admin/src/lib/docs.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Server-side utilities for reading project documentation from the monorepo
|
||||
* `docs/` directory and rendering it as HTML.
|
||||
*
|
||||
* All functions are async and must only be called from server components or
|
||||
* server actions (no 'use client' imports of this module).
|
||||
*
|
||||
* Directory layout relative to monorepo root:
|
||||
* docs/adr/ — Architecture Decision Records (NNN-title.md)
|
||||
* docs/architecture/ — longer architecture notes (topic.md)
|
||||
*/
|
||||
import { readdir, readFile } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { marked } from 'marked';
|
||||
|
||||
// apps/admin sits two levels below the monorepo root.
|
||||
const DOCS_ROOT = path.resolve(process.cwd(), '../../docs');
|
||||
|
||||
export type DocCategory = 'adr' | 'architecture';
|
||||
|
||||
export interface DocMeta {
|
||||
category: DocCategory;
|
||||
slug: string; // filename without .md
|
||||
title: string; // first H1 from the file, fallback = slug
|
||||
href: string; // /docs/adr/0001-monorepo-polyglot
|
||||
status?: string; // for ADRs: "Accepted", "Proposed", …
|
||||
date?: string; // for ADRs: date after em-dash on Status line
|
||||
}
|
||||
|
||||
export interface DocPage extends DocMeta {
|
||||
html: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Extract the first # heading from markdown. */
|
||||
function extractTitle(md: string): string {
|
||||
const m = md.match(/^#\s+(.+)$/m);
|
||||
return m ? m[1].trim() : '';
|
||||
}
|
||||
|
||||
/** Extract status + date from "## Status\nAccepted — 2026-04-13" pattern. */
|
||||
function extractStatus(md: string): { status?: string; date?: string } {
|
||||
const block = md.match(/##\s+Status\s*\n+([^\n#]+)/);
|
||||
if (!block) return {};
|
||||
const line = block[1].trim();
|
||||
// "Accepted — 2026-04-13" or "Proposed"
|
||||
const parts = line.split(/\s*[–—]\s*/);
|
||||
return { status: parts[0]?.trim(), date: parts[1]?.trim() };
|
||||
}
|
||||
|
||||
function slugFromFile(filename: string): string {
|
||||
return filename.replace(/\.md$/, '');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** List all docs in a category, sorted by filename. */
|
||||
export async function listDocs(category: DocCategory): Promise<DocMeta[]> {
|
||||
const dir = path.join(DOCS_ROOT, category);
|
||||
let files: string[];
|
||||
try {
|
||||
files = (await readdir(dir)).filter((f) => f.endsWith('.md')).sort();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Promise.all(
|
||||
files.map(async (file) => {
|
||||
const slug = slugFromFile(file);
|
||||
const md = await readFile(path.join(dir, file), 'utf8');
|
||||
const title = extractTitle(md) || slug;
|
||||
const { status, date } = extractStatus(md);
|
||||
return {
|
||||
category,
|
||||
slug,
|
||||
title,
|
||||
href: `/docs/${category}/${slug}`,
|
||||
status,
|
||||
date,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** List all docs across all categories. */
|
||||
export async function listAllDocs(): Promise<Record<DocCategory, DocMeta[]>> {
|
||||
const [adr, architecture] = await Promise.all([listDocs('adr'), listDocs('architecture')]);
|
||||
return { adr, architecture };
|
||||
}
|
||||
|
||||
/** Read and render a single doc to HTML. */
|
||||
export async function getDoc(category: DocCategory, slug: string): Promise<DocPage | null> {
|
||||
const file = path.join(DOCS_ROOT, category, `${slug}.md`);
|
||||
let md: string;
|
||||
try {
|
||||
md = await readFile(file, 'utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = extractTitle(md) || slug;
|
||||
const { status, date } = extractStatus(md);
|
||||
const html = await marked(md, { gfm: true });
|
||||
|
||||
return {
|
||||
category,
|
||||
slug,
|
||||
title,
|
||||
href: `/docs/${category}/${slug}`,
|
||||
status,
|
||||
date,
|
||||
html,
|
||||
};
|
||||
}
|
||||
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 and API calls
|
||||
if (pathname.startsWith('/login') || pathname.startsWith('/api/')) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
const sid = req.cookies.get('sid')?.value;
|
||||
if (!sid) {
|
||||
const url = req.nextUrl.clone();
|
||||
url.pathname = '/login';
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
// Verify admin role via API. INTERNAL_API_URL (e.g. http://api:3078) is preferred
|
||||
// when set — it points to the API service on the internal Docker network, avoiding
|
||||
// a Caddy round-trip. Falls back to NEXT_PUBLIC_API_URL for dev, or localhost.
|
||||
const apiBase =
|
||||
process.env.INTERNAL_API_URL ||
|
||||
process.env.NEXT_PUBLIC_API_URL ||
|
||||
'http://localhost:3078';
|
||||
try {
|
||||
const profile = await fetch(`${apiBase}/api/user/me`, {
|
||||
headers: { cookie: `sid=${sid}` },
|
||||
next: { revalidate: 0 },
|
||||
});
|
||||
if (!profile.ok) throw new Error('not ok');
|
||||
const data = (await profile.json()) as { role?: string };
|
||||
if (data.role !== 'admin') {
|
||||
const url = req.nextUrl.clone();
|
||||
url.pathname = '/forbidden';
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
} catch {
|
||||
const url = req.nextUrl.clone();
|
||||
url.pathname = '/login';
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/', '/((?!_next/static|_next/image|favicon.ico).*)'],
|
||||
};
|
||||
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');
|
||||
})
|
||||
);
|
||||
});
|
||||
179
apps/web/src/app/connect/page.tsx
Normal file
179
apps/web/src/app/connect/page.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { getIntegrations, disconnectIntegration, deleteAccount, logout } from '@/lib/api';
|
||||
import type { Integration } from '@oo/shared-types';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
function ConnectPageInner() {
|
||||
const searchParams = useSearchParams();
|
||||
const [integrations, setIntegrations] = useState<Integration[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [disconnecting, setDisconnecting] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const { integrations: list } = await getIntegrations();
|
||||
setIntegrations(list);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// Show banner if just connected
|
||||
const justConnected = searchParams.get('connected');
|
||||
|
||||
const isConnected = (provider: string) =>
|
||||
integrations.some((i) => i.provider === provider && i.status === 'connected');
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
if (!confirm('Delete your account? This cannot be undone.')) return;
|
||||
setDeleting(true);
|
||||
await deleteAccount();
|
||||
await logout();
|
||||
window.location.href = '/sign-in';
|
||||
};
|
||||
|
||||
const handleDisconnect = async (provider: string) => {
|
||||
setDisconnecting(provider);
|
||||
await disconnectIntegration(provider);
|
||||
await load();
|
||||
setDisconnecting(null);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<main style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{ color: 'var(--gray)', fontSize: '0.875rem' }}>Loading…</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const todoistConnected = isConnected('todoist');
|
||||
|
||||
return (
|
||||
<main style={{ minHeight: '100vh', padding: '4rem 2rem', maxWidth: '480px', margin: '0 auto' }}>
|
||||
<h2 style={{ fontSize: '1.5rem', fontWeight: 300, marginBottom: '0.5rem', letterSpacing: '-0.02em' }}>
|
||||
Connect your apps
|
||||
</h2>
|
||||
<p style={{ color: 'var(--gray)', fontSize: '0.875rem', marginBottom: '3rem' }}>
|
||||
oO reads what you need, when you need it.
|
||||
</p>
|
||||
|
||||
{justConnected && (
|
||||
<div style={{
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.75rem 1rem',
|
||||
marginBottom: '1.5rem',
|
||||
fontSize: '0.875rem',
|
||||
color: 'var(--white)',
|
||||
}}>
|
||||
{justConnected} connected.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Todoist card */}
|
||||
<div style={{
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: '0.75rem',
|
||||
padding: '1.25rem 1.5rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '1rem',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.875rem' }}>
|
||||
{/* Todoist logomark */}
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" aria-label="Todoist">
|
||||
<rect width="24" height="24" rx="6" fill="#DB4035"/>
|
||||
<path d="M6 8.5L11 13l7-7" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, fontSize: '0.9rem' }}>Todoist</div>
|
||||
<div style={{ color: 'var(--gray)', fontSize: '0.75rem', marginTop: '0.1rem' }}>
|
||||
{todoistConnected ? 'Connected' : 'Tasks & to-dos'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{todoistConnected ? (
|
||||
<button
|
||||
onClick={() => handleDisconnect('todoist')}
|
||||
disabled={disconnecting === 'todoist'}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
color: 'var(--gray)',
|
||||
borderRadius: '0.375rem',
|
||||
padding: '0.375rem 0.875rem',
|
||||
fontSize: '0.8rem',
|
||||
}}
|
||||
>
|
||||
{disconnecting === 'todoist' ? '…' : 'Disconnect'}
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
href="/api/integrations/todoist/connect?redirectTo=/connect"
|
||||
style={{
|
||||
background: 'var(--white)',
|
||||
color: 'var(--black)',
|
||||
borderRadius: '0.375rem',
|
||||
padding: '0.375rem 0.875rem',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Connect
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{todoistConnected && (
|
||||
<div style={{ marginTop: '3rem' }}>
|
||||
<a
|
||||
href="/tip"
|
||||
style={{
|
||||
display: 'block',
|
||||
textAlign: 'center',
|
||||
background: 'var(--white)',
|
||||
color: 'var(--black)',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.875rem',
|
||||
fontWeight: 500,
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
>
|
||||
See my tip →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: '4rem', borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: '2rem' }}>
|
||||
<button
|
||||
onClick={handleDeleteAccount}
|
||||
disabled={deleting}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'rgba(255,255,255,0.2)',
|
||||
fontSize: '0.8rem',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
{deleting ? 'Deleting…' : 'Delete account'}
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ConnectPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<ConnectPageInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
323
apps/web/src/app/tip/page.tsx
Normal file
323
apps/web/src/app/tip/page.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { getRecommendation, sendFeedback, getVapidPublicKey, subscribePush } from '@/lib/api';
|
||||
import type { Tip } from '@oo/shared-types';
|
||||
|
||||
type State = 'loading' | 'tip' | 'empty' | 'actions' | 'done';
|
||||
|
||||
// Fade wrapper — children fade in when `visible`, fade out when not
|
||||
function Fade({ visible, children, style }: {
|
||||
visible: boolean;
|
||||
children: React.ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
return (
|
||||
<div style={{
|
||||
opacity: visible ? 1 : 0,
|
||||
transition: visible ? 'opacity 3.5s ease' : 'opacity 0.3s ease',
|
||||
pointerEvents: visible ? 'auto' : 'none',
|
||||
...style,
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TipPage() {
|
||||
const [tip, setTip] = useState<Tip | null>(null);
|
||||
const [state, setState] = useState<State>('loading');
|
||||
const [visible, setVisible] = useState(false);
|
||||
const holdTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [pressed, setPressed] = useState(false);
|
||||
const [pushState, setPushState] = useState<'idle' | 'subscribed' | 'denied'>('idle');
|
||||
|
||||
// Fade in after state change settles
|
||||
useEffect(() => {
|
||||
if (state === 'loading' || state === 'done') {
|
||||
setVisible(false);
|
||||
} else {
|
||||
const t = setTimeout(() => setVisible(true), 30);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
const loadTip = useCallback(async () => {
|
||||
setVisible(false);
|
||||
setState('loading');
|
||||
try {
|
||||
const rec = await getRecommendation();
|
||||
if (!rec) {
|
||||
setState('empty');
|
||||
return;
|
||||
}
|
||||
setTip(rec.tip);
|
||||
setState('tip');
|
||||
} catch (err: any) {
|
||||
console.error('[tip] loadTip error', err?.status, err?.message);
|
||||
setState('empty');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadTip(); }, [loadTip]);
|
||||
|
||||
// Check existing push permission on mount
|
||||
useEffect(() => {
|
||||
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
|
||||
setPushState('subscribed');
|
||||
} else if (typeof Notification !== 'undefined' && Notification.permission === 'denied') {
|
||||
setPushState('denied');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const requestPush = useCallback(async () => {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== 'granted') { setPushState('denied'); return; }
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.register('/sw.js');
|
||||
const vapidKey = await getVapidPublicKey();
|
||||
const sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: vapidKey,
|
||||
});
|
||||
await subscribePush(sub.toJSON());
|
||||
setPushState('subscribed');
|
||||
} catch { setPushState('denied'); }
|
||||
}, []);
|
||||
|
||||
const react = async (action: 'done' | 'dismiss' | 'snooze' | 'helpful' | 'not_helpful') => {
|
||||
if (!tip) return;
|
||||
const isNavigating = ['done', 'dismiss', 'snooze'].includes(action);
|
||||
if (isNavigating) {
|
||||
setVisible(false);
|
||||
setState('done');
|
||||
} else {
|
||||
setState('tip');
|
||||
}
|
||||
await sendFeedback(tip.id, { action });
|
||||
if (isNavigating) setTimeout(() => loadTip(), 700);
|
||||
};
|
||||
|
||||
const onPointerDown = () => {
|
||||
if (state !== 'tip') return;
|
||||
setPressed(true);
|
||||
holdTimer.current = setTimeout(() => {
|
||||
setState('actions');
|
||||
setVisible(true);
|
||||
setPressed(false);
|
||||
}, 600);
|
||||
};
|
||||
|
||||
const onPointerUp = () => {
|
||||
setPressed(false);
|
||||
if (holdTimer.current) {
|
||||
clearTimeout(holdTimer.current);
|
||||
holdTimer.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
<style>{`
|
||||
@keyframes breathe {
|
||||
0%, 100% { opacity: 0.3; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<main
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerLeave={onPointerUp}
|
||||
style={{
|
||||
height: '100dvh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
cursor: state === 'tip' ? 'default' : 'auto',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Ambient glow — breathes while loading */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: 'radial-gradient(ellipse at center, rgba(255,255,255,0.06) 0%, transparent 65%)',
|
||||
animation: state === 'loading' ? 'breathe 4s ease-in-out infinite' : undefined,
|
||||
opacity: state === 'loading' ? undefined : pressed ? 0.3 : 0,
|
||||
transition: state !== 'loading' ? 'opacity 0.4s ease' : undefined,
|
||||
pointerEvents: 'none',
|
||||
}} />
|
||||
|
||||
{/* Loading label */}
|
||||
{(state === 'loading' || state === 'done') && (
|
||||
<p style={{
|
||||
margin: 0,
|
||||
color: 'rgba(255,255,255,0.55)',
|
||||
fontSize: '0.7rem',
|
||||
letterSpacing: '0.18em',
|
||||
textTransform: 'uppercase',
|
||||
animation: 'breathe 4s ease-in-out infinite',
|
||||
}}>
|
||||
reading you…
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Tip */}
|
||||
{(state === 'tip' || state === 'actions') && tip && (
|
||||
<Fade visible={visible && state !== 'actions'} style={{ textAlign: 'center', maxWidth: '420px', padding: '0 2rem' }}>
|
||||
<p style={{
|
||||
fontSize: 'clamp(1.25rem, 4vw, 1.75rem)',
|
||||
fontWeight: 300,
|
||||
lineHeight: 1.45,
|
||||
letterSpacing: '-0.01em',
|
||||
color: 'rgba(255,255,255,1)',
|
||||
transition: 'opacity 0.2s ease',
|
||||
opacity: pressed ? 0.5 : 1,
|
||||
}}>
|
||||
{tip.content}
|
||||
</p>
|
||||
<p style={{
|
||||
marginTop: '2rem',
|
||||
color: 'rgba(255,255,255,0.18)',
|
||||
fontSize: '0.65rem',
|
||||
letterSpacing: '0.12em',
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
hold to act
|
||||
</p>
|
||||
{pushState === 'idle' && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); requestPush(); }}
|
||||
style={{
|
||||
marginTop: '2.5rem',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'rgba(255,255,255,0.18)',
|
||||
fontSize: '0.65rem',
|
||||
letterSpacing: '0.12em',
|
||||
textTransform: 'uppercase',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
notify me
|
||||
</button>
|
||||
)}
|
||||
</Fade>
|
||||
)}
|
||||
|
||||
{/* Empty */}
|
||||
{state === 'empty' && (
|
||||
<Fade visible={visible} style={{ textAlign: 'center' }}>
|
||||
<p style={{ fontSize: '1.1rem', fontWeight: 300, color: 'rgba(255,255,255,0.35)' }}>
|
||||
All clear.
|
||||
</p>
|
||||
<button
|
||||
onClick={loadTip}
|
||||
style={{
|
||||
marginTop: '2rem',
|
||||
background: 'transparent',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
color: 'rgba(255,255,255,0.35)',
|
||||
borderRadius: '0.375rem',
|
||||
padding: '0.5rem 1rem',
|
||||
fontSize: '0.8rem',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Check again
|
||||
</button>
|
||||
</Fade>
|
||||
)}
|
||||
|
||||
{/* Action sheet */}
|
||||
{state === 'actions' && (
|
||||
<>
|
||||
<div
|
||||
onClick={() => { setState('tip'); }}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
animation: 'none',
|
||||
}}
|
||||
/>
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: '#111',
|
||||
borderRadius: '1rem 1rem 0 0',
|
||||
padding: '1.5rem 1.5rem 2.5rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.75rem',
|
||||
transform: 'translateY(0)',
|
||||
transition: 'transform 0.3s ease',
|
||||
}}>
|
||||
{tip && (
|
||||
<p style={{
|
||||
color: 'rgba(255,255,255,0.35)',
|
||||
fontSize: '0.8rem',
|
||||
marginBottom: '0.5rem',
|
||||
lineHeight: 1.4,
|
||||
}}>
|
||||
{tip.content}
|
||||
</p>
|
||||
)}
|
||||
<ActionButton label="Done ✓" onClick={() => react('done')} primary />
|
||||
<ActionButton label="Helpful" onClick={() => react('helpful')} />
|
||||
<ActionButton label="Not helpful" onClick={() => react('not_helpful')} />
|
||||
<ActionButton label="Snooze" onClick={() => react('snooze')} />
|
||||
<ActionButton label="Dismiss" onClick={() => react('dismiss')} />
|
||||
<button
|
||||
onClick={() => setState('tip')}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'rgba(255,255,255,0.25)',
|
||||
padding: '0.5rem',
|
||||
fontSize: '0.8rem',
|
||||
cursor: 'pointer',
|
||||
marginTop: '0.25rem',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton({ label, onClick, primary }: { label: string; onClick: () => void; primary?: boolean }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
background: primary ? 'var(--white)' : 'rgba(255,255,255,0.06)',
|
||||
color: primary ? 'var(--black)' : 'var(--white)',
|
||||
border: 'none',
|
||||
borderRadius: '0.625rem',
|
||||
padding: '1rem',
|
||||
fontSize: '0.95rem',
|
||||
fontWeight: primary ? 500 : 400,
|
||||
width: '100%',
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
131
apps/web/src/components/__tests__/TipPage.test.tsx
Normal file
131
apps/web/src/components/__tests__/TipPage.test.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, act, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
// Mock the API module — we test UI behaviour, not network calls
|
||||
vi.mock('@/lib/api', () => ({
|
||||
getRecommendation: vi.fn(),
|
||||
sendFeedback: vi.fn().mockResolvedValue(undefined),
|
||||
getVapidPublicKey: vi.fn(),
|
||||
subscribePush: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getRecommendation, sendFeedback } from '@/lib/api';
|
||||
import TipPage from '@/app/tip/page';
|
||||
|
||||
const mockGetRec = getRecommendation as ReturnType<typeof vi.fn>;
|
||||
const mockSendFeedback = sendFeedback as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('TipPage — empty / error states', () => {
|
||||
it('shows "All clear." when no tip is returned', async () => {
|
||||
mockGetRec.mockResolvedValue(null);
|
||||
render(<TipPage />);
|
||||
await waitFor(() => expect(screen.getByText('All clear.')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows "All clear." when getRecommendation throws', async () => {
|
||||
mockGetRec.mockRejectedValue(Object.assign(new Error('Network error'), { status: 503 }));
|
||||
render(<TipPage />);
|
||||
await waitFor(() => expect(screen.getByText('All clear.')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('"Check again" button re-calls getRecommendation', async () => {
|
||||
mockGetRec.mockResolvedValue(null);
|
||||
render(<TipPage />);
|
||||
await waitFor(() => screen.getByText('Check again'));
|
||||
|
||||
mockGetRec.mockResolvedValue({
|
||||
tip: { id: 'todoist:2', content: 'New tip', source: 'todoist', createdAt: '' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Check again'));
|
||||
await waitFor(() => expect(mockGetRec).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
});
|
||||
|
||||
describe('TipPage — tip display', () => {
|
||||
it('renders tip content after loading', async () => {
|
||||
mockGetRec.mockResolvedValue({
|
||||
tip: { id: 'todoist:1', content: 'Write the test', source: 'todoist', createdAt: '' },
|
||||
});
|
||||
render(<TipPage />);
|
||||
await waitFor(() => expect(screen.getByText('Write the test')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows "hold to act" hint when tip is displayed', async () => {
|
||||
mockGetRec.mockResolvedValue({
|
||||
tip: { id: 'todoist:3', content: 'Do the thing', source: 'todoist', createdAt: '' },
|
||||
});
|
||||
render(<TipPage />);
|
||||
await waitFor(() => expect(screen.getByText(/hold to act/i)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows "reading you…" while loading', async () => {
|
||||
// Never resolves during this assertion
|
||||
mockGetRec.mockReturnValue(new Promise(() => {}));
|
||||
render(<TipPage />);
|
||||
expect(screen.getByText(/reading you/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TipPage — action sheet', () => {
|
||||
// Render with real timers, THEN switch to fake for hold simulation
|
||||
async function renderTipAndHold(id: string, content: string) {
|
||||
mockGetRec.mockResolvedValue({ tip: { id, content, source: 'todoist', createdAt: '' } });
|
||||
render(<TipPage />);
|
||||
// Wait for tip to appear (real timers — no deadlock)
|
||||
await screen.findByText(content);
|
||||
const main = screen.getByRole('main');
|
||||
|
||||
// Switch to fake timers now that the component is fully loaded
|
||||
vi.useFakeTimers();
|
||||
act(() => { main.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })); });
|
||||
act(() => { vi.advanceTimersByTime(650); });
|
||||
vi.useRealTimers();
|
||||
|
||||
// Wait for action sheet
|
||||
await screen.findByText('Done ✓');
|
||||
return main;
|
||||
}
|
||||
|
||||
it('action sheet appears after a long press (600 ms)', async () => {
|
||||
await renderTipAndHold('tip:lp', 'Hold me');
|
||||
expect(screen.getByText('Done ✓')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('action sheet does not appear on short press (<600 ms)', async () => {
|
||||
mockGetRec.mockResolvedValue({ tip: { id: 'tip:sp', content: 'Short press', source: 'todoist', createdAt: '' } });
|
||||
render(<TipPage />);
|
||||
await screen.findByText('Short press');
|
||||
const main = screen.getByRole('main');
|
||||
|
||||
vi.useFakeTimers();
|
||||
act(() => { main.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })); });
|
||||
act(() => { vi.advanceTimersByTime(200); });
|
||||
act(() => { main.dispatchEvent(new PointerEvent('pointerup', { bubbles: true })); });
|
||||
vi.useRealTimers();
|
||||
|
||||
expect(screen.queryByText('Done ✓')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking "Done ✓" calls sendFeedback with action=done', async () => {
|
||||
await renderTipAndHold('tip:d', 'Do it');
|
||||
await act(async () => { fireEvent.click(screen.getByText('Done ✓')); });
|
||||
expect(mockSendFeedback).toHaveBeenCalledWith('tip:d', { action: 'done' });
|
||||
});
|
||||
|
||||
it('clicking "Dismiss" calls sendFeedback with action=dismiss', async () => {
|
||||
await renderTipAndHold('tip:dis', 'Dismiss me');
|
||||
await act(async () => { fireEvent.click(screen.getByText('Dismiss')); });
|
||||
expect(mockSendFeedback).toHaveBeenCalledWith('tip:dis', { action: 'dismiss' });
|
||||
});
|
||||
|
||||
it('clicking "Helpful" calls sendFeedback with action=helpful (non-navigating)', async () => {
|
||||
await renderTipAndHold('tip:help', 'Helpful tip');
|
||||
await act(async () => { fireEvent.click(screen.getByText('Helpful')); });
|
||||
expect(mockSendFeedback).toHaveBeenCalledWith('tip:help', { action: 'helpful' });
|
||||
});
|
||||
});
|
||||
83
apps/web/src/lib/api.ts
Normal file
83
apps/web/src/lib/api.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { RecommendResponse, TipFeedback, IntegrationsResponse, UserProfile } from '@oo/shared-types';
|
||||
|
||||
const API = '/api';
|
||||
|
||||
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${API}${path}`, {
|
||||
credentials: 'include',
|
||||
...init,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw Object.assign(new Error(err.error ?? 'API error'), { status: res.status });
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json() as T;
|
||||
}
|
||||
|
||||
export async function getSession() {
|
||||
return apiFetch<{ user: { id: string; email: string; name?: string; image?: string } | null }>('/auth/session');
|
||||
}
|
||||
|
||||
export async function getRecommendation(): Promise<RecommendResponse | null> {
|
||||
try {
|
||||
return await apiFetch<RecommendResponse>('/recommend', { method: 'POST' });
|
||||
} catch (e: any) {
|
||||
if (e.status === 204 || e.status === 422) return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendFeedback(tipId: string, feedback: TipFeedback) {
|
||||
return apiFetch<{ ok: boolean }>(`/tip/${encodeURIComponent(tipId)}/feedback`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(feedback),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getIntegrations(): Promise<IntegrationsResponse> {
|
||||
return apiFetch<IntegrationsResponse>('/integrations');
|
||||
}
|
||||
|
||||
export async function disconnectIntegration(provider: string) {
|
||||
return apiFetch<{ ok: boolean }>(`/integrations/${provider}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function getProfile(): Promise<UserProfile> {
|
||||
return apiFetch<UserProfile>('/user/me');
|
||||
}
|
||||
|
||||
export async function giveConsent() {
|
||||
return apiFetch<{ ok: boolean }>('/user/consent', { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function deleteAccount() {
|
||||
return apiFetch<{ ok: boolean }>('/user/me', { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
return apiFetch<{ ok: boolean }>('/auth/logout', { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function getVapidPublicKey(): Promise<string> {
|
||||
const { key } = await apiFetch<{ key: string }>('/push/vapid-public-key');
|
||||
return key;
|
||||
}
|
||||
|
||||
export async function subscribePush(subscription: PushSubscriptionJSON) {
|
||||
return apiFetch<{ ok: boolean }>('/push/subscribe', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(subscription),
|
||||
});
|
||||
}
|
||||
|
||||
export async function unsubscribePush(endpoint: string) {
|
||||
return apiFetch<{ ok: boolean }>('/push/subscribe', {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ endpoint }),
|
||||
});
|
||||
}
|
||||
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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
62
docs/adr/0006-admin-console-framework.md
Normal file
62
docs/adr/0006-admin-console-framework.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# ADR-0006: Admin console framework — Next.js 15 + Tremor + shadcn/ui + embed specialist tools
|
||||
|
||||
## Status
|
||||
Accepted — 2026-04-15
|
||||
|
||||
## Context
|
||||
M1 ships a bandit-driven recommender, an event bus, and a live feedback loop. Without a cockpit to observe these systems, every model change ships blind. An admin console is needed to:
|
||||
|
||||
1. **Observe** — DAU/WAU, tip outcomes, reaction rates, LinUCB arm stats, feature distributions
|
||||
2. **Inspect** — per-user identity, consents, integrations, reward history
|
||||
3. **Act** — revoke tokens, replay signals, reset a per-user bandit, promote a policy
|
||||
4. **Audit** — every operator action is logged
|
||||
|
||||
The team is two people. The stack is TypeScript/React/Tailwind. Any framework that forks the stack creates a context-switch tax and a second deployment surface.
|
||||
|
||||
## Decision
|
||||
|
||||
### App shell — `apps/admin`, Next.js 15, App Router
|
||||
|
||||
Same stack as `apps/web`. Reuses `packages/shared-types`, the Auth.js session cookie, and the API rewrite convention. Deployed at `admin.o.alogins.net` behind Caddy, port 3080 in dev.
|
||||
|
||||
### UI libraries
|
||||
|
||||
| Layer | Library | Reason |
|
||||
|-------|---------|--------|
|
||||
| Charts / KPI | **Tremor** | Analytics-first React + Tailwind components (KPI cards, time-series, bar lists). Designed for dashboards, not bolted on. |
|
||||
| CRUD primitives | **shadcn/ui** | Copy-paste Radix components; forms, dialogs, command palette. No version lock-in — code lives in-repo. |
|
||||
| Heavy grids | **TanStack Table v8** | Sortable / paginated / virtualized tables for events, users, tips. |
|
||||
| Extra charts | **Recharts** | Fallback where Tremor falls short (histograms, distributions). |
|
||||
|
||||
### Link out, don't embed
|
||||
|
||||
Specialized MLOps tooling runs as **separate external services** with their own auth, linked from the admin shell — not embedded or reimplemented:
|
||||
|
||||
- **MLflow** → `https://o.alogins.net/mlflow` — experiment tracking, model registry, artifact browser; own basic-auth for now; see M3 for SSO consolidation
|
||||
- **Airflow** → `https://o.alogins.net/airflow` — batch pipeline orchestration, dataset management; own web-auth for now
|
||||
- **Grafana panels** → `/admin/infra` (iframed panels) — infra metrics
|
||||
- **Marimo notebooks** → launch-out link from admin
|
||||
|
||||
The admin shell links to these services; clicking them opens a new tab. The `/experiments` and `/models` admin pages are hub pages with direct links to the relevant MLflow/Airflow views.
|
||||
|
||||
### AuthZ
|
||||
|
||||
`profile.role` column on the `users` table (values: `'user'` | `'admin'`). First admin seeded via `ADMIN_SEED_EMAIL` env var at startup. Admin-only gate in Next.js middleware checks the session and the role returned by `GET /api/user/me`. Every write action through the admin API is appended to an `admin_actions` audit log.
|
||||
|
||||
### Rejected alternatives
|
||||
|
||||
| Option | Rejected because |
|
||||
|--------|-----------------|
|
||||
| Retool / AppSmith | Admin logic leaves the repo; weak analytics affordances |
|
||||
| Streamlit / Gradio | Python-first; splits the frontend stack; thin RBAC |
|
||||
| React-admin / Refine.dev | Strong CRUD scaffolding, analytics views feel bolted on |
|
||||
| Superset / Metabase as the admin surface | Excellent BI, poor operational writes; plan: adopt Superset in M4 for BI alongside batch pipelines |
|
||||
|
||||
## Consequences
|
||||
|
||||
- One more Next.js app in the monorepo. Build/dev added to Turborepo.
|
||||
- Tremor + shadcn/ui are added as dependencies. shadcn components are copied into `apps/admin/src/components/ui/` — no runtime version coupling.
|
||||
- MLflow (`o.alogins.net/mlflow*` → port 5000) and Airflow (`o.alogins.net/airflow*` → port 8080) are path-based routes in the existing `o.alogins.net` Caddy block, started via `docker compose --profile mlops up`.
|
||||
- Each service manages its own auth (MLflow: built-in basic-auth; Airflow: built-in web UI auth). M3 will consolidate both behind the shared OIDC provider.
|
||||
- The `NEXT_PUBLIC_MLFLOW_URL` and `NEXT_PUBLIC_AIRFLOW_URL` build args in `Dockerfile.admin` default to the production URLs; override for dev builds.
|
||||
- `admin_actions` audit log grows unboundedly — needs a retention policy before M4.
|
||||
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
|
||||
Accepted — 2026-04-16
|
||||
|
||||
## Context
|
||||
|
||||
M1 shipped LinUCB (d=5, α=1.0) as the first learned policy via `ml/serving /score`. After the M1 admin console landed, we ran an offline simulation to compare LinUCB against a new ε-greedy ridge-regression policy before deciding which to keep live.
|
||||
|
||||
**ε-greedy v1 design:**
|
||||
- Ridge regression estimator, θ updated online (equivalent to LinUCB without the UCB bonus).
|
||||
- d=7 feature vector: base 5 (is\_overdue, task\_age\_days, priority, hour\_of\_day, bias) + sin/cos encoding of day\_of\_week.
|
||||
- ε=0.10 random exploration; 90% argmax(θ·x).
|
||||
- Separate per-user state files (`{user}_egreedy.json`), independent of LinUCB state.
|
||||
|
||||
**Simulation setup (rule judge, seed=42):**
|
||||
- 5 synthetic personas × 20 rounds × 8 tasks/round = 100 judgments per policy.
|
||||
- Reward inferred from dwell-time (same `inferReward` logic as production): dismiss=−1, snooze=+0.1, done<15 s=−0.3, done 15 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.
|
||||
@@ -15,7 +15,7 @@
|
||||
| `auth` | TS | OAuth (Google; Apple in M1), sessions, JWT | identities, sessions | Node monolith |
|
||||
| `profile` | TS | user profile, preferences, consents | profiles | Node monolith |
|
||||
| `integrations` | TS | third-party connectors, token vault, signal fetch | credentials, cursors | Node monolith |
|
||||
| `events` | TS | event-bus abstraction + durable log (M1) | signal store | Node monolith (in-proc emitter) |
|
||||
| `events` | TS | event-bus abstraction + durable log | signal store | Node monolith (in-proc emitter, bridges to NATS JetStream when `NATS_URL` set) |
|
||||
| `recommender` | TS | orchestration: candidates → policy → tip; feedback sink | tip history | Node monolith |
|
||||
| `notifier` | TS | push/email delivery, quiet hours, dedupe | delivery log | Node monolith (web push in M1) |
|
||||
| `ml/serving` | Python | online scoring for policies/models | — (stateless) | **separate process** |
|
||||
@@ -46,21 +46,44 @@ User reactions (done / snooze / dismiss) are events too. They close the loop as
|
||||
- **Protobuf** for event schemas with a schema registry (ADR-0005) — train/serve parity depends on this.
|
||||
- **OpenAPI** for HTTP; TS client auto-generated; Python pydantic hand-written while consumers are few.
|
||||
- **Feast** for feature store when we get there; homegrown adapter until then (Phase 1 seam).
|
||||
- **MLflow** for model registry; artifacts in MinIO/S3.
|
||||
- **MLflow** for model registry and experiment tracking; deployed at `o.alogins.net/mlflow`.
|
||||
- **Airflow** for batch pipelines; deployed at `o.alogins.net/airflow`.
|
||||
- **Auth.js** embedded behind an OIDC-shaped boundary (ADR-0004). Swap to a standalone OIDC provider when mobile ships.
|
||||
- **k3s** as the first step beyond docker-compose — no "compose → full k8s" cliff.
|
||||
|
||||
## Decision flow for a new tip
|
||||
## AI stack
|
||||
|
||||
All LLM inference routes through **LiteLLM** (`llm.alogins.net`) backed by **Ollama** (local, `localhost:11434`). This means:
|
||||
- Model aliases (`tip-generator`, `embedder`, `judge`) decouple code from model names.
|
||||
- Swapping qwen2.5 → llama3.2 = one-line config change in LiteLLM, zero code change in oO.
|
||||
- Cloud fallback (Anthropic) is opt-in and gated behind `ANTHROPIC_API_KEY` — used only in offline simulation.
|
||||
|
||||
**OpenWebUI** (`ai.alogins.net`) is the human-facing interface for prompt iteration and model testing during development.
|
||||
|
||||
## Decision flow for a new tip (Phase 2 target)
|
||||
|
||||
```
|
||||
client ─► gateway ─► recommender
|
||||
│
|
||||
├─► candidates: integrations.fetchCandidates(user) + advice.library
|
||||
├─► context: FeatureAssembler(user, request)
|
||||
├─► policy: PolicyRegistry.get(policyName).pick(candidates, context)
|
||||
├─► shadows: run shadow policies in parallel, log their picks
|
||||
└─► persist: TipInstance{context_snapshot, policy, tip}
|
||||
◄─ tip
|
||||
client ─► gateway ─► recommender (TS)
|
||||
│
|
||||
▼
|
||||
ml/serving (Python)
|
||||
│
|
||||
├─► context: ml/features/context.py
|
||||
│ (tasks + reactions + time patterns → prompt)
|
||||
│
|
||||
├─► generate: LiteLLM → Ollama
|
||||
│ → N TipCandidates {content, kind, model, prompt_version}
|
||||
│
|
||||
├─► score: bandit policy scores each candidate
|
||||
│
|
||||
├─► shadows: shadow policies log picks without serving
|
||||
│
|
||||
└─► persist: tip_scores {candidate, policy, features, latency}
|
||||
◄─ best TipCandidate
|
||||
```
|
||||
|
||||
Feedback travels back the same path: `POST /feedback → events.emit(feedback.reaction)` → pipelines consume → bandit/model updated on next retrain.
|
||||
**Phase 1 (shipped M1):** candidates come from Todoist task list, no LLM. The bandit scores tasks directly.
|
||||
|
||||
**Phase 2 (shipped M2):** LLM candidates are generated in parallel with Todoist fetch. Both pools are merged, scored by the bandit, and the winner served. `tip_scores` tracks `prompt_version`, `llm_model`, and `tip_kind` for every row.
|
||||
|
||||
Feedback: `POST /feedback → events.emit(reaction)` → online bandit update + `prompt_version` tracked for A/B analysis.
|
||||
|
||||
63
infra/ci/ci.yml
Normal file
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
|
||||
32
infra/docker/Dockerfile.admin
Normal file
32
infra/docker/Dockerfile.admin
Normal file
@@ -0,0 +1,32 @@
|
||||
FROM node:22-alpine AS base
|
||||
RUN npm install -g pnpm
|
||||
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./
|
||||
COPY packages/shared-types/package.json ./packages/shared-types/
|
||||
COPY apps/admin/package.json ./apps/admin/
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/packages/shared-types/node_modules ./packages/shared-types/node_modules
|
||||
COPY --from=deps /app/apps/admin/node_modules ./apps/admin/node_modules
|
||||
COPY tsconfig.base.json ./
|
||||
COPY packages/shared-types ./packages/shared-types
|
||||
COPY apps/admin ./apps/admin
|
||||
RUN pnpm --filter @oo/shared-types build
|
||||
ARG NEXT_PUBLIC_MLFLOW_URL=/mlflow
|
||||
ARG NEXT_PUBLIC_AIRFLOW_URL=/airflow
|
||||
ENV NEXT_TELEMETRY_DISABLED=1 \
|
||||
NEXT_PUBLIC_MLFLOW_URL=$NEXT_PUBLIC_MLFLOW_URL \
|
||||
NEXT_PUBLIC_AIRFLOW_URL=$NEXT_PUBLIC_AIRFLOW_URL
|
||||
RUN pnpm --filter @oo/admin build
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=3080
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/apps/admin/.next/standalone ./
|
||||
COPY --from=builder /app/apps/admin/.next/static ./apps/admin/.next/static
|
||||
CMD ["node", "apps/admin/server.js"]
|
||||
32
infra/docker/Dockerfile.api
Normal file
32
infra/docker/Dockerfile.api
Normal file
@@ -0,0 +1,32 @@
|
||||
FROM node:22-alpine AS base
|
||||
RUN npm install -g pnpm
|
||||
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./
|
||||
COPY packages/shared-types/package.json ./packages/shared-types/
|
||||
COPY services/api/package.json ./services/api/
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/packages/shared-types/node_modules ./packages/shared-types/node_modules
|
||||
COPY --from=deps /app/services/api/node_modules ./services/api/node_modules
|
||||
COPY tsconfig.base.json ./
|
||||
COPY packages/shared-types ./packages/shared-types
|
||||
COPY services/api ./services/api
|
||||
RUN pnpm --filter @oo/shared-types build
|
||||
RUN pnpm --filter @oo/api build
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
RUN npm install -g pnpm
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./
|
||||
COPY packages/shared-types/package.json ./packages/shared-types/
|
||||
COPY services/api/package.json ./services/api/
|
||||
RUN pnpm install --prod --frozen-lockfile
|
||||
COPY --from=builder /app/packages/shared-types/dist ./packages/shared-types/dist
|
||||
COPY --from=builder /app/services/api/dist ./services/api/dist
|
||||
WORKDIR /app/services/api
|
||||
CMD ["node", "dist/index.js"]
|
||||
6
infra/docker/Dockerfile.ml
Normal file
6
infra/docker/Dockerfile.ml
Normal file
@@ -0,0 +1,6 @@
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY ml/serving/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY ml/serving/main.py .
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
29
infra/docker/Dockerfile.web
Normal file
29
infra/docker/Dockerfile.web
Normal file
@@ -0,0 +1,29 @@
|
||||
FROM node:22-alpine AS base
|
||||
RUN npm install -g pnpm
|
||||
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./
|
||||
COPY packages/shared-types/package.json ./packages/shared-types/
|
||||
COPY apps/web/package.json ./apps/web/
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/packages/shared-types/node_modules ./packages/shared-types/node_modules
|
||||
COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules
|
||||
COPY tsconfig.base.json ./
|
||||
COPY packages/shared-types ./packages/shared-types
|
||||
COPY apps/web ./apps/web
|
||||
RUN pnpm --filter @oo/shared-types build
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN pnpm --filter @oo/web build
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/apps/web/.next/standalone ./
|
||||
COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static
|
||||
COPY --from=builder /app/apps/web/public ./apps/web/public
|
||||
CMD ["node", "apps/web/server.js"]
|
||||
206
infra/docker/docker-compose.yml
Normal file
206
infra/docker/docker-compose.yml
Normal file
@@ -0,0 +1,206 @@
|
||||
name: oo
|
||||
|
||||
services:
|
||||
# ── core profile ──────────────────────────────────────────────────────────
|
||||
|
||||
api:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: infra/docker/Dockerfile.api
|
||||
profiles: [core, full]
|
||||
env_file: ../../.env.local
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
volumes:
|
||||
- /mnt/ssd/dbs/oo:/mnt/ssd/dbs/oo
|
||||
ports:
|
||||
- "127.0.0.1:3078:3078"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3078/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
web:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: infra/docker/Dockerfile.web
|
||||
profiles: [core, full]
|
||||
env_file: ../../.env.local
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: "3079"
|
||||
HOSTNAME: "0.0.0.0"
|
||||
NEXT_PUBLIC_API_URL: "" # Caddy routes /api/* directly to the API in prod
|
||||
ports:
|
||||
- "127.0.0.1:3079:3079"
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
|
||||
admin:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: infra/docker/Dockerfile.admin
|
||||
profiles: [core, full]
|
||||
env_file: ../../.env.local
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: "3080"
|
||||
HOSTNAME: "0.0.0.0"
|
||||
NEXT_PUBLIC_API_URL: ""
|
||||
INTERNAL_API_URL: "http://api:3078"
|
||||
ports:
|
||||
- "127.0.0.1:3080:3080"
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
|
||||
# ── full profile ──────────────────────────────────────────────────────────
|
||||
|
||||
ml-serving:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: infra/docker/Dockerfile.ml
|
||||
profiles: [full]
|
||||
env_file: ../../.env.local
|
||||
environment:
|
||||
LITELLM_URL: ${LITELLM_URL:-http://host.docker.internal:4000}
|
||||
OLLAMA_URL: ${OLLAMA_URL:-http://host.docker.internal:11434}
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
ports:
|
||||
- "127.0.0.1:8000:8000"
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/health',timeout=3).status==200 else 1)"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ── mlops profile — MLflow + Airflow ──────────────────────────────────────
|
||||
# Start: docker compose --profile mlops up
|
||||
# MLflow UI: http://localhost:5000 or https://o.alogins.net/mlflow (admin / password — change via basic_auth.ini)
|
||||
# Airflow UI: http://localhost:8080/airflow or https://o.alogins.net/airflow (admin / AIRFLOW_ADMIN_PASSWORD)
|
||||
# Caddy routes /mlflow* and /airflow* inside the o.alogins.net block
|
||||
|
||||
airflow-db:
|
||||
image: postgres:16-alpine
|
||||
profiles: [mlops]
|
||||
environment:
|
||||
POSTGRES_DB: airflow
|
||||
POSTGRES_USER: airflow
|
||||
POSTGRES_PASSWORD: ${AIRFLOW_DB_PASSWORD:-airflow}
|
||||
volumes:
|
||||
- /mnt/ssd/dbs/oo/airflow-db:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U airflow"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
airflow-init:
|
||||
image: apache/airflow:2.9.3
|
||||
profiles: [mlops]
|
||||
entrypoint: /bin/bash
|
||||
command:
|
||||
- -c
|
||||
- |
|
||||
airflow db migrate
|
||||
airflow users create \
|
||||
--username admin \
|
||||
--firstname Admin \
|
||||
--lastname User \
|
||||
--role Admin \
|
||||
--email admin@oo.local \
|
||||
--password "$${AIRFLOW_ADMIN_PASSWORD:-admin}"
|
||||
environment:
|
||||
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://airflow:${AIRFLOW_DB_PASSWORD:-airflow}@airflow-db/airflow
|
||||
AIRFLOW__CORE__EXECUTOR: LocalExecutor
|
||||
AIRFLOW__WEBSERVER__SECRET_KEY: ${AIRFLOW_SECRET_KEY:-change-me-in-prod}
|
||||
AIRFLOW__WEBSERVER__BASE_URL: ${AIRFLOW_BASE_URL:-https://o.alogins.net/airflow}
|
||||
depends_on:
|
||||
airflow-db:
|
||||
condition: service_healthy
|
||||
restart: "no"
|
||||
|
||||
airflow-webserver:
|
||||
image: apache/airflow:2.9.3
|
||||
profiles: [mlops]
|
||||
command: webserver
|
||||
environment:
|
||||
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://airflow:${AIRFLOW_DB_PASSWORD:-airflow}@airflow-db/airflow
|
||||
AIRFLOW__CORE__EXECUTOR: LocalExecutor
|
||||
AIRFLOW__WEBSERVER__SECRET_KEY: ${AIRFLOW_SECRET_KEY:-change-me-in-prod}
|
||||
AIRFLOW__CORE__FERNET_KEY: ${AIRFLOW_FERNET_KEY:-}
|
||||
AIRFLOW__WEBSERVER__BASE_URL: ${AIRFLOW_BASE_URL:-https://o.alogins.net/airflow}
|
||||
volumes:
|
||||
- ../../ml/pipelines:/opt/airflow/dags:ro
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
depends_on:
|
||||
airflow-init:
|
||||
condition: service_completed_successfully
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--fail", "http://localhost:8080/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
|
||||
airflow-scheduler:
|
||||
image: apache/airflow:2.9.3
|
||||
profiles: [mlops]
|
||||
command: scheduler
|
||||
environment:
|
||||
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://airflow:${AIRFLOW_DB_PASSWORD:-airflow}@airflow-db/airflow
|
||||
AIRFLOW__CORE__EXECUTOR: LocalExecutor
|
||||
AIRFLOW__CORE__FERNET_KEY: ${AIRFLOW_FERNET_KEY:-}
|
||||
volumes:
|
||||
- ../../ml/pipelines:/opt/airflow/dags:ro
|
||||
depends_on:
|
||||
airflow-init:
|
||||
condition: service_completed_successfully
|
||||
|
||||
# ── events profile — NATS JetStream ─────────────────────────────────────
|
||||
# Start: docker compose --profile events up
|
||||
# NATS monitoring: http://localhost:8222
|
||||
# Enable in the API by setting NATS_URL=nats://nats:4222 in .env.local
|
||||
|
||||
nats:
|
||||
image: nats:2.10-alpine
|
||||
profiles: [events, full]
|
||||
command: ["-js", "-sd", "/data", "-m", "8222"]
|
||||
volumes:
|
||||
- /mnt/ssd/dbs/oo/nats:/data
|
||||
ports:
|
||||
- "127.0.0.1:4222:4222" # client connections
|
||||
- "127.0.0.1:8222:8222" # HTTP monitoring
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8222/healthz"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
mlflow:
|
||||
image: ghcr.io/mlflow/mlflow:v2.14.3
|
||||
profiles: [mlops]
|
||||
command: >
|
||||
mlflow server
|
||||
--backend-store-uri sqlite:////mlflow/mlflow.db
|
||||
--default-artifact-root /mlflow/artifacts
|
||||
--host 0.0.0.0
|
||||
--port 5000
|
||||
--app-name basic-auth
|
||||
--static-prefix /mlflow
|
||||
environment:
|
||||
MLFLOW_AUTH_CONFIG_PATH: /mlflow/basic_auth.ini
|
||||
volumes:
|
||||
- /mnt/ssd/dbs/oo/mlflow:/mlflow
|
||||
- ../../infra/mlflow/basic_auth.ini:/mlflow/basic_auth.ini:ro
|
||||
ports:
|
||||
- "127.0.0.1:5000:5000"
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:5000/health',timeout=3).status==200 else 1)"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
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
|
||||
@@ -4,8 +4,8 @@ Python. Owns models, features, training, online scoring.
|
||||
|
||||
| Dir | Role | Phase |
|
||||
|---|---|---|
|
||||
| `serving/` | FastAPI online scorer (`/score`), called by `recommender` | 1 |
|
||||
| `features/` | feature definitions + store adapter (Feast later) | 1 |
|
||||
| `serving/` | FastAPI online scorer (`/score`, `/generate`) + LiteLLM gateway, called by `recommender` | 1–2 |
|
||||
| `features/` | context assembler (`context.py`): signals → `PromptContext`; Feast adapter later | 2 |
|
||||
| `pipelines/` | batch feature + training DAGs (Prefect/Airflow) | 4 |
|
||||
| `registry/` | MLflow-backed model registry integration | 4 |
|
||||
| `experiments/` | A/B assignment + multi-armed bandit policies | 4 |
|
||||
|
||||
204
ml/experiments/sim/llm_judge.py
Normal file
204
ml/experiments/sim/llm_judge.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""
|
||||
LLM-based user reaction judge.
|
||||
|
||||
Uses Claude Haiku when ANTHROPIC_API_KEY is set; falls back to a
|
||||
deterministic persona-based rule when it is not.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import random
|
||||
|
||||
from personas import Persona
|
||||
|
||||
ACTIONS = ["done", "snooze", "dismiss"]
|
||||
|
||||
# Reward is NOT a fixed map anymore — it depends on action + simulated dwell time.
|
||||
# Use infer_reward() to compute the final reward after simulating dwell.
|
||||
_BASE_REWARDS: dict[str, float] = {
|
||||
"done": 1.0, # placeholder; real reward computed from dwell
|
||||
"snooze": 0.1,
|
||||
"dismiss": -1.0,
|
||||
}
|
||||
|
||||
|
||||
def infer_reward(action: str, dwell_ms: int) -> float:
|
||||
"""Mirror of production inferReward() in recommender.ts."""
|
||||
if action == "dismiss":
|
||||
return -1.0
|
||||
if action == "snooze":
|
||||
return 0.1
|
||||
# done — dwell-based
|
||||
if dwell_ms < 15_000:
|
||||
return -0.3 # stale / reflex done
|
||||
if dwell_ms < 120_000:
|
||||
return 1.0 # magic zone
|
||||
if dwell_ms < 600_000:
|
||||
return 0.6 # good
|
||||
return 0.3 # eventually done
|
||||
|
||||
_HOUR_PERIODS = {
|
||||
(5, 10): "morning",
|
||||
(10, 14): "midday",
|
||||
(14, 18): "afternoon",
|
||||
(18, 22): "evening",
|
||||
}
|
||||
|
||||
|
||||
def _period(hour: int) -> str:
|
||||
for (lo, hi), name in _HOUR_PERIODS.items():
|
||||
if lo <= hour < hi:
|
||||
return name
|
||||
return "night"
|
||||
|
||||
|
||||
# ── Deterministic judge ────────────────────────────────────────────────────
|
||||
|
||||
def _engagement_score(persona: Persona, tip: dict, hour: int) -> float:
|
||||
"""0–1 score of how well this tip fits this persona right now."""
|
||||
features = tip.get("features", {})
|
||||
priority = features.get("priority", 1)
|
||||
is_overdue = features.get("is_overdue", False)
|
||||
|
||||
p = 0.35
|
||||
priority_norm = (priority - 1) / 3.0
|
||||
p += (priority_norm - 0.5) * persona.prefers_high_priority * 0.4
|
||||
if is_overdue:
|
||||
p += (persona.prefers_overdue - 0.5) * 0.3
|
||||
|
||||
is_morning = 5 <= hour < 10
|
||||
is_evening = 18 <= hour < 22
|
||||
if persona.morning_active and is_morning:
|
||||
p += 0.15
|
||||
elif persona.evening_active and is_evening:
|
||||
p += 0.15
|
||||
elif persona.morning_active and not is_morning and not is_evening:
|
||||
p -= 0.10
|
||||
elif persona.evening_active and not is_evening and not is_morning:
|
||||
p -= 0.10
|
||||
|
||||
return max(0.05, min(0.90, p))
|
||||
|
||||
|
||||
def _simulate_dwell_ms(engagement: float, rng: random.Random) -> int:
|
||||
"""
|
||||
Simulate how many milliseconds the user takes to act on a tip.
|
||||
|
||||
High engagement → quick action (magic zone, 15s–2min).
|
||||
Medium engagement → slower (2–10min).
|
||||
Low engagement → very slow (>10min) — tip helped eventually but not 'magic'.
|
||||
For snooze/dismiss the dwell doesn't affect reward; return a short value.
|
||||
"""
|
||||
if engagement >= 0.70:
|
||||
# Strong match — magic zone: 15s–90s
|
||||
return rng.randint(15_000, 90_000)
|
||||
elif engagement >= 0.50:
|
||||
# Moderate match — good zone: 2–8min
|
||||
return rng.randint(120_000, 480_000)
|
||||
else:
|
||||
# Weak match but still done — eventually: 10–30min
|
||||
return rng.randint(600_000, 1_800_000)
|
||||
|
||||
|
||||
def _rule_judge(persona: Persona, tip: dict, hour: int, rng: random.Random) -> tuple[str, int]:
|
||||
"""Return (action, dwell_ms) based on persona preferences and task features."""
|
||||
engagement = _engagement_score(persona, tip, hour)
|
||||
|
||||
r = rng.random()
|
||||
if r < engagement * 0.55:
|
||||
# done — dwell depends on engagement
|
||||
dwell = _simulate_dwell_ms(engagement, rng)
|
||||
return "done", dwell
|
||||
elif r < engagement:
|
||||
return "snooze", rng.randint(3_000, 20_000)
|
||||
else:
|
||||
return "dismiss", rng.randint(1_000, 5_000)
|
||||
|
||||
|
||||
# ── LLM judge ─────────────────────────────────────────────────────────────
|
||||
|
||||
_anthropic_client = None
|
||||
|
||||
def _get_client():
|
||||
global _anthropic_client
|
||||
if _anthropic_client is None:
|
||||
try:
|
||||
import anthropic # type: ignore
|
||||
key = os.environ.get("ANTHROPIC_API_KEY", "")
|
||||
if key:
|
||||
_anthropic_client = anthropic.Anthropic(api_key=key)
|
||||
except ImportError:
|
||||
pass
|
||||
return _anthropic_client
|
||||
|
||||
|
||||
def _llm_judge(
|
||||
persona: Persona, tip: dict, hour: int, day_of_week: int, rng: random.Random,
|
||||
) -> tuple[str, int]:
|
||||
client = _get_client()
|
||||
if client is None:
|
||||
return _rule_judge(persona, tip, hour, rng)
|
||||
|
||||
features = tip.get("features", {})
|
||||
priority = features.get("priority", 1)
|
||||
is_overdue = features.get("is_overdue", False)
|
||||
age_days = features.get("task_age_days", 0)
|
||||
|
||||
priority_label = {1: "low", 2: "normal", 3: "high", 4: "urgent"}.get(priority, "normal")
|
||||
overdue_str = f", overdue by {age_days:.0f} day(s)" if is_overdue else ""
|
||||
days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||
day_str = days[day_of_week % 7]
|
||||
|
||||
prompt = (
|
||||
f"You are simulating how a specific user reacts to a task recommendation app.\n\n"
|
||||
f"User persona: {persona.name}\n"
|
||||
f"Persona: {persona.description}\n\n"
|
||||
f'Recommended task: "{tip.get("content", "Unknown task")}"\n'
|
||||
f"Task: priority={priority_label}{overdue_str}\n"
|
||||
f"Current time: {_period(hour)} ({hour}:00, {day_str})\n\n"
|
||||
f"How does this user react? Reply with exactly one word: done | snooze | dismiss\n\n"
|
||||
f"- done: acts on this tip (marks task complete)\n"
|
||||
f"- snooze: acknowledges but not now\n"
|
||||
f"- dismiss: ignores or rejects it"
|
||||
)
|
||||
|
||||
try:
|
||||
message = client.messages.create(
|
||||
model="claude-haiku-4-5-20251001",
|
||||
max_tokens=10,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
raw = message.content[0].text.strip().lower().split()[0]
|
||||
action = raw if raw in ACTIONS else _rule_judge(persona, tip, hour, rng)[0]
|
||||
except Exception:
|
||||
action, _ = _rule_judge(persona, tip, hour, rng)
|
||||
|
||||
# Simulate dwell based on engagement level
|
||||
engagement = _engagement_score(persona, tip, hour)
|
||||
dwell = _simulate_dwell_ms(engagement, rng) if action == "done" else rng.randint(2_000, 15_000)
|
||||
return action, dwell
|
||||
|
||||
|
||||
# ── Public API ─────────────────────────────────────────────────────────────
|
||||
|
||||
def judge(
|
||||
persona: Persona,
|
||||
tip: dict,
|
||||
hour: int,
|
||||
day_of_week: int,
|
||||
rng: random.Random,
|
||||
use_llm: bool = True,
|
||||
) -> tuple[str, int, float]:
|
||||
"""Return (action, dwell_ms, reward).
|
||||
|
||||
action — 'done' | 'snooze' | 'dismiss'
|
||||
dwell_ms — simulated milliseconds between tip appearance and user action
|
||||
reward — inferred from action + dwell_ms via infer_reward()
|
||||
"""
|
||||
if use_llm and os.environ.get("ANTHROPIC_API_KEY"):
|
||||
action, dwell_ms = _llm_judge(persona, tip, hour, day_of_week, rng)
|
||||
else:
|
||||
action, dwell_ms = _rule_judge(persona, tip, hour, rng)
|
||||
|
||||
return action, dwell_ms, infer_reward(action, dwell_ms)
|
||||
79
ml/experiments/sim/personas.py
Normal file
79
ml/experiments/sim/personas.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Synthetic user personas for simulation."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Persona:
|
||||
name: str
|
||||
description: str
|
||||
# Feature preference weights — used by deterministic judge
|
||||
prefers_high_priority: float # 0–1: scales response to priority
|
||||
prefers_overdue: float # 0–1: scales response to overdue tasks
|
||||
morning_active: bool # higher engagement hours 6–10
|
||||
evening_active: bool # higher engagement hours 18–22
|
||||
recency_bias: float # 0–1: prefers recently-due tasks
|
||||
|
||||
|
||||
PERSONAS: list[Persona] = [
|
||||
Persona(
|
||||
name="deadline-driven",
|
||||
description=(
|
||||
"Responds urgently to overdue and high-priority tasks. "
|
||||
"Most active in the morning. Dismisses low-priority tips."
|
||||
),
|
||||
prefers_high_priority=0.9,
|
||||
prefers_overdue=0.85,
|
||||
morning_active=True,
|
||||
evening_active=False,
|
||||
recency_bias=0.3,
|
||||
),
|
||||
Persona(
|
||||
name="evening-relaxed",
|
||||
description=(
|
||||
"Reviews tasks in the evenings. Neutral on priority. "
|
||||
"Snoozes morning recommendations."
|
||||
),
|
||||
prefers_high_priority=0.5,
|
||||
prefers_overdue=0.4,
|
||||
morning_active=False,
|
||||
evening_active=True,
|
||||
recency_bias=0.5,
|
||||
),
|
||||
Persona(
|
||||
name="low-priority-first",
|
||||
description=(
|
||||
"Clears small tasks first. Snoozes urgent items until deadline. "
|
||||
"Morning person."
|
||||
),
|
||||
prefers_high_priority=0.2,
|
||||
prefers_overdue=0.6,
|
||||
morning_active=True,
|
||||
evening_active=False,
|
||||
recency_bias=0.7,
|
||||
),
|
||||
Persona(
|
||||
name="consistent-responder",
|
||||
description=(
|
||||
"Engages consistently across hours and days. "
|
||||
"Acts on helpful tips regardless of priority."
|
||||
),
|
||||
prefers_high_priority=0.6,
|
||||
prefers_overdue=0.6,
|
||||
morning_active=True,
|
||||
evening_active=True,
|
||||
recency_bias=0.5,
|
||||
),
|
||||
Persona(
|
||||
name="overdue-ignorer",
|
||||
description=(
|
||||
"Avoids overdue tasks (stress avoidance). "
|
||||
"Focuses on future-due, high-priority items. Evening person."
|
||||
),
|
||||
prefers_high_priority=0.8,
|
||||
prefers_overdue=0.1,
|
||||
morning_active=False,
|
||||
evening_active=True,
|
||||
recency_bias=0.2,
|
||||
),
|
||||
]
|
||||
527
ml/experiments/sim/runner.py
Normal file
527
ml/experiments/sim/runner.py
Normal file
@@ -0,0 +1,527 @@
|
||||
"""
|
||||
oO simulation runner — compares two recommendation policies.
|
||||
|
||||
Judge modes:
|
||||
rule Deterministic persona-based rules (default, no external deps)
|
||||
llm Claude Haiku via Anthropic API (requires ANTHROPIC_API_KEY)
|
||||
claude-code Two-phase: Claude Code acts as the judge (you are the judge)
|
||||
|
||||
Usage — rule/llm (single pass):
|
||||
python runner.py --n-users 5 --n-rounds 10 --no-llm
|
||||
python runner.py --n-users 5 --n-rounds 10
|
||||
|
||||
Usage — claude-code judge (two phases):
|
||||
# Phase 1: score candidates, write judgment requests
|
||||
python runner.py --judge claude-code --phase score \\
|
||||
--n-users 5 --n-rounds 10 --out /tmp/oo-cc-sim.json
|
||||
|
||||
# (Claude Code reads /tmp/oo-cc-sim-requests.json and writes /tmp/oo-cc-sim-responses.json)
|
||||
|
||||
# Phase 2: apply responses, run rewards, produce results
|
||||
python runner.py --judge claude-code --phase reward --plan /tmp/oo-cc-sim-plan.json \\
|
||||
--out /tmp/oo-cc-sim.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import random
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
import httpx
|
||||
|
||||
from llm_judge import ACTIONS, infer_reward, judge
|
||||
from personas import PERSONAS, Persona
|
||||
from task_generator import generate_task_pool
|
||||
|
||||
POLICY_SCORE_ENDPOINTS: dict[str, str] = {
|
||||
"linucb-v1": "/score",
|
||||
"egreedy-v1": "/score/egreedy",
|
||||
}
|
||||
POLICY_REWARD_ENDPOINTS: dict[str, str] = {
|
||||
"linucb-v1": "/reward",
|
||||
"egreedy-v1": "/reward/egreedy",
|
||||
}
|
||||
|
||||
|
||||
def _call_score(
|
||||
client: httpx.Client, ml_url: str, policy: str,
|
||||
user_id: str, tasks: list[dict], hour: int, dow: int,
|
||||
) -> dict | None:
|
||||
endpoint = POLICY_SCORE_ENDPOINTS.get(policy, "/score")
|
||||
body = {
|
||||
"user_id": user_id,
|
||||
"candidates": [
|
||||
{
|
||||
"id": t["id"], "content": t["content"], "source": t["source"],
|
||||
"source_id": None,
|
||||
"features": {
|
||||
"hour_of_day": hour,
|
||||
"is_overdue": t["features"]["is_overdue"],
|
||||
"task_age_days": t["features"]["task_age_days"],
|
||||
"priority": t["features"]["priority"],
|
||||
},
|
||||
}
|
||||
for t in tasks
|
||||
],
|
||||
"context": {"hour_of_day": hour, "day_of_week": dow},
|
||||
}
|
||||
try:
|
||||
r = client.post(f"{ml_url}{endpoint}", json=body, timeout=5.0)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
print(f" [warn] score {policy}: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def _call_reward(
|
||||
client: httpx.Client, ml_url: str, policy: str,
|
||||
user_id: str, tip_id: str, reward: float, features: dict,
|
||||
day_of_week: int = 0,
|
||||
) -> None:
|
||||
endpoint = POLICY_REWARD_ENDPOINTS.get(policy, "/reward")
|
||||
try:
|
||||
client.post(
|
||||
f"{ml_url}{endpoint}",
|
||||
json={"user_id": user_id, "tip_id": tip_id, "reward": reward,
|
||||
"features": features, "day_of_week": day_of_week},
|
||||
timeout=5.0,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f" [warn] reward {policy}: {e}", file=sys.stderr)
|
||||
|
||||
|
||||
# ── Standard single-pass runner (rule / llm modes) ─────────────────────────
|
||||
|
||||
def run_simulation(
|
||||
n_users: int, n_rounds: int, tasks_per_round: int,
|
||||
ml_url: str, policies: list[str], use_llm: bool, seed: int,
|
||||
) -> dict:
|
||||
rng = random.Random(seed)
|
||||
run_id = str(uuid.uuid4())[:8]
|
||||
started_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
|
||||
user_personas = [
|
||||
(f"sim-{run_id}-u{i}", PERSONAS[i % len(PERSONAS)])
|
||||
for i in range(n_users)
|
||||
]
|
||||
|
||||
acc: dict[str, dict] = {
|
||||
p: {
|
||||
"total_reward": 0.0, "n_pulls": 0,
|
||||
"cumulative_rewards": [],
|
||||
"action_counts": {a: 0 for a in ACTIONS},
|
||||
}
|
||||
for p in policies
|
||||
}
|
||||
events: list[dict] = []
|
||||
|
||||
with httpx.Client(trust_env=False) as client:
|
||||
for rnd in range(n_rounds):
|
||||
hour = rng.randint(6, 22)
|
||||
dow = rng.randint(0, 6)
|
||||
round_rewards = {p: 0.0 for p in policies}
|
||||
|
||||
for user_id, persona in user_personas:
|
||||
seed_tasks = rnd * 997 + abs(hash(user_id)) % 997
|
||||
tasks = generate_task_pool(n=tasks_per_round, seed=seed_tasks)
|
||||
|
||||
for policy in policies:
|
||||
p_user = f"{user_id}-{policy}"
|
||||
scored = _call_score(client, ml_url, policy, p_user, tasks, hour, dow)
|
||||
if not scored:
|
||||
continue
|
||||
tip_id = scored.get("tip_id")
|
||||
tip = next((t for t in tasks if t["id"] == tip_id), None)
|
||||
if not tip:
|
||||
continue
|
||||
|
||||
action, dwell_ms, reward = judge(persona, tip, hour, dow, rng, use_llm=use_llm)
|
||||
_call_reward(client, ml_url, policy, p_user, tip_id, reward, {
|
||||
"hour_of_day": hour,
|
||||
"is_overdue": tip["features"]["is_overdue"],
|
||||
"task_age_days": tip["features"]["task_age_days"],
|
||||
"priority": tip["features"]["priority"],
|
||||
}, day_of_week=dow)
|
||||
|
||||
acc[policy]["total_reward"] += reward
|
||||
acc[policy]["n_pulls"] += 1
|
||||
acc[policy]["action_counts"][action] += 1
|
||||
round_rewards[policy] += reward
|
||||
events.append({
|
||||
"round": rnd, "user_id": user_id, "persona": persona.name,
|
||||
"policy": policy, "tip_content": tip["content"],
|
||||
"priority": tip["features"]["priority"],
|
||||
"is_overdue": tip["features"]["is_overdue"],
|
||||
"action": action, "dwell_ms": dwell_ms, "reward": reward,
|
||||
"hour": hour, "day_of_week": dow,
|
||||
})
|
||||
|
||||
for p in policies:
|
||||
prev = acc[p]["cumulative_rewards"][-1] if acc[p]["cumulative_rewards"] else 0.0
|
||||
acc[p]["cumulative_rewards"].append(prev + round_rewards[p])
|
||||
|
||||
mode = "llm" if use_llm else "rule"
|
||||
print(f" Round {rnd+1:>3}/{n_rounds} [{mode}] " + " ".join(
|
||||
f"{p}={acc[p]['cumulative_rewards'][-1]:+.2f}" for p in policies
|
||||
))
|
||||
|
||||
return _build_result(run_id, started_at, policies, acc, events,
|
||||
n_users, n_rounds, tasks_per_round, use_llm, seed)
|
||||
|
||||
|
||||
# ── Claude Code judge — phase 1: score ─────────────────────────────────────
|
||||
|
||||
def run_score_phase(
|
||||
n_users: int, n_rounds: int, tasks_per_round: int,
|
||||
ml_url: str, policies: list[str], seed: int, out_path: str,
|
||||
) -> None:
|
||||
"""Score all candidates and write judgment requests for Claude Code."""
|
||||
rng = random.Random(seed)
|
||||
run_id = str(uuid.uuid4())[:8]
|
||||
started_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
|
||||
user_personas = [
|
||||
(f"sim-{run_id}-u{i}", PERSONAS[i % len(PERSONAS)])
|
||||
for i in range(n_users)
|
||||
]
|
||||
|
||||
plan_rounds: list[dict] = []
|
||||
judgment_requests: list[dict] = []
|
||||
|
||||
print(f"[Phase 1] Scoring {n_rounds} rounds × {n_users} users × {len(policies)} policies…")
|
||||
|
||||
with httpx.Client(trust_env=False) as client:
|
||||
for rnd in range(n_rounds):
|
||||
hour = rng.randint(6, 22)
|
||||
dow = rng.randint(0, 6)
|
||||
round_sessions: list[dict] = []
|
||||
|
||||
for user_id, persona in user_personas:
|
||||
seed_tasks = rnd * 997 + abs(hash(user_id)) % 997
|
||||
tasks = generate_task_pool(n=tasks_per_round, seed=seed_tasks)
|
||||
|
||||
for policy in policies:
|
||||
p_user = f"{user_id}-{policy}"
|
||||
scored = _call_score(client, ml_url, policy, p_user, tasks, hour, dow)
|
||||
if not scored:
|
||||
continue
|
||||
tip_id = scored.get("tip_id")
|
||||
tip = next((t for t in tasks if t["id"] == tip_id), None)
|
||||
if not tip:
|
||||
continue
|
||||
|
||||
req_id = f"r{rnd}_{user_id.split('-')[-1]}_{policy}"
|
||||
round_sessions.append({
|
||||
"req_id": req_id,
|
||||
"p_user": p_user,
|
||||
"policy": policy,
|
||||
"user_id": user_id,
|
||||
"persona_name": persona.name,
|
||||
"tip_id": tip_id,
|
||||
"tip_features": tip["features"],
|
||||
"tip_content": tip["content"],
|
||||
"ml_score": scored.get("score"),
|
||||
})
|
||||
|
||||
judgment_requests.append({
|
||||
"id": req_id,
|
||||
"round": rnd,
|
||||
"hour": hour,
|
||||
"day_of_week": dow,
|
||||
"policy": policy,
|
||||
"persona_name": persona.name,
|
||||
"persona_description": persona.description,
|
||||
"tip_content": tip["content"],
|
||||
"priority": tip["features"]["priority"],
|
||||
"is_overdue": tip["features"]["is_overdue"],
|
||||
"age_days": tip["features"]["task_age_days"],
|
||||
"ml_score": scored.get("score"),
|
||||
})
|
||||
|
||||
plan_rounds.append({
|
||||
"round": rnd, "hour": hour, "dow": dow,
|
||||
"sessions": round_sessions,
|
||||
})
|
||||
print(f" Round {rnd+1:>3}/{n_rounds}: {len(round_sessions)} sessions scored")
|
||||
|
||||
plan = {
|
||||
"run_id": run_id,
|
||||
"started_at": started_at,
|
||||
"config": {
|
||||
"n_users": n_users, "n_rounds": n_rounds,
|
||||
"tasks_per_round": tasks_per_round, "policies": policies,
|
||||
"use_llm": False, "seed": seed,
|
||||
},
|
||||
"user_personas": [
|
||||
{"user_id": uid, "persona_name": p.name, "persona_description": p.description}
|
||||
for uid, p in user_personas
|
||||
],
|
||||
"rounds": plan_rounds,
|
||||
}
|
||||
|
||||
base = out_path.replace(".json", "")
|
||||
plan_path = f"{base}-plan.json"
|
||||
requests_path = f"{base}-requests.json"
|
||||
responses_path = f"{base}-responses.json"
|
||||
|
||||
Path(plan_path).write_text(json.dumps(plan, indent=2))
|
||||
Path(requests_path).write_text(json.dumps(judgment_requests, indent=2))
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print(f"Phase 1 complete — {len(judgment_requests)} judgment requests.")
|
||||
print()
|
||||
print(f" Requests : {requests_path}")
|
||||
print(f" Plan : {plan_path}")
|
||||
print()
|
||||
print('Claude Code: read the requests file, judge each tip for the persona,')
|
||||
print(f'then write your responses to: {responses_path}')
|
||||
print()
|
||||
print('Response format: { "<id>": "<action>" | { "action": "<action>", "dwell_ms": <int> } }')
|
||||
print('Valid actions: done | snooze | dismiss')
|
||||
print()
|
||||
print('For "done", optionally specify dwell_ms (ms between tip appearing and user acting):')
|
||||
print(' { "r0_u0_linucb-v1": { "action": "done", "dwell_ms": 45000 } } # magic zone')
|
||||
print(' { "r0_u0_linucb-v1": "snooze" } # plain string also ok (uses default 60s dwell for done)')
|
||||
print()
|
||||
print('Reward is inferred from action + dwell_ms:')
|
||||
print(' dismiss → -1.0')
|
||||
print(' snooze → 0.1')
|
||||
print(' done < 15s → -0.3 (stale task)')
|
||||
print(' done 15s–2min → 1.0 (magic!)')
|
||||
print(' done 2–10min → 0.6 (good)')
|
||||
print(' done > 10min → 0.3 (eventually)')
|
||||
print()
|
||||
print('Then run Phase 2:')
|
||||
print(f' python runner.py --judge claude-code --phase reward \\')
|
||||
print(f' --plan {plan_path} --out {out_path}')
|
||||
|
||||
|
||||
# ── Claude Code judge — phase 2: reward ────────────────────────────────────
|
||||
|
||||
def run_reward_phase(plan_path: str, out_path: str, ml_url: str) -> dict:
|
||||
"""Apply Claude Code judgments, send reward signals, compute metrics."""
|
||||
plan = json.loads(Path(plan_path).read_text())
|
||||
base = plan_path.replace("-plan.json", "")
|
||||
responses_path = f"{base}-responses.json"
|
||||
|
||||
if not Path(responses_path).exists():
|
||||
print(f"ERROR: responses file not found: {responses_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
raw_responses = json.loads(Path(responses_path).read_text())
|
||||
|
||||
# Responses can be either { id: "action" } or { id: { action, dwell_ms } }
|
||||
def _parse_response(v) -> tuple[str, int]:
|
||||
if isinstance(v, dict):
|
||||
return v["action"], int(v.get("dwell_ms", 60_000))
|
||||
return str(v), 60_000 # plain string → assume 60s dwell for "done"
|
||||
|
||||
responses: dict[str, tuple[str, int]] = {k: _parse_response(v) for k, v in raw_responses.items()}
|
||||
|
||||
invalid = {k: v[0] for k, v in responses.items() if v[0] not in ACTIONS}
|
||||
if invalid:
|
||||
print(f"ERROR: invalid actions in responses: {invalid}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
policies: list[str] = plan["config"]["policies"]
|
||||
acc: dict[str, dict] = {
|
||||
p: {
|
||||
"total_reward": 0.0, "n_pulls": 0,
|
||||
"cumulative_rewards": [],
|
||||
"action_counts": {a: 0 for a in ACTIONS},
|
||||
}
|
||||
for p in policies
|
||||
}
|
||||
events: list[dict] = []
|
||||
persona_map = {u["user_id"]: u["persona_name"] for u in plan["user_personas"]}
|
||||
missing_responses = 0
|
||||
|
||||
print(f"[Phase 2] Applying {len(responses)} judgments → reward calls…")
|
||||
|
||||
with httpx.Client(trust_env=False) as client:
|
||||
for rnd_data in plan["rounds"]:
|
||||
rnd = rnd_data["round"]
|
||||
round_rewards = {p: 0.0 for p in policies}
|
||||
|
||||
for session in rnd_data["sessions"]:
|
||||
req_id = session["req_id"]
|
||||
resp = responses.get(req_id)
|
||||
if not resp:
|
||||
print(f" [warn] no response for {req_id}, defaulting to snooze")
|
||||
action, dwell_ms = "snooze", 10_000
|
||||
missing_responses += 1
|
||||
else:
|
||||
action, dwell_ms = resp
|
||||
|
||||
reward = infer_reward(action, dwell_ms)
|
||||
_call_reward(
|
||||
client, ml_url, session["policy"], session["p_user"],
|
||||
session["tip_id"], reward,
|
||||
{"hour_of_day": rnd_data["hour"], **session["tip_features"]},
|
||||
day_of_week=rnd_data["dow"],
|
||||
)
|
||||
|
||||
p = session["policy"]
|
||||
acc[p]["total_reward"] += reward
|
||||
acc[p]["n_pulls"] += 1
|
||||
acc[p]["action_counts"][action] += 1
|
||||
round_rewards[p] += reward
|
||||
|
||||
events.append({
|
||||
"round": rnd,
|
||||
"user_id": session["user_id"],
|
||||
"persona": persona_map.get(session["user_id"], "?"),
|
||||
"policy": p,
|
||||
"tip_content": session["tip_content"],
|
||||
"priority": session["tip_features"]["priority"],
|
||||
"is_overdue": session["tip_features"]["is_overdue"],
|
||||
"action": action,
|
||||
"dwell_ms": dwell_ms,
|
||||
"reward": reward,
|
||||
"hour": rnd_data["hour"],
|
||||
"day_of_week": rnd_data["dow"],
|
||||
})
|
||||
|
||||
for p in policies:
|
||||
prev = acc[p]["cumulative_rewards"][-1] if acc[p]["cumulative_rewards"] else 0.0
|
||||
acc[p]["cumulative_rewards"].append(prev + round_rewards[p])
|
||||
|
||||
print(f" Round {rnd+1:>3}/{plan['config']['n_rounds']} [cc] " + " ".join(
|
||||
f"{p}={acc[p]['cumulative_rewards'][-1]:+.2f}" for p in policies
|
||||
))
|
||||
|
||||
if missing_responses:
|
||||
print(f" [warn] {missing_responses} requests had no response (defaulted to snooze)")
|
||||
|
||||
cfg = plan["config"]
|
||||
result = _build_result(
|
||||
plan["run_id"], plan["started_at"], policies, acc, events,
|
||||
cfg["n_users"], cfg["n_rounds"], cfg["tasks_per_round"],
|
||||
use_llm=False, seed=cfg["seed"],
|
||||
)
|
||||
result["judge_mode"] = "claude-code"
|
||||
Path(out_path).write_text(json.dumps(result, indent=2))
|
||||
return result
|
||||
|
||||
|
||||
# ── Shared result builder ───────────────────────────────────────────────────
|
||||
|
||||
def _build_result(
|
||||
run_id: str, started_at: str, policies: list[str],
|
||||
acc: dict, events: list[dict],
|
||||
n_users: int, n_rounds: int, tasks_per_round: int,
|
||||
use_llm: bool, seed: int,
|
||||
) -> dict:
|
||||
summary = {
|
||||
p: {
|
||||
"total_reward": acc[p]["total_reward"],
|
||||
"mean_reward": (
|
||||
acc[p]["total_reward"] / acc[p]["n_pulls"]
|
||||
if acc[p]["n_pulls"] > 0 else 0.0
|
||||
),
|
||||
"n_pulls": acc[p]["n_pulls"],
|
||||
"cumulative_rewards": acc[p]["cumulative_rewards"],
|
||||
"action_counts": acc[p]["action_counts"],
|
||||
}
|
||||
for p in policies
|
||||
}
|
||||
winner = max(policies, key=lambda p: summary[p]["total_reward"])
|
||||
|
||||
persona_breakdown: dict[str, dict] = {}
|
||||
for ev in events:
|
||||
pname = ev["persona"]
|
||||
pol = ev["policy"]
|
||||
persona_breakdown.setdefault(pname, {}).setdefault(pol, {"reward": 0.0, "n": 0})
|
||||
persona_breakdown[pname][pol]["reward"] += ev["reward"]
|
||||
persona_breakdown[pname][pol]["n"] += 1
|
||||
|
||||
return {
|
||||
"run_id": run_id,
|
||||
"started_at": started_at,
|
||||
"finished_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"config": {
|
||||
"n_users": n_users, "n_rounds": n_rounds,
|
||||
"tasks_per_round": tasks_per_round, "policies": policies,
|
||||
"use_llm": use_llm, "seed": seed,
|
||||
},
|
||||
"summary": summary,
|
||||
"winner": winner,
|
||||
"persona_breakdown": persona_breakdown,
|
||||
"events": events,
|
||||
}
|
||||
|
||||
|
||||
# ── CLI ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="oO simulation runner")
|
||||
parser.add_argument("--judge", choices=["rule", "llm", "claude-code"], default="rule")
|
||||
parser.add_argument("--phase", choices=["score", "reward"], default=None,
|
||||
help="For --judge claude-code only")
|
||||
parser.add_argument("--plan", default=None,
|
||||
help="Plan file path (for --judge claude-code --phase reward)")
|
||||
parser.add_argument("--n-users", type=int, default=5)
|
||||
parser.add_argument("--n-rounds", type=int, default=20)
|
||||
parser.add_argument("--tasks-per-round", type=int, default=8)
|
||||
parser.add_argument("--ml-url", default="http://localhost:5001")
|
||||
parser.add_argument("--policies", nargs="+", default=["linucb-v1", "egreedy-v1"])
|
||||
parser.add_argument("--no-llm", action="store_true",
|
||||
help="Alias for --judge rule (backwards compat)")
|
||||
parser.add_argument("--seed", type=int, default=42)
|
||||
parser.add_argument("--out", default=None)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.no_llm:
|
||||
args.judge = "rule"
|
||||
|
||||
out_path = args.out or f"/tmp/oo-sim-{int(time.time())}.json"
|
||||
|
||||
if args.judge == "claude-code":
|
||||
if args.phase == "score":
|
||||
run_score_phase(
|
||||
n_users=args.n_users, n_rounds=args.n_rounds,
|
||||
tasks_per_round=args.tasks_per_round, ml_url=args.ml_url,
|
||||
policies=args.policies, seed=args.seed, out_path=out_path,
|
||||
)
|
||||
elif args.phase == "reward":
|
||||
if not args.plan:
|
||||
print("ERROR: --plan is required for --phase reward", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
result = run_reward_phase(args.plan, out_path, args.ml_url)
|
||||
print()
|
||||
print(f"Winner : {result['winner']}")
|
||||
for p, s in result["summary"].items():
|
||||
print(f" {p:20s} total={s['total_reward']:+.2f} mean={s['mean_reward']:+.4f} pulls={s['n_pulls']}")
|
||||
print(f"Results: {out_path}")
|
||||
else:
|
||||
print("ERROR: --judge claude-code requires --phase score or --phase reward",
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
else:
|
||||
use_llm = (args.judge == "llm")
|
||||
print(f"oO simulation: {args.n_users} users × {args.n_rounds} rounds")
|
||||
print(f"Policies : {args.policies}")
|
||||
print(f"ML URL : {args.ml_url}")
|
||||
print(f"Judge : {args.judge}")
|
||||
print()
|
||||
|
||||
result = run_simulation(
|
||||
n_users=args.n_users, n_rounds=args.n_rounds,
|
||||
tasks_per_round=args.tasks_per_round, ml_url=args.ml_url,
|
||||
policies=args.policies, use_llm=use_llm, seed=args.seed,
|
||||
)
|
||||
Path(out_path).write_text(json.dumps(result, indent=2))
|
||||
print()
|
||||
print(f"Winner : {result['winner']}")
|
||||
for p, s in result["summary"].items():
|
||||
print(f" {p:20s} total={s['total_reward']:+.2f} mean={s['mean_reward']:+.4f} pulls={s['n_pulls']}")
|
||||
print(f"Results: {out_path}")
|
||||
62
ml/experiments/sim/task_generator.py
Normal file
62
ml/experiments/sim/task_generator.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Generate synthetic task pools for simulation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
|
||||
_TEMPLATES = [
|
||||
"Send weekly report to team",
|
||||
"Review pull request #{n}",
|
||||
"Schedule meeting with {name}",
|
||||
"Update project documentation",
|
||||
"Fix bug in authentication module",
|
||||
"Prepare presentation for stakeholders",
|
||||
"Call back {name}",
|
||||
"Submit expense report",
|
||||
"Review quarterly goals",
|
||||
"Clean up inbox",
|
||||
"Follow up on proposal to {name}",
|
||||
"Complete onboarding checklist",
|
||||
"Write tests for feature #{n}",
|
||||
"Deploy hotfix to production",
|
||||
"Respond to support ticket #{n}",
|
||||
"Draft release notes",
|
||||
"Update dependencies",
|
||||
"Review design mockups",
|
||||
"Archive old tickets",
|
||||
"Check in with {name}",
|
||||
]
|
||||
|
||||
_NAMES = ["Alice", "Bob", "Carol", "David", "Eve", "Frank", "Grace"]
|
||||
|
||||
|
||||
def generate_task_pool(n: int = 10, seed: int | None = None) -> list[dict]:
|
||||
"""Return n synthetic tasks with randomly sampled features."""
|
||||
rng = random.Random(seed)
|
||||
|
||||
tasks = []
|
||||
for i in range(n):
|
||||
priority = rng.choices([1, 2, 3, 4], weights=[0.3, 0.3, 0.25, 0.15])[0]
|
||||
# age_days: most tasks fresh, a few stale
|
||||
age_days = rng.choices(
|
||||
[0.0, 0.5, 1.0, 3.0, 7.0, 14.0],
|
||||
weights=[0.35, 0.20, 0.20, 0.12, 0.08, 0.05],
|
||||
)[0] + rng.random() * 0.5
|
||||
# is_overdue only meaningful when age > 0
|
||||
is_overdue = age_days > 0.5 and rng.random() < 0.65
|
||||
|
||||
template = rng.choice(_TEMPLATES)
|
||||
content = template.format(n=rng.randint(100, 999), name=rng.choice(_NAMES))
|
||||
|
||||
tasks.append({
|
||||
"id": f"sim:{i}",
|
||||
"content": content,
|
||||
"source": "sim",
|
||||
"features": {
|
||||
"is_overdue": is_overdue,
|
||||
"task_age_days": age_days if is_overdue else 0.0,
|
||||
"priority": priority,
|
||||
},
|
||||
})
|
||||
|
||||
return tasks
|
||||
3
ml/features/__init__.py
Normal file
3
ml/features/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .context import build_context, PromptContext, TaskSignal
|
||||
|
||||
__all__ = ["build_context", "PromptContext", "TaskSignal"]
|
||||
63
ml/features/context.py
Normal file
63
ml/features/context.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
Context assembler — converts raw user signals into a PromptContext for LLM tip generation.
|
||||
|
||||
Usage:
|
||||
from ml.features.context import build_context
|
||||
ctx = build_context(tasks, hour_of_day=9, day_of_week=2)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskSignal:
|
||||
id: str
|
||||
content: str
|
||||
priority: int = 1 # 1–4 (Todoist scale)
|
||||
is_overdue: bool = False
|
||||
task_age_days: float = 0.0
|
||||
due_date: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PromptContext:
|
||||
tasks: list[dict] = field(default_factory=list)
|
||||
hour_of_day: int = 12
|
||||
day_of_week: int = 0
|
||||
extra: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
def build_context(
|
||||
tasks: list[TaskSignal],
|
||||
hour_of_day: int = 12,
|
||||
day_of_week: int = 0,
|
||||
extra: dict | None = None,
|
||||
) -> PromptContext:
|
||||
"""
|
||||
Assemble user signals into a PromptContext.
|
||||
|
||||
Signals are sorted so overdue + high-priority tasks appear first,
|
||||
giving the LLM the most actionable context at the top of the prompt.
|
||||
"""
|
||||
sorted_tasks = sorted(
|
||||
tasks,
|
||||
key=lambda t: (not t.is_overdue, -t.priority, -t.task_age_days),
|
||||
)
|
||||
task_dicts = [
|
||||
{
|
||||
"id": t.id,
|
||||
"content": t.content,
|
||||
"priority": t.priority,
|
||||
"is_overdue": t.is_overdue,
|
||||
"task_age_days": round(t.task_age_days, 1),
|
||||
"due_date": t.due_date,
|
||||
}
|
||||
for t in sorted_tasks
|
||||
]
|
||||
return PromptContext(
|
||||
tasks=task_dicts,
|
||||
hour_of_day=hour_of_day,
|
||||
day_of_week=day_of_week,
|
||||
extra=extra or {},
|
||||
)
|
||||
64
ml/features/test_context.py
Normal file
64
ml/features/test_context.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Tests for ml/features/context.py"""
|
||||
import pytest
|
||||
import sys, os; sys.path.insert(0, os.path.dirname(__file__))
|
||||
from context import build_context, TaskSignal, PromptContext
|
||||
|
||||
|
||||
def test_empty_tasks():
|
||||
ctx = build_context([], hour_of_day=9, day_of_week=1)
|
||||
assert ctx.tasks == []
|
||||
assert ctx.hour_of_day == 9
|
||||
assert ctx.day_of_week == 1
|
||||
|
||||
|
||||
def test_overdue_tasks_sorted_first():
|
||||
tasks = [
|
||||
TaskSignal(id="a", content="Normal task", priority=1, is_overdue=False),
|
||||
TaskSignal(id="b", content="Overdue task", priority=2, is_overdue=True, task_age_days=3.0),
|
||||
]
|
||||
ctx = build_context(tasks)
|
||||
assert ctx.tasks[0]["id"] == "b"
|
||||
|
||||
|
||||
def test_high_priority_within_non_overdue():
|
||||
tasks = [
|
||||
TaskSignal(id="lo", content="Low prio", priority=1, is_overdue=False),
|
||||
TaskSignal(id="hi", content="High prio", priority=4, is_overdue=False),
|
||||
]
|
||||
ctx = build_context(tasks)
|
||||
assert ctx.tasks[0]["id"] == "hi"
|
||||
|
||||
|
||||
def test_extra_fields_passed_through():
|
||||
ctx = build_context([], extra={"mood": "focused"})
|
||||
assert ctx.extra["mood"] == "focused"
|
||||
|
||||
|
||||
def test_task_age_rounded():
|
||||
tasks = [TaskSignal(id="x", content="Task", task_age_days=1.23456)]
|
||||
ctx = build_context(tasks)
|
||||
assert ctx.tasks[0]["task_age_days"] == 1.2
|
||||
|
||||
|
||||
def test_overdue_sorted_by_priority():
|
||||
tasks = [
|
||||
TaskSignal(id="lo", content="Low", priority=1, is_overdue=True),
|
||||
TaskSignal(id="hi", content="High", priority=4, is_overdue=True),
|
||||
]
|
||||
ctx = build_context(tasks)
|
||||
assert ctx.tasks[0]["id"] == "hi"
|
||||
|
||||
|
||||
def test_overdue_same_priority_sorted_by_age():
|
||||
tasks = [
|
||||
TaskSignal(id="new", content="New", priority=2, is_overdue=True, task_age_days=1.0),
|
||||
TaskSignal(id="old", content="Old", priority=2, is_overdue=True, task_age_days=5.0),
|
||||
]
|
||||
ctx = build_context(tasks)
|
||||
assert ctx.tasks[0]["id"] == "old"
|
||||
|
||||
|
||||
def test_due_date_none_preserved():
|
||||
tasks = [TaskSignal(id="x", content="No due", due_date=None)]
|
||||
ctx = build_context(tasks)
|
||||
assert ctx.tasks[0]["due_date"] is None
|
||||
556
ml/serving/main.py
Normal file
556
ml/serving/main.py
Normal file
@@ -0,0 +1,556 @@
|
||||
"""
|
||||
oO ML Serving — Phase 1: LinUCB contextual bandit.
|
||||
|
||||
Contract:
|
||||
POST /score { user_id, candidates, context } → { tip_id, score, policy }
|
||||
POST /reward { user_id, tip_id, reward, features } → { ok }
|
||||
POST /reset/{user_id} → { ok }
|
||||
GET /stats/{user_id} → { pulls, cumulative_reward, estimated_mean, last_updated }
|
||||
GET /features/{user_id} → { history: [{ ts, features, score }] }
|
||||
GET /health → { ok }
|
||||
|
||||
Features (d=5):
|
||||
hour_sin, hour_cos — cyclical time-of-day encoding
|
||||
is_overdue — 0 or 1
|
||||
task_age_days — days since due date (clipped 0–30, normalised 0–1)
|
||||
priority_norm — Todoist priority 1–4, normalised to 0–1
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
from typing import Optional, Deque
|
||||
|
||||
import httpx
|
||||
import numpy as np
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
app = FastAPI(title="oO ML Serving", version="1.0.0")
|
||||
|
||||
LITELLM_URL = os.getenv("LITELLM_URL", "http://localhost:4000")
|
||||
LITELLM_MASTER_KEY = os.getenv("LITELLM_MASTER_KEY", "sk-oo-dev")
|
||||
|
||||
STATE_DIR = Path(os.getenv("STATE_DIR", "/tmp/oo-bandit-state"))
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
ALPHA = 1.0 # LinUCB exploration coefficient
|
||||
D = 5 # LinUCB feature dimension
|
||||
D7 = 7 # ε-greedy feature dimension (adds day-of-week cyclical encoding)
|
||||
EPSILON = 0.1 # ε-greedy exploration rate
|
||||
FEATURE_HISTORY_SIZE = 100 # per-user ring buffer
|
||||
|
||||
|
||||
# ── Per-user in-memory feature history ────────────────────────────────────
|
||||
_feature_history: dict[str, deque] = {}
|
||||
|
||||
def get_feature_history(user_id: str) -> deque:
|
||||
if user_id not in _feature_history:
|
||||
_feature_history[user_id] = deque(maxlen=FEATURE_HISTORY_SIZE)
|
||||
return _feature_history[user_id]
|
||||
|
||||
|
||||
# ── Feature helpers ────────────────────────────────────────────────────────
|
||||
|
||||
def build_feature_vector(features: dict) -> np.ndarray:
|
||||
hour = features.get("hour_of_day", 12)
|
||||
hour_sin = math.sin(2 * math.pi * hour / 24)
|
||||
hour_cos = math.cos(2 * math.pi * hour / 24)
|
||||
is_overdue = float(bool(features.get("is_overdue", False)))
|
||||
age = min(float(features.get("task_age_days", 0)), 30.0) / 30.0
|
||||
priority = (float(features.get("priority", 1)) - 1.0) / 3.0
|
||||
return np.array([hour_sin, hour_cos, is_overdue, age, priority], dtype=np.float64)
|
||||
|
||||
|
||||
# ── Per-user bandit state (disjoint LinUCB, global arm) ───────────────────
|
||||
|
||||
# ── LinUCB state helpers ───────────────────────────────────────────────────
|
||||
|
||||
def state_path(user_id: str) -> Path:
|
||||
safe = "".join(c if c.isalnum() else "_" for c in user_id)
|
||||
return STATE_DIR / f"{safe}.json"
|
||||
|
||||
|
||||
def load_state(user_id: str) -> tuple[np.ndarray, np.ndarray, dict]:
|
||||
"""Returns (A, b, meta). A is DxD, b is D-vector."""
|
||||
p = state_path(user_id)
|
||||
if p.exists():
|
||||
raw = json.loads(p.read_text())
|
||||
A = np.array(raw["A"], dtype=np.float64)
|
||||
b = np.array(raw["b"], dtype=np.float64)
|
||||
meta = raw.get("meta", {})
|
||||
return A, b, meta
|
||||
return np.identity(D, dtype=np.float64), np.zeros(D, dtype=np.float64), {}
|
||||
|
||||
|
||||
def save_state(user_id: str, A: np.ndarray, b: np.ndarray, meta: dict) -> None:
|
||||
p = state_path(user_id)
|
||||
p.write_text(json.dumps({"A": A.tolist(), "b": b.tolist(), "meta": meta}))
|
||||
|
||||
|
||||
# ── ε-greedy state helpers (d=7, extended features) ───────────────────────
|
||||
|
||||
def build_feature_vector_7(features: dict, day_of_week: int = 0) -> np.ndarray:
|
||||
"""d=7: base 5 features + day-of-week cyclical encoding."""
|
||||
base = build_feature_vector(features)
|
||||
dow_sin = math.sin(2 * math.pi * day_of_week / 7)
|
||||
dow_cos = math.cos(2 * math.pi * day_of_week / 7)
|
||||
return np.append(base, [dow_sin, dow_cos])
|
||||
|
||||
|
||||
def state7_path(user_id: str) -> Path:
|
||||
safe = "".join(c if c.isalnum() else "_" for c in user_id)
|
||||
return STATE_DIR / f"{safe}_egreedy.json"
|
||||
|
||||
|
||||
def load_state7(user_id: str) -> tuple[np.ndarray, np.ndarray, dict]:
|
||||
"""Returns (A, b, meta) for ε-greedy d=7 policy."""
|
||||
p = state7_path(user_id)
|
||||
if p.exists():
|
||||
raw = json.loads(p.read_text())
|
||||
A = np.array(raw["A"], dtype=np.float64)
|
||||
b = np.array(raw["b"], dtype=np.float64)
|
||||
return A, b, raw.get("meta", {})
|
||||
return np.identity(D7, dtype=np.float64), np.zeros(D7, dtype=np.float64), {}
|
||||
|
||||
|
||||
def save_state7(user_id: str, A: np.ndarray, b: np.ndarray, meta: dict) -> None:
|
||||
p = state7_path(user_id)
|
||||
p.write_text(json.dumps({"A": A.tolist(), "b": b.tolist(), "meta": meta}))
|
||||
|
||||
|
||||
# ── API models ─────────────────────────────────────────────────────────────
|
||||
|
||||
class CandidateFeatures(BaseModel):
|
||||
hour_of_day: int = 12
|
||||
is_overdue: bool = False
|
||||
task_age_days: float = 0.0
|
||||
priority: int = 1
|
||||
|
||||
|
||||
class Candidate(BaseModel):
|
||||
id: str
|
||||
content: str
|
||||
source: str
|
||||
source_id: Optional[str] = None
|
||||
features: CandidateFeatures = CandidateFeatures()
|
||||
|
||||
|
||||
class Context(BaseModel):
|
||||
hour_of_day: int = 12
|
||||
day_of_week: int = 0
|
||||
|
||||
|
||||
class ScoreRequest(BaseModel):
|
||||
user_id: str
|
||||
candidates: list[Candidate]
|
||||
context: Context = Context()
|
||||
|
||||
|
||||
class ScoreResponse(BaseModel):
|
||||
tip_id: str
|
||||
score: float
|
||||
policy: str
|
||||
|
||||
|
||||
class RewardRequest(BaseModel):
|
||||
user_id: str
|
||||
tip_id: str
|
||||
reward: float # +1 done, +0.5 helpful, 0 snooze, -0.5 not_helpful, -1 dismiss
|
||||
features: CandidateFeatures
|
||||
day_of_week: int = 0 # included so egreedy can train dow features correctly
|
||||
|
||||
|
||||
class RewardResponse(BaseModel):
|
||||
ok: bool
|
||||
|
||||
|
||||
class PromptContext(BaseModel):
|
||||
tasks: list[dict] = []
|
||||
hour_of_day: int = 12
|
||||
day_of_week: int = 0
|
||||
extra: dict = {}
|
||||
|
||||
|
||||
class GenerateRequest(BaseModel):
|
||||
user_id: str
|
||||
context: PromptContext = PromptContext()
|
||||
n: int = 3
|
||||
|
||||
|
||||
class TipCandidate(BaseModel):
|
||||
id: str
|
||||
content: str
|
||||
source: str = "llm"
|
||||
rationale: Optional[str] = None
|
||||
|
||||
|
||||
class GenerateResponse(BaseModel):
|
||||
candidates: list[TipCandidate]
|
||||
model: str
|
||||
prompt_tokens: int = 0
|
||||
completion_tokens: int = 0
|
||||
|
||||
|
||||
_GENERATE_SYSTEM = (
|
||||
"You are a personal productivity coach. "
|
||||
"Given the user's current context, generate actionable, specific tips. "
|
||||
"Respond ONLY with a JSON array of objects, each with keys: "
|
||||
'"id" (short slug), "content" (the tip, ≤2 sentences), "rationale" (why now, ≤1 sentence). '
|
||||
"No markdown, no prose outside the JSON array."
|
||||
)
|
||||
|
||||
|
||||
def _build_prompt(ctx: PromptContext, n: int) -> str:
|
||||
lines = [f"Time: {ctx.hour_of_day:02d}:00, day_of_week={ctx.day_of_week}"]
|
||||
if ctx.tasks:
|
||||
overdue = [t for t in ctx.tasks if t.get("is_overdue")]
|
||||
lines.append(f"Tasks: {len(ctx.tasks)} total, {len(overdue)} overdue")
|
||||
for t in ctx.tasks[:5]:
|
||||
due = t.get("due_date", "no due date")
|
||||
lines.append(f" - [{t.get('priority','?')}] {t.get('content','?')} (due: {due})")
|
||||
for k, v in ctx.extra.items():
|
||||
lines.append(f"{k}: {v}")
|
||||
lines.append(f"\nGenerate {n} tips as a JSON array.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ── Endpoints ──────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
_RETRY_SUFFIX = (
|
||||
"\n\nYour previous response was not valid JSON. "
|
||||
"Reply ONLY with the JSON array — no prose, no markdown fences."
|
||||
)
|
||||
|
||||
_MAX_GENERATE_RETRIES = 2
|
||||
|
||||
|
||||
def _parse_llm_json(raw: str) -> list[dict]:
|
||||
"""Strip markdown fences and parse JSON array. Raises ValueError on failure."""
|
||||
text = raw.strip()
|
||||
if text.startswith("```"):
|
||||
parts = text.split("```")
|
||||
text = parts[1] if len(parts) > 1 else text
|
||||
if text.startswith("json"):
|
||||
text = text[4:]
|
||||
return json.loads(text)
|
||||
|
||||
|
||||
@app.post("/generate", response_model=GenerateResponse)
|
||||
async def generate(req: GenerateRequest) -> GenerateResponse:
|
||||
"""Generate tip candidates via LiteLLM → tip-generator alias.
|
||||
|
||||
Retries up to _MAX_GENERATE_RETRIES times on malformed JSON, appending
|
||||
a correction hint to the conversation so the model can self-correct.
|
||||
"""
|
||||
prompt = _build_prompt(req.context, req.n)
|
||||
messages: list[dict] = [
|
||||
{"role": "system", "content": _GENERATE_SYSTEM},
|
||||
{"role": "user", "content": prompt},
|
||||
]
|
||||
headers = {"Authorization": f"Bearer {LITELLM_MASTER_KEY}"}
|
||||
last_parse_error: str = ""
|
||||
last_raw: str = ""
|
||||
total_usage: dict = {"prompt_tokens": 0, "completion_tokens": 0}
|
||||
model_used = "tip-generator"
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
for attempt in range(1 + _MAX_GENERATE_RETRIES):
|
||||
payload = {"model": "tip-generator", "messages": messages, "temperature": 0.7}
|
||||
try:
|
||||
resp = await client.post(
|
||||
f"{LITELLM_URL}/chat/completions",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise HTTPException(status_code=502, detail=f"LiteLLM error: {e.response.text}")
|
||||
except httpx.RequestError as e:
|
||||
raise HTTPException(status_code=503, detail=f"LiteLLM unreachable: {e}")
|
||||
|
||||
data = resp.json()
|
||||
usage = data.get("usage", {})
|
||||
total_usage["prompt_tokens"] += usage.get("prompt_tokens", 0)
|
||||
total_usage["completion_tokens"] += usage.get("completion_tokens", 0)
|
||||
model_used = data.get("model", "tip-generator")
|
||||
|
||||
last_raw = data["choices"][0]["message"]["content"]
|
||||
try:
|
||||
items = _parse_llm_json(last_raw)
|
||||
break
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
last_parse_error = str(e)
|
||||
# Feed the bad reply back so the model can self-correct
|
||||
messages.append({"role": "assistant", "content": last_raw})
|
||||
messages.append({"role": "user", "content": _RETRY_SUFFIX})
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"LLM returned invalid JSON after {_MAX_GENERATE_RETRIES} retries: "
|
||||
f"{last_parse_error}\n{last_raw[:200]}",
|
||||
)
|
||||
|
||||
candidates = [
|
||||
TipCandidate(
|
||||
id=item.get("id", f"tip-{i}"),
|
||||
content=item.get("content", ""),
|
||||
rationale=item.get("rationale"),
|
||||
)
|
||||
for i, item in enumerate(items)
|
||||
]
|
||||
|
||||
return GenerateResponse(
|
||||
candidates=candidates,
|
||||
model=model_used,
|
||||
prompt_tokens=total_usage["prompt_tokens"],
|
||||
completion_tokens=total_usage["completion_tokens"],
|
||||
)
|
||||
|
||||
|
||||
@app.post("/score", response_model=ScoreResponse)
|
||||
def score(req: ScoreRequest) -> ScoreResponse:
|
||||
if not req.candidates:
|
||||
raise HTTPException(status_code=422, detail="No candidates")
|
||||
|
||||
A, b, meta = load_state(req.user_id)
|
||||
try:
|
||||
A_inv = np.linalg.inv(A)
|
||||
except np.linalg.LinAlgError:
|
||||
A_inv = np.identity(D, dtype=np.float64)
|
||||
|
||||
theta = A_inv @ b
|
||||
|
||||
best_id = None
|
||||
best_score = -float("inf")
|
||||
best_features: dict = {}
|
||||
|
||||
for candidate in req.candidates:
|
||||
feat_dict = {
|
||||
"hour_of_day": req.context.hour_of_day,
|
||||
"is_overdue": candidate.features.is_overdue,
|
||||
"task_age_days": candidate.features.task_age_days,
|
||||
"priority": candidate.features.priority,
|
||||
}
|
||||
x = build_feature_vector(feat_dict)
|
||||
exploit = float(theta @ x)
|
||||
explore = ALPHA * math.sqrt(float(x @ A_inv @ x))
|
||||
ucb = exploit + explore
|
||||
if ucb > best_score:
|
||||
best_score = ucb
|
||||
best_id = candidate.id
|
||||
best_features = feat_dict
|
||||
|
||||
# Log to feature history ring buffer
|
||||
history = get_feature_history(req.user_id)
|
||||
history.append({
|
||||
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"features": best_features,
|
||||
"score": best_score,
|
||||
"tip_id": best_id,
|
||||
})
|
||||
|
||||
# Update meta stats
|
||||
meta["pulls"] = meta.get("pulls", 0) + 1
|
||||
meta["last_updated"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
save_state(req.user_id, A, b, meta)
|
||||
|
||||
return ScoreResponse(tip_id=best_id, score=best_score, policy="linucb-v1")
|
||||
|
||||
|
||||
@app.post("/reward", response_model=RewardResponse)
|
||||
def reward(req: RewardRequest) -> RewardResponse:
|
||||
A, b, meta = load_state(req.user_id)
|
||||
feat_dict = {
|
||||
"hour_of_day": req.features.hour_of_day,
|
||||
"is_overdue": req.features.is_overdue,
|
||||
"task_age_days": req.features.task_age_days,
|
||||
"priority": req.features.priority,
|
||||
}
|
||||
x = build_feature_vector(feat_dict)
|
||||
A += np.outer(x, x)
|
||||
b += req.reward * x
|
||||
|
||||
# Track cumulative reward in meta
|
||||
meta["cumulative_reward"] = meta.get("cumulative_reward", 0.0) + req.reward
|
||||
meta["reward_count"] = meta.get("reward_count", 0) + 1
|
||||
meta["last_updated"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
|
||||
save_state(req.user_id, A, b, meta)
|
||||
return RewardResponse(ok=True)
|
||||
|
||||
|
||||
@app.post("/score/egreedy", response_model=ScoreResponse)
|
||||
def score_egreedy(req: ScoreRequest) -> ScoreResponse:
|
||||
"""ε-greedy policy with d=7 features (adds day-of-week encoding).
|
||||
|
||||
Exploration: pick uniformly at random with probability ε.
|
||||
Exploitation: pick argmax of linear payoff estimate θ·x.
|
||||
Differs from LinUCB in: no UCB bonus, richer feature space.
|
||||
"""
|
||||
if not req.candidates:
|
||||
raise HTTPException(status_code=422, detail="No candidates")
|
||||
|
||||
A, b, meta = load_state7(req.user_id)
|
||||
try:
|
||||
A_inv = np.linalg.inv(A)
|
||||
except np.linalg.LinAlgError:
|
||||
A_inv = np.identity(D7, dtype=np.float64)
|
||||
theta = A_inv @ b
|
||||
|
||||
dow = req.context.day_of_week
|
||||
exploring = np.random.random() < EPSILON
|
||||
|
||||
if exploring:
|
||||
chosen = req.candidates[np.random.randint(len(req.candidates))]
|
||||
feat_dict = {
|
||||
"hour_of_day": req.context.hour_of_day,
|
||||
"is_overdue": chosen.features.is_overdue,
|
||||
"task_age_days": chosen.features.task_age_days,
|
||||
"priority": chosen.features.priority,
|
||||
}
|
||||
x = build_feature_vector_7(feat_dict, dow)
|
||||
best_score = float(theta @ x)
|
||||
best_id = chosen.id
|
||||
else:
|
||||
best_id = None
|
||||
best_score = -float("inf")
|
||||
feat_dict = {}
|
||||
for candidate in req.candidates:
|
||||
fd = {
|
||||
"hour_of_day": req.context.hour_of_day,
|
||||
"is_overdue": candidate.features.is_overdue,
|
||||
"task_age_days": candidate.features.task_age_days,
|
||||
"priority": candidate.features.priority,
|
||||
}
|
||||
x = build_feature_vector_7(fd, dow)
|
||||
s = float(theta @ x)
|
||||
if s > best_score:
|
||||
best_score = s
|
||||
best_id = candidate.id
|
||||
feat_dict = fd
|
||||
|
||||
history = get_feature_history(req.user_id)
|
||||
history.append({
|
||||
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"features": {**feat_dict, "day_of_week": dow, "exploring": exploring},
|
||||
"score": best_score,
|
||||
"tip_id": best_id,
|
||||
"policy": "egreedy-v1",
|
||||
})
|
||||
|
||||
meta["pulls"] = meta.get("pulls", 0) + 1
|
||||
meta["explore_count"] = meta.get("explore_count", 0) + int(exploring)
|
||||
meta["last_updated"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
save_state7(req.user_id, A, b, meta)
|
||||
|
||||
return ScoreResponse(tip_id=best_id, score=best_score, policy="egreedy-v1")
|
||||
|
||||
|
||||
@app.post("/reward/egreedy", response_model=RewardResponse)
|
||||
def reward_egreedy(req: RewardRequest) -> RewardResponse:
|
||||
"""Update ε-greedy ridge estimator with observed reward."""
|
||||
A, b, meta = load_state7(req.user_id)
|
||||
feat_dict = {
|
||||
"hour_of_day": req.features.hour_of_day,
|
||||
"is_overdue": req.features.is_overdue,
|
||||
"task_age_days": req.features.task_age_days,
|
||||
"priority": req.features.priority,
|
||||
}
|
||||
x = build_feature_vector_7(feat_dict, day_of_week=req.day_of_week)
|
||||
A += np.outer(x, x)
|
||||
b += req.reward * x
|
||||
|
||||
meta["cumulative_reward"] = meta.get("cumulative_reward", 0.0) + req.reward
|
||||
meta["reward_count"] = meta.get("reward_count", 0) + 1
|
||||
meta["last_updated"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
save_state7(req.user_id, A, b, meta)
|
||||
return RewardResponse(ok=True)
|
||||
|
||||
|
||||
@app.get("/stats/egreedy/{user_id}")
|
||||
def stats_egreedy(user_id: str):
|
||||
"""ε-greedy policy stats — pulls, cumulative reward, θ vector."""
|
||||
A, b, meta = load_state7(user_id)
|
||||
try:
|
||||
theta = (np.linalg.inv(A) @ b).tolist()
|
||||
except np.linalg.LinAlgError:
|
||||
theta = [0.0] * D7
|
||||
|
||||
pulls = meta.get("pulls", 0)
|
||||
cumulative_reward = meta.get("cumulative_reward", 0.0)
|
||||
reward_count = meta.get("reward_count", 0)
|
||||
explore_count = meta.get("explore_count", 0)
|
||||
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"policy": "egreedy-v1",
|
||||
"pulls": pulls,
|
||||
"reward_count": reward_count,
|
||||
"cumulative_reward": cumulative_reward,
|
||||
"estimated_mean_reward": cumulative_reward / reward_count if reward_count > 0 else 0.0,
|
||||
"exploration_rate": explore_count / pulls if pulls > 0 else 0.0,
|
||||
"theta": theta,
|
||||
"feature_labels": ["hour_sin", "hour_cos", "is_overdue", "task_age", "priority", "dow_sin", "dow_cos"],
|
||||
"last_updated": meta.get("last_updated"),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/reset/{user_id}", response_model=RewardResponse)
|
||||
def reset(user_id: str) -> RewardResponse:
|
||||
"""Reset per-user bandit state (admin action)."""
|
||||
p = state_path(user_id)
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
p7 = state7_path(user_id)
|
||||
if p7.exists():
|
||||
p7.unlink()
|
||||
if user_id in _feature_history:
|
||||
_feature_history[user_id].clear()
|
||||
return RewardResponse(ok=True)
|
||||
|
||||
|
||||
@app.get("/stats/{user_id}")
|
||||
def stats(user_id: str):
|
||||
"""Return current LinUCB state summary for a user."""
|
||||
A, b, meta = load_state(user_id)
|
||||
try:
|
||||
A_inv = np.linalg.inv(A)
|
||||
theta = (A_inv @ b).tolist()
|
||||
except np.linalg.LinAlgError:
|
||||
theta = [0.0] * D
|
||||
|
||||
pulls = meta.get("pulls", 0)
|
||||
cumulative_reward = meta.get("cumulative_reward", 0.0)
|
||||
reward_count = meta.get("reward_count", 0)
|
||||
estimated_mean = cumulative_reward / reward_count if reward_count > 0 else 0.0
|
||||
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"pulls": pulls,
|
||||
"reward_count": reward_count,
|
||||
"cumulative_reward": cumulative_reward,
|
||||
"estimated_mean_reward": estimated_mean,
|
||||
"theta": theta,
|
||||
"last_updated": meta.get("last_updated"),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/features/{user_id}")
|
||||
def features(user_id: str):
|
||||
"""Return recent feature vectors logged at scoring time."""
|
||||
history = get_feature_history(user_id)
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"history": list(history),
|
||||
}
|
||||
10
ml/serving/package.json
Normal file
10
ml/serving/package.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "@oo/ml-serving",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": ".venv/bin/uvicorn main:app --reload --port 8000",
|
||||
"start": ".venv/bin/uvicorn main:app --port 8000",
|
||||
"test": ".venv/bin/python -m pytest tests/ -v"
|
||||
}
|
||||
}
|
||||
4
ml/serving/requirements-dev.txt
Normal file
4
ml/serving/requirements-dev.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
-r requirements.txt
|
||||
pytest==8.3.5
|
||||
pytest-asyncio==0.24.0
|
||||
httpx==0.28.1
|
||||
6
ml/serving/requirements.txt
Normal file
6
ml/serving/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.32.1
|
||||
pydantic==2.10.4
|
||||
numpy>=1.26.0
|
||||
httpx>=0.27.0
|
||||
anthropic>=0.40.0
|
||||
0
ml/serving/tests/__init__.py
Normal file
0
ml/serving/tests/__init__.py
Normal file
225
ml/serving/tests/test_generate.py
Normal file
225
ml/serving/tests/test_generate.py
Normal file
@@ -0,0 +1,225 @@
|
||||
"""
|
||||
Tests for POST /generate — LiteLLM gateway.
|
||||
LiteLLM is mocked; no real network calls.
|
||||
"""
|
||||
import json
|
||||
import pytest
|
||||
import httpx
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from httpx import AsyncClient, ASGITransport, Response
|
||||
|
||||
from main import app, _build_prompt, PromptContext
|
||||
|
||||
|
||||
def _litellm_response(candidates: list[dict]) -> Response:
|
||||
import httpx
|
||||
body = {
|
||||
"model": "tip-generator",
|
||||
"choices": [{"message": {"content": json.dumps(candidates)}}],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 20},
|
||||
}
|
||||
req = httpx.Request("POST", "http://litellm/chat/completions")
|
||||
return Response(200, json=body, request=req)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_generate_returns_candidates():
|
||||
fake_items = [
|
||||
{"id": "tip-1", "content": "Do the overdue task now.", "rationale": "It's been waiting."},
|
||||
{"id": "tip-2", "content": "Take a 5-minute break.", "rationale": "You've been working long."},
|
||||
]
|
||||
mock_resp = _litellm_response(fake_items)
|
||||
|
||||
with patch("main.httpx.AsyncClient") as MockClient:
|
||||
instance = AsyncMock()
|
||||
instance.post = AsyncMock(return_value=mock_resp)
|
||||
instance.__aenter__ = AsyncMock(return_value=instance)
|
||||
instance.__aexit__ = AsyncMock(return_value=False)
|
||||
MockClient.return_value = instance
|
||||
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
resp = await client.post("/generate", json={"user_id": "u1", "n": 2})
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["candidates"]) == 2
|
||||
assert data["candidates"][0]["id"] == "tip-1"
|
||||
assert data["model"] == "tip-generator"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_generate_strips_markdown_fence():
|
||||
fake_items = [{"id": "tip-a", "content": "Focus.", "rationale": "Now."}]
|
||||
fenced = "```json\n" + json.dumps(fake_items) + "\n```"
|
||||
body = {
|
||||
"model": "tip-generator",
|
||||
"choices": [{"message": {"content": fenced}}],
|
||||
"usage": {},
|
||||
}
|
||||
req = httpx.Request("POST", "http://litellm/chat/completions")
|
||||
mock_resp = Response(200, json=body, request=req)
|
||||
|
||||
with patch("main.httpx.AsyncClient") as MockClient:
|
||||
instance = AsyncMock()
|
||||
instance.post = AsyncMock(return_value=mock_resp)
|
||||
instance.__aenter__ = AsyncMock(return_value=instance)
|
||||
instance.__aexit__ = AsyncMock(return_value=False)
|
||||
MockClient.return_value = instance
|
||||
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
resp = await client.post("/generate", json={"user_id": "u1"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["candidates"][0]["id"] == "tip-a"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_generate_503_on_unreachable():
|
||||
import httpx as _httpx
|
||||
|
||||
with patch("main.httpx.AsyncClient") as MockClient:
|
||||
instance = AsyncMock()
|
||||
instance.post = AsyncMock(side_effect=_httpx.ConnectError("refused"))
|
||||
instance.__aenter__ = AsyncMock(return_value=instance)
|
||||
instance.__aexit__ = AsyncMock(return_value=False)
|
||||
MockClient.return_value = instance
|
||||
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
resp = await client.post("/generate", json={"user_id": "u1"})
|
||||
|
||||
assert resp.status_code == 503
|
||||
|
||||
|
||||
def test_build_prompt_includes_tasks():
|
||||
ctx = PromptContext(
|
||||
tasks=[{"content": "Write report", "priority": 4, "is_overdue": True, "due_date": "2026-04-15"}],
|
||||
hour_of_day=9,
|
||||
day_of_week=2,
|
||||
)
|
||||
prompt = _build_prompt(ctx, n=3)
|
||||
assert "Write report" in prompt
|
||||
assert "09:00" in prompt
|
||||
assert "Generate 3 tips" in prompt
|
||||
|
||||
|
||||
def test_build_prompt_truncates_at_five():
|
||||
tasks = [{"content": f"Task {i}", "priority": 1, "is_overdue": False, "due_date": None} for i in range(8)]
|
||||
ctx = PromptContext(tasks=tasks, hour_of_day=12)
|
||||
prompt = _build_prompt(ctx, n=2)
|
||||
assert "Task 4" in prompt
|
||||
assert "Task 5" not in prompt
|
||||
|
||||
|
||||
def test_build_prompt_extra_fields():
|
||||
ctx = PromptContext(tasks=[], hour_of_day=8, extra={"mood": "focused", "energy": "high"})
|
||||
prompt = _build_prompt(ctx, n=1)
|
||||
assert "mood: focused" in prompt
|
||||
assert "energy: high" in prompt
|
||||
|
||||
|
||||
def test_build_prompt_empty_tasks_no_task_line():
|
||||
ctx = PromptContext(tasks=[], hour_of_day=10)
|
||||
prompt = _build_prompt(ctx, n=2)
|
||||
assert "Tasks:" not in prompt
|
||||
assert "Generate 2 tips" in prompt
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_generate_retry_succeeds_on_second_attempt():
|
||||
"""First response is invalid JSON; second is valid. Should return 200."""
|
||||
valid_items = [{"id": "tip-ok", "content": "Retry worked.", "rationale": "Second try."}]
|
||||
bad_req = httpx.Request("POST", "http://litellm/chat/completions")
|
||||
bad_resp = Response(200, json={
|
||||
"model": "tip-generator",
|
||||
"choices": [{"message": {"content": "this is not json"}}],
|
||||
"usage": {},
|
||||
}, request=bad_req)
|
||||
good_resp = _litellm_response(valid_items)
|
||||
|
||||
with patch("main.httpx.AsyncClient") as MockClient:
|
||||
instance = AsyncMock()
|
||||
instance.post = AsyncMock(side_effect=[bad_resp, good_resp])
|
||||
instance.__aenter__ = AsyncMock(return_value=instance)
|
||||
instance.__aexit__ = AsyncMock(return_value=False)
|
||||
MockClient.return_value = instance
|
||||
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
resp = await client.post("/generate", json={"user_id": "u1", "n": 1})
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["candidates"][0]["id"] == "tip-ok"
|
||||
assert instance.post.call_count == 2
|
||||
# Retry message should include the correction suffix
|
||||
second_call_messages = instance.post.call_args_list[1][1]["json"]["messages"]
|
||||
assert any("not valid JSON" in m["content"] for m in second_call_messages)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_generate_502_after_all_retries_exhausted():
|
||||
"""All attempts return invalid JSON → 502."""
|
||||
bad_req = httpx.Request("POST", "http://litellm/chat/completions")
|
||||
|
||||
def _bad_resp():
|
||||
return Response(200, json={
|
||||
"model": "tip-generator",
|
||||
"choices": [{"message": {"content": "not json at all"}}],
|
||||
"usage": {},
|
||||
}, request=bad_req)
|
||||
|
||||
from main import _MAX_GENERATE_RETRIES
|
||||
responses = [_bad_resp() for _ in range(1 + _MAX_GENERATE_RETRIES)]
|
||||
|
||||
with patch("main.httpx.AsyncClient") as MockClient:
|
||||
instance = AsyncMock()
|
||||
instance.post = AsyncMock(side_effect=responses)
|
||||
instance.__aenter__ = AsyncMock(return_value=instance)
|
||||
instance.__aexit__ = AsyncMock(return_value=False)
|
||||
MockClient.return_value = instance
|
||||
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
resp = await client.post("/generate", json={"user_id": "u1"})
|
||||
|
||||
assert resp.status_code == 502
|
||||
assert "retries" in resp.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_generate_502_on_upstream_http_error():
|
||||
"""LiteLLM returns 500 → HTTPStatusError → 502."""
|
||||
err_req = httpx.Request("POST", "http://litellm/chat/completions")
|
||||
err_resp = Response(500, text="internal error", request=err_req)
|
||||
|
||||
with patch("main.httpx.AsyncClient") as MockClient:
|
||||
instance = AsyncMock()
|
||||
instance.post = AsyncMock(side_effect=httpx.HTTPStatusError(
|
||||
"500", request=err_req, response=err_resp
|
||||
))
|
||||
instance.__aenter__ = AsyncMock(return_value=instance)
|
||||
instance.__aexit__ = AsyncMock(return_value=False)
|
||||
MockClient.return_value = instance
|
||||
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
resp = await client.post("/generate", json={"user_id": "u1"})
|
||||
|
||||
assert resp.status_code == 502
|
||||
assert "LiteLLM error" in resp.json()["detail"]
|
||||
|
||||
|
||||
def test_parse_llm_json_bare_fence():
|
||||
from main import _parse_llm_json
|
||||
raw = "```\n[{\"id\":\"x\",\"content\":\"hi\"}]\n```"
|
||||
items = _parse_llm_json(raw)
|
||||
assert items[0]["id"] == "x"
|
||||
|
||||
|
||||
def test_parse_llm_json_no_fence():
|
||||
from main import _parse_llm_json
|
||||
raw = '[{"id":"plain","content":"no fence"}]'
|
||||
items = _parse_llm_json(raw)
|
||||
assert items[0]["id"] == "plain"
|
||||
|
||||
|
||||
def test_parse_llm_json_raises_on_invalid():
|
||||
from main import _parse_llm_json
|
||||
with pytest.raises((ValueError, Exception)):
|
||||
_parse_llm_json("this is not json")
|
||||
261
ml/serving/tests/test_score.py
Normal file
261
ml/serving/tests/test_score.py
Normal file
@@ -0,0 +1,261 @@
|
||||
"""
|
||||
Unit tests for ml/serving — feature building and scoring contract.
|
||||
Run with: pytest ml/serving/tests/
|
||||
"""
|
||||
import math
|
||||
import pytest
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
from main import app, build_feature_vector
|
||||
|
||||
|
||||
class TestFeatureVector:
|
||||
def test_shape(self):
|
||||
v = build_feature_vector({"hour_of_day": 8, "is_overdue": True, "task_age_days": 3, "priority": 3})
|
||||
assert v.shape == (5,)
|
||||
|
||||
def test_hour_encoding_noon(self):
|
||||
v = build_feature_vector({"hour_of_day": 12})
|
||||
# sin(2π * 12/24) = sin(π) ≈ 0
|
||||
assert abs(v[0]) < 1e-10
|
||||
# cos(2π * 12/24) = cos(π) = -1
|
||||
assert abs(v[1] - (-1.0)) < 1e-10
|
||||
|
||||
def test_hour_encoding_midnight(self):
|
||||
v = build_feature_vector({"hour_of_day": 0})
|
||||
# sin(0) = 0
|
||||
assert abs(v[0]) < 1e-10
|
||||
# cos(0) = 1
|
||||
assert abs(v[1] - 1.0) < 1e-10
|
||||
|
||||
def test_hour_encoding_6am(self):
|
||||
v = build_feature_vector({"hour_of_day": 6})
|
||||
# sin(2π * 6/24) = sin(π/2) = 1
|
||||
assert abs(v[0] - 1.0) < 1e-10
|
||||
# cos(π/2) = 0
|
||||
assert abs(v[1]) < 1e-10
|
||||
|
||||
def test_age_clipped_at_30(self):
|
||||
v_long = build_feature_vector({"task_age_days": 100})
|
||||
v_cap = build_feature_vector({"task_age_days": 30})
|
||||
assert v_long[3] == v_cap[3] == 1.0
|
||||
|
||||
def test_age_zero(self):
|
||||
v = build_feature_vector({"task_age_days": 0})
|
||||
assert v[3] == pytest.approx(0.0)
|
||||
|
||||
def test_age_15_days_normalised(self):
|
||||
v = build_feature_vector({"task_age_days": 15})
|
||||
assert v[3] == pytest.approx(0.5)
|
||||
|
||||
def test_priority_normalised(self):
|
||||
v1 = build_feature_vector({"priority": 1})
|
||||
v4 = build_feature_vector({"priority": 4})
|
||||
assert v1[4] == pytest.approx(0.0)
|
||||
assert v4[4] == pytest.approx(1.0)
|
||||
|
||||
def test_priority_2_and_3(self):
|
||||
v2 = build_feature_vector({"priority": 2})
|
||||
v3 = build_feature_vector({"priority": 3})
|
||||
assert v2[4] == pytest.approx(1 / 3)
|
||||
assert v3[4] == pytest.approx(2 / 3)
|
||||
|
||||
def test_is_overdue_true(self):
|
||||
v = build_feature_vector({"is_overdue": True})
|
||||
assert v[2] == 1.0
|
||||
|
||||
def test_is_overdue_false(self):
|
||||
v = build_feature_vector({"is_overdue": False})
|
||||
assert v[2] == 0.0
|
||||
|
||||
def test_defaults_when_no_keys(self):
|
||||
v = build_feature_vector({})
|
||||
# hour=12 → sin(π)≈0, cos(π)=-1
|
||||
assert abs(v[0]) < 1e-10
|
||||
assert abs(v[1] - (-1.0)) < 1e-10
|
||||
assert v[2] == 0.0 # is_overdue=False
|
||||
assert v[3] == 0.0 # task_age_days=0
|
||||
assert v[4] == 0.0 # priority=1 → (1-1)/3=0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health():
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
r = await client.get("/health")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["ok"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_score_returns_a_candidate():
|
||||
payload = {
|
||||
"user_id": "test-user",
|
||||
"candidates": [
|
||||
{"id": "t:1", "content": "Task A", "source": "todoist", "source_id": "1",
|
||||
"features": {"is_overdue": True, "task_age_days": 2, "priority": 3}},
|
||||
{"id": "t:2", "content": "Task B", "source": "todoist", "source_id": "2",
|
||||
"features": {"is_overdue": False, "task_age_days": 0, "priority": 1}},
|
||||
],
|
||||
"context": {"hour_of_day": 9, "day_of_week": 1},
|
||||
}
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
r = await client.post("/score", json=payload)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["tip_id"] in {"t:1", "t:2"}
|
||||
assert "policy" in body
|
||||
assert body["policy"] == "linucb-v1"
|
||||
assert isinstance(body["score"], float)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_score_single_candidate_always_selected():
|
||||
"""With a single candidate there is no choice — it must be returned."""
|
||||
payload = {
|
||||
"user_id": "solo-user",
|
||||
"candidates": [
|
||||
{"id": "only:1", "content": "Only task", "source": "todoist",
|
||||
"features": {"is_overdue": False, "task_age_days": 0, "priority": 1}},
|
||||
],
|
||||
"context": {"hour_of_day": 10, "day_of_week": 0},
|
||||
}
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
r = await client.post("/score", json=payload)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["tip_id"] == "only:1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_score_empty_candidates_returns_422():
|
||||
payload = {"user_id": "u", "candidates": [], "context": {"hour_of_day": 9, "day_of_week": 1}}
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
r = await client.post("/score", json=payload)
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reward_accepted():
|
||||
payload = {
|
||||
"user_id": "reward-user",
|
||||
"tip_id": "t:1",
|
||||
"reward": 1.0,
|
||||
"features": {"hour_of_day": 9, "is_overdue": True, "task_age_days": 2, "priority": 3},
|
||||
}
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
r = await client.post("/reward", json=payload)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["ok"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reward_updates_stats():
|
||||
"""Posting a reward should increase cumulative_reward in /stats."""
|
||||
user_id = "reward-stats-user"
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
r0 = await client.get(f"/stats/{user_id}")
|
||||
before = r0.json()["cumulative_reward"]
|
||||
|
||||
await client.post("/reward", json={
|
||||
"user_id": user_id,
|
||||
"tip_id": "tip:x",
|
||||
"reward": 1.0,
|
||||
"features": {"hour_of_day": 8, "is_overdue": False, "task_age_days": 0, "priority": 2},
|
||||
})
|
||||
r1 = await client.get(f"/stats/{user_id}")
|
||||
assert r1.json()["cumulative_reward"] == pytest.approx(before + 1.0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_score_increments_pulls():
|
||||
user_id = "pull-counter-user"
|
||||
payload = {
|
||||
"user_id": user_id,
|
||||
"candidates": [
|
||||
{"id": "t:p1", "content": "Pull task", "source": "todoist",
|
||||
"features": {"is_overdue": False, "task_age_days": 1, "priority": 2}},
|
||||
],
|
||||
"context": {"hour_of_day": 10, "day_of_week": 2},
|
||||
}
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
r0 = await client.get(f"/stats/{user_id}")
|
||||
pulls_before = r0.json()["pulls"]
|
||||
|
||||
await client.post("/score", json=payload)
|
||||
await client.post("/score", json=payload)
|
||||
|
||||
r1 = await client.get(f"/stats/{user_id}")
|
||||
assert r1.json()["pulls"] == pulls_before + 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_clears_state():
|
||||
user_id = "reset-user"
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
# Score once to build state
|
||||
await client.post("/score", json={
|
||||
"user_id": user_id,
|
||||
"candidates": [
|
||||
{"id": "t:r", "content": "Reset task", "source": "todoist",
|
||||
"features": {"is_overdue": True, "task_age_days": 5, "priority": 4}},
|
||||
],
|
||||
"context": {"hour_of_day": 14, "day_of_week": 3},
|
||||
})
|
||||
r_reset = await client.post(f"/reset/{user_id}")
|
||||
assert r_reset.json()["ok"] is True
|
||||
|
||||
r_stats = await client.get(f"/stats/{user_id}")
|
||||
assert r_stats.json()["pulls"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_features_endpoint_returns_history():
|
||||
user_id = "features-user"
|
||||
payload = {
|
||||
"user_id": user_id,
|
||||
"candidates": [
|
||||
{"id": "t:f1", "content": "Feature task", "source": "todoist",
|
||||
"features": {"is_overdue": False, "task_age_days": 0, "priority": 1}},
|
||||
],
|
||||
"context": {"hour_of_day": 7, "day_of_week": 0},
|
||||
}
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
await client.post("/score", json=payload)
|
||||
r = await client.get(f"/features/{user_id}")
|
||||
body = r.json()
|
||||
assert r.status_code == 200
|
||||
assert "history" in body
|
||||
assert len(body["history"]) >= 1
|
||||
entry = body["history"][-1]
|
||||
assert "ts" in entry
|
||||
assert "score" in entry
|
||||
assert "tip_id" in entry
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stats_for_fresh_user():
|
||||
"""A user with no history should return zero/default stats without error."""
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
r = await client.get("/stats/brand-new-user-xyz-abc")
|
||||
body = r.json()
|
||||
assert r.status_code == 200
|
||||
assert body["pulls"] == 0
|
||||
assert body["cumulative_reward"] == 0.0
|
||||
assert body["estimated_mean_reward"] == 0.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reward_negative_value():
|
||||
"""Dismissing a tip should decrease cumulative_reward."""
|
||||
user_id = "dismiss-user-neg"
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
r0 = await client.get(f"/stats/{user_id}")
|
||||
before = r0.json()["cumulative_reward"]
|
||||
|
||||
await client.post("/reward", json={
|
||||
"user_id": user_id,
|
||||
"tip_id": "t:neg",
|
||||
"reward": -1.0,
|
||||
"features": {"hour_of_day": 20, "is_overdue": False, "task_age_days": 0, "priority": 1},
|
||||
})
|
||||
r1 = await client.get(f"/stats/{user_id}")
|
||||
assert r1.json()["cumulative_reward"] == pytest.approx(before - 1.0)
|
||||
22
package.json
Normal file
22
package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "oo-monorepo",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"scripts": {
|
||||
"build": "turbo build",
|
||||
"dev": "turbo dev",
|
||||
"lint": "turbo lint",
|
||||
"type-check": "turbo type-check",
|
||||
"test": "turbo test",
|
||||
"clean": "turbo clean"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": ["better-sqlite3", "esbuild", "sharp"]
|
||||
},
|
||||
"devDependencies": {
|
||||
"turbo": "^2.3.3",
|
||||
"typescript": "^5.7.3",
|
||||
"@types/node": "^22.10.5"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user