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>
This commit is contained in:
@@ -0,0 +1,671 @@
|
||||
/**
|
||||
* 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 (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: () => 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} 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<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))
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user