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
+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[]>>
}