Files
vyndr/src/routes/grading.js
T

437 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 };