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:
Benjamin Sutter
2026-05-24 14:18:42 +02:00
parent 7934da7669
commit d4171fe9b5
9 changed files with 640 additions and 170 deletions
+166 -135
View File
@@ -17,6 +17,7 @@ import type {
MarketSignalClassification, MarketSignalClassification,
} from '../IAIService' } from '../IAIService'
import { mockProvenance } from '../IAIService' import { mockProvenance } from '../IAIService'
import { aiTraceStore } from '../tracing'
import { mockParseNeed } from './needParser' import { mockParseNeed } from './needParser'
import { buildComparisonSummary } from './compareBuilder' import { buildComparisonSummary } from './compareBuilder'
import { buildMockDecisionBrief } from './decisionBrief' import { buildMockDecisionBrief } from './decisionBrief'
@@ -24,6 +25,27 @@ import { buildMockDecisionBrief } from './decisionBrief'
const SIMULATED_DELAY = { fast: 300, medium: 600, slow: 1800 } const SIMULATED_DELAY = { fast: 300, medium: 600, slow: 1800 }
const delay = (ms: number) => new Promise(r => setTimeout(r, ms)) 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 ──────────── // ── Follow-up question templates keyed by ParsedNeedCriteria field ────────────
interface QuestionTemplate { interface QuestionTemplate {
@@ -99,141 +121,150 @@ function buildFollowUpQuestions(criteria: ParsedNeedCriteria): FollowUpQuestion[
// ── Service ─────────────────────────────────────────────────────────────────── // ── Service ───────────────────────────────────────────────────────────────────
export const MockAIService: IAIService = { export const MockAIService: IAIService = {
async parseNeed(input: string): Promise<AIResponse<ReturnType<typeof mockParseNeed>>> { parseNeed: (input: string) =>
await delay(SIMULATED_DELAY.fast) traceMock('parseNeed', async () => {
return { data: mockParseNeed(input), provenance: mockProvenance() } await delay(SIMULATED_DELAY.fast)
}, return { data: mockParseNeed(input), provenance: mockProvenance() }
}),
async generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise<AIResponse<FollowUpQuestion[]>> { generateFollowUpQuestions: (criteria: ParsedNeedCriteria) =>
await delay(SIMULATED_DELAY.medium) traceMock('generateFollowUpQuestions', async () => {
return { data: buildFollowUpQuestions(criteria), provenance: mockProvenance() } await delay(SIMULATED_DELAY.medium)
}, return { data: buildFollowUpQuestions(criteria), provenance: mockProvenance() }
}),
async generateMatchExplanation(input: MatchExplanationInput): Promise<AIResponse<MatchExplanation>> { generateMatchExplanation: (input: MatchExplanationInput) =>
await delay(SIMULATED_DELAY.medium) traceMock('generateMatchExplanation', async () => {
const isStrong = input.matchScore >= 78 await delay(SIMULATED_DELAY.medium)
const isMedium = input.matchScore >= 52 const isStrong = input.matchScore >= 78
const headline = isStrong const isMedium = input.matchScore >= 52
? `Starkes Match — ${input.propertyTitle} erfüllt Ihre Kernkriterien hervorragend` const headline = isStrong
: isMedium ? `Starkes Match — ${input.propertyTitle} erfüllt Ihre Kernkriterien hervorragend`
? `Gutes Match mit einzelnen Kompromissen für ${input.propertyTitle}` : isMedium
: `Schwaches Match — mehrere Kriterien nicht erfüllt bei ${input.propertyTitle}` ? `Gutes Match mit einzelnen Kompromissen für ${input.propertyTitle}`
const positiveText = input.positiveFactors.slice(0, 2).map(f => f.explanation).join('; ') : `Schwaches Match — mehrere Kriterien nicht erfüllt bei ${input.propertyTitle}`
const negativeText = input.negativeFactors.slice(0, 1).map(f => f.explanation).join('; ') const positiveText = input.positiveFactors.slice(0, 2).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}.` : ''}` const negativeText = input.negativeFactors.slice(0, 1).map(f => f.explanation).join('; ')
return { const summary = `${input.propertyTitle} in ${input.propertyCity} erreicht ${input.matchScore}/100 Punkte.${positiveText ? ` Hauptstärken: ${positiveText}.` : ''}${negativeText ? ` Einschränkung: ${negativeText}.` : ''}`
data: { return {
headline, data: {
summary, headline,
keyReasons: [ summary,
...input.positiveFactors.slice(0, 2).map(f => `+ ${f.explanation}`), keyReasons: [
...input.negativeFactors.slice(0, 1).map(f => ` ${f.explanation}`), ...input.positiveFactors.slice(0, 2).map(f => `+ ${f.explanation}`),
], ...input.negativeFactors.slice(0, 1).map(f => ` ${f.explanation}`),
}, ],
provenance: mockProvenance(), },
} provenance: mockProvenance(),
}, }
}),
async summarizeTradeOffs(tradeoffs: TradeOffInput[]): Promise<AIResponse<TradeOffSummary>> { summarizeTradeOffs: (tradeoffs: TradeOffInput[]) =>
await delay(SIMULATED_DELAY.fast) traceMock('summarizeTradeOffs', async () => {
const critical = tradeoffs.filter(t => t.severity === 'HIGH') await delay(SIMULATED_DELAY.fast)
const overallRisk: TradeOffSummary['overallRisk'] = const critical = tradeoffs.filter(t => t.severity === 'HIGH')
critical.length >= 2 ? 'HIGH' : critical.length === 1 ? 'MEDIUM' : 'LOW' const overallRisk: TradeOffSummary['overallRisk'] =
const riskLabel = overallRisk === 'HIGH' ? 'Hoch' : overallRisk === 'MEDIUM' ? 'Mittel' : 'Gering' critical.length >= 2 ? 'HIGH' : critical.length === 1 ? 'MEDIUM' : 'LOW'
return { const riskLabel = overallRisk === 'HIGH' ? 'Hoch' : overallRisk === 'MEDIUM' ? 'Mittel' : 'Gering'
data: { return {
headline: tradeoffs.length === 0 data: {
? 'Keine wesentlichen Trade-offs identifiziert' headline: tradeoffs.length === 0
: `${tradeoffs.length} Trade-off${tradeoffs.length > 1 ? 's' : ''} — Gesamtrisiko: ${riskLabel}`, ? 'Keine wesentlichen Trade-offs identifiziert'
items: tradeoffs.map(t => ({ concern: t.concern, severity: t.severity, mitigation: t.mitigation })), : `${tradeoffs.length} Trade-off${tradeoffs.length > 1 ? 's' : ''} — Gesamtrisiko: ${riskLabel}`,
overallRisk, items: tradeoffs.map(t => ({ concern: t.concern, severity: t.severity, mitigation: t.mitigation })),
}, overallRisk,
provenance: mockProvenance(), },
} provenance: mockProvenance(),
}, }
}),
async summarizeComparison(items: UnifiedMatchResult[]): Promise<AIResponse<ComparisonSummary>> { summarizeComparison: (items: UnifiedMatchResult[]) =>
await delay(SIMULATED_DELAY.medium) traceMock('summarizeComparison', async () => {
return { data: buildComparisonSummary(items), provenance: mockProvenance() } await delay(SIMULATED_DELAY.medium)
}, return { data: buildComparisonSummary(items), provenance: mockProvenance() }
}),
async generateDecisionBrief(shortlistId: string): Promise<AIResponse<DecisionBrief>> { generateDecisionBrief: (shortlistId: string) =>
await delay(SIMULATED_DELAY.slow) traceMock('generateDecisionBrief', async () => {
return { data: buildMockDecisionBrief(shortlistId), provenance: mockProvenance() } await delay(SIMULATED_DELAY.slow)
}, return { data: buildMockDecisionBrief(shortlistId), provenance: mockProvenance() }
}),
async generateDataQualitySummary(_propertyId: string, quality: DataQualityInput): Promise<AIResponse<DataQualitySummary>> { generateDataQualitySummary: (_propertyId: string, quality: DataQualityInput) =>
await delay(SIMULATED_DELAY.fast) traceMock('generateDataQualitySummary', async () => {
const level = await delay(SIMULATED_DELAY.fast)
quality.score >= 0.85 ? 'excellent' const level =
: quality.score >= 0.70 ? 'good' quality.score >= 0.85 ? 'excellent'
: quality.score >= 0.55 ? 'fair' : quality.score >= 0.70 ? 'good'
: quality.score >= 0.40 ? 'poor' : quality.score >= 0.55 ? 'fair'
: 'critical' : quality.score >= 0.40 ? 'poor'
const assessments: Record<string, string> = { : 'critical'
excellent: 'Exzellente Datenqualität — alle Kernfelder vollständig und aktuell.', const assessments: Record<string, string> = {
good: 'Gute Datenqualität — kleinere Lücken beeinflussen die Matchgenauigkeit nicht wesentlich.', excellent: 'Exzellente Datenqualität — alle Kernfelder vollständig und aktuell.',
fair: 'Ausreichende Datenqualität — fehlende Felder können die Matchgenauigkeit beeinträchtigen.', good: 'Gute Datenqualität — kleinere Lücken beeinflussen die Matchgenauigkeit nicht wesentlich.',
poor: 'Geringe Datenqualität — wichtige Felder fehlen, Match-Score mit Vorsicht interpretieren.', fair: 'Ausreichende Datenqualität — fehlende Felder können die Matchgenauigkeit beeinträchtigen.',
critical: 'Kritische Datenqualität — fundamentale Felder fehlen, Match-Ergebnis stark eingeschränkt.', 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 { const hasCritical = quality.missingCriticalFields.length > 0
data: { return {
overallAssessment: assessments[level], data: {
missingCriticalFields: quality.missingCriticalFields, overallAssessment: assessments[level],
recommendation: hasCritical missingCriticalFields: quality.missingCriticalFields,
? `Fehlende Pflichtfelder ergänzen: ${quality.missingCriticalFields.join(', ')}` recommendation: hasCritical
: quality.score < 0.70 ? `Fehlende Pflichtfelder ergänzen: ${quality.missingCriticalFields.join(', ')}`
? 'Daten aktualisieren und optionale Felder ergänzen für bessere Matchgenauigkeit.' : quality.score < 0.70
: 'Keine sofortigen Massnahmen erforderlich.', ? 'Daten aktualisieren und optionale Felder ergänzen für bessere Matchgenauigkeit.'
confidence: quality.score, : 'Keine sofortigen Massnahmen erforderlich.',
}, confidence: quality.score,
provenance: mockProvenance(), },
} provenance: mockProvenance(),
}, }
}),
async classifyMarketSignal(signalText: string): Promise<AIResponse<MarketSignalClassification>> { classifyMarketSignal: (signalText: string) =>
await delay(SIMULATED_DELAY.medium) traceMock('classifyMarketSignal', async () => {
const t = signalText.toLowerCase() await delay(SIMULATED_DELAY.medium)
let signalType: MarketSignalClassification['signalType'] = 'UNKNOWN' const t = signalText.toLowerCase()
if (t.includes('neubau') || t.includes('baubewilligung') || t.includes('umbau')) signalType = 'CONSTRUCTION' let signalType: MarketSignalClassification['signalType'] = 'UNKNOWN'
else if (t.includes('expansion') || t.includes('wachstum') || t.includes('sucht fläche')) signalType = 'EXPANSION' if (t.includes('neubau') || t.includes('baubewilligung') || t.includes('umbau')) signalType = 'CONSTRUCTION'
else if (t.includes('verlegt') || t.includes('umzug') || t.includes('relocation')) signalType = 'RELOCATION' else if (t.includes('expansion') || t.includes('wachstum') || t.includes('sucht fläche')) signalType = 'EXPANSION'
else if (t.includes('stellenabbau') || t.includes('restruktur') || t.includes('fusion')) signalType = 'RESTRUCTURING' else if (t.includes('verlegt') || t.includes('umzug') || t.includes('relocation')) signalType = 'RELOCATION'
else if (t.includes('frei') || t.includes('kündigung') || t.includes('schliessung') || t.includes('leerstand')) signalType = 'VACANCY' else if (t.includes('stellenabbau') || t.includes('restruktur') || t.includes('fusion')) signalType = 'RESTRUCTURING'
const areaMatch = signalText.match(/(\d{2,5})\s*m²/) else if (t.includes('frei') || t.includes('kündigung') || t.includes('schliessung') || t.includes('leerstand')) signalType = 'VACANCY'
const monthsMatch = signalText.match(/(\d{1,2})\s*Monate?n?/) const areaMatch = signalText.match(/(\d{2,5})\s*/)
return { const monthsMatch = signalText.match(/(\d{1,2})\s*Monate?n?/)
data: { return {
signalType, data: {
probability: 0.65, signalType,
timeHorizonMonths: monthsMatch ? parseInt(monthsMatch[1]) : null, probability: 0.65,
areaSqmEstimate: areaMatch ? parseInt(areaMatch[1]) : null, timeHorizonMonths: monthsMatch ? parseInt(monthsMatch[1]) : null,
credibility: 'MEDIUM', areaSqmEstimate: areaMatch ? parseInt(areaMatch[1]) : null,
reasoning: `Keyword-basierte Klassifikation (Mock). Signaltyp: ${signalType}.`, credibility: 'MEDIUM',
}, reasoning: `Keyword-basierte Klassifikation (Mock). Signaltyp: ${signalType}.`,
provenance: mockProvenance(), },
} provenance: mockProvenance(),
}, }
}),
async generateOfferEmail(payload: OfferEmailPayload): Promise<AIResponse<{ subject: string; body: string }>> { generateOfferEmail: (payload: OfferEmailPayload) =>
await delay(SIMULATED_DELAY.medium * 2) traceMock('generateOfferEmail', async () => {
return { await delay(SIMULATED_DELAY.medium * 2)
data: { return {
subject: `Passende Gewerbeflächen zu Ihrer Anfrage: ${payload.needTitle}`, data: {
body: subject: `Passende Gewerbeflächen zu Ihrer Anfrage: ${payload.needTitle}`,
`Sehr geehrte Damen und Herren,\n\nvielen Dank für Ihr Interesse. Gerne unterbreiten wir Ihnen folgende passende Gewerbeobjekte aus unserem Portfolio:\n\n` + body:
payload.properties.map((p, i) => `${p} (Match-Score: ${payload.matchScores[i]}%)`).join('\n') + `Sehr geehrte Damen und Herren,\n\nvielen Dank für Ihr Interesse. Gerne unterbreiten wir Ihnen folgende passende Gewerbeobjekte aus unserem Portfolio:\n\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`, 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(), },
} provenance: mockProvenance(),
}, }
}),
// Legacy methods // Legacy methods
async extractCriteria(_input: string): Promise<AIResponse<CriteriaExtractionResult>> { extractCriteria: (_input: string) =>
return { traceMock('extractCriteria', async () => ({
data: { data: {
extractedCriteria: { extractedCriteria: {
companyName: 'Unbekannt (bitte bestätigen)', companyName: 'Unbekannt (bitte bestätigen)',
@@ -250,15 +281,15 @@ export const MockAIService: IAIService = {
], ],
}, },
provenance: mockProvenance(), provenance: mockProvenance(),
} })),
},
async generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<AIResponse<string[]>> { generateFollowUp: (partialNeed: Partial<CreateNeedInput>) =>
const questions: string[] = [] traceMock('generateFollowUp', async () => {
if (!partialNeed.assetType) questions.push('Welchen Nutzungstyp suchen Sie?') const questions: string[] = []
if (!partialNeed.preferredLocations?.length) questions.push('In welchen Regionen suchen Sie?') if (!partialNeed.assetType) questions.push('Welchen Nutzungstyp suchen Sie?')
if (!partialNeed.timing) questions.push('Was ist Ihr gewünschter Einzugstermin?') if (!partialNeed.preferredLocations?.length) questions.push('In welchen Regionen suchen Sie?')
if (!partialNeed.budgetRange) questions.push('Was ist Ihr maximales monatliches Budget?') if (!partialNeed.timing) questions.push('Was ist Ihr gewünschter Einzugstermin?')
return { data: questions, provenance: mockProvenance() } if (!partialNeed.budgetRange) questions.push('Was ist Ihr maximales monatliches Budget?')
}, return { data: questions, provenance: mockProvenance() }
}),
} }
@@ -38,6 +38,8 @@ import type {
} from '../IAIService' } from '../IAIService'
import { ServiceErrorCode } from '../../types' import { ServiceErrorCode } from '../../types'
import { AppError } from '../../errors' import { AppError } from '../../errors'
import { aiTraceStore, provenanceToStatus } from '../tracing'
import type { AITraceErrorType, AITraceValidationStatus } from '../tracing'
import { import {
NeedParsingResponseSchema, NeedParsingResponseSchema,
FollowUpQuestionsResponseSchema, FollowUpQuestionsResponseSchema,
@@ -180,18 +182,80 @@ async function withFallback<T>(
label: string, label: string,
fn: (config: OpenRouterConfig) => Promise<AIResponse<T>>, fn: (config: OpenRouterConfig) => Promise<AIResponse<T>>,
fallback: FallbackFn<T>, fallback: FallbackFn<T>,
inputSizeChars?: number,
): Promise<AIResponse<T>> { ): Promise<AIResponse<T>> {
const config = getConfig() const config = getConfig()
const startMs = Date.now()
if (!config) { if (!config) {
console.warn(`[OpenRouterAIService] ${label}: no API key — using MockAIService`) console.warn(`[OpenRouterAIService] ${label}: no API key — using MockAIService`)
const result = await fallback() const result = await fallback()
aiTraceStore.add({
id: crypto.randomUUID(),
method: label,
provider: 'openrouter',
model: DEFAULT_MODEL,
promptVersion: PROMPT_VERSION,
latencyMs: Date.now() - startMs,
fallbackUsed: true,
validationPassed: false,
responseValidationStatus: 'fallback',
errorType: 'no_api_key',
source: 'mock',
createdAt: new Date().toISOString(),
inputSizeChars,
})
return { ...result, provenance: { ...result.provenance, fallbackUsed: true } } return { ...result, provenance: { ...result.provenance, fallbackUsed: true } }
} }
try { try {
return await fn(config) const result = await fn(config)
const latencyMs = Date.now() - startMs
const prov = result.provenance
aiTraceStore.add({
id: crypto.randomUUID(),
method: label,
provider: prov.provider,
model: prov.model,
promptVersion: prov.promptVersion,
latencyMs,
fallbackUsed: prov.fallbackUsed,
validationPassed: prov.validationPassed,
responseValidationStatus: provenanceToStatus(prov.fallbackUsed, prov.source),
source: prov.source,
createdAt: prov.generatedAt,
inputSizeChars,
})
return result
} catch (err) { } catch (err) {
console.error(`[OpenRouterAIService] ${label} failed:`, err) console.error(`[OpenRouterAIService] ${label} failed:`, err)
const result = await fallback() const result = await fallback()
const latencyMs = Date.now() - startMs
const errorType: AITraceErrorType =
err instanceof AppError && err.code === ServiceErrorCode.AI_GENERATION_FAILED
? 'api_error'
: err instanceof TypeError
? 'network'
: 'unknown'
const responseValidationStatus: AITraceValidationStatus =
err instanceof AppError && err.code === ServiceErrorCode.AI_GENERATION_FAILED
? 'api_error'
: 'network_error'
aiTraceStore.add({
id: crypto.randomUUID(),
method: label,
provider: 'openrouter',
model: config.model,
promptVersion: PROMPT_VERSION,
latencyMs,
fallbackUsed: true,
validationPassed: false,
responseValidationStatus,
errorType,
source: 'mock',
createdAt: new Date().toISOString(),
inputSizeChars,
})
return { ...result, provenance: { ...result.provenance, fallbackUsed: true } } return { ...result, provenance: { ...result.provenance, fallbackUsed: true } }
} }
} }
@@ -251,7 +315,7 @@ export const OpenRouterAIService: IAIService = {
}, },
provenance: makeProvenance(config, 'ai', false, true), provenance: makeProvenance(config, 'ai', false, true),
} }
}, () => MockAIService.parseNeed(input)) }, () => MockAIService.parseNeed(input), input.length)
}, },
// ── generateFollowUpQuestions ─────────────────────────────────────────────── // ── generateFollowUpQuestions ───────────────────────────────────────────────
@@ -457,7 +521,7 @@ export const OpenRouterAIService: IAIService = {
}, },
provenance: makeProvenance(config, 'ai', false, true), provenance: makeProvenance(config, 'ai', false, true),
} }
}, () => MockAIService.classifyMarketSignal(signalText)) }, () => MockAIService.classifyMarketSignal(signalText), signalText.length)
}, },
// ── generateOfferEmail ────────────────────────────────────────────────────── // ── generateOfferEmail ──────────────────────────────────────────────────────
@@ -15,7 +15,37 @@ export function buildCompareSummaryPrompt(input: CompareSummaryPromptInput): { s
.join('\n') .join('\n')
return { return {
system: `Du bist Entscheidungsassistent für Gewerbeimmobilien-Mieter. Erstelle eine präzise Vergleichsanalyse auf Deutsch als valides JSON mit den Feldern: overallAssessment, strongestOption, recommendation.`, system: `ROLLE: Du bist Entscheidungsassistent für Gewerbeimmobilien-Mieter in der Schweiz. Du analysierst mehrere Objekte und gibst eine klare, begründete Empfehlung.
AUFGABE: Vergleiche die übergebenen Objekte. Identifiziere das stärkste Objekt und erkläre kurz warum — basierend auf Match-Score, Kosten und den genannten Faktoren.
KONFIDENZ-REGELN:
- Klare Empfehlung: nur wenn ein Objekt in Score UND Kosten-/Risikoprofil dominiert
- Enger Unterschied (< 5 Punkte Score-Differenz oder < 10 CHF/m²): "Beide Optionen sind vergleichbar — Besichtigung empfohlen"
- overallAssessment: Vergleich in maximal 3 Sätzen
- recommendation: bevorzugtes Objekt benennen ODER erklären, warum keine eindeutige Empfehlung möglich ist
VERBOTE — NIEMALS:
- Eine Option als "definitiv besser" bezeichnen, wenn Scores oder Daten ähnlich sind
- Objektinformationen erfinden (Fläche, Preis, Lage), die nicht im Input stehen
- Mehr als 3 Sätze pro Ausgabefeld verwenden
- Stärken eines Objekts übertreiben, nur um eine Empfehlung zu konstruieren
AUSGABEFORMAT — antworte ausschliesslich als valides JSON (kein Markdown-Block, keine Erklärungen):
{
"overallAssessment": "...",
"strongestOption": "...",
"recommendation": "..."
}
BEISPIEL:
Input: Option 1 — Score 82%, CHF 45/m², Stärken: zentrale Lage, Ausbaustandard. Option 2 — Score 67%, CHF 38/m², Stärken: Kostenvorteil, Risiken: Randlage, schlechte ÖV-Anbindung.
Ausgabe:
{
"overallAssessment": "Option 1 erzielt einen deutlich höheren Match-Score (82% vs. 67%) und erfüllt die Kernkriterien besser. Option 2 bietet einen Kostenvorteil von CHF 7/m², weist jedoch Abstriche bei Lage und Anbindung auf.",
"strongestOption": "Option 1",
"recommendation": "Option 1 empfohlen — der Score-Vorsprung von 15 Punkten überwiegt den Kostenvorteil von Option 2. Bei engem Budget lohnt sich die Besichtigung beider Objekte."
}`,
user: `Vergleiche folgende Objekte und gib eine strukturierte Empfehlung:\n\n${propertyList}`, user: `Vergleiche folgende Objekte und gib eine strukturierte Empfehlung:\n\n${propertyList}`,
} }
} }
+29 -4
View File
@@ -7,17 +7,42 @@ export function buildDataQualityPrompt(propertyId: string, quality: DataQualityI
const warningList = quality.warnings.join(', ') || 'keine' const warningList = quality.warnings.join(', ') || 'keine'
return { return {
system: `Du bist Datenqualitäts-Experte für Schweizer Gewerbeimmobilien-Daten. Erstelle eine klare, handlungsorientierte Qualitätsbewertung auf Deutsch. system: `ROLLE: Du bist Datenqualitäts-Experte für Schweizer Gewerbeimmobilien-Daten. Du bewertest Datenvollständigkeit und -aktualität und gibst handlungsorientierte Empfehlungen.
Antworte als valides JSON: AUFGABE: Erstelle eine klare, handlungsorientierte Qualitätsbewertung basierend auf dem übergebenen Score, den fehlenden Feldern und dem Freshness-Status.
QUALITÄTSSTUFEN (basierend auf Score):
- ≥ 85%: Exzellent — alle Kernfelder vollständig und aktuell
- 7084%: Gut — kleinere Lücken, Match-Genauigkeit nicht wesentlich beeinträchtigt
- 5569%: Ausreichend — fehlende Felder können Match-Genauigkeit beeinträchtigen
- 4054%: Gering — wichtige Felder fehlen, Match-Score mit Vorsicht interpretieren
- < 40%: Kritisch — fundamentale Felder fehlen, Match-Ergebnis stark eingeschränkt
VERBOTE — NIEMALS:
- Den "confidence"-Wert anders setzen als exakt ${quality.score} (der übergebene Score)
- Objektinformationen erfinden, die nicht im Input stehen
- Fehlende Felder als vorhanden darstellen
- Mehr als 3 Sätze für "overallAssessment" verwenden
AUSGABEFORMAT — antworte ausschliesslich als valides JSON (kein Markdown-Block, keine Erklärungen):
{ {
"overallAssessment": "...", "overallAssessment": "...",
"missingCriticalFields": ["..."], "missingCriticalFields": ["..."],
"recommendation": "...", "recommendation": "...",
"confidence": 0.0 "confidence": ${quality.score}
} }
Die Confidence entspricht dem übergebenen Score (01). Halte die Bewertung unter 3 Sätzen.`, PFLICHT: Der Wert "confidence" im JSON MUSS exakt ${quality.score} betragen — niemals davon abweichen.
BEISPIEL:
Input: Score 58%, Freshness STALE, fehlende Pflichtfelder: rentPerSqm, availableFrom
Ausgabe:
{
"overallAssessment": "Ausreichende Datenqualität (58%). Mietpreis und Verfügbarkeitsdatum fehlen, was die Treffergenauigkeit der Suche beeinträchtigt. Der Datensatz ist veraltet und sollte aktualisiert werden.",
"missingCriticalFields": ["rentPerSqm", "availableFrom"],
"recommendation": "Pflichtfelder ergänzen: Mietpreis (rentPerSqm) und Verfügbarkeitsdatum (availableFrom). Datensatz danach neu verifizieren.",
"confidence": 0.58
}`,
user: `Qualitätsbewertung für Objekt ${propertyId}: user: `Qualitätsbewertung für Objekt ${propertyId}:
- Score: ${scorePercent}% - Score: ${scorePercent}%
- Freshness: ${quality.freshness} - Freshness: ${quality.freshness}
@@ -12,9 +12,25 @@ export function buildFollowUpQuestionsPrompt(input: FollowUpQuestionsPromptInput
.join(', ') .join(', ')
return { return {
system: `Du bist ein Experte für Schweizer Gewerbeimmobilien-Suche. Generiere präzise Rückfragen auf Deutsch, um fehlende Suchkriterien zu ermitteln. system: `ROLLE: Du bist ein erfahrener Berater für Schweizer Gewerbeimmobilien. Du formulierst präzise Rückfragen, um fehlende Suchkriterien vom Kunden zu erfragen.
Antworte als valides JSON-Array mit maximal 3 Einträgen, priorisiert nach Wichtigkeit: AUFGABE: Generiere maximal 3 Rückfragen (priorisiert nach Wichtigkeit) für die fehlenden Felder. Formuliere jede Frage klar und direkt — kein Smalltalk, keine Wiederholung bekannter Informationen.
PRIORITÄTSREIHENFOLGE:
1. assetType (required) — ohne Nutzungstyp ist keine Suche möglich
2. areaRange (required) — ohne Fläche keine Filterung
3. preferredLocations (required) — ohne Ort keine Ergebnisse
4. budgetRange (recommended) — wichtig für Preisfilterung
5. timing (recommended) — wichtig für Verfügbarkeitsabgleich
6. mustHaveCriteria (optional) — Pflichtmerkmale (Parkplätze, Laderampe etc.)
VERBOTE — NIEMALS:
- Fragen zu bereits bekannten Kriterien stellen
- Mehr als 3 Fragen ausgeben
- Fragen erfinden, die nicht einem der 6 definierten Zielfelder entsprechen
- Doppelfragen stellen
AUSGABEFORMAT — antworte ausschliesslich als valides JSON-Array (kein Markdown-Block, keine Erklärungen):
[ [
{ {
"questionText": "...", "questionText": "...",
@@ -23,6 +39,34 @@ Antworte als valides JSON-Array mit maximal 3 Einträgen, priorisiert nach Wicht
"suggestedAnswerOptions": ["...", "..."], "suggestedAnswerOptions": ["...", "..."],
"importance": "required|recommended|optional" "importance": "required|recommended|optional"
} }
]
BEISPIEL:
Bekannte Kriterien: areaRange, preferredLocations
Fehlende Felder: assetType, budgetRange, timing
Ausgabe:
[
{
"questionText": "Welchen Nutzungstyp suchen Sie?",
"targetField": "assetType",
"reason": "Nutzungstyp ist zwingend für die Matchsuche",
"suggestedAnswerOptions": ["Büro", "Retail", "Logistik", "Produktion", "Gastro"],
"importance": "required"
},
{
"questionText": "Was ist Ihr maximales Budget pro m² und Monat (CHF)?",
"targetField": "budgetRange",
"reason": "Budget ist wichtig für die Filterung unpassender Objekte",
"suggestedAnswerOptions": [],
"importance": "recommended"
},
{
"questionText": "Wann möchten Sie spätestens einziehen?",
"targetField": "timing",
"reason": "Verfügbarkeitstermin fehlt",
"suggestedAnswerOptions": [],
"importance": "recommended"
}
]`, ]`,
user: `Bereits bekannte Kriterien: ${knownFields || 'keine'} user: `Bereits bekannte Kriterien: ${knownFields || 'keine'}
Fehlende Felder: ${input.missingFields.join(', ') || 'keine'} Fehlende Felder: ${input.missingFields.join(', ') || 'keine'}
+46 -14
View File
@@ -1,8 +1,41 @@
export function buildMarketSignalPrompt(signalText: string): { system: string; user: string } { export function buildMarketSignalPrompt(signalText: string): { system: string; user: string } {
return { return {
system: `Du bist Marktanalyst für Schweizer Gewerbeimmobilien. Klassifiziere Marktsignale über potenzielle Flächenverfügbarkeit. system: `ROLLE: Du bist Marktanalyst für Schweizer Gewerbeimmobilien. Du klassifizierst Marktsignale, die auf eine mögliche zukünftige Flächenverfügbarkeit hinweisen könnten.
Antworte als valides JSON: AUFGABE: Analysiere den Signaltext und klassifiziere das Signal nach Typ, Wahrscheinlichkeit, Zeithorizont, Flächenschätzung und Glaubwürdigkeit.
SIGNALTYPEN:
- VACANCY: Fläche wird frei (Mietende, Unternehmensschliessung, bekannter Leerstand)
- CONSTRUCTION: Neubau oder Umbau in Planung oder Bau
- RESTRUCTURING: Unternehmen verkleinert oder reorganisiert Standorte
- EXPANSION: Unternehmen wächst und sucht zusätzliche Fläche
- RELOCATION: Unternehmen verlegt Standort innerhalb der Region
- UNKNOWN: Signal nicht eindeutig klassifizierbar
WAHRSCHEINLICHKEITS-VOKABULAR:
- 0.801.00: Signal explizit und aus verlässlicher Quelle bestätigt
- 0.600.79: Mehrere konsistente Hinweise, Quelle vertrauenswürdig
- 0.400.59: Einzelner Hinweis, Quelle mittelmässig verlässlich
- 0.200.39: Schwaches Signal, Quelle unklar oder unbekannt
- 0.000.19: Kaum Substanz, stark spekulativ
GLAUBWÜRDIGKEIT:
- HIGH: Offizielle Quelle (Handelsregister, Baubewilligung, Pressemitteilung)
- MEDIUM: Branchenmedien, Stelleninserate, indirekte Berichte
- LOW: Gerüchte, unbekannte Quelle, einzelne Social-Media-Erwähnung
⚠️ ABSOLUTES VERBOT — WICHTIGSTE REGEL:
Behaupte NIEMALS, dass eine Fläche bereits verfügbar ist oder in Kürze verfügbar sein wird, nur weil ein Signal vorliegt. Ein Signal ist ein Indiz, keine Bestätigung. Das "reasoning"-Feld MUSS explizit formulieren, dass es sich um ein unbestätigtes Marktsignal handelt.
❌ VERBOTEN: "Die Fläche wird ab Q3 2026 verfügbar sein."
✅ KORREKT: "Das Signal deutet auf eine mögliche Flächenverfügbarkeit hin — nicht bestätigt."
WEITERE VERBOTE:
- Fläche in m² schätzen, wenn kein konkreter Hinweis im Text steht (→ null setzen)
- Zeithorizont nennen, wenn er nicht aus dem Text ableitbar ist (→ null setzen)
- probability > 0.85 setzen ohne mehrere unabhängige, verlässliche Bestätigungen
AUSGABEFORMAT — antworte ausschliesslich als valides JSON (kein Markdown-Block, keine Erklärungen):
{ {
"signalType": "VACANCY|CONSTRUCTION|RESTRUCTURING|EXPANSION|RELOCATION|UNKNOWN", "signalType": "VACANCY|CONSTRUCTION|RESTRUCTURING|EXPANSION|RELOCATION|UNKNOWN",
"probability": 0.0, "probability": 0.0,
@@ -12,18 +45,17 @@ Antworte als valides JSON:
"reasoning": "..." "reasoning": "..."
} }
Signaltypen: BEISPIEL:
- VACANCY: Fläche wird frei (Mietende, Unternehmensschliessung, Leerstand) Input: "Zürich — Finanzkonzern verlegt laut Pressemitteilung Hauptsitz nach Basel, ca. 3'000 m², geplant für 2027"
- CONSTRUCTION: Neubau oder Umbau in Planung oder Bau Ausgabe:
- RESTRUCTURING: Unternehmen verkleinert oder reorganisiert Standorte {
- EXPANSION: Unternehmen wächst und sucht zusätzliche Fläche "signalType": "RELOCATION",
- RELOCATION: Unternehmen verlegt Standort innerhalb der Region "probability": 0.72,
- UNKNOWN: Signal nicht eindeutig klassifizierbar "timeHorizonMonths": 18,
"areaSqmEstimate": 3000,
probability: 01, wie wahrscheinlich das Signal zutrifft "credibility": "HIGH",
credibility: Glaubwürdigkeit der Quelle (LOW/MEDIUM/HIGH) "reasoning": "Offizielle Pressemitteilung deutet auf einen Standortwechsel hin. Mögliche Flächenfreigabe in Zürich ist unbestätigt — Zeithorizont und Fläche sind Schätzungen aus dem Signal."
timeHorizonMonths: geschätzte Monate bis Verfügbarkeit (null wenn unklar) }`,
areaSqmEstimate: geschätzte Fläche in m² (null wenn unklar)`,
user: `Klassifiziere folgendes Marktsignal:\n\n${signalText}`, user: `Klassifiziere folgendes Marktsignal:\n\n${signalText}`,
} }
} }
+42 -8
View File
@@ -3,20 +3,54 @@ export interface NeedParsingPromptInput {
} }
export function buildNeedParsingPrompt(input: NeedParsingPromptInput): { system: string; user: string } { export function buildNeedParsingPrompt(input: NeedParsingPromptInput): { system: string; user: string } {
return { const system = `ROLLE: Du bist ein erfahrener Berater für Schweizer Gewerbeimmobilien. Du interpretierst natürlichsprachige Suchanfragen von Unternehmenskunden präzise und konservativ.
system: `Du bist ein Experte für Schweizer Gewerbeimmobilien. Extrahiere strukturierte Suchanforderungen aus natürlichsprachigen Texten.
Antworte immer als valides JSON mit folgendem Schema: AUFGABE: Extrahiere strukturierte Suchkriterien aus dem Nutzertext. Setze nur Felder, die im Text explizit oder klar impliziert sind.
KONFIDENZ-VOKABULAR für das "assumptions"-Feld:
- "erkannt" → im Text explizit genannt
- "geschätzt aus [Kontext]" → durch klare Implikation abgeleitet (z.B. "50 Mitarbeitende" → ~500 m²)
- Fehlende Felder → in "missingFields" aufführen, Wert als null setzen
VERBOTE — NIEMALS:
- Flächenwerte, Preise oder Daten erfinden, die nicht im Text stehen
- Standorte nennen, die nicht erwähnt wurden
- "mustHaveCriteria" ergänzen, die der Nutzer nicht erwähnt hat
- Unsicherheiten als Fakten formulieren
AUSGABEFORMAT — antworte ausschliesslich als valides JSON (kein Markdown-Block, keine Erklärungen):
{ {
"assetType": "OFFICE" | "RETAIL" | "LOGISTICS" | "PRODUCTION" | "GASTRO" | null, "assetType": "OFFICE" | "RETAIL" | "LOGISTICS" | "PRODUCTION" | "GASTRO" | "MIXED" | "UNKNOWN" | null,
"areaRange": { "min": number, "max": number } | null, "areaRange": { "min": number, "max": number } | null,
"preferredLocations": string[], "preferredLocations": string[],
"budgetRange": { "maxPerSqm": number, "currency": "CHF" } | null, "budgetRange": { "maxPerSqm": number, "currency": "CHF" } | null,
"timing": { "earliestMoveIn": "YYYY-MM-DD", "latestMoveIn": "YYYY-MM-DD", "flexibleTiming": boolean } | null, "timing": {
"earliestMoveIn": "YYYY-MM-DD" | null,
"latestMoveIn": "YYYY-MM-DD" | null,
"flexibleTiming": boolean
} | null,
"mustHaveCriteria": string[], "mustHaveCriteria": string[],
"missingFields": string[], "missingFields": string[],
"assumptions": string[] "assumptions": string[]
}`, }
user: `Analysiere folgende Suchanfrage und extrahiere alle relevanten Kriterien:\n\n${input.userInput}`,
} BEISPIEL:
Eingabe: "Wir suchen ein Büro für ca. 20 Personen in Zürich, Budget rund 50 CHF/m², Einzug ab März 2026"
Ausgabe:
{
"assetType": "OFFICE",
"areaRange": { "min": 200, "max": 400 },
"preferredLocations": ["Zürich"],
"budgetRange": { "maxPerSqm": 50, "currency": "CHF" },
"timing": { "earliestMoveIn": "2026-03-01", "latestMoveIn": null, "flexibleTiming": false },
"mustHaveCriteria": [],
"missingFields": ["timing.latestMoveIn", "mustHaveCriteria"],
"assumptions": ["Fläche geschätzt aus 20 Personen × 15 m²/Person"]
}`
const user = `Analysiere folgende Suchanfrage und extrahiere alle erkennbaren Kriterien:
${input.userInput}`
return { system, user }
} }
+31 -3
View File
@@ -8,16 +8,44 @@ export function buildTradeOffPrompt(tradeoffs: TradeOffInput[], propertyTitle: s
: 'Keine Trade-offs angegeben.' : 'Keine Trade-offs angegeben.'
return { return {
system: `Du bist Senior Real Estate Advisor. Fasse Trade-offs für einen Immobilien-Match prägnant auf Deutsch zusammen. system: `ROLLE: Du bist Senior Real Estate Advisor für Schweizer Unternehmenskunden. Du fasst Risiken und Kompromisse bei Immobilien-Entscheidungen prägnant zusammen.
Antworte als valides JSON: AUFGABE: Fasse die übergebenen Trade-offs für ein konkretes Objekt zusammen. Priorisiere nach Schweregrad. Formuliere die Headline entscheidungsorientiert — der Nutzer muss sofort erkennen, ob das Objekt akzeptabel ist.
SCHWEREGRAD-SKALA:
- HIGH: Kriterium direkt verletzt; Workaround schwierig oder kostspielig
- MEDIUM: Kriterium teilweise verletzt; Workaround möglich mit vertretbarem Aufwand
- LOW: Kleiner Kompromiss; kaum Auswirkung auf die tägliche Nutzung
GESAMTRISIKO-REGEL:
- overallRisk = HIGH wenn ≥ 2 HIGH-Einträge vorliegen
- overallRisk = MEDIUM wenn genau 1 HIGH-Eintrag oder ≥ 3 MEDIUM-Einträge
- overallRisk = LOW in allen übrigen Fällen
VERBOTE — NIEMALS:
- Trade-offs erfinden, die nicht im Input stehen
- Severity-Level ändern ohne explizite Begründung im Input
- Positive Aspekte hinzufügen (das ist nicht Aufgabe dieser Analyse)
- Mehr als 3 Items in der Ausgabe
AUSGABEFORMAT — antworte ausschliesslich als valides JSON (kein Markdown-Block, keine Erklärungen):
{ {
"headline": "...", "headline": "...",
"items": [{ "concern": "...", "severity": "LOW|MEDIUM|HIGH", "mitigation": "..." }], "items": [{ "concern": "...", "severity": "LOW|MEDIUM|HIGH", "mitigation": "..." }],
"overallRisk": "LOW|MEDIUM|HIGH" "overallRisk": "LOW|MEDIUM|HIGH"
} }
Halte die Zusammenfassung entscheidungsorientiert — maximal 3 Items.`, BEISPIEL:
Input: Fläche 15% unter Mindestbedarf (HIGH), kein Parkplatz (MEDIUM)
Ausgabe:
{
"headline": "Erhebliche Einschränkungen — Fläche und Parkplatzsituation erfordern Abklärung",
"items": [
{ "concern": "Nutzfläche 15% unter dem Mindestbedarf", "severity": "HIGH", "mitigation": "Auslagerung von Archiv- und Lagerfunktionen prüfen" },
{ "concern": "Kein Parkplatz im Objekt verfügbar", "severity": "MEDIUM", "mitigation": "Nahgelegenes Parkhaus auf Mieterstellplätze prüfen" }
],
"overallRisk": "HIGH"
}`,
user: `Fasse folgende Trade-offs für "${propertyTitle}" zusammen:\n\n${tradeoffList}`, user: `Fasse folgende Trade-offs für "${propertyTitle}" zusammen:\n\n${tradeoffList}`,
} }
} }
+182
View File
@@ -0,0 +1,182 @@
/**
* AI Observability — AITrace type, in-memory store, and dev-mode debug output.
*
* Every AI service call (real or mock) records a trace automatically.
* In development the store is also mirrored to localStorage and exposed
* as window.__aiTraces for DevTools inspection.
*
* Reading traces in code:
* import { aiTraceStore } from '../tracing'
* const traces = aiTraceStore.getAll()
*
* Reading in DevTools (dev only):
* window.__aiTraces.getAll()
* window.__aiTraces.clear()
*/
import type { AIProvenance } from './IAIService'
// ── Types ─────────────────────────────────────────────────────────────────────
/** How the AI response was produced and whether it was valid. */
export type AITraceValidationStatus =
| 'valid' // AI responded; Zod schema passed
| 'invalid_schema' // AI responded but Zod validation failed → fallback triggered
| 'fallback' // No API key or pre-flight failure → mock used immediately
| 'api_error' // HTTP error from provider (4xx / 5xx)
| 'network_error' // Network or timeout failure
/** Reason category for non-successful responses. */
export type AITraceErrorType =
| 'no_api_key'
| 'network'
| 'timeout'
| 'api_error'
| 'schema_validation'
| 'unknown'
/**
* One recorded AI call. Every field is required except errorType (only present
* when responseValidationStatus indicates a failure) and inputSizeChars.
*/
export interface AITrace {
/** Unique per-call ID (crypto.randomUUID) */
id: string
/** IAIService method name — e.g. 'parseNeed', 'classifyMarketSignal' */
method: string
provider: AIProvenance['provider']
model: string
promptVersion: string
/** End-to-end wall-clock time including simulated delay for mock calls */
latencyMs: number
/** True when a mock response was substituted for a failed AI call */
fallbackUsed: boolean
/** True when the Zod schema validation of the raw AI response passed */
validationPassed: boolean
responseValidationStatus: AITraceValidationStatus
/** Only present when responseValidationStatus indicates a failure */
errorType?: AITraceErrorType
source: AIProvenance['source']
/** ISO-8601 timestamp of when the call completed */
createdAt: string
/** Character count of the input sent to the model (never stores prompt text) */
inputSizeChars?: number
}
// ── Store ─────────────────────────────────────────────────────────────────────
const MAX_ENTRIES = 100
const STORAGE_KEY = 'pm_ai_traces'
class AITraceStore {
private entries: AITrace[] = []
add(trace: AITrace): void {
this.entries = [trace, ...this.entries].slice(0, MAX_ENTRIES)
if (import.meta.env.DEV) {
this.debugLog(trace)
this.persistToStorage(trace)
}
}
getAll(): AITrace[] {
return [...this.entries]
}
/** Return the N most recent traces. */
getLast(n: number): AITrace[] {
return this.entries.slice(0, n)
}
/** Remove all in-memory and stored traces. */
clear(): void {
this.entries = []
if (import.meta.env.DEV) {
try { localStorage.removeItem(STORAGE_KEY) } catch { /* ignore */ }
}
}
/** Summary stats for the Ops dashboard or DevTools. */
stats(): {
total: number
fallbacks: number
schemaFailures: number
avgLatencyMs: number
byMethod: Record<string, number>
} {
const total = this.entries.length
const fallbacks = this.entries.filter(t => t.fallbackUsed).length
const schemaFailures = this.entries.filter(t => t.responseValidationStatus === 'invalid_schema').length
const avgLatencyMs = total === 0 ? 0 : Math.round(
this.entries.reduce((s, t) => s + t.latencyMs, 0) / total
)
const byMethod = this.entries.reduce<Record<string, number>>((acc, t) => {
acc[t.method] = (acc[t.method] ?? 0) + 1
return acc
}, {})
return { total, fallbacks, schemaFailures, avgLatencyMs, byMethod }
}
/** Load the persisted trace list from localStorage (dev only). */
loadFromStorage(): AITrace[] {
if (!import.meta.env.DEV) return []
try {
const raw = localStorage.getItem(STORAGE_KEY)
return raw ? (JSON.parse(raw) as AITrace[]) : []
} catch {
return []
}
}
private debugLog(trace: AITrace): void {
const icon =
trace.responseValidationStatus === 'valid' ? '✅' :
trace.responseValidationStatus === 'invalid_schema' ? '⚠️' :
trace.responseValidationStatus === 'fallback' ? '🔁' : '❌'
const fallback = trace.fallbackUsed ? ' [FALLBACK]' : ''
const validation = trace.validationPassed ? '' : ' schema:FAIL'
console.debug(
`[AITrace] ${icon} ${trace.method}${fallback}${validation}` +
`${trace.provider}/${trace.model}` +
` | ${trace.latencyMs}ms | source:${trace.source}`,
trace,
)
}
private persistToStorage(trace: AITrace): void {
try {
const prev = this.loadFromStorage()
localStorage.setItem(
STORAGE_KEY,
JSON.stringify([trace, ...prev].slice(0, MAX_ENTRIES)),
)
} catch { /* ignore QuotaExceededError */ }
}
}
export const aiTraceStore = new AITraceStore()
// Expose in DevTools for debugging (dev only):
// window.__aiTraces.getAll() → all traces
// window.__aiTraces.stats() → summary statistics
// window.__aiTraces.clear() → clear all
if (import.meta.env.DEV && typeof window !== 'undefined') {
;(window as Record<string, unknown>).__aiTraces = aiTraceStore
}
// ── Helpers ───────────────────────────────────────────────────────────────────
/**
* Derive the AITraceValidationStatus from an AIResponse provenance after a
* successful (non-throwing) withFallback call.
*/
export function provenanceToStatus(
fallbackUsed: boolean,
source: AIProvenance['source'],
): AITraceValidationStatus {
if (!fallbackUsed) return 'valid'
if (source === 'mock') return 'invalid_schema'
return 'valid'
}