Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/python-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions backend/.env.template
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion backend/backlog_app/api/view/movie_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])

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

Expand Down
9 changes: 9 additions & 0 deletions backend/backlog_app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"

Expand Down
2 changes: 2 additions & 0 deletions backend/backlog_app/servicies/ai_agent/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from .client import AIClient
from .deepseek_translater import TranslationService
37 changes: 37 additions & 0 deletions backend/backlog_app/servicies/ai_agent/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
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()
data = await response.json()
return data["choices"][0]["message"]["content"]
12 changes: 12 additions & 0 deletions backend/backlog_app/servicies/ai_agent/deepseek_translater.py
Original file line number Diff line number Diff line change
@@ -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
35 changes: 24 additions & 11 deletions backend/backlog_app/servicies/imdb_api/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -57,27 +57,30 @@ 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)
votes = t.get("rating", {}).get("voteCount", 0)
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", {})
Expand All @@ -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
54 changes: 50 additions & 4 deletions backend/backlog_app/tasks/movie_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,32 @@

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:
logger.info("Movie <%s> has no year, skipping rating update", movie.id)
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
Expand All @@ -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)
Empty file.
40 changes: 40 additions & 0 deletions backend/tests/test_servicies/test_ai_agent/test_client.py
Original file line number Diff line number Diff line change
@@ -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")
12 changes: 6 additions & 6 deletions backend/tests/test_servicies/test_imdb_api/test_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,9 @@ 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_get_id.assert_awaited_once_with("Interstellar", 2014)
mock_request.assert_awaited_once_with(
HTTPMethod.GET,
endpoint="titles/tt0816692",
Expand All @@ -56,9 +56,9 @@ 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")
mock_get_title.assert_awaited_once_with("Interstellar", 2014)
assert imdb_rating == 8.6
assert metacritic_score == 74

Expand All @@ -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
Expand All @@ -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
Loading