Skip to content

Commit f1730f3

Browse files
authored
(SP:2) feat(api): clean up AI helper for Vercel & fix orders i18n (#245)
- Refactor /api/ai/explain route for Vercel deployment - Replace dynamic import with static import of groq-sdk - Use request.json() instead of Netlify-safe body parsing - Add proper error handling with Groq.APIError types - Simplify GET health check endpoint - Update model from llama3-70b-8192 to llama-3.3-70b-versatile - Add table.openOrder and table.orderId to en/uk/pl locales
1 parent c23f2c0 commit f1730f3

6 files changed

Lines changed: 117 additions & 175 deletions

File tree

frontend/app/api/ai/explain/route.ts

Lines changed: 96 additions & 158 deletions
Original file line numberDiff line numberDiff line change
@@ -2,53 +2,16 @@ export const runtime = 'nodejs';
22

33
import { NextRequest, NextResponse } from 'next/server';
44
import { z } from 'zod';
5+
import Groq from 'groq-sdk';
56
import {
67
createExplainPrompt,
78
type ExplanationResponse,
89
} from '@/lib/ai/prompts';
910
import { getClientIp } from '@/lib/security/client-ip';
1011

1112
// =============================================================================
12-
// SERVER-SIDE LOGGING (sanitized - no sensitive data exposed)
13+
// Rate Limiter (in-memory, resets on cold start)
1314
// =============================================================================
14-
function logEnvironmentDiagnostics() {
15-
const apiKey = process.env.GROQ_API_KEY;
16-
console.log('[ENV] GROQ_API_KEY configured:', !!apiKey);
17-
console.log('[ENV] NODE_ENV:', process.env.NODE_ENV);
18-
}
19-
20-
function logRequestDiagnostics(request: NextRequest) {
21-
console.log('[REQ] Method:', request.method);
22-
console.log('[REQ] URL path:', new URL(request.url).pathname);
23-
}
24-
25-
function logBodyParsingResult(success: boolean, error?: unknown) {
26-
console.log('[BODY] Parse success:', success);
27-
if (error) {
28-
console.log('[BODY] Parse error:', error instanceof Error ? error.message : 'Unknown error');
29-
}
30-
}
31-
32-
function logGroqInitialization(success: boolean, error?: unknown) {
33-
console.log('[GROQ] Init success:', success);
34-
if (error) {
35-
const err = error as Error & { status?: number; code?: string };
36-
console.log('[GROQ] Init error:', err.name, err.message);
37-
}
38-
}
39-
40-
function logGroqApiCall(phase: 'start' | 'success' | 'error', details?: unknown) {
41-
if (phase === 'start') {
42-
console.log('[GROQ] Starting API call');
43-
} else if (phase === 'success') {
44-
console.log('[GROQ] API call successful');
45-
} else if (phase === 'error') {
46-
const err = details as Error & { status?: number; code?: string };
47-
console.log('[GROQ] API error:', err?.name, err?.message);
48-
}
49-
}
50-
// =============================================================================
51-
5215
const rateLimiter = new Map<string, { count: number; resetAt: number }>();
5316
const MAX_REQUESTS_PER_WINDOW = 10;
5417
const RATE_LIMIT_WINDOW_MS = 20 * 60 * 1000;
@@ -136,22 +99,21 @@ function parseExplanationResponse(content: string): ExplanationResponse {
13699
return parsed as ExplanationResponse;
137100
}
138101

102+
// =============================================================================
103+
// POST /api/ai/explain - Generate term explanation in 3 languages
104+
// =============================================================================
139105
export async function POST(request: NextRequest) {
140-
logEnvironmentDiagnostics();
141-
logRequestDiagnostics(request);
142-
106+
// Fail fast if API key is missing
143107
const apiKey = process.env.GROQ_API_KEY;
144108
if (!apiKey) {
145-
console.error('[FATAL] GROQ_API_KEY is not configured. Check environment variables.');
109+
console.error('[ai/explain] GROQ_API_KEY not configured');
146110
return NextResponse.json(
147-
{
148-
error: 'AI service not configured',
149-
code: 'SERVICE_UNAVAILABLE',
150-
},
111+
{ error: 'AI service not configured', code: 'SERVICE_UNAVAILABLE' },
151112
{ status: 503 }
152113
);
153114
}
154115

116+
// Rate limiting
155117
const clientIp = getClientIp(request) ?? 'unknown';
156118
const rateLimit = checkRateLimit(clientIp);
157119

@@ -174,21 +136,11 @@ export async function POST(request: NextRequest) {
174136
);
175137
}
176138

177-
// Safe JSON body parsing for Netlify
139+
// Parse and validate request body
178140
let body: unknown;
179141
try {
180-
const text = await request.text();
181-
if (!text || text.trim() === '') {
182-
console.log('[BODY] Empty request body received');
183-
return NextResponse.json(
184-
{ error: 'Request body is empty', code: 'EMPTY_BODY' },
185-
{ status: 400 }
186-
);
187-
}
188-
body = JSON.parse(text);
189-
logBodyParsingResult(true);
190-
} catch (parseError) {
191-
logBodyParsingResult(false, parseError);
142+
body = await request.json();
143+
} catch {
192144
return NextResponse.json(
193145
{ error: 'Invalid JSON body', code: 'INVALID_JSON' },
194146
{ status: 400 }
@@ -209,65 +161,28 @@ export async function POST(request: NextRequest) {
209161

210162
const { term, context } = validationResult.data;
211163

212-
// Dynamic import for Netlify compatibility
213-
let Groq: typeof import('groq-sdk').default;
214-
try {
215-
const groqModule = await import('groq-sdk');
216-
Groq = groqModule.default;
217-
} catch (importError) {
218-
console.error('[SDK_IMPORT_ERROR] Failed to import groq-sdk:',
219-
importError instanceof Error ? importError.message : String(importError)
220-
);
221-
return NextResponse.json(
222-
{
223-
error: 'Failed to load AI client',
224-
code: 'SDK_IMPORT_ERROR',
225-
},
226-
{ status: 503 }
227-
);
228-
}
229-
230-
let groq: InstanceType<typeof Groq>;
231-
try {
232-
groq = new Groq({ apiKey });
233-
logGroqInitialization(true);
234-
} catch (initError) {
235-
logGroqInitialization(false, initError);
236-
console.error('[SDK_INIT_ERROR] Failed to initialize Groq client:',
237-
initError instanceof Error ? initError.message : String(initError)
238-
);
239-
return NextResponse.json(
240-
{
241-
error: 'Failed to initialize AI client',
242-
code: 'SDK_INIT_ERROR',
243-
},
244-
{ status: 503 }
245-
);
246-
}
164+
// Initialize Groq client
165+
const groq = new Groq({ apiKey });
247166

248167
try {
249168
const prompt = createExplainPrompt({ term, context });
250169

251-
logGroqApiCall('start');
252170
const chatCompletion = await groq.chat.completions.create({
253-
messages: [
254-
{
255-
role: 'user',
256-
content: prompt,
257-
},
258-
],
259-
model: 'llama3-70b-8192',
171+
messages: [{ role: 'user', content: prompt }],
172+
model: 'llama-3.3-70b-versatile',
260173
temperature: 0.7,
261174
max_tokens: 1500,
262175
top_p: 1,
263176
});
264-
logGroqApiCall('success', chatCompletion);
265177

266178
const content = chatCompletion.choices[0]?.message?.content;
267179

268180
if (!content) {
269-
console.error('[ERROR] No content in Groq response');
270-
throw new Error('No content in response');
181+
console.error('[ai/explain] Empty response from Groq');
182+
return NextResponse.json(
183+
{ error: 'AI returned empty response', code: 'EMPTY_RESPONSE' },
184+
{ status: 502 }
185+
);
271186
}
272187

273188
const explanation = parseExplanationResponse(content);
@@ -281,64 +196,87 @@ export async function POST(request: NextRequest) {
281196
},
282197
});
283198
} catch (error) {
284-
logGroqApiCall('error', error);
285-
console.error('[GROQ_ERROR]', error instanceof Error ? error.message : 'Unknown error');
286-
287-
if (error instanceof Error) {
288-
if (
289-
error.message.includes('401') ||
290-
error.message.includes('authentication') ||
291-
error.message.includes('Invalid API Key')
292-
) {
293-
console.error('[AUTH_ERROR] API key authentication failed');
294-
return NextResponse.json(
295-
{
296-
error: 'AI service authentication failed',
297-
code: 'AUTH_ERROR',
298-
},
299-
{ status: 503 }
300-
);
301-
}
302-
if (
303-
error.message.includes('429') ||
304-
error.message.includes('rate limit')
305-
) {
306-
return NextResponse.json(
307-
{ error: 'AI service rate limited', code: 'API_RATE_LIMITED' },
308-
{ status: 503 }
309-
);
310-
}
311-
if (error.message.includes('model')) {
312-
return NextResponse.json(
313-
{
314-
error: 'AI model not available',
315-
code: 'MODEL_ERROR',
316-
},
317-
{ status: 503 }
318-
);
319-
}
320-
}
199+
return handleGroqError(error);
200+
}
201+
}
202+
203+
// =============================================================================
204+
// GET /api/ai/explain - Health check
205+
// =============================================================================
206+
export async function GET() {
207+
const hasApiKey = !!process.env.GROQ_API_KEY;
321208

209+
if (!hasApiKey) {
322210
return NextResponse.json(
323-
{
324-
error: 'Failed to generate explanation',
325-
code: 'AI_ERROR',
326-
},
327-
{ status: 500 }
211+
{ status: 'error', service: 'ai-explain', message: 'API key not configured' },
212+
{ status: 503 }
328213
);
329214
}
330-
}
331215

332-
export async function GET() {
333216
return NextResponse.json(
334-
{
335-
status: 'ok',
336-
service: 'ai-explain',
337-
env: {
338-
hasGroqKey: !!process.env.GROQ_API_KEY,
339-
nodeEnv: process.env.NODE_ENV,
340-
},
341-
},
217+
{ status: 'ok', service: 'ai-explain' },
342218
{ status: 200 }
343219
);
344220
}
221+
222+
// =============================================================================
223+
// Error Handling
224+
// =============================================================================
225+
function handleGroqError(error: unknown): NextResponse {
226+
// Handle Groq SDK specific errors
227+
if (error instanceof Groq.APIError) {
228+
console.error(`[ai/explain] Groq API error: ${error.status} ${error.message}`);
229+
230+
if (error.status === 401) {
231+
return NextResponse.json(
232+
{ error: 'AI service authentication failed', code: 'AUTH_ERROR' },
233+
{ status: 503 }
234+
);
235+
}
236+
237+
if (error.status === 429) {
238+
return NextResponse.json(
239+
{ error: 'AI service rate limited', code: 'API_RATE_LIMITED' },
240+
{ status: 503 }
241+
);
242+
}
243+
244+
if (error.status === 404) {
245+
return NextResponse.json(
246+
{ error: 'AI model not available', code: 'MODEL_ERROR' },
247+
{ status: 503 }
248+
);
249+
}
250+
251+
// Other API errors (500, 503, etc.)
252+
return NextResponse.json(
253+
{ error: 'AI service temporarily unavailable', code: 'API_ERROR' },
254+
{ status: 503 }
255+
);
256+
}
257+
258+
// Handle JSON parse errors from response parsing
259+
if (error instanceof SyntaxError) {
260+
console.error('[ai/explain] Failed to parse AI response as JSON');
261+
return NextResponse.json(
262+
{ error: 'AI returned invalid format', code: 'PARSE_ERROR' },
263+
{ status: 502 }
264+
);
265+
}
266+
267+
// Handle response structure validation errors
268+
if (error instanceof Error && error.message === 'Invalid response structure') {
269+
console.error('[ai/explain] AI response missing required fields');
270+
return NextResponse.json(
271+
{ error: 'AI returned incomplete response', code: 'INVALID_STRUCTURE' },
272+
{ status: 502 }
273+
);
274+
}
275+
276+
// Unknown errors
277+
console.error('[ai/explain] Unexpected error:', error);
278+
return NextResponse.json(
279+
{ error: 'Failed to generate explanation', code: 'AI_ERROR' },
280+
{ status: 500 }
281+
);
282+
}

frontend/messages/en.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -547,7 +547,9 @@
547547
"items": "Items",
548548
"date": "Date",
549549
"status": "Status",
550-
"total": "Total"
550+
"total": "Total",
551+
"openOrder": "Open order {id}",
552+
"orderId": "Order ID"
551553
},
552554
"orderHeadline": {
553555
"incomplete": "Order {id} (incomplete)",
@@ -921,7 +923,7 @@
921923
}
922924
},
923925
"onlineCounter": {
924-
"one": "you are one step closer to the goal",
926+
"one": "one step closer to the goal",
925927
"two": "chasing the dream",
926928
"upToFive": "on the way to an offer",
927929
"upToTen": "closer to the dream job",

frontend/messages/pl.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -547,7 +547,9 @@
547547
"items": "Produkty",
548548
"date": "Data",
549549
"status": "Status",
550-
"total": "Razem"
550+
"total": "Razem",
551+
"openOrder": "Otwórz zamówienie {id}",
552+
"orderId": "ID zamówienia"
551553
},
552554
"orderHeadline": {
553555
"incomplete": "Zamówienie {id} (niekompletne)",

frontend/messages/uk.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -547,7 +547,9 @@
547547
"items": "Товари",
548548
"date": "Дата",
549549
"status": "Статус",
550-
"total": "Всього"
550+
"total": "Всього",
551+
"openOrder": "Відкрити замовлення {id}",
552+
"orderId": "ID замовлення"
551553
},
552554
"orderHeadline": {
553555
"incomplete": "Замовлення {id} (незавершене)",
@@ -921,7 +923,7 @@
921923
}
922924
},
923925
"onlineCounter": {
924-
"one": "ти на крок до цілі",
926+
"one": "на крок до цілі",
925927
"two": "йдуть до мрії",
926928
"upToFive": "на шляху до оффера",
927929
"upToTen": "ближчі до dream job",

0 commit comments

Comments
 (0)