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
@@ -0,0 +1,22 @@
import { Chip } from '@mui/material'
interface Props {
confidence: number
size?: 'small' | 'medium'
}
function confidenceColor(c: number): string {
if (c >= 0.8) return '#1a7a4a'
if (c >= 0.6) return '#d97706'
return '#c0392b'
}
export function ConfidenceFieldBadge({ confidence, size = 'small' }: Props) {
return (
<Chip
label={`${Math.round(confidence * 100)}%`}
size={size}
sx={{ bgcolor: confidenceColor(confidence), color: 'white', fontWeight: 700, fontSize: 11 }}
/>
)
}
@@ -0,0 +1,96 @@
import { Box, Card, Typography, Alert, Stack, Chip } from '@mui/material'
import type { ParseNeedResult } from '../../domain/needBuilder'
import { ExtractedFieldRow } from './ExtractedFieldRow'
interface Props {
result: ParseNeedResult
}
const ASSET_LABELS: Record<string, string> = {
OFFICE: 'Büro',
LOGISTICS: 'Logistik / Lager',
RETAIL: 'Retail',
PRODUCTION: 'Produktion',
GASTRO: 'Gastro / F&B',
}
export function CriteriaReviewPanel({ result }: Props) {
const { extractedCriteria: c, confidenceByField: conf, missingFields, assumptions } = result
return (
<Card sx={{ p: 3, height: '100%' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 0.5 }}>
Extrahierte Kriterien
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2 }}>
{result.rawSummary}
</Typography>
<ExtractedFieldRow
label="Nutzungstyp"
value={c.assetType ? (ASSET_LABELS[c.assetType] ?? c.assetType) : 'Nicht erkannt'}
confidence={conf.assetType ?? 0.2}
missing={!c.assetType}
/>
<ExtractedFieldRow
label="Flächenbedarf"
value={c.areaRange ? `${c.areaRange.min}${c.areaRange.max}` : 'Nicht erkannt'}
confidence={conf.areaRange ?? 0.2}
missing={!c.areaRange}
/>
<ExtractedFieldRow
label="Standort"
value={c.preferredLocations && c.preferredLocations.length > 0 ? c.preferredLocations.join(', ') : 'Nicht erkannt'}
confidence={conf.preferredLocations ?? 0.15}
missing={!c.preferredLocations || c.preferredLocations.length === 0}
/>
<ExtractedFieldRow
label="Budget"
value={c.budgetRange ? `max. CHF ${c.budgetRange.maxPerSqm}/m²` : 'Nicht erkannt'}
confidence={conf.budgetRange ?? 0.2}
missing={!c.budgetRange}
/>
<ExtractedFieldRow
label="Verfügbarkeit"
value={c.timing ? `ab ${c.timing.earliestMoveIn}${c.timing.flexibleTiming ? ' (flexibel)' : ''}` : 'Nicht erkannt'}
confidence={conf.timing ?? 0.2}
missing={!c.timing}
/>
{c.mustHaveCriteria && c.mustHaveCriteria.length > 0 && (
<ExtractedFieldRow
label="Must-haves"
value={c.mustHaveCriteria.join(', ')}
confidence={conf.mustHaveCriteria ?? 0.85}
/>
)}
{assumptions.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 1 }} color="text.secondary">
Annahmen der KI
</Typography>
<Stack spacing={0.5}>
{assumptions.map((a, i) => (
<Alert key={i} severity="warning" sx={{ py: 0, px: 1, '& .MuiAlert-message': { fontSize: 12 } }}>
{a}
</Alert>
))}
</Stack>
</Box>
)}
{missingFields.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 1 }} color="text.secondary">
Fehlende Angaben
</Typography>
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5 }}>
{missingFields.map(f => (
<Chip key={f} label={f} size="small" color="warning" variant="outlined" />
))}
</Stack>
</Box>
)}
</Card>
)
}
@@ -0,0 +1,35 @@
import { Box, Typography } from '@mui/material'
import { ConfidenceFieldBadge } from './ConfidenceFieldBadge'
interface Props {
label: string
value: string
confidence: number
missing?: boolean
}
export function ExtractedFieldRow({ label, value, confidence, missing = false }: Props) {
return (
<Box
sx={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 1,
py: 1,
borderBottom: '1px solid #f1f5f9',
opacity: missing ? 0.55 : 1,
}}
>
<Box sx={{ flex: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600, display: 'block' }}>
{label}
</Typography>
<Typography variant="body2" sx={{ fontStyle: missing ? 'italic' : 'normal' }}>
{value}
</Typography>
</Box>
<ConfidenceFieldBadge confidence={confidence} />
</Box>
)
}
+65
View File
@@ -0,0 +1,65 @@
import { Box, Button, Card, Typography } from '@mui/material'
import { ArrowRight } from 'lucide-react'
import type { FollowUpQuestion } from '../../domain/needBuilder'
import { FollowUpQuestionCard } from './FollowUpQuestionCard'
interface Props {
questions: FollowUpQuestion[]
answers: Record<string, string>
onAnswer: (id: string, answer: string) => void
onContinue: () => void
}
export function FollowUpPanel({ questions, answers, onAnswer, onContinue }: Props) {
const requiredUnanswered = questions
.filter(q => q.importance === 'required')
.filter(q => !answers[q.id])
const sorted = [
...questions.filter(q => q.importance === 'required'),
...questions.filter(q => q.importance === 'recommended'),
...questions.filter(q => q.importance === 'optional'),
]
return (
<Card sx={{ p: 3, height: '100%', display: 'flex', flexDirection: 'column' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 0.5 }}>
Rückfragen der KI
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2 }}>
Beantworten Sie die Pflichtfelder für optimale Ergebnisse.
</Typography>
<Box sx={{ flex: 1, overflow: 'auto' }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{sorted.map(q => (
<FollowUpQuestionCard
key={q.id}
question={q}
answer={answers[q.id] ?? ''}
onAnswer={ans => onAnswer(q.id, ans)}
/>
))}
</Box>
</Box>
<Box sx={{ pt: 2, mt: 'auto' }}>
{requiredUnanswered.length > 0 && (
<Typography variant="caption" color="error" sx={{ display: 'block', mb: 1 }}>
{requiredUnanswered.length} Pflichtfeld{requiredUnanswered.length > 1 ? 'er' : ''} fehlt noch.
</Typography>
)}
<Button
variant="contained"
fullWidth
disabled={requiredUnanswered.length > 0}
onClick={onContinue}
endIcon={<ArrowRight size={16} />}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
>
Weiter zur Gewichtung
</Button>
</Box>
</Card>
)
}
@@ -0,0 +1,66 @@
import { Box, Chip, Stack, TextField, Typography } from '@mui/material'
import type { FollowUpQuestion } from '../../domain/needBuilder'
interface Props {
question: FollowUpQuestion
answer: string
onAnswer: (answer: string) => void
}
const IMPORTANCE_LABEL: Record<FollowUpQuestion['importance'], string> = {
required: 'Pflichtfeld',
recommended: 'Empfohlen',
optional: 'Optional',
}
const IMPORTANCE_COLOR: Record<FollowUpQuestion['importance'], 'error' | 'warning' | 'default'> = {
required: 'error',
recommended: 'warning',
optional: 'default',
}
export function FollowUpQuestionCard({ question, answer, onAnswer }: Props) {
return (
<Box sx={{ pb: 2, borderBottom: '1px solid #f1f5f9' }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, mb: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 500, flex: 1 }}>
{question.questionText}
</Typography>
<Chip
label={IMPORTANCE_LABEL[question.importance]}
size="small"
color={IMPORTANCE_COLOR[question.importance]}
variant="outlined"
sx={{ flexShrink: 0 }}
/>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
{question.reason}
</Typography>
{question.suggestedAnswerOptions && question.suggestedAnswerOptions.length > 0 ? (
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5 }}>
{question.suggestedAnswerOptions.map(opt => (
<Chip
key={opt}
label={opt}
size="small"
clickable
variant={answer === opt ? 'filled' : 'outlined'}
color={answer === opt ? 'primary' : 'default'}
onClick={() => onAnswer(answer === opt ? '' : opt)}
/>
))}
</Stack>
) : (
<TextField
size="small"
fullWidth
placeholder="Ihre Antwort (optional)"
value={answer}
onChange={e => onAnswer(e.target.value)}
/>
)}
</Box>
)
}
@@ -0,0 +1,31 @@
import { Box, Button, Typography } from '@mui/material'
import { AlertTriangle, RotateCcw } from 'lucide-react'
interface Props {
message: string
onRetry: () => void
}
export function NeedBuilderErrorState({ message, onRetry }: Props) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 12, gap: 3, maxWidth: 480, mx: 'auto' }}>
<AlertTriangle size={48} color="#c0392b" />
<Box sx={{ textAlign: 'center' }}>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 1 }}>
Analyse fehlgeschlagen
</Typography>
<Typography variant="body2" color="text.secondary">
{message}
</Typography>
</Box>
<Button
variant="contained"
startIcon={<RotateCcw size={16} />}
onClick={onRetry}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
>
Erneut versuchen
</Button>
</Box>
)
}
@@ -0,0 +1,31 @@
import { Box, Stepper, Step, StepLabel } from '@mui/material'
import type { NeedBuilderStep } from '../../domain/needBuilder'
import { NeedBuilderStep as S } from '../../domain/needBuilder'
interface Props {
step: NeedBuilderStep
}
const STEPS = ['Bedarf eingeben', 'Kriterien prüfen', 'Gewichtung', 'Speichern']
function toStepIndex(step: NeedBuilderStep): number {
if (step === S.IDLE || step === S.PARSING) return 0
if (step === S.PARSED_REQUIRES_REVIEW || step === S.CLARIFICATION_REQUIRED) return 1
if (step === S.WEIGHTING_REVIEW) return 2
return 3
}
export function NeedBuilderProgress({ step }: Props) {
if (step === S.IDLE) return null
return (
<Box sx={{ px: 3, py: 2, borderBottom: '1px solid #e2e8f0', bgcolor: '#f8fafc' }}>
<Stepper activeStep={toStepIndex(step)} alternativeLabel>
{STEPS.map(label => (
<Step key={label}>
<StepLabel>{label}</StepLabel>
</Step>
))}
</Stepper>
</Box>
)
}
+116
View File
@@ -0,0 +1,116 @@
import { Box, Card, Chip, Divider, LinearProgress, Stack, Typography } from '@mui/material'
import { MapPin, Ruler, Wallet, Clock, CheckSquare } from 'lucide-react'
import type { ParsedNeedCriteria } from '../../domain/needBuilder'
import { WEIGHTING_KEYS, WEIGHTING_LABELS } from '../../domain/needBuilder'
import type { WeightingKey } from '../../domain/needBuilder'
interface Props {
criteria: ParsedNeedCriteria
weights: Record<WeightingKey, number>
}
const ASSET_LABELS: Record<string, string> = {
OFFICE: 'Büro',
LOGISTICS: 'Logistik / Lager',
RETAIL: 'Retail',
PRODUCTION: 'Produktion',
GASTRO: 'Gastro / F&B',
}
export function NeedCardPreview({ criteria: c, weights }: Props) {
const maxWeight = Math.max(...WEIGHTING_KEYS.map(k => weights[k] ?? 0), 0.01)
return (
<Box sx={{ maxWidth: 720, mx: 'auto' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
Vorschau Neuer Bedarf
</Typography>
<Card sx={{ p: 3, mb: 2 }}>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
{c.assetType && (
<Chip label={ASSET_LABELS[c.assetType] ?? c.assetType} size="small" sx={{ bgcolor: '#1e3a5f', color: 'white' }} />
)}
<Chip label="ENTWURF" size="small" variant="outlined" />
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2, mb: 2 }}>
{c.preferredLocations && c.preferredLocations.length > 0 && (
<Box sx={{ display: 'flex', gap: 1 }}>
<MapPin size={16} color="#64748b" style={{ flexShrink: 0, marginTop: 2 }} />
<Box>
<Typography variant="caption" color="text.secondary">Standort</Typography>
<Typography variant="body2">{c.preferredLocations.join(', ')}</Typography>
</Box>
</Box>
)}
{c.areaRange && (
<Box sx={{ display: 'flex', gap: 1 }}>
<Ruler size={16} color="#64748b" style={{ flexShrink: 0, marginTop: 2 }} />
<Box>
<Typography variant="caption" color="text.secondary">Fläche</Typography>
<Typography variant="body2">{c.areaRange.min}{c.areaRange.max} m²</Typography>
</Box>
</Box>
)}
{c.budgetRange && (
<Box sx={{ display: 'flex', gap: 1 }}>
<Wallet size={16} color="#64748b" style={{ flexShrink: 0, marginTop: 2 }} />
<Box>
<Typography variant="caption" color="text.secondary">Budget</Typography>
<Typography variant="body2">max. CHF {c.budgetRange.maxPerSqm}/m²</Typography>
</Box>
</Box>
)}
{c.timing && (
<Box sx={{ display: 'flex', gap: 1 }}>
<Clock size={16} color="#64748b" style={{ flexShrink: 0, marginTop: 2 }} />
<Box>
<Typography variant="caption" color="text.secondary">Verfügbarkeit</Typography>
<Typography variant="body2">ab {c.timing.earliestMoveIn}</Typography>
</Box>
</Box>
)}
</Box>
{c.mustHaveCriteria && c.mustHaveCriteria.length > 0 && (
<Box sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', gap: 1, mb: 0.5 }}>
<CheckSquare size={16} color="#64748b" />
<Typography variant="caption" color="text.secondary">Must-haves</Typography>
</Box>
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5 }}>
{c.mustHaveCriteria.map(m => (
<Chip key={m} label={m} size="small" variant="outlined" />
))}
</Stack>
</Box>
)}
<Divider sx={{ my: 2 }} />
{/* Weights */}
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 1 }} color="text.secondary">
Gewichtungsprofil
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
{WEIGHTING_KEYS.map(k => {
const pct = Math.round((weights[k] ?? 0) * 100)
return (
<Box key={k} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="caption" sx={{ width: 120, flexShrink: 0 }}>{WEIGHTING_LABELS[k]}</Typography>
<LinearProgress
variant="determinate"
value={((weights[k] ?? 0) / maxWeight) * 100}
sx={{ flex: 1, height: 6, borderRadius: 3, bgcolor: '#e2e8f0', '& .MuiLinearProgress-bar': { bgcolor: '#1e3a5f' } }}
/>
<Typography variant="caption" sx={{ width: 32, textAlign: 'right' }}>{pct}%</Typography>
</Box>
)
})}
</Box>
</Card>
</Box>
)
}
+66
View File
@@ -0,0 +1,66 @@
import { Box, Button, Card, Chip, TextField, Typography, Stack } from '@mui/material'
import { Sparkles } from 'lucide-react'
interface Props {
value: string
onChange: (v: string) => void
onSubmit: () => void
}
const EXAMPLES = [
'Büro Zürich 500800 m², ab September 2025, max. 45 CHF/m², gute ÖV-Anbindung',
'Logistik Basel 2000 m², Tiefgarage, sofort verfügbar',
'Retail Bern Innenstadt, hohe Passantenfrequenz, max. CHF 200/m²',
]
export function NeedInput({ value, onChange, onSubmit }: Props) {
return (
<Box sx={{ maxWidth: 720, mx: 'auto' }}>
<Card sx={{ p: 3, mb: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
Flächenbedarf beschreiben
</Typography>
<TextField
multiline
rows={5}
fullWidth
placeholder="Beispiel: Wir suchen 8001.000 m² Bürofläche in Zürich-West, verfügbar ab September 2025, Budget max. 45 CHF/m². Wichtig: gute ÖV-Anbindung, moderne Infrastruktur."
value={value}
onChange={e => onChange(e.target.value)}
slotProps={{ htmlInput: { maxLength: 2000 } }}
sx={{ mb: 1 }}
/>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', textAlign: 'right', mb: 2 }}>
{value.length}/2000
</Typography>
<Button
variant="contained"
fullWidth
disabled={value.length < 20}
onClick={onSubmit}
endIcon={<Sparkles size={16} />}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
>
KI-Analyse starten
</Button>
</Card>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
Beispiele:
</Typography>
<Stack direction="row" spacing={1} sx={{ flexWrap: 'wrap', gap: 1 }}>
{EXAMPLES.map(q => (
<Chip
key={q}
label={q}
size="small"
variant="outlined"
clickable
onClick={() => onChange(q)}
sx={{ maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis' }}
/>
))}
</Stack>
</Box>
)
}
+98
View File
@@ -0,0 +1,98 @@
import { Box, Button, Card, Slider, Typography } from '@mui/material'
import { RotateCcw } from 'lucide-react'
import { WEIGHTING_KEYS, WEIGHTING_LABELS } from '../../domain/needBuilder'
import type { WeightingKey } from '../../domain/needBuilder'
import { weightingService } from '../../services/weightingService'
interface Props {
weights: Record<WeightingKey, number>
onChange: (weights: Record<WeightingKey, number>) => void
assetType?: string
}
export function WeightingEditor({ weights, onChange, assetType }: Props) {
const total = WEIGHTING_KEYS.reduce((sum, k) => sum + (weights[k] ?? 0), 0)
const totalPct = Math.round(total * 100)
const isBalanced = totalPct >= 95 && totalPct <= 105
function handleSlider(key: WeightingKey, pct: number) {
onChange({ ...weights, [key]: pct / 100 })
}
function handleReset() {
onChange(weightingService.getDefaultWeights(assetType))
}
return (
<Box sx={{ maxWidth: 720, mx: 'auto' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Kriteriengewichtung anpassen
</Typography>
<Typography variant="caption" color="text.secondary">
Passen Sie an, wie stark jedes Kriterium das Matching beeinflusst.
</Typography>
</Box>
<Button
size="small"
variant="outlined"
startIcon={<RotateCcw size={14} />}
onClick={handleReset}
>
Zurücksetzen
</Button>
</Box>
<Card sx={{ p: 3 }}>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 3 }}>
{WEIGHTING_KEYS.map(key => {
const pct = Math.round((weights[key] ?? 0) * 100)
return (
<Box key={key}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{WEIGHTING_LABELS[key]}
</Typography>
<Typography variant="body2" color="text.secondary">
{pct}%
</Typography>
</Box>
<Slider
value={pct}
min={0}
max={40}
step={1}
onChange={(_, v) => handleSlider(key, v as number)}
size="small"
sx={{ color: '#1e3a5f' }}
/>
</Box>
)
})}
</Box>
<Box
sx={{
mt: 3,
pt: 2,
borderTop: '1px solid #e2e8f0',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Typography variant="body2" color="text.secondary">
Gesamt
</Typography>
<Typography
variant="body2"
sx={{ fontWeight: 700, color: isBalanced ? '#1a7a4a' : '#c0392b' }}
>
{totalPct}%{!isBalanced && ' — Summe sollte ~100% ergeben'}
</Typography>
</Box>
</Card>
</Box>
)
}
+10
View File
@@ -0,0 +1,10 @@
export { ConfidenceFieldBadge } from './ConfidenceFieldBadge'
export { CriteriaReviewPanel } from './CriteriaReviewPanel'
export { ExtractedFieldRow } from './ExtractedFieldRow'
export { FollowUpPanel } from './FollowUpPanel'
export { FollowUpQuestionCard } from './FollowUpQuestionCard'
export { NeedBuilderErrorState } from './NeedBuilderErrorState'
export { NeedBuilderProgress } from './NeedBuilderProgress'
export { NeedCardPreview } from './NeedCardPreview'
export { NeedInput } from './NeedInput'
export { WeightingEditor } from './WeightingEditor'
+69
View File
@@ -0,0 +1,69 @@
import type { AssetType } from './enums'
export interface ParsedNeedCriteria {
assetType?: AssetType
areaRange?: { min: number; max: number }
preferredLocations?: string[]
budgetRange?: { maxPerSqm: number; maxMonthlyTotal?: number; currency: string }
timing?: { earliestMoveIn: string; latestMoveIn?: string; contractDurationMonths?: number; flexibleTiming: boolean }
mustHaveCriteria?: string[]
softFactors?: { minPrestige?: number; requireParking?: boolean; maxPublicTransportMinutes?: number; requireHighVisibility?: boolean }
infrastructureRequirements?: string[]
accessibilityRequirements?: string[]
prestigeImportance?: 'LOW' | 'MEDIUM' | 'HIGH'
flexibilityNeed?: 'LOW' | 'MEDIUM' | 'HIGH'
expansionPotential?: boolean
parkingNeed?: boolean
visibilityNeed?: 'LOW' | 'MEDIUM' | 'HIGH'
footfallNeed?: 'LOW' | 'MEDIUM' | 'HIGH'
companyName?: string
notes?: string
}
export interface FollowUpQuestion {
id: string
questionText: string
targetField: string
reason: string
suggestedAnswerOptions?: string[]
importance: 'required' | 'recommended' | 'optional'
}
export interface ParseNeedResult {
extractedCriteria: ParsedNeedCriteria
confidenceByField: Record<string, number>
missingFields: string[]
assumptions: string[]
suggestedWeights: Record<string, number>
followUpQuestionCandidates: FollowUpQuestion[]
rawSummary: string
promptVersion: string
schemaVersion: string
}
export const NeedBuilderStep = {
IDLE: 'idle',
PARSING: 'parsing',
PARSED_REQUIRES_REVIEW: 'parsed_requires_review',
CLARIFICATION_REQUIRED: 'clarification_required',
WEIGHTING_REVIEW: 'weighting_review',
READY_TO_SAVE: 'ready_to_save',
SAVING: 'saving',
SAVED: 'saved',
ERROR: 'error',
} as const
export type NeedBuilderStep = typeof NeedBuilderStep[keyof typeof NeedBuilderStep]
export const WEIGHTING_KEYS = ['area', 'location', 'budget', 'timing', 'prestige', 'accessibility', 'expansionPotential', 'flexibility'] as const
export type WeightingKey = typeof WEIGHTING_KEYS[number]
export const WEIGHTING_LABELS: Record<WeightingKey, string> = {
area: 'Fläche',
location: 'Standort',
budget: 'Budget',
timing: 'Verfügbarkeit',
prestige: 'Prestige',
accessibility: 'Erreichbarkeit',
expansionPotential: 'Expansionspotenzial',
flexibility: 'Flexibilität',
}
+152 -294
View File
@@ -1,332 +1,190 @@
import { useState } from 'react' import { useState } from 'react'
import { import { Box, Button, CircularProgress, Typography } from '@mui/material'
Box, import { ArrowRight, ArrowLeft, Save } from 'lucide-react'
Button,
Card,
Chip,
TextField,
Typography,
CircularProgress,
Alert,
Stack,
Divider,
} from '@mui/material'
import { Sparkles, ArrowRight } from 'lucide-react'
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
import { aiService, type CriteriaExtractionResult } from '../../services/aiService' import { PageHeader } from '../../components/layout'
import {
type Step = 'input' | 'extracting' | 'review' | 'done' NeedBuilderProgress,
NeedInput,
const EXAMPLE_QUERIES = [ CriteriaReviewPanel,
'Büro Zürich 500-800m²', FollowUpPanel,
'Logistik Basel 2000m²', WeightingEditor,
'Retail Bern Innenstadt', NeedCardPreview,
] NeedBuilderErrorState,
} from '../../components/demand'
function ConfidenceColor(score: number): string { import { aiService } from '../../services/aiService'
if (score >= 0.8) return '#1a7a4a' import { weightingService } from '../../services/weightingService'
if (score >= 0.6) return '#d97706' import { NeedBuilderStep } from '../../domain/needBuilder'
return '#c0392b' import type { ParseNeedResult } from '../../domain/needBuilder'
} import type { WeightingKey } from '../../domain/needBuilder'
export default function AISearch() { export default function AISearch() {
const navigate = useNavigate() const navigate = useNavigate()
const [step, setStep] = useState<Step>('input') const [step, setStep] = useState<typeof NeedBuilderStep[keyof typeof NeedBuilderStep]>(NeedBuilderStep.IDLE)
const [inputText, setInputText] = useState('') const [inputText, setInputText] = useState('')
const [extractedCriteria, setExtractedCriteria] = useState<CriteriaExtractionResult | null>(null) const [parseResult, setParseResult] = useState<ParseNeedResult | null>(null)
const [followUpAnswers, setFollowUpAnswers] = useState<Record<number, string>>({}) const [answers, setAnswers] = useState<Record<string, string>>({})
const [weights, setWeights] = useState<Record<WeightingKey, number>>(weightingService.getDefaultWeights())
const [error, setError] = useState<string | null>(null)
const handleAnalyze = async () => { async function handleAnalyze() {
setStep('extracting') setStep(NeedBuilderStep.PARSING)
await new Promise(r => setTimeout(r, 1500)) setError(null)
const resp = await aiService.extractCriteria(inputText) try {
const result = resp.data const resp = await aiService.parseNeed(inputText)
setExtractedCriteria(result) const result = resp.data
setStep('review') setParseResult(result)
setWeights(result.suggestedWeights as Record<WeightingKey, number>)
setStep(NeedBuilderStep.PARSED_REQUIRES_REVIEW)
} catch {
setError('Die KI-Analyse ist fehlgeschlagen. Bitte versuchen Sie es erneut.')
setStep(NeedBuilderStep.ERROR)
}
} }
const handleStartSearch = () => { function handleAnswer(id: string, ans: string) {
navigate('/demand/results', { state: { needId: 'need-001' } }) setAnswers(prev => ({ ...prev, [id]: ans }))
} }
function handleContinueToWeighting() {
setStep(NeedBuilderStep.WEIGHTING_REVIEW)
}
function handleContinueToSave() {
setStep(NeedBuilderStep.READY_TO_SAVE)
}
async function handleSave() {
setStep(NeedBuilderStep.SAVING)
await new Promise(r => setTimeout(r, 800))
navigate('/demand/results', { state: { fromNeedBuilder: true } })
}
function handleRetry() {
setStep(NeedBuilderStep.IDLE)
setError(null)
setParseResult(null)
}
const isReview = step === NeedBuilderStep.PARSED_REQUIRES_REVIEW || step === NeedBuilderStep.CLARIFICATION_REQUIRED
const isSaveStep = step === NeedBuilderStep.READY_TO_SAVE || step === NeedBuilderStep.SAVING
return ( return (
<Box> <Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Page Header */} <PageHeader
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2 }}> title="AI Bedarfsanalyse"
<Typography variant="h5" sx={{ fontWeight: 700 }} color="text.primary"> subtitle="Beschreiben Sie Ihren Flächenbedarf in natürlicher Sprache"
AI Bedarfsanalyse />
</Typography> <NeedBuilderProgress step={step} />
<Typography variant="body2" color="text.secondary">
Beschreiben Sie Ihren Flächenbedarf in natürlicher Sprache
</Typography>
</Box>
<Box sx={{ px: 3, py: 3 }}> <Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3 }}>
{/* Step 1: Input */} {/* Step: Input */}
{step === 'input' && ( {step === NeedBuilderStep.IDLE && (
<Box> <NeedInput value={inputText} onChange={setInputText} onSubmit={handleAnalyze} />
<Card sx={{ maxWidth: 680, mx: 'auto', p: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
Flächenbedarf beschreiben
</Typography>
<TextField
multiline
rows={5}
fullWidth
placeholder="Beispiel: Wir suchen 8001.000 m² Bürofläche in Zürich-West, verfügbar ab September 2025, Budget max. 45 CHF/m². Wichtig: gute ÖV-Anbindung, moderne Infrastruktur."
value={inputText}
onChange={e => setInputText(e.target.value)}
slotProps={{ htmlInput: { maxLength: 2000 } }}
sx={{ mb: 1 }}
/>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', textAlign: 'right', mb: 2 }}>
{inputText.length}/2000
</Typography>
<Button
variant="contained"
fullWidth
disabled={inputText.length < 20}
onClick={handleAnalyze}
endIcon={<Sparkles size={16} />}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' }, mb: 2 }}
>
Analysieren
</Button>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', textAlign: 'center' }}>
Die KI extrahiert automatisch Kriterien, Standortpräferenzen und Budget aus Ihrer Beschreibung.
</Typography>
</Card>
{/* Example Queries */}
<Box sx={{ maxWidth: 680, mx: 'auto', mt: 2 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
Beispiele:
</Typography>
<Stack direction="row" spacing={1} sx={{ flexWrap: 'wrap', gap: 1 }}>
{EXAMPLE_QUERIES.map(q => (
<Chip
key={q}
label={q}
size="small"
variant="outlined"
clickable
onClick={() => setInputText(q)}
sx={{ cursor: 'pointer' }}
/>
))}
</Stack>
</Box>
</Box>
)} )}
{/* Step 2: Extracting */} {/* Step: Parsing */}
{step === 'extracting' && ( {step === NeedBuilderStep.PARSING && (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', py: 12, gap: 3 }}> <Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', py: 16, gap: 3 }}>
<CircularProgress size={48} sx={{ color: '#1e3a5f' }} /> <CircularProgress size={48} sx={{ color: '#1e3a5f' }} />
<Typography variant="h6" color="text.secondary"> <Typography variant="h6" color="text.secondary">
KI analysiert Ihren Bedarf... KI analysiert Ihren Bedarf
</Typography>
<Typography variant="caption" color="text.secondary">
Kriterien werden extrahiert und bewertet
</Typography> </Typography>
</Box> </Box>
)} )}
{/* Step 3: Review */} {/* Step: Criteria Review + Follow-up */}
{step === 'review' && extractedCriteria && ( {isReview && parseResult && (
<Box> <Box>
<Box className="grid grid-cols-2 gap-4" sx={{ mb: 3 }}> <Box className="grid grid-cols-2 gap-4" sx={{ mb: 2 }}>
{/* Left: Extracted Criteria */} <CriteriaReviewPanel result={parseResult} />
<Card sx={{ p: 3 }}> <FollowUpPanel
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}> questions={parseResult.followUpQuestionCandidates}
Extrahierte Kriterien answers={answers}
</Typography> onAnswer={handleAnswer}
onContinue={handleContinueToWeighting}
<Stack spacing={2}> />
{/* Confidence badge */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Typography variant="caption" color="text.secondary">Gesamtkonfidenz:</Typography>
<Chip
label={`${Math.round(extractedCriteria.confidence * 100)}%`}
size="small"
sx={{
bgcolor: ConfidenceColor(extractedCriteria.confidence),
color: 'white',
fontWeight: 700,
}}
/>
</Box>
<Divider />
{/* Criteria items */}
{extractedCriteria.extractedCriteria.requiredArea && (
<Box>
<Typography
variant="caption"
sx={{ fontWeight: 600, color: ConfidenceColor(extractedCriteria.confidence) }}
>
Flächenbedarf
</Typography>
<Typography variant="body2">
{extractedCriteria.extractedCriteria.requiredArea.min}
{extractedCriteria.extractedCriteria.requiredArea.max} m²
</Typography>
</Box>
)}
{extractedCriteria.extractedCriteria.budgetRange && (
<Box>
<Typography
variant="caption"
sx={{ fontWeight: 600, color: ConfidenceColor(extractedCriteria.confidence) }}
>
Budget
</Typography>
<Typography variant="body2">
max. {extractedCriteria.extractedCriteria.budgetRange.maxPerSqm}{' '}
{extractedCriteria.extractedCriteria.budgetRange.currency}/m²
</Typography>
</Box>
)}
{extractedCriteria.extractedCriteria.companyName && (
<Box>
<Typography
variant="caption"
sx={{ fontWeight: 600, color: ConfidenceColor(0.5) }}
>
Unternehmen
</Typography>
<Typography variant="body2">
{extractedCriteria.extractedCriteria.companyName}
</Typography>
</Box>
)}
{extractedCriteria.extractedCriteria.preferredLocations && extractedCriteria.extractedCriteria.preferredLocations.length > 0 && (
<Box>
<Typography
variant="caption"
sx={{ fontWeight: 600, color: ConfidenceColor(0.85) }}
>
Standort
</Typography>
<Typography variant="body2">
{extractedCriteria.extractedCriteria.preferredLocations.join(', ')}
</Typography>
</Box>
)}
{extractedCriteria.extractedCriteria.timing && (
<Box>
<Typography
variant="caption"
sx={{ fontWeight: 600, color: ConfidenceColor(0.85) }}
>
Verfügbarkeit
</Typography>
<Typography variant="body2">
ab {extractedCriteria.extractedCriteria.timing.earliestMoveIn}
</Typography>
</Box>
)}
{extractedCriteria.extractedCriteria.assetType && (
<Box>
<Typography
variant="caption"
sx={{ fontWeight: 600, color: ConfidenceColor(0.85) }}
>
Objekttyp
</Typography>
<Typography variant="body2">
{extractedCriteria.extractedCriteria.assetType}
</Typography>
</Box>
)}
</Stack>
{/* Assumptions */}
{extractedCriteria.assumptions.length > 0 && (
<Box sx={{ mt: 3 }}>
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 1 }} color="text.secondary">
Annahmen der KI
</Typography>
<Stack spacing={0.5}>
{extractedCriteria.assumptions.map((a, i) => (
<Alert key={i} severity="warning" sx={{ py: 0, px: 1, '& .MuiAlert-message': { fontSize: 12 } }}>
{a}
</Alert>
))}
</Stack>
</Box>
)}
{/* Missing Fields */}
{extractedCriteria.missingFields.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 1 }} color="text.secondary">
Fehlende Informationen
</Typography>
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5 }}>
{extractedCriteria.missingFields.map(f => (
<Chip
key={f}
label={f}
size="small"
color="warning"
variant="outlined"
/>
))}
</Stack>
</Box>
)}
</Card>
{/* Right: Follow-up Questions */}
<Card sx={{ p: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 0.5 }}>
Rückfragen der KI
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2 }}>
Diese Fragen sind optional verbessern jedoch die Trefferqualität.
</Typography>
<Stack spacing={3}>
{extractedCriteria.followUpQuestions.map((q, i) => (
<Box key={i}>
<Typography variant="body2" sx={{ fontWeight: 500, mb: 1 }}>
{i + 1}. {q}
</Typography>
<TextField
size="small"
fullWidth
placeholder="Ihre Antwort (optional)"
value={followUpAnswers[i] ?? ''}
onChange={e =>
setFollowUpAnswers(prev => ({ ...prev, [i]: e.target.value }))
}
/>
</Box>
))}
</Stack>
</Card>
</Box> </Box>
<Box sx={{ display: 'flex', justifyContent: 'flex-start' }}>
<Button
variant="outlined"
startIcon={<ArrowLeft size={16} />}
onClick={() => setStep(NeedBuilderStep.IDLE)}
>
Neu eingeben
</Button>
</Box>
</Box>
)}
{/* Footer Actions */} {/* Step: Weighting */}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2 }}> {step === NeedBuilderStep.WEIGHTING_REVIEW && (
<Button variant="outlined" onClick={() => setStep('input')}> <Box>
<WeightingEditor
weights={weights}
onChange={setWeights}
assetType={parseResult?.extractedCriteria.assetType}
/>
<Box sx={{ maxWidth: 720, mx: 'auto', display: 'flex', justifyContent: 'space-between', mt: 3 }}>
<Button
variant="outlined"
startIcon={<ArrowLeft size={16} />}
onClick={() => setStep(NeedBuilderStep.PARSED_REQUIRES_REVIEW)}
>
Zurück Zurück
</Button> </Button>
<Button <Button
variant="contained" variant="contained"
onClick={handleStartSearch}
endIcon={<ArrowRight size={16} />} endIcon={<ArrowRight size={16} />}
onClick={handleContinueToSave}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }} sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
> >
Suche starten Vorschau & Speichern
</Button> </Button>
</Box> </Box>
</Box> </Box>
)} )}
{/* Step: Preview + Save */}
{isSaveStep && parseResult && (
<Box>
<NeedCardPreview criteria={parseResult.extractedCriteria} weights={weights} />
<Box sx={{ maxWidth: 720, mx: 'auto', display: 'flex', justifyContent: 'space-between', mt: 3 }}>
<Button
variant="outlined"
startIcon={<ArrowLeft size={16} />}
onClick={() => setStep(NeedBuilderStep.WEIGHTING_REVIEW)}
disabled={step === NeedBuilderStep.SAVING}
>
Zurück
</Button>
<Button
variant="contained"
onClick={handleSave}
disabled={step === NeedBuilderStep.SAVING}
endIcon={
step === NeedBuilderStep.SAVING
? <CircularProgress size={16} color="inherit" />
: <Save size={16} />
}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
>
{step === NeedBuilderStep.SAVING ? 'Wird gespeichert…' : 'Bedarf speichern & Suche starten'}
</Button>
</Box>
</Box>
)}
{/* Step: Error */}
{step === NeedBuilderStep.ERROR && (
<NeedBuilderErrorState
message={error ?? 'Unbekannter Fehler'}
onRetry={handleRetry}
/>
)}
</Box> </Box>
</Box> </Box>
) )
+227 -2
View File
@@ -1,6 +1,10 @@
import type { ItemResponse, ServiceError } from './types' import type { ItemResponse, ServiceError } from './types'
import { ServiceErrorCode } from './types' import { ServiceErrorCode } from './types'
import type { CreateNeedInput } from '../domain/need' 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 { export interface CriteriaExtractionResult {
extractedCriteria: Partial<CreateNeedInput> extractedCriteria: Partial<CreateNeedInput>
@@ -15,9 +19,218 @@ export interface AIServiceProvider {
generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<string[]> 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 = { const MockupAIServiceProvider: AIServiceProvider = {
async extractCriteria(_input: string): Promise<CriteriaExtractionResult> { async extractCriteria(_input: string): Promise<CriteriaExtractionResult> {
// Deterministic mock extraction — simulates AI parsing
return { return {
extractedCriteria: { extractedCriteria: {
companyName: 'Unbekannt (bitte bestätigen)', companyName: 'Unbekannt (bitte bestätigen)',
@@ -51,7 +264,6 @@ const notConfiguredError = (): ServiceError => ({
message: 'OpenRouter nicht konfiguriert', message: 'OpenRouter nicht konfiguriert',
}) })
// Stub — swap for real OpenRouter implementation without changing call sites
export const openRouterAIService: AIServiceProvider = { export const openRouterAIService: AIServiceProvider = {
async extractCriteria(_input: string): Promise<CriteriaExtractionResult> { async extractCriteria(_input: string): Promise<CriteriaExtractionResult> {
throw notConfiguredError() throw notConfiguredError()
@@ -62,6 +274,7 @@ export const openRouterAIService: AIServiceProvider = {
} }
export const aiService = { export const aiService = {
// Legacy methods
async extractCriteria(input: string): Promise<ItemResponse<CriteriaExtractionResult>> { async extractCriteria(input: string): Promise<ItemResponse<CriteriaExtractionResult>> {
const data = await provider.extractCriteria(input) const data = await provider.extractCriteria(input)
return { data } return { data }
@@ -70,4 +283,16 @@ export const aiService = {
const data = await provider.generateFollowUp(partialNeed) const data = await provider.generateFollowUp(partialNeed)
return { data } 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) }
},
}