diff --git a/src/pages/demand/Results.tsx b/src/pages/demand/Results.tsx
index c2232b4..5c49e17 100644
--- a/src/pages/demand/Results.tsx
+++ b/src/pages/demand/Results.tsx
@@ -13,6 +13,7 @@ import {
UnifiedResultFeed,
} from '../../components/results'
import { AddToPipelineDialog } from '../../components/shortlist'
+import { ErrorState } from '../../components/ui'
import { useSessionStore } from '../../stores/sessionStore'
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
@@ -65,7 +66,7 @@ export default function Results() {
queryClient.invalidateQueries({ queryKey: ['needs'] })
}
- const { data: results = [], isLoading } = useUnifiedResults(effectiveNeedId)
+ const { data: results = [], isLoading, error } = useUnifiedResults(effectiveNeedId)
const isStaff = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
@@ -185,6 +186,8 @@ export default function Results() {
{isLoading ? (
+ ) : error ? (
+
) : sorted.length === 0 ? (
) : (
diff --git a/src/pages/supply/FutureAvailability.tsx b/src/pages/supply/FutureAvailability.tsx
index 63fffe9..1bb30ec 100644
--- a/src/pages/supply/FutureAvailability.tsx
+++ b/src/pages/supply/FutureAvailability.tsx
@@ -9,6 +9,7 @@ import {
DEFAULT_SIGNAL_FILTERS,
} from '../../components/future-signals'
import { AddToShortlistDialog } from '../../components/shortlist'
+import { ErrorState } from '../../components/ui'
import type { SignalFilterState } from '../../components/future-signals'
import type { FutureSignal } from '../../domain/futureSignal'
@@ -29,7 +30,7 @@ function applyFilters(signals: FutureSignal[], f: SignalFilterState): FutureSign
}
export default function FutureAvailability() {
- const { data: signals = [], isLoading } = useFutureSignals()
+ const { data: signals = [], isLoading, isError, refetch } = useFutureSignals()
const [filters, setFilters] = useState(DEFAULT_SIGNAL_FILTERS)
const [selectedSignal, setSelectedSignal] = useState(null)
@@ -102,6 +103,8 @@ export default function FutureAvailability() {
))}
+ ) : isError ? (
+
) : filtered.length === 0 ? (
) : (
diff --git a/src/pages/supply/MatchCenter.tsx b/src/pages/supply/MatchCenter.tsx
index 2dded20..ceba5e6 100644
--- a/src/pages/supply/MatchCenter.tsx
+++ b/src/pages/supply/MatchCenter.tsx
@@ -15,6 +15,7 @@ import { useNeeds } from '../../hooks/useNeeds'
import { useMatchCenterStore } from '../../stores/matchCenterStore'
import { useToastStore } from '../../stores/toastStore'
import { MatchListCard, MatchBriefingPanel, MatchCenterSkeleton } from '../../components/match-center'
+import { ErrorState } from '../../components/ui'
import type { Match } from '../../domain/match'
const STRENGTH_OPTIONS = [
@@ -32,7 +33,7 @@ const STATUS_OPTIONS = [
]
export default function MatchCenter() {
- const { data: matches = [], isLoading } = useMatches()
+ const { data: matches = [], isLoading, isError, refetch } = useMatches()
const { data: properties = [] } = useProperties()
const { data: needs = [] } = useNeeds()
const { setSelectedProperty, setSelectedNeed } = useMatchCenterStore()
@@ -141,6 +142,8 @@ export default function MatchCenter() {
{isLoading ? (
+ ) : isError ? (
+
) : filtered.length === 0 ? (
Keine Matches für diese Filter.
diff --git a/src/services/ai/IAIService.ts b/src/services/ai/IAIService.ts
new file mode 100644
index 0000000..314bed2
--- /dev/null
+++ b/src/services/ai/IAIService.ts
@@ -0,0 +1,86 @@
+import type { ItemResponse } from '../types'
+import type { CreateNeedInput } from '../../domain/need'
+import type { ParseNeedResult, ParsedNeedCriteria, FollowUpQuestion } from '../../domain/needBuilder'
+import type { UnifiedMatchResult } from '../../domain/unifiedResult'
+
+// ── Response Types ────────────────────────────────────────────────────────────
+
+export interface DecisionBrief {
+ id: string
+ shortlistId: string
+ summary: string
+ sections: { title: string; body: string }[]
+ generatedAt: string
+ isDraft: true
+}
+
+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
+}
+
+export interface OfferEmailPayload {
+ needTitle: string
+ properties: string[]
+ matchScores: number[]
+}
+
+export interface ParsedListingData {
+ assetType?: string
+ areaSqm?: number
+ rentPerSqm?: number
+ city?: string
+ softLevels?: Record
+ parking?: number
+ fitOut?: string
+}
+
+// ── Legacy types (kept for backward compatibility) ────────────────────────────
+
+export interface CriteriaExtractionResult {
+ extractedCriteria: Partial
+ confidence: number
+ missingFields: string[]
+ assumptions: string[]
+ followUpQuestions: string[]
+}
+
+export interface AIServiceProvider {
+ extractCriteria(naturalLanguageInput: string): Promise
+ generateFollowUp(partialNeed: Partial): Promise
+}
+
+// ── Service Interface ─────────────────────────────────────────────────────────
+
+export interface IAIService {
+ // Need parsing (F008)
+ parseNeed(input: string): Promise>
+ generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise>
+
+ // Compare (F014)
+ summarizeComparison(items: UnifiedMatchResult[]): Promise>
+
+ // Shortlist decision brief
+ generateDecisionBrief(shortlistId: string): Promise>
+
+ // Offer email (supply side)
+ generateOfferEmail(payload: OfferEmailPayload): Promise>
+
+ // Legacy methods
+ extractCriteria(input: string): Promise>
+ generateFollowUp(partialNeed: Partial): Promise>
+}
diff --git a/src/services/ai/index.ts b/src/services/ai/index.ts
new file mode 100644
index 0000000..ab3b56d
--- /dev/null
+++ b/src/services/ai/index.ts
@@ -0,0 +1,20 @@
+/**
+ * AI Service Factory
+ *
+ * Selects the active implementation based on the VITE_USE_REAL_AI feature flag.
+ *
+ * Mock mode (default): no API key required, all responses are deterministic.
+ * Real mode: set VITE_USE_REAL_AI=true + VITE_OPENROUTER_API_KEY in .env
+ */
+import { MockAIService } from './mock/MockAIService'
+import { OpenRouterAIService } from './openrouter/OpenRouterAIService'
+import type { IAIService } from './IAIService'
+
+const useRealAI = import.meta.env.VITE_USE_REAL_AI === 'true'
+ && !!import.meta.env.VITE_OPENROUTER_API_KEY
+
+export const aiService: IAIService = useRealAI ? OpenRouterAIService : MockAIService
+
+// Named export so callers can reach the concrete impl when needed
+export { MockAIService, OpenRouterAIService }
+export type { IAIService }
diff --git a/src/services/ai/mock/MockAIService.ts b/src/services/ai/mock/MockAIService.ts
new file mode 100644
index 0000000..d24ac28
--- /dev/null
+++ b/src/services/ai/mock/MockAIService.ts
@@ -0,0 +1,88 @@
+import type { ItemResponse } from '../../types'
+import type { CreateNeedInput } from '../../../domain/need'
+import type { ParsedNeedCriteria, FollowUpQuestion } from '../../../domain/needBuilder'
+import type { UnifiedMatchResult } from '../../../domain/unifiedResult'
+import type {
+ IAIService,
+ DecisionBrief,
+ ComparisonSummary,
+ CriteriaExtractionResult,
+ OfferEmailPayload,
+} from '../IAIService'
+import { mockParseNeed } from './needParser'
+import { buildComparisonSummary } from './compareBuilder'
+import { buildMockDecisionBrief } from './decisionBrief'
+
+const SIMULATED_DELAY = {
+ fast: 300,
+ medium: 600,
+ slow: 1800,
+}
+
+const delay = (ms: number) => new Promise(r => setTimeout(r, ms))
+
+export const MockAIService: IAIService = {
+ async parseNeed(input: string): Promise>> {
+ await delay(SIMULATED_DELAY.fast)
+ return { data: mockParseNeed(input) }
+ },
+
+ async generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise> {
+ await delay(SIMULATED_DELAY.medium)
+ const result = mockParseNeed(JSON.stringify(criteria))
+ return { data: result.followUpQuestionCandidates }
+ },
+
+ async summarizeComparison(items: UnifiedMatchResult[]): Promise> {
+ await delay(SIMULATED_DELAY.medium)
+ return { data: buildComparisonSummary(items) }
+ },
+
+ async generateDecisionBrief(shortlistId: string): Promise> {
+ await delay(SIMULATED_DELAY.slow)
+ return { data: buildMockDecisionBrief(shortlistId) }
+ },
+
+ async generateOfferEmail(payload: OfferEmailPayload): Promise> {
+ await delay(SIMULATED_DELAY.medium * 2)
+ 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`,
+ },
+ }
+ },
+
+ // Legacy methods
+ async extractCriteria(_input: string): Promise> {
+ return {
+ data: {
+ 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): Promise> {
+ 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 { data: questions }
+ },
+}
diff --git a/src/services/ai/mock/compareBuilder.ts b/src/services/ai/mock/compareBuilder.ts
new file mode 100644
index 0000000..02fa40d
--- /dev/null
+++ b/src/services/ai/mock/compareBuilder.ts
@@ -0,0 +1,124 @@
+import type { UnifiedMatchResult } from '../../../domain/unifiedResult'
+import type { ComparisonSummary } from '../IAIService'
+
+export 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,
+ }
+}
diff --git a/src/services/ai/mock/decisionBrief.ts b/src/services/ai/mock/decisionBrief.ts
new file mode 100644
index 0000000..1755156
--- /dev/null
+++ b/src/services/ai/mock/decisionBrief.ts
@@ -0,0 +1,29 @@
+import type { DecisionBrief } from '../IAIService'
+
+export 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,
+ }
+}
diff --git a/src/services/ai/mock/listingParser.ts b/src/services/ai/mock/listingParser.ts
new file mode 100644
index 0000000..a093167
--- /dev/null
+++ b/src/services/ai/mock/listingParser.ts
@@ -0,0 +1,64 @@
+import type { ParsedListingData } from '../IAIService'
+
+export async function parseListingText(text: string): Promise {
+ 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 = {}
+ 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
+}
diff --git a/src/services/ai/mock/needParser.ts b/src/services/ai/mock/needParser.ts
new file mode 100644
index 0000000..18c64d6
--- /dev/null
+++ b/src/services/ai/mock/needParser.ts
@@ -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(`(? 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 = {
+ 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',
+ }
+}
diff --git a/src/services/ai/openrouter/OpenRouterAIService.ts b/src/services/ai/openrouter/OpenRouterAIService.ts
new file mode 100644
index 0000000..89296aa
--- /dev/null
+++ b/src/services/ai/openrouter/OpenRouterAIService.ts
@@ -0,0 +1,149 @@
+/**
+ * OpenRouter AI Service
+ *
+ * To activate:
+ * 1. Set VITE_USE_REAL_AI=true in your .env file
+ * 2. Set VITE_OPENROUTER_API_KEY=
+ * 3. Optionally set VITE_OPENROUTER_MODEL (default: anthropic/claude-3-5-haiku)
+ *
+ * This service is model-agnostic — change VITE_OPENROUTER_MODEL to switch
+ * between Claude, GPT-4o, Mistral, Llama, etc. without code changes.
+ */
+import type { ItemResponse } from '../../types'
+import type { CreateNeedInput } from '../../../domain/need'
+import type { ParseNeedResult, ParsedNeedCriteria, FollowUpQuestion } from '../../../domain/needBuilder'
+import type { UnifiedMatchResult } from '../../../domain/unifiedResult'
+import type {
+ IAIService,
+ DecisionBrief,
+ ComparisonSummary,
+ CriteriaExtractionResult,
+ OfferEmailPayload,
+} from '../IAIService'
+import { ServiceErrorCode } from '../../types'
+import { AppError } from '../../errors'
+import { buildNeedParsingPrompt } from '../prompts/needParsingPrompt'
+import { buildCompareSummaryPrompt } from '../prompts/compareSummaryPrompt'
+import { buildDecisionBriefPrompt } from '../prompts/decisionBriefPrompt'
+import { MockAIService } from '../mock/MockAIService'
+
+const API_BASE = 'https://openrouter.ai/api/v1'
+const DEFAULT_MODEL = 'anthropic/claude-3-5-haiku'
+
+function getConfig(): { apiKey: string; model: string } | null {
+ const apiKey = import.meta.env.VITE_OPENROUTER_API_KEY as string | undefined
+ if (!apiKey) return null
+ return {
+ apiKey,
+ model: (import.meta.env.VITE_OPENROUTER_MODEL as string | undefined) ?? DEFAULT_MODEL,
+ }
+}
+
+async function chat(config: { apiKey: string; model: string }, system: string, user: string): Promise {
+ const res = await fetch(`${API_BASE}/chat/completions`, {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${config.apiKey}`,
+ 'Content-Type': 'application/json',
+ 'HTTP-Referer': window.location.origin,
+ },
+ body: JSON.stringify({
+ model: config.model,
+ messages: [
+ { role: 'system', content: system },
+ { role: 'user', content: user },
+ ],
+ }),
+ })
+ if (!res.ok) {
+ const body = await res.text()
+ throw new AppError({ code: ServiceErrorCode.AI_GENERATION_FAILED, message: `OpenRouter error ${res.status}: ${body}` })
+ }
+ const json = await res.json() as { choices: Array<{ message: { content: string } }> }
+ return json.choices[0]?.message?.content ?? ''
+}
+
+function parseJSON(raw: string, fallback: T): T {
+ const jsonMatch = raw.match(/```json\n?([\s\S]*?)\n?```/) ?? raw.match(/(\{[\s\S]*\})/)
+ try {
+ return JSON.parse(jsonMatch ? jsonMatch[1] : raw) as T
+ } catch {
+ return fallback
+ }
+}
+
+export const OpenRouterAIService: IAIService = {
+ async parseNeed(input: string): Promise> {
+ const config = getConfig()
+ if (!config) return MockAIService.parseNeed(input)
+
+ const { system, user } = buildNeedParsingPrompt({ userInput: input })
+ const raw = await chat(config, system, user)
+ const parsed = parseJSON(raw, null)
+ // If parse fails fall back to mock (keeps app working even with bad AI responses)
+ if (!parsed) return MockAIService.parseNeed(input)
+ return MockAIService.parseNeed(input) // TODO: map parsed JSON → ParseNeedResult shape
+ },
+
+ async generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise> {
+ const config = getConfig()
+ if (!config) return MockAIService.generateFollowUpQuestions(criteria)
+ // TODO: implement OpenRouter call using followUpQuestionsPrompt
+ return MockAIService.generateFollowUpQuestions(criteria)
+ },
+
+ async summarizeComparison(items: UnifiedMatchResult[]): Promise> {
+ const config = getConfig()
+ if (!config) return MockAIService.summarizeComparison(items)
+
+ const properties = items
+ .filter(i => i.resultType !== 'FUTURE_AVAILABILITY')
+ .map(i => ({
+ title: (i as Record & { property?: { title?: string } }).property?.title ?? 'Unbekannt',
+ matchScore: i.matchScore,
+ city: (i as Record & { property?: { location?: { city?: string } } }).property?.location?.city ?? '–',
+ rentPerSqm: (i as Record & { property?: { rentPricePerSqm?: number } }).property?.rentPricePerSqm ?? 0,
+ positiveFactors: i.match.positiveFactors.slice(0, 2).map(f => f.explanation ?? f.label),
+ negativeFactors: i.match.negativeFactors.slice(0, 2).map(f => f.explanation ?? f.label),
+ }))
+
+ const { system, user } = buildCompareSummaryPrompt({ properties })
+ const raw = await chat(config, system, user)
+ const parsed = parseJSON>(raw, {})
+ if (!parsed.overallAssessment) return MockAIService.summarizeComparison(items)
+ // Merge AI overallAssessment into mock baseline
+ const mock = await MockAIService.summarizeComparison(items)
+ return { data: { ...mock.data, overallAssessment: parsed.overallAssessment, recommendation: parsed.recommendation ?? mock.data.recommendation } }
+ },
+
+ async generateDecisionBrief(shortlistId: string): Promise> {
+ const config = getConfig()
+ if (!config) return MockAIService.generateDecisionBrief(shortlistId)
+ // TODO: pass real shortlist items via context when available
+ const { system, user } = buildDecisionBriefPrompt({ shortlistItems: [], needSummary: shortlistId })
+ const raw = await chat(config, system, user)
+ const parsed = parseJSON>(raw, {})
+ if (!parsed.summary) return MockAIService.generateDecisionBrief(shortlistId)
+ const mock = await MockAIService.generateDecisionBrief(shortlistId)
+ return { data: { ...mock.data, summary: parsed.summary, sections: parsed.sections ?? mock.data.sections } }
+ },
+
+ async generateOfferEmail(payload: OfferEmailPayload): Promise> {
+ const config = getConfig()
+ if (!config) return MockAIService.generateOfferEmail(payload)
+ // TODO: implement OpenRouter call
+ return MockAIService.generateOfferEmail(payload)
+ },
+
+ async extractCriteria(input: string): Promise> {
+ const config = getConfig()
+ if (!config) return MockAIService.extractCriteria(input)
+ return MockAIService.extractCriteria(input)
+ },
+
+ async generateFollowUp(partialNeed: Partial): Promise> {
+ const config = getConfig()
+ if (!config) return MockAIService.generateFollowUp(partialNeed)
+ return MockAIService.generateFollowUp(partialNeed)
+ },
+}
diff --git a/src/services/ai/prompts/compareSummaryPrompt.ts b/src/services/ai/prompts/compareSummaryPrompt.ts
new file mode 100644
index 0000000..60af18e
--- /dev/null
+++ b/src/services/ai/prompts/compareSummaryPrompt.ts
@@ -0,0 +1,21 @@
+export interface CompareSummaryPromptInput {
+ properties: Array<{
+ title: string
+ matchScore: number
+ city: string
+ rentPerSqm: number
+ positiveFactors: string[]
+ negativeFactors: string[]
+ }>
+}
+
+export function buildCompareSummaryPrompt(input: CompareSummaryPromptInput): { system: string; user: string } {
+ const propertyList = input.properties
+ .map((p, i) => `Option ${i + 1}: ${p.title} (${p.city}) — Score: ${p.matchScore}%, CHF ${p.rentPerSqm}/m², Stärken: ${p.positiveFactors.join(', ')}, Risiken: ${p.negativeFactors.join(', ')}`)
+ .join('\n')
+
+ return {
+ system: `Du bist Entscheidungsassistent für Gewerbeimmobilien-Mieter. Erstelle eine präzise Vergleichsanalyse auf Deutsch als valides JSON mit den Feldern: overallAssessment, strongestOption, recommendation.`,
+ user: `Vergleiche folgende Objekte und gib eine strukturierte Empfehlung:\n\n${propertyList}`,
+ }
+}
diff --git a/src/services/ai/prompts/decisionBriefPrompt.ts b/src/services/ai/prompts/decisionBriefPrompt.ts
new file mode 100644
index 0000000..6fe7c72
--- /dev/null
+++ b/src/services/ai/prompts/decisionBriefPrompt.ts
@@ -0,0 +1,22 @@
+export interface DecisionBriefPromptInput {
+ shortlistItems: Array<{
+ title: string
+ city: string
+ matchScore: number
+ areaSqm: number
+ rentPerSqm: number
+ topReasons: string[]
+ }>
+ needSummary: string
+}
+
+export function buildDecisionBriefPrompt(input: DecisionBriefPromptInput): { system: string; user: string } {
+ const itemList = input.shortlistItems
+ .map(i => `- ${i.title} (${i.city}): Score ${i.matchScore}%, ${i.areaSqm}m², CHF ${i.rentPerSqm}/m² — ${i.topReasons.join(', ')}`)
+ .join('\n')
+
+ return {
+ system: `Du bist Senior Real Estate Advisor. Erstelle ein strukturiertes Entscheidungs-Briefing auf Deutsch als JSON mit: summary, sections (Zusammenfassung, Standortbewertung, Budgetanalyse, Empfohlene nächste Schritte).`,
+ user: `Erstelle ein Entscheidungs-Briefing für folgende Shortlist:\n\nSuchprofil: ${input.needSummary}\n\nObjekte:\n${itemList}`,
+ }
+}
diff --git a/src/services/ai/prompts/matchExplanationPrompt.ts b/src/services/ai/prompts/matchExplanationPrompt.ts
new file mode 100644
index 0000000..81ffacc
--- /dev/null
+++ b/src/services/ai/prompts/matchExplanationPrompt.ts
@@ -0,0 +1,19 @@
+export interface MatchExplanationPromptInput {
+ propertyTitle: string
+ propertyCity: string
+ matchScore: number
+ positiveFactors: Array<{ criterion: string; explanation: string }>
+ negativeFactors: Array<{ criterion: string; explanation: string }>
+ needSummary: string
+}
+
+export function buildMatchExplanationPrompt(input: MatchExplanationPromptInput): { system: string; user: string } {
+ return {
+ system: `Du bist ein Experte für Schweizer Gewerbeimmobilien. Erkläre Match-Ergebnisse präzise und entscheidungsorientiert auf Deutsch. Maximal 3 Sätze.`,
+ user: `Erkläre warum das Objekt "${input.propertyTitle}" in ${input.propertyCity} einen Match Score von ${input.matchScore}% hat.
+
+Stärken: ${input.positiveFactors.map(f => f.explanation).join(', ')}
+Schwächen: ${input.negativeFactors.map(f => f.explanation).join(', ')}
+Suchprofil: ${input.needSummary}`,
+ }
+}
diff --git a/src/services/ai/prompts/needParsingPrompt.ts b/src/services/ai/prompts/needParsingPrompt.ts
new file mode 100644
index 0000000..903831e
--- /dev/null
+++ b/src/services/ai/prompts/needParsingPrompt.ts
@@ -0,0 +1,22 @@
+export interface NeedParsingPromptInput {
+ userInput: string
+}
+
+export function buildNeedParsingPrompt(input: NeedParsingPromptInput): { system: string; user: string } {
+ return {
+ system: `Du bist ein Experte für Schweizer Gewerbeimmobilien. Extrahiere strukturierte Suchanforderungen aus natürlichsprachigen Texten.
+
+Antworte immer als valides JSON mit folgendem Schema:
+{
+ "assetType": "OFFICE" | "RETAIL" | "LOGISTICS" | "PRODUCTION" | "GASTRO" | null,
+ "areaRange": { "min": number, "max": number } | null,
+ "preferredLocations": string[],
+ "budgetRange": { "maxPerSqm": number, "currency": "CHF" } | null,
+ "timing": { "earliestMoveIn": "YYYY-MM-DD", "latestMoveIn": "YYYY-MM-DD", "flexibleTiming": boolean } | null,
+ "mustHaveCriteria": string[],
+ "missingFields": string[],
+ "assumptions": string[]
+}`,
+ user: `Analysiere folgende Suchanfrage und extrahiere alle relevanten Kriterien:\n\n${input.userInput}`,
+ }
+}
diff --git a/src/services/aiService.ts b/src/services/aiService.ts
index 181c163..41cdcad 100644
--- a/src/services/aiService.ts
+++ b/src/services/aiService.ts
@@ -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
- confidence: number
- missingFields: string[]
- assumptions: string[]
- followUpQuestions: string[]
-}
-
-export interface AIServiceProvider {
- extractCriteria(naturalLanguageInput: string): Promise
- generateFollowUp(partialNeed: Partial): Promise
-}
-
-// ── 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(`(? 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 = {
- 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',
- }
-}
-
-// ── Legacy mock provider (kept for backward compat) ───────────────────────────
-
-const MockupAIServiceProvider: AIServiceProvider = {
- async extractCriteria(_input: string): Promise {
- 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): Promise {
- 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 {
- throw notConfiguredError()
- },
- async generateFollowUp(_partialNeed: Partial): Promise {
- throw notConfiguredError()
- },
-}
-
-export const aiService = {
- // Legacy methods
- async extractCriteria(input: string): Promise> {
- const data = await provider.extractCriteria(input)
- return { data }
- },
- async generateFollowUp(partialNeed: Partial): Promise> {
- const data = await provider.generateFollowUp(partialNeed)
- return { data }
- },
-
- // F014: Compare summary
- async summarizeComparison(items: UnifiedMatchResult[]): Promise> {
- await new Promise(r => setTimeout(r, 600))
- return { data: buildComparisonSummary(items) }
- },
-
- // F008 methods
- async parseNeed(input: string): Promise> {
- await new Promise(r => setTimeout(r, 300))
- const data = mockParseNeed(input)
- return { data }
- },
- async generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise> {
- await new Promise(r => setTimeout(r, 600))
- const result = mockParseNeed(JSON.stringify(criteria))
- return { data: result.followUpQuestionCandidates }
- },
-
- async generateDecisionBrief(shortlistId: string): Promise> {
- 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
- parking?: number
- fitOut?: string
-}
-
-export async function parseListingText(text: string): Promise {
- 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 = {}
- 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'
diff --git a/src/services/errors.ts b/src/services/errors.ts
new file mode 100644
index 0000000..9cde0f7
--- /dev/null
+++ b/src/services/errors.ts
@@ -0,0 +1,61 @@
+/**
+ * Error normalisation utilities for all services.
+ *
+ * Convention for new services:
+ *
+ * async myMethod(): Promise> {
+ * try {
+ * const data = await provider.getAll()
+ * return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
+ * } catch (err) {
+ * throwServiceError(err)
+ * }
+ * }
+ *
+ * React Query catches the thrown AppError, sets isError = true, and exposes
+ * error.message so pages can render .
+ */
+
+// Re-export so call sites have one import point.
+export type { ServiceError } from './types'
+export { ServiceErrorCode } from './types'
+import type { ServiceError } from './types'
+import { ServiceErrorCode } from './types'
+
+// ── AppError ──────────────────────────────────────────────────────────────────
+
+/** A structured error thrown by services. React Query treats it as any Error. */
+export class AppError extends Error {
+ readonly code: ServiceErrorCode
+
+ constructor(serviceError: ServiceError) {
+ super(serviceError.message)
+ this.name = 'AppError'
+ this.code = serviceError.code
+ }
+}
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+/** Map an unknown catch value to a structured ServiceError. */
+export function normalizeError(err: unknown): ServiceError {
+ if (err instanceof AppError) {
+ return { code: err.code, message: err.message }
+ }
+ if (err instanceof Error) {
+ return { code: ServiceErrorCode.NETWORK_ERROR, message: err.message }
+ }
+ if (typeof err === 'string' && err.length > 0) {
+ return { code: ServiceErrorCode.NETWORK_ERROR, message: err }
+ }
+ return { code: ServiceErrorCode.BACKEND_UNAVAILABLE, message: 'Unbekannter Fehler' }
+}
+
+/**
+ * Normalise and rethrow as AppError.
+ * Use in every service catch block instead of plain `throw err`.
+ * Return type `never` lets TypeScript understand the function always throws.
+ */
+export function throwServiceError(err: unknown): never {
+ throw new AppError(normalizeError(err))
+}
diff --git a/src/services/futureSignalService.ts b/src/services/futureSignalService.ts
index 3e9d43f..03e85d7 100644
--- a/src/services/futureSignalService.ts
+++ b/src/services/futureSignalService.ts
@@ -4,53 +4,82 @@ import type { FutureSignal } from '../domain/futureSignal'
import type { ReviewStatus } from '../domain/enums'
import type { FutureSignalSummary } from '../domain/dashboard'
import type { ListResponse, ItemResponse } from './types'
+import { throwServiceError } from './errors'
const provider = MockupFutureSignalProvider
export const futureSignalService = {
async getAll(filters?: FutureSignalFilters): Promise> {
- const data = await provider.getAll(filters)
- return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
+ try {
+ const data = await provider.getAll(filters)
+ return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
+ } catch (err) {
+ throwServiceError(err)
+ }
},
async getById(id: string): Promise> {
- const data = await provider.getById(id)
- return { data }
+ try {
+ const data = await provider.getById(id)
+ return { data }
+ } catch (err) {
+ throwServiceError(err)
+ }
},
async getByProperty(propertyId: string): Promise> {
- const data = await provider.getByProperty(propertyId)
- return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
+ try {
+ const data = await provider.getByProperty(propertyId)
+ return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
+ } catch (err) {
+ throwServiceError(err)
+ }
},
async verify(id: string, verifiedBy: string): Promise> {
- const data = await provider.verify(id, verifiedBy)
- return { data }
+ try {
+ const data = await provider.verify(id, verifiedBy)
+ return { data }
+ } catch (err) {
+ throwServiceError(err)
+ }
},
async updateReviewStatus(id: string, status: ReviewStatus): Promise> {
- const data = await provider.updateReviewStatus(id, status)
- return { data }
+ try {
+ const data = await provider.updateReviewStatus(id, status)
+ return { data }
+ } catch (err) {
+ throwServiceError(err)
+ }
},
async getSignalsForProperty(propertyId: string): Promise> {
- const data = await provider.getByProperty(propertyId)
- return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
+ try {
+ const data = await provider.getByProperty(propertyId)
+ return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
+ } catch (err) {
+ throwServiceError(err)
+ }
},
async getSignalSummary(): Promise {
- const signals = await provider.getAll()
- const RESTRICTED: readonly string[] = ['CONFIDENTIAL', 'INTERNAL']
- return {
- total: signals.length,
- highConfidence: signals.filter(s => s.confidenceScore >= 0.75).length,
- restricted: signals.filter(s => RESTRICTED.includes(s.sensitivityLevel)).length,
- needsReview: signals.filter(s => !s.isVerified).length,
- avgTimeHorizonMonths:
- signals.length > 0
- ? Math.round(signals.reduce((sum, s) => sum + (s.timeHorizonMonths ?? 0), 0) / signals.length)
- : 0,
- timeHorizonDistribution: {
- short: signals.filter(s => (s.timeHorizonMonths ?? 0) <= 6).length,
- medium: signals.filter(s => { const m = s.timeHorizonMonths ?? 0; return m > 6 && m <= 12 }).length,
- long: signals.filter(s => (s.timeHorizonMonths ?? 0) > 12).length,
- },
+ try {
+ const signals = await provider.getAll()
+ const RESTRICTED: readonly string[] = ['CONFIDENTIAL', 'INTERNAL']
+ return {
+ total: signals.length,
+ highConfidence: signals.filter(s => s.confidenceScore >= 0.75).length,
+ restricted: signals.filter(s => RESTRICTED.includes(s.sensitivityLevel)).length,
+ needsReview: signals.filter(s => !s.isVerified).length,
+ avgTimeHorizonMonths:
+ signals.length > 0
+ ? Math.round(signals.reduce((sum, s) => sum + (s.timeHorizonMonths ?? 0), 0) / signals.length)
+ : 0,
+ timeHorizonDistribution: {
+ short: signals.filter(s => (s.timeHorizonMonths ?? 0) <= 6).length,
+ medium: signals.filter(s => { const m = s.timeHorizonMonths ?? 0; return m > 6 && m <= 12 }).length,
+ long: signals.filter(s => (s.timeHorizonMonths ?? 0) > 12).length,
+ },
+ }
+ } catch (err) {
+ throwServiceError(err)
}
},
}
diff --git a/src/services/inquiryService.ts b/src/services/inquiryService.ts
index d879ae1..f2dc1c4 100644
--- a/src/services/inquiryService.ts
+++ b/src/services/inquiryService.ts
@@ -1,6 +1,8 @@
import { MockupInquiryProvider } from '../provider/MockupInquiryProvider'
import type { InquiryFilters } from '../provider/IInquiryProvider'
import type { Inquiry, Attachment } from '../domain/inquiry'
+import type { ListResponse, ItemResponse } from './types'
+import { throwServiceError } from './errors'
const provider = MockupInquiryProvider
@@ -10,31 +12,29 @@ export interface InquiryReplyPayload {
attachments?: Attachment[]
}
-type ServiceResult = { data: T; error: null } | { data: null; error: string }
-
export const inquiryService = {
- async getActiveInquiries(filters?: InquiryFilters): Promise> {
+ async getActiveInquiries(filters?: InquiryFilters): Promise> {
try {
const data = await provider.getAll(filters)
- return { data, error: null }
- } catch (e) {
- return { data: null, error: e instanceof Error ? e.message : 'Unbekannter Fehler' }
+ return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
+ } catch (err) {
+ throwServiceError(err)
}
},
- async getInquiryById(id: string): Promise> {
+ async getInquiryById(id: string): Promise> {
try {
const data = await provider.getById(id)
- return { data, error: null }
- } catch (e) {
- return { data: null, error: e instanceof Error ? e.message : 'Unbekannter Fehler' }
+ return { data }
+ } catch (err) {
+ throwServiceError(err)
}
},
async sendInquiryReply(
inquiryId: string,
payload: InquiryReplyPayload,
- ): Promise> {
+ ): Promise> {
try {
const data = await provider.addMessage(inquiryId, {
senderType: 'supply_user',
@@ -43,27 +43,27 @@ export const inquiryService = {
body: payload.body,
attachments: payload.attachments ?? [],
})
- return { data, error: null }
- } catch (e) {
- return { data: null, error: e instanceof Error ? e.message : 'Unbekannter Fehler' }
+ return { data }
+ } catch (err) {
+ throwServiceError(err)
}
},
- async markThreadAsRead(id: string): Promise> {
+ async markThreadAsRead(id: string): Promise> {
try {
const data = await provider.markThreadAsRead(id)
- return { data, error: null }
- } catch (e) {
- return { data: null, error: e instanceof Error ? e.message : 'Unbekannter Fehler' }
+ return { data }
+ } catch (err) {
+ throwServiceError(err)
}
},
- async getUnreadCount(): Promise> {
+ async getUnreadCount(): Promise> {
try {
const data = await provider.getUnreadCount()
- return { data, error: null }
- } catch (e) {
- return { data: null, error: e instanceof Error ? e.message : 'Unbekannter Fehler' }
+ return { data }
+ } catch (err) {
+ throwServiceError(err)
}
},
}
diff --git a/src/services/matchService.ts b/src/services/matchService.ts
index 5883531..508997c 100644
--- a/src/services/matchService.ts
+++ b/src/services/matchService.ts
@@ -12,44 +12,77 @@ import type { AdditionalPropertyMatch } from '../domain/additionalMatch'
import type { ListResponse, ItemResponse } from './types'
import { buildFullMatch, computeRankedMatches } from '../features/matching/rankingEngine'
import { ResultType } from '../domain/enums'
+import { throwServiceError } from './errors'
const provider = MockupMatchProvider
export const matchService = {
async getAll(filters?: MatchFilters): Promise> {
- const data = await provider.getAll(filters)
- return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
+ try {
+ const data = await provider.getAll(filters)
+ return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
+ } catch (err) {
+ throwServiceError(err)
+ }
},
async getById(id: string): Promise> {
- const data = await provider.getById(id)
- return { data }
+ try {
+ const data = await provider.getById(id)
+ return { data }
+ } catch (err) {
+ throwServiceError(err)
+ }
},
async getByNeed(needId: string): Promise> {
- const data = await provider.getByNeed(needId)
- return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
+ try {
+ const data = await provider.getByNeed(needId)
+ return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
+ } catch (err) {
+ throwServiceError(err)
+ }
},
async getByProperty(propertyId: string): Promise> {
- const data = await provider.getByProperty(propertyId)
- return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
+ try {
+ const data = await provider.getByProperty(propertyId)
+ return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
+ } catch (err) {
+ throwServiceError(err)
+ }
},
async approve(id: string, reviewedBy: string): Promise> {
- const data = await provider.approve(id, reviewedBy)
- return { data }
+ try {
+ const data = await provider.approve(id, reviewedBy)
+ return { data }
+ } catch (err) {
+ throwServiceError(err)
+ }
},
async getMatchesForProperty(propertyId: string): Promise> {
- const data = await provider.getByProperty(propertyId)
- return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
+ try {
+ const data = await provider.getByProperty(propertyId)
+ return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
+ } catch (err) {
+ throwServiceError(err)
+ }
},
async getMatchDetail(id: string): Promise> {
- const data = await provider.getById(id)
- return { data }
+ try {
+ const data = await provider.getById(id)
+ return { data }
+ } catch (err) {
+ throwServiceError(err)
+ }
},
async getScoreBreakdown(matchId: string): Promise> {
- const match = await provider.getById(matchId)
- return { data: match?.scoreBreakdown ?? null }
+ try {
+ const match = await provider.getById(matchId)
+ return { data: match?.scoreBreakdown ?? null }
+ } catch (err) {
+ throwServiceError(err)
+ }
},
// ── Engine-based methods ──────────────────────────────────────────────────
@@ -59,112 +92,128 @@ export const matchService = {
},
async computeMatchesForNeed(needId: string): Promise> {
- const [need, properties] = await Promise.all([
- MockupNeedProvider.getById(needId),
- MockupPropertyProvider.getAll(),
- ])
- if (!need) return { data: [], meta: { total: 0, page: 1, pageSize: 0, hasMore: false } }
- const data = computeRankedMatches(need, properties)
- return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
+ try {
+ const [need, properties] = await Promise.all([
+ MockupNeedProvider.getById(needId),
+ MockupPropertyProvider.getAll(),
+ ])
+ if (!need) return { data: [], meta: { total: 0, page: 1, pageSize: 0, hasMore: false } }
+ const data = computeRankedMatches(need, properties)
+ return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
+ } catch (err) {
+ throwServiceError(err)
+ }
},
async getNeedMatchesForProperty(
propertyId: string,
opts?: { minScore?: number },
): Promise {
- const minScore = opts?.minScore ?? 80
- const [matches, needs] = await Promise.all([
- provider.getByProperty(propertyId),
- MockupNeedProvider.getAll(),
- ])
- return matches
- .filter(m => m.matchScore >= minScore)
- .sort((a, b) => b.matchScore - a.matchScore)
- .map(m => {
- const need = needs.find(n => n.id === m.needId)
- if (!need) return null
- return {
- matchId: m.id,
- score: m.matchScore,
- needId: need.id,
- needTitle: `${need.companyName}`,
- company: need.companyName,
- areaRequired: `${need.requiredArea.min}–${need.requiredArea.max} m²`,
- budget: `max ${need.budgetRange.maxPerSqm} CHF/m²/Jahr`,
- locations: need.preferredLocations,
- assetType: need.assetType,
- timing: need.timing?.earliestMoveIn ?? '–',
- mustHaves: need.mustCriteriaText ?? [],
- matchHighlights: m.positiveFactors.map(f => f.explanation),
- } satisfies PropertyNeedMatch
- })
- .filter((x): x is PropertyNeedMatch => x !== null)
+ try {
+ const minScore = opts?.minScore ?? 80
+ const [matches, needs] = await Promise.all([
+ provider.getByProperty(propertyId),
+ MockupNeedProvider.getAll(),
+ ])
+ return matches
+ .filter(m => m.matchScore >= minScore)
+ .sort((a, b) => b.matchScore - a.matchScore)
+ .map(m => {
+ const need = needs.find(n => n.id === m.needId)
+ if (!need) return null
+ return {
+ matchId: m.id,
+ score: m.matchScore,
+ needId: need.id,
+ needTitle: `${need.companyName}`,
+ company: need.companyName,
+ areaRequired: `${need.requiredArea.min}–${need.requiredArea.max} m²`,
+ budget: `max ${need.budgetRange.maxPerSqm} CHF/m²/Jahr`,
+ locations: need.preferredLocations,
+ assetType: need.assetType,
+ timing: need.timing?.earliestMoveIn ?? '–',
+ mustHaves: need.mustCriteriaText ?? [],
+ matchHighlights: m.positiveFactors.map(f => f.explanation),
+ } satisfies PropertyNeedMatch
+ })
+ .filter((x): x is PropertyNeedMatch => x !== null)
+ } catch (err) {
+ throwServiceError(err)
+ }
},
async getAdditionalMatchesForInquiry(
inquiryId: string,
opts?: { minScore?: number; excludePropertyId?: string },
): Promise {
- const minScore = opts?.minScore ?? 80
- const inquiry = await MockupInquiryProvider.getById(inquiryId)
- if (!inquiry) return []
- const excludeId = opts?.excludePropertyId ?? inquiry.propertyId
- const [allProperties, allMatches] = await Promise.all([
- MockupPropertyProvider.getAll(),
- provider.getAll(),
- ])
- const portfolioProperties = allProperties.filter(
- p => p.resultType === ResultType.VERIFIED_PORTFOLIO && p.id !== excludeId,
- )
- return portfolioProperties
- .map(p => {
- const match = allMatches.find(m => m.propertyId === p.id)
- const score = match?.matchScore ?? 0
- return { property: p, score, match }
- })
- .filter(({ score }) => score >= minScore)
- .sort((a, b) => b.score - a.score)
- .slice(0, 5)
- .map(({ property: p, score, match }) => ({
- propertyId: p.id,
- title: p.title,
- location: `${p.location.city}${p.location.district ? `, ${p.location.district}` : ''}`,
- areaSqm: p.areaSqm,
- rentPricePerSqm: p.rentPricePerSqm,
- matchScore: score,
- imageUrl: p.images?.[0],
- reasons: (match?.positiveFactors ?? []).slice(0, 3).map(f => f.explanation ?? f.label ?? '').filter(Boolean),
- topUncertainty: (match?.tradeoffs ?? match?.tradeOffs ?? [])[0]?.description
- ?? (match?.negativeFactors ?? [])[0]?.explanation
- ?? undefined,
- }))
+ try {
+ const minScore = opts?.minScore ?? 80
+ const inquiry = await MockupInquiryProvider.getById(inquiryId)
+ if (!inquiry) return []
+ const excludeId = opts?.excludePropertyId ?? inquiry.propertyId
+ const [allProperties, allMatches] = await Promise.all([
+ MockupPropertyProvider.getAll(),
+ provider.getAll(),
+ ])
+ const portfolioProperties = allProperties.filter(
+ p => p.resultType === ResultType.VERIFIED_PORTFOLIO && p.id !== excludeId,
+ )
+ return portfolioProperties
+ .map(p => {
+ const match = allMatches.find(m => m.propertyId === p.id)
+ const score = match?.matchScore ?? 0
+ return { property: p, score, match }
+ })
+ .filter(({ score }) => score >= minScore)
+ .sort((a, b) => b.score - a.score)
+ .slice(0, 5)
+ .map(({ property: p, score, match }) => ({
+ propertyId: p.id,
+ title: p.title,
+ location: `${p.location.city}${p.location.district ? `, ${p.location.district}` : ''}`,
+ areaSqm: p.areaSqm,
+ rentPricePerSqm: p.rentPricePerSqm,
+ matchScore: score,
+ imageUrl: p.images?.[0],
+ reasons: (match?.positiveFactors ?? []).slice(0, 3).map(f => f.explanation ?? f.label ?? '').filter(Boolean),
+ topUncertainty: (match?.tradeoffs ?? match?.tradeOffs ?? [])[0]?.description
+ ?? (match?.negativeFactors ?? [])[0]?.explanation
+ ?? undefined,
+ }))
+ } catch (err) {
+ throwServiceError(err)
+ }
},
async getStrongMatches(minScore = 80): Promise {
- const [matches, properties] = await Promise.all([
- provider.getAll(),
- MockupPropertyProvider.getAll(),
- ])
- return matches
- .filter(m => m.matchScore >= minScore)
- .slice(0, 5)
- .map(m => {
- const prop = properties.find(p => p.id === m.propertyId)
- const topFactor = m.positiveFactors?.[0]
- const firstAction = m.nextBestActions?.[0]
- return {
- matchId: m.id,
- propertyId: m.propertyId,
- propertyTitle: prop?.title ?? 'Unbekanntes Objekt',
- propertyAddress: prop?.address
- ? `${prop.address.street} ${prop.address.houseNumber}, ${prop.address.city}`
- : '–',
- needSummary: m.needId,
- matchScore: m.matchScore,
- topReason: topFactor?.explanation ?? topFactor?.criterion ?? '–',
- missingDataCount: m.missingData?.length ?? 0,
- nextBestAction: firstAction?.label ?? '–',
- } satisfies StrongMatchItem
- })
+ try {
+ const [matches, properties] = await Promise.all([
+ provider.getAll(),
+ MockupPropertyProvider.getAll(),
+ ])
+ return matches
+ .filter(m => m.matchScore >= minScore)
+ .slice(0, 5)
+ .map(m => {
+ const prop = properties.find(p => p.id === m.propertyId)
+ const topFactor = m.positiveFactors?.[0]
+ const firstAction = m.nextBestActions?.[0]
+ return {
+ matchId: m.id,
+ propertyId: m.propertyId,
+ propertyTitle: prop?.title ?? 'Unbekanntes Objekt',
+ propertyAddress: prop?.address
+ ? `${prop.address.street} ${prop.address.houseNumber}, ${prop.address.city}`
+ : '–',
+ needSummary: m.needId,
+ matchScore: m.matchScore,
+ topReason: topFactor?.explanation ?? topFactor?.criterion ?? '–',
+ missingDataCount: m.missingData?.length ?? 0,
+ nextBestAction: firstAction?.label ?? '–',
+ } satisfies StrongMatchItem
+ })
+ } catch (err) {
+ throwServiceError(err)
+ }
},
}
diff --git a/src/services/needService.ts b/src/services/needService.ts
index 6fb0a08..7479244 100644
--- a/src/services/needService.ts
+++ b/src/services/needService.ts
@@ -2,28 +2,49 @@ import { MockupNeedProvider } from '../provider/MockupNeedProvider'
import type { NeedFilters } from '../provider/INeedProvider'
import type { Need, CreateNeedInput, UpdateNeedInput } from '../domain/need'
import type { ListResponse, ItemResponse } from './types'
+import { throwServiceError } from './errors'
const provider = MockupNeedProvider
export const needService = {
async getAll(filters?: NeedFilters): Promise> {
- const data = await provider.getAll(filters)
- return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
+ try {
+ const data = await provider.getAll(filters)
+ return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
+ } catch (err) {
+ throwServiceError(err)
+ }
},
async getById(id: string): Promise> {
- const data = await provider.getById(id)
- return { data }
+ try {
+ const data = await provider.getById(id)
+ return { data }
+ } catch (err) {
+ throwServiceError(err)
+ }
},
async create(input: CreateNeedInput): Promise> {
- const data = await provider.create(input)
- return { data }
+ try {
+ const data = await provider.create(input)
+ return { data }
+ } catch (err) {
+ throwServiceError(err)
+ }
},
async update(id: string, input: UpdateNeedInput): Promise> {
- const data = await provider.update(id, input)
- return { data }
+ try {
+ const data = await provider.update(id, input)
+ return { data }
+ } catch (err) {
+ throwServiceError(err)
+ }
},
async remove(id: string): Promise> {
- await provider.remove(id)
- return { data: undefined }
+ try {
+ await provider.remove(id)
+ return { data: undefined }
+ } catch (err) {
+ throwServiceError(err)
+ }
},
}
diff --git a/src/services/propertyService.ts b/src/services/propertyService.ts
index 028cc16..398ee84 100644
--- a/src/services/propertyService.ts
+++ b/src/services/propertyService.ts
@@ -3,42 +3,71 @@ import type { PropertyFilters } from '../provider/IPropertyProvider'
import type { CreatePropertyInput, UpdatePropertyInput } from '../domain/property'
import type { ListResponse, ItemResponse } from './types'
import type { Property } from '../domain/property'
+import { throwServiceError } from './errors'
const provider = MockupPropertyProvider
const ACTIVE_STATUSES: readonly string[] = ['AVAILABLE_NOW', 'AVAILABLE_SOON']
export const propertyService = {
async getAll(filters?: PropertyFilters): Promise> {
- const data = await provider.getAll(filters)
- return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
+ try {
+ const data = await provider.getAll(filters)
+ return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
+ } catch (err) {
+ throwServiceError(err)
+ }
},
async getById(id: string): Promise> {
- const data = await provider.getById(id)
- return { data }
+ try {
+ const data = await provider.getById(id)
+ return { data }
+ } catch (err) {
+ throwServiceError(err)
+ }
},
async create(input: CreatePropertyInput): Promise> {
- const data = await provider.create(input)
- return { data }
+ try {
+ const data = await provider.create(input)
+ return { data }
+ } catch (err) {
+ throwServiceError(err)
+ }
},
async update(id: string, input: UpdatePropertyInput): Promise> {
- const data = await provider.update(id, input)
- return { data }
+ try {
+ const data = await provider.update(id, input)
+ return { data }
+ } catch (err) {
+ throwServiceError(err)
+ }
},
async remove(id: string): Promise> {
- await provider.remove(id)
- return { data: undefined }
+ try {
+ await provider.remove(id)
+ return { data: undefined }
+ } catch (err) {
+ throwServiceError(err)
+ }
},
async getDashboardPropertiesSummary(): Promise<{ total: number; active: number }> {
- const data = await provider.getAll()
- return {
- total: data.length,
- active: data.filter(p => ACTIVE_STATUSES.includes(p.availabilityStatus)).length,
+ try {
+ const data = await provider.getAll()
+ return {
+ total: data.length,
+ active: data.filter(p => ACTIVE_STATUSES.includes(p.availabilityStatus)).length,
+ }
+ } catch (err) {
+ throwServiceError(err)
}
},
async getProperties(filters?: PropertyFilters): Promise> {
- const data = await provider.getAll(filters)
- return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
+ try {
+ const data = await provider.getAll(filters)
+ return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
+ } catch (err) {
+ throwServiceError(err)
+ }
},
}
diff --git a/src/services/shortlistService.ts b/src/services/shortlistService.ts
index 6b8692f..c05cee7 100644
--- a/src/services/shortlistService.ts
+++ b/src/services/shortlistService.ts
@@ -2,36 +2,65 @@ import { MockupShortlistProvider } from '../provider/MockupShortlistProvider'
import type { ShortlistFilters } from '../provider/IShortlistProvider'
import type { Shortlist, CreateShortlistInput, UpdateShortlistInput, ShortlistItemInput } from '../domain/shortlist'
import type { ListResponse, ItemResponse } from './types'
+import { throwServiceError } from './errors'
const provider = MockupShortlistProvider
export const shortlistService = {
async getAll(filters?: ShortlistFilters): Promise> {
- const data = await provider.getAll(filters)
- return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
+ try {
+ const data = await provider.getAll(filters)
+ return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
+ } catch (err) {
+ throwServiceError(err)
+ }
},
async getById(id: string): Promise> {
- const data = await provider.getById(id)
- return { data }
+ try {
+ const data = await provider.getById(id)
+ return { data }
+ } catch (err) {
+ throwServiceError(err)
+ }
},
async create(input: CreateShortlistInput): Promise> {
- const data = await provider.create(input)
- return { data }
+ try {
+ const data = await provider.create(input)
+ return { data }
+ } catch (err) {
+ throwServiceError(err)
+ }
},
async update(id: string, input: UpdateShortlistInput): Promise> {
- const data = await provider.update(id, input)
- return { data }
+ try {
+ const data = await provider.update(id, input)
+ return { data }
+ } catch (err) {
+ throwServiceError(err)
+ }
},
async addItem(id: string, item: ShortlistItemInput): Promise> {
- const data = await provider.addItem(id, item)
- return { data }
+ try {
+ const data = await provider.addItem(id, item)
+ return { data }
+ } catch (err) {
+ throwServiceError(err)
+ }
},
async removeItem(id: string, resultId: string): Promise> {
- const data = await provider.removeItem(id, resultId)
- return { data }
+ try {
+ const data = await provider.removeItem(id, resultId)
+ return { data }
+ } catch (err) {
+ throwServiceError(err)
+ }
},
async remove(id: string): Promise> {
- await provider.remove(id)
- return { data: undefined }
+ try {
+ await provider.remove(id)
+ return { data: undefined }
+ } catch (err) {
+ throwServiceError(err)
+ }
},
}