Sessions 5-7a: 955 tests, deployment ready

This commit is contained in:
Kev
2026-06-08 18:35:13 -04:00
parent 06b82624a2
commit 1fa04dc776
371 changed files with 49366 additions and 955 deletions
+436
View File
@@ -0,0 +1,436 @@
/**
* Resolution + correction endpoints.
*
* POST /api/grading/resolve — called by the ESPN poller at FINAL
* POST /api/grading/correct — called by the morning sweep (Section 8)
*
* Auth model:
* - X-VYNDR-Internal-Key header must match VYNDR_INTERNAL_KEY
* - Source IP must be loopback (127.0.0.1 / ::1 / ::ffff:127.0.0.1)
* Two-factor "in-cluster only": even if the key leaks, an attacker still
* needs a foothold inside Docker's network. PM2 pollers and the morning
* cron run from the same host, so this is fine in practice.
*
* Service-role Supabase: we read & write across every user's grade_history
* for the game, bypassing RLS intentionally.
*/
const express = require('express');
const path = require('path');
const { getSupabaseServiceClient } = require('../utils/supabase');
const { getSportConfig } = require('../config/sports');
const { logResolution } = require('../services/training/jsonlLogger');
const webPush = require('../services/distribution/webPush');
const telegram = require('../services/distribution/telegram');
const discord = require('../services/distribution/discord');
const clvTracker = require('../services/intelligence/clvTracker');
const accuracyTracker = require('../services/intelligence/accuracyTracker');
const weightAdjuster = require('../services/intelligence/weightAdjuster');
const router = express.Router();
const LOOPBACK_IPS = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
function requireInternal(req, res, next) {
const expected = process.env.VYNDR_INTERNAL_KEY;
if (!expected) {
// Refuse to serve if the secret isn't configured — better than
// accidentally exposing the endpoint with a default value.
return res.status(503).json({ error: 'Internal auth not configured' });
}
const provided = req.get('X-VYNDR-Internal-Key');
if (!provided || provided !== expected) {
return res.status(401).json({ error: 'Invalid internal key' });
}
const remoteIp = req.ip || req.socket?.remoteAddress;
if (!LOOPBACK_IPS.has(remoteIp)) {
return res.status(403).json({ error: 'Origin not permitted' });
}
return next();
}
// ---------------------------------------------------------------
// Box-score traversal — sport-specific shapes flattened into a uniform
// { playerEspnId, statsBag } pair so downstream calculateStat doesn't
// need to know which sport produced the box.
// ---------------------------------------------------------------
function indexBasketballBox(boxScore) {
// Two teams, each with statistics[0].athletes[]
const out = new Map();
for (const team of boxScore?.boxscore?.players || []) {
const stats = team?.statistics?.[0];
if (!stats?.athletes) continue;
for (const a of stats.athletes) {
const id = a?.athlete?.id || a?.id;
if (!id) continue;
out.set(String(id), {
statsBag: a.stats || [],
starter: !!a.starter,
});
}
}
return out;
}
function indexMlbBox(boxScore) {
const out = new Map();
const teams = boxScore?.liveData?.boxscore?.teams || {};
for (const side of ['home', 'away']) {
const players = teams[side]?.players || {};
for (const key of Object.keys(players)) {
const p = players[key];
// MLB Stats API "person" carries a numeric ID. The player_id_map's
// mlbam_id column is matched to this for ESPN-graded props.
const id = p?.person?.id;
if (!id) continue;
// Aggregate batting + pitching stats into a single bag.
out.set(String(id), {
statsBag: { ...(p.stats?.batting || {}), ...(p.stats?.pitching || {}) },
starter: !!p.gameStatus?.isCurrentBatter,
});
}
}
return out;
}
function indexFootballBox(boxScore) {
// statistics is an array of categories: passing, rushing, receiving, …
const out = new Map();
for (const team of boxScore?.boxscore?.players || []) {
const cats = team?.statistics || [];
if (!Array.isArray(cats)) continue;
for (const cat of cats) {
for (const a of cat?.athletes || []) {
const id = a?.athlete?.id || a?.id;
if (!id) continue;
const existing = out.get(String(id)) || { statsBag: {}, starter: !!a.starter };
// Each category exposes a different stat label ordering; build a
// named map per category from labels[] × stats[].
const labels = cat?.labels || cat?.keys || [];
const stats = a?.stats || [];
const named = {};
labels.forEach((label, i) => { named[label] = stats[i]; });
existing.statsBag[cat.name || cat.text] = named;
out.set(String(id), existing);
}
}
}
return out;
}
function indexNhlBox(boxScore) {
// ESPN NHL surfaces statistics as a labels/stats pair per athlete.
const out = new Map();
for (const team of boxScore?.boxscore?.players || []) {
for (const cat of team?.statistics || []) {
const labels = cat?.labels || [];
for (const a of cat?.athletes || []) {
const id = a?.athlete?.id || a?.id;
if (!id) continue;
const stats = a?.stats || [];
const named = {};
labels.forEach((label, i) => { named[label.toLowerCase()] = Number(stats[i]) || 0; });
const existing = out.get(String(id)) || { statsBag: {}, starter: !!a.starter };
Object.assign(existing.statsBag, named);
out.set(String(id), existing);
}
}
}
return out;
}
function indexBoxScore(sport, boxScore) {
switch (sport) {
case 'nba':
case 'wnba':
case 'ncaab':
return indexBasketballBox(boxScore);
case 'nfl':
case 'ncaafb':
return indexFootballBox(boxScore);
case 'mlb':
return indexMlbBox(boxScore);
case 'nhl':
return indexNhlBox(boxScore);
default:
return new Map();
}
}
// calculateStat: pick the parse strategy based on which key the statMap
// entry carries. Format pecking order matches the four documented styles
// in src/config/sports.js.
function calculateStat(statsBag, statType, sportCfg) {
const map = sportCfg.statMap?.[statType];
if (!map) return null;
if (map.calc) return Number(map.calc(statsBag)) || 0;
if (map.mlbCalc) return Number(map.mlbCalc(statsBag)) || 0;
if (map.mlbField) {
const raw = statsBag?.[map.mlbField];
return map.parse ? map.parse(raw) : (Number(raw) || 0);
}
if (map.category) {
const cat = statsBag?.[map.category];
if (!cat) return 0;
return Number(cat[map.field]) || 0;
}
if (map.idx !== undefined) {
const raw = statsBag?.[map.idx];
if (raw === undefined || raw === null || raw === '') return 0;
return map.parse ? map.parse(raw) : (Number(raw) || 0);
}
if (map.field) return Number(statsBag?.[map.field]) || 0;
return 0;
}
function emoji(result) {
return { hit: '✅', miss: '❌', push: '➡️', void: '⚪' }[result] || '•';
}
// ---------------------------------------------------------------
// Resolution endpoint
// ---------------------------------------------------------------
async function handleVoidGame(supabase, gameId, reason) {
// Mark every unresolved prop for this game as void with the reason.
const { data: rows, error: lookupErr } = await supabase
.from('grade_history')
.select('id, player_id, player_name, stat_type, line, direction, grade, sport')
.eq('game_id', gameId)
.is('resolved_at', null);
if (lookupErr) throw lookupErr;
if (!rows || rows.length === 0) return { resolved: 0, voided: 0, results: [] };
const nowIso = new Date().toISOString();
const ids = rows.map((r) => r.id);
await supabase
.from('grade_history')
.update({ result: 'void', resolved_at: nowIso, correction_note: reason })
.in('id', ids);
return { resolved: 0, voided: rows.length, results: rows.map((r) => ({ ...r, result: 'void' })) };
}
router.post('/resolve', requireInternal, async (req, res) => {
const { gameId, sport, boxScore, void: isVoid, reason } = req.body || {};
if (!gameId || !sport) {
return res.status(400).json({ error: 'gameId and sport are required' });
}
let sportCfg;
try { sportCfg = getSportConfig(sport); }
catch (err) { return res.status(400).json({ error: err.message }); }
const supabase = getSupabaseServiceClient();
if (isVoid) {
try {
const summary = await handleVoidGame(supabase, gameId, reason || 'void');
return res.json(summary);
} catch (err) {
console.error('[VYNDR] Void resolution error:', err.message);
return res.status(503).json({ error: 'Void processing failed' });
}
}
if (!boxScore) return res.status(400).json({ error: 'boxScore required (or void: true)' });
// 1. Pull unresolved props for this game.
const { data: unresolved, error: lookupErr } = await supabase
.from('grade_history')
.select('id, player_id, player_name, stat_type, line, direction, grade, sport, projection, closing_line_id, factors')
.eq('game_id', gameId)
.is('resolved_at', null);
if (lookupErr) {
console.error('[VYNDR] grade_history lookup error:', lookupErr.message);
return res.status(503).json({ error: 'Resolution lookup failed' });
}
if (!unresolved || unresolved.length === 0) {
return res.json({ resolved: 0, voided: 0, results: [] });
}
// 2. Walk the box score into a per-player stats bag.
const boxIndex = indexBoxScore(sport, boxScore);
const nowIso = new Date().toISOString();
const updates = []; // { id, patch } per prop
const resolutionRows = []; // resolution_results inserts
const results = []; // response payload
for (const prop of unresolved) {
const playerEspnId = String(prop.player_id);
const found = boxIndex.get(playerEspnId);
if (!found) {
const patch = {
result: 'void',
resolved_at: nowIso,
correction_note: 'DNP - Player not in box score',
};
updates.push({ id: prop.id, patch });
resolutionRows.push({
grade_id: prop.id,
game_id: gameId,
sport,
player_espn_id: playerEspnId,
player_name: prop.player_name,
stat_type: prop.stat_type,
line: Number(prop.line),
direction: prop.direction,
actual_value: 0,
result: 'void',
margin: null,
correction_note: 'DNP',
});
results.push({ ...prop, result: 'void', actual_value: null });
continue;
}
const actual = calculateStat(found.statsBag, prop.stat_type, sportCfg);
let result, margin;
if (actual == null) {
result = 'void';
margin = null;
} else {
const line = Number(prop.line);
margin = actual - line;
if (prop.direction === 'over') {
if (actual > line) result = 'hit';
else if (actual < line) result = 'miss';
else result = 'push';
} else {
if (actual < line) result = 'hit';
else if (actual > line) result = 'miss';
else result = 'push';
}
}
const actualNum = actual == null ? 0 : actual;
updates.push({
id: prop.id,
patch: { result, actual_value: actualNum, margin, resolved_at: nowIso, was_starter: !!found.starter },
});
resolutionRows.push({
grade_id: prop.id,
game_id: gameId,
sport,
player_espn_id: playerEspnId,
player_name: prop.player_name,
stat_type: prop.stat_type,
line: Number(prop.line),
direction: prop.direction,
actual_value: actualNum,
result,
margin,
was_starter: !!found.starter,
closing_line_id: prop.closing_line_id || null,
});
results.push({ ...prop, result, actual_value: actualNum, margin });
}
// 3. Atomic-ish batch write — Supabase doesn't expose a true transaction,
// but a single .insert / single .upsert is one round-trip.
if (resolutionRows.length) {
const { error: insertErr } = await supabase.from('resolution_results').insert(resolutionRows);
if (insertErr) console.warn('[VYNDR] resolution_results insert error:', insertErr.message);
}
// grade_history updates can't be batched (different patches per row), but
// they're indexed by primary key so latency is bounded.
for (const u of updates) {
const { error: updErr } = await supabase.from('grade_history').update(u.patch).eq('id', u.id);
if (updErr) console.warn('[VYNDR] grade_history update error:', updErr.message);
}
// 4. Side effects — none can block the response or each other.
const sideEffects = [];
for (const r of results) {
sideEffects.push(Promise.resolve().then(() => {
logResolution({
sport,
player_espn_id: String(r.player_id),
player_name: r.player_name,
stat_type: r.stat_type,
line: Number(r.line),
direction: r.direction,
actual_value: r.actual_value,
result: r.result,
margin: r.margin,
grade: r.grade,
});
}));
if (webPush.configured() && r.result !== 'void') {
sideEffects.push(webPush.sendPushToSport(sport, {
title: 'VYNDR Grade Resolved',
body: `${r.player_name} ${r.direction} ${r.line} ${r.stat_type}: ${(r.result || '').toUpperCase()} ${emoji(r.result)} (actual ${r.actual_value}, margin ${r.margin})`,
url: '/ledger',
}, { kind: 'resolution' }));
}
if (telegram.configured?.()) {
sideEffects.push(telegram.postToTelegram({
text: `${emoji(r.result)} ${(r.result || '').toUpperCase()} | ${r.player_name} ${r.direction} ${r.line} ${r.stat_type} | Actual: ${r.actual_value} | Grade: ${r.grade}`,
}));
}
if (discord.webhookFor?.('results')) {
sideEffects.push(discord.postToDiscord('results', {
text: `${(r.result || '').toUpperCase()} ${emoji(r.result)}${r.player_name} ${r.direction} ${r.line} ${r.stat_type}${r.actual_value} (margin ${r.margin}) — Grade ${r.grade}`,
}));
}
// Learning-loop hooks — all fire-and-forget. None can block the
// response or each other. Failures inside any of these are logged
// by the service itself and never propagate up.
if (r.result === 'hit' || r.result === 'miss' || r.result === 'push' || r.result === 'void') {
sideEffects.push(accuracyTracker.recordResolution(sport, r.grade, r.result));
}
if (r.result === 'hit' || r.result === 'miss') {
sideEffects.push(clvTracker.computeCLV(r.id));
sideEffects.push(weightAdjuster.adjustWeights({
sport,
stat_type: r.stat_type,
grade: r.grade,
result: r.result,
factors: Array.isArray(r.factors) ? r.factors : [],
grade_id: r.id,
}));
}
}
// Fire-and-forget; one failure can't block another.
Promise.allSettled(sideEffects).catch(() => { /* swallowed */ });
const resolvedCount = results.filter((r) => r.result === 'hit' || r.result === 'miss' || r.result === 'push').length;
const voidedCount = results.length - resolvedCount;
return res.json({ resolved: resolvedCount, voided: voidedCount, results });
});
// ---------------------------------------------------------------
// POST /pipeline — called by n8n (or curl) to run the grading pipeline
// for one sport. Same auth as /resolve. The orchestrator handles all
// upstream calls; we just wrap it in HTTP with input validation.
// ---------------------------------------------------------------
const VALID_SPORTS = new Set(['nba', 'wnba', 'mlb', 'nfl', 'nhl', 'ncaab', 'ncaafb']);
router.post('/pipeline', requireInternal, async (req, res) => {
const { sport, options } = req.body || {};
if (!sport || !VALID_SPORTS.has(sport)) {
return res.status(400).json({ error: 'sport must be one of: nba, wnba, mlb, nfl, nhl, ncaab, ncaafb' });
}
// Lazy-load the orchestrator so this route doesn't pay the require cost
// until it's actually invoked (and so unit tests of /resolve don't pull
// in the whole adapter graph).
const { runPipeline } = require('../services/intelligence/gradingOrchestrator');
try {
const summary = await runPipeline(sport, options || {});
return res.json(summary);
} catch (err) {
console.error('[VYNDR] Pipeline error:', err.message);
return res.status(503).json({ error: 'Pipeline run failed' });
}
});
// Exported so server.js can wire it up with a larger body limit; also lets
// tests import the helper without binding to Express.
module.exports = router;
module.exports.__helpers = { calculateStat, indexBoxScore, requireInternal };