feat: Zod AI validation, AIProvenance governance, fix tests (154 green)

- Add AIProvenance + AIResponse<T> to IAIService — all 11 methods now
  return structured provenance (provider, model, source, fallbackUsed,
  validationPassed) instead of bare ItemResponse<T>
- Add schemas.ts with Zod schemas for all 8 AI response types;
  validateAIResponse() utility returns null on failure, never throws
- Rewrite OpenRouterAIService: every method validates AI JSON against
  its Zod schema; failed validation triggers MockAIService fallback
  with fallbackUsed:true — no invalid data can reach the UI
- Fix MockAIService.generateFollowUpQuestions: replace broken
  mockParseNeed(JSON.stringify(criteria)) with direct ParsedNeedCriteria
  field inspection; returns max 3 prioritised FollowUpQuestion objects
- Add provenance: mockProvenance() to all MockAIService responses
- Improve decisionBriefPrompt: structured JSON schema example,
  confidence vocabulary, availability disclaimer
- Improve matchExplanationPrompt: score-tier vocabulary, isFutureSignal
  flag forbids confirmed-availability language for future signals
- Add 102 new tests: mustHaveScorer (16), softFactorEnrichment (38),
  aiSchemas (52) — 154 total, all passing; 0 TypeScript errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-24 13:44:46 +02:00
