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 {
|
||||
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 {
|
||||
|
||||
@ -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', // 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 {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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(`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
|
||||
</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];
|
||||
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 (
|
||||
<>
|
||||
<div className="text-2xs font-bold uppercase tracking-[0.06em] text-faint">AI suggestion</div>
|
||||
<AIDecisionCard {...decisionToCard(latest)} defaultExpanded />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-2xs font-bold uppercase tracking-[0.06em] text-faint">AI suggestion</div>
|
||||
<AIDecisionCard {...card} defaultExpanded />
|
||||
<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