feat: F008 AI need builder — smart parse, criteria review, follow-up & weighting

Domain: ParsedNeedCriteria, FollowUpQuestion, ParseNeedResult, NeedBuilderStep, WEIGHTING_KEYS/LABELS. Services: weightingService (asset-type profiles), aiService extended with parseNeed (keyword extraction mock, 1.4s delay) and generateFollowUpQuestions. Components: NeedBuilderProgress, NeedInput, ConfidenceFieldBadge, ExtractedFieldRow, CriteriaReviewPanel, FollowUpQuestionCard, FollowUpPanel, WeightingEditor, NeedCardPreview, NeedBuilderErrorState. Page: AINeedBuilderPage replaces AISearch with 4-step flow (input → review → weighting → save).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-16 12:21:49 +02:00
parent cb18f3c381
commit 73a6a167e0
15 changed files with 1116 additions and 296 deletions
+227 -2
View File
@@ -1,6 +1,10 @@
import type { ItemResponse, ServiceError } from './types'
import { ServiceErrorCode } from './types'
import type { CreateNeedInput } from '../domain/need'
import type { AssetType } from '../domain/enums'
import type { ParseNeedResult, ParsedNeedCriteria, FollowUpQuestion } from '../domain/needBuilder'
// ── Legacy types (kept for backward compatibility) ────────────────────────────
export interface CriteriaExtractionResult {
extractedCriteria: Partial<CreateNeedInput>
@@ -15,9 +19,218 @@ export interface AIServiceProvider {
generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<string[]>
}
// ── Mock parse logic ──────────────────────────────────────────────────────────
function mockParseNeed(input: string): ParseNeedResult {
const lower = input.toLowerCase()
// Asset type
const assetType: AssetType | undefined =
lower.includes('büro') || lower.includes('office') ? 'OFFICE'
: lower.includes('logistik') || lower.includes('lager') ? 'LOGISTICS'
: lower.includes('retail') || lower.includes('laden') || lower.includes('shop') ? 'RETAIL'
: lower.includes('produktion') || lower.includes('gewerbe') || lower.includes('industrie') ? 'PRODUCTION'
: lower.includes('gastro') || lower.includes('restaurant') ? 'GASTRO'
: undefined
// Area
const areaRangeMatch = input.match(/(\d+)\s*[\-]\s*(\d+)\s*m[²2]/i)
const areaSingleMatch = input.match(/(\d{3,5})\s*m[²2]/i)
let areaRange: { min: number; max: number } | undefined
let areaConfidence = 0.25
if (areaRangeMatch) {
areaRange = { min: parseInt(areaRangeMatch[1]), max: parseInt(areaRangeMatch[2]) }
areaConfidence = 0.95
} else if (areaSingleMatch) {
const base = parseInt(areaSingleMatch[1])
areaRange = { min: Math.round(base * 0.8), max: Math.round(base * 1.2) }
areaConfidence = 0.70
}
// Locations
const CITIES: [string, string][] = [
['zürich', 'Zürich'], ['basel', 'Basel'], ['bern', 'Bern'], ['genf', 'Genf'],
['lausanne', 'Lausanne'], ['winterthur', 'Winterthur'], ['zug', 'Zug'],
['luzern', 'Luzern'], ['st. gallen', 'St. Gallen'], ['lugano', 'Lugano'],
['biel', 'Biel'], ['schaffhausen', 'Schaffhausen'],
]
const preferredLocations = CITIES.filter(([k]) => lower.includes(k)).map(([, v]) => v)
const locationConfidence = preferredLocations.length > 0 ? 0.88 : 0.15
// Budget
const budgetPerSqmMatch = input.match(/(\d+)\s*(?:CHF)?\s*\/\s*m[²2]/i)
const budgetMaxMatch = input.match(/(?:max\.?|bis|höchstens)\s*(?:CHF\s*)?(\d+)/i)
let budgetRange: { maxPerSqm: number; currency: string } | undefined
let budgetConfidence = 0.20
if (budgetPerSqmMatch) {
budgetRange = { maxPerSqm: parseInt(budgetPerSqmMatch[1]), currency: 'CHF' }
budgetConfidence = 0.92
} else if (budgetMaxMatch) {
budgetRange = { maxPerSqm: parseInt(budgetMaxMatch[1]), currency: 'CHF' }
budgetConfidence = 0.60
}
// Timing
const yearMatch = input.match(/(?:ab\s+)?(?:Q[1-4]\s*\/?\s*)?(\d{4})/)
const soonMatch = lower.includes('sofort') || lower.includes('asap')
let timing: ParsedNeedCriteria['timing'] | undefined
let timingConfidence = 0.20
if (soonMatch) {
timing = { earliestMoveIn: '2025-07-01', latestMoveIn: '2025-10-01', flexibleTiming: false }
timingConfidence = 0.85
} else if (yearMatch) {
timing = { earliestMoveIn: `${yearMatch[1]}-01-01`, latestMoveIn: `${yearMatch[1]}-12-31`, flexibleTiming: lower.includes('flexibel') }
timingConfidence = 0.75
}
// Must-haves
const mustHaveCriteria: string[] = []
if (lower.includes('öv') || lower.includes('bahnhof') || lower.includes('tram')) mustHaveCriteria.push('Gute ÖV-Anbindung')
if (lower.includes('parking') || lower.includes('parkplatz') || lower.includes('tiefgarage')) mustHaveCriteria.push('Parkplätze vorhanden')
if (lower.includes('klimaanlage') || lower.includes('klima')) mustHaveCriteria.push('Klimaanlage')
if (lower.includes('server') || lower.includes('rechenzentr')) mustHaveCriteria.push('Serverraum / IT-Infrastruktur')
if (lower.includes('barrierefrei')) mustHaveCriteria.push('Barrierefreiheit')
if (lower.includes('küche') || lower.includes('kantine')) mustHaveCriteria.push('Kantine / Küche')
// Soft
const prestigeImportance: 'LOW' | 'MEDIUM' | 'HIGH' | undefined =
lower.includes('prestige') || lower.includes('representativ') || lower.includes('repräsentativ') ? 'HIGH'
: lower.includes('standard') ? 'LOW'
: undefined
const parkingNeed = lower.includes('parking') || lower.includes('parkplatz')
const visibilityNeed: 'HIGH' | undefined = lower.includes('sichtbar') || lower.includes('passanten') ? 'HIGH' : undefined
const footfallNeed: 'HIGH' | undefined = lower.includes('frequenz') || lower.includes('laufkundschaft') ? 'HIGH' : undefined
// Missing fields
const missingFields: string[] = []
if (!assetType) missingFields.push('Nutzungstyp')
if (!areaRange) missingFields.push('Flächenbedarf')
if (preferredLocations.length === 0) missingFields.push('Standort')
if (!budgetRange) missingFields.push('Budget')
if (!timing) missingFields.push('Verfügbarkeitstermin')
// Assumptions
const assumptions: string[] = []
if (areaRange && areaSingleMatch && !areaRangeMatch) {
assumptions.push(`Flächenrange aus Einzelangabe (${areaSingleMatch[1]} m²) geschätzt — bitte prüfen`)
}
if (budgetRange && !budgetPerSqmMatch && budgetMaxMatch) {
assumptions.push('Budget als Pauschalangabe interpretiert — Angabe pro m² unklar')
}
if (!assetType) {
assumptions.push('Nutzungstyp konnte nicht eindeutig erkannt werden')
}
// Confidence by field
const confidenceByField: Record<string, number> = {
assetType: assetType ? 0.92 : 0.20,
areaRange: areaConfidence,
preferredLocations: locationConfidence,
budgetRange: budgetConfidence,
timing: timingConfidence,
mustHaveCriteria: mustHaveCriteria.length > 0 ? 0.85 : 0.10,
prestigeImportance: prestigeImportance ? 0.80 : 0.20,
parkingNeed: parkingNeed ? 0.90 : 0.30,
}
// Follow-up questions
const followUpQuestionCandidates: FollowUpQuestion[] = []
if (!assetType) {
followUpQuestionCandidates.push({
id: 'fq-asset-type',
questionText: 'Welchen Nutzungstyp suchen Sie?',
targetField: 'assetType',
reason: 'Der Nutzungstyp konnte nicht eindeutig erkannt werden.',
suggestedAnswerOptions: ['Büro', 'Logistik / Lager', 'Retail', 'Produktion', 'Gastro / F&B'],
importance: 'required',
})
}
if (preferredLocations.length === 0) {
followUpQuestionCandidates.push({
id: 'fq-location',
questionText: 'In welcher Region oder Stadt suchen Sie?',
targetField: 'preferredLocations',
reason: 'Kein konkreter Standort angegeben.',
suggestedAnswerOptions: ['Zürich', 'Basel', 'Bern', 'Zug', 'Luzern', 'Genf'],
importance: 'required',
})
}
if (!timing) {
followUpQuestionCandidates.push({
id: 'fq-timing',
questionText: 'Ab wann benötigen Sie die Fläche?',
targetField: 'timing',
reason: 'Kein Verfügbarkeitsdatum erkannt.',
suggestedAnswerOptions: ['Sofort', 'In 3 Monaten', 'In 6 Monaten', 'In 12 Monaten', 'Flexibel'],
importance: 'recommended',
})
}
if (!budgetRange) {
followUpQuestionCandidates.push({
id: 'fq-budget',
questionText: 'Was ist Ihr Maximalbudget pro m² und Jahr?',
targetField: 'budgetRange',
reason: 'Kein Budget erkannt.',
suggestedAnswerOptions: ['< CHF 20/m²', 'CHF 2040/m²', 'CHF 4080/m²', '> CHF 80/m²', 'Flexible'],
importance: 'recommended',
})
}
followUpQuestionCandidates.push({
id: 'fq-parking',
questionText: 'Benötigen Sie Parkplätze vor Ort?',
targetField: 'parkingNeed',
reason: 'Angabe zu Parkplatzbedarf verbessert die Matchqualität.',
suggestedAnswerOptions: ['Ja, zwingend', 'Ja, wenn möglich', 'Nein'],
importance: 'optional',
})
// Suggested weights
const suggestedWeights: Record<string, number> = {
area: 0.20,
location: preferredLocations.length > 0 ? 0.28 : 0.22,
budget: budgetRange ? 0.22 : 0.18,
timing: timing ? 0.15 : 0.12,
prestige: prestigeImportance === 'HIGH' ? 0.10 : 0.05,
accessibility: mustHaveCriteria.includes('Gute ÖV-Anbindung') ? 0.08 : 0.04,
expansionPotential: 0.03,
flexibility: lower.includes('flexibel') ? 0.07 : 0.03,
}
const rawSummary = `Bedarf analysiert: ${assetType ?? 'Typ unbekannt'} · ${preferredLocations.join(', ') || 'Standort unklar'} · ${areaRange ? `${areaRange.min}${areaRange.max}` : 'Fläche unklar'} · ${budgetRange ? `max. CHF ${budgetRange.maxPerSqm}/m²` : 'Budget unklar'}`
return {
extractedCriteria: {
assetType,
areaRange,
preferredLocations,
budgetRange,
timing,
mustHaveCriteria,
infrastructureRequirements: [],
accessibilityRequirements: mustHaveCriteria.includes('Gute ÖV-Anbindung') ? ['ÖV-Anbindung'] : [],
prestigeImportance,
flexibilityNeed: lower.includes('flexibel') ? 'HIGH' : 'MEDIUM',
expansionPotential: lower.includes('wachstum') || lower.includes('expansion'),
parkingNeed,
visibilityNeed,
footfallNeed,
},
confidenceByField,
missingFields,
assumptions,
suggestedWeights,
followUpQuestionCandidates,
rawSummary,
promptVersion: 'mock-v1.0',
schemaVersion: '1.0.0',
}
}
// ── Legacy mock provider (kept for backward compat) ───────────────────────────
const MockupAIServiceProvider: AIServiceProvider = {
async extractCriteria(_input: string): Promise<CriteriaExtractionResult> {
// Deterministic mock extraction — simulates AI parsing
return {
extractedCriteria: {
companyName: 'Unbekannt (bitte bestätigen)',
@@ -51,7 +264,6 @@ const notConfiguredError = (): ServiceError => ({
message: 'OpenRouter nicht konfiguriert',
})
// Stub — swap for real OpenRouter implementation without changing call sites
export const openRouterAIService: AIServiceProvider = {
async extractCriteria(_input: string): Promise<CriteriaExtractionResult> {
throw notConfiguredError()
@@ -62,6 +274,7 @@ export const openRouterAIService: AIServiceProvider = {
}
export const aiService = {
// Legacy methods
async extractCriteria(input: string): Promise<ItemResponse<CriteriaExtractionResult>> {
const data = await provider.extractCriteria(input)
return { data }
@@ -70,4 +283,16 @@ export const aiService = {
const data = await provider.generateFollowUp(partialNeed)
return { data }
},
// F008 methods
async parseNeed(input: string): Promise<ItemResponse<ParseNeedResult>> {
await new Promise(r => setTimeout(r, 1400))
const data = mockParseNeed(input)
return { data }
},
async generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise<ItemResponse<FollowUpQuestion[]>> {
await new Promise(r => setTimeout(r, 600))
const result = mockParseNeed(JSON.stringify(criteria))
return { data: result.followUpQuestionCandidates }
},
}
+32
View File
@@ -0,0 +1,32 @@
import type { WeightingKey } from '../domain/needBuilder'
type WeightProfile = Record<WeightingKey, number>
const PROFILES: Record<string, WeightProfile> = {
OFFICE: {
area: 0.20, location: 0.25, budget: 0.20, timing: 0.15,
prestige: 0.10, accessibility: 0.05, expansionPotential: 0.03, flexibility: 0.02,
},
LOGISTICS: {
area: 0.30, location: 0.20, budget: 0.20, timing: 0.15,
prestige: 0.02, accessibility: 0.08, expansionPotential: 0.03, flexibility: 0.02,
},
RETAIL: {
area: 0.15, location: 0.30, budget: 0.20, timing: 0.10,
prestige: 0.12, accessibility: 0.08, expansionPotential: 0.03, flexibility: 0.02,
},
PRODUCTION: {
area: 0.30, location: 0.20, budget: 0.20, timing: 0.15,
prestige: 0.02, accessibility: 0.07, expansionPotential: 0.04, flexibility: 0.02,
},
DEFAULT: {
area: 0.25, location: 0.25, budget: 0.20, timing: 0.15,
prestige: 0.07, accessibility: 0.05, expansionPotential: 0.02, flexibility: 0.01,
},
}
export const weightingService = {
getDefaultWeights(assetType?: string): WeightProfile {
return { ...(PROFILES[assetType ?? 'DEFAULT'] ?? PROFILES.DEFAULT) }
},
}