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
+205
View File
@@ -0,0 +1,205 @@
import type { Property } from '../../domain/property'
import type { MustHaveResult } from '../../domain/match'
// ── Penalty per failed CERTAIN criterion (applied to totalScore) ──────────────
export const MUST_HAVE_PENALTY_PER_MISS = 8
export const MUST_HAVE_MAX_PENALTY = 24
interface KeywordRule {
keywords: string[]
check: (p: Property) => boolean | null // null = data not available
explanation: (p: Property, passed: boolean) => string
}
function parking(p: Property): number {
return p.hardFacts?.parking ?? p.softFactors?.parkingSpots ?? 0
}
const RULES: KeywordRule[] = [
{
keywords: ['schaufenster', 'shopfenster', 'shopfront', 'ladenlokal', 'vitrine'],
check: (p) => p.hardFacts?.hasStorefront ?? null,
explanation: (p, ok) =>
p.hardFacts?.hasStorefront === undefined
? 'Keine Daten zum Schaufenster verfügbar'
: ok ? 'Schaufenster / Shopfront bestätigt' : 'Kein Schaufenster vorhanden',
},
{
keywords: ['erdgeschoss', 'parterre', 'ground floor', 'eg ', 'eg,', '(eg)'],
check: (p) => {
const floor = p.hardFacts?.floor ?? p.floorLevel
if (floor === undefined) return null
return floor === 0
},
explanation: (p, ok) => {
const floor = p.hardFacts?.floor ?? p.floorLevel
if (floor === undefined) return 'Stockwerk nicht bekannt'
return ok ? `Erdgeschoss bestätigt (Etage ${floor})` : `Nicht Erdgeschoss (Etage ${floor})`
},
},
{
keywords: ['klimaanlage', 'air conditioning', 'aircondition', 'klima ', 'kühlung', 'hvac', 'klimatisierung', 'klimatisier'],
check: (p) => p.hardFacts?.hasAirConditioning ?? null,
explanation: (p, ok) =>
p.hardFacts?.hasAirConditioning === undefined
? 'Klimaanlage nicht dokumentiert'
: ok ? 'Klimaanlage vorhanden' : 'Keine Klimaanlage vorhanden',
},
{
keywords: ['laderampe', 'loading dock', 'rampe', 'verladerampe', 'tor'],
check: (p) => {
const docks = p.hardFacts?.loadingDocksCount
if (docks === undefined) return null
return docks > 0
},
explanation: (p, ok) => {
const docks = p.hardFacts?.loadingDocksCount
if (docks === undefined) return 'Laderampe / Tor nicht dokumentiert'
return ok ? `${docks} Laderampe(n) vorhanden` : 'Keine Laderampe vorhanden'
},
},
{
keywords: ['parkplatz', 'parkplätze', 'parking', 'stellplatz', 'stellplätze', 'autoabstellplatz'],
check: (p) => {
const count = parking(p)
return count > 0
},
explanation: (p, ok) => {
const count = parking(p)
if (count === 0) return 'Kein Parkplatz vorhanden oder keine Daten'
return ok ? `${count} Parkplatz/Parkplätze vorhanden` : 'Kein Parkplatz'
},
},
{
keywords: ['barrierefrei', 'rollstuhl', 'handicap', 'behindertengerecht', 'iv-gerecht'],
check: (p) => p.hardFacts?.isBarrierFree ?? null,
explanation: (p, ok) =>
p.hardFacts?.isBarrierFree === undefined
? 'Barrierefreiheit nicht dokumentiert'
: ok ? 'Barrierefrei bestätigt' : 'Nicht barrierefrei',
},
{
keywords: ['serverraum', 'rechenzentrum', 'server room', 'datacenter', 'it-raum'],
check: (p) => p.hardFacts?.hasServerRoom ?? null,
explanation: (p, ok) =>
p.hardFacts?.hasServerRoom === undefined
? 'Serverraum nicht dokumentiert'
: ok ? 'Serverraum vorhanden' : 'Kein Serverraum',
},
{
keywords: ['langfristig', 'festvertrag', 'langzeitmiete', '10 jahre', '10-jahres', 'langfristiger vertrag'],
check: (p) => {
const dur = p.contractDurationMonths
if (dur === undefined) return null
return dur >= 120
},
explanation: (p, ok) => {
const dur = p.contractDurationMonths
if (dur === undefined) return 'Vertragsdauer nicht bekannt'
return ok
? `Langfristige Vermietung möglich (${Math.round(dur / 12)} Jahre)`
: `Nur bis ${Math.round(dur / 12)} Jahre — langfristiger Vertrag evtl. nicht möglich`
},
},
{
keywords: ['tageslicht', 'fensterfront', 'natural light', 'natürliches licht', 'aussenfenster'],
check: (_p) => null, // no structured field yet — always UNKNOWN
explanation: (_p, _ok) => 'Tageslicht / Fensterfront nicht strukturiert erfasst',
},
]
// ── Number extraction helpers ──────────────────────────────────────────────────
function extractNumber(text: string): number | null {
const m = text.match(/(\d+)/)
return m ? parseInt(m[1], 10) : null
}
function isParkingKeyword(text: string): boolean {
return RULES[4].keywords.some(kw => text.toLowerCase().includes(kw))
}
// ── Main scorer ───────────────────────────────────────────────────────────────
export function scoreMustHaves(
criteria: string[],
property: Property,
): {
results: MustHaveResult[]
passedCount: number
totalCount: number
scoreImpact: number
} {
if (criteria.length === 0) {
return { results: [], passedCount: 0, totalCount: 0, scoreImpact: 0 }
}
const results: MustHaveResult[] = criteria.map((raw) => {
const lower = raw.toLowerCase()
// Special case: parking with minimum count ("mind. 5 parkplätze")
if (isParkingKeyword(lower)) {
const required = extractNumber(lower)
const available = parking(property)
if (required !== null) {
const passed = available >= required
return {
criterion: raw,
passed,
confidence: 'CERTAIN' as const,
explanation: passed
? `${available} Parkplätze vorhanden (mind. ${required} verlangt)`
: `Nur ${available} Parkplätze (mind. ${required} verlangt)`,
}
}
// No number: just check if any parking available
const hasParking = available > 0
return {
criterion: raw,
passed: hasParking,
confidence: available === 0 ? 'UNKNOWN' as const : 'CERTAIN' as const,
explanation: hasParking ? `${available} Parkplatz/Parkplätze vorhanden` : 'Keine Parkplätze dokumentiert',
}
}
for (const rule of RULES) {
if (rule.keywords.some(kw => lower.includes(kw))) {
const result = rule.check(property)
if (result === null) {
return {
criterion: raw,
passed: false,
confidence: 'UNKNOWN' as const,
explanation: rule.explanation(property, false),
}
}
return {
criterion: raw,
passed: result,
confidence: 'CERTAIN' as const,
explanation: rule.explanation(property, result),
}
}
}
// No keyword matched
return {
criterion: raw,
passed: false,
confidence: 'UNKNOWN' as const,
explanation: 'Kriterium konnte nicht automatisch geprüft werden',
}
})
const certain = results.filter(r => r.confidence === 'CERTAIN')
const passedCount = results.filter(r => r.passed && r.confidence !== 'UNKNOWN').length
const failedCertain = certain.filter(r => !r.passed).length
const scoreImpact = -Math.min(MUST_HAVE_MAX_PENALTY, failedCertain * MUST_HAVE_PENALTY_PER_MISS)
return {
results,
passedCount,
totalCount: certain.length,
scoreImpact,
}
}
+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
}