feat: unified error handling + AI service modularisation

Error handling (Prompt 2):
- src/services/errors.ts: AppError class, normalizeError(), throwServiceError() helper
- 6 services wrapped with try/catch (property, match, need, shortlist, futureSignal, inquiry)
- inquiryService aligned from custom ServiceResult<T> to standard ServiceResponse types
- Results, MatchCenter, FutureAvailability pages show <ErrorState onRetry> on query failure

AI modularisation (Prompt 3):
- src/services/aiService.ts reduced from 755 → 19 lines (barrel re-export)
- src/services/ai/IAIService.ts: typed interface + all response types
- src/services/ai/mock/: needParser, compareBuilder, decisionBrief, listingParser, MockAIService
- src/services/ai/openrouter/OpenRouterAIService.ts: model-agnostic skeleton
- src/services/ai/prompts/: 4 prompt template files (needParsing, matchExplanation, compareSummary, decisionBrief)
- src/services/ai/index.ts: factory selects Mock or OpenRouter via VITE_USE_REAL_AI flag
- All existing import paths unchanged — zero call-site modifications

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-24 00:32:01 +02:00
parent efc72b720e
commit 6515acb7f0
23 changed files with 1468 additions and 955 deletions
+377
View File
@@ -0,0 +1,377 @@
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 = [
'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 — 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
// 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,
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,
notes,
},
confidenceByField,
missingFields,
assumptions,
suggestedWeights,
followUpQuestionCandidates,
rawSummary,
promptVersion: 'mock-v1.0',
schemaVersion: '1.0.0',
}
}