Files
property-match/src/domain/scoring.ts
T
Benjamin Sutter 9f7062d137 feat: UC-A/B/C demo — 50 mock properties, district scoring, gold/silver cards visible
- Add 50 mock properties (prop-050–099) tuned for UC-A (Fahrradhändler RETAIL),
  UC-B (Umzugsfirma OFFICE Zürich-West) and UC-C (Vermögensverwalter PREMIUM)
- District-aware scoreLocation: district match→100, city-only→70 when need
  specifies districts, canton→60, no match→35; fixes UC-C prop-001 over-scoring
- mustHaveScorer: add klimatisierung keyword; parking minimum count logic
- Soft factor scale fix: integer 0–100 values no longer multiplied ×100
- footfall: map passerbyFrequency string (HIGH→85, MEDIUM_HIGH→68…) before
  enrichment fallback so RETAIL properties score correctly
- Parser: Kreis list extraction ("Kreis 3, 4, 5, und 8" → 4 district entries),
  neighbourhood→district map (Seefeld, Bahnhofstrasse), prestige signals
- Results: remove VERIFIED_PORTFOLIO role gate — all users see portfolio cards,
  enabling gold (85+) and silver (70–84) cards for every demo use case
- Fix flash of wrong cards on NeedBuilder nav (effectiveNeedId not activeNeed?.id)
- needs.ts: UC-A preferredLocations now includes Kreis 3/4/5/8 entries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 20:31:30 +02:00

