diff --git a/src/components/AiDecisionPanel.tsx b/src/components/AiDecisionPanel.tsx new file mode 100644 index 0000000..c6e6e45 --- /dev/null +++ b/src/components/AiDecisionPanel.tsx @@ -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 ( +
+ {/* ---------------- 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 && ( +
+
+ +

What the agent reasoned

+ {deviation && flagged: {deviation}} + narrative, not verdict +
+
+

{analysis}

+ {citations && ( + + )} +
+ +

+ 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. +

+
+
+
+ )} + + {/* Honest about the gap. Better than a fabricated waterfall. */} +

+ 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. +

+
+ ); +} + +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/screens/ApplicationScreen.tsx b/src/screens/ApplicationScreen.tsx index abe2594..908a418 100644 --- a/src/screens/ApplicationScreen.tsx +++ b/src/screens/ApplicationScreen.tsx @@ -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(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() {
{/* Evidence first — see the note at the top of this file. */}
- +
+ {([ + ['evidence', 'Evidence & decision'], + ['ai', 'AI trail'], + ] as const).map(([key, label]) => ( + + ))} +
+ + {tab === 'evidence' ? ( + + ) : ( + + )}