console: chain of thought, from data the console already has

Follows the previous commit but drops everything that would have needed a
backend change. The chain is built entirely from the audit trail the console
already fetches: the REASONED rung is the employee's own reasoning fields
(attribution_reason, ai_recommendation_rationale, and the rest — already in the
data), and the DECIDED rung is the activity it committed, the state it moved the
lead to, and its confidence (ai_recommendation_confidence, also a real field on
the step). The finding stays visible above the chain.

No dependency on any endpoint change: the tool-call rung and the tbl_ai_decisions
join are gone. What remains is real, self-contained, and ships with a push.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yashas 2026-09-09 00:46:42 +05:30
parent 8e86fa7a0a
commit 900f34d11e
3 changed files with 25 additions and 95 deletions

View File

@ -54,16 +54,6 @@
.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

@ -6,77 +6,34 @@ import './ChainOfThought.css'
*
* 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.
* that. Before this, the reasoning existed (the employees write it as workflow
* fields, so it is already in the audit trail) but the console showed only the
* conclusion. An operator either trusted it blind or went digging. 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.
* The chain reads the way the employee's own record reads: what it REASONED and
* what it DECIDED. It is assembled from real fields already on the step the
* employee's own words for the reasoning, the activity it committed and the
* state it moved the lead to for the decision never narrated after the fact.
* A step may carry more than one reasoning field (attribution AND eligibility,
* say); each is its own rung.
*
* 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 the first sentence, since the employees write the conclusion
* first is lifted out and stays visible, so the "why" can be read without
* opening the chain.
*/
/** 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 }) {
export default function ChainOfThought({ reasoning, decided, confidence }) {
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')
const steps = reasoning.length + 1 // each reasoning rung, plus Decided
return (
<div className="cot">
@ -93,7 +50,7 @@ export default function ChainOfThought({ reasoning, tools, decided, confidence,
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>
<span className="cot__ct">· {steps} step{steps === 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" />
@ -102,19 +59,6 @@ export default function ChainOfThought({ reasoning, tools, decided, confidence,
{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>
@ -127,10 +71,11 @@ export default function ChainOfThought({ reasoning, tools, decided, confidence,
<div className="cot__b">
<b>{decided.what}</b>
{decided.stage ? <> moved to <b>{decided.stage}</b></> : null}
{(confidence != null || model) ? (
{confidence != null ? (
<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}
<span className="cot__conf">
{Math.round(confidence * (confidence <= 1 ? 100 : 1))}% confidence
</span>
</div>
) : null}
</div>

View File

@ -281,17 +281,14 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
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).
// The decision's confidence, when the step recorded it ai_recommendation_confidence
// is a real field the advice step writes into the audit data, so it needs
// no backend change to reach here.
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]
.filter(([base, f]) => MONEY.has(base) && f.value)
.map(([base, f]) => [base.replace(/_/g, ' '), '₹' + Number(f.value).toLocaleString('en-IN')]),
@ -441,12 +438,10 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
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) ? (
{it.kind === 'ai' && it.reasoning.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}