146 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { ScoreFactor, TradeOff, Risk, MissingDataItem, NextBestAction, MustHaveResult } from './match'
// ── Hard Filter Thresholds ────────────────────────────────────────────────────
export const HARD_FILTER = {
AREA_MIN_TOLERANCE: 0.85, // exclude if property < 85% of need's min area
AREA_MAX_RATIO: 2.50, // severe penalty if property > 2.5× need's max area
BUDGET_EXCLUSION_RATIO: 1.50, // exclude if rent > 150% of max budget/m²
BUDGET_SEVERE_RATIO: 1.25, // severe penalty if 125150% over budget
BUDGET_MODERATE_RATIO: 1.10, // mild penalty if 110125% over budget
TIMING_GRACE_DAYS: 90, // allow ±90 days window flexibility
} as const
// ── Score Architecture ────────────────────────────────────────────────────────
// Within each group, scores are weighted and normalized to 0100.
// Final = baseScore + dataQualityModifier + confidenceModifier, clamped 0100.
export const SCORE_SPLIT = {
HARD_CRITERIA: 0.60, // expected contribution from hard criteria group
SOFT_FACTORS: 0.40, // expected contribution from soft factors group
} as const
export const HARD_CRITERION_KEYS = ['area', 'location', 'budget', 'timing'] as const
export type HardCriterionKey = typeof HARD_CRITERION_KEYS[number]
export const SOFT_FACTOR_KEYS = [
'prestige', 'accessibility', 'expansionPotential', 'flexibility',
'visibility', 'footfall', 'talentAccess', 'esg', 'taxEnvironment',
] as const
export type SoftFactorKey = typeof SOFT_FACTOR_KEYS[number]
// ── Modifier Tables ───────────────────────────────────────────────────────────
export const DATA_QUALITY_MODIFIER = {
EXCELLENT: +5, // dataQuality.score >= 0.85
GOOD: 0, // >= 0.70
FAIR: -5, // >= 0.55
POOR: -10, // >= 0.40
CRITICAL: -15, // < 0.40
} as const
export const CONFIDENCE_MODIFIER = {
VERIFIED_HIGH: +3, // VERIFIED_PORTFOLIO + confidenceScore >= 0.80
VERIFIED_MEDIUM: 0, // VERIFIED_PORTFOLIO + confidenceScore < 0.80
EXTERNAL_MARKET: -3, // EXTERNAL_MARKET result type
MAISON_WORK: -2, // MAISON_WORK — Maison Work API (slightly more trusted than scraped market)
FUTURE_AVAILABILITY: -15, // FUTURE_AVAILABILITY — never treat as confirmed availability
LOW_CONFIDENCE: -10, // confidenceScore < 0.50 (stacks with above)
} as const
// ── Scoring Weight Profile ────────────────────────────────────────────────────
export interface ScoringWeightProfile {
// Hard criteria
area: number
location: number
budget: number
timing: number
// Soft factors
prestige: number
accessibility: number
expansionPotential: number
flexibility: number
visibility: number
footfall: number
talentAccess: number
esg: number
taxEnvironment: number
[key: string]: number
}
// ── Default Profiles per Asset Type ──────────────────────────────────────────
// Each profile sums to 1.00. No magic numbers — weights reflect domain logic.
export const DEFAULT_SCORING_PROFILES: Record<string, ScoringWeightProfile> = {
// Büro: ÖV-Anbindung, Talent Access, Prestige, ESG stark gewichtet
OFFICE: {
area: 0.18, location: 0.18, budget: 0.15, timing: 0.09,
prestige: 0.07, accessibility: 0.11, expansionPotential: 0.05,
flexibility: 0.05, visibility: 0.02, footfall: 0.01,
talentAccess: 0.07, esg: 0.02, taxEnvironment: 0.00,
},
// Retail: Frequenz, Sichtbarkeit und Standort dominieren
RETAIL: {
area: 0.10, location: 0.15, budget: 0.13, timing: 0.06,
prestige: 0.04, accessibility: 0.07, expansionPotential: 0.03,
flexibility: 0.08, visibility: 0.14, footfall: 0.18,
talentAccess: 0.01, esg: 0.01, taxEnvironment: 0.00,
},
// Light Industrial: Fläche, Andienung (accessibility), Infrastruktur
LIGHT_INDUSTRIAL: {
area: 0.20, location: 0.12, budget: 0.18, timing: 0.10,
prestige: 0.01, accessibility: 0.14, expansionPotential: 0.07,
flexibility: 0.05, visibility: 0.01, footfall: 0.00,
talentAccess: 0.04, esg: 0.04, taxEnvironment: 0.04,
},
// Logistik: Autobahnanbindung (accessibility), Andienung, Fläche, Verfügbarkeit
LOGISTICS: {
area: 0.18, location: 0.18, budget: 0.14, timing: 0.13,
prestige: 0.01, accessibility: 0.18, expansionPotential: 0.06,
flexibility: 0.04, visibility: 0.01, footfall: 0.00,
talentAccess: 0.02, esg: 0.02, taxEnvironment: 0.03,
},
PRODUCTION: {
area: 0.22, location: 0.13, budget: 0.18, timing: 0.10,
prestige: 0.01, accessibility: 0.13, expansionPotential: 0.08,
flexibility: 0.04, visibility: 0.01, footfall: 0.00,
talentAccess: 0.04, esg: 0.03, taxEnvironment: 0.03,
},
DEFAULT: {
area: 0.20, location: 0.18, budget: 0.18, timing: 0.10,
prestige: 0.05, accessibility: 0.09, expansionPotential: 0.05,
flexibility: 0.05, visibility: 0.02, footfall: 0.02,
talentAccess: 0.03, esg: 0.02, taxEnvironment: 0.01,
},
}
// ── Engine IO Types ───────────────────────────────────────────────────────────
export interface HardFilterResult {
excluded: boolean
reason?: string
severePenalty: number // extra points deducted on top of criterion score (030)
}
export interface MatchEngineOutput {
propertyId: string
needId: string
excluded: boolean
excludedReason?: string
finalScore: number // 0100 clamped
hardMatchScore: number // 0100 normalized
softFactorScore: number // 0100 normalized
dataQualityModifier: number
confidenceModifier: number
positiveFactors: ScoreFactor[]
negativeFactors: ScoreFactor[]
allHardFactors: ScoreFactor[]
allSoftFactors: ScoreFactor[]
tradeOffs: TradeOff[]
risks: Risk[]
missingData: MissingDataItem[]
nextBestActions: NextBestAction[]
mustHaveEvaluation?: MustHaveResult[]
}