From 8e86fa7a0a4bca171ccb2bc1da52279701e715b2 Mon Sep 17 00:00:00 2001 From: Yashas Date: Wed, 9 Sep 2026 00:33:18 +0530 Subject: [PATCH] console: the chain of thought is on the page, not behind a link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/components/ChainOfThought.css | 69 +++++++++++++++ src/components/ChainOfThought.jsx | 142 ++++++++++++++++++++++++++++++ src/components/Timeline.jsx | 78 +++++++++++++--- 3 files changed, 278 insertions(+), 11 deletions(-) create mode 100644 src/components/ChainOfThought.css create mode 100644 src/components/ChainOfThought.jsx diff --git a/src/components/ChainOfThought.css b/src/components/ChainOfThought.css new file mode 100644 index 0000000..1468237 --- /dev/null +++ b/src/components/ChainOfThought.css @@ -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; } diff --git a/src/components/ChainOfThought.jsx b/src/components/ChainOfThought.jsx new file mode 100644 index 0000000..22fd699 --- /dev/null +++ b/src/components/ChainOfThought.jsx @@ -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 ( +
+ {finding ?

{finding}

: null} + + + + {open ? ( +
    + {toolPills.length ? ( +
  1. + Checked +
    + {toolPills.map((t, i) => ( + + {t.name} + + ))} +
    +
  2. + ) : null} + + {reasoning.map(([label, text], i) => ( +
  3. + {i === 0 ? 'Reasoned' : label} +
    {text}
    +
  4. + ))} + +
  5. + Decided +
    + {decided.what} + {decided.stage ? <> — moved to {decided.stage} : null} + {(confidence != null || model) ? ( +
    + {confidence != null ? {Math.round(confidence * (confidence <= 1 ? 100 : 1))}% confidence : null} + {model ? {model} : null} +
    + ) : null} +
    +
  6. +
+ ) : null} +
+ ) +} diff --git a/src/components/Timeline.jsx b/src/components/Timeline.jsx index 4a42bd4..9362457 100644 --- a/src/components/Timeline.jsx +++ b/src/components/Timeline.jsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { useZino } from '../api/provider.jsx' import AgentChip from './AgentChip.jsx' import ClampText from './ClampText.jsx' +import ChainOfThought from './ChainOfThought.jsx' import { AGENTS } from '../api/agents.js' import { APP_ID, STAGES, baseFieldId } from '../api/config.js' import './Timeline.css' @@ -23,23 +24,34 @@ import './Timeline.css' * that did appear was an accident (a DATA_UPDATE row happens to write the * 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'], ['eligibility_reason', 'Eligibility'], - ['dedupe_match_ref', 'Duplicate of'], - ['contact_notes', 'Call'], - ['ai_recommendation_rationale', 'Recommendation'], - ['quoted_breakup', 'How the premium was reached'], + ['ai_recommendation_rationale', 'Cover advice'], ['kyc_mismatch_notes', 'KYC'], ['referral_analysis', 'Referral analysis'], - ['uw_decision_notes', 'Underwriting decision'], + ['uw_decision_notes', 'Underwriting'], + ['contact_notes', 'Call'], ['documents_notes', 'Documents'], - ['customer_reply', 'The customer said'], - ['customer_answer', 'We replied'], + ['quoted_breakup', 'How the premium was reached'], ['lost_reason', 'Why it was dropped'], ['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']) /** @@ -261,9 +273,25 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall }) stage: moved ? stage : null, when: when(r.created_at), 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]) .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] .filter(([base, f]) => MONEY.has(base) && f.value) .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 // Keep the newest line as the preview — an operator scanning the trail // 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) continue } @@ -407,7 +435,35 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall }) ) : 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) ? ( + + ) : 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]) => ( +
+ {label} + +
+ )) + : null} + + {/* What was said to and by the customer — always a plain quote. */} + {it.conversation.map(([label, v]) => (
{label}