feat: unified error handling + AI service modularisation

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-24 00:32:01 +02:00
parent efc72b720e
commit 6515acb7f0
23 changed files with 1468 additions and 955 deletions
+86
View File
@@ -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[]>>
}
+20
View File
@@ -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 }
+88
View File
@@ -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 }
},
}
+124
View File
@@ -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,
}
}
+29
View File
@@ -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,
}
}
+64
View File
@@ -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
}
+377
View File
@@ -0,0 +1,377 @@
import type { ParseNeedResult, ParsedNeedCriteria, FollowUpQuestion } from '../../../domain/needBuilder'
import type { AssetType } from '../../../domain/enums'
export function mockParseNeed(input: string): ParseNeedResult {
const lower = input.toLowerCase()
// Asset type — RETAIL before LOGISTICS to avoid false match on "Nebenräume (Lager)"
const assetType: AssetType | undefined =
lower.includes('retail') || lower.includes('laden') || lower.includes('shop')
|| lower.includes('verkaufslokal') || lower.includes('ladenlokal') || lower.includes('ladenfläche')
|| lower.includes('verkaufsfläche') || lower.includes('schaufenster') ? 'RETAIL'
: lower.includes('büro') || lower.includes('office') || lower.includes('sitzungszimmer') || lower.includes('arbeitsplätze') ? 'OFFICE'
: lower.includes('logistik') || lower.includes('lagerhalle') || lower.includes('lagerraum')
|| (lower.includes('lager') && (lower.includes('logistik') || lower.includes('rampe') || lower.includes('palette') || lower.includes('lkw'))) ? 'LOGISTICS'
: lower.includes('lager') ? 'LOGISTICS'
: lower.includes('produktion') || lower.includes('gewerbe') || lower.includes('industrie') ? 'PRODUCTION'
: lower.includes('gastro') || lower.includes('restaurant') ? 'GASTRO'
: undefined
// Area
const areaRangeMatch = input.match(/(\d+)\s*[\-]\s*(\d+)\s*m[²2]/i)
const areaSingleMatch = input.match(/(\d{3,5})\s*m[²2]/i)
let areaRange: { min: number; max: number } | undefined
let areaConfidence = 0.25
if (areaRangeMatch) {
areaRange = { min: parseInt(areaRangeMatch[1]), max: parseInt(areaRangeMatch[2]) }
areaConfidence = 0.95
} else if (areaSingleMatch) {
const base = parseInt(areaSingleMatch[1])
areaRange = { min: Math.round(base * 0.8), max: Math.round(base * 1.2) }
areaConfidence = 0.70
}
// Locations — use word boundaries to avoid false matches like "Umzugsfirma" → "Zug"
const CITIES: [string, string][] = [
['zürich', 'Zürich'], ['basel', 'Basel'], ['bern', 'Bern'], ['genf', 'Genf'],
['lausanne', 'Lausanne'], ['winterthur', 'Winterthur'], ['zug', 'Zug'],
['luzern', 'Luzern'], ['st. gallen', 'St. Gallen'], ['lugano', 'Lugano'],
['biel', 'Biel'], ['schaffhausen', 'Schaffhausen'],
['wädenswil', 'Wädenswil'], ['thalwil', 'Thalwil'], ['uster', 'Uster'],
['horgen', 'Horgen'], ['küsnacht', 'Küsnacht'], ['baar', 'Baar'],
]
const preferredLocations = CITIES.filter(([k]) => {
if (k.includes(' ') || k.includes('.')) return lower.includes(k)
// Word boundary: not preceded/followed by a letter (including German umlauts)
const re = new RegExp(`(?<![a-zA-ZäöüÄÖÜß])${k}(?![a-zA-ZäöüÄÖÜß])`)
return re.test(lower)
}).map(([, v]) => v)
// Infer Zürich when Zürich-specific districts or landmarks are mentioned
const ZURICH_SIGNALS = [
'seefeld', 'bellevue', 'paradeplatz', 'bahnhofstrasse', 'zürich-west', 'zürich west',
'oerlikon', 'altstetten', 'kreis 1', 'kreis 2', 'kreis 3', 'kreis 4', 'kreis 5',
'kreis 6', 'kreis 7', 'kreis 8', 'langstrasse', 'hardbrücke', 'freilager',
'europaallee', 'zürich nord', 'zürich süd',
]
if (!preferredLocations.includes('Zürich') && ZURICH_SIGNALS.some(s => lower.includes(s))) {
preferredLocations.push('Zürich')
}
// Contextual Zürich inference: only truly Zürich-specific place/brand names
const ZURICH_ONLY_SIGNALS = [
'industrie groove', 'industrie-groove',
'pfingstweidstrasse', 'hardbrücke', 'freilager', 'europaallee',
'zürich-west', 'zürich west', 'zürich nord', 'zürich süd',
'hürlimann', 'viadukt', 'schiffbau',
]
if (preferredLocations.length === 0 && ZURICH_ONLY_SIGNALS.some(s => lower.includes(s))) {
preferredLocations.push('Zürich')
}
// Extract district list from patterns like "Kreis 3, 4, 5, und 8" or "Kreis 3/4/5"
const kreisListMatch = lower.match(/\bkreis\s+([\d]+(?:\s*[,\/]\s*[\d]+)*(?:\s+und\s+[\d]+)?)/)
if (kreisListMatch) {
const nums = kreisListMatch[1].match(/\d+/g) ?? []
nums.forEach(n => {
const k = `Zürich Kreis ${n}`
if (!preferredLocations.includes(k)) preferredLocations.push(k)
})
if (!preferredLocations.includes('Zürich')) preferredLocations.push('Zürich')
}
// Map Zürich landmark/neighbourhood names → district-level location entries
const ZURICH_DISTRICT_MAP: [string, string][] = [
['seefeld', 'Zürich Seefeld'], ['bellevue', 'Zürich Seefeld'],
['bahnhofstrasse', 'Zürich Kreis 1'], ['paradeplatz', 'Zürich Kreis 1'],
['zürich-west', 'Zürich-West'], ['pfingstweidstrasse', 'Zürich-West'],
['limmatstrasse', 'Zürich-West'],
]
ZURICH_DISTRICT_MAP.forEach(([k, v]) => {
if (lower.includes(k) && !preferredLocations.includes(v)) preferredLocations.push(v)
})
const locationConfidence = preferredLocations.length > 0 ? 0.88 : 0.15
// Budget — strip Swiss price notation (320.--) before matching
const cleanedBudget = input.replace(/(\d)\.-+/g, '$1').replace(/(\d)\.—/g, '$1')
// "MZ 320/m²", "CHF 320/m²", "320/m²", "320.--/m²/a"
const budgetPerSqmMatch = cleanedBudget.match(/(?:mz|mietzins|max\.?|budget)?\s*(?:CHF\s*)?(\d{2,4})\s*\/\s*m[²2]/i)
// Range "250 280 CHF" or "250-280/m²" — require explicit currency or /m² so area ranges like "120150m2" are not matched
const budgetRangeMatch =
cleanedBudget.match(/(\d{2,4})\s*[\-]\s*(\d{2,4})\s*(?:CHF|Fr\.?)\b/i) ??
cleanedBudget.match(/(?:CHF|Fr\.?)\s*(\d{2,4})\s*[\-]\s*(\d{2,4})/i) ??
cleanedBudget.match(/(\d{2,4})\s*[\-]\s*(\d{2,4})\s*\/\s*m[²2]/i)
const budgetMaxMatch = input.match(/(?:max\.?|bis|höchstens)\s*(?:CHF\s*)?(\d+)/i)
let budgetRange: { maxPerSqm: number; currency: string } | undefined
let budgetConfidence = 0.20
// Monthly-to-annual threshold: Swiss retail quotes monthly (150/m²/mon → 1800/year),
// office/logistics quote monthly too but at lower values (45/m²/mon → 540/year).
const monthlyThreshold = assetType === 'RETAIL' ? 500 : 150
if (budgetPerSqmMatch) {
const raw = parseInt(budgetPerSqmMatch[1])
budgetRange = { maxPerSqm: raw < monthlyThreshold ? raw * 12 : raw, currency: 'CHF' }
budgetConfidence = 0.92
} else if (budgetRangeMatch) {
// Take upper value of range as max
const raw = parseInt(budgetRangeMatch[2])
budgetRange = { maxPerSqm: raw < monthlyThreshold ? raw * 12 : raw, currency: 'CHF' }
budgetConfidence = 0.75
} else if (budgetMaxMatch) {
const raw = parseInt(budgetMaxMatch[1])
budgetRange = { maxPerSqm: raw < monthlyThreshold ? raw * 12 : raw, currency: 'CHF' }
budgetConfidence = 0.60
}
// Timing
const yearMatch = input.match(/(?:ab\s+)?(?:Q[1-4]\s*\/?\s*)?(20\d{2})/)
// "Q4/26" or "Q1/27" — two-digit year (collect all occurrences, use first as earliest)
const quarterMatches = [...input.matchAll(/Q([1-4])\s*[\/\-]?\s*(\d{2})\b/gi)]
const soonMatch = lower.includes('sofort') || lower.includes('asap')
let timing: ParsedNeedCriteria['timing'] | undefined
let timingConfidence = 0.20
if (soonMatch) {
timing = { earliestMoveIn: '2025-07-01', latestMoveIn: '2025-10-01', flexibleTiming: false }
timingConfidence = 0.85
} else if (yearMatch) {
timing = { earliestMoveIn: `${yearMatch[1]}-01-01`, latestMoveIn: `${yearMatch[1]}-12-31`, flexibleTiming: lower.includes('flexibel') }
timingConfidence = 0.75
} else if (quarterMatches.length > 0) {
// Use first Q as earliest, last Q as latest (6-month window minimum)
const qStartMonth = [0, 1, 4, 7, 10] // [unused, Q1, Q2, Q3, Q4]
const first = quarterMatches[0]
const last = quarterMatches[quarterMatches.length - 1]
const yr1 = 2000 + parseInt(first[2])
const yr2 = 2000 + parseInt(last[2])
const q1 = parseInt(first[1])
const q2 = parseInt(last[1])
const startM = String(qStartMonth[q1]).padStart(2, '0')
const endM = String(Math.min(12, qStartMonth[q2] + 2)).padStart(2, '0')
timing = {
earliestMoveIn: `${yr1}-${startM}-01`,
latestMoveIn: `${yr2}-${endM}-${endM === '12' ? '31' : '28'}`,
flexibleTiming: true,
}
timingConfidence = 0.70
}
// Must-haves
const mustHaveCriteria: string[] = []
if (lower.includes('öv') || lower.includes('bahnhof') || lower.includes('tram') || lower.includes('erschlossen')) mustHaveCriteria.push('Gute ÖV-Anbindung')
if (lower.includes('parking') || lower.includes('parkplatz') || lower.includes('tiefgarage') || lower.includes('stellplatz')) mustHaveCriteria.push('Parkplätze vorhanden')
if (lower.includes('klimaanlage') || lower.includes('klima')) mustHaveCriteria.push('Klimaanlage')
if (lower.includes('server') || lower.includes('rechenzentr')) mustHaveCriteria.push('Serverraum / IT-Infrastruktur')
if (lower.includes('barrierefrei')) mustHaveCriteria.push('Barrierefreiheit')
if (lower.includes('küche') || lower.includes('kantine')) mustHaveCriteria.push('Kantine / Küche')
if (lower.includes('laderampe') || lower.includes('rampe') || lower.includes('verladetor')) mustHaveCriteria.push('Laderampe')
if (lower.includes('schaufenster') || lower.includes('shopfenster') || lower.includes('vitrine')) mustHaveCriteria.push('Schaufenster')
if (lower.includes('verpflegung') || lower.includes('restaurant') || lower.includes('lunch') || lower.includes('mittagessen') || lower.includes('takeaway')) mustHaveCriteria.push('Verpflegungsmöglichkeiten')
if (lower.includes('startup') || lower.includes('start-up') || lower.includes('community') || lower.includes('coworking') || lower.includes('junge firmen') || lower.includes('jungunternehm')) mustHaveCriteria.push('Startup-Community / innovative Nachbarn')
if (lower.includes('wachsen') || lower.includes('expansion') || lower.includes('erweiterungsoption') || lower.includes('wachstum') || lower.includes('wachstumsoption') || lower.includes('flexibilität zum wachsen')) mustHaveCriteria.push('Erweiterungsoption vorhanden')
if (lower.includes('werkstatt') || lower.includes('atelier') || lower.includes('reparatur')) mustHaveCriteria.push('Werkstatt / Atelier')
// Structured requirement fields
const requireGroundFloor = lower.includes('erdgeschoss') || lower.includes('parterre')
|| lower.includes('schaufenster') || lower.includes('ladenlokal') || undefined as boolean | undefined
const requireAirConditioning = lower.includes('klimaanlage') || lower.includes('klimatisierung')
|| lower.includes('klima') || lower.includes('air conditioning')
|| lower.includes('kühlung') || undefined as boolean | undefined
const requireLoadingDock = lower.includes('laderampe') || lower.includes('verladerampe')
|| lower.includes('ladetor') || lower.includes('rampe') || undefined as boolean | undefined
const requireBarrierFree = lower.includes('barrierefrei') || lower.includes('rollstuhl')
|| lower.includes('iv-gerecht') || lower.includes('behindertengerecht') || undefined as boolean | undefined
const ceilingMatch = input.match(/(\d+(?:[.,]\d+)?)\s*m(?:eter)?\s*(?:deckenhöhe|hallenhöhe|lichte\s+höhe)/i)
?? input.match(/deckenhöhe\s+(?:mind\.?\s*)?(\d+(?:[.,]\d+)?)\s*m/i)
const minCeilingHeightM = ceilingMatch ? parseFloat(ceilingMatch[1].replace(',', '.')) : undefined
// Parking: "3-5 Parkplätze" → extract lower bound (minimum); "5 Parkplätze" → 5
const parkingRangeMatch = input.match(/(\d+)\s*[-]\s*\d+\s*(?:parkplätze?|stellplätze?|pp\b)/i)
const parkingSingleMatch = input.match(/(?:mind(?:estens)?\.?\s+)?(\d+)\s*(?:parkplätze?|stellplätze?|pp\b)/i)
const requiredParkingMin = parkingRangeMatch ? parseInt(parkingRangeMatch[1])
: parkingSingleMatch ? parseInt(parkingSingleMatch[1]) : undefined
const fitOutStr: 'BASIC' | 'FULL' | 'PREMIUM' | undefined =
lower.includes('schlüsselfertig') || lower.includes('premium') || lower.includes('hochwertig')
|| lower.includes('top ausgebaut') || lower.includes('top-ausgebaut') ? 'PREMIUM'
: lower.includes('vollausbau') || lower.includes('vollständig ausgebaut') || lower.includes('ausgebaut')
|| lower.includes('ready to use') || lower.includes('reddy to use') || lower.includes('bezugsfertig') ? 'FULL'
: lower.includes('basisausbau') || lower.includes('rohbau') || lower.includes('einfach') ? 'BASIC'
: undefined
// Contract duration: "7-jähriger Vertrag", "Laufzeit 7 Jahre", standalone "7 Jahre" at sentence start
// Exclude "in X Jahren", "X Jahre im Geschäft", "X Jahre Erfahrung" etc.
const contractMatch = input.match(/(\d+)[- ]?j[aä]hrige?(?:r)?\s+(?:vertrag|mietvertrag|laufzeit)/i)
?? input.match(/(?:vertrag|laufzeit|mietdauer).{0,20}(\d+)\s*jahre?/i)
?? input.match(/\b(\d+)\s*jahre?\b(?!\s*(?:erfahrung|planung|rendite|im\b|alt\b|alten|altes|jung))/i)
const minContractDurationMonths = contractMatch ? parseInt(contractMatch[1]) * 12 : undefined
// Soft
const prestigeImportance: 'LOW' | 'MEDIUM' | 'HIGH' | undefined =
lower.includes('prestige') || lower.includes('representativ') || lower.includes('repräsentativ')
|| lower.includes('topadresse') || lower.includes('top adresse') || lower.includes('innerstädtisch')
|| lower.includes('topaddresse') ? 'HIGH'
: lower.includes('standard') ? 'LOW'
: undefined
const parkingNeed = lower.includes('parking') || lower.includes('parkplatz')
const visibilityNeed: 'HIGH' | undefined = lower.includes('sichtbar') || lower.includes('passanten') ? 'HIGH' : undefined
const footfallNeed: 'HIGH' | undefined = lower.includes('frequenz') || lower.includes('laufkundschaft') ? 'HIGH' : undefined
// Missing fields
const missingFields: string[] = []
if (!assetType) missingFields.push('Nutzungstyp')
if (!areaRange) missingFields.push('Flächenbedarf')
if (preferredLocations.length === 0) missingFields.push('Standort')
if (!budgetRange) missingFields.push('Budget')
if (!timing) missingFields.push('Verfügbarkeitstermin')
// Assumptions
const assumptions: string[] = []
if (areaRange && areaSingleMatch && !areaRangeMatch) {
assumptions.push(`Flächenrange aus Einzelangabe (${areaSingleMatch[1]} m²) geschätzt — bitte prüfen`)
}
if (budgetRange && !budgetPerSqmMatch && budgetMaxMatch) {
assumptions.push('Budget als Pauschalangabe interpretiert — Angabe pro m² unklar')
}
if (!assetType) {
assumptions.push('Nutzungstyp konnte nicht eindeutig erkannt werden')
}
// Confidence by field
const confidenceByField: Record<string, number> = {
assetType: assetType ? 0.92 : 0.20,
areaRange: areaConfidence,
preferredLocations: locationConfidence,
budgetRange: budgetConfidence,
timing: timingConfidence,
mustHaveCriteria: mustHaveCriteria.length > 0 ? 0.85 : 0.10,
prestigeImportance: prestigeImportance ? 0.80 : 0.20,
parkingNeed: parkingNeed ? 0.90 : 0.30,
}
// Follow-up questions
const followUpQuestionCandidates: FollowUpQuestion[] = []
if (!assetType) {
followUpQuestionCandidates.push({
id: 'fq-asset-type',
questionText: 'Welchen Nutzungstyp suchen Sie?',
targetField: 'assetType',
reason: 'Der Nutzungstyp konnte nicht eindeutig erkannt werden.',
suggestedAnswerOptions: ['Büro', 'Logistik / Lager', 'Retail', 'Produktion', 'Gastro / F&B'],
importance: 'required',
})
}
if (preferredLocations.length === 0) {
followUpQuestionCandidates.push({
id: 'fq-location',
questionText: 'In welcher Region oder Stadt suchen Sie?',
targetField: 'preferredLocations',
reason: 'Kein konkreter Standort angegeben.',
suggestedAnswerOptions: ['Zürich', 'Basel', 'Bern', 'Zug', 'Luzern', 'Genf'],
importance: 'required',
})
}
if (!timing) {
followUpQuestionCandidates.push({
id: 'fq-timing',
questionText: 'Ab wann benötigen Sie die Fläche?',
targetField: 'timing',
reason: 'Kein Verfügbarkeitsdatum erkannt.',
suggestedAnswerOptions: ['Sofort', 'In 3 Monaten', 'In 6 Monaten', 'In 12 Monaten', 'Flexibel'],
importance: 'recommended',
})
}
if (!budgetRange) {
followUpQuestionCandidates.push({
id: 'fq-budget',
questionText: 'Was ist Ihr Maximalbudget pro m² und Jahr?',
targetField: 'budgetRange',
reason: 'Kein Budget erkannt.',
suggestedAnswerOptions: ['< CHF 300/m²/J', 'CHF 300420/m²/J', 'CHF 420540/m²/J', '> CHF 540/m²/J', 'Flexibel'],
importance: 'recommended',
})
}
followUpQuestionCandidates.push({
id: 'fq-parking',
questionText: 'Benötigen Sie Parkplätze vor Ort?',
targetField: 'parkingNeed',
reason: 'Angabe zu Parkplatzbedarf verbessert die Matchqualität.',
suggestedAnswerOptions: ['Ja, zwingend', 'Ja, wenn möglich', 'Nein'],
importance: 'optional',
})
// Special notes: apartment wish (UC-A type) + lease options (UC-C type)
const notesParts: string[] = []
const apartmentMatch = input.match(/(\d[.,]\d)\s*zimmer[- ]?wohn/i)
?? (lower.includes('zimmer') && lower.includes('wohnung') ? [''] : null)
if (apartmentMatch) {
const sizeHint = apartmentMatch[1] ? `${apartmentMatch[1]}-Zi-Wohnung` : 'Wohnung'
const rentMatch = input.match(/(\d['.\s]?\d{3})[.,\-\s]*(?:inkl|inkl\.)/i)
?? input.match(/(\d{4})[.,\-]\s*(?:chf|fr)?/i)
const rentHint = rentMatch ? ` max. CHF ${rentMatch[1].replace(/['\s]/g, "'")}` : ''
notesParts.push(`Wunsch: ${sizeHint} im Haus oder in der Nähe${rentHint} inkl. NK`)
}
const leaseOptionMatch = input.match(/(\d+)\s*[*×x]\s*(\d+)\s*jahre?\s*(?:echte\s*)?optione?n?/i)
if (leaseOptionMatch) {
notesParts.push(`Mietoption: ${leaseOptionMatch[1]}×${leaseOptionMatch[2]} Jahre echte Optionen gefordert`)
}
const notes = notesParts.length > 0 ? notesParts.join(' | ') : undefined
// Semantic signal flags used for weight boosting
const hasExpansionSignal = lower.includes('wachsen') || lower.includes('wachstum') || lower.includes('expansion') || lower.includes('erweiterung')
const hasCommunitySignal = lower.includes('startup') || lower.includes('community') || lower.includes('coworking') || lower.includes('junge firmen')
const hasPrestigeSignal = prestigeImportance === 'HIGH' || lower.includes('repräsentativ') || lower.includes('charakter') || lower.includes('beeindrucken')
const hasFlexSignal = lower.includes('flexibel') || lower.includes('wachsen') || hasCommunitySignal
// Suggested weights
const suggestedWeights: Record<string, number> = {
area: 0.18,
location: preferredLocations.length > 0 ? 0.25 : 0.20,
budget: budgetRange ? 0.20 : 0.16,
timing: timing ? 0.14 : 0.10,
prestige: hasPrestigeSignal ? 0.10 : 0.04,
accessibility: mustHaveCriteria.includes('Gute ÖV-Anbindung') ? 0.08 : 0.04,
expansionPotential: hasExpansionSignal ? 0.08 : 0.03,
flexibility: hasFlexSignal ? 0.08 : 0.03,
talentAccess: hasCommunitySignal ? 0.06 : 0.03,
}
const rawSummary = `Bedarf analysiert: ${assetType ?? 'Typ unbekannt'} · ${preferredLocations.join(', ') || 'Standort unklar'} · ${areaRange ? `${areaRange.min}${areaRange.max}` : 'Fläche unklar'} · ${budgetRange ? `max. CHF ${budgetRange.maxPerSqm}/m²` : 'Budget unklar'}`
return {
extractedCriteria: {
assetType,
areaRange,
preferredLocations,
budgetRange,
timing,
mustHaveCriteria,
infrastructureRequirements: [],
accessibilityRequirements: [],
prestigeImportance: hasPrestigeSignal ? 'HIGH' : prestigeImportance,
flexibilityNeed: hasFlexSignal ? 'HIGH' : 'MEDIUM',
expansionPotential: hasExpansionSignal,
parkingNeed,
visibilityNeed,
footfallNeed,
requireGroundFloor: requireGroundFloor || undefined,
requireAirConditioning: requireAirConditioning || undefined,
requireLoadingDock: requireLoadingDock || undefined,
requireBarrierFree: requireBarrierFree || undefined,
requiredParkingMin,
requiredFitOut: fitOutStr,
minCeilingHeightM,
minContractDurationMonths,
notes,
},
confidenceByField,
missingFields,
assumptions,
suggestedWeights,
followUpQuestionCandidates,
rawSummary,
promptVersion: 'mock-v1.0',
schemaVersion: '1.0.0',
}
}
@@ -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
View File
@@ -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 "120150m2" are not matched
const budgetRangeMatch =
cleanedBudget.match(/(\d{2,4})\s*[\-]\s*(\d{2,4})\s*(?:CHF|Fr\.?)\b/i) ??
cleanedBudget.match(/(?:CHF|Fr\.?)\s*(\d{2,4})\s*[\-]\s*(\d{2,4})/i) ??
cleanedBudget.match(/(\d{2,4})\s*[\-]\s*(\d{2,4})\s*\/\s*m[²2]/i)
const budgetMaxMatch = input.match(/(?:max\.?|bis|höchstens)\s*(?:CHF\s*)?(\d+)/i)
let budgetRange: { maxPerSqm: number; currency: string } | undefined
let budgetConfidence = 0.20
// Monthly-to-annual threshold: Swiss retail quotes monthly (150/m²/mon → 1800/year),
// office/logistics quote monthly too but at lower values (45/m²/mon → 540/year).
const monthlyThreshold = assetType === 'RETAIL' ? 500 : 150
if (budgetPerSqmMatch) {
const raw = parseInt(budgetPerSqmMatch[1])
budgetRange = { maxPerSqm: raw < monthlyThreshold ? raw * 12 : raw, currency: 'CHF' }
budgetConfidence = 0.92
} else if (budgetRangeMatch) {
// Take upper value of range as max
const raw = parseInt(budgetRangeMatch[2])
budgetRange = { maxPerSqm: raw < monthlyThreshold ? raw * 12 : raw, currency: 'CHF' }
budgetConfidence = 0.75
} else if (budgetMaxMatch) {
const raw = parseInt(budgetMaxMatch[1])
budgetRange = { maxPerSqm: raw < monthlyThreshold ? raw * 12 : raw, currency: 'CHF' }
budgetConfidence = 0.60
}
// Timing
const yearMatch = input.match(/(?:ab\s+)?(?:Q[1-4]\s*\/?\s*)?(20\d{2})/)
// "Q4/26" or "Q1/27" — two-digit year (collect all occurrences, use first as earliest)
const quarterMatches = [...input.matchAll(/Q([1-4])\s*[\/\-]?\s*(\d{2})\b/gi)]
const soonMatch = lower.includes('sofort') || lower.includes('asap')
let timing: ParsedNeedCriteria['timing'] | undefined
let timingConfidence = 0.20
if (soonMatch) {
timing = { earliestMoveIn: '2025-07-01', latestMoveIn: '2025-10-01', flexibleTiming: false }
timingConfidence = 0.85
} else if (yearMatch) {
timing = { earliestMoveIn: `${yearMatch[1]}-01-01`, latestMoveIn: `${yearMatch[1]}-12-31`, flexibleTiming: lower.includes('flexibel') }
timingConfidence = 0.75
} else if (quarterMatches.length > 0) {
// Use first Q as earliest, last Q as latest (6-month window minimum)
const qStartMonth = [0, 1, 4, 7, 10] // [unused, Q1, Q2, Q3, Q4]
const first = quarterMatches[0]
const last = quarterMatches[quarterMatches.length - 1]
const yr1 = 2000 + parseInt(first[2])
const yr2 = 2000 + parseInt(last[2])
const q1 = parseInt(first[1])
const q2 = parseInt(last[1])
const startM = String(qStartMonth[q1]).padStart(2, '0')
const endM = String(Math.min(12, qStartMonth[q2] + 2)).padStart(2, '0')
timing = {
earliestMoveIn: `${yr1}-${startM}-01`,
latestMoveIn: `${yr2}-${endM}-${endM === '12' ? '31' : '28'}`,
flexibleTiming: true,
}
timingConfidence = 0.70
}
// Must-haves
const mustHaveCriteria: string[] = []
if (lower.includes('öv') || lower.includes('bahnhof') || lower.includes('tram') || lower.includes('erschlossen')) mustHaveCriteria.push('Gute ÖV-Anbindung')
if (lower.includes('parking') || lower.includes('parkplatz') || lower.includes('tiefgarage') || lower.includes('stellplatz')) mustHaveCriteria.push('Parkplätze vorhanden')
if (lower.includes('klimaanlage') || lower.includes('klima')) mustHaveCriteria.push('Klimaanlage')
if (lower.includes('server') || lower.includes('rechenzentr')) mustHaveCriteria.push('Serverraum / IT-Infrastruktur')
if (lower.includes('barrierefrei')) mustHaveCriteria.push('Barrierefreiheit')
if (lower.includes('küche') || lower.includes('kantine')) mustHaveCriteria.push('Kantine / Küche')
if (lower.includes('laderampe') || lower.includes('rampe') || lower.includes('verladetor')) mustHaveCriteria.push('Laderampe')
if (lower.includes('schaufenster') || lower.includes('shopfenster') || lower.includes('vitrine')) mustHaveCriteria.push('Schaufenster')
if (lower.includes('verpflegung') || lower.includes('restaurant') || lower.includes('lunch') || lower.includes('mittagessen') || lower.includes('takeaway')) mustHaveCriteria.push('Verpflegungsmöglichkeiten')
if (lower.includes('startup') || lower.includes('start-up') || lower.includes('community') || lower.includes('coworking') || lower.includes('junge firmen') || lower.includes('jungunternehm')) mustHaveCriteria.push('Startup-Community / innovative Nachbarn')
if (lower.includes('wachsen') || lower.includes('expansion') || lower.includes('erweiterungsoption') || lower.includes('wachstum') || lower.includes('wachstumsoption') || lower.includes('flexibilität zum wachsen')) mustHaveCriteria.push('Erweiterungsoption vorhanden')
if (lower.includes('werkstatt') || lower.includes('atelier') || lower.includes('reparatur')) mustHaveCriteria.push('Werkstatt / Atelier')
// Structured requirement fields
const requireGroundFloor = lower.includes('erdgeschoss') || lower.includes('parterre')
|| lower.includes('schaufenster') || lower.includes('ladenlokal') || undefined as boolean | undefined
const requireAirConditioning = lower.includes('klimaanlage') || lower.includes('klimatisierung')
|| lower.includes('klima') || lower.includes('air conditioning')
|| lower.includes('kühlung') || undefined as boolean | undefined
const requireLoadingDock = lower.includes('laderampe') || lower.includes('verladerampe')
|| lower.includes('ladetor') || lower.includes('rampe') || undefined as boolean | undefined
const requireBarrierFree = lower.includes('barrierefrei') || lower.includes('rollstuhl')
|| lower.includes('iv-gerecht') || lower.includes('behindertengerecht') || undefined as boolean | undefined
const ceilingMatch = input.match(/(\d+(?:[.,]\d+)?)\s*m(?:eter)?\s*(?:deckenhöhe|hallenhöhe|lichte\s+höhe)/i)
?? input.match(/deckenhöhe\s+(?:mind\.?\s*)?(\d+(?:[.,]\d+)?)\s*m/i)
const minCeilingHeightM = ceilingMatch ? parseFloat(ceilingMatch[1].replace(',', '.')) : undefined
// Parking: "3-5 Parkplätze" → extract lower bound (minimum); "5 Parkplätze" → 5
const parkingRangeMatch = input.match(/(\d+)\s*[-]\s*\d+\s*(?:parkplätze?|stellplätze?|pp\b)/i)
const parkingSingleMatch = input.match(/(?:mind(?:estens)?\.?\s+)?(\d+)\s*(?:parkplätze?|stellplätze?|pp\b)/i)
const requiredParkingMin = parkingRangeMatch ? parseInt(parkingRangeMatch[1])
: parkingSingleMatch ? parseInt(parkingSingleMatch[1]) : undefined
const fitOutStr: 'BASIC' | 'FULL' | 'PREMIUM' | undefined =
lower.includes('schlüsselfertig') || lower.includes('premium') || lower.includes('hochwertig')
|| lower.includes('top ausgebaut') || lower.includes('top-ausgebaut') ? 'PREMIUM'
: lower.includes('vollausbau') || lower.includes('vollständig ausgebaut') || lower.includes('ausgebaut')
|| lower.includes('ready to use') || lower.includes('reddy to use') || lower.includes('bezugsfertig') ? 'FULL'
: lower.includes('basisausbau') || lower.includes('rohbau') || lower.includes('einfach') ? 'BASIC'
: undefined
// Contract duration: "7-jähriger Vertrag", "Laufzeit 7 Jahre", standalone "7 Jahre" at sentence start
// Exclude "in X Jahren", "X Jahre im Geschäft", "X Jahre Erfahrung" etc.
const contractMatch = input.match(/(\d+)[- ]?j[aä]hrige?(?:r)?\s+(?:vertrag|mietvertrag|laufzeit)/i)
?? input.match(/(?:vertrag|laufzeit|mietdauer).{0,20}(\d+)\s*jahre?/i)
?? input.match(/\b(\d+)\s*jahre?\b(?!\s*(?:erfahrung|planung|rendite|im\b|alt\b|alten|altes|jung))/i)
const minContractDurationMonths = contractMatch ? parseInt(contractMatch[1]) * 12 : undefined
// Soft
const prestigeImportance: 'LOW' | 'MEDIUM' | 'HIGH' | undefined =
lower.includes('prestige') || lower.includes('representativ') || lower.includes('repräsentativ')
|| lower.includes('topadresse') || lower.includes('top adresse') || lower.includes('innerstädtisch')
|| lower.includes('topaddresse') ? 'HIGH'
: lower.includes('standard') ? 'LOW'
: undefined
const parkingNeed = lower.includes('parking') || lower.includes('parkplatz')
const visibilityNeed: 'HIGH' | undefined = lower.includes('sichtbar') || lower.includes('passanten') ? 'HIGH' : undefined
const footfallNeed: 'HIGH' | undefined = lower.includes('frequenz') || lower.includes('laufkundschaft') ? 'HIGH' : undefined
// Missing fields
const missingFields: string[] = []
if (!assetType) missingFields.push('Nutzungstyp')
if (!areaRange) missingFields.push('Flächenbedarf')
if (preferredLocations.length === 0) missingFields.push('Standort')
if (!budgetRange) missingFields.push('Budget')
if (!timing) missingFields.push('Verfügbarkeitstermin')
// Assumptions
const assumptions: string[] = []
if (areaRange && areaSingleMatch && !areaRangeMatch) {
assumptions.push(`Flächenrange aus Einzelangabe (${areaSingleMatch[1]} m²) geschätzt — bitte prüfen`)
}
if (budgetRange && !budgetPerSqmMatch && budgetMaxMatch) {
assumptions.push('Budget als Pauschalangabe interpretiert — Angabe pro m² unklar')
}
if (!assetType) {
assumptions.push('Nutzungstyp konnte nicht eindeutig erkannt werden')
}
// Confidence by field
const confidenceByField: Record<string, number> = {
assetType: assetType ? 0.92 : 0.20,
areaRange: areaConfidence,
preferredLocations: locationConfidence,
budgetRange: budgetConfidence,
timing: timingConfidence,
mustHaveCriteria: mustHaveCriteria.length > 0 ? 0.85 : 0.10,
prestigeImportance: prestigeImportance ? 0.80 : 0.20,
parkingNeed: parkingNeed ? 0.90 : 0.30,
}
// Follow-up questions
const followUpQuestionCandidates: FollowUpQuestion[] = []
if (!assetType) {
followUpQuestionCandidates.push({
id: 'fq-asset-type',
questionText: 'Welchen Nutzungstyp suchen Sie?',
targetField: 'assetType',
reason: 'Der Nutzungstyp konnte nicht eindeutig erkannt werden.',
suggestedAnswerOptions: ['Büro', 'Logistik / Lager', 'Retail', 'Produktion', 'Gastro / F&B'],
importance: 'required',
})
}
if (preferredLocations.length === 0) {
followUpQuestionCandidates.push({
id: 'fq-location',
questionText: 'In welcher Region oder Stadt suchen Sie?',
targetField: 'preferredLocations',
reason: 'Kein konkreter Standort angegeben.',
suggestedAnswerOptions: ['Zürich', 'Basel', 'Bern', 'Zug', 'Luzern', 'Genf'],
importance: 'required',
})
}
if (!timing) {
followUpQuestionCandidates.push({
id: 'fq-timing',
questionText: 'Ab wann benötigen Sie die Fläche?',
targetField: 'timing',
reason: 'Kein Verfügbarkeitsdatum erkannt.',
suggestedAnswerOptions: ['Sofort', 'In 3 Monaten', 'In 6 Monaten', 'In 12 Monaten', 'Flexibel'],
importance: 'recommended',
})
}
if (!budgetRange) {
followUpQuestionCandidates.push({
id: 'fq-budget',
questionText: 'Was ist Ihr Maximalbudget pro m² und Jahr?',
targetField: 'budgetRange',
reason: 'Kein Budget erkannt.',
suggestedAnswerOptions: ['< CHF 300/m²/J', 'CHF 300420/m²/J', 'CHF 420540/m²/J', '> CHF 540/m²/J', 'Flexibel'],
importance: 'recommended',
})
}
followUpQuestionCandidates.push({
id: 'fq-parking',
questionText: 'Benötigen Sie Parkplätze vor Ort?',
targetField: 'parkingNeed',
reason: 'Angabe zu Parkplatzbedarf verbessert die Matchqualität.',
suggestedAnswerOptions: ['Ja, zwingend', 'Ja, wenn möglich', 'Nein'],
importance: 'optional',
})
// Special notes: apartment wish (UC-A type) + lease options (UC-C type)
const notesParts: string[] = []
const apartmentMatch = input.match(/(\d[.,]\d)\s*zimmer[- ]?wohn/i)
?? (lower.includes('zimmer') && lower.includes('wohnung') ? [''] : null)
if (apartmentMatch) {
const sizeHint = apartmentMatch[1] ? `${apartmentMatch[1]}-Zi-Wohnung` : 'Wohnung'
const rentMatch = input.match(/(\d['.\s]?\d{3})[.,\-\s]*(?:inkl|inkl\.)/i)
?? input.match(/(\d{4})[.,\-]\s*(?:chf|fr)?/i)
const rentHint = rentMatch ? ` max. CHF ${rentMatch[1].replace(/['\s]/g, "'")}` : ''
notesParts.push(`Wunsch: ${sizeHint} im Haus oder in der Nähe${rentHint} inkl. NK`)
}
const leaseOptionMatch = input.match(/(\d+)\s*[*×x]\s*(\d+)\s*jahre?\s*(?:echte\s*)?optione?n?/i)
if (leaseOptionMatch) {
notesParts.push(`Mietoption: ${leaseOptionMatch[1]}×${leaseOptionMatch[2]} Jahre echte Optionen gefordert`)
}
const notes = notesParts.length > 0 ? notesParts.join(' | ') : undefined
// Semantic signal flags used for weight boosting
const hasExpansionSignal = lower.includes('wachsen') || lower.includes('wachstum') || lower.includes('expansion') || lower.includes('erweiterung')
const hasCommunitySignal = lower.includes('startup') || lower.includes('community') || lower.includes('coworking') || lower.includes('junge firmen')
const hasPrestigeSignal = prestigeImportance === 'HIGH' || lower.includes('repräsentativ') || lower.includes('charakter') || lower.includes('beeindrucken')
const hasFlexSignal = lower.includes('flexibel') || lower.includes('wachsen') || hasCommunitySignal
// Suggested weights
const suggestedWeights: Record<string, number> = {
area: 0.18,
location: preferredLocations.length > 0 ? 0.25 : 0.20,
budget: budgetRange ? 0.20 : 0.16,
timing: timing ? 0.14 : 0.10,
prestige: hasPrestigeSignal ? 0.10 : 0.04,
accessibility: mustHaveCriteria.includes('Gute ÖV-Anbindung') ? 0.08 : 0.04,
expansionPotential: hasExpansionSignal ? 0.08 : 0.03,
flexibility: hasFlexSignal ? 0.08 : 0.03,
talentAccess: hasCommunitySignal ? 0.06 : 0.03,
}
const rawSummary = `Bedarf analysiert: ${assetType ?? 'Typ unbekannt'} · ${preferredLocations.join(', ') || 'Standort unklar'} · ${areaRange ? `${areaRange.min}${areaRange.max}` : 'Fläche unklar'} · ${budgetRange ? `max. CHF ${budgetRange.maxPerSqm}/m²` : 'Budget unklar'}`
return {
extractedCriteria: {
assetType,
areaRange,
preferredLocations,
budgetRange,
timing,
mustHaveCriteria,
infrastructureRequirements: [],
accessibilityRequirements: [],
prestigeImportance: hasPrestigeSignal ? 'HIGH' : prestigeImportance,
flexibilityNeed: hasFlexSignal ? 'HIGH' : 'MEDIUM',
expansionPotential: hasExpansionSignal,
parkingNeed,
visibilityNeed,
footfallNeed,
requireGroundFloor: requireGroundFloor || undefined,
requireAirConditioning: requireAirConditioning || undefined,
requireLoadingDock: requireLoadingDock || undefined,
requireBarrierFree: requireBarrierFree || undefined,
requiredParkingMin,
requiredFitOut: fitOutStr,
minCeilingHeightM,
minContractDurationMonths,
notes,
},
confidenceByField,
missingFields,
assumptions,
suggestedWeights,
followUpQuestionCandidates,
rawSummary,
promptVersion: 'mock-v1.0',
schemaVersion: '1.0.0',
}
}
// ── 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'
+61
View File
@@ -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))
}
+57 -28
View File
@@ -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)
}
},
}
+22 -22
View File
@@ -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
View File
@@ -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}`,
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} `,
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
View File
@@ -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)
}
},
}
+45 -16
View File
@@ -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)
}
},
}
+43 -14
View File
@@ -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)
}
},
}