Skip to content

Commit 46dd859

Browse files
matthew leanmatthew lean
authored andcommitted
fix(api): normalise xAI response to match InterpretationResult schema
- Harden system prompt with explicit type constraints and worked example - Add normalizeXaiResponse() to fix shape mismatches at call time: - driftMagnitude: convert string ('Moderate') -> number (0.5) - driftType: map freeform text to enum ('Broadening and pejoration' -> 'broadening') - snapshot.date: convert bare year ints to ISO8601 strings ('1400' -> '1400-01-01') - snapshot.definition: accept 'description'/'meaning'/'sense' fallbacks - snapshot.register/sentiment/confidence/sourceIds: supply safe defaults - summaryOfChange: rescue fields that AI placed at root level
1 parent c60a569 commit 46dd859

1 file changed

Lines changed: 202 additions & 7 deletions

File tree

pwa/netlify/functions/interpret.ts

Lines changed: 202 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,209 @@ interface InterpretRequest {
1313
useMock?: boolean
1414
}
1515

16+
const DRIFT_TYPE_MAP: Record<string, string> = {
17+
pejoration: 'pejoration',
18+
amelioration: 'amelioration',
19+
narrowing: 'narrowing',
20+
broadening: 'broadening',
21+
'semantic-shift': 'semantic-shift',
22+
'semantic shift': 'semantic-shift',
23+
'semantic_shift': 'semantic-shift',
24+
stable: 'stable',
25+
reclamation: 'reclamation',
26+
}
27+
28+
const DRIFT_MAGNITUDE_MAP: Record<string, number> = {
29+
minimal: 0.1,
30+
slight: 0.2,
31+
low: 0.25,
32+
minor: 0.3,
33+
moderate: 0.5,
34+
significant: 0.65,
35+
substantial: 0.7,
36+
high: 0.75,
37+
major: 0.8,
38+
extreme: 0.9,
39+
complete: 0.95,
40+
}
41+
42+
const VALID_REGISTERS = new Set(['formal', 'informal', 'neutral', 'technical', 'vulgar', 'archaic'])
43+
const VALID_SENTIMENTS = new Set(['positive', 'negative', 'neutral'])
44+
const VALID_SENTIMENT_SHIFTS = new Set([
45+
'positive-to-negative', 'negative-to-positive', 'neutral-to-negative',
46+
'neutral-to-positive', 'positive-to-neutral', 'negative-to-neutral', 'stable', 'complex',
47+
])
48+
49+
function normalizeDriftMagnitude(raw: unknown): number {
50+
if (typeof raw === 'number' && raw >= 0 && raw <= 1) return raw
51+
if (typeof raw === 'number') return Math.min(Math.max(raw / 10, 0), 1)
52+
if (typeof raw === 'string') {
53+
const asFloat = parseFloat(raw)
54+
if (!isNaN(asFloat)) return asFloat >= 0 && asFloat <= 1 ? asFloat : Math.min(Math.max(asFloat / 10, 0), 1)
55+
const key = raw.toLowerCase().trim()
56+
return DRIFT_MAGNITUDE_MAP[key] ?? 0.5
57+
}
58+
return 0.5
59+
}
60+
61+
function normalizeDriftType(raw: unknown): string {
62+
if (typeof raw !== 'string') return 'semantic-shift'
63+
const lower = raw.toLowerCase().trim()
64+
if (DRIFT_TYPE_MAP[lower]) return DRIFT_TYPE_MAP[lower]
65+
for (const [key, val] of Object.entries(DRIFT_TYPE_MAP)) {
66+
if (lower.includes(key)) return val
67+
}
68+
return 'semantic-shift'
69+
}
70+
71+
function yearToDate(year: unknown): string {
72+
if (typeof year === 'number') return `${String(Math.abs(year)).padStart(4, '0')}-01-01`
73+
if (typeof year === 'string') {
74+
if (/^\d{4}-\d{2}-\d{2}/.test(year)) return year
75+
const n = parseInt(year, 10)
76+
if (!isNaN(n)) return `${String(Math.abs(n)).padStart(4, '0')}-01-01`
77+
}
78+
return '2024-01-01'
79+
}
80+
81+
function eraLabelFromDate(date: string): string {
82+
const year = parseInt(date.slice(0, 4), 10)
83+
if (year <= 500) return 'Ancient'
84+
if (year <= 1100) return 'Old English'
85+
if (year <= 1500) return 'Middle English'
86+
if (year <= 1700) return 'Early Modern English'
87+
if (year <= 1900) return 'Modern English'
88+
if (year <= 1999) return '20th Century'
89+
return 'Contemporary'
90+
}
91+
92+
let _snapshotCounter = 0
93+
function normalizeSnapshot(raw: Record<string, unknown>, prefix: string): Record<string, unknown> {
94+
_snapshotCounter++
95+
const year = raw.year ?? raw.date ?? raw.period
96+
const date = raw.date && typeof raw.date === 'string' && /^\d{4}-\d{2}-\d{2}/.test(raw.date)
97+
? raw.date
98+
: yearToDate(year)
99+
100+
return {
101+
snapshotId: raw.snapshotId ?? raw.id ?? `${prefix}-snap-${_snapshotCounter}`,
102+
date,
103+
eraLabel: raw.eraLabel ?? raw.era ?? raw.period_label ?? eraLabelFromDate(date),
104+
definition: raw.definition ?? raw.description ?? raw.meaning ?? raw.sense ?? '',
105+
usageNote: raw.usageNote ?? raw.usage_note ?? raw.note ?? undefined,
106+
exampleUsage: raw.exampleUsage ?? raw.example ?? raw.example_usage ?? undefined,
107+
register: VALID_REGISTERS.has(String(raw.register)) ? raw.register : 'neutral',
108+
sentiment: VALID_SENTIMENTS.has(String(raw.sentiment)) ? raw.sentiment : 'neutral',
109+
confidence: typeof raw.confidence === 'number' ? raw.confidence : 0.8,
110+
sourceIds: Array.isArray(raw.sourceIds) ? raw.sourceIds : [],
111+
}
112+
}
113+
114+
function normalizeXaiResponse(parsed: unknown, query: string, normalizedQuery: string, mode: string): unknown {
115+
if (typeof parsed !== 'object' || parsed === null) return parsed
116+
_snapshotCounter = 0
117+
118+
const obj = parsed as Record<string, unknown>
119+
const prefix = normalizedQuery.replace(/\s+/g, '-')
120+
121+
// Normalize currentSnapshot
122+
if (obj.currentSnapshot && typeof obj.currentSnapshot === 'object') {
123+
obj.currentSnapshot = normalizeSnapshot(obj.currentSnapshot as Record<string, unknown>, `${prefix}-current`)
124+
}
125+
126+
// Normalize historicalSnapshots
127+
if (Array.isArray(obj.historicalSnapshots)) {
128+
obj.historicalSnapshots = obj.historicalSnapshots.map((s: unknown) =>
129+
typeof s === 'object' && s !== null
130+
? normalizeSnapshot(s as Record<string, unknown>, prefix)
131+
: s,
132+
)
133+
}
134+
135+
// Normalize summaryOfChange — the AI sometimes places fields at root level
136+
const rootDriftType = obj.driftType
137+
const rootDriftMagnitude = obj.driftMagnitude
138+
139+
if (obj.summaryOfChange && typeof obj.summaryOfChange === 'object') {
140+
const soc = obj.summaryOfChange as Record<string, unknown>
141+
soc.driftMagnitude = normalizeDriftMagnitude(soc.driftMagnitude ?? rootDriftMagnitude)
142+
soc.driftType = normalizeDriftType(soc.driftType ?? rootDriftType)
143+
if (!VALID_SENTIMENT_SHIFTS.has(String(soc.sentimentShift))) {
144+
soc.sentimentShift = 'stable'
145+
}
146+
} else if (rootDriftType || rootDriftMagnitude) {
147+
// AI put summaryOfChange fields at root level — rescue them
148+
obj.summaryOfChange = {
149+
shortSummary: String(obj.shortSummary ?? obj.summary ?? ''),
150+
longSummary: String(obj.longSummary ?? obj.detailedSummary ?? obj.summary ?? ''),
151+
sentimentShift: VALID_SENTIMENT_SHIFTS.has(String(obj.sentimentShift)) ? obj.sentimentShift : 'stable',
152+
driftType: normalizeDriftType(rootDriftType),
153+
driftMagnitude: normalizeDriftMagnitude(rootDriftMagnitude),
154+
}
155+
}
156+
157+
// Ensure required top-level fields
158+
obj.query = obj.query ?? query
159+
obj.normalizedQuery = obj.normalizedQuery ?? normalizedQuery
160+
obj.mode = mode
161+
162+
return obj
163+
}
164+
16165
const INTERPRET_SYSTEM_PROMPT = `You are BackWords, a scholarly assistant specialising in the historical evolution of language.
17166
You produce detailed, academically rigorous JSON objects describing how a word or phrase has changed in meaning over time.
18167
19-
You MUST respond with a single valid JSON object matching the InterpretationResult schema, with no markdown fences.
20-
Include at minimum:
21-
- lexemeId, query, normalizedQuery, mode, currentSnapshot, historicalSnapshots (2–5 entries)
22-
- summaryOfChange with shortSummary, longSummary, sentimentShift, driftType, driftMagnitude
23-
- keyDates (2–4 entries), sources (2–4 entries), relatedConcepts (1–3 entries)
24-
- timelineEvents (one per snapshot)`
168+
You MUST respond with a single valid JSON object with NO markdown fences and NO extra text.
169+
The object MUST match this exact structure:
170+
171+
{
172+
"lexemeId": "word-en-00",
173+
"query": "word",
174+
"normalizedQuery": "word",
175+
"currentSnapshot": {
176+
"snapshotId": "word-current",
177+
"date": "2024-01-01",
178+
"eraLabel": "Contemporary",
179+
"definition": "...",
180+
"register": "neutral",
181+
"sentiment": "neutral",
182+
"confidence": 0.9,
183+
"sourceIds": []
184+
},
185+
"historicalSnapshots": [
186+
{
187+
"snapshotId": "word-snap-1",
188+
"date": "1400-01-01",
189+
"eraLabel": "Middle English",
190+
"definition": "...",
191+
"register": "formal",
192+
"sentiment": "neutral",
193+
"confidence": 0.85,
194+
"sourceIds": []
195+
}
196+
],
197+
"summaryOfChange": {
198+
"shortSummary": "...",
199+
"longSummary": "...",
200+
"sentimentShift": "stable",
201+
"driftType": "broadening",
202+
"driftMagnitude": 0.6
203+
},
204+
"keyDates": [],
205+
"sources": [],
206+
"relatedConcepts": [],
207+
"timelineEvents": []
208+
}
209+
210+
CRITICAL RULES:
211+
- date fields MUST be ISO8601 strings like "1400-01-01", NEVER bare year numbers
212+
- driftMagnitude MUST be a decimal number 0–1 (e.g. 0.5), NEVER a string like "Moderate"
213+
- driftType MUST be exactly one of: pejoration | amelioration | narrowing | broadening | semantic-shift | stable | reclamation
214+
- register MUST be exactly one of: formal | informal | neutral | technical | vulgar | archaic
215+
- sentiment MUST be exactly one of: positive | negative | neutral
216+
- sentimentShift MUST be exactly one of: positive-to-negative | negative-to-positive | neutral-to-negative | neutral-to-positive | positive-to-neutral | negative-to-neutral | stable | complex
217+
- Include 2–5 historicalSnapshots
218+
- currentSnapshot.definition and each historicalSnapshot.definition must be non-empty strings`
25219

26220
export default async function handler(req: Request): Promise<Response> {
27221
if (req.method === 'OPTIONS') return optionsResponse()
@@ -67,7 +261,8 @@ Return a complete InterpretationResult JSON object.`
67261

68262
const extracted = extractJson(raw)
69263
const parsed: unknown = JSON.parse(extracted)
70-
return jsonResponse(parsed)
264+
const normResult = normalizeXaiResponse(parsed, query, normalized, mode)
265+
return jsonResponse(normResult)
71266
} catch (err) {
72267
const msg = err instanceof Error ? err.message : String(err)
73268
console.error('[interpret] xAI error:', msg)

0 commit comments

Comments
 (0)