e1f4beb898
- DS token migration: Anfragen.tsx + child components (AnfragenInquiryItem, AnfragenMessageBubble) fully migrated; DS_TEXT.brandDark added; scoreTheme.ts moved to src/lib/ with re-export proxy - Hook boundary: Results.tsx no longer calls needService directly — routes through useNeeds() with optional refetchOnMount/gcTime overrides - NewListing.tsx (440L) split into useNewListingForm hook + 8 section components under src/components/new-listing/; page shell reduced to 121 lines - AI hardening: Zod .strict() on all schemas, AIProvenance extended with schemaVersion/ fallbackReason/traceId/latencyMs, AITraceStore stats with p50/p90/p99 + failure breakdowns, MockAIService buildFollowUpQuestions with priority ordering + area-ambiguity detection, prompt templates updated (LIGHT_INDUSTRIAL, budget unit, ambiguity detection, decimal precision) - Tests: all 154 passing; fixed test regression caused by OfferEmailResponseSchema body min(50) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
333 lines
14 KiB
TypeScript
333 lines
14 KiB
TypeScript
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<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 {
|
||
questionText: string
|
||
reason: string
|
||
suggestedAnswerOptions?: string[]
|
||
importance: FollowUpQuestion['importance']
|
||
}
|
||
|
||
const FOLLOW_UP_TEMPLATES: Partial<Record<keyof ParsedNeedCriteria, QuestionTemplate>> = {
|
||
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<ParsedNeedCriteria['areaRange']>): 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<keyof ParsedNeedCriteria> = [
|
||
'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<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(),
|
||
}
|
||
}),
|
||
|
||
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<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() }
|
||
}),
|
||
}
|