Session 29: Content generation templates — slate threads, POTD, recaps, matchup previews (1660 tests)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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 },
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user