@@ -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+
16165const INTERPRET_SYSTEM_PROMPT = `You are BackWords, a scholarly assistant specialising in the historical evolution of language.
17166You 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
26220export 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