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:
@@ -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} m²` : '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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 500–800 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 800–1.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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
@@ -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
@@ -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 800–1.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
@@ -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 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 = {
|
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 }
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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