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:
+19
-754
@@ -1,754 +1,19 @@
|
||||
import type { ItemResponse, ServiceError } from './types'
|
||||
import { ServiceErrorCode } from './types'
|
||||
import type { CreateNeedInput } from '../domain/need'
|
||||
import type { AssetType } from '../domain/enums'
|
||||
import type { ParseNeedResult, ParsedNeedCriteria, FollowUpQuestion } from '../domain/needBuilder'
|
||||
import type { UnifiedMatchResult } from '../domain/unifiedResult'
|
||||
|
||||
// ── Decision Brief ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface DecisionBrief {
|
||||
id: string
|
||||
shortlistId: string
|
||||
summary: string
|
||||
sections: { title: string; body: string }[]
|
||||
generatedAt: string
|
||||
isDraft: true
|
||||
}
|
||||
|
||||
function buildMockDecisionBrief(shortlistId: string): DecisionBrief {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
shortlistId,
|
||||
summary: 'Die Shortlist enthält qualitativ hochwertige Matches mit starker Standortübereinstimmung. Die verfügbaren Flächen decken den Bedarf gut ab. Zwei Objekte eignen sich als Erstbesichtigungen.',
|
||||
sections: [
|
||||
{
|
||||
title: 'Zusammenfassung der Objekte',
|
||||
body: 'Die Shortlist umfasst mehrere Objekte aus dem verifizierten Portfolio. Die Matchscores liegen zwischen 74 und 88, was auf eine gute bis sehr gute Übereinstimmung mit den Suchkriterien hinweist.',
|
||||
},
|
||||
{
|
||||
title: 'Standortbewertung',
|
||||
body: 'Die Mehrheit der Objekte befindet sich in bevorzugten Lagen. Die ÖV-Anbindung ist bei allen Objekten als gut bis sehr gut einzustufen.',
|
||||
},
|
||||
{
|
||||
title: 'Budgetanalyse',
|
||||
body: 'Die Mietpreise liegen im budgetkonformen Bereich. Keine der Optionen überschreitet das maximale Budget pro m².',
|
||||
},
|
||||
{
|
||||
title: 'Empfohlene nächste Schritte',
|
||||
body: '1. Besichtigung der Top-2-Objekte vereinbaren. 2. Detaillierte Flächenpläne anfordern. 3. Vertragskonditionen prüfen lassen.',
|
||||
},
|
||||
],
|
||||
generatedAt: new Date().toISOString(),
|
||||
isDraft: true,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Compare Summary ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface ComparisonSummary {
|
||||
strongestOption: { matchId: string; label: string; reason: string }
|
||||
bestValue: { matchId: string; label: string; reason: string } | null
|
||||
highestConfidence: { matchId: string; label: string; confidenceLevel: number }
|
||||
biggestTradeoffs: string[]
|
||||
missingDataWarnings: string[]
|
||||
recommendedNextStep: string
|
||||
overallAssessment: string
|
||||
perPropertyAssessment: Array<{
|
||||
matchId: string
|
||||
label: string
|
||||
strengths: string[]
|
||||
weaknesses: string[]
|
||||
bestFor: string
|
||||
keyRisk: string | null
|
||||
}>
|
||||
recommendation: string
|
||||
}
|
||||
|
||||
function buildComparisonSummary(items: UnifiedMatchResult[]): ComparisonSummary {
|
||||
const getTitle = (item: UnifiedMatchResult) =>
|
||||
item.resultType !== 'FUTURE_AVAILABILITY'
|
||||
? (item as any).property?.title ?? `Match ${item.matchScore}`
|
||||
: (item as any).signal?.companyName ?? 'Zukunftssignal'
|
||||
|
||||
if (items.length === 0) {
|
||||
return {
|
||||
strongestOption: { matchId: '', label: '–', reason: 'Keine Ergebnisse' },
|
||||
bestValue: null,
|
||||
highestConfidence: { matchId: '', label: '–', confidenceLevel: 0 },
|
||||
biggestTradeoffs: [],
|
||||
missingDataWarnings: [],
|
||||
recommendedNextStep: 'Suchergebnisse überprüfen',
|
||||
overallAssessment: 'Keine Ergebnisse für die Analyse verfügbar.',
|
||||
perPropertyAssessment: [],
|
||||
recommendation: 'Suchergebnisse überprüfen und erneut versuchen.',
|
||||
}
|
||||
}
|
||||
|
||||
const strongest = items.reduce((a, b) => a.matchScore > b.matchScore ? a : b)
|
||||
|
||||
const propertyItems = items.filter(i => i.resultType !== 'FUTURE_AVAILABILITY')
|
||||
const bestValue = propertyItems.length > 0
|
||||
? propertyItems.reduce((a, b) =>
|
||||
((a as any).property?.rentPricePerSqm ?? Infinity) <= ((b as any).property?.rentPricePerSqm ?? Infinity) ? a : b
|
||||
)
|
||||
: null
|
||||
|
||||
const highestConf = items.reduce((a, b) =>
|
||||
a.match.confidenceLevel >= b.match.confidenceLevel ? a : b
|
||||
)
|
||||
|
||||
const tradeoffs = items
|
||||
.flatMap(i => i.match.tradeoffs?.slice(0, 1).map(t => `${getTitle(i)}: ${t.concern}`) ?? [])
|
||||
.slice(0, 3)
|
||||
|
||||
const missingWarnings = items
|
||||
.filter(i => (i.match.missingData?.filter(m => m.importance === 'CRITICAL').length ?? 0) > 0)
|
||||
.map(i => `${getTitle(i)}: fehlende Pflichtfelder`)
|
||||
|
||||
const topNextAction = strongest.match.nextBestActions?.[0]?.label ?? 'Objekt besichtigen oder Details prüfen'
|
||||
|
||||
// Overall assessment
|
||||
const labels = items.map(getTitle)
|
||||
const scoreLeader = getTitle(strongest)
|
||||
const priceLeader = bestValue ? getTitle(bestValue) : null
|
||||
let overallAssessment: string
|
||||
if (items.length === 1) {
|
||||
overallAssessment = `${labels[0]} erfüllt die Kernkriterien mit einem Match Score von ${strongest.matchScore}/100. Eine Vergleichsbasis fehlt — weitere Optionen hinzufügen für eine fundierte Entscheidung.`
|
||||
} else if (priceLeader && priceLeader !== scoreLeader) {
|
||||
overallAssessment = `Beide Optionen erfüllen Standort und Fläche gut. ${scoreLeader} führt beim Match Score und Prestige, ${priceLeader} beim Preis-Leistungs-Verhältnis.`
|
||||
} else {
|
||||
overallAssessment = `${scoreLeader} dominiert in den meisten Kriterien mit dem höchsten Match Score (${strongest.matchScore}/100). Die Optionen unterscheiden sich hauptsächlich in Lage und Ausstattung.`
|
||||
}
|
||||
|
||||
// Per-property assessment
|
||||
const deriveBestFor = (item: UnifiedMatchResult): string => {
|
||||
const assetType = (item as any).property?.assetType ?? ''
|
||||
const score = item.matchScore
|
||||
const hasPosPrestige = item.match.positiveFactors.some(f =>
|
||||
f.criterion.toLowerCase().includes('prestige') || f.criterion.toLowerCase().includes('location')
|
||||
)
|
||||
const hasLowPrice = bestValue?.matchId === item.matchId
|
||||
if (hasPosPrestige && score >= 80) return 'Repräsentationsbedarf mit Prestige-Anforderungen'
|
||||
if (hasLowPrice) return 'Kostenoptimierte Wachstumsphase'
|
||||
if (assetType === 'LOGISTICS' || assetType === 'PRODUCTION') return 'Operative Effizienz und Flächenflexibilität'
|
||||
if (assetType === 'RETAIL') return 'Hohe Kundenfrequenz und Sichtbarkeit'
|
||||
if (score >= 80) return 'Anspruchsvolle Anforderungen mit hoher Matchqualität'
|
||||
return 'Ausgewogenes Preis-Leistungs-Profil'
|
||||
}
|
||||
|
||||
const perPropertyAssessment = items.map(item => {
|
||||
const topStrengths = item.match.positiveFactors
|
||||
.slice(0, 2)
|
||||
.map(f => f.explanation)
|
||||
const topWeaknesses = item.match.negativeFactors.length > 0
|
||||
? item.match.negativeFactors.slice(0, 2).map(f => f.explanation)
|
||||
: ['Keine kritischen Schwächen identifiziert']
|
||||
const keyRisk = item.match.risks?.[0]?.description ?? null
|
||||
return {
|
||||
matchId: item.matchId,
|
||||
label: getTitle(item),
|
||||
strengths: topStrengths,
|
||||
weaknesses: topWeaknesses,
|
||||
bestFor: deriveBestFor(item),
|
||||
keyRisk,
|
||||
}
|
||||
})
|
||||
|
||||
// Recommendation
|
||||
const recommendation = items.length >= 2
|
||||
? `Besichtigung beider Objekte empfohlen — danach Entscheidung auf Basis Mietvertragslaufzeit und Ausbaustandard.`
|
||||
: `Besichtigung von ${getTitle(strongest)} empfohlen — anschliessend Vertragskonditionen und Laufzeit prüfen.`
|
||||
|
||||
return {
|
||||
strongestOption: {
|
||||
matchId: strongest.matchId,
|
||||
label: getTitle(strongest),
|
||||
reason: `Höchster Match Score (${strongest.matchScore}/100)`,
|
||||
},
|
||||
bestValue: bestValue
|
||||
? {
|
||||
matchId: bestValue.matchId,
|
||||
label: getTitle(bestValue),
|
||||
reason: `Niedrigster Mietpreis (CHF ${(bestValue as any).property?.rentPricePerSqm ?? '–'}/m²)`,
|
||||
}
|
||||
: null,
|
||||
highestConfidence: {
|
||||
matchId: highestConf.matchId,
|
||||
label: getTitle(highestConf),
|
||||
confidenceLevel: highestConf.match.confidenceLevel,
|
||||
},
|
||||
biggestTradeoffs: tradeoffs,
|
||||
missingDataWarnings: missingWarnings,
|
||||
recommendedNextStep: topNextAction,
|
||||
overallAssessment,
|
||||
perPropertyAssessment,
|
||||
recommendation,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Legacy types (kept for backward compatibility) ────────────────────────────
|
||||
|
||||
export interface CriteriaExtractionResult {
|
||||
extractedCriteria: Partial<CreateNeedInput>
|
||||
confidence: number
|
||||
missingFields: string[]
|
||||
assumptions: string[]
|
||||
followUpQuestions: string[]
|
||||
}
|
||||
|
||||
export interface AIServiceProvider {
|
||||
extractCriteria(naturalLanguageInput: string): Promise<CriteriaExtractionResult>
|
||||
generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<string[]>
|
||||
}
|
||||
|
||||
// ── Mock parse logic ──────────────────────────────────────────────────────────
|
||||
|
||||
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 "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<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 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<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} 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',
|
||||
}
|
||||
}
|
||||
|
||||
// ── Legacy mock provider (kept for backward compat) ───────────────────────────
|
||||
|
||||
const MockupAIServiceProvider: AIServiceProvider = {
|
||||
async extractCriteria(_input: string): Promise<CriteriaExtractionResult> {
|
||||
return {
|
||||
extractedCriteria: {
|
||||
companyName: 'Unbekannt (bitte bestätigen)',
|
||||
requiredArea: { min: 400, max: 900 },
|
||||
budgetRange: { maxPerSqm: 40, currency: 'CHF' },
|
||||
},
|
||||
confidence: 0.72,
|
||||
missingFields: ['assetType', 'timing', 'preferredLocations'],
|
||||
assumptions: ['Fläche aus Zahlenangabe geschätzt', 'Budget aus Kostennennung abgeleitet'],
|
||||
followUpQuestions: [
|
||||
'Welchen Nutzungstyp suchen Sie (Büro, Retail, Logistik)?',
|
||||
'In welchen Städten oder Regionen suchen Sie?',
|
||||
'Wann möchten Sie spätestens einziehen?',
|
||||
],
|
||||
}
|
||||
},
|
||||
async generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<string[]> {
|
||||
const questions: string[] = []
|
||||
if (!partialNeed.assetType) questions.push('Welchen Nutzungstyp suchen Sie?')
|
||||
if (!partialNeed.preferredLocations?.length) questions.push('In welchen Regionen suchen Sie?')
|
||||
if (!partialNeed.timing) questions.push('Was ist Ihr gewünschter Einzugstermin?')
|
||||
if (!partialNeed.budgetRange) questions.push('Was ist Ihr maximales monatliches Budget?')
|
||||
return questions
|
||||
},
|
||||
}
|
||||
|
||||
const provider = MockupAIServiceProvider
|
||||
|
||||
const notConfiguredError = (): ServiceError => ({
|
||||
code: ServiceErrorCode.AI_GENERATION_FAILED,
|
||||
message: 'OpenRouter nicht konfiguriert',
|
||||
})
|
||||
|
||||
export const openRouterAIService: AIServiceProvider = {
|
||||
async extractCriteria(_input: string): Promise<CriteriaExtractionResult> {
|
||||
throw notConfiguredError()
|
||||
},
|
||||
async generateFollowUp(_partialNeed: Partial<CreateNeedInput>): Promise<string[]> {
|
||||
throw notConfiguredError()
|
||||
},
|
||||
}
|
||||
|
||||
export const aiService = {
|
||||
// Legacy methods
|
||||
async extractCriteria(input: string): Promise<ItemResponse<CriteriaExtractionResult>> {
|
||||
const data = await provider.extractCriteria(input)
|
||||
return { data }
|
||||
},
|
||||
async generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<ItemResponse<string[]>> {
|
||||
const data = await provider.generateFollowUp(partialNeed)
|
||||
return { data }
|
||||
},
|
||||
|
||||
// F014: Compare summary
|
||||
async summarizeComparison(items: UnifiedMatchResult[]): Promise<ItemResponse<ComparisonSummary>> {
|
||||
await new Promise(r => setTimeout(r, 600))
|
||||
return { data: buildComparisonSummary(items) }
|
||||
},
|
||||
|
||||
// F008 methods
|
||||
async parseNeed(input: string): Promise<ItemResponse<ParseNeedResult>> {
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
const data = mockParseNeed(input)
|
||||
return { data }
|
||||
},
|
||||
async generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise<ItemResponse<FollowUpQuestion[]>> {
|
||||
await new Promise(r => setTimeout(r, 600))
|
||||
const result = mockParseNeed(JSON.stringify(criteria))
|
||||
return { data: result.followUpQuestionCandidates }
|
||||
},
|
||||
|
||||
async generateDecisionBrief(shortlistId: string): Promise<ItemResponse<DecisionBrief>> {
|
||||
await new Promise(r => setTimeout(r, 1800))
|
||||
return { data: buildMockDecisionBrief(shortlistId) }
|
||||
},
|
||||
|
||||
async generateOfferEmail(payload: {
|
||||
needTitle: string
|
||||
properties: string[]
|
||||
matchScores: number[]
|
||||
}): Promise<{ data: { subject: string; body: string }; error: null }> {
|
||||
await new Promise(r => setTimeout(r, 1200))
|
||||
return {
|
||||
data: {
|
||||
subject: `Passende Gewerbeflächen zu Ihrer Anfrage: ${payload.needTitle}`,
|
||||
body:
|
||||
`Sehr geehrte Damen und Herren,\n\nvielen Dank für Ihr Interesse. Gerne unterbreiten wir Ihnen folgende passende Gewerbeobjekte aus unserem Portfolio:\n\n` +
|
||||
payload.properties.map((p, i) => `• ${p} (Match-Score: ${payload.matchScores[i]}%)`).join('\n') +
|
||||
`\n\nGerne arrangieren wir Besichtigungstermine für die genannten Objekte und stehen für alle weiteren Fragen zur Verfügung.\n\nFreundliche Grüsse\nWincasa AG`,
|
||||
},
|
||||
error: null,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// ── Listing Parser (Supply Side) ──────────────────────────────────────────────
|
||||
|
||||
export interface ParsedListingData {
|
||||
assetType?: string
|
||||
areaSqm?: number
|
||||
rentPerSqm?: number
|
||||
city?: string
|
||||
softLevels?: Record<string, string>
|
||||
parking?: number
|
||||
fitOut?: string
|
||||
}
|
||||
|
||||
export async function parseListingText(text: string): Promise<ParsedListingData> {
|
||||
await new Promise(r => setTimeout(r, 900))
|
||||
const t = text.toLowerCase()
|
||||
const result: ParsedListingData = {}
|
||||
|
||||
// Asset type
|
||||
if (t.includes('laden') || t.includes('retail') || t.includes('shop') || t.includes('geschäft')) {
|
||||
result.assetType = 'RETAIL'
|
||||
} else if (t.includes('lager') || t.includes('logistik')) {
|
||||
result.assetType = 'LOGISTICS'
|
||||
} else if (t.includes('produktion') || t.includes('industrie') || t.includes('werkstatt')) {
|
||||
result.assetType = 'PRODUCTION'
|
||||
} else {
|
||||
result.assetType = 'OFFICE'
|
||||
}
|
||||
|
||||
// Area
|
||||
const areaMatch = text.match(/(\d{2,5})\s*m²/) ?? text.match(/(\d{2,5})\s*Quadratmeter/)
|
||||
if (areaMatch) result.areaSqm = parseInt(areaMatch[1])
|
||||
|
||||
// Price — if raw < 500 assume monthly → convert to annual
|
||||
const priceMatch = text.match(/(\d{2,4})\s*(?:CHF|Fr\.?)\s*\/\s*m²/) ?? text.match(/CHF\s*(\d{2,4})/)
|
||||
if (priceMatch) {
|
||||
const raw = parseInt(priceMatch[1])
|
||||
result.rentPerSqm = raw < 500 ? raw * 12 : raw
|
||||
}
|
||||
|
||||
// City
|
||||
const CITIES = ['Zürich', 'Bern', 'Basel', 'Genf', 'Lausanne', 'Zug', 'Winterthur', 'St. Gallen', 'Lugano', 'Luzern', 'Biel', 'Thun', 'Kloten', 'Opfikon', 'Uster']
|
||||
for (const city of CITIES) {
|
||||
if (t.includes(city.toLowerCase())) { result.city = city; break }
|
||||
}
|
||||
|
||||
// Soft factors
|
||||
const soft: Record<string, string> = {}
|
||||
soft.prestige = (t.includes('zentrum') || t.includes('innenstadt') || t.includes('hauptbahnhof') || t.includes('repräsentativ') || t.includes('prestige'))
|
||||
? 'HIGH' : (t.includes('gewerbegebiet') || t.includes('peripherie') || t.includes('industriezone'))
|
||||
? 'LOW' : 'MEDIUM'
|
||||
soft.accessibility = (t.includes('bahnhof') || t.includes('s-bahn') || t.includes('tram') || t.includes('öv') || t.includes('zentrum'))
|
||||
? 'HIGH' : (t.includes('autobahn') || t.includes('gewerbegebiet'))
|
||||
? 'LOW' : 'MEDIUM'
|
||||
if (result.assetType === 'RETAIL') {
|
||||
soft.visibility = t.includes('fussgänger') || t.includes('passanten') || t.includes('frequenz') ? 'HIGH' : 'MEDIUM'
|
||||
soft.footfall = soft.visibility
|
||||
}
|
||||
if (t.includes('nachhaltig') || t.includes('minergie') || t.includes('esg') || t.includes('zertifiziert')) soft.esg = 'HIGH'
|
||||
if (t.includes('expansion') || t.includes('erweiter') || t.includes('wachstum')) soft.expansionPotential = 'HIGH'
|
||||
if (result.city === 'Zug') soft.taxEnvironment = 'HIGH'
|
||||
if (t.includes('hochwertig') || t.includes('premium') || t.includes('erstklassig')) {
|
||||
soft.prestige = 'HIGH'
|
||||
result.fitOut = 'PREMIUM'
|
||||
} else if (t.includes('einfach') || t.includes('standard-ausbau')) {
|
||||
result.fitOut = 'BASIC'
|
||||
}
|
||||
result.softLevels = soft
|
||||
|
||||
// Parking
|
||||
const parkingMatch = text.match(/(\d+)\s*Parkplätze?/) ?? text.match(/(\d+)\s*PP/)
|
||||
if (parkingMatch) result.parking = parseInt(parkingMatch[1])
|
||||
|
||||
return result
|
||||
}
|
||||
/**
|
||||
* Backward-compatible barrel re-export.
|
||||
*
|
||||
* All existing import paths (e.g. `import { aiService } from '../../services/aiService'`)
|
||||
* continue to work without any change in the call sites.
|
||||
*
|
||||
* To add new AI features: extend IAIService and implement in MockAIService / OpenRouterAIService.
|
||||
*/
|
||||
export { aiService, MockAIService, OpenRouterAIService } from './ai'
|
||||
export type { IAIService } from './ai'
|
||||
export type {
|
||||
DecisionBrief,
|
||||
ComparisonSummary,
|
||||
CriteriaExtractionResult,
|
||||
AIServiceProvider,
|
||||
ParsedListingData,
|
||||
OfferEmailPayload,
|
||||
} from './ai/IAIService'
|
||||
export { parseListingText } from './ai/mock/listingParser'
|
||||
|
||||
Reference in New Issue
Block a user