Session 54: Audit cleanup — name edges + polish (2255 tests)

P1 name edge cases (BOTH playerName.js copies, kept identical):
- normalizeName strips hyphens (display+key): "Jung-hoo Lee" === "Jung Hoo Lee".
- nameKey strips single-letter MIDDLE tokens: "Josh H Smith" === "Josh Smith"
  (keeps first+last; real middle names + collapsed initials untouched).
- richie -> richard added to NICKNAMES.

P2 polish:
- Team Hub names normalized at the source (teamService.getTeamHub) so
  "J.C. Escarra" renders as "JC Escarra" like the dashboard.
- snapshotService dedup keeps the highest-confidence GRADE but the richest
  DISPLAY (accented "José" over "Jose") so prop rows match the pitcher line.
- correlationWarning names the game: "2 legs from the same game (NYY @ BOS)".

Backend 2246 -> 2255 tests (+9), 194 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-19 15:45:07 -04:00
parent b012da13f8
commit 8629021774
11 changed files with 137 additions and 12 deletions
+4 -1
View File
@@ -296,7 +296,10 @@ function correlationWarning(legs) {
if (worst) return `${worst.count} legs from ${worst.team} — high correlation`;
for (let i = 0; i < list.length; i += 1) {
for (let j = i + 1; j < list.length; j += 1) {
if (sameVal(list[i].game, list[j].game)) return '⚠ 2 legs from the same game — correlated';
if (sameVal(list[i].game, list[j].game)) {
const g = list[i].game;
return g ? `⚠ 2 legs from the same game (${g}) — correlated` : '⚠ 2 legs from the same game — correlated';
}
}
}
return null;
+17 -2
View File
@@ -213,15 +213,30 @@ async function runSnapshot(sport, opts = {}) {
// merged names. PropLine sends "Matt"/"Matthew", "A.J."/"AJ", "(STL)" tags as
// separate players; collapse to ONE grade per normalized player + stat (keep
// the highest-confidence; rawGraded is already confidence-desc).
// Session 54 — also keep the RICHEST display per player (prefer the accented
// variant: "José" over "Jose", then the longer string) so the prop rows match
// the accented pitcher line. The GRADE picked is still the highest-confidence.
const hasAccent = (s) => [...String(s)].some((c) => c.charCodeAt(0) > 127);
const richerDisplay = (a, b) => {
if (!b) return a;
if (hasAccent(a) !== hasAccent(b)) return hasAccent(a) ? a : b;
return a.length >= b.length ? a : b;
};
const dedup = new Map();
const bestDisplay = new Map();
for (const g of rawGraded) {
const disp = normalizeName(g.player || g.player_name).display || g.player || g.player_name || '';
const k = `${nameKey(disp)}|${String(g.stat_type || g.stat || '').toLowerCase()}`;
const pk = nameKey(disp);
bestDisplay.set(pk, richerDisplay(disp, bestDisplay.get(pk)));
const k = `${pk}|${String(g.stat_type || g.stat || '').toLowerCase()}`;
const cur = { ...g, player: disp, player_name: disp };
const prev = dedup.get(k);
if (!prev || (Number(g.confidence) || 0) > (Number(prev.confidence) || 0)) dedup.set(k, cur);
}
const graded = [...dedup.values()];
const graded = [...dedup.values()].map((g) => {
const disp = bestDisplay.get(nameKey(g.player)) || g.player;
return { ...g, player: disp, player_name: disp };
});
// Archetype per unique player (pure math once we have stats). Best-effort —
// a missing stat line → no badge (not a fallback archetype).
+3 -3
View File
@@ -13,7 +13,7 @@
* Everything is injectable so the whole build is unit-testable with no network.
*/
const { nameKey } = require('../utils/playerName');
const { nameKey, normalizeName } = require('../utils/playerName');
const HUB_TTL = 15 * 60; // expensive to build; 15-min cache
const ROSTER_CONCURRENCY = 8;
@@ -83,7 +83,7 @@ async function getTeamHub(sport, abbr, opts = {}) {
const env = await cacheGet(`grades:${sp}`).catch(() => null);
const byPlayer = {};
for (const g of (env && env.grades) || []) {
const disp = g.player || g.player_name;
const disp = normalizeName(g.player || g.player_name).display || g.player || g.player_name;
const k = nameKey(disp);
if (!byPlayer[k]) byPlayer[k] = { player: disp, archetype: g.archetype ? { primary: g.archetype } : null, position: null, stats: [], props: [], propCount: 0 };
byPlayer[k].props.push({ stat: statLabel(g.stat_type || g.stat), line: g.line, side: sideChar(g.direction), grade: g.grade, gradedAt: g.gradedAt || null });
@@ -124,7 +124,7 @@ async function getTeamHub(sport, abbr, opts = {}) {
archetype = c.primary ? { primary: c.primary.name } : null;
}
return {
player: p.name,
player: normalizeName(p.name).display || p.name,
position: p.position,
jersey: p.jersey,
archetype,
+8 -1
View File
@@ -38,6 +38,7 @@ const NICKNAMES = {
jim: 'james', jimmy: 'james', ray: 'raymond', fred: 'frederick',
kenny: 'kenneth', sam: 'samuel', pat: 'patrick', greg: 'gregory',
steve: 'steven', tim: 'timothy', frank: 'francis', mickey: 'michael',
richie: 'richard',
};
// Collapse adjacent single-letter words: "J C Escarra" → "JC Escarra",
@@ -50,6 +51,7 @@ function normalizeName(raw) {
const display = collapseInitials(String(raw == null ? '' : raw)
.replace(/\s*\([^)]*\)\s*/g, ' ') // strip parenthetical team tags "(STL)"
.replace(/\./g, '') // strip dots: "A.J." → "AJ", "Jr." → "Jr"
.replace(/-/g, ' ') // "Jung-hoo" → "Jung hoo" (matches "Jung Hoo")
.replace(/\s+/g, ' ')
.trim());
const folded = display.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
@@ -63,8 +65,13 @@ function normalizeName(raw) {
*/
function nameKey(raw) {
const { key } = normalizeName(raw);
const parts = key.split(/\s+/).filter(Boolean);
let parts = key.split(/\s+/).filter(Boolean);
if (parts.length >= 2 && NICKNAMES[parts[0]]) parts[0] = NICKNAMES[parts[0]];
// Strip single-letter MIDDLE tokens ("josh h smith" → "josh smith"); keep the
// first (may be a collapsed initial like "jc") and last token.
if (parts.length >= 3) {
parts = parts.filter((p, i, arr) => i === 0 || i === arr.length - 1 || p.length > 1);
}
return parts.join(' ');
}