parent 8f1db31683
commit e62391af66
9 changed files with 1259 additions and 235 deletions
+227 -189
View File
@@ -6,22 +6,24 @@
* VITE_OPENROUTER_API_KEY=<your-key>
* VITE_OPENROUTER_MODEL=anthropic/claude-3-5-haiku (optional, default shown)
*
* All methods follow this contract:
* 1. If API key is missing → explicit warn + MockAIService fallback
* 2. If API call fails → explicit error log + MockAIService fallback
* 3. If JSON parse fails explicit warn + MockAIService fallback
* 4. On success → fully AI-generated response, no silent mock merge
* 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
*
* Methods that use a hybrid approach (AI text merged into mock structure) are
* explicitly documented with why mock data fills the remaining fields.
* No invalid data ever reaches the UI.
*/
import type { ItemResponse } from '../../types'
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,
@@ -36,6 +38,17 @@ import type {
} from '../IAIService'
import { ServiceErrorCode } from '../../types'
import { AppError } from '../../errors'
import {
NeedParsingResponseSchema,
FollowUpQuestionsResponseSchema,
TradeOffSummaryResponseSchema,
CompareSummaryResponseSchema,
DecisionBriefResponseSchema,
DataQualitySummaryResponseSchema,
MarketSignalClassificationResponseSchema,
OfferEmailResponseSchema,
validateAIResponse,
} from '../schemas'
import { buildNeedParsingPrompt } from '../prompts/needParsingPrompt'
import { buildFollowUpQuestionsPrompt } from '../prompts/followUpQuestionsPrompt'
import { buildMatchExplanationPrompt } from '../prompts/matchExplanationPrompt'
@@ -48,9 +61,9 @@ 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.0'
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 {
@@ -67,28 +80,45 @@ function getConfig(): OpenRouterConfig | null {
}
}
function makeProvenance(
config: OpenRouterConfig,
source: AIProvenance['source'],
fallbackUsed: boolean,
validationPassed: boolean,
): AIProvenance {
return {
provider: 'openrouter',
model: config.model,
generatedAt: new Date().toISOString(),
promptVersion: PROMPT_VERSION,
source,
fallbackUsed,
validationPassed,
}
}
// ── HTTP helper ───────────────────────────────────────────────────────────────
async function chat(config: OpenRouterConfig, system: string, user: string): Promise<string> {
const res = await fetch(`${API_BASE}/chat/completions`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${config.apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': window.location.origin,
'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 },
{ role: 'user', content: user },
],
}),
})
if (!res.ok) {
const body = await res.text()
throw new AppError({
code: ServiceErrorCode.AI_GENERATION_FAILED,
code: ServiceErrorCode.AI_GENERATION_FAILED,
message: `OpenRouter error ${res.status}: ${body}`,
})
}
@@ -99,8 +129,7 @@ async function chat(config: OpenRouterConfig, system: string, user: string): Pro
// ── JSON extraction ───────────────────────────────────────────────────────────
function extractJSON<T>(raw: string): T | null {
// Try fenced code block first, then bare object/array
const fenced = raw.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/)
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
@@ -109,7 +138,7 @@ function extractJSON<T>(raw: string): T | null {
}
}
// ── Helpers for ParseNeedResult mapping ───────────────────────────────────────
// ── ParseNeed helpers ─────────────────────────────────────────────────────────
type RawNeedParseAI = {
assetType?: string | null
@@ -124,12 +153,12 @@ type RawNeedParseAI = {
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²)?',
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)?',
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?`
}
@@ -145,23 +174,25 @@ function defaultSuggestedWeights(): Record<string, number> {
// ── Fallback wrapper ──────────────────────────────────────────────────────────
type FallbackFn<T> = () => Promise<ItemResponse<T>>
type FallbackFn<T> = () => Promise<AIResponse<T>>
async function withFallback<T>(
label: string,
fn: (config: OpenRouterConfig) => Promise<ItemResponse<T>>,
fn: (config: OpenRouterConfig) => Promise<AIResponse<T>>,
fallback: FallbackFn<T>,
): Promise<ItemResponse<T>> {
): Promise<AIResponse<T>> {
const config = getConfig()
if (!config) {
console.warn(`[OpenRouterAIService] ${label}: no API key — using MockAIService`)
return fallback()
const result = await fallback()
return { ...result, provenance: { ...result.provenance, fallbackUsed: true } }
}
try {
return await fn(config)
} catch (err) {
console.error(`[OpenRouterAIService] ${label} failed:`, err)
return fallback()
const result = await fallback()
return { ...result, provenance: { ...result.provenance, fallbackUsed: true } }
}
}
@@ -170,20 +201,24 @@ async function withFallback<T>(
export const OpenRouterAIService: IAIService = {
// ── parseNeed ───────────────────────────────────────────────────────────────
parseNeed(input: string): Promise<ItemResponse<ParseNeedResult>> {
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 ai = extractJSON<RawNeedParseAI>(raw)
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: could not parse JSON — using mock fallback')
return MockAIService.parseNeed(input)
console.warn('[OpenRouterAIService] parseNeed: invalid response — using mock fallback')
const fb = await MockAIService.parseNeed(input)
return { ...fb, provenance: makeProvenance(config, 'mock', true, false) }
}
const extractedCriteria: ParsedNeedCriteria = {
assetType: (ai.assetType ?? undefined) as AssetType | undefined,
areaRange: ai.areaRange ?? undefined,
assetType: (ai.assetType ?? undefined) as AssetType | undefined,
areaRange: ai.areaRange ?? undefined,
preferredLocations: ai.preferredLocations,
budgetRange: ai.budgetRange ?? undefined,
budgetRange: ai.budgetRange ?? undefined,
timing: ai.timing
? { ...ai.timing, latestMoveIn: ai.timing.latestMoveIn ?? undefined }
: undefined,
@@ -196,67 +231,71 @@ export const OpenRouterAIService: IAIService = {
})
missingFields.forEach(f => { confidenceByField[f] = 0 })
const followUpQuestionCandidates: FollowUpQuestion[] = missingFields.map((field, i) => ({
id: `fq-or-${i}`,
id: `fq-or-${i}`,
questionText: followUpForField(field),
targetField: field,
reason: `Feld "${field}" nicht im Text erkannt`,
importance: 'recommended' as const,
targetField: field,
reason: `Feld "${field}" nicht im Text erkannt`,
importance: 'recommended' as const,
}))
return {
data: {
extractedCriteria,
confidenceByField,
missingFields,
assumptions: ai.assumptions ?? [],
suggestedWeights: defaultSuggestedWeights(),
assumptions: ai.assumptions ?? [],
suggestedWeights: defaultSuggestedWeights(),
followUpQuestionCandidates,
rawSummary: raw.substring(0, 500),
promptVersion: PROMPT_VERSION,
schemaVersion: SCHEMA_VERSION,
rawSummary: raw.substring(0, 500),
promptVersion: PROMPT_VERSION,
schemaVersion: SCHEMA_VERSION,
},
provenance: makeProvenance(config, 'ai', false, true),
}
}, () => MockAIService.parseNeed(input))
},
// ── generateFollowUpQuestions ───────────────────────────────────────────────
generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise<ItemResponse<FollowUpQuestion[]>> {
generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise<AIResponse<FollowUpQuestion[]>> {
return withFallback('generateFollowUpQuestions', async (config) => {
const missingFields = Object.entries(criteria)
.filter(([, v]) => v == null)
.map(([k]) => k)
const { system, user } = buildFollowUpQuestionsPrompt({ criteria, missingFields })
const raw = await chat(config, system, user)
type RawFQ = { questionText?: string; targetField?: string; reason?: string; suggestedAnswerOptions?: string[]; importance?: string }
const ai = extractJSON<RawFQ[]>(raw)
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: empty response — using mock fallback')
return MockAIService.generateFollowUpQuestions(criteria)
console.warn('[OpenRouterAIService] generateFollowUpQuestions: invalid response — using mock fallback')
const fb = await MockAIService.generateFollowUpQuestions(criteria)
return { ...fb, provenance: makeProvenance(config, 'mock', true, false) }
}
return {
data: ai.map((q, i) => ({
id: `fq-or-${i}`,
questionText: q.questionText ?? '?',
targetField: q.targetField ?? 'unknown',
reason: q.reason ?? 'AI-generiert',
id: `fq-or-${i}`,
questionText: q.questionText,
targetField: q.targetField,
reason: q.reason ?? 'AI-generiert',
suggestedAnswerOptions: q.suggestedAnswerOptions,
importance: (['required', 'recommended', 'optional'].includes(q.importance ?? '')
? q.importance
: 'recommended') as FollowUpQuestion['importance'],
importance: (q.importance ?? 'recommended') as FollowUpQuestion['importance'],
})),
provenance: makeProvenance(config, 'ai', false, true),
}
}, () => MockAIService.generateFollowUpQuestions(criteria))
},
// ── generateMatchExplanation ────────────────────────────────────────────────
generateMatchExplanation(input: MatchExplanationInput): Promise<ItemResponse<MatchExplanation>> {
// 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)
// matchExplanationPrompt returns plain text (max 3 sentences), not JSON
const raw = await chat(config, system, user)
const summary = raw.trim()
if (!summary) {
console.warn('[OpenRouterAIService] generateMatchExplanation: empty response — using mock fallback')
return MockAIService.generateMatchExplanation(input)
const fb = await MockAIService.generateMatchExplanation(input)
return { ...fb, provenance: makeProvenance(config, 'mock', true, false) }
}
const scoreLabel = input.matchScore >= 78 ? 'Starkes' : input.matchScore >= 52 ? 'Gutes' : 'Schwaches'
return {
@@ -268,43 +307,43 @@ export const OpenRouterAIService: IAIService = {
...input.negativeFactors.slice(0, 1).map(f => ` ${f.explanation}`),
],
},
provenance: makeProvenance(config, 'ai', false, true),
}
}, () => MockAIService.generateMatchExplanation(input))
},
// ── summarizeTradeOffs ──────────────────────────────────────────────────────
summarizeTradeOffs(tradeoffs: TradeOffInput[]): Promise<ItemResponse<TradeOffSummary>> {
summarizeTradeOffs(tradeoffs: TradeOffInput[]): Promise<AIResponse<TradeOffSummary>> {
return withFallback('summarizeTradeOffs', async (config) => {
const { system, user } = buildTradeOffPrompt(tradeoffs, 'Objekt')
const raw = await chat(config, system, user)
type RawTradeOff = {
headline?: string
items?: Array<{ concern?: string; severity?: string; mitigation?: string }>
overallRisk?: string
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) }
}
const ai = extractJSON<RawTradeOff>(raw)
if (!ai?.headline) {
console.warn('[OpenRouterAIService] summarizeTradeOffs: incomplete response — using mock fallback')
return MockAIService.summarizeTradeOffs(tradeoffs)
}
const validSeverity = (s?: string): 'LOW' | 'MEDIUM' | 'HIGH' =>
(['LOW', 'MEDIUM', 'HIGH'].includes(s ?? '') ? s : 'MEDIUM') as 'LOW' | 'MEDIUM' | 'HIGH'
return {
data: {
headline: ai.headline,
items: (ai.items ?? []).map(item => ({
concern: item.concern ?? '',
severity: validSeverity(item.severity),
headline: ai.headline,
items: ai.items.map(item => ({
concern: item.concern,
severity: item.severity,
mitigation: item.mitigation,
})),
overallRisk: validSeverity(ai.overallRisk),
overallRisk: ai.overallRisk,
},
provenance: makeProvenance(config, 'ai', false, true),
}
}, () => MockAIService.summarizeTradeOffs(tradeoffs))
},
// ── summarizeComparison ─────────────────────────────────────────────────────
summarizeComparison(items: UnifiedMatchResult[]): Promise<ItemResponse<ComparisonSummary>> {
// 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 }
@@ -312,200 +351,199 @@ export const OpenRouterAIService: IAIService = {
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,
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)
type RawComparison = {
overallAssessment?: string
recommendation?: string
strongestOption?: { matchId?: string; label?: string; reason?: string }
const raw = await chat(config, system, user)
const json = extractJSON<unknown>(raw)
const ai = json ? validateAIResponse(CompareSummaryResponseSchema, json, 'summarizeComparison') : null
if (!ai) {
console.warn('[OpenRouterAIService] summarizeComparison: invalid response — using mock fallback')
const fb = await MockAIService.summarizeComparison(items)
return { ...fb, provenance: makeProvenance(config, 'mock', true, false) }
}
const ai = extractJSON<RawComparison>(raw)
if (!ai?.overallAssessment) {
console.warn('[OpenRouterAIService] summarizeComparison: incomplete response — using mock fallback')
return MockAIService.summarizeComparison(items)
}
// Hybrid: AI provides the narrative, mock provides the structural data (perPropertyAssessment etc.)
const mock = await MockAIService.summarizeComparison(items)
return {
data: {
...mock.data,
overallAssessment: ai.overallAssessment,
recommendation: ai.recommendation ?? mock.data.recommendation,
recommendation: ai.recommendation ?? mock.data.recommendation,
},
provenance: makeProvenance(config, 'hybrid', false, true),
}
}, () => MockAIService.summarizeComparison(items))
},
// ── generateDecisionBrief ───────────────────────────────────────────────────
generateDecisionBrief(shortlistId: string): Promise<ItemResponse<DecisionBrief>> {
// 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)
type RawBrief = {
summary?: string
sections?: Array<{ title?: string; body?: string }>
}
const ai = extractJSON<RawBrief>(raw)
if (!ai?.summary) {
console.warn('[OpenRouterAIService] generateDecisionBrief: incomplete response — using mock fallback')
return MockAIService.generateDecisionBrief(shortlistId)
const raw = await chat(config, system, user)
const json = extractJSON<unknown>(raw)
const ai = json ? validateAIResponse(DecisionBriefResponseSchema, json, 'generateDecisionBrief') : null
if (!ai) {
console.warn('[OpenRouterAIService] generateDecisionBrief: invalid response — using mock fallback')
const fb = await MockAIService.generateDecisionBrief(shortlistId)
return { ...fb, provenance: makeProvenance(config, 'mock', true, false) }
}
const mock = await MockAIService.generateDecisionBrief(shortlistId)
return {
data: {
...mock.data,
summary: ai.summary,
sections: ai.sections?.map(s => ({
title: s.title ?? '',
body: s.body ?? '',
})) ?? mock.data.sections,
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<ItemResponse<DataQualitySummary>> {
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)
type RawDQ = {
overallAssessment?: string
missingCriticalFields?: string[]
recommendation?: string
confidence?: number
}
const ai = extractJSON<RawDQ>(raw)
if (!ai?.overallAssessment) {
console.warn('[OpenRouterAIService] generateDataQualitySummary: incomplete response — using mock fallback')
return MockAIService.generateDataQualitySummary(propertyId, quality)
const raw = await chat(config, system, user)
const json = extractJSON<unknown>(raw)
const ai = json ? validateAIResponse(DataQualitySummaryResponseSchema, json, 'generateDataQualitySummary') : null
if (!ai) {
console.warn('[OpenRouterAIService] generateDataQualitySummary: invalid response — using mock fallback')
const fb = await MockAIService.generateDataQualitySummary(propertyId, quality)
return { ...fb, provenance: makeProvenance(config, 'mock', true, false) }
}
return {
data: {
overallAssessment: ai.overallAssessment,
overallAssessment: ai.overallAssessment,
missingCriticalFields: ai.missingCriticalFields ?? quality.missingCriticalFields,
recommendation: ai.recommendation ?? '',
confidence: typeof ai.confidence === 'number' ? ai.confidence : quality.score,
recommendation: ai.recommendation,
confidence: ai.confidence,
},
provenance: makeProvenance(config, 'ai', false, true),
}
}, () => MockAIService.generateDataQualitySummary(propertyId, quality))
},
// ── classifyMarketSignal ────────────────────────────────────────────────────
classifyMarketSignal(signalText: string): Promise<ItemResponse<MarketSignalClassification>> {
classifyMarketSignal(signalText: string): Promise<AIResponse<MarketSignalClassification>> {
return withFallback('classifyMarketSignal', async (config) => {
const { system, user } = buildMarketSignalPrompt(signalText)
const raw = await chat(config, system, user)
type RawSignal = {
signalType?: string
probability?: number
timeHorizonMonths?: number | null
areaSqmEstimate?: number | null
credibility?: string
reasoning?: string
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) }
}
const ai = extractJSON<RawSignal>(raw)
if (!ai?.signalType) {
console.warn('[OpenRouterAIService] classifyMarketSignal: incomplete response — using mock fallback')
return MockAIService.classifyMarketSignal(signalText)
}
const validSignalType = (s?: string): MarketSignalClassification['signalType'] => {
const valid: MarketSignalClassification['signalType'][] =
['VACANCY', 'CONSTRUCTION', 'RESTRUCTURING', 'EXPANSION', 'RELOCATION', 'UNKNOWN']
return (valid.includes(s as MarketSignalClassification['signalType']) ? s : 'UNKNOWN') as MarketSignalClassification['signalType']
}
const validCredibility = (s?: string): 'LOW' | 'MEDIUM' | 'HIGH' =>
(['LOW', 'MEDIUM', 'HIGH'].includes(s ?? '') ? s : 'MEDIUM') as 'LOW' | 'MEDIUM' | 'HIGH'
return {
data: {
signalType: validSignalType(ai.signalType),
probability: typeof ai.probability === 'number'
? Math.min(1, Math.max(0, ai.probability))
: 0.5,
timeHorizonMonths: typeof ai.timeHorizonMonths === 'number' ? ai.timeHorizonMonths : null,
areaSqmEstimate: typeof ai.areaSqmEstimate === 'number' ? ai.areaSqmEstimate : null,
credibility: validCredibility(ai.credibility),
reasoning: ai.reasoning ?? '',
signalType: ai.signalType,
probability: ai.probability,
timeHorizonMonths: ai.timeHorizonMonths ?? null,
areaSqmEstimate: ai.areaSqmEstimate ?? null,
credibility: ai.credibility,
reasoning: ai.reasoning,
},
provenance: makeProvenance(config, 'ai', false, true),
}
}, () => MockAIService.classifyMarketSignal(signalText))
},
// ── generateOfferEmail ──────────────────────────────────────────────────────
generateOfferEmail(payload: OfferEmailPayload): Promise<ItemResponse<{ subject: string; body: string }>> {
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)
type RawEmail = { subject?: string; body?: string }
const ai = extractJSON<RawEmail>(raw)
if (!ai?.subject || !ai?.body) {
console.warn('[OpenRouterAIService] generateOfferEmail: incomplete response — using mock fallback')
return MockAIService.generateOfferEmail(payload)
const user = `Suchanfrage: "${payload.needTitle}"\n\nObjekte:\n${propertyList}\n\nErstelle eine professionelle Angebotsmail.`
const raw = await chat(config, system, user)
const json = extractJSON<unknown>(raw)
const ai = json ? validateAIResponse(OfferEmailResponseSchema, json, 'generateOfferEmail') : null
if (!ai) {
console.warn('[OpenRouterAIService] generateOfferEmail: invalid response — using mock fallback')
const fb = await MockAIService.generateOfferEmail(payload)
return { ...fb, provenance: makeProvenance(config, 'mock', true, false) }
}
return {
data: { subject: ai.subject, body: ai.body },
provenance: makeProvenance(config, 'ai', false, true),
}
return { data: { subject: ai.subject, body: ai.body } }
}, () => MockAIService.generateOfferEmail(payload))
},
// ── Legacy: extractCriteria ─────────────────────────────────────────────────
extractCriteria(input: string): Promise<ItemResponse<CriteriaExtractionResult>> {
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 ai = extractJSON<RawNeedParseAI>(raw)
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: could not parse JSON — using mock fallback')
return MockAIService.extractCriteria(input)
console.warn('[OpenRouterAIService] extractCriteria: invalid response — using mock fallback')
const fb = await MockAIService.extractCriteria(input)
return { ...fb, provenance: makeProvenance(config, 'mock', true, false) }
}
return {
data: {
extractedCriteria: {
assetType: ai.assetType as AssetType | undefined ?? undefined,
requiredArea: ai.areaRange ?? undefined,
assetType: ai.assetType as AssetType | undefined ?? undefined,
requiredArea: ai.areaRange ?? undefined,
preferredLocations: ai.preferredLocations ?? [],
budgetRange: ai.budgetRange ?? undefined,
budgetRange: ai.budgetRange ?? undefined,
},
confidence: 0.80,
missingFields: ai.missingFields ?? [],
assumptions: ai.assumptions ?? [],
followUpQuestions: (ai.missingFields ?? []).map(followUpForField),
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<ItemResponse<string[]>> {
generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<AIResponse<string[]>> {
return withFallback('generateFollowUp', async (config) => {
const missingFields = [
...(!partialNeed.assetType ? ['assetType'] : []),
...(!partialNeed.assetType ? ['assetType'] : []),
...(!partialNeed.preferredLocations?.length ? ['preferredLocations'] : []),
...(!partialNeed.timing ? ['timing'] : []),
...(!partialNeed.budgetRange ? ['budgetRange'] : []),
...(!partialNeed.timing ? ['timing'] : []),
...(!partialNeed.budgetRange ? ['budgetRange'] : []),
]
if (!missingFields.length) return { data: [] }
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)
type RawFQ = { questionText?: string }
const ai = extractJSON<RawFQ[]>(raw)
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: empty response — using mock fallback')
return MockAIService.generateFollowUp(partialNeed)
console.warn('[OpenRouterAIService] generateFollowUp: invalid response — using mock fallback')
const fb = await MockAIService.generateFollowUp(partialNeed)
return { ...fb, provenance: makeProvenance(config, 'mock', true, false) }
}
return {
data: ai.map(q => q.questionText).filter(Boolean),
provenance: makeProvenance(config, 'ai', false, true),
}
return { data: ai.map(q => q.questionText ?? '').filter(Boolean) }
}, () => MockAIService.generateFollowUp(partialNeed))
},
}