/
alexefan136
/
flowstack
Обзор
Документация
Войти
/
alexefan136
/
flowstack
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
core/engine/src/api/chats.py
1 168 строк
37 KB
Alexander Efanov
Обновление репозитория
15 июл 2026, 12:19
15 июл 2026, 12:19
76704c6
Код
Авторство
О чём код?
"""API endpoints для чатов с полной интеграцией LLM и RAG. Предоставляет: - CRUD операции для чатов - Управление сообщениями - Streaming ответов от LLM через SSE - Интеграцию с RAG (Retrieval-Augmented Generation) - Статистику и аналитику по чатам - Экспорт и поиск по истории Эндпоинты: - POST /api/v1/chats — создать чат - GET /api/v1/chats — список чатов - GET /api/v1/chats/{id} — получить чат с историей - PATCH /api/v1/chats/{id} — обновить чат - DELETE /api/v1/chats/{id} — удалить чат - POST /api/v1/chats/{id}/messages — добавить сообщение - GET /api/v1/chats/{id}/messages — список сообщений - POST /api/v1/chats/{id}/send — отправить и получить ответ (stream/sync) - GET /api/v1/chats/{id}/stats — статистика чата - POST /api/v1/chats/{id}/clear — очистить историю - GET /api/v1/chats/{id}/export — экспорт чата - POST /api/v1/chats/{id}/search — поиск по истории """ from __future__ import annotations import json import uuid from collections.abc import AsyncIterator from datetime import datetime, timezone from enum import Enum from typing import Any import structlog from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi.responses import JSONResponse from pydantic import BaseModel, Field, field_validator from sqlalchemy.ext.asyncio import AsyncSession from sse_starlette.sse import EventSourceResponse from src.api.dependencies import ( get_chat_repo, get_db_session, get_message_repo, get_user_ctx, get_user_repo, ) from src.db.repositories import ChatRepository, MessageRepository, UserRepository from src.middleware.auth import UserContext from src.schemas.chat import ( ChatCreate, ChatResponse, ChatUpdate, ChatWithMessages, MessageCreate, MessageResponse, ) from src.services.chat_service import ChatService logger = structlog.get_logger() router = APIRouter(prefix="/api/v1/chats", tags=["chats"]) # ============================================================================ # ENUMS & CONSTANTS # ============================================================================ class MessageRole(str, Enum): """Роли сообщений в чате.""" USER = "user" ASSISTANT = "assistant" SYSTEM = "system" class ExportFormat(str, Enum): """Форматы экспорта чата.""" JSON = "json" MARKDOWN = "markdown" TEXT = "text" class SSEEventType(str, Enum): """Типы событий для Server-Sent Events.""" USER_MESSAGE = "user_message" RAG_CONTEXT = "rag_context" CONTENT = "content" TOOL_CALL = "tool_call" TOOL_RESULT = "tool_result" DONE = "done" ERROR = "error" # ============================================================================ # REQUEST MODELS # ============================================================================ class SendMessageRequest(BaseModel): """Запрос на отправку сообщения с авто-генерацией ответа.""" content: str = Field( ..., min_length=1, max_length=50_000, description="Содержимое сообщения пользователя", ) stream: bool = Field( default=True, description="Стримить ответ через SSE (true) или вернуть полный JSON (false)", ) use_rag: bool | None = Field( default=None, description="Использовать RAG (по умолчанию из настроек чата)", ) temperature: float | None = Field( default=None, ge=0.0, le=2.0, description="Temperature для LLM (переопределяет настройки чата)", ) max_tokens: int | None = Field( default=None, ge=1, le=100_000, description="Максимальное количество токенов в ответе", ) @field_validator("content") @classmethod def validate_content(cls, v: str) -> str: """Валидация содержимого сообщения.""" v = v.strip() if not v: raise ValueError( "Message content cannot be empty after stripping whitespace" ) return v class MessageSearchRequest(BaseModel): """Запрос на поиск по истории чата.""" query: str = Field( ..., min_length=1, max_length=500, description="Поисковый запрос" ) role: MessageRole | None = Field(None, description="Фильтр по роли сообщения") limit: int = Field( 50, ge=1, le=500, description="Максимальное количество результатов" ) case_sensitive: bool = Field(False, description="Учитывать регистр") class ClearHistoryRequest(BaseModel): """Запрос на очистку истории чата.""" keep_system_messages: bool = Field( default=True, description="Сохранить системные сообщения", ) before_date: datetime | None = Field( default=None, description="Удалить сообщения до указанной даты", ) # ============================================================================ # RESPONSE MODELS # ============================================================================ class ChatStatsResponse(BaseModel): """Статистика чата.""" chat_id: uuid.UUID total_messages: int user_messages: int assistant_messages: int system_messages: int total_tokens_used: int avg_tokens_per_message: float first_message_at: datetime | None last_message_at: datetime | None rag_enabled: bool model: str class MessageSearchResult(BaseModel): """Результат поиска по сообщениям.""" message_id: uuid.UUID role: str content: str created_at: datetime tokens_used: int context_before: str | None = None context_after: str | None = None class ExportResponse(BaseModel): """Ответ с экспортированным чатом.""" chat_id: uuid.UUID format: ExportFormat content: str messages_count: int exported_at: datetime class SSEEvent(BaseModel): """Структурированное SSE событие.""" type: str data: dict[str, Any] def to_sse_format(self) -> dict[str, str]: """Преобразовать в формат для EventSourceResponse.""" return { "event": self.type, "data": json.dumps(self.data, ensure_ascii=False), } # ============================================================================ # DEPENDENCIES # ============================================================================ async def get_chat_service( user_ctx: UserContext = Depends(get_user_ctx), session: AsyncSession = Depends(get_db_session), ) -> ChatService: """Получить ChatService для бизнес-логики.""" return ChatService(session, user_ctx.user_id, user_ctx.workspace_id) # ============================================================================ # CHAT CRUD ENDPOINTS # ============================================================================ @router.post("", response_model=ChatResponse, status_code=status.HTTP_201_CREATED) async def create_chat( request: ChatCreate, user_ctx: UserContext = Depends(get_user_ctx), chat_repo: ChatRepository = Depends(get_chat_repo), user_repo: UserRepository = Depends(get_user_repo), ) -> ChatResponse: """ Создать новый чат. Автоматически создаёт пользователя если его нет в БД. """ # Обеспечиваем существование user await user_repo.ensure_exists(user_ctx.user_id, user_ctx.workspace_id) chat = await chat_repo.create( user_id=user_ctx.user_id, workspace_id=user_ctx.workspace_id, title=request.title, rag_enabled=request.rag_enabled, rag_workspace_id=request.rag_workspace_id, model=request.model, temperature=request.temperature, top_k=request.top_k, ) logger.info( "chat.created", chat_id=str(chat.id), user_id=str(user_ctx.user_id), workspace_id=user_ctx.workspace_id, rag_enabled=chat.rag_enabled, model=chat.model, ) return ChatResponse( id=chat.id, workspace_id=chat.workspace_id, title=chat.title, rag_enabled=chat.rag_enabled, rag_workspace_id=chat.rag_workspace_id, model=chat.model, temperature=chat.temperature, top_k=chat.top_k, messages_count=0, created_at=chat.created_at, updated_at=chat.updated_at, ) @router.get("", response_model=list[ChatResponse]) async def list_chats( limit: int = Query(50, ge=1, le=500, description="Максимальное количество чатов"), offset: int = Query(0, ge=0, description="Смещение для пагинации"), rag_enabled: bool | None = Query(None, description="Фильтр по RAG"), model: str | None = Query(None, description="Фильтр по модели"), chat_repo: ChatRepository = Depends(get_chat_repo), ) -> list[ChatResponse]: """ Список чатов пользователя с фильтрацией и пагинацией. Поддерживает фильтры по: - rag_enabled: чаты с включённым/выключенным RAG - model: чаты с определённой моделью """ chats = await chat_repo.list(limit=limit, offset=offset) # Применяем фильтры (в идеале это должно быть в репозитории) if rag_enabled is not None: chats = [c for c in chats if c.rag_enabled == rag_enabled] if model is not None: chats = [c for c in chats if c.model == model] logger.info( "chats.listed", count=len(chats), limit=limit, offset=offset, rag_filter=rag_enabled, model_filter=model, ) return [ ChatResponse( id=chat.id, workspace_id=chat.workspace_id, title=chat.title, rag_enabled=chat.rag_enabled, rag_workspace_id=chat.rag_workspace_id, model=chat.model, temperature=chat.temperature, top_k=chat.top_k, messages_count=len(chat.messages) if chat.messages else 0, created_at=chat.created_at, updated_at=chat.updated_at, ) for chat in chats ] @router.get("/{chat_id}", response_model=ChatWithMessages) async def get_chat( chat_id: uuid.UUID, include_messages: bool = Query(True, description="Включить историю сообщений"), messages_limit: int = Query(1000, ge=1, le=10000, description="Лимит сообщений"), chat_repo: ChatRepository = Depends(get_chat_repo), message_repo: MessageRepository = Depends(get_message_repo), ) -> ChatWithMessages: """ Получить чат с историей сообщений. Можно отключить загрузку сообщений для быстрого получения метаданных чата. """ chat = await chat_repo.get(chat_id) if chat is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Chat {chat_id} not found", ) messages = [] if include_messages: messages = await message_repo.list_by_chat(chat_id, limit=messages_limit) chat_response = ChatResponse( id=chat.id, workspace_id=chat.workspace_id, title=chat.title, rag_enabled=chat.rag_enabled, rag_workspace_id=chat.rag_workspace_id, model=chat.model, temperature=chat.temperature, top_k=chat.top_k, messages_count=len(messages), created_at=chat.created_at, updated_at=chat.updated_at, ) message_responses = [ MessageResponse( id=msg.id, chat_id=msg.chat_id, role=msg.role, content=msg.content, sources=msg.sources, tokens_used=msg.tokens_used, created_at=msg.created_at, ) for msg in messages ] logger.debug( "chat.retrieved", chat_id=str(chat_id), messages_count=len(messages), ) return ChatWithMessages(chat=chat_response, messages=message_responses) @router.patch("/{chat_id}", response_model=ChatResponse) async def update_chat( chat_id: uuid.UUID, request: ChatUpdate, chat_repo: ChatRepository = Depends(get_chat_repo), ) -> ChatResponse: """ Обновить чат (частичное обновление). Можно обновить: title, rag_enabled, rag_workspace_id, model, temperature, top_k """ update_data = request.model_dump(exclude_unset=True) if not update_data: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="No fields to update", ) chat = await chat_repo.update(chat_id, **update_data) if chat is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Chat {chat_id} not found", ) logger.info( "chat.updated", chat_id=str(chat_id), updated_fields=list(update_data.keys()), ) # Получаем актуальное количество сообщений messages_count = len(chat.messages) if chat.messages else 0 return ChatResponse( id=chat.id, workspace_id=chat.workspace_id, title=chat.title, rag_enabled=chat.rag_enabled, rag_workspace_id=chat.rag_workspace_id, model=chat.model, temperature=chat.temperature, top_k=chat.top_k, messages_count=messages_count, created_at=chat.created_at, updated_at=chat.updated_at, ) @router.delete("/{chat_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_chat( chat_id: uuid.UUID, chat_repo: ChatRepository = Depends(get_chat_repo), ) -> None: """ Удалить чат и всю его историю. Операция необратима. Все сообщения будут удалены каскадно. """ deleted = await chat_repo.delete(chat_id) if not deleted: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Chat {chat_id} not found", ) logger.info("chat.deleted", chat_id=str(chat_id)) # ============================================================================ # MESSAGE ENDPOINTS # ============================================================================ @router.post( "/{chat_id}/messages", response_model=MessageResponse, status_code=status.HTTP_201_CREATED, ) async def create_message( chat_id: uuid.UUID, request: MessageCreate, chat_repo: ChatRepository = Depends(get_chat_repo), message_repo: MessageRepository = Depends(get_message_repo), ) -> MessageResponse: """ Добавить сообщение в чат (без генерации ответа). Используется для: - Добавления системных сообщений - Импорта истории - Ручного добавления сообщений """ # Проверяем что чат существует chat = await chat_repo.get(chat_id) if chat is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Chat {chat_id} not found", ) message = await message_repo.create( chat_id=chat_id, role=request.role, content=request.content, sources=request.sources, ) if message is None: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create message", ) logger.info( "message.created", message_id=str(message.id), chat_id=str(chat_id), role=message.role, content_length=len(message.content), ) return MessageResponse( id=message.id, chat_id=message.chat_id, role=message.role, content=message.content, sources=message.sources, tokens_used=message.tokens_used, created_at=message.created_at, ) @router.get("/{chat_id}/messages", response_model=list[MessageResponse]) async def list_messages( chat_id: uuid.UUID, limit: int = Query( 100, ge=1, le=10000, description="Максимальное количество сообщений" ), offset: int = Query(0, ge=0, description="Смещение для пагинации"), role: MessageRole | None = Query(None, description="Фильтр по роли"), chat_repo: ChatRepository = Depends(get_chat_repo), message_repo: MessageRepository = Depends(get_message_repo), ) -> list[MessageResponse]: """ Список сообщений в чате с фильтрацией и пагинацией. Сообщения возвращаются в хронологическом порядке (старые → новые). """ # Проверяем что чат существует chat = await chat_repo.get(chat_id) if chat is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Chat {chat_id} not found", ) messages = await message_repo.list_by_chat(chat_id, limit=limit, offset=offset) # Применяем фильтр по роли if role is not None: messages = [m for m in messages if m.role == role.value] logger.debug( "messages.listed", chat_id=str(chat_id), count=len(messages), limit=limit, offset=offset, role_filter=role, ) return [ MessageResponse( id=msg.id, chat_id=msg.chat_id, role=msg.role, content=msg.content, sources=msg.sources, tokens_used=msg.tokens_used, created_at=msg.created_at, ) for msg in messages ] # ============================================================================ # SEND MESSAGE ENDPOINT (RAG + LLM Integration) # ============================================================================ @router.post("/{chat_id}/send") async def send_message( chat_id: uuid.UUID, request: SendMessageRequest, chat_repo: ChatRepository = Depends(get_chat_repo), chat_service: ChatService = Depends(get_chat_service), ) -> Any: """ Отправить сообщение и получить ответ от LLM (с опциональным RAG). **Flow:** 1. Валидация что чат существует 2. Сохранение user message в PostgreSQL 3. Если chat.rag_enabled=true → HTTP POST в RAG /search 4. Формирование prompt с context + history 5. Стриминг ответа от LLM (или возврат полного JSON) 6. Сохранение assistant response с sources **Streaming mode (stream=true):** SSE поток событий: - `user_message`: сохранённое сообщение пользователя - `rag_context`: контекст из RAG (если включён) - `content`: токены ответа от LLM - `tool_call`: вызов инструмента (если есть) - `tool_result`: результат инструмента - `done`: завершение с полной статистикой - `error`: ошибка выполнения **Non-streaming mode (stream=false):** JSON с полным ответом и метаданными. """ # Проверяем что чат существует chat = await chat_repo.get(chat_id) if chat is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Chat {chat_id} not found", ) logger.info( "send_message.started", chat_id=str(chat_id), stream=request.stream, content_length=len(request.content), use_rag=request.use_rag, ) if request.stream: return EventSourceResponse( _stream_chat_response( chat_id=chat_id, content=request.content, use_rag=request.use_rag, temperature=request.temperature, max_tokens=request.max_tokens, chat_service=chat_service, ) ) # Non-streaming mode: собрать полный ответ return await _get_full_chat_response( chat_id=chat_id, content=request.content, use_rag=request.use_rag, temperature=request.temperature, max_tokens=request.max_tokens, chat_service=chat_service, ) async def _stream_chat_response( chat_id: uuid.UUID, content: str, use_rag: bool | None, temperature: float | None, max_tokens: int | None, chat_service: ChatService, ) -> AsyncIterator[dict[str, str]]: """ Генератор SSE событий для streaming ответа. Yields: Dict с event type и JSON data """ try: async for event in chat_service.send_message( chat_id=chat_id, user_content=content, stream=True, ): event_type = event.get("type", SSEEventType.CONTENT.value) event_data = json.dumps(event, ensure_ascii=False) yield { "event": event_type, "data": event_data, } # Логируем важные события (исправлено для structlog) if event_type in (SSEEventType.DONE.value, SSEEventType.ERROR.value): logger.info( "send_message.stream.event", chat_id=str(chat_id), event_type=event_type, ) except Exception as e: logger.exception( "send_message.stream.error", chat_id=str(chat_id), error=str(e), error_type=type(e).__name__, ) error_event = { "type": SSEEventType.ERROR.value, "error": str(e), "error_type": type(e).__name__, } yield { "event": SSEEventType.ERROR.value, "data": json.dumps(error_event, ensure_ascii=False), } async def _get_full_chat_response( chat_id: uuid.UUID, content: str, use_rag: bool | None, temperature: float | None, max_tokens: int | None, chat_service: ChatService, ) -> JSONResponse: """ Получить полный ответ без streaming. Returns: JSONResponse с полным ответом и метаданными """ full_response: dict[str, Any] = {} try: async for event in chat_service.send_message( chat_id=chat_id, user_content=content, stream=False, ): event_type = event.get("type") if event_type == SSEEventType.DONE.value: full_response = event elif event_type == SSEEventType.ERROR.value: logger.error( "send_message.sync.error", chat_id=str(chat_id), error=event.get("error"), ) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=event.get("error", "Unknown error"), ) except HTTPException: raise except Exception as e: logger.exception( "send_message.sync.unexpected_error", chat_id=str(chat_id), error=str(e), ) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Unexpected error: {str(e)}", ) if not full_response: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="No response received from LLM", ) logger.info( "send_message.sync.completed", chat_id=str(chat_id), tokens_used=full_response.get("tokens_used", 0), ) return JSONResponse(content=full_response) # ============================================================================ # CHAT STATISTICS & ANALYTICS ENDPOINTS # ============================================================================ @router.get("/{chat_id}/stats", response_model=ChatStatsResponse) async def get_chat_stats( chat_id: uuid.UUID, chat_repo: ChatRepository = Depends(get_chat_repo), message_repo: MessageRepository = Depends(get_message_repo), ) -> ChatStatsResponse: """ Получить детальную статистику чата. Включает: - Количество сообщений по ролям - Общее количество использованных токенов - Среднее количество токенов на сообщение - Временные метки первого и последнего сообщения """ chat = await chat_repo.get(chat_id) if chat is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Chat {chat_id} not found", ) messages = await message_repo.list_by_chat(chat_id, limit=100_000) # Подсчёт статистики total_messages = len(messages) user_messages = sum(1 for m in messages if m.role == "user") assistant_messages = sum(1 for m in messages if m.role == "assistant") system_messages = sum(1 for m in messages if m.role == "system") total_tokens = sum(m.tokens_used for m in messages) avg_tokens = total_tokens / total_messages if total_messages > 0 else 0.0 first_message_at = messages[0].created_at if messages else None last_message_at = messages[-1].created_at if messages else None logger.debug( "chat.stats.retrieved", chat_id=str(chat_id), total_messages=total_messages, total_tokens=total_tokens, ) return ChatStatsResponse( chat_id=chat_id, total_messages=total_messages, user_messages=user_messages, assistant_messages=assistant_messages, system_messages=system_messages, total_tokens_used=total_tokens, avg_tokens_per_message=round(avg_tokens, 2), first_message_at=first_message_at, last_message_at=last_message_at, rag_enabled=chat.rag_enabled, model=chat.model, ) # ============================================================================ # CHAT MANAGEMENT ENDPOINTS # ============================================================================ @router.post("/{chat_id}/clear", status_code=status.HTTP_200_OK) async def clear_chat_history( chat_id: uuid.UUID, request: ClearHistoryRequest | None = None, chat_repo: ChatRepository = Depends(get_chat_repo), message_repo: MessageRepository = Depends(get_message_repo), session: AsyncSession = Depends(get_db_session), ) -> dict[str, Any]: """ Очистить историю чата. Опции: - keep_system_messages: сохранить системные сообщения (default: true) - before_date: удалить только сообщения до указанной даты Возвращает количество удалённых сообщений. **Note:** Используется POST вместо DELETE, так как DELETE не должен иметь body согласно HTTP спецификации, и многие клиенты/прокси его игнорируют. """ chat = await chat_repo.get(chat_id) if chat is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Chat {chat_id} not found", ) if request is None: request = ClearHistoryRequest() messages = await message_repo.list_by_chat(chat_id, limit=100_000) # Фильтруем сообщения для удаления messages_to_delete = [] for msg in messages: # Пропускаем системные если нужно if request.keep_system_messages and msg.role == "system": continue # Фильтр по дате if request.before_date and msg.created_at >= request.before_date: continue messages_to_delete.append(msg) # Удаляем сообщения deleted_count = 0 for msg in messages_to_delete: try: await session.delete(msg) deleted_count += 1 except Exception as e: logger.warning( "clear_history.delete_message.failed", message_id=str(msg.id), error=str(e), ) # ВАЖНО: коммитим транзакцию! if deleted_count > 0: try: await session.commit() except Exception as e: await session.rollback() logger.error( "clear_history.commit.failed", chat_id=str(chat_id), error=str(e), ) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to commit deletion: {str(e)}", ) logger.info( "chat.history.cleared", chat_id=str(chat_id), deleted_count=deleted_count, total_messages=len(messages), keep_system=request.keep_system_messages, before_date=str(request.before_date) if request.before_date else None, ) return { "status": "success", "deleted_count": deleted_count, "remaining_count": len(messages) - deleted_count, } # ============================================================================ # SEARCH & EXPORT ENDPOINTS # ============================================================================ @router.post("/{chat_id}/search", response_model=list[MessageSearchResult]) async def search_messages( chat_id: uuid.UUID, request: MessageSearchRequest, chat_repo: ChatRepository = Depends(get_chat_repo), message_repo: MessageRepository = Depends(get_message_repo), ) -> list[MessageSearchResult]: """ Поиск по истории чата. Поддерживает: - Полнотекстовый поиск по содержимому - Фильтр по роли сообщения - Контекст (предыдущее и следующее сообщение) """ chat = await chat_repo.get(chat_id) if chat is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Chat {chat_id} not found", ) messages = await message_repo.list_by_chat(chat_id, limit=100_000) # Поиск query = request.query if request.case_sensitive else request.query.lower() results: list[MessageSearchResult] = [] for i, msg in enumerate(messages): # Фильтр по роли if request.role and msg.role != request.role.value: continue # Поиск в содержимом content = msg.content if request.case_sensitive else msg.content.lower() if query not in content: continue # Получаем контекст (ограничиваем длину для безопасности) context_before = messages[i - 1].content[:500] if i > 0 else None context_after = messages[i + 1].content[:500] if i < len(messages) - 1 else None results.append( MessageSearchResult( message_id=msg.id, role=msg.role, content=msg.content, created_at=msg.created_at, tokens_used=msg.tokens_used, context_before=context_before, context_after=context_after, ) ) if len(results) >= request.limit: break logger.info( "chat.search.completed", chat_id=str(chat_id), query=request.query, results_count=len(results), ) return results @router.get("/{chat_id}/export", response_model=ExportResponse) async def export_chat( chat_id: uuid.UUID, export_format: ExportFormat = Query( ExportFormat.JSON, alias="format", description="Формат экспорта" ), include_metadata: bool = Query(True, description="Включить метаданные"), chat_repo: ChatRepository = Depends(get_chat_repo), message_repo: MessageRepository = Depends(get_message_repo), ) -> ExportResponse: """ Экспорт чата в различных форматах. Поддерживаемые форматы: - **json**: полная структура с метаданными - **markdown**: форматированный текст с заголовками - **text**: простой текст без форматирования """ chat = await chat_repo.get(chat_id) if chat is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Chat {chat_id} not found", ) messages = await message_repo.list_by_chat(chat_id, limit=100_000) # Генерация контента в зависимости от формата if export_format == ExportFormat.JSON: content = _export_as_json(chat, messages, include_metadata) elif export_format == ExportFormat.MARKDOWN: content = _export_as_markdown(chat, messages, include_metadata) else: # TEXT content = _export_as_text(chat, messages, include_metadata) logger.info( "chat.exported", chat_id=str(chat_id), format=export_format.value, messages_count=len(messages), content_length=len(content), ) return ExportResponse( chat_id=chat_id, format=export_format, content=content, messages_count=len(messages), exported_at=datetime.now(timezone.utc), ) def _export_as_json(chat: Any, messages: list[Any], include_metadata: bool) -> str: """Экспорт чата в JSON формат.""" data: dict[str, Any] = { "messages": [ { "id": str(msg.id), "role": msg.role, "content": msg.content, "created_at": msg.created_at.isoformat(), "tokens_used": msg.tokens_used, "sources": msg.sources, } for msg in messages ] } if include_metadata: data["metadata"] = { "chat_id": str(chat.id), "title": chat.title, "model": chat.model, "rag_enabled": chat.rag_enabled, "created_at": chat.created_at.isoformat(), "updated_at": chat.updated_at.isoformat(), } return json.dumps(data, ensure_ascii=False, indent=2) def _export_as_markdown(chat: Any, messages: list[Any], include_metadata: bool) -> str: """Экспорт чата в Markdown формат.""" lines: list[str] = [] if include_metadata: lines.extend( [ f"# {chat.title or 'Chat'}", "", f"**Chat ID:** `{chat.id}`", f"**Model:** {chat.model}", f"**RAG Enabled:** {chat.rag_enabled}", f"**Created:** {chat.created_at.isoformat()}", f"**Messages:** {len(messages)}", "", "---", "", ] ) for msg in messages: role_display = { "user": "👤 **User**", "assistant": "🤖 **Assistant**", "system": "⚙️ **System**", }.get(msg.role, msg.role.upper()) lines.extend( [ f"### {role_display}", f"*{msg.created_at.isoformat()}*", "", msg.content, "", "---", "", ] ) return "\n".join(lines) def _export_as_text(chat: Any, messages: list[Any], include_metadata: bool) -> str: """Экспорт чата в plain text формат.""" lines: list[str] = [] if include_metadata: lines.extend( [ f"Chat: {chat.title or 'Untitled'}", f"ID: {chat.id}", f"Model: {chat.model}", f"Messages: {len(messages)}", "=" * 80, "", ] ) for msg in messages: lines.extend( [ f"[{msg.created_at.isoformat()}] {msg.role.upper()}:", msg.content, "", ] ) return "\n".join(lines)