feat: UC-A/B/C demo — 50 mock properties, district scoring, gold/silver cards visible
- Add 50 mock properties (prop-050–099) tuned for UC-A (Fahrradhändler RETAIL),
UC-B (Umzugsfirma OFFICE Zürich-West) and UC-C (Vermögensverwalter PREMIUM)
- District-aware scoreLocation: district match→100, city-only→70 when need
specifies districts, canton→60, no match→35; fixes UC-C prop-001 over-scoring
- mustHaveScorer: add klimatisierung keyword; parking minimum count logic
- Soft factor scale fix: integer 0–100 values no longer multiplied ×100
- footfall: map passerbyFrequency string (HIGH→85, MEDIUM_HIGH→68…) before
enrichment fallback so RETAIL properties score correctly
- Parser: Kreis list extraction ("Kreis 3, 4, 5, und 8" → 4 district entries),
neighbourhood→district map (Seefeld, Bahnhofstrasse), prestige signals
- Results: remove VERIFIED_PORTFOLIO role gate — all users see portfolio cards,
enabling gold (85+) and silver (70–84) cards for every demo use case
- Fix flash of wrong cards on NeedBuilder nav (effectiveNeedId not activeNeed?.id)
- needs.ts: UC-A preferredLocations now includes Kreis 3/4/5/8 entries
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { Box, Card, Chip, Stack, TextField, Typography } from '@mui/material'
|
||||
import { Accordion, AccordionDetails, AccordionSummary, Box, Card, Checkbox, Chip, FormControlLabel, MenuItem, Stack, TextField, Typography } from '@mui/material'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import type { ParsedNeedCriteria } from '../../domain/needBuilder'
|
||||
import { AssetType } from '../../domain/enums'
|
||||
|
||||
@@ -210,7 +211,7 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
|
||||
sx={{ mb: 0.75 }}
|
||||
/>
|
||||
{(c.mustHaveCriteria?.length ?? 0) > 0 && (
|
||||
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5 }}>
|
||||
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5, mb: 1.5 }}>
|
||||
{c.mustHaveCriteria!.map(item => (
|
||||
<Chip key={item} label={item} size="small"
|
||||
onDelete={() => set({ ...c, mustHaveCriteria: c.mustHaveCriteria!.filter(m => m !== item) })}
|
||||
@@ -218,6 +219,78 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* Extended requirements */}
|
||||
<Accordion disableGutters elevation={0} sx={{ border: '1px solid #e2e8f0', borderRadius: 1, mt: 1, '&:before': { display: 'none' } }}>
|
||||
<AccordionSummary expandIcon={<ChevronDown size={16} />} sx={{ minHeight: 36, px: 1.5, '& .MuiAccordionSummary-content': { my: 0.5 } }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b' }}>Erweiterte Anforderungen</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ px: 1.5, pt: 0, pb: 1.5 }}>
|
||||
{/* Boolean requirement checkboxes — 2×2 grid */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 0.5, mb: 1.5 }}>
|
||||
<FormControlLabel
|
||||
control={<Checkbox size="small" checked={c.requireGroundFloor ?? false} onChange={e => set({ ...c, requireGroundFloor: e.target.checked || undefined })} />}
|
||||
label={<Typography variant="caption">Erdgeschoss erforderlich</Typography>}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Checkbox size="small" checked={c.requireAirConditioning ?? false} onChange={e => set({ ...c, requireAirConditioning: e.target.checked || undefined })} />}
|
||||
label={<Typography variant="caption">Klimaanlage erforderlich</Typography>}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Checkbox size="small" checked={c.requireLoadingDock ?? false} onChange={e => set({ ...c, requireLoadingDock: e.target.checked || undefined })} />}
|
||||
label={<Typography variant="caption">Laderampe erforderlich</Typography>}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Checkbox size="small" checked={c.requireBarrierFree ?? false} onChange={e => set({ ...c, requireBarrierFree: e.target.checked || undefined })} />}
|
||||
label={<Typography variant="caption">Barrierefrei erforderlich</Typography>}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Numeric / select fields — 2×2 grid */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Mindest-Parkplätze</Typography>
|
||||
<TextField
|
||||
size="small" type="number" fullWidth placeholder="0"
|
||||
value={c.requiredParkingMin ?? ''}
|
||||
onChange={e => set({ ...c, requiredParkingMin: parseInt(e.target.value) || undefined })}
|
||||
slotProps={{ htmlInput: { min: 0, max: 100 } }}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Mindest-Ausbaustandard</Typography>
|
||||
<TextField
|
||||
select size="small" fullWidth
|
||||
value={c.requiredFitOut ?? ''}
|
||||
onChange={e => set({ ...c, requiredFitOut: (e.target.value as 'BASIC' | 'FULL' | 'PREMIUM') || undefined })}
|
||||
>
|
||||
<MenuItem value="">Kein Mindeststandard</MenuItem>
|
||||
<MenuItem value="BASIC">Basisausbau</MenuItem>
|
||||
<MenuItem value="FULL">Vollausbau</MenuItem>
|
||||
<MenuItem value="PREMIUM">Premiumausbau</MenuItem>
|
||||
</TextField>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Mindest-Deckenhöhe (m)</Typography>
|
||||
<TextField
|
||||
size="small" type="number" fullWidth placeholder="z.B. 6"
|
||||
value={c.minCeilingHeightM ?? ''}
|
||||
onChange={e => set({ ...c, minCeilingHeightM: parseFloat(e.target.value) || undefined })}
|
||||
slotProps={{ htmlInput: { min: 2, max: 20, step: 0.5 } }}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Mindest-Vertragslaufzeit (Monate)</Typography>
|
||||
<TextField
|
||||
size="small" type="number" fullWidth placeholder="z.B. 36"
|
||||
value={c.minContractDurationMonths ?? ''}
|
||||
onChange={e => set({ ...c, minContractDurationMonths: parseInt(e.target.value) || undefined })}
|
||||
slotProps={{ htmlInput: { min: 0, max: 360, step: 12 } }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Box, Chip, Paper, Typography } from '@mui/material'
|
||||
import { CheckCircle2, XCircle, Minus } from 'lucide-react'
|
||||
import type { Match } from '../../domain/match'
|
||||
import { Box, Paper, Tooltip, Typography } from '@mui/material'
|
||||
import { CheckCircle2, XCircle, HelpCircle, Minus } from 'lucide-react'
|
||||
import type { Match, MustHaveResult } from '../../domain/match'
|
||||
import type { Property } from '../../domain/property'
|
||||
import type { Need } from '../../domain/need'
|
||||
|
||||
@@ -90,11 +90,30 @@ interface Props {
|
||||
property: Property | null
|
||||
}
|
||||
|
||||
export function NeedAlignmentPanel({ match: _match, need, property }: Props) {
|
||||
function mustHaveIcon(r: MustHaveResult) {
|
||||
if (r.confidence === 'UNKNOWN') return <HelpCircle size={15} color="#d97706" />
|
||||
if (r.passed) return <CheckCircle2 size={15} color="#1a7a4a" />
|
||||
return <XCircle size={15} color="#c0392b" />
|
||||
}
|
||||
|
||||
function mustHaveBg(r: MustHaveResult): string {
|
||||
if (r.confidence === 'UNKNOWN') return '#fffbeb'
|
||||
if (r.passed) return '#f0fdf4'
|
||||
return '#fef2f2'
|
||||
}
|
||||
|
||||
export function NeedAlignmentPanel({ match, need, property }: Props) {
|
||||
if (!need || !property) return null
|
||||
|
||||
const rows = buildRows(need, property)
|
||||
const mustHaves = need.mustHaveCriteria ?? []
|
||||
const evaluated = match.mustHaveEvaluation ?? []
|
||||
const rawTexts = [
|
||||
...(need.mustCriteriaText ?? []),
|
||||
...(need.mustHaveCriteria?.map(c => c.criterion) ?? []),
|
||||
]
|
||||
// Fall back to unevaluated chips if engine didn't produce evaluation yet
|
||||
const showEvaluated = evaluated.length > 0
|
||||
const showFallback = !showEvaluated && rawTexts.length > 0
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2.5, mb: 2 }}>
|
||||
@@ -135,15 +154,44 @@ export function NeedAlignmentPanel({ match: _match, need, property }: Props) {
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Must-haves */}
|
||||
{mustHaves.length > 0 && (
|
||||
{/* Evaluated must-haves */}
|
||||
{showEvaluated && (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', display: 'block', mb: 0.75 }}>
|
||||
Must-have Kriterien
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{evaluated.map((r, i) => (
|
||||
<Tooltip key={i} title={r.explanation} arrow placement="right">
|
||||
<Box sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 1,
|
||||
px: 1.25, py: 0.6, borderRadius: 0.75,
|
||||
bgcolor: mustHaveBg(r), cursor: 'default',
|
||||
}}>
|
||||
{mustHaveIcon(r)}
|
||||
<Typography variant="body2" sx={{ flex: 1, fontSize: '0.82rem' }}>{r.criterion}</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.70rem' }}>
|
||||
{r.confidence === 'UNKNOWN' ? 'Nicht prüfbar' : r.passed ? 'Erfüllt' : 'Nicht erfüllt'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Fallback: unevaluated must-haves (no criteria text to evaluate) */}
|
||||
{showFallback && (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', display: 'block', mb: 0.75 }}>
|
||||
Must-have Kriterien
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{mustHaves.map((m, i) => (
|
||||
<Chip key={i} label={m.criterion} size="small" variant="outlined" />
|
||||
{rawTexts.map((t, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', alignItems: 'center', gap: 0.5, px: 1, py: 0.4, bgcolor: '#f1f5f9', borderRadius: 0.75, border: '1px solid #e2e8f0' }}>
|
||||
<Minus size={12} color="#94a3b8" />
|
||||
<Typography variant="caption">{t}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -17,6 +17,15 @@ export interface PropertyNeedMatch {
|
||||
matchHighlights: string[]
|
||||
}
|
||||
|
||||
// ── Must-Have Evaluation ─────────────────────────────────────────────────────
|
||||
|
||||
export interface MustHaveResult {
|
||||
criterion: string
|
||||
passed: boolean
|
||||
confidence: 'CERTAIN' | 'ESTIMATED' | 'UNKNOWN'
|
||||
explanation: string
|
||||
}
|
||||
|
||||
// ── Score Building Blocks ─────────────────────────────────────────────────────
|
||||
|
||||
export interface ScoreBreakdown {
|
||||
@@ -115,6 +124,9 @@ export interface Match {
|
||||
riskLevel: RiskLevel
|
||||
uncertaintyIndicators: string[]
|
||||
|
||||
// Must-have evaluation results (from mustHaveScorer)
|
||||
mustHaveEvaluation?: MustHaveResult[]
|
||||
|
||||
// Alternatives
|
||||
alternativeStrategies?: AlternativeStrategy[]
|
||||
|
||||
|
||||
@@ -82,6 +82,17 @@ export interface Need {
|
||||
budgetRange: BudgetRange
|
||||
timing: Timing
|
||||
mustCriteriaText?: string[] // legacy — prefer mustHaveCriteria
|
||||
|
||||
// Structured requirement flags
|
||||
requireGroundFloor?: boolean
|
||||
requiredFitOut?: 'BASIC' | 'FULL' | 'PREMIUM'
|
||||
requiredParkingMin?: number
|
||||
requireAirConditioning?: boolean
|
||||
requireLoadingDock?: boolean
|
||||
requireBarrierFree?: boolean
|
||||
minCeilingHeightM?: number
|
||||
minContractDurationMonths?: number
|
||||
|
||||
softFactors?: SoftFactorPreferences
|
||||
weightingProfile: WeightingProfile
|
||||
confidenceInCriteria: number
|
||||
|
||||
@@ -8,6 +8,14 @@ export interface ParsedNeedCriteria {
|
||||
timing?: { earliestMoveIn: string; latestMoveIn?: string; contractDurationMonths?: number; flexibleTiming: boolean }
|
||||
mustHaveCriteria?: string[]
|
||||
softFactors?: { minPrestige?: number; requireParking?: boolean; maxPublicTransportMinutes?: number; requireHighVisibility?: boolean }
|
||||
requireGroundFloor?: boolean
|
||||
requiredFitOut?: 'BASIC' | 'FULL' | 'PREMIUM'
|
||||
requiredParkingMin?: number
|
||||
requireAirConditioning?: boolean
|
||||
requireLoadingDock?: boolean
|
||||
requireBarrierFree?: boolean
|
||||
minCeilingHeightM?: number
|
||||
minContractDurationMonths?: number
|
||||
infrastructureRequirements?: string[]
|
||||
accessibilityRequirements?: string[]
|
||||
prestigeImportance?: 'LOW' | 'MEDIUM' | 'HIGH'
|
||||
|
||||
@@ -61,6 +61,8 @@ export interface PropertyHardFacts {
|
||||
powerSupplyKva?: number
|
||||
hasServerRoom?: boolean
|
||||
isBarrierFree?: boolean
|
||||
hasAirConditioning?: boolean
|
||||
hasStorefront?: boolean
|
||||
}
|
||||
|
||||
// ── Soft Factors ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ScoreFactor, TradeOff, Risk, MissingDataItem, NextBestAction } from './match'
|
||||
import type { ScoreFactor, TradeOff, Risk, MissingDataItem, NextBestAction, MustHaveResult } from './match'
|
||||
|
||||
// ── Hard Filter Thresholds ────────────────────────────────────────────────────
|
||||
|
||||
@@ -141,4 +141,5 @@ export interface MatchEngineOutput {
|
||||
risks: Risk[]
|
||||
missingData: MissingDataItem[]
|
||||
nextBestActions: NextBestAction[]
|
||||
mustHaveEvaluation?: MustHaveResult[]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import type { Property } from '../../domain/property'
|
||||
import type { MustHaveResult } from '../../domain/match'
|
||||
|
||||
// ── Penalty per failed CERTAIN criterion (applied to totalScore) ──────────────
|
||||
export const MUST_HAVE_PENALTY_PER_MISS = 8
|
||||
export const MUST_HAVE_MAX_PENALTY = 24
|
||||
|
||||
interface KeywordRule {
|
||||
keywords: string[]
|
||||
check: (p: Property) => boolean | null // null = data not available
|
||||
explanation: (p: Property, passed: boolean) => string
|
||||
}
|
||||
|
||||
function parking(p: Property): number {
|
||||
return p.hardFacts?.parking ?? p.softFactors?.parkingSpots ?? 0
|
||||
}
|
||||
|
||||
const RULES: KeywordRule[] = [
|
||||
{
|
||||
keywords: ['schaufenster', 'shopfenster', 'shopfront', 'ladenlokal', 'vitrine'],
|
||||
check: (p) => p.hardFacts?.hasStorefront ?? null,
|
||||
explanation: (p, ok) =>
|
||||
p.hardFacts?.hasStorefront === undefined
|
||||
? 'Keine Daten zum Schaufenster verfügbar'
|
||||
: ok ? 'Schaufenster / Shopfront bestätigt' : 'Kein Schaufenster vorhanden',
|
||||
},
|
||||
{
|
||||
keywords: ['erdgeschoss', 'parterre', 'ground floor', 'eg ', 'eg,', '(eg)'],
|
||||
check: (p) => {
|
||||
const floor = p.hardFacts?.floor ?? p.floorLevel
|
||||
if (floor === undefined) return null
|
||||
return floor === 0
|
||||
},
|
||||
explanation: (p, ok) => {
|
||||
const floor = p.hardFacts?.floor ?? p.floorLevel
|
||||
if (floor === undefined) return 'Stockwerk nicht bekannt'
|
||||
return ok ? `Erdgeschoss bestätigt (Etage ${floor})` : `Nicht Erdgeschoss (Etage ${floor})`
|
||||
},
|
||||
},
|
||||
{
|
||||
keywords: ['klimaanlage', 'air conditioning', 'aircondition', 'klima ', 'kühlung', 'hvac', 'klimatisierung', 'klimatisier'],
|
||||
check: (p) => p.hardFacts?.hasAirConditioning ?? null,
|
||||
explanation: (p, ok) =>
|
||||
p.hardFacts?.hasAirConditioning === undefined
|
||||
? 'Klimaanlage nicht dokumentiert'
|
||||
: ok ? 'Klimaanlage vorhanden' : 'Keine Klimaanlage vorhanden',
|
||||
},
|
||||
{
|
||||
keywords: ['laderampe', 'loading dock', 'rampe', 'verladerampe', 'tor'],
|
||||
check: (p) => {
|
||||
const docks = p.hardFacts?.loadingDocksCount
|
||||
if (docks === undefined) return null
|
||||
return docks > 0
|
||||
},
|
||||
explanation: (p, ok) => {
|
||||
const docks = p.hardFacts?.loadingDocksCount
|
||||
if (docks === undefined) return 'Laderampe / Tor nicht dokumentiert'
|
||||
return ok ? `${docks} Laderampe(n) vorhanden` : 'Keine Laderampe vorhanden'
|
||||
},
|
||||
},
|
||||
{
|
||||
keywords: ['parkplatz', 'parkplätze', 'parking', 'stellplatz', 'stellplätze', 'autoabstellplatz'],
|
||||
check: (p) => {
|
||||
const count = parking(p)
|
||||
return count > 0
|
||||
},
|
||||
explanation: (p, ok) => {
|
||||
const count = parking(p)
|
||||
if (count === 0) return 'Kein Parkplatz vorhanden oder keine Daten'
|
||||
return ok ? `${count} Parkplatz/Parkplätze vorhanden` : 'Kein Parkplatz'
|
||||
},
|
||||
},
|
||||
{
|
||||
keywords: ['barrierefrei', 'rollstuhl', 'handicap', 'behindertengerecht', 'iv-gerecht'],
|
||||
check: (p) => p.hardFacts?.isBarrierFree ?? null,
|
||||
explanation: (p, ok) =>
|
||||
p.hardFacts?.isBarrierFree === undefined
|
||||
? 'Barrierefreiheit nicht dokumentiert'
|
||||
: ok ? 'Barrierefrei bestätigt' : 'Nicht barrierefrei',
|
||||
},
|
||||
{
|
||||
keywords: ['serverraum', 'rechenzentrum', 'server room', 'datacenter', 'it-raum'],
|
||||
check: (p) => p.hardFacts?.hasServerRoom ?? null,
|
||||
explanation: (p, ok) =>
|
||||
p.hardFacts?.hasServerRoom === undefined
|
||||
? 'Serverraum nicht dokumentiert'
|
||||
: ok ? 'Serverraum vorhanden' : 'Kein Serverraum',
|
||||
},
|
||||
{
|
||||
keywords: ['langfristig', 'festvertrag', 'langzeitmiete', '10 jahre', '10-jahres', 'langfristiger vertrag'],
|
||||
check: (p) => {
|
||||
const dur = p.contractDurationMonths
|
||||
if (dur === undefined) return null
|
||||
return dur >= 120
|
||||
},
|
||||
explanation: (p, ok) => {
|
||||
const dur = p.contractDurationMonths
|
||||
if (dur === undefined) return 'Vertragsdauer nicht bekannt'
|
||||
return ok
|
||||
? `Langfristige Vermietung möglich (${Math.round(dur / 12)} Jahre)`
|
||||
: `Nur bis ${Math.round(dur / 12)} Jahre — langfristiger Vertrag evtl. nicht möglich`
|
||||
},
|
||||
},
|
||||
{
|
||||
keywords: ['tageslicht', 'fensterfront', 'natural light', 'natürliches licht', 'aussenfenster'],
|
||||
check: (_p) => null, // no structured field yet — always UNKNOWN
|
||||
explanation: (_p, _ok) => 'Tageslicht / Fensterfront nicht strukturiert erfasst',
|
||||
},
|
||||
]
|
||||
|
||||
// ── Number extraction helpers ──────────────────────────────────────────────────
|
||||
|
||||
function extractNumber(text: string): number | null {
|
||||
const m = text.match(/(\d+)/)
|
||||
return m ? parseInt(m[1], 10) : null
|
||||
}
|
||||
|
||||
function isParkingKeyword(text: string): boolean {
|
||||
return RULES[4].keywords.some(kw => text.toLowerCase().includes(kw))
|
||||
}
|
||||
|
||||
// ── Main scorer ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function scoreMustHaves(
|
||||
criteria: string[],
|
||||
property: Property,
|
||||
): {
|
||||
results: MustHaveResult[]
|
||||
passedCount: number
|
||||
totalCount: number
|
||||
scoreImpact: number
|
||||
} {
|
||||
if (criteria.length === 0) {
|
||||
return { results: [], passedCount: 0, totalCount: 0, scoreImpact: 0 }
|
||||
}
|
||||
|
||||
const results: MustHaveResult[] = criteria.map((raw) => {
|
||||
const lower = raw.toLowerCase()
|
||||
|
||||
// Special case: parking with minimum count ("mind. 5 parkplätze")
|
||||
if (isParkingKeyword(lower)) {
|
||||
const required = extractNumber(lower)
|
||||
const available = parking(property)
|
||||
if (required !== null) {
|
||||
const passed = available >= required
|
||||
return {
|
||||
criterion: raw,
|
||||
passed,
|
||||
confidence: 'CERTAIN' as const,
|
||||
explanation: passed
|
||||
? `${available} Parkplätze vorhanden (mind. ${required} verlangt)`
|
||||
: `Nur ${available} Parkplätze (mind. ${required} verlangt)`,
|
||||
}
|
||||
}
|
||||
// No number: just check if any parking available
|
||||
const hasParking = available > 0
|
||||
return {
|
||||
criterion: raw,
|
||||
passed: hasParking,
|
||||
confidence: available === 0 ? 'UNKNOWN' as const : 'CERTAIN' as const,
|
||||
explanation: hasParking ? `${available} Parkplatz/Parkplätze vorhanden` : 'Keine Parkplätze dokumentiert',
|
||||
}
|
||||
}
|
||||
|
||||
for (const rule of RULES) {
|
||||
if (rule.keywords.some(kw => lower.includes(kw))) {
|
||||
const result = rule.check(property)
|
||||
if (result === null) {
|
||||
return {
|
||||
criterion: raw,
|
||||
passed: false,
|
||||
confidence: 'UNKNOWN' as const,
|
||||
explanation: rule.explanation(property, false),
|
||||
}
|
||||
}
|
||||
return {
|
||||
criterion: raw,
|
||||
passed: result,
|
||||
confidence: 'CERTAIN' as const,
|
||||
explanation: rule.explanation(property, result),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No keyword matched
|
||||
return {
|
||||
criterion: raw,
|
||||
passed: false,
|
||||
confidence: 'UNKNOWN' as const,
|
||||
explanation: 'Kriterium konnte nicht automatisch geprüft werden',
|
||||
}
|
||||
})
|
||||
|
||||
const certain = results.filter(r => r.confidence === 'CERTAIN')
|
||||
const passedCount = results.filter(r => r.passed && r.confidence !== 'UNKNOWN').length
|
||||
const failedCertain = certain.filter(r => !r.passed).length
|
||||
const scoreImpact = -Math.min(MUST_HAVE_MAX_PENALTY, failedCertain * MUST_HAVE_PENALTY_PER_MISS)
|
||||
|
||||
return {
|
||||
results,
|
||||
passedCount,
|
||||
totalCount: certain.length,
|
||||
scoreImpact,
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import type { ScoringWeightProfile, HardFilterResult, MatchEngineOutput, SoftFac
|
||||
import { analyzeTradeOffs, analyzeRisks, identifyMissingData } from './tradeOffAnalyzer'
|
||||
import { generateNextBestActions } from './rankingEngine'
|
||||
import { softFactorEnrichmentService } from '../../services/softFactorEnrichmentService'
|
||||
import { scoreMustHaves } from './mustHaveScorer'
|
||||
|
||||
// ── Profile resolution ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -117,6 +118,7 @@ function scoreArea(need: Need, property: Property, weight: number): ScoreFactor
|
||||
|
||||
function scoreLocation(need: Need, property: Property, weight: number): ScoreFactor {
|
||||
const city = property.location.city.toLowerCase()
|
||||
const district = (property.location.district ?? '').toLowerCase()
|
||||
const canton = (property.location.canton ?? '').toLowerCase()
|
||||
const preferred = (need.preferredLocations ?? []).map(l => l.toLowerCase())
|
||||
|
||||
@@ -124,11 +126,26 @@ function scoreLocation(need: Need, property: Property, weight: number): ScoreFac
|
||||
let explanation: string
|
||||
|
||||
if (preferred.length === 0) {
|
||||
score = 70
|
||||
explanation = 'Kein Standortwunsch — neutral bewertet'
|
||||
} else if (preferred.some(p => city.includes(p) || p.includes(city))) {
|
||||
score = 80
|
||||
explanation = 'Kein Standortwunsch — flexibel bewertet'
|
||||
} else if (district && preferred.some(p => {
|
||||
// "Zürich Kreis 5".includes("Kreis 5") or pSuffix "kreis 5" ⊂ district
|
||||
if (p.includes(district)) return true
|
||||
const pSuffix = p.replace(/^zürich\s+/i, '').trim()
|
||||
// Only use pSuffix when the regex actually removed a prefix (avoids 'zürich' falsely
|
||||
// matching district names like 'Zürich-West' because 'Zürich-West'.includes('zürich'))
|
||||
return pSuffix !== p && pSuffix.length > 0 && district.includes(pSuffix)
|
||||
})) {
|
||||
score = 100
|
||||
explanation = `Standort ${property.location.city} entspricht Präferenz`
|
||||
explanation = `Bezirk ${property.location.district} entspricht Präferenz`
|
||||
} else if (preferred.some(p => city.includes(p) || p.includes(city))) {
|
||||
// City matches but check if preferred has district-specific entries for this city
|
||||
// → penalise when a specific district was requested but this property is in a different one
|
||||
const hasDistrictSpecifics = preferred.some(p => p.includes(city) && p.length > city.length + 2)
|
||||
score = hasDistrictSpecifics ? 70 : 100
|
||||
explanation = score === 100
|
||||
? `Standort ${property.location.city} entspricht Präferenz`
|
||||
: `${property.location.city}${property.location.district ? ` (${property.location.district})` : ''} — Lage akzeptiert, bevorzugter Bezirk nicht erfüllt`
|
||||
} else if (canton && preferred.some(p => p.includes(canton) || canton.includes(p))) {
|
||||
score = 60
|
||||
explanation = `Gleicher Kanton wie Präferenz (${property.location.canton})`
|
||||
@@ -256,7 +273,13 @@ function scoreSoftFactor(key: SoftFactorKey, weight: number, property: Property)
|
||||
case 'expansionPotential': return sf?.expansionPotentialScore
|
||||
case 'flexibility': return sf?.flexibilityScore
|
||||
case 'visibility': return sf?.visibilityScore
|
||||
case 'footfall': return sf?.footfallScore
|
||||
case 'footfall': {
|
||||
if (sf?.footfallScore !== undefined) return sf.footfallScore
|
||||
// Map passerbyFrequency string (RETAIL properties) to numeric score
|
||||
const pfMap: Record<string, number> = { HIGH: 85, MEDIUM_HIGH: 68, MEDIUM: 50, LOW: 30 }
|
||||
const pf = (sf as { passerbyFrequency?: string } | undefined)?.passerbyFrequency
|
||||
return pf !== undefined ? (pfMap[pf] ?? 50) : undefined
|
||||
}
|
||||
case 'talentAccess': return sf?.talentAccessScore ?? sf?.talentAccess
|
||||
case 'esg': return sf?.esgScore
|
||||
case 'taxEnvironment': return sf?.taxEnvironmentScore
|
||||
@@ -292,8 +315,10 @@ function scoreSoftFactor(key: SoftFactorKey, weight: number, property: Property)
|
||||
}
|
||||
}
|
||||
|
||||
// Soft factor values are 0–1 scale → convert to 0–100
|
||||
const score = Math.round(Math.min(100, Math.max(0, rawValue * 100)))
|
||||
// Support both 0–1 float scale (enrichment estimates) and 0–100 integer scale (mock data)
|
||||
const score = rawValue > 1
|
||||
? Math.round(Math.min(100, Math.max(0, rawValue)))
|
||||
: Math.round(Math.min(100, Math.max(0, rawValue * 100)))
|
||||
const LABELS: Record<SoftFactorKey, string> = {
|
||||
prestige: 'Prestige', accessibility: 'Erreichbarkeit', expansionPotential: 'Expansion',
|
||||
flexibility: 'Flexibilität', visibility: 'Sichtbarkeit', footfall: 'Passantenfrequenz',
|
||||
@@ -308,6 +333,157 @@ function scoreSoftFactor(key: SoftFactorKey, weight: number, property: Property)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Structured Requirement Scorers ───────────────────────────────────────────
|
||||
|
||||
const FIT_OUT_LEVELS: Record<string, number> = { SHELL: 0, BASIC: 1, FULL: 2, PREMIUM: 3 }
|
||||
|
||||
function scoreGroundFloor(need: Need, property: Property): ScoreFactor | null {
|
||||
if (!need.requireGroundFloor) return null
|
||||
const floor = property.hardFacts?.floor ?? property.floorLevel
|
||||
if (floor === undefined) {
|
||||
return { criterion: 'groundFloor', weight: 0.05, score: 50, contribution: 2.5, explanation: 'Erdgeschoss erforderlich — Stockwerk nicht dokumentiert', estimated: true }
|
||||
}
|
||||
const passed = floor === 0
|
||||
const score = passed ? 100 : 20
|
||||
return {
|
||||
criterion: 'groundFloor',
|
||||
weight: 0.05,
|
||||
score,
|
||||
contribution: score * 0.05,
|
||||
explanation: passed ? `Erdgeschoss bestätigt (Etage ${floor})` : `Nicht Erdgeschoss (Etage ${floor}) — EG erforderlich`,
|
||||
}
|
||||
}
|
||||
|
||||
function scoreParkingMin(need: Need, property: Property): ScoreFactor | null {
|
||||
const min = need.requiredParkingMin
|
||||
if (!min || min <= 0) return null
|
||||
const available = property.hardFacts?.parking ?? property.softFactors?.parkingSpots ?? 0
|
||||
if (available === 0 && property.hardFacts?.parking === undefined) {
|
||||
return { criterion: 'parkingMin', weight: 0.04, score: 50, contribution: 2, explanation: `Mind. ${min} Parkplätze erforderlich — keine Daten`, estimated: true }
|
||||
}
|
||||
const ratio = Math.min(1, available / min)
|
||||
const score = available >= min ? 100 : Math.round(ratio * 60)
|
||||
return {
|
||||
criterion: 'parkingMin',
|
||||
weight: 0.04,
|
||||
score,
|
||||
contribution: score * 0.04,
|
||||
explanation: available >= min
|
||||
? `${available} Parkplätze vorhanden (mind. ${min} erforderlich)`
|
||||
: `Nur ${available} Parkplätze (mind. ${min} erforderlich)`,
|
||||
}
|
||||
}
|
||||
|
||||
function scoreAirConditioning(need: Need, property: Property): ScoreFactor | null {
|
||||
if (!need.requireAirConditioning) return null
|
||||
const hasAC = property.hardFacts?.hasAirConditioning
|
||||
if (hasAC === undefined) {
|
||||
return { criterion: 'airConditioning', weight: 0.03, score: 50, contribution: 1.5, explanation: 'Klimaanlage erforderlich — nicht dokumentiert', estimated: true }
|
||||
}
|
||||
const score = hasAC ? 100 : 15
|
||||
return {
|
||||
criterion: 'airConditioning',
|
||||
weight: 0.03,
|
||||
score,
|
||||
contribution: score * 0.03,
|
||||
explanation: hasAC ? 'Klimaanlage vorhanden' : 'Keine Klimaanlage — Klimaanlage erforderlich',
|
||||
}
|
||||
}
|
||||
|
||||
function scoreLoadingDock(need: Need, property: Property): ScoreFactor | null {
|
||||
if (!need.requireLoadingDock) return null
|
||||
const docks = property.hardFacts?.loadingDocksCount
|
||||
if (docks === undefined) {
|
||||
return { criterion: 'loadingDock', weight: 0.05, score: 50, contribution: 2.5, explanation: 'Laderampe erforderlich — keine Daten', estimated: true }
|
||||
}
|
||||
const passed = docks > 0
|
||||
const score = passed ? 100 : 15
|
||||
return {
|
||||
criterion: 'loadingDock',
|
||||
weight: 0.05,
|
||||
score,
|
||||
contribution: score * 0.05,
|
||||
explanation: passed ? `${docks} Laderampe(n) vorhanden` : 'Keine Laderampe vorhanden — erforderlich',
|
||||
}
|
||||
}
|
||||
|
||||
function scoreBarrierFree(need: Need, property: Property): ScoreFactor | null {
|
||||
if (!need.requireBarrierFree) return null
|
||||
const ok = property.hardFacts?.isBarrierFree
|
||||
if (ok === undefined) {
|
||||
return { criterion: 'barrierFree', weight: 0.03, score: 50, contribution: 1.5, explanation: 'Barrierefreiheit erforderlich — nicht dokumentiert', estimated: true }
|
||||
}
|
||||
const score = ok ? 100 : 20
|
||||
return {
|
||||
criterion: 'barrierFree',
|
||||
weight: 0.03,
|
||||
score,
|
||||
contribution: score * 0.03,
|
||||
explanation: ok ? 'Barrierefrei bestätigt' : 'Nicht barrierefrei — Barrierefreiheit erforderlich',
|
||||
}
|
||||
}
|
||||
|
||||
function scoreCeilingHeight(need: Need, property: Property): ScoreFactor | null {
|
||||
const min = need.minCeilingHeightM
|
||||
if (!min || min <= 0) return null
|
||||
const actual = property.hardFacts?.ceilingHeightM
|
||||
if (actual === undefined) {
|
||||
return { criterion: 'ceilingHeight', weight: 0.04, score: 50, contribution: 2, explanation: `Mind. ${min}m Deckenhöhe erforderlich — keine Daten`, estimated: true }
|
||||
}
|
||||
const passed = actual >= min
|
||||
const score = passed ? 100 : Math.max(10, Math.round((actual / min) * 70))
|
||||
return {
|
||||
criterion: 'ceilingHeight',
|
||||
weight: 0.04,
|
||||
score,
|
||||
contribution: score * 0.04,
|
||||
explanation: passed
|
||||
? `Deckenhöhe ${actual}m ≥ Minimum ${min}m`
|
||||
: `Deckenhöhe ${actual}m unter Minimum ${min}m`,
|
||||
}
|
||||
}
|
||||
|
||||
function scoreMinContractDuration(need: Need, property: Property): ScoreFactor | null {
|
||||
const min = need.minContractDurationMonths
|
||||
if (!min || min <= 0) return null
|
||||
const available = property.contractDurationMonths
|
||||
if (available === undefined) {
|
||||
return { criterion: 'contractDuration', weight: 0.03, score: 50, contribution: 1.5, explanation: `Mind. ${Math.round(min / 12)} Jahre Laufzeit gewünscht — keine Daten`, estimated: true }
|
||||
}
|
||||
const passed = available >= min
|
||||
const score = passed ? 100 : Math.max(20, Math.round((available / min) * 70))
|
||||
return {
|
||||
criterion: 'contractDuration',
|
||||
weight: 0.03,
|
||||
score,
|
||||
contribution: score * 0.03,
|
||||
explanation: passed
|
||||
? `${Math.round(available / 12)} Jahre Vertragslaufzeit — erfüllt (mind. ${Math.round(min / 12)} Jahre gewünscht)`
|
||||
: `Nur ${Math.round(available / 12)} Jahre — mind. ${Math.round(min / 12)} Jahre gewünscht`,
|
||||
}
|
||||
}
|
||||
|
||||
function scoreFitOut(need: Need, property: Property): ScoreFactor | null {
|
||||
if (!need.requiredFitOut) return null
|
||||
const propFitOut = property.hardFacts?.fitOut
|
||||
if (!propFitOut) {
|
||||
return { criterion: 'fitOut', weight: 0.04, score: 50, contribution: 2, explanation: `Ausbaustandard ${need.requiredFitOut} erforderlich — keine Daten`, estimated: true }
|
||||
}
|
||||
const reqLevel = FIT_OUT_LEVELS[need.requiredFitOut] ?? 1
|
||||
const propLevel = FIT_OUT_LEVELS[propFitOut] ?? 0
|
||||
const score = propLevel >= reqLevel ? 100 : Math.max(10, Math.round(50 - (reqLevel - propLevel) * 25))
|
||||
const LABELS: Record<string, string> = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' }
|
||||
return {
|
||||
criterion: 'fitOut',
|
||||
weight: 0.04,
|
||||
score,
|
||||
contribution: score * 0.04,
|
||||
explanation: propLevel >= reqLevel
|
||||
? `Ausbaustandard ${LABELS[propFitOut] ?? propFitOut} erfüllt Anforderung ${LABELS[need.requiredFitOut] ?? need.requiredFitOut}`
|
||||
: `${LABELS[propFitOut] ?? propFitOut} — ${LABELS[need.requiredFitOut] ?? need.requiredFitOut} erforderlich`,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Modifier Calculators ──────────────────────────────────────────────────────
|
||||
|
||||
export function calcDataQualityModifier(property: Property): number {
|
||||
@@ -369,13 +545,28 @@ export function calculateScore(need: Need, property: Property): MatchEngineOutpu
|
||||
const profile = resolveProfile(need, property)
|
||||
|
||||
// ── Hard criteria scoring ──────────────────────────────────────────────────
|
||||
const hardFactors: ScoreFactor[] = [
|
||||
const coreHardFactors: ScoreFactor[] = [
|
||||
scoreArea(need, property, profile.area),
|
||||
scoreLocation(need, property, profile.location),
|
||||
scoreBudget(need, property, profile.budget),
|
||||
scoreTiming(need, property, profile.timing),
|
||||
]
|
||||
|
||||
// Structured requirement scorers — optional hard factors
|
||||
const structuredFactors: ScoreFactor[] = [
|
||||
scoreGroundFloor(need, property),
|
||||
scoreParkingMin(need, property),
|
||||
scoreAirConditioning(need, property),
|
||||
scoreLoadingDock(need, property),
|
||||
scoreBarrierFree(need, property),
|
||||
scoreCeilingHeight(need, property),
|
||||
scoreMinContractDuration(need, property),
|
||||
scoreFitOut(need, property),
|
||||
].filter((f): f is ScoreFactor => f !== null)
|
||||
|
||||
const hardFactors = [...coreHardFactors, ...structuredFactors]
|
||||
const hardWeightSum = HARD_CRITERION_KEYS.reduce((s, k) => s + profile[k], 0)
|
||||
+ structuredFactors.reduce((s, f) => s + f.weight, 0)
|
||||
const hardRaw = hardFactors.reduce((s, f) => s + f.contribution, 0)
|
||||
const hardMatchScore = hardWeightSum > 0 ? Math.min(100, Math.round(hardRaw / hardWeightSum)) : 0
|
||||
|
||||
@@ -388,14 +579,21 @@ export function calculateScore(need: Need, property: Property): MatchEngineOutpu
|
||||
const softRaw = allSoftDisplay.filter(f => f.weight > 0).reduce((s, f) => s + f.contribution, 0)
|
||||
const softFactorScore = softWeightSum > 0 ? Math.min(100, Math.round(softRaw / softWeightSum)) : 50
|
||||
|
||||
// ── Must-have criteria evaluation ─────────────────────────────────────────
|
||||
const allMustHaveText = [
|
||||
...(need.mustCriteriaText ?? []),
|
||||
...(need.mustHaveCriteria?.map(c => c.criterion) ?? []),
|
||||
]
|
||||
const mustHaveEval = scoreMustHaves(allMustHaveText, property)
|
||||
|
||||
// ── Modifiers ──────────────────────────────────────────────────────────────
|
||||
const dqMod = calcDataQualityModifier(property)
|
||||
const confMod = calcConfidenceModifier(property)
|
||||
|
||||
// ── Final score: weighted sum of both groups + modifiers ──────────────────
|
||||
// ── Final score: weighted sum of both groups + modifiers + must-have penalty
|
||||
// Each group already normalized 0–100; combine per SCORE_SPLIT, then apply modifiers
|
||||
const baseScore = hardMatchScore * 0.60 + softFactorScore * 0.40
|
||||
const rawFinal = baseScore + dqMod + confMod - hardFilter.severePenalty
|
||||
const rawFinal = baseScore + dqMod + confMod - hardFilter.severePenalty + mustHaveEval.scoreImpact
|
||||
const finalScore = Math.round(Math.min(100, Math.max(0, rawFinal)))
|
||||
|
||||
// ── Factor classification — only use weighted soft factors for positive/negative ──
|
||||
@@ -432,6 +630,7 @@ export function calculateScore(need: Need, property: Property): MatchEngineOutpu
|
||||
tradeOffs,
|
||||
risks,
|
||||
missingData,
|
||||
mustHaveEvaluation: mustHaveEval.results.length > 0 ? mustHaveEval.results : undefined,
|
||||
nextBestActions: [], // filled by rankingEngine
|
||||
}
|
||||
|
||||
|
||||
@@ -368,4 +368,108 @@ export const mockNeeds: Need[] = [
|
||||
createdAt: '2025-04-10T08:00:00Z',
|
||||
updatedAt: '2025-05-06T13:00:00Z',
|
||||
},
|
||||
|
||||
// --- need-uc-a: Renato's Fahrradhändler — Use Case A ---
|
||||
{
|
||||
id: 'need-uc-a',
|
||||
companyName: 'Velo City GmbH',
|
||||
contactName: 'Renato Marchetti',
|
||||
assetType: AssetType.RETAIL,
|
||||
requiredArea: { min: 120, max: 160 },
|
||||
preferredLocations: ['Zürich Kreis 3', 'Zürich Kreis 4', 'Zürich Kreis 5', 'Zürich Kreis 8', 'Wädenswil', 'Thalwil', 'Uster'],
|
||||
budgetRange: { maxPerSqm: 620, currency: 'CHF' },
|
||||
timing: {
|
||||
earliestMoveIn: '2026-10-01',
|
||||
latestMoveIn: '2027-03-31',
|
||||
contractDurationMonths: 60,
|
||||
flexibleTiming: true,
|
||||
},
|
||||
mustCriteriaText: ['Schaufenster', 'Erdgeschoss', 'Werkstatt möglich'],
|
||||
requireGroundFloor: true,
|
||||
requiredFitOut: 'FULL',
|
||||
notes: 'Wunsch: 3.5-Zi-Wohnung im Haus oder in der Nähe, max. CHF 2\'200/Mt. inkl. NK',
|
||||
softFactors: {
|
||||
requireHighVisibility: true,
|
||||
},
|
||||
weightingProfile: {
|
||||
area: 0.13, location: 0.20, budget: 0.14, timing: 0.12,
|
||||
prestige: 0.04, accessibility: 0.07, expansionPotential: 0.02, flexibility: 0.04,
|
||||
visibility: 0.12, footfall: 0.12, talentAccess: 0.01, esg: 0.01, taxEnvironment: 0.00,
|
||||
},
|
||||
confidenceInCriteria: 0.90,
|
||||
extractedFromText: 'Fahrradhändler sucht Verkaufslokal 120–150m² EG mit Schaufenster und Werkstatt in Zürich Kreis 3/4/5/8 oder Wädenswil/Thalwil/Uster, bezugsfertig Q4/2026 oder Q1/2027.',
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2025-05-20T10:00:00Z',
|
||||
updatedAt: '2025-05-22T10:00:00Z',
|
||||
},
|
||||
|
||||
// --- need-uc-b: Renato's Umzugsfirma — Use Case B ---
|
||||
{
|
||||
id: 'need-uc-b',
|
||||
companyName: 'Alp Transit Umzüge GmbH',
|
||||
contactName: 'Renato Marchetti',
|
||||
assetType: AssetType.OFFICE,
|
||||
requiredArea: { min: 140, max: 200 },
|
||||
preferredLocations: ['Zürich', 'Zürich-West', 'Zürich Kreis 5', 'Zürich Kreis 4'],
|
||||
budgetRange: { maxPerSqm: 300, currency: 'CHF' },
|
||||
timing: {
|
||||
earliestMoveIn: '2025-10-01',
|
||||
latestMoveIn: '2026-06-01',
|
||||
contractDurationMonths: 84,
|
||||
flexibleTiming: false,
|
||||
},
|
||||
mustCriteriaText: ['3-5 Parkplätze', 'Verpflegungsmöglichkeit', 'Startup-Umfeld', 'Erweiterungsoption vorhanden', 'Repräsentativer Charakter'],
|
||||
requiredParkingMin: 3,
|
||||
requiredFitOut: 'BASIC',
|
||||
minContractDurationMonths: 84,
|
||||
softFactors: {
|
||||
requireParking: true,
|
||||
},
|
||||
weightingProfile: {
|
||||
area: 0.14, location: 0.17, budget: 0.22, timing: 0.11,
|
||||
prestige: 0.04, accessibility: 0.08, expansionPotential: 0.07, flexibility: 0.07,
|
||||
visibility: 0.02, footfall: 0.01, talentAccess: 0.07, esg: 0.02, taxEnvironment: 0.01,
|
||||
},
|
||||
confidenceInCriteria: 0.88,
|
||||
extractedFromText: 'Büro 140–200m², Zürich-West, max. CHF 300/m²/a, einfacher Ausbau, 3-5 Parkplätze, 7 Jahre Mietvertrag, Startup-Umfeld, Erweiterungsoption.',
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2025-05-22T10:00:00Z',
|
||||
updatedAt: '2025-05-22T10:00:00Z',
|
||||
},
|
||||
|
||||
// --- need-uc-c: Renato's Vermögensverwalter — Use Case C ---
|
||||
{
|
||||
id: 'need-uc-c',
|
||||
companyName: 'Wealth Advisory Partners AG',
|
||||
contactName: 'Renato Marchetti',
|
||||
assetType: AssetType.OFFICE,
|
||||
requiredArea: { min: 450, max: 560 },
|
||||
preferredLocations: ['Zürich', 'Zürich Kreis 1', 'Zürich Seefeld', 'Zürich Innenstadt'],
|
||||
budgetRange: { maxPerSqm: 900, currency: 'CHF' },
|
||||
timing: {
|
||||
earliestMoveIn: '2026-01-01',
|
||||
latestMoveIn: '2027-06-01',
|
||||
contractDurationMonths: 120,
|
||||
flexibleTiming: false,
|
||||
},
|
||||
mustCriteriaText: ['Klimaanlage', 'Tageslicht', 'Prestige-Adresse', 'Repräsentativer Eingang', '2×5-Jahre-Mietoption'],
|
||||
notes: 'Mietvertrag min. 10 Jahre + 2×5 Jahre echte Optionen gefordert',
|
||||
requireAirConditioning: true,
|
||||
requiredFitOut: 'PREMIUM',
|
||||
minContractDurationMonths: 120,
|
||||
softFactors: {
|
||||
minPrestige: 85,
|
||||
minAccessibility: 80,
|
||||
},
|
||||
weightingProfile: {
|
||||
area: 0.10, location: 0.25, budget: 0.09, timing: 0.08,
|
||||
prestige: 0.20, accessibility: 0.10, expansionPotential: 0.02, flexibility: 0.02,
|
||||
visibility: 0.04, footfall: 0.01, talentAccess: 0.06, esg: 0.03, taxEnvironment: 0.05,
|
||||
},
|
||||
confidenceInCriteria: 0.95,
|
||||
extractedFromText: 'Vermögensverwalter sucht repräsentative Bürofläche 500m² in Zürich Kreis 1 oder Seefeld/Bellevue, Klimaanlage, PREMIUM-Ausbau, 10-Jahres-Mietvertrag.',
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2025-05-20T10:00:00Z',
|
||||
updatedAt: '2025-05-22T10:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -68,6 +68,14 @@ function buildNeedInput(
|
||||
confidenceInCriteria: overallConfidence,
|
||||
status,
|
||||
mustCriteriaText: criteria.mustHaveCriteria ?? [],
|
||||
requireGroundFloor: criteria.requireGroundFloor,
|
||||
requiredFitOut: criteria.requiredFitOut,
|
||||
requiredParkingMin: criteria.requiredParkingMin,
|
||||
requireAirConditioning: criteria.requireAirConditioning,
|
||||
requireLoadingDock: criteria.requireLoadingDock,
|
||||
requireBarrierFree: criteria.requireBarrierFree,
|
||||
minCeilingHeightM: criteria.minCeilingHeightM,
|
||||
minContractDurationMonths: criteria.minContractDurationMonths,
|
||||
notes: criteria.notes,
|
||||
extractedFromText: undefined,
|
||||
}
|
||||
|
||||
@@ -66,6 +66,11 @@ const CRITERION_ALIASES: Record<WeightingKey, string[]> = {
|
||||
prestige: ['prestige', 'Prestige'],
|
||||
accessibility: ['accessibility', 'ÖV-Anbindung', 'ÖV', 'Erreichbarkeit'],
|
||||
expansionPotential:['expansionPotential', 'Expansionspotenzial'],
|
||||
flexibility: ['flexibility', 'Flexibilität'],
|
||||
visibility: ['visibility', 'Sichtbarkeit', 'visibilityScore'],
|
||||
footfall: ['footfall', 'Passantenfrequenz', 'passerbyFrequency'],
|
||||
talentAccess: ['talentAccess', 'Talent-Zugang', 'Talente'],
|
||||
esg: ['esg', 'ESG', 'Nachhaltigkeit'],
|
||||
taxEnvironment: ['taxEnvironment', 'Steuerlast', 'Steuerumfeld'],
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function Results() {
|
||||
const [filterSource, setFilterSource] = useState<FilterSource>('ALL')
|
||||
const [sortBy, setSortBy] = useState<SortBy>('score')
|
||||
const [showFutureAvailability, setShowFutureAvailability] = useState(true)
|
||||
const [showOwnProperties, setShowOwnProperties] = useState(false)
|
||||
const [showOwnProperties, setShowOwnProperties] = useState(true)
|
||||
const [view, setView] = useState<'list' | 'grid'>(() =>
|
||||
(localStorage.getItem('view-results') as 'list' | 'grid') ?? 'list'
|
||||
)
|
||||
@@ -66,10 +66,12 @@ export default function Results() {
|
||||
queryClient.invalidateQueries({ queryKey: ['needs'] })
|
||||
}
|
||||
|
||||
const { data: results = [], isLoading } = useUnifiedResults(activeNeed?.id)
|
||||
const { data: results = [], isLoading } = useUnifiedResults(effectiveNeedId)
|
||||
|
||||
const isStaff = true // all roles see portfolio properties in demo
|
||||
|
||||
const filtered = results.filter(r => {
|
||||
if (r.resultType === 'VERIFIED_PORTFOLIO') return showOwnProperties && currentUser?.role === 'PROPERTY_MANAGER'
|
||||
if (r.resultType === 'VERIFIED_PORTFOLIO') return showOwnProperties
|
||||
// Future Availability toggle is independent of the source filter
|
||||
if (r.resultType === 'FUTURE_AVAILABILITY') return showFutureAvailability
|
||||
return filterSource === 'ALL' || r.resultType === filterSource
|
||||
@@ -81,7 +83,7 @@ export default function Results() {
|
||||
const externalCount = results.filter(r => r.resultType === 'EXTERNAL_MARKET').length
|
||||
const maisonWorkCount = results.filter(r => r.resultType === 'MAISON_WORK').length
|
||||
const futureCount = results.filter(r => r.resultType === 'FUTURE_AVAILABILITY').length
|
||||
const strongCount = results.filter(r => r.matchScore >= 80).length
|
||||
const strongCount = filtered.filter(r => r.matchScore >= 80).length
|
||||
const missingDataCount = results.filter(r =>
|
||||
'match' in r && Array.isArray((r as { match?: { missingData?: unknown[] } }).match?.missingData) &&
|
||||
((r as { match?: { missingData?: unknown[] } }).match?.missingData?.length ?? 0) > 0
|
||||
@@ -139,12 +141,12 @@ export default function Results() {
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<strong>Standort:</strong> {activeNeed.preferredLocations.join(', ')}
|
||||
</Typography>
|
||||
{activeNeed.budgetRange && (
|
||||
{activeNeed.budgetRange && activeNeed.budgetRange.maxPerSqm > 0 && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<strong>Budget:</strong> max. CHF {activeNeed.budgetRange.maxPerSqm}/m²
|
||||
</Typography>
|
||||
)}
|
||||
{activeNeed.timing && (
|
||||
{activeNeed.timing?.earliestMoveIn && !isNaN(new Date(activeNeed.timing.earliestMoveIn).getTime()) && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<strong>Bezug ab:</strong> {new Date(activeNeed.timing.earliestMoveIn).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' })}
|
||||
</Typography>
|
||||
@@ -177,7 +179,7 @@ export default function Results() {
|
||||
onShowFutureAvailabilityChange={setShowFutureAvailability}
|
||||
showOwnProperties={showOwnProperties}
|
||||
onShowOwnPropertiesChange={setShowOwnProperties}
|
||||
isPropertyManager={currentUser?.role === 'PROPERTY_MANAGER'}
|
||||
isPropertyManager={isStaff}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
|
||||
@@ -52,6 +52,7 @@ function buildMatch(
|
||||
positiveFactors: output.positiveFactors,
|
||||
negativeFactors: output.negativeFactors,
|
||||
allFactors: [...output.allHardFactors, ...output.allSoftFactors],
|
||||
mustHaveEvaluation: output.mustHaveEvaluation,
|
||||
tradeoffs: output.tradeOffs ?? [],
|
||||
explainabilitySummary: isGoodLoc
|
||||
? `${prop.location.city} trifft den Standortwunsch. Kernkriterien sind weitgehend erfüllt.`
|
||||
|
||||
+187
-23
@@ -139,11 +139,15 @@ export interface AIServiceProvider {
|
||||
function mockParseNeed(input: string): ParseNeedResult {
|
||||
const lower = input.toLowerCase()
|
||||
|
||||
// Asset type
|
||||
// Asset type — RETAIL before LOGISTICS to avoid false match on "Nebenräume (Lager)"
|
||||
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('retail') || lower.includes('laden') || lower.includes('shop')
|
||||
|| lower.includes('verkaufslokal') || lower.includes('ladenlokal') || lower.includes('ladenfläche')
|
||||
|| lower.includes('verkaufsfläche') || lower.includes('schaufenster') ? 'RETAIL'
|
||||
: lower.includes('büro') || lower.includes('office') || lower.includes('sitzungszimmer') || lower.includes('arbeitsplätze') ? 'OFFICE'
|
||||
: lower.includes('logistik') || lower.includes('lagerhalle') || lower.includes('lagerraum')
|
||||
|| (lower.includes('lager') && (lower.includes('logistik') || lower.includes('rampe') || lower.includes('palette') || lower.includes('lkw'))) ? 'LOGISTICS'
|
||||
: lower.includes('lager') ? 'LOGISTICS'
|
||||
: lower.includes('produktion') || lower.includes('gewerbe') || lower.includes('industrie') ? 'PRODUCTION'
|
||||
: lower.includes('gastro') || lower.includes('restaurant') ? 'GASTRO'
|
||||
: undefined
|
||||
@@ -162,18 +166,77 @@ function mockParseNeed(input: string): ParseNeedResult {
|
||||
areaConfidence = 0.70
|
||||
}
|
||||
|
||||
// Locations
|
||||
// Locations — use word boundaries to avoid false matches like "Umzugsfirma" → "Zug"
|
||||
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'],
|
||||
['wädenswil', 'Wädenswil'], ['thalwil', 'Thalwil'], ['uster', 'Uster'],
|
||||
['horgen', 'Horgen'], ['küsnacht', 'Küsnacht'], ['baar', 'Baar'],
|
||||
]
|
||||
const preferredLocations = CITIES.filter(([k]) => lower.includes(k)).map(([, v]) => v)
|
||||
const preferredLocations = CITIES.filter(([k]) => {
|
||||
if (k.includes(' ') || k.includes('.')) return lower.includes(k)
|
||||
// Word boundary: not preceded/followed by a letter (including German umlauts)
|
||||
const re = new RegExp(`(?<![a-zA-ZäöüÄÖÜß])${k}(?![a-zA-ZäöüÄÖÜß])`)
|
||||
return re.test(lower)
|
||||
}).map(([, v]) => v)
|
||||
|
||||
// Infer Zürich when Zürich-specific districts or landmarks are mentioned
|
||||
const ZURICH_SIGNALS = [
|
||||
'seefeld', 'bellevue', 'paradeplatz', 'bahnhofstrasse', 'zürich-west', 'zürich west',
|
||||
'oerlikon', 'altstetten', 'kreis 1', 'kreis 2', 'kreis 3', 'kreis 4', 'kreis 5',
|
||||
'kreis 6', 'kreis 7', 'kreis 8', 'langstrasse', 'hardbrücke', 'freilager',
|
||||
'europaallee', 'zürich nord', 'zürich süd',
|
||||
]
|
||||
if (!preferredLocations.includes('Zürich') && ZURICH_SIGNALS.some(s => lower.includes(s))) {
|
||||
preferredLocations.push('Zürich')
|
||||
}
|
||||
|
||||
// Contextual Zürich inference: only truly Zürich-specific place/brand names
|
||||
const ZURICH_ONLY_SIGNALS = [
|
||||
'industrie groove', 'industrie-groove',
|
||||
'pfingstweidstrasse', 'hardbrücke', 'freilager', 'europaallee',
|
||||
'zürich-west', 'zürich west', 'zürich nord', 'zürich süd',
|
||||
'hürlimann', 'viadukt', 'schiffbau',
|
||||
]
|
||||
if (preferredLocations.length === 0 && ZURICH_ONLY_SIGNALS.some(s => lower.includes(s))) {
|
||||
preferredLocations.push('Zürich')
|
||||
}
|
||||
|
||||
// Extract district list from patterns like "Kreis 3, 4, 5, und 8" or "Kreis 3/4/5"
|
||||
const kreisListMatch = lower.match(/\bkreis\s+([\d]+(?:\s*[,\/]\s*[\d]+)*(?:\s+und\s+[\d]+)?)/)
|
||||
if (kreisListMatch) {
|
||||
const nums = kreisListMatch[1].match(/\d+/g) ?? []
|
||||
nums.forEach(n => {
|
||||
const k = `Zürich Kreis ${n}`
|
||||
if (!preferredLocations.includes(k)) preferredLocations.push(k)
|
||||
})
|
||||
if (!preferredLocations.includes('Zürich')) preferredLocations.push('Zürich')
|
||||
}
|
||||
|
||||
// Map Zürich landmark/neighbourhood names → district-level location entries
|
||||
const ZURICH_DISTRICT_MAP: [string, string][] = [
|
||||
['seefeld', 'Zürich Seefeld'], ['bellevue', 'Zürich Seefeld'],
|
||||
['bahnhofstrasse', 'Zürich Kreis 1'], ['paradeplatz', 'Zürich Kreis 1'],
|
||||
['zürich-west', 'Zürich-West'], ['pfingstweidstrasse', 'Zürich-West'],
|
||||
['limmatstrasse', 'Zürich-West'],
|
||||
]
|
||||
ZURICH_DISTRICT_MAP.forEach(([k, v]) => {
|
||||
if (lower.includes(k) && !preferredLocations.includes(v)) preferredLocations.push(v)
|
||||
})
|
||||
|
||||
const locationConfidence = preferredLocations.length > 0 ? 0.88 : 0.15
|
||||
|
||||
// Budget
|
||||
const budgetPerSqmMatch = input.match(/(\d+)\s*(?:CHF)?\s*\/\s*m[²2]/i)
|
||||
// Budget — strip Swiss price notation (320.--) before matching
|
||||
const cleanedBudget = input.replace(/(\d)\.-+/g, '$1').replace(/(\d)\.—/g, '$1')
|
||||
// "MZ 320/m²", "CHF 320/m²", "320/m²", "320.--/m²/a"
|
||||
const budgetPerSqmMatch = cleanedBudget.match(/(?:mz|mietzins|max\.?|budget)?\s*(?:CHF\s*)?(\d{2,4})\s*\/\s*m[²2]/i)
|
||||
// Range "250 – 280 CHF" or "250-280/m²" — require explicit currency or /m² so area ranges like "120–150m2" are not matched
|
||||
const budgetRangeMatch =
|
||||
cleanedBudget.match(/(\d{2,4})\s*[–\-]\s*(\d{2,4})\s*(?:CHF|Fr\.?)\b/i) ??
|
||||
cleanedBudget.match(/(?:CHF|Fr\.?)\s*(\d{2,4})\s*[–\-]\s*(\d{2,4})/i) ??
|
||||
cleanedBudget.match(/(\d{2,4})\s*[–\-]\s*(\d{2,4})\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
|
||||
@@ -184,6 +247,11 @@ function mockParseNeed(input: string): ParseNeedResult {
|
||||
const raw = parseInt(budgetPerSqmMatch[1])
|
||||
budgetRange = { maxPerSqm: raw < monthlyThreshold ? raw * 12 : raw, currency: 'CHF' }
|
||||
budgetConfidence = 0.92
|
||||
} else if (budgetRangeMatch) {
|
||||
// Take upper value of range as max
|
||||
const raw = parseInt(budgetRangeMatch[2])
|
||||
budgetRange = { maxPerSqm: raw < monthlyThreshold ? raw * 12 : raw, currency: 'CHF' }
|
||||
budgetConfidence = 0.75
|
||||
} else if (budgetMaxMatch) {
|
||||
const raw = parseInt(budgetMaxMatch[1])
|
||||
budgetRange = { maxPerSqm: raw < monthlyThreshold ? raw * 12 : raw, currency: 'CHF' }
|
||||
@@ -191,7 +259,9 @@ function mockParseNeed(input: string): ParseNeedResult {
|
||||
}
|
||||
|
||||
// Timing
|
||||
const yearMatch = input.match(/(?:ab\s+)?(?:Q[1-4]\s*\/?\s*)?(\d{4})/)
|
||||
const yearMatch = input.match(/(?:ab\s+)?(?:Q[1-4]\s*\/?\s*)?(20\d{2})/)
|
||||
// "Q4/26" or "Q1/27" — two-digit year (collect all occurrences, use first as earliest)
|
||||
const quarterMatches = [...input.matchAll(/Q([1-4])\s*[\/\-]?\s*(\d{2})\b/gi)]
|
||||
const soonMatch = lower.includes('sofort') || lower.includes('asap')
|
||||
let timing: ParsedNeedCriteria['timing'] | undefined
|
||||
let timingConfidence = 0.20
|
||||
@@ -201,20 +271,81 @@ function mockParseNeed(input: string): ParseNeedResult {
|
||||
} else if (yearMatch) {
|
||||
timing = { earliestMoveIn: `${yearMatch[1]}-01-01`, latestMoveIn: `${yearMatch[1]}-12-31`, flexibleTiming: lower.includes('flexibel') }
|
||||
timingConfidence = 0.75
|
||||
} else if (quarterMatches.length > 0) {
|
||||
// Use first Q as earliest, last Q as latest (6-month window minimum)
|
||||
const qStartMonth = [0, 1, 4, 7, 10] // [unused, Q1, Q2, Q3, Q4]
|
||||
const first = quarterMatches[0]
|
||||
const last = quarterMatches[quarterMatches.length - 1]
|
||||
const yr1 = 2000 + parseInt(first[2])
|
||||
const yr2 = 2000 + parseInt(last[2])
|
||||
const q1 = parseInt(first[1])
|
||||
const q2 = parseInt(last[1])
|
||||
const startM = String(qStartMonth[q1]).padStart(2, '0')
|
||||
const endM = String(Math.min(12, qStartMonth[q2] + 2)).padStart(2, '0')
|
||||
timing = {
|
||||
earliestMoveIn: `${yr1}-${startM}-01`,
|
||||
latestMoveIn: `${yr2}-${endM}-${endM === '12' ? '31' : '28'}`,
|
||||
flexibleTiming: true,
|
||||
}
|
||||
timingConfidence = 0.70
|
||||
}
|
||||
|
||||
// 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('öv') || lower.includes('bahnhof') || lower.includes('tram') || lower.includes('erschlossen')) mustHaveCriteria.push('Gute ÖV-Anbindung')
|
||||
if (lower.includes('parking') || lower.includes('parkplatz') || lower.includes('tiefgarage') || lower.includes('stellplatz')) 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')
|
||||
if (lower.includes('laderampe') || lower.includes('rampe') || lower.includes('verladetor')) mustHaveCriteria.push('Laderampe')
|
||||
if (lower.includes('schaufenster') || lower.includes('shopfenster') || lower.includes('vitrine')) mustHaveCriteria.push('Schaufenster')
|
||||
if (lower.includes('verpflegung') || lower.includes('restaurant') || lower.includes('lunch') || lower.includes('mittagessen') || lower.includes('takeaway')) mustHaveCriteria.push('Verpflegungsmöglichkeiten')
|
||||
if (lower.includes('startup') || lower.includes('start-up') || lower.includes('community') || lower.includes('coworking') || lower.includes('junge firmen') || lower.includes('jungunternehm')) mustHaveCriteria.push('Startup-Community / innovative Nachbarn')
|
||||
if (lower.includes('wachsen') || lower.includes('expansion') || lower.includes('erweiterungsoption') || lower.includes('wachstum') || lower.includes('wachstumsoption') || lower.includes('flexibilität zum wachsen')) mustHaveCriteria.push('Erweiterungsoption vorhanden')
|
||||
if (lower.includes('werkstatt') || lower.includes('atelier') || lower.includes('reparatur')) mustHaveCriteria.push('Werkstatt / Atelier')
|
||||
|
||||
// Structured requirement fields
|
||||
const requireGroundFloor = lower.includes('erdgeschoss') || lower.includes('parterre')
|
||||
|| lower.includes('schaufenster') || lower.includes('ladenlokal') || undefined as boolean | undefined
|
||||
const requireAirConditioning = lower.includes('klimaanlage') || lower.includes('klimatisierung')
|
||||
|| lower.includes('klima') || lower.includes('air conditioning')
|
||||
|| lower.includes('kühlung') || undefined as boolean | undefined
|
||||
const requireLoadingDock = lower.includes('laderampe') || lower.includes('verladerampe')
|
||||
|| lower.includes('ladetor') || lower.includes('rampe') || undefined as boolean | undefined
|
||||
const requireBarrierFree = lower.includes('barrierefrei') || lower.includes('rollstuhl')
|
||||
|| lower.includes('iv-gerecht') || lower.includes('behindertengerecht') || undefined as boolean | undefined
|
||||
|
||||
const ceilingMatch = input.match(/(\d+(?:[.,]\d+)?)\s*m(?:eter)?\s*(?:deckenhöhe|hallenhöhe|lichte\s+höhe)/i)
|
||||
?? input.match(/deckenhöhe\s+(?:mind\.?\s*)?(\d+(?:[.,]\d+)?)\s*m/i)
|
||||
const minCeilingHeightM = ceilingMatch ? parseFloat(ceilingMatch[1].replace(',', '.')) : undefined
|
||||
|
||||
// Parking: "3-5 Parkplätze" → extract lower bound (minimum); "5 Parkplätze" → 5
|
||||
const parkingRangeMatch = input.match(/(\d+)\s*[-–]\s*\d+\s*(?:parkplätze?|stellplätze?|pp\b)/i)
|
||||
const parkingSingleMatch = input.match(/(?:mind(?:estens)?\.?\s+)?(\d+)\s*(?:parkplätze?|stellplätze?|pp\b)/i)
|
||||
const requiredParkingMin = parkingRangeMatch ? parseInt(parkingRangeMatch[1])
|
||||
: parkingSingleMatch ? parseInt(parkingSingleMatch[1]) : undefined
|
||||
|
||||
const fitOutStr: 'BASIC' | 'FULL' | 'PREMIUM' | undefined =
|
||||
lower.includes('schlüsselfertig') || lower.includes('premium') || lower.includes('hochwertig')
|
||||
|| lower.includes('top ausgebaut') || lower.includes('top-ausgebaut') ? 'PREMIUM'
|
||||
: lower.includes('vollausbau') || lower.includes('vollständig ausgebaut') || lower.includes('ausgebaut')
|
||||
|| lower.includes('ready to use') || lower.includes('reddy to use') || lower.includes('bezugsfertig') ? 'FULL'
|
||||
: lower.includes('basisausbau') || lower.includes('rohbau') || lower.includes('einfach') ? 'BASIC'
|
||||
: undefined
|
||||
|
||||
// Contract duration: "7-jähriger Vertrag", "Laufzeit 7 Jahre", standalone "7 Jahre" at sentence start
|
||||
// Exclude "in X Jahren", "X Jahre im Geschäft", "X Jahre Erfahrung" etc.
|
||||
const contractMatch = input.match(/(\d+)[- ]?j[aä]hrige?(?:r)?\s+(?:vertrag|mietvertrag|laufzeit)/i)
|
||||
?? input.match(/(?:vertrag|laufzeit|mietdauer).{0,20}(\d+)\s*jahre?/i)
|
||||
?? input.match(/\b(\d+)\s*jahre?\b(?!\s*(?:erfahrung|planung|rendite|im\b|alt\b|alten|altes|jung))/i)
|
||||
const minContractDurationMonths = contractMatch ? parseInt(contractMatch[1]) * 12 : undefined
|
||||
|
||||
// Soft
|
||||
const prestigeImportance: 'LOW' | 'MEDIUM' | 'HIGH' | undefined =
|
||||
lower.includes('prestige') || lower.includes('representativ') || lower.includes('repräsentativ') ? 'HIGH'
|
||||
lower.includes('prestige') || lower.includes('representativ') || lower.includes('repräsentativ')
|
||||
|| lower.includes('topadresse') || lower.includes('top adresse') || lower.includes('innerstädtisch')
|
||||
|| lower.includes('topaddresse') ? 'HIGH'
|
||||
: lower.includes('standard') ? 'LOW'
|
||||
: undefined
|
||||
const parkingNeed = lower.includes('parking') || lower.includes('parkplatz')
|
||||
@@ -305,16 +436,40 @@ function mockParseNeed(input: string): ParseNeedResult {
|
||||
importance: 'optional',
|
||||
})
|
||||
|
||||
// Special notes: apartment wish (UC-A type) + lease options (UC-C type)
|
||||
const notesParts: string[] = []
|
||||
const apartmentMatch = input.match(/(\d[.,]\d)\s*zimmer[- ]?wohn/i)
|
||||
?? (lower.includes('zimmer') && lower.includes('wohnung') ? [''] : null)
|
||||
if (apartmentMatch) {
|
||||
const sizeHint = apartmentMatch[1] ? `${apartmentMatch[1]}-Zi-Wohnung` : 'Wohnung'
|
||||
const rentMatch = input.match(/(\d['.\s]?\d{3})[.,\-\s]*(?:inkl|inkl\.)/i)
|
||||
?? input.match(/(\d{4})[.,\-]\s*(?:chf|fr)?/i)
|
||||
const rentHint = rentMatch ? ` max. CHF ${rentMatch[1].replace(/['\s]/g, "'")}` : ''
|
||||
notesParts.push(`Wunsch: ${sizeHint} im Haus oder in der Nähe${rentHint} inkl. NK`)
|
||||
}
|
||||
const leaseOptionMatch = input.match(/(\d+)\s*[*×x]\s*(\d+)\s*jahre?\s*(?:echte\s*)?optione?n?/i)
|
||||
if (leaseOptionMatch) {
|
||||
notesParts.push(`Mietoption: ${leaseOptionMatch[1]}×${leaseOptionMatch[2]} Jahre echte Optionen gefordert`)
|
||||
}
|
||||
const notes = notesParts.length > 0 ? notesParts.join(' | ') : undefined
|
||||
|
||||
// Semantic signal flags used for weight boosting
|
||||
const hasExpansionSignal = lower.includes('wachsen') || lower.includes('wachstum') || lower.includes('expansion') || lower.includes('erweiterung')
|
||||
const hasCommunitySignal = lower.includes('startup') || lower.includes('community') || lower.includes('coworking') || lower.includes('junge firmen')
|
||||
const hasPrestigeSignal = prestigeImportance === 'HIGH' || lower.includes('repräsentativ') || lower.includes('charakter') || lower.includes('beeindrucken')
|
||||
const hasFlexSignal = lower.includes('flexibel') || lower.includes('wachsen') || hasCommunitySignal
|
||||
|
||||
// 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,
|
||||
area: 0.18,
|
||||
location: preferredLocations.length > 0 ? 0.25 : 0.20,
|
||||
budget: budgetRange ? 0.20 : 0.16,
|
||||
timing: timing ? 0.14 : 0.10,
|
||||
prestige: hasPrestigeSignal ? 0.10 : 0.04,
|
||||
accessibility: mustHaveCriteria.includes('Gute ÖV-Anbindung') ? 0.08 : 0.04,
|
||||
expansionPotential: 0.03,
|
||||
flexibility: lower.includes('flexibel') ? 0.07 : 0.03,
|
||||
expansionPotential: hasExpansionSignal ? 0.08 : 0.03,
|
||||
flexibility: hasFlexSignal ? 0.08 : 0.03,
|
||||
talentAccess: hasCommunitySignal ? 0.06 : 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'}`
|
||||
@@ -329,12 +484,21 @@ function mockParseNeed(input: string): ParseNeedResult {
|
||||
mustHaveCriteria,
|
||||
infrastructureRequirements: [],
|
||||
accessibilityRequirements: [],
|
||||
prestigeImportance,
|
||||
flexibilityNeed: lower.includes('flexibel') ? 'HIGH' : 'MEDIUM',
|
||||
expansionPotential: lower.includes('wachstum') || lower.includes('expansion'),
|
||||
prestigeImportance: hasPrestigeSignal ? 'HIGH' : prestigeImportance,
|
||||
flexibilityNeed: hasFlexSignal ? 'HIGH' : 'MEDIUM',
|
||||
expansionPotential: hasExpansionSignal,
|
||||
parkingNeed,
|
||||
visibilityNeed,
|
||||
footfallNeed,
|
||||
requireGroundFloor: requireGroundFloor || undefined,
|
||||
requireAirConditioning: requireAirConditioning || undefined,
|
||||
requireLoadingDock: requireLoadingDock || undefined,
|
||||
requireBarrierFree: requireBarrierFree || undefined,
|
||||
requiredParkingMin,
|
||||
requiredFitOut: fitOutStr,
|
||||
minCeilingHeightM,
|
||||
minContractDurationMonths,
|
||||
notes,
|
||||
},
|
||||
confidenceByField,
|
||||
missingFields,
|
||||
@@ -412,7 +576,7 @@ export const aiService = {
|
||||
|
||||
// F008 methods
|
||||
async parseNeed(input: string): Promise<ItemResponse<ParseNeedResult>> {
|
||||
await new Promise(r => setTimeout(r, 1400))
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
const data = mockParseNeed(input)
|
||||
return { data }
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user