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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user