Files
property-match/src/services/ai/mock/MockAIService.ts
T
Benjamin Sutter d4171fe9b5 feat(ai): observability tracing + improved prompt templates
- Add AITrace type, AITraceStore (circular buffer, localStorage in DEV,
  window.__aiTraces for DevTools), provenanceToStatus() helper
- Instrument OpenRouterAIService withFallback with latency tracking and
  trace recording across all three paths (no-key, success, error)
- Wrap all MockAIService methods with traceMock for consistent in-memory
  tracing including method name, latency, and validation status
- Improve all 6 prompt templates with ROLLE/AUFGABE/VERBOTE/BEISPIEL
  structure; marketSignalPrompt carries hard prohibition against claiming
  confirmed availability from unconfirmed signals

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 14:18:42 +02:00

296 lines
13 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 { aiTraceStore } from '../tracing'
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))
// ── Tracing wrapper ───────────────────────────────────────────────────────────
async function traceMock<T>(method: string, fn: () => Promise<AIResponse<T>>): Promise<AIResponse<T>> {
const startMs = Date.now()
const result = await fn()
aiTraceStore.add({
id: crypto.randomUUID(),
method,
provider: 'mock',
model: 'mock',
promptVersion: 'mock',
latencyMs: Date.now() - startMs,
fallbackUsed: false,
validationPassed: true,
responseValidationStatus: 'valid',
source: 'mock',
createdAt: new Date().toISOString(),
})
return result
}
// ── 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 = {
parseNeed: (input: string) =>
traceMock('parseNeed', async () => {
await delay(SIMULATED_DELAY.fast)
return { data: mockParseNeed(input), provenance: mockProvenance() }
}),
generateFollowUpQuestions: (criteria: ParsedNeedCriteria) =>
traceMock('generateFollowUpQuestions', async () => {
await delay(SIMULATED_DELAY.medium)
return { data: buildFollowUpQuestions(criteria), provenance: mockProvenance() }
}),
generateMatchExplanation: (input: MatchExplanationInput) =>
traceMock('generateMatchExplanation', async () => {
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(),
}
}),
summarizeTradeOffs: (tradeoffs: TradeOffInput[]) =>
traceMock('summarizeTradeOffs', async () => {
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(),
}
}),
summarizeComparison: (items: UnifiedMatchResult[]) =>
traceMock('summarizeComparison', async () => {
await delay(SIMULATED_DELAY.medium)
return { data: buildComparisonSummary(items), provenance: mockProvenance() }
}),
generateDecisionBrief: (shortlistId: string) =>
traceMock('generateDecisionBrief', async () => {
await delay(SIMULATED_DELAY.slow)
return { data: buildMockDecisionBrief(shortlistId), provenance: mockProvenance() }
}),
generateDataQualitySummary: (_propertyId: string, quality: DataQualityInput) =>
traceMock('generateDataQualitySummary', async () => {
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(),
}
}),
classifyMarketSignal: (signalText: string) =>
traceMock('classifyMarketSignal', async () => {
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(),
}
}),
generateOfferEmail: (payload: OfferEmailPayload) =>
traceMock('generateOfferEmail', async () => {
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
extractCriteria: (_input: string) =>
traceMock('extractCriteria', async () => ({
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(),
})),
generateFollowUp: (partialNeed: Partial<CreateNeedInput>) =>
traceMock('generateFollowUp', async () => {
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() }
}),
}