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 { 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[]>>
}
+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 { 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 (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 = {
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() }
},
}
+170 -132
View File
@@ -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))
},
}
+35 -5
View File
@@ -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": "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 }>
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 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
}