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>
This commit is contained in:
Yuriy 2026-02-15 15:51:59 +03:00
parent 62958239ac
commit 44b14cc4fd
23 changed files with 353 additions and 408 deletions

View File

@ -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=

View File

@ -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' });
@ -54,51 +34,36 @@ 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<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\рикреплены файлы: ${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). Могу помочь с данными, поиском, анализом файлов и отчётами. Что нужно?';
}

View File

@ -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'
);
},

View File

@ -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

View File

@ -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",
]

View File

@ -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)

View File

@ -64,6 +64,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:
url = self.DATABASE_URL

View File

@ -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

View File

@ -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.01.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)

View File

@ -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:

View File

@ -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

View File

@ -20,8 +20,9 @@ export default function AIAccessSettings({ onSave }: Props) {
</FormField>
<FormField label="Модель">
<select value={model} onChange={e => setModel(e.target.value)} className="input-field" disabled={!enabled}>
<option value="claude-3-sonnet">Claude 3 Sonnet</option><option value="claude-3-opus">Claude 3 Opus</option>
<option value="gpt-4">GPT-4</option><option value="local">Локальная модель</option>
<option value="claude-sonnet-4-20250514">Claude Sonnet 4</option>
<option value="claude-3-sonnet">Claude 3 Sonnet</option>
<option value="claude-3-opus">Claude 3 Opus</option>
</select>
</FormField>
<FormField label="Макс. токенов">

View File

@ -258,7 +258,7 @@
### Библиотеки
- `csv-parse` - для парсинга CSV файлов
- `openai` - для работы с OpenAI API
- `ai` - для работы с Anthropic Claude API
- В будущем: `pdf-parse`, `xlsx`, `tesseract.js` для полной поддержки всех форматов
### Файлы

View File

@ -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) │
└────────┬────────┘

View File

@ -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=

View File

@ -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

View File

@ -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

View File

@ -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).

View File

@ -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

View File

@ -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)

View File

@ -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' }),
}),
};

View File

@ -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'",

View File

@ -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",