e62391af66
- Add AIProvenance + AIResponse<T> to IAIService — all 11 methods now return structured provenance (provider, model, source, fallbackUsed, validationPassed) instead of bare ItemResponse<T> - Add schemas.ts with Zod schemas for all 8 AI response types; validateAIResponse() utility returns null on failure, never throws - Rewrite OpenRouterAIService: every method validates AI JSON against its Zod schema; failed validation triggers MockAIService fallback with fallbackUsed:true — no invalid data can reach the UI - Fix MockAIService.generateFollowUpQuestions: replace broken mockParseNeed(JSON.stringify(criteria)) with direct ParsedNeedCriteria field inspection; returns max 3 prioritised FollowUpQuestion objects - Add provenance: mockProvenance() to all MockAIService responses - Improve decisionBriefPrompt: structured JSON schema example, confidence vocabulary, availability disclaimer - Improve matchExplanationPrompt: score-tier vocabulary, isFutureSignal flag forbids confirmed-availability language for future signals - Add 102 new tests: mustHaveScorer (16), softFactorEnrichment (38), aiSchemas (52) — 154 total, all passing; 0 TypeScript errors Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
550 lines
26 KiB
TypeScript
550 lines
26 KiB
TypeScript
/**
|
||
* OpenRouter AI Service
|
||
*
|
||
* Activation:
|
||
* VITE_AI_PROVIDER=openrouter
|
||
* VITE_OPENROUTER_API_KEY=<your-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<string> {
|
||
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<T>(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<string, string> = {
|
||
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<string, number> {
|
||
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<T> = () => Promise<AIResponse<T>>
|
||
|
||
async function withFallback<T>(
|
||
label: string,
|
||
fn: (config: OpenRouterConfig) => Promise<AIResponse<T>>,
|
||
fallback: FallbackFn<T>,
|
||
): Promise<AIResponse<T>> {
|
||
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<AIResponse<ParseNeedResult>> {
|
||
return withFallback('parseNeed', async (config) => {
|
||
const { system, user } = buildNeedParsingPrompt({ userInput: input })
|
||
const raw = await chat(config, system, user)
|
||
const json = extractJSON<RawNeedParseAI>(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<string, number> = {}
|
||
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<AIResponse<FollowUpQuestion[]>> {
|
||
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<unknown[]>(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<AIResponse<MatchExplanation>> {
|
||
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<AIResponse<TradeOffSummary>> {
|
||
return withFallback('summarizeTradeOffs', async (config) => {
|
||
const { system, user } = buildTradeOffPrompt(tradeoffs, 'Objekt')
|
||
const raw = await chat(config, system, user)
|
||
const json = extractJSON<unknown>(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<AIResponse<ComparisonSummary>> {
|
||
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<unknown>(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<AIResponse<DecisionBrief>> {
|
||
return withFallback('generateDecisionBrief', async (config) => {
|
||
const { system, user } = buildDecisionBriefPrompt({ shortlistItems: [], needSummary: shortlistId })
|
||
const raw = await chat(config, system, user)
|
||
const json = extractJSON<unknown>(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<AIResponse<DataQualitySummary>> {
|
||
return withFallback('generateDataQualitySummary', async (config) => {
|
||
const { system, user } = buildDataQualityPrompt(propertyId, quality)
|
||
const raw = await chat(config, system, user)
|
||
const json = extractJSON<unknown>(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<AIResponse<MarketSignalClassification>> {
|
||
return withFallback('classifyMarketSignal', async (config) => {
|
||
const { system, user } = buildMarketSignalPrompt(signalText)
|
||
const raw = await chat(config, system, user)
|
||
const json = extractJSON<unknown>(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<AIResponse<{ subject: string; body: string }>> {
|
||
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<unknown>(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<AIResponse<CriteriaExtractionResult>> {
|
||
return withFallback('extractCriteria', async (config) => {
|
||
const { system, user } = buildNeedParsingPrompt({ userInput: input })
|
||
const raw = await chat(config, system, user)
|
||
const json = extractJSON<RawNeedParseAI>(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<CreateNeedInput>): Promise<AIResponse<string[]>> {
|
||
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<unknown[]>(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))
|
||
},
|
||
}
|