5 Commits
Author SHA1 Message Date
Arxip222 b0bbc739f3 update swagger
continuous-integration/drone/push Build is passing
2025-12-24 18:45:38 +03:00
Arxip222 42fcc0eb16 Забыл батчить, теперь ок
continuous-integration/drone/push Build is passing
2025-12-24 16:17:50 +03:00
Arxip222 683f779c31 UTF 8 вместо абракадабры
continuous-integration/drone/push Build is passing
2025-12-24 15:55:49 +03:00
Arxip222 ef71c67683 styling add
continuous-integration/drone/push Build is passing
2025-12-24 15:49:39 +03:00
Arxip222 570f0b7ea7 Messages for indexing 2025-12-24 15:45:35 +03:00
9 changed files with 1580 additions and 249 deletions
+1405 -214
View File
File diff suppressed because it is too large Load Diff
@@ -42,9 +42,18 @@ class RAGService:
) )
) )
embeddings = self.embedding_service.embed_texts([c.content for c in chunks]) EMBEDDING_BATCH_SIZE = 50
all_embeddings: list[list[float]] = []
for i in range(0, len(chunks), EMBEDDING_BATCH_SIZE):
batch_chunks = chunks[i:i + EMBEDDING_BATCH_SIZE]
batch_texts = [c.content for c in batch_chunks]
batch_embeddings = self.embedding_service.embed_texts(batch_texts)
all_embeddings.extend(batch_embeddings)
print(f"Created {len(all_embeddings)} embeddings, upserting to Qdrant...")
await self.vector_repository.upsert_chunks( await self.vector_repository.upsert_chunks(
chunks, embeddings, model_version=self.embedding_service.model_version() chunks, all_embeddings, model_version=self.embedding_service.model_version()
) )
return chunks return chunks
@@ -39,5 +39,10 @@ class TextSplitter:
def _split_sentences(self, text: str) -> Iterable[str]: def _split_sentences(self, text: str) -> Iterable[str]:
parts = re.split(r"(?<=[\.\?\!])\s+", text) parts = re.split(r"(?<=[\.\?\!])\s+", text)
if len(parts) == 1 and len(text) > self.chunk_size * 2:
chunk_text = []
for i in range(0, len(text), self.chunk_size):
chunk_text.append(text[i:i + self.chunk_size])
return chunk_text
return [p.strip() for p in parts if p.strip()] return [p.strip() for p in parts if p.strip()]
@@ -3,6 +3,7 @@ Use cases для работы с документами
""" """
from uuid import UUID from uuid import UUID
from typing import BinaryIO, Optional from typing import BinaryIO, Optional
import httpx
from src.domain.entities.document import Document from src.domain.entities.document import Document
from src.domain.repositories.document_repository import IDocumentRepository from src.domain.repositories.document_repository import IDocumentRepository
from src.domain.repositories.collection_repository import ICollectionRepository from src.domain.repositories.collection_repository import ICollectionRepository
@@ -10,6 +11,7 @@ from src.domain.repositories.collection_access_repository import ICollectionAcce
from src.application.services.document_parser_service import DocumentParserService from src.application.services.document_parser_service import DocumentParserService
from src.application.services.rag_service import RAGService from src.application.services.rag_service import RAGService
from src.shared.exceptions import NotFoundError, ForbiddenError from src.shared.exceptions import NotFoundError, ForbiddenError
from src.shared.config import settings
class DocumentUseCases: class DocumentUseCases:
@@ -60,12 +62,34 @@ class DocumentUseCases:
) )
return await self.document_repository.create(document) return await self.document_repository.create(document)
async def _send_telegram_notification(self, telegram_id: str, message: str):
"""Отправить уведомление пользователю через Telegram Bot API"""
if not settings.TELEGRAM_BOT_TOKEN:
return
try:
url = f"https://api.telegram.org/bot{settings.TELEGRAM_BOT_TOKEN}/sendMessage"
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.post(
url,
json={
"chat_id": telegram_id,
"text": message,
"parse_mode": "HTML"
}
)
if response.status_code != 200:
print(f"Failed to send Telegram notification: {response.status_code}")
except Exception as e:
print(f"Error sending Telegram notification: {e}")
async def upload_and_parse_document( async def upload_and_parse_document(
self, self,
collection_id: UUID, collection_id: UUID,
file: BinaryIO, file: BinaryIO,
filename: str, filename: str,
user_id: UUID user_id: UUID,
telegram_id: Optional[str] = None
) -> Document: ) -> Document:
"""Загрузить и распарсить документ, затем автоматически проиндексировать""" """Загрузить и распарсить документ, затем автоматически проиндексировать"""
collection = await self.collection_repository.get_by_id(collection_id) collection = await self.collection_repository.get_by_id(collection_id)
@@ -86,11 +110,37 @@ class DocumentUseCases:
) )
document = await self.document_repository.create(document) document = await self.document_repository.create(document)
if self.rag_service: if self.rag_service and telegram_id:
try: try:
await self.rag_service.index_document(document) await self._send_telegram_notification(
telegram_id,
"🔄 <b>Начинаю индексацию документа...</b>\n\n"
f"📄 <b>Документ:</b> {title}\n\n"
f"Это может занять некоторое время.\n"
f"Вы получите уведомление по завершении."
)
chunks = await self.rag_service.index_document(document)
await self._send_telegram_notification(
telegram_id,
"✅ <b>Индексация завершена!</b>\n\n"
f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n"
f"📄 <b>Документ:</b> {title}\n"
f"📊 <b>Проиндексировано чанков:</b> {len(chunks)}\n\n"
f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n"
f"💡 <b>Теперь вы можете задавать вопросы по этому документу!</b>\n"
f"Просто напишите ваш вопрос, и я найду ответ на основе загруженного документа."
)
except Exception as e: except Exception as e:
print(f"Ошибка при автоматической индексации документа {document.document_id}: {e}") print(f"Ошибка при автоматической индексации документа {document.document_id}: {e}")
if telegram_id:
await self._send_telegram_notification(
telegram_id,
"⚠️ <b>Ошибка при индексации</b>\n\n"
f"Документ загружен, но индексация не завершена.\n"
f"Ошибка: {str(e)[:200]}"
)
return document return document
@@ -36,6 +36,8 @@ class QdrantVectorRepository(IVectorRepository):
embeddings: Sequence[list[float]], embeddings: Sequence[list[float]],
model_version: str, model_version: str,
) -> None: ) -> None:
BATCH_SIZE = 100
points = [] points = []
for chunk, vector in zip(chunks, embeddings): for chunk, vector in zip(chunks, embeddings):
points.append( points.append(
@@ -52,6 +54,12 @@ class QdrantVectorRepository(IVectorRepository):
}, },
) )
) )
if len(points) >= BATCH_SIZE:
self.client.upsert(collection_name=self.collection_name, points=points)
points = []
if points:
self.client.upsert(collection_name=self.collection_name, points=points) self.client.upsert(collection_name=self.collection_name, points=points)
async def search( async def search(
+2 -1
View File
@@ -69,7 +69,8 @@ async def upload_document(
collection_id=collection_id, collection_id=collection_id,
file=file.file, file=file.file,
filename=file.filename, filename=file.filename,
user_id=current_user.user_id user_id=current_user.user_id,
telegram_id=current_user.telegram_id
) )
return DocumentResponse.from_entity(document) return DocumentResponse.from_entity(document)
@@ -3,6 +3,7 @@ from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton, C
from aiogram.filters import Command, StateFilter from aiogram.filters import Command, StateFilter
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
import aiohttp import aiohttp
from urllib.parse import unquote
from tg_bot.config.settings import settings from tg_bot.config.settings import settings
from tg_bot.infrastructure.http_client import create_http_session from tg_bot.infrastructure.http_client import create_http_session
from tg_bot.infrastructure.telegram.states.collection_states import ( from tg_bot.infrastructure.telegram.states.collection_states import (
@@ -10,6 +11,18 @@ from tg_bot.infrastructure.telegram.states.collection_states import (
CollectionEditStates CollectionEditStates
) )
def decode_title(title: str) -> str:
if not title:
return "Без названия"
try:
decoded = unquote(title)
if decoded != title or '%' not in title:
return decoded
return title
except Exception:
return title
router = Router() router = Router()
@@ -243,7 +256,7 @@ async def cmd_search(message: Message):
response = f"<b>Результаты поиска:</b> \"{query}\"\n\n" response = f"<b>Результаты поиска:</b> \"{query}\"\n\n"
for i, doc in enumerate(results[:5], 1): for i, doc in enumerate(results[:5], 1):
title = doc.get("title", "Без названия") title = decode_title(doc.get("title", "Без названия"))
content = doc.get("content", "")[:200] content = doc.get("content", "")[:200]
response += f"{i}. <b>{title}</b>\n" response += f"{i}. <b>{title}</b>\n"
response += f" <i>{content}...</i>\n\n" response += f" <i>{content}...</i>\n\n"
@@ -378,7 +391,7 @@ async def show_collection_documents(callback: CallbackQuery):
for i, doc in enumerate(documents[:10], 1): for i, doc in enumerate(documents[:10], 1):
doc_id = doc.get("document_id") doc_id = doc.get("document_id")
title = doc.get("title", "Без названия") title = decode_title(doc.get("title", "Без названия"))
content_preview = doc.get("content", "")[:100] content_preview = doc.get("content", "")[:100]
response += f"{i}. <b>{title}</b>\n" response += f"{i}. <b>{title}</b>\n"
if content_preview: if content_preview:
@@ -6,6 +6,7 @@ from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton, C
from aiogram.filters import StateFilter from aiogram.filters import StateFilter
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
import aiohttp import aiohttp
from urllib.parse import unquote
from tg_bot.config.settings import settings from tg_bot.config.settings import settings
from tg_bot.infrastructure.http_client import create_http_session from tg_bot.infrastructure.http_client import create_http_session
from tg_bot.infrastructure.telegram.states.collection_states import ( from tg_bot.infrastructure.telegram.states.collection_states import (
@@ -13,6 +14,18 @@ from tg_bot.infrastructure.telegram.states.collection_states import (
DocumentUploadStates DocumentUploadStates
) )
def decode_title(title: str) -> str:
"""Декодирует URL-encoded название документа"""
if not title:
return "Без названия"
try:
decoded = unquote(title)
if decoded != title or '%' not in title:
return decoded
return title
except Exception:
return title
router = Router() router = Router()
@@ -108,7 +121,7 @@ async def view_document(callback: CallbackQuery):
) )
return return
title = document.get("title", "Без названия") title = decode_title(document.get("title", "Без названия"))
content = document.get("content", "") content = document.get("content", "")
collection_id = document.get("collection_id") collection_id = document.get("collection_id")
@@ -184,7 +197,7 @@ async def edit_document_prompt(callback: CallbackQuery, state: FSMContext):
await callback.message.answer( await callback.message.answer(
"<b>Редактирование документа</b>\n\n" "<b>Редактирование документа</b>\n\n"
"Отправьте новое название документа или /skip чтобы оставить текущее.\n\n" "Отправьте новое название документа или /skip чтобы оставить текущее.\n\n"
f"Текущее название: <b>{document.get('title', 'Без названия')}</b>", f"Текущее название: <b>{decode_title(document.get('title', 'Без названия'))}</b>",
parse_mode="HTML" parse_mode="HTML"
) )
await callback.answer() await callback.answer()
@@ -360,8 +373,9 @@ async def process_upload_document(message: Message, state: FSMContext):
if result: if result:
await message.answer( await message.answer(
f"<b>Документ загружен</b>\n\n" f"<b>Документ загружен и добавлен в коллекцию</b>\n\n"
f"Название: <b>{result.get('title', filename)}</b>", f"<b>Название:</b> {decode_title(result.get('title', filename))}\n\n"
f"📄 Документ сейчас индексируется. Вы получите уведомление, когда индексация завершится.\n\n",
parse_mode="HTML" parse_mode="HTML"
) )
else: else:
@@ -3,6 +3,8 @@ from aiogram.types import Message
from tg_bot.config.settings import settings from tg_bot.config.settings import settings
from tg_bot.domain.user_service import UserService, User from tg_bot.domain.user_service import UserService, User
from tg_bot.application.services.rag_service import RAGService from tg_bot.application.services.rag_service import RAGService
import re
router = Router() router = Router()
rag_service = RAGService() rag_service = RAGService()
@@ -60,22 +62,39 @@ async def process_premium_question(message: Message, user: User, question_text:
# Беседа уже сохранена в бэкенде через API /rag/question # Беседа уже сохранена в бэкенде через API /rag/question
import re
formatted_answer = answer
formatted_answer = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', formatted_answer)
formatted_answer = re.sub(r'^(\d+)\.\s+', r'\1. ', formatted_answer, flags=re.MULTILINE)
formatted_answer = formatted_answer.replace("- ", "")
response = ( response = (
f"<b>Ваш вопрос:</b>\n" f"<b>Ваш вопрос:</b>\n"
f"<i>{question_text[:200]}</i>\n\n" f"<i>{question_text[:200]}</i>\n\n"
f"<b>Ответ:</b>\n{answer}\n\n" f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n"
f"💬 <b>Ответ:</b>\n\n"
f"{formatted_answer}\n\n"
) )
if sources: if sources:
response += f"<b>Источники:</b>\n" response += f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n"
response += f"📚 <b>Источники:</b>\n"
for idx, source in enumerate(sources[:5], 1): for idx, source in enumerate(sources[:5], 1):
title = source.get('title', 'Без названия') title = source.get('title', 'Без названия')
try:
from urllib.parse import unquote
decoded = unquote(title)
if decoded != title or '%' in title:
title = decoded
except:
pass
response += f" {idx}. {title}\n" response += f" {idx}. {title}\n"
response += "\n<i>Используйте /mycollections для просмотра всех коллекций</i>\n\n" response += "\n<i>💡 Используйте /mycollections для просмотра всех коллекций</i>\n\n"
response += ( response += (
f"<b>Статус:</b> Premium (вопросов безлимитно)\n" f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n"
f"<b>Всего вопросов:</b> {user.questions_used}" f"<b>Статус:</b> Premium (вопросов безлимитно)\n"
f"📊 <b>Всего вопросов:</b> {user.questions_used}"
) )
except Exception as e: except Exception as e:
@@ -83,9 +102,12 @@ async def process_premium_question(message: Message, user: User, question_text:
response = ( response = (
f"<b>Ваш вопрос:</b>\n" f"<b>Ваш вопрос:</b>\n"
f"<i>{question_text[:200]}</i>\n\n" f"<i>{question_text[:200]}</i>\n\n"
f"Ошибка при генерации ответа. Попробуйте позже.\n\n" f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n"
f"<b>Статус:</b> Premium\n" f"<b>Ошибка при генерации ответа.</b>\n"
f"<b>Всего вопросов:</b> {user.questions_used}" f"Попробуйте позже.\n\n"
f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n"
f"✨ <b>Статус:</b> Premium\n"
f"📊 <b>Всего вопросов:</b> {user.questions_used}"
) )
await message.answer(response, parse_mode="HTML") await message.answer(response, parse_mode="HTML")
@@ -109,40 +131,58 @@ async def process_free_question(message: Message, user: User, question_text: str
# Уже все сохранили через /rag/question # Уже все сохранили через /rag/question
formatted_answer = answer
formatted_answer = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', formatted_answer)
formatted_answer = re.sub(r'^(\d+)\.\s+', r'\1. ', formatted_answer, flags=re.MULTILINE)
formatted_answer = formatted_answer.replace("- ", "")
response = ( response = (
f"<b>Ваш вопрос:</b>\n" f"<b>Ваш вопрос:</b>\n"
f"<i>{question_text[:200]}</i>\n\n" f"<i>{question_text[:200]}</i>\n\n"
f"<b>Ответ:</b>\n{answer}\n\n" f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n"
f"💬 <b>Ответ:</b>\n\n"
f"{formatted_answer}\n\n"
) )
if sources: if sources:
response += f"<b>Источники:</b>\n" response += f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n"
response += f"📚 <b>Источники:</b>\n"
for idx, source in enumerate(sources[:5], 1): for idx, source in enumerate(sources[:5], 1):
title = source.get('title', 'Без названия') title = source.get('title', 'Без названия')
try:
from urllib.parse import unquote
decoded = unquote(title)
if decoded != title or '%' in title:
title = decoded
except:
pass
response += f" {idx}. {title}\n" response += f" {idx}. {title}\n"
response += "\n<i>Используйте /mycollections для просмотра всех коллекций</i>\n\n" response += "\n<i>💡 Используйте /mycollections для просмотра всех коллекций</i>\n\n"
response += ( response += (
f"<b>Статус:</b> Бесплатный доступ\n" f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n"
f"<b>Использовано вопросов:</b> {user.questions_used}/{settings.FREE_QUESTIONS_LIMIT}\n" f"📊 <b>Статус:</b> Бесплатный доступ\n"
f"<b>Осталось бесплатных:</b> {remaining}\n\n" f"📈 <b>Использовано вопросов:</b> {user.questions_used}/{settings.FREE_QUESTIONS_LIMIT}\n"
f"🎯 <b>Осталось бесплатных:</b> {remaining}\n\n"
) )
if remaining <= 3 and remaining > 0: if remaining <= 3 and remaining > 0:
response += f"<i>Осталось мало вопросов! Для продолжения используйте /buy</i>\n\n" response += f"⚠️ <i>Осталось мало вопросов! Для продолжения используйте /buy</i>\n\n"
response += f"<i>Для безлимитного доступа: /buy</i>" response += f"💎 <i>Для безлимитного доступа: /buy</i>"
except Exception as e: except Exception as e:
print(f"Error generating answer: {e}") print(f"Error generating answer: {e}")
response = ( response = (
f"<b>Ваш вопрос:</b>\n" f"<b>Ваш вопрос:</b>\n"
f"<i>{question_text[:200]}</i>\n\n" f"<i>{question_text[:200]}</i>\n\n"
f"Ошибка при генерации ответа. Попробуйте позже.\n\n" f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n"
f"<b>Статус:</b> Бесплатный доступ\n" f"<b>Ошибка при генерации ответа.</b>\n"
f"<b>Использовано вопросов:</b> {user.questions_used}/{settings.FREE_QUESTIONS_LIMIT}\n" f"Попробуйте позже.\n\n"
f"<b>Осталось бесплатных:</b> {remaining}\n\n" f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n"
f"<i>Для безлимитного доступа: /buy</i>" f"📊 <b>Статус:</b> Бесплатный доступ\n"
f"📈 <b>Использовано вопросов:</b> {user.questions_used}/{settings.FREE_QUESTIONS_LIMIT}\n"
f"🎯 <b>Осталось бесплатных:</b> {remaining}\n\n"
f"💎 <i>Для безлимитного доступа: /buy</i>"
) )
await message.answer(response, parse_mode="HTML") await message.answer(response, parse_mode="HTML")