feat: OpenRouter-ready AI service — all 11 methods implemented
IAIService: + generateMatchExplanation, summarizeTradeOffs, generateDataQualitySummary, classifyMarketSignal (4 new methods covering all documented AI output types) OpenRouterAIService: - All 11 methods now make real API calls via withFallback() pattern - Every fallback is explicit (console.warn/error) — no silent mock bleed-through - Proper JSON extraction with type-safe parsers, no any casts - parseNeed: AI JSON → ParseNeedResult mapping (no TODO stubs) - generateFollowUpQuestions, generateMatchExplanation, summarizeTradeOffs, generateDataQualitySummary, classifyMarketSignal, generateOfferEmail, extractCriteria, generateFollowUp: fully implemented Prompts: +followUpQuestionsPrompt, +tradeOffPrompt, +dataQualityPrompt, +marketSignalPrompt Factory (index.ts): - VITE_AI_PROVIDER=mock|openrouter (new, takes priority) - VITE_USE_REAL_AI=true still supported (legacy compat) - Missing API key → explicit console.warn + MockAIService fallback Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,17 +8,19 @@ import type {
|
||||
ComparisonSummary,
|
||||
CriteriaExtractionResult,
|
||||
OfferEmailPayload,
|
||||
MatchExplanationInput,
|
||||
MatchExplanation,
|
||||
TradeOffInput,
|
||||
TradeOffSummary,
|
||||
DataQualityInput,
|
||||
DataQualitySummary,
|
||||
MarketSignalClassification,
|
||||
} from '../IAIService'
|
||||
import { mockParseNeed } from './needParser'
|
||||
import { buildComparisonSummary } from './compareBuilder'
|
||||
import { buildMockDecisionBrief } from './decisionBrief'
|
||||
|
||||
const SIMULATED_DELAY = {
|
||||
fast: 300,
|
||||
medium: 600,
|
||||
slow: 1800,
|
||||
}
|
||||
|
||||
const SIMULATED_DELAY = { fast: 300, medium: 600, slow: 1800 }
|
||||
const delay = (ms: number) => new Promise(r => setTimeout(r, ms))
|
||||
|
||||
export const MockAIService: IAIService = {
|
||||
@@ -33,6 +35,47 @@ export const MockAIService: IAIService = {
|
||||
return { data: result.followUpQuestionCandidates }
|
||||
},
|
||||
|
||||
async generateMatchExplanation(input: MatchExplanationInput): Promise<ItemResponse<MatchExplanation>> {
|
||||
await delay(SIMULATED_DELAY.medium)
|
||||
const isStrong = input.matchScore >= 78
|
||||
const isMedium = input.matchScore >= 52
|
||||
const headline = isStrong
|
||||
? `Starkes Match — ${input.propertyTitle} erfüllt Ihre Kernkriterien hervorragend`
|
||||
: isMedium
|
||||
? `Gutes Match mit einzelnen Kompromissen für ${input.propertyTitle}`
|
||||
: `Schwaches Match — mehrere Kriterien nicht erfüllt bei ${input.propertyTitle}`
|
||||
const positiveText = input.positiveFactors.slice(0, 2).map(f => f.explanation).join('; ')
|
||||
const negativeText = input.negativeFactors.slice(0, 1).map(f => f.explanation).join('; ')
|
||||
const summary = `${input.propertyTitle} in ${input.propertyCity} erreicht ${input.matchScore}/100 Punkte.${positiveText ? ` Hauptstärken: ${positiveText}.` : ''}${negativeText ? ` Einschränkung: ${negativeText}.` : ''}`
|
||||
return {
|
||||
data: {
|
||||
headline,
|
||||
summary,
|
||||
keyReasons: [
|
||||
...input.positiveFactors.slice(0, 2).map(f => `+ ${f.explanation}`),
|
||||
...input.negativeFactors.slice(0, 1).map(f => `− ${f.explanation}`),
|
||||
],
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
async summarizeTradeOffs(tradeoffs: TradeOffInput[]): Promise<ItemResponse<TradeOffSummary>> {
|
||||
await delay(SIMULATED_DELAY.fast)
|
||||
const critical = tradeoffs.filter(t => t.severity === 'HIGH')
|
||||
const overallRisk: TradeOffSummary['overallRisk'] =
|
||||
critical.length >= 2 ? 'HIGH' : critical.length === 1 ? 'MEDIUM' : 'LOW'
|
||||
const riskLabel = overallRisk === 'HIGH' ? 'Hoch' : overallRisk === 'MEDIUM' ? 'Mittel' : 'Gering'
|
||||
return {
|
||||
data: {
|
||||
headline: tradeoffs.length === 0
|
||||
? 'Keine wesentlichen Trade-offs identifiziert'
|
||||
: `${tradeoffs.length} Trade-off${tradeoffs.length > 1 ? 's' : ''} — Gesamtrisiko: ${riskLabel}`,
|
||||
items: tradeoffs.map(t => ({ concern: t.concern, severity: t.severity, mitigation: t.mitigation })),
|
||||
overallRisk,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
async summarizeComparison(items: UnifiedMatchResult[]): Promise<ItemResponse<ComparisonSummary>> {
|
||||
await delay(SIMULATED_DELAY.medium)
|
||||
return { data: buildComparisonSummary(items) }
|
||||
@@ -43,6 +86,59 @@ export const MockAIService: IAIService = {
|
||||
return { data: buildMockDecisionBrief(shortlistId) }
|
||||
},
|
||||
|
||||
async generateDataQualitySummary(_propertyId: string, quality: DataQualityInput): Promise<ItemResponse<DataQualitySummary>> {
|
||||
await delay(SIMULATED_DELAY.fast)
|
||||
const level =
|
||||
quality.score >= 0.85 ? 'excellent'
|
||||
: quality.score >= 0.70 ? 'good'
|
||||
: quality.score >= 0.55 ? 'fair'
|
||||
: quality.score >= 0.40 ? 'poor'
|
||||
: 'critical'
|
||||
const assessments: Record<string, string> = {
|
||||
excellent: 'Exzellente Datenqualität — alle Kernfelder vollständig und aktuell.',
|
||||
good: 'Gute Datenqualität — kleinere Lücken beeinflussen die Matchgenauigkeit nicht wesentlich.',
|
||||
fair: 'Ausreichende Datenqualität — fehlende Felder können die Matchgenauigkeit beeinträchtigen.',
|
||||
poor: 'Geringe Datenqualität — wichtige Felder fehlen, Match-Score mit Vorsicht interpretieren.',
|
||||
critical: 'Kritische Datenqualität — fundamentale Felder fehlen, Match-Ergebnis stark eingeschränkt.',
|
||||
}
|
||||
const hasCritical = quality.missingCriticalFields.length > 0
|
||||
return {
|
||||
data: {
|
||||
overallAssessment: assessments[level],
|
||||
missingCriticalFields: quality.missingCriticalFields,
|
||||
recommendation: hasCritical
|
||||
? `Fehlende Pflichtfelder ergänzen: ${quality.missingCriticalFields.join(', ')}`
|
||||
: quality.score < 0.70
|
||||
? 'Daten aktualisieren und optionale Felder ergänzen für bessere Matchgenauigkeit.'
|
||||
: 'Keine sofortigen Massnahmen erforderlich.',
|
||||
confidence: quality.score,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
async classifyMarketSignal(signalText: string): Promise<ItemResponse<MarketSignalClassification>> {
|
||||
await delay(SIMULATED_DELAY.medium)
|
||||
const t = signalText.toLowerCase()
|
||||
let signalType: MarketSignalClassification['signalType'] = 'UNKNOWN'
|
||||
if (t.includes('neubau') || t.includes('baubewilligung') || t.includes('umbau')) signalType = 'CONSTRUCTION'
|
||||
else if (t.includes('expansion') || t.includes('wachstum') || t.includes('sucht fläche')) signalType = 'EXPANSION'
|
||||
else if (t.includes('verlegt') || t.includes('umzug') || t.includes('relocation')) signalType = 'RELOCATION'
|
||||
else if (t.includes('stellenabbau') || t.includes('restruktur') || t.includes('fusion')) signalType = 'RESTRUCTURING'
|
||||
else if (t.includes('frei') || t.includes('kündigung') || t.includes('schliessung') || t.includes('leerstand')) signalType = 'VACANCY'
|
||||
const areaMatch = signalText.match(/(\d{2,5})\s*m²/)
|
||||
const monthsMatch = signalText.match(/(\d{1,2})\s*Monate?n?/)
|
||||
return {
|
||||
data: {
|
||||
signalType,
|
||||
probability: 0.65,
|
||||
timeHorizonMonths: monthsMatch ? parseInt(monthsMatch[1]) : null,
|
||||
areaSqmEstimate: areaMatch ? parseInt(areaMatch[1]) : null,
|
||||
credibility: 'MEDIUM',
|
||||
reasoning: `Keyword-basierte Klassifikation (Mock). Signaltyp: ${signalType}.`,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
async generateOfferEmail(payload: OfferEmailPayload): Promise<ItemResponse<{ subject: string; body: string }>> {
|
||||
await delay(SIMULATED_DELAY.medium * 2)
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user