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:
Benjamin Sutter
2026-05-24 12:43:51 +02:00
parent 723f553939
commit c972392b78
9 changed files with 755 additions and 88 deletions
+102 -6
View File
@@ -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 {