StatsHub Docs
Plans

AI Analysis Agentic Pipeline Implementation Plan

The implementation plan for turning AI match analysis into an agentic pipeline. Written 2026-03-23.

For agentic workers: REQUIRED SUB-SKILL: Use superpowers

(recommended) or superpowers
to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Replace the current single-shot AI analysis call with a 4-phase agentic pipeline that produces grounded, data-connected match analysis instead of generic football journalism.

Architecture: Phase 1 fetches all data in parallel (unchanged). Phase 2 runs a private generateObject digest that reasons over the data and produces a structured scratchpad. Phases 3a–3c run sequentially, each receiving the digest as context. Phase 4 runs curation in parallel after Phase 3b, with Phase 3c (verdict) following Phase 4 completion.

Tech Stack: Next.js API route, Vercel AI SDK (generateObject), Zod, SSE streaming, xai/grok-4.1-fast-non-reasoning model via gateway()


File Map

FileActionNotes
src/pages/api/ai-analysis.tsModifyMain handler only — handleTipsResearch is untouched

No other files change.


Task 1: Verify maxDuration export and read the current handler

Files:

  • Read: src/pages/api/ai-analysis.ts

  • Step 1: Check for maxDuration export

Open src/pages/api/ai-analysis.ts and search for maxDuration. If it does not exist at the top level, add it:

export const maxDuration = 60;

Add it directly after the imports, before the sendEvent function. This prevents serverless timeout on Vercel.

  • Step 2: Commit if changed
git add src/pages/api/ai-analysis.ts
git commit -m "fix: add maxDuration=60 to ai-analysis API route"

If maxDuration was already present, skip this commit.


Task 2: Add the digest schema and helper

Files:

  • Modify: src/pages/api/ai-analysis.ts

The digest is a generateObject call that produces structured internal reasoning. It uses a dedicated Zod schema. Add this immediately after the existing aiCurationSchema definition (search for aiCurationSchema to find the insertion point — it is near the top of the file, after the imports).

Also: remove the aiCurationSchema constant entirely — it is replaced by the new per-section inline schemas in Task 3. And remove the categorySchema constant that currently sits inside the handler function body — it will be redeclared inside the new try block in Task 3 to avoid a duplicate const error.

  • Step 1: Add the digestSchema and serialiseDigest helper

After the existing aiCurationSchema block, insert:

const digestSchema = z.object({
  signals: z.array(z.string()).max(3).describe(
    'Up to 3 strongest stat signals. Each entry: player name, stat type, hit rate, sample size, and one sentence on why it is 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 only: specific injuries or suspensions with their 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. Example: "Kamara suspended → Villa press disrupted → opponent midfield throughput trends more relevant." Also include signals to deprioritise given the news.'
  ),
  topBets: z.array(z.string()).max(2).describe(
    'Best 1-2 bets as one sentence each: player + market + why it stands out in this specific match. If no strong signals exist, return an empty array — do not fabricate.'
  ),
});

type Digest = z.infer<typeof digestSchema>;

function serialiseDigest(digest: Digest): string {
  const lines: string[] = [];
  if (digest.signals.length > 0) {
    lines.push('SIGNALS:\n' + digest.signals.map(s => `- ${s}`).join('\n'));
  }
  if (digest.newsFacts.length > 0) {
    lines.push('NEWS FACTS:\n' + digest.newsFacts.map(f => `- ${f}`).join('\n'));
  }
  if (digest.connections.length > 0) {
    lines.push('CONNECTIONS:\n' + digest.connections.map(c => `- ${c}`).join('\n'));
  }
  if (digest.topBets.length > 0) {
    lines.push('TOP BETS:\n' + digest.topBets.map(b => `- ${b}`).join('\n'));
  }
  return lines.join('\n\n');
}
  • Step 2: Verify the file still parses (no TS errors)
npx tsc --noEmit 2>&1 | head -30

