console: the call, in the customer's own words

Recording is now on for Meera (93) and the transcript is kept on the lead
(94), so the call stops being a thing only one AI ever saw.

The wake that ends a call already carried far more than Engage used — a
summary, a sentiment analysis, and the FULL TRANSCRIPT, turn by turn —
and all of it was dropped once the three-sentence write-up was done. So a
customer said "I think it's 12,000 rupees" and "lower premium", and the
file held a paraphrase.

That matters here more than it would elsewhere: consent_artefact =
verbal_call is written from what the agent HEARD, and it is the artefact
justifying every later contact. The evidence for it should be the
customer's own words.

"Hear the call" now sits on the Log Contact entry and opens the
conversation, laid out like the WhatsApp thread — agent right, caller left
— because it is the same kind of thing and should not need learning
twice. It also files under Contact in the lead record.

Turns are split on the speaker labels rather than on newlines: a single
turn wraps, and splitting per line shattered one sentence into four
bubbles. A transcript that will not parse is shown raw — it is still
evidence, and "nothing to display" would be a lie.

NOT INCLUDED: the audio. Recording is enabled and the WAV is uploaded,
but recording_url is delivered to the agent's post-call WEBHOOK
(voice_internal_handler.go:429), not in the wake payload, so nothing puts
it on the lead. Wiring that webhook to an external-API activity is a
separate change, worth doing when someone asks to listen rather than to
read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yashas 2026-09-08 17:19:53 +05:30
parent c7cd5602ae
commit 4b590474e4
4 changed files with 137 additions and 2 deletions

View File

@ -0,0 +1,105 @@
import { useEffect, useRef } from 'react'
import './Conversation.css'
/**
* The call, turn by turn.
*
* The voice agent hands the workflow a full transcript when a call ends, and
* until now Engage read it, wrote three sentences of summary, and the words
* themselves were dropped. So a customer said "I think it's 12,000 rupees" and
* "lower premium", and the file held one AI's paraphrase of that.
*
* It matters more here than it would elsewhere: `consent_artefact =
* verbal_call` is recorded from what the agent HEARD, and it is the artefact
* justifying every later contact. A colleague reviewing that months later
* should see the customer's own words.
*
* Rendered like the WhatsApp thread on purpose agent right, caller left
* because it is the same kind of thing and should not need learning twice.
*/
function turnsFrom(text) {
if (!text || typeof text !== 'string') return []
const out = []
// "Agent: ..." / "Caller: ..." with a turn continuing until the next label.
// Split on the labels rather than on newlines: a single turn wraps, and
// splitting per line would shatter one sentence into four bubbles.
const re = /^(Agent|Caller|Customer|User|Assistant):\s*/i
for (const raw of text.split('\n')) {
const line = raw.replace(/\s+$/, '')
if (!line.trim()) continue
const m = line.match(re)
if (m) {
const who = m[1].toLowerCase()
out.push({
side: (who === 'agent' || who === 'assistant') ? 'us' : 'them',
text: line.slice(m[0].length).trim(),
})
} else if (out.length) {
out[out.length - 1].text += (out[out.length - 1].text ? '\n' : '') + line.trim()
}
}
return out.filter((t) => t.text)
}
export default function CallTranscript({ text, name, onClose }) {
const ref = useRef(null)
const restore = useRef(null)
const turns = turnsFrom(text)
useEffect(() => {
restore.current = document.activeElement
ref.current?.focus()
const onKey = (e) => { if (e.key === 'Escape') onClose() }
document.addEventListener('keydown', onKey)
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', onKey)
document.body.style.overflow = prev
if (restore.current instanceof HTMLElement) restore.current.focus()
}
}, [onClose])
return (
<div className="prose__scrim" onClick={onClose} role="presentation">
<div
className="prose__dlg conv"
role="dialog"
aria-modal="true"
aria-label="Call transcript"
tabIndex={-1}
ref={ref}
onClick={(e) => e.stopPropagation()}
>
<div className="prose__head">
<h3>The call with {name || 'the customer'}</h3>
<button type="button" onClick={onClose} aria-label="Close">
<svg viewBox="0 0 14 14" aria-hidden="true">
<path d="M3.5 3.5l7 7M10.5 3.5l-7 7" fill="none" stroke="currentColor"
strokeWidth="1.6" strokeLinecap="round" />
</svg>
</button>
</div>
<div className="conv__body">
{turns.length ? turns.map((t, i) => (
<div key={i} className={'bub bub--' + t.side}>
<p>{t.text}</p>
<span>{t.side === 'us' ? 'Meera' : (name || 'the customer')}</span>
</div>
)) : (
/* A transcript that will not parse is still evidence, so it is
shown raw rather than replaced with "nothing to display". */
<pre className="conv__raw">{text}</pre>
)}
</div>
<p className="conv__note">
Recorded by the voice agent as the call happened. This is what was said
the call notes in the trail are Engage&apos;s reading of it, which is a
different thing.
</p>
</div>
</div>
)
}

