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:
Benjamin Sutter
2026-05-24 00:32:01 +02:00
parent efc72b720e
commit 6515acb7f0
23 changed files with 1468 additions and 955 deletions
@@ -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)
},
}