diff --git a/.env.example b/.env.example index 44ab951..c0d7d64 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,18 @@ SUMMARIES_TIMEOUT_MS="15000" SUMMARIES_RATE_WINDOW_MS="60000" SUMMARIES_RATE_MAX="4" +# Summaries budget preflight (optional) +# If MODEL_MAX_TOKENS is set, we reserve RESPONSE_TOKENS and trim input accordingly. +# Otherwise we use TARGET_MAX_TOKENS as an overall budget. +SUMMARIES_MODEL_MAX_TOKENS="" +SUMMARIES_TARGET_MAX_TOKENS="" +SUMMARIES_RESPONSE_TOKENS="600" +# Approx chars per token used to bound input when estimating (default 4) +SUMMARIES_CHARS_PER_TOKEN="4" +# Optional stricter caps (defaults: posts=150, chars=12000) +SUMMARIES_MAX_POSTS="" +SUMMARIES_MAX_CHARS="" + # Resend (optional, for email notifications) RESEND_API_KEY="" RESEND_FROM="notifications@matchday-pulse.dev" @@ -45,6 +57,11 @@ RESEND_FROM="notifications@matchday-pulse.dev" # Bluesky verification (optional) BSKY_VERIFY_TTL_MS="600000" +# Twitter API (optional GA integration) +# Provide a Bearer token to enable real profile resolution and recent tweet fetching. +# Leave empty to use allowlist-only placeholder data. +TWITTER_BEARER_TOKEN="" + # Remote Slack MCP Server # Slack bot token for posting messages via Slack Web API SLACK_BOT_TOKEN="" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 706100f..5cde722 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,30 +9,41 @@ on: - chore/** pull_request: branches: - - "**" + - main jobs: - test: - name: Install and Test + test-and-build: runs-on: ubuntu-latest - timeout-minutes: 15 + + strategy: + matrix: + node-version: [20.x] steps: - name: Checkout uses: actions/checkout@v4 - - name: Setup Node + - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: - node-version: 20 + node-version: ${{ matrix.node-version }} cache: npm - name: Install dependencies run: npm ci + env: + CI: true - - name: Run Vitest - run: npx vitest run --reporter=verbose + - name: Run unit tests + run: npm run test:run + env: + CI: true + # Provide minimal envs so tests that rely on server-only env don't crash + SUPABASE_URL: http://localhost + SUPABASE_SERVICE_ROLE_KEY: stub + OPENAI_API_KEY: stub - # Optional: run the simple test runner as a smoke check - - name: Run simple test suite - run: npm test + - name: Build (SvelteKit) + run: npm run build + env: + CI: true diff --git a/package-lock.json b/package-lock.json index c66567e..2a95807 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "arsenal-matchday-pulse", + "name": "slack-mcp", "version": "0.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "arsenal-matchday-pulse", + "name": "slack-mcp", "version": "0.0.1", "hasInstallScript": true, "dependencies": { @@ -23,6 +23,7 @@ "wink-sentiment": "^5.0.0" }, "devDependencies": { + "@sveltejs/adapter-auto": "^7.0.0", "@sveltejs/adapter-vercel": "^5.10.3", "@sveltejs/kit": "^2.46.5", "@vitest/coverage-v8": "^1.6.1", @@ -1437,6 +1438,16 @@ "acorn": "^8.9.0" } }, + "node_modules/@sveltejs/adapter-auto": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-7.0.0.tgz", + "integrity": "sha512-ImDWaErTOCkRS4Gt+5gZuymKFBobnhChXUZ9lhUZLahUgvA4OOvRzi3sahzYgbxGj5nkA6OV0GAW378+dl/gyw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, "node_modules/@sveltejs/adapter-vercel": { "version": "5.10.3", "resolved": "https://registry.npmjs.org/@sveltejs/adapter-vercel/-/adapter-vercel-5.10.3.tgz", diff --git a/package.json b/package.json index 62ec4f3..28828e6 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "wink-sentiment": "^5.0.0" }, "devDependencies": { + "@sveltejs/adapter-auto": "^7.0.0", "@sveltejs/adapter-vercel": "^5.10.3", "@sveltejs/kit": "^2.46.5", "@vitest/coverage-v8": "^1.6.1", diff --git a/src/lib/services/bskyService.ts b/src/lib/services/bskyService.ts index a0f8005..01c77de 100644 --- a/src/lib/services/bskyService.ts +++ b/src/lib/services/bskyService.ts @@ -384,7 +384,13 @@ export function summarizeSentiment(posts: SimplePost[]) { else neuCount++; } - const total = posts.length || 1; + const total = posts.length; + if (total === 0) { + return { + ratios: { pos: 0, neg: 0, neu: 0 }, + counts: { total: 0, pos: 0, neg: 0, neu: 0 } + }; + } const pos = posCount / total; const neg = negCount / total; const neu = neuCount / total; diff --git a/src/lib/services/twitterService.ts b/src/lib/services/twitterService.ts index ae89c5b..42f9441 100644 --- a/src/lib/services/twitterService.ts +++ b/src/lib/services/twitterService.ts @@ -50,13 +50,59 @@ export const DEFAULT_RECENCY_MINUTES = TWITTER_DEFAULT_RECENCY_MINUTES; * @returns An array of profiles where each entry has `handle` and `displayName` set to the handle, `followersCount` and `postsCount` left undefined, and `createdAt` set to `null` */ export async function resolveAllowlistProfiles(handles: string[] = TWITTER_ALLOWLIST): Promise { - return handles.map((h) => ({ - handle: h, - displayName: h, - followersCount: undefined, - postsCount: undefined, - createdAt: null - })); + const bearer = process.env.TWITTER_BEARER_TOKEN; + // If no bearer token configured, fall back to minimal profiles + if (!bearer) { + return handles.map((h) => ({ + handle: h, + displayName: h, + followersCount: undefined, + postsCount: undefined, + createdAt: null + })); + } + + // With bearer token, try to resolve basic profile info; gracefully fall back per-handle on errors + const resolved: TwitterProfileBasic[] = []; + for (const handle of handles) { + try { + const url = `https://api.twitter.com/2/users/by/username/${encodeURIComponent(handle)}?user.fields=created_at,public_metrics,name,username`; + const res = await fetch(url, { + headers: { Authorization: `Bearer ${bearer}` } + }); + if (!res.ok) throw new Error(`twitter_user_lookup_failed ${res.status}`); + const data: any = await res.json(); + const u = data?.data; + if (u?.id) { + resolved.push({ + user_id: String(u.id), + handle: u.username || handle, + displayName: u.name || handle, + followersCount: u.public_metrics?.followers_count ?? undefined, + postsCount: u.public_metrics?.tweet_count ?? undefined, + createdAt: u.created_at ?? null + }); + continue; + } + // Fallback if response incomplete + resolved.push({ + handle, + displayName: handle, + followersCount: undefined, + postsCount: undefined, + createdAt: null + }); + } catch { + resolved.push({ + handle, + displayName: handle, + followersCount: undefined, + postsCount: undefined, + createdAt: null + }); + } + } + return resolved; } /** @@ -126,11 +172,28 @@ function keyOf(p: TwitterProfileBasic): string { export async function selectEligibleAccounts(params?: { matchId?: string | null }): Promise { const matchId = params?.matchId ?? null; - // 1) Start from allowlist-based resolution - const baseProfiles = await resolveAllowlistProfiles(); + // 1) Start from allowlist-based resolution (support vi.spyOn in tests by referencing module namespace) + let baseProfiles: TwitterProfileBasic[] = []; + try { + const selfMod: any = await import('./twitterService'); + if (selfMod && typeof selfMod.resolveAllowlistProfiles === 'function') { + baseProfiles = await selfMod.resolveAllowlistProfiles(); + } else { + baseProfiles = await resolveAllowlistProfiles(); + } + } catch { + baseProfiles = await resolveAllowlistProfiles(); + } // 2) Load overrides (per-match takes precedence over global) - const { include: inc, exclude: exc } = await getOverrides({ platform: 'twitter', matchId }); + let ov: any; + try { + ov = await getOverrides({ platform: 'twitter', matchId }); + } catch { + ov = { include: [], exclude: [] }; + } + const inc = (ov?.include ?? []) as any[]; + const exc = (ov?.exclude ?? []) as any[]; // Build exclude set const excludeKeys = new Set(); @@ -206,7 +269,78 @@ export async function fetchRecentTweetsForAccounts( _accounts: SelectedAccount[], _sinceMinutes: number = DEFAULT_RECENCY_MINUTES ): Promise { - return []; + const bearer = process.env.TWITTER_BEARER_TOKEN; + if (!_accounts?.length || !bearer) { + return []; + } + + const startIso = new Date(Date.now() - Math.max(1, _sinceMinutes) * 60_000).toISOString(); + + // Helper to resolve user id if missing + async function ensureUserId(acc: SelectedAccount): Promise<{ user_id?: string; handle: string; displayName?: string }> { + const basic = { + user_id: acc.profile.user_id, + handle: acc.profile.handle, + displayName: acc.profile.displayName + }; + if (basic.user_id) return basic; + + try { + const url = `https://api.twitter.com/2/users/by/username/${encodeURIComponent(basic.handle)}?user.fields=name,username`; + const res = await fetch(url, { + headers: { Authorization: `Bearer ${bearer}` } + }); + if (!res.ok) return basic; + const data: any = await res.json(); + const u = data?.data; + if (u?.id) { + return { + user_id: String(u.id), + handle: u.username || basic.handle, + displayName: u.name || basic.displayName || basic.handle + }; + } + return basic; + } catch { + return basic; + } + } + + const tweets: SimpleTweet[] = []; + for (const acc of _accounts) { + try { + const auth = { Authorization: `Bearer ${bearer}` }; + const user = await ensureUserId(acc); + if (!user.user_id) continue; + + // Fetch recent tweets for this user since startIso + const url = new URL(`https://api.twitter.com/2/users/${encodeURIComponent(user.user_id)}/tweets`); + url.searchParams.set('max_results', '100'); + url.searchParams.set('start_time', startIso); + url.searchParams.set('tweet.fields', 'created_at'); + + const res = await fetch(url.toString(), { headers: auth }); + if (!res.ok) continue; + const data: any = await res.json(); + const arr: any[] = Array.isArray(data?.data) ? data.data : []; + for (const t of arr) { + if (!t?.id || !t?.text || !t?.created_at) continue; + tweets.push({ + id: String(t.id), + author: { user_id: user.user_id, handle: user.handle, displayName: user.displayName }, + text: t.text, + createdAt: t.created_at + }); + } + } catch { + // skip account on error + continue; + } + } + + // Sort newest first + tweets.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)); + return tweets; } /** @@ -242,4 +376,4 @@ export async function getAccountsSnapshot(): Promise< createdAt: a.profile.createdAt ?? null, eligibility: a.eligibility })); -} \ No newline at end of file +} diff --git a/src/routes/admin/observability/+page.svelte b/src/routes/admin/observability/+page.svelte new file mode 100644 index 0000000..4dacf61 --- /dev/null +++ b/src/routes/admin/observability/+page.svelte @@ -0,0 +1,356 @@ + + +
+

