import type { CreateNeedInput } from '../../../domain/need' import type { ParsedNeedCriteria, FollowUpQuestion } from '../../../domain/needBuilder' import type { UnifiedMatchResult } from '../../../domain/unifiedResult' import type { IAIService, AIResponse, DecisionBrief, ComparisonSummary, CriteriaExtractionResult, OfferEmailPayload, MatchExplanationInput, MatchExplanation, TradeOffInput, TradeOffSummary, DataQualityInput, DataQualitySummary, MarketSignalClassification, } from '../IAIService' import { mockProvenance } from '../IAIService' import { aiTraceStore } from '../tracing' import { mockParseNeed } from './needParser' import { buildComparisonSummary } from './compareBuilder' 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 { questionText: string reason: string suggestedAnswerOptions?: string[] importance: FollowUpQuestion['importance'] } const FOLLOW_UP_TEMPLATES: Partial> = { assetType: { questionText: 'Welchen Nutzungstyp suchen Sie?', reason: 'Nutzungstyp ist zwingend für die Matchsuche', suggestedAnswerOptions: ['Büro', 'Retail', 'Logistik', 'Produktion', 'Leichtindustrie', 'Gastro'], importance: 'required', }, areaRange: { questionText: 'Welche Fläche benötigen Sie (min–max in m²)?', reason: 'Flächenbedarf ist zwingend für die Filterung', importance: 'required', }, preferredLocations: { questionText: 'In welchen Städten oder Regionen suchen Sie?', reason: 'Standortpräferenz fehlt', suggestedAnswerOptions: ['Zürich', 'Basel', 'Bern', 'Zug', 'Genf', 'Lausanne'], importance: 'required', }, budgetRange: { questionText: 'Was ist Ihr maximales Budget pro m² und Jahr (CHF)?', reason: 'Budget ist wichtig für die Filterung unpassender Objekte', importance: 'recommended', }, timing: { questionText: 'Wann möchten Sie spätestens einziehen?', reason: 'Verfügbarkeitstermin fehlt', importance: 'recommended', }, mustHaveCriteria: { questionText: 'Haben Sie zwingende Anforderungen (ÖV-Anbindung, Parkplätze, Laderampe)?', reason: 'Pflichtkriterien sind für die Filterung relevant', importance: 'optional', }, } const AREA_AMBIGUITY_RATIO_THRESHOLD = 8 const AREA_AMBIGUITY_QUESTION: QuestionTemplate = { questionText: 'Ihre Flächenangabe ist sehr weit gefasst — können Sie den Bereich präzisieren (z.B. min 300 m², max 600 m²)?', reason: 'Zu grosse Spanne reduziert die Matchgenauigkeit erheblich', importance: 'required', } function isAreaAmbiguous(areaRange: NonNullable): boolean { const { min, max } = areaRange if (min <= 0 || max <= 0) return true return max / min > AREA_AMBIGUITY_RATIO_THRESHOLD } // Priority order: required fields first, recommended next, optional last. // Max 3 questions returned. Area range ambiguity is detected and raised as a // required clarification even when areaRange is nominally present. const FIELD_PRIORITY: Array = [ 'assetType', 'areaRange', 'preferredLocations', 'budgetRange', 'timing', 'mustHaveCriteria', ] function buildFollowUpQuestions(criteria: ParsedNeedCriteria): FollowUpQuestion[] { const questions: FollowUpQuestion[] = [] let idx = 0 for (const field of FIELD_PRIORITY) { if (questions.length >= 3) break if (field === 'areaRange') { if (!criteria.areaRange) { const tpl = FOLLOW_UP_TEMPLATES['areaRange']! questions.push({ id: `fq-mock-${idx++}`, questionText: tpl.questionText, targetField: 'areaRange', reason: tpl.reason, importance: tpl.importance }) } else if (isAreaAmbiguous(criteria.areaRange)) { questions.push({ id: `fq-mock-${idx++}`, questionText: AREA_AMBIGUITY_QUESTION.questionText, targetField: 'areaRange', reason: AREA_AMBIGUITY_QUESTION.reason, importance: AREA_AMBIGUITY_QUESTION.importance }) } continue } const isMissing = field === 'preferredLocations' ? !criteria.preferredLocations?.length : field === 'mustHaveCriteria' ? !criteria.mustHaveCriteria?.length : !criteria[field] if (isMissing) { const tpl = FOLLOW_UP_TEMPLATES[field] if (!tpl) continue questions.push({ id: `fq-mock-${idx++}`, questionText: tpl.questionText, targetField: field, reason: tpl.reason, importance: tpl.importance, suggestedAnswerOptions: tpl.suggestedAnswerOptions, }) } } return questions } // ── Service ─────────────────────────────────────────────────────────────────── export const MockAIService: IAIService = { parseNeed: (input: string) => traceMock('parseNeed', async () => { await delay(SIMULATED_DELAY.fast) return { data: mockParseNeed(input), provenance: mockProvenance() } }), generateFollowUpQuestions: (criteria: ParsedNeedCriteria) => traceMock('generateFollowUpQuestions', async () => { await delay(SIMULATED_DELAY.medium) return { data: buildFollowUpQuestions(criteria), 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(), } }), 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(), } }), summarizeComparison: (items: UnifiedMatchResult[]) => traceMock('summarizeComparison', async () => { await delay(SIMULATED_DELAY.medium) return { data: buildComparisonSummary(items), provenance: mockProvenance() } }), generateDecisionBrief: (shortlistId: string) => traceMock('generateDecisionBrief', async () => { await delay(SIMULATED_DELAY.slow) return { data: buildMockDecisionBrief(shortlistId), 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(), } }), 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(), } }), 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 extractCriteria: (_input: string) => traceMock('extractCriteria', async () => ({ data: { extractedCriteria: { companyName: 'Unbekannt (bitte bestätigen)', requiredArea: { min: 400, max: 900 }, budgetRange: { maxPerSqm: 40, currency: 'CHF' }, }, confidence: 0.72, missingFields: ['assetType', 'timing', 'preferredLocations'], assumptions: ['Fläche aus Zahlenangabe geschätzt', 'Budget aus Kostennennung abgeleitet'], followUpQuestions: [ 'Welchen Nutzungstyp suchen Sie (Büro, Retail, Logistik)?', 'In welchen Städten oder Regionen suchen Sie?', 'Wann möchten Sie spätestens einziehen?', ], }, 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() } }), }