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:
@@ -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