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_ID=
GOOGLE_CLIENT_SECRET= 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 OAuth — https://developer.todoist.com/appconsole.html
TODOIST_CLIENT_ID= TODOIST_CLIENT_ID=
TODOIST_CLIENT_SECRET= TODOIST_CLIENT_SECRET=

1
.gitignore vendored
View File

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

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'; 'use client';
import { useEffect, useState, useRef, useCallback } from 'react'; 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'; import type { Tip } from '@oo/shared-types';
type State = 'loading' | 'tip' | 'empty' | 'actions' | 'done'; type State = 'loading' | 'tip' | 'empty' | 'actions' | 'done';
@@ -30,6 +30,7 @@ export default function TipPage() {
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const holdTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const holdTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [pressed, setPressed] = useState(false); const [pressed, setPressed] = useState(false);
const [pushState, setPushState] = useState<'idle' | 'subscribed' | 'denied'>('idle');
// Fade in after state change settles // Fade in after state change settles
useEffect(() => { useEffect(() => {
@@ -60,6 +61,31 @@ export default function TipPage() {
useEffect(() => { loadTip(); }, [loadTip]); 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') => { const react = async (action: 'done' | 'dismiss' | 'snooze') => {
if (!tip) return; if (!tip) return;
setVisible(false); setVisible(false);
@@ -161,6 +187,24 @@ export default function TipPage() {
}}> }}>
hold to act hold to act
</p> </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> </Fade>
)} )}

View File

@@ -62,3 +62,22 @@ export async function deleteAccount() {
export async function logout() { export async function logout() {
return apiFetch<{ ok: boolean }>('/auth/logout', { method: 'POST' }); 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. oO ML Serving — Phase 1: LinUCB contextual bandit.
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).
Contract: Contract:
POST /score POST /score { user_id, candidates, context } → { tip_id, score, policy }
Body: { user_id: str, candidates: [{ id: str, content: str, source: str, source_id?: str }] } POST /reward { user_id, tip_id, reward, features } → { ok }
Response: { tip_id: str, score: float } 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 fastapi import FastAPI, HTTPException
from pydantic import BaseModel 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): class Candidate(BaseModel):
id: str id: str
content: str content: str
source: 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): class ScoreRequest(BaseModel):
user_id: str user_id: str
candidates: list[Candidate] candidates: list[Candidate]
context: Context = Context()
class ScoreResponse(BaseModel): class ScoreResponse(BaseModel):
@@ -35,15 +104,69 @@ class ScoreResponse(BaseModel):
policy: str 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") @app.get("/health")
def health(): def health():
return {"ok": True} return {"ok": True}
@app.post("/score", response_model=ScoreResponse) @app.post("/score", response_model=ScoreResponse)
def score(req: ScoreRequest): def score(req: ScoreRequest) -> ScoreResponse:
if not req.candidates: if not req.candidates:
raise HTTPException(status_code=422, detail="No candidates") raise HTTPException(status_code=422, detail="No candidates")
# Stub: random uniform scoring — real model slots in here
chosen = random.choice(req.candidates) A, b = load_state(req.user_id)
return ScoreResponse(tip_id=chosen.id, score=1.0, policy="stub-random") 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", "version": "0.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "uvicorn main:app --reload --port 8000", "dev": ".venv/bin/uvicorn main:app --reload --port 8000",
"start": "uvicorn main:app --port 8000" "start": ".venv/bin/uvicorn main:app --port 8000"
} }
} }

View File

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

100
pnpm-lock.yaml generated
View File

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

View File

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

View File

