ваше сообщение коммита

This commit is contained in:
2025-07-09 15:05:42 +03:00
parent f6b678b20f
commit 158f9221f6
6 changed files with 260 additions and 37 deletions

View File

@@ -76,9 +76,16 @@ async function reinitPoolFromDbSettings() {
} }
} }
// При старте приложения — сразу пробуем инициализировать из db_settings // При старте приложения — убираем автоматический вызов reinitPoolFromDbSettings
if (process.env.NODE_ENV !== 'migration') { // if (process.env.NODE_ENV !== 'migration') {
reinitPoolFromDbSettings(); // reinitPoolFromDbSettings();
// }
// Экспортируем функцию для явной инициализации пула
async function initDbPool() {
if (process.env.NODE_ENV !== 'migration') {
await reinitPoolFromDbSettings();
}
} }
// Функция для сохранения гостевого сообщения в базе данных // Функция для сохранения гостевого сообщения в базе данных
@@ -99,4 +106,4 @@ async function saveGuestMessageToDatabase(message, language, guestId) {
} }
// Экспортируем функции для работы с базой данных // Экспортируем функции для работы с базой данных
module.exports = { query, getQuery, pool, getPool, setPoolChangeCallback }; module.exports = { query, getQuery, pool, getPool, setPoolChangeCallback, initDbPool };

View File

