diff --git a/src/components/demand/NeedExtendedRequirements.tsx b/src/components/demand/NeedExtendedRequirements.tsx
index dd6ec4b..bdcbec4 100644
--- a/src/components/demand/NeedExtendedRequirements.tsx
+++ b/src/components/demand/NeedExtendedRequirements.tsx
@@ -1,4 +1,4 @@
-import { Accordion, AccordionDetails, AccordionSummary, Box, Checkbox, FormControlLabel, MenuItem, TextField, Typography } from '@mui/material'
+import { Accordion, AccordionDetails, AccordionSummary, Box, Checkbox, FormControlLabel, MenuItem, Switch, TextField, Typography } from '@mui/material'
import { ChevronDown } from 'lucide-react'
import type { ParsedNeedCriteria } from '../../domain/needBuilder'
@@ -73,6 +73,44 @@ export function NeedExtendedRequirements({ criteria: c, onChange: set }: Props)
slotProps={{ htmlInput: { min: 0, max: 360, step: 12 } }}
/>
+
+ Eigenes Ausbaubudget (max. CHF/m²)
+ set({ ...c, fitOutBudgetMaxPerSqm: parseInt(e.target.value) || undefined })}
+ slotProps={{ htmlInput: { min: 0, max: 5000, step: 50 } }}
+ helperText="Ihr Beitrag — exkl. MAB des Vermieters"
+ />
+
+
+
+ {/* Divisibility */}
+
+ set({ ...c, requiresDivisibility: e.target.checked || undefined })}
+ />
+ }
+ label={Fläche muss teilbar sein}
+ sx={{ mb: c.requiresDivisibility ? 1 : 0 }}
+ />
+ {c.requiresDivisibility && (
+
+ Mindesteinheit (m²)
+ set({ ...c, minDivisibleUnit: parseInt(e.target.value) || undefined })}
+ sx={{ width: 160 }}
+ slotProps={{ htmlInput: { min: 10, max: 5000, step: 10 } }}
+ />
+
+ )}
diff --git a/src/components/demand/NeedInput.tsx b/src/components/demand/NeedInput.tsx
index 9ddb5a9..129600f 100644
--- a/src/components/demand/NeedInput.tsx
+++ b/src/components/demand/NeedInput.tsx
@@ -1,5 +1,5 @@
import { useState } from 'react'
-import { Box, Card, Chip, Stack, TextField, Typography } from '@mui/material'
+import { Box, Card, Chip, FormControlLabel, Slider, Stack, Switch, TextField, Typography } from '@mui/material'
import type { ParsedNeedCriteria } from '../../domain/needBuilder'
import { AssetType } from '../../domain/enums'
import { NeedExtendedRequirements } from './NeedExtendedRequirements'
@@ -34,6 +34,8 @@ const BUDGET_PRESETS = [
const LOCATION_PRESETS = ['Zürich', 'Zürich-West', 'Zürich-Nord', 'Bern', 'Basel', 'Luzern']
+const RADIUS_MARKS = [5, 10, 20, 30, 50, 100].map(v => ({ value: v, label: v === 5 || v === 100 ? `${v} km` : `${v}` }))
+
function FieldLabel({ children }: { children: React.ReactNode }) {
return (
@@ -161,7 +163,27 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
))}
)}
- !LOCATION_PRESETS.includes(l)).length ?? 0) > 0 ? 0 : 2.5 }} />
+ {(c.preferredLocations?.filter(l => !LOCATION_PRESETS.includes(l)).length ?? 0) === 0 && }
+
+ {/* Radius */}
+
+
+ Suchradius
+
+ {c.searchRadius ?? 30} km
+
+
+ set({ ...c, searchRadius: v as number })}
+ size="small"
+ sx={{ color: '#1e3a5f', '& .MuiSlider-markLabel': { fontSize: '0.62rem' } }}
+ />
+
{/* Budget */}
Budget (max CHF/m²/Jahr)
@@ -220,6 +242,30 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
)}
+ {/* Anonymous search */}
+
+
+
+ Anonyme Suche
+
+
+ Firmenname wird nicht an Vermieter übermittelt
+
+
+ set({ ...c, isAnonymous: e.target.checked })}
+ sx={{ '& .MuiSwitch-thumb': { bgcolor: c.isAnonymous ? '#7c3aed' : undefined }, '& .Mui-checked + .MuiSwitch-track': { bgcolor: '#7c3aed' } }}
+ />
+ }
+ label=""
+ sx={{ m: 0 }}
+ />
+
+
)
diff --git a/src/components/match-card/MatchCardCompact.tsx b/src/components/match-card/MatchCardCompact.tsx
index 57c76c9..ed524a4 100644
--- a/src/components/match-card/MatchCardCompact.tsx
+++ b/src/components/match-card/MatchCardCompact.tsx
@@ -115,6 +115,35 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
{vm.availabilityLabel && (
)}
+ {vm.fitOutLabel && (
+
+ )}
+ {vm.fitOutInvestment && !vm.fitOutInvestment.isFullyCovered && (
+ 100000 ? '#d97706' : '#94a3b8',
+ color: vm.fitOutInvestment.netTotal.max > 100000 ? '#92400e' : '#64748b',
+ }}
+ />
+ )}
+ {vm.isDivisible && vm.minDivisibleUnitSqm && (
+
+ )}
{topRisk && (
= {
+ SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau',
+}
+
+const AMORTIZATION_YEARS = 5
+const READY_TO_MOVE_IN = new Set(['FULL', 'PREMIUM'])
+
+interface Props {
+ fitOut: string
+ areaSqm: number
+ mabPerSqm: number
+ rentPricePerSqm: number
+ tenantBudgetPerSqm?: number
+}
+
+function chf(value: number): string {
+ return `CHF ${Math.round(value).toLocaleString('de-CH')}.–`
+}
+
+function chfRange(min: number, max: number): string {
+ if (Math.round(min) === Math.round(max)) return chf(min)
+ return `${chf(min)} – ${chf(max)}`
+}
+
+interface RowProps { label: string; value: string; sub?: string; isTotal?: boolean; isWarning?: boolean }
+
+function Row({ label, value, sub, isTotal, isWarning }: RowProps) {
+ return (
+
+
+ {label}
+ {sub && {sub}}
+
+
+ {value}
+
+
+ )
+}
+
+export function FitOutCostPanel({ fitOut, areaSqm, mabPerSqm, rentPricePerSqm, tenantBudgetPerSqm = 0 }: Props) {
+ const rentPerYear = rentPricePerSqm * areaSqm
+ const isReadyToMoveIn = READY_TO_MOVE_IN.has(fitOut)
+ const fitOutLabel = FIT_OUT_LABELS[fitOut] ?? fitOut
+
+ const investment = isReadyToMoveIn
+ ? null
+ : calcFitOutInvestment(fitOut, areaSqm, mabPerSqm, tenantBudgetPerSqm)
+
+ const fitOutPerYear = investment && !investment.isFullyCovered ? {
+ min: Math.round(investment.netTotal.min / AMORTIZATION_YEARS),
+ max: Math.round(investment.netTotal.max / AMORTIZATION_YEARS),
+ } : { min: 0, max: 0 }
+
+ const totalPerYear = {
+ min: rentPerYear + fitOutPerYear.min,
+ max: rentPerYear + fitOutPerYear.max,
+ }
+ const isWarning = !isReadyToMoveIn && totalPerYear.max > rentPerYear * 1.3
+ const disclaimer = isReadyToMoveIn
+ ? null
+ : 'Ausbaukosten nach CRB/BKP-Normen, amortisiert über 5 Jahre. Tatsächliche Kosten je nach Ausbauumfang.'
+
+ return (
+
+
+
+ Reale Jahresbelastung
+ {!isReadyToMoveIn && (
+
+ )}
+
+
+
+
+ {isReadyToMoveIn ? (
+
+ ) : investment?.isFullyCovered ? (
+
+ ) : (
+ 0 ? ` abzgl. CHF ${mabPerSqm} MAB` : '') +
+ ` × ${areaSqm.toLocaleString('de-CH')} m² ÷ ${AMORTIZATION_YEARS} J.`
+ }
+ />
+ )}
+
+
+
+ {disclaimer && (
+
+ {disclaimer}
+
+ )}
+
+ )
+}
diff --git a/src/components/match-detail/MatchDetailHero.tsx b/src/components/match-detail/MatchDetailHero.tsx
index 68bab31..988d525 100644
--- a/src/components/match-detail/MatchDetailHero.tsx
+++ b/src/components/match-detail/MatchDetailHero.tsx
@@ -19,6 +19,10 @@ interface MatchDetailHeroProps {
keyFacts: Array<{ label: string; value: string }>
onCompare: () => void
onShortlist: () => void
+ searchCenterLat?: number
+ searchCenterLng?: number
+ searchRadiusKm?: number
+ searchLabel?: string
}
export function MatchDetailHero({
@@ -32,6 +36,10 @@ export function MatchDetailHero({
keyFacts,
onCompare,
onShortlist,
+ searchCenterLat,
+ searchCenterLng,
+ searchRadiusKm,
+ searchLabel,
}: MatchDetailHeroProps) {
return (
<>
@@ -51,6 +59,10 @@ export function MatchDetailHero({
lng={property.location.coordinates.lng}
label={property.title}
height={340}
+ searchCenterLat={searchCenterLat}
+ searchCenterLng={searchCenterLng}
+ searchRadiusKm={searchRadiusKm}
+ searchLabel={searchLabel}
/>
) : null
)}
diff --git a/src/components/match-detail/MatchDetailPropertySections.tsx b/src/components/match-detail/MatchDetailPropertySections.tsx
index 1a9bc6e..991444a 100644
--- a/src/components/match-detail/MatchDetailPropertySections.tsx
+++ b/src/components/match-detail/MatchDetailPropertySections.tsx
@@ -1,5 +1,5 @@
import { Box, Button, Chip, Paper, Typography } from '@mui/material'
-import { Building2, Clock, ExternalLink, Info, Layers, Tag, Train, TrendingUp } from 'lucide-react'
+import { Building2, Clock, ExternalLink, HardHat, Info, Layers, Tag, Train, TrendingUp } from 'lucide-react'
import { useMatchDetail } from '../../hooks/useMatches'
import { ResultType } from '../../domain/enums'
import type { Property } from '../../domain/property'
@@ -63,6 +63,12 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
{property.breakoutOption && }
{property.riskLevel && }
{property.expansionPotentialSqm != null && }
+ {property.hardFacts?.fitOut && (() => {
+ const LABELS: Record = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' }
+ const base = LABELS[property.hardFacts!.fitOut!] ?? property.hardFacts!.fitOut!
+ const mab = property.hardFacts!.mieterausbaubeitragPerSqm
+ return
+ })()}
{/* Eigenschaften */}
diff --git a/src/components/match-detail/MatchDetailScoreBreakdown.tsx b/src/components/match-detail/MatchDetailScoreBreakdown.tsx
index 83e7af2..6ffd7bc 100644
--- a/src/components/match-detail/MatchDetailScoreBreakdown.tsx
+++ b/src/components/match-detail/MatchDetailScoreBreakdown.tsx
@@ -18,6 +18,7 @@ import type { Property } from '../../domain/property'
import type { Need } from '../../domain/need'
import type { FutureSignal } from '../../domain/futureSignal'
import { DS_TEXT, DS_BORDER, DS_BG } from '../../lib/ds'
+import { lookupCityCoords } from '../../lib/locationIntelligence'
type Match = NonNullable['data']>
@@ -40,6 +41,9 @@ export const MatchDetailScoreBreakdown = memo(function MatchDetailScoreBreakdown
}: Props) {
const [showFullAnalysis, setShowFullAnalysis] = useState(false)
+ const searchCenter = need?.preferredLocations?.[0] ? lookupCityCoords(need.preferredLocations[0]) : null
+ const searchLabel = need?.preferredLocations?.[0]
+
return (
<>
{/* ── Full analysis toggle ── */}
@@ -77,6 +81,10 @@ export const MatchDetailScoreBreakdown = memo(function MatchDetailScoreBreakdown
lng={property.location.coordinates.lng}
label={property.title}
height={220}
+ searchCenterLat={searchCenter?.lat}
+ searchCenterLng={searchCenter?.lng}
+ searchRadiusKm={need?.searchRadius}
+ searchLabel={searchLabel}
/>
)}
diff --git a/src/components/match-detail/PropertyOverviewPanel.tsx b/src/components/match-detail/PropertyOverviewPanel.tsx
index e43af4a..1440831 100644
--- a/src/components/match-detail/PropertyOverviewPanel.tsx
+++ b/src/components/match-detail/PropertyOverviewPanel.tsx
@@ -1,5 +1,5 @@
import { Box, Chip, Paper, Typography } from '@mui/material'
-import { Banknote, Calendar, MapPin, Maximize2, Tag } from 'lucide-react'
+import { Banknote, Calendar, HardHat, MapPin, Maximize2, Tag } from 'lucide-react'
import type { Match } from '../../domain/match'
import type { Property } from '../../domain/property'
import type { FutureSignal } from '../../domain/futureSignal'
@@ -42,6 +42,16 @@ export function PropertyOverviewPanel({ match, property, signal }: Props) {
{ icon: , label: 'Mietpreis', value: property?.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²/Jahr` : '–' },
{ icon: , label: 'Verfügbar ab', value: property?.availabilityDate ?? '–' },
{ icon: , label: 'Objekttyp', value: property?.assetType ?? '–' },
+ ...(property?.hardFacts?.fitOut ? [{
+ icon: ,
+ label: 'Ausbaustandard',
+ value: (() => {
+ const LABELS: Record = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' }
+ const base = LABELS[property.hardFacts!.fitOut!] ?? property.hardFacts!.fitOut!
+ const mab = property.hardFacts!.mieterausbaubeitragPerSqm
+ return mab ? `${base} + CHF ${mab}/m² MAB` : base
+ })(),
+ }] : []),
]
return (
diff --git a/src/components/match-detail/index.ts b/src/components/match-detail/index.ts
index 11aadff..67f8ca0 100644
--- a/src/components/match-detail/index.ts
+++ b/src/components/match-detail/index.ts
@@ -12,4 +12,5 @@ export { MissingInformationPanel } from './MissingInformationPanel'
export { SourceProvenancePanel } from './SourceProvenancePanel'
export { FutureAvailabilityContextPanel } from './FutureAvailabilityContextPanel'
export { NextActionsPanel } from './NextActionsPanel'
+export { FitOutCostPanel } from './FitOutCostPanel'
export { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitStatusChip, UnitRow } from './MatchDetailPropertyDetails'
diff --git a/src/components/new-listing/TechnicalDetailsSection.tsx b/src/components/new-listing/TechnicalDetailsSection.tsx
index 1329fea..02505c3 100644
--- a/src/components/new-listing/TechnicalDetailsSection.tsx
+++ b/src/components/new-listing/TechnicalDetailsSection.tsx
@@ -1,4 +1,4 @@
-import { Box, Card, MenuItem, TextField, Typography } from '@mui/material'
+import { Box, Card, FormControlLabel, MenuItem, Switch, TextField, Typography } from '@mui/material'
import { FIT_OUT_OPTIONS } from '../../pages/supply/newListingConstants'
interface Props {
@@ -10,6 +10,12 @@ interface Props {
onParkingChange: (v: string) => void
ceilingHeight: string
onCeilingHeightChange: (v: string) => void
+ mieterausbaubeitrag: string
+ onMieterausbaubeitragChange: (v: string) => void
+ isFlexible: boolean
+ onIsFlexibleChange: (v: boolean) => void
+ minLettableSqm: string
+ onMinLettableSqmChange: (v: string) => void
}
export function TechnicalDetailsSection({
@@ -17,6 +23,9 @@ export function TechnicalDetailsSection({
fitOut, onFitOutChange,
parking, onParkingChange,
ceilingHeight, onCeilingHeightChange,
+ mieterausbaubeitrag, onMieterausbaubeitragChange,
+ isFlexible, onIsFlexibleChange,
+ minLettableSqm, onMinLettableSqmChange,
}: Props) {
return (
@@ -35,6 +44,14 @@ export function TechnicalDetailsSection({
))}
+ onMieterausbaubeitragChange(e.target.value)}
+ size="small" type="number" fullWidth
+ slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
+ helperText="Beitrag des Vermieters"
+ />
onParkingChange(e.target.value)}
@@ -46,6 +63,40 @@ export function TechnicalDetailsSection({
size="small" type="number" slotProps={{ htmlInput: { step: 0.1, min: 2 } }} fullWidth
/>
+
+ {/* Divisibility */}
+
+ onIsFlexibleChange(e.target.checked)}
+ size="small"
+ />
+ }
+ label={
+
+ Fläche ist teilbar
+
+ Mieter können auch nur einen Teil der Fläche mieten
+
+
+ }
+ sx={{ m: 0, flex: 1 }}
+ />
+ {isFlexible && (
+ onMinLettableSqmChange(e.target.value)}
+ size="small"
+ type="number"
+ sx={{ width: 180 }}
+ slotProps={{ htmlInput: { min: 10, step: 10 } }}
+ helperText="Kleinste vermietbare Einheit"
+ />
+ )}
+
)
}
diff --git a/src/components/shared/PropertyMap.tsx b/src/components/shared/PropertyMap.tsx
index fd20395..9d09233 100644
--- a/src/components/shared/PropertyMap.tsx
+++ b/src/components/shared/PropertyMap.tsx
@@ -1,6 +1,6 @@
import { useEffect } from 'react'
-import { Box } from '@mui/material'
-import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet'
+import { Box, Typography } from '@mui/material'
+import { MapContainer, TileLayer, Marker, Popup, Circle, useMap } from 'react-leaflet'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
@@ -12,11 +12,21 @@ L.Icon.Default.mergeOptions({
shadowUrl: new URL('leaflet/dist/images/marker-shadow.png', import.meta.url).href,
})
+const searchIcon = L.divIcon({
+ className: '',
+ html: ``,
+ iconAnchor: [7, 7],
+})
+
interface Props {
lat: number
lng: number
label?: string
height?: number
+ searchCenterLat?: number
+ searchCenterLng?: number
+ searchRadiusKm?: number
+ searchLabel?: string
}
function RecenterOnChange({ lat, lng }: { lat: number; lng: number }) {
@@ -27,34 +37,85 @@ function RecenterOnChange({ lat, lng }: { lat: number; lng: number }) {
return null
}
-export function PropertyMap({ lat, lng, label, height = 260 }: Props) {
+function FitToRadius({ propLat, propLng, searchLat, searchLng, radiusKm }: {
+ propLat: number; propLng: number
+ searchLat: number; searchLng: number; radiusKm: number
+}) {
+ const map = useMap()
+ useEffect(() => {
+ const circleBounds = L.latLng(searchLat, searchLng).toBounds(radiusKm * 1000 * 2)
+ const bounds = circleBounds.extend([propLat, propLng])
+ map.fitBounds(bounds, { padding: [30, 30] })
+ }, [map, propLat, propLng, searchLat, searchLng, radiusKm])
+ return null
+}
+
+export function PropertyMap({ lat, lng, label, height = 260, searchCenterLat, searchCenterLng, searchRadiusKm, searchLabel }: Props) {
+ const hasSearch = searchCenterLat !== undefined && searchCenterLng !== undefined && searchRadiusKm !== undefined
+
return (
-
+
-
+ {hasSearch
+ ?
+ :
+ }
+
+ {/* Search radius circle */}
+ {hasSearch && (
+
+ )}
+
+ {/* Search center marker */}
+ {hasSearch && (
+
+ {searchLabel ?? `Suchradius: ${searchRadiusKm} km`}
+
+ )}
+
+ {/* Property marker */}
{label && {label}}
+
+ {/* Legend overlay */}
+ {hasSearch && (
+
+
+
+
+ {searchLabel ?? 'Suchzentrum'} · {searchRadiusKm} km Radius
+
+
+
+
+ Objekt
+
+
+ )}
)
}
diff --git a/src/components/supply/PropertyDetailOverview.tsx b/src/components/supply/PropertyDetailOverview.tsx
index 859f3a4..c677590 100644
--- a/src/components/supply/PropertyDetailOverview.tsx
+++ b/src/components/supply/PropertyDetailOverview.tsx
@@ -1,4 +1,4 @@
-import { Box, Button, Divider, LinearProgress, TextField, Typography } from '@mui/material'
+import { Box, Button, Divider, LinearProgress, MenuItem, TextField, Typography } from '@mui/material'
import { ExternalLink, FileText } from 'lucide-react'
import type { Property, UpdatePropertyInput } from '../../domain/property'
import { PropertyMap } from '../shared'
@@ -179,15 +179,59 @@ export function PropertyDetailOverview({ p, editing, draft, onDraftChange }: Pro
{/* Object details */}
-
- {p.propertyNumber && }
-
-
-
-
-
-
-
+ {editing ? (
+
+ onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), fitOut: (e.target.value || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined } })}
+ >
+ {[
+ { value: '', label: 'Keine Angabe' },
+ { value: 'SHELL', label: 'Rohbau' },
+ { value: 'BASIC', label: 'Basisausbau' },
+ { value: 'FULL', label: 'Vollausbau' },
+ { value: 'PREMIUM', label: 'Premiumausbau' },
+ ].map(o => )}
+
+ onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), mieterausbaubeitragPerSqm: parseInt(e.target.value) || undefined } })}
+ />
+ onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), parking: parseInt(e.target.value) || undefined } })}
+ />
+ onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), ceilingHeightM: parseFloat(e.target.value) || undefined } })}
+ />
+
+ ) : (
+
+ {p.propertyNumber && }
+
+
+
+ {p.hardFacts?.mieterausbaubeitragPerSqm && (
+
+ )}
+
+
+
+
+ )}
{/* Map */}
{p.location.coordinates && (
diff --git a/src/components/supply/UnitStructurePanel.tsx b/src/components/supply/UnitStructurePanel.tsx
index 751498f..57f2a0c 100644
--- a/src/components/supply/UnitStructurePanel.tsx
+++ b/src/components/supply/UnitStructurePanel.tsx
@@ -1,9 +1,10 @@
import { useMemo, useState } from 'react'
-import { Box, Button, Checkbox, Chip, Collapse, Divider, IconButton, Tooltip, Typography } from '@mui/material'
+import { Box, Button, Checkbox, Chip, Collapse, Divider, IconButton, Switch, TextField, Tooltip, Typography } from '@mui/material'
import { ChevronDown, ChevronUp, Layers, Users } from 'lucide-react'
import { useNavigate } from 'react-router'
import type { Property, PropertyUnit, UnitNeedMatch } from '../../domain/property'
import { useUnitMatchesMap, useUnitBundle, useBundleMatches } from '../../hooks/useUnitMatches'
+import { useUpdateUnit } from '../../hooks/useProperties'
import { DS_BORDER, DS_PRE_MARKET, DS_SURFACE, DS_TEXT } from '../../lib/ds'
import { floorLabel } from './PropertyDetailHelpers'
@@ -27,6 +28,9 @@ export function UnitStructurePanel({ p }: { p: Property }) {
const navigate = useNavigate()
const [selectedIds, setSelectedIds] = useState>(new Set())
const [expandedUnit, setExpandedUnit] = useState(null)
+ const [editingFlexUnit, setEditingFlexUnit] = useState(null)
+ const [flexDraft, setFlexDraft] = useState>({})
+ const updateUnit = useUpdateUnit(p.id)
const freeUnits = useMemo(() => (p.units ?? []).filter(u => u.available), [p.units])
@@ -114,9 +118,65 @@ export function UnitStructurePanel({ p }: { p: Property }) {
{u.available ? (
<>
- {u.isFlexible && (
-
+
+ {/* Teilbar toggle */}
+
+
+ {
+ const flexible = e.target.checked
+ if (flexible) {
+ setEditingFlexUnit(u.id)
+ setFlexDraft(d => ({ ...d, [u.id]: u.minLettableSqm }))
+ } else {
+ updateUnit.mutate({ unitId: u.id, data: { isFlexible: false, minLettableSqm: undefined } })
+ setEditingFlexUnit(null)
+ }
+ }}
+ sx={{ '& .MuiSwitch-thumb': { width: 10, height: 10 }, '& .MuiSwitch-switchBase': { p: '4px' }, '& .Mui-checked + .MuiSwitch-track': { bgcolor: '#1d4ed8' } }}
+ />
+
+ Teilbar
+
+
+
+
+ {/* Inline min-unit editor */}
+ {editingFlexUnit === u.id && (
+
+ setFlexDraft(d => ({ ...d, [u.id]: parseInt(e.target.value) || undefined }))}
+ sx={{ width: 72, '& .MuiInputBase-input': { fontSize: '0.68rem', py: 0.375, px: 0.75 } }}
+ slotProps={{ htmlInput: { min: 10, max: u.areaSqm, step: 10 } }}
+ />
+
+
+
)}
+
)}
+ {activeNeed.searchRadius && (
+
+ Radius: {activeNeed.searchRadius} km
+
+ )}
+ {activeNeed.requiresDivisibility && activeNeed.minDivisibleUnit && (
+
+ Teilbar ab: {activeNeed.minDivisibleUnit} m²
+
+ )}
+ {activeNeed.requiredFitOut && (
+
+ Ausbaustandard: min. {
+ activeNeed.requiredFitOut === 'BASIC' ? 'Basisausbau' :
+ activeNeed.requiredFitOut === 'FULL' ? 'Vollausbau' : 'Premiumausbau'
+ }
+
+ )}
+ {activeNeed.fitOutBudgetMaxPerSqm && (
+
+ Ausbaubudget: max. CHF {activeNeed.fitOutBudgetMaxPerSqm}/m²
+
+ )}
{activeNeed.mustCriteriaText && activeNeed.mustCriteriaText.length > 0 && (
Must-haves: {activeNeed.mustCriteriaText.slice(0, 3).join(' · ')}
@@ -168,6 +191,14 @@ export default function Results() {
)}
+
+ {activeNeed.isAnonymous && (
+
+ )}
+
)}
diff --git a/src/pages/supply/NewListing.tsx b/src/pages/supply/NewListing.tsx
index 20bd8ee..11d3dd9 100644
--- a/src/pages/supply/NewListing.tsx
+++ b/src/pages/supply/NewListing.tsx
@@ -83,6 +83,9 @@ export default function NewListing() {
fitOut={form.fitOut} onFitOutChange={form.setFitOut}
parking={form.parking} onParkingChange={form.setParking}
ceilingHeight={form.ceilingHeight} onCeilingHeightChange={form.setCeilingHeight}
+ mieterausbaubeitrag={form.mieterausbaubeitrag} onMieterausbaubeitragChange={form.setMieterausbaubeitrag}
+ isFlexible={form.isFlexible} onIsFlexibleChange={form.setIsFlexible}
+ minLettableSqm={form.minLettableSqm} onMinLettableSqmChange={form.setMinLettableSqm}
/>
>
+ // Fit-out investment advice (demand side)
+ generateFitOutAdvice(input: FitOutAdviceInput): Promise>
+
// Legacy methods
extractCriteria(input: string): Promise>
generateFollowUp(partialNeed: Partial): Promise>
diff --git a/src/services/ai/backend/BackendAIService.ts b/src/services/ai/backend/BackendAIService.ts
new file mode 100644
index 0000000..33bf972
--- /dev/null
+++ b/src/services/ai/backend/BackendAIService.ts
@@ -0,0 +1,671 @@
+/**
+ * Backend AI Service — PowerOn Proxy
+ *
+ * Routes all LLM calls through the PowerOn backend. The LLM provider API key
+ * is stored ONLY server-side and never reaches the browser bundle.
+ *
+ * ┌─────────────────────────────────────────────────────────────────────────┐
+ * │ PowerOn Backend Contract │
+ * │ │
+ * │ Endpoint: POST /api/ai/chat/completions │
+ * │ Headers: Content-Type: application/json │
+ * │ (session auth cookie handled by backend — no API key here) │
+ * │ │
+ * │ Request body: │
+ * │ { │
+ * │ messages: { role: 'system' | 'user'; content: string }[] │
+ * │ } │
+ * │ │
+ * │ Response (OpenAI-compatible): │
+ * │ { │
+ * │ choices: [{ message: { content: string } }] │
+ * │ } │
+ * │ │
+ * │ The backend adds: │
+ * │ - Authorization: Bearer (server-side env var) │
+ * │ - Model selection / routing │
+ * │ - Rate limiting & audit logging │
+ * └─────────────────────────────────────────────────────────────────────────┘
+ *
+ * Dev setup — add to vite.config.ts:
+ * server: { proxy: { '/api': process.env.AI_BACKEND_URL ?? 'http://localhost:3001' } }
+ *
+ * Every method follows this contract:
+ * 1. HTTP error → error log + MockAIService fallback
+ * 2. JSON parse fail → warn + MockAIService fallback
+ * 3. Zod schema fail → warn + MockAIService fallback
+ * 4. Success → AI response, source: 'ai', validationPassed: true
+ */
+import type { CreateNeedInput } from '../../../domain/need'
+import type { ParseNeedResult, ParsedNeedCriteria, FollowUpQuestion } from '../../../domain/needBuilder'
+import type { UnifiedMatchResult } from '../../../domain/unifiedResult'
+import type { AssetType } from '../../../domain/enums'
+import type {
+ IAIService,
+ AIResponse,
+ AIProvenance,
+ DecisionBrief,
+ ComparisonSummary,
+ CriteriaExtractionResult,
+ OfferEmailPayload,
+ MatchExplanationInput,
+ MatchExplanation,
+ TradeOffInput,
+ TradeOffSummary,
+ DataQualityInput,
+ DataQualitySummary,
+ MarketSignalClassification,
+ FitOutAdviceInput,
+ FitOutAdvice,
+} from '../IAIService'
+import { ServiceErrorCode } from '../../types'
+import { AppError } from '../../errors'
+import { aiTraceStore, provenanceToStatus } from '../tracing'
+import type { AITraceErrorType, AITraceValidationStatus } from '../tracing'
+import {
+ NeedParsingResponseSchema,
+ FollowUpQuestionsResponseSchema,
+ TradeOffSummaryResponseSchema,
+ CompareSummaryResponseSchema,
+ DecisionBriefResponseSchema,
+ DataQualitySummaryResponseSchema,
+ MarketSignalClassificationResponseSchema,
+ OfferEmailResponseSchema,
+ validateAIResponse,
+} from '../schemas'
+import { buildNeedParsingPrompt } from '../prompts/needParsingPrompt'
+import { buildFollowUpQuestionsPrompt } from '../prompts/followUpQuestionsPrompt'
+import { buildMatchExplanationPrompt } from '../prompts/matchExplanationPrompt'
+import { buildTradeOffPrompt } from '../prompts/tradeOffPrompt'
+import { buildCompareSummaryPrompt } from '../prompts/compareSummaryPrompt'
+import { buildDecisionBriefPrompt } from '../prompts/decisionBriefPrompt'
+import { buildDataQualityPrompt } from '../prompts/dataQualityPrompt'
+import { buildMarketSignalPrompt } from '../prompts/marketSignalPrompt'
+import { MockAIService } from '../mock/MockAIService'
+
+// ── Config ────────────────────────────────────────────────────────────────────
+
+/** Relative URL — resolved by Vite proxy in dev, by the same-origin backend in prod. */
+const API_BASE = '/api/ai'
+
+/**
+ * Placeholder recorded in traces. The actual model is backend-controlled;
+ * PowerOn may return it in a response extension field in future.
+ */
+const BACKEND_MODEL_PLACEHOLDER = 'backend-controlled'
+
+const PROMPT_VERSION = 'v1.1'
+const SCHEMA_VERSION = 'v1.0'
+
+// ── Provenance ────────────────────────────────────────────────────────────────
+
+function makeProvenance(
+ source: AIProvenance['source'],
+ fallbackUsed: boolean,
+ validationPassed: boolean,
+ extras: { fallbackReason?: string } = {},
+): AIProvenance {
+ return {
+ provider: 'backend',
+ model: BACKEND_MODEL_PLACEHOLDER,
+ generatedAt: new Date().toISOString(),
+ promptVersion: PROMPT_VERSION,
+ schemaVersion: SCHEMA_VERSION,
+ source,
+ fallbackUsed,
+ validationPassed,
+ traceId: crypto.randomUUID(),
+ fallbackReason: extras.fallbackReason,
+ }
+}
+
+// ── HTTP helper ───────────────────────────────────────────────────────────────
+
+async function chat(system: string, user: string): Promise {
+ const res = await fetch(`${API_BASE}/chat/completions`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ // No Authorization header — the API key lives server-side only.
+ },
+ body: JSON.stringify({
+ messages: [
+ { role: 'system', content: system },
+ { role: 'user', content: user },
+ ],
+ }),
+ })
+ if (!res.ok) {
+ const body = await res.text()
+ throw new AppError({
+ code: ServiceErrorCode.AI_GENERATION_FAILED,
+ // Truncate to avoid leaking full backend error detail to the console.
+ message: `Backend AI error ${res.status}: ${body.slice(0, 200)}`,
+ })
+ }
+ const json = await res.json() as { choices: Array<{ message: { content: string } }> }
+ return json.choices[0]?.message?.content ?? ''
+}
+
+// ── JSON extraction ───────────────────────────────────────────────────────────
+
+function extractJSON(raw: string): T | null {
+ const fenced = raw.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/)
+ const candidate = fenced ? fenced[1] : raw.match(/([\[{][\s\S]*[\]}])/)?.[1] ?? raw
+ try {
+ return JSON.parse(candidate) as T
+ } catch {
+ return null
+ }
+}
+
+// ── ParseNeed helpers ─────────────────────────────────────────────────────────
+
+type RawNeedParseAI = {
+ assetType?: string | null
+ areaRange?: { min: number; max: number } | null
+ preferredLocations?: string[]
+ budgetRange?: { maxPerSqm: number; currency: string } | null
+ timing?: { earliestMoveIn: string; latestMoveIn?: string; flexibleTiming: boolean } | null
+ mustHaveCriteria?: string[]
+ missingFields?: string[]
+ assumptions?: string[]
+}
+
+function followUpForField(field: string): string {
+ const MAP: Record = {
+ assetType: 'Welchen Nutzungstyp suchen Sie (Büro, Retail, Logistik, Produktion)?',
+ areaRange: 'Welche Fläche benötigen Sie (min–max in m²)?',
+ preferredLocations: 'In welchen Städten oder Regionen suchen Sie?',
+ budgetRange: 'Was ist Ihr maximales Budget pro m² und Jahr?',
+ timing: 'Wann möchten Sie spätestens einziehen?',
+ mustHaveCriteria: 'Haben Sie zwingende Anforderungen (ÖV-Anbindung, Parkplätze, Laderampe)?',
+ }
+ return MAP[field] ?? `Können Sie "${field}" präzisieren?`
+}
+
+function defaultSuggestedWeights(): Record {
+ return {
+ area: 0.25, location: 0.20, budget: 0.20, timing: 0.15,
+ prestige: 0.05, accessibility: 0.05, expansionPotential: 0.02,
+ flexibility: 0.02, visibility: 0.02, footfall: 0.01, talentAccess: 0.01,
+ esg: 0.01, taxEnvironment: 0.01,
+ }
+}
+
+// ── Fallback wrapper ──────────────────────────────────────────────────────────
+
+type FallbackFn = () => Promise>
+
+async function withFallback(
+ label: string,
+ fn: () => Promise>,
+ fallback: FallbackFn,
+ inputSizeChars?: number,
+): Promise> {
+ const startMs = Date.now()
+ const callId = crypto.randomUUID()
+
+ try {
+ const result = await fn()
+ const latencyMs = Date.now() - startMs
+ const prov = result.provenance
+ const provenance: AIProvenance = {
+ ...prov,
+ traceId: callId,
+ latencyMs,
+ schemaVersion: SCHEMA_VERSION,
+ }
+ aiTraceStore.add({
+ id: callId,
+ method: label,
+ provider: prov.provider,
+ model: prov.model,
+ promptVersion: prov.promptVersion,
+ latencyMs,
+ fallbackUsed: prov.fallbackUsed,
+ validationPassed: prov.validationPassed,
+ responseValidationStatus: provenanceToStatus(prov.fallbackUsed, prov.source, prov.fallbackReason),
+ fallbackReason: prov.fallbackReason,
+ source: prov.source,
+ createdAt: prov.generatedAt,
+ inputSizeChars,
+ })
+ return { ...result, provenance }
+ } catch (err) {
+ console.error(`[BackendAIService] ${label} failed:`, err)
+ const result = await fallback()
+ const latencyMs = Date.now() - startMs
+ const errorType: AITraceErrorType =
+ err instanceof AppError && err.code === ServiceErrorCode.AI_GENERATION_FAILED
+ ? 'api_error'
+ : err instanceof TypeError
+ ? 'network'
+ : 'unknown'
+ const responseValidationStatus: AITraceValidationStatus =
+ err instanceof AppError && err.code === ServiceErrorCode.AI_GENERATION_FAILED
+ ? 'api_error'
+ : 'network_error'
+ const fallbackReason = `${errorType}: ${err instanceof Error ? err.message.slice(0, 100) : 'unknown error'}`
+ const provenance: AIProvenance = {
+ ...result.provenance,
+ fallbackUsed: true,
+ traceId: callId,
+ fallbackReason,
+ schemaVersion: SCHEMA_VERSION,
+ latencyMs,
+ }
+ aiTraceStore.add({
+ id: callId,
+ method: label,
+ provider: 'backend',
+ model: BACKEND_MODEL_PLACEHOLDER,
+ promptVersion: PROMPT_VERSION,
+ latencyMs,
+ fallbackUsed: true,
+ validationPassed: false,
+ responseValidationStatus,
+ errorType,
+ fallbackReason,
+ source: 'mock',
+ createdAt: new Date().toISOString(),
+ inputSizeChars,
+ })
+ return { ...result, provenance }
+ }
+}
+
+// ── Service ───────────────────────────────────────────────────────────────────
+
+export const BackendAIService: IAIService = {
+
+ // ── parseNeed ───────────────────────────────────────────────────────────────
+ parseNeed(input: string): Promise> {
+ return withFallback('parseNeed', async () => {
+ const { system, user } = buildNeedParsingPrompt({ userInput: input })
+ const raw = await chat(system, user)
+ const json = extractJSON(raw)
+ const ai = json ? validateAIResponse(NeedParsingResponseSchema, json, 'parseNeed') : null
+
+ if (!ai) {
+ console.warn('[BackendAIService] parseNeed: invalid response — using mock fallback')
+ const fb = await MockAIService.parseNeed(input)
+ return { ...fb, provenance: makeProvenance('mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
+ }
+
+ const extractedCriteria: ParseNeedResult['extractedCriteria'] = {
+ assetType: (ai.assetType ?? undefined) as AssetType | undefined,
+ areaRange: ai.areaRange ?? undefined,
+ preferredLocations: ai.preferredLocations,
+ budgetRange: ai.budgetRange ?? undefined,
+ timing: ai.timing
+ ? { ...ai.timing, earliestMoveIn: ai.timing.earliestMoveIn ?? '', flexibleTiming: ai.timing.flexibleTiming ?? false }
+ : undefined,
+ mustHaveCriteria: ai.mustHaveCriteria,
+ }
+ const missingFields = ai.missingFields ?? []
+ const confidenceByField: Record = {}
+ Object.keys(extractedCriteria).forEach(k => {
+ confidenceByField[k] = extractedCriteria[k as keyof typeof extractedCriteria] != null ? 0.85 : 0
+ })
+ missingFields.forEach(f => { confidenceByField[f] = 0 })
+ const followUpQuestionCandidates: FollowUpQuestion[] = missingFields.map((field, i) => ({
+ id: `fq-be-${i}`,
+ questionText: followUpForField(field),
+ targetField: field,
+ reason: `Feld "${field}" nicht im Text erkannt`,
+ importance: 'recommended' as const,
+ }))
+ return {
+ data: {
+ extractedCriteria,
+ confidenceByField,
+ missingFields,
+ assumptions: ai.assumptions ?? [],
+ suggestedWeights: defaultSuggestedWeights(),
+ followUpQuestionCandidates,
+ rawSummary: raw.substring(0, 500),
+ promptVersion: PROMPT_VERSION,
+ schemaVersion: SCHEMA_VERSION,
+ },
+ provenance: makeProvenance('ai', false, true),
+ }
+ }, () => MockAIService.parseNeed(input), input.length)
+ },
+
+ // ── generateFollowUpQuestions ───────────────────────────────────────────────
+ generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise> {
+ return withFallback('generateFollowUpQuestions', async () => {
+ const missingFields = [
+ ...(!criteria.assetType ? ['assetType'] : []),
+ ...(!criteria.areaRange || (criteria.areaRange.min <= 0 && criteria.areaRange.max <= 0) ? ['areaRange'] : []),
+ ...(!criteria.preferredLocations?.length ? ['preferredLocations'] : []),
+ ...(!criteria.budgetRange ? ['budgetRange'] : []),
+ ...(!criteria.timing ? ['timing'] : []),
+ ...(!criteria.mustHaveCriteria?.length ? ['mustHaveCriteria'] : []),
+ ]
+ const { system, user } = buildFollowUpQuestionsPrompt({ criteria, missingFields })
+ const raw = await chat(system, user)
+ const json = extractJSON(raw)
+ const ai = json ? validateAIResponse(FollowUpQuestionsResponseSchema, json, 'generateFollowUpQuestions') : null
+
+ if (!ai?.length) {
+ console.warn('[BackendAIService] generateFollowUpQuestions: invalid response — using mock fallback')
+ const fb = await MockAIService.generateFollowUpQuestions(criteria)
+ return { ...fb, provenance: makeProvenance('mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
+ }
+ return {
+ data: ai.map((q, i) => ({
+ id: `fq-be-${i}`,
+ questionText: q.questionText,
+ targetField: q.targetField,
+ reason: q.reason ?? 'AI-generiert',
+ suggestedAnswerOptions: q.suggestedAnswerOptions,
+ importance: (q.importance ?? 'recommended') as FollowUpQuestion['importance'],
+ })),
+ provenance: makeProvenance('ai', false, true),
+ }
+ }, () => MockAIService.generateFollowUpQuestions(criteria))
+ },
+
+ // ── generateMatchExplanation ────────────────────────────────────────────────
+ generateMatchExplanation(input: MatchExplanationInput): Promise> {
+ return withFallback('generateMatchExplanation', async () => {
+ const { system, user } = buildMatchExplanationPrompt(input)
+ const raw = await chat(system, user)
+ const summary = raw.trim()
+
+ if (!summary) {
+ console.warn('[BackendAIService] generateMatchExplanation: empty response — using mock fallback')
+ const fb = await MockAIService.generateMatchExplanation(input)
+ return { ...fb, provenance: makeProvenance('mock', true, false, { fallbackReason: 'empty_response' }) }
+ }
+ const scoreLabel = input.matchScore >= 78 ? 'Starkes' : input.matchScore >= 52 ? 'Gutes' : 'Schwaches'
+ return {
+ data: {
+ headline: `${scoreLabel} Match — ${input.propertyTitle} (${input.matchScore}/100)`,
+ summary,
+ keyReasons: [
+ ...input.positiveFactors.slice(0, 2).map(f => `+ ${f.explanation}`),
+ ...input.negativeFactors.slice(0, 1).map(f => `− ${f.explanation}`),
+ ],
+ },
+ provenance: makeProvenance('ai', false, true),
+ }
+ }, () => MockAIService.generateMatchExplanation(input))
+ },
+
+ // ── summarizeTradeOffs ──────────────────────────────────────────────────────
+ summarizeTradeOffs(tradeoffs: TradeOffInput[]): Promise> {
+ return withFallback('summarizeTradeOffs', async () => {
+ const { system, user } = buildTradeOffPrompt(tradeoffs, 'Objekt')
+ const raw = await chat(system, user)
+ const json = extractJSON(raw)
+ const ai = json ? validateAIResponse(TradeOffSummaryResponseSchema, json, 'summarizeTradeOffs') : null
+
+ if (!ai) {
+ console.warn('[BackendAIService] summarizeTradeOffs: invalid response — using mock fallback')
+ const fb = await MockAIService.summarizeTradeOffs(tradeoffs)
+ return { ...fb, provenance: makeProvenance('mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
+ }
+ return {
+ data: {
+ headline: ai.headline,
+ items: ai.items.map(item => ({
+ concern: item.concern,
+ severity: item.severity,
+ mitigation: item.mitigation,
+ })),
+ overallRisk: ai.overallRisk,
+ },
+ provenance: makeProvenance('ai', false, true),
+ }
+ }, () => MockAIService.summarizeTradeOffs(tradeoffs))
+ },
+
+ // ── summarizeComparison ─────────────────────────────────────────────────────
+ summarizeComparison(items: UnifiedMatchResult[]): Promise> {
+ return withFallback('summarizeComparison', async () => {
+ type ItemWithProp = UnifiedMatchResult & {
+ property?: { title?: string; location?: { city?: string }; rentPricePerSqm?: number }
+ }
+ const properties = (items as ItemWithProp[])
+ .filter(i => i.resultType !== 'FUTURE_AVAILABILITY')
+ .map(i => ({
+ title: i.property?.title ?? `Match ${i.matchScore}`,
+ matchScore: i.matchScore,
+ city: i.property?.location?.city ?? '–',
+ rentPerSqm: i.property?.rentPricePerSqm ?? 0,
+ positiveFactors: i.match.positiveFactors.slice(0, 2).map(f => f.explanation ?? f.criterion),
+ negativeFactors: i.match.negativeFactors.slice(0, 2).map(f => f.explanation ?? f.criterion),
+ }))
+ const { system, user } = buildCompareSummaryPrompt({ properties })
+ const raw = await chat(system, user)
+ const json = extractJSON(raw)
+ const ai = json ? validateAIResponse(CompareSummaryResponseSchema, json, 'summarizeComparison') : null
+
+ if (!ai) {
+ console.warn('[BackendAIService] summarizeComparison: invalid response — using mock fallback')
+ const fb = await MockAIService.summarizeComparison(items)
+ return { ...fb, provenance: makeProvenance('mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
+ }
+ const mock = await MockAIService.summarizeComparison(items)
+ return {
+ data: {
+ ...mock.data,
+ overallAssessment: ai.overallAssessment,
+ recommendation: ai.recommendation ?? mock.data.recommendation,
+ },
+ provenance: makeProvenance('hybrid', false, true),
+ }
+ }, () => MockAIService.summarizeComparison(items))
+ },
+
+ // ── generateDecisionBrief ───────────────────────────────────────────────────
+ generateDecisionBrief(shortlistId: string): Promise> {
+ return withFallback('generateDecisionBrief', async () => {
+ const { system, user } = buildDecisionBriefPrompt({ shortlistItems: [], needSummary: shortlistId })
+ const raw = await chat(system, user)
+ const json = extractJSON(raw)
+ const ai = json ? validateAIResponse(DecisionBriefResponseSchema, json, 'generateDecisionBrief') : null
+
+ if (!ai) {
+ console.warn('[BackendAIService] generateDecisionBrief: invalid response — using mock fallback')
+ const fb = await MockAIService.generateDecisionBrief(shortlistId)
+ return { ...fb, provenance: makeProvenance('mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
+ }
+ const mock = await MockAIService.generateDecisionBrief(shortlistId)
+ return {
+ data: {
+ ...mock.data,
+ summary: ai.summary,
+ sections: ai.sections.map(s => ({ title: s.title, body: s.body })),
+ },
+ provenance: makeProvenance('hybrid', false, true),
+ }
+ }, () => MockAIService.generateDecisionBrief(shortlistId))
+ },
+
+ // ── generateDataQualitySummary ──────────────────────────────────────────────
+ generateDataQualitySummary(propertyId: string, quality: DataQualityInput): Promise> {
+ return withFallback('generateDataQualitySummary', async () => {
+ const { system, user } = buildDataQualityPrompt(propertyId, quality)
+ const raw = await chat(system, user)
+ const json = extractJSON(raw)
+ const ai = json ? validateAIResponse(DataQualitySummaryResponseSchema, json, 'generateDataQualitySummary') : null
+
+ if (!ai) {
+ console.warn('[BackendAIService] generateDataQualitySummary: invalid response — using mock fallback')
+ const fb = await MockAIService.generateDataQualitySummary(propertyId, quality)
+ return { ...fb, provenance: makeProvenance('mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
+ }
+ return {
+ data: {
+ overallAssessment: ai.overallAssessment,
+ missingCriticalFields: ai.missingCriticalFields ?? quality.missingCriticalFields,
+ recommendation: ai.recommendation,
+ confidence: ai.confidence,
+ },
+ provenance: makeProvenance('ai', false, true),
+ }
+ }, () => MockAIService.generateDataQualitySummary(propertyId, quality))
+ },
+
+ // ── classifyMarketSignal ────────────────────────────────────────────────────
+ classifyMarketSignal(signalText: string): Promise> {
+ return withFallback('classifyMarketSignal', async () => {
+ const { system, user } = buildMarketSignalPrompt(signalText)
+ const raw = await chat(system, user)
+ const json = extractJSON(raw)
+ const ai = json
+ ? validateAIResponse(MarketSignalClassificationResponseSchema, json, 'classifyMarketSignal')
+ : null
+
+ if (!ai) {
+ console.warn('[BackendAIService] classifyMarketSignal: invalid response — using mock fallback')
+ const fb = await MockAIService.classifyMarketSignal(signalText)
+ return { ...fb, provenance: makeProvenance('mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
+ }
+ return {
+ data: {
+ signalType: ai.signalType,
+ probability: ai.probability,
+ timeHorizonMonths: ai.timeHorizonMonths ?? null,
+ areaSqmEstimate: ai.areaSqmEstimate ?? null,
+ credibility: ai.credibility,
+ reasoning: ai.reasoning,
+ },
+ provenance: makeProvenance('ai', false, true),
+ }
+ }, () => MockAIService.classifyMarketSignal(signalText), signalText.length)
+ },
+
+ // ── generateOfferEmail ──────────────────────────────────────────────────────
+ generateOfferEmail(payload: OfferEmailPayload): Promise> {
+ return withFallback('generateOfferEmail', async () => {
+ const propertyList = payload.properties
+ .map((p, i) => `• ${p} (Match-Score: ${payload.matchScores[i]}%)`)
+ .join('\n')
+ const system = `Du bist Immobilienmakler bei Wincasa AG. Erstelle eine professionelle, knappe Angebotsmail auf Deutsch. Antworte als JSON: { "subject": "...", "body": "..." }`
+ const user = `Suchanfrage: "${payload.needTitle}"\n\nObjekte:\n${propertyList}\n\nErstelle eine professionelle Angebotsmail.`
+ const raw = await chat(system, user)
+ const json = extractJSON(raw)
+ const ai = json ? validateAIResponse(OfferEmailResponseSchema, json, 'generateOfferEmail') : null
+
+ if (!ai) {
+ console.warn('[BackendAIService] generateOfferEmail: invalid response — using mock fallback')
+ const fb = await MockAIService.generateOfferEmail(payload)
+ return { ...fb, provenance: makeProvenance('mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
+ }
+ return {
+ data: { subject: ai.subject, body: ai.body },
+ provenance: makeProvenance('ai', false, true),
+ }
+ }, () => MockAIService.generateOfferEmail(payload))
+ },
+
+ // ── generateFitOutAdvice ────────────────────────────────────────────────────
+ generateFitOutAdvice(input: FitOutAdviceInput): Promise> {
+ return withFallback('generateFitOutAdvice', async () => {
+ const FIT_LABELS: Record = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' }
+ const system = `Du bist Schweizer Gewerbeimmobilien-Experte. Bewerte die Ausbausituation und empfiehl die beste Verhandlungsoption.
+Verfügbare Optionen: MIETERAUSBAU (Mieter zahlt alles), BKZ (Vermieter zahlt Einmalpauschale), MAB_AMORTISATION (MAB über Miete amortisiert).
+Antworte als JSON:
+{
+ "recommendation": "MIETERAUSBAU" | "BKZ" | "MAB_AMORTISATION",
+ "headline": "kurze Empfehlung (max 80 Zeichen)",
+ "explanation": "2-3 Sätze Begründung auf Deutsch",
+ "negotiationTip": "konkreter Verhandlungstipp auf Deutsch",
+ "estimatedNetInvestment": "CHF-Betrag als String"
+}`
+ const user = `Übergabezustand: ${FIT_LABELS[input.fitOut] ?? input.fitOut}
+Fläche: ${input.areaSqm} m²
+MAB des Vermieters: CHF ${input.mabPerSqm}/m²
+Monatliche Miete: CHF ${input.monthlyRentPerSqm}/m²${input.tenantBudgetPerSqm ? `\nEigenes Ausbaubudget: CHF ${input.tenantBudgetPerSqm}/m²` : ''}${input.requiredFitOut ? `\nGewünschter Zustand: ${FIT_LABELS[input.requiredFitOut] ?? input.requiredFitOut}` : ''}
+
+Bitte analysiere die Situation und empfiehl die beste Option für den Mieter.`
+
+ const raw = await chat(system, user)
+ const json = extractJSON(raw)
+
+ if (!json || !json.recommendation || !json.headline) {
+ console.warn('[BackendAIService] generateFitOutAdvice: invalid response — using mock fallback')
+ const fb = await MockAIService.generateFitOutAdvice(input)
+ return { ...fb, provenance: makeProvenance('mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
+ }
+ return {
+ data: {
+ recommendation: json.recommendation,
+ headline: json.headline,
+ explanation: json.explanation ?? '',
+ negotiationTip: json.negotiationTip ?? '',
+ estimatedNetInvestment: json.estimatedNetInvestment ?? '–',
+ },
+ provenance: makeProvenance('ai', false, true),
+ }
+ }, () => MockAIService.generateFitOutAdvice(input))
+ },
+
+ // ── Legacy: extractCriteria ─────────────────────────────────────────────────
+ extractCriteria(input: string): Promise> {
+ return withFallback('extractCriteria', async () => {
+ const { system, user } = buildNeedParsingPrompt({ userInput: input })
+ const raw = await chat(system, user)
+ const json = extractJSON(raw)
+ const ai = json ? validateAIResponse(NeedParsingResponseSchema, json, 'extractCriteria') : null
+
+ if (!ai) {
+ console.warn('[BackendAIService] extractCriteria: invalid response — using mock fallback')
+ const fb = await MockAIService.extractCriteria(input)
+ return { ...fb, provenance: makeProvenance('mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
+ }
+ return {
+ data: {
+ extractedCriteria: {
+ assetType: ai.assetType as AssetType | undefined ?? undefined,
+ requiredArea: ai.areaRange ?? undefined,
+ preferredLocations: ai.preferredLocations ?? [],
+ budgetRange: ai.budgetRange ?? undefined,
+ },
+ confidence: 0.80,
+ missingFields: ai.missingFields ?? [],
+ assumptions: ai.assumptions ?? [],
+ followUpQuestions: (ai.missingFields ?? []).map(followUpForField),
+ },
+ provenance: makeProvenance('ai', false, true),
+ }
+ }, () => MockAIService.extractCriteria(input))
+ },
+
+ // ── Legacy: generateFollowUp ────────────────────────────────────────────────
+ generateFollowUp(partialNeed: Partial): Promise> {
+ return withFallback('generateFollowUp', async () => {
+ const missingFields = [
+ ...(!partialNeed.assetType ? ['assetType'] : []),
+ ...(!partialNeed.preferredLocations?.length ? ['preferredLocations'] : []),
+ ...(!partialNeed.timing ? ['timing'] : []),
+ ...(!partialNeed.budgetRange ? ['budgetRange'] : []),
+ ]
+ if (!missingFields.length) {
+ return { data: [], provenance: makeProvenance('ai', false, true) }
+ }
+ const { system, user } = buildFollowUpQuestionsPrompt({
+ criteria: partialNeed as ParsedNeedCriteria,
+ missingFields,
+ })
+ const raw = await chat(system, user)
+ const json = extractJSON(raw)
+ const ai = json ? validateAIResponse(FollowUpQuestionsResponseSchema, json, 'generateFollowUp') : null
+
+ if (!ai?.length) {
+ console.warn('[BackendAIService] generateFollowUp: invalid response — using mock fallback')
+ const fb = await MockAIService.generateFollowUp(partialNeed)
+ return { ...fb, provenance: makeProvenance('mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
+ }
+ return {
+ data: ai.map(q => q.questionText).filter(Boolean),
+ provenance: makeProvenance('ai', false, true),
+ }
+ }, () => MockAIService.generateFollowUp(partialNeed))
+ },
+}
diff --git a/src/services/ai/index.ts b/src/services/ai/index.ts
index b6a629f..7fbeea4 100644
--- a/src/services/ai/index.ts
+++ b/src/services/ai/index.ts
@@ -1,44 +1,47 @@
/**
* AI Service Factory
*
- * Provider selection (priority order):
- * 1. VITE_AI_PROVIDER=openrouter → OpenRouterAIService (requires VITE_OPENROUTER_API_KEY)
- * 2. VITE_AI_PROVIDER=mock → MockAIService (deterministic, no API key required)
- * 3. VITE_USE_REAL_AI=true → OpenRouterAIService (legacy flag, requires VITE_OPENROUTER_API_KEY)
- * 4. (default) → MockAIService
+ * Provider selection via VITE_AI_PROVIDER:
*
- * If VITE_AI_PROVIDER=openrouter but VITE_OPENROUTER_API_KEY is missing, the factory
- * logs a warning and falls back to MockAIService — never silently fails.
+ * backend (default) → BackendAIService
+ * Calls POST /api/ai/chat/completions on the PowerOn backend.
+ * The LLM API key is stored server-side only — not in this bundle.
+ * In development: configure a Vite proxy (see vite.config.ts).
*
- * Optional: VITE_OPENROUTER_MODEL controls which model OpenRouter uses.
- * Default: anthropic/claude-3-5-haiku
+ * mock → MockAIService
+ * Deterministic responses, no network calls.
+ * Use for local dev without a backend, or in CI.
+ *
+ * REMOVED: VITE_OPENROUTER_API_KEY and VITE_OPENROUTER_MODEL.
+ * The OpenRouter key is now a server-side secret in PowerOn.
*/
import { MockAIService } from './mock/MockAIService'
-import { OpenRouterAIService } from './openrouter/OpenRouterAIService'
+import { BackendAIService } from './backend/BackendAIService'
import type { IAIService } from './IAIService'
function resolveProvider(): IAIService {
const provider = import.meta.env.VITE_AI_PROVIDER as string | undefined
- const legacyRealAI = import.meta.env.VITE_USE_REAL_AI === 'true'
- const apiKey = import.meta.env.VITE_OPENROUTER_API_KEY as string | undefined
- const wantsOpenRouter = provider === 'openrouter' || (legacyRealAI && !provider)
-
- if (wantsOpenRouter) {
- if (!apiKey) {
- console.warn(
- '[aiService] OpenRouter selected but VITE_OPENROUTER_API_KEY is missing — falling back to MockAIService.',
- 'Set VITE_AI_PROVIDER=mock to suppress this warning.',
- )
- return MockAIService
- }
- return OpenRouterAIService
+ if (provider === 'mock') {
+ return MockAIService
}
- return MockAIService
+ if (provider === 'openrouter') {
+ console.warn(
+ '[aiService] VITE_AI_PROVIDER=openrouter is no longer supported. ' +
+ 'Direct OpenRouter calls have been removed from the frontend. ' +
+ 'Using BackendAIService (POST /api/ai/chat/completions) instead. ' +
+ 'Set VITE_AI_PROVIDER=backend or remove the variable to suppress this warning.',
+ )
+ }
+
+ // Default: backend proxy. Falls back to mock automatically on network/HTTP errors.
+ return BackendAIService
}
export const aiService: IAIService = resolveProvider()
-export { MockAIService, OpenRouterAIService }
+export { MockAIService, BackendAIService }
+// Compatibility alias for any code that still imports OpenRouterAIService by name.
+export { OpenRouterAIService } from './openrouter/OpenRouterAIService'
export type { IAIService }
diff --git a/src/services/ai/mock/MockAIService.ts b/src/services/ai/mock/MockAIService.ts
index 4bf678c..52101bb 100644
--- a/src/services/ai/mock/MockAIService.ts
+++ b/src/services/ai/mock/MockAIService.ts
@@ -10,6 +10,8 @@ import type {
TradeOffSummary,
DataQualityInput,
MarketSignalClassification,
+ FitOutAdviceInput,
+ FitOutAdvice,
} from '../IAIService'
import { mockProvenance } from '../IAIService'
import { aiTraceStore } from '../tracing'
@@ -294,6 +296,48 @@ export const MockAIService: IAIService = {
}
}),
+ generateFitOutAdvice: (input: FitOutAdviceInput) =>
+ traceMock('generateFitOutAdvice', async () => {
+ await delay(SIMULATED_DELAY.medium)
+ const mab = input.mabPerSqm
+ const fitOut = input.fitOut
+
+ let recommendation: FitOutAdvice['recommendation']
+ let headline: string
+ let explanation: string
+ let negotiationTip: string
+
+ if (fitOut === 'SHELL') {
+ if (mab >= 300) {
+ recommendation = 'MAB_AMORTISATION'
+ headline = 'MAB-Amortisation empfohlen — Vermieter trägt Grossteil der Ausbaukosten'
+ explanation = `Mit CHF ${mab}/m² MAB übernimmt der Vermieter einen erheblichen Teil der Ausbauinvestition. Die verbleibende Nettoinvestition wird über die Vertragslaufzeit amortisiert. Für ${input.areaSqm.toLocaleString('de-CH')} m² Rohbaufläche ist dies die kosteneffizienteste Lösung.`
+ negotiationTip = 'Verhandeln Sie eine höhere MAB-Rate gegen eine längere Mietvertragslaufzeit (min. 5 Jahre).'
+ } else {
+ recommendation = 'BKZ'
+ headline = 'Baukostenzuschuss (BKZ) verhandeln — Vermieter zahlt Ausbaupauschale'
+ explanation = `Bei SHELL-Übergabe ohne wesentlichem MAB ist ein Baukostenzuschuss (BKZ) die effektivste Option. Der Vermieter zahlt einen einmaligen Betrag, den Sie für den Innenausbau nutzen. Typisch sind CHF 200–400/m² als BKZ.`
+ negotiationTip = `Fordern Sie CHF ${Math.round(300 * input.areaSqm / 1000) * 1000}.– als BKZ-Pauschale. Reichen Sie Ausbauofferten von 2 Generalunternehmern vor der Unterzeichnung ein.`
+ }
+ } else {
+ recommendation = 'MIETERAUSBAU'
+ headline = 'Mieterausbau auf eigene Rechnung — geringe Restinvestition'
+ explanation = `${input.fitOut === 'BASIC' ? 'Basisausbau' : 'Vollausbau'} erfordert nur noch Anpassungen nach Ihren Bedürfnissen. Die Investition ist überschaubar und amortisiert sich bei einer Mietdauer von 3+ Jahren.`
+ negotiationTip = 'Lassen Sie eine Ausbauklausel im Mietvertrag festhalten: Entfernung von Mieterausbauten bei Auszug nur auf explizite Anforderung des Vermieters.'
+ }
+
+ const grossMin = (fitOut === 'SHELL' ? 800 : 400) - mab
+ const grossMax = (fitOut === 'SHELL' ? 1500 : 800) - mab
+ const netMin = Math.max(0, grossMin)
+ const netMax = Math.max(0, grossMax)
+ const estimatedNetInvestment = netMax <= 0
+ ? 'Vollständig durch MAB gedeckt'
+ : `CHF ${Math.round(netMin * input.areaSqm / 1000) * 1000}–${Math.round(netMax * input.areaSqm / 1000) * 1000}.–`
+
+ const data: FitOutAdvice = { recommendation, headline, explanation, negotiationTip, estimatedNetInvestment }
+ return { data, provenance: mockProvenance() }
+ }),
+
// Legacy methods
extractCriteria: (_input: string) =>
traceMock('extractCriteria', async () => ({
diff --git a/src/services/ai/mock/needParser.ts b/src/services/ai/mock/needParser.ts
index 5c03f75..09b8903 100644
--- a/src/services/ai/mock/needParser.ts
+++ b/src/services/ai/mock/needParser.ts
@@ -198,6 +198,13 @@ export function mockParseNeed(input: string): ParseNeedResult {
: lower.includes('basisausbau') || lower.includes('rohbau') || lower.includes('einfach') ? 'BASIC'
: undefined
+ // Search radius: "innerhalb von 30 km", "30km Umkreis", "im Umkreis von 50 km", "radius 20km"
+ const radiusMatch =
+ input.match(/(?:innerhalb\s+(?:von\s+)?|im\s+umkreis\s+(?:von\s+)?|radius\s+(?:von\s+)?)(\d+)\s*km/i) ??
+ input.match(/(\d+)\s*km\s*(?:umkreis|radius|entfernung)/i) ??
+ input.match(/(\d+)\s*km/i)
+ const searchRadius = radiusMatch ? Math.min(100, Math.max(1, parseInt(radiusMatch[1]))) : 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)
@@ -244,6 +251,7 @@ export function mockParseNeed(input: string): ParseNeedResult {
budgetRange: budgetConfidence,
timing: timingConfidence,
mustHaveCriteria: mustHaveCriteria.length > 0 ? 0.85 : 0.10,
+ searchRadius: searchRadius ? 0.90 : 0.20,
prestigeImportance: prestigeImportance ? 0.80 : 0.20,
parkingNeed: parkingNeed ? 0.90 : 0.30,
}
@@ -362,6 +370,7 @@ export function mockParseNeed(input: string): ParseNeedResult {
requiredFitOut: fitOutStr,
minCeilingHeightM,
minContractDurationMonths,
+ searchRadius,
notes,
},
confidenceByField,
diff --git a/src/services/ai/openrouter/OpenRouterAIService.ts b/src/services/ai/openrouter/OpenRouterAIService.ts
index 1686c8b..d7d5c2b 100644
--- a/src/services/ai/openrouter/OpenRouterAIService.ts
+++ b/src/services/ai/openrouter/OpenRouterAIService.ts
@@ -1,650 +1,12 @@
/**
- * OpenRouter AI Service
+ * @deprecated Direct OpenRouter calls from the frontend have been removed.
*
- * Activation:
- * VITE_AI_PROVIDER=openrouter
- * VITE_OPENROUTER_API_KEY=
- * VITE_OPENROUTER_MODEL=anthropic/claude-3-5-haiku (optional, default shown)
+ * All LLM requests now go through the PowerOn backend proxy at /api/ai/chat/completions
+ * so that the API key never appears in the browser bundle.
*
- * Every method follows this contract:
- * 1. No API key → warn + MockAIService fallback (fallbackUsed: true)
- * 2. HTTP error → error log + MockAIService fallback
- * 3. JSON parse fail → warn + MockAIService fallback
- * 4. Zod schema fail → warn + MockAIService fallback ← NEW
- * 5. Success (full AI) → AI response, source: 'ai', validationPassed: true
- * 6. Hybrid → source: 'hybrid', documented per-method
+ * This file is kept as a compatibility re-export so that any existing imports
+ * of `OpenRouterAIService` continue to compile without changes.
*
- * No invalid data ever reaches the UI.
+ * → Implementation moved to: src/services/ai/backend/BackendAIService.ts
*/
-import type { CreateNeedInput } from '../../../domain/need'
-import type { ParseNeedResult, ParsedNeedCriteria, FollowUpQuestion } from '../../../domain/needBuilder'
-import type { UnifiedMatchResult } from '../../../domain/unifiedResult'
-import type { AssetType } from '../../../domain/enums'
-import type {
- IAIService,
- AIResponse,
- AIProvenance,
- DecisionBrief,
- ComparisonSummary,
- CriteriaExtractionResult,
- OfferEmailPayload,
- MatchExplanationInput,
- MatchExplanation,
- TradeOffInput,
- TradeOffSummary,
- DataQualityInput,
- DataQualitySummary,
- MarketSignalClassification,
-} from '../IAIService'
-import { ServiceErrorCode } from '../../types'
-import { AppError } from '../../errors'
-import { aiTraceStore, provenanceToStatus } from '../tracing'
-import type { AITraceErrorType, AITraceValidationStatus } from '../tracing'
-import {
- NeedParsingResponseSchema,
- FollowUpQuestionsResponseSchema,
- TradeOffSummaryResponseSchema,
- CompareSummaryResponseSchema,
- DecisionBriefResponseSchema,
- DataQualitySummaryResponseSchema,
- MarketSignalClassificationResponseSchema,
- OfferEmailResponseSchema,
- validateAIResponse,
-} from '../schemas'
-import { buildNeedParsingPrompt } from '../prompts/needParsingPrompt'
-import { buildFollowUpQuestionsPrompt } from '../prompts/followUpQuestionsPrompt'
-import { buildMatchExplanationPrompt } from '../prompts/matchExplanationPrompt'
-import { buildTradeOffPrompt } from '../prompts/tradeOffPrompt'
-import { buildCompareSummaryPrompt } from '../prompts/compareSummaryPrompt'
-import { buildDecisionBriefPrompt } from '../prompts/decisionBriefPrompt'
-import { buildDataQualityPrompt } from '../prompts/dataQualityPrompt'
-import { buildMarketSignalPrompt } from '../prompts/marketSignalPrompt'
-import { MockAIService } from '../mock/MockAIService'
-
-// ── Config ────────────────────────────────────────────────────────────────────
-
-const API_BASE = 'https://openrouter.ai/api/v1'
-const DEFAULT_MODEL = 'anthropic/claude-3-5-haiku'
-const PROMPT_VERSION = 'v1.1'
-const SCHEMA_VERSION = 'v1.0'
-
-interface OpenRouterConfig {
- apiKey: string
- model: string
-}
-
-function getConfig(): OpenRouterConfig | null {
- const apiKey = import.meta.env.VITE_OPENROUTER_API_KEY as string | undefined
- if (!apiKey) return null
- return {
- apiKey,
- model: (import.meta.env.VITE_OPENROUTER_MODEL as string | undefined) ?? DEFAULT_MODEL,
- }
-}
-
-function makeProvenance(
- config: OpenRouterConfig,
- source: AIProvenance['source'],
- fallbackUsed: boolean,
- validationPassed: boolean,
- extras: { fallbackReason?: string } = {},
-): AIProvenance {
- return {
- provider: 'openrouter',
- model: config.model,
- generatedAt: new Date().toISOString(),
- promptVersion: PROMPT_VERSION,
- schemaVersion: SCHEMA_VERSION,
- source,
- fallbackUsed,
- validationPassed,
- traceId: crypto.randomUUID(),
- fallbackReason: extras.fallbackReason,
- }
-}
-
-// ── HTTP helper ───────────────────────────────────────────────────────────────
-
-async function chat(config: OpenRouterConfig, system: string, user: string): Promise {
- const res = await fetch(`${API_BASE}/chat/completions`, {
- method: 'POST',
- headers: {
- 'Authorization': `Bearer ${config.apiKey}`,
- 'Content-Type': 'application/json',
- 'HTTP-Referer': window.location.origin,
- },
- body: JSON.stringify({
- model: config.model,
- messages: [
- { role: 'system', content: system },
- { role: 'user', content: user },
- ],
- }),
- })
- if (!res.ok) {
- const body = await res.text()
- throw new AppError({
- code: ServiceErrorCode.AI_GENERATION_FAILED,
- message: `OpenRouter error ${res.status}: ${body}`,
- })
- }
- const json = await res.json() as { choices: Array<{ message: { content: string } }> }
- return json.choices[0]?.message?.content ?? ''
-}
-
-// ── JSON extraction ───────────────────────────────────────────────────────────
-
-function extractJSON(raw: string): T | null {
- const fenced = raw.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/)
- const candidate = fenced ? fenced[1] : raw.match(/([\[{][\s\S]*[\]}])/)?.[1] ?? raw
- try {
- return JSON.parse(candidate) as T
- } catch {
- return null
- }
-}
-
-// ── ParseNeed helpers ─────────────────────────────────────────────────────────
-
-type RawNeedParseAI = {
- assetType?: string | null
- areaRange?: { min: number; max: number } | null
- preferredLocations?: string[]
- budgetRange?: { maxPerSqm: number; currency: string } | null
- timing?: { earliestMoveIn: string; latestMoveIn?: string; flexibleTiming: boolean } | null
- mustHaveCriteria?: string[]
- missingFields?: string[]
- assumptions?: string[]
-}
-
-function followUpForField(field: string): string {
- const MAP: Record = {
- assetType: 'Welchen Nutzungstyp suchen Sie (Büro, Retail, Logistik, Produktion)?',
- areaRange: 'Welche Fläche benötigen Sie (min–max in m²)?',
- preferredLocations: 'In welchen Städten oder Regionen suchen Sie?',
- budgetRange: 'Was ist Ihr maximales Budget pro m² und Jahr?',
- timing: 'Wann möchten Sie spätestens einziehen?',
- mustHaveCriteria: 'Haben Sie zwingende Anforderungen (ÖV-Anbindung, Parkplätze, Laderampe)?',
- }
- return MAP[field] ?? `Können Sie "${field}" präzisieren?`
-}
-
-function defaultSuggestedWeights(): Record {
- return {
- area: 0.25, location: 0.20, budget: 0.20, timing: 0.15,
- prestige: 0.05, accessibility: 0.05, expansionPotential: 0.02,
- flexibility: 0.02, visibility: 0.02, footfall: 0.01, talentAccess: 0.01,
- esg: 0.01, taxEnvironment: 0.01,
- }
-}
-
-// ── Fallback wrapper ──────────────────────────────────────────────────────────
-
-type FallbackFn = () => Promise>
-
-async function withFallback(
- label: string,
- fn: (config: OpenRouterConfig) => Promise>,
- fallback: FallbackFn,
- inputSizeChars?: number,
-): Promise> {
- const config = getConfig()
- const startMs = Date.now()
- const callId = crypto.randomUUID()
-
- if (!config) {
- console.warn(`[OpenRouterAIService] ${label}: no API key — using MockAIService`)
- const result = await fallback()
- const latencyMs = Date.now() - startMs
- const provenance: AIProvenance = {
- ...result.provenance,
- fallbackUsed: true,
- traceId: callId,
- fallbackReason: 'no_api_key',
- schemaVersion: SCHEMA_VERSION,
- latencyMs,
- }
- aiTraceStore.add({
- id: callId,
- method: label,
- provider: 'openrouter',
- model: DEFAULT_MODEL,
- promptVersion: PROMPT_VERSION,
- latencyMs,
- fallbackUsed: true,
- validationPassed: false,
- responseValidationStatus: 'fallback',
- errorType: 'no_api_key',
- fallbackReason: 'no_api_key',
- source: 'mock',
- createdAt: new Date().toISOString(),
- inputSizeChars,
- })
- return { ...result, provenance }
- }
-
- try {
- const result = await fn(config)
- const latencyMs = Date.now() - startMs
- const prov = result.provenance
- const provenance: AIProvenance = {
- ...prov,
- traceId: callId,
- latencyMs,
- schemaVersion: SCHEMA_VERSION,
- }
- aiTraceStore.add({
- id: callId,
- method: label,
- provider: prov.provider,
- model: prov.model,
- promptVersion: prov.promptVersion,
- latencyMs,
- fallbackUsed: prov.fallbackUsed,
- validationPassed: prov.validationPassed,
- responseValidationStatus: provenanceToStatus(prov.fallbackUsed, prov.source, prov.fallbackReason),
- fallbackReason: prov.fallbackReason,
- source: prov.source,
- createdAt: prov.generatedAt,
- inputSizeChars,
- })
- return { ...result, provenance }
- } catch (err) {
- console.error(`[OpenRouterAIService] ${label} failed:`, err)
- const result = await fallback()
- const latencyMs = Date.now() - startMs
- const errorType: AITraceErrorType =
- err instanceof AppError && err.code === ServiceErrorCode.AI_GENERATION_FAILED
- ? 'api_error'
- : err instanceof TypeError
- ? 'network'
- : 'unknown'
- const responseValidationStatus: AITraceValidationStatus =
- err instanceof AppError && err.code === ServiceErrorCode.AI_GENERATION_FAILED
- ? 'api_error'
- : 'network_error'
- const fallbackReason = `${errorType}: ${err instanceof Error ? err.message.slice(0, 100) : 'unknown error'}`
- const provenance: AIProvenance = {
- ...result.provenance,
- fallbackUsed: true,
- traceId: callId,
- fallbackReason,
- schemaVersion: SCHEMA_VERSION,
- latencyMs,
- }
- aiTraceStore.add({
- id: callId,
- method: label,
- provider: 'openrouter',
- model: config.model,
- promptVersion: PROMPT_VERSION,
- latencyMs,
- fallbackUsed: true,
- validationPassed: false,
- responseValidationStatus,
- errorType,
- fallbackReason,
- source: 'mock',
- createdAt: new Date().toISOString(),
- inputSizeChars,
- })
- return { ...result, provenance }
- }
-}
-
-// ── Service ───────────────────────────────────────────────────────────────────
-
-export const OpenRouterAIService: IAIService = {
-
- // ── parseNeed ───────────────────────────────────────────────────────────────
- parseNeed(input: string): Promise> {
- return withFallback('parseNeed', async (config) => {
- const { system, user } = buildNeedParsingPrompt({ userInput: input })
- const raw = await chat(config, system, user)
- const json = extractJSON(raw)
- const ai = json ? validateAIResponse(NeedParsingResponseSchema, json, 'parseNeed') : null
-
- if (!ai) {
- console.warn('[OpenRouterAIService] parseNeed: invalid response — using mock fallback')
- const fb = await MockAIService.parseNeed(input)
- return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
- }
-
- const extractedCriteria: ParsedNeedCriteria = {
- assetType: (ai.assetType ?? undefined) as AssetType | undefined,
- areaRange: ai.areaRange ?? undefined,
- preferredLocations: ai.preferredLocations,
- budgetRange: ai.budgetRange ?? undefined,
- timing: ai.timing
- ? { ...ai.timing, earliestMoveIn: ai.timing.earliestMoveIn ?? '', flexibleTiming: ai.timing.flexibleTiming ?? false }
- : undefined,
- mustHaveCriteria: ai.mustHaveCriteria,
- }
- const missingFields = ai.missingFields ?? []
- const confidenceByField: Record = {}
- Object.keys(extractedCriteria).forEach(k => {
- confidenceByField[k] = extractedCriteria[k as keyof ParsedNeedCriteria] != null ? 0.85 : 0
- })
- missingFields.forEach(f => { confidenceByField[f] = 0 })
- const followUpQuestionCandidates: FollowUpQuestion[] = missingFields.map((field, i) => ({
- id: `fq-or-${i}`,
- questionText: followUpForField(field),
- targetField: field,
- reason: `Feld "${field}" nicht im Text erkannt`,
- importance: 'recommended' as const,
- }))
- return {
- data: {
- extractedCriteria,
- confidenceByField,
- missingFields,
- assumptions: ai.assumptions ?? [],
- suggestedWeights: defaultSuggestedWeights(),
- followUpQuestionCandidates,
- rawSummary: raw.substring(0, 500),
- promptVersion: PROMPT_VERSION,
- schemaVersion: SCHEMA_VERSION,
- },
- provenance: makeProvenance(config, 'ai', false, true),
- }
- }, () => MockAIService.parseNeed(input), input.length)
- },
-
- // ── generateFollowUpQuestions ───────────────────────────────────────────────
- generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise> {
- return withFallback('generateFollowUpQuestions', async (config) => {
- const missingFields = [
- ...(!criteria.assetType ? ['assetType'] : []),
- ...(!criteria.areaRange || (criteria.areaRange.min <= 0 && criteria.areaRange.max <= 0) ? ['areaRange'] : []),
- ...(!criteria.preferredLocations?.length ? ['preferredLocations'] : []),
- ...(!criteria.budgetRange ? ['budgetRange'] : []),
- ...(!criteria.timing ? ['timing'] : []),
- ...(!criteria.mustHaveCriteria?.length ? ['mustHaveCriteria'] : []),
- ]
- const { system, user } = buildFollowUpQuestionsPrompt({ criteria, missingFields })
- const raw = await chat(config, system, user)
- const json = extractJSON(raw)
- const ai = json ? validateAIResponse(FollowUpQuestionsResponseSchema, json, 'generateFollowUpQuestions') : null
-
- if (!ai?.length) {
- console.warn('[OpenRouterAIService] generateFollowUpQuestions: invalid response — using mock fallback')
- const fb = await MockAIService.generateFollowUpQuestions(criteria)
- return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
- }
- return {
- data: ai.map((q, i) => ({
- id: `fq-or-${i}`,
- questionText: q.questionText,
- targetField: q.targetField,
- reason: q.reason ?? 'AI-generiert',
- suggestedAnswerOptions: q.suggestedAnswerOptions,
- importance: (q.importance ?? 'recommended') as FollowUpQuestion['importance'],
- })),
- provenance: makeProvenance(config, 'ai', false, true),
- }
- }, () => MockAIService.generateFollowUpQuestions(criteria))
- },
-
- // ── generateMatchExplanation ────────────────────────────────────────────────
- // Plain-text response — no JSON schema to validate, but non-empty check enforced.
- generateMatchExplanation(input: MatchExplanationInput): Promise> {
- return withFallback('generateMatchExplanation', async (config) => {
- const { system, user } = buildMatchExplanationPrompt(input)
- const raw = await chat(config, system, user)
- const summary = raw.trim()
-
- if (!summary) {
- console.warn('[OpenRouterAIService] generateMatchExplanation: empty response — using mock fallback')
- const fb = await MockAIService.generateMatchExplanation(input)
- return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: 'empty_response' }) }
- }
- const scoreLabel = input.matchScore >= 78 ? 'Starkes' : input.matchScore >= 52 ? 'Gutes' : 'Schwaches'
- return {
- data: {
- headline: `${scoreLabel} Match — ${input.propertyTitle} (${input.matchScore}/100)`,
- summary,
- keyReasons: [
- ...input.positiveFactors.slice(0, 2).map(f => `+ ${f.explanation}`),
- ...input.negativeFactors.slice(0, 1).map(f => `− ${f.explanation}`),
- ],
- },
- provenance: makeProvenance(config, 'ai', false, true),
- }
- }, () => MockAIService.generateMatchExplanation(input))
- },
-
- // ── summarizeTradeOffs ──────────────────────────────────────────────────────
- summarizeTradeOffs(tradeoffs: TradeOffInput[]): Promise> {
- return withFallback('summarizeTradeOffs', async (config) => {
- const { system, user } = buildTradeOffPrompt(tradeoffs, 'Objekt')
- const raw = await chat(config, system, user)
- const json = extractJSON(raw)
- const ai = json ? validateAIResponse(TradeOffSummaryResponseSchema, json, 'summarizeTradeOffs') : null
-
- if (!ai) {
- console.warn('[OpenRouterAIService] summarizeTradeOffs: invalid response — using mock fallback')
- const fb = await MockAIService.summarizeTradeOffs(tradeoffs)
- return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
- }
- return {
- data: {
- headline: ai.headline,
- items: ai.items.map(item => ({
- concern: item.concern,
- severity: item.severity,
- mitigation: item.mitigation,
- })),
- overallRisk: ai.overallRisk,
- },
- provenance: makeProvenance(config, 'ai', false, true),
- }
- }, () => MockAIService.summarizeTradeOffs(tradeoffs))
- },
-
- // ── summarizeComparison ─────────────────────────────────────────────────────
- // Hybrid: AI provides narrative text; mock provides structural per-property data.
- // source: 'hybrid' — both are labeled in provenance.
- summarizeComparison(items: UnifiedMatchResult[]): Promise> {
- return withFallback('summarizeComparison', async (config) => {
- type ItemWithProp = UnifiedMatchResult & {
- property?: { title?: string; location?: { city?: string }; rentPricePerSqm?: number }
- }
- const properties = (items as ItemWithProp[])
- .filter(i => i.resultType !== 'FUTURE_AVAILABILITY')
- .map(i => ({
- title: i.property?.title ?? `Match ${i.matchScore}`,
- matchScore: i.matchScore,
- city: i.property?.location?.city ?? '–',
- rentPerSqm: i.property?.rentPricePerSqm ?? 0,
- positiveFactors: i.match.positiveFactors.slice(0, 2).map(f => f.explanation ?? f.criterion),
- negativeFactors: i.match.negativeFactors.slice(0, 2).map(f => f.explanation ?? f.criterion),
- }))
- const { system, user } = buildCompareSummaryPrompt({ properties })
- const raw = await chat(config, system, user)
- const json = extractJSON(raw)
- const ai = json ? validateAIResponse(CompareSummaryResponseSchema, json, 'summarizeComparison') : null
-
- if (!ai) {
- console.warn('[OpenRouterAIService] summarizeComparison: invalid response — using mock fallback')
- const fb = await MockAIService.summarizeComparison(items)
- return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
- }
- const mock = await MockAIService.summarizeComparison(items)
- return {
- data: {
- ...mock.data,
- overallAssessment: ai.overallAssessment,
- recommendation: ai.recommendation ?? mock.data.recommendation,
- },
- provenance: makeProvenance(config, 'hybrid', false, true),
- }
- }, () => MockAIService.summarizeComparison(items))
- },
-
- // ── generateDecisionBrief ───────────────────────────────────────────────────
- // Hybrid: AI generates narrative summary + sections; mock fills structural metadata.
- generateDecisionBrief(shortlistId: string): Promise> {
- return withFallback('generateDecisionBrief', async (config) => {
- const { system, user } = buildDecisionBriefPrompt({ shortlistItems: [], needSummary: shortlistId })
- const raw = await chat(config, system, user)
- const json = extractJSON(raw)
- const ai = json ? validateAIResponse(DecisionBriefResponseSchema, json, 'generateDecisionBrief') : null
-
- if (!ai) {
- console.warn('[OpenRouterAIService] generateDecisionBrief: invalid response — using mock fallback')
- const fb = await MockAIService.generateDecisionBrief(shortlistId)
- return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
- }
- const mock = await MockAIService.generateDecisionBrief(shortlistId)
- return {
- data: {
- ...mock.data,
- summary: ai.summary,
- sections: ai.sections.map(s => ({ title: s.title, body: s.body })),
- },
- provenance: makeProvenance(config, 'hybrid', false, true),
- }
- }, () => MockAIService.generateDecisionBrief(shortlistId))
- },
-
- // ── generateDataQualitySummary ──────────────────────────────────────────────
- generateDataQualitySummary(propertyId: string, quality: DataQualityInput): Promise> {
- return withFallback('generateDataQualitySummary', async (config) => {
- const { system, user } = buildDataQualityPrompt(propertyId, quality)
- const raw = await chat(config, system, user)
- const json = extractJSON(raw)
- const ai = json ? validateAIResponse(DataQualitySummaryResponseSchema, json, 'generateDataQualitySummary') : null
-
- if (!ai) {
- console.warn('[OpenRouterAIService] generateDataQualitySummary: invalid response — using mock fallback')
- const fb = await MockAIService.generateDataQualitySummary(propertyId, quality)
- return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
- }
- return {
- data: {
- overallAssessment: ai.overallAssessment,
- missingCriticalFields: ai.missingCriticalFields ?? quality.missingCriticalFields,
- recommendation: ai.recommendation,
- confidence: ai.confidence,
- },
- provenance: makeProvenance(config, 'ai', false, true),
- }
- }, () => MockAIService.generateDataQualitySummary(propertyId, quality))
- },
-
- // ── classifyMarketSignal ────────────────────────────────────────────────────
- classifyMarketSignal(signalText: string): Promise> {
- return withFallback('classifyMarketSignal', async (config) => {
- const { system, user } = buildMarketSignalPrompt(signalText)
- const raw = await chat(config, system, user)
- const json = extractJSON(raw)
- const ai = json
- ? validateAIResponse(MarketSignalClassificationResponseSchema, json, 'classifyMarketSignal')
- : null
-
- if (!ai) {
- console.warn('[OpenRouterAIService] classifyMarketSignal: invalid response — using mock fallback')
- const fb = await MockAIService.classifyMarketSignal(signalText)
- return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
- }
- return {
- data: {
- signalType: ai.signalType,
- probability: ai.probability,
- timeHorizonMonths: ai.timeHorizonMonths ?? null,
- areaSqmEstimate: ai.areaSqmEstimate ?? null,
- credibility: ai.credibility,
- reasoning: ai.reasoning,
- },
- provenance: makeProvenance(config, 'ai', false, true),
- }
- }, () => MockAIService.classifyMarketSignal(signalText), signalText.length)
- },
-
- // ── generateOfferEmail ──────────────────────────────────────────────────────
- generateOfferEmail(payload: OfferEmailPayload): Promise> {
- return withFallback('generateOfferEmail', async (config) => {
- const propertyList = payload.properties
- .map((p, i) => `• ${p} (Match-Score: ${payload.matchScores[i]}%)`)
- .join('\n')
- const system = `Du bist Immobilienmakler bei Wincasa AG. Erstelle eine professionelle, knappe Angebotsmail auf Deutsch. Antworte als JSON: { "subject": "...", "body": "..." }`
- const user = `Suchanfrage: "${payload.needTitle}"\n\nObjekte:\n${propertyList}\n\nErstelle eine professionelle Angebotsmail.`
- const raw = await chat(config, system, user)
- const json = extractJSON(raw)
- const ai = json ? validateAIResponse(OfferEmailResponseSchema, json, 'generateOfferEmail') : null
-
- if (!ai) {
- console.warn('[OpenRouterAIService] generateOfferEmail: invalid response — using mock fallback')
- const fb = await MockAIService.generateOfferEmail(payload)
- return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
- }
- return {
- data: { subject: ai.subject, body: ai.body },
- provenance: makeProvenance(config, 'ai', false, true),
- }
- }, () => MockAIService.generateOfferEmail(payload))
- },
-
- // ── Legacy: extractCriteria ─────────────────────────────────────────────────
- extractCriteria(input: string): Promise> {
- return withFallback('extractCriteria', async (config) => {
- const { system, user } = buildNeedParsingPrompt({ userInput: input })
- const raw = await chat(config, system, user)
- const json = extractJSON(raw)
- const ai = json ? validateAIResponse(NeedParsingResponseSchema, json, 'extractCriteria') : null
-
- if (!ai) {
- console.warn('[OpenRouterAIService] extractCriteria: invalid response — using mock fallback')
- const fb = await MockAIService.extractCriteria(input)
- return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
- }
- return {
- data: {
- extractedCriteria: {
- assetType: ai.assetType as AssetType | undefined ?? undefined,
- requiredArea: ai.areaRange ?? undefined,
- preferredLocations: ai.preferredLocations ?? [],
- budgetRange: ai.budgetRange ?? undefined,
- },
- confidence: 0.80,
- missingFields: ai.missingFields ?? [],
- assumptions: ai.assumptions ?? [],
- followUpQuestions: (ai.missingFields ?? []).map(followUpForField),
- },
- provenance: makeProvenance(config, 'ai', false, true),
- }
- }, () => MockAIService.extractCriteria(input))
- },
-
- // ── Legacy: generateFollowUp ────────────────────────────────────────────────
- generateFollowUp(partialNeed: Partial): Promise> {
- return withFallback('generateFollowUp', async (config) => {
- const missingFields = [
- ...(!partialNeed.assetType ? ['assetType'] : []),
- ...(!partialNeed.preferredLocations?.length ? ['preferredLocations'] : []),
- ...(!partialNeed.timing ? ['timing'] : []),
- ...(!partialNeed.budgetRange ? ['budgetRange'] : []),
- ]
- if (!missingFields.length) {
- return { data: [], provenance: makeProvenance(config, 'ai', false, true) }
- }
- const { system, user } = buildFollowUpQuestionsPrompt({
- criteria: partialNeed as ParsedNeedCriteria,
- missingFields,
- })
- const raw = await chat(config, system, user)
- const json = extractJSON(raw)
- const ai = json ? validateAIResponse(FollowUpQuestionsResponseSchema, json, 'generateFollowUp') : null
-
- if (!ai?.length) {
- console.warn('[OpenRouterAIService] generateFollowUp: invalid response — using mock fallback')
- const fb = await MockAIService.generateFollowUp(partialNeed)
- return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
- }
- return {
- data: ai.map(q => q.questionText).filter(Boolean),
- provenance: makeProvenance(config, 'ai', false, true),
- }
- }, () => MockAIService.generateFollowUp(partialNeed))
- },
-}
+export { BackendAIService as OpenRouterAIService } from '../backend/BackendAIService'
diff --git a/src/services/aiSearch/needSearchMapper.ts b/src/services/aiSearch/needSearchMapper.ts
index 6f92cf4..d6ddea1 100644
--- a/src/services/aiSearch/needSearchMapper.ts
+++ b/src/services/aiSearch/needSearchMapper.ts
@@ -16,6 +16,10 @@ export function generateSummary(c: ParsedNeedCriteria): string {
if (c.budgetRange?.maxPerSqm) parts.push(`Budget max. CHF ${c.budgetRange.maxPerSqm}/m²`)
if (c.timing?.earliestMoveIn) parts.push(`ab ${c.timing.earliestMoveIn}`)
if (c.mustHaveCriteria?.length) parts.push(`Must-haves: ${c.mustHaveCriteria.join(', ')}`)
+ if (c.searchRadius) parts.push(`Radius ${c.searchRadius} km`)
+ if (c.isAnonymous) parts.push('Anonyme Suche')
+ if (c.requiresDivisibility && c.minDivisibleUnit) parts.push(`Teilbar ab ${c.minDivisibleUnit} m²`)
+ if (c.fitOutBudgetMaxPerSqm) parts.push(`Ausbaubudget max. CHF ${c.fitOutBudgetMaxPerSqm}/m²`)
return parts.join(', ')
}
@@ -50,6 +54,11 @@ export function buildNeedInput(
requireBarrierFree: criteria.requireBarrierFree,
minCeilingHeightM: criteria.minCeilingHeightM,
minContractDurationMonths: criteria.minContractDurationMonths,
+ searchRadius: criteria.searchRadius,
+ isAnonymous: criteria.isAnonymous,
+ requiresDivisibility: criteria.requiresDivisibility,
+ minDivisibleUnit: criteria.minDivisibleUnit,
+ fitOutBudgetMaxPerSqm: criteria.fitOutBudgetMaxPerSqm,
notes: criteria.notes,
extractedFromText: undefined,
}
diff --git a/src/services/unitService.ts b/src/services/unitService.ts
new file mode 100644
index 0000000..05f9363
--- /dev/null
+++ b/src/services/unitService.ts
@@ -0,0 +1,15 @@
+import { MockupUnitProvider } from '../provider/MockupUnitProvider'
+import type { PropertyUnit } from '../domain/property'
+import { throwServiceError } from './errors'
+import type { ItemResponse } from './types'
+
+export const unitService = {
+ async update(unitId: string, data: Partial): Promise> {
+ try {
+ const unit = await MockupUnitProvider.update(unitId, data)
+ return { data: unit }
+ } catch (err) {
+ throwServiceError('unitService.update', err)
+ }
+ },
+}
diff --git a/vite.config.ts b/vite.config.ts
index c676acd..13165d6 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -7,4 +7,18 @@ export default defineConfig({
react(),
tailwindcss(),
],
+ server: {
+ proxy: {
+ // In dev: forward /api/* to the local PowerOn backend.
+ // Set AI_BACKEND_URL in your shell or .env.local (no VITE_ prefix — never bundled).
+ // Example: AI_BACKEND_URL=http://localhost:3001
+ //
+ // In production: the same-origin backend serves /api/* directly.
+ // No proxy needed; the relative URL /api/ai/chat/completions resolves correctly.
+ '/api': {
+ target: process.env['AI_BACKEND_URL'] ?? 'http://localhost:3001',
+ changeOrigin: true,
+ },
+ },
+ },
})