Session 32: Grades pipeline + NFL/NHL wiring + rate limiting + audit cleanup (1718 tests)
- gradeSlateService writes grades:{sport} cache (closes content pipeline →
dataLevel full); fire-and-forget from oddsService.recordDownstream, gated
by shouldGradeSlate (off in test, GRADE_SLATE_ON_FETCH override)
- NFL/NHL wired: oddsService SPORT_KEYS/SPORT_MARKETS (correct the-odds-api
keys americanfootball_nfl/icehockey_nhl), proplineAdapter MARKETS, NHL
MARKET_MAP keys to avoid silent-zero
- rate limiting mounted on 8 public cached routers (odds/parlay 30/min,
rest 60/min)
- jsonlLogger writes to temp under test (no more dirtied tracked artifact);
5MB pipeline test given 20s timeout
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Grade-slate writer (Session 32).
|
||||
*
|
||||
* Closes the content pipeline. The grading engine (engine1 via
|
||||
* analyzeViaEngine1) grades props on demand, but nothing ever persisted a
|
||||
* sport's graded slate. `contentTemplateService.collectSlateData` reads a
|
||||
* `grades:{sport}` cache "when present" — without a writer it was always
|
||||
* empty, so slate/POTD content degraded to lines/schedule and never reached
|
||||
* `dataLevel: 'full'`.
|
||||
*
|
||||
* This service grades a sport's freshly-fetched props and writes the
|
||||
* `grades:{sport}` cache in the exact shape contentTemplateService expects.
|
||||
* It is wired fire-and-forget into `oddsService.recordDownstream` so it runs
|
||||
* on a cache MISS (≈hourly) WITHOUT holding the odds HTTP response — content
|
||||
* endpoints read the grades cache independently and asynchronously.
|
||||
*
|
||||
* The legacy grade shape (player_name?/player, stat_type, line, direction,
|
||||
* grade, confidence, edge_pct, reasoning.summary) is already what
|
||||
* `contentTemplateService.normalizeGrade` reads — so no field remapping is
|
||||
* needed at the write boundary.
|
||||
*/
|
||||
|
||||
// Cost bounds: the odds slate carries one row per player+stat+line+book.
|
||||
// We dedupe to unique player+stat+line and cap how many we grade, because
|
||||
// each grade fans out to feature computation. Grading runs at most once per
|
||||
// cache-miss per sport, but we still bound the herd.
|
||||
const DEFAULT_LIMIT = 25;
|
||||
const DEFAULT_CONCURRENCY = 5;
|
||||
const DEFAULT_TTL = 7200; // 2 hours — matches the spec's grades-cache TTL.
|
||||
|
||||
// Collapse the multi-book prop rows to one entry per gradeable prop.
|
||||
function dedupeProps(props, limit) {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const p of props || []) {
|
||||
if (!p || !p.player || !p.stat_type || p.line == null) continue;
|
||||
const key = `${p.player}::${p.stat_type}::${p.line}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(p);
|
||||
if (out.length >= limit) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// engine1 is direction-aware, so a prop grades differently over vs under.
|
||||
// Grade both sides and keep the higher-confidence verdict — that's the
|
||||
// side the engine actually favors.
|
||||
async function gradeBestSide(grade, prop, sport) {
|
||||
const base = {
|
||||
player: prop.player,
|
||||
stat_type: prop.stat_type,
|
||||
line: prop.line,
|
||||
sport,
|
||||
book: prop.book,
|
||||
};
|
||||
const sides = await Promise.all([
|
||||
Promise.resolve()
|
||||
.then(() => grade({ ...base, direction: 'over' }))
|
||||
.catch(() => null),
|
||||
Promise.resolve()
|
||||
.then(() => grade({ ...base, direction: 'under' }))
|
||||
.catch(() => null),
|
||||
]);
|
||||
const cands = sides.filter(Boolean);
|
||||
if (cands.length === 0) return null;
|
||||
return cands.reduce((a, b) => ((Number(b.confidence) || 0) > (Number(a.confidence) || 0) ? b : a));
|
||||
}
|
||||
|
||||
// Run an async mapper over items with a bounded concurrency.
|
||||
async function mapLimit(items, concurrency, fn) {
|
||||
const results = new Array(items.length);
|
||||
let cursor = 0;
|
||||
async function worker() {
|
||||
while (cursor < items.length) {
|
||||
const i = cursor++;
|
||||
results[i] = await fn(items[i], i);
|
||||
}
|
||||
}
|
||||
const pool = Array.from({ length: Math.min(concurrency, items.length) }, () => worker());
|
||||
await Promise.all(pool);
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grade a sport's props and write the `grades:{sport}` cache.
|
||||
*
|
||||
* @param {string} sport
|
||||
* @param {Array} props normalized odds props (oddsNormalizer shape)
|
||||
* @param {Object} [opts]
|
||||
* @param {Function} [opts.grade] grader (default analyzeViaEngine1)
|
||||
* @param {Function} [opts.cacheSet] cache writer (default redis.cacheSet)
|
||||
* @param {string} [opts.source] provider tag ('propline' | 'odds-api')
|
||||
* @param {number} [opts.limit] max unique props graded
|
||||
* @param {number} [opts.ttl] cache TTL seconds
|
||||
* @param {Function} [opts.now] timestamp source (testable)
|
||||
* @returns {Promise<{written:boolean,count:number,error?:string}>}
|
||||
*/
|
||||
async function gradeAndCacheSlate(sport, props, opts = {}) {
|
||||
const grade = opts.grade || require('./intelligence/analyzeViaEngine1').analyzeViaEngine1;
|
||||
const cacheSet = opts.cacheSet || require('../utils/redis').cacheSet;
|
||||
const source = opts.source || 'odds-api';
|
||||
const limit = opts.limit || DEFAULT_LIMIT;
|
||||
const ttl = opts.ttl || DEFAULT_TTL;
|
||||
const concurrency = opts.concurrency || DEFAULT_CONCURRENCY;
|
||||
const now = opts.now || (() => new Date().toISOString());
|
||||
|
||||
if (!Array.isArray(props) || props.length === 0) {
|
||||
return { written: false, count: 0 };
|
||||
}
|
||||
|
||||
try {
|
||||
const unique = dedupeProps(props, limit);
|
||||
if (unique.length === 0) return { written: false, count: 0 };
|
||||
|
||||
const graded = (await mapLimit(unique, concurrency, (p) => gradeBestSide(grade, p, sport)))
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => (Number(b.confidence) || 0) - (Number(a.confidence) || 0));
|
||||
|
||||
if (graded.length === 0) return { written: false, count: 0 };
|
||||
|
||||
const envelope = { grades: graded, updated_at: now(), source };
|
||||
await cacheSet(`grades:${sport}`, envelope, ttl);
|
||||
return { written: true, count: graded.length };
|
||||
} catch (e) {
|
||||
// Best-effort — slate grading must never break odds delivery.
|
||||
console.warn('[gradeSlateService] grade slate failed:', e.message);
|
||||
return { written: false, count: 0, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
gradeAndCacheSlate,
|
||||
__internals: { dedupeProps, gradeBestSide, mapLimit, DEFAULT_LIMIT, DEFAULT_TTL, DEFAULT_CONCURRENCY },
|
||||
};
|
||||
Reference in New Issue
Block a user