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:
Benjamin Sutter
2026-06-06 20:09:55 +02:00
parent a825672197
commit b2530f9e20
40 changed files with 1755 additions and 733 deletions
+23 -1
View File
@@ -7,7 +7,7 @@ import type { UnifiedMatchResult } from '../../domain/unifiedResult'
export interface AIProvenance {
/** Which AI provider produced this response */
provider: 'openrouter' | 'mock'
provider: 'openrouter' | 'mock' | 'backend'
/** Exact model ID (e.g. 'anthropic/claude-3-5-haiku') or 'mock' */
model: string
/** ISO-8601 timestamp of generation */
@@ -157,6 +157,25 @@ export interface DataQualityInput {
warnings: string[]
}
// ── Fit-out advice ────────────────────────────────────────────────────────────
export interface FitOutAdviceInput {
fitOut: string
areaSqm: number
mabPerSqm: number
requiredFitOut?: string
tenantBudgetPerSqm?: number
monthlyRentPerSqm: number
}
export interface FitOutAdvice {
recommendation: 'MIETERAUSBAU' | 'BKZ' | 'MAB_AMORTISATION'
headline: string
explanation: string
negotiationTip: string
estimatedNetInvestment: string
}
// ── Legacy types (kept for backward compatibility) ────────────────────────────
export interface CriteriaExtractionResult {
@@ -198,6 +217,9 @@ export interface IAIService {
// Offer email (supply side)
generateOfferEmail(payload: OfferEmailPayload): Promise<AIResponse<{ subject: string; body: string }>>
// Fit-out investment advice (demand side)
generateFitOutAdvice(input: FitOutAdviceInput): Promise<AIResponse<FitOutAdvice>>
// Legacy methods
extractCriteria(input: string): Promise<AIResponse<CriteriaExtractionResult>>
generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<AIResponse<string[]>>
+671
View File
@@ -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 (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))
},
}
+28 -25
View File
@@ -1,44 +1,47 @@
/**
* AI Service Factory
*
* Provider selection (priority order):
* 1. VITE_AI_PROVIDER=openrouter → OpenRouterAIService (requires VITE_OPENROUTER_API_KEY)
* 2. VITE_AI_PROVIDER=mock → MockAIService (deterministic, no API key required)
* 3. VITE_USE_REAL_AI=true → OpenRouterAIService (legacy flag, requires VITE_OPENROUTER_API_KEY)
* 4. (default) → MockAIService
* Provider selection via VITE_AI_PROVIDER:
*
* If VITE_AI_PROVIDER=openrouter but VITE_OPENROUTER_API_KEY is missing, the factory
* logs a warning and falls back to MockAIService — never silently fails.
* backend (default) → BackendAIService
* Calls POST /api/ai/chat/completions on the PowerOn backend.
* The LLM API key is stored server-side only — not in this bundle.
* In development: configure a Vite proxy (see vite.config.ts).
*
* Optional: VITE_OPENROUTER_MODEL controls which model OpenRouter uses.
* Default: anthropic/claude-3-5-haiku
* mock → MockAIService
* Deterministic responses, no network calls.
* Use for local dev without a backend, or in CI.
*
* REMOVED: VITE_OPENROUTER_API_KEY and VITE_OPENROUTER_MODEL.
* The OpenRouter key is now a server-side secret in PowerOn.
*/
import { MockAIService } from './mock/MockAIService'
import { OpenRouterAIService } from './openrouter/OpenRouterAIService'
import { BackendAIService } from './backend/BackendAIService'
import type { IAIService } from './IAIService'
function resolveProvider(): IAIService {
const provider = import.meta.env.VITE_AI_PROVIDER as string | undefined
const legacyRealAI = import.meta.env.VITE_USE_REAL_AI === 'true'
const apiKey = import.meta.env.VITE_OPENROUTER_API_KEY as string | undefined
const wantsOpenRouter = provider === 'openrouter' || (legacyRealAI && !provider)
if (wantsOpenRouter) {
if (!apiKey) {
console.warn(
'[aiService] OpenRouter selected but VITE_OPENROUTER_API_KEY is missing — falling back to MockAIService.',
'Set VITE_AI_PROVIDER=mock to suppress this warning.',
)
return MockAIService
}
return OpenRouterAIService
if (provider === 'mock') {
return MockAIService
}
return MockAIService
if (provider === 'openrouter') {
console.warn(
'[aiService] VITE_AI_PROVIDER=openrouter is no longer supported. ' +
'Direct OpenRouter calls have been removed from the frontend. ' +
'Using BackendAIService (POST /api/ai/chat/completions) instead. ' +
'Set VITE_AI_PROVIDER=backend or remove the variable to suppress this warning.',
)
}
// Default: backend proxy. Falls back to mock automatically on network/HTTP errors.
return BackendAIService
}
export const aiService: IAIService = resolveProvider()
export { MockAIService, OpenRouterAIService }
export { MockAIService, BackendAIService }
// Compatibility alias for any code that still imports OpenRouterAIService by name.
export { OpenRouterAIService } from './openrouter/OpenRouterAIService'
export type { IAIService }
+44
View File
@@ -10,6 +10,8 @@ import type {
TradeOffSummary,
DataQualityInput,
MarketSignalClassification,
FitOutAdviceInput,
FitOutAdvice,
} from '../IAIService'
import { mockProvenance } from '../IAIService'
import { aiTraceStore } from '../tracing'
@@ -294,6 +296,48 @@ export const MockAIService: IAIService = {
}
}),
generateFitOutAdvice: (input: FitOutAdviceInput) =>
traceMock('generateFitOutAdvice', async () => {
await delay(SIMULATED_DELAY.medium)
const mab = input.mabPerSqm
const fitOut = input.fitOut
let recommendation: FitOutAdvice['recommendation']
let headline: string
let explanation: string
let negotiationTip: string
if (fitOut === 'SHELL') {
if (mab >= 300) {
recommendation = 'MAB_AMORTISATION'
headline = 'MAB-Amortisation empfohlen — Vermieter trägt Grossteil der Ausbaukosten'
explanation = `Mit CHF ${mab}/m² MAB übernimmt der Vermieter einen erheblichen Teil der Ausbauinvestition. Die verbleibende Nettoinvestition wird über die Vertragslaufzeit amortisiert. Für ${input.areaSqm.toLocaleString('de-CH')} m² Rohbaufläche ist dies die kosteneffizienteste Lösung.`
negotiationTip = 'Verhandeln Sie eine höhere MAB-Rate gegen eine längere Mietvertragslaufzeit (min. 5 Jahre).'
} else {
recommendation = 'BKZ'
headline = 'Baukostenzuschuss (BKZ) verhandeln — Vermieter zahlt Ausbaupauschale'
explanation = `Bei SHELL-Übergabe ohne wesentlichem MAB ist ein Baukostenzuschuss (BKZ) die effektivste Option. Der Vermieter zahlt einen einmaligen Betrag, den Sie für den Innenausbau nutzen. Typisch sind CHF 200400/m² als BKZ.`
negotiationTip = `Fordern Sie CHF ${Math.round(300 * input.areaSqm / 1000) * 1000}. als BKZ-Pauschale. Reichen Sie Ausbauofferten von 2 Generalunternehmern vor der Unterzeichnung ein.`
}
} else {
recommendation = 'MIETERAUSBAU'
headline = 'Mieterausbau auf eigene Rechnung — geringe Restinvestition'
explanation = `${input.fitOut === 'BASIC' ? 'Basisausbau' : 'Vollausbau'} erfordert nur noch Anpassungen nach Ihren Bedürfnissen. Die Investition ist überschaubar und amortisiert sich bei einer Mietdauer von 3+ Jahren.`
negotiationTip = 'Lassen Sie eine Ausbauklausel im Mietvertrag festhalten: Entfernung von Mieterausbauten bei Auszug nur auf explizite Anforderung des Vermieters.'
}
const grossMin = (fitOut === 'SHELL' ? 800 : 400) - mab
const grossMax = (fitOut === 'SHELL' ? 1500 : 800) - mab
const netMin = Math.max(0, grossMin)
const netMax = Math.max(0, grossMax)
const estimatedNetInvestment = netMax <= 0
? 'Vollständig durch MAB gedeckt'
: `CHF ${Math.round(netMin * input.areaSqm / 1000) * 1000}${Math.round(netMax * input.areaSqm / 1000) * 1000}.`
const data: FitOutAdvice = { recommendation, headline, explanation, negotiationTip, estimatedNetInvestment }
return { data, provenance: mockProvenance() }
}),
// Legacy methods
extractCriteria: (_input: string) =>
traceMock('extractCriteria', async () => ({
+9
View File
@@ -198,6 +198,13 @@ export function mockParseNeed(input: string): ParseNeedResult {
: lower.includes('basisausbau') || lower.includes('rohbau') || lower.includes('einfach') ? 'BASIC'
: undefined
// Search radius: "innerhalb von 30 km", "30km Umkreis", "im Umkreis von 50 km", "radius 20km"
const radiusMatch =
input.match(/(?:innerhalb\s+(?:von\s+)?|im\s+umkreis\s+(?:von\s+)?|radius\s+(?:von\s+)?)(\d+)\s*km/i) ??
input.match(/(\d+)\s*km\s*(?:umkreis|radius|entfernung)/i) ??
input.match(/(\d+)\s*km/i)
const searchRadius = radiusMatch ? Math.min(100, Math.max(1, parseInt(radiusMatch[1]))) : undefined
// Contract duration: "7-jähriger Vertrag", "Laufzeit 7 Jahre", standalone "7 Jahre" at sentence start
// Exclude "in X Jahren", "X Jahre im Geschäft", "X Jahre Erfahrung" etc.
const contractMatch = input.match(/(\d+)[- ]?j[aä]hrige?(?:r)?\s+(?:vertrag|mietvertrag|laufzeit)/i)
@@ -244,6 +251,7 @@ export function mockParseNeed(input: string): ParseNeedResult {
budgetRange: budgetConfidence,
timing: timingConfidence,
mustHaveCriteria: mustHaveCriteria.length > 0 ? 0.85 : 0.10,
searchRadius: searchRadius ? 0.90 : 0.20,
prestigeImportance: prestigeImportance ? 0.80 : 0.20,
parkingNeed: parkingNeed ? 0.90 : 0.30,
}
@@ -362,6 +370,7 @@ export function mockParseNeed(input: string): ParseNeedResult {
requiredFitOut: fitOutStr,
minCeilingHeightM,
minContractDurationMonths,
searchRadius,
notes,
},
confidenceByField,
@@ -1,650 +1,12 @@
/**
* OpenRouter AI Service
* @deprecated Direct OpenRouter calls from the frontend have been removed.
*
* Activation:
* VITE_AI_PROVIDER=openrouter
* VITE_OPENROUTER_API_KEY=<your-key>
* VITE_OPENROUTER_MODEL=anthropic/claude-3-5-haiku (optional, default shown)
* All LLM requests now go through the PowerOn backend proxy at /api/ai/chat/completions
* so that the API key never appears in the browser bundle.
*
* 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
* This file is kept as a compatibility re-export so that any existing imports
* of `OpenRouterAIService` continue to compile without changes.
*
* No invalid data ever reaches the UI.
* → Implementation moved to: src/services/ai/backend/BackendAIService.ts
*/
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 { 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 ────────────────────────────────────────────────────────────────────
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,
extras: { fallbackReason?: string } = {},
): AIProvenance {
return {
provider: 'openrouter',
model: config.model,
generatedAt: new Date().toISOString(),
promptVersion: PROMPT_VERSION,
schemaVersion: SCHEMA_VERSION,
source,
fallbackUsed,
validationPassed,
traceId: crypto.randomUUID(),
fallbackReason: extras.fallbackReason,
}
}
// ── 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 (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: (config: OpenRouterConfig) => Promise<AIResponse<T>>,
fallback: FallbackFn<T>,
inputSizeChars?: number,
): Promise<AIResponse<T>> {
const config = getConfig()
const startMs = Date.now()
const callId = crypto.randomUUID()
if (!config) {
console.warn(`[OpenRouterAIService] ${label}: no API key — using MockAIService`)
const result = await fallback()
const latencyMs = Date.now() - startMs
const provenance: AIProvenance = {
...result.provenance,
fallbackUsed: true,
traceId: callId,
fallbackReason: 'no_api_key',
schemaVersion: SCHEMA_VERSION,
latencyMs,
}
aiTraceStore.add({
id: callId,
method: label,
provider: 'openrouter',
model: DEFAULT_MODEL,
promptVersion: PROMPT_VERSION,
latencyMs,
fallbackUsed: true,
validationPassed: false,
responseValidationStatus: 'fallback',
errorType: 'no_api_key',
fallbackReason: 'no_api_key',
source: 'mock',
createdAt: new Date().toISOString(),
inputSizeChars,
})
return { ...result, provenance }
}
try {
const result = await fn(config)
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(`[OpenRouterAIService] ${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: 'openrouter',
model: config.model,
promptVersion: PROMPT_VERSION,
latencyMs,
fallbackUsed: true,
validationPassed: false,
responseValidationStatus,
errorType,
fallbackReason,
source: 'mock',
createdAt: new Date().toISOString(),
inputSizeChars,
})
return { ...result, provenance }
}
}
// ── 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, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
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, 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 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), input.length)
},
// ── generateFollowUpQuestions ───────────────────────────────────────────────
generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise<AIResponse<FollowUpQuestion[]>> {
return withFallback('generateFollowUpQuestions', async (config) => {
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(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, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
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, { 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(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, { 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(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, { 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(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, { 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(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, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
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, { 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(config, 'ai', false, true),
}
}, () => MockAIService.classifyMarketSignal(signalText), signalText.length)
},
// ── 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, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
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, { 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(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, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
return {
data: ai.map(q => q.questionText).filter(Boolean),
provenance: makeProvenance(config, 'ai', false, true),
}
}, () => MockAIService.generateFollowUp(partialNeed))
},
}
export { BackendAIService as OpenRouterAIService } from '../backend/BackendAIService'
@@ -16,6 +16,10 @@ export function generateSummary(c: ParsedNeedCriteria): string {
if (c.budgetRange?.maxPerSqm) parts.push(`Budget max. CHF ${c.budgetRange.maxPerSqm}/m²`)
if (c.timing?.earliestMoveIn) parts.push(`ab ${c.timing.earliestMoveIn}`)
if (c.mustHaveCriteria?.length) parts.push(`Must-haves: ${c.mustHaveCriteria.join(', ')}`)
if (c.searchRadius) parts.push(`Radius ${c.searchRadius} km`)
if (c.isAnonymous) parts.push('Anonyme Suche')
if (c.requiresDivisibility && c.minDivisibleUnit) parts.push(`Teilbar ab ${c.minDivisibleUnit}`)
if (c.fitOutBudgetMaxPerSqm) parts.push(`Ausbaubudget max. CHF ${c.fitOutBudgetMaxPerSqm}/m²`)
return parts.join(', ')
}
@@ -50,6 +54,11 @@ export function buildNeedInput(
requireBarrierFree: criteria.requireBarrierFree,
minCeilingHeightM: criteria.minCeilingHeightM,
minContractDurationMonths: criteria.minContractDurationMonths,
searchRadius: criteria.searchRadius,
isAnonymous: criteria.isAnonymous,
requiresDivisibility: criteria.requiresDivisibility,
minDivisibleUnit: criteria.minDivisibleUnit,
fitOutBudgetMaxPerSqm: criteria.fitOutBudgetMaxPerSqm,
notes: criteria.notes,
extractedFromText: undefined,
}
+15
View File
@@ -0,0 +1,15 @@
import { MockupUnitProvider } from '../provider/MockupUnitProvider'
import type { PropertyUnit } from '../domain/property'
import { throwServiceError } from './errors'
import type { ItemResponse } from './types'
export const unitService = {
async update(unitId: string, data: Partial<PropertyUnit>): Promise<ItemResponse<PropertyUnit>> {
try {
const unit = await MockupUnitProvider.update(unitId, data)
return { data: unit }
} catch (err) {
throwServiceError('unitService.update', err)
}
},
}