From d47baa17828a1ca3329562356f4e8933d217662c Mon Sep 17 00:00:00 2001 From: Yuriy Date: Sun, 15 Feb 2026 14:02:14 +0300 Subject: [PATCH] =?UTF-8?q?refactor:=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D1=81=D1=81=D1=8B=D0=BB=D0=BA=D0=B8=20=D0=BD?= =?UTF-8?q?=D0=B0=20knowledge/=20(=D0=BD=D0=B5=20=D0=BE=D1=82=D0=BD=D0=BE?= =?UTF-8?q?=D1=81=D0=B8=D1=82=D1=81=D1=8F=20=D0=BA=20=D0=9A=D0=9B=D0=93=20?= =?UTF-8?q?=D0=90=D0=A1=D0=A3=20=D0=A2=D0=9A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Удалены API routes, rewrites и ссылки на knowledge/reglaments - knowledge/ удалена из git-истории (6 ГБ PDF не относящихся к проекту) - Нормативная база обслуживается через модуль legal (routes/legal/) Co-authored-by: Cursor --- .gitignore | 5 +- app/api/openapi/route.ts | 140 ----------------- components/KnowledgeGraphVisualization.tsx | 175 ++------------------- components/KnowledgePanel.tsx | 27 +--- components/SemanticSearch.tsx | 18 +-- docs/AI_KNOWLEDGE_SYSTEM.md | 2 + docs/AUTONOMOUS_AGENT.md | 15 +- docs/RESILIENCE_PATTERNS.md | 2 +- prompts/domain/tmc.md | 2 +- scripts/update-manifest.js | 171 +------------------- scripts/validate-manifest.js | 58 +------ 11 files changed, 34 insertions(+), 581 deletions(-) diff --git a/.gitignore b/.gitignore index 692c345..5235982 100644 --- a/.gitignore +++ b/.gitignore @@ -32,4 +32,7 @@ build/ # OS .DS_Store -Thumbs.db \ No newline at end of file +Thumbs.db + +# Нормативная база (регламенты) — вынесена в отдельный сервис, не в репозитории КЛГ АСУ ТК +knowledge/ \ No newline at end of file diff --git a/app/api/openapi/route.ts b/app/api/openapi/route.ts index 54333bd..a64b2dd 100644 --- a/app/api/openapi/route.ts +++ b/app/api/openapi/route.ts @@ -119,138 +119,6 @@ const openApiSpec = { }, }, }, - '/api/knowledge/graph': { - get: { - summary: 'Получение Knowledge Graph', - description: 'Возвращает граф знаний или результаты поиска в графе', - tags: ['Knowledge Graph'], - parameters: [ - { - name: 'query', - in: 'query', - description: 'Поисковый запрос (опционально)', - required: false, - schema: { - type: 'string', - }, - }, - { - name: 'format', - in: 'query', - description: 'Формат ответа', - required: false, - schema: { - type: 'string', - enum: ['json', 'visualization'], - default: 'json', - }, - }, - ], - responses: { - '200': { - description: 'Успешный ответ', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - nodes: { - type: 'array', - items: { - type: 'object', - properties: { - id: { type: 'string' }, - type: { type: 'string' }, - label: { type: 'string' }, - properties: { type: 'object' }, - }, - }, - }, - edges: { - type: 'array', - items: { - type: 'object', - properties: { - id: { type: 'string' }, - source: { type: 'string' }, - target: { type: 'string' }, - type: { type: 'string' }, - weight: { type: 'number' }, - }, - }, - }, - }, - }, - }, - }, - }, - '429': { - description: 'Превышен лимит запросов', - }, - '500': { - description: 'Внутренняя ошибка сервера', - }, - }, - }, - }, - '/api/knowledge/search': { - post: { - summary: 'Семантический поиск в базе знаний', - description: 'Выполняет семантический поиск по базе знаний', - tags: ['Knowledge Base'], - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - required: ['query'], - properties: { - query: { - type: 'string', - description: 'Поисковый запрос', - }, - limit: { - type: 'number', - default: 10, - description: 'Максимальное количество результатов', - }, - }, - }, - }, - }, - }, - responses: { - '200': { - description: 'Успешный ответ', - }, - }, - }, - }, - '/api/knowledge/insights': { - get: { - summary: 'Получение инсайтов', - description: 'Генерирует инсайты на основе данных', - tags: ['Knowledge Base'], - responses: { - '200': { - description: 'Успешный ответ', - }, - }, - }, - }, - '/api/knowledge/recommendations': { - get: { - summary: 'Получение рекомендаций', - description: 'Генерирует рекомендации на основе данных', - tags: ['Knowledge Base'], - responses: { - '200': { - description: 'Успешный ответ', - }, - }, - }, - }, }, components: { schemas: { @@ -274,14 +142,6 @@ const openApiSpec = { name: 'AI Agent', description: 'Endpoints для взаимодействия с автономным агентом', }, - { - name: 'Knowledge Graph', - description: 'Endpoints для работы с графом знаний', - }, - { - name: 'Knowledge Base', - description: 'Endpoints для работы с базой знаний', - }, ], }; diff --git a/components/KnowledgeGraphVisualization.tsx b/components/KnowledgeGraphVisualization.tsx index 62bcf2b..2283668 100644 --- a/components/KnowledgeGraphVisualization.tsx +++ b/components/KnowledgeGraphVisualization.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect, useRef, useState } from 'react'; -import { logInfo, logError } from '@/lib/logger-client'; +import { logError } from '@/lib/logger-client'; interface GraphNode { id: string; @@ -33,180 +33,23 @@ export default function KnowledgeGraphVisualization({ const [error, setError] = useState(null); useEffect(() => { - let visNetwork: any = null; - const loadGraph = async () => { try { setLoading(true); setError(null); - - const url = query - ? `/api/knowledge/graph?query=${encodeURIComponent(query)}&format=visualization` - : '/api/knowledge/graph?format=visualization'; - - const response = await fetch(url); - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - - const data = await response.json(); - setGraph(data); - - // Динамически загружаем vis-network и vis-data - let Network: any = null; - let DataSet: any = null; - - try { - // Импортируем vis-network для Network - const visNetwork = await import('vis-network'); - Network = visNetwork.Network || visNetwork.default?.Network || visNetwork.default || visNetwork; - - // Импортируем vis-data для DataSet - const visData = await import('vis-data'); - DataSet = visData.DataSet || visData.default?.DataSet || visData.default || visData; - - // Если не получилось через ESM, пробуем через require (для SSR) - if (!Network || !DataSet) { - if (typeof window === 'undefined') { - const visNetworkReq = require('vis-network'); - Network = visNetworkReq.Network || visNetworkReq.default?.Network || visNetworkReq; - - const visDataReq = require('vis-data'); - DataSet = visDataReq.DataSet || visDataReq.default?.DataSet || visDataReq; - } - } - } catch (err) { - logError('vis-network/vis-data import error', err); - setError(`Ошибка загрузки библиотек визуализации: ${err instanceof Error ? err.message : 'Неизвестная ошибка'}`); - setLoading(false); - return; - } - - if (!Network) { - logError('vis-network Network not found'); - setError('vis-network Network не найден'); - setLoading(false); - return; - } - - if (!DataSet) { - logError('vis-data DataSet not found'); - setError('vis-data DataSet не найден. Установите: npm install vis-data'); - setLoading(false); - return; - } - - if (!containerRef.current) { - return; - } - - // Подготовка данных для vis-network - const nodes = new DataSet( - data.nodes.map((node: GraphNode) => { - // Создаем новый объект без id, чтобы избежать дублирования - const { id, label, type, ...restNode } = node; - return { - id, - label: label || id, - group: type, - title: `${type}: ${label}\n${JSON.stringify(node, null, 2)}`, - ...restNode, - }; - }) - ); - - const edges = new DataSet( - data.edges.map((edge: GraphEdge) => ({ - id: edge.id, - from: edge.source, - to: edge.target, - label: edge.type, - value: edge.weight, - title: `${edge.type} (weight: ${edge.weight})`, - })) - ); - - const networkData = { nodes, edges }; - - const options = { - nodes: { - shape: 'dot', - size: 16, - font: { - size: 12, - color: '#333', - }, - borderWidth: 2, - shadow: true, - }, - edges: { - width: 2, - color: { color: '#848484' }, - smooth: { - type: 'continuous', - }, - arrows: { - to: { - enabled: true, - scaleFactor: 0.5, - }, - }, - font: { - size: 10, - align: 'middle', - }, - }, - physics: { - enabled: true, - stabilization: { - iterations: 200, - }, - }, - interaction: { - hover: true, - tooltipDelay: 100, - zoomView: true, - dragView: true, - }, - groups: { - aircraft: { color: { background: '#2196f3', border: '#1976d2' } }, - audit: { color: { background: '#ff9800', border: '#f57c00' } }, - risk: { color: { background: '#f44336', border: '#d32f2f' } }, - operator: { color: { background: '#4caf50', border: '#388e3c' } }, - regulation: { color: { background: '#9c27b0', border: '#7b1fa2' } }, - document: { color: { background: '#00bcd4', border: '#0097a7' } }, - }, - }; - - visNetwork = new Network(containerRef.current, networkData, options); - - // Обработка клика на узел - visNetwork.on('click', (params: any) => { - if (params.nodes.length > 0 && onNodeClick) { - onNodeClick(params.nodes[0]); - } - }); - - logInfo('Knowledge graph visualization loaded', { - nodes: data.nodes.length, - edges: data.edges.length, - }); - } catch (err: any) { - logError('Failed to load knowledge graph visualization', err); - setError(err.message || 'Ошибка загрузки графа знаний'); + // Модуль knowledge вынесен в отдельный сервис (КЛГ АСУ ТК не использует локальную папку knowledge/) + setGraph({ nodes: [], edges: [] }); + setLoading(false); + return; + } catch (err) { + logError('Knowledge graph unavailable', err); + setError('Модуль графа знаний вынесен в отдельный сервис'); } finally { setLoading(false); } }; - loadGraph(); - - return () => { - if (visNetwork) { - visNetwork.destroy(); - } - }; - }, [query, onNodeClick]); + }, [query]); if (loading) { return ( diff --git a/components/KnowledgePanel.tsx b/components/KnowledgePanel.tsx index 90bce9e..9a5e727 100644 --- a/components/KnowledgePanel.tsx +++ b/components/KnowledgePanel.tsx @@ -47,30 +47,11 @@ export default function KnowledgePanel({ const loadKnowledge = async () => { setLoading(true); try { - // Загружаем инсайты - const insightsRes = await fetch('/api/knowledge/insights', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - data: [{ id: entityId, type: entityType }], - context: `${entityType} ${entityId}`, - }), - }); - const insightsData = await insightsRes.json(); - setInsights(insightsData.insights || []); - - // Загружаем рекомендации - const recRes = await fetch('/api/knowledge/recommendations', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - [String(entityType)]: [{ id: entityId }], - }), - }); - const recData = await recRes.json(); - setRecommendations(recData.recommendations || []); + // Модуль нормативной базы (knowledge) вынесен в отдельный сервис + setInsights([]); + setRecommendations([]); } catch (error) { - console.error('Failed to load knowledge:', error); + console.error('Knowledge module in separate service', error); } finally { setLoading(false); } diff --git a/components/SemanticSearch.tsx b/components/SemanticSearch.tsx index 54b375f..ccc2c87 100644 --- a/components/SemanticSearch.tsx +++ b/components/SemanticSearch.tsx @@ -31,21 +31,9 @@ export default function SemanticSearch({ setLoading(true); try { - const response = await fetch('/api/knowledge/search', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - query, - type: type === 'all' ? undefined : type, - limit: 10, - threshold: 0.7, - }), - }); - - const data = await response.json(); - setResults(data.results || []); - } catch (error) { - // Ошибка уже обработана в API + // Модуль нормативной базы (knowledge) вынесен в отдельный сервис + setResults([]); + } catch { setResults([]); } finally { setLoading(false); diff --git a/docs/AI_KNOWLEDGE_SYSTEM.md b/docs/AI_KNOWLEDGE_SYSTEM.md index 5ee8b10..495f4ff 100644 --- a/docs/AI_KNOWLEDGE_SYSTEM.md +++ b/docs/AI_KNOWLEDGE_SYSTEM.md @@ -1,5 +1,7 @@ # AI-Powered Knowledge System +> **Примечание.** Модуль нормативной базы (knowledge) вынесен в отдельный сервис. Описание ниже сохранено как справочное; эндпоинты `/api/knowledge/*` в данном репозитории (КЛГ АСУ ТК) не реализованы. + ## Обзор Система знаний на основе ИИ для автоматического извлечения, хранения и поиска информации из документов и данных. diff --git a/docs/AUTONOMOUS_AGENT.md b/docs/AUTONOMOUS_AGENT.md index 3fae673..7be8452 100644 --- a/docs/AUTONOMOUS_AGENT.md +++ b/docs/AUTONOMOUS_AGENT.md @@ -42,14 +42,7 @@ - **Рёбра**: Связи между сущностями - **Embeddings**: Векторные представления для семантического поиска -**Пример:** -```typescript -import { buildKnowledgeGraph } from '@/lib/ai/knowledge-graph'; - -const graph = await buildKnowledgeGraph(); -// graph.nodes - узлы графа -// graph.edges - связи между узлами -``` +**Пример:** Модуль нормативной базы (knowledge) вынесен в отдельный сервис; граф знаний доступен через внешний API. ### 2. LLM Reasoning @@ -159,11 +152,11 @@ const result = await agent.execute(plan); } ``` -#### GET `/api/knowledge/graph` +#### Модуль knowledge -Получение Knowledge Graph. +Модуль нормативной базы (knowledge) вынесен в отдельный сервис. Эндпоинт `GET /api/knowledge/graph` в данном репозитории не реализован. -**Query Parameters:** +**Query Parameters (внешний сервис):** - `query` - поисковый запрос (опционально) - `format` - формат ответа: `json` или `visualization` diff --git a/docs/RESILIENCE_PATTERNS.md b/docs/RESILIENCE_PATTERNS.md index 15ca4b0..a6c5f00 100644 --- a/docs/RESILIENCE_PATTERNS.md +++ b/docs/RESILIENCE_PATTERNS.md @@ -44,7 +44,7 @@ const result = await bulkheads.ai.execute(async () => { **Глобальные bulkheads:** - `ai` - 5 одновременных запросов - `database` - 20 одновременных запросов -- `knowledgeGraph` - 2 построения графа одновременно +- Модуль нормативной базы (knowledge) вынесен в отдельный сервис; лимит `knowledgeGraph` не используется в этом репозитории. - `fileProcessing` - 3 файла одновременно ### 3. Retry с Exponential Backoff diff --git a/prompts/domain/tmc.md b/prompts/domain/tmc.md index f5b6428..df204b1 100644 --- a/prompts/domain/tmc.md +++ b/prompts/domain/tmc.md @@ -2,6 +2,6 @@ Домен: Учет ТМЦ (TMC Registry) -- Использовать только утверждённые шаблоны и регламенты из `knowledge/`. +- Модуль нормативной базы (knowledge) вынесен в отдельный сервис; использовать только утверждённые шаблоны и регламенты, доступные через него. - Все поля должны соответствовать структурам, описанным в словарях и спецификациях проекта. - При работе с ТМЦ приоритет имеют документы с `domain = tmc` в `manifest.json`. diff --git a/scripts/update-manifest.js b/scripts/update-manifest.js index 1ddcff0..ede8703 100755 --- a/scripts/update-manifest.js +++ b/scripts/update-manifest.js @@ -1,170 +1,9 @@ #!/usr/bin/env node /** - * Скрипт для добавления всех файлов из knowledge/ в manifest.json - * Приводит все записи к единому формату с обязательными полями + * Раньше: добавление файлов из knowledge/ в manifest.json. + * Модуль нормативной базы (knowledge) вынесен в отдельный сервис. + * Скрипт сохранён как заглушка для совместимости с CI/скриптами. */ -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const MANIFEST_PATH = path.join(__dirname, '../index/manifest.json'); -const KNOWLEDGE_DIR = path.join(__dirname, '../knowledge'); - -// Ключевые документы для статуса approved (3-5 документов) -const KEY_DOCUMENTS_PATTERNS = [ - /papa_project_bootstrap/i, - /145\.1в/i, - /рц-ап-145/i, - /tv3_117_kniga_1.*turboval/i, - /tech_cards_registry/i -]; - -function getDocumentType(filePath) { - const dir = path.dirname(filePath).split(path.sep).pop(); - if (dir === 'reglaments') return 'reglament'; - if (dir === 'guides') return 'template'; - if (dir === 'samples') return 'sample'; - return 'reglament'; -} - -function getDomain(filePath, fileName) { - const lowerName = fileName.toLowerCase(); - if (lowerName.includes('tmc') || lowerName.includes('tv3') || lowerName.includes('двигател') || lowerName.includes('byulleten')) return 'tmc'; - if (lowerName.includes('145') || lowerName.includes('183') || lowerName.includes('регламент') || lowerName.includes('руководство')) return 'regulations'; - if (lowerName.includes('анкет') || lowerName.includes('hr')) return 'hr'; - if (lowerName.includes('инспекц')) return 'inspection'; - return 'core'; -} - -function generateId(type, index) { - const prefix = type === 'reglament' ? 'reg' : type === 'template' ? 'guide' : 'sample'; - return `${prefix}-${String(index).padStart(3, '0')}`; -} - -function getTitle(fileName) { - // Убираем расширение - let title = fileName.replace(/\.[^.]+$/, ''); - // Убираем "— копия" и подобное - title = title.replace(/\s*—\s*копия\s*/gi, '').trim(); - // Если название слишком длинное, обрезаем - if (title.length > 100) { - title = title.substring(0, 97) + '...'; - } - return title || fileName; -} - -function isKeyDocument(fileName) { - return KEY_DOCUMENTS_PATTERNS.some(pattern => pattern.test(fileName)); -} - -function getAllFiles() { - const files = []; - - ['reglaments', 'guides', 'samples'].forEach(dir => { - const dirPath = path.join(KNOWLEDGE_DIR, dir); - if (!fs.existsSync(dirPath)) return; - - const dirFiles = fs.readdirSync(dirPath) - .filter(file => { - const filePath = path.join(dirPath, file); - const stat = fs.statSync(filePath); - return stat.isFile() && file !== '.DS_Store' && file !== 'README.md'; - }) - .map(file => ({ - fileName: file, - filePath: path.join(dir, file), - fullPath: path.join(dirPath, file) - })); - - files.push(...dirFiles); - }); - - return files; -} - -function updateManifest() { - // console.log('🔍 Сканирование файлов в knowledge/...\n'); - - // Получаем все файлы - const allFiles = getAllFiles(); - console.log(`Найдено файлов: ${allFiles.length}\n`); - - // Генерируем документы для всех файлов - const documents = []; - let regIndex = 1; - let guideIndex = 1; - let sampleIndex = 1; - - // Сортируем файлы для консистентности - allFiles.sort((a, b) => { - const typeA = getDocumentType(a.filePath); - const typeB = getDocumentType(b.filePath); - const typeOrder = { reglament: 0, template: 1, sample: 2 }; - if (typeOrder[typeA] !== typeOrder[typeB]) { - return typeOrder[typeA] - typeOrder[typeB]; - } - return a.fileName.localeCompare(b.fileName); - }); - - allFiles.forEach(file => { - const relativePath = `knowledge/${file.filePath}`; - const type = getDocumentType(relativePath); - - // Генерируем ID - let id; - if (type === 'reglament') { - id = generateId('reglament', regIndex++); - } else if (type === 'template') { - id = generateId('template', guideIndex++); - } else { - id = generateId('sample', sampleIndex++); - } - - const doc = { - id, - title: getTitle(file.fileName), - path: relativePath, - type, - domain: getDomain(relativePath, file.fileName), - version: '1.0.0', - status: isKeyDocument(file.fileName) ? 'approved' : 'draft' - }; - - documents.push(doc); - }); - - // Читаем текущий manifest - const manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8')); - - // Обновляем manifest - manifest.documents = documents; - manifest.manifest.lastUpdated = new Date().toISOString(); - - // Обновляем метаданные - const statusBreakdown = { approved: 0, draft: 0, deprecated: 0 }; - const fileTypes = {}; - - documents.forEach(doc => { - statusBreakdown[doc.status] = (statusBreakdown[doc.status] || 0) + 1; - const ext = path.extname(doc.path).slice(1).toLowerCase(); - fileTypes[ext] = (fileTypes[ext] || 0) + 1; - }); - - manifest.metadata.documentCount = documents.length; - manifest.metadata.statusBreakdown = statusBreakdown; - manifest.metadata.fileTypes = fileTypes; - - // Сохраняем - fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + '\n', 'utf8'); - - console.log(`✅ Обновлено документов: ${documents.length}`); - console.log(` - Approved: ${statusBreakdown.approved}`); - console.log(` - Draft: ${statusBreakdown.draft}`); - console.log(` - Deprecated: ${statusBreakdown.deprecated}\n`); -} - -updateManifest(); +console.log('Модуль нормативной базы (knowledge) вынесен в отдельный сервис. Обновление manifest по knowledge/ пропущено.'); +process.exit(0); diff --git a/scripts/validate-manifest.js b/scripts/validate-manifest.js index 5480a47..8b0bcd2 100755 --- a/scripts/validate-manifest.js +++ b/scripts/validate-manifest.js @@ -20,7 +20,7 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const MANIFEST_PATH = path.join(__dirname, '../index/manifest.json'); -const KNOWLEDGE_DIR = path.join(__dirname, '../knowledge'); +// Модуль knowledge вынесен в отдельный сервис — проверка файлов в knowledge/ отключена // Обязательные поля документа const REQUIRED_FIELDS = ['id', 'title', 'path', 'type', 'domain', 'version', 'status']; @@ -154,9 +154,6 @@ function validateManifest() { } }); - // Проверка: все файлы из knowledge/ должны быть в manifest - checkKnowledgeFilesInManifest(manifest, documentPaths); - printResults(); if (errors.length > 0) { @@ -164,59 +161,6 @@ function validateManifest() { } } -function getAllKnowledgeFiles() { - const files = []; - - if (!fs.existsSync(KNOWLEDGE_DIR)) { - return files; - } - - ['reglaments', 'guides', 'samples'].forEach(dir => { - const dirPath = path.join(KNOWLEDGE_DIR, dir); - if (!fs.existsSync(dirPath)) return; - - const dirFiles = fs.readdirSync(dirPath) - .filter(file => { - const filePath = path.join(dirPath, file); - try { - const stat = fs.statSync(filePath); - return stat.isFile() && file !== '.DS_Store' && file !== 'README.md'; - } catch (e) { - return false; - } - }) - .map(file => { - const relativePath = `knowledge/${dir}/${file}`; - return { - fileName: file, - relativePath: relativePath, - fullPath: path.join(dirPath, file) - }; - }); - - files.push(...dirFiles); - }); - - return files; -} - -function checkKnowledgeFilesInManifest(manifest, manifestPaths) { - console.log('🔍 Проверка соответствия файлов knowledge/ и manifest...\n'); - - const knowledgeFiles = getAllKnowledgeFiles(); - const manifestPathsSet = new Set(manifestPaths); - - // Проверяем, что каждый файл из knowledge/ есть в manifest - knowledgeFiles.forEach(file => { - if (!manifestPathsSet.has(file.relativePath)) { - errors.push(`Файл из knowledge/ отсутствует в manifest: ${file.relativePath}`); - } - }); - - console.log(` Проверено файлов в knowledge/: ${knowledgeFiles.length}`); - console.log(` Записей в manifest: ${manifestPaths.size}\n`); -} - function printResults() { if (errors.length > 0) { console.log('❌ Ошибки:\n');