This commit is contained in:
2025-12-13 14:39:50 +03:00
commit b666cdcb95
79 changed files with 3081 additions and 0 deletions

View File

@@ -0,0 +1,22 @@
from app.services.auth_service import auth_service, AuthService
from app.services.cache_service import cache_service, CacheService
from app.services.storage_service import storage_service, StorageService
from app.services.gigachat_service import gigachat_service, GigaChatService
from app.services.chat_service import chat_service, ChatService
from app.services.schedule_generator import schedule_generator, ScheduleGenerator
__all__ = [
"auth_service",
"AuthService",
"cache_service",
"CacheService",
"storage_service",
"StorageService",
"gigachat_service",
"GigaChatService",
"chat_service",
"ChatService",
"schedule_generator",
"ScheduleGenerator",
]

View File

@@ -0,0 +1,73 @@
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.crud import user as crud_user
from app.core.security import verify_password, create_access_token, create_refresh_token, decode_token
from app.schemas.user import UserCreate
from app.schemas.token import Token
from datetime import timedelta
class AuthService:
async def authenticate(
self,
db: AsyncSession,
email: str,
password: str
) -> Optional[Token]:
"""Аутентификация пользователя"""
db_user = await crud_user.get_by_email(db, email)
if not db_user:
return None
if not verify_password(password, db_user.hashed_password):
return None
access_token = create_access_token(
data={"sub": db_user.id, "email": db_user.email}
)
refresh_token = create_refresh_token(
data={"sub": db_user.id, "email": db_user.email}
)
return Token(
access_token=access_token,
refresh_token=refresh_token,
token_type="bearer"
)
async def register(
self,
db: AsyncSession,
user_in: UserCreate
):
"""Регистрация нового пользователя"""
# Проверка существования пользователя
existing_user = await crud_user.get_by_email(db, user_in.email)
if existing_user:
raise ValueError("User with this email already exists")
from app.core.security import get_password_hash
hashed_password = get_password_hash(user_in.password)
db_user = await crud_user.create(db, user_in, hashed_password)
return db_user
def verify_token(self, token: str) -> Optional[dict]:
"""Проверка токена"""
payload = decode_token(token)
if payload and payload.get("type") == "access":
return payload
return None
def refresh_access_token(self, refresh_token: str) -> Optional[str]:
"""Обновление access token"""
payload = decode_token(refresh_token)
if payload and payload.get("type") == "refresh":
return create_access_token(
data={"sub": payload.get("sub"), "email": payload.get("email")}
)
return None
auth_service = AuthService()

View File

@@ -0,0 +1,70 @@
import json
from typing import Optional, List, Dict, Any
import redis.asyncio as redis
from app.core.config import settings
class CacheService:
def __init__(self):
self.redis_client: Optional[redis.Redis] = None
async def connect(self):
"""Подключение к Redis"""
self.redis_client = await redis.from_url(
settings.redis_url,
encoding="utf-8",
decode_responses=True
)
async def disconnect(self):
"""Отключение от Redis"""
if self.redis_client:
await self.redis_client.close()
async def get(self, key: str) -> Optional[str]:
"""Получить значение по ключу"""
if not self.redis_client:
await self.connect()
return await self.redis_client.get(key)
async def set(self, key: str, value: str, expire: int = 3600):
"""Установить значение с TTL"""
if not self.redis_client:
await self.connect()
await self.redis_client.setex(key, expire, value)
async def delete(self, key: str):
"""Удалить ключ"""
if not self.redis_client:
await self.connect()
await self.redis_client.delete(key)
async def get_conversation_context(
self,
conversation_id: str
) -> List[Dict[str, Any]]:
"""Получить контекст разговора"""
key = f"conversation:{conversation_id}"
data = await self.get(key)
if data:
return json.loads(data)
return []
async def save_conversation_context(
self,
conversation_id: str,
context: List[Dict[str, Any]],
expire: int = 86400 * 7 # 7 дней
):
"""Сохранить контекст разговора"""
key = f"conversation:{conversation_id}"
await self.set(key, json.dumps(context), expire=expire)
async def cache_token(self, token: str, expire: int = 1800):
"""Кэшировать токен (для rate limiting)"""
key = f"token:{token}"
await self.set(key, "1", expire=expire)
cache_service = CacheService()

View File

