Запрос на слияние 'sber_mobile' (#22) из sber_mobile в main

This commit is contained in:
DmitrievMS
2025-06-14 10:10:37 +00:00
11 changed files with 2846 additions and 118 deletions

2357
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,9 @@
"homepage": "https://bitbucket.org/online-mentor/multi-stub#readme", "homepage": "https://bitbucket.org/online-mentor/multi-stub#readme",
"dependencies": { "dependencies": {
"@supabase/supabase-js": "^2.49.4", "@supabase/supabase-js": "^2.49.4",
"@langchain/community": "^0.3.41",
"@langchain/core": "^0.3.46",
"@langchain/langgraph": "^0.2.65",
"ai": "^4.1.13", "ai": "^4.1.13",
"axios": "^1.7.7", "axios": "^1.7.7",
"bcrypt": "^5.1.0", "bcrypt": "^5.1.0",
@@ -34,8 +37,11 @@
"express": "5.0.1", "express": "5.0.1",
"express-jwt": "^8.5.1", "express-jwt": "^8.5.1",
"express-session": "^1.18.1", "express-session": "^1.18.1",
"gigachat": "^0.0.14",
"jsdom": "^25.0.1", "jsdom": "^25.0.1",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"langchain": "^0.3.7",
"langchain-gigachat": "^0.0.11",
"mongodb": "^6.12.0", "mongodb": "^6.12.0",
"mongoose": "^8.9.2", "mongoose": "^8.9.2",
"mongoose-sequence": "^6.0.1", "mongoose-sequence": "^6.0.1",
@@ -44,7 +50,7 @@
"pbkdf2-password": "^1.2.1", "pbkdf2-password": "^1.2.1",
"rotating-file-stream": "^3.2.5", "rotating-file-stream": "^3.2.5",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"uuid": "^11.0.3" "zod": "^3.24.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.17.0", "@eslint/js": "^9.17.0",

View File

@@ -170,7 +170,7 @@ CREATE TABLE payment_service_details (
CREATE TABLE tickets ( CREATE TABLE tickets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id), user_id UUID NOT NULL REFERENCES auth.users(id),
building_id UUID NOT NULL REFERENCES buildings(id), apartment_id UUID NOT NULL REFERENCES apartments(id),
title TEXT NOT NULL, title TEXT NOT NULL,
description TEXT NOT NULL, description TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('open', 'in_progress', 'resolved')), status TEXT NOT NULL CHECK (status IN ('open', 'in_progress', 'resolved')),
@@ -197,6 +197,7 @@ CREATE INDEX idx_votes_initiative ON votes(initiative_id);
CREATE INDEX idx_messages_chat ON messages(chat_id); CREATE INDEX idx_messages_chat ON messages(chat_id);
CREATE INDEX idx_cameras_building ON cameras(building_id); CREATE INDEX idx_cameras_building ON cameras(building_id);
CREATE INDEX idx_tickets_user ON tickets(user_id); CREATE INDEX idx_tickets_user ON tickets(user_id);
CREATE INDEX idx_tickets_apartment ON tickets(apartment_id);
CREATE INDEX idx_apartments_building ON apartments(building_id); CREATE INDEX idx_apartments_building ON apartments(building_id);
CREATE INDEX idx_apartment_residents_apartment ON apartment_residents(apartment_id); CREATE INDEX idx_apartment_residents_apartment ON apartment_residents(apartment_id);
CREATE INDEX idx_apartment_residents_user ON apartment_residents(user_id); CREATE INDEX idx_apartment_residents_user ON apartment_residents(user_id);

View File

@@ -1,5 +1,3 @@
const fetch = require('node-fetch');
const getSupabaseUrl = async () => { const getSupabaseUrl = async () => {
const response = await fetch('https://admin.bro-js.ru/api/config/v1/dev'); const response = await fetch('https://admin.bro-js.ru/api/config/v1/dev');
const data = await response.json(); const data = await response.json();
@@ -18,8 +16,74 @@ const getSupabaseServiceKey = async () => {
return data.features['sber_mobile'].SUPABASE_SERVICE_KEY.value; return data.features['sber_mobile'].SUPABASE_SERVICE_KEY.value;
}; };
const getGigaAuth = async () => {
const response = await fetch('https://admin.bro-js.ru/api/config/v1/dev');
const data = await response.json();
return data.features['sber_mobile'].GIGA_AUTH.value;
};
const getLangsmithApiKey = async () => {
const response = await fetch('https://admin.bro-js.ru/api/config/v1/dev');
const data = await response.json();
return data.features['sber_mobile'].LANGSMITH_API_KEY.value;
};
const getLangsmithEndpoint = async () => {
const response = await fetch('https://admin.bro-js.ru/api/config/v1/dev');
const data = await response.json();
return data.features['sber_mobile'].LANGSMITH_ENDPOINT.value;
};
const getLangsmithTracing = async () => {
const response = await fetch('https://admin.bro-js.ru/api/config/v1/dev');
const data = await response.json();
return data.features['sber_mobile'].LANGSMITH_TRACING.value;
};
const getLangsmithProject = async () => {
const response = await fetch('https://admin.bro-js.ru/api/config/v1/dev');
const data = await response.json();
return data.features['sber_mobile'].LANGSMITH_PROJECT.value;
};
const getTavilyApiKey = async () => {
const response = await fetch('https://admin.bro-js.ru/api/config/v1/dev');
const data = await response.json();
return data.features['sber_mobile'].TAVILY_API_KEY.value;
};
const getRagSupabaseServiceRoleKey = async () => {
const response = await fetch('https://admin.bro-js.ru/api/config/v1/dev');
const data = await response.json();
return data.features['sber_mobile'].RAG_SUPABASE_SERVICE_ROLE_KEY.value;
};
const getRagSupabaseUrl = async () => {
const response = await fetch('https://admin.bro-js.ru/api/config/v1/dev');
const data = await response.json();
return data.features['sber_mobile'].RAG_SUPABASE_URL.value;
};
module.exports = { module.exports = {
getSupabaseUrl, getSupabaseUrl,
getSupabaseKey, getSupabaseKey,
getSupabaseServiceKey getSupabaseServiceKey
}; };
// IIFE для установки переменных окружения
(async () => {
try {
process.env.GIGA_AUTH = await getGigaAuth();
process.env.LANGSMITH_API_KEY = await getLangsmithApiKey();
process.env.LANGSMITH_ENDPOINT = await getLangsmithEndpoint();
process.env.LANGSMITH_TRACING = await getLangsmithTracing();
process.env.LANGSMITH_PROJECT = await getLangsmithProject();
process.env.TAVILY_API_KEY = await getTavilyApiKey();
process.env.RAG_SUPABASE_SERVICE_ROLE_KEY = await getRagSupabaseServiceRoleKey();
process.env.RAG_SUPABASE_URL = await getRagSupabaseUrl();
console.log('Environment variables loaded successfully');
} catch (error) {
console.error('Error loading environment variables:', error);
}
})();

View File

@@ -0,0 +1,66 @@
import { StructuredTool, ToolRunnableConfig } from '@langchain/core/tools';
import { z } from 'zod';
import { CallbackManagerForToolRun } from '@langchain/core/callbacks/manager';
import { getSupabaseClient } from '../supabaseClient';
export class CreateTicketTool extends StructuredTool {
name = 'create_ticket';
description = 'Создает заявку в системе. ВАЖНО: используй этот инструмент ТОЛЬКО после получения явного согласия пользователя на создание заявки с конкретным текстом.';
schema = z.object({
title: z.string().describe('Заголовок заявки'),
description: z.string().describe('Подробное описание проблемы'),
category: z.string().describe('Категория заявки (например: ремонт, уборка, техническая_поддержка, жалоба)'),
});
private userId: string;
private apartmentId: string;
constructor(userId: string, apartmentId: string) {
super();
this.userId = userId;
this.apartmentId = apartmentId;
}
protected async _call(
arg: z.infer<typeof this.schema>,
runManager?: CallbackManagerForToolRun,
parentConfig?: ToolRunnableConfig<Record<string, any>>
): Promise<string> {
try {
if (!this.apartmentId) {
return 'Не удалось определить вашу квартиру. Обратитесь к администратору для создания заявки.';
}
const supabase = getSupabaseClient();
const { data: ticket, error } = await supabase
.from('tickets')
.insert({
user_id: this.userId,
apartment_id: this.apartmentId,
title: arg.title,
description: arg.description,
category: arg.category,
status: 'open'
})
.select()
.single();
if (error) {
return 'Произошла ошибка при создании заявки. Попробуйте позже или обратитесь к администратору.';
}
return `Заявка успешно создана!
Номер заявки: ${ticket.id}
Заголовок: ${ticket.title}
Статус: Открыта
Дата создания: ${new Date(ticket.created_at).toLocaleString('ru-RU')}
Ваша заявка принята в работу. Мы свяжемся с вами в ближайшее время.`;
} catch (error) {
return 'Произошла техническая ошибка при создании заявки. Пожалуйста, попробуйте позже.';
}
}
}

View File

@@ -0,0 +1,18 @@
import { Agent } from 'node:https';
import { GigaChat } from 'langchain-gigachat';
const httpsAgent = new Agent({
rejectUnauthorized: false,
});
// Получаем GIGA_AUTH из переменной окружения (устанавливается в get-constants.js)
export const gigachat = new GigaChat({
model: 'GigaChat-2',
temperature: 0.7,
scope: 'GIGACHAT_API_PERS',
streaming: false,
credentials: process.env.GIGA_AUTH,
httpsAgent
});
export default gigachat;

View File

@@ -0,0 +1,41 @@
import { StructuredTool, ToolRunnableConfig } from '@langchain/core/tools';
import { z } from 'zod';
import { CallbackManagerForToolRun } from '@langchain/core/callbacks/manager';
import { getVectorStore } from './vector-store';
export class KnowledgeBaseTool extends StructuredTool {
name = 'search_knowledge_base';
description = 'Ищет информацию в базе знаний компании о процессах, оплатах, подаче заявок, правилах и документах УК. Используй этот инструмент для вопросов, требующих специфических знаний о компании.';
schema = z.object({
query: z.string().describe('Поисковый запрос для поиска в базе знаний'),
});
protected async _call(
arg: z.infer<typeof this.schema>,
runManager?: CallbackManagerForToolRun,
parentConfig?: ToolRunnableConfig<Record<string, any>>
): Promise<string> {
try {
const vectorStore = getVectorStore();
const retriever = vectorStore.asRetriever({
k: 5
});
const relevantDocs = await retriever.getRelevantDocuments(arg.query);
if (!relevantDocs || relevantDocs.length === 0) {
return 'В базе знаний не найдено информации по данному запросу. Возможно, стоит переформулировать вопрос или обратиться к специалисту.';
}
const formattedDocs = relevantDocs.map((doc, index) => {
return `Документ ${index + 1}:\n${doc.pageContent}\n`;
}).join('\n---\n');
return `Найдена следующая информация в базе знаний компании:\n\n${formattedDocs}\n\спользуй эту информацию для ответа на вопрос пользователя.`;
} catch (error) {
return 'Произошла ошибка при поиске в базе знаний. Попробуйте переформулировать запрос.';
}
}
}

View File

@@ -0,0 +1,166 @@
import { HumanMessage, AIMessage, SystemMessage, BaseMessage } from '@langchain/core/messages';
import { ChatPromptTemplate, MessagesPlaceholder } from '@langchain/core/prompts';
import { createReactAgent } from '@langchain/langgraph/prebuilt';
import { MemorySaver } from '@langchain/langgraph';
import gigachat from './gigachat';
import { SupportContextTool } from './support-context-tool';
import { KnowledgeBaseTool } from './knowledge-base-tool';
import { CreateTicketTool } from './create-ticket-tool';
export interface SupportAgentConfig {
temperature?: number;
threadId?: string;
}
export interface SupportResponse {
content: string;
success: boolean;
error?: string;
}
export class SupportAgent {
private llm: any;
private memorySaver: MemorySaver;
private agent: any;
private systemPrompt: string;
private threadId: string;
private isFirstMessage: boolean;
private userId: string;
constructor(config: SupportAgentConfig = {}) {
this.systemPrompt = this.getDefaultSystemPrompt();
this.threadId = config.threadId || 'default';
this.userId = this.threadId;
this.memorySaver = new MemorySaver();
this.isFirstMessage = true;
this.llm = gigachat;
if (config.temperature !== undefined) {
this.llm.temperature = config.temperature;
}
const tools = [
new SupportContextTool(this.userId),
new KnowledgeBaseTool()
];
this.agent = createReactAgent({
llm: this.llm,
tools: tools,
checkpointSaver: this.memorySaver
});
}
private getDefaultSystemPrompt(): string {
return `Ты - профессиональный агент службы поддержки управляющей компании.
ОСНОВНЫЕ ПРИНЦИПЫ:
- Помогай только с реальными проблемами и вопросами, связанными с ЖКХ, управляющей компанией и приложением
- Будь вежливым, профессиональным и по существу
- Если вопрос неуместен, не связан с твоими обязанностями или является развлекательным - вежливо откажись и перенаправь к основным темам
ДОСТУПНЫЕ ИНСТРУМЕНТЫ:
1. get_support_context - получает историю сообщений пользователя
ВСЕГДА используй ПЕРВЫМ при каждом новом сообщении
2. search_knowledge_base - поиск в базе знаний компании
Используй ТОЛЬКО для серьезных вопросов о:
- Процессах оплаты ЖКХ и тарифах
- Подаче заявок и документообороте
- Правилах и регламентах УК
- Технических вопросах приложения
- Процедурах и инструкциях компании
3. create_ticket - создание заявки в системе
Используй ТОЛЬКО когда:
- Пользователь сообщает о реальной проблеме (поломка, неисправность, жалоба)
- Проблема требует вмешательства УК или технических служб
- ОБЯЗАТЕЛЬНО сначала покажи пользователю полный текст заявки
- Получи ЯВНОЕ согласие пользователя перед созданием
- НЕ создавай заявки для консультационных вопросов
ПРАВИЛА ИСПОЛЬЗОВАНИЯ ИНСТРУМЕНТОВ:
- НЕ используй search_knowledge_base и create_ticket для:
* Общих вопросов и болтовни
* Развлекательных запросов
* Вопросов не по теме ЖКХ/УК
* Простых консультаций, которые можно решить обычным ответом
АЛГОРИТМ РАБОТЫ:
1. Получи контекст истории сообщений
2. Определи, является ли вопрос уместным и серьезным
3. Если нужна специфическая информация - найди в базе знаний
4. Если нужно создать заявку - покажи текст и получи согласие
5. Дай полный и полезный ответ
Всегда отвечай на русском языке и фокусируйся на помощи с реальными проблемами ЖКХ.`;
}
public async processMessage(userMessage: string, apartmentId?: string): Promise<SupportResponse> {
try {
const messages: BaseMessage[] = [];
if (this.isFirstMessage) {
messages.push(new SystemMessage(this.systemPrompt));
this.isFirstMessage = false;
}
messages.push(new HumanMessage(userMessage));
// Создаем инструменты с актуальным apartmentId
const tools = [
new SupportContextTool(this.userId),
new KnowledgeBaseTool(),
new CreateTicketTool(this.userId, apartmentId || '')
];
// Пересоздаем агента с обновленными инструментами
const tempAgent = createReactAgent({
llm: this.llm,
tools: tools,
checkpointSaver: this.memorySaver
});
const response = await tempAgent.invoke({
messages: messages
}, {
configurable: {
thread_id: this.threadId
}
});
const lastMessage = response.messages[response.messages.length - 1];
return {
content: typeof lastMessage.content === 'string' ? lastMessage.content : 'Извините, не удалось сформировать ответ.',
success: true
};
} catch (error) {
console.error('Ошибка при обработке сообщения:', error);
return {
content: 'Извините, произошла ошибка при обработке вашего запроса. Попробуйте позже.',
success: false,
error: error instanceof Error ? error.message : 'Неизвестная ошибка'
};
}
}
public async clearHistory(): Promise<void> {
this.memorySaver = new MemorySaver();
const tools = [
new SupportContextTool(this.userId),
new KnowledgeBaseTool()
];
this.agent = createReactAgent({
llm: this.llm,
tools: tools,
checkpointSaver: this.memorySaver
});
this.isFirstMessage = true;
}
}

View File

@@ -0,0 +1,56 @@
import { StructuredTool, ToolRunnableConfig } from '@langchain/core/tools';
import { z } from 'zod';
import { CallbackManagerForToolRun } from '@langchain/core/callbacks/manager';
import { getSupabaseClient } from '../supabaseClient';
export class SupportContextTool extends StructuredTool {
name = 'get_support_context';
description = 'Получает последние 10 сообщений из истории поддержки для понимания контекста разговора. Используй этот инструмент в начале разговора.';
schema = z.object({});
private userId: string;
constructor(userId: string) {
super();
this.userId = userId;
}
protected async _call(
arg: z.infer<typeof this.schema>,
runManager?: CallbackManagerForToolRun,
parentConfig?: ToolRunnableConfig<Record<string, any>>
): Promise<string> {
try {
const supabase = getSupabaseClient();
const { data: messages, error } = await supabase
.from('support')
.select('message, is_from_user, created_at')
.eq('user_id', this.userId)
.order('created_at', { ascending: false })
.limit(10);
if (error) {
return 'Не удалось получить историю сообщений.';
}
if (!messages || messages.length === 0) {
return 'История сообщений поддержки пуста. Это первое обращение пользователя.';
}
const chronologicalMessages = messages.reverse();
const contextMessages = chronologicalMessages.map((msg, index) => {
const role = msg.is_from_user ? 'Пользователь' : 'Агент поддержки';
const time = new Date(msg.created_at).toLocaleString('ru-RU');
return `${index + 1}. [${time}] ${role}: ${msg.message}`;
}).join('\n');
return `Последние сообщения из истории поддержки (${messages.length} сообщений):\n\n${contextMessages}\n\спользуй этот контекст для понимания предыдущих обращений пользователя и предоставления более точных ответов.`;
} catch (error) {
return 'Произошла ошибка при получении истории сообщений.';
}
}
}

View File

@@ -0,0 +1,33 @@
import { createClient } from '@supabase/supabase-js';
import { SupabaseVectorStore } from '@langchain/community/vectorstores/supabase';
import { GigaChatEmbeddings } from 'langchain-gigachat';
import { Agent } from 'node:https';
const httpsAgent = new Agent({
rejectUnauthorized: false,
});
let vectorStoreInstance: SupabaseVectorStore | null = null;
export function getVectorStore(): SupabaseVectorStore {
if (!vectorStoreInstance) {
const client = createClient(
process.env.RAG_SUPABASE_URL!,
process.env.RAG_SUPABASE_SERVICE_ROLE_KEY!,
);
vectorStoreInstance = new SupabaseVectorStore(
new GigaChatEmbeddings({
credentials: process.env.GIGA_AUTH,
httpsAgent,
}),
{
client,
tableName: 'slon',
queryName: 'match_slon'
}
);
}
return vectorStoreInstance;
}

View File

@@ -1,16 +1,148 @@
const router = require('express').Router(); const router = require('express').Router();
const { getSupabaseClient } = require('./supabaseClient'); const { getSupabaseClient } = require('./supabaseClient');
const { SupportAgent } = require('./support-ai-agent/support-agent');
// Хранилище агентов для разных пользователей
const userAgents = new Map();
/**
* Получить или создать агента для пользователя
*/
function getUserAgent(userId) {
if (!userAgents.has(userId)) {
const config = {
threadId: userId,
temperature: 0.7
};
userAgents.set(userId, new SupportAgent(config));
}
return userAgents.get(userId);
}
// GET /api/support - Получить историю сообщений пользователя
router.get('/support', async (req, res) => {
const supabase = getSupabaseClient();
const { user_id } = req.query;
if (!user_id) {
return res.status(400).json({ error: 'user_id обязателен' });
}
try {
// Получаем все сообщения пользователя из базы данных
const { data: messages, error } = await supabase
.from('support')
.select('*')
.eq('user_id', user_id)
.order('created_at', { ascending: true });
if (error) {
return res.status(400).json({ error: error.message });
}
res.json({
messages: messages || [],
success: true
});
} catch (error) {
console.error('Ошибка в GET /support:', error);
res.status(500).json({
error: 'Внутренняя ошибка сервера',
success: false
});
}
});
// POST /api/support // POST /api/support
router.post('/support', async (req, res) => { router.post('/support', async (req, res) => {
const supabase = getSupabaseClient(); const supabase = getSupabaseClient();
const { user_id, message } = req.body; const { user_id, message, apartment_id } = req.body;
if (!user_id || !message) return res.status(400).json({ error: 'user_id и message обязательны' });
const { error } = await supabase if (!user_id || !message) {
.from('support') return res.status(400).json({ error: 'user_id и message обязательны' });
.insert({ user_id, message, is_from_user: true }); }
if (error) return res.status(400).json({ error: error.message });
res.json({ reply: 'Спасибо за ваше сообщение! Служба поддержки свяжется с вами в ближайшее время.' }); try {
// Сохраняем сообщение пользователя в базу данных
const { error: insertError } = await supabase
.from('support')
.insert({ user_id, message, is_from_user: true });
if (insertError) {
return res.status(400).json({ error: insertError.message });
}
// Получаем агента для пользователя
const agent = getUserAgent(user_id);
// Получаем ответ от AI-агента, передавая apartment_id
const aiResponse = await agent.processMessage(message, apartment_id);
if (!aiResponse.success) {
console.error('Ошибка AI-агента:', aiResponse.error);
return res.status(500).json({
error: 'Ошибка при генерации ответа',
reply: 'Извините, произошла ошибка. Попробуйте позже.'
});
}
// Сохраняем ответ агента в базу данных
const { error: responseError } = await supabase
.from('support')
.insert({
user_id,
message: aiResponse.content,
is_from_user: false
});
if (responseError) {
console.error('Ошибка сохранения ответа:', responseError);
// Не возвращаем ошибку пользователю, так как ответ уже сгенерирован
}
// Возвращаем ответ пользователю
res.json({
reply: aiResponse.content,
success: true
});
} catch (error) {
console.error('Ошибка в supportApi:', error);
res.status(500).json({
error: 'Внутренняя ошибка сервера',
reply: 'Извините, произошла ошибка. Попробуйте позже.'
});
}
});
// DELETE /api/support/history/:userId - Очистка истории диалога
router.delete('/support/history/:userId', async (req, res) => {
const { userId } = req.params;
try {
if (userAgents.has(userId)) {
const agent = userAgents.get(userId);
await agent.clearHistory();
res.json({
message: 'История диалога очищена',
success: true
});
} else {
res.json({
message: 'Агент для данного пользователя не найден',
success: true
});
}
} catch (error) {
console.error('Ошибка в /support/history:', error);
res.status(500).json({
error: 'Внутренняя ошибка сервера',
success: false
});
}
}); });
module.exports = router; module.exports = router;