diff --git a/server/routers/kfu-m-24-1/sber_mobile/index.js b/server/routers/kfu-m-24-1/sber_mobile/index.js index 653579f..9ef7bc9 100644 --- a/server/routers/kfu-m-24-1/sber_mobile/index.js +++ b/server/routers/kfu-m-24-1/sber_mobile/index.js @@ -1,6 +1,4 @@ const router = require('express').Router(); - - const authRouter = require('./auth'); const { supabaseRouter } = require('./supabaseClient'); const profileRouter = require('./profile'); @@ -10,22 +8,19 @@ const additionalServicesRouter = require('./additional_services'); const chatsRouter = require('./chats'); const camerasRouter = require('./cameras'); const ticketsRouter = require('./tickets'); - const messagesRouter = require('./messages'); - const moderationRouter = require('./moderation'); - const utilityPaymentsRouter = require('./utility_payments'); const apartmentsRouter = require('./apartments'); const buildingsRouter = require('./buildings'); const userApartmentsRouter = require('./user_apartments'); const avatarRouter = require('./media'); const supportRouter = require('./supportApi'); +const moderateRouter = require('./moderate.js'); module.exports = router; - router.use('/auth', authRouter); router.use('/supabase', supabaseRouter); router.use('', profileRouter); @@ -35,18 +30,12 @@ router.use('', additionalServicesRouter); router.use('', chatsRouter); router.use('', camerasRouter); router.use('', ticketsRouter); - router.use('', messagesRouter); - router.use('', moderationRouter); - router.use('', utilityPaymentsRouter); router.use('', apartmentsRouter); router.use('', buildingsRouter); router.use('', userApartmentsRouter); router.use('', avatarRouter); router.use('', supportRouter); - - - - +router.use('', moderateRouter); \ No newline at end of file diff --git a/server/routers/kfu-m-24-1/sber_mobile/initiatives-ai-agents/llm.ts b/server/routers/kfu-m-24-1/sber_mobile/initiatives-ai-agents/llm.ts new file mode 100644 index 0000000..a5a0e23 --- /dev/null +++ b/server/routers/kfu-m-24-1/sber_mobile/initiatives-ai-agents/llm.ts @@ -0,0 +1,22 @@ +import { GigaChat as GigaChatLang} from 'langchain-gigachat'; +import { GigaChat } from 'gigachat'; +import { Agent } from 'node:https'; + +const httpsAgent = new Agent({ + rejectUnauthorized: false, +}); + +export const llm_mod = (GIGA_AUTH) => + new GigaChatLang({ + credentials: GIGA_AUTH, + temperature: 0.2, + model: 'GigaChat-2-Max', + httpsAgent, +}); + +export const llm_gen = (GIGA_AUTH) => + new GigaChat({ + credentials: GIGA_AUTH, + model: 'GigaChat-2', + httpsAgent, +}); \ No newline at end of file diff --git a/server/routers/kfu-m-24-1/sber_mobile/initiatives-ai-agents/moderation.ts b/server/routers/kfu-m-24-1/sber_mobile/initiatives-ai-agents/moderation.ts new file mode 100644 index 0000000..dc2022c --- /dev/null +++ b/server/routers/kfu-m-24-1/sber_mobile/initiatives-ai-agents/moderation.ts @@ -0,0 +1,58 @@ +import { llm_mod } from './llm' +import { z } from "zod"; + + +// возвращаю комментарий + исправленное предложение + булево значение + +export const moderationText = async (title: string, description: string, GIGA_AUTH): Promise<[string, string | undefined, boolean]> => { + + const moderationLlm = llm_mod(GIGA_AUTH).withStructuredOutput(z.object({ + comment: z.string(), + fixedText: z.string().optional(), + isApproved: z.boolean(), + }) as any) + + const prompt = ` + Представь, что ты модерируешь предложения от жильцов многоквартирного дома (это личная инициатива по улучшения, + не имеющая отношения к Управляющей компании). + + Заголовок: ${title} + Основной текст: ${description} + + Твои задачи: + 1. Проверь предложение и заголовок на спам. + 2. Проверь, чтобы заголовок и текст были на одну тему. + 3. Проверь само предложение пользователя на отсутствие грубой лексики и пошлостей. + 4. Проверь грамматику. + 5. Проверь на бессмысленность предложения. Оно не должно содержать только случайные символы. + 6. Не должно быть рекламы, ссылок и т.д. + 7. Проверь предложение на информативность, предложение не может быть коротким, оно должно ясно отражжать суть инициативы. + 8. Предложение должно быть в вежливой форме. + + - Если все правила соблюдены, то предложение принимается! + + - Если предложение отклонено, всегда пиши комментарий и fixedText! + + Правила написания комментария: + - Если предложение отклоняется, пиши комментарий со следующей формулировкой: + "Предложение отклонено. Причина: (укажи проблему)" + + Правила написания fixedText: + - Если предложение отклонено, то верни в поле "fixedText" измененный текст, который будет соответствовать правилам. + - Если предложение отклонено и содержит запрещённый контент (рекламу, личные данные), удали всю информацию, + которая противоречит правилам, и верни в только подходящий фрагмент, сохраняя общий смысл. + - Если текст не представляет никакой ценности, возврати в поле "fixedText" правило, + по которому оно не прошло. + -Если предложение принимается, то ничего не возвращай в поле fixedText. + ` + + const result = await moderationLlm.invoke(prompt); + console.log(result) + // Дополнительная проверка + if(!result.isApproved && result.comment.trim() === '' && (!result.fixedText || result.fixedText.trim() === '')) { + result.comment = 'Предложение отклонено. Причина: несоблюдение требований к оформлению или содержанию.', + result.fixedText = description + } + + return [result.comment, result.fixedText, result.isApproved]; +}; \ No newline at end of file diff --git a/server/routers/kfu-m-24-1/sber_mobile/initiatives-ai-agents/picture.ts b/server/routers/kfu-m-24-1/sber_mobile/initiatives-ai-agents/picture.ts new file mode 100644 index 0000000..d216c5d --- /dev/null +++ b/server/routers/kfu-m-24-1/sber_mobile/initiatives-ai-agents/picture.ts @@ -0,0 +1,38 @@ +import { llm_gen } from './llm' +import { detectImage } from 'gigachat'; + +export const generatePicture = async (prompt: string, GIGA_AUTH) => { + const resp = await llm_gen(GIGA_AUTH).chat({ + messages: [ + { + "role": "system", + "content": "Ты — Василий Кандинский для жильцов многоквартирного дома" + }, + { + role: "user", + content: `Старайся передать атмосферу уюта и безопасности. + Нарисуй картинку подходящую для такого события: ${prompt} + В картинке не должно быть текста, только изображение.`, + }, + ], + function_call: 'auto', + }); + + // Получение изображения по идентификатору + const detectedImage = detectImage(resp.choices[0]?.message.content ?? ''); + + if (!detectedImage?.uuid) { + throw new Error('Не удалось получить UUID изображения из ответа GigaChat'); + } + + const image = await llm_gen(GIGA_AUTH).getImage(detectedImage.uuid); + + // Возвращаем содержимое изображения, убеждаясь что это Buffer + if (Buffer.isBuffer(image.content)) { + return image.content; + } else if (typeof image.content === 'string') { + return Buffer.from(image.content, 'binary'); + } else { + throw new Error('Unexpected image content type: ' + typeof image.content); + } +} diff --git a/server/routers/kfu-m-24-1/sber_mobile/initiatives.js b/server/routers/kfu-m-24-1/sber_mobile/initiatives.js index 3ca0562..42f82e4 100644 --- a/server/routers/kfu-m-24-1/sber_mobile/initiatives.js +++ b/server/routers/kfu-m-24-1/sber_mobile/initiatives.js @@ -38,9 +38,9 @@ router.get('/initiatives/:id', async (req, res) => { // Создать инициативу router.post('/initiatives', async (req, res) => { const supabase = getSupabaseClient(); - const { building_id, creator_id, title, description, status, target_amount, image_url } = req.body; + const { building_id, creator_id, title, description, status, target_amount, current_amount, image_url } = req.body; const { data, error } = await supabase.from('initiatives').insert([ - { building_id, creator_id, title, description, status, target_amount, image_url } + { building_id, creator_id, title, description, status, target_amount, current_amount: current_amount || 0, image_url } ]).select().single(); if (error) return res.status(400).json({ error: error.message }); res.json(data); diff --git a/server/routers/kfu-m-24-1/sber_mobile/moderate.js b/server/routers/kfu-m-24-1/sber_mobile/moderate.js new file mode 100644 index 0000000..0e8b3f8 --- /dev/null +++ b/server/routers/kfu-m-24-1/sber_mobile/moderate.js @@ -0,0 +1,164 @@ +const router = require('express').Router(); +const { moderationText } = require('./initiatives-ai-agents/moderation.ts'); +const { generatePicture } = require('./initiatives-ai-agents/picture.ts'); +const { getSupabaseClient } = require('./supabaseClient'); +const { getGigaAuth } = require('./get-constants'); + +async function getGigaKey() { + const GIGA_AUTH = await getGigaAuth(); + return GIGA_AUTH; + } + +// Обработчик для модерации и создания инициативы +router.post('/moderate', async (req, res) => { + + const GIGA_AUTH = await getGigaKey(); + + try { + const { title, description, building_id, creator_id, target_amount, status } = req.body; + + if (!title || !description) { + res.status(400).json({ error: 'Заголовок и описание обязательны' }); + return; + } + + if (!building_id || !creator_id) { + res.status(400).json({ error: 'ID дома и создателя обязательны' }); + return; + } + + // Валидация статуса, если передан + const validStatuses = ['moderation', 'review', 'fundraising', 'approved', 'rejected']; + if (status && !validStatuses.includes(status)) { + res.status(400).json({ error: `Недопустимый статус. Допустимые значения: ${validStatuses.join(', ')}` }); + return; + } + + console.log('Запрос на модерацию:', { title: title.substring(0, 50), description: description.substring(0, 100) }); + + // Модерация текста (передаем title и description как body) + const [comment, fixedText, isApproved] = await moderationText(title, description, GIGA_AUTH); + + console.log('Результат модерации получен:', { comment, fixedText: fixedText?.substring(0, 100), isApproved }); + + // Если модерация не прошла, возвращаем undefined + if (!isApproved) { + if (!comment || comment.trim() === '') { + console.warn('Обнаружен некорректный результат модерации - пустой комментарий при отклонении'); + } + + res.json({ + comment, + fixedText, + isApproved, + initiative: undefined + }); + return; + } + + // Модерация прошла, генерируем изображение используя заголовок как промпт + console.log('Модерация прошла, генерируем изображение с промптом:', title); + + const imageBuffer = await generatePicture(title, GIGA_AUTH); + + if (!imageBuffer || imageBuffer.length === 0) { + res.status(500).json({ error: 'Получен пустой буфер изображения' }); + return; + } + + // Получаем Supabase клиент и создаем имя файла + const supabase = getSupabaseClient(); + const timestamp = Date.now(); + const filename = `image_${creator_id}_${timestamp}.jpg`; + + // Загружаем изображение в Supabase Storage + let uploadResult; + let retries = 0; + const maxRetries = 5; + + while (retries < maxRetries) { + try { + uploadResult = await supabase.storage + .from('images') + .upload(filename, imageBuffer, { + contentType: 'image/jpeg', + upsert: true + }); + + if (!uploadResult.error) { + break; // Успешная загрузка + } + + retries++; + + if (retries < maxRetries) { + // Ждем перед повторной попыткой + await new Promise(resolve => setTimeout(resolve, 1000 * retries)); + } + } catch (error) { + console.warn(`Попытка загрузки ${retries + 1} неудачна (исключение):`, error.message); + retries++; + + if (retries < maxRetries) { + // Ждем перед повторной попыткой + await new Promise(resolve => setTimeout(resolve, 1000 * retries)); + } else { + throw error; // Перебрасываем ошибку после всех попыток + } + } + } + + if (uploadResult?.error) { + console.error('Supabase storage error after all retries:', uploadResult.error); + res.status(500).json({ error: 'Ошибка при сохранении изображения после нескольких попыток' }); + return; + } + + console.log('Изображение успешно загружено в Supabase Storage:', filename); + + // Получаем публичный URL + const { data: urlData } = supabase.storage + .from('images') + .getPublicUrl(filename); + + // Определяем статус: если передан в запросе, используем его, иначе 'review' + const finalStatus = status || 'review'; + + // Создаем инициативу в базе данных + const { data: initiative, error: initiativeError } = await supabase + .from('initiatives') + .insert([{ + building_id, + creator_id, + title: fixedText || title, + description, + status: finalStatus, + target_amount: target_amount || null, + current_amount: 0, + image_url: urlData.publicUrl + }]) + .select() + .single(); + + if (initiativeError) { + console.error('Ошибка создания инициативы:', initiativeError); + res.status(500).json({ error: 'Ошибка при создании инициативы', details: initiativeError.message }); + return; + } + + console.log('Инициатива успешно создана:', initiative.id); + + res.json({ + comment, + fixedText, + isApproved, + initiative + }); + + } catch (error) { + console.error('Error in moderation and initiative creation:', error); + res.status(500).json({ error: 'Внутренняя ошибка сервера', details: error.message }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/server/routers/kfu-m-24-1/sber_mobile/votes.js b/server/routers/kfu-m-24-1/sber_mobile/votes.js index d46df22..9a861da 100644 --- a/server/routers/kfu-m-24-1/sber_mobile/votes.js +++ b/server/routers/kfu-m-24-1/sber_mobile/votes.js @@ -6,39 +6,100 @@ router.get('/votes/:initiative_id', async (req, res) => { const supabase = getSupabaseClient(); const { initiative_id } = req.params; const { data, error } = await supabase.from('votes').select('*').eq('initiative_id', initiative_id); - if (error) return res.status(400).json({ error: error.message }); + if (error) + return res.status(400).json({ error: error.message }); res.json(data); }); // Получить голос пользователя по инициативе -router.get('/votes/:initiative_id/:user_id', async (req, res) => { +router.get('/votes/:initiative_id/user/:user_id', async (req, res) => { const supabase = getSupabaseClient(); const { initiative_id, user_id } = req.params; const { data, error } = await supabase.from('votes').select('*').eq('initiative_id', initiative_id).eq('user_id', user_id).single(); - if (error) return res.status(400).json({ error: error.message }); + if (error) { + console.log(error, '/votes/:initiative_id/:user_id') + console.log(initiative_id, user_id) + return res.status(400).json({ error: error.message }); + } res.json(data); }); -// Получить все голоса по инициативе (через query) -router.get('/votes', async (req, res) => { +// Получить статистику голосов по инициативе +router.get('/votes/stats/:initiative_id', async (req, res) => { const supabase = getSupabaseClient(); - const { initiative_id } = req.query; - if (!initiative_id) return res.status(400).json({ error: 'initiative_id required' }); - const { data, error } = await supabase.from('votes').select('*').eq('initiative_id', initiative_id); - if (error) return res.status(400).json({ error: error.message }); - res.json(data); + const { initiative_id } = req.params; + + const { data, error } = await supabase + .from('votes') + .select('vote_type') + .eq('initiative_id', initiative_id); + console.log(data, error) + if (error) { + console.log('/votes/:initiative_id/stats') + res.status(400).json({ error: error.message }); + } + const stats = { + for: data.filter(vote => vote.vote_type === 'for').length, + against: data.filter(vote => vote.vote_type === 'against').length, + total: data.length + }; + + res.json(stats); }); -// Проголосовать (создать или обновить голос) +// Проголосовать (создать, обновить или удалить голос) router.post('/votes', async (req, res) => { const supabase = getSupabaseClient(); const { initiative_id, user_id, vote_type } = req.body; - // upsert: если голос уже есть, обновить, иначе создать - const { data, error } = await supabase.from('votes').upsert([ - { initiative_id, user_id, vote_type } - ], { onConflict: ['initiative_id', 'user_id'] }).select().single(); - if (error) return res.status(400).json({ error: error.message }); - res.json(data); + + // Проверяем существующий голос + const { data: existingVote, error: checkError } = await supabase + .from('votes') + .select('*') + .eq('initiative_id', initiative_id) + .eq('user_id', user_id) + .single(); + + if (checkError && checkError.code !== 'PGRST116') { + console.log('1/votes') + return res.status(400).json({ error: checkError.message }); + } + + if (existingVote) { + if (existingVote.vote_type === vote_type) { + // Если нажали тот же тип голоса - УДАЛЯЕМ (отменяем голос) + const { error: deleteError } = await supabase + .from('votes') + .delete() + .eq('initiative_id', initiative_id) + .eq('user_id', user_id); + + if (deleteError) return res.status(400).json({ error: deleteError.message }); + res.json({ message: 'Vote removed', action: 'removed', previous_vote: existingVote.vote_type }); + } else { + // Если нажали другой тип голоса - ОБНОВЛЯЕМ + const { data, error } = await supabase + .from('votes') + .update({ vote_type }) + .eq('initiative_id', initiative_id) + .eq('user_id', user_id) + .select() + .single(); + + if (error) return res.status(400).json({ error: error.message }); + res.json({ ...data, action: 'updated', previous_vote: existingVote.vote_type }); + } + } else { + // Если голоса нет - СОЗДАЕМ новый + const { data, error } = await supabase + .from('votes') + .insert([{ initiative_id, user_id, vote_type }]) + .select() + .single(); + + if (error) return res.status(400).json({ error: error.message }); + res.json({ ...data, action: 'created' }); + } }); module.exports = router; \ No newline at end of file