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:
parent
3b95cf8f81
commit
850a12b648
@ -3,6 +3,7 @@ import type { Channel, Decision, Lead, Segment, Tone } from '../types';
|
|||||||
import {
|
import {
|
||||||
ACTIVITY_NAME_BY_UID,
|
ACTIVITY_NAME_BY_UID,
|
||||||
AI_EMPLOYEES,
|
AI_EMPLOYEES,
|
||||||
|
AI_RECOMMENDATION,
|
||||||
STATE_NAME_BY_UID,
|
STATE_NAME_BY_UID,
|
||||||
aiEmployeeForState,
|
aiEmployeeForState,
|
||||||
stageOfState,
|
stageOfState,
|
||||||
@ -161,7 +162,8 @@ function firstSentence(text: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function decisionToCard(d: AiDecision): Decision {
|
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 reasoning = (d.reasoning ?? '').trim();
|
||||||
const activityName =
|
const activityName =
|
||||||
ACTIVITY_NAME_BY_UID[d.activity_id] ??
|
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 ---
|
// --- audit timeline ---
|
||||||
|
|
||||||
export interface TimelineItem {
|
export interface TimelineItem {
|
||||||
|
|||||||
@ -130,8 +130,24 @@ export const AI_EMPLOYEES: Record<string, { name: string; role: string }> = {
|
|||||||
'29180': { name: 'Aria', role: 'Lead Qualifier' },
|
'29180': { name: 'Aria', role: 'Lead Qualifier' },
|
||||||
'29181': { name: 'Aria Engage', role: 'Calls & Scheduling' },
|
'29181': { name: 'Aria Engage', role: 'Calls & Scheduling' },
|
||||||
'29182': { name: 'Aria Underwrite', role: 'Underwriting' },
|
'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
|
// Which AI employee is driving a given state (for owner attribution when no
|
||||||
// human sales agent is set).
|
// human sales agent is set).
|
||||||
export function aiEmployeeForState(stateName?: string): { name: string; role: string } | null {
|
export function aiEmployeeForState(stateName?: string): { name: string; role: string } | null {
|
||||||
|
|||||||
@ -109,12 +109,19 @@ export interface AiDecision {
|
|||||||
instance_id: string;
|
instance_id: string;
|
||||||
activity_id: string;
|
activity_id: string;
|
||||||
activity_name?: string;
|
activity_name?: string;
|
||||||
|
/** Server-resolved display name for the AI employee (preferred over the
|
||||||
|
* static AI_EMPLOYEES map). */
|
||||||
|
employee_name?: string;
|
||||||
reasoning: string;
|
reasoning: string;
|
||||||
confidence: number;
|
confidence: number;
|
||||||
status: string;
|
status: string;
|
||||||
instance_state?: string;
|
instance_state?: string;
|
||||||
knowledge_sources?: unknown;
|
knowledge_sources?: unknown;
|
||||||
tool_calls?: 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;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { CheckCircle2 } from 'lucide-react';
|
import { CheckCircle2, ChevronRight, Sparkles, Wand2 } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
AIDecisionCard,
|
AIDecisionCard,
|
||||||
|
Avatar,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
|
ConfidenceRing,
|
||||||
formatINR,
|
formatINR,
|
||||||
Input,
|
Input,
|
||||||
MultiSelect,
|
MultiSelect,
|
||||||
@ -13,7 +15,8 @@ import {
|
|||||||
import { LookupField } from '../form';
|
import { LookupField } from '../form';
|
||||||
import type { LookupValue } from '../form';
|
import type { LookupValue } from '../form';
|
||||||
import { useQuery, useZino } from '../../api/provider';
|
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 { fieldFromSchema } from '../../api/schema';
|
||||||
import { ACTIVITIES } from '../../api/config';
|
import { ACTIVITIES } from '../../api/config';
|
||||||
import { recommendCalc, saValidIssues } from '../../lib/recommend';
|
import { recommendCalc, saValidIssues } from '../../lib/recommend';
|
||||||
@ -70,6 +73,46 @@ export function RecommendBody({ instanceId, record, onSuccess }: ActivityBodyPro
|
|||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
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(`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) : '—';
|
const incomeMultiple = record?.annual_income ? (sum / record.annual_income).toFixed(1) : '—';
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
@ -217,22 +260,158 @@ export function RecommendBody({ instanceId, record, onSuccess }: ActivityBodyPro
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<RecommendAiSuggestion instanceId={instanceId} />
|
<RecommendAiSuggestion
|
||||||
|
instanceId={instanceId}
|
||||||
|
onApply={applyRecommendation}
|
||||||
|
applying={applying}
|
||||||
|
applyNote={applyNote}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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 { client } = useZino();
|
||||||
const q = useQuery(() => client.aiDecisions(Number(instanceId)), [instanceId]);
|
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;
|
if (!latest) return null;
|
||||||
const card = decisionToCard(latest);
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="text-2xs font-bold uppercase tracking-[0.06em] text-faint">AI suggestion</div>
|
<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">Aria’s 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user