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,
} 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*/)
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() }
}),
}
@@ -38,6 +38,8 @@ import type {
} from '../IAIService'
import { ServiceErrorCode } from '../../types'
import { AppError } from '../../errors'
import { aiTraceStore, provenanceToStatus } from '../tracing'
import type { AITraceErrorType, AITraceValidationStatus } from '../tracing'
import {
NeedParsingResponseSchema,
FollowUpQuestionsResponseSchema,
@@ -180,18 +182,80 @@ async function withFallback<T>(
label: string,
fn: (config: OpenRouterConfig) => Promise<AIResponse<T>>,
fallback: FallbackFn<T>,
inputSizeChars?: number,
): Promise<AIResponse<T>> {
const config = getConfig()
const startMs = Date.now()
if (!config) {
console.warn(`[OpenRouterAIService] ${label}: no API key — using MockAIService`)
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 } }
}
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) {
console.error(`[OpenRouterAIService] ${label} failed:`, err)
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 } }
}
}
@@ -251,7 +315,7 @@ export const OpenRouterAIService: IAIService = {
},
provenance: makeProvenance(config, 'ai', false, true),
}
}, () => MockAIService.parseNeed(input))
}, () => MockAIService.parseNeed(input), input.length)
},
// ── generateFollowUpQuestions ───────────────────────────────────────────────
@@ -457,7 +521,7 @@ export const OpenRouterAIService: IAIService = {
},
provenance: makeProvenance(config, 'ai', false, true),
}
}, () => MockAIService.classifyMarketSignal(signalText))
}, () => MockAIService.classifyMarketSignal(signalText), signalText.length)
},
// ── generateOfferEmail ──────────────────────────────────────────────────────
@@ -15,7 +15,37 @@ export function buildCompareSummaryPrompt(input: CompareSummaryPromptInput): { s
.join('\n')
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}`,
}
}
+29 -4
View File
@@ -7,17 +7,42 @@ export function buildDataQualityPrompt(propertyId: string, quality: DataQualityI
const warningList = quality.warnings.join(', ') || 'keine'
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": "...",
"missingCriticalFields": ["..."],
"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}:
- Score: ${scorePercent}%
- Freshness: ${quality.freshness}
@@ -12,9 +12,25 @@ export function buildFollowUpQuestionsPrompt(input: FollowUpQuestionsPromptInput
.join(', ')
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": "...",
@@ -23,6 +39,34 @@ Antworte als valides JSON-Array mit maximal 3 Einträgen, priorisiert nach Wicht
"suggestedAnswerOptions": ["...", "..."],
"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'}
Fehlende Felder: ${input.missingFields.join(', ') || 'keine'}
+46 -14
View File
@@ -1,8 +1,41 @@
export function buildMarketSignalPrompt(signalText: string): { system: string; user: string } {
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",
"probability": 0.0,
@@ -12,18 +45,17 @@ Antworte als valides JSON:
"reasoning": "..."
}
Signaltypen:
- VACANCY: Fläche wird frei (Mietende, Unternehmensschliessung, 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
probability: 01, wie wahrscheinlich das Signal zutrifft
credibility: Glaubwürdigkeit der Quelle (LOW/MEDIUM/HIGH)
timeHorizonMonths: geschätzte Monate bis Verfügbarkeit (null wenn unklar)
areaSqmEstimate: geschätzte Fläche in m² (null wenn unklar)`,
BEISPIEL:
Input: "Zürich — Finanzkonzern verlegt laut Pressemitteilung Hauptsitz nach Basel, ca. 3'000 m², geplant für 2027"
Ausgabe:
{
"signalType": "RELOCATION",
"probability": 0.72,
"timeHorizonMonths": 18,
"areaSqmEstimate": 3000,
"credibility": "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."
}`,
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 } {
return {
system: `Du bist ein Experte für Schweizer Gewerbeimmobilien. Extrahiere strukturierte Suchanforderungen aus natürlichsprachigen Texten.
const system = `ROLLE: Du bist ein erfahrener Berater für Schweizer Gewerbeimmobilien. Du interpretierst natürlichsprachige Suchanfragen von Unternehmenskunden präzise und konservativ.
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,
"preferredLocations": string[],
"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[],
"missingFields": 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.'
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": "...",
"items": [{ "concern": "...", "severity": "LOW|MEDIUM|HIGH", "mitigation": "..." }],
"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}`,
}
}
+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'
}