The why behind events: narrative context from the match report
'X is shown the red card' explains nothing. ESPN's summary carries the
full post-match report (Reuters-style narrative) that we fetched and
discarded — it has exactly the missing why ('red-carded for bringing
down Gutierrez just outside the box').
- normalizer keeps the report (headline + plain-text story, schema v4;
boot backfill re-stores played matches automatically)
- expanding an event surfaces the report sentences about that player,
scored by event vocabulary so a player's sending-off sentence beats
his goal mention; attribution line marks the source
- the raw no-reason fallback line is suppressed once the report
explains a booking; xG/momentum/VAR layers stay underneath
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -75,7 +75,7 @@ export interface EspnTimelineEvent {
|
||||
shootout?: boolean;
|
||||
}
|
||||
/** Current normalizer schema — bump to make the boot backfill re-store. */
|
||||
export const SUMMARY_V = 3;
|
||||
export const SUMMARY_V = 4;
|
||||
|
||||
export interface EspnCommentaryLine { minute: number | null; text: string }
|
||||
|
||||
@@ -95,11 +95,15 @@ export interface EspnSummary {
|
||||
events: EspnTimelineEvent[];
|
||||
/** Full play-by-play feed (attempts, fouls, corners, VAR…), kickoff first. */
|
||||
commentary?: EspnCommentaryLine[];
|
||||
/** Post-match report article (the narrative — why cards were shown, how
|
||||
* goals came about). Plain text, appears around full time. */
|
||||
report?: { headline: string; story: string } | null;
|
||||
}
|
||||
|
||||
export interface RawSummary {
|
||||
gameInfo?: { venue?: { fullName?: string } };
|
||||
commentary?: { sequence?: number | string; time?: { displayValue?: string }; text?: string }[];
|
||||
article?: { headline?: string; story?: string };
|
||||
header?: { competitions?: { competitors?: { id: string; team?: { displayName?: string }; homeAway: 'home' | 'away' }[] }[] };
|
||||
headToHeadGames?: { events?: { gameDate: string; homeTeamId: string; awayTeamId: string; homeTeamScore: string; awayTeamScore: string }[] }[];
|
||||
boxscore?: {
|
||||
@@ -182,5 +186,12 @@ export function normalizeSummary(raw: RawSummary): EspnSummary {
|
||||
})
|
||||
.filter((c) => c.text);
|
||||
|
||||
return { v: SUMMARY_V, fetchedAt: Date.now(), venue: raw.gameInfo?.venue?.fullName ?? null, teams, h2h, form, lineups, stats, events, commentary };
|
||||
const story = (raw.article?.story ?? '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 8000);
|
||||
const report = story ? { headline: raw.article?.headline ?? '', story } : null;
|
||||
|
||||
return { v: SUMMARY_V, fetchedAt: Date.now(), venue: raw.gameInfo?.venue?.fullName ?? null, teams, h2h, form, lineups, stats, events, commentary, report };
|
||||
}
|
||||
|
||||
@@ -131,6 +131,7 @@ export function buildPreview(num: number, state: TournamentState, model: ModelEn
|
||||
? { home: summary!.stats[eh.id] ?? {}, away: summary!.stats[ea.id] ?? {} }
|
||||
: null,
|
||||
commentary: summary?.commentary ?? [],
|
||||
report: summary?.report ?? null,
|
||||
events: (summary?.events ?? []).map((e) => ({
|
||||
minute: e.minute,
|
||||
type: e.type,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo, useState, type KeyboardEvent } from 'react';
|
||||
import { ArrowRightLeft, ChevronDown, CircleDot, Volleyball } from 'lucide-react';
|
||||
import { fmt, useT } from '@/lib/i18n';
|
||||
import { eventContext, goalShot, momentumVerdict, parseEvents, halfTimeScore, varLines, type CommentaryLine, type EventKind, type ParsedEvent } from '@/lib/matchEvents';
|
||||
import { eventContext, goalShot, momentumVerdict, parseEvents, halfTimeScore, reportSentences, varLines, type CommentaryLine, type EventKind, type ParsedEvent } from '@/lib/matchEvents';
|
||||
import type { MatchLiveEvent, RichShot } from '@/lib/types';
|
||||
|
||||
/** Crisp event markers instead of emoji: ball icon for goals, card-shaped
|
||||
@@ -61,22 +61,28 @@ interface RichContext {
|
||||
shots: RichShot[];
|
||||
momentum: { minute: number; value: number }[];
|
||||
commentary: CommentaryLine[];
|
||||
report: string;
|
||||
}
|
||||
|
||||
/** The expanded story, from data we trust: the chance behind a goal (xG,
|
||||
* situation, distance), whether it came against the run of play, any VAR
|
||||
* line for the decision — then the feed's own description of the action. */
|
||||
function ContextPanel({ e, ctx, shots, momentum, commentary }: {
|
||||
function ContextPanel({ e, ctx, shots, momentum, commentary, report }: {
|
||||
e: ParsedEvent;
|
||||
ctx: string;
|
||||
shots: RichShot[];
|
||||
momentum: { minute: number; value: number }[];
|
||||
commentary: CommentaryLine[];
|
||||
report: string;
|
||||
}) {
|
||||
const t = useT();
|
||||
const shot = goalShot(e, shots);
|
||||
const verdict = momentumVerdict(e, momentum);
|
||||
const vars = varLines(e, commentary);
|
||||
const story = reportSentences(e, report);
|
||||
// A booking's ctx falls back to the raw "is shown the red card" line when
|
||||
// the feed gives no reason — pure noise once the report explains it.
|
||||
const showCtx = !(story.length > 0 && (e.kind === 'yellow' || e.kind === 'red') && ctx === e.raw.trim());
|
||||
const situations = t.match.situations as Record<string, string>;
|
||||
const chance = shot && shot.xg != null
|
||||
? fmt(shot.distM != null ? t.match.insight.chance : t.match.insight.chanceNoDist, {
|
||||
@@ -88,10 +94,12 @@ function ContextPanel({ e, ctx, shots, momentum, commentary }: {
|
||||
: null;
|
||||
return (
|
||||
<div className="mt-1.5 space-y-1 whitespace-normal text-xs leading-relaxed">
|
||||
{chance && <p className="font-medium text-ink-soft">{chance}</p>}
|
||||
{story.map((s, j) => <p key={j} className="text-ink-soft">{s}</p>)}
|
||||
{chance && <p className="font-medium text-muted">{chance}</p>}
|
||||
{verdict && <p className="text-muted">{verdict === 'against' ? t.match.insight.againstRun : t.match.insight.withRun}</p>}
|
||||
{vars.map((v, j) => <p key={j} className="text-gold">{v}</p>)}
|
||||
<p className="text-faint">{ctx}</p>
|
||||
{showCtx && <p className="text-faint">{ctx}</p>}
|
||||
{story.length > 0 && <p className="text-[10px] uppercase tracking-wide text-faint">{t.match.reportAttribution}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -189,13 +197,15 @@ export interface MatchTimelineProps {
|
||||
shots?: RichShot[];
|
||||
/** Attack-momentum curve (positive = home pressure). */
|
||||
momentum?: { minute: number; value: number }[];
|
||||
/** Post-match report text — narrative context per event. */
|
||||
report?: string;
|
||||
status: 'scheduled' | 'live' | 'finished';
|
||||
minute?: number | null;
|
||||
homeScore?: number | null;
|
||||
awayScore?: number | null;
|
||||
}
|
||||
|
||||
export function MatchTimeline({ events, commentary = [], shots = [], momentum = [], status, minute, homeScore, awayScore }: MatchTimelineProps) {
|
||||
export function MatchTimeline({ events, commentary = [], shots = [], momentum = [], report = '', status, minute, homeScore, awayScore }: MatchTimelineProps) {
|
||||
const t = useT();
|
||||
// Tap-to-expand context per row (multiple rows can stay open).
|
||||
const [open, setOpen] = useState<ReadonlySet<number>>(new Set());
|
||||
@@ -266,7 +276,7 @@ export function MatchTimeline({ events, commentary = [], shots = [], momentum =
|
||||
);
|
||||
}
|
||||
const ctx = eventContext(e);
|
||||
const rich = { shots, momentum, commentary };
|
||||
const rich = { shots, momentum, commentary, report };
|
||||
const content = e.kind === 'goal'
|
||||
? <GoalCard e={e} ctx={ctx} rich={rich} open={open.has(i)} onToggle={() => toggle(i)} />
|
||||
: <MinorChip e={e} ctx={ctx} rich={rich} open={open.has(i)} onToggle={() => toggle(i)} />;
|
||||
@@ -303,8 +313,8 @@ export function MatchTimeline({ events, commentary = [], shots = [], momentum =
|
||||
{e.side === null
|
||||
? <NeutralChip e={e} showMinute={false} />
|
||||
: e.kind === 'goal'
|
||||
? <GoalCard e={e} mobile ctx={eventContext(e)} rich={{ shots, momentum, commentary }} open={open.has(i)} onToggle={() => toggle(i)} />
|
||||
: <MinorChip e={e} mobile ctx={eventContext(e)} rich={{ shots, momentum, commentary }} open={open.has(i)} onToggle={() => toggle(i)} />}
|
||||
? <GoalCard e={e} mobile ctx={eventContext(e)} rich={{ shots, momentum, commentary, report }} open={open.has(i)} onToggle={() => toggle(i)} />
|
||||
: <MinorChip e={e} mobile ctx={eventContext(e)} rich={{ shots, momentum, commentary, report }} open={open.has(i)} onToggle={() => toggle(i)} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -370,7 +370,7 @@ export function MatchPreviewPage() {
|
||||
{tab === 'timeline' && preview.events.length > 0 && (
|
||||
<Card>
|
||||
<CardBody className="px-2 sm:px-4">
|
||||
<MatchTimeline events={preview.events} commentary={preview.commentary} shots={rich?.shots ?? []} momentum={rich?.momentum ?? []} status={f.status} minute={f.minute} homeScore={f.homeScore} awayScore={f.awayScore} />
|
||||
<MatchTimeline events={preview.events} commentary={preview.commentary} shots={rich?.shots ?? []} momentum={rich?.momentum ?? []} report={preview.report?.story ?? ''} status={f.status} minute={f.minute} homeScore={f.homeScore} awayScore={f.awayScore} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -141,6 +141,7 @@ export const de: Dict = {
|
||||
IndividualPlay: "Einzelaktion",
|
||||
ThrowInSetPiece: "Nach einem Einwurf",
|
||||
},
|
||||
reportAttribution: "Aus dem ESPN-Spielbericht.",
|
||||
insight: {
|
||||
chance: "{situation} · {dist} m vor dem Tor · {xg} xG — eine {pct}-%-Chance",
|
||||
chanceNoDist: "{situation} · {xg} xG — eine {pct}-%-Chance",
|
||||
|
||||
@@ -140,6 +140,7 @@ export const en = {
|
||||
IndividualPlay: "Individual effort",
|
||||
ThrowInSetPiece: "From a throw-in",
|
||||
},
|
||||
reportAttribution: "From the ESPN match report.",
|
||||
insight: {
|
||||
chance: "{situation} · {dist} m out · {xg} xG — a {pct}% chance",
|
||||
chanceNoDist: "{situation} · {xg} xG — a {pct}% chance",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { eventContext, goalEvents, goalShot, halfTimeScore, momentumVerdict, parseEvents, varLines } from './matchEvents';
|
||||
import { eventContext, goalEvents, goalShot, halfTimeScore, momentumVerdict, parseEvents, reportSentences, varLines } from './matchEvents';
|
||||
import type { MatchLiveEvent } from './types';
|
||||
|
||||
// Real shapes from the ESPN feed (WC 2026 match 2, Korea Republic – Czechia).
|
||||
@@ -169,6 +169,38 @@ describe('goal insight', () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('reportSentences', () => {
|
||||
// Real sentences from the ESPN/Reuters report for Mexico 2-0 South Africa.
|
||||
const story =
|
||||
"They were a goal up inside 10 minutes when Sphephelo Sithole was caught in possession on the edge of his box by Erik Lira and the ball fell to Quinones, who drove a low shot through the legs of Williams to give the co-hosts a dream start in the ninth minute. " +
|
||||
"Sithole's miserable afternoon was complete when he was red-carded for bringing down Gutierrez just outside the box early in the second period, but it took until midway through the half for Mexico to kill off the contest.";
|
||||
|
||||
const red = parseEvents([{ minute: 49, type: 'Red Card', team: 'away', text: 'Yaya Sithole (South Africa) is shown the red card.' }])[0]!;
|
||||
const goal = parseEvents([{ minute: 9, type: 'Goal', team: 'home', text: 'Goal! Mexico 1, South Africa 0. Julián Quiñones (Mexico) left footed shot from outside the box.' }])[0]!;
|
||||
|
||||
it('picks the card sentence for the card, not the goal narrative', () => {
|
||||
const out = reportSentences(red, story);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]).toContain('red-carded for bringing down Gutierrez');
|
||||
});
|
||||
|
||||
it('finds the goal narrative despite missing diacritics in the report', () => {
|
||||
const out = reportSentences(goal, story);
|
||||
expect(out[0]).toContain('drove a low shot through the legs of Williams');
|
||||
});
|
||||
|
||||
it('returns nothing for players the report never mentions', () => {
|
||||
const other = parseEvents([{ minute: 70, type: 'Yellow Card', team: 'home', text: 'John Nobody (Mexico) is shown the yellow card.' }])[0]!;
|
||||
expect(reportSentences(other, story)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns nothing for subs and neutral events', () => {
|
||||
const sub = parseEvents([{ minute: 60, type: 'Substitution', team: 'home', text: 'Substitution, Mexico. A replaces Sithole.' }])[0]!;
|
||||
expect(reportSentences(sub, story)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('halfTimeScore', () => {
|
||||
it('is the last goal score at minute ≤ 45', () => {
|
||||
expect(halfTimeScore(parseEvents(realEvents))).toEqual([0, 0]);
|
||||
|
||||
@@ -209,6 +209,33 @@ export function momentumVerdict(
|
||||
return (e.side === 'home') === avg > 0 ? 'with' : 'against';
|
||||
}
|
||||
|
||||
|
||||
const KIND_WORDS: Record<string, RegExp> = {
|
||||
goal: /\b(goal|scor|header|finish|net|drove|struck|slott|tapp|convert|fired|curl|poked|volley|made it|doubled|equali[sz]|lead|winner)\w*/i,
|
||||
red: /\b(red[- ]card|sent[- ]off|dismiss|marching orders|red card)\w*/i,
|
||||
yellow: /\b(book|yellow|caution)\w*/i,
|
||||
};
|
||||
|
||||
/**
|
||||
* The match report's sentences about THIS event — the narrative "why" the
|
||||
* feed lacks ("red-carded for bringing down Gutierrez just outside the
|
||||
* box"). Sentences must mention the player; those matching the event kind's
|
||||
* vocabulary win, so a player's card sentence beats his goal sentence.
|
||||
*/
|
||||
export function reportSentences(e: ParsedEvent, story: string, max = 2): string[] {
|
||||
if (!story || e.kind === 'sub' || e.kind === 'other') return [];
|
||||
const last = norm(e.title).split(' ').pop() ?? '';
|
||||
if (last.length < 3) return [];
|
||||
const kindRe = KIND_WORDS[e.kind === 'goal' ? 'goal' : e.kind === 'red' ? 'red' : 'yellow'];
|
||||
const scored = story
|
||||
.split(/(?<=[.!?])\s+/)
|
||||
.filter((s) => s.length > 20 && s.length < 400 && new RegExp(`\\b${last}\\b`, 'i').test(norm(s)))
|
||||
.map((s) => ({ s: s.trim(), hit: kindRe ? kindRe.test(s) : false }));
|
||||
if (!scored.length) return [];
|
||||
const hits = scored.filter((x) => x.hit).map((x) => x.s);
|
||||
return (hits.length ? hits : [scored[0]!.s]).slice(0, max);
|
||||
}
|
||||
|
||||
/** VAR / video-review feed lines belonging to this event (±1 minute) —
|
||||
* the one slice of the play-by-play that genuinely explains a decision. */
|
||||
export function varLines(e: ParsedEvent, commentary: CommentaryLine[]): string[] {
|
||||
|
||||
@@ -161,6 +161,8 @@ export interface MatchPreview {
|
||||
events: MatchLiveEvent[];
|
||||
/** Full play-by-play feed, kickoff first — buildup context for events. */
|
||||
commentary: { minute: number | null; text: string }[];
|
||||
/** Post-match report — the narrative source for event context. */
|
||||
report: { headline: string; story: string } | null;
|
||||
/** Honest note on which data layers are populated yet. */
|
||||
dataNote: string;
|
||||
updatedAt: number | null;
|
||||
|
||||
Reference in New Issue
Block a user