Expected: no errors related to the new schema or helper.

  • Step 3: Commit
git add src/pages/api/ai-analysis.ts
git commit -m "feat: add digest schema and serialiseDigest helper to ai-analysis"

Task 3: Replace the main handler with the agentic pipeline

Files:

  • Modify: src/pages/api/ai-analysis.ts

This is the core change. Replace everything inside the try block of the main handler (from the // --- Fetch everything in parallel --- comment through to sendEvent(res, 'done', null)) with the new 4-phase pipeline. The handleTipsResearch function below remains completely untouched.

  • Step 1: Replace the main handler try block

Locate the try { block in the main handler export function (after the SSE headers are flushed). Replace only the body of the try block — from const baseUrl = ... through sendEvent(res, 'done', null); res.end(); — with the following. Do NOT remove or replace the outer catch (error) { ... } block that follows — it handles unexpected errors and sends the error SSE event.

Also: before making this replacement, remove the existing categorySchema const declaration from inside the handler function body (it appears before the try block). The replacement code below redeclares it inside the try block.

    const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
    const isUpcoming = !matchDate || new Date(matchDate) > new Date();
    const cookie = req.headers.cookie || '';

    // Helper to fetch trends for a specific odds range
    const fetchTrends = (from: number, to: number, pageSize = 20) =>
      fetch(`${baseUrl}/api/props/player-trends`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Cookie: cookie },
        body: JSON.stringify({
          games: [Number(eventId)], statTypes: [], bookmakers: [],
          oddsRange: { from, to }, page: 1, pageSize,
          sorting: { column: 'trendPercentage', direction: 'desc' }, minWindow: 5,
        }),
      }).then(r => r.json()).then(r => r?.data || []).catch(() => []);

    // Helper to summarise trends for AI prompt
    const summariseTrends = (trends: any[]) => trends.length > 0
      ? trends.map((t: any, i: number) =>
          `[${i}] ${t.playerName} (${t.teamName}): ${t.statType} ${t.oddsType} ${t.line} — ` +
          `${t.trendHits}/${t.trendWindow} (${Math.round(t.trendHits / t.trendWindow * 100)}%) — ` +
          `Best odds: ${t.bookmakers?.[0]?.oddsValue?.toFixed(2) || 'N/A'}`
        ).join('\n')
      : 'None available.';

    // Helper to summarise value bets for AI prompt
    const summariseValueBets = (vbs: any[]) => vbs.length > 0
      ? vbs.map((vb: any) =>
          `${vb.playerName} (${vb.teamName}): ${vb.marketType} over ${vb.line} — ` +
          `Fair odds: ${vb.scannerFairOdds?.toFixed(2) || 'N/A'}, Best: ${vb.bestOverOdds?.toFixed(2) || 'N/A'}, ` +
          `EV: ${vb.scannerEdge != null ? `${vb.scannerEdge.toFixed(1)}%` : 'N/A'}, ` +
          `L10: ${vb.hitRates?.l10 != null ? `${Math.round(vb.hitRates.l10)}%` : 'N/A'}`
        ).join('\n')
      : 'None available.';

    // AI curation schema for each category
    const categorySchema = z.object({
      bestIndices: z.array(z.number()).max(5).describe('Indices of the best 3-5 picks from this category'),
    });

    // --- PHASE 1: Fetch everything in parallel ---
    const [builderTrends, singlesTrends, longshotTrends, valueBetsRaw, perplexityResult] = await Promise.all([
      fetchTrends(1.01, 1.59, 15),
      fetchTrends(1.60, 3.00, 20),
      fetchTrends(3.01, 50, 15),
      fetch(`${baseUrl}/api/value-bets-v2?eventIds=${eventId}&mode=scanner&perPage=15`, { headers: { Cookie: cookie } })
        .then(r => r.json()).then(r => r?.data || []).catch(() => []),
      webSearch(
        `${homeTeamName} vs ${awayTeamName} match preview ${tournamentName || 'football'}. ` +
        `Include: team form, key injuries/suspensions, tactical notes, head-to-head recent history. Be concise.`
      ).catch(() => null),
    ]);

    const matchPreview = perplexityResult?.data?.answer || 'Match preview unavailable.';

    // --- PHASE 2: Internal digest (not streamed) ---
    let digestStr = '';
    try {
      const allTrendsSummary = [
        builderTrends.length > 0 ? `BET BUILDER TRENDS (low odds):\n${summariseTrends(builderTrends)}` : '',
        singlesTrends.length > 0 ? `SINGLES TRENDS (mid odds):\n${summariseTrends(singlesTrends)}` : '',
        longshotTrends.length > 0 ? `LONGSHOT TRENDS (high odds):\n${summariseTrends(longshotTrends)}` : '',
      ].filter(Boolean).join('\n\n') || 'No trends available.';

      const valueBetsSummary = summariseValueBets(valueBetsRaw);

      const { object: digest } = await generateObject({
        model: gateway('xai/grok-4.1-fast-non-reasoning'),
        temperature: 0.3,
        schema: digestSchema,
        prompt: `You are a football betting analyst. Analyse the following data for ${homeTeamName} vs ${awayTeamName}${tournamentName ? ` (${tournamentName})` : ''} and produce a structured internal digest.

MATCH PREVIEW (from web search):
${matchPreview}

PLAYER TRENDS:
${allTrendsSummary}

VALUE BETS:
${valueBetsSummary}

Instructions:
- Extract the 2-3 strongest statistical signals (high hit rate + good sample size + relevant odds)
- Extract only concrete, specific facts from the match preview (injuries with named players, specific form stats)
- Identify explicit connections between news facts and statistical signals — e.g. how an injury changes which trends become more or less meaningful
- List the 1-2 best bets that combine strong stats AND match context
- If there is no meaningful data in a category, leave it empty — do not fabricate`,
      });

      digestStr = serialiseDigest(digest);
    } catch (digestErr) {
      console.error('[AI-ANALYSIS] Digest failed, proceeding without context:', digestErr);
      // digestStr remains '' — pipeline degrades gracefully
    }

    const digestContext = digestStr
      ? `You have already analysed this match. Your findings:\n${digestStr}`
      : '';

    const BANNED_PHRASES = [
      '"counter-attacking prowess"', '"wing play"', '"ball-winning ability"',
      '"could be a good bet"', '"form, injuries, and tactical notes"',
    ].join(', ');

    // --- PHASE 3a: Match context ---
    const { object: ctxResult } = await generateObject({
      model: gateway('xai/grok-4.1-fast-non-reasoning'),
      temperature: 0.5,
      schema: z.object({ matchContext: z.string() }),
      system: digestContext || undefined,
      prompt: `Write a match context for ${homeTeamName} vs ${awayTeamName}${tournamentName ? ` (${tournamentName})` : ''} for an ${isUpcoming ? 'upcoming' : 'post-match'} analysis.

Rules:
- Lead with the most surprising or important fact — 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 advising a knowledgeable bettor
- Do NOT use these phrases: ${BANNED_PHRASES}
- Every sentence must be traceable to a specific fact or stat — no generic observations`,
    });

    sendEvent(res, 'matchContext', ctxResult.matchContext);

    // --- PHASE 3b: Best pick ---
    const singlesText = summariseTrends(singlesTrends);
    const { object: pickResult } = await generateObject({
      model: gateway('xai/grok-4.1-fast-non-reasoning'),
      temperature: 0.3,
      schema: z.object({
        bestPickIndex: z.number().nullable().describe('0-based index into the singles list, or null if no singles exist'),
        bestPickReason: z.string().describe('One concrete sentence connecting the stat signal to the match context'),
      }),
      system: digestContext || undefined,
      prompt: `Pick the single best bet from this singles list for ${homeTeamName} vs ${awayTeamName}.

SINGLES (indexed 0-based):
${singlesText}

Rules:
- bestPickIndex: the index of the single best trend (balance hit rate, sample size, odds value)
- bestPickReason: one sentence that names the specific stat signal (e.g. "8/10 games") AND a match context factor (e.g. an injury or form run) that makes this pick compelling in this specific fixture
- If no singles exist, return bestPickIndex: null`,
    });

    const bestPickIndex = pickResult.bestPickIndex;
    if (bestPickIndex != null && singlesTrends[bestPickIndex]) {
      sendEvent(res, 'bestPick', { ...singlesTrends[bestPickIndex], reason: pickResult.bestPickReason });
    }

    // --- PHASE 4: Trend curation (parallel, uses bestPickIndex for deduplication) ---
    const deprioritiseInstruction = digestStr
      ? '\n\nAdditionally: 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).'
      : '';

    const curateCategory = async (trendsList: any[], label: string, criteria: string) => {
      if (trendsList.length <= 5) return trendsList;
      const text = summariseTrends(trendsList);
      const { object } = await generateObject({
        model: gateway('xai/grok-4.1-fast-non-reasoning'),
        temperature: 0.3,
        schema: categorySchema,
        system: digestContext || undefined,
        prompt: `Pick the best 3-5 trends from this ${label} list. ${criteria}${deprioritiseInstruction}

TRENDS (indexed):
${text}

Pick indices that offer the best value — consider hit rate vs odds, sample size, and diversity of stat types and teams.`,
      });
      return object.bestIndices.map((i: number) => trendsList[i]).filter(Boolean);
    };

    const [curatedBuilder, curatedSingles, curatedLongshots] = await Promise.all([
      curateCategory(builderTrends, 'Bet Builder Adds (under 1.60 odds)', 'These are low-odds banker legs. Prefer very high hit rates (85%+) and large sample sizes.'),
      curateCategory(
        singlesTrends.filter((_: any, i: number) => i !== bestPickIndex),
        'Singles (1.60-3.00 odds)',
        'These are the core singles range. Balance hit rate with odds value — a 70% hit rate at 2.50 is better than 90% at 1.65.',
      ),
      curateCategory(longshotTrends, 'Longshots (3.00+ odds)', 'These are bigger odds plays. Accept lower hit rates but look for decent sample sizes and standout odds.'),
    ]);

    if (curatedBuilder.length > 0) sendEvent(res, 'trends_builder', curatedBuilder);
    if (curatedSingles.length > 0) sendEvent(res, 'trends_singles', curatedSingles);
    if (curatedLongshots.length > 0) sendEvent(res, 'trends_longshots', curatedLongshots);

    // --- PHASE 3c: Verdict (after curation, before valueBets) ---
    const topBetsContext = digestStr ? `\n\nTop bets from your analysis:\n${digestStr.split('TOP BETS:')[1]?.trim() || ''}` : '';
    const { object: verdictResult } = await generateObject({
      model: gateway('xai/grok-4.1-fast-non-reasoning'),
      temperature: 0.5,
      schema: z.object({ verdict: z.string() }),
      system: digestContext || undefined,
      prompt: `Write a verdict for ${homeTeamName} vs ${awayTeamName}${tournamentName ? ` (${tournamentName})` : ''}.${topBetsContext}

Rules:
- 2-3 sentences
- Name at least one specific bet (player + market) and explain why it stands out in THIS specific match
- Reference the match situation (not just stats) — injuries, form, tactical context
- Write as if advising a trusted friend — opinionated, direct, specific
- Do NOT use: ${BANNED_PHRASES}
- No hedging language like "could be a good bet" or "worth considering"`,
    });

    sendEvent(res, 'verdict', verdictResult.verdict);

    // Value bets — raw, unchanged, sent after verdict
    if (valueBetsRaw.length > 0) {
      sendEvent(res, 'valueBets', valueBetsRaw.map((vb: any) => ({
        playerName: vb.playerName, playerId: vb.playerId,
        teamName: vb.teamName, teamId: vb.teamId,
        position: vb.position || null,
        marketType: vb.marketType, line: vb.line,
        bestOverOdds: vb.bestOverOdds || null,
        scannerFairOdds: vb.scannerFairOdds || null,
        scannerModelName: vb.scannerModelName || null,
        edge: vb.scannerEdge || vb.avgOverEdge || null,
        hitRates: vb.hitRates || null,
        sampleSize: vb.sampleSize || null,
        recentGames: (vb.recentGames || []).slice(0, 10),
        bookmakers: (vb.bookmakers || []).map((b: any) => ({ source: b.source, overOdds: b.overOdds })),
        activityInfo: vb.activityInfo || null,
      })));
    }

    sendEvent(res, 'done', null);
    res.end();
  • Step 2: Verify TypeScript compiles
