From fb029cf0bc103e677bd0f5a684eebeb5392a5b96 Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Sun, 21 Jun 2026 00:22:14 +0200 Subject: [PATCH] feat(ai): AI pre-market rent recommendation from regional comparables, supply & demand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IAIService.recommendPreMarketRent: recommended price + range, verdict (UNDERPRICED/FAIR/AMBITIOUS), drivers, rationale, confidence - MockAIService: deterministic recommendation from locationIntelligence — regional comp median, vacancy (supply), demand strength + days-on-market, rent trend (forward for pre-market) - BackendAIService: LLM prompt with market context + mock fallback - usePreMarketRentRecommendation hook; PreMarketPriceAdvisor component shows the recommendation per released unit with verdict ("zu günstig" when underpriced) + adjustable expected price + "Empfehlung übernehmen" - Replaces the simple indexed suggestion with a market-driven AI recommendation that flags underpricing Co-Authored-By: Claude Opus 4.8 --- .../supply/PreMarketPriceAdvisor.tsx | 91 +++++++++++++++++++ src/components/supply/PreMarketUnitGrid.tsx | 58 ++---------- src/hooks/useAI.ts | 11 ++- src/services/ai/IAIService.ts | 24 +++++ src/services/ai/backend/BackendAIService.ts | 38 ++++++++ src/services/ai/mock/MockAIService.ts | 68 ++++++++++++++ src/services/aiService.ts | 2 + 7 files changed, 239 insertions(+), 53 deletions(-) create mode 100644 src/components/supply/PreMarketPriceAdvisor.tsx diff --git a/src/components/supply/PreMarketPriceAdvisor.tsx b/src/components/supply/PreMarketPriceAdvisor.tsx new file mode 100644 index 0000000..e91612b --- /dev/null +++ b/src/components/supply/PreMarketPriceAdvisor.tsx @@ -0,0 +1,91 @@ +import { useState } from 'react' +import { Box, Button, Chip, CircularProgress, TextField, Typography } from '@mui/material' +import { Sparkles } from 'lucide-react' +import type { Property, PropertyUnit } from '../../domain/property' +import { usePreMarketRentRecommendation } from '../../hooks/useAI' +import { useUpdateUnit } from '../../hooks/useProperties' +import { resolveUnitFacts } from '../../lib/unitFacts' +import { DS_PRE_MARKET, DS_SURFACE, DS_TEXT } from '../../lib/ds' + +const VERDICT_META: Record = { + UNDERPRICED: { label: 'zu günstig', bg: '#fef3c7', fg: '#92400e' }, + FAIR: { label: 'marktgerecht', bg: '#dcfce7', fg: '#166534' }, + AMBITIOUS: { label: 'ambitioniert', bg: '#fee2e2', fg: '#991b1b' }, +} + +interface Props { + property: Property + unit: PropertyUnit +} + +/** KI-Preisempfehlung für eine Pre-Market-Einheit (regionale Vergleichsmieten, Angebot, Nachfrage). */ +export function PreMarketPriceAdvisor({ property, unit }: Props) { + const facts = resolveUnitFacts(property, unit) + const { data, isLoading, isError } = usePreMarketRentRecommendation({ + city: property.location?.city ?? '', + assetType: property.assetType, + areaSqm: unit.areaSqm, + currentRentPerSqm: facts.rentPricePerSqm, + availableFrom: unit.schattenmarktRelease?.availableFrom, + }) + const updateUnit = useUpdateUnit(property.id) + const [draft, setDraft] = useState(unit.expectedRentPerSqm != null ? String(unit.expectedRentPerSqm) : '') + + function saveExpected(value: string) { + updateUnit.mutate({ unitId: unit.id, data: { expectedRentPerSqm: parseInt(value) || undefined } }) + } + + const rec = data?.data + const verdict = rec ? VERDICT_META[rec.verdict] : null + + return ( + + + + + KI-Preisempfehlung Pre-Market + + {verdict && ( + + )} + + + {isLoading ? ( + + + Marktanalyse läuft… + + ) : isError || !rec ? ( + Keine Empfehlung verfügbar. + ) : ( + <> + + Empfehlung: CHF {rec.recommendedPerSqm}/m² + (CHF {rec.rangeMinPerSqm}–{rec.rangeMaxPerSqm}) + + + {rec.drivers.join(' · ')} + + + setDraft(e.target.value)} + onBlur={e => saveExpected(e.target.value)} + slotProps={{ inputLabel: { shrink: true }, htmlInput: { min: 0, step: 10 } }} + sx={{ width: 150, '& .MuiInputBase-input': { fontSize: '0.72rem', py: 0.5 } }} + /> + + + + )} + + ) +} diff --git a/src/components/supply/PreMarketUnitGrid.tsx b/src/components/supply/PreMarketUnitGrid.tsx index 385dd5a..0a0b650 100644 --- a/src/components/supply/PreMarketUnitGrid.tsx +++ b/src/components/supply/PreMarketUnitGrid.tsx @@ -1,14 +1,13 @@ import { useState } from 'react' -import { Box, Button, CircularProgress, Collapse, IconButton, Switch, TextField, Tooltip, Typography } from '@mui/material' -import { EyeOff, Pencil, Sparkles } from 'lucide-react' +import { Box, CircularProgress, Collapse, IconButton, Switch, TextField, Tooltip, Typography } from '@mui/material' +import { EyeOff, Pencil } from 'lucide-react' import type { Property } from '../../domain/property' -import { useUpdateUnit } from '../../hooks/useProperties' -import { DS_BORDER, DS_PRE_MARKET, DS_SURFACE, DS_TEXT } from '../../lib/ds' +import { DS_BORDER, DS_PRE_MARKET, DS_TEXT } from '../../lib/ds' import { FIT_OUT_LABELS } from '../../lib/constants' import { resolveUnitFacts } from '../../lib/unitFacts' -import { suggestFutureRent } from '../../lib/rentEstimate' import { floorLabel } from './PropertyDetailHelpers' import { UnitFieldsEditor } from './UnitFieldsEditor' +import { PreMarketPriceAdvisor } from './PreMarketPriceAdvisor' type PropertyUnit = NonNullable[number] @@ -29,14 +28,6 @@ interface Props { export function PreMarketUnitGrid({ property, units, unitStates, unitSaving, setUnitStates, saveUnit }: Props) { const [editing, setEditing] = useState(null) - const [expectedDraft, setExpectedDraft] = useState>(() => - Object.fromEntries(units.map(u => [u.id, u.expectedRentPerSqm != null ? String(u.expectedRentPerSqm) : ''])), - ) - const updateUnit = useUpdateUnit(property.id) - - function saveExpected(unitId: string, value: string) { - updateUnit.mutate({ unitId, data: { expectedRentPerSqm: parseInt(value) || undefined } }) - } return ( @@ -153,45 +144,8 @@ export function PreMarketUnitGrid({ property, units, unitStates, unitSaving, set {detailParts.join(' · ')} - {/* KI-Preis-Ansicht: nur wenn freigegeben — Vorschlag, anpassbar wenn zu günstig */} - {us.enabled && (() => { - const current = f.rentPricePerSqm - const suggestion = suggestFutureRent(property.location?.city ?? '', current) - return ( - - - - - KI-Preisempfehlung Pre-Market - - - - Heute CHF {current}/m² - {suggestion ? ` → indexiert CHF ${suggestion}/m²` : ''} — anpassen, falls zu günstig. - - - setExpectedDraft(prev => ({ ...prev, [u.id]: e.target.value }))} - onBlur={e => saveExpected(u.id, e.target.value)} - slotProps={{ inputLabel: { shrink: true }, htmlInput: { min: 0, step: 10 } }} - sx={{ width: 150, '& .MuiInputBase-input': { fontSize: '0.72rem', py: 0.5 } }} - /> - {suggestion != null && ( - - )} - - - ) - })()} + {/* KI-Preisempfehlung: nur wenn freigegeben */} + {us.enabled && } {/* Inline editor (shared) */} diff --git a/src/hooks/useAI.ts b/src/hooks/useAI.ts index c2efa6c..9e895a8 100644 --- a/src/hooks/useAI.ts +++ b/src/hooks/useAI.ts @@ -1,6 +1,6 @@ import { useMutation, useQuery } from '@tanstack/react-query' import { aiService, parseListingText } from '../services/aiService' -import type { OfferEmailPayload, FitOutAdviceInput } from '../services/aiService' +import type { OfferEmailPayload, FitOutAdviceInput, PreMarketRentInput } from '../services/aiService' export function useParseNeed() { return useMutation({ @@ -34,3 +34,12 @@ export function useFitOutAdvice(input: FitOutAdviceInput | null) { staleTime: Infinity, }) } + +export function usePreMarketRentRecommendation(input: PreMarketRentInput | null) { + return useQuery({ + queryKey: ['preMarketRent', input], + queryFn: () => aiService.recommendPreMarketRent(input!), + enabled: !!input && !!input.city, + staleTime: Infinity, + }) +} diff --git a/src/services/ai/IAIService.ts b/src/services/ai/IAIService.ts index 8f5e4cf..2fef478 100644 --- a/src/services/ai/IAIService.ts +++ b/src/services/ai/IAIService.ts @@ -176,6 +176,27 @@ export interface FitOutAdvice { estimatedNetInvestment: string } +// ── Pre-market rent recommendation ───────────────────────────────────────────── + +export interface PreMarketRentInput { + city: string + assetType: string + areaSqm: number + currentRentPerSqm: number + availableFrom?: string // ISO — Pre-Market liegt in der Zukunft +} + +export interface PreMarketRentRecommendation { + recommendedPerSqm: number + rangeMinPerSqm: number + rangeMaxPerSqm: number + verdict: 'UNDERPRICED' | 'FAIR' | 'AMBITIOUS' // Bewertung des heutigen Preises + deltaVsCurrentPct: number // Empfehlung vs. heutiger Preis + drivers: string[] // Vergleichsmiete, Angebot, Nachfrage, Trend … + rationale: string + confidence: 'LOW' | 'MEDIUM' | 'HIGH' +} + // ── Legacy types (kept for backward compatibility) ──────────────────────────── export interface CriteriaExtractionResult { @@ -220,6 +241,9 @@ export interface IAIService { // Fit-out investment advice (demand side) generateFitOutAdvice(input: FitOutAdviceInput): Promise> + // Pre-market rent recommendation (supply side) — based on regional comparables, supply & demand + recommendPreMarketRent(input: PreMarketRentInput): 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 index 33bf972..ef907fb 100644 --- a/src/services/ai/backend/BackendAIService.ts +++ b/src/services/ai/backend/BackendAIService.ts @@ -57,6 +57,8 @@ import type { MarketSignalClassification, FitOutAdviceInput, FitOutAdvice, + PreMarketRentInput, + PreMarketRentRecommendation, } from '../IAIService' import { ServiceErrorCode } from '../../types' import { AppError } from '../../errors' @@ -82,6 +84,7 @@ import { buildDecisionBriefPrompt } from '../prompts/decisionBriefPrompt' import { buildDataQualityPrompt } from '../prompts/dataQualityPrompt' import { buildMarketSignalPrompt } from '../prompts/marketSignalPrompt' import { MockAIService } from '../mock/MockAIService' +import { getCityIntelligence, getMarketRent } from '../../../lib/locationIntelligence' // ── Config ──────────────────────────────────────────────────────────────────── @@ -606,6 +609,41 @@ Bitte analysiere die Situation und empfiehl die beste Option für den Mieter.` }, () => MockAIService.generateFitOutAdvice(input)) }, + // ── recommendPreMarketRent ────────────────────────────────────────────────── + recommendPreMarketRent(input: PreMarketRentInput): Promise> { + return withFallback('recommendPreMarketRent', async () => { + const intel = getCityIntelligence(input.city) + const comp = getMarketRent(input.city, input.assetType) + const system = `Du bist Schweizer Gewerbeimmobilien-Marktanalyst. Empfiehl einen Pre-Market-Mietpreis (CHF/m²/Jahr) auf Basis regionaler Vergleichsmieten, Angebot (Leerstand) und Nachfrage. Antworte als JSON: +{ + "recommendedPerSqm": number, + "rangeMinPerSqm": number, + "rangeMaxPerSqm": number, + "verdict": "UNDERPRICED" | "FAIR" | "AMBITIOUS", + "deltaVsCurrentPct": number, + "drivers": ["kurze Treiber auf Deutsch"], + "rationale": "2-3 Sätze Begründung auf Deutsch", + "confidence": "LOW" | "MEDIUM" | "HIGH" +}` + const user = `Stadt: ${input.city} +Nutzung: ${input.assetType} +Fläche: ${input.areaSqm} m² +Heutiger Preis: CHF ${input.currentRentPerSqm}/m² +Vergleichsmiete (Median): ${comp ?? 'unbekannt'} +Leerstand: ${intel?.vacancyRatePct ?? '?'}% +Nachfrage: ${intel?.demandStrength ?? '?'} +Miettrend 12M: ${intel?.rentTrend12m ?? '?'}% +Ø Vermietungsdauer: ${intel?.avgDaysOnMarket ?? '?'} Tage` + const raw = await chat(system, user) + const json = extractJSON(raw) + if (!json || typeof json.recommendedPerSqm !== 'number') { + const fb = await MockAIService.recommendPreMarketRent(input) + return { ...fb, provenance: makeProvenance('mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) } + } + return { data: json, provenance: makeProvenance('ai', false, true) } + }, () => MockAIService.recommendPreMarketRent(input)) + }, + // ── Legacy: extractCriteria ───────────────────────────────────────────────── extractCriteria(input: string): Promise> { return withFallback('extractCriteria', async () => { diff --git a/src/services/ai/mock/MockAIService.ts b/src/services/ai/mock/MockAIService.ts index 52101bb..4615938 100644 --- a/src/services/ai/mock/MockAIService.ts +++ b/src/services/ai/mock/MockAIService.ts @@ -12,12 +12,15 @@ import type { MarketSignalClassification, FitOutAdviceInput, FitOutAdvice, + PreMarketRentInput, + PreMarketRentRecommendation, } from '../IAIService' import { mockProvenance } from '../IAIService' import { aiTraceStore } from '../tracing' import { mockParseNeed } from './needParser' import { buildComparisonSummary } from './compareBuilder' import { buildMockDecisionBrief } from './decisionBrief' +import { getCityIntelligence, getMarketRent } from '../../../lib/locationIntelligence' const SIMULATED_DELAY = { fast: 300, medium: 600, slow: 1800 } const delay = (ms: number) => new Promise(r => setTimeout(r, ms)) @@ -338,6 +341,71 @@ export const MockAIService: IAIService = { return { data, provenance: mockProvenance() } }), + // ── recommendPreMarketRent ────────────────────────────────────────────────── + recommendPreMarketRent: (input: PreMarketRentInput) => + traceMock('recommendPreMarketRent', async () => { + await delay(SIMULATED_DELAY.fast) + const intel = getCityIntelligence(input.city) + const comp = getMarketRent(input.city, input.assetType) + const current = input.currentRentPerSqm + const ASSET_LABELS: Record = { OFFICE: 'Bürofläche', LOGISTICS: 'Logistikfläche', LIGHT_INDUSTRIAL: 'Gewerbefläche', RETAIL: 'Retailfläche', PRODUCTION: 'Produktionsfläche' } + const assetLabel = ASSET_LABELS[input.assetType] ?? 'Fläche' + + // Ohne regionale Vergleichsdaten: nur grobe Schätzung, niedrige Konfidenz + if (!intel || comp == null) { + const rec = Math.round(current * 1.02) + const data: PreMarketRentRecommendation = { + recommendedPerSqm: rec, rangeMinPerSqm: Math.round(rec * 0.93), rangeMaxPerSqm: Math.round(rec * 1.07), + verdict: 'FAIR', deltaVsCurrentPct: 0, + drivers: ['Keine regionalen Vergleichsdaten verfügbar'], + rationale: 'Keine ausreichenden Marktdaten für diese Region — Empfehlung beruht auf dem heutigen Preis.', + confidence: 'LOW', + } + return { data, provenance: mockProvenance() } + } + + // Angebot/Nachfrage-Anpassung auf die regionale Vergleichsmiete + let adj = 0 + if (intel.vacancyRatePct < 2.5) adj += 0.06 + else if (intel.vacancyRatePct < 4) adj += 0.02 + else if (intel.vacancyRatePct > 5.5) adj -= 0.06 + else if (intel.vacancyRatePct > 4.5) adj -= 0.03 + adj += { VERY_HIGH: 0.06, HIGH: 0.03, MEDIUM: 0, LOW: -0.05 }[intel.demandStrength] + if (intel.avgDaysOnMarket < 35) adj += 0.02 + else if (intel.avgDaysOnMarket > 75) adj -= 0.03 + const trendFwd = intel.rentTrend12m / 100 // Pre-Market liegt in der Zukunft → Trend vorwärts + + const recommended = Math.round(comp * (1 + adj + trendFwd)) + const rangeMin = Math.round(recommended * 0.93) + const rangeMax = Math.round(recommended * 1.07) + const deltaVsCurrentPct = Math.round(((recommended - current) / current) * 100) + const verdict: PreMarketRentRecommendation['verdict'] = + deltaVsCurrentPct >= 6 ? 'UNDERPRICED' : deltaVsCurrentPct <= -6 ? 'AMBITIOUS' : 'FAIR' + + const supplyLabel = intel.vacancyRatePct < 3 ? 'sehr knappes Angebot' : intel.vacancyRatePct > 5 ? 'entspanntes Angebot' : 'ausgeglichenes Angebot' + const demandLabel = { VERY_HIGH: 'sehr hohe Nachfrage', HIGH: 'hohe Nachfrage', MEDIUM: 'mittlere Nachfrage', LOW: 'schwache Nachfrage' }[intel.demandStrength] + const drivers = [ + `Vergleichsmiete Region: CHF ${comp}/m²`, + `Leerstand ${intel.vacancyRatePct}% (${supplyLabel})`, + demandLabel, + `Miettrend ${intel.rentTrend12m >= 0 ? '+' : ''}${intel.rentTrend12m}% (12 M)`, + `Ø Vermietungsdauer ${intel.avgDaysOnMarket} Tage`, + ] + const verdictText = + verdict === 'UNDERPRICED' ? `Ihr heutiger Preis (CHF ${current}/m²) liegt ${Math.abs(deltaVsCurrentPct)}% unter der Empfehlung — klarer Spielraum nach oben.` + : verdict === 'AMBITIOUS' ? `Ihr heutiger Preis liegt ${Math.abs(deltaVsCurrentPct)}% über der Markteinschätzung — ambitioniert.` + : 'Ihr heutiger Preis ist marktgerecht.' + const rationale = `Auf Basis vergleichbarer ${assetLabel} in ${input.city} (Median CHF ${comp}/m²), ${supplyLabel} und ${demandLabel}. Empfehlung für Pre-Market: CHF ${recommended}/m² (CHF ${rangeMin}–${rangeMax}). ${verdictText}` + const confidence: PreMarketRentRecommendation['confidence'] = + intel.demandStrength === 'LOW' || intel.avgDaysOnMarket > 75 ? 'MEDIUM' : 'HIGH' + + const data: PreMarketRentRecommendation = { + recommendedPerSqm: recommended, rangeMinPerSqm: rangeMin, rangeMaxPerSqm: rangeMax, + verdict, deltaVsCurrentPct, drivers, rationale, confidence, + } + return { data, provenance: mockProvenance() } + }), + // Legacy methods extractCriteria: (_input: string) => traceMock('extractCriteria', async () => ({ diff --git a/src/services/aiService.ts b/src/services/aiService.ts index 9ecc0b9..4469046 100644 --- a/src/services/aiService.ts +++ b/src/services/aiService.ts @@ -24,5 +24,7 @@ export type { MarketSignalClassification, FitOutAdvice, FitOutAdviceInput, + PreMarketRentInput, + PreMarketRentRecommendation, } from './ai/IAIService' export { parseListingText } from './ai/mock/listingParser'