Files
klz-cables.com/app/api/ai-search/route.ts
Marc Mintel d6bdd28b30
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🧪 QA (push) Successful in 1m30s
Build & Deploy / 🏗️ Build (push) Successful in 6m41s
Build & Deploy / 🚀 Deploy (push) Failing after 11s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
fix(ai-search): fix linting errors for deployment
2026-03-17 23:03:48 +01:00

219 lines
8.9 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';
import { generateText } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';
// @ts-expect-error - Local version of @mintel/payload-ai/tools/mcpAdapter might not have types published yet
import { createMcpTools } from '@mintel/payload-ai/tools/mcpAdapter';
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');
if (productDescriptions) {
contextStr = `KATALOG & PRODUKTE:\n${productDescriptions}`;
}
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."
- Wenn technisches Wissen aus dem Kabelhandbuch benötigt wird, NUTZE UNBEDINGT eines der "kabelfachmann_*" Tools, anstatt zu raten oder zu behaupten du wüsstest es nicht! Das Tool weiss alles.
VORGEHEN:
1. Prüfe den KONTEXT auf passende Katalog-Kabel für das Kundenprojekt.
2. Wenn du tiefgehendes Wissen zu einem Kabeltyp brauchst (z.B. Biegeradius, Normen, Querschnitte), rufe das Kabelfachmann-Tool auf.
3. Nenne direkt 1-2 passende Produktserien aus dem Kontext oder der Tool-Abfrage, die für diesen Fall Sinn machen.
4. 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?).
5. 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 openrouterApiKey = process.env.OPENROUTER_API_KEY;
if (!openrouterApiKey) {
throw new Error('OPENROUTER_API_KEY is not set');
}
const openrouter = createOpenAI({
baseURL: 'https://openrouter.ai/api/v1',
apiKey: openrouterApiKey,
});
let mcpTools: Record<string, any> = {};
const mcpUrl = process.env.KABELFACHMANN_MCP_URL || 'http://host.docker.internal:3007/sse';
try {
const { tools } = await createMcpTools({
name: 'kabelfachmann',
url: mcpUrl,
});
mcpTools = tools;
} catch (e) {
console.warn('Failed to load MCP tools', e);
Sentry.captureException(e, { tags: { context: 'ai-search-mcp' } });
}
const { text } = await generateText({
model: openrouter('google/gemini-3.0-flash'),
system: systemPrompt,
messages: cappedMessages.map((m: any) => ({
role: m.role,
content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content),
})),
tools: mcpTools,
// @ts-expect-error - maxSteps might be missing in some versions of generateText types
maxSteps: 5,
temperature: 0.3,
maxTokens: MAX_RESPONSE_TOKENS,
});
// 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 },
);
}
}