Session 29: Content generation templates — slate threads, POTD, recaps, matchup previews (1660 tests)

This commit is contained in:
Kev
2026-06-13 21:30:57 -04:00
parent c48aecd510
commit 927c4a5c65
11 changed files with 1063 additions and 1 deletions
+4
View File
@@ -159,6 +159,10 @@ const lineMovementRoutes = require('./routes/lineMovement');
app.use('/api/lines', lineMovementRoutes);
const bookComparisonRoutes = require('./routes/bookComparison');
app.use('/api/books', bookComparisonRoutes);
// Session 29 — content templates: structured social/newsletter content
// generated from live data, degrading gracefully by data level.
const contentRoutes = require('./routes/content');
app.use('/api/content', contentRoutes);
// Session 18 — internal ops endpoints (admin dashboard triggers,
// shared-key auth via `VYNDR_INTERNAL_KEY`). Never reachable from
// the public surface; the Next.js admin route proxies through with
+102
View File
@@ -0,0 +1,102 @@
'use strict';
/**
* /api/content (Session 29)
*
* Structured content objects generated from live VYNDR data, degrading
* gracefully by data level. Read-only, zero-credit (reads cached data).
* `?format=text` additionally returns post-ready plain text.
*
* GET /api/content/slate/:sport → daily slate thread
* GET /api/content/potd/:sport → prop (or game) of the day
* GET /api/content/recap/:sport → results recap (needs resolved grades)
* GET /api/content/preview/:sport/:gameId → matchup preview for one game
*/
const express = require('express');
const template = require('../services/contentTemplateService');
const formatter = require('../services/contentFormatter');
const { cacheGet } = require('../utils/redis');
const router = express.Router();
const MISSION_HEADER = { 'X-VYNDR-Mission': 'Every post is a free ad' };
const SUPPORTED = new Set(['nba', 'wnba', 'mlb', 'soccer', 'nfl', 'nhl']);
function guard(req, res) {
const sport = String(req.params.sport || '').toLowerCase();
if (!SUPPORTED.has(sport)) {
res.status(404).set(MISSION_HEADER).json({ error: `No content for sport: ${sport}` });
return null;
}
return sport;
}
router.get('/slate/:sport', async (req, res) => {
const sport = guard(req, res);
if (!sport) return undefined;
try {
const data = await template.collectSlateData(sport);
const thread = template.generateSlateThread(sport, data);
const body = { ...thread };
if (req.query.format === 'text') body.text = formatter.formatSlateThread(thread);
return res.set(MISSION_HEADER).json(body);
} catch (err) {
console.error(`[content/slate/${sport}]`, err.message);
return res.status(500).set(MISSION_HEADER).json({ error: 'Content generation failed' });
}
});
router.get('/potd/:sport', async (req, res) => {
const sport = guard(req, res);
if (!sport) return undefined;
try {
const data = await template.collectSlateData(sport);
const potd = template.generatePOTD(sport, data);
const body = { ...potd };
if (req.query.format === 'text') body.text = formatter.formatPOTD(potd);
return res.set(MISSION_HEADER).json(body);
} catch (err) {
console.error(`[content/potd/${sport}]`, err.message);
return res.status(500).set(MISSION_HEADER).json({ error: 'Content generation failed' });
}
});
router.get('/recap/:sport', async (req, res) => {
const sport = guard(req, res);
if (!sport) return undefined;
try {
// Resolved grades land in a daily cache when the grading flow settles.
const utc = new Date().toISOString().split('T')[0];
const cache = (await cacheGet(`results:${sport}:${utc}`)) ?? (await cacheGet(`results:${sport}`));
const resolved = Array.isArray(cache) ? cache : Array.isArray(cache?.grades) ? cache.grades : [];
const recap = template.generateResultsRecap(sport, resolved);
const body = { ...recap };
if (req.query.format === 'text') body.text = formatter.formatRecap(recap);
return res.set(MISSION_HEADER).json(body);
} catch (err) {
console.error(`[content/recap/${sport}]`, err.message);
return res.status(500).set(MISSION_HEADER).json({ error: 'Content generation failed' });
}
});
router.get('/preview/:sport/:gameId', async (req, res) => {
const sport = guard(req, res);
if (!sport) return undefined;
const { gameId } = req.params;
try {
const data = await template.collectSlateData(sport);
const game = (data.schedule || []).find((g) => String(g.id) === String(gameId));
if (!game) {
return res.status(404).set(MISSION_HEADER).json({ error: 'Game not on today\'s schedule.' });
}
const lines = template.__internals.findGameLinesFor(game, data.gameLines);
const preview = template.generateMatchupPreview(game, lines, data.streaks);
return res.set(MISSION_HEADER).json(preview);
} catch (err) {
console.error(`[content/preview/${sport}]`, err.message);
return res.status(500).set(MISSION_HEADER).json({ error: 'Content generation failed' });
}
});
module.exports = router;
+105
View File
@@ -0,0 +1,105 @@
'use strict';
/**
* Content formatter (Session 29).
*
* Transforms structured content objects (from contentTemplateService) into
* platform-ready PLAIN TEXT — suitable for X threads / Telegram. Image
* formatting is a separate design-layer concern. Pure + defensive: a
* missing field renders as a sensible blank, never "undefined".
*/
function fmtOdds(o) {
const n = Number(o);
if (!Number.isFinite(n)) return String(o ?? '');
return n > 0 ? `+${n}` : `${n}`;
}
function fmtTime(iso) {
if (!iso) return '';
try {
return new Date(iso).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', timeZoneName: 'short' });
} catch {
return String(iso);
}
}
function formatPickPost(p) {
const lines = [`${p.player} · ${p.stat} ${p.side || ''} ${p.line ?? ''}`.replace(/\s+/g, ' ').trim()];
lines.push(`Grade: ${p.grade}${p.confidence ? ` · ${p.confidence}% confidence` : ''}`);
if (p.edge) lines.push(`Edge: ${p.edge > 0 ? '+' : ''}${p.edge}%`);
if (p.analysis) lines.push(p.analysis);
return lines.join('\n');
}
function formatGameHighlight(p) {
const parts = [`${p.game}${p.time ? ` · ${fmtTime(p.time)}` : ''}`];
if (p.bestAwayML) parts.push(`Best away ML: ${fmtOdds(p.bestAwayML.odds)} (${p.bestAwayML.book})`);
if (p.bestHomeML) parts.push(`Best home ML: ${fmtOdds(p.bestHomeML.odds)} (${p.bestHomeML.book})`);
if (p.total != null) parts.push(`O/U ${p.total}`);
if (p.bookCount) parts.push(`${p.bookCount} books`);
return parts.join('\n');
}
function formatMovers(p) {
const head = '📈 Biggest line moves:';
const rows = (p.movers || []).map((m) => {
const d = m.delta > 0 ? `+${m.delta}` : `${m.delta}`;
return `${m.player} ${String(m.stat || '').replace(/_/g, ' ')}: ${m.opening ?? '?'}${m.current ?? '?'} (${d})${m.sharpSignal ? ' ⚡' : ''}`;
});
return [head, ...rows].join('\n');
}
function formatSchedule(p) {
return (p.games || [])
.map((g) => `${g.away || '?'} @ ${g.home || '?'}${g.time ? ` · ${fmtTime(g.time)}` : ''}`)
.join('\n');
}
function formatPost(post) {
switch (post.role) {
case 'hook': return post.text || '';
case 'cta': return post.text || '';
case 'pick': return formatPickPost(post);
case 'game_highlight': return formatGameHighlight(post);
case 'movers': return formatMovers(post);
case 'schedule': return formatSchedule(post);
default: return '';
}
}
/** Returns an array of post-ready strings (one per thread post). */
function formatSlateThread(thread) {
if (!thread || !Array.isArray(thread.posts)) return [];
return thread.posts.map(formatPost);
}
/** Single-block POTD text. */
function formatPOTD(potd) {
if (!potd || potd.available === false) return 'No standout pick today — check the full slate at vyndr.app.';
if (potd.dataLevel === 'lines') {
return `🎯 GAME OF THE DAY\n${potd.game}${potd.time ? ` · ${fmtTime(potd.time)}` : ''}\n${potd.total != null ? `O/U ${potd.total}` : ''}${potd.bestHomeML ? `\nBest home ML ${fmtOdds(potd.bestHomeML.odds)} (${potd.bestHomeML.book})` : ''}`.trim();
}
return `🎯 PROP OF THE DAY\n${formatPickPost(potd)}`;
}
/** Recap text block. */
function formatRecap(recap) {
if (!recap || recap.available === false) return 'No graded results in yet today.';
const { record, winRate } = recap;
const lines = [`📊 VYNDR RECAP — ${recap.date}`];
lines.push(`Record: ${record.wins}-${record.losses}${record.pushes ? `-${record.pushes}` : ''}${winRate != null ? ` (${Math.round(winRate * 100)}%)` : ''}`);
if (recap.topHits && recap.topHits.length) {
lines.push('Top hits:');
for (const h of recap.topHits) lines.push(`${h.player} ${h.stat} ${h.side || ''} ${h.line ?? ''}`.replace(/\s+/g, ' ').trim());
}
if (recap.metrics && recap.metrics.brierScore != null) lines.push(`Brier: ${recap.metrics.brierScore}`);
return lines.join('\n');
}
module.exports = {
formatSlateThread,
formatPOTD,
formatRecap,
__internals: { formatPost, formatPickPost, formatGameHighlight, formatMovers, formatSchedule },
};
+421
View File
@@ -0,0 +1,421 @@
'use strict';
/**
* Content template engine (Session 29).
*
* Generates STRUCTURED DATA objects (not text, not images) from live VYNDR
* data. A separate formatter (contentFormatter) renders text; a future
* design layer renders images.
*
* Templates degrade gracefully by data level:
* full → graded props exist → rich content (grade, edge, projection)
* lines → Tank01 game lines exist → line-focused content
* schedule → only ESPN schedule → matchup/time content
* empty → nothing today → minimal, never a crash
*
* The pure generators take a `data` bundle and never touch the network.
* `collectSlateData` is the thin orchestrator that gathers the bundle from
* the live services; its collectors are injectable so it stays testable.
*/
const GRADE_RANK = { 'A+': 0, A: 1, 'A-': 2, 'B+': 3, B: 4, 'B-': 5, 'C+': 6, C: 7, 'C-': 8, 'D+': 9, D: 10, 'D-': 11, F: 12 };
const SITE = 'vyndr.app';
// ---- field-alias normalizers (grades come from several shapes) --------
function gradeField(g, ...keys) {
for (const k of keys) if (g && g[k] != null && g[k] !== '') return g[k];
return null;
}
function normalizeGrade(g) {
return {
player: gradeField(g, 'player_name', 'player'),
stat: gradeField(g, 'stat_type', 'stat'),
line: gradeField(g, 'line'),
side: gradeField(g, 'side', 'direction'),
grade: gradeField(g, 'grade') || 'C',
confidence: Number(gradeField(g, 'confidence')) || 0,
edge: Number(gradeField(g, 'edge', 'edge_pct')) || 0,
projection: gradeField(g, 'projection'),
matchup: gradeField(g, 'matchup'),
killConditions: g?.killConditions || g?.kill_conditions_triggered || [],
analysis: gradeField(g, 'analysis', 'one_line_reason') || (g?.reasoning && g.reasoning.summary) || null,
_raw: g,
};
}
// ---- data level -------------------------------------------------------
function determineDataLevel(data) {
const grades = data?.grades || [];
const gameLines = data?.gameLines || {};
const schedule = data?.schedule || [];
if (grades.length > 0) return 'full';
if (gameLines && Object.keys(gameLines).length > 0) return 'lines';
if (schedule.length > 0) return 'schedule';
return 'empty';
}
function prettyDate(now = Date.now()) {
return new Date(now).toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' });
}
function sportLabel(sport) {
return String(sport || '').toUpperCase();
}
// ---- game-line highlight extraction -----------------------------------
function numOdds(v) {
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
function americanToDecimal(odds) {
const n = Number(odds);
if (!Number.isFinite(n) || n === 0) return null;
return n > 0 ? 1 + n / 100 : 1 + 100 / Math.abs(n);
}
// books: array of [bookName, { homeML, awayML, total, homeSpread, ... }]
function bestOdds(books, field) {
let best = null;
for (const [book, line] of books) {
const dec = americanToDecimal(line?.[field]);
if (dec == null) continue;
if (!best || dec > best.decimal) best = { book, odds: line[field], decimal: dec };
}
return best;
}
function consensus(books, field) {
const vals = books.map(([, l]) => Number(l?.[field])).filter((n) => Number.isFinite(n));
if (vals.length === 0) return null;
return Math.round((vals.reduce((a, b) => a + b, 0) / vals.length) * 10) / 10;
}
// Disagreement = spread between best & worst home-ML decimal payout.
function bookDisagreement(books) {
const decs = books.map(([, l]) => americanToDecimal(l?.homeML)).filter((n) => n != null);
if (decs.length < 2) return 0;
return Math.round((Math.max(...decs) - Math.min(...decs)) * 100) / 100;
}
function findGameLinesFor(game, gameLines) {
if (!gameLines) return null;
const h = (game.homeTeam?.abbreviation || '').toUpperCase();
const a = (game.awayTeam?.abbreviation || '').toUpperCase();
for (const entry of Object.values(gameLines)) {
const eh = String(entry.homeTeam || '').toUpperCase();
const ea = String(entry.awayTeam || '').toUpperCase();
if ((eh === h && ea === a) || (eh === a && ea === h)) return entry;
}
return null;
}
function extractGameLineHighlights(schedule, gameLines) {
const highlights = [];
for (const game of schedule || []) {
const lines = findGameLinesFor(game, gameLines);
const books = lines && lines.books ? Object.entries(lines.books) : [];
if (books.length === 0) continue;
highlights.push({
type: 'game_line_highlight',
game: `${game.awayTeam?.abbreviation || '?'} @ ${game.homeTeam?.abbreviation || '?'}`,
time: game.gameTime || null,
venue: game.venue || null,
bookCount: books.length,
bestHomeML: bestOdds(books, 'homeML'),
bestAwayML: bestOdds(books, 'awayML'),
total: consensus(books, 'total'),
spread: consensus(books, 'homeSpread'),
disagreement: bookDisagreement(books),
});
}
return highlights.sort((a, b) => (b.disagreement || 0) - (a.disagreement || 0));
}
// ---- hook + CTA -------------------------------------------------------
function generateHookText(sport, data) {
const label = sportLabel(sport);
const date = prettyDate();
const games = (data.schedule || []).length;
const bookCount = maxBookCount(data.gameLines);
if (data.dataLevel === 'full') {
return `🔥 VYNDR SLATE — ${date}. ${games} ${label} games graded${bookCount ? ` · lines from ${bookCount} books` : ''}. Top reads below. 🧵`;
}
if (data.dataLevel === 'lines') {
return `🔥 VYNDR SLATE — ${date}. ${games} ${label} games · lines from ${bookCount} sportsbooks. Here's what stands out. 🧵`;
}
if (data.dataLevel === 'schedule') {
return `🔥 VYNDR SLATE — ${date}. ${games} ${label} games on the board today. 🧵`;
}
return `VYNDR — ${date}. No ${label} games today. Back tomorrow.`;
}
function maxBookCount(gameLines) {
if (!gameLines) return 0;
let max = 0;
for (const entry of Object.values(gameLines)) {
const n = entry && entry.books ? Object.keys(entry.books).length : 0;
if (n > max) max = n;
}
return max;
}
function generateCTAText(dataLevel) {
if (dataLevel === 'empty') return `Full slate + intelligence at ${SITE}.`;
return `Full slate live at ${SITE}. Free tier: reads on us. Founder pricing from $14.99/mo.`;
}
function generateAnalysisLine(grade) {
if (grade.analysis) return grade.analysis;
const edge = grade.edge ? `${grade.edge > 0 ? '+' : ''}${grade.edge}% edge` : null;
const proj = grade.projection != null ? `projection ${grade.projection}` : null;
return [proj, edge].filter(Boolean).join(' · ') || 'Model edge on this line.';
}
// ---- slate thread -----------------------------------------------------
function generateSlateThread(sport, data) {
const dataLevel = data.dataLevel || determineDataLevel(data);
const enriched = { ...data, dataLevel };
const thread = { type: 'slate_thread', sport, date: prettyDate(), dataLevel, posts: [] };
thread.posts.push({ role: 'hook', text: generateHookText(sport, enriched) });
if (dataLevel === 'full') {
const top = (data.grades || []).map(normalizeGrade)
.sort((a, b) => b.confidence - a.confidence)
.slice(0, 5);
for (const g of top) {
thread.posts.push({
role: 'pick',
player: g.player, stat: g.stat, line: g.line, side: g.side,
grade: g.grade, confidence: g.confidence, edge: g.edge, projection: g.projection,
analysis: generateAnalysisLine(g), cardData: g._raw,
});
}
} else if (dataLevel === 'lines') {
const highlights = extractGameLineHighlights(data.schedule, data.gameLines);
for (const h of highlights.slice(0, 4)) thread.posts.push({ role: 'game_highlight', ...h });
if ((data.movers || []).length > 0) {
thread.posts.push({ role: 'movers', movers: data.movers.slice(0, 3) });
}
} else if (dataLevel === 'schedule') {
const games = (data.schedule || []).slice(0, 8).map((g) => ({
away: g.awayTeam?.name || g.awayTeam?.abbreviation || null,
home: g.homeTeam?.name || g.homeTeam?.abbreviation || null,
time: g.gameTime || null,
venue: g.venue || null,
}));
thread.posts.push({ role: 'schedule', games });
}
thread.posts.push({ role: 'cta', text: generateCTAText(dataLevel) });
return thread;
}
// ---- prop / game of the day -------------------------------------------
function generatePOTD(sport, data) {
const dataLevel = data.dataLevel || determineDataLevel(data);
if (dataLevel === 'full' && (data.grades || []).length > 0) {
const best = (data.grades || []).map(normalizeGrade).reduce((a, b) => (b.confidence > a.confidence ? b : a));
return {
type: 'potd', dataLevel: 'full',
player: best.player, stat: best.stat, line: best.line, side: best.side,
grade: best.grade, confidence: best.confidence, edge: best.edge,
projection: best.projection, matchup: best.matchup,
killConditions: best.killConditions,
analysis: generateAnalysisLine(best), cardData: best._raw,
};
}
if (dataLevel === 'lines') {
const top = extractGameLineHighlights(data.schedule, data.gameLines)[0];
if (top) return { type: 'potd', dataLevel: 'lines', subtype: 'game_of_the_day', ...top };
}
return { type: 'potd', dataLevel: 'empty', available: false };
}
// ---- results recap (pure, takes resolved grades) ----------------------
function gradeTier(grade) {
return String(grade || '').charAt(0).toUpperCase();
}
function tierRecord(grades, tier) {
const inTier = grades.filter((g) => gradeTier(gradeField(g, 'grade')) === tier);
const wins = inTier.filter((g) => g.result === 'win').length;
const losses = inTier.filter((g) => g.result === 'loss').length;
return { wins, losses, total: inTier.length };
}
function brierScore(grades) {
// Brier = mean( (p - outcome)^2 ); p from confidence%, outcome 1 win / 0 loss.
const scored = grades.filter((g) => g.result === 'win' || g.result === 'loss');
if (scored.length === 0) return null;
const sum = scored.reduce((acc, g) => {
const p = (Number(gradeField(g, 'confidence')) || 50) / 100;
const outcome = g.result === 'win' ? 1 : 0;
return acc + (p - outcome) ** 2;
}, 0);
return Math.round((sum / scored.length) * 1000) / 1000;
}
function avgCLV(grades) {
const vals = grades.map((g) => Number(gradeField(g, 'clv'))).filter((n) => Number.isFinite(n));
if (vals.length === 0) return null;
return Math.round((vals.reduce((a, b) => a + b, 0) / vals.length) * 100) / 100;
}
function generateResultsRecap(sport, resolvedGrades) {
if (!Array.isArray(resolvedGrades) || resolvedGrades.length === 0) {
return { type: 'recap', available: false };
}
const wins = resolvedGrades.filter((g) => g.result === 'win');
const losses = resolvedGrades.filter((g) => g.result === 'loss');
const pushes = resolvedGrades.filter((g) => g.result === 'push');
const decided = wins.length + losses.length;
const byConfidence = (arr) => [...arr].sort((a, b) => (Number(gradeField(b, 'confidence')) || 0) - (Number(gradeField(a, 'confidence')) || 0));
return {
type: 'recap',
available: true,
sport,
date: prettyDate(),
record: { wins: wins.length, losses: losses.length, pushes: pushes.length },
winRate: decided > 0 ? Math.round((wins.length / decided) * 1000) / 1000 : null,
topHits: byConfidence(wins).slice(0, 3).map((g) => ({
player: gradeField(g, 'player_name', 'player'),
stat: gradeField(g, 'stat_type', 'stat'),
line: gradeField(g, 'line'),
side: gradeField(g, 'side', 'direction'),
grade: gradeField(g, 'grade'),
actual: gradeField(g, 'actual'),
})),
biggestMiss: losses.length > 0 ? (() => {
const m = byConfidence(losses)[0];
return {
player: gradeField(m, 'player_name', 'player'),
stat: gradeField(m, 'stat_type', 'stat'),
line: gradeField(m, 'line'),
side: gradeField(m, 'side', 'direction'),
grade: gradeField(m, 'grade'),
actual: gradeField(m, 'actual'),
};
})() : null,
byTier: { A: tierRecord(resolvedGrades, 'A'), B: tierRecord(resolvedGrades, 'B'), C: tierRecord(resolvedGrades, 'C') },
metrics: { brierScore: brierScore(resolvedGrades), clv: avgCLV(resolvedGrades) },
};
}
// ---- matchup preview --------------------------------------------------
function generateMatchupNarrative(game, lines, streaks) {
const away = game.awayTeam?.name || game.awayTeam?.abbreviation || 'Away';
const home = game.homeTeam?.name || game.homeTeam?.abbreviation || 'Home';
const matched = (streaks || []).length;
if (lines && lines.total != null) {
return `${away} at ${home} — consensus total ${lines.total}${matched ? `, ${matched} active player streak${matched === 1 ? '' : 's'} in play` : ''}.`;
}
return `${away} at ${home}${matched ? `${matched} player${matched === 1 ? '' : 's'} riding a streak` : ''}.`;
}
function generateMatchupPreview(game, gameLines, streaks) {
const books = gameLines && gameLines.books ? Object.entries(gameLines.books) : [];
const lines = books.length > 0 ? {
spread: consensus(books, 'homeSpread'),
total: consensus(books, 'total'),
homeFavorite: (() => {
const hs = consensus(books, 'homeSpread');
return hs != null ? hs < 0 : null;
})(),
bookCount: books.length,
} : null;
const h = (game.homeTeam?.abbreviation || '').toUpperCase();
const a = (game.awayTeam?.abbreviation || '').toUpperCase();
const playerStreaks = (streaks || []).filter((s) => {
const t = (s.team || '').toUpperCase();
return t && (t === h || t === a);
});
return {
type: 'matchup_preview',
away: game.awayTeam || null,
home: game.homeTeam || null,
time: game.gameTime || null,
venue: game.venue || null,
lines,
playerStreaks,
narrative: generateMatchupNarrative(game, lines, playerStreaks),
};
}
// ---- orchestrator (injectable collectors for testability) -------------
function defaultCollectors() {
const scheduleService = require('./scheduleService');
const lineSnapshotService = require('./lineSnapshotService');
const bookComparisonService = require('./bookComparisonService');
const streaksService = require('./streaksService');
const { loadRosterLogs } = require('./rosterLogs');
const { cacheGet } = require('../utils/redis');
const nbaAdapter = require('./adapters/tank01NbaAdapter');
const mlbAdapter = require('./adapters/tank01MlbAdapter');
const { __internals: glInternals } = require('../routes/gameLines');
return {
getSchedule: (sport, date) => scheduleService.getSchedule(sport, date),
getGameLines: async (sport, date) => {
const fetcher = sport === 'nba' ? nbaAdapter.getNBABettingOdds : sport === 'mlb' ? mlbAdapter.getMLBBettingOdds : null;
if (!fetcher) return {};
const body = await fetcher(date).catch(() => null);
return body ? glInternals.normalizeGameLines(body) : {};
},
getGrades: async (sport) => {
// odds-api grades cache (when present); degrade to [] otherwise.
const utc = new Date().toISOString().split('T')[0];
const cache = (await cacheGet(`grades:${sport}:${utc}`)) ?? (await cacheGet(`grades:${sport}`));
return Array.isArray(cache) ? cache : Array.isArray(cache?.grades) ? cache.grades : [];
},
getStreaks: async (sport) => {
const roster = await loadRosterLogs(sport);
return streaksService.computeStreaks(roster, sport, {});
},
getMovers: (sport) => lineSnapshotService.getBiggestMovers(sport, { limit: 10 }),
getBestLines: async (sport) => {
const utc = new Date().toISOString().split('T')[0];
const cache = (await cacheGet(`odds:${sport}:${utc}`)) ?? (await cacheGet(`odds:${sport}`));
const props = Array.isArray(cache?.props) ? cache.props : [];
return bookComparisonService.bestLines(props, { limit: 10 });
},
};
}
async function collectSlateData(sport, deps) {
const c = deps || defaultCollectors();
const date = (() => {
try { return require('./scheduleService').todayET(); } catch { return undefined; }
})();
const [schedule, gameLines, grades, streaks, movers, bestLines] = await Promise.allSettled([
c.getSchedule(sport, date),
c.getGameLines(sport, date),
c.getGrades(sport),
c.getStreaks(sport),
c.getMovers(sport),
c.getBestLines(sport),
]);
const val = (r, fallback) => (r.status === 'fulfilled' && r.value != null ? r.value : fallback);
const data = {
sport,
schedule: val(schedule, []),
gameLines: val(gameLines, {}),
grades: val(grades, []),
streaks: val(streaks, []),
movers: val(movers, []),
bestLines: val(bestLines, []),
};
data.dataLevel = determineDataLevel(data);
return data;
}
module.exports = {
collectSlateData,
determineDataLevel,
generateSlateThread,
generatePOTD,
generateResultsRecap,
generateMatchupPreview,
__internals: {
normalizeGrade, extractGameLineHighlights, generateHookText, generateCTAText,
brierScore, avgCLV, tierRecord, bestOdds, consensus, bookDisagreement, findGameLinesFor,
},
};