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:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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):
|
||||
{
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user