feat(ai): observability tracing + improved prompt templates
- Add AITrace type, AITraceStore (circular buffer, localStorage in DEV, window.__aiTraces for DevTools), provenanceToStatus() helper - Instrument OpenRouterAIService withFallback with latency tracking and trace recording across all three paths (no-key, success, error) - Wrap all MockAIService methods with traceMock for consistent in-memory tracing including method name, latency, and validation status - Improve all 6 prompt templates with ROLLE/AUFGABE/VERBOTE/BEISPIEL structure; marketSignalPrompt carries hard prohibition against claiming confirmed availability from unconfirmed signals Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ import type {
|
||||
MarketSignalClassification,
|
||||
} from '../IAIService'
|
||||
import { mockProvenance } from '../IAIService'
|
||||
import { aiTraceStore } from '../tracing'
|
||||
import { mockParseNeed } from './needParser'
|
||||
import { buildComparisonSummary } from './compareBuilder'
|
||||
import { buildMockDecisionBrief } from './decisionBrief'
|
||||
@@ -24,6 +25,27 @@ import { buildMockDecisionBrief } from './decisionBrief'
|
||||
const SIMULATED_DELAY = { fast: 300, medium: 600, slow: 1800 }
|
||||
const delay = (ms: number) => new Promise(r => setTimeout(r, ms))
|
||||
|
||||
// ── Tracing wrapper ───────────────────────────────────────────────────────────
|
||||
|
||||
async function traceMock<T>(method: string, fn: () => Promise<AIResponse<T>>): Promise<AIResponse<T>> {
|
||||
const startMs = Date.now()
|
||||
const result = await fn()
|
||||
aiTraceStore.add({
|
||||
id: crypto.randomUUID(),
|
||||
method,
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
promptVersion: 'mock',
|
||||
latencyMs: Date.now() - startMs,
|
||||
fallbackUsed: false,
|
||||
validationPassed: true,
|
||||
responseValidationStatus: 'valid',
|
||||
source: 'mock',
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// ── Follow-up question templates keyed by ParsedNeedCriteria field ────────────
|
||||
|
||||
interface QuestionTemplate {
|
||||
@@ -99,17 +121,20 @@ function buildFollowUpQuestions(criteria: ParsedNeedCriteria): FollowUpQuestion[
|
||||
// ── Service ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const MockAIService: IAIService = {
|
||||
async parseNeed(input: string): Promise<AIResponse<ReturnType<typeof mockParseNeed>>> {
|
||||
parseNeed: (input: string) =>
|
||||
traceMock('parseNeed', async () => {
|
||||
await delay(SIMULATED_DELAY.fast)
|
||||
return { data: mockParseNeed(input), provenance: mockProvenance() }
|
||||
},
|
||||
}),
|
||||
|
||||
async generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise<AIResponse<FollowUpQuestion[]>> {
|
||||
generateFollowUpQuestions: (criteria: ParsedNeedCriteria) =>
|
||||
traceMock('generateFollowUpQuestions', async () => {
|
||||
await delay(SIMULATED_DELAY.medium)
|
||||
return { data: buildFollowUpQuestions(criteria), provenance: mockProvenance() }
|
||||
},
|
||||
}),
|
||||
|
||||
async generateMatchExplanation(input: MatchExplanationInput): Promise<AIResponse<MatchExplanation>> {
|
||||
generateMatchExplanation: (input: MatchExplanationInput) =>
|
||||
traceMock('generateMatchExplanation', async () => {
|
||||
await delay(SIMULATED_DELAY.medium)
|
||||
const isStrong = input.matchScore >= 78
|
||||
const isMedium = input.matchScore >= 52
|
||||
@@ -132,9 +157,10 @@ export const MockAIService: IAIService = {
|
||||
},
|
||||
provenance: mockProvenance(),
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
async summarizeTradeOffs(tradeoffs: TradeOffInput[]): Promise<AIResponse<TradeOffSummary>> {
|
||||
summarizeTradeOffs: (tradeoffs: TradeOffInput[]) =>
|
||||
traceMock('summarizeTradeOffs', async () => {
|
||||
await delay(SIMULATED_DELAY.fast)
|
||||
const critical = tradeoffs.filter(t => t.severity === 'HIGH')
|
||||
const overallRisk: TradeOffSummary['overallRisk'] =
|
||||
@@ -150,19 +176,22 @@ export const MockAIService: IAIService = {
|
||||
},
|
||||
provenance: mockProvenance(),
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
async summarizeComparison(items: UnifiedMatchResult[]): Promise<AIResponse<ComparisonSummary>> {
|
||||
summarizeComparison: (items: UnifiedMatchResult[]) =>
|
||||
traceMock('summarizeComparison', async () => {
|
||||
await delay(SIMULATED_DELAY.medium)
|
||||
return { data: buildComparisonSummary(items), provenance: mockProvenance() }
|
||||
},
|
||||
}),
|
||||
|
||||
async generateDecisionBrief(shortlistId: string): Promise<AIResponse<DecisionBrief>> {
|
||||
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>> {
|
||||
generateDataQualitySummary: (_propertyId: string, quality: DataQualityInput) =>
|
||||
traceMock('generateDataQualitySummary', async () => {
|
||||
await delay(SIMULATED_DELAY.fast)
|
||||
const level =
|
||||
quality.score >= 0.85 ? 'excellent'
|
||||
@@ -191,9 +220,10 @@ export const MockAIService: IAIService = {
|
||||
},
|
||||
provenance: mockProvenance(),
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
async classifyMarketSignal(signalText: string): Promise<AIResponse<MarketSignalClassification>> {
|
||||
classifyMarketSignal: (signalText: string) =>
|
||||
traceMock('classifyMarketSignal', async () => {
|
||||
await delay(SIMULATED_DELAY.medium)
|
||||
const t = signalText.toLowerCase()
|
||||
let signalType: MarketSignalClassification['signalType'] = 'UNKNOWN'
|
||||
@@ -215,9 +245,10 @@ export const MockAIService: IAIService = {
|
||||
},
|
||||
provenance: mockProvenance(),
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
async generateOfferEmail(payload: OfferEmailPayload): Promise<AIResponse<{ subject: string; body: string }>> {
|
||||
generateOfferEmail: (payload: OfferEmailPayload) =>
|
||||
traceMock('generateOfferEmail', async () => {
|
||||
await delay(SIMULATED_DELAY.medium * 2)
|
||||
return {
|
||||
data: {
|
||||
@@ -229,11 +260,11 @@ export const MockAIService: IAIService = {
|
||||
},
|
||||
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[]>> {
|
||||
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}`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
- 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": "...",
|
||||
"missingCriticalFields": ["..."],
|
||||
"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}:
|
||||
- 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'}
|
||||
|
||||
@@ -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.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",
|
||||
"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: 0–1, 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}`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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}`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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