Session 46: Grade card intel + name normalization + pitchers (2122 tests)
Three focused P1 fixes on the Session-45 snapshot model.
- Grade card intel ROOT CAUSE: gameLogService is NBA/WNBA-only (offline Python),
so MLB props never got l5_avg/l20_avg and buildIntelFields returned {}. Wired
MLB game logs into featureCache.gameLogFeatures via mlbStatsAdapter.getPlayerStats
(pure mlbGameLogFeatures + MLB stat_type->field map). buildIntelFields gained
playerStats/projection fallbacks for partial intel.
- Player name normalization: src/utils/playerName.js (+ web/src/lib copy):
normalizeName -> {display,key}. Strips periods, de-dots suffix, accent-folds
the key. Applied in snapshotService grouping, slateAdapter grade index +
player-strip merge (variants collapse, longest name shown), and
playerIntelService. "A.J. Ewing"/"AJ Ewing" + "Jazz Chisholm"/"Jr." now merge.
- MLB starting pitchers: new GET /api/schedule/:sport/pitchers (probablePitchers
service wrapping mlbStatsAdapter.getScheduleWithPitchers + best-effort ERA).
Slate fetches it, builds a team->pitcher map (full name + mascot match),
attaches pitchers to MLB GameCardData. + Next proxy.
Backend 2100 -> 2122 tests (+22), 176 suites. Web build clean (exit 0).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,25 @@ router.use(createRateLimit({ windowMs: 60_000, max: 60 }));
|
||||
|
||||
const MISSION_HEADER = { 'X-VYNDR-Mission': 'The slate is never empty' };
|
||||
|
||||
// GET /api/schedule/:sport/pitchers (Session 46) — today's MLB probable starters
|
||||
// (statsapi.mlb.com; the ESPN schedule above doesn't carry them). MLB only.
|
||||
router.get('/:sport/pitchers', async (req, res) => {
|
||||
const sport = String(req.params.sport || '').toLowerCase();
|
||||
if (sport !== 'mlb') {
|
||||
return res.set(MISSION_HEADER).json({ sport, date: null, games: [] });
|
||||
}
|
||||
const date = req.query.date || scheduleService.todayET();
|
||||
try {
|
||||
const { getProbablePitchers } = require('../services/probablePitchers');
|
||||
const games = await getProbablePitchers(date);
|
||||
res.set('Cache-Control', 'public, max-age=300');
|
||||
return res.set(MISSION_HEADER).json({ sport, date, games });
|
||||
} catch (err) {
|
||||
console.error('[schedule/pitchers]', err.message);
|
||||
return res.set(MISSION_HEADER).json({ sport, date, games: [] });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:sport', async (req, res) => {
|
||||
const sport = String(req.params.sport || '').toLowerCase();
|
||||
const date = req.query.date || scheduleService.todayET();
|
||||
|
||||
@@ -296,18 +296,31 @@ function matchupGradeFromRank(rank) {
|
||||
* yield the fallback. The archetype strip lights up once the snapshot pipeline
|
||||
* (Session 44) feeds per-player season lines into the grade response.
|
||||
*/
|
||||
function buildIntelFields(features = {}) {
|
||||
const firstFinite = (...vals) => vals.find((v) => Number.isFinite(v));
|
||||
|
||||
function buildIntelFields(features = {}, opts = {}) {
|
||||
const out = {};
|
||||
const round1 = (n) => Math.round(n * 10) / 10;
|
||||
if (Number.isFinite(features.l20_avg)) out.season_avg = round1(features.l20_avg);
|
||||
else if (Number.isFinite(features.season_avg)) out.season_avg = round1(features.season_avg);
|
||||
if (Number.isFinite(features.l10_avg)) out.last10_avg = round1(features.l10_avg);
|
||||
else if (Number.isFinite(features.l5_avg)) out.last10_avg = round1(features.l5_avg);
|
||||
// Resilience (Session 46): fall back to a caller-supplied playerStats bundle
|
||||
// and the model projection when the feature vector is sparse. Partial intel
|
||||
// beats none — we add only the fields we can actually back with a number.
|
||||
const ps = opts.playerStats || {};
|
||||
const proj = Number.isFinite(opts.projection) ? opts.projection : undefined;
|
||||
|
||||
const form = computeFormScore(features);
|
||||
const seasonAvg = firstFinite(features.l20_avg, features.season_avg, ps.season_avg, proj);
|
||||
if (seasonAvg != null) out.season_avg = round1(seasonAvg);
|
||||
|
||||
const last10 = firstFinite(features.l10_avg, features.l5_avg, ps.last10_avg);
|
||||
if (last10 != null) out.last10_avg = round1(last10);
|
||||
|
||||
let form = computeFormScore(features);
|
||||
if (form == null && Number.isFinite(ps.form)) form = Math.round(ps.form);
|
||||
if (form != null) out.form = form;
|
||||
|
||||
if (Number.isFinite(features.usage_rate)) out.usage = `${round1(features.usage_rate)}%`;
|
||||
else if (Number.isFinite(features.minutes_per_game)) out.usage = `${Math.round(features.minutes_per_game)} min`;
|
||||
else if (ps.usage) out.usage = String(ps.usage);
|
||||
|
||||
const matchup = matchupGradeFromRank(features.opp_rank_stat);
|
||||
if (matchup) out.matchup_grade = matchup;
|
||||
if (Number.isFinite(features.rest_days)) out.rest = `${features.rest_days}d rest`;
|
||||
|
||||
@@ -76,7 +76,66 @@ function daysBetween(aIso, bIso) {
|
||||
return Math.floor(ms / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
// MLB stat_type → the per-game field name in a statsapi.mlb.com game-log row.
|
||||
const MLB_LOG_FIELD = {
|
||||
total_bases: 'totalBases', home_runs: 'homeRuns', hits: 'hits', rbi: 'rbi',
|
||||
runs: 'runs', stolen_bases: 'stolenBases', walks: 'baseOnBalls',
|
||||
strikeouts: 'strikeOuts', earned_runs: 'earnedRuns', hits_allowed: 'hits',
|
||||
innings_pitched: 'inningsPitched',
|
||||
};
|
||||
|
||||
function mlbStatValue(statObj, statType) {
|
||||
const f = MLB_LOG_FIELD[statType];
|
||||
if (!f || !statObj) return null;
|
||||
const n = parseFloat(statObj[f]);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session 46 — derive recent/season averages from a real MLB game log
|
||||
* (mlbStatsAdapter.getPlayerStats result). PURE so it's unit-testable without
|
||||
* the network. Produces l5_avg / l10_avg (recent form) + l20_avg (the season
|
||||
* per-game reference) — exactly the fields buildIntelFields consumes, which the
|
||||
* NBA/WNBA-only Python game-log path never populated for MLB.
|
||||
*/
|
||||
function mlbGameLogFeatures(res, statType) {
|
||||
if (!res || !res.found) return {};
|
||||
const out = {};
|
||||
const logs = Array.isArray(res.last10) ? res.last10 : [];
|
||||
const vals = logs.map((g) => mlbStatValue(g.stat, statType)).filter((v) => v != null);
|
||||
if (vals.length) {
|
||||
const m5 = avg(vals.slice(-5)); // game logs are chronological (recent last)
|
||||
const m10 = avg(vals.slice(-10));
|
||||
const s10 = stddev(vals.slice(-10));
|
||||
if (m5 != null) out.l5_avg = m5;
|
||||
if (m10 != null) out.l10_avg = m10;
|
||||
if (s10 != null) out.l10_stddev = s10;
|
||||
}
|
||||
const seasonTotal = mlbStatValue(res.season, statType);
|
||||
const games = parseFloat(res.season && (res.season.gamesPlayed ?? res.season.gamesStarted ?? res.season.gamesPitched));
|
||||
if (seasonTotal != null && Number.isFinite(games) && games > 0) {
|
||||
out.l20_avg = seasonTotal / games;
|
||||
} else if (out.l10_avg != null) {
|
||||
out.l20_avg = out.l10_avg; // baseline so form has a reference
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function gameLogFeatures(playerName, sport, statType) {
|
||||
// MLB game logs come from the FREE statsapi.mlb.com (Session 46) — the Python
|
||||
// gameLogService only covers NBA/WNBA, so MLB props had no recent/season
|
||||
// averages and the grade card's intel sections stayed empty.
|
||||
if (sport === 'mlb') {
|
||||
try {
|
||||
const mlbStats = require('../adapters/mlbStatsAdapter');
|
||||
const res = await mlbStats.getPlayerStats(playerName);
|
||||
return mlbGameLogFeatures(res, statType);
|
||||
} catch (e) {
|
||||
console.warn('[featureCache] MLB game-log features failed:', e.message);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const logs = await gameLogs.getGameLogs(playerName, sport, 20);
|
||||
if (!logs || logs.length === 0) return {};
|
||||
|
||||
@@ -261,6 +320,8 @@ module.exports = {
|
||||
coachFeatures,
|
||||
lineupFeatures,
|
||||
statFromGameLog,
|
||||
mlbGameLogFeatures,
|
||||
mlbStatValue,
|
||||
avg,
|
||||
stddev,
|
||||
daysBetween,
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
*/
|
||||
|
||||
const { classify } = require('./archetypeService');
|
||||
const { normalizeName, nameKey } = require('../utils/playerName');
|
||||
|
||||
const toNum = (v) => {
|
||||
const n = parseFloat(v);
|
||||
@@ -31,14 +32,18 @@ const fmt3 = (v) => {
|
||||
function sanitizePlayerName(raw) {
|
||||
let decoded = String(raw == null ? '' : raw);
|
||||
try { decoded = decodeURIComponent(decoded); } catch { /* malformed % — use raw */ }
|
||||
return decoded
|
||||
const cleaned = decoded
|
||||
.replace(/[^\p{L}\p{N}\s.'-]/gu, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 60);
|
||||
// Session 46 — normalize periods/suffix for display ("A.J. Ewing" → "AJ Ewing").
|
||||
return normalizeName(cleaned).display;
|
||||
}
|
||||
|
||||
const normName = (n) => String(n == null ? '' : n).toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
// Session 46 — match by the normalized name key so name variants resolve to the
|
||||
// same player (snapshot grades, profile lookups).
|
||||
const normName = (n) => nameKey(n);
|
||||
|
||||
async function loadPlayerGrades(sport, name, cacheGetFn) {
|
||||
const env = await cacheGetFn(`grades:${sport}`);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* probablePitchers — today's MLB probable starters (Session 46).
|
||||
*
|
||||
* The ESPN schedule (`scheduleService`) doesn't carry probable pitchers, so MLB
|
||||
* game cards never showed them. This wraps `mlbStatsAdapter.getScheduleWithPitchers`
|
||||
* (FREE statsapi.mlb.com) and best-effort enriches each starter's season ERA.
|
||||
* Everything is graceful + injectable. `shapePitcherGames` is pure/unit-tested.
|
||||
*/
|
||||
|
||||
const mlbStats = require('./adapters/mlbStatsAdapter');
|
||||
|
||||
/** Map the adapter's schedule shape → a compact { home, away } pitcher record. */
|
||||
function shapePitcherGames(scheduleGames) {
|
||||
return (scheduleGames || [])
|
||||
.map((g) => ({
|
||||
home: { team: g.home?.team || null, pitcher: g.home?.probablePitcher?.name || null, pitcherId: g.home?.probablePitcher?.id || null, era: null },
|
||||
away: { team: g.away?.team || null, pitcher: g.away?.probablePitcher?.name || null, pitcherId: g.away?.probablePitcher?.id || null, era: null },
|
||||
}))
|
||||
.filter((x) => x.home.pitcher || x.away.pitcher);
|
||||
}
|
||||
|
||||
async function getProbablePitchers(date, opts = {}) {
|
||||
const adapter = opts.mlbAdapter || mlbStats;
|
||||
let games;
|
||||
try {
|
||||
games = await adapter.getScheduleWithPitchers(date);
|
||||
} catch (e) {
|
||||
console.warn('[probablePitchers] schedule fetch failed:', e.message);
|
||||
return [];
|
||||
}
|
||||
const shaped = shapePitcherGames(games);
|
||||
|
||||
if (opts.withEra !== false) {
|
||||
const eraLookup = opts.eraLookup || (async (id) => {
|
||||
const s = await adapter.getSeasonAverages(id, undefined, 'pitching').catch(() => null);
|
||||
const era = s && parseFloat(s.era);
|
||||
return Number.isFinite(era) ? era : null;
|
||||
});
|
||||
await Promise.all(
|
||||
shaped.flatMap((g) => [g.home, g.away]).map(async (side) => {
|
||||
if (side.pitcherId) {
|
||||
try { side.era = await eraLookup(side.pitcherId); } catch { side.era = null; }
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
return shaped;
|
||||
}
|
||||
|
||||
module.exports = { getProbablePitchers, shapePitcherGames };
|
||||
@@ -25,7 +25,10 @@ const DELTA_NOISE = 0.5; // ignore movements smaller than this
|
||||
const DELTA_MOVE = 1.0; // ticker MOVE threshold
|
||||
const STATS_CONCURRENCY = 5;
|
||||
|
||||
const norm = (s) => String(s == null ? '' : s).toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
const { nameKey } = require('../utils/playerName');
|
||||
// Session 46 — group/dedupe by the normalized name key so "A.J. Ewing" and
|
||||
// "AJ Ewing" (or "Jazz Chisholm" / "Jazz Chisholm Jr.") collapse to one player.
|
||||
const norm = (s) => nameKey(s);
|
||||
const lastName = (full) => {
|
||||
const parts = String(full || '').trim().split(/\s+/);
|
||||
return parts.length > 1 ? parts[parts.length - 1] : (parts[0] || '');
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Player-name normalization (Session 46) — the ONE source of truth for comparing
|
||||
* and de-duplicating player names. PropLine sends variants ("A.J. Ewing" vs
|
||||
* "AJ Ewing", "Jazz Chisholm" vs "Jazz Chisholm Jr.") as different players; this
|
||||
* collapses them.
|
||||
*
|
||||
* normalizeName("A.J. Ewing") → { display: "AJ Ewing", key: "aj ewing" }
|
||||
* normalizeName("Jazz Chisholm Jr.")→ { display: "Jazz Chisholm Jr", key: "jazz chisholm" }
|
||||
* normalizeName("Jazz Chisholm") → { display: "Jazz Chisholm", key: "jazz chisholm" }
|
||||
* normalizeName("Ronald Acuña Jr.") → { display: "Ronald Acuña Jr", key: "ronald acuna" }
|
||||
*
|
||||
* `display` keeps proper casing + accents (periods stripped, suffix de-dotted).
|
||||
* `key` is accent-folded, lowercased, suffix-stripped for comparison.
|
||||
*
|
||||
* NOTE: an identical copy lives at web/src/lib/playerName.js for the frontend
|
||||
* (the Next bundle can't import from src/). A test cross-checks they agree.
|
||||
*/
|
||||
|
||||
const SUFFIXES = new Set(['jr', 'sr', 'ii', 'iii', 'iv', 'v']);
|
||||
|
||||
function normalizeName(raw) {
|
||||
const display = String(raw == null ? '' : raw)
|
||||
.replace(/\./g, '') // "A.J." → "AJ", "Jr." → "Jr"
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
const folded = display.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
|
||||
const key = folded.split(' ').filter((t) => t && !SUFFIXES.has(t)).join(' ');
|
||||
return { display, key };
|
||||
}
|
||||
|
||||
/** Comparison key only (the common case). */
|
||||
function nameKey(raw) {
|
||||
return normalizeName(raw).key;
|
||||
}
|
||||
|
||||
module.exports = { normalizeName, nameKey, SUFFIXES };
|
||||
Reference in New Issue
Block a user