klg-asutk-app/app/api/ai-chat/route.ts
Yuriy 44b14cc4fd feat: все AI-функции переведены на Anthropic Claude API
- 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>
2026-02-15 15:51:59 +03:00

182 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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). Могу помочь с данными, поиском, анализом файлов и отчётами. Что нужно?';
}