feat: M1 admin console — all 10 remaining pages + signal/quality/ops infrastructure

Admin console (issues #63–72):
- Event stream viewer: live-tail ring buffer (500 events) with subject/user filters
- Feature store browser: per-user feature vector history from ml/serving
- Model registry panel: MLflow embed at /admin/models
- Experiment dashboard: LinUCB per-user stats (pulls, reward, θ) + bandit reset
- Recommendation log: per-tip explainability (policy, score, features, latency)
- Reward analytics: daily reaction breakdown + per-policy compare
- Data quality widget: missing-feature rate, stale-token rate, daily completeness
- Ops actions: replay-signal, policy enable/disable; user actions link to Users page
- SQL runner: read-only SELECT runner with saved queries
- Health rollup: fan-out to api/ml/sqlite/event-bus with auto-refresh

Backend:
- tip_scores table: logs features+policy+score+latency at every scoring call (#67)
- saved_queries table: per-admin saved SQL (#71)
- Event bus: 500-event ring buffer + tail() API (#63)
- Admin routes: /events, /tips, /reward-analytics, /data-quality, /health,
  /policies, /replay-signal, /sql, /saved-queries endpoints
- /api/ml/* admin-gated proxy to ml/serving (#64, #66)
- Shadow-policy registry in recommender (#56)

ML serving:
- /reset/{user_id}: clear bandit state + feature history (#66)
- /stats/{user_id}: pulls, cumulative reward, estimated mean, θ (#66)
- /features/{user_id}: last 100 feature vectors logged at scoring time (#64)
- Meta (pulls, rewards) persisted alongside A/b matrices

Web:
- Tip action sheet adds Helpful / Not helpful buttons (#62)
- TipFeedback type extended with helpful/not_helpful actions
- Rewards mapped: helpful=+0.5, not_helpful=−0.5

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-16 03:56:48 +00:00
parent 2402a140e9
commit e62c726ea4
37 changed files with 3386 additions and 38 deletions

View File

@@ -0,0 +1,12 @@
import { AdminShell } from '@/components/AdminShell';
import { AuditLog } from '@/components/AuditLog';
export const dynamic = 'force-dynamic';
export default function AuditPage() {
return (
<AdminShell>
<AuditLog />
</AdminShell>
);
}

View File

@@ -0,0 +1,89 @@
'use client';
import { useEffect, useState } from 'react';
import { AdminShell } from '@/components/AdminShell';
import { getDataQuality } from '@/lib/api';
function Pct({ value }: { value: number }) {
const pct = (value * 100).toFixed(1);
const color = value < 0.05 ? 'text-green-400' : value < 0.2 ? 'text-yellow-400' : 'text-red-400';
return <span className={color}>{pct}%</span>;
}
export default function DataQualityPage() {
const [data, setData] = useState<Awaited<ReturnType<typeof getDataQuality>> | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
getDataQuality()
.then(setData)
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, []);
return (
<AdminShell>
<div className="space-y-6">
<h1 className="text-xl font-semibold">Data quality</h1>
{error && <p className="text-red-400 text-sm">{error}</p>}
{loading && <p className="text-gray-500 text-sm">Loading</p>}
{data && (
<>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<div className="bg-gray-900 border border-gray-800 rounded p-4">
<div className="text-xs text-gray-500 mb-1">Scoring calls (30d)</div>
<div className="text-2xl font-semibold">{data.scoringCallsLast30d}</div>
</div>
<div className="bg-gray-900 border border-gray-800 rounded p-4">
<div className="text-xs text-gray-500 mb-1">Missing feature rate</div>
<div className="text-2xl font-semibold"><Pct value={data.missingFeatureRate} /></div>
</div>
<div className="bg-gray-900 border border-gray-800 rounded p-4">
<div className="text-xs text-gray-500 mb-1">Integration tokens</div>
<div className="text-2xl font-semibold">{data.totalTokens}</div>
</div>
<div className="bg-gray-900 border border-gray-800 rounded p-4">
<div className="text-xs text-gray-500 mb-1">Stale token rate (&gt;7d)</div>
<div className="text-2xl font-semibold"><Pct value={data.staleTokenRate} /></div>
</div>
</div>
<div className="space-y-2">
<h2 className="text-sm font-medium text-gray-400">Daily feature completeness (14d)</h2>
<table className="w-full text-xs">
<thead>
<tr className="border-b border-gray-800 text-gray-500 text-left">
<th className="py-2 pr-4">Date</th>
<th className="py-2 pr-4">Scoring calls</th>
<th className="py-2 pr-4">With features</th>
<th className="py-2 pr-4">Coverage</th>
<th className="py-2">Avg candidates</th>
</tr>
</thead>
<tbody>
{data.dailyQuality.map((row) => {
const coverage = row.total > 0 ? row.withFeatures / row.total : 0;
return (
<tr key={row.date} className="border-b border-gray-800/50">
<td className="py-1.5 pr-4 font-mono text-gray-500">{row.date}</td>
<td className="py-1.5 pr-4 text-gray-300">{row.total}</td>
<td className="py-1.5 pr-4 text-gray-300">{row.withFeatures}</td>
<td className="py-1.5 pr-4"><Pct value={coverage} /></td>
<td className="py-1.5 text-gray-300">{row.avgCandidates?.toFixed(1) ?? '—'}</td>
</tr>
);
})}
{data.dailyQuality.length === 0 && (
<tr><td colSpan={5} className="py-4 text-center text-gray-600">No data yet</td></tr>
)}
</tbody>
</table>
</div>
</>
)}
</div>
</AdminShell>
);
}

View File

@@ -0,0 +1,73 @@
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: { category: string; slug: string };
}) {
if (!isDocCategory(params.category)) notFound();
const doc = await getDoc(params.category, params.slug);
if (!doc) notFound();
const categoryLabel = CATEGORY_LABELS[params.category];
return (
<AdminShell>
<div className="max-w-3xl space-y-6">
{/* Breadcrumb */}
<nav className="flex items-center gap-2 text-xs text-gray-500">
<Link href="/docs" className="hover:text-gray-300 transition-colors">
Docs
</Link>
<span>/</span>
<span className="text-gray-400">{categoryLabel}</span>
<span>/</span>
<span className="text-gray-300 truncate">{doc.slug}</span>
</nav>
{/* Meta bar */}
{(doc.status || doc.date) && (
<div className="flex items-center gap-3 text-xs text-gray-500">
{doc.status && (
<span
className={`px-1.5 py-0.5 rounded font-medium ${
doc.status === 'Accepted'
? 'bg-emerald-900 text-emerald-300'
: doc.status === 'Proposed'
? 'bg-yellow-900 text-yellow-300'
: doc.status === 'Deprecated'
? 'bg-red-900 text-red-400'
: 'bg-gray-800 text-gray-400'
}`}
>
{doc.status}
</span>
)}
{doc.date && <span>{doc.date}</span>}
</div>
)}
{/* Markdown body */}
<article
className="prose-doc"
dangerouslySetInnerHTML={{ __html: doc.html }}
/>
</div>
</AdminShell>
);
}

View File

@@ -0,0 +1,82 @@
import Link from 'next/link';
import { AdminShell } from '@/components/AdminShell';
import { listAllDocs, type DocMeta } from '@/lib/docs';
export const dynamic = 'force-dynamic';
function StatusBadge({ status }: { status?: string }) {
if (!status) return null;
const color =
status === 'Accepted'
? 'bg-emerald-900 text-emerald-300'
: status === 'Proposed'
? 'bg-yellow-900 text-yellow-300'
: status === 'Deprecated'
? 'bg-red-900 text-red-400'
: 'bg-gray-800 text-gray-400';
return (
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${color}`}>{status}</span>
);
}
function DocList({ docs, emptyText }: { docs: DocMeta[]; emptyText: string }) {
if (docs.length === 0) {
return <p className="text-sm text-gray-500">{emptyText}</p>;
}
return (
<ul className="divide-y divide-gray-800">
{docs.map((doc) => (
<li key={doc.href}>
<Link
href={doc.href}
className="flex items-center gap-4 px-4 py-3 hover:bg-gray-800 transition-colors rounded"
>
<span className="flex-1 text-sm text-gray-200 leading-snug">{doc.title}</span>
<StatusBadge status={doc.status} />
{doc.date && (
<span className="text-xs text-gray-600 tabular-nums w-24 text-right">
{doc.date}
</span>
)}
</Link>
</li>
))}
</ul>
);
}
export default async function DocsPage() {
const { adr, architecture } = await listAllDocs();
return (
<AdminShell>
<div className="space-y-8 max-w-3xl">
<div>
<h1 className="text-xl font-semibold">Docs</h1>
<p className="text-sm text-gray-500 mt-0.5">
Architecture Decision Records and design notes from{' '}
<code className="text-xs bg-gray-800 px-1 py-0.5 rounded">docs/</code>
</p>
</div>
<section className="space-y-2">
<h2 className="text-xs text-gray-500 uppercase tracking-widest font-medium px-1">
Architecture Decision Records
</h2>
<div className="rounded-lg border border-gray-800 bg-gray-900 overflow-hidden">
<DocList docs={adr} emptyText="No ADRs found." />
</div>
</section>
<section className="space-y-2">
<h2 className="text-xs text-gray-500 uppercase tracking-widest font-medium px-1">
Architecture notes
</h2>
<div className="rounded-lg border border-gray-800 bg-gray-900 overflow-hidden">
<DocList docs={architecture} emptyText="No architecture docs found." />
</div>
</section>
</div>
</AdminShell>
);
}

View File

@@ -0,0 +1,93 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { AdminShell } from '@/components/AdminShell';
import { getEvents, StoredEvent } from '@/lib/api';
const SUBJECTS = ['', 'signals.tip', 'signals.task', 'signals.tip.served', 'signals.tip.feedback', 'signals.task.synced'];
export default function EventsPage() {
const [events, setEvents] = useState<StoredEvent[]>([]);
const [subject, setSubject] = useState('');
const [userId, setUserId] = useState('');
const [live, setLive] = useState(true);
const [error, setError] = useState('');
const sinceRef = useRef(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const fetchEvents = async (reset = false) => {
try {
const since = reset ? 0 : sinceRef.current;
const res = await getEvents({ subject: subject || undefined, userId: userId || undefined, limit: 100, since });
sinceRef.current = res.nextSince;
setEvents((prev) => {
const next = reset ? res.events : [...prev, ...res.events];
return next.slice(-500); // keep last 500
});
setError('');
} catch (e: any) {
setError(e.message);
}
};
useEffect(() => {
sinceRef.current = 0;
fetchEvents(true);
}, [subject, userId]);
useEffect(() => {
if (live) {
timerRef.current = setInterval(() => fetchEvents(false), 2000);
} else if (timerRef.current) {
clearInterval(timerRef.current);
}
return () => { if (timerRef.current) clearInterval(timerRef.current); };
}, [live, subject, userId]);
return (
<AdminShell>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Event stream</h1>
<div className="flex items-center gap-2">
<label className="flex items-center gap-1.5 text-sm text-gray-400 cursor-pointer">
<input type="checkbox" checked={live} onChange={(e) => setLive(e.target.checked)} className="accent-indigo-500" />
Live
</label>
<button onClick={() => { sinceRef.current = 0; fetchEvents(true); }} className="text-xs text-gray-400 hover:text-white border border-gray-700 rounded px-2 py-1">
Refresh
</button>
</div>
</div>
<div className="flex gap-3">
<select value={subject} onChange={(e) => setSubject(e.target.value)} className="bg-gray-900 border border-gray-700 rounded px-2 py-1 text-sm text-gray-300">
{SUBJECTS.map((s) => <option key={s} value={s}>{s || 'All subjects'}</option>)}
</select>
<input
value={userId}
onChange={(e) => setUserId(e.target.value)}
placeholder="Filter by user ID"
className="bg-gray-900 border border-gray-700 rounded px-2 py-1 text-sm text-gray-300 w-64"
/>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
<div className="font-mono text-xs space-y-1 max-h-[70vh] overflow-y-auto">
{events.length === 0 && (
<p className="text-gray-500 text-sm">No events yet. Waiting</p>
)}
{[...events].reverse().map((e) => (
<div key={e.id} className="flex gap-3 border-b border-gray-800 pb-1">
<span className="text-gray-600 w-12 flex-shrink-0">{e.id}</span>
<span className="text-gray-500 w-24 flex-shrink-0">{e.ts.slice(11, 19)}</span>
<span className="text-indigo-400 w-40 flex-shrink-0">{e.subject}</span>
<span className="text-gray-300 break-all">{JSON.stringify(e.payload)}</span>
</div>
))}
</div>
</div>
</AdminShell>
);
}

View File

@@ -0,0 +1,124 @@
'use client';
import { useEffect, useState } from 'react';
import { AdminShell } from '@/components/AdminShell';
import { resetBandit } from '@/lib/api';
interface BanditStats {
user_id: string;
pulls: number;
reward_count: number;
cumulative_reward: number;
estimated_mean_reward: number;
theta: number[];
last_updated: string | null;
}
const FEATURE_LABELS = ['hour_sin', 'hour_cos', 'is_overdue', 'task_age', 'priority'];
export default function ExperimentsPage() {
const [userId, setUserId] = useState('');
const [stats, setStats] = useState<BanditStats | null>(null);
const [loading, setLoading] = useState(false);
const [resetting, setResetting] = useState(false);
const [error, setError] = useState('');
const [resetMsg, setResetMsg] = useState('');
const fetchStats = async () => {
if (!userId.trim()) return;
setLoading(true);
setError('');
try {
const res = await fetch(`/api/ml/stats/${encodeURIComponent(userId.trim())}`, { credentials: 'include' });
if (!res.ok) throw new Error(res.statusText);
setStats(await res.json());
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
};
const handleReset = async () => {
if (!userId.trim()) return;
if (!confirm(`Reset LinUCB state for user ${userId}?`)) return;
setResetting(true);
try {
await resetBandit(userId.trim());
setResetMsg('Bandit state reset.');
setStats(null);
} catch (e: any) {
setError(e.message);
} finally {
setResetting(false);
}
};
return (
<AdminShell>
<div className="space-y-6">
<h1 className="text-xl font-semibold">Experiment dashboard</h1>
<p className="text-sm text-gray-500">LinUCB per-user bandit stats pulled from ml/serving.</p>
<div className="flex gap-2">
<input
value={userId}
onChange={(e) => setUserId(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && fetchStats()}
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={fetchStats} className="bg-indigo-600 hover:bg-indigo-500 text-white rounded px-4 py-1.5 text-sm">
Load
</button>
{stats && (
<button onClick={handleReset} disabled={resetting} className="bg-red-800 hover:bg-red-700 text-white rounded px-4 py-1.5 text-sm disabled:opacity-50">
Reset bandit
</button>
)}
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
{resetMsg && <p className="text-green-400 text-sm">{resetMsg}</p>}
{loading && <p className="text-gray-500 text-sm">Loading</p>}
{stats && (
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<StatCard label="Pulls" value={stats.pulls} />
<StatCard label="Reward samples" value={stats.reward_count} />
<StatCard label="Cumulative reward" value={stats.cumulative_reward.toFixed(2)} />
<StatCard label="Mean reward" value={stats.estimated_mean_reward.toFixed(3)} />
</div>
)}
{stats?.theta && (
<div className="space-y-2">
<h2 className="text-sm font-medium text-gray-400">θ (learned weight vector)</h2>
<div className="flex gap-3 flex-wrap">
{stats.theta.map((v, i) => (
<div key={i} className="bg-gray-900 border border-gray-800 rounded p-3 text-center min-w-[100px]">
<div className="text-xs text-gray-500 mb-1">{FEATURE_LABELS[i] ?? `feat_${i}`}</div>
<div className={`text-sm font-mono ${v > 0 ? 'text-green-400' : v < 0 ? 'text-red-400' : 'text-gray-400'}`}>
{v.toFixed(4)}
</div>
</div>
))}
</div>
{stats.last_updated && (
<p className="text-xs text-gray-600">Last updated: {stats.last_updated}</p>
)}
</div>
)}
</div>
</AdminShell>
);
}
function StatCard({ label, value }: { label: string; value: string | number }) {
return (
<div className="bg-gray-900 border border-gray-800 rounded p-4">
<div className="text-xs text-gray-500 mb-1">{label}</div>
<div className="text-2xl font-semibold text-white">{value}</div>
</div>
);
}

View File

@@ -0,0 +1,98 @@
'use client';
import { useEffect, useState } from 'react';
import { AdminShell } from '@/components/AdminShell';
interface FeatureEntry {
ts: string;
features: Record<string, unknown>;
score: number;
tip_id: string;
}
const FEATURE_NAMES = ['hour_of_day', 'is_overdue', 'task_age_days', 'priority'];
export default function FeaturesPage() {
const [userId, setUserId] = useState('');
const [history, setHistory] = useState<FeatureEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const fetch_ = async () => {
if (!userId.trim()) return;
setLoading(true);
setError('');
try {
const res = await fetch(`/api/ml/features/${encodeURIComponent(userId.trim())}`, { credentials: 'include' });
if (!res.ok) throw new Error(res.statusText);
const data = await res.json();
setHistory(data.history ?? []);
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
};
return (
<AdminShell>
<div className="space-y-4">
<h1 className="text-xl font-semibold">Feature store browser</h1>
<p className="text-sm text-gray-500">
Features sent to ml/serving per scoring call for a user. Shows last 100 entries.
</p>
<div className="flex gap-2">
<input
value={userId}
onChange={(e) => setUserId(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && fetch_()}
placeholder="User ID"
className="bg-gray-900 border border-gray-700 rounded px-3 py-1.5 text-sm text-gray-300 w-80"
/>
<button onClick={fetch_} className="bg-indigo-600 hover:bg-indigo-500 text-white rounded px-4 py-1.5 text-sm">
Load
</button>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
{loading && <p className="text-gray-500 text-sm">Loading</p>}
{history.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-xs font-mono">
<thead>
<tr className="border-b border-gray-800 text-gray-500">
<th className="text-left py-2 pr-4">Time</th>
<th className="text-left py-2 pr-4">Score</th>
{FEATURE_NAMES.map((f) => (
<th key={f} className="text-left py-2 pr-4">{f}</th>
))}
<th className="text-left py-2">Tip ID</th>
</tr>
</thead>
<tbody>
{[...history].reverse().map((entry, i) => (
<tr key={i} className="border-b border-gray-800/50">
<td className="py-1.5 pr-4 text-gray-500">{entry.ts.slice(11, 19)}</td>
<td className="py-1.5 pr-4 text-indigo-300">{entry.score.toFixed(4)}</td>
{FEATURE_NAMES.map((f) => (
<td key={f} className="py-1.5 pr-4 text-gray-300">
{String(entry.features[f] ?? '—')}
</td>
))}
<td className="py-1.5 text-gray-500 truncate max-w-xs">{entry.tip_id}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{history.length === 0 && !loading && userId && (
<p className="text-gray-500 text-sm">No scoring history for this user yet.</p>
)}
</div>
</AdminShell>
);
}

View File

@@ -0,0 +1,10 @@
export default function ForbiddenPage() {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center space-y-2">
<h1 className="text-2xl font-semibold">403 Forbidden</h1>
<p className="text-gray-400 text-sm">Your account does not have admin access.</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,123 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
body {
background: var(--background);
color: var(--foreground);
font-family: system-ui, -apple-system, sans-serif;
}
/* -------------------------------------------------------------------------
Markdown prose — used on /docs pages via the .prose-doc class.
Purposely minimal: dark theme, respects the existing gray palette.
------------------------------------------------------------------------- */
.prose-doc {
color: #d1d5db; /* gray-300 */
line-height: 1.7;
font-size: 0.9375rem;
}
.prose-doc h1,
.prose-doc h2,
.prose-doc h3,
.prose-doc h4 {
color: #f3f4f6; /* gray-100 */
font-weight: 600;
margin-top: 1.75em;
margin-bottom: 0.5em;
line-height: 1.3;
}
.prose-doc h1 { font-size: 1.5rem; margin-top: 0; border-bottom: 1px solid #374151; padding-bottom: 0.4em; }
.prose-doc h2 { font-size: 1.2rem; }
.prose-doc h3 { font-size: 1.05rem; }
.prose-doc h4 { font-size: 0.95rem; }
.prose-doc p { margin-top: 0.75em; margin-bottom: 0.75em; }
.prose-doc p:first-child { margin-top: 0; }
.prose-doc a {
color: #818cf8; /* indigo-400 */
text-decoration: underline;
text-underline-offset: 2px;
}
.prose-doc a:hover { color: #a5b4fc; }
.prose-doc code {
background: #1f2937; /* gray-800 */
border-radius: 3px;
padding: 0.15em 0.4em;
font-size: 0.85em;
color: #e5e7eb;
font-family: ui-monospace, 'Cascadia Code', 'Fira Mono', monospace;
}
.prose-doc pre {
background: #111827; /* gray-900 */
border: 1px solid #374151;
border-radius: 6px;
padding: 1em 1.25em;
overflow-x: auto;
margin: 1.25em 0;
}
.prose-doc pre code {
background: none;
padding: 0;
font-size: 0.85em;
color: #d1d5db;
}
.prose-doc ul,
.prose-doc ol {
padding-left: 1.5em;
margin: 0.75em 0;
}
.prose-doc li { margin: 0.25em 0; }
.prose-doc ul { list-style-type: disc; }
.prose-doc ol { list-style-type: decimal; }
.prose-doc blockquote {
border-left: 3px solid #4b5563;
margin: 1em 0;
padding: 0.25em 1em;
color: #9ca3af;
font-style: italic;
}
.prose-doc table {
width: 100%;
border-collapse: collapse;
margin: 1.25em 0;
font-size: 0.875rem;
}
.prose-doc th {
text-align: left;
padding: 0.5em 0.75em;
background: #1f2937;
color: #9ca3af;
font-weight: 500;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid #374151;
}
.prose-doc td {
padding: 0.5em 0.75em;
border-bottom: 1px solid #1f2937;
vertical-align: top;
}
.prose-doc tr:last-child td { border-bottom: none; }
.prose-doc hr {
border: none;
border-top: 1px solid #374151;
margin: 2em 0;
}
.prose-doc strong { color: #f3f4f6; font-weight: 600; }
.prose-doc em { color: #d1d5db; }

View File

@@ -0,0 +1,71 @@
'use client';
import { useEffect, useState } from 'react';
import { AdminShell } from '@/components/AdminShell';
import { getHealth, HealthStatus } from '@/lib/api';
const STATUS_STYLES: Record<string, string> = {
ok: 'bg-green-900 text-green-300 border-green-800',
degraded: 'bg-yellow-900 text-yellow-300 border-yellow-800',
down: 'bg-red-900 text-red-300 border-red-800',
};
export default function HealthPage() {
const [health, setHealth] = useState<HealthStatus | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const refresh = () => {
setLoading(true);
getHealth()
.then(setHealth)
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
};
useEffect(() => {
refresh();
const t = setInterval(refresh, 15_000);
return () => clearInterval(t);
}, []);
return (
<AdminShell>
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Health</h1>
<div className="flex items-center gap-3">
{health && (
<span className={`text-xs px-2 py-1 rounded border ${health.ok ? 'bg-green-900 text-green-300 border-green-800' : 'bg-red-900 text-red-300 border-red-800'}`}>
{health.ok ? 'All systems operational' : 'Degraded'}
</span>
)}
<button onClick={refresh} className="text-xs text-gray-400 hover:text-white border border-gray-700 rounded px-2 py-1">
Refresh
</button>
</div>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
{loading && !health && <p className="text-gray-500 text-sm">Checking</p>}
{health && (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4">
{health.services.map((svc) => (
<div key={svc.name} className={`rounded border p-4 ${STATUS_STYLES[svc.status] ?? STATUS_STYLES.down}`}>
<div className="text-xs font-medium uppercase tracking-wide mb-1">{svc.name}</div>
<div className="text-lg font-semibold capitalize">{svc.status}</div>
{svc.latencyMs > 0 && (
<div className="text-xs opacity-70 mt-1">{svc.latencyMs}ms</div>
)}
</div>
))}
</div>
<p className="text-xs text-gray-600">Last checked: {health.checkedAt} · auto-refreshes every 15s</p>
</>
)}
</div>
</AdminShell>
);
}

View File

@@ -0,0 +1,15 @@
import type { Metadata } from 'next';
import './globals.css';
export const metadata: Metadata = {
title: 'oO Admin',
description: 'oO admin console',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className="dark">
<body className="min-h-screen bg-gray-950 text-gray-100">{children}</body>
</html>
);
}

View File

@@ -0,0 +1,16 @@
export default function LoginPage() {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center space-y-4">
<h1 className="text-2xl font-semibold">oO Admin</h1>
<p className="text-gray-400 text-sm">Sign in via the main app first, then return here.</p>
<a
href={`${process.env.NEXT_PUBLIC_WEB_URL ?? 'http://localhost:3079'}/sign-in`}
className="inline-block px-4 py-2 bg-white text-black rounded text-sm font-medium hover:bg-gray-200 transition-colors"
>
Sign in with Google
</a>
</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { AdminShell } from '@/components/AdminShell';
export default function ModelsPage() {
const mlflowUrl = process.env.NEXT_PUBLIC_MLFLOW_URL ?? 'http://localhost:5000';
return (
<AdminShell>
<div className="space-y-4 h-[calc(100vh-4rem)]">
<div className="flex items-center justify-between flex-shrink-0">
<h1 className="text-xl font-semibold">Model registry</h1>
<a href={mlflowUrl} target="_blank" rel="noreferrer" className="text-xs text-gray-400 hover:text-white border border-gray-700 rounded px-2 py-1">
Open MLflow
</a>
</div>
<p className="text-sm text-gray-500 flex-shrink-0">
MLflow is embedded below when running under the <code className="text-xs bg-gray-800 px-1 rounded">full</code> compose profile.
Promote or archive model versions via the MLflow UI; each action writes to the audit log automatically.
</p>
<div className="flex-1 rounded border border-gray-800 overflow-hidden" style={{ height: 'calc(100vh - 12rem)' }}>
<iframe
src={`${mlflowUrl}/#/models`}
className="w-full h-full bg-white"
title="MLflow Model Registry"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
/>
</div>
</div>
</AdminShell>
);
}

View File

@@ -0,0 +1,114 @@
'use client';
import { useEffect, useState } from 'react';
import { AdminShell } from '@/components/AdminShell';
import { getPolicies, togglePolicy, replaySignal, PolicyInfo } from '@/lib/api';
const VALID_SUBJECTS = ['signals.tip.served', 'signals.tip.feedback', 'signals.task.synced'];
export default function OpsPage() {
const [policies, setPolicies] = useState<PolicyInfo[]>([]);
const [replaySubject, setReplaySubject] = useState(VALID_SUBJECTS[0]);
const [replayPayload, setReplayPayload] = useState('{\n "userId": "",\n "tipId": ""\n}');
const [msg, setMsg] = useState('');
const [error, setError] = useState('');
useEffect(() => {
getPolicies().then((r) => setPolicies(r.policies)).catch(() => {});
}, []);
const handleToggle = async (name: string, active: boolean) => {
try {
await togglePolicy(name, active);
setPolicies((prev) => prev.map((p) => p.name === name ? { ...p, active } : p));
setMsg(`Policy "${name}" ${active ? 'enabled' : 'disabled'}.`);
} catch (e: any) {
setError(e.message);
}
};
const handleReplay = async () => {
let payload: Record<string, unknown>;
try {
payload = JSON.parse(replayPayload);
} catch {
setError('Invalid JSON payload');
return;
}
try {
await replaySignal(replaySubject, payload);
setMsg(`Signal replayed: ${replaySubject}`);
setError('');
} catch (e: any) {
setError(e.message);
}
};
return (
<AdminShell>
<div className="space-y-8">
<h1 className="text-xl font-semibold">Ops actions</h1>
{msg && <p className="text-green-400 text-sm">{msg}</p>}
{error && <p className="text-red-400 text-sm">{error}</p>}
{/* Policy toggles */}
<section className="space-y-3">
<h2 className="text-base font-medium text-gray-300">Policies</h2>
{policies.length === 0 ? (
<p className="text-gray-500 text-sm">No shadow policies registered. Shadow policies can be added to the recommender source.</p>
) : (
<div className="space-y-2">
{policies.map((p) => (
<div key={p.name} className="flex items-center justify-between bg-gray-900 border border-gray-800 rounded p-3">
<span className="text-sm text-gray-300 font-mono">{p.name}</span>
<button
onClick={() => handleToggle(p.name, !p.active)}
className={`px-3 py-1 rounded text-xs ${p.active ? 'bg-green-800 text-green-200' : 'bg-gray-800 text-gray-400'}`}
>
{p.active ? 'Active' : 'Disabled'}
</button>
</div>
))}
</div>
)}
</section>
{/* Replay signal */}
<section className="space-y-3">
<h2 className="text-base font-medium text-gray-300">Replay signal</h2>
<p className="text-sm text-gray-500">Re-emit a past event on the in-process bus. Useful for backfill and testing.</p>
<div className="space-y-2">
<select
value={replaySubject}
onChange={(e) => setReplaySubject(e.target.value)}
className="bg-gray-900 border border-gray-700 rounded px-3 py-1.5 text-sm text-gray-300 w-full max-w-sm"
>
{VALID_SUBJECTS.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<textarea
value={replayPayload}
onChange={(e) => setReplayPayload(e.target.value)}
rows={6}
className="w-full max-w-xl bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm font-mono text-gray-300"
/>
<button
onClick={handleReplay}
className="bg-indigo-600 hover:bg-indigo-500 text-white rounded px-4 py-1.5 text-sm"
>
Replay
</button>
</div>
</section>
{/* User-level ops */}
<section className="space-y-3">
<h2 className="text-base font-medium text-gray-300">User-level actions</h2>
<p className="text-sm text-gray-500">
Revoke integration tokens and reset bandit state are available on the{' '}
<a href="/users" className="text-indigo-400 hover:underline">Users page</a> navigate to a user detail view.
</p>
</section>
</div>
</AdminShell>
);
}

View File

@@ -0,0 +1,12 @@
import { AdminShell } from '@/components/AdminShell';
import { OverviewDashboard } from '@/components/OverviewDashboard';
export const dynamic = 'force-dynamic';
export default function OverviewPage() {
return (
<AdminShell>
<OverviewDashboard />
</AdminShell>
);
}

View File

@@ -0,0 +1,144 @@
'use client';
import { useEffect, useState } from 'react';
import { AdminShell } from '@/components/AdminShell';
import { getRewardAnalytics } from '@/lib/api';
const ACTION_COLORS: Record<string, string> = {
done: 'bg-green-500',
helpful: 'bg-teal-500',
snooze: 'bg-yellow-500',
not_helpful: 'bg-orange-500',
dismiss: 'bg-red-500',
};
export default function RewardAnalyticsPage() {
const [days, setDays] = useState(30);
const [data, setData] = useState<Awaited<ReturnType<typeof getRewardAnalytics>> | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
setLoading(true);
getRewardAnalytics(days)
.then(setData)
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, [days]);
// Aggregate totals per action across all days
const totals: Record<string, number> = {};
for (const row of data?.daily ?? []) {
totals[row.action] = (totals[row.action] ?? 0) + Number(row.count);
}
const grandTotal = Object.values(totals).reduce((a, b) => a + b, 0);
// Aggregate per policy
const policyMap: Record<string, Record<string, number>> = {};
for (const row of data?.byPolicy ?? []) {
if (!row.policy) continue;
policyMap[row.policy] ??= {};
if (row.action) policyMap[row.policy][row.action] = (policyMap[row.policy][row.action] ?? 0) + Number(row.count);
}
return (
<AdminShell>
<div className="space-y-6">
<div className="flex items-center gap-4">
<h1 className="text-xl font-semibold">Reward analytics</h1>
<select value={days} onChange={(e) => setDays(Number(e.target.value))} className="bg-gray-900 border border-gray-700 rounded px-2 py-1 text-sm text-gray-300">
<option value={7}>Last 7 days</option>
<option value={30}>Last 30 days</option>
<option value={90}>Last 90 days</option>
</select>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
{loading && <p className="text-gray-500 text-sm">Loading</p>}
{/* Reaction breakdown bar */}
{grandTotal > 0 && (
<div className="space-y-2">
<h2 className="text-sm font-medium text-gray-400">Reaction distribution ({grandTotal} total)</h2>
<div className="flex rounded overflow-hidden h-6">
{Object.entries(totals).map(([action, count]) => (
<div
key={action}
title={`${action}: ${count} (${((count / grandTotal) * 100).toFixed(1)}%)`}
className={`${ACTION_COLORS[action] ?? 'bg-gray-500'} transition-all`}
style={{ width: `${(count / grandTotal) * 100}%` }}
/>
))}
</div>
<div className="flex flex-wrap gap-3 text-xs text-gray-400">
{Object.entries(totals).map(([action, count]) => (
<span key={action} className="flex items-center gap-1">
<span className={`inline-block w-2 h-2 rounded-full ${ACTION_COLORS[action] ?? 'bg-gray-500'}`} />
{action}: {count} ({((count / grandTotal) * 100).toFixed(1)}%)
</span>
))}
</div>
</div>
)}
{/* Per-policy table */}
{Object.keys(policyMap).length > 0 && (
<div className="space-y-2">
<h2 className="text-sm font-medium text-gray-400">Per-policy reactions</h2>
<table className="w-full text-xs">
<thead>
<tr className="border-b border-gray-800 text-gray-500 text-left">
<th className="py-2 pr-4">Policy</th>
{['done', 'helpful', 'snooze', 'not_helpful', 'dismiss'].map((a) => (
<th key={a} className="py-2 pr-4">{a}</th>
))}
</tr>
</thead>
<tbody>
{Object.entries(policyMap).map(([policy, actions]) => (
<tr key={policy} className="border-b border-gray-800/50">
<td className="py-2 pr-4 font-medium text-indigo-300">{policy}</td>
{['done', 'helpful', 'snooze', 'not_helpful', 'dismiss'].map((a) => (
<td key={a} className="py-2 pr-4 text-gray-300">{actions[a] ?? 0}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Daily table */}
{(data?.daily?.length ?? 0) > 0 && (
<div className="space-y-2">
<h2 className="text-sm font-medium text-gray-400">Daily breakdown</h2>
<div className="overflow-x-auto max-h-80">
<table className="w-full text-xs font-mono">
<thead>
<tr className="border-b border-gray-800 text-gray-500 text-left">
<th className="py-1.5 pr-4">Date</th>
<th className="py-1.5 pr-4">Action</th>
<th className="py-1.5">Count</th>
</tr>
</thead>
<tbody>
{data!.daily.map((row, i) => (
<tr key={i} className="border-b border-gray-800/40">
<td className="py-1 pr-4 text-gray-500">{row.date}</td>
<td className="py-1 pr-4 text-gray-300">{row.action}</td>
<td className="py-1 text-gray-300">{row.count}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{!loading && grandTotal === 0 && (
<p className="text-gray-500 text-sm">No reaction data in this period.</p>
)}
</div>
</AdminShell>
);
}

View File

@@ -0,0 +1,152 @@
'use client';
import { useEffect, useState } from 'react';
import { AdminShell } from '@/components/AdminShell';
import { runSql, getSavedQueries, saveQuery, deleteSavedQuery, SavedQuery } from '@/lib/api';
const EXAMPLE_QUERIES = [
'SELECT * FROM users ORDER BY created_at DESC LIMIT 20',
'SELECT action, count(*) as cnt FROM tip_feedback GROUP BY action',
'SELECT policy, count(*) as cnt FROM tip_scores GROUP BY policy',
'SELECT date(served_at) as day, count(*) as tips FROM tip_views GROUP BY day ORDER BY day DESC LIMIT 14',
];
export default function SqlPage() {
const [query, setQuery] = useState(EXAMPLE_QUERIES[0]);
const [rows, setRows] = useState<unknown[]>([]);
const [cols, setCols] = useState<string[]>([]);
const [rowCount, setRowCount] = useState<number | null>(null);
const [running, setRunning] = useState(false);
const [error, setError] = useState('');
const [savedQueries, setSavedQueries] = useState<SavedQuery[]>([]);
const [saveName, setSaveName] = useState('');
useEffect(() => {
getSavedQueries().then((r) => setSavedQueries(r.queries)).catch(() => {});
}, []);
const run = async () => {
if (!query.trim()) return;
setRunning(true);
setError('');
try {
const res = await runSql(query);
const r = res.rows as Record<string, unknown>[];
setRows(r);
setRowCount(res.rowCount);
setCols(r.length > 0 ? Object.keys(r[0] as object) : []);
} catch (e: any) {
setError(e.message);
setRows([]);
setRowCount(null);
} finally {
setRunning(false);
}
};
const handleSave = async () => {
if (!saveName.trim()) return;
await saveQuery(saveName, query);
const res = await getSavedQueries();
setSavedQueries(res.queries);
setSaveName('');
};
const handleDelete = async (id: string) => {
await deleteSavedQuery(id);
setSavedQueries((prev) => prev.filter((q) => q.id !== id));
};
return (
<AdminShell>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">SQL runner</h1>
<span className="text-xs text-gray-500">Read-only · SELECT only · sunsets in M4</span>
</div>
<div className="flex gap-4">
{/* Editor */}
<div className="flex-1 space-y-2">
<textarea
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') run(); }}
rows={6}
spellCheck={false}
className="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm font-mono text-gray-200 focus:outline-none focus:border-indigo-500"
placeholder="SELECT ..."
/>
<div className="flex items-center gap-2">
<button
onClick={run}
disabled={running}
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded px-4 py-1.5 text-sm"
>
{running ? 'Running…' : 'Run (⌘↵)'}
</button>
<input
value={saveName}
onChange={(e) => setSaveName(e.target.value)}
placeholder="Save as…"
className="bg-gray-900 border border-gray-700 rounded px-3 py-1.5 text-sm text-gray-300 w-40"
/>
<button onClick={handleSave} className="text-sm text-gray-400 hover:text-white border border-gray-700 rounded px-3 py-1.5">
Save
</button>
</div>
</div>
{/* Saved / examples */}
<div className="w-56 space-y-2 flex-shrink-0">
<p className="text-xs text-gray-500 font-medium uppercase tracking-wide">Saved queries</p>
{savedQueries.length === 0 && (
<p className="text-xs text-gray-600">None saved yet</p>
)}
{savedQueries.map((q) => (
<div key={q.id} className="flex items-start justify-between gap-1">
<button onClick={() => setQuery(q.sql)} className="text-xs text-indigo-400 hover:text-indigo-300 text-left">{q.name}</button>
<button onClick={() => handleDelete(q.id)} className="text-xs text-gray-600 hover:text-red-400"></button>
</div>
))}
<p className="text-xs text-gray-500 font-medium uppercase tracking-wide pt-2">Examples</p>
{EXAMPLE_QUERIES.map((q, i) => (
<button key={i} onClick={() => setQuery(q)} className="block text-xs text-gray-500 hover:text-gray-300 text-left truncate w-full">
{q.slice(0, 40)}
</button>
))}
</div>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
{rowCount !== null && (
<p className="text-xs text-gray-500">{rowCount} rows returned</p>
)}
{cols.length > 0 && (
<div className="overflow-auto max-h-[50vh] border border-gray-800 rounded">
<table className="w-full text-xs font-mono">
<thead className="sticky top-0 bg-gray-950">
<tr className="border-b border-gray-800">
{cols.map((c) => (
<th key={c} className="text-left py-2 px-3 text-gray-500 font-medium">{c}</th>
))}
</tr>
</thead>
<tbody>
{(rows as Record<string, unknown>[]).map((row, i) => (
<tr key={i} className="border-b border-gray-800/40 hover:bg-gray-900/40">
{cols.map((c) => (
<td key={c} className="py-1.5 px-3 text-gray-300">{String(row[c] ?? '')}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</AdminShell>
);
}

View File

@@ -0,0 +1,97 @@
'use client';
import { useEffect, useState } from 'react';
import { AdminShell } from '@/components/AdminShell';
import { getTips, TipScore } from '@/lib/api';
export default function TipsPage() {
const [tips, setTips] = useState<TipScore[]>([]);
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [userId, setUserId] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const LIMIT = 50;
const fetch_ = async (off = 0) => {
setLoading(true);
try {
const res = await getTips({ limit: LIMIT, offset: off, userId: userId || undefined });
setTips(res.tips);
setTotal(res.total);
setOffset(off);
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
};
useEffect(() => { fetch_(0); }, [userId]);
return (
<AdminShell>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Recommendation log</h1>
<span className="text-sm text-gray-500">{total} total</span>
</div>
<div className="flex gap-2">
<input
value={userId}
onChange={(e) => setUserId(e.target.value)}
placeholder="Filter by user ID"
className="bg-gray-900 border border-gray-700 rounded px-3 py-1.5 text-sm text-gray-300 w-72"
/>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-gray-800 text-gray-500 text-left">
<th className="py-2 pr-3">Served at</th>
<th className="py-2 pr-3">User</th>
<th className="py-2 pr-3">Policy</th>
<th className="py-2 pr-3">Score</th>
<th className="py-2 pr-3">Candidates</th>
<th className="py-2 pr-3">Latency</th>
<th className="py-2">Features</th>
</tr>
</thead>
<tbody>
{tips.map((t) => {
const feats = t.featuresJson ? JSON.parse(t.featuresJson) : null;
return (
<tr key={t.id} className="border-b border-gray-800/50 hover:bg-gray-900/50">
<td className="py-1.5 pr-3 text-gray-500 font-mono">{t.servedAt.slice(0, 19)}</td>
<td className="py-1.5 pr-3 text-gray-400 font-mono truncate max-w-[120px]">{t.userId.slice(0, 8)}</td>
<td className="py-1.5 pr-3">
<span className={`px-1.5 py-0.5 rounded text-xs ${t.policy === 'random' ? 'bg-gray-800 text-gray-400' : 'bg-indigo-900 text-indigo-300'}`}>
{t.policy}
</span>
</td>
<td className="py-1.5 pr-3 font-mono text-gray-300">{t.mlScore != null ? (t.mlScore / 1000).toFixed(3) : '—'}</td>
<td className="py-1.5 pr-3 text-gray-400">{t.candidateCount ?? '—'}</td>
<td className="py-1.5 pr-3 text-gray-400">{t.latencyMs != null ? `${t.latencyMs}ms` : '—'}</td>
<td className="py-1.5 text-gray-500 font-mono text-xs">
{feats ? `p${feats.priority} ${feats.is_overdue ? '⚠' : ''}` : '—'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="flex gap-3 items-center text-sm">
<button onClick={() => fetch_(offset - LIMIT)} disabled={offset === 0 || loading} className="text-gray-400 hover:text-white disabled:opacity-30"> Prev</button>
<span className="text-gray-600">{offset + 1}{Math.min(offset + LIMIT, total)} of {total}</span>
<button onClick={() => fetch_(offset + LIMIT)} disabled={offset + LIMIT >= total || loading} className="text-gray-400 hover:text-white disabled:opacity-30">Next </button>
</div>
</div>
</AdminShell>
);
}

View File

@@ -0,0 +1,12 @@
import { AdminShell } from '@/components/AdminShell';
import { UserDetail } from '@/components/UserDetail';
export const dynamic = 'force-dynamic';
export default function UserDetailPage({ params }: { params: { id: string } }) {
return (
<AdminShell>
<UserDetail userId={params.id} />
</AdminShell>
);
}

View File

@@ -0,0 +1,12 @@
import { AdminShell } from '@/components/AdminShell';
import { UsersTable } from '@/components/UsersTable';
export const dynamic = 'force-dynamic';
export default function UsersPage() {
return (
<AdminShell>
<UsersTable />
</AdminShell>
);
}

View File

@@ -0,0 +1,58 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
const NAV = [
{ href: '/', label: 'Overview' },
{ href: '/users', label: 'Users' },
{ href: '/events', label: 'Events' },
{ href: '/features', label: 'Features' },
{ href: '/tips', label: 'Rec log' },
{ href: '/reward-analytics', label: 'Rewards' },
{ href: '/experiments', label: 'Experiments' },
{ href: '/models', label: 'Models' },
{ href: '/data-quality', label: 'Data quality' },
{ href: '/ops', label: 'Ops' },
{ href: '/sql', label: 'SQL runner' },
{ href: '/health', label: 'Health' },
{ href: '/audit', label: 'Audit log' },
{ href: '/docs', label: 'Docs' },
];
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 space-y-0.5">
{NAV.map(({ href, label }) => {
const active = href === '/' ? pathname === '/' : pathname.startsWith(href);
return (
<Link
key={href}
href={href}
className={`flex items-center px-3 py-2 rounded text-sm transition-colors ${
active
? 'bg-gray-800 text-white font-medium'
: 'text-gray-400 hover:text-white hover:bg-gray-900'
}`}
>
{label}
</Link>
);
})}
</nav>
</aside>
{/* Main content */}
<main className="flex-1 overflow-auto p-6">{children}</main>
</div>
);
}

View File

@@ -0,0 +1,112 @@
'use client';
import { useEffect, useState } from 'react';
import { getAuditLog, type AuditAction } from '@/lib/api';
const PAGE_SIZE = 50;
export function AuditLog() {
const [rows, setRows] = useState<AuditAction[]>([]);
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
getAuditLog(PAGE_SIZE, offset)
.then(({ actions, total }) => {
setRows(actions);
setTotal(total);
})
.catch((e) => setError(String(e.message)))
.finally(() => setLoading(false));
}, [offset]);
if (error) return <p className="text-red-400 text-sm">Error: {error}</p>;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Audit log</h1>
<span className="text-sm text-gray-500">{total} entries</span>
</div>
<div className="rounded-lg border border-gray-800 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-900 border-b border-gray-800">
<tr>
{['Time', 'Admin', 'Action', 'Target'].map((h) => (
<th
key={h}
className="text-left px-4 py-2.5 text-xs text-gray-500 font-medium uppercase tracking-wide"
>
{h}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{loading ? (
<tr>
<td colSpan={4} className="px-4 py-6 text-center text-gray-500">
Loading
</td>
</tr>
) : rows.length === 0 ? (
<tr>
<td colSpan={4} className="px-4 py-6 text-center text-gray-500">
No actions logged yet.
</td>
</tr>
) : (
rows.map((a) => (
<tr key={a.id} className="hover:bg-gray-900 transition-colors">
<td className="px-4 py-2.5 text-xs tabular-nums text-gray-400">
{a.createdAt.slice(0, 19).replace('T', ' ')}
</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-300 truncate max-w-[8rem]">
{a.adminId.slice(0, 8)}
</td>
<td className="px-4 py-2.5">
<span className="px-1.5 py-0.5 rounded bg-gray-800 text-xs text-gray-200 font-mono">
{a.action}
</span>
</td>
<td className="px-4 py-2.5 text-xs text-gray-400">
{a.targetType && (
<span className="text-gray-500">{a.targetType}: </span>
)}
<span className="font-mono">{a.targetId?.slice(0, 12) ?? '—'}</span>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{total > PAGE_SIZE && (
<div className="flex items-center gap-3 text-sm">
<button
disabled={offset === 0}
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
className="px-3 py-1.5 rounded border border-gray-700 disabled:opacity-30 hover:border-gray-500 transition-colors"
>
Previous
</button>
<span className="text-gray-500">
{offset + 1}{Math.min(offset + PAGE_SIZE, total)} of {total}
</span>
<button
disabled={offset + PAGE_SIZE >= total}
onClick={() => setOffset(offset + PAGE_SIZE)}
className="px-3 py-1.5 rounded border border-gray-700 disabled:opacity-30 hover:border-gray-500 transition-colors"
>
Next
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,165 @@
'use client';
import { useEffect, useState } from 'react';
import { getStats, type AdminStats } from '@/lib/api';
function KpiCard({
title,
value,
sub,
}: {
title: string;
value: string | number;
sub?: string;
}) {
return (
<div className="rounded-lg border border-gray-800 bg-gray-900 px-5 py-4 space-y-1">
<p className="text-xs text-gray-500 uppercase tracking-widest font-medium">{title}</p>
<p className="text-3xl font-bold tabular-nums">{value}</p>
{sub && <p className="text-xs text-gray-500">{sub}</p>}
</div>
);
}
function ReactionBar({ reactions }: { reactions: Record<string, number> }) {
const total = Object.values(reactions).reduce((a, b) => a + b, 0);
if (total === 0) return <p className="text-sm text-gray-500">No reactions yet.</p>;
const COLORS: Record<string, string> = {
done: 'bg-emerald-500',
snooze: 'bg-yellow-400',
dismiss: 'bg-red-500',
};
return (
<div className="space-y-2">
{Object.entries(reactions).map(([action, count]) => (
<div key={action} className="flex items-center gap-3">
<span className="w-16 text-xs text-gray-400 capitalize">{action}</span>
<div className="flex-1 bg-gray-800 rounded-full h-2">
<div
className={`${COLORS[action] ?? 'bg-gray-500'} h-2 rounded-full transition-all`}
style={{ width: `${((count / total) * 100).toFixed(1)}%` }}
/>
</div>
<span className="w-8 text-right text-xs tabular-nums text-gray-400">{count}</span>
</div>
))}
</div>
);
}
export function OverviewDashboard() {
const [stats, setStats] = useState<AdminStats | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
getStats()
.then(setStats)
.catch((e) => setError(String(e.message)));
}, []);
if (error) {
return <p className="text-red-400 text-sm">Failed to load stats: {error}</p>;
}
const activationPct =
stats && stats.totalUsers > 0
? ((stats.activatedUsers / stats.totalUsers) * 100).toFixed(1)
: null;
return (
<div className="space-y-8">
<div>
<h1 className="text-xl font-semibold">Overview</h1>
<p className="text-sm text-gray-500 mt-0.5">Last 7 days unless noted</p>
</div>
{/* KPI grid */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<KpiCard title="DAU" value={stats?.dau ?? '—'} sub="unique users today" />
<KpiCard title="WAU" value={stats?.wau ?? '—'} sub="unique users last 7 d" />
<KpiCard
title="Tips served"
value={stats?.tipsServedLast7d ?? '—'}
sub="last 7 days"
/>
<KpiCard
title="Activation"
value={activationPct != null ? `${activationPct}%` : '—'}
sub={
stats
? `${stats.activatedUsers} of ${stats.totalUsers} users`
: 'users who saw ≥1 tip'
}
/>
</div>
{/* Reactions */}
<div className="rounded-lg border border-gray-800 bg-gray-900 px-5 py-4 max-w-sm">
<p className="text-xs text-gray-500 uppercase tracking-widest font-medium mb-3">
Reactions last 7 days
</p>
{stats ? (
<ReactionBar reactions={stats.reactionsLast7d} />
) : (
<p className="text-sm text-gray-500">Loading</p>
)}
</div>
{/* Activation funnel */}
<div className="rounded-lg border border-gray-800 bg-gray-900 px-5 py-4 max-w-sm">
<p className="text-xs text-gray-500 uppercase tracking-widest font-medium mb-3">
Activation funnel
</p>
{stats ? (
<div className="space-y-2 text-sm">
<FunnelRow label="Total users" value={stats.totalUsers} max={stats.totalUsers} />
<FunnelRow
label="Saw ≥1 tip"
value={stats.activatedUsers}
max={stats.totalUsers}
/>
<FunnelRow
label="Reacted to tip"
value={Object.values(stats.reactionsLast7d).reduce((a, b) => a + b, 0)}
max={stats.tipsServedLast7d}
dimMax
/>
</div>
) : (
<p className="text-sm text-gray-500">Loading</p>
)}
</div>
</div>
);
}
function FunnelRow({
label,
value,
max,
dimMax,
}: {
label: string;
value: number;
max: number;
dimMax?: boolean;
}) {
const pct = max > 0 ? (value / max) * 100 : 0;
return (
<div className="flex items-center gap-3">
<span className="w-32 text-gray-400 text-xs">{label}</span>
<div className="flex-1 bg-gray-800 rounded-full h-1.5">
<div
className="bg-indigo-500 h-1.5 rounded-full transition-all"
style={{ width: `${pct.toFixed(1)}%` }}
/>
</div>
<span className="w-8 text-right text-xs tabular-nums text-gray-300">{value}</span>
{!dimMax && max > 0 && (
<span className="text-xs text-gray-600 tabular-nums">{pct.toFixed(0)}%</span>
)}
</div>
);
}

View File

@@ -0,0 +1,159 @@
'use client';
import { useEffect, useState } from 'react';
import { getUserDetail, revokeIntegration, resetBandit, type AdminUserDetail } from '@/lib/api';
export function UserDetail({ userId }: { userId: string }) {
const [data, setData] = useState<AdminUserDetail | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null); // which action is running
useEffect(() => {
getUserDetail(userId)
.then(setData)
.catch((e) => setError(String(e.message)));
}, [userId]);
async function handleRevoke(provider: string) {
if (!confirm(`Revoke ${provider} for this user?`)) return;
setBusy(`revoke:${provider}`);
try {
await revokeIntegration(userId, provider);
setData((d) =>
d
? { ...d, integrations: d.integrations.filter((i) => i.provider !== provider) }
: d,
);
} catch (e: unknown) {
alert(`Failed: ${(e as Error).message}`);
} finally {
setBusy(null);
}
}
async function handleResetBandit() {
if (!confirm("Reset this user's LinUCB model? Their personalization will start over.")) return;
setBusy('bandit');
try {
await resetBandit(userId);
alert('Bandit reset.');
} catch (e: unknown) {
alert(`Failed: ${(e as Error).message}`);
} finally {
setBusy(null);
}
}
if (error) return <p className="text-red-400 text-sm">Error: {error}</p>;
if (!data) return <p className="text-gray-500 text-sm">Loading</p>;
const { user, integrations, tipsServed, lastTipAt, recentFeedback } = data;
return (
<div className="space-y-6 max-w-2xl">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<h1 className="text-xl font-semibold">{user.name ?? user.email}</h1>
<p className="text-sm text-gray-400 mt-0.5">{user.email}</p>
</div>
<div className="flex gap-2">
<button
onClick={handleResetBandit}
disabled={busy === 'bandit'}
className="px-3 py-1.5 text-xs rounded border border-gray-700 hover:border-red-600 hover:text-red-400 transition-colors disabled:opacity-40"
>
{busy === 'bandit' ? 'Resetting…' : 'Reset bandit'}
</button>
</div>
</div>
{/* Identity */}
<Section title="Identity">
<Row label="ID" value={user.id} mono />
<Row label="Role" value={user.role} />
<Row label="Consent" value={user.consentGiven ? `yes (${user.consentAt?.slice(0, 10)})` : 'no'} />
<Row label="Joined" value={user.createdAt.slice(0, 10)} />
{user.deletedAt && <Row label="Deleted" value={user.deletedAt.slice(0, 10)} />}
</Section>
{/* Integrations */}
<Section title="Integrations">
{integrations.length === 0 ? (
<p className="text-sm text-gray-500">No integrations connected.</p>
) : (
integrations.map((i) => (
<div key={i.provider} className="flex items-center justify-between py-1">
<div>
<span className="text-sm capitalize">{i.provider}</span>
<span className="ml-2 text-xs text-gray-500">
connected {i.connectedAt.slice(0, 10)}
</span>
</div>
<button
onClick={() => handleRevoke(i.provider)}
disabled={busy === `revoke:${i.provider}`}
className="text-xs text-red-500 hover:text-red-400 transition-colors disabled:opacity-40"
>
{busy === `revoke:${i.provider}` ? 'Revoking…' : 'Revoke'}
</button>
</div>
))
)}
</Section>
{/* Tip stats */}
<Section title="Tip activity">
<Row label="Tips served (all time)" value={String(tipsServed)} />
<Row label="Last tip" value={lastTipAt?.slice(0, 19).replace('T', ' ') ?? '—'} />
</Section>
{/* Feedback history */}
<Section title="Recent feedback">
{recentFeedback.length === 0 ? (
<p className="text-sm text-gray-500">No feedback recorded.</p>
) : (
<div className="space-y-1">
{recentFeedback.map((f) => (
<div key={f.id} className="flex items-center gap-4 text-sm">
<span
className={`w-16 text-xs font-medium ${
f.action === 'done'
? 'text-emerald-400'
: f.action === 'snooze'
? 'text-yellow-400'
: 'text-red-400'
}`}
>
{f.action}
</span>
<span className="text-gray-500 text-xs tabular-nums">
{f.createdAt.slice(0, 19).replace('T', ' ')}
</span>
<span className="text-gray-600 text-xs font-mono truncate">{f.tipId}</span>
</div>
))}
</div>
)}
</Section>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="rounded-lg border border-gray-800 bg-gray-900 px-5 py-4 space-y-2">
<p className="text-xs text-gray-500 uppercase tracking-widest font-medium mb-3">{title}</p>
{children}
</div>
);
}
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex items-baseline gap-3 text-sm">
<span className="w-36 flex-shrink-0 text-gray-500">{label}</span>
<span className={mono ? 'font-mono text-xs text-gray-300' : 'text-gray-200'}>{value}</span>
</div>
);
}

View File

@@ -0,0 +1,134 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { getUsers, type AdminUser } from '@/lib/api';
const PAGE_SIZE = 50;
export function UsersTable() {
const [users, setUsers] = useState<AdminUser[]>([]);
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
getUsers(PAGE_SIZE, offset)
.then(({ users, total }) => {
setUsers(users);
setTotal(total);
})
.catch((e) => setError(String(e.message)))
.finally(() => setLoading(false));
}, [offset]);
if (error) return <p className="text-red-400 text-sm">Error: {error}</p>;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Users</h1>
<span className="text-sm text-gray-500">{total} total</span>
</div>
<div className="rounded-lg border border-gray-800 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-900 border-b border-gray-800">
<tr>
{['Email', 'Name', 'Role', 'Consent', 'Joined', 'Status'].map((h) => (
<th
key={h}
className="text-left px-4 py-2.5 text-xs text-gray-500 font-medium uppercase tracking-wide"
>
{h}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{loading ? (
<tr>
<td colSpan={6} className="px-4 py-6 text-center text-gray-500">
Loading
</td>
</tr>
) : users.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-6 text-center text-gray-500">
No users yet.
</td>
</tr>
) : (
users.map((u) => (
<tr
key={u.id}
className="hover:bg-gray-900 transition-colors cursor-pointer"
>
<td className="px-4 py-2.5">
<Link href={`/users/${u.id}`} className="hover:underline text-indigo-400">
{u.email}
</Link>
</td>
<td className="px-4 py-2.5 text-gray-300">{u.name ?? '—'}</td>
<td className="px-4 py-2.5">
<span
className={`px-1.5 py-0.5 rounded text-xs font-medium ${
u.role === 'admin'
? 'bg-indigo-900 text-indigo-300'
: 'bg-gray-800 text-gray-400'
}`}
>
{u.role}
</span>
</td>
<td className="px-4 py-2.5">
{u.consentGiven ? (
<span className="text-emerald-400 text-xs">yes</span>
) : (
<span className="text-gray-600 text-xs">no</span>
)}
</td>
<td className="px-4 py-2.5 text-gray-400 text-xs tabular-nums">
{u.createdAt.slice(0, 10)}
</td>
<td className="px-4 py-2.5">
{u.deletedAt ? (
<span className="text-red-500 text-xs">deleted</span>
) : (
<span className="text-emerald-500 text-xs">active</span>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{total > PAGE_SIZE && (
<div className="flex items-center gap-3 text-sm">
<button
disabled={offset === 0}
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
className="px-3 py-1.5 rounded border border-gray-700 disabled:opacity-30 hover:border-gray-500 transition-colors"
>
Previous
</button>
<span className="text-gray-500">
{offset + 1}{Math.min(offset + PAGE_SIZE, total)} of {total}
</span>
<button
disabled={offset + PAGE_SIZE >= total}
onClick={() => setOffset(offset + PAGE_SIZE)}
className="px-3 py-1.5 rounded border border-gray-700 disabled:opacity-30 hover:border-gray-500 transition-colors"
>
Next
</button>
</div>
)}
</div>
);
}

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

@@ -0,0 +1,222 @@
const API = '/api';
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API}${path}`, {
credentials: 'include',
...init,
headers: { 'Content-Type': 'application/json', ...init?.headers },
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw Object.assign(new Error(err.error ?? 'API error'), { status: res.status });
}
return res.json() as T;
}
// ── Types ──────────────────────────────────────────────────────────────────
export interface AdminStats {
dau: number;
wau: number;
tipsServedLast7d: number;
reactionsLast7d: Record<string, number>;
totalUsers: number;
activatedUsers: number;
}
export interface AdminUser {
id: string;
email: string;
name: string | null;
image: string | null;
role: string;
consentGiven: boolean;
consentAt: string | null;
createdAt: string;
deletedAt: string | null;
}
export interface AdminUserDetail {
user: AdminUser;
integrations: { provider: string; connectedAt: string }[];
tipsServed: number;
lastTipAt: string | null;
recentFeedback: { id: string; action: string; createdAt: string; tipId: string }[];
}
export interface AuditAction {
id: string;
adminId: string;
action: string;
targetType: string | null;
targetId: string | null;
detail: string | null;
createdAt: string;
}
export interface StoredEvent {
id: number;
subject: string;
payload: unknown;
ts: string;
}
export interface TipScore {
id: string;
userId: string;
tipId: string;
policy: string;
mlScore: number | null;
featuresJson: string | null;
candidateCount: number | null;
latencyMs: number | null;
servedAt: string;
}
export interface HealthStatus {
ok: boolean;
checkedAt: string;
services: { name: string; status: string; latencyMs: number }[];
}
export interface PolicyInfo {
name: string;
active: boolean;
}
export interface SavedQuery {
id: string;
name: string;
sql: string;
createdAt: string;
}
export interface BanditStats {
user_id: string;
pulls: number;
reward_count: number;
cumulative_reward: number;
estimated_mean_reward: number;
theta: number[];
last_updated: string | null;
}
export interface FeatureHistory {
user_id: string;
history: { ts: string; features: Record<string, unknown>; score: number; tip_id: string }[];
}
// ── Fetchers ───────────────────────────────────────────────────────────────
export function getStats() {
return apiFetch<AdminStats>('/admin/stats');
}
export function getUsers(limit = 50, offset = 0) {
return apiFetch<{ users: AdminUser[]; total: number }>(
`/admin/users?limit=${limit}&offset=${offset}`,
);
}
export function getUserDetail(id: string) {
return apiFetch<AdminUserDetail>(`/admin/users/${id}`);
}
export function revokeIntegration(userId: string, provider: string) {
return apiFetch<{ ok: boolean }>(`/admin/users/${userId}/revoke-integration`, {
method: 'POST',
body: JSON.stringify({ provider }),
});
}
export function resetBandit(userId: string) {
return apiFetch<{ ok: boolean }>(`/admin/users/${userId}/reset-bandit`, {
method: 'POST',
});
}
export function getAuditLog(limit = 50, offset = 0) {
return apiFetch<{ actions: AuditAction[]; total: number }>(
`/admin/audit?limit=${limit}&offset=${offset}`,
);
}
export function getEvents(params: { subject?: string; userId?: string; limit?: number; since?: number } = {}) {
const q = new URLSearchParams();
if (params.subject) q.set('subject', params.subject);
if (params.userId) q.set('userId', params.userId);
if (params.limit) q.set('limit', String(params.limit));
if (params.since) q.set('since', String(params.since));
return apiFetch<{ events: StoredEvent[]; nextSince: number }>(`/admin/events?${q}`);
}
export function getTips(params: { limit?: number; offset?: number; userId?: string } = {}) {
const q = new URLSearchParams();
if (params.limit) q.set('limit', String(params.limit));
if (params.offset) q.set('offset', String(params.offset));
if (params.userId) q.set('userId', params.userId);
return apiFetch<{ tips: TipScore[]; total: number }>(`/admin/tips?${q}`);
}
export function getRewardAnalytics(days = 30) {
return apiFetch<{
daily: { date: string; action: string; count: number }[];
byPolicy: { policy: string; action: string; count: number }[];
byHour: { action: string; count: number; avgHour: number }[];
}>(`/admin/reward-analytics?days=${days}`);
}
export function getDataQuality() {
return apiFetch<{
scoringCallsLast30d: number;
missingFeatureRate: number;
staleTokenRate: number;
totalTokens: number;
staleTokens: number;
dailyQuality: { date: string; total: number; withFeatures: number; avgCandidates: number }[];
}>('/admin/data-quality');
}
export function getHealth() {
return apiFetch<HealthStatus>('/admin/health');
}
export function getPolicies() {
return apiFetch<{ policies: PolicyInfo[] }>('/admin/policies');
}
export function togglePolicy(name: string, active: boolean) {
return apiFetch<{ ok: boolean }>(`/admin/policies/${name}/toggle`, {
method: 'POST',
body: JSON.stringify({ active }),
});
}
export function replaySignal(subject: string, payload: Record<string, unknown>) {
return apiFetch<{ ok: boolean }>('/admin/replay-signal', {
method: 'POST',
body: JSON.stringify({ subject, payload }),
});
}
export function runSql(query: string) {
return apiFetch<{ rows: unknown[]; rowCount: number }>('/admin/sql', {
method: 'POST',
body: JSON.stringify({ query }),
});
}
export function getSavedQueries() {
return apiFetch<{ queries: SavedQuery[] }>('/admin/saved-queries');
}
export function saveQuery(name: string, querySql: string) {
return apiFetch<{ id: string }>('/admin/saved-queries', {
method: 'POST',
body: JSON.stringify({ name, querySql }),
});
}
export function deleteSavedQuery(id: string) {
return apiFetch<{ ok: boolean }>(`/admin/saved-queries/${id}`, { method: 'DELETE' });
}

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

@@ -0,0 +1,119 @@
/**
* Server-side utilities for reading project documentation from the monorepo
* `docs/` directory and rendering it as HTML.
*
* All functions are async and must only be called from server components or
* server actions (no 'use client' imports of this module).
*
* Directory layout relative to monorepo root:
* docs/adr/ — Architecture Decision Records (NNN-title.md)
* docs/architecture/ — longer architecture notes (topic.md)
*/
import { readdir, readFile } from 'fs/promises';
import path from 'path';
import { marked } from 'marked';
// apps/admin sits two levels below the monorepo root.
const DOCS_ROOT = path.resolve(process.cwd(), '../../docs');
export type DocCategory = 'adr' | 'architecture';
export interface DocMeta {
category: DocCategory;
slug: string; // filename without .md
title: string; // first H1 from the file, fallback = slug
href: string; // /docs/adr/0001-monorepo-polyglot
status?: string; // for ADRs: "Accepted", "Proposed", …
date?: string; // for ADRs: date after em-dash on Status line
}
export interface DocPage extends DocMeta {
html: string;
}
// ---------------------------------------------------------------------------
// internal helpers
// ---------------------------------------------------------------------------
/** Extract the first # heading from markdown. */
function extractTitle(md: string): string {
const m = md.match(/^#\s+(.+)$/m);
return m ? m[1].trim() : '';
}
/** Extract status + date from "## Status\nAccepted — 2026-04-13" pattern. */
function extractStatus(md: string): { status?: string; date?: string } {
const block = md.match(/##\s+Status\s*\n+([^\n#]+)/);
if (!block) return {};
const line = block[1].trim();
// "Accepted — 2026-04-13" or "Proposed"
const parts = line.split(/\s*[–—]\s*/);
return { status: parts[0]?.trim(), date: parts[1]?.trim() };
}
function slugFromFile(filename: string): string {
return filename.replace(/\.md$/, '');
}
// ---------------------------------------------------------------------------
// public API
// ---------------------------------------------------------------------------
/** List all docs in a category, sorted by filename. */
export async function listDocs(category: DocCategory): Promise<DocMeta[]> {
const dir = path.join(DOCS_ROOT, category);
let files: string[];
try {
files = (await readdir(dir)).filter((f) => f.endsWith('.md')).sort();
} catch {
return [];
}
return Promise.all(
files.map(async (file) => {
const slug = slugFromFile(file);
const md = await readFile(path.join(dir, file), 'utf8');
const title = extractTitle(md) || slug;
const { status, date } = extractStatus(md);
return {
category,
slug,
title,
href: `/docs/${category}/${slug}`,
status,
date,
};
}),
);
}
/** List all docs across all categories. */
export async function listAllDocs(): Promise<Record<DocCategory, DocMeta[]>> {
const [adr, architecture] = await Promise.all([listDocs('adr'), listDocs('architecture')]);
return { adr, architecture };
}
/** Read and render a single doc to HTML. */
export async function getDoc(category: DocCategory, slug: string): Promise<DocPage | null> {
const file = path.join(DOCS_ROOT, category, `${slug}.md`);
let md: string;
try {
md = await readFile(file, 'utf8');
} catch {
return null;
}
const title = extractTitle(md) || slug;
const { status, date } = extractStatus(md);
const html = await marked(md, { gfm: true });
return {
category,
slug,
title,
href: `/docs/${category}/${slug}`,
status,
date,
html,
};
}

View File

@@ -0,0 +1,45 @@
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. The API is same-origin in production (Caddy routes
// /api/* to the Express service), so we use the rewrite target in dev.
const apiBase = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3078';
try {
const profile = await fetch(`${apiBase}/api/user/me`, {
headers: { cookie: `sid=${sid}` },
next: { revalidate: 0 },
});
if (!profile.ok) throw new Error('not ok');
const data = (await profile.json()) as { role?: string };
if (data.role !== 'admin') {
const url = req.nextUrl.clone();
url.pathname = '/forbidden';
return NextResponse.redirect(url);
}
} catch {
const url = req.nextUrl.clone();
url.pathname = '/login';
return NextResponse.redirect(url);
}
return NextResponse.next();
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};

View File

@@ -86,12 +86,17 @@ export default function TipPage() {
} catch { setPushState('denied'); } } catch { setPushState('denied'); }
}, []); }, []);
const react = async (action: 'done' | 'dismiss' | 'snooze') => { const react = async (action: 'done' | 'dismiss' | 'snooze' | 'helpful' | 'not_helpful') => {
if (!tip) return; if (!tip) return;
setVisible(false); const isNavigating = ['done', 'dismiss', 'snooze'].includes(action);
setState('done'); if (isNavigating) {
setVisible(false);
setState('done');
} else {
setState('tip');
}
await sendFeedback(tip.id, { action }); await sendFeedback(tip.id, { action });
setTimeout(() => loadTip(), 700); if (isNavigating) setTimeout(() => loadTip(), 700);
}; };
const onPointerDown = () => { const onPointerDown = () => {
@@ -269,6 +274,8 @@ export default function TipPage() {
</p> </p>
)} )}
<ActionButton label="Done ✓" onClick={() => react('done')} primary /> <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="Snooze" onClick={() => react('snooze')} />
<ActionButton label="Dismiss" onClick={() => react('dismiss')} /> <ActionButton label="Dismiss" onClick={() => react('dismiss')} />
<button <button

View File

@@ -2,9 +2,12 @@
oO ML Serving — Phase 1: LinUCB contextual bandit. oO ML Serving — Phase 1: LinUCB contextual bandit.
Contract: Contract:
POST /score { user_id, candidates, context } → { tip_id, score, policy } POST /score { user_id, candidates, context } → { tip_id, score, policy }
POST /reward { user_id, tip_id, reward, features } → { ok } POST /reward { user_id, tip_id, reward, features } → { ok }
GET /health{ 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): Features (d=5):
hour_sin, hour_cos — cyclical time-of-day encoding hour_sin, hour_cos — cyclical time-of-day encoding
@@ -18,9 +21,10 @@ from __future__ import annotations
import json import json
import math import math
import os import os
import random import time
from collections import deque
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional, Deque
import numpy as np import numpy as np
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
@@ -33,6 +37,16 @@ STATE_DIR.mkdir(parents=True, exist_ok=True)
ALPHA = 1.0 # exploration coefficient ALPHA = 1.0 # exploration coefficient
D = 5 # feature dimension D = 5 # feature dimension
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 ──────────────────────────────────────────────────────── # ── Feature helpers ────────────────────────────────────────────────────────
@@ -54,20 +68,21 @@ def state_path(user_id: str) -> Path:
return STATE_DIR / f"{safe}.json" return STATE_DIR / f"{safe}.json"
def load_state(user_id: str) -> tuple[np.ndarray, np.ndarray]: def load_state(user_id: str) -> tuple[np.ndarray, np.ndarray, dict]:
"""Returns (A, b). A is DxD, b is D-vector.""" """Returns (A, b, meta). A is DxD, b is D-vector."""
p = state_path(user_id) p = state_path(user_id)
if p.exists(): if p.exists():
raw = json.loads(p.read_text()) raw = json.loads(p.read_text())
A = np.array(raw["A"], dtype=np.float64) A = np.array(raw["A"], dtype=np.float64)
b = np.array(raw["b"], dtype=np.float64) b = np.array(raw["b"], dtype=np.float64)
return A, b meta = raw.get("meta", {})
return np.identity(D, dtype=np.float64), np.zeros(D, dtype=np.float64) 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) -> None: def save_state(user_id: str, A: np.ndarray, b: np.ndarray, meta: dict) -> None:
p = state_path(user_id) p = state_path(user_id)
p.write_text(json.dumps({"A": A.tolist(), "b": b.tolist()})) p.write_text(json.dumps({"A": A.tolist(), "b": b.tolist(), "meta": meta}))
# ── API models ───────────────────────────────────────────────────────────── # ── API models ─────────────────────────────────────────────────────────────
@@ -107,7 +122,7 @@ class ScoreResponse(BaseModel):
class RewardRequest(BaseModel): class RewardRequest(BaseModel):
user_id: str user_id: str
tip_id: str tip_id: str
reward: float # +1 done, 0 snooze, -1 dismiss reward: float # +1 done, +0.5 helpful, 0 snooze, -0.5 not_helpful, -1 dismiss
features: CandidateFeatures features: CandidateFeatures
@@ -127,7 +142,7 @@ 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")
A, b = load_state(req.user_id) A, b, meta = load_state(req.user_id)
try: try:
A_inv = np.linalg.inv(A) A_inv = np.linalg.inv(A)
except np.linalg.LinAlgError: except np.linalg.LinAlgError:
@@ -137,6 +152,7 @@ def score(req: ScoreRequest) -> ScoreResponse:
best_id = None best_id = None
best_score = -float("inf") best_score = -float("inf")
best_features: dict = {}
for candidate in req.candidates: for candidate in req.candidates:
feat_dict = { feat_dict = {
@@ -152,13 +168,28 @@ def score(req: ScoreRequest) -> ScoreResponse:
if ucb > best_score: if ucb > best_score:
best_score = ucb best_score = ucb
best_id = candidate.id 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") return ScoreResponse(tip_id=best_id, score=best_score, policy="linucb-v1")
@app.post("/reward", response_model=RewardResponse) @app.post("/reward", response_model=RewardResponse)
def reward(req: RewardRequest) -> RewardResponse: def reward(req: RewardRequest) -> RewardResponse:
A, b = load_state(req.user_id) A, b, meta = load_state(req.user_id)
feat_dict = { feat_dict = {
"hour_of_day": req.features.hour_of_day, "hour_of_day": req.features.hour_of_day,
"is_overdue": req.features.is_overdue, "is_overdue": req.features.is_overdue,
@@ -168,5 +199,58 @@ def reward(req: RewardRequest) -> RewardResponse:
x = build_feature_vector(feat_dict) x = build_feature_vector(feat_dict)
A += np.outer(x, x) A += np.outer(x, x)
b += req.reward * x b += req.reward * x
save_state(req.user_id, A, b)
# 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) return RewardResponse(ok=True)
@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()
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),
}

View File

@@ -14,6 +14,6 @@ export interface RecommendResponse {
/** POST /tip/:id/feedback request body */ /** POST /tip/:id/feedback request body */
export interface TipFeedback { export interface TipFeedback {
action: 'done' | 'dismiss' | 'snooze'; action: 'done' | 'dismiss' | 'snooze' | 'helpful' | 'not_helpful';
snoozedUntil?: string; // ISO 8601, required when action = snooze snoozedUntil?: string; // ISO 8601, required when action = snooze
} }

View File

@@ -8,6 +8,9 @@ sqlite.pragma('journal_mode = WAL');
sqlite.pragma('foreign_keys = ON'); sqlite.pragma('foreign_keys = ON');
export const db = drizzle(sqlite, { schema }); export const db = drizzle(sqlite, { schema });
// Raw sqlite client — used by the SQL runner endpoint.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const rawSqlite: any = sqlite;
export function runMigrations() { export function runMigrations() {
sqlite.exec(` sqlite.exec(`
@@ -17,6 +20,7 @@ export function runMigrations() {
name TEXT, name TEXT,
image TEXT, image TEXT,
google_id TEXT UNIQUE, google_id TEXT UNIQUE,
role TEXT NOT NULL DEFAULT 'user',
consent_given INTEGER NOT NULL DEFAULT 0, consent_given INTEGER NOT NULL DEFAULT 0,
consent_at TEXT, consent_at TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
@@ -43,11 +47,72 @@ export function runMigrations() {
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS tip_views (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
tip_id TEXT NOT NULL,
served_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS push_subscriptions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
endpoint TEXT NOT NULL UNIQUE,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions ( CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id), user_id TEXT NOT NULL REFERENCES users(id),
expires_at TEXT NOT NULL, expires_at TEXT NOT NULL,
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS admin_actions (
id TEXT PRIMARY KEY,
admin_id TEXT NOT NULL REFERENCES users(id),
action TEXT NOT NULL,
target_type TEXT,
target_id TEXT,
detail TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS tip_scores (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
tip_id TEXT NOT NULL,
policy TEXT NOT NULL,
ml_score INTEGER,
features_json TEXT,
candidate_count INTEGER,
latency_ms INTEGER,
served_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS saved_queries (
id TEXT PRIMARY KEY,
admin_id TEXT NOT NULL REFERENCES users(id),
name TEXT NOT NULL,
sql TEXT NOT NULL,
created_at TEXT NOT NULL
);
`); `);
// Additive column migrations — safe to run on existing DBs.
// SQLite doesn't support IF NOT EXISTS on ALTER TABLE; we ignore the error if already present.
for (const stmt of [
`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'`,
`ALTER TABLE push_subscriptions ADD COLUMN created_at TEXT NOT NULL DEFAULT ''`,
]) {
try { sqlite.exec(stmt); } catch { /* column already exists */ }
}
// Seed first admin from env (ADMIN_SEED_EMAIL).
const seedEmail = process.env.ADMIN_SEED_EMAIL;
if (seedEmail) {
sqlite.prepare(`UPDATE users SET role = 'admin' WHERE email = ? AND role = 'user'`).run(seedEmail);
}
} }

View File

@@ -6,6 +6,7 @@ export const users = sqliteTable('users', {
name: text('name'), name: text('name'),
image: text('image'), image: text('image'),
googleId: text('google_id').unique(), googleId: text('google_id').unique(),
role: text('role').notNull().default('user'), // 'user' | 'admin'
consentGiven: integer('consent_given', { mode: 'boolean' }).notNull().default(false), consentGiven: integer('consent_given', { mode: 'boolean' }).notNull().default(false),
consentAt: text('consent_at'), consentAt: text('consent_at'),
createdAt: text('created_at').notNull(), createdAt: text('created_at').notNull(),
@@ -54,3 +55,37 @@ export const sessions = sqliteTable('sessions', {
expiresAt: text('expires_at').notNull(), expiresAt: text('expires_at').notNull(),
createdAt: text('created_at').notNull(), createdAt: text('created_at').notNull(),
}); });
// Audit log — every admin write action is appended here.
export const adminActions = sqliteTable('admin_actions', {
id: text('id').primaryKey(),
adminId: text('admin_id').notNull().references(() => users.id),
action: text('action').notNull(), // e.g. 'revoke_token', 'reset_bandit'
targetType: text('target_type'), // e.g. 'user', 'integration'
targetId: text('target_id'),
detail: text('detail'), // JSON blob for extra context
createdAt: text('created_at').notNull(),
});
// Recommendation explainability log — one row per tip served.
// features/scores are JSON blobs. Retained 30 days (GDPR).
export const tipScores = sqliteTable('tip_scores', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id),
tipId: text('tip_id').notNull(),
policy: text('policy').notNull(),
mlScore: integer('ml_score', { mode: 'number' }), // null when random fallback
featuresJson: text('features_json'), // JSON: { is_overdue, task_age_days, priority, hour_of_day, day_of_week }
candidateCount: integer('candidate_count'),
latencyMs: integer('latency_ms'),
servedAt: text('served_at').notNull(),
});
// Admin saved SQL queries.
export const savedQueries = sqliteTable('saved_queries', {
id: text('id').primaryKey(),
adminId: text('admin_id').notNull().references(() => users.id),
name: text('name').notNull(),
sql: text('sql').notNull(),
createdAt: text('created_at').notNull(),
});

View File

@@ -22,7 +22,7 @@ export type TipServedEvent = {
export type TipFeedbackEvent = { export type TipFeedbackEvent = {
userId: string; userId: string;
tipId: string; tipId: string;
action: 'done' | 'dismiss' | 'snooze'; action: 'done' | 'dismiss' | 'snooze' | 'helpful' | 'not_helpful';
reward: number; reward: number;
createdAt: string; createdAt: string;
}; };
@@ -39,14 +39,56 @@ type EventMap = {
'signals.task.synced': TaskSyncedEvent; 'signals.task.synced': TaskSyncedEvent;
}; };
export type StoredEvent = {
id: number;
subject: string;
payload: unknown;
ts: string;
};
const RING_SIZE = 500;
class Bus extends EventEmitter { class Bus extends EventEmitter {
private ring: StoredEvent[] = [];
private seq = 0;
publish<K extends keyof EventMap>(subject: K, payload: EventMap[K]): void { publish<K extends keyof EventMap>(subject: K, payload: EventMap[K]): void {
const entry: StoredEvent = {
id: ++this.seq,
subject,
payload,
ts: new Date().toISOString(),
};
if (this.ring.length >= RING_SIZE) this.ring.shift();
this.ring.push(entry);
this.emit(subject, payload); this.emit(subject, payload);
} }
subscribe<K extends keyof EventMap>(subject: K, handler: (payload: EventMap[K]) => void): void { subscribe<K extends keyof EventMap>(subject: K, handler: (payload: EventMap[K]) => void): void {
this.on(subject, handler); this.on(subject, handler);
} }
/**
* Return recent events from the ring buffer.
* @param subject optional filter (prefix match, e.g. "signals.tip")
* @param userId optional user ID filter
* @param limit max events to return (default 100)
* @param since only events with id > since
*/
tail(opts: { subject?: string; userId?: string; limit?: number; since?: number } = {}): StoredEvent[] {
const { subject, userId, limit = 100, since = 0 } = opts;
let results = this.ring.filter((e) => {
if (e.id <= since) return false;
if (subject && !e.subject.startsWith(subject)) return false;
if (userId) {
const p = e.payload as Record<string, unknown>;
if (p.userId !== userId) return false;
}
return true;
});
if (results.length > limit) results = results.slice(results.length - limit);
return results;
}
} }
export const bus = new Bus(); export const bus = new Bus();

View File

@@ -10,8 +10,12 @@ 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 { pushRouter } from './routes/push.js';
import { adminRouter } from './routes/admin.js';
import { mkdir } from 'fs/promises'; import { mkdir } from 'fs/promises';
import { dirname } from 'path'; import { dirname } from 'path';
import { requireAuth } from './middleware/session.js';
import { requireAdmin } from './middleware/admin.js';
import type { Request, Response } from 'express';
await mkdir(dirname(config.DATABASE_PATH), { recursive: true }); await mkdir(dirname(config.DATABASE_PATH), { recursive: true });
runMigrations(); runMigrations();
@@ -35,6 +39,27 @@ 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.use('/api/push', pushRouter);
app.use('/api/admin', adminRouter);
// Proxy ml/serving endpoints through the API (admin-only).
// Allows admin UI to call /api/ml/stats/:userId, /api/ml/features/:userId
// without needing direct access to the ml/serving port.
app.use('/api/ml', requireAuth as any, requireAdmin as any, async (req: Request, res: Response) => {
const mlUrl = config.ML_SERVING_URL;
const target = `${mlUrl}${req.path}`;
try {
const upstream = await fetch(target, {
method: req.method,
headers: { 'Content-Type': 'application/json' },
body: req.method !== 'GET' ? JSON.stringify(req.body) : undefined,
signal: AbortSignal.timeout(5000),
});
const data = await upstream.json();
res.status(upstream.status).json(data);
} catch (e: any) {
res.status(502).json({ error: 'ml/serving unavailable', detail: e.message });
}
});
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,609 @@
import { type Router as ExpressRouter, Router, Response } from 'express';
import { db, rawSqlite } from '../db/index.js';
import {
users,
integrationTokens,
tipViews,
tipFeedback,
adminActions,
tipScores,
savedQueries,
} from '../db/schema.js';
import { eq, desc, sql, gte, and, isNull, lt } from 'drizzle-orm';
import { requireAuth, AuthenticatedRequest } from '../middleware/session.js';
import { requireAdmin } from '../middleware/admin.js';
import { nanoid } from 'nanoid';
import { bus } from '../events/bus.js';
import { config } from '../config.js';
import { getShadowPolicies, setPolicyActive } from './recommender.js';
const router: ExpressRouter = Router();
router.use(requireAuth, requireAdmin);
// ---------------------------------------------------------------------------
// GET /api/admin/stats
// ---------------------------------------------------------------------------
router.get('/stats', async (_req: AuthenticatedRequest, res: Response) => {
const now = new Date();
const dayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString();
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString();
const [dauRow] = await db
.select({ count: sql<number>`count(distinct user_id)` })
.from(tipViews)
.where(gte(tipViews.servedAt, dayAgo));
const [wauRow] = await db
.select({ count: sql<number>`count(distinct user_id)` })
.from(tipViews)
.where(gte(tipViews.servedAt, weekAgo));
const [tipsRow] = await db
.select({ count: sql<number>`count(*)` })
.from(tipViews)
.where(gte(tipViews.servedAt, weekAgo));
const reactionRows = await db
.select({ action: tipFeedback.action, count: sql<number>`count(*)` })
.from(tipFeedback)
.where(gte(tipFeedback.createdAt, weekAgo))
.groupBy(tipFeedback.action);
const reactions: Record<string, number> = {};
for (const row of reactionRows) reactions[row.action] = Number(row.count);
const [totalUsersRow] = await db
.select({ count: sql<number>`count(*)` })
.from(users)
.where(isNull(users.deletedAt));
const [activatedRow] = await db
.select({ count: sql<number>`count(distinct user_id)` })
.from(tipViews);
res.json({
dau: Number(dauRow?.count ?? 0),
wau: Number(wauRow?.count ?? 0),
tipsServedLast7d: Number(tipsRow?.count ?? 0),
reactionsLast7d: reactions,
totalUsers: Number(totalUsersRow?.count ?? 0),
activatedUsers: Number(activatedRow?.count ?? 0),
});
});
// ---------------------------------------------------------------------------
// GET /api/admin/users?limit=50&offset=0
// ---------------------------------------------------------------------------
router.get('/users', async (req: AuthenticatedRequest, res: Response) => {
const limit = Math.min(Number(req.query.limit ?? 50), 200);
const offset = Number(req.query.offset ?? 0);
const rows = await db
.select({
id: users.id,
email: users.email,
name: users.name,
image: users.image,
role: users.role,
consentGiven: users.consentGiven,
createdAt: users.createdAt,
deletedAt: users.deletedAt,
})
.from(users)
.orderBy(desc(users.createdAt))
.limit(limit)
.offset(offset);
const [countRow] = await db
.select({ count: sql<number>`count(*)` })
.from(users);
res.json({ users: rows, total: Number(countRow?.count ?? 0) });
});
// ---------------------------------------------------------------------------
// GET /api/admin/users/:id
// ---------------------------------------------------------------------------
router.get('/users/:id', async (req: AuthenticatedRequest, res: Response) => {
const userId = req.params.id as string;
const [user] = await db
.select()
.from(users)
.where(eq(users.id, userId))
.limit(1);
if (!user) {
res.status(404).json({ error: 'User not found' });
return;
}
const integrations = await db
.select({ provider: integrationTokens.provider, connectedAt: integrationTokens.connectedAt })
.from(integrationTokens)
.where(eq(integrationTokens.userId, user.id));
const [tipStats] = await db
.select({ count: sql<number>`count(*)` })
.from(tipViews)
.where(eq(tipViews.userId, user.id));
const recentFeedback = await db
.select()
.from(tipFeedback)
.where(eq(tipFeedback.userId, user.id))
.orderBy(desc(tipFeedback.createdAt))
.limit(20);
const [lastView] = await db
.select({ servedAt: tipViews.servedAt })
.from(tipViews)
.where(eq(tipViews.userId, user.id))
.orderBy(desc(tipViews.servedAt))
.limit(1);
res.json({
user: {
id: user.id,
email: user.email,
name: user.name,
image: user.image,
role: user.role,
consentGiven: user.consentGiven,
consentAt: user.consentAt,
createdAt: user.createdAt,
deletedAt: user.deletedAt,
},
integrations,
tipsServed: Number(tipStats?.count ?? 0),
lastTipAt: lastView?.servedAt ?? null,
recentFeedback,
});
});
// ---------------------------------------------------------------------------
// POST /api/admin/users/:id/revoke-integration
// ---------------------------------------------------------------------------
router.post(
'/users/:id/revoke-integration',
async (req: AuthenticatedRequest, res: Response) => {
const targetUserId = req.params.id as string;
const { provider } = req.body as { provider: string };
if (!provider) {
res.status(400).json({ error: 'provider required' });
return;
}
const [token] = await db
.select()
.from(integrationTokens)
.where(
and(
eq(integrationTokens.userId, targetUserId),
eq(integrationTokens.provider, provider),
),
)
.limit(1);
if (!token) {
res.status(404).json({ error: 'Integration not found' });
return;
}
if (provider === 'todoist') {
await fetch('https://api.todoist.com/sync/v9/access_tokens/revoke', {
method: 'POST',
headers: { Authorization: `Bearer ${token.accessToken}` },
}).catch(() => {});
}
await db
.delete(integrationTokens)
.where(
and(
eq(integrationTokens.userId, targetUserId),
eq(integrationTokens.provider, provider),
),
);
await db.insert(adminActions).values({
id: nanoid(),
adminId: req.userId!,
action: 'revoke_integration',
targetType: 'integration',
targetId: token.id,
detail: JSON.stringify({ userId: targetUserId, provider }),
createdAt: new Date().toISOString(),
});
res.json({ ok: true });
},
);
// ---------------------------------------------------------------------------
// POST /api/admin/users/:id/reset-bandit
// ---------------------------------------------------------------------------
router.post(
'/users/:id/reset-bandit',
async (req: AuthenticatedRequest, res: Response) => {
const targetUserId = req.params.id as string;
const mlUrl = process.env.ML_SERVING_URL ?? 'http://localhost:8000';
let mlOk = true;
let mlError = '';
try {
const mlRes = await fetch(`${mlUrl}/reset/${targetUserId}`, { method: 'POST' });
mlOk = mlRes.ok;
if (!mlOk) mlError = mlRes.statusText;
} catch (e) {
mlOk = false;
mlError = String(e);
}
if (!mlOk) {
res.status(502).json({ error: 'ml/serving reset failed', detail: mlError });
return;
}
await db.insert(adminActions).values({
id: nanoid(),
adminId: req.userId!,
action: 'reset_bandit',
targetType: 'user',
targetId: targetUserId,
detail: null,
createdAt: new Date().toISOString(),
});
res.json({ ok: true });
},
);
// ---------------------------------------------------------------------------
// GET /api/admin/audit?limit=50&offset=0
// ---------------------------------------------------------------------------
router.get('/audit', async (req: AuthenticatedRequest, res: Response) => {
const limit = Math.min(Number(req.query.limit ?? 50), 200);
const offset = Number(req.query.offset ?? 0);
const rows = await db
.select()
.from(adminActions)
.orderBy(desc(adminActions.createdAt))
.limit(limit)
.offset(offset);
const [countRow] = await db
.select({ count: sql<number>`count(*)` })
.from(adminActions);
res.json({ actions: rows, total: Number(countRow?.count ?? 0) });
});
// ---------------------------------------------------------------------------
// GET /api/admin/events?subject=signals.tip&userId=&limit=100&since=0
// Returns recent events from the in-process ring buffer.
// ---------------------------------------------------------------------------
router.get('/events', async (req: AuthenticatedRequest, res: Response) => {
const subject = req.query.subject as string | undefined;
const userId = req.query.userId as string | undefined;
const limit = Math.min(Number(req.query.limit ?? 100), 500);
const since = Number(req.query.since ?? 0);
const events = bus.tail({ subject, userId, limit, since });
res.json({ events, nextSince: events.length > 0 ? events[events.length - 1].id : since });
});
// ---------------------------------------------------------------------------
// GET /api/admin/tips?limit=50&offset=0&userId=
// Recommendation log — per-tip explainability.
// ---------------------------------------------------------------------------
router.get('/tips', async (req: AuthenticatedRequest, res: Response) => {
const limit = Math.min(Number(req.query.limit ?? 50), 200);
const offset = Number(req.query.offset ?? 0);
const userId = req.query.userId as string | undefined;
const conditions = userId ? [eq(tipScores.userId, userId)] : [];
const rows = await db
.select()
.from(tipScores)
.where(conditions.length ? and(...(conditions as [ReturnType<typeof eq>])) : undefined)
.orderBy(desc(tipScores.servedAt))
.limit(limit)
.offset(offset);
const [countRow] = await db
.select({ count: sql<number>`count(*)` })
.from(tipScores)
.where(conditions.length ? and(...(conditions as [ReturnType<typeof eq>])) : undefined);
res.json({ tips: rows, total: Number(countRow?.count ?? 0) });
});
// ---------------------------------------------------------------------------
// GET /api/admin/reward-analytics?days=30
// Reaction distribution over time + per-policy compare.
// ---------------------------------------------------------------------------
router.get('/reward-analytics', async (req: AuthenticatedRequest, res: Response) => {
const days = Math.min(Number(req.query.days ?? 30), 90);
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
// Daily reaction counts
const dailyRows = await db
.select({
date: sql<string>`date(created_at)`,
action: tipFeedback.action,
count: sql<number>`count(*)`,
})
.from(tipFeedback)
.where(gte(tipFeedback.createdAt, since))
.groupBy(sql`date(created_at)`, tipFeedback.action)
.orderBy(sql`date(created_at)`);
// Per-policy reward distribution (tip_scores joined with tip_feedback)
const policyRows = await db
.select({
policy: tipScores.policy,
action: tipFeedback.action,
count: sql<number>`count(*)`,
})
.from(tipScores)
.leftJoin(tipFeedback, eq(tipScores.tipId, tipFeedback.tipId))
.where(gte(tipScores.servedAt, since))
.groupBy(tipScores.policy, tipFeedback.action);
// By hour_of_day (extracted from featuresJson)
const hourRows = await db
.select({
action: tipFeedback.action,
count: sql<number>`count(*)`,
avgHour: sql<number>`avg(json_extract(ts.features_json, '$.hour_of_day'))`,
})
.from(tipFeedback)
.leftJoin(tipScores, eq(tipFeedback.tipId, tipScores.tipId))
.where(gte(tipFeedback.createdAt, since))
.groupBy(tipFeedback.action);
res.json({
daily: dailyRows,
byPolicy: policyRows,
byHour: hourRows,
});
});
// ---------------------------------------------------------------------------
// GET /api/admin/data-quality
// Missing-signal rates, stale token rates, feature NaN heatmap.
// ---------------------------------------------------------------------------
router.get('/data-quality', async (req: AuthenticatedRequest, res: Response) => {
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
// Total scoring calls in last 30d
const [totalRow] = await db
.select({ count: sql<number>`count(*)` })
.from(tipScores)
.where(gte(tipScores.servedAt, thirtyDaysAgo));
const total = Number(totalRow?.count ?? 0);
// Calls with no features (features_json IS NULL)
const [missingFeaturesRow] = await db
.select({ count: sql<number>`count(*)` })
.from(tipScores)
.where(and(gte(tipScores.servedAt, thirtyDaysAgo), isNull(tipScores.featuresJson)));
const missingFeatures = Number(missingFeaturesRow?.count ?? 0);
// Stale tokens: connected more than 7 days ago with no recent tip (proxy for stale)
const [staleTokensRow] = await db
.select({ count: sql<number>`count(*)` })
.from(integrationTokens)
.where(lt(integrationTokens.connectedAt, sevenDaysAgo));
const staleTokens = Number(staleTokensRow?.count ?? 0);
const [totalTokensRow] = await db
.select({ count: sql<number>`count(*)` })
.from(integrationTokens);
const totalTokens = Number(totalTokensRow?.count ?? 0);
// Daily feature completeness (last 14 days)
const dailyQuality = await db
.select({
date: sql<string>`date(served_at)`,
total: sql<number>`count(*)`,
withFeatures: sql<number>`sum(case when features_json is not null then 1 else 0 end)`,
avgCandidates: sql<number>`avg(candidate_count)`,
})
.from(tipScores)
.where(gte(tipScores.servedAt, new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString()))
.groupBy(sql`date(served_at)`)
.orderBy(sql`date(served_at)`);
res.json({
scoringCallsLast30d: total,
missingFeatureRate: total > 0 ? missingFeatures / total : 0,
staleTokenRate: totalTokens > 0 ? staleTokens / totalTokens : 0,
totalTokens,
staleTokens,
dailyQuality,
});
});
// ---------------------------------------------------------------------------
// GET /api/admin/health
// Fan-out to all subsystem /health endpoints.
// ---------------------------------------------------------------------------
router.get('/health', async (_req: AuthenticatedRequest, res: Response) => {
const checks: Array<{ name: string; url: string }> = [
{ name: 'api', url: `http://localhost:${process.env.PORT ?? 3001}/health` },
{ name: 'ml-serving', url: `${config.ML_SERVING_URL}/health` },
];
const results = await Promise.allSettled(
checks.map(async ({ name, url }) => {
const t0 = Date.now();
try {
const r = await fetch(url, { signal: AbortSignal.timeout(3000) });
return { name, status: r.ok ? 'ok' : 'degraded', latencyMs: Date.now() - t0 };
} catch {
return { name, status: 'down', latencyMs: Date.now() - t0 };
}
}),
);
// SQLite health
let dbStatus = 'ok';
try {
await db.select({ one: sql<number>`1` }).from(users).limit(1);
} catch {
dbStatus = 'down';
}
// Event bus: always ok if process is alive
const eventBusStatus = 'ok';
const services = results.map((r) =>
r.status === 'fulfilled' ? r.value : { name: 'unknown', status: 'down', latencyMs: 0 },
);
services.push({ name: 'sqlite', status: dbStatus, latencyMs: 0 });
services.push({ name: 'event-bus', status: eventBusStatus, latencyMs: 0 });
const allOk = services.every((s) => s.status === 'ok');
res.json({ ok: allOk, services, checkedAt: new Date().toISOString() });
});
// ---------------------------------------------------------------------------
// GET /api/admin/policies
// POST /api/admin/policies/:name/toggle
// ---------------------------------------------------------------------------
router.get('/policies', async (_req: AuthenticatedRequest, res: Response) => {
res.json({ policies: getShadowPolicies() });
});
router.post('/policies/:name/toggle', async (req: AuthenticatedRequest, res: Response) => {
const { name } = req.params as { name: string };
const { active } = req.body as { active: boolean };
const ok = setPolicyActive(name, active);
if (!ok) {
res.status(404).json({ error: 'Policy not found' });
return;
}
await db.insert(adminActions).values({
id: nanoid(),
adminId: req.userId!,
action: active ? 'enable_policy' : 'disable_policy',
targetType: 'policy',
targetId: name,
detail: null,
createdAt: new Date().toISOString(),
});
res.json({ ok: true });
});
// ---------------------------------------------------------------------------
// POST /api/admin/replay-signal
// Re-emit a past event on the bus (for testing / backfill).
// Body: { subject: string, payload: object }
// ---------------------------------------------------------------------------
router.post('/replay-signal', async (req: AuthenticatedRequest, res: Response) => {
const { subject, payload } = req.body as { subject: string; payload: Record<string, unknown> };
if (!subject || !payload) {
res.status(400).json({ error: 'subject and payload required' });
return;
}
const validSubjects = ['signals.tip.served', 'signals.tip.feedback', 'signals.task.synced'];
if (!validSubjects.includes(subject)) {
res.status(400).json({ error: 'unknown subject' });
return;
}
bus.publish(subject as 'signals.tip.served', payload as any);
await db.insert(adminActions).values({
id: nanoid(),
adminId: req.userId!,
action: 'replay_signal',
targetType: 'event',
targetId: null,
detail: JSON.stringify({ subject, payload }),
createdAt: new Date().toISOString(),
});
res.json({ ok: true });
});
// ---------------------------------------------------------------------------
// POST /api/admin/sql
// Read-only SQL runner. Only SELECT allowed.
// ---------------------------------------------------------------------------
router.post('/sql', async (req: AuthenticatedRequest, res: Response) => {
const { query } = req.body as { query: string };
if (!query || typeof query !== 'string') {
res.status(400).json({ error: 'query required' });
return;
}
const normalized = query.trim().toUpperCase();
if (!normalized.startsWith('SELECT') && !normalized.startsWith('WITH')) {
res.status(400).json({ error: 'Only SELECT queries are allowed' });
return;
}
// Block any mutation keywords (belt-and-suspenders)
const forbidden = /\b(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|ATTACH|PRAGMA|VACUUM)\b/i;
if (forbidden.test(query)) {
res.status(400).json({ error: 'Mutation statements are not allowed' });
return;
}
try {
const stmt = rawSqlite.prepare(query);
const rows = stmt.all();
res.json({ rows, rowCount: rows.length });
} catch (e: any) {
res.status(400).json({ error: e.message ?? 'Query error' });
}
});
// ---------------------------------------------------------------------------
// GET /api/admin/saved-queries
// POST /api/admin/saved-queries
// DELETE /api/admin/saved-queries/:id
// ---------------------------------------------------------------------------
router.get('/saved-queries', async (req: AuthenticatedRequest, res: Response) => {
const rows = await db
.select()
.from(savedQueries)
.where(eq(savedQueries.adminId, req.userId!))
.orderBy(desc(savedQueries.createdAt));
res.json({ queries: rows });
});
router.post('/saved-queries', async (req: AuthenticatedRequest, res: Response) => {
const { name, querySql } = req.body as { name: string; querySql: string };
if (!name || !querySql) {
res.status(400).json({ error: 'name and querySql required' });
return;
}
const id = nanoid();
await db.insert(savedQueries).values({
id,
adminId: req.userId!,
name,
sql: querySql,
createdAt: new Date().toISOString(),
});
res.json({ id });
});
router.delete('/saved-queries/:id', async (req: AuthenticatedRequest, res: Response) => {
const { id } = req.params as { id: string };
await db
.delete(savedQueries)
.where(and(eq(savedQueries.id, id), eq(savedQueries.adminId, req.userId!)));
res.json({ ok: true });
});
export { router as adminRouter };

View File

@@ -1,7 +1,7 @@
import { type Router as ExpressRouter, Router, Response } from 'express'; import { type Router as ExpressRouter, Router, Response } from 'express';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { db } from '../db/index.js'; import { db } from '../db/index.js';
import { integrationTokens, tipFeedback, tipViews } from '../db/schema.js'; import { integrationTokens, tipFeedback, tipViews, tipScores } 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 { config } from '../config.js';
@@ -24,7 +24,31 @@ interface CachedTask extends Tip {
const taskCache = new Map<string, { tasks: CachedTask[]; fetchedAt: number }>(); const taskCache = new Map<string, { tasks: CachedTask[]; fetchedAt: number }>();
/** Parse a Todoist due date string into age in days (relative to now) */ // ---------------------------------------------------------------------------
// Shadow-policy registry
// ---------------------------------------------------------------------------
// A shadow policy runs alongside the active policy, logs its picks, but does
// NOT affect what the user sees. Promotion to A/B or live is a manual step.
// Structure: Map<policyName, { active: boolean }>
const shadowPolicies = new Map<string, { active: boolean }>([
// Example: enable random as a shadow baseline
// ('random-shadow', { active: true }),
]);
export function getShadowPolicies() {
return Array.from(shadowPolicies.entries()).map(([name, s]) => ({ name, ...s }));
}
export function setPolicyActive(name: string, active: boolean): boolean {
if (!shadowPolicies.has(name)) return false;
shadowPolicies.set(name, { active });
return true;
}
// ---------------------------------------------------------------------------
// Todoist helpers
// ---------------------------------------------------------------------------
function dueAgeDays(due: { date?: string; datetime?: string } | null | undefined): number { function dueAgeDays(due: { date?: string; datetime?: string } | null | undefined): number {
if (!due) return 0; if (!due) return 0;
const dateStr = due.datetime ?? due.date; const dateStr = due.datetime ?? due.date;
@@ -71,11 +95,17 @@ async function fetchTodoistTasks(userId: string, accessToken: string): Promise<C
}); });
taskCache.set(userId, { tasks, fetchedAt: Date.now() }); taskCache.set(userId, { tasks, fetchedAt: Date.now() });
bus.publish('signals.task.synced', { userId, count: tasks.length, syncedAt: now.toISOString() });
return tasks; return tasks;
} }
/** Call ml/serving for scored selection; returns tip_id or null on failure */ /** Call ml/serving for scored selection; returns { tip_id, score } or null on failure */
async function remotePolicy(userId: string, tasks: CachedTask[]): Promise<string | null> { async function remotePolicy(
userId: string,
tasks: CachedTask[],
): Promise<{ tipId: string; score: number } | null> {
const hour = new Date().getHours(); const hour = new Date().getHours();
const dayOfWeek = new Date().getDay(); const dayOfWeek = new Date().getDay();
@@ -99,8 +129,8 @@ async function remotePolicy(userId: string, tasks: CachedTask[]): Promise<string
signal: AbortSignal.timeout(3000), signal: AbortSignal.timeout(3000),
}); });
if (!res.ok) return null; if (!res.ok) return null;
const { tip_id } = (await res.json()) as { tip_id: string }; const data = (await res.json()) as { tip_id: string; score: number };
return tip_id; return { tipId: data.tip_id, score: data.score };
} catch { } catch {
return null; return null;
} }
@@ -111,7 +141,9 @@ function randomPolicy(candidates: CachedTask[]): CachedTask | null {
return candidates[Math.floor(Math.random() * candidates.length)]; return candidates[Math.floor(Math.random() * candidates.length)];
} }
/** POST /api/recommend */ // ---------------------------------------------------------------------------
// POST /api/recommend
// ---------------------------------------------------------------------------
router.post('/recommend', requireAuth, async (req: AuthenticatedRequest, res: Response) => { router.post('/recommend', requireAuth, async (req: AuthenticatedRequest, res: Response) => {
const [token] = await db const [token] = await db
.select() .select()
@@ -130,10 +162,15 @@ router.post('/recommend', requireAuth, async (req: AuthenticatedRequest, res: Re
return; return;
} }
const hour = new Date().getHours();
const dayOfWeek = new Date().getDay();
const t0 = Date.now();
// RemotePolicy with RandomPolicy fallback // RemotePolicy with RandomPolicy fallback
const scoredId = await remotePolicy(req.userId!, tasks); const scored = await remotePolicy(req.userId!, tasks);
const tip = scoredId const latencyMs = Date.now() - t0;
? (tasks.find((t) => t.id === scoredId) ?? randomPolicy(tasks)) const tip = scored
? (tasks.find((t) => t.id === scored.tipId) ?? randomPolicy(tasks))
: randomPolicy(tasks); : randomPolicy(tasks);
if (!tip) { if (!tip) {
@@ -141,25 +178,63 @@ router.post('/recommend', requireAuth, async (req: AuthenticatedRequest, res: Re
return; return;
} }
const policy = scored ? 'linucb-v1' : 'random';
const servedAt = new Date().toISOString(); const servedAt = new Date().toISOString();
await db.insert(tipViews).values({ id: nanoid(), userId: req.userId!, tipId: tip.id, servedAt }); await db.insert(tipViews).values({ id: nanoid(), userId: req.userId!, tipId: tip.id, servedAt });
// Log recommendation explainability
await db.insert(tipScores).values({
id: nanoid(),
userId: req.userId!,
tipId: tip.id,
policy,
mlScore: scored ? Math.round(scored.score * 1000) : null,
featuresJson: JSON.stringify({
is_overdue: tip.features.is_overdue,
task_age_days: tip.features.task_age_days,
priority: tip.features.priority,
hour_of_day: hour,
day_of_week: dayOfWeek,
}),
candidateCount: tasks.length,
latencyMs,
servedAt,
});
bus.publish('signals.tip.served', { bus.publish('signals.tip.served', {
userId: req.userId!, userId: req.userId!,
tipId: tip.id, tipId: tip.id,
policy: scoredId ? 'linucb-v1' : 'random', policy,
servedAt, servedAt,
}); });
// Run shadow policies (fire-and-forget, no effect on user)
for (const [name, s] of shadowPolicies) {
if (!s.active) continue;
if (name.startsWith('random')) {
const shadowTip = randomPolicy(tasks);
bus.publish('signals.tip.served', {
userId: req.userId!,
tipId: shadowTip?.id ?? 'none',
policy: `shadow:${name}`,
servedAt,
});
}
}
res.json({ tip }); res.json({ tip });
}); });
/** POST /api/tip/:id/feedback */ // ---------------------------------------------------------------------------
// POST /api/tip/:id/feedback
// ---------------------------------------------------------------------------
router.post('/tip/:id/feedback', requireAuth, async (req: AuthenticatedRequest, res: Response) => { router.post('/tip/:id/feedback', requireAuth, async (req: AuthenticatedRequest, res: Response) => {
const { action } = req.body as { action: string }; const { action } = req.body as { action: string };
const tipId = String(req.params.id); const tipId = String(req.params.id);
if (!['done', 'dismiss', 'snooze'].includes(action)) { const validActions = ['done', 'dismiss', 'snooze', 'helpful', 'not_helpful'];
if (!validActions.includes(action)) {
res.status(400).json({ error: 'Invalid action' }); res.status(400).json({ error: 'Invalid action' });
return; return;
} }
@@ -173,18 +248,31 @@ router.post('/tip/:id/feedback', requireAuth, async (req: AuthenticatedRequest,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}); });
// Capture task features before clearing cache // Map action to reward (helpful/not_helpful supplement behavioural signals)
const reward = action === 'done' ? 1.0 : action === 'dismiss' ? -1.0 : 0.0; const rewardMap: Record<string, number> = {
done: 1.0,
helpful: 0.5,
snooze: 0.0,
not_helpful: -0.5,
dismiss: -1.0,
};
const reward = rewardMap[action] ?? 0.0;
const task = taskCache.get(req.userId!)?.tasks.find((t) => t.id === tipId); const task = taskCache.get(req.userId!)?.tasks.find((t) => t.id === tipId);
taskCache.delete(req.userId!);
// Clear cache on behavioural actions (not on explicit helpful/not_helpful)
if (['done', 'dismiss', 'snooze'].includes(action)) {
taskCache.delete(req.userId!);
}
bus.publish('signals.tip.feedback', { bus.publish('signals.tip.feedback', {
userId: req.userId!, userId: req.userId!,
tipId, tipId,
action: action as 'done' | 'dismiss' | 'snooze', action: action as 'done' | 'dismiss' | 'snooze' | 'helpful' | 'not_helpful',
reward, reward,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}); });
if (task) { if (task) {
fetch(`${config.ML_SERVING_URL}/reward`, { fetch(`${config.ML_SERVING_URL}/reward`, {
method: 'POST', method: 'POST',