From c0059194e47de1b3188c89085e43b1494c5744bd Mon Sep 17 00:00:00 2001 From: Yashas Date: Tue, 18 Aug 2026 11:25:52 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20chain-of-custody=20trace=20=E2=80=94=20?= =?UTF-8?q?every=20step,=20attributed=20and=20expandable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AI trail tab listed who did what but nothing about WHAT each step did, and the agent's contribution was the one step with no visible action behind it. New InstanceTrace: one chronological list of every completed step on a file, read from the workflow's own audit trail. * Each row names the actor and role, with a bot glyph for the agent and a person for a human, plus a header count — "1 by an agent, 3 by people". * ELAPSED TIME between steps, and this is the point: humans act seconds apart because someone is clicking, and the agent's row shows a minute and a half of unattended work. That number is the demo. * Expand a row to see exactly what was submitted — labelled, typed, from view-service, so a human's remarks and the agent's analysis are inspected the same way. * Expanding the AGENT's row also shows the three checks it ran before writing, each as asked/returned, with the breached one in amber. Removed the standalone "tools the assessor calls" card: the same information now sits attached to the step where it happened, which is where a reader looks for it, and showing it twice was clutter. Still not shown, still not invented: individual model calls, the SQL each tool ran, tokens and cost. That is in aiemployee.tbl_ai_trace_events and needs either an org-scoped token or an rdbms bridge. Tool RESULTS are shown and attributed; raw payloads are not fabricated. Co-Authored-By: Claude Opus 5 (1M context) --- src/api/types.ts | 3 + src/components/AiDecisionPanel.tsx | 202 +--------------------- src/components/InstanceTrace.tsx | 258 +++++++++++++++++++++++++++++ 3 files changed, 266 insertions(+), 197 deletions(-) create mode 100644 src/components/InstanceTrace.tsx diff --git a/src/api/types.ts b/src/api/types.ts index a9c0c86..cd079d6 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -73,6 +73,9 @@ export interface AuditEntry { state_name?: string; created_at?: string; performed_at?: string; + /** What was submitted, already labelled and typed by view-service. This is + * what lets the trace show WHAT each step did, not just who did it. */ + fields?: Array<{ field_id: string; label: string; data_type: string; value: string }>; ai_reasoning?: string | null; ai_confidence?: number | null; ai_model?: string | null; diff --git a/src/components/AiDecisionPanel.tsx b/src/components/AiDecisionPanel.tsx index c6e6e45..21bc775 100644 --- a/src/components/AiDecisionPanel.tsx +++ b/src/components/AiDecisionPanel.tsx @@ -1,8 +1,8 @@ -import { Bot, Calculator, CheckCircle2, Cpu, ScrollText, User } from 'lucide-react'; -import { Badge, Card, Facts } from './core'; +import { CheckCircle2, Cpu } from 'lucide-react'; +import { Badge, Facts } from './core'; +import { InstanceTrace } from './InstanceTrace'; import type { AuditEntry, Row } from '../api/types'; -import { POLICY } from '../api/config'; -import { dateTime, money, moneyShort, num, pct, ratio, str } from '../format'; +import { str } from '../format'; /** * Who did what on this file, and what the agent actually saw. @@ -36,171 +36,14 @@ import { dateTime, money, moneyShort, num, pct, ratio, str } from '../format'; * 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 (
- {/* ---------------- who did what ---------------- */} - - - Who did what - - } - subtitle="From the workflow's audit trail — not from this screen's opinion" - actions={ - agentSteps.length > 0 ? ( - - {agentSteps.length} of {steps.length} by an agent - - ) : ( - no agent steps yet - ) - } - > - {steps.length === 0 ? ( -

No completed steps recorded yet.

- ) : ( -
    - {steps.map((e, i) => { - const agent = isAgent(e); - return ( -
  1. - - {agent ? : } - -
    -
    - - {str(e.activity_name) || str(e.activity_id)} - - {agent && agent} -
    -
    - {str(e.user_name) || 'System'} - {(e.user_roles ?? []).length > 0 && ( - - {' · '} - {(e.user_roles ?? []).join(', ')} - - )} -
    -
    - {dateTime(e.created_at)} -
  2. - ); - })} -
- )} -
- - {/* ---------------- 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. */} - - - Tools the assessor calls - - } - subtitle={`Deterministic SQL, same formulas as the gate · policy ${POLICY.version}`} - > -
- POLICY.varianceTolerancePct - ? ('amber' as const) - : ('emerald' as const), - }, - ]} - /> - - -
-
+ {/* ---------------- what the agent wrote ---------------- */} {analysis && ( @@ -237,38 +80,3 @@ export function AiDecisionPanel({ row, audit }: { row: Row; audit: AuditEntry[]
); } - -function ToolBlock({ - name, - purpose, - rows, -}: { - name: string; - purpose: string; - rows: Array<{ label: string; value: string; tone?: 'amber' | 'emerald' }>; -}) { - return ( -
-
- - {name} - - {purpose} -
-
- {rows.map((r) => ( -
-
{r.label}
-
- {r.value} -
-
- ))} -
-
- ); -} diff --git a/src/components/InstanceTrace.tsx b/src/components/InstanceTrace.tsx new file mode 100644 index 0000000..a5d63a8 --- /dev/null +++ b/src/components/InstanceTrace.tsx @@ -0,0 +1,258 @@ +import { useState } from 'react'; +import { Bot, ChevronRight, Clock, User, Wrench } from 'lucide-react'; +import { Badge } from './core'; +import type { AuditEntry, Row } from '../api/types'; +import { POLICY } from '../api/config'; +import { moneyShort, num, pct, ratio, str } from '../format'; + +/** + * The whole life of one file, in order, with every step attributed. + * + * WHAT THIS IS FOR. Anyone can be told "an agent assessed this". A trace lets + * them check it: each step names who performed it, when, how long after the + * previous one, and exactly what they submitted. The agent's step sits in the + * same list as the humans', on the same terms, because that is the honest + * claim — it is a member of staff on a chain of custody, not a black box + * bolted to the side. + * + * Every row is read from the workflow's own audit trail (`/view/audit`). + * "Credit Assessor AI" appears because the platform recorded the agent + * submitting under its own JWT. Nothing on this screen is asserted by the + * frontend. + * + * THE GAP THAT TELLS THE STORY is the elapsed time on the agent's row. On a + * typical file the humans act seconds apart because a person is clicking; the + * agent's row shows a minute and a half of unattended work. That number is the + * demo, and it is why elapsed time is shown at all. + * + * What is NOT here: the agent's internal steps — individual model calls, the + * SQL each tool ran, tokens, cost. That lives in + * aiemployee.tbl_ai_trace_events and needs either an org-scoped token or the + * rdbms bridge in 10_trace_view.sql. The tool RESULTS are shown, attributed; + * the raw payloads are not invented. + */ + +const AI_ROLES = ['ai_credit_assessor', 'ai_loan_advisor']; + +function isAgent(e: AuditEntry): boolean { + return (e.user_roles ?? []).some((r) => AI_ROLES.includes(r)); +} + +/** Commit rows only. An activity's commit row carries the state it reached; + * everything else in the trail is engine bookkeeping. */ +function steps(entries: AuditEntry[]): AuditEntry[] { + return entries + .filter((e) => str(e.execution_state).startsWith('hdfc-state-')) + .slice() + .sort((a, b) => String(a.created_at ?? '').localeCompare(String(b.created_at ?? ''))); +} + +function gap(prev?: string, cur?: string): string | null { + if (!prev || !cur) return null; + const ms = new Date(cur).getTime() - new Date(prev).getTime(); + if (!Number.isFinite(ms) || ms < 0) return null; + if (ms < 1000) return null; + const s = Math.round(ms / 1000); + if (s < 90) return `+${s}s`; + const m = Math.floor(s / 60); + return `+${m}m ${s % 60}s`; +} + +function time(v?: string): string { + if (!v) return ''; + const d = new Date(v); + return Number.isNaN(d.getTime()) + ? v + : d.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); +} + +export function InstanceTrace({ row, audit }: { row: Row; audit: AuditEntry[] }) { + const list = steps(audit); + const [open, setOpen] = useState(null); + const agentCount = list.filter(isAgent).length; + + if (list.length === 0) { + return

No completed steps recorded yet.

; + } + + return ( +
+
+
+

Chain of custody

+

+ Every step on this file, in order, from the workflow's audit trail +

+
+
+ {agentCount} by an agent + {list.length - agentCount} by people +
+
+ +
    + {list.map((e, i) => { + const agent = isAgent(e); + const key = `${e.id}-${i}`; + const isOpen = open === key; + const elapsed = gap(list[i - 1]?.created_at, e.created_at); + const fields = e.fields ?? []; + + return ( +
  1. + + + {isOpen && ( +
    + {/* On the agent's step, what it consulted before deciding. */} + {agent && } + + {fields.length === 0 ? ( +

    This step submitted no fields.

    + ) : ( +
    +

    + Submitted +

    +
    + {fields + .filter((f) => str(f.value) !== '') + .map((f) => ( +
    +
    {f.label}
    +
    + {f.data_type === 'number' + ? moneyShort(f.value) + : str(f.value)} +
    +
    + ))} +
    +
    + )} +
    + )} +
  2. + ); + })} +
+
+ ); +} + +/** The deterministic checks the assessor consulted, and what they returned for + * this file. The figures are the ones on the instance; the attribution is the + * fixed mapping in 09_agent_tools.sql. */ +function ToolsUsed({ row }: { row: Row }) { + const isPersonal = str(row.loan_product_family) === 'personal'; + const variance = num(row.income_variance_pct); + const breach = variance !== null && variance > POLICY.varianceTolerancePct; + + const tools: Array<{ name: string; asked: string; got: string; tone?: 'amber' }> = [ + { + name: 'turnover corroboration', + asked: 'compare the declared income sources', + got: isPersonal + ? `${moneyShort(row.itr_declared_income)} declared vs ${moneyShort(row.bank_credits_12m)} banked · spread ${pct(variance)} vs ${POLICY.varianceTolerancePct}% tolerance` + : `${moneyShort(row.itr_declared_income)} tax · ${moneyShort(row.gst_turnover_12m)} GST · ${moneyShort(row.bank_credits_12m)} bank · spread ${pct(variance)} vs ${POLICY.varianceTolerancePct}% tolerance`, + ...(breach ? { tone: 'amber' as const } : {}), + }, + { + name: 'capacity', + asked: 'can this borrower carry the facility', + got: isPersonal + ? `FOIR ${ratio((num(row.foir) ?? 0) * 100, 1)}% vs ${(POLICY.foirCeiling * 100).toFixed(0)}% ceiling · eligible up to ${moneyShort(row.max_eligible_amount)}` + : `DSCR ${ratio(row.dscr)} vs ${POLICY.dscrFloor} floor · eligible up to ${moneyShort(row.max_eligible_amount)}`, + }, + { + name: 'standing and conduct', + asked: 'is the credit record clean', + got: `bureau ${str(row.bureau_score) || '—'} vs ${POLICY.bureauFloor} floor${ + isPersonal ? '' : ` · CMR-${str(row.cmr_rank) || '—'} vs ceiling ${POLICY.cmrCeiling}` + } · DPD ${str(row.live_dpd_max) || '0'}d · ${str(row.bounce_count) || '0'} cheque returns`, + }, + ]; + + return ( +
+

+ + Checks it ran before writing +

+
    + {tools.map((t) => ( +
  • +

    {t.name}

    +

    asked: {t.asked}

    +

    + {t.got} +

    +
  • + ))} +
+

+ Each is deterministic SQL running the bank's own formula — the agent quotes what it is + given and computes nothing itself. +

+
+ ); +}