From e62391af66c3511e2c9cce2f2a732206e6ce8e11 Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Sun, 24 May 2026 13:44:46 +0200 Subject: [PATCH] feat: Zod AI validation, AIProvenance governance, fix tests (154 green) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add AIProvenance + AIResponse to IAIService — all 11 methods now return structured provenance (provider, model, source, fallbackUsed, validationPassed) instead of bare ItemResponse - 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 --- .../matching/__tests__/mustHaveScorer.test.ts | 158 +++++++ .../__tests__/softFactorEnrichment.test.ts | 165 +++++++ src/services/ai/IAIService.ts | 70 ++- src/services/ai/__tests__/aiSchemas.test.ts | 345 +++++++++++++++ src/services/ai/mock/MockAIService.ts | 116 ++++- .../ai/openrouter/OpenRouterAIService.ts | 416 ++++++++++-------- .../ai/prompts/decisionBriefPrompt.ts | 44 +- .../ai/prompts/matchExplanationPrompt.ts | 31 +- src/services/ai/schemas.ts | 149 +++++++ 9 files changed, 1259 insertions(+), 235 deletions(-) create mode 100644 src/features/matching/__tests__/mustHaveScorer.test.ts create mode 100644 src/features/matching/__tests__/softFactorEnrichment.test.ts create mode 100644 src/services/ai/__tests__/aiSchemas.test.ts create mode 100644 src/services/ai/schemas.ts diff --git a/src/features/matching/__tests__/mustHaveScorer.test.ts b/src/features/matching/__tests__/mustHaveScorer.test.ts new file mode 100644 index 0000000..f597166 --- /dev/null +++ b/src/features/matching/__tests__/mustHaveScorer.test.ts @@ -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) + }) +}) diff --git a/src/features/matching/__tests__/softFactorEnrichment.test.ts b/src/features/matching/__tests__/softFactorEnrichment.test.ts new file mode 100644 index 0000000..346a587 --- /dev/null +++ b/src/features/matching/__tests__/softFactorEnrichment.test.ts @@ -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, 0–1)', () => { + 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 0–1 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) + }) +}) diff --git a/src/services/ai/IAIService.ts b/src/services/ai/IAIService.ts index 460b1f2..a82cc7d 100644 --- a/src/services/ai/IAIService.ts +++ b/src/services/ai/IAIService.ts @@ -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 instead of ItemResponse. + * `data` is backward-compatible — existing call sites using `result.data.xxx` + * continue to work unchanged. + */ +export type AIResponse = { + data: T + provenance: AIProvenance +} + +// ── Helper ──────────────────────────────────────────────────────────────────── + +export function mockProvenance(overrides?: Partial): 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> - generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise> + parseNeed(input: string): Promise> + generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise> // Match explainability (F004) - generateMatchExplanation(input: MatchExplanationInput): Promise> - summarizeTradeOffs(tradeoffs: TradeOffInput[]): Promise> + generateMatchExplanation(input: MatchExplanationInput): Promise> + summarizeTradeOffs(tradeoffs: TradeOffInput[]): Promise> // Compare (F014) - summarizeComparison(items: UnifiedMatchResult[]): Promise> + summarizeComparison(items: UnifiedMatchResult[]): Promise> // Shortlist decision brief - generateDecisionBrief(shortlistId: string): Promise> + generateDecisionBrief(shortlistId: string): Promise> // Data quality - generateDataQualitySummary(propertyId: string, quality: DataQualityInput): Promise> + generateDataQualitySummary(propertyId: string, quality: DataQualityInput): Promise> // Market signal classification (OPERATIONS) - classifyMarketSignal(signalText: string): Promise> + classifyMarketSignal(signalText: string): Promise> // Offer email (supply side) - generateOfferEmail(payload: OfferEmailPayload): Promise> + generateOfferEmail(payload: OfferEmailPayload): Promise> // Legacy methods - extractCriteria(input: string): Promise> - generateFollowUp(partialNeed: Partial): Promise> + extractCriteria(input: string): Promise> + generateFollowUp(partialNeed: Partial): Promise> } diff --git a/src/services/ai/__tests__/aiSchemas.test.ts b/src/services/ai/__tests__/aiSchemas.test.ts new file mode 100644 index 0000000..00b6b01 --- /dev/null +++ b/src/services/ai/__tests__/aiSchemas.test.ts @@ -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() + }) +}) diff --git a/src/services/ai/mock/MockAIService.ts b/src/services/ai/mock/MockAIService.ts index 377b567..d03247e 100644 --- a/src/services/ai/mock/MockAIService.ts +++ b/src/services/ai/mock/MockAIService.ts @@ -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> = { + 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 (min–max 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 = [] + + 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>> { + async parseNeed(input: string): Promise>> { await delay(SIMULATED_DELAY.fast) - return { data: mockParseNeed(input) } + return { data: mockParseNeed(input), provenance: mockProvenance() } }, - async generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise> { + async generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise> { 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> { + async generateMatchExplanation(input: MatchExplanationInput): Promise> { 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> { + async summarizeTradeOffs(tradeoffs: TradeOffInput[]): Promise> { 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> { + async summarizeComparison(items: UnifiedMatchResult[]): Promise> { await delay(SIMULATED_DELAY.medium) - return { data: buildComparisonSummary(items) } + return { data: buildComparisonSummary(items), provenance: mockProvenance() } }, - async generateDecisionBrief(shortlistId: string): Promise> { + async generateDecisionBrief(shortlistId: string): Promise> { await delay(SIMULATED_DELAY.slow) - return { data: buildMockDecisionBrief(shortlistId) } + return { data: buildMockDecisionBrief(shortlistId), provenance: mockProvenance() } }, - async generateDataQualitySummary(_propertyId: string, quality: DataQualityInput): Promise> { + async generateDataQualitySummary(_propertyId: string, quality: DataQualityInput): Promise> { 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> { + async classifyMarketSignal(signalText: string): Promise> { 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> { + async generateOfferEmail(payload: OfferEmailPayload): Promise> { 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> { + async extractCriteria(_input: string): Promise> { return { data: { extractedCriteria: { @@ -170,15 +249,16 @@ export const MockAIService: IAIService = { 'Wann möchten Sie spätestens einziehen?', ], }, + provenance: mockProvenance(), } }, - async generateFollowUp(partialNeed: Partial): Promise> { + async generateFollowUp(partialNeed: Partial): Promise> { 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() } }, } diff --git a/src/services/ai/openrouter/OpenRouterAIService.ts b/src/services/ai/openrouter/OpenRouterAIService.ts index 138f9c4..dcfda9e 100644 --- a/src/services/ai/openrouter/OpenRouterAIService.ts +++ b/src/services/ai/openrouter/OpenRouterAIService.ts @@ -6,22 +6,24 @@ * VITE_OPENROUTER_API_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' @@ -48,9 +61,9 @@ import { MockAIService } from '../mock/MockAIService' // ── Config ──────────────────────────────────────────────────────────────────── -const API_BASE = 'https://openrouter.ai/api/v1' -const DEFAULT_MODEL = 'anthropic/claude-3-5-haiku' -const PROMPT_VERSION = 'v1.0' +const API_BASE = 'https://openrouter.ai/api/v1' +const DEFAULT_MODEL = 'anthropic/claude-3-5-haiku' +const PROMPT_VERSION = 'v1.1' const SCHEMA_VERSION = 'v1.0' interface OpenRouterConfig { @@ -67,28 +80,45 @@ 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 { const res = await fetch(`${API_BASE}/chat/completions`, { method: 'POST', headers: { - 'Authorization': `Bearer ${config.apiKey}`, - 'Content-Type': 'application/json', - 'HTTP-Referer': window.location.origin, + 'Authorization': `Bearer ${config.apiKey}`, + 'Content-Type': 'application/json', + 'HTTP-Referer': window.location.origin, }, body: JSON.stringify({ model: config.model, messages: [ { role: 'system', content: system }, - { role: 'user', content: user }, + { role: 'user', content: user }, ], }), }) if (!res.ok) { const body = await res.text() throw new AppError({ - code: ServiceErrorCode.AI_GENERATION_FAILED, + code: ServiceErrorCode.AI_GENERATION_FAILED, message: `OpenRouter error ${res.status}: ${body}`, }) } @@ -99,8 +129,7 @@ async function chat(config: OpenRouterConfig, system: string, user: string): Pro // ── JSON extraction ─────────────────────────────────────────────────────────── function extractJSON(raw: string): T | null { - // Try fenced code block first, then bare object/array - const fenced = raw.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/) + const fenced = raw.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/) const candidate = fenced ? fenced[1] : raw.match(/([\[{][\s\S]*[\]}])/)?.[1] ?? raw try { return JSON.parse(candidate) as T @@ -109,7 +138,7 @@ function extractJSON(raw: string): T | null { } } -// ── Helpers for ParseNeedResult mapping ─────────────────────────────────────── +// ── ParseNeed helpers ───────────────────────────────────────────────────────── type RawNeedParseAI = { assetType?: string | null @@ -124,12 +153,12 @@ type RawNeedParseAI = { function followUpForField(field: string): string { const MAP: Record = { - assetType: 'Welchen Nutzungstyp suchen Sie (Büro, Retail, Logistik, Produktion)?', - areaRange: 'Welche Fläche benötigen Sie (min–max in m²)?', + assetType: 'Welchen Nutzungstyp suchen Sie (Büro, Retail, Logistik, Produktion)?', + areaRange: 'Welche Fläche benötigen Sie (min–max in m²)?', preferredLocations: 'In welchen Städten oder Regionen suchen Sie?', - budgetRange: 'Was ist Ihr maximales Budget pro m² und Jahr?', - timing: 'Wann möchten Sie spätestens einziehen?', - mustHaveCriteria: 'Haben Sie zwingende Anforderungen (ÖV-Anbindung, Parkplätze, Laderampe)?', + budgetRange: 'Was ist Ihr maximales Budget pro m² und Jahr?', + timing: 'Wann möchten Sie spätestens einziehen?', + mustHaveCriteria: 'Haben Sie zwingende Anforderungen (ÖV-Anbindung, Parkplätze, Laderampe)?', } return MAP[field] ?? `Können Sie "${field}" präzisieren?` } @@ -145,23 +174,25 @@ function defaultSuggestedWeights(): Record { // ── Fallback wrapper ────────────────────────────────────────────────────────── -type FallbackFn = () => Promise> +type FallbackFn = () => Promise> async function withFallback( label: string, - fn: (config: OpenRouterConfig) => Promise>, + fn: (config: OpenRouterConfig) => Promise>, fallback: FallbackFn, -): Promise> { +): Promise> { 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,20 +201,24 @@ async function withFallback( export const OpenRouterAIService: IAIService = { // ── parseNeed ─────────────────────────────────────────────────────────────── - parseNeed(input: string): Promise> { + parseNeed(input: string): Promise> { return withFallback('parseNeed', async (config) => { const { system, user } = buildNeedParsingPrompt({ userInput: input }) - const raw = await chat(config, system, user) - const ai = extractJSON(raw) + const raw = await chat(config, system, user) + const json = extractJSON(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, + assetType: (ai.assetType ?? undefined) as AssetType | undefined, + areaRange: ai.areaRange ?? undefined, preferredLocations: ai.preferredLocations, - budgetRange: ai.budgetRange ?? undefined, + budgetRange: ai.budgetRange ?? undefined, timing: ai.timing ? { ...ai.timing, latestMoveIn: ai.timing.latestMoveIn ?? undefined } : undefined, @@ -196,67 +231,71 @@ export const OpenRouterAIService: IAIService = { }) missingFields.forEach(f => { confidenceByField[f] = 0 }) const followUpQuestionCandidates: FollowUpQuestion[] = missingFields.map((field, i) => ({ - id: `fq-or-${i}`, + id: `fq-or-${i}`, questionText: followUpForField(field), - targetField: field, - reason: `Feld "${field}" nicht im Text erkannt`, - importance: 'recommended' as const, + targetField: field, + reason: `Feld "${field}" nicht im Text erkannt`, + importance: 'recommended' as const, })) return { data: { extractedCriteria, confidenceByField, missingFields, - assumptions: ai.assumptions ?? [], - suggestedWeights: defaultSuggestedWeights(), + assumptions: ai.assumptions ?? [], + suggestedWeights: defaultSuggestedWeights(), followUpQuestionCandidates, - rawSummary: raw.substring(0, 500), - promptVersion: PROMPT_VERSION, - schemaVersion: SCHEMA_VERSION, + rawSummary: raw.substring(0, 500), + promptVersion: PROMPT_VERSION, + schemaVersion: SCHEMA_VERSION, }, + provenance: makeProvenance(config, 'ai', false, true), } }, () => MockAIService.parseNeed(input)) }, // ── generateFollowUpQuestions ─────────────────────────────────────────────── - generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise> { + generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise> { 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(raw) + const raw = await chat(config, system, user) + const json = extractJSON(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', - reason: q.reason ?? 'AI-generiert', + id: `fq-or-${i}`, + 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> { + // Plain-text response — no JSON schema to validate, but non-empty check enforced. + generateMatchExplanation(input: MatchExplanationInput): Promise> { 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 raw = await chat(config, system, user) 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> { + summarizeTradeOffs(tradeoffs: TradeOffInput[]): Promise> { 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 raw = await chat(config, system, user) + const json = extractJSON(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(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), + headline: ai.headline, + 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> { + // Hybrid: AI provides narrative text; mock provides structural per-property data. + // source: 'hybrid' — both are labeled in provenance. + summarizeComparison(items: UnifiedMatchResult[]): Promise> { return withFallback('summarizeComparison', async (config) => { type ItemWithProp = UnifiedMatchResult & { property?: { title?: string; location?: { city?: string }; rentPricePerSqm?: number } @@ -312,200 +351,199 @@ export const OpenRouterAIService: IAIService = { const properties = (items as ItemWithProp[]) .filter(i => i.resultType !== 'FUTURE_AVAILABILITY') .map(i => ({ - title: i.property?.title ?? `Match ${i.matchScore}`, - matchScore: i.matchScore, - city: i.property?.location?.city ?? '–', - rentPerSqm: i.property?.rentPricePerSqm ?? 0, + title: i.property?.title ?? `Match ${i.matchScore}`, + matchScore: i.matchScore, + city: i.property?.location?.city ?? '–', + rentPerSqm: i.property?.rentPricePerSqm ?? 0, positiveFactors: i.match.positiveFactors.slice(0, 2).map(f => f.explanation ?? f.criterion), negativeFactors: i.match.negativeFactors.slice(0, 2).map(f => f.explanation ?? f.criterion), })) 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 raw = await chat(config, system, user) + const json = extractJSON(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(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: { ...mock.data, overallAssessment: ai.overallAssessment, - recommendation: ai.recommendation ?? mock.data.recommendation, + recommendation: ai.recommendation ?? mock.data.recommendation, }, + provenance: makeProvenance(config, 'hybrid', false, true), } }, () => MockAIService.summarizeComparison(items)) }, // ── generateDecisionBrief ─────────────────────────────────────────────────── - generateDecisionBrief(shortlistId: string): Promise> { + // Hybrid: AI generates narrative summary + sections; mock fills structural metadata. + generateDecisionBrief(shortlistId: string): Promise> { 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(raw) - if (!ai?.summary) { - console.warn('[OpenRouterAIService] generateDecisionBrief: incomplete response — using mock fallback') - return MockAIService.generateDecisionBrief(shortlistId) + const raw = await chat(config, system, user) + const json = extractJSON(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, + summary: ai.summary, + 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> { + generateDataQualitySummary(propertyId: string, quality: DataQualityInput): Promise> { 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(raw) - if (!ai?.overallAssessment) { - console.warn('[OpenRouterAIService] generateDataQualitySummary: incomplete response — using mock fallback') - return MockAIService.generateDataQualitySummary(propertyId, quality) + const raw = await chat(config, system, user) + const json = extractJSON(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, + 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> { + classifyMarketSignal(signalText: string): Promise> { 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 raw = await chat(config, system, user) + const json = extractJSON(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(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> { + generateOfferEmail(payload: OfferEmailPayload): Promise> { return withFallback('generateOfferEmail', async (config) => { const propertyList = payload.properties .map((p, i) => `• ${p} (Match-Score: ${payload.matchScores[i]}%)`) .join('\n') 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(raw) - if (!ai?.subject || !ai?.body) { - console.warn('[OpenRouterAIService] generateOfferEmail: incomplete response — using mock fallback') - return MockAIService.generateOfferEmail(payload) + const user = `Suchanfrage: "${payload.needTitle}"\n\nObjekte:\n${propertyList}\n\nErstelle eine professionelle Angebotsmail.` + const raw = await chat(config, system, user) + const json = extractJSON(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> { + extractCriteria(input: string): Promise> { return withFallback('extractCriteria', async (config) => { const { system, user } = buildNeedParsingPrompt({ userInput: input }) - const raw = await chat(config, system, user) - const ai = extractJSON(raw) + const raw = await chat(config, system, user) + const json = extractJSON(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: { extractedCriteria: { - assetType: ai.assetType as AssetType | undefined ?? undefined, - requiredArea: ai.areaRange ?? undefined, + assetType: ai.assetType as AssetType | undefined ?? undefined, + requiredArea: ai.areaRange ?? undefined, preferredLocations: ai.preferredLocations ?? [], - budgetRange: ai.budgetRange ?? undefined, + budgetRange: ai.budgetRange ?? undefined, }, - confidence: 0.80, - missingFields: ai.missingFields ?? [], - assumptions: ai.assumptions ?? [], - followUpQuestions: (ai.missingFields ?? []).map(followUpForField), + confidence: 0.80, + missingFields: ai.missingFields ?? [], + assumptions: ai.assumptions ?? [], + followUpQuestions: (ai.missingFields ?? []).map(followUpForField), }, + provenance: makeProvenance(config, 'ai', false, true), } }, () => MockAIService.extractCriteria(input)) }, // ── Legacy: generateFollowUp ──────────────────────────────────────────────── - generateFollowUp(partialNeed: Partial): Promise> { + generateFollowUp(partialNeed: Partial): Promise> { return withFallback('generateFollowUp', async (config) => { const missingFields = [ - ...(!partialNeed.assetType ? ['assetType'] : []), + ...(!partialNeed.assetType ? ['assetType'] : []), ...(!partialNeed.preferredLocations?.length ? ['preferredLocations'] : []), - ...(!partialNeed.timing ? ['timing'] : []), - ...(!partialNeed.budgetRange ? ['budgetRange'] : []), + ...(!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(raw) + const raw = await chat(config, system, user) + const json = extractJSON(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)) }, } diff --git a/src/services/ai/prompts/decisionBriefPrompt.ts b/src/services/ai/prompts/decisionBriefPrompt.ts index 6fe7c72..1d6aefa 100644 --- a/src/services/ai/prompts/decisionBriefPrompt.ts +++ b/src/services/ai/prompts/decisionBriefPrompt.ts @@ -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": "1–2 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: 2–4 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 } } diff --git a/src/services/ai/prompts/matchExplanationPrompt.ts b/src/services/ai/prompts/matchExplanationPrompt.ts index 81ffacc..13e300d 100644 --- a/src/services/ai/prompts/matchExplanationPrompt.ts +++ b/src/services/ai/prompts/matchExplanationPrompt.ts @@ -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 52–77: "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 1–2 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 } } diff --git a/src/services/ai/schemas.ts b/src/services/ai/schemas.ts new file mode 100644 index 0000000..4f281d9 --- /dev/null +++ b/src/services/ai/schemas.ts @@ -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 + +// ── 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( + schema: z.ZodType, + 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 +}