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