Files
property-match/src/services/ai/mock/needParser.ts
T
Benjamin Sutter b2530f9e20 feat: Reale Jahresbelastung, must-have fixes, fitOut data coverage
Reale Jahresbelastung (Change 6):
- FitOutCostPanel zeigt Jahresmiete + amortisierte Ausbaukosten für alle fitOut-Werte
- FULL/PREMIUM: Ausbau CHF 0 (bezugsfertig), SHELL/BASIC: CRB/BKP-Richtwerte amortisiert über 5 J.
- Match-Card-Chip zeigt geschätzte Investition (orange wenn >100k)
- FitOutInvestment-Typ + calcFitOutInvestment() in fitOutUtils.ts
- BackendAIService + MockAIService mit generateFitOutAdvice (IAIService-Interface)

Must-have Kriterien (Nicht prüfbar Fix):
- ÖV-Anbindung: Minutengrenze aus Freitext extrahiert, gegen publicTransportMinutes geprüft
- Mindestfläche: m²-Wert aus Freitext extrahiert, gegen areaSqm geprüft
- Ausbaugrad: neues Keyword-Rule für FULL/PREMIUM

fitOut-Datenpflege:
- fitOut-Werte zu 32 fehlenden Properties ergänzt (Logistik=SHELL, Standard=BASIC, Modern=FULL)
- MAB-Werte (200/150/250 CHF/m²) zu 3 BASIC-Objekten hinzugefügt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 20:09:55 +02:00

