e62391af66
- 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>
265 lines
12 KiB
TypeScript
265 lines
12 KiB
TypeScript
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 (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<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() }
|
||
},
|
||
}
|