feat: unified error handling + AI service modularisation
Error handling (Prompt 2): - src/services/errors.ts: AppError class, normalizeError(), throwServiceError() helper - 6 services wrapped with try/catch (property, match, need, shortlist, futureSignal, inquiry) - inquiryService aligned from custom ServiceResult<T> to standard ServiceResponse types - Results, MatchCenter, FutureAvailability pages show <ErrorState onRetry> on query failure AI modularisation (Prompt 3): - src/services/aiService.ts reduced from 755 → 19 lines (barrel re-export) - src/services/ai/IAIService.ts: typed interface + all response types - src/services/ai/mock/: needParser, compareBuilder, decisionBrief, listingParser, MockAIService - src/services/ai/openrouter/OpenRouterAIService.ts: model-agnostic skeleton - src/services/ai/prompts/: 4 prompt template files (needParsing, matchExplanation, compareSummary, decisionBrief) - src/services/ai/index.ts: factory selects Mock or OpenRouter via VITE_USE_REAL_AI flag - All existing import paths unchanged — zero call-site modifications Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* OpenRouter AI Service
|
||||
*
|
||||
* To activate:
|
||||
* 1. Set VITE_USE_REAL_AI=true in your .env file
|
||||
* 2. Set VITE_OPENROUTER_API_KEY=<your-key>
|
||||
* 3. Optionally set VITE_OPENROUTER_MODEL (default: anthropic/claude-3-5-haiku)
|
||||
*
|
||||
* This service is model-agnostic — change VITE_OPENROUTER_MODEL to switch
|
||||
* between Claude, GPT-4o, Mistral, Llama, etc. without code changes.
|
||||
*/
|
||||
import type { ItemResponse } from '../../types'
|
||||
import type { CreateNeedInput } from '../../../domain/need'
|
||||
import type { ParseNeedResult, ParsedNeedCriteria, FollowUpQuestion } from '../../../domain/needBuilder'
|
||||
import type { UnifiedMatchResult } from '../../../domain/unifiedResult'
|
||||
import type {
|
||||
IAIService,
|
||||
DecisionBrief,
|
||||
ComparisonSummary,
|
||||
CriteriaExtractionResult,
|
||||
OfferEmailPayload,
|
||||
} from '../IAIService'
|
||||
import { ServiceErrorCode } from '../../types'
|
||||
import { AppError } from '../../errors'
|
||||
import { buildNeedParsingPrompt } from '../prompts/needParsingPrompt'
|
||||
import { buildCompareSummaryPrompt } from '../prompts/compareSummaryPrompt'
|
||||
import { buildDecisionBriefPrompt } from '../prompts/decisionBriefPrompt'
|
||||
import { MockAIService } from '../mock/MockAIService'
|
||||
|
||||
const API_BASE = 'https://openrouter.ai/api/v1'
|
||||
const DEFAULT_MODEL = 'anthropic/claude-3-5-haiku'
|
||||
|
||||
function getConfig(): { apiKey: string; model: string } | 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,
|
||||
}
|
||||
}
|
||||
|
||||
async function chat(config: { apiKey: string; model: string }, system: string, user: string): Promise<string> {
|
||||
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 ?? ''
|
||||
}
|
||||
|
||||
function parseJSON<T>(raw: string, fallback: T): T {
|
||||
const jsonMatch = raw.match(/```json\n?([\s\S]*?)\n?```/) ?? raw.match(/(\{[\s\S]*\})/)
|
||||
try {
|
||||
return JSON.parse(jsonMatch ? jsonMatch[1] : raw) as T
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
export const OpenRouterAIService: IAIService = {
|
||||
async parseNeed(input: string): Promise<ItemResponse<ParseNeedResult>> {
|
||||
const config = getConfig()
|
||||
if (!config) return MockAIService.parseNeed(input)
|
||||
|
||||
const { system, user } = buildNeedParsingPrompt({ userInput: input })
|
||||
const raw = await chat(config, system, user)
|
||||
const parsed = parseJSON(raw, null)
|
||||
// If parse fails fall back to mock (keeps app working even with bad AI responses)
|
||||
if (!parsed) return MockAIService.parseNeed(input)
|
||||
return MockAIService.parseNeed(input) // TODO: map parsed JSON → ParseNeedResult shape
|
||||
},
|
||||
|
||||
async generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise<ItemResponse<FollowUpQuestion[]>> {
|
||||
const config = getConfig()
|
||||
if (!config) return MockAIService.generateFollowUpQuestions(criteria)
|
||||
// TODO: implement OpenRouter call using followUpQuestionsPrompt
|
||||
return MockAIService.generateFollowUpQuestions(criteria)
|
||||
},
|
||||
|
||||
async summarizeComparison(items: UnifiedMatchResult[]): Promise<ItemResponse<ComparisonSummary>> {
|
||||
const config = getConfig()
|
||||
if (!config) return MockAIService.summarizeComparison(items)
|
||||
|
||||
const properties = items
|
||||
.filter(i => i.resultType !== 'FUTURE_AVAILABILITY')
|
||||
.map(i => ({
|
||||
title: (i as Record<string, unknown> & { property?: { title?: string } }).property?.title ?? 'Unbekannt',
|
||||
matchScore: i.matchScore,
|
||||
city: (i as Record<string, unknown> & { property?: { location?: { city?: string } } }).property?.location?.city ?? '–',
|
||||
rentPerSqm: (i as Record<string, unknown> & { property?: { rentPricePerSqm?: number } }).property?.rentPricePerSqm ?? 0,
|
||||
positiveFactors: i.match.positiveFactors.slice(0, 2).map(f => f.explanation ?? f.label),
|
||||
negativeFactors: i.match.negativeFactors.slice(0, 2).map(f => f.explanation ?? f.label),
|
||||
}))
|
||||
|
||||
const { system, user } = buildCompareSummaryPrompt({ properties })
|
||||
const raw = await chat(config, system, user)
|
||||
const parsed = parseJSON<Partial<ComparisonSummary>>(raw, {})
|
||||
if (!parsed.overallAssessment) return MockAIService.summarizeComparison(items)
|
||||
// Merge AI overallAssessment into mock baseline
|
||||
const mock = await MockAIService.summarizeComparison(items)
|
||||
return { data: { ...mock.data, overallAssessment: parsed.overallAssessment, recommendation: parsed.recommendation ?? mock.data.recommendation } }
|
||||
},
|
||||
|
||||
async generateDecisionBrief(shortlistId: string): Promise<ItemResponse<DecisionBrief>> {
|
||||
const config = getConfig()
|
||||
if (!config) return MockAIService.generateDecisionBrief(shortlistId)
|
||||
// TODO: pass real shortlist items via context when available
|
||||
const { system, user } = buildDecisionBriefPrompt({ shortlistItems: [], needSummary: shortlistId })
|
||||
const raw = await chat(config, system, user)
|
||||
const parsed = parseJSON<Partial<DecisionBrief>>(raw, {})
|
||||
if (!parsed.summary) return MockAIService.generateDecisionBrief(shortlistId)
|
||||
const mock = await MockAIService.generateDecisionBrief(shortlistId)
|
||||
return { data: { ...mock.data, summary: parsed.summary, sections: parsed.sections ?? mock.data.sections } }
|
||||
},
|
||||
|
||||
async generateOfferEmail(payload: OfferEmailPayload): Promise<ItemResponse<{ subject: string; body: string }>> {
|
||||
const config = getConfig()
|
||||
if (!config) return MockAIService.generateOfferEmail(payload)
|
||||
// TODO: implement OpenRouter call
|
||||
return MockAIService.generateOfferEmail(payload)
|
||||
},
|
||||
|
||||
async extractCriteria(input: string): Promise<ItemResponse<CriteriaExtractionResult>> {
|
||||
const config = getConfig()
|
||||
if (!config) return MockAIService.extractCriteria(input)
|
||||
return MockAIService.extractCriteria(input)
|
||||
},
|
||||
|
||||
async generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<ItemResponse<string[]>> {
|
||||
const config = getConfig()
|
||||
if (!config) return MockAIService.generateFollowUp(partialNeed)
|
||||
return MockAIService.generateFollowUp(partialNeed)
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user