View File

@ -61,3 +61,18 @@
line-height: 1.5; line-height: 1.5;
color: var(--zk-grey); color: var(--zk-grey);
} }
/* A transcript the parser could not split into turns is still evidence.
Shown raw rather than replaced with "nothing to display". */
.conv__raw {
margin: 0;
padding: 14px 16px;
font: inherit;
font-size: 0.82rem;
line-height: 1.6;
white-space: pre-wrap;
overflow-wrap: anywhere;
color: var(--zk-muted);
background: var(--zk-tint);
border-radius: var(--r-md);
}

View File

@ -130,7 +130,7 @@ function filesIn(value) {
/** The two activities that ARE the WhatsApp thread. */ /** The two activities that ARE the WhatsApp thread. */
const CHAT_ACTS = new Set(['zk-act-customer-reply', 'zk-act-answer-customer']) const CHAT_ACTS = new Set(['zk-act-customer-reply', 'zk-act-answer-customer'])
export default function Timeline({ rows, onOpenAgent, onOpenChat }) { export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall }) {
const { client } = useZino() const { client } = useZino()
// DATA_UPDATE entries are the platform writing fields, not anyone deciding // DATA_UPDATE entries are the platform writing fields, not anyone deciding
// anything. They are the bulk of a busy trail and they are hidden until asked // anything. They are the bulk of a busy trail and they are hidden until asked
@ -240,6 +240,9 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat }) {
// the person who typed. "System · Customer Reply" told the reader the // the person who typed. "System · Customer Reply" told the reader the
// opposite of what happened. // opposite of what happened.
const isChat = CHAT_ACTS.has(r.activity_id) const isChat = CHAT_ACTS.has(r.activity_id)
// Log Contact is where the call lands. Offer the words themselves beside
// the summary, because they are not the same thing.
const hasCall = r.activity_id === 'zk-act-contact' && Boolean(byBase.get('call_transcript')?.value)
const stage = STAGES.find((s) => s.uid === r.execution_state) const stage = STAGES.find((s) => s.uid === r.execution_state)
// A self-loop Capture Motor Risk runs inside Document Pending and settles // A self-loop Capture Motor Risk runs inside Document Pending and settles
// back into it is not a move, and printing " Document Pending" against // back into it is not a move, and printing " Document Pending" against
@ -251,6 +254,7 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat }) {
key: r.id ?? i, key: r.id ?? i,
kind: isChat ? 'chat' : kind, kind: isChat ? 'chat' : kind,
isChat, isChat,
hasCall,
agentKey: aiRole || null, agentKey: aiRole || null,
actor: aiRole ? AGENTS[aiRole].full : (r.user_name || 'System'), actor: aiRole ? AGENTS[aiRole].full : (r.user_name || 'System'),
what: isChat ? 'WhatsApp' : isSystem ? 'Data updated' : (r.activity_name || prettyUid(r.activity_id)), what: isChat ? 'WhatsApp' : isSystem ? 'Data updated' : (r.activity_name || prettyUid(r.activity_id)),
@ -365,6 +369,11 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat }) {
Open thread Open thread
</button> </button>
) : null} ) : null}
{it.hasCall && onOpenCall ? (
<button type="button" className="tl__open" onClick={onOpenCall}>
Hear the call
</button>
) : null}
</div> </div>
{/* What was received, named and openable. The count is stated {/* What was received, named and openable. The count is stated

View File

@ -4,6 +4,7 @@ import { useZino } from '../api/provider.jsx'
import ActivityForm from '../components/ActivityForm.jsx' import ActivityForm from '../components/ActivityForm.jsx'
import AgentCard from '../components/AgentCard.jsx' import AgentCard from '../components/AgentCard.jsx'
import AgentChip from '../components/AgentChip.jsx' import AgentChip from '../components/AgentChip.jsx'
import CallTranscript from '../components/CallTranscript.jsx'
import ClampText from '../components/ClampText.jsx' import ClampText from '../components/ClampText.jsx'
import Conversation from '../components/Conversation.jsx' import Conversation from '../components/Conversation.jsx'
import Timeline from '../components/Timeline.jsx' import Timeline from '../components/Timeline.jsx'
@ -61,7 +62,7 @@ const GROUPS = [
['Source', ['lead_ref','product_line','source_channel','partner_code','partner_branch','rm_or_agent_id','consent_artefact']], ['Source', ['lead_ref','product_line','source_channel','partner_code','partner_branch','rm_or_agent_id','consent_artefact']],
['Customer', ['customer_name','entity_name','mobile','email','pan','gstin','udyam_no']], ['Customer', ['customer_name','entity_name','mobile','email','pan','gstin','udyam_no']],
['Intake', ['lead_score','attribution_status','attribution_reason','dedupe_match_ref','eligibility_outcome','eligibility_reason']], ['Intake', ['lead_score','attribution_status','attribution_reason','dedupe_match_ref','eligibility_outcome','eligibility_reason']],
['Contact', ['contact_outcome','outreach_window','contact_notes']], ['Contact', ['contact_outcome','outreach_window','contact_notes','call_transcript']],
// The five OCR slots led this list and were absent from it, which is how a // The five OCR slots led this list and were absent from it, which is how a
// lead with an RC, an expiring policy and a PAN attached showed "Documents 2" // lead with an RC, an expiring policy and a PAN attached showed "Documents 2"
// the status and the notes. They were also absent from the view itself // the status and the notes. They were also absent from the view itself
@ -152,6 +153,7 @@ export default function Lead() {
const [audit, setAudit] = useState(null) const [audit, setAudit] = useState(null)
const [showChat, setShowChat] = useState(false) const [showChat, setShowChat] = useState(false)
const [openAgent, setOpenAgent] = useState(null) const [openAgent, setOpenAgent] = useState(null)
const [showCall, setShowCall] = useState(false)
// A clock in state rather than Date.now() in the render body. Reading the // A clock in state rather than Date.now() in the render body. Reading the
// wall clock while rendering is impure React may render twice and get two // wall clock while rendering is impure React may render twice and get two
// answers and it also means the "nothing for 6 minutes" counter would only // answers and it also means the "nothing for 6 minutes" counter would only
@ -704,6 +706,7 @@ export default function Lead() {
rows={audit} rows={audit}
onOpenAgent={setOpenAgent} onOpenAgent={setOpenAgent}
onOpenChat={() => setShowChat(true)} onOpenChat={() => setShowChat(true)}
onOpenCall={() => setShowCall(true)}
/> />
</div> </div>
</div> </div>
@ -716,6 +719,9 @@ export default function Lead() {
{openAgent ? ( {openAgent ? (
<AgentCard agentKey={openAgent} onClose={() => setOpenAgent(null)} /> <AgentCard agentKey={openAgent} onClose={() => setOpenAgent(null)} />
) : null} ) : null}
{showCall ? (
<CallTranscript text={row.call_transcript} name={row.customer_name} onClose={() => setShowCall(false)} />
) : null}
</section> </section>
) )
} }