feat: OpenRouter-ready AI service — all 11 methods implemented
IAIService: + generateMatchExplanation, summarizeTradeOffs, generateDataQualitySummary, classifyMarketSignal (4 new methods covering all documented AI output types) OpenRouterAIService: - All 11 methods now make real API calls via withFallback() pattern - Every fallback is explicit (console.warn/error) — no silent mock bleed-through - Proper JSON extraction with type-safe parsers, no any casts - parseNeed: AI JSON → ParseNeedResult mapping (no TODO stubs) - generateFollowUpQuestions, generateMatchExplanation, summarizeTradeOffs, generateDataQualitySummary, classifyMarketSignal, generateOfferEmail, extractCriteria, generateFollowUp: fully implemented Prompts: +followUpQuestionsPrompt, +tradeOffPrompt, +dataQualityPrompt, +marketSignalPrompt Factory (index.ts): - VITE_AI_PROVIDER=mock|openrouter (new, takes priority) - VITE_USE_REAL_AI=true still supported (legacy compat) - Missing API key → explicit console.warn + MockAIService fallback Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -49,6 +49,62 @@ export interface ParsedListingData {
|
|||||||
fitOut?: string
|
fitOut?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── New Response Types ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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[]
|
||||||
|
}
|
||||||
|
|
||||||
// ── Legacy types (kept for backward compatibility) ────────────────────────────
|
// ── Legacy types (kept for backward compatibility) ────────────────────────────
|
||||||
|
|
||||||
export interface CriteriaExtractionResult {
|
export interface CriteriaExtractionResult {
|
||||||
@@ -71,12 +127,22 @@ export interface IAIService {
|
|||||||
parseNeed(input: string): Promise<ItemResponse<ParseNeedResult>>
|
parseNeed(input: string): Promise<ItemResponse<ParseNeedResult>>
|
||||||
generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise<ItemResponse<FollowUpQuestion[]>>
|
generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise<ItemResponse<FollowUpQuestion[]>>
|
||||||
|
|
||||||
|
// Match explainability (F004)
|
||||||
|
generateMatchExplanation(input: MatchExplanationInput): Promise<ItemResponse<MatchExplanation>>
|
||||||
|
summarizeTradeOffs(tradeoffs: TradeOffInput[]): Promise<ItemResponse<TradeOffSummary>>
|
||||||
|
|
||||||
// Compare (F014)
|
// Compare (F014)
|
||||||
summarizeComparison(items: UnifiedMatchResult[]): Promise<ItemResponse<ComparisonSummary>>
|
summarizeComparison(items: UnifiedMatchResult[]): Promise<ItemResponse<ComparisonSummary>>
|
||||||
|
|
||||||
// Shortlist decision brief
|
// Shortlist decision brief
|
||||||
generateDecisionBrief(shortlistId: string): Promise<ItemResponse<DecisionBrief>>
|
generateDecisionBrief(shortlistId: string): Promise<ItemResponse<DecisionBrief>>
|
||||||
|
|
||||||
|
// Data quality
|
||||||
|
generateDataQualitySummary(propertyId: string, quality: DataQualityInput): Promise<ItemResponse<DataQualitySummary>>
|
||||||
|
|
||||||
|
// Market signal classification (OPERATIONS)
|
||||||
|
classifyMarketSignal(signalText: string): Promise<ItemResponse<MarketSignalClassification>>
|
||||||
|
|
||||||
// Offer email (supply side)
|
// Offer email (supply side)
|
||||||
generateOfferEmail(payload: OfferEmailPayload): Promise<ItemResponse<{ subject: string; body: string }>>
|
generateOfferEmail(payload: OfferEmailPayload): Promise<ItemResponse<{ subject: string; body: string }>>
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,44 @@
|
|||||||
/**
|
/**
|
||||||
* AI Service Factory
|
* AI Service Factory
|
||||||
*
|
*
|
||||||
* Selects the active implementation based on the VITE_USE_REAL_AI feature flag.
|
* 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
|
||||||
*
|
*
|
||||||
* Mock mode (default): no API key required, all responses are deterministic.
|
* If VITE_AI_PROVIDER=openrouter but VITE_OPENROUTER_API_KEY is missing, the factory
|
||||||
* Real mode: set VITE_USE_REAL_AI=true + VITE_OPENROUTER_API_KEY in .env
|
* logs a warning and falls back to MockAIService — never silently fails.
|
||||||
|
*
|
||||||
|
* Optional: VITE_OPENROUTER_MODEL controls which model OpenRouter uses.
|
||||||
|
* Default: anthropic/claude-3-5-haiku
|
||||||
*/
|
*/
|
||||||
import { MockAIService } from './mock/MockAIService'
|
import { MockAIService } from './mock/MockAIService'
|
||||||
import { OpenRouterAIService } from './openrouter/OpenRouterAIService'
|
import { OpenRouterAIService } from './openrouter/OpenRouterAIService'
|
||||||
import type { IAIService } from './IAIService'
|
import type { IAIService } from './IAIService'
|
||||||
|
|
||||||
const useRealAI = import.meta.env.VITE_USE_REAL_AI === 'true'
|
function resolveProvider(): IAIService {
|
||||||
&& !!import.meta.env.VITE_OPENROUTER_API_KEY
|
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
|
||||||
|
|
||||||
export const aiService: IAIService = useRealAI ? OpenRouterAIService : MockAIService
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
return MockAIService
|
||||||
|
}
|
||||||
|
|
||||||
|
export const aiService: IAIService = resolveProvider()
|
||||||
|
|
||||||
// Named export so callers can reach the concrete impl when needed
|
|
||||||
export { MockAIService, OpenRouterAIService }
|
export { MockAIService, OpenRouterAIService }
|
||||||
export type { IAIService }
|
export type { IAIService }
|
||||||
|
|||||||
@@ -8,17 +8,19 @@ import type {
|
|||||||
ComparisonSummary,
|
ComparisonSummary,
|
||||||
CriteriaExtractionResult,
|
CriteriaExtractionResult,
|
||||||
OfferEmailPayload,
|
OfferEmailPayload,
|
||||||
|
MatchExplanationInput,
|
||||||
|
MatchExplanation,
|
||||||
|
TradeOffInput,
|
||||||
|
TradeOffSummary,
|
||||||
|
DataQualityInput,
|
||||||
|
DataQualitySummary,
|
||||||
|
MarketSignalClassification,
|
||||||
} from '../IAIService'
|
} from '../IAIService'
|
||||||
import { mockParseNeed } from './needParser'
|
import { mockParseNeed } from './needParser'
|
||||||
import { buildComparisonSummary } from './compareBuilder'
|
import { buildComparisonSummary } from './compareBuilder'
|
||||||
import { buildMockDecisionBrief } from './decisionBrief'
|
import { buildMockDecisionBrief } from './decisionBrief'
|
||||||
|
|
||||||
const SIMULATED_DELAY = {
|
const SIMULATED_DELAY = { fast: 300, medium: 600, slow: 1800 }
|
||||||
fast: 300,
|
|
||||||
medium: 600,
|
|
||||||
slow: 1800,
|
|
||||||
}
|
|
||||||
|
|
||||||
const delay = (ms: number) => new Promise(r => setTimeout(r, ms))
|
const delay = (ms: number) => new Promise(r => setTimeout(r, ms))
|
||||||
|
|
||||||
export const MockAIService: IAIService = {
|
export const MockAIService: IAIService = {
|
||||||
@@ -33,6 +35,47 @@ export const MockAIService: IAIService = {
|
|||||||
return { data: result.followUpQuestionCandidates }
|
return { data: result.followUpQuestionCandidates }
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async generateMatchExplanation(input: MatchExplanationInput): Promise<ItemResponse<MatchExplanation>> {
|
||||||
|
await delay(SIMULATED_DELAY.medium)
|
||||||
|
const isStrong = input.matchScore >= 78
|
||||||
|
const isMedium = input.matchScore >= 52
|
||||||
|
const headline = isStrong
|
||||||
|
? `Starkes Match — ${input.propertyTitle} erfüllt Ihre Kernkriterien hervorragend`
|
||||||
|
: isMedium
|
||||||
|
? `Gutes Match mit einzelnen Kompromissen für ${input.propertyTitle}`
|
||||||
|
: `Schwaches Match — mehrere Kriterien nicht erfüllt bei ${input.propertyTitle}`
|
||||||
|
const positiveText = input.positiveFactors.slice(0, 2).map(f => f.explanation).join('; ')
|
||||||
|
const negativeText = input.negativeFactors.slice(0, 1).map(f => f.explanation).join('; ')
|
||||||
|
const summary = `${input.propertyTitle} in ${input.propertyCity} erreicht ${input.matchScore}/100 Punkte.${positiveText ? ` Hauptstärken: ${positiveText}.` : ''}${negativeText ? ` Einschränkung: ${negativeText}.` : ''}`
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
headline,
|
||||||
|
summary,
|
||||||
|
keyReasons: [
|
||||||
|
...input.positiveFactors.slice(0, 2).map(f => `+ ${f.explanation}`),
|
||||||
|
...input.negativeFactors.slice(0, 1).map(f => `− ${f.explanation}`),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async summarizeTradeOffs(tradeoffs: TradeOffInput[]): Promise<ItemResponse<TradeOffSummary>> {
|
||||||
|
await delay(SIMULATED_DELAY.fast)
|
||||||
|
const critical = tradeoffs.filter(t => t.severity === 'HIGH')
|
||||||
|
const overallRisk: TradeOffSummary['overallRisk'] =
|
||||||
|
critical.length >= 2 ? 'HIGH' : critical.length === 1 ? 'MEDIUM' : 'LOW'
|
||||||
|
const riskLabel = overallRisk === 'HIGH' ? 'Hoch' : overallRisk === 'MEDIUM' ? 'Mittel' : 'Gering'
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
headline: tradeoffs.length === 0
|
||||||
|
? 'Keine wesentlichen Trade-offs identifiziert'
|
||||||
|
: `${tradeoffs.length} Trade-off${tradeoffs.length > 1 ? 's' : ''} — Gesamtrisiko: ${riskLabel}`,
|
||||||
|
items: tradeoffs.map(t => ({ concern: t.concern, severity: t.severity, mitigation: t.mitigation })),
|
||||||
|
overallRisk,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async summarizeComparison(items: UnifiedMatchResult[]): Promise<ItemResponse<ComparisonSummary>> {
|
async summarizeComparison(items: UnifiedMatchResult[]): Promise<ItemResponse<ComparisonSummary>> {
|
||||||
await delay(SIMULATED_DELAY.medium)
|
await delay(SIMULATED_DELAY.medium)
|
||||||
return { data: buildComparisonSummary(items) }
|
return { data: buildComparisonSummary(items) }
|
||||||
@@ -43,6 +86,59 @@ export const MockAIService: IAIService = {
|
|||||||
return { data: buildMockDecisionBrief(shortlistId) }
|
return { data: buildMockDecisionBrief(shortlistId) }
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async generateDataQualitySummary(_propertyId: string, quality: DataQualityInput): Promise<ItemResponse<DataQualitySummary>> {
|
||||||
|
await delay(SIMULATED_DELAY.fast)
|
||||||
|
const level =
|
||||||
|
quality.score >= 0.85 ? 'excellent'
|
||||||
|
: quality.score >= 0.70 ? 'good'
|
||||||
|
: quality.score >= 0.55 ? 'fair'
|
||||||
|
: quality.score >= 0.40 ? 'poor'
|
||||||
|
: 'critical'
|
||||||
|
const assessments: Record<string, string> = {
|
||||||
|
excellent: 'Exzellente Datenqualität — alle Kernfelder vollständig und aktuell.',
|
||||||
|
good: 'Gute Datenqualität — kleinere Lücken beeinflussen die Matchgenauigkeit nicht wesentlich.',
|
||||||
|
fair: 'Ausreichende Datenqualität — fehlende Felder können die Matchgenauigkeit beeinträchtigen.',
|
||||||
|
poor: 'Geringe Datenqualität — wichtige Felder fehlen, Match-Score mit Vorsicht interpretieren.',
|
||||||
|
critical: 'Kritische Datenqualität — fundamentale Felder fehlen, Match-Ergebnis stark eingeschränkt.',
|
||||||
|
}
|
||||||
|
const hasCritical = quality.missingCriticalFields.length > 0
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
overallAssessment: assessments[level],
|
||||||
|
missingCriticalFields: quality.missingCriticalFields,
|
||||||
|
recommendation: hasCritical
|
||||||
|
? `Fehlende Pflichtfelder ergänzen: ${quality.missingCriticalFields.join(', ')}`
|
||||||
|
: quality.score < 0.70
|
||||||
|
? 'Daten aktualisieren und optionale Felder ergänzen für bessere Matchgenauigkeit.'
|
||||||
|
: 'Keine sofortigen Massnahmen erforderlich.',
|
||||||
|
confidence: quality.score,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async classifyMarketSignal(signalText: string): Promise<ItemResponse<MarketSignalClassification>> {
|
||||||
|
await delay(SIMULATED_DELAY.medium)
|
||||||
|
const t = signalText.toLowerCase()
|
||||||
|
let signalType: MarketSignalClassification['signalType'] = 'UNKNOWN'
|
||||||
|
if (t.includes('neubau') || t.includes('baubewilligung') || t.includes('umbau')) signalType = 'CONSTRUCTION'
|
||||||
|
else if (t.includes('expansion') || t.includes('wachstum') || t.includes('sucht fläche')) signalType = 'EXPANSION'
|
||||||
|
else if (t.includes('verlegt') || t.includes('umzug') || t.includes('relocation')) signalType = 'RELOCATION'
|
||||||
|
else if (t.includes('stellenabbau') || t.includes('restruktur') || t.includes('fusion')) signalType = 'RESTRUCTURING'
|
||||||
|
else if (t.includes('frei') || t.includes('kündigung') || t.includes('schliessung') || t.includes('leerstand')) signalType = 'VACANCY'
|
||||||
|
const areaMatch = signalText.match(/(\d{2,5})\s*m²/)
|
||||||
|
const monthsMatch = signalText.match(/(\d{1,2})\s*Monate?n?/)
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
signalType,
|
||||||
|
probability: 0.65,
|
||||||
|
timeHorizonMonths: monthsMatch ? parseInt(monthsMatch[1]) : null,
|
||||||
|
areaSqmEstimate: areaMatch ? parseInt(areaMatch[1]) : null,
|
||||||
|
credibility: 'MEDIUM',
|
||||||
|
reasoning: `Keyword-basierte Klassifikation (Mock). Signaltyp: ${signalType}.`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async generateOfferEmail(payload: OfferEmailPayload): Promise<ItemResponse<{ subject: string; body: string }>> {
|
async generateOfferEmail(payload: OfferEmailPayload): Promise<ItemResponse<{ subject: string; body: string }>> {
|
||||||
await delay(SIMULATED_DELAY.medium * 2)
|
await delay(SIMULATED_DELAY.medium * 2)
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,36 +1,64 @@
|
|||||||
/**
|
/**
|
||||||
* OpenRouter AI Service
|
* OpenRouter AI Service
|
||||||
*
|
*
|
||||||
* To activate:
|
* Activation:
|
||||||
* 1. Set VITE_USE_REAL_AI=true in your .env file
|
* VITE_AI_PROVIDER=openrouter
|
||||||
* 2. Set VITE_OPENROUTER_API_KEY=<your-key>
|
* VITE_OPENROUTER_API_KEY=<your-key>
|
||||||
* 3. Optionally set VITE_OPENROUTER_MODEL (default: anthropic/claude-3-5-haiku)
|
* VITE_OPENROUTER_MODEL=anthropic/claude-3-5-haiku (optional, default shown)
|
||||||
*
|
*
|
||||||
* This service is model-agnostic — change VITE_OPENROUTER_MODEL to switch
|
* All methods follow this contract:
|
||||||
* between Claude, GPT-4o, Mistral, Llama, etc. without code changes.
|
* 1. If API key is missing → explicit warn + MockAIService fallback
|
||||||
|
* 2. If API call fails → explicit error log + MockAIService fallback
|
||||||
|
* 3. If JSON parse fails → explicit warn + MockAIService fallback
|
||||||
|
* 4. On success → fully AI-generated response, no silent mock merge
|
||||||
|
*
|
||||||
|
* Methods that use a hybrid approach (AI text merged into mock structure) are
|
||||||
|
* explicitly documented with why mock data fills the remaining fields.
|
||||||
*/
|
*/
|
||||||
import type { ItemResponse } from '../../types'
|
import type { ItemResponse } from '../../types'
|
||||||
import type { CreateNeedInput } from '../../../domain/need'
|
import type { CreateNeedInput } from '../../../domain/need'
|
||||||
import type { ParseNeedResult, ParsedNeedCriteria, FollowUpQuestion } from '../../../domain/needBuilder'
|
import type { ParseNeedResult, ParsedNeedCriteria, FollowUpQuestion } from '../../../domain/needBuilder'
|
||||||
import type { UnifiedMatchResult } from '../../../domain/unifiedResult'
|
import type { UnifiedMatchResult } from '../../../domain/unifiedResult'
|
||||||
|
import type { AssetType } from '../../../domain/enums'
|
||||||
import type {
|
import type {
|
||||||
IAIService,
|
IAIService,
|
||||||
DecisionBrief,
|
DecisionBrief,
|
||||||
ComparisonSummary,
|
ComparisonSummary,
|
||||||
CriteriaExtractionResult,
|
CriteriaExtractionResult,
|
||||||
OfferEmailPayload,
|
OfferEmailPayload,
|
||||||
|
MatchExplanationInput,
|
||||||
|
MatchExplanation,
|
||||||
|
TradeOffInput,
|
||||||
|
TradeOffSummary,
|
||||||
|
DataQualityInput,
|
||||||
|
DataQualitySummary,
|
||||||
|
MarketSignalClassification,
|
||||||
} from '../IAIService'
|
} from '../IAIService'
|
||||||
import { ServiceErrorCode } from '../../types'
|
import { ServiceErrorCode } from '../../types'
|
||||||
import { AppError } from '../../errors'
|
import { AppError } from '../../errors'
|
||||||
import { buildNeedParsingPrompt } from '../prompts/needParsingPrompt'
|
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 { buildCompareSummaryPrompt } from '../prompts/compareSummaryPrompt'
|
||||||
import { buildDecisionBriefPrompt } from '../prompts/decisionBriefPrompt'
|
import { buildDecisionBriefPrompt } from '../prompts/decisionBriefPrompt'
|
||||||
|
import { buildDataQualityPrompt } from '../prompts/dataQualityPrompt'
|
||||||
|
import { buildMarketSignalPrompt } from '../prompts/marketSignalPrompt'
|
||||||
import { MockAIService } from '../mock/MockAIService'
|
import { MockAIService } from '../mock/MockAIService'
|
||||||
|
|
||||||
|
// ── Config ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const API_BASE = 'https://openrouter.ai/api/v1'
|
const API_BASE = 'https://openrouter.ai/api/v1'
|
||||||
const DEFAULT_MODEL = 'anthropic/claude-3-5-haiku'
|
const DEFAULT_MODEL = 'anthropic/claude-3-5-haiku'
|
||||||
|
const PROMPT_VERSION = 'v1.0'
|
||||||
|
const SCHEMA_VERSION = 'v1.0'
|
||||||
|
|
||||||
function getConfig(): { apiKey: string; model: string } | null {
|
interface OpenRouterConfig {
|
||||||
|
apiKey: string
|
||||||
|
model: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfig(): OpenRouterConfig | null {
|
||||||
const apiKey = import.meta.env.VITE_OPENROUTER_API_KEY as string | undefined
|
const apiKey = import.meta.env.VITE_OPENROUTER_API_KEY as string | undefined
|
||||||
if (!apiKey) return null
|
if (!apiKey) return null
|
||||||
return {
|
return {
|
||||||
@@ -39,7 +67,9 @@ function getConfig(): { apiKey: string; model: string } | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function chat(config: { apiKey: string; model: string }, system: string, user: string): Promise<string> {
|
// ── HTTP helper ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function chat(config: OpenRouterConfig, system: string, user: string): Promise<string> {
|
||||||
const res = await fetch(`${API_BASE}/chat/completions`, {
|
const res = await fetch(`${API_BASE}/chat/completions`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -57,93 +87,425 @@ async function chat(config: { apiKey: string; model: string }, system: string, u
|
|||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = await res.text()
|
const body = await res.text()
|
||||||
throw new AppError({ code: ServiceErrorCode.AI_GENERATION_FAILED, message: `OpenRouter error ${res.status}: ${body}` })
|
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 } }> }
|
const json = await res.json() as { choices: Array<{ message: { content: string } }> }
|
||||||
return json.choices[0]?.message?.content ?? ''
|
return json.choices[0]?.message?.content ?? ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseJSON<T>(raw: string, fallback: T): T {
|
// ── JSON extraction ───────────────────────────────────────────────────────────
|
||||||
const jsonMatch = raw.match(/```json\n?([\s\S]*?)\n?```/) ?? raw.match(/(\{[\s\S]*\})/)
|
|
||||||
|
function extractJSON<T>(raw: string): T | null {
|
||||||
|
// Try fenced code block first, then bare object/array
|
||||||
|
const fenced = raw.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/)
|
||||||
|
const candidate = fenced ? fenced[1] : raw.match(/([\[{][\s\S]*[\]}])/)?.[1] ?? raw
|
||||||
try {
|
try {
|
||||||
return JSON.parse(jsonMatch ? jsonMatch[1] : raw) as T
|
return JSON.parse(candidate) as T
|
||||||
} catch {
|
} catch {
|
||||||
return fallback
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Helpers for ParseNeedResult mapping ───────────────────────────────────────
|
||||||
|
|
||||||
|
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<string, string> = {
|
||||||
|
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<string, number> {
|
||||||
|
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<T> = () => Promise<ItemResponse<T>>
|
||||||
|
|
||||||
|
async function withFallback<T>(
|
||||||
|
label: string,
|
||||||
|
fn: (config: OpenRouterConfig) => Promise<ItemResponse<T>>,
|
||||||
|
fallback: FallbackFn<T>,
|
||||||
|
): Promise<ItemResponse<T>> {
|
||||||
|
const config = getConfig()
|
||||||
|
if (!config) {
|
||||||
|
console.warn(`[OpenRouterAIService] ${label}: no API key — using MockAIService`)
|
||||||
|
return fallback()
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await fn(config)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[OpenRouterAIService] ${label} failed:`, err)
|
||||||
|
return fallback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Service ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const OpenRouterAIService: IAIService = {
|
export const OpenRouterAIService: IAIService = {
|
||||||
async parseNeed(input: string): Promise<ItemResponse<ParseNeedResult>> {
|
|
||||||
const config = getConfig()
|
|
||||||
if (!config) return MockAIService.parseNeed(input)
|
|
||||||
|
|
||||||
|
// ── parseNeed ───────────────────────────────────────────────────────────────
|
||||||
|
parseNeed(input: string): Promise<ItemResponse<ParseNeedResult>> {
|
||||||
|
return withFallback('parseNeed', async (config) => {
|
||||||
const { system, user } = buildNeedParsingPrompt({ userInput: input })
|
const { system, user } = buildNeedParsingPrompt({ userInput: input })
|
||||||
const raw = await chat(config, system, user)
|
const raw = await chat(config, system, user)
|
||||||
const parsed = parseJSON(raw, null)
|
const ai = extractJSON<RawNeedParseAI>(raw)
|
||||||
// If parse fails fall back to mock (keeps app working even with bad AI responses)
|
if (!ai) {
|
||||||
if (!parsed) return MockAIService.parseNeed(input)
|
console.warn('[OpenRouterAIService] parseNeed: could not parse JSON — using mock fallback')
|
||||||
return MockAIService.parseNeed(input) // TODO: map parsed JSON → ParseNeedResult shape
|
return MockAIService.parseNeed(input)
|
||||||
},
|
}
|
||||||
|
const extractedCriteria: ParsedNeedCriteria = {
|
||||||
async generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise<ItemResponse<FollowUpQuestion[]>> {
|
assetType: (ai.assetType ?? undefined) as AssetType | undefined,
|
||||||
const config = getConfig()
|
areaRange: ai.areaRange ?? undefined,
|
||||||
if (!config) return MockAIService.generateFollowUpQuestions(criteria)
|
preferredLocations: ai.preferredLocations,
|
||||||
// TODO: implement OpenRouter call using followUpQuestionsPrompt
|
budgetRange: ai.budgetRange ?? undefined,
|
||||||
return MockAIService.generateFollowUpQuestions(criteria)
|
timing: ai.timing
|
||||||
},
|
? { ...ai.timing, latestMoveIn: ai.timing.latestMoveIn ?? undefined }
|
||||||
|
: undefined,
|
||||||
async summarizeComparison(items: UnifiedMatchResult[]): Promise<ItemResponse<ComparisonSummary>> {
|
mustHaveCriteria: ai.mustHaveCriteria,
|
||||||
const config = getConfig()
|
}
|
||||||
if (!config) return MockAIService.summarizeComparison(items)
|
const missingFields = ai.missingFields ?? []
|
||||||
|
const confidenceByField: Record<string, number> = {}
|
||||||
const properties = items
|
Object.keys(extractedCriteria).forEach(k => {
|
||||||
.filter(i => i.resultType !== 'FUTURE_AVAILABILITY')
|
confidenceByField[k] = extractedCriteria[k as keyof ParsedNeedCriteria] != null ? 0.85 : 0
|
||||||
.map(i => ({
|
})
|
||||||
title: (i as Record<string, unknown> & { property?: { title?: string } }).property?.title ?? 'Unbekannt',
|
missingFields.forEach(f => { confidenceByField[f] = 0 })
|
||||||
matchScore: i.matchScore,
|
const followUpQuestionCandidates: FollowUpQuestion[] = missingFields.map((field, i) => ({
|
||||||
city: (i as Record<string, unknown> & { property?: { location?: { city?: string } } }).property?.location?.city ?? '–',
|
id: `fq-or-${i}`,
|
||||||
rentPerSqm: (i as Record<string, unknown> & { property?: { rentPricePerSqm?: number } }).property?.rentPricePerSqm ?? 0,
|
questionText: followUpForField(field),
|
||||||
positiveFactors: i.match.positiveFactors.slice(0, 2).map(f => f.explanation ?? f.label),
|
targetField: field,
|
||||||
negativeFactors: i.match.negativeFactors.slice(0, 2).map(f => f.explanation ?? f.label),
|
reason: `Feld "${field}" nicht im Text erkannt`,
|
||||||
|
importance: 'recommended' as const,
|
||||||
}))
|
}))
|
||||||
|
return {
|
||||||
const { system, user } = buildCompareSummaryPrompt({ properties })
|
data: {
|
||||||
const raw = await chat(config, system, user)
|
extractedCriteria,
|
||||||
const parsed = parseJSON<Partial<ComparisonSummary>>(raw, {})
|
confidenceByField,
|
||||||
if (!parsed.overallAssessment) return MockAIService.summarizeComparison(items)
|
missingFields,
|
||||||
// Merge AI overallAssessment into mock baseline
|
assumptions: ai.assumptions ?? [],
|
||||||
const mock = await MockAIService.summarizeComparison(items)
|
suggestedWeights: defaultSuggestedWeights(),
|
||||||
return { data: { ...mock.data, overallAssessment: parsed.overallAssessment, recommendation: parsed.recommendation ?? mock.data.recommendation } }
|
followUpQuestionCandidates,
|
||||||
},
|
rawSummary: raw.substring(0, 500),
|
||||||
|
promptVersion: PROMPT_VERSION,
|
||||||
async generateDecisionBrief(shortlistId: string): Promise<ItemResponse<DecisionBrief>> {
|
schemaVersion: SCHEMA_VERSION,
|
||||||
const config = getConfig()
|
},
|
||||||
if (!config) return MockAIService.generateDecisionBrief(shortlistId)
|
}
|
||||||
// TODO: pass real shortlist items via context when available
|
}, () => MockAIService.parseNeed(input))
|
||||||
const { system, user } = buildDecisionBriefPrompt({ shortlistItems: [], needSummary: shortlistId })
|
},
|
||||||
const raw = await chat(config, system, user)
|
|
||||||
const parsed = parseJSON<Partial<DecisionBrief>>(raw, {})
|
// ── generateFollowUpQuestions ───────────────────────────────────────────────
|
||||||
if (!parsed.summary) return MockAIService.generateDecisionBrief(shortlistId)
|
generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise<ItemResponse<FollowUpQuestion[]>> {
|
||||||
const mock = await MockAIService.generateDecisionBrief(shortlistId)
|
return withFallback('generateFollowUpQuestions', async (config) => {
|
||||||
return { data: { ...mock.data, summary: parsed.summary, sections: parsed.sections ?? mock.data.sections } }
|
const missingFields = Object.entries(criteria)
|
||||||
},
|
.filter(([, v]) => v == null)
|
||||||
|
.map(([k]) => k)
|
||||||
async generateOfferEmail(payload: OfferEmailPayload): Promise<ItemResponse<{ subject: string; body: string }>> {
|
const { system, user } = buildFollowUpQuestionsPrompt({ criteria, missingFields })
|
||||||
const config = getConfig()
|
const raw = await chat(config, system, user)
|
||||||
if (!config) return MockAIService.generateOfferEmail(payload)
|
type RawFQ = { questionText?: string; targetField?: string; reason?: string; suggestedAnswerOptions?: string[]; importance?: string }
|
||||||
// TODO: implement OpenRouter call
|
const ai = extractJSON<RawFQ[]>(raw)
|
||||||
return MockAIService.generateOfferEmail(payload)
|
if (!ai?.length) {
|
||||||
},
|
console.warn('[OpenRouterAIService] generateFollowUpQuestions: empty response — using mock fallback')
|
||||||
|
return MockAIService.generateFollowUpQuestions(criteria)
|
||||||
async extractCriteria(input: string): Promise<ItemResponse<CriteriaExtractionResult>> {
|
}
|
||||||
const config = getConfig()
|
return {
|
||||||
if (!config) return MockAIService.extractCriteria(input)
|
data: ai.map((q, i) => ({
|
||||||
return MockAIService.extractCriteria(input)
|
id: `fq-or-${i}`,
|
||||||
},
|
questionText: q.questionText ?? '?',
|
||||||
|
targetField: q.targetField ?? 'unknown',
|
||||||
async generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<ItemResponse<string[]>> {
|
reason: q.reason ?? 'AI-generiert',
|
||||||
const config = getConfig()
|
suggestedAnswerOptions: q.suggestedAnswerOptions,
|
||||||
if (!config) return MockAIService.generateFollowUp(partialNeed)
|
importance: (['required', 'recommended', 'optional'].includes(q.importance ?? '')
|
||||||
return MockAIService.generateFollowUp(partialNeed)
|
? q.importance
|
||||||
|
: 'recommended') as FollowUpQuestion['importance'],
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}, () => MockAIService.generateFollowUpQuestions(criteria))
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── generateMatchExplanation ────────────────────────────────────────────────
|
||||||
|
generateMatchExplanation(input: MatchExplanationInput): Promise<ItemResponse<MatchExplanation>> {
|
||||||
|
return withFallback('generateMatchExplanation', async (config) => {
|
||||||
|
const { system, user } = buildMatchExplanationPrompt(input)
|
||||||
|
const raw = await chat(config, system, user)
|
||||||
|
// matchExplanationPrompt returns plain text (max 3 sentences), not JSON
|
||||||
|
const summary = raw.trim()
|
||||||
|
if (!summary) {
|
||||||
|
console.warn('[OpenRouterAIService] generateMatchExplanation: empty response — using mock fallback')
|
||||||
|
return MockAIService.generateMatchExplanation(input)
|
||||||
|
}
|
||||||
|
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}`),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}, () => MockAIService.generateMatchExplanation(input))
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── summarizeTradeOffs ──────────────────────────────────────────────────────
|
||||||
|
summarizeTradeOffs(tradeoffs: TradeOffInput[]): Promise<ItemResponse<TradeOffSummary>> {
|
||||||
|
return withFallback('summarizeTradeOffs', async (config) => {
|
||||||
|
const { system, user } = buildTradeOffPrompt(tradeoffs, 'Objekt')
|
||||||
|
const raw = await chat(config, system, user)
|
||||||
|
type RawTradeOff = {
|
||||||
|
headline?: string
|
||||||
|
items?: Array<{ concern?: string; severity?: string; mitigation?: string }>
|
||||||
|
overallRisk?: string
|
||||||
|
}
|
||||||
|
const ai = extractJSON<RawTradeOff>(raw)
|
||||||
|
if (!ai?.headline) {
|
||||||
|
console.warn('[OpenRouterAIService] summarizeTradeOffs: incomplete response — using mock fallback')
|
||||||
|
return MockAIService.summarizeTradeOffs(tradeoffs)
|
||||||
|
}
|
||||||
|
const validSeverity = (s?: string): 'LOW' | 'MEDIUM' | 'HIGH' =>
|
||||||
|
(['LOW', 'MEDIUM', 'HIGH'].includes(s ?? '') ? s : 'MEDIUM') as 'LOW' | 'MEDIUM' | 'HIGH'
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
headline: ai.headline,
|
||||||
|
items: (ai.items ?? []).map(item => ({
|
||||||
|
concern: item.concern ?? '',
|
||||||
|
severity: validSeverity(item.severity),
|
||||||
|
mitigation: item.mitigation,
|
||||||
|
})),
|
||||||
|
overallRisk: validSeverity(ai.overallRisk),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}, () => MockAIService.summarizeTradeOffs(tradeoffs))
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── summarizeComparison ─────────────────────────────────────────────────────
|
||||||
|
summarizeComparison(items: UnifiedMatchResult[]): Promise<ItemResponse<ComparisonSummary>> {
|
||||||
|
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)
|
||||||
|
type RawComparison = {
|
||||||
|
overallAssessment?: string
|
||||||
|
recommendation?: string
|
||||||
|
strongestOption?: { matchId?: string; label?: string; reason?: string }
|
||||||
|
}
|
||||||
|
const ai = extractJSON<RawComparison>(raw)
|
||||||
|
if (!ai?.overallAssessment) {
|
||||||
|
console.warn('[OpenRouterAIService] summarizeComparison: incomplete response — using mock fallback')
|
||||||
|
return MockAIService.summarizeComparison(items)
|
||||||
|
}
|
||||||
|
// Hybrid: AI provides the narrative, mock provides the structural data (perPropertyAssessment etc.)
|
||||||
|
const mock = await MockAIService.summarizeComparison(items)
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
...mock.data,
|
||||||
|
overallAssessment: ai.overallAssessment,
|
||||||
|
recommendation: ai.recommendation ?? mock.data.recommendation,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}, () => MockAIService.summarizeComparison(items))
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── generateDecisionBrief ───────────────────────────────────────────────────
|
||||||
|
generateDecisionBrief(shortlistId: string): Promise<ItemResponse<DecisionBrief>> {
|
||||||
|
return withFallback('generateDecisionBrief', async (config) => {
|
||||||
|
const { system, user } = buildDecisionBriefPrompt({ shortlistItems: [], needSummary: shortlistId })
|
||||||
|
const raw = await chat(config, system, user)
|
||||||
|
type RawBrief = {
|
||||||
|
summary?: string
|
||||||
|
sections?: Array<{ title?: string; body?: string }>
|
||||||
|
}
|
||||||
|
const ai = extractJSON<RawBrief>(raw)
|
||||||
|
if (!ai?.summary) {
|
||||||
|
console.warn('[OpenRouterAIService] generateDecisionBrief: incomplete response — using mock fallback')
|
||||||
|
return MockAIService.generateDecisionBrief(shortlistId)
|
||||||
|
}
|
||||||
|
const mock = await MockAIService.generateDecisionBrief(shortlistId)
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
...mock.data,
|
||||||
|
summary: ai.summary,
|
||||||
|
sections: ai.sections?.map(s => ({
|
||||||
|
title: s.title ?? '',
|
||||||
|
body: s.body ?? '',
|
||||||
|
})) ?? mock.data.sections,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}, () => MockAIService.generateDecisionBrief(shortlistId))
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── generateDataQualitySummary ──────────────────────────────────────────────
|
||||||
|
generateDataQualitySummary(propertyId: string, quality: DataQualityInput): Promise<ItemResponse<DataQualitySummary>> {
|
||||||
|
return withFallback('generateDataQualitySummary', async (config) => {
|
||||||
|
const { system, user } = buildDataQualityPrompt(propertyId, quality)
|
||||||
|
const raw = await chat(config, system, user)
|
||||||
|
type RawDQ = {
|
||||||
|
overallAssessment?: string
|
||||||
|
missingCriticalFields?: string[]
|
||||||
|
recommendation?: string
|
||||||
|
confidence?: number
|
||||||
|
}
|
||||||
|
const ai = extractJSON<RawDQ>(raw)
|
||||||
|
if (!ai?.overallAssessment) {
|
||||||
|
console.warn('[OpenRouterAIService] generateDataQualitySummary: incomplete response — using mock fallback')
|
||||||
|
return MockAIService.generateDataQualitySummary(propertyId, quality)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
overallAssessment: ai.overallAssessment,
|
||||||
|
missingCriticalFields: ai.missingCriticalFields ?? quality.missingCriticalFields,
|
||||||
|
recommendation: ai.recommendation ?? '',
|
||||||
|
confidence: typeof ai.confidence === 'number' ? ai.confidence : quality.score,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}, () => MockAIService.generateDataQualitySummary(propertyId, quality))
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── classifyMarketSignal ────────────────────────────────────────────────────
|
||||||
|
classifyMarketSignal(signalText: string): Promise<ItemResponse<MarketSignalClassification>> {
|
||||||
|
return withFallback('classifyMarketSignal', async (config) => {
|
||||||
|
const { system, user } = buildMarketSignalPrompt(signalText)
|
||||||
|
const raw = await chat(config, system, user)
|
||||||
|
type RawSignal = {
|
||||||
|
signalType?: string
|
||||||
|
probability?: number
|
||||||
|
timeHorizonMonths?: number | null
|
||||||
|
areaSqmEstimate?: number | null
|
||||||
|
credibility?: string
|
||||||
|
reasoning?: string
|
||||||
|
}
|
||||||
|
const ai = extractJSON<RawSignal>(raw)
|
||||||
|
if (!ai?.signalType) {
|
||||||
|
console.warn('[OpenRouterAIService] classifyMarketSignal: incomplete response — using mock fallback')
|
||||||
|
return MockAIService.classifyMarketSignal(signalText)
|
||||||
|
}
|
||||||
|
const validSignalType = (s?: string): MarketSignalClassification['signalType'] => {
|
||||||
|
const valid: MarketSignalClassification['signalType'][] =
|
||||||
|
['VACANCY', 'CONSTRUCTION', 'RESTRUCTURING', 'EXPANSION', 'RELOCATION', 'UNKNOWN']
|
||||||
|
return (valid.includes(s as MarketSignalClassification['signalType']) ? s : 'UNKNOWN') as MarketSignalClassification['signalType']
|
||||||
|
}
|
||||||
|
const validCredibility = (s?: string): 'LOW' | 'MEDIUM' | 'HIGH' =>
|
||||||
|
(['LOW', 'MEDIUM', 'HIGH'].includes(s ?? '') ? s : 'MEDIUM') as 'LOW' | 'MEDIUM' | 'HIGH'
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
signalType: validSignalType(ai.signalType),
|
||||||
|
probability: typeof ai.probability === 'number'
|
||||||
|
? Math.min(1, Math.max(0, ai.probability))
|
||||||
|
: 0.5,
|
||||||
|
timeHorizonMonths: typeof ai.timeHorizonMonths === 'number' ? ai.timeHorizonMonths : null,
|
||||||
|
areaSqmEstimate: typeof ai.areaSqmEstimate === 'number' ? ai.areaSqmEstimate : null,
|
||||||
|
credibility: validCredibility(ai.credibility),
|
||||||
|
reasoning: ai.reasoning ?? '',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}, () => MockAIService.classifyMarketSignal(signalText))
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── generateOfferEmail ──────────────────────────────────────────────────────
|
||||||
|
generateOfferEmail(payload: OfferEmailPayload): Promise<ItemResponse<{ subject: string; body: string }>> {
|
||||||
|
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)
|
||||||
|
type RawEmail = { subject?: string; body?: string }
|
||||||
|
const ai = extractJSON<RawEmail>(raw)
|
||||||
|
if (!ai?.subject || !ai?.body) {
|
||||||
|
console.warn('[OpenRouterAIService] generateOfferEmail: incomplete response — using mock fallback')
|
||||||
|
return MockAIService.generateOfferEmail(payload)
|
||||||
|
}
|
||||||
|
return { data: { subject: ai.subject, body: ai.body } }
|
||||||
|
}, () => MockAIService.generateOfferEmail(payload))
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Legacy: extractCriteria ─────────────────────────────────────────────────
|
||||||
|
extractCriteria(input: string): Promise<ItemResponse<CriteriaExtractionResult>> {
|
||||||
|
return withFallback('extractCriteria', async (config) => {
|
||||||
|
const { system, user } = buildNeedParsingPrompt({ userInput: input })
|
||||||
|
const raw = await chat(config, system, user)
|
||||||
|
const ai = extractJSON<RawNeedParseAI>(raw)
|
||||||
|
if (!ai) {
|
||||||
|
console.warn('[OpenRouterAIService] extractCriteria: could not parse JSON — using mock fallback')
|
||||||
|
return MockAIService.extractCriteria(input)
|
||||||
|
}
|
||||||
|
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),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}, () => MockAIService.extractCriteria(input))
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Legacy: generateFollowUp ────────────────────────────────────────────────
|
||||||
|
generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<ItemResponse<string[]>> {
|
||||||
|
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: [] }
|
||||||
|
const { system, user } = buildFollowUpQuestionsPrompt({
|
||||||
|
criteria: partialNeed as ParsedNeedCriteria,
|
||||||
|
missingFields,
|
||||||
|
})
|
||||||
|
const raw = await chat(config, system, user)
|
||||||
|
type RawFQ = { questionText?: string }
|
||||||
|
const ai = extractJSON<RawFQ[]>(raw)
|
||||||
|
if (!ai?.length) {
|
||||||
|
console.warn('[OpenRouterAIService] generateFollowUp: empty response — using mock fallback')
|
||||||
|
return MockAIService.generateFollowUp(partialNeed)
|
||||||
|
}
|
||||||
|
return { data: ai.map(q => q.questionText ?? '').filter(Boolean) }
|
||||||
|
}, () => MockAIService.generateFollowUp(partialNeed))
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { DataQualityInput } from '../IAIService'
|
||||||
|
|
||||||
|
export function buildDataQualityPrompt(propertyId: string, quality: DataQualityInput): { system: string; user: string } {
|
||||||
|
const scorePercent = Math.round(quality.score * 100)
|
||||||
|
const criticalList = quality.missingCriticalFields.join(', ') || 'keine'
|
||||||
|
const optionalList = quality.missingOptionalFields.join(', ') || 'keine'
|
||||||
|
const warningList = quality.warnings.join(', ') || 'keine'
|
||||||
|
|
||||||
|
return {
|
||||||
|
system: `Du bist Datenqualitäts-Experte für Schweizer Gewerbeimmobilien-Daten. Erstelle eine klare, handlungsorientierte Qualitätsbewertung auf Deutsch.
|
||||||
|
|
||||||
|
Antworte als valides JSON:
|
||||||
|
{
|
||||||
|
"overallAssessment": "...",
|
||||||
|
"missingCriticalFields": ["..."],
|
||||||
|
"recommendation": "...",
|
||||||
|
"confidence": 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
Die Confidence entspricht dem übergebenen Score (0–1). Halte die Bewertung unter 3 Sätzen.`,
|
||||||
|
user: `Qualitätsbewertung für Objekt ${propertyId}:
|
||||||
|
- Score: ${scorePercent}%
|
||||||
|
- Freshness: ${quality.freshness}
|
||||||
|
- Fehlende Pflichtfelder: ${criticalList}
|
||||||
|
- Fehlende optionale Felder: ${optionalList}
|
||||||
|
- Warnungen: ${warningList}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { ParsedNeedCriteria } from '../../../domain/needBuilder'
|
||||||
|
|
||||||
|
export interface FollowUpQuestionsPromptInput {
|
||||||
|
criteria: ParsedNeedCriteria
|
||||||
|
missingFields: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFollowUpQuestionsPrompt(input: FollowUpQuestionsPromptInput): { system: string; user: string } {
|
||||||
|
const knownFields = Object.entries(input.criteria)
|
||||||
|
.filter(([, v]) => v != null)
|
||||||
|
.map(([k]) => k)
|
||||||
|
.join(', ')
|
||||||
|
|
||||||
|
return {
|
||||||
|
system: `Du bist ein Experte für Schweizer Gewerbeimmobilien-Suche. Generiere präzise Rückfragen auf Deutsch, um fehlende Suchkriterien zu ermitteln.
|
||||||
|
|
||||||
|
Antworte als valides JSON-Array mit maximal 3 Einträgen, priorisiert nach Wichtigkeit:
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"questionText": "...",
|
||||||
|
"targetField": "assetType|areaRange|preferredLocations|budgetRange|timing|mustHaveCriteria",
|
||||||
|
"reason": "...",
|
||||||
|
"suggestedAnswerOptions": ["...", "..."],
|
||||||
|
"importance": "required|recommended|optional"
|
||||||
|
}
|
||||||
|
]`,
|
||||||
|
user: `Bereits bekannte Kriterien: ${knownFields || 'keine'}
|
||||||
|
Fehlende Felder: ${input.missingFields.join(', ') || 'keine'}
|
||||||
|
|
||||||
|
Generiere Rückfragen für die wichtigsten fehlenden Informationen.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
export function buildMarketSignalPrompt(signalText: string): { system: string; user: string } {
|
||||||
|
return {
|
||||||
|
system: `Du bist Marktanalyst für Schweizer Gewerbeimmobilien. Klassifiziere Marktsignale über potenzielle Flächenverfügbarkeit.
|
||||||
|
|
||||||
|
Antworte als valides JSON:
|
||||||
|
{
|
||||||
|
"signalType": "VACANCY|CONSTRUCTION|RESTRUCTURING|EXPANSION|RELOCATION|UNKNOWN",
|
||||||
|
"probability": 0.0,
|
||||||
|
"timeHorizonMonths": null,
|
||||||
|
"areaSqmEstimate": null,
|
||||||
|
"credibility": "LOW|MEDIUM|HIGH",
|
||||||
|
"reasoning": "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
Signaltypen:
|
||||||
|
- VACANCY: Fläche wird frei (Mietende, Unternehmensschliessung, Leerstand)
|
||||||
|
- CONSTRUCTION: Neubau oder Umbau in Planung oder Bau
|
||||||
|
- RESTRUCTURING: Unternehmen verkleinert oder reorganisiert Standorte
|
||||||
|
- EXPANSION: Unternehmen wächst und sucht zusätzliche Fläche
|
||||||
|
- RELOCATION: Unternehmen verlegt Standort innerhalb der Region
|
||||||
|
- UNKNOWN: Signal nicht eindeutig klassifizierbar
|
||||||
|
|
||||||
|
probability: 0–1, wie wahrscheinlich das Signal zutrifft
|
||||||
|
credibility: Glaubwürdigkeit der Quelle (LOW/MEDIUM/HIGH)
|
||||||
|
timeHorizonMonths: geschätzte Monate bis Verfügbarkeit (null wenn unklar)
|
||||||
|
areaSqmEstimate: geschätzte Fläche in m² (null wenn unklar)`,
|
||||||
|
user: `Klassifiziere folgendes Marktsignal:\n\n${signalText}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { TradeOffInput } from '../IAIService'
|
||||||
|
|
||||||
|
export function buildTradeOffPrompt(tradeoffs: TradeOffInput[], propertyTitle: string): { system: string; user: string } {
|
||||||
|
const tradeoffList = tradeoffs.length > 0
|
||||||
|
? tradeoffs
|
||||||
|
.map(t => `- ${t.criterion}: ${t.concern} (Schweregrad: ${t.severity})${t.mitigation ? ` — Massnahme: ${t.mitigation}` : ''}`)
|
||||||
|
.join('\n')
|
||||||
|
: 'Keine Trade-offs angegeben.'
|
||||||
|
|
||||||
|
return {
|
||||||
|
system: `Du bist Senior Real Estate Advisor. Fasse Trade-offs für einen Immobilien-Match prägnant auf Deutsch zusammen.
|
||||||
|
|
||||||
|
Antworte als valides JSON:
|
||||||
|
{
|
||||||
|
"headline": "...",
|
||||||
|
"items": [{ "concern": "...", "severity": "LOW|MEDIUM|HIGH", "mitigation": "..." }],
|
||||||
|
"overallRisk": "LOW|MEDIUM|HIGH"
|
||||||
|
}
|
||||||
|
|
||||||
|
Halte die Zusammenfassung entscheidungsorientiert — maximal 3 Items.`,
|
||||||
|
user: `Fasse folgende Trade-offs für "${propertyTitle}" zusammen:\n\n${tradeoffList}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,5 +15,12 @@ export type {
|
|||||||
AIServiceProvider,
|
AIServiceProvider,
|
||||||
ParsedListingData,
|
ParsedListingData,
|
||||||
OfferEmailPayload,
|
OfferEmailPayload,
|
||||||
|
MatchExplanation,
|
||||||
|
MatchExplanationInput,
|
||||||
|
TradeOffInput,
|
||||||
|
TradeOffSummary,
|
||||||
|
DataQualityInput,
|
||||||
|
DataQualitySummary,
|
||||||
|
MarketSignalClassification,
|
||||||
} from './ai/IAIService'
|
} from './ai/IAIService'
|
||||||
export { parseListingText } from './ai/mock/listingParser'
|
export { parseListingText } from './ai/mock/listingParser'
|
||||||
|
|||||||
Reference in New Issue
Block a user