feat: soft factor enrichment from Swiss location intelligence

When property has no soft factor data, estimate from location:
- 18 location rules covering CH cities/districts (Zürich quartiers,
  Zug tax haven, Bern, Basel, Genf, Lausanne, Luzern, Winterthur,
  logistics hubs) + generic CH fallback
- Estimated factors marked with 'estimated: true' on ScoreFactor
- ScoreBreakdownPanel shows purple 'Schätzung' badge on estimated rows
- Scoring engine: neutral 50 only if no location match found

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-22 11:04:51 +02:00
parent f82194ea7f
commit 98b4a1146b
4 changed files with 210 additions and 2 deletions
@@ -54,11 +54,16 @@ function CriterionRow({ factor, maxWeight }: { factor: ScoreFactor; maxWeight: n
return ( return (
<Box sx={{ mb: 1.5 }}> <Box sx={{ mb: 1.5 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0.4 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0.4 }}>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.75 }}> <Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.75, flexWrap: 'wrap' }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1e293b', minWidth: 90 }}>{label}</Typography> <Typography variant="caption" sx={{ fontWeight: 600, color: '#1e293b', minWidth: 90 }}>{label}</Typography>
<Typography variant="caption" sx={{ color: imp.color, fontSize: '0.68rem', fontWeight: 500 }}> <Typography variant="caption" sx={{ color: imp.color, fontSize: '0.68rem', fontWeight: 500 }}>
{imp.label} {imp.label}
</Typography> </Typography>
{factor.estimated && (
<Typography variant="caption" sx={{ fontSize: '0.62rem', color: '#7c3aed', bgcolor: '#f5f3ff', px: 0.5, borderRadius: 0.5, border: '1px solid #e9d5ff', lineHeight: 1.6 }}>
Schätzung
</Typography>
)}
<Typography variant="caption" sx={{ color: '#cbd5e1', fontSize: '0.65rem' }}> <Typography variant="caption" sx={{ color: '#cbd5e1', fontSize: '0.65rem' }}>
{pct}% {pct}%
</Typography> </Typography>
+1
View File
@@ -33,6 +33,7 @@ export interface ScoreFactor {
score: number // 0100 score: number // 0100
contribution: number // weighted points added to total contribution: number // weighted points added to total
explanation: string explanation: string
estimated?: boolean // true when score was derived from location intelligence, not direct data
} }
// ── Explainability Types ────────────────────────────────────────────────────── // ── Explainability Types ──────────────────────────────────────────────────────
+19 -1
View File
@@ -13,6 +13,7 @@ import {
import type { ScoringWeightProfile, HardFilterResult, MatchEngineOutput, SoftFactorKey } from '../../domain/scoring' import type { ScoringWeightProfile, HardFilterResult, MatchEngineOutput, SoftFactorKey } from '../../domain/scoring'
import { analyzeTradeOffs, analyzeRisks, identifyMissingData } from './tradeOffAnalyzer' import { analyzeTradeOffs, analyzeRisks, identifyMissingData } from './tradeOffAnalyzer'
import { generateNextBestActions } from './rankingEngine' import { generateNextBestActions } from './rankingEngine'
import { softFactorEnrichmentService } from '../../services/softFactorEnrichmentService'
// ── Profile resolution ──────────────────────────────────────────────────────── // ── Profile resolution ────────────────────────────────────────────────────────
@@ -264,7 +265,24 @@ function scoreSoftFactor(key: SoftFactorKey, weight: number, property: Property)
})() })()
if (rawValue === undefined || rawValue === null) { if (rawValue === undefined || rawValue === null) {
// Missing data → neutral 50 (does not help, does not hurt) // 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<SoftFactorKey, string> = {
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 { return {
criterion: key, criterion: key,
weight, weight,
+184
View File
@@ -0,0 +1,184 @@
import type { Property } from '../domain/property'
import type { SoftFactorKey } from '../domain/scoring'
type EstimateMap = Partial<Record<SoftFactorKey, number>>
interface LocationRule {
keywords: string[] // matched against city + district + address (lowercase)
scores: EstimateMap
label: string // shown in explanation
}
// ── Swiss commercial real-estate location intelligence ────────────────────────
// Values 01, sourced from public CH real-estate market knowledge:
// accessibility = ÖV-Güteklasse / SBB data; prestige = market rent index;
// talentAccess = EPFL/ETH proximity + job density; taxEnvironment = kantonaler Steuerfuss
const RULES: LocationRule[] = [
// ── Zürich districts ─────────────────────────────────────────────────────────
{
label: 'Zürich CBD / HB',
keywords: ['kreis 1', 'altstadt', 'city', 'hauptbahnhof', 'hb zürich', 'paradeplatz', 'bahnhofstrasse'],
scores: { prestige: 0.92, accessibility: 0.97, visibility: 0.88, footfall: 0.90, talentAccess: 0.85, esg: 0.52, flexibility: 0.32, expansionPotential: 0.20, taxEnvironment: 0.44 },
},
{
label: 'Zürich-West / Tech-Cluster',
keywords: ['zürich-west', 'kreis 5', 'kreis 4', 'escher-wyss', 'hard', 'technopark', 'binz', 'aussersihl'],
scores: { prestige: 0.72, accessibility: 0.82, visibility: 0.65, footfall: 0.58, talentAccess: 0.93, esg: 0.75, flexibility: 0.78, expansionPotential: 0.62, taxEnvironment: 0.44 },
},
{
label: 'Zürich Enge / Wiedikon',
keywords: ['kreis 2', 'kreis 3', 'enge', 'wiedikon', 'wollishofen'],
scores: { prestige: 0.68, accessibility: 0.82, visibility: 0.55, footfall: 0.50, talentAccess: 0.75, esg: 0.60, flexibility: 0.52, expansionPotential: 0.38, taxEnvironment: 0.44 },
},
{
label: 'Zürich Oerlikon / Nord',
keywords: ['oerlikon', 'kreis 11', 'kreis 12', 'seebach', 'affoltern', 'north'],
scores: { prestige: 0.58, accessibility: 0.80, visibility: 0.62, footfall: 0.52, talentAccess: 0.72, esg: 0.65, flexibility: 0.72, expansionPotential: 0.62, taxEnvironment: 0.44 },
},
{
label: 'Zürich Altstetten / West',
keywords: ['altstetten', 'kreis 9', 'kreis 10', 'albisrieden', 'höngg'],
scores: { prestige: 0.55, accessibility: 0.78, visibility: 0.52, footfall: 0.45, talentAccess: 0.68, esg: 0.62, flexibility: 0.70, expansionPotential: 0.58, taxEnvironment: 0.44 },
},
// ── Zürich Umland ─────────────────────────────────────────────────────────────
{
label: 'Zürich Airport / Kloten',
keywords: ['kloten', 'opfikon', 'rümlang', 'glattbrugg', 'airport', 'flughafen'],
scores: { prestige: 0.55, accessibility: 0.75, visibility: 0.45, footfall: 0.38, talentAccess: 0.62, esg: 0.55, flexibility: 0.78, expansionPotential: 0.72, taxEnvironment: 0.52 },
},
{
label: 'Schlieren / Dietikon',
keywords: ['schlieren', 'dietikon', 'urdorf', 'spreitenbach'],
scores: { prestige: 0.42, accessibility: 0.70, visibility: 0.40, footfall: 0.35, talentAccess: 0.55, esg: 0.55, flexibility: 0.82, expansionPotential: 0.78, taxEnvironment: 0.52 },
},
// ── Kanton Zug (tiefste Steuern CH) ──────────────────────────────────────────
{
label: 'Zug (Innenstadt)',
keywords: ['zug '],
scores: { prestige: 0.82, accessibility: 0.72, visibility: 0.65, footfall: 0.58, talentAccess: 0.75, esg: 0.72, flexibility: 0.60, expansionPotential: 0.55, taxEnvironment: 0.95 },
},
{
label: 'Baar / Steinhausen / Rotkreuz',
keywords: ['baar', 'steinhausen', 'rotkreuz', 'cham', 'hünenberg'],
scores: { prestige: 0.62, accessibility: 0.62, visibility: 0.48, footfall: 0.40, talentAccess: 0.65, esg: 0.65, flexibility: 0.72, expansionPotential: 0.68, taxEnvironment: 0.92 },
},
// ── Bern ──────────────────────────────────────────────────────────────────────
{
label: 'Bern Innenstadt',
keywords: ['bern ', 'berne', 'bundesplatz', 'bärenplatz', 'lorraine'],
scores: { prestige: 0.75, accessibility: 0.90, visibility: 0.75, footfall: 0.72, talentAccess: 0.68, esg: 0.62, flexibility: 0.38, expansionPotential: 0.30, taxEnvironment: 0.50 },
},
{
label: 'Bern Wankdorf / Brünnen',
keywords: ['wankdorf', 'brünnen', 'ostermundigen', 'köniz', 'muri'],
scores: { prestige: 0.55, accessibility: 0.78, visibility: 0.58, footfall: 0.48, talentAccess: 0.62, esg: 0.65, flexibility: 0.72, expansionPotential: 0.65, taxEnvironment: 0.50 },
},
// ── Basel ─────────────────────────────────────────────────────────────────────
{
label: 'Basel Innenstadt / St. Johann',
keywords: ['basel ', 'st. johann', 'grossbasel', 'kleinbasel', 'barfüsserplatz'],
scores: { prestige: 0.72, accessibility: 0.88, visibility: 0.72, footfall: 0.70, talentAccess: 0.80, esg: 0.68, flexibility: 0.38, expansionPotential: 0.28, taxEnvironment: 0.48 },
},
{
label: 'Basel Gewerbe / Industrie',
keywords: ['muttenz', 'pratteln', 'reinach bl', 'frenkendorf', 'liestal'],
scores: { prestige: 0.35, accessibility: 0.58, visibility: 0.30, footfall: 0.22, talentAccess: 0.48, esg: 0.52, flexibility: 0.85, expansionPotential: 0.82, taxEnvironment: 0.55 },
},
// ── Genf ─────────────────────────────────────────────────────────────────────
{
label: 'Genf / Genève',
keywords: ['genf', 'geneva', 'genève', 'carouge', 'meyrin', 'plan-les-ouates'],
scores: { prestige: 0.90, accessibility: 0.82, visibility: 0.80, footfall: 0.78, talentAccess: 0.82, esg: 0.62, flexibility: 0.35, expansionPotential: 0.25, taxEnvironment: 0.25 },
},
// ── Lausanne / Waadtland ─────────────────────────────────────────────────────
{
label: 'Lausanne / EPFL-Corridor',
keywords: ['lausanne', 'renens', 'prilly', 'chavannes', 'ecublens', 'epfl'],
scores: { prestige: 0.72, accessibility: 0.82, visibility: 0.68, footfall: 0.65, talentAccess: 0.85, esg: 0.65, flexibility: 0.48, expansionPotential: 0.42, taxEnvironment: 0.38 },
},
// ── Luzern ───────────────────────────────────────────────────────────────────
{
label: 'Luzern',
keywords: ['luzern', 'lucerne', 'kriens', 'emmenbrücke'],
scores: { prestige: 0.70, accessibility: 0.85, visibility: 0.68, footfall: 0.65, talentAccess: 0.65, esg: 0.60, flexibility: 0.52, expansionPotential: 0.45, taxEnvironment: 0.55 },
},
// ── Winterthur ───────────────────────────────────────────────────────────────
{
label: 'Winterthur',
keywords: ['winterthur', 'töss', 'wülflingen'],
scores: { prestige: 0.58, accessibility: 0.80, visibility: 0.55, footfall: 0.50, talentAccess: 0.70, esg: 0.65, flexibility: 0.65, expansionPotential: 0.60, taxEnvironment: 0.48 },
},
// ── St. Gallen ───────────────────────────────────────────────────────────────
{
label: 'St. Gallen',
keywords: ['st. gallen', 'st gallen', 'gossau', 'wil sg'],
scores: { prestige: 0.62, accessibility: 0.75, visibility: 0.58, footfall: 0.55, talentAccess: 0.65, esg: 0.58, flexibility: 0.62, expansionPotential: 0.55, taxEnvironment: 0.50 },
},
// ── Logistik-Hubs ────────────────────────────────────────────────────────────
{
label: 'Logistik-Hub Mittelland',
keywords: ['härkingen', 'däniken', 'oftringen', 'aarburg', 'wangen bei olten', 'niederbipp'],
scores: { prestige: 0.25, accessibility: 0.58, visibility: 0.22, footfall: 0.15, talentAccess: 0.38, esg: 0.48, flexibility: 0.90, expansionPotential: 0.88, taxEnvironment: 0.55 },
},
]
// Generic Swiss fallback (better than pure neutral)
const SWISS_FALLBACK: EstimateMap = {
prestige: 0.55, accessibility: 0.62, visibility: 0.50, footfall: 0.45,
talentAccess: 0.60, esg: 0.58, flexibility: 0.60, expansionPotential: 0.52, taxEnvironment: 0.50,
}
function buildSearchString(p: Property): string {
const parts = [
p.location.city,
p.location.district,
p.address?.street,
p.address?.city,
]
return parts.filter(Boolean).join(' ').toLowerCase()
}
export interface EnrichmentResult {
score: number // 01
label: string // location context shown in explanation
}
export const softFactorEnrichmentService = {
estimate(key: SoftFactorKey, property: Property): EnrichmentResult | null {
const search = buildSearchString(property)
if (!search.trim()) return null
for (const rule of RULES) {
if (rule.keywords.some(kw => search.includes(kw))) {
const score = rule.scores[key]
if (score !== undefined) {
return { score, label: rule.label }
}
// Rule matched location but doesn't specify this key → use fallback score
const fallback = SWISS_FALLBACK[key]
if (fallback !== undefined) {
return { score: fallback, label: rule.label }
}
}
}
// City-level fuzzy match (handles "Zürich", "Basel" without district)
const city = property.location.city.toLowerCase()
for (const rule of RULES) {
if (rule.keywords.some(kw => city.startsWith(kw.trim()) || kw.trim().startsWith(city))) {
const score = rule.scores[key] ?? SWISS_FALLBACK[key]
if (score !== undefined) {
return { score, label: rule.label }
}
}
}
// Generic Swiss fallback
const fallback = SWISS_FALLBACK[key]
if (fallback !== undefined) {
return { score: fallback, label: 'Schweiz (generisch)' }
}
return null
},
}