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 +

+ +

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

+
+ ); +}