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'; export const dynamic = 'force-dynamic'; export const maxDuration = 60; // Max allowed duration (Vercel) // Config and constants const RATE_LIMIT_POINTS = 20; // 20 requests per minute const RATE_LIMIT_DURATION = 60; // 1 minute window const DAILY_BUDGET_LIMIT = 200; // max 200 requests per IP per day const DAILY_BUDGET_DURATION = 60 * 60 * 24; // 24h const MAX_CONVERSATION_MESSAGES = 20; // max messages in context const MAX_RESPONSE_TOKENS = 300; // cap AI response length — keeps it chat-like // Removed requestSchema as it's replaced by direct parsing export async function POST(req: NextRequest) { // Changed req type to NextRequest try { let body: any; try { body = await req.json(); } catch { return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }); } const { messages, honeypot } = body; // Get client IP for rate limiting const forwarded = req.headers.get('x-forwarded-for'); const clientIp = forwarded?.split(',')[0]?.trim() || req.headers.get('x-real-ip') || 'unknown'; // 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 (IP-based) try { // Per-minute burst limit const minuteKey = `ai_rate:${clientIp}:min`; const minuteCount = await redis.incr(minuteKey); if (minuteCount === 1) await redis.expire(minuteKey, RATE_LIMIT_DURATION); if (minuteCount > RATE_LIMIT_POINTS) { return NextResponse.json( { error: 'Zu viele Anfragen. Bitte warte einen Moment.' }, { status: 429 }, ); } // Daily budget limit const dayKey = `ai_rate:${clientIp}:day`; const dayCount = await redis.incr(dayKey); if (dayCount === 1) await redis.expire(dayKey, DAILY_BUDGET_DURATION); if (dayCount > DAILY_BUDGET_LIMIT) { return NextResponse.json( { error: 'Tägliches Limit erreicht. Bitte versuche es morgen erneut.' }, { status: 429 }, ); } } catch (redisError) { console.error('Redis Rate Limiting Error:', redisError); Sentry.captureException(redisError, { tags: { context: 'ai-search-rate-limit' } }); // Fail open if Redis is down } // 4. Cap conversation length to limit token usage const cappedMessages = messages.slice(-MAX_CONVERSATION_MESSAGES); // 4. Fetch Context from Qdrant based on the latest message let contextStr = ''; let foundProducts: any[] = []; // Team context — hardcoded from translation data (no Payload collection for team) const teamContextStr = ` Das ECHTE KLZ Team: - Michael Bodemer (Geschäftsführer) — Der Macher, packt an wenn es kompliziert wird, kennt Kabelnetze in- und auswendig - Klaus Mintel (Geschäftsführer) — Der Fels in der Brandung, jahrzehntelange Erfahrung, stabiles Netzwerk`; 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) => ({ id: p.id as string, title: p.payload?.data?.title as string, sku: p.payload?.data?.sku as string, slug: p.payload?.data?.slug as string, })); } } catch (searchError) { console.error('Qdrant Search Error:', searchError); Sentry.captureException(searchError, { 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 "Ohm" — der digitale KI-Berater von KLZ Cables. Dein Name ist eine Anspielung auf die Einheit des elektrischen Widerstands. STIL & PERSÖNLICHKEIT: - Antworte KURZ, KNAPP und PROFESSIONELL (maximal 2-3 Sätze). - Schreibe wie in einem lockeren, aber kompetenten B2B-Chat (Du-Form ist okay, aber fachlich top). - Kein Markdown, nur Fließtext. - NIEMALS Platzhalter wie [Ihr Name], [Name], [Firma] verwenden. DEINE HAUPTAUFGABE: BERATEN, NICHT AUSFRAGEN! - Wenn der Kunde ein Projekt nennt (z.B. "Windpark 30kV"), dann lies im KONTEXT nach, welche Kabel passen, und EMPFIEHL SIE DIREKT! (z.B. "Für 30kV Windparks nehmen wir meistens NA2XS(F)2Y."). - Stelle NIEMALS mehr als EINE Rückfrage pro Nachricht. - FRAGE NICHT nach abstrakten Dingen wie "Welchen Kabeltyp brauchst du?" -> DAS IST DEIN JOB, IHM DAS ZU SAGEN! - FRAGE NICHT nach Längen oder genauen Trassen, es sei denn, der Kunde hat schon ganz klar gesagt, was er kaufen will. - Biete aktiv Hilfe an: "Ich kann dir die passenden Querschnitte raussuchen, wenn du willst." VORGEHEN: 1. Prüfe den KONTEXT auf passende Kabel für das Kundenprojekt. 2. Nenne direkt 1-2 passende Produktserien aus dem Kontext, die für diesen Fall Sinn machen. 3. Biete eine konkrete Hilfestellung an (z.B. Leitungsberechnung, Verfügbarkeitsprüfung) ODER stelle EINE einzige fachliche Rückfrage, um das Kabel weiter einzugrenzen (z.B. Alu oder Kupfer?). 4. Wenn das Projekt klar ist und die Kabeltypen besprochen sind, frag nach, ob ein Kollege (z.B. Micha) ein konkretes Angebot machen soll. GRENZEN: - PRIVAT-ANFRAGEN: B2B only. Private Hausinstallationen lehnen wir freundlich ab. - Keine Preise oder genauen Lieferzeiten versprechen. Immer auf die menschlichen Kollegen verweisen für finale Angebote. KONTEXT KABEL & TEAM: ${contextStr || 'Kein Katalogkontext verfügbar.'} ${teamContextStr} `; const mistralKey = process.env.MISTRAL_API_KEY; if (!mistralKey) { throw new Error('MISTRAL_API_KEY is not set'); } // DSGVO: Mistral AI API direkt (EU/Frankreich) statt OpenRouter (US) const fetchRes = await fetch('https://api.mistral.ai/v1/chat/completions', { method: 'POST', headers: { Authorization: `Bearer ${mistralKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ model: 'ministral-8b-latest', temperature: 0.3, max_tokens: MAX_RESPONSE_TOKENS, messages: [ { role: 'system', content: systemPrompt }, ...cappedMessages.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(); console.error('Mistral API Error:', errBody); Sentry.captureException(new Error(`Mistral ${fetchRes.status}: ${errBody}`), { tags: { context: 'ai-search-mistral' }, }); // Return user-friendly error based on status const userMsg = fetchRes.status === 429 ? 'Der KI-Service ist gerade überlastet. Bitte versuche es in ein paar Sekunden erneut.' : fetchRes.status >= 500 ? 'Der KI-Service ist vorübergehend nicht erreichbar. Bitte versuche es gleich nochmal.' : 'Es gab ein Problem mit der KI-Anfrage. Bitte versuche es erneut.'; return NextResponse.json({ error: userMsg }, { status: 502 }); } 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: 'Ein interner Fehler ist aufgetreten. Bitte versuche es erneut.' }, { status: 500 }, ); } }