Files
property-match/src/services/ai/backend/BackendAIService.ts
T
Benjamin Sutter b2530f9e20 feat: Reale Jahresbelastung, must-have fixes, fitOut data coverage
Reale Jahresbelastung (Change 6):
- FitOutCostPanel zeigt Jahresmiete + amortisierte Ausbaukosten für alle fitOut-Werte
- FULL/PREMIUM: Ausbau CHF 0 (bezugsfertig), SHELL/BASIC: CRB/BKP-Richtwerte amortisiert über 5 J.
- Match-Card-Chip zeigt geschätzte Investition (orange wenn >100k)
- FitOutInvestment-Typ + calcFitOutInvestment() in fitOutUtils.ts
- BackendAIService + MockAIService mit generateFitOutAdvice (IAIService-Interface)

Must-have Kriterien (Nicht prüfbar Fix):
- ÖV-Anbindung: Minutengrenze aus Freitext extrahiert, gegen publicTransportMinutes geprüft
- Mindestfläche: m²-Wert aus Freitext extrahiert, gegen areaSqm geprüft
- Ausbaugrad: neues Keyword-Rule für FULL/PREMIUM

fitOut-Datenpflege:
- fitOut-Werte zu 32 fehlenden Properties ergänzt (Logistik=SHELL, Standard=BASIC, Modern=FULL)
- MAB-Werte (200/150/250 CHF/m²) zu 3 BASIC-Objekten hinzugefügt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 20:09:55 +02:00

672 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 <OPENROUTER_API_KEY> (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<string> {
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<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 (minmax 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: () => Promise<AIResponse<T>>,
fallback: FallbackFn<T>,
inputSizeChars?: number,
): Promise<AIResponse<T>> {
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<AIResponse<ParseNeedResult>> {
return withFallback('parseNeed', async () => {
const { system, user } = buildNeedParsingPrompt({ userInput: input })
const raw = await chat(system, user)
const json = extractJSON<RawNeedParseAI>(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<string, number> = {}
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<AIResponse<FollowUpQuestion[]>> {
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<unknown[]>(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<AIResponse<MatchExplanation>> {
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<AIResponse<TradeOffSummary>> {
return withFallback('summarizeTradeOffs', async () => {
const { system, user } = buildTradeOffPrompt(tradeoffs, 'Objekt')
const raw = await chat(system, user)
const json = extractJSON<unknown>(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<AIResponse<ComparisonSummary>> {
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<unknown>(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<AIResponse<DecisionBrief>> {
return withFallback('generateDecisionBrief', async () => {
const { system, user } = buildDecisionBriefPrompt({ shortlistItems: [], needSummary: shortlistId })
const raw = await chat(system, user)
const json = extractJSON<unknown>(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<AIResponse<DataQualitySummary>> {
return withFallback('generateDataQualitySummary', async () => {
const { system, user } = buildDataQualityPrompt(propertyId, quality)
const raw = await chat(system, user)
const json = extractJSON<unknown>(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<AIResponse<MarketSignalClassification>> {
return withFallback('classifyMarketSignal', async () => {
const { system, user } = buildMarketSignalPrompt(signalText)
const raw = await chat(system, user)
const json = extractJSON<unknown>(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<AIResponse<{ subject: string; body: string }>> {
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<unknown>(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<AIResponse<FitOutAdvice>> {
return withFallback('generateFitOutAdvice', async () => {
const FIT_LABELS: Record<string, string> = { 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}
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<FitOutAdvice>(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<AIResponse<CriteriaExtractionResult>> {
return withFallback('extractCriteria', async () => {
const { system, user } = buildNeedParsingPrompt({ userInput: input })
const raw = await chat(system, user)
const json = extractJSON<RawNeedParseAI>(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<CreateNeedInput>): Promise<AIResponse<string[]>> {
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<unknown[]>(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))
},
}