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
+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() }
},
}