Add call transcript analysis via Grok (xAI)#430
Conversation
When a call session transitions to 'completed' with a non-empty transcript, a Supabase Database Webhook (pg_net trigger) POSTs the session id to a new Next.js route handler, which calls Grok (xAI) via the AI SDK to generate a structured analysis (title, summary, key points, sentiment, action items) and stores it in a new call_sessions.analysis JSONB column. - migration: add analysis/analysis_status/analysis_generated_at columns, partial index, pg_net webhook trigger gated on the completed transition - lib/ai/summarize-call.ts: defensive transcript normalizer + generateObject - app/api/call-sessions/analyze: authenticated, idempotent handler - types, .env.example, @ai-sdk/xai dependency, unit tests Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NcdZdGkUc2UWCsg2gx32rg
…ipts Pivot from the simple single-column summary to the richer, table-based analysis shape established on feat/scripts-call-analysis. - migration: replace the analysis JSONB columns with call_session_analysis (one rich row per call: language, topic, engagement, sentiment, key requests, etc.) and call_session_analytics (aggregate per run); keep the pg_net webhook trigger on the completed transition (transcript-shape safe) - lib/ai/analyze-call.ts: shared transcript extraction + Grok generateObject with the rich schema, mapped onto the table columns - route /api/call-sessions/analyze: refactored to insert call_session_analysis, idempotent (skip if a row exists / too short / not completed) - scripts/analyze-call-sessions.mjs (recent/daily-cron) and scripts/backfill-call-analysis.mjs (historical backfill, batch pattern), wired into scripts + root package.json - types, tests updated; drop the old summarize-call lib/test Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NcdZdGkUc2UWCsg2gx32rg
|
|
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Claude finished @gianpaj's task in 2m 55s —— View job PR Review: Call transcript analysis via Grok (xAI)Nice, well-documented PR — the three-path design (webhook / recent-cron / backfill), idempotency guards, fire-and-forget webhook, and 🔴 Correctness / consistency
🟠 Security / privacy
🟡 Minor
✅ Looks good
None of these are blockers for the happy path, but #1–#3 undermine the retry/backfill guarantees the PR describes and are worth addressing before merge. Happy to help with any of the fixes. |
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
There was a problem hiding this comment.
Code Review
This pull request introduces automated call transcript analysis using xAI's Grok model, adding a Next.js API route, database migrations for analysis tables, a webhook trigger, and CLI scripts for running recent analyses and backfills. The review feedback highlights several critical areas for improvement: a potential security bypass in the API route if the shared secret is undefined, database query inefficiencies in the scripts due to in-memory filtering instead of using LEFT JOINs, a missing UNIQUE constraint on session_id in the analysis table, inadequate error handling in the API route, and inefficient/unstable date sorting inside .sort() comparators.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| const authHeader = request.headers.get('authorization'); | ||
| if (authHeader !== `Bearer ${process.env.CALL_SUMMARY_SECRET}`) { | ||
| return new NextResponse('Unauthorized', { status: 401 }); | ||
| } |
There was a problem hiding this comment.
If process.env.CALL_SUMMARY_SECRET is not set or is empty (e.g., in a development or misconfigured environment), process.env.CALL_SUMMARY_SECRET will be undefined. This allows any request with an Authorization: Bearer undefined header to bypass the authentication check. Ensure that the secret is defined and not empty before performing the comparison.
| const authHeader = request.headers.get('authorization'); | |
| if (authHeader !== `Bearer ${process.env.CALL_SUMMARY_SECRET}`) { | |
| return new NextResponse('Unauthorized', { status: 401 }); | |
| } | |
| const authHeader = request.headers.get('authorization'); | |
| const secret = process.env.CALL_SUMMARY_SECRET; | |
| if (!secret || authHeader !== `Bearer ${secret}`) { | |
| return new NextResponse('Unauthorized', { status: 401 }); | |
| } |
| async function getRecentCallSessions(supabase, hoursAgo, limit = null) { | ||
| const cutoffTime = new Date( | ||
| Date.now() - hoursAgo * 60 * 60 * 1000, | ||
| ).toISOString(); | ||
|
|
||
| let query = supabase | ||
| .from('call_sessions') | ||
| .select(SESSION_COLUMNS) | ||
| .gte('started_at', cutoffTime) | ||
| .eq('status', 'completed') | ||
| .not('transcript', 'is', null) | ||
| .gte('duration_seconds', MIN_ANALYSIS_CALL_DURATION_SECONDS) | ||
| .order('started_at', { ascending: false }); | ||
|
|
||
| if (limit) { | ||
| query = query.limit(limit); | ||
| } | ||
|
|
||
| const { data, error } = await query; | ||
| if (error) { | ||
| throw new Error(`Error fetching call sessions: ${error.message}`); | ||
| } | ||
|
|
||
| return filterAnalyzedSessions(supabase, data || []); | ||
| } |
There was a problem hiding this comment.
Fetching all recent completed sessions and then filtering them in memory by making chunked database queries to call_session_analysis is highly inefficient. As the database grows, this will cause significant performance degradation. Instead, perform a LEFT JOIN with an IS NULL check directly in the Supabase query to fetch only the unanalyzed sessions in a single database round-trip.
async function getRecentCallSessions(supabase, hoursAgo, limit = null) {
const cutoffTime = new Date(
Date.now() - hoursAgo * 60 * 60 * 1000,
).toISOString();
let query = supabase
.from('call_sessions')
.select(`${SESSION_COLUMNS}, call_session_analysis!left(id)`)
.is('call_session_analysis', null)
.gte('started_at', cutoffTime)
.eq('status', 'completed')
.not('transcript', 'is', null)
.gte('duration_seconds', MIN_ANALYSIS_CALL_DURATION_SECONDS)
.order('started_at', { ascending: false });
if (limit) {
query = query.limit(limit);
}
const { data, error } = await query;
if (error) {
throw new Error(`Error fetching call sessions: ${error.message}`);
}
return data || [];
}| export async function getAllCompletedCallSessions(supabase, options = {}) { | ||
| const { minDuration = MIN_ANALYSIS_CALL_DURATION_SECONDS, models = [] } = | ||
| options; | ||
| const rows = []; | ||
| let from = 0; | ||
|
|
||
| while (true) { | ||
| let query = supabase | ||
| .from('call_sessions') | ||
| .select(SESSION_COLUMNS) | ||
| .eq('status', 'completed') | ||
| .not('transcript', 'is', null) | ||
| .gte('duration_seconds', minDuration) | ||
| .order('started_at', { ascending: false }) | ||
| .range(from, from + DB_FETCH_PAGE_SIZE - 1); | ||
|
|
||
| if (models.length > 0) { | ||
| query = query.in('model', models); | ||
| } | ||
|
|
||
| const { data, error } = await query; | ||
| if (error) { | ||
| throw new Error(`Error fetching call sessions: ${error.message}`); | ||
| } | ||
| if (!data || data.length === 0) { | ||
| break; | ||
| } | ||
|
|
||
| rows.push(...data); | ||
| if (data.length < DB_FETCH_PAGE_SIZE) { | ||
| break; | ||
| } | ||
| from += DB_FETCH_PAGE_SIZE; | ||
| } | ||
|
|
||
| return filterAnalyzedSessions(supabase, rows); | ||
| } |
There was a problem hiding this comment.
Fetching all completed sessions in pages of 1000 and then filtering them in memory by querying call_session_analysis in chunks of 200 will result in a massive number of database queries and high memory usage on large datasets. Optimize this by performing a LEFT JOIN with an IS NULL check directly in the database query to fetch only the unanalyzed sessions.
export async function getAllCompletedCallSessions(supabase, options = {}) {
const { minDuration = MIN_ANALYSIS_CALL_DURATION_SECONDS, models = [] } =
options;
const rows = [];
let from = 0;
while (true) {
let query = supabase
.from('call_sessions')
.select(`${SESSION_COLUMNS}, call_session_analysis!left(id)`)
.is('call_session_analysis', null)
.eq('status', 'completed')
.not('transcript', 'is', null)
.gte('duration_seconds', minDuration)
.order('started_at', { ascending: false })
.range(from, from + DB_FETCH_PAGE_SIZE - 1);
if (models.length > 0) {
query = query.in('model', models);
}
const { data, error } = await query;
if (error) {
throw new Error(`Error fetching call sessions: ${error.message}`);
}
if (!data || data.length === 0) {
break;
}
rows.push(...data);
if (data.length < DB_FETCH_PAGE_SIZE) {
break;
}
from += DB_FETCH_PAGE_SIZE;
}
return rows;
}| create table if not exists public.call_session_analysis ( | ||
| id uuid primary key default gen_random_uuid(), | ||
| session_id uuid not null references public.call_sessions(id) on delete cascade, |
There was a problem hiding this comment.
The call_session_analysis table is designed to have a 1-to-1 relationship with call_sessions ("one rich row per analysed call"). However, there is no UNIQUE constraint on session_id. Without a unique constraint, concurrent webhook invocations or manual script runs could insert duplicate analysis rows for the same session. Add a unique constraint to enforce data integrity.
create table if not exists public.call_session_analysis (
id uuid primary key default gen_random_uuid(),
session_id uuid not null unique references public.call_sessions(id) on delete cascade,| if (selectError || !session) { | ||
| return NextResponse.json( | ||
| { error: 'Call session not found' }, | ||
| { status: 404 }, | ||
| ); | ||
| } |
There was a problem hiding this comment.
If a database error occurs during the query (e.g., connection timeout or permission issue), selectError will be populated. Treating this as a 404 Not Found is misleading and makes debugging difficult. It is better to log the error and return a 500 Internal Server Error when selectError is present.
if (selectError) {
console.error('Error fetching call session:', selectError);
return NextResponse.json(
{ error: 'Failed to fetch call session' },
{ status: 500 },
);
}
if (!session) {
return NextResponse.json(
{ error: 'Call session not found' },
{ status: 404 },
);
}| const { count: existing } = await supabase | ||
| .from('call_session_analysis') | ||
| .select('id', { count: 'exact', head: true }) | ||
| .eq('session_id', id); | ||
|
|
||
| if ((existing ?? 0) > 0) { | ||
| return NextResponse.json({ skipped: true }); | ||
| } |
There was a problem hiding this comment.
If the query to check for an existing analysis fails, countError is ignored and existing will be null. This causes the code to proceed to call the LLM and attempt an insert, which is wasteful and could fail. Properly handle the query error by logging it and returning a 500 status.
const { count: existing, error: countError } = await supabase
.from('call_session_analysis')
.select('id', { count: 'exact', head: true })
.eq('session_id', id);
if (countError) {
console.error('Error checking existing analysis:', countError);
return NextResponse.json(
{ error: 'Failed to check existing analysis' },
{ status: 500 },
);
}
if ((existing ?? 0) > 0) {
return NextResponse.json({ skipped: true });
}| return [...normalizedAssistant, ...normalizedUser].sort((a, b) => { | ||
| const timeA = a.timestamp ? new Date(a.timestamp).getTime() : 0; | ||
| const timeB = b.timestamp ? new Date(b.timestamp).getTime() : 0; | ||
| return timeA - timeB; | ||
| }); |
There was a problem hiding this comment.
Parsing date strings using new Date() inside a .sort() comparator is inefficient because it instantiates new objects repeatedly. Furthermore, if any timestamp is invalid or malformed, new Date().getTime() returns NaN, which leads to unstable and incorrect sorting behavior in JavaScript. Use Date.parse and handle NaN values safely.
return [...normalizedAssistant, ...normalizedUser].sort((a, b) => {
const timeA = a.timestamp ? Date.parse(a.timestamp) : 0;
const timeB = b.timestamp ? Date.parse(b.timestamp) : 0;
const scoreA = Number.isNaN(timeA) ? 0 : timeA;
const scoreB = Number.isNaN(timeB) ? 0 : timeB;
return scoreA - scoreB;
});| return [...normalizedAssistant, ...normalizedUser].sort((a, b) => { | ||
| const timeA = a.timestamp ? new Date(a.timestamp).getTime() : 0; | ||
| const timeB = b.timestamp ? new Date(b.timestamp).getTime() : 0; | ||
| return timeA - timeB; | ||
| }); |
There was a problem hiding this comment.
Parsing date strings using new Date() inside a .sort() comparator is inefficient because it instantiates new objects repeatedly. Furthermore, if any timestamp is invalid or malformed, new Date().getTime() returns NaN, which leads to unstable and incorrect sorting behavior in JavaScript. Use Date.parse and handle NaN values safely.
return [...normalizedAssistant, ...normalizedUser].sort((a, b) => {
const timeA = a.timestamp ? Date.parse(a.timestamp) : 0;
const timeB = b.timestamp ? Date.parse(b.timestamp) : 0;
const scoreA = Number.isNaN(timeA) ? 0 : timeA;
const scoreB = Number.isNaN(timeB) ? 0 : timeB;
return scoreA - scoreB;
});| // CRON_SECRET pattern used by /api/daily-stats, but a distinct secret so the | ||
| // webhook isn't conflated with the cron. | ||
| const authHeader = request.headers.get('authorization'); | ||
| if (authHeader !== `Bearer ${process.env.CALL_SUMMARY_SECRET}`) { |
There was a problem hiding this comment.
Guard against an unset secret. If CALL_SUMMARY_SECRET is undefined, this compares the incoming header against the literal string "Bearer undefined", which an attacker can trivially send — turning a misconfiguration into an open, unauthenticated endpoint that triggers paid Grok calls and DB writes.
const secret = process.env.CALL_SUMMARY_SECRET;
if (!secret) {
Sentry.captureMessage('CALL_SUMMARY_SECRET not configured');
return new NextResponse('Server misconfigured', { status: 500 });
}
if (authHeader !== `Bearer ${secret}`) {
return new NextResponse('Unauthorized', { status: 401 });
}A constant-time comparison (crypto.timingSafeEqual) would also be a nice hardening here.
| const { count: existing } = await supabase | ||
| .from('call_session_analysis') | ||
| .select('id', { count: 'exact', head: true }) | ||
| .eq('session_id', id); | ||
|
|
||
| if ((existing ?? 0) > 0) { | ||
| return NextResponse.json({ skipped: true }); | ||
| } | ||
|
|
||
| try { | ||
| const analysis = await analyzeTranscript(session); | ||
|
|
||
| const { error: insertError } = await supabase | ||
| .from('call_session_analysis') | ||
| .insert(toAnalysisRow(session, analysis)); |
There was a problem hiding this comment.
Race condition / duplicate rows. This read-then-insert isn't atomic, and there's no unique constraint on call_session_analysis.session_id. Two webhook deliveries (Supabase retries, or a webhook firing while the backfill script runs) can both pass the "row exists" check and both insert — producing duplicate analysis rows for one session.
Recommend adding a unique index on session_id in the migration and relying on upsert/onConflict (or catching the unique-violation) instead of a separate count query. See the migration comment for the index suggestion.
| export async function saveAllSessionAnalyses(supabase, results) { | ||
| let successCount = 0; | ||
| let errorCount = 0; | ||
|
|
||
| for (const result of results) { | ||
| const { error } = await supabase | ||
| .from('call_session_analysis') | ||
| .insert(buildAnalysisRecord(result)); |
There was a problem hiding this comment.
Errored sessions get a row that permanently blocks reprocessing. buildAnalysisRecord always builds an insert row even when result.error is set (no analysis), and saveAllSessionAnalyses inserts it. But filterAnalyzedSessions excludes any session that has a row in call_session_analysis. So a session that errored once can never be picked up again by the recent-cron or backfill scripts.
This directly contradicts:
- the webhook path, which deliberately does not write a row on failure so retries/backfill can reprocess (see
route.tslines 95-97), and - the PR description's claim that backfill "reprocesses any sessions that still lack an analysis row."
Consider skipping the insert when result.error is set, or have filterAnalyzedSessions only treat rows with error is null as "analyzed".
| let analysis; | ||
| try { | ||
| analysis = parseLlmJson(responseText); | ||
| } catch (parseError) { | ||
| analysis = { | ||
| raw_response: responseText, | ||
| parse_error: parseError.message, | ||
| }; | ||
| } |
There was a problem hiding this comment.
A JSON parse failure produces analysis = { raw_response, parse_error } with no top-level result.error. Downstream this means:
aggregateInsightscounts it as a valid analysis (r.analysis && !r.error), inflating success metrics.buildAnalysisRecordwrites a row with all analysis columnsnullanderror: null— i.e. a "successful" empty row that then blocks reprocessing (see filter issue above).
Set result.error (e.g. error: \parse failed: ${parseError.message}`) instead of stashing it inside analysis`, so it's treated as a failure consistently.
| create index if not exists idx_call_session_analysis_session_id | ||
| on public.call_session_analysis (session_id); |
There was a problem hiding this comment.
Consider a unique index here to make idempotency enforceable at the DB level and close the webhook race (see route.ts):
create unique index if not exists idx_call_session_analysis_session_id
on public.call_session_analysis (session_id);This only works cleanly if you stop writing error rows for the same session (see the script comments) — otherwise an error row and a later success row would collide. If you want to keep both, a partial unique index where error is null is the alternative.
| experimental_telemetry: { | ||
| isEnabled: true, | ||
| recordInputs: true, | ||
| recordOutputs: true, | ||
| }, |
There was a problem hiding this comment.
recordInputs: true / recordOutputs: true will write the full transcript into telemetry spans (and anything that ingests them). Given the explicitly intimate/adult nature of these conversations, this is a privacy exposure worth a second look — consider recordInputs: false (or redaction) unless the traces are tightly access-controlled.
| export const MIN_ANALYSIS_CALL_DURATION_SECONDS = 120; | ||
|
|
||
| // Confirm the exact current Grok model id against xAI docs; override via env. | ||
| const DEFAULT_MODEL = 'grok-4'; |
There was a problem hiding this comment.
Minor: the code default is grok-4, but .env.example sets XAI_SUMMARY_MODEL=grok-4-fast-non-reasoning. Worth aligning so the documented default matches the code default (and confirm the chosen model supports the structured-output / generateObject path reliably).
|
The latest updates on your projects. Learn more about Argos notifications ↗︎
|
There was a problem hiding this comment.
Important
Solid, well-structured feature, but a few things warrant a look before merge: verbatim call transcripts are shipped to Sentry, the idempotency guard has no DB-level uniqueness backing it, and the default Grok model id is unverified (and diverges from .env.example).
Reviewed changes — initial review of the full PR adding Grok-based call transcript analysis across a webhook route, two scripts, and a new schema.
- New webhook route (
app/api/call-sessions/analyze/route.ts) — bearer-secret auth, session lookup, idempotency guards, thenanalyzeTranscript+ insert. - Core analysis lib (
lib/ai/analyze-call.ts) —extractMessages,buildConversationSummary,analyzeTranscript(generateObject+ Zod),toAnalysisRow. - Scripts —
analyze-call-sessions.mjs(recent/cron) andbackfill-call-analysis.mjs, sharing extraction logic but usinggenerateText+ manual JSON parse. - Migration —
call_session_analysis+call_session_analyticstables, indexes, RLS (enabled, no policies), and asecurity definertrigger firing apg_netPOST onstatus → completed. - Config —
@ai-sdk/xaidependency, new env vars, package scripts, unit tests.
ℹ️ Webhook and script paths can produce non-identical rows despite the "identical rows" claim
The PR notes that the webhook, recent-cron, and backfill paths share prompt/schema/extraction "to ensure consistency", but only the extraction helpers are actually shared. The route uses generateObject with a Zod schema and a system prompt; the scripts use generateText with a JSON-in-prompt instruction, no system message, and a manual parser. The resulting rows can diverge in practice.
- Enum enforcement — the Zod schema rejects/coerces off-schema
topic_categoryvalues; the script stores whatever string the model returns. - Null/falsy coercion —
buildAnalysisRecorduses|| null/|| [](soduration_seconds: 0, empty-stringwhere_conversation_died, andnullkey_user_requestsare coerced), whiletoAnalysisRowuses?? nulland passes schema output through unchanged.
Technical details
# Webhook vs script row divergence
## Affected sites
- `apps/web/lib/ai/analyze-call.ts` — `analyzeTranscript` uses `generateObject` + Zod + `system` prompt; `toAnalysisRow` uses `?? null`.
- `scripts/analyze-call-sessions.mjs` — `analyzeCallSessionsWithLLM` uses `generateText` + `buildPrompt` (no system message) + `parseLlmJson`; `buildAnalysisRecord` uses `|| null` / `|| []`.
## Required outcome
- Either make the two paths genuinely equivalent (shared system prompt + shared null-handling), or soften the "identical rows" claim in the PR/code comments so downstream consumers don't assume column-level parity between webhook-written and script-written rows.
## Open questions for the human
- Is exact row parity a real requirement (e.g. analytics joins assume it), or is "same schema, best-effort same semantics" acceptable? That decides whether this needs a fix or just a doc correction.ℹ️ Nitpicks
extractMessages/extractMessagessort comparator (analyze-call.ts:163,analyze-call-sessions.mjs:344): a non-null but unparseable timestamp yieldsNaNfromgetTime(), which thets ? … : 0ternary doesn't guard —NaNcomparisons give an undefined sort order.new Date(ts).getTime() || 0would coerceNaNto0.
Claude Opus | 𝕏
…privacy) - route: guard unset CALL_SUMMARY_SECRET (500), split selectError (500) from not-found (404) via maybeSingle, handle countError, upsert onConflict session_id to close the read-then-insert race - migration: UNIQUE(session_id) on call_session_analysis (DB-level idempotency); drop now-redundant plain index - analyze-call lib: disable AI-SDK telemetry for the analysis call so verbatim transcripts are never shipped to Sentry; safe epoch sort (Date.parse + NaN) - scripts: PostgREST !left/is-null anti-join instead of in-memory filtering; don't persist rows for failed/parse-failed analyses (keeps them retryable); treat parse failures as errors not fake successes; upsert on save; safe sort - align default Grok model to grok-4 across code and .env.example Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NcdZdGkUc2UWCsg2gx32rg
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — incremental re-review of commit 62fdcac, which addresses all five inline findings from the prior pullfrog review.
- Auth guard for unset secret —
route.tsnow returns500whenCALL_SUMMARY_SECRETis unset, before the bearer comparison, closing theBearer undefinedbypass. - DB-level idempotency — migration adds
uniqueoncall_session_analysis.session_id(redundant plain index dropped), and the route plus both script save paths switched toupsert(..., { onConflict: 'session_id', ignoreDuplicates: true }). - Sentry transcript leak fixed —
analyzeTranscriptsetsexperimental_telemetrytoisEnabled/recordInputs/recordOutputs: false, so verbatim transcripts no longer reach the Sentry AI-SDK span. - Parse-failure handling — the recent-cron script now records
result.errorand skips the insert on unparseable LLM output, andsaveAllSessionAnalysesskips failed analyses (newskippedCount) so sessions stay retryable. - Anti-join replaces in-memory filtering —
filterAnalyzedSessionsremoved in favor of a PostgRESTcall_session_analysis!left(id)+.is(..., null)anti-join across both fetch paths. - Stable timestamp sort — shared
toEpoch()helper coerces missing/unparseable timestamps to0, fixing theNaNsort instability;.env.examplerealigned to thegrok-4default.
The anti-join migration is the most substantive change; it's sound because the backfill fetch loop completes before any inserts run, so .range() pagination over the server-filtered set stays stable.
The one prior point not carried into commits is the informational "webhook vs script can produce non-identical rows" observation (the route uses generateObject+Zod+system prompt; scripts use generateText+manual parse with || coercion). That remains a documentation/scope decision for you — no action required to merge.
Claude Opus | 𝕏
Per maintainer decision, use the current xAI flagship (grok-4.3) as the default analysis model across the lib, scripts, and .env.example. It supports structured outputs (generateObject); bare `grok-4` was not a listed current id. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NcdZdGkUc2UWCsg2gx32rg
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — incremental re-review of commit 94eea55, which bumps the default Grok model from grok-4 to grok-4.3.
- Default model bump —
DEFAULT_MODELupdated togrok-4.3in bothapps/web/lib/ai/analyze-call.tsandscripts/analyze-call-sessions.mjs, with.env.exampleand the JSDoc comments realigned so all four sites agree. - Comment clarified — the prior "confirm the exact current Grok model id against xAI docs" note is replaced with "xAI flagship; supports structured outputs (
generateObject)".
This closes the last open thread from the initial review (the model id was previously flagged as unverified). grok-4.3 is a current xAI model (released 2026-04-30) that supports structured outputs via the native xAI API, so the webhook's generateObject path is sound. The change is applied consistently across every site, so no new divergence is introduced.
Claude Opus | 𝕏

Summary
Adds automatic analysis of call transcripts using xAI's Grok model to extract structured insights (language, topic, engagement, sentiment, etc.). Includes a webhook-triggered API route for real-time analysis when calls complete, a daily cron script for recent calls, and a backfill script for historical sessions. All three paths write identical rows to a new
call_session_analysistable.Changes
call_session_analysis(per-call analysis) andcall_session_analytics(aggregate insights) tables with indexes and RLS policies. Includes a Postgres trigger that enqueues analysis via webhook when calls complete./api/call-sessions/analyze): Accepts webhook POST from Supabase, validates the session, callsanalyzeTranscript(), and inserts the result. Includes idempotency guards and error handling.lib/ai/analyze-call.ts): ExportsanalyzeTranscript(),extractMessages(),buildConversationSummary(), andtoAnalysisRow(). Uses Zod schema for structured LLM output. Handles variable transcript shapes (bare arrays,{ messages },{ user_transcriptions }).scripts/analyze-call-sessions.mjs: Analyzes recent calls (default 24h) in batches, writes CSV + insights JSON, saves to DB. Supports--dry-run,--hours,--limit,--debug,--smoke-test.scripts/backfill-call-analysis.mjs: Analyzes all completed sessions without an analysis row. Supports--limit,--min-duration,--modelsfiltering.apps/web/tests/analyze-call.test.ts): Unit tests for message extraction, conversation summary formatting, and row mapping.XAI_API_KEYand optionalXAI_SUMMARY_MODELenv vars; updated.env.exampleand package.json dependencies.How to test
call_session_analysiswithin seconds.pnpm analyze-call-sessions --dry-run --hours=1to see CSV + insights without writing.pnpm backfill-call-analysis --dry-run --limit=10to test batch processing.pnpm test analyze-call.test.tscovers message extraction, truncation, and row mapping.Scope
Checklist
pnpm run fixallpnpm run type-checkNotes for reviewers
{ messages },{ user_transcriptions }); the extraction logic normalizes all variants and sorts chronologically.notable_patternsso it's not conflated with actual disengagement.https://claude.ai/code/session_01NcdZdGkUc2UWCsg2gx32rg