AI recomendation feature
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user