diff --git a/src/services/ai/mock/MockAIService.ts b/src/services/ai/mock/MockAIService.ts index d03247e..3191e06 100644 --- a/src/services/ai/mock/MockAIService.ts +++ b/src/services/ai/mock/MockAIService.ts @@ -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(method: string, fn: () => Promise>): Promise> { + 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>> { - 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> { - 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> { - 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> { - 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> { - 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> { - 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> { - 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 = { - 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 = { + 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> { - await delay(SIMULATED_DELAY.medium) - const t = signalText.toLowerCase() - let signalType: MarketSignalClassification['signalType'] = 'UNKNOWN' - if (t.includes('neubau') || t.includes('baubewilligung') || t.includes('umbau')) signalType = 'CONSTRUCTION' - else if (t.includes('expansion') || t.includes('wachstum') || t.includes('sucht fläche')) signalType = 'EXPANSION' - else if (t.includes('verlegt') || t.includes('umzug') || t.includes('relocation')) signalType = 'RELOCATION' - else if (t.includes('stellenabbau') || t.includes('restruktur') || t.includes('fusion')) signalType = 'RESTRUCTURING' - else if (t.includes('frei') || t.includes('kündigung') || t.includes('schliessung') || t.includes('leerstand')) signalType = 'VACANCY' - const areaMatch = signalText.match(/(\d{2,5})\s*m²/) - const monthsMatch = signalText.match(/(\d{1,2})\s*Monate?n?/) - return { - data: { - signalType, - probability: 0.65, - timeHorizonMonths: monthsMatch ? parseInt(monthsMatch[1]) : null, - areaSqmEstimate: areaMatch ? parseInt(areaMatch[1]) : null, - credibility: 'MEDIUM', - reasoning: `Keyword-basierte Klassifikation (Mock). Signaltyp: ${signalType}.`, - }, - provenance: mockProvenance(), - } - }, + classifyMarketSignal: (signalText: string) => + traceMock('classifyMarketSignal', async () => { + await delay(SIMULATED_DELAY.medium) + const t = signalText.toLowerCase() + let signalType: MarketSignalClassification['signalType'] = 'UNKNOWN' + if (t.includes('neubau') || t.includes('baubewilligung') || t.includes('umbau')) signalType = 'CONSTRUCTION' + else if (t.includes('expansion') || t.includes('wachstum') || t.includes('sucht fläche')) signalType = 'EXPANSION' + else if (t.includes('verlegt') || t.includes('umzug') || t.includes('relocation')) signalType = 'RELOCATION' + else if (t.includes('stellenabbau') || t.includes('restruktur') || t.includes('fusion')) signalType = 'RESTRUCTURING' + else if (t.includes('frei') || t.includes('kündigung') || t.includes('schliessung') || t.includes('leerstand')) signalType = 'VACANCY' + const areaMatch = signalText.match(/(\d{2,5})\s*m²/) + const monthsMatch = signalText.match(/(\d{1,2})\s*Monate?n?/) + return { + data: { + signalType, + probability: 0.65, + timeHorizonMonths: monthsMatch ? parseInt(monthsMatch[1]) : null, + areaSqmEstimate: areaMatch ? parseInt(areaMatch[1]) : null, + credibility: 'MEDIUM', + reasoning: `Keyword-basierte Klassifikation (Mock). Signaltyp: ${signalType}.`, + }, + provenance: mockProvenance(), + } + }), - async generateOfferEmail(payload: OfferEmailPayload): Promise> { - 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> { - 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): Promise> { - 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) => + 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() } + }), } diff --git a/src/services/ai/openrouter/OpenRouterAIService.ts b/src/services/ai/openrouter/OpenRouterAIService.ts index dcfda9e..d0fe364 100644 --- a/src/services/ai/openrouter/OpenRouterAIService.ts +++ b/src/services/ai/openrouter/OpenRouterAIService.ts @@ -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( label: string, fn: (config: OpenRouterConfig) => Promise>, fallback: FallbackFn, + inputSizeChars?: number, ): Promise> { 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 ────────────────────────────────────────────────────── diff --git a/src/services/ai/prompts/compareSummaryPrompt.ts b/src/services/ai/prompts/compareSummaryPrompt.ts index 60af18e..4bb5b7d 100644 --- a/src/services/ai/prompts/compareSummaryPrompt.ts +++ b/src/services/ai/prompts/compareSummaryPrompt.ts @@ -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}`, } } diff --git a/src/services/ai/prompts/dataQualityPrompt.ts b/src/services/ai/prompts/dataQualityPrompt.ts index 84fbcf1..c38b12f 100644 --- a/src/services/ai/prompts/dataQualityPrompt.ts +++ b/src/services/ai/prompts/dataQualityPrompt.ts @@ -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} diff --git a/src/services/ai/prompts/followUpQuestionsPrompt.ts b/src/services/ai/prompts/followUpQuestionsPrompt.ts index 535c086..fb8291b 100644 --- a/src/services/ai/prompts/followUpQuestionsPrompt.ts +++ b/src/services/ai/prompts/followUpQuestionsPrompt.ts @@ -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'} diff --git a/src/services/ai/prompts/marketSignalPrompt.ts b/src/services/ai/prompts/marketSignalPrompt.ts index c7b727a..623b469 100644 --- a/src/services/ai/prompts/marketSignalPrompt.ts +++ b/src/services/ai/prompts/marketSignalPrompt.ts @@ -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}`, } } diff --git a/src/services/ai/prompts/needParsingPrompt.ts b/src/services/ai/prompts/needParsingPrompt.ts index 903831e..8af10ef 100644 --- a/src/services/ai/prompts/needParsingPrompt.ts +++ b/src/services/ai/prompts/needParsingPrompt.ts @@ -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 } } diff --git a/src/services/ai/prompts/tradeOffPrompt.ts b/src/services/ai/prompts/tradeOffPrompt.ts index 823581a..15e150c 100644 --- a/src/services/ai/prompts/tradeOffPrompt.ts +++ b/src/services/ai/prompts/tradeOffPrompt.ts @@ -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}`, } } diff --git a/src/services/ai/tracing.ts b/src/services/ai/tracing.ts new file mode 100644 index 0000000..0ebb2a4 --- /dev/null +++ b/src/services/ai/tracing.ts @@ -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 + } { + 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>((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).__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' +}