feat: score transparency on all cards + budget parser fix
- Single scoring system: MockupNeedProvider rewrites matches via calculateScore exclusively — allFactors always populated, no more dual-system (computeScore removed), weights from weightingProfile reflected in every breakdown - ScoreInlineBreakdown: new component shows hard/soft criteria with importance labels (Entscheidend/Sehr wichtig/…) and formula on compact + expanded cards - MatchCardAdapter: passes scoreBreakdown + allFactors to ViewModel - MatchDetail: 'Zukunftssignal' label replaced with 'Future Availability' - aiService budget parser: values < 100 treated as monthly and multiplied by 12 to produce correct annual CHF/m²/year value — fixes 0-result searches Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import type { IMatchProvider, MatchFilters } from './IMatchProvider'
|
||||
import type { Match } from '../domain/match'
|
||||
import { mockMatches } from '../mock-data/matches'
|
||||
|
||||
export const matchStore: Match[] = [...mockMatches]
|
||||
// Matches are computed dynamically via calculateScore so weights always reflect need.weightingProfile
|
||||
export const matchStore: Match[] = []
|
||||
const store = matchStore
|
||||
|
||||
export const MockupMatchProvider: IMatchProvider = {
|
||||
|
||||
@@ -3,95 +3,35 @@ import type { Need, CreateNeedInput, UpdateNeedInput } from '../domain/need'
|
||||
import { mockNeeds } from '../mock-data/needs'
|
||||
import { matchStore } from './MockupMatchProvider'
|
||||
import { propertyStore } from './MockupPropertyProvider'
|
||||
import { MatchStrength, MatchStatus, RiskLevel } from '../domain/enums'
|
||||
import { MatchStrength, MatchStatus, RiskLevel, ResultType } from '../domain/enums'
|
||||
import type { Match } from '../domain/match'
|
||||
import type { MatchEngineOutput } from '../domain/scoring'
|
||||
import type { Property } from '../domain/property'
|
||||
import { getEffectiveUnits } from '../domain/property'
|
||||
import { calculateScore } from '../features/matching/scoreCalculator'
|
||||
|
||||
const store: Need[] = [...mockNeeds]
|
||||
|
||||
// ── Location scoring ───────────────────────────────────────────────────────────
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const CANTON_MAP: Record<string, string> = {
|
||||
zürich: 'zh', zug: 'zg', winterthur: 'zh', uster: 'zh', bülach: 'zh', oerlikon: 'zh',
|
||||
bern: 'be', biel: 'be', thun: 'be', köniz: 'be',
|
||||
basel: 'bs', muttenz: 'bl', pratteln: 'bl', reinach: 'bl', allschwil: 'bl', binningen: 'bl',
|
||||
genf: 'ge', genève: 'ge', carouge: 'ge', lancy: 'ge',
|
||||
'st. gallen': 'sg', 'st.gallen': 'sg', rapperswil: 'sg',
|
||||
}
|
||||
|
||||
function locationScore(propCity: string, preferredLocations: string[]): number {
|
||||
const pc = propCity.toLowerCase()
|
||||
for (const pref of preferredLocations) {
|
||||
const p = pref.toLowerCase()
|
||||
if (pc.includes(p) || p.includes(pc)) return 1.0
|
||||
}
|
||||
const propCanton = CANTON_MAP[pc]
|
||||
if (propCanton) {
|
||||
for (const pref of preferredLocations) {
|
||||
const prefCanton = CANTON_MAP[pref.toLowerCase()]
|
||||
if (prefCanton && prefCanton === propCanton) return 0.55
|
||||
}
|
||||
}
|
||||
return 0.30
|
||||
}
|
||||
|
||||
function computeScore(
|
||||
prop: {
|
||||
assetType: string
|
||||
location: { city: string }
|
||||
resultType?: string
|
||||
},
|
||||
need: Need,
|
||||
areaSqm: number,
|
||||
rentPricePerSqm: number | undefined,
|
||||
isPreMarket = false,
|
||||
): number | null {
|
||||
if (need.assetType && prop.assetType !== need.assetType) return null
|
||||
|
||||
const locScore = locationScore(prop.location.city, need.preferredLocations ?? [])
|
||||
let score = locScore >= 0.9 ? 65 : locScore >= 0.5 ? 42 : 28
|
||||
|
||||
if (need.requiredArea && areaSqm) {
|
||||
const { min, max } = need.requiredArea
|
||||
if (areaSqm >= min && areaSqm <= max) score += 20
|
||||
else if (areaSqm >= min * 0.7 && areaSqm <= max * 1.5) score += 10
|
||||
else if (areaSqm < min * 0.5 || areaSqm > max * 2) score -= 10
|
||||
}
|
||||
|
||||
if (need.budgetRange?.maxPerSqm && rentPricePerSqm) {
|
||||
const monthlyRate = rentPricePerSqm / 12
|
||||
if (monthlyRate <= need.budgetRange.maxPerSqm) score += 10
|
||||
else if (monthlyRate <= need.budgetRange.maxPerSqm * 1.2) score += 3
|
||||
else score -= 8
|
||||
}
|
||||
|
||||
if (prop.resultType === 'FUTURE_AVAILABILITY') {
|
||||
score = Math.round(score * 0.82)
|
||||
} else if (isPreMarket) {
|
||||
score = Math.round(score * 0.92)
|
||||
}
|
||||
|
||||
score += Math.floor(Math.random() * 6) - 2
|
||||
return Math.min(97, Math.max(22, score))
|
||||
}
|
||||
|
||||
function strengthFromScore(s: number): string {
|
||||
function strengthFromScore(s: number): MatchStrength {
|
||||
if (s >= 75) return MatchStrength.STRONG
|
||||
if (s >= 55) return MatchStrength.MODERATE
|
||||
return MatchStrength.WEAK
|
||||
}
|
||||
|
||||
function buildMatch(
|
||||
prop: typeof propertyStore[0],
|
||||
prop: Property,
|
||||
unitId: string | undefined,
|
||||
need: Need,
|
||||
score: number,
|
||||
output: MatchEngineOutput,
|
||||
effectiveResultType: string,
|
||||
resultId: string,
|
||||
isGoodLoc: boolean,
|
||||
areaLabel: string,
|
||||
now: string,
|
||||
): Match {
|
||||
const locationFactor = output.allHardFactors.find(f => f.criterion === 'location')
|
||||
const isGoodLoc = (locationFactor?.score ?? 0) >= 70
|
||||
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
propertyId: prop.id,
|
||||
@@ -99,27 +39,22 @@ function buildMatch(
|
||||
needId: need.id,
|
||||
resultId,
|
||||
resultType: effectiveResultType as Match['resultType'],
|
||||
matchScore: score,
|
||||
matchStrength: strengthFromScore(score) as typeof MatchStrength[keyof typeof MatchStrength],
|
||||
matchScore: output.finalScore,
|
||||
matchStrength: strengthFromScore(output.finalScore),
|
||||
status: MatchStatus.PENDING_REVIEW,
|
||||
scoreBreakdown: {
|
||||
hardMatchScore: score + 5,
|
||||
softFactorScore: score - 5,
|
||||
confidenceModifier: isGoodLoc ? 0.96 : 0.82,
|
||||
dataQualityModifier: 0.92,
|
||||
totalScore: score,
|
||||
hardMatchScore: output.hardMatchScore,
|
||||
softFactorScore: output.softFactorScore,
|
||||
confidenceModifier: output.confidenceModifier,
|
||||
dataQualityModifier: output.dataQualityModifier,
|
||||
totalScore: output.finalScore,
|
||||
},
|
||||
positiveFactors: isGoodLoc
|
||||
? [{ criterion: 'Standort', weight: 0.25, score: 92, contribution: 23, explanation: `${prop.location.city} – bevorzugter Standort` }]
|
||||
: [{ criterion: 'Fläche', weight: 0.25, score: 70, contribution: 17.5, explanation: `${areaLabel} verfügbar` }],
|
||||
negativeFactors: !isGoodLoc
|
||||
? [{ criterion: 'Standort', weight: 0.25, score: 35, contribution: 8.75, explanation: `${prop.location.city} liegt außerhalb der bevorzugten Region` }]
|
||||
: [],
|
||||
tradeoffs: !isGoodLoc
|
||||
? [{ criterion: 'Standort', concern: `${prop.location.city} ist nicht im Präferenzgebiet`, severity: 'MEDIUM' as const }]
|
||||
: [],
|
||||
positiveFactors: output.positiveFactors,
|
||||
negativeFactors: output.negativeFactors,
|
||||
allFactors: [...output.allHardFactors, ...output.allSoftFactors],
|
||||
tradeoffs: output.tradeOffs ?? [],
|
||||
explainabilitySummary: isGoodLoc
|
||||
? `${prop.location.city} trifft den Standortwunsch. ${areaLabel} entspricht den Kernkriterien.`
|
||||
? `${prop.location.city} trifft den Standortwunsch. Kernkriterien sind weitgehend erfüllt.`
|
||||
: `Abweichender Standort (${prop.location.city}). Nur bei Engpass im Zielgebiet empfohlen.`,
|
||||
confidenceLevel: isGoodLoc ? 0.88 : 0.60,
|
||||
riskLevel: isGoodLoc ? RiskLevel.LOW : RiskLevel.MEDIUM,
|
||||
@@ -130,56 +65,50 @@ function buildMatch(
|
||||
}
|
||||
}
|
||||
|
||||
function scoreProperty(need: Need, prop: Property, overrideArea?: number, overridePrice?: number, overrideResultType?: string): MatchEngineOutput {
|
||||
if (overrideArea !== undefined || overridePrice !== undefined || overrideResultType !== undefined) {
|
||||
return calculateScore(need, {
|
||||
...prop,
|
||||
areaSqm: overrideArea ?? prop.areaSqm,
|
||||
rentPricePerSqm: overridePrice ?? prop.rentPricePerSqm,
|
||||
resultType: (overrideResultType ?? prop.resultType) as ResultType,
|
||||
})
|
||||
}
|
||||
return calculateScore(need, prop)
|
||||
}
|
||||
|
||||
function generateSyntheticMatches(need: Need) {
|
||||
const now = new Date().toISOString()
|
||||
const MIN_SCORE = 22
|
||||
|
||||
for (const prop of propertyStore) {
|
||||
const hasExplicitUnits = (prop.units ?? []).length > 0
|
||||
|
||||
if (hasExplicitUnits) {
|
||||
// Multi-unit property: score against aggregate area (tenants renting the whole floor/building)
|
||||
const propScore = computeScore(prop, need, prop.areaSqm, prop.rentPricePerSqm)
|
||||
if (propScore !== null && propScore >= 25) {
|
||||
const isGoodLoc = locationScore(prop.location.city, need.preferredLocations ?? []) >= 0.9
|
||||
matchStore.push(buildMatch(
|
||||
prop, undefined, need, propScore,
|
||||
prop.resultType ?? 'VERIFIED_PORTFOLIO', prop.id,
|
||||
isGoodLoc, `${prop.areaSqm.toLocaleString('de-CH')} m²`, now,
|
||||
))
|
||||
// Whole-property match (multi-unit building)
|
||||
const output = scoreProperty(need, prop)
|
||||
if (!output.excluded && output.finalScore >= MIN_SCORE) {
|
||||
matchStore.push(buildMatch(prop, undefined, need, output, prop.resultType ?? 'VERIFIED_PORTFOLIO', prop.id, now))
|
||||
}
|
||||
|
||||
// Unit-level pre-market: generate per released unit (for tenants seeking that specific unit size)
|
||||
// Per released unit (pre-market)
|
||||
for (const unit of prop.units!) {
|
||||
if (!unit.schattenmarktRelease?.enabled) continue
|
||||
const unitScore = computeScore(prop, need, unit.areaSqm, unit.rentPricePerSqm ?? prop.rentPricePerSqm, true)
|
||||
if (unitScore === null || unitScore < 25) continue
|
||||
const unitOutput = scoreProperty(need, prop, unit.areaSqm, unit.rentPricePerSqm ?? prop.rentPricePerSqm, ResultType.FUTURE_AVAILABILITY)
|
||||
if (unitOutput.excluded || unitOutput.finalScore < MIN_SCORE) continue
|
||||
const resultId = `schattenmarkt-${prop.id}-${unit.id}`
|
||||
const isGoodLoc = locationScore(prop.location.city, need.preferredLocations ?? []) >= 0.9
|
||||
matchStore.push(buildMatch(
|
||||
prop, unit.id, need, unitScore,
|
||||
'FUTURE_AVAILABILITY', resultId,
|
||||
isGoodLoc, `${unit.areaSqm.toLocaleString('de-CH')} m²`, now,
|
||||
))
|
||||
matchStore.push(buildMatch(prop, unit.id, need, unitOutput, ResultType.FUTURE_AVAILABILITY, resultId, now))
|
||||
}
|
||||
} else {
|
||||
// No explicit units: use getEffectiveUnits (whole property synthesised as one unit)
|
||||
// Single-unit / synthesised units
|
||||
for (const unit of getEffectiveUnits(prop)) {
|
||||
const isPreMarket = unit.schattenmarktRelease?.enabled === true
|
||||
const isFutureProp = prop.resultType === 'FUTURE_AVAILABILITY'
|
||||
const score = computeScore(prop, need, unit.areaSqm, unit.rentPricePerSqm ?? prop.rentPricePerSqm, isPreMarket)
|
||||
if (score === null || score < 25) continue
|
||||
|
||||
const effectiveResultType = (isPreMarket || isFutureProp)
|
||||
? 'FUTURE_AVAILABILITY'
|
||||
: (prop.resultType ?? 'VERIFIED_PORTFOLIO')
|
||||
const isFutureProp = prop.resultType === ResultType.FUTURE_AVAILABILITY
|
||||
const effectiveResultType = (isPreMarket || isFutureProp) ? ResultType.FUTURE_AVAILABILITY : (prop.resultType ?? ResultType.VERIFIED_PORTFOLIO)
|
||||
const output = scoreProperty(need, prop, unit.areaSqm, unit.rentPricePerSqm ?? prop.rentPricePerSqm, effectiveResultType)
|
||||
if (output.excluded || output.finalScore < MIN_SCORE) continue
|
||||
const resultId = isPreMarket ? `schattenmarkt-${prop.id}` : prop.id
|
||||
const isGoodLoc = locationScore(prop.location.city, need.preferredLocations ?? []) >= 0.9
|
||||
|
||||
matchStore.push(buildMatch(
|
||||
prop, undefined, need, score,
|
||||
effectiveResultType, resultId,
|
||||
isGoodLoc, `${unit.areaSqm.toLocaleString('de-CH')} m²`, now,
|
||||
))
|
||||
matchStore.push(buildMatch(prop, undefined, need, output, effectiveResultType, resultId, now))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -207,10 +136,22 @@ export const MockupNeedProvider: INeedProvider = {
|
||||
async update(id, data: UpdateNeedInput) {
|
||||
const idx = store.findIndex(n => n.id === id)
|
||||
store[idx] = { ...store[idx], ...data, updatedAt: new Date().toISOString() }
|
||||
return store[idx]
|
||||
// Remove old matches and recompute with updated weights
|
||||
const updated = store[idx]
|
||||
const startLen = matchStore.length
|
||||
for (let i = startLen - 1; i >= 0; i--) {
|
||||
if (matchStore[i].needId === id) matchStore.splice(i, 1)
|
||||
}
|
||||
generateSyntheticMatches(updated)
|
||||
return updated
|
||||
},
|
||||
async remove(id) {
|
||||
const idx = store.findIndex(n => n.id === id)
|
||||
store.splice(idx, 1)
|
||||
},
|
||||
}
|
||||
|
||||
// Compute matches for all pre-existing needs so scores reflect their weightingProfile
|
||||
for (const need of store) {
|
||||
generateSyntheticMatches(need)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user