AI recomendation feature
This commit is contained in:
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* bannedClaims.js — guardrail for AI-generated customer copy (spec §15, §18).
|
||||||
|
*
|
||||||
|
* Every AI explanation MUST pass validateClaim() before it is shown or stored.
|
||||||
|
* On any violation the caller discards the AI text and uses the deterministic
|
||||||
|
* fallback. This enforces: no guaranteed discount, no guaranteed hail event,
|
||||||
|
* no insurance/legal advice, no manufacturer names (unless brand mode enabled).
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Manufacturer / brand names that must not appear in customer-facing copy (§3, §15).
|
||||||
|
const MANUFACTURER_NAMES = [
|
||||||
|
'gaf', 'owens corning', 'certainteed', 'malarkey', 'iko', 'tamko',
|
||||||
|
'atlas', 'decra', 'davinci', 'brava', 'f-wave', 'euroshield', 'boral', 'timberline',
|
||||||
|
];
|
||||||
|
|
||||||
|
const BANNED_PATTERNS = [
|
||||||
|
{ id: 'guaranteed_discount', re: /\b(guarantee[ds]?|guaranteed)\b[^.]*\b(discount|savings|premium)\b/i },
|
||||||
|
{ id: 'guaranteed_discount2', re: /\b(discount|savings)\b[^.]*\bguarantee/i },
|
||||||
|
{ id: 'will_get_hail', re: /\b(will|going to|guaranteed to)\b[^.]*\b(hail|storm|be hit|get hit)\b/i },
|
||||||
|
{ id: 'never_replace', re: /\bnever (have to )?replace\b/i },
|
||||||
|
{ id: 'lifetime_no_maintenance', re: /\b(maintenance[- ]free|zero maintenance) (for )?life/i },
|
||||||
|
{ id: 'file_a_claim', re: /\b(file|submit) (a|an|your) (insurance )?claim\b/i },
|
||||||
|
{ id: 'insurance_advice', re: /\b(you should|we recommend|advise you to) (change|switch|drop|cancel) (your )?insurance\b/i },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {{ ok: boolean, violations: string[] }}
|
||||||
|
*/
|
||||||
|
export function validateClaim(text = '', { allowBrands = false } = {}) {
|
||||||
|
const violations = [];
|
||||||
|
const lower = text.toLowerCase();
|
||||||
|
|
||||||
|
for (const { id, re } of BANNED_PATTERNS) {
|
||||||
|
if (re.test(text)) violations.push(id);
|
||||||
|
}
|
||||||
|
if (!allowBrands) {
|
||||||
|
for (const name of MANUFACTURER_NAMES) {
|
||||||
|
if (lower.includes(name)) { violations.push(`manufacturer:${name}`); break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: violations.length === 0, violations };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BANNED_CLAIM_IDS = BANNED_PATTERNS.map((p) => p.id);
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* explain.js — AI recommendation explanation + rep brief (spec §9).
|
||||||
|
*
|
||||||
|
* Design:
|
||||||
|
* - The deterministic engine already chose the package and the reasons. The AI
|
||||||
|
* only turns them into warm plain-language copy; it cannot change the result.
|
||||||
|
* - Reuses the app's existing Groq integration (config.groqApiKey). With no valid
|
||||||
|
* key (config.isDemoMode) it skips the API and uses a deterministic fallback.
|
||||||
|
* - EVERY AI output passes validateClaim(); on any violation we discard the AI
|
||||||
|
* text and use the safe fallback (spec §15, §18).
|
||||||
|
*/
|
||||||
|
import Groq from 'groq-sdk';
|
||||||
|
import { config } from '../../../config/env';
|
||||||
|
import { SYSTEM_PROMPT } from './knowledgeBase';
|
||||||
|
import { validateClaim } from './bannedClaims';
|
||||||
|
|
||||||
|
const AI_MODEL = 'openai/gpt-oss-120b';
|
||||||
|
|
||||||
|
const PKG_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' };
|
||||||
|
|
||||||
|
function fmtUSD(n) { return `$${Math.round(n).toLocaleString('en-US')}`; }
|
||||||
|
|
||||||
|
// ── Deterministic fallback (also the safety net if AI is unavailable/invalid) ──
|
||||||
|
|
||||||
|
function fallbackExplanation({ inputs, recommendation, valueModel }) {
|
||||||
|
const type = recommendation.selectedPackage;
|
||||||
|
const label = PKG_LABEL[type];
|
||||||
|
const reasons = recommendation.reasons;
|
||||||
|
|
||||||
|
const row = valueModel.rows.find((r) => r.type === type);
|
||||||
|
let savingsLine = '';
|
||||||
|
if (valueModel.premiumKnown && row?.savings) {
|
||||||
|
savingsLine = ` Over 10 years, an illustrative savings scenario ranges from ${fmtUSD(row.savings.low)} to ${fmtUSD(row.savings.high)} — this is only an estimate and must be confirmed with your insurance carrier.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const customerExplanation =
|
||||||
|
`Based on your answers, the ${label} option appears to be the strongest fit for your home and goals.` +
|
||||||
|
` ${reasons[0] || ''}${savingsLine} We still recommend reviewing all three options with your roofing consultant, since final pricing, roof measurement, ventilation, and insurance eligibility must be verified.`;
|
||||||
|
|
||||||
|
return { customerExplanation, reasons, repBrief: buildRepBrief({ inputs, recommendation }), aiUsed: false, guardrail: { ok: true, violations: [] } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rep brief is deterministic regardless of AI (spec §9) — concise sales prep.
|
||||||
|
function buildRepBrief({ inputs, recommendation }) {
|
||||||
|
const parts = [];
|
||||||
|
parts.push(`Recommended: ${PKG_LABEL[recommendation.selectedPackage]} (${recommendation.modelVersion}).`);
|
||||||
|
if (inputs.ownershipHorizon) parts.push(`Ownership ${inputs.ownershipHorizon} yrs.`);
|
||||||
|
if (inputs.budgetPreference) parts.push(`Budget pref: ${inputs.budgetPreference}.`);
|
||||||
|
if (inputs.premiumUnknown || !inputs.premiumAmount) parts.push('Premium skipped — bring savings-scenario explanation.');
|
||||||
|
else parts.push(`Premium ~$${inputs.premiumAmount}/yr.`);
|
||||||
|
if (inputs.atticConcern && inputs.atticConcern !== 'none') parts.push('Mentioned attic/ventilation — bring ventilation guidance.');
|
||||||
|
if (inputs.pestConcern === 'yes') parts.push('Pest concern — discuss critter guard.');
|
||||||
|
if (inputs.issueType === 'leak') parts.push('Active leak — prioritize inspection.');
|
||||||
|
if (inputs.hoaConstraints === 'yes') parts.push('HOA constraints — verify approval before final material.');
|
||||||
|
if (inputs.financingInterest === 'yes') parts.push('Interested in financing — prepare options.');
|
||||||
|
if (recommendation.budgetGuardApplied) parts.push('Budget guard applied (did not upsell to Best over stated low-cost preference).');
|
||||||
|
if (recommendation.uncertaintyFlags.length) parts.push(`Data gaps: ${recommendation.uncertaintyFlags.join(', ')}.`);
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AI path ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function buildUserPrompt({ inputs, hailSnapshot, recommendation, valueModel }) {
|
||||||
|
const type = recommendation.selectedPackage;
|
||||||
|
const row = valueModel.rows.find((r) => r.type === type);
|
||||||
|
return `Write a 3-4 sentence plain-language explanation for the homeowner of why the ${PKG_LABEL[type]} roof option is highlighted, then a one-line internal rep brief.
|
||||||
|
|
||||||
|
Context (do not contradict; do not add numbers not here):
|
||||||
|
- Recommended option: ${PKG_LABEL[type]}
|
||||||
|
- Deterministic reasons: ${recommendation.reasons.join(' | ')}
|
||||||
|
- Ownership horizon: ${inputs.ownershipHorizon || 'unknown'}
|
||||||
|
- Budget preference: ${inputs.budgetPreference || 'unknown'}
|
||||||
|
- Roof age: ${inputs.roofAgeYears ?? 'unknown'} years
|
||||||
|
- Area hail exposure (historical): ${hailSnapshot?.exposureTier || 'unknown'}
|
||||||
|
- Annual premium: ${inputs.premiumUnknown || !inputs.premiumAmount ? 'not provided' : '$' + inputs.premiumAmount}
|
||||||
|
- 10-yr illustrative savings (base, ${type}): ${row?.savings ? '$' + row.savings.base : 'n/a'}
|
||||||
|
|
||||||
|
Respond ONLY as JSON: {"customerExplanation": "...", "repBrief": "..."}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateExplanation(ctx) {
|
||||||
|
// No valid key → deterministic mode (still fully functional for the demo).
|
||||||
|
if (config.isDemoMode(config.groqApiKey)) {
|
||||||
|
return fallbackExplanation(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const groq = new Groq({ apiKey: config.groqApiKey, dangerouslyAllowBrowser: true });
|
||||||
|
const completion = await groq.chat.completions.create({
|
||||||
|
model: AI_MODEL,
|
||||||
|
temperature: 0.3,
|
||||||
|
max_tokens: 400,
|
||||||
|
response_format: { type: 'json_object' },
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: SYSTEM_PROMPT },
|
||||||
|
{ role: 'user', content: buildUserPrompt(ctx) },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const raw = completion.choices?.[0]?.message?.content || '';
|
||||||
|
let parsed;
|
||||||
|
try { parsed = JSON.parse(raw); } catch { return fallbackExplanation(ctx); }
|
||||||
|
|
||||||
|
const customerExplanation = String(parsed.customerExplanation || '').trim();
|
||||||
|
const repBriefAI = String(parsed.repBrief || '').trim();
|
||||||
|
|
||||||
|
// Guardrail: validate the customer-facing text. Any violation → safe fallback.
|
||||||
|
const guardrail = validateClaim(customerExplanation);
|
||||||
|
if (!guardrail.ok || customerExplanation.length < 20) {
|
||||||
|
const fb = fallbackExplanation(ctx);
|
||||||
|
return { ...fb, guardrail, blockedAiText: customerExplanation || null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rep brief stays deterministic + optional AI sentence (also validated).
|
||||||
|
const repValidated = validateClaim(repBriefAI).ok ? repBriefAI : '';
|
||||||
|
const repBrief = [buildRepBrief(ctx), repValidated].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
return { customerExplanation, reasons: ctx.recommendation.reasons, repBrief, aiUsed: true, guardrail };
|
||||||
|
} catch {
|
||||||
|
return fallbackExplanation(ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* knowledgeBase.js — approved content blocks for AI grounding (spec §9, §15).
|
||||||
|
* The AI must ground answers in these; unknown questions route to the rep. Keeping
|
||||||
|
* the approved facts in one place makes the guardrails auditable.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const APPROVED_FACTS = {
|
||||||
|
class4: 'Class 4 means the roofing material has passed a higher impact-resistance test (UL 2218). Whether it affects an insurance premium is decided by the carrier, not the contractor.',
|
||||||
|
hailFraming: 'Historical hail data describes the surrounding AREA over time. It is not a forecast or a guarantee that a specific home will be hit.',
|
||||||
|
insuranceDisclaimer: 'Any insurance premium savings are illustrative and must be confirmed with the homeowner’s insurance carrier or licensed agent. Discounts depend on state, carrier, policy, roof class, and documentation.',
|
||||||
|
serviceLife: 'Premium systems are designed for longer service life, but actual lifespan depends on installation, ventilation, maintenance, climate, and product. Avoid promises like “never replace again.”',
|
||||||
|
preliminary: 'Every wizard estimate is preliminary until a roof measurement and contractor inspection verify it.',
|
||||||
|
neutrality: 'Describe systems by generic category (architectural shingle, Class 4 impact-rated shingle, composite/synthetic or stone-coated steel). Do not name manufacturers.',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Compact system prompt for the AI explainer (kept short for fast demo responses).
|
||||||
|
export const SYSTEM_PROMPT = `You are LynkedUp Pro's roofing estimate explainer. You write short, plain-language, HONEST explanations for a homeowner comparing Good/Better/Best roof options.
|
||||||
|
|
||||||
|
NON-NEGOTIABLE RULES:
|
||||||
|
- Never guarantee an insurance discount or savings. Say savings are illustrative and must be confirmed with their carrier.
|
||||||
|
- Never say a home WILL get hail or be hit. Only describe historical AREA exposure.
|
||||||
|
- Never give insurance or legal advice. Never tell them to file a claim or change insurance.
|
||||||
|
- Never name roofing manufacturers/brands. Use generic categories only.
|
||||||
|
- Never promise "never replace again" — say "designed for longer service life."
|
||||||
|
- Do not invent measurements, prices, or discounts beyond what you are given.
|
||||||
|
- The recommended option is decided by a deterministic engine; you EXPLAIN it, you do not change it.
|
||||||
|
|
||||||
|
Approved facts you may use:
|
||||||
|
- ${APPROVED_FACTS.class4}
|
||||||
|
- ${APPROVED_FACTS.hailFraming}
|
||||||
|
- ${APPROVED_FACTS.insuranceDisclaimer}
|
||||||
|
- ${APPROVED_FACTS.serviceLife}
|
||||||
|
|
||||||
|
Tone: warm, clear, non-pushy. No hype.`;
|
||||||
@@ -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 HailRiskStep from './steps/HailRiskStep';
|
||||||
import OptionsStep from './steps/OptionsStep';
|
import OptionsStep from './steps/OptionsStep';
|
||||||
import AddonsStep from './steps/AddonsStep';
|
import AddonsStep from './steps/AddonsStep';
|
||||||
|
import RecommendationStep from './steps/RecommendationStep';
|
||||||
import PlaceholderStep from './steps/PlaceholderStep';
|
import PlaceholderStep from './steps/PlaceholderStep';
|
||||||
|
|
||||||
// Step registry — later phases swap PlaceholderStep entries for real components.
|
// Step registry — later phases swap PlaceholderStep entries for real components.
|
||||||
@@ -28,7 +29,7 @@ const STEP_COMPONENTS = {
|
|||||||
hailRisk: HailRiskStep,
|
hailRisk: HailRiskStep,
|
||||||
options: OptionsStep,
|
options: OptionsStep,
|
||||||
addons: AddonsStep,
|
addons: AddonsStep,
|
||||||
recommendation: (props) => <PlaceholderStep stepId="recommendation" {...props} />,
|
recommendation: RecommendationStep,
|
||||||
report: (props) => <PlaceholderStep stepId="report" {...props} />,
|
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;
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
/**
|
||||||
|
* scoringEngine.js — deterministic Good/Better/Best recommendation (spec §9).
|
||||||
|
*
|
||||||
|
* Contract (spec §9, §18):
|
||||||
|
* - Deterministic & reproducible: same inputs + same weights ⇒ same scores.
|
||||||
|
* - Logged: returns the full per-factor breakdown so it can be audited.
|
||||||
|
* - AI may EXPLAIN the result but must never silently change it.
|
||||||
|
* - Never always recommends Best — budget preference + uncertainty penalties guard
|
||||||
|
* against over-recommendation (spec §15 fairness).
|
||||||
|
*
|
||||||
|
* Pure function. No I/O, no randomness, no Date.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TYPES = ['GOOD', 'BETTER', 'BEST'];
|
||||||
|
export const SCORING_MODEL_VERSION = 'scoring-v1';
|
||||||
|
|
||||||
|
// ── Per-factor sub-scores (0..1) by package type ─────────────────────────────
|
||||||
|
|
||||||
|
function ownershipFactor(horizon) {
|
||||||
|
switch (horizon) {
|
||||||
|
case '1-5': return { GOOD: 1.0, BETTER: 0.6, BEST: 0.3 };
|
||||||
|
case '6-15': return { GOOD: 0.5, BETTER: 1.0, BEST: 0.7 };
|
||||||
|
case '16+': return { GOOD: 0.3, BETTER: 0.7, BEST: 1.0 };
|
||||||
|
default: return { GOOD: 0.6, BETTER: 0.7, BEST: 0.6 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hailFactor(exposureTier) {
|
||||||
|
switch (exposureTier) {
|
||||||
|
case 'low': return { GOOD: 1.0, BETTER: 0.7, BEST: 0.5 };
|
||||||
|
case 'moderate': return { GOOD: 0.5, BETTER: 1.0, BEST: 0.8 };
|
||||||
|
case 'high': return { GOOD: 0.2, BETTER: 0.8, BEST: 1.0 };
|
||||||
|
default: return { GOOD: 0.6, BETTER: 0.7, BEST: 0.6 }; // unknown → neutral
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function budgetFactor(pref) {
|
||||||
|
switch (pref) {
|
||||||
|
case 'lowest': return { GOOD: 1.0, BETTER: 0.5, BEST: 0.2 };
|
||||||
|
case 'balanced': return { GOOD: 0.6, BETTER: 1.0, BEST: 0.6 };
|
||||||
|
case 'longterm': return { GOOD: 0.3, BETTER: 0.7, BEST: 1.0 };
|
||||||
|
default: return { GOOD: 0.7, BETTER: 0.8, BEST: 0.6 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function roofAgeFactor(years, issueType) {
|
||||||
|
const urgent = issueType === 'leak';
|
||||||
|
if (years >= 15 || urgent) return { GOOD: 0.5, BETTER: 0.8, BEST: 1.0 };
|
||||||
|
if (years >= 8) return { GOOD: 0.7, BETTER: 1.0, BEST: 0.8 };
|
||||||
|
return { GOOD: 1.0, BETTER: 0.8, BEST: 0.6 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insurance-savings factor scales with premium × each package's discount.
|
||||||
|
function insuranceFactor(premium, packages) {
|
||||||
|
if (!premium || premium <= 0) {
|
||||||
|
return { factors: { GOOD: 0.5, BETTER: 0.5, BEST: 0.5 }, known: false };
|
||||||
|
}
|
||||||
|
const savings = {};
|
||||||
|
packages.forEach((p) => { savings[p.type] = premium * ((p.insuranceDiscountPct || 0) / 100); });
|
||||||
|
const max = Math.max(...Object.values(savings), 1);
|
||||||
|
const factors = {};
|
||||||
|
TYPES.forEach((t) => { factors[t] = +(savings[t] / max).toFixed(3); });
|
||||||
|
return { factors, known: true, savings };
|
||||||
|
}
|
||||||
|
|
||||||
|
function aestheticsFactor(inputs) {
|
||||||
|
const premiumLook = inputs.budgetPreference === 'longterm' || inputs.hoaConstraints === 'yes';
|
||||||
|
return premiumLook
|
||||||
|
? { GOOD: 0.5, BETTER: 0.8, BEST: 1.0 }
|
||||||
|
: { GOOD: 0.7, BETTER: 0.9, BEST: 1.0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reason synthesis (deterministic, feeds AI + fallback copy) ────────────────
|
||||||
|
|
||||||
|
function buildReasons(type, ctx) {
|
||||||
|
const { inputs, hailSnapshot } = ctx;
|
||||||
|
const r = [];
|
||||||
|
if (type === 'BEST') {
|
||||||
|
if (inputs.ownershipHorizon === '16+') r.push('You plan to stay in the home long-term (16+ years), so a longer-life system spreads its cost over more years.');
|
||||||
|
if (hailSnapshot?.exposureTier === 'high') r.push('Your area shows higher historical hail exposure, where the most durable system adds the most protection.');
|
||||||
|
if (inputs.premiumAmount > 0) r.push('Your insurance premium creates a potential savings scenario if your carrier recognizes qualifying impact-resistant materials.');
|
||||||
|
r.push('It carries the strongest lifecycle value case and fewest expected future roof cycles.');
|
||||||
|
} else if (type === 'BETTER') {
|
||||||
|
r.push('It improves hail resistance with a Class 4 impact-rated system at a balanced cost.');
|
||||||
|
if (hailSnapshot?.exposureTier !== 'low') r.push('Given your area’s historical hail activity, the added impact resistance is meaningful.');
|
||||||
|
if (inputs.premiumAmount > 0) r.push('It may qualify for insurance premium consideration where available — confirm eligibility with your carrier.');
|
||||||
|
r.push('It balances stronger protection against premium-system cost.');
|
||||||
|
} else {
|
||||||
|
if (inputs.budgetPreference === 'lowest') r.push('You prioritized the lowest upfront investment.');
|
||||||
|
if (inputs.ownershipHorizon === '1-5') r.push('A shorter ownership horizon makes lowest upfront cost a rational choice.');
|
||||||
|
if (hailSnapshot?.exposureTier === 'low') r.push('Your area shows lower historical hail exposure.');
|
||||||
|
r.push('It uses a quality architectural shingle system that meets code with standard accessories.');
|
||||||
|
}
|
||||||
|
return r.slice(0, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* score() — main entry.
|
||||||
|
* @param {object} args { inputs, hailSnapshot, tenantConfig }
|
||||||
|
* @returns recommendation record (spec §12 recommendations)
|
||||||
|
*/
|
||||||
|
export function score({ inputs, hailSnapshot, tenantConfig }) {
|
||||||
|
const weights = tenantConfig.scoringWeights;
|
||||||
|
const packages = tenantConfig.packages;
|
||||||
|
|
||||||
|
const fOwn = ownershipFactor(inputs.ownershipHorizon);
|
||||||
|
const fHail = hailFactor(hailSnapshot?.exposureTier);
|
||||||
|
const fBudget = budgetFactor(inputs.budgetPreference);
|
||||||
|
const fAge = roofAgeFactor(inputs.roofAgeYears ?? 10, inputs.issueType);
|
||||||
|
const fIns = insuranceFactor(inputs.premiumAmount, packages);
|
||||||
|
const fAes = aestheticsFactor(inputs);
|
||||||
|
|
||||||
|
const uncertaintyFlags = [];
|
||||||
|
if (!inputs.premiumAmount || inputs.premiumUnknown) uncertaintyFlags.push('premium_unknown');
|
||||||
|
if (!hailSnapshot || hailSnapshot.exposureTier == null) uncertaintyFlags.push('hail_unknown');
|
||||||
|
if (inputs.hoaConstraints === 'unsure') uncertaintyFlags.push('hoa_unsure');
|
||||||
|
|
||||||
|
const scores = {};
|
||||||
|
TYPES.forEach((t) => {
|
||||||
|
const factors = {
|
||||||
|
ownershipHorizon: fOwn[t],
|
||||||
|
hailRisk: fHail[t],
|
||||||
|
insuranceSavings: fIns.factors[t],
|
||||||
|
budgetPreference: fBudget[t],
|
||||||
|
roofAge: fAge[t],
|
||||||
|
aesthetics: fAes[t],
|
||||||
|
};
|
||||||
|
let total =
|
||||||
|
factors.ownershipHorizon * weights.ownershipHorizon +
|
||||||
|
factors.hailRisk * weights.hailRisk +
|
||||||
|
factors.insuranceSavings * weights.insuranceSavings +
|
||||||
|
factors.budgetPreference * weights.budgetPreference +
|
||||||
|
factors.roofAge * weights.roofAge +
|
||||||
|
factors.aesthetics * weights.aesthetics;
|
||||||
|
|
||||||
|
// Uncertainty penalty — applied to BEST so missing data never auto-upsells.
|
||||||
|
if (t === 'BEST') total -= 0.04 * uncertaintyFlags.length;
|
||||||
|
|
||||||
|
scores[t] = { total: +total.toFixed(4), factors };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pick the top-scoring package.
|
||||||
|
let selectedPackage = TYPES.reduce((best, t) => (scores[t].total > scores[best].total ? t : best), 'GOOD');
|
||||||
|
|
||||||
|
// Budget guard (spec §7/§15): a strong "lowest upfront" preference must not be
|
||||||
|
// overridden to BEST unless exposure is high AND the homeowner is long-term.
|
||||||
|
let budgetGuardApplied = false;
|
||||||
|
if (inputs.budgetPreference === 'lowest' && selectedPackage === 'BEST') {
|
||||||
|
const justified = hailSnapshot?.exposureTier === 'high' && inputs.ownershipHorizon === '16+';
|
||||||
|
if (!justified) { selectedPackage = 'BETTER'; budgetGuardApplied = true; }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
modelVersion: SCORING_MODEL_VERSION,
|
||||||
|
weights,
|
||||||
|
scores,
|
||||||
|
selectedPackage,
|
||||||
|
reasons: buildReasons(selectedPackage, { inputs, hailSnapshot }),
|
||||||
|
uncertaintyFlags,
|
||||||
|
budgetGuardApplied,
|
||||||
|
insuranceKnown: fIns.known,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* valueModel.js — 10-year value comparison (spec §10).
|
||||||
|
*
|
||||||
|
* Makes premium options understandable WITHOUT pretending savings are guaranteed:
|
||||||
|
* - 10-yr premium savings scenario = annual premium × assumed discount × 10
|
||||||
|
* - shows low/base/high range (carrier discount is never certain)
|
||||||
|
* - upgrade breakeven only when pricing AND discount are known
|
||||||
|
* - lifecycle value delta is labeled a model estimate, not a promise
|
||||||
|
*
|
||||||
|
* Pure function. Honors conservativeMode (spec §18) by suppressing savings claims.
|
||||||
|
*/
|
||||||
|
import { pricePackage } from './pricing';
|
||||||
|
|
||||||
|
const YEARS = 10;
|
||||||
|
|
||||||
|
export function computeTenYearValue({ inputs, tenantConfig }) {
|
||||||
|
const { packages, pricing, discountScenarios, conservativeMode } = tenantConfig;
|
||||||
|
const premium = (!inputs.premiumUnknown && inputs.premiumAmount > 0) ? inputs.premiumAmount : null;
|
||||||
|
|
||||||
|
const goodPrice = pricePackage(packages.find((p) => p.type === 'GOOD'), pricing).base;
|
||||||
|
|
||||||
|
const rows = packages.map((pkg) => {
|
||||||
|
const price = pricePackage(pkg, pricing);
|
||||||
|
const scen = discountScenarios[pkg.type] || { low: 0, base: 0, high: 0 };
|
||||||
|
|
||||||
|
// 10-year premium savings scenario (illustrative).
|
||||||
|
const savings = (premium && !conservativeMode)
|
||||||
|
? {
|
||||||
|
low: Math.round(premium * (scen.low / 100) * YEARS),
|
||||||
|
base: Math.round(premium * (scen.base / 100) * YEARS),
|
||||||
|
high: Math.round(premium * (scen.high / 100) * YEARS),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Upgrade cost vs the Good baseline.
|
||||||
|
const upgradeCost = Math.max(0, price.base - goodPrice);
|
||||||
|
|
||||||
|
// Breakeven (years) — only when both pricing and a positive savings scenario exist.
|
||||||
|
const annualSavingsBase = (premium && !conservativeMode) ? premium * (scen.base / 100) : 0;
|
||||||
|
const breakevenYears = (upgradeCost > 0 && annualSavingsBase > 0)
|
||||||
|
? +(upgradeCost / annualSavingsBase).toFixed(1)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Lifecycle value delta (model estimate): savings + avoided future roof reserve − upgrade cost.
|
||||||
|
// Avoided-reserve proxy: extra service life beyond Good, valued at Good's price prorated.
|
||||||
|
const goodLife = packages.find((p) => p.type === 'GOOD').serviceLifeYears || 28;
|
||||||
|
const extraLife = Math.max(0, (pkg.serviceLifeYears || goodLife) - goodLife);
|
||||||
|
const avoidedReserve = Math.round((extraLife / goodLife) * goodPrice * 0.5);
|
||||||
|
const lifecycleDelta = (savings ? savings.base : 0) + avoidedReserve - upgradeCost;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: pkg.type,
|
||||||
|
name: pkg.name,
|
||||||
|
price,
|
||||||
|
discountPct: scen.base,
|
||||||
|
serviceLifeYears: pkg.serviceLifeYears,
|
||||||
|
hailResistance: pkg.hailResistance,
|
||||||
|
savings,
|
||||||
|
upgradeCost,
|
||||||
|
breakevenYears,
|
||||||
|
avoidedReserve,
|
||||||
|
lifecycleDelta: Math.round(lifecycleDelta),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
years: YEARS,
|
||||||
|
premiumKnown: !!premium,
|
||||||
|
conservativeMode: !!conservativeMode,
|
||||||
|
rows,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user