feat: surface Aria Advisor product recommendation in Recommend form

Pull the pre-activity recommendation (product, suggested SA, rationale,
confidence) from Aria Advisor's submitted decision and show it in the
Recommend Product form, with one-click apply that fills SA + resolves the
product name to its catalogue lookup value.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bhanu Prakash Sai Potteri 2026-06-26 12:42:51 +05:30
parent 3b95cf8f81
commit 850a12b648
4 changed files with 268 additions and 10 deletions

View File

@ -3,6 +3,7 @@ import type { Channel, Decision, Lead, Segment, Tone } from '../types';
import {
ACTIVITY_NAME_BY_UID,
AI_EMPLOYEES,
AI_RECOMMENDATION,
STATE_NAME_BY_UID,
aiEmployeeForState,
stageOfState,
@ -161,7 +162,8 @@ function firstSentence(text: string): string {
}
export function decisionToCard(d: AiDecision): Decision {
const emp = AI_EMPLOYEES[d.ai_user_id] ?? { name: `AI ${d.ai_user_id}`, role: 'AI Employee' };
const known = AI_EMPLOYEES[d.ai_user_id];
const emp = known ?? { name: d.employee_name?.trim() || `AI ${d.ai_user_id}`, role: 'AI Employee' };
const reasoning = (d.reasoning ?? '').trim();
const activityName =
ACTIVITY_NAME_BY_UID[d.activity_id] ??
@ -180,6 +182,60 @@ export function decisionToCard(d: AiDecision): Decision {
};
}
// --- Aria Advisor product recommendation ---
export interface AiRecommendation {
/** Product name as the AI named it, e.g. "ABSLI DigiShield Term Plan". */
product: string;
sumAssured: number | null;
rationale: string;
/** 01 (the decision confidence, or the ai_* field / 100). */
confidence: number;
employee: string;
role: string;
time: string | null;
}
function num(v: unknown): number | null {
if (v == null || v === '') return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
/** Pull Aria Advisor's pre-activity product recommendation out of the decision
* stream (it fires after Schedule Meeting). Returns null if she hasn't run. */
export function recommendationFromDecisions(decisions: AiDecision[]): AiRecommendation | null {
const F = AI_RECOMMENDATION.fields;
// A recommendation decision = the right activity/employee, carrying a product
// name, and actually *submitted* (skip failed retries, which other employees
// log at low confidence). Prefer the real Aria Advisor over any stand-in.
const candidates = decisions.filter(
(x) =>
x.status === 'submitted' &&
typeof x.data?.[F.product] === 'string' &&
(x.data[F.product] as string).trim() !== '' &&
(x.activity_id === AI_RECOMMENDATION.activityUid || x.ai_user_id === AI_RECOMMENDATION.aiUserId),
);
const d =
candidates.find((x) => x.ai_user_id === AI_RECOMMENDATION.aiUserId) ?? candidates[0];
const product = d?.data?.[F.product];
if (!d || typeof product !== 'string' || !product.trim()) return null;
const emp = AI_EMPLOYEES[d.ai_user_id] ?? {
name: d.employee_name?.trim() || 'Aria Advisor',
role: 'Product Recommendation',
};
const pct = num(d.data?.[F.confidence]);
return {
product: product.trim(),
sumAssured: num(d.data?.[F.sumAssured]),
rationale: (typeof d.data?.[F.rationale] === 'string' ? (d.data[F.rationale] as string) : d.reasoning ?? '').trim(),
confidence: typeof d.confidence === 'number' && d.confidence > 0 ? d.confidence : pct != null ? pct / 100 : 0,
employee: emp.name,
role: emp.role,
time: d.created_at ? formatTime(d.created_at) : null,
};
}
// --- audit timeline ---
export interface TimelineItem {

View File

@ -130,8 +130,24 @@ export const AI_EMPLOYEES: Record<string, { name: string; role: string }> = {
'29180': { name: 'Aria', role: 'Lead Qualifier' },
'29181': { name: 'Aria Engage', role: 'Calls & Scheduling' },
'29182': { name: 'Aria Underwrite', role: 'Underwriting' },
'29188': { name: 'Aria Advisor', role: 'Product Recommendation' },
};
// Aria Advisor's internal recommendation activity. It fires from the Schedule
// Meeting trigger (after the meeting is booked) and records the product
// recommendation as a decision — surfaced in the Recommend Product form. Its
// data{} carries the ai_* fields below.
export const AI_RECOMMENDATION = {
activityUid: '14d734da-f1e2-49af-9715-2146d26c0e1a',
aiUserId: '29188',
fields: {
product: 'ai_recommended_product_input', // product name (string)
sumAssured: 'ai_suggested_sum_assured_input', // number
rationale: 'ai_recommendation_rationale_input', // text
confidence: 'ai_recommendation_confidence_input', // 0100
},
} as const;
// Which AI employee is driving a given state (for owner attribution when no
// human sales agent is set).
export function aiEmployeeForState(stateName?: string): { name: string; role: string } | null {

View File

@ -109,12 +109,19 @@ export interface AiDecision {
instance_id: string;
activity_id: string;
activity_name?: string;
/** Server-resolved display name for the AI employee (preferred over the
* static AI_EMPLOYEES map). */
employee_name?: string;
reasoning: string;
confidence: number;
status: string;
instance_state?: string;
knowledge_sources?: unknown;
tool_calls?: unknown;
/** The activity payload the AI employee submitted e.g. the Aria Advisor
* product recommendation carries ai_recommended_product_input /
* ai_suggested_sum_assured_input / ai_recommendation_confidence_input here. */
data?: Record<string, unknown>;
created_at: string;
}

View File

@ -1,9 +1,11 @@
import { useEffect, useMemo, useState } from 'react';
import { CheckCircle2 } from 'lucide-react';
import { CheckCircle2, ChevronRight, Sparkles, Wand2 } from 'lucide-react';
import {
AIDecisionCard,
Avatar,
Button,
Card,
ConfidenceRing,
formatINR,
Input,
MultiSelect,
@ -13,7 +15,8 @@ import {
import { LookupField } from '../form';
import type { LookupValue } from '../form';
import { useQuery, useZino } from '../../api/provider';
import { decisionToCard } from '../../api/adapters';
import { decisionToCard, recommendationFromDecisions } from '../../api/adapters';
import type { AiRecommendation } from '../../api/adapters';
import { fieldFromSchema } from '../../api/schema';
import { ACTIVITIES } from '../../api/config';
import { recommendCalc, saValidIssues } from '../../lib/recommend';
@ -70,6 +73,46 @@ export function RecommendBody({ instanceId, record, onSuccess }: ActivityBodyPro
const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null);
// One-click apply of Aria Advisor's pre-activity recommendation: sets the sum
// assured straight off, and resolves her product *name* against the live
// catalogue into the lookup value the field needs. The agent can still edit.
const [applying, setApplying] = useState(false);
const [applyNote, setApplyNote] = useState<string | null>(null);
async function applyRecommendation(rec: AiRecommendation) {
setApplyNote(null);
if (rec.sumAssured && rec.sumAssured > 0) {
setSum(rec.sumAssured);
setPremiumTouched(false); // let the rate-card premium recompute
}
if (!productField?.lookupTemplate) return;
setApplying(true);
try {
const { records } = await client.lookupRecords(productField.lookupTemplate, {
activityId: ACTIVITIES.RECOMMEND_PRODUCT.uid,
fieldId: F.product,
instanceId,
search: '',
limit: 50,
});
const match = matchProduct(rec.product, records, productField.lookupDisplay ?? []);
if (match) {
const stored: LookupValue = {};
(productField.lookupStorage ?? []).forEach((c) => (stored[c] = match[c] ?? null));
(productField.lookupDisplay ?? []).forEach((c) => {
if (!(c in stored)) stored[c] = match[c] ?? null;
});
setProduct(stored);
} else {
setApplyNote(`Couldnt match “${rec.product}” to the catalogue — pick the product manually.`);
}
} catch {
setApplyNote('Couldnt load the product catalogue — pick the product manually.');
} finally {
setApplying(false);
}
}
const incomeMultiple = record?.annual_income ? (sum / record.annual_income).toFixed(1) : '—';
async function submit() {
@ -217,22 +260,158 @@ export function RecommendBody({ instanceId, record, onSuccess }: ActivityBodyPro
</div>
</div>
<RecommendAiSuggestion instanceId={instanceId} />
<RecommendAiSuggestion
instanceId={instanceId}
onApply={applyRecommendation}
applying={applying}
applyNote={applyNote}
/>
</div>
</div>
);
}
function RecommendAiSuggestion({ instanceId }: { instanceId: number | string }) {
/** Match Aria's free-text product name to a catalogue row by case/punctuation-
* insensitive containment (longest matching label wins). */
function matchProduct(
aiName: string,
rows: Array<Record<string, unknown>>,
displayCols: string[],
): Record<string, unknown> | null {
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
const ai = norm(aiName);
if (!ai) return null;
let best: Record<string, unknown> | null = null;
let bestLen = 0;
for (const row of rows) {
// Try each display column on its own *and* the joined label — a product
// name like "DigiShield" must still match a multi-column "DigiShield · term".
const candidates = [...displayCols.map((c) => row[c]), displayCols.map((c) => row[c]).join(' ')]
.map((v) => (v == null ? '' : norm(String(v))))
.filter(Boolean);
for (const n of candidates) {
if ((ai.includes(n) || n.includes(ai)) && n.length > bestLen) {
best = row;
bestLen = n.length;
}
}
}
return best;
}
interface RecommendAiSuggestionProps {
instanceId: number | string;
onApply: (rec: AiRecommendation) => void;
applying: boolean;
applyNote: string | null;
}
function RecommendAiSuggestion({ instanceId, onApply, applying, applyNote }: RecommendAiSuggestionProps) {
const { client } = useZino();
const q = useQuery(() => client.aiDecisions(Number(instanceId)), [instanceId]);
const latest = (q.data ?? [])[0];
const decisions = q.data ?? [];
const rec = recommendationFromDecisions(decisions);
// No structured recommendation yet → fall back to the generic latest-decision
// card so the agent still sees whatever Aria last did.
if (!rec) {
const latest = decisions[0];
if (!latest) return null;
const card = decisionToCard(latest);
return (
<>
<div className="text-2xs font-bold uppercase tracking-[0.06em] text-faint">AI suggestion</div>
<AIDecisionCard {...card} defaultExpanded />
<AIDecisionCard {...decisionToCard(latest)} defaultExpanded />
</>
);
}
return (
<>
<div className="text-2xs font-bold uppercase tracking-[0.06em] text-faint">Arias recommendation</div>
<AriaRecommendationCard rec={rec} onApply={() => onApply(rec)} applying={applying} applyNote={applyNote} />
</>
);
}
function AriaRecommendationCard({
rec,
onApply,
applying,
applyNote,
}: {
rec: AiRecommendation;
onApply: () => void;
applying: boolean;
applyNote: string | null;
}) {
const [open, setOpen] = useState(false);
return (
<article className="relative bg-card rounded-lg border border-border-subtle shadow-md overflow-hidden font-sans">
<span className="absolute top-0 left-0 bottom-0 w-1 bg-sunrise" />
<div className="flex gap-4 pl-[22px] pr-5 py-[18px]">
<div className="flex-1 min-w-0">
<header className="flex items-center gap-2.5 mb-3">
<Avatar name={rec.employee} ai size={36} />
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-base font-bold text-strong">{rec.employee}</span>
<span className="text-[10px] font-bold uppercase tracking-[0.06em] text-faint bg-sunk px-[7px] py-[3px] rounded-pill">
{rec.role}
</span>
</div>
<div className="text-xs text-muted mt-0.5">
Recommended before your meeting
{rec.time && <span className="text-faint"> · {rec.time}</span>}
</div>
</div>
</header>
{/* what it recommended */}
<div className="rounded-lg border border-border-default bg-sunk px-3.5 py-3">
<div className="flex items-center gap-1.5 text-2xs font-bold uppercase tracking-[0.06em] text-sunrise-600">
<Sparkles size={12} /> Recommends
</div>
<div className="mt-1 text-base font-bold text-strong leading-snug">{rec.product}</div>
{rec.sumAssured != null && (
<div className="mt-2 flex items-baseline justify-between">
<span className="text-xs text-muted">Suggested cover</span>
<span className="text-sm font-semibold text-strong nums">{formatINR(rec.sumAssured)}</span>
</div>
)}
</div>
{/* what it thought */}
{rec.rationale && (
<div className="mt-3">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="inline-flex items-center gap-1.5 bg-none border-none p-0 cursor-pointer font-sans text-xs font-semibold text-link"
>
<ChevronRight size={14} className={open ? 'rotate-90 transition-transform' : 'transition-transform'} />
{open ? 'Hide reasoning' : 'Why this product'}
</button>
{open && (
<p className="mt-2.5 px-4 py-3.5 bg-sunk rounded-lg text-sm leading-normal text-body whitespace-pre-line max-h-[260px] overflow-y-auto">
{rec.rationale}
</p>
)}
</div>
)}
<div className="mt-4">
<Button variant="secondary" disabled={applying} onClick={onApply}>
<Wand2 size={14} />
{applying ? 'Applying…' : 'Use this suggestion'}
</Button>
{applyNote && <div className="mt-2 text-xs text-amber-700">{applyNote}</div>}
</div>
</div>
<div className="shrink-0 flex flex-col items-center pt-0.5">
<ConfidenceRing value={rec.confidence} size={76} />
</div>
</div>
</article>
);
}