/** * Backend AI Service — PowerOn Proxy * * Routes all LLM calls through the PowerOn backend. The LLM provider API key * is stored ONLY server-side and never reaches the browser bundle. * * ┌─────────────────────────────────────────────────────────────────────────┐ * │ PowerOn Backend Contract │ * │ │ * │ Endpoint: POST /api/ai/chat/completions │ * │ Headers: Content-Type: application/json │ * │ (session auth cookie handled by backend — no API key here) │ * │ │ * │ Request body: │ * │ { │ * │ messages: { role: 'system' | 'user'; content: string }[] │ * │ } │ * │ │ * │ Response (OpenAI-compatible): │ * │ { │ * │ choices: [{ message: { content: string } }] │ * │ } │ * │ │ * │ The backend adds: │ * │ - Authorization: Bearer (server-side env var) │ * │ - Model selection / routing │ * │ - Rate limiting & audit logging │ * └─────────────────────────────────────────────────────────────────────────┘ * * Dev setup — add to vite.config.ts: * server: { proxy: { '/api': process.env.AI_BACKEND_URL ?? 'http://localhost:3001' } } * * Every method follows this contract: * 1. HTTP error → error log + MockAIService fallback * 2. JSON parse fail → warn + MockAIService fallback * 3. Zod schema fail → warn + MockAIService fallback * 4. Success → AI response, source: 'ai', validationPassed: true */ 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, FitOutAdviceInput, FitOutAdvice, } from '../IAIService' import { ServiceErrorCode } from '../../types' import { AppError } from '../../errors' import { aiTraceStore, provenanceToStatus } from '../tracing' import type { AITraceErrorType, AITraceValidationStatus } from '../tracing' 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 ──────────────────────────────────────────────────────────────────── /** Relative URL — resolved by Vite proxy in dev, by the same-origin backend in prod. */ const API_BASE = '/api/ai' /** * Placeholder recorded in traces. The actual model is backend-controlled; * PowerOn may return it in a response extension field in future. */ const BACKEND_MODEL_PLACEHOLDER = 'backend-controlled' const PROMPT_VERSION = 'v1.1' const SCHEMA_VERSION = 'v1.0' // ── Provenance ──────────────────────────────────────────────────────────────── function makeProvenance( source: AIProvenance['source'], fallbackUsed: boolean, validationPassed: boolean, extras: { fallbackReason?: string } = {}, ): AIProvenance { return { provider: 'backend', model: BACKEND_MODEL_PLACEHOLDER, generatedAt: new Date().toISOString(), promptVersion: PROMPT_VERSION, schemaVersion: SCHEMA_VERSION, source, fallbackUsed, validationPassed, traceId: crypto.randomUUID(), fallbackReason: extras.fallbackReason, } } // ── HTTP helper ─────────────────────────────────────────────────────────────── async function chat(system: string, user: string): Promise { const res = await fetch(`${API_BASE}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', // No Authorization header — the API key lives server-side only. }, body: JSON.stringify({ 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, // Truncate to avoid leaking full backend error detail to the console. message: `Backend AI error ${res.status}: ${body.slice(0, 200)}`, }) } 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: () => Promise>, fallback: FallbackFn, inputSizeChars?: number, ): Promise> { const startMs = Date.now() const callId = crypto.randomUUID() try { const result = await fn() const latencyMs = Date.now() - startMs const prov = result.provenance const provenance: AIProvenance = { ...prov, traceId: callId, latencyMs, schemaVersion: SCHEMA_VERSION, } aiTraceStore.add({ id: callId, method: label, provider: prov.provider, model: prov.model, promptVersion: prov.promptVersion, latencyMs, fallbackUsed: prov.fallbackUsed, validationPassed: prov.validationPassed, responseValidationStatus: provenanceToStatus(prov.fallbackUsed, prov.source, prov.fallbackReason), fallbackReason: prov.fallbackReason, source: prov.source, createdAt: prov.generatedAt, inputSizeChars, }) return { ...result, provenance } } catch (err) { console.error(`[BackendAIService] ${label} failed:`, err) const result = await fallback() const latencyMs = Date.now() - startMs const errorType: AITraceErrorType = err instanceof AppError && err.code === ServiceErrorCode.AI_GENERATION_FAILED ? 'api_error' : err instanceof TypeError ? 'network' : 'unknown' const responseValidationStatus: AITraceValidationStatus = err instanceof AppError && err.code === ServiceErrorCode.AI_GENERATION_FAILED ? 'api_error' : 'network_error' const fallbackReason = `${errorType}: ${err instanceof Error ? err.message.slice(0, 100) : 'unknown error'}` const provenance: AIProvenance = { ...result.provenance, fallbackUsed: true, traceId: callId, fallbackReason, schemaVersion: SCHEMA_VERSION, latencyMs, } aiTraceStore.add({ id: callId, method: label, provider: 'backend', model: BACKEND_MODEL_PLACEHOLDER, promptVersion: PROMPT_VERSION, latencyMs, fallbackUsed: true, validationPassed: false, responseValidationStatus, errorType, fallbackReason, source: 'mock', createdAt: new Date().toISOString(), inputSizeChars, }) return { ...result, provenance } } } // ── Service ─────────────────────────────────────────────────────────────────── export const BackendAIService: IAIService = { // ── parseNeed ─────────────────────────────────────────────────────────────── parseNeed(input: string): Promise> { return withFallback('parseNeed', async () => { const { system, user } = buildNeedParsingPrompt({ userInput: input }) const raw = await chat(system, user) const json = extractJSON(raw) const ai = json ? validateAIResponse(NeedParsingResponseSchema, json, 'parseNeed') : null if (!ai) { console.warn('[BackendAIService] parseNeed: invalid response — using mock fallback') const fb = await MockAIService.parseNeed(input) return { ...fb, provenance: makeProvenance('mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) } } const extractedCriteria: ParseNeedResult['extractedCriteria'] = { assetType: (ai.assetType ?? undefined) as AssetType | undefined, areaRange: ai.areaRange ?? undefined, preferredLocations: ai.preferredLocations, budgetRange: ai.budgetRange ?? undefined, timing: ai.timing ? { ...ai.timing, earliestMoveIn: ai.timing.earliestMoveIn ?? '', flexibleTiming: ai.timing.flexibleTiming ?? false } : undefined, mustHaveCriteria: ai.mustHaveCriteria, } const missingFields = ai.missingFields ?? [] const confidenceByField: Record = {} Object.keys(extractedCriteria).forEach(k => { confidenceByField[k] = extractedCriteria[k as keyof typeof extractedCriteria] != null ? 0.85 : 0 }) missingFields.forEach(f => { confidenceByField[f] = 0 }) const followUpQuestionCandidates: FollowUpQuestion[] = missingFields.map((field, i) => ({ id: `fq-be-${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('ai', false, true), } }, () => MockAIService.parseNeed(input), input.length) }, // ── generateFollowUpQuestions ─────────────────────────────────────────────── generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise> { return withFallback('generateFollowUpQuestions', async () => { const missingFields = [ ...(!criteria.assetType ? ['assetType'] : []), ...(!criteria.areaRange || (criteria.areaRange.min <= 0 && criteria.areaRange.max <= 0) ? ['areaRange'] : []), ...(!criteria.preferredLocations?.length ? ['preferredLocations'] : []), ...(!criteria.budgetRange ? ['budgetRange'] : []), ...(!criteria.timing ? ['timing'] : []), ...(!criteria.mustHaveCriteria?.length ? ['mustHaveCriteria'] : []), ] const { system, user } = buildFollowUpQuestionsPrompt({ criteria, missingFields }) const raw = await chat(system, user) const json = extractJSON(raw) const ai = json ? validateAIResponse(FollowUpQuestionsResponseSchema, json, 'generateFollowUpQuestions') : null if (!ai?.length) { console.warn('[BackendAIService] generateFollowUpQuestions: invalid response — using mock fallback') const fb = await MockAIService.generateFollowUpQuestions(criteria) return { ...fb, provenance: makeProvenance('mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) } } return { data: ai.map((q, i) => ({ id: `fq-be-${i}`, questionText: q.questionText, targetField: q.targetField, reason: q.reason ?? 'AI-generiert', suggestedAnswerOptions: q.suggestedAnswerOptions, importance: (q.importance ?? 'recommended') as FollowUpQuestion['importance'], })), provenance: makeProvenance('ai', false, true), } }, () => MockAIService.generateFollowUpQuestions(criteria)) }, // ── generateMatchExplanation ──────────────────────────────────────────────── generateMatchExplanation(input: MatchExplanationInput): Promise> { return withFallback('generateMatchExplanation', async () => { const { system, user } = buildMatchExplanationPrompt(input) const raw = await chat(system, user) const summary = raw.trim() if (!summary) { console.warn('[BackendAIService] generateMatchExplanation: empty response — using mock fallback') const fb = await MockAIService.generateMatchExplanation(input) return { ...fb, provenance: makeProvenance('mock', true, false, { fallbackReason: 'empty_response' }) } } 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('ai', false, true), } }, () => MockAIService.generateMatchExplanation(input)) }, // ── summarizeTradeOffs ────────────────────────────────────────────────────── summarizeTradeOffs(tradeoffs: TradeOffInput[]): Promise> { return withFallback('summarizeTradeOffs', async () => { const { system, user } = buildTradeOffPrompt(tradeoffs, 'Objekt') const raw = await chat(system, user) const json = extractJSON(raw) const ai = json ? validateAIResponse(TradeOffSummaryResponseSchema, json, 'summarizeTradeOffs') : null if (!ai) { console.warn('[BackendAIService] summarizeTradeOffs: invalid response — using mock fallback') const fb = await MockAIService.summarizeTradeOffs(tradeoffs) return { ...fb, provenance: makeProvenance('mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) } } return { data: { headline: ai.headline, items: ai.items.map(item => ({ concern: item.concern, severity: item.severity, mitigation: item.mitigation, })), overallRisk: ai.overallRisk, }, provenance: makeProvenance('ai', false, true), } }, () => MockAIService.summarizeTradeOffs(tradeoffs)) }, // ── summarizeComparison ───────────────────────────────────────────────────── summarizeComparison(items: UnifiedMatchResult[]): Promise> { return withFallback('summarizeComparison', async () => { 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(system, user) const json = extractJSON(raw) const ai = json ? validateAIResponse(CompareSummaryResponseSchema, json, 'summarizeComparison') : null if (!ai) { console.warn('[BackendAIService] summarizeComparison: invalid response — using mock fallback') const fb = await MockAIService.summarizeComparison(items) return { ...fb, provenance: makeProvenance('mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) } } const mock = await MockAIService.summarizeComparison(items) return { data: { ...mock.data, overallAssessment: ai.overallAssessment, recommendation: ai.recommendation ?? mock.data.recommendation, }, provenance: makeProvenance('hybrid', false, true), } }, () => MockAIService.summarizeComparison(items)) }, // ── generateDecisionBrief ─────────────────────────────────────────────────── generateDecisionBrief(shortlistId: string): Promise> { return withFallback('generateDecisionBrief', async () => { const { system, user } = buildDecisionBriefPrompt({ shortlistItems: [], needSummary: shortlistId }) const raw = await chat(system, user) const json = extractJSON(raw) const ai = json ? validateAIResponse(DecisionBriefResponseSchema, json, 'generateDecisionBrief') : null if (!ai) { console.warn('[BackendAIService] generateDecisionBrief: invalid response — using mock fallback') const fb = await MockAIService.generateDecisionBrief(shortlistId) return { ...fb, provenance: makeProvenance('mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) } } 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('hybrid', false, true), } }, () => MockAIService.generateDecisionBrief(shortlistId)) }, // ── generateDataQualitySummary ────────────────────────────────────────────── generateDataQualitySummary(propertyId: string, quality: DataQualityInput): Promise> { return withFallback('generateDataQualitySummary', async () => { const { system, user } = buildDataQualityPrompt(propertyId, quality) const raw = await chat(system, user) const json = extractJSON(raw) const ai = json ? validateAIResponse(DataQualitySummaryResponseSchema, json, 'generateDataQualitySummary') : null if (!ai) { console.warn('[BackendAIService] generateDataQualitySummary: invalid response — using mock fallback') const fb = await MockAIService.generateDataQualitySummary(propertyId, quality) return { ...fb, provenance: makeProvenance('mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) } } return { data: { overallAssessment: ai.overallAssessment, missingCriticalFields: ai.missingCriticalFields ?? quality.missingCriticalFields, recommendation: ai.recommendation, confidence: ai.confidence, }, provenance: makeProvenance('ai', false, true), } }, () => MockAIService.generateDataQualitySummary(propertyId, quality)) }, // ── classifyMarketSignal ──────────────────────────────────────────────────── classifyMarketSignal(signalText: string): Promise> { return withFallback('classifyMarketSignal', async () => { const { system, user } = buildMarketSignalPrompt(signalText) const raw = await chat(system, user) const json = extractJSON(raw) const ai = json ? validateAIResponse(MarketSignalClassificationResponseSchema, json, 'classifyMarketSignal') : null if (!ai) { console.warn('[BackendAIService] classifyMarketSignal: invalid response — using mock fallback') const fb = await MockAIService.classifyMarketSignal(signalText) return { ...fb, provenance: makeProvenance('mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) } } return { data: { signalType: ai.signalType, probability: ai.probability, timeHorizonMonths: ai.timeHorizonMonths ?? null, areaSqmEstimate: ai.areaSqmEstimate ?? null, credibility: ai.credibility, reasoning: ai.reasoning, }, provenance: makeProvenance('ai', false, true), } }, () => MockAIService.classifyMarketSignal(signalText), signalText.length) }, // ── generateOfferEmail ────────────────────────────────────────────────────── generateOfferEmail(payload: OfferEmailPayload): Promise> { return withFallback('generateOfferEmail', async () => { 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(system, user) const json = extractJSON(raw) const ai = json ? validateAIResponse(OfferEmailResponseSchema, json, 'generateOfferEmail') : null if (!ai) { console.warn('[BackendAIService] generateOfferEmail: invalid response — using mock fallback') const fb = await MockAIService.generateOfferEmail(payload) return { ...fb, provenance: makeProvenance('mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) } } return { data: { subject: ai.subject, body: ai.body }, provenance: makeProvenance('ai', false, true), } }, () => MockAIService.generateOfferEmail(payload)) }, // ── generateFitOutAdvice ──────────────────────────────────────────────────── generateFitOutAdvice(input: FitOutAdviceInput): Promise> { return withFallback('generateFitOutAdvice', async () => { const FIT_LABELS: Record = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' } const system = `Du bist Schweizer Gewerbeimmobilien-Experte. Bewerte die Ausbausituation und empfiehl die beste Verhandlungsoption. Verfügbare Optionen: MIETERAUSBAU (Mieter zahlt alles), BKZ (Vermieter zahlt Einmalpauschale), MAB_AMORTISATION (MAB über Miete amortisiert). Antworte als JSON: { "recommendation": "MIETERAUSBAU" | "BKZ" | "MAB_AMORTISATION", "headline": "kurze Empfehlung (max 80 Zeichen)", "explanation": "2-3 Sätze Begründung auf Deutsch", "negotiationTip": "konkreter Verhandlungstipp auf Deutsch", "estimatedNetInvestment": "CHF-Betrag als String" }` const user = `Übergabezustand: ${FIT_LABELS[input.fitOut] ?? input.fitOut} Fläche: ${input.areaSqm} m² MAB des Vermieters: CHF ${input.mabPerSqm}/m² Monatliche Miete: CHF ${input.monthlyRentPerSqm}/m²${input.tenantBudgetPerSqm ? `\nEigenes Ausbaubudget: CHF ${input.tenantBudgetPerSqm}/m²` : ''}${input.requiredFitOut ? `\nGewünschter Zustand: ${FIT_LABELS[input.requiredFitOut] ?? input.requiredFitOut}` : ''} Bitte analysiere die Situation und empfiehl die beste Option für den Mieter.` const raw = await chat(system, user) const json = extractJSON(raw) if (!json || !json.recommendation || !json.headline) { console.warn('[BackendAIService] generateFitOutAdvice: invalid response — using mock fallback') const fb = await MockAIService.generateFitOutAdvice(input) return { ...fb, provenance: makeProvenance('mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) } } return { data: { recommendation: json.recommendation, headline: json.headline, explanation: json.explanation ?? '', negotiationTip: json.negotiationTip ?? '', estimatedNetInvestment: json.estimatedNetInvestment ?? '–', }, provenance: makeProvenance('ai', false, true), } }, () => MockAIService.generateFitOutAdvice(input)) }, // ── Legacy: extractCriteria ───────────────────────────────────────────────── extractCriteria(input: string): Promise> { return withFallback('extractCriteria', async () => { const { system, user } = buildNeedParsingPrompt({ userInput: input }) const raw = await chat(system, user) const json = extractJSON(raw) const ai = json ? validateAIResponse(NeedParsingResponseSchema, json, 'extractCriteria') : null if (!ai) { console.warn('[BackendAIService] extractCriteria: invalid response — using mock fallback') const fb = await MockAIService.extractCriteria(input) return { ...fb, provenance: makeProvenance('mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) } } 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('ai', false, true), } }, () => MockAIService.extractCriteria(input)) }, // ── Legacy: generateFollowUp ──────────────────────────────────────────────── generateFollowUp(partialNeed: Partial): Promise> { return withFallback('generateFollowUp', async () => { const missingFields = [ ...(!partialNeed.assetType ? ['assetType'] : []), ...(!partialNeed.preferredLocations?.length ? ['preferredLocations'] : []), ...(!partialNeed.timing ? ['timing'] : []), ...(!partialNeed.budgetRange ? ['budgetRange'] : []), ] if (!missingFields.length) { return { data: [], provenance: makeProvenance('ai', false, true) } } const { system, user } = buildFollowUpQuestionsPrompt({ criteria: partialNeed as ParsedNeedCriteria, missingFields, }) const raw = await chat(system, user) const json = extractJSON(raw) const ai = json ? validateAIResponse(FollowUpQuestionsResponseSchema, json, 'generateFollowUp') : null if (!ai?.length) { console.warn('[BackendAIService] generateFollowUp: invalid response — using mock fallback') const fb = await MockAIService.generateFollowUp(partialNeed) return { ...fb, provenance: makeProvenance('mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) } } return { data: ai.map(q => q.questionText).filter(Boolean), provenance: makeProvenance('ai', false, true), } }, () => MockAIService.generateFollowUp(partialNeed)) }, }