Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -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 };
|
||||
Reference in New Issue
Block a user