/** * OpenRouter AI Service * * Activation: * VITE_AI_PROVIDER=openrouter * VITE_OPENROUTER_API_KEY= * VITE_OPENROUTER_MODEL=anthropic/claude-3-5-haiku (optional, default shown) * * Every method follows this contract: * 1. No API key → warn + MockAIService fallback (fallbackUsed: true) * 2. HTTP error → error log + MockAIService fallback * 3. JSON parse fail → warn + MockAIService fallback * 4. Zod schema fail → warn + MockAIService fallback ← NEW * 5. Success (full AI) → AI response, source: 'ai', validationPassed: true * 6. Hybrid → source: 'hybrid', documented per-method * * No invalid data ever reaches the UI. */ import type { CreateNeedInput } from '../../../domain/need' import type { ParseNeedResult, ParsedNeedCriteria, FollowUpQuestion } from '../../../domain/needBuilder' import type { UnifiedMatchResult } from '../../../domain/unifiedResult' import type { AssetType } from '../../../domain/enums' import type { IAIService, AIResponse, AIProvenance, DecisionBrief, ComparisonSummary, CriteriaExtractionResult, OfferEmailPayload, MatchExplanationInput, MatchExplanation, TradeOffInput, TradeOffSummary, DataQualityInput, DataQualitySummary, MarketSignalClassification, } from '../IAIService' import { ServiceErrorCode } from '../../types' import { AppError } from '../../errors' import { NeedParsingResponseSchema, FollowUpQuestionsResponseSchema, TradeOffSummaryResponseSchema, CompareSummaryResponseSchema, DecisionBriefResponseSchema, DataQualitySummaryResponseSchema, MarketSignalClassificationResponseSchema, OfferEmailResponseSchema, validateAIResponse, } from '../schemas' import { buildNeedParsingPrompt } from '../prompts/needParsingPrompt' import { buildFollowUpQuestionsPrompt } from '../prompts/followUpQuestionsPrompt' import { buildMatchExplanationPrompt } from '../prompts/matchExplanationPrompt' import { buildTradeOffPrompt } from '../prompts/tradeOffPrompt' import { buildCompareSummaryPrompt } from '../prompts/compareSummaryPrompt' import { buildDecisionBriefPrompt } from '../prompts/decisionBriefPrompt' import { buildDataQualityPrompt } from '../prompts/dataQualityPrompt' import { buildMarketSignalPrompt } from '../prompts/marketSignalPrompt' import { MockAIService } from '../mock/MockAIService' // ── Config ──────────────────────────────────────────────────────────────────── const API_BASE = 'https://openrouter.ai/api/v1' const DEFAULT_MODEL = 'anthropic/claude-3-5-haiku' const PROMPT_VERSION = 'v1.1' const SCHEMA_VERSION = 'v1.0' interface OpenRouterConfig { apiKey: string model: string } function getConfig(): OpenRouterConfig | null { const apiKey = import.meta.env.VITE_OPENROUTER_API_KEY as string | undefined if (!apiKey) return null return { apiKey, model: (import.meta.env.VITE_OPENROUTER_MODEL as string | undefined) ?? DEFAULT_MODEL, } } function makeProvenance( config: OpenRouterConfig, source: AIProvenance['source'], fallbackUsed: boolean, validationPassed: boolean, ): AIProvenance { return { provider: 'openrouter', model: config.model, generatedAt: new Date().toISOString(), promptVersion: PROMPT_VERSION, source, fallbackUsed, validationPassed, } } // ── HTTP helper ─────────────────────────────────────────────────────────────── async function chat(config: OpenRouterConfig, system: string, user: string): Promise { const res = await fetch(`${API_BASE}/chat/completions`, { method: 'POST', headers: { 'Authorization': `Bearer ${config.apiKey}`, 'Content-Type': 'application/json', 'HTTP-Referer': window.location.origin, }, body: JSON.stringify({ model: config.model, messages: [ { role: 'system', content: system }, { role: 'user', content: user }, ], }), }) if (!res.ok) { const body = await res.text() throw new AppError({ code: ServiceErrorCode.AI_GENERATION_FAILED, message: `OpenRouter error ${res.status}: ${body}`, }) } const json = await res.json() as { choices: Array<{ message: { content: string } }> } return json.choices[0]?.message?.content ?? '' } // ── JSON extraction ─────────────────────────────────────────────────────────── function extractJSON(raw: string): T | null { const fenced = raw.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/) const candidate = fenced ? fenced[1] : raw.match(/([\[{][\s\S]*[\]}])/)?.[1] ?? raw try { return JSON.parse(candidate) as T } catch { return null } } // ── ParseNeed helpers ───────────────────────────────────────────────────────── type RawNeedParseAI = { assetType?: string | null areaRange?: { min: number; max: number } | null preferredLocations?: string[] budgetRange?: { maxPerSqm: number; currency: string } | null timing?: { earliestMoveIn: string; latestMoveIn?: string; flexibleTiming: boolean } | null mustHaveCriteria?: string[] missingFields?: string[] assumptions?: string[] } function followUpForField(field: string): string { const MAP: Record = { assetType: 'Welchen Nutzungstyp suchen Sie (Büro, Retail, Logistik, Produktion)?', areaRange: 'Welche Fläche benötigen Sie (min–max in m²)?', preferredLocations: 'In welchen Städten oder Regionen suchen Sie?', budgetRange: 'Was ist Ihr maximales Budget pro m² und Jahr?', timing: 'Wann möchten Sie spätestens einziehen?', mustHaveCriteria: 'Haben Sie zwingende Anforderungen (ÖV-Anbindung, Parkplätze, Laderampe)?', } return MAP[field] ?? `Können Sie "${field}" präzisieren?` } function defaultSuggestedWeights(): Record { return { area: 0.25, location: 0.20, budget: 0.20, timing: 0.15, prestige: 0.05, accessibility: 0.05, expansionPotential: 0.02, flexibility: 0.02, visibility: 0.02, footfall: 0.01, talentAccess: 0.01, esg: 0.01, taxEnvironment: 0.01, } } // ── Fallback wrapper ────────────────────────────────────────────────────────── type FallbackFn = () => Promise> async function withFallback( label: string, fn: (config: OpenRouterConfig) => Promise>, fallback: FallbackFn, ): Promise> { const config = getConfig() if (!config) { console.warn(`[OpenRouterAIService] ${label}: no API key — using MockAIService`) const result = await fallback() return { ...result, provenance: { ...result.provenance, fallbackUsed: true } } } try { return await fn(config) } catch (err) { console.error(`[OpenRouterAIService] ${label} failed:`, err) const result = await fallback() return { ...result, provenance: { ...result.provenance, fallbackUsed: true } } } } // ── Service ─────────────────────────────────────────────────────────────────── export const OpenRouterAIService: IAIService = { // ── parseNeed ─────────────────────────────────────────────────────────────── parseNeed(input: string): Promise> { return withFallback('parseNeed', async (config) => { const { system, user } = buildNeedParsingPrompt({ userInput: input }) const raw = await chat(config, system, user) const json = extractJSON(raw) const ai = json ? validateAIResponse(NeedParsingResponseSchema, json, 'parseNeed') : null if (!ai) { console.warn('[OpenRouterAIService] parseNeed: invalid response — using mock fallback') const fb = await MockAIService.parseNeed(input) return { ...fb, provenance: makeProvenance(config, 'mock', true, false) } } const extractedCriteria: ParsedNeedCriteria = { assetType: (ai.assetType ?? undefined) as AssetType | undefined, areaRange: ai.areaRange ?? undefined, preferredLocations: ai.preferredLocations, budgetRange: ai.budgetRange ?? undefined, timing: ai.timing ? { ...ai.timing, latestMoveIn: ai.timing.latestMoveIn ?? undefined } : undefined, mustHaveCriteria: ai.mustHaveCriteria, } const missingFields = ai.missingFields ?? [] const confidenceByField: Record = {} Object.keys(extractedCriteria).forEach(k => { confidenceByField[k] = extractedCriteria[k as keyof ParsedNeedCriteria] != null ? 0.85 : 0 }) missingFields.forEach(f => { confidenceByField[f] = 0 }) const followUpQuestionCandidates: FollowUpQuestion[] = missingFields.map((field, i) => ({ id: `fq-or-${i}`, questionText: followUpForField(field), targetField: field, reason: `Feld "${field}" nicht im Text erkannt`, importance: 'recommended' as const, })) return { data: { extractedCriteria, confidenceByField, missingFields, assumptions: ai.assumptions ?? [], suggestedWeights: defaultSuggestedWeights(), followUpQuestionCandidates, rawSummary: raw.substring(0, 500), promptVersion: PROMPT_VERSION, schemaVersion: SCHEMA_VERSION, }, provenance: makeProvenance(config, 'ai', false, true), } }, () => MockAIService.parseNeed(input)) }, // ── generateFollowUpQuestions ─────────────────────────────────────────────── generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise> { return withFallback('generateFollowUpQuestions', async (config) => { const missingFields = Object.entries(criteria) .filter(([, v]) => v == null) .map(([k]) => k) const { system, user } = buildFollowUpQuestionsPrompt({ criteria, missingFields }) const raw = await chat(config, system, user) const json = extractJSON(raw) const ai = json ? validateAIResponse(FollowUpQuestionsResponseSchema, json, 'generateFollowUpQuestions') : null if (!ai?.length) { console.warn('[OpenRouterAIService] generateFollowUpQuestions: invalid response — using mock fallback') const fb = await MockAIService.generateFollowUpQuestions(criteria) return { ...fb, provenance: makeProvenance(config, 'mock', true, false) } } return { data: ai.map((q, i) => ({ id: `fq-or-${i}`, questionText: q.questionText, targetField: q.targetField, reason: q.reason ?? 'AI-generiert', suggestedAnswerOptions: q.suggestedAnswerOptions, importance: (q.importance ?? 'recommended') as FollowUpQuestion['importance'], })), provenance: makeProvenance(config, 'ai', false, true), } }, () => MockAIService.generateFollowUpQuestions(criteria)) }, // ── generateMatchExplanation ──────────────────────────────────────────────── // Plain-text response — no JSON schema to validate, but non-empty check enforced. generateMatchExplanation(input: MatchExplanationInput): Promise> { return withFallback('generateMatchExplanation', async (config) => { const { system, user } = buildMatchExplanationPrompt(input) const raw = await chat(config, system, user) const summary = raw.trim() if (!summary) { console.warn('[OpenRouterAIService] generateMatchExplanation: empty response — using mock fallback') const fb = await MockAIService.generateMatchExplanation(input) return { ...fb, provenance: makeProvenance(config, 'mock', true, false) } } const scoreLabel = input.matchScore >= 78 ? 'Starkes' : input.matchScore >= 52 ? 'Gutes' : 'Schwaches' return { data: { headline: `${scoreLabel} Match — ${input.propertyTitle} (${input.matchScore}/100)`, summary, keyReasons: [ ...input.positiveFactors.slice(0, 2).map(f => `+ ${f.explanation}`), ...input.negativeFactors.slice(0, 1).map(f => `− ${f.explanation}`), ], }, provenance: makeProvenance(config, 'ai', false, true), } }, () => MockAIService.generateMatchExplanation(input)) }, // ── summarizeTradeOffs ────────────────────────────────────────────────────── summarizeTradeOffs(tradeoffs: TradeOffInput[]): Promise> { return withFallback('summarizeTradeOffs', async (config) => { const { system, user } = buildTradeOffPrompt(tradeoffs, 'Objekt') const raw = await chat(config, system, user) const json = extractJSON(raw) const ai = json ? validateAIResponse(TradeOffSummaryResponseSchema, json, 'summarizeTradeOffs') : null if (!ai) { console.warn('[OpenRouterAIService] summarizeTradeOffs: invalid response — using mock fallback') const fb = await MockAIService.summarizeTradeOffs(tradeoffs) return { ...fb, provenance: makeProvenance(config, 'mock', true, false) } } return { data: { headline: ai.headline, items: ai.items.map(item => ({ concern: item.concern, severity: item.severity, mitigation: item.mitigation, })), overallRisk: ai.overallRisk, }, provenance: makeProvenance(config, 'ai', false, true), } }, () => MockAIService.summarizeTradeOffs(tradeoffs)) }, // ── summarizeComparison ───────────────────────────────────────────────────── // Hybrid: AI provides narrative text; mock provides structural per-property data. // source: 'hybrid' — both are labeled in provenance. summarizeComparison(items: UnifiedMatchResult[]): Promise> { return withFallback('summarizeComparison', async (config) => { type ItemWithProp = UnifiedMatchResult & { property?: { title?: string; location?: { city?: string }; rentPricePerSqm?: number } } const properties = (items as ItemWithProp[]) .filter(i => i.resultType !== 'FUTURE_AVAILABILITY') .map(i => ({ title: i.property?.title ?? `Match ${i.matchScore}`, matchScore: i.matchScore, city: i.property?.location?.city ?? '–', rentPerSqm: i.property?.rentPricePerSqm ?? 0, positiveFactors: i.match.positiveFactors.slice(0, 2).map(f => f.explanation ?? f.criterion), negativeFactors: i.match.negativeFactors.slice(0, 2).map(f => f.explanation ?? f.criterion), })) const { system, user } = buildCompareSummaryPrompt({ properties }) const raw = await chat(config, system, user) const json = extractJSON(raw) const ai = json ? validateAIResponse(CompareSummaryResponseSchema, json, 'summarizeComparison') : null if (!ai) { console.warn('[OpenRouterAIService] summarizeComparison: invalid response — using mock fallback') const fb = await MockAIService.summarizeComparison(items) return { ...fb, provenance: makeProvenance(config, 'mock', true, false) } } const mock = await MockAIService.summarizeComparison(items) return { data: { ...mock.data, overallAssessment: ai.overallAssessment, recommendation: ai.recommendation ?? mock.data.recommendation, }, provenance: makeProvenance(config, 'hybrid', false, true), } }, () => MockAIService.summarizeComparison(items)) }, // ── generateDecisionBrief ─────────────────────────────────────────────────── // Hybrid: AI generates narrative summary + sections; mock fills structural metadata. generateDecisionBrief(shortlistId: string): Promise> { return withFallback('generateDecisionBrief', async (config) => { const { system, user } = buildDecisionBriefPrompt({ shortlistItems: [], needSummary: shortlistId }) const raw = await chat(config, system, user) const json = extractJSON(raw) const ai = json ? validateAIResponse(DecisionBriefResponseSchema, json, 'generateDecisionBrief') : null if (!ai) { console.warn('[OpenRouterAIService] generateDecisionBrief: invalid response — using mock fallback') const fb = await MockAIService.generateDecisionBrief(shortlistId) return { ...fb, provenance: makeProvenance(config, 'mock', true, false) } } const mock = await MockAIService.generateDecisionBrief(shortlistId) return { data: { ...mock.data, summary: ai.summary, sections: ai.sections.map(s => ({ title: s.title, body: s.body })), }, provenance: makeProvenance(config, 'hybrid', false, true), } }, () => MockAIService.generateDecisionBrief(shortlistId)) }, // ── generateDataQualitySummary ────────────────────────────────────────────── generateDataQualitySummary(propertyId: string, quality: DataQualityInput): Promise> { return withFallback('generateDataQualitySummary', async (config) => { const { system, user } = buildDataQualityPrompt(propertyId, quality) const raw = await chat(config, system, user) const json = extractJSON(raw) const ai = json ? validateAIResponse(DataQualitySummaryResponseSchema, json, 'generateDataQualitySummary') : null if (!ai) { console.warn('[OpenRouterAIService] generateDataQualitySummary: invalid response — using mock fallback') const fb = await MockAIService.generateDataQualitySummary(propertyId, quality) return { ...fb, provenance: makeProvenance(config, 'mock', true, false) } } return { data: { overallAssessment: ai.overallAssessment, missingCriticalFields: ai.missingCriticalFields ?? quality.missingCriticalFields, recommendation: ai.recommendation, confidence: ai.confidence, }, provenance: makeProvenance(config, 'ai', false, true), } }, () => MockAIService.generateDataQualitySummary(propertyId, quality)) }, // ── classifyMarketSignal ──────────────────────────────────────────────────── classifyMarketSignal(signalText: string): Promise> { return withFallback('classifyMarketSignal', async (config) => { const { system, user } = buildMarketSignalPrompt(signalText) const raw = await chat(config, system, user) const json = extractJSON(raw) const ai = json ? validateAIResponse(MarketSignalClassificationResponseSchema, json, 'classifyMarketSignal') : null if (!ai) { console.warn('[OpenRouterAIService] classifyMarketSignal: invalid response — using mock fallback') const fb = await MockAIService.classifyMarketSignal(signalText) return { ...fb, provenance: makeProvenance(config, 'mock', true, false) } } return { data: { signalType: ai.signalType, probability: ai.probability, timeHorizonMonths: ai.timeHorizonMonths ?? null, areaSqmEstimate: ai.areaSqmEstimate ?? null, credibility: ai.credibility, reasoning: ai.reasoning, }, provenance: makeProvenance(config, 'ai', false, true), } }, () => MockAIService.classifyMarketSignal(signalText)) }, // ── generateOfferEmail ────────────────────────────────────────────────────── generateOfferEmail(payload: OfferEmailPayload): Promise> { return withFallback('generateOfferEmail', async (config) => { const propertyList = payload.properties .map((p, i) => `• ${p} (Match-Score: ${payload.matchScores[i]}%)`) .join('\n') const system = `Du bist Immobilienmakler bei Wincasa AG. Erstelle eine professionelle, knappe Angebotsmail auf Deutsch. Antworte als JSON: { "subject": "...", "body": "..." }` const user = `Suchanfrage: "${payload.needTitle}"\n\nObjekte:\n${propertyList}\n\nErstelle eine professionelle Angebotsmail.` const raw = await chat(config, system, user) const json = extractJSON(raw) const ai = json ? validateAIResponse(OfferEmailResponseSchema, json, 'generateOfferEmail') : null if (!ai) { console.warn('[OpenRouterAIService] generateOfferEmail: invalid response — using mock fallback') const fb = await MockAIService.generateOfferEmail(payload) return { ...fb, provenance: makeProvenance(config, 'mock', true, false) } } return { data: { subject: ai.subject, body: ai.body }, provenance: makeProvenance(config, 'ai', false, true), } }, () => MockAIService.generateOfferEmail(payload)) }, // ── Legacy: extractCriteria ───────────────────────────────────────────────── extractCriteria(input: string): Promise> { return withFallback('extractCriteria', async (config) => { const { system, user } = buildNeedParsingPrompt({ userInput: input }) const raw = await chat(config, system, user) const json = extractJSON(raw) const ai = json ? validateAIResponse(NeedParsingResponseSchema, json, 'extractCriteria') : null if (!ai) { console.warn('[OpenRouterAIService] extractCriteria: invalid response — using mock fallback') const fb = await MockAIService.extractCriteria(input) return { ...fb, provenance: makeProvenance(config, 'mock', true, false) } } return { data: { extractedCriteria: { assetType: ai.assetType as AssetType | undefined ?? undefined, requiredArea: ai.areaRange ?? undefined, preferredLocations: ai.preferredLocations ?? [], budgetRange: ai.budgetRange ?? undefined, }, confidence: 0.80, missingFields: ai.missingFields ?? [], assumptions: ai.assumptions ?? [], followUpQuestions: (ai.missingFields ?? []).map(followUpForField), }, provenance: makeProvenance(config, 'ai', false, true), } }, () => MockAIService.extractCriteria(input)) }, // ── Legacy: generateFollowUp ──────────────────────────────────────────────── generateFollowUp(partialNeed: Partial): Promise> { return withFallback('generateFollowUp', async (config) => { const missingFields = [ ...(!partialNeed.assetType ? ['assetType'] : []), ...(!partialNeed.preferredLocations?.length ? ['preferredLocations'] : []), ...(!partialNeed.timing ? ['timing'] : []), ...(!partialNeed.budgetRange ? ['budgetRange'] : []), ] if (!missingFields.length) { return { data: [], provenance: makeProvenance(config, 'ai', false, true) } } const { system, user } = buildFollowUpQuestionsPrompt({ criteria: partialNeed as ParsedNeedCriteria, missingFields, }) const raw = await chat(config, system, user) const json = extractJSON(raw) const ai = json ? validateAIResponse(FollowUpQuestionsResponseSchema, json, 'generateFollowUp') : null if (!ai?.length) { console.warn('[OpenRouterAIService] generateFollowUp: invalid response — using mock fallback') const fb = await MockAIService.generateFollowUp(partialNeed) return { ...fb, provenance: makeProvenance(config, 'mock', true, false) } } return { data: ai.map(q => q.questionText).filter(Boolean), provenance: makeProvenance(config, 'ai', false, true), } }, () => MockAIService.generateFollowUp(partialNeed)) }, }