@@ -0,0 +1,107 @@
import uuid
import json
from typing import Optional, List, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.gigachat_service import gigachat_service
from app.services.cache_service import cache_service
from app.models.ai_conversation import AIConversation
from app.schemas.ai import ChatRequest, ChatResponse
from app.core.config import settings
# Персона "Планета Земля"
EARTH_PERSONA = """Ты планета Земля - анимированный персонаж и друг детей с расстройством аутистического спектра (РАС).
Твоя личность:
- Добрая, терпеливая, понимающая
- Говоришь простым языком
- Используешь эмодзи 🌍✨
- Поощряешь любые достижения
- Даешь четкие инструкции
Особенности общения:
- Короткие предложения
- Избегай сложных метафор
- Подтверждай понимание
- Задавай уточняющие вопросы
- Будь позитивным и поддерживающим"""
class ChatService:
async def chat(
self,
db: AsyncSession,
user_id: str,
request: ChatRequest
) -> ChatResponse:
"""Обработка чата с ИИ-агентом"""
# Получить или создать conversation_id
conversation_id = request.conversation_id or str(uuid.uuid4())
# Загрузить контекст из кэша
context = await cache_service.get_conversation_context(conversation_id)
# Добавить персону в начало, если контекст пустой
if not context:
context.append({
"role": "system",
"content": EARTH_PERSONA
})
# Добавить сообщение пользователя
context.append({
"role": "user",
"content": request.message
})
# Отправить запрос в GigaChat
try:
result = await gigachat_service.chat(
message=request.message,
context=context[:-1], # Без последнего сообщения (оно добавится автоматически)
model=settings.GIGACHAT_MODEL_CHAT
)
# Извлечь ответ
choices = result.get("choices", [])
if not choices:
raise Exception("No response from GigaChat")
response_text = choices[0].get("message", {}).get("content", "")
tokens_used = result.get("usage", {}).get("total_tokens")
model_used = result.get("model")
# Добавить ответ в контекст
context.append({
"role": "assistant",
"content": response_text
})
# Сохранить контекст в кэш
await cache_service.save_conversation_context(conversation_id, context)
# Сохранить в БД
conversation = AIConversation(
user_id=user_id,
conversation_id=conversation_id,
message=request.message,
response=response_text,
tokens_used=tokens_used,
model=model_used,
context=context
)
db.add(conversation)
await db.commit()
return ChatResponse(
response=response_text,
conversation_id=conversation_id,
tokens_used=tokens_used,
model=model_used
)
except Exception as e:
raise Exception(f"Chat service error: {str(e)}")
chat_service = ChatService()

View File

@@ -0,0 +1,102 @@
import aiohttp
import base64
import uuid
import time
from typing import Optional, List, Dict, Any
from app.core.config import settings
class GigaChatService:
def __init__(self):
self.access_token: Optional[str] = None
self.token_expires_at: Optional[float] = None
async def _get_token(self) -> str:
"""Получить OAuth токен"""
# Проверяем, не истек ли токен (оставляем запас 60 секунд)
if self.access_token and self.token_expires_at:
if time.time() < (self.token_expires_at - 60):
return self.access_token
credentials = f"{settings.GIGACHAT_CLIENT_ID}:{settings.GIGACHAT_CLIENT_SECRET}"
encoded_credentials = base64.b64encode(credentials.encode()).decode()
headers = {
"Authorization": f"Basic {encoded_credentials}",
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
"RqUID": str(uuid.uuid4())
}
data = {"scope": "GIGACHAT_API_PERS"}
async with aiohttp.ClientSession() as session:
async with session.post(
settings.GIGACHAT_AUTH_URL,
headers=headers,
data=data
) as response:
if response.status != 200:
raise Exception(f"Failed to get token: {response.status}")
result = await response.json()
self.access_token = result.get("access_token")
expires_in = result.get("expires_at", 1800)
# expires_at может быть timestamp или количество секунд
if expires_in > 1000000000: # Это timestamp
self.token_expires_at = expires_in
else: # Это количество секунд
self.token_expires_at = time.time() + expires_in
return self.access_token
async def chat(
self,
message: str,
context: Optional[List[Dict[str, Any]]] = None,
model: str = None
) -> Dict[str, Any]:
"""Отправить сообщение в GigaChat"""
token = await self._get_token()
model = model or settings.GIGACHAT_MODEL_CHAT
messages = context or []
messages.append({"role": "user", "content": message})
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
payload = {
"model": model,
"messages": messages,
"temperature": 0.7,
"max_tokens": 2000
}
async with aiohttp.ClientSession() as session:
async with session.post(
f"{settings.GIGACHAT_BASE_URL}/chat/completions",
headers=headers,
json=payload
) as response:
if response.status != 200:
error_text = await response.text()
raise Exception(f"GigaChat API error: {response.status} - {error_text}")
result = await response.json()
return result
async def generate_text(
self,
prompt: str,
model: str = None
) -> str:
"""Генерация текста по промпту"""
result = await self.chat(prompt, model=model)
return result.get("choices", [{}])[0].get("message", {}).get("content", "")
gigachat_service = GigaChatService()

View File

