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:
2026-06-12 14:16:59 +02:00
parent 089d789e49
commit 2d52995967
9 changed files with 97 additions and 12 deletions
+13 -2
View File
@@ -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 };
}
+1
View File
@@ -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,
+18 -8
View File
@@ -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>
);
+1 -1
View File
@@ -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>
)}
+1
View File
@@ -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",
+1
View File
@@ -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",
+33 -1
View File
@@ -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]);
+27
View File
@@ -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[] {
+2
View File
@@ -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;