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
219 lines
8.9 KiB
TypeScript
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 },
|
|
);
|
|
}
|
|
}
|