<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: patrickboxfordpartners</title>
    <description>The latest articles on DEV Community by patrickboxfordpartners (@patrickboxfordpartners).</description>
    <link>https://dev.to/patrickboxfordpartners</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4044846%2F33b8ed9f-e462-461c-a5c8-82c1a7a349d2.jpg</url>
      <title>DEV Community: patrickboxfordpartners</title>
      <link>https://dev.to/patrickboxfordpartners</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/patrickboxfordpartners"/>
    <language>en</language>
    <item>
      <title>Building a Real-Time AI Interview Analysis Pipeline with LiveKit, Deepgram, and Grok</title>
      <dc:creator>patrickboxfordpartners</dc:creator>
      <pubDate>Fri, 24 Jul 2026 05:16:38 +0000</pubDate>
      <link>https://dev.to/patrickboxfordpartners/building-a-real-time-ai-interview-analysis-pipeline-with-livekit-deepgram-and-grok-1oj2</link>
      <guid>https://dev.to/patrickboxfordpartners/building-a-real-time-ai-interview-analysis-pipeline-with-livekit-deepgram-and-grok-1oj2</guid>
      <description>&lt;p&gt;Most AI apps process data after the fact. You upload a file, wait, get a result. This post is about doing it the other way: scoring a live video interview as the candidate speaks, chunk by chunk, so the interviewer sees signals in real time rather than a report 20 minutes later.&lt;/p&gt;

&lt;p&gt;This is the architecture behind TrueVoice HQ (&lt;a href="https://truevoicehq.com" rel="noopener noreferrer"&gt;https://truevoicehq.com&lt;/a&gt;), an AI interview analysis platform. Here's how the real-time pipeline works.&lt;/p&gt;

&lt;p&gt;The Pipeline&lt;/p&gt;

&lt;p&gt;Candidate speaks&lt;br&gt;
       ↓&lt;br&gt;
LiveKit (WebRTC audio/video stream)&lt;br&gt;
       ↓&lt;br&gt;
Deepgram (real-time STT → transcript chunks)&lt;br&gt;
       ↓&lt;br&gt;
Supabase Edge Function: analyze-chunk (Grok scores each chunk live)&lt;br&gt;
       ↓&lt;br&gt;
Supabase Realtime (scores pushed to interviewer dashboard)&lt;br&gt;
       ↓&lt;br&gt;
Supabase Edge Function: generate-final-report (Grok synthesizes full report on end)&lt;/p&gt;

&lt;p&gt;Five services. One direction. No polling.&lt;/p&gt;

&lt;p&gt;Why Chunk-Based Analysis?&lt;/p&gt;

&lt;p&gt;The naive approach: record the whole interview, transcribe it, send the full transcript to an LLM, get scores back. This works fine for async review. It's useless for live interviews.&lt;/p&gt;

&lt;p&gt;Chunk-based analysis means the interviewer sees a live authenticity score updating every 30-60 seconds. By the time the interview ends, the final report is synthesizing data that's already been processed, not starting from scratch.&lt;/p&gt;

&lt;p&gt;Each chunk scores four dimensions (0-25 each, 100 total):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;speech: natural filler words, self-corrections, vocal variety vs. flat scripted cadence&lt;/li&gt;
&lt;li&gt;timing: measured response delays (instant = memorized, normal = thinking, very delayed = searching)&lt;/li&gt;
&lt;li&gt;flow: conversational back-and-forth vs. monologue delivery&lt;/li&gt;
&lt;li&gt;linguistic: spoken contractions and informal grammar vs. written/formal language&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The system also flags specific patterns: AI-generated opener phrases ("Certainly", "Absolutely", "That's a great question"), suspiciously precise statistics without citations, and company-specific phrases each organization has flagged from past interviews.&lt;/p&gt;

&lt;p&gt;The Edge Function: analyze-chunk&lt;/p&gt;

&lt;p&gt;Deployed on Supabase Edge Functions (Deno runtime). Called every time a new transcript chunk arrives from Deepgram.&lt;/p&gt;

&lt;p&gt;// Uses grok-3-fast -- non-reasoning model for lower latency on chunk analysis&lt;br&gt;
  const grokResponse = await fetch("&lt;a href="https://api.x.ai/v1/chat/completions" rel="noopener noreferrer"&gt;https://api.x.ai/v1/chat/completions&lt;/a&gt;", {&lt;br&gt;
    method: "POST",&lt;br&gt;
    headers: { Authorization: &lt;code&gt;Bearer ${xaiKey}&lt;/code&gt;, "Content-Type": "application/json" },&lt;br&gt;
    body: JSON.stringify({&lt;br&gt;
      model: "grok-3-fast",&lt;br&gt;
      messages: [&lt;br&gt;
        { role: "system", content: ANALYSIS_PROMPT },&lt;br&gt;
        {&lt;br&gt;
          role: "user",&lt;br&gt;
          content: [&lt;br&gt;
            &lt;code&gt;Elapsed time: ${elapsed_seconds}s&lt;/code&gt;,&lt;br&gt;
            &lt;code&gt;Response timing data: ${JSON.stringify(response_delays)}&lt;/code&gt;,&lt;br&gt;
            &lt;code&gt;Transcript chunk: "${chunk_text}"&lt;/code&gt;,&lt;br&gt;
          ].join("\n"),&lt;br&gt;
        },&lt;br&gt;
      ],&lt;br&gt;
      temperature: 0.3,&lt;br&gt;
    }),&lt;br&gt;
  });&lt;/p&gt;

