Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🧪 QA (push) Successful in 1m18s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
CI - Lint, Typecheck & Test / quality-assurance (pull_request) Failing after 3m55s
158 lines
6.3 KiB
TypeScript
158 lines
6.3 KiB
TypeScript
import { NextResponse, NextRequest } from 'next/server'; // Added NextRequest
|
|
import { searchProducts } from '../../../src/lib/qdrant';
|
|
import redis from '../../../src/lib/redis';
|
|
import { z } from 'zod';
|
|
import * as Sentry from '@sentry/nextjs';
|
|
// Config and constants
|
|
const RATE_LIMIT_POINTS = 5; // 5 requests
|
|
const RATE_LIMIT_DURATION = 60 * 1; // per 1 minute
|
|
|
|
// Removed requestSchema as it's replaced by direct parsing
|
|
|
|
export async function POST(req: NextRequest) {
|
|
// Changed req type to NextRequest
|
|
try {
|
|
const { messages, visitorId, honeypot } = await req.json();
|
|
|
|
// 1. Basic Validation
|
|
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
|
return NextResponse.json({ error: 'Valid messages array is required' }, { status: 400 });
|
|
}
|
|
|
|
const latestMessage = messages[messages.length - 1].content;
|
|
const isBot = honeypot && honeypot.length > 0;
|
|
|
|
// Check if the input itself is obviously spam/too long
|
|
if (latestMessage.length > 500) {
|
|
return NextResponse.json({ error: 'Message too long' }, { status: 400 });
|
|
}
|
|
|
|
// 2. Honeypot check
|
|
if (isBot) {
|
|
console.warn('Honeypot triggered in AI search');
|
|
// Tarpit the bot
|
|
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
return NextResponse.json({
|
|
answerText: 'Vielen Dank für Ihre Anfrage.',
|
|
products: [],
|
|
});
|
|
}
|
|
|
|
// 3. Rate Limiting via Redis
|
|
try {
|
|
if (visitorId) {
|
|
const requestCount = await redis.incr(`ai_search_rate_limit:${visitorId}`);
|
|
if (requestCount === 1) {
|
|
await redis.expire(`ai_search_rate_limit:${visitorId}`, RATE_LIMIT_DURATION); // Use constant
|
|
}
|
|
|
|
if (requestCount > RATE_LIMIT_POINTS) {
|
|
// Use constant
|
|
return NextResponse.json(
|
|
{
|
|
error: 'Rate limit exceeded. Please try again later.',
|
|
},
|
|
{ status: 429 },
|
|
);
|
|
}
|
|
}
|
|
} catch (redisError) {
|
|
// Renamed variable for clarity
|
|
console.error('Redis Rate Limiting Error:', redisError); // Changed to error for consistency
|
|
Sentry.captureException(redisError, { tags: { context: 'ai-search-rate-limit' } });
|
|
// Fail open if Redis is down
|
|
}
|
|
|
|
// 4. Fetch Context from Qdrant based on the latest message
|
|
let contextStr = '';
|
|
let foundProducts: any[] = [];
|
|
|
|
try {
|
|
const searchResults = await searchProducts(latestMessage, 5);
|
|
|
|
if (searchResults && searchResults.length > 0) {
|
|
const productDescriptions = searchResults
|
|
.filter((p) => p.payload?.type === 'product' || !p.payload?.type)
|
|
.map((p: any) => p.payload?.content)
|
|
.join('\n\n');
|
|
|
|
const knowledgeDescriptions = searchResults
|
|
.filter((p) => p.payload?.type === 'knowledge')
|
|
.map((p: any) => p.payload?.content)
|
|
.join('\n\n');
|
|
|
|
contextStr = `KATALOG & PRODUKTE:\n${productDescriptions}\n\nKABELWISSEN (Handbuch):\n${knowledgeDescriptions}`;
|
|
|
|
foundProducts = searchResults
|
|
.filter((p) => (p.payload?.type === 'product' || !p.payload?.type) && p.payload?.data)
|
|
.map((p: any) => p.payload?.data);
|
|
}
|
|
} catch (e) {
|
|
console.error('Qdrant Search Error:', e);
|
|
Sentry.captureException(e, { tags: { context: 'ai-search-qdrant' } });
|
|
// We can still proceed without context if Qdrant fails
|
|
}
|
|
|
|
// 5. Generate AI Response via OpenRouter (Mistral for DSGVO)
|
|
const systemPrompt = `Du bist ein professioneller und extrem kompetenter Sales-Engineer / Consultant der Firma "KLZ Cables".
|
|
Deine Aufgabe ist es, Kunden und Interessenten bei der Auswahl von Mittelspannungskabeln, Starkstromkabeln und Infrastrukturausrüstung beratend zur Seite zu stehen.
|
|
|
|
WICHTIGE REGELN:
|
|
1. ANTWORTE IMMER IN DER SPRACHE DES BENUTZERS. Wenn der Benutzer Deutsch spricht, antworte auf Deutsch.
|
|
2. Wenn der Kunde vage ist (z.B. "Ich will einen Windpark bauen"), würge ihn NICHT ab. Stelle stattdessen gezielte, professionelle Rückfragen als Berater (z.B. "Für einen Windpark benötigen wir einige Rahmendaten: Reden wir über die Parkverkabelung (Mittelspannung, z.B. 20kV oder 33kV) oder die Netzanbindung? Welche Querschnitte oder Ströme erwarten Sie?").
|
|
3. Nutze das bereitgestellte KABELWISSEN und KATALOG-Gedächtnis unten, um deine Antworten zu fundieren.
|
|
4. Bleibe stets professionell, lösungsorientiert und leicht technisch (Industrial Aesthetic). Du kannst humorvoll sein, wenn der Nutzer offensichtlich Quatsch fragt, aber lenke es immer elegant zurück zu Kabeln oder Energieinfrastruktur.
|
|
5. Antworte in reinem Text (kein Markdown für die Antwort, es sei denn es sind einfache Absätze oder Listen).
|
|
6. Wenn genügend Informationen vorhanden sind, präsentiere passende Kabel aus dem Katalog.
|
|
7. Oute dich als Berater von KLZ Cables.
|
|
|
|
VERFÜGBARER KONTEXT:
|
|
${contextStr ? contextStr : 'Keine spezifischen Katalogdaten für diese Anfrage gefunden.'}
|
|
`;
|
|
|
|
const openRouterKey = process.env.OPENROUTER_API_KEY;
|
|
if (!openRouterKey) {
|
|
throw new Error('OPENROUTER_API_KEY is not set');
|
|
}
|
|
|
|
const fetchRes = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${openRouterKey}`,
|
|
'Content-Type': 'application/json',
|
|
'HTTP-Referer': process.env.NEXT_PUBLIC_BASE_URL || 'https://klz-cables.com',
|
|
'X-Title': 'KLZ Cables Search AI',
|
|
},
|
|
body: JSON.stringify({
|
|
model: 'mistralai/mistral-large-2407',
|
|
temperature: 0.3,
|
|
messages: [
|
|
{ role: 'system', content: systemPrompt },
|
|
...messages.map((m: any) => ({
|
|
role: m.role,
|
|
content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content),
|
|
})),
|
|
],
|
|
}),
|
|
});
|
|
|
|
if (!fetchRes.ok) {
|
|
const errBody = await fetchRes.text();
|
|
throw new Error(`OpenRouter API Error: ${errBody}`);
|
|
}
|
|
|
|
const data = await fetchRes.json();
|
|
const text = data.choices[0].message.content;
|
|
|
|
// Return the AI's answer along with any found products
|
|
return NextResponse.json({
|
|
answerText: text,
|
|
products: foundProducts,
|
|
});
|
|
} catch (error) {
|
|
console.error('AI Search API Error:', error);
|
|
Sentry.captureException(error, { tags: { context: 'ai-search-api' } });
|
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
|
}
|
|
}
|