diff --git a/src/api/config.ts b/src/api/config.ts index b695335..d84dfd9 100644 --- a/src/api/config.ts +++ b/src/api/config.ts @@ -136,7 +136,13 @@ export const STAGE_ACTIONS: Record = { ], 'Credit Approved': [ { activity: ACT.MAKE_OFFER, label: 'Make Offer & Issue KFS', roles: ['rm_business_banking'], kind: 'primary' }, - { activity: ACT.AI_OFFER, label: 'AI Offer Recommendation', roles: ['ai_loan_advisor'], kind: 'secondary' }, + // ACT.AI_OFFER is deliberately ABSENT. It is the Loan Advisor's activity + // and the Advisor performs it unprompted, woken by the workflow itself + // (11_advisor.sql / 12_advise_on_clean_approval.sql). Listing it here + // would put a button on screen whose only purpose is for a person to + // sign in as the agent and press it — which is a person impersonating an + // agent, not an agent working. If it is not on this list, the only thing + // that can perform it is the thing that is supposed to. { activity: ACT.DECLINE, label: 'Decline', roles: ['rm_business_banking', 'credit_manager'], kind: 'danger' }, ], 'Offer Made': [ diff --git a/src/components/AdvisorPanel.tsx b/src/components/AdvisorPanel.tsx new file mode 100644 index 0000000..5f3a134 --- /dev/null +++ b/src/components/AdvisorPanel.tsx @@ -0,0 +1,136 @@ +import { Sparkles, ArrowRight, Check } from 'lucide-react'; +import { Card, Badge } from './core'; +import type { Row } from '../api/types'; +import { str, num, money, pct } from '../format'; + +/** + * The Loan Advisor's structuring recommendation. + * + * WHY THIS IS A SEPARATE CARD from the assessment. The Assessor's output is + * evidence about a file; this is a PROPOSAL about a contract. They carry + * different weight and a reader must not confuse them — an assessment that + * turns out wrong is a bad read, a recommendation that turns out wrong is a + * mispriced loan. So this card says "recommended, not offered" in as many + * words, and the numbers here are never shown in the same block as the + * committed offer. + * + * WHAT MAKES THIS WORTH SHOWING AT ALL. On its own, an agent suggesting terms + * is unremarkable. What is worth a client's attention is the last block: once + * the relationship manager has issued the offer, this card compares the two + * and says plainly whether the human took the advice or departed from it. That + * is the governance question a bank actually has about an advisory agent — not + * "is it clever" but "can I see when someone overruled it, and on what". + * + * Violet throughout, which is this app's one convention for model-written + * content. If a number in here is not violet it is not from the agent. + */ + +function Row2({ label, value, hint }: { label: string; value: string; hint?: string }) { + return ( +
+
{label}
+
{value || '—'}
+ {hint &&
{hint}
} +
+ ); +} + +const CONFIDENCE_TONE = { high: 'emerald', medium: 'amber', low: 'rose' } as const; + +export function AdvisorPanel({ row }: { row: Row }) { + const amount = num(row.ai_suggested_amount); + const roi = num(row.ai_suggested_roi); + const tenure = num(row.ai_suggested_tenure); + const emi = num(row.ai_suggested_emi); + const rationale = str(row.ai_rationale); + const confidence = str(row.ai_confidence).toLowerCase(); + + // Nothing to show until the agent has run. An empty card on a file it has + // not reached reads as a failure rather than as "not yet". + if (amount === null && !rationale) return null; + + // The committed offer, if a manager has issued one yet. + const offAmount = num(row.offered_amount); + const offRoi = num(row.offered_roi); + const offTenure = num(row.offered_tenure_months); + const hasOffer = offAmount !== null || offRoi !== null; + + const departures: string[] = []; + if (hasOffer) { + if (offAmount !== null && amount !== null && offAmount !== amount) + departures.push(`amount ${money(amount)} → ${money(offAmount)}`); + if (offRoi !== null && roi !== null && offRoi !== roi) + departures.push(`rate ${pct(roi)} → ${pct(offRoi)}`); + if (offTenure !== null && tenure !== null && offTenure !== tenure) + departures.push(`tenor ${tenure} → ${offTenure} months`); + } + + const tone = (CONFIDENCE_TONE as Record)[confidence]; + + return ( + + + Recommended structure + + } + subtitle="Proposed by an agent · advisory, not an offer" + className="border-ai-edge bg-ai-soft" + > +
+
+ + 14.5 ? 'deviation-priced' : 'standard grid'} /> + + +
+ + {rationale && ( +

{rationale}

+ )} + + {confidence && ( +
+ Its own confidence + {confidence} +
+ )} + + {/* The governance beat: did the human follow it? */} + {hasOffer && ( +
+ {departures.length === 0 ? ( +

+ + The manager issued the offer on these terms unchanged. +

+ ) : ( +
+

+ + The manager departed from this recommendation +

+
    + {departures.map((d) => ( +
  • {d}
  • + ))} +
+

+ Departing needs no approval — the recommendation is advisory. It is recorded so the + departure is visible, not to prevent it. +

+
+ )} +
+ )} + +

