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}