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:
Benjamin Sutter
2026-05-24 13:44:46 +02:00
parent 8f1db31683
commit e62391af66
9 changed files with 1259 additions and 235 deletions
@@ -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, 01)', () => {
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 01 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)
})
})
+56 -14
View File
@@ -1,8 +1,52 @@
import type { ItemResponse } from '../types'
import type { CreateNeedInput } from '../../domain/need' import type { CreateNeedInput } from '../../domain/need'
import type { ParseNeedResult, ParsedNeedCriteria, FollowUpQuestion } from '../../domain/needBuilder' import type { ParseNeedResult, ParsedNeedCriteria, FollowUpQuestion } from '../../domain/needBuilder'
import type { UnifiedMatchResult } from '../../domain/unifiedResult' import type { UnifiedMatchResult } from '../../domain/unifiedResult'
// ── 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 ──────────────────────────────────────────────────────────── // ── Response Types ────────────────────────────────────────────────────────────
export interface DecisionBrief { export interface DecisionBrief {
@@ -49,8 +93,6 @@ export interface ParsedListingData {
fitOut?: string fitOut?: string
} }
// ── New Response Types ────────────────────────────────────────────────────────
export interface MatchExplanation { export interface MatchExplanation {
headline: string headline: string
summary: string summary: string
@@ -124,29 +166,29 @@ export interface AIServiceProvider {
export interface IAIService { export interface IAIService {
// Need parsing (F008) // Need parsing (F008)
parseNeed(input: string): Promise<ItemResponse<ParseNeedResult>> parseNeed(input: string): Promise<AIResponse<ParseNeedResult>>
generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise<ItemResponse<FollowUpQuestion[]>> generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise<AIResponse<FollowUpQuestion[]>>
// Match explainability (F004) // Match explainability (F004)
generateMatchExplanation(input: MatchExplanationInput): Promise<ItemResponse<MatchExplanation>> generateMatchExplanation(input: MatchExplanationInput): Promise<AIResponse<MatchExplanation>>
summarizeTradeOffs(tradeoffs: TradeOffInput[]): Promise<ItemResponse<TradeOffSummary>> summarizeTradeOffs(tradeoffs: TradeOffInput[]): Promise<AIResponse<TradeOffSummary>>
// Compare (F014) // Compare (F014)
summarizeComparison(items: UnifiedMatchResult[]): Promise<ItemResponse<ComparisonSummary>> summarizeComparison(items: UnifiedMatchResult[]): Promise<AIResponse<ComparisonSummary>>
// Shortlist decision brief // Shortlist decision brief
generateDecisionBrief(shortlistId: string): Promise<ItemResponse<DecisionBrief>> generateDecisionBrief(shortlistId: string): Promise<AIResponse<DecisionBrief>>
// Data quality // Data quality
generateDataQualitySummary(propertyId: string, quality: DataQualityInput): Promise<ItemResponse<DataQualitySummary>> generateDataQualitySummary(propertyId: string, quality: DataQualityInput): Promise<AIResponse<DataQualitySummary>>
// Market signal classification (OPERATIONS) // Market signal classification (OPERATIONS)
classifyMarketSignal(signalText: string): Promise<ItemResponse<MarketSignalClassification>> classifyMarketSignal(signalText: string): Promise<AIResponse<MarketSignalClassification>>
// Offer email (supply side) // Offer email (supply side)
generateOfferEmail(payload: OfferEmailPayload): Promise<ItemResponse<{ subject: string; body: string }>> generateOfferEmail(payload: OfferEmailPayload): Promise<AIResponse<{ subject: string; body: string }>>
// Legacy methods // Legacy methods
extractCriteria(input: string): Promise<ItemResponse<CriteriaExtractionResult>> extractCriteria(input: string): Promise<AIResponse<CriteriaExtractionResult>>
generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<ItemResponse<string[]>> generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<AIResponse<string[]>>
} }
+345
View File
@@ -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()
})
})
+98 -18
View File
@@ -1,9 +1,9 @@
import type { ItemResponse } from '../../types'
import type { CreateNeedInput } from '../../../domain/need' import type { CreateNeedInput } from '../../../domain/need'
import type { ParsedNeedCriteria, FollowUpQuestion } from '../../../domain/needBuilder' import type { ParsedNeedCriteria, FollowUpQuestion } from '../../../domain/needBuilder'
import type { UnifiedMatchResult } from '../../../domain/unifiedResult' import type { UnifiedMatchResult } from '../../../domain/unifiedResult'
import type { import type {
IAIService, IAIService,
AIResponse,
DecisionBrief, DecisionBrief,
ComparisonSummary, ComparisonSummary,
CriteriaExtractionResult, CriteriaExtractionResult,
@@ -16,6 +16,7 @@ import type {
DataQualitySummary, DataQualitySummary,
MarketSignalClassification, MarketSignalClassification,
} from '../IAIService' } from '../IAIService'
import { mockProvenance } from '../IAIService'
import { mockParseNeed } from './needParser' import { mockParseNeed } from './needParser'
import { buildComparisonSummary } from './compareBuilder' import { buildComparisonSummary } from './compareBuilder'
import { buildMockDecisionBrief } from './decisionBrief' import { buildMockDecisionBrief } from './decisionBrief'
@@ -23,19 +24,92 @@ import { buildMockDecisionBrief } from './decisionBrief'
const SIMULATED_DELAY = { fast: 300, medium: 600, slow: 1800 } const SIMULATED_DELAY = { fast: 300, medium: 600, slow: 1800 }
const delay = (ms: number) => new Promise(r => setTimeout(r, ms)) const delay = (ms: number) => new Promise(r => setTimeout(r, ms))
// ── 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 (minmax 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 = { 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) 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) await delay(SIMULATED_DELAY.medium)
const result = mockParseNeed(JSON.stringify(criteria)) return { data: buildFollowUpQuestions(criteria), provenance: mockProvenance() }
return { data: result.followUpQuestionCandidates }
}, },
async generateMatchExplanation(input: MatchExplanationInput): Promise<ItemResponse<MatchExplanation>> { async generateMatchExplanation(input: MatchExplanationInput): Promise<AIResponse<MatchExplanation>> {
await delay(SIMULATED_DELAY.medium) await delay(SIMULATED_DELAY.medium)
const isStrong = input.matchScore >= 78 const isStrong = input.matchScore >= 78
const isMedium = input.matchScore >= 52 const isMedium = input.matchScore >= 52
@@ -56,10 +130,11 @@ export const MockAIService: IAIService = {
...input.negativeFactors.slice(0, 1).map(f => ` ${f.explanation}`), ...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) await delay(SIMULATED_DELAY.fast)
const critical = tradeoffs.filter(t => t.severity === 'HIGH') const critical = tradeoffs.filter(t => t.severity === 'HIGH')
const overallRisk: TradeOffSummary['overallRisk'] = 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 })), items: tradeoffs.map(t => ({ concern: t.concern, severity: t.severity, mitigation: t.mitigation })),
overallRisk, overallRisk,
}, },
provenance: mockProvenance(),
} }
}, },
async summarizeComparison(items: UnifiedMatchResult[]): Promise<ItemResponse<ComparisonSummary>> { async summarizeComparison(items: UnifiedMatchResult[]): Promise<AIResponse<ComparisonSummary>> {
await delay(SIMULATED_DELAY.medium) 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) 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) await delay(SIMULATED_DELAY.fast)
const level = const level =
quality.score >= 0.85 ? 'excellent' quality.score >= 0.85 ? 'excellent'
@@ -113,10 +189,11 @@ export const MockAIService: IAIService = {
: 'Keine sofortigen Massnahmen erforderlich.', : 'Keine sofortigen Massnahmen erforderlich.',
confidence: quality.score, confidence: quality.score,
}, },
provenance: mockProvenance(),
} }
}, },
async classifyMarketSignal(signalText: string): Promise<ItemResponse<MarketSignalClassification>> { async classifyMarketSignal(signalText: string): Promise<AIResponse<MarketSignalClassification>> {
await delay(SIMULATED_DELAY.medium) await delay(SIMULATED_DELAY.medium)
const t = signalText.toLowerCase() const t = signalText.toLowerCase()
let signalType: MarketSignalClassification['signalType'] = 'UNKNOWN' let signalType: MarketSignalClassification['signalType'] = 'UNKNOWN'
@@ -136,10 +213,11 @@ export const MockAIService: IAIService = {
credibility: 'MEDIUM', credibility: 'MEDIUM',
reasoning: `Keyword-basierte Klassifikation (Mock). Signaltyp: ${signalType}.`, 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) await delay(SIMULATED_DELAY.medium * 2)
return { return {
data: { data: {
@@ -149,11 +227,12 @@ export const MockAIService: IAIService = {
payload.properties.map((p, i) => `${p} (Match-Score: ${payload.matchScores[i]}%)`).join('\n') + 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`, `\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 // Legacy methods
async extractCriteria(_input: string): Promise<ItemResponse<CriteriaExtractionResult>> { async extractCriteria(_input: string): Promise<AIResponse<CriteriaExtractionResult>> {
return { return {
data: { data: {
extractedCriteria: { extractedCriteria: {
@@ -170,15 +249,16 @@ export const MockAIService: IAIService = {
'Wann möchten Sie spätestens einziehen?', '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[] = [] const questions: string[] = []
if (!partialNeed.assetType) questions.push('Welchen Nutzungstyp suchen Sie?') if (!partialNeed.assetType) questions.push('Welchen Nutzungstyp suchen Sie?')
if (!partialNeed.preferredLocations?.length) questions.push('In welchen Regionen 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.timing) questions.push('Was ist Ihr gewünschter Einzugstermin?')
if (!partialNeed.budgetRange) questions.push('Was ist Ihr maximales monatliches Budget?') if (!partialNeed.budgetRange) questions.push('Was ist Ihr maximales monatliches Budget?')
return { data: questions } return { data: questions, provenance: mockProvenance() }
}, },
} }
+170 -132
View File
@@ -6,22 +6,24 @@
* VITE_OPENROUTER_API_KEY=<your-key> * VITE_OPENROUTER_API_KEY=<your-key>
* VITE_OPENROUTER_MODEL=anthropic/claude-3-5-haiku (optional, default shown) * VITE_OPENROUTER_MODEL=anthropic/claude-3-5-haiku (optional, default shown)
* *
* All methods follow this contract: * Every method follows this contract:
* 1. If API key is missing → explicit warn + MockAIService fallback * 1. No API key warn + MockAIService fallback (fallbackUsed: true)
* 2. If API call fails → explicit error log + MockAIService fallback * 2. HTTP error → error log + MockAIService fallback
* 3. If JSON parse fails explicit warn + MockAIService fallback * 3. JSON parse fail → warn + MockAIService fallback
* 4. On success → fully AI-generated response, no silent mock merge * 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 * No invalid data ever reaches the UI.
* explicitly documented with why mock data fills the remaining fields.
*/ */
import type { ItemResponse } from '../../types'
import type { CreateNeedInput } from '../../../domain/need' import type { CreateNeedInput } from '../../../domain/need'
import type { ParseNeedResult, ParsedNeedCriteria, FollowUpQuestion } from '../../../domain/needBuilder' import type { ParseNeedResult, ParsedNeedCriteria, FollowUpQuestion } from '../../../domain/needBuilder'
import type { UnifiedMatchResult } from '../../../domain/unifiedResult' import type { UnifiedMatchResult } from '../../../domain/unifiedResult'
import type { AssetType } from '../../../domain/enums' import type { AssetType } from '../../../domain/enums'
import type { import type {
IAIService, IAIService,
AIResponse,
AIProvenance,
DecisionBrief, DecisionBrief,
ComparisonSummary, ComparisonSummary,
CriteriaExtractionResult, CriteriaExtractionResult,
@@ -36,6 +38,17 @@ import type {
} from '../IAIService' } from '../IAIService'
import { ServiceErrorCode } from '../../types' import { ServiceErrorCode } from '../../types'
import { AppError } from '../../errors' import { AppError } from '../../errors'
import {
NeedParsingResponseSchema,
FollowUpQuestionsResponseSchema,
TradeOffSummaryResponseSchema,
CompareSummaryResponseSchema,
DecisionBriefResponseSchema,
DataQualitySummaryResponseSchema,
MarketSignalClassificationResponseSchema,
OfferEmailResponseSchema,
validateAIResponse,
} from '../schemas'
import { buildNeedParsingPrompt } from '../prompts/needParsingPrompt' import { buildNeedParsingPrompt } from '../prompts/needParsingPrompt'
import { buildFollowUpQuestionsPrompt } from '../prompts/followUpQuestionsPrompt' import { buildFollowUpQuestionsPrompt } from '../prompts/followUpQuestionsPrompt'
import { buildMatchExplanationPrompt } from '../prompts/matchExplanationPrompt' import { buildMatchExplanationPrompt } from '../prompts/matchExplanationPrompt'
@@ -50,7 +63,7 @@ import { MockAIService } from '../mock/MockAIService'
const API_BASE = 'https://openrouter.ai/api/v1' const API_BASE = 'https://openrouter.ai/api/v1'
const DEFAULT_MODEL = 'anthropic/claude-3-5-haiku' const DEFAULT_MODEL = 'anthropic/claude-3-5-haiku'
const PROMPT_VERSION = 'v1.0' const PROMPT_VERSION = 'v1.1'
const SCHEMA_VERSION = 'v1.0' const SCHEMA_VERSION = 'v1.0'
interface OpenRouterConfig { 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 ─────────────────────────────────────────────────────────────── // ── HTTP helper ───────────────────────────────────────────────────────────────
async function chat(config: OpenRouterConfig, system: string, user: string): Promise<string> { 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 ─────────────────────────────────────────────────────────── // ── JSON extraction ───────────────────────────────────────────────────────────
function extractJSON<T>(raw: string): T | null { 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 fenced = raw.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/)
const candidate = fenced ? fenced[1] : raw.match(/([\[{][\s\S]*[\]}])/)?.[1] ?? raw const candidate = fenced ? fenced[1] : raw.match(/([\[{][\s\S]*[\]}])/)?.[1] ?? raw
try { try {
@@ -109,7 +138,7 @@ function extractJSON<T>(raw: string): T | null {
} }
} }
// ── Helpers for ParseNeedResult mapping ─────────────────────────────────────── // ── ParseNeed helpers ─────────────────────────────────────────────────────────
type RawNeedParseAI = { type RawNeedParseAI = {
assetType?: string | null assetType?: string | null
@@ -145,23 +174,25 @@ function defaultSuggestedWeights(): Record<string, number> {
// ── Fallback wrapper ────────────────────────────────────────────────────────── // ── Fallback wrapper ──────────────────────────────────────────────────────────
type FallbackFn<T> = () => Promise<ItemResponse<T>> type FallbackFn<T> = () => Promise<AIResponse<T>>
async function withFallback<T>( async function withFallback<T>(
label: string, label: string,
fn: (config: OpenRouterConfig) => Promise<ItemResponse<T>>, fn: (config: OpenRouterConfig) => Promise<AIResponse<T>>,
fallback: FallbackFn<T>, fallback: FallbackFn<T>,
): Promise<ItemResponse<T>> { ): Promise<AIResponse<T>> {
const config = getConfig() const config = getConfig()
if (!config) { if (!config) {
console.warn(`[OpenRouterAIService] ${label}: no API key — using MockAIService`) console.warn(`[OpenRouterAIService] ${label}: no API key — using MockAIService`)
return fallback() const result = await fallback()
return { ...result, provenance: { ...result.provenance, fallbackUsed: true } }
} }
try { try {
return await fn(config) return await fn(config)
} catch (err) { } catch (err) {
console.error(`[OpenRouterAIService] ${label} failed:`, 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 = { export const OpenRouterAIService: IAIService = {
// ── parseNeed ─────────────────────────────────────────────────────────────── // ── parseNeed ───────────────────────────────────────────────────────────────
parseNeed(input: string): Promise<ItemResponse<ParseNeedResult>> { parseNeed(input: string): Promise<AIResponse<ParseNeedResult>> {
return withFallback('parseNeed', async (config) => { return withFallback('parseNeed', async (config) => {
const { system, user } = buildNeedParsingPrompt({ userInput: input }) const { system, user } = buildNeedParsingPrompt({ userInput: input })
const raw = await chat(config, system, user) const raw = await chat(config, system, user)
const ai = extractJSON<RawNeedParseAI>(raw) const json = extractJSON<RawNeedParseAI>(raw)
const ai = json ? validateAIResponse(NeedParsingResponseSchema, json, 'parseNeed') : null
if (!ai) { if (!ai) {
console.warn('[OpenRouterAIService] parseNeed: could not parse JSON — using mock fallback') console.warn('[OpenRouterAIService] parseNeed: invalid response — using mock fallback')
return MockAIService.parseNeed(input) const fb = await MockAIService.parseNeed(input)
return { ...fb, provenance: makeProvenance(config, 'mock', true, false) }
} }
const extractedCriteria: ParsedNeedCriteria = { const extractedCriteria: ParsedNeedCriteria = {
assetType: (ai.assetType ?? undefined) as AssetType | undefined, assetType: (ai.assetType ?? undefined) as AssetType | undefined,
areaRange: ai.areaRange ?? undefined, areaRange: ai.areaRange ?? undefined,
@@ -214,49 +249,53 @@ export const OpenRouterAIService: IAIService = {
promptVersion: PROMPT_VERSION, promptVersion: PROMPT_VERSION,
schemaVersion: SCHEMA_VERSION, schemaVersion: SCHEMA_VERSION,
}, },
provenance: makeProvenance(config, 'ai', false, true),
} }
}, () => MockAIService.parseNeed(input)) }, () => MockAIService.parseNeed(input))
}, },
// ── generateFollowUpQuestions ─────────────────────────────────────────────── // ── generateFollowUpQuestions ───────────────────────────────────────────────
generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise<ItemResponse<FollowUpQuestion[]>> { generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise<AIResponse<FollowUpQuestion[]>> {
return withFallback('generateFollowUpQuestions', async (config) => { return withFallback('generateFollowUpQuestions', async (config) => {
const missingFields = Object.entries(criteria) const missingFields = Object.entries(criteria)
.filter(([, v]) => v == null) .filter(([, v]) => v == null)
.map(([k]) => k) .map(([k]) => k)
const { system, user } = buildFollowUpQuestionsPrompt({ criteria, missingFields }) const { system, user } = buildFollowUpQuestionsPrompt({ criteria, missingFields })
const raw = await chat(config, system, user) const raw = await chat(config, system, user)
type RawFQ = { questionText?: string; targetField?: string; reason?: string; suggestedAnswerOptions?: string[]; importance?: string } const json = extractJSON<unknown[]>(raw)
const ai = extractJSON<RawFQ[]>(raw) const ai = json ? validateAIResponse(FollowUpQuestionsResponseSchema, json, 'generateFollowUpQuestions') : null
if (!ai?.length) { if (!ai?.length) {
console.warn('[OpenRouterAIService] generateFollowUpQuestions: empty response — using mock fallback') console.warn('[OpenRouterAIService] generateFollowUpQuestions: invalid response — using mock fallback')
return MockAIService.generateFollowUpQuestions(criteria) const fb = await MockAIService.generateFollowUpQuestions(criteria)
return { ...fb, provenance: makeProvenance(config, 'mock', true, false) }
} }
return { return {
data: ai.map((q, i) => ({ data: ai.map((q, i) => ({
id: `fq-or-${i}`, id: `fq-or-${i}`,
questionText: q.questionText ?? '?', questionText: q.questionText,
targetField: q.targetField ?? 'unknown', targetField: q.targetField,
reason: q.reason ?? 'AI-generiert', reason: q.reason ?? 'AI-generiert',
suggestedAnswerOptions: q.suggestedAnswerOptions, suggestedAnswerOptions: q.suggestedAnswerOptions,
importance: (['required', 'recommended', 'optional'].includes(q.importance ?? '') importance: (q.importance ?? 'recommended') as FollowUpQuestion['importance'],
? q.importance
: 'recommended') as FollowUpQuestion['importance'],
})), })),
provenance: makeProvenance(config, 'ai', false, true),
} }
}, () => MockAIService.generateFollowUpQuestions(criteria)) }, () => MockAIService.generateFollowUpQuestions(criteria))
}, },
// ── generateMatchExplanation ──────────────────────────────────────────────── // ── 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) => { return withFallback('generateMatchExplanation', async (config) => {
const { system, user } = buildMatchExplanationPrompt(input) const { system, user } = buildMatchExplanationPrompt(input)
const raw = await chat(config, system, user) const raw = await chat(config, system, user)
// matchExplanationPrompt returns plain text (max 3 sentences), not JSON
const summary = raw.trim() const summary = raw.trim()
if (!summary) { if (!summary) {
console.warn('[OpenRouterAIService] generateMatchExplanation: empty response — using mock fallback') 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' const scoreLabel = input.matchScore >= 78 ? 'Starkes' : input.matchScore >= 52 ? 'Gutes' : 'Schwaches'
return { return {
@@ -268,43 +307,43 @@ export const OpenRouterAIService: IAIService = {
...input.negativeFactors.slice(0, 1).map(f => ` ${f.explanation}`), ...input.negativeFactors.slice(0, 1).map(f => ` ${f.explanation}`),
], ],
}, },
provenance: makeProvenance(config, 'ai', false, true),
} }
}, () => MockAIService.generateMatchExplanation(input)) }, () => MockAIService.generateMatchExplanation(input))
}, },
// ── summarizeTradeOffs ────────────────────────────────────────────────────── // ── summarizeTradeOffs ──────────────────────────────────────────────────────
summarizeTradeOffs(tradeoffs: TradeOffInput[]): Promise<ItemResponse<TradeOffSummary>> { summarizeTradeOffs(tradeoffs: TradeOffInput[]): Promise<AIResponse<TradeOffSummary>> {
return withFallback('summarizeTradeOffs', async (config) => { return withFallback('summarizeTradeOffs', async (config) => {
const { system, user } = buildTradeOffPrompt(tradeoffs, 'Objekt') const { system, user } = buildTradeOffPrompt(tradeoffs, 'Objekt')
const raw = await chat(config, system, user) const raw = await chat(config, system, user)
type RawTradeOff = { const json = extractJSON<unknown>(raw)
headline?: string const ai = json ? validateAIResponse(TradeOffSummaryResponseSchema, json, 'summarizeTradeOffs') : null
items?: Array<{ concern?: string; severity?: string; mitigation?: string }>
overallRisk?: string 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 { return {
data: { data: {
headline: ai.headline, headline: ai.headline,
items: (ai.items ?? []).map(item => ({ items: ai.items.map(item => ({
concern: item.concern ?? '', concern: item.concern,
severity: validSeverity(item.severity), severity: item.severity,
mitigation: item.mitigation, mitigation: item.mitigation,
})), })),
overallRisk: validSeverity(ai.overallRisk), overallRisk: ai.overallRisk,
}, },
provenance: makeProvenance(config, 'ai', false, true),
} }
}, () => MockAIService.summarizeTradeOffs(tradeoffs)) }, () => MockAIService.summarizeTradeOffs(tradeoffs))
}, },
// ── summarizeComparison ───────────────────────────────────────────────────── // ── 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) => { return withFallback('summarizeComparison', async (config) => {
type ItemWithProp = UnifiedMatchResult & { type ItemWithProp = UnifiedMatchResult & {
property?: { title?: string; location?: { city?: string }; rentPricePerSqm?: number } property?: { title?: string; location?: { city?: string }; rentPricePerSqm?: number }
@@ -321,17 +360,14 @@ export const OpenRouterAIService: IAIService = {
})) }))
const { system, user } = buildCompareSummaryPrompt({ properties }) const { system, user } = buildCompareSummaryPrompt({ properties })
const raw = await chat(config, system, user) const raw = await chat(config, system, user)
type RawComparison = { const json = extractJSON<unknown>(raw)
overallAssessment?: string const ai = json ? validateAIResponse(CompareSummaryResponseSchema, json, 'summarizeComparison') : null
recommendation?: string
strongestOption?: { matchId?: string; label?: string; reason?: string } 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) const mock = await MockAIService.summarizeComparison(items)
return { return {
data: { data: {
@@ -339,107 +375,93 @@ export const OpenRouterAIService: IAIService = {
overallAssessment: ai.overallAssessment, overallAssessment: ai.overallAssessment,
recommendation: ai.recommendation ?? mock.data.recommendation, recommendation: ai.recommendation ?? mock.data.recommendation,
}, },
provenance: makeProvenance(config, 'hybrid', false, true),
} }
}, () => MockAIService.summarizeComparison(items)) }, () => MockAIService.summarizeComparison(items))
}, },
// ── generateDecisionBrief ─────────────────────────────────────────────────── // ── 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) => { return withFallback('generateDecisionBrief', async (config) => {
const { system, user } = buildDecisionBriefPrompt({ shortlistItems: [], needSummary: shortlistId }) const { system, user } = buildDecisionBriefPrompt({ shortlistItems: [], needSummary: shortlistId })
const raw = await chat(config, system, user) const raw = await chat(config, system, user)
type RawBrief = { const json = extractJSON<unknown>(raw)
summary?: string const ai = json ? validateAIResponse(DecisionBriefResponseSchema, json, 'generateDecisionBrief') : null
sections?: Array<{ title?: string; body?: string }>
} if (!ai) {
const ai = extractJSON<RawBrief>(raw) console.warn('[OpenRouterAIService] generateDecisionBrief: invalid response — using mock fallback')
if (!ai?.summary) { const fb = await MockAIService.generateDecisionBrief(shortlistId)
console.warn('[OpenRouterAIService] generateDecisionBrief: incomplete response — using mock fallback') return { ...fb, provenance: makeProvenance(config, 'mock', true, false) }
return MockAIService.generateDecisionBrief(shortlistId)
} }
const mock = await MockAIService.generateDecisionBrief(shortlistId) const mock = await MockAIService.generateDecisionBrief(shortlistId)
return { return {
data: { data: {
...mock.data, ...mock.data,
summary: ai.summary, summary: ai.summary,
sections: ai.sections?.map(s => ({ sections: ai.sections.map(s => ({ title: s.title, body: s.body })),
title: s.title ?? '',
body: s.body ?? '',
})) ?? mock.data.sections,
}, },
provenance: makeProvenance(config, 'hybrid', false, true),
} }
}, () => MockAIService.generateDecisionBrief(shortlistId)) }, () => MockAIService.generateDecisionBrief(shortlistId))
}, },
// ── generateDataQualitySummary ────────────────────────────────────────────── // ── generateDataQualitySummary ──────────────────────────────────────────────
generateDataQualitySummary(propertyId: string, quality: DataQualityInput): Promise<ItemResponse<DataQualitySummary>> { generateDataQualitySummary(propertyId: string, quality: DataQualityInput): Promise<AIResponse<DataQualitySummary>> {
return withFallback('generateDataQualitySummary', async (config) => { return withFallback('generateDataQualitySummary', async (config) => {
const { system, user } = buildDataQualityPrompt(propertyId, quality) const { system, user } = buildDataQualityPrompt(propertyId, quality)
const raw = await chat(config, system, user) const raw = await chat(config, system, user)
type RawDQ = { const json = extractJSON<unknown>(raw)
overallAssessment?: string const ai = json ? validateAIResponse(DataQualitySummaryResponseSchema, json, 'generateDataQualitySummary') : null
missingCriticalFields?: string[]
recommendation?: string if (!ai) {
confidence?: number console.warn('[OpenRouterAIService] generateDataQualitySummary: invalid response — using mock fallback')
} const fb = await MockAIService.generateDataQualitySummary(propertyId, quality)
const ai = extractJSON<RawDQ>(raw) return { ...fb, provenance: makeProvenance(config, 'mock', true, false) }
if (!ai?.overallAssessment) {
console.warn('[OpenRouterAIService] generateDataQualitySummary: incomplete response — using mock fallback')
return MockAIService.generateDataQualitySummary(propertyId, quality)
} }
return { return {
data: { data: {
overallAssessment: ai.overallAssessment, overallAssessment: ai.overallAssessment,
missingCriticalFields: ai.missingCriticalFields ?? quality.missingCriticalFields, missingCriticalFields: ai.missingCriticalFields ?? quality.missingCriticalFields,
recommendation: ai.recommendation ?? '', recommendation: ai.recommendation,
confidence: typeof ai.confidence === 'number' ? ai.confidence : quality.score, confidence: ai.confidence,
}, },
provenance: makeProvenance(config, 'ai', false, true),
} }
}, () => MockAIService.generateDataQualitySummary(propertyId, quality)) }, () => MockAIService.generateDataQualitySummary(propertyId, quality))
}, },
// ── classifyMarketSignal ──────────────────────────────────────────────────── // ── classifyMarketSignal ────────────────────────────────────────────────────
classifyMarketSignal(signalText: string): Promise<ItemResponse<MarketSignalClassification>> { classifyMarketSignal(signalText: string): Promise<AIResponse<MarketSignalClassification>> {
return withFallback('classifyMarketSignal', async (config) => { return withFallback('classifyMarketSignal', async (config) => {
const { system, user } = buildMarketSignalPrompt(signalText) const { system, user } = buildMarketSignalPrompt(signalText)
const raw = await chat(config, system, user) const raw = await chat(config, system, user)
type RawSignal = { const json = extractJSON<unknown>(raw)
signalType?: string const ai = json
probability?: number ? validateAIResponse(MarketSignalClassificationResponseSchema, json, 'classifyMarketSignal')
timeHorizonMonths?: number | null : null
areaSqmEstimate?: number | null
credibility?: string if (!ai) {
reasoning?: string 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 { return {
data: { data: {
signalType: validSignalType(ai.signalType), signalType: ai.signalType,
probability: typeof ai.probability === 'number' probability: ai.probability,
? Math.min(1, Math.max(0, ai.probability)) timeHorizonMonths: ai.timeHorizonMonths ?? null,
: 0.5, areaSqmEstimate: ai.areaSqmEstimate ?? null,
timeHorizonMonths: typeof ai.timeHorizonMonths === 'number' ? ai.timeHorizonMonths : null, credibility: ai.credibility,
areaSqmEstimate: typeof ai.areaSqmEstimate === 'number' ? ai.areaSqmEstimate : null, reasoning: ai.reasoning,
credibility: validCredibility(ai.credibility),
reasoning: ai.reasoning ?? '',
}, },
provenance: makeProvenance(config, 'ai', false, true),
} }
}, () => MockAIService.classifyMarketSignal(signalText)) }, () => MockAIService.classifyMarketSignal(signalText))
}, },
// ── generateOfferEmail ────────────────────────────────────────────────────── // ── generateOfferEmail ──────────────────────────────────────────────────────
generateOfferEmail(payload: OfferEmailPayload): Promise<ItemResponse<{ subject: string; body: string }>> { generateOfferEmail(payload: OfferEmailPayload): Promise<AIResponse<{ subject: string; body: string }>> {
return withFallback('generateOfferEmail', async (config) => { return withFallback('generateOfferEmail', async (config) => {
const propertyList = payload.properties const propertyList = payload.properties
.map((p, i) => `${p} (Match-Score: ${payload.matchScores[i]}%)`) .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 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 user = `Suchanfrage: "${payload.needTitle}"\n\nObjekte:\n${propertyList}\n\nErstelle eine professionelle Angebotsmail.`
const raw = await chat(config, system, user) const raw = await chat(config, system, user)
type RawEmail = { subject?: string; body?: string } const json = extractJSON<unknown>(raw)
const ai = extractJSON<RawEmail>(raw) const ai = json ? validateAIResponse(OfferEmailResponseSchema, json, 'generateOfferEmail') : null
if (!ai?.subject || !ai?.body) {
console.warn('[OpenRouterAIService] generateOfferEmail: incomplete response — using mock fallback') if (!ai) {
return MockAIService.generateOfferEmail(payload) 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)) }, () => MockAIService.generateOfferEmail(payload))
}, },
// ── Legacy: extractCriteria ───────────────────────────────────────────────── // ── Legacy: extractCriteria ─────────────────────────────────────────────────
extractCriteria(input: string): Promise<ItemResponse<CriteriaExtractionResult>> { extractCriteria(input: string): Promise<AIResponse<CriteriaExtractionResult>> {
return withFallback('extractCriteria', async (config) => { return withFallback('extractCriteria', async (config) => {
const { system, user } = buildNeedParsingPrompt({ userInput: input }) const { system, user } = buildNeedParsingPrompt({ userInput: input })
const raw = await chat(config, system, user) const raw = await chat(config, system, user)
const ai = extractJSON<RawNeedParseAI>(raw) const json = extractJSON<RawNeedParseAI>(raw)
const ai = json ? validateAIResponse(NeedParsingResponseSchema, json, 'extractCriteria') : null
if (!ai) { if (!ai) {
console.warn('[OpenRouterAIService] extractCriteria: could not parse JSON — using mock fallback') console.warn('[OpenRouterAIService] extractCriteria: invalid response — using mock fallback')
return MockAIService.extractCriteria(input) const fb = await MockAIService.extractCriteria(input)
return { ...fb, provenance: makeProvenance(config, 'mock', true, false) }
} }
return { return {
data: { data: {
@@ -480,12 +510,13 @@ export const OpenRouterAIService: IAIService = {
assumptions: ai.assumptions ?? [], assumptions: ai.assumptions ?? [],
followUpQuestions: (ai.missingFields ?? []).map(followUpForField), followUpQuestions: (ai.missingFields ?? []).map(followUpForField),
}, },
provenance: makeProvenance(config, 'ai', false, true),
} }
}, () => MockAIService.extractCriteria(input)) }, () => MockAIService.extractCriteria(input))
}, },
// ── Legacy: generateFollowUp ──────────────────────────────────────────────── // ── Legacy: generateFollowUp ────────────────────────────────────────────────
generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<ItemResponse<string[]>> { generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<AIResponse<string[]>> {
return withFallback('generateFollowUp', async (config) => { return withFallback('generateFollowUp', async (config) => {
const missingFields = [ const missingFields = [
...(!partialNeed.assetType ? ['assetType'] : []), ...(!partialNeed.assetType ? ['assetType'] : []),
@@ -493,19 +524,26 @@ export const OpenRouterAIService: IAIService = {
...(!partialNeed.timing ? ['timing'] : []), ...(!partialNeed.timing ? ['timing'] : []),
...(!partialNeed.budgetRange ? ['budgetRange'] : []), ...(!partialNeed.budgetRange ? ['budgetRange'] : []),
] ]
if (!missingFields.length) return { data: [] } if (!missingFields.length) {
return { data: [], provenance: makeProvenance(config, 'ai', false, true) }
}
const { system, user } = buildFollowUpQuestionsPrompt({ const { system, user } = buildFollowUpQuestionsPrompt({
criteria: partialNeed as ParsedNeedCriteria, criteria: partialNeed as ParsedNeedCriteria,
missingFields, missingFields,
}) })
const raw = await chat(config, system, user) const raw = await chat(config, system, user)
type RawFQ = { questionText?: string } const json = extractJSON<unknown[]>(raw)
const ai = extractJSON<RawFQ[]>(raw) const ai = json ? validateAIResponse(FollowUpQuestionsResponseSchema, json, 'generateFollowUp') : null
if (!ai?.length) { if (!ai?.length) {
console.warn('[OpenRouterAIService] generateFollowUp: empty response — using mock fallback') console.warn('[OpenRouterAIService] generateFollowUp: invalid response — using mock fallback')
return MockAIService.generateFollowUp(partialNeed) 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)) }, () => MockAIService.generateFollowUp(partialNeed))
}, },
} }
+35 -5
View File
@@ -11,12 +11,42 @@ export interface DecisionBriefPromptInput {
} }
export function buildDecisionBriefPrompt(input: DecisionBriefPromptInput): { system: string; user: string } { 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(', ')}`) .map(i => `- ${i.title} (${i.city}): Score ${i.matchScore}%, ${i.areaSqm}m², CHF ${i.rentPerSqm}/m² — ${i.topReasons.join(', ')}`)
.join('\n') .join('\n')
: '(keine Objekte auf der Shortlist)'
return { const system = `Du bist Senior Real Estate Advisor bei Wincasa AG. Du erstellst strukturierte Entscheidungs-Briefings für Unternehmenskunden auf Deutsch.
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}`, 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": "12 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: 24 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 }> positiveFactors: Array<{ criterion: string; explanation: string }>
negativeFactors: Array<{ criterion: string; explanation: string }> negativeFactors: Array<{ criterion: string; explanation: string }>
needSummary: string needSummary: string
isFutureSignal?: boolean
} }
export function buildMatchExplanationPrompt(input: MatchExplanationPromptInput): { system: string; user: string } { export function buildMatchExplanationPrompt(input: MatchExplanationPromptInput): { system: string; user: string } {
return { const confidenceNote = input.isFutureSignal
system: `Du bist ein Experte für Schweizer Gewerbeimmobilien. Erkläre Match-Ergebnisse präzise und entscheidungsorientiert auf Deutsch. Maximal 3 Sätze.`, ? '\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".'
user: `Erkläre warum das Objekt "${input.propertyTitle}" in ${input.propertyCity} einen Match Score von ${input.matchScore}% hat. : ''
Stärken: ${input.positiveFactors.map(f => f.explanation).join(', ')} const system = `Du bist Experte für Schweizer Gewerbeimmobilien bei Wincasa AG. Du erklärst Match-Ergebnisse präzise und entscheidungsorientiert auf Deutsch.
Schwächen: ${input.negativeFactors.map(f => f.explanation).join(', ')}
Suchprofil: ${input.needSummary}`, Konfidenz-Vokabular:
} - Score ≥ 78: "starkes Match", "erfüllt die Kernkriterien", "klar empfehlenswert"
- Score 5277: "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 12 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 }
} }
+149
View File
@@ -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
}