Compare commits

...

2 Commits

Author SHA1 Message Date
900f34d11e 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>
2026-09-09 00:46:42 +05:30
8e86fa7a0a console: the chain of thought is on the page, not behind a link
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) <noreply@anthropic.com>
2026-09-09 00:33:18 +05:30
3 changed files with 208 additions and 11 deletions

View File

@ -0,0 +1,59 @@
.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__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); }

View File

@ -0,0 +1,87 @@
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 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'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.
*
* 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.
*/
function findingOf(text) {
const m = String(text).match(/^(.{24,200}?[.!?])(\s|$)/)
return m ? m[1].trim() : null
}
export default function ChainOfThought({ reasoning, decided, confidence }) {
const [open, setOpen] = useState(false)
const primary = reasoning.length ? reasoning[0][1] : ''
const finding = findingOf(primary)
const steps = reasoning.length + 1 // each reasoning rung, plus Decided
return (
<div className="cot">
{finding ? <p className="cot__finding">{finding}</p> : null}
<button
type="button"
className="cot__toggle"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
<svg className="cot__brain" viewBox="0 0 16 16" aria-hidden="true">
<path d="M6 2.5a2 2 0 0 0-2 2 2 2 0 0 0-1 3.7A2 2 0 0 0 4 11.5a2 2 0 0 0 2 2M10 2.5a2 2 0 0 1 2 2 2 2 0 0 1 1 3.7 2 2 0 0 1-1 3.3 2 2 0 0 1-2 2M8 3v10"
fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
</svg>
Chain of thought
<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" />
</svg>
</button>
{open ? (
<ol className="cot__steps">
{reasoning.map(([label, text], i) => (
<li className="cot__step" key={label}>
<span className="cot__mark">{i === 0 ? 'Reasoned' : label}</span>
<div className="cot__b">{text}</div>
</li>
))}
<li className="cot__step">
<span className="cot__mark">Decided</span>
<div className="cot__b">
<b>{decided.what}</b>
{decided.stage ? <> moved to <b>{decided.stage}</b></> : null}
{confidence != null ? (
<div className="cot__foot">
<span className="cot__conf">
{Math.round(confidence * (confidence <= 1 ? 100 : 1))}% confidence
</span>
</div>
) : null}
</div>
</li>
</ol>
) : null}
</div>
)
}

View File

@ -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,22 @@ 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, 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
})(),
figures: [...byBase]
.filter(([base, f]) => MONEY.has(base) && f.value)
.map(([base, f]) => [base.replace(/_/g, ' '), '₹' + Number(f.value).toLocaleString('en-IN')]),
@ -315,7 +340,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 +432,33 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
</div>
) : 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 ? (
<ChainOfThought
reasoning={it.reasoning}
confidence={it.confidence}
decided={{ what: it.what, stage: it.stage ? it.stage.name : null }}
/>
) : 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]) => (
<div className="tl__say" key={label}>
<span className="tl__saylabel">{label}</span>
<ClampText text={String(v)} title={`${label}${it.what}`} lines={2} threshold={120} />
</div>
))
: null}
{/* What was said to and by the customer — always a plain quote. */}
{it.conversation.map(([label, v]) => (
<div className="tl__say" key={label}>
<span className="tl__saylabel">{label}</span>
<ClampText text={String(v)} title={`${label}${it.what}`} lines={2} threshold={120} />