diff --git a/backend/src/infrastructure/external/__init__.py b/backend/src/infrastructure/external/__init__.py new file mode 100644 index 0000000..6911390 --- /dev/null +++ b/backend/src/infrastructure/external/__init__.py @@ -0,0 +1,4 @@ +""" +External services +""" + diff --git a/backend/src/infrastructure/external/deepseek_client.py b/backend/src/infrastructure/external/deepseek_client.py new file mode 100644 index 0000000..277bc33 --- /dev/null +++ b/backend/src/infrastructure/external/deepseek_client.py @@ -0,0 +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 + diff --git a/backend/src/infrastructure/external/telegram_auth.py b/backend/src/infrastructure/external/telegram_auth.py new file mode 100644 index 0000000..c09b37b --- /dev/null +++ b/backend/src/infrastructure/external/telegram_auth.py @@ -0,0 +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 + } + diff --git a/backend/src/infrastructure/external/yandex_ocr.py b/backend/src/infrastructure/external/yandex_ocr.py new file mode 100644 index 0000000..4db4ddb --- /dev/null +++ b/backend/src/infrastructure/external/yandex_ocr.py @@ -0,0 +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 + diff --git a/backend/src/infrastructure/repositories/postgresql/__init__.py b/backend/src/infrastructure/repositories/postgresql/__init__.py new file mode 100644 index 0000000..794676e --- /dev/null +++ b/backend/src/infrastructure/repositories/postgresql/__init__.py @@ -0,0 +1,4 @@ +""" +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 new file mode 100644 index 0000000..eb0acc6 --- /dev/null +++ b/backend/src/infrastructure/repositories/postgresql/collection_access_repository.py @@ -0,0 +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 + ) + diff --git a/backend/src/infrastructure/repositories/postgresql/collection_repository.py b/backend/src/infrastructure/repositories/postgresql/collection_repository.py new file mode 100644 index 0000000..402fcca --- /dev/null +++ b/backend/src/infrastructure/repositories/postgresql/collection_repository.py @@ -0,0 +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 + ) + diff --git a/backend/src/infrastructure/repositories/postgresql/conversation_repository.py b/backend/src/infrastructure/repositories/postgresql/conversation_repository.py new file mode 100644 index 0000000..7c3e923 --- /dev/null +++ b/backend/src/infrastructure/repositories/postgresql/conversation_repository.py @@ -0,0 +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 + ) + diff --git a/backend/src/infrastructure/repositories/postgresql/document_repository.py b/backend/src/infrastructure/repositories/postgresql/document_repository.py new file mode 100644 index 0000000..e435739 --- /dev/null +++ b/backend/src/infrastructure/repositories/postgresql/document_repository.py @@ -0,0 +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 + ) + diff --git a/backend/src/infrastructure/repositories/postgresql/message_repository.py b/backend/src/infrastructure/repositories/postgresql/message_repository.py new file mode 100644 index 0000000..a21662b --- /dev/null +++ b/backend/src/infrastructure/repositories/postgresql/message_repository.py @@ -0,0 +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 + ) + diff --git a/backend/src/infrastructure/repositories/postgresql/user_repository.py b/backend/src/infrastructure/repositories/postgresql/user_repository.py new file mode 100644 index 0000000..3ae81d3 --- /dev/null +++ b/backend/src/infrastructure/repositories/postgresql/user_repository.py @@ -0,0 +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 + ) +