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,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);
+122
View File
@@ -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 homeowners 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 areas 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,
};
}