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>
This commit is contained in:
@@ -17,6 +17,7 @@ import type {
|
||||
MarketSignalClassification,
|
||||
} from '../IAIService'
|
||||
import { mockProvenance } from '../IAIService'
|
||||
import { aiTraceStore } from '../tracing'
|
||||
import { mockParseNeed } from './needParser'
|
||||
import { buildComparisonSummary } from './compareBuilder'
|
||||
import { buildMockDecisionBrief } from './decisionBrief'
|
||||
@@ -24,6 +25,27 @@ 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 {
|
||||
@@ -99,141 +121,150 @@ function buildFollowUpQuestions(criteria: ParsedNeedCriteria): FollowUpQuestion[
|
||||
// ── 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() }
|
||||
},
|
||||
parseNeed: (input: string) =>
|
||||
traceMock('parseNeed', async () => {
|
||||
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() }
|
||||
},
|
||||
generateFollowUpQuestions: (criteria: ParsedNeedCriteria) =>
|
||||
traceMock('generateFollowUpQuestions', async () => {
|
||||
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(),
|
||||
}
|
||||
},
|
||||
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(),
|
||||
}
|
||||
}),
|
||||
|
||||
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(),
|
||||
}
|
||||
},
|
||||
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(),
|
||||
}
|
||||
}),
|
||||
|
||||
async summarizeComparison(items: UnifiedMatchResult[]): Promise<AIResponse<ComparisonSummary>> {
|
||||
await delay(SIMULATED_DELAY.medium)
|
||||
return { data: buildComparisonSummary(items), provenance: mockProvenance() }
|
||||
},
|
||||
summarizeComparison: (items: UnifiedMatchResult[]) =>
|
||||
traceMock('summarizeComparison', async () => {
|
||||
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() }
|
||||
},
|
||||
generateDecisionBrief: (shortlistId: string) =>
|
||||
traceMock('generateDecisionBrief', async () => {
|
||||
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(),
|
||||
}
|
||||
},
|
||||
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(),
|
||||
}
|
||||
}),
|
||||
|
||||
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(),
|
||||
}
|
||||
},
|
||||
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(),
|
||||
}
|
||||
}),
|
||||
|
||||
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(),
|
||||
}
|
||||
},
|
||||
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
|
||||
async extractCriteria(_input: string): Promise<AIResponse<CriteriaExtractionResult>> {
|
||||
return {
|
||||
extractCriteria: (_input: string) =>
|
||||
traceMock('extractCriteria', async () => ({
|
||||
data: {
|
||||
extractedCriteria: {
|
||||
companyName: 'Unbekannt (bitte bestätigen)',
|
||||
@@ -250,15 +281,15 @@ export const MockAIService: IAIService = {
|
||||
],
|
||||
},
|
||||
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() }
|
||||
},
|
||||
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() }
|
||||
}),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user