+ The agent read the file, priced every tenor the bank offers through a calculation tool and + chose from the result. It cannot issue an offer: its role holds permission on this one + advisory activity and on none of the fields an offer commits. +

+
+
+ ); +} diff --git a/src/prefill.ts b/src/prefill.ts index eecc6c8..d344a14 100644 --- a/src/prefill.ts +++ b/src/prefill.ts @@ -102,7 +102,13 @@ export function prefillFor( offered_amount: base, offered_roi: roi, offered_tenure_months: tenure, - offered_fee: Math.round((base * 0.0125) / 500) * 500, + // Fee grid, HDFC-PR-2026.08 clause 4.1: 1.25% on the MSME product, + // flat 2,500 on the personal one. The agents cite this clause, so a + // single blended figure here would visibly contradict them. + offered_fee: + str(row.loan_product_family) === 'personal' + ? 2500 + : Math.round((base * 0.0125) / 500) * 500, }; } @@ -126,15 +132,6 @@ export function prefillFor( disbursed_at: new Date().toISOString(), }; - case ACT.AI_OFFER: { - const approved = num(row.cm_approved_amount); - const base = approved ?? (maxElig > 0 ? Math.min(requested, maxElig) : requested); - return { - ai_suggested_amount: base, - ai_suggested_roi: flag ? POLICY.indicativeRoi + 0.75 : POLICY.indicativeRoi, - ai_suggested_tenure: num(row.requested_tenure_months) ?? 36, - }; - } // Declines open EMPTY. See rule 1 above — a pre-written reason for refusing // someone credit is not a convenience, and the reason is the whole record. diff --git a/src/screens/ApplicationScreen.tsx b/src/screens/ApplicationScreen.tsx index 618992a..8ea6e91 100644 --- a/src/screens/ApplicationScreen.tsx +++ b/src/screens/ApplicationScreen.tsx @@ -8,6 +8,7 @@ import { Badge, Button, Card, ErrorNote, Facts, Spinner } from '../components/co import { StageBadge, StageRail } from '../components/Stage'; import { EvidencePanel } from '../components/EvidencePanel'; import { AiDecisionPanel } from '../components/AiDecisionPanel'; +import { AdvisorPanel } from '../components/AdvisorPanel'; import { ActivityForm } from '../components/ActivityForm'; import { prefillFor } from '../prefill'; import type { Row } from '../api/types'; @@ -176,6 +177,11 @@ export function ApplicationScreen() { + {/* What the Loan Advisor proposed. Renders itself away until the + agent has actually run, so a file it has not reached shows + nothing rather than an empty promise. */} + + {/* The human decision trail. Only rendered once someone has acted: an empty "Credit review" card on a file nobody has touched suggests a step was skipped. */}