From b2530f9e208b78a7f709ff226cb5ca5f69822854 Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Sat, 6 Jun 2026 20:09:55 +0200 Subject: [PATCH] feat: Reale Jahresbelastung, must-have fixes, fitOut data coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reale Jahresbelastung (Change 6): - FitOutCostPanel zeigt Jahresmiete + amortisierte Ausbaukosten für alle fitOut-Werte - FULL/PREMIUM: Ausbau CHF 0 (bezugsfertig), SHELL/BASIC: CRB/BKP-Richtwerte amortisiert über 5 J. - Match-Card-Chip zeigt geschätzte Investition (orange wenn >100k) - FitOutInvestment-Typ + calcFitOutInvestment() in fitOutUtils.ts - BackendAIService + MockAIService mit generateFitOutAdvice (IAIService-Interface) Must-have Kriterien (Nicht prüfbar Fix): - ÖV-Anbindung: Minutengrenze aus Freitext extrahiert, gegen publicTransportMinutes geprüft - Mindestfläche: m²-Wert aus Freitext extrahiert, gegen areaSqm geprüft - Ausbaugrad: neues Keyword-Rule für FULL/PREMIUM fitOut-Datenpflege: - fitOut-Werte zu 32 fehlenden Properties ergänzt (Logistik=SHELL, Standard=BASIC, Modern=FULL) - MAB-Werte (200/150/250 CHF/m²) zu 3 BASIC-Objekten hinzugefügt Co-Authored-By: Claude Sonnet 4.6 --- .../demand/NeedExtendedRequirements.tsx | 40 +- src/components/demand/NeedInput.tsx | 50 +- .../match-card/MatchCardCompact.tsx | 29 + .../match-card/MatchCardViewModel.ts | 10 + .../match-detail/FitOutCostPanel.tsx | 132 ++++ .../match-detail/MatchDetailHero.tsx | 12 + .../MatchDetailPropertySections.tsx | 8 +- .../MatchDetailScoreBreakdown.tsx | 8 + .../match-detail/PropertyOverviewPanel.tsx | 12 +- src/components/match-detail/index.ts | 1 + .../new-listing/TechnicalDetailsSection.tsx | 53 +- src/components/shared/PropertyMap.tsx | 93 ++- .../supply/PropertyDetailOverview.tsx | 64 +- src/components/supply/UnitStructurePanel.tsx | 66 +- src/domain/need.ts | 5 + src/domain/needBuilder.ts | 5 + src/domain/property.ts | 1 + src/features/matching/matchCardAdapter.ts | 33 + src/features/matching/mustHaveScorer.ts | 66 ++ src/features/matching/scoreCalculator.ts | 44 +- src/hooks/useNewListingForm.ts | 15 +- src/hooks/useProperties.ts | 14 + src/lib/constants.ts | 8 + src/lib/fitOutUtils.ts | 58 ++ src/lib/locationIntelligence.ts | 31 + src/mock-data/needs.ts | 8 + src/mock-data/properties.ts | 48 +- src/pages/demand/MatchDetail.tsx | 31 +- src/pages/demand/Results.tsx | 34 +- src/pages/supply/NewListing.tsx | 3 + src/pages/supply/newListingMapper.ts | 15 + src/services/ai/IAIService.ts | 24 +- src/services/ai/backend/BackendAIService.ts | 671 ++++++++++++++++++ src/services/ai/index.ts | 53 +- src/services/ai/mock/MockAIService.ts | 44 ++ src/services/ai/mock/needParser.ts | 9 + .../ai/openrouter/OpenRouterAIService.ts | 652 +---------------- src/services/aiSearch/needSearchMapper.ts | 9 + src/services/unitService.ts | 15 + vite.config.ts | 14 + 40 files changed, 1755 insertions(+), 733 deletions(-) create mode 100644 src/components/match-detail/FitOutCostPanel.tsx create mode 100644 src/lib/fitOutUtils.ts create mode 100644 src/services/ai/backend/BackendAIService.ts create mode 100644 src/services/unitService.ts 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({ {o.label} ))} + 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 => {o.label})} + + 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 } }} + /> + + + )} + + )} 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, + }, + }, + }, })