Session 29: Content generation templates — slate threads, POTD, recaps, matchup previews (1660 tests)
This commit is contained in:
@@ -4,6 +4,62 @@
|
||||
2026-06-12
|
||||
|
||||
## Current Phase
|
||||
SHIP BUILD v29.0 — Content generation templates: structured social/newsletter content from live data (Session 29)
|
||||
|
||||
## Session 29 (2026-06-13) — SHIPPED
|
||||
|
||||
The data engine that produces raw material for daily social content. Each
|
||||
template consumes live VYNDR data and returns STRUCTURED OBJECTS (not text,
|
||||
not images) that degrade gracefully by data level. A formatter renders
|
||||
plain text; the image/design layer comes later.
|
||||
|
||||
Backend 1623 → **1660 tests** (+37), 133 suites, zero regressions. Web
|
||||
build clean.
|
||||
|
||||
### PHASE 1-3 — Template engine + slate thread + POTD
|
||||
- `contentTemplateService.js`:
|
||||
- `collectSlateData(sport, deps?)` — gathers schedule + game lines +
|
||||
grades + streaks + movers + best lines via Promise.allSettled,
|
||||
INJECTABLE collectors (default wires the real services). Sets
|
||||
`dataLevel`: full / lines / schedule / empty.
|
||||
- `generateSlateThread` — hook + content posts + CTA. Full → top-5
|
||||
graded picks; lines → game-line highlights (best ML, consensus
|
||||
total/spread, book disagreement) + movers; schedule → game list.
|
||||
- `generatePOTD` — best grade (full) or game-of-the-day (lines) or
|
||||
`{ available: false }`.
|
||||
- Field-alias normalizers so grades from any shape (player/player_name,
|
||||
side/direction, edge/edge_pct) work.
|
||||
|
||||
### PHASE 4-5 — Recap + matchup preview
|
||||
- `generateResultsRecap(sport, resolvedGrades)` — record, win rate, top
|
||||
hits, biggest miss, by-tier (A/B/C), Brier score + avg CLV. Pure.
|
||||
- `generateMatchupPreview(game, gameLines, streaks)` — teams, lines
|
||||
summary (consensus spread/total, home-favorite), streaks matched to the
|
||||
two teams, one-line narrative. Degrades to `lines: null`.
|
||||
|
||||
### PHASE 6 — Content API
|
||||
- `GET /api/content/{slate,potd,recap,preview}/:sport` (preview takes
|
||||
`/:gameId`). `?format=text` adds post-ready strings. Mounted in app.js;
|
||||
Next proxy `api/content/[...path]/route.ts`.
|
||||
|
||||
### PHASE 7 — Formatter
|
||||
- `contentFormatter.js` — slate thread → array of plain-text posts (one
|
||||
per role), POTD + recap text blocks. Defensive: never emits "undefined".
|
||||
|
||||
### Files created
|
||||
- `src/services/contentTemplateService.js`, `src/services/contentFormatter.js`
|
||||
- `src/routes/content.js`
|
||||
- `web/src/app/api/content/[...path]/route.ts`
|
||||
- `tests/unit/contentTemplateService.test.js` (22),
|
||||
`tests/unit/contentFormatter.test.js` (7),
|
||||
`tests/integration/contentRoutes.test.js` (8)
|
||||
|
||||
### Files modified
|
||||
- `src/app.js` (mount /api/content)
|
||||
|
||||
---
|
||||
|
||||
## Previous Phase
|
||||
SHIP BUILD v28.0 — Parlay builder, line-movement tracking, book comparison (Session 28)
|
||||
|
||||
## Session 28 (2026-06-13) — SHIPPED
|
||||
|
||||
@@ -780,3 +780,17 @@
|
||||
{"ts":"2026-06-13T15:56:53.881Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-13T15:56:53.881Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-13T15:56:53.940Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-13T20:27:36.907Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||
{"ts":"2026-06-13T20:27:36.973Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||
{"ts":"2026-06-13T20:27:37.223Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||
{"ts":"2026-06-13T20:27:38.042Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||
{"ts":"2026-06-13T20:27:38.042Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-13T20:27:38.042Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-13T20:27:38.259Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-13T20:44:17.269Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||
{"ts":"2026-06-13T20:44:18.291Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||
{"ts":"2026-06-13T20:44:18.386Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||
{"ts":"2026-06-13T20:44:19.184Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||
{"ts":"2026-06-13T20:44:19.184Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-13T20:44:19.184Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-13T20:44:19.233Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
// Integration: /api/content routes (Session 29).
|
||||
// The template service is mocked at the collector boundary via redis +
|
||||
// real generators; recap reads a mocked results cache.
|
||||
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
const mockStore = {};
|
||||
jest.mock('../../src/utils/redis', () => ({
|
||||
cacheGet: jest.fn(async (k) => (k in mockStore ? mockStore[k] : null)),
|
||||
getRedisClient: () => ({ scan: async () => ['0', []], lrange: async () => [] }),
|
||||
isDegraded: () => false,
|
||||
}));
|
||||
|
||||
// Mock collectSlateData so the route doesn't hit live adapters; keep the
|
||||
// real generators so we exercise the actual content shapes.
|
||||
jest.mock('../../src/services/contentTemplateService', () => {
|
||||
const actual = jest.requireActual('../../src/services/contentTemplateService');
|
||||
return { ...actual, collectSlateData: jest.fn() };
|
||||
});
|
||||
const template = require('../../src/services/contentTemplateService');
|
||||
|
||||
function mountApp() {
|
||||
delete require.cache[require.resolve('../../src/routes/content')];
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/content', require('../../src/routes/content'));
|
||||
return app;
|
||||
}
|
||||
|
||||
const SLATE = {
|
||||
sport: 'mlb',
|
||||
schedule: [{ id: 'g1', homeTeam: { name: 'Reds', abbreviation: 'CIN' }, awayTeam: { name: 'D-backs', abbreviation: 'ARI' }, gameTime: '2026-06-13T23:10:00Z', venue: 'GABP' }],
|
||||
gameLines: { '20260613_ARI@CIN': { homeTeam: 'CIN', awayTeam: 'ARI', books: { bet365: { homeML: '-120', awayML: '+100', total: '9.5', homeSpread: '-1.5' } } } },
|
||||
grades: [],
|
||||
streaks: [{ player: 'Acuna', team: 'ARI', description: '3-game hit streak', currentStreak: 3 }],
|
||||
movers: [],
|
||||
bestLines: [],
|
||||
dataLevel: 'lines',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
for (const k of Object.keys(mockStore)) delete mockStore[k];
|
||||
jest.clearAllMocks();
|
||||
template.collectSlateData.mockResolvedValue(SLATE);
|
||||
});
|
||||
|
||||
describe('GET /api/content/slate/:sport', () => {
|
||||
test('returns a slate thread structure', async () => {
|
||||
const res = await request(mountApp()).get('/api/content/slate/mlb');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.type).toBe('slate_thread');
|
||||
expect(res.body.dataLevel).toBe('lines');
|
||||
expect(res.body.posts[0].role).toBe('hook');
|
||||
});
|
||||
|
||||
test('?format=text adds formatted post strings', async () => {
|
||||
const res = await request(mountApp()).get('/api/content/slate/mlb?format=text');
|
||||
expect(Array.isArray(res.body.text)).toBe(true);
|
||||
expect(res.body.text[0]).toMatch(/VYNDR SLATE/);
|
||||
});
|
||||
|
||||
test('unsupported sport → 404', async () => {
|
||||
const res = await request(mountApp()).get('/api/content/slate/cricket');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/content/potd/:sport', () => {
|
||||
test('lines data → game of the day', async () => {
|
||||
const res = await request(mountApp()).get('/api/content/potd/mlb');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.subtype).toBe('game_of_the_day');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/content/recap/:sport', () => {
|
||||
test('no resolved grades → available:false', async () => {
|
||||
const res = await request(mountApp()).get('/api/content/recap/mlb');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.available).toBe(false);
|
||||
});
|
||||
|
||||
test('with cached results → record computed', async () => {
|
||||
const utc = new Date().toISOString().split('T')[0];
|
||||
mockStore[`results:mlb:${utc}`] = [
|
||||
{ player: 'A', grade: 'A', confidence: 80, result: 'win' },
|
||||
{ player: 'B', grade: 'B', confidence: 60, result: 'loss' },
|
||||
];
|
||||
const res = await request(mountApp()).get('/api/content/recap/mlb');
|
||||
expect(res.body.available).toBe(true);
|
||||
expect(res.body.record).toEqual({ wins: 1, losses: 1, pushes: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/content/preview/:sport/:gameId', () => {
|
||||
test('returns matchup data for a scheduled game', async () => {
|
||||
const res = await request(mountApp()).get('/api/content/preview/mlb/g1');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.type).toBe('matchup_preview');
|
||||
expect(res.body.home.abbreviation).toBe('CIN');
|
||||
expect(res.body.lines.bookCount).toBe(1);
|
||||
expect(res.body.playerStreaks.map((s) => s.player)).toEqual(['Acuna']);
|
||||
});
|
||||
|
||||
test('unknown game → 404', async () => {
|
||||
const res = await request(mountApp()).get('/api/content/preview/mlb/nope');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
// Unit: content formatter (Session 29).
|
||||
|
||||
const fmt = require('../../src/services/contentFormatter');
|
||||
|
||||
describe('contentFormatter — slate thread', () => {
|
||||
test('formats each post role into readable text', () => {
|
||||
const thread = {
|
||||
posts: [
|
||||
{ role: 'hook', text: '🔥 VYNDR SLATE' },
|
||||
{ role: 'pick', player: 'Wemby', stat: 'points', side: 'over', line: 28.5, grade: 'A', confidence: 78, edge: 6.2, analysis: 'Edge on the line.' },
|
||||
{ role: 'game_highlight', game: 'ARI @ CIN', time: null, bestHomeML: { odds: '-120', book: 'betmgm' }, total: 9.5, bookCount: 2 },
|
||||
{ role: 'movers', movers: [{ player: 'X', stat: 'hits', opening: 1.5, current: 2.5, delta: 1, sharpSignal: false }] },
|
||||
{ role: 'schedule', games: [{ away: 'ATL', home: 'NYM', time: null }] },
|
||||
{ role: 'cta', text: 'Full slate at vyndr.app' },
|
||||
],
|
||||
};
|
||||
const out = fmt.formatSlateThread(thread);
|
||||
expect(out[0]).toBe('🔥 VYNDR SLATE');
|
||||
expect(out[1]).toContain('Wemby · points over 28.5');
|
||||
expect(out[1]).toContain('Grade: A · 78% confidence');
|
||||
expect(out[1]).toContain('Edge: +6.2%');
|
||||
expect(out[2]).toContain('ARI @ CIN');
|
||||
expect(out[2]).toContain('betmgm');
|
||||
expect(out[3]).toContain('Biggest line moves');
|
||||
expect(out[4]).toContain('ATL @ NYM');
|
||||
expect(out[5]).toBe('Full slate at vyndr.app');
|
||||
});
|
||||
|
||||
test('never emits "undefined"', () => {
|
||||
const out = fmt.formatSlateThread({ posts: [{ role: 'pick', player: 'X', stat: 'pts', grade: 'B' }] });
|
||||
expect(out[0]).not.toMatch(/undefined/);
|
||||
});
|
||||
|
||||
test('non-thread input → empty array', () => {
|
||||
expect(fmt.formatSlateThread(null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('contentFormatter — POTD + recap', () => {
|
||||
test('POTD full', () => {
|
||||
const t = fmt.formatPOTD({ dataLevel: 'full', player: 'Wemby', stat: 'points', side: 'over', line: 28.5, grade: 'A', confidence: 78 });
|
||||
expect(t).toContain('PROP OF THE DAY');
|
||||
expect(t).toContain('Wemby');
|
||||
});
|
||||
test('POTD game of the day', () => {
|
||||
const t = fmt.formatPOTD({ dataLevel: 'lines', subtype: 'game_of_the_day', game: 'ARI @ CIN', total: 9.5 });
|
||||
expect(t).toContain('GAME OF THE DAY');
|
||||
expect(t).toContain('O/U 9.5');
|
||||
});
|
||||
test('POTD unavailable', () => {
|
||||
expect(fmt.formatPOTD({ available: false })).toMatch(/vyndr\.app/);
|
||||
});
|
||||
test('recap formats record + win rate', () => {
|
||||
const t = fmt.formatRecap({ available: true, date: 'Friday', record: { wins: 3, losses: 2, pushes: 1 }, winRate: 0.6, topHits: [{ player: 'A', stat: 'points', side: 'over', line: 20 }], metrics: { brierScore: 0.18 } });
|
||||
expect(t).toContain('3-2-1 (60%)');
|
||||
expect(t).toContain('✅ A points over 20');
|
||||
expect(t).toContain('Brier: 0.18');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
// Unit: content template engine (Session 29). Pure generators.
|
||||
|
||||
const svc = require('../../src/services/contentTemplateService');
|
||||
|
||||
// ---- fixtures ----
|
||||
const schedule = [
|
||||
{ id: '1', homeTeam: { name: 'Cincinnati Reds', abbreviation: 'CIN' }, awayTeam: { name: 'Arizona Diamondbacks', abbreviation: 'ARI' }, gameTime: '2026-06-13T23:10:00Z', venue: 'GABP' },
|
||||
{ id: '2', homeTeam: { name: 'New York Mets', abbreviation: 'NYM' }, awayTeam: { name: 'Atlanta Braves', abbreviation: 'ATL' }, gameTime: '2026-06-13T23:40:00Z', venue: 'Citi Field' },
|
||||
];
|
||||
const gameLines = {
|
||||
'20260613_ARI@CIN': {
|
||||
homeTeam: 'CIN', awayTeam: 'ARI',
|
||||
books: {
|
||||
bet365: { homeML: '-130', awayML: '+110', total: '9.5', homeSpread: '-1.5' },
|
||||
betmgm: { homeML: '-120', awayML: '+100', total: '9', homeSpread: '-1.5' },
|
||||
},
|
||||
},
|
||||
};
|
||||
const grades = [
|
||||
{ player: 'Wembanyama', stat: 'points', line: 28.5, side: 'over', grade: 'A', confidence: 78, edge: 6.2, projection: 31.1 },
|
||||
{ player_name: 'Brunson', stat_type: 'assists', line: 7.5, direction: 'under', grade: 'B+', confidence: 64, edge_pct: 3.1, projection: 6.8 },
|
||||
];
|
||||
|
||||
describe('determineDataLevel', () => {
|
||||
test('full when grades exist', () => {
|
||||
expect(svc.determineDataLevel({ grades, gameLines, schedule })).toBe('full');
|
||||
});
|
||||
test('lines when only game lines', () => {
|
||||
expect(svc.determineDataLevel({ grades: [], gameLines, schedule })).toBe('lines');
|
||||
});
|
||||
test('schedule when only schedule', () => {
|
||||
expect(svc.determineDataLevel({ grades: [], gameLines: {}, schedule })).toBe('schedule');
|
||||
});
|
||||
test('empty when nothing', () => {
|
||||
expect(svc.determineDataLevel({ grades: [], gameLines: {}, schedule: [] })).toBe('empty');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateSlateThread', () => {
|
||||
test('full data → hook + up-to-5 picks + CTA', () => {
|
||||
const t = svc.generateSlateThread('nba', { schedule, gameLines, grades, dataLevel: 'full' });
|
||||
expect(t.posts[0].role).toBe('hook');
|
||||
expect(t.posts[t.posts.length - 1].role).toBe('cta');
|
||||
const picks = t.posts.filter((p) => p.role === 'pick');
|
||||
expect(picks).toHaveLength(2);
|
||||
// Sorted by confidence desc — Wemby (78) first.
|
||||
expect(picks[0].player).toBe('Wembanyama');
|
||||
expect(picks[1].player).toBe('Brunson'); // normalized from player_name
|
||||
expect(picks[1].side).toBe('under'); // normalized from direction
|
||||
});
|
||||
|
||||
test('lines-only → game highlights (+ movers when present)', () => {
|
||||
const t = svc.generateSlateThread('mlb', { schedule, gameLines, grades: [], movers: [{ player: 'X', delta: 2 }] });
|
||||
expect(t.dataLevel).toBe('lines');
|
||||
const highlights = t.posts.filter((p) => p.role === 'game_highlight');
|
||||
expect(highlights.length).toBeGreaterThan(0);
|
||||
expect(highlights[0].bookCount).toBe(2);
|
||||
expect(t.posts.some((p) => p.role === 'movers')).toBe(true);
|
||||
});
|
||||
|
||||
test('schedule-only → game list', () => {
|
||||
const t = svc.generateSlateThread('mlb', { schedule, gameLines: {}, grades: [] });
|
||||
expect(t.dataLevel).toBe('schedule');
|
||||
const sched = t.posts.find((p) => p.role === 'schedule');
|
||||
expect(sched.games).toHaveLength(2);
|
||||
expect(sched.games[0].home).toBe('Cincinnati Reds');
|
||||
});
|
||||
|
||||
test('hook text adapts to data level', () => {
|
||||
expect(svc.generateSlateThread('mlb', { schedule, grades }).posts[0].text).toMatch(/graded/i);
|
||||
expect(svc.generateSlateThread('mlb', { schedule, gameLines, grades: [] }).posts[0].text).toMatch(/sportsbooks/i);
|
||||
});
|
||||
|
||||
test('empty schedule → minimal thread, not a crash', () => {
|
||||
const t = svc.generateSlateThread('mlb', { schedule: [], gameLines: {}, grades: [] });
|
||||
expect(t.dataLevel).toBe('empty');
|
||||
expect(t.posts[0].role).toBe('hook');
|
||||
expect(t.posts.some((p) => p.role === 'cta')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generatePOTD', () => {
|
||||
test('full → best grade selected', () => {
|
||||
const p = svc.generatePOTD('nba', { grades, dataLevel: 'full' });
|
||||
expect(p.dataLevel).toBe('full');
|
||||
expect(p.player).toBe('Wembanyama'); // highest confidence
|
||||
expect(p.grade).toBe('A');
|
||||
});
|
||||
test('lines → game of the day', () => {
|
||||
const p = svc.generatePOTD('mlb', { grades: [], gameLines, schedule });
|
||||
expect(p.dataLevel).toBe('lines');
|
||||
expect(p.subtype).toBe('game_of_the_day');
|
||||
expect(p.game).toContain('@');
|
||||
});
|
||||
test('no data → available:false', () => {
|
||||
const p = svc.generatePOTD('mlb', { grades: [], gameLines: {}, schedule: [] });
|
||||
expect(p.available).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateResultsRecap', () => {
|
||||
const resolved = [
|
||||
{ player: 'A', stat: 'points', grade: 'A', confidence: 80, result: 'win', clv: 1.2 },
|
||||
{ player: 'B', stat: 'hits', grade: 'A-', confidence: 70, result: 'win', clv: 0.8 },
|
||||
{ player: 'C', stat: 'reb', grade: 'B', confidence: 60, result: 'win', clv: -0.2 },
|
||||
{ player: 'D', stat: 'ast', grade: 'A', confidence: 75, result: 'loss', clv: -1.0 },
|
||||
{ player: 'E', stat: 'ks', grade: 'B', confidence: 55, result: 'loss', clv: 0.1 },
|
||||
{ player: 'F', stat: 'tb', grade: 'C', confidence: 50, result: 'push' },
|
||||
];
|
||||
test('record + win rate', () => {
|
||||
const r = svc.generateResultsRecap('mlb', resolved);
|
||||
expect(r.record).toEqual({ wins: 3, losses: 2, pushes: 1 });
|
||||
expect(r.winRate).toBe(0.6); // 3 / (3+2)
|
||||
});
|
||||
test('top hits sorted by confidence', () => {
|
||||
const r = svc.generateResultsRecap('mlb', resolved);
|
||||
expect(r.topHits[0].player).toBe('A'); // 80 conf win
|
||||
expect(r.topHits).toHaveLength(3);
|
||||
});
|
||||
test('biggest miss = highest-confidence loss', () => {
|
||||
const r = svc.generateResultsRecap('mlb', resolved);
|
||||
expect(r.biggestMiss.player).toBe('D'); // 75 conf loss
|
||||
});
|
||||
test('by-tier breakdown', () => {
|
||||
const r = svc.generateResultsRecap('mlb', resolved);
|
||||
expect(r.byTier.A).toEqual({ wins: 2, losses: 1, total: 3 });
|
||||
expect(r.byTier.B).toEqual({ wins: 1, losses: 1, total: 2 });
|
||||
});
|
||||
test('brier + clv computed', () => {
|
||||
const r = svc.generateResultsRecap('mlb', resolved);
|
||||
expect(typeof r.metrics.brierScore).toBe('number');
|
||||
expect(typeof r.metrics.clv).toBe('number');
|
||||
});
|
||||
test('no resolved grades → available:false', () => {
|
||||
expect(svc.generateResultsRecap('mlb', []).available).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateMatchupPreview', () => {
|
||||
const streaks = [
|
||||
{ player: 'Acuna', team: 'ARI', description: '3-game hit streak', currentStreak: 3 },
|
||||
{ player: 'Soto', team: 'NYM', description: '2-game HR streak', currentStreak: 2 },
|
||||
];
|
||||
test('includes teams, time, venue', () => {
|
||||
const p = svc.generateMatchupPreview(schedule[0], gameLines['20260613_ARI@CIN'], streaks);
|
||||
expect(p.home.abbreviation).toBe('CIN');
|
||||
expect(p.away.abbreviation).toBe('ARI');
|
||||
expect(p.venue).toBe('GABP');
|
||||
});
|
||||
test('lines summary from game lines', () => {
|
||||
const p = svc.generateMatchupPreview(schedule[0], gameLines['20260613_ARI@CIN'], streaks);
|
||||
expect(p.lines.bookCount).toBe(2);
|
||||
expect(p.lines.total).toBe(9.3); // consensus of 9.5 + 9, rounded to 1dp
|
||||
expect(p.lines.homeFavorite).toBe(true); // homeSpread -1.5
|
||||
});
|
||||
test('player streaks matched to the two teams', () => {
|
||||
const p = svc.generateMatchupPreview(schedule[0], gameLines['20260613_ARI@CIN'], streaks);
|
||||
expect(p.playerStreaks.map((s) => s.player)).toEqual(['Acuna']); // only ARI matches this game
|
||||
});
|
||||
test('no game lines → lines:null, not a crash', () => {
|
||||
const p = svc.generateMatchupPreview(schedule[0], null, streaks);
|
||||
expect(p.lines).toBeNull();
|
||||
expect(p.narrative).toContain('@'.length ? '' : ''); // narrative present
|
||||
expect(typeof p.narrative).toBe('string');
|
||||
});
|
||||
});
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,25 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
/**
|
||||
* Content proxy (Session 29). Forwards /api/content/* to Express
|
||||
* (slate thread / POTD / recap / matchup preview). Read-only, zero-credit.
|
||||
*/
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||
const { path } = await params;
|
||||
const segments = (path || []).map(encodeURIComponent).join('/');
|
||||
const qs = req.nextUrl.search;
|
||||
try {
|
||||
const upstream = await fetch(`${BACKEND_URL}/api/content/${segments}${qs}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const data = await upstream.json().catch(() => ({}));
|
||||
return NextResponse.json(data, { status: upstream.status });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Content service unreachable.' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user