diff --git a/src/components/demand/ConfidenceFieldBadge.tsx b/src/components/demand/ConfidenceFieldBadge.tsx
new file mode 100644
index 0000000..a89f6ab
--- /dev/null
+++ b/src/components/demand/ConfidenceFieldBadge.tsx
@@ -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 (
+
+ )
+}
diff --git a/src/components/demand/CriteriaReviewPanel.tsx b/src/components/demand/CriteriaReviewPanel.tsx
new file mode 100644
index 0000000..97ca855
--- /dev/null
+++ b/src/components/demand/CriteriaReviewPanel.tsx
@@ -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 = {
+ 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 (
+
+
+ Extrahierte Kriterien
+
+
+ {result.rawSummary}
+
+
+
+
+ 0 ? c.preferredLocations.join(', ') : 'Nicht erkannt'}
+ confidence={conf.preferredLocations ?? 0.15}
+ missing={!c.preferredLocations || c.preferredLocations.length === 0}
+ />
+
+
+ {c.mustHaveCriteria && c.mustHaveCriteria.length > 0 && (
+
+ )}
+
+ {assumptions.length > 0 && (
+
+
+ Annahmen der KI
+
+
+ {assumptions.map((a, i) => (
+
+ {a}
+
+ ))}
+
+
+ )}
+
+ {missingFields.length > 0 && (
+
+
+ Fehlende Angaben
+
+
+ {missingFields.map(f => (
+
+ ))}
+
+
+ )}
+
+ )
+}
diff --git a/src/components/demand/ExtractedFieldRow.tsx b/src/components/demand/ExtractedFieldRow.tsx
new file mode 100644
index 0000000..16c4da0
--- /dev/null
+++ b/src/components/demand/ExtractedFieldRow.tsx
@@ -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 (
+
+
+
+ {label}
+
+
+ {value}
+
+
+
+
+ )
+}
diff --git a/src/components/demand/FollowUpPanel.tsx b/src/components/demand/FollowUpPanel.tsx
new file mode 100644
index 0000000..7ab378e
--- /dev/null
+++ b/src/components/demand/FollowUpPanel.tsx
@@ -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
+ 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 (
+
+
+ Rückfragen der KI
+
+
+ Beantworten Sie die Pflichtfelder für optimale Ergebnisse.
+
+
+
+
+ {sorted.map(q => (
+ onAnswer(q.id, ans)}
+ />
+ ))}
+
+
+
+
+ {requiredUnanswered.length > 0 && (
+
+ {requiredUnanswered.length} Pflichtfeld{requiredUnanswered.length > 1 ? 'er' : ''} fehlt noch.
+
+ )}
+
+
+
+ )
+}
diff --git a/src/components/demand/FollowUpQuestionCard.tsx b/src/components/demand/FollowUpQuestionCard.tsx
new file mode 100644
index 0000000..9b54db9
--- /dev/null
+++ b/src/components/demand/FollowUpQuestionCard.tsx
@@ -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 = {
+ required: 'Pflichtfeld',
+ recommended: 'Empfohlen',
+ optional: 'Optional',
+}
+
+const IMPORTANCE_COLOR: Record = {
+ required: 'error',
+ recommended: 'warning',
+ optional: 'default',
+}
+
+export function FollowUpQuestionCard({ question, answer, onAnswer }: Props) {
+ return (
+
+
+
+ {question.questionText}
+
+
+
+
+ {question.reason}
+
+
+ {question.suggestedAnswerOptions && question.suggestedAnswerOptions.length > 0 ? (
+
+ {question.suggestedAnswerOptions.map(opt => (
+ onAnswer(answer === opt ? '' : opt)}
+ />
+ ))}
+
+ ) : (
+ onAnswer(e.target.value)}
+ />
+ )}
+
+ )
+}
diff --git a/src/components/demand/NeedBuilderErrorState.tsx b/src/components/demand/NeedBuilderErrorState.tsx
new file mode 100644
index 0000000..97a8105
--- /dev/null
+++ b/src/components/demand/NeedBuilderErrorState.tsx
@@ -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 (
+
+
+
+
+ Analyse fehlgeschlagen
+
+
+ {message}
+
+
+ }
+ onClick={onRetry}
+ sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
+ >
+ Erneut versuchen
+
+
+ )
+}
diff --git a/src/components/demand/NeedBuilderProgress.tsx b/src/components/demand/NeedBuilderProgress.tsx
new file mode 100644
index 0000000..7dbde2c
--- /dev/null
+++ b/src/components/demand/NeedBuilderProgress.tsx
@@ -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 (
+
+
+ {STEPS.map(label => (
+
+ {label}
+
+ ))}
+
+
+ )
+}
diff --git a/src/components/demand/NeedCardPreview.tsx b/src/components/demand/NeedCardPreview.tsx
new file mode 100644
index 0000000..b4a906a
--- /dev/null
+++ b/src/components/demand/NeedCardPreview.tsx
@@ -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
+}
+
+const ASSET_LABELS: Record = {
+ 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 (
+
+
+ Vorschau — Neuer Bedarf
+
+
+
+ {/* Header */}
+
+ {c.assetType && (
+
+ )}
+
+
+
+
+ {c.preferredLocations && c.preferredLocations.length > 0 && (
+
+
+
+ Standort
+ {c.preferredLocations.join(', ')}
+
+
+ )}
+ {c.areaRange && (
+
+
+
+ Fläche
+ {c.areaRange.min}–{c.areaRange.max} m²
+
+
+ )}
+ {c.budgetRange && (
+
+
+
+ Budget
+ max. CHF {c.budgetRange.maxPerSqm}/m²
+
+
+ )}
+ {c.timing && (
+
+
+
+ Verfügbarkeit
+ ab {c.timing.earliestMoveIn}
+
+
+ )}
+
+
+ {c.mustHaveCriteria && c.mustHaveCriteria.length > 0 && (
+
+
+
+ Must-haves
+
+
+ {c.mustHaveCriteria.map(m => (
+
+ ))}
+
+
+ )}
+
+
+
+ {/* Weights */}
+
+ Gewichtungsprofil
+
+
+ {WEIGHTING_KEYS.map(k => {
+ const pct = Math.round((weights[k] ?? 0) * 100)
+ return (
+
+ {WEIGHTING_LABELS[k]}
+
+ {pct}%
+
+ )
+ })}
+
+
+
+ )
+}
diff --git a/src/components/demand/NeedInput.tsx b/src/components/demand/NeedInput.tsx
new file mode 100644
index 0000000..6a41b40
--- /dev/null
+++ b/src/components/demand/NeedInput.tsx
@@ -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 (
+
+
+
+ Flächenbedarf beschreiben
+
+ onChange(e.target.value)}
+ slotProps={{ htmlInput: { maxLength: 2000 } }}
+ sx={{ mb: 1 }}
+ />
+
+ {value.length}/2000
+
+ }
+ sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
+ >
+ KI-Analyse starten
+
+
+
+
+ Beispiele:
+
+
+ {EXAMPLES.map(q => (
+ onChange(q)}
+ sx={{ maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis' }}
+ />
+ ))}
+
+
+ )
+}
diff --git a/src/components/demand/WeightingEditor.tsx b/src/components/demand/WeightingEditor.tsx
new file mode 100644
index 0000000..6101a21
--- /dev/null
+++ b/src/components/demand/WeightingEditor.tsx
@@ -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
+ onChange: (weights: Record) => 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 (
+
+
+
+
+ Kriteriengewichtung anpassen
+
+
+ Passen Sie an, wie stark jedes Kriterium das Matching beeinflusst.
+
+
+ }
+ onClick={handleReset}
+ >
+ Zurücksetzen
+
+
+
+
+
+ {WEIGHTING_KEYS.map(key => {
+ const pct = Math.round((weights[key] ?? 0) * 100)
+ return (
+
+
+
+ {WEIGHTING_LABELS[key]}
+
+
+ {pct}%
+
+
+ handleSlider(key, v as number)}
+ size="small"
+ sx={{ color: '#1e3a5f' }}
+ />
+
+ )
+ })}
+
+
+
+
+ Gesamt
+
+
+ {totalPct}%{!isBalanced && ' — Summe sollte ~100% ergeben'}
+
+
+
+
+ )
+}
diff --git a/src/components/demand/index.ts b/src/components/demand/index.ts
new file mode 100644
index 0000000..c7e53f3
--- /dev/null
+++ b/src/components/demand/index.ts
@@ -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'
diff --git a/src/domain/needBuilder.ts b/src/domain/needBuilder.ts
new file mode 100644
index 0000000..07a5ddd
--- /dev/null
+++ b/src/domain/needBuilder.ts
@@ -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
+ missingFields: string[]
+ assumptions: string[]
+ suggestedWeights: Record
+ 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 = {
+ area: 'Fläche',
+ location: 'Standort',
+ budget: 'Budget',
+ timing: 'Verfügbarkeit',
+ prestige: 'Prestige',
+ accessibility: 'Erreichbarkeit',
+ expansionPotential: 'Expansionspotenzial',
+ flexibility: 'Flexibilität',
+}
diff --git a/src/pages/demand/AISearch.tsx b/src/pages/demand/AISearch.tsx
index db303ae..dd19ec0 100644
--- a/src/pages/demand/AISearch.tsx
+++ b/src/pages/demand/AISearch.tsx
@@ -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('input')
+ const [step, setStep] = useState(NeedBuilderStep.IDLE)
const [inputText, setInputText] = useState('')
- const [extractedCriteria, setExtractedCriteria] = useState(null)
- const [followUpAnswers, setFollowUpAnswers] = useState>({})
+ const [parseResult, setParseResult] = useState(null)
+ const [answers, setAnswers] = useState>({})
+ const [weights, setWeights] = useState>(weightingService.getDefaultWeights())
+ const [error, setError] = useState(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)
+ 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 (
-
- {/* Page Header */}
-
-
- AI Bedarfsanalyse
-
-
- Beschreiben Sie Ihren Flächenbedarf in natürlicher Sprache
-
-
+
+
+
-
- {/* Step 1: Input */}
- {step === 'input' && (
-
-
-
- Flächenbedarf beschreiben
-
- setInputText(e.target.value)}
- slotProps={{ htmlInput: { maxLength: 2000 } }}
- sx={{ mb: 1 }}
- />
-
- {inputText.length}/2000
-
- }
- sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' }, mb: 2 }}
- >
- Analysieren
-
-
- Die KI extrahiert automatisch Kriterien, Standortpräferenzen und Budget aus Ihrer Beschreibung.
-
-
-
- {/* Example Queries */}
-
-
- Beispiele:
-
-
- {EXAMPLE_QUERIES.map(q => (
- setInputText(q)}
- sx={{ cursor: 'pointer' }}
- />
- ))}
-
-
-
+
+ {/* Step: Input */}
+ {step === NeedBuilderStep.IDLE && (
+
)}
- {/* Step 2: Extracting */}
- {step === 'extracting' && (
-
+ {/* Step: Parsing */}
+ {step === NeedBuilderStep.PARSING && (
+
- KI analysiert Ihren Bedarf...
+ KI analysiert Ihren Bedarf…
+
+
+ Kriterien werden extrahiert und bewertet
)}
- {/* Step 3: Review */}
- {step === 'review' && extractedCriteria && (
+ {/* Step: Criteria Review + Follow-up */}
+ {isReview && parseResult && (
-
- {/* Left: Extracted Criteria */}
-
-
- Extrahierte Kriterien
-
-
-
- {/* Confidence badge */}
-
- Gesamtkonfidenz:
-
-
-
-
-
- {/* Criteria items */}
- {extractedCriteria.extractedCriteria.requiredArea && (
-
-
- Flächenbedarf
-
-
- {extractedCriteria.extractedCriteria.requiredArea.min}–
- {extractedCriteria.extractedCriteria.requiredArea.max} m²
-
-
- )}
-
- {extractedCriteria.extractedCriteria.budgetRange && (
-
-
- Budget
-
-
- max. {extractedCriteria.extractedCriteria.budgetRange.maxPerSqm}{' '}
- {extractedCriteria.extractedCriteria.budgetRange.currency}/m²
-
-
- )}
-
- {extractedCriteria.extractedCriteria.companyName && (
-
-
- Unternehmen
-
-
- {extractedCriteria.extractedCriteria.companyName}
-
-
- )}
-
- {extractedCriteria.extractedCriteria.preferredLocations && extractedCriteria.extractedCriteria.preferredLocations.length > 0 && (
-
-
- Standort
-
-
- {extractedCriteria.extractedCriteria.preferredLocations.join(', ')}
-
-
- )}
-
- {extractedCriteria.extractedCriteria.timing && (
-
-
- Verfügbarkeit
-
-
- ab {extractedCriteria.extractedCriteria.timing.earliestMoveIn}
-
-
- )}
-
- {extractedCriteria.extractedCriteria.assetType && (
-
-
- Objekttyp
-
-
- {extractedCriteria.extractedCriteria.assetType}
-
-
- )}
-
-
- {/* Assumptions */}
- {extractedCriteria.assumptions.length > 0 && (
-
-
- Annahmen der KI
-
-
- {extractedCriteria.assumptions.map((a, i) => (
-
- {a}
-
- ))}
-
-
- )}
-
- {/* Missing Fields */}
- {extractedCriteria.missingFields.length > 0 && (
-
-
- Fehlende Informationen
-
-
- {extractedCriteria.missingFields.map(f => (
-
- ))}
-
-
- )}
-
-
- {/* Right: Follow-up Questions */}
-
-
- Rückfragen der KI
-
-
- Diese Fragen sind optional — verbessern jedoch die Trefferqualität.
-
-
-
- {extractedCriteria.followUpQuestions.map((q, i) => (
-
-
- {i + 1}. {q}
-
-
- setFollowUpAnswers(prev => ({ ...prev, [i]: e.target.value }))
- }
- />
-
- ))}
-
-
+
+
+
+
+ }
+ onClick={() => setStep(NeedBuilderStep.IDLE)}
+ >
+ Neu eingeben
+
+
+
+ )}
- {/* Footer Actions */}
-
-
}
+ onClick={handleContinueToSave}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
>
- Suche starten
+ Vorschau & Speichern
)}
+
+ {/* Step: Preview + Save */}
+ {isSaveStep && parseResult && (
+
+
+
+ }
+ onClick={() => setStep(NeedBuilderStep.WEIGHTING_REVIEW)}
+ disabled={step === NeedBuilderStep.SAVING}
+ >
+ Zurück
+
+
+ :
+ }
+ sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
+ >
+ {step === NeedBuilderStep.SAVING ? 'Wird gespeichert…' : 'Bedarf speichern & Suche starten'}
+
+
+
+ )}
+
+ {/* Step: Error */}
+ {step === NeedBuilderStep.ERROR && (
+
+ )}
)
diff --git a/src/services/aiService.ts b/src/services/aiService.ts
index 53db6c4..e253645 100644
--- a/src/services/aiService.ts
+++ b/src/services/aiService.ts
@@ -1,6 +1,10 @@
import type { ItemResponse, ServiceError } from './types'
import { ServiceErrorCode } from './types'
import type { CreateNeedInput } from '../domain/need'
+import type { AssetType } from '../domain/enums'
+import type { ParseNeedResult, ParsedNeedCriteria, FollowUpQuestion } from '../domain/needBuilder'
+
+// ── Legacy types (kept for backward compatibility) ────────────────────────────
export interface CriteriaExtractionResult {
extractedCriteria: Partial
@@ -15,9 +19,218 @@ export interface AIServiceProvider {
generateFollowUp(partialNeed: Partial): Promise
}
+// ── 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 = {
+ 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 = {
+ area: 0.20,
+ location: preferredLocations.length > 0 ? 0.28 : 0.22,
+ budget: budgetRange ? 0.22 : 0.18,
+ timing: timing ? 0.15 : 0.12,
+ prestige: prestigeImportance === 'HIGH' ? 0.10 : 0.05,
+ accessibility: mustHaveCriteria.includes('Gute ÖV-Anbindung') ? 0.08 : 0.04,
+ expansionPotential: 0.03,
+ flexibility: lower.includes('flexibel') ? 0.07 : 0.03,
+ }
+
+ const rawSummary = `Bedarf analysiert: ${assetType ?? 'Typ unbekannt'} · ${preferredLocations.join(', ') || 'Standort unklar'} · ${areaRange ? `${areaRange.min}–${areaRange.max} m²` : 'Fläche unklar'} · ${budgetRange ? `max. CHF ${budgetRange.maxPerSqm}/m²` : 'Budget unklar'}`
+
+ return {
+ extractedCriteria: {
+ assetType,
+ areaRange,
+ preferredLocations,
+ budgetRange,
+ timing,
+ mustHaveCriteria,
+ infrastructureRequirements: [],
+ accessibilityRequirements: mustHaveCriteria.includes('Gute ÖV-Anbindung') ? ['ÖV-Anbindung'] : [],
+ prestigeImportance,
+ flexibilityNeed: lower.includes('flexibel') ? 'HIGH' : 'MEDIUM',
+ expansionPotential: lower.includes('wachstum') || lower.includes('expansion'),
+ parkingNeed,
+ visibilityNeed,
+ footfallNeed,
+ },
+ confidenceByField,
+ missingFields,
+ assumptions,
+ suggestedWeights,
+ followUpQuestionCandidates,
+ rawSummary,
+ promptVersion: 'mock-v1.0',
+ schemaVersion: '1.0.0',
+ }
+}
+
+// ── Legacy mock provider (kept for backward compat) ───────────────────────────
+
const MockupAIServiceProvider: AIServiceProvider = {
async extractCriteria(_input: string): Promise {
- // Deterministic mock extraction — simulates AI parsing
return {
extractedCriteria: {
companyName: 'Unbekannt (bitte bestätigen)',
@@ -51,7 +264,6 @@ const notConfiguredError = (): ServiceError => ({
message: 'OpenRouter nicht konfiguriert',
})
-// Stub — swap for real OpenRouter implementation without changing call sites
export const openRouterAIService: AIServiceProvider = {
async extractCriteria(_input: string): Promise {
throw notConfiguredError()
@@ -62,6 +274,7 @@ export const openRouterAIService: AIServiceProvider = {
}
export const aiService = {
+ // Legacy methods
async extractCriteria(input: string): Promise> {
const data = await provider.extractCriteria(input)
return { data }
@@ -70,4 +283,16 @@ export const aiService = {
const data = await provider.generateFollowUp(partialNeed)
return { data }
},
+
+ // F008 methods
+ async parseNeed(input: string): Promise> {
+ await new Promise(r => setTimeout(r, 1400))
+ const data = mockParseNeed(input)
+ return { data }
+ },
+ async generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise> {
+ await new Promise(r => setTimeout(r, 600))
+ const result = mockParseNeed(JSON.stringify(criteria))
+ return { data: result.followUpQuestionCandidates }
+ },
}
diff --git a/src/services/weightingService.ts b/src/services/weightingService.ts
new file mode 100644
index 0000000..1b6ca24
--- /dev/null
+++ b/src/services/weightingService.ts
@@ -0,0 +1,32 @@
+import type { WeightingKey } from '../domain/needBuilder'
+
+type WeightProfile = Record
+
+const PROFILES: Record = {
+ 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) }
+ },
+}