@@ -4,6 +4,7 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const db = require('../db'); const db = require('../db');
const { requireAuth } = require('../middleware/auth'); const { requireAuth } = require('../middleware/auth');
const vectorSearchClient = require('../services/vectorSearchClient');
router.use((req, res, next) => { router.use((req, res, next) => {
console.log('Tables router received:', req.method, req.originalUrl); console.log('Tables router received:', req.method, req.originalUrl);
@@ -79,6 +80,13 @@ router.post('/:id/rows', async (req, res, next) => {
'INSERT INTO user_rows (table_id) VALUES ($1) RETURNING *', 'INSERT INTO user_rows (table_id) VALUES ($1) RETURNING *',
[tableId] [tableId]
); );
// Получаем все строки и значения для upsert
const rows = (await db.getQuery()('SELECT r.id as row_id, c.value as text, c2.value as answer FROM user_rows r LEFT JOIN user_cell_values c ON c.row_id = r.id AND c.column_id = 1 LEFT JOIN user_cell_values c2 ON c2.row_id = r.id AND c2.column_id = 2 WHERE r.table_id = $1', [tableId])).rows;
const upsertRows = rows.filter(r => r.row_id && r.text).map(r => ({ row_id: r.row_id, text: r.text, metadata: { answer: r.answer } }));
console.log('[DEBUG][upsertRows]', upsertRows);
if (upsertRows.length > 0) {
await vectorSearchClient.upsert(tableId, upsertRows);
}
res.json(result.rows[0]); res.json(result.rows[0]);
} catch (err) { } catch (err) {
next(err); next(err);
@@ -94,6 +102,24 @@ router.patch('/cell/:cellId', async (req, res, next) => {
'UPDATE user_cell_values SET value = $1, updated_at = NOW() WHERE id = $2 RETURNING *', 'UPDATE user_cell_values SET value = $1, updated_at = NOW() WHERE id = $2 RETURNING *',
[value, cellId] [value, cellId]
); );
// Получаем row_id и table_id
const row = (await db.getQuery()('SELECT row_id FROM user_cell_values WHERE id = $1', [cellId])).rows[0];
if (row) {
const rowId = row.row_id;
const table = (await db.getQuery()('SELECT table_id FROM user_rows WHERE id = $1', [rowId])).rows[0];
if (table) {
const tableId = table.table_id;
// Получаем всю строку для upsert
const rowData = (await db.getQuery()('SELECT r.id as row_id, c.value as text, c2.value as answer FROM user_rows r LEFT JOIN user_cell_values c ON c.row_id = r.id AND c.column_id = 1 LEFT JOIN user_cell_values c2 ON c2.row_id = r.id AND c2.column_id = 2 WHERE r.id = $1', [rowId])).rows[0];
if (rowData) {
const upsertRows = [{ row_id: rowData.row_id, text: rowData.text, metadata: { answer: rowData.answer } }].filter(r => r.row_id && r.text);
console.log('[DEBUG][upsertRows]', upsertRows);
if (upsertRows.length > 0) {
await vectorSearchClient.upsert(tableId, upsertRows);
}
}
}
}
res.json(result.rows[0]); res.json(result.rows[0]);
} catch (err) { } catch (err) {
next(err); next(err);
@@ -110,6 +136,20 @@ router.post('/cell', async (req, res, next) => {
RETURNING *`, RETURNING *`,
[row_id, column_id, value] [row_id, column_id, value]
); );
// Получаем table_id
const table = (await db.getQuery()('SELECT table_id FROM user_rows WHERE id = $1', [row_id])).rows[0];
if (table) {
const tableId = table.table_id;
// Получаем всю строку для upsert
const rowData = (await db.getQuery()('SELECT r.id as row_id, c.value as text, c2.value as answer FROM user_rows r LEFT JOIN user_cell_values c ON c.row_id = r.id AND c.column_id = 1 LEFT JOIN user_cell_values c2 ON c2.row_id = r.id AND c2.column_id = 2 WHERE r.id = $1', [row_id])).rows[0];
if (rowData) {
const upsertRows = [{ row_id: rowData.row_id, text: rowData.text, metadata: { answer: rowData.answer } }].filter(r => r.row_id && r.text);
console.log('[DEBUG][upsertRows]', upsertRows);
if (upsertRows.length > 0) {
await vectorSearchClient.upsert(tableId, upsertRows);
}
}
}
res.json(result.rows[0]); res.json(result.rows[0]);
} catch (err) { } catch (err) {
next(err); next(err);
@@ -120,7 +160,19 @@ router.post('/cell', async (req, res, next) => {
router.delete('/row/:rowId', async (req, res, next) => { router.delete('/row/:rowId', async (req, res, next) => {
try { try {
const rowId = req.params.rowId; const rowId = req.params.rowId;
// Получаем table_id
const table = (await db.getQuery()('SELECT table_id FROM user_rows WHERE id = $1', [rowId])).rows[0];
await db.getQuery()('DELETE FROM user_rows WHERE id = $1', [rowId]); await db.getQuery()('DELETE FROM user_rows WHERE id = $1', [rowId]);
if (table) {
const tableId = table.table_id;
// Получаем все строки для rebuild
const rows = (await db.getQuery()('SELECT r.id as row_id, c.value as text, c2.value as answer FROM user_rows r LEFT JOIN user_cell_values c ON c.row_id = r.id AND c.column_id = 1 LEFT JOIN user_cell_values c2 ON c2.row_id = r.id AND c2.column_id = 2 WHERE r.table_id = $1', [tableId])).rows;
const rebuildRows = rows.filter(r => r.row_id && r.text).map(r => ({ row_id: r.row_id, text: r.text, metadata: { answer: r.answer } }));
console.log('[DEBUG][rebuildRows]', rebuildRows);
if (rebuildRows.length > 0) {
await vectorSearchClient.rebuild(tableId, rebuildRows);
}
}
res.json({ success: true }); res.json({ success: true });
} catch (err) { } catch (err) {
next(err); next(err);
@@ -206,6 +258,51 @@ router.patch('/:id', async (req, res, next) => {
} }
}); });
// Получить id колонок с purpose 'question' и 'answer'
async function getQuestionAnswerColumnIds(tableId) {
const { rows } = await db.getQuery()(
`SELECT id, options FROM user_columns WHERE table_id = $1`, [tableId]
);
let questionCol = null, answerCol = null;
for (const col of rows) {
if (col.options && col.options.purpose === 'question') questionCol = col.id;
if (col.options && col.options.purpose === 'answer') answerCol = col.id;
}
return { questionCol, answerCol };
}
// Пересобрать векторный индекс для таблицы (только для админа)
router.post('/:id/rebuild-index', requireAuth, async (req, res, next) => {
try {
if (!req.session.isAdmin) {
return res.status(403).json({ error: 'Доступ только для администратора' });
}
const tableId = req.params.id;
const { questionCol, answerCol } = await getQuestionAnswerColumnIds(tableId);
if (!questionCol || !answerCol) {
return res.status(400).json({ error: 'Не найдены колонки с вопросами и ответами' });
}
const rows = (await db.getQuery()(
`SELECT r.id as row_id, c.value as text, c2.value as answer
FROM user_rows r
LEFT JOIN user_cell_values c ON c.row_id = r.id AND c.column_id = $2
LEFT JOIN user_cell_values c2 ON c2.row_id = r.id AND c2.column_id = $3
WHERE r.table_id = $1`,
[tableId, questionCol, answerCol]
)).rows;
const rebuildRows = rows.filter(r => r.row_id && r.text).map(r => ({ row_id: r.row_id, text: r.text, metadata: { answer: r.answer } }));
console.log('[DEBUG][rebuildRows]', rebuildRows);
if (rebuildRows.length > 0) {
await vectorSearchClient.rebuild(tableId, rebuildRows);
res.json({ success: true, count: rebuildRows.length });
} else {
res.status(400).json({ error: 'Нет валидных строк для индексации' });
}
} catch (err) {
next(err);
}
});
// DELETE: удалить таблицу и каскадно все связанные строки/столбцы/ячейки (доступно всем) // DELETE: удалить таблицу и каскадно все связанные строки/столбцы/ячейки (доступно всем)
router.delete('/:id', requireAuth, async (req, res, next) => { router.delete('/:id', requireAuth, async (req, res, next) => {
const dbModule = require('../db'); const dbModule = require('../db');

View File

@@ -5,6 +5,7 @@ const { initWSS } = require('./wsHub');
const logger = require('./utils/logger'); const logger = require('./utils/logger');
const { getBot } = require('./services/telegramBot'); const { getBot } = require('./services/telegramBot');
const EmailBotService = require('./services/emailBot'); const EmailBotService = require('./services/emailBot');
const { initDbPool } = require('./db');
const PORT = process.env.PORT || 8000; const PORT = process.env.PORT || 8000;
@@ -44,10 +45,15 @@ async function initServices() {
const server = http.createServer(app); const server = http.createServer(app);
initWSS(server); initWSS(server);
async function startServer() {
await initDbPool(); // Дождаться пересоздания пула!
await initServices(); // Только теперь запускать сервисы
console.log(`Server is running on port ${PORT}`);
}
server.listen(PORT, async () => { server.listen(PORT, async () => {
try { try {
await initServices(); await startServer();
console.log(`Server is running on port ${PORT}`);
} catch (error) { } catch (error) {
console.error('Error starting server:', error); console.error('Error starting server:', error);
process.exit(1); process.exit(1);

View File

@@ -8,6 +8,8 @@ const identityService = require('./identity-service');
const aiAssistant = require('./ai-assistant'); const aiAssistant = require('./ai-assistant');
const { checkAdminRole } = require('./admin-role'); const { checkAdminRole } = require('./admin-role');
const { broadcastContactsUpdate } = require('../wsHub'); const { broadcastContactsUpdate } = require('../wsHub');
const aiAssistantSettingsService = require('./aiAssistantSettingsService');
const { ragAnswer, generateLLMResponse } = require('./ragService');
let botInstance = null; let botInstance = null;
let telegramSettingsCache = null; let telegramSettingsCache = null;
@@ -335,8 +337,34 @@ async function getBot() {
] ]
); );
// 3. Получить ответ от ИИ // 3. Получить ответ от ИИ (RAG + LLM)
const aiResponse = await aiAssistant.getResponse(content, 'auto'); const aiSettings = await aiAssistantSettingsService.getSettings();
let ragTableId = null;
if (aiSettings && aiSettings.selected_rag_tables) {
ragTableId = Array.isArray(aiSettings.selected_rag_tables)
? aiSettings.selected_rag_tables[0]
: aiSettings.selected_rag_tables;
}
let aiResponse;
if (ragTableId) {
// Сначала ищем ответ через RAG
const ragResult = await ragAnswer({ tableId: ragTableId, userQuestion: content });
if (ragResult && ragResult.answer) {
aiResponse = ragResult.answer;
} else {
aiResponse = await generateLLMResponse({
userQuestion: content,
context: ragResult && ragResult.context ? ragResult.context : '',
answer: ragResult && ragResult.answer ? ragResult.answer : '',
systemPrompt: aiSettings ? aiSettings.system_prompt : '',
history: null,
model: aiSettings ? aiSettings.model : undefined,
language: aiSettings && aiSettings.languages && aiSettings.languages.length > 0 ? aiSettings.languages[0] : 'ru'
});
}
} else {
aiResponse = await aiAssistant.getResponse(content, 'auto');
}
// 4. Сохранить ответ в БД с conversation_id // 4. Сохранить ответ в БД с conversation_id
await db.getQuery()( await db.getQuery()(
`INSERT INTO messages (user_id, conversation_id, sender_type, content, channel, role, direction, created_at) `INSERT INTO messages (user_id, conversation_id, sender_type, content, channel, role, direction, created_at)

View File

@@ -1,8 +1,11 @@
const axios = require('axios'); const axios = require('axios');
const logger = require('../utils/logger');
const VECTOR_SEARCH_URL = process.env.VECTOR_SEARCH_URL || 'http://vector-search:8001'; const VECTOR_SEARCH_URL = process.env.VECTOR_SEARCH_URL || 'http://vector-search:8001';
async function upsert(tableId, rows) { async function upsert(tableId, rows) {
logger.info(`[VectorSearch] upsert: tableId=${tableId}, rows=${rows.length}`);
try {
const res = await axios.post(`${VECTOR_SEARCH_URL}/upsert`, { const res = await axios.post(`${VECTOR_SEARCH_URL}/upsert`, {
table_id: String(tableId), table_id: String(tableId),
rows: rows.map(r => ({ rows: rows.map(r => ({
@@ -11,27 +14,48 @@ async function upsert(tableId, rows) {
metadata: r.metadata || {} metadata: r.metadata || {}
})) }))
}); });
logger.info(`[VectorSearch] upsert result:`, res.data);
return res.data; return res.data;
} catch (error) {
logger.error(`[VectorSearch] upsert error:`, error.message);
throw error;
}
} }
async function search(tableId, query, topK = 3) { async function search(tableId, query, topK = 3) {
logger.info(`[VectorSearch] search: tableId=${tableId}, query="${query}", topK=${topK}`);
try {
const res = await axios.post(`${VECTOR_SEARCH_URL}/search`, { const res = await axios.post(`${VECTOR_SEARCH_URL}/search`, {
table_id: String(tableId), table_id: String(tableId),
query, query,
top_k: topK top_k: topK
}); });
logger.info(`[VectorSearch] search result:`, res.data.results);
return res.data.results; return res.data.results;
} catch (error) {
logger.error(`[VectorSearch] search error:`, error.message);
throw error;
}
} }
async function remove(tableId, rowIds) { async function remove(tableId, rowIds) {
const res = await axios.post(`${VECTOR_SEARCH_URL}/delete`, { logger.info(`[VectorSearch] remove: tableId=${tableId}, rowIds=${rowIds}`);
try {
const res = await axios.post(`${VECTOR_SEARCH_URL}/remove`, {
table_id: String(tableId), table_id: String(tableId),
row_ids: rowIds.map(id => String(id)) row_ids: rowIds.map(String)
}); });
logger.info(`[VectorSearch] remove result:`, res.data);
return res.data; return res.data;
} catch (error) {
logger.error(`[VectorSearch] remove error:`, error.message);
throw error;
}
} }
async function rebuild(tableId, rows) { async function rebuild(tableId, rows) {
logger.info(`[VectorSearch] rebuild: tableId=${tableId}, rows=${rows.length}`);
try {
const res = await axios.post(`${VECTOR_SEARCH_URL}/rebuild`, { const res = await axios.post(`${VECTOR_SEARCH_URL}/rebuild`, {
table_id: String(tableId), table_id: String(tableId),
rows: rows.map(r => ({ rows: rows.map(r => ({
@@ -40,18 +64,26 @@ async function rebuild(tableId, rows) {
metadata: r.metadata || {} metadata: r.metadata || {}
})) }))
}); });
logger.info(`[VectorSearch] rebuild result:`, res.data);
return res.data; return res.data;
} catch (error) {
logger.error(`[VectorSearch] rebuild error:`, error.message);
throw error;
}
} }
async function health() { async function health() {
logger.info(`[VectorSearch] health check`);
try { try {
const res = await axios.get(`${VECTOR_SEARCH_URL}/health`, { timeout: 5000 }); const res = await axios.get(`${VECTOR_SEARCH_URL}/health`, { timeout: 5000 });
logger.info(`[VectorSearch] health result:`, res.data);
return { return {
status: 'ok', status: 'ok',
url: VECTOR_SEARCH_URL, url: VECTOR_SEARCH_URL,
response: res.data response: res.data
}; };
} catch (error) { } catch (error) {
logger.error(`[VectorSearch] health error:`, error.message);
return { return {
status: 'error', status: 'error',
url: VECTOR_SEARCH_URL, url: VECTOR_SEARCH_URL,

View File

@@ -2,6 +2,12 @@
<div class="user-table-header" v-if="tableMeta"> <div class="user-table-header" v-if="tableMeta">
<h2>{{ tableMeta.name }}</h2> <h2>{{ tableMeta.name }}</h2>
<div class="table-desc">{{ tableMeta.description }}</div> <div class="table-desc">{{ tableMeta.description }}</div>
<button v-if="isAdmin" class="rebuild-btn" @click="rebuildIndex" :disabled="rebuilding">
{{ rebuilding ? 'Пересборка...' : 'Пересобрать индекс' }}
</button>
<span v-if="rebuildStatus" :class="['rebuild-status', rebuildStatus.success ? 'success' : 'error']">
{{ rebuildStatus.message }}
</span>
</div> </div>
<div class="notion-table-wrapper"> <div class="notion-table-wrapper">
<table class="notion-table"> <table class="notion-table">
@@ -97,6 +103,11 @@
import { ref, onMounted } from 'vue'; import { ref, onMounted } from 'vue';
import tablesService from '../../services/tablesService'; import tablesService from '../../services/tablesService';
import TableCell from './TableCell.vue'; import TableCell from './TableCell.vue';
import { useAuthContext } from '@/composables/useAuth';
import axios from 'axios';
const { isAdmin } = useAuthContext();
const rebuilding = ref(false);
const rebuildStatus = ref(null);
const props = defineProps({ tableId: Number }); const props = defineProps({ tableId: Number });
const columns = ref([]); const columns = ref([]);
@@ -252,6 +263,22 @@ function deleteColumn(col) {
tablesService.deleteColumn(col.id).then(fetchTable); tablesService.deleteColumn(col.id).then(fetchTable);
} }
} }
async function rebuildIndex() {
rebuilding.value = true;
rebuildStatus.value = null;
try {
const { data } = await axios.post(`/tables/${props.tableId}/rebuild-index`);
if (data.success) {
rebuildStatus.value = { success: true, message: `Индекс пересобран (${data.count || 0} строк)` };
} else {
rebuildStatus.value = { success: false, message: data.message || 'Ошибка пересборки' };
}
} catch (e) {
rebuildStatus.value = { success: false, message: e.response?.data?.error || e.message || 'Ошибка запроса' };
}
rebuilding.value = false;
}
</script> </script>
<style scoped> <style scoped>
@@ -448,4 +475,30 @@ tr:hover .delete-row-btn {
top: 0; left: 0; right: 0; bottom: 0; top: 0; left: 0; right: 0; bottom: 0;
z-index: 1999; z-index: 1999;
} }
.rebuild-btn {
margin-top: 1em;
background: #2ecc40;
color: #fff;
border: none;
border-radius: 6px;
padding: 0.5rem 1.5rem;
cursor: pointer;
font-size: 1rem;
transition: background 0.2s;
}
.rebuild-btn:disabled {
background: #b2e6c2;
color: #fff;
cursor: not-allowed;
}
.rebuild-status {
margin-left: 1em;
font-weight: 500;
}
.rebuild-status.success {
color: #2ecc40;
}
.rebuild-status.error {
color: #ff4d4f;
}
</style> </style>