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,
|
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,17 +121,20 @@ 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) =>
|
||||||
|
traceMock('parseNeed', async () => {
|
||||||
await delay(SIMULATED_DELAY.fast)
|
await delay(SIMULATED_DELAY.fast)
|
||||||
return { data: mockParseNeed(input), provenance: mockProvenance() }
|
return { data: mockParseNeed(input), provenance: mockProvenance() }
|
||||||
},
|
}),
|
||||||
|
|
||||||
async generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise<AIResponse<FollowUpQuestion[]>> {
|
generateFollowUpQuestions: (criteria: ParsedNeedCriteria) =>
|
||||||
|
traceMock('generateFollowUpQuestions', async () => {
|
||||||
await delay(SIMULATED_DELAY.medium)
|
await delay(SIMULATED_DELAY.medium)
|
||||||
return { data: buildFollowUpQuestions(criteria), provenance: mockProvenance() }
|
return { data: buildFollowUpQuestions(criteria), provenance: mockProvenance() }
|
||||||
},
|
}),
|
||||||
|
|
||||||
async generateMatchExplanation(input: MatchExplanationInput): Promise<AIResponse<MatchExplanation>> {
|
generateMatchExplanation: (input: MatchExplanationInput) =>
|
||||||
|
traceMock('generateMatchExplanation', async () => {
|
||||||
await delay(SIMULATED_DELAY.medium)
|
await delay(SIMULATED_DELAY.medium)
|
||||||
const isStrong = input.matchScore >= 78
|
const isStrong = input.matchScore >= 78
|
||||||
const isMedium = input.matchScore >= 52
|
const isMedium = input.matchScore >= 52
|
||||||
@@ -132,9 +157,10 @@ export const MockAIService: IAIService = {
|
|||||||
},
|
},
|
||||||
provenance: mockProvenance(),
|
provenance: mockProvenance(),
|
||||||
}
|
}
|
||||||
},
|
}),
|
||||||
|
|
||||||
async summarizeTradeOffs(tradeoffs: TradeOffInput[]): Promise<AIResponse<TradeOffSummary>> {
|
summarizeTradeOffs: (tradeoffs: TradeOffInput[]) =>
|
||||||
|
traceMock('summarizeTradeOffs', async () => {
|
||||||
await delay(SIMULATED_DELAY.fast)
|
await delay(SIMULATED_DELAY.fast)
|
||||||
const critical = tradeoffs.filter(t => t.severity === 'HIGH')
|
const critical = tradeoffs.filter(t => t.severity === 'HIGH')
|
||||||
const overallRisk: TradeOffSummary['overallRisk'] =
|
const overallRisk: TradeOffSummary['overallRisk'] =
|
||||||
@@ -150,19 +176,22 @@ export const MockAIService: IAIService = {
|
|||||||
},
|
},
|
||||||
provenance: mockProvenance(),
|
provenance: mockProvenance(),
|
||||||
}
|
}
|
||||||
},
|
}),
|
||||||
|
|
||||||
async summarizeComparison(items: UnifiedMatchResult[]): Promise<AIResponse<ComparisonSummary>> {
|
summarizeComparison: (items: UnifiedMatchResult[]) =>
|
||||||
|
traceMock('summarizeComparison', async () => {
|
||||||
await delay(SIMULATED_DELAY.medium)
|
await delay(SIMULATED_DELAY.medium)
|
||||||
return { data: buildComparisonSummary(items), provenance: mockProvenance() }
|
return { data: buildComparisonSummary(items), provenance: mockProvenance() }
|
||||||
},
|
}),
|
||||||
|
|
||||||
async generateDecisionBrief(shortlistId: string): Promise<AIResponse<DecisionBrief>> {
|
generateDecisionBrief: (shortlistId: string) =>
|
||||||
|
traceMock('generateDecisionBrief', async () => {
|
||||||
await delay(SIMULATED_DELAY.slow)
|
await delay(SIMULATED_DELAY.slow)
|
||||||
return { data: buildMockDecisionBrief(shortlistId), provenance: mockProvenance() }
|
return { data: buildMockDecisionBrief(shortlistId), provenance: mockProvenance() }
|
||||||
},
|
}),
|
||||||
|
|
||||||
async generateDataQualitySummary(_propertyId: string, quality: DataQualityInput): Promise<AIResponse<DataQualitySummary>> {
|
generateDataQualitySummary: (_propertyId: string, quality: DataQualityInput) =>
|
||||||
|
traceMock('generateDataQualitySummary', async () => {
|
||||||
await delay(SIMULATED_DELAY.fast)
|
await delay(SIMULATED_DELAY.fast)
|
||||||
const level =
|
const level =
|
||||||
quality.score >= 0.85 ? 'excellent'
|
quality.score >= 0.85 ? 'excellent'
|
||||||
@@ -191,9 +220,10 @@ export const MockAIService: IAIService = {
|
|||||||
},
|
},
|
||||||
provenance: mockProvenance(),
|
provenance: mockProvenance(),
|
||||||
}
|
}
|
||||||
},
|
}),
|
||||||
|
|
||||||
async classifyMarketSignal(signalText: string): Promise<AIResponse<MarketSignalClassification>> {
|
classifyMarketSignal: (signalText: string) =>
|
||||||
|
traceMock('classifyMarketSignal', async () => {
|
||||||
await delay(SIMULATED_DELAY.medium)
|
await delay(SIMULATED_DELAY.medium)
|
||||||
const t = signalText.toLowerCase()
|
const t = signalText.toLowerCase()
|
||||||
let signalType: MarketSignalClassification['signalType'] = 'UNKNOWN'
|
let signalType: MarketSignalClassification['signalType'] = 'UNKNOWN'
|
||||||
@@ -215,9 +245,10 @@ export const MockAIService: IAIService = {
|
|||||||
},
|
},
|
||||||
provenance: mockProvenance(),
|
provenance: mockProvenance(),
|
||||||
}
|
}
|
||||||
},
|
}),
|
||||||
|
|
||||||
async generateOfferEmail(payload: OfferEmailPayload): Promise<AIResponse<{ subject: string; body: string }>> {
|
generateOfferEmail: (payload: OfferEmailPayload) =>
|
||||||
|
traceMock('generateOfferEmail', async () => {
|
||||||
await delay(SIMULATED_DELAY.medium * 2)
|
await delay(SIMULATED_DELAY.medium * 2)
|
||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
@@ -229,11 +260,11 @@ export const MockAIService: IAIService = {
|
|||||||
},
|
},
|
||||||
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>) =>
|
||||||
|
traceMock('generateFollowUp', async () => {
|
||||||
const questions: string[] = []
|
const questions: string[] = []
|
||||||
if (!partialNeed.assetType) questions.push('Welchen Nutzungstyp suchen Sie?')
|
if (!partialNeed.assetType) questions.push('Welchen Nutzungstyp suchen Sie?')
|
||||||
if (!partialNeed.preferredLocations?.length) questions.push('In welchen Regionen 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.timing) questions.push('Was ist Ihr gewünschter Einzugstermin?')
|
||||||
if (!partialNeed.budgetRange) questions.push('Was ist Ihr maximales monatliches Budget?')
|
if (!partialNeed.budgetRange) questions.push('Was ist Ihr maximales monatliches Budget?')
|
||||||
return { data: questions, provenance: mockProvenance() }
|
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}`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
- 70–84%: Gut — kleinere Lücken, Match-Genauigkeit nicht wesentlich beeinträchtigt
|
||||||
|
- 55–69%: Ausreichend — fehlende Felder können Match-Genauigkeit beeinträchtigen
|
||||||
|
- 40–54%: 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 (0–1). 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'}
|
||||||
|
|||||||
@@ -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.80–1.00: Signal explizit und aus verlässlicher Quelle bestätigt
|
||||||
|
- 0.60–0.79: Mehrere konsistente Hinweise, Quelle vertrauenswürdig
|
||||||
|
- 0.40–0.59: Einzelner Hinweis, Quelle mittelmässig verlässlich
|
||||||
|
- 0.20–0.39: Schwaches Signal, Quelle unklar oder unbekannt
|
||||||
|
- 0.00–0.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: 0–1, 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}`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user