feat(ai): AI pre-market rent recommendation from regional comparables, supply & demand

- 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 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-06-21 00:22:14 +02:00
parent 3e1e945661
commit fb029cf0bc
7 changed files with 239 additions and 53 deletions
+24
View File
@@ -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<AIResponse<FitOutAdvice>>
// Pre-market rent recommendation (supply side) — based on regional comparables, supply & demand
recommendPreMarketRent(input: PreMarketRentInput): Promise<AIResponse<PreMarketRentRecommendation>>
// Legacy methods
extractCriteria(input: string): Promise<AIResponse<CriteriaExtractionResult>>
generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<AIResponse<string[]>>
@@ -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<AIResponse<PreMarketRentRecommendation>> {
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}
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<PreMarketRentRecommendation>(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<AIResponse<CriteriaExtractionResult>> {
return withFallback('extractCriteria', async () => {
+68
View File
@@ -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<string, string> = { 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 () => ({
+2
View File
@@ -24,5 +24,7 @@ export type {
MarketSignalClassification,
FitOutAdvice,
FitOutAdviceInput,
PreMarketRentInput,
PreMarketRentRecommendation,
} from './ai/IAIService'
export { parseListingText } from './ai/mock/listingParser'