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>
This commit is contained in:
Benjamin Sutter
2026-05-22 20:31:30 +02:00
parent 98b4a1146b
commit 9f7062d137
16 changed files with 3942 additions and 52 deletions
+209 -10
View File
@@ -14,6 +14,7 @@ import type { ScoringWeightProfile, HardFilterResult, MatchEngineOutput, SoftFac
import { analyzeTradeOffs, analyzeRisks, identifyMissingData } from './tradeOffAnalyzer'
import { generateNextBestActions } from './rankingEngine'
import { softFactorEnrichmentService } from '../../services/softFactorEnrichmentService'
import { scoreMustHaves } from './mustHaveScorer'
// ── Profile resolution ────────────────────────────────────────────────────────
@@ -117,6 +118,7 @@ function scoreArea(need: Need, property: Property, weight: number): ScoreFactor
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())
@@ -124,11 +126,26 @@ function scoreLocation(need: Need, property: Property, weight: number): ScoreFac
let explanation: string
if (preferred.length === 0) {
score = 70
explanation = 'Kein Standortwunsch — neutral bewertet'
} else if (preferred.some(p => city.includes(p) || p.includes(city))) {
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 = `Standort ${property.location.city} entspricht Präferenz`
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})`
@@ -256,7 +273,13 @@ function scoreSoftFactor(key: SoftFactorKey, weight: number, property: Property)
case 'expansionPotential': return sf?.expansionPotentialScore
case 'flexibility': return sf?.flexibilityScore
case 'visibility': return sf?.visibilityScore
case 'footfall': return sf?.footfallScore
case 'footfall': {
if (sf?.footfallScore !== undefined) return sf.footfallScore
// Map passerbyFrequency string (RETAIL properties) to numeric score
const pfMap: Record<string, number> = { 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
@@ -292,8 +315,10 @@ function scoreSoftFactor(key: SoftFactorKey, weight: number, property: Property)
}
}
// Soft factor values are 01 scale → convert to 0100
const score = Math.round(Math.min(100, Math.max(0, rawValue * 100)))
// Support both 01 float scale (enrichment estimates) and 0100 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<SoftFactorKey, string> = {
prestige: 'Prestige', accessibility: 'Erreichbarkeit', expansionPotential: 'Expansion',
flexibility: 'Flexibilität', visibility: 'Sichtbarkeit', footfall: 'Passantenfrequenz',
@@ -308,6 +333,157 @@ function scoreSoftFactor(key: SoftFactorKey, weight: number, property: Property)
}
}
// ── Structured Requirement Scorers ───────────────────────────────────────────
const FIT_OUT_LEVELS: Record<string, number> = { 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<string, string> = { 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 {
@@ -369,13 +545,28 @@ export function calculateScore(need: Need, property: Property): MatchEngineOutpu
const profile = resolveProfile(need, property)
// ── Hard criteria scoring ──────────────────────────────────────────────────
const hardFactors: ScoreFactor[] = [
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
@@ -388,14 +579,21 @@ export function calculateScore(need: Need, property: Property): MatchEngineOutpu
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)
// ── Modifiers ──────────────────────────────────────────────────────────────
const dqMod = calcDataQualityModifier(property)
const confMod = calcConfidenceModifier(property)
// ── Final score: weighted sum of both groups + modifiers ──────────────────
// ── Final score: weighted sum of both groups + modifiers + must-have penalty
// Each group already normalized 0100; combine per SCORE_SPLIT, then apply modifiers
const baseScore = hardMatchScore * 0.60 + softFactorScore * 0.40
const rawFinal = baseScore + dqMod + confMod - hardFilter.severePenalty
const rawFinal = baseScore + dqMod + confMod - hardFilter.severePenalty + mustHaveEval.scoreImpact
const finalScore = Math.round(Math.min(100, Math.max(0, rawFinal)))
// ── Factor classification — only use weighted soft factors for positive/negative ──
@@ -432,6 +630,7 @@ export function calculateScore(need: Need, property: Property): MatchEngineOutpu
tradeOffs,
risks,
missingData,
mustHaveEvaluation: mustHaveEval.results.length > 0 ? mustHaveEval.results : undefined,
nextBestActions: [], // filled by rankingEngine
}