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:
Kev
2026-06-18 23:56:26 -04:00
parent f8b120c0aa
commit c8fc9f577e
20 changed files with 608 additions and 32 deletions
+22
View File
@@ -0,0 +1,22 @@
/* Player-name normalization (Session 46) — frontend copy of
src/utils/playerName.js (the Next bundle can't import from src/). Keep the two
in sync; tests/unit cross-checks they agree. CommonJS so .tsx imports it AND
Jest requires it directly. */
const SUFFIXES = new Set(['jr', 'sr', 'ii', 'iii', 'iv', 'v']);
function normalizeName(raw) {
const display = String(raw == null ? '' : raw)
.replace(/\./g, '')
.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 };
}
function nameKey(raw) {
return normalizeName(raw).key;
}
module.exports = { normalizeName, nameKey, SUFFIXES };
+47 -10
View File
@@ -193,8 +193,9 @@ function isRelevantGame(game, now = Date.now()) {
}
// ── Pre-graded snapshot overlay (Session 45) ────────────────────────
const snorm = (s) => String(s == null ? '' : s).toLowerCase().replace(/[^a-z0-9]/g, '');
const gradeKey = (player, stat) => `${snorm(player)}|${String(stat || '').toLowerCase()}`;
// Session 46 — key by the normalized name so "A.J. Ewing"/"AJ Ewing" merge.
const { nameKey } = require('./playerName');
const gradeKey = (player, stat) => `${nameKey(player)}|${String(stat || '').toLowerCase()}`;
const sideCh = (dir) => (String(dir || 'over').toLowerCase() === 'under' ? 'U' : 'O');
/** Index snapshot grades by player|stat → the locked grade record. */
@@ -250,23 +251,27 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat
const order = [];
for (const p of gameProps || []) {
if (!p || !p.player) continue;
// Session 46 — group by the normalized key so name variants ("A.J. Ewing"
// / "AJ Ewing") merge into ONE strip; display the longest seen variant.
const pk = nameKey(p.player);
const rec = gradeIndex[gradeKey(p.player, p.stat_type || p.stat)];
if (!byPlayer[p.player]) {
byPlayer[p.player] = {
if (!byPlayer[pk]) {
byPlayer[pk] = {
player: p.player,
team: p.team || '',
archetype: rec && rec.archetype ? { primary: rec.archetype } : undefined,
stats: [],
props: [],
};
order.push(p.player);
} else if (!byPlayer[p.player].archetype && rec && rec.archetype) {
byPlayer[p.player].archetype = { primary: rec.archetype };
order.push(pk);
} else {
if (String(p.player).length > String(byPlayer[pk].player).length) byPlayer[pk].player = p.player;
if (!byPlayer[pk].archetype && rec && rec.archetype) byPlayer[pk].archetype = { primary: rec.archetype };
}
if (rec) {
const side = sideCh(rec.direction);
const delta = deltaIndex[`${gradeKey(p.player, p.stat_type || p.stat)}|${side}`];
byPlayer[p.player].props.push({
byPlayer[pk].props.push({
stat: statShort(rec.stat_type || rec.stat),
line: rec.line,
side,
@@ -277,12 +282,42 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat
delta: delta ? { delta: delta.delta, direction: delta.direction, currentLine: delta.currentLine } : null,
});
} else {
byPlayer[p.player].props.push({
byPlayer[pk].props.push({
stat: statShort(p.stat_type || p.stat), line: p.line, side: '', grade: null, awaiting: true,
});
}
}
return order.map((name) => byPlayer[name]);
return order.map((key) => byPlayer[key]);
}
// ── MLB probable pitchers (Session 46) ──────────────────────────────
const teamToken = (name) => String(name == null ? '' : name).toLowerCase().replace(/[^a-z0-9 ]/g, '').trim();
const teamMascot = (name) => { const t = teamToken(name).split(' ').filter(Boolean); return t.length ? t[t.length - 1] : ''; };
/** Index probable-pitcher games by team (full + mascot) → { pitcher, era }. */
function buildPitcherMap(pitcherGames) {
const map = {};
for (const g of pitcherGames || []) {
for (const side of [g.home, g.away]) {
if (!side || !side.pitcher || !side.team) continue;
const entry = { name: side.pitcher, era: side.era != null ? String(side.era) : null };
map[teamToken(side.team)] = entry;
const m = teamMascot(side.team);
if (m && map[m] == null) map[m] = entry;
}
}
return map;
}
/** Resolve { away, home } pitchers for a game's team names → GameCard shape. */
function pitchersForGameTeams(awayTeam, homeTeam, pitcherMap) {
if (!pitcherMap) return undefined;
const look = (name) => pitcherMap[teamToken(name)] || pitcherMap[teamMascot(name)] || null;
const a = look(awayTeam);
const h = look(homeTeam);
if (!a && !h) return undefined;
const one = (p) => ({ name: (p && p.name) || 'TBD', era: (p && p.era) || '—' });
return { away: one(a), home: one(h) };
}
module.exports = {
@@ -299,4 +334,6 @@ module.exports = {
statShort,
gradedAgo,
buildPlayerStripsFromProps,
buildPitcherMap,
pitchersForGameTeams,
};