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:
@@ -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 0–1 scale → convert to 0–100
|
||||
const score = Math.round(Math.min(100, Math.max(0, rawValue * 100)))
|
||||
// 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<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 0–100; 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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user