6515acb7f0
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>
125 lines
5.3 KiB
TypeScript
125 lines
5.3 KiB
TypeScript
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,
|
||
}
|
||
}
|