Item 5 — daily hero prop is a live RULE (biggest model-vs-market disagreement)

The landing hero was a static Jokic "Example" card with a name-length pick and a
hardcoded A- 73% +6.2% fallback. Now it's deterministic and live:

- heroPropService.pickHeroProp reads the pre-graded snapshot and selects the
  prop with the LARGEST |projection - line| gap among A/B grades (conviction,
  not noise) — the read where VYNDR disagrees most with the market, the card
  that makes a stranger argue. No curation, no grading (reads cache → no API
  credits). GET /api/hero-prop (backend) + repointed Next proxy.
- The card shows the disagreement EXPLICITLY: the book's line vs VYNDR's model,
  side by side (model in green), with the real grade timestamp ("Graded 2:14
  PM"). The EXAMPLE chip is gone.
- Empty slate → the MOST RECENT real graded read (flagged "LATEST READ", real
  date). Nothing cached → { available:false } and the card HIDES. No
  hand-written fallback — the Jokic card is deleted. Survives a dead night: a
  live rule shows tonight's real MLB read, never a phantom July NBA card.

7 service tests lock the rule (max-gap, A/B gate, projection/line required,
empty→recent, hidden, cross-sport). colorContract updated to the new
disagreement display. Change-affected suites green, web build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-18 01:29:55 -04:00
parent 89a2977f57
commit 9b9aab4262
8 changed files with 345 additions and 460 deletions
+17 -161
View File
@@ -1,174 +1,30 @@
import { NextResponse } from 'next/server';
// Session 17 — Next.js App Router refuses to compile a route that
// exports BOTH `dynamic = 'force-dynamic'` AND `revalidate`. The two
// modes are mutually exclusive: force-dynamic skips static
// generation; revalidate gates ISR. Session 16 shipped both, which
// silently broke the route at build time (production audit found a
// hard 404 on /api/hero-prop).
//
// We keep `force-dynamic` (we want the random-prop pick to vary per
// request) and emit the 15-minute cache via the response's
// Cache-Control header — Coolify's reverse proxy honors it.
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
const HERO_FETCH_TIMEOUT_MS = 6000;
interface OddsResponseProp {
player: string;
stat_type: string;
line: number;
direction?: 'over' | 'under';
book?: string;
home_team?: string;
away_team?: string;
}
interface OddsResponse {
sport?: string;
source?: string;
props?: OddsResponseProp[];
error?: string;
}
interface GradeResponse {
grade?: string;
confidence?: number;
edge_pct?: number;
projection?: number;
reasoning?: { summary?: string; steps?: unknown };
kill_conditions_triggered?: Array<{ code: string; reason: string }>;
}
/**
* Live hero prop endpoint (Session 16).
*
* Picks one fresh, real prop from the day's odds and grades it. The
* landing page hero renders this in place of the static Jokic mockup
* — cold visitors see proof of live intelligence on first paint
* instead of a hypothetical example.
*
* Sport cascade: NBA → WNBA → MLB. Whichever sport produces a non-
* empty `props` list first wins. When every sport is empty (off-
* hours, holiday slate, upstream odds quota burned), responds with
* `{ isStatic: true }` and the client falls back to the existing
* static card. Never throws — odds outages must not blank the
* landing page.
*
* Two-stage flow:
* 1. GET ${BACKEND}/api/odds/{sport} → pick random prop
* 2. POST ${BACKEND}/api/analyze/prop → grade it
*
* Both calls share a 6s timeout (AbortController). The overall
* route is wrapped in try/catch and always 200s (with `isStatic:true`
* on failure) so the client renders gracefully.
* Hero-prop proxy (Truth-Everywhere Part 2, item 5) — forwards to the Express
* /api/hero-prop, which reads the pre-graded snapshot for the largest
* |model - consensus| gap among A/B grades (the read where VYNDR disagrees most
* with the market). Empty slate → most recent real graded read. Any failure →
* { available: false } so the card HIDES; there is NO hand-written fallback
* (the old static Jokic card is gone).
*/
async function fetchWithTimeout(url: string, init?: RequestInit, ms = HERO_FETCH_TIMEOUT_MS): Promise<Response | null> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
try {
return await fetch(url, { ...init, signal: controller.signal });
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
async function pickPropFromSport(sport: string): Promise<{ prop: OddsResponseProp; sport: string } | null> {
const res = await fetchWithTimeout(`${BACKEND_URL}/api/odds/${sport}`, {
method: 'GET',
headers: { Accept: 'application/json' },
cache: 'no-store',
});
if (!res || !res.ok) return null;
const body = (await res.json().catch(() => null)) as OddsResponse | null;
if (!body || !Array.isArray(body.props) || body.props.length === 0) return null;
// Bias toward A-list player names — props with longer player names
// tend to be top-of-rotation stars (better hero material). Cheap
// heuristic, not a hard filter; we still random-pick among the top
// half of the sorted list so multiple page loads vary.
const sorted = body.props
.filter((p) => p.player && p.stat_type && Number.isFinite(p.line))
.sort((a, b) => (b.player.length - a.player.length));
if (sorted.length === 0) return null;
const topHalf = sorted.slice(0, Math.max(3, Math.ceil(sorted.length / 2)));
const pick = topHalf[Math.floor(Math.random() * topHalf.length)];
return { prop: pick, sport };
}
async function gradeProp(sport: string, prop: OddsResponseProp): Promise<GradeResponse | null> {
const res = await fetchWithTimeout(`${BACKEND_URL}/api/analyze/prop`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({
sport,
player: prop.player,
stat_type: prop.stat_type,
line: prop.line,
direction: prop.direction || 'over',
book: prop.book || 'draftkings',
}),
cache: 'no-store',
});
if (!res || !res.ok) return null;
return (await res.json().catch(() => null)) as GradeResponse | null;
}
export async function GET() {
// The order matters: NBA props lead because mid-summer the cascade
// would otherwise constantly land on the same sport. After NBA
// off-season concludes, swap to a season-aware ordering (winter:
// NBA, summer: MLB + WNBA, fall: NFL — when supported).
const sportsToTry = ['nba', 'wnba', 'mlb'];
try {
for (const sport of sportsToTry) {
const picked = await pickPropFromSport(sport);
if (!picked) continue;
const grade = await gradeProp(picked.sport, picked.prop);
if (!grade) continue;
return NextResponse.json(
{
isStatic: false,
sport: picked.sport,
prop: picked.prop,
grade,
},
{ headers: { 'Cache-Control': 'public, s-maxage=900, stale-while-revalidate=60' } },
);
}
const upstream = await fetch(`${BACKEND_URL}/api/hero-prop`, {
method: 'GET',
headers: { Accept: 'application/json' },
cache: 'no-store',
});
const data = await upstream.json().catch(() => ({ available: false }));
return NextResponse.json(data, {
status: 200,
headers: { 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=60' },
});
} catch {
// Falls through to static fallback below.
return NextResponse.json({ available: false }, { status: 200 });
}
// Static fallback — keeps the hero alive when every sport is empty.
return NextResponse.json(
{
isStatic: true,
sport: 'nba',
prop: {
player: 'Nikola Jokic',
stat_type: 'points',
line: 26.5,
direction: 'over',
book: 'draftkings',
home_team: 'DEN',
away_team: 'LAL',
},
grade: {
grade: 'A-',
confidence: 73,
edge_pct: 6.2,
projection: 29.4,
reasoning: {
summary: 'L5 form is 28.6 over 5 games, +2.1 above the line. Lakers are bottom-five vs Cs.',
},
kill_conditions_triggered: [],
},
},
{ headers: { 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=60' } },
);
}