feat!: migrate to OpenAI native tool calls and fix circular deps (#fuck-gemini)

- Fix circular dependencies in agent/tools
- Migrate from custom JSON to OpenAI tool calls format
- Add async streaming (step_stream, complete_stream)
- Simplify prompt system and remove token counting
- Add 5 new API endpoints (/health, /v1/models, /api/memory/*)
- Add 3 new tools (get_torrent_by_index, add_torrent_by_index, set_language)
- Fix all 500 tests and add coverage config (80% threshold)
- Add comprehensive docs (README, pytest guide)

BREAKING: LLM interface changed, memory injection via get_memory()
This commit is contained in:
2025-12-06 19:11:05 +01:00
parent 2c8cdd3ab1
commit 9ca31e45e0
92 changed files with 7897 additions and 1786 deletions
+2 -1
View File
@@ -1,7 +1,8 @@
"""JSON-based repository implementations."""
from .movie_repository import JsonMovieRepository
from .tvshow_repository import JsonTVShowRepository
from .subtitle_repository import JsonSubtitleRepository
from .tvshow_repository import JsonTVShowRepository
__all__ = [
"JsonMovieRepository",
@@ -1,11 +1,14 @@
"""JSON-based movie repository implementation."""
from typing import List, Optional, Dict, Any
import logging
from domain.movies.repositories import MovieRepository
import logging
from datetime import datetime
from typing import Any
from domain.movies.entities import Movie
from domain.shared.value_objects import ImdbId
from ..memory import Memory
from domain.movies.repositories import MovieRepository
from domain.movies.value_objects import MovieTitle, Quality, ReleaseYear
from domain.shared.value_objects import FilePath, FileSize, ImdbId
from infrastructure.persistence import get_memory
logger = logging.getLogger(__name__)
@@ -13,103 +16,129 @@ logger = logging.getLogger(__name__)
class JsonMovieRepository(MovieRepository):
"""
JSON-based implementation of MovieRepository.
Stores movies in the memory.json file.
Stores movies in the LTM library using the memory context.
"""
def __init__(self, memory: Memory):
"""
Initialize repository.
Args:
memory: Memory instance for persistence
"""
self.memory = memory
def save(self, movie: Movie) -> None:
"""Save a movie to the repository."""
movies = self._load_all()
"""
Save a movie to the repository.
Updates existing movie if IMDb ID matches.
Args:
movie: Movie entity to save.
"""
memory = get_memory()
movies = memory.ltm.library.get("movies", [])
# Remove existing movie with same IMDb ID
movies = [m for m in movies if m.get('imdb_id') != str(movie.imdb_id)]
# Add new movie
movies = [m for m in movies if m.get("imdb_id") != str(movie.imdb_id)]
movies.append(self._to_dict(movie))
# Save to memory
self.memory.set('movies', movies)
memory.ltm.library["movies"] = movies
memory.save()
logger.debug(f"Saved movie: {movie.imdb_id}")
def find_by_imdb_id(self, imdb_id: ImdbId) -> Optional[Movie]:
"""Find a movie by its IMDb ID."""
movies = self._load_all()
def find_by_imdb_id(self, imdb_id: ImdbId) -> Movie | None:
"""
Find a movie by its IMDb ID.
Args:
imdb_id: IMDb ID to search for.
Returns:
Movie if found, None otherwise.
"""
memory = get_memory()
movies = memory.ltm.library.get("movies", [])
for movie_dict in movies:
if movie_dict.get('imdb_id') == str(imdb_id):
if movie_dict.get("imdb_id") == str(imdb_id):
return self._from_dict(movie_dict)
return None
def find_all(self) -> List[Movie]:
"""Get all movies in the repository."""
movies_dict = self._load_all()
def find_all(self) -> list[Movie]:
"""
Get all movies in the repository.
Returns:
List of all Movie entities.
"""
memory = get_memory()
movies_dict = memory.ltm.library.get("movies", [])
return [self._from_dict(m) for m in movies_dict]
def delete(self, imdb_id: ImdbId) -> bool:
"""Delete a movie from the repository."""
movies = self._load_all()
"""
Delete a movie from the repository.
Args:
imdb_id: IMDb ID of movie to delete.
Returns:
True if deleted, False if not found.
"""
memory = get_memory()
movies = memory.ltm.library.get("movies", [])
initial_count = len(movies)
# Filter out the movie
movies = [m for m in movies if m.get('imdb_id') != str(imdb_id)]
movies = [m for m in movies if m.get("imdb_id") != str(imdb_id)]
if len(movies) < initial_count:
self.memory.set('movies', movies)
memory.ltm.library["movies"] = movies
memory.save()
logger.debug(f"Deleted movie: {imdb_id}")
return True
return False
def exists(self, imdb_id: ImdbId) -> bool:
"""Check if a movie exists in the repository."""
"""
Check if a movie exists in the repository.
Args:
imdb_id: IMDb ID to check.
Returns:
True if exists, False otherwise.
"""
return self.find_by_imdb_id(imdb_id) is not None
def _load_all(self) -> List[Dict[str, Any]]:
"""Load all movies from memory."""
return self.memory.get('movies', [])
def _to_dict(self, movie: Movie) -> Dict[str, Any]:
def _to_dict(self, movie: Movie) -> dict[str, Any]:
"""Convert Movie entity to dict for storage."""
return {
'imdb_id': str(movie.imdb_id),
'title': movie.title.value,
'release_year': movie.release_year.value if movie.release_year else None,
'quality': movie.quality.value,
'file_path': str(movie.file_path) if movie.file_path else None,
'file_size': movie.file_size.bytes if movie.file_size else None,
'tmdb_id': movie.tmdb_id,
'overview': movie.overview,
'poster_path': movie.poster_path,
'vote_average': movie.vote_average,
'added_at': movie.added_at.isoformat(),
"imdb_id": str(movie.imdb_id),
"title": movie.title.value,
"release_year": movie.release_year.value if movie.release_year else None,
"quality": movie.quality.value,
"file_path": str(movie.file_path) if movie.file_path else None,
"file_size": movie.file_size.bytes if movie.file_size else None,
"tmdb_id": movie.tmdb_id,
"added_at": movie.added_at.isoformat(),
}
def _from_dict(self, data: Dict[str, Any]) -> Movie:
def _from_dict(self, data: dict[str, Any]) -> Movie:
"""Convert dict from storage to Movie entity."""
from domain.movies.value_objects import MovieTitle, ReleaseYear, Quality
from domain.shared.value_objects import FilePath, FileSize
from datetime import datetime
# Parse quality string to enum
quality_str = data.get("quality", "unknown")
quality = Quality.from_string(quality_str)
return Movie(
imdb_id=ImdbId(data['imdb_id']),
title=MovieTitle(data['title']),
release_year=ReleaseYear(data['release_year']) if data.get('release_year') else None,
quality=Quality(data.get('quality', 'unknown')),
file_path=FilePath(data['file_path']) if data.get('file_path') else None,
file_size=FileSize(data['file_size']) if data.get('file_size') else None,
tmdb_id=data.get('tmdb_id'),
overview=data.get('overview'),
poster_path=data.get('poster_path'),
vote_average=data.get('vote_average'),
added_at=datetime.fromisoformat(data['added_at']) if data.get('added_at') else datetime.now(),
imdb_id=ImdbId(data["imdb_id"]),
title=MovieTitle(data["title"]),
release_year=(
ReleaseYear(data["release_year"]) if data.get("release_year") else None
),
quality=quality,
file_path=FilePath(data["file_path"]) if data.get("file_path") else None,
file_size=FileSize(data["file_size"]) if data.get("file_size") else None,
tmdb_id=data.get("tmdb_id"),
added_at=(
datetime.fromisoformat(data["added_at"])
if data.get("added_at")
else datetime.now()
),
)
@@ -1,12 +1,13 @@
"""JSON-based subtitle repository implementation."""
from typing import List, Optional, Dict, Any
import logging
from domain.subtitles.repositories import SubtitleRepository
import logging
from typing import Any
from domain.shared.value_objects import FilePath, ImdbId
from domain.subtitles.entities import Subtitle
from domain.subtitles.repositories import SubtitleRepository
from domain.subtitles.value_objects import Language, SubtitleFormat, TimingOffset
from domain.shared.value_objects import ImdbId, FilePath
from ..memory import Memory
from infrastructure.persistence import get_memory
logger = logging.getLogger(__name__)
@@ -14,114 +15,130 @@ logger = logging.getLogger(__name__)
class JsonSubtitleRepository(SubtitleRepository):
"""
JSON-based implementation of SubtitleRepository.
Stores subtitles in the memory.json file.
Stores subtitles in the LTM library using the memory context.
"""
def __init__(self, memory: Memory):
"""
Initialize repository.
Args:
memory: Memory instance for persistence
"""
self.memory = memory
def save(self, subtitle: Subtitle) -> None:
"""Save a subtitle to the repository."""
subtitles = self._load_all()
# Add new subtitle (we allow multiple subtitles for same media)
"""
Save a subtitle to the repository.
Multiple subtitles can exist for the same media.
Args:
subtitle: Subtitle entity to save.
"""
memory = get_memory()
subtitles = memory.ltm.library.get("subtitles", [])
subtitles.append(self._to_dict(subtitle))
# Save to memory
self.memory.set('subtitles', subtitles)
if "subtitles" not in memory.ltm.library:
memory.ltm.library["subtitles"] = []
memory.ltm.library["subtitles"] = subtitles
memory.save()
logger.debug(f"Saved subtitle for: {subtitle.media_imdb_id}")
def find_by_media(
self,
media_imdb_id: ImdbId,
language: Optional[Language] = None,
season: Optional[int] = None,
episode: Optional[int] = None
) -> List[Subtitle]:
"""Find subtitles for a media item."""
subtitles = self._load_all()
language: Language | None = None,
season: int | None = None,
episode: int | None = None,
) -> list[Subtitle]:
"""
Find subtitles for a media item.
Args:
media_imdb_id: IMDb ID of the media.
language: Optional language filter.
season: Optional season number filter.
episode: Optional episode number filter.
Returns:
List of matching Subtitle entities.
"""
memory = get_memory()
subtitles = memory.ltm.library.get("subtitles", [])
results = []
for sub_dict in subtitles:
# Filter by IMDb ID
if sub_dict.get('media_imdb_id') != str(media_imdb_id):
if sub_dict.get("media_imdb_id") != str(media_imdb_id):
continue
# Filter by language if specified
if language and sub_dict.get('language') != language.value:
if language and sub_dict.get("language") != language.value:
continue
# Filter by season/episode if specified
if season is not None and sub_dict.get('season_number') != season:
if season is not None and sub_dict.get("season_number") != season:
continue
if episode is not None and sub_dict.get('episode_number') != episode:
if episode is not None and sub_dict.get("episode_number") != episode:
continue
results.append(self._from_dict(sub_dict))
return results
def delete(self, subtitle: Subtitle) -> bool:
"""Delete a subtitle from the repository."""
subtitles = self._load_all()
"""
Delete a subtitle from the repository.
Matches by file path.
Args:
subtitle: Subtitle entity to delete.
Returns:
True if deleted, False if not found.
"""
memory = get_memory()
subtitles = memory.ltm.library.get("subtitles", [])
initial_count = len(subtitles)
# Filter out the subtitle (match by file path)
subtitles = [
s for s in subtitles
if s.get('file_path') != str(subtitle.file_path)
s for s in subtitles if s.get("file_path") != str(subtitle.file_path)
]
if len(subtitles) < initial_count:
self.memory.set('subtitles', subtitles)
memory.ltm.library["subtitles"] = subtitles
memory.save()
logger.debug(f"Deleted subtitle: {subtitle.file_path}")
return True
return False
def _load_all(self) -> List[Dict[str, Any]]:
"""Load all subtitles from memory."""
return self.memory.get('subtitles', [])
def _to_dict(self, subtitle: Subtitle) -> Dict[str, Any]:
def _to_dict(self, subtitle: Subtitle) -> dict[str, Any]:
"""Convert Subtitle entity to dict for storage."""
return {
'media_imdb_id': str(subtitle.media_imdb_id),
'language': subtitle.language.value,
'format': subtitle.format.value,
'file_path': str(subtitle.file_path),
'season_number': subtitle.season_number,
'episode_number': subtitle.episode_number,
'timing_offset': subtitle.timing_offset.milliseconds,
'hearing_impaired': subtitle.hearing_impaired,
'forced': subtitle.forced,
'source': subtitle.source,
'uploader': subtitle.uploader,
'download_count': subtitle.download_count,
'rating': subtitle.rating,
"media_imdb_id": str(subtitle.media_imdb_id),
"language": subtitle.language.value,
"format": subtitle.format.value,
"file_path": str(subtitle.file_path),
"season_number": subtitle.season_number,
"episode_number": subtitle.episode_number,
"timing_offset": subtitle.timing_offset.milliseconds,
"hearing_impaired": subtitle.hearing_impaired,
"forced": subtitle.forced,
"source": subtitle.source,
"uploader": subtitle.uploader,
"download_count": subtitle.download_count,
"rating": subtitle.rating,
}
def _from_dict(self, data: Dict[str, Any]) -> Subtitle:
def _from_dict(self, data: dict[str, Any]) -> Subtitle:
"""Convert dict from storage to Subtitle entity."""
return Subtitle(
media_imdb_id=ImdbId(data['media_imdb_id']),
language=Language.from_code(data['language']),
format=SubtitleFormat.from_extension(data['format']),
file_path=FilePath(data['file_path']),
season_number=data.get('season_number'),
episode_number=data.get('episode_number'),
timing_offset=TimingOffset(data.get('timing_offset', 0)),
hearing_impaired=data.get('hearing_impaired', False),
forced=data.get('forced', False),
source=data.get('source'),
uploader=data.get('uploader'),
download_count=data.get('download_count'),
rating=data.get('rating'),
media_imdb_id=ImdbId(data["media_imdb_id"]),
language=Language.from_code(data["language"]),
format=SubtitleFormat.from_extension(data["format"]),
file_path=FilePath(data["file_path"]),
season_number=data.get("season_number"),
episode_number=data.get("episode_number"),
timing_offset=TimingOffset(data.get("timing_offset", 0)),
hearing_impaired=data.get("hearing_impaired", False),
forced=data.get("forced", False),
source=data.get("source"),
uploader=data.get("uploader"),
download_count=data.get("download_count"),
rating=data.get("rating"),
)
@@ -1,12 +1,14 @@
"""JSON-based TV show repository implementation."""
from typing import List, Optional, Dict, Any
import logging
from domain.tv_shows.repositories import TVShowRepository
from domain.tv_shows.entities import TVShow
from domain.tv_shows.value_objects import ShowStatus
import logging
from datetime import datetime
from typing import Any
from domain.shared.value_objects import ImdbId
from ..memory import Memory
from domain.tv_shows.entities import TVShow
from domain.tv_shows.repositories import TVShowRepository
from domain.tv_shows.value_objects import ShowStatus
from infrastructure.persistence import get_memory
logger = logging.getLogger(__name__)
@@ -14,99 +16,121 @@ logger = logging.getLogger(__name__)
class JsonTVShowRepository(TVShowRepository):
"""
JSON-based implementation of TVShowRepository.
Stores TV shows in the memory.json file (compatible with existing tv_shows structure).
Stores TV shows in the LTM library using the memory context.
"""
def __init__(self, memory: Memory):
"""
Initialize repository.
Args:
memory: Memory instance for persistence
"""
self.memory = memory
def save(self, show: TVShow) -> None:
"""Save a TV show to the repository."""
shows = self._load_all()
"""
Save a TV show to the repository.
Updates existing show if IMDb ID matches.
Args:
show: TVShow entity to save.
"""
memory = get_memory()
shows = memory.ltm.library.get("tv_shows", [])
# Remove existing show with same IMDb ID
shows = [s for s in shows if s.get('imdb_id') != str(show.imdb_id)]
# Add new show
shows = [s for s in shows if s.get("imdb_id") != str(show.imdb_id)]
shows.append(self._to_dict(show))
# Save to memory
self.memory.set('tv_shows', shows)
memory.ltm.library["tv_shows"] = shows
memory.save()
logger.debug(f"Saved TV show: {show.imdb_id}")
def find_by_imdb_id(self, imdb_id: ImdbId) -> Optional[TVShow]:
"""Find a TV show by its IMDb ID."""
shows = self._load_all()
def find_by_imdb_id(self, imdb_id: ImdbId) -> TVShow | None:
"""
Find a TV show by its IMDb ID.
Args:
imdb_id: IMDb ID to search for.
Returns:
TVShow if found, None otherwise.
"""
memory = get_memory()
shows = memory.ltm.library.get("tv_shows", [])
for show_dict in shows:
if show_dict.get('imdb_id') == str(imdb_id):
if show_dict.get("imdb_id") == str(imdb_id):
return self._from_dict(show_dict)
return None
def find_all(self) -> List[TVShow]:
"""Get all TV shows in the repository."""
shows_dict = self._load_all()
def find_all(self) -> list[TVShow]:
"""
Get all TV shows in the repository.
Returns:
List of all TVShow entities.
"""
memory = get_memory()
shows_dict = memory.ltm.library.get("tv_shows", [])
return [self._from_dict(s) for s in shows_dict]
def delete(self, imdb_id: ImdbId) -> bool:
"""Delete a TV show from the repository."""
shows = self._load_all()
"""
Delete a TV show from the repository.
Args:
imdb_id: IMDb ID of show to delete.
Returns:
True if deleted, False if not found.
"""
memory = get_memory()
shows = memory.ltm.library.get("tv_shows", [])
initial_count = len(shows)
# Filter out the show
shows = [s for s in shows if s.get('imdb_id') != str(imdb_id)]
shows = [s for s in shows if s.get("imdb_id") != str(imdb_id)]
if len(shows) < initial_count:
self.memory.set('tv_shows', shows)
memory.ltm.library["tv_shows"] = shows
memory.save()
logger.debug(f"Deleted TV show: {imdb_id}")
return True
return False
def exists(self, imdb_id: ImdbId) -> bool:
"""Check if a TV show exists in the repository."""
"""
Check if a TV show exists in the repository.
Args:
imdb_id: IMDb ID to check.
Returns:
True if exists, False otherwise.
"""
return self.find_by_imdb_id(imdb_id) is not None
def _load_all(self) -> List[Dict[str, Any]]:
"""Load all TV shows from memory."""
return self.memory.get('tv_shows', [])
def _to_dict(self, show: TVShow) -> Dict[str, Any]:
def _to_dict(self, show: TVShow) -> dict[str, Any]:
"""Convert TVShow entity to dict for storage."""
return {
'imdb_id': str(show.imdb_id),
'title': show.title,
'seasons_count': show.seasons_count,
'status': show.status.value,
'tmdb_id': show.tmdb_id,
'overview': show.overview,
'poster_path': show.poster_path,
'first_air_date': show.first_air_date,
'vote_average': show.vote_average,
'added_at': show.added_at.isoformat(),
"imdb_id": str(show.imdb_id),
"title": show.title,
"seasons_count": show.seasons_count,
"status": show.status.value,
"tmdb_id": show.tmdb_id,
"first_air_date": show.first_air_date,
"added_at": show.added_at.isoformat(),
}
def _from_dict(self, data: Dict[str, Any]) -> TVShow:
def _from_dict(self, data: dict[str, Any]) -> TVShow:
"""Convert dict from storage to TVShow entity."""
from datetime import datetime
return TVShow(
imdb_id=ImdbId(data['imdb_id']),
title=data['title'],
seasons_count=data['seasons_count'],
status=ShowStatus.from_string(data['status']),
tmdb_id=data.get('tmdb_id'),
overview=data.get('overview'),
poster_path=data.get('poster_path'),
first_air_date=data.get('first_air_date'),
vote_average=data.get('vote_average'),
added_at=datetime.fromisoformat(data['added_at']) if data.get('added_at') else datetime.now(),
imdb_id=ImdbId(data["imdb_id"]),
title=data["title"],
seasons_count=data["seasons_count"],
status=ShowStatus.from_string(data["status"]),
tmdb_id=data.get("tmdb_id"),
first_air_date=data.get("first_air_date"),
added_at=(
datetime.fromisoformat(data["added_at"])
if data.get("added_at")
else datetime.now()
),
)