StatsHub Docs
Specs

AI Analysis — Agentic Pipeline Redesign

The design record for the agentic AI-analysis pipeline. Written 2026-03-23, paired with the plan of the same date.

Date: 2026-03-23 Scope: src/pages/api/ai-analysis.ts (main analysis action only — tips-research unchanged)


Problem

The current implementation generates matchContext and verdict independently from the trends/value bets data. The AI sees the data in its prompt but doesn't reason about it — it pattern-fills generic football analysis language instead of making specific, data-grounded observations. The web search (match news layer) and the stats (trends/value bets layer) never connect.


Goal

Produce analysis where:

  • Every sentence in matchContext is grounded in either a specific stat signal or a web-sourced fact
  • bestPick is justified by the intersection of match context AND statistical edge
  • verdict names specific bets and explains why they stand out in this specific match
  • Trends curation is match-aware (de-prioritises picks that conflict with known news)

Architecture — Option B: Sequential Agentic Pipeline

Phase 1 — Data Gathering (parallel, unchanged)

Fetch concurrently:

  • Builder trends (odds 1.01–1.59, top 15)
  • Singles trends (odds 1.60–3.00, top 20)
  • Longshot trends (odds 3.01–50, top 15)
  • Value bets (/api/value-bets-v2?mode=scanner&perPage=15)
  • Perplexity web search (match preview: form, injuries, tactical notes)

No change from current implementation.


Phase 2 — Internal Digest (new, not streamed)

SDK function: generateObject with a structured Zod schema (not generateText) — this ensures reliable parsing and clean injection into Phase 3 prompts.

Model: xai/grok-4.1-fast-non-reasoning, temp 0.3

Failure handling: If the digest call throws or returns malformed output, the pipeline falls back gracefully — Phase 3 and 4 proceed with an empty digest string (''). This means analysis degrades to the current implementation's quality rather than failing entirely. Log the error server-side.

Inputs injected:

  • Full match preview text from Perplexity (or 'Match preview unavailable.')
  • All trends (all three buckets), summarised with: player, team, stat type, line, hit rate, window, best odds
  • All value bets summarised with: player, market, fair odds, best bookie odds, EV%, hit rates

Schema:

const digestSchema = z.object({
  signals: z.array(z.string()).max(3).describe(
    'Up to 3 strongest stat signals. Each: player name, stat, hit rate, sample size, why significant. If fewer than 3 exist, return fewer — do not pad.'
  ),
  newsFacts: z.array(z.string()).max(4).describe(
    'Concrete facts from the match preview: specific injuries/suspensions with tactical implication, surprising form facts. Skip generic observations.'
  ),
  connections: z.array(z.string()).max(3).describe(
    'Explicit connections between a news fact and a stat signal. E.g. "Kamara suspended → Villa press disrupted → opponent midfield throughput trends more relevant." Include signals to deprioritise.'
  ),
  topBets: z.array(z.string()).max(2).describe(
    'Best 1-2 bets overall as one sentence each: player + market + why it stands out in this specific match. If no strong signals exist, return an empty array.'
  ),
});

The digest object is serialised to a string for injection into Phase 3 prompts:

SIGNALS: [signals joined by newline]
NEWS FACTS: [newsFacts joined by newline]
CONNECTIONS: [connections joined by newline]
TOP BETS: [topBets joined by newline]

Phase 3 — Analysis Sections (sequential)

All Phase 3 calls use generateObject. Model: xai/grok-4.1-fast-non-reasoning.

All calls receive the digest as a system-level context block:

"You have already analysed this match. Your findings:\n[serialised digest]"

If the digest is empty (fallback), this context block is omitted.

The isUpcoming flag (derived from matchDate) is preserved in all Phase 3 prompts, same as the current implementation — used to frame analysis as "preview" vs "post-match".

3a. matchContext (streamed first, temp 0.5)

Schema: z.object({ matchContext: z.string() })

Prompt instructions:

  • Lead with the most surprising or important fact from NEWS FACTS or SIGNALS — not a generic form summary
  • Connect at least one news fact to a stat signal explicitly
  • Name specific players where relevant
  • 2-3 sentences maximum
  • Write as a sharp analyst, not a journalist
  • Banned phrases (do not use): "counter-attacking prowess", "wing play", "ball-winning ability", "could be a good bet", "form, injuries, and tactical notes"

Streamed to frontend immediately on receipt.

3b. bestPick (streamed second, temp 0.3)

Schema: z.object({ bestPickIndex: z.number().nullable(), bestPickReason: z.string() })

  • bestPickIndex is a 0-based index into the singles bucket array (same as current)
  • The selected index is stored and passed to Phase 4 to exclude it from curated singles (preserving current deduplication behaviour)
  • bestPickReason must: reference a specific stat from SIGNALS (hit rate, sample size), reference a match context factor from NEWS FACTS or CONNECTIONS, and connect both in one concrete sentence
  • If no singles exist, return bestPickIndex: null

