All projects
CompletedJun 2026 — present

EchoSight

Call QA reports from raw transcripts, with every flag auditable.

A contact-center call quality review tool that turns a raw transcript into an annotated coaching report in a single API call. Deterministic, rule-based moment detection keeps every flag auditable, Gemini powers transcript simulation and a live interactive agent chat, and the in-memory store sits behind a small adapter so it can swap to any database without touching business logic. Built for a hackathon and complete as delivered.

Built with

Node.jsExpressGemini APIServer-Sent Events

Highlights

  • ▸Pairs deterministic rule-based moment detection with AI simulation, so every flag in a report traces back to a rule.
  • ▸Streams simulated transcripts over SSE and hosts a live agent chat behind the same REST API.
  • ▸Transcript ingestion and review work without a Gemini key; only simulation and chat need one.

From the README

EchoSight

Contact-center call quality review tool — AI-powered moment detection, live agent simulation, and supervisor coaching dashboard.

EchoSight turns a raw call transcript into a fully annotated coaching report in a single API call. It combines deterministic rule-based moment detection (every flag is auditable) with Gemini-backed transcript simulation and a live interactive agent chat — all behind a clean REST API and an in-memory store that swaps to any database without touching business logic.


Quick Start

# 1. Install dependencies
cd echosight-backend
npm install

# 2. Set your Gemini API key
echo "GEMINI_API_KEY=your-key-here" > ../.env

# 3. Start the server
npm run dev        # nodemon watch mode
# or
npm start          # plain node

# Server listens at http://localhost:3001

