- Unify API: lib/api.ts uses /api/v1, inbox uses /api/inbox (rewrites) - Remove localhost refs: openapi, inbox page - Add rewrites: /api/inbox|tmc -> inbox-server, /api/v1 -> FastAPI - Add stub routes: knowledge/insights, recommendations, search, log-error - Transfer from PAPA: prompts (inspection, tmc), scripts, supabase, data/tmc-requests - Fix inbox-server: ORDER BY created_at, package.json - Remove redundant app/api/inbox/files route (rewrites handle it) - knowledge/ in gitignore (large PDFs) Co-authored-by: Cursor <cursoragent@cursor.com>
442 lines
23 KiB
TypeScript
442 lines
23 KiB
TypeScript
export const dynamic = "force-dynamic";
|
||
import { NextRequest, NextResponse } from 'next/server';
|
||
import OpenAI from 'openai';
|
||
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 { withTimeout, TIMEOUTS } from '@/lib/resilience/timeout';
|
||
import { retryWithBackoff, RETRY_CONFIGS } from '@/lib/resilience/retry';
|
||
import { circuitBreakers } from '@/lib/resilience/circuit-breaker';
|
||
import { bulkheads } from '@/lib/resilience/bulkhead';
|
||
import { overloadProtectors } from '@/lib/resilience/overload-protection';
|
||
import { recordPerformance } from '@/lib/monitoring/metrics';
|
||
|
||
const openai = process.env.OPENAI_API_KEY
|
||
? new OpenAI({
|
||
apiKey: process.env.OPENAI_API_KEY,
|
||
})
|
||
: null;
|
||
|
||
export async function POST(request: NextRequest) {
|
||
const startTime = Date.now();
|
||
try {
|
||
// Overload protection
|
||
if (!overloadProtectors.ai.check()) {
|
||
recordPerformance('/api/ai-chat', Date.now() - startTime, 503, { method: 'POST' });
|
||
return NextResponse.json(
|
||
{ error: 'AI service overloaded, please try again later' },
|
||
{ status: 503 }
|
||
);
|
||
}
|
||
|
||
// Rate limiting
|
||
const identifier = getRateLimitIdentifier(request);
|
||
const limitCheck = rateLimit(identifier, 50, 60000); // 50 запросов в минуту
|
||
|
||
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(),
|
||
},
|
||
}
|
||
);
|
||
}
|
||
|
||
// Проверяем, есть ли файлы в запросе (FormData)
|
||
const contentType = request.headers.get('content-type') || '';
|
||
let message = '';
|
||
let history: any[] = [];
|
||
let files: any[] = [];
|
||
const fileContents: Array<{ name: string; type: string; content: string }> = [];
|
||
|
||
if (contentType.includes('multipart/form-data')) {
|
||
// Обработка FormData с файлами
|
||
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 (e) {
|
||
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,
|
||
});
|
||
|
||
// Читаем содержимое файла
|
||
const arrayBuffer = await file.arrayBuffer();
|
||
const buffer = Buffer.from(arrayBuffer);
|
||
|
||
// Парсим файл в зависимости от типа
|
||
try {
|
||
const { parseDocument } = await import('@/lib/ai/document-parser');
|
||
const content = await parseDocument(buffer, file.name, file.type);
|
||
fileContents.push({
|
||
name: file.name,
|
||
type: file.type,
|
||
content: content,
|
||
});
|
||
} catch (error) {
|
||
logError(`Ошибка парсинга файла ${file.name}`, error);
|
||
fileContents.push({
|
||
name: file.name,
|
||
type: file.type,
|
||
content: `[Не удалось прочитать файл: ${file.name}]`,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
// Обычный JSON запрос
|
||
const body = await request.json();
|
||
message = body.message || '';
|
||
history = body.history || [];
|
||
files = body.files || [];
|
||
}
|
||
|
||
// Санитизация входных данных
|
||
message = sanitizeText(message);
|
||
if (history && 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 && files.length > 0,
|
||
});
|
||
|
||
if (!process.env.OPENAI_API_KEY) {
|
||
return NextResponse.json(
|
||
{ error: 'OpenAI API ключ не настроен', response: generateMockResponse(message, files) },
|
||
{ status: 200 } // Возвращаем 200, но используем заглушку
|
||
);
|
||
}
|
||
|
||
try {
|
||
// Получаем доступные данные из системы для контекста
|
||
const dataContext = '\n\nДоступные базы данных в системе:\n' +
|
||
'- Воздушные суда: данные из реестра\n' +
|
||
'- Нормативные документы: 19+ документов (ICAO, EASA, FAA, МАК, АРМАК, ФАП)\n' +
|
||
'- Организации: авиакомпании и операторы\n' +
|
||
'- Риски: критические, высокие, средние, низкие\n' +
|
||
'- Аудиты: плановые и внеплановые\n' +
|
||
'- Чек-листы: предполетные осмотры, техническое обслуживание\n' +
|
||
'- Заявки: сертификация, разрешения\n' +
|
||
'- Пользователи: администраторы, инженеры, аудиторы\n' +
|
||
'- Документы: сертификаты, техническая документация, отчёты';
|
||
|
||
// Формируем системное сообщение с полной информацией о системе
|
||
const systemMessage = {
|
||
role: 'system' as const,
|
||
content: `Ты ИИ агент системы контроля лётной годности воздушных судов. ` +
|
||
`Твоя задача - помогать пользователям с управлением системой, анализом документов, ` +
|
||
`внесением данных в базу, поиском информации. ` +
|
||
`Отвечай на русском языке профессионально, точно и дружелюбно.\n\n` +
|
||
`У ТЕБЯ ЕСТЬ ПОЛНЫЙ ДОСТУП КО ВСЕМ БАЗАМ ДАННЫХ СИСТЕМЫ И ВОЗМОЖНОСТЬ ВНОСИТЬ ДАННЫЕ:\n\n` +
|
||
`ВАЖНО: Когда пользователь загружает документы (PDF, XLS, CSV, изображения), ` +
|
||
`ты должен проанализировать их содержимое и автоматически извлечь данные для внесения в систему. ` +
|
||
`Если в документах есть информация о воздушных судах, аудитах или чек-листах, ` +
|
||
`предложи пользователю внести эти данные в соответствующие карточки.\n\n` +
|
||
`1. ВОЗДУШНЫЕ СУДА (aircraft):\n` +
|
||
` - Регистрационные номера, типы, операторы, статусы\n` +
|
||
` - Для получения данных используй: POST /api/ai-data с dataType: "aircraft"\n` +
|
||
` - Можешь фильтровать по: registrationNumber, operator, type, status\n\n` +
|
||
`2. НОРМАТИВНЫЕ ДОКУМЕНТЫ (regulations):\n` +
|
||
` - Конвенция о международной гражданской авиации (Chicago Convention) с 19 приложениями (ICAO)\n` +
|
||
` - Документы ICAO (Annexes)\n` +
|
||
` - Правила EASA (Европейское агентство по безопасности авиации)\n` +
|
||
` - Правила FAA (Федеральное управление гражданской авиации США)\n` +
|
||
` - Документы МАК (Межгосударственный авиационный комитет)\n` +
|
||
` - Документы АРМАК (Агентство по регулированию гражданской авиации)\n` +
|
||
` - Авиационные правила РФ (ФАП-128, ФАП-145, ФАП-147, ФАП-21, ФАП-25, ФАП-29, ФАП-39, ФАП-50)\n` +
|
||
` - Воздушный кодекс РФ\n` +
|
||
` - Для получения данных используй: POST /api/ai-data с dataType: "regulations"\n\n` +
|
||
`3. ОРГАНИЗАЦИИ (organizations):\n` +
|
||
` - Авиакомпании и операторы\n` +
|
||
` - Для получения данных используй: POST /api/ai-data с dataType: "organizations"\n\n` +
|
||
`4. РИСКИ (risks):\n` +
|
||
` - Уровни: Критический, Высокий, Средний, Низкий\n` +
|
||
` - Категории, статусы, привязка к ВС\n` +
|
||
` - Для получения данных используй: POST /api/ai-data с dataType: "risks"\n\n` +
|
||
|
||
'5. АУДИТЫ (audits):\n' +
|
||
' - Плановые и внеплановые аудиты\n' +
|
||
' - Статусы: Запланирован, В процессе, Завершён\n' +
|
||
' - Для получения данных используй: POST /api/ai-data с dataType: "audits"\n\n' +
|
||
|
||
'6. ЧЕК-ЛИСТЫ (checklists):\n' +
|
||
' - Предполетные осмотры, техническое обслуживание\n' +
|
||
' - Для получения данных используй: POST /api/ai-data с dataType: "checklists"\n\n' +
|
||
|
||
'7. ЗАЯВКИ (applications):\n' +
|
||
' - Сертификация, разрешения на эксплуатацию\n' +
|
||
' - Для получения данных используй: POST /api/ai-data с dataType: "applications"\n\n' +
|
||
|
||
'8. ПОЛЬЗОВАТЕЛИ (users):\n' +
|
||
' - Администраторы, инженеры, аудиторы\n' +
|
||
' - Для получения данных используй: POST /api/ai-data с dataType: "users"\n\n' +
|
||
|
||
'9. ДОКУМЕНТЫ (documents):\n' +
|
||
' - Сертификаты, техническая документация, отчёты\n' +
|
||
' - Для получения данных используй: POST /api/ai-data с dataType: "documents"\n\n' +
|
||
|
||
'КАК РАБОТАТЬ С ДАННЫМИ:\n' +
|
||
'- Когда пользователь спрашивает о данных, автоматически запрашивай их через /api/ai-data\n' +
|
||
'- Используй полученные данные для точных ответов\n' +
|
||
'- При поиске применяй фильтры для уточнения результатов\n' +
|
||
'- Если пользователь просит добавить данные, уточни необходимую информацию и предложи структурированный формат\n' +
|
||
'- При ответах на вопросы о нормативных требованиях ссылайся на соответствующие документы\n\n' +
|
||
|
||
'ПРИМЕРЫ ЗАПРОСОВ:\n' +
|
||
'- "Сколько воздушных судов у Аэрофлота?" → запроси aircraft с фильтром operator: "Аэрофлот"\n' +
|
||
'- "Какие критические риски есть?" → запроси risks с фильтром level: "Критический"\n' +
|
||
'- "Покажи все аудиты" → запроси audits\n' +
|
||
'- "Найди ВС RA-12345" → запроси aircraft с фильтром registrationNumber: "RA-12345"\n\n' +
|
||
|
||
dataContext
|
||
};
|
||
|
||
// Анализируем запрос пользователя и определяем, нужно ли запросить данные
|
||
const userMessageContent = message;
|
||
let dataToInclude = '';
|
||
|
||
// Определяем, запрашивает ли пользователь данные
|
||
const lowerMessage = message.toLowerCase();
|
||
const dataKeywords: Record<string, string[]> = {
|
||
aircraft: ['вс', 'воздушн', 'самолёт', 'самолет', 'aircraft', 'регистрац'],
|
||
risks: ['риск', 'риски', 'опасн', 'проблем'],
|
||
audits: ['аудит', 'проверк', 'инспекц'],
|
||
organizations: ['организац', 'компани', 'авиакомпани', 'оператор'],
|
||
checklists: ['чек-лист', 'чеклист', 'осмотр', 'проверк'],
|
||
applications: ['заявк', 'сертификац', 'разрешен'],
|
||
users: ['пользовател', 'пользователь', 'пользователи', 'user'],
|
||
documents: ['документ', 'сертификат', 'отчёт', 'отчет'],
|
||
regulations: ['норматив', 'правил', 'требован', 'стандарт', 'fap', 'icao', 'easa', 'faa'],
|
||
};
|
||
|
||
// Определяем тип данных для запроса
|
||
let requestedDataType: string | null = null;
|
||
for (const [dataType, keywords] of Object.entries(dataKeywords)) {
|
||
if (keywords.some(keyword => lowerMessage.includes(keyword))) {
|
||
requestedDataType = dataType;
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Если запрашиваются данные, получаем их через внутренний вызов
|
||
if (requestedDataType) {
|
||
try {
|
||
// Используем внутренний импорт для получения данных
|
||
const { getDataForAI } = await import('../ai-data-helper');
|
||
const dataResult = await getDataForAI(requestedDataType, extractFilters(message));
|
||
|
||
if (dataResult && dataResult.data) {
|
||
dataToInclude = `\n\n[ДАННЫЕ ИЗ СИСТЕМЫ]\n` +
|
||
`Тип: ${dataResult.dataType}\n` +
|
||
`Количество записей: ${dataResult.count}\n` +
|
||
`Данные: ${JSON.stringify(dataResult.data.slice(0, 10), null, 2)}${dataResult.count > 10 ? `\n... и еще ${dataResult.count - 10} записей` : ''}\n` +
|
||
`[КОНЕЦ ДАННЫХ]`;
|
||
}
|
||
} catch (error) {
|
||
logWarn('Не удалось получить данные для ИИ агента', { error: String(error) });
|
||
// Продолжаем без данных
|
||
}
|
||
}
|
||
|
||
// Формируем массив сообщений для OpenAI
|
||
const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
|
||
systemMessage,
|
||
...history.map((msg: any) => ({
|
||
role: msg.role === 'user' ? 'user' as const : 'assistant' as const,
|
||
content: msg.content,
|
||
})),
|
||
{
|
||
role: 'user' as const,
|
||
content: `${userMessageContent}${files && files.length > 0
|
||
? `\n\nПрикреплены файлы: ${files.map((f: any) => f.name).join(', ')}`
|
||
: ''}${fileContents.length > 0
|
||
? `\n\n[СОДЕРЖИМОЕ ФАЙЛОВ]\n${fileContents.map(f => `Файл: ${f.name}\n${f.content.substring(0, 5000)}${f.content.length > 5000 ? '...' : ''}`).join('\n\n---\n\n')}\n[КОНЕЦ СОДЕРЖИМОГО ФАЙЛОВ]`
|
||
: ''}${dataToInclude}`,
|
||
},
|
||
];
|
||
|
||
// Запрос к OpenAI API
|
||
if (!openai) {
|
||
throw new Error('OpenAI API ключ не настроен');
|
||
}
|
||
if (!openai) {
|
||
return NextResponse.json(
|
||
{ error: 'OpenAI API key not configured' },
|
||
{ status: 503 }
|
||
);
|
||
}
|
||
|
||
// Используем bulkhead, circuit breaker, retry и timeout для устойчивости
|
||
const completion = await bulkheads.ai.execute(async () => {
|
||
return circuitBreakers.openai.execute(async () => {
|
||
return retryWithBackoff(
|
||
() =>
|
||
withTimeout(
|
||
openai.chat.completions.create({
|
||
model: 'gpt-4o-mini', // Используем более доступную модель
|
||
messages: messages,
|
||
temperature: 0.7,
|
||
max_tokens: 1000,
|
||
}),
|
||
TIMEOUTS.OPENAI_API,
|
||
'OpenAI API request timed out'
|
||
),
|
||
{
|
||
...RETRY_CONFIGS.OPENAI_API,
|
||
onRetry: (attempt, error) => {
|
||
logWarn(`OpenAI chat retry attempt ${attempt}`, { error: error.message });
|
||
},
|
||
}
|
||
);
|
||
});
|
||
});
|
||
|
||
const aiResponse = completion.choices[0]?.message?.content || 'Извините, не удалось получить ответ.';
|
||
|
||
// Запись метрики производительности
|
||
const duration = Date.now() - startTime;
|
||
recordPerformance('/api/ai-chat', duration, 200, { method: 'POST' });
|
||
|
||
return NextResponse.json({
|
||
response: aiResponse,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
} catch (openaiError: any) {
|
||
logError('Ошибка OpenAI API', openaiError);
|
||
|
||
// Если ошибка API, возвращаем заглушку
|
||
return NextResponse.json({
|
||
response: `${generateMockResponse(message, files)}\n\n⚠️ Примечание: OpenAI API временно недоступен, используется локальная обработка.`,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
}
|
||
} catch (error: any) {
|
||
return handleError(error, {
|
||
path: '/api/ai-chat',
|
||
method: 'POST',
|
||
});
|
||
}
|
||
}
|
||
|
||
// Функция для извлечения фильтров из запроса пользователя
|
||
function extractFilters(message: string): any {
|
||
const filters: any = {};
|
||
const lowerMessage = message.toLowerCase();
|
||
|
||
// Извлечение регистрационного номера ВС
|
||
const regNumberMatch = message.match(/RA-[\dA-Z]+/i);
|
||
if (regNumberMatch) {
|
||
filters.registrationNumber = regNumberMatch[0];
|
||
}
|
||
|
||
// Извлечение оператора/компании
|
||
const companies = ['аэрофлот', 's7', 'уральск', 'победа', 'нордавиа'];
|
||
for (const company of companies) {
|
||
if (lowerMessage.includes(company)) {
|
||
filters.operator = company;
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Извлечение уровня риска
|
||
if (lowerMessage.includes('критическ')) {
|
||
filters.level = 'Критический';
|
||
} else if (lowerMessage.includes('высок')) {
|
||
filters.level = 'Высокий';
|
||
} else if (lowerMessage.includes('средн')) {
|
||
filters.level = 'Средний';
|
||
} else if (lowerMessage.includes('низк')) {
|
||
filters.level = 'Низкий';
|
||
}
|
||
|
||
// Извлечение статуса
|
||
if (lowerMessage.includes('активн')) {
|
||
filters.status = 'Активен';
|
||
} else if (lowerMessage.includes('обслуживан')) {
|
||
filters.status = 'На обслуживании';
|
||
}
|
||
|
||
return Object.keys(filters).length > 0 ? filters : undefined;
|
||
}
|
||
|
||
// Функция-заглушка для случаев, когда API недоступен
|
||
function generateMockResponse(message: string, files: any[]): string {
|
||
const lowerMessage = message.toLowerCase();
|
||
|
||
if (lowerMessage.includes('добавить') || lowerMessage.includes('создать')) {
|
||
if (lowerMessage.includes('вс') || lowerMessage.includes('воздушн')) {
|
||
return `Для добавления воздушного судна мне нужна следующая информация:\n\n` +
|
||
`1. Регистрационный номер (например: RA-12345)\n` +
|
||
`2. Серийный номер\n` +
|
||
`3. Тип ВС (например: Boeing 737-800)\n` +
|
||
`4. Оператор (название компании)\n` +
|
||
`5. Статус (Активен/На обслуживании)\n\n` +
|
||
`Предоставьте эти данные в структурированном виде, и я внесу их в базу.`;
|
||
}
|
||
if (lowerMessage.includes('риск')) {
|
||
return `Для добавления риска требуется:\n\n` +
|
||
`1. Название риска\n` +
|
||
`2. Уровень: Критический/Высокий/Средний/Низкий\n` +
|
||
`3. Категория\n` +
|
||
`4. ВС (регистрационный номер)\n` +
|
||
`5. Описание\n\n` +
|
||
`Укажите эти данные для автоматического внесения.`;
|
||
}
|
||
}
|
||
|
||
if (lowerMessage.includes('найти') || lowerMessage.includes('поиск')) {
|
||
return `Я могу помочь с поиском:\n\n` +
|
||
`• Воздушное судно по номеру\n` +
|
||
`• Риски по уровню или категории\n` +
|
||
`• Документы по типу или ВС\n` +
|
||
`• Аудиты по организации или дате\n\n` +
|
||
`Уточните, что именно нужно найти?`;
|
||
}
|
||
|
||
if (files && files.length > 0) {
|
||
return `Получено ${files.length} файл(ов). Анализирую содержимое...\n\n` +
|
||
`Если в файлах есть структурированные данные (списки ВС, риски, документы), ` +
|
||
`я могу автоматически извлечь их и предложить внести в базу данных.\n\n` +
|
||
`Продолжить анализ?`;
|
||
}
|
||
|
||
return `Я ИИ агент системы контроля лётной годности. Могу помочь с:\n\n` +
|
||
`✅ Добавлением данных в базу (ВС, риски, документы, аудиты)\n` +
|
||
`✅ Поиском информации по системе\n` +
|
||
`✅ Анализом прикрепленных файлов\n` +
|
||
`✅ Генерацией отчетов\n` +
|
||
`✅ Ответами на вопросы о системе\n\n` +
|
||
`Что именно вам нужно?`;
|
||
}
|