/** * Grading pipeline orchestrator. * * Called by n8n at 10:30 AM, 1 PM, 4 PM, 6 PM ET (and on demand from the * /api/grading/pipeline endpoint). For one sport per call, it: * * 1. Pulls today's scoreboard from the sport config's ESPN endpoint. * We do NOT call SharpAPI for the slate — only for player props per * game. Scoreboard is the source of truth for which games exist. * 2. For each game, fetches player props via SharpAPI. * 3. For each prop, builds a feature vector + trap composite + * consistency score, then asks Engine 1 to grade. * 4. Persists the grade to grade_history. * 5. Queues A/B-tier grades for Engine 2. * 6. Drains the Engine 2 queue (best-effort, one batch). * * Failure semantics: * - SharpAPI down → 0 props graded, summary still returns. * - Per-prop error → log + skip, other props continue. * - Engine 2 queue failure → does not affect Engine 1 grades that * are already in the database. */ const axios = require('axios'); const { getSportConfig } = require('../../config/sports'); const { getSupabaseServiceClient } = require('../../utils/supabase'); const featureCache = require('./featureCache'); const trapDetection = require('./trapDetection'); const consistencyScore = require('./consistencyScore'); const engine1 = require('./engine1'); const engine2 = require('./engine2'); const gameLogService = require('./gameLogService'); const probabilityEstimator = require('./probabilityEstimator'); const sharpApi = require('../adapters/sharpApiAdapter'); const HTTP_TIMEOUT_MS = 15_000; async function fetchTodaysGames(sportCfg) { try { const res = await axios.get(sportCfg.espnScoreboard, { timeout: HTTP_TIMEOUT_MS }); const events = res.data?.events || []; return events.map((ev) => { const comp = ev?.competitions?.[0]; const teams = (comp?.competitors || []).reduce((acc, t) => { const role = t?.homeAway === 'home' ? 'home' : 'away'; acc[role] = { id: t?.id, abbr: t?.team?.abbreviation, name: t?.team?.displayName }; return acc; }, {}); return { gameId: String(ev.id), gameDate: ev?.date, home: teams.home, away: teams.away, state: ev?.status?.type?.state, }; }); } catch (err) { console.warn('[orchestrator] scoreboard fetch failed:', err.message); return []; } } async function buildPropContext(prop, game, sport) { // Determine whether this prop's player is on home or away team. We // don't have a roster lookup at this point of the pipeline; the orchestrator // treats prop.team (if SharpAPI provides) as the canonical, falling back // to "unknown" for home_away. const team = prop.team || prop.teamAbbr; const isHome = team && game.home?.abbr === team; const opponentAbbr = isHome ? game.away?.abbr : game.home?.abbr; return { playerId: prop.playerId || prop.player_id || null, playerName: prop.player, statType: prop.statType || prop.stat_type, sport, line: Number(prop.line), direction: prop.direction || 'over', teamAbbr: team, opponentAbbr, gameId: game.gameId, gameContext: { home_away: team ? (isHome ? 'home' : 'away') : null, }, }; } async function gradeProp(prop, game, sport) { const ctx = await buildPropContext(prop, game, sport); // Feature vector — every signal computed in 6b. const featurePayload = await featureCache.getFeatures({ playerId: ctx.playerId, playerName: ctx.playerName, statType: ctx.statType, sport: ctx.sport, teamAbbr: ctx.teamAbbr, opponentAbbr: ctx.opponentAbbr, gameId: ctx.gameId, gameContext: ctx.gameContext, }); const features = featurePayload?.features || {}; // Trap detector — uses features + lineMovement snapshots already in DB. const trap = await trapDetection.getTrapScore({ playerName: ctx.playerName, statType: ctx.statType, sport: ctx.sport, gameId: ctx.gameId, gameContext: ctx.gameContext, features, odds: { playerLine: ctx.line, consensus: prop.consensus }, }); // Consistency — Engine 2 uses this verbatim in its prompt. let consistency = { consistency: 'unknown', score: null, games: 0 }; let gameLogs = null; try { gameLogs = await gameLogService.getGameLogs(ctx.playerName, ctx.sport, 20); if (gameLogs && gameLogs.length) { consistency = await consistencyScore.getConsistency({ playerName: ctx.playerName, sport: ctx.sport, statType: ctx.statType, gameLogs, }); } } catch (err) { console.warn('[orchestrator] consistency failed for', ctx.playerName, err.message); } // P(Over) — quantile-based probability from game logs. We pass the same // game logs to the estimator that consistency uses, so both views agree // on the same data window. Null if no logs (Python service down). let probability = { p_over: null, p_under: null, components: {}, reason: 'no_logs' }; if (gameLogs && gameLogs.length) { probability = probabilityEstimator.estimateProbability({ gameLogs, line: ctx.line, statType: ctx.statType, features, }); } // Engine 1 — rule-based, deterministic. const result = engine1.gradeProp({ features, trap, consistency, prop: { line: ctx.line, direction: ctx.direction }, }); return { ctx, features, trap, consistency, probability, engine1Result: result }; } async function persistGrade(graded, prop, sport) { const supabase = getSupabaseServiceClient(); const { ctx, engine1Result, trap, consistency, features, probability } = graded; const row = { player_id: ctx.playerId, player_name: ctx.playerName, sport, stat_type: ctx.statType, line: ctx.line, direction: ctx.direction, grade: engine1Result.grade, projection: Number.isFinite(features.l5_avg) ? features.l5_avg : null, // modeled_prob is the implied probability from Engine 1's grade tier; // p_over is the quantile-based probability from game logs. Both useful // — the former for grade-vs-line edge math, the latter for UI display. modeled_prob: Number.isFinite(engine1Result?.confidence) ? engine1Result.confidence : null, implied_prob: null, p_over: Number.isFinite(probability?.p_over) ? probability.p_over : null, // factors drive the weight adjuster: each resolved prop's factors get // nudged based on hit/miss outcome. Stored as JSONB so we can also // surface them in the UI "why this grade" tooltip. factors: Array.isArray(engine1Result?.all_factors) ? engine1Result.all_factors : (Array.isArray(engine1Result?.top_factors) ? engine1Result.top_factors : null), game_date: new Date().toISOString().slice(0, 10), game_id: ctx.gameId, }; const { data, error } = await supabase.from('grade_history').insert(row).select('id').single(); if (error) { console.warn('[orchestrator] grade_history insert failed:', error.message); return null; } // Hand the gradeId + full context to engine2 so it can build a prompt. engine2.queueAnalysis(data.id, { player_name: ctx.playerName, team: ctx.teamAbbr, sport, direction: ctx.direction, line: ctx.line, stat_type: ctx.statType, home_team: prop._home, away_team: prop._away, game_date: row.game_date, engine1_grade: engine1Result.grade, engine1_factors: engine1Result.top_factors, features, trap, consistency, probability, recentGames: [], }); return data.id; } async function gradeProps(props, game, sport) { const out = []; for (const prop of props) { try { const graded = await gradeProp(prop, game, sport); const gradeId = await persistGrade(graded, { ...prop, _home: game.home?.name, _away: game.away?.name }, sport); out.push({ gradeId, grade: graded.engine1Result.grade, prop }); } catch (err) { console.warn('[orchestrator] gradeProp failed for', prop?.player, err.message); } } return out; } async function runPipeline(sport, options = {}) { const start = Date.now(); let sportCfg; try { sportCfg = getSportConfig(sport); } catch (err) { return { error: err.message, sport, games_processed: 0, props_graded: 0, duration_ms: Date.now() - start }; } const games = await fetchTodaysGames(sportCfg); if (games.length === 0) { return { sport, games_processed: 0, props_graded: 0, engine2_queued: 0, errors: 0, duration_ms: Date.now() - start }; } let propsGraded = 0; let errors = 0; let engine2Queued = 0; for (const game of games) { let props; try { props = await sharpApi.getPlayerProps(sport, game.gameId); } catch (err) { console.warn('[orchestrator] sharpApi failed for', game.gameId, err.message); errors += 1; continue; } if (!Array.isArray(props) || props.length === 0) continue; const before = engine2.getQueueSize(); const graded = await gradeProps(props, game, sport); propsGraded += graded.length; engine2Queued += engine2.getQueueSize() - before; } // Drain the Engine 2 queue with a bounded loop. Each processQueue() // call handles ENGINE2_BATCH_SIZE items, so for slates of ~50+ A/B // grades one call would leave most of the queue parked. Cap at 5 // iterations (≈50 props per pipeline run with default batch size) // — beyond that, the next pipeline cycle picks up the remainder. let engine2Summary = { processed: 0, succeeded: 0, failed: 0, remaining: engine2.getQueueSize() }; if (!options.skipEngine2) { const MAX_DRAIN_ITERS = 5; let drainIters = 0; const totals = { processed: 0, succeeded: 0, failed: 0 }; while (engine2.getQueueSize() > 0 && drainIters < MAX_DRAIN_ITERS) { const round = await engine2.processQueue(); totals.processed += round.processed || 0; totals.succeeded += round.succeeded || 0; totals.failed += round.failed || 0; drainIters += 1; // If a round processes 0 items, the queue is stuck (likely // disabled or all calls failing) — break early instead of looping. if ((round.processed || 0) === 0) break; } engine2Summary = { ...totals, remaining: engine2.getQueueSize(), iterations: drainIters }; } return { sport, games_processed: games.length, props_graded: propsGraded, engine2_queued: engine2Queued, engine2_summary: engine2Summary, errors, duration_ms: Date.now() - start, }; } function getEngineStatus() { return { engine2_queue_size: engine2.getQueueSize(), adapters_configured: { sharp_api: sharpApi.configured(), open_router: require('../adapters/openRouterAdapter').configured(), }, }; } module.exports = { runPipeline, gradeProps, gradeProp, getEngineStatus, __internals: { fetchTodaysGames, buildPropContext, persistGrade }, };