Admin: Summaries Observability

+ +
+

Admin Token

+
+ + +
+ Token is stored in this tab's sessionStorage. Do not share. +
+ +
+

Controls

+
+ + + + +
+
+ +
+
+

Metrics

+ {#if loadingMetrics} +

Loading metrics…

+ {:else if errorMetrics} +

{errorMetrics}

+ {:else if !metrics} +

No data.

+ {:else} +

+ Window: {new Date(metrics.windowStart).toLocaleString()} → {new Date(metrics.windowEnd).toLocaleString()} +

+
+
+
Total
+
{metrics.total}
+
+
+
Success
+
{metrics.byStatus.ok} ({pct(metrics.byStatus.ok, metrics.total)})
+
+
+
Rate limited
+
{metrics.byStatus.rate_limited}
+
+
+
Missing key
+
{metrics.byStatus.missing_key}
+
+
+
Timeout
+
{metrics.byStatus.timeout}
+
+
+
Failed
+
{metrics.byStatus.failed}
+
+
+
+ {#if metrics.total > 0} +
+ ok +
+
+ non-ok +
+ {/if} +
+ {/if} +
+ +
+

Recent Activity

+ {#if loadingRecent} +

Loading recent…

+ {:else if errorRecent} +

{errorRecent}

+ {:else if recent.length === 0} +

No recent rows.

+ {:else} +
+ + + + + + + + + + + + + + + + + + {#each recent as r} + + + + + + + + + + + + + + {/each} + +
TimeStatusMatchPlatformPhaseWin (m)PostsCharsModelDuration (ms)Error
{new Date(r.created_at).toLocaleString()}{r.status}{r.match_id}{r.platform}{r.phase}{r.window_minutes}{r.posts_count}{r.chars_count}{r.model ?? '-'}{r.duration_ms ?? '-'}{r.error_message ?? ''}
+
+ {/if} +
+
+
+ + diff --git a/src/routes/api/accounts/plan/+server.ts b/src/routes/api/accounts/plan/+server.ts index 6f6dce0..cb4e2a5 100644 --- a/src/routes/api/accounts/plan/+server.ts +++ b/src/routes/api/accounts/plan/+server.ts @@ -1,5 +1,6 @@ import type { RequestHandler } from '@sveltejs/kit'; -import { env } from '$env/dynamic/private'; +/* In tests, SvelteKit $env modules are not available; read from process.env */ +const envVar = (name: string) => process.env[name]; import { BUDGET_PER_PLATFORM_DOLLARS, getPlatformCostConfig, estimateMaxAccounts } from '$lib/config/budget'; import { getAccountsSnapshot } from '$lib/services/bskyService'; import { BSKY_MAX_ACCOUNTS } from '$lib/config/bsky'; @@ -49,7 +50,7 @@ type PlatformPlan = { export const GET: RequestHandler = async ({ request }) => { try { // Admin-only guard: require ADMIN_SECRET via header - const adminSecret = env.ADMIN_SECRET; + const adminSecret = envVar('ADMIN_SECRET'); if (!adminSecret) { return new Response(JSON.stringify({ error: 'admin_not_configured' }), { status: 501, diff --git a/src/routes/api/admin/summaries/metrics/+server.ts b/src/routes/api/admin/summaries/metrics/+server.ts new file mode 100644 index 0000000..9d077d8 --- /dev/null +++ b/src/routes/api/admin/summaries/metrics/+server.ts @@ -0,0 +1,97 @@ +import type { RequestHandler } from '@sveltejs/kit'; + +function authOk(expected: string | undefined, token: string | null) { + return !!expected && !!token && token === expected; +} + +function env(name: string) { + return process.env[name]; +} + +/** + * Admin metrics for summaries audit logs (summary_requests). + * - Requires header: x-admin-token: + * - Query params: + * - hours: lookback window in hours (default 24) + * - limit: max rows to scan from Supabase (default 1000) + * - Response: + * { + * windowStart: ISO, + * windowEnd: ISO, + * total: number, + * byStatus: { ok: number, rate_limited: number, missing_key: number, timeout: number, failed: number }, + * successRate: number (0..1), + * } + */ +export const GET: RequestHandler = async (event) => { + const token = event.request.headers.get('x-admin-token'); + const expected = env('ADMIN_SECRET'); + if (!authOk(expected, token)) { + return new Response(JSON.stringify({ error: 'unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } }); + } + + const supaUrl = env('SUPABASE_URL') || env('PUBLIC_SUPABASE_URL'); + const serviceKey = env('SUPABASE_SERVICE_ROLE_KEY'); + if (!supaUrl || !serviceKey) { + return new Response(JSON.stringify({ error: 'server_misconfigured', message: 'Supabase URL or service role key missing' }), { status: 500, headers: { 'Content-Type': 'application/json' } }); + } + + const hoursParam = Number(event.url.searchParams.get('hours')); + const hours = Number.isFinite(hoursParam) && hoursParam > 0 ? hoursParam : 24; + const limitParam = Number(event.url.searchParams.get('limit')); + const limit = Number.isFinite(limitParam) && limitParam > 0 ? Math.min(limitParam, 5000) : 1000; + + const end = new Date(); + const start = new Date(end.getTime() - hours * 60 * 60 * 1000); + const startISO = start.toISOString(); + + try { + // Fetch statuses within window (scan limited rows, newest first) + const url = new URL(`${supaUrl}/rest/v1/summary_requests`); + url.searchParams.set('select', 'status'); + url.searchParams.set('created_at', `gte.${startISO}`); + url.searchParams.set('order', 'created_at.desc'); + url.searchParams.set('limit', String(limit)); + + const res = await fetch(url.toString(), { + headers: { + apikey: serviceKey, + Authorization: `Bearer ${serviceKey}` + } + }); + + if (!res.ok) { + return new Response(JSON.stringify({ error: 'supabase_error', status: res.status }), { status: 502, headers: { 'Content-Type': 'application/json' } }); + } + + const rows: Array<{ status: 'ok' | 'rate_limited' | 'missing_key' | 'timeout' | 'failed' }> = await res.json(); + + const byStatus = { + ok: 0, + rate_limited: 0, + missing_key: 0, + timeout: 0, + failed: 0 + }; + for (const r of rows) { + if (r && r.status && r.status in byStatus) { + // @ts-ignore + byStatus[r.status] += 1; + } + } + const total = rows.length; + const successRate = total > 0 ? byStatus.ok / total : 0; + + const payload = { + windowStart: start.toISOString(), + windowEnd: end.toISOString(), + total, + byStatus, + successRate + }; + + return new Response(JSON.stringify(payload), { headers: { 'Content-Type': 'application/json' } }); + } catch (e: any) { + return new Response(JSON.stringify({ error: 'metrics_failed', message: e?.message ?? 'Unknown error' }), { status: 500, headers: { 'Content-Type': 'application/json' } }); + } +}; diff --git a/src/routes/api/admin/summaries/recent/+server.ts b/src/routes/api/admin/summaries/recent/+server.ts new file mode 100644 index 0000000..995b236 --- /dev/null +++ b/src/routes/api/admin/summaries/recent/+server.ts @@ -0,0 +1,98 @@ +import type { RequestHandler } from '@sveltejs/kit'; + +function authOk(expected: string | undefined, token: string | null) { + return !!expected && !!token && token === expected; +} + +function env(name: string) { + return process.env[name]; +} + +/** + * Admin: Recent summary_requests rows for observability + * - Requires header: x-admin-token: + * - Query params: + * - hours: lookback window in hours (default 24) + * - limit: max rows (default 50, max 500) + * - status: optional filter by status (ok|rate_limited|missing_key|timeout|failed) + * - Response: array of recent rows with selected columns + */ +export const GET: RequestHandler = async (event) => { + const token = event.request.headers.get('x-admin-token'); + const expected = env('ADMIN_SECRET'); + if (!authOk(expected, token)) { + return new Response(JSON.stringify({ error: 'unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' } + }); + } + + const supaUrl = env('SUPABASE_URL') || env('PUBLIC_SUPABASE_URL'); + const serviceKey = env('SUPABASE_SERVICE_ROLE_KEY'); + if (!supaUrl || !serviceKey) { + return new Response( + JSON.stringify({ error: 'server_misconfigured', message: 'Supabase URL or service role key missing' }), + { status: 500, headers: { 'Content-Type': 'application/json' } } + ); + } + + const hoursParam = Number(event.url.searchParams.get('hours')); + const hours = Number.isFinite(hoursParam) && hoursParam > 0 ? hoursParam : 24; + const limitParam = Number(event.url.searchParams.get('limit')); + const limit = Number.isFinite(limitParam) && limitParam > 0 ? Math.min(limitParam, 500) : 50; + const status = (event.url.searchParams.get('status') || '').toLowerCase(); + + const end = new Date(); + const start = new Date(end.getTime() - hours * 60 * 60 * 1000); + const startISO = start.toISOString(); + + try { + const url = new URL(`${supaUrl}/rest/v1/summary_requests`); + // select a subset of columns for the grid + url.searchParams.set( + 'select', + [ + 'id', + 'created_at', + 'match_id', + 'platform', + 'phase', + 'window_minutes', + 'posts_count', + 'chars_count', + 'model', + 'status', + 'error_message', + 'duration_ms' + ].join(',') + ); + url.searchParams.set('created_at', `gte.${startISO}`); + url.searchParams.set('order', 'created_at.desc'); + url.searchParams.set('limit', String(limit)); + if (status && ['ok', 'rate_limited', 'missing_key', 'timeout', 'failed'].includes(status)) { + url.searchParams.set('status', `eq.${status}`); + } + + const res = await fetch(url.toString(), { + headers: { + apikey: serviceKey, + Authorization: `Bearer ${serviceKey}` + } + }); + + if (!res.ok) { + return new Response(JSON.stringify({ error: 'supabase_error', status: res.status }), { + status: 502, + headers: { 'Content-Type': 'application/json' } + }); + } + + const rows = await res.json(); + return new Response(JSON.stringify(rows), { headers: { 'Content-Type': 'application/json' } }); + } catch (e: any) { + return new Response(JSON.stringify({ error: 'recent_failed', message: e?.message ?? 'Unknown error' }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } +}; diff --git a/src/routes/api/comments/+server.ts b/src/routes/api/comments/+server.ts index 1a0f2e9..34f9f10 100644 --- a/src/routes/api/comments/+server.ts +++ b/src/routes/api/comments/+server.ts @@ -7,6 +7,36 @@ const ALLOWED_PLATFORMS: Platform[] = ['bsky', 'twitter', 'threads', 'combined'] // In-memory fallback store when Supabase is not configured const COMMENTS_MEM = new Map(); // key = matchId +const COMMENTS_TTL = new Map(); // key = matchId, value = expiry timestamp +const MAX_COMMENTS_PER_MATCH = 1000; // Maximum comments per match to prevent memory bloat +const COMMENTS_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days TTL for fallback storage + +// Cleanup expired entries +function cleanupExpiredComments() { + const now = Date.now(); + for (const [matchId, expiry] of COMMENTS_TTL.entries()) { + if (now > expiry) { + COMMENTS_MEM.delete(matchId); + COMMENTS_TTL.delete(matchId); + } + } +} + +// Add comment with TTL and size limits +function addCommentToMemory(matchId: string, comment: Comment) { + cleanupExpiredComments(); + + let comments = COMMENTS_MEM.get(matchId) || []; + + // Enforce size limit + if (comments.length >= MAX_COMMENTS_PER_MATCH) { + comments = comments.slice(-MAX_COMMENTS_PER_MATCH + 1); // Keep most recent + } + + comments.unshift(comment); + COMMENTS_MEM.set(matchId, comments); + COMMENTS_TTL.set(matchId, Date.now() + COMMENTS_TTL_MS); +} function uid() { return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; @@ -141,9 +171,7 @@ export const POST: RequestHandler = async ({ request }) => { createdAt: new Date().toISOString(), status: 'active' }; - const arr = COMMENTS_MEM.get(matchId) ?? []; - arr.unshift(comment); - COMMENTS_MEM.set(matchId, arr); + addCommentToMemory(matchId, comment); return new Response(JSON.stringify({ ok: true, comment, note: 'supabase_insert_failed_fallback' }), { status: 201, @@ -169,9 +197,7 @@ export const POST: RequestHandler = async ({ request }) => { status: 'active' }; - const arr = COMMENTS_MEM.get(matchId) ?? []; - arr.unshift(comment); - COMMENTS_MEM.set(matchId, arr); + addCommentToMemory(matchId, comment); return new Response(JSON.stringify({ ok: true, comment }), { status: 201, diff --git a/src/routes/api/summaries/latest/+server.ts b/src/routes/api/summaries/latest/+server.ts index 105437f..0d048fd 100644 --- a/src/routes/api/summaries/latest/+server.ts +++ b/src/routes/api/summaries/latest/+server.ts @@ -59,6 +59,10 @@ async function notifySlack(text: string) { const MAX_POSTS = 150; const MAX_CHARS = 12000; +// Budget caps (override via env) +const MAX_POSTS_ENV = Number(process.env.SUMMARIES_MAX_POSTS ?? MAX_POSTS); +const MAX_CHARS_ENV = Number(process.env.SUMMARIES_MAX_CHARS ?? MAX_CHARS); + // Supported platforms for now; "combined" currently aliases to bsky until X/Threads are wired type Platform = 'bsky' | 'twitter' | 'threads' | 'combined'; type Phase = 'pre' | 'live' | 'post'; @@ -143,17 +147,59 @@ export const GET: RequestHandler = async ({ url }) => { let texts: string[] = []; let accountsUsed: Array<{ did: string; handle: string; displayName?: string }> = []; + // Start wall-clock timer for audit + const started = Date.now(); + + // Prepare audit insert helper (Supabase admin) + async function audit( + status: 'ok' | 'rate_limited' | 'missing_key' | 'timeout' | 'failed', + extra?: { error?: string; usage?: any } + ) { + try { + const url = process.env.SUPABASE_URL || process.env.PUBLIC_SUPABASE_URL; + const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + if (!url || !serviceKey) return; + const body = { + match_id: matchId, + platform, + phase, + window_minutes: sinceMin, + posts_count: texts.length, + chars_count: joined.length, + model: env('OPENAI_MODEL', 'gpt-5'), + prompt_tokens: extra?.usage?.prompt_tokens ?? null, + completion_tokens: extra?.usage?.completion_tokens ?? null, + total_tokens: extra?.usage?.total_tokens ?? null, + status, + error_message: extra?.error ?? null, + duration_ms: Date.now() - started + }; + await fetch(`${url}/rest/v1/summary_requests`, { + method: 'POST', + headers: { + apikey: serviceKey, + Authorization: `Bearer ${serviceKey}`, + 'Content-Type': 'application/json', + Prefer: 'return=minimal' + }, + body: JSON.stringify(body) + }); + } catch { + // Do not fail request due to audit failures + } + } + if (platform === 'bsky' || platform === 'combined') { - const accounts = await selectEligibleAccounts(); - accountsUsed = accounts.map((a) => ({ + const accounts = (await selectEligibleAccounts()) ?? []; + accountsUsed = (accounts ?? []).map((a) => ({ did: a.profile.did, handle: a.profile.handle, displayName: a.profile.displayName })); - const posts = await fetchRecentPostsForAccounts(accounts, sinceMin); + const posts = (await fetchRecentPostsForAccounts(accounts, sinceMin)) ?? []; texts = posts .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)) - .slice(0, MAX_POSTS) + .slice(0, MAX_POSTS_ENV) .map((p) => `[${new Date(p.createdAt).toISOString()}] @${p.author.handle}: ${p.text}`); } else { // Placeholder: not yet wired @@ -163,13 +209,42 @@ export const GET: RequestHandler = async ({ url }) => { // Truncate by total chars to keep prompt bounded let joined = texts.join('\n'); + // Budget-aware trimming (approximate tokens to chars) + // Configure via: + // - SUMMARIES_MODEL_MAX_TOKENS (e.g., 8192 for many models) + // - SUMMARIES_TARGET_MAX_TOKENS (fallback total budget if model max unknown) + // - SUMMARIES_RESPONSE_TOKENS (reserve for completion; default 600) + // - SUMMARIES_CHARS_PER_TOKEN (approx; default 4) + { + const MODEL_MAX_TOKENS = Number(process.env.SUMMARIES_MODEL_MAX_TOKENS ?? 0); + const TARGET_MAX_TOKENS = Number(process.env.SUMMARIES_TARGET_MAX_TOKENS ?? 0); + const RESPONSE_TOKENS = Number(process.env.SUMMARIES_RESPONSE_TOKENS ?? 600); + const CHARS_PER_TOKEN = Number(process.env.SUMMARIES_CHARS_PER_TOKEN ?? 4); + + let availableTokens = 0; + if (MODEL_MAX_TOKENS > 0) { + availableTokens = Math.max(0, MODEL_MAX_TOKENS - RESPONSE_TOKENS); + } else if (TARGET_MAX_TOKENS > 0) { + availableTokens = Math.max(0, TARGET_MAX_TOKENS - RESPONSE_TOKENS); + } + if (availableTokens > 0) { + const budgetCharLimit = Math.max(0, Math.floor(availableTokens * CHARS_PER_TOKEN)); + if (joined.length > budgetCharLimit) { + joined = joined.slice(0, budgetCharLimit); + } + } + } + // Rate limiting (global, in-memory) const now = Date.now(); SUMMARY_REQ_TIMESTAMPS = SUMMARY_REQ_TIMESTAMPS.filter((ts) => now - ts < RATE_WINDOW_MS); if (SUMMARY_REQ_TIMESTAMPS.length >= RATE_MAX) { const retryAfterMs = RATE_WINDOW_MS - (now - SUMMARY_REQ_TIMESTAMPS[0]); // Notice ops via Slack MCP (optional) - await notifySlack(`[summaries/latest] rate_limited: retry in ${Math.ceil(retryAfterMs / 1000)}s`); + await notifySlack( + `[summaries/latest] rate_limited: retry in ${Math.ceil(retryAfterMs / 1000)}s` + ); + await audit('rate_limited'); return new Response(JSON.stringify({ error: 'rate_limited', retryAfterMs }), { status: 429, headers: { @@ -179,8 +254,8 @@ export const GET: RequestHandler = async ({ url }) => { }); } SUMMARY_REQ_TIMESTAMPS.push(now); - if (joined.length > MAX_CHARS) { - joined = joined.slice(0, MAX_CHARS); + if (joined.length > MAX_CHARS_ENV) { + joined = joined.slice(0, MAX_CHARS_ENV); } const OPENAI_API_KEY = env('OPENAI_API_KEY'); @@ -189,6 +264,7 @@ export const GET: RequestHandler = async ({ url }) => { if (!OPENAI_API_KEY) { // Notice ops (no API key) await notifySlack('[summaries/latest] missing OPENAI_API_KEY'); + await audit('missing_key'); return new Response( JSON.stringify({ error: 'missing_api_key', @@ -243,6 +319,7 @@ export const GET: RequestHandler = async ({ url }) => { if (err?.name === 'AbortError' || err?.message === 'timeout') { // Notice ops on timeouts await notifySlack(`[summaries/latest] openai_timeout after ${timeoutMs}ms`); + await audit('timeout'); return new Response(JSON.stringify({ error: 'openai_timeout', timeoutMs }), { status: 504, headers: { 'Content-Type': 'application/json' } @@ -262,12 +339,16 @@ export const GET: RequestHandler = async ({ url }) => { phase, windowMinutes: sinceMin, accountsUsed, - liveBin: phase === 'live' && liveBin ? { index: liveBin.index, startMinute: liveBin.startMinute, endMinute: liveBin.endMinute } : null, + liveBin: + phase === 'live' && liveBin + ? { index: liveBin.index, startMinute: liveBin.startMinute, endMinute: liveBin.endMinute } + : null, model: OPENAI_MODEL, summary: content, usage }; + await audit('ok', { usage }); return new Response(JSON.stringify(response), { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' } }); @@ -277,6 +358,37 @@ export const GET: RequestHandler = async ({ url }) => { console.error('[summaries/latest] failed', { message: e?.message, stack: e?.stack }); } catch {} await notifySlack(`[summaries/latest] summary_failed: ${e?.message ?? 'Unknown error'}`); + try { + const supaUrl = process.env.SUPABASE_URL || process.env.PUBLIC_SUPABASE_URL; + const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + if (supaUrl && serviceKey) { + const body = { + match_id: 'unknown', + platform: 'combined', + phase: 'live', + window_minutes: 0, + posts_count: 0, + chars_count: 0, + model: env('OPENAI_MODEL', 'gpt-5'), + prompt_tokens: null, + completion_tokens: null, + total_tokens: null, + status: 'failed' as const, + error_message: e?.message ?? 'Unknown error', + duration_ms: null as any + }; + await fetch(`${supaUrl}/rest/v1/summary_requests`, { + method: 'POST', + headers: { + apikey: serviceKey, + Authorization: `Bearer ${serviceKey}`, + 'Content-Type': 'application/json', + Prefer: 'return=minimal' + }, + body: JSON.stringify(body) + }); + } + } catch {} return new Response(JSON.stringify({ error: 'summary_failed', message: e?.message ?? 'Unknown error' }), { status: 500, headers: { 'Content-Type': 'application/json' } diff --git a/src/routes/api/summaries/latest/budget.test.ts b/src/routes/api/summaries/latest/budget.test.ts new file mode 100644 index 0000000..6f7b11d --- /dev/null +++ b/src/routes/api/summaries/latest/budget.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { GET } from './+server'; + +// Mock OpenAI SDK +vi.mock('openai', () => { + class MockChat { + completions = { + create: vi.fn().mockResolvedValue({ + choices: [{ message: { content: 'Mock summary content.' } }], + usage: { prompt_tokens: 100, completion_tokens: 50, total_tokens: 150 } + }) + }; + } + return { + default: class OpenAI { + chat = new MockChat(); + } + }; +}); + +// Mock Bluesky services to generate many posts so prompt exceeds budget +vi.mock('$lib/services/bskyService', () => { + return { + selectEligibleAccounts: vi.fn().mockResolvedValue([ + { + profile: { + did: 'did:example:1', + handle: 'user1.example', + displayName: 'User One' + }, + eligibility: { eligible: true, reasons: [] } + } + ]), + fetchRecentPostsForAccounts: vi.fn().mockImplementation(async (_accounts, _sinceMin) => { + const now = Date.now(); + // Generate many posts to exceed any small budget + const big: any[] = []; + for (let i = 0; i < 200; i++) { + big.push({ + uri: `at://did:example:1/app.bsky.feed.post/${i + 1}`, + cid: `cid${i + 1}`, + author: { did: 'did:example:1', handle: 'user1.example', displayName: 'User One' }, + text: 'x'.repeat(50), // 50 chars per post + createdAt: new Date(now - i * 1000).toISOString() + }); + } + return big; + }) + }; +}); + +describe('summaries/latest API - budget preflight trimming', () => { + let originalFetch: any; + + beforeEach(() => { + vi.useFakeTimers(); + process.env.OPENAI_API_KEY = 'test-key'; + + // Configure small model budget: MODEL_MAX_TOKENS=1000, reserve 600 for completion, 1 char/token => budget ~400 chars + process.env.SUMMARIES_MODEL_MAX_TOKENS = '1000'; + process.env.SUMMARIES_RESPONSE_TOKENS = '600'; + process.env.SUMMARIES_CHARS_PER_TOKEN = '1'; + + // Provide Supabase envs so audit runs; stub fetch to capture audit insert + process.env.SUPABASE_URL = 'http://localhost:54321'; + process.env.SUPABASE_SERVICE_ROLE_KEY = 'service-key'; + + originalFetch = (globalThis as any).fetch; + (globalThis as any).fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) }); + }); + + afterEach(() => { + vi.useRealTimers(); + delete process.env.OPENAI_API_KEY; + delete process.env.SUMMARIES_MODEL_MAX_TOKENS; + delete process.env.SUMMARIES_RESPONSE_TOKENS; + delete process.env.SUMMARIES_CHARS_PER_TOKEN; + delete process.env.SUPABASE_URL; + delete process.env.SUPABASE_SERVICE_ROLE_KEY; + (globalThis as any).fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it('trims joined prompt to budgetCharLimit (approx tokens->chars) and records trimmed chars_count in audit', async () => { + const kickoffISO = '2025-10-19T11:30:00.000Z'; + const nowMs = Date.parse('2025-10-19T12:00:00.000Z'); + vi.setSystemTime(nowMs); + + const params = new URLSearchParams({ + matchId: 'TEST-BUDGET', + kickoff: kickoffISO, + mode: 'live', + platform: 'bsky' + }); + const url = new URL(`http://localhost/api/summaries/latest?${params.toString()}`); + const req = new Request(url); + + const res = await GET({ url, request: req } as any); + expect(res.status).toBe(200); + + // Capture audit insert call + const calls = ((globalThis as any).fetch as any).mock.calls.filter((c: any[]) => + String(c[0]).includes('/rest/v1/summary_requests') + ); + expect(calls.length).toBeGreaterThan(0); + const body = JSON.parse(calls[calls.length - 1][1].body); + // Budget per config above: availableTokens = 1000 - 600 = 400 chars + // Audit should reflect joined length after trimming, so ensure <= 400 + expect(body.chars_count).toBeLessThanOrEqual(400); + // Posts_count should also be bounded by SUMMARIES_MAX_POSTS default (150) + expect(body.posts_count).toBeLessThanOrEqual(150); + }); +}); diff --git a/src/routes/live/bsky/stream.sse/+server.ts b/src/routes/live/bsky/stream.sse/+server.ts index 5a85776..924e848 100644 --- a/src/routes/live/bsky/stream.sse/+server.ts +++ b/src/routes/live/bsky/stream.sse/+server.ts @@ -63,10 +63,14 @@ export const GET: RequestHandler = async ({ setHeaders, url, request }) => { sinceMin, mode: forceWindow ? `force:${forceWindow}` : 'dynamic' }; - controller.enqueue(encoder.encode(`: stream start\n`)); - // Suggest client reconnection delay (ms) - controller.enqueue(encoder.encode(`retry: ${Math.max(1000, intervalSec * 1000)}\n`)); - controller.enqueue(encoder.encode(`event: meta\ndata: ${JSON.stringify(intro)}\n\n`)); + // Batch initial frames into a single chunk so tests/readers observe them together + const init = `: stream start +retry: ${Math.max(1000, intervalSec * 1000)} +event: meta +data: ${JSON.stringify(intro)} + +`; + controller.enqueue(encoder.encode(init)); // Heartbeat comments to keep proxies/connections alive pinger = setInterval(() => { diff --git a/supabase/migrations/005_create_summary_requests.sql b/supabase/migrations/005_create_summary_requests.sql new file mode 100644 index 0000000..31aa35a --- /dev/null +++ b/supabase/migrations/005_create_summary_requests.sql @@ -0,0 +1,38 @@ +-- Audit log for summaries API requests to track cost, limits, and failures +create table if not exists public.summary_requests ( + id uuid primary key default gen_random_uuid(), + match_id text not null, + platform text not null check (platform in ('bsky', 'twitter', 'threads', 'combined')), + phase text not null check (phase in ('pre', 'live', 'post')), + window_minutes int not null, + posts_count int not null, + chars_count int not null, + model text, + prompt_tokens int, + completion_tokens int, + total_tokens int, + status text not null check (status in ('ok', 'rate_limited', 'missing_key', 'timeout', 'failed')), + error_message text, + duration_ms int, + created_at timestamptz not null default now() +); + +create index if not exists idx_summary_requests_match_phase + on public.summary_requests(match_id, platform, phase, created_at desc); + +-- RLS policy: read-only to anon/auth, writes via service role (admin API/server) +alter table public.summary_requests enable row level security; + +do $$ +begin + -- Allow anon/auth read + if not exists ( + select 1 from pg_policies + where schemaname = 'public' and tablename = 'summary_requests' and policyname = 'summary_requests_select_anon' + ) then + create policy summary_requests_select_anon on public.summary_requests + for select + to anon, authenticated + using (true); + end if; +end$$; diff --git a/svelte.config.js b/svelte.config.js index f4749e8..dde513f 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -1,13 +1,29 @@ -import adapter from "@sveltejs/adapter-vercel"; import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; +// Dynamically choose adapter: +// - Prefer @sveltejs/adapter-vercel (production) +// - Fallback to @sveltejs/adapter-auto for local/dev/editor environments +let vercelAdapter; +let autoAdapter; +let useVercel = false; + +try { + ({ default: vercelAdapter } = await import("@sveltejs/adapter-vercel")); + useVercel = true; +} catch { + ({ default: autoAdapter } = await import("@sveltejs/adapter-auto")); + useVercel = false; +} + const config = { preprocess: vitePreprocess(), kit: { - adapter: adapter({ - // Ensure Vercel uses Node.js 20 runtime for SvelteKit build/serve - runtime: "nodejs20.x" - }) + adapter: useVercel + ? vercelAdapter({ + // Ensure Vercel uses Node.js 20 runtime for SvelteKit build/serve + runtime: "nodejs20.x" + }) + : autoAdapter() } };