npx tsc --noEmit 2>&1 | head -40

Expected: no errors. Common fix: if digestSchema or Digest is referenced before declaration, move the schema definitions above the handler export.

  • Step 3: Commit
git add src/pages/api/ai-analysis.ts
git commit -m "feat: replace ai-analysis main handler with 4-phase agentic pipeline"

Task 4: Smoke test in the browser

There are no automated tests for this API (it calls external AI models with no mocks), so manual smoke testing is the validation approach.

  • Step 1: Start the dev server
bun run dev
  • Step 2: Navigate to any fixture page

Go to a fixture that has trends data (a Premier League match works best). Click the AI ANALYSIS button.

  • Step 3: Verify the streaming sections appear in order

Expected sequence in the panel:

  1. Spinner → "Gathering match data..."
  2. Match Context appears (should reference specific players/stats, not generic phrases)
  3. Top Pick card appears with a reason sentence
  4. Player Trends tabs appear (Builder / Singles / Longshots)
  5. Value Bets section appears (if data available)
  6. Verdict appears (should name a specific bet)
  7. Regenerate button appears
  • Step 4: Check server logs for digest output

In the terminal running bun run dev, look for [AI-ANALYSIS] Digest failed — this should NOT appear. If it does, check the model name and API key config.

  • Step 5: Verify SSE event order in network tab

