From 0da194b157ccc83d9f783d8f14ea94b6e0111073 Mon Sep 17 00:00:00 2001 From: Mayur Shinde Date: Tue, 9 Jun 2026 17:45:29 +0530 Subject: [PATCH] AI recomendation feature --- src/modules/estimateWizard/ai/bannedClaims.js | 44 +++++ src/modules/estimateWizard/ai/explain.js | 122 +++++++++++++ .../estimateWizard/ai/knowledgeBase.js | 34 ++++ .../components/TenYearValueChart.jsx | 52 ++++++ .../estimateWizard/components/WizardShell.jsx | 3 +- .../components/steps/RecommendationStep.jsx | 127 ++++++++++++++ .../estimateWizard/engine/scoringEngine.js | 163 ++++++++++++++++++ .../estimateWizard/engine/valueModel.js | 72 ++++++++ 8 files changed, 616 insertions(+), 1 deletion(-) create mode 100644 src/modules/estimateWizard/ai/bannedClaims.js create mode 100644 src/modules/estimateWizard/ai/explain.js create mode 100644 src/modules/estimateWizard/ai/knowledgeBase.js create mode 100644 src/modules/estimateWizard/components/TenYearValueChart.jsx create mode 100644 src/modules/estimateWizard/components/steps/RecommendationStep.jsx create mode 100644 src/modules/estimateWizard/engine/scoringEngine.js create mode 100644 src/modules/estimateWizard/engine/valueModel.js diff --git a/src/modules/estimateWizard/ai/bannedClaims.js b/src/modules/estimateWizard/ai/bannedClaims.js new file mode 100644 index 0000000..9f7f924 --- /dev/null +++ b/src/modules/estimateWizard/ai/bannedClaims.js @@ -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); diff --git a/src/modules/estimateWizard/ai/explain.js b/src/modules/estimateWizard/ai/explain.js new file mode 100644 index 0000000..4703337 --- /dev/null +++ b/src/modules/estimateWizard/ai/explain.js @@ -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); + } +} diff --git a/src/modules/estimateWizard/ai/knowledgeBase.js b/src/modules/estimateWizard/ai/knowledgeBase.js new file mode 100644 index 0000000..27ad14a --- /dev/null +++ b/src/modules/estimateWizard/ai/knowledgeBase.js @@ -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.`; diff --git a/src/modules/estimateWizard/components/TenYearValueChart.jsx b/src/modules/estimateWizard/components/TenYearValueChart.jsx new file mode 100644 index 0000000..ab6b088 --- /dev/null +++ b/src/modules/estimateWizard/components/TenYearValueChart.jsx @@ -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 ( +
+

10-year value comparison

+
+ + + + + `$${Number(v).toLocaleString('en-US')}`} + contentStyle={{ borderRadius: 12, border: '1px solid #e4e4e7', fontSize: 12 }} + /> + + + {data.map((d) => )} + + {valueModel.premiumKnown && !valueModel.conservativeMode && ( + + )} + + +
+

+ {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.'} +

+
+ ); +}; + +export default TenYearValueChart; diff --git a/src/modules/estimateWizard/components/WizardShell.jsx b/src/modules/estimateWizard/components/WizardShell.jsx index 71033bc..fddf82c 100644 --- a/src/modules/estimateWizard/components/WizardShell.jsx +++ b/src/modules/estimateWizard/components/WizardShell.jsx @@ -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) => , + recommendation: RecommendationStep, report: (props) => , }; diff --git a/src/modules/estimateWizard/components/steps/RecommendationStep.jsx b/src/modules/estimateWizard/components/steps/RecommendationStep.jsx new file mode 100644 index 0000000..0778b93 --- /dev/null +++ b/src/modules/estimateWizard/components/steps/RecommendationStep.jsx @@ -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 ( +
+ { patchSession({ selectedPackage: t }); logEvent('package_selected', { package: t, after: 'recommendation' }); }} + /> + + {/* AI explanation */} +
+
+ + + Why we highlighted {PKG_LABEL[highlight]} + + {explanation && ( + + {explanation.aiUsed ? 'AI-assisted' : 'Reviewed'} + + )} +
+ + {loadingAI && !explanation ? ( +
+ Preparing your explanation… +
+ ) : ( + <> +

+ {explanation?.customerExplanation} +

+
    + {recommendation.reasons.map((r) => ( +
  • + {r} +
  • + ))} +
+ + )} +
+ + + +

+ This recommendation considers your stated budget and goals — all three options stay available. + Insurance savings are illustrative and must be confirmed with your carrier. +

+
+ ); +}; + +export default RecommendationStep; diff --git a/src/modules/estimateWizard/engine/scoringEngine.js b/src/modules/estimateWizard/engine/scoringEngine.js new file mode 100644 index 0000000..b6c088f --- /dev/null +++ b/src/modules/estimateWizard/engine/scoringEngine.js @@ -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, + }; +} diff --git a/src/modules/estimateWizard/engine/valueModel.js b/src/modules/estimateWizard/engine/valueModel.js new file mode 100644 index 0000000..a87f017 --- /dev/null +++ b/src/modules/estimateWizard/engine/valueModel.js @@ -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, + }; +}