diff --git a/src/components/CallTranscript.jsx b/src/components/CallTranscript.jsx new file mode 100644 index 0000000..d15b74f --- /dev/null +++ b/src/components/CallTranscript.jsx @@ -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 ( +
+
e.stopPropagation()} + > +
+

The call with {name || 'the customer'}

+ +
+ +
+ {turns.length ? turns.map((t, i) => ( +
+

{t.text}

+ {t.side === 'us' ? 'Meera' : (name || 'the customer')} +
+ )) : ( + /* A transcript that will not parse is still evidence, so it is + shown raw rather than replaced with "nothing to display". */ +
{text}
+ )} +
+ +

+ Recorded by the voice agent as the call happened. This is what was said — + the call notes in the trail are Engage's reading of it, which is a + different thing. +

+
+
+ ) +} diff --git a/src/components/Conversation.css b/src/components/Conversation.css index f5f2cf5..d9e55b8 100644 --- a/src/components/Conversation.css +++ b/src/components/Conversation.css @@ -61,3 +61,18 @@ line-height: 1.5; 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); +} diff --git a/src/components/Timeline.jsx b/src/components/Timeline.jsx index f2ce670..4a42bd4 100644 --- a/src/components/Timeline.jsx +++ b/src/components/Timeline.jsx @@ -130,7 +130,7 @@ function filesIn(value) { /** The two activities that ARE the WhatsApp thread. */ 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() // 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 @@ -240,6 +240,9 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat }) { // the person who typed. "System · Customer Reply" told the reader the // opposite of what happened. 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) // A self-loop — Capture Motor Risk runs inside Document Pending and settles // 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, kind: isChat ? 'chat' : kind, isChat, + hasCall, agentKey: aiRole || null, actor: aiRole ? AGENTS[aiRole].full : (r.user_name || 'System'), 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 ) : null} + {it.hasCall && onOpenCall ? ( + + ) : null} {/* What was received, named and openable. The count is stated diff --git a/src/screens/Lead.jsx b/src/screens/Lead.jsx index 30fa34c..8c9e5ea 100644 --- a/src/screens/Lead.jsx +++ b/src/screens/Lead.jsx @@ -4,6 +4,7 @@ import { useZino } from '../api/provider.jsx' import ActivityForm from '../components/ActivityForm.jsx' import AgentCard from '../components/AgentCard.jsx' import AgentChip from '../components/AgentChip.jsx' +import CallTranscript from '../components/CallTranscript.jsx' import ClampText from '../components/ClampText.jsx' import Conversation from '../components/Conversation.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']], ['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']], - ['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 // 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 @@ -152,6 +153,7 @@ export default function Lead() { const [audit, setAudit] = useState(null) const [showChat, setShowChat] = useState(false) const [openAgent, setOpenAgent] = useState(null) + const [showCall, setShowCall] = useState(false) // 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 // answers — and it also means the "nothing for 6 minutes" counter would only @@ -704,6 +706,7 @@ export default function Lead() { rows={audit} onOpenAgent={setOpenAgent} onOpenChat={() => setShowChat(true)} + onOpenCall={() => setShowCall(true)} /> @@ -716,6 +719,9 @@ export default function Lead() { {openAgent ? ( setOpenAgent(null)} /> ) : null} + {showCall ? ( + setShowCall(false)} /> + ) : null} ) }