From 6515acb7f080b1d3b1c05c9d21fd64487a4b336b Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Sun, 24 May 2026 00:32:01 +0200 Subject: [PATCH] feat: unified error handling + AI service modularisation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Error handling (Prompt 2): - src/services/errors.ts: AppError class, normalizeError(), throwServiceError() helper - 6 services wrapped with try/catch (property, match, need, shortlist, futureSignal, inquiry) - inquiryService aligned from custom ServiceResult to standard ServiceResponse types - Results, MatchCenter, FutureAvailability pages show on query failure AI modularisation (Prompt 3): - src/services/aiService.ts reduced from 755 → 19 lines (barrel re-export) - src/services/ai/IAIService.ts: typed interface + all response types - src/services/ai/mock/: needParser, compareBuilder, decisionBrief, listingParser, MockAIService - src/services/ai/openrouter/OpenRouterAIService.ts: model-agnostic skeleton - src/services/ai/prompts/: 4 prompt template files (needParsing, matchExplanation, compareSummary, decisionBrief) - src/services/ai/index.ts: factory selects Mock or OpenRouter via VITE_USE_REAL_AI flag - All existing import paths unchanged — zero call-site modifications Co-Authored-By: Claude Sonnet 4.6 --- src/pages/demand/Results.tsx | 5 +- src/pages/supply/FutureAvailability.tsx | 5 +- src/pages/supply/MatchCenter.tsx | 5 +- src/services/ai/IAIService.ts | 86 ++ src/services/ai/index.ts | 20 + src/services/ai/mock/MockAIService.ts | 88 ++ src/services/ai/mock/compareBuilder.ts | 124 +++ src/services/ai/mock/decisionBrief.ts | 29 + src/services/ai/mock/listingParser.ts | 64 ++ src/services/ai/mock/needParser.ts | 377 +++++++++ .../ai/openrouter/OpenRouterAIService.ts | 149 ++++ .../ai/prompts/compareSummaryPrompt.ts | 21 + .../ai/prompts/decisionBriefPrompt.ts | 22 + .../ai/prompts/matchExplanationPrompt.ts | 19 + src/services/ai/prompts/needParsingPrompt.ts | 22 + src/services/aiService.ts | 773 +----------------- src/services/errors.ts | 61 ++ src/services/futureSignalService.ts | 85 +- src/services/inquiryService.ts | 44 +- src/services/matchService.ts | 265 +++--- src/services/needService.ts | 41 +- src/services/propertyService.ts | 61 +- src/services/shortlistService.ts | 57 +- 23 files changed, 1468 insertions(+), 955 deletions(-) create mode 100644 src/services/ai/IAIService.ts create mode 100644 src/services/ai/index.ts create mode 100644 src/services/ai/mock/MockAIService.ts create mode 100644 src/services/ai/mock/compareBuilder.ts create mode 100644 src/services/ai/mock/decisionBrief.ts create mode 100644 src/services/ai/mock/listingParser.ts create mode 100644 src/services/ai/mock/needParser.ts create mode 100644 src/services/ai/openrouter/OpenRouterAIService.ts create mode 100644 src/services/ai/prompts/compareSummaryPrompt.ts create mode 100644 src/services/ai/prompts/decisionBriefPrompt.ts create mode 100644 src/services/ai/prompts/matchExplanationPrompt.ts create mode 100644 src/services/ai/prompts/needParsingPrompt.ts create mode 100644 src/services/errors.ts 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) + } }, }