diff --git a/backend/requirements.txt b/backend/requirements.txt index 40dec95..6f1f3a7 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,13 +1,13 @@ -fastapi==0.104.1 -uvicorn[standard]==0.24.0 -sqlalchemy[asyncio]==2.0.23 -asyncpg==0.29.0 -alembic==1.12.1 -pydantic==2.5.0 -pydantic-settings==2.1.0 -python-multipart==0.0.6 -httpx==0.25.2 -PyMuPDF==1.23.8 -Pillow==10.2.0 -dishka==0.7.0 - +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +sqlalchemy[asyncio]==2.0.23 +asyncpg==0.29.0 +alembic==1.12.1 +pydantic==2.5.0 +pydantic-settings==2.1.0 +python-multipart==0.0.6 +httpx==0.25.2 +PyMuPDF==1.23.8 +Pillow==10.2.0 +dishka==0.7.0 + diff --git a/backend/src/__init__.py b/backend/src/__init__.py index d18a9ef..a5b5e23 100644 --- a/backend/src/__init__.py +++ b/backend/src/__init__.py @@ -1,4 +1,4 @@ -""" -ИИ-юрист система -""" - +""" +ИИ-юрист система +""" + diff --git a/backend/src/application/__init__.py b/backend/src/application/__init__.py index 836f8d1..65eef5e 100644 --- a/backend/src/application/__init__.py +++ b/backend/src/application/__init__.py @@ -1,4 +1,4 @@ -""" -Application layer -""" - +""" +Application layer +""" + diff --git a/backend/src/application/services/__init__.py b/backend/src/application/services/__init__.py new file mode 100644 index 0000000..109d991 --- /dev/null +++ b/backend/src/application/services/__init__.py @@ -0,0 +1,4 @@ +""" +Application services +""" + diff --git a/backend/src/application/services/document_parser_service.py b/backend/src/application/services/document_parser_service.py new file mode 100644 index 0000000..c151794 --- /dev/null +++ b/backend/src/application/services/document_parser_service.py @@ -0,0 +1,72 @@ +""" +Сервис парсинга документов +""" +from typing import BinaryIO +from src.infrastructure.external.yandex_ocr import YandexOCRService, YandexOCRError + + +class DocumentParserService: + """Сервис для парсинга документов""" + + def __init__(self, ocr_service: YandexOCRService): + self.ocr_service = ocr_service + + async def parse_pdf(self, file: BinaryIO, filename: str) -> tuple[str, str]: + """ + Парсинг PDF файла + + Args: + file: Файловый объект + filename: Имя файла + + Returns: + Кортеж (title, content) + + Raises: + YandexOCRError: При ошибке распознавания + """ + try: + content = await self.ocr_service.parse_pdf(file) + + title = filename.rsplit(".", 1)[0] if "." in filename else filename + + if not content or not content.strip() or content.startswith("Ошибка распознавания:"): + if not content or content.startswith("Ошибка распознавания:"): + pass + else: + content = f"Документ {filename} загружен, но текст не был распознан." + + return title, content + except YandexOCRError as e: + title = filename.rsplit(".", 1)[0] if "." in filename else filename + content = f" Ошибка распознавания документа: {str(e)}" + return title, content + except Exception as e: + title = filename.rsplit(".", 1)[0] if "." in filename else filename + content = f" Ошибка при парсинге документа: {str(e)}" + return title, content + + async def parse_image(self, file: BinaryIO, filename: str) -> tuple[str, str]: + """ + Парсинг изображения + + Args: + file: Файловый объект изображения + filename: Имя файла + + Returns: + Кортеж (title, content) + """ + try: + content = await self.ocr_service.parse_image(file) + title = filename.rsplit(".", 1)[0] if "." in filename else filename + + if not content or not content.strip(): + content = f"Изображение {filename} загружено, но текст не был распознан." + + return title, content + except YandexOCRError: + raise + except Exception as e: + raise YandexOCRError(f"Ошибка при парсинге изображения: {str(e)}") from e + diff --git a/backend/src/application/use_cases/__init__.py b/backend/src/application/use_cases/__init__.py new file mode 100644 index 0000000..c620e35 --- /dev/null +++ b/backend/src/application/use_cases/__init__.py @@ -0,0 +1,4 @@ +""" +Application use cases +""" + diff --git a/backend/src/application/use_cases/collection_use_cases.py b/backend/src/application/use_cases/collection_use_cases.py new file mode 100644 index 0000000..6e1b9f8 --- /dev/null +++ b/backend/src/application/use_cases/collection_use_cases.py @@ -0,0 +1,141 @@ +""" +Use cases для работы с коллекциями +""" +from uuid import UUID +from typing import Optional +from src.domain.entities.collection import Collection +from src.domain.entities.collection_access import CollectionAccess +from src.domain.repositories.collection_repository import ICollectionRepository +from src.domain.repositories.collection_access_repository import ICollectionAccessRepository +from src.domain.repositories.user_repository import IUserRepository +from src.shared.exceptions import NotFoundError, ForbiddenError + + +class CollectionUseCases: + """Use cases для коллекций""" + + def __init__( + self, + collection_repository: ICollectionRepository, + access_repository: ICollectionAccessRepository, + user_repository: IUserRepository + ): + self.collection_repository = collection_repository + self.access_repository = access_repository + self.user_repository = user_repository + + async def create_collection( + self, + name: str, + owner_id: UUID, + description: str = "", + is_public: bool = False + ) -> Collection: + """Создать коллекцию""" + owner = await self.user_repository.get_by_id(owner_id) + if not owner: + raise NotFoundError(f"Пользователь {owner_id} не найден") + + collection = Collection( + name=name, + owner_id=owner_id, + description=description, + is_public=is_public + ) + return await self.collection_repository.create(collection) + + async def get_collection(self, collection_id: UUID) -> Collection: + """Получить коллекцию по ID""" + collection = await self.collection_repository.get_by_id(collection_id) + if not collection: + raise NotFoundError(f"Коллекция {collection_id} не найдена") + return collection + + async def update_collection( + self, + collection_id: UUID, + user_id: UUID, + name: str | None = None, + description: str | None = None, + is_public: bool | None = None + ) -> Collection: + """Обновить коллекцию""" + collection = await self.get_collection(collection_id) + + if collection.owner_id != user_id: + raise ForbiddenError("Только владелец может изменять коллекцию") + + if name is not None: + collection.name = name + if description is not None: + collection.description = description + if is_public is not None: + collection.is_public = is_public + + return await self.collection_repository.update(collection) + + async def delete_collection(self, collection_id: UUID, user_id: UUID) -> bool: + """Удалить коллекцию""" + collection = await self.get_collection(collection_id) + + if collection.owner_id != user_id: + raise ForbiddenError("Только владелец может удалять коллекцию") + + return await self.collection_repository.delete(collection_id) + + async def grant_access(self, collection_id: UUID, user_id: UUID, owner_id: UUID) -> CollectionAccess: + """Предоставить доступ пользователю к коллекции""" + collection = await self.get_collection(collection_id) + + if collection.owner_id != owner_id: + raise ForbiddenError("Только владелец может предоставлять доступ") + + user = await self.user_repository.get_by_id(user_id) + if not user: + raise NotFoundError(f"Пользователь {user_id} не найден") + + existing_access = await self.access_repository.get_by_user_and_collection(user_id, collection_id) + if existing_access: + return existing_access + + access = CollectionAccess(user_id=user_id, collection_id=collection_id) + return await self.access_repository.create(access) + + async def revoke_access(self, collection_id: UUID, user_id: UUID, owner_id: UUID) -> bool: + """Отозвать доступ пользователя к коллекции""" + collection = await self.get_collection(collection_id) + + if collection.owner_id != owner_id: + raise ForbiddenError("Только владелец может отзывать доступ") + + return await self.access_repository.delete_by_user_and_collection(user_id, collection_id) + + async def check_access(self, collection_id: UUID, user_id: UUID) -> bool: + """Проверить доступ пользователя к коллекции""" + collection = await self.get_collection(collection_id) + + if collection.owner_id == user_id: + return True + + if collection.is_public: + return True + + access = await self.access_repository.get_by_user_and_collection(user_id, collection_id) + return access is not None + + async def list_user_collections(self, user_id: UUID, skip: int = 0, limit: int = 100) -> list[Collection]: + """Получить коллекции, доступные пользователю""" + owned = await self.collection_repository.list_by_owner(user_id, skip=skip, limit=limit) + + public = await self.collection_repository.list_public(skip=skip, limit=limit) + + accesses = await self.access_repository.list_by_user(user_id) + accessed_collections = [] + for access in accesses: + collection = await self.collection_repository.get_by_id(access.collection_id) + if collection: + accessed_collections.append(collection) + + all_collections = {c.collection_id: c for c in owned + public + accessed_collections} + return list(all_collections.values())[skip:skip+limit] + diff --git a/backend/src/application/use_cases/conversation_use_cases.py b/backend/src/application/use_cases/conversation_use_cases.py new file mode 100644 index 0000000..83aee18 --- /dev/null +++ b/backend/src/application/use_cases/conversation_use_cases.py @@ -0,0 +1,68 @@ +""" +Use cases для работы с беседами +""" +from uuid import UUID +from src.domain.entities.conversation import Conversation +from src.domain.repositories.conversation_repository import IConversationRepository +from src.domain.repositories.collection_repository import ICollectionRepository +from src.domain.repositories.collection_access_repository import ICollectionAccessRepository +from src.shared.exceptions import NotFoundError, ForbiddenError + + +class ConversationUseCases: + """Use cases для бесед""" + + def __init__( + self, + conversation_repository: IConversationRepository, + collection_repository: ICollectionRepository, + access_repository: ICollectionAccessRepository + ): + self.conversation_repository = conversation_repository + self.collection_repository = collection_repository + self.access_repository = access_repository + + async def create_conversation(self, user_id: UUID, collection_id: UUID) -> Conversation: + """Создать беседу""" + collection = await self.collection_repository.get_by_id(collection_id) + if not collection: + raise NotFoundError(f"Коллекция {collection_id} не найдена") + + has_access = await self._check_collection_access(user_id, collection) + if not has_access: + raise ForbiddenError("Нет доступа к коллекции") + + conversation = Conversation(user_id=user_id, collection_id=collection_id) + return await self.conversation_repository.create(conversation) + + async def get_conversation(self, conversation_id: UUID, user_id: UUID) -> Conversation: + """Получить беседу по ID""" + conversation = await self.conversation_repository.get_by_id(conversation_id) + if not conversation: + raise NotFoundError(f"Беседа {conversation_id} не найдена") + + if conversation.user_id != user_id: + raise ForbiddenError("Нет доступа к этой беседе") + + return conversation + + async def delete_conversation(self, conversation_id: UUID, user_id: UUID) -> bool: + """Удалить беседу""" + conversation = await self.get_conversation(conversation_id, user_id) + return await self.conversation_repository.delete(conversation_id) + + async def list_user_conversations(self, user_id: UUID, skip: int = 0, limit: int = 100) -> list[Conversation]: + """Получить беседы пользователя""" + return await self.conversation_repository.list_by_user(user_id, skip=skip, limit=limit) + + async def _check_collection_access(self, user_id: UUID, collection) -> bool: + """Проверить доступ пользователя к коллекции""" + if collection.owner_id == user_id: + return True + + if collection.is_public: + return True + + access = await self.access_repository.get_by_user_and_collection(user_id, collection.collection_id) + return access is not None + diff --git a/backend/src/application/use_cases/document_use_cases.py b/backend/src/application/use_cases/document_use_cases.py new file mode 100644 index 0000000..f73b531 --- /dev/null +++ b/backend/src/application/use_cases/document_use_cases.py @@ -0,0 +1,119 @@ +""" +Use cases для работы с документами +""" +from uuid import UUID +from typing import BinaryIO, Optional +from src.domain.entities.document import Document +from src.domain.repositories.document_repository import IDocumentRepository +from src.domain.repositories.collection_repository import ICollectionRepository +from src.application.services.document_parser_service import DocumentParserService +from src.shared.exceptions import NotFoundError, ForbiddenError + + +class DocumentUseCases: + """Use cases для документов""" + + def __init__( + self, + document_repository: IDocumentRepository, + collection_repository: ICollectionRepository, + parser_service: DocumentParserService + ): + self.document_repository = document_repository + self.collection_repository = collection_repository + self.parser_service = parser_service + + async def create_document( + self, + collection_id: UUID, + title: str, + content: str, + metadata: dict | None = None + ) -> Document: + """Создать документ""" + collection = await self.collection_repository.get_by_id(collection_id) + if not collection: + raise NotFoundError(f"Коллекция {collection_id} не найдена") + + document = Document( + collection_id=collection_id, + title=title, + content=content, + metadata=metadata or {} + ) + return await self.document_repository.create(document) + + async def upload_and_parse_document( + self, + collection_id: UUID, + file: BinaryIO, + filename: str, + user_id: UUID + ) -> Document: + """Загрузить и распарсить документ""" + collection = await self.collection_repository.get_by_id(collection_id) + if not collection: + raise NotFoundError(f"Коллекция {collection_id} не найдена") + + if collection.owner_id != user_id: + raise ForbiddenError("Только владелец может добавлять документы") + + title, content = await self.parser_service.parse_pdf(file, filename) + + document = Document( + collection_id=collection_id, + title=title, + content=content, + metadata={"filename": filename} + ) + return await self.document_repository.create(document) + + async def get_document(self, document_id: UUID) -> Document: + """Получить документ по ID""" + document = await self.document_repository.get_by_id(document_id) + if not document: + raise NotFoundError(f"Документ {document_id} не найден") + return document + + async def update_document( + self, + document_id: UUID, + user_id: UUID, + title: str | None = None, + content: str | None = None, + metadata: dict | None = None + ) -> Document: + """Обновить документ""" + document = await self.get_document(document_id) + + collection = await self.collection_repository.get_by_id(document.collection_id) + if not collection or collection.owner_id != user_id: + raise ForbiddenError("Только владелец коллекции может изменять документы") + + if title is not None: + document.title = title + if content is not None: + document.content = content + if metadata is not None: + document.metadata = metadata + + return await self.document_repository.update(document) + + async def delete_document(self, document_id: UUID, user_id: UUID) -> bool: + """Удалить документ""" + document = await self.get_document(document_id) + + collection = await self.collection_repository.get_by_id(document.collection_id) + if not collection or collection.owner_id != user_id: + raise ForbiddenError("Только владелец коллекции может удалять документы") + + return await self.document_repository.delete(document_id) + + async def list_collection_documents(self, collection_id: UUID, skip: int = 0, limit: int = 100) -> list[Document]: + """Получить документы коллекции""" + collection = await self.collection_repository.get_by_id(collection_id) + if not collection: + raise NotFoundError(f"Коллекция {collection_id} не найдена") + + return await self.document_repository.list_by_collection(collection_id, skip=skip, limit=limit) + diff --git a/backend/src/application/use_cases/message_use_cases.py b/backend/src/application/use_cases/message_use_cases.py new file mode 100644 index 0000000..aba6c1a --- /dev/null +++ b/backend/src/application/use_cases/message_use_cases.py @@ -0,0 +1,93 @@ +""" +Use cases для работы с сообщениями +""" +from uuid import UUID +from src.domain.entities.message import Message, MessageRole +from src.domain.repositories.message_repository import IMessageRepository +from src.domain.repositories.conversation_repository import IConversationRepository +from src.shared.exceptions import NotFoundError, ForbiddenError + + +class MessageUseCases: + """Use cases для сообщений""" + + def __init__( + self, + message_repository: IMessageRepository, + conversation_repository: IConversationRepository + ): + self.message_repository = message_repository + self.conversation_repository = conversation_repository + + async def create_message( + self, + conversation_id: UUID, + content: str, + role: MessageRole, + user_id: UUID, + sources: dict | None = None + ) -> Message: + """Создать сообщение""" + conversation = await self.conversation_repository.get_by_id(conversation_id) + if not conversation: + raise NotFoundError(f"Беседа {conversation_id} не найдена") + + if conversation.user_id != user_id: + raise ForbiddenError("Нет доступа к этой беседе") + + message = Message( + conversation_id=conversation_id, + content=content, + role=role, + sources=sources or {} + ) + + conversation.update_timestamp() + await self.conversation_repository.update(conversation) + + return await self.message_repository.create(message) + + async def get_message(self, message_id: UUID) -> Message: + """Получить сообщение по ID""" + message = await self.message_repository.get_by_id(message_id) + if not message: + raise NotFoundError(f"Сообщение {message_id} не найдено") + return message + + async def update_message( + self, + message_id: UUID, + content: str | None = None, + sources: dict | None = None + ) -> Message: + """Обновить сообщение""" + message = await self.get_message(message_id) + + if content is not None: + message.content = content + if sources is not None: + message.sources = sources + + return await self.message_repository.update(message) + + async def delete_message(self, message_id: UUID) -> bool: + """Удалить сообщение""" + return await self.message_repository.delete(message_id) + + async def list_conversation_messages( + self, + conversation_id: UUID, + user_id: UUID, + skip: int = 0, + limit: int = 100 + ) -> list[Message]: + """Получить сообщения беседы""" + conversation = await self.conversation_repository.get_by_id(conversation_id) + if not conversation: + raise NotFoundError(f"Беседа {conversation_id} не найдена") + + if conversation.user_id != user_id: + raise ForbiddenError("Нет доступа к этой беседе") + + return await self.message_repository.list_by_conversation(conversation_id, skip=skip, limit=limit) + diff --git a/backend/src/application/use_cases/user_use_cases.py b/backend/src/application/use_cases/user_use_cases.py new file mode 100644 index 0000000..293263f --- /dev/null +++ b/backend/src/application/use_cases/user_use_cases.py @@ -0,0 +1,55 @@ +""" +Use cases для работы с пользователями +""" +from uuid import UUID +from typing import Optional +from src.domain.entities.user import User, UserRole +from src.domain.repositories.user_repository import IUserRepository +from src.shared.exceptions import NotFoundError, ValidationError + + +class UserUseCases: + """Use cases для пользователей""" + + def __init__(self, user_repository: IUserRepository): + self.user_repository = user_repository + + async def create_user(self, telegram_id: str, role: UserRole = UserRole.USER) -> User: + """Создать пользователя""" + existing_user = await self.user_repository.get_by_telegram_id(telegram_id) + if existing_user: + raise ValidationError(f"Пользователь с telegram_id {telegram_id} уже существует") + + user = User(telegram_id=telegram_id, role=role) + return await self.user_repository.create(user) + + async def get_user(self, user_id: UUID) -> User: + """Получить пользователя по ID""" + user = await self.user_repository.get_by_id(user_id) + if not user: + raise NotFoundError(f"Пользователь {user_id} не найден") + return user + + async def get_user_by_telegram_id(self, telegram_id: str) -> Optional[User]: + """Получить пользователя по Telegram ID""" + return await self.user_repository.get_by_telegram_id(telegram_id) + + async def update_user(self, user_id: UUID, telegram_id: str | None = None, role: UserRole | None = None) -> User: + """Обновить пользователя""" + user = await self.get_user(user_id) + + if telegram_id is not None: + user.telegram_id = telegram_id + if role is not None: + user.role = role + + return await self.user_repository.update(user) + + async def delete_user(self, user_id: UUID) -> bool: + """Удалить пользователя""" + return await self.user_repository.delete(user_id) + + async def list_users(self, skip: int = 0, limit: int = 100) -> list[User]: + """Получить список пользователей""" + return await self.user_repository.list_all(skip=skip, limit=limit) + diff --git a/backend/src/domain/__init__.py b/backend/src/domain/__init__.py index 7e8decd..e5a6b75 100644 --- a/backend/src/domain/__init__.py +++ b/backend/src/domain/__init__.py @@ -1,4 +1,4 @@ -""" -Domain layer -""" - +""" +Domain layer +""" + diff --git a/backend/src/domain/entities/__init__.py b/backend/src/domain/entities/__init__.py index c3a6d2e..38477ca 100644 --- a/backend/src/domain/entities/__init__.py +++ b/backend/src/domain/entities/__init__.py @@ -1,4 +1,4 @@ -""" -Domain entities -""" - +""" +Domain entities +""" + diff --git a/backend/src/domain/entities/collection.py b/backend/src/domain/entities/collection.py index abfc3a0..21e1970 100644 --- a/backend/src/domain/entities/collection.py +++ b/backend/src/domain/entities/collection.py @@ -1,26 +1,26 @@ -""" -Доменная сущность Collection -""" -from datetime import datetime -from uuid import UUID, uuid4 - - -class Collection: - """Каталог документов""" - - def __init__( - self, - name: str, - owner_id: UUID, - description: str = "", - is_public: bool = False, - collection_id: UUID | None = None, - created_at: datetime | None = None - ): - self.collection_id = collection_id or uuid4() - self.name = name - self.description = description - self.owner_id = owner_id - self.is_public = is_public - self.created_at = created_at or datetime.utcnow() - +""" +Доменная сущность Collection +""" +from datetime import datetime +from uuid import UUID, uuid4 + + +class Collection: + """Каталог документов""" + + def __init__( + self, + name: str, + owner_id: UUID, + description: str = "", + is_public: bool = False, + collection_id: UUID | None = None, + created_at: datetime | None = None + ): + self.collection_id = collection_id or uuid4() + self.name = name + self.description = description + self.owner_id = owner_id + self.is_public = is_public + self.created_at = created_at or datetime.utcnow() + diff --git a/backend/src/domain/entities/collection_access.py b/backend/src/domain/entities/collection_access.py index df4cd0a..ca8dda6 100644 --- a/backend/src/domain/entities/collection_access.py +++ b/backend/src/domain/entities/collection_access.py @@ -1,22 +1,22 @@ -""" -Доменная сущность CollectionAccess -""" -from datetime import datetime -from uuid import UUID, uuid4 - - -class CollectionAccess: - """Доступ пользователя к коллекции""" - - def __init__( - self, - user_id: UUID, - collection_id: UUID, - access_id: UUID | None = None, - created_at: datetime | None = None - ): - self.access_id = access_id or uuid4() - self.user_id = user_id - self.collection_id = collection_id - self.created_at = created_at or datetime.utcnow() - +""" +Доменная сущность CollectionAccess +""" +from datetime import datetime +from uuid import UUID, uuid4 + + +class CollectionAccess: + """Доступ пользователя к коллекции""" + + def __init__( + self, + user_id: UUID, + collection_id: UUID, + access_id: UUID | None = None, + created_at: datetime | None = None + ): + self.access_id = access_id or uuid4() + self.user_id = user_id + self.collection_id = collection_id + self.created_at = created_at or datetime.utcnow() + diff --git a/backend/src/domain/entities/conversation.py b/backend/src/domain/entities/conversation.py index ffe42cc..8b1b5d6 100644 --- a/backend/src/domain/entities/conversation.py +++ b/backend/src/domain/entities/conversation.py @@ -1,28 +1,28 @@ -""" -Доменная сущность Conversation -""" -from datetime import datetime -from uuid import UUID, uuid4 - - -class Conversation: - """Беседа пользователя с ИИ""" - - def __init__( - self, - user_id: UUID, - collection_id: UUID, - conversation_id: UUID | None = None, - created_at: datetime | None = None, - updated_at: datetime | None = None - ): - self.conversation_id = conversation_id or uuid4() - self.user_id = user_id - self.collection_id = collection_id - self.created_at = created_at or datetime.utcnow() - self.updated_at = updated_at or datetime.utcnow() - - def update_timestamp(self): - """Обновить время последнего изменения""" - self.updated_at = datetime.utcnow() - +""" +Доменная сущность Conversation +""" +from datetime import datetime +from uuid import UUID, uuid4 + + +class Conversation: + """Беседа пользователя с ИИ""" + + def __init__( + self, + user_id: UUID, + collection_id: UUID, + conversation_id: UUID | None = None, + created_at: datetime | None = None, + updated_at: datetime | None = None + ): + self.conversation_id = conversation_id or uuid4() + self.user_id = user_id + self.collection_id = collection_id + self.created_at = created_at or datetime.utcnow() + self.updated_at = updated_at or datetime.utcnow() + + def update_timestamp(self): + """Обновить время последнего изменения""" + self.updated_at = datetime.utcnow() + diff --git a/backend/src/domain/entities/document.py b/backend/src/domain/entities/document.py index 48bc95d..39db997 100644 --- a/backend/src/domain/entities/document.py +++ b/backend/src/domain/entities/document.py @@ -1,27 +1,27 @@ -""" -Доменная сущность Document -""" -from datetime import datetime -from uuid import UUID, uuid4 -from typing import Any - - -class Document: - """Документ в коллекции""" - - def __init__( - self, - collection_id: UUID, - title: str, - content: str, - metadata: dict[str, Any] | None = None, - document_id: UUID | None = None, - created_at: datetime | None = None - ): - self.document_id = document_id or uuid4() - self.collection_id = collection_id - self.title = title - self.content = content - self.metadata = metadata or {} - self.created_at = created_at or datetime.utcnow() - +""" +Доменная сущность Document +""" +from datetime import datetime +from uuid import UUID, uuid4 +from typing import Any + + +class Document: + """Документ в коллекции""" + + def __init__( + self, + collection_id: UUID, + title: str, + content: str, + metadata: dict[str, Any] | None = None, + document_id: UUID | None = None, + created_at: datetime | None = None + ): + self.document_id = document_id or uuid4() + self.collection_id = collection_id + self.title = title + self.content = content + self.metadata = metadata or {} + self.created_at = created_at or datetime.utcnow() + diff --git a/backend/src/domain/entities/embedding.py b/backend/src/domain/entities/embedding.py index 4da5977..d067aa0 100644 --- a/backend/src/domain/entities/embedding.py +++ b/backend/src/domain/entities/embedding.py @@ -1,25 +1,25 @@ -""" -Доменная сущность Embedding -""" -from datetime import datetime -from uuid import UUID, uuid4 -from typing import Any - - -class Embedding: - """Эмбеддинг документа""" - - def __init__( - self, - document_id: UUID, - embedding: list[float] | None = None, - model_version: str = "", - embedding_id: UUID | None = None, - created_at: datetime | None = None - ): - self.embedding_id = embedding_id or uuid4() - self.document_id = document_id - self.embedding = embedding or [] - self.model_version = model_version - self.created_at = created_at or datetime.utcnow() - +""" +Доменная сущность Embedding +""" +from datetime import datetime +from uuid import UUID, uuid4 +from typing import Any + + +class Embedding: + """Эмбеддинг документа""" + + def __init__( + self, + document_id: UUID, + embedding: list[float] | None = None, + model_version: str = "", + embedding_id: UUID | None = None, + created_at: datetime | None = None + ): + self.embedding_id = embedding_id or uuid4() + self.document_id = document_id + self.embedding = embedding or [] + self.model_version = model_version + self.created_at = created_at or datetime.utcnow() + diff --git a/backend/src/domain/entities/message.py b/backend/src/domain/entities/message.py index 967f439..92d77b4 100644 --- a/backend/src/domain/entities/message.py +++ b/backend/src/domain/entities/message.py @@ -1,35 +1,35 @@ -""" -Доменная сущность Message -""" -from datetime import datetime -from uuid import UUID, uuid4 -from typing import Any -from enum import Enum - - -class MessageRole(str, Enum): - """Роли сообщений""" - USER = "user" - ASSISTANT = "assistant" - SYSTEM = "system" - - -class Message: - """Сообщение в беседе""" - - def __init__( - self, - conversation_id: UUID, - content: str, - role: MessageRole, - sources: dict[str, Any] | None = None, - message_id: UUID | None = None, - created_at: datetime | None = None - ): - self.message_id = message_id or uuid4() - self.conversation_id = conversation_id - self.content = content - self.role = role - self.sources = sources or {} - self.created_at = created_at or datetime.utcnow() - +""" +Доменная сущность Message +""" +from datetime import datetime +from uuid import UUID, uuid4 +from typing import Any +from enum import Enum + + +class MessageRole(str, Enum): + """Роли сообщений""" + USER = "user" + ASSISTANT = "assistant" + SYSTEM = "system" + + +class Message: + """Сообщение в беседе""" + + def __init__( + self, + conversation_id: UUID, + content: str, + role: MessageRole, + sources: dict[str, Any] | None = None, + message_id: UUID | None = None, + created_at: datetime | None = None + ): + self.message_id = message_id or uuid4() + self.conversation_id = conversation_id + self.content = content + self.role = role + self.sources = sources or {} + self.created_at = created_at or datetime.utcnow() + diff --git a/backend/src/domain/entities/user.py b/backend/src/domain/entities/user.py index a9cc815..5c3680d 100644 --- a/backend/src/domain/entities/user.py +++ b/backend/src/domain/entities/user.py @@ -1,33 +1,33 @@ -""" -Доменная сущность User -""" -from datetime import datetime -from uuid import UUID, uuid4 -from enum import Enum - - -class UserRole(str, Enum): - """Роли пользователей""" - USER = "user" - ADMIN = "admin" - - -class User: - """Пользователь системы""" - - def __init__( - self, - telegram_id: str, - role: UserRole = UserRole.USER, - user_id: UUID | None = None, - created_at: datetime | None = None - ): - self.user_id = user_id or uuid4() - self.telegram_id = telegram_id - self.role = role - self.created_at = created_at or datetime.utcnow() - - def is_admin(self) -> bool: - """проверка, является ли пользователь администратором""" - return self.role == UserRole.ADMIN - +""" +Доменная сущность User +""" +from datetime import datetime +from uuid import UUID, uuid4 +from enum import Enum + + +class UserRole(str, Enum): + """Роли пользователей""" + USER = "user" + ADMIN = "admin" + + +class User: + """Пользователь системы""" + + def __init__( + self, + telegram_id: str, + role: UserRole = UserRole.USER, + user_id: UUID | None = None, + created_at: datetime | None = None + ): + self.user_id = user_id or uuid4() + self.telegram_id = telegram_id + self.role = role + self.created_at = created_at or datetime.utcnow() + + def is_admin(self) -> bool: + """проверка, является ли пользователь администратором""" + return self.role == UserRole.ADMIN + diff --git a/backend/src/domain/repositories/__init__.py b/backend/src/domain/repositories/__init__.py index 0647a45..e2a46b4 100644 --- a/backend/src/domain/repositories/__init__.py +++ b/backend/src/domain/repositories/__init__.py @@ -1,4 +1,4 @@ -""" -Domain repositories interfaces -""" - +""" +Domain repositories interfaces +""" + diff --git a/backend/src/domain/repositories/collection_access_repository.py b/backend/src/domain/repositories/collection_access_repository.py index 765649e..da1eaff 100644 --- a/backend/src/domain/repositories/collection_access_repository.py +++ b/backend/src/domain/repositories/collection_access_repository.py @@ -1,47 +1,47 @@ -""" -Интерфейс репозитория для CollectionAccess -""" -from abc import ABC, abstractmethod -from uuid import UUID -from typing import Optional -from src.domain.entities.collection_access import CollectionAccess - - -class ICollectionAccessRepository(ABC): - """Интерфейс репозитория доступа к коллекциям""" - - @abstractmethod - async def create(self, access: CollectionAccess) -> CollectionAccess: - """Создать доступ""" - pass - - @abstractmethod - async def get_by_id(self, access_id: UUID) -> Optional[CollectionAccess]: - """Получить доступ по ID""" - pass - - @abstractmethod - async def delete(self, access_id: UUID) -> bool: - """Удалить доступ""" - pass - - @abstractmethod - async def delete_by_user_and_collection(self, user_id: UUID, collection_id: UUID) -> bool: - """Удалить доступ пользователя к коллекции""" - pass - - @abstractmethod - async def get_by_user_and_collection(self, user_id: UUID, collection_id: UUID) -> Optional[CollectionAccess]: - """Получить доступ пользователя к коллекции""" - pass - - @abstractmethod - async def list_by_user(self, user_id: UUID) -> list[CollectionAccess]: - """Получить доступы пользователя""" - pass - - @abstractmethod - async def list_by_collection(self, collection_id: UUID) -> list[CollectionAccess]: - """Получить доступы к коллекции""" - pass - +""" +Интерфейс репозитория для CollectionAccess +""" +from abc import ABC, abstractmethod +from uuid import UUID +from typing import Optional +from src.domain.entities.collection_access import CollectionAccess + + +class ICollectionAccessRepository(ABC): + """Интерфейс репозитория доступа к коллекциям""" + + @abstractmethod + async def create(self, access: CollectionAccess) -> CollectionAccess: + """Создать доступ""" + pass + + @abstractmethod + async def get_by_id(self, access_id: UUID) -> Optional[CollectionAccess]: + """Получить доступ по ID""" + pass + + @abstractmethod + async def delete(self, access_id: UUID) -> bool: + """Удалить доступ""" + pass + + @abstractmethod + async def delete_by_user_and_collection(self, user_id: UUID, collection_id: UUID) -> bool: + """Удалить доступ пользователя к коллекции""" + pass + + @abstractmethod + async def get_by_user_and_collection(self, user_id: UUID, collection_id: UUID) -> Optional[CollectionAccess]: + """Получить доступ пользователя к коллекции""" + pass + + @abstractmethod + async def list_by_user(self, user_id: UUID) -> list[CollectionAccess]: + """Получить доступы пользователя""" + pass + + @abstractmethod + async def list_by_collection(self, collection_id: UUID) -> list[CollectionAccess]: + """Получить доступы к коллекции""" + pass + diff --git a/backend/src/domain/repositories/collection_repository.py b/backend/src/domain/repositories/collection_repository.py index 6e4e0a3..9cdf785 100644 --- a/backend/src/domain/repositories/collection_repository.py +++ b/backend/src/domain/repositories/collection_repository.py @@ -1,42 +1,42 @@ -""" -Интерфейс репозитория для Collection -""" -from abc import ABC, abstractmethod -from uuid import UUID -from typing import Optional -from src.domain.entities.collection import Collection - - -class ICollectionRepository(ABC): - """Интерфейс репозитория коллекций""" - - @abstractmethod - async def create(self, collection: Collection) -> Collection: - """Создать коллекцию""" - pass - - @abstractmethod - async def get_by_id(self, collection_id: UUID) -> Optional[Collection]: - """Получить коллекцию по ID""" - pass - - @abstractmethod - async def update(self, collection: Collection) -> Collection: - """Обновить коллекцию""" - pass - - @abstractmethod - async def delete(self, collection_id: UUID) -> bool: - """Удалить коллекцию""" - pass - - @abstractmethod - async def list_by_owner(self, owner_id: UUID, skip: int = 0, limit: int = 100) -> list[Collection]: - """Получить коллекции владельца""" - pass - - @abstractmethod - async def list_public(self, skip: int = 0, limit: int = 100) -> list[Collection]: - """Получить публичные коллекции""" - pass - +""" +Интерфейс репозитория для Collection +""" +from abc import ABC, abstractmethod +from uuid import UUID +from typing import Optional +from src.domain.entities.collection import Collection + + +class ICollectionRepository(ABC): + """Интерфейс репозитория коллекций""" + + @abstractmethod + async def create(self, collection: Collection) -> Collection: + """Создать коллекцию""" + pass + + @abstractmethod + async def get_by_id(self, collection_id: UUID) -> Optional[Collection]: + """Получить коллекцию по ID""" + pass + + @abstractmethod + async def update(self, collection: Collection) -> Collection: + """Обновить коллекцию""" + pass + + @abstractmethod + async def delete(self, collection_id: UUID) -> bool: + """Удалить коллекцию""" + pass + + @abstractmethod + async def list_by_owner(self, owner_id: UUID, skip: int = 0, limit: int = 100) -> list[Collection]: + """Получить коллекции владельца""" + pass + + @abstractmethod + async def list_public(self, skip: int = 0, limit: int = 100) -> list[Collection]: + """Получить публичные коллекции""" + pass + diff --git a/backend/src/domain/repositories/conversation_repository.py b/backend/src/domain/repositories/conversation_repository.py index 7eccd6a..7bdd380 100644 --- a/backend/src/domain/repositories/conversation_repository.py +++ b/backend/src/domain/repositories/conversation_repository.py @@ -1,42 +1,42 @@ -""" -Интерфейс репозитория для Conversation -""" -from abc import ABC, abstractmethod -from uuid import UUID -from typing import Optional -from src.domain.entities.conversation import Conversation - - -class IConversationRepository(ABC): - """Интерфейс репозитория бесед""" - - @abstractmethod - async def create(self, conversation: Conversation) -> Conversation: - """Создать беседу""" - pass - - @abstractmethod - async def get_by_id(self, conversation_id: UUID) -> Optional[Conversation]: - """Получить беседу по ID""" - pass - - @abstractmethod - async def update(self, conversation: Conversation) -> Conversation: - """Обновить беседу""" - pass - - @abstractmethod - async def delete(self, conversation_id: UUID) -> bool: - """Удалить беседу""" - pass - - @abstractmethod - async def list_by_user(self, user_id: UUID, skip: int = 0, limit: int = 100) -> list[Conversation]: - """Получить беседы пользователя""" - pass - - @abstractmethod - async def list_by_collection(self, collection_id: UUID, skip: int = 0, limit: int = 100) -> list[Conversation]: - """Получить беседы по коллекции""" - pass - +""" +Интерфейс репозитория для Conversation +""" +from abc import ABC, abstractmethod +from uuid import UUID +from typing import Optional +from src.domain.entities.conversation import Conversation + + +class IConversationRepository(ABC): + """Интерфейс репозитория бесед""" + + @abstractmethod + async def create(self, conversation: Conversation) -> Conversation: + """Создать беседу""" + pass + + @abstractmethod + async def get_by_id(self, conversation_id: UUID) -> Optional[Conversation]: + """Получить беседу по ID""" + pass + + @abstractmethod + async def update(self, conversation: Conversation) -> Conversation: + """Обновить беседу""" + pass + + @abstractmethod + async def delete(self, conversation_id: UUID) -> bool: + """Удалить беседу""" + pass + + @abstractmethod + async def list_by_user(self, user_id: UUID, skip: int = 0, limit: int = 100) -> list[Conversation]: + """Получить беседы пользователя""" + pass + + @abstractmethod + async def list_by_collection(self, collection_id: UUID, skip: int = 0, limit: int = 100) -> list[Conversation]: + """Получить беседы по коллекции""" + pass + diff --git a/backend/src/domain/repositories/document_repository.py b/backend/src/domain/repositories/document_repository.py index 68f4826..e251879 100644 --- a/backend/src/domain/repositories/document_repository.py +++ b/backend/src/domain/repositories/document_repository.py @@ -1,37 +1,37 @@ -""" -Интерфейс репозитория для Document -""" -from abc import ABC, abstractmethod -from uuid import UUID -from typing import Optional -from src.domain.entities.document import Document - - -class IDocumentRepository(ABC): - """Интерфейс репозитория документов""" - - @abstractmethod - async def create(self, document: Document) -> Document: - """Создать документ""" - pass - - @abstractmethod - async def get_by_id(self, document_id: UUID) -> Optional[Document]: - """Получить документ по ID""" - pass - - @abstractmethod - async def update(self, document: Document) -> Document: - """Обновить документ""" - pass - - @abstractmethod - async def delete(self, document_id: UUID) -> bool: - """Удалить документ""" - pass - - @abstractmethod - async def list_by_collection(self, collection_id: UUID, skip: int = 0, limit: int = 100) -> list[Document]: - """Получить документы коллекции""" - pass - +""" +Интерфейс репозитория для Document +""" +from abc import ABC, abstractmethod +from uuid import UUID +from typing import Optional +from src.domain.entities.document import Document + + +class IDocumentRepository(ABC): + """Интерфейс репозитория документов""" + + @abstractmethod + async def create(self, document: Document) -> Document: + """Создать документ""" + pass + + @abstractmethod + async def get_by_id(self, document_id: UUID) -> Optional[Document]: + """Получить документ по ID""" + pass + + @abstractmethod + async def update(self, document: Document) -> Document: + """Обновить документ""" + pass + + @abstractmethod + async def delete(self, document_id: UUID) -> bool: + """Удалить документ""" + pass + + @abstractmethod + async def list_by_collection(self, collection_id: UUID, skip: int = 0, limit: int = 100) -> list[Document]: + """Получить документы коллекции""" + pass + diff --git a/backend/src/domain/repositories/message_repository.py b/backend/src/domain/repositories/message_repository.py index d40d477..121e358 100644 --- a/backend/src/domain/repositories/message_repository.py +++ b/backend/src/domain/repositories/message_repository.py @@ -1,37 +1,37 @@ -""" -Интерфейс репозитория для Message -""" -from abc import ABC, abstractmethod -from uuid import UUID -from typing import Optional -from src.domain.entities.message import Message - - -class IMessageRepository(ABC): - """Интерфейс репозитория сообщений""" - - @abstractmethod - async def create(self, message: Message) -> Message: - """Создать сообщение""" - pass - - @abstractmethod - async def get_by_id(self, message_id: UUID) -> Optional[Message]: - """Получить сообщение по ID""" - pass - - @abstractmethod - async def update(self, message: Message) -> Message: - """Обновить сообщение""" - pass - - @abstractmethod - async def delete(self, message_id: UUID) -> bool: - """Удалить сообщение""" - pass - - @abstractmethod - async def list_by_conversation(self, conversation_id: UUID, skip: int = 0, limit: int = 100) -> list[Message]: - """Получить сообщения беседы""" - pass - +""" +Интерфейс репозитория для Message +""" +from abc import ABC, abstractmethod +from uuid import UUID +from typing import Optional +from src.domain.entities.message import Message + + +class IMessageRepository(ABC): + """Интерфейс репозитория сообщений""" + + @abstractmethod + async def create(self, message: Message) -> Message: + """Создать сообщение""" + pass + + @abstractmethod + async def get_by_id(self, message_id: UUID) -> Optional[Message]: + """Получить сообщение по ID""" + pass + + @abstractmethod + async def update(self, message: Message) -> Message: + """Обновить сообщение""" + pass + + @abstractmethod + async def delete(self, message_id: UUID) -> bool: + """Удалить сообщение""" + pass + + @abstractmethod + async def list_by_conversation(self, conversation_id: UUID, skip: int = 0, limit: int = 100) -> list[Message]: + """Получить сообщения беседы""" + pass + diff --git a/backend/src/domain/repositories/user_repository.py b/backend/src/domain/repositories/user_repository.py index a724816..101ed38 100644 --- a/backend/src/domain/repositories/user_repository.py +++ b/backend/src/domain/repositories/user_repository.py @@ -1,42 +1,42 @@ -""" -Интерфейс репозитория для User -""" -from abc import ABC, abstractmethod -from uuid import UUID -from typing import Optional -from src.domain.entities.user import User - - -class IUserRepository(ABC): - """Интерфейс репозитория пользователей""" - - @abstractmethod - async def create(self, user: User) -> User: - """Создать пользователя""" - pass - - @abstractmethod - async def get_by_id(self, user_id: UUID) -> Optional[User]: - """Получить пользователя по ID""" - pass - - @abstractmethod - async def get_by_telegram_id(self, telegram_id: str) -> Optional[User]: - """Получить пользователя по Telegram ID""" - pass - - @abstractmethod - async def update(self, user: User) -> User: - """Обновить пользователя""" - pass - - @abstractmethod - async def delete(self, user_id: UUID) -> bool: - """Удалить пользователя""" - pass - - @abstractmethod - async def list_all(self, skip: int = 0, limit: int = 100) -> list[User]: - """Получить список всех пользователей""" - pass - +""" +Интерфейс репозитория для User +""" +from abc import ABC, abstractmethod +from uuid import UUID +from typing import Optional +from src.domain.entities.user import User + + +class IUserRepository(ABC): + """Интерфейс репозитория пользователей""" + + @abstractmethod + async def create(self, user: User) -> User: + """Создать пользователя""" + pass + + @abstractmethod + async def get_by_id(self, user_id: UUID) -> Optional[User]: + """Получить пользователя по ID""" + pass + + @abstractmethod + async def get_by_telegram_id(self, telegram_id: str) -> Optional[User]: + """Получить пользователя по Telegram ID""" + pass + + @abstractmethod + async def update(self, user: User) -> User: + """Обновить пользователя""" + pass + + @abstractmethod + async def delete(self, user_id: UUID) -> bool: + """Удалить пользователя""" + pass + + @abstractmethod + async def list_all(self, skip: int = 0, limit: int = 100) -> list[User]: + """Получить список всех пользователей""" + pass + diff --git a/backend/src/infrastructure/__init__.py b/backend/src/infrastructure/__init__.py index a0b510e..815b868 100644 --- a/backend/src/infrastructure/__init__.py +++ b/backend/src/infrastructure/__init__.py @@ -1,4 +1,4 @@ -""" -Infrastructure layer -""" - +""" +Infrastructure layer +""" + diff --git a/backend/src/infrastructure/database/__init__.py b/backend/src/infrastructure/database/__init__.py index 0049886..cb10bf6 100644 --- a/backend/src/infrastructure/database/__init__.py +++ b/backend/src/infrastructure/database/__init__.py @@ -1,4 +1,4 @@ -""" -Database infrastructure -""" - +""" +Database infrastructure +""" + diff --git a/backend/src/infrastructure/database/base.py b/backend/src/infrastructure/database/base.py index 8c19b37..9e25f3e 100644 --- a/backend/src/infrastructure/database/base.py +++ b/backend/src/infrastructure/database/base.py @@ -1,32 +1,32 @@ -""" -Базовые настройки базы данных -""" -from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker -from sqlalchemy.orm import declarative_base -from src.shared.config import settings - -engine = create_async_engine( - settings.database_url.replace("postgresql://", "postgresql+asyncpg://"), - echo=settings.DEBUG, - future=True -) - -AsyncSessionLocal = async_sessionmaker( - engine, - class_=AsyncSession, - expire_on_commit=False, - autocommit=False, - autoflush=False -) - -Base = declarative_base() - - -async def get_db() -> AsyncSession: - """Dependency для получения сессии БД""" - async with AsyncSessionLocal() as session: - try: - yield session - finally: - await session.close() - +""" +Базовые настройки базы данных +""" +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker +from sqlalchemy.orm import declarative_base +from src.shared.config import settings + +engine = create_async_engine( + settings.database_url.replace("postgresql://", "postgresql+asyncpg://"), + echo=settings.DEBUG, + future=True +) + +AsyncSessionLocal = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, + autocommit=False, + autoflush=False +) + +Base = declarative_base() + + +async def get_db() -> AsyncSession: + """Dependency для получения сессии БД""" + async with AsyncSessionLocal() as session: + try: + yield session + finally: + await session.close() + diff --git a/backend/src/infrastructure/database/models.py b/backend/src/infrastructure/database/models.py index b2312fe..8671da0 100644 --- a/backend/src/infrastructure/database/models.py +++ b/backend/src/infrastructure/database/models.py @@ -1,109 +1,109 @@ -""" -SQLAlchemy модели для базы данных -""" -from sqlalchemy import Column, String, Text, Boolean, DateTime, ForeignKey, JSON, Integer -from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.orm import relationship -from datetime import datetime -import uuid -from src.infrastructure.database.base import Base - - -class UserModel(Base): - """Модель пользователя""" - __tablename__ = "users" - - user_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - telegram_id = Column(String, unique=True, nullable=False, index=True) - role = Column(String, nullable=False, default="user") - created_at = Column(DateTime, nullable=False, default=datetime.utcnow) - collections = relationship("CollectionModel", back_populates="owner", cascade="all, delete-orphan") - conversations = relationship("ConversationModel", back_populates="user", cascade="all, delete-orphan") - collection_accesses = relationship("CollectionAccessModel", back_populates="user", cascade="all, delete-orphan") - - -class CollectionModel(Base): - """Модель коллекции""" - __tablename__ = "collections" - - collection_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - name = Column(String, nullable=False) - description = Column(Text, nullable=True) - owner_id = Column(UUID(as_uuid=True), ForeignKey("users.user_id"), nullable=False) - is_public = Column(Boolean, nullable=False, default=False) - created_at = Column(DateTime, nullable=False, default=datetime.utcnow) - owner = relationship("UserModel", back_populates="collections") - documents = relationship("DocumentModel", back_populates="collection", cascade="all, delete-orphan") - conversations = relationship("ConversationModel", back_populates="collection", cascade="all, delete-orphan") - accesses = relationship("CollectionAccessModel", back_populates="collection", cascade="all, delete-orphan") - - -class DocumentModel(Base): - """Модель документа""" - __tablename__ = "documents" - - document_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - collection_id = Column(UUID(as_uuid=True), ForeignKey("collections.collection_id"), nullable=False) - title = Column(String, nullable=False) - content = Column(Text, nullable=False) - document_metadata = Column("metadata", JSON, nullable=True, default={}) - created_at = Column(DateTime, nullable=False, default=datetime.utcnow) - collection = relationship("CollectionModel", back_populates="documents") - embeddings = relationship("EmbeddingModel", back_populates="document", cascade="all, delete-orphan") - - -class EmbeddingModel(Base): - """Модель эмбеддинга (заглушка)""" - __tablename__ = "embeddings" - - embedding_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - document_id = Column(UUID(as_uuid=True), ForeignKey("documents.document_id"), nullable=False) - embedding = Column(JSON, nullable=True) - model_version = Column(String, nullable=True) - created_at = Column(DateTime, nullable=False, default=datetime.utcnow) - document = relationship("DocumentModel", back_populates="embeddings") - - -class ConversationModel(Base): - """Модель беседы""" - __tablename__ = "conversations" - - conversation_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - user_id = Column(UUID(as_uuid=True), ForeignKey("users.user_id"), nullable=False) - collection_id = Column(UUID(as_uuid=True), ForeignKey("collections.collection_id"), nullable=False) - created_at = Column(DateTime, nullable=False, default=datetime.utcnow) - updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow) - - user = relationship("UserModel", back_populates="conversations") - collection = relationship("CollectionModel", back_populates="conversations") - messages = relationship("MessageModel", back_populates="conversation", cascade="all, delete-orphan") - - -class MessageModel(Base): - """Модель сообщения""" - __tablename__ = "messages" - - message_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - conversation_id = Column(UUID(as_uuid=True), ForeignKey("conversations.conversation_id"), nullable=False) - content = Column(Text, nullable=False) - role = Column(String, nullable=False) - sources = Column(JSON, nullable=True, default={}) - created_at = Column(DateTime, nullable=False, default=datetime.utcnow) - conversation = relationship("ConversationModel", back_populates="messages") - - -class CollectionAccessModel(Base): - """Модель доступа к коллекции""" - __tablename__ = "collection_access" - - access_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - user_id = Column(UUID(as_uuid=True), ForeignKey("users.user_id"), nullable=False) - collection_id = Column(UUID(as_uuid=True), ForeignKey("collections.collection_id"), nullable=False) - created_at = Column(DateTime, nullable=False, default=datetime.utcnow) - user = relationship("UserModel", back_populates="collection_accesses") - collection = relationship("CollectionModel", back_populates="accesses") - - __table_args__ = ( - {"comment": "Уникальный доступ пользователя к коллекции"}, - ) - +""" +SQLAlchemy модели для базы данных +""" +from sqlalchemy import Column, String, Text, Boolean, DateTime, ForeignKey, JSON, Integer +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +from datetime import datetime +import uuid +from src.infrastructure.database.base import Base + + +class UserModel(Base): + """Модель пользователя""" + __tablename__ = "users" + + user_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + telegram_id = Column(String, unique=True, nullable=False, index=True) + role = Column(String, nullable=False, default="user") + created_at = Column(DateTime, nullable=False, default=datetime.utcnow) + collections = relationship("CollectionModel", back_populates="owner", cascade="all, delete-orphan") + conversations = relationship("ConversationModel", back_populates="user", cascade="all, delete-orphan") + collection_accesses = relationship("CollectionAccessModel", back_populates="user", cascade="all, delete-orphan") + + +class CollectionModel(Base): + """Модель коллекции""" + __tablename__ = "collections" + + collection_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name = Column(String, nullable=False) + description = Column(Text, nullable=True) + owner_id = Column(UUID(as_uuid=True), ForeignKey("users.user_id"), nullable=False) + is_public = Column(Boolean, nullable=False, default=False) + created_at = Column(DateTime, nullable=False, default=datetime.utcnow) + owner = relationship("UserModel", back_populates="collections") + documents = relationship("DocumentModel", back_populates="collection", cascade="all, delete-orphan") + conversations = relationship("ConversationModel", back_populates="collection", cascade="all, delete-orphan") + accesses = relationship("CollectionAccessModel", back_populates="collection", cascade="all, delete-orphan") + + +class DocumentModel(Base): + """Модель документа""" + __tablename__ = "documents" + + document_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + collection_id = Column(UUID(as_uuid=True), ForeignKey("collections.collection_id"), nullable=False) + title = Column(String, nullable=False) + content = Column(Text, nullable=False) + document_metadata = Column("metadata", JSON, nullable=True, default={}) + created_at = Column(DateTime, nullable=False, default=datetime.utcnow) + collection = relationship("CollectionModel", back_populates="documents") + embeddings = relationship("EmbeddingModel", back_populates="document", cascade="all, delete-orphan") + + +class EmbeddingModel(Base): + """Модель эмбеддинга (заглушка)""" + __tablename__ = "embeddings" + + embedding_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + document_id = Column(UUID(as_uuid=True), ForeignKey("documents.document_id"), nullable=False) + embedding = Column(JSON, nullable=True) + model_version = Column(String, nullable=True) + created_at = Column(DateTime, nullable=False, default=datetime.utcnow) + document = relationship("DocumentModel", back_populates="embeddings") + + +class ConversationModel(Base): + """Модель беседы""" + __tablename__ = "conversations" + + conversation_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id = Column(UUID(as_uuid=True), ForeignKey("users.user_id"), nullable=False) + collection_id = Column(UUID(as_uuid=True), ForeignKey("collections.collection_id"), nullable=False) + created_at = Column(DateTime, nullable=False, default=datetime.utcnow) + updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow) + + user = relationship("UserModel", back_populates="conversations") + collection = relationship("CollectionModel", back_populates="conversations") + messages = relationship("MessageModel", back_populates="conversation", cascade="all, delete-orphan") + + +class MessageModel(Base): + """Модель сообщения""" + __tablename__ = "messages" + + message_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + conversation_id = Column(UUID(as_uuid=True), ForeignKey("conversations.conversation_id"), nullable=False) + content = Column(Text, nullable=False) + role = Column(String, nullable=False) + sources = Column(JSON, nullable=True, default={}) + created_at = Column(DateTime, nullable=False, default=datetime.utcnow) + conversation = relationship("ConversationModel", back_populates="messages") + + +class CollectionAccessModel(Base): + """Модель доступа к коллекции""" + __tablename__ = "collection_access" + + access_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id = Column(UUID(as_uuid=True), ForeignKey("users.user_id"), nullable=False) + collection_id = Column(UUID(as_uuid=True), ForeignKey("collections.collection_id"), nullable=False) + created_at = Column(DateTime, nullable=False, default=datetime.utcnow) + user = relationship("UserModel", back_populates="collection_accesses") + collection = relationship("CollectionModel", back_populates="accesses") + + __table_args__ = ( + {"comment": "Уникальный доступ пользователя к коллекции"}, + ) + diff --git a/backend/src/infrastructure/external/__init__.py b/backend/src/infrastructure/external/__init__.py index 6911390..9f32a93 100644 --- a/backend/src/infrastructure/external/__init__.py +++ b/backend/src/infrastructure/external/__init__.py @@ -1,4 +1,4 @@ -""" -External services -""" - +""" +External services +""" + diff --git a/backend/src/infrastructure/external/deepseek_client.py b/backend/src/infrastructure/external/deepseek_client.py index 277bc33..5b1acd0 100644 --- a/backend/src/infrastructure/external/deepseek_client.py +++ b/backend/src/infrastructure/external/deepseek_client.py @@ -1,223 +1,223 @@ -""" -Клиент для работы с DeepSeek API -""" -import json -from typing import Optional, AsyncIterator -import httpx -from src.shared.config import settings - - -class DeepSeekAPIError(Exception): - """Ошибка при работе с DeepSeek API""" - pass - - -class DeepSeekClient: - """Клиент для работы с DeepSeek API""" - - def __init__(self, api_key: str | None = None, api_url: str | None = None): - self.api_key = api_key or settings.DEEPSEEK_API_KEY - self.api_url = api_url or settings.DEEPSEEK_API_URL - self.timeout = 60.0 - - def _get_headers(self) -> dict[str, str]: - """Получить заголовки для запроса""" - if not self.api_key: - raise DeepSeekAPIError("DEEPSEEK_API_KEY не установлен в настройках") - - return { - "Content-Type": "application/json", - "Authorization": f"Bearer {self.api_key}" - } - - async def chat_completion( - self, - messages: list[dict[str, str]], - model: str = "deepseek-chat", - temperature: float = 0.7, - max_tokens: Optional[int] = None, - stream: bool = False - ) -> dict: - """ - Отправка запроса на генерацию ответа - - Args: - messages: Список сообщений в формате [{"role": "user", "content": "..."}] - model: Модель для использования (по умолчанию "deepseek-chat") - temperature: Температура генерации (0.0-2.0) - max_tokens: Максимальное количество токенов в ответе - stream: Использовать ли потоковую генерацию - - Returns: - Ответ от API в формате: - { - "content": "текст ответа", - "usage": { - "prompt_tokens": int, - "completion_tokens": int, - "total_tokens": int - } - } - - Raises: - DeepSeekAPIError: При ошибке API - """ - if not self.api_key: - return { - "content": " DEEPSEEK_API_KEY не установлен. Установите ключ в настройках для работы с DeepSeek API.", - "usage": { - "prompt_tokens": 0, - "completion_tokens": 0, - "total_tokens": 0 - } - } - - payload = { - "model": model, - "messages": messages, - "temperature": temperature, - "stream": stream - } - - if max_tokens is not None: - payload["max_tokens"] = max_tokens - - try: - async with httpx.AsyncClient(timeout=self.timeout) as client: - response = await client.post( - self.api_url, - headers=self._get_headers(), - json=payload - ) - response.raise_for_status() - - data = response.json() - - if "choices" in data and len(data["choices"]) > 0: - content = data["choices"][0]["message"]["content"] - else: - raise DeepSeekAPIError("Неожиданный формат ответа от DeepSeek API") - - usage = data.get("usage", {}) - - return { - "content": content, - "usage": { - "prompt_tokens": usage.get("prompt_tokens", 0), - "completion_tokens": usage.get("completion_tokens", 0), - "total_tokens": usage.get("total_tokens", 0) - } - } - - except httpx.HTTPStatusError as e: - error_msg = f"Ошибка DeepSeek API: {e.response.status_code}" - try: - error_data = e.response.json() - if "error" in error_data: - error_msg = f"Ошибка DeepSeek API: {error_data['error'].get('message', error_msg)}" - except: - pass - raise DeepSeekAPIError(error_msg) from e - except httpx.RequestError as e: - raise DeepSeekAPIError(f"Ошибка подключения к DeepSeek API: {str(e)}") from e - except Exception as e: - raise DeepSeekAPIError(f"Неожиданная ошибка при работе с DeepSeek API: {str(e)}") from e - - async def stream_chat_completion( - self, - messages: list[dict[str, str]], - model: str = "deepseek-chat", - temperature: float = 0.7, - max_tokens: Optional[int] = None - ) -> AsyncIterator[str]: - """ - Потоковая генерация ответа - - Args: - messages: Список сообщений в формате [{"role": "user", "content": "..."}] - model: Модель для использования - temperature: Температура генерации - max_tokens: Максимальное количество токенов - - Yields: - Части ответа (chunks) по мере генерации - - Raises: - DeepSeekAPIError: При ошибке API - """ - if not self.api_key: - yield "⚠️ DEEPSEEK_API_KEY не установлен. Установите ключ в настройках для работы с DeepSeek API." - return - - payload = { - "model": model, - "messages": messages, - "temperature": temperature, - "stream": True - } - - if max_tokens is not None: - payload["max_tokens"] = max_tokens - - try: - async with httpx.AsyncClient(timeout=self.timeout) as client: - async with client.stream( - "POST", - self.api_url, - headers=self._get_headers(), - json=payload - ) as response: - response.raise_for_status() - - async for line in response.aiter_lines(): - if not line.strip(): - continue - - if line.startswith("data: "): - line = line[6:] - - if line.strip() == "[DONE]": - break - - try: - data = json.loads(line) - - if "choices" in data and len(data["choices"]) > 0: - delta = data["choices"][0].get("delta", {}) - content = delta.get("content", "") - if content: - yield content - except json.JSONDecodeError: - continue - - except httpx.HTTPStatusError as e: - error_msg = f"Ошибка DeepSeek API: {e.response.status_code}" - try: - error_data = e.response.json() - if "error" in error_data: - error_msg = f"Ошибка DeepSeek API: {error_data['error'].get('message', error_msg)}" - except: - pass - raise DeepSeekAPIError(error_msg) from e - except httpx.RequestError as e: - raise DeepSeekAPIError(f"Ошибка подключения к DeepSeek API: {str(e)}") from e - except Exception as e: - raise DeepSeekAPIError(f"Неожиданная ошибка при потоковой генерации: {str(e)}") from e - - async def health_check(self) -> bool: - """ - Проверка доступности API - - Returns: - True если API доступен, False иначе - """ - if not self.api_key: - return False - - try: - test_messages = [{"role": "user", "content": "test"}] - await self.chat_completion(test_messages, max_tokens=1) - return True - except Exception: - return False - +""" +Клиент для работы с DeepSeek API +""" +import json +from typing import Optional, AsyncIterator +import httpx +from src.shared.config import settings + + +class DeepSeekAPIError(Exception): + """Ошибка при работе с DeepSeek API""" + pass + + +class DeepSeekClient: + """Клиент для работы с DeepSeek API""" + + def __init__(self, api_key: str | None = None, api_url: str | None = None): + self.api_key = api_key or settings.DEEPSEEK_API_KEY + self.api_url = api_url or settings.DEEPSEEK_API_URL + self.timeout = 60.0 + + def _get_headers(self) -> dict[str, str]: + """Получить заголовки для запроса""" + if not self.api_key: + raise DeepSeekAPIError("DEEPSEEK_API_KEY не установлен в настройках") + + return { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.api_key}" + } + + async def chat_completion( + self, + messages: list[dict[str, str]], + model: str = "deepseek-chat", + temperature: float = 0.7, + max_tokens: Optional[int] = None, + stream: bool = False + ) -> dict: + """ + Отправка запроса на генерацию ответа + + Args: + messages: Список сообщений в формате [{"role": "user", "content": "..."}] + model: Модель для использования (по умолчанию "deepseek-chat") + temperature: Температура генерации (0.0-2.0) + max_tokens: Максимальное количество токенов в ответе + stream: Использовать ли потоковую генерацию + + Returns: + Ответ от API в формате: + { + "content": "текст ответа", + "usage": { + "prompt_tokens": int, + "completion_tokens": int, + "total_tokens": int + } + } + + Raises: + DeepSeekAPIError: При ошибке API + """ + if not self.api_key: + return { + "content": " DEEPSEEK_API_KEY не установлен. Установите ключ в настройках для работы с DeepSeek API.", + "usage": { + "prompt_tokens": 0, + "completion_tokens": 0, + "total_tokens": 0 + } + } + + payload = { + "model": model, + "messages": messages, + "temperature": temperature, + "stream": stream + } + + if max_tokens is not None: + payload["max_tokens"] = max_tokens + + try: + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.post( + self.api_url, + headers=self._get_headers(), + json=payload + ) + response.raise_for_status() + + data = response.json() + + if "choices" in data and len(data["choices"]) > 0: + content = data["choices"][0]["message"]["content"] + else: + raise DeepSeekAPIError("Неожиданный формат ответа от DeepSeek API") + + usage = data.get("usage", {}) + + return { + "content": content, + "usage": { + "prompt_tokens": usage.get("prompt_tokens", 0), + "completion_tokens": usage.get("completion_tokens", 0), + "total_tokens": usage.get("total_tokens", 0) + } + } + + except httpx.HTTPStatusError as e: + error_msg = f"Ошибка DeepSeek API: {e.response.status_code}" + try: + error_data = e.response.json() + if "error" in error_data: + error_msg = f"Ошибка DeepSeek API: {error_data['error'].get('message', error_msg)}" + except: + pass + raise DeepSeekAPIError(error_msg) from e + except httpx.RequestError as e: + raise DeepSeekAPIError(f"Ошибка подключения к DeepSeek API: {str(e)}") from e + except Exception as e: + raise DeepSeekAPIError(f"Неожиданная ошибка при работе с DeepSeek API: {str(e)}") from e + + async def stream_chat_completion( + self, + messages: list[dict[str, str]], + model: str = "deepseek-chat", + temperature: float = 0.7, + max_tokens: Optional[int] = None + ) -> AsyncIterator[str]: + """ + Потоковая генерация ответа + + Args: + messages: Список сообщений в формате [{"role": "user", "content": "..."}] + model: Модель для использования + temperature: Температура генерации + max_tokens: Максимальное количество токенов + + Yields: + Части ответа (chunks) по мере генерации + + Raises: + DeepSeekAPIError: При ошибке API + """ + if not self.api_key: + yield "⚠️ DEEPSEEK_API_KEY не установлен. Установите ключ в настройках для работы с DeepSeek API." + return + + payload = { + "model": model, + "messages": messages, + "temperature": temperature, + "stream": True + } + + if max_tokens is not None: + payload["max_tokens"] = max_tokens + + try: + async with httpx.AsyncClient(timeout=self.timeout) as client: + async with client.stream( + "POST", + self.api_url, + headers=self._get_headers(), + json=payload + ) as response: + response.raise_for_status() + + async for line in response.aiter_lines(): + if not line.strip(): + continue + + if line.startswith("data: "): + line = line[6:] + + if line.strip() == "[DONE]": + break + + try: + data = json.loads(line) + + if "choices" in data and len(data["choices"]) > 0: + delta = data["choices"][0].get("delta", {}) + content = delta.get("content", "") + if content: + yield content + except json.JSONDecodeError: + continue + + except httpx.HTTPStatusError as e: + error_msg = f"Ошибка DeepSeek API: {e.response.status_code}" + try: + error_data = e.response.json() + if "error" in error_data: + error_msg = f"Ошибка DeepSeek API: {error_data['error'].get('message', error_msg)}" + except: + pass + raise DeepSeekAPIError(error_msg) from e + except httpx.RequestError as e: + raise DeepSeekAPIError(f"Ошибка подключения к DeepSeek API: {str(e)}") from e + except Exception as e: + raise DeepSeekAPIError(f"Неожиданная ошибка при потоковой генерации: {str(e)}") from e + + async def health_check(self) -> bool: + """ + Проверка доступности API + + Returns: + True если API доступен, False иначе + """ + if not self.api_key: + return False + + try: + test_messages = [{"role": "user", "content": "test"}] + await self.chat_completion(test_messages, max_tokens=1) + return True + except Exception: + return False + diff --git a/backend/src/infrastructure/external/telegram_auth.py b/backend/src/infrastructure/external/telegram_auth.py index c09b37b..e28a966 100644 --- a/backend/src/infrastructure/external/telegram_auth.py +++ b/backend/src/infrastructure/external/telegram_auth.py @@ -1,35 +1,35 @@ -""" -Сервис для работы с Telegram Bot API -""" -from typing import Optional -from src.shared.config import settings - - -class TelegramAuthService: - """ - Сервис для работы с Telegram Bot API - - """ - - def __init__(self, bot_token: str | None = None): - self.bot_token = bot_token or settings.TELEGRAM_BOT_TOKEN - - async def get_user_info(self, telegram_id: str) -> Optional[dict]: - """ - Получение информации о пользователе через Telegram Bot API - - Args: - telegram_id: ID пользователя в Telegram - - Returns: - Информация о пользователе или None - """ - if not self.bot_token: - return None - - return { - "id": telegram_id, - "first_name": "User", - "username": None - } - +""" +Сервис для работы с Telegram Bot API +""" +from typing import Optional +from src.shared.config import settings + + +class TelegramAuthService: + """ + Сервис для работы с Telegram Bot API + + """ + + def __init__(self, bot_token: str | None = None): + self.bot_token = bot_token or settings.TELEGRAM_BOT_TOKEN + + async def get_user_info(self, telegram_id: str) -> Optional[dict]: + """ + Получение информации о пользователе через Telegram Bot API + + Args: + telegram_id: ID пользователя в Telegram + + Returns: + Информация о пользователе или None + """ + if not self.bot_token: + return None + + return { + "id": telegram_id, + "first_name": "User", + "username": None + } + diff --git a/backend/src/infrastructure/external/yandex_ocr.py b/backend/src/infrastructure/external/yandex_ocr.py index 4db4ddb..3cdee5d 100644 --- a/backend/src/infrastructure/external/yandex_ocr.py +++ b/backend/src/infrastructure/external/yandex_ocr.py @@ -1,280 +1,280 @@ -""" -Интеграция с Yandex Vision OCR для парсинга документов -""" -import base64 -import io -from typing import BinaryIO -import httpx -import fitz -from PIL import Image -from src.shared.config import settings - - -class YandexOCRError(Exception): - """Ошибка при работе с Yandex OCR API""" - pass - - -class YandexOCRService: - """Сервис для работы с Yandex Vision OCR""" - - def __init__(self, api_key: str | None = None): - self.api_key = api_key or settings.YANDEX_OCR_API_KEY - self.api_url = settings.YANDEX_OCR_API_URL - self.timeout = 120.0 - self.max_file_size = 10 * 1024 * 1024 - - def _get_headers(self) -> dict[str, str]: - """Получить заголовки для запроса""" - if not self.api_key: - raise YandexOCRError("YANDEX_OCR_API_KEY не установлен в настройках") - - return { - "Authorization": f"Api-Key {self.api_key}", - "Content-Type": "application/json" - } - - def _validate_file_size(self, file_content: bytes) -> None: - """Проверка размера файла""" - if len(file_content) > self.max_file_size: - raise YandexOCRError( - f"Файл слишком большой: {len(file_content)} байт. " - f"Максимальный размер: {self.max_file_size} байт (10 МБ)" - ) - - async def extract_text( - self, - file_content: bytes, - file_type: str = "pdf", - language_codes: list[str] | None = None - ) -> str: - """ - Извлечение текста из файла через Yandex Vision OCR - - Args: - file_content: Содержимое файла в байтах - file_type: Тип файла (pdf, image) - language_codes: Коды языков для распознавания (по умолчанию ['ru', 'en']) - - Returns: - Извлеченный текст - - Raises: - YandexOCRError: При ошибке API - """ - if not self.api_key: - return " YANDEX_OCR_API_KEY не установлен. Установите ключ в настройках для распознавания документов." - - self._validate_file_size(file_content) - - image_data = base64.b64encode(file_content).decode('utf-8') - - if language_codes is None: - language_codes = ['ru', 'en'] - - model = 'page' - - payload = { - "analyze_specs": [{ - "content": image_data, - "features": [{ - "type": "TEXT_DETECTION", - "text_detection_config": { - "model": model, - "language_codes": language_codes - } - }] - }] - } - - try: - async with httpx.AsyncClient(timeout=self.timeout) as client: - response = await client.post( - self.api_url, - headers=self._get_headers(), - json=payload - ) - response.raise_for_status() - - result = response.json() - - return self._extract_text_from_response(result) - - except httpx.HTTPStatusError as e: - error_msg = f"Ошибка Yandex OCR API: {e.response.status_code}" - try: - error_data = e.response.json() - if "message" in error_data: - error_msg = f"Ошибка Yandex OCR API: {error_data['message']}" - except: - pass - raise YandexOCRError(error_msg) from e - except httpx.RequestError as e: - raise YandexOCRError(f"Ошибка подключения к Yandex OCR API: {str(e)}") from e - except YandexOCRError: - raise - except Exception as e: - import traceback - error_details = traceback.format_exc() - raise YandexOCRError(f"Неожиданная ошибка при работе с Yandex OCR: {str(e)}\n{error_details}") from e - - def _extract_text_from_response(self, response: dict) -> str: - """ - Извлечение текста из ответа Yandex Vision API - - Args: - response: JSON ответ от API - - Returns: - Извлеченный текст - """ - import json - - if not self.api_key: - return " YANDEX_OCR_API_KEY не установлен. Установите ключ в настройках для распознавания документов." - - text_parts = [] - - if "results" not in response: - if "error" in response: - error_msg = response.get("error", {}).get("message", "Неизвестная ошибка") - raise YandexOCRError(f"Ошибка Yandex OCR API: {error_msg}") - raise YandexOCRError(f"Неожиданный формат ответа от Yandex OCR API. Структура: {list(response.keys())}") - - for result in response["results"]: - if "results" not in result: - continue - - for annotation in result["results"]: - if "textDetection" not in annotation: - continue - - text_detection = annotation["textDetection"] - - if "pages" in text_detection: - for page in text_detection["pages"]: - if "blocks" in page: - for block in page["blocks"]: - if "lines" in block: - for line in block["lines"]: - if "words" in line: - line_text = " ".join([ - word.get("text", "") - for word in line["words"] - ]) - if line_text: - text_parts.append(line_text) - - full_text = "\n".join(text_parts) - - if not full_text.strip(): - return f" Не удалось извлечь текст из документа. Возможно, документ пустой или нечитаемый. Структура ответа: {json.dumps(response, indent=2, ensure_ascii=False)[:500]}" - - return full_text - - async def parse_pdf(self, file: BinaryIO) -> str: - """ - Парсинг PDF документа через YandexOCR - - Yandex Vision API не поддерживает PDF напрямую, поэтому - конвертируем каждую страницу PDF в изображение и распознаем отдельно. - - Args: - file: Файловый объект PDF - - Returns: - Текст из документа (объединенный текст со всех страниц) - """ - file_content = await self._read_file(file) - - images = await self._pdf_to_images(file_content) - - if not images: - return " Не удалось конвертировать PDF в изображения. Возможно, файл поврежден." - - all_text_parts = [] - for i, image_bytes in enumerate(images, 1): - try: - page_text = await self.extract_text(image_bytes, file_type="image") - if page_text and not page_text.startswith("Ошибка распознавания:"): - all_text_parts.append(f"--- Страница {i} ---\n{page_text}") - except YandexOCRError as e: - all_text_parts.append(f"--- Страница {i} ---\n Ошибка распознавания: {str(e)}") - - if not all_text_parts: - return " Не удалось распознать текст ни с одной страницы PDF." - - return "\n\n".join(all_text_parts) - - async def _pdf_to_images(self, pdf_content: bytes) -> list[bytes]: - """ - Конвертация PDF в список изображений (по одной на страницу) - - Args: - pdf_content: Содержимое PDF файла в байтах - - Returns: - Список изображений в формате PNG (каждое в байтах) - """ - try: - pdf_document = fitz.open(stream=pdf_content, filetype="pdf") - - images = [] - for page_num in range(len(pdf_document)): - page = pdf_document[page_num] - - mat = fitz.Matrix(2.0, 2.0) - pix = page.get_pixmap(matrix=mat) - - img_data = pix.tobytes("png") - images.append(img_data) - - pdf_document.close() - return images - - except Exception as e: - raise YandexOCRError(f"Ошибка при конвертации PDF в изображения: {str(e)}") from e - - async def parse_image(self, file: BinaryIO) -> str: - """ - Парсинг изображения через YandexOCR - - Args: - file: Файловый объект изображения (PNG, JPEG, etc.) - - Returns: - Текст из изображения - """ - file_content = await self._read_file(file) - return await self.extract_text(file_content, file_type="image") - - async def _read_file(self, file: BinaryIO) -> bytes: - """ - Чтение содержимого файла в байты - - Args: - file: Файловый объект - - Returns: - Содержимое файла в байтах - """ - if hasattr(file, 'read'): - content = file.read() - if hasattr(file, 'seek'): - file.seek(0) - return content - else: - raise YandexOCRError("Некорректный файловый объект") - - async def health_check(self) -> bool: - """ - Проверка доступности API - - Returns: - True если API доступен, False иначе - """ - if not self.api_key: - return False - - return True - +""" +Интеграция с Yandex Vision OCR для парсинга документов +""" +import base64 +import io +from typing import BinaryIO +import httpx +import fitz +from PIL import Image +from src.shared.config import settings + + +class YandexOCRError(Exception): + """Ошибка при работе с Yandex OCR API""" + pass + + +class YandexOCRService: + """Сервис для работы с Yandex Vision OCR""" + + def __init__(self, api_key: str | None = None): + self.api_key = api_key or settings.YANDEX_OCR_API_KEY + self.api_url = settings.YANDEX_OCR_API_URL + self.timeout = 120.0 + self.max_file_size = 10 * 1024 * 1024 + + def _get_headers(self) -> dict[str, str]: + """Получить заголовки для запроса""" + if not self.api_key: + raise YandexOCRError("YANDEX_OCR_API_KEY не установлен в настройках") + + return { + "Authorization": f"Api-Key {self.api_key}", + "Content-Type": "application/json" + } + + def _validate_file_size(self, file_content: bytes) -> None: + """Проверка размера файла""" + if len(file_content) > self.max_file_size: + raise YandexOCRError( + f"Файл слишком большой: {len(file_content)} байт. " + f"Максимальный размер: {self.max_file_size} байт (10 МБ)" + ) + + async def extract_text( + self, + file_content: bytes, + file_type: str = "pdf", + language_codes: list[str] | None = None + ) -> str: + """ + Извлечение текста из файла через Yandex Vision OCR + + Args: + file_content: Содержимое файла в байтах + file_type: Тип файла (pdf, image) + language_codes: Коды языков для распознавания (по умолчанию ['ru', 'en']) + + Returns: + Извлеченный текст + + Raises: + YandexOCRError: При ошибке API + """ + if not self.api_key: + return " YANDEX_OCR_API_KEY не установлен. Установите ключ в настройках для распознавания документов." + + self._validate_file_size(file_content) + + image_data = base64.b64encode(file_content).decode('utf-8') + + if language_codes is None: + language_codes = ['ru', 'en'] + + model = 'page' + + payload = { + "analyze_specs": [{ + "content": image_data, + "features": [{ + "type": "TEXT_DETECTION", + "text_detection_config": { + "model": model, + "language_codes": language_codes + } + }] + }] + } + + try: + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.post( + self.api_url, + headers=self._get_headers(), + json=payload + ) + response.raise_for_status() + + result = response.json() + + return self._extract_text_from_response(result) + + except httpx.HTTPStatusError as e: + error_msg = f"Ошибка Yandex OCR API: {e.response.status_code}" + try: + error_data = e.response.json() + if "message" in error_data: + error_msg = f"Ошибка Yandex OCR API: {error_data['message']}" + except: + pass + raise YandexOCRError(error_msg) from e + except httpx.RequestError as e: + raise YandexOCRError(f"Ошибка подключения к Yandex OCR API: {str(e)}") from e + except YandexOCRError: + raise + except Exception as e: + import traceback + error_details = traceback.format_exc() + raise YandexOCRError(f"Неожиданная ошибка при работе с Yandex OCR: {str(e)}\n{error_details}") from e + + def _extract_text_from_response(self, response: dict) -> str: + """ + Извлечение текста из ответа Yandex Vision API + + Args: + response: JSON ответ от API + + Returns: + Извлеченный текст + """ + import json + + if not self.api_key: + return " YANDEX_OCR_API_KEY не установлен. Установите ключ в настройках для распознавания документов." + + text_parts = [] + + if "results" not in response: + if "error" in response: + error_msg = response.get("error", {}).get("message", "Неизвестная ошибка") + raise YandexOCRError(f"Ошибка Yandex OCR API: {error_msg}") + raise YandexOCRError(f"Неожиданный формат ответа от Yandex OCR API. Структура: {list(response.keys())}") + + for result in response["results"]: + if "results" not in result: + continue + + for annotation in result["results"]: + if "textDetection" not in annotation: + continue + + text_detection = annotation["textDetection"] + + if "pages" in text_detection: + for page in text_detection["pages"]: + if "blocks" in page: + for block in page["blocks"]: + if "lines" in block: + for line in block["lines"]: + if "words" in line: + line_text = " ".join([ + word.get("text", "") + for word in line["words"] + ]) + if line_text: + text_parts.append(line_text) + + full_text = "\n".join(text_parts) + + if not full_text.strip(): + return f" Не удалось извлечь текст из документа. Возможно, документ пустой или нечитаемый. Структура ответа: {json.dumps(response, indent=2, ensure_ascii=False)[:500]}" + + return full_text + + async def parse_pdf(self, file: BinaryIO) -> str: + """ + Парсинг PDF документа через YandexOCR + + Yandex Vision API не поддерживает PDF напрямую, поэтому + конвертируем каждую страницу PDF в изображение и распознаем отдельно. + + Args: + file: Файловый объект PDF + + Returns: + Текст из документа (объединенный текст со всех страниц) + """ + file_content = await self._read_file(file) + + images = await self._pdf_to_images(file_content) + + if not images: + return " Не удалось конвертировать PDF в изображения. Возможно, файл поврежден." + + all_text_parts = [] + for i, image_bytes in enumerate(images, 1): + try: + page_text = await self.extract_text(image_bytes, file_type="image") + if page_text and not page_text.startswith("Ошибка распознавания:"): + all_text_parts.append(f"--- Страница {i} ---\n{page_text}") + except YandexOCRError as e: + all_text_parts.append(f"--- Страница {i} ---\n Ошибка распознавания: {str(e)}") + + if not all_text_parts: + return " Не удалось распознать текст ни с одной страницы PDF." + + return "\n\n".join(all_text_parts) + + async def _pdf_to_images(self, pdf_content: bytes) -> list[bytes]: + """ + Конвертация PDF в список изображений (по одной на страницу) + + Args: + pdf_content: Содержимое PDF файла в байтах + + Returns: + Список изображений в формате PNG (каждое в байтах) + """ + try: + pdf_document = fitz.open(stream=pdf_content, filetype="pdf") + + images = [] + for page_num in range(len(pdf_document)): + page = pdf_document[page_num] + + mat = fitz.Matrix(2.0, 2.0) + pix = page.get_pixmap(matrix=mat) + + img_data = pix.tobytes("png") + images.append(img_data) + + pdf_document.close() + return images + + except Exception as e: + raise YandexOCRError(f"Ошибка при конвертации PDF в изображения: {str(e)}") from e + + async def parse_image(self, file: BinaryIO) -> str: + """ + Парсинг изображения через YandexOCR + + Args: + file: Файловый объект изображения (PNG, JPEG, etc.) + + Returns: + Текст из изображения + """ + file_content = await self._read_file(file) + return await self.extract_text(file_content, file_type="image") + + async def _read_file(self, file: BinaryIO) -> bytes: + """ + Чтение содержимого файла в байты + + Args: + file: Файловый объект + + Returns: + Содержимое файла в байтах + """ + if hasattr(file, 'read'): + content = file.read() + if hasattr(file, 'seek'): + file.seek(0) + return content + else: + raise YandexOCRError("Некорректный файловый объект") + + async def health_check(self) -> bool: + """ + Проверка доступности API + + Returns: + True если API доступен, False иначе + """ + if not self.api_key: + return False + + return True + diff --git a/backend/src/infrastructure/repositories/postgresql/__init__.py b/backend/src/infrastructure/repositories/postgresql/__init__.py index 794676e..e8bb48e 100644 --- a/backend/src/infrastructure/repositories/postgresql/__init__.py +++ b/backend/src/infrastructure/repositories/postgresql/__init__.py @@ -1,4 +1,4 @@ -""" -PostgreSQL repository implementations -""" - +""" +PostgreSQL repository implementations +""" + diff --git a/backend/src/infrastructure/repositories/postgresql/collection_access_repository.py b/backend/src/infrastructure/repositories/postgresql/collection_access_repository.py index eb0acc6..7100a0b 100644 --- a/backend/src/infrastructure/repositories/postgresql/collection_access_repository.py +++ b/backend/src/infrastructure/repositories/postgresql/collection_access_repository.py @@ -1,107 +1,107 @@ -""" -Реализация репозитория доступа к коллекциям для PostgreSQL -""" -from uuid import UUID -from typing import Optional -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select -from src.domain.entities.collection_access import CollectionAccess -from src.domain.repositories.collection_access_repository import ICollectionAccessRepository -from src.infrastructure.database.models import CollectionAccessModel -from src.shared.exceptions import NotFoundError - - -class PostgreSQLCollectionAccessRepository(ICollectionAccessRepository): - """PostgreSQL реализация репозитория доступа к коллекциям""" - - def __init__(self, session: AsyncSession): - self.session = session - - async def create(self, access: CollectionAccess) -> CollectionAccess: - """Создать доступ""" - db_access = CollectionAccessModel( - access_id=access.access_id, - user_id=access.user_id, - collection_id=access.collection_id, - created_at=access.created_at - ) - self.session.add(db_access) - await self.session.commit() - await self.session.refresh(db_access) - return self._to_entity(db_access) - - async def get_by_id(self, access_id: UUID) -> Optional[CollectionAccess]: - """Получить доступ по ID""" - result = await self.session.execute( - select(CollectionAccessModel).where(CollectionAccessModel.access_id == access_id) - ) - db_access = result.scalar_one_or_none() - return self._to_entity(db_access) if db_access else None - - async def delete(self, access_id: UUID) -> bool: - """Удалить доступ""" - result = await self.session.execute( - select(CollectionAccessModel).where(CollectionAccessModel.access_id == access_id) - ) - db_access = result.scalar_one_or_none() - if not db_access: - return False - - await self.session.delete(db_access) - await self.session.commit() - return True - - async def delete_by_user_and_collection(self, user_id: UUID, collection_id: UUID) -> bool: - """Удалить доступ пользователя к коллекции""" - result = await self.session.execute( - select(CollectionAccessModel).where( - CollectionAccessModel.user_id == user_id, - CollectionAccessModel.collection_id == collection_id - ) - ) - db_access = result.scalar_one_or_none() - if not db_access: - return False - - await self.session.delete(db_access) - await self.session.commit() - return True - - async def get_by_user_and_collection(self, user_id: UUID, collection_id: UUID) -> Optional[CollectionAccess]: - """Получить доступ пользователя к коллекции""" - result = await self.session.execute( - select(CollectionAccessModel).where( - CollectionAccessModel.user_id == user_id, - CollectionAccessModel.collection_id == collection_id - ) - ) - db_access = result.scalar_one_or_none() - return self._to_entity(db_access) if db_access else None - - async def list_by_user(self, user_id: UUID) -> list[CollectionAccess]: - """Получить доступы пользователя""" - result = await self.session.execute( - select(CollectionAccessModel).where(CollectionAccessModel.user_id == user_id) - ) - db_accesses = result.scalars().all() - return [self._to_entity(db_access) for db_access in db_accesses] - - async def list_by_collection(self, collection_id: UUID) -> list[CollectionAccess]: - """Получить доступы к коллекции""" - result = await self.session.execute( - select(CollectionAccessModel).where(CollectionAccessModel.collection_id == collection_id) - ) - db_accesses = result.scalars().all() - return [self._to_entity(db_access) for db_access in db_accesses] - - def _to_entity(self, db_access: CollectionAccessModel | None) -> CollectionAccess | None: - """Преобразовать модель БД в доменную сущность""" - if not db_access: - return None - return CollectionAccess( - access_id=db_access.access_id, - user_id=db_access.user_id, - collection_id=db_access.collection_id, - created_at=db_access.created_at - ) - +""" +Реализация репозитория доступа к коллекциям для PostgreSQL +""" +from uuid import UUID +from typing import Optional +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from src.domain.entities.collection_access import CollectionAccess +from src.domain.repositories.collection_access_repository import ICollectionAccessRepository +from src.infrastructure.database.models import CollectionAccessModel +from src.shared.exceptions import NotFoundError + + +class PostgreSQLCollectionAccessRepository(ICollectionAccessRepository): + """PostgreSQL реализация репозитория доступа к коллекциям""" + + def __init__(self, session: AsyncSession): + self.session = session + + async def create(self, access: CollectionAccess) -> CollectionAccess: + """Создать доступ""" + db_access = CollectionAccessModel( + access_id=access.access_id, + user_id=access.user_id, + collection_id=access.collection_id, + created_at=access.created_at + ) + self.session.add(db_access) + await self.session.commit() + await self.session.refresh(db_access) + return self._to_entity(db_access) + + async def get_by_id(self, access_id: UUID) -> Optional[CollectionAccess]: + """Получить доступ по ID""" + result = await self.session.execute( + select(CollectionAccessModel).where(CollectionAccessModel.access_id == access_id) + ) + db_access = result.scalar_one_or_none() + return self._to_entity(db_access) if db_access else None + + async def delete(self, access_id: UUID) -> bool: + """Удалить доступ""" + result = await self.session.execute( + select(CollectionAccessModel).where(CollectionAccessModel.access_id == access_id) + ) + db_access = result.scalar_one_or_none() + if not db_access: + return False + + await self.session.delete(db_access) + await self.session.commit() + return True + + async def delete_by_user_and_collection(self, user_id: UUID, collection_id: UUID) -> bool: + """Удалить доступ пользователя к коллекции""" + result = await self.session.execute( + select(CollectionAccessModel).where( + CollectionAccessModel.user_id == user_id, + CollectionAccessModel.collection_id == collection_id + ) + ) + db_access = result.scalar_one_or_none() + if not db_access: + return False + + await self.session.delete(db_access) + await self.session.commit() + return True + + async def get_by_user_and_collection(self, user_id: UUID, collection_id: UUID) -> Optional[CollectionAccess]: + """Получить доступ пользователя к коллекции""" + result = await self.session.execute( + select(CollectionAccessModel).where( + CollectionAccessModel.user_id == user_id, + CollectionAccessModel.collection_id == collection_id + ) + ) + db_access = result.scalar_one_or_none() + return self._to_entity(db_access) if db_access else None + + async def list_by_user(self, user_id: UUID) -> list[CollectionAccess]: + """Получить доступы пользователя""" + result = await self.session.execute( + select(CollectionAccessModel).where(CollectionAccessModel.user_id == user_id) + ) + db_accesses = result.scalars().all() + return [self._to_entity(db_access) for db_access in db_accesses] + + async def list_by_collection(self, collection_id: UUID) -> list[CollectionAccess]: + """Получить доступы к коллекции""" + result = await self.session.execute( + select(CollectionAccessModel).where(CollectionAccessModel.collection_id == collection_id) + ) + db_accesses = result.scalars().all() + return [self._to_entity(db_access) for db_access in db_accesses] + + def _to_entity(self, db_access: CollectionAccessModel | None) -> CollectionAccess | None: + """Преобразовать модель БД в доменную сущность""" + if not db_access: + return None + return CollectionAccess( + access_id=db_access.access_id, + user_id=db_access.user_id, + collection_id=db_access.collection_id, + created_at=db_access.created_at + ) + diff --git a/backend/src/infrastructure/repositories/postgresql/collection_repository.py b/backend/src/infrastructure/repositories/postgresql/collection_repository.py index 402fcca..28427ba 100644 --- a/backend/src/infrastructure/repositories/postgresql/collection_repository.py +++ b/backend/src/infrastructure/repositories/postgresql/collection_repository.py @@ -1,106 +1,106 @@ -""" -Реализация репозитория коллекций для PostgreSQL -""" -from uuid import UUID -from typing import Optional -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select -from src.domain.entities.collection import Collection -from src.domain.repositories.collection_repository import ICollectionRepository -from src.infrastructure.database.models import CollectionModel -from src.shared.exceptions import NotFoundError - - -class PostgreSQLCollectionRepository(ICollectionRepository): - """PostgreSQL реализация репозитория коллекций""" - - def __init__(self, session: AsyncSession): - self.session = session - - async def create(self, collection: Collection) -> Collection: - """Создать коллекцию""" - db_collection = CollectionModel( - collection_id=collection.collection_id, - name=collection.name, - description=collection.description, - owner_id=collection.owner_id, - is_public=collection.is_public, - created_at=collection.created_at - ) - self.session.add(db_collection) - await self.session.commit() - await self.session.refresh(db_collection) - return self._to_entity(db_collection) - - async def get_by_id(self, collection_id: UUID) -> Optional[Collection]: - """Получить коллекцию по ID""" - result = await self.session.execute( - select(CollectionModel).where(CollectionModel.collection_id == collection_id) - ) - db_collection = result.scalar_one_or_none() - return self._to_entity(db_collection) if db_collection else None - - async def update(self, collection: Collection) -> Collection: - """Обновить коллекцию""" - result = await self.session.execute( - select(CollectionModel).where(CollectionModel.collection_id == collection.collection_id) - ) - db_collection = result.scalar_one_or_none() - if not db_collection: - raise NotFoundError(f"Коллекция {collection.collection_id} не найдена") - - db_collection.name = collection.name - db_collection.description = collection.description - db_collection.is_public = collection.is_public - await self.session.commit() - await self.session.refresh(db_collection) - return self._to_entity(db_collection) - - async def delete(self, collection_id: UUID) -> bool: - """Удалить коллекцию""" - result = await self.session.execute( - select(CollectionModel).where(CollectionModel.collection_id == collection_id) - ) - db_collection = result.scalar_one_or_none() - if not db_collection: - return False - - await self.session.delete(db_collection) - await self.session.commit() - return True - - async def list_by_owner(self, owner_id: UUID, skip: int = 0, limit: int = 100) -> list[Collection]: - """Получить коллекции владельца""" - result = await self.session.execute( - select(CollectionModel) - .where(CollectionModel.owner_id == owner_id) - .offset(skip) - .limit(limit) - ) - db_collections = result.scalars().all() - return [self._to_entity(db_collection) for db_collection in db_collections] - - async def list_public(self, skip: int = 0, limit: int = 100) -> list[Collection]: - """Получить публичные коллекции""" - result = await self.session.execute( - select(CollectionModel) - .where(CollectionModel.is_public == True) - .offset(skip) - .limit(limit) - ) - db_collections = result.scalars().all() - return [self._to_entity(db_collection) for db_collection in db_collections] - - def _to_entity(self, db_collection: CollectionModel | None) -> Collection | None: - """Преобразовать модель БД в доменную сущность""" - if not db_collection: - return None - return Collection( - collection_id=db_collection.collection_id, - name=db_collection.name, - description=db_collection.description or "", - owner_id=db_collection.owner_id, - is_public=db_collection.is_public, - created_at=db_collection.created_at - ) - +""" +Реализация репозитория коллекций для PostgreSQL +""" +from uuid import UUID +from typing import Optional +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from src.domain.entities.collection import Collection +from src.domain.repositories.collection_repository import ICollectionRepository +from src.infrastructure.database.models import CollectionModel +from src.shared.exceptions import NotFoundError + + +class PostgreSQLCollectionRepository(ICollectionRepository): + """PostgreSQL реализация репозитория коллекций""" + + def __init__(self, session: AsyncSession): + self.session = session + + async def create(self, collection: Collection) -> Collection: + """Создать коллекцию""" + db_collection = CollectionModel( + collection_id=collection.collection_id, + name=collection.name, + description=collection.description, + owner_id=collection.owner_id, + is_public=collection.is_public, + created_at=collection.created_at + ) + self.session.add(db_collection) + await self.session.commit() + await self.session.refresh(db_collection) + return self._to_entity(db_collection) + + async def get_by_id(self, collection_id: UUID) -> Optional[Collection]: + """Получить коллекцию по ID""" + result = await self.session.execute( + select(CollectionModel).where(CollectionModel.collection_id == collection_id) + ) + db_collection = result.scalar_one_or_none() + return self._to_entity(db_collection) if db_collection else None + + async def update(self, collection: Collection) -> Collection: + """Обновить коллекцию""" + result = await self.session.execute( + select(CollectionModel).where(CollectionModel.collection_id == collection.collection_id) + ) + db_collection = result.scalar_one_or_none() + if not db_collection: + raise NotFoundError(f"Коллекция {collection.collection_id} не найдена") + + db_collection.name = collection.name + db_collection.description = collection.description + db_collection.is_public = collection.is_public + await self.session.commit() + await self.session.refresh(db_collection) + return self._to_entity(db_collection) + + async def delete(self, collection_id: UUID) -> bool: + """Удалить коллекцию""" + result = await self.session.execute( + select(CollectionModel).where(CollectionModel.collection_id == collection_id) + ) + db_collection = result.scalar_one_or_none() + if not db_collection: + return False + + await self.session.delete(db_collection) + await self.session.commit() + return True + + async def list_by_owner(self, owner_id: UUID, skip: int = 0, limit: int = 100) -> list[Collection]: + """Получить коллекции владельца""" + result = await self.session.execute( + select(CollectionModel) + .where(CollectionModel.owner_id == owner_id) + .offset(skip) + .limit(limit) + ) + db_collections = result.scalars().all() + return [self._to_entity(db_collection) for db_collection in db_collections] + + async def list_public(self, skip: int = 0, limit: int = 100) -> list[Collection]: + """Получить публичные коллекции""" + result = await self.session.execute( + select(CollectionModel) + .where(CollectionModel.is_public == True) + .offset(skip) + .limit(limit) + ) + db_collections = result.scalars().all() + return [self._to_entity(db_collection) for db_collection in db_collections] + + def _to_entity(self, db_collection: CollectionModel | None) -> Collection | None: + """Преобразовать модель БД в доменную сущность""" + if not db_collection: + return None + return Collection( + collection_id=db_collection.collection_id, + name=db_collection.name, + description=db_collection.description or "", + owner_id=db_collection.owner_id, + is_public=db_collection.is_public, + created_at=db_collection.created_at + ) + diff --git a/backend/src/infrastructure/repositories/postgresql/conversation_repository.py b/backend/src/infrastructure/repositories/postgresql/conversation_repository.py index 7c3e923..7d65063 100644 --- a/backend/src/infrastructure/repositories/postgresql/conversation_repository.py +++ b/backend/src/infrastructure/repositories/postgresql/conversation_repository.py @@ -1,104 +1,104 @@ -""" -Реализация репозитория бесед для PostgreSQL -""" -from uuid import UUID -from typing import Optional -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select -from src.domain.entities.conversation import Conversation -from src.domain.repositories.conversation_repository import IConversationRepository -from src.infrastructure.database.models import ConversationModel -from src.shared.exceptions import NotFoundError - - -class PostgreSQLConversationRepository(IConversationRepository): - """PostgreSQL реализация репозитория бесед""" - - def __init__(self, session: AsyncSession): - self.session = session - - async def create(self, conversation: Conversation) -> Conversation: - """Создать беседу""" - db_conversation = ConversationModel( - conversation_id=conversation.conversation_id, - user_id=conversation.user_id, - collection_id=conversation.collection_id, - created_at=conversation.created_at, - updated_at=conversation.updated_at - ) - self.session.add(db_conversation) - await self.session.commit() - await self.session.refresh(db_conversation) - return self._to_entity(db_conversation) - - async def get_by_id(self, conversation_id: UUID) -> Optional[Conversation]: - """Получить беседу по ID""" - result = await self.session.execute( - select(ConversationModel).where(ConversationModel.conversation_id == conversation_id) - ) - db_conversation = result.scalar_one_or_none() - return self._to_entity(db_conversation) if db_conversation else None - - async def update(self, conversation: Conversation) -> Conversation: - """Обновить беседу""" - result = await self.session.execute( - select(ConversationModel).where(ConversationModel.conversation_id == conversation.conversation_id) - ) - db_conversation = result.scalar_one_or_none() - if not db_conversation: - raise NotFoundError(f"Беседа {conversation.conversation_id} не найдена") - - db_conversation.user_id = conversation.user_id - db_conversation.collection_id = conversation.collection_id - db_conversation.updated_at = conversation.updated_at - await self.session.commit() - await self.session.refresh(db_conversation) - return self._to_entity(db_conversation) - - async def delete(self, conversation_id: UUID) -> bool: - """Удалить беседу""" - result = await self.session.execute( - select(ConversationModel).where(ConversationModel.conversation_id == conversation_id) - ) - db_conversation = result.scalar_one_or_none() - if not db_conversation: - return False - - await self.session.delete(db_conversation) - await self.session.commit() - return True - - async def list_by_user(self, user_id: UUID, skip: int = 0, limit: int = 100) -> list[Conversation]: - """Получить беседы пользователя""" - result = await self.session.execute( - select(ConversationModel) - .where(ConversationModel.user_id == user_id) - .offset(skip) - .limit(limit) - ) - db_conversations = result.scalars().all() - return [self._to_entity(db_conversation) for db_conversation in db_conversations] - - async def list_by_collection(self, collection_id: UUID, skip: int = 0, limit: int = 100) -> list[Conversation]: - """Получить беседы по коллекции""" - result = await self.session.execute( - select(ConversationModel) - .where(ConversationModel.collection_id == collection_id) - .offset(skip) - .limit(limit) - ) - db_conversations = result.scalars().all() - return [self._to_entity(db_conversation) for db_conversation in db_conversations] - - def _to_entity(self, db_conversation: ConversationModel | None) -> Conversation | None: - """Преобразовать модель БД в доменную сущность""" - if not db_conversation: - return None - return Conversation( - conversation_id=db_conversation.conversation_id, - user_id=db_conversation.user_id, - collection_id=db_conversation.collection_id, - created_at=db_conversation.created_at, - updated_at=db_conversation.updated_at - ) - +""" +Реализация репозитория бесед для PostgreSQL +""" +from uuid import UUID +from typing import Optional +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from src.domain.entities.conversation import Conversation +from src.domain.repositories.conversation_repository import IConversationRepository +from src.infrastructure.database.models import ConversationModel +from src.shared.exceptions import NotFoundError + + +class PostgreSQLConversationRepository(IConversationRepository): + """PostgreSQL реализация репозитория бесед""" + + def __init__(self, session: AsyncSession): + self.session = session + + async def create(self, conversation: Conversation) -> Conversation: + """Создать беседу""" + db_conversation = ConversationModel( + conversation_id=conversation.conversation_id, + user_id=conversation.user_id, + collection_id=conversation.collection_id, + created_at=conversation.created_at, + updated_at=conversation.updated_at + ) + self.session.add(db_conversation) + await self.session.commit() + await self.session.refresh(db_conversation) + return self._to_entity(db_conversation) + + async def get_by_id(self, conversation_id: UUID) -> Optional[Conversation]: + """Получить беседу по ID""" + result = await self.session.execute( + select(ConversationModel).where(ConversationModel.conversation_id == conversation_id) + ) + db_conversation = result.scalar_one_or_none() + return self._to_entity(db_conversation) if db_conversation else None + + async def update(self, conversation: Conversation) -> Conversation: + """Обновить беседу""" + result = await self.session.execute( + select(ConversationModel).where(ConversationModel.conversation_id == conversation.conversation_id) + ) + db_conversation = result.scalar_one_or_none() + if not db_conversation: + raise NotFoundError(f"Беседа {conversation.conversation_id} не найдена") + + db_conversation.user_id = conversation.user_id + db_conversation.collection_id = conversation.collection_id + db_conversation.updated_at = conversation.updated_at + await self.session.commit() + await self.session.refresh(db_conversation) + return self._to_entity(db_conversation) + + async def delete(self, conversation_id: UUID) -> bool: + """Удалить беседу""" + result = await self.session.execute( + select(ConversationModel).where(ConversationModel.conversation_id == conversation_id) + ) + db_conversation = result.scalar_one_or_none() + if not db_conversation: + return False + + await self.session.delete(db_conversation) + await self.session.commit() + return True + + async def list_by_user(self, user_id: UUID, skip: int = 0, limit: int = 100) -> list[Conversation]: + """Получить беседы пользователя""" + result = await self.session.execute( + select(ConversationModel) + .where(ConversationModel.user_id == user_id) + .offset(skip) + .limit(limit) + ) + db_conversations = result.scalars().all() + return [self._to_entity(db_conversation) for db_conversation in db_conversations] + + async def list_by_collection(self, collection_id: UUID, skip: int = 0, limit: int = 100) -> list[Conversation]: + """Получить беседы по коллекции""" + result = await self.session.execute( + select(ConversationModel) + .where(ConversationModel.collection_id == collection_id) + .offset(skip) + .limit(limit) + ) + db_conversations = result.scalars().all() + return [self._to_entity(db_conversation) for db_conversation in db_conversations] + + def _to_entity(self, db_conversation: ConversationModel | None) -> Conversation | None: + """Преобразовать модель БД в доменную сущность""" + if not db_conversation: + return None + return Conversation( + conversation_id=db_conversation.conversation_id, + user_id=db_conversation.user_id, + collection_id=db_conversation.collection_id, + created_at=db_conversation.created_at, + updated_at=db_conversation.updated_at + ) + diff --git a/backend/src/infrastructure/repositories/postgresql/document_repository.py b/backend/src/infrastructure/repositories/postgresql/document_repository.py index e435739..ca04c5f 100644 --- a/backend/src/infrastructure/repositories/postgresql/document_repository.py +++ b/backend/src/infrastructure/repositories/postgresql/document_repository.py @@ -1,95 +1,95 @@ -""" -Реализация репозитория документов для PostgreSQL -""" -from uuid import UUID -from typing import Optional -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select -from src.domain.entities.document import Document -from src.domain.repositories.document_repository import IDocumentRepository -from src.infrastructure.database.models import DocumentModel -from src.shared.exceptions import NotFoundError - - -class PostgreSQLDocumentRepository(IDocumentRepository): - """PostgreSQL реализация репозитория документов""" - - def __init__(self, session: AsyncSession): - self.session = session - - async def create(self, document: Document) -> Document: - """Создать документ""" - db_document = DocumentModel( - document_id=document.document_id, - collection_id=document.collection_id, - title=document.title, - content=document.content, - document_metadata=document.metadata, - created_at=document.created_at - ) - self.session.add(db_document) - await self.session.commit() - await self.session.refresh(db_document) - return self._to_entity(db_document) - - async def get_by_id(self, document_id: UUID) -> Optional[Document]: - """Получить документ по ID""" - result = await self.session.execute( - select(DocumentModel).where(DocumentModel.document_id == document_id) - ) - db_document = result.scalar_one_or_none() - return self._to_entity(db_document) if db_document else None - - async def update(self, document: Document) -> Document: - """Обновить документ""" - result = await self.session.execute( - select(DocumentModel).where(DocumentModel.document_id == document.document_id) - ) - db_document = result.scalar_one_or_none() - if not db_document: - raise NotFoundError(f"Документ {document.document_id} не найден") - - db_document.title = document.title - db_document.content = document.content - db_document.document_metadata = document.metadata - await self.session.commit() - await self.session.refresh(db_document) - return self._to_entity(db_document) - - async def delete(self, document_id: UUID) -> bool: - """Удалить документ""" - result = await self.session.execute( - select(DocumentModel).where(DocumentModel.document_id == document_id) - ) - db_document = result.scalar_one_or_none() - if not db_document: - return False - - await self.session.delete(db_document) - await self.session.commit() - return True - - async def list_by_collection(self, collection_id: UUID, skip: int = 0, limit: int = 100) -> list[Document]: - """Получить документы коллекции""" - result = await self.session.execute( - select(DocumentModel) - .where(DocumentModel.collection_id == collection_id) - .offset(skip) - .limit(limit) - ) - db_documents = result.scalars().all() - return [self._to_entity(db_document) for db_document in db_documents] - - def _to_entity(self, db_document: DocumentModel | None) -> Document | None: - """Преобразовать модель БД в доменную сущность""" - if not db_document: - return None - return Document( - document_id=db_document.document_id, - collection_id=db_document.collection_id, - title=db_document.title, - content=db_document.content, - metadata=db_document.document_metadata or {}, - created_at=db_document.created_at - ) - +""" +Реализация репозитория документов для PostgreSQL +""" +from uuid import UUID +from typing import Optional +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from src.domain.entities.document import Document +from src.domain.repositories.document_repository import IDocumentRepository +from src.infrastructure.database.models import DocumentModel +from src.shared.exceptions import NotFoundError + + +class PostgreSQLDocumentRepository(IDocumentRepository): + """PostgreSQL реализация репозитория документов""" + + def __init__(self, session: AsyncSession): + self.session = session + + async def create(self, document: Document) -> Document: + """Создать документ""" + db_document = DocumentModel( + document_id=document.document_id, + collection_id=document.collection_id, + title=document.title, + content=document.content, + document_metadata=document.metadata, + created_at=document.created_at + ) + self.session.add(db_document) + await self.session.commit() + await self.session.refresh(db_document) + return self._to_entity(db_document) + + async def get_by_id(self, document_id: UUID) -> Optional[Document]: + """Получить документ по ID""" + result = await self.session.execute( + select(DocumentModel).where(DocumentModel.document_id == document_id) + ) + db_document = result.scalar_one_or_none() + return self._to_entity(db_document) if db_document else None + + async def update(self, document: Document) -> Document: + """Обновить документ""" + result = await self.session.execute( + select(DocumentModel).where(DocumentModel.document_id == document.document_id) + ) + db_document = result.scalar_one_or_none() + if not db_document: + raise NotFoundError(f"Документ {document.document_id} не найден") + + db_document.title = document.title + db_document.content = document.content + db_document.document_metadata = document.metadata + await self.session.commit() + await self.session.refresh(db_document) + return self._to_entity(db_document) + + async def delete(self, document_id: UUID) -> bool: + """Удалить документ""" + result = await self.session.execute( + select(DocumentModel).where(DocumentModel.document_id == document_id) + ) + db_document = result.scalar_one_or_none() + if not db_document: + return False + + await self.session.delete(db_document) + await self.session.commit() + return True + + async def list_by_collection(self, collection_id: UUID, skip: int = 0, limit: int = 100) -> list[Document]: + """Получить документы коллекции""" + result = await self.session.execute( + select(DocumentModel) + .where(DocumentModel.collection_id == collection_id) + .offset(skip) + .limit(limit) + ) + db_documents = result.scalars().all() + return [self._to_entity(db_document) for db_document in db_documents] + + def _to_entity(self, db_document: DocumentModel | None) -> Document | None: + """Преобразовать модель БД в доменную сущность""" + if not db_document: + return None + return Document( + document_id=db_document.document_id, + collection_id=db_document.collection_id, + title=db_document.title, + content=db_document.content, + metadata=db_document.document_metadata or {}, + created_at=db_document.created_at + ) + diff --git a/backend/src/infrastructure/repositories/postgresql/message_repository.py b/backend/src/infrastructure/repositories/postgresql/message_repository.py index a21662b..0189cda 100644 --- a/backend/src/infrastructure/repositories/postgresql/message_repository.py +++ b/backend/src/infrastructure/repositories/postgresql/message_repository.py @@ -1,96 +1,96 @@ -""" -Реализация репозитория сообщений для PostgreSQL -""" -from uuid import UUID -from typing import Optional -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select -from src.domain.entities.message import Message, MessageRole -from src.domain.repositories.message_repository import IMessageRepository -from src.infrastructure.database.models import MessageModel -from src.shared.exceptions import NotFoundError - - -class PostgreSQLMessageRepository(IMessageRepository): - """PostgreSQL реализация репозитория сообщений""" - - def __init__(self, session: AsyncSession): - self.session = session - - async def create(self, message: Message) -> Message: - """Создать сообщение""" - db_message = MessageModel( - message_id=message.message_id, - conversation_id=message.conversation_id, - content=message.content, - role=message.role.value, - sources=message.sources, - created_at=message.created_at - ) - self.session.add(db_message) - await self.session.commit() - await self.session.refresh(db_message) - return self._to_entity(db_message) - - async def get_by_id(self, message_id: UUID) -> Optional[Message]: - """Получить сообщение по ID""" - result = await self.session.execute( - select(MessageModel).where(MessageModel.message_id == message_id) - ) - db_message = result.scalar_one_or_none() - return self._to_entity(db_message) if db_message else None - - async def update(self, message: Message) -> Message: - """Обновить сообщение""" - result = await self.session.execute( - select(MessageModel).where(MessageModel.message_id == message.message_id) - ) - db_message = result.scalar_one_or_none() - if not db_message: - raise NotFoundError(f"Сообщение {message.message_id} не найдено") - - db_message.content = message.content - db_message.role = message.role.value - db_message.sources = message.sources - await self.session.commit() - await self.session.refresh(db_message) - return self._to_entity(db_message) - - async def delete(self, message_id: UUID) -> bool: - """Удалить сообщение""" - result = await self.session.execute( - select(MessageModel).where(MessageModel.message_id == message_id) - ) - db_message = result.scalar_one_or_none() - if not db_message: - return False - - await self.session.delete(db_message) - await self.session.commit() - return True - - async def list_by_conversation(self, conversation_id: UUID, skip: int = 0, limit: int = 100) -> list[Message]: - """Получить сообщения беседы""" - result = await self.session.execute( - select(MessageModel) - .where(MessageModel.conversation_id == conversation_id) - .order_by(MessageModel.created_at) - .offset(skip) - .limit(limit) - ) - db_messages = result.scalars().all() - return [self._to_entity(db_message) for db_message in db_messages] - - def _to_entity(self, db_message: MessageModel | None) -> Message | None: - """Преобразовать модель БД в доменную сущность""" - if not db_message: - return None - return Message( - message_id=db_message.message_id, - conversation_id=db_message.conversation_id, - content=db_message.content, - role=MessageRole(db_message.role), - sources=db_message.sources or {}, - created_at=db_message.created_at - ) - +""" +Реализация репозитория сообщений для PostgreSQL +""" +from uuid import UUID +from typing import Optional +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from src.domain.entities.message import Message, MessageRole +from src.domain.repositories.message_repository import IMessageRepository +from src.infrastructure.database.models import MessageModel +from src.shared.exceptions import NotFoundError + + +class PostgreSQLMessageRepository(IMessageRepository): + """PostgreSQL реализация репозитория сообщений""" + + def __init__(self, session: AsyncSession): + self.session = session + + async def create(self, message: Message) -> Message: + """Создать сообщение""" + db_message = MessageModel( + message_id=message.message_id, + conversation_id=message.conversation_id, + content=message.content, + role=message.role.value, + sources=message.sources, + created_at=message.created_at + ) + self.session.add(db_message) + await self.session.commit() + await self.session.refresh(db_message) + return self._to_entity(db_message) + + async def get_by_id(self, message_id: UUID) -> Optional[Message]: + """Получить сообщение по ID""" + result = await self.session.execute( + select(MessageModel).where(MessageModel.message_id == message_id) + ) + db_message = result.scalar_one_or_none() + return self._to_entity(db_message) if db_message else None + + async def update(self, message: Message) -> Message: + """Обновить сообщение""" + result = await self.session.execute( + select(MessageModel).where(MessageModel.message_id == message.message_id) + ) + db_message = result.scalar_one_or_none() + if not db_message: + raise NotFoundError(f"Сообщение {message.message_id} не найдено") + + db_message.content = message.content + db_message.role = message.role.value + db_message.sources = message.sources + await self.session.commit() + await self.session.refresh(db_message) + return self._to_entity(db_message) + + async def delete(self, message_id: UUID) -> bool: + """Удалить сообщение""" + result = await self.session.execute( + select(MessageModel).where(MessageModel.message_id == message_id) + ) + db_message = result.scalar_one_or_none() + if not db_message: + return False + + await self.session.delete(db_message) + await self.session.commit() + return True + + async def list_by_conversation(self, conversation_id: UUID, skip: int = 0, limit: int = 100) -> list[Message]: + """Получить сообщения беседы""" + result = await self.session.execute( + select(MessageModel) + .where(MessageModel.conversation_id == conversation_id) + .order_by(MessageModel.created_at) + .offset(skip) + .limit(limit) + ) + db_messages = result.scalars().all() + return [self._to_entity(db_message) for db_message in db_messages] + + def _to_entity(self, db_message: MessageModel | None) -> Message | None: + """Преобразовать модель БД в доменную сущность""" + if not db_message: + return None + return Message( + message_id=db_message.message_id, + conversation_id=db_message.conversation_id, + content=db_message.content, + role=MessageRole(db_message.role), + sources=db_message.sources or {}, + created_at=db_message.created_at + ) + diff --git a/backend/src/infrastructure/repositories/postgresql/user_repository.py b/backend/src/infrastructure/repositories/postgresql/user_repository.py index 3ae81d3..d27801c 100644 --- a/backend/src/infrastructure/repositories/postgresql/user_repository.py +++ b/backend/src/infrastructure/repositories/postgresql/user_repository.py @@ -1,95 +1,95 @@ -""" -Реализация репозитория пользователей для PostgreSQL -""" -from uuid import UUID -from typing import Optional -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select -from src.domain.entities.user import User, UserRole -from src.domain.repositories.user_repository import IUserRepository -from src.infrastructure.database.models import UserModel -from src.shared.exceptions import NotFoundError - - -class PostgreSQLUserRepository(IUserRepository): - """PostgreSQL реализация репозитория пользователей""" - - def __init__(self, session: AsyncSession): - self.session = session - - async def create(self, user: User) -> User: - """Создать пользователя""" - db_user = UserModel( - user_id=user.user_id, - telegram_id=user.telegram_id, - role=user.role.value, - created_at=user.created_at - ) - self.session.add(db_user) - await self.session.commit() - await self.session.refresh(db_user) - return self._to_entity(db_user) - - async def get_by_id(self, user_id: UUID) -> Optional[User]: - """Получить пользователя по ID""" - result = await self.session.execute( - select(UserModel).where(UserModel.user_id == user_id) - ) - db_user = result.scalar_one_or_none() - return self._to_entity(db_user) if db_user else None - - async def get_by_telegram_id(self, telegram_id: str) -> Optional[User]: - """Получить пользователя по Telegram ID""" - result = await self.session.execute( - select(UserModel).where(UserModel.telegram_id == telegram_id) - ) - db_user = result.scalar_one_or_none() - return self._to_entity(db_user) if db_user else None - - async def update(self, user: User) -> User: - """Обновить пользователя""" - result = await self.session.execute( - select(UserModel).where(UserModel.user_id == user.user_id) - ) - db_user = result.scalar_one_or_none() - if not db_user: - raise NotFoundError(f"Пользователь {user.user_id} не найден") - - db_user.telegram_id = user.telegram_id - db_user.role = user.role.value - await self.session.commit() - await self.session.refresh(db_user) - return self._to_entity(db_user) - - async def delete(self, user_id: UUID) -> bool: - """Удалить пользователя""" - result = await self.session.execute( - select(UserModel).where(UserModel.user_id == user_id) - ) - db_user = result.scalar_one_or_none() - if not db_user: - return False - - await self.session.delete(db_user) - await self.session.commit() - return True - - async def list_all(self, skip: int = 0, limit: int = 100) -> list[User]: - """Получить список всех пользователей""" - result = await self.session.execute( - select(UserModel).offset(skip).limit(limit) - ) - db_users = result.scalars().all() - return [self._to_entity(db_user) for db_user in db_users] - - def _to_entity(self, db_user: UserModel | None) -> User | None: - """Преобразовать модель БД в доменную сущность""" - if not db_user: - return None - return User( - user_id=db_user.user_id, - telegram_id=db_user.telegram_id, - role=UserRole(db_user.role), - created_at=db_user.created_at - ) - +""" +Реализация репозитория пользователей для PostgreSQL +""" +from uuid import UUID +from typing import Optional +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from src.domain.entities.user import User, UserRole +from src.domain.repositories.user_repository import IUserRepository +from src.infrastructure.database.models import UserModel +from src.shared.exceptions import NotFoundError + + +class PostgreSQLUserRepository(IUserRepository): + """PostgreSQL реализация репозитория пользователей""" + + def __init__(self, session: AsyncSession): + self.session = session + + async def create(self, user: User) -> User: + """Создать пользователя""" + db_user = UserModel( + user_id=user.user_id, + telegram_id=user.telegram_id, + role=user.role.value, + created_at=user.created_at + ) + self.session.add(db_user) + await self.session.commit() + await self.session.refresh(db_user) + return self._to_entity(db_user) + + async def get_by_id(self, user_id: UUID) -> Optional[User]: + """Получить пользователя по ID""" + result = await self.session.execute( + select(UserModel).where(UserModel.user_id == user_id) + ) + db_user = result.scalar_one_or_none() + return self._to_entity(db_user) if db_user else None + + async def get_by_telegram_id(self, telegram_id: str) -> Optional[User]: + """Получить пользователя по Telegram ID""" + result = await self.session.execute( + select(UserModel).where(UserModel.telegram_id == telegram_id) + ) + db_user = result.scalar_one_or_none() + return self._to_entity(db_user) if db_user else None + + async def update(self, user: User) -> User: + """Обновить пользователя""" + result = await self.session.execute( + select(UserModel).where(UserModel.user_id == user.user_id) + ) + db_user = result.scalar_one_or_none() + if not db_user: + raise NotFoundError(f"Пользователь {user.user_id} не найден") + + db_user.telegram_id = user.telegram_id + db_user.role = user.role.value + await self.session.commit() + await self.session.refresh(db_user) + return self._to_entity(db_user) + + async def delete(self, user_id: UUID) -> bool: + """Удалить пользователя""" + result = await self.session.execute( + select(UserModel).where(UserModel.user_id == user_id) + ) + db_user = result.scalar_one_or_none() + if not db_user: + return False + + await self.session.delete(db_user) + await self.session.commit() + return True + + async def list_all(self, skip: int = 0, limit: int = 100) -> list[User]: + """Получить список всех пользователей""" + result = await self.session.execute( + select(UserModel).offset(skip).limit(limit) + ) + db_users = result.scalars().all() + return [self._to_entity(db_user) for db_user in db_users] + + def _to_entity(self, db_user: UserModel | None) -> User | None: + """Преобразовать модель БД в доменную сущность""" + if not db_user: + return None + return User( + user_id=db_user.user_id, + telegram_id=db_user.telegram_id, + role=UserRole(db_user.role), + created_at=db_user.created_at + ) + diff --git a/backend/src/presentation/__init__.py b/backend/src/presentation/__init__.py index a6a752d..8a4781c 100644 --- a/backend/src/presentation/__init__.py +++ b/backend/src/presentation/__init__.py @@ -1,4 +1,4 @@ -""" -Presentation layer -""" - +""" +Presentation layer +""" + diff --git a/backend/src/presentation/api/v1/__init__.py b/backend/src/presentation/api/v1/__init__.py new file mode 100644 index 0000000..ec4c3fa --- /dev/null +++ b/backend/src/presentation/api/v1/__init__.py @@ -0,0 +1,4 @@ +""" +API v1 роутеры +""" + diff --git a/backend/src/presentation/api/v1/admin.py b/backend/src/presentation/api/v1/admin.py new file mode 100644 index 0000000..5151778 --- /dev/null +++ b/backend/src/presentation/api/v1/admin.py @@ -0,0 +1,56 @@ +""" +Админ-панель - упрощенная версия через API эндпоинты +В будущем можно интегрировать полноценную админ-панель +""" +from fastapi import APIRouter, HTTPException +from typing import List +from uuid import UUID +from dishka.integrations.fastapi import FromDishka +from src.presentation.schemas.user_schemas import UserResponse +from src.presentation.schemas.collection_schemas import CollectionResponse +from src.presentation.schemas.document_schemas import DocumentResponse +from src.presentation.schemas.conversation_schemas import ConversationResponse +from src.presentation.schemas.message_schemas import MessageResponse +from src.domain.entities.user import User, UserRole +from src.application.use_cases.user_use_cases import UserUseCases +from src.application.use_cases.collection_use_cases import CollectionUseCases + +router = APIRouter(prefix="/admin", tags=["admin"]) + + +@router.get("/users", response_model=List[UserResponse]) +async def admin_list_users( + skip: int = 0, + limit: int = 100, + current_user: FromDishka[User] = FromDishka(), + use_cases: FromDishka[UserUseCases] = FromDishka() +): + """Получить список всех пользователей (только для админов)""" + if not current_user.is_admin(): + raise HTTPException(status_code=403, detail="Требуются права администратора") + users = await use_cases.list_users(skip=skip, limit=limit) + return [UserResponse.from_entity(user) for user in users] + + +@router.get("/collections", response_model=List[CollectionResponse]) +async def admin_list_collections( + skip: int = 0, + limit: int = 100, + current_user: FromDishka[User] = FromDishka(), + use_cases: FromDishka[CollectionUseCases] = FromDishka() +): + """Получить список всех коллекций (только для админов)""" + from src.infrastructure.database.base import AsyncSessionLocal + from src.infrastructure.repositories.postgresql.collection_repository import PostgreSQLCollectionRepository + from sqlalchemy import select + from src.infrastructure.database.models import CollectionModel + + async with AsyncSessionLocal() as session: + repo = PostgreSQLCollectionRepository(session) + result = await session.execute( + select(CollectionModel).offset(skip).limit(limit) + ) + db_collections = result.scalars().all() + collections = [repo._to_entity(c) for c in db_collections if c] + return [CollectionResponse.from_entity(c) for c in collections if c] + diff --git a/backend/src/presentation/api/v1/collections.py b/backend/src/presentation/api/v1/collections.py new file mode 100644 index 0000000..38b4bc0 --- /dev/null +++ b/backend/src/presentation/api/v1/collections.py @@ -0,0 +1,120 @@ +""" +API роутеры для работы с коллекциями +""" +from uuid import UUID +from fastapi import APIRouter, status +from fastapi.responses import JSONResponse +from typing import List +from dishka.integrations.fastapi import FromDishka +from src.presentation.schemas.collection_schemas import ( + CollectionCreate, + CollectionUpdate, + CollectionResponse, + CollectionAccessGrant, + CollectionAccessResponse +) +from src.application.use_cases.collection_use_cases import CollectionUseCases +from src.domain.entities.user import User +from src.presentation.middleware.auth_middleware import get_current_user + +router = APIRouter(prefix="/collections", tags=["collections"]) + + +@router.post("", response_model=CollectionResponse, status_code=status.HTTP_201_CREATED) +async def create_collection( + collection_data: CollectionCreate, + current_user: User = FromDishka(), + use_cases: FromDishka[CollectionUseCases] = FromDishka() +): + """Создать коллекцию""" + collection = await use_cases.create_collection( + name=collection_data.name, + owner_id=current_user.user_id, + description=collection_data.description, + is_public=collection_data.is_public + ) + return CollectionResponse.from_entity(collection) + + +@router.get("/{collection_id}", response_model=CollectionResponse) +async def get_collection( + collection_id: UUID, + use_cases: FromDishka[CollectionUseCases] = FromDishka() +): + """Получить коллекцию по ID""" + collection = await use_cases.get_collection(collection_id) + return CollectionResponse.from_entity(collection) + + +@router.put("/{collection_id}", response_model=CollectionResponse) +async def update_collection( + collection_id: UUID, + collection_data: CollectionUpdate, + current_user: User = FromDishka(), + use_cases: FromDishka[CollectionUseCases] = FromDishka() +): + """Обновить коллекцию""" + collection = await use_cases.update_collection( + collection_id=collection_id, + user_id=current_user.user_id, + name=collection_data.name, + description=collection_data.description, + is_public=collection_data.is_public + ) + return CollectionResponse.from_entity(collection) + + +@router.delete("/{collection_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_collection( + collection_id: UUID, + current_user: User = FromDishka(), + use_cases: FromDishka[CollectionUseCases] = FromDishka() +): + """Удалить коллекцию""" + await use_cases.delete_collection(collection_id, current_user.user_id) + return JSONResponse(status_code=status.HTTP_204_NO_CONTENT, content=None) + + +@router.get("", response_model=List[CollectionResponse]) +async def list_collections( + skip: int = 0, + limit: int = 100, + current_user: User = FromDishka(), + use_cases: FromDishka[CollectionUseCases] = FromDishka() +): + """Получить список коллекций, доступных пользователю""" + collections = await use_cases.list_user_collections( + user_id=current_user.user_id, + skip=skip, + limit=limit + ) + return [CollectionResponse.from_entity(c) for c in collections] + + +@router.post("/{collection_id}/access", response_model=CollectionAccessResponse, status_code=status.HTTP_201_CREATED) +async def grant_access( + collection_id: UUID, + access_data: CollectionAccessGrant, + current_user: User = FromDishka(), + use_cases: FromDishka[CollectionUseCases] = FromDishka() +): + """Предоставить доступ пользователю к коллекции""" + access = await use_cases.grant_access( + collection_id=collection_id, + user_id=access_data.user_id, + owner_id=current_user.user_id + ) + return CollectionAccessResponse.from_entity(access) + + +@router.delete("/{collection_id}/access/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +async def revoke_access( + collection_id: UUID, + user_id: UUID, + current_user: User = FromDishka(), + use_cases: FromDishka[CollectionUseCases] = FromDishka() +): + """Отозвать доступ пользователя к коллекции""" + await use_cases.revoke_access(collection_id, user_id, current_user.user_id) + return JSONResponse(status_code=status.HTTP_204_NO_CONTENT, content=None) + diff --git a/backend/src/presentation/api/v1/conversations.py b/backend/src/presentation/api/v1/conversations.py new file mode 100644 index 0000000..fe0fdfb --- /dev/null +++ b/backend/src/presentation/api/v1/conversations.py @@ -0,0 +1,69 @@ +""" +API роутеры для работы с беседами +""" +from uuid import UUID +from fastapi import APIRouter, status +from fastapi.responses import JSONResponse +from typing import List +from dishka.integrations.fastapi import FromDishka +from src.presentation.schemas.conversation_schemas import ( + ConversationCreate, + ConversationResponse +) +from src.application.use_cases.conversation_use_cases import ConversationUseCases +from src.domain.entities.user import User + +router = APIRouter(prefix="/conversations", tags=["conversations"]) + + +@router.post("", response_model=ConversationResponse, status_code=status.HTTP_201_CREATED) +async def create_conversation( + conversation_data: ConversationCreate, + current_user: FromDishka[User] = FromDishka(), + use_cases: FromDishka[ConversationUseCases] = FromDishka() +): + """Создать беседу""" + conversation = await use_cases.create_conversation( + user_id=current_user.user_id, + collection_id=conversation_data.collection_id + ) + return ConversationResponse.from_entity(conversation) + + +@router.get("/{conversation_id}", response_model=ConversationResponse) +async def get_conversation( + conversation_id: UUID, + current_user: FromDishka[User] = FromDishka(), + use_cases: FromDishka[ConversationUseCases] = FromDishka() +): + """Получить беседу по ID""" + conversation = await use_cases.get_conversation(conversation_id, current_user.user_id) + return ConversationResponse.from_entity(conversation) + + +@router.delete("/{conversation_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_conversation( + conversation_id: UUID, + current_user: FromDishka[User] = FromDishka(), + use_cases: FromDishka[ConversationUseCases] = FromDishka() +): + """Удалить беседу""" + await use_cases.delete_conversation(conversation_id, current_user.user_id) + return JSONResponse(status_code=status.HTTP_204_NO_CONTENT, content=None) + + +@router.get("", response_model=List[ConversationResponse]) +async def list_conversations( + skip: int = 0, + limit: int = 100, + current_user: FromDishka[User] = FromDishka(), + use_cases: FromDishka[ConversationUseCases] = FromDishka() +): + """Получить список бесед пользователя""" + conversations = await use_cases.list_user_conversations( + user_id=current_user.user_id, + skip=skip, + limit=limit + ) + return [ConversationResponse.from_entity(c) for c in conversations] + diff --git a/backend/src/presentation/api/v1/documents.py b/backend/src/presentation/api/v1/documents.py new file mode 100644 index 0000000..60cc0ea --- /dev/null +++ b/backend/src/presentation/api/v1/documents.py @@ -0,0 +1,121 @@ +""" +API роутеры для работы с документами +""" +from uuid import UUID +from fastapi import APIRouter, status, UploadFile, File +from fastapi.responses import JSONResponse +from typing import List +from dishka.integrations.fastapi import FromDishka +from src.presentation.schemas.document_schemas import ( + DocumentCreate, + DocumentUpdate, + DocumentResponse +) +from src.application.use_cases.document_use_cases import DocumentUseCases +from src.domain.entities.user import User + +router = APIRouter(prefix="/documents", tags=["documents"]) + + +@router.post("", response_model=DocumentResponse, status_code=status.HTTP_201_CREATED) +async def create_document( + document_data: DocumentCreate, + current_user: FromDishka[User] = FromDishka(), + use_cases: FromDishka[DocumentUseCases] = FromDishka() +): + """Создать документ""" + document = await use_cases.create_document( + collection_id=document_data.collection_id, + title=document_data.title, + content=document_data.content, + metadata=document_data.metadata + ) + return DocumentResponse.from_entity(document) + + +@router.post("/upload", response_model=DocumentResponse, status_code=status.HTTP_201_CREATED) +async def upload_document( + collection_id: UUID, + file: UploadFile = File(...), + current_user: FromDishka[User] = FromDishka(), + use_cases: FromDishka[DocumentUseCases] = FromDishka() +): + """Загрузить и распарсить PDF документ или изображение""" + if not file.filename: + raise JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={"detail": "Имя файла не указано"} + ) + + supported_formats = ['.pdf', '.png', '.jpg', '.jpeg', '.tiff', '.bmp'] + file_ext = file.filename.lower().rsplit('.', 1)[-1] if '.' in file.filename else '' + + if f'.{file_ext}' not in supported_formats: + raise JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={"detail": f"Неподдерживаемый формат файла. Поддерживаются: {', '.join(supported_formats)}"} + ) + + document = await use_cases.upload_and_parse_document( + collection_id=collection_id, + file=file.file, + filename=file.filename, + user_id=current_user.user_id + ) + return DocumentResponse.from_entity(document) + + +@router.get("/{document_id}", response_model=DocumentResponse) +async def get_document( + document_id: UUID, + use_cases: FromDishka[DocumentUseCases] = FromDishka() +): + """Получить документ по ID""" + document = await use_cases.get_document(document_id) + return DocumentResponse.from_entity(document) + + +@router.put("/{document_id}", response_model=DocumentResponse) +async def update_document( + document_id: UUID, + document_data: DocumentUpdate, + current_user: FromDishka[User] = FromDishka(), + use_cases: FromDishka[DocumentUseCases] = FromDishka() +): + """Обновить документ""" + document = await use_cases.update_document( + document_id=document_id, + user_id=current_user.user_id, + title=document_data.title, + content=document_data.content, + metadata=document_data.metadata + ) + return DocumentResponse.from_entity(document) + + +@router.delete("/{document_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_document( + document_id: UUID, + current_user: FromDishka[User] = FromDishka(), + use_cases: FromDishka[DocumentUseCases] = FromDishka() +): + """Удалить документ""" + await use_cases.delete_document(document_id, current_user.user_id) + return JSONResponse(status_code=status.HTTP_204_NO_CONTENT, content=None) + + +@router.get("/collection/{collection_id}", response_model=List[DocumentResponse]) +async def list_collection_documents( + collection_id: UUID, + skip: int = 0, + limit: int = 100, + use_cases: FromDishka[DocumentUseCases] = FromDishka() +): + """Получить документы коллекции""" + documents = await use_cases.list_collection_documents( + collection_id=collection_id, + skip=skip, + limit=limit + ) + return [DocumentResponse.from_entity(d) for d in documents] + diff --git a/backend/src/presentation/api/v1/messages.py b/backend/src/presentation/api/v1/messages.py new file mode 100644 index 0000000..e0757a0 --- /dev/null +++ b/backend/src/presentation/api/v1/messages.py @@ -0,0 +1,88 @@ +""" +API роутеры для работы с сообщениями +""" +from uuid import UUID +from fastapi import APIRouter, status +from fastapi.responses import JSONResponse +from typing import List +from dishka.integrations.fastapi import FromDishka +from src.presentation.schemas.message_schemas import ( + MessageCreate, + MessageUpdate, + MessageResponse +) +from src.application.use_cases.message_use_cases import MessageUseCases +from src.domain.entities.user import User + +router = APIRouter(prefix="/messages", tags=["messages"]) + + +@router.post("", response_model=MessageResponse, status_code=status.HTTP_201_CREATED) +async def create_message( + message_data: MessageCreate, + current_user: FromDishka[User] = FromDishka(), + use_cases: FromDishka[MessageUseCases] = FromDishka() +): + """Создать сообщение""" + message = await use_cases.create_message( + conversation_id=message_data.conversation_id, + content=message_data.content, + role=message_data.role, + user_id=current_user.user_id, + sources=message_data.sources + ) + return MessageResponse.from_entity(message) + + +@router.get("/{message_id}", response_model=MessageResponse) +async def get_message( + message_id: UUID, + use_cases: FromDishka[MessageUseCases] = FromDishka() +): + """Получить сообщение по ID""" + message = await use_cases.get_message(message_id) + return MessageResponse.from_entity(message) + + +@router.put("/{message_id}", response_model=MessageResponse) +async def update_message( + message_id: UUID, + message_data: MessageUpdate, + use_cases: FromDishka[MessageUseCases] = FromDishka() +): + """Обновить сообщение""" + message = await use_cases.update_message( + message_id=message_id, + content=message_data.content, + sources=message_data.sources + ) + return MessageResponse.from_entity(message) + + +@router.delete("/{message_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_message( + message_id: UUID, + use_cases: FromDishka[MessageUseCases] = FromDishka() +): + """Удалить сообщение""" + await use_cases.delete_message(message_id) + return JSONResponse(status_code=status.HTTP_204_NO_CONTENT, content=None) + + +@router.get("/conversation/{conversation_id}", response_model=List[MessageResponse]) +async def list_conversation_messages( + conversation_id: UUID, + skip: int = 0, + limit: int = 100, + current_user: FromDishka[User] = FromDishka(), + use_cases: FromDishka[MessageUseCases] = FromDishka() +): + """Получить сообщения беседы""" + messages = await use_cases.list_conversation_messages( + conversation_id=conversation_id, + user_id=current_user.user_id, + skip=skip, + limit=limit + ) + return [MessageResponse.from_entity(m) for m in messages] + diff --git a/backend/src/presentation/api/v1/users.py b/backend/src/presentation/api/v1/users.py new file mode 100644 index 0000000..0121328 --- /dev/null +++ b/backend/src/presentation/api/v1/users.py @@ -0,0 +1,81 @@ +""" +API роутеры для работы с пользователями +""" +from uuid import UUID +from fastapi import APIRouter, status +from fastapi.responses import JSONResponse +from typing import List +from dishka.integrations.fastapi import FromDishka +from src.presentation.schemas.user_schemas import UserCreate, UserUpdate, UserResponse +from src.application.use_cases.user_use_cases import UserUseCases +from src.domain.entities.user import User + +router = APIRouter(prefix="/users", tags=["users"]) + + +@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED) +async def create_user( + user_data: UserCreate, + use_cases: FromDishka[UserUseCases] = FromDishka() +): + """Создать пользователя""" + user = await use_cases.create_user( + telegram_id=user_data.telegram_id, + role=user_data.role + ) + return UserResponse.from_entity(user) + + +@router.get("/me", response_model=UserResponse) +async def get_current_user_info( + current_user: FromDishka[User] = FromDishka() +): + """Получить информацию о текущем пользователе""" + return UserResponse.from_entity(current_user) + + +@router.get("/{user_id}", response_model=UserResponse) +async def get_user( + user_id: UUID, + use_cases: FromDishka[UserUseCases] = FromDishka() +): + """Получить пользователя по ID""" + user = await use_cases.get_user(user_id) + return UserResponse.from_entity(user) + + +@router.put("/{user_id}", response_model=UserResponse) +async def update_user( + user_id: UUID, + user_data: UserUpdate, + use_cases: FromDishka[UserUseCases] = FromDishka() +): + """Обновить пользователя""" + user = await use_cases.update_user( + user_id=user_id, + telegram_id=user_data.telegram_id, + role=user_data.role + ) + return UserResponse.from_entity(user) + + +@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_user( + user_id: UUID, + use_cases: FromDishka[UserUseCases] = FromDishka() +): + """Удалить пользователя""" + await use_cases.delete_user(user_id) + return JSONResponse(status_code=status.HTTP_204_NO_CONTENT, content=None) + + +@router.get("", response_model=List[UserResponse]) +async def list_users( + skip: int = 0, + limit: int = 100, + use_cases: FromDishka[UserUseCases] = FromDishka() +): + """Получить список пользователей""" + users = await use_cases.list_users(skip=skip, limit=limit) + return [UserResponse.from_entity(user) for user in users] + diff --git a/backend/src/presentation/schemas/__init__.py b/backend/src/presentation/schemas/__init__.py new file mode 100644 index 0000000..ff9ce4f --- /dev/null +++ b/backend/src/presentation/schemas/__init__.py @@ -0,0 +1,4 @@ +""" +Pydantic schemas +""" + diff --git a/backend/src/presentation/schemas/collection_schemas.py b/backend/src/presentation/schemas/collection_schemas.py new file mode 100644 index 0000000..8848fd2 --- /dev/null +++ b/backend/src/presentation/schemas/collection_schemas.py @@ -0,0 +1,77 @@ +""" +Pydantic схемы для Collection +""" +from uuid import UUID +from datetime import datetime +from pydantic import BaseModel + + +class CollectionBase(BaseModel): + """Базовая схема коллекции""" + name: str + description: str = "" + is_public: bool = False + + +class CollectionCreate(CollectionBase): + """Схема создания коллекции""" + pass + + +class CollectionUpdate(BaseModel): + """Схема обновления коллекции""" + name: str | None = None + description: str | None = None + is_public: bool | None = None + + +class CollectionResponse(BaseModel): + """Схема ответа с коллекцией""" + collection_id: UUID + name: str + description: str + owner_id: UUID + is_public: bool + created_at: datetime + + @classmethod + def from_entity(cls, collection: "Collection") -> "CollectionResponse": + """Создать из доменной сущности""" + return cls( + collection_id=collection.collection_id, + name=collection.name, + description=collection.description, + owner_id=collection.owner_id, + is_public=collection.is_public, + created_at=collection.created_at + ) + + class Config: + from_attributes = True + + +class CollectionAccessGrant(BaseModel): + """Схема предоставления доступа""" + user_id: UUID + + +class CollectionAccessResponse(BaseModel): + """Схема ответа с доступом""" + access_id: UUID + user_id: UUID + collection_id: UUID + created_at: datetime + + @classmethod + def from_entity(cls, access: "CollectionAccess") -> "CollectionAccessResponse": + """Создать из доменной сущности""" + return cls( + access_id=access.access_id, + user_id=access.user_id, + collection_id=access.collection_id, + created_at=access.created_at + ) + + class Config: + from_attributes = True + diff --git a/backend/src/presentation/schemas/conversation_schemas.py b/backend/src/presentation/schemas/conversation_schemas.py new file mode 100644 index 0000000..22aeb83 --- /dev/null +++ b/backend/src/presentation/schemas/conversation_schemas.py @@ -0,0 +1,35 @@ +""" +Pydantic схемы для Conversation +""" +from uuid import UUID +from datetime import datetime +from pydantic import BaseModel + + +class ConversationCreate(BaseModel): + """Схема создания беседы""" + collection_id: UUID + + +class ConversationResponse(BaseModel): + """Схема ответа с беседой""" + conversation_id: UUID + user_id: UUID + collection_id: UUID + created_at: datetime + updated_at: datetime + + @classmethod + def from_entity(cls, conversation: "Conversation") -> "ConversationResponse": + """Создать из доменной сущности""" + return cls( + conversation_id=conversation.conversation_id, + user_id=conversation.user_id, + collection_id=conversation.collection_id, + created_at=conversation.created_at, + updated_at=conversation.updated_at + ) + + class Config: + from_attributes = True + diff --git a/backend/src/presentation/schemas/document_schemas.py b/backend/src/presentation/schemas/document_schemas.py new file mode 100644 index 0000000..8660e5c --- /dev/null +++ b/backend/src/presentation/schemas/document_schemas.py @@ -0,0 +1,52 @@ +""" +Pydantic схемы для Document +""" +from uuid import UUID +from datetime import datetime +from typing import Any +from pydantic import BaseModel + + +class DocumentBase(BaseModel): + """Базовая схема документа""" + title: str + content: str + metadata: dict[str, Any] = {} + + +class DocumentCreate(DocumentBase): + """Схема создания документа""" + collection_id: UUID + + +class DocumentUpdate(BaseModel): + """Схема обновления документа""" + title: str | None = None + content: str | None = None + metadata: dict[str, Any] | None = None + + +class DocumentResponse(BaseModel): + """Схема ответа с документом""" + document_id: UUID + collection_id: UUID + title: str + content: str + metadata: dict[str, Any] + created_at: datetime + + @classmethod + def from_entity(cls, document: "Document") -> "DocumentResponse": + """Создать из доменной сущности""" + return cls( + document_id=document.document_id, + collection_id=document.collection_id, + title=document.title, + content=document.content, + metadata=document.metadata, + created_at=document.created_at + ) + + class Config: + from_attributes = True + diff --git a/backend/src/presentation/schemas/message_schemas.py b/backend/src/presentation/schemas/message_schemas.py new file mode 100644 index 0000000..2f346e8 --- /dev/null +++ b/backend/src/presentation/schemas/message_schemas.py @@ -0,0 +1,52 @@ +""" +Pydantic схемы для Message +""" +from uuid import UUID +from datetime import datetime +from typing import Any +from pydantic import BaseModel +from src.domain.entities.message import MessageRole + + +class MessageBase(BaseModel): + """Базовая схема сообщения""" + content: str + role: MessageRole + sources: dict[str, Any] = {} + + +class MessageCreate(MessageBase): + """Схема создания сообщения""" + conversation_id: UUID + + +class MessageUpdate(BaseModel): + """Схема обновления сообщения""" + content: str | None = None + sources: dict[str, Any] | None = None + + +class MessageResponse(BaseModel): + """Схема ответа с сообщением""" + message_id: UUID + conversation_id: UUID + content: str + role: MessageRole + sources: dict[str, Any] + created_at: datetime + + @classmethod + def from_entity(cls, message: "Message") -> "MessageResponse": + """Создать из доменной сущности""" + return cls( + message_id=message.message_id, + conversation_id=message.conversation_id, + content=message.content, + role=message.role, + sources=message.sources, + created_at=message.created_at + ) + + class Config: + from_attributes = True + diff --git a/backend/src/presentation/schemas/user_schemas.py b/backend/src/presentation/schemas/user_schemas.py new file mode 100644 index 0000000..16a14f0 --- /dev/null +++ b/backend/src/presentation/schemas/user_schemas.py @@ -0,0 +1,46 @@ +""" +Pydantic схемы для User +""" +from uuid import UUID +from datetime import datetime +from pydantic import BaseModel +from src.domain.entities.user import UserRole + + +class UserBase(BaseModel): + """Базовая схема пользователя""" + telegram_id: str + role: UserRole + + +class UserCreate(UserBase): + """Схема создания пользователя""" + pass + + +class UserUpdate(BaseModel): + """Схема обновления пользователя""" + telegram_id: str | None = None + role: UserRole | None = None + + +class UserResponse(BaseModel): + """Схема ответа с пользователем""" + user_id: UUID + telegram_id: str + role: UserRole + created_at: datetime + + @classmethod + def from_entity(cls, user: "User") -> "UserResponse": + """Создать из доменной сущности""" + return cls( + user_id=user.user_id, + telegram_id=user.telegram_id, + role=user.role, + created_at=user.created_at + ) + + class Config: + from_attributes = True + diff --git a/backend/src/shared/__init__.py b/backend/src/shared/__init__.py index a4fb19c..1757ec5 100644 --- a/backend/src/shared/__init__.py +++ b/backend/src/shared/__init__.py @@ -1,4 +1,4 @@ -""" -Shared utilities -""" - +""" +Shared utilities +""" + diff --git a/backend/src/shared/config.py b/backend/src/shared/config.py index 0486964..f60b4e7 100644 --- a/backend/src/shared/config.py +++ b/backend/src/shared/config.py @@ -1,48 +1,48 @@ -""" -Конфигурация приложения - -""" -from pydantic_settings import BaseSettings -from typing import Optional - - -class Settings(BaseSettings): - """Настройки (загружаются из .env автоматически)""" - - POSTGRES_HOST: str = "localhost" - POSTGRES_PORT: int = 5432 - POSTGRES_USER: str = "postgres" - POSTGRES_PASSWORD: str = "postgres" - POSTGRES_DB: str = "lawyer_ai" - - QDRANT_HOST: str = "localhost" - QDRANT_PORT: int = 6333 - - REDIS_HOST: str = "localhost" - REDIS_PORT: int = 6379 - - TELEGRAM_BOT_TOKEN: Optional[str] = None - YANDEX_OCR_API_KEY: Optional[str] = None - DEEPSEEK_API_KEY: Optional[str] = None - - YANDEX_OCR_API_URL: str = "https://vision.api.cloud.yandex.net/vision/v1/batchAnalyze" - DEEPSEEK_API_URL: str = "https://api.deepseek.com/v1/chat/completions" - - APP_NAME: str = "ИИ-юрист" - DEBUG: bool = False - SECRET_KEY: str = "your-secret-key-change-in-production" - CORS_ORIGINS: list[str] = ["*"] - - @property - def database_url(self) -> str: - """Вычисляемый URL подключения""" - return f"postgresql://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}" - - class Config: - env_file = ".env" - case_sensitive = True - - -settings = Settings() - - +""" +Конфигурация приложения + +""" +from pydantic_settings import BaseSettings +from typing import Optional + + +class Settings(BaseSettings): + """Настройки (загружаются из .env автоматически)""" + + POSTGRES_HOST: str = "localhost" + POSTGRES_PORT: int = 5432 + POSTGRES_USER: str = "postgres" + POSTGRES_PASSWORD: str = "postgres" + POSTGRES_DB: str = "lawyer_ai" + + QDRANT_HOST: str = "localhost" + QDRANT_PORT: int = 6333 + + REDIS_HOST: str = "localhost" + REDIS_PORT: int = 6379 + + TELEGRAM_BOT_TOKEN: Optional[str] = None + YANDEX_OCR_API_KEY: Optional[str] = None + DEEPSEEK_API_KEY: Optional[str] = None + + YANDEX_OCR_API_URL: str = "https://vision.api.cloud.yandex.net/vision/v1/batchAnalyze" + DEEPSEEK_API_URL: str = "https://api.deepseek.com/v1/chat/completions" + + APP_NAME: str = "ИИ-юрист" + DEBUG: bool = False + SECRET_KEY: str = "your-secret-key-change-in-production" + CORS_ORIGINS: list[str] = ["*"] + + @property + def database_url(self) -> str: + """Вычисляемый URL подключения""" + return f"postgresql://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}" + + class Config: + env_file = ".env" + case_sensitive = True + + +settings = Settings() + + diff --git a/backend/src/shared/exceptions.py b/backend/src/shared/exceptions.py index 10162c0..e8ff058 100644 --- a/backend/src/shared/exceptions.py +++ b/backend/src/shared/exceptions.py @@ -1,35 +1,35 @@ -""" -Кастомные исключения приложения -""" - - -class LawyerAIException(Exception): - """Базовое исключение приложения""" - pass - - -class NotFoundError(LawyerAIException): - """Ресурс не найден""" - pass - - -class UnauthorizedError(LawyerAIException): - """Пользователь не авторизован""" - pass - - -class ForbiddenError(LawyerAIException): - """Доступ запрещен""" - pass - - -class ValidationError(LawyerAIException): - """Ошибка валидации данных""" - pass - - -class DatabaseError(LawyerAIException): - """Ошибка базы данных""" - pass - - +""" +Кастомные исключения приложения +""" + + +class LawyerAIException(Exception): + """Базовое исключение приложения""" + pass + + +class NotFoundError(LawyerAIException): + """Ресурс не найден""" + pass + + +class UnauthorizedError(LawyerAIException): + """Пользователь не авторизован""" + pass + + +class ForbiddenError(LawyerAIException): + """Доступ запрещен""" + pass + + +class ValidationError(LawyerAIException): + """Ошибка валидации данных""" + pass + + +class DatabaseError(LawyerAIException): + """Ошибка базы данных""" + pass + +