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:
@@ -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 (min–max 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() }
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user