infrastructure + deepseek+ocr

This commit is contained in:
bokho 2025-12-14 19:23:13 +03:00
parent ae252a796c
commit 4a043f8e70
11 changed files with 1149 additions and 0 deletions

View File

@ -0,0 +1,4 @@
"""
External services
"""

View File

@ -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

View File

@ -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
}

View File

@ -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

View File

@ -0,0 +1,4 @@
"""
PostgreSQL repository implementations
"""

View File

@ -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
)

View File

@ -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
)

View File

@ -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
)

View File

@ -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
)

View File

@ -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
)

View File

@ -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
)