&lt;p&gt;The response timing data (response_delays) is the interesting part. It's measured on the client side by tracking the gap between when the interviewer finishes a question and when the candidate starts speaking. That timestamp gets passed with each chunk so the model has real delay data, not just inference from speech patterns.&lt;/p&gt;

&lt;p&gt;Handling Reasoning Model Output in Production&lt;/p&gt;

&lt;p&gt;One problem you hit immediately when using LLMs in a structured data pipeline: the model sometimes wraps its JSON in markdown fences, adds explanation text, or (for reasoning models) prepends a ... block before the actual output.&lt;/p&gt;

&lt;p&gt;This utility handles all three cases:&lt;/p&gt;

&lt;p&gt;function extractJson(raw: string): string | null {&lt;br&gt;
    // Strip reasoning model thinking blocks&lt;br&gt;
    const stripped = raw.replace(/[\s\S]*?&amp;lt;\/think&amp;gt;/gi, "").trim();&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Find all {...} blocks, try from last to first
const matches = [...stripped.matchAll(/\{[\s\S]*?\}/g)];
for (let i = matches.length - 1; i &amp;gt;= 0; i--) {
  try {
    JSON.parse(matches[i][0]);
    return matches[i][0];
  } catch {
    continue;
  }
}

// Fall back to greedy match
const greedy = stripped.match(/\{[\s\S]*\}/);
return greedy ? greedy[0] : null;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;Why take the last match rather than the first? When a model produces explanation text before JSON, the last {...} block is the actual structured output. The earlier ones are fragments from the explanation. This pattern has held up across Grok, Claude, and GPT-4o.&lt;/p&gt;

&lt;p&gt;If parsing fails completely, fall back to neutral mid-range scores (15/25 each) rather than zeros. Zeros would make the interviewer dashboard look alarming. Neutral scores let the session continue without a false signal.&lt;/p&gt;

&lt;p&gt;LiveKit JWT Generation in Deno (No Library Required)&lt;/p&gt;

&lt;p&gt;LiveKit access tokens are signed JWTs. In Node.js you'd use the livekit-server-sdk. In Deno edge functions, npm libraries aren't always available cleanly, but the Web Crypto API is. Here's a&lt;br&gt;
pure-Deno JWT generator:&lt;/p&gt;

&lt;p&gt;async function generateLiveKitToken(&lt;br&gt;
    apiKey: string,&lt;br&gt;
    apiSecret: string,&lt;br&gt;
    roomName: string,&lt;br&gt;
    participantIdentity: string,&lt;br&gt;
    participantName: string,&lt;br&gt;
    isHost: boolean = false&lt;br&gt;
  ): Promise {&lt;br&gt;
    const now = Math.floor(Date.now() / 1000);&lt;br&gt;
    const payload = {&lt;br&gt;
      iss: apiKey,&lt;br&gt;
      sub: participantIdentity,&lt;br&gt;
      exp: now + 86400,&lt;br&gt;
      nbf: now,&lt;br&gt;
      name: participantName,&lt;br&gt;
      video: {&lt;br&gt;
        room: roomName,&lt;br&gt;
        roomJoin: true,&lt;br&gt;
        canPublish: true,&lt;br&gt;
        canSubscribe: true,&lt;br&gt;
        canPublishData: true,&lt;br&gt;
        ...(isHost &amp;amp;&amp;amp; { roomAdmin: true, roomCreate: true, roomRecord: true }),&lt;br&gt;
      },&lt;br&gt;
    };&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const encoder = new TextEncoder();
const headerB64 = toBase64Url(encoder.encode(JSON.stringify({ alg: "HS256", typ: "JWT" })));
const payloadB64 = toBase64Url(encoder.encode(JSON.stringify(payload)));
const signingInput = `${headerB64}.${payloadB64}`;

const key = await crypto.subtle.importKey(
  "raw",
  encoder.encode(apiSecret),
  { name: "HMAC", hash: "SHA-256" },
  false,
  ["sign"]
);

const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(signingInput));
return `${signingInput}.${toBase64Url(new Uint8Array(signature))}`;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
          role: "user",&lt;br&gt;
          content: [&lt;br&gt;
            &lt;code&gt;Elapsed time: ${elapsed_seconds}s&lt;/code&gt;,&lt;br&gt;
            &lt;code&gt;Response timing data: ${JSON.stringify(response_delays)}&lt;/code&gt;,&lt;br&gt;
            &lt;code&gt;Transcript chunk: "${chunk_text}"&lt;/code&gt;,&lt;br&gt;
          ].join("\n"),&lt;br&gt;
        },&lt;br&gt;
      ],&lt;br&gt;
      temperature: 0.3,&lt;br&gt;
    }),&lt;br&gt;
  });&lt;/p&gt;