386 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { ParseNeedResult, ParsedNeedCriteria, FollowUpQuestion } from '../../../domain/needBuilder'
import type { AssetType } from '../../../domain/enums'
export function mockParseNeed(input: string): ParseNeedResult {
const lower = input.toLowerCase()
// Asset type — RETAIL before LOGISTICS to avoid false match on "Nebenräume (Lager)"
const assetType: AssetType | undefined =
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
// Area
const areaRangeMatch = input.match(/(\d+)\s*[\-]\s*(\d+)\s*m[²2]/i)
const areaSingleMatch = input.match(/(\d{3,5})\s*m[²2]/i)
let areaRange: { min: number; max: number } | undefined
let areaConfidence = 0.25
if (areaRangeMatch) {
areaRange = { min: parseInt(areaRangeMatch[1]), max: parseInt(areaRangeMatch[2]) }
areaConfidence = 0.95
} else if (areaSingleMatch) {
const base = parseInt(areaSingleMatch[1])
areaRange = { min: Math.round(base * 0.8), max: Math.round(base * 1.2) }
areaConfidence = 0.70
}
// 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]) => {
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 = [
'pfingstweidstrasse', '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 — 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
// Monthly-to-annual threshold: Swiss retail quotes monthly (150/m²/mon → 1800/year),
// office/logistics quote monthly too but at lower values (45/m²/mon → 540/year).
const monthlyThreshold = assetType === 'RETAIL' ? 500 : 150
if (budgetPerSqmMatch) {
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' }
budgetConfidence = 0.60
}
// Timing
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
if (soonMatch) {
timing = { earliestMoveIn: '2025-07-01', latestMoveIn: '2025-10-01', flexibleTiming: false }
timingConfidence = 0.85
} 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') || 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
// Search radius: "innerhalb von 30 km", "30km Umkreis", "im Umkreis von 50 km", "radius 20km"
const radiusMatch =
input.match(/(?:innerhalb\s+(?:von\s+)?|im\s+umkreis\s+(?:von\s+)?|radius\s+(?:von\s+)?)(\d+)\s*km/i) ??
input.match(/(\d+)\s*km\s*(?:umkreis|radius|entfernung)/i) ??
input.match(/(\d+)\s*km/i)
const searchRadius = radiusMatch ? Math.min(100, Math.max(1, parseInt(radiusMatch[1]))) : 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')
|| 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')
const visibilityNeed: 'HIGH' | undefined = lower.includes('sichtbar') || lower.includes('passanten') ? 'HIGH' : undefined
const footfallNeed: 'HIGH' | undefined = lower.includes('frequenz') || lower.includes('laufkundschaft') ? 'HIGH' : undefined
// Missing fields
const missingFields: string[] = []
if (!assetType) missingFields.push('Nutzungstyp')
if (!areaRange) missingFields.push('Flächenbedarf')
if (preferredLocations.length === 0) missingFields.push('Standort')
if (!budgetRange) missingFields.push('Budget')
if (!timing) missingFields.push('Verfügbarkeitstermin')
// Assumptions
const assumptions: string[] = []
if (areaRange && areaSingleMatch && !areaRangeMatch) {
assumptions.push(`Flächenrange aus Einzelangabe (${areaSingleMatch[1]} m²) geschätzt — bitte prüfen`)
}
if (budgetRange && !budgetPerSqmMatch && budgetMaxMatch) {
assumptions.push('Budget als Pauschalangabe interpretiert — Angabe pro m² unklar')
}
if (!assetType) {
assumptions.push('Nutzungstyp konnte nicht eindeutig erkannt werden')
}
// Confidence by field
const confidenceByField: Record<string, number> = {
assetType: assetType ? 0.92 : 0.20,
areaRange: areaConfidence,
preferredLocations: locationConfidence,
budgetRange: budgetConfidence,
timing: timingConfidence,
mustHaveCriteria: mustHaveCriteria.length > 0 ? 0.85 : 0.10,
searchRadius: searchRadius ? 0.90 : 0.20,
prestigeImportance: prestigeImportance ? 0.80 : 0.20,
parkingNeed: parkingNeed ? 0.90 : 0.30,
}
// Follow-up questions
const followUpQuestionCandidates: FollowUpQuestion[] = []
if (!assetType) {
followUpQuestionCandidates.push({
id: 'fq-asset-type',
questionText: 'Welchen Nutzungstyp suchen Sie?',
targetField: 'assetType',
reason: 'Der Nutzungstyp konnte nicht eindeutig erkannt werden.',
suggestedAnswerOptions: ['Büro', 'Logistik / Lager', 'Retail', 'Produktion', 'Gastro / F&B'],
importance: 'required',
})
}
if (preferredLocations.length === 0) {
followUpQuestionCandidates.push({
id: 'fq-location',
questionText: 'In welcher Region oder Stadt suchen Sie?',
targetField: 'preferredLocations',
reason: 'Kein konkreter Standort angegeben.',
suggestedAnswerOptions: ['Zürich', 'Basel', 'Bern', 'Zug', 'Luzern', 'Genf'],
importance: 'required',
})
}
if (!timing) {
followUpQuestionCandidates.push({
id: 'fq-timing',
questionText: 'Ab wann benötigen Sie die Fläche?',
targetField: 'timing',
reason: 'Kein Verfügbarkeitsdatum erkannt.',
suggestedAnswerOptions: ['Sofort', 'In 3 Monaten', 'In 6 Monaten', 'In 12 Monaten', 'Flexibel'],
importance: 'recommended',
})
}
if (!budgetRange) {
followUpQuestionCandidates.push({
id: 'fq-budget',
questionText: 'Was ist Ihr Maximalbudget pro m² und Jahr?',
targetField: 'budgetRange',
reason: 'Kein Budget erkannt.',
suggestedAnswerOptions: ['< CHF 300/m²/J', 'CHF 300420/m²/J', 'CHF 420540/m²/J', '> CHF 540/m²/J', 'Flexibel'],
importance: 'recommended',
})
}
followUpQuestionCandidates.push({
id: 'fq-parking',
questionText: 'Benötigen Sie Parkplätze vor Ort?',
targetField: 'parkingNeed',
reason: 'Angabe zu Parkplatzbedarf verbessert die Matchqualität.',
suggestedAnswerOptions: ['Ja, zwingend', 'Ja, wenn möglich', 'Nein'],
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.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: 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'}`
return {
extractedCriteria: {
assetType,
areaRange,
preferredLocations,
budgetRange,
timing,
mustHaveCriteria,
infrastructureRequirements: [],
accessibilityRequirements: [],
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,
searchRadius,
notes,
},
confidenceByField,
missingFields,
assumptions,
suggestedWeights,
followUpQuestionCandidates,
rawSummary,
promptVersion: 'mock-v1.0',
schemaVersion: '1.0.0',
}
}