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:
+227
-2
@@ -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 20–40/m²', 'CHF 40–80/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} m²` : '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 }
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user