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
+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;