From 73a6a167e0a6dd74a014555add9562555f6a079d Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Sat, 16 May 2026 12:21:49 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20F008=20AI=20need=20builder=20=E2=80=94?= =?UTF-8?q?=20smart=20parse,=20criteria=20review,=20follow-up=20&=20weight?= =?UTF-8?q?ing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../demand/ConfidenceFieldBadge.tsx | 22 + src/components/demand/CriteriaReviewPanel.tsx | 96 ++++ src/components/demand/ExtractedFieldRow.tsx | 35 ++ src/components/demand/FollowUpPanel.tsx | 65 +++ .../demand/FollowUpQuestionCard.tsx | 66 +++ .../demand/NeedBuilderErrorState.tsx | 31 ++ src/components/demand/NeedBuilderProgress.tsx | 31 ++ src/components/demand/NeedCardPreview.tsx | 116 +++++ src/components/demand/NeedInput.tsx | 66 +++ src/components/demand/WeightingEditor.tsx | 98 ++++ src/components/demand/index.ts | 10 + src/domain/needBuilder.ts | 69 +++ src/pages/demand/AISearch.tsx | 446 ++++++------------ src/services/aiService.ts | 229 ++++++++- src/services/weightingService.ts | 32 ++ 15 files changed, 1116 insertions(+), 296 deletions(-) create mode 100644 src/components/demand/ConfidenceFieldBadge.tsx create mode 100644 src/components/demand/CriteriaReviewPanel.tsx create mode 100644 src/components/demand/ExtractedFieldRow.tsx create mode 100644 src/components/demand/FollowUpPanel.tsx create mode 100644 src/components/demand/FollowUpQuestionCard.tsx create mode 100644 src/components/demand/NeedBuilderErrorState.tsx create mode 100644 src/components/demand/NeedBuilderProgress.tsx create mode 100644 src/components/demand/NeedCardPreview.tsx create mode 100644 src/components/demand/NeedInput.tsx create mode 100644 src/components/demand/WeightingEditor.tsx create mode 100644 src/components/demand/index.ts create mode 100644 src/domain/needBuilder.ts create mode 100644 src/services/weightingService.ts 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} + + + + + ) +} 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 + + + + + + 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. + + + + + + + + {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 - - - - 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 })) - } - /> - - ))} - - + + + + + + + + )} - {/* Footer Actions */} - - )} + + {/* Step: Preview + Save */} + {isSaveStep && parseResult && ( + + + + + + + + )} + + {/* 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) } + }, +}