Files
property-match/src/services/ai/IAIService.ts
T
Benjamin Sutter fb029cf0bc 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>
2026-06-21 00:22:14 +02:00

251 lines
8.7 KiB
TypeScript

import type { CreateNeedInput } from '../../domain/need'
import type { ParseNeedResult, ParsedNeedCriteria, FollowUpQuestion } from '../../domain/needBuilder'
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
// ── AI Provenance ─────────────────────────────────────────────────────────────
// Attached to every AI response so the UI can always trace where data came from.
export interface AIProvenance {
/** Which AI provider produced this response */
provider: 'openrouter' | 'mock' | 'backend'
/** Exact model ID (e.g. 'anthropic/claude-3-5-haiku') or 'mock' */
model: string
/** ISO-8601 timestamp of generation */
generatedAt: string
/** Prompt version string used to generate this response */
promptVersion: string
/** Zod schema version used for validation */
schemaVersion: string
/** Whether the response is AI-only, mock-only, or a hybrid merge */
source: 'ai' | 'mock' | 'hybrid'
/** True when the original AI call failed and mock was substituted */
fallbackUsed: boolean
/** Human-readable reason why a fallback occurred — undefined when no fallback */
fallbackReason?: string
/** True when the AI response passed Zod schema validation */
validationPassed: boolean
/** Unique request ID — correlates AIResponse with AITrace.id */
traceId: string
/** Wall-clock latency for this call in milliseconds */
latencyMs?: number
}
/**
* All AI service methods return AIResponse<T> instead of ItemResponse<T>.
* `data` is backward-compatible — existing call sites using `result.data.xxx`
* continue to work unchanged.
*/
export type AIResponse<T> = {
data: T
provenance: AIProvenance
}
// ── Helper ────────────────────────────────────────────────────────────────────
export function mockProvenance(overrides?: Partial<AIProvenance>): AIProvenance {
return {
provider: 'mock',
model: 'mock',
generatedAt: new Date().toISOString(),
promptVersion: 'mock',
schemaVersion: 'mock',
source: 'mock',
fallbackUsed: false,
validationPassed: true,
traceId: crypto.randomUUID(),
...overrides,
}
}
// ── Response Types ────────────────────────────────────────────────────────────
export interface DecisionBrief {
id: string
shortlistId: string
summary: string
sections: { title: string; body: string }[]
generatedAt: string
isDraft: true
}
export interface ComparisonSummary {
strongestOption: { matchId: string; label: string; reason: string }
bestValue: { matchId: string; label: string; reason: string } | null
highestConfidence: { matchId: string; label: string; confidenceLevel: number }
biggestTradeoffs: string[]
missingDataWarnings: string[]
recommendedNextStep: string
overallAssessment: string
perPropertyAssessment: Array<{
matchId: string
label: string
strengths: string[]
weaknesses: string[]
bestFor: string
keyRisk: string | null
}>
recommendation: string
}
export interface OfferEmailPayload {
needTitle: string
properties: string[]
matchScores: number[]
}
export interface ParsedListingData {
assetType?: string
areaSqm?: number
rentPerSqm?: number
city?: string
softLevels?: Record<string, string>
parking?: number
fitOut?: string
}
export interface MatchExplanation {
headline: string
summary: string
keyReasons: string[]
}
export interface TradeOffSummary {
headline: string
items: Array<{ concern: string; severity: 'LOW' | 'MEDIUM' | 'HIGH'; mitigation?: string }>
overallRisk: 'LOW' | 'MEDIUM' | 'HIGH'
}
export interface DataQualitySummary {
overallAssessment: string
missingCriticalFields: string[]
recommendation: string
confidence: number
}
export interface MarketSignalClassification {
signalType: 'VACANCY' | 'CONSTRUCTION' | 'RESTRUCTURING' | 'EXPANSION' | 'RELOCATION' | 'UNKNOWN'
probability: number
timeHorizonMonths: number | null
areaSqmEstimate: number | null
credibility: 'LOW' | 'MEDIUM' | 'HIGH'
reasoning: string
}
// ── Input Types ───────────────────────────────────────────────────────────────
export interface MatchExplanationInput {
propertyTitle: string
propertyCity: string
matchScore: number
positiveFactors: Array<{ criterion: string; explanation: string }>
negativeFactors: Array<{ criterion: string; explanation: string }>
needSummary: string
}
export interface TradeOffInput {
criterion: string
concern: string
severity: 'LOW' | 'MEDIUM' | 'HIGH'
mitigation?: string
}
export interface DataQualityInput {
score: number
freshness: string
missingCriticalFields: string[]
missingOptionalFields: string[]
warnings: string[]
}
// ── Fit-out advice ────────────────────────────────────────────────────────────
export interface FitOutAdviceInput {
fitOut: string
areaSqm: number
mabPerSqm: number
requiredFitOut?: string
tenantBudgetPerSqm?: number
monthlyRentPerSqm: number
}
export interface FitOutAdvice {
recommendation: 'MIETERAUSBAU' | 'BKZ' | 'MAB_AMORTISATION'
headline: string
explanation: string
negotiationTip: string
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 {
extractedCriteria: Partial<CreateNeedInput>
confidence: number
missingFields: string[]
assumptions: string[]
followUpQuestions: string[]
}
export interface AIServiceProvider {
extractCriteria(naturalLanguageInput: string): Promise<CriteriaExtractionResult>
generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<string[]>
}
// ── Service Interface ─────────────────────────────────────────────────────────
export interface IAIService {
// Need parsing (F008)
parseNeed(input: string): Promise<AIResponse<ParseNeedResult>>
generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise<AIResponse<FollowUpQuestion[]>>
// Match explainability (F004)
generateMatchExplanation(input: MatchExplanationInput): Promise<AIResponse<MatchExplanation>>
summarizeTradeOffs(tradeoffs: TradeOffInput[]): Promise<AIResponse<TradeOffSummary>>
// Compare (F014)
summarizeComparison(items: UnifiedMatchResult[]): Promise<AIResponse<ComparisonSummary>>
// Shortlist decision brief
generateDecisionBrief(shortlistId: string): Promise<AIResponse<DecisionBrief>>
// Data quality
generateDataQualitySummary(propertyId: string, quality: DataQualityInput): Promise<AIResponse<DataQualitySummary>>
// Market signal classification (OPERATIONS)
classifyMarketSignal(signalText: string): Promise<AIResponse<MarketSignalClassification>>
// Offer email (supply side)
generateOfferEmail(payload: OfferEmailPayload): Promise<AIResponse<{ subject: string; body: string }>>
// 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[]>>
}