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
+37 -7
View File
@@ -11,12 +11,42 @@ export interface DecisionBriefPromptInput {
}
export function buildDecisionBriefPrompt(input: DecisionBriefPromptInput): { system: string; user: string } {
const itemList = input.shortlistItems
.map(i => `- ${i.title} (${i.city}): Score ${i.matchScore}%, ${i.areaSqm}m², CHF ${i.rentPerSqm}/m² — ${i.topReasons.join(', ')}`)
.join('\n')
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 }
}