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(`(? 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 "120–150m2" 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 // 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 = { assetType: assetType ? 0.92 : 0.20, areaRange: areaConfidence, preferredLocations: locationConfidence, budgetRange: budgetConfidence, timing: timingConfidence, mustHaveCriteria: mustHaveCriteria.length > 0 ? 0.85 : 0.10, 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 300–420/m²/J', 'CHF 420–540/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 = { 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} m²` : '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, notes, }, confidenceByField, missingFields, assumptions, suggestedWeights, followUpQuestionCandidates, rawSummary, promptVersion: 'mock-v1.0', schemaVersion: '1.0.0', } }