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)
})
})