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 OptionsStep from './steps/OptionsStep';
|
||||
import AddonsStep from './steps/AddonsStep';
|
||||
import RecommendationStep from './steps/RecommendationStep';
|
||||
import PlaceholderStep from './steps/PlaceholderStep';
|
||||
|
||||
// Step registry — later phases swap PlaceholderStep entries for real components.
|
||||
@@ -28,7 +29,7 @@ const STEP_COMPONENTS = {
|
||||
hailRisk: HailRiskStep,
|
||||
options: OptionsStep,
|
||||
addons: AddonsStep,
|
||||
recommendation: (props) => <PlaceholderStep stepId="recommendation" {...props} />,
|
||||
recommendation: RecommendationStep,
|
||||
report: (props) => <PlaceholderStep stepId="report" {...props} />,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* RecommendationStep — Screen 8 "AI recommendation" (spec §9, §10).
|
||||
*
|
||||
* Flow:
|
||||
* 1. Deterministic scoring engine picks the highlight + reasons (logged).
|
||||
* 2. 10-year value model computes the savings/lifecycle comparison.
|
||||
* 3. AI turns the reasons into plain-language copy (guardrailed); falls back to
|
||||
* deterministic text if AI is unavailable or trips a banned-claim check.
|
||||
* The recommendation record is frozen into the session (spec §12 recommendations).
|
||||
*/
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Sparkles, Loader2, Check, ShieldCheck } from 'lucide-react';
|
||||
import { useEstimateWizard } from '../../EstimateWizardContext';
|
||||
import { score } from '../../engine/scoringEngine';
|
||||
import { computeTenYearValue } from '../../engine/valueModel';
|
||||
import { generateExplanation } from '../../ai/explain';
|
||||
import GoodBetterBestCards from '../GoodBetterBestCards';
|
||||
import TenYearValueChart from '../TenYearValueChart';
|
||||
|
||||
const PKG_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' };
|
||||
|
||||
const RecommendationStep = () => {
|
||||
const { inputs, session, patchSession, tenantConfig, logEvent } = useEstimateWizard();
|
||||
|
||||
// Deterministic — recomputed from current answers, stable across renders.
|
||||
const recommendation = useMemo(
|
||||
() => score({ inputs, hailSnapshot: session.hailSnapshot, tenantConfig }),
|
||||
[inputs, session.hailSnapshot, tenantConfig]
|
||||
);
|
||||
const value = useMemo(
|
||||
() => computeTenYearValue({ inputs, tenantConfig }),
|
||||
[inputs, tenantConfig]
|
||||
);
|
||||
|
||||
const [explanation, setExplanation] = useState(session.recommendation?.explanation || null);
|
||||
const [loadingAI, setLoadingAI] = useState(true);
|
||||
|
||||
// Freeze deterministic record + log it (reproducible audit, spec §18).
|
||||
useEffect(() => {
|
||||
patchSession({ recommendation: { ...recommendation, value } });
|
||||
logEvent('recommendation_scored', {
|
||||
modelVersion: recommendation.modelVersion,
|
||||
selectedPackage: recommendation.selectedPackage,
|
||||
scores: Object.fromEntries(Object.entries(recommendation.scores).map(([k, v]) => [k, v.total])),
|
||||
uncertaintyFlags: recommendation.uncertaintyFlags,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [recommendation, value]);
|
||||
|
||||
// AI explanation (async, guardrailed).
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoadingAI(true);
|
||||
generateExplanation({ inputs, hailSnapshot: session.hailSnapshot, recommendation, valueModel: value, tenantConfig })
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
setExplanation(res);
|
||||
patchSession({ recommendation: { ...recommendation, value, explanation: res } });
|
||||
logEvent('ai_explanation_generated', { aiUsed: res.aiUsed, guardrailOk: res.guardrail?.ok });
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [recommendation, value]);
|
||||
|
||||
// Lock the highlighted option in as the customer's selection if they hadn't chosen.
|
||||
useEffect(() => {
|
||||
if (!session.selectedPackage) patchSession({ selectedPackage: recommendation.selectedPackage });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const highlight = recommendation.selectedPackage;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<GoodBetterBestCards
|
||||
packages={tenantConfig.packages}
|
||||
pricing={tenantConfig.pricing}
|
||||
selectedType={session.selectedPackage || highlight}
|
||||
highlightedType={highlight}
|
||||
onSelect={(t) => { patchSession({ selectedPackage: t }); logEvent('package_selected', { package: t, after: 'recommendation' }); }}
|
||||
/>
|
||||
|
||||
{/* AI explanation */}
|
||||
<div className="rounded-2xl border border-amber-200 dark:border-amber-500/20 bg-amber-50/60 dark:bg-amber-500/5 p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Sparkles size={16} className="text-amber-500" />
|
||||
<span className="font-semibold text-zinc-900 dark:text-white">
|
||||
Why we highlighted {PKG_LABEL[highlight]}
|
||||
</span>
|
||||
{explanation && (
|
||||
<span className="ml-auto text-[10px] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400 inline-flex items-center gap-1">
|
||||
<ShieldCheck size={11} /> {explanation.aiUsed ? 'AI-assisted' : 'Reviewed'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loadingAI && !explanation ? (
|
||||
<div className="flex items-center gap-2 text-sm text-zinc-500 dark:text-zinc-400 py-2">
|
||||
<Loader2 size={15} className="animate-spin" /> Preparing your explanation…
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-zinc-700 dark:text-zinc-200 leading-relaxed">
|
||||
{explanation?.customerExplanation}
|
||||
</p>
|
||||
<ul className="mt-3 space-y-1.5">
|
||||
{recommendation.reasons.map((r) => (
|
||||
<li key={r} className="flex items-start gap-2 text-sm text-zinc-600 dark:text-zinc-300">
|
||||
<Check size={14} className="text-amber-500 mt-0.5 shrink-0" /> {r}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TenYearValueChart valueModel={value} />
|
||||
|
||||
<p className="text-xs text-zinc-400 leading-relaxed">
|
||||
This recommendation considers your stated budget and goals — all three options stay available.
|
||||
Insurance savings are illustrative and must be confirmed with your carrier.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecommendationStep;
|
||||
@@ -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