@@ -32,4 +32,8 @@ export const config = {
WEB_BASE_URL: optional('WEB_BASE_URL', 'http://localhost:3000'), WEB_BASE_URL: optional('WEB_BASE_URL', 'http://localhost:3000'),
ML_SERVING_URL: optional('ML_SERVING_URL', 'http://localhost:8000'), 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(), 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', { export const sessions = sqliteTable('sessions', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id), 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 { integrationsRouter } from './routes/integrations.js';
import { recommenderRouter } from './routes/recommender.js'; import { recommenderRouter } from './routes/recommender.js';
import { userRouter } from './routes/user.js'; import { userRouter } from './routes/user.js';
import { pushRouter } from './routes/push.js';
import { mkdir } from 'fs/promises'; import { mkdir } from 'fs/promises';
import { dirname } from 'path'; import { dirname } from 'path';
@@ -33,6 +34,7 @@ app.use('/api/auth', authRouter);
app.use('/api/integrations', integrationsRouter); app.use('/api/integrations', integrationsRouter);
app.use('/api', recommenderRouter); app.use('/api', recommenderRouter);
app.use('/api/user', userRouter); app.use('/api/user', userRouter);
app.use('/api/push', pushRouter);
app.listen(config.PORT, () => { app.listen(config.PORT, () => {
console.log(`oO API listening on http://localhost:${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 { integrationTokens, tipFeedback, tipViews } from '../db/schema.js';
import { eq, and } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
import { requireAuth, AuthenticatedRequest } from '../middleware/session.js'; 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'; import type { Tip } from '@oo/shared-types';
const router: ExpressRouter = Router(); const router: ExpressRouter = Router();
const CACHE_TTL_MS = 30_000; // 30 seconds const CACHE_TTL_MS = 30_000;
const taskCache = new Map<string, { tips: Tip[]; fetchedAt: number }>();
/** Fetch active Todoist tasks, with a 30s in-memory cache per user */ interface TaskFeatures {
async function fetchTodoistTasks(userId: string, accessToken: string): Promise<Tip[]> { 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); const cached = taskCache.get(userId);
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) { if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) return cached.tasks;
return cached.tips;
}
const res = await fetch('https://api.todoist.com/api/v1/tasks?filter=today%7Coverdue', { const res = await fetch('https://api.todoist.com/api/v1/tasks?filter=today%7Coverdue', {
headers: { Authorization: `Bearer ${accessToken}` }, 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 body = (await res.json()) as {
const tips: Tip[] = (body.results ?? []).map((t) => ({ results: Array<{
id: `todoist:${t.id}`, id: string;
content: t.content, content: string;
source: 'todoist' as const, priority: number;
sourceId: t.id, due: { date?: string; datetime?: string; is_recurring?: boolean } | null;
createdAt: new Date().toISOString(), }>;
})); };
taskCache.set(userId, { tips, fetchedAt: Date.now() }); const now = new Date();
return tips; 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;
} }
/** /** Call ml/serving for scored selection; returns tip_id or null on failure */
* RandomPolicy — picks one task at random from the candidate set. async function remotePolicy(userId: string, tasks: CachedTask[]): Promise<string | null> {
* Contract: same interface the ML scorer will implement. const hour = new Date().getHours();
*/ const dayOfWeek = new Date().getDay();
function randomPolicy(candidates: Tip[]): Tip | null {
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; if (!candidates.length) return null;
return candidates[Math.floor(Math.random() * candidates.length)]; 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 const [token] = await db
.select() .select()
.from(integrationTokens) .from(integrationTokens)
.where( .where(and(eq(integrationTokens.userId, req.userId!), eq(integrationTokens.provider, 'todoist')))
and(
eq(integrationTokens.userId, req.userId!),
eq(integrationTokens.provider, 'todoist'),
),
)
.limit(1); .limit(1);
if (!token) { if (!token) {
@@ -64,20 +124,31 @@ router.post('/recommend', requireAuth, async (req: AuthenticatedRequest, res: Re
return; return;
} }
const candidates = await fetchTodoistTasks(req.userId!, token.accessToken); const tasks = await fetchTodoistTasks(req.userId!, token.accessToken);
const tip = randomPolicy(candidates); 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) { if (!tip) {
res.status(204).end(); res.status(204).end();
return; return;
} }
// Record metric: tip served const servedAt = new Date().toISOString();
await db.insert(tipViews).values({ await db.insert(tipViews).values({ id: nanoid(), userId: req.userId!, tipId: tip.id, servedAt });
id: nanoid(),
bus.publish('signals.tip.served', {
userId: req.userId!, userId: req.userId!,
tipId: tip.id, tipId: tip.id,
servedAt: new Date().toISOString(), policy: scoredId ? 'linucb-v1' : 'random',
servedAt,
}); });
res.json({ tip }); res.json({ tip });
@@ -102,27 +173,44 @@ router.post('/tip/:id/feedback', requireAuth, async (req: AuthenticatedRequest,
createdAt: new Date().toISOString(), 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!); 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:')) { if (action === 'done' && tipId.startsWith('todoist:')) {
const todoistId = tipId.slice(8); const todoistId = tipId.slice(8);
const [token] = await db const [tok] = await db
.select() .select()
.from(integrationTokens) .from(integrationTokens)
.where( .where(and(eq(integrationTokens.userId, req.userId!), eq(integrationTokens.provider, 'todoist')))
and(
eq(integrationTokens.userId, req.userId!),
eq(integrationTokens.provider, 'todoist'),
),
)
.limit(1); .limit(1);
if (token) { if (tok) {
await fetch(`https://api.todoist.com/api/v1/tasks/${todoistId}/close`, { await fetch(`https://api.todoist.com/api/v1/tasks/${todoistId}/close`, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token.accessToken}` }, headers: { Authorization: `Bearer ${tok.accessToken}` },
}).catch(() => {}); }).catch(() => {});
} }
} }