POST /calls and GET /calls* work without a Gemini key.
/calls/simulate, /calls/simulate/stream, and all /chat/* routes require GEMINI_API_KEY.


System Architecture

Storage Layer

Business Logic Layer

Express Server :3001

REST / SSE

Browser / Frontend Client

app.js
CORS · JSON middleware
Static file serving

routes/calls.js
Transcript ingestion
AI simulation · SSE streaming

routes/chat.js
Live session lifecycle
Message routing · Post-call summary

gemini-simulator.js
generateTranscript()

chat-agent.js
getGreeting() · getChatResponse()
getFollowUp() · getChatSummary()

moment-detector.js
detectMoments()

summarizer.js
computeSummary()

memory-db.js
saveCall · getCall · listCalls · getMoments

chat-sessions.js
createSession · getSession · deleteSession

Gemini 2.5 Flash API

ElementDescription
Browser / Frontend ClientAny HTTP client — browser, Postman, or a React frontend hitting localhost:3001
app.jsExpress root. Registers CORS (wildcard), JSON body parsing, mounts both routers, serves index.html for all non-API routes
routes/calls.jsAll /calls/* endpoints: ingestion, AI simulation (one-shot + SSE), listing, and moment retrieval
routes/chat.jsAll /chat/* endpoints: session start, per-message exchange, and session end with AI coaching summary
gemini-simulator.jsGenerates realistic call transcripts from a one-line issue description. Self-validates output against moment rules and retries if any type is missing
chat-agent.jsManages the live Gemini conversation: greeting, per-message response, automatic follow-up when agent says "let me check", post-call coaching summary
moment-detector.jsStateless, deterministic rule engine. Scans turns for escalation keywords, empathy phrases, dead-air gaps, and long monologues. Zero ML
summarizer.jsComputes the full analytics payload: talk time, response timing, word stats, sentiment arc, empathy score, quality score
memory-db.jsIn-memory Map store. Exports exactly four functions (saveCall, getCall, listCalls, getMoments). Swap this file for a DB adapter — nothing else changes
chat-sessions.jsIn-memory store for live sessions. Holds Gemini conversation history and turn buffer for the duration of one call
Gemini 2.5 Flash APIPowers transcript generation (JSON mode), live agent conversation, and post-call coaching analysis

Call Ingestion Flow

memory-db.jssummarizer.jsmoment-detector.jsroutes/calls.jsClientmemory-db.jssummarizer.jsmoment-detector.jsroutes/calls.jsClientPOST /calls { callId, agentName, duration, turns[] }detectMoments(turns)moments[]computeSummary(turns, moments, duration)summary { callQualityScore, talkTimeSeconds,\navgAgentResponseTime, longestTurn, … }saveCall({ callId, agentName, duration, turns, moments, summary })201 { callId, momentCount }
ElementDescription
POST /callsEntry point for external transcript ingestion. Requires callId, agentName, turns[]. duration is optional but enables accurate time-based talk ratios
detectMoments(turns)Stateless scan — no DB reads. Returns one moment object per rule hit. A single turn can produce multiple moments simultaneously
computeSummary(turns, moments, duration)Pure function, no side effects. duration is used to approximate last-turn speaking time for talkTimeSeconds
saveCall(...)Persists the complete call object keyed by callId. Returns 409 if callId already exists
201 { callId, momentCount }Lightweight acknowledgement. Full annotated data is retrievable via GET /calls/:id

AI Simulation Flow (SSE Streaming)

memory-db.jsmoment-detector.jsGemini APIgemini-simulator.jsroutes/calls.jsClientmemory-db.jsmoment-detector.jsGemini APIgemini-simulator.jsroutes/calls.jsClientalt[missing moment types]loop[each turn — scaled delay 400ms–2500ms]GET /calls/simulate/stream?issue=billing+disputeSSE headers (text/event-stream)event:status { message: "AI is generating…" }generateTranscript(issue, agentName)generateContent (JSON mode, T=0.7)raw transcript JSONdetectMoments(turns) — self-validateretry with targeted fix hints (T=0.5)corrected transcript{ agentName, duration, turns[] }event:start { agentName, duration, turnCount }event:turn { index, speaker, text, t, moments[] }saveCall(...)event:complete { callId, summary, momentCount }
ElementDescription
GET /calls/simulate/streamOpens an SSE connection. Requires issue query param. Optional agentName (default: Priya)
SSE headersSets Content-Type: text/event-stream, Cache-Control: no-cache, X-Accel-Buffering: no to prevent proxy buffering
event:statusFirst event fires immediately, before Gemini latency, so the client knows something is happening
generateTranscript()Sends a structured system prompt with 4 hard rules (≥2 escalations, ≥2 empathy phrases, exactly one dead-air gap >15s, ≥1 monologue >50 words)
self-validatedetectMoments() runs on the AI's own output. If any moment type is absent, a targeted retry prompt lists exactly what to fix
scaled delayPer-turn delay = gap_seconds × 1000 × 0.08, clamped 400ms–2500ms. Gives a realistic replay feel without wasting real time
event:turnEach turn is emitted individually with its detected moments already attached — the client can highlight moments as they arrive
event:completeFinal event. callId is assigned after streaming completes. Contains the full summary object

Live Chat Session Flow

memory-db.jsmoment-detector.jsGemini APIchat-agent.jsroutes/chat.jsUser (Customer)memory-db.jsmoment-detector.jsGemini APIchat-agent.jsroutes/chat.jsUser (Customer)Record customer turn + timestampTimestamp set to agentT+4s to avoid false dead_airalt[agent said "let me check / one moment / bear with me…"]loop[Each message exchange]POST /chat/start { agentName }getGreeting(agentName)system prompt + "BEGIN_CALL" T=0.9greeting text{ sessionId, agentMessage, t:0, moments:[] }POST /chat/:id/message { text }detectMoments(session.turns)getChatResponse(geminiHistory, agentName) T=0.8full conversation historyagent reply textgetFollowUp(geminiHistory, agentName) T=0.7SYSTEM follow-up triggerresults / next steps{ agentMessage, agentT, customerMoments,\nagentMoments, followUp, allMoments }POST /chat/:id/endgetChatSummary(turns, agentName)transcript + analysis prompt JSON mode T=0.3{ conclusion, resolved, customerSatisfied,\nagentPerformance, coachingNote }saveCall(...){ callId, duration, summary, aiSummary }
ElementDescription
POST /chat/startCreates session in chat-sessions.js, calls Gemini for a greeting, returns sessionId used for all subsequent calls
getGreeting()Temperature 0.9 — higher randomness for a natural-sounding opening. Sends BEGIN_CALL trigger to the agent persona system prompt
session.geminiHistoryRunning [{ role, parts }] array passed to every Gemini call. Maintains full conversation context across the entire session
getChatResponse()Temperature 0.8. Sends full geminiHistory so Gemini has context of everything said so far
needsFollowUp()Checks agent reply against 15 trigger phrases ("let me check", "one moment", "bear with me", etc.)
getFollowUp()Appends a SYSTEM message instructing the model to deliver lookup results. Timestamp set to agentT + 4 to avoid triggering a false dead_air moment between the two consecutive agent turns
getChatSummary()Separate Gemini call in JSON mode at temperature 0.3 (low for consistent structured output). Returns 5 fields the UI can display as a coaching card
POST /chat/:id/endComputes final moments + summary, calls getChatSummary(), saves to memory-db, deletes the live session

Moment Detection Rules

yes

yes

yes

yes

Turn { speaker, text, t }

speaker === 'customer'
AND text contains
escalation keyword?

🔴 escalation_signal
keywords: cancel · refund · manager
lawsuit · ridiculous · unacceptable

speaker === 'agent'
AND text contains
empathy phrase?

🟢 empathy_statement
phrases: 'I understand' · 'I'm sorry'
'I apologise' · 'I can see why'

speaker === 'agent'
AND prev turn === 'customer'
AND gap > 15s?

🟡 dead_air
matchedText: 'Xs gap'
only fires on agent response turns

word count > 50?

🔵 long_monologue
matchedText: 'N words'
applies to either speaker

moment[]
type · turnIndex · t · speaker · matchedText

ElementDescription
Turn inputA single element of turns[]. Required fields: speaker (agent|customer), text, t (seconds from call start)
escalation_signalFires on customer turns only. 6 keywords chosen for churn and conflict signals
empathy_statementFires on agent turns only. 4 exact phrases — the same ones the Gemini agent persona is instructed to produce, closing the validation loop
dead_airFires on the agent's response turn when gap from the previous customer turn exceeds 15 seconds. Measures company-side delay only
long_monologueFires on any turn exceeding 50 words. Catches customer venting or agent over-explaining a resolution
moment outputEach object carries turnIndex for O(1) annotation lookup, t for timeline display, and matchedText for supervisor audit

Call Quality Score

improved

declined

neutral

base = 100

− 10 per unaddressed escalation
max(0, escalations − empathy responses)

− 15 per dead_air instance

sentimentArc

+ 10

− 15

± 0

clamp to 0–100

callQualityScore 0–100

ElementDescription
base = 100Every call starts at a perfect score. Points are deducted for measurable failures only
unaddressed escalation −10An escalation that was not followed by any empathy phrase. Each one costs 10 points
dead_air −15Each dead-air instance costs 15 points — weighted higher than a missed empathy phrase because it is a harder, more objective failure
sentimentArc +10 / −15Computed by comparing escalation density in first vs. second half. improved means the agent de-escalated; declined means it got worse. Asymmetric weights (−15 > +10) because decline is a stronger signal
clamp 0–100Prevents extreme multi-failure calls from producing negative scores

Summary Statistics Reference

Every call returns a summary object with the following fields:

summary

Turn Counts

totalTurns

agentTurnCount

customerTurnCount

Talk Ratio

talkRatio.agent %

talkRatio.customer %

Talk Time

talkTimeSeconds.agent

talkTimeSeconds.customer

talkTimeSeconds.agentPct

talkTimeSeconds.customerPct

Word Stats

avgTurnWords.agent

avgTurnWords.customer

avgTurnWords.overall

longestTurn.speaker

longestTurn.words

longestTurn.preview

Response Timing

avgAgentResponseTime

maxAgentResponseTime

longestSilence

firstResponseTime

Quality Metrics

callQualityScore

escalationCount

empathyScore

empathyCount

agentEmpathyRate

deadAirCount

sentimentArc

momentBreakdown

FieldTypeDescription
totalTurnsnumberTotal turn count across all speakers
agentTurnCountnumberTurns where speaker === 'agent'
customerTurnCountnumberTurns where speaker === 'customer'
talkRatio{ agent, customer }Percentage share of turns per speaker (turn-count based)
talkTimeSeconds{ agent, customer, agentPct, customerPct }Approximate speaking seconds derived from timestamp gaps. More accurate than talkRatio for calls with lopsided turn lengths
avgTurnWords{ agent, customer, overall }Mean word count per turn, broken out by speaker
longestTurn{ speaker, words, turnIndex, t, preview }Turn with the highest word count. preview is the first 80 characters
avgAgentResponseTimenumber (s)Mean gap on all customer→agent transitions. Core measure of agent responsiveness
maxAgentResponseTimenumber (s)Worst single agent response delay in the call
longestSilencenumber (s)Largest gap between any two consecutive turns (not filtered to customer→agent)
firstResponseTimenumber (s)Timestamp of the first agent turn — how quickly the agent opened the call
callQualityScorenumber 0–100Composite quality score (see formula above)
escalationCountnumberTotal escalation_signal moments detected
empathyScorenumber 0–1.0min(1, empathyCount / escalationCount). 1.0 = every escalation was addressed
empathyCountnumberTotal empathy_statement moments detected
agentEmpathyRatenumber %Percentage of agent's turns that contained an empathy phrase
deadAirCountnumberTotal dead_air moments (>15s gaps on agent response)
sentimentArcimproved | neutral | declinedCall trajectory based on escalation distribution across first vs. second half
momentBreakdown{ escalation_signal, empathy_statement, dead_air, long_monologue }Count of each moment type

Data Model

has

has

has

CALL

string

callId

PK

string

agentName

number

duration

TURN

string

speaker

string

text

number

t

MOMENT

string

type

number

turnIndex

number

t

string

speaker

string

matchedText

SUMMARY

number

totalTurns

number

callQualityScore

number

escalationCount

number

empathyScore

number

avgAgentResponseTime

number

longestSilence

string

sentimentArc

object

talkTimeSeconds

object

avgTurnWords

object

longestTurn

EntityFieldDescription
CALLcallIdPrimary key. Format: call-001 (ingested) or sim-{timestamp} (simulated) or call-{timestamp} (chat)
CALLagentNameAgent display name used in Gemini persona prompts
CALLdurationTotal call length in seconds. Optional on ingestion; computed from startTime on chat sessions
TURNspeakeragent or customer. Determines which detection rules apply
TURNtextRaw spoken text. No formatting — plain conversational speech
TURNtCumulative seconds from call start. Used for gap calculations and timeline display
MOMENTtypeOne of: escalation_signal, empathy_statement, dead_air, long_monologue
MOMENTturnIndexIndex into turns[] — enables O(1) annotation lookup when building annotatedTurns
MOMENTmatchedTextThe exact keyword, phrase, or measurement that triggered the rule. Used for supervisor audit
SUMMARYtalkTimeSecondsObject containing agent, customer, agentPct, customerPct — time-based talk distribution
SUMMARYlongestTurnObject: { speaker, words, turnIndex, t, preview } — the most verbose turn in the call

Project Structure

EchoSight/
├── .env                          # GEMINI_API_KEY (git-ignored)
├── index.html                    # Static dashboard served at /
├── README.md                     # This file
├── ECHOSIGHT_DOCS.md             # Full technical reference
└── echosight-backend/
    ├── index.js                  # Entry point — starts Express on :3001
    ├── package.json
    └── src/
        ├── app.js                # Express app, middleware, route mounts, static serving
        ├── routes/
        │   ├── calls.js          # /calls/* — ingestion, simulation, SSE, listing
        │   └── chat.js           # /chat/* — session start, message, end
        ├── services/
        │   ├── moment-detector.js  # Deterministic rule engine (no ML)
        │   ├── summarizer.js       # Analytics computation — all summary fields
        │   ├── gemini-simulator.js # AI transcript generation with self-validation
        │   └── chat-agent.js       # Live AI agent: greeting, response, follow-up, summary
        └── storage/
            ├── memory-db.js        # Call store — swap this file to change DB
            └── chat-sessions.js    # Live session store

Key Design Decisions

Why deterministic detection instead of LLM classification?
Every moment flag is traceable to a specific keyword or timestamp. Supervisors can audit findings. LLM classifiers produce scores — not reasons. For compliance review and agent feedback, auditability beats marginal accuracy.

Why does the simulator validate its own output?
Gemini occasionally misses required moment types despite explicit instructions. Running detectMoments() on the output before returning it makes the simulator and the detector share the same truth. If the check fails, a targeted retry prompt tells the model exactly which types are missing.

Why is talkTimeSeconds separate from talkRatio?
talkRatio counts turns. A 2-word reply and a 200-word explanation both count as 1 turn. talkTimeSeconds uses the timestamp gap to the next turn as a proxy for speaking duration — a far more meaningful measure of who dominated the conversation.

Why is storage a 4-function contract in its own file?
Every route and service goes through memory-db.js. Swapping to Postgres, Redis, or MongoDB means rewriting one file with the same four function signatures. No other file is aware that storage exists.


License

Apache 2.0 — see echosight-backend/package.json.

Local Storage Notice

This site uses local storage to cache GitHub data for better performance, and an anonymous visit counter. No personal data is stored or shared.