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
+187 -23
View File
@@ -139,11 +139,15 @@ export interface AIServiceProvider {
function mockParseNeed(input: string): ParseNeedResult {
const lower = input.toLowerCase()
// Asset type
// Asset type — RETAIL before LOGISTICS to avoid false match on "Nebenräume (Lager)"
const assetType: AssetType | undefined =
lower.includes('büro') || lower.includes('office') ? 'OFFICE'
: lower.includes('logistik') || lower.includes('lager') ? 'LOGISTICS'
: lower.includes('retail') || lower.includes('laden') || lower.includes('shop') ? 'RETAIL'
lower.includes('retail') || lower.includes('laden') || lower.includes('shop')
|| lower.includes('verkaufslokal') || lower.includes('ladenlokal') || lower.includes('ladenfläche')
|| lower.includes('verkaufsfläche') || lower.includes('schaufenster') ? 'RETAIL'
: lower.includes('büro') || lower.includes('office') || lower.includes('sitzungszimmer') || lower.includes('arbeitsplätze') ? 'OFFICE'
: lower.includes('logistik') || lower.includes('lagerhalle') || lower.includes('lagerraum')
|| (lower.includes('lager') && (lower.includes('logistik') || lower.includes('rampe') || lower.includes('palette') || lower.includes('lkw'))) ? 'LOGISTICS'
: lower.includes('lager') ? 'LOGISTICS'
: lower.includes('produktion') || lower.includes('gewerbe') || lower.includes('industrie') ? 'PRODUCTION'
: lower.includes('gastro') || lower.includes('restaurant') ? 'GASTRO'
: undefined
@@ -162,18 +166,77 @@ function mockParseNeed(input: string): ParseNeedResult {
areaConfidence = 0.70
}
// Locations
// Locations — use word boundaries to avoid false matches like "Umzugsfirma" → "Zug"
const CITIES: [string, string][] = [
['zürich', 'Zürich'], ['basel', 'Basel'], ['bern', 'Bern'], ['genf', 'Genf'],
['lausanne', 'Lausanne'], ['winterthur', 'Winterthur'], ['zug', 'Zug'],
['luzern', 'Luzern'], ['st. gallen', 'St. Gallen'], ['lugano', 'Lugano'],
['biel', 'Biel'], ['schaffhausen', 'Schaffhausen'],
['wädenswil', 'Wädenswil'], ['thalwil', 'Thalwil'], ['uster', 'Uster'],
['horgen', 'Horgen'], ['küsnacht', 'Küsnacht'], ['baar', 'Baar'],
]
const preferredLocations = CITIES.filter(([k]) => lower.includes(k)).map(([, v]) => v)
const preferredLocations = CITIES.filter(([k]) => {
if (k.includes(' ') || k.includes('.')) return lower.includes(k)
// Word boundary: not preceded/followed by a letter (including German umlauts)
const re = new RegExp(`(?<![a-zA-ZäöüÄÖÜß])${k}(?![a-zA-ZäöüÄÖÜß])`)
return re.test(lower)
}).map(([, v]) => v)
// Infer Zürich when Zürich-specific districts or landmarks are mentioned
const ZURICH_SIGNALS = [
'seefeld', 'bellevue', 'paradeplatz', 'bahnhofstrasse', 'zürich-west', 'zürich west',
'oerlikon', 'altstetten', 'kreis 1', 'kreis 2', 'kreis 3', 'kreis 4', 'kreis 5',
'kreis 6', 'kreis 7', 'kreis 8', 'langstrasse', 'hardbrücke', 'freilager',
'europaallee', 'zürich nord', 'zürich süd',
]
if (!preferredLocations.includes('Zürich') && ZURICH_SIGNALS.some(s => lower.includes(s))) {
preferredLocations.push('Zürich')
}
// Contextual Zürich inference: only truly Zürich-specific place/brand names
const ZURICH_ONLY_SIGNALS = [
'industrie groove', 'industrie-groove',
'pfingstweidstrasse', 'hardbrücke', 'freilager', 'europaallee',
'zürich-west', 'zürich west', 'zürich nord', 'zürich süd',
'hürlimann', 'viadukt', 'schiffbau',
]
if (preferredLocations.length === 0 && ZURICH_ONLY_SIGNALS.some(s => lower.includes(s))) {
preferredLocations.push('Zürich')
}
// Extract district list from patterns like "Kreis 3, 4, 5, und 8" or "Kreis 3/4/5"
const kreisListMatch = lower.match(/\bkreis\s+([\d]+(?:\s*[,\/]\s*[\d]+)*(?:\s+und\s+[\d]+)?)/)
if (kreisListMatch) {
const nums = kreisListMatch[1].match(/\d+/g) ?? []
nums.forEach(n => {
const k = `Zürich Kreis ${n}`
if (!preferredLocations.includes(k)) preferredLocations.push(k)
})
if (!preferredLocations.includes('Zürich')) preferredLocations.push('Zürich')
}
// Map Zürich landmark/neighbourhood names → district-level location entries
const ZURICH_DISTRICT_MAP: [string, string][] = [
['seefeld', 'Zürich Seefeld'], ['bellevue', 'Zürich Seefeld'],
['bahnhofstrasse', 'Zürich Kreis 1'], ['paradeplatz', 'Zürich Kreis 1'],
['zürich-west', 'Zürich-West'], ['pfingstweidstrasse', 'Zürich-West'],
['limmatstrasse', 'Zürich-West'],
]
ZURICH_DISTRICT_MAP.forEach(([k, v]) => {
if (lower.includes(k) && !preferredLocations.includes(v)) preferredLocations.push(v)
})
const locationConfidence = preferredLocations.length > 0 ? 0.88 : 0.15
// Budget
const budgetPerSqmMatch = input.match(/(\d+)\s*(?:CHF)?\s*\/\s*m[²2]/i)
// Budget — strip Swiss price notation (320.--) before matching
const cleanedBudget = input.replace(/(\d)\.-+/g, '$1').replace(/(\d)\.—/g, '$1')
// "MZ 320/m²", "CHF 320/m²", "320/m²", "320.--/m²/a"
const budgetPerSqmMatch = cleanedBudget.match(/(?:mz|mietzins|max\.?|budget)?\s*(?:CHF\s*)?(\d{2,4})\s*\/\s*m[²2]/i)
// Range "250 280 CHF" or "250-280/m²" — require explicit currency or /m² so area ranges like "120150m2" are not matched
const budgetRangeMatch =
cleanedBudget.match(/(\d{2,4})\s*[\-]\s*(\d{2,4})\s*(?:CHF|Fr\.?)\b/i) ??
cleanedBudget.match(/(?:CHF|Fr\.?)\s*(\d{2,4})\s*[\-]\s*(\d{2,4})/i) ??
cleanedBudget.match(/(\d{2,4})\s*[\-]\s*(\d{2,4})\s*\/\s*m[²2]/i)
const budgetMaxMatch = input.match(/(?:max\.?|bis|höchstens)\s*(?:CHF\s*)?(\d+)/i)
let budgetRange: { maxPerSqm: number; currency: string } | undefined
let budgetConfidence = 0.20
@@ -184,6 +247,11 @@ function mockParseNeed(input: string): ParseNeedResult {
const raw = parseInt(budgetPerSqmMatch[1])
budgetRange = { maxPerSqm: raw < monthlyThreshold ? raw * 12 : raw, currency: 'CHF' }
budgetConfidence = 0.92
} else if (budgetRangeMatch) {
// Take upper value of range as max
const raw = parseInt(budgetRangeMatch[2])
budgetRange = { maxPerSqm: raw < monthlyThreshold ? raw * 12 : raw, currency: 'CHF' }
budgetConfidence = 0.75
} else if (budgetMaxMatch) {
const raw = parseInt(budgetMaxMatch[1])
budgetRange = { maxPerSqm: raw < monthlyThreshold ? raw * 12 : raw, currency: 'CHF' }
@@ -191,7 +259,9 @@ function mockParseNeed(input: string): ParseNeedResult {
}
// Timing
const yearMatch = input.match(/(?:ab\s+)?(?:Q[1-4]\s*\/?\s*)?(\d{4})/)
const yearMatch = input.match(/(?:ab\s+)?(?:Q[1-4]\s*\/?\s*)?(20\d{2})/)
// "Q4/26" or "Q1/27" — two-digit year (collect all occurrences, use first as earliest)
const quarterMatches = [...input.matchAll(/Q([1-4])\s*[\/\-]?\s*(\d{2})\b/gi)]
const soonMatch = lower.includes('sofort') || lower.includes('asap')
let timing: ParsedNeedCriteria['timing'] | undefined
let timingConfidence = 0.20
@@ -201,20 +271,81 @@ function mockParseNeed(input: string): ParseNeedResult {
} else if (yearMatch) {
timing = { earliestMoveIn: `${yearMatch[1]}-01-01`, latestMoveIn: `${yearMatch[1]}-12-31`, flexibleTiming: lower.includes('flexibel') }
timingConfidence = 0.75
} else if (quarterMatches.length > 0) {
// Use first Q as earliest, last Q as latest (6-month window minimum)
const qStartMonth = [0, 1, 4, 7, 10] // [unused, Q1, Q2, Q3, Q4]
const first = quarterMatches[0]
const last = quarterMatches[quarterMatches.length - 1]
const yr1 = 2000 + parseInt(first[2])
const yr2 = 2000 + parseInt(last[2])
const q1 = parseInt(first[1])
const q2 = parseInt(last[1])
const startM = String(qStartMonth[q1]).padStart(2, '0')
const endM = String(Math.min(12, qStartMonth[q2] + 2)).padStart(2, '0')
timing = {
earliestMoveIn: `${yr1}-${startM}-01`,
latestMoveIn: `${yr2}-${endM}-${endM === '12' ? '31' : '28'}`,
flexibleTiming: true,
}
timingConfidence = 0.70
}
// Must-haves
const mustHaveCriteria: string[] = []
if (lower.includes('öv') || lower.includes('bahnhof') || lower.includes('tram')) mustHaveCriteria.push('Gute ÖV-Anbindung')
if (lower.includes('parking') || lower.includes('parkplatz') || lower.includes('tiefgarage')) mustHaveCriteria.push('Parkplätze vorhanden')
if (lower.includes('öv') || lower.includes('bahnhof') || lower.includes('tram') || lower.includes('erschlossen')) mustHaveCriteria.push('Gute ÖV-Anbindung')
if (lower.includes('parking') || lower.includes('parkplatz') || lower.includes('tiefgarage') || lower.includes('stellplatz')) mustHaveCriteria.push('Parkplätze vorhanden')
if (lower.includes('klimaanlage') || lower.includes('klima')) mustHaveCriteria.push('Klimaanlage')
if (lower.includes('server') || lower.includes('rechenzentr')) mustHaveCriteria.push('Serverraum / IT-Infrastruktur')
if (lower.includes('barrierefrei')) mustHaveCriteria.push('Barrierefreiheit')
if (lower.includes('küche') || lower.includes('kantine')) mustHaveCriteria.push('Kantine / Küche')
if (lower.includes('laderampe') || lower.includes('rampe') || lower.includes('verladetor')) mustHaveCriteria.push('Laderampe')
if (lower.includes('schaufenster') || lower.includes('shopfenster') || lower.includes('vitrine')) mustHaveCriteria.push('Schaufenster')
if (lower.includes('verpflegung') || lower.includes('restaurant') || lower.includes('lunch') || lower.includes('mittagessen') || lower.includes('takeaway')) mustHaveCriteria.push('Verpflegungsmöglichkeiten')
if (lower.includes('startup') || lower.includes('start-up') || lower.includes('community') || lower.includes('coworking') || lower.includes('junge firmen') || lower.includes('jungunternehm')) mustHaveCriteria.push('Startup-Community / innovative Nachbarn')
if (lower.includes('wachsen') || lower.includes('expansion') || lower.includes('erweiterungsoption') || lower.includes('wachstum') || lower.includes('wachstumsoption') || lower.includes('flexibilität zum wachsen')) mustHaveCriteria.push('Erweiterungsoption vorhanden')
if (lower.includes('werkstatt') || lower.includes('atelier') || lower.includes('reparatur')) mustHaveCriteria.push('Werkstatt / Atelier')
// Structured requirement fields
const requireGroundFloor = lower.includes('erdgeschoss') || lower.includes('parterre')
|| lower.includes('schaufenster') || lower.includes('ladenlokal') || undefined as boolean | undefined
const requireAirConditioning = lower.includes('klimaanlage') || lower.includes('klimatisierung')
|| lower.includes('klima') || lower.includes('air conditioning')
|| lower.includes('kühlung') || undefined as boolean | undefined
const requireLoadingDock = lower.includes('laderampe') || lower.includes('verladerampe')
|| lower.includes('ladetor') || lower.includes('rampe') || undefined as boolean | undefined
const requireBarrierFree = lower.includes('barrierefrei') || lower.includes('rollstuhl')
|| lower.includes('iv-gerecht') || lower.includes('behindertengerecht') || undefined as boolean | undefined
const ceilingMatch = input.match(/(\d+(?:[.,]\d+)?)\s*m(?:eter)?\s*(?:deckenhöhe|hallenhöhe|lichte\s+höhe)/i)
?? input.match(/deckenhöhe\s+(?:mind\.?\s*)?(\d+(?:[.,]\d+)?)\s*m/i)
const minCeilingHeightM = ceilingMatch ? parseFloat(ceilingMatch[1].replace(',', '.')) : undefined
// Parking: "3-5 Parkplätze" → extract lower bound (minimum); "5 Parkplätze" → 5
const parkingRangeMatch = input.match(/(\d+)\s*[-]\s*\d+\s*(?:parkplätze?|stellplätze?|pp\b)/i)
const parkingSingleMatch = input.match(/(?:mind(?:estens)?\.?\s+)?(\d+)\s*(?:parkplätze?|stellplätze?|pp\b)/i)
const requiredParkingMin = parkingRangeMatch ? parseInt(parkingRangeMatch[1])
: parkingSingleMatch ? parseInt(parkingSingleMatch[1]) : undefined
const fitOutStr: 'BASIC' | 'FULL' | 'PREMIUM' | undefined =
lower.includes('schlüsselfertig') || lower.includes('premium') || lower.includes('hochwertig')
|| lower.includes('top ausgebaut') || lower.includes('top-ausgebaut') ? 'PREMIUM'
: lower.includes('vollausbau') || lower.includes('vollständig ausgebaut') || lower.includes('ausgebaut')
|| lower.includes('ready to use') || lower.includes('reddy to use') || lower.includes('bezugsfertig') ? 'FULL'
: lower.includes('basisausbau') || lower.includes('rohbau') || lower.includes('einfach') ? 'BASIC'
: undefined
// Contract duration: "7-jähriger Vertrag", "Laufzeit 7 Jahre", standalone "7 Jahre" at sentence start
// Exclude "in X Jahren", "X Jahre im Geschäft", "X Jahre Erfahrung" etc.
const contractMatch = input.match(/(\d+)[- ]?j[aä]hrige?(?:r)?\s+(?:vertrag|mietvertrag|laufzeit)/i)
?? input.match(/(?:vertrag|laufzeit|mietdauer).{0,20}(\d+)\s*jahre?/i)
?? input.match(/\b(\d+)\s*jahre?\b(?!\s*(?:erfahrung|planung|rendite|im\b|alt\b|alten|altes|jung))/i)
const minContractDurationMonths = contractMatch ? parseInt(contractMatch[1]) * 12 : undefined
// Soft
const prestigeImportance: 'LOW' | 'MEDIUM' | 'HIGH' | undefined =
lower.includes('prestige') || lower.includes('representativ') || lower.includes('repräsentativ') ? 'HIGH'
lower.includes('prestige') || lower.includes('representativ') || lower.includes('repräsentativ')
|| lower.includes('topadresse') || lower.includes('top adresse') || lower.includes('innerstädtisch')
|| lower.includes('topaddresse') ? 'HIGH'
: lower.includes('standard') ? 'LOW'
: undefined
const parkingNeed = lower.includes('parking') || lower.includes('parkplatz')
@@ -305,16 +436,40 @@ function mockParseNeed(input: string): ParseNeedResult {
importance: 'optional',
})
// Special notes: apartment wish (UC-A type) + lease options (UC-C type)
const notesParts: string[] = []
const apartmentMatch = input.match(/(\d[.,]\d)\s*zimmer[- ]?wohn/i)
?? (lower.includes('zimmer') && lower.includes('wohnung') ? [''] : null)
if (apartmentMatch) {
const sizeHint = apartmentMatch[1] ? `${apartmentMatch[1]}-Zi-Wohnung` : 'Wohnung'
const rentMatch = input.match(/(\d['.\s]?\d{3})[.,\-\s]*(?:inkl|inkl\.)/i)
?? input.match(/(\d{4})[.,\-]\s*(?:chf|fr)?/i)
const rentHint = rentMatch ? ` max. CHF ${rentMatch[1].replace(/['\s]/g, "'")}` : ''
notesParts.push(`Wunsch: ${sizeHint} im Haus oder in der Nähe${rentHint} inkl. NK`)
}
const leaseOptionMatch = input.match(/(\d+)\s*[*×x]\s*(\d+)\s*jahre?\s*(?:echte\s*)?optione?n?/i)
if (leaseOptionMatch) {
notesParts.push(`Mietoption: ${leaseOptionMatch[1]}×${leaseOptionMatch[2]} Jahre echte Optionen gefordert`)
}
const notes = notesParts.length > 0 ? notesParts.join(' | ') : undefined
// Semantic signal flags used for weight boosting
const hasExpansionSignal = lower.includes('wachsen') || lower.includes('wachstum') || lower.includes('expansion') || lower.includes('erweiterung')
const hasCommunitySignal = lower.includes('startup') || lower.includes('community') || lower.includes('coworking') || lower.includes('junge firmen')
const hasPrestigeSignal = prestigeImportance === 'HIGH' || lower.includes('repräsentativ') || lower.includes('charakter') || lower.includes('beeindrucken')
const hasFlexSignal = lower.includes('flexibel') || lower.includes('wachsen') || hasCommunitySignal
// Suggested weights
const suggestedWeights: Record<string, number> = {
area: 0.20,
location: preferredLocations.length > 0 ? 0.28 : 0.22,
budget: budgetRange ? 0.22 : 0.18,
timing: timing ? 0.15 : 0.12,
prestige: prestigeImportance === 'HIGH' ? 0.10 : 0.05,
area: 0.18,
location: preferredLocations.length > 0 ? 0.25 : 0.20,
budget: budgetRange ? 0.20 : 0.16,
timing: timing ? 0.14 : 0.10,
prestige: hasPrestigeSignal ? 0.10 : 0.04,
accessibility: mustHaveCriteria.includes('Gute ÖV-Anbindung') ? 0.08 : 0.04,
expansionPotential: 0.03,
flexibility: lower.includes('flexibel') ? 0.07 : 0.03,
expansionPotential: hasExpansionSignal ? 0.08 : 0.03,
flexibility: hasFlexSignal ? 0.08 : 0.03,
talentAccess: hasCommunitySignal ? 0.06 : 0.03,
}
const rawSummary = `Bedarf analysiert: ${assetType ?? 'Typ unbekannt'} · ${preferredLocations.join(', ') || 'Standort unklar'} · ${areaRange ? `${areaRange.min}${areaRange.max}` : 'Fläche unklar'} · ${budgetRange ? `max. CHF ${budgetRange.maxPerSqm}/m²` : 'Budget unklar'}`
@@ -329,12 +484,21 @@ function mockParseNeed(input: string): ParseNeedResult {
mustHaveCriteria,
infrastructureRequirements: [],
accessibilityRequirements: [],
prestigeImportance,
flexibilityNeed: lower.includes('flexibel') ? 'HIGH' : 'MEDIUM',
expansionPotential: lower.includes('wachstum') || lower.includes('expansion'),
prestigeImportance: hasPrestigeSignal ? 'HIGH' : prestigeImportance,
flexibilityNeed: hasFlexSignal ? 'HIGH' : 'MEDIUM',
expansionPotential: hasExpansionSignal,
parkingNeed,
visibilityNeed,
footfallNeed,
requireGroundFloor: requireGroundFloor || undefined,
requireAirConditioning: requireAirConditioning || undefined,
requireLoadingDock: requireLoadingDock || undefined,
requireBarrierFree: requireBarrierFree || undefined,
requiredParkingMin,
requiredFitOut: fitOutStr,
minCeilingHeightM,
minContractDurationMonths,
notes,
},
confidenceByField,
missingFields,
@@ -412,7 +576,7 @@ export const aiService = {
// F008 methods
async parseNeed(input: string): Promise<ItemResponse<ParseNeedResult>> {
await new Promise(r => setTimeout(r, 1400))
await new Promise(r => setTimeout(r, 300))
const data = mockParseNeed(input)
return { data }
},