feat: AI trail tab — who did what, which tools, what it reasoned
The console showed the agent's OUTPUT but never showed that an agent produced
it. On a screen full of numbers a viewer cannot tell which came from a model,
which from a rule, and which from a person. This makes it visible.
Second tab on the file: "AI trail", beside "Evidence & decision".
* WHO DID WHAT — the real audit trail from /view/audit, one row per
completed step, each attributed. "Credit Assessor AI" appears there
because the agent submitted the activity under its OWN JWT and the
platform recorded it, not because this component says so. Agent rows get
a bot glyph and a violet chip; human rows get a person and no chip, plus
a count: "1 of 6 by an agent".
* TOOLS IT CALLS — the three deterministic SQL tools, named, each with the
figures it returns for this file shown against the threshold it was
tested on. Spread beyond tolerance goes amber.
* WHAT IT REASONED — the narrative, citations and deviation flag verbatim,
violet as everywhere else, closing with the explicit statement that it
computed no ratio and chose no outcome.
Deliberately NOT shown: the per-step trace (individual model calls, raw tool
payloads, durations, tokens, cost). It exists in
aiemployee.tbl_ai_trace_events and is served by
/ai-employee/monitor/instances/:id/trace, but that endpoint requires an
ORG-SCOPED token and the app JWT carries no org claim — it answers 403 "no
organization scope on this account" for every user of this console. A footnote
says so and points at Studio. Rendering a plausible waterfall from guesses
would be the one dishonest thing on a screen whose whole purpose is showing
what actually happened.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8e846d6df3
commit
11da9abed5
274
src/components/AiDecisionPanel.tsx
Normal file
274
src/components/AiDecisionPanel.tsx
Normal file
@ -0,0 +1,274 @@
|
||||
import { Bot, Calculator, CheckCircle2, Cpu, ScrollText, User } from 'lucide-react';
|
||||
import { Badge, Card, Facts } from './core';
|
||||
import type { AuditEntry, Row } from '../api/types';
|
||||
import { POLICY } from '../api/config';
|
||||
import { dateTime, money, moneyShort, num, pct, ratio, str } from '../format';
|
||||
|
||||
/**
|
||||
* Who did what on this file, and what the agent actually saw.
|
||||
*
|
||||
* THE CLAIM THIS PANEL HAS TO MAKE, and the reason it exists: on a screen full
|
||||
* of numbers, a viewer cannot tell which came from a model and which from a
|
||||
* rule, or which steps a person took. Saying it out loud is weaker than showing
|
||||
* it — so every row here is attributed, and the attribution is read from the
|
||||
* workflow's own audit trail rather than asserted by this component.
|
||||
*
|
||||
* WHAT IS REAL AND WHAT IS NOT, because that distinction matters more here than
|
||||
* anywhere else in the app:
|
||||
*
|
||||
* * The timeline is the ACTUAL audit trail (`/view/audit`). "Credit
|
||||
* Assessor AI" appears there because the agent submitted the activity under
|
||||
* its own JWT and the platform recorded it — not because this file says so.
|
||||
* * The agent's analysis, citations and deviation flag are the ACTUAL values
|
||||
* it wrote, read off the instance.
|
||||
* * The tool figures are the ACTUAL values on the instance, labelled with the
|
||||
* tool that computes them. The mapping is fixed in 09_agent_tools.sql and
|
||||
* each tool's SQL was verified to agree with the gate.
|
||||
*
|
||||
* NOT shown, and deliberately not faked: the per-step trace — individual LLM
|
||||
* calls, raw tool payloads, durations, token counts, cost. That lives in
|
||||
* `aiemployee.tbl_ai_trace_events` and is served by
|
||||
* `/ai-employee/monitor/instances/:id/trace`, which requires an ORG-SCOPED
|
||||
* token. The app JWT has no org claim (its claims are user_id / name / email /
|
||||
* sub / exp / iat), so that endpoint answers 403 "no organization scope on this
|
||||
* account" for every user of this console. Rendering a plausible-looking
|
||||
* waterfall from guesses would be the one dishonest thing on a screen whose
|
||||
* whole job is showing what actually happened.
|
||||
*/
|
||||
|
||||
/** The roles that belong to an agent rather than a person. */
|
||||
const AI_ROLES = ['ai_credit_assessor', 'ai_loan_advisor'];
|
||||
|
||||
function isAgent(e: AuditEntry): boolean {
|
||||
const roles = e.user_roles ?? [];
|
||||
return roles.some((r) => AI_ROLES.includes(r));
|
||||
}
|
||||
|
||||
/** Audit rows worth showing a human. The commit row for an activity carries the
|
||||
* state it reached; the rest is engine bookkeeping. */
|
||||
function meaningful(entries: AuditEntry[]): AuditEntry[] {
|
||||
return entries
|
||||
.filter((e) => {
|
||||
const st = str(e.execution_state);
|
||||
// A commit row's execution_state is the state uid it landed on.
|
||||
return st.startsWith('hdfc-state-');
|
||||
})
|
||||
.slice()
|
||||
.reverse();
|
||||
}
|
||||
|
||||
export function AiDecisionPanel({ row, audit }: { row: Row; audit: AuditEntry[] }) {
|
||||
const steps = meaningful(audit);
|
||||
const agentSteps = steps.filter(isAgent);
|
||||
|
||||
const itr = num(row.itr_declared_income);
|
||||
const gst = num(row.gst_turnover_12m);
|
||||
const bank = num(row.bank_credits_12m);
|
||||
const variance = num(row.income_variance_pct);
|
||||
const dscr = num(row.dscr);
|
||||
const maxElig = num(row.max_eligible_amount);
|
||||
const analysis = str(row.ai_analysis);
|
||||
const citations = str(row.assessment_citations);
|
||||
const deviation = str(row.deviation_flags);
|
||||
const isPersonal = str(row.loan_product_family) === 'personal';
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* ---------------- who did what ---------------- */}
|
||||
<Card
|
||||
title={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<ScrollText className="h-4 w-4 text-muted" />
|
||||
Who did what
|
||||
</span>
|
||||
}
|
||||
subtitle="From the workflow's audit trail — not from this screen's opinion"
|
||||
actions={
|
||||
agentSteps.length > 0 ? (
|
||||
<Badge tone="violet">
|
||||
{agentSteps.length} of {steps.length} by an agent
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge tone="slate">no agent steps yet</Badge>
|
||||
)
|
||||
}
|
||||
>
|
||||
{steps.length === 0 ? (
|
||||
<p className="px-4 py-4 text-sm text-faint">No completed steps recorded yet.</p>
|
||||
) : (
|
||||
<ol className="px-4 py-2">
|
||||
{steps.map((e, i) => {
|
||||
const agent = isAgent(e);
|
||||
return (
|
||||
<li
|
||||
key={`${e.id}-${i}`}
|
||||
className="flex items-start gap-3 border-b border-line py-3 last:border-0"
|
||||
>
|
||||
<span
|
||||
className={`mt-0.5 shrink-0 rounded-full p-1.5 ring-1 ring-inset ${
|
||||
agent
|
||||
? 'bg-ai-soft text-ai ring-ai-edge'
|
||||
: 'bg-panel2 text-muted ring-line'
|
||||
}`}
|
||||
>
|
||||
{agent ? <Bot className="h-3.5 w-3.5" /> : <User className="h-3.5 w-3.5" />}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold text-ink">
|
||||
{str(e.activity_name) || str(e.activity_id)}
|
||||
</span>
|
||||
{agent && <Badge tone="violet">agent</Badge>}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-muted">
|
||||
{str(e.user_name) || 'System'}
|
||||
{(e.user_roles ?? []).length > 0 && (
|
||||
<span className="text-faint">
|
||||
{' · '}
|
||||
{(e.user_roles ?? []).join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs text-faint">{dateTime(e.created_at)}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* ---------------- the tools ----------------
|
||||
Deterministic SQL the assessor calls instead of doing arithmetic. The
|
||||
figures are the ones on the file; the attribution is the fixed mapping
|
||||
from 09_agent_tools.sql. */}
|
||||
<Card
|
||||
title={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Calculator className="h-4 w-4 text-muted" />
|
||||
Tools the assessor calls
|
||||
</span>
|
||||
}
|
||||
subtitle={`Deterministic SQL, same formulas as the gate · policy ${POLICY.version}`}
|
||||
>
|
||||
<div className="space-y-3 px-4 py-4">
|
||||
<ToolBlock
|
||||
name="hdfc_income_crosscheck"
|
||||
purpose="Compares the three turnover sources and returns the spread"
|
||||
rows={[
|
||||
{ label: 'Declared to tax (ITR)', value: moneyShort(itr) },
|
||||
{ label: 'Declared to GST', value: moneyShort(gst) },
|
||||
{ label: 'Observed in banking', value: moneyShort(bank) },
|
||||
{
|
||||
label: 'Spread',
|
||||
value: `${pct(variance)} against ${POLICY.varianceTolerancePct}% tolerance`,
|
||||
tone:
|
||||
variance === null
|
||||
? undefined
|
||||
: variance > POLICY.varianceTolerancePct
|
||||
? ('amber' as const)
|
||||
: ('emerald' as const),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<ToolBlock
|
||||
name="hdfc_capacity_check"
|
||||
purpose="EMI, coverage ratio and the ceiling the file can carry"
|
||||
rows={
|
||||
isPersonal
|
||||
? [
|
||||
{ label: 'FOIR', value: `${ratio((num(row.foir) ?? 0) * 100, 1)}% against ${(POLICY.foirCeiling * 100).toFixed(0)}% ceiling` },
|
||||
{ label: 'Max eligible', value: `${moneyShort(maxElig)} against ${moneyShort(row.requested_amount)} requested` },
|
||||
]
|
||||
: [
|
||||
{ label: 'DSCR', value: `${ratio(dscr)} against ${POLICY.dscrFloor} floor` },
|
||||
{ label: 'Max eligible', value: `${moneyShort(maxElig)} against ${moneyShort(row.requested_amount)} requested` },
|
||||
{ label: 'Indicative EMI', value: money(row.offered_emi ?? null) },
|
||||
]
|
||||
}
|
||||
/>
|
||||
<ToolBlock
|
||||
name="hdfc_standing_and_conduct"
|
||||
purpose="Bureau standing and account conduct against each ceiling"
|
||||
rows={[
|
||||
{ label: 'Bureau score', value: `${str(row.bureau_score) || '—'} against floor ${POLICY.bureauFloor}` },
|
||||
...(isPersonal
|
||||
? []
|
||||
: [{ label: 'CIBIL MSME Rank', value: `CMR-${str(row.cmr_rank) || '—'} against ceiling CMR-${POLICY.cmrCeiling}` }]),
|
||||
{ label: 'Live DPD', value: `${str(row.live_dpd_max) || '—'} d against ${POLICY.dpdCeiling} d ceiling` },
|
||||
{ label: 'Cheque returns', value: `${str(row.bounce_count) || '0'} against ${POLICY.bounceCeiling} threshold` },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* ---------------- what the agent wrote ---------------- */}
|
||||
{analysis && (
|
||||
<section className="lift rounded-lg border border-ai-edge bg-ai-soft">
|
||||
<header className="flex flex-wrap items-center gap-2 border-b border-ai-edge px-4 py-3">
|
||||
<Cpu className="h-4 w-4 text-ai" />
|
||||
<h2 className="text-sm font-semibold text-ai">What the agent reasoned</h2>
|
||||
{deviation && <Badge tone="violet">flagged: {deviation}</Badge>}
|
||||
<span className="ml-auto text-[11px] text-ai/70">narrative, not verdict</span>
|
||||
</header>
|
||||
<div className="space-y-4 px-4 py-4">
|
||||
<p className="text-sm leading-relaxed whitespace-pre-wrap text-ink">{analysis}</p>
|
||||
{citations && (
|
||||
<Facts cols={1} rows={[{ label: 'Policy it relied on', value: citations, wide: true }]} />
|
||||
)}
|
||||
<div className="flex items-start gap-2 border-t border-ai-edge pt-3">
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-ai" />
|
||||
<p className="text-xs text-ai">
|
||||
The agent wrote the account above and flagged the deviation. It did not compute any
|
||||
ratio and did not choose the outcome — the figures come from the tools and the
|
||||
approve / refer / reject decision comes from the rules engine, after it submitted.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Honest about the gap. Better than a fabricated waterfall. */}
|
||||
<p className="px-1 text-xs text-faint">
|
||||
Per-step detail — individual model calls, raw tool payloads, durations and cost — is
|
||||
recorded by the platform against this instance but is not readable with an app sign-in.
|
||||
It requires an org-scoped token, so it is available from Studio rather than here.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolBlock({
|
||||
name,
|
||||
purpose,
|
||||
rows,
|
||||
}: {
|
||||
name: string;
|
||||
purpose: string;
|
||||
rows: Array<{ label: string; value: string; tone?: 'amber' | 'emerald' }>;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border border-line bg-panel2 px-3 py-2.5">
|
||||
<div className="flex flex-wrap items-baseline gap-2">
|
||||
<code className="rounded bg-panel px-1.5 py-0.5 text-[11px] font-semibold text-brand ring-1 ring-inset ring-line">
|
||||
{name}
|
||||
</code>
|
||||
<span className="text-xs text-muted">{purpose}</span>
|
||||
</div>
|
||||
<dl className="mt-2 grid grid-cols-1 gap-x-6 gap-y-1.5 sm:grid-cols-2">
|
||||
{rows.map((r) => (
|
||||
<div key={r.label} className="flex items-baseline justify-between gap-3">
|
||||
<dt className="text-xs text-faint">{r.label}</dt>
|
||||
<dd
|
||||
className={`tnum text-xs font-semibold ${
|
||||
r.tone === 'amber' ? 'text-warn' : r.tone === 'emerald' ? 'text-good' : 'text-ink'
|
||||
}`}
|
||||
>
|
||||
{r.value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -7,6 +7,7 @@ import type { Action } from '../api/config';
|
||||
import { Badge, Button, Card, ErrorNote, Facts, Spinner } from '../components/core';
|
||||
import { StageBadge, StageRail } from '../components/Stage';
|
||||
import { EvidencePanel } from '../components/EvidencePanel';
|
||||
import { AiDecisionPanel } from '../components/AiDecisionPanel';
|
||||
import { ActivityForm } from '../components/ActivityForm';
|
||||
import type { Row } from '../api/types';
|
||||
import {
|
||||
@ -31,6 +32,10 @@ export function ApplicationScreen() {
|
||||
const { id = '' } = useParams();
|
||||
const { client, user } = useZino();
|
||||
const [openAction, setOpenAction] = useState<Action | null>(null);
|
||||
// Two views of the same file: the evidence a decision rests on, and the record
|
||||
// of who produced it. Tabs rather than one long column — the second is what a
|
||||
// sceptical reader asks for, and it should be one click away, not a scroll.
|
||||
const [tab, setTab] = useState<'evidence' | 'ai'>('evidence');
|
||||
|
||||
// The detail view is the right endpoint for one instance. The record view
|
||||
// filtered on instance_id is kept as a fallback because it returns the same
|
||||
@ -56,6 +61,11 @@ export function ApplicationScreen() {
|
||||
15000,
|
||||
);
|
||||
|
||||
// The audit trail is what makes the AI attribution real rather than asserted:
|
||||
// "Credit Assessor AI" appears because the platform recorded the agent
|
||||
// submitting the activity under its own identity.
|
||||
const audit = useQuery(() => client.audit(id), [id], true, 15000);
|
||||
|
||||
const row = file.data;
|
||||
const stage = str(row?.current_state_name);
|
||||
const actions = actionsFor(stage, user?.roles);
|
||||
@ -111,7 +121,30 @@ export function ApplicationScreen() {
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||
{/* Evidence first — see the note at the top of this file. */}
|
||||
<div className="space-y-4 xl:col-span-2">
|
||||
<EvidencePanel row={row} />
|
||||
<div className="flex gap-1 rounded-lg border border-line bg-panel p-1">
|
||||
{([
|
||||
['evidence', 'Evidence & decision'],
|
||||
['ai', 'AI trail'],
|
||||
] as const).map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setTab(key)}
|
||||
className={`flex-1 rounded-md px-3 py-2 text-sm font-semibold transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand ${
|
||||
tab === key
|
||||
? 'bg-brand text-brand-fg'
|
||||
: 'text-muted hover:bg-panel2 hover:text-ink'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'evidence' ? (
|
||||
<EvidencePanel row={row} />
|
||||
) : (
|
||||
<AiDecisionPanel row={row} audit={audit.data ?? []} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user