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}
: null} + + + + {open ? ( +