Open browser DevTools → Network tab → find the /api/ai-analysis request → click it → EventStream tab. Verify events arrive in this order: matchContextbestPicktrends_* (any order) → verdictvalueBetsdone

Specifically: verdict must appear before valueBets. If they are reversed, the try block body was not fully replaced correctly.

  • Step 6: Verify no section is missing

If any section is missing (e.g. no verdict), check for data: {"section":"error",...} events in the EventStream tab.

  • Step 6: Quality check the match context

Read the match context text. It should:

  • Reference a specific player by name
  • Not contain any of the banned phrases ("counter-attacking prowess" etc.)
  • Connect a news fact to a stat (e.g. "X is suspended, which makes Y's trend at Z% more significant")

Task 5: Final commit and cleanup

  • Step 1: Check for any leftover debug logs
grep -n "console.log" src/pages/api/ai-analysis.ts

Remove any console.log statements added during development. console.error for the digest fallback is intentional — keep it.

  • Step 2: Final commit
git add src/pages/api/ai-analysis.ts
git commit -m "feat: complete ai-analysis agentic pipeline — digest-driven grounded analysis"

Summary of Changes

WhatBeforeAfter
Data gatheringParallel fetchUnchanged
AI reasoningSingle generateObject call with all dataPhase 2 digest produces structured internal reasoning first
matchContextGeneric form summaryGrounded in specific signals + news facts from digest
bestPick reasonChosen by hit rate aloneChosen + explained with stat AND match context connection
Trend curationMatch-unawareReceives digest, deprioritises injured/irrelevant players
verdictGeneric 2-3 sentencesNames specific bets, references match situation
tips-researchUnchanged
FrontendNo changes required

On this page