&lt;p&gt;The response timing data (response_delays) is the interesting part. It's measured on the client side by tracking the gap between when the interviewer finishes a question and when the candidate starts speaking. That timestamp gets passed with each chunk so the model has real delay data, not just inference from speech patterns.&lt;/p&gt;

&lt;p&gt;Handling Reasoning Model Output in Production&lt;/p&gt;

&lt;p&gt;One problem you hit immediately when using LLMs in a structured data pipeline: the model sometimes wraps its JSON in markdown fences, adds explanation text, or (for reasoning models) prepends a ... block before the actual output.&lt;/p&gt;

&lt;p&gt;This utility handles all three cases:&lt;/p&gt;

&lt;p&gt;function extractJson(raw: string): string | null {&lt;br&gt;
    // Strip reasoning model thinking blocks&lt;br&gt;
    const stripped = raw.replace(/[\s\S]*?&amp;lt;\/think&amp;gt;/gi, "").trim();&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Find all {...} blocks, try from last to first
const matches = [...stripped.matchAll(/\{[\s\S]*?\}/g)];
for (let i = matches.length - 1; i &amp;gt;= 0; i--) {
  try {
    JSON.parse(matches[i][0]);
    return matches[i][0];
  } catch {
    continue;
  }
}

// Fall back to greedy match
const greedy = stripped.match(/\{[\s\S]*\}/);
return greedy ? greedy[0] : null;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;Why take the last match rather than the first? When a model produces explanation text before JSON, the last {...} block is the actual structured output. The earlier ones are fragments from the explanation. This pattern has held up across Grok, Claude, and GPT-4o.&lt;/p&gt;

&lt;p&gt;If parsing fails completely, fall back to neutral mid-range scores (15/25 each) rather than zeros. Zeros would make the interviewer dashboard look alarming. Neutral scores let the session continue without a false signal.&lt;/p&gt;

&lt;p&gt;LiveKit JWT Generation in Deno (No Library Required)&lt;/p&gt;

&lt;p&gt;LiveKit access tokens are signed JWTs. In Node.js you'd use the livekit-server-sdk. In Deno edge functions, npm libraries aren't always available cleanly, but the Web Crypto API is. Here's a&lt;br&gt;
pure-Deno JWT generator:&lt;/p&gt;

&lt;p&gt;async function generateLiveKitToken( &lt;br&gt;
    apiKey: string,&lt;br&gt;
    apiSecret: string,&lt;br&gt;&lt;br&gt;
    roomName: string,&lt;br&gt;
    participantIdentity: string,&lt;br&gt;
    participantName: string,&lt;br&gt;
    isHost: boolean = false &lt;br&gt;
  ): Promise {&lt;br&gt;
    const now = Math.floor(Date.now() / 1000);&lt;br&gt;&lt;br&gt;
    const payload = {&lt;br&gt;
      iss: apiKey,&lt;br&gt;
      sub: participantIdentity,&lt;br&gt;
      exp: now + 86400,&lt;br&gt;
      nbf: now,&lt;br&gt;
      name: participantName,&lt;br&gt;
      video: {&lt;br&gt;
        room: roomName,&lt;br&gt;
        roomJoin: true,&lt;br&gt;
        canPublish: true,&lt;br&gt;
        canSubscribe: true,&lt;br&gt;
        canPublishData: true,&lt;br&gt;
        ...(isHost &amp;amp;&amp;amp; { roomAdmin: true, roomCreate: true, roomRecord: true }),&lt;br&gt;
      },&lt;br&gt;
    };&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const encoder = new TextEncoder();
const headerB64 = toBase64Url(encoder.encode(JSON.stringify({ alg: "HS256", typ: "JWT" })));
const payloadB64 = toBase64Url(encoder.encode(JSON.stringify(payload)));
const signingInput = `${headerB64}.${payloadB64}`;

const key = await crypto.subtle.importKey(
  "raw",
  encoder.encode(apiSecret),
  { name: "HMAC", hash: "SHA-256" },
  false,
  ["sign"]
);

const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(signingInput));
return `${signingInput}.${toBase64Url(new Uint8Array(signature))}`;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;function toBase64Url(data: Uint8Array): string {&lt;br&gt;
    return btoa(String.fromCharCode(...data))&lt;br&gt;
      .replace(/=/g, "")&lt;br&gt;
      .replace(/+/g, "-")&lt;br&gt;
      .replace(/\//g, "_");&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;crypto.subtle is available globally in Deno, btoa handles the base64 encoding, and the URL-safe replacement (+ to -, / to _, strip =) is required by the JWT spec.&lt;/p&gt;

&lt;p&gt;Final Report: Synthesis, Not Re-Analysis&lt;/p&gt;

&lt;p&gt;When the interview ends, generate-final-report doesn't re-process the transcript from scratch. It synthesizes from the chunk data already stored:&lt;/p&gt;

&lt;p&gt;const [chunksRes, interviewRes, flagsRes] = await Promise.all([&lt;br&gt;
    supabase.from("transcript_chunks").select("&lt;em&gt;").eq("interview_id", interview_id).order("chunk_index"),&lt;br&gt;
    supabase.from("interviews").select("&lt;/em&gt;").eq("interview_id", interview_id).single(),&lt;br&gt;
    supabase.from("interview_flags").select("*").eq("interview_id", interview_id),&lt;br&gt;
  ]);&lt;/p&gt;

&lt;p&gt;The model gets the chunk-level score history, the flag log with timestamps, and the full transcript. This produces a more accurate final report than sending the raw transcript alone. The chunk scores act as a ground truth the model can't contradict.&lt;/p&gt;

&lt;p&gt;Two post-report tasks run fire-and-forget (non-blocking):&lt;/p&gt;

&lt;p&gt;if (interview.resume_text) {&lt;br&gt;
    fetch(&lt;code&gt;${supabaseUrl}/functions/v1/analyze-resume&lt;/code&gt;, { ... }).catch(() =&amp;gt; {});&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;if (interview.candidate_email) {&lt;br&gt;
    fetch(&lt;code&gt;${supabaseUrl}/functions/v1/send-interview-email&lt;/code&gt;, { ... }).catch(() =&amp;gt; {});&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;The .catch(() =&amp;gt; {}) is intentional. These are nice-to-have enrichments. If they fail, the report is already saved. Don't let optional steps block the critical path.&lt;/p&gt;

&lt;p&gt;What I'd Do Differently&lt;/p&gt;

&lt;p&gt;Move the Deepgram connection server-side. The current architecture has the client receiving Deepgram transcripts and forwarding chunks to the edge function. Moving it server-side would eliminate one round trip and reduce the chance of a client network hiccup dropping a chunk.&lt;/p&gt;

&lt;p&gt;Chunk overlap. Each chunk is currently independent. A candidate who trails off at the end of one chunk and picks up mid-thought in the next looks like two separate utterances. Adding the last 2-3 sentences of the previous chunk as context would smooth out scoring at boundaries.&lt;/p&gt;

&lt;p&gt;Supabase Realtime subscriptions instead of polling. Scores get written to the database and the interviewer dashboard polls. Switching to Realtime would push score updates instantly.&lt;/p&gt;

&lt;p&gt;Stack Summary&lt;/p&gt;

&lt;p&gt;┌───────────────────────┬───────────────────────────┐&lt;br&gt;
  │         Layer         │          Service          │&lt;br&gt;
  ├───────────────────────┼───────────────────────────┤&lt;br&gt;
  │ Video/audio streaming │ LiveKit Cloud             │&lt;br&gt;
  ├───────────────────────┼───────────────────────────┤&lt;br&gt;
  │ Speech-to-text        │ Deepgram Nova-2           │&lt;br&gt;
  ├───────────────────────┼───────────────────────────┤&lt;br&gt;
  │ AI analysis           │ xAI Grok 3 Fast           │&lt;br&gt;
  ├───────────────────────┼───────────────────────────┤&lt;br&gt;
  │ Edge functions        │ Supabase (Deno)           │&lt;br&gt;
  ├───────────────────────┼───────────────────────────┤&lt;br&gt;
  │ Database + auth       │ Supabase (PostgreSQL)     │&lt;br&gt;
  ├───────────────────────┼───────────────────────────┤&lt;br&gt;
  │ Frontend              │ React + Vite + TypeScript │&lt;br&gt;
  ├───────────────────────┼───────────────────────────┤&lt;br&gt;
  │ Deployment            │ Vercel                    │&lt;br&gt;
  └───────────────────────┴───────────────────────────┘&lt;/p&gt;

&lt;p&gt;Source: &lt;a href="https://github.com/patrickboxfordpartners/truevoice" rel="noopener noreferrer"&gt;https://github.com/patrickboxfordpartners/truevoice&lt;/a&gt; · Live: &lt;a href="https://truevoicehq.com" rel="noopener noreferrer"&gt;https://truevoicehq.com&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;Patrick Mitchell is the founder of Boxford Partners (&lt;a href="https://boxfordpartners.com" rel="noopener noreferrer"&gt;https://boxfordpartners.com&lt;/a&gt;), a product studio that has shipped 8 companies across AI, SaaS, voice, and fintech.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>llm</category>
      <category>streaming</category>
    </item>
  </channel>
</rss>