@@ -0,0 +1,130 @@
import json
from typing import List, Dict, Any, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.gigachat_service import gigachat_service
from app.core.config import settings
from app.crud import schedule as crud_schedule, task as crud_task
from app.schemas.schedule import ScheduleCreate
from app.schemas.task import TaskCreate
from datetime import date
SCHEDULE_GENERATION_PROMPT = """Ты планета Земля, друг детей с расстройством аутистического спектра (РАС).
Создай расписание на {date} для ребенка {age} лет.
Предпочтения ребенка: {preferences}
Важные правила:
1. Задания должны быть простыми и понятными
2. Каждое задание имеет четкие временные рамки
3. Используй визуальные описания
4. Избегай резких переходов между активностями
5. Включи время на отдых
6. Учитывай особенности РАС
Верни ТОЛЬКО валидный JSON формат без дополнительного текста:
{{
"title": "Название расписания",
"description": "Краткое описание",
"tasks": [
{{
"title": "Название задания",
"description": "Подробное описание",
"duration_minutes": 30,
"category": "утренняя_рутина",
"order": 0
}}
]
}}"""
class ScheduleGenerator:
async def generate(
self,
db: AsyncSession,
user_id: str,
child_age: int,
preferences: List[str],
schedule_date: str,
description: Optional[str] = None
) -> Dict[str, Any]:
"""Генерация расписания через GigaChat"""
# Формируем промпт
prompt = SCHEDULE_GENERATION_PROMPT.format(
date=schedule_date,
age=child_age,
preferences=", ".join(preferences) if preferences else "нет особых предпочтений"
)
if description:
prompt += f"\n\nДополнительная информация: {description}"
# Генерируем через GigaChat
try:
response_text = await gigachat_service.generate_text(
prompt=prompt,
model=settings.GIGACHAT_MODEL_SCHEDULE
)
# Парсим JSON ответ
# Убираем markdown код блоки если есть
if "```json" in response_text:
response_text = response_text.split("```json")[1].split("```")[0].strip()
elif "```" in response_text:
response_text = response_text.split("```")[1].split("```")[0].strip()
schedule_data = json.loads(response_text)
# Создаем расписание в БД
schedule_create = ScheduleCreate(
title=schedule_data.get("title", f"Расписание на {schedule_date}"),
date=date.fromisoformat(schedule_date),
description=schedule_data.get("description") or description
)
db_schedule = await crud_schedule.create(
db,
{
**schedule_create.model_dump(),
"user_id": user_id
}
)
# Создаем задачи
tasks_data = schedule_data.get("tasks", [])
for task_data in tasks_data:
task_create = TaskCreate(
schedule_id=db_schedule.id,
title=task_data.get("title"),
description=task_data.get("description"),
duration_minutes=task_data.get("duration_minutes", 30),
category=task_data.get("category"),
order=task_data.get("order", 0)
)
await crud_task.create(db, task_create.model_dump())
await db.refresh(db_schedule)
return {
"schedule_id": db_schedule.id,
"title": db_schedule.title,
"tasks": [
{
"title": task.title,
"description": task.description,
"duration_minutes": task.duration_minutes,
"category": task.category,
"order": task.order
}
for task in db_schedule.tasks
]
}
except json.JSONDecodeError as e:
raise Exception(f"Failed to parse GigaChat response as JSON: {str(e)}")
except Exception as e:
raise Exception(f"Schedule generation error: {str(e)}")
schedule_generator = ScheduleGenerator()

View File

@@ -0,0 +1,80 @@
import uuid
from typing import Optional, BinaryIO
from botocore.exceptions import ClientError
import boto3
from app.core.config import settings
class StorageService:
def __init__(self):
self.client = None
self._initialize_client()
def _initialize_client(self):
"""Инициализация S3/MinIO клиента"""
self.client = boto3.client(
"s3",
endpoint_url=f"{'https' if settings.STORAGE_USE_SSL else 'http'}://{settings.STORAGE_ENDPOINT}",
aws_access_key_id=settings.STORAGE_ACCESS_KEY,
aws_secret_access_key=settings.STORAGE_SECRET_KEY,
region_name=settings.STORAGE_REGION,
use_ssl=settings.STORAGE_USE_SSL,
verify=False # Для MinIO в dev
)
async def upload_file(
self,
file_obj: BinaryIO,
filename: str,
content_type: str = "image/jpeg"
) -> str:
"""Загрузить файл в хранилище"""
file_key = f"{uuid.uuid4()}_{filename}"
try:
self.client.upload_fileobj(
file_obj,
settings.STORAGE_BUCKET,
file_key,
ExtraArgs={"ContentType": content_type}
)
# Формируем URL
url = f"{'https' if settings.STORAGE_USE_SSL else 'http'}://{settings.STORAGE_ENDPOINT}/{settings.STORAGE_BUCKET}/{file_key}"
return url
except ClientError as e:
raise Exception(f"Failed to upload file: {str(e)}")
async def delete_file(self, file_key: str) -> bool:
"""Удалить файл из хранилища"""
try:
# Извлекаем ключ из URL если передан полный URL
if "/" in file_key:
file_key = file_key.split("/")[-1]
self.client.delete_object(
Bucket=settings.STORAGE_BUCKET,
Key=file_key
)
return True
except ClientError:
return False
async def get_file_url(self, file_key: str) -> Optional[str]:
"""Получить URL файла"""
try:
if "/" in file_key:
file_key = file_key.split("/")[-1]
url = self.client.generate_presigned_url(
"get_object",
Params={"Bucket": settings.STORAGE_BUCKET, "Key": file_key},
ExpiresIn=3600
)
return url
except ClientError:
return None
storage_service = StorageService()