Files
property-match/src/services/ai/mock/compareBuilder.ts
T
Benjamin Sutter 6515acb7f0 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>
2026-05-24 00:32:01 +02:00

125 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
}
}