refactor: architecture compliance pass — DS tokens, hook boundary, god component split, AI hardening

- DS token migration: Anfragen.tsx + child components (AnfragenInquiryItem, AnfragenMessageBubble)
  fully migrated; DS_TEXT.brandDark added; scoreTheme.ts moved to src/lib/ with re-export proxy
- Hook boundary: Results.tsx no longer calls needService directly — routes through useNeeds()
  with optional refetchOnMount/gcTime overrides
- NewListing.tsx (440L) split into useNewListingForm hook + 8 section components under
  src/components/new-listing/; page shell reduced to 121 lines
- AI hardening: Zod .strict() on all schemas, AIProvenance extended with schemaVersion/
  fallbackReason/traceId/latencyMs, AITraceStore stats with p50/p90/p99 + failure breakdowns,
  MockAIService buildFollowUpQuestions with priority ordering + area-ambiguity detection,
  prompt templates updated (LIGHT_INDUSTRIAL, budget unit, ambiguity detection, decimal precision)
- Tests: all 154 passing; fixed test regression caused by OfferEmailResponseSchema body min(50)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-24 16:10:39 +02:00
parent e36c5bc979
commit e1f4beb898
44 changed files with 1610 additions and 1058 deletions
+10
View File
@@ -14,12 +14,20 @@ export interface AIProvenance {
generatedAt: string
/** Prompt version string used to generate this response */
promptVersion: string
/** Zod schema version used for validation */
schemaVersion: 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
/** Human-readable reason why a fallback occurred — undefined when no fallback */
fallbackReason?: string
/** True when the AI response passed Zod schema validation */
validationPassed: boolean
/** Unique request ID — correlates AIResponse with AITrace.id */
traceId: string
/** Wall-clock latency for this call in milliseconds */
latencyMs?: number
}
/**
@@ -40,9 +48,11 @@ export function mockProvenance(overrides?: Partial<AIProvenance>): AIProvenance
model: 'mock',
generatedAt: new Date().toISOString(),
promptVersion: 'mock',
schemaVersion: 'mock',
source: 'mock',
fallbackUsed: false,
validationPassed: true,
traceId: crypto.randomUUID(),
...overrides,
}
}
+3 -3
View File
@@ -301,7 +301,7 @@ describe('OfferEmailResponseSchema', () => {
expect(result.success).toBe(false)
})
it('rejects body shorter than 10 characters', () => {
it('rejects body shorter than 50 characters', () => {
const result = OfferEmailResponseSchema.safeParse({ subject: 'Angebot', body: 'Kurz.' })
expect(result.success).toBe(false)
})
@@ -313,11 +313,11 @@ describe('validateAIResponse helper', () => {
it('returns parsed data when schema passes', () => {
const result = validateAIResponse(
OfferEmailResponseSchema,
{ subject: 'Test', body: 'Long enough body text here.' },
{ subject: 'Angebot Büroflächen', body: 'This body is definitely long enough to pass the fifty character minimum threshold.' },
'test',
)
expect(result).not.toBeNull()
expect(result?.subject).toBe('Test')
expect(result?.subject).toBe('Angebot Büroflächen')
})
it('returns null when schema fails (does not throw)', () => {
+56 -19
View File
@@ -59,7 +59,7 @@ const FOLLOW_UP_TEMPLATES: Partial<Record<keyof ParsedNeedCriteria, QuestionTemp
assetType: {
questionText: 'Welchen Nutzungstyp suchen Sie?',
reason: 'Nutzungstyp ist zwingend für die Matchsuche',
suggestedAnswerOptions: ['Büro', 'Retail', 'Logistik', 'Produktion', 'Gastro'],
suggestedAnswerOptions: ['Büro', 'Retail', 'Logistik', 'Produktion', 'Leichtindustrie', 'Gastro'],
importance: 'required',
},
areaRange: {
@@ -74,7 +74,7 @@ const FOLLOW_UP_TEMPLATES: Partial<Record<keyof ParsedNeedCriteria, QuestionTemp
importance: 'required',
},
budgetRange: {
questionText: 'Was ist Ihr maximales Budget pro m² und Monat (CHF)?',
questionText: 'Was ist Ihr maximales Budget pro m² und Jahr (CHF)?',
reason: 'Budget ist wichtig für die Filterung unpassender Objekte',
importance: 'recommended',
},
@@ -90,32 +90,69 @@ const FOLLOW_UP_TEMPLATES: Partial<Record<keyof ParsedNeedCriteria, QuestionTemp
},
}
const AREA_AMBIGUITY_RATIO_THRESHOLD = 8
const AREA_AMBIGUITY_QUESTION: QuestionTemplate = {
questionText: 'Ihre Flächenangabe ist sehr weit gefasst — können Sie den Bereich präzisieren (z.B. min 300 m², max 600 m²)?',
reason: 'Zu grosse Spanne reduziert die Matchgenauigkeit erheblich',
importance: 'required',
}
function isAreaAmbiguous(areaRange: NonNullable<ParsedNeedCriteria['areaRange']>): boolean {
const { min, max } = areaRange
if (min <= 0 || max <= 0) return true
return max / min > AREA_AMBIGUITY_RATIO_THRESHOLD
}
// Priority order: required fields first, recommended next, optional last.
// Max 3 questions returned. Area range ambiguity is detected and raised as a
// required clarification even when areaRange is nominally present.
const FIELD_PRIORITY: Array<keyof ParsedNeedCriteria> = [
'assetType',
'areaRange',
'preferredLocations',
'budgetRange',
'timing',
'mustHaveCriteria',
]
function buildFollowUpQuestions(criteria: ParsedNeedCriteria): FollowUpQuestion[] {
const missing: Array<keyof ParsedNeedCriteria> = []
const questions: FollowUpQuestion[] = []
let idx = 0
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')
for (const field of FIELD_PRIORITY) {
if (questions.length >= 3) break
return missing
.slice(0, 3)
.map((field, i) => {
if (field === 'areaRange') {
if (!criteria.areaRange) {
const tpl = FOLLOW_UP_TEMPLATES['areaRange']!
questions.push({ id: `fq-mock-${idx++}`, questionText: tpl.questionText, targetField: 'areaRange', reason: tpl.reason, importance: tpl.importance })
} else if (isAreaAmbiguous(criteria.areaRange)) {
questions.push({ id: `fq-mock-${idx++}`, questionText: AREA_AMBIGUITY_QUESTION.questionText, targetField: 'areaRange', reason: AREA_AMBIGUITY_QUESTION.reason, importance: AREA_AMBIGUITY_QUESTION.importance })
}
continue
}
const isMissing =
field === 'preferredLocations' ? !criteria.preferredLocations?.length
: field === 'mustHaveCriteria' ? !criteria.mustHaveCriteria?.length
: !criteria[field]
if (isMissing) {
const tpl = FOLLOW_UP_TEMPLATES[field]
if (!tpl) return null
const q: FollowUpQuestion = {
id: `fq-mock-${i}`,
if (!tpl) continue
questions.push({
id: `fq-mock-${idx++}`,
questionText: tpl.questionText,
targetField: field,
reason: tpl.reason,
importance: tpl.importance,
suggestedAnswerOptions: tpl.suggestedAnswerOptions,
}
return q
})
.filter((q): q is FollowUpQuestion => q !== null)
})
}
}
return questions
}
// ── Service ───────────────────────────────────────────────────────────────────
@@ -87,15 +87,19 @@ function makeProvenance(
source: AIProvenance['source'],
fallbackUsed: boolean,
validationPassed: boolean,
extras: { fallbackReason?: string } = {},
): AIProvenance {
return {
provider: 'openrouter',
model: config.model,
generatedAt: new Date().toISOString(),
promptVersion: PROMPT_VERSION,
schemaVersion: SCHEMA_VERSION,
source,
fallbackUsed,
validationPassed,
traceId: crypto.randomUUID(),
fallbackReason: extras.fallbackReason,
}
}
@@ -186,34 +190,51 @@ async function withFallback<T>(
): Promise<AIResponse<T>> {
const config = getConfig()
const startMs = Date.now()
const callId = crypto.randomUUID()
if (!config) {
console.warn(`[OpenRouterAIService] ${label}: no API key — using MockAIService`)
const result = await fallback()
const latencyMs = Date.now() - startMs
const provenance: AIProvenance = {
...result.provenance,
fallbackUsed: true,
traceId: callId,
fallbackReason: 'no_api_key',
schemaVersion: SCHEMA_VERSION,
latencyMs,
}
aiTraceStore.add({
id: crypto.randomUUID(),
id: callId,
method: label,
provider: 'openrouter',
model: DEFAULT_MODEL,
promptVersion: PROMPT_VERSION,
latencyMs: Date.now() - startMs,
latencyMs,
fallbackUsed: true,
validationPassed: false,
responseValidationStatus: 'fallback',
errorType: 'no_api_key',
fallbackReason: 'no_api_key',
source: 'mock',
createdAt: new Date().toISOString(),
inputSizeChars,
})
return { ...result, provenance: { ...result.provenance, fallbackUsed: true } }
return { ...result, provenance }
}
try {
const result = await fn(config)
const latencyMs = Date.now() - startMs
const prov = result.provenance
const provenance: AIProvenance = {
...prov,
traceId: callId,
latencyMs,
schemaVersion: SCHEMA_VERSION,
}
aiTraceStore.add({
id: crypto.randomUUID(),
id: callId,
method: label,
provider: prov.provider,
model: prov.model,
@@ -221,12 +242,13 @@ async function withFallback<T>(
latencyMs,
fallbackUsed: prov.fallbackUsed,
validationPassed: prov.validationPassed,
responseValidationStatus: provenanceToStatus(prov.fallbackUsed, prov.source),
responseValidationStatus: provenanceToStatus(prov.fallbackUsed, prov.source, prov.fallbackReason),
fallbackReason: prov.fallbackReason,
source: prov.source,
createdAt: prov.generatedAt,
inputSizeChars,
})
return result
return { ...result, provenance }
} catch (err) {
console.error(`[OpenRouterAIService] ${label} failed:`, err)
const result = await fallback()
@@ -241,8 +263,17 @@ async function withFallback<T>(
err instanceof AppError && err.code === ServiceErrorCode.AI_GENERATION_FAILED
? 'api_error'
: 'network_error'
const fallbackReason = `${errorType}: ${err instanceof Error ? err.message.slice(0, 100) : 'unknown error'}`
const provenance: AIProvenance = {
...result.provenance,
fallbackUsed: true,
traceId: callId,
fallbackReason,
schemaVersion: SCHEMA_VERSION,
latencyMs,
}
aiTraceStore.add({
id: crypto.randomUUID(),
id: callId,
method: label,
provider: 'openrouter',
model: config.model,
@@ -252,11 +283,12 @@ async function withFallback<T>(
validationPassed: false,
responseValidationStatus,
errorType,
fallbackReason,
source: 'mock',
createdAt: new Date().toISOString(),
inputSizeChars,
})
return { ...result, provenance: { ...result.provenance, fallbackUsed: true } }
return { ...result, provenance }
}
}
@@ -275,7 +307,7 @@ export const OpenRouterAIService: IAIService = {
if (!ai) {
console.warn('[OpenRouterAIService] parseNeed: invalid response — using mock fallback')
const fb = await MockAIService.parseNeed(input)
return { ...fb, provenance: makeProvenance(config, 'mock', true, false) }
return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
const extractedCriteria: ParsedNeedCriteria = {
@@ -321,9 +353,14 @@ export const OpenRouterAIService: IAIService = {
// ── generateFollowUpQuestions ───────────────────────────────────────────────
generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise<AIResponse<FollowUpQuestion[]>> {
return withFallback('generateFollowUpQuestions', async (config) => {
const missingFields = Object.entries(criteria)
.filter(([, v]) => v == null)
.map(([k]) => k)
const missingFields = [
...(!criteria.assetType ? ['assetType'] : []),
...(!criteria.areaRange || (criteria.areaRange.min <= 0 && criteria.areaRange.max <= 0) ? ['areaRange'] : []),
...(!criteria.preferredLocations?.length ? ['preferredLocations'] : []),
...(!criteria.budgetRange ? ['budgetRange'] : []),
...(!criteria.timing ? ['timing'] : []),
...(!criteria.mustHaveCriteria?.length ? ['mustHaveCriteria'] : []),
]
const { system, user } = buildFollowUpQuestionsPrompt({ criteria, missingFields })
const raw = await chat(config, system, user)
const json = extractJSON<unknown[]>(raw)
@@ -332,7 +369,7 @@ export const OpenRouterAIService: IAIService = {
if (!ai?.length) {
console.warn('[OpenRouterAIService] generateFollowUpQuestions: invalid response — using mock fallback')
const fb = await MockAIService.generateFollowUpQuestions(criteria)
return { ...fb, provenance: makeProvenance(config, 'mock', true, false) }
return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
return {
data: ai.map((q, i) => ({
@@ -359,7 +396,7 @@ export const OpenRouterAIService: IAIService = {
if (!summary) {
console.warn('[OpenRouterAIService] generateMatchExplanation: empty response — using mock fallback')
const fb = await MockAIService.generateMatchExplanation(input)
return { ...fb, provenance: makeProvenance(config, 'mock', true, false) }
return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: 'empty_response' }) }
}
const scoreLabel = input.matchScore >= 78 ? 'Starkes' : input.matchScore >= 52 ? 'Gutes' : 'Schwaches'
return {
@@ -387,7 +424,7 @@ export const OpenRouterAIService: IAIService = {
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) }
return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
return {
data: {
@@ -430,7 +467,7 @@ export const OpenRouterAIService: IAIService = {
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) }
return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
const mock = await MockAIService.summarizeComparison(items)
return {
@@ -456,7 +493,7 @@ export const OpenRouterAIService: IAIService = {
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) }
return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
const mock = await MockAIService.generateDecisionBrief(shortlistId)
return {
@@ -481,7 +518,7 @@ export const OpenRouterAIService: IAIService = {
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 { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
return {
data: {
@@ -508,7 +545,7 @@ export const OpenRouterAIService: IAIService = {
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) }
return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
return {
data: {
@@ -539,7 +576,7 @@ export const OpenRouterAIService: IAIService = {
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 { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
return {
data: { subject: ai.subject, body: ai.body },
@@ -559,7 +596,7 @@ export const OpenRouterAIService: IAIService = {
if (!ai) {
console.warn('[OpenRouterAIService] extractCriteria: invalid response — using mock fallback')
const fb = await MockAIService.extractCriteria(input)
return { ...fb, provenance: makeProvenance(config, 'mock', true, false) }
return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
return {
data: {
@@ -602,7 +639,7 @@ export const OpenRouterAIService: IAIService = {
if (!ai?.length) {
console.warn('[OpenRouterAIService] generateFollowUp: invalid response — using mock fallback')
const fb = await MockAIService.generateFollowUp(partialNeed)
return { ...fb, provenance: makeProvenance(config, 'mock', true, false) }
return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
return {
data: ai.map(q => q.questionText).filter(Boolean),
@@ -24,8 +24,12 @@ PRIORITÄTSREIHENFOLGE:
5. timing (recommended) — wichtig für Verfügbarkeitsabgleich
6. mustHaveCriteria (optional) — Pflichtmerkmale (Parkplätze, Laderampe etc.)
MEHRDEUTIGKEITSERKENNUNG — prüfe auch bekannte Felder auf Ambiguität:
- areaRange vorhanden, aber max/min-Verhältnis > 5: generiere eine Präzisierungsfrage (targetField: "areaRange", importance: "required") statt sie als vollständig zu behandeln
- areaRange vorhanden, aber min = 0 oder max = 0: generiere dieselbe Präzisierungsfrage
VERBOTE — NIEMALS:
- Fragen zu bereits bekannten Kriterien stellen
- Fragen zu bereits bekannten, eindeutigen Kriterien stellen
- Mehr als 3 Fragen ausgeben
- Fragen erfinden, die nicht einem der 6 definierten Zielfelder entsprechen
- Doppelfragen stellen
@@ -54,7 +58,7 @@ Ausgabe:
"importance": "required"
},
{
"questionText": "Was ist Ihr maximales Budget pro m² und Monat (CHF)?",
"questionText": "Was ist Ihr maximales Budget pro m² und Jahr (CHF)?",
"targetField": "budgetRange",
"reason": "Budget ist wichtig für die Filterung unpassender Objekte",
"suggestedAnswerOptions": [],
@@ -34,6 +34,7 @@ WEITERE VERBOTE:
- Fläche in m² schätzen, wenn kein konkreter Hinweis im Text steht (→ null setzen)
- Zeithorizont nennen, wenn er nicht aus dem Text ableitbar ist (→ null setzen)
- probability > 0.85 setzen ohne mehrere unabhängige, verlässliche Bestätigungen
- probability mit mehr als 2 Dezimalstellen angeben (z.B. 0.724 → 0.72, 0.6666 → 0.67)
AUSGABEFORMAT — antworte ausschliesslich als valides JSON (kein Markdown-Block, keine Erklärungen):
{
+11 -5
View File
@@ -20,10 +20,10 @@ VERBOTE — NIEMALS:
AUSGABEFORMAT — antworte ausschliesslich als valides JSON (kein Markdown-Block, keine Erklärungen):
{
"assetType": "OFFICE" | "RETAIL" | "LOGISTICS" | "PRODUCTION" | "GASTRO" | "MIXED" | "UNKNOWN" | null,
"areaRange": { "min": number, "max": number } | null,
"assetType": "OFFICE" | "RETAIL" | "LOGISTICS" | "PRODUCTION" | "LIGHT_INDUSTRIAL" | "GASTRO" | "MIXED" | "UNKNOWN" | null,
"areaRange": { "min": number (≥1), "max": number (≥1, ≥ min) } | null,
"preferredLocations": string[],
"budgetRange": { "maxPerSqm": number, "currency": "CHF" } | null,
"budgetRange": { "maxPerSqm": number (CHF/m²/Jahr), "currency": "CHF" } | null,
"timing": {
"earliestMoveIn": "YYYY-MM-DD" | null,
"latestMoveIn": "YYYY-MM-DD" | null,
@@ -34,14 +34,20 @@ AUSGABEFORMAT — antworte ausschliesslich als valides JSON (kein Markdown-Block
"assumptions": string[]
}
FELDREGELN:
- areaRange.min und areaRange.max müssen beide ≥ 1 sein — nie 0 setzen
- budgetRange.maxPerSqm ist CHF pro m² pro Jahr (Jahresmiete) — nicht Monatsmiete
- LIGHT_INDUSTRIAL: Leichtindustrielle Nutzung (Werkstatt, Atelier, kleine Produktion), klar abgegrenzt von LOGISTICS
- Maximale Einträge: preferredLocations max. 20, mustHaveCriteria max. 10
BEISPIEL:
Eingabe: "Wir suchen ein Büro für ca. 20 Personen in Zürich, Budget rund 50 CHF/m², Einzug ab März 2026"
Eingabe: "Wir suchen ein Büro für ca. 20 Personen in Zürich, Budget rund 600 CHF/m²/Jahr, Einzug ab März 2026"
Ausgabe:
{
"assetType": "OFFICE",
"areaRange": { "min": 200, "max": 400 },
"preferredLocations": ["Zürich"],
"budgetRange": { "maxPerSqm": 50, "currency": "CHF" },
"budgetRange": { "maxPerSqm": 600, "currency": "CHF" },
"timing": { "earliestMoveIn": "2026-03-01", "latestMoveIn": null, "flexibleTiming": false },
"mustHaveCriteria": [],
"missingFields": ["timing.latestMoveIn", "mustHaveCriteria"],
+126 -84
View File
@@ -4,6 +4,10 @@
* 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.
*
* All schemas use .strict() — any unknown key from the AI response triggers
* immediate validation failure and fallback, preventing hallucinated fields
* from reaching the UI.
*/
import { z } from 'zod'
@@ -20,33 +24,44 @@ 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 const NeedParsingResponseSchema = z
.object({
assetType: z
.enum(['OFFICE', 'RETAIL', 'LOGISTICS', 'PRODUCTION', 'LIGHT_INDUSTRIAL', 'GASTRO', 'MIXED', 'UNKNOWN'])
.optional()
.nullable(),
areaRange: z
.object({
min: z.number().min(1, 'minimum area must be ≥ 1 m²').max(100_000),
max: z.number().min(0).max(100_000),
})
.strict()
.optional()
.nullable()
.refine(r => r == null || r.max >= r.min, { message: 'areaRange.max must be >= min' }),
preferredLocations: z.array(z.string().min(1).max(100)).max(20).optional(),
budgetRange: z
.object({
maxPerSqm: z.number().positive().max(100_000, 'budget > 100k CHF/m²/a is implausible'),
currency: z.string().min(1).max(10),
})
.strict()
.optional()
.nullable(),
timing: z
.object({
earliestMoveIn: z.string().optional(),
latestMoveIn: z.string().optional(),
flexibleTiming: z.boolean().optional(),
})
.strict()
.optional()
.nullable(),
mustHaveCriteria: z.array(z.string().min(1).max(200)).max(30).optional(),
missingFields: z.array(z.string().min(1)).max(20).optional(),
assumptions: z.array(z.string().min(1)).max(20).optional(),
})
.strict()
export type NeedParsingResponseRaw = z.infer<typeof NeedParsingResponseSchema>
@@ -54,81 +69,102 @@ export type NeedParsingResponseRaw = z.infer<typeof NeedParsingResponseSchema>
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(),
}),
z
.object({
questionText: z.string().min(5).max(500),
targetField: z.string().min(1).max(100),
reason: z.string().min(3).max(300).optional(),
suggestedAnswerOptions: z.array(z.string().min(1).max(200)).max(10).optional(),
importance: z.enum(['required', 'recommended', 'optional']),
})
.strict(),
)
.max(5)
.max(3)
// ── 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,
})
export const TradeOffSummaryResponseSchema = z
.object({
headline: z.string().min(5).max(300),
items: z
.array(
z
.object({
concern: z.string().min(5).max(300),
severity: SeveritySchema,
mitigation: z.string().max(300).optional(),
})
.strict(),
)
.max(3),
overallRisk: SeveritySchema,
})
.strict()
// ── 4. Comparison Summary ─────────────────────────────────────────────────────
export const CompareSummaryResponseSchema = z.object({
overallAssessment: z.string().min(1),
recommendation: z.string().optional(),
strongestOption: z.string().optional(),
})
export const CompareSummaryResponseSchema = z
.object({
overallAssessment: z.string().min(10).max(1000),
recommendation: z.string().min(5).max(500).optional(),
strongestOption: z.string().min(1).max(200).optional(),
})
.strict()
// ── 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),
})
export const DecisionBriefResponseSchema = z
.object({
summary: z.string().min(10).max(500),
sections: z
.array(
z
.object({
title: z.string().min(1).max(100),
body: z.string().min(10).max(1000),
})
.strict(),
)
.min(1)
.max(6),
})
.strict()
// ── 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),
})
export const DataQualitySummaryResponseSchema = z
.object({
overallAssessment: z.string().min(10).max(600),
missingCriticalFields: z.array(z.string().min(1).max(100)).max(30).optional(),
recommendation: z.string().min(5).max(500),
confidence: z.number().min(0).max(1),
})
.strict()
// ── 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),
})
export const MarketSignalClassificationResponseSchema = z
.object({
signalType: z.enum([
'VACANCY', 'CONSTRUCTION', 'RESTRUCTURING',
'EXPANSION', 'RELOCATION', 'UNKNOWN',
]),
probability: ProbabilitySchema,
timeHorizonMonths: z.number().int().min(1).max(240).optional().nullable(),
areaSqmEstimate: z.number().positive().max(1_000_000).optional().nullable(),
credibility: CredibilitySchema,
reasoning: z.string().min(10).max(1000),
})
.strict()
// ── 8. Offer Email ────────────────────────────────────────────────────────────
export const OfferEmailResponseSchema = z.object({
subject: z.string().min(1),
body: z.string().min(10),
})
export const OfferEmailResponseSchema = z
.object({
subject: z.string().min(5).max(200),
body: z.string().min(50).max(5000),
})
.strict()
// ── Validation helper ─────────────────────────────────────────────────────────
@@ -144,6 +180,12 @@ export function validateAIResponse<T>(
): T | null {
const result = schema.safeParse(raw)
if (result.success) return result.data
console.warn(`[AISchema] ${label} validation failed:`, result.error.flatten())
const errors = result.error.flatten()
console.warn(`[AISchema] ${label} validation failed`, {
fieldErrors: errors.fieldErrors,
formErrors: errors.formErrors,
receivedKeys: typeof raw === 'object' && raw !== null ? Object.keys(raw as object) : [],
snippet: JSON.stringify(raw).slice(0, 300),
})
return null
}
+67 -7
View File
@@ -56,6 +56,8 @@ export interface AITrace {
responseValidationStatus: AITraceValidationStatus
/** Only present when responseValidationStatus indicates a failure */
errorType?: AITraceErrorType
/** Human-readable reason for the fallback — mirrors AIProvenance.fallbackReason */
fallbackReason?: string
source: AIProvenance['source']
/** ISO-8601 timestamp of when the call completed */
createdAt: string
@@ -65,6 +67,11 @@ export interface AITrace {
// ── Store ─────────────────────────────────────────────────────────────────────
function percentile(sortedArr: number[], p: number): number {
if (sortedArr.length === 0) return 0
return sortedArr[Math.max(0, Math.ceil(p * sortedArr.length) - 1)]
}
const MAX_ENTRIES = 100
const STORAGE_KEY = 'pm_ai_traces'
@@ -103,19 +110,67 @@ class AITraceStore {
fallbacks: number
schemaFailures: number
avgLatencyMs: number
latencyPercentiles: { p50: number; p90: number; p99: number }
byMethod: Record<string, number>
failureCountByError: Record<string, number>
validationFailuresByMethod: Record<string, number>
fallbackReasonDistribution: Record<string, number>
promptVersionUsage: Record<string, number>
} {
const total = this.entries.length
const fallbacks = this.entries.filter(t => t.fallbackUsed).length
const total = this.entries.length
const fallbacks = this.entries.filter(t => t.fallbackUsed).length
const schemaFailures = this.entries.filter(t => t.responseValidationStatus === 'invalid_schema').length
const avgLatencyMs = total === 0 ? 0 : Math.round(
this.entries.reduce((s, t) => s + t.latencyMs, 0) / total
)
const sortedLatencies = [...this.entries.map(t => t.latencyMs)].sort((a, b) => a - b)
const avgLatencyMs = total === 0 ? 0 : Math.round(sortedLatencies.reduce((s, l) => s + l, 0) / total)
const byMethod = this.entries.reduce<Record<string, number>>((acc, t) => {
acc[t.method] = (acc[t.method] ?? 0) + 1
return acc
}, {})
return { total, fallbacks, schemaFailures, avgLatencyMs, byMethod }
const failureCountByError = this.entries
.filter(t => t.errorType)
.reduce<Record<string, number>>((acc, t) => {
acc[t.errorType!] = (acc[t.errorType!] ?? 0) + 1
return acc
}, {})
const validationFailuresByMethod = this.entries
.filter(t => t.responseValidationStatus === 'invalid_schema')
.reduce<Record<string, number>>((acc, t) => {
acc[t.method] = (acc[t.method] ?? 0) + 1
return acc
}, {})
const fallbackReasonDistribution = this.entries
.filter(t => t.fallbackReason)
.reduce<Record<string, number>>((acc, t) => {
acc[t.fallbackReason!] = (acc[t.fallbackReason!] ?? 0) + 1
return acc
}, {})
const promptVersionUsage = this.entries.reduce<Record<string, number>>((acc, t) => {
acc[t.promptVersion] = (acc[t.promptVersion] ?? 0) + 1
return acc
}, {})
return {
total,
fallbacks,
schemaFailures,
avgLatencyMs,
latencyPercentiles: {
p50: percentile(sortedLatencies, 0.50),
p90: percentile(sortedLatencies, 0.90),
p99: percentile(sortedLatencies, 0.99),
},
byMethod,
failureCountByError,
validationFailuresByMethod,
fallbackReasonDistribution,
promptVersionUsage,
}
}
/** Load the persisted trace list from localStorage (dev only). */
@@ -140,7 +195,8 @@ class AITraceStore {
console.debug(
`[AITrace] ${icon} ${trace.method}${fallback}${validation}` +
`${trace.provider}/${trace.model}` +
` | ${trace.latencyMs}ms | source:${trace.source}`,
` | ${trace.latencyMs}ms | source:${trace.source}` +
(trace.fallbackReason ? ` | reason:${trace.fallbackReason}` : ''),
trace,
)
}
@@ -175,8 +231,12 @@ if (import.meta.env.DEV && typeof window !== 'undefined') {
export function provenanceToStatus(
fallbackUsed: boolean,
source: AIProvenance['source'],
fallbackReason?: string,
): AITraceValidationStatus {
if (!fallbackUsed) return 'valid'
if (fallbackReason === 'no_api_key') return 'fallback'
if (fallbackReason?.startsWith('api_error')) return 'api_error'
if (fallbackReason?.startsWith('network')) return 'network_error'
if (source === 'mock') return 'invalid_schema'
return 'valid'
}