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:
@@ -13,6 +13,7 @@ import {
|
|||||||
UnifiedResultFeed,
|
UnifiedResultFeed,
|
||||||
} from '../../components/results'
|
} from '../../components/results'
|
||||||
import { AddToPipelineDialog } from '../../components/shortlist'
|
import { AddToPipelineDialog } from '../../components/shortlist'
|
||||||
|
import { ErrorState } from '../../components/ui'
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
||||||
|
|
||||||
@@ -65,7 +66,7 @@ export default function Results() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['needs'] })
|
queryClient.invalidateQueries({ queryKey: ['needs'] })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: results = [], isLoading } = useUnifiedResults(effectiveNeedId)
|
const { data: results = [], isLoading, error } = useUnifiedResults(effectiveNeedId)
|
||||||
|
|
||||||
const isStaff = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
|
const isStaff = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
|
||||||
|
|
||||||
@@ -185,6 +186,8 @@ export default function Results() {
|
|||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<FeedSkeleton />
|
<FeedSkeleton />
|
||||||
|
) : error ? (
|
||||||
|
<ErrorState message={(error as Error).message} />
|
||||||
) : sorted.length === 0 ? (
|
) : sorted.length === 0 ? (
|
||||||
<FeedEmptyState filtered={filterSource !== 'ALL' || !showFutureAvailability || !showOwnProperties} />
|
<FeedEmptyState filtered={filterSource !== 'ALL' || !showFutureAvailability || !showOwnProperties} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
DEFAULT_SIGNAL_FILTERS,
|
DEFAULT_SIGNAL_FILTERS,
|
||||||
} from '../../components/future-signals'
|
} from '../../components/future-signals'
|
||||||
import { AddToShortlistDialog } from '../../components/shortlist'
|
import { AddToShortlistDialog } from '../../components/shortlist'
|
||||||
|
import { ErrorState } from '../../components/ui'
|
||||||
import type { SignalFilterState } from '../../components/future-signals'
|
import type { SignalFilterState } from '../../components/future-signals'
|
||||||
import type { FutureSignal } from '../../domain/futureSignal'
|
import type { FutureSignal } from '../../domain/futureSignal'
|
||||||
|
|
||||||
@@ -29,7 +30,7 @@ function applyFilters(signals: FutureSignal[], f: SignalFilterState): FutureSign
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function FutureAvailability() {
|
export default function FutureAvailability() {
|
||||||
const { data: signals = [], isLoading } = useFutureSignals()
|
const { data: signals = [], isLoading, isError, refetch } = useFutureSignals()
|
||||||
const [filters, setFilters] = useState<SignalFilterState>(DEFAULT_SIGNAL_FILTERS)
|
const [filters, setFilters] = useState<SignalFilterState>(DEFAULT_SIGNAL_FILTERS)
|
||||||
const [selectedSignal, setSelectedSignal] = useState<FutureSignal | null>(null)
|
const [selectedSignal, setSelectedSignal] = useState<FutureSignal | null>(null)
|
||||||
|
|
||||||
@@ -102,6 +103,8 @@ export default function FutureAvailability() {
|
|||||||
<Box key={i} sx={{ p: 2, mb: 1, bgcolor: 'white', borderRadius: 1, height: 120 }} />
|
<Box key={i} sx={{ p: 2, mb: 1, bgcolor: 'white', borderRadius: 1, height: 120 }} />
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
) : isError ? (
|
||||||
|
<ErrorState onRetry={refetch} />
|
||||||
) : filtered.length === 0 ? (
|
) : filtered.length === 0 ? (
|
||||||
<FutureSignalEmptyState context={signals.length === 0 ? 'no-signals' : 'filtered-empty'} />
|
<FutureSignalEmptyState context={signals.length === 0 ? 'no-signals' : 'filtered-empty'} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { useNeeds } from '../../hooks/useNeeds'
|
|||||||
import { useMatchCenterStore } from '../../stores/matchCenterStore'
|
import { useMatchCenterStore } from '../../stores/matchCenterStore'
|
||||||
import { useToastStore } from '../../stores/toastStore'
|
import { useToastStore } from '../../stores/toastStore'
|
||||||
import { MatchListCard, MatchBriefingPanel, MatchCenterSkeleton } from '../../components/match-center'
|
import { MatchListCard, MatchBriefingPanel, MatchCenterSkeleton } from '../../components/match-center'
|
||||||
|
import { ErrorState } from '../../components/ui'
|
||||||
import type { Match } from '../../domain/match'
|
import type { Match } from '../../domain/match'
|
||||||
|
|
||||||
const STRENGTH_OPTIONS = [
|
const STRENGTH_OPTIONS = [
|
||||||
@@ -32,7 +33,7 @@ const STATUS_OPTIONS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export default function MatchCenter() {
|
export default function MatchCenter() {
|
||||||
const { data: matches = [], isLoading } = useMatches()
|
const { data: matches = [], isLoading, isError, refetch } = useMatches()
|
||||||
const { data: properties = [] } = useProperties()
|
const { data: properties = [] } = useProperties()
|
||||||
const { data: needs = [] } = useNeeds()
|
const { data: needs = [] } = useNeeds()
|
||||||
const { setSelectedProperty, setSelectedNeed } = useMatchCenterStore()
|
const { setSelectedProperty, setSelectedNeed } = useMatchCenterStore()
|
||||||
@@ -141,6 +142,8 @@ export default function MatchCenter() {
|
|||||||
<Box sx={{ flex: 1, overflowY: 'auto', bgcolor: '#f8fafc' }}>
|
<Box sx={{ flex: 1, overflowY: 'auto', bgcolor: '#f8fafc' }}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<MatchCenterSkeleton />
|
<MatchCenterSkeleton />
|
||||||
|
) : isError ? (
|
||||||
|
<ErrorState onRetry={refetch} />
|
||||||
) : filtered.length === 0 ? (
|
) : filtered.length === 0 ? (
|
||||||
<Box sx={{ p: 6, textAlign: 'center' }}>
|
<Box sx={{ p: 6, textAlign: 'center' }}>
|
||||||
<Typography color="text.secondary">Keine Matches für diese Filter.</Typography>
|
<Typography color="text.secondary">Keine Matches für diese Filter.</Typography>
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type { ItemResponse } from '../types'
|
||||||
|
import type { CreateNeedInput } from '../../domain/need'
|
||||||
|
import type { ParseNeedResult, ParsedNeedCriteria, FollowUpQuestion } from '../../domain/needBuilder'
|
||||||
|
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
||||||
|
|
||||||
|
// ── Response Types ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface DecisionBrief {
|
||||||
|
id: string
|
||||||
|
shortlistId: string
|
||||||
|
summary: string
|
||||||
|
sections: { title: string; body: string }[]
|
||||||
|
generatedAt: string
|
||||||
|
isDraft: true
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComparisonSummary {
|
||||||
|
strongestOption: { matchId: string; label: string; reason: string }
|
||||||
|
bestValue: { matchId: string; label: string; reason: string } | null
|
||||||
|
highestConfidence: { matchId: string; label: string; confidenceLevel: number }
|
||||||
|
biggestTradeoffs: string[]
|
||||||
|
missingDataWarnings: string[]
|
||||||
|
recommendedNextStep: string
|
||||||
|
overallAssessment: string
|
||||||
|
perPropertyAssessment: Array<{
|
||||||
|
matchId: string
|
||||||
|
label: string
|
||||||
|
strengths: string[]
|
||||||
|
weaknesses: string[]
|
||||||
|
bestFor: string
|
||||||
|
keyRisk: string | null
|
||||||
|
}>
|
||||||
|
recommendation: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OfferEmailPayload {
|
||||||
|
needTitle: string
|
||||||
|
properties: string[]
|
||||||
|
matchScores: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedListingData {
|
||||||
|
assetType?: string
|
||||||
|
areaSqm?: number
|
||||||
|
rentPerSqm?: number
|
||||||
|
city?: string
|
||||||
|
softLevels?: Record<string, string>
|
||||||
|
parking?: number
|
||||||
|
fitOut?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Legacy types (kept for backward compatibility) ────────────────────────────
|
||||||
|
|
||||||
|
export interface CriteriaExtractionResult {
|
||||||
|
extractedCriteria: Partial<CreateNeedInput>
|
||||||
|
confidence: number
|
||||||
|
missingFields: string[]
|
||||||
|
assumptions: string[]
|
||||||
|
followUpQuestions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIServiceProvider {
|
||||||
|
extractCriteria(naturalLanguageInput: string): Promise<CriteriaExtractionResult>
|
||||||
|
generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<string[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Service Interface ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface IAIService {
|
||||||
|
// Need parsing (F008)
|
||||||
|
parseNeed(input: string): Promise<ItemResponse<ParseNeedResult>>
|
||||||
|
generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise<ItemResponse<FollowUpQuestion[]>>
|
||||||
|
|
||||||
|
// Compare (F014)
|
||||||
|
summarizeComparison(items: UnifiedMatchResult[]): Promise<ItemResponse<ComparisonSummary>>
|
||||||
|
|
||||||
|
// Shortlist decision brief
|
||||||
|
generateDecisionBrief(shortlistId: string): Promise<ItemResponse<DecisionBrief>>
|
||||||
|
|
||||||
|
// Offer email (supply side)
|
||||||
|
generateOfferEmail(payload: OfferEmailPayload): Promise<ItemResponse<{ subject: string; body: string }>>
|
||||||
|
|
||||||
|
// Legacy methods
|
||||||
|
extractCriteria(input: string): Promise<ItemResponse<CriteriaExtractionResult>>
|
||||||
|
generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<ItemResponse<string[]>>
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* AI Service Factory
|
||||||
|
*
|
||||||
|
* Selects the active implementation based on the VITE_USE_REAL_AI feature flag.
|
||||||
|
*
|
||||||
|
* Mock mode (default): no API key required, all responses are deterministic.
|
||||||
|
* Real mode: set VITE_USE_REAL_AI=true + VITE_OPENROUTER_API_KEY in .env
|
||||||
|
*/
|
||||||
|
import { MockAIService } from './mock/MockAIService'
|
||||||
|
import { OpenRouterAIService } from './openrouter/OpenRouterAIService'
|
||||||
|
import type { IAIService } from './IAIService'
|
||||||
|
|
||||||
|
const useRealAI = import.meta.env.VITE_USE_REAL_AI === 'true'
|
||||||
|
&& !!import.meta.env.VITE_OPENROUTER_API_KEY
|
||||||
|
|
||||||
|
export const aiService: IAIService = useRealAI ? OpenRouterAIService : MockAIService
|
||||||
|
|
||||||
|
// Named export so callers can reach the concrete impl when needed
|
||||||
|
export { MockAIService, OpenRouterAIService }
|
||||||
|
export type { IAIService }
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import type { ItemResponse } from '../../types'
|
||||||
|
import type { CreateNeedInput } from '../../../domain/need'
|
||||||
|
import type { ParsedNeedCriteria, FollowUpQuestion } from '../../../domain/needBuilder'
|
||||||
|
import type { UnifiedMatchResult } from '../../../domain/unifiedResult'
|
||||||
|
import type {
|
||||||
|
IAIService,
|
||||||
|
DecisionBrief,
|
||||||
|
ComparisonSummary,
|
||||||
|
CriteriaExtractionResult,
|
||||||
|
OfferEmailPayload,
|
||||||
|
} from '../IAIService'
|
||||||
|
import { mockParseNeed } from './needParser'
|
||||||
|
import { buildComparisonSummary } from './compareBuilder'
|
||||||
|
import { buildMockDecisionBrief } from './decisionBrief'
|
||||||
|
|
||||||
|
const SIMULATED_DELAY = {
|
||||||
|
fast: 300,
|
||||||
|
medium: 600,
|
||||||
|
slow: 1800,
|
||||||
|
}
|
||||||
|
|
||||||
|
const delay = (ms: number) => new Promise(r => setTimeout(r, ms))
|
||||||
|
|
||||||
|
export const MockAIService: IAIService = {
|
||||||
|
async parseNeed(input: string): Promise<ItemResponse<ReturnType<typeof mockParseNeed>>> {
|
||||||
|
await delay(SIMULATED_DELAY.fast)
|
||||||
|
return { data: mockParseNeed(input) }
|
||||||
|
},
|
||||||
|
|
||||||
|
async generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise<ItemResponse<FollowUpQuestion[]>> {
|
||||||
|
await delay(SIMULATED_DELAY.medium)
|
||||||
|
const result = mockParseNeed(JSON.stringify(criteria))
|
||||||
|
return { data: result.followUpQuestionCandidates }
|
||||||
|
},
|
||||||
|
|
||||||
|
async summarizeComparison(items: UnifiedMatchResult[]): Promise<ItemResponse<ComparisonSummary>> {
|
||||||
|
await delay(SIMULATED_DELAY.medium)
|
||||||
|
return { data: buildComparisonSummary(items) }
|
||||||
|
},
|
||||||
|
|
||||||
|
async generateDecisionBrief(shortlistId: string): Promise<ItemResponse<DecisionBrief>> {
|
||||||
|
await delay(SIMULATED_DELAY.slow)
|
||||||
|
return { data: buildMockDecisionBrief(shortlistId) }
|
||||||
|
},
|
||||||
|
|
||||||
|
async generateOfferEmail(payload: OfferEmailPayload): Promise<ItemResponse<{ subject: string; body: string }>> {
|
||||||
|
await delay(SIMULATED_DELAY.medium * 2)
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
subject: `Passende Gewerbeflächen zu Ihrer Anfrage: ${payload.needTitle}`,
|
||||||
|
body:
|
||||||
|
`Sehr geehrte Damen und Herren,\n\nvielen Dank für Ihr Interesse. Gerne unterbreiten wir Ihnen folgende passende Gewerbeobjekte aus unserem Portfolio:\n\n` +
|
||||||
|
payload.properties.map((p, i) => `• ${p} (Match-Score: ${payload.matchScores[i]}%)`).join('\n') +
|
||||||
|
`\n\nGerne arrangieren wir Besichtigungstermine für die genannten Objekte und stehen für alle weiteren Fragen zur Verfügung.\n\nFreundliche Grüsse\nWincasa AG`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Legacy methods
|
||||||
|
async extractCriteria(_input: string): Promise<ItemResponse<CriteriaExtractionResult>> {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
extractedCriteria: {
|
||||||
|
companyName: 'Unbekannt (bitte bestätigen)',
|
||||||
|
requiredArea: { min: 400, max: 900 },
|
||||||
|
budgetRange: { maxPerSqm: 40, currency: 'CHF' },
|
||||||
|
},
|
||||||
|
confidence: 0.72,
|
||||||
|
missingFields: ['assetType', 'timing', 'preferredLocations'],
|
||||||
|
assumptions: ['Fläche aus Zahlenangabe geschätzt', 'Budget aus Kostennennung abgeleitet'],
|
||||||
|
followUpQuestions: [
|
||||||
|
'Welchen Nutzungstyp suchen Sie (Büro, Retail, Logistik)?',
|
||||||
|
'In welchen Städten oder Regionen suchen Sie?',
|
||||||
|
'Wann möchten Sie spätestens einziehen?',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<ItemResponse<string[]>> {
|
||||||
|
const questions: string[] = []
|
||||||
|
if (!partialNeed.assetType) questions.push('Welchen Nutzungstyp suchen Sie?')
|
||||||
|
if (!partialNeed.preferredLocations?.length) questions.push('In welchen Regionen suchen Sie?')
|
||||||
|
if (!partialNeed.timing) questions.push('Was ist Ihr gewünschter Einzugstermin?')
|
||||||
|
if (!partialNeed.budgetRange) questions.push('Was ist Ihr maximales monatliches Budget?')
|
||||||
|
return { data: questions }
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import type { UnifiedMatchResult } from '../../../domain/unifiedResult'
|
||||||
|
import type { ComparisonSummary } from '../IAIService'
|
||||||
|
|
||||||
|
export function buildComparisonSummary(items: UnifiedMatchResult[]): ComparisonSummary {
|
||||||
|
const getTitle = (item: UnifiedMatchResult) =>
|
||||||
|
item.resultType !== 'FUTURE_AVAILABILITY'
|
||||||
|
? (item as any).property?.title ?? `Match ${item.matchScore}`
|
||||||
|
: (item as any).signal?.companyName ?? 'Zukunftssignal'
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return {
|
||||||
|
strongestOption: { matchId: '', label: '–', reason: 'Keine Ergebnisse' },
|
||||||
|
bestValue: null,
|
||||||
|
highestConfidence: { matchId: '', label: '–', confidenceLevel: 0 },
|
||||||
|
biggestTradeoffs: [],
|
||||||
|
missingDataWarnings: [],
|
||||||
|
recommendedNextStep: 'Suchergebnisse überprüfen',
|
||||||
|
overallAssessment: 'Keine Ergebnisse für die Analyse verfügbar.',
|
||||||
|
perPropertyAssessment: [],
|
||||||
|
recommendation: 'Suchergebnisse überprüfen und erneut versuchen.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const strongest = items.reduce((a, b) => a.matchScore > b.matchScore ? a : b)
|
||||||
|
|
||||||
|
const propertyItems = items.filter(i => i.resultType !== 'FUTURE_AVAILABILITY')
|
||||||
|
const bestValue = propertyItems.length > 0
|
||||||
|
? propertyItems.reduce((a, b) =>
|
||||||
|
((a as any).property?.rentPricePerSqm ?? Infinity) <= ((b as any).property?.rentPricePerSqm ?? Infinity) ? a : b
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
|
||||||
|
const highestConf = items.reduce((a, b) =>
|
||||||
|
a.match.confidenceLevel >= b.match.confidenceLevel ? a : b
|
||||||
|
)
|
||||||
|
|
||||||
|
const tradeoffs = items
|
||||||
|
.flatMap(i => i.match.tradeoffs?.slice(0, 1).map(t => `${getTitle(i)}: ${t.concern}`) ?? [])
|
||||||
|
.slice(0, 3)
|
||||||
|
|
||||||
|
const missingWarnings = items
|
||||||
|
.filter(i => (i.match.missingData?.filter(m => m.importance === 'CRITICAL').length ?? 0) > 0)
|
||||||
|
.map(i => `${getTitle(i)}: fehlende Pflichtfelder`)
|
||||||
|
|
||||||
|
const topNextAction = strongest.match.nextBestActions?.[0]?.label ?? 'Objekt besichtigen oder Details prüfen'
|
||||||
|
|
||||||
|
// Overall assessment
|
||||||
|
const labels = items.map(getTitle)
|
||||||
|
const scoreLeader = getTitle(strongest)
|
||||||
|
const priceLeader = bestValue ? getTitle(bestValue) : null
|
||||||
|
let overallAssessment: string
|
||||||
|
if (items.length === 1) {
|
||||||
|
overallAssessment = `${labels[0]} erfüllt die Kernkriterien mit einem Match Score von ${strongest.matchScore}/100. Eine Vergleichsbasis fehlt — weitere Optionen hinzufügen für eine fundierte Entscheidung.`
|
||||||
|
} else if (priceLeader && priceLeader !== scoreLeader) {
|
||||||
|
overallAssessment = `Beide Optionen erfüllen Standort und Fläche gut. ${scoreLeader} führt beim Match Score und Prestige, ${priceLeader} beim Preis-Leistungs-Verhältnis.`
|
||||||
|
} else {
|
||||||
|
overallAssessment = `${scoreLeader} dominiert in den meisten Kriterien mit dem höchsten Match Score (${strongest.matchScore}/100). Die Optionen unterscheiden sich hauptsächlich in Lage und Ausstattung.`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-property assessment
|
||||||
|
const deriveBestFor = (item: UnifiedMatchResult): string => {
|
||||||
|
const assetType = (item as any).property?.assetType ?? ''
|
||||||
|
const score = item.matchScore
|
||||||
|
const hasPosPrestige = item.match.positiveFactors.some(f =>
|
||||||
|
f.criterion.toLowerCase().includes('prestige') || f.criterion.toLowerCase().includes('location')
|
||||||
|
)
|
||||||
|
const hasLowPrice = bestValue?.matchId === item.matchId
|
||||||
|
if (hasPosPrestige && score >= 80) return 'Repräsentationsbedarf mit Prestige-Anforderungen'
|
||||||
|
if (hasLowPrice) return 'Kostenoptimierte Wachstumsphase'
|
||||||
|
if (assetType === 'LOGISTICS' || assetType === 'PRODUCTION') return 'Operative Effizienz und Flächenflexibilität'
|
||||||
|
if (assetType === 'RETAIL') return 'Hohe Kundenfrequenz und Sichtbarkeit'
|
||||||
|
if (score >= 80) return 'Anspruchsvolle Anforderungen mit hoher Matchqualität'
|
||||||
|
return 'Ausgewogenes Preis-Leistungs-Profil'
|
||||||
|
}
|
||||||
|
|
||||||
|
const perPropertyAssessment = items.map(item => {
|
||||||
|
const topStrengths = item.match.positiveFactors
|
||||||
|
.slice(0, 2)
|
||||||
|
.map(f => f.explanation)
|
||||||
|
const topWeaknesses = item.match.negativeFactors.length > 0
|
||||||
|
? item.match.negativeFactors.slice(0, 2).map(f => f.explanation)
|
||||||
|
: ['Keine kritischen Schwächen identifiziert']
|
||||||
|
const keyRisk = item.match.risks?.[0]?.description ?? null
|
||||||
|
return {
|
||||||
|
matchId: item.matchId,
|
||||||
|
label: getTitle(item),
|
||||||
|
strengths: topStrengths,
|
||||||
|
weaknesses: topWeaknesses,
|
||||||
|
bestFor: deriveBestFor(item),
|
||||||
|
keyRisk,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Recommendation
|
||||||
|
const recommendation = items.length >= 2
|
||||||
|
? `Besichtigung beider Objekte empfohlen — danach Entscheidung auf Basis Mietvertragslaufzeit und Ausbaustandard.`
|
||||||
|
: `Besichtigung von ${getTitle(strongest)} empfohlen — anschliessend Vertragskonditionen und Laufzeit prüfen.`
|
||||||
|
|
||||||
|
return {
|
||||||
|
strongestOption: {
|
||||||
|
matchId: strongest.matchId,
|
||||||
|
label: getTitle(strongest),
|
||||||
|
reason: `Höchster Match Score (${strongest.matchScore}/100)`,
|
||||||
|
},
|
||||||
|
bestValue: bestValue
|
||||||
|
? {
|
||||||
|
matchId: bestValue.matchId,
|
||||||
|
label: getTitle(bestValue),
|
||||||
|
reason: `Niedrigster Mietpreis (CHF ${(bestValue as any).property?.rentPricePerSqm ?? '–'}/m²)`,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
highestConfidence: {
|
||||||
|
matchId: highestConf.matchId,
|
||||||
|
label: getTitle(highestConf),
|
||||||
|
confidenceLevel: highestConf.match.confidenceLevel,
|
||||||
|
},
|
||||||
|
biggestTradeoffs: tradeoffs,
|
||||||
|
missingDataWarnings: missingWarnings,
|
||||||
|
recommendedNextStep: topNextAction,
|
||||||
|
overallAssessment,
|
||||||
|
perPropertyAssessment,
|
||||||
|
recommendation,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { DecisionBrief } from '../IAIService'
|
||||||
|
|
||||||
|
export function buildMockDecisionBrief(shortlistId: string): DecisionBrief {
|
||||||
|
return {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
shortlistId,
|
||||||
|
summary: 'Die Shortlist enthält qualitativ hochwertige Matches mit starker Standortübereinstimmung. Die verfügbaren Flächen decken den Bedarf gut ab. Zwei Objekte eignen sich als Erstbesichtigungen.',
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: 'Zusammenfassung der Objekte',
|
||||||
|
body: 'Die Shortlist umfasst mehrere Objekte aus dem verifizierten Portfolio. Die Matchscores liegen zwischen 74 und 88, was auf eine gute bis sehr gute Übereinstimmung mit den Suchkriterien hinweist.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Standortbewertung',
|
||||||
|
body: 'Die Mehrheit der Objekte befindet sich in bevorzugten Lagen. Die ÖV-Anbindung ist bei allen Objekten als gut bis sehr gut einzustufen.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Budgetanalyse',
|
||||||
|
body: 'Die Mietpreise liegen im budgetkonformen Bereich. Keine der Optionen überschreitet das maximale Budget pro m².',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Empfohlene nächste Schritte',
|
||||||
|
body: '1. Besichtigung der Top-2-Objekte vereinbaren. 2. Detaillierte Flächenpläne anfordern. 3. Vertragskonditionen prüfen lassen.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
isDraft: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type { ParsedListingData } from '../IAIService'
|
||||||
|
|
||||||
|
export async function parseListingText(text: string): Promise<ParsedListingData> {
|
||||||
|
await new Promise(r => setTimeout(r, 900))
|
||||||
|
const t = text.toLowerCase()
|
||||||
|
const result: ParsedListingData = {}
|
||||||
|
|
||||||
|
// Asset type
|
||||||
|
if (t.includes('laden') || t.includes('retail') || t.includes('shop') || t.includes('geschäft')) {
|
||||||
|
result.assetType = 'RETAIL'
|
||||||
|
} else if (t.includes('lager') || t.includes('logistik')) {
|
||||||
|
result.assetType = 'LOGISTICS'
|
||||||
|
} else if (t.includes('produktion') || t.includes('industrie') || t.includes('werkstatt')) {
|
||||||
|
result.assetType = 'PRODUCTION'
|
||||||
|
} else {
|
||||||
|
result.assetType = 'OFFICE'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Area
|
||||||
|
const areaMatch = text.match(/(\d{2,5})\s*m²/) ?? text.match(/(\d{2,5})\s*Quadratmeter/)
|
||||||
|
if (areaMatch) result.areaSqm = parseInt(areaMatch[1])
|
||||||
|
|
||||||
|
// Price — if raw < 500 assume monthly → convert to annual
|
||||||
|
const priceMatch = text.match(/(\d{2,4})\s*(?:CHF|Fr\.?)\s*\/\s*m²/) ?? text.match(/CHF\s*(\d{2,4})/)
|
||||||
|
if (priceMatch) {
|
||||||
|
const raw = parseInt(priceMatch[1])
|
||||||
|
result.rentPerSqm = raw < 500 ? raw * 12 : raw
|
||||||
|
}
|
||||||
|
|
||||||
|
// City
|
||||||
|
const CITIES = ['Zürich', 'Bern', 'Basel', 'Genf', 'Lausanne', 'Zug', 'Winterthur', 'St. Gallen', 'Lugano', 'Luzern', 'Biel', 'Thun', 'Kloten', 'Opfikon', 'Uster']
|
||||||
|
for (const city of CITIES) {
|
||||||
|
if (t.includes(city.toLowerCase())) { result.city = city; break }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft factors
|
||||||
|
const soft: Record<string, string> = {}
|
||||||
|
soft.prestige = (t.includes('zentrum') || t.includes('innenstadt') || t.includes('hauptbahnhof') || t.includes('repräsentativ') || t.includes('prestige'))
|
||||||
|
? 'HIGH' : (t.includes('gewerbegebiet') || t.includes('peripherie') || t.includes('industriezone'))
|
||||||
|
? 'LOW' : 'MEDIUM'
|
||||||
|
soft.accessibility = (t.includes('bahnhof') || t.includes('s-bahn') || t.includes('tram') || t.includes('öv') || t.includes('zentrum'))
|
||||||
|
? 'HIGH' : (t.includes('autobahn') || t.includes('gewerbegebiet'))
|
||||||
|
? 'LOW' : 'MEDIUM'
|
||||||
|
if (result.assetType === 'RETAIL') {
|
||||||
|
soft.visibility = t.includes('fussgänger') || t.includes('passanten') || t.includes('frequenz') ? 'HIGH' : 'MEDIUM'
|
||||||
|
soft.footfall = soft.visibility
|
||||||
|
}
|
||||||
|
if (t.includes('nachhaltig') || t.includes('minergie') || t.includes('esg') || t.includes('zertifiziert')) soft.esg = 'HIGH'
|
||||||
|
if (t.includes('expansion') || t.includes('erweiter') || t.includes('wachstum')) soft.expansionPotential = 'HIGH'
|
||||||
|
if (result.city === 'Zug') soft.taxEnvironment = 'HIGH'
|
||||||
|
if (t.includes('hochwertig') || t.includes('premium') || t.includes('erstklassig')) {
|
||||||
|
soft.prestige = 'HIGH'
|
||||||
|
result.fitOut = 'PREMIUM'
|
||||||
|
} else if (t.includes('einfach') || t.includes('standard-ausbau')) {
|
||||||
|
result.fitOut = 'BASIC'
|
||||||
|
}
|
||||||
|
result.softLevels = soft
|
||||||
|
|
||||||
|
// Parking
|
||||||
|
const parkingMatch = text.match(/(\d+)\s*Parkplätze?/) ?? text.match(/(\d+)\s*PP/)
|
||||||
|
if (parkingMatch) result.parking = parseInt(parkingMatch[1])
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
import type { ParseNeedResult, ParsedNeedCriteria, FollowUpQuestion } from '../../../domain/needBuilder'
|
||||||
|
import type { AssetType } from '../../../domain/enums'
|
||||||
|
|
||||||
|
export function mockParseNeed(input: string): ParseNeedResult {
|
||||||
|
const lower = input.toLowerCase()
|
||||||
|
|
||||||
|
// Asset type — RETAIL before LOGISTICS to avoid false match on "Nebenräume (Lager)"
|
||||||
|
const assetType: AssetType | undefined =
|
||||||
|
lower.includes('retail') || lower.includes('laden') || lower.includes('shop')
|
||||||
|
|| lower.includes('verkaufslokal') || lower.includes('ladenlokal') || lower.includes('ladenfläche')
|
||||||
|
|| lower.includes('verkaufsfläche') || lower.includes('schaufenster') ? 'RETAIL'
|
||||||
|
: lower.includes('büro') || lower.includes('office') || lower.includes('sitzungszimmer') || lower.includes('arbeitsplätze') ? 'OFFICE'
|
||||||
|
: lower.includes('logistik') || lower.includes('lagerhalle') || lower.includes('lagerraum')
|
||||||
|
|| (lower.includes('lager') && (lower.includes('logistik') || lower.includes('rampe') || lower.includes('palette') || lower.includes('lkw'))) ? 'LOGISTICS'
|
||||||
|
: lower.includes('lager') ? 'LOGISTICS'
|
||||||
|
: lower.includes('produktion') || lower.includes('gewerbe') || lower.includes('industrie') ? 'PRODUCTION'
|
||||||
|
: lower.includes('gastro') || lower.includes('restaurant') ? 'GASTRO'
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
// Area
|
||||||
|
const areaRangeMatch = input.match(/(\d+)\s*[–\-–]\s*(\d+)\s*m[²2]/i)
|
||||||
|
const areaSingleMatch = input.match(/(\d{3,5})\s*m[²2]/i)
|
||||||
|
let areaRange: { min: number; max: number } | undefined
|
||||||
|
let areaConfidence = 0.25
|
||||||
|
if (areaRangeMatch) {
|
||||||
|
areaRange = { min: parseInt(areaRangeMatch[1]), max: parseInt(areaRangeMatch[2]) }
|
||||||
|
areaConfidence = 0.95
|
||||||
|
} else if (areaSingleMatch) {
|
||||||
|
const base = parseInt(areaSingleMatch[1])
|
||||||
|
areaRange = { min: Math.round(base * 0.8), max: Math.round(base * 1.2) }
|
||||||
|
areaConfidence = 0.70
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locations — use word boundaries to avoid false matches like "Umzugsfirma" → "Zug"
|
||||||
|
const CITIES: [string, string][] = [
|
||||||
|
['zürich', 'Zürich'], ['basel', 'Basel'], ['bern', 'Bern'], ['genf', 'Genf'],
|
||||||
|
['lausanne', 'Lausanne'], ['winterthur', 'Winterthur'], ['zug', 'Zug'],
|
||||||
|
['luzern', 'Luzern'], ['st. gallen', 'St. Gallen'], ['lugano', 'Lugano'],
|
||||||
|
['biel', 'Biel'], ['schaffhausen', 'Schaffhausen'],
|
||||||
|
['wädenswil', 'Wädenswil'], ['thalwil', 'Thalwil'], ['uster', 'Uster'],
|
||||||
|
['horgen', 'Horgen'], ['küsnacht', 'Küsnacht'], ['baar', 'Baar'],
|
||||||
|
]
|
||||||
|
const preferredLocations = CITIES.filter(([k]) => {
|
||||||
|
if (k.includes(' ') || k.includes('.')) return lower.includes(k)
|
||||||
|
// Word boundary: not preceded/followed by a letter (including German umlauts)
|
||||||
|
const re = new RegExp(`(?<![a-zA-ZäöüÄÖÜß])${k}(?![a-zA-ZäöüÄÖÜß])`)
|
||||||
|
return re.test(lower)
|
||||||
|
}).map(([, v]) => v)
|
||||||
|
|
||||||
|
// Infer Zürich when Zürich-specific districts or landmarks are mentioned
|
||||||
|
const ZURICH_SIGNALS = [
|
||||||
|
'seefeld', 'bellevue', 'paradeplatz', 'bahnhofstrasse', 'zürich-west', 'zürich west',
|
||||||
|
'oerlikon', 'altstetten', 'kreis 1', 'kreis 2', 'kreis 3', 'kreis 4', 'kreis 5',
|
||||||
|
'kreis 6', 'kreis 7', 'kreis 8', 'langstrasse', 'hardbrücke', 'freilager',
|
||||||
|
'europaallee', 'zürich nord', 'zürich süd',
|
||||||
|
]
|
||||||
|
if (!preferredLocations.includes('Zürich') && ZURICH_SIGNALS.some(s => lower.includes(s))) {
|
||||||
|
preferredLocations.push('Zürich')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contextual Zürich inference: only truly Zürich-specific place/brand names
|
||||||
|
const ZURICH_ONLY_SIGNALS = [
|
||||||
|
'industrie groove', 'industrie-groove',
|
||||||
|
'pfingstweidstrasse', 'hardbrücke', 'freilager', 'europaallee',
|
||||||
|
'zürich-west', 'zürich west', 'zürich nord', 'zürich süd',
|
||||||
|
'hürlimann', 'viadukt', 'schiffbau',
|
||||||
|
]
|
||||||
|
if (preferredLocations.length === 0 && ZURICH_ONLY_SIGNALS.some(s => lower.includes(s))) {
|
||||||
|
preferredLocations.push('Zürich')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract district list from patterns like "Kreis 3, 4, 5, und 8" or "Kreis 3/4/5"
|
||||||
|
const kreisListMatch = lower.match(/\bkreis\s+([\d]+(?:\s*[,\/]\s*[\d]+)*(?:\s+und\s+[\d]+)?)/)
|
||||||
|
if (kreisListMatch) {
|
||||||
|
const nums = kreisListMatch[1].match(/\d+/g) ?? []
|
||||||
|
nums.forEach(n => {
|
||||||
|
const k = `Zürich Kreis ${n}`
|
||||||
|
if (!preferredLocations.includes(k)) preferredLocations.push(k)
|
||||||
|
})
|
||||||
|
if (!preferredLocations.includes('Zürich')) preferredLocations.push('Zürich')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map Zürich landmark/neighbourhood names → district-level location entries
|
||||||
|
const ZURICH_DISTRICT_MAP: [string, string][] = [
|
||||||
|
['seefeld', 'Zürich Seefeld'], ['bellevue', 'Zürich Seefeld'],
|
||||||
|
['bahnhofstrasse', 'Zürich Kreis 1'], ['paradeplatz', 'Zürich Kreis 1'],
|
||||||
|
['zürich-west', 'Zürich-West'], ['pfingstweidstrasse', 'Zürich-West'],
|
||||||
|
['limmatstrasse', 'Zürich-West'],
|
||||||
|
]
|
||||||
|
ZURICH_DISTRICT_MAP.forEach(([k, v]) => {
|
||||||
|
if (lower.includes(k) && !preferredLocations.includes(v)) preferredLocations.push(v)
|
||||||
|
})
|
||||||
|
|
||||||
|
const locationConfidence = preferredLocations.length > 0 ? 0.88 : 0.15
|
||||||
|
|
||||||
|
// Budget — strip Swiss price notation (320.--) before matching
|
||||||
|
const cleanedBudget = input.replace(/(\d)\.-+/g, '$1').replace(/(\d)\.—/g, '$1')
|
||||||
|
// "MZ 320/m²", "CHF 320/m²", "320/m²", "320.--/m²/a"
|
||||||
|
const budgetPerSqmMatch = cleanedBudget.match(/(?:mz|mietzins|max\.?|budget)?\s*(?:CHF\s*)?(\d{2,4})\s*\/\s*m[²2]/i)
|
||||||
|
// Range "250 – 280 CHF" or "250-280/m²" — require explicit currency or /m² so area ranges like "120–150m2" are not matched
|
||||||
|
const budgetRangeMatch =
|
||||||
|
cleanedBudget.match(/(\d{2,4})\s*[–\-]\s*(\d{2,4})\s*(?:CHF|Fr\.?)\b/i) ??
|
||||||
|
cleanedBudget.match(/(?:CHF|Fr\.?)\s*(\d{2,4})\s*[–\-]\s*(\d{2,4})/i) ??
|
||||||
|
cleanedBudget.match(/(\d{2,4})\s*[–\-]\s*(\d{2,4})\s*\/\s*m[²2]/i)
|
||||||
|
const budgetMaxMatch = input.match(/(?:max\.?|bis|höchstens)\s*(?:CHF\s*)?(\d+)/i)
|
||||||
|
let budgetRange: { maxPerSqm: number; currency: string } | undefined
|
||||||
|
let budgetConfidence = 0.20
|
||||||
|
// Monthly-to-annual threshold: Swiss retail quotes monthly (150/m²/mon → 1800/year),
|
||||||
|
// office/logistics quote monthly too but at lower values (45/m²/mon → 540/year).
|
||||||
|
const monthlyThreshold = assetType === 'RETAIL' ? 500 : 150
|
||||||
|
if (budgetPerSqmMatch) {
|
||||||
|
const raw = parseInt(budgetPerSqmMatch[1])
|
||||||
|
budgetRange = { maxPerSqm: raw < monthlyThreshold ? raw * 12 : raw, currency: 'CHF' }
|
||||||
|
budgetConfidence = 0.92
|
||||||
|
} else if (budgetRangeMatch) {
|
||||||
|
// Take upper value of range as max
|
||||||
|
const raw = parseInt(budgetRangeMatch[2])
|
||||||
|
budgetRange = { maxPerSqm: raw < monthlyThreshold ? raw * 12 : raw, currency: 'CHF' }
|
||||||
|
budgetConfidence = 0.75
|
||||||
|
} else if (budgetMaxMatch) {
|
||||||
|
const raw = parseInt(budgetMaxMatch[1])
|
||||||
|
budgetRange = { maxPerSqm: raw < monthlyThreshold ? raw * 12 : raw, currency: 'CHF' }
|
||||||
|
budgetConfidence = 0.60
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timing
|
||||||
|
const yearMatch = input.match(/(?:ab\s+)?(?:Q[1-4]\s*\/?\s*)?(20\d{2})/)
|
||||||
|
// "Q4/26" or "Q1/27" — two-digit year (collect all occurrences, use first as earliest)
|
||||||
|
const quarterMatches = [...input.matchAll(/Q([1-4])\s*[\/\-]?\s*(\d{2})\b/gi)]
|
||||||
|
const soonMatch = lower.includes('sofort') || lower.includes('asap')
|
||||||
|
let timing: ParsedNeedCriteria['timing'] | undefined
|
||||||
|
let timingConfidence = 0.20
|
||||||
|
if (soonMatch) {
|
||||||
|
timing = { earliestMoveIn: '2025-07-01', latestMoveIn: '2025-10-01', flexibleTiming: false }
|
||||||
|
timingConfidence = 0.85
|
||||||
|
} else if (yearMatch) {
|
||||||
|
timing = { earliestMoveIn: `${yearMatch[1]}-01-01`, latestMoveIn: `${yearMatch[1]}-12-31`, flexibleTiming: lower.includes('flexibel') }
|
||||||
|
timingConfidence = 0.75
|
||||||
|
} else if (quarterMatches.length > 0) {
|
||||||
|
// Use first Q as earliest, last Q as latest (6-month window minimum)
|
||||||
|
const qStartMonth = [0, 1, 4, 7, 10] // [unused, Q1, Q2, Q3, Q4]
|
||||||
|
const first = quarterMatches[0]
|
||||||
|
const last = quarterMatches[quarterMatches.length - 1]
|
||||||
|
const yr1 = 2000 + parseInt(first[2])
|
||||||
|
const yr2 = 2000 + parseInt(last[2])
|
||||||
|
const q1 = parseInt(first[1])
|
||||||
|
const q2 = parseInt(last[1])
|
||||||
|
const startM = String(qStartMonth[q1]).padStart(2, '0')
|
||||||
|
const endM = String(Math.min(12, qStartMonth[q2] + 2)).padStart(2, '0')
|
||||||
|
timing = {
|
||||||
|
earliestMoveIn: `${yr1}-${startM}-01`,
|
||||||
|
latestMoveIn: `${yr2}-${endM}-${endM === '12' ? '31' : '28'}`,
|
||||||
|
flexibleTiming: true,
|
||||||
|
}
|
||||||
|
timingConfidence = 0.70
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must-haves
|
||||||
|
const mustHaveCriteria: string[] = []
|
||||||
|
if (lower.includes('öv') || lower.includes('bahnhof') || lower.includes('tram') || lower.includes('erschlossen')) mustHaveCriteria.push('Gute ÖV-Anbindung')
|
||||||
|
if (lower.includes('parking') || lower.includes('parkplatz') || lower.includes('tiefgarage') || lower.includes('stellplatz')) mustHaveCriteria.push('Parkplätze vorhanden')
|
||||||
|
if (lower.includes('klimaanlage') || lower.includes('klima')) mustHaveCriteria.push('Klimaanlage')
|
||||||
|
if (lower.includes('server') || lower.includes('rechenzentr')) mustHaveCriteria.push('Serverraum / IT-Infrastruktur')
|
||||||
|
if (lower.includes('barrierefrei')) mustHaveCriteria.push('Barrierefreiheit')
|
||||||
|
if (lower.includes('küche') || lower.includes('kantine')) mustHaveCriteria.push('Kantine / Küche')
|
||||||
|
if (lower.includes('laderampe') || lower.includes('rampe') || lower.includes('verladetor')) mustHaveCriteria.push('Laderampe')
|
||||||
|
if (lower.includes('schaufenster') || lower.includes('shopfenster') || lower.includes('vitrine')) mustHaveCriteria.push('Schaufenster')
|
||||||
|
if (lower.includes('verpflegung') || lower.includes('restaurant') || lower.includes('lunch') || lower.includes('mittagessen') || lower.includes('takeaway')) mustHaveCriteria.push('Verpflegungsmöglichkeiten')
|
||||||
|
if (lower.includes('startup') || lower.includes('start-up') || lower.includes('community') || lower.includes('coworking') || lower.includes('junge firmen') || lower.includes('jungunternehm')) mustHaveCriteria.push('Startup-Community / innovative Nachbarn')
|
||||||
|
if (lower.includes('wachsen') || lower.includes('expansion') || lower.includes('erweiterungsoption') || lower.includes('wachstum') || lower.includes('wachstumsoption') || lower.includes('flexibilität zum wachsen')) mustHaveCriteria.push('Erweiterungsoption vorhanden')
|
||||||
|
if (lower.includes('werkstatt') || lower.includes('atelier') || lower.includes('reparatur')) mustHaveCriteria.push('Werkstatt / Atelier')
|
||||||
|
|
||||||
|
// Structured requirement fields
|
||||||
|
const requireGroundFloor = lower.includes('erdgeschoss') || lower.includes('parterre')
|
||||||
|
|| lower.includes('schaufenster') || lower.includes('ladenlokal') || undefined as boolean | undefined
|
||||||
|
const requireAirConditioning = lower.includes('klimaanlage') || lower.includes('klimatisierung')
|
||||||
|
|| lower.includes('klima') || lower.includes('air conditioning')
|
||||||
|
|| lower.includes('kühlung') || undefined as boolean | undefined
|
||||||
|
const requireLoadingDock = lower.includes('laderampe') || lower.includes('verladerampe')
|
||||||
|
|| lower.includes('ladetor') || lower.includes('rampe') || undefined as boolean | undefined
|
||||||
|
const requireBarrierFree = lower.includes('barrierefrei') || lower.includes('rollstuhl')
|
||||||
|
|| lower.includes('iv-gerecht') || lower.includes('behindertengerecht') || undefined as boolean | undefined
|
||||||
|
|
||||||
|
const ceilingMatch = input.match(/(\d+(?:[.,]\d+)?)\s*m(?:eter)?\s*(?:deckenhöhe|hallenhöhe|lichte\s+höhe)/i)
|
||||||
|
?? input.match(/deckenhöhe\s+(?:mind\.?\s*)?(\d+(?:[.,]\d+)?)\s*m/i)
|
||||||
|
const minCeilingHeightM = ceilingMatch ? parseFloat(ceilingMatch[1].replace(',', '.')) : undefined
|
||||||
|
|
||||||
|
// Parking: "3-5 Parkplätze" → extract lower bound (minimum); "5 Parkplätze" → 5
|
||||||
|
const parkingRangeMatch = input.match(/(\d+)\s*[-–]\s*\d+\s*(?:parkplätze?|stellplätze?|pp\b)/i)
|
||||||
|
const parkingSingleMatch = input.match(/(?:mind(?:estens)?\.?\s+)?(\d+)\s*(?:parkplätze?|stellplätze?|pp\b)/i)
|
||||||
|
const requiredParkingMin = parkingRangeMatch ? parseInt(parkingRangeMatch[1])
|
||||||
|
: parkingSingleMatch ? parseInt(parkingSingleMatch[1]) : undefined
|
||||||
|
|
||||||
|
const fitOutStr: 'BASIC' | 'FULL' | 'PREMIUM' | undefined =
|
||||||
|
lower.includes('schlüsselfertig') || lower.includes('premium') || lower.includes('hochwertig')
|
||||||
|
|| lower.includes('top ausgebaut') || lower.includes('top-ausgebaut') ? 'PREMIUM'
|
||||||
|
: lower.includes('vollausbau') || lower.includes('vollständig ausgebaut') || lower.includes('ausgebaut')
|
||||||
|
|| lower.includes('ready to use') || lower.includes('reddy to use') || lower.includes('bezugsfertig') ? 'FULL'
|
||||||
|
: lower.includes('basisausbau') || lower.includes('rohbau') || lower.includes('einfach') ? 'BASIC'
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
// Contract duration: "7-jähriger Vertrag", "Laufzeit 7 Jahre", standalone "7 Jahre" at sentence start
|
||||||
|
// Exclude "in X Jahren", "X Jahre im Geschäft", "X Jahre Erfahrung" etc.
|
||||||
|
const contractMatch = input.match(/(\d+)[- ]?j[aä]hrige?(?:r)?\s+(?:vertrag|mietvertrag|laufzeit)/i)
|
||||||
|
?? input.match(/(?:vertrag|laufzeit|mietdauer).{0,20}(\d+)\s*jahre?/i)
|
||||||
|
?? input.match(/\b(\d+)\s*jahre?\b(?!\s*(?:erfahrung|planung|rendite|im\b|alt\b|alten|altes|jung))/i)
|
||||||
|
const minContractDurationMonths = contractMatch ? parseInt(contractMatch[1]) * 12 : undefined
|
||||||
|
|
||||||
|
// Soft
|
||||||
|
const prestigeImportance: 'LOW' | 'MEDIUM' | 'HIGH' | undefined =
|
||||||
|
lower.includes('prestige') || lower.includes('representativ') || lower.includes('repräsentativ')
|
||||||
|
|| lower.includes('topadresse') || lower.includes('top adresse') || lower.includes('innerstädtisch')
|
||||||
|
|| lower.includes('topaddresse') ? 'HIGH'
|
||||||
|
: lower.includes('standard') ? 'LOW'
|
||||||
|
: undefined
|
||||||
|
const parkingNeed = lower.includes('parking') || lower.includes('parkplatz')
|
||||||
|
const visibilityNeed: 'HIGH' | undefined = lower.includes('sichtbar') || lower.includes('passanten') ? 'HIGH' : undefined
|
||||||
|
const footfallNeed: 'HIGH' | undefined = lower.includes('frequenz') || lower.includes('laufkundschaft') ? 'HIGH' : undefined
|
||||||
|
|
||||||
|
// Missing fields
|
||||||
|
const missingFields: string[] = []
|
||||||
|
if (!assetType) missingFields.push('Nutzungstyp')
|
||||||
|
if (!areaRange) missingFields.push('Flächenbedarf')
|
||||||
|
if (preferredLocations.length === 0) missingFields.push('Standort')
|
||||||
|
if (!budgetRange) missingFields.push('Budget')
|
||||||
|
if (!timing) missingFields.push('Verfügbarkeitstermin')
|
||||||
|
|
||||||
|
// Assumptions
|
||||||
|
const assumptions: string[] = []
|
||||||
|
if (areaRange && areaSingleMatch && !areaRangeMatch) {
|
||||||
|
assumptions.push(`Flächenrange aus Einzelangabe (${areaSingleMatch[1]} m²) geschätzt — bitte prüfen`)
|
||||||
|
}
|
||||||
|
if (budgetRange && !budgetPerSqmMatch && budgetMaxMatch) {
|
||||||
|
assumptions.push('Budget als Pauschalangabe interpretiert — Angabe pro m² unklar')
|
||||||
|
}
|
||||||
|
if (!assetType) {
|
||||||
|
assumptions.push('Nutzungstyp konnte nicht eindeutig erkannt werden')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confidence by field
|
||||||
|
const confidenceByField: Record<string, number> = {
|
||||||
|
assetType: assetType ? 0.92 : 0.20,
|
||||||
|
areaRange: areaConfidence,
|
||||||
|
preferredLocations: locationConfidence,
|
||||||
|
budgetRange: budgetConfidence,
|
||||||
|
timing: timingConfidence,
|
||||||
|
mustHaveCriteria: mustHaveCriteria.length > 0 ? 0.85 : 0.10,
|
||||||
|
prestigeImportance: prestigeImportance ? 0.80 : 0.20,
|
||||||
|
parkingNeed: parkingNeed ? 0.90 : 0.30,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Follow-up questions
|
||||||
|
const followUpQuestionCandidates: FollowUpQuestion[] = []
|
||||||
|
|
||||||
|
if (!assetType) {
|
||||||
|
followUpQuestionCandidates.push({
|
||||||
|
id: 'fq-asset-type',
|
||||||
|
questionText: 'Welchen Nutzungstyp suchen Sie?',
|
||||||
|
targetField: 'assetType',
|
||||||
|
reason: 'Der Nutzungstyp konnte nicht eindeutig erkannt werden.',
|
||||||
|
suggestedAnswerOptions: ['Büro', 'Logistik / Lager', 'Retail', 'Produktion', 'Gastro / F&B'],
|
||||||
|
importance: 'required',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (preferredLocations.length === 0) {
|
||||||
|
followUpQuestionCandidates.push({
|
||||||
|
id: 'fq-location',
|
||||||
|
questionText: 'In welcher Region oder Stadt suchen Sie?',
|
||||||
|
targetField: 'preferredLocations',
|
||||||
|
reason: 'Kein konkreter Standort angegeben.',
|
||||||
|
suggestedAnswerOptions: ['Zürich', 'Basel', 'Bern', 'Zug', 'Luzern', 'Genf'],
|
||||||
|
importance: 'required',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (!timing) {
|
||||||
|
followUpQuestionCandidates.push({
|
||||||
|
id: 'fq-timing',
|
||||||
|
questionText: 'Ab wann benötigen Sie die Fläche?',
|
||||||
|
targetField: 'timing',
|
||||||
|
reason: 'Kein Verfügbarkeitsdatum erkannt.',
|
||||||
|
suggestedAnswerOptions: ['Sofort', 'In 3 Monaten', 'In 6 Monaten', 'In 12 Monaten', 'Flexibel'],
|
||||||
|
importance: 'recommended',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (!budgetRange) {
|
||||||
|
followUpQuestionCandidates.push({
|
||||||
|
id: 'fq-budget',
|
||||||
|
questionText: 'Was ist Ihr Maximalbudget pro m² und Jahr?',
|
||||||
|
targetField: 'budgetRange',
|
||||||
|
reason: 'Kein Budget erkannt.',
|
||||||
|
suggestedAnswerOptions: ['< CHF 300/m²/J', 'CHF 300–420/m²/J', 'CHF 420–540/m²/J', '> CHF 540/m²/J', 'Flexibel'],
|
||||||
|
importance: 'recommended',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
followUpQuestionCandidates.push({
|
||||||
|
id: 'fq-parking',
|
||||||
|
questionText: 'Benötigen Sie Parkplätze vor Ort?',
|
||||||
|
targetField: 'parkingNeed',
|
||||||
|
reason: 'Angabe zu Parkplatzbedarf verbessert die Matchqualität.',
|
||||||
|
suggestedAnswerOptions: ['Ja, zwingend', 'Ja, wenn möglich', 'Nein'],
|
||||||
|
importance: 'optional',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Special notes: apartment wish (UC-A type) + lease options (UC-C type)
|
||||||
|
const notesParts: string[] = []
|
||||||
|
const apartmentMatch = input.match(/(\d[.,]\d)\s*zimmer[- ]?wohn/i)
|
||||||
|
?? (lower.includes('zimmer') && lower.includes('wohnung') ? [''] : null)
|
||||||
|
if (apartmentMatch) {
|
||||||
|
const sizeHint = apartmentMatch[1] ? `${apartmentMatch[1]}-Zi-Wohnung` : 'Wohnung'
|
||||||
|
const rentMatch = input.match(/(\d['.\s]?\d{3})[.,\-\s]*(?:inkl|inkl\.)/i)
|
||||||
|
?? input.match(/(\d{4})[.,\-]\s*(?:chf|fr)?/i)
|
||||||
|
const rentHint = rentMatch ? ` max. CHF ${rentMatch[1].replace(/['\s]/g, "'")}` : ''
|
||||||
|
notesParts.push(`Wunsch: ${sizeHint} im Haus oder in der Nähe${rentHint} inkl. NK`)
|
||||||
|
}
|
||||||
|
const leaseOptionMatch = input.match(/(\d+)\s*[*×x]\s*(\d+)\s*jahre?\s*(?:echte\s*)?optione?n?/i)
|
||||||
|
if (leaseOptionMatch) {
|
||||||
|
notesParts.push(`Mietoption: ${leaseOptionMatch[1]}×${leaseOptionMatch[2]} Jahre echte Optionen gefordert`)
|
||||||
|
}
|
||||||
|
const notes = notesParts.length > 0 ? notesParts.join(' | ') : undefined
|
||||||
|
|
||||||
|
// Semantic signal flags used for weight boosting
|
||||||
|
const hasExpansionSignal = lower.includes('wachsen') || lower.includes('wachstum') || lower.includes('expansion') || lower.includes('erweiterung')
|
||||||
|
const hasCommunitySignal = lower.includes('startup') || lower.includes('community') || lower.includes('coworking') || lower.includes('junge firmen')
|
||||||
|
const hasPrestigeSignal = prestigeImportance === 'HIGH' || lower.includes('repräsentativ') || lower.includes('charakter') || lower.includes('beeindrucken')
|
||||||
|
const hasFlexSignal = lower.includes('flexibel') || lower.includes('wachsen') || hasCommunitySignal
|
||||||
|
|
||||||
|
// Suggested weights
|
||||||
|
const suggestedWeights: Record<string, number> = {
|
||||||
|
area: 0.18,
|
||||||
|
location: preferredLocations.length > 0 ? 0.25 : 0.20,
|
||||||
|
budget: budgetRange ? 0.20 : 0.16,
|
||||||
|
timing: timing ? 0.14 : 0.10,
|
||||||
|
prestige: hasPrestigeSignal ? 0.10 : 0.04,
|
||||||
|
accessibility: mustHaveCriteria.includes('Gute ÖV-Anbindung') ? 0.08 : 0.04,
|
||||||
|
expansionPotential: hasExpansionSignal ? 0.08 : 0.03,
|
||||||
|
flexibility: hasFlexSignal ? 0.08 : 0.03,
|
||||||
|
talentAccess: hasCommunitySignal ? 0.06 : 0.03,
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawSummary = `Bedarf analysiert: ${assetType ?? 'Typ unbekannt'} · ${preferredLocations.join(', ') || 'Standort unklar'} · ${areaRange ? `${areaRange.min}–${areaRange.max} m²` : 'Fläche unklar'} · ${budgetRange ? `max. CHF ${budgetRange.maxPerSqm}/m²` : 'Budget unklar'}`
|
||||||
|
|
||||||
|
return {
|
||||||
|
extractedCriteria: {
|
||||||
|
assetType,
|
||||||
|
areaRange,
|
||||||
|
preferredLocations,
|
||||||
|
budgetRange,
|
||||||
|
timing,
|
||||||
|
mustHaveCriteria,
|
||||||
|
infrastructureRequirements: [],
|
||||||
|
accessibilityRequirements: [],
|
||||||
|
prestigeImportance: hasPrestigeSignal ? 'HIGH' : prestigeImportance,
|
||||||
|
flexibilityNeed: hasFlexSignal ? 'HIGH' : 'MEDIUM',
|
||||||
|
expansionPotential: hasExpansionSignal,
|
||||||
|
parkingNeed,
|
||||||
|
visibilityNeed,
|
||||||
|
footfallNeed,
|
||||||
|
requireGroundFloor: requireGroundFloor || undefined,
|
||||||
|
requireAirConditioning: requireAirConditioning || undefined,
|
||||||
|
requireLoadingDock: requireLoadingDock || undefined,
|
||||||
|
requireBarrierFree: requireBarrierFree || undefined,
|
||||||
|
requiredParkingMin,
|
||||||
|
requiredFitOut: fitOutStr,
|
||||||
|
minCeilingHeightM,
|
||||||
|
minContractDurationMonths,
|
||||||
|
notes,
|
||||||
|
},
|
||||||
|
confidenceByField,
|
||||||
|
missingFields,
|
||||||
|
assumptions,
|
||||||
|
suggestedWeights,
|
||||||
|
followUpQuestionCandidates,
|
||||||
|
rawSummary,
|
||||||
|
promptVersion: 'mock-v1.0',
|
||||||
|
schemaVersion: '1.0.0',
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* OpenRouter AI Service
|
||||||
|
*
|
||||||
|
* To activate:
|
||||||
|
* 1. Set VITE_USE_REAL_AI=true in your .env file
|
||||||
|
* 2. Set VITE_OPENROUTER_API_KEY=<your-key>
|
||||||
|
* 3. Optionally set VITE_OPENROUTER_MODEL (default: anthropic/claude-3-5-haiku)
|
||||||
|
*
|
||||||
|
* This service is model-agnostic — change VITE_OPENROUTER_MODEL to switch
|
||||||
|
* between Claude, GPT-4o, Mistral, Llama, etc. without code changes.
|
||||||
|
*/
|
||||||
|
import type { ItemResponse } from '../../types'
|
||||||
|
import type { CreateNeedInput } from '../../../domain/need'
|
||||||
|
import type { ParseNeedResult, ParsedNeedCriteria, FollowUpQuestion } from '../../../domain/needBuilder'
|
||||||
|
import type { UnifiedMatchResult } from '../../../domain/unifiedResult'
|
||||||
|
import type {
|
||||||
|
IAIService,
|
||||||
|
DecisionBrief,
|
||||||
|
ComparisonSummary,
|
||||||
|
CriteriaExtractionResult,
|
||||||
|
OfferEmailPayload,
|
||||||
|
} from '../IAIService'
|
||||||
|
import { ServiceErrorCode } from '../../types'
|
||||||
|
import { AppError } from '../../errors'
|
||||||
|
import { buildNeedParsingPrompt } from '../prompts/needParsingPrompt'
|
||||||
|
import { buildCompareSummaryPrompt } from '../prompts/compareSummaryPrompt'
|
||||||
|
import { buildDecisionBriefPrompt } from '../prompts/decisionBriefPrompt'
|
||||||
|
import { MockAIService } from '../mock/MockAIService'
|
||||||
|
|
||||||
|
const API_BASE = 'https://openrouter.ai/api/v1'
|
||||||
|
const DEFAULT_MODEL = 'anthropic/claude-3-5-haiku'
|
||||||
|
|
||||||
|
function getConfig(): { apiKey: string; model: string } | null {
|
||||||
|
const apiKey = import.meta.env.VITE_OPENROUTER_API_KEY as string | undefined
|
||||||
|
if (!apiKey) return null
|
||||||
|
return {
|
||||||
|
apiKey,
|
||||||
|
model: (import.meta.env.VITE_OPENROUTER_MODEL as string | undefined) ?? DEFAULT_MODEL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chat(config: { apiKey: string; model: string }, system: string, user: string): Promise<string> {
|
||||||
|
const res = await fetch(`${API_BASE}/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${config.apiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'HTTP-Referer': window.location.origin,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: config.model,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: system },
|
||||||
|
{ role: 'user', content: user },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text()
|
||||||
|
throw new AppError({ code: ServiceErrorCode.AI_GENERATION_FAILED, message: `OpenRouter error ${res.status}: ${body}` })
|
||||||
|
}
|
||||||
|
const json = await res.json() as { choices: Array<{ message: { content: string } }> }
|
||||||
|
return json.choices[0]?.message?.content ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJSON<T>(raw: string, fallback: T): T {
|
||||||
|
const jsonMatch = raw.match(/```json\n?([\s\S]*?)\n?```/) ?? raw.match(/(\{[\s\S]*\})/)
|
||||||
|
try {
|
||||||
|
return JSON.parse(jsonMatch ? jsonMatch[1] : raw) as T
|
||||||
|
} catch {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OpenRouterAIService: IAIService = {
|
||||||
|
async parseNeed(input: string): Promise<ItemResponse<ParseNeedResult>> {
|
||||||
|
const config = getConfig()
|
||||||
|
if (!config) return MockAIService.parseNeed(input)
|
||||||
|
|
||||||
|
const { system, user } = buildNeedParsingPrompt({ userInput: input })
|
||||||
|
const raw = await chat(config, system, user)
|
||||||
|
const parsed = parseJSON(raw, null)
|
||||||
|
// If parse fails fall back to mock (keeps app working even with bad AI responses)
|
||||||
|
if (!parsed) return MockAIService.parseNeed(input)
|
||||||
|
return MockAIService.parseNeed(input) // TODO: map parsed JSON → ParseNeedResult shape
|
||||||
|
},
|
||||||
|
|
||||||
|
async generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise<ItemResponse<FollowUpQuestion[]>> {
|
||||||
|
const config = getConfig()
|
||||||
|
if (!config) return MockAIService.generateFollowUpQuestions(criteria)
|
||||||
|
// TODO: implement OpenRouter call using followUpQuestionsPrompt
|
||||||
|
return MockAIService.generateFollowUpQuestions(criteria)
|
||||||
|
},
|
||||||
|
|
||||||
|
async summarizeComparison(items: UnifiedMatchResult[]): Promise<ItemResponse<ComparisonSummary>> {
|
||||||
|
const config = getConfig()
|
||||||
|
if (!config) return MockAIService.summarizeComparison(items)
|
||||||
|
|
||||||
|
const properties = items
|
||||||
|
.filter(i => i.resultType !== 'FUTURE_AVAILABILITY')
|
||||||
|
.map(i => ({
|
||||||
|
title: (i as Record<string, unknown> & { property?: { title?: string } }).property?.title ?? 'Unbekannt',
|
||||||
|
matchScore: i.matchScore,
|
||||||
|
city: (i as Record<string, unknown> & { property?: { location?: { city?: string } } }).property?.location?.city ?? '–',
|
||||||
|
rentPerSqm: (i as Record<string, unknown> & { property?: { rentPricePerSqm?: number } }).property?.rentPricePerSqm ?? 0,
|
||||||
|
positiveFactors: i.match.positiveFactors.slice(0, 2).map(f => f.explanation ?? f.label),
|
||||||
|
negativeFactors: i.match.negativeFactors.slice(0, 2).map(f => f.explanation ?? f.label),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { system, user } = buildCompareSummaryPrompt({ properties })
|
||||||
|
const raw = await chat(config, system, user)
|
||||||
|
const parsed = parseJSON<Partial<ComparisonSummary>>(raw, {})
|
||||||
|
if (!parsed.overallAssessment) return MockAIService.summarizeComparison(items)
|
||||||
|
// Merge AI overallAssessment into mock baseline
|
||||||
|
const mock = await MockAIService.summarizeComparison(items)
|
||||||
|
return { data: { ...mock.data, overallAssessment: parsed.overallAssessment, recommendation: parsed.recommendation ?? mock.data.recommendation } }
|
||||||
|
},
|
||||||
|
|
||||||
|
async generateDecisionBrief(shortlistId: string): Promise<ItemResponse<DecisionBrief>> {
|
||||||
|
const config = getConfig()
|
||||||
|
if (!config) return MockAIService.generateDecisionBrief(shortlistId)
|
||||||
|
// TODO: pass real shortlist items via context when available
|
||||||
|
const { system, user } = buildDecisionBriefPrompt({ shortlistItems: [], needSummary: shortlistId })
|
||||||
|
const raw = await chat(config, system, user)
|
||||||
|
const parsed = parseJSON<Partial<DecisionBrief>>(raw, {})
|
||||||
|
if (!parsed.summary) return MockAIService.generateDecisionBrief(shortlistId)
|
||||||
|
const mock = await MockAIService.generateDecisionBrief(shortlistId)
|
||||||
|
return { data: { ...mock.data, summary: parsed.summary, sections: parsed.sections ?? mock.data.sections } }
|
||||||
|
},
|
||||||
|
|
||||||
|
async generateOfferEmail(payload: OfferEmailPayload): Promise<ItemResponse<{ subject: string; body: string }>> {
|
||||||
|
const config = getConfig()
|
||||||
|
if (!config) return MockAIService.generateOfferEmail(payload)
|
||||||
|
// TODO: implement OpenRouter call
|
||||||
|
return MockAIService.generateOfferEmail(payload)
|
||||||
|
},
|
||||||
|
|
||||||
|
async extractCriteria(input: string): Promise<ItemResponse<CriteriaExtractionResult>> {
|
||||||
|
const config = getConfig()
|
||||||
|
if (!config) return MockAIService.extractCriteria(input)
|
||||||
|
return MockAIService.extractCriteria(input)
|
||||||
|
},
|
||||||
|
|
||||||
|
async generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<ItemResponse<string[]>> {
|
||||||
|
const config = getConfig()
|
||||||
|
if (!config) return MockAIService.generateFollowUp(partialNeed)
|
||||||
|
return MockAIService.generateFollowUp(partialNeed)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
export interface CompareSummaryPromptInput {
|
||||||
|
properties: Array<{
|
||||||
|
title: string
|
||||||
|
matchScore: number
|
||||||
|
city: string
|
||||||
|
rentPerSqm: number
|
||||||
|
positiveFactors: string[]
|
||||||
|
negativeFactors: string[]
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCompareSummaryPrompt(input: CompareSummaryPromptInput): { system: string; user: string } {
|
||||||
|
const propertyList = input.properties
|
||||||
|
.map((p, i) => `Option ${i + 1}: ${p.title} (${p.city}) — Score: ${p.matchScore}%, CHF ${p.rentPerSqm}/m², Stärken: ${p.positiveFactors.join(', ')}, Risiken: ${p.negativeFactors.join(', ')}`)
|
||||||
|
.join('\n')
|
||||||
|
|
||||||
|
return {
|
||||||
|
system: `Du bist Entscheidungsassistent für Gewerbeimmobilien-Mieter. Erstelle eine präzise Vergleichsanalyse auf Deutsch als valides JSON mit den Feldern: overallAssessment, strongestOption, recommendation.`,
|
||||||
|
user: `Vergleiche folgende Objekte und gib eine strukturierte Empfehlung:\n\n${propertyList}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export interface DecisionBriefPromptInput {
|
||||||
|
shortlistItems: Array<{
|
||||||
|
title: string
|
||||||
|
city: string
|
||||||
|
matchScore: number
|
||||||
|
areaSqm: number
|
||||||
|
rentPerSqm: number
|
||||||
|
topReasons: string[]
|
||||||
|
}>
|
||||||
|
needSummary: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDecisionBriefPrompt(input: DecisionBriefPromptInput): { system: string; user: string } {
|
||||||
|
const itemList = input.shortlistItems
|
||||||
|
.map(i => `- ${i.title} (${i.city}): Score ${i.matchScore}%, ${i.areaSqm}m², CHF ${i.rentPerSqm}/m² — ${i.topReasons.join(', ')}`)
|
||||||
|
.join('\n')
|
||||||
|
|
||||||
|
return {
|
||||||
|
system: `Du bist Senior Real Estate Advisor. Erstelle ein strukturiertes Entscheidungs-Briefing auf Deutsch als JSON mit: summary, sections (Zusammenfassung, Standortbewertung, Budgetanalyse, Empfohlene nächste Schritte).`,
|
||||||
|
user: `Erstelle ein Entscheidungs-Briefing für folgende Shortlist:\n\nSuchprofil: ${input.needSummary}\n\nObjekte:\n${itemList}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export interface MatchExplanationPromptInput {
|
||||||
|
propertyTitle: string
|
||||||
|
propertyCity: string
|
||||||
|
matchScore: number
|
||||||
|
positiveFactors: Array<{ criterion: string; explanation: string }>
|
||||||
|
negativeFactors: Array<{ criterion: string; explanation: string }>
|
||||||
|
needSummary: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMatchExplanationPrompt(input: MatchExplanationPromptInput): { system: string; user: string } {
|
||||||
|
return {
|
||||||
|
system: `Du bist ein Experte für Schweizer Gewerbeimmobilien. Erkläre Match-Ergebnisse präzise und entscheidungsorientiert auf Deutsch. Maximal 3 Sätze.`,
|
||||||
|
user: `Erkläre warum das Objekt "${input.propertyTitle}" in ${input.propertyCity} einen Match Score von ${input.matchScore}% hat.
|
||||||
|
|
||||||
|
Stärken: ${input.positiveFactors.map(f => f.explanation).join(', ')}
|
||||||
|
Schwächen: ${input.negativeFactors.map(f => f.explanation).join(', ')}
|
||||||
|
Suchprofil: ${input.needSummary}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export interface NeedParsingPromptInput {
|
||||||
|
userInput: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildNeedParsingPrompt(input: NeedParsingPromptInput): { system: string; user: string } {
|
||||||
|
return {
|
||||||
|
system: `Du bist ein Experte für Schweizer Gewerbeimmobilien. Extrahiere strukturierte Suchanforderungen aus natürlichsprachigen Texten.
|
||||||
|
|
||||||
|
Antworte immer als valides JSON mit folgendem Schema:
|
||||||
|
{
|
||||||
|
"assetType": "OFFICE" | "RETAIL" | "LOGISTICS" | "PRODUCTION" | "GASTRO" | null,
|
||||||
|
"areaRange": { "min": number, "max": number } | null,
|
||||||
|
"preferredLocations": string[],
|
||||||
|
"budgetRange": { "maxPerSqm": number, "currency": "CHF" } | null,
|
||||||
|
"timing": { "earliestMoveIn": "YYYY-MM-DD", "latestMoveIn": "YYYY-MM-DD", "flexibleTiming": boolean } | null,
|
||||||
|
"mustHaveCriteria": string[],
|
||||||
|
"missingFields": string[],
|
||||||
|
"assumptions": string[]
|
||||||
|
}`,
|
||||||
|
user: `Analysiere folgende Suchanfrage und extrahiere alle relevanten Kriterien:\n\n${input.userInput}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
-754
@@ -1,754 +1,19 @@
|
|||||||
import type { ItemResponse, ServiceError } from './types'
|
/**
|
||||||
import { ServiceErrorCode } from './types'
|
* Backward-compatible barrel re-export.
|
||||||
import type { CreateNeedInput } from '../domain/need'
|
*
|
||||||
import type { AssetType } from '../domain/enums'
|
* All existing import paths (e.g. `import { aiService } from '../../services/aiService'`)
|
||||||
import type { ParseNeedResult, ParsedNeedCriteria, FollowUpQuestion } from '../domain/needBuilder'
|
* continue to work without any change in the call sites.
|
||||||
import type { UnifiedMatchResult } from '../domain/unifiedResult'
|
*
|
||||||
|
* To add new AI features: extend IAIService and implement in MockAIService / OpenRouterAIService.
|
||||||
// ── Decision Brief ────────────────────────────────────────────────────────────
|
*/
|
||||||
|
export { aiService, MockAIService, OpenRouterAIService } from './ai'
|
||||||
export interface DecisionBrief {
|
export type { IAIService } from './ai'
|
||||||
id: string
|
export type {
|
||||||
shortlistId: string
|
DecisionBrief,
|
||||||
summary: string
|
ComparisonSummary,
|
||||||
sections: { title: string; body: string }[]
|
CriteriaExtractionResult,
|
||||||
generatedAt: string
|
AIServiceProvider,
|
||||||
isDraft: true
|
ParsedListingData,
|
||||||
}
|
OfferEmailPayload,
|
||||||
|
} from './ai/IAIService'
|
||||||
function buildMockDecisionBrief(shortlistId: string): DecisionBrief {
|
export { parseListingText } from './ai/mock/listingParser'
|
||||||
return {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
shortlistId,
|
|
||||||
summary: 'Die Shortlist enthält qualitativ hochwertige Matches mit starker Standortübereinstimmung. Die verfügbaren Flächen decken den Bedarf gut ab. Zwei Objekte eignen sich als Erstbesichtigungen.',
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
title: 'Zusammenfassung der Objekte',
|
|
||||||
body: 'Die Shortlist umfasst mehrere Objekte aus dem verifizierten Portfolio. Die Matchscores liegen zwischen 74 und 88, was auf eine gute bis sehr gute Übereinstimmung mit den Suchkriterien hinweist.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Standortbewertung',
|
|
||||||
body: 'Die Mehrheit der Objekte befindet sich in bevorzugten Lagen. Die ÖV-Anbindung ist bei allen Objekten als gut bis sehr gut einzustufen.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Budgetanalyse',
|
|
||||||
body: 'Die Mietpreise liegen im budgetkonformen Bereich. Keine der Optionen überschreitet das maximale Budget pro m².',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Empfohlene nächste Schritte',
|
|
||||||
body: '1. Besichtigung der Top-2-Objekte vereinbaren. 2. Detaillierte Flächenpläne anfordern. 3. Vertragskonditionen prüfen lassen.',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
generatedAt: new Date().toISOString(),
|
|
||||||
isDraft: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Compare Summary ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface ComparisonSummary {
|
|
||||||
strongestOption: { matchId: string; label: string; reason: string }
|
|
||||||
bestValue: { matchId: string; label: string; reason: string } | null
|
|
||||||
highestConfidence: { matchId: string; label: string; confidenceLevel: number }
|
|
||||||
biggestTradeoffs: string[]
|
|
||||||
missingDataWarnings: string[]
|
|
||||||
recommendedNextStep: string
|
|
||||||
overallAssessment: string
|
|
||||||
perPropertyAssessment: Array<{
|
|
||||||
matchId: string
|
|
||||||
label: string
|
|
||||||
strengths: string[]
|
|
||||||
weaknesses: string[]
|
|
||||||
bestFor: string
|
|
||||||
keyRisk: string | null
|
|
||||||
}>
|
|
||||||
recommendation: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildComparisonSummary(items: UnifiedMatchResult[]): ComparisonSummary {
|
|
||||||
const getTitle = (item: UnifiedMatchResult) =>
|
|
||||||
item.resultType !== 'FUTURE_AVAILABILITY'
|
|
||||||
? (item as any).property?.title ?? `Match ${item.matchScore}`
|
|
||||||
: (item as any).signal?.companyName ?? 'Zukunftssignal'
|
|
||||||
|
|
||||||
if (items.length === 0) {
|
|
||||||
return {
|
|
||||||
strongestOption: { matchId: '', label: '–', reason: 'Keine Ergebnisse' },
|
|
||||||
bestValue: null,
|
|
||||||
highestConfidence: { matchId: '', label: '–', confidenceLevel: 0 },
|
|
||||||
biggestTradeoffs: [],
|
|
||||||
missingDataWarnings: [],
|
|
||||||
recommendedNextStep: 'Suchergebnisse überprüfen',
|
|
||||||
overallAssessment: 'Keine Ergebnisse für die Analyse verfügbar.',
|
|
||||||
perPropertyAssessment: [],
|
|
||||||
recommendation: 'Suchergebnisse überprüfen und erneut versuchen.',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const strongest = items.reduce((a, b) => a.matchScore > b.matchScore ? a : b)
|
|
||||||
|
|
||||||
const propertyItems = items.filter(i => i.resultType !== 'FUTURE_AVAILABILITY')
|
|
||||||
const bestValue = propertyItems.length > 0
|
|
||||||
? propertyItems.reduce((a, b) =>
|
|
||||||
((a as any).property?.rentPricePerSqm ?? Infinity) <= ((b as any).property?.rentPricePerSqm ?? Infinity) ? a : b
|
|
||||||
)
|
|
||||||
: null
|
|
||||||
|
|
||||||
const highestConf = items.reduce((a, b) =>
|
|
||||||
a.match.confidenceLevel >= b.match.confidenceLevel ? a : b
|
|
||||||
)
|
|
||||||
|
|
||||||
const tradeoffs = items
|
|
||||||
.flatMap(i => i.match.tradeoffs?.slice(0, 1).map(t => `${getTitle(i)}: ${t.concern}`) ?? [])
|
|
||||||
.slice(0, 3)
|
|
||||||
|
|
||||||
const missingWarnings = items
|
|
||||||
.filter(i => (i.match.missingData?.filter(m => m.importance === 'CRITICAL').length ?? 0) > 0)
|
|
||||||
.map(i => `${getTitle(i)}: fehlende Pflichtfelder`)
|
|
||||||
|
|
||||||
const topNextAction = strongest.match.nextBestActions?.[0]?.label ?? 'Objekt besichtigen oder Details prüfen'
|
|
||||||
|
|
||||||
// Overall assessment
|
|
||||||
const labels = items.map(getTitle)
|
|
||||||
const scoreLeader = getTitle(strongest)
|
|
||||||
const priceLeader = bestValue ? getTitle(bestValue) : null
|
|
||||||
let overallAssessment: string
|
|
||||||
if (items.length === 1) {
|
|
||||||
overallAssessment = `${labels[0]} erfüllt die Kernkriterien mit einem Match Score von ${strongest.matchScore}/100. Eine Vergleichsbasis fehlt — weitere Optionen hinzufügen für eine fundierte Entscheidung.`
|
|
||||||
} else if (priceLeader && priceLeader !== scoreLeader) {
|
|
||||||
overallAssessment = `Beide Optionen erfüllen Standort und Fläche gut. ${scoreLeader} führt beim Match Score und Prestige, ${priceLeader} beim Preis-Leistungs-Verhältnis.`
|
|
||||||
} else {
|
|
||||||
overallAssessment = `${scoreLeader} dominiert in den meisten Kriterien mit dem höchsten Match Score (${strongest.matchScore}/100). Die Optionen unterscheiden sich hauptsächlich in Lage und Ausstattung.`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-property assessment
|
|
||||||
const deriveBestFor = (item: UnifiedMatchResult): string => {
|
|
||||||
const assetType = (item as any).property?.assetType ?? ''
|
|
||||||
const score = item.matchScore
|
|
||||||
const hasPosPrestige = item.match.positiveFactors.some(f =>
|
|
||||||
f.criterion.toLowerCase().includes('prestige') || f.criterion.toLowerCase().includes('location')
|
|
||||||
)
|
|
||||||
const hasLowPrice = bestValue?.matchId === item.matchId
|
|
||||||
if (hasPosPrestige && score >= 80) return 'Repräsentationsbedarf mit Prestige-Anforderungen'
|
|
||||||
if (hasLowPrice) return 'Kostenoptimierte Wachstumsphase'
|
|
||||||
if (assetType === 'LOGISTICS' || assetType === 'PRODUCTION') return 'Operative Effizienz und Flächenflexibilität'
|
|
||||||
if (assetType === 'RETAIL') return 'Hohe Kundenfrequenz und Sichtbarkeit'
|
|
||||||
if (score >= 80) return 'Anspruchsvolle Anforderungen mit hoher Matchqualität'
|
|
||||||
return 'Ausgewogenes Preis-Leistungs-Profil'
|
|
||||||
}
|
|
||||||
|
|
||||||
const perPropertyAssessment = items.map(item => {
|
|
||||||
const topStrengths = item.match.positiveFactors
|
|
||||||
.slice(0, 2)
|
|
||||||
.map(f => f.explanation)
|
|
||||||
const topWeaknesses = item.match.negativeFactors.length > 0
|
|
||||||
? item.match.negativeFactors.slice(0, 2).map(f => f.explanation)
|
|
||||||
: ['Keine kritischen Schwächen identifiziert']
|
|
||||||
const keyRisk = item.match.risks?.[0]?.description ?? null
|
|
||||||
return {
|
|
||||||
matchId: item.matchId,
|
|
||||||
label: getTitle(item),
|
|
||||||
strengths: topStrengths,
|
|
||||||
weaknesses: topWeaknesses,
|
|
||||||
bestFor: deriveBestFor(item),
|
|
||||||
keyRisk,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Recommendation
|
|
||||||
const recommendation = items.length >= 2
|
|
||||||
? `Besichtigung beider Objekte empfohlen — danach Entscheidung auf Basis Mietvertragslaufzeit und Ausbaustandard.`
|
|
||||||
: `Besichtigung von ${getTitle(strongest)} empfohlen — anschliessend Vertragskonditionen und Laufzeit prüfen.`
|
|
||||||
|
|
||||||
return {
|
|
||||||
strongestOption: {
|
|
||||||
matchId: strongest.matchId,
|
|
||||||
label: getTitle(strongest),
|
|
||||||
reason: `Höchster Match Score (${strongest.matchScore}/100)`,
|
|
||||||
},
|
|
||||||
bestValue: bestValue
|
|
||||||
? {
|
|
||||||
matchId: bestValue.matchId,
|
|
||||||
label: getTitle(bestValue),
|
|
||||||
reason: `Niedrigster Mietpreis (CHF ${(bestValue as any).property?.rentPricePerSqm ?? '–'}/m²)`,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
highestConfidence: {
|
|
||||||
matchId: highestConf.matchId,
|
|
||||||
label: getTitle(highestConf),
|
|
||||||
confidenceLevel: highestConf.match.confidenceLevel,
|
|
||||||
},
|
|
||||||
biggestTradeoffs: tradeoffs,
|
|
||||||
missingDataWarnings: missingWarnings,
|
|
||||||
recommendedNextStep: topNextAction,
|
|
||||||
overallAssessment,
|
|
||||||
perPropertyAssessment,
|
|
||||||
recommendation,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Legacy types (kept for backward compatibility) ────────────────────────────
|
|
||||||
|
|
||||||
export interface CriteriaExtractionResult {
|
|
||||||
extractedCriteria: Partial<CreateNeedInput>
|
|
||||||
confidence: number
|
|
||||||
missingFields: string[]
|
|
||||||
assumptions: string[]
|
|
||||||
followUpQuestions: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AIServiceProvider {
|
|
||||||
extractCriteria(naturalLanguageInput: string): Promise<CriteriaExtractionResult>
|
|
||||||
generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<string[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Mock parse logic ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function mockParseNeed(input: string): ParseNeedResult {
|
|
||||||
const lower = input.toLowerCase()
|
|
||||||
|
|
||||||
// Asset type — RETAIL before LOGISTICS to avoid false match on "Nebenräume (Lager)"
|
|
||||||
const assetType: AssetType | undefined =
|
|
||||||
lower.includes('retail') || lower.includes('laden') || lower.includes('shop')
|
|
||||||
|| lower.includes('verkaufslokal') || lower.includes('ladenlokal') || lower.includes('ladenfläche')
|
|
||||||
|| lower.includes('verkaufsfläche') || lower.includes('schaufenster') ? 'RETAIL'
|
|
||||||
: lower.includes('büro') || lower.includes('office') || lower.includes('sitzungszimmer') || lower.includes('arbeitsplätze') ? 'OFFICE'
|
|
||||||
: lower.includes('logistik') || lower.includes('lagerhalle') || lower.includes('lagerraum')
|
|
||||||
|| (lower.includes('lager') && (lower.includes('logistik') || lower.includes('rampe') || lower.includes('palette') || lower.includes('lkw'))) ? 'LOGISTICS'
|
|
||||||
: lower.includes('lager') ? 'LOGISTICS'
|
|
||||||
: lower.includes('produktion') || lower.includes('gewerbe') || lower.includes('industrie') ? 'PRODUCTION'
|
|
||||||
: lower.includes('gastro') || lower.includes('restaurant') ? 'GASTRO'
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
// Area
|
|
||||||
const areaRangeMatch = input.match(/(\d+)\s*[–\-–]\s*(\d+)\s*m[²2]/i)
|
|
||||||
const areaSingleMatch = input.match(/(\d{3,5})\s*m[²2]/i)
|
|
||||||
let areaRange: { min: number; max: number } | undefined
|
|
||||||
let areaConfidence = 0.25
|
|
||||||
if (areaRangeMatch) {
|
|
||||||
areaRange = { min: parseInt(areaRangeMatch[1]), max: parseInt(areaRangeMatch[2]) }
|
|
||||||
areaConfidence = 0.95
|
|
||||||
} else if (areaSingleMatch) {
|
|
||||||
const base = parseInt(areaSingleMatch[1])
|
|
||||||
areaRange = { min: Math.round(base * 0.8), max: Math.round(base * 1.2) }
|
|
||||||
areaConfidence = 0.70
|
|
||||||
}
|
|
||||||
|
|
||||||
// Locations — use word boundaries to avoid false matches like "Umzugsfirma" → "Zug"
|
|
||||||
const CITIES: [string, string][] = [
|
|
||||||
['zürich', 'Zürich'], ['basel', 'Basel'], ['bern', 'Bern'], ['genf', 'Genf'],
|
|
||||||
['lausanne', 'Lausanne'], ['winterthur', 'Winterthur'], ['zug', 'Zug'],
|
|
||||||
['luzern', 'Luzern'], ['st. gallen', 'St. Gallen'], ['lugano', 'Lugano'],
|
|
||||||
['biel', 'Biel'], ['schaffhausen', 'Schaffhausen'],
|
|
||||||
['wädenswil', 'Wädenswil'], ['thalwil', 'Thalwil'], ['uster', 'Uster'],
|
|
||||||
['horgen', 'Horgen'], ['küsnacht', 'Küsnacht'], ['baar', 'Baar'],
|
|
||||||
]
|
|
||||||
const preferredLocations = CITIES.filter(([k]) => {
|
|
||||||
if (k.includes(' ') || k.includes('.')) return lower.includes(k)
|
|
||||||
// Word boundary: not preceded/followed by a letter (including German umlauts)
|
|
||||||
const re = new RegExp(`(?<![a-zA-ZäöüÄÖÜß])${k}(?![a-zA-ZäöüÄÖÜß])`)
|
|
||||||
return re.test(lower)
|
|
||||||
}).map(([, v]) => v)
|
|
||||||
|
|
||||||
// Infer Zürich when Zürich-specific districts or landmarks are mentioned
|
|
||||||
const ZURICH_SIGNALS = [
|
|
||||||
'seefeld', 'bellevue', 'paradeplatz', 'bahnhofstrasse', 'zürich-west', 'zürich west',
|
|
||||||
'oerlikon', 'altstetten', 'kreis 1', 'kreis 2', 'kreis 3', 'kreis 4', 'kreis 5',
|
|
||||||
'kreis 6', 'kreis 7', 'kreis 8', 'langstrasse', 'hardbrücke', 'freilager',
|
|
||||||
'europaallee', 'zürich nord', 'zürich süd',
|
|
||||||
]
|
|
||||||
if (!preferredLocations.includes('Zürich') && ZURICH_SIGNALS.some(s => lower.includes(s))) {
|
|
||||||
preferredLocations.push('Zürich')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Contextual Zürich inference: only truly Zürich-specific place/brand names
|
|
||||||
const ZURICH_ONLY_SIGNALS = [
|
|
||||||
'industrie groove', 'industrie-groove',
|
|
||||||
'pfingstweidstrasse', 'hardbrücke', 'freilager', 'europaallee',
|
|
||||||
'zürich-west', 'zürich west', 'zürich nord', 'zürich süd',
|
|
||||||
'hürlimann', 'viadukt', 'schiffbau',
|
|
||||||
]
|
|
||||||
if (preferredLocations.length === 0 && ZURICH_ONLY_SIGNALS.some(s => lower.includes(s))) {
|
|
||||||
preferredLocations.push('Zürich')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract district list from patterns like "Kreis 3, 4, 5, und 8" or "Kreis 3/4/5"
|
|
||||||
const kreisListMatch = lower.match(/\bkreis\s+([\d]+(?:\s*[,\/]\s*[\d]+)*(?:\s+und\s+[\d]+)?)/)
|
|
||||||
if (kreisListMatch) {
|
|
||||||
const nums = kreisListMatch[1].match(/\d+/g) ?? []
|
|
||||||
nums.forEach(n => {
|
|
||||||
const k = `Zürich Kreis ${n}`
|
|
||||||
if (!preferredLocations.includes(k)) preferredLocations.push(k)
|
|
||||||
})
|
|
||||||
if (!preferredLocations.includes('Zürich')) preferredLocations.push('Zürich')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map Zürich landmark/neighbourhood names → district-level location entries
|
|
||||||
const ZURICH_DISTRICT_MAP: [string, string][] = [
|
|
||||||
['seefeld', 'Zürich Seefeld'], ['bellevue', 'Zürich Seefeld'],
|
|
||||||
['bahnhofstrasse', 'Zürich Kreis 1'], ['paradeplatz', 'Zürich Kreis 1'],
|
|
||||||
['zürich-west', 'Zürich-West'], ['pfingstweidstrasse', 'Zürich-West'],
|
|
||||||
['limmatstrasse', 'Zürich-West'],
|
|
||||||
]
|
|
||||||
ZURICH_DISTRICT_MAP.forEach(([k, v]) => {
|
|
||||||
if (lower.includes(k) && !preferredLocations.includes(v)) preferredLocations.push(v)
|
|
||||||
})
|
|
||||||
|
|
||||||
const locationConfidence = preferredLocations.length > 0 ? 0.88 : 0.15
|
|
||||||
|
|
||||||
// Budget — strip Swiss price notation (320.--) before matching
|
|
||||||
const cleanedBudget = input.replace(/(\d)\.-+/g, '$1').replace(/(\d)\.—/g, '$1')
|
|
||||||
// "MZ 320/m²", "CHF 320/m²", "320/m²", "320.--/m²/a"
|
|
||||||
const budgetPerSqmMatch = cleanedBudget.match(/(?:mz|mietzins|max\.?|budget)?\s*(?:CHF\s*)?(\d{2,4})\s*\/\s*m[²2]/i)
|
|
||||||
// Range "250 – 280 CHF" or "250-280/m²" — require explicit currency or /m² so area ranges like "120–150m2" are not matched
|
|
||||||
const budgetRangeMatch =
|
|
||||||
cleanedBudget.match(/(\d{2,4})\s*[–\-]\s*(\d{2,4})\s*(?:CHF|Fr\.?)\b/i) ??
|
|
||||||
cleanedBudget.match(/(?:CHF|Fr\.?)\s*(\d{2,4})\s*[–\-]\s*(\d{2,4})/i) ??
|
|
||||||
cleanedBudget.match(/(\d{2,4})\s*[–\-]\s*(\d{2,4})\s*\/\s*m[²2]/i)
|
|
||||||
const budgetMaxMatch = input.match(/(?:max\.?|bis|höchstens)\s*(?:CHF\s*)?(\d+)/i)
|
|
||||||
let budgetRange: { maxPerSqm: number; currency: string } | undefined
|
|
||||||
let budgetConfidence = 0.20
|
|
||||||
// Monthly-to-annual threshold: Swiss retail quotes monthly (150/m²/mon → 1800/year),
|
|
||||||
// office/logistics quote monthly too but at lower values (45/m²/mon → 540/year).
|
|
||||||
const monthlyThreshold = assetType === 'RETAIL' ? 500 : 150
|
|
||||||
if (budgetPerSqmMatch) {
|
|
||||||
const raw = parseInt(budgetPerSqmMatch[1])
|
|
||||||
budgetRange = { maxPerSqm: raw < monthlyThreshold ? raw * 12 : raw, currency: 'CHF' }
|
|
||||||
budgetConfidence = 0.92
|
|
||||||
} else if (budgetRangeMatch) {
|
|
||||||
// Take upper value of range as max
|
|
||||||
const raw = parseInt(budgetRangeMatch[2])
|
|
||||||
budgetRange = { maxPerSqm: raw < monthlyThreshold ? raw * 12 : raw, currency: 'CHF' }
|
|
||||||
budgetConfidence = 0.75
|
|
||||||
} else if (budgetMaxMatch) {
|
|
||||||
const raw = parseInt(budgetMaxMatch[1])
|
|
||||||
budgetRange = { maxPerSqm: raw < monthlyThreshold ? raw * 12 : raw, currency: 'CHF' }
|
|
||||||
budgetConfidence = 0.60
|
|
||||||
}
|
|
||||||
|
|
||||||
// Timing
|
|
||||||
const yearMatch = input.match(/(?:ab\s+)?(?:Q[1-4]\s*\/?\s*)?(20\d{2})/)
|
|
||||||
// "Q4/26" or "Q1/27" — two-digit year (collect all occurrences, use first as earliest)
|
|
||||||
const quarterMatches = [...input.matchAll(/Q([1-4])\s*[\/\-]?\s*(\d{2})\b/gi)]
|
|
||||||
const soonMatch = lower.includes('sofort') || lower.includes('asap')
|
|
||||||
let timing: ParsedNeedCriteria['timing'] | undefined
|
|
||||||
let timingConfidence = 0.20
|
|
||||||
if (soonMatch) {
|
|
||||||
timing = { earliestMoveIn: '2025-07-01', latestMoveIn: '2025-10-01', flexibleTiming: false }
|
|
||||||
timingConfidence = 0.85
|
|
||||||
} else if (yearMatch) {
|
|
||||||
timing = { earliestMoveIn: `${yearMatch[1]}-01-01`, latestMoveIn: `${yearMatch[1]}-12-31`, flexibleTiming: lower.includes('flexibel') }
|
|
||||||
timingConfidence = 0.75
|
|
||||||
} else if (quarterMatches.length > 0) {
|
|
||||||
// Use first Q as earliest, last Q as latest (6-month window minimum)
|
|
||||||
const qStartMonth = [0, 1, 4, 7, 10] // [unused, Q1, Q2, Q3, Q4]
|
|
||||||
const first = quarterMatches[0]
|
|
||||||
const last = quarterMatches[quarterMatches.length - 1]
|
|
||||||
const yr1 = 2000 + parseInt(first[2])
|
|
||||||
const yr2 = 2000 + parseInt(last[2])
|
|
||||||
const q1 = parseInt(first[1])
|
|
||||||
const q2 = parseInt(last[1])
|
|
||||||
const startM = String(qStartMonth[q1]).padStart(2, '0')
|
|
||||||
const endM = String(Math.min(12, qStartMonth[q2] + 2)).padStart(2, '0')
|
|
||||||
timing = {
|
|
||||||
earliestMoveIn: `${yr1}-${startM}-01`,
|
|
||||||
latestMoveIn: `${yr2}-${endM}-${endM === '12' ? '31' : '28'}`,
|
|
||||||
flexibleTiming: true,
|
|
||||||
}
|
|
||||||
timingConfidence = 0.70
|
|
||||||
}
|
|
||||||
|
|
||||||
// Must-haves
|
|
||||||
const mustHaveCriteria: string[] = []
|
|
||||||
if (lower.includes('öv') || lower.includes('bahnhof') || lower.includes('tram') || lower.includes('erschlossen')) mustHaveCriteria.push('Gute ÖV-Anbindung')
|
|
||||||
if (lower.includes('parking') || lower.includes('parkplatz') || lower.includes('tiefgarage') || lower.includes('stellplatz')) mustHaveCriteria.push('Parkplätze vorhanden')
|
|
||||||
if (lower.includes('klimaanlage') || lower.includes('klima')) mustHaveCriteria.push('Klimaanlage')
|
|
||||||
if (lower.includes('server') || lower.includes('rechenzentr')) mustHaveCriteria.push('Serverraum / IT-Infrastruktur')
|
|
||||||
if (lower.includes('barrierefrei')) mustHaveCriteria.push('Barrierefreiheit')
|
|
||||||
if (lower.includes('küche') || lower.includes('kantine')) mustHaveCriteria.push('Kantine / Küche')
|
|
||||||
if (lower.includes('laderampe') || lower.includes('rampe') || lower.includes('verladetor')) mustHaveCriteria.push('Laderampe')
|
|
||||||
if (lower.includes('schaufenster') || lower.includes('shopfenster') || lower.includes('vitrine')) mustHaveCriteria.push('Schaufenster')
|
|
||||||
if (lower.includes('verpflegung') || lower.includes('restaurant') || lower.includes('lunch') || lower.includes('mittagessen') || lower.includes('takeaway')) mustHaveCriteria.push('Verpflegungsmöglichkeiten')
|
|
||||||
if (lower.includes('startup') || lower.includes('start-up') || lower.includes('community') || lower.includes('coworking') || lower.includes('junge firmen') || lower.includes('jungunternehm')) mustHaveCriteria.push('Startup-Community / innovative Nachbarn')
|
|
||||||
if (lower.includes('wachsen') || lower.includes('expansion') || lower.includes('erweiterungsoption') || lower.includes('wachstum') || lower.includes('wachstumsoption') || lower.includes('flexibilität zum wachsen')) mustHaveCriteria.push('Erweiterungsoption vorhanden')
|
|
||||||
if (lower.includes('werkstatt') || lower.includes('atelier') || lower.includes('reparatur')) mustHaveCriteria.push('Werkstatt / Atelier')
|
|
||||||
|
|
||||||
// Structured requirement fields
|
|
||||||
const requireGroundFloor = lower.includes('erdgeschoss') || lower.includes('parterre')
|
|
||||||
|| lower.includes('schaufenster') || lower.includes('ladenlokal') || undefined as boolean | undefined
|
|
||||||
const requireAirConditioning = lower.includes('klimaanlage') || lower.includes('klimatisierung')
|
|
||||||
|| lower.includes('klima') || lower.includes('air conditioning')
|
|
||||||
|| lower.includes('kühlung') || undefined as boolean | undefined
|
|
||||||
const requireLoadingDock = lower.includes('laderampe') || lower.includes('verladerampe')
|
|
||||||
|| lower.includes('ladetor') || lower.includes('rampe') || undefined as boolean | undefined
|
|
||||||
const requireBarrierFree = lower.includes('barrierefrei') || lower.includes('rollstuhl')
|
|
||||||
|| lower.includes('iv-gerecht') || lower.includes('behindertengerecht') || undefined as boolean | undefined
|
|
||||||
|
|
||||||
const ceilingMatch = input.match(/(\d+(?:[.,]\d+)?)\s*m(?:eter)?\s*(?:deckenhöhe|hallenhöhe|lichte\s+höhe)/i)
|
|
||||||
?? input.match(/deckenhöhe\s+(?:mind\.?\s*)?(\d+(?:[.,]\d+)?)\s*m/i)
|
|
||||||
const minCeilingHeightM = ceilingMatch ? parseFloat(ceilingMatch[1].replace(',', '.')) : undefined
|
|
||||||
|
|
||||||
// Parking: "3-5 Parkplätze" → extract lower bound (minimum); "5 Parkplätze" → 5
|
|
||||||
const parkingRangeMatch = input.match(/(\d+)\s*[-–]\s*\d+\s*(?:parkplätze?|stellplätze?|pp\b)/i)
|
|
||||||
const parkingSingleMatch = input.match(/(?:mind(?:estens)?\.?\s+)?(\d+)\s*(?:parkplätze?|stellplätze?|pp\b)/i)
|
|
||||||
const requiredParkingMin = parkingRangeMatch ? parseInt(parkingRangeMatch[1])
|
|
||||||
: parkingSingleMatch ? parseInt(parkingSingleMatch[1]) : undefined
|
|
||||||
|
|
||||||
const fitOutStr: 'BASIC' | 'FULL' | 'PREMIUM' | undefined =
|
|
||||||
lower.includes('schlüsselfertig') || lower.includes('premium') || lower.includes('hochwertig')
|
|
||||||
|| lower.includes('top ausgebaut') || lower.includes('top-ausgebaut') ? 'PREMIUM'
|
|
||||||
: lower.includes('vollausbau') || lower.includes('vollständig ausgebaut') || lower.includes('ausgebaut')
|
|
||||||
|| lower.includes('ready to use') || lower.includes('reddy to use') || lower.includes('bezugsfertig') ? 'FULL'
|
|
||||||
: lower.includes('basisausbau') || lower.includes('rohbau') || lower.includes('einfach') ? 'BASIC'
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
// Contract duration: "7-jähriger Vertrag", "Laufzeit 7 Jahre", standalone "7 Jahre" at sentence start
|
|
||||||
// Exclude "in X Jahren", "X Jahre im Geschäft", "X Jahre Erfahrung" etc.
|
|
||||||
const contractMatch = input.match(/(\d+)[- ]?j[aä]hrige?(?:r)?\s+(?:vertrag|mietvertrag|laufzeit)/i)
|
|
||||||
?? input.match(/(?:vertrag|laufzeit|mietdauer).{0,20}(\d+)\s*jahre?/i)
|
|
||||||
?? input.match(/\b(\d+)\s*jahre?\b(?!\s*(?:erfahrung|planung|rendite|im\b|alt\b|alten|altes|jung))/i)
|
|
||||||
const minContractDurationMonths = contractMatch ? parseInt(contractMatch[1]) * 12 : undefined
|
|
||||||
|
|
||||||
// Soft
|
|
||||||
const prestigeImportance: 'LOW' | 'MEDIUM' | 'HIGH' | undefined =
|
|
||||||
lower.includes('prestige') || lower.includes('representativ') || lower.includes('repräsentativ')
|
|
||||||
|| lower.includes('topadresse') || lower.includes('top adresse') || lower.includes('innerstädtisch')
|
|
||||||
|| lower.includes('topaddresse') ? 'HIGH'
|
|
||||||
: lower.includes('standard') ? 'LOW'
|
|
||||||
: undefined
|
|
||||||
const parkingNeed = lower.includes('parking') || lower.includes('parkplatz')
|
|
||||||
const visibilityNeed: 'HIGH' | undefined = lower.includes('sichtbar') || lower.includes('passanten') ? 'HIGH' : undefined
|
|
||||||
const footfallNeed: 'HIGH' | undefined = lower.includes('frequenz') || lower.includes('laufkundschaft') ? 'HIGH' : undefined
|
|
||||||
|
|
||||||
// Missing fields
|
|
||||||
const missingFields: string[] = []
|
|
||||||
if (!assetType) missingFields.push('Nutzungstyp')
|
|
||||||
if (!areaRange) missingFields.push('Flächenbedarf')
|
|
||||||
if (preferredLocations.length === 0) missingFields.push('Standort')
|
|
||||||
if (!budgetRange) missingFields.push('Budget')
|
|
||||||
if (!timing) missingFields.push('Verfügbarkeitstermin')
|
|
||||||
|
|
||||||
// Assumptions
|
|
||||||
const assumptions: string[] = []
|
|
||||||
if (areaRange && areaSingleMatch && !areaRangeMatch) {
|
|
||||||
assumptions.push(`Flächenrange aus Einzelangabe (${areaSingleMatch[1]} m²) geschätzt — bitte prüfen`)
|
|
||||||
}
|
|
||||||
if (budgetRange && !budgetPerSqmMatch && budgetMaxMatch) {
|
|
||||||
assumptions.push('Budget als Pauschalangabe interpretiert — Angabe pro m² unklar')
|
|
||||||
}
|
|
||||||
if (!assetType) {
|
|
||||||
assumptions.push('Nutzungstyp konnte nicht eindeutig erkannt werden')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Confidence by field
|
|
||||||
const confidenceByField: Record<string, number> = {
|
|
||||||
assetType: assetType ? 0.92 : 0.20,
|
|
||||||
areaRange: areaConfidence,
|
|
||||||
preferredLocations: locationConfidence,
|
|
||||||
budgetRange: budgetConfidence,
|
|
||||||
timing: timingConfidence,
|
|
||||||
mustHaveCriteria: mustHaveCriteria.length > 0 ? 0.85 : 0.10,
|
|
||||||
prestigeImportance: prestigeImportance ? 0.80 : 0.20,
|
|
||||||
parkingNeed: parkingNeed ? 0.90 : 0.30,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Follow-up questions
|
|
||||||
const followUpQuestionCandidates: FollowUpQuestion[] = []
|
|
||||||
|
|
||||||
if (!assetType) {
|
|
||||||
followUpQuestionCandidates.push({
|
|
||||||
id: 'fq-asset-type',
|
|
||||||
questionText: 'Welchen Nutzungstyp suchen Sie?',
|
|
||||||
targetField: 'assetType',
|
|
||||||
reason: 'Der Nutzungstyp konnte nicht eindeutig erkannt werden.',
|
|
||||||
suggestedAnswerOptions: ['Büro', 'Logistik / Lager', 'Retail', 'Produktion', 'Gastro / F&B'],
|
|
||||||
importance: 'required',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (preferredLocations.length === 0) {
|
|
||||||
followUpQuestionCandidates.push({
|
|
||||||
id: 'fq-location',
|
|
||||||
questionText: 'In welcher Region oder Stadt suchen Sie?',
|
|
||||||
targetField: 'preferredLocations',
|
|
||||||
reason: 'Kein konkreter Standort angegeben.',
|
|
||||||
suggestedAnswerOptions: ['Zürich', 'Basel', 'Bern', 'Zug', 'Luzern', 'Genf'],
|
|
||||||
importance: 'required',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (!timing) {
|
|
||||||
followUpQuestionCandidates.push({
|
|
||||||
id: 'fq-timing',
|
|
||||||
questionText: 'Ab wann benötigen Sie die Fläche?',
|
|
||||||
targetField: 'timing',
|
|
||||||
reason: 'Kein Verfügbarkeitsdatum erkannt.',
|
|
||||||
suggestedAnswerOptions: ['Sofort', 'In 3 Monaten', 'In 6 Monaten', 'In 12 Monaten', 'Flexibel'],
|
|
||||||
importance: 'recommended',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (!budgetRange) {
|
|
||||||
followUpQuestionCandidates.push({
|
|
||||||
id: 'fq-budget',
|
|
||||||
questionText: 'Was ist Ihr Maximalbudget pro m² und Jahr?',
|
|
||||||
targetField: 'budgetRange',
|
|
||||||
reason: 'Kein Budget erkannt.',
|
|
||||||
suggestedAnswerOptions: ['< CHF 300/m²/J', 'CHF 300–420/m²/J', 'CHF 420–540/m²/J', '> CHF 540/m²/J', 'Flexibel'],
|
|
||||||
importance: 'recommended',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
followUpQuestionCandidates.push({
|
|
||||||
id: 'fq-parking',
|
|
||||||
questionText: 'Benötigen Sie Parkplätze vor Ort?',
|
|
||||||
targetField: 'parkingNeed',
|
|
||||||
reason: 'Angabe zu Parkplatzbedarf verbessert die Matchqualität.',
|
|
||||||
suggestedAnswerOptions: ['Ja, zwingend', 'Ja, wenn möglich', 'Nein'],
|
|
||||||
importance: 'optional',
|
|
||||||
})
|
|
||||||
|
|
||||||
// Special notes: apartment wish (UC-A type) + lease options (UC-C type)
|
|
||||||
const notesParts: string[] = []
|
|
||||||
const apartmentMatch = input.match(/(\d[.,]\d)\s*zimmer[- ]?wohn/i)
|
|
||||||
?? (lower.includes('zimmer') && lower.includes('wohnung') ? [''] : null)
|
|
||||||
if (apartmentMatch) {
|
|
||||||
const sizeHint = apartmentMatch[1] ? `${apartmentMatch[1]}-Zi-Wohnung` : 'Wohnung'
|
|
||||||
const rentMatch = input.match(/(\d['.\s]?\d{3})[.,\-\s]*(?:inkl|inkl\.)/i)
|
|
||||||
?? input.match(/(\d{4})[.,\-]\s*(?:chf|fr)?/i)
|
|
||||||
const rentHint = rentMatch ? ` max. CHF ${rentMatch[1].replace(/['\s]/g, "'")}` : ''
|
|
||||||
notesParts.push(`Wunsch: ${sizeHint} im Haus oder in der Nähe${rentHint} inkl. NK`)
|
|
||||||
}
|
|
||||||
const leaseOptionMatch = input.match(/(\d+)\s*[*×x]\s*(\d+)\s*jahre?\s*(?:echte\s*)?optione?n?/i)
|
|
||||||
if (leaseOptionMatch) {
|
|
||||||
notesParts.push(`Mietoption: ${leaseOptionMatch[1]}×${leaseOptionMatch[2]} Jahre echte Optionen gefordert`)
|
|
||||||
}
|
|
||||||
const notes = notesParts.length > 0 ? notesParts.join(' | ') : undefined
|
|
||||||
|
|
||||||
// Semantic signal flags used for weight boosting
|
|
||||||
const hasExpansionSignal = lower.includes('wachsen') || lower.includes('wachstum') || lower.includes('expansion') || lower.includes('erweiterung')
|
|
||||||
const hasCommunitySignal = lower.includes('startup') || lower.includes('community') || lower.includes('coworking') || lower.includes('junge firmen')
|
|
||||||
const hasPrestigeSignal = prestigeImportance === 'HIGH' || lower.includes('repräsentativ') || lower.includes('charakter') || lower.includes('beeindrucken')
|
|
||||||
const hasFlexSignal = lower.includes('flexibel') || lower.includes('wachsen') || hasCommunitySignal
|
|
||||||
|
|
||||||
// Suggested weights
|
|
||||||
const suggestedWeights: Record<string, number> = {
|
|
||||||
area: 0.18,
|
|
||||||
location: preferredLocations.length > 0 ? 0.25 : 0.20,
|
|
||||||
budget: budgetRange ? 0.20 : 0.16,
|
|
||||||
timing: timing ? 0.14 : 0.10,
|
|
||||||
prestige: hasPrestigeSignal ? 0.10 : 0.04,
|
|
||||||
accessibility: mustHaveCriteria.includes('Gute ÖV-Anbindung') ? 0.08 : 0.04,
|
|
||||||
expansionPotential: hasExpansionSignal ? 0.08 : 0.03,
|
|
||||||
flexibility: hasFlexSignal ? 0.08 : 0.03,
|
|
||||||
talentAccess: hasCommunitySignal ? 0.06 : 0.03,
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawSummary = `Bedarf analysiert: ${assetType ?? 'Typ unbekannt'} · ${preferredLocations.join(', ') || 'Standort unklar'} · ${areaRange ? `${areaRange.min}–${areaRange.max} m²` : 'Fläche unklar'} · ${budgetRange ? `max. CHF ${budgetRange.maxPerSqm}/m²` : 'Budget unklar'}`
|
|
||||||
|
|
||||||
return {
|
|
||||||
extractedCriteria: {
|
|
||||||
assetType,
|
|
||||||
areaRange,
|
|
||||||
preferredLocations,
|
|
||||||
budgetRange,
|
|
||||||
timing,
|
|
||||||
mustHaveCriteria,
|
|
||||||
infrastructureRequirements: [],
|
|
||||||
accessibilityRequirements: [],
|
|
||||||
prestigeImportance: hasPrestigeSignal ? 'HIGH' : prestigeImportance,
|
|
||||||
flexibilityNeed: hasFlexSignal ? 'HIGH' : 'MEDIUM',
|
|
||||||
expansionPotential: hasExpansionSignal,
|
|
||||||
parkingNeed,
|
|
||||||
visibilityNeed,
|
|
||||||
footfallNeed,
|
|
||||||
requireGroundFloor: requireGroundFloor || undefined,
|
|
||||||
requireAirConditioning: requireAirConditioning || undefined,
|
|
||||||
requireLoadingDock: requireLoadingDock || undefined,
|
|
||||||
requireBarrierFree: requireBarrierFree || undefined,
|
|
||||||
requiredParkingMin,
|
|
||||||
requiredFitOut: fitOutStr,
|
|
||||||
minCeilingHeightM,
|
|
||||||
minContractDurationMonths,
|
|
||||||
notes,
|
|
||||||
},
|
|
||||||
confidenceByField,
|
|
||||||
missingFields,
|
|
||||||
assumptions,
|
|
||||||
suggestedWeights,
|
|
||||||
followUpQuestionCandidates,
|
|
||||||
rawSummary,
|
|
||||||
promptVersion: 'mock-v1.0',
|
|
||||||
schemaVersion: '1.0.0',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Legacy mock provider (kept for backward compat) ───────────────────────────
|
|
||||||
|
|
||||||
const MockupAIServiceProvider: AIServiceProvider = {
|
|
||||||
async extractCriteria(_input: string): Promise<CriteriaExtractionResult> {
|
|
||||||
return {
|
|
||||||
extractedCriteria: {
|
|
||||||
companyName: 'Unbekannt (bitte bestätigen)',
|
|
||||||
requiredArea: { min: 400, max: 900 },
|
|
||||||
budgetRange: { maxPerSqm: 40, currency: 'CHF' },
|
|
||||||
},
|
|
||||||
confidence: 0.72,
|
|
||||||
missingFields: ['assetType', 'timing', 'preferredLocations'],
|
|
||||||
assumptions: ['Fläche aus Zahlenangabe geschätzt', 'Budget aus Kostennennung abgeleitet'],
|
|
||||||
followUpQuestions: [
|
|
||||||
'Welchen Nutzungstyp suchen Sie (Büro, Retail, Logistik)?',
|
|
||||||
'In welchen Städten oder Regionen suchen Sie?',
|
|
||||||
'Wann möchten Sie spätestens einziehen?',
|
|
||||||
],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<string[]> {
|
|
||||||
const questions: string[] = []
|
|
||||||
if (!partialNeed.assetType) questions.push('Welchen Nutzungstyp suchen Sie?')
|
|
||||||
if (!partialNeed.preferredLocations?.length) questions.push('In welchen Regionen suchen Sie?')
|
|
||||||
if (!partialNeed.timing) questions.push('Was ist Ihr gewünschter Einzugstermin?')
|
|
||||||
if (!partialNeed.budgetRange) questions.push('Was ist Ihr maximales monatliches Budget?')
|
|
||||||
return questions
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const provider = MockupAIServiceProvider
|
|
||||||
|
|
||||||
const notConfiguredError = (): ServiceError => ({
|
|
||||||
code: ServiceErrorCode.AI_GENERATION_FAILED,
|
|
||||||
message: 'OpenRouter nicht konfiguriert',
|
|
||||||
})
|
|
||||||
|
|
||||||
export const openRouterAIService: AIServiceProvider = {
|
|
||||||
async extractCriteria(_input: string): Promise<CriteriaExtractionResult> {
|
|
||||||
throw notConfiguredError()
|
|
||||||
},
|
|
||||||
async generateFollowUp(_partialNeed: Partial<CreateNeedInput>): Promise<string[]> {
|
|
||||||
throw notConfiguredError()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const aiService = {
|
|
||||||
// Legacy methods
|
|
||||||
async extractCriteria(input: string): Promise<ItemResponse<CriteriaExtractionResult>> {
|
|
||||||
const data = await provider.extractCriteria(input)
|
|
||||||
return { data }
|
|
||||||
},
|
|
||||||
async generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<ItemResponse<string[]>> {
|
|
||||||
const data = await provider.generateFollowUp(partialNeed)
|
|
||||||
return { data }
|
|
||||||
},
|
|
||||||
|
|
||||||
// F014: Compare summary
|
|
||||||
async summarizeComparison(items: UnifiedMatchResult[]): Promise<ItemResponse<ComparisonSummary>> {
|
|
||||||
await new Promise(r => setTimeout(r, 600))
|
|
||||||
return { data: buildComparisonSummary(items) }
|
|
||||||
},
|
|
||||||
|
|
||||||
// F008 methods
|
|
||||||
async parseNeed(input: string): Promise<ItemResponse<ParseNeedResult>> {
|
|
||||||
await new Promise(r => setTimeout(r, 300))
|
|
||||||
const data = mockParseNeed(input)
|
|
||||||
return { data }
|
|
||||||
},
|
|
||||||
async generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise<ItemResponse<FollowUpQuestion[]>> {
|
|
||||||
await new Promise(r => setTimeout(r, 600))
|
|
||||||
const result = mockParseNeed(JSON.stringify(criteria))
|
|
||||||
return { data: result.followUpQuestionCandidates }
|
|
||||||
},
|
|
||||||
|
|
||||||
async generateDecisionBrief(shortlistId: string): Promise<ItemResponse<DecisionBrief>> {
|
|
||||||
await new Promise(r => setTimeout(r, 1800))
|
|
||||||
return { data: buildMockDecisionBrief(shortlistId) }
|
|
||||||
},
|
|
||||||
|
|
||||||
async generateOfferEmail(payload: {
|
|
||||||
needTitle: string
|
|
||||||
properties: string[]
|
|
||||||
matchScores: number[]
|
|
||||||
}): Promise<{ data: { subject: string; body: string }; error: null }> {
|
|
||||||
await new Promise(r => setTimeout(r, 1200))
|
|
||||||
return {
|
|
||||||
data: {
|
|
||||||
subject: `Passende Gewerbeflächen zu Ihrer Anfrage: ${payload.needTitle}`,
|
|
||||||
body:
|
|
||||||
`Sehr geehrte Damen und Herren,\n\nvielen Dank für Ihr Interesse. Gerne unterbreiten wir Ihnen folgende passende Gewerbeobjekte aus unserem Portfolio:\n\n` +
|
|
||||||
payload.properties.map((p, i) => `• ${p} (Match-Score: ${payload.matchScores[i]}%)`).join('\n') +
|
|
||||||
`\n\nGerne arrangieren wir Besichtigungstermine für die genannten Objekte und stehen für alle weiteren Fragen zur Verfügung.\n\nFreundliche Grüsse\nWincasa AG`,
|
|
||||||
},
|
|
||||||
error: null,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Listing Parser (Supply Side) ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface ParsedListingData {
|
|
||||||
assetType?: string
|
|
||||||
areaSqm?: number
|
|
||||||
rentPerSqm?: number
|
|
||||||
city?: string
|
|
||||||
softLevels?: Record<string, string>
|
|
||||||
parking?: number
|
|
||||||
fitOut?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function parseListingText(text: string): Promise<ParsedListingData> {
|
|
||||||
await new Promise(r => setTimeout(r, 900))
|
|
||||||
const t = text.toLowerCase()
|
|
||||||
const result: ParsedListingData = {}
|
|
||||||
|
|
||||||
// Asset type
|
|
||||||
if (t.includes('laden') || t.includes('retail') || t.includes('shop') || t.includes('geschäft')) {
|
|
||||||
result.assetType = 'RETAIL'
|
|
||||||
} else if (t.includes('lager') || t.includes('logistik')) {
|
|
||||||
result.assetType = 'LOGISTICS'
|
|
||||||
} else if (t.includes('produktion') || t.includes('industrie') || t.includes('werkstatt')) {
|
|
||||||
result.assetType = 'PRODUCTION'
|
|
||||||
} else {
|
|
||||||
result.assetType = 'OFFICE'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Area
|
|
||||||
const areaMatch = text.match(/(\d{2,5})\s*m²/) ?? text.match(/(\d{2,5})\s*Quadratmeter/)
|
|
||||||
if (areaMatch) result.areaSqm = parseInt(areaMatch[1])
|
|
||||||
|
|
||||||
// Price — if raw < 500 assume monthly → convert to annual
|
|
||||||
const priceMatch = text.match(/(\d{2,4})\s*(?:CHF|Fr\.?)\s*\/\s*m²/) ?? text.match(/CHF\s*(\d{2,4})/)
|
|
||||||
if (priceMatch) {
|
|
||||||
const raw = parseInt(priceMatch[1])
|
|
||||||
result.rentPerSqm = raw < 500 ? raw * 12 : raw
|
|
||||||
}
|
|
||||||
|
|
||||||
// City
|
|
||||||
const CITIES = ['Zürich', 'Bern', 'Basel', 'Genf', 'Lausanne', 'Zug', 'Winterthur', 'St. Gallen', 'Lugano', 'Luzern', 'Biel', 'Thun', 'Kloten', 'Opfikon', 'Uster']
|
|
||||||
for (const city of CITIES) {
|
|
||||||
if (t.includes(city.toLowerCase())) { result.city = city; break }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Soft factors
|
|
||||||
const soft: Record<string, string> = {}
|
|
||||||
soft.prestige = (t.includes('zentrum') || t.includes('innenstadt') || t.includes('hauptbahnhof') || t.includes('repräsentativ') || t.includes('prestige'))
|
|
||||||
? 'HIGH' : (t.includes('gewerbegebiet') || t.includes('peripherie') || t.includes('industriezone'))
|
|
||||||
? 'LOW' : 'MEDIUM'
|
|
||||||
soft.accessibility = (t.includes('bahnhof') || t.includes('s-bahn') || t.includes('tram') || t.includes('öv') || t.includes('zentrum'))
|
|
||||||
? 'HIGH' : (t.includes('autobahn') || t.includes('gewerbegebiet'))
|
|
||||||
? 'LOW' : 'MEDIUM'
|
|
||||||
if (result.assetType === 'RETAIL') {
|
|
||||||
soft.visibility = t.includes('fussgänger') || t.includes('passanten') || t.includes('frequenz') ? 'HIGH' : 'MEDIUM'
|
|
||||||
soft.footfall = soft.visibility
|
|
||||||
}
|
|
||||||
if (t.includes('nachhaltig') || t.includes('minergie') || t.includes('esg') || t.includes('zertifiziert')) soft.esg = 'HIGH'
|
|
||||||
if (t.includes('expansion') || t.includes('erweiter') || t.includes('wachstum')) soft.expansionPotential = 'HIGH'
|
|
||||||
if (result.city === 'Zug') soft.taxEnvironment = 'HIGH'
|
|
||||||
if (t.includes('hochwertig') || t.includes('premium') || t.includes('erstklassig')) {
|
|
||||||
soft.prestige = 'HIGH'
|
|
||||||
result.fitOut = 'PREMIUM'
|
|
||||||
} else if (t.includes('einfach') || t.includes('standard-ausbau')) {
|
|
||||||
result.fitOut = 'BASIC'
|
|
||||||
}
|
|
||||||
result.softLevels = soft
|
|
||||||
|
|
||||||
// Parking
|
|
||||||
const parkingMatch = text.match(/(\d+)\s*Parkplätze?/) ?? text.match(/(\d+)\s*PP/)
|
|
||||||
if (parkingMatch) result.parking = parseInt(parkingMatch[1])
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Error normalisation utilities for all services.
|
||||||
|
*
|
||||||
|
* Convention for new services:
|
||||||
|
*
|
||||||
|
* async myMethod(): Promise<ListResponse<Foo>> {
|
||||||
|
* try {
|
||||||
|
* const data = await provider.getAll()
|
||||||
|
* return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
||||||
|
* } catch (err) {
|
||||||
|
* throwServiceError(err)
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* React Query catches the thrown AppError, sets isError = true, and exposes
|
||||||
|
* error.message so pages can render <ErrorState message={error.message} />.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Re-export so call sites have one import point.
|
||||||
|
export type { ServiceError } from './types'
|
||||||
|
export { ServiceErrorCode } from './types'
|
||||||
|
import type { ServiceError } from './types'
|
||||||
|
import { ServiceErrorCode } from './types'
|
||||||
|
|
||||||
|
// ── AppError ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** A structured error thrown by services. React Query treats it as any Error. */
|
||||||
|
export class AppError extends Error {
|
||||||
|
readonly code: ServiceErrorCode
|
||||||
|
|
||||||
|
constructor(serviceError: ServiceError) {
|
||||||
|
super(serviceError.message)
|
||||||
|
this.name = 'AppError'
|
||||||
|
this.code = serviceError.code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Map an unknown catch value to a structured ServiceError. */
|
||||||
|
export function normalizeError(err: unknown): ServiceError {
|
||||||
|
if (err instanceof AppError) {
|
||||||
|
return { code: err.code, message: err.message }
|
||||||
|
}
|
||||||
|
if (err instanceof Error) {
|
||||||
|
return { code: ServiceErrorCode.NETWORK_ERROR, message: err.message }
|
||||||
|
}
|
||||||
|
if (typeof err === 'string' && err.length > 0) {
|
||||||
|
return { code: ServiceErrorCode.NETWORK_ERROR, message: err }
|
||||||
|
}
|
||||||
|
return { code: ServiceErrorCode.BACKEND_UNAVAILABLE, message: 'Unbekannter Fehler' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise and rethrow as AppError.
|
||||||
|
* Use in every service catch block instead of plain `throw err`.
|
||||||
|
* Return type `never` lets TypeScript understand the function always throws.
|
||||||
|
*/
|
||||||
|
export function throwServiceError(err: unknown): never {
|
||||||
|
throw new AppError(normalizeError(err))
|
||||||
|
}
|
||||||
@@ -4,37 +4,63 @@ import type { FutureSignal } from '../domain/futureSignal'
|
|||||||
import type { ReviewStatus } from '../domain/enums'
|
import type { ReviewStatus } from '../domain/enums'
|
||||||
import type { FutureSignalSummary } from '../domain/dashboard'
|
import type { FutureSignalSummary } from '../domain/dashboard'
|
||||||
import type { ListResponse, ItemResponse } from './types'
|
import type { ListResponse, ItemResponse } from './types'
|
||||||
|
import { throwServiceError } from './errors'
|
||||||
|
|
||||||
const provider = MockupFutureSignalProvider
|
const provider = MockupFutureSignalProvider
|
||||||
|
|
||||||
export const futureSignalService = {
|
export const futureSignalService = {
|
||||||
async getAll(filters?: FutureSignalFilters): Promise<ListResponse<FutureSignal>> {
|
async getAll(filters?: FutureSignalFilters): Promise<ListResponse<FutureSignal>> {
|
||||||
|
try {
|
||||||
const data = await provider.getAll(filters)
|
const data = await provider.getAll(filters)
|
||||||
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
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>> {
|
async getById(id: string): Promise<ItemResponse<FutureSignal | null>> {
|
||||||
|
try {
|
||||||
const data = await provider.getById(id)
|
const data = await provider.getById(id)
|
||||||
return { data }
|
return { data }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async getByProperty(propertyId: string): Promise<ListResponse<FutureSignal>> {
|
async getByProperty(propertyId: string): Promise<ListResponse<FutureSignal>> {
|
||||||
|
try {
|
||||||
const data = await provider.getByProperty(propertyId)
|
const data = await provider.getByProperty(propertyId)
|
||||||
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
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>> {
|
async verify(id: string, verifiedBy: string): Promise<ItemResponse<FutureSignal>> {
|
||||||
|
try {
|
||||||
const data = await provider.verify(id, verifiedBy)
|
const data = await provider.verify(id, verifiedBy)
|
||||||
return { data }
|
return { data }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async updateReviewStatus(id: string, status: ReviewStatus): Promise<ItemResponse<FutureSignal>> {
|
async updateReviewStatus(id: string, status: ReviewStatus): Promise<ItemResponse<FutureSignal>> {
|
||||||
|
try {
|
||||||
const data = await provider.updateReviewStatus(id, status)
|
const data = await provider.updateReviewStatus(id, status)
|
||||||
return { data }
|
return { data }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async getSignalsForProperty(propertyId: string): Promise<ListResponse<FutureSignal>> {
|
async getSignalsForProperty(propertyId: string): Promise<ListResponse<FutureSignal>> {
|
||||||
|
try {
|
||||||
const data = await provider.getByProperty(propertyId)
|
const data = await provider.getByProperty(propertyId)
|
||||||
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async getSignalSummary(): Promise<FutureSignalSummary> {
|
async getSignalSummary(): Promise<FutureSignalSummary> {
|
||||||
|
try {
|
||||||
const signals = await provider.getAll()
|
const signals = await provider.getAll()
|
||||||
const RESTRICTED: readonly string[] = ['CONFIDENTIAL', 'INTERNAL']
|
const RESTRICTED: readonly string[] = ['CONFIDENTIAL', 'INTERNAL']
|
||||||
return {
|
return {
|
||||||
@@ -52,5 +78,8 @@ export const futureSignalService = {
|
|||||||
long: signals.filter(s => (s.timeHorizonMonths ?? 0) > 12).length,
|
long: signals.filter(s => (s.timeHorizonMonths ?? 0) > 12).length,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { MockupInquiryProvider } from '../provider/MockupInquiryProvider'
|
import { MockupInquiryProvider } from '../provider/MockupInquiryProvider'
|
||||||
import type { InquiryFilters } from '../provider/IInquiryProvider'
|
import type { InquiryFilters } from '../provider/IInquiryProvider'
|
||||||
import type { Inquiry, Attachment } from '../domain/inquiry'
|
import type { Inquiry, Attachment } from '../domain/inquiry'
|
||||||
|
import type { ListResponse, ItemResponse } from './types'
|
||||||
|
import { throwServiceError } from './errors'
|
||||||
|
|
||||||
const provider = MockupInquiryProvider
|
const provider = MockupInquiryProvider
|
||||||
|
|
||||||
@@ -10,31 +12,29 @@ export interface InquiryReplyPayload {
|
|||||||
attachments?: Attachment[]
|
attachments?: Attachment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServiceResult<T> = { data: T; error: null } | { data: null; error: string }
|
|
||||||
|
|
||||||
export const inquiryService = {
|
export const inquiryService = {
|
||||||
async getActiveInquiries(filters?: InquiryFilters): Promise<ServiceResult<Inquiry[]>> {
|
async getActiveInquiries(filters?: InquiryFilters): Promise<ListResponse<Inquiry>> {
|
||||||
try {
|
try {
|
||||||
const data = await provider.getAll(filters)
|
const data = await provider.getAll(filters)
|
||||||
return { data, error: null }
|
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
||||||
} catch (e) {
|
} catch (err) {
|
||||||
return { data: null, error: e instanceof Error ? e.message : 'Unbekannter Fehler' }
|
throwServiceError(err)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async getInquiryById(id: string): Promise<ServiceResult<Inquiry | null>> {
|
async getInquiryById(id: string): Promise<ItemResponse<Inquiry | null>> {
|
||||||
try {
|
try {
|
||||||
const data = await provider.getById(id)
|
const data = await provider.getById(id)
|
||||||
return { data, error: null }
|
return { data }
|
||||||
} catch (e) {
|
} catch (err) {
|
||||||
return { data: null, error: e instanceof Error ? e.message : 'Unbekannter Fehler' }
|
throwServiceError(err)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async sendInquiryReply(
|
async sendInquiryReply(
|
||||||
inquiryId: string,
|
inquiryId: string,
|
||||||
payload: InquiryReplyPayload,
|
payload: InquiryReplyPayload,
|
||||||
): Promise<ServiceResult<Inquiry>> {
|
): Promise<ItemResponse<Inquiry>> {
|
||||||
try {
|
try {
|
||||||
const data = await provider.addMessage(inquiryId, {
|
const data = await provider.addMessage(inquiryId, {
|
||||||
senderType: 'supply_user',
|
senderType: 'supply_user',
|
||||||
@@ -43,27 +43,27 @@ export const inquiryService = {
|
|||||||
body: payload.body,
|
body: payload.body,
|
||||||
attachments: payload.attachments ?? [],
|
attachments: payload.attachments ?? [],
|
||||||
})
|
})
|
||||||
return { data, error: null }
|
return { data }
|
||||||
} catch (e) {
|
} catch (err) {
|
||||||
return { data: null, error: e instanceof Error ? e.message : 'Unbekannter Fehler' }
|
throwServiceError(err)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async markThreadAsRead(id: string): Promise<ServiceResult<Inquiry>> {
|
async markThreadAsRead(id: string): Promise<ItemResponse<Inquiry>> {
|
||||||
try {
|
try {
|
||||||
const data = await provider.markThreadAsRead(id)
|
const data = await provider.markThreadAsRead(id)
|
||||||
return { data, error: null }
|
return { data }
|
||||||
} catch (e) {
|
} catch (err) {
|
||||||
return { data: null, error: e instanceof Error ? e.message : 'Unbekannter Fehler' }
|
throwServiceError(err)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async getUnreadCount(): Promise<ServiceResult<number>> {
|
async getUnreadCount(): Promise<ItemResponse<number>> {
|
||||||
try {
|
try {
|
||||||
const data = await provider.getUnreadCount()
|
const data = await provider.getUnreadCount()
|
||||||
return { data, error: null }
|
return { data }
|
||||||
} catch (e) {
|
} catch (err) {
|
||||||
return { data: null, error: e instanceof Error ? e.message : 'Unbekannter Fehler' }
|
throwServiceError(err)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,44 +12,77 @@ import type { AdditionalPropertyMatch } from '../domain/additionalMatch'
|
|||||||
import type { ListResponse, ItemResponse } from './types'
|
import type { ListResponse, ItemResponse } from './types'
|
||||||
import { buildFullMatch, computeRankedMatches } from '../features/matching/rankingEngine'
|
import { buildFullMatch, computeRankedMatches } from '../features/matching/rankingEngine'
|
||||||
import { ResultType } from '../domain/enums'
|
import { ResultType } from '../domain/enums'
|
||||||
|
import { throwServiceError } from './errors'
|
||||||
|
|
||||||
const provider = MockupMatchProvider
|
const provider = MockupMatchProvider
|
||||||
|
|
||||||
export const matchService = {
|
export const matchService = {
|
||||||
async getAll(filters?: MatchFilters): Promise<ListResponse<Match>> {
|
async getAll(filters?: MatchFilters): Promise<ListResponse<Match>> {
|
||||||
|
try {
|
||||||
const data = await provider.getAll(filters)
|
const data = await provider.getAll(filters)
|
||||||
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
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>> {
|
async getById(id: string): Promise<ItemResponse<Match | null>> {
|
||||||
|
try {
|
||||||
const data = await provider.getById(id)
|
const data = await provider.getById(id)
|
||||||
return { data }
|
return { data }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async getByNeed(needId: string): Promise<ListResponse<Match>> {
|
async getByNeed(needId: string): Promise<ListResponse<Match>> {
|
||||||
|
try {
|
||||||
const data = await provider.getByNeed(needId)
|
const data = await provider.getByNeed(needId)
|
||||||
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async getByProperty(propertyId: string): Promise<ListResponse<Match>> {
|
async getByProperty(propertyId: string): Promise<ListResponse<Match>> {
|
||||||
|
try {
|
||||||
const data = await provider.getByProperty(propertyId)
|
const data = await provider.getByProperty(propertyId)
|
||||||
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
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>> {
|
async approve(id: string, reviewedBy: string): Promise<ItemResponse<Match>> {
|
||||||
|
try {
|
||||||
const data = await provider.approve(id, reviewedBy)
|
const data = await provider.approve(id, reviewedBy)
|
||||||
return { data }
|
return { data }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async getMatchesForProperty(propertyId: string): Promise<ListResponse<Match>> {
|
async getMatchesForProperty(propertyId: string): Promise<ListResponse<Match>> {
|
||||||
|
try {
|
||||||
const data = await provider.getByProperty(propertyId)
|
const data = await provider.getByProperty(propertyId)
|
||||||
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
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>> {
|
async getMatchDetail(id: string): Promise<ItemResponse<Match | null>> {
|
||||||
|
try {
|
||||||
const data = await provider.getById(id)
|
const data = await provider.getById(id)
|
||||||
return { data }
|
return { data }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async getScoreBreakdown(matchId: string): Promise<ItemResponse<ScoreBreakdown | null>> {
|
async getScoreBreakdown(matchId: string): Promise<ItemResponse<ScoreBreakdown | null>> {
|
||||||
|
try {
|
||||||
const match = await provider.getById(matchId)
|
const match = await provider.getById(matchId)
|
||||||
return { data: match?.scoreBreakdown ?? null }
|
return { data: match?.scoreBreakdown ?? null }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Engine-based methods ──────────────────────────────────────────────────
|
// ── Engine-based methods ──────────────────────────────────────────────────
|
||||||
@@ -59,6 +92,7 @@ export const matchService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async computeMatchesForNeed(needId: string): Promise<ListResponse<Match>> {
|
async computeMatchesForNeed(needId: string): Promise<ListResponse<Match>> {
|
||||||
|
try {
|
||||||
const [need, properties] = await Promise.all([
|
const [need, properties] = await Promise.all([
|
||||||
MockupNeedProvider.getById(needId),
|
MockupNeedProvider.getById(needId),
|
||||||
MockupPropertyProvider.getAll(),
|
MockupPropertyProvider.getAll(),
|
||||||
@@ -66,12 +100,16 @@ export const matchService = {
|
|||||||
if (!need) return { data: [], meta: { total: 0, page: 1, pageSize: 0, hasMore: false } }
|
if (!need) return { data: [], meta: { total: 0, page: 1, pageSize: 0, hasMore: false } }
|
||||||
const data = computeRankedMatches(need, properties)
|
const data = computeRankedMatches(need, properties)
|
||||||
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async getNeedMatchesForProperty(
|
async getNeedMatchesForProperty(
|
||||||
propertyId: string,
|
propertyId: string,
|
||||||
opts?: { minScore?: number },
|
opts?: { minScore?: number },
|
||||||
): Promise<PropertyNeedMatch[]> {
|
): Promise<PropertyNeedMatch[]> {
|
||||||
|
try {
|
||||||
const minScore = opts?.minScore ?? 80
|
const minScore = opts?.minScore ?? 80
|
||||||
const [matches, needs] = await Promise.all([
|
const [matches, needs] = await Promise.all([
|
||||||
provider.getByProperty(propertyId),
|
provider.getByProperty(propertyId),
|
||||||
@@ -99,12 +137,16 @@ export const matchService = {
|
|||||||
} satisfies PropertyNeedMatch
|
} satisfies PropertyNeedMatch
|
||||||
})
|
})
|
||||||
.filter((x): x is PropertyNeedMatch => x !== null)
|
.filter((x): x is PropertyNeedMatch => x !== null)
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async getAdditionalMatchesForInquiry(
|
async getAdditionalMatchesForInquiry(
|
||||||
inquiryId: string,
|
inquiryId: string,
|
||||||
opts?: { minScore?: number; excludePropertyId?: string },
|
opts?: { minScore?: number; excludePropertyId?: string },
|
||||||
): Promise<AdditionalPropertyMatch[]> {
|
): Promise<AdditionalPropertyMatch[]> {
|
||||||
|
try {
|
||||||
const minScore = opts?.minScore ?? 80
|
const minScore = opts?.minScore ?? 80
|
||||||
const inquiry = await MockupInquiryProvider.getById(inquiryId)
|
const inquiry = await MockupInquiryProvider.getById(inquiryId)
|
||||||
if (!inquiry) return []
|
if (!inquiry) return []
|
||||||
@@ -138,9 +180,13 @@ export const matchService = {
|
|||||||
?? (match?.negativeFactors ?? [])[0]?.explanation
|
?? (match?.negativeFactors ?? [])[0]?.explanation
|
||||||
?? undefined,
|
?? undefined,
|
||||||
}))
|
}))
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async getStrongMatches(minScore = 80): Promise<StrongMatchItem[]> {
|
async getStrongMatches(minScore = 80): Promise<StrongMatchItem[]> {
|
||||||
|
try {
|
||||||
const [matches, properties] = await Promise.all([
|
const [matches, properties] = await Promise.all([
|
||||||
provider.getAll(),
|
provider.getAll(),
|
||||||
MockupPropertyProvider.getAll(),
|
MockupPropertyProvider.getAll(),
|
||||||
@@ -166,5 +212,8 @@ export const matchService = {
|
|||||||
nextBestAction: firstAction?.label ?? '–',
|
nextBestAction: firstAction?.label ?? '–',
|
||||||
} satisfies StrongMatchItem
|
} satisfies StrongMatchItem
|
||||||
})
|
})
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,28 +2,49 @@ import { MockupNeedProvider } from '../provider/MockupNeedProvider'
|
|||||||
import type { NeedFilters } from '../provider/INeedProvider'
|
import type { NeedFilters } from '../provider/INeedProvider'
|
||||||
import type { Need, CreateNeedInput, UpdateNeedInput } from '../domain/need'
|
import type { Need, CreateNeedInput, UpdateNeedInput } from '../domain/need'
|
||||||
import type { ListResponse, ItemResponse } from './types'
|
import type { ListResponse, ItemResponse } from './types'
|
||||||
|
import { throwServiceError } from './errors'
|
||||||
|
|
||||||
const provider = MockupNeedProvider
|
const provider = MockupNeedProvider
|
||||||
|
|
||||||
export const needService = {
|
export const needService = {
|
||||||
async getAll(filters?: NeedFilters): Promise<ListResponse<Need>> {
|
async getAll(filters?: NeedFilters): Promise<ListResponse<Need>> {
|
||||||
|
try {
|
||||||
const data = await provider.getAll(filters)
|
const data = await provider.getAll(filters)
|
||||||
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
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>> {
|
async getById(id: string): Promise<ItemResponse<Need | null>> {
|
||||||
|
try {
|
||||||
const data = await provider.getById(id)
|
const data = await provider.getById(id)
|
||||||
return { data }
|
return { data }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async create(input: CreateNeedInput): Promise<ItemResponse<Need>> {
|
async create(input: CreateNeedInput): Promise<ItemResponse<Need>> {
|
||||||
|
try {
|
||||||
const data = await provider.create(input)
|
const data = await provider.create(input)
|
||||||
return { data }
|
return { data }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async update(id: string, input: UpdateNeedInput): Promise<ItemResponse<Need>> {
|
async update(id: string, input: UpdateNeedInput): Promise<ItemResponse<Need>> {
|
||||||
|
try {
|
||||||
const data = await provider.update(id, input)
|
const data = await provider.update(id, input)
|
||||||
return { data }
|
return { data }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async remove(id: string): Promise<ItemResponse<void>> {
|
async remove(id: string): Promise<ItemResponse<void>> {
|
||||||
|
try {
|
||||||
await provider.remove(id)
|
await provider.remove(id)
|
||||||
return { data: undefined }
|
return { data: undefined }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,42 +3,71 @@ import type { PropertyFilters } from '../provider/IPropertyProvider'
|
|||||||
import type { CreatePropertyInput, UpdatePropertyInput } from '../domain/property'
|
import type { CreatePropertyInput, UpdatePropertyInput } from '../domain/property'
|
||||||
import type { ListResponse, ItemResponse } from './types'
|
import type { ListResponse, ItemResponse } from './types'
|
||||||
import type { Property } from '../domain/property'
|
import type { Property } from '../domain/property'
|
||||||
|
import { throwServiceError } from './errors'
|
||||||
|
|
||||||
const provider = MockupPropertyProvider
|
const provider = MockupPropertyProvider
|
||||||
const ACTIVE_STATUSES: readonly string[] = ['AVAILABLE_NOW', 'AVAILABLE_SOON']
|
const ACTIVE_STATUSES: readonly string[] = ['AVAILABLE_NOW', 'AVAILABLE_SOON']
|
||||||
|
|
||||||
export const propertyService = {
|
export const propertyService = {
|
||||||
async getAll(filters?: PropertyFilters): Promise<ListResponse<Property>> {
|
async getAll(filters?: PropertyFilters): Promise<ListResponse<Property>> {
|
||||||
|
try {
|
||||||
const data = await provider.getAll(filters)
|
const data = await provider.getAll(filters)
|
||||||
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
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>> {
|
async getById(id: string): Promise<ItemResponse<Property | null>> {
|
||||||
|
try {
|
||||||
const data = await provider.getById(id)
|
const data = await provider.getById(id)
|
||||||
return { data }
|
return { data }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async create(input: CreatePropertyInput): Promise<ItemResponse<Property>> {
|
async create(input: CreatePropertyInput): Promise<ItemResponse<Property>> {
|
||||||
|
try {
|
||||||
const data = await provider.create(input)
|
const data = await provider.create(input)
|
||||||
return { data }
|
return { data }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async update(id: string, input: UpdatePropertyInput): Promise<ItemResponse<Property>> {
|
async update(id: string, input: UpdatePropertyInput): Promise<ItemResponse<Property>> {
|
||||||
|
try {
|
||||||
const data = await provider.update(id, input)
|
const data = await provider.update(id, input)
|
||||||
return { data }
|
return { data }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async remove(id: string): Promise<ItemResponse<void>> {
|
async remove(id: string): Promise<ItemResponse<void>> {
|
||||||
|
try {
|
||||||
await provider.remove(id)
|
await provider.remove(id)
|
||||||
return { data: undefined }
|
return { data: undefined }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async getDashboardPropertiesSummary(): Promise<{ total: number; active: number }> {
|
async getDashboardPropertiesSummary(): Promise<{ total: number; active: number }> {
|
||||||
|
try {
|
||||||
const data = await provider.getAll()
|
const data = await provider.getAll()
|
||||||
return {
|
return {
|
||||||
total: data.length,
|
total: data.length,
|
||||||
active: data.filter(p => ACTIVE_STATUSES.includes(p.availabilityStatus)).length,
|
active: data.filter(p => ACTIVE_STATUSES.includes(p.availabilityStatus)).length,
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async getProperties(filters?: PropertyFilters): Promise<ListResponse<Property>> {
|
async getProperties(filters?: PropertyFilters): Promise<ListResponse<Property>> {
|
||||||
|
try {
|
||||||
const data = await provider.getAll(filters)
|
const data = await provider.getAll(filters)
|
||||||
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,36 +2,65 @@ import { MockupShortlistProvider } from '../provider/MockupShortlistProvider'
|
|||||||
import type { ShortlistFilters } from '../provider/IShortlistProvider'
|
import type { ShortlistFilters } from '../provider/IShortlistProvider'
|
||||||
import type { Shortlist, CreateShortlistInput, UpdateShortlistInput, ShortlistItemInput } from '../domain/shortlist'
|
import type { Shortlist, CreateShortlistInput, UpdateShortlistInput, ShortlistItemInput } from '../domain/shortlist'
|
||||||
import type { ListResponse, ItemResponse } from './types'
|
import type { ListResponse, ItemResponse } from './types'
|
||||||
|
import { throwServiceError } from './errors'
|
||||||
|
|
||||||
const provider = MockupShortlistProvider
|
const provider = MockupShortlistProvider
|
||||||
|
|
||||||
export const shortlistService = {
|
export const shortlistService = {
|
||||||
async getAll(filters?: ShortlistFilters): Promise<ListResponse<Shortlist>> {
|
async getAll(filters?: ShortlistFilters): Promise<ListResponse<Shortlist>> {
|
||||||
|
try {
|
||||||
const data = await provider.getAll(filters)
|
const data = await provider.getAll(filters)
|
||||||
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
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>> {
|
async getById(id: string): Promise<ItemResponse<Shortlist | null>> {
|
||||||
|
try {
|
||||||
const data = await provider.getById(id)
|
const data = await provider.getById(id)
|
||||||
return { data }
|
return { data }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async create(input: CreateShortlistInput): Promise<ItemResponse<Shortlist>> {
|
async create(input: CreateShortlistInput): Promise<ItemResponse<Shortlist>> {
|
||||||
|
try {
|
||||||
const data = await provider.create(input)
|
const data = await provider.create(input)
|
||||||
return { data }
|
return { data }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async update(id: string, input: UpdateShortlistInput): Promise<ItemResponse<Shortlist>> {
|
async update(id: string, input: UpdateShortlistInput): Promise<ItemResponse<Shortlist>> {
|
||||||
|
try {
|
||||||
const data = await provider.update(id, input)
|
const data = await provider.update(id, input)
|
||||||
return { data }
|
return { data }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async addItem(id: string, item: ShortlistItemInput): Promise<ItemResponse<Shortlist>> {
|
async addItem(id: string, item: ShortlistItemInput): Promise<ItemResponse<Shortlist>> {
|
||||||
|
try {
|
||||||
const data = await provider.addItem(id, item)
|
const data = await provider.addItem(id, item)
|
||||||
return { data }
|
return { data }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async removeItem(id: string, resultId: string): Promise<ItemResponse<Shortlist>> {
|
async removeItem(id: string, resultId: string): Promise<ItemResponse<Shortlist>> {
|
||||||
|
try {
|
||||||
const data = await provider.removeItem(id, resultId)
|
const data = await provider.removeItem(id, resultId)
|
||||||
return { data }
|
return { data }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async remove(id: string): Promise<ItemResponse<void>> {
|
async remove(id: string): Promise<ItemResponse<void>> {
|
||||||
|
try {
|
||||||
await provider.remove(id)
|
await provider.remove(id)
|
||||||
return { data: undefined }
|
return { data: undefined }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user