feat: unified error handling + AI service modularisation

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-24 00:32:01 +02:00
parent efc72b720e
commit 6515acb7f0
23 changed files with 1468 additions and 955 deletions
+157 -108
View File
@@ -12,44 +12,77 @@ import type { AdditionalPropertyMatch } from '../domain/additionalMatch'
import type { ListResponse, ItemResponse } from './types'
import { buildFullMatch, computeRankedMatches } from '../features/matching/rankingEngine'
import { ResultType } from '../domain/enums'
import { throwServiceError } from './errors'
const provider = MockupMatchProvider
export const matchService = {
async getAll(filters?: MatchFilters): Promise<ListResponse<Match>> {
const data = await provider.getAll(filters)
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
try {
const data = await provider.getAll(filters)
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
} catch (err) {
throwServiceError(err)
}
},
async getById(id: string): Promise<ItemResponse<Match | null>> {
const data = await provider.getById(id)
return { data }
try {
const data = await provider.getById(id)
return { data }
} catch (err) {
throwServiceError(err)
}
},
async getByNeed(needId: string): Promise<ListResponse<Match>> {
const data = await provider.getByNeed(needId)
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
try {
const data = await provider.getByNeed(needId)
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
} catch (err) {
throwServiceError(err)
}
},
async getByProperty(propertyId: string): Promise<ListResponse<Match>> {
const data = await provider.getByProperty(propertyId)
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
try {
const data = await provider.getByProperty(propertyId)
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
} catch (err) {
throwServiceError(err)
}
},
async approve(id: string, reviewedBy: string): Promise<ItemResponse<Match>> {
const data = await provider.approve(id, reviewedBy)
return { data }
try {
const data = await provider.approve(id, reviewedBy)
return { data }
} catch (err) {
throwServiceError(err)
}
},
async getMatchesForProperty(propertyId: string): Promise<ListResponse<Match>> {
const data = await provider.getByProperty(propertyId)
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
try {
const data = await provider.getByProperty(propertyId)
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
} catch (err) {
throwServiceError(err)
}
},
async getMatchDetail(id: string): Promise<ItemResponse<Match | null>> {
const data = await provider.getById(id)
return { data }
try {
const data = await provider.getById(id)
return { data }
} catch (err) {
throwServiceError(err)
}
},
async getScoreBreakdown(matchId: string): Promise<ItemResponse<ScoreBreakdown | null>> {
const match = await provider.getById(matchId)
return { data: match?.scoreBreakdown ?? null }
try {
const match = await provider.getById(matchId)
return { data: match?.scoreBreakdown ?? null }
} catch (err) {
throwServiceError(err)
}
},
// ── Engine-based methods ──────────────────────────────────────────────────
@@ -59,112 +92,128 @@ export const matchService = {
},
async computeMatchesForNeed(needId: string): Promise<ListResponse<Match>> {
const [need, properties] = await Promise.all([
MockupNeedProvider.getById(needId),
MockupPropertyProvider.getAll(),
])
if (!need) return { data: [], meta: { total: 0, page: 1, pageSize: 0, hasMore: false } }
const data = computeRankedMatches(need, properties)
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
try {
const [need, properties] = await Promise.all([
MockupNeedProvider.getById(needId),
MockupPropertyProvider.getAll(),
])
if (!need) return { data: [], meta: { total: 0, page: 1, pageSize: 0, hasMore: false } }
const data = computeRankedMatches(need, properties)
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
} catch (err) {
throwServiceError(err)
}
},
async getNeedMatchesForProperty(
propertyId: string,
opts?: { minScore?: number },
): Promise<PropertyNeedMatch[]> {
const minScore = opts?.minScore ?? 80
const [matches, needs] = await Promise.all([
provider.getByProperty(propertyId),
MockupNeedProvider.getAll(),
])
return matches
.filter(m => m.matchScore >= minScore)
.sort((a, b) => b.matchScore - a.matchScore)
.map(m => {
const need = needs.find(n => n.id === m.needId)
if (!need) return null
return {
matchId: m.id,
score: m.matchScore,
needId: need.id,
needTitle: `${need.companyName}`,
company: need.companyName,
areaRequired: `${need.requiredArea.min}${need.requiredArea.max}`,
budget: `max ${need.budgetRange.maxPerSqm} CHF/m²/Jahr`,
locations: need.preferredLocations,
assetType: need.assetType,
timing: need.timing?.earliestMoveIn ?? '',
mustHaves: need.mustCriteriaText ?? [],
matchHighlights: m.positiveFactors.map(f => f.explanation),
} satisfies PropertyNeedMatch
})
.filter((x): x is PropertyNeedMatch => x !== null)
try {
const minScore = opts?.minScore ?? 80
const [matches, needs] = await Promise.all([
provider.getByProperty(propertyId),
MockupNeedProvider.getAll(),
])
return matches
.filter(m => m.matchScore >= minScore)
.sort((a, b) => b.matchScore - a.matchScore)
.map(m => {
const need = needs.find(n => n.id === m.needId)
if (!need) return null
return {
matchId: m.id,
score: m.matchScore,
needId: need.id,
needTitle: `${need.companyName}`,
company: need.companyName,
areaRequired: `${need.requiredArea.min}${need.requiredArea.max} `,
budget: `max ${need.budgetRange.maxPerSqm} CHF/m²/Jahr`,
locations: need.preferredLocations,
assetType: need.assetType,
timing: need.timing?.earliestMoveIn ?? '',
mustHaves: need.mustCriteriaText ?? [],
matchHighlights: m.positiveFactors.map(f => f.explanation),
} satisfies PropertyNeedMatch
})
.filter((x): x is PropertyNeedMatch => x !== null)
} catch (err) {
throwServiceError(err)
}
},
async getAdditionalMatchesForInquiry(
inquiryId: string,
opts?: { minScore?: number; excludePropertyId?: string },
): Promise<AdditionalPropertyMatch[]> {
const minScore = opts?.minScore ?? 80
const inquiry = await MockupInquiryProvider.getById(inquiryId)
if (!inquiry) return []
const excludeId = opts?.excludePropertyId ?? inquiry.propertyId
const [allProperties, allMatches] = await Promise.all([
MockupPropertyProvider.getAll(),
provider.getAll(),
])
const portfolioProperties = allProperties.filter(
p => p.resultType === ResultType.VERIFIED_PORTFOLIO && p.id !== excludeId,
)
return portfolioProperties
.map(p => {
const match = allMatches.find(m => m.propertyId === p.id)
const score = match?.matchScore ?? 0
return { property: p, score, match }
})
.filter(({ score }) => score >= minScore)
.sort((a, b) => b.score - a.score)
.slice(0, 5)
.map(({ property: p, score, match }) => ({
propertyId: p.id,
title: p.title,
location: `${p.location.city}${p.location.district ? `, ${p.location.district}` : ''}`,
areaSqm: p.areaSqm,
rentPricePerSqm: p.rentPricePerSqm,
matchScore: score,
imageUrl: p.images?.[0],
reasons: (match?.positiveFactors ?? []).slice(0, 3).map(f => f.explanation ?? f.label ?? '').filter(Boolean),
topUncertainty: (match?.tradeoffs ?? match?.tradeOffs ?? [])[0]?.description
?? (match?.negativeFactors ?? [])[0]?.explanation
?? undefined,
}))
try {
const minScore = opts?.minScore ?? 80
const inquiry = await MockupInquiryProvider.getById(inquiryId)
if (!inquiry) return []
const excludeId = opts?.excludePropertyId ?? inquiry.propertyId
const [allProperties, allMatches] = await Promise.all([
MockupPropertyProvider.getAll(),
provider.getAll(),
])
const portfolioProperties = allProperties.filter(
p => p.resultType === ResultType.VERIFIED_PORTFOLIO && p.id !== excludeId,
)
return portfolioProperties
.map(p => {
const match = allMatches.find(m => m.propertyId === p.id)
const score = match?.matchScore ?? 0
return { property: p, score, match }
})
.filter(({ score }) => score >= minScore)
.sort((a, b) => b.score - a.score)
.slice(0, 5)
.map(({ property: p, score, match }) => ({
propertyId: p.id,
title: p.title,
location: `${p.location.city}${p.location.district ? `, ${p.location.district}` : ''}`,
areaSqm: p.areaSqm,
rentPricePerSqm: p.rentPricePerSqm,
matchScore: score,
imageUrl: p.images?.[0],
reasons: (match?.positiveFactors ?? []).slice(0, 3).map(f => f.explanation ?? f.label ?? '').filter(Boolean),
topUncertainty: (match?.tradeoffs ?? match?.tradeOffs ?? [])[0]?.description
?? (match?.negativeFactors ?? [])[0]?.explanation
?? undefined,
}))
} catch (err) {
throwServiceError(err)
}
},
async getStrongMatches(minScore = 80): Promise<StrongMatchItem[]> {
const [matches, properties] = await Promise.all([
provider.getAll(),
MockupPropertyProvider.getAll(),
])
return matches
.filter(m => m.matchScore >= minScore)
.slice(0, 5)
.map(m => {
const prop = properties.find(p => p.id === m.propertyId)
const topFactor = m.positiveFactors?.[0]
const firstAction = m.nextBestActions?.[0]
return {
matchId: m.id,
propertyId: m.propertyId,
propertyTitle: prop?.title ?? 'Unbekanntes Objekt',
propertyAddress: prop?.address
? `${prop.address.street} ${prop.address.houseNumber}, ${prop.address.city}`
: '',
needSummary: m.needId,
matchScore: m.matchScore,
topReason: topFactor?.explanation ?? topFactor?.criterion ?? '',
missingDataCount: m.missingData?.length ?? 0,
nextBestAction: firstAction?.label ?? '',
} satisfies StrongMatchItem
})
try {
const [matches, properties] = await Promise.all([
provider.getAll(),
MockupPropertyProvider.getAll(),
])
return matches
.filter(m => m.matchScore >= minScore)
.slice(0, 5)
.map(m => {
const prop = properties.find(p => p.id === m.propertyId)
const topFactor = m.positiveFactors?.[0]
const firstAction = m.nextBestActions?.[0]
return {
matchId: m.id,
propertyId: m.propertyId,
propertyTitle: prop?.title ?? 'Unbekanntes Objekt',
propertyAddress: prop?.address
? `${prop.address.street} ${prop.address.houseNumber}, ${prop.address.city}`
: '',
needSummary: m.needId,
matchScore: m.matchScore,
topReason: topFactor?.explanation ?? topFactor?.criterion ?? '',
missingDataCount: m.missingData?.length ?? 0,
nextBestAction: firstAction?.label ?? '',
} satisfies StrongMatchItem
})
} catch (err) {
throwServiceError(err)
}
},
}