Streamed to frontend immediately on receipt.

3c. verdict (streamed last, after Phase 4 completes, temp 0.5)

Schema: z.object({ verdict: z.string() })

Prompt instructions:

  • 2-3 sentences
  • Must name at least one specific bet (player + market) drawn from TOP BETS or the best pick
  • Must reference the match situation (not just stats)
  • Write as if advising a friend who trusts your judgement — opinionated, specific
  • No "could be a good bet" hedging language

Streamed to frontend after Phase 4 completes.


Phase 4 — Trend Curation (parallel, starts after Phase 3b completes)

Model: xai/grok-4.1-fast-non-reasoning, temp 0.3 (same as current implementation)

Phase 4 starts after Phase 3b resolves — this is required because bestPickIndex from 3b must be known before the singles curation call can exclude it. Builder and longshot curation technically have no such dependency, but for simplicity all three fire together after 3b. Phase 3c (verdict) awaits Phase 4 completion before being called.

Same 3-category curation as now (builder, singles, longshots), including the existing per-category criteria strings (e.g. "Prefer very high hit rates (85%+)" for builder, "Balance hit rate with odds value" for singles, "Accept lower hit rates but look for decent sample sizes" for longshots). The digest adds one extra instruction to each, appended to the existing criteria:

"Additionally: deprioritise any trend for a player whose relevance is undermined by the match news in the digest (e.g. injured players, suspended players, or players whose team context makes the stat less meaningful)."

The singles curation call excludes the bestPickIndex selected in Phase 3b to avoid duplication in the UI (same as current singlesTrends.filter(…) logic).

Curated results are streamed per-category as each resolves — order may vary:

trends_builder  /  trends_singles  /  trends_longshots  (non-deterministic order, frontend handles each independently)

valueBets — sent raw immediately after Phase 4 (same cleaned projection as current implementation, no AI processing). This is unchanged.


Execution Flow

Phase 1 (parallel):   fetchTrends × 3  +  fetchValueBets  +  perplexitySearch
                               ↓ (all resolve)
Phase 2 (sequential): digestCall  →  [fallback to '' on error]

3a matchContext        → stream immediately

3b bestPick            → stream immediately (bestPickIndex stored)

Phase 4 (parallel):   curateBuilder + curateSingles(excl. bestPickIndex) + curateLongshots
                               ↓ (all three resolve, stream per-category as they arrive)
3c verdict             → stream
valueBets event        → stream
done event

Each step waits for the previous before starting. Phase 4 is the only parallel step post-digest.


SSE Event Order

Events arrive in this order (trends_* order within their group is non-deterministic):

matchContext  →  bestPick  →  [trends_builder / trends_singles / trends_longshots in any order]  →  valueBets  →  verdict  →  done

The frontend handles each section via independent state setters — arrival order within the trends group does not affect correctness.


Latency Budget

The digest step adds ~3–5s of sequential latency before matchContext is first streamed. Total pipeline: Phase 1 (~3–4s) + Phase 2 (~2–3s) + Phase 3a (~1–2s) = first content visible ~6–9s from open. This is acceptable per user agreement.

Serverless timeout: This endpoint requires export const maxDuration = 60 (or equivalent) in Next.js to avoid hitting the default 10s/30s limit. The current implementation likely already requires this. Verify this config exists on the route or add it.


Prompt Quality Rules

Banned phrases (inject into system prompt for Phase 3a, 3c):

  • "counter-attacking prowess"
  • "wing play"
  • "ball-winning ability"
  • "could be a good bet"
  • "form, injuries, and tactical notes"
  • Any phrase that would apply to any match regardless of the specific data

These do NOT need to appear in the digest prompt (Phase 2) — the digest is internal reasoning, not user-facing.

Required patterns (Phase 3 prompts):

  • Every claim must be traceable to the digest
  • Player names must appear where relevant
  • Stat values (hit rates, odds, EV) should be quoted when they support a claim

Rollback

If the digest degrades output quality (e.g. hallucinated connections), the Phase 2 failure fallback (empty digest string) provides a natural rollback path — no code change needed, just throw from the digest call or set digest to ''.


Files Changed

  • src/pages/api/ai-analysis.ts — main analysis handler rewritten; tips-research handler unchanged

Files Unchanged

  • src/components/fixtures/ai-analysis-panel.tsx — no frontend changes needed
  • src/components/fixtures/ai-analysis-button.tsx — unchanged
  • src/pages/fixture/[fixtureSlug]/[fixtureId].tsx — unchanged

What This Does NOT Change

  • Tips research flow (action: 'tips-research') — untouched
  • Frontend panel UI and streaming consumption
  • SSE event names and data shapes
  • Trends curation scoring criteria (beyond the new match-awareness instruction)
  • valueBets SSE event — sent raw, same as current implementation

On this page