Compare commits

..

4 Commits

14 changed files with 448 additions and 46 deletions

47
.dockerignore Normal file
View File

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

4
.env
View File

@@ -1,5 +1,5 @@
GIGACHAT_CLIENT_ID=your-client-id GIGACHAT_CLIENT_ID=019966f0-5781-76e6-a84f-ec7de158188a
GIGACHAT_CLIENT_SECRET=your-client-secret GIGACHAT_CLIENT_SECRET=MDE5OTY2ZjAtNTc4MS03NmU2LWE4NGYtZWM3ZGUxNTgxODhhOjI3MDMxZjIxLWY3NWYtNGI4NS05MzM1LTI4ZDYyOWM3MmM0MA==
GIGACHAT_AUTH_URL=https://ngw.devices.sberbank.ru:9443/api/v2/oauth GIGACHAT_AUTH_URL=https://ngw.devices.sberbank.ru:9443/api/v2/oauth
GIGACHAT_BASE_URL=https://gigachat.devices.sberbank.ru/api/v1 GIGACHAT_BASE_URL=https://gigachat.devices.sberbank.ru/api/v1

52
Dockerfile Normal file
View File

@@ -0,0 +1,52 @@
# Многоступенчатая сборка для оптимизации размера образа
FROM python:3.11-slim as builder
# Установка системных зависимостей для сборки
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
gcc \
g++ \
&& rm -rf /var/lib/apt/lists/*
# Создание виртуального окружения
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Копирование файлов зависимостей
COPY requirements.txt pyproject.toml ./
# Установка зависимостей
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
# Финальный образ
FROM python:3.11-slim
# Установка только runtime зависимостей
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
# Копирование виртуального окружения из builder
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Создание рабочей директории
WORKDIR /app
# Копирование кода проекта
COPY agents/ ./agents/
COPY models/ ./models/
COPY services/ ./services/
COPY prompts/ ./prompts/
COPY scripts/ ./scripts/
COPY app.py ./
# Установка переменных окружения
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONPATH=/app
# Запуск FastAPI сервера
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -21,7 +21,7 @@ class ChatAgent:
user_id: UUID, user_id: UUID,
message: str, message: str,
conversation_id: Optional[str] = None, conversation_id: Optional[str] = None,
model: str = "GigaChat-2-Lite", model: str = "GigaChat-2",
) -> tuple[str, int]: ) -> tuple[str, int]:
""" """
Отправить сообщение и получить ответ. Отправить сообщение и получить ответ.
@@ -39,22 +39,25 @@ class ChatAgent:
context_messages = [] context_messages = []
if conversation_id: if conversation_id:
cached_context = await self.cache.get_context(str(conversation_id)) cached_context = await self.cache.get_context(str(conversation_id))
# Фильтруем системные сообщения из кэша - они не должны там храниться
context_messages = [ context_messages = [
GigaChatMessage(role=msg["role"], content=msg["content"]) GigaChatMessage(role=msg["role"], content=msg["content"])
for msg in cached_context for msg in cached_context
if msg["role"] != "system"
] ]
# Добавляем системный промпт в начало # Системное сообщение ВСЕГДА должно быть первым
system_message = GigaChatMessage(role="system", content=EARTH_PERSONA) 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)) context_messages.append(GigaChatMessage(role="user", content=message))
# Отправляем запрос # Отправляем запрос (не передаем message отдельно, т.к. оно уже в context_messages)
response = await self.gigachat.chat_with_response( response = await self.gigachat.chat_with_response(
message=message, message="", # Пустое, т.к. сообщение уже добавлено в context_messages
context=context_messages, context=context_messages,
model=model, model=model,
temperature=0.7, temperature=0.7,
@@ -76,7 +79,7 @@ class ChatAgent:
user_id: UUID, user_id: UUID,
message: str, message: str,
context: Optional[List[dict]] = None, context: Optional[List[dict]] = None,
model: str = "GigaChat-2-Lite", model: str = "GigaChat-2",
) -> tuple[str, int]: ) -> tuple[str, int]:
""" """
Отправить сообщение с явным контекстом. Отправить сообщение с явным контекстом.
@@ -100,8 +103,9 @@ class ChatAgent:
context_messages.append(GigaChatMessage(role="user", content=message)) context_messages.append(GigaChatMessage(role="user", content=message))
# Отправляем запрос (не передаем message отдельно, т.к. оно уже в context_messages)
response = await self.gigachat.chat_with_response( response = await self.gigachat.chat_with_response(
message=message, message="", # Пустое, т.к. сообщение уже добавлено в context_messages
context=context_messages, context=context_messages,
model=model, model=model,
temperature=0.7, temperature=0.7,

View File

@@ -23,7 +23,8 @@ class GigaChatClient:
async def _get_session(self) -> aiohttp.ClientSession: async def _get_session(self) -> aiohttp.ClientSession:
"""Получить HTTP сессию (lazy initialization).""" """Получить HTTP сессию (lazy initialization)."""
if self._session is None or self._session.closed: 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 return self._session
async def chat( async def chat(
@@ -73,7 +74,7 @@ class GigaChatClient:
Args: Args:
message: Текст сообщения message: Текст сообщения
context: История сообщений context: История сообщений (уже должна содержать системное сообщение первым)
model: Модель GigaChat model: Модель GigaChat
temperature: Температура генерации temperature: Температура генерации
max_tokens: Максимальное количество токенов max_tokens: Максимальное количество токенов
@@ -81,8 +82,22 @@ class GigaChatClient:
Returns: Returns:
Полный ответ от API Полный ответ от 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( request = GigaChatRequest(
model=model, model=model,

143
app.py Normal file
View File

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

32
docker-compose.yml Normal file
View File

@@ -0,0 +1,32 @@
version: '3.8'
services:
ai-agents:
ports:
- "8001:8000"
build:
context: .
dockerfile: Dockerfile
container_name: new-planet-ai-agents
environment:
- REDIS_URL=redis://newplanet-redis:6379/0
- GIGACHAT_CLIENT_ID=${GIGACHAT_CLIENT_ID}
- GIGACHAT_CLIENT_SECRET=${GIGACHAT_CLIENT_SECRET}
- PYTHONUNBUFFERED=1
- PYTHONDONTWRITEBYTECODE=1
- PYTHONPATH=/app
volumes:
- ./agents:/app/agents
- ./models:/app/models
- ./services:/app/services
- ./prompts:/app/prompts
- ./scripts:/app/scripts
- ./app.py:/app/app.py
networks:
- new-planet-network
# Запуск FastAPI сервера
command: uvicorn app:app --host 0.0.0.0 --port 8000
networks:
new-planet-network:
external: true

View File

@@ -41,9 +41,9 @@ class GigaChatUsage(BaseModel):
class GigaChatResponse(BaseModel): class GigaChatResponse(BaseModel):
"""Ответ от GigaChat API.""" """Ответ от GigaChat API."""
id: str id: Optional[str] = None
object: str object: Optional[str] = None
created: int created: Optional[int] = None
model: str model: str
choices: List[GigaChatChoice] choices: List[GigaChatChoice]
usage: GigaChatUsage usage: GigaChatUsage

View File

@@ -1,5 +1,7 @@
"""Pydantic модели для расписаний.""" """Pydantic модели для расписаний."""
from datetime import date from __future__ import annotations
from datetime import date as date_type
from typing import List, Optional from typing import List, Optional
from uuid import UUID from uuid import UUID
@@ -24,7 +26,7 @@ class Schedule(BaseModel):
id: Optional[UUID] = None id: Optional[UUID] = None
title: str = Field(..., description="Название расписания") title: str = Field(..., description="Название расписания")
date: date = Field(..., description="Дата расписания") date: date_type = Field(..., description="Дата расписания")
tasks: List[Task] = Field(default_factory=list, description="Список заданий") tasks: List[Task] = Field(default_factory=list, description="Список заданий")
user_id: Optional[UUID] = None user_id: Optional[UUID] = None
created_at: Optional[str] = None created_at: Optional[str] = None
@@ -35,6 +37,6 @@ class ScheduleGenerateRequest(BaseModel):
child_age: int = Field(..., ge=1, le=18, description="Возраст ребенка") child_age: int = Field(..., ge=1, le=18, description="Возраст ребенка")
preferences: List[str] = Field(default_factory=list, 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="Существующие задания для учета") existing_tasks: Optional[List[str]] = Field(None, description="Существующие задания для учета")

View File

@@ -7,6 +7,7 @@ CHAT_SYSTEM_PROMPT = """Ты планета Земля - помощник для
- Помогать понять задания - Помогать понять задания
- Мотивировать и поддерживать - Мотивировать и поддерживать
- Объяснять простым языком - Объяснять простым языком
- Расписывать действия пошагово
Правила общения: Правила общения:
- Используй короткие предложения - Используй короткие предложения

View File

@@ -37,18 +37,37 @@ SCHEDULE_GENERATION_PROMPT = """Ты планета Земля, друг дет
Категории заданий: утренняя_рутина, обучение, игра, отдых, вечерняя_рутина Категории заданий: утренняя_рутина, обучение, игра, отдых, вечерняя_рутина
""" """
SCHEDULE_UPDATE_PROMPT = """Ты планета Земля. Обнови расписание с учетом следующих изменений: SCHEDULE_UPDATE_PROMPT = """Ты планета Земля, друг детей с расстройством аутистического спектра (РАС).
Существующее расписание: Текущее расписание:
{existing_schedule} {existing_schedule}
Запрос пользователя: Запрос пользователя: {user_request}
{user_request}
Верни ТОЛЬКО валидный JSON с обновленным расписанием: Обнови расписание согласно запросу. Сохрани структуру и логику расписания, но внеси необходимые изменения.
Важные правила при обновлении:
1. Сохраняй простоту и понятность заданий
2. Поддерживай четкие временные рамки
3. Избегай резких переходов между активностями
4. Включи время на отдых между заданиями
5. Учитывай особенности РАС
Верни ТОЛЬКО валидный JSON формат без дополнительного текста:
{{ {{
"title": "Название расписания", "title": "Название расписания",
"tasks": [...] "description": "Краткое описание",
"tasks": [
{{
"title": "Название задания",
"description": "Подробное описание",
"duration_minutes": 30,
"category": "утренняя_рутина",
"order": 0
}}
]
}} }}
Категории заданий: утренняя_рутина, обучение, игра, отдых, вечерняя_рутина
""" """

View File

@@ -14,6 +14,8 @@ numpy
aiohttp aiohttp
redis redis
Pillow Pillow
fastapi
uvicorn[standard]
# Dev # Dev
pytest pytest

View File

@@ -12,7 +12,8 @@ class CacheService:
"""Сервис для работы с Redis кэшем.""" """Сервис для работы с Redis кэшем."""
def __init__(self, redis_url: Optional[str] = None): 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 self._client: Optional[redis.Redis] = None
async def _get_client(self) -> redis.Redis: async def _get_client(self) -> redis.Redis:

View File

@@ -1,9 +1,13 @@
"""Управление токенами GigaChat.""" """Управление токенами GigaChat."""
import base64
import os import os
import time import time
import uuid
from typing import Optional from typing import Optional
from urllib.parse import urlencode
import aiohttp import aiohttp
from aiohttp import FormData
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv()
@@ -17,10 +21,13 @@ class TokenManager:
client_id: Optional[str] = None, client_id: Optional[str] = None,
client_secret: Optional[str] = None, client_secret: Optional[str] = None,
auth_url: Optional[str] = None, auth_url: Optional[str] = None,
credentials: Optional[str] = None,
): ):
self.client_id = client_id or os.getenv("GIGACHAT_CLIENT_ID") # Приоритет: переданные параметры > переменные окружения > .env файл
self.client_secret = client_secret or os.getenv("GIGACHAT_CLIENT_SECRET") self.credentials = credentials or os.environ.get("GIGACHAT_CREDENTIALS") or os.getenv("GIGACHAT_CREDENTIALS")
self.auth_url = auth_url or os.getenv( 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" "GIGACHAT_AUTH_URL", "https://ngw.devices.sberbank.ru:9443/api/v2/oauth"
) )
self._access_token: Optional[str] = None 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: if not force_refresh and self._access_token and time.time() < self._expires_at:
return self._access_token return self._access_token
async with aiohttp.ClientSession() as session: # Определяем, какой вариант используется: готовый ключ или client_id/client_secret
auth = aiohttp.BasicAuth(self.client_id, self.client_secret) if self.credentials:
async with session.post( # Используем готовый ключ авторизации (уже закодированный в Base64)
self.auth_url, # Убираем префикс "Basic " если он есть
auth=auth, credentials_key = self.credentials.strip().replace('\n', '').replace('\r', '')
data={"scope": "GIGACHAT_API_PERS"}, if credentials_key.startswith('Basic '):
) as response: credentials_key = credentials_key[6:]
if response.status != 200:
error_text = await response.text()
raise Exception(f"Failed to get token: {response.status} - {error_text}")
data = await response.json() connector = aiohttp.TCPConnector(ssl=False)
self._access_token = data["access_token"] async with aiohttp.ClientSession(connector=connector) as session:
# Токен обычно действителен 30 минут, обновляем за 5 минут до истечения headers = {
expires_in = data.get("expires_in", 1800) "Authorization": f"Basic {credentials_key}",
self._expires_at = time.time() + expires_in - 300 "Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
"RqUID": str(uuid.uuid4())
}
return self._access_token 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"]
expires_in = data.get("expires_in", 1800)
self._expires_at = time.time() + expires_in - 300
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: def is_token_valid(self) -> bool:
"""Проверить, действителен ли текущий токен.""" """Проверить, действителен ли текущий токен."""