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:
parent
c0059194e4
commit
083ee9e8e4
@ -136,7 +136,13 @@ export const STAGE_ACTIONS: Record<string, Action[]> = {
|
||||
],
|
||||
'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': [
|
||||
|
||||
136
src/components/AdvisorPanel.tsx
Normal file
136
src/components/AdvisorPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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() {
|
||||
</div>
|
||||
</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:
|
||||
an empty "Credit review" card on a file nobody has touched
|
||||
suggests a step was skipped. */}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user