console: the chain of thought is on the page, not behind a link

An agentic console has to answer one question before anyone will rely on it:
why did the AI do that. The reasoning was always recorded — the employees write
it, the platform stores the tools they called — but the trail showed only the
conclusion. An operator either trusted it blind or opened the raw record.

Each AI step now carries its chain of thought, assembled from real data, never
narrated after the fact: what it CHECKED (the tools it called, each marked for
whether it came back with anything), what it REASONED (the employee's own
words), and what it DECIDED (the activity it committed, the state it moved the
lead to, its confidence). The finding — the first sentence — stays visible; the
chain opens under it.

A tool that returned nothing is drawn hollow. That is the Manju Malik failure
made visible: a licence check that came back empty and was reasoned over as if
it were a finding is exactly what wrongly dropped a real lead, and an operator
scanning the chain should see the empty result the machine reasoned from.

The reasoning ships now — it is already in the audit data. The tool rung lights
up once view-service joins tbl_ai_decisions (next commit); until then the chain
shows two rungs and degrades cleanly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yashas 2026-09-09 00:33:18 +05:30
parent a49ae3d86c
commit 8e86fa7a0a
3 changed files with 278 additions and 11 deletions

View File

@ -0,0 +1,69 @@
.cot { margin-top: var(--sp-2); }
/* The finding, always visible the one sentence that answers "what did it
conclude" without opening anything. */
.cot__finding {
margin: 0 0 var(--sp-2);
font-size: var(--fs-sm);
line-height: var(--lh-normal);
font-weight: var(--fw-medium);
color: var(--zk-ink);
}
.cot__toggle {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-1) 0;
border: 0;
background: none;
cursor: pointer;
font: inherit;
font-size: var(--fs-2xs);
font-weight: var(--fw-semi);
color: var(--zk-blue-dark);
}
.cot__brain { width: 15px; height: 15px; flex: none; }
.cot__ct { color: var(--zk-grey); font-weight: var(--fw-normal); }
.cot__caret { width: 11px; height: 11px; transition: transform .18s var(--ease); }
.cot__caret.is-open { transform: rotate(90deg); }
.cot__steps {
list-style: none;
margin: var(--sp-3) 0 0;
padding: var(--sp-3) 0 var(--sp-2);
border-left: 2px solid var(--zk-blue-light);
padding-left: var(--sp-4);
}
.cot__step { position: relative; display: grid; grid-template-columns: 82px 1fr; gap: var(--sp-3); padding: var(--sp-2) 0; }
.cot__step::before {
content: ""; position: absolute; left: calc(var(--sp-4) * -1 - 6px); top: 9px;
width: 9px; height: 9px; border-radius: 50%; background: var(--zk-white); border: 2px solid var(--zk-blue);
}
.cot__mark {
font-size: var(--fs-3xs);
font-weight: var(--fw-semi);
letter-spacing: .06em;
text-transform: uppercase;
color: var(--zk-blue-dark);
padding-top: 1px;
}
.cot__b { font-size: var(--fs-xs); line-height: var(--lh-normal); color: var(--zk-muted); overflow-wrap: anywhere; }
.cot__b b { color: var(--zk-ink); font-weight: var(--fw-semi); }
.cot__tool {
display: inline-flex; align-items: center; gap: 6px;
margin: 3px 6px 0 0; padding: 2px 9px;
border-radius: var(--r-pill); background: var(--zk-white); border: 1px solid var(--zk-line);
font-family: var(--font-mono); font-size: 11.5px; color: var(--zk-muted);
}
.cot__tool::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--zk-good, #1f9d63); }
.cot__tool.is-empty { color: var(--zk-grey); }
.cot__tool.is-empty::before { background: var(--zk-grey); }
.cot__foot { display: flex; flex-wrap: wrap; gap: 6px; margin-top: var(--sp-2); }
.cot__conf { font-size: 11.5px; font-weight: var(--fw-semi); color: var(--zk-blue-dark); background: var(--zk-tint-blue); border: 1px solid var(--zk-blue-light); padding: 1px 9px; border-radius: var(--r-pill); }
.cot__model { font-family: var(--font-mono); font-size: 11px; color: var(--zk-grey); padding: 2px 0; }

