import type { Need } from '../../domain/need' import type { Property } from '../../domain/property' import type { ScoreFactor } from '../../domain/match' import { ResultType, AvailabilityStatus, AssetType } from '../../domain/enums' import { HARD_FILTER, DATA_QUALITY_MODIFIER, CONFIDENCE_MODIFIER, HARD_CRITERION_KEYS, SOFT_FACTOR_KEYS, DEFAULT_SCORING_PROFILES, } from '../../domain/scoring' import type { ScoringWeightProfile, HardFilterResult, MatchEngineOutput, SoftFactorKey } from '../../domain/scoring' import { analyzeTradeOffs, analyzeRisks, identifyMissingData } from './tradeOffAnalyzer' import { generateNextBestActions } from './rankingEngine' import { softFactorEnrichmentService } from '../../services/softFactorEnrichmentService' import { scoreMustHaves } from './mustHaveScorer' // ── Profile resolution ──────────────────────────────────────────────────────── function resolveProfile(need: Need, property: Property): ScoringWeightProfile { const base = { ...(DEFAULT_SCORING_PROFILES[property.assetType] ?? DEFAULT_SCORING_PROFILES.DEFAULT) } const np = need.weightingProfile if (!np) return base // Apply need's custom core weights, then renormalize the full profile to 1.00 const CORE = [ 'area', 'location', 'budget', 'timing', 'prestige', 'accessibility', 'expansionPotential', 'flexibility', 'visibility', 'footfall', 'talentAccess', 'esg', 'taxEnvironment', ] for (const key of CORE) { if (typeof np[key] === 'number') base[key] = np[key] } const total = Object.values(base).reduce((s, v) => s + v, 0) if (total > 0) for (const key of Object.keys(base)) base[key] /= total return base } // ── Hard Filters ────────────────────────────────────────────────────────────── export function applyHardFilters(need: Need, property: Property): HardFilterResult { const assetOk = property.assetType === need.assetType || property.assetType === AssetType.MIXED || need.assetType === AssetType.UNKNOWN if (!assetOk) { return { excluded: true, reason: `Nutzungstyp ${property.assetType} stimmt nicht mit ${need.assetType} überein`, severePenalty: 0 } } // Area: hard exclude below tolerance const areaMin = need.requiredArea?.min ?? 0 const propArea = property.areaSqmMin ?? property.areaSqm if (areaMin > 0 && propArea < areaMin * HARD_FILTER.AREA_MIN_TOLERANCE) { return { excluded: true, reason: `Fläche ${propArea} m² unterschreitet Minimum ${areaMin} m² um mehr als ${Math.round((1 - HARD_FILTER.AREA_MIN_TOLERANCE) * 100)}%`, severePenalty: 0, } } // Region exclusion const city = property.location.city.toLowerCase() const excluded = (need.excludedLocations ?? []).map(l => l.toLowerCase()) if (excluded.some(e => city.includes(e) || e.includes(city))) { return { excluded: true, reason: `Standort ${property.location.city} ist ausgeschlossen`, severePenalty: 0 } } // Budget: hard exclude if massively over const maxBudget = need.budgetRange?.maxPerSqm ?? 0 if (maxBudget > 0 && property.rentPricePerSqm > maxBudget * HARD_FILTER.BUDGET_EXCLUSION_RATIO) { return { excluded: true, reason: `Miete CHF ${property.rentPricePerSqm}/m² überschreitet Budget CHF ${maxBudget}/m² um mehr als ${Math.round((HARD_FILTER.BUDGET_EXCLUSION_RATIO - 1) * 100)}%`, severePenalty: 0, } } // Usage/zoning: occupied property is severe penalty, not exclude if (property.availabilityStatus === AvailabilityStatus.OCCUPIED) { return { excluded: false, reason: undefined, severePenalty: 25 } } return { excluded: false, reason: undefined, severePenalty: 0 } } // ── Hard Criterion Scorers ──────────────────────────────────────────────────── function scoreArea(need: Need, property: Property, weight: number): ScoreFactor { const { min = 0, max = Infinity } = need.requiredArea ?? {} const area = property.areaSqm const areaMin = property.areaSqmMin ?? area const areaMax = property.areaSqmMax ?? area let score: number let explanation: string // Flexible property — check range overlap const overlap = areaMin <= max && areaMax >= min if (overlap) { score = 100 explanation = `Fläche ${areaMin === areaMax ? `${area}` : `${areaMin}–${areaMax}`} m² deckt Bedarf ${min}–${max} m² ab` } else if (area > max) { const ratio = area / max score = ratio <= HARD_FILTER.AREA_MAX_RATIO ? Math.max(40, Math.round(100 - (ratio - 1) * 50)) : 20 explanation = `Fläche ${area} m² überschreitet Maximum ${max} m² (${Math.round((ratio - 1) * 100)}% zu viel)` } else { // Area between tolerance and min — mild penalty const ratio = area / min score = Math.round(40 + ratio * 30) explanation = `Fläche ${area} m² leicht unter Minimum ${min} m²` } return { criterion: 'area', weight, score, contribution: score * weight, explanation } } function scoreLocation(need: Need, property: Property, weight: number): ScoreFactor { const city = property.location.city.toLowerCase() const district = (property.location.district ?? '').toLowerCase() const canton = (property.location.canton ?? '').toLowerCase() const preferred = (need.preferredLocations ?? []).map(l => l.toLowerCase()) let score: number let explanation: string if (preferred.length === 0) { score = 80 explanation = 'Kein Standortwunsch — flexibel bewertet' } else if (district && preferred.some(p => { // "Zürich Kreis 5".includes("Kreis 5") or pSuffix "kreis 5" ⊂ district if (p.includes(district)) return true const pSuffix = p.replace(/^zürich\s+/i, '').trim() // Only use pSuffix when the regex actually removed a prefix (avoids 'zürich' falsely // matching district names like 'Zürich-West' because 'Zürich-West'.includes('zürich')) return pSuffix !== p && pSuffix.length > 0 && district.includes(pSuffix) })) { score = 100 explanation = `Bezirk ${property.location.district} entspricht Präferenz` } else if (preferred.some(p => city.includes(p) || p.includes(city))) { // City matches but check if preferred has district-specific entries for this city // → penalise when a specific district was requested but this property is in a different one const hasDistrictSpecifics = preferred.some(p => p.includes(city) && p.length > city.length + 2) score = hasDistrictSpecifics ? 70 : 100 explanation = score === 100 ? `Standort ${property.location.city} entspricht Präferenz` : `${property.location.city}${property.location.district ? ` (${property.location.district})` : ''} — Lage akzeptiert, bevorzugter Bezirk nicht erfüllt` } else if (canton && preferred.some(p => p.includes(canton) || canton.includes(p))) { score = 60 explanation = `Gleicher Kanton wie Präferenz (${property.location.canton})` } else { score = 35 explanation = `Standort ${property.location.city} nicht in Präferenzliste` } return { criterion: 'location', weight, score, contribution: score * weight, explanation } } function scoreBudget(need: Need, property: Property, weight: number): ScoreFactor { const maxBudget = need.budgetRange?.maxPerSqm ?? 0 const rent = property.rentPricePerSqm let score: number let explanation: string if (maxBudget <= 0) { score = 60 explanation = 'Kein Budget angegeben — neutral bewertet' } else if (rent <= maxBudget) { const ratio = rent / maxBudget // Very cheap can indicate quality issues — slight penalty below 50% of budget score = ratio >= 0.50 ? 100 : 88 explanation = `Miete CHF ${rent}/m² liegt ${Math.round((1 - ratio) * 100)}% unter Budget CHF ${maxBudget}/m²` } else { const overRatio = rent / maxBudget if (overRatio <= HARD_FILTER.BUDGET_MODERATE_RATIO) { score = 75 explanation = `Miete CHF ${rent}/m² leicht über Budget (+${Math.round((overRatio - 1) * 100)}%)` } else if (overRatio <= HARD_FILTER.BUDGET_SEVERE_RATIO) { score = 45 explanation = `Miete CHF ${rent}/m² merklich über Budget (+${Math.round((overRatio - 1) * 100)}%)` } else { score = 20 explanation = `Miete CHF ${rent}/m² stark über Budget (+${Math.round((overRatio - 1) * 100)}%)` } } return { criterion: 'budget', weight, score, contribution: score * weight, explanation } } function scoreTiming(need: Need, property: Property, weight: number): ScoreFactor { const isFutureSignal = property.resultType === ResultType.FUTURE_AVAILABILITY const rawDate = property.availabilityDate const propDate = rawDate && rawDate !== '' ? new Date(rawDate) : null const earliest = need.timing?.earliestMoveIn ? new Date(need.timing.earliestMoveIn) : null const latest = need.timing?.latestMoveIn ? new Date(need.timing.latestMoveIn) : null const graceMs = HARD_FILTER.TIMING_GRACE_DAYS * 86_400_000 let score: number let explanation: string // CRITICAL RULE: Future availability is never treated as confirmed if (isFutureSignal) { if (!propDate) { score = 30 explanation = 'Zukünftiges Signal — kein Datum, Verfügbarkeit unbestätigt' } else if (latest && propDate.getTime() > latest.getTime() + graceMs) { score = 20 explanation = `Zukünftiges Signal — erwartet ${rawDate}, nach gewünschtem Zeitfenster (unbestätigt)` } else if (earliest && propDate.getTime() < earliest.getTime()) { score = 50 explanation = `Zukünftiges Signal — erwartet ${rawDate}, vor gewünschtem Einzug (unbestätigt)` } else { score = 42 explanation = `Zukünftiges Signal — Zeitfenster passt, Verfügbarkeit jedoch unbestätigt` } return { criterion: 'timing', weight, score, contribution: score * weight, explanation } } const isNow = property.availabilityStatus === AvailabilityStatus.AVAILABLE_NOW || property.availabilityStatus === AvailabilityStatus.AVAILABLE_SOON if (isNow) { const tooEarly = earliest && new Date() < earliest score = tooEarly ? 80 : 100 explanation = tooEarly ? `Sofort verfügbar — Einzug jedoch erst ab ${need.timing?.earliestMoveIn} geplant` : 'Sofort verfügbar — entspricht Verfügbarkeitswunsch' return { criterion: 'timing', weight, score, contribution: score * weight, explanation } } if (!propDate) { score = 38 explanation = 'Kein Verfügbarkeitsdatum angegeben' return { criterion: 'timing', weight, score, contribution: score * weight, explanation } } if (earliest && latest) { const t = propDate.getTime() if (t >= earliest.getTime() && t <= latest.getTime()) { score = 95 explanation = `Verfügbar ${rawDate} liegt im Einzugsfenster` } else if (t < earliest.getTime()) { const diff = earliest.getTime() - t score = diff < graceMs ? 80 : 65 explanation = `Verfügbar ${rawDate} vor gewünschtem Einzug — kurze Leerstandszeit` } else { const diff = t - latest.getTime() score = diff < graceMs ? 55 : 28 explanation = `Verfügbar ${rawDate} nach gewünschtem Zeitfenster` } } else { score = 65 explanation = `Verfügbar ${rawDate}` } return { criterion: 'timing', weight, score, contribution: score * weight, explanation } } // ── Soft Factor Scorer ──────────────────────────────────────────────────────── function scoreSoftFactor(key: SoftFactorKey, weight: number, property: Property): ScoreFactor { const sf = property.softFactors const hf = property.hardFacts const rawValue = (() => { switch (key) { case 'prestige': return sf?.prestigeScore ?? sf?.prestige case 'accessibility': return sf?.commuterAccessScore ?? sf?.accessibility ?? (hf?.publicTransportScore !== undefined ? hf.publicTransportScore / 10 : undefined) case 'expansionPotential': return sf?.expansionPotentialScore case 'flexibility': return sf?.flexibilityScore case 'visibility': return sf?.visibilityScore case 'footfall': { if (sf?.footfallScore !== undefined) return sf.footfallScore // Map passerbyFrequency string (RETAIL properties) to numeric score const pfMap: Record = { HIGH: 85, MEDIUM_HIGH: 68, MEDIUM: 50, LOW: 30 } const pf = (sf as { passerbyFrequency?: string } | undefined)?.passerbyFrequency return pf !== undefined ? (pfMap[pf] ?? 50) : undefined } case 'talentAccess': return sf?.talentAccessScore ?? sf?.talentAccess case 'esg': return sf?.esgScore case 'taxEnvironment': return sf?.taxEnvironmentScore default: return undefined } })() if (rawValue === undefined || rawValue === null) { // No direct data — try location-intelligence estimate const est = softFactorEnrichmentService.estimate(key, property) if (est) { const score = Math.round(Math.min(100, Math.max(0, est.score * 100))) const LABELS: Record = { prestige: 'Prestige', accessibility: 'Erreichbarkeit', expansionPotential: 'Expansion', flexibility: 'Flexibilität', visibility: 'Sichtbarkeit', footfall: 'Passantenfrequenz', talentAccess: 'Talent-Zugang', esg: 'ESG', taxEnvironment: 'Steuerumfeld', } return { criterion: key, weight, score, contribution: score * weight, explanation: `${LABELS[key] ?? key}: ${score}/100 — Schätzung basierend auf Standort (${est.label})`, estimated: true, } } return { criterion: key, weight, score: 50, contribution: 50 * weight, explanation: `${key}: keine Daten verfügbar — neutral bewertet`, } } // Support both 0–1 float scale (enrichment estimates) and 0–100 integer scale (mock data) const score = rawValue > 1 ? Math.round(Math.min(100, Math.max(0, rawValue))) : Math.round(Math.min(100, Math.max(0, rawValue * 100))) const LABELS: Record = { prestige: 'Prestige', accessibility: 'Erreichbarkeit', expansionPotential: 'Expansion', flexibility: 'Flexibilität', visibility: 'Sichtbarkeit', footfall: 'Passantenfrequenz', talentAccess: 'Talent-Zugang', esg: 'ESG', taxEnvironment: 'Steuerumfeld', } return { criterion: key, weight, score, contribution: score * weight, explanation: `${LABELS[key] ?? key}: ${score}/100`, } } // ── Structured Requirement Scorers ─────────────────────────────────────────── const FIT_OUT_LEVELS: Record = { SHELL: 0, BASIC: 1, FULL: 2, PREMIUM: 3 } function scoreGroundFloor(need: Need, property: Property): ScoreFactor | null { if (!need.requireGroundFloor) return null const floor = property.hardFacts?.floor ?? property.floorLevel if (floor === undefined) { return { criterion: 'groundFloor', weight: 0.05, score: 50, contribution: 2.5, explanation: 'Erdgeschoss erforderlich — Stockwerk nicht dokumentiert', estimated: true } } const passed = floor === 0 const score = passed ? 100 : 20 return { criterion: 'groundFloor', weight: 0.05, score, contribution: score * 0.05, explanation: passed ? `Erdgeschoss bestätigt (Etage ${floor})` : `Nicht Erdgeschoss (Etage ${floor}) — EG erforderlich`, } } function scoreParkingMin(need: Need, property: Property): ScoreFactor | null { const min = need.requiredParkingMin if (!min || min <= 0) return null const available = property.hardFacts?.parking ?? property.softFactors?.parkingSpots ?? 0 if (available === 0 && property.hardFacts?.parking === undefined) { return { criterion: 'parkingMin', weight: 0.04, score: 50, contribution: 2, explanation: `Mind. ${min} Parkplätze erforderlich — keine Daten`, estimated: true } } const ratio = Math.min(1, available / min) const score = available >= min ? 100 : Math.round(ratio * 60) return { criterion: 'parkingMin', weight: 0.04, score, contribution: score * 0.04, explanation: available >= min ? `${available} Parkplätze vorhanden (mind. ${min} erforderlich)` : `Nur ${available} Parkplätze (mind. ${min} erforderlich)`, } } function scoreAirConditioning(need: Need, property: Property): ScoreFactor | null { if (!need.requireAirConditioning) return null const hasAC = property.hardFacts?.hasAirConditioning if (hasAC === undefined) { return { criterion: 'airConditioning', weight: 0.03, score: 50, contribution: 1.5, explanation: 'Klimaanlage erforderlich — nicht dokumentiert', estimated: true } } const score = hasAC ? 100 : 15 return { criterion: 'airConditioning', weight: 0.03, score, contribution: score * 0.03, explanation: hasAC ? 'Klimaanlage vorhanden' : 'Keine Klimaanlage — Klimaanlage erforderlich', } } function scoreLoadingDock(need: Need, property: Property): ScoreFactor | null { if (!need.requireLoadingDock) return null const docks = property.hardFacts?.loadingDocksCount if (docks === undefined) { return { criterion: 'loadingDock', weight: 0.05, score: 50, contribution: 2.5, explanation: 'Laderampe erforderlich — keine Daten', estimated: true } } const passed = docks > 0 const score = passed ? 100 : 15 return { criterion: 'loadingDock', weight: 0.05, score, contribution: score * 0.05, explanation: passed ? `${docks} Laderampe(n) vorhanden` : 'Keine Laderampe vorhanden — erforderlich', } } function scoreBarrierFree(need: Need, property: Property): ScoreFactor | null { if (!need.requireBarrierFree) return null const ok = property.hardFacts?.isBarrierFree if (ok === undefined) { return { criterion: 'barrierFree', weight: 0.03, score: 50, contribution: 1.5, explanation: 'Barrierefreiheit erforderlich — nicht dokumentiert', estimated: true } } const score = ok ? 100 : 20 return { criterion: 'barrierFree', weight: 0.03, score, contribution: score * 0.03, explanation: ok ? 'Barrierefrei bestätigt' : 'Nicht barrierefrei — Barrierefreiheit erforderlich', } } function scoreCeilingHeight(need: Need, property: Property): ScoreFactor | null { const min = need.minCeilingHeightM if (!min || min <= 0) return null const actual = property.hardFacts?.ceilingHeightM if (actual === undefined) { return { criterion: 'ceilingHeight', weight: 0.04, score: 50, contribution: 2, explanation: `Mind. ${min}m Deckenhöhe erforderlich — keine Daten`, estimated: true } } const passed = actual >= min const score = passed ? 100 : Math.max(10, Math.round((actual / min) * 70)) return { criterion: 'ceilingHeight', weight: 0.04, score, contribution: score * 0.04, explanation: passed ? `Deckenhöhe ${actual}m ≥ Minimum ${min}m` : `Deckenhöhe ${actual}m unter Minimum ${min}m`, } } function scoreMinContractDuration(need: Need, property: Property): ScoreFactor | null { const min = need.minContractDurationMonths if (!min || min <= 0) return null const available = property.contractDurationMonths if (available === undefined) { return { criterion: 'contractDuration', weight: 0.03, score: 50, contribution: 1.5, explanation: `Mind. ${Math.round(min / 12)} Jahre Laufzeit gewünscht — keine Daten`, estimated: true } } const passed = available >= min const score = passed ? 100 : Math.max(20, Math.round((available / min) * 70)) return { criterion: 'contractDuration', weight: 0.03, score, contribution: score * 0.03, explanation: passed ? `${Math.round(available / 12)} Jahre Vertragslaufzeit — erfüllt (mind. ${Math.round(min / 12)} Jahre gewünscht)` : `Nur ${Math.round(available / 12)} Jahre — mind. ${Math.round(min / 12)} Jahre gewünscht`, } } function scoreFitOut(need: Need, property: Property): ScoreFactor | null { if (!need.requiredFitOut) return null const propFitOut = property.hardFacts?.fitOut if (!propFitOut) { return { criterion: 'fitOut', weight: 0.04, score: 50, contribution: 2, explanation: `Ausbaustandard ${need.requiredFitOut} erforderlich — keine Daten`, estimated: true } } const reqLevel = FIT_OUT_LEVELS[need.requiredFitOut] ?? 1 const propLevel = FIT_OUT_LEVELS[propFitOut] ?? 0 const score = propLevel >= reqLevel ? 100 : Math.max(10, Math.round(50 - (reqLevel - propLevel) * 25)) const LABELS: Record = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' } return { criterion: 'fitOut', weight: 0.04, score, contribution: score * 0.04, explanation: propLevel >= reqLevel ? `Ausbaustandard ${LABELS[propFitOut] ?? propFitOut} erfüllt Anforderung ${LABELS[need.requiredFitOut] ?? need.requiredFitOut}` : `${LABELS[propFitOut] ?? propFitOut} — ${LABELS[need.requiredFitOut] ?? need.requiredFitOut} erforderlich`, } } // ── Modifier Calculators ────────────────────────────────────────────────────── export function calcDataQualityModifier(property: Property): number { const s = property.dataQuality?.score ?? 0.5 if (s >= 0.85) return DATA_QUALITY_MODIFIER.EXCELLENT if (s >= 0.70) return DATA_QUALITY_MODIFIER.GOOD if (s >= 0.55) return DATA_QUALITY_MODIFIER.FAIR if (s >= 0.40) return DATA_QUALITY_MODIFIER.POOR return DATA_QUALITY_MODIFIER.CRITICAL } export function calcConfidenceModifier(property: Property): number { let mod = 0 if (property.resultType === ResultType.FUTURE_AVAILABILITY) { mod += CONFIDENCE_MODIFIER.FUTURE_AVAILABILITY } else if (property.resultType === ResultType.MAISON_WORK) { mod += CONFIDENCE_MODIFIER.MAISON_WORK } else { // VERIFIED_PORTFOLIO mod += property.confidenceScore >= 0.80 ? CONFIDENCE_MODIFIER.VERIFIED_HIGH : CONFIDENCE_MODIFIER.VERIFIED_MEDIUM } if (property.confidenceScore < 0.50) { mod += CONFIDENCE_MODIFIER.LOW_CONFIDENCE } return mod } // ── Main Engine Function ────────────────────────────────────────────────────── export function calculateScore(need: Need, property: Property): MatchEngineOutput { const hardFilter = applyHardFilters(need, property) if (hardFilter.excluded) { return { propertyId: property.id, needId: need.id, excluded: true, excludedReason: hardFilter.reason, finalScore: 0, hardMatchScore: 0, softFactorScore: 0, dataQualityModifier: 0, confidenceModifier: 0, positiveFactors: [], negativeFactors: [], allHardFactors: [], allSoftFactors: [], tradeOffs: [], risks: [], missingData: identifyMissingData(property, need), nextBestActions: [], } } const profile = resolveProfile(need, property) // ── Hard criteria scoring ────────────────────────────────────────────────── const coreHardFactors: ScoreFactor[] = [ scoreArea(need, property, profile.area), scoreLocation(need, property, profile.location), scoreBudget(need, property, profile.budget), scoreTiming(need, property, profile.timing), ] // Structured requirement scorers — optional hard factors const structuredFactors: ScoreFactor[] = [ scoreGroundFloor(need, property), scoreParkingMin(need, property), scoreAirConditioning(need, property), scoreLoadingDock(need, property), scoreBarrierFree(need, property), scoreCeilingHeight(need, property), scoreMinContractDuration(need, property), scoreFitOut(need, property), ].filter((f): f is ScoreFactor => f !== null) const hardFactors = [...coreHardFactors, ...structuredFactors] const hardWeightSum = HARD_CRITERION_KEYS.reduce((s, k) => s + profile[k], 0) + structuredFactors.reduce((s, f) => s + f.weight, 0) const hardRaw = hardFactors.reduce((s, f) => s + f.contribution, 0) const hardMatchScore = hardWeightSum > 0 ? Math.min(100, Math.round(hardRaw / hardWeightSum)) : 0 // ── Soft factor scoring ──────────────────────────────────────────────────── // All 9 factors always included in output so breakdown is always complete. // Only weighted factors (weight > 0) contribute to the score calculation. const allSoftDisplay: ScoreFactor[] = SOFT_FACTOR_KEYS .map(k => scoreSoftFactor(k, profile[k] ?? 0, property)) const softWeightSum = SOFT_FACTOR_KEYS.reduce((s, k) => s + (profile[k] ?? 0), 0) const softRaw = allSoftDisplay.filter(f => f.weight > 0).reduce((s, f) => s + f.contribution, 0) const softFactorScore = softWeightSum > 0 ? Math.min(100, Math.round(softRaw / softWeightSum)) : 50 // ── Must-have criteria evaluation ───────────────────────────────────────── const allMustHaveText = [ ...(need.mustCriteriaText ?? []), ...(need.mustHaveCriteria?.map(c => c.criterion) ?? []), ] const mustHaveEval = scoreMustHaves(allMustHaveText, property) // ── Final score: hard/soft weighted sum, clamped 0–100 ─────────────────── const rawFinal = hardMatchScore * 0.60 + softFactorScore * 0.40 - hardFilter.severePenalty const finalScore = Math.round(Math.min(100, Math.max(0, rawFinal))) // ── Factor classification — only use weighted soft factors for positive/negative ── const weightedSoftFactors = allSoftDisplay.filter(f => f.weight > 0) const allFactors = [...hardFactors, ...weightedSoftFactors] const THRESHOLD_POSITIVE = 70 const THRESHOLD_NEGATIVE = 45 const positiveFactors = allFactors .filter(f => f.score >= THRESHOLD_POSITIVE) .sort((a, b) => b.contribution - a.contribution) .slice(0, 4) const negativeFactors = allFactors .filter(f => f.score < THRESHOLD_NEGATIVE) .sort((a, b) => a.contribution - b.contribution) .slice(0, 4) const tradeOffs = analyzeTradeOffs(hardFactors, weightedSoftFactors, need, property) const risks = analyzeRisks(property, hardFactors) const missingData = identifyMissingData(property, need) const output: MatchEngineOutput = { propertyId: property.id, needId: need.id, excluded: false, finalScore, hardMatchScore, softFactorScore, dataQualityModifier: 0, confidenceModifier: 0, positiveFactors, negativeFactors, allHardFactors: hardFactors, allSoftFactors: allSoftDisplay, tradeOffs, risks, missingData, mustHaveEvaluation: mustHaveEval.results.length > 0 ? mustHaveEval.results : undefined, nextBestActions: [], // filled by rankingEngine } output.nextBestActions = generateNextBestActions(output, property, need) return output }