feat: M1 — LinUCB bandit, RemotePolicy, Web Push, event bus

ML serving:
- LinUCB contextual bandit (disjoint, d=5 features: hour_sin/cos, is_overdue, task_age, priority)
- /score endpoint replaces stub random; /reward endpoint for online learning
- Per-user model state persisted to disk as JSON (survives restarts)
- venv at ml/serving/.venv; start with pnpm dev from ml/serving

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-15 14:08:00 +00:00
parent 08dfa1d8c9
commit c7edd92e15
16 changed files with 648 additions and 75 deletions

View File

@@ -14,6 +14,11 @@ ML_SERVING_URL=http://localhost:8000
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=

1
.gitignore vendored
View File

@@ -11,6 +11,7 @@ build/
__pycache__/
*.pyc
.venv/
__pycache__/
.mypy_cache/
.pytest_cache/
.ruff_cache/

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

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

View File

@@ -1,7 +1,7 @@
'use client';
import { useEffect, useState, useRef, useCallback } from 'react';
import { getRecommendation, sendFeedback } from '@/lib/api';
import { getRecommendation, sendFeedback, getVapidPublicKey, subscribePush } from '@/lib/api';
import type { Tip } from '@oo/shared-types';
type State = 'loading' | 'tip' | 'empty' | 'actions' | 'done';
@@ -30,6 +30,7 @@ export default function TipPage() {
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(() => {
@@ -60,6 +61,31 @@ export default function TipPage() {
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') => {
if (!tip) return;
setVisible(false);
@@ -161,6 +187,24 @@ export default function TipPage() {
}}>
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>
)}

View File

@@ -62,3 +62,22 @@ export async function deleteAccount() {
export async function logout() {
return apiFetch<{ ok: boolean }>('/auth/logout', { method: 'POST' });
}
export async function getVapidPublicKey(): Promise<string> {
const { key } = await apiFetch<{ key: string }>('/push/vapid-public-key');
return key;
}
export async function subscribePush(subscription: PushSubscriptionJSON) {
return apiFetch<{ ok: boolean }>('/push/subscribe', {
method: 'POST',
body: JSON.stringify(subscription),
});
}
export async function unsubscribePush(endpoint: string) {
return apiFetch<{ ok: boolean }>('/push/subscribe', {
method: 'DELETE',
body: JSON.stringify({ endpoint }),
});
}

View File

@@ -1,32 +1,101 @@
"""
oO ML Serving — Phase 0 stub.
Returns a placeholder response that matches the interface the real scorer will implement.
The recommender service calls this via RemotePolicy (not yet wired in Phase 0).
oO ML Serving — Phase 1: LinUCB contextual bandit.
Contract:
POST /score
Body: { user_id: str, candidates: [{ id: str, content: str, source: str, source_id?: str }] }
Response: { tip_id: str, score: float }
POST /score { user_id, candidates, context } → { tip_id, score, policy }
POST /reward { user_id, tip_id, reward, features } → { ok }
GET /health → { ok }
Features (d=5):
hour_sin, hour_cos — cyclical time-of-day encoding
is_overdue — 0 or 1
task_age_days — days since due date (clipped 030, normalised 01)
priority_norm — Todoist priority 14, normalised to 01
"""
from __future__ import annotations
import json
import math
import os
import random
from pathlib import Path
from typing import Optional
import numpy as np
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import random
app = FastAPI(title="oO ML Serving", version="0.0.0")
app = FastAPI(title="oO ML Serving", version="1.0.0")
STATE_DIR = Path(os.getenv("STATE_DIR", "/tmp/oo-bandit-state"))
STATE_DIR.mkdir(parents=True, exist_ok=True)
ALPHA = 1.0 # exploration coefficient
D = 5 # feature dimension
# ── 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) ───────────────────
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]:
"""Returns (A, b). 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)
return A, b
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) -> None:
p = state_path(user_id)
p.write_text(json.dumps({"A": A.tolist(), "b": b.tolist()}))
# ── 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: str | None = None
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):
@@ -35,15 +104,69 @@ class ScoreResponse(BaseModel):
policy: str
class RewardRequest(BaseModel):
user_id: str
tip_id: str
reward: float # +1 done, 0 snooze, -1 dismiss
features: CandidateFeatures
class RewardResponse(BaseModel):
ok: bool
# ── Endpoints ──────────────────────────────────────────────────────────────
@app.get("/health")
def health():
return {"ok": True}
@app.post("/score", response_model=ScoreResponse)
def score(req: ScoreRequest):
def score(req: ScoreRequest) -> ScoreResponse:
if not req.candidates:
raise HTTPException(status_code=422, detail="No candidates")
# Stub: random uniform scoring — real model slots in here
chosen = random.choice(req.candidates)
return ScoreResponse(tip_id=chosen.id, score=1.0, policy="stub-random")
A, b = 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")
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
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 = 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
save_state(req.user_id, A, b)
return RewardResponse(ok=True)

View File

@@ -3,7 +3,7 @@
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "uvicorn main:app --reload --port 8000",
"start": "uvicorn main:app --port 8000"
"dev": ".venv/bin/uvicorn main:app --reload --port 8000",
"start": ".venv/bin/uvicorn main:app --port 8000"
}
}

View File

@@ -1,3 +1,4 @@
fastapi==0.115.6
uvicorn[standard]==0.32.1
pydantic==2.10.4
numpy>=1.26.0

100
pnpm-lock.yaml generated
View File

@@ -89,6 +89,9 @@ importers:
openid-client:
specifier: ^6.3.4
version: 6.8.3
web-push:
specifier: ^3.6.7
version: 3.6.7
zod:
specifier: ^3.24.1
version: 3.25.76
@@ -108,6 +111,9 @@ importers:
'@types/express-session':
specifier: ^1.18.1
version: 1.18.2
'@types/web-push':
specifier: ^3.6.4
version: 3.6.4
drizzle-kit:
specifier: ^0.30.4
version: 0.30.6
@@ -856,13 +862,23 @@ packages:
'@types/serve-static@2.2.0':
resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==}
'@types/web-push@3.6.4':
resolution: {integrity: sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==}
accepts@1.3.8:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
engines: {node: '>= 0.6'}
agent-base@7.1.4:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
array-flatten@1.1.1:
resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==}
asn1.js@5.4.1:
resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
@@ -875,10 +891,16 @@ packages:
bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
bn.js@4.12.3:
resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==}
body-parser@1.20.4:
resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
@@ -1080,6 +1102,9 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
ecdsa-sig-formatter@1.0.11:
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
@@ -1214,6 +1239,14 @@ packages:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'}
http_ece@1.2.0:
resolution: {integrity: sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==}
engines: {node: '>=16'}
https-proxy-agent@7.0.6:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'}
@@ -1238,6 +1271,12 @@ packages:
jose@6.2.2:
resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==}
jwa@2.0.1:
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
jws@4.0.1:
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -1270,6 +1309,9 @@ packages:
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
engines: {node: '>=10'}
minimalistic-assert@1.0.1:
resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==}
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
@@ -1568,6 +1610,11 @@ packages:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
web-push@3.6.7:
resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==}
engines: {node: '>= 16'}
hasBin: true
web-streams-polyfill@3.3.3:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}
@@ -2027,13 +2074,26 @@ snapshots:
'@types/http-errors': 2.0.5
'@types/node': 22.19.17
'@types/web-push@3.6.4':
dependencies:
'@types/node': 22.19.17
accepts@1.3.8:
dependencies:
mime-types: 2.1.35
negotiator: 0.6.3
agent-base@7.1.4: {}
array-flatten@1.1.1: {}
asn1.js@5.4.1:
dependencies:
bn.js: 4.12.3
inherits: 2.0.4
minimalistic-assert: 1.0.1
safer-buffer: 2.1.2
base64-js@1.5.1: {}
better-sqlite3@11.10.0:
@@ -2051,6 +2111,8 @@ snapshots:
inherits: 2.0.4
readable-stream: 3.6.2
bn.js@4.12.3: {}
body-parser@1.20.4:
dependencies:
bytes: 3.1.2
@@ -2068,6 +2130,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
buffer-equal-constant-time@1.0.1: {}
buffer-from@1.1.2: {}
buffer@5.7.1:
@@ -2164,6 +2228,10 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
ecdsa-sig-formatter@1.0.11:
dependencies:
safe-buffer: 5.2.1
ee-first@1.1.1: {}
encodeurl@2.0.0: {}
@@ -2409,6 +2477,15 @@ snapshots:
statuses: 2.0.2
toidentifier: 1.0.1
http_ece@1.2.0: {}
https-proxy-agent@7.0.6:
dependencies:
agent-base: 7.1.4
debug: 4.4.3
transitivePeerDependencies:
- supports-color
iconv-lite@0.4.24:
dependencies:
safer-buffer: 2.1.2
@@ -2425,6 +2502,17 @@ snapshots:
jose@6.2.2: {}
jwa@2.0.1:
dependencies:
buffer-equal-constant-time: 1.0.1
ecdsa-sig-formatter: 1.0.11
safe-buffer: 5.2.1
jws@4.0.1:
dependencies:
jwa: 2.0.1
safe-buffer: 5.2.1
math-intrinsics@1.1.0: {}
media-typer@0.3.0: {}
@@ -2443,6 +2531,8 @@ snapshots:
mimic-response@3.1.0: {}
minimalistic-assert@1.0.1: {}
minimist@1.2.8: {}
mkdirp-classic@0.5.3: {}
@@ -2778,6 +2868,16 @@ snapshots:
vary@1.1.2: {}
web-push@3.6.7:
dependencies:
asn1.js: 5.4.1
http_ece: 1.2.0
https-proxy-agent: 7.0.6
jws: 4.0.1
minimist: 1.2.8
transitivePeerDependencies:
- supports-color
web-streams-polyfill@3.3.3: {}
which@4.0.0:

View File

@@ -13,26 +13,28 @@
},
"dependencies": {
"@oo/shared-types": "workspace:*",
"better-sqlite3": "^11.8.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"drizzle-orm": "^0.38.3",
"express": "^4.21.2",
"express-session": "^1.18.1",
"better-sqlite3": "^11.8.1",
"drizzle-orm": "^0.38.3",
"openid-client": "^6.3.4",
"node-fetch": "^3.3.2",
"dotenv": "^16.4.7",
"zod": "^3.24.1",
"nanoid": "^5.1.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5"
"node-fetch": "^3.3.2",
"openid-client": "^6.3.4",
"web-push": "^3.6.7",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/express-session": "^1.18.1",
"@types/better-sqlite3": "^7.6.12",
"@types/cookie-parser": "^1.4.8",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/express-session": "^1.18.1",
"@types/web-push": "^3.6.4",
"drizzle-kit": "^0.30.4",
"tsx": "^4.19.2",
"typescript": "^5.7.3",
"drizzle-kit": "^0.30.4"
"typescript": "^5.7.3"
}
}

View File

@@ -32,4 +32,8 @@ export const config = {
WEB_BASE_URL: optional('WEB_BASE_URL', 'http://localhost:3000'),
ML_SERVING_URL: optional('ML_SERVING_URL', 'http://localhost:8000'),
VAPID_PUBLIC_KEY: optional('VAPID_PUBLIC_KEY', ''),
VAPID_PRIVATE_KEY: optional('VAPID_PRIVATE_KEY', ''),
VAPID_SUBJECT: optional('VAPID_SUBJECT', 'mailto:admin@localhost'),
};

View File

@@ -39,6 +39,15 @@ export const tipViews = sqliteTable('tip_views', {
servedAt: text('served_at').notNull(),
});
export const pushSubscriptions = sqliteTable('push_subscriptions', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id),
endpoint: text('endpoint').notNull().unique(),
p256dh: text('p256dh').notNull(),
auth: text('auth').notNull(),
createdAt: text('created_at').notNull(),
});
export const sessions = sqliteTable('sessions', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id),

View File

@@ -0,0 +1,52 @@
/**
* EventBus — in-process today, NATS JetStream tomorrow.
*
* To swap to NATS: replace the EventEmitter body with a NATS JetStream
* publish call. Subjects and payload shapes are the durable contract.
*
* Subjects follow the pattern: <domain>.<entity>.<verb>
* signals.tip.served — a tip was returned to the client
* signals.tip.feedback — user reacted (done / dismiss / snooze)
* signals.task.synced — Todoist task list refreshed for a user
*/
import { EventEmitter } from 'events';
export type TipServedEvent = {
userId: string;
tipId: string;
policy: string;
servedAt: string;
};
export type TipFeedbackEvent = {
userId: string;
tipId: string;
action: 'done' | 'dismiss' | 'snooze';
reward: number;
createdAt: string;
};
export type TaskSyncedEvent = {
userId: string;
count: number;
syncedAt: string;
};
type EventMap = {
'signals.tip.served': TipServedEvent;
'signals.tip.feedback': TipFeedbackEvent;
'signals.task.synced': TaskSyncedEvent;
};
class Bus extends EventEmitter {
publish<K extends keyof EventMap>(subject: K, payload: EventMap[K]): void {
this.emit(subject, payload);
}
subscribe<K extends keyof EventMap>(subject: K, handler: (payload: EventMap[K]) => void): void {
this.on(subject, handler);
}
}
export const bus = new Bus();

View File

@@ -9,6 +9,7 @@ import { authRouter } from './routes/auth.js';
import { integrationsRouter } from './routes/integrations.js';
import { recommenderRouter } from './routes/recommender.js';
import { userRouter } from './routes/user.js';
import { pushRouter } from './routes/push.js';
import { mkdir } from 'fs/promises';
import { dirname } from 'path';
@@ -33,6 +34,7 @@ app.use('/api/auth', authRouter);
app.use('/api/integrations', integrationsRouter);
app.use('/api', recommenderRouter);
app.use('/api/user', userRouter);
app.use('/api/push', pushRouter);
app.listen(config.PORT, () => {
console.log(`oO API listening on http://localhost:${config.PORT}`);

View File

@@ -0,0 +1,98 @@
import { type Router as ExpressRouter, Router, Response } from 'express';
import webpush from 'web-push';
import { nanoid } from 'nanoid';
import { db } from '../db/index.js';
import { pushSubscriptions } from '../db/schema.js';
import { eq, and } from 'drizzle-orm';
import { requireAuth, AuthenticatedRequest } from '../middleware/session.js';
import { config } from '../config.js';
const router: ExpressRouter = Router();
if (config.VAPID_PUBLIC_KEY && config.VAPID_PRIVATE_KEY) {
webpush.setVapidDetails(
config.VAPID_SUBJECT,
config.VAPID_PUBLIC_KEY,
config.VAPID_PRIVATE_KEY,
);
}
/** GET /api/push/vapid-public-key — client needs this to subscribe */
router.get('/vapid-public-key', (_req, res: Response) => {
res.json({ key: config.VAPID_PUBLIC_KEY });
});
/** POST /api/push/subscribe — save or refresh a push subscription */
router.post('/subscribe', requireAuth, async (req: AuthenticatedRequest, res: Response) => {
const { endpoint, keys } = req.body as {
endpoint: string;
keys: { p256dh: string; auth: string };
};
if (!endpoint || !keys?.p256dh || !keys?.auth) {
res.status(400).json({ error: 'Invalid subscription' });
return;
}
// Upsert by endpoint
const existing = await db
.select()
.from(pushSubscriptions)
.where(eq(pushSubscriptions.endpoint, endpoint))
.limit(1);
if (existing.length) {
await db
.update(pushSubscriptions)
.set({ p256dh: keys.p256dh, auth: keys.auth })
.where(eq(pushSubscriptions.endpoint, endpoint));
} else {
await db.insert(pushSubscriptions).values({
id: nanoid(),
userId: req.userId!,
endpoint,
p256dh: keys.p256dh,
auth: keys.auth,
createdAt: new Date().toISOString(),
});
}
res.json({ ok: true });
});
/** DELETE /api/push/subscribe — unsubscribe */
router.delete('/subscribe', requireAuth, async (req: AuthenticatedRequest, res: Response) => {
const { endpoint } = req.body as { endpoint: string };
if (endpoint) {
await db
.delete(pushSubscriptions)
.where(
and(
eq(pushSubscriptions.userId, req.userId!),
eq(pushSubscriptions.endpoint, endpoint),
),
);
}
res.json({ ok: true });
});
/** Send a push notification to a user — called internally */
export async function sendPushToUser(userId: string, payload: { title: string; body: string; url?: string }) {
if (!config.VAPID_PUBLIC_KEY) return;
const subs = await db
.select()
.from(pushSubscriptions)
.where(eq(pushSubscriptions.userId, userId));
await Promise.allSettled(
subs.map((sub) =>
webpush.sendNotification(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
JSON.stringify(payload),
).catch(() => {}),
),
);
}
export { router as pushRouter };

View File

@@ -4,44 +4,109 @@ import { db } from '../db/index.js';
import { integrationTokens, tipFeedback, tipViews } from '../db/schema.js';
import { eq, and } from 'drizzle-orm';
import { requireAuth, AuthenticatedRequest } from '../middleware/session.js';
import { config } from '../config.js';
import { bus } from '../events/bus.js';
import type { Tip } from '@oo/shared-types';
const router: ExpressRouter = Router();
const CACHE_TTL_MS = 30_000; // 30 seconds
const taskCache = new Map<string, { tips: Tip[]; fetchedAt: number }>();
const CACHE_TTL_MS = 30_000;
/** Fetch active Todoist tasks, with a 30s in-memory cache per user */
async function fetchTodoistTasks(userId: string, accessToken: string): Promise<Tip[]> {
interface TaskFeatures {
is_overdue: boolean;
task_age_days: number;
priority: number;
}
interface CachedTask extends Tip {
features: TaskFeatures;
}
const taskCache = new Map<string, { tasks: CachedTask[]; fetchedAt: number }>();
/** Parse a Todoist due date string into age in days (relative to now) */
function dueAgeDays(due: { date?: string; datetime?: string } | null | undefined): number {
if (!due) return 0;
const dateStr = due.datetime ?? due.date;
if (!dateStr) return 0;
const dueMs = new Date(dateStr).getTime();
return Math.max(0, (Date.now() - dueMs) / (1000 * 60 * 60 * 24));
}
async function fetchTodoistTasks(userId: string, accessToken: string): Promise<CachedTask[]> {
const cached = taskCache.get(userId);
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
return cached.tips;
}
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) return cached.tasks;
const res = await fetch('https://api.todoist.com/api/v1/tasks?filter=today%7Coverdue', {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) return cached?.tips ?? [];
if (!res.ok) return cached?.tasks ?? [];
const body = (await res.json()) as { results: Array<{ id: string; content: string }> };
const tips: Tip[] = (body.results ?? []).map((t) => ({
id: `todoist:${t.id}`,
content: t.content,
source: 'todoist' as const,
sourceId: t.id,
createdAt: new Date().toISOString(),
}));
const body = (await res.json()) as {
results: Array<{
id: string;
content: string;
priority: number;
due: { date?: string; datetime?: string; is_recurring?: boolean } | null;
}>;
};
taskCache.set(userId, { tips, fetchedAt: Date.now() });
return tips;
const now = new Date();
const tasks: CachedTask[] = (body.results ?? []).map((t) => {
const ageDays = dueAgeDays(t.due);
const isOverdue = ageDays > 0;
return {
id: `todoist:${t.id}`,
content: t.content,
source: 'todoist' as const,
sourceId: t.id,
createdAt: now.toISOString(),
features: {
is_overdue: isOverdue,
task_age_days: ageDays,
priority: t.priority ?? 1,
},
};
});
taskCache.set(userId, { tasks, fetchedAt: Date.now() });
return tasks;
}
/**
* RandomPolicy — picks one task at random from the candidate set.
* Contract: same interface the ML scorer will implement.
*/
function randomPolicy(candidates: Tip[]): Tip | null {
/** Call ml/serving for scored selection; returns tip_id or null on failure */
async function remotePolicy(userId: string, tasks: CachedTask[]): Promise<string | null> {
const hour = new Date().getHours();
const dayOfWeek = new Date().getDay();
const body = {
user_id: userId,
candidates: tasks.map((t) => ({
id: t.id,
content: t.content,
source: t.source,
source_id: t.sourceId ?? null,
features: t.features,
})),
context: { hour_of_day: hour, day_of_week: dayOfWeek },
};
try {
const res = await fetch(`${config.ML_SERVING_URL}/score`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(3000),
});
if (!res.ok) return null;
const { tip_id } = (await res.json()) as { tip_id: string };
return tip_id;
} catch {
return null;
}
}
function randomPolicy(candidates: CachedTask[]): CachedTask | null {
if (!candidates.length) return null;
return candidates[Math.floor(Math.random() * candidates.length)];
}
@@ -51,12 +116,7 @@ router.post('/recommend', requireAuth, async (req: AuthenticatedRequest, res: Re
const [token] = await db
.select()
.from(integrationTokens)
.where(
and(
eq(integrationTokens.userId, req.userId!),
eq(integrationTokens.provider, 'todoist'),
),
)
.where(and(eq(integrationTokens.userId, req.userId!), eq(integrationTokens.provider, 'todoist')))
.limit(1);
if (!token) {
@@ -64,20 +124,31 @@ router.post('/recommend', requireAuth, async (req: AuthenticatedRequest, res: Re
return;
}
const candidates = await fetchTodoistTasks(req.userId!, token.accessToken);
const tip = randomPolicy(candidates);
const tasks = await fetchTodoistTasks(req.userId!, token.accessToken);
if (!tasks.length) {
res.status(204).end();
return;
}
// RemotePolicy with RandomPolicy fallback
const scoredId = await remotePolicy(req.userId!, tasks);
const tip = scoredId
? (tasks.find((t) => t.id === scoredId) ?? randomPolicy(tasks))
: randomPolicy(tasks);
if (!tip) {
res.status(204).end();
return;
}
// Record metric: tip served
await db.insert(tipViews).values({
id: nanoid(),
const servedAt = new Date().toISOString();
await db.insert(tipViews).values({ id: nanoid(), userId: req.userId!, tipId: tip.id, servedAt });
bus.publish('signals.tip.served', {
userId: req.userId!,
tipId: tip.id,
servedAt: new Date().toISOString(),
policy: scoredId ? 'linucb-v1' : 'random',
servedAt,
});
res.json({ tip });
@@ -102,27 +173,44 @@ router.post('/tip/:id/feedback', requireAuth, async (req: AuthenticatedRequest,
createdAt: new Date().toISOString(),
});
// Invalidate cache so next recommend fetches fresh tasks
// Capture task features before clearing cache
const reward = action === 'done' ? 1.0 : action === 'dismiss' ? -1.0 : 0.0;
const task = taskCache.get(req.userId!)?.tasks.find((t) => t.id === tipId);
taskCache.delete(req.userId!);
// If done, mark complete in Todoist
bus.publish('signals.tip.feedback', {
userId: req.userId!,
tipId,
action: action as 'done' | 'dismiss' | 'snooze',
reward,
createdAt: new Date().toISOString(),
});
if (task) {
fetch(`${config.ML_SERVING_URL}/reward`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_id: req.userId!,
tip_id: tipId,
reward,
features: task.features,
}),
}).catch(() => {});
}
// Mark complete in Todoist if done
if (action === 'done' && tipId.startsWith('todoist:')) {
const todoistId = tipId.slice(8);
const [token] = await db
const [tok] = await db
.select()
.from(integrationTokens)
.where(
and(
eq(integrationTokens.userId, req.userId!),
eq(integrationTokens.provider, 'todoist'),
),
)
.where(and(eq(integrationTokens.userId, req.userId!), eq(integrationTokens.provider, 'todoist')))
.limit(1);
if (token) {
if (tok) {
await fetch(`https://api.todoist.com/api/v1/tasks/${todoistId}/close`, {
method: 'POST',
headers: { Authorization: `Bearer ${token.accessToken}` },
headers: { Authorization: `Bearer ${tok.accessToken}` },
}).catch(() => {});
}
}