refactor: architecture compliance pass — DS tokens, hook boundary, god component split, AI hardening
- 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>
This commit is contained in:
@@ -59,7 +59,7 @@ const FOLLOW_UP_TEMPLATES: Partial<Record<keyof ParsedNeedCriteria, QuestionTemp
|
||||
assetType: {
|
||||
questionText: 'Welchen Nutzungstyp suchen Sie?',
|
||||
reason: 'Nutzungstyp ist zwingend für die Matchsuche',
|
||||
suggestedAnswerOptions: ['Büro', 'Retail', 'Logistik', 'Produktion', 'Gastro'],
|
||||
suggestedAnswerOptions: ['Büro', 'Retail', 'Logistik', 'Produktion', 'Leichtindustrie', 'Gastro'],
|
||||
importance: 'required',
|
||||
},
|
||||
areaRange: {
|
||||
@@ -74,7 +74,7 @@ const FOLLOW_UP_TEMPLATES: Partial<Record<keyof ParsedNeedCriteria, QuestionTemp
|
||||
importance: 'required',
|
||||
},
|
||||
budgetRange: {
|
||||
questionText: 'Was ist Ihr maximales Budget pro m² und Monat (CHF)?',
|
||||
questionText: 'Was ist Ihr maximales Budget pro m² und Jahr (CHF)?',
|
||||
reason: 'Budget ist wichtig für die Filterung unpassender Objekte',
|
||||
importance: 'recommended',
|
||||
},
|
||||
@@ -90,32 +90,69 @@ const FOLLOW_UP_TEMPLATES: Partial<Record<keyof ParsedNeedCriteria, QuestionTemp
|
||||
},
|
||||
}
|
||||
|
||||
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 missing: Array<keyof ParsedNeedCriteria> = []
|
||||
const questions: FollowUpQuestion[] = []
|
||||
let idx = 0
|
||||
|
||||
if (!criteria.assetType) missing.push('assetType')
|
||||
if (!criteria.areaRange) missing.push('areaRange')
|
||||
if (!criteria.preferredLocations?.length) missing.push('preferredLocations')
|
||||
if (!criteria.budgetRange) missing.push('budgetRange')
|
||||
if (!criteria.timing) missing.push('timing')
|
||||
if (!criteria.mustHaveCriteria?.length) missing.push('mustHaveCriteria')
|
||||
for (const field of FIELD_PRIORITY) {
|
||||
if (questions.length >= 3) break
|
||||
|
||||
return missing
|
||||
.slice(0, 3)
|
||||
.map((field, i) => {
|
||||
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) return null
|
||||
const q: FollowUpQuestion = {
|
||||
id: `fq-mock-${i}`,
|
||||
if (!tpl) continue
|
||||
questions.push({
|
||||
id: `fq-mock-${idx++}`,
|
||||
questionText: tpl.questionText,
|
||||
targetField: field,
|
||||
reason: tpl.reason,
|
||||
importance: tpl.importance,
|
||||
suggestedAnswerOptions: tpl.suggestedAnswerOptions,
|
||||
}
|
||||
return q
|
||||
})
|
||||
.filter((q): q is FollowUpQuestion => q !== null)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return questions
|
||||
}
|
||||
|
||||
// ── Service ───────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user