Compare commits

..

8 Commits

Author SHA1 Message Date
95f87c253c Merge pull request 'Обновлены настройки для интеграции с AI-agent сервисом. Изменены переменные окружения в .env, добавлен путь к .env в конфигурации, обновлен GigaChatSe…' (#3) from backendFixes into master
Reviewed-on: #3
2025-12-24 02:00:29 +03:00
32bfec2074 Обновлены настройки для интеграции с AI-agent сервисом. Изменены переменные окружения в .env, добавлен путь к .env в конфигурации, обновлен GigaChatService для работы через AI-agent. Также исправлены запросы в ScheduleGenerator для корректной загрузки задач. Обновлен docker-compose для подключения к AI-agent сервису. 2025-12-24 01:59:39 +03:00
a649fb1192 Изменения:
-добавлены нетворки в докер композ
-исправлен рутинг (баг пайчарма)
-запросы к ии агентам не проходят из-за ссл сертификата (пробовали отключить, но пока не выходит, нужно доделать)
2025-12-19 00:57:13 +03:00
24f4ce118f Обновить new-planet-backend/requirements.txt 2025-12-18 17:18:04 +03:00
c256012a69 Merge pull request 'Refactored:' (#2) from feature/projectUpped into master
Reviewed-on: #2
2025-12-18 17:08:16 +03:00
4bbe086cec Refactored:
- пофикшен баг с авторизацией;
- поменен README.md, более подробно описан запуск проекта;
- починен .env для проекта.
Checked:
- docker-compose работает;
- auth работает;
- чат с нейросетью работает, но кидает 400 из за NEWPLANET-AI-AGENTS,нужно настроить подключение.
2025-12-18 17:07:33 +03:00
4cb6043931 Merge pull request 'Refactored:' (#1) from feature/projectUpped into master
Reviewed-on: #1
2025-12-18 14:33:08 +03:00
e04933b9c1 Refactored:
- пофикшен баг с авторизацией;
- поменен README.md, более подробно описан запуск проекта;
- починен .env для проекта.
Checked:
- docker-compose работает;
- auth работает;
- чат с нейросетью работает, но кидает 400 из за NEWPLANET-AI-AGENTS,нужно настроить подключение.
2025-12-18 14:14:04 +03:00
77 changed files with 2235 additions and 112 deletions

5
.idea/misc.xml generated
View File

@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ProjectRootManager" version="2" languageLevel="JDK_23" default="true" project-jdk-name="openjdk-23" project-jdk-type="JavaSDK"> <component name="Black">
<output url="file://$PROJECT_DIR$/out" /> <option name="sdkName" value="Python 3.14 (New-planet-api)" />
</component> </component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.14 (New-planet-api)" project-jdk-type="Python SDK" />
</project> </project>

View File

@@ -2,8 +2,11 @@
<module type="JAVA_MODULE" version="4"> <module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true"> <component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output /> <exclude-output />
<content url="file://$MODULE_DIR$" /> <content url="file://$MODULE_DIR$">
<orderEntry type="inheritedJdk" /> <sourceFolder url="file://$MODULE_DIR$/new-planet-backend" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.14 (New-planet-api)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />
</component> </component>
</module> </module>

36
logs/app.log Normal file
View File

@@ -0,0 +1,36 @@
2025-12-23 21:37:30 - root - INFO - Starting up...
2025-12-23 21:37:30 - root - INFO - Starting up...
2025-12-23 21:39:37 - root - INFO - Shutting down...
2025-12-23 21:39:37 - root - INFO - Shutting down...
2025-12-23 21:39:42 - root - INFO - Starting up...
2025-12-23 21:39:42 - root - INFO - Starting up...
2025-12-23 22:07:40 - root - INFO - Shutting down...
2025-12-23 22:07:40 - root - INFO - Shutting down...
2025-12-23 22:07:45 - root - INFO - Starting up...
2025-12-23 22:07:45 - root - INFO - Starting up...
2025-12-23 22:12:42 - root - INFO - Shutting down...
2025-12-23 22:12:42 - root - INFO - Shutting down...
2025-12-23 22:12:47 - root - INFO - Starting up...
2025-12-23 22:12:47 - root - INFO - Starting up...
2025-12-23 22:23:22 - root - INFO - Shutting down...
2025-12-23 22:23:22 - root - INFO - Shutting down...
2025-12-23 22:23:27 - root - INFO - Starting up...
2025-12-23 22:23:27 - root - INFO - Starting up...
2025-12-23 22:52:47 - root - INFO - Shutting down...
2025-12-23 22:52:47 - root - INFO - Shutting down...
2025-12-23 22:52:54 - root - INFO - Starting up...
2025-12-23 22:52:54 - root - INFO - Starting up...
2025-12-23 23:12:01 - root - INFO - Shutting down...
2025-12-23 23:12:01 - root - INFO - Shutting down...
2025-12-23 23:12:07 - root - INFO - Starting up...
2025-12-23 23:12:07 - root - INFO - Starting up...
2025-12-23 23:15:05 - root - INFO - Shutting down...
2025-12-23 23:15:05 - root - INFO - Shutting down...
2025-12-23 23:15:10 - root - INFO - Starting up...
2025-12-23 23:15:10 - root - INFO - Starting up...
2025-12-24 00:01:39 - root - INFO - Shutting down...
2025-12-24 00:01:39 - root - INFO - Shutting down...
2025-12-24 00:01:45 - root - INFO - Starting up...
2025-12-24 00:01:45 - root - INFO - Starting up...
2025-12-24 01:38:58 - root - INFO - Shutting down...
2025-12-24 01:38:58 - root - INFO - Shutting down...

View File

@@ -1,7 +1,33 @@
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/newplanet 
REDIS_URL=redis://localhost:6379 GIGACHAT_CLIENT_ID=019966f0-5781-76e6-a84f-ec7de158188a
SECRET_KEY=your-secret-key-here GIGACHAT_CLIENT_SECRET=MDE5OTY2ZjAtNTc4MS03NmU2LWE4NGYtZWM3ZGUxNTgxODhhOjI3MDMxZjIxLWY3NWYtNGI4NS05MzM1LTI4ZDYyOWM3MmM0MA==
GIGACHAT_API_KEY=your-gigachat-api-key GIGACHAT_AUTH_URL=https://ngw.devices.sberbank.ru:9443/api/v2/oauth
MINIO_ENDPOINT=localhost:9000 GIGACHAT_BASE_URL=https://gigachat.devices.sberbank.ru/api/v1
MINIO_ACCESS_KEY=minioadmin GIGACHAT_MODEL_CHAT=GigaChat-2-Lite
MINIO_SECRET_KEY=minioadmin GIGACHAT_MODEL_SCHEDULE=GigaChat-2-Pro
# Security
SECRET_KEY=3db8542397edddbd6162ad823157e36f8d47232aa646725d4799266229ba7aa4
# Database
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=newplanet
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_DB=0
# Storage
STORAGE_ENDPOINT=localhost:9000
STORAGE_ACCESS_KEY=minioadmin
STORAGE_SECRET_KEY=minioadmin
STORAGE_BUCKET=new-planet-images
STORAGE_USE_SSL=false
STORAGE_REGION=us-east-1
# Agents
AI_AGENT_BASE_URL=http://localhost:8001

View File

@@ -23,30 +23,37 @@ Backend API для мобильного приложения **Новая Пла
### Установка ### Установка
1. Клонируйте репозиторий 1. Клонируйте репозиторий
2. Установите зависимости: 2. Установить окружение ("python -m venv venv")
3. Установите зависимости:
```bash ```bash
pip install -r requirements.txt pip install -r requirements.txt
``` ```
3. Настройте `.env`: 4. Настройте `.env`:
```bash ```bash
cp .env.example .env cp .env.example .env
#В целом вам нужно поменять GIGACHAT API секцию, JWT Secret key сгенерить, просто в поисковике генератор на 256 байт сделаете JWT
#Для гигачата логинетесь, дергаете от туда CLIENT_ID и SECRET KEY
# Отредактируйте .env с вашими настройками # Отредактируйте .env с вашими настройками
``` ```
4. Запустите инфраструктуру (Docker): 5. Запустите инфраструктуру (Docker):
```bash ```bash
docker-compose -f docker/docker-compose.yml up -d docker-compose -f docker/docker-compose.yml up -d
# или используйте вариант ниже,но лучше вариант выше для избежания непредвиденного
#также напоминаю что вам необходим сам запущенный докер чтобы тестировать локально
docker-compose up
``` ```
5. Примените миграции: 6. Примените миграции:
```bash ```bash
alembic upgrade head alembic upgrade head
``` ```
6. Запустите сервер: 7. Запустите сервер:
```bash ```bash
uvicorn app.main:app --reload uvicorn app.main:app --reload
# если не запустилось, проверяйте есть ли .venv(установлено ли окружение для питона), также попробуйте в венве uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
``` ```
API доступен на `http://localhost:8000` API доступен на `http://localhost:8000`
@@ -60,6 +67,9 @@ Swagger UI: `http://localhost:8000/docs`
- `POST /api/v1/auth/refresh` - Обновление токена - `POST /api/v1/auth/refresh` - Обновление токена
- `GET /api/v1/auth/me` - Текущий пользователь - `GET /api/v1/auth/me` - Текущий пользователь
### Для написания всех запросов ниже:
Не забывайте авторизоваться в сваггере, сверху кнопка Authorize
### Schedules ### Schedules
- `GET /api/v1/schedules` - Список расписаний - `GET /api/v1/schedules` - Список расписаний
- `POST /api/v1/schedules` - Создать расписание - `POST /api/v1/schedules` - Создать расписание
@@ -101,6 +111,6 @@ Swagger UI: `http://localhost:8000/docs`
## 🔗 Связанные репозитории ## 🔗 Связанные репозитории
- **Frontend (Android)** — [new-planet-android](https://github.com/your-org/new-planet-android) - **Frontend (Android)** — [new-planet-android](https://git.bro-js.ru/Glevel/New-planet-app.git)
- **AI Agents** — [new-planet-ai-agents](https://github.com/your-org/new-planet-ai-agents) - **AI Agents** — [new-planet-ai-agents](https://git.bro-js.ru/Glevel/New-planet-ai-agent.git)

View File

@@ -1,9 +1,8 @@
from logging.config import fileConfig from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool from sqlalchemy import pool
from alembic import context from alembic import context
import asyncio import asyncio
from sqlalchemy.ext.asyncio import AsyncEngine from sqlalchemy.ext.asyncio import create_async_engine
# this is the Alembic Config object # this is the Alembic Config object
config = context.config config = context.config
@@ -22,12 +21,12 @@ target_metadata = Base.metadata
def get_url(): def get_url():
"""Получить URL БД""" """Получить URL БД"""
return settings.database_url.replace("+asyncpg", "") return settings.database_url
def run_migrations_offline() -> None: def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.""" """Run migrations in 'offline' mode."""
url = get_url() url = get_url().replace("+asyncpg", "")
context.configure( context.configure(
url=url, url=url,
target_metadata=target_metadata, target_metadata=target_metadata,
@@ -48,15 +47,9 @@ def do_run_migrations(connection):
async def run_migrations_online() -> None: async def run_migrations_online() -> None:
"""Run migrations in 'online' mode.""" """Run migrations in 'online' mode."""
configuration = config.get_section(config.config_ini_section) connectable = create_async_engine(
configuration["sqlalchemy.url"] = get_url() get_url(),
connectable = AsyncEngine(
engine_from_config(
configuration,
prefix="sqlalchemy.",
poolclass=pool.NullPool, poolclass=pool.NullPool,
future=True,
)
) )
async with connectable.connect() as connection: async with connectable.connect() as connection:

View File

@@ -0,0 +1,121 @@
"""Initial migration
Revision ID: 001_initial
Revises:
Create Date: 2024-01-01 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '001_initial'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# Create users table (enum will be created automatically by SQLAlchemy)
op.create_table(
'users',
sa.Column('id', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('email', sa.String(length=255), nullable=False),
sa.Column('hashed_password', sa.String(length=255), nullable=False),
sa.Column('role', postgresql.ENUM('CHILD', 'PARENT', 'EDUCATOR', name='userrole'), nullable=False),
sa.Column('full_name', sa.String(length=255), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_users_email', 'users', ['email'], unique=True)
# Create schedules table
op.create_table(
'schedules',
sa.Column('id', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('user_id', sa.String(), nullable=False),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('date', sa.Date(), nullable=False),
sa.Column('description', sa.String(length=1000), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_schedules_user_id', 'schedules', ['user_id'])
op.create_index('ix_schedules_date', 'schedules', ['date'])
# Create tasks table
op.create_table(
'tasks',
sa.Column('id', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('schedule_id', sa.String(), nullable=False),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('image_url', sa.String(length=500), nullable=True),
sa.Column('duration_minutes', sa.Integer(), nullable=False, server_default='30'),
sa.Column('completed', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('order', sa.Integer(), nullable=False, server_default='0'),
sa.Column('category', sa.String(length=100), nullable=True),
sa.ForeignKeyConstraint(['schedule_id'], ['schedules.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_tasks_schedule_id', 'tasks', ['schedule_id'])
# Create rewards table
op.create_table(
'rewards',
sa.Column('id', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('user_id', sa.String(), nullable=False),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('image_url', sa.String(length=500), nullable=True),
sa.Column('points_required', sa.Integer(), nullable=False, server_default='1'),
sa.Column('is_claimed', sa.Boolean(), nullable=False, server_default='false'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_rewards_user_id', 'rewards', ['user_id'])
# Create ai_conversations table
op.create_table(
'ai_conversations',
sa.Column('id', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('user_id', sa.String(), nullable=False),
sa.Column('conversation_id', sa.String(), nullable=False),
sa.Column('message', sa.Text(), nullable=False),
sa.Column('response', sa.Text(), nullable=False),
sa.Column('tokens_used', sa.Integer(), nullable=True),
sa.Column('model', sa.String(length=100), nullable=True),
sa.Column('context', postgresql.JSON(astext_type=sa.Text()), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('conversation_id')
)
op.create_index('ix_ai_conversations_user_id', 'ai_conversations', ['user_id'])
op.create_index('ix_ai_conversations_conversation_id', 'ai_conversations', ['conversation_id'])
def downgrade() -> None:
op.drop_index('ix_ai_conversations_conversation_id', table_name='ai_conversations')
op.drop_index('ix_ai_conversations_user_id', table_name='ai_conversations')
op.drop_table('ai_conversations')
op.drop_index('ix_rewards_user_id', table_name='rewards')
op.drop_table('rewards')
op.drop_index('ix_tasks_schedule_id', table_name='tasks')
op.drop_table('tasks')
op.drop_index('ix_schedules_date', table_name='schedules')
op.drop_index('ix_schedules_user_id', table_name='schedules')
op.drop_table('schedules')
op.drop_index('ix_users_email', table_name='users')
op.drop_table('users')
op.execute("DROP TYPE userrole")

View File

@@ -7,7 +7,7 @@ from app.crud import user as crud_user
from app.services.auth_service import auth_service from app.services.auth_service import auth_service
from app.models.user import User from app.models.user import User
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/v1/auth/login") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
async def get_current_user( async def get_current_user(

View File

@@ -3,9 +3,27 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db from app.db.session import get_db
from app.api.deps import get_current_active_user from app.api.deps import get_current_active_user
from app.models.user import User from app.models.user import User
from app.schemas.ai import ChatRequest, ChatResponse, ScheduleGenerateRequest, ScheduleGenerateResponse from app.models.ai_conversation import AIConversation
from app.schemas.ai import (
ChatRequest,
ChatResponse,
ScheduleGenerateRequest,
ScheduleGenerateResponse,
ConversationHistory,
ConversationListItem,
ScheduleUpdateRequest,
ScheduleUpdateResponse,
RecommendationRequest,
RecommendationResponse,
)
from app.services.chat_service import chat_service from app.services.chat_service import chat_service
from app.services.schedule_generator import schedule_generator from app.services.schedule_generator import schedule_generator
from app.services.cache_service import cache_service
from app.services.gigachat_service import gigachat_service
from app.crud import schedule as crud_schedule, task as crud_task
from app.schemas.task import TaskCreate
from app.schemas.schedule import ScheduleUpdate
from app.core.config import settings
router = APIRouter() router = APIRouter()
@@ -30,7 +48,6 @@ async def chat_with_ai(
detail=f"Chat error: {str(e)}" detail=f"Chat error: {str(e)}"
) )
@router.post("/schedule/generate", response_model=ScheduleGenerateResponse) @router.post("/schedule/generate", response_model=ScheduleGenerateResponse)
async def generate_schedule_ai( async def generate_schedule_ai(
request: ScheduleGenerateRequest, request: ScheduleGenerateRequest,
@@ -58,3 +75,4 @@ async def generate_schedule_ai(
detail=f"Failed to generate schedule: {str(e)}" detail=f"Failed to generate schedule: {str(e)}"
) )

View File

@@ -1,5 +1,13 @@
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
from typing import Optional from typing import Optional
from dotenv import load_dotenv
from pathlib import Path
# Загружаем .env файл перед созданием Settings
# Ищем .env в корне проекта (на уровень выше от app/)
env_path = Path(__file__).parent.parent.parent / ".env"
# override=True гарантирует, что переменные из .env перезапишут существующие
load_dotenv(dotenv_path=env_path, override=True)
class Settings(BaseSettings): class Settings(BaseSettings):
@@ -10,15 +18,15 @@ class Settings(BaseSettings):
DEBUG: bool = False DEBUG: bool = False
# Security # Security
SECRET_KEY: str SECRET_KEY: str = "3db8542397edddbd6162ad823157e36f8d47232aa646725d4799266229ba7aa4"
ALGORITHM: str = "HS256" ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
REFRESH_TOKEN_EXPIRE_DAYS: int = 7 REFRESH_TOKEN_EXPIRE_DAYS: int = 7
# Database # Database
POSTGRES_USER: str POSTGRES_USER: str = "postgres"
POSTGRES_PASSWORD: str POSTGRES_PASSWORD: str = "postgres"
POSTGRES_DB: str POSTGRES_DB: str = "newplanet"
POSTGRES_HOST: str = "localhost" POSTGRES_HOST: str = "localhost"
POSTGRES_PORT: int = 5432 POSTGRES_PORT: int = 5432
DATABASE_URL: Optional[str] = None DATABASE_URL: Optional[str] = None
@@ -43,15 +51,22 @@ class Settings(BaseSettings):
# Storage (MinIO/S3) # Storage (MinIO/S3)
STORAGE_ENDPOINT: str = "localhost:9000" STORAGE_ENDPOINT: str = "localhost:9000"
STORAGE_ACCESS_KEY: str STORAGE_ACCESS_KEY: str = "minioadmin"
STORAGE_SECRET_KEY: str STORAGE_SECRET_KEY: str = "minioadmin"
STORAGE_BUCKET: str = "new-planet-images" STORAGE_BUCKET: str = "new-planet-images"
STORAGE_USE_SSL: bool = False STORAGE_USE_SSL: bool = False
STORAGE_REGION: str = "us-east-1" STORAGE_REGION: str = "us-east-1"
# GigaChat # AI Agent Service (внешний сервис для работы с GigaChat)
GIGACHAT_CLIENT_ID: str # URL можно переопределить через переменную окружения AI_AGENT_BASE_URL
GIGACHAT_CLIENT_SECRET: str # Для Docker сети используйте: http://ai-agent:8000 (или имя сервиса из docker-compose)
# Для локальной разработки используйте: http://localhost:8000
AI_AGENT_BASE_URL: str = "http://ai-agent:8000"
AI_AGENT_TIMEOUT: int = 120 # Таймаут в секундах
# GigaChat (оставлено для обратной совместимости, но используется через AI-agent сервис)
GIGACHAT_CLIENT_ID: str = "019966f4-1c5c-7382-9006-b84419fbe5d1"
GIGACHAT_CLIENT_SECRET: str = "MDE5OTY2ZjQtMWM1Yy03MzgyLTkwMDYtYjg0NDE5ZmJlNWQxOjJjODBmOWE2LWU4YWMtNDE4YS1iOGVkLWE4NTE0YzVkNDAwNw=="
GIGACHAT_AUTH_URL: str = "https://ngw.devices.sberbank.ru:9443/api/v2/oauth" GIGACHAT_AUTH_URL: str = "https://ngw.devices.sberbank.ru:9443/api/v2/oauth"
GIGACHAT_BASE_URL: str = "https://gigachat.devices.sberbank.ru/api/v1" GIGACHAT_BASE_URL: str = "https://gigachat.devices.sberbank.ru/api/v1"
GIGACHAT_MODEL_CHAT: str = "GigaChat-2-Lite" GIGACHAT_MODEL_CHAT: str = "GigaChat-2-Lite"
@@ -65,7 +80,9 @@ class Settings(BaseSettings):
RATE_LIMIT_PER_MINUTE: int = 60 RATE_LIMIT_PER_MINUTE: int = 60
class Config: class Config:
env_file = ".env" # Путь к .env файлу относительно корня проекта
env_file = str(env_path) if env_path.exists() else ".env"
env_file_encoding = "utf-8"
case_sensitive = True case_sensitive = True

View File

@@ -1,20 +1,54 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
from jose import JWTError, jwt from jose import JWTError, jwt
from passlib.context import CryptContext import bcrypt
from app.core.config import settings from config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool: def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Проверка пароля""" """Проверка пароля"""
return pwd_context.verify(plain_password, hashed_password) # Используем bcrypt напрямую для проверки
try:
return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8'))
except (ValueError, TypeError, AttributeError):
return False
def _truncate_password_to_72_bytes(password: str) -> str:
"""Обрезает пароль до 72 байт, корректно обрабатывая UTF-8"""
password_bytes = password.encode('utf-8')
if len(password_bytes) <= 72:
return password
# Обрезаем до 72 байт
password_bytes = password_bytes[:72]
# Удаляем неполные UTF-8 последовательности в конце
# (байты, которые начинаются с 10xxxxxx, но не являются началом символа)
while password_bytes and (password_bytes[-1] & 0xC0) == 0x80:
password_bytes = password_bytes[:-1]
return password_bytes.decode('utf-8', errors='replace')
def get_password_hash(password: str) -> str: def get_password_hash(password: str) -> str:
"""Хеширование пароля""" """Хеширование пароля"""
return pwd_context.hash(password) # bcrypt имеет ограничение в 72 байта
# Обрезаем пароль до 72 байт перед хешированием
password_bytes = password.encode('utf-8')
if len(password_bytes) > 72:
# Обрезаем до 72 байт
password_bytes = password_bytes[:72]
# Удаляем неполные UTF-8 последовательности в конце
while password_bytes and (password_bytes[-1] & 0xC0) == 0x80:
password_bytes = password_bytes[:-1]
password = password_bytes.decode('utf-8', errors='replace')
password_bytes = password.encode('utf-8')
# Используем bcrypt напрямую, чтобы избежать проблем с инициализацией passlib
salt = bcrypt.gensalt()
hashed = bcrypt.hashpw(password_bytes, salt)
return hashed.decode('utf-8')
def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str: def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:

View File

@@ -1,6 +1,5 @@
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import declarative_base
from sqlalchemy import Column, DateTime, func from sqlalchemy import Column, DateTime, func, String
from sqlalchemy.dialects.postgresql import UUID
import uuid import uuid
Base = declarative_base() Base = declarative_base()
@@ -11,7 +10,7 @@ class BaseModel(Base):
__abstract__ = True __abstract__ = True
id = Column( id = Column(
UUID(as_uuid=False), String,
primary_key=True, primary_key=True,
default=lambda: str(uuid.uuid4()), default=lambda: str(uuid.uuid4()),
nullable=False nullable=False

View File

@@ -1,5 +1,5 @@
from app.db.base import Base from base import Base
from app.db.session import engine from session import engine
from app.models import user, schedule, task, reward, ai_conversation from app.models import user, schedule, task, reward, ai_conversation

View File

@@ -3,8 +3,7 @@ from app.core.config import settings
engine = create_async_engine( engine = create_async_engine(
settings.database_url, settings.database_url,
echo=settings.DEBUG, echo=settings.DEBUG
future=True
) )
AsyncSessionLocal = async_sessionmaker( AsyncSessionLocal = async_sessionmaker(

View File

@@ -1,3 +1,18 @@
import sys
from pathlib import Path
# Добавляем корневую директорию проекта в PYTHONPATH при прямом запуске
# Это нужно, чтобы Python мог найти модуль 'app'
# Проверяем, запускается ли файл напрямую, проверяя имя скрипта
if sys.argv and len(sys.argv) > 0:
script_path = Path(sys.argv[0]).resolve()
current_file = Path(__file__).resolve()
# Если скрипт запускается напрямую (не через модуль)
if script_path == current_file or script_path.name == current_file.name:
project_root = current_file.parent.parent # new-planet-backend
if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.exceptions import HTTPException as StarletteHTTPException
@@ -73,7 +88,7 @@ if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run( uvicorn.run(
"app.main:app", "app.main:app",
host="0.0.0.0", host="127.0.0.1",
port=8000, port=8000,
reload=settings.DEBUG reload=settings.DEBUG
) )

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, String, Integer, ForeignKey, Text from sqlalchemy import Column, String, Integer, ForeignKey, Text, Boolean
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.db.base import BaseModel from app.db.base import BaseModel

View File

@@ -38,3 +38,41 @@ class ConversationHistory(BaseModel):
class Config: class Config:
from_attributes = True from_attributes = True
class ConversationListItem(BaseModel):
"""Элемент списка разговоров"""
conversation_id: str
last_message: Optional[str] = None
created_at: datetime
updated_at: datetime
message_count: int = 0
class Config:
from_attributes = True
class ScheduleUpdateRequest(BaseModel):
"""Запрос на обновление расписания через ИИ"""
user_request: str = Field(..., min_length=1, max_length=1000, description="Описание желаемых изменений")
class ScheduleUpdateResponse(BaseModel):
"""Ответ после обновления расписания"""
schedule_id: str
title: str
tasks: List[Dict[str, Any]]
tokens_used: Optional[int] = None
class RecommendationRequest(BaseModel):
"""Запрос на получение рекомендаций"""
preferences: List[str] = Field(default_factory=list, description="Предпочтения пользователя")
category: Optional[str] = Field(None, description="Категория заданий")
completed_tasks: Optional[List[str]] = Field(default_factory=list, description="Уже выполненные задания")
top_k: int = Field(5, ge=1, le=20, description="Количество рекомендаций")
class RecommendationResponse(BaseModel):
"""Ответ с рекомендациями"""
recommendations: List[Dict[str, Any]]
total: int

View File

@@ -11,7 +11,7 @@ class UserBase(BaseModel):
class UserCreate(UserBase): class UserCreate(UserBase):
password: str = Field(..., min_length=8) password: str = Field(..., min_length=8, max_length=72, description="Password must be between 8 and 72 characters")
class UserUpdate(BaseModel): class UserUpdate(BaseModel):

View File

@@ -4,6 +4,7 @@ from app.services.storage_service import storage_service, StorageService
from app.services.gigachat_service import gigachat_service, GigaChatService from app.services.gigachat_service import gigachat_service, GigaChatService
from app.services.chat_service import chat_service, ChatService from app.services.chat_service import chat_service, ChatService
from app.services.schedule_generator import schedule_generator, ScheduleGenerator from app.services.schedule_generator import schedule_generator, ScheduleGenerator
from app.services.ai_agent_client import ai_agent_client, AIAgentClient
__all__ = [ __all__ = [
"auth_service", "auth_service",
@@ -18,5 +19,7 @@ __all__ = [
"ChatService", "ChatService",
"schedule_generator", "schedule_generator",
"ScheduleGenerator", "ScheduleGenerator",
"ai_agent_client",
"AIAgentClient",
] ]

View File

@@ -0,0 +1,117 @@
import aiohttp
from typing import Optional, List, Dict, Any
from app.core.config import settings
class AIAgentClient:
"""
Клиент для взаимодействия с внешним AI-agent сервисом.
Сервис должен быть доступен в Docker сети и предоставлять следующие endpoints:
- POST /api/v1/chat - для чата с ИИ
- POST /api/v1/schedule/generate - для генерации расписаний
Примечание: Структура API endpoints может отличаться в зависимости от реализации
внешнего сервиса. При необходимости измените пути в методах этого класса.
"""
def __init__(self, base_url: Optional[str] = None):
self.base_url = base_url or settings.AI_AGENT_BASE_URL
if not self.base_url.endswith('/'):
self.base_url = self.base_url.rstrip('/')
async def chat(
self,
message: str,
conversation_id: Optional[str] = None,
context: Optional[List[Dict[str, Any]]] = None
) -> Dict[str, Any]:
"""
Отправить сообщение в чат через AI-agent сервис.
Ожидаемый формат ответа от сервиса:
{
"response": "текст ответа",
"conversation_id": "id беседы",
"tokens_used": 100,
"model": "модель"
}
или формат GigaChat API (с полем choices).
"""
url = f"{self.base_url}/api/v1/chat"
payload = {
"message": message,
}
if conversation_id:
payload["conversation_id"] = conversation_id
if context:
payload["context"] = context
async with aiohttp.ClientSession() as session:
async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=120)) as response:
if response.status != 200:
error_text = await response.text()
raise Exception(
f"AI-agent service error: HTTP {response.status} - {error_text}"
)
result = await response.json()
return result
async def generate_schedule(
self,
child_age: int,
preferences: List[str],
date: str,
description: Optional[str] = None
) -> Dict[str, Any]:
"""Сгенерировать расписание через AI-agent сервис"""
url = f"{self.base_url}/api/v1/schedule/generate"
payload = {
"child_age": child_age,
"preferences": preferences,
"date": date
}
if description:
payload["description"] = description
async with aiohttp.ClientSession() as session:
async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=120)) as response:
if response.status != 200:
error_text = await response.text()
raise Exception(
f"AI-agent service error: HTTP {response.status} - {error_text}"
)
result = await response.json()
return result
async def generate_text(
self,
prompt: str,
model: Optional[str] = None
) -> str:
"""Генерация текста по промпту через AI-agent сервис"""
# Для совместимости с текущим интерфейсом используем chat endpoint
result = await self.chat(message=prompt)
# Извлекаем текст ответа
# Предполагаем, что ответ имеет структуру ChatResponse
response_text = result.get("response", "")
if not response_text:
# Если структура другая, пытаемся извлечь из choices (как в GigaChat формате)
choices = result.get("choices", [])
if choices:
response_text = choices[0].get("message", {}).get("content", "")
return response_text
# Создаем экземпляр клиента
ai_agent_client = AIAgentClient()

View File

@@ -1,25 +1,50 @@
import os
import aiohttp import aiohttp
import base64 import base64
import uuid import uuid
import time import time
from urllib.parse import urlencode
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
from app.core.config import settings
from app.core.config import settings
from app.services.ai_agent_client import ai_agent_client
class GigaChatService: class GigaChatService:
"""
Сервис для работы с GigaChat через внешний AI-agent сервис.
Все запросы к GigaChat теперь проходят через внешний сервис.
"""
def __init__(self): def __init__(self):
self.access_token: Optional[str] = None self.access_token: Optional[str] = None
self.token_expires_at: Optional[float] = None self.token_expires_at: Optional[float] = None
async def _get_token(self) -> str: async def _get_token(self) -> str:
"""Получить OAuth токен""" """
Получить OAuth токен.
ВНИМАНИЕ: Этот метод больше не используется, так как все запросы
к GigaChat теперь проходят через внешний AI-agent сервис.
Метод оставлен для возможной обратной совместимости.
"""
# Проверяем, не истек ли токен (оставляем запас 60 секунд) # Проверяем, не истек ли токен (оставляем запас 60 секунд)
if self.access_token and self.token_expires_at: if self.access_token and self.token_expires_at:
if time.time() < (self.token_expires_at - 60): if time.time() < (self.token_expires_at - 60):
return self.access_token return self.access_token
credentials = f"{settings.GIGACHAT_CLIENT_ID}:{settings.GIGACHAT_CLIENT_SECRET}" # Проверяем наличие credentials
encoded_credentials = base64.b64encode(credentials.encode()).decode() client_id = os.getenv("GIGACHAT_CLIENT_ID")
client_secret = os.getenv("GIGACHAT_CLIENT_SECRET")
if not client_id or not client_secret:
raise Exception(
"GigaChat credentials not configured. "
"Please set GIGACHAT_CLIENT_ID and GIGACHAT_CLIENT_SECRET in .env file"
)
# Формируем credentials и кодируем в Base64 с явным указанием UTF-8
credentials = f"{client_id}:{client_secret}".strip().encode('utf-8') # Обрезаем лишние символы
encoded_credentials = base64.b64encode(credentials).decode('utf-8')
headers = { headers = {
"Authorization": f"Basic {encoded_credentials}", "Authorization": f"Basic {encoded_credentials}",
@@ -28,25 +53,61 @@ class GigaChatService:
"RqUID": str(uuid.uuid4()) "RqUID": str(uuid.uuid4())
} }
data = {"scope": "GIGACHAT_API_PERS"} # Правильно кодируем данные формы (как в рабочем примере)
form_data = {
"grant_type": "client_credentials",
"scope": "GIGACHAT_API_PERS"
}
async with aiohttp.ClientSession() as session: # Отключаем проверку SSL (только для разработки!)
# Используем ssl=False для полного отключения проверки сертификата
connector = aiohttp.TCPConnector(ssl=False)
async with aiohttp.ClientSession(connector=connector) as session:
async with session.post( async with session.post(
settings.GIGACHAT_AUTH_URL, os.getenv("GIGACHAT_BASE_URL"),
headers=headers, headers=headers,
data=data data=form_data
) as response: ) as response:
if response.status != 200: if response.status != 200:
raise Exception(f"Failed to get token: {response.status}") # Получаем детали ошибки из ответа
try:
error_body = await response.text()
# Пытаемся распарсить как JSON, если не получается - возвращаем текст
try:
error_json = await response.json()
error_detail = error_json.get("error_description") or error_json.get("error") or str(error_json)
except:
error_detail = error_body
except:
error_detail = "No error details available"
raise Exception(
f"Failed to get token: HTTP {response.status}. "
f"Error details: {error_detail}. "
f"Check your GIGACHAT_CLIENT_ID and GIGACHAT_CLIENT_SECRET_2 in .env file"
)
result = await response.json() result = await response.json()
self.access_token = result.get("access_token") self.access_token = result.get("access_token")
expires_in = result.get("expires_at", 1800) if not self.access_token:
raise Exception(f"Token not found in response: {result}")
# Обрабатываем время истечения токена (может быть expires_at или expires_in)
expires_at = result.get("expires_at")
expires_in = result.get("expires_in")
if expires_at:
# expires_at может быть timestamp или количество секунд # expires_at может быть timestamp или количество секунд
if expires_in > 1000000000: # Это timestamp if expires_at > 1000000000: # Это timestamp
self.token_expires_at = expires_in self.token_expires_at = expires_at
else: # Это количество секунд else: # Это количество секунд
self.token_expires_at = time.time() + expires_at
elif expires_in:
# expires_in - это всегда количество секунд до истечения
self.token_expires_at = time.time() + expires_in self.token_expires_at = time.time() + expires_in
else:
# По умолчанию 30 минут (1800 секунд)
self.token_expires_at = time.time() + 1800
return self.access_token return self.access_token
@@ -56,46 +117,59 @@ class GigaChatService:
context: Optional[List[Dict[str, Any]]] = None, context: Optional[List[Dict[str, Any]]] = None,
model: str = None model: str = None
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Отправить сообщение в GigaChat""" """
token = await self._get_token() Отправить сообщение в GigaChat через внешний AI-agent сервис.
model = model or settings.GIGACHAT_MODEL_CHAT Сохраняет обратную совместимость с форматом ответа GigaChat API.
"""
try:
# Используем внешний AI-agent сервис
result = await ai_agent_client.chat(
message=message,
conversation_id=None, # Если нужен conversation_id, его нужно передавать отдельно
context=context
)
messages = context or [] # Преобразуем ответ AI-agent сервиса в формат, совместимый с GigaChat API
messages.append({"role": "user", "content": message}) # Предполагаем, что ai_agent_client возвращает структуру ChatResponse или аналогичную
if "response" in result:
headers = { # Если ответ в формате ChatResponse, преобразуем в формат GigaChat
"Authorization": f"Bearer {token}", return {
"Content-Type": "application/json" "model": result.get("model", model or settings.GIGACHAT_MODEL_CHAT or "GigaChat"),
"choices": [{
"message": {
"role": "assistant",
"content": result["response"]
},
"finish_reason": "stop"
}],
"usage": {
"total_tokens": result.get("tokens_used", 0),
"prompt_tokens": 0,
"completion_tokens": result.get("tokens_used", 0)
} }
payload = {
"model": model,
"messages": messages,
"temperature": 0.7,
"max_tokens": 2000
} }
else:
async with aiohttp.ClientSession() as session: # Если ответ уже в формате GigaChat, возвращаем как есть
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 return result
except Exception as e:
# Если внешний сервис недоступен, пробрасываем ошибку
raise Exception(f"AI-agent service error: {str(e)}")
async def generate_text( async def generate_text(
self, self,
prompt: str, prompt: str,
model: str = None model: str = None
) -> str: ) -> str:
"""Генерация текста по промпту""" """
result = await self.chat(prompt, model=model) Генерация текста по промпту через внешний AI-agent сервис.
return result.get("choices", [{}])[0].get("message", {}).get("content", "") """
try:
# Используем метод generate_text из ai_agent_client
response_text = await ai_agent_client.generate_text(prompt=prompt, model=model)
return response_text
except Exception as e:
# Если произошла ошибка, пробрасываем её
raise Exception(f"AI-agent service error: {str(e)}")
gigachat_service = GigaChatService() gigachat_service = GigaChatService()

View File

@@ -1,11 +1,14 @@
import json import json
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.services.gigachat_service import gigachat_service from app.services.gigachat_service import gigachat_service
from app.core.config import settings from app.core.config import settings
from app.crud import schedule as crud_schedule, task as crud_task from app.crud import schedule as crud_schedule, task as crud_task
from app.schemas.schedule import ScheduleCreate from app.schemas.schedule import ScheduleCreate
from app.schemas.task import TaskCreate from app.schemas.task import TaskCreate
from app.models.schedule import Schedule
from datetime import date from datetime import date
@@ -104,11 +107,17 @@ class ScheduleGenerator:
await crud_task.create(db, task_create.model_dump()) await crud_task.create(db, task_create.model_dump())
await db.refresh(db_schedule) # Загружаем расписание с задачами через selectinload для async корректной работы
result = await db.execute(
select(Schedule)
.where(Schedule.id == db_schedule.id)
.options(selectinload(Schedule.tasks))
)
db_schedule_with_tasks = result.scalar_one()
return { return {
"schedule_id": db_schedule.id, "schedule_id": db_schedule_with_tasks.id,
"title": db_schedule.title, "title": db_schedule_with_tasks.title,
"tasks": [ "tasks": [
{ {
"title": task.title, "title": task.title,
@@ -117,7 +126,7 @@ class ScheduleGenerator:
"category": task.category, "category": task.category,
"order": task.order "order": task.order
} }
for task in db_schedule.tasks for task in db_schedule_with_tasks.tasks
] ]
} }
except json.JSONDecodeError as e: except json.JSONDecodeError as e:

View File

@@ -17,6 +17,8 @@ services:
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
networks:
- new-planet-network
redis: redis:
image: redis:7-alpine image: redis:7-alpine
@@ -30,6 +32,8 @@ services:
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
networks:
- new-planet-network
minio: minio:
image: minio/minio:latest image: minio/minio:latest
@@ -48,9 +52,19 @@ services:
interval: 30s interval: 30s
timeout: 20s timeout: 20s
retries: 3 retries: 3
networks:
- new-planet-network
volumes: volumes:
postgres_data: postgres_data:
redis_data: redis_data:
minio_data: minio_data:
networks:
new-planet-network:
driver: bridge
# ВАЖНО: Внешний AI-agent сервис (https://git.bro-js.ru/Glevel/New-planet-ai-agent.git)
# должен быть запущен в этой же сети для доступа к GigaChat.
# Убедитесь, что сервис ai-agent доступен по имени 'ai-agent' в сети new-planet-network.
external: false

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@ uvicorn[standard]
sqlalchemy>=2.0 sqlalchemy>=2.0
alembic alembic
asyncpg asyncpg
psycopg2-binary
redis redis
pydantic pydantic
pydantic-settings pydantic-settings
@@ -12,3 +13,4 @@ boto3
aiohttp aiohttp
websockets websockets
python-multipart python-multipart
email-validator