- ai_service.py: единый AI-сервис (chat, chat_with_history, analyze_document) - routes/ai.py: POST /api/v1/ai/chat (chat, summarize, extract_risks, classify, translate) - config.py: ANTHROPIC_API_KEY, ANTHROPIC_MODEL - requirements.txt: anthropic>=0.42.0 - api-client.ts: aiApi (chat, summarize, extractRisks) - CSP: connect-src добавлен https://api.anthropic.com - app/api/ai-chat: прокси на бэкенд /api/v1/ai/chat (Anthropic) - legal_agents/llm_client.py: переведён на ai_service (Claude) - AIAccessSettings: только Claude (Sonnet 4, 3 Sonnet, 3 Opus) - k8s, .env.example: OPENAI → ANTHROPIC - package.json: удалена зависимость openai - Документация: OpenAI/GPT заменены на Claude/Anthropic Провайдер: исключительно Anthropic Claude Модель по умолчанию: claude-sonnet-4-20250514 Co-authored-by: Cursor <cursoragent@cursor.com>
182 lines
7.6 KiB
TypeScript
182 lines
7.6 KiB
TypeScript
export const dynamic = 'force-dynamic';
|
||
import { NextRequest, NextResponse } from 'next/server';
|
||
import { rateLimit, getRateLimitIdentifier } from '@/lib/rate-limit';
|
||
import { handleError } from '@/lib/error-handler';
|
||
import { logAudit, logSecurity, logError, logWarn } from '@/lib/logger';
|
||
import { sanitizeText } from '@/lib/sanitize';
|
||
import { recordPerformance } from '@/lib/monitoring/metrics';
|
||
|
||
const BACKEND_URL = process.env.BACKEND_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
||
|
||
export async function POST(request: NextRequest) {
|
||
const startTime = Date.now();
|
||
try {
|
||
// Rate limiting
|
||
const identifier = getRateLimitIdentifier(request);
|
||
const limitCheck = rateLimit(identifier, 50, 60000);
|
||
if (!limitCheck.allowed) {
|
||
logSecurity('Rate limit exceeded', { identifier, path: '/api/ai-chat' });
|
||
recordPerformance('/api/ai-chat', Date.now() - startTime, 429, { method: 'POST' });
|
||
return NextResponse.json(
|
||
{
|
||
error: 'Превышен лимит запросов. Попробуйте позже.',
|
||
code: 'RATE_LIMIT_EXCEEDED',
|
||
resetTime: limitCheck.resetTime,
|
||
},
|
||
{
|
||
status: 429,
|
||
headers: {
|
||
'X-RateLimit-Limit': '50',
|
||
'X-RateLimit-Remaining': limitCheck.remaining.toString(),
|
||
'X-RateLimit-Reset': limitCheck.resetTime.toString(),
|
||
},
|
||
}
|
||
);
|
||
}
|
||
|
||
const contentType = request.headers.get('content-type') || '';
|
||
let message = '';
|
||
let history: { role: string; content: string }[] = [];
|
||
let files: any[] = [];
|
||
const fileContents: Array<{ name: string; type: string; content: string }> = [];
|
||
|
||
if (contentType.includes('multipart/form-data')) {
|
||
const formData = await request.formData();
|
||
message = (formData.get('message') as string) || '';
|
||
const historyStr = formData.get('history') as string;
|
||
if (historyStr) {
|
||
try {
|
||
history = JSON.parse(historyStr);
|
||
} catch {
|
||
history = [];
|
||
}
|
||
}
|
||
const fileCount = parseInt((formData.get('fileCount') as string) || '0');
|
||
for (let i = 0; i < fileCount; i++) {
|
||
const file = formData.get(`file_${i}`) as File;
|
||
if (file) {
|
||
files.push({ name: file.name, size: file.size, type: file.type });
|
||
try {
|
||
const { parseDocument } = await import('@/lib/ai/document-parser');
|
||
const arrayBuffer = await file.arrayBuffer();
|
||
const buffer = Buffer.from(arrayBuffer);
|
||
const content = await parseDocument(buffer, file.name, file.type);
|
||
fileContents.push({ name: file.name, type: file.type, content });
|
||
} catch (err) {
|
||
logError(`Ошибка парсинга файла ${file.name}`, err);
|
||
fileContents.push({
|
||
name: file.name,
|
||
type: file.type,
|
||
content: `[Не удалось прочитать файл: ${file.name}]`,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
const body = await request.json();
|
||
message = body.message || '';
|
||
history = body.history || [];
|
||
files = body.files || [];
|
||
}
|
||
|
||
message = sanitizeText(message);
|
||
if (Array.isArray(history)) {
|
||
history = history.map((msg: any) => ({
|
||
...msg,
|
||
content: sanitizeText(msg.content || ''),
|
||
}));
|
||
}
|
||
|
||
logAudit('AI_CHAT_REQUEST', 'ai-chat', {
|
||
identifier,
|
||
messageLength: message.length,
|
||
hasFiles: files?.length > 0,
|
||
});
|
||
|
||
const authHeader =
|
||
request.headers.get('authorization') ||
|
||
(request.cookies.get('auth-token')?.value ? `Bearer ${request.cookies.get('auth-token').value}` : null);
|
||
|
||
const dataContext =
|
||
'\n\nДоступные базы данных в системе:\n' +
|
||
'- Воздушные суда, нормативные документы, организации, риски, аудиты, чек-листы, заявки, пользователи, документы';
|
||
|
||
let prompt = message;
|
||
if (history?.length) {
|
||
const historyText = history
|
||
.map((m: any) => `${m.role === 'user' ? 'Пользователь' : 'Ассистент'}: ${m.content}`)
|
||
.join('\n\n');
|
||
prompt = `[Предыдущий диалог]\n${historyText}\n\n[Текущий вопрос]\n${message}`;
|
||
}
|
||
if (fileContents.length > 0) {
|
||
prompt +=
|
||
'\n\n[СОДЕРЖИМОЕ ФАЙЛОВ]\n' +
|
||
fileContents
|
||
.map(
|
||
(f) =>
|
||
`Файл: ${f.name}\n${f.content.substring(0, 5000)}${f.content.length > 5000 ? '...' : ''}`
|
||
)
|
||
.join('\n\n---\n\n') +
|
||
'\n[КОНЕЦ СОДЕРЖИМОГО ФАЙЛОВ]';
|
||
}
|
||
|
||
const backendChatUrl = `${BACKEND_URL.replace(/\/$/, '')}/api/v1/ai/chat`;
|
||
const res = await fetch(backendChatUrl, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
...(authHeader ? { Authorization: authHeader } : {}),
|
||
},
|
||
body: JSON.stringify({
|
||
prompt,
|
||
task: 'chat',
|
||
context: dataContext,
|
||
}),
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const errBody = await res.json().catch(() => ({}));
|
||
if (res.status === 503) {
|
||
return NextResponse.json({
|
||
response: `${generateMockResponse(message, files)}\n\n⚠️ Примечание: AI-сервис (Anthropic Claude) недоступен. Проверьте ANTHROPIC_API_KEY на бэкенде.`,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
}
|
||
recordPerformance('/api/ai-chat', Date.now() - startTime, res.status, { method: 'POST' });
|
||
return NextResponse.json(
|
||
{ error: errBody.detail || 'AI-сервис недоступен' },
|
||
{ status: res.status }
|
||
);
|
||
}
|
||
|
||
const data = await res.json();
|
||
recordPerformance('/api/ai-chat', Date.now() - startTime, 200, { method: 'POST' });
|
||
return NextResponse.json({
|
||
response: data.result || 'Извините, не удалось получить ответ.',
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
} catch (error: any) {
|
||
logError('Ошибка AI chat (Anthropic proxy)', error);
|
||
return handleError(error, { path: '/api/ai-chat', method: 'POST' });
|
||
}
|
||
}
|
||
|
||
function generateMockResponse(message: string, files: any[]): string {
|
||
const lower = message.toLowerCase();
|
||
if (lower.includes('добавить') || lower.includes('создать')) {
|
||
if (lower.includes('вс') || lower.includes('воздушн')) {
|
||
return 'Для добавления воздушного судна нужны: регистрационный номер, серийный номер, тип ВС, оператор, статус.';
|
||
}
|
||
if (lower.includes('риск')) {
|
||
return 'Для добавления риска укажите: название, уровень (Критический/Высокий/Средний/Низкий), категорию, ВС, описание.';
|
||
}
|
||
}
|
||
if (lower.includes('найти') || lower.includes('поиск')) {
|
||
return 'Могу помочь с поиском: ВС по номеру, риски по уровню, документы, аудиты. Уточните, что искать?';
|
||
}
|
||
if (files?.length) {
|
||
return `Получено ${files.length} файл(ов). Анализирую содержимое. При наличии структурированных данных предложу внести их в базу.`;
|
||
}
|
||
return 'Я AI-ассистент системы контроля лётной годности (Anthropic Claude). Могу помочь с данными, поиском, анализом файлов и отчётами. Что нужно?';
|
||
}
|