AI call QA: score call recordings against a weighted rubric with evidence
Problem
Call-center / medical scheduling teams need consistent, evidence-backed quality screening of scheduler calls, and schedulers need office-specific policy answers mid-conversation (app/page.tsx hero copy).
What it does
Upload a call recording; the app stores it on Cloudinary, transcribes it with speaker labels and sentiment analysis via AssemblyAI, flags low-confidence or strongly-valenced utterances as 'abnormalities', then sends the transcript plus a detailed QA scorecard prompt to an LLM that returns structured scores (Greeting, Active Listening, Information Gathering, Objection Handling, Closing, Communication Skills), critical errors, a weighted percentage, and a final judgement, rendered as an animated results dashboard (app/analysis/route.ts, app/page.tsx). A second section mocks a 'multi-office knowledge base' assistant UI with no backend (app/page.tsx lines 487-598).
Architecture
[web] Next.js client (upload, audio player, results dashboard) (client)
[api] /analysis route handler (orchestration) (api)
[cloudinary] Cloudinary (audio storage) (external)
[assemblyai] AssemblyAI (transcription + sentiment) (external)
[llm] DeepSeek via ChatOpenAI-compatible endpoint (structured QA scoring) (model)
web ──▶ api · POST FormData (audio + additional_checks)
api ──▶ cloudinary · upload_stream to 'user-uploads' folder
api ──▶ assemblyai · transcribe with speaker_labels + sentiment_analysis
api ──▶ llm · transcript + abnormalities -> JSON scorecard (jsonMode)
api ──▶ web · JSON.stringify(finalOutput) in {ai} fieldSimplified — box-and-arrow sketch of the repo structure, not a system diagram.
Decisions
01
Structured output via Zod schema + jsonMode
Forces the LLM to return a typed scorecard (enum categories, 0-5 scores, 0-425 total) that the UI can render directly; zod-form-data validates the upload MIME types server-side
Evidence: zod/schema.ts
02
Sentiment-based anomaly prefilter before LLM scoring
Passes only low-confidence (<0.5) or strongly POSITIVE/NEGATIVE utterances as 'Abnormalities' alongside the transcript, focusing the model on suspicious moments
Evidence: app/analysis/route.ts
03
DeepSeek behind the OpenAI-compatible LangChain client
ChatOpenAI is pointed at api.deepseek.com with model 'deepseek-v4-flash', so the LLM provider is swappable via config only
Evidence: config.ts
04
Detailed rubric embedded as a system prompt
A long weighted scorecard (Greeting 8% ... Communication 17%, critical-error rules, output JSON shape) is hardcoded in the route to keep scoring consistent across calls
Evidence: app/analysis/route.ts
05
Pre-baked sample analysis as initial UI state
The results view is seeded with a hardcoded example analysis and sample Cloudinary audio, so the pitch page demos the full flow even before a real upload completes
Evidence: app/page.tsx
The hard parts
- Consistency of LLM scoring across calls relies entirely on prompt rubric adherence with jsonMode (no evaluator/retry loop)
- Sentiment 'abnormality' heuristic is crude: flagging every POSITIVE utterance (route.ts line 68) likely floods the prompt
- Async chain latency: upload -> transcription -> LLM scoring all in one request with no streaming or job queue
Known limitations
- The 'additional_checks' textarea is collected and validated but never used in the prompt (app/analysis/route.ts never references data.data.additional_checks)
- The multi-office knowledge-base assistant is a static UI mock — sending a question only echoes the text into the chat window (app/page.tsx lines 536-553)
- No auth, no persistence of analyses, no user accounts; audio is uploaded to a shared public Cloudinary folder
- Results dashboard depends on client-side JSON.parse of a stringified LLM response with no server-side re-validation
- Silent failure paths: uploadImage and transcription failures return undefined, causing the client to hang on 'Screening call…' (route.ts lines 24-25, 49, 62)