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 { 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__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__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__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 * 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 * 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 * that. Before this, the reasoning existed (the employees write it as workflow
* platform records the tools they called in tbl_ai_decisions) but the console * fields, so it is already in the audit trail) but the console showed only the
* showed only the conclusion. An operator either trusted it blind or opened * conclusion. An operator either trusted it blind or went digging. Neither is
* the raw record. Neither is oversight. * oversight.
* *
* The chain reads the way the employee actually ran: what it CHECKED (the * The chain reads the way the employee's own record reads: what it REASONED and
* tools it called, each marked for whether it came back with anything), what * what it DECIDED. It is assembled from real fields already on the step the
* it REASONED, and what it DECIDED. It is assembled from real fields, never * employee's own words for the reasoning, the activity it committed and the
* narrated after the fact the reasoning is the employee's own text, the * state it moved the lead to for the decision never narrated after the fact.
* tools are the calls it actually made, the decision is the activity it * A step may carry more than one reasoning field (attribution AND eligibility,
* committed and the state it moved the lead to. * say); each is its own rung.
* *
* A tool that returned nothing is drawn with a hollow marker. That is not * The finding the first sentence, since the employees write the conclusion
* cosmetic: a licence check that came back empty and was read as "unlicensed" * first is lifted out and stays visible, so the "why" can be read without
* is exactly how a real lead was wrongly dropped, and an operator scanning the * opening the chain.
* 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) { function findingOf(text) {
const m = String(text).match(/^(.{24,200}?[.!?])(\s|$)/) const m = String(text).match(/^(.{24,200}?[.!?])(\s|$)/)
return m ? m[1].trim() : null return m ? m[1].trim() : null
} }
/** export default function ChainOfThought({ reasoning, decided, confidence }) {
* 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 [open, setOpen] = useState(false)
const toolPills = tools && tools.length ? distinctTools(tools) : []
const primary = reasoning.length ? reasoning[0][1] : '' const primary = reasoning.length ? reasoning[0][1] : ''
const finding = findingOf(primary) const finding = findingOf(primary)
// Only the steps that actually have something behind them. A step with no const steps = reasoning.length + 1 // each reasoning rung, plus Decided
// 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 ( return (
<div className="cot"> <div className="cot">
@ -93,7 +50,7 @@ export default function ChainOfThought({ reasoning, tools, decided, confidence,
fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" /> fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
</svg> </svg>
Chain of thought 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"> <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" <path d="M4 2.5 8 6l-4 3.5" fill="none" stroke="currentColor" strokeWidth="1.6"
strokeLinecap="round" strokeLinejoin="round" /> strokeLinecap="round" strokeLinejoin="round" />
@ -102,19 +59,6 @@ export default function ChainOfThought({ reasoning, tools, decided, confidence,
{open ? ( {open ? (
<ol className="cot__steps"> <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) => ( {reasoning.map(([label, text], i) => (
<li className="cot__step" key={label}> <li className="cot__step" key={label}>
<span className="cot__mark">{i === 0 ? 'Reasoned' : label}</span> <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"> <div className="cot__b">
<b>{decided.what}</b> <b>{decided.what}</b>
{decided.stage ? <> moved to <b>{decided.stage}</b></> : null} {decided.stage ? <> moved to <b>{decided.stage}</b></> : null}
{(confidence != null || model) ? ( {confidence != null ? (
<div className="cot__foot"> <div className="cot__foot">
{confidence != null ? <span className="cot__conf">{Math.round(confidence * (confidence <= 1 ? 100 : 1))}% confidence</span> : null} <span className="cot__conf">
{model ? <span className="cot__model">{model}</span> : null} {Math.round(confidence * (confidence <= 1 ? 100 : 1))}% confidence
</span>
</div> </div>
) : null} ) : null}
</div> </div>

View File

@ -281,17 +281,14 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
conversation: CONVERSATION conversation: CONVERSATION
.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() !== ''),
// The decision's confidence and model, when the step recorded them. The // The decision's confidence, when the step recorded it ai_recommendation_confidence
// confidence field is written on the advice step; ai_tools/ai_model come // is a real field the advice step writes into the audit data, so it needs
// from the audit row once view-service joins tbl_ai_decisions (until then // no backend change to reach here.
// they are absent and the chain simply shows two rungs instead of three).
confidence: (() => { confidence: (() => {
const c = byBase.get('ai_recommendation_confidence')?.value const c = byBase.get('ai_recommendation_confidence')?.value
const n = c == null ? null : Number(c) const n = c == null ? null : Number(c)
return Number.isFinite(n) ? n : null 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')]),
@ -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", what it decided. This is the answer to "why did it do that",
which is the first thing a person needs before trusting or which is the first thing a person needs before trusting or
overriding a machine. */} overriding a machine. */}
{it.kind === 'ai' && (it.reasoning.length || it.tools.length) ? ( {it.kind === 'ai' && it.reasoning.length ? (
<ChainOfThought <ChainOfThought
reasoning={it.reasoning} reasoning={it.reasoning}
tools={it.tools}
confidence={it.confidence} confidence={it.confidence}
model={it.model}
decided={{ what: it.what, stage: it.stage ? it.stage.name : null }} decided={{ what: it.what, stage: it.stage ? it.stage.name : null }}
/> />
) : null} ) : null}