AI recomendation feature

This commit is contained in:
Mayur Shinde
2026-06-09 17:45:29 +05:30
parent 03801a5087
commit 0da194b157
8 changed files with 616 additions and 1 deletions
@@ -0,0 +1,52 @@
/**
* TenYearValueChart — upfront vs illustrative 10-yr savings, per option (spec §10).
* Savings bars only render when a premium was provided and conservative mode is off.
*/
import React from 'react';
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend, Cell } from 'recharts';
const PKG_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' };
const BAR_COLOR = { GOOD: '#a1a1aa', BETTER: '#3b82f6', BEST: '#fda913' };
const TenYearValueChart = ({ valueModel }) => {
const data = valueModel.rows.map((r) => ({
name: PKG_LABEL[r.type],
type: r.type,
Upfront: r.price.base,
'Est. 10-yr savings': r.savings ? r.savings.base : 0,
}));
const fmt = (n) => `$${(n / 1000).toFixed(0)}k`;
return (
<div className="rounded-2xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 p-4">
<h4 className="font-semibold text-zinc-900 dark:text-white text-sm mb-3">10-year value comparison</h4>
<div style={{ width: '100%', height: 220 }}>
<ResponsiveContainer>
<BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<XAxis dataKey="name" tick={{ fontSize: 12 }} stroke="#a1a1aa" />
<YAxis tickFormatter={fmt} tick={{ fontSize: 11 }} stroke="#a1a1aa" width={44} />
<Tooltip
formatter={(v) => `$${Number(v).toLocaleString('en-US')}`}
contentStyle={{ borderRadius: 12, border: '1px solid #e4e4e7', fontSize: 12 }}
/>
<Legend wrapperStyle={{ fontSize: 12 }} />
<Bar dataKey="Upfront" radius={[4, 4, 0, 0]}>
{data.map((d) => <Cell key={d.type} fill={BAR_COLOR[d.type]} fillOpacity={0.85} />)}
</Bar>
{valueModel.premiumKnown && !valueModel.conservativeMode && (
<Bar dataKey="Est. 10-yr savings" radius={[4, 4, 0, 0]} fill="#22c55e" fillOpacity={0.8} />
)}
</BarChart>
</ResponsiveContainer>
</div>
<p className="text-[11px] text-zinc-400 mt-2">
{valueModel.premiumKnown
? 'Savings shown are illustrative and depend on carrier approval, policy terms, and documentation.'
: 'Add your insurance premium earlier in the wizard to see an illustrative 10-year savings scenario.'}
</p>
</div>
);
};
export default TenYearValueChart;
@@ -18,6 +18,7 @@ import PremiumStep from './steps/PremiumStep';
import HailRiskStep from './steps/HailRiskStep';
import OptionsStep from './steps/OptionsStep';
import AddonsStep from './steps/AddonsStep';
import RecommendationStep from './steps/RecommendationStep';
import PlaceholderStep from './steps/PlaceholderStep';
// Step registry — later phases swap PlaceholderStep entries for real components.
@@ -28,7 +29,7 @@ const STEP_COMPONENTS = {
hailRisk: HailRiskStep,
options: OptionsStep,
addons: AddonsStep,
recommendation: (props) => <PlaceholderStep stepId="recommendation" {...props} />,
recommendation: RecommendationStep,
report: (props) => <PlaceholderStep stepId="report" {...props} />,
};
@@ -0,0 +1,127 @@
/**
* RecommendationStep — Screen 8 "AI recommendation" (spec §9, §10).
*
* Flow:
* 1. Deterministic scoring engine picks the highlight + reasons (logged).
* 2. 10-year value model computes the savings/lifecycle comparison.
* 3. AI turns the reasons into plain-language copy (guardrailed); falls back to
* deterministic text if AI is unavailable or trips a banned-claim check.
* The recommendation record is frozen into the session (spec §12 recommendations).
*/
import React, { useEffect, useMemo, useState } from 'react';
import { Sparkles, Loader2, Check, ShieldCheck } from 'lucide-react';
import { useEstimateWizard } from '../../EstimateWizardContext';
import { score } from '../../engine/scoringEngine';
import { computeTenYearValue } from '../../engine/valueModel';
import { generateExplanation } from '../../ai/explain';
import GoodBetterBestCards from '../GoodBetterBestCards';
import TenYearValueChart from '../TenYearValueChart';
const PKG_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' };
const RecommendationStep = () => {
const { inputs, session, patchSession, tenantConfig, logEvent } = useEstimateWizard();
// Deterministic — recomputed from current answers, stable across renders.
const recommendation = useMemo(
() => score({ inputs, hailSnapshot: session.hailSnapshot, tenantConfig }),
[inputs, session.hailSnapshot, tenantConfig]
);
const value = useMemo(
() => computeTenYearValue({ inputs, tenantConfig }),
[inputs, tenantConfig]
);
const [explanation, setExplanation] = useState(session.recommendation?.explanation || null);
const [loadingAI, setLoadingAI] = useState(true);
// Freeze deterministic record + log it (reproducible audit, spec §18).
useEffect(() => {
patchSession({ recommendation: { ...recommendation, value } });
logEvent('recommendation_scored', {
modelVersion: recommendation.modelVersion,
selectedPackage: recommendation.selectedPackage,
scores: Object.fromEntries(Object.entries(recommendation.scores).map(([k, v]) => [k, v.total])),
uncertaintyFlags: recommendation.uncertaintyFlags,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [recommendation, value]);
// AI explanation (async, guardrailed).
useEffect(() => {
let cancelled = false;
setLoadingAI(true);
generateExplanation({ inputs, hailSnapshot: session.hailSnapshot, recommendation, valueModel: value, tenantConfig })
.then((res) => {
if (cancelled) return;
setExplanation(res);
patchSession({ recommendation: { ...recommendation, value, explanation: res } });
logEvent('ai_explanation_generated', { aiUsed: res.aiUsed, guardrailOk: res.guardrail?.ok });
});
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [recommendation, value]);
// Lock the highlighted option in as the customer's selection if they hadn't chosen.
useEffect(() => {
if (!session.selectedPackage) patchSession({ selectedPackage: recommendation.selectedPackage });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const highlight = recommendation.selectedPackage;
return (
<div className="space-y-5">
<GoodBetterBestCards
packages={tenantConfig.packages}
pricing={tenantConfig.pricing}
selectedType={session.selectedPackage || highlight}
highlightedType={highlight}
onSelect={(t) => { patchSession({ selectedPackage: t }); logEvent('package_selected', { package: t, after: 'recommendation' }); }}
/>
{/* AI explanation */}
<div className="rounded-2xl border border-amber-200 dark:border-amber-500/20 bg-amber-50/60 dark:bg-amber-500/5 p-4">
<div className="flex items-center gap-2 mb-2">
<Sparkles size={16} className="text-amber-500" />
<span className="font-semibold text-zinc-900 dark:text-white">
Why we highlighted {PKG_LABEL[highlight]}
</span>
{explanation && (
<span className="ml-auto text-[10px] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400 inline-flex items-center gap-1">
<ShieldCheck size={11} /> {explanation.aiUsed ? 'AI-assisted' : 'Reviewed'}
</span>
)}
</div>
{loadingAI && !explanation ? (
<div className="flex items-center gap-2 text-sm text-zinc-500 dark:text-zinc-400 py-2">
<Loader2 size={15} className="animate-spin" /> Preparing your explanation
</div>
) : (
<>
<p className="text-sm text-zinc-700 dark:text-zinc-200 leading-relaxed">
{explanation?.customerExplanation}
</p>
<ul className="mt-3 space-y-1.5">
{recommendation.reasons.map((r) => (
<li key={r} className="flex items-start gap-2 text-sm text-zinc-600 dark:text-zinc-300">
<Check size={14} className="text-amber-500 mt-0.5 shrink-0" /> {r}
</li>
))}
</ul>
</>
)}
</div>
<TenYearValueChart valueModel={value} />
<p className="text-xs text-zinc-400 leading-relaxed">
This recommendation considers your stated budget and goals all three options stay available.
Insurance savings are illustrative and must be confirmed with your carrier.
</p>
</div>
);
};
export default RecommendationStep;