feat: unified error handling + AI service modularisation
Error handling (Prompt 2): - src/services/errors.ts: AppError class, normalizeError(), throwServiceError() helper - 6 services wrapped with try/catch (property, match, need, shortlist, futureSignal, inquiry) - inquiryService aligned from custom ServiceResult<T> to standard ServiceResponse types - Results, MatchCenter, FutureAvailability pages show <ErrorState onRetry> on query failure AI modularisation (Prompt 3): - src/services/aiService.ts reduced from 755 → 19 lines (barrel re-export) - src/services/ai/IAIService.ts: typed interface + all response types - src/services/ai/mock/: needParser, compareBuilder, decisionBrief, listingParser, MockAIService - src/services/ai/openrouter/OpenRouterAIService.ts: model-agnostic skeleton - src/services/ai/prompts/: 4 prompt template files (needParsing, matchExplanation, compareSummary, decisionBrief) - src/services/ai/index.ts: factory selects Mock or OpenRouter via VITE_USE_REAL_AI flag - All existing import paths unchanged — zero call-site modifications Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, string>
|
||||
parking?: number
|
||||
fitOut?: string
|
||||
}
|
||||
|
||||
// ── Legacy types (kept for backward compatibility) ────────────────────────────
|
||||
|
||||
export interface CriteriaExtractionResult {
|
||||
extractedCriteria: Partial<CreateNeedInput>
|
||||
confidence: number
|
||||
missingFields: string[]
|
||||
assumptions: string[]
|
||||
followUpQuestions: string[]
|
||||
}
|
||||
|
||||
export interface AIServiceProvider {
|
||||
extractCriteria(naturalLanguageInput: string): Promise<CriteriaExtractionResult>
|
||||
generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<string[]>
|
||||
}
|
||||
|
||||
// ── Service Interface ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface IAIService {
|
||||
// Need parsing (F008)
|
||||
parseNeed(input: string): Promise<ItemResponse<ParseNeedResult>>
|
||||
generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise<ItemResponse<FollowUpQuestion[]>>
|
||||
|
||||
// Compare (F014)
|
||||
summarizeComparison(items: UnifiedMatchResult[]): Promise<ItemResponse<ComparisonSummary>>
|
||||
|
||||
// Shortlist decision brief
|
||||
generateDecisionBrief(shortlistId: string): Promise<ItemResponse<DecisionBrief>>
|
||||
|
||||
// Offer email (supply side)
|
||||
generateOfferEmail(payload: OfferEmailPayload): Promise<ItemResponse<{ subject: string; body: string }>>
|
||||
|
||||
// Legacy methods
|
||||
extractCriteria(input: string): Promise<ItemResponse<CriteriaExtractionResult>>
|
||||
generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<ItemResponse<string[]>>
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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<ItemResponse<ReturnType<typeof mockParseNeed>>> {
|
||||
await delay(SIMULATED_DELAY.fast)
|
||||
return { data: mockParseNeed(input) }
|
||||
},
|
||||
|
||||
async generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise<ItemResponse<FollowUpQuestion[]>> {
|
||||
await delay(SIMULATED_DELAY.medium)
|
||||
const result = mockParseNeed(JSON.stringify(criteria))
|
||||
return { data: result.followUpQuestionCandidates }
|
||||
},
|
||||
|
||||
async summarizeComparison(items: UnifiedMatchResult[]): Promise<ItemResponse<ComparisonSummary>> {
|
||||
await delay(SIMULATED_DELAY.medium)
|
||||
return { data: buildComparisonSummary(items) }
|
||||
},
|
||||
|
||||
async generateDecisionBrief(shortlistId: string): Promise<ItemResponse<DecisionBrief>> {
|
||||
await delay(SIMULATED_DELAY.slow)
|
||||
return { data: buildMockDecisionBrief(shortlistId) }
|
||||
},
|
||||
|
||||
async generateOfferEmail(payload: OfferEmailPayload): Promise<ItemResponse<{ subject: string; body: string }>> {
|
||||
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<ItemResponse<CriteriaExtractionResult>> {
|
||||
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<CreateNeedInput>): Promise<ItemResponse<string[]>> {
|
||||
const questions: string[] = []
|
||||
if (!partialNeed.assetType) questions.push('Welchen Nutzungstyp suchen Sie?')
|
||||
if (!partialNeed.preferredLocations?.length) questions.push('In welchen Regionen suchen Sie?')
|
||||
if (!partialNeed.timing) questions.push('Was ist Ihr gewünschter Einzugstermin?')
|
||||
if (!partialNeed.budgetRange) questions.push('Was ist Ihr maximales monatliches Budget?')
|
||||
return { data: questions }
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { ParsedListingData } from '../IAIService'
|
||||
|
||||
export async function parseListingText(text: string): Promise<ParsedListingData> {
|
||||
await new Promise(r => setTimeout(r, 900))
|
||||
const t = text.toLowerCase()
|
||||
const result: ParsedListingData = {}
|
||||
|
||||
// Asset type
|
||||
if (t.includes('laden') || t.includes('retail') || t.includes('shop') || t.includes('geschäft')) {
|
||||
result.assetType = 'RETAIL'
|
||||
} else if (t.includes('lager') || t.includes('logistik')) {
|
||||
result.assetType = 'LOGISTICS'
|
||||
} else if (t.includes('produktion') || t.includes('industrie') || t.includes('werkstatt')) {
|
||||
result.assetType = 'PRODUCTION'
|
||||
} else {
|
||||
result.assetType = 'OFFICE'
|
||||
}
|
||||
|
||||
// Area
|
||||
const areaMatch = text.match(/(\d{2,5})\s*m²/) ?? text.match(/(\d{2,5})\s*Quadratmeter/)
|
||||
if (areaMatch) result.areaSqm = parseInt(areaMatch[1])
|
||||
|
||||
// Price — if raw < 500 assume monthly → convert to annual
|
||||
const priceMatch = text.match(/(\d{2,4})\s*(?:CHF|Fr\.?)\s*\/\s*m²/) ?? text.match(/CHF\s*(\d{2,4})/)
|
||||
if (priceMatch) {
|
||||
const raw = parseInt(priceMatch[1])
|
||||
result.rentPerSqm = raw < 500 ? raw * 12 : raw
|
||||
}
|
||||
|
||||
// City
|
||||
const CITIES = ['Zürich', 'Bern', 'Basel', 'Genf', 'Lausanne', 'Zug', 'Winterthur', 'St. Gallen', 'Lugano', 'Luzern', 'Biel', 'Thun', 'Kloten', 'Opfikon', 'Uster']
|
||||
for (const city of CITIES) {
|
||||
if (t.includes(city.toLowerCase())) { result.city = city; break }
|
||||
}
|
||||
|
||||
// Soft factors
|
||||
const soft: Record<string, string> = {}
|
||||
soft.prestige = (t.includes('zentrum') || t.includes('innenstadt') || t.includes('hauptbahnhof') || t.includes('repräsentativ') || t.includes('prestige'))
|
||||
? 'HIGH' : (t.includes('gewerbegebiet') || t.includes('peripherie') || t.includes('industriezone'))
|
||||
? 'LOW' : 'MEDIUM'
|
||||
soft.accessibility = (t.includes('bahnhof') || t.includes('s-bahn') || t.includes('tram') || t.includes('öv') || t.includes('zentrum'))
|
||||
? 'HIGH' : (t.includes('autobahn') || t.includes('gewerbegebiet'))
|
||||
? 'LOW' : 'MEDIUM'
|
||||
if (result.assetType === 'RETAIL') {
|
||||
soft.visibility = t.includes('fussgänger') || t.includes('passanten') || t.includes('frequenz') ? 'HIGH' : 'MEDIUM'
|
||||
soft.footfall = soft.visibility
|
||||
}
|
||||
if (t.includes('nachhaltig') || t.includes('minergie') || t.includes('esg') || t.includes('zertifiziert')) soft.esg = 'HIGH'
|
||||
if (t.includes('expansion') || t.includes('erweiter') || t.includes('wachstum')) soft.expansionPotential = 'HIGH'
|
||||
if (result.city === 'Zug') soft.taxEnvironment = 'HIGH'
|
||||
if (t.includes('hochwertig') || t.includes('premium') || t.includes('erstklassig')) {
|
||||
soft.prestige = 'HIGH'
|
||||
result.fitOut = 'PREMIUM'
|
||||
} else if (t.includes('einfach') || t.includes('standard-ausbau')) {
|
||||
result.fitOut = 'BASIC'
|
||||
}
|
||||
result.softLevels = soft
|
||||
|
||||
// Parking
|
||||
const parkingMatch = text.match(/(\d+)\s*Parkplätze?/) ?? text.match(/(\d+)\s*PP/)
|
||||
if (parkingMatch) result.parking = parseInt(parkingMatch[1])
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
import type { ParseNeedResult, ParsedNeedCriteria, FollowUpQuestion } from '../../../domain/needBuilder'
|
||||
import type { AssetType } from '../../../domain/enums'
|
||||
|
||||
export function mockParseNeed(input: string): ParseNeedResult {
|
||||
const lower = input.toLowerCase()
|
||||
|
||||
// Asset type — RETAIL before LOGISTICS to avoid false match on "Nebenräume (Lager)"
|
||||
const assetType: AssetType | undefined =
|
||||
lower.includes('retail') || lower.includes('laden') || lower.includes('shop')
|
||||
|| lower.includes('verkaufslokal') || lower.includes('ladenlokal') || lower.includes('ladenfläche')
|
||||
|| lower.includes('verkaufsfläche') || lower.includes('schaufenster') ? 'RETAIL'
|
||||
: lower.includes('büro') || lower.includes('office') || lower.includes('sitzungszimmer') || lower.includes('arbeitsplätze') ? 'OFFICE'
|
||||
: lower.includes('logistik') || lower.includes('lagerhalle') || lower.includes('lagerraum')
|
||||
|| (lower.includes('lager') && (lower.includes('logistik') || lower.includes('rampe') || lower.includes('palette') || lower.includes('lkw'))) ? 'LOGISTICS'
|
||||
: lower.includes('lager') ? 'LOGISTICS'
|
||||
: lower.includes('produktion') || lower.includes('gewerbe') || lower.includes('industrie') ? 'PRODUCTION'
|
||||
: lower.includes('gastro') || lower.includes('restaurant') ? 'GASTRO'
|
||||
: undefined
|
||||
|
||||
// Area
|
||||
const areaRangeMatch = input.match(/(\d+)\s*[–\-–]\s*(\d+)\s*m[²2]/i)
|
||||
const areaSingleMatch = input.match(/(\d{3,5})\s*m[²2]/i)
|
||||
let areaRange: { min: number; max: number } | undefined
|
||||
let areaConfidence = 0.25
|
||||
if (areaRangeMatch) {
|
||||
areaRange = { min: parseInt(areaRangeMatch[1]), max: parseInt(areaRangeMatch[2]) }
|
||||
areaConfidence = 0.95
|
||||
} else if (areaSingleMatch) {
|
||||
const base = parseInt(areaSingleMatch[1])
|
||||
areaRange = { min: Math.round(base * 0.8), max: Math.round(base * 1.2) }
|
||||
areaConfidence = 0.70
|
||||
}
|
||||
|
||||
// Locations — use word boundaries to avoid false matches like "Umzugsfirma" → "Zug"
|
||||
const CITIES: [string, string][] = [
|
||||
['zürich', 'Zürich'], ['basel', 'Basel'], ['bern', 'Bern'], ['genf', 'Genf'],
|
||||
['lausanne', 'Lausanne'], ['winterthur', 'Winterthur'], ['zug', 'Zug'],
|
||||
['luzern', 'Luzern'], ['st. gallen', 'St. Gallen'], ['lugano', 'Lugano'],
|
||||
['biel', 'Biel'], ['schaffhausen', 'Schaffhausen'],
|
||||
['wädenswil', 'Wädenswil'], ['thalwil', 'Thalwil'], ['uster', 'Uster'],
|
||||
['horgen', 'Horgen'], ['küsnacht', 'Küsnacht'], ['baar', 'Baar'],
|
||||
]
|
||||
const preferredLocations = CITIES.filter(([k]) => {
|
||||
if (k.includes(' ') || k.includes('.')) return lower.includes(k)
|
||||
// Word boundary: not preceded/followed by a letter (including German umlauts)
|
||||
const re = new RegExp(`(?<![a-zA-ZäöüÄÖÜß])${k}(?![a-zA-ZäöüÄÖÜß])`)
|
||||
return re.test(lower)
|
||||
}).map(([, v]) => v)
|
||||
|
||||
// Infer Zürich when Zürich-specific districts or landmarks are mentioned
|
||||
const ZURICH_SIGNALS = [
|
||||
'seefeld', 'bellevue', 'paradeplatz', 'bahnhofstrasse', 'zürich-west', 'zürich west',
|
||||
'oerlikon', 'altstetten', 'kreis 1', 'kreis 2', 'kreis 3', 'kreis 4', 'kreis 5',
|
||||
'kreis 6', 'kreis 7', 'kreis 8', 'langstrasse', 'hardbrücke', 'freilager',
|
||||
'europaallee', 'zürich nord', 'zürich süd',
|
||||
]
|
||||
if (!preferredLocations.includes('Zürich') && ZURICH_SIGNALS.some(s => lower.includes(s))) {
|
||||
preferredLocations.push('Zürich')
|
||||
}
|
||||
|
||||
// Contextual Zürich inference: only truly Zürich-specific place/brand names
|
||||
const ZURICH_ONLY_SIGNALS = [
|
||||
'industrie groove', 'industrie-groove',
|
||||
'pfingstweidstrasse', 'hardbrücke', 'freilager', 'europaallee',
|
||||
'zürich-west', 'zürich west', 'zürich nord', 'zürich süd',
|
||||
'hürlimann', 'viadukt', 'schiffbau',
|
||||
]
|
||||
if (preferredLocations.length === 0 && ZURICH_ONLY_SIGNALS.some(s => lower.includes(s))) {
|
||||
preferredLocations.push('Zürich')
|
||||
}
|
||||
|
||||
// Extract district list from patterns like "Kreis 3, 4, 5, und 8" or "Kreis 3/4/5"
|
||||
const kreisListMatch = lower.match(/\bkreis\s+([\d]+(?:\s*[,\/]\s*[\d]+)*(?:\s+und\s+[\d]+)?)/)
|
||||
if (kreisListMatch) {
|
||||
const nums = kreisListMatch[1].match(/\d+/g) ?? []
|
||||
nums.forEach(n => {
|
||||
const k = `Zürich Kreis ${n}`
|
||||
if (!preferredLocations.includes(k)) preferredLocations.push(k)
|
||||
})
|
||||
if (!preferredLocations.includes('Zürich')) preferredLocations.push('Zürich')
|
||||
}
|
||||
|
||||
// Map Zürich landmark/neighbourhood names → district-level location entries
|
||||
const ZURICH_DISTRICT_MAP: [string, string][] = [
|
||||
['seefeld', 'Zürich Seefeld'], ['bellevue', 'Zürich Seefeld'],
|
||||
['bahnhofstrasse', 'Zürich Kreis 1'], ['paradeplatz', 'Zürich Kreis 1'],
|
||||
['zürich-west', 'Zürich-West'], ['pfingstweidstrasse', 'Zürich-West'],
|
||||
['limmatstrasse', 'Zürich-West'],
|
||||
]
|
||||
ZURICH_DISTRICT_MAP.forEach(([k, v]) => {
|
||||
if (lower.includes(k) && !preferredLocations.includes(v)) preferredLocations.push(v)
|
||||
})
|
||||
|
||||
const locationConfidence = preferredLocations.length > 0 ? 0.88 : 0.15
|
||||
|
||||
// Budget — strip Swiss price notation (320.--) before matching
|
||||
const cleanedBudget = input.replace(/(\d)\.-+/g, '$1').replace(/(\d)\.—/g, '$1')
|
||||
// "MZ 320/m²", "CHF 320/m²", "320/m²", "320.--/m²/a"
|
||||
const budgetPerSqmMatch = cleanedBudget.match(/(?:mz|mietzins|max\.?|budget)?\s*(?:CHF\s*)?(\d{2,4})\s*\/\s*m[²2]/i)
|
||||
// Range "250 – 280 CHF" or "250-280/m²" — require explicit currency or /m² so area ranges like "120–150m2" are not matched
|
||||
const budgetRangeMatch =
|
||||
cleanedBudget.match(/(\d{2,4})\s*[–\-]\s*(\d{2,4})\s*(?:CHF|Fr\.?)\b/i) ??
|
||||
cleanedBudget.match(/(?:CHF|Fr\.?)\s*(\d{2,4})\s*[–\-]\s*(\d{2,4})/i) ??
|
||||
cleanedBudget.match(/(\d{2,4})\s*[–\-]\s*(\d{2,4})\s*\/\s*m[²2]/i)
|
||||
const budgetMaxMatch = input.match(/(?:max\.?|bis|höchstens)\s*(?:CHF\s*)?(\d+)/i)
|
||||
let budgetRange: { maxPerSqm: number; currency: string } | undefined
|
||||
let budgetConfidence = 0.20
|
||||
// Monthly-to-annual threshold: Swiss retail quotes monthly (150/m²/mon → 1800/year),
|
||||
// office/logistics quote monthly too but at lower values (45/m²/mon → 540/year).
|
||||
const monthlyThreshold = assetType === 'RETAIL' ? 500 : 150
|
||||
if (budgetPerSqmMatch) {
|
||||
const raw = parseInt(budgetPerSqmMatch[1])
|
||||
budgetRange = { maxPerSqm: raw < monthlyThreshold ? raw * 12 : raw, currency: 'CHF' }
|
||||
budgetConfidence = 0.92
|
||||
} else if (budgetRangeMatch) {
|
||||
// Take upper value of range as max
|
||||
const raw = parseInt(budgetRangeMatch[2])
|
||||
budgetRange = { maxPerSqm: raw < monthlyThreshold ? raw * 12 : raw, currency: 'CHF' }
|
||||
budgetConfidence = 0.75
|
||||
} else if (budgetMaxMatch) {
|
||||
const raw = parseInt(budgetMaxMatch[1])
|
||||
budgetRange = { maxPerSqm: raw < monthlyThreshold ? raw * 12 : raw, currency: 'CHF' }
|
||||
budgetConfidence = 0.60
|
||||
}
|
||||
|
||||
// Timing
|
||||
const yearMatch = input.match(/(?:ab\s+)?(?:Q[1-4]\s*\/?\s*)?(20\d{2})/)
|
||||
// "Q4/26" or "Q1/27" — two-digit year (collect all occurrences, use first as earliest)
|
||||
const quarterMatches = [...input.matchAll(/Q([1-4])\s*[\/\-]?\s*(\d{2})\b/gi)]
|
||||
const soonMatch = lower.includes('sofort') || lower.includes('asap')
|
||||
let timing: ParsedNeedCriteria['timing'] | undefined
|
||||
let timingConfidence = 0.20
|
||||
if (soonMatch) {
|
||||
timing = { earliestMoveIn: '2025-07-01', latestMoveIn: '2025-10-01', flexibleTiming: false }
|
||||
timingConfidence = 0.85
|
||||
} else if (yearMatch) {
|
||||
timing = { earliestMoveIn: `${yearMatch[1]}-01-01`, latestMoveIn: `${yearMatch[1]}-12-31`, flexibleTiming: lower.includes('flexibel') }
|
||||
timingConfidence = 0.75
|
||||
} else if (quarterMatches.length > 0) {
|
||||
// Use first Q as earliest, last Q as latest (6-month window minimum)
|
||||
const qStartMonth = [0, 1, 4, 7, 10] // [unused, Q1, Q2, Q3, Q4]
|
||||
const first = quarterMatches[0]
|
||||
const last = quarterMatches[quarterMatches.length - 1]
|
||||
const yr1 = 2000 + parseInt(first[2])
|
||||
const yr2 = 2000 + parseInt(last[2])
|
||||
const q1 = parseInt(first[1])
|
||||
const q2 = parseInt(last[1])
|
||||
const startM = String(qStartMonth[q1]).padStart(2, '0')
|
||||
const endM = String(Math.min(12, qStartMonth[q2] + 2)).padStart(2, '0')
|
||||
timing = {
|
||||
earliestMoveIn: `${yr1}-${startM}-01`,
|
||||
latestMoveIn: `${yr2}-${endM}-${endM === '12' ? '31' : '28'}`,
|
||||
flexibleTiming: true,
|
||||
}
|
||||
timingConfidence = 0.70
|
||||
}
|
||||
|
||||
// Must-haves
|
||||
const mustHaveCriteria: string[] = []
|
||||
if (lower.includes('öv') || lower.includes('bahnhof') || lower.includes('tram') || lower.includes('erschlossen')) mustHaveCriteria.push('Gute ÖV-Anbindung')
|
||||
if (lower.includes('parking') || lower.includes('parkplatz') || lower.includes('tiefgarage') || lower.includes('stellplatz')) mustHaveCriteria.push('Parkplätze vorhanden')
|
||||
if (lower.includes('klimaanlage') || lower.includes('klima')) mustHaveCriteria.push('Klimaanlage')
|
||||
if (lower.includes('server') || lower.includes('rechenzentr')) mustHaveCriteria.push('Serverraum / IT-Infrastruktur')
|
||||
if (lower.includes('barrierefrei')) mustHaveCriteria.push('Barrierefreiheit')
|
||||
if (lower.includes('küche') || lower.includes('kantine')) mustHaveCriteria.push('Kantine / Küche')
|
||||
if (lower.includes('laderampe') || lower.includes('rampe') || lower.includes('verladetor')) mustHaveCriteria.push('Laderampe')
|
||||
if (lower.includes('schaufenster') || lower.includes('shopfenster') || lower.includes('vitrine')) mustHaveCriteria.push('Schaufenster')
|
||||
if (lower.includes('verpflegung') || lower.includes('restaurant') || lower.includes('lunch') || lower.includes('mittagessen') || lower.includes('takeaway')) mustHaveCriteria.push('Verpflegungsmöglichkeiten')
|
||||
if (lower.includes('startup') || lower.includes('start-up') || lower.includes('community') || lower.includes('coworking') || lower.includes('junge firmen') || lower.includes('jungunternehm')) mustHaveCriteria.push('Startup-Community / innovative Nachbarn')
|
||||
if (lower.includes('wachsen') || lower.includes('expansion') || lower.includes('erweiterungsoption') || lower.includes('wachstum') || lower.includes('wachstumsoption') || lower.includes('flexibilität zum wachsen')) mustHaveCriteria.push('Erweiterungsoption vorhanden')
|
||||
if (lower.includes('werkstatt') || lower.includes('atelier') || lower.includes('reparatur')) mustHaveCriteria.push('Werkstatt / Atelier')
|
||||
|
||||
// Structured requirement fields
|
||||
const requireGroundFloor = lower.includes('erdgeschoss') || lower.includes('parterre')
|
||||
|| lower.includes('schaufenster') || lower.includes('ladenlokal') || undefined as boolean | undefined
|
||||
const requireAirConditioning = lower.includes('klimaanlage') || lower.includes('klimatisierung')
|
||||
|| lower.includes('klima') || lower.includes('air conditioning')
|
||||
|| lower.includes('kühlung') || undefined as boolean | undefined
|
||||
const requireLoadingDock = lower.includes('laderampe') || lower.includes('verladerampe')
|
||||
|| lower.includes('ladetor') || lower.includes('rampe') || undefined as boolean | undefined
|
||||
const requireBarrierFree = lower.includes('barrierefrei') || lower.includes('rollstuhl')
|
||||
|| lower.includes('iv-gerecht') || lower.includes('behindertengerecht') || undefined as boolean | undefined
|
||||
|
||||
const ceilingMatch = input.match(/(\d+(?:[.,]\d+)?)\s*m(?:eter)?\s*(?:deckenhöhe|hallenhöhe|lichte\s+höhe)/i)
|
||||
?? input.match(/deckenhöhe\s+(?:mind\.?\s*)?(\d+(?:[.,]\d+)?)\s*m/i)
|
||||
const minCeilingHeightM = ceilingMatch ? parseFloat(ceilingMatch[1].replace(',', '.')) : undefined
|
||||
|
||||
// Parking: "3-5 Parkplätze" → extract lower bound (minimum); "5 Parkplätze" → 5
|
||||
const parkingRangeMatch = input.match(/(\d+)\s*[-–]\s*\d+\s*(?:parkplätze?|stellplätze?|pp\b)/i)
|
||||
const parkingSingleMatch = input.match(/(?:mind(?:estens)?\.?\s+)?(\d+)\s*(?:parkplätze?|stellplätze?|pp\b)/i)
|
||||
const requiredParkingMin = parkingRangeMatch ? parseInt(parkingRangeMatch[1])
|
||||
: parkingSingleMatch ? parseInt(parkingSingleMatch[1]) : undefined
|
||||
|
||||
const fitOutStr: 'BASIC' | 'FULL' | 'PREMIUM' | undefined =
|
||||
lower.includes('schlüsselfertig') || lower.includes('premium') || lower.includes('hochwertig')
|
||||
|| lower.includes('top ausgebaut') || lower.includes('top-ausgebaut') ? 'PREMIUM'
|
||||
: lower.includes('vollausbau') || lower.includes('vollständig ausgebaut') || lower.includes('ausgebaut')
|
||||
|| lower.includes('ready to use') || lower.includes('reddy to use') || lower.includes('bezugsfertig') ? 'FULL'
|
||||
: lower.includes('basisausbau') || lower.includes('rohbau') || lower.includes('einfach') ? 'BASIC'
|
||||
: undefined
|
||||
|
||||
// Contract duration: "7-jähriger Vertrag", "Laufzeit 7 Jahre", standalone "7 Jahre" at sentence start
|
||||
// Exclude "in X Jahren", "X Jahre im Geschäft", "X Jahre Erfahrung" etc.
|
||||
const contractMatch = input.match(/(\d+)[- ]?j[aä]hrige?(?:r)?\s+(?:vertrag|mietvertrag|laufzeit)/i)
|
||||
?? input.match(/(?:vertrag|laufzeit|mietdauer).{0,20}(\d+)\s*jahre?/i)
|
||||
?? input.match(/\b(\d+)\s*jahre?\b(?!\s*(?:erfahrung|planung|rendite|im\b|alt\b|alten|altes|jung))/i)
|
||||
const minContractDurationMonths = contractMatch ? parseInt(contractMatch[1]) * 12 : undefined
|
||||
|
||||
// Soft
|
||||
const prestigeImportance: 'LOW' | 'MEDIUM' | 'HIGH' | undefined =
|
||||
lower.includes('prestige') || lower.includes('representativ') || lower.includes('repräsentativ')
|
||||
|| lower.includes('topadresse') || lower.includes('top adresse') || lower.includes('innerstädtisch')
|
||||
|| lower.includes('topaddresse') ? 'HIGH'
|
||||
: lower.includes('standard') ? 'LOW'
|
||||
: undefined
|
||||
const parkingNeed = lower.includes('parking') || lower.includes('parkplatz')
|
||||
const visibilityNeed: 'HIGH' | undefined = lower.includes('sichtbar') || lower.includes('passanten') ? 'HIGH' : undefined
|
||||
const footfallNeed: 'HIGH' | undefined = lower.includes('frequenz') || lower.includes('laufkundschaft') ? 'HIGH' : undefined
|
||||
|
||||
// Missing fields
|
||||
const missingFields: string[] = []
|
||||
if (!assetType) missingFields.push('Nutzungstyp')
|
||||
if (!areaRange) missingFields.push('Flächenbedarf')
|
||||
if (preferredLocations.length === 0) missingFields.push('Standort')
|
||||
if (!budgetRange) missingFields.push('Budget')
|
||||
if (!timing) missingFields.push('Verfügbarkeitstermin')
|
||||
|
||||
// Assumptions
|
||||
const assumptions: string[] = []
|
||||
if (areaRange && areaSingleMatch && !areaRangeMatch) {
|
||||
assumptions.push(`Flächenrange aus Einzelangabe (${areaSingleMatch[1]} m²) geschätzt — bitte prüfen`)
|
||||
}
|
||||
if (budgetRange && !budgetPerSqmMatch && budgetMaxMatch) {
|
||||
assumptions.push('Budget als Pauschalangabe interpretiert — Angabe pro m² unklar')
|
||||
}
|
||||
if (!assetType) {
|
||||
assumptions.push('Nutzungstyp konnte nicht eindeutig erkannt werden')
|
||||
}
|
||||
|
||||
// Confidence by field
|
||||
const confidenceByField: Record<string, number> = {
|
||||
assetType: assetType ? 0.92 : 0.20,
|
||||
areaRange: areaConfidence,
|
||||
preferredLocations: locationConfidence,
|
||||
budgetRange: budgetConfidence,
|
||||
timing: timingConfidence,
|
||||
mustHaveCriteria: mustHaveCriteria.length > 0 ? 0.85 : 0.10,
|
||||
prestigeImportance: prestigeImportance ? 0.80 : 0.20,
|
||||
parkingNeed: parkingNeed ? 0.90 : 0.30,
|
||||
}
|
||||
|
||||
// Follow-up questions
|
||||
const followUpQuestionCandidates: FollowUpQuestion[] = []
|
||||
|
||||
if (!assetType) {
|
||||
followUpQuestionCandidates.push({
|
||||
id: 'fq-asset-type',
|
||||
questionText: 'Welchen Nutzungstyp suchen Sie?',
|
||||
targetField: 'assetType',
|
||||
reason: 'Der Nutzungstyp konnte nicht eindeutig erkannt werden.',
|
||||
suggestedAnswerOptions: ['Büro', 'Logistik / Lager', 'Retail', 'Produktion', 'Gastro / F&B'],
|
||||
importance: 'required',
|
||||
})
|
||||
}
|
||||
if (preferredLocations.length === 0) {
|
||||
followUpQuestionCandidates.push({
|
||||
id: 'fq-location',
|
||||
questionText: 'In welcher Region oder Stadt suchen Sie?',
|
||||
targetField: 'preferredLocations',
|
||||
reason: 'Kein konkreter Standort angegeben.',
|
||||
suggestedAnswerOptions: ['Zürich', 'Basel', 'Bern', 'Zug', 'Luzern', 'Genf'],
|
||||
importance: 'required',
|
||||
})
|
||||
}
|
||||
if (!timing) {
|
||||
followUpQuestionCandidates.push({
|
||||
id: 'fq-timing',
|
||||
questionText: 'Ab wann benötigen Sie die Fläche?',
|
||||
targetField: 'timing',
|
||||
reason: 'Kein Verfügbarkeitsdatum erkannt.',
|
||||
suggestedAnswerOptions: ['Sofort', 'In 3 Monaten', 'In 6 Monaten', 'In 12 Monaten', 'Flexibel'],
|
||||
importance: 'recommended',
|
||||
})
|
||||
}
|
||||
if (!budgetRange) {
|
||||
followUpQuestionCandidates.push({
|
||||
id: 'fq-budget',
|
||||
questionText: 'Was ist Ihr Maximalbudget pro m² und Jahr?',
|
||||
targetField: 'budgetRange',
|
||||
reason: 'Kein Budget erkannt.',
|
||||
suggestedAnswerOptions: ['< CHF 300/m²/J', 'CHF 300–420/m²/J', 'CHF 420–540/m²/J', '> CHF 540/m²/J', 'Flexibel'],
|
||||
importance: 'recommended',
|
||||
})
|
||||
}
|
||||
followUpQuestionCandidates.push({
|
||||
id: 'fq-parking',
|
||||
questionText: 'Benötigen Sie Parkplätze vor Ort?',
|
||||
targetField: 'parkingNeed',
|
||||
reason: 'Angabe zu Parkplatzbedarf verbessert die Matchqualität.',
|
||||
suggestedAnswerOptions: ['Ja, zwingend', 'Ja, wenn möglich', 'Nein'],
|
||||
importance: 'optional',
|
||||
})
|
||||
|
||||
// Special notes: apartment wish (UC-A type) + lease options (UC-C type)
|
||||
const notesParts: string[] = []
|
||||
const apartmentMatch = input.match(/(\d[.,]\d)\s*zimmer[- ]?wohn/i)
|
||||
?? (lower.includes('zimmer') && lower.includes('wohnung') ? [''] : null)
|
||||
if (apartmentMatch) {
|
||||
const sizeHint = apartmentMatch[1] ? `${apartmentMatch[1]}-Zi-Wohnung` : 'Wohnung'
|
||||
const rentMatch = input.match(/(\d['.\s]?\d{3})[.,\-\s]*(?:inkl|inkl\.)/i)
|
||||
?? input.match(/(\d{4})[.,\-]\s*(?:chf|fr)?/i)
|
||||
const rentHint = rentMatch ? ` max. CHF ${rentMatch[1].replace(/['\s]/g, "'")}` : ''
|
||||
notesParts.push(`Wunsch: ${sizeHint} im Haus oder in der Nähe${rentHint} inkl. NK`)
|
||||
}
|
||||
const leaseOptionMatch = input.match(/(\d+)\s*[*×x]\s*(\d+)\s*jahre?\s*(?:echte\s*)?optione?n?/i)
|
||||
if (leaseOptionMatch) {
|
||||
notesParts.push(`Mietoption: ${leaseOptionMatch[1]}×${leaseOptionMatch[2]} Jahre echte Optionen gefordert`)
|
||||
}
|
||||
const notes = notesParts.length > 0 ? notesParts.join(' | ') : undefined
|
||||
|
||||
// Semantic signal flags used for weight boosting
|
||||
const hasExpansionSignal = lower.includes('wachsen') || lower.includes('wachstum') || lower.includes('expansion') || lower.includes('erweiterung')
|
||||
const hasCommunitySignal = lower.includes('startup') || lower.includes('community') || lower.includes('coworking') || lower.includes('junge firmen')
|
||||
const hasPrestigeSignal = prestigeImportance === 'HIGH' || lower.includes('repräsentativ') || lower.includes('charakter') || lower.includes('beeindrucken')
|
||||
const hasFlexSignal = lower.includes('flexibel') || lower.includes('wachsen') || hasCommunitySignal
|
||||
|
||||
// Suggested weights
|
||||
const suggestedWeights: Record<string, number> = {
|
||||
area: 0.18,
|
||||
location: preferredLocations.length > 0 ? 0.25 : 0.20,
|
||||
budget: budgetRange ? 0.20 : 0.16,
|
||||
timing: timing ? 0.14 : 0.10,
|
||||
prestige: hasPrestigeSignal ? 0.10 : 0.04,
|
||||
accessibility: mustHaveCriteria.includes('Gute ÖV-Anbindung') ? 0.08 : 0.04,
|
||||
expansionPotential: hasExpansionSignal ? 0.08 : 0.03,
|
||||
flexibility: hasFlexSignal ? 0.08 : 0.03,
|
||||
talentAccess: hasCommunitySignal ? 0.06 : 0.03,
|
||||
}
|
||||
|
||||
const rawSummary = `Bedarf analysiert: ${assetType ?? 'Typ unbekannt'} · ${preferredLocations.join(', ') || 'Standort unklar'} · ${areaRange ? `${areaRange.min}–${areaRange.max} m²` : 'Fläche unklar'} · ${budgetRange ? `max. CHF ${budgetRange.maxPerSqm}/m²` : 'Budget unklar'}`
|
||||
|
||||
return {
|
||||
extractedCriteria: {
|
||||
assetType,
|
||||
areaRange,
|
||||
preferredLocations,
|
||||
budgetRange,
|
||||
timing,
|
||||
mustHaveCriteria,
|
||||
infrastructureRequirements: [],
|
||||
accessibilityRequirements: [],
|
||||
prestigeImportance: hasPrestigeSignal ? 'HIGH' : prestigeImportance,
|
||||
flexibilityNeed: hasFlexSignal ? 'HIGH' : 'MEDIUM',
|
||||
expansionPotential: hasExpansionSignal,
|
||||
parkingNeed,
|
||||
visibilityNeed,
|
||||
footfallNeed,
|
||||
requireGroundFloor: requireGroundFloor || undefined,
|
||||
requireAirConditioning: requireAirConditioning || undefined,
|
||||
requireLoadingDock: requireLoadingDock || undefined,
|
||||
requireBarrierFree: requireBarrierFree || undefined,
|
||||
requiredParkingMin,
|
||||
requiredFitOut: fitOutStr,
|
||||
minCeilingHeightM,
|
||||
minContractDurationMonths,
|
||||
notes,
|
||||
},
|
||||
confidenceByField,
|
||||
missingFields,
|
||||
assumptions,
|
||||
suggestedWeights,
|
||||
followUpQuestionCandidates,
|
||||
rawSummary,
|
||||
promptVersion: 'mock-v1.0',
|
||||
schemaVersion: '1.0.0',
|
||||
}
|
||||
}
|
||||
@@ -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=<your-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<string> {
|
||||
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<T>(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<ItemResponse<ParseNeedResult>> {
|
||||
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<ItemResponse<FollowUpQuestion[]>> {
|
||||
const config = getConfig()
|
||||
if (!config) return MockAIService.generateFollowUpQuestions(criteria)
|
||||
// TODO: implement OpenRouter call using followUpQuestionsPrompt
|
||||
return MockAIService.generateFollowUpQuestions(criteria)
|
||||
},
|
||||
|
||||
async summarizeComparison(items: UnifiedMatchResult[]): Promise<ItemResponse<ComparisonSummary>> {
|
||||
const config = getConfig()
|
||||
if (!config) return MockAIService.summarizeComparison(items)
|
||||
|
||||
const properties = items
|
||||
.filter(i => i.resultType !== 'FUTURE_AVAILABILITY')
|
||||
.map(i => ({
|
||||
title: (i as Record<string, unknown> & { property?: { title?: string } }).property?.title ?? 'Unbekannt',
|
||||
matchScore: i.matchScore,
|
||||
city: (i as Record<string, unknown> & { property?: { location?: { city?: string } } }).property?.location?.city ?? '–',
|
||||
rentPerSqm: (i as Record<string, unknown> & { 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<Partial<ComparisonSummary>>(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<ItemResponse<DecisionBrief>> {
|
||||
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<Partial<DecisionBrief>>(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<ItemResponse<{ subject: string; body: string }>> {
|
||||
const config = getConfig()
|
||||
if (!config) return MockAIService.generateOfferEmail(payload)
|
||||
// TODO: implement OpenRouter call
|
||||
return MockAIService.generateOfferEmail(payload)
|
||||
},
|
||||
|
||||
async extractCriteria(input: string): Promise<ItemResponse<CriteriaExtractionResult>> {
|
||||
const config = getConfig()
|
||||
if (!config) return MockAIService.extractCriteria(input)
|
||||
return MockAIService.extractCriteria(input)
|
||||
},
|
||||
|
||||
async generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<ItemResponse<string[]>> {
|
||||
const config = getConfig()
|
||||
if (!config) return MockAIService.generateFollowUp(partialNeed)
|
||||
return MockAIService.generateFollowUp(partialNeed)
|
||||
},
|
||||
}
|
||||
@@ -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}`,
|
||||
}
|
||||
}
|
||||
@@ -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}`,
|
||||
}
|
||||
}
|
||||
@@ -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}`,
|
||||
}
|
||||
}
|
||||
@@ -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}`,
|
||||
}
|
||||
}
|
||||
+19
-754
@@ -1,754 +1,19 @@
|
||||
import type { ItemResponse, ServiceError } from './types'
|
||||
import { ServiceErrorCode } from './types'
|
||||
import type { CreateNeedInput } from '../domain/need'
|
||||
import type { AssetType } from '../domain/enums'
|
||||
import type { ParseNeedResult, ParsedNeedCriteria, FollowUpQuestion } from '../domain/needBuilder'
|
||||
import type { UnifiedMatchResult } from '../domain/unifiedResult'
|
||||
|
||||
// ── Decision Brief ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface DecisionBrief {
|
||||
id: string
|
||||
shortlistId: string
|
||||
summary: string
|
||||
sections: { title: string; body: string }[]
|
||||
generatedAt: string
|
||||
isDraft: true
|
||||
}
|
||||
|
||||
function buildMockDecisionBrief(shortlistId: string): DecisionBrief {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
shortlistId,
|
||||
summary: 'Die Shortlist enthält qualitativ hochwertige Matches mit starker Standortübereinstimmung. Die verfügbaren Flächen decken den Bedarf gut ab. Zwei Objekte eignen sich als Erstbesichtigungen.',
|
||||
sections: [
|
||||
{
|
||||
title: 'Zusammenfassung der Objekte',
|
||||
body: 'Die Shortlist umfasst mehrere Objekte aus dem verifizierten Portfolio. Die Matchscores liegen zwischen 74 und 88, was auf eine gute bis sehr gute Übereinstimmung mit den Suchkriterien hinweist.',
|
||||
},
|
||||
{
|
||||
title: 'Standortbewertung',
|
||||
body: 'Die Mehrheit der Objekte befindet sich in bevorzugten Lagen. Die ÖV-Anbindung ist bei allen Objekten als gut bis sehr gut einzustufen.',
|
||||
},
|
||||
{
|
||||
title: 'Budgetanalyse',
|
||||
body: 'Die Mietpreise liegen im budgetkonformen Bereich. Keine der Optionen überschreitet das maximale Budget pro m².',
|
||||
},
|
||||
{
|
||||
title: 'Empfohlene nächste Schritte',
|
||||
body: '1. Besichtigung der Top-2-Objekte vereinbaren. 2. Detaillierte Flächenpläne anfordern. 3. Vertragskonditionen prüfen lassen.',
|
||||
},
|
||||
],
|
||||
generatedAt: new Date().toISOString(),
|
||||
isDraft: true,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Compare Summary ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface ComparisonSummary {
|
||||
strongestOption: { matchId: string; label: string; reason: string }
|
||||
bestValue: { matchId: string; label: string; reason: string } | null
|
||||
highestConfidence: { matchId: string; label: string; confidenceLevel: number }
|
||||
biggestTradeoffs: string[]
|
||||
missingDataWarnings: string[]
|
||||
recommendedNextStep: string
|
||||
overallAssessment: string
|
||||
perPropertyAssessment: Array<{
|
||||
matchId: string
|
||||
label: string
|
||||
strengths: string[]
|
||||
weaknesses: string[]
|
||||
bestFor: string
|
||||
keyRisk: string | null
|
||||
}>
|
||||
recommendation: string
|
||||
}
|
||||
|
||||
function buildComparisonSummary(items: UnifiedMatchResult[]): ComparisonSummary {
|
||||
const getTitle = (item: UnifiedMatchResult) =>
|
||||
item.resultType !== 'FUTURE_AVAILABILITY'
|
||||
? (item as any).property?.title ?? `Match ${item.matchScore}`
|
||||
: (item as any).signal?.companyName ?? 'Zukunftssignal'
|
||||
|
||||
if (items.length === 0) {
|
||||
return {
|
||||
strongestOption: { matchId: '', label: '–', reason: 'Keine Ergebnisse' },
|
||||
bestValue: null,
|
||||
highestConfidence: { matchId: '', label: '–', confidenceLevel: 0 },
|
||||
biggestTradeoffs: [],
|
||||
missingDataWarnings: [],
|
||||
recommendedNextStep: 'Suchergebnisse überprüfen',
|
||||
overallAssessment: 'Keine Ergebnisse für die Analyse verfügbar.',
|
||||
perPropertyAssessment: [],
|
||||
recommendation: 'Suchergebnisse überprüfen und erneut versuchen.',
|
||||
}
|
||||
}
|
||||
|
||||
const strongest = items.reduce((a, b) => a.matchScore > b.matchScore ? a : b)
|
||||
|
||||
const propertyItems = items.filter(i => i.resultType !== 'FUTURE_AVAILABILITY')
|
||||
const bestValue = propertyItems.length > 0
|
||||
? propertyItems.reduce((a, b) =>
|
||||
((a as any).property?.rentPricePerSqm ?? Infinity) <= ((b as any).property?.rentPricePerSqm ?? Infinity) ? a : b
|
||||
)
|
||||
: null
|
||||
|
||||
const highestConf = items.reduce((a, b) =>
|
||||
a.match.confidenceLevel >= b.match.confidenceLevel ? a : b
|
||||
)
|
||||
|
||||
const tradeoffs = items
|
||||
.flatMap(i => i.match.tradeoffs?.slice(0, 1).map(t => `${getTitle(i)}: ${t.concern}`) ?? [])
|
||||
.slice(0, 3)
|
||||
|
||||
const missingWarnings = items
|
||||
.filter(i => (i.match.missingData?.filter(m => m.importance === 'CRITICAL').length ?? 0) > 0)
|
||||
.map(i => `${getTitle(i)}: fehlende Pflichtfelder`)
|
||||
|
||||
const topNextAction = strongest.match.nextBestActions?.[0]?.label ?? 'Objekt besichtigen oder Details prüfen'
|
||||
|
||||
// Overall assessment
|
||||
const labels = items.map(getTitle)
|
||||
const scoreLeader = getTitle(strongest)
|
||||
const priceLeader = bestValue ? getTitle(bestValue) : null
|
||||
let overallAssessment: string
|
||||
if (items.length === 1) {
|
||||
overallAssessment = `${labels[0]} erfüllt die Kernkriterien mit einem Match Score von ${strongest.matchScore}/100. Eine Vergleichsbasis fehlt — weitere Optionen hinzufügen für eine fundierte Entscheidung.`
|
||||
} else if (priceLeader && priceLeader !== scoreLeader) {
|
||||
overallAssessment = `Beide Optionen erfüllen Standort und Fläche gut. ${scoreLeader} führt beim Match Score und Prestige, ${priceLeader} beim Preis-Leistungs-Verhältnis.`
|
||||
} else {
|
||||
overallAssessment = `${scoreLeader} dominiert in den meisten Kriterien mit dem höchsten Match Score (${strongest.matchScore}/100). Die Optionen unterscheiden sich hauptsächlich in Lage und Ausstattung.`
|
||||
}
|
||||
|
||||
// Per-property assessment
|
||||
const deriveBestFor = (item: UnifiedMatchResult): string => {
|
||||
const assetType = (item as any).property?.assetType ?? ''
|
||||
const score = item.matchScore
|
||||
const hasPosPrestige = item.match.positiveFactors.some(f =>
|
||||
f.criterion.toLowerCase().includes('prestige') || f.criterion.toLowerCase().includes('location')
|
||||
)
|
||||
const hasLowPrice = bestValue?.matchId === item.matchId
|
||||
if (hasPosPrestige && score >= 80) return 'Repräsentationsbedarf mit Prestige-Anforderungen'
|
||||
if (hasLowPrice) return 'Kostenoptimierte Wachstumsphase'
|
||||
if (assetType === 'LOGISTICS' || assetType === 'PRODUCTION') return 'Operative Effizienz und Flächenflexibilität'
|
||||
if (assetType === 'RETAIL') return 'Hohe Kundenfrequenz und Sichtbarkeit'
|
||||
if (score >= 80) return 'Anspruchsvolle Anforderungen mit hoher Matchqualität'
|
||||
return 'Ausgewogenes Preis-Leistungs-Profil'
|
||||
}
|
||||
|
||||
const perPropertyAssessment = items.map(item => {
|
||||
const topStrengths = item.match.positiveFactors
|
||||
.slice(0, 2)
|
||||
.map(f => f.explanation)
|
||||
const topWeaknesses = item.match.negativeFactors.length > 0
|
||||
? item.match.negativeFactors.slice(0, 2).map(f => f.explanation)
|
||||
: ['Keine kritischen Schwächen identifiziert']
|
||||
const keyRisk = item.match.risks?.[0]?.description ?? null
|
||||
return {
|
||||
matchId: item.matchId,
|
||||
label: getTitle(item),
|
||||
strengths: topStrengths,
|
||||
weaknesses: topWeaknesses,
|
||||
bestFor: deriveBestFor(item),
|
||||
keyRisk,
|
||||
}
|
||||
})
|
||||
|
||||
// Recommendation
|
||||
const recommendation = items.length >= 2
|
||||
? `Besichtigung beider Objekte empfohlen — danach Entscheidung auf Basis Mietvertragslaufzeit und Ausbaustandard.`
|
||||
: `Besichtigung von ${getTitle(strongest)} empfohlen — anschliessend Vertragskonditionen und Laufzeit prüfen.`
|
||||
|
||||
return {
|
||||
strongestOption: {
|
||||
matchId: strongest.matchId,
|
||||
label: getTitle(strongest),
|
||||
reason: `Höchster Match Score (${strongest.matchScore}/100)`,
|
||||
},
|
||||
bestValue: bestValue
|
||||
? {
|
||||
matchId: bestValue.matchId,
|
||||
label: getTitle(bestValue),
|
||||
reason: `Niedrigster Mietpreis (CHF ${(bestValue as any).property?.rentPricePerSqm ?? '–'}/m²)`,
|
||||
}
|
||||
: null,
|
||||
highestConfidence: {
|
||||
matchId: highestConf.matchId,
|
||||
label: getTitle(highestConf),
|
||||
confidenceLevel: highestConf.match.confidenceLevel,
|
||||
},
|
||||
biggestTradeoffs: tradeoffs,
|
||||
missingDataWarnings: missingWarnings,
|
||||
recommendedNextStep: topNextAction,
|
||||
overallAssessment,
|
||||
perPropertyAssessment,
|
||||
recommendation,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Legacy types (kept for backward compatibility) ────────────────────────────
|
||||
|
||||
export interface CriteriaExtractionResult {
|
||||
extractedCriteria: Partial<CreateNeedInput>
|
||||
confidence: number
|
||||
missingFields: string[]
|
||||
assumptions: string[]
|
||||
followUpQuestions: string[]
|
||||
}
|
||||
|
||||
export interface AIServiceProvider {
|
||||
extractCriteria(naturalLanguageInput: string): Promise<CriteriaExtractionResult>
|
||||
generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<string[]>
|
||||
}
|
||||
|
||||
// ── Mock parse logic ──────────────────────────────────────────────────────────
|
||||
|
||||
function mockParseNeed(input: string): ParseNeedResult {
|
||||
const lower = input.toLowerCase()
|
||||
|
||||
// Asset type — RETAIL before LOGISTICS to avoid false match on "Nebenräume (Lager)"
|
||||
const assetType: AssetType | undefined =
|
||||
lower.includes('retail') || lower.includes('laden') || lower.includes('shop')
|
||||
|| lower.includes('verkaufslokal') || lower.includes('ladenlokal') || lower.includes('ladenfläche')
|
||||
|| lower.includes('verkaufsfläche') || lower.includes('schaufenster') ? 'RETAIL'
|
||||
: lower.includes('büro') || lower.includes('office') || lower.includes('sitzungszimmer') || lower.includes('arbeitsplätze') ? 'OFFICE'
|
||||
: lower.includes('logistik') || lower.includes('lagerhalle') || lower.includes('lagerraum')
|
||||
|| (lower.includes('lager') && (lower.includes('logistik') || lower.includes('rampe') || lower.includes('palette') || lower.includes('lkw'))) ? 'LOGISTICS'
|
||||
: lower.includes('lager') ? 'LOGISTICS'
|
||||
: lower.includes('produktion') || lower.includes('gewerbe') || lower.includes('industrie') ? 'PRODUCTION'
|
||||
: lower.includes('gastro') || lower.includes('restaurant') ? 'GASTRO'
|
||||
: undefined
|
||||
|
||||
// Area
|
||||
const areaRangeMatch = input.match(/(\d+)\s*[–\-–]\s*(\d+)\s*m[²2]/i)
|
||||
const areaSingleMatch = input.match(/(\d{3,5})\s*m[²2]/i)
|
||||
let areaRange: { min: number; max: number } | undefined
|
||||
let areaConfidence = 0.25
|
||||
if (areaRangeMatch) {
|
||||
areaRange = { min: parseInt(areaRangeMatch[1]), max: parseInt(areaRangeMatch[2]) }
|
||||
areaConfidence = 0.95
|
||||
} else if (areaSingleMatch) {
|
||||
const base = parseInt(areaSingleMatch[1])
|
||||
areaRange = { min: Math.round(base * 0.8), max: Math.round(base * 1.2) }
|
||||
areaConfidence = 0.70
|
||||
}
|
||||
|
||||
// Locations — use word boundaries to avoid false matches like "Umzugsfirma" → "Zug"
|
||||
const CITIES: [string, string][] = [
|
||||
['zürich', 'Zürich'], ['basel', 'Basel'], ['bern', 'Bern'], ['genf', 'Genf'],
|
||||
['lausanne', 'Lausanne'], ['winterthur', 'Winterthur'], ['zug', 'Zug'],
|
||||
['luzern', 'Luzern'], ['st. gallen', 'St. Gallen'], ['lugano', 'Lugano'],
|
||||
['biel', 'Biel'], ['schaffhausen', 'Schaffhausen'],
|
||||
['wädenswil', 'Wädenswil'], ['thalwil', 'Thalwil'], ['uster', 'Uster'],
|
||||
['horgen', 'Horgen'], ['küsnacht', 'Küsnacht'], ['baar', 'Baar'],
|
||||
]
|
||||
const preferredLocations = CITIES.filter(([k]) => {
|
||||
if (k.includes(' ') || k.includes('.')) return lower.includes(k)
|
||||
// Word boundary: not preceded/followed by a letter (including German umlauts)
|
||||
const re = new RegExp(`(?<![a-zA-ZäöüÄÖÜß])${k}(?![a-zA-ZäöüÄÖÜß])`)
|
||||
return re.test(lower)
|
||||
}).map(([, v]) => v)
|
||||
|
||||
// Infer Zürich when Zürich-specific districts or landmarks are mentioned
|
||||
const ZURICH_SIGNALS = [
|
||||
'seefeld', 'bellevue', 'paradeplatz', 'bahnhofstrasse', 'zürich-west', 'zürich west',
|
||||
'oerlikon', 'altstetten', 'kreis 1', 'kreis 2', 'kreis 3', 'kreis 4', 'kreis 5',
|
||||
'kreis 6', 'kreis 7', 'kreis 8', 'langstrasse', 'hardbrücke', 'freilager',
|
||||
'europaallee', 'zürich nord', 'zürich süd',
|
||||
]
|
||||
if (!preferredLocations.includes('Zürich') && ZURICH_SIGNALS.some(s => lower.includes(s))) {
|
||||
preferredLocations.push('Zürich')
|
||||
}
|
||||
|
||||
// Contextual Zürich inference: only truly Zürich-specific place/brand names
|
||||
const ZURICH_ONLY_SIGNALS = [
|
||||
'industrie groove', 'industrie-groove',
|
||||
'pfingstweidstrasse', 'hardbrücke', 'freilager', 'europaallee',
|
||||
'zürich-west', 'zürich west', 'zürich nord', 'zürich süd',
|
||||
'hürlimann', 'viadukt', 'schiffbau',
|
||||
]
|
||||
if (preferredLocations.length === 0 && ZURICH_ONLY_SIGNALS.some(s => lower.includes(s))) {
|
||||
preferredLocations.push('Zürich')
|
||||
}
|
||||
|
||||
// Extract district list from patterns like "Kreis 3, 4, 5, und 8" or "Kreis 3/4/5"
|
||||
const kreisListMatch = lower.match(/\bkreis\s+([\d]+(?:\s*[,\/]\s*[\d]+)*(?:\s+und\s+[\d]+)?)/)
|
||||
if (kreisListMatch) {
|
||||
const nums = kreisListMatch[1].match(/\d+/g) ?? []
|
||||
nums.forEach(n => {
|
||||
const k = `Zürich Kreis ${n}`
|
||||
if (!preferredLocations.includes(k)) preferredLocations.push(k)
|
||||
})
|
||||
if (!preferredLocations.includes('Zürich')) preferredLocations.push('Zürich')
|
||||
}
|
||||
|
||||
// Map Zürich landmark/neighbourhood names → district-level location entries
|
||||
const ZURICH_DISTRICT_MAP: [string, string][] = [
|
||||
['seefeld', 'Zürich Seefeld'], ['bellevue', 'Zürich Seefeld'],
|
||||
['bahnhofstrasse', 'Zürich Kreis 1'], ['paradeplatz', 'Zürich Kreis 1'],
|
||||
['zürich-west', 'Zürich-West'], ['pfingstweidstrasse', 'Zürich-West'],
|
||||
['limmatstrasse', 'Zürich-West'],
|
||||
]
|
||||
ZURICH_DISTRICT_MAP.forEach(([k, v]) => {
|
||||
if (lower.includes(k) && !preferredLocations.includes(v)) preferredLocations.push(v)
|
||||
})
|
||||
|
||||
const locationConfidence = preferredLocations.length > 0 ? 0.88 : 0.15
|
||||
|
||||
// Budget — strip Swiss price notation (320.--) before matching
|
||||
const cleanedBudget = input.replace(/(\d)\.-+/g, '$1').replace(/(\d)\.—/g, '$1')
|
||||
// "MZ 320/m²", "CHF 320/m²", "320/m²", "320.--/m²/a"
|
||||
const budgetPerSqmMatch = cleanedBudget.match(/(?:mz|mietzins|max\.?|budget)?\s*(?:CHF\s*)?(\d{2,4})\s*\/\s*m[²2]/i)
|
||||
// Range "250 – 280 CHF" or "250-280/m²" — require explicit currency or /m² so area ranges like "120–150m2" are not matched
|
||||
const budgetRangeMatch =
|
||||
cleanedBudget.match(/(\d{2,4})\s*[–\-]\s*(\d{2,4})\s*(?:CHF|Fr\.?)\b/i) ??
|
||||
cleanedBudget.match(/(?:CHF|Fr\.?)\s*(\d{2,4})\s*[–\-]\s*(\d{2,4})/i) ??
|
||||
cleanedBudget.match(/(\d{2,4})\s*[–\-]\s*(\d{2,4})\s*\/\s*m[²2]/i)
|
||||
const budgetMaxMatch = input.match(/(?:max\.?|bis|höchstens)\s*(?:CHF\s*)?(\d+)/i)
|
||||
let budgetRange: { maxPerSqm: number; currency: string } | undefined
|
||||
let budgetConfidence = 0.20
|
||||
// Monthly-to-annual threshold: Swiss retail quotes monthly (150/m²/mon → 1800/year),
|
||||
// office/logistics quote monthly too but at lower values (45/m²/mon → 540/year).
|
||||
const monthlyThreshold = assetType === 'RETAIL' ? 500 : 150
|
||||
if (budgetPerSqmMatch) {
|
||||
const raw = parseInt(budgetPerSqmMatch[1])
|
||||
budgetRange = { maxPerSqm: raw < monthlyThreshold ? raw * 12 : raw, currency: 'CHF' }
|
||||
budgetConfidence = 0.92
|
||||
} else if (budgetRangeMatch) {
|
||||
// Take upper value of range as max
|
||||
const raw = parseInt(budgetRangeMatch[2])
|
||||
budgetRange = { maxPerSqm: raw < monthlyThreshold ? raw * 12 : raw, currency: 'CHF' }
|
||||
budgetConfidence = 0.75
|
||||
} else if (budgetMaxMatch) {
|
||||
const raw = parseInt(budgetMaxMatch[1])
|
||||
budgetRange = { maxPerSqm: raw < monthlyThreshold ? raw * 12 : raw, currency: 'CHF' }
|
||||
budgetConfidence = 0.60
|
||||
}
|
||||
|
||||
// Timing
|
||||
const yearMatch = input.match(/(?:ab\s+)?(?:Q[1-4]\s*\/?\s*)?(20\d{2})/)
|
||||
// "Q4/26" or "Q1/27" — two-digit year (collect all occurrences, use first as earliest)
|
||||
const quarterMatches = [...input.matchAll(/Q([1-4])\s*[\/\-]?\s*(\d{2})\b/gi)]
|
||||
const soonMatch = lower.includes('sofort') || lower.includes('asap')
|
||||
let timing: ParsedNeedCriteria['timing'] | undefined
|
||||
let timingConfidence = 0.20
|
||||
if (soonMatch) {
|
||||
timing = { earliestMoveIn: '2025-07-01', latestMoveIn: '2025-10-01', flexibleTiming: false }
|
||||
timingConfidence = 0.85
|
||||
} else if (yearMatch) {
|
||||
timing = { earliestMoveIn: `${yearMatch[1]}-01-01`, latestMoveIn: `${yearMatch[1]}-12-31`, flexibleTiming: lower.includes('flexibel') }
|
||||
timingConfidence = 0.75
|
||||
} else if (quarterMatches.length > 0) {
|
||||
// Use first Q as earliest, last Q as latest (6-month window minimum)
|
||||
const qStartMonth = [0, 1, 4, 7, 10] // [unused, Q1, Q2, Q3, Q4]
|
||||
const first = quarterMatches[0]
|
||||
const last = quarterMatches[quarterMatches.length - 1]
|
||||
const yr1 = 2000 + parseInt(first[2])
|
||||
const yr2 = 2000 + parseInt(last[2])
|
||||
const q1 = parseInt(first[1])
|
||||
const q2 = parseInt(last[1])
|
||||
const startM = String(qStartMonth[q1]).padStart(2, '0')
|
||||
const endM = String(Math.min(12, qStartMonth[q2] + 2)).padStart(2, '0')
|
||||
timing = {
|
||||
earliestMoveIn: `${yr1}-${startM}-01`,
|
||||
latestMoveIn: `${yr2}-${endM}-${endM === '12' ? '31' : '28'}`,
|
||||
flexibleTiming: true,
|
||||
}
|
||||
timingConfidence = 0.70
|
||||
}
|
||||
|
||||
// Must-haves
|
||||
const mustHaveCriteria: string[] = []
|
||||
if (lower.includes('öv') || lower.includes('bahnhof') || lower.includes('tram') || lower.includes('erschlossen')) mustHaveCriteria.push('Gute ÖV-Anbindung')
|
||||
if (lower.includes('parking') || lower.includes('parkplatz') || lower.includes('tiefgarage') || lower.includes('stellplatz')) mustHaveCriteria.push('Parkplätze vorhanden')
|
||||
if (lower.includes('klimaanlage') || lower.includes('klima')) mustHaveCriteria.push('Klimaanlage')
|
||||
if (lower.includes('server') || lower.includes('rechenzentr')) mustHaveCriteria.push('Serverraum / IT-Infrastruktur')
|
||||
if (lower.includes('barrierefrei')) mustHaveCriteria.push('Barrierefreiheit')
|
||||
if (lower.includes('küche') || lower.includes('kantine')) mustHaveCriteria.push('Kantine / Küche')
|
||||
if (lower.includes('laderampe') || lower.includes('rampe') || lower.includes('verladetor')) mustHaveCriteria.push('Laderampe')
|
||||
if (lower.includes('schaufenster') || lower.includes('shopfenster') || lower.includes('vitrine')) mustHaveCriteria.push('Schaufenster')
|
||||
if (lower.includes('verpflegung') || lower.includes('restaurant') || lower.includes('lunch') || lower.includes('mittagessen') || lower.includes('takeaway')) mustHaveCriteria.push('Verpflegungsmöglichkeiten')
|
||||
if (lower.includes('startup') || lower.includes('start-up') || lower.includes('community') || lower.includes('coworking') || lower.includes('junge firmen') || lower.includes('jungunternehm')) mustHaveCriteria.push('Startup-Community / innovative Nachbarn')
|
||||
if (lower.includes('wachsen') || lower.includes('expansion') || lower.includes('erweiterungsoption') || lower.includes('wachstum') || lower.includes('wachstumsoption') || lower.includes('flexibilität zum wachsen')) mustHaveCriteria.push('Erweiterungsoption vorhanden')
|
||||
if (lower.includes('werkstatt') || lower.includes('atelier') || lower.includes('reparatur')) mustHaveCriteria.push('Werkstatt / Atelier')
|
||||
|
||||
// Structured requirement fields
|
||||
const requireGroundFloor = lower.includes('erdgeschoss') || lower.includes('parterre')
|
||||
|| lower.includes('schaufenster') || lower.includes('ladenlokal') || undefined as boolean | undefined
|
||||
const requireAirConditioning = lower.includes('klimaanlage') || lower.includes('klimatisierung')
|
||||
|| lower.includes('klima') || lower.includes('air conditioning')
|
||||
|| lower.includes('kühlung') || undefined as boolean | undefined
|
||||
const requireLoadingDock = lower.includes('laderampe') || lower.includes('verladerampe')
|
||||
|| lower.includes('ladetor') || lower.includes('rampe') || undefined as boolean | undefined
|
||||
const requireBarrierFree = lower.includes('barrierefrei') || lower.includes('rollstuhl')
|
||||
|| lower.includes('iv-gerecht') || lower.includes('behindertengerecht') || undefined as boolean | undefined
|
||||
|
||||
const ceilingMatch = input.match(/(\d+(?:[.,]\d+)?)\s*m(?:eter)?\s*(?:deckenhöhe|hallenhöhe|lichte\s+höhe)/i)
|
||||
?? input.match(/deckenhöhe\s+(?:mind\.?\s*)?(\d+(?:[.,]\d+)?)\s*m/i)
|
||||
const minCeilingHeightM = ceilingMatch ? parseFloat(ceilingMatch[1].replace(',', '.')) : undefined
|
||||
|
||||
// Parking: "3-5 Parkplätze" → extract lower bound (minimum); "5 Parkplätze" → 5
|
||||
const parkingRangeMatch = input.match(/(\d+)\s*[-–]\s*\d+\s*(?:parkplätze?|stellplätze?|pp\b)/i)
|
||||
const parkingSingleMatch = input.match(/(?:mind(?:estens)?\.?\s+)?(\d+)\s*(?:parkplätze?|stellplätze?|pp\b)/i)
|
||||
const requiredParkingMin = parkingRangeMatch ? parseInt(parkingRangeMatch[1])
|
||||
: parkingSingleMatch ? parseInt(parkingSingleMatch[1]) : undefined
|
||||
|
||||
const fitOutStr: 'BASIC' | 'FULL' | 'PREMIUM' | undefined =
|
||||
lower.includes('schlüsselfertig') || lower.includes('premium') || lower.includes('hochwertig')
|
||||
|| lower.includes('top ausgebaut') || lower.includes('top-ausgebaut') ? 'PREMIUM'
|
||||
: lower.includes('vollausbau') || lower.includes('vollständig ausgebaut') || lower.includes('ausgebaut')
|
||||
|| lower.includes('ready to use') || lower.includes('reddy to use') || lower.includes('bezugsfertig') ? 'FULL'
|
||||
: lower.includes('basisausbau') || lower.includes('rohbau') || lower.includes('einfach') ? 'BASIC'
|
||||
: undefined
|
||||
|
||||
// Contract duration: "7-jähriger Vertrag", "Laufzeit 7 Jahre", standalone "7 Jahre" at sentence start
|
||||
// Exclude "in X Jahren", "X Jahre im Geschäft", "X Jahre Erfahrung" etc.
|
||||
const contractMatch = input.match(/(\d+)[- ]?j[aä]hrige?(?:r)?\s+(?:vertrag|mietvertrag|laufzeit)/i)
|
||||
?? input.match(/(?:vertrag|laufzeit|mietdauer).{0,20}(\d+)\s*jahre?/i)
|
||||
?? input.match(/\b(\d+)\s*jahre?\b(?!\s*(?:erfahrung|planung|rendite|im\b|alt\b|alten|altes|jung))/i)
|
||||
const minContractDurationMonths = contractMatch ? parseInt(contractMatch[1]) * 12 : undefined
|
||||
|
||||
// Soft
|
||||
const prestigeImportance: 'LOW' | 'MEDIUM' | 'HIGH' | undefined =
|
||||
lower.includes('prestige') || lower.includes('representativ') || lower.includes('repräsentativ')
|
||||
|| lower.includes('topadresse') || lower.includes('top adresse') || lower.includes('innerstädtisch')
|
||||
|| lower.includes('topaddresse') ? 'HIGH'
|
||||
: lower.includes('standard') ? 'LOW'
|
||||
: undefined
|
||||
const parkingNeed = lower.includes('parking') || lower.includes('parkplatz')
|
||||
const visibilityNeed: 'HIGH' | undefined = lower.includes('sichtbar') || lower.includes('passanten') ? 'HIGH' : undefined
|
||||
const footfallNeed: 'HIGH' | undefined = lower.includes('frequenz') || lower.includes('laufkundschaft') ? 'HIGH' : undefined
|
||||
|
||||
// Missing fields
|
||||
const missingFields: string[] = []
|
||||
if (!assetType) missingFields.push('Nutzungstyp')
|
||||
if (!areaRange) missingFields.push('Flächenbedarf')
|
||||
if (preferredLocations.length === 0) missingFields.push('Standort')
|
||||
if (!budgetRange) missingFields.push('Budget')
|
||||
if (!timing) missingFields.push('Verfügbarkeitstermin')
|
||||
|
||||
// Assumptions
|
||||
const assumptions: string[] = []
|
||||
if (areaRange && areaSingleMatch && !areaRangeMatch) {
|
||||
assumptions.push(`Flächenrange aus Einzelangabe (${areaSingleMatch[1]} m²) geschätzt — bitte prüfen`)
|
||||
}
|
||||
if (budgetRange && !budgetPerSqmMatch && budgetMaxMatch) {
|
||||
assumptions.push('Budget als Pauschalangabe interpretiert — Angabe pro m² unklar')
|
||||
}
|
||||
if (!assetType) {
|
||||
assumptions.push('Nutzungstyp konnte nicht eindeutig erkannt werden')
|
||||
}
|
||||
|
||||
// Confidence by field
|
||||
const confidenceByField: Record<string, number> = {
|
||||
assetType: assetType ? 0.92 : 0.20,
|
||||
areaRange: areaConfidence,
|
||||
preferredLocations: locationConfidence,
|
||||
budgetRange: budgetConfidence,
|
||||
timing: timingConfidence,
|
||||
mustHaveCriteria: mustHaveCriteria.length > 0 ? 0.85 : 0.10,
|
||||
prestigeImportance: prestigeImportance ? 0.80 : 0.20,
|
||||
parkingNeed: parkingNeed ? 0.90 : 0.30,
|
||||
}
|
||||
|
||||
// Follow-up questions
|
||||
const followUpQuestionCandidates: FollowUpQuestion[] = []
|
||||
|
||||
if (!assetType) {
|
||||
followUpQuestionCandidates.push({
|
||||
id: 'fq-asset-type',
|
||||
questionText: 'Welchen Nutzungstyp suchen Sie?',
|
||||
targetField: 'assetType',
|
||||
reason: 'Der Nutzungstyp konnte nicht eindeutig erkannt werden.',
|
||||
suggestedAnswerOptions: ['Büro', 'Logistik / Lager', 'Retail', 'Produktion', 'Gastro / F&B'],
|
||||
importance: 'required',
|
||||
})
|
||||
}
|
||||
if (preferredLocations.length === 0) {
|
||||
followUpQuestionCandidates.push({
|
||||
id: 'fq-location',
|
||||
questionText: 'In welcher Region oder Stadt suchen Sie?',
|
||||
targetField: 'preferredLocations',
|
||||
reason: 'Kein konkreter Standort angegeben.',
|
||||
suggestedAnswerOptions: ['Zürich', 'Basel', 'Bern', 'Zug', 'Luzern', 'Genf'],
|
||||
importance: 'required',
|
||||
})
|
||||
}
|
||||
if (!timing) {
|
||||
followUpQuestionCandidates.push({
|
||||
id: 'fq-timing',
|
||||
questionText: 'Ab wann benötigen Sie die Fläche?',
|
||||
targetField: 'timing',
|
||||
reason: 'Kein Verfügbarkeitsdatum erkannt.',
|
||||
suggestedAnswerOptions: ['Sofort', 'In 3 Monaten', 'In 6 Monaten', 'In 12 Monaten', 'Flexibel'],
|
||||
importance: 'recommended',
|
||||
})
|
||||
}
|
||||
if (!budgetRange) {
|
||||
followUpQuestionCandidates.push({
|
||||
id: 'fq-budget',
|
||||
questionText: 'Was ist Ihr Maximalbudget pro m² und Jahr?',
|
||||
targetField: 'budgetRange',
|
||||
reason: 'Kein Budget erkannt.',
|
||||
suggestedAnswerOptions: ['< CHF 300/m²/J', 'CHF 300–420/m²/J', 'CHF 420–540/m²/J', '> CHF 540/m²/J', 'Flexibel'],
|
||||
importance: 'recommended',
|
||||
})
|
||||
}
|
||||
followUpQuestionCandidates.push({
|
||||
id: 'fq-parking',
|
||||
questionText: 'Benötigen Sie Parkplätze vor Ort?',
|
||||
targetField: 'parkingNeed',
|
||||
reason: 'Angabe zu Parkplatzbedarf verbessert die Matchqualität.',
|
||||
suggestedAnswerOptions: ['Ja, zwingend', 'Ja, wenn möglich', 'Nein'],
|
||||
importance: 'optional',
|
||||
})
|
||||
|
||||
// Special notes: apartment wish (UC-A type) + lease options (UC-C type)
|
||||
const notesParts: string[] = []
|
||||
const apartmentMatch = input.match(/(\d[.,]\d)\s*zimmer[- ]?wohn/i)
|
||||
?? (lower.includes('zimmer') && lower.includes('wohnung') ? [''] : null)
|
||||
if (apartmentMatch) {
|
||||
const sizeHint = apartmentMatch[1] ? `${apartmentMatch[1]}-Zi-Wohnung` : 'Wohnung'
|
||||
const rentMatch = input.match(/(\d['.\s]?\d{3})[.,\-\s]*(?:inkl|inkl\.)/i)
|
||||
?? input.match(/(\d{4})[.,\-]\s*(?:chf|fr)?/i)
|
||||
const rentHint = rentMatch ? ` max. CHF ${rentMatch[1].replace(/['\s]/g, "'")}` : ''
|
||||
notesParts.push(`Wunsch: ${sizeHint} im Haus oder in der Nähe${rentHint} inkl. NK`)
|
||||
}
|
||||
const leaseOptionMatch = input.match(/(\d+)\s*[*×x]\s*(\d+)\s*jahre?\s*(?:echte\s*)?optione?n?/i)
|
||||
if (leaseOptionMatch) {
|
||||
notesParts.push(`Mietoption: ${leaseOptionMatch[1]}×${leaseOptionMatch[2]} Jahre echte Optionen gefordert`)
|
||||
}
|
||||
const notes = notesParts.length > 0 ? notesParts.join(' | ') : undefined
|
||||
|
||||
// Semantic signal flags used for weight boosting
|
||||
const hasExpansionSignal = lower.includes('wachsen') || lower.includes('wachstum') || lower.includes('expansion') || lower.includes('erweiterung')
|
||||
const hasCommunitySignal = lower.includes('startup') || lower.includes('community') || lower.includes('coworking') || lower.includes('junge firmen')
|
||||
const hasPrestigeSignal = prestigeImportance === 'HIGH' || lower.includes('repräsentativ') || lower.includes('charakter') || lower.includes('beeindrucken')
|
||||
const hasFlexSignal = lower.includes('flexibel') || lower.includes('wachsen') || hasCommunitySignal
|
||||
|
||||
// Suggested weights
|
||||
const suggestedWeights: Record<string, number> = {
|
||||
area: 0.18,
|
||||
location: preferredLocations.length > 0 ? 0.25 : 0.20,
|
||||
budget: budgetRange ? 0.20 : 0.16,
|
||||
timing: timing ? 0.14 : 0.10,
|
||||
prestige: hasPrestigeSignal ? 0.10 : 0.04,
|
||||
accessibility: mustHaveCriteria.includes('Gute ÖV-Anbindung') ? 0.08 : 0.04,
|
||||
expansionPotential: hasExpansionSignal ? 0.08 : 0.03,
|
||||
flexibility: hasFlexSignal ? 0.08 : 0.03,
|
||||
talentAccess: hasCommunitySignal ? 0.06 : 0.03,
|
||||
}
|
||||
|
||||
const rawSummary = `Bedarf analysiert: ${assetType ?? 'Typ unbekannt'} · ${preferredLocations.join(', ') || 'Standort unklar'} · ${areaRange ? `${areaRange.min}–${areaRange.max} m²` : 'Fläche unklar'} · ${budgetRange ? `max. CHF ${budgetRange.maxPerSqm}/m²` : 'Budget unklar'}`
|
||||
|
||||
return {
|
||||
extractedCriteria: {
|
||||
assetType,
|
||||
areaRange,
|
||||
preferredLocations,
|
||||
budgetRange,
|
||||
timing,
|
||||
mustHaveCriteria,
|
||||
infrastructureRequirements: [],
|
||||
accessibilityRequirements: [],
|
||||
prestigeImportance: hasPrestigeSignal ? 'HIGH' : prestigeImportance,
|
||||
flexibilityNeed: hasFlexSignal ? 'HIGH' : 'MEDIUM',
|
||||
expansionPotential: hasExpansionSignal,
|
||||
parkingNeed,
|
||||
visibilityNeed,
|
||||
footfallNeed,
|
||||
requireGroundFloor: requireGroundFloor || undefined,
|
||||
requireAirConditioning: requireAirConditioning || undefined,
|
||||
requireLoadingDock: requireLoadingDock || undefined,
|
||||
requireBarrierFree: requireBarrierFree || undefined,
|
||||
requiredParkingMin,
|
||||
requiredFitOut: fitOutStr,
|
||||
minCeilingHeightM,
|
||||
minContractDurationMonths,
|
||||
notes,
|
||||
},
|
||||
confidenceByField,
|
||||
missingFields,
|
||||
assumptions,
|
||||
suggestedWeights,
|
||||
followUpQuestionCandidates,
|
||||
rawSummary,
|
||||
promptVersion: 'mock-v1.0',
|
||||
schemaVersion: '1.0.0',
|
||||
}
|
||||
}
|
||||
|
||||
// ── Legacy mock provider (kept for backward compat) ───────────────────────────
|
||||
|
||||
const MockupAIServiceProvider: AIServiceProvider = {
|
||||
async extractCriteria(_input: string): Promise<CriteriaExtractionResult> {
|
||||
return {
|
||||
extractedCriteria: {
|
||||
companyName: 'Unbekannt (bitte bestätigen)',
|
||||
requiredArea: { min: 400, max: 900 },
|
||||
budgetRange: { maxPerSqm: 40, currency: 'CHF' },
|
||||
},
|
||||
confidence: 0.72,
|
||||
missingFields: ['assetType', 'timing', 'preferredLocations'],
|
||||
assumptions: ['Fläche aus Zahlenangabe geschätzt', 'Budget aus Kostennennung abgeleitet'],
|
||||
followUpQuestions: [
|
||||
'Welchen Nutzungstyp suchen Sie (Büro, Retail, Logistik)?',
|
||||
'In welchen Städten oder Regionen suchen Sie?',
|
||||
'Wann möchten Sie spätestens einziehen?',
|
||||
],
|
||||
}
|
||||
},
|
||||
async generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<string[]> {
|
||||
const questions: string[] = []
|
||||
if (!partialNeed.assetType) questions.push('Welchen Nutzungstyp suchen Sie?')
|
||||
if (!partialNeed.preferredLocations?.length) questions.push('In welchen Regionen suchen Sie?')
|
||||
if (!partialNeed.timing) questions.push('Was ist Ihr gewünschter Einzugstermin?')
|
||||
if (!partialNeed.budgetRange) questions.push('Was ist Ihr maximales monatliches Budget?')
|
||||
return questions
|
||||
},
|
||||
}
|
||||
|
||||
const provider = MockupAIServiceProvider
|
||||
|
||||
const notConfiguredError = (): ServiceError => ({
|
||||
code: ServiceErrorCode.AI_GENERATION_FAILED,
|
||||
message: 'OpenRouter nicht konfiguriert',
|
||||
})
|
||||
|
||||
export const openRouterAIService: AIServiceProvider = {
|
||||
async extractCriteria(_input: string): Promise<CriteriaExtractionResult> {
|
||||
throw notConfiguredError()
|
||||
},
|
||||
async generateFollowUp(_partialNeed: Partial<CreateNeedInput>): Promise<string[]> {
|
||||
throw notConfiguredError()
|
||||
},
|
||||
}
|
||||
|
||||
export const aiService = {
|
||||
// Legacy methods
|
||||
async extractCriteria(input: string): Promise<ItemResponse<CriteriaExtractionResult>> {
|
||||
const data = await provider.extractCriteria(input)
|
||||
return { data }
|
||||
},
|
||||
async generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<ItemResponse<string[]>> {
|
||||
const data = await provider.generateFollowUp(partialNeed)
|
||||
return { data }
|
||||
},
|
||||
|
||||
// F014: Compare summary
|
||||
async summarizeComparison(items: UnifiedMatchResult[]): Promise<ItemResponse<ComparisonSummary>> {
|
||||
await new Promise(r => setTimeout(r, 600))
|
||||
return { data: buildComparisonSummary(items) }
|
||||
},
|
||||
|
||||
// F008 methods
|
||||
async parseNeed(input: string): Promise<ItemResponse<ParseNeedResult>> {
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
const data = mockParseNeed(input)
|
||||
return { data }
|
||||
},
|
||||
async generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise<ItemResponse<FollowUpQuestion[]>> {
|
||||
await new Promise(r => setTimeout(r, 600))
|
||||
const result = mockParseNeed(JSON.stringify(criteria))
|
||||
return { data: result.followUpQuestionCandidates }
|
||||
},
|
||||
|
||||
async generateDecisionBrief(shortlistId: string): Promise<ItemResponse<DecisionBrief>> {
|
||||
await new Promise(r => setTimeout(r, 1800))
|
||||
return { data: buildMockDecisionBrief(shortlistId) }
|
||||
},
|
||||
|
||||
async generateOfferEmail(payload: {
|
||||
needTitle: string
|
||||
properties: string[]
|
||||
matchScores: number[]
|
||||
}): Promise<{ data: { subject: string; body: string }; error: null }> {
|
||||
await new Promise(r => setTimeout(r, 1200))
|
||||
return {
|
||||
data: {
|
||||
subject: `Passende Gewerbeflächen zu Ihrer Anfrage: ${payload.needTitle}`,
|
||||
body:
|
||||
`Sehr geehrte Damen und Herren,\n\nvielen Dank für Ihr Interesse. Gerne unterbreiten wir Ihnen folgende passende Gewerbeobjekte aus unserem Portfolio:\n\n` +
|
||||
payload.properties.map((p, i) => `• ${p} (Match-Score: ${payload.matchScores[i]}%)`).join('\n') +
|
||||
`\n\nGerne arrangieren wir Besichtigungstermine für die genannten Objekte und stehen für alle weiteren Fragen zur Verfügung.\n\nFreundliche Grüsse\nWincasa AG`,
|
||||
},
|
||||
error: null,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// ── Listing Parser (Supply Side) ──────────────────────────────────────────────
|
||||
|
||||
export interface ParsedListingData {
|
||||
assetType?: string
|
||||
areaSqm?: number
|
||||
rentPerSqm?: number
|
||||
city?: string
|
||||
softLevels?: Record<string, string>
|
||||
parking?: number
|
||||
fitOut?: string
|
||||
}
|
||||
|
||||
export async function parseListingText(text: string): Promise<ParsedListingData> {
|
||||
await new Promise(r => setTimeout(r, 900))
|
||||
const t = text.toLowerCase()
|
||||
const result: ParsedListingData = {}
|
||||
|
||||
// Asset type
|
||||
if (t.includes('laden') || t.includes('retail') || t.includes('shop') || t.includes('geschäft')) {
|
||||
result.assetType = 'RETAIL'
|
||||
} else if (t.includes('lager') || t.includes('logistik')) {
|
||||
result.assetType = 'LOGISTICS'
|
||||
} else if (t.includes('produktion') || t.includes('industrie') || t.includes('werkstatt')) {
|
||||
result.assetType = 'PRODUCTION'
|
||||
} else {
|
||||
result.assetType = 'OFFICE'
|
||||
}
|
||||
|
||||
// Area
|
||||
const areaMatch = text.match(/(\d{2,5})\s*m²/) ?? text.match(/(\d{2,5})\s*Quadratmeter/)
|
||||
if (areaMatch) result.areaSqm = parseInt(areaMatch[1])
|
||||
|
||||
// Price — if raw < 500 assume monthly → convert to annual
|
||||
const priceMatch = text.match(/(\d{2,4})\s*(?:CHF|Fr\.?)\s*\/\s*m²/) ?? text.match(/CHF\s*(\d{2,4})/)
|
||||
if (priceMatch) {
|
||||
const raw = parseInt(priceMatch[1])
|
||||
result.rentPerSqm = raw < 500 ? raw * 12 : raw
|
||||
}
|
||||
|
||||
// City
|
||||
const CITIES = ['Zürich', 'Bern', 'Basel', 'Genf', 'Lausanne', 'Zug', 'Winterthur', 'St. Gallen', 'Lugano', 'Luzern', 'Biel', 'Thun', 'Kloten', 'Opfikon', 'Uster']
|
||||
for (const city of CITIES) {
|
||||
if (t.includes(city.toLowerCase())) { result.city = city; break }
|
||||
}
|
||||
|
||||
// Soft factors
|
||||
const soft: Record<string, string> = {}
|
||||
soft.prestige = (t.includes('zentrum') || t.includes('innenstadt') || t.includes('hauptbahnhof') || t.includes('repräsentativ') || t.includes('prestige'))
|
||||
? 'HIGH' : (t.includes('gewerbegebiet') || t.includes('peripherie') || t.includes('industriezone'))
|
||||
? 'LOW' : 'MEDIUM'
|
||||
soft.accessibility = (t.includes('bahnhof') || t.includes('s-bahn') || t.includes('tram') || t.includes('öv') || t.includes('zentrum'))
|
||||
? 'HIGH' : (t.includes('autobahn') || t.includes('gewerbegebiet'))
|
||||
? 'LOW' : 'MEDIUM'
|
||||
if (result.assetType === 'RETAIL') {
|
||||
soft.visibility = t.includes('fussgänger') || t.includes('passanten') || t.includes('frequenz') ? 'HIGH' : 'MEDIUM'
|
||||
soft.footfall = soft.visibility
|
||||
}
|
||||
if (t.includes('nachhaltig') || t.includes('minergie') || t.includes('esg') || t.includes('zertifiziert')) soft.esg = 'HIGH'
|
||||
if (t.includes('expansion') || t.includes('erweiter') || t.includes('wachstum')) soft.expansionPotential = 'HIGH'
|
||||
if (result.city === 'Zug') soft.taxEnvironment = 'HIGH'
|
||||
if (t.includes('hochwertig') || t.includes('premium') || t.includes('erstklassig')) {
|
||||
soft.prestige = 'HIGH'
|
||||
result.fitOut = 'PREMIUM'
|
||||
} else if (t.includes('einfach') || t.includes('standard-ausbau')) {
|
||||
result.fitOut = 'BASIC'
|
||||
}
|
||||
result.softLevels = soft
|
||||
|
||||
// Parking
|
||||
const parkingMatch = text.match(/(\d+)\s*Parkplätze?/) ?? text.match(/(\d+)\s*PP/)
|
||||
if (parkingMatch) result.parking = parseInt(parkingMatch[1])
|
||||
|
||||
return result
|
||||
}
|
||||
/**
|
||||
* Backward-compatible barrel re-export.
|
||||
*
|
||||
* All existing import paths (e.g. `import { aiService } from '../../services/aiService'`)
|
||||
* continue to work without any change in the call sites.
|
||||
*
|
||||
* To add new AI features: extend IAIService and implement in MockAIService / OpenRouterAIService.
|
||||
*/
|
||||
export { aiService, MockAIService, OpenRouterAIService } from './ai'
|
||||
export type { IAIService } from './ai'
|
||||
export type {
|
||||
DecisionBrief,
|
||||
ComparisonSummary,
|
||||
CriteriaExtractionResult,
|
||||
AIServiceProvider,
|
||||
ParsedListingData,
|
||||
OfferEmailPayload,
|
||||
} from './ai/IAIService'
|
||||
export { parseListingText } from './ai/mock/listingParser'
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Error normalisation utilities for all services.
|
||||
*
|
||||
* Convention for new services:
|
||||
*
|
||||
* async myMethod(): Promise<ListResponse<Foo>> {
|
||||
* 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 <ErrorState message={error.message} />.
|
||||
*/
|
||||
|
||||
// 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))
|
||||
}
|
||||
@@ -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<ListResponse<FutureSignal>> {
|
||||
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<ItemResponse<FutureSignal | null>> {
|
||||
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<ListResponse<FutureSignal>> {
|
||||
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<ItemResponse<FutureSignal>> {
|
||||
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<ItemResponse<FutureSignal>> {
|
||||
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<ListResponse<FutureSignal>> {
|
||||
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<FutureSignalSummary> {
|
||||
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)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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<T> = { data: T; error: null } | { data: null; error: string }
|
||||
|
||||
export const inquiryService = {
|
||||
async getActiveInquiries(filters?: InquiryFilters): Promise<ServiceResult<Inquiry[]>> {
|
||||
async getActiveInquiries(filters?: InquiryFilters): Promise<ListResponse<Inquiry>> {
|
||||
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<ServiceResult<Inquiry | null>> {
|
||||
async getInquiryById(id: string): Promise<ItemResponse<Inquiry | null>> {
|
||||
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<ServiceResult<Inquiry>> {
|
||||
): Promise<ItemResponse<Inquiry>> {
|
||||
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<ServiceResult<Inquiry>> {
|
||||
async markThreadAsRead(id: string): Promise<ItemResponse<Inquiry>> {
|
||||
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<ServiceResult<number>> {
|
||||
async getUnreadCount(): Promise<ItemResponse<number>> {
|
||||
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)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
+157
-108
@@ -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<ListResponse<Match>> {
|
||||
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<ItemResponse<Match | null>> {
|
||||
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<ListResponse<Match>> {
|
||||
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<ListResponse<Match>> {
|
||||
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<ItemResponse<Match>> {
|
||||
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<ListResponse<Match>> {
|
||||
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<ItemResponse<Match | null>> {
|
||||
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<ItemResponse<ScoreBreakdown | null>> {
|
||||
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<ListResponse<Match>> {
|
||||
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<PropertyNeedMatch[]> {
|
||||
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<AdditionalPropertyMatch[]> {
|
||||
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<StrongMatchItem[]> {
|
||||
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)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
+31
-10
@@ -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<ListResponse<Need>> {
|
||||
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<ItemResponse<Need | null>> {
|
||||
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<ItemResponse<Need>> {
|
||||
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<ItemResponse<Need>> {
|
||||
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<ItemResponse<void>> {
|
||||
await provider.remove(id)
|
||||
return { data: undefined }
|
||||
try {
|
||||
await provider.remove(id)
|
||||
return { data: undefined }
|
||||
} catch (err) {
|
||||
throwServiceError(err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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<ListResponse<Property>> {
|
||||
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<ItemResponse<Property | null>> {
|
||||
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<ItemResponse<Property>> {
|
||||
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<ItemResponse<Property>> {
|
||||
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<ItemResponse<void>> {
|
||||
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<ListResponse<Property>> {
|
||||
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)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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<ListResponse<Shortlist>> {
|
||||
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<ItemResponse<Shortlist | null>> {
|
||||
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<ItemResponse<Shortlist>> {
|
||||
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<ItemResponse<Shortlist>> {
|
||||
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<ItemResponse<Shortlist>> {
|
||||
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<ItemResponse<Shortlist>> {
|
||||
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<ItemResponse<void>> {
|
||||
await provider.remove(id)
|
||||
return { data: undefined }
|
||||
try {
|
||||
await provider.remove(id)
|
||||
return { data: undefined }
|
||||
} catch (err) {
|
||||
throwServiceError(err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user