From 9a0407e87e20a2e43da16afb2b73e29cab6c8970 Mon Sep 17 00:00:00 2001 From: FDKost Date: Wed, 24 Dec 2025 01:57:36 +0300 Subject: [PATCH] Update environment variables, Docker configuration, and dependencies; refactor token management and chat agent logic. Added FastAPI server setup and improved message handling in GigaChat client. --- .dockerignore | 47 +++++++++++++ .env | 4 +- Dockerfile | 6 +- agents/chat_agent.py | 20 +++--- agents/gigachat_client.py | 23 ++++-- app.py | 143 ++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 14 ++-- models/gigachat_types.py | 6 +- models/schedule.py | 8 ++- requirements.txt | 2 + services/cache_service.py | 3 +- services/token_manager.py | 122 +++++++++++++++++++++++++++----- 12 files changed, 348 insertions(+), 50 deletions(-) create mode 100644 .dockerignore create mode 100644 app.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5e3b20e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,47 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +ENV/ +.venv + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +*.cover + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Project specific +.env +*.log +*.db +*.sqlite + +# Git +.git/ +.gitignore + +# Docker +docker-compose.yml +Dockerfile +.dockerignore + +# Documentation +*.md +!README.md + diff --git a/.env b/.env index eee1bbc..604d97a 100644 --- a/.env +++ b/.env @@ -1,5 +1,5 @@ -GIGACHAT_CLIENT_ID=019966f4-1c5c-7382-9006-b84419fbe5d1 -GIGACHAT_CLIENT_SECRET="MDE5OTY2ZjQtMWM1Yy03MzgyLTkwMDYtYjg0NDE5ZmJlNWQxOmRjNTk2ZmFlLWMzY2UtNDRmNC05NDk3LWE2YWIxMDI5ZmE1OA==" +GIGACHAT_CLIENT_ID=019966f0-5781-76e6-a84f-ec7de158188a +GIGACHAT_CLIENT_SECRET=MDE5OTY2ZjAtNTc4MS03NmU2LWE4NGYtZWM3ZGUxNTgxODhhOjI3MDMxZjIxLWY3NWYtNGI4NS05MzM1LTI4ZDYyOWM3MmM0MA== GIGACHAT_AUTH_URL=https://ngw.devices.sberbank.ru:9443/api/v2/oauth GIGACHAT_BASE_URL=https://gigachat.devices.sberbank.ru/api/v1 diff --git a/Dockerfile b/Dockerfile index 60974c5..39108e2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,13 +40,13 @@ COPY models/ ./models/ COPY services/ ./services/ COPY prompts/ ./prompts/ COPY scripts/ ./scripts/ +COPY app.py ./ # Установка переменных окружения ENV PYTHONUNBUFFERED=1 \ PYTHONDONTWRITEBYTECODE=1 \ PYTHONPATH=/app -# По умолчанию запускаем Python REPL для интерактивного использования -# В production это будет использоваться как библиотека, импортируемая в backend -CMD ["python"] +# Запуск FastAPI сервера +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/agents/chat_agent.py b/agents/chat_agent.py index c18d12c..f92ecb8 100644 --- a/agents/chat_agent.py +++ b/agents/chat_agent.py @@ -21,7 +21,7 @@ class ChatAgent: user_id: UUID, message: str, conversation_id: Optional[str] = None, - model: str = "GigaChat-2-Lite", + model: str = "GigaChat-2", ) -> tuple[str, int]: """ Отправить сообщение и получить ответ. @@ -39,22 +39,25 @@ class ChatAgent: context_messages = [] if conversation_id: cached_context = await self.cache.get_context(str(conversation_id)) + # Фильтруем системные сообщения из кэша - они не должны там храниться context_messages = [ GigaChatMessage(role=msg["role"], content=msg["content"]) for msg in cached_context + if msg["role"] != "system" ] - # Добавляем системный промпт в начало + # Системное сообщение ВСЕГДА должно быть первым system_message = GigaChatMessage(role="system", content=EARTH_PERSONA) - if not context_messages or context_messages[0].role != "system": - context_messages.insert(0, system_message) + # Убеждаемся, что системное сообщение первое (удаляем все системные сообщения и добавляем одно в начало) + context_messages = [msg for msg in context_messages if msg.role != "system"] + context_messages.insert(0, system_message) # Добавляем текущее сообщение пользователя context_messages.append(GigaChatMessage(role="user", content=message)) - # Отправляем запрос + # Отправляем запрос (не передаем message отдельно, т.к. оно уже в context_messages) response = await self.gigachat.chat_with_response( - message=message, + message="", # Пустое, т.к. сообщение уже добавлено в context_messages context=context_messages, model=model, temperature=0.7, @@ -76,7 +79,7 @@ class ChatAgent: user_id: UUID, message: str, context: Optional[List[dict]] = None, - model: str = "GigaChat-2-Lite", + model: str = "GigaChat-2", ) -> tuple[str, int]: """ Отправить сообщение с явным контекстом. @@ -100,8 +103,9 @@ class ChatAgent: context_messages.append(GigaChatMessage(role="user", content=message)) + # Отправляем запрос (не передаем message отдельно, т.к. оно уже в context_messages) response = await self.gigachat.chat_with_response( - message=message, + message="", # Пустое, т.к. сообщение уже добавлено в context_messages context=context_messages, model=model, temperature=0.7, diff --git a/agents/gigachat_client.py b/agents/gigachat_client.py index 4d016f4..fd38774 100644 --- a/agents/gigachat_client.py +++ b/agents/gigachat_client.py @@ -23,7 +23,8 @@ class GigaChatClient: async def _get_session(self) -> aiohttp.ClientSession: """Получить HTTP сессию (lazy initialization).""" if self._session is None or self._session.closed: - self._session = aiohttp.ClientSession() + connector = aiohttp.TCPConnector(ssl=False) + self._session = aiohttp.ClientSession(connector=connector) return self._session async def chat( @@ -73,7 +74,7 @@ class GigaChatClient: Args: message: Текст сообщения - context: История сообщений + context: История сообщений (уже должна содержать системное сообщение первым) model: Модель GigaChat temperature: Температура генерации max_tokens: Максимальное количество токенов @@ -81,8 +82,22 @@ class GigaChatClient: Returns: Полный ответ от API """ - messages = context or [] - messages.append(GigaChatMessage(role="user", content=message)) + # Создаем копию списка, чтобы не изменять оригинал + messages = list(context) if context else [] + + # Убеждаемся, что системное сообщение первое + system_messages = [msg for msg in messages if msg.role == "system"] + non_system_messages = [msg for msg in messages if msg.role != "system"] + + # Если есть системные сообщения, берем первое, иначе оставляем список пустым + if system_messages: + messages = [system_messages[0]] + non_system_messages + else: + messages = non_system_messages + + # Добавляем текущее сообщение пользователя только если его еще нет в конце + if not messages or messages[-1].role != "user" or messages[-1].content != message: + messages.append(GigaChatMessage(role="user", content=message)) request = GigaChatRequest( model=model, diff --git a/app.py b/app.py new file mode 100644 index 0000000..e21a7ef --- /dev/null +++ b/app.py @@ -0,0 +1,143 @@ +"""FastAPI сервер для AI-агентов.""" +import os +from typing import List, Optional, Dict, Any +from uuid import UUID, uuid4 + +from fastapi import FastAPI, HTTPException, APIRouter +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel, Field + +from agents.gigachat_client import GigaChatClient +from agents.chat_agent import ChatAgent +from services.token_manager import TokenManager +from services.cache_service import CacheService +from models.gigachat_types import GigaChatMessage + +app = FastAPI(title="New Planet AI Agents API", version="1.0.0") + +# CORS middleware для работы с backend +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # В production указать конкретные домены + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Роутер для API эндпоинтов +api_router = APIRouter(prefix="/api/v1", tags=["ai"]) + +# Инициализация сервисов +token_manager = TokenManager() +gigachat_client = GigaChatClient(token_manager) +cache_service = CacheService() # Использует REDIS_URL из окружения +chat_agent = ChatAgent(gigachat_client, cache_service) + + +# Модели запросов/ответов +class ChatRequest(BaseModel): + """Запрос на отправку сообщения в чат.""" + message: str = Field(..., min_length=1, max_length=2000) + conversation_id: Optional[str] = None + context: Optional[List[Dict[str, Any]]] = None + model: Optional[str] = "GigaChat-2" + user_id: Optional[UUID] = None + + +class ChatResponse(BaseModel): + """Ответ от ИИ-агента.""" + response: str + conversation_id: Optional[str] = None + tokens_used: Optional[int] = None + model: Optional[str] = None + + +class GenerateTextRequest(BaseModel): + """Запрос на генерацию текста.""" + prompt: str = Field(..., min_length=1) + model: Optional[str] = "GigaChat-2-Pro" + + +class GenerateTextResponse(BaseModel): + """Ответ с сгенерированным текстом.""" + text: str + + +@app.get("/health") +async def health_check(): + """Проверка здоровья сервиса.""" + return {"status": "ok", "service": "ai-agents"} + + +@api_router.post("/chat", response_model=ChatResponse) +async def chat(request: ChatRequest): + """ + Отправить сообщение в чат через ChatAgent. + + Поддерживает два режима: + 1. С conversation_id - использует ChatAgent с контекстом из Redis + 2. С явным context - использует chat_with_context + """ + try: + user_id = request.user_id or uuid4() + + if request.context: + # Используем явный контекст + response_text, tokens_used = await chat_agent.chat_with_context( + user_id=user_id, + message=request.message, + context=request.context, + model=request.model or "GigaChat-2", + ) + else: + # Используем ChatAgent с conversation_id + response_text, tokens_used = await chat_agent.chat( + user_id=user_id, + message=request.message, + conversation_id=request.conversation_id, + model=request.model or "GigaChat-2", + ) + + return ChatResponse( + response=response_text, + conversation_id=request.conversation_id, + tokens_used=tokens_used, + model=request.model or "GigaChat-2", + ) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Chat error: {str(e)}") + + +@api_router.post("/generate_text", response_model=GenerateTextResponse) +async def generate_text(request: GenerateTextRequest): + """ + Генерация текста по промпту через GigaChat. + """ + try: + response_text = await gigachat_client.chat( + message=request.prompt, + model=request.model or "GigaChat-2-Pro", + temperature=0.7, + max_tokens=2000, + ) + + return GenerateTextResponse(text=response_text) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Generate text error: {str(e)}") + + +# Подключение роутера к приложению +app.include_router(api_router) + + +@app.on_event("shutdown") +async def shutdown(): + """Закрытие соединений при остановке.""" + await gigachat_client.close() + await cache_service.close() + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) + diff --git a/docker-compose.yml b/docker-compose.yml index 6dbc94b..c1da3b6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,14 +1,15 @@ version: '3.8' services: - ai-agents: + ports: + - "8001:8000" build: context: . dockerfile: Dockerfile container_name: new-planet-ai-agents environment: - - REDIS_URL=redis://redis:6379/0 + - REDIS_URL=redis://newplanet-redis:6379/0 - GIGACHAT_CLIENT_ID=${GIGACHAT_CLIENT_ID} - GIGACHAT_CLIENT_SECRET=${GIGACHAT_CLIENT_SECRET} - PYTHONUNBUFFERED=1 @@ -20,13 +21,12 @@ services: - ./services:/app/services - ./prompts:/app/prompts - ./scripts:/app/scripts + - ./app.py:/app/app.py networks: - new-planet-network - # По умолчанию запускается Python REPL для интерактивного использования - # Можно переопределить через docker-compose run или command - stdin_open: true - tty: true + # Запуск FastAPI сервера + command: uvicorn app:app --host 0.0.0.0 --port 8000 networks: new-planet-network: - external: true + external: true diff --git a/models/gigachat_types.py b/models/gigachat_types.py index 70bd580..671e9ba 100644 --- a/models/gigachat_types.py +++ b/models/gigachat_types.py @@ -41,9 +41,9 @@ class GigaChatUsage(BaseModel): class GigaChatResponse(BaseModel): """Ответ от GigaChat API.""" - id: str - object: str - created: int + id: Optional[str] = None + object: Optional[str] = None + created: Optional[int] = None model: str choices: List[GigaChatChoice] usage: GigaChatUsage diff --git a/models/schedule.py b/models/schedule.py index 603976b..4dae94e 100644 --- a/models/schedule.py +++ b/models/schedule.py @@ -1,5 +1,7 @@ """Pydantic модели для расписаний.""" -from datetime import date +from __future__ import annotations + +from datetime import date as date_type from typing import List, Optional from uuid import UUID @@ -24,7 +26,7 @@ class Schedule(BaseModel): id: Optional[UUID] = None title: str = Field(..., description="Название расписания") - date: date = Field(..., description="Дата расписания") + date: date_type = Field(..., description="Дата расписания") tasks: List[Task] = Field(default_factory=list, description="Список заданий") user_id: Optional[UUID] = None created_at: Optional[str] = None @@ -35,6 +37,6 @@ class ScheduleGenerateRequest(BaseModel): child_age: int = Field(..., ge=1, le=18, description="Возраст ребенка") preferences: List[str] = Field(default_factory=list, description="Предпочтения ребенка") - date: date = Field(..., description="Дата расписания") + date: date_type = Field(..., description="Дата расписания") existing_tasks: Optional[List[str]] = Field(None, description="Существующие задания для учета") diff --git a/requirements.txt b/requirements.txt index 5723a2f..c845035 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,6 +14,8 @@ numpy aiohttp redis Pillow +fastapi +uvicorn[standard] # Dev pytest diff --git a/services/cache_service.py b/services/cache_service.py index a861513..c139a7a 100644 --- a/services/cache_service.py +++ b/services/cache_service.py @@ -12,7 +12,8 @@ class CacheService: """Сервис для работы с Redis кэшем.""" def __init__(self, redis_url: Optional[str] = None): - self.redis_url = redis_url or "redis://localhost:6379/0" + import os + self.redis_url = redis_url or os.getenv("REDIS_URL", "redis://localhost:6379/0") self._client: Optional[redis.Redis] = None async def _get_client(self) -> redis.Redis: diff --git a/services/token_manager.py b/services/token_manager.py index 489e164..9538841 100644 --- a/services/token_manager.py +++ b/services/token_manager.py @@ -1,9 +1,13 @@ """Управление токенами GigaChat.""" +import base64 import os import time +import uuid from typing import Optional +from urllib.parse import urlencode import aiohttp +from aiohttp import FormData from dotenv import load_dotenv load_dotenv() @@ -17,10 +21,13 @@ class TokenManager: client_id: Optional[str] = None, client_secret: Optional[str] = None, auth_url: Optional[str] = None, + credentials: Optional[str] = None, ): - self.client_id = client_id or os.getenv("GIGACHAT_CLIENT_ID") - self.client_secret = client_secret or os.getenv("GIGACHAT_CLIENT_SECRET") - self.auth_url = auth_url or os.getenv( + # Приоритет: переданные параметры > переменные окружения > .env файл + self.credentials = credentials or os.environ.get("GIGACHAT_CREDENTIALS") or os.getenv("GIGACHAT_CREDENTIALS") + self.client_id = client_id or os.environ.get("GIGACHAT_CLIENT_ID") or os.getenv("GIGACHAT_CLIENT_ID") + self.client_secret = client_secret or os.environ.get("GIGACHAT_CLIENT_SECRET") or os.getenv("GIGACHAT_CLIENT_SECRET") + self.auth_url = auth_url or os.environ.get("GIGACHAT_AUTH_URL") or os.getenv( "GIGACHAT_AUTH_URL", "https://ngw.devices.sberbank.ru:9443/api/v2/oauth" ) self._access_token: Optional[str] = None @@ -39,24 +46,101 @@ class TokenManager: if not force_refresh and self._access_token and time.time() < self._expires_at: return self._access_token - async with aiohttp.ClientSession() as session: - auth = aiohttp.BasicAuth(self.client_id, self.client_secret) - async with session.post( - self.auth_url, - auth=auth, - data={"scope": "GIGACHAT_API_PERS"}, - ) as response: - if response.status != 200: - error_text = await response.text() - raise Exception(f"Failed to get token: {response.status} - {error_text}") + # Определяем, какой вариант используется: готовый ключ или client_id/client_secret + if self.credentials: + # Используем готовый ключ авторизации (уже закодированный в Base64) + # Убираем префикс "Basic " если он есть + credentials_key = self.credentials.strip().replace('\n', '').replace('\r', '') + if credentials_key.startswith('Basic '): + credentials_key = credentials_key[6:] + + connector = aiohttp.TCPConnector(ssl=False) + async with aiohttp.ClientSession(connector=connector) as session: + headers = { + "Authorization": f"Basic {credentials_key}", + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + "RqUID": str(uuid.uuid4()) + } + + form_data = { + "scope": "GIGACHAT_API_PERS" + } + + async with session.post( + self.auth_url, + headers=headers, + data=form_data, + ) as response: + if response.status != 200: + error_text = await response.text() + raise Exception(f"Failed to get token: {response.status} - {error_text}") - data = await response.json() - self._access_token = data["access_token"] - # Токен обычно действителен 30 минут, обновляем за 5 минут до истечения - expires_in = data.get("expires_in", 1800) - self._expires_at = time.time() + expires_in - 300 + data = await response.json() + self._access_token = data["access_token"] + expires_in = data.get("expires_in", 1800) + self._expires_at = time.time() + expires_in - 300 - return self._access_token + return self._access_token + elif self.client_id and self.client_secret: + # Очищаем от пробелов и переносов + client_secret = self.client_secret.strip().replace('\n', '').replace('\r', '') + + # Проверяем, является ли client_secret уже закодированным ключом Base64 + # Если secret начинается с букв/цифр и длиннее 50 символов, это уже ключ авторизации + is_already_encoded = len(client_secret) > 50 and all(c.isalnum() or c in '+/=' for c in client_secret) + + if is_already_encoded: + # Это уже готовый ключ авторизации в Base64 + encoded_credentials = client_secret + print(f"DEBUG: Using pre-encoded authorization key (length: {len(encoded_credentials)})") + else: + # Это настоящий client_id и client_secret, нужно закодировать + client_id = self.client_id.strip().replace('\n', '').replace('\r', '') + + if not client_id or not client_secret: + raise Exception("GIGACHAT_CLIENT_ID and GIGACHAT_CLIENT_SECRET cannot be empty after cleaning") + + credentials_string = f"{client_id}:{client_secret}" + encoded_credentials = base64.b64encode(credentials_string.encode('utf-8')).decode('utf-8') + print(f"DEBUG: Encoded client_id:client_secret (length: {len(encoded_credentials)})") + + connector = aiohttp.TCPConnector(ssl=False) + async with aiohttp.ClientSession(connector=connector) as session: + headers = { + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + "RqUID": str(uuid.uuid4()), + "Authorization": f"Basic {encoded_credentials}" + } + + payload = {"scope": "GIGACHAT_API_PERS"} + + print(f"DEBUG: Authorization header starts with: Basic {encoded_credentials[:10]}...") + + async with session.post( + self.auth_url, + headers=headers, + data=payload, + ) as response: + if response.status != 200: + error_text = await response.text() + raise Exception( + f"Failed to get token: {response.status} - {error_text}. " + f"URL: {self.auth_url}" + ) + + data = await response.json() + self._access_token = data["access_token"] + expires_in = data.get("expires_in", 1800) + self._expires_at = time.time() + expires_in - 300 + + return self._access_token + else: + raise Exception( + "Either GIGACHAT_CREDENTIALS (ready authorization key) or " + "GIGACHAT_CLIENT_ID and GIGACHAT_CLIENT_SECRET must be set" + ) def is_token_valid(self) -> bool: """Проверить, действителен ли текущий токен."""