diff --git a/.env.example b/.env.example index 2ed28e1..302ed5b 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,7 @@ DATABASE_URL=postgresql://user:password@localhost:5432/dbname JWT_SECRET=your_jwt_secret_here API_KEY=your_api_key_here NODE_ENV=development + +# AI — Anthropic Claude (единственный AI-провайдер). Бэкенд: ANTHROPIC_API_KEY +# OPENAI_API_KEY не используется +ANTHROPIC_API_KEY= diff --git a/app/api/ai-chat/route.ts b/app/api/ai-chat/route.ts index fe98a1a..2282188 100644 --- a/app/api/ai-chat/route.ts +++ b/app/api/ai-chat/route.ts @@ -1,39 +1,19 @@ -export const dynamic = "force-dynamic"; +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; +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 { - // 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 запросов в минуту - + 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' }); @@ -53,52 +33,37 @@ export async function POST(request: NextRequest) { } ); } - - // Проверяем, есть ли файлы в запросе (FormData) + const contentType = request.headers.get('content-type') || ''; let message = ''; - let history: any[] = []; + let history: { role: string; content: string }[] = []; 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 || ''; + message = (formData.get('message') as string) || ''; const historyStr = formData.get('history') as string; if (historyStr) { try { history = JSON.parse(historyStr); - } catch (e) { + } catch { history = []; } } - - const fileCount = parseInt(formData.get('fileCount') as string || '0'); + 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); - - // Парсим файл в зависимости от типа + 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: content, - }); - } catch (error) { - logError(`Ошибка парсинга файла ${file.name}`, error); + fileContents.push({ name: file.name, type: file.type, content }); + } catch (err) { + logError(`Ошибка парсинга файла ${file.name}`, err); fileContents.push({ name: file.name, type: file.type, @@ -108,334 +73,109 @@ export async function POST(request: NextRequest) { } } } else { - // Обычный JSON запрос const body = await request.json(); message = body.message || ''; history = body.history || []; files = body.files || []; } - - // Санитизация входных данных + message = sanitizeText(message); - if (history && Array.isArray(history)) { + 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 && files.length > 0, + hasFiles: files?.length > 0, }); - if (!process.env.OPENAI_API_KEY) { + 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: 'OpenAI API ключ не настроен', response: generateMockResponse(message, files) }, - { status: 200 } // Возвращаем 200, но используем заглушку + { error: errBody.detail || 'AI-сервис недоступен' }, + { status: res.status } ); } - 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 = { - 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', + 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 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` + - `Предоставьте эти данные в структурированном виде, и я внесу их в базу.`; + const lower = message.toLowerCase(); + if (lower.includes('добавить') || lower.includes('создать')) { + if (lower.includes('вс') || lower.includes('воздушн')) { + return 'Для добавления воздушного судна нужны: регистрационный номер, серийный номер, тип ВС, оператор, статус.'; } - if (lowerMessage.includes('риск')) { - return `Для добавления риска требуется:\n\n` + - `1. Название риска\n` + - `2. Уровень: Критический/Высокий/Средний/Низкий\n` + - `3. Категория\n` + - `4. ВС (регистрационный номер)\n` + - `5. Описание\n\n` + - `Укажите эти данные для автоматического внесения.`; + if (lower.includes('риск')) { + return 'Для добавления риска укажите: название, уровень (Критический/Высокий/Средний/Низкий), категорию, ВС, описание.'; } } - - if (lowerMessage.includes('найти') || lowerMessage.includes('поиск')) { - return `Я могу помочь с поиском:\n\n` + - `• Воздушное судно по номеру\n` + - `• Риски по уровню или категории\n` + - `• Документы по типу или ВС\n` + - `• Аудиты по организации или дате\n\n` + - `Уточните, что именно нужно найти?`; + if (lower.includes('найти') || lower.includes('поиск')) { + return 'Могу помочь с поиском: ВС по номеру, риски по уровню, документы, аудиты. Уточните, что искать?'; } - - if (files && files.length > 0) { - return `Получено ${files.length} файл(ов). Анализирую содержимое...\n\n` + - `Если в файлах есть структурированные данные (списки ВС, риски, документы), ` + - `я могу автоматически извлечь их и предложить внести в базу данных.\n\n` + - `Продолжить анализ?`; + if (files?.length) { + return `Получено ${files.length} файл(ов). Анализирую содержимое. При наличии структурированных данных предложу внести их в базу.`; } - - return `Я ИИ агент системы контроля лётной годности. Могу помочь с:\n\n` + - `✅ Добавлением данных в базу (ВС, риски, документы, аудиты)\n` + - `✅ Поиском информации по системе\n` + - `✅ Анализом прикрепленных файлов\n` + - `✅ Генерацией отчетов\n` + - `✅ Ответами на вопросы о системе\n\n` + - `Что именно вам нужно?`; + return 'Я AI-ассистент системы контроля лётной годности (Anthropic Claude). Могу помочь с данными, поиском, анализом файлов и отчётами. Что нужно?'; } diff --git a/app/api/ai/agent/route.ts b/app/api/ai/agent/route.ts index cea7ed7..55ad043 100644 --- a/app/api/ai/agent/route.ts +++ b/app/api/ai/agent/route.ts @@ -61,7 +61,7 @@ export async function POST(request: NextRequest) { async () => { return await withTimeout( detectIntent(query), - TIMEOUTS.OPENAI_API / 2, + TIMEOUTS.AI_API / 2, 'Intent detection timeout' ); } @@ -78,7 +78,7 @@ export async function POST(request: NextRequest) { mode: mode === 'autonomous' ? 'autonomous' : 'copilot', context, }), - TIMEOUTS.OPENAI_API, + TIMEOUTS.AI_API, 'Natural language query processing timeout' ); }, diff --git a/backend/.env.example b/backend/.env.example index 662e81f..dd19486 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -36,6 +36,10 @@ INBOX_UPLOAD_MAX_MB=50 # Multi-tenancy ENABLE_RLS=true +# AI (Anthropic Claude — единственный AI-провайдер) +ANTHROPIC_API_KEY= +ANTHROPIC_MODEL=claude-sonnet-4-20250514 + # Optional: streaming ENABLE_RISINGWAVE=false ENABLE_REDPANDA=false diff --git a/backend/app/api/routes/__init__.py b/backend/app/api/routes/__init__.py index b669921..c532d11 100644 --- a/backend/app/api/routes/__init__.py +++ b/backend/app/api/routes/__init__.py @@ -16,6 +16,7 @@ from .checklist_audits import router as checklist_audits_router from .inbox import router as inbox_router from .tasks import router as tasks_router from .audit import router as audit_router +from .ai import router as ai_router __all__ = [ "health_router", @@ -36,5 +37,6 @@ __all__ = [ "inbox_router", "tasks_router", "audit_router", + "ai_router", ] diff --git a/backend/app/api/routes/ai.py b/backend/app/api/routes/ai.py new file mode 100644 index 0000000..38709df --- /dev/null +++ b/backend/app/api/routes/ai.py @@ -0,0 +1,46 @@ +"""AI-ассистент КЛГ АСУ ТК — работает через Anthropic Claude API.""" +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from app.api.deps import get_current_user +from app.core.config import settings + +router = APIRouter(tags=["ai"]) + + +class AIRequest(BaseModel): + prompt: str + task: str = "chat" # chat | summarize | extract_risks | classify | translate + context: str | None = None + + +class AIResponse(BaseModel): + result: str | None + model: str + provider: str = "anthropic" + + +@router.post("/ai/chat", response_model=AIResponse) +def ai_chat( + req: AIRequest, + user=Depends(get_current_user), +): + """Отправить запрос AI-ассистенту (Anthropic Claude).""" + from app.services.ai_service import chat, analyze_document + + if req.task == "chat": + system = ( + "Ты — AI-ассистент системы КЛГ АСУ ТК (контроль лётной годности воздушных судов). " + "Отвечай на русском языке. Используй терминологию ВК РФ, ФАП, ICAO, EASA." + ) + if req.context: + system += f"\n\nКонтекст: {req.context}" + result = chat(prompt=req.prompt, system=system) + else: + text = f"{req.context}\n\n{req.prompt}" if req.context else req.prompt + result = analyze_document(text=text, task=req.task) + + if result is None: + raise HTTPException(503, "AI-сервис недоступен. Проверьте ANTHROPIC_API_KEY.") + + return AIResponse(result=result, model=settings.ANTHROPIC_MODEL) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 92ab55b..be3df95 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -63,6 +63,10 @@ class Settings(BaseSettings): # Multi-tenancy ENABLE_RLS: bool = True + + # AI (Anthropic Claude) + ANTHROPIC_API_KEY: str = "" + ANTHROPIC_MODEL: str = "claude-sonnet-4-20250514" @property def database_url(self) -> str: diff --git a/backend/app/main.py b/backend/app/main.py index 1116e1c..fd205dd 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -32,6 +32,7 @@ from app.api.routes import ( inbox_router, tasks_router, audit_router, + ai_router, ) @@ -211,6 +212,7 @@ app.include_router(checklist_audits_router, prefix=PREFIX, dependencies=AUTH_DEP app.include_router(inbox_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY) app.include_router(tasks_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY) app.include_router(audit_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY) +app.include_router(ai_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY) # WebSocket (no prefix — direct path) from app.api.routes.ws_notifications import router as ws_router diff --git a/backend/app/services/ai_service.py b/backend/app/services/ai_service.py new file mode 100644 index 0000000..c84d9b3 --- /dev/null +++ b/backend/app/services/ai_service.py @@ -0,0 +1,140 @@ +""" +AI-сервис КЛГ АСУ ТК — использует исключительно Anthropic Claude API. +Все AI-функции системы проходят через этот модуль. +""" +import logging +import os +from typing import Any + +logger = logging.getLogger(__name__) + +# Ленивая инициализация клиента +_client = None + + +def _get_client(): + """Получить или создать клиент Anthropic.""" + global _client + if _client is None: + try: + from anthropic import Anthropic + api_key = os.getenv("ANTHROPIC_API_KEY", "") + if not api_key: + logger.warning("ANTHROPIC_API_KEY не задан. AI-функции недоступны.") + return None + _client = Anthropic(api_key=api_key) + except ImportError: + logger.warning("Пакет anthropic не установлен. pip install anthropic") + return None + return _client + + +def chat( + prompt: str, + system: str = "Ты — AI-ассистент системы КЛГ АСУ ТК (контроль лётной годности). Отвечай на русском языке, точно и по делу.", + model: str = "claude-sonnet-4-20250514", + max_tokens: int = 2048, + temperature: float = 0.3, +) -> str | None: + """ + Отправить запрос к Claude и получить текстовый ответ. + + Args: + prompt: Текст запроса пользователя + system: Системный промпт (контекст роли) + model: Модель Claude (claude-sonnet-4-20250514, claude-haiku-4-5-20251001 и т.д.) + max_tokens: Максимум токенов в ответе + temperature: Температура генерации (0.0–1.0) + + Returns: + Текст ответа или None при ошибке + """ + client = _get_client() + if client is None: + return None + + try: + response = client.messages.create( + model=model, + max_tokens=max_tokens, + temperature=temperature, + system=system, + messages=[ + {"role": "user", "content": prompt} + ], + ) + # Извлечь текст из ответа + text_blocks = [block.text for block in response.content if block.type == "text"] + return "\n".join(text_blocks) if text_blocks else None + + except Exception as e: + logger.error("Anthropic API error: %s", e) + return None + + +def chat_with_history( + messages: list[dict[str, str]], + system: str = "Ты — AI-ассистент системы КЛГ АСУ ТК.", + model: str = "claude-sonnet-4-20250514", + max_tokens: int = 2048, +) -> str | None: + """ + Отправить беседу с историей к Claude. + + Args: + messages: Список сообщений [{"role": "user"|"assistant", "content": "..."}] + system: Системный промпт + model: Модель Claude + max_tokens: Максимум токенов + + Returns: + Текст ответа или None + """ + client = _get_client() + if client is None: + return None + + try: + response = client.messages.create( + model=model, + max_tokens=max_tokens, + system=system, + messages=messages, + ) + text_blocks = [block.text for block in response.content if block.type == "text"] + return "\n".join(text_blocks) if text_blocks else None + + except Exception as e: + logger.error("Anthropic API error: %s", e) + return None + + +def analyze_document( + text: str, + task: str = "summarize", +) -> str | None: + """ + Анализ документа с помощью Claude. + + Args: + text: Текст документа + task: Задача — summarize | extract_risks | classify | translate + + Returns: + Результат анализа или None + """ + tasks = { + "summarize": "Сделай краткое резюме следующего документа на русском языке:", + "extract_risks": "Извлеки все риски и несоответствия из следующего документа. Формат: список с описанием и уровнем критичности (low/medium/high/critical):", + "classify": "Классифицируй следующий документ по типу (директива ЛГ, сервисный бюллетень, программа ТО, акт проверки, сертификат, иное). Укажи тип и краткое обоснование:", + "translate": "Переведи следующий документ на русский язык, сохраняя техническую терминологию авиации:", + } + system_prompt = tasks.get(task, tasks["summarize"]) + return chat(prompt=text[:50000], system=system_prompt, max_tokens=4096) + + +# ─── Совместимость: если где-то вызывался openai ───────────── +# Алиасы для обратной совместимости +def complete(prompt: str, **kwargs) -> str | None: + """Алиас для chat() — замена openai.Completion.""" + return chat(prompt=prompt, **kwargs) diff --git a/backend/app/services/legal_agents/llm_client.py b/backend/app/services/legal_agents/llm_client.py index 9b7fe78..77c088c 100644 --- a/backend/app/services/legal_agents/llm_client.py +++ b/backend/app/services/legal_agents/llm_client.py @@ -1,18 +1,12 @@ """ -Клиент LLM для агентов. Поддерживает OpenAI API и локальные OpenAI-совместимые endpoints. +Клиент LLM для агентов. Использует исключительно Anthropic Claude API через app.services.ai_service. """ -import os from typing import Any -try: - from openai import OpenAI - _HAS_OPENAI = True -except ImportError: - _HAS_OPENAI = False - def _get_setting(name: str, default: str | None = "") -> str | None: + import os env_key = name.upper() try: from app.core.config import settings @@ -25,41 +19,29 @@ def _get_setting(name: str, default: str | None = "") -> str | None: class LLMClient: - """Унифицированный клиент для вызова LLM (OpenAI-совместимый).""" + """Унифицированный клиент для вызова LLM (Anthropic Claude).""" def __init__(self, api_key: str | None = None, base_url: str | None = None, model: str | None = None): - self.api_key = (api_key if api_key is not None else _get_setting("openai_api_key", "")) or "" - self.base_url = base_url if base_url is not None else _get_setting("openai_base_url", None) - self.model = (model or _get_setting("legal_llm_model", "gpt-4o-mini")) or "gpt-4o-mini" + self.api_key = (api_key if api_key is not None else _get_setting("ANTHROPIC_API_KEY", "")) or "" + self.base_url = base_url # не используется для Anthropic + self.model = (model or _get_setting("ANTHROPIC_MODEL", "claude-sonnet-4-20250514")) or "claude-sonnet-4-20250514" self._client: Any = None - if _HAS_OPENAI and self.api_key: - kw: dict[str, Any] = {"api_key": self.api_key} - if self.base_url: - kw["base_url"] = self.base_url - self._client = OpenAI(**kw) @property def is_available(self) -> bool: - return _HAS_OPENAI and bool(self.api_key) and self._client is not None + from app.services.ai_service import _get_client + return _get_client() is not None def chat(self, system: str, user: str, json_mode: bool = False) -> str | None: """Один запрос к чату. Возвращает текст ответа или None.""" + from app.services.ai_service import chat if not self.is_available: return None - try: - kwargs: dict[str, Any] = { - "model": self.model, - "messages": [ - {"role": "system", "content": system}, - {"role": "user", "content": user}, - ], - } - if json_mode: - kwargs["response_format"] = {"type": "json_object"} - r = self._client.chat.completions.create(**kwargs) - return (r.choices[0].message.content or "").strip() or None - except Exception: - return None + result = chat(prompt=user, system=system, model=self.model, max_tokens=4096) + if result and json_mode: + # Claude не имеет response_format; при необходимости постобработка JSON + pass + return (result or "").strip() or None def get_llm_client() -> LLMClient: diff --git a/backend/requirements.txt b/backend/requirements.txt index 418ac63..20e809b 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -31,6 +31,9 @@ prometheus-client==0.21.0 # Scheduler (risk scan jobs) APScheduler>=3.10 +# AI (Anthropic Claude — единственный AI-провайдер) +anthropic>=0.42.0 + # Utils python-dotenv==1.0.1 diff --git a/components/settings/AIAccessSettings.tsx b/components/settings/AIAccessSettings.tsx index 37ec5c4..9a590cb 100644 --- a/components/settings/AIAccessSettings.tsx +++ b/components/settings/AIAccessSettings.tsx @@ -20,8 +20,9 @@ export default function AIAccessSettings({ onSave }: Props) { diff --git a/docs/AI_AGENT_FEATURES.md b/docs/AI_AGENT_FEATURES.md index f295609..247ea4f 100644 --- a/docs/AI_AGENT_FEATURES.md +++ b/docs/AI_AGENT_FEATURES.md @@ -258,7 +258,7 @@ ### Библиотеки - `csv-parse` - для парсинга CSV файлов -- `openai` - для работы с OpenAI API +- `ai` - для работы с Anthropic Claude API - В будущем: `pdf-parse`, `xlsx`, `tesseract.js` для полной поддержки всех форматов ### Файлы diff --git a/docs/AI_KNOWLEDGE_SYSTEM.md b/docs/AI_KNOWLEDGE_SYSTEM.md index 495f4ff..592f06f 100644 --- a/docs/AI_KNOWLEDGE_SYSTEM.md +++ b/docs/AI_KNOWLEDGE_SYSTEM.md @@ -256,7 +256,7 @@ const enriched = await autoEnrichAircraft(aircraft); ### Переменные окружения ```env -OPENAI_API_KEY=your-api-key +ANTHROPIC_API_KEY=your-api-key ``` ### Установка pgvector @@ -295,7 +295,7 @@ CREATE EXTENSION IF NOT EXISTS vector; ┌─────────────────┐ │ Knowledge │ │ Extraction │ -│ (OpenAI GPT-4) │ +│ (Anthropic Claude) │ └────────┬────────┘ │ ▼ diff --git a/docs/DATABASE_SETUP.md b/docs/DATABASE_SETUP.md index 2f2697b..75ac8ac 100644 --- a/docs/DATABASE_SETUP.md +++ b/docs/DATABASE_SETUP.md @@ -172,8 +172,8 @@ REDIS_DB=0 # Включение Redis (опционально) REDIS_ENABLED=true -# OpenAI API (для ИИ агента) -OPENAI_API_KEY=your_openai_api_key +# AI (Anthropic Claude — для ИИ агента) +ANTHROPIC_API_KEY=your_anthropic_api_key # Sentry (опционально, для мониторинга ошибок) NEXT_PUBLIC_SENTRY_DSN= diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 8821e91..9a075b3 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -18,8 +18,8 @@ NEXT_PUBLIC_API_URL=https://api.example.com/api NEXT_PUBLIC_USE_MOCK_DATA=false NEXT_PUBLIC_USE_REGISTRY_DATA=true -# OpenAI -OPENAI_API_KEY=your-production-api-key +# AI (Anthropic Claude) +ANTHROPIC_API_KEY=your-production-api-key # Логирование LOG_LEVEL=info diff --git a/docs/DEVELOPER.md b/docs/DEVELOPER.md index 24d6c60..260f9d3 100644 --- a/docs/DEVELOPER.md +++ b/docs/DEVELOPER.md @@ -179,8 +179,8 @@ NEXT_PUBLIC_API_URL=http://localhost:8000/api NEXT_PUBLIC_USE_MOCK_DATA=false NEXT_PUBLIC_USE_REGISTRY_DATA=true -# OpenAI -OPENAI_API_KEY=your-api-key +# AI (Anthropic Claude) +ANTHROPIC_API_KEY=your-api-key # Логирование LOG_LEVEL=info diff --git a/docs/LEGAL_MODULE.md b/docs/LEGAL_MODULE.md index e1a1304..513659a 100644 --- a/docs/LEGAL_MODULE.md +++ b/docs/LEGAL_MODULE.md @@ -88,11 +88,10 @@ В `.env` или переменных окружения: -- `OPENAI_API_KEY` — ключ OpenAI (или аналог) -- `OPENAI_BASE_URL` — базовый URL для локальных OpenAI-совместимых моделей -- `LEGAL_LLM_MODEL` — модель (по умолчанию `gpt-4o-mini`) +- `ANTHROPIC_API_KEY` — ключ Anthropic Claude API +- `ANTHROPIC_MODEL` — модель (по умолчанию `claude-sonnet-4-20250514`) -В `app.core.config` добавлены: `openai_api_key`, `openai_base_url`, `legal_llm_model`. +В `app.core.config` добавлены: `ANTHROPIC_API_KEY`, `ANTHROPIC_MODEL`. Если LLM недоступен (нет ключа или ошибка), агенты работают в режиме заглушек (эвристики, пустые списки). @@ -106,4 +105,4 @@ cd backend && python -m app.db.seed_legal ## Зависимости -- `openai>=1.0.0` — для вызова LLM (OpenAI-совместимый API). +- `anthropic>=0.42.0` — для вызова LLM (Anthropic Claude API). diff --git a/docs/RESILIENCE_PATTERNS.md b/docs/RESILIENCE_PATTERNS.md index a6c5f00..b5ed70d 100644 --- a/docs/RESILIENCE_PATTERNS.md +++ b/docs/RESILIENCE_PATTERNS.md @@ -15,13 +15,13 @@ ```typescript import { circuitBreakers } from '@/lib/resilience/circuit-breaker'; -const result = await circuitBreakers.openai.execute(async () => { - return await openai.chat.completions.create(...); +const result = await circuitBreakers.ai.execute(async () => { + return await aiApi.chat(prompt); }); ``` **Глобальные circuit breakers:** -- `openai` - для OpenAI API +- `ai` - для Anthropic Claude API - `database` - для PostgreSQL - `redis` - для Redis - `externalApi` - для внешних API diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml index d79e1d2..fc88319 100644 --- a/k8s/deployment.yaml +++ b/k8s/deployment.yaml @@ -48,11 +48,11 @@ spec: configMapKeyRef: name: klg-config key: redis-host - - name: OPENAI_API_KEY + - name: ANTHROPIC_API_KEY valueFrom: secretKeyRef: name: klg-secrets - key: openai-api-key + key: anthropic-api-key resources: requests: memory: "512Mi" @@ -127,4 +127,4 @@ stringData: db-name: "klg_db" db-user: "postgres" db-password: "" # SET VIA CI/CD PIPELINE - openai-api-key: "" # SET VIA CI/CD PIPELINE + anthropic-api-key: "" # SET VIA CI/CD PIPELINE (Anthropic Claude) diff --git a/lib/api/api-client.ts b/lib/api/api-client.ts index 1866299..5b64f31 100644 --- a/lib/api/api-client.ts +++ b/lib/api/api-client.ts @@ -247,3 +247,22 @@ export const attachmentsApi = { export const healthApi = { check: () => apiFetch('/health'), }; + +// AI Assistant (Anthropic Claude via backend) +export const aiApi = { + chat: (prompt: string, context?: string) => + apiFetch<{ result: string; model: string; provider: string }>('/ai/chat', { + method: 'POST', + body: JSON.stringify({ prompt, task: 'chat', context }), + }), + summarize: (text: string) => + apiFetch<{ result: string }>('/ai/chat', { + method: 'POST', + body: JSON.stringify({ prompt: text, task: 'summarize' }), + }), + extractRisks: (text: string) => + apiFetch<{ result: string }>('/ai/chat', { + method: 'POST', + body: JSON.stringify({ prompt: text, task: 'extract_risks' }), + }), +}; diff --git a/middleware.ts b/middleware.ts index 8fa6ee1..aa4d7a4 100644 --- a/middleware.ts +++ b/middleware.ts @@ -36,7 +36,7 @@ export function middleware(request: NextRequest) { "style-src 'self' 'unsafe-inline'", "img-src 'self' data: https:", "font-src 'self'", - "connect-src 'self'", + "connect-src 'self' https://api.anthropic.com", "frame-ancestors 'none'", "base-uri 'self'", "form-action 'self'", diff --git a/package.json b/package.json index dd57450..a9a8901 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,6 @@ "kafkajs": "^2.2.4", "next": "^14.0.0", "next-auth": "^5.0.0-beta.30", - "openai": "^6.16.0", "pg": "^8.11.3", "pino": "^10.2.1", "prettier": "^3.8.1",