From cda3b8470b4b688956607b8148ee053f07d1e3af Mon Sep 17 00:00:00 2001 From: Nikita Yakovlev Date: Mon, 23 Mar 2026 12:15:55 +0300 Subject: [PATCH 1/9] add AI Client for sending requests to Cloud model --- backend/.env.template | 10 ++++++ backend/backlog_app/config.py | 9 +++++ .../servicies/ai_agent/__init__.py | 2 ++ .../backlog_app/servicies/ai_agent/client.py | 36 +++++++++++++++++++ 4 files changed, 57 insertions(+) create mode 100644 backend/backlog_app/servicies/ai_agent/__init__.py create mode 100644 backend/backlog_app/servicies/ai_agent/client.py diff --git a/backend/.env.template b/backend/.env.template index 7220d36..7abf947 100644 --- a/backend/.env.template +++ b/backend/.env.template @@ -9,8 +9,18 @@ BACKLOG__TASKIQ__RBMQ_PORT=5672 BACKLOG__TASKIQ__RBMQ_USERNAME=guest BACKLOG__TASKIQ__RBMQ_PASSWORD=guest +BACKLOG__SMTP__USERNAME= +BACKLOG__SMTP__PASSWORD= +BACKLOG__SMTP__SERVER=127.0.0.1 +BACKLOG__SMTP__PORT=1025 +BACKLOG__SMTP__USE_TLS=False + BACKLOG__ACCESS_TOKEN_DB__RESET_PASSWORD_TOKEN_SECRET= BACKLOG__ACCESS_TOKEN_DB__VERIFICATION_TOKEN_SECRET= +BACKLOG__AI_AGENT__BASE_URL= +BACKLOG__AI_AGENT__ACCESS_ID= +BACKLOG__AI_AGENT__TOKEN= + BACKLOG__SUPERUSER__EMAIL=admin@site.com BACKLOG__SUPERUSER__PASSWORD=admin diff --git a/backend/backlog_app/config.py b/backend/backlog_app/config.py index 6f5b327..36c668f 100644 --- a/backend/backlog_app/config.py +++ b/backend/backlog_app/config.py @@ -75,6 +75,14 @@ def url(self) -> str: return f"amqp://{self.rbmq_username}:{self.rbmq_password}@{self.rbmq_host}:{self.rbmq_port}//" +class AIAgentConfig(BaseModel): + base_url: str + access_id: str + token: str + model: str = "DeepSeek v3.2" + timeout: int = 10 + + class Settings(BaseSettings): model_config = SettingsConfigDict( case_sensitive=False, @@ -127,6 +135,7 @@ def settings_customise_sources( access_token_db: AccessToken superuser: SuperUser smtp: SMTPConfig + ai_agent: AIAgentConfig cors_origins: list[str] = ["http://localhost:5173"] imdb_url: str = "https://api.imdbapi.dev" diff --git a/backend/backlog_app/servicies/ai_agent/__init__.py b/backend/backlog_app/servicies/ai_agent/__init__.py new file mode 100644 index 0000000..0b71b53 --- /dev/null +++ b/backend/backlog_app/servicies/ai_agent/__init__.py @@ -0,0 +1,2 @@ +from .client import AIClient +from .deepseek_translater import TranslationService diff --git a/backend/backlog_app/servicies/ai_agent/client.py b/backend/backlog_app/servicies/ai_agent/client.py new file mode 100644 index 0000000..cc92afa --- /dev/null +++ b/backend/backlog_app/servicies/ai_agent/client.py @@ -0,0 +1,36 @@ +import httpx + +from backlog_app.config import settings + + +class AIClient: + def __init__(self, model: str): + self.model = model + self.client = httpx.AsyncClient(timeout=settings.ai_agent.timeout) + + async def translate(self, text: str) -> str: + payload = { + "model": self.model, + "messages": [ + { + "role": "user", + "content": ( + "Translate the following text from English into Russian, " + "preserve the meaning and naturalness of the language:\n\n" + f"{text}" + ), + } + ], + "temperature": 0.3, + } + + response = await self.client.post( + f"{settings.ai_agent.base_url}/api/v1/cloud-ai/agents/{settings.ai_agent.access_id}/v1/chat/completions", + json=payload, + headers={ + "Authorization": f"Bearer {settings.ai_agent.token}", + }, + ) + + response.raise_for_status() + return response.json()["choices"][0]["message"]["content"] From 04d0fe1c8513879b4c3eac97e9c9173b2c397d64 Mon Sep 17 00:00:00 2001 From: Nikita Yakovlev Date: Mon, 23 Mar 2026 12:18:05 +0300 Subject: [PATCH 2/9] add deepseek translater service --- .../servicies/ai_agent/deepseek_translater.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 backend/backlog_app/servicies/ai_agent/deepseek_translater.py diff --git a/backend/backlog_app/servicies/ai_agent/deepseek_translater.py b/backend/backlog_app/servicies/ai_agent/deepseek_translater.py new file mode 100644 index 0000000..94c057a --- /dev/null +++ b/backend/backlog_app/servicies/ai_agent/deepseek_translater.py @@ -0,0 +1,12 @@ +from backlog_app.config import settings + +from .client import AIClient + + +class TranslationService: + def __init__(self): + self.client = AIClient(settings.ai_agent.model) + + async def translate(self, text: str) -> str: + result = await self.client.translate(text) + return result From 18a63343f408794d4469de7aecd8cbd274a4b58f Mon Sep 17 00:00:00 2001 From: Nikita Yakovlev Date: Mon, 23 Mar 2026 12:18:29 +0300 Subject: [PATCH 3/9] add to IMDV provider method for getting title description --- .../servicies/imdb_api/provider.py | 35 +++++++++++++------ 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/backend/backlog_app/servicies/imdb_api/provider.py b/backend/backlog_app/servicies/imdb_api/provider.py index 18fd420..5808fab 100644 --- a/backend/backlog_app/servicies/imdb_api/provider.py +++ b/backend/backlog_app/servicies/imdb_api/provider.py @@ -43,11 +43,11 @@ async def _request( status_code=status_code, detail="SERVER_ERROR" ) from e - async def get_title_id(self, title: str, year: int | None = None) -> str: + async def get_title_id(self, title: str, year: int) -> str: """ title identifier is of type str, because the imdb identifier is tt0816692 """ - params = {"query": title, "limit": 2} + params = {"query": title, "limit": 10} response = await self._request( HTTPMethod.GET, endpoint="search/titles", @@ -57,11 +57,12 @@ async def get_title_id(self, title: str, year: int | None = None) -> str: if not titles: raise HTTPException(status_code=404, detail="Title not found") + logger.debug("TITLE YEAR: %s", year) if year: - exact_year_match = [t for t in titles if t.get("startYear") == year] - if exact_year_match: - logger.debug("Found exact year match: %s", exact_year_match[0]) - return exact_year_match[0]["id"] + for t in titles: + if t.get("startYear") == year: + logger.debug("Found match by year: %s", t) + return t["id"] def popularity_score(t): rating = t.get("rating", {}).get("aggregateRating", 0) @@ -69,15 +70,17 @@ def popularity_score(t): return rating * votes best_match = max(titles, key=popularity_score) - logger.debug("Best match by popularity: %s", best_match) + logger.warning( + "No title found for year %s, using most popular match: %s", year, best_match + ) return best_match["id"] - async def get_title(self, title: str) -> dict: - title_id = await self.get_title_id(title) + async def get_title(self, title: str, year: int) -> dict: + title_id = await self.get_title_id(title, year) return await self._request(HTTPMethod.GET, endpoint=f"titles/{title_id}") - async def get_title_rating(self, title: str) -> tuple[float, float]: - title_data = await self.get_title(title) + async def get_title_rating(self, title: str, year: int) -> tuple[float, float]: + title_data = await self.get_title(title, year) rating = title_data.get("rating", {}) metacritic_rating = title_data.get("metacritic", {}) @@ -86,3 +89,13 @@ async def get_title_rating(self, title: str) -> tuple[float, float]: logger.debug("Title Rating: %s, %s", rating, metacritic_rating) return rating.get("aggregateRating"), metacritic_rating.get("score") + + async def get_title_description(self, title: str, year: int) -> str: + title_data = await self.get_title(title, year) + + description = title_data.get("plot") + + logger.debug("Found title info: %s", title_data) + logger.debug("Title Description: %s", description) + + return description From c1c76d8c29063a13c404f30ca4dba99ec4056048 Mon Sep 17 00:00:00 2001 From: Nikita Yakovlev Date: Mon, 23 Mar 2026 12:18:49 +0300 Subject: [PATCH 4/9] new background tasks for description --- backend/backlog_app/tasks/movie_task.py | 54 +++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/backend/backlog_app/tasks/movie_task.py b/backend/backlog_app/tasks/movie_task.py index 0661301..54c2ddc 100644 --- a/backend/backlog_app/tasks/movie_task.py +++ b/backend/backlog_app/tasks/movie_task.py @@ -2,19 +2,22 @@ from sqlalchemy.ext.asyncio import AsyncSession +from backlog_app.api import crud from backlog_app.api.crud import partial_update_movie from backlog_app.config import settings from backlog_app.models import User from backlog_app.schemas.movie import MovieRead, MovieUpdate +from backlog_app.servicies.ai_agent import TranslationService from backlog_app.servicies.imdb_api.provider import IMDBProvider -from backlog_app.storages.database import get_async_session logger = logging.getLogger(__name__) +provider = IMDBProvider(base_url=settings.imdb_url) +translator = TranslationService() -async def update_movie_rating(movie: MovieRead, db: AsyncSession, user: User): - provider = IMDBProvider(base_url=settings.imdb_url) +async def update_movie_rating(movie: MovieRead, db: AsyncSession, user: User): + movie_db = await crud.get_movie_by_id(db, movie.id) year = getattr(movie, "year", None) if year is None: @@ -22,7 +25,9 @@ async def update_movie_rating(movie: MovieRead, db: AsyncSession, user: User): return try: - imdb_rating, metacritic_score = await provider.get_title_rating(movie.title) + imdb_rating, metacritic_score = await provider.get_title_rating( + movie_db.title, movie_db.year + ) except Exception as e: logger.error("Failed to fetch rating for movie <%s>: %s", movie.id, e) return @@ -38,3 +43,44 @@ async def update_movie_rating(movie: MovieRead, db: AsyncSession, user: User): ) logger.info("Movie <%s> ratings updated in background", movie.id) + + +async def update_movie_description( + movie: MovieRead, db: AsyncSession, user: User +) -> None: + + movie_db = await crud.get_movie_by_id(db, movie.id) + + description = getattr(movie_db, "description", None) + year = getattr(movie_db, "year", None) + + if description and description.strip(): + logger.info("Movie <%s> already has description, skipping", movie.id) + return + + if not year: + logger.info("Movie <%s> has no year, skipping description update", movie.id) + return + + try: + en_description = await provider.get_title_description( + movie_db.title, movie_db.year + ) + except Exception as e: + logger.error("Failed to fetch description for movie <%s>: %s", movie.id, e) + return + + try: + ru_description = await translator.translate(en_description) + except Exception as e: + logger.error("Failed to translate description for movie <%s>: %s", movie.id, e) + return + + await partial_update_movie( + db, + movie.id, + MovieUpdate(description=ru_description), + user, + ) + + logger.info("Movie <%s> description updated in background", movie.id) From a636856723c83e66bf5cb2c30a85f4f287c9068b Mon Sep 17 00:00:00 2001 From: Nikita Yakovlev Date: Mon, 23 Mar 2026 12:19:14 +0300 Subject: [PATCH 5/9] add second background task for create method --- backend/backlog_app/api/view/movie_view.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/backlog_app/api/view/movie_view.py b/backend/backlog_app/api/view/movie_view.py index 540875f..4aade70 100644 --- a/backend/backlog_app/api/view/movie_view.py +++ b/backend/backlog_app/api/view/movie_view.py @@ -10,7 +10,7 @@ from backlog_app.models.users import User from backlog_app.schemas.movie import MovieCreate, MovieList, MovieRead, MovieUpdate from backlog_app.storages.database import get_async_session -from backlog_app.tasks.movie_task import update_movie_rating +from backlog_app.tasks.movie_task import update_movie_description, update_movie_rating router = APIRouter(prefix="/movies", tags=["Movies"]) @@ -24,6 +24,7 @@ async def add_movie( ): movie = await crud.create_movie(db, movie_create, user=user) background_tasks.add_task(update_movie_rating, movie, db, user) + background_tasks.add_task(update_movie_description, movie, db, user) return movie From d1d95242802cc51828d6443e737c71da5ca46a15 Mon Sep 17 00:00:00 2001 From: Nikita Yakovlev Date: Mon, 23 Mar 2026 12:28:34 +0300 Subject: [PATCH 6/9] unit test for client module --- .../backlog_app/servicies/ai_agent/client.py | 3 +- .../test_servicies/test_ai_agent/__init__.py | 0 .../test_ai_agent/test_client.py | 40 +++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_servicies/test_ai_agent/__init__.py create mode 100644 backend/tests/test_servicies/test_ai_agent/test_client.py diff --git a/backend/backlog_app/servicies/ai_agent/client.py b/backend/backlog_app/servicies/ai_agent/client.py index cc92afa..0108e26 100644 --- a/backend/backlog_app/servicies/ai_agent/client.py +++ b/backend/backlog_app/servicies/ai_agent/client.py @@ -33,4 +33,5 @@ async def translate(self, text: str) -> str: ) response.raise_for_status() - return response.json()["choices"][0]["message"]["content"] + data = await response.json() + return data["choices"][0]["message"]["content"] diff --git a/backend/tests/test_servicies/test_ai_agent/__init__.py b/backend/tests/test_servicies/test_ai_agent/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/test_servicies/test_ai_agent/test_client.py b/backend/tests/test_servicies/test_ai_agent/test_client.py new file mode 100644 index 0000000..fc26c75 --- /dev/null +++ b/backend/tests/test_servicies/test_ai_agent/test_client.py @@ -0,0 +1,40 @@ +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from backlog_app.servicies.ai_agent import AIClient + + +@pytest.mark.asyncio +async def test_translate_success(): + client = AIClient(model="gpt-test") + + mock_response = AsyncMock() + mock_response.raise_for_status = AsyncMock() + mock_response.json = AsyncMock( + return_value={"choices": [{"message": {"content": "Привет мир"}}]} + ) + + with patch.object(client.client, "post", return_value=mock_response) as mock_post: + result = await client.translate("Hello world") + assert result == "Привет мир" + + called_payload = mock_post.call_args[1]["json"] + assert ( + "Translate the following text from English into Russian" + in called_payload["messages"][0]["content"] + ) + assert "Hello world" in called_payload["messages"][0]["content"] + + +@pytest.mark.asyncio +async def test_translate_http_error(): + client = AIClient(model="gpt-test") + + mock_response = AsyncMock() + mock_response.raise_for_status = Mock(side_effect=Exception("HTTP Error")) + mock_response.json = AsyncMock(return_value={}) + + with patch.object(client.client, "post", return_value=mock_response): + with pytest.raises(Exception, match="HTTP Error"): + await client.translate("Hello world") From 96ea7fd272dc9ba21bdc00de5feef381f2ded58d Mon Sep 17 00:00:00 2001 From: Nikita Yakovlev Date: Mon, 23 Mar 2026 12:33:22 +0300 Subject: [PATCH 7/9] add new env to GH Action --- .github/workflows/python-check.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/python-check.yml b/.github/workflows/python-check.yml index 3e6dc75..9c74eb2 100644 --- a/.github/workflows/python-check.yml +++ b/.github/workflows/python-check.yml @@ -68,8 +68,13 @@ jobs: BACKLOG__SUPERUSER__PASSWORD: admin BACKLOG__ACCESS_TOKEN_DB__RESET_PASSWORD_TOKEN_SECRET: secret1 BACKLOG__ACCESS_TOKEN_DB__VERIFICATION_TOKEN_SECRET: secret2 + BACKLOG__SMTP__USERNAME: admin@site.com + BACKLOG__SMTP__PASSWORD: admin BACKLOG__SMTP__SERVER: 127.0.0.1 BACKLOG__SMTP__PORT: 1025 + BACKLOG__AI_AGENT__BASE_URL: https://example.com + BACKLOG__AI_AGENT__ACCESS_ID: 1234 + BACKLOG__AI_AGENT__TOKEN: secret3 - name: Upload artefacts uses: actions/upload-artifact@v4 From 8de59c270016ef5b8c06da2e66ed0a1f52b0b35f Mon Sep 17 00:00:00 2001 From: Nikita Yakovlev Date: Mon, 23 Mar 2026 12:36:32 +0300 Subject: [PATCH 8/9] fix IMDB provider test --- .../tests/test_servicies/test_imdb_api/test_provider.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/tests/test_servicies/test_imdb_api/test_provider.py b/backend/tests/test_servicies/test_imdb_api/test_provider.py index 3de3983..0ed9677 100644 --- a/backend/tests/test_servicies/test_imdb_api/test_provider.py +++ b/backend/tests/test_servicies/test_imdb_api/test_provider.py @@ -33,7 +33,7 @@ async def test_get_title_success(monkeypatch): monkeypatch.setattr(imdb, "get_title_id", mock_get_id) monkeypatch.setattr(imdb, "_request", mock_request) - result = await imdb.get_title("Interstellar") + result = await imdb.get_title("Interstellar", 2014) mock_get_id.assert_awaited_once_with("Interstellar") mock_request.assert_awaited_once_with( @@ -56,7 +56,7 @@ async def test_get_title_rating_success(monkeypatch): mock_get_title = AsyncMock(return_value=mock_title_data) monkeypatch.setattr(imdb, "get_title", mock_get_title) - imdb_rating, metacritic_score = await imdb.get_title_rating("Interstellar") + imdb_rating, metacritic_score = await imdb.get_title_rating("Interstellar", 2014) mock_get_title.assert_awaited_once_with("Interstellar") assert imdb_rating == 8.6 @@ -75,7 +75,7 @@ async def test_get_title_rating_without_metacritic(monkeypatch): AsyncMock(return_value=mock_title_data), ) - imdb_rating, metacritic_score = await imdb.get_title_rating("Cars") + imdb_rating, metacritic_score = await imdb.get_title_rating("Cars", 2006) assert imdb_rating == 7.1 assert metacritic_score is None @@ -91,7 +91,7 @@ async def test_get_title_rating_empty_data(monkeypatch): AsyncMock(return_value={}), ) - imdb_rating, metacritic_score = await imdb.get_title_rating("Unknown") + imdb_rating, metacritic_score = await imdb.get_title_rating("Unknown", 0000) assert imdb_rating is None assert metacritic_score is None From 1c0ba8e9b294857a5867234ac959b41b0035e08e Mon Sep 17 00:00:00 2001 From: Nikita Yakovlev Date: Mon, 23 Mar 2026 12:39:48 +0300 Subject: [PATCH 9/9] fix IMDB provider test x2 --- backend/tests/test_servicies/test_imdb_api/test_provider.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/tests/test_servicies/test_imdb_api/test_provider.py b/backend/tests/test_servicies/test_imdb_api/test_provider.py index 0ed9677..3af89f9 100644 --- a/backend/tests/test_servicies/test_imdb_api/test_provider.py +++ b/backend/tests/test_servicies/test_imdb_api/test_provider.py @@ -35,7 +35,7 @@ async def test_get_title_success(monkeypatch): result = await imdb.get_title("Interstellar", 2014) - mock_get_id.assert_awaited_once_with("Interstellar") + mock_get_id.assert_awaited_once_with("Interstellar", 2014) mock_request.assert_awaited_once_with( HTTPMethod.GET, endpoint="titles/tt0816692", @@ -58,7 +58,7 @@ async def test_get_title_rating_success(monkeypatch): imdb_rating, metacritic_score = await imdb.get_title_rating("Interstellar", 2014) - mock_get_title.assert_awaited_once_with("Interstellar") + mock_get_title.assert_awaited_once_with("Interstellar", 2014) assert imdb_rating == 8.6 assert metacritic_score == 74