From 850a12b64813d98ee5da0882162134c68fb6bb8b Mon Sep 17 00:00:00 2001 From: Bhanu Prakash Sai Potteri Date: Fri, 26 Jun 2026 12:42:51 +0530 Subject: [PATCH] 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 --- src/api/adapters.ts | 58 +++++- src/api/config.ts | 16 ++ src/api/types.ts | 7 + src/components/activities/RecommendBody.tsx | 197 +++++++++++++++++++- 4 files changed, 268 insertions(+), 10 deletions(-) diff --git a/src/api/adapters.ts b/src/api/adapters.ts index 2139b1f..c4c07a9 100644 --- a/src/api/adapters.ts +++ b/src/api/adapters.ts @@ -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; + /** 0–1 (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 { diff --git a/src/api/config.ts b/src/api/config.ts index 3940331..0d55fe8 100644 --- a/src/api/config.ts +++ b/src/api/config.ts @@ -130,8 +130,24 @@ export const AI_EMPLOYEES: Record = { '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', // 0–100 + }, +} 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 { diff --git a/src/api/types.ts b/src/api/types.ts index 0312403..6aba1d1 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -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; created_at: string; } diff --git a/src/components/activities/RecommendBody.tsx b/src/components/activities/RecommendBody.tsx index d2d53e6..6fee624 100644 --- a/src/components/activities/RecommendBody.tsx +++ b/src/components/activities/RecommendBody.tsx @@ -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(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(`Couldn’t match “${rec.product}” to the catalogue — pick the product manually.`); + } + } catch { + setApplyNote('Couldn’t 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 - + ); } -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>, + displayCols: string[], +): Record | null { + const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); + const ai = norm(aiName); + if (!ai) return null; + let best: Record | 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]; - if (!latest) return null; - const card = decisionToCard(latest); + 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; + return ( + <> +
AI suggestion
+ + + ); + } + return ( <> -
AI suggestion
- +
Aria’s recommendation
+ 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 ( +
+ +
+
+
+ +
+
+ {rec.employee} + + {rec.role} + +
+
+ Recommended before your meeting + {rec.time && · {rec.time}} +
+
+
+ + {/* what it recommended */} +
+
+ Recommends +
+
{rec.product}
+ {rec.sumAssured != null && ( +
+ Suggested cover + ₹{formatINR(rec.sumAssured)} +
+ )} +
+ + {/* what it thought */} + {rec.rationale && ( +
+ + {open && ( +

+ {rec.rationale} +

+ )} +
+ )} + +
+ + {applyNote &&
{applyNote}
} +
+
+ +
+ +
+
+
+ ); +}