Cleaner event panels: hide raw feed prose, one best report sentence

- the ESPN feed description and attribution line are gone from goal
  panels whenever the report narrative or shot data tells the story
  (attribution lives in a tooltip); bookings keep their reason line
- one report sentence per event, ranked by event vocabulary with a
  bonus when the player is the actor ('Quinones, who drove a low
  shot…' beats 'goals from Quinones and Jimenez gave…')
- momentum verdicts only ever applied to goals (a clear-pressure
  booking no longer claims it was 'scored against the run of play')

Routine events the report never mentions (e.g. an early booking) show
what the sources have: the reason. There is no richer feed for those.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 14:41:27 +02:00
parent 2d52995967
commit 9e1f6e5cee
3 changed files with 26 additions and 10 deletions
+8 -6
View File
@@ -77,12 +77,9 @@ function ContextPanel({ e, ctx, shots, momentum, commentary, report }: {
}) {
const t = useT();
const shot = goalShot(e, shots);
const verdict = momentumVerdict(e, momentum);
const verdict = e.kind === 'goal' ? momentumVerdict(e, momentum) : null;
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, {
@@ -92,14 +89,19 @@ function ContextPanel({ e, ctx, shots, momentum, commentary, report }: {
pct: Math.round(shot.xg * 100),
}) + (shot.xgot != null ? ` · ${shot.xgot.toFixed(2)} xGOT` : '')
: null;
// The raw feed line is a fallback, not a feature: a goal's prose hides once
// the report or shot data tells the story; a booking's no-reason sentence
// hides once the report explains it. Real reasons ("For a bad foul.") stay.
const showCtx = e.kind === 'goal'
? story.length === 0 && !chance
: !(story.length > 0 && ctx === e.raw.trim());
return (
<div className="mt-1.5 space-y-1 whitespace-normal text-xs leading-relaxed">
{story.map((s, j) => <p key={j} className="text-ink-soft">{s}</p>)}
{story.map((s, j) => <p key={j} className="text-ink-soft" title={t.match.reportAttribution}>{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>)}
{showCtx && <p className="text-faint">{ctx}</p>}
{story.length > 0 && <p className="text-[10px] uppercase tracking-wide text-faint">{t.match.reportAttribution}</p>}
</div>
);
}
+8
View File
@@ -185,6 +185,14 @@ describe('reportSentences', () => {
expect(out[0]).toContain('red-carded for bringing down Gutierrez');
});
it('prefers the most event-specific sentence over a generic summary', () => {
const noisy = "Goals from Quinones and Jimenez gave Mexico the win. " +
"They were a goal up when the ball fell to Quinones, who drove a low shot through the legs of Williams to score in the ninth minute.";
const out = reportSentences(goal, noisy);
expect(out).toHaveLength(1);
expect(out[0]).toContain('drove a low shot');
});
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');
+10 -4
View File
@@ -222,7 +222,7 @@ const KIND_WORDS: Record<string, RegExp> = {
* 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[] {
export function reportSentences(e: ParsedEvent, story: string, max = 1): string[] {
if (!story || e.kind === 'sub' || e.kind === 'other') return [];
const last = norm(e.title).split(' ').pop() ?? '';
if (last.length < 3) return [];
@@ -230,10 +230,16 @@ export function reportSentences(e: ParsedEvent, story: string, max = 2): string[
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 }));
.map((s) => {
let hits = kindRe ? (s.match(new RegExp(kindRe.source, 'gi')) ?? []).length : 0;
// The player ACTING ("Quinones, who drove a low shot") outranks the
// player merely listed ("goals from Quinones and Jimenez gave…").
if (kindRe && new RegExp(`\\b${last}\\b[^.]{0,60}?${kindRe.source}`, 'i').test(norm(s))) hits += 2;
return { s: s.trim(), hits };
});
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);
scored.sort((a, b) => b.hits - a.hits);
return scored.slice(0, max).map((x) => x.s);
}
/** VAR / video-review feed lines belonging to this event (±1 minute) —