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
+56 -14
View File
@@ -1,8 +1,52 @@
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'
// ── AI Provenance ─────────────────────────────────────────────────────────────
// Attached to every AI response so the UI can always trace where data came from.
export interface AIProvenance {
/** Which AI provider produced this response */
provider: 'openrouter' | 'mock'
/** Exact model ID (e.g. 'anthropic/claude-3-5-haiku') or 'mock' */
model: string
/** ISO-8601 timestamp of generation */
generatedAt: string
/** Prompt version string used to generate this response */
promptVersion: string
/** Whether the response is AI-only, mock-only, or a hybrid merge */
source: 'ai' | 'mock' | 'hybrid'
/** True when the original AI call failed and mock was substituted */
fallbackUsed: boolean
/** True when the AI response passed Zod schema validation */
validationPassed: boolean
}
/**
* All AI service methods return AIResponse<T> instead of ItemResponse<T>.
* `data` is backward-compatible — existing call sites using `result.data.xxx`
* continue to work unchanged.
*/
export type AIResponse<T> = {
data: T
provenance: AIProvenance
}
// ── Helper ────────────────────────────────────────────────────────────────────
export function mockProvenance(overrides?: Partial<AIProvenance>): AIProvenance {
return {
provider: 'mock',
model: 'mock',
generatedAt: new Date().toISOString(),
promptVersion: 'mock',
source: 'mock',
fallbackUsed: false,
validationPassed: true,
...overrides,
}
}
// ── Response Types ────────────────────────────────────────────────────────────
export interface DecisionBrief {
@@ -49,8 +93,6 @@ export interface ParsedListingData {
fitOut?: string
}
// ── New Response Types ────────────────────────────────────────────────────────
export interface MatchExplanation {
headline: string
summary: string
@@ -124,29 +166,29 @@ export interface AIServiceProvider {
export interface IAIService {
// Need parsing (F008)
parseNeed(input: string): Promise<ItemResponse<ParseNeedResult>>
generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise<ItemResponse<FollowUpQuestion[]>>
parseNeed(input: string): Promise<AIResponse<ParseNeedResult>>
generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise<AIResponse<FollowUpQuestion[]>>
// Match explainability (F004)
generateMatchExplanation(input: MatchExplanationInput): Promise<ItemResponse<MatchExplanation>>
summarizeTradeOffs(tradeoffs: TradeOffInput[]): Promise<ItemResponse<TradeOffSummary>>
generateMatchExplanation(input: MatchExplanationInput): Promise<AIResponse<MatchExplanation>>
summarizeTradeOffs(tradeoffs: TradeOffInput[]): Promise<AIResponse<TradeOffSummary>>
// Compare (F014)
summarizeComparison(items: UnifiedMatchResult[]): Promise<ItemResponse<ComparisonSummary>>
summarizeComparison(items: UnifiedMatchResult[]): Promise<AIResponse<ComparisonSummary>>
// Shortlist decision brief
generateDecisionBrief(shortlistId: string): Promise<ItemResponse<DecisionBrief>>
generateDecisionBrief(shortlistId: string): Promise<AIResponse<DecisionBrief>>
// Data quality
generateDataQualitySummary(propertyId: string, quality: DataQualityInput): Promise<ItemResponse<DataQualitySummary>>
generateDataQualitySummary(propertyId: string, quality: DataQualityInput): Promise<AIResponse<DataQualitySummary>>
// Market signal classification (OPERATIONS)
classifyMarketSignal(signalText: string): Promise<ItemResponse<MarketSignalClassification>>
classifyMarketSignal(signalText: string): Promise<AIResponse<MarketSignalClassification>>
// Offer email (supply side)
generateOfferEmail(payload: OfferEmailPayload): Promise<ItemResponse<{ subject: string; body: string }>>
generateOfferEmail(payload: OfferEmailPayload): Promise<AIResponse<{ subject: string; body: string }>>
// Legacy methods
extractCriteria(input: string): Promise<ItemResponse<CriteriaExtractionResult>>
generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<ItemResponse<string[]>>
extractCriteria(input: string): Promise<AIResponse<CriteriaExtractionResult>>
generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<AIResponse<string[]>>
}
+345
View File
@@ -0,0 +1,345 @@
import { describe, it, expect } from 'vitest'
import {
NeedParsingResponseSchema,
FollowUpQuestionsResponseSchema,
TradeOffSummaryResponseSchema,
CompareSummaryResponseSchema,
DecisionBriefResponseSchema,
DataQualitySummaryResponseSchema,
MarketSignalClassificationResponseSchema,
OfferEmailResponseSchema,
ProbabilitySchema,
validateAIResponse,
} from '../schemas'
// ── ProbabilitySchema ─────────────────────────────────────────────────────────
describe('ProbabilitySchema', () => {
it('accepts 0', () => expect(ProbabilitySchema.safeParse(0).success).toBe(true))
it('accepts 1', () => expect(ProbabilitySchema.safeParse(1).success).toBe(true))
it('accepts 0.65', () => expect(ProbabilitySchema.safeParse(0.65).success).toBe(true))
it('rejects -5', () => expect(ProbabilitySchema.safeParse(-5).success).toBe(false))
it('rejects 1.5', () => expect(ProbabilitySchema.safeParse(1.5).success).toBe(false))
it('rejects NaN', () => expect(ProbabilitySchema.safeParse(NaN).success).toBe(false))
})
// ── NeedParsingResponseSchema ─────────────────────────────────────────────────
describe('NeedParsingResponseSchema', () => {
it('accepts a complete valid response', () => {
const result = NeedParsingResponseSchema.safeParse({
assetType: 'OFFICE',
areaRange: { min: 200, max: 500 },
preferredLocations: ['Zürich', 'Basel'],
budgetRange: { maxPerSqm: 45, currency: 'CHF' },
timing: { earliestMoveIn: '2025-01-01', flexibleTiming: false },
mustHaveCriteria: ['Erdgeschoss'],
missingFields: [],
assumptions: ['Fläche geschätzt'],
})
expect(result.success).toBe(true)
})
it('accepts a minimal response (all fields optional)', () => {
const result = NeedParsingResponseSchema.safeParse({})
expect(result.success).toBe(true)
})
it('accepts null for nullable fields', () => {
const result = NeedParsingResponseSchema.safeParse({ assetType: null, areaRange: null })
expect(result.success).toBe(true)
})
it('rejects areaRange where max < min', () => {
const result = NeedParsingResponseSchema.safeParse({
areaRange: { min: 500, max: 200 },
})
expect(result.success).toBe(false)
})
it('rejects unknown assetType value', () => {
const result = NeedParsingResponseSchema.safeParse({ assetType: 'WAREHOUSE' })
expect(result.success).toBe(false)
})
it('rejects negative area values', () => {
const result = NeedParsingResponseSchema.safeParse({ areaRange: { min: -10, max: 500 } })
expect(result.success).toBe(false)
})
})
// ── FollowUpQuestionsResponseSchema ───────────────────────────────────────────
describe('FollowUpQuestionsResponseSchema', () => {
const validQuestion = {
questionText: 'Welchen Nutzungstyp suchen Sie?',
targetField: 'assetType',
reason: 'Pflichtfeld fehlt',
importance: 'required',
}
it('accepts valid question array', () => {
const result = FollowUpQuestionsResponseSchema.safeParse([validQuestion])
expect(result.success).toBe(true)
})
it('accepts empty array', () => {
const result = FollowUpQuestionsResponseSchema.safeParse([])
expect(result.success).toBe(true)
})
it('rejects array with more than 5 items', () => {
const questions = Array(6).fill(validQuestion)
const result = FollowUpQuestionsResponseSchema.safeParse(questions)
expect(result.success).toBe(false)
})
it('rejects question with empty questionText', () => {
const result = FollowUpQuestionsResponseSchema.safeParse([{ ...validQuestion, questionText: '' }])
expect(result.success).toBe(false)
})
it('rejects unknown importance value', () => {
const result = FollowUpQuestionsResponseSchema.safeParse([{ ...validQuestion, importance: 'critical' }])
expect(result.success).toBe(false)
})
})
// ── TradeOffSummaryResponseSchema ─────────────────────────────────────────────
describe('TradeOffSummaryResponseSchema', () => {
const valid = {
headline: 'Hohe Mietkosten',
items: [{ concern: 'Budget überschritten', severity: 'HIGH', mitigation: 'Verhandlung möglich' }],
overallRisk: 'HIGH',
}
it('accepts valid trade-off summary', () => {
expect(TradeOffSummaryResponseSchema.safeParse(valid).success).toBe(true)
})
it('rejects unknown severity value', () => {
const bad = { ...valid, items: [{ concern: 'test', severity: 'CRITICAL' }] }
expect(TradeOffSummaryResponseSchema.safeParse(bad).success).toBe(false)
})
it('rejects unknown overallRisk value', () => {
const bad = { ...valid, overallRisk: 'EXTREME' }
expect(TradeOffSummaryResponseSchema.safeParse(bad).success).toBe(false)
})
it('rejects more than 5 items', () => {
const bad = { ...valid, items: Array(6).fill({ concern: 'x', severity: 'LOW' }) }
expect(TradeOffSummaryResponseSchema.safeParse(bad).success).toBe(false)
})
it('rejects missing headline', () => {
const { headline: _, ...bad } = valid
expect(TradeOffSummaryResponseSchema.safeParse(bad).success).toBe(false)
})
})
// ── CompareSummaryResponseSchema ──────────────────────────────────────────────
describe('CompareSummaryResponseSchema', () => {
it('accepts valid compare summary', () => {
const result = CompareSummaryResponseSchema.safeParse({
overallAssessment: 'Objekt A ist am besten geeignet.',
recommendation: 'Besichtigungstermin für Objekt A vereinbaren.',
strongestOption: 'Objekt A',
})
expect(result.success).toBe(true)
})
it('accepts response with only overallAssessment (others optional)', () => {
const result = CompareSummaryResponseSchema.safeParse({ overallAssessment: 'Gute Optionen.' })
expect(result.success).toBe(true)
})
it('rejects empty overallAssessment', () => {
const result = CompareSummaryResponseSchema.safeParse({ overallAssessment: '' })
expect(result.success).toBe(false)
})
})
// ── DecisionBriefResponseSchema ───────────────────────────────────────────────
describe('DecisionBriefResponseSchema', () => {
const validSection = { title: 'Zusammenfassung', body: 'Überblick über die Situation.' }
it('accepts valid decision brief', () => {
const result = DecisionBriefResponseSchema.safeParse({
summary: 'Executive Summary.',
sections: [validSection, { title: 'Empfehlung', body: 'Objekt A priorisieren.' }],
})
expect(result.success).toBe(true)
})
it('rejects missing sections', () => {
const result = DecisionBriefResponseSchema.safeParse({ summary: 'OK' })
expect(result.success).toBe(false)
})
it('rejects empty sections array', () => {
const result = DecisionBriefResponseSchema.safeParse({ summary: 'OK', sections: [] })
expect(result.success).toBe(false)
})
it('rejects more than 6 sections', () => {
const result = DecisionBriefResponseSchema.safeParse({
summary: 'OK',
sections: Array(7).fill(validSection),
})
expect(result.success).toBe(false)
})
it('rejects section with empty body', () => {
const result = DecisionBriefResponseSchema.safeParse({
summary: 'OK',
sections: [{ title: 'Test', body: '' }],
})
expect(result.success).toBe(false)
})
})
// ── DataQualitySummaryResponseSchema ──────────────────────────────────────────
describe('DataQualitySummaryResponseSchema', () => {
it('accepts valid data quality summary', () => {
const result = DataQualitySummaryResponseSchema.safeParse({
overallAssessment: 'Gute Datenqualität.',
missingCriticalFields: ['areaSqm'],
recommendation: 'Fläche ergänzen.',
confidence: 0.75,
})
expect(result.success).toBe(true)
})
it('rejects confidence > 1', () => {
const result = DataQualitySummaryResponseSchema.safeParse({
overallAssessment: 'Test',
recommendation: 'Test',
confidence: 1.5,
})
expect(result.success).toBe(false)
})
it('rejects confidence < 0', () => {
const result = DataQualitySummaryResponseSchema.safeParse({
overallAssessment: 'Test',
recommendation: 'Test',
confidence: -0.1,
})
expect(result.success).toBe(false)
})
})
// ── MarketSignalClassificationResponseSchema ──────────────────────────────────
describe('MarketSignalClassificationResponseSchema', () => {
const valid = {
signalType: 'VACANCY',
probability: 0.75,
timeHorizonMonths: 6,
areaSqmEstimate: 1200,
credibility: 'HIGH',
reasoning: 'Kündigung bekannt.',
}
it('accepts valid market signal', () => {
expect(MarketSignalClassificationResponseSchema.safeParse(valid).success).toBe(true)
})
it('accepts null for nullable optional fields', () => {
const result = MarketSignalClassificationResponseSchema.safeParse({
...valid,
timeHorizonMonths: null,
areaSqmEstimate: null,
})
expect(result.success).toBe(true)
})
it('rejects probability -5', () => {
expect(MarketSignalClassificationResponseSchema.safeParse({ ...valid, probability: -5 }).success).toBe(false)
})
it('rejects probability 1.5', () => {
expect(MarketSignalClassificationResponseSchema.safeParse({ ...valid, probability: 1.5 }).success).toBe(false)
})
it('rejects unknown signalType', () => {
expect(MarketSignalClassificationResponseSchema.safeParse({ ...valid, signalType: 'FIRE_SALE' }).success).toBe(false)
})
it('rejects unknown credibility value', () => {
expect(MarketSignalClassificationResponseSchema.safeParse({ ...valid, credibility: 'VERY_HIGH' }).success).toBe(false)
})
it('rejects missing reasoning', () => {
const { reasoning: _, ...bad } = valid
expect(MarketSignalClassificationResponseSchema.safeParse(bad).success).toBe(false)
})
it('rejects non-integer timeHorizonMonths', () => {
expect(MarketSignalClassificationResponseSchema.safeParse({ ...valid, timeHorizonMonths: 2.5 }).success).toBe(false)
})
})
// ── OfferEmailResponseSchema ──────────────────────────────────────────────────
describe('OfferEmailResponseSchema', () => {
it('accepts valid offer email', () => {
const result = OfferEmailResponseSchema.safeParse({
subject: 'Angebot Büroflächen',
body: 'Sehr geehrte Damen und Herren, wir bieten folgende Objekte an.',
})
expect(result.success).toBe(true)
})
it('rejects empty subject', () => {
const result = OfferEmailResponseSchema.safeParse({ subject: '', body: 'Valid body text here.' })
expect(result.success).toBe(false)
})
it('rejects body shorter than 10 characters', () => {
const result = OfferEmailResponseSchema.safeParse({ subject: 'Angebot', body: 'Kurz.' })
expect(result.success).toBe(false)
})
})
// ── validateAIResponse helper ─────────────────────────────────────────────────
describe('validateAIResponse helper', () => {
it('returns parsed data when schema passes', () => {
const result = validateAIResponse(
OfferEmailResponseSchema,
{ subject: 'Test', body: 'Long enough body text here.' },
'test',
)
expect(result).not.toBeNull()
expect(result?.subject).toBe('Test')
})
it('returns null when schema fails (does not throw)', () => {
const result = validateAIResponse(
OfferEmailResponseSchema,
{ subject: '', body: 'x' },
'test',
)
expect(result).toBeNull()
})
it('returns null for completely wrong shape (does not throw)', () => {
const result = validateAIResponse(
MarketSignalClassificationResponseSchema,
{ probability: -999, signalType: 'INVALID' },
'test',
)
expect(result).toBeNull()
})
it('returns null for null input (does not throw)', () => {
const result = validateAIResponse(OfferEmailResponseSchema, null, 'test')
expect(result).toBeNull()
})
})
+98 -18
View File
@@ -1,9 +1,9 @@
import type { ItemResponse } from '../../types'
import type { CreateNeedInput } from '../../../domain/need'
import type { ParsedNeedCriteria, FollowUpQuestion } from '../../../domain/needBuilder'
import type { UnifiedMatchResult } from '../../../domain/unifiedResult'
import type {
IAIService,
AIResponse,
DecisionBrief,
ComparisonSummary,
CriteriaExtractionResult,
@@ -16,6 +16,7 @@ import type {
DataQualitySummary,
MarketSignalClassification,
} from '../IAIService'
import { mockProvenance } from '../IAIService'
import { mockParseNeed } from './needParser'
import { buildComparisonSummary } from './compareBuilder'
import { buildMockDecisionBrief } from './decisionBrief'
@@ -23,19 +24,92 @@ import { buildMockDecisionBrief } from './decisionBrief'
const SIMULATED_DELAY = { fast: 300, medium: 600, slow: 1800 }
const delay = (ms: number) => new Promise(r => setTimeout(r, ms))
// ── Follow-up question templates keyed by ParsedNeedCriteria field ────────────
interface QuestionTemplate {
questionText: string
reason: string
suggestedAnswerOptions?: string[]
importance: FollowUpQuestion['importance']
}
const FOLLOW_UP_TEMPLATES: Partial<Record<keyof ParsedNeedCriteria, QuestionTemplate>> = {
assetType: {
questionText: 'Welchen Nutzungstyp suchen Sie?',
reason: 'Nutzungstyp ist zwingend für die Matchsuche',
suggestedAnswerOptions: ['Büro', 'Retail', 'Logistik', 'Produktion', 'Gastro'],
importance: 'required',
},
areaRange: {
questionText: 'Welche Fläche benötigen Sie (minmax in m²)?',
reason: 'Flächenbedarf ist zwingend für die Filterung',
importance: 'required',
},
preferredLocations: {
questionText: 'In welchen Städten oder Regionen suchen Sie?',
reason: 'Standortpräferenz fehlt',
suggestedAnswerOptions: ['Zürich', 'Basel', 'Bern', 'Zug', 'Genf', 'Lausanne'],
importance: 'required',
},
budgetRange: {
questionText: 'Was ist Ihr maximales Budget pro m² und Monat (CHF)?',
reason: 'Budget ist wichtig für die Filterung unpassender Objekte',
importance: 'recommended',
},
timing: {
questionText: 'Wann möchten Sie spätestens einziehen?',
reason: 'Verfügbarkeitstermin fehlt',
importance: 'recommended',
},
mustHaveCriteria: {
questionText: 'Haben Sie zwingende Anforderungen (ÖV-Anbindung, Parkplätze, Laderampe)?',
reason: 'Pflichtkriterien sind für die Filterung relevant',
importance: 'optional',
},
}
function buildFollowUpQuestions(criteria: ParsedNeedCriteria): FollowUpQuestion[] {
const missing: Array<keyof ParsedNeedCriteria> = []
if (!criteria.assetType) missing.push('assetType')
if (!criteria.areaRange) missing.push('areaRange')
if (!criteria.preferredLocations?.length) missing.push('preferredLocations')
if (!criteria.budgetRange) missing.push('budgetRange')
if (!criteria.timing) missing.push('timing')
if (!criteria.mustHaveCriteria?.length) missing.push('mustHaveCriteria')
return missing
.slice(0, 3)
.map((field, i) => {
const tpl = FOLLOW_UP_TEMPLATES[field]
if (!tpl) return null
const q: FollowUpQuestion = {
id: `fq-mock-${i}`,
questionText: tpl.questionText,
targetField: field,
reason: tpl.reason,
importance: tpl.importance,
suggestedAnswerOptions: tpl.suggestedAnswerOptions,
}
return q
})
.filter((q): q is FollowUpQuestion => q !== null)
}
// ── Service ───────────────────────────────────────────────────────────────────
export const MockAIService: IAIService = {
async parseNeed(input: string): Promise<ItemResponse<ReturnType<typeof mockParseNeed>>> {
async parseNeed(input: string): Promise<AIResponse<ReturnType<typeof mockParseNeed>>> {
await delay(SIMULATED_DELAY.fast)
return { data: mockParseNeed(input) }
return { data: mockParseNeed(input), provenance: mockProvenance() }
},
async generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise<ItemResponse<FollowUpQuestion[]>> {
async generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise<AIResponse<FollowUpQuestion[]>> {
await delay(SIMULATED_DELAY.medium)
const result = mockParseNeed(JSON.stringify(criteria))
return { data: result.followUpQuestionCandidates }
return { data: buildFollowUpQuestions(criteria), provenance: mockProvenance() }
},
async generateMatchExplanation(input: MatchExplanationInput): Promise<ItemResponse<MatchExplanation>> {
async generateMatchExplanation(input: MatchExplanationInput): Promise<AIResponse<MatchExplanation>> {
await delay(SIMULATED_DELAY.medium)
const isStrong = input.matchScore >= 78
const isMedium = input.matchScore >= 52
@@ -56,10 +130,11 @@ export const MockAIService: IAIService = {
...input.negativeFactors.slice(0, 1).map(f => ` ${f.explanation}`),
],
},
provenance: mockProvenance(),
}
},
async summarizeTradeOffs(tradeoffs: TradeOffInput[]): Promise<ItemResponse<TradeOffSummary>> {
async summarizeTradeOffs(tradeoffs: TradeOffInput[]): Promise<AIResponse<TradeOffSummary>> {
await delay(SIMULATED_DELAY.fast)
const critical = tradeoffs.filter(t => t.severity === 'HIGH')
const overallRisk: TradeOffSummary['overallRisk'] =
@@ -73,20 +148,21 @@ export const MockAIService: IAIService = {
items: tradeoffs.map(t => ({ concern: t.concern, severity: t.severity, mitigation: t.mitigation })),
overallRisk,
},
provenance: mockProvenance(),
}
},
async summarizeComparison(items: UnifiedMatchResult[]): Promise<ItemResponse<ComparisonSummary>> {
async summarizeComparison(items: UnifiedMatchResult[]): Promise<AIResponse<ComparisonSummary>> {
await delay(SIMULATED_DELAY.medium)
return { data: buildComparisonSummary(items) }
return { data: buildComparisonSummary(items), provenance: mockProvenance() }
},
async generateDecisionBrief(shortlistId: string): Promise<ItemResponse<DecisionBrief>> {
async generateDecisionBrief(shortlistId: string): Promise<AIResponse<DecisionBrief>> {
await delay(SIMULATED_DELAY.slow)
return { data: buildMockDecisionBrief(shortlistId) }
return { data: buildMockDecisionBrief(shortlistId), provenance: mockProvenance() }
},
async generateDataQualitySummary(_propertyId: string, quality: DataQualityInput): Promise<ItemResponse<DataQualitySummary>> {
async generateDataQualitySummary(_propertyId: string, quality: DataQualityInput): Promise<AIResponse<DataQualitySummary>> {
await delay(SIMULATED_DELAY.fast)
const level =
quality.score >= 0.85 ? 'excellent'
@@ -113,10 +189,11 @@ export const MockAIService: IAIService = {
: 'Keine sofortigen Massnahmen erforderlich.',
confidence: quality.score,
},
provenance: mockProvenance(),
}
},
async classifyMarketSignal(signalText: string): Promise<ItemResponse<MarketSignalClassification>> {
async classifyMarketSignal(signalText: string): Promise<AIResponse<MarketSignalClassification>> {
await delay(SIMULATED_DELAY.medium)
const t = signalText.toLowerCase()
let signalType: MarketSignalClassification['signalType'] = 'UNKNOWN'
@@ -136,10 +213,11 @@ export const MockAIService: IAIService = {
credibility: 'MEDIUM',
reasoning: `Keyword-basierte Klassifikation (Mock). Signaltyp: ${signalType}.`,
},
provenance: mockProvenance(),
}
},
async generateOfferEmail(payload: OfferEmailPayload): Promise<ItemResponse<{ subject: string; body: string }>> {
async generateOfferEmail(payload: OfferEmailPayload): Promise<AIResponse<{ subject: string; body: string }>> {
await delay(SIMULATED_DELAY.medium * 2)
return {
data: {
@@ -149,11 +227,12 @@ export const MockAIService: IAIService = {
payload.properties.map((p, i) => `${p} (Match-Score: ${payload.matchScores[i]}%)`).join('\n') +
`\n\nGerne arrangieren wir Besichtigungstermine für die genannten Objekte und stehen für alle weiteren Fragen zur Verfügung.\n\nFreundliche Grüsse\nWincasa AG`,
},
provenance: mockProvenance(),
}
},
// Legacy methods
async extractCriteria(_input: string): Promise<ItemResponse<CriteriaExtractionResult>> {
async extractCriteria(_input: string): Promise<AIResponse<CriteriaExtractionResult>> {
return {
data: {
extractedCriteria: {
@@ -170,15 +249,16 @@ export const MockAIService: IAIService = {
'Wann möchten Sie spätestens einziehen?',
],
},
provenance: mockProvenance(),
}
},
async generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<ItemResponse<string[]>> {
async generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<AIResponse<string[]>> {
const questions: string[] = []
if (!partialNeed.assetType) questions.push('Welchen Nutzungstyp suchen Sie?')
if (!partialNeed.preferredLocations?.length) questions.push('In welchen Regionen suchen Sie?')
if (!partialNeed.timing) questions.push('Was ist Ihr gewünschter Einzugstermin?')
if (!partialNeed.budgetRange) questions.push('Was ist Ihr maximales monatliches Budget?')
return { data: questions }
return { data: questions, provenance: mockProvenance() }
},
}
+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))
},
}
+37 -7
View File
@@ -11,12 +11,42 @@ export interface DecisionBriefPromptInput {
}
export function buildDecisionBriefPrompt(input: DecisionBriefPromptInput): { system: string; user: string } {
const itemList = input.shortlistItems
.map(i => `- ${i.title} (${i.city}): Score ${i.matchScore}%, ${i.areaSqm}m², CHF ${i.rentPerSqm}/m² — ${i.topReasons.join(', ')}`)
.join('\n')
const itemList = input.shortlistItems.length > 0
? input.shortlistItems
.map(i => `- ${i.title} (${i.city}): Score ${i.matchScore}%, ${i.areaSqm}m², CHF ${i.rentPerSqm}/m² — ${i.topReasons.join(', ')}`)
.join('\n')
: '(keine Objekte auf der Shortlist)'
return {
system: `Du bist Senior Real Estate Advisor. Erstelle ein strukturiertes Entscheidungs-Briefing auf Deutsch als JSON mit: summary, sections (Zusammenfassung, Standortbewertung, Budgetanalyse, Empfohlene nächste Schritte).`,
user: `Erstelle ein Entscheidungs-Briefing für folgende Shortlist:\n\nSuchprofil: ${input.needSummary}\n\nObjekte:\n${itemList}`,
}
const system = `Du bist Senior Real Estate Advisor bei Wincasa AG. Du erstellst strukturierte Entscheidungs-Briefings für Unternehmenskunden auf Deutsch.
WICHTIG — Konfidenzregeln:
- Schreibe nur, was durch die Daten belegt ist. Verwende "scheint", "deutet darauf hin", "laut Datenlage" wenn du Einschätzungen machst.
- Stelle keine Verfügbarkeit als gesichert dar, wenn sie nicht explizit bestätigt ist.
- Unterscheide zwischen Stärken ("erfüllt vollständig") und Hinweisen ("Tendenz erkennbar").
AUSGABEFORMAT — antworte ausschliesslich als valides JSON (kein Markdown, keine Erklärungen):
{
"summary": "12 Sätze Executive Summary",
"sections": [
{ "title": "Zusammenfassung", "body": "Überblick über die Shortlist und Gesamtbewertung" },
{ "title": "Standortbewertung", "body": "Vergleich der Standorte nach Erreichbarkeit, Prestige, Eignung" },
{ "title": "Budgetanalyse", "body": "Kostenvergleich und Budget-Effizienz der Objekte" },
{ "title": "Empfohlene nächste Schritte", "body": "Konkrete, priorisierte Handlungsempfehlungen" }
]
}
Regeln:
- Genau 4 Sektionen, Reihenfolge wie oben
- summary: max 2 Sätze
- body jeder Sektion: 24 Sätze, entscheidungsorientiert
- Kein JSON in Markdown-Codeblöcken`
const user = `Erstelle ein Entscheidungs-Briefing für folgende Shortlist:
Suchprofil: ${input.needSummary}
Objekte:
${itemList}`
return { system, user }
}
@@ -5,15 +5,32 @@ export interface MatchExplanationPromptInput {
positiveFactors: Array<{ criterion: string; explanation: string }>
negativeFactors: Array<{ criterion: string; explanation: string }>
needSummary: string
isFutureSignal?: boolean
}
export function buildMatchExplanationPrompt(input: MatchExplanationPromptInput): { system: string; user: string } {
return {
system: `Du bist ein Experte für Schweizer Gewerbeimmobilien. Erkläre Match-Ergebnisse präzise und entscheidungsorientiert auf Deutsch. Maximal 3 Sätze.`,
user: `Erkläre warum das Objekt "${input.propertyTitle}" in ${input.propertyCity} einen Match Score von ${input.matchScore}% hat.
const confidenceNote = input.isFutureSignal
? '\nDieses Objekt ist ein Zukunftssignal (noch nicht verfügbar). Stelle die Verfügbarkeit NICHT als gesichert dar. Verwende Formulierungen wie "könnte verfügbar werden", "Signal deutet auf mögliche Fläche hin".'
: ''
Stärken: ${input.positiveFactors.map(f => f.explanation).join(', ')}
Schwächen: ${input.negativeFactors.map(f => f.explanation).join(', ')}
Suchprofil: ${input.needSummary}`,
}
const system = `Du bist Experte für Schweizer Gewerbeimmobilien bei Wincasa AG. Du erklärst Match-Ergebnisse präzise und entscheidungsorientiert auf Deutsch.
Konfidenz-Vokabular:
- Score ≥ 78: "starkes Match", "erfüllt die Kernkriterien", "klar empfehlenswert"
- Score 5277: "gutes Match mit Kompromissen", "weitgehend geeignet", "einzelne Einschränkungen"
- Score < 52: "schwaches Match", "deutliche Abweichungen", "kritische Lücken"
Regeln:
- Maximal 3 Sätze
- Nenne die 12 stärksten Gründe für den Score
- Erwähne die grösste Einschränkung, falls vorhanden
- Keine allgemeinen Floskeln ("ein attraktives Objekt") — nur konkrete Fakten aus den Score-Faktoren${confidenceNote}`
const user = `Erkläre warum das Objekt "${input.propertyTitle}" in ${input.propertyCity} einen Match-Score von ${input.matchScore}/100 hat.
Stärken: ${input.positiveFactors.map(f => f.explanation).join('; ')}
Schwächen: ${input.negativeFactors.map(f => f.explanation).join('; ')}
Suchprofil: ${input.needSummary}`
return { system, user }
}
+149
View File
@@ -0,0 +1,149 @@
/**
* Zod schemas for all AI response types.
*
* Every OpenRouter response is validated against its schema before reaching
* the UI. Validation failures trigger an explicit fallback to MockAIService —
* no invalid data ever passes through silently.
*/
import { z } from 'zod'
// ── Shared primitives ─────────────────────────────────────────────────────────
export const SeveritySchema = z.enum(['LOW', 'MEDIUM', 'HIGH'])
export const CredibilitySchema = z.enum(['LOW', 'MEDIUM', 'HIGH'])
/** Probability must be strictly within [0, 1] — no -5, no 1.5 */
export const ProbabilitySchema = z.number().min(0).max(1)
/** Match score must be within [0, 100] */
export const ScoreSchema = z.number().min(0).max(100)
// ── 1. Need Parsing ───────────────────────────────────────────────────────────
export const NeedParsingResponseSchema = z.object({
assetType: z
.enum(['OFFICE', 'RETAIL', 'LOGISTICS', 'PRODUCTION', 'GASTRO', 'MIXED', 'UNKNOWN'])
.optional()
.nullable(),
areaRange: z
.object({ min: z.number().min(0), max: z.number().min(0) })
.optional()
.nullable()
.refine(r => r == null || r.max >= r.min, { message: 'areaRange.max must be >= min' }),
preferredLocations: z.array(z.string().min(1)).optional(),
budgetRange: z
.object({ maxPerSqm: z.number().positive(), currency: z.string().min(1) })
.optional()
.nullable(),
timing: z
.object({
earliestMoveIn: z.string().optional(),
latestMoveIn: z.string().optional(),
flexibleTiming: z.boolean().optional(),
})
.optional()
.nullable(),
mustHaveCriteria: z.array(z.string()).optional(),
missingFields: z.array(z.string()).optional(),
assumptions: z.array(z.string()).optional(),
})
export type NeedParsingResponseRaw = z.infer<typeof NeedParsingResponseSchema>
// ── 2. Follow-Up Questions ────────────────────────────────────────────────────
export const FollowUpQuestionsResponseSchema = z
.array(
z.object({
questionText: z.string().min(1),
targetField: z.string().min(1),
reason: z.string().optional(),
suggestedAnswerOptions: z.array(z.string()).optional(),
importance: z
.enum(['required', 'recommended', 'optional'])
.optional(),
}),
)
.max(5)
// ── 3. Trade-Off Summary ──────────────────────────────────────────────────────
export const TradeOffSummaryResponseSchema = z.object({
headline: z.string().min(1),
items: z
.array(
z.object({
concern: z.string().min(1),
severity: SeveritySchema,
mitigation: z.string().optional(),
}),
)
.max(5),
overallRisk: SeveritySchema,
})
// ── 4. Comparison Summary ─────────────────────────────────────────────────────
export const CompareSummaryResponseSchema = z.object({
overallAssessment: z.string().min(1),
recommendation: z.string().optional(),
strongestOption: z.string().optional(),
})
// ── 5. Decision Brief ────────────────────────────────────────────────────────
export const DecisionBriefResponseSchema = z.object({
summary: z.string().min(1),
sections: z
.array(z.object({ title: z.string().min(1), body: z.string().min(1) }))
.min(1)
.max(6),
})
// ── 6. Data Quality Summary ───────────────────────────────────────────────────
export const DataQualitySummaryResponseSchema = z.object({
overallAssessment: z.string().min(1),
missingCriticalFields: z.array(z.string()).optional(),
recommendation: z.string().min(1),
confidence: z.number().min(0).max(1),
})
// ── 7. Market Signal Classification ──────────────────────────────────────────
export const MarketSignalClassificationResponseSchema = z.object({
signalType: z.enum([
'VACANCY', 'CONSTRUCTION', 'RESTRUCTURING',
'EXPANSION', 'RELOCATION', 'UNKNOWN',
]),
probability: ProbabilitySchema,
timeHorizonMonths: z.number().positive().int().optional().nullable(),
areaSqmEstimate: z.number().positive().optional().nullable(),
credibility: CredibilitySchema,
reasoning: z.string().min(1),
})
// ── 8. Offer Email ────────────────────────────────────────────────────────────
export const OfferEmailResponseSchema = z.object({
subject: z.string().min(1),
body: z.string().min(10),
})
// ── Validation helper ─────────────────────────────────────────────────────────
/**
* Validates raw AI JSON against a Zod schema.
* Returns the parsed value on success, or null on failure.
* Logs a structured warning on failure — never throws.
*/
export function validateAIResponse<T>(
schema: z.ZodType<T>,
raw: unknown,
label: string,
): T | null {
const result = schema.safeParse(raw)
if (result.success) return result.data
console.warn(`[AISchema] ${label} validation failed:`, result.error.flatten())
return null
}