Compare commits
2 Commits
a49ae3d86c
...
900f34d11e
| Author | SHA1 | Date | |
|---|---|---|---|
| 900f34d11e | |||
| 8e86fa7a0a |
59
src/components/ChainOfThought.css
Normal file
59
src/components/ChainOfThought.css
Normal 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); }
|
||||||
|
|
||||||
87
src/components/ChainOfThought.jsx
Normal file
87
src/components/ChainOfThought.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ import { useState } from 'react'
|
|||||||
import { useZino } from '../api/provider.jsx'
|
import { useZino } from '../api/provider.jsx'
|
||||||
import AgentChip from './AgentChip.jsx'
|
import AgentChip from './AgentChip.jsx'
|
||||||
import ClampText from './ClampText.jsx'
|
import ClampText from './ClampText.jsx'
|
||||||
|
import ChainOfThought from './ChainOfThought.jsx'
|
||||||
import { AGENTS } from '../api/agents.js'
|
import { AGENTS } from '../api/agents.js'
|
||||||
import { APP_ID, STAGES, baseFieldId } from '../api/config.js'
|
import { APP_ID, STAGES, baseFieldId } from '../api/config.js'
|
||||||
import './Timeline.css'
|
import './Timeline.css'
|
||||||
@ -23,23 +24,34 @@ import './Timeline.css'
|
|||||||
* that did appear was an accident (a DATA_UPDATE row happens to write the
|
* that did appear was an accident (a DATA_UPDATE row happens to write the
|
||||||
* unsuffixed key).
|
* 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'],
|
['attribution_reason', 'Attribution'],
|
||||||
['eligibility_reason', 'Eligibility'],
|
['eligibility_reason', 'Eligibility'],
|
||||||
['dedupe_match_ref', 'Duplicate of'],
|
['ai_recommendation_rationale', 'Cover advice'],
|
||||||
['contact_notes', 'Call'],
|
|
||||||
['ai_recommendation_rationale', 'Recommendation'],
|
|
||||||
['quoted_breakup', 'How the premium was reached'],
|
|
||||||
['kyc_mismatch_notes', 'KYC'],
|
['kyc_mismatch_notes', 'KYC'],
|
||||||
['referral_analysis', 'Referral analysis'],
|
['referral_analysis', 'Referral analysis'],
|
||||||
['uw_decision_notes', 'Underwriting decision'],
|
['uw_decision_notes', 'Underwriting'],
|
||||||
|
['contact_notes', 'Call'],
|
||||||
['documents_notes', 'Documents'],
|
['documents_notes', 'Documents'],
|
||||||
['customer_reply', 'The customer said'],
|
['quoted_breakup', 'How the premium was reached'],
|
||||||
['customer_answer', 'We replied'],
|
|
||||||
['lost_reason', 'Why it was dropped'],
|
['lost_reason', 'Why it was dropped'],
|
||||||
['resume_note', 'Why now'],
|
['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'])
|
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,
|
stage: moved ? stage : null,
|
||||||
when: when(r.created_at),
|
when: when(r.created_at),
|
||||||
docs,
|
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])
|
.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() !== ''),
|
||||||
|
// 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]
|
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')]),
|
||||||
@ -315,7 +340,7 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
|
|||||||
last.when = it.when
|
last.when = it.when
|
||||||
// Keep the newest line as the preview — an operator scanning the trail
|
// Keep the newest line as the preview — an operator scanning the trail
|
||||||
// wants where the conversation GOT to, not where it started.
|
// 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)
|
last.docs = last.docs.concat(it.docs)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@ -407,7 +432,33 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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}>
|
<div className="tl__say" key={label}>
|
||||||
<span className="tl__saylabel">{label}</span>
|
<span className="tl__saylabel">{label}</span>
|
||||||
<ClampText text={String(v)} title={`${label} — ${it.what}`} lines={2} threshold={120} />
|
<ClampText text={String(v)} title={`${label} — ${it.what}`} lines={2} threshold={120} />
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user