feat: Zod AI validation, AIProvenance governance, fix tests (154 green)
- Add AIProvenance + AIResponse<T> to IAIService — all 11 methods now return structured provenance (provider, model, source, fallbackUsed, validationPassed) instead of bare ItemResponse<T> - Add schemas.ts with Zod schemas for all 8 AI response types; validateAIResponse() utility returns null on failure, never throws - Rewrite OpenRouterAIService: every method validates AI JSON against its Zod schema; failed validation triggers MockAIService fallback with fallbackUsed:true — no invalid data can reach the UI - Fix MockAIService.generateFollowUpQuestions: replace broken mockParseNeed(JSON.stringify(criteria)) with direct ParsedNeedCriteria field inspection; returns max 3 prioritised FollowUpQuestion objects - Add provenance: mockProvenance() to all MockAIService responses - Improve decisionBriefPrompt: structured JSON schema example, confidence vocabulary, availability disclaimer - Improve matchExplanationPrompt: score-tier vocabulary, isFutureSignal flag forbids confirmed-availability language for future signals - Add 102 new tests: mustHaveScorer (16), softFactorEnrichment (38), aiSchemas (52) — 154 total, all passing; 0 TypeScript errors Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { scoreMustHaves, MUST_HAVE_PENALTY_PER_MISS, MUST_HAVE_MAX_PENALTY } from '../mustHaveScorer'
|
||||
import { makeProperty } from './fixtures'
|
||||
|
||||
// ── Empty criteria ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('scoreMustHaves — empty criteria', () => {
|
||||
it('returns zero impact and empty results when criteria list is empty', () => {
|
||||
const out = scoreMustHaves([], makeProperty())
|
||||
expect(out.results).toHaveLength(0)
|
||||
expect(Math.abs(out.scoreImpact)).toBe(0) // Math.abs handles JS -0 === +0
|
||||
expect(out.passedCount).toBe(0)
|
||||
expect(out.totalCount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Erdgeschoss detection ──────────────────────────────────────────────────────
|
||||
|
||||
describe('scoreMustHaves — Erdgeschoss (ground floor)', () => {
|
||||
it('PASSES when property is floor 0 and criterion contains "erdgeschoss"', () => {
|
||||
const prop = makeProperty({ hardFacts: { floor: 0 } } as any)
|
||||
const out = scoreMustHaves(['Erdgeschoss erforderlich'], prop)
|
||||
expect(out.results[0].passed).toBe(true)
|
||||
expect(out.results[0].confidence).toBe('CERTAIN')
|
||||
expect(Math.abs(out.scoreImpact)).toBe(0) // no penalty for pass
|
||||
})
|
||||
|
||||
it('FAILS when property is floor 1 and criterion contains "erdgeschoss"', () => {
|
||||
const prop = makeProperty({ hardFacts: { floor: 1 } } as any)
|
||||
const out = scoreMustHaves(['Erdgeschoss ist Pflicht'], prop)
|
||||
expect(out.results[0].passed).toBe(false)
|
||||
expect(out.results[0].confidence).toBe('CERTAIN')
|
||||
expect(out.scoreImpact).toBe(-MUST_HAVE_PENALTY_PER_MISS)
|
||||
})
|
||||
|
||||
it('returns UNKNOWN confidence when floor field is not documented', () => {
|
||||
const prop = makeProperty() // no hardFacts in base fixture
|
||||
const out = scoreMustHaves(['Erdgeschoss'], prop)
|
||||
expect(out.results[0].confidence).toBe('UNKNOWN')
|
||||
expect(Math.abs(out.scoreImpact)).toBe(0) // UNKNOWN doesn't apply penalty
|
||||
})
|
||||
})
|
||||
|
||||
// ── Klimaanlage detection ──────────────────────────────────────────────────────
|
||||
|
||||
describe('scoreMustHaves — Klimaanlage (air conditioning)', () => {
|
||||
it('PASSES when hasAirConditioning is true', () => {
|
||||
const prop = makeProperty({ hardFacts: { hasAirConditioning: true } } as any)
|
||||
const out = scoreMustHaves(['Klimaanlage vorhanden'], prop)
|
||||
expect(out.results[0].passed).toBe(true)
|
||||
expect(out.results[0].confidence).toBe('CERTAIN')
|
||||
})
|
||||
|
||||
it('FAILS when hasAirConditioning is false', () => {
|
||||
const prop = makeProperty({ hardFacts: { hasAirConditioning: false } } as any)
|
||||
const out = scoreMustHaves(['Air conditioning benötigt'], prop)
|
||||
expect(out.results[0].passed).toBe(false)
|
||||
expect(out.results[0].confidence).toBe('CERTAIN')
|
||||
expect(out.scoreImpact).toBe(-MUST_HAVE_PENALTY_PER_MISS)
|
||||
})
|
||||
|
||||
it('returns UNKNOWN when AC data is missing', () => {
|
||||
const prop = makeProperty()
|
||||
const out = scoreMustHaves(['HVAC Anlage'], prop)
|
||||
expect(out.results[0].confidence).toBe('UNKNOWN')
|
||||
})
|
||||
})
|
||||
|
||||
// ── Laderampe detection ───────────────────────────────────────────────────────
|
||||
|
||||
describe('scoreMustHaves — Laderampe (loading dock)', () => {
|
||||
it('PASSES when loadingDocksCount >= 1', () => {
|
||||
const prop = makeProperty({ hardFacts: { loadingDocksCount: 2 } } as any)
|
||||
const out = scoreMustHaves(['Laderampe erforderlich'], prop)
|
||||
expect(out.results[0].passed).toBe(true)
|
||||
})
|
||||
|
||||
it('FAILS when loadingDocksCount is 0', () => {
|
||||
const prop = makeProperty({ hardFacts: { loadingDocksCount: 0 } } as any)
|
||||
const out = scoreMustHaves(['Verladerampe oder Tor'], prop)
|
||||
expect(out.results[0].passed).toBe(false)
|
||||
expect(out.scoreImpact).toBe(-MUST_HAVE_PENALTY_PER_MISS)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Parking with minimum count ────────────────────────────────────────────────
|
||||
|
||||
describe('scoreMustHaves — Parkplatz with minimum count', () => {
|
||||
it('PASSES when available parking >= required count', () => {
|
||||
const prop = makeProperty({ softFactors: { ...makeProperty().softFactors, parkingSpots: 6 } as any })
|
||||
const out = scoreMustHaves(['mind. 5 Parkplätze'], prop)
|
||||
expect(out.results[0].passed).toBe(true)
|
||||
expect(out.results[0].confidence).toBe('CERTAIN')
|
||||
expect(out.results[0].explanation).toMatch(/vorhanden/)
|
||||
})
|
||||
|
||||
it('FAILS when available parking < required count', () => {
|
||||
const prop = makeProperty({ softFactors: { ...makeProperty().softFactors, parkingSpots: 3 } as any })
|
||||
const out = scoreMustHaves(['mind. 10 Parkplätze'], prop)
|
||||
expect(out.results[0].passed).toBe(false)
|
||||
expect(out.results[0].confidence).toBe('CERTAIN')
|
||||
expect(out.scoreImpact).toBe(-MUST_HAVE_PENALTY_PER_MISS)
|
||||
})
|
||||
|
||||
it('detects parking keyword without numeric count', () => {
|
||||
const prop = makeProperty({ softFactors: { ...makeProperty().softFactors, parkingSpots: 2 } as any })
|
||||
const out = scoreMustHaves(['Parkplatz vorhanden'], prop)
|
||||
expect(out.results[0].passed).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Penalty accumulation and cap ─────────────────────────────────────────────
|
||||
|
||||
describe('scoreMustHaves — penalty accumulation and cap', () => {
|
||||
it(`applies -${MUST_HAVE_PENALTY_PER_MISS} per failed CERTAIN criterion`, () => {
|
||||
const prop = makeProperty({
|
||||
hardFacts: { hasAirConditioning: false, loadingDocksCount: 0 },
|
||||
} as any)
|
||||
const out = scoreMustHaves(['Klimaanlage', 'Laderampe'], prop)
|
||||
expect(out.scoreImpact).toBe(-2 * MUST_HAVE_PENALTY_PER_MISS)
|
||||
})
|
||||
|
||||
it(`caps total penalty at -${MUST_HAVE_MAX_PENALTY}`, () => {
|
||||
const prop = makeProperty({
|
||||
hardFacts: {
|
||||
hasAirConditioning: false,
|
||||
loadingDocksCount: 0,
|
||||
floor: 2,
|
||||
isBarrierFree: false,
|
||||
},
|
||||
} as any)
|
||||
const out = scoreMustHaves([
|
||||
'Klimaanlage',
|
||||
'Laderampe',
|
||||
'Erdgeschoss erforderlich',
|
||||
'Barrierefrei',
|
||||
], prop)
|
||||
expect(out.scoreImpact).toBeGreaterThanOrEqual(-MUST_HAVE_MAX_PENALTY)
|
||||
expect(out.scoreImpact).toBeLessThanOrEqual(0)
|
||||
})
|
||||
|
||||
it('UNKNOWN criteria do not contribute to scoreImpact', () => {
|
||||
// No hardFacts on base fixture → Klimaanlage and Laderampe return UNKNOWN
|
||||
const out = scoreMustHaves(['Klimaanlage', 'Laderampe'], makeProperty())
|
||||
expect(Math.abs(out.scoreImpact)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Unrecognized criterion ────────────────────────────────────────────────────
|
||||
|
||||
describe('scoreMustHaves — unrecognized criterion', () => {
|
||||
it('returns UNKNOWN and false for an unrecognized criterion string', () => {
|
||||
const out = scoreMustHaves(['Einzigartiges Sonderkriterium XYZ'], makeProperty())
|
||||
expect(out.results[0].confidence).toBe('UNKNOWN')
|
||||
expect(out.results[0].passed).toBe(false)
|
||||
expect(Math.abs(out.scoreImpact)).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { softFactorEnrichmentService } from '../../../services/softFactorEnrichmentService'
|
||||
import { ResultType } from '../../../domain/enums'
|
||||
import { makeProperty } from './fixtures'
|
||||
import type { Property } from '../../../domain/property'
|
||||
|
||||
// Helper: create a property with only location context — no address that could
|
||||
// accidentally trigger another location's keyword rule (e.g. 'Bahnhofstrasse'
|
||||
// in the default fixture triggers the Zürich CBD rule for any city).
|
||||
function locProp(city: string, district?: string): Property {
|
||||
return makeProperty({
|
||||
location: { city, district: district ?? '', country: 'CH' },
|
||||
address: { street: 'Teststrasse', houseNumber: '1', postalCode: '0000', city, country: 'CH' },
|
||||
})
|
||||
}
|
||||
|
||||
// ── Exact keyword match — Zürich CBD ──────────────────────────────────────────
|
||||
|
||||
describe('softFactorEnrichmentService — exact keyword match (Zürich CBD)', () => {
|
||||
it('returns prestige 0.92 for Zürich CBD (Bahnhofstrasse address keyword)', () => {
|
||||
const prop = makeProperty() // default fixture has address.street: 'Bahnhofstrasse' → CBD match
|
||||
const result = softFactorEnrichmentService.estimate('prestige', prop)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.score).toBeCloseTo(0.92, 1)
|
||||
})
|
||||
|
||||
it('returns very high accessibility (≥ 0.95) for Zürich CBD', () => {
|
||||
const prop = makeProperty()
|
||||
const result = softFactorEnrichmentService.estimate('accessibility', prop)
|
||||
expect(result!.score).toBeGreaterThanOrEqual(0.95)
|
||||
})
|
||||
|
||||
it('returns a non-empty label string', () => {
|
||||
const result = softFactorEnrichmentService.estimate('prestige', makeProperty())
|
||||
expect(result!.label).toBeTruthy()
|
||||
expect(typeof result!.label).toBe('string')
|
||||
})
|
||||
})
|
||||
|
||||
// ── Zürich-West tech cluster ──────────────────────────────────────────────────
|
||||
|
||||
describe('softFactorEnrichmentService — Zürich-West tech cluster (Kreis 5)', () => {
|
||||
it('returns talentAccess ≥ 0.90 for Zürich-West Technopark', () => {
|
||||
// Use 'kreis 5' as district keyword — address.city = 'Zürich' is neutral
|
||||
const prop = locProp('Zürich', 'Kreis 5')
|
||||
const result = softFactorEnrichmentService.estimate('talentAccess', prop)
|
||||
expect(result!.score).toBeGreaterThanOrEqual(0.90)
|
||||
})
|
||||
|
||||
it('returns higher ESG score for Zürich-West (0.75) than Zürich CBD (0.52)', () => {
|
||||
const cbd = makeProperty() // CBD via Bahnhofstrasse address
|
||||
const west = locProp('Zürich', 'Kreis 5')
|
||||
const cbdEsg = softFactorEnrichmentService.estimate('esg', cbd)
|
||||
const westEsg = softFactorEnrichmentService.estimate('esg', west)
|
||||
expect(westEsg!.score).toBeGreaterThan(cbdEsg!.score)
|
||||
})
|
||||
|
||||
it('returns higher flexibility score for Zürich-West than Zürich CBD', () => {
|
||||
const cbd = makeProperty()
|
||||
const west = locProp('Zürich', 'Kreis 5')
|
||||
expect(softFactorEnrichmentService.estimate('flexibility', west)!.score)
|
||||
.toBeGreaterThan(softFactorEnrichmentService.estimate('flexibility', cbd)!.score)
|
||||
})
|
||||
})
|
||||
|
||||
// ── City-level fuzzy match ────────────────────────────────────────────────────
|
||||
|
||||
describe('softFactorEnrichmentService — city-level fallback', () => {
|
||||
it('returns a result for "Zürich" city without a district', () => {
|
||||
const result = softFactorEnrichmentService.estimate('accessibility', locProp('Zürich'))
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.score).toBeGreaterThan(0)
|
||||
expect(result!.score).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('returns a result for Basel city', () => {
|
||||
const result = softFactorEnrichmentService.estimate('talentAccess', locProp('Basel'))
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.score).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('returns taxEnvironment > 0.90 for Zug (lowest taxes in CH)', () => {
|
||||
const result = softFactorEnrichmentService.estimate('taxEnvironment', locProp('Zug'))
|
||||
expect(result!.score).toBeGreaterThan(0.90)
|
||||
})
|
||||
|
||||
it('Zug prestige is higher than Winterthur prestige', () => {
|
||||
const zugResult = softFactorEnrichmentService.estimate('prestige', locProp('Zug'))
|
||||
const wintResult = softFactorEnrichmentService.estimate('prestige', locProp('Winterthur'))
|
||||
expect(zugResult!.score).toBeGreaterThan(wintResult!.score)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Swiss generic fallback ────────────────────────────────────────────────────
|
||||
|
||||
describe('softFactorEnrichmentService — Swiss generic fallback', () => {
|
||||
it('returns a non-null estimate for an unknown Swiss city', () => {
|
||||
const result = softFactorEnrichmentService.estimate('prestige', locProp('Münsingen'))
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.score).toBeGreaterThan(0)
|
||||
expect(result!.score).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('label includes "Schweiz" for generic Swiss fallback', () => {
|
||||
// 'Kleindorf' matches no rule → Swiss generic fallback
|
||||
const result = softFactorEnrichmentService.estimate('accessibility', locProp('Kleindorf'))
|
||||
expect(result!.label).toMatch(/Schweiz/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Score range invariant ─────────────────────────────────────────────────────
|
||||
|
||||
describe('softFactorEnrichmentService — score range invariant (all keys, 0–1)', () => {
|
||||
const locations = ['Zürich', 'Basel', 'Zug', 'Bern'] as const
|
||||
const keys = ['prestige', 'accessibility', 'talentAccess', 'esg', 'taxEnvironment', 'footfall'] as const
|
||||
|
||||
for (const city of locations) {
|
||||
for (const key of keys) {
|
||||
it(`score is 0–1 for ${key} in ${city}`, () => {
|
||||
const result = softFactorEnrichmentService.estimate(key, locProp(city))
|
||||
if (result !== null) {
|
||||
expect(result.score).toBeGreaterThanOrEqual(0)
|
||||
expect(result.score).toBeLessThanOrEqual(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ── Pre-Market vs Market Signal enrichment ────────────────────────────────────
|
||||
|
||||
describe('softFactorEnrichmentService — Pre-Market vs Market Signal', () => {
|
||||
it('returns identical estimate for same location regardless of resultType', () => {
|
||||
// The enrichment service is location-only — resultType is irrelevant.
|
||||
// FUTURE_AVAILABILITY (pre-market) and EXTERNAL_MARKET should produce the same soft factor score.
|
||||
const sharedLocation = { city: 'Zürich', district: 'Oerlikon', country: 'CH' }
|
||||
const sharedAddress = { street: 'Thurgauerstrasse', houseNumber: '1', postalCode: '8050', city: 'Zürich', country: 'CH' }
|
||||
|
||||
const futureSignal = makeProperty({
|
||||
location: sharedLocation,
|
||||
address: sharedAddress,
|
||||
resultType: ResultType.FUTURE_AVAILABILITY,
|
||||
})
|
||||
const marketResult = makeProperty({
|
||||
location: sharedLocation,
|
||||
address: sharedAddress,
|
||||
resultType: ResultType.EXTERNAL_MARKET,
|
||||
})
|
||||
|
||||
const futureEst = softFactorEnrichmentService.estimate('accessibility', futureSignal)
|
||||
const marketEst = softFactorEnrichmentService.estimate('accessibility', marketResult)
|
||||
expect(futureEst?.score).toBe(marketEst?.score)
|
||||
expect(futureEst?.label).toBe(marketEst?.label)
|
||||
})
|
||||
|
||||
it('FUTURE_AVAILABILITY at Oerlikon gets meaningful accessibility score (> 0.70)', () => {
|
||||
const prop = makeProperty({
|
||||
location: { city: 'Zürich', district: 'Oerlikon', country: 'CH' },
|
||||
address: { street: 'Thurgauerstrasse', houseNumber: '40', postalCode: '8050', city: 'Zürich', country: 'CH' },
|
||||
resultType: ResultType.FUTURE_AVAILABILITY,
|
||||
})
|
||||
const result = softFactorEnrichmentService.estimate('accessibility', prop)
|
||||
expect(result!.score).toBeGreaterThan(0.70)
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,52 @@
|
||||
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'
|
||||
|
||||
// ── 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'
|
||||
/** 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
|
||||
/** 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
|
||||
/** True when the AI response passed Zod schema validation */
|
||||
validationPassed: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 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',
|
||||
source: 'mock',
|
||||
fallbackUsed: false,
|
||||
validationPassed: true,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Response Types ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface DecisionBrief {
|
||||
@@ -49,8 +93,6 @@ export interface ParsedListingData {
|
||||
fitOut?: string
|
||||
}
|
||||
|
||||
// ── New Response Types ────────────────────────────────────────────────────────
|
||||
|
||||
export interface MatchExplanation {
|
||||
headline: string
|
||||
summary: string
|
||||
@@ -124,29 +166,29 @@ export interface AIServiceProvider {
|
||||
|
||||
export interface IAIService {
|
||||
// Need parsing (F008)
|
||||
parseNeed(input: string): Promise<ItemResponse<ParseNeedResult>>
|
||||
generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise<ItemResponse<FollowUpQuestion[]>>
|
||||
parseNeed(input: string): Promise<AIResponse<ParseNeedResult>>
|
||||
generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise<AIResponse<FollowUpQuestion[]>>
|
||||
|
||||
// Match explainability (F004)
|
||||
generateMatchExplanation(input: MatchExplanationInput): Promise<ItemResponse<MatchExplanation>>
|
||||
summarizeTradeOffs(tradeoffs: TradeOffInput[]): Promise<ItemResponse<TradeOffSummary>>
|
||||
generateMatchExplanation(input: MatchExplanationInput): Promise<AIResponse<MatchExplanation>>
|
||||
summarizeTradeOffs(tradeoffs: TradeOffInput[]): Promise<AIResponse<TradeOffSummary>>
|
||||
|
||||
// Compare (F014)
|
||||
summarizeComparison(items: UnifiedMatchResult[]): Promise<ItemResponse<ComparisonSummary>>
|
||||
summarizeComparison(items: UnifiedMatchResult[]): Promise<AIResponse<ComparisonSummary>>
|
||||
|
||||
// Shortlist decision brief
|
||||
generateDecisionBrief(shortlistId: string): Promise<ItemResponse<DecisionBrief>>
|
||||
generateDecisionBrief(shortlistId: string): Promise<AIResponse<DecisionBrief>>
|
||||
|
||||
// Data quality
|
||||
generateDataQualitySummary(propertyId: string, quality: DataQualityInput): Promise<ItemResponse<DataQualitySummary>>
|
||||
generateDataQualitySummary(propertyId: string, quality: DataQualityInput): Promise<AIResponse<DataQualitySummary>>
|
||||
|
||||
// Market signal classification (OPERATIONS)
|
||||
classifyMarketSignal(signalText: string): Promise<ItemResponse<MarketSignalClassification>>
|
||||
classifyMarketSignal(signalText: string): Promise<AIResponse<MarketSignalClassification>>
|
||||
|
||||
// Offer email (supply side)
|
||||
generateOfferEmail(payload: OfferEmailPayload): Promise<ItemResponse<{ subject: string; body: string }>>
|
||||
generateOfferEmail(payload: OfferEmailPayload): Promise<AIResponse<{ subject: string; body: string }>>
|
||||
|
||||
// Legacy methods
|
||||
extractCriteria(input: string): Promise<ItemResponse<CriteriaExtractionResult>>
|
||||
generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<ItemResponse<string[]>>
|
||||
extractCriteria(input: string): Promise<AIResponse<CriteriaExtractionResult>>
|
||||
generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<AIResponse<string[]>>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
NeedParsingResponseSchema,
|
||||
FollowUpQuestionsResponseSchema,
|
||||
TradeOffSummaryResponseSchema,
|
||||
CompareSummaryResponseSchema,
|
||||
DecisionBriefResponseSchema,
|
||||
DataQualitySummaryResponseSchema,
|
||||
MarketSignalClassificationResponseSchema,
|
||||
OfferEmailResponseSchema,
|
||||
ProbabilitySchema,
|
||||
validateAIResponse,
|
||||
} from '../schemas'
|
||||
|
||||
// ── ProbabilitySchema ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('ProbabilitySchema', () => {
|
||||
it('accepts 0', () => expect(ProbabilitySchema.safeParse(0).success).toBe(true))
|
||||
it('accepts 1', () => expect(ProbabilitySchema.safeParse(1).success).toBe(true))
|
||||
it('accepts 0.65', () => expect(ProbabilitySchema.safeParse(0.65).success).toBe(true))
|
||||
it('rejects -5', () => expect(ProbabilitySchema.safeParse(-5).success).toBe(false))
|
||||
it('rejects 1.5', () => expect(ProbabilitySchema.safeParse(1.5).success).toBe(false))
|
||||
it('rejects NaN', () => expect(ProbabilitySchema.safeParse(NaN).success).toBe(false))
|
||||
})
|
||||
|
||||
// ── NeedParsingResponseSchema ─────────────────────────────────────────────────
|
||||
|
||||
describe('NeedParsingResponseSchema', () => {
|
||||
it('accepts a complete valid response', () => {
|
||||
const result = NeedParsingResponseSchema.safeParse({
|
||||
assetType: 'OFFICE',
|
||||
areaRange: { min: 200, max: 500 },
|
||||
preferredLocations: ['Zürich', 'Basel'],
|
||||
budgetRange: { maxPerSqm: 45, currency: 'CHF' },
|
||||
timing: { earliestMoveIn: '2025-01-01', flexibleTiming: false },
|
||||
mustHaveCriteria: ['Erdgeschoss'],
|
||||
missingFields: [],
|
||||
assumptions: ['Fläche geschätzt'],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts a minimal response (all fields optional)', () => {
|
||||
const result = NeedParsingResponseSchema.safeParse({})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts null for nullable fields', () => {
|
||||
const result = NeedParsingResponseSchema.safeParse({ assetType: null, areaRange: null })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects areaRange where max < min', () => {
|
||||
const result = NeedParsingResponseSchema.safeParse({
|
||||
areaRange: { min: 500, max: 200 },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects unknown assetType value', () => {
|
||||
const result = NeedParsingResponseSchema.safeParse({ assetType: 'WAREHOUSE' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects negative area values', () => {
|
||||
const result = NeedParsingResponseSchema.safeParse({ areaRange: { min: -10, max: 500 } })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ── FollowUpQuestionsResponseSchema ───────────────────────────────────────────
|
||||
|
||||
describe('FollowUpQuestionsResponseSchema', () => {
|
||||
const validQuestion = {
|
||||
questionText: 'Welchen Nutzungstyp suchen Sie?',
|
||||
targetField: 'assetType',
|
||||
reason: 'Pflichtfeld fehlt',
|
||||
importance: 'required',
|
||||
}
|
||||
|
||||
it('accepts valid question array', () => {
|
||||
const result = FollowUpQuestionsResponseSchema.safeParse([validQuestion])
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts empty array', () => {
|
||||
const result = FollowUpQuestionsResponseSchema.safeParse([])
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects array with more than 5 items', () => {
|
||||
const questions = Array(6).fill(validQuestion)
|
||||
const result = FollowUpQuestionsResponseSchema.safeParse(questions)
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects question with empty questionText', () => {
|
||||
const result = FollowUpQuestionsResponseSchema.safeParse([{ ...validQuestion, questionText: '' }])
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects unknown importance value', () => {
|
||||
const result = FollowUpQuestionsResponseSchema.safeParse([{ ...validQuestion, importance: 'critical' }])
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ── TradeOffSummaryResponseSchema ─────────────────────────────────────────────
|
||||
|
||||
describe('TradeOffSummaryResponseSchema', () => {
|
||||
const valid = {
|
||||
headline: 'Hohe Mietkosten',
|
||||
items: [{ concern: 'Budget überschritten', severity: 'HIGH', mitigation: 'Verhandlung möglich' }],
|
||||
overallRisk: 'HIGH',
|
||||
}
|
||||
|
||||
it('accepts valid trade-off summary', () => {
|
||||
expect(TradeOffSummaryResponseSchema.safeParse(valid).success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects unknown severity value', () => {
|
||||
const bad = { ...valid, items: [{ concern: 'test', severity: 'CRITICAL' }] }
|
||||
expect(TradeOffSummaryResponseSchema.safeParse(bad).success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects unknown overallRisk value', () => {
|
||||
const bad = { ...valid, overallRisk: 'EXTREME' }
|
||||
expect(TradeOffSummaryResponseSchema.safeParse(bad).success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects more than 5 items', () => {
|
||||
const bad = { ...valid, items: Array(6).fill({ concern: 'x', severity: 'LOW' }) }
|
||||
expect(TradeOffSummaryResponseSchema.safeParse(bad).success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects missing headline', () => {
|
||||
const { headline: _, ...bad } = valid
|
||||
expect(TradeOffSummaryResponseSchema.safeParse(bad).success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ── CompareSummaryResponseSchema ──────────────────────────────────────────────
|
||||
|
||||
describe('CompareSummaryResponseSchema', () => {
|
||||
it('accepts valid compare summary', () => {
|
||||
const result = CompareSummaryResponseSchema.safeParse({
|
||||
overallAssessment: 'Objekt A ist am besten geeignet.',
|
||||
recommendation: 'Besichtigungstermin für Objekt A vereinbaren.',
|
||||
strongestOption: 'Objekt A',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts response with only overallAssessment (others optional)', () => {
|
||||
const result = CompareSummaryResponseSchema.safeParse({ overallAssessment: 'Gute Optionen.' })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects empty overallAssessment', () => {
|
||||
const result = CompareSummaryResponseSchema.safeParse({ overallAssessment: '' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ── DecisionBriefResponseSchema ───────────────────────────────────────────────
|
||||
|
||||
describe('DecisionBriefResponseSchema', () => {
|
||||
const validSection = { title: 'Zusammenfassung', body: 'Überblick über die Situation.' }
|
||||
|
||||
it('accepts valid decision brief', () => {
|
||||
const result = DecisionBriefResponseSchema.safeParse({
|
||||
summary: 'Executive Summary.',
|
||||
sections: [validSection, { title: 'Empfehlung', body: 'Objekt A priorisieren.' }],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects missing sections', () => {
|
||||
const result = DecisionBriefResponseSchema.safeParse({ summary: 'OK' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects empty sections array', () => {
|
||||
const result = DecisionBriefResponseSchema.safeParse({ summary: 'OK', sections: [] })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects more than 6 sections', () => {
|
||||
const result = DecisionBriefResponseSchema.safeParse({
|
||||
summary: 'OK',
|
||||
sections: Array(7).fill(validSection),
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects section with empty body', () => {
|
||||
const result = DecisionBriefResponseSchema.safeParse({
|
||||
summary: 'OK',
|
||||
sections: [{ title: 'Test', body: '' }],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ── DataQualitySummaryResponseSchema ──────────────────────────────────────────
|
||||
|
||||
describe('DataQualitySummaryResponseSchema', () => {
|
||||
it('accepts valid data quality summary', () => {
|
||||
const result = DataQualitySummaryResponseSchema.safeParse({
|
||||
overallAssessment: 'Gute Datenqualität.',
|
||||
missingCriticalFields: ['areaSqm'],
|
||||
recommendation: 'Fläche ergänzen.',
|
||||
confidence: 0.75,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects confidence > 1', () => {
|
||||
const result = DataQualitySummaryResponseSchema.safeParse({
|
||||
overallAssessment: 'Test',
|
||||
recommendation: 'Test',
|
||||
confidence: 1.5,
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects confidence < 0', () => {
|
||||
const result = DataQualitySummaryResponseSchema.safeParse({
|
||||
overallAssessment: 'Test',
|
||||
recommendation: 'Test',
|
||||
confidence: -0.1,
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ── MarketSignalClassificationResponseSchema ──────────────────────────────────
|
||||
|
||||
describe('MarketSignalClassificationResponseSchema', () => {
|
||||
const valid = {
|
||||
signalType: 'VACANCY',
|
||||
probability: 0.75,
|
||||
timeHorizonMonths: 6,
|
||||
areaSqmEstimate: 1200,
|
||||
credibility: 'HIGH',
|
||||
reasoning: 'Kündigung bekannt.',
|
||||
}
|
||||
|
||||
it('accepts valid market signal', () => {
|
||||
expect(MarketSignalClassificationResponseSchema.safeParse(valid).success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts null for nullable optional fields', () => {
|
||||
const result = MarketSignalClassificationResponseSchema.safeParse({
|
||||
...valid,
|
||||
timeHorizonMonths: null,
|
||||
areaSqmEstimate: null,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects probability -5', () => {
|
||||
expect(MarketSignalClassificationResponseSchema.safeParse({ ...valid, probability: -5 }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects probability 1.5', () => {
|
||||
expect(MarketSignalClassificationResponseSchema.safeParse({ ...valid, probability: 1.5 }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects unknown signalType', () => {
|
||||
expect(MarketSignalClassificationResponseSchema.safeParse({ ...valid, signalType: 'FIRE_SALE' }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects unknown credibility value', () => {
|
||||
expect(MarketSignalClassificationResponseSchema.safeParse({ ...valid, credibility: 'VERY_HIGH' }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects missing reasoning', () => {
|
||||
const { reasoning: _, ...bad } = valid
|
||||
expect(MarketSignalClassificationResponseSchema.safeParse(bad).success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects non-integer timeHorizonMonths', () => {
|
||||
expect(MarketSignalClassificationResponseSchema.safeParse({ ...valid, timeHorizonMonths: 2.5 }).success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ── OfferEmailResponseSchema ──────────────────────────────────────────────────
|
||||
|
||||
describe('OfferEmailResponseSchema', () => {
|
||||
it('accepts valid offer email', () => {
|
||||
const result = OfferEmailResponseSchema.safeParse({
|
||||
subject: 'Angebot Büroflächen',
|
||||
body: 'Sehr geehrte Damen und Herren, wir bieten folgende Objekte an.',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects empty subject', () => {
|
||||
const result = OfferEmailResponseSchema.safeParse({ subject: '', body: 'Valid body text here.' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects body shorter than 10 characters', () => {
|
||||
const result = OfferEmailResponseSchema.safeParse({ subject: 'Angebot', body: 'Kurz.' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ── validateAIResponse helper ─────────────────────────────────────────────────
|
||||
|
||||
describe('validateAIResponse helper', () => {
|
||||
it('returns parsed data when schema passes', () => {
|
||||
const result = validateAIResponse(
|
||||
OfferEmailResponseSchema,
|
||||
{ subject: 'Test', body: 'Long enough body text here.' },
|
||||
'test',
|
||||
)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.subject).toBe('Test')
|
||||
})
|
||||
|
||||
it('returns null when schema fails (does not throw)', () => {
|
||||
const result = validateAIResponse(
|
||||
OfferEmailResponseSchema,
|
||||
{ subject: '', body: 'x' },
|
||||
'test',
|
||||
)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for completely wrong shape (does not throw)', () => {
|
||||
const result = validateAIResponse(
|
||||
MarketSignalClassificationResponseSchema,
|
||||
{ probability: -999, signalType: 'INVALID' },
|
||||
'test',
|
||||
)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for null input (does not throw)', () => {
|
||||
const result = validateAIResponse(OfferEmailResponseSchema, null, 'test')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { ItemResponse } from '../../types'
|
||||
import type { CreateNeedInput } from '../../../domain/need'
|
||||
import type { ParsedNeedCriteria, FollowUpQuestion } from '../../../domain/needBuilder'
|
||||
import type { UnifiedMatchResult } from '../../../domain/unifiedResult'
|
||||
import type {
|
||||
IAIService,
|
||||
AIResponse,
|
||||
DecisionBrief,
|
||||
ComparisonSummary,
|
||||
CriteriaExtractionResult,
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
DataQualitySummary,
|
||||
MarketSignalClassification,
|
||||
} from '../IAIService'
|
||||
import { mockProvenance } from '../IAIService'
|
||||
import { mockParseNeed } from './needParser'
|
||||
import { buildComparisonSummary } from './compareBuilder'
|
||||
import { buildMockDecisionBrief } from './decisionBrief'
|
||||
@@ -23,19 +24,92 @@ import { buildMockDecisionBrief } from './decisionBrief'
|
||||
const SIMULATED_DELAY = { fast: 300, medium: 600, slow: 1800 }
|
||||
const delay = (ms: number) => new Promise(r => setTimeout(r, ms))
|
||||
|
||||
// ── Follow-up question templates keyed by ParsedNeedCriteria field ────────────
|
||||
|
||||
interface QuestionTemplate {
|
||||
questionText: string
|
||||
reason: string
|
||||
suggestedAnswerOptions?: string[]
|
||||
importance: FollowUpQuestion['importance']
|
||||
}
|
||||
|
||||
const FOLLOW_UP_TEMPLATES: Partial<Record<keyof ParsedNeedCriteria, QuestionTemplate>> = {
|
||||
assetType: {
|
||||
questionText: 'Welchen Nutzungstyp suchen Sie?',
|
||||
reason: 'Nutzungstyp ist zwingend für die Matchsuche',
|
||||
suggestedAnswerOptions: ['Büro', 'Retail', 'Logistik', 'Produktion', 'Gastro'],
|
||||
importance: 'required',
|
||||
},
|
||||
areaRange: {
|
||||
questionText: 'Welche Fläche benötigen Sie (min–max in m²)?',
|
||||
reason: 'Flächenbedarf ist zwingend für die Filterung',
|
||||
importance: 'required',
|
||||
},
|
||||
preferredLocations: {
|
||||
questionText: 'In welchen Städten oder Regionen suchen Sie?',
|
||||
reason: 'Standortpräferenz fehlt',
|
||||
suggestedAnswerOptions: ['Zürich', 'Basel', 'Bern', 'Zug', 'Genf', 'Lausanne'],
|
||||
importance: 'required',
|
||||
},
|
||||
budgetRange: {
|
||||
questionText: 'Was ist Ihr maximales Budget pro m² und Monat (CHF)?',
|
||||
reason: 'Budget ist wichtig für die Filterung unpassender Objekte',
|
||||
importance: 'recommended',
|
||||
},
|
||||
timing: {
|
||||
questionText: 'Wann möchten Sie spätestens einziehen?',
|
||||
reason: 'Verfügbarkeitstermin fehlt',
|
||||
importance: 'recommended',
|
||||
},
|
||||
mustHaveCriteria: {
|
||||
questionText: 'Haben Sie zwingende Anforderungen (ÖV-Anbindung, Parkplätze, Laderampe)?',
|
||||
reason: 'Pflichtkriterien sind für die Filterung relevant',
|
||||
importance: 'optional',
|
||||
},
|
||||
}
|
||||
|
||||
function buildFollowUpQuestions(criteria: ParsedNeedCriteria): FollowUpQuestion[] {
|
||||
const missing: Array<keyof ParsedNeedCriteria> = []
|
||||
|
||||
if (!criteria.assetType) missing.push('assetType')
|
||||
if (!criteria.areaRange) missing.push('areaRange')
|
||||
if (!criteria.preferredLocations?.length) missing.push('preferredLocations')
|
||||
if (!criteria.budgetRange) missing.push('budgetRange')
|
||||
if (!criteria.timing) missing.push('timing')
|
||||
if (!criteria.mustHaveCriteria?.length) missing.push('mustHaveCriteria')
|
||||
|
||||
return missing
|
||||
.slice(0, 3)
|
||||
.map((field, i) => {
|
||||
const tpl = FOLLOW_UP_TEMPLATES[field]
|
||||
if (!tpl) return null
|
||||
const q: FollowUpQuestion = {
|
||||
id: `fq-mock-${i}`,
|
||||
questionText: tpl.questionText,
|
||||
targetField: field,
|
||||
reason: tpl.reason,
|
||||
importance: tpl.importance,
|
||||
suggestedAnswerOptions: tpl.suggestedAnswerOptions,
|
||||
}
|
||||
return q
|
||||
})
|
||||
.filter((q): q is FollowUpQuestion => q !== null)
|
||||
}
|
||||
|
||||
// ── Service ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const MockAIService: IAIService = {
|
||||
async parseNeed(input: string): Promise<ItemResponse<ReturnType<typeof mockParseNeed>>> {
|
||||
async parseNeed(input: string): Promise<AIResponse<ReturnType<typeof mockParseNeed>>> {
|
||||
await delay(SIMULATED_DELAY.fast)
|
||||
return { data: mockParseNeed(input) }
|
||||
return { data: mockParseNeed(input), provenance: mockProvenance() }
|
||||
},
|
||||
|
||||
async generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise<ItemResponse<FollowUpQuestion[]>> {
|
||||
async generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise<AIResponse<FollowUpQuestion[]>> {
|
||||
await delay(SIMULATED_DELAY.medium)
|
||||
const result = mockParseNeed(JSON.stringify(criteria))
|
||||
return { data: result.followUpQuestionCandidates }
|
||||
return { data: buildFollowUpQuestions(criteria), provenance: mockProvenance() }
|
||||
},
|
||||
|
||||
async generateMatchExplanation(input: MatchExplanationInput): Promise<ItemResponse<MatchExplanation>> {
|
||||
async generateMatchExplanation(input: MatchExplanationInput): Promise<AIResponse<MatchExplanation>> {
|
||||
await delay(SIMULATED_DELAY.medium)
|
||||
const isStrong = input.matchScore >= 78
|
||||
const isMedium = input.matchScore >= 52
|
||||
@@ -56,10 +130,11 @@ export const MockAIService: IAIService = {
|
||||
...input.negativeFactors.slice(0, 1).map(f => `− ${f.explanation}`),
|
||||
],
|
||||
},
|
||||
provenance: mockProvenance(),
|
||||
}
|
||||
},
|
||||
|
||||
async summarizeTradeOffs(tradeoffs: TradeOffInput[]): Promise<ItemResponse<TradeOffSummary>> {
|
||||
async summarizeTradeOffs(tradeoffs: TradeOffInput[]): Promise<AIResponse<TradeOffSummary>> {
|
||||
await delay(SIMULATED_DELAY.fast)
|
||||
const critical = tradeoffs.filter(t => t.severity === 'HIGH')
|
||||
const overallRisk: TradeOffSummary['overallRisk'] =
|
||||
@@ -73,20 +148,21 @@ export const MockAIService: IAIService = {
|
||||
items: tradeoffs.map(t => ({ concern: t.concern, severity: t.severity, mitigation: t.mitigation })),
|
||||
overallRisk,
|
||||
},
|
||||
provenance: mockProvenance(),
|
||||
}
|
||||
},
|
||||
|
||||
async summarizeComparison(items: UnifiedMatchResult[]): Promise<ItemResponse<ComparisonSummary>> {
|
||||
async summarizeComparison(items: UnifiedMatchResult[]): Promise<AIResponse<ComparisonSummary>> {
|
||||
await delay(SIMULATED_DELAY.medium)
|
||||
return { data: buildComparisonSummary(items) }
|
||||
return { data: buildComparisonSummary(items), provenance: mockProvenance() }
|
||||
},
|
||||
|
||||
async generateDecisionBrief(shortlistId: string): Promise<ItemResponse<DecisionBrief>> {
|
||||
async generateDecisionBrief(shortlistId: string): Promise<AIResponse<DecisionBrief>> {
|
||||
await delay(SIMULATED_DELAY.slow)
|
||||
return { data: buildMockDecisionBrief(shortlistId) }
|
||||
return { data: buildMockDecisionBrief(shortlistId), provenance: mockProvenance() }
|
||||
},
|
||||
|
||||
async generateDataQualitySummary(_propertyId: string, quality: DataQualityInput): Promise<ItemResponse<DataQualitySummary>> {
|
||||
async generateDataQualitySummary(_propertyId: string, quality: DataQualityInput): Promise<AIResponse<DataQualitySummary>> {
|
||||
await delay(SIMULATED_DELAY.fast)
|
||||
const level =
|
||||
quality.score >= 0.85 ? 'excellent'
|
||||
@@ -113,10 +189,11 @@ export const MockAIService: IAIService = {
|
||||
: 'Keine sofortigen Massnahmen erforderlich.',
|
||||
confidence: quality.score,
|
||||
},
|
||||
provenance: mockProvenance(),
|
||||
}
|
||||
},
|
||||
|
||||
async classifyMarketSignal(signalText: string): Promise<ItemResponse<MarketSignalClassification>> {
|
||||
async classifyMarketSignal(signalText: string): Promise<AIResponse<MarketSignalClassification>> {
|
||||
await delay(SIMULATED_DELAY.medium)
|
||||
const t = signalText.toLowerCase()
|
||||
let signalType: MarketSignalClassification['signalType'] = 'UNKNOWN'
|
||||
@@ -136,10 +213,11 @@ export const MockAIService: IAIService = {
|
||||
credibility: 'MEDIUM',
|
||||
reasoning: `Keyword-basierte Klassifikation (Mock). Signaltyp: ${signalType}.`,
|
||||
},
|
||||
provenance: mockProvenance(),
|
||||
}
|
||||
},
|
||||
|
||||
async generateOfferEmail(payload: OfferEmailPayload): Promise<ItemResponse<{ subject: string; body: string }>> {
|
||||
async generateOfferEmail(payload: OfferEmailPayload): Promise<AIResponse<{ subject: string; body: string }>> {
|
||||
await delay(SIMULATED_DELAY.medium * 2)
|
||||
return {
|
||||
data: {
|
||||
@@ -149,11 +227,12 @@ export const MockAIService: IAIService = {
|
||||
payload.properties.map((p, i) => `• ${p} (Match-Score: ${payload.matchScores[i]}%)`).join('\n') +
|
||||
`\n\nGerne arrangieren wir Besichtigungstermine für die genannten Objekte und stehen für alle weiteren Fragen zur Verfügung.\n\nFreundliche Grüsse\nWincasa AG`,
|
||||
},
|
||||
provenance: mockProvenance(),
|
||||
}
|
||||
},
|
||||
|
||||
// Legacy methods
|
||||
async extractCriteria(_input: string): Promise<ItemResponse<CriteriaExtractionResult>> {
|
||||
async extractCriteria(_input: string): Promise<AIResponse<CriteriaExtractionResult>> {
|
||||
return {
|
||||
data: {
|
||||
extractedCriteria: {
|
||||
@@ -170,15 +249,16 @@ export const MockAIService: IAIService = {
|
||||
'Wann möchten Sie spätestens einziehen?',
|
||||
],
|
||||
},
|
||||
provenance: mockProvenance(),
|
||||
}
|
||||
},
|
||||
|
||||
async generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<ItemResponse<string[]>> {
|
||||
async generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<AIResponse<string[]>> {
|
||||
const questions: string[] = []
|
||||
if (!partialNeed.assetType) questions.push('Welchen Nutzungstyp suchen Sie?')
|
||||
if (!partialNeed.preferredLocations?.length) questions.push('In welchen Regionen suchen Sie?')
|
||||
if (!partialNeed.timing) questions.push('Was ist Ihr gewünschter Einzugstermin?')
|
||||
if (!partialNeed.budgetRange) questions.push('Was ist Ihr maximales monatliches Budget?')
|
||||
return { data: questions }
|
||||
return { data: questions, provenance: mockProvenance() }
|
||||
},
|
||||
}
|
||||
|
||||
@@ -6,22 +6,24 @@
|
||||
* VITE_OPENROUTER_API_KEY=<your-key>
|
||||
* VITE_OPENROUTER_MODEL=anthropic/claude-3-5-haiku (optional, default shown)
|
||||
*
|
||||
* All methods follow this contract:
|
||||
* 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
|
||||
* Every method follows this contract:
|
||||
* 1. No API key → warn + MockAIService fallback (fallbackUsed: true)
|
||||
* 2. HTTP error → error log + MockAIService fallback
|
||||
* 3. JSON parse fail → warn + MockAIService fallback
|
||||
* 4. Zod schema fail → warn + MockAIService fallback ← NEW
|
||||
* 5. Success (full AI) → AI response, source: 'ai', validationPassed: true
|
||||
* 6. Hybrid → source: 'hybrid', documented per-method
|
||||
*
|
||||
* Methods that use a hybrid approach (AI text merged into mock structure) are
|
||||
* explicitly documented with why mock data fills the remaining fields.
|
||||
* No invalid data ever reaches the UI.
|
||||
*/
|
||||
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 { AssetType } from '../../../domain/enums'
|
||||
import type {
|
||||
IAIService,
|
||||
AIResponse,
|
||||
AIProvenance,
|
||||
DecisionBrief,
|
||||
ComparisonSummary,
|
||||
CriteriaExtractionResult,
|
||||
@@ -36,6 +38,17 @@ import type {
|
||||
} from '../IAIService'
|
||||
import { ServiceErrorCode } from '../../types'
|
||||
import { AppError } from '../../errors'
|
||||
import {
|
||||
NeedParsingResponseSchema,
|
||||
FollowUpQuestionsResponseSchema,
|
||||
TradeOffSummaryResponseSchema,
|
||||
CompareSummaryResponseSchema,
|
||||
DecisionBriefResponseSchema,
|
||||
DataQualitySummaryResponseSchema,
|
||||
MarketSignalClassificationResponseSchema,
|
||||
OfferEmailResponseSchema,
|
||||
validateAIResponse,
|
||||
} from '../schemas'
|
||||
import { buildNeedParsingPrompt } from '../prompts/needParsingPrompt'
|
||||
import { buildFollowUpQuestionsPrompt } from '../prompts/followUpQuestionsPrompt'
|
||||
import { buildMatchExplanationPrompt } from '../prompts/matchExplanationPrompt'
|
||||
@@ -50,7 +63,7 @@ import { MockAIService } from '../mock/MockAIService'
|
||||
|
||||
const API_BASE = 'https://openrouter.ai/api/v1'
|
||||
const DEFAULT_MODEL = 'anthropic/claude-3-5-haiku'
|
||||
const PROMPT_VERSION = 'v1.0'
|
||||
const PROMPT_VERSION = 'v1.1'
|
||||
const SCHEMA_VERSION = 'v1.0'
|
||||
|
||||
interface OpenRouterConfig {
|
||||
@@ -67,6 +80,23 @@ function getConfig(): OpenRouterConfig | null {
|
||||
}
|
||||
}
|
||||
|
||||
function makeProvenance(
|
||||
config: OpenRouterConfig,
|
||||
source: AIProvenance['source'],
|
||||
fallbackUsed: boolean,
|
||||
validationPassed: boolean,
|
||||
): AIProvenance {
|
||||
return {
|
||||
provider: 'openrouter',
|
||||
model: config.model,
|
||||
generatedAt: new Date().toISOString(),
|
||||
promptVersion: PROMPT_VERSION,
|
||||
source,
|
||||
fallbackUsed,
|
||||
validationPassed,
|
||||
}
|
||||
}
|
||||
|
||||
// ── HTTP helper ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function chat(config: OpenRouterConfig, system: string, user: string): Promise<string> {
|
||||
@@ -99,7 +129,6 @@ async function chat(config: OpenRouterConfig, system: string, user: string): Pro
|
||||
// ── JSON extraction ───────────────────────────────────────────────────────────
|
||||
|
||||
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 {
|
||||
@@ -109,7 +138,7 @@ function extractJSON<T>(raw: string): T | null {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers for ParseNeedResult mapping ───────────────────────────────────────
|
||||
// ── ParseNeed helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
type RawNeedParseAI = {
|
||||
assetType?: string | null
|
||||
@@ -145,23 +174,25 @@ function defaultSuggestedWeights(): Record<string, number> {
|
||||
|
||||
// ── Fallback wrapper ──────────────────────────────────────────────────────────
|
||||
|
||||
type FallbackFn<T> = () => Promise<ItemResponse<T>>
|
||||
type FallbackFn<T> = () => Promise<AIResponse<T>>
|
||||
|
||||
async function withFallback<T>(
|
||||
label: string,
|
||||
fn: (config: OpenRouterConfig) => Promise<ItemResponse<T>>,
|
||||
fn: (config: OpenRouterConfig) => Promise<AIResponse<T>>,
|
||||
fallback: FallbackFn<T>,
|
||||
): Promise<ItemResponse<T>> {
|
||||
): Promise<AIResponse<T>> {
|
||||
const config = getConfig()
|
||||
if (!config) {
|
||||
console.warn(`[OpenRouterAIService] ${label}: no API key — using MockAIService`)
|
||||
return fallback()
|
||||
const result = await fallback()
|
||||
return { ...result, provenance: { ...result.provenance, fallbackUsed: true } }
|
||||
}
|
||||
try {
|
||||
return await fn(config)
|
||||
} catch (err) {
|
||||
console.error(`[OpenRouterAIService] ${label} failed:`, err)
|
||||
return fallback()
|
||||
const result = await fallback()
|
||||
return { ...result, provenance: { ...result.provenance, fallbackUsed: true } }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,15 +201,19 @@ async function withFallback<T>(
|
||||
export const OpenRouterAIService: IAIService = {
|
||||
|
||||
// ── parseNeed ───────────────────────────────────────────────────────────────
|
||||
parseNeed(input: string): Promise<ItemResponse<ParseNeedResult>> {
|
||||
parseNeed(input: string): Promise<AIResponse<ParseNeedResult>> {
|
||||
return withFallback('parseNeed', async (config) => {
|
||||
const { system, user } = buildNeedParsingPrompt({ userInput: input })
|
||||
const raw = await chat(config, system, user)
|
||||
const ai = extractJSON<RawNeedParseAI>(raw)
|
||||
const json = extractJSON<RawNeedParseAI>(raw)
|
||||
const ai = json ? validateAIResponse(NeedParsingResponseSchema, json, 'parseNeed') : null
|
||||
|
||||
if (!ai) {
|
||||
console.warn('[OpenRouterAIService] parseNeed: could not parse JSON — using mock fallback')
|
||||
return MockAIService.parseNeed(input)
|
||||
console.warn('[OpenRouterAIService] parseNeed: invalid response — using mock fallback')
|
||||
const fb = await MockAIService.parseNeed(input)
|
||||
return { ...fb, provenance: makeProvenance(config, 'mock', true, false) }
|
||||
}
|
||||
|
||||
const extractedCriteria: ParsedNeedCriteria = {
|
||||
assetType: (ai.assetType ?? undefined) as AssetType | undefined,
|
||||
areaRange: ai.areaRange ?? undefined,
|
||||
@@ -214,49 +249,53 @@ export const OpenRouterAIService: IAIService = {
|
||||
promptVersion: PROMPT_VERSION,
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
},
|
||||
provenance: makeProvenance(config, 'ai', false, true),
|
||||
}
|
||||
}, () => MockAIService.parseNeed(input))
|
||||
},
|
||||
|
||||
// ── generateFollowUpQuestions ───────────────────────────────────────────────
|
||||
generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise<ItemResponse<FollowUpQuestion[]>> {
|
||||
generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise<AIResponse<FollowUpQuestion[]>> {
|
||||
return withFallback('generateFollowUpQuestions', async (config) => {
|
||||
const missingFields = Object.entries(criteria)
|
||||
.filter(([, v]) => v == null)
|
||||
.map(([k]) => k)
|
||||
const { system, user } = buildFollowUpQuestionsPrompt({ criteria, missingFields })
|
||||
const raw = await chat(config, system, user)
|
||||
type RawFQ = { questionText?: string; targetField?: string; reason?: string; suggestedAnswerOptions?: string[]; importance?: string }
|
||||
const ai = extractJSON<RawFQ[]>(raw)
|
||||
const json = extractJSON<unknown[]>(raw)
|
||||
const ai = json ? validateAIResponse(FollowUpQuestionsResponseSchema, json, 'generateFollowUpQuestions') : null
|
||||
|
||||
if (!ai?.length) {
|
||||
console.warn('[OpenRouterAIService] generateFollowUpQuestions: empty response — using mock fallback')
|
||||
return MockAIService.generateFollowUpQuestions(criteria)
|
||||
console.warn('[OpenRouterAIService] generateFollowUpQuestions: invalid response — using mock fallback')
|
||||
const fb = await MockAIService.generateFollowUpQuestions(criteria)
|
||||
return { ...fb, provenance: makeProvenance(config, 'mock', true, false) }
|
||||
}
|
||||
return {
|
||||
data: ai.map((q, i) => ({
|
||||
id: `fq-or-${i}`,
|
||||
questionText: q.questionText ?? '?',
|
||||
targetField: q.targetField ?? 'unknown',
|
||||
questionText: q.questionText,
|
||||
targetField: q.targetField,
|
||||
reason: q.reason ?? 'AI-generiert',
|
||||
suggestedAnswerOptions: q.suggestedAnswerOptions,
|
||||
importance: (['required', 'recommended', 'optional'].includes(q.importance ?? '')
|
||||
? q.importance
|
||||
: 'recommended') as FollowUpQuestion['importance'],
|
||||
importance: (q.importance ?? 'recommended') as FollowUpQuestion['importance'],
|
||||
})),
|
||||
provenance: makeProvenance(config, 'ai', false, true),
|
||||
}
|
||||
}, () => MockAIService.generateFollowUpQuestions(criteria))
|
||||
},
|
||||
|
||||
// ── generateMatchExplanation ────────────────────────────────────────────────
|
||||
generateMatchExplanation(input: MatchExplanationInput): Promise<ItemResponse<MatchExplanation>> {
|
||||
// Plain-text response — no JSON schema to validate, but non-empty check enforced.
|
||||
generateMatchExplanation(input: MatchExplanationInput): Promise<AIResponse<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 fb = await MockAIService.generateMatchExplanation(input)
|
||||
return { ...fb, provenance: makeProvenance(config, 'mock', true, false) }
|
||||
}
|
||||
const scoreLabel = input.matchScore >= 78 ? 'Starkes' : input.matchScore >= 52 ? 'Gutes' : 'Schwaches'
|
||||
return {
|
||||
@@ -268,43 +307,43 @@ export const OpenRouterAIService: IAIService = {
|
||||
...input.negativeFactors.slice(0, 1).map(f => `− ${f.explanation}`),
|
||||
],
|
||||
},
|
||||
provenance: makeProvenance(config, 'ai', false, true),
|
||||
}
|
||||
}, () => MockAIService.generateMatchExplanation(input))
|
||||
},
|
||||
|
||||
// ── summarizeTradeOffs ──────────────────────────────────────────────────────
|
||||
summarizeTradeOffs(tradeoffs: TradeOffInput[]): Promise<ItemResponse<TradeOffSummary>> {
|
||||
summarizeTradeOffs(tradeoffs: TradeOffInput[]): Promise<AIResponse<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 json = extractJSON<unknown>(raw)
|
||||
const ai = json ? validateAIResponse(TradeOffSummaryResponseSchema, json, 'summarizeTradeOffs') : null
|
||||
|
||||
if (!ai) {
|
||||
console.warn('[OpenRouterAIService] summarizeTradeOffs: invalid response — using mock fallback')
|
||||
const fb = await MockAIService.summarizeTradeOffs(tradeoffs)
|
||||
return { ...fb, provenance: makeProvenance(config, 'mock', true, false) }
|
||||
}
|
||||
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),
|
||||
items: ai.items.map(item => ({
|
||||
concern: item.concern,
|
||||
severity: item.severity,
|
||||
mitigation: item.mitigation,
|
||||
})),
|
||||
overallRisk: validSeverity(ai.overallRisk),
|
||||
overallRisk: ai.overallRisk,
|
||||
},
|
||||
provenance: makeProvenance(config, 'ai', false, true),
|
||||
}
|
||||
}, () => MockAIService.summarizeTradeOffs(tradeoffs))
|
||||
},
|
||||
|
||||
// ── summarizeComparison ─────────────────────────────────────────────────────
|
||||
summarizeComparison(items: UnifiedMatchResult[]): Promise<ItemResponse<ComparisonSummary>> {
|
||||
// Hybrid: AI provides narrative text; mock provides structural per-property data.
|
||||
// source: 'hybrid' — both are labeled in provenance.
|
||||
summarizeComparison(items: UnifiedMatchResult[]): Promise<AIResponse<ComparisonSummary>> {
|
||||
return withFallback('summarizeComparison', async (config) => {
|
||||
type ItemWithProp = UnifiedMatchResult & {
|
||||
property?: { title?: string; location?: { city?: string }; rentPricePerSqm?: number }
|
||||
@@ -321,17 +360,14 @@ export const OpenRouterAIService: IAIService = {
|
||||
}))
|
||||
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 json = extractJSON<unknown>(raw)
|
||||
const ai = json ? validateAIResponse(CompareSummaryResponseSchema, json, 'summarizeComparison') : null
|
||||
|
||||
if (!ai) {
|
||||
console.warn('[OpenRouterAIService] summarizeComparison: invalid response — using mock fallback')
|
||||
const fb = await MockAIService.summarizeComparison(items)
|
||||
return { ...fb, provenance: makeProvenance(config, 'mock', true, false) }
|
||||
}
|
||||
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: {
|
||||
@@ -339,107 +375,93 @@ export const OpenRouterAIService: IAIService = {
|
||||
overallAssessment: ai.overallAssessment,
|
||||
recommendation: ai.recommendation ?? mock.data.recommendation,
|
||||
},
|
||||
provenance: makeProvenance(config, 'hybrid', false, true),
|
||||
}
|
||||
}, () => MockAIService.summarizeComparison(items))
|
||||
},
|
||||
|
||||
// ── generateDecisionBrief ───────────────────────────────────────────────────
|
||||
generateDecisionBrief(shortlistId: string): Promise<ItemResponse<DecisionBrief>> {
|
||||
// Hybrid: AI generates narrative summary + sections; mock fills structural metadata.
|
||||
generateDecisionBrief(shortlistId: string): Promise<AIResponse<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 json = extractJSON<unknown>(raw)
|
||||
const ai = json ? validateAIResponse(DecisionBriefResponseSchema, json, 'generateDecisionBrief') : null
|
||||
|
||||
if (!ai) {
|
||||
console.warn('[OpenRouterAIService] generateDecisionBrief: invalid response — using mock fallback')
|
||||
const fb = await MockAIService.generateDecisionBrief(shortlistId)
|
||||
return { ...fb, provenance: makeProvenance(config, 'mock', true, false) }
|
||||
}
|
||||
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,
|
||||
sections: ai.sections.map(s => ({ title: s.title, body: s.body })),
|
||||
},
|
||||
provenance: makeProvenance(config, 'hybrid', false, true),
|
||||
}
|
||||
}, () => MockAIService.generateDecisionBrief(shortlistId))
|
||||
},
|
||||
|
||||
// ── generateDataQualitySummary ──────────────────────────────────────────────
|
||||
generateDataQualitySummary(propertyId: string, quality: DataQualityInput): Promise<ItemResponse<DataQualitySummary>> {
|
||||
generateDataQualitySummary(propertyId: string, quality: DataQualityInput): Promise<AIResponse<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)
|
||||
const json = extractJSON<unknown>(raw)
|
||||
const ai = json ? validateAIResponse(DataQualitySummaryResponseSchema, json, 'generateDataQualitySummary') : null
|
||||
|
||||
if (!ai) {
|
||||
console.warn('[OpenRouterAIService] generateDataQualitySummary: invalid response — using mock fallback')
|
||||
const fb = await MockAIService.generateDataQualitySummary(propertyId, quality)
|
||||
return { ...fb, provenance: makeProvenance(config, 'mock', true, false) }
|
||||
}
|
||||
return {
|
||||
data: {
|
||||
overallAssessment: ai.overallAssessment,
|
||||
missingCriticalFields: ai.missingCriticalFields ?? quality.missingCriticalFields,
|
||||
recommendation: ai.recommendation ?? '',
|
||||
confidence: typeof ai.confidence === 'number' ? ai.confidence : quality.score,
|
||||
recommendation: ai.recommendation,
|
||||
confidence: ai.confidence,
|
||||
},
|
||||
provenance: makeProvenance(config, 'ai', false, true),
|
||||
}
|
||||
}, () => MockAIService.generateDataQualitySummary(propertyId, quality))
|
||||
},
|
||||
|
||||
// ── classifyMarketSignal ────────────────────────────────────────────────────
|
||||
classifyMarketSignal(signalText: string): Promise<ItemResponse<MarketSignalClassification>> {
|
||||
classifyMarketSignal(signalText: string): Promise<AIResponse<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 json = extractJSON<unknown>(raw)
|
||||
const ai = json
|
||||
? validateAIResponse(MarketSignalClassificationResponseSchema, json, 'classifyMarketSignal')
|
||||
: null
|
||||
|
||||
if (!ai) {
|
||||
console.warn('[OpenRouterAIService] classifyMarketSignal: invalid response — using mock fallback')
|
||||
const fb = await MockAIService.classifyMarketSignal(signalText)
|
||||
return { ...fb, provenance: makeProvenance(config, 'mock', true, false) }
|
||||
}
|
||||
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 ?? '',
|
||||
signalType: ai.signalType,
|
||||
probability: ai.probability,
|
||||
timeHorizonMonths: ai.timeHorizonMonths ?? null,
|
||||
areaSqmEstimate: ai.areaSqmEstimate ?? null,
|
||||
credibility: ai.credibility,
|
||||
reasoning: ai.reasoning,
|
||||
},
|
||||
provenance: makeProvenance(config, 'ai', false, true),
|
||||
}
|
||||
}, () => MockAIService.classifyMarketSignal(signalText))
|
||||
},
|
||||
|
||||
// ── generateOfferEmail ──────────────────────────────────────────────────────
|
||||
generateOfferEmail(payload: OfferEmailPayload): Promise<ItemResponse<{ subject: string; body: string }>> {
|
||||
generateOfferEmail(payload: OfferEmailPayload): Promise<AIResponse<{ subject: string; body: string }>> {
|
||||
return withFallback('generateOfferEmail', async (config) => {
|
||||
const propertyList = payload.properties
|
||||
.map((p, i) => `• ${p} (Match-Score: ${payload.matchScores[i]}%)`)
|
||||
@@ -447,25 +469,33 @@ export const OpenRouterAIService: IAIService = {
|
||||
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)
|
||||
const json = extractJSON<unknown>(raw)
|
||||
const ai = json ? validateAIResponse(OfferEmailResponseSchema, json, 'generateOfferEmail') : null
|
||||
|
||||
if (!ai) {
|
||||
console.warn('[OpenRouterAIService] generateOfferEmail: invalid response — using mock fallback')
|
||||
const fb = await MockAIService.generateOfferEmail(payload)
|
||||
return { ...fb, provenance: makeProvenance(config, 'mock', true, false) }
|
||||
}
|
||||
return {
|
||||
data: { subject: ai.subject, body: ai.body },
|
||||
provenance: makeProvenance(config, 'ai', false, true),
|
||||
}
|
||||
return { data: { subject: ai.subject, body: ai.body } }
|
||||
}, () => MockAIService.generateOfferEmail(payload))
|
||||
},
|
||||
|
||||
// ── Legacy: extractCriteria ─────────────────────────────────────────────────
|
||||
extractCriteria(input: string): Promise<ItemResponse<CriteriaExtractionResult>> {
|
||||
extractCriteria(input: string): Promise<AIResponse<CriteriaExtractionResult>> {
|
||||
return withFallback('extractCriteria', async (config) => {
|
||||
const { system, user } = buildNeedParsingPrompt({ userInput: input })
|
||||
const raw = await chat(config, system, user)
|
||||
const ai = extractJSON<RawNeedParseAI>(raw)
|
||||
const json = extractJSON<RawNeedParseAI>(raw)
|
||||
const ai = json ? validateAIResponse(NeedParsingResponseSchema, json, 'extractCriteria') : null
|
||||
|
||||
if (!ai) {
|
||||
console.warn('[OpenRouterAIService] extractCriteria: could not parse JSON — using mock fallback')
|
||||
return MockAIService.extractCriteria(input)
|
||||
console.warn('[OpenRouterAIService] extractCriteria: invalid response — using mock fallback')
|
||||
const fb = await MockAIService.extractCriteria(input)
|
||||
return { ...fb, provenance: makeProvenance(config, 'mock', true, false) }
|
||||
}
|
||||
return {
|
||||
data: {
|
||||
@@ -480,12 +510,13 @@ export const OpenRouterAIService: IAIService = {
|
||||
assumptions: ai.assumptions ?? [],
|
||||
followUpQuestions: (ai.missingFields ?? []).map(followUpForField),
|
||||
},
|
||||
provenance: makeProvenance(config, 'ai', false, true),
|
||||
}
|
||||
}, () => MockAIService.extractCriteria(input))
|
||||
},
|
||||
|
||||
// ── Legacy: generateFollowUp ────────────────────────────────────────────────
|
||||
generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<ItemResponse<string[]>> {
|
||||
generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<AIResponse<string[]>> {
|
||||
return withFallback('generateFollowUp', async (config) => {
|
||||
const missingFields = [
|
||||
...(!partialNeed.assetType ? ['assetType'] : []),
|
||||
@@ -493,19 +524,26 @@ export const OpenRouterAIService: IAIService = {
|
||||
...(!partialNeed.timing ? ['timing'] : []),
|
||||
...(!partialNeed.budgetRange ? ['budgetRange'] : []),
|
||||
]
|
||||
if (!missingFields.length) return { data: [] }
|
||||
if (!missingFields.length) {
|
||||
return { data: [], provenance: makeProvenance(config, 'ai', false, true) }
|
||||
}
|
||||
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)
|
||||
const json = extractJSON<unknown[]>(raw)
|
||||
const ai = json ? validateAIResponse(FollowUpQuestionsResponseSchema, json, 'generateFollowUp') : null
|
||||
|
||||
if (!ai?.length) {
|
||||
console.warn('[OpenRouterAIService] generateFollowUp: empty response — using mock fallback')
|
||||
return MockAIService.generateFollowUp(partialNeed)
|
||||
console.warn('[OpenRouterAIService] generateFollowUp: invalid response — using mock fallback')
|
||||
const fb = await MockAIService.generateFollowUp(partialNeed)
|
||||
return { ...fb, provenance: makeProvenance(config, 'mock', true, false) }
|
||||
}
|
||||
return {
|
||||
data: ai.map(q => q.questionText).filter(Boolean),
|
||||
provenance: makeProvenance(config, 'ai', false, true),
|
||||
}
|
||||
return { data: ai.map(q => q.questionText ?? '').filter(Boolean) }
|
||||
}, () => MockAIService.generateFollowUp(partialNeed))
|
||||
},
|
||||
}
|
||||
|
||||
@@ -11,12 +11,42 @@ export interface DecisionBriefPromptInput {
|
||||
}
|
||||
|
||||
export function buildDecisionBriefPrompt(input: DecisionBriefPromptInput): { system: string; user: string } {
|
||||
const itemList = input.shortlistItems
|
||||
const itemList = input.shortlistItems.length > 0
|
||||
? input.shortlistItems
|
||||
.map(i => `- ${i.title} (${i.city}): Score ${i.matchScore}%, ${i.areaSqm}m², CHF ${i.rentPerSqm}/m² — ${i.topReasons.join(', ')}`)
|
||||
.join('\n')
|
||||
: '(keine Objekte auf der Shortlist)'
|
||||
|
||||
return {
|
||||
system: `Du bist Senior Real Estate Advisor. Erstelle ein strukturiertes Entscheidungs-Briefing auf Deutsch als JSON mit: summary, sections (Zusammenfassung, Standortbewertung, Budgetanalyse, Empfohlene nächste Schritte).`,
|
||||
user: `Erstelle ein Entscheidungs-Briefing für folgende Shortlist:\n\nSuchprofil: ${input.needSummary}\n\nObjekte:\n${itemList}`,
|
||||
const system = `Du bist Senior Real Estate Advisor bei Wincasa AG. Du erstellst strukturierte Entscheidungs-Briefings für Unternehmenskunden auf Deutsch.
|
||||
|
||||
WICHTIG — Konfidenzregeln:
|
||||
- Schreibe nur, was durch die Daten belegt ist. Verwende "scheint", "deutet darauf hin", "laut Datenlage" wenn du Einschätzungen machst.
|
||||
- Stelle keine Verfügbarkeit als gesichert dar, wenn sie nicht explizit bestätigt ist.
|
||||
- Unterscheide zwischen Stärken ("erfüllt vollständig") und Hinweisen ("Tendenz erkennbar").
|
||||
|
||||
AUSGABEFORMAT — antworte ausschliesslich als valides JSON (kein Markdown, keine Erklärungen):
|
||||
{
|
||||
"summary": "1–2 Sätze Executive Summary",
|
||||
"sections": [
|
||||
{ "title": "Zusammenfassung", "body": "Überblick über die Shortlist und Gesamtbewertung" },
|
||||
{ "title": "Standortbewertung", "body": "Vergleich der Standorte nach Erreichbarkeit, Prestige, Eignung" },
|
||||
{ "title": "Budgetanalyse", "body": "Kostenvergleich und Budget-Effizienz der Objekte" },
|
||||
{ "title": "Empfohlene nächste Schritte", "body": "Konkrete, priorisierte Handlungsempfehlungen" }
|
||||
]
|
||||
}
|
||||
|
||||
Regeln:
|
||||
- Genau 4 Sektionen, Reihenfolge wie oben
|
||||
- summary: max 2 Sätze
|
||||
- body jeder Sektion: 2–4 Sätze, entscheidungsorientiert
|
||||
- Kein JSON in Markdown-Codeblöcken`
|
||||
|
||||
const user = `Erstelle ein Entscheidungs-Briefing für folgende Shortlist:
|
||||
|
||||
Suchprofil: ${input.needSummary}
|
||||
|
||||
Objekte:
|
||||
${itemList}`
|
||||
|
||||
return { system, user }
|
||||
}
|
||||
|
||||
@@ -5,15 +5,32 @@ export interface MatchExplanationPromptInput {
|
||||
positiveFactors: Array<{ criterion: string; explanation: string }>
|
||||
negativeFactors: Array<{ criterion: string; explanation: string }>
|
||||
needSummary: string
|
||||
isFutureSignal?: boolean
|
||||
}
|
||||
|
||||
export function buildMatchExplanationPrompt(input: MatchExplanationPromptInput): { system: string; user: string } {
|
||||
return {
|
||||
system: `Du bist ein Experte für Schweizer Gewerbeimmobilien. Erkläre Match-Ergebnisse präzise und entscheidungsorientiert auf Deutsch. Maximal 3 Sätze.`,
|
||||
user: `Erkläre warum das Objekt "${input.propertyTitle}" in ${input.propertyCity} einen Match Score von ${input.matchScore}% hat.
|
||||
const confidenceNote = input.isFutureSignal
|
||||
? '\nDieses Objekt ist ein Zukunftssignal (noch nicht verfügbar). Stelle die Verfügbarkeit NICHT als gesichert dar. Verwende Formulierungen wie "könnte verfügbar werden", "Signal deutet auf mögliche Fläche hin".'
|
||||
: ''
|
||||
|
||||
Stärken: ${input.positiveFactors.map(f => f.explanation).join(', ')}
|
||||
Schwächen: ${input.negativeFactors.map(f => f.explanation).join(', ')}
|
||||
Suchprofil: ${input.needSummary}`,
|
||||
}
|
||||
const system = `Du bist Experte für Schweizer Gewerbeimmobilien bei Wincasa AG. Du erklärst Match-Ergebnisse präzise und entscheidungsorientiert auf Deutsch.
|
||||
|
||||
Konfidenz-Vokabular:
|
||||
- Score ≥ 78: "starkes Match", "erfüllt die Kernkriterien", "klar empfehlenswert"
|
||||
- Score 52–77: "gutes Match mit Kompromissen", "weitgehend geeignet", "einzelne Einschränkungen"
|
||||
- Score < 52: "schwaches Match", "deutliche Abweichungen", "kritische Lücken"
|
||||
|
||||
Regeln:
|
||||
- Maximal 3 Sätze
|
||||
- Nenne die 1–2 stärksten Gründe für den Score
|
||||
- Erwähne die grösste Einschränkung, falls vorhanden
|
||||
- Keine allgemeinen Floskeln ("ein attraktives Objekt") — nur konkrete Fakten aus den Score-Faktoren${confidenceNote}`
|
||||
|
||||
const user = `Erkläre warum das Objekt "${input.propertyTitle}" in ${input.propertyCity} einen Match-Score von ${input.matchScore}/100 hat.
|
||||
|
||||
Stärken: ${input.positiveFactors.map(f => f.explanation).join('; ')}
|
||||
Schwächen: ${input.negativeFactors.map(f => f.explanation).join('; ')}
|
||||
Suchprofil: ${input.needSummary}`
|
||||
|
||||
return { system, user }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Zod schemas for all AI response types.
|
||||
*
|
||||
* Every OpenRouter response is validated against its schema before reaching
|
||||
* the UI. Validation failures trigger an explicit fallback to MockAIService —
|
||||
* no invalid data ever passes through silently.
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
|
||||
// ── Shared primitives ─────────────────────────────────────────────────────────
|
||||
|
||||
export const SeveritySchema = z.enum(['LOW', 'MEDIUM', 'HIGH'])
|
||||
export const CredibilitySchema = z.enum(['LOW', 'MEDIUM', 'HIGH'])
|
||||
|
||||
/** Probability must be strictly within [0, 1] — no -5, no 1.5 */
|
||||
export const ProbabilitySchema = z.number().min(0).max(1)
|
||||
|
||||
/** Match score must be within [0, 100] */
|
||||
export const ScoreSchema = z.number().min(0).max(100)
|
||||
|
||||
// ── 1. Need Parsing ───────────────────────────────────────────────────────────
|
||||
|
||||
export const NeedParsingResponseSchema = z.object({
|
||||
assetType: z
|
||||
.enum(['OFFICE', 'RETAIL', 'LOGISTICS', 'PRODUCTION', 'GASTRO', 'MIXED', 'UNKNOWN'])
|
||||
.optional()
|
||||
.nullable(),
|
||||
areaRange: z
|
||||
.object({ min: z.number().min(0), max: z.number().min(0) })
|
||||
.optional()
|
||||
.nullable()
|
||||
.refine(r => r == null || r.max >= r.min, { message: 'areaRange.max must be >= min' }),
|
||||
preferredLocations: z.array(z.string().min(1)).optional(),
|
||||
budgetRange: z
|
||||
.object({ maxPerSqm: z.number().positive(), currency: z.string().min(1) })
|
||||
.optional()
|
||||
.nullable(),
|
||||
timing: z
|
||||
.object({
|
||||
earliestMoveIn: z.string().optional(),
|
||||
latestMoveIn: z.string().optional(),
|
||||
flexibleTiming: z.boolean().optional(),
|
||||
})
|
||||
.optional()
|
||||
.nullable(),
|
||||
mustHaveCriteria: z.array(z.string()).optional(),
|
||||
missingFields: z.array(z.string()).optional(),
|
||||
assumptions: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
export type NeedParsingResponseRaw = z.infer<typeof NeedParsingResponseSchema>
|
||||
|
||||
// ── 2. Follow-Up Questions ────────────────────────────────────────────────────
|
||||
|
||||
export const FollowUpQuestionsResponseSchema = z
|
||||
.array(
|
||||
z.object({
|
||||
questionText: z.string().min(1),
|
||||
targetField: z.string().min(1),
|
||||
reason: z.string().optional(),
|
||||
suggestedAnswerOptions: z.array(z.string()).optional(),
|
||||
importance: z
|
||||
.enum(['required', 'recommended', 'optional'])
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.max(5)
|
||||
|
||||
// ── 3. Trade-Off Summary ──────────────────────────────────────────────────────
|
||||
|
||||
export const TradeOffSummaryResponseSchema = z.object({
|
||||
headline: z.string().min(1),
|
||||
items: z
|
||||
.array(
|
||||
z.object({
|
||||
concern: z.string().min(1),
|
||||
severity: SeveritySchema,
|
||||
mitigation: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.max(5),
|
||||
overallRisk: SeveritySchema,
|
||||
})
|
||||
|
||||
// ── 4. Comparison Summary ─────────────────────────────────────────────────────
|
||||
|
||||
export const CompareSummaryResponseSchema = z.object({
|
||||
overallAssessment: z.string().min(1),
|
||||
recommendation: z.string().optional(),
|
||||
strongestOption: z.string().optional(),
|
||||
})
|
||||
|
||||
// ── 5. Decision Brief ────────────────────────────────────────────────────────
|
||||
|
||||
export const DecisionBriefResponseSchema = z.object({
|
||||
summary: z.string().min(1),
|
||||
sections: z
|
||||
.array(z.object({ title: z.string().min(1), body: z.string().min(1) }))
|
||||
.min(1)
|
||||
.max(6),
|
||||
})
|
||||
|
||||
// ── 6. Data Quality Summary ───────────────────────────────────────────────────
|
||||
|
||||
export const DataQualitySummaryResponseSchema = z.object({
|
||||
overallAssessment: z.string().min(1),
|
||||
missingCriticalFields: z.array(z.string()).optional(),
|
||||
recommendation: z.string().min(1),
|
||||
confidence: z.number().min(0).max(1),
|
||||
})
|
||||
|
||||
// ── 7. Market Signal Classification ──────────────────────────────────────────
|
||||
|
||||
export const MarketSignalClassificationResponseSchema = z.object({
|
||||
signalType: z.enum([
|
||||
'VACANCY', 'CONSTRUCTION', 'RESTRUCTURING',
|
||||
'EXPANSION', 'RELOCATION', 'UNKNOWN',
|
||||
]),
|
||||
probability: ProbabilitySchema,
|
||||
timeHorizonMonths: z.number().positive().int().optional().nullable(),
|
||||
areaSqmEstimate: z.number().positive().optional().nullable(),
|
||||
credibility: CredibilitySchema,
|
||||
reasoning: z.string().min(1),
|
||||
})
|
||||
|
||||
// ── 8. Offer Email ────────────────────────────────────────────────────────────
|
||||
|
||||
export const OfferEmailResponseSchema = z.object({
|
||||
subject: z.string().min(1),
|
||||
body: z.string().min(10),
|
||||
})
|
||||
|
||||
// ── Validation helper ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validates raw AI JSON against a Zod schema.
|
||||
* Returns the parsed value on success, or null on failure.
|
||||
* Logs a structured warning on failure — never throws.
|
||||
*/
|
||||
export function validateAIResponse<T>(
|
||||
schema: z.ZodType<T>,
|
||||
raw: unknown,
|
||||
label: string,
|
||||
): T | null {
|
||||
const result = schema.safeParse(raw)
|
||||
if (result.success) return result.data
|
||||
console.warn(`[AISchema] ${label} validation failed:`, result.error.flatten())
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user