View File

@ -0,0 +1,142 @@
import { useState } from 'react'
import './ChainOfThought.css'
/**
* How an AI employee reached a decision, step by step.
*
* This is the trust surface. A person signing off on or overriding a
* machine's decision needs the answer to one question first: WHY did it do
* that. Before this, the reasoning existed (the employees write it, and the
* platform records the tools they called in tbl_ai_decisions) but the console
* showed only the conclusion. An operator either trusted it blind or opened
* the raw record. Neither is oversight.
*
* The chain reads the way the employee actually ran: what it CHECKED (the
* tools it called, each marked for whether it came back with anything), what
* it REASONED, and what it DECIDED. It is assembled from real fields, never
* narrated after the fact the reasoning is the employee's own text, the
* tools are the calls it actually made, the decision is the activity it
* committed and the state it moved the lead to.
*
* A tool that returned nothing is drawn with a hollow marker. That is not
* cosmetic: a licence check that came back empty and was read as "unlicensed"
* is exactly how a real lead was wrongly dropped, and an operator scanning the
* chain should see the empty result the machine reasoned from.
*/
/** The finding is the first sentence the employees write the conclusion
* first lifted out so it can be read without opening the chain. */
function findingOf(text) {
const m = String(text).match(/^(.{24,200}?[.!?])(\s|$)/)
return m ? m[1].trim() : null
}
/**
* Tool ids are how the runtime names a call, not how a person reads one.
* `retrieve_kb_7f9f…` is a knowledge lookup; `call_agentic_tool_29601` is a
* registered tool by number. Give each a name an operator recognises; fall
* back to a de-slugged version of whatever it is.
*/
function toolLabel(name) {
const n = String(name)
if (n.startsWith('retrieve_kb')) return 'Knowledge base'
if (n.startsWith('search_knowledge')) return 'Knowledge base'
if (n === 'get_task_context') return 'Task context'
if (n === 'lookup_partner') return 'Partner registry'
if (n.startsWith('call_agentic_tool')) return 'Agentic tool'
return n
.replace(/_[0-9a-f-]{8,}$/i, '')
.replace(/_\d+$/, '')
.replace(/_/g, ' ')
.replace(/^\w/, (c) => c.toUpperCase())
}
/** One pill per distinct tool. The same knowledge base queried three times is
* one thing checked, not three; an operator wants the surfaces consulted, not
* the call count. A tool is "empty" only if EVERY call to it came back empty. */
function distinctTools(tools) {
const seen = new Map()
for (const t of tools) {
const label = toolLabel(t.name)
if (!seen.has(label)) seen.set(label, { name: label, empty: t.empty })
else if (!t.empty) seen.get(label).empty = false
}
return [...seen.values()]
}
export default function ChainOfThought({ reasoning, tools, decided, confidence, model }) {
const [open, setOpen] = useState(false)
const toolPills = tools && tools.length ? distinctTools(tools) : []
const primary = reasoning.length ? reasoning[0][1] : ''
const finding = findingOf(primary)
// Only the steps that actually have something behind them. A step with no
// evidence is not drawn a hollow "Checked" with no tools would be noise.
const steps = []
if (toolPills.length) steps.push('checked')
if (reasoning.length) steps.push('reasoned')
steps.push('decided')
return (
<div className="cot">
{finding ? <p className="cot__finding">{finding}</p> : null}
<button
type="button"
className="cot__toggle"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
<svg className="cot__brain" viewBox="0 0 16 16" aria-hidden="true">
<path d="M6 2.5a2 2 0 0 0-2 2 2 2 0 0 0-1 3.7A2 2 0 0 0 4 11.5a2 2 0 0 0 2 2M10 2.5a2 2 0 0 1 2 2 2 2 0 0 1 1 3.7 2 2 0 0 1-1 3.3 2 2 0 0 1-2 2M8 3v10"
fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
</svg>
Chain of thought
<span className="cot__ct">· {steps.length} step{steps.length === 1 ? '' : 's'}</span>
<svg className={'cot__caret' + (open ? ' is-open' : '')} viewBox="0 0 12 12" aria-hidden="true">
<path d="M4 2.5 8 6l-4 3.5" fill="none" stroke="currentColor" strokeWidth="1.6"
strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{open ? (
<ol className="cot__steps">
{toolPills.length ? (
<li className="cot__step">
<span className="cot__mark">Checked</span>
<div className="cot__b">
{toolPills.map((t, i) => (
<span key={i} className={'cot__tool' + (t.empty ? ' is-empty' : '')} title={t.empty ? 'returned nothing' : 'returned a result'}>
{t.name}
</span>
))}
</div>
</li>
) : null}
{reasoning.map(([label, text], i) => (
<li className="cot__step" key={label}>
<span className="cot__mark">{i === 0 ? 'Reasoned' : label}</span>
<div className="cot__b">{text}</div>
</li>
))}
<li className="cot__step">
<span className="cot__mark">Decided</span>
<div className="cot__b">
<b>{decided.what}</b>
{decided.stage ? <> moved to <b>{decided.stage}</b></> : null}
{(confidence != null || model) ? (
<div className="cot__foot">
{confidence != null ? <span className="cot__conf">{Math.round(confidence * (confidence <= 1 ? 100 : 1))}% confidence</span> : null}
{model ? <span className="cot__model">{model}</span> : null}
</div>
) : null}
</div>
</li>
</ol>
) : null}
</div>
)
}

