Session E (night2): Phase 4 — scan + parlay polish

4.1 ROOT CAUSE of 'Ohtani returns nothing': no backend
    /api/players/search existed (MLB 404'd; NBA/WNBA hit the offline
    Python service). New Express route + mlbStatsAdapter.matchPlayers —
    canonical nameKey fuzzy match (exact > last-name prefix > folded
    substring). LIVE-VERIFIED vs the real 1,299-player list: Ohtani /
    Aaron Judge / Sánchez / sanchez / Chisholm Jr all resolve; accented
    and unaccented return identical results. Non-MLB matches the
    platform's cached names (rosterlogs + grades), cache-only.
4.2 Reveal choreography per §7: analyzing steps → DECLASSIFIED stamp →
    90ms-staggered context panels (entrance floors visible per the
    Phase-0 rule); prefers-reduced-motion skips straight to the card.
4.3 PRIOR READS chips on scan results — the model's public ledger
    history for the player (deferred-render, outcomes + pending, never
    invented). /api/ledger/model gains ?player= on entries.
4.4 Parlay Lab: humanized stat labels via the ONE shared formatter
    (lib/gradeAdapter.statLabel); 1-leg provisional grade ('Leg grade:
    B — add a leg for the combined read'); discoverable entry — Nav
    'Parlay Lab' item opens the drawer via window.__openParlay, and the
    open drawer now renders an honest empty state at 0 legs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-11 00:59:17 -04:00
parent 9ae64516e6
commit 5d19660f8e
12 changed files with 325 additions and 26 deletions
+2
View File
@@ -128,6 +128,8 @@ app.use('/api/preferences', require('./routes/preferences'));
app.use('/api/stripe', stripeRoutes);
app.use('/api/stats', statsRoutes);
app.use('/api/props', propsRoutes);
// Session 60 (night2/E) — the scan search box's canonical player resolver.
app.use('/api/players', require('./routes/players'));
app.use('/api/waitlist', waitlistRoutes);
app.use('/api/pipeline', pipelineRoutes);
app.use('/api/share-card', shareCardRoutes);
+2
View File
@@ -92,6 +92,8 @@ router.get('/model', async (req, res) => {
.select(ROW_COLUMNS)
.is('user_id', null);
q = applyFilters(q, req);
// Session 60 (night2/E) — PRIOR READS: a player's own public history.
if (req.query.player) q = q.eq('player_key', nameKey(String(req.query.player).slice(0, 60)));
const { data, error } = await q
.order('graded_at', { ascending: false })
.limit(limit);
+74
View File
@@ -0,0 +1,74 @@
'use strict';
/**
* GET /api/players/search (Session 60, night2/E — audit fix 4.1).
*
* The scan search box's canonical resolver. Before this route existed, MLB
* search 404'd at Express and NBA/WNBA hit the (usually offline) Python
* service — "Ohtani" returned nothing while his tile sat on the page.
*
* MLB: fuzzy match against the cached statsapi player list (free, 24h
* cache) — case/diacritic-insensitive, nickname/suffix-aware via nameKey.
* Other sports: cache-only match against the names the platform already
* knows (rosterlogs blob + tonight's graded slate). Never an upstream call
* for non-MLB; empty is a valid answer.
*/
const express = require('express');
const { createRateLimit } = require('../middleware/rateLimit');
const { nameKey } = require('../utils/playerName');
const router = express.Router();
router.use(createRateLimit({ windowMs: 60_000, max: 60 }));
async function cachedNames(sport) {
const { cacheGet } = require('../utils/redis');
const out = new Map();
try {
const blob = await cacheGet(`rosterlogs:${sport}`);
for (const p of Array.isArray(blob) ? blob : []) {
if (p && p.name) out.set(nameKey(p.name), { full_name: p.name, team: p.team || null });
}
} catch { /* cache-only, degrade */ }
try {
const env = await cacheGet(`grades:${sport}`);
for (const g of (env && env.grades) || []) {
const n = g.player || g.player_name;
if (n && !out.has(nameKey(n))) out.set(nameKey(n), { full_name: n, team: g.team || null });
}
} catch { /* cache-only, degrade */ }
return [...out.values()];
}
router.get('/search', async (req, res) => {
const sport = String(req.query.sport || 'NBA').toUpperCase();
const q = String(req.query.q || '').trim();
if (q.length < 2) return res.json({ players: [] });
try {
if (sport === 'MLB') {
const { searchPlayers } = require('../services/adapters/mlbStatsAdapter');
const hits = await searchPlayers(q, { limit: 12 });
return res.json({
players: hits.map((h) => ({ id: String(h.id), full_name: h.fullName, team: h.team || undefined, position: h.position || undefined })),
});
}
// NBA/WNBA/soccer — the names the platform already carries (cache-only).
const names = await cachedNames(sport.toLowerCase());
const qKey = nameKey(q);
const qLast = qKey.split(' ').pop();
const players = names
.filter((p) => {
const k = nameKey(p.full_name);
return k === qKey || k.includes(qKey) || (qLast.length >= 3 && k.split(' ').some((w) => w.startsWith(qLast)));
})
.slice(0, 12)
.map((p, i) => ({ id: `${sport}-${i}-${nameKey(p.full_name)}`, full_name: p.full_name, team: p.team || undefined }));
return res.json({ players });
} catch (err) {
console.error('[players/search]', err.message);
return res.json({ players: [] });
}
});
module.exports = router;
+41
View File
@@ -144,6 +144,45 @@ const { nameKey } = require('../../utils/playerName');
* requires a UNIQUE same-last-name + same-first-initial candidate; anything
* ambiguous returns null (a missing profile beats another player's log).
*/
/**
* Session 60 (night2/E, audit fix 4.1) — fuzzy MULTI-match against the
* canonical list. Pure: rank = exact nameKey > last-name prefix > folded
* substring. Case/diacritic-insensitive via nameKey's folding, so
* "ohtani", "Sánchez", "sanchez", "Chisholm Jr" all resolve.
*/
function matchPlayers(people, query, limit = 12) {
const qKey = nameKey(query);
if (!qKey) return [];
const qLast = qKey.split(' ').pop();
const scored = [];
for (const p of people || []) {
const k = nameKey(p.fullName);
if (!k) continue;
let score = null;
if (k === qKey) score = 0;
else if (k.split(' ').some((w) => w.startsWith(qLast)) && qLast.length >= 3) score = 1;
else if (k.includes(qKey)) score = 2;
if (score == null) continue;
scored.push({ score, p });
}
scored.sort((a, b) => a.score - b.score);
return scored.slice(0, limit).map(({ p }) => ({
id: p.id,
fullName: p.fullName,
team: p.currentTeam?.name ?? null,
position: p.primaryPosition?.abbreviation ?? null,
}));
}
/** Multi-result fuzzy search (scan search box). Cached list, free API. */
async function searchPlayers(query, opts = {}) {
const season = opts.season || DEFAULT_SEASON;
const url = `${BASE}/sports/1/players?season=${season}`;
const data = await fetchWithCache(url, `mlbstats:players:${season}`, 24 * 3600);
const people = (data && Array.isArray(data.people)) ? data.people : [];
return matchPlayers(people, query, opts.limit || 12);
}
async function searchPlayer(name, season = DEFAULT_SEASON) {
const targetKey = nameKey(name);
if (!targetKey) return null;
@@ -248,6 +287,8 @@ module.exports = {
getSeasonAverages,
getBatterVsPitcher,
searchPlayer,
searchPlayers,
matchPlayers,
getPlayerStats,
getTeams,
resolveTeam,