feat: F008 AI need builder — full spec compliance (editable criteria, grouped sections, needService.create)
- NeedInput: Reset button, Asset-Type Quick Select chips, 3 typed examples - ExtractedFieldRow: inline edit mode (pencil → TextField, Enter/blur commits) - CriteriaReviewPanel: 3 grouped sections (Hard Facts, Soft Factors, Must-haves) + AI Assumptions + Missing Information; all 14 criteria fields shown and editable; onCriteriaChange propagates typed edits to parent - FollowUpPanel: Rückfragen neu generieren button triggers onReparse - NeedCardPreview: Need Title input, Confidence Summary with progress bar, Missing Critical Fields alert, low-confidence draft label - AISearch page: editedCriteria state (mutable copy of parse result), handleReparse via generateFollowUpQuestions, handleSave calls needService.create() with full CreateNeedInput mapping, low-confidence → status DRAFT Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+113
-44
@@ -13,18 +13,54 @@ import {
|
||||
NeedBuilderErrorState,
|
||||
} from '../../components/demand'
|
||||
import { aiService } from '../../services/aiService'
|
||||
import { needService } from '../../services/needService'
|
||||
import { weightingService } from '../../services/weightingService'
|
||||
import { NeedBuilderStep } from '../../domain/needBuilder'
|
||||
import type { ParseNeedResult } from '../../domain/needBuilder'
|
||||
import type { WeightingKey } from '../../domain/needBuilder'
|
||||
import type { ParseNeedResult, ParsedNeedCriteria, WeightingKey } from '../../domain/needBuilder'
|
||||
import { AssetType } from '../../domain/enums'
|
||||
import type { CreateNeedInput } from '../../domain/need'
|
||||
|
||||
// ── Map ParsedNeedCriteria → CreateNeedInput ───────────────────────────────────
|
||||
|
||||
function buildNeedInput(
|
||||
criteria: ParsedNeedCriteria,
|
||||
weights: Record<WeightingKey, number>,
|
||||
needTitle: string,
|
||||
overallConfidence: number,
|
||||
): CreateNeedInput {
|
||||
return {
|
||||
companyName: needTitle || criteria.companyName || 'Neuer Bedarf',
|
||||
assetType: criteria.assetType ?? AssetType.UNKNOWN,
|
||||
requiredArea: criteria.areaRange ?? { min: 0, max: 0 },
|
||||
preferredLocations: criteria.preferredLocations ?? [],
|
||||
budgetRange: criteria.budgetRange ?? { maxPerSqm: 0, currency: 'CHF' },
|
||||
timing: {
|
||||
earliestMoveIn: criteria.timing?.earliestMoveIn ?? '',
|
||||
latestMoveIn: criteria.timing?.latestMoveIn ?? criteria.timing?.earliestMoveIn ?? '',
|
||||
contractDurationMonths: criteria.timing?.contractDurationMonths,
|
||||
flexibleTiming: criteria.timing?.flexibleTiming ?? true,
|
||||
},
|
||||
weightingProfile: weights,
|
||||
confidenceInCriteria: overallConfidence,
|
||||
status: overallConfidence < 0.6 ? 'DRAFT' : 'ACTIVE',
|
||||
mustCriteriaText: criteria.mustHaveCriteria ?? [],
|
||||
notes: criteria.notes,
|
||||
extractedFromText: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AISearch() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [step, setStep] = useState<typeof NeedBuilderStep[keyof typeof NeedBuilderStep]>(NeedBuilderStep.IDLE)
|
||||
const [inputText, setInputText] = useState('')
|
||||
const [parseResult, setParseResult] = useState<ParseNeedResult | null>(null)
|
||||
const [editedCriteria, setEditedCriteria] = useState<ParsedNeedCriteria | null>(null)
|
||||
const [answers, setAnswers] = useState<Record<string, string>>({})
|
||||
const [weights, setWeights] = useState<Record<WeightingKey, number>>(weightingService.getDefaultWeights())
|
||||
const [needTitle, setNeedTitle] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function handleAnalyze() {
|
||||
@@ -34,6 +70,7 @@ export default function AISearch() {
|
||||
const resp = await aiService.parseNeed(inputText)
|
||||
const result = resp.data
|
||||
setParseResult(result)
|
||||
setEditedCriteria({ ...result.extractedCriteria })
|
||||
setWeights(result.suggestedWeights as Record<WeightingKey, number>)
|
||||
setStep(NeedBuilderStep.PARSED_REQUIRES_REVIEW)
|
||||
} catch {
|
||||
@@ -42,33 +79,61 @@ export default function AISearch() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReparse() {
|
||||
if (!editedCriteria) return
|
||||
setStep(NeedBuilderStep.PARSING)
|
||||
try {
|
||||
const resp = await aiService.generateFollowUpQuestions(editedCriteria)
|
||||
if (parseResult) {
|
||||
setParseResult({ ...parseResult, followUpQuestionCandidates: resp.data })
|
||||
}
|
||||
setStep(NeedBuilderStep.PARSED_REQUIRES_REVIEW)
|
||||
} catch {
|
||||
setStep(NeedBuilderStep.PARSED_REQUIRES_REVIEW)
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
if (!editedCriteria || !parseResult) return
|
||||
setStep(NeedBuilderStep.SAVING)
|
||||
await new Promise(r => setTimeout(r, 800))
|
||||
navigate('/demand/results', { state: { fromNeedBuilder: true } })
|
||||
|
||||
const fieldEntries = Object.entries(parseResult.confidenceByField)
|
||||
const overallConfidence = fieldEntries.length > 0
|
||||
? fieldEntries.reduce((sum, [, v]) => sum + v, 0) / fieldEntries.length
|
||||
: 0
|
||||
|
||||
try {
|
||||
const input = buildNeedInput(editedCriteria, weights, needTitle, overallConfidence)
|
||||
await needService.create(input)
|
||||
setStep(NeedBuilderStep.SAVED)
|
||||
navigate('/demand/results', { state: { fromNeedBuilder: true } })
|
||||
} catch {
|
||||
setError('Speichern fehlgeschlagen. Bitte versuchen Sie es erneut.')
|
||||
setStep(NeedBuilderStep.ERROR)
|
||||
}
|
||||
}
|
||||
|
||||
function handleRetry() {
|
||||
setStep(NeedBuilderStep.IDLE)
|
||||
setError(null)
|
||||
setParseResult(null)
|
||||
setEditedCriteria(null)
|
||||
}
|
||||
|
||||
const isReview = step === NeedBuilderStep.PARSED_REQUIRES_REVIEW || step === NeedBuilderStep.CLARIFICATION_REQUIRED
|
||||
const isSaveStep = step === NeedBuilderStep.READY_TO_SAVE || step === NeedBuilderStep.SAVING
|
||||
|
||||
const overallConfidence = parseResult
|
||||
? (() => {
|
||||
const entries = Object.entries(parseResult.confidenceByField)
|
||||
return entries.length > 0 ? entries.reduce((s, [, v]) => s + v, 0) / entries.length : 0
|
||||
})()
|
||||
: 0
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
<PageHeader
|
||||
@@ -78,6 +143,7 @@ export default function AISearch() {
|
||||
<NeedBuilderProgress step={step} />
|
||||
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3 }}>
|
||||
|
||||
{/* Step: Input */}
|
||||
{step === NeedBuilderStep.IDLE && (
|
||||
<NeedInput value={inputText} onChange={setInputText} onSubmit={handleAnalyze} />
|
||||
@@ -87,36 +153,35 @@ export default function AISearch() {
|
||||
{step === NeedBuilderStep.PARSING && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', py: 16, gap: 3 }}>
|
||||
<CircularProgress size={48} sx={{ color: '#1e3a5f' }} />
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
KI analysiert Ihren Bedarf…
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Kriterien werden extrahiert und bewertet
|
||||
</Typography>
|
||||
<Typography variant="h6" color="text.secondary">KI analysiert Ihren Bedarf…</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Kriterien werden extrahiert und bewertet</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Step: Criteria Review + Follow-up */}
|
||||
{isReview && parseResult && (
|
||||
{isReview && parseResult && editedCriteria && (
|
||||
<Box>
|
||||
<Box className="grid grid-cols-2 gap-4" sx={{ mb: 2 }}>
|
||||
<CriteriaReviewPanel result={parseResult} />
|
||||
<CriteriaReviewPanel
|
||||
result={parseResult}
|
||||
criteria={editedCriteria}
|
||||
onCriteriaChange={setEditedCriteria}
|
||||
/>
|
||||
<FollowUpPanel
|
||||
questions={parseResult.followUpQuestionCandidates}
|
||||
answers={answers}
|
||||
onAnswer={handleAnswer}
|
||||
onContinue={handleContinueToWeighting}
|
||||
onContinue={() => setStep(NeedBuilderStep.WEIGHTING_REVIEW)}
|
||||
onReparse={handleReparse}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-start' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ArrowLeft size={16} />}
|
||||
onClick={() => setStep(NeedBuilderStep.IDLE)}
|
||||
>
|
||||
Neu eingeben
|
||||
</Button>
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ArrowLeft size={16} />}
|
||||
onClick={() => setStep(NeedBuilderStep.IDLE)}
|
||||
>
|
||||
Neu eingeben
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -126,20 +191,16 @@ export default function AISearch() {
|
||||
<WeightingEditor
|
||||
weights={weights}
|
||||
onChange={setWeights}
|
||||
assetType={parseResult?.extractedCriteria.assetType}
|
||||
assetType={editedCriteria?.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)}
|
||||
>
|
||||
<Button variant="outlined" startIcon={<ArrowLeft size={16} />} onClick={() => setStep(NeedBuilderStep.PARSED_REQUIRES_REVIEW)}>
|
||||
Zurück
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
endIcon={<ArrowRight size={16} />}
|
||||
onClick={handleContinueToSave}
|
||||
onClick={() => setStep(NeedBuilderStep.READY_TO_SAVE)}
|
||||
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
|
||||
>
|
||||
Vorschau & Speichern
|
||||
@@ -149,9 +210,16 @@ export default function AISearch() {
|
||||
)}
|
||||
|
||||
{/* Step: Preview + Save */}
|
||||
{isSaveStep && parseResult && (
|
||||
{isSaveStep && parseResult && editedCriteria && (
|
||||
<Box>
|
||||
<NeedCardPreview criteria={parseResult.extractedCriteria} weights={weights} />
|
||||
<NeedCardPreview
|
||||
criteria={editedCriteria}
|
||||
weights={weights}
|
||||
confidenceByField={parseResult.confidenceByField}
|
||||
missingFields={parseResult.missingFields}
|
||||
needTitle={needTitle}
|
||||
onNeedTitleChange={setNeedTitle}
|
||||
/>
|
||||
<Box sx={{ maxWidth: 720, mx: 'auto', display: 'flex', justifyContent: 'space-between', mt: 3 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
@@ -172,7 +240,11 @@ export default function AISearch() {
|
||||
}
|
||||
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
|
||||
>
|
||||
{step === NeedBuilderStep.SAVING ? 'Wird gespeichert…' : 'Bedarf speichern & Suche starten'}
|
||||
{step === NeedBuilderStep.SAVING
|
||||
? 'Wird gespeichert…'
|
||||
: overallConfidence < 0.6
|
||||
? 'Als Entwurf speichern'
|
||||
: 'Bedarf speichern & Suche starten'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -180,10 +252,7 @@ export default function AISearch() {
|
||||
|
||||
{/* Step: Error */}
|
||||
{step === NeedBuilderStep.ERROR && (
|
||||
<NeedBuilderErrorState
|
||||
message={error ?? 'Unbekannter Fehler'}
|
||||
onRetry={handleRetry}
|
||||
/>
|
||||
<NeedBuilderErrorState message={error ?? 'Unbekannter Fehler'} onRetry={handleRetry} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user