View File

@ -2,6 +2,7 @@ import { useState } from 'react'
import { useZino } from '../api/provider.jsx' import { useZino } from '../api/provider.jsx'
import AgentChip from './AgentChip.jsx' import AgentChip from './AgentChip.jsx'
import ClampText from './ClampText.jsx' import ClampText from './ClampText.jsx'
import ChainOfThought from './ChainOfThought.jsx'
import { AGENTS } from '../api/agents.js' import { AGENTS } from '../api/agents.js'
import { APP_ID, STAGES, baseFieldId } from '../api/config.js' import { APP_ID, STAGES, baseFieldId } from '../api/config.js'
import './Timeline.css' import './Timeline.css'
@ -23,23 +24,34 @@ import './Timeline.css'
* that did appear was an accident (a DATA_UPDATE row happens to write the * that did appear was an accident (a DATA_UPDATE row happens to write the
* unsuffixed key). * unsuffixed key).
*/ */
const NARRATIVE = [ /* AN AI STEP'S REASONING, in the order it is worth reading. These become the
"Reasoned" rung of the chain of thought the employee's own words for why
it did what it did rather than loose quote blocks. The first present one
is the finding shown without opening the chain. */
const REASONING = [
['attribution_reason', 'Attribution'], ['attribution_reason', 'Attribution'],
['eligibility_reason', 'Eligibility'], ['eligibility_reason', 'Eligibility'],
['dedupe_match_ref', 'Duplicate of'], ['ai_recommendation_rationale', 'Cover advice'],
['contact_notes', 'Call'],
['ai_recommendation_rationale', 'Recommendation'],
['quoted_breakup', 'How the premium was reached'],
['kyc_mismatch_notes', 'KYC'], ['kyc_mismatch_notes', 'KYC'],
['referral_analysis', 'Referral analysis'], ['referral_analysis', 'Referral analysis'],
['uw_decision_notes', 'Underwriting decision'], ['uw_decision_notes', 'Underwriting'],
['contact_notes', 'Call'],
['documents_notes', 'Documents'], ['documents_notes', 'Documents'],
['customer_reply', 'The customer said'], ['quoted_breakup', 'How the premium was reached'],
['customer_answer', 'We replied'],
['lost_reason', 'Why it was dropped'], ['lost_reason', 'Why it was dropped'],
['resume_note', 'Why now'], ['resume_note', 'Why now'],
] ]
/* CONVERSATION lines stay as plain quotes they are what was said to and by
the customer, not the machine's reasoning, and belong beside the thread. */
const CONVERSATION = [
['customer_reply', 'The customer said'],
['customer_answer', 'We replied'],
['dedupe_match_ref', 'Duplicate of'],
]
const NARRATIVE = [...REASONING, ...CONVERSATION]
const MONEY = new Set(['quoted_premium', 'commission_amount', 'sme_value_at_risk', 'motor_idv']) const MONEY = new Set(['quoted_premium', 'commission_amount', 'sme_value_at_risk', 'motor_idv'])
/** /**
@ -261,9 +273,25 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
stage: moved ? stage : null, stage: moved ? stage : null,
when: when(r.created_at), when: when(r.created_at),
docs, docs,
narrative: NARRATIVE // The reasoning rungs of the chain of thought the employee's own text.
reasoning: REASONING
.map(([k, label]) => [label, byBase.get(k)?.value]) .map(([k, label]) => [label, byBase.get(k)?.value])
.filter(([, v]) => v !== undefined && v !== null && typeof v !== 'object' && String(v).trim() !== ''), .filter(([, v]) => v !== undefined && v !== null && typeof v !== 'object' && String(v).trim() !== ''),
// Conversation lines stay as plain quotes.
conversation: CONVERSATION
.map(([k, label]) => [label, byBase.get(k)?.value])
.filter(([, v]) => v !== undefined && v !== null && typeof v !== 'object' && String(v).trim() !== ''),
// The decision's confidence and model, when the step recorded them. The
// confidence field is written on the advice step; ai_tools/ai_model come
// from the audit row once view-service joins tbl_ai_decisions (until then
// they are absent and the chain simply shows two rungs instead of three).
confidence: (() => {
const c = byBase.get('ai_recommendation_confidence')?.value
const n = c == null ? null : Number(c)
return Number.isFinite(n) ? n : null
})(),
tools: Array.isArray(r.ai_tools) ? r.ai_tools : [],
model: r.ai_model || '',
figures: [...byBase] figures: [...byBase]
.filter(([base, f]) => MONEY.has(base) && f.value) .filter(([base, f]) => MONEY.has(base) && f.value)
.map(([base, f]) => [base.replace(/_/g, ' '), '₹' + Number(f.value).toLocaleString('en-IN')]), .map(([base, f]) => [base.replace(/_/g, ' '), '₹' + Number(f.value).toLocaleString('en-IN')]),
@ -315,7 +343,7 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
last.when = it.when last.when = it.when
// Keep the newest line as the preview an operator scanning the trail // Keep the newest line as the preview an operator scanning the trail
// wants where the conversation GOT to, not where it started. // wants where the conversation GOT to, not where it started.
if (it.narrative.length) last.narrative = it.narrative if (it.conversation.length) last.conversation = it.conversation
last.docs = last.docs.concat(it.docs) last.docs = last.docs.concat(it.docs)
continue continue
} }
@ -407,7 +435,35 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
</div> </div>
) : null} ) : null}
{it.narrative.map(([label, v]) => ( {/* THE CHAIN OF THOUGHT. For an AI step, the reasoning is not a
loose quote it is how the employee reached the decision, so
it renders as the chain: what it checked, what it reasoned,
what it decided. This is the answer to "why did it do that",
which is the first thing a person needs before trusting or
overriding a machine. */}
{it.kind === 'ai' && (it.reasoning.length || it.tools.length) ? (
<ChainOfThought
reasoning={it.reasoning}
tools={it.tools}
confidence={it.confidence}
model={it.model}
decided={{ what: it.what, stage: it.stage ? it.stage.name : null }}
/>
) : null}
{/* A human step's reason, and the AI step's reasoning when it is
not the story's actor (rare), stay as a plain quote. */}
{it.kind !== 'ai'
? it.reasoning.map(([label, v]) => (
<div className="tl__say" key={label}>
<span className="tl__saylabel">{label}</span>
<ClampText text={String(v)} title={`${label}${it.what}`} lines={2} threshold={120} />
</div>
))
: null}
{/* What was said to and by the customer — always a plain quote. */}
{it.conversation.map(([label, v]) => (
<div className="tl__say" key={label}> <div className="tl__say" key={label}>
<span className="tl__saylabel">{label}</span> <span className="tl__saylabel">{label}</span>
<ClampText text={String(v)} title={`${label}${it.what}`} lines={2} threshold={120} /> <ClampText text={String(v)} title={`${label}${it.what}`} lines={2} threshold={120} />