feat: the Loan Advisor acts on its own, and no human can press its button

The Advisor existed as a role and an activity that nothing ever invoked, so
the only way to see it act was to sign in as advisor.ai and click. That is a
person impersonating an agent. Either it works unprompted or it is not real.

 * AdvisorPanel shows the recommendation, and — the part worth showing — says
   plainly whether the manager issued the offer on those terms or departed
   from them, itemised. That is the governance question about an advisory
   agent: not whether it is clever, but whether an override is visible.
 * ACT.AI_OFFER is removed from STAGE_ACTIONS. A button whose only use is for
   a human to act as the agent should not exist, and its prefill draft goes
   with it.
 * offered_fee now follows the per-product grid (1.25% MSME / flat 2,500
   personal) instead of a blended 1.25%. The agents cite that clause by
   number now, so a contradiction would be on screen.

Backend, in sm2/custom-apps/hdfc-loan-desk: 11_advisor.sql (employee, the
hdfc_offer_structuring tool that prices every offered tenor, the pricing-grid
knowledge, and the wake on Approve) and 12_advise_on_clean_approval.sql (the
conditional wake for files the gate approves outright, which never touch
Approve at all).
This commit is contained in:
Yashas 2026-08-18 11:57:40 +05:30
parent c0059194e4
commit 083ee9e8e4
4 changed files with 156 additions and 11 deletions

View File

@ -136,7 +136,13 @@ export const STAGE_ACTIONS: Record<string, Action[]> = {
], ],
'Credit Approved': [ 'Credit Approved': [
{ activity: ACT.MAKE_OFFER, label: 'Make Offer & Issue KFS', roles: ['rm_business_banking'], kind: 'primary' }, { 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' }, { activity: ACT.DECLINE, label: 'Decline', roles: ['rm_business_banking', 'credit_manager'], kind: 'danger' },
], ],
'Offer Made': [ 'Offer Made': [

View File

@ -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 (
<div>
<div className="text-[11px] font-semibold uppercase tracking-wide text-ai/70">{label}</div>
<div className="tnum text-lg font-semibold text-ai">{value || '—'}</div>
{hint && <div className="mt-0.5 text-[11px] text-muted">{hint}</div>}
</div>
);
}
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<string, 'emerald' | 'amber' | 'rose'>)[confidence];
return (
<Card
title={
<span className="inline-flex items-center gap-2 text-ai">
<Sparkles className="h-4 w-4" />
Recommended structure
</span>
}
subtitle="Proposed by an agent · advisory, not an offer"
className="border-ai-edge bg-ai-soft"
>
<div className="space-y-4 px-4 py-4">
<div className="grid grid-cols-2 gap-4">
<Row2 label="Amount" value={money(amount)} />
<Row2 label="Rate" value={pct(roi)} hint={roi !== null && roi > 14.5 ? 'deviation-priced' : 'standard grid'} />
<Row2 label="Tenor" value={tenure !== null ? `${tenure} months` : ''} hint="shortest that stays in policy" />
<Row2 label="Instalment" value={money(emi)} />
</div>
{rationale && (
<p className="border-t border-ai-edge pt-3 text-sm leading-relaxed text-ink">{rationale}</p>
)}
{confidence && (
<div className="flex items-center gap-2">
<span className="text-xs text-muted">Its own confidence</span>
<Badge tone={tone ?? 'slate'}>{confidence}</Badge>
</div>
)}
{/* The governance beat: did the human follow it? */}
{hasOffer && (
<div className="rounded-md border border-line bg-panel px-3 py-2.5">
{departures.length === 0 ? (
<p className="flex items-start gap-2 text-xs text-good">
<Check className="mt-0.5 h-3.5 w-3.5 shrink-0" />
The manager issued the offer on these terms unchanged.
</p>
) : (
<div className="space-y-1">
<p className="flex items-center gap-2 text-xs font-semibold text-warn">
<ArrowRight className="h-3.5 w-3.5" />
The manager departed from this recommendation
</p>
<ul className="tnum space-y-0.5 pl-5 text-xs text-muted">
{departures.map((d) => (
<li key={d} className="list-disc">{d}</li>
))}
</ul>
<p className="pt-1 text-[11px] text-faint">
Departing needs no approval the recommendation is advisory. It is recorded so the
departure is visible, not to prevent it.
</p>
</div>
)}
</div>
)}
<p className="border-t border-ai-edge pt-3 text-[11px] text-ai/80">
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.
</p>
</div>
</Card>
);
}

View File

@ -102,7 +102,13 @@ export function prefillFor(
offered_amount: base, offered_amount: base,
offered_roi: roi, offered_roi: roi,
offered_tenure_months: tenure, 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(), 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 // 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. // someone credit is not a convenience, and the reason is the whole record.

View File

@ -8,6 +8,7 @@ import { Badge, Button, Card, ErrorNote, Facts, Spinner } from '../components/co
import { StageBadge, StageRail } from '../components/Stage'; import { StageBadge, StageRail } from '../components/Stage';
import { EvidencePanel } from '../components/EvidencePanel'; import { EvidencePanel } from '../components/EvidencePanel';
import { AiDecisionPanel } from '../components/AiDecisionPanel'; import { AiDecisionPanel } from '../components/AiDecisionPanel';
import { AdvisorPanel } from '../components/AdvisorPanel';
import { ActivityForm } from '../components/ActivityForm'; import { ActivityForm } from '../components/ActivityForm';
import { prefillFor } from '../prefill'; import { prefillFor } from '../prefill';
import type { Row } from '../api/types'; import type { Row } from '../api/types';
@ -176,6 +177,11 @@ export function ApplicationScreen() {
</div> </div>
</Card> </Card>
{/* 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. */}
<AdvisorPanel row={row} />
{/* The human decision trail. Only rendered once someone has acted: {/* The human decision trail. Only rendered once someone has acted:
an empty "Credit review" card on a file nobody has touched an empty "Credit review" card on a file nobody has touched
suggests a step was skipped. */} suggests a step was skipped. */}