diff --git a/backend/db/migrations/028_create_dynamic_tables.sql b/backend/db/migrations/028_create_dynamic_tables.sql index 51c69b5..2862edf 100644 --- a/backend/db/migrations/028_create_dynamic_tables.sql +++ b/backend/db/migrations/028_create_dynamic_tables.sql @@ -32,7 +32,7 @@ CREATE TABLE IF NOT EXISTS user_cell_values ( id SERIAL PRIMARY KEY, row_id INTEGER NOT NULL REFERENCES user_rows(id) ON DELETE CASCADE, column_id INTEGER NOT NULL REFERENCES user_columns(id) ON DELETE CASCADE, - value TEXT, + value_encrypted TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(row_id, column_id) diff --git a/backend/package.json b/backend/package.json index e48c4b4..71f4f2a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -26,7 +26,8 @@ "generate:abi": "node scripts/generate-abi.js", "generate:flattened": "node scripts/generate-flattened.js", "compile:full": "npx hardhat compile && npm run generate:abi && npm run generate:flattened", - "seed:legal": "node scripts/seed/legalTemplatesSeed.js" + "seed:legal": "node scripts/seed/legalTemplatesSeed.js", + "import:legal": "node scripts/import-legal-docs.js" }, "bin": {}, "engines": { diff --git a/backend/scripts/import-legal-docs.js b/backend/scripts/import-legal-docs.js new file mode 100644 index 0000000..2791813 --- /dev/null +++ b/backend/scripts/import-legal-docs.js @@ -0,0 +1,291 @@ +/** + * Copyright (c) 2024-2025 Тарабанов Александр Викторович + * All rights reserved. + * + * This software is proprietary and confidential. + * Unauthorized copying, modification, or distribution is prohibited. + * + * For licensing inquiries: info@hb3-accelerator.com + * Website: https://hb3-accelerator.com + * GitHub: https://github.com/VC-HB3-Accelerator + */ + +/** + * Импорт правовых документов из папки legal в admin_pages_simple + * Конвертирует markdown файлы в HTML и добавляет их как опубликованные страницы + */ + +const db = require('../db'); +const fs = require('fs'); +const path = require('path'); + +// Маппинг файлов на документацию +const legalDocsMapping = { + 'CONSENT_PERSONAL_DATA_RU.md': { + title: 'Согласие на обработку персональных данных', + summary: 'Шаблон пользовательского согласия на обработку персональных данных' + }, + 'COOKIE_CONSENT_RU.md': { + title: 'Согласие на использование файлов cookie', + summary: 'Шаблон согласия на использование cookie по категориям' + }, + 'PDN_RIGHTS_AND_REVOCATION_RU.md': { + title: 'Права субъектов персональных данных и отзыв согласия', + summary: 'Информация о правах субъектов ПДн и форма отзыва согласия' + }, + 'PRIVACY_POLICY_RU.md': { + title: 'Политика конфиденциальности', + summary: 'Публичная политика конфиденциальности сервиса' + }, + 'SERVICE_ACT_TEMPLATE_RU.md': { + title: 'Акт выполненных работ', + summary: 'Шаблон акта выполненных работ для подтверждения оказанных услуг' + }, + 'SERVICE_AGREEMENT_RU.md': { + title: 'Договор оказания услуг', + summary: 'Минимальный договор оказания услуг / лицензионный договор' + }, + 'service-terms.md': { + title: 'Условия приобретения и обслуживания Digital Legal Entity', + summary: 'Условия приобретения, лицензирования и обслуживания DLE' + } +}; + +async function ensureTable(tableName) { + const existsRes = await db.getQuery()( + `SELECT to_regclass($1) as exists`, + [tableName] + ); + if (!existsRes.rows[0].exists) { + await db.getQuery()(` + CREATE TABLE ${tableName} ( + id SERIAL PRIMARY KEY, + author_address TEXT NULL, + title TEXT, + summary TEXT, + content TEXT, + seo JSONB, + status TEXT, + visibility TEXT, + required_permission TEXT, + format TEXT, + mime_type TEXT, + storage_type TEXT, + file_path TEXT, + size_bytes BIGINT, + checksum TEXT, + is_system_template BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() + ) + `); + } +} + +async function ensureColumns(tableName) { + const needed = { + author_address: 'TEXT', + title: 'TEXT', + summary: 'TEXT', + content: 'TEXT', + seo: 'JSONB', + status: 'TEXT', + visibility: 'TEXT', + required_permission: 'TEXT', + format: 'TEXT', + mime_type: 'TEXT', + storage_type: 'TEXT', + file_path: 'TEXT', + size_bytes: 'BIGINT', + checksum: 'TEXT', + is_system_template: 'BOOLEAN DEFAULT FALSE', + created_at: 'TIMESTAMP DEFAULT NOW()', + updated_at: 'TIMESTAMP DEFAULT NOW()' + }; + + const existingRes = await db.getQuery()( + `SELECT column_name FROM information_schema.columns WHERE table_name = $1`, + [tableName] + ); + const existing = existingRes.rows.map(r => r.column_name); + + for (const [col, type] of Object.entries(needed)) { + if (!existing.includes(col)) { + await db.getQuery()(`ALTER TABLE ${tableName} ADD COLUMN ${col} ${type}`); + } + } +} + +// Простая функция конвертации markdown в HTML +function markdownToHtml(markdown) { + let html = markdown; + + // Заголовки + html = html.replace(/^### (.*$)/gim, '

$1

'); + html = html.replace(/^## (.*$)/gim, '

$1

'); + html = html.replace(/^# (.*$)/gim, '

$1

'); + + // Списки + html = html.replace(/^\- (.*$)/gim, '
  • $1
  • '); + html = html.replace(/^(\d+)\. (.*$)/gim, '
  • $2
  • '); + html = html.replace(/(
  • .*<\/li>\n)+/g, ''); + + // Жирный текст + html = html.replace(/\*\*(.*?)\*\*/g, '$1'); + html = html.replace(/__(.*?)__/g, '$1'); + + // Курсив + html = html.replace(/\*(.*?)\*/g, '$1'); + html = html.replace(/_(.*?)_/g, '$1'); + + // Ссылки + html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); + + // Изображения + html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '$1'); + + // Code blocks + html = html.replace(/```([\s\S]*?)```/g, '
    $1
    '); + html = html.replace(/`([^`]+)`/g, '$1'); + + // Blockquotes + html = html.replace(/^> (.*$)/gim, '
    $1
    '); + + // Горизонтальная линия + html = html.replace(/^---$/gim, '
    '); + + // Параграфы + html = html.split('\n\n').map(p => { + p = p.trim(); + if (p && !p.match(/^<[hul]/) && !p.match(/<\/[hul]/) && !p.match(/^
    /) && !p.match(/<\/pre>/) && !p.match(/^
    /) && !p.match(/<\/blockquote>/)) { + return `

    ${p}

    `; + } + return p; + }).join('\n\n'); + + return html; +} + +async function importLegalDocument(filename, metadata) { + // Пробуем разные пути в зависимости от окружения + const possiblePaths = [ + path.join('/legal', filename), // Docker окружение + path.join(__dirname, '..', '..', 'legal', filename), // Локальное окружение + path.join(process.cwd(), '..', 'legal', filename) // Альтернативный путь + ]; + + let filePath = null; + for (const possiblePath of possiblePaths) { + if (fs.existsSync(possiblePath)) { + filePath = possiblePath; + break; + } + } + + if (!filePath) { + console.error(`Файл не найден: ${filename}`); + console.error(`Проверенные пути: ${possiblePaths.join(', ')}`); + return null; + } + + console.log(`Чтение файла: ${filename}`); + const markdownContent = fs.readFileSync(filePath, 'utf-8'); + + // Конвертируем markdown в HTML + const htmlContent = markdownToHtml(markdownContent); + + // Проверяем, существует ли уже документ с таким названием + const existing = await db.getQuery()( + `SELECT id FROM admin_pages_simple WHERE title = $1 LIMIT 1`, + [metadata.title] + ); + + const pageData = { + title: metadata.title, + summary: metadata.summary, + content: htmlContent, + seo: { + title: metadata.title, + description: metadata.summary, + keywords: 'ПДн, политика, согласие, правовые документы, DLE' + }, + status: 'published', + visibility: 'public', + required_permission: null, + format: 'html', + mime_type: 'text/html', + storage_type: 'embedded' + }; + + if (existing.rows.length > 0) { + // Обновляем существующий документ + const sql = `UPDATE admin_pages_simple + SET summary = $2, content = $3, seo = $4, status = $5, visibility = $6, + format = $7, mime_type = $8, storage_type = $9, updated_at = NOW() + WHERE id = $1`; + await db.getQuery()(sql, [ + existing.rows[0].id, + pageData.summary, + pageData.content, + JSON.stringify(pageData.seo), + pageData.status, + pageData.visibility, + pageData.format, + pageData.mime_type, + pageData.storage_type + ]); + console.log(`✓ Обновлен: ${metadata.title}`); + return { updated: 1, inserted: 0 }; + } else { + // Вставляем новый документ + const sql = `INSERT INTO admin_pages_simple + (author_address, title, summary, content, seo, status, visibility, required_permission, format, mime_type, storage_type) + VALUES (NULL, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`; + await db.getQuery()(sql, [ + pageData.title, + pageData.summary, + pageData.content, + JSON.stringify(pageData.seo), + pageData.status, + pageData.visibility, + pageData.required_permission, + pageData.format, + pageData.mime_type, + pageData.storage_type + ]); + console.log(`✓ Создан: ${metadata.title}`); + return { updated: 0, inserted: 1 }; + } +} + +async function main() { + try { + console.log('Начало импорта правовых документов...'); + + const tableName = 'admin_pages_simple'; + await ensureTable(tableName); + await ensureColumns(tableName); + + let totalInserted = 0; + let totalUpdated = 0; + + // Импортируем все документы из маппинга + for (const [filename, metadata] of Object.entries(legalDocsMapping)) { + const result = await importLegalDocument(filename, metadata); + if (result) { + totalInserted += result.inserted; + totalUpdated += result.updated; + } + } + + console.log(`\n✓ Импорт завершен: создано=${totalInserted}, обновлено=${totalUpdated}`); + } catch (error) { + console.error('Ошибка импорта правовых документов:', error); + process.exit(1); + } +} + +main().then(() => process.exit(0)).catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/backend/services/ai-assistant.js b/backend/services/ai-assistant.js index 8506d7b..d73da14 100644 --- a/backend/services/ai-assistant.js +++ b/backend/services/ai-assistant.js @@ -137,7 +137,8 @@ class AIAssistant { systemPrompt: aiSettings ? aiSettings.system_prompt : '', history: conversationHistory, model: aiSettings ? aiSettings.model : undefined, - rules: rules ? rules.rules : null + rules: rules ? rules.rules : null, + selectedRagTables: aiSettings ? aiSettings.selected_rag_tables : [] }); if (!aiResponse) { diff --git a/backend/services/encryptedDatabaseService.js b/backend/services/encryptedDatabaseService.js index bbeda3f..498b315 100644 --- a/backend/services/encryptedDatabaseService.js +++ b/backend/services/encryptedDatabaseService.js @@ -216,8 +216,18 @@ class EncryptedDataService { continue; } const currentParamIndex = paramIndex++; - filteredData[key] = value; // Добавляем в отфильтрованные данные - console.log(`✅ Добавили зашифрованное поле ${key} = "${value}" в filteredData`); + + // Преобразуем значение в строку для шифрования + let valueToEncrypt; + if (typeof value === 'object') { + // Если это объект/массив, преобразуем в JSON + valueToEncrypt = JSON.stringify(value); + } else { + valueToEncrypt = value; + } + + filteredData[key] = valueToEncrypt; // Добавляем в отфильтрованные данные + console.log(`✅ Добавили зашифрованное поле ${key} = "${valueToEncrypt}" в filteredData`); if (encryptedColumn.data_type === 'jsonb') { encryptedData[`${key}_encrypted`] = `encrypt_json($${currentParamIndex}, ${hasEncryptedFields ? '$1::text' : 'NULL'})`; } else { @@ -289,7 +299,27 @@ class EncryptedDataService { const placeholders = Object.keys(allData).map(key => allData[key]).join(', '); const query = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders}) RETURNING *`; - const params = hasEncryptedFields ? [this.encryptionKey, ...Object.values(filteredData)] : [...Object.values(filteredData)]; + + // Собираем параметры в правильном порядке: сначала для encrypted, потом для unencrypted + const paramsArray = []; + if (hasEncryptedFields) paramsArray.push(this.encryptionKey); + + // Добавляем параметры для encrypted колонок + for (const key of Object.keys(encryptedData)) { + const originalKey = key.replace('_encrypted', ''); + if (filteredData[originalKey] !== undefined) { + paramsArray.push(filteredData[originalKey]); + } else if (filteredData[originalKey + '_unencrypted'] !== undefined) { + paramsArray.push(filteredData[originalKey + '_unencrypted']); + } + } + + // Добавляем параметры для unencrypted колонок + for (const key of Object.keys(unencryptedData)) { + paramsArray.push(filteredData[key + '_unencrypted'] || filteredData[key]); + } + + const params = paramsArray; console.log(`🔍 Выполняем INSERT запрос:`, query); console.log(`🔍 Параметры:`, params); @@ -359,9 +389,13 @@ class EncryptedDataService { params.push(...paramsToAdd); } - // Добавляем ключ шифрования в начало, если есть зашифрованные поля - const hasEncryptedFields = columns.some(col => col.column_name.endsWith('_encrypted')); - if (hasEncryptedFields) { + // Определяем, нужно ли добавлять ключ шифрования + // Проверяем, есть ли в WHERE условиях зашифрованные колонки + const hasEncryptedFieldsInConditions = Object.keys(conditions).some(key => { + return columns.find(col => col.column_name === `${key}_encrypted`); + }); + + if (hasEncryptedFieldsInConditions) { params.unshift(this.encryptionKey); } diff --git a/backend/services/ragService.js b/backend/services/ragService.js index fb65144..277f73c 100644 --- a/backend/services/ragService.js +++ b/backend/services/ragService.js @@ -201,13 +201,20 @@ async function ragAnswer({ tableId, userQuestion, product = null, threshold = 30 /** * Загрузка всех плейсхолдеров и их значений из пользовательских таблиц * Возвращает объект: { placeholder1: value1, placeholder2: value2, ... } + * @param {Array} selectedRagTables - Массив ID выбранных RAG таблиц для фильтрации */ -async function getAllPlaceholdersWithValues() { +async function getAllPlaceholdersWithValues(selectedRagTables = []) { try { console.log('[RAG] Начинаем загрузку плейсхолдеров...'); - // Получаем все колонки с плейсхолдерами - const columns = await encryptedDb.getData('user_columns', {}); + // Получаем колонки с плейсхолдерами + let columns = await encryptedDb.getData('user_columns', {}); + + // Фильтруем по выбранным RAG таблицам, если они указаны + if (selectedRagTables && selectedRagTables.length > 0) { + columns = columns.filter(col => selectedRagTables.includes(col.table_id)); + console.log(`[RAG] Фильтруем по RAG таблицам: ${selectedRagTables.join(', ')}`); + } console.log(`[RAG] Получено колонок: ${columns.length}`); const columnsWithPlaceholders = columns.filter(col => col.placeholder && col.placeholder.trim() !== ''); @@ -281,7 +288,8 @@ async function generateLLMResponse({ date, rules, history, - model + model, + selectedRagTables }) { console.log(`[RAG] generateLLMResponse called with:`, { userQuestion, @@ -338,7 +346,7 @@ async function generateLLMResponse({ // --- ДОБАВЛЕНО: подстановка плейсхолдеров --- let finalSystemPrompt = systemPrompt; if (systemPrompt && systemPrompt.includes('{')) { - const placeholders = await getAllPlaceholdersWithValues(); + const placeholders = await getAllPlaceholdersWithValues(selectedRagTables); finalSystemPrompt = replacePlaceholders(systemPrompt, placeholders); console.log(`[RAG] Подставлены плейсхолдеры в системный промпт`); } diff --git a/docs/setup-ai-assistant.md b/docs/setup-ai-assistant.md index 35488ae..2841ab6 100644 --- a/docs/setup-ai-assistant.md +++ b/docs/setup-ai-assistant.md @@ -242,11 +242,12 @@ ollama serve - Используйте эмодзи умеренно (1-2 на сообщение) Правила ответа: -1. Сначала ищите ответ в базе знаний (RAG) -2. Если нашли — отвечайте на основе найденной информации -3. Если не нашли — честно скажите и предложите помощь оператора -4. Не придумывайте информацию о ценах, сроках, условиях -5. При сложных вопросах предлагайте связаться с менеджером +1. ОБЯЗАТЕЛЬНО: Отвечайте ТОЛЬКО на русском языке. Все вопросы и ответы должны быть на русском языке +2. Сначала ищите ответ в базе знаний (RAG) +3. Если нашли — отвечайте на основе найденной информации +4. Если не нашли — честно скажите и предложите помощь оператора +5. Не придумывайте информацию о ценах, сроках, условиях +6. При сложных вопросах предлагайте связаться с менеджером Всегда заканчивайте: "Чем еще могу помочь? 😊" ``` @@ -258,6 +259,16 @@ ollama serve > 💡 **Подсказка**: Модели автоматически подтянутся из настроек Ollama +> 📊 **Размер контекстного окна**: +> - **Qwen2.5:7b**: Базовый контекст = **32,768 токенов** (~24,000 русских слов) +> - Всего данных, отправляемых в модель: +> - Системный промпт: ~500-2000 символов (~300-1200 токенов) +> - История диалога: до 20 сообщений (~100-500 токенов на сообщение = ~2000-10000 токенов) +> - RAG контекст: ~500-2000 токенов (из найденных данных) +> - Текущий вопрос: ~50-200 токенов +> - **Итого**: примерно 3,000-15,000 токенов (запас достаточен) +> - Если нужен больший контекст → используйте Qwen3 с YaRN (до 131K токенов) + ### 4.4 Выбор RAG-таблицы 1. В поле **"Выбранные RAG-таблицы"** выберите созданную таблицу: diff --git a/frontend/src/components/tables/UserTableView.vue b/frontend/src/components/tables/UserTableView.vue index a6b839b..83d31dd 100644 --- a/frontend/src/components/tables/UserTableView.vue +++ b/frontend/src/components/tables/UserTableView.vue @@ -156,7 +156,17 @@ - + +