Files
property-match/src/services/ai/mock/MockAIService.ts
T
Benjamin Sutter e62391af66 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>
2026-05-24 13:44:46 +02:00

265 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
OfferEmailPayload,
MatchExplanationInput,
MatchExplanation,
TradeOffInput,
TradeOffSummary,
DataQualityInput,
DataQualitySummary,
MarketSignalClassification,
} from '../IAIService'
import { mockProvenance } from '../IAIService'
import { mockParseNeed } from './needParser'
import { buildComparisonSummary } from './compareBuilder'
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<AIResponse<ReturnType<typeof mockParseNeed>>> {
await delay(SIMULATED_DELAY.fast)
return { data: mockParseNeed(input), provenance: mockProvenance() }
},
async generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise<AIResponse<FollowUpQuestion[]>> {
await delay(SIMULATED_DELAY.medium)
return { data: buildFollowUpQuestions(criteria), provenance: mockProvenance() }
},
async generateMatchExplanation(input: MatchExplanationInput): Promise<AIResponse<MatchExplanation>> {
await delay(SIMULATED_DELAY.medium)
const isStrong = input.matchScore >= 78
const isMedium = input.matchScore >= 52
const headline = isStrong
? `Starkes Match — ${input.propertyTitle} erfüllt Ihre Kernkriterien hervorragend`
: isMedium
? `Gutes Match mit einzelnen Kompromissen für ${input.propertyTitle}`
: `Schwaches Match — mehrere Kriterien nicht erfüllt bei ${input.propertyTitle}`
const positiveText = input.positiveFactors.slice(0, 2).map(f => f.explanation).join('; ')
const negativeText = input.negativeFactors.slice(0, 1).map(f => f.explanation).join('; ')
const summary = `${input.propertyTitle} in ${input.propertyCity} erreicht ${input.matchScore}/100 Punkte.${positiveText ? ` Hauptstärken: ${positiveText}.` : ''}${negativeText ? ` Einschränkung: ${negativeText}.` : ''}`
return {
data: {
headline,
summary,
keyReasons: [
...input.positiveFactors.slice(0, 2).map(f => `+ ${f.explanation}`),
...input.negativeFactors.slice(0, 1).map(f => ` ${f.explanation}`),
],
},
provenance: mockProvenance(),
}
},
async summarizeTradeOffs(tradeoffs: TradeOffInput[]): Promise<AIResponse<TradeOffSummary>> {
await delay(SIMULATED_DELAY.fast)
const critical = tradeoffs.filter(t => t.severity === 'HIGH')
const overallRisk: TradeOffSummary['overallRisk'] =
critical.length >= 2 ? 'HIGH' : critical.length === 1 ? 'MEDIUM' : 'LOW'
const riskLabel = overallRisk === 'HIGH' ? 'Hoch' : overallRisk === 'MEDIUM' ? 'Mittel' : 'Gering'
return {
data: {
headline: tradeoffs.length === 0
? 'Keine wesentlichen Trade-offs identifiziert'
: `${tradeoffs.length} Trade-off${tradeoffs.length > 1 ? 's' : ''} — Gesamtrisiko: ${riskLabel}`,
items: tradeoffs.map(t => ({ concern: t.concern, severity: t.severity, mitigation: t.mitigation })),
overallRisk,
},
provenance: mockProvenance(),
}
},
async summarizeComparison(items: UnifiedMatchResult[]): Promise<AIResponse<ComparisonSummary>> {
await delay(SIMULATED_DELAY.medium)
return { data: buildComparisonSummary(items), provenance: mockProvenance() }
},
async generateDecisionBrief(shortlistId: string): Promise<AIResponse<DecisionBrief>> {
await delay(SIMULATED_DELAY.slow)
return { data: buildMockDecisionBrief(shortlistId), provenance: mockProvenance() }
},
async generateDataQualitySummary(_propertyId: string, quality: DataQualityInput): Promise<AIResponse<DataQualitySummary>> {
await delay(SIMULATED_DELAY.fast)
const level =
quality.score >= 0.85 ? 'excellent'
: quality.score >= 0.70 ? 'good'
: quality.score >= 0.55 ? 'fair'
: quality.score >= 0.40 ? 'poor'
: 'critical'
const assessments: Record<string, string> = {
excellent: 'Exzellente Datenqualität — alle Kernfelder vollständig und aktuell.',
good: 'Gute Datenqualität — kleinere Lücken beeinflussen die Matchgenauigkeit nicht wesentlich.',
fair: 'Ausreichende Datenqualität — fehlende Felder können die Matchgenauigkeit beeinträchtigen.',
poor: 'Geringe Datenqualität — wichtige Felder fehlen, Match-Score mit Vorsicht interpretieren.',
critical: 'Kritische Datenqualität — fundamentale Felder fehlen, Match-Ergebnis stark eingeschränkt.',
}
const hasCritical = quality.missingCriticalFields.length > 0
return {
data: {
overallAssessment: assessments[level],
missingCriticalFields: quality.missingCriticalFields,
recommendation: hasCritical
? `Fehlende Pflichtfelder ergänzen: ${quality.missingCriticalFields.join(', ')}`
: quality.score < 0.70
? 'Daten aktualisieren und optionale Felder ergänzen für bessere Matchgenauigkeit.'
: 'Keine sofortigen Massnahmen erforderlich.',
confidence: quality.score,
},
provenance: mockProvenance(),
}
},
async classifyMarketSignal(signalText: string): Promise<AIResponse<MarketSignalClassification>> {
await delay(SIMULATED_DELAY.medium)
const t = signalText.toLowerCase()
let signalType: MarketSignalClassification['signalType'] = 'UNKNOWN'
if (t.includes('neubau') || t.includes('baubewilligung') || t.includes('umbau')) signalType = 'CONSTRUCTION'
else if (t.includes('expansion') || t.includes('wachstum') || t.includes('sucht fläche')) signalType = 'EXPANSION'
else if (t.includes('verlegt') || t.includes('umzug') || t.includes('relocation')) signalType = 'RELOCATION'
else if (t.includes('stellenabbau') || t.includes('restruktur') || t.includes('fusion')) signalType = 'RESTRUCTURING'
else if (t.includes('frei') || t.includes('kündigung') || t.includes('schliessung') || t.includes('leerstand')) signalType = 'VACANCY'
const areaMatch = signalText.match(/(\d{2,5})\s*m²/)
const monthsMatch = signalText.match(/(\d{1,2})\s*Monate?n?/)
return {
data: {
signalType,
probability: 0.65,
timeHorizonMonths: monthsMatch ? parseInt(monthsMatch[1]) : null,
areaSqmEstimate: areaMatch ? parseInt(areaMatch[1]) : null,
credibility: 'MEDIUM',
reasoning: `Keyword-basierte Klassifikation (Mock). Signaltyp: ${signalType}.`,
},
provenance: mockProvenance(),
}
},
async generateOfferEmail(payload: OfferEmailPayload): Promise<AIResponse<{ subject: string; body: string }>> {
await delay(SIMULATED_DELAY.medium * 2)
return {
data: {
subject: `Passende Gewerbeflächen zu Ihrer Anfrage: ${payload.needTitle}`,
body:
`Sehr geehrte Damen und Herren,\n\nvielen Dank für Ihr Interesse. Gerne unterbreiten wir Ihnen folgende passende Gewerbeobjekte aus unserem Portfolio:\n\n` +
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<AIResponse<CriteriaExtractionResult>> {
return {
data: {
extractedCriteria: {
companyName: 'Unbekannt (bitte bestätigen)',
requiredArea: { min: 400, max: 900 },
budgetRange: { maxPerSqm: 40, currency: 'CHF' },
},
confidence: 0.72,
missingFields: ['assetType', 'timing', 'preferredLocations'],
assumptions: ['Fläche aus Zahlenangabe geschätzt', 'Budget aus Kostennennung abgeleitet'],
followUpQuestions: [
'Welchen Nutzungstyp suchen Sie (Büro, Retail, Logistik)?',
'In welchen Städten oder Regionen suchen Sie?',
'Wann möchten Sie spätestens einziehen?',
],
},
provenance: mockProvenance(),
}
},
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, provenance: mockProvenance() }
},
}