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
+152 -294
View File
@@ -1,332 +1,190 @@
import { useState } from 'react'
import {
Box,
Button,
Card,
Chip,
TextField,
Typography,
CircularProgress,
Alert,
Stack,
Divider,
} from '@mui/material'
import { Sparkles, ArrowRight } from 'lucide-react'
import { Box, Button, CircularProgress, Typography } from '@mui/material'
import { ArrowRight, ArrowLeft, Save } from 'lucide-react'
import { useNavigate } from 'react-router'
import { aiService, type CriteriaExtractionResult } from '../../services/aiService'
type Step = 'input' | 'extracting' | 'review' | 'done'
const EXAMPLE_QUERIES = [
'Büro Zürich 500-800m²',
'Logistik Basel 2000m²',
'Retail Bern Innenstadt',
]
function ConfidenceColor(score: number): string {
if (score >= 0.8) return '#1a7a4a'
if (score >= 0.6) return '#d97706'
return '#c0392b'
}
import { PageHeader } from '../../components/layout'
import {
NeedBuilderProgress,
NeedInput,
CriteriaReviewPanel,
FollowUpPanel,
WeightingEditor,
NeedCardPreview,
NeedBuilderErrorState,
} from '../../components/demand'
import { aiService } from '../../services/aiService'
import { weightingService } from '../../services/weightingService'
import { NeedBuilderStep } from '../../domain/needBuilder'
import type { ParseNeedResult } from '../../domain/needBuilder'
import type { WeightingKey } from '../../domain/needBuilder'
export default function AISearch() {
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 [extractedCriteria, setExtractedCriteria] = useState<CriteriaExtractionResult | null>(null)
const [followUpAnswers, setFollowUpAnswers] = useState<Record<number, string>>({})
const [parseResult, setParseResult] = useState<ParseNeedResult | null>(null)
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 () => {
setStep('extracting')
await new Promise(r => setTimeout(r, 1500))
const resp = await aiService.extractCriteria(inputText)
const result = resp.data
setExtractedCriteria(result)
setStep('review')
async function handleAnalyze() {
setStep(NeedBuilderStep.PARSING)
setError(null)
try {
const resp = await aiService.parseNeed(inputText)
const result = resp.data
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 = () => {
navigate('/demand/results', { state: { needId: 'need-001' } })
function handleAnswer(id: string, ans: string) {
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 (
<Box>
{/* Page Header */}
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2 }}>
<Typography variant="h5" sx={{ fontWeight: 700 }} color="text.primary">
AI Bedarfsanalyse
</Typography>
<Typography variant="body2" color="text.secondary">
Beschreiben Sie Ihren Flächenbedarf in natürlicher Sprache
</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<PageHeader
title="AI Bedarfsanalyse"
subtitle="Beschreiben Sie Ihren Flächenbedarf in natürlicher Sprache"
/>
<NeedBuilderProgress step={step} />
<Box sx={{ px: 3, py: 3 }}>
{/* Step 1: Input */}
{step === 'input' && (
<Box>
<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>
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3 }}>
{/* Step: Input */}
{step === NeedBuilderStep.IDLE && (
<NeedInput value={inputText} onChange={setInputText} onSubmit={handleAnalyze} />
)}
{/* Step 2: Extracting */}
{step === 'extracting' && (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', py: 12, gap: 3 }}>
{/* Step: Parsing */}
{step === NeedBuilderStep.PARSING && (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', py: 16, gap: 3 }}>
<CircularProgress size={48} sx={{ color: '#1e3a5f' }} />
<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>
</Box>
)}
{/* Step 3: Review */}
{step === 'review' && extractedCriteria && (
{/* Step: Criteria Review + Follow-up */}
{isReview && parseResult && (
<Box>
<Box className="grid grid-cols-2 gap-4" sx={{ mb: 3 }}>
{/* Left: Extracted Criteria */}
<Card sx={{ p: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
Extrahierte Kriterien
</Typography>
<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 className="grid grid-cols-2 gap-4" sx={{ mb: 2 }}>
<CriteriaReviewPanel result={parseResult} />
<FollowUpPanel
questions={parseResult.followUpQuestionCandidates}
answers={answers}
onAnswer={handleAnswer}
onContinue={handleContinueToWeighting}
/>
</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 */}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2 }}>
<Button variant="outlined" onClick={() => setStep('input')}>
{/* Step: Weighting */}
{step === NeedBuilderStep.WEIGHTING_REVIEW && (
<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
</Button>
<Button
variant="contained"
onClick={handleStartSearch}
endIcon={<ArrowRight size={16} />}
onClick={handleContinueToSave}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
>
Suche starten
Vorschau & Speichern
</Button>
</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>
)