├── tests ├── __init__.py ├── test_sequel_salad.py ├── test_bttf_trivia.py ├── test_title_detectives.py └── test_trivia.py ├── gemini_movie_detectives_api ├── __init__.py ├── quiz │ ├── __init__.py │ ├── base.py │ ├── sequel_salad.py │ ├── bttf_trivia.py │ ├── title_detectives.py │ └── trivia.py ├── templates │ ├── trivia │ │ ├── prompt_answer.jinja │ │ ├── metadata.jinja │ │ └── prompt_question.jinja │ ├── bttf-trivia │ │ ├── prompt_answer.jinja │ │ └── prompt_question.jinja │ ├── personality │ │ ├── scientist.jinja │ │ ├── dad.jinja │ │ ├── christmas.jinja │ │ └── default.jinja │ ├── title-detectives │ │ ├── metadata.jinja │ │ ├── prompt_answer.jinja │ │ └── prompt_question.jinja │ └── sequel-salad │ │ ├── prompt_answer.jinja │ │ └── prompt_question.jinja ├── template.py ├── config.py ├── cleanup.py ├── speech.py ├── gemini.py ├── imagen.py ├── tmdb.py ├── wiki.py ├── model.py ├── storage.py └── main.py ├── poetry.toml ├── doc ├── logo.png ├── mockup.png ├── firestore.png ├── make-help.png ├── architecture.png ├── demo-profile.png ├── cloud-logging.png ├── demo-bttf-trivia.png ├── demo-sequel-salad.png └── sequel-salad-dad-jokes.png ├── .dockerignore ├── .gitignore ├── .pre-commit-config.yaml ├── Dockerfile ├── .github └── workflows │ └── test.yml ├── pyproject.toml ├── Makefile └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /gemini_movie_detectives_api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /gemini_movie_detectives_api/quiz/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /poetry.toml: -------------------------------------------------------------------------------- 1 | [virtualenvs] 2 | in-project = true 3 | -------------------------------------------------------------------------------- /doc/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vojay-dev/gemini-movie-detectives-api/HEAD/doc/logo.png -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .venv/ 2 | .github/ 3 | .git/ 4 | doc/ 5 | gemini-movie-detectives-api_latest.tar.gz 6 | -------------------------------------------------------------------------------- /doc/mockup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vojay-dev/gemini-movie-detectives-api/HEAD/doc/mockup.png -------------------------------------------------------------------------------- /doc/firestore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vojay-dev/gemini-movie-detectives-api/HEAD/doc/firestore.png -------------------------------------------------------------------------------- /doc/make-help.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vojay-dev/gemini-movie-detectives-api/HEAD/doc/make-help.png -------------------------------------------------------------------------------- /doc/architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vojay-dev/gemini-movie-detectives-api/HEAD/doc/architecture.png -------------------------------------------------------------------------------- /doc/demo-profile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vojay-dev/gemini-movie-detectives-api/HEAD/doc/demo-profile.png -------------------------------------------------------------------------------- /doc/cloud-logging.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vojay-dev/gemini-movie-detectives-api/HEAD/doc/cloud-logging.png -------------------------------------------------------------------------------- /doc/demo-bttf-trivia.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vojay-dev/gemini-movie-detectives-api/HEAD/doc/demo-bttf-trivia.png -------------------------------------------------------------------------------- /doc/demo-sequel-salad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vojay-dev/gemini-movie-detectives-api/HEAD/doc/demo-sequel-salad.png -------------------------------------------------------------------------------- /doc/sequel-salad-dad-jokes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vojay-dev/gemini-movie-detectives-api/HEAD/doc/sequel-salad-dad-jokes.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | .venv/ 3 | dist/ 4 | .env 5 | gcp-vojay-gemini.json 6 | firebase-vojay.json 7 | gemini-movie-detectives-api_latest.tar.gz 8 | -------------------------------------------------------------------------------- /gemini_movie_detectives_api/templates/trivia/prompt_answer.jinja: -------------------------------------------------------------------------------- 1 | The participants answered: 2 | 3 | {{ answer }} 4 | 5 | Come up with a nice reply to the participants to conclude the quiz. 6 | 7 | Reply using this JSON schema: 8 | 9 | {"answer": str} 10 | 11 | - answer: Your answer to the participants 12 | -------------------------------------------------------------------------------- /gemini_movie_detectives_api/templates/bttf-trivia/prompt_answer.jinja: -------------------------------------------------------------------------------- 1 | The participants answered: 2 | 3 | {{ answer }} 4 | 5 | Come up with a nice reply to the participants to conclude the quiz. 6 | 7 | Reply using this JSON schema: 8 | 9 | {"answer": str} 10 | 11 | - answer: Your answer to the participants 12 | -------------------------------------------------------------------------------- /gemini_movie_detectives_api/templates/personality/scientist.jinja: -------------------------------------------------------------------------------- 1 | You are a top tier scientist. You have a hard time phrasing things in an easy way. All your responses must use as many 2 | complicated phrasings as possible, also include as many science facts if possible, if it fits to the context. 3 | 4 | For formatting your responses, stick strictly to the template described below! 5 | -------------------------------------------------------------------------------- /gemini_movie_detectives_api/templates/personality/dad.jinja: -------------------------------------------------------------------------------- 1 | You are a proud dad. However, you must add dad jokes to almost every sentence. Add as many silly dad jokes as possible 2 | to your responses and try to make them related to the movie or movies in general. Also, you cant resist and have to add 3 | many emojis to your answer. 4 | 5 | For formatting your responses, stick strictly to the template described below! 6 | -------------------------------------------------------------------------------- /gemini_movie_detectives_api/templates/trivia/metadata.jinja: -------------------------------------------------------------------------------- 1 | Movie tagline: {{ tagline }} 2 | Movie overview: {{ overview }} 3 | Movie genre(s): {{ genres }} 4 | Movie budget: {{ budget }} Dollar 5 | Movie revenue: {{ revenue }} Dollar 6 | Movie average rating (1-10): {{ average_rating }} 7 | Movie rating count: {{ rating_count }} 8 | Movie release date: {{ release_date }} 9 | Movie runtime: {{ runtime }} minutes 10 | -------------------------------------------------------------------------------- /gemini_movie_detectives_api/templates/title-detectives/metadata.jinja: -------------------------------------------------------------------------------- 1 | Movie tagline: {{ tagline }} 2 | Movie overview: {{ overview }} 3 | Movie genre(s): {{ genres }} 4 | Movie budget: {{ budget }} Dollar 5 | Movie revenue: {{ revenue }} Dollar 6 | Movie average rating (1-10): {{ average_rating }} 7 | Movie rating count: {{ rating_count }} 8 | Movie release date: {{ release_date }} 9 | Movie runtime: {{ runtime }} minutes 10 | -------------------------------------------------------------------------------- /gemini_movie_detectives_api/templates/personality/christmas.jinja: -------------------------------------------------------------------------------- 1 | You are Santa Claus and you love Christmas. Add as many Christmas related facts and trivia to your answers as you can. 2 | Also start your sentences often with "Ho ho ho" and end them with "Merry Christmas". You are a real 3 | Christmas enthusiast, so also do not forget Christmas related emojis in your responses. 4 | 5 | For formatting your responses, stick strictly to the template described below! 6 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v2.3.0 4 | hooks: 5 | - id: check-yaml 6 | - id: end-of-file-fixer 7 | - id: trailing-whitespace 8 | - repo: https://github.com/psf/black 9 | rev: 22.10.0 10 | hooks: 11 | - id: black 12 | - repo: https://github.com/astral-sh/ruff-pre-commit 13 | rev: v0.3.5 14 | hooks: 15 | - id: ruff 16 | - id: ruff-format 17 | -------------------------------------------------------------------------------- /gemini_movie_detectives_api/templates/personality/default.jinja: -------------------------------------------------------------------------------- 1 | You are a young, knowledgeable show master. People love the way you moderate, since you combine fun moderation with 2 | facts and there is always something people learn from your questions. You speak like a teenager with the language of 3 | the youth. You are the perfect show master for a young audience, phrasing things in a casual and cool way. Ensure to 4 | add youth words, slang words and phrases. 5 | 6 | For formatting your responses, stick strictly to the template described below! 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=linux/amd64 python:3.12-slim 2 | 3 | ENV PYTHONUNBUFFERED=1 \ 4 | PYTHONDONTWRITEBYTECODE=1 \ 5 | PIP_NO_CACHE_DIR=off \ 6 | PIP_DISABLE_PIP_VERSION_CHECK=on \ 7 | PIP_DEFAULT_TIMEOUT=100 \ 8 | POETRY_CACHE_DIR=/tmp/poetry_cache 9 | 10 | WORKDIR /workdir 11 | COPY ./pyproject.toml /workdir/pyproject.toml 12 | RUN pip3 install poetry==1.8.2 13 | RUN poetry config virtualenvs.create false 14 | RUN poetry install --no-dev && rm -rf $POETRY_CACHE_DIR 15 | 16 | COPY . /workdir 17 | EXPOSE 9091 18 | CMD ["fastapi", "run", "gemini_movie_detectives_api/main.py", "--port", "9091"] 19 | -------------------------------------------------------------------------------- /gemini_movie_detectives_api/templates/title-detectives/prompt_answer.jinja: -------------------------------------------------------------------------------- 1 | The participants answered: 2 | 3 | {{ answer }} 4 | 5 | It is your decision how many points the participants get. They get 0, 1, 2 or 3 points based on this definition: 6 | 7 | 0: no points, to far away from original title 8 | 1-2: close enough, depends on your decision 9 | 3: best result, got exact title, small spelling mistakes are ok 10 | 11 | Be nice, if it is close, it is fine. Answer in a funny and nice way. 12 | 13 | Reply using this JSON schema: 14 | 15 | {"points": int, "answer": str} 16 | 17 | - points: The amount of points you want to give the participants, must be between 0 and 3 18 | - answer: Your answer to the participants 19 | -------------------------------------------------------------------------------- /gemini_movie_detectives_api/templates/sequel-salad/prompt_answer.jinja: -------------------------------------------------------------------------------- 1 | The participants answered: 2 | 3 | {{ answer }} 4 | 5 | It is your decision how many points the participants get. They get 0, 1, 2 or 3 points based on this definition: 6 | 7 | 0: no points, to far away from original franchise 8 | 1-2: close enough, depends on your decision 9 | 3: best result, got exact franchise, small spelling mistakes are ok 10 | 11 | Be nice, if it is close, it is fine. Answer in a funny and nice way. 12 | 13 | Reply using this JSON schema: 14 | 15 | {"points": int, "answer": str} 16 | 17 | - points: The amount of points you want to give the participants, must be between 0 and 3 18 | - answer: Your answer to the participants 19 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test and lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v4 18 | 19 | - uses: actions/setup-python@v3 20 | with: 21 | python-version: "3.12" 22 | 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install poetry 27 | poetry install 28 | 29 | - name: Run tests 30 | run: poetry run python -m unittest -v 31 | 32 | - name: Lint 33 | uses: chartboost/ruff-action@v1 34 | -------------------------------------------------------------------------------- /gemini_movie_detectives_api/template.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from jinja2 import Environment, PackageLoader, select_autoescape 4 | 5 | from gemini_movie_detectives_api.model import Personality, QuizType 6 | 7 | 8 | class TemplateManager: 9 | 10 | def __init__(self): 11 | self.env = Environment( 12 | loader=PackageLoader('gemini_movie_detectives_api'), 13 | autoescape=select_autoescape() 14 | ) 15 | 16 | def render_template(self, quiz_type: QuizType, name: str, **kwargs: Any) -> str: 17 | return self.env.get_template(f'{quiz_type.value}/{name}.jinja').render(**kwargs) 18 | 19 | def render_personality(self, personality: Personality) -> str: 20 | return self.env.get_template(f'personality/{personality.value}.jinja').render() 21 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "gemini-movie-detectives-api" 3 | version = "0.1.0" 4 | description = "Use Gemini Pro LLM via VertexAI to create an engaging quiz game incorporating TMDB API data" 5 | authors = ["Volker Janz "] 6 | readme = "README.md" 7 | 8 | [tool.poetry.dependencies] 9 | python = "^3.12" 10 | fastapi = "^0.111.0" 11 | uvicorn = {extras = ["standard"], version = "^0.29.0"} 12 | python-dotenv = "^1.0.1" 13 | httpx = "^0.27.0" 14 | pydantic-settings = "^2.2.1" 15 | google-cloud-aiplatform = ">=1.38" 16 | jinja2 = "^3.1.3" 17 | ruff = "^0.3.5" 18 | pre-commit = "^3.7.0" 19 | google-cloud-texttospeech = "^2.16.3" 20 | emoji = "^2.12.1" 21 | apscheduler = "^3.10.4" 22 | firebase-admin = "^6.5.0" 23 | wikipedia = "^1.4.0" 24 | pytest = "^8.2.2" 25 | colorlog = "^6.8.2" 26 | google-cloud-logging = "^3.10.0" 27 | pytz = "^2025.1" 28 | 29 | 30 | [build-system] 31 | requires = ["poetry-core"] 32 | build-backend = "poetry.core.masonry.api" 33 | -------------------------------------------------------------------------------- /gemini_movie_detectives_api/templates/title-detectives/prompt_question.jinja: -------------------------------------------------------------------------------- 1 | You are the host for a movie quiz show for children and young adults. The participants have to guess the movie title 2 | based on the question you ask. 3 | 4 | {{ personality }} 5 | 6 | Together with the actual question, you also give 2 hints. 7 | 8 | The first hint is to help guessing the movie. Give a more obvious hint, but still not directly revealing the title. 9 | 10 | The second hint should be the movie title, but you replace half of the letters with underscores. For example, if the 11 | movie title is "The Lion King", you would write "T_e _i_n _i_g". 12 | 13 | Ensure to not add the title directly to the question and or hints. The participants should guess the title based on the 14 | information you provide. 15 | 16 | The title you are asking for is: 17 | 18 | {{ title }} 19 | 20 | Here are some details to have more input for coming up with a proper question, you can use them in the question or hint: 21 | 22 | {% include 'title-detectives/metadata.jinja' %} 23 | 24 | Reply using this JSON schema: 25 | 26 | {"question": str, "hint1": str, "hint2": str} 27 | 28 | - question: your generated question 29 | - hint1: The first hint to help the participants 30 | - hint2: The second hint to get the title more easily 31 | -------------------------------------------------------------------------------- /gemini_movie_detectives_api/config.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | from pydantic import BaseModel 3 | from pydantic_settings import BaseSettings, SettingsConfigDict 4 | 5 | 6 | class TmdbImagesConfig(BaseModel): 7 | base_url: str 8 | secure_base_url: str 9 | backdrop_sizes: list[str] 10 | logo_sizes: list[str] 11 | poster_sizes: list[str] 12 | profile_sizes: list[str] 13 | still_sizes: list[str] 14 | 15 | 16 | class Settings(BaseSettings): 17 | model_config = SettingsConfigDict(env_file='.env', env_file_encoding='utf-8') 18 | tmdb_api_key: str 19 | tmp_images_dir: str = '/tmp/movie-detectives/images' 20 | tmp_audio_dir: str = '/tmp/movie-detectives/audio' 21 | cleanup_interval_min: int = 10 22 | cleanup_file_max_age_sec: int = 3600 23 | gcp_gemini_model: str = 'gemini-2.0-flash' 24 | gcp_imagen_model: str = 'imagen-3.0-generate-002' 25 | gcp_tts_lang: str = 'en-US' 26 | gcp_tts_voice: str = 'en-US-Studio-Q' 27 | gcp_project_id: str 28 | gcp_location: str 29 | gcp_service_account_file: str 30 | gcp_cloud_logging_enabled: bool = False 31 | firebase_service_account_file: str 32 | quiz_max_retries: int = 4 33 | limits_reset_password: str = 'secret' 34 | 35 | 36 | def load_tmdb_images_config(settings: Settings) -> TmdbImagesConfig: 37 | response = httpx.get('https://api.themoviedb.org/3/configuration', headers={ 38 | 'Authorization': f'Bearer {settings.tmdb_api_key}' 39 | }) 40 | 41 | return TmdbImagesConfig(**response.json()['images']) 42 | -------------------------------------------------------------------------------- /gemini_movie_detectives_api/cleanup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | from pathlib import Path 4 | from typing import List 5 | 6 | from apscheduler.schedulers.background import BackgroundScheduler 7 | import logging 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class TempDirCleaner: 13 | 14 | def __init__(self, dir_paths: List[Path], age_limit_seconds: int = 3600, interval_minutes: int = 10): 15 | self.dir_paths = dir_paths 16 | self.age_limit_seconds = age_limit_seconds 17 | self.interval_minutes = interval_minutes 18 | self.scheduler = BackgroundScheduler() 19 | 20 | # create the directories if they do not exist 21 | for dir_path in self.dir_paths: 22 | dir_path.mkdir(parents=True, exist_ok=True) 23 | 24 | # perform initial cleanup 25 | self.cleanup() 26 | 27 | def cleanup(self) -> None: 28 | now = time.time() 29 | for dir_path in self.dir_paths: 30 | for filename in os.listdir(dir_path): 31 | file_path = dir_path / filename 32 | if file_path.is_file(): 33 | file_age = now - file_path.stat().st_mtime 34 | if file_age > self.age_limit_seconds: 35 | os.remove(file_path) 36 | logger.info('Removed %s', file_path) 37 | 38 | def start(self) -> None: 39 | self.scheduler.add_job(self.cleanup, 'interval', minutes=self.interval_minutes) 40 | self.scheduler.start() 41 | logger.info('Started temp dir cleaner with interval %d minutes', self.interval_minutes) 42 | 43 | def stop(self) -> None: 44 | self.scheduler.shutdown() 45 | logger.info('Stopped temp dir cleaner') 46 | -------------------------------------------------------------------------------- /gemini_movie_detectives_api/quiz/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Any, Optional, TypeVar, Generic 3 | 4 | from vertexai.generative_models import ChatSession 5 | 6 | from gemini_movie_detectives_api.gemini import GeminiClient 7 | from gemini_movie_detectives_api.imagen import ImagenClient 8 | from gemini_movie_detectives_api.model import Personality 9 | from gemini_movie_detectives_api.speech import SpeechClient 10 | from gemini_movie_detectives_api.storage import FirestoreClient 11 | from gemini_movie_detectives_api.template import TemplateManager 12 | from gemini_movie_detectives_api.tmdb import TmdbClient 13 | from gemini_movie_detectives_api.wiki import WikiClient 14 | 15 | T = TypeVar('T') 16 | R = TypeVar('R') 17 | 18 | 19 | class AbstractQuiz(ABC, Generic[T, R]): 20 | def __init__( 21 | self, 22 | template_manager: TemplateManager, 23 | gemini_client: GeminiClient, 24 | imagen_client: ImagenClient, 25 | speech_client: SpeechClient, 26 | firestore_client: FirestoreClient, 27 | tmdb_client: TmdbClient, 28 | wiki_client: WikiClient 29 | ): 30 | self.template_manager = template_manager 31 | self.gemini_client = gemini_client 32 | self.imagen_client = imagen_client 33 | self.speech_client = speech_client 34 | self.firestore_client = firestore_client 35 | self.tmdb_client = tmdb_client 36 | self.wiki_client = wiki_client 37 | 38 | @abstractmethod 39 | def start_quiz(self, personality: Personality, chat: ChatSession) -> T: 40 | pass 41 | 42 | @abstractmethod 43 | def finish_quiz(self, answer: Any, quiz_data: T, chat: ChatSession, user_id: Optional[str]) -> R: 44 | pass 45 | -------------------------------------------------------------------------------- /gemini_movie_detectives_api/templates/bttf-trivia/prompt_question.jinja: -------------------------------------------------------------------------------- 1 | You are the host for a movie quiz show for children and young adults. The participants have to choose the correct answer 2 | out of 4 possible answers for your question. 3 | 4 | {{ personality }} 5 | 6 | The show is all about the movie franchise "Back to the Future". So ensure to generate a question that is related to this 7 | franchise. Also, your replies should be in the spirit of the franchise but still considering the personality described 8 | above. 9 | 10 | Use the following context, to generate a question for the participants: 11 | 12 | -- BEGIN CONTEXT --- 13 | 14 | {{ context }} 15 | 16 | -- END CONTEXT --- 17 | 18 | Together with the actual question, you also give 4 possible answers to choose from. 19 | 20 | Exactly one of the four answers must be correct, the other three must be wrong. Choose randomly, which of the four 21 | answers is the correct one. Ensure that the correct answer is not always the same position and that the correct 22 | answer is based on the context given above. 23 | 24 | Also, the three wrong answers should be plausible, but not too close to the correct answer and must be wrong based on 25 | the given context. 26 | 27 | Reply using this JSON schema: 28 | 29 | {"question": str, "option_1": str, "option_2": str, "option_3": str, "option_4": str, "correct_answer": int} 30 | 31 | The strings in your reply must not contain any double quotes ("). 32 | 33 | - question: your generated question 34 | - option_1: The first possible answer 35 | - option_2: The second possible answer 36 | - option_3: The third possible answer 37 | - option_4: The fourth possible answer 38 | - correct_answer: The number of the correct answer (1-4), only one of them must be correct 39 | -------------------------------------------------------------------------------- /tests/test_sequel_salad.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import Mock 3 | 4 | from gemini_movie_detectives_api.model import Personality, SequelSaladData 5 | from gemini_movie_detectives_api.quiz.sequel_salad import SequelSalad 6 | from gemini_movie_detectives_api.template import TemplateManager 7 | 8 | 9 | class TestSequelSalad(unittest.TestCase): 10 | 11 | def test_start_quiz(self): 12 | template_manager = TemplateManager() 13 | 14 | gemini_client = Mock() 15 | imagen_client = Mock() 16 | speech_client = Mock() 17 | firestore_client = Mock() 18 | tmdb_client = Mock() 19 | wiki_client = Mock() 20 | chat_session = Mock() 21 | 22 | franchises = ['franchise1', 'franchise2'] 23 | 24 | firestore_client.get_franchises.return_value = franchises 25 | gemini_client.get_chat_response.return_value = '{"sequel_plot": "plot", "sequel_title": "title", "poster_prompt": "prompt"}' 26 | speech_client.synthesize_to_file.return_value = 'audio.mp3' 27 | imagen_client.generate_image.return_value = 'poster.jpg' 28 | 29 | sequel_salad = SequelSalad(template_manager, gemini_client, imagen_client, speech_client, firestore_client, tmdb_client, wiki_client) 30 | sequel_salad_data: SequelSaladData = sequel_salad.start_quiz(Personality.DEFAULT, chat_session) 31 | 32 | self.assertIn(sequel_salad_data.franchise, franchises) 33 | self.assertEqual('audio.mp3', sequel_salad_data.speech) 34 | self.assertEqual('poster.jpg', sequel_salad_data.poster) 35 | 36 | self.assertEqual('plot', sequel_salad_data.question.sequel_plot) 37 | self.assertEqual('title', sequel_salad_data.question.sequel_title) 38 | self.assertEqual('prompt', sequel_salad_data.question.poster_prompt) 39 | 40 | 41 | if __name__ == '__main__': 42 | unittest.main() 43 | -------------------------------------------------------------------------------- /gemini_movie_detectives_api/templates/trivia/prompt_question.jinja: -------------------------------------------------------------------------------- 1 | You are the host for a movie quiz show for children and young adults. The participants have to choose the correct answer 2 | out of 4 possible answers for your question. 3 | 4 | {{ personality }} 5 | 6 | The question must be about a specific movie, which is: 7 | 8 | {{ title }} 9 | 10 | Here are some details to have more input for coming up with a proper question: 11 | 12 | {% include 'trivia/metadata.jinja' %} 13 | 14 | Also, use the following context about the movie, to generate a question for the participants: 15 | 16 | -- BEGIN CONTEXT --- 17 | 18 | {{ context }} 19 | 20 | -- END CONTEXT --- 21 | 22 | Together with the actual question, you also give 4 possible answers to choose from. 23 | 24 | Exactly one of the four answers must be correct, the other three must be wrong. Choose randomly, which of the four 25 | answers is the correct one. Ensure that the correct answer is not always the same position and that the correct 26 | answer is based on the context given above. 27 | 28 | Ensure to not include the correct answer in the question itself. The question should be solvable based on the context. 29 | 30 | Also, the three wrong answers should be plausible, but not too close to the correct answer and must be wrong based on 31 | the given context. 32 | 33 | Reply using this JSON schema: 34 | 35 | {"question": str, "option_1": str, "option_2": str, "option_3": str, "option_4": str, "correct_answer": int} 36 | 37 | The strings in your reply must not contain any double quotes ("). 38 | 39 | - question: your generated question about the given movie 40 | - option_1: The first possible answer 41 | - option_2: The second possible answer 42 | - option_3: The third possible answer 43 | - option_4: The fourth possible answer 44 | - correct_answer: The number of the correct answer (1-4), only one of them must be correct 45 | -------------------------------------------------------------------------------- /tests/test_bttf_trivia.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import Mock 3 | 4 | from gemini_movie_detectives_api.model import Personality, BttfTriviaData 5 | from gemini_movie_detectives_api.quiz.bttf_trivia import BttfTrivia 6 | from gemini_movie_detectives_api.template import TemplateManager 7 | 8 | 9 | class TestTitleDetectives(unittest.TestCase): 10 | 11 | def test_start_quiz(self): 12 | template_manager = TemplateManager() 13 | 14 | gemini_client = Mock() 15 | imagen_client = Mock() 16 | speech_client = Mock() 17 | firestore_client = Mock() 18 | tmdb_client = Mock() 19 | wiki_client = Mock() 20 | chat_session = Mock() 21 | 22 | wiki_client.get_random_bttf_facts.return_value = 'facts' 23 | gemini_client.get_chat_response.return_value = '{"question": "question", "option_1": "option 1", "option_2": "option 2" , "option_3": "option 3" , "option_4": "option 4", "correct_answer": 4}' 24 | speech_client.synthesize_to_file.return_value = 'audio.mp3' 25 | 26 | bttf_trivia = BttfTrivia(template_manager, gemini_client, imagen_client, speech_client, firestore_client, tmdb_client, wiki_client) 27 | bttf_trivia_data: BttfTriviaData = bttf_trivia.start_quiz(Personality.DEFAULT, chat_session) 28 | 29 | self.assertEqual('audio.mp3', bttf_trivia_data.speech) 30 | 31 | self.assertEqual('question', bttf_trivia_data.question.question) 32 | self.assertEqual('option 1', bttf_trivia_data.question.option_1) 33 | self.assertEqual('option 2', bttf_trivia_data.question.option_2) 34 | self.assertEqual('option 3', bttf_trivia_data.question.option_3) 35 | self.assertEqual('option 4', bttf_trivia_data.question.option_4) 36 | self.assertEqual(4, bttf_trivia_data.question.correct_answer) 37 | 38 | 39 | if __name__ == '__main__': 40 | unittest.main() 41 | -------------------------------------------------------------------------------- /gemini_movie_detectives_api/templates/sequel-salad/prompt_question.jinja: -------------------------------------------------------------------------------- 1 | You are the host for a movie quiz show for children and young adults. 2 | 3 | {{ personality }} 4 | 5 | For a real, existing movie franchise, you need to come up with a new part that does not yet exist and takes place after 6 | the real, existing movies of this franchise. You need to generate a fake plot for your new movie in this franchise. 7 | 8 | The franchise for your fake sequel is: {{ franchise }} 9 | 10 | This plot must not be longer than 600 characters. It must be a short summary, with some hints about the franchise but it 11 | most not contain the actual name of the franchise. It is the task of the participants to guess the franchise based on 12 | your generated fake plot for a real, existing franchise. 13 | 14 | Also come up with a title of your fake sequel for the franchise. Ensure that it fits the franchise and the plot. The title 15 | must not contain the name of the franchise! 16 | 17 | Together with the plot and title, generate a prompt for a text-to-image diffusion model, to generate a movie poster for 18 | your fake movie. The prompt must be a short, keyword-based text that describes the movie poster you want to generate. 19 | Ensure your fake movie poster prompt fits the plot and title of your fake sequel and contains a short style, subject and 20 | context description. Ensure the prompt fits to the Google Imagen safety guidelines, write your prompt in a way to generate 21 | a family-friendly movie poster, while maintaining the context of the plot. You must not use any sensitive or negative words 22 | in the generated prompt, that violate Google's Responsible AI practices. 23 | 24 | Reply using this JSON schema: 25 | 26 | {"sequel_plot": str, "sequel_title": str, "poster_prompt": str} 27 | 28 | - sequel_plot: The plot of your fake sequel 29 | - sequel_title: The title of your fake sequel 30 | - poster_prompt: The prompt for the movie poster 31 | -------------------------------------------------------------------------------- /gemini_movie_detectives_api/speech.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from pathlib import Path 3 | 4 | import emoji 5 | from google.cloud import texttospeech 6 | from google.oauth2.service_account import Credentials 7 | 8 | 9 | class SpeechClient: 10 | 11 | def __init__( 12 | self, 13 | tmp_audio_dir: Path, 14 | credentials: Credentials, 15 | language_code: str, 16 | voice_name: str, 17 | audio_encoding: texttospeech.AudioEncoding = texttospeech.AudioEncoding.LINEAR16, 18 | speaking_rate: float = 0.85 19 | ) -> None: 20 | self.tmp_audio_dir = tmp_audio_dir 21 | self.client = texttospeech.TextToSpeechClient(credentials=credentials) 22 | 23 | # noinspection PyTypeChecker 24 | self.voice = texttospeech.VoiceSelectionParams( 25 | language_code=language_code, 26 | name=voice_name 27 | ) 28 | 29 | # noinspection PyTypeChecker 30 | self.audio_config = texttospeech.AudioConfig( 31 | audio_encoding=audio_encoding, 32 | speaking_rate=speaking_rate 33 | ) 34 | 35 | def synthesize(self, text: str) -> bytes: 36 | # remove emojis 37 | text = emoji.replace_emoji(text, replace='') 38 | 39 | # noinspection PyTypeChecker 40 | synthesis_input = texttospeech.SynthesisInput(text=text) 41 | response = self.client.synthesize_speech( 42 | input=synthesis_input, 43 | voice=self.voice, 44 | audio_config=self.audio_config 45 | ) 46 | 47 | return response.audio_content 48 | 49 | def synthesize_to_file(self, text: str) -> str: 50 | audio_bytes = self.synthesize(text) 51 | file_id = str(uuid.uuid4()) 52 | audio_file_path = f'{self.tmp_audio_dir}/{file_id}.mp3' 53 | 54 | with open(audio_file_path, 'wb') as file: 55 | file.write(audio_bytes) 56 | 57 | file_url = f'/audio/{file_id}.mp3' 58 | return file_url 59 | -------------------------------------------------------------------------------- /gemini_movie_detectives_api/gemini.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import vertexai 4 | from google.oauth2.service_account import Credentials 5 | from vertexai import generative_models 6 | from vertexai.generative_models import GenerativeModel, ChatSession 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | GENERATION_CONFIG = { 12 | 'temperature': 0.6, 13 | # initialize Gemini with JSON mode enabled, see: https://ai.google.dev/gemini-api/docs/api-overview#json 14 | 'response_mime_type': 'application/json' 15 | } 16 | 17 | SAFETY_CONFIG = [ 18 | generative_models.SafetySetting( 19 | category=generative_models.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, 20 | threshold=generative_models.HarmBlockThreshold.BLOCK_ONLY_HIGH, 21 | ), 22 | generative_models.SafetySetting( 23 | category=generative_models.HarmCategory.HARM_CATEGORY_HARASSMENT, 24 | threshold=generative_models.HarmBlockThreshold.BLOCK_ONLY_HIGH, 25 | ), 26 | generative_models.SafetySetting( 27 | category=generative_models.HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, 28 | threshold=generative_models.HarmBlockThreshold.BLOCK_ONLY_HIGH, 29 | ), 30 | ] 31 | 32 | 33 | class GeminiClient: 34 | 35 | def __init__(self, project_id: str, location: str, credentials: Credentials, model: str): 36 | vertexai.init(project=project_id, location=location, credentials=credentials) 37 | 38 | logger.info('loading model: %s', model) 39 | logger.info('generation config: %s', GENERATION_CONFIG) 40 | 41 | self.model = GenerativeModel(model, safety_settings=SAFETY_CONFIG) 42 | 43 | def start_chat(self) -> ChatSession: 44 | return self.model.start_chat(response_validation=False) 45 | 46 | @staticmethod 47 | def get_chat_response(chat: ChatSession, prompt: str) -> str: 48 | text_response = [] 49 | responses = chat.send_message(prompt, generation_config=GENERATION_CONFIG, stream=True) 50 | for chunk in responses: 51 | text_response.append(chunk.text) 52 | return ''.join(text_response) 53 | -------------------------------------------------------------------------------- /gemini_movie_detectives_api/imagen.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import uuid 3 | from pathlib import Path 4 | from typing import Optional 5 | 6 | import vertexai 7 | from google.oauth2.service_account import Credentials 8 | from vertexai.preview.vision_models import ImageGenerationModel 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class ImagenClient: 14 | 15 | def __init__(self, project_id: str, location: str, credentials: Credentials, model: str, tmp_images_dir: Path): 16 | vertexai.init(project=project_id, location=location, credentials=credentials) 17 | logger.info('loading model: %s', model) 18 | 19 | self.model = ImageGenerationModel.from_pretrained(model) 20 | self.tmp_images_dir = tmp_images_dir 21 | 22 | def generate_image(self, prompt: str, fallback: Optional[str] = None) -> Optional[str]: 23 | file_id = uuid.uuid4() 24 | image_file_path = f'{self.tmp_images_dir}/{file_id}.png' 25 | 26 | if self._try_generate_image(prompt, image_file_path): 27 | return f'/images/{file_id}.png' 28 | 29 | if fallback and self._try_generate_image(self._get_fallback_prompt(fallback), image_file_path): 30 | logger.info('used fallback prompt to generate image') 31 | return f'/images/{file_id}.png' 32 | 33 | return None 34 | 35 | def _try_generate_image(self, prompt: str, image_file_path: str) -> bool: 36 | try: 37 | self.model.generate_images( 38 | prompt=prompt, 39 | aspect_ratio='3:4', 40 | number_of_images=1, 41 | safety_filter_level='block_few', 42 | person_generation='allow_adult', 43 | ).images[0].save(image_file_path, include_generation_parameters=False) 44 | 45 | return True 46 | except Exception as e: 47 | logger.warning('could not generate image: %s', e) 48 | return False 49 | 50 | @staticmethod 51 | def _get_fallback_prompt(fallback: str) -> str: 52 | return f'Kids friendly movie poster in the context of: {fallback}' 53 | -------------------------------------------------------------------------------- /tests/test_title_detectives.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import Mock 3 | 4 | from gemini_movie_detectives_api.model import Personality, TitleDetectivesData 5 | from gemini_movie_detectives_api.quiz.title_detectives import TitleDetectives 6 | from gemini_movie_detectives_api.template import TemplateManager 7 | 8 | 9 | class TestTitleDetectives(unittest.TestCase): 10 | 11 | def test_start_quiz(self): 12 | template_manager = TemplateManager() 13 | 14 | gemini_client = Mock() 15 | imagen_client = Mock() 16 | speech_client = Mock() 17 | firestore_client = Mock() 18 | tmdb_client = Mock() 19 | wiki_client = Mock() 20 | chat_session = Mock() 21 | 22 | tmdb_client.get_random_movie.return_value = { 23 | 'title': 'Some Movie', 24 | 'tagline': 'A Great Adventure', 25 | 'overview': 'Lorem ipsum dolor sit amet', 26 | 'genres': [{'name': 'Action'}, {'name': 'Adventure'}], 27 | 'budget': 1000000, 28 | 'revenue': 2000000, 29 | 'vote_average': 8.0, 30 | 'vote_count': 1000, 31 | 'release_date': '2021-01-01', 32 | 'runtime': 120 33 | } 34 | 35 | gemini_client.get_chat_response.return_value = '{"question": "What is the movie?", "hint1": "hint1", "hint2": "hint2"}' 36 | speech_client.synthesize_to_file.return_value = 'audio.mp3' 37 | 38 | title_detectives = TitleDetectives(template_manager, gemini_client, imagen_client, speech_client, firestore_client, tmdb_client, wiki_client) 39 | title_detectives_data: TitleDetectivesData = title_detectives.start_quiz(Personality.DEFAULT, chat_session) 40 | 41 | self.assertEqual('Some Movie', title_detectives_data.movie['title']) 42 | self.assertEqual('audio.mp3', title_detectives_data.speech) 43 | 44 | self.assertEqual('What is the movie?', title_detectives_data.question.question) 45 | self.assertEqual('hint1', title_detectives_data.question.hint1) 46 | self.assertEqual('hint2', title_detectives_data.question.hint2) 47 | 48 | 49 | if __name__ == '__main__': 50 | unittest.main() 51 | -------------------------------------------------------------------------------- /gemini_movie_detectives_api/tmdb.py: -------------------------------------------------------------------------------- 1 | import random 2 | from functools import lru_cache 3 | from typing import List 4 | 5 | import httpx 6 | 7 | from gemini_movie_detectives_api.config import TmdbImagesConfig 8 | 9 | 10 | class TmdbClient: 11 | 12 | def __init__(self, tmdb_api_key: str, tmdb_images_config: TmdbImagesConfig): 13 | self.tmdb_images_config = tmdb_images_config 14 | self.tmdb_api_key = tmdb_api_key 15 | 16 | def get_poster_url(self, poster_path: str, size='original') -> str: 17 | base_url = self.tmdb_images_config.secure_base_url 18 | 19 | if size not in self.tmdb_images_config.poster_sizes: 20 | size = 'original' 21 | 22 | return f'{base_url}{size}{poster_path}' 23 | 24 | def get_movies(self, page: int, vote_avg_min: float, vote_count_min: float) -> List[dict]: 25 | response = httpx.get('https://api.themoviedb.org/3/discover/movie', headers={ 26 | 'Authorization': f'Bearer {self.tmdb_api_key}' 27 | }, params={ 28 | 'sort_by': 'popularity.desc', 29 | 'include_adult': 'false', 30 | 'include_video': 'false', 31 | 'language': 'en-US', 32 | 'with_original_language': 'en', 33 | 'vote_average.gte': vote_avg_min, 34 | 'vote_count.gte': vote_count_min, 35 | 'page': page 36 | }) 37 | 38 | movies = response.json()['results'] 39 | 40 | for movie in movies: 41 | movie['poster_url'] = self.get_poster_url(movie['poster_path']) 42 | 43 | return movies 44 | 45 | def get_random_movie(self, page_min: int, page_max: int, vote_avg_min: float, vote_count_min: float) -> dict | None: 46 | movies = self.get_movies(random.randint(page_min, page_max), vote_avg_min, vote_count_min) 47 | if not movies: 48 | return None 49 | 50 | return self.get_movie_details(random.choice(movies)['id']) 51 | 52 | @lru_cache(maxsize=1024) 53 | def get_movie_details(self, movie_id: int) -> dict: 54 | response = httpx.get(f'https://api.themoviedb.org/3/movie/{movie_id}', headers={ 55 | 'Authorization': f'Bearer {self.tmdb_api_key}' 56 | }, params={ 57 | 'language': 'en-US' 58 | }) 59 | 60 | movie = response.json() 61 | movie['poster_url'] = self.get_poster_url(movie['poster_path']) 62 | 63 | return movie 64 | -------------------------------------------------------------------------------- /gemini_movie_detectives_api/wiki.py: -------------------------------------------------------------------------------- 1 | import random 2 | from time import sleep 3 | 4 | import wikipedia 5 | from pydantic import BaseModel 6 | 7 | from gemini_movie_detectives_api.tmdb import TmdbClient 8 | 9 | 10 | class MovieFacts(BaseModel): 11 | movie_title: str 12 | facts: str 13 | movie: dict 14 | 15 | 16 | class WikiClient: 17 | 18 | MAX_RETRIES = 5 19 | RETRY_DELAY = 1 20 | 21 | def __init__(self, tmdb_client: TmdbClient): 22 | self.tmdb_client = tmdb_client 23 | wikipedia.set_lang('en') 24 | 25 | def get_random_movie_facts(self) -> MovieFacts: 26 | retries = 0 27 | while retries < self.MAX_RETRIES: 28 | try: 29 | # The following config ensures to get rather familiar movies 30 | random_movie = self.tmdb_client.get_random_movie( 31 | page_min=1, 32 | page_max=10, 33 | vote_avg_min=4.0, 34 | vote_count_min=4000 35 | ) 36 | 37 | original_title = random_movie['original_title'] 38 | related_pages = wikipedia.search(original_title) 39 | if not related_pages: 40 | raise ValueError(f'No Wikipedia page found for {original_title}') 41 | 42 | return MovieFacts( 43 | movie_title=original_title, 44 | facts=wikipedia.page(related_pages[0]).content, 45 | movie=random_movie 46 | ) 47 | except BaseException as e: 48 | retries += 1 49 | if retries >= self.MAX_RETRIES: 50 | raise ValueError('Failed to get random movie facts after multiple attempts') from e 51 | sleep(self.RETRY_DELAY) 52 | 53 | def get_random_bttf_facts(self) -> str: 54 | retries = 0 55 | while retries < self.MAX_RETRIES: 56 | try: 57 | related_pages = wikipedia.search('Back to the Future') 58 | random_page = random.choice(related_pages) 59 | 60 | return wikipedia.page(random_page).content 61 | except BaseException as e: 62 | retries += 1 63 | if retries >= self.MAX_RETRIES: 64 | raise ValueError('Failed to get bttf facts after multiple attempts') from e 65 | sleep(self.RETRY_DELAY) 66 | -------------------------------------------------------------------------------- /tests/test_trivia.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import Mock 3 | 4 | from gemini_movie_detectives_api.model import Personality, TriviaData 5 | from gemini_movie_detectives_api.quiz.trivia import Trivia 6 | from gemini_movie_detectives_api.template import TemplateManager 7 | from gemini_movie_detectives_api.wiki import MovieFacts 8 | 9 | 10 | class TestTitleDetectives(unittest.TestCase): 11 | 12 | def test_start_quiz(self): 13 | template_manager = TemplateManager() 14 | 15 | gemini_client = Mock() 16 | imagen_client = Mock() 17 | speech_client = Mock() 18 | firestore_client = Mock() 19 | tmdb_client = Mock() 20 | wiki_client = Mock() 21 | chat_session = Mock() 22 | 23 | wiki_client.get_random_movie_facts.return_value = MovieFacts(movie_title='Some Movie', facts='facts', movie={ 24 | 'title': 'Some Movie', 25 | 'tagline': 'A Great Adventure', 26 | 'overview': 'Lorem ipsum dolor sit amet', 27 | 'genres': [{'name': 'Action'}, {'name': 'Adventure'}], 28 | 'budget': 1000000, 29 | 'revenue': 2000000, 30 | 'vote_average': 8.0, 31 | 'vote_count': 1000, 32 | 'release_date': '2021-01-01', 33 | 'runtime': 120 34 | }) 35 | gemini_client.get_chat_response.return_value = '{"question": "question", "option_1": "option 1", "option_2": "option 2" , "option_3": "option 3" , "option_4": "option 4", "correct_answer": 4}' 36 | speech_client.synthesize_to_file.return_value = 'audio.mp3' 37 | 38 | trivia = Trivia(template_manager, gemini_client, imagen_client, speech_client, firestore_client, tmdb_client, wiki_client) 39 | trivia_data: TriviaData = trivia.start_quiz(Personality.DEFAULT, chat_session) 40 | 41 | self.assertEqual('audio.mp3', trivia_data.speech) 42 | self.assertEqual('Some Movie', trivia_data.movie['title']) 43 | 44 | self.assertEqual('question', trivia_data.question.question) 45 | self.assertEqual('option 1', trivia_data.question.option_1) 46 | self.assertEqual('option 2', trivia_data.question.option_2) 47 | self.assertEqual('option 3', trivia_data.question.option_3) 48 | self.assertEqual('option 4', trivia_data.question.option_4) 49 | self.assertEqual(4, trivia_data.question.correct_answer) 50 | 51 | 52 | if __name__ == '__main__': 53 | unittest.main() 54 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | define MOVIE_DETECTIVES_LOGO 2 | __ __ _ ____ _ _ _ 3 | | \/ | _____ _(_) ___ | _ \ ___| |_ ___ ___| |_(_)_ _____ ___ 4 | | |\/| |/ _ \ \ / / |/ _ \ | | | |/ _ \ __/ _ \/ __| __| \ \ / / _ \/ __| 5 | | | | | (_) \ V /| | __/ | |_| | __/ || __/ (__| |_| |\ V / __/\__ | 6 | |_| |_|\___/ \_/ |_|\___| |____/ \___|\__\___|\___|\__|_| \_/ \___||___/ 7 | endef 8 | export MOVIE_DETECTIVES_LOGO 9 | 10 | .PHONY: all 11 | all: 12 | @echo "$$MOVIE_DETECTIVES_LOGO" 13 | @echo "Run make help to see available commands" 14 | 15 | .PHONY: help 16 | help: 17 | @echo "$$MOVIE_DETECTIVES_LOGO" 18 | @echo "Available commands:" 19 | @echo " make .venv - Install dependencies using Poetry" 20 | @echo " make run - Run service locally" 21 | @echo " make test - Run tests" 22 | @echo " make ruff - Run linter" 23 | @echo " make check - Run tests and linter" 24 | @echo " make docker-build - Build Docker image" 25 | @echo " make docker-start - Start Docker container" 26 | @echo " make docker-stop - Stop Docker container" 27 | @echo " make docker-logs - View Docker container logs" 28 | @echo " make clean - Remove build artifact" 29 | @echo " make build - Build deployable artifact" 30 | 31 | # Install dependencies 32 | .venv: 33 | @command -v poetry >/dev/null 2>&1 || { echo >&2 "Poetry is not installed"; exit 1; } 34 | poetry config virtualenvs.in-project true --local 35 | poetry install 36 | @echo "Virtual env was created within project dir as .venv" 37 | 38 | # Run service locally 39 | .PHONY: run 40 | run: 41 | poetry run fastapi dev gemini_movie_detectives_api/main.py 42 | 43 | # Run tests and linter 44 | .PHONY: test 45 | test: 46 | poetry run python -m pytest tests/ -v -Wignore 47 | 48 | .PHONY: ruff 49 | ruff: 50 | poetry run ruff check --fix 51 | 52 | .PHONY: check 53 | check: test ruff 54 | 55 | # Docker interaction to build image and run service with Docker 56 | .PHONY: docker-build 57 | docker-build: 58 | docker build -t gemini-movie-detectives-api . 59 | 60 | .PHONY: docker-start 61 | docker-start: docker-build 62 | docker run -d --rm --name gemini-movie-detectives-api -p 9091:9091 gemini-movie-detectives-api 63 | @echo "Gemini Movie Detectives Backend running on port 9091" 64 | 65 | .PHONY: docker-stop 66 | docker-stop: 67 | @if [ $$(docker ps -q -f name=gemini-movie-detectives-api) ]; then \ 68 | echo "Stopping gemini-movie-detectives-api container..."; \ 69 | docker stop gemini-movie-detectives-api; \ 70 | else \ 71 | echo "Container gemini-movie-detectives-api is not running"; \ 72 | fi 73 | 74 | .PHONY: docker-logs 75 | docker-logs: 76 | @if [ $$(docker ps -q -f name=gemini-movie-detectives-api) ]; then \ 77 | docker logs -f gemini-movie-detectives-api; \ 78 | else \ 79 | echo "Container gemini-movie-detectives-api is not running"; \ 80 | fi 81 | 82 | # Remove existing build artifact 83 | .PHONY: clean 84 | clean: 85 | rm -rf gemini-movie-detectives-api_latest.tar.gz 86 | 87 | # Build a deployable artifact 88 | .PHONY: build 89 | build: clean 90 | docker image rm gemini-movie-detectives-api 91 | docker build -t gemini-movie-detectives-api . 92 | docker save gemini-movie-detectives-api:latest | gzip > gemini-movie-detectives-api_latest.tar.gz 93 | -------------------------------------------------------------------------------- /gemini_movie_detectives_api/model.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from enum import Enum 3 | from typing import Union, Optional 4 | 5 | from pydantic import BaseModel, ConfigDict 6 | from vertexai.generative_models import ChatSession 7 | 8 | 9 | class QuizType(str, Enum): 10 | TITLE_DETECTIVES = 'title-detectives' 11 | SEQUEL_SALAD = 'sequel-salad' 12 | BTTF_TRIVIA = 'bttf-trivia' 13 | TRIVIA = 'trivia' 14 | 15 | 16 | class Personality(str, Enum): 17 | DEFAULT = 'default' 18 | CHRISTMAS = 'christmas' 19 | SCIENTIST = 'scientist' 20 | DAD = 'dad' 21 | 22 | 23 | # Title Detectives 24 | 25 | class TitleDetectivesGeminiQuestion(BaseModel): 26 | question: str 27 | hint1: str 28 | hint2: str 29 | 30 | 31 | class TitleDetectivesGeminiAnswer(BaseModel): 32 | points: int 33 | answer: str 34 | 35 | 36 | class TitleDetectivesData(BaseModel): 37 | question: TitleDetectivesGeminiQuestion 38 | movie: dict 39 | speech: str 40 | 41 | 42 | class TitleDetectivesResult(BaseModel): 43 | question: TitleDetectivesGeminiQuestion 44 | movie: dict 45 | user_answer: str 46 | result: TitleDetectivesGeminiAnswer 47 | speech: str 48 | 49 | 50 | # Sequel Salad 51 | 52 | class SequelSaladGeminiQuestion(BaseModel): 53 | sequel_plot: str 54 | sequel_title: str 55 | poster_prompt: str 56 | 57 | 58 | class SequelSaladGeminiAnswer(BaseModel): 59 | points: int 60 | answer: str 61 | 62 | 63 | class SequelSaladData(BaseModel): 64 | question: SequelSaladGeminiQuestion 65 | franchise: str 66 | speech: str 67 | poster: Optional[str] = None 68 | 69 | 70 | class SequelSaladResult(BaseModel): 71 | question: SequelSaladGeminiQuestion 72 | franchise: str 73 | user_answer: str 74 | result: SequelSaladGeminiAnswer 75 | speech: str 76 | 77 | 78 | # Back to the Future Trivia 79 | 80 | class BttfTriviaGeminiQuestion(BaseModel): 81 | question: str 82 | option_1: str 83 | option_2: str 84 | option_3: str 85 | option_4: str 86 | correct_answer: int 87 | 88 | 89 | class BttfTriviaGeminiAnswer(BaseModel): 90 | answer: str 91 | 92 | 93 | class BttfTriviaData(BaseModel): 94 | question: BttfTriviaGeminiQuestion 95 | speech: str 96 | 97 | 98 | class BttfTriviaResult(BaseModel): 99 | question: BttfTriviaGeminiQuestion 100 | user_answer: int 101 | result: BttfTriviaGeminiAnswer 102 | points: int 103 | speech: str 104 | 105 | 106 | # Movie Trivia 107 | 108 | class TriviaGeminiQuestion(BaseModel): 109 | question: str 110 | option_1: str 111 | option_2: str 112 | option_3: str 113 | option_4: str 114 | correct_answer: int 115 | 116 | 117 | class TriviaGeminiAnswer(BaseModel): 118 | answer: str 119 | 120 | 121 | class TriviaData(BaseModel): 122 | question: TriviaGeminiQuestion 123 | movie: dict 124 | speech: str 125 | 126 | 127 | class TriviaResult(BaseModel): 128 | question: TriviaGeminiQuestion 129 | movie: dict 130 | user_answer: int 131 | result: TriviaGeminiAnswer 132 | points: int 133 | speech: str 134 | 135 | 136 | class StartQuizRequest(BaseModel): 137 | quiz_type: QuizType 138 | personality: Personality = Personality.DEFAULT 139 | 140 | 141 | class StartQuizResponse(BaseModel): 142 | quiz_id: str 143 | quiz_type: QuizType 144 | quiz_data: Union[TitleDetectivesData, SequelSaladData, BttfTriviaData, TriviaData] 145 | 146 | 147 | class FinishQuizRequest(BaseModel): 148 | quiz_id: str 149 | answer: Union[str, int] 150 | 151 | 152 | class FinishQuizResponse(BaseModel): 153 | quiz_id: str 154 | quiz_result: Union[TitleDetectivesResult, SequelSaladResult, BttfTriviaResult, TriviaResult] 155 | 156 | 157 | class SessionData(BaseModel): 158 | model_config = ConfigDict(arbitrary_types_allowed=True) 159 | quiz_id: str 160 | quiz_type: QuizType 161 | quiz_data: Union[TitleDetectivesData, SequelSaladData, BttfTriviaData, TriviaData] 162 | chat: ChatSession 163 | started_at: datetime 164 | 165 | 166 | class SessionResponse(BaseModel): 167 | quiz_id: str 168 | quiz_type: QuizType 169 | started_at: datetime 170 | 171 | 172 | class ResetLimitsRequest(BaseModel): 173 | password: str 174 | 175 | 176 | class LimitsResponse(BaseModel): 177 | limits: dict 178 | usage_counts: dict 179 | current_date: datetime 180 | -------------------------------------------------------------------------------- /gemini_movie_detectives_api/quiz/sequel_salad.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import random 3 | from typing import Any, Optional 4 | 5 | from fastapi import HTTPException 6 | from google.api_core.exceptions import GoogleAPIError 7 | from pydantic_core import from_json 8 | from starlette import status 9 | from vertexai.generative_models import ChatSession 10 | 11 | from gemini_movie_detectives_api.model import SequelSaladData, SequelSaladGeminiQuestion, \ 12 | SequelSaladResult, SequelSaladGeminiAnswer, Personality, QuizType 13 | from gemini_movie_detectives_api.quiz.base import AbstractQuiz 14 | 15 | logger: logging.Logger = logging.getLogger(__name__) 16 | 17 | 18 | class SequelSalad(AbstractQuiz[SequelSaladData, SequelSaladResult]): 19 | 20 | def start_quiz(self, personality: Personality, chat: ChatSession) -> SequelSaladData: 21 | try: 22 | franchise = random.choice(self.firestore_client.get_franchises()) 23 | prompt = self._generate_question_prompt( 24 | personality=personality, 25 | franchise=franchise 26 | ) 27 | 28 | logger.debug('starting quiz with generated prompt: %s', prompt) 29 | gemini_reply = self.gemini_client.get_chat_response(chat, prompt) 30 | gemini_question = self._parse_gemini_question(gemini_reply) 31 | 32 | poster = self.imagen_client.generate_image(gemini_question.poster_prompt, fallback=franchise) 33 | 34 | logger.info('correct answer: %s', franchise) 35 | 36 | return SequelSaladData( 37 | question=gemini_question, 38 | franchise=franchise, 39 | speech=self.speech_client.synthesize_to_file(gemini_question.sequel_plot), 40 | poster=poster 41 | ) 42 | except GoogleAPIError as e: 43 | raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Google API error: {e}') 44 | except BaseException as e: 45 | raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Internal server error: {e}') 46 | 47 | def finish_quiz(self, answer: str, quiz_data: SequelSaladData, chat: ChatSession, user_id: Optional[str]) -> SequelSaladResult: 48 | try: 49 | prompt = self._generate_answer_prompt(answer=answer) 50 | 51 | logger.debug('evaluating quiz answer with generated prompt: %s', prompt) 52 | gemini_reply = self.gemini_client.get_chat_response(chat, prompt) 53 | gemini_answer = self._parse_gemini_answer(gemini_reply) 54 | 55 | if user_id: 56 | self.firestore_client.inc_games(user_id, QuizType.SEQUEL_SALAD) 57 | self.firestore_client.inc_score(user_id, QuizType.SEQUEL_SALAD, gemini_answer.points) 58 | 59 | return SequelSaladResult( 60 | question=quiz_data.question, 61 | franchise=quiz_data.franchise, 62 | user_answer=answer, 63 | result=gemini_answer, 64 | speech=self.speech_client.synthesize_to_file(gemini_answer.answer) 65 | ) 66 | except GoogleAPIError as e: 67 | raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Google API error: {e}') 68 | except BaseException as e: 69 | raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Internal server error: {e}') 70 | 71 | @staticmethod 72 | def _parse_gemini_question(gemini_reply: str) -> SequelSaladGeminiQuestion: 73 | try: 74 | return SequelSaladGeminiQuestion.model_validate(from_json(gemini_reply)) 75 | except Exception as e: 76 | msg = f'Gemini replied with an unexpected format. Gemini reply: {gemini_reply}, error: {e}' 77 | logger.warning(msg) 78 | raise ValueError(msg) 79 | 80 | @staticmethod 81 | def _parse_gemini_answer(gemini_reply: str) -> SequelSaladGeminiAnswer: 82 | try: 83 | return SequelSaladGeminiAnswer.model_validate(from_json(gemini_reply)) 84 | except Exception as e: 85 | msg = f'Gemini replied with an unexpected format. Gemini reply: {gemini_reply}, error: {e}' 86 | logger.warning(msg) 87 | raise ValueError(msg) 88 | 89 | def _generate_question_prompt(self, personality: Personality, **kwargs: Any) -> str: 90 | personality = self.template_manager.render_personality(personality) 91 | 92 | return self.template_manager.render_template( 93 | quiz_type=QuizType.SEQUEL_SALAD, 94 | name='prompt_question', 95 | personality=personality, 96 | **kwargs 97 | ) 98 | 99 | def _generate_answer_prompt(self, answer: str) -> str: 100 | return self.template_manager.render_template(QuizType.SEQUEL_SALAD, 'prompt_answer', answer=answer) 101 | -------------------------------------------------------------------------------- /gemini_movie_detectives_api/quiz/bttf_trivia.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Optional 3 | 4 | from fastapi import HTTPException 5 | from google.api_core.exceptions import GoogleAPIError 6 | from pydantic_core import from_json 7 | from starlette import status 8 | from vertexai.generative_models import ChatSession 9 | 10 | from gemini_movie_detectives_api.model import Personality, QuizType, \ 11 | BttfTriviaData, BttfTriviaGeminiQuestion, BttfTriviaGeminiAnswer, BttfTriviaResult 12 | from gemini_movie_detectives_api.quiz.base import AbstractQuiz 13 | 14 | logger: logging.Logger = logging.getLogger(__name__) 15 | 16 | 17 | class BttfTrivia(AbstractQuiz[BttfTriviaData, BttfTriviaResult]): 18 | 19 | def start_quiz(self, personality: Personality, chat: ChatSession) -> BttfTriviaData: 20 | context = self.wiki_client.get_random_bttf_facts() 21 | 22 | try: 23 | prompt = self._generate_question_prompt( 24 | personality=personality, 25 | context=context 26 | ) 27 | 28 | logger.debug('starting quiz with generated prompt: %s', prompt) 29 | gemini_reply = self.gemini_client.get_chat_response(chat, prompt) 30 | gemini_question = self._parse_gemini_question(gemini_reply) 31 | 32 | logger.info('correct answer: %s', gemini_question.correct_answer) 33 | 34 | return BttfTriviaData( 35 | question=gemini_question, 36 | speech=self.speech_client.synthesize_to_file(gemini_question.question) 37 | ) 38 | except GoogleAPIError as e: 39 | raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Google API error: {e}') 40 | except BaseException as e: 41 | raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Internal server error: {e}') 42 | 43 | def finish_quiz(self, answer: int, quiz_data: BttfTriviaData, chat: ChatSession, user_id: Optional[str]) -> BttfTriviaResult: 44 | try: 45 | is_correct_answer = answer == quiz_data.question.correct_answer 46 | user_answer = getattr(quiz_data.question, f'option_{answer}') 47 | 48 | prompt = self._generate_answer_prompt(answer=f'{user_answer} ({answer}) which is {"correct" if is_correct_answer else "incorrect"}') 49 | 50 | logger.debug('evaluating quiz answer with generated prompt: %s', prompt) 51 | gemini_reply = self.gemini_client.get_chat_response(chat, prompt) 52 | gemini_answer = self._parse_gemini_answer(gemini_reply) 53 | 54 | points = 3 if is_correct_answer else 0 55 | 56 | if user_id: 57 | self.firestore_client.inc_games(user_id, QuizType.BTTF_TRIVIA) 58 | self.firestore_client.inc_score(user_id, QuizType.BTTF_TRIVIA, points) 59 | 60 | return BttfTriviaResult( 61 | question=quiz_data.question, 62 | user_answer=answer, 63 | result=gemini_answer, 64 | points=points, 65 | speech=self.speech_client.synthesize_to_file(gemini_answer.answer) 66 | ) 67 | except GoogleAPIError as e: 68 | raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Google API error: {e}') 69 | except BaseException as e: 70 | raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Internal server error: {e}') 71 | 72 | @staticmethod 73 | def _parse_gemini_question(gemini_reply: str) -> BttfTriviaGeminiQuestion: 74 | try: 75 | return BttfTriviaGeminiQuestion.model_validate(from_json(gemini_reply)) 76 | except Exception as e: 77 | msg = f'Gemini replied with an unexpected format. Gemini reply: {gemini_reply}, error: {e}' 78 | logger.warning(msg) 79 | raise ValueError(msg) 80 | 81 | @staticmethod 82 | def _parse_gemini_answer(gemini_reply: str) -> BttfTriviaGeminiAnswer: 83 | try: 84 | return BttfTriviaGeminiAnswer.model_validate(from_json(gemini_reply), strict=False) 85 | except Exception as e: 86 | msg = f'Gemini replied with an unexpected format. Gemini reply: {gemini_reply}, error: {e}' 87 | logger.warning(msg) 88 | raise ValueError(msg) 89 | 90 | def _generate_question_prompt( 91 | self, 92 | personality: Personality, 93 | context: str 94 | ) -> str: 95 | personality = self.template_manager.render_personality(personality) 96 | 97 | return self.template_manager.render_template( 98 | quiz_type=QuizType.BTTF_TRIVIA, 99 | name='prompt_question', 100 | personality=personality, 101 | context=context 102 | ) 103 | 104 | def _generate_answer_prompt(self, answer: int) -> str: 105 | return self.template_manager.render_template(QuizType.BTTF_TRIVIA, 'prompt_answer', answer=answer) 106 | -------------------------------------------------------------------------------- /gemini_movie_detectives_api/quiz/title_detectives.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any, Optional 3 | 4 | from fastapi import HTTPException 5 | from google.api_core.exceptions import GoogleAPIError 6 | from pydantic_core import from_json 7 | from starlette import status 8 | from vertexai.generative_models import ChatSession 9 | 10 | from gemini_movie_detectives_api.model import TitleDetectivesData, TitleDetectivesResult, \ 11 | TitleDetectivesGeminiQuestion, TitleDetectivesGeminiAnswer, Personality, QuizType 12 | from gemini_movie_detectives_api.quiz.base import AbstractQuiz 13 | 14 | logger: logging.Logger = logging.getLogger(__name__) 15 | 16 | 17 | class TitleDetectives(AbstractQuiz[TitleDetectivesData, TitleDetectivesResult]): 18 | 19 | def start_quiz(self, personality: Personality, chat: ChatSession) -> TitleDetectivesData: 20 | movie = self.tmdb_client.get_random_movie( 21 | page_min=1, 22 | page_max=100, 23 | vote_avg_min=4.0, 24 | vote_count_min=800 25 | ) 26 | 27 | if not movie: 28 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='No movie found with given criteria') 29 | 30 | try: 31 | prompt = self._generate_question_prompt( 32 | personality=personality, 33 | movie_title=movie['title'], 34 | tagline=movie['tagline'], 35 | overview=movie['overview'], 36 | genres=', '.join([genre['name'] for genre in movie['genres']]), 37 | budget=movie['budget'], 38 | revenue=movie['revenue'], 39 | average_rating=movie['vote_average'], 40 | rating_count=movie['vote_count'], 41 | release_date=movie['release_date'], 42 | runtime=movie['runtime'] 43 | ) 44 | 45 | logger.debug('starting quiz with generated prompt: %s', prompt) 46 | gemini_reply = self.gemini_client.get_chat_response(chat, prompt) 47 | gemini_question = self._parse_gemini_question(gemini_reply) 48 | 49 | logger.info('correct answer: %s', movie['title']) 50 | 51 | return TitleDetectivesData( 52 | question=gemini_question, 53 | movie=movie, 54 | speech=self.speech_client.synthesize_to_file(gemini_question.question) 55 | ) 56 | except GoogleAPIError as e: 57 | raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Google API error: {e}') 58 | except BaseException as e: 59 | raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Internal server error: {e}') 60 | 61 | def finish_quiz(self, answer: str, quiz_data: TitleDetectivesData, chat: ChatSession, user_id: Optional[str]) -> TitleDetectivesResult: 62 | try: 63 | prompt = self._generate_answer_prompt(answer=answer) 64 | 65 | logger.debug('evaluating quiz answer with generated prompt: %s', prompt) 66 | gemini_reply = self.gemini_client.get_chat_response(chat, prompt) 67 | gemini_answer = self._parse_gemini_answer(gemini_reply) 68 | 69 | if user_id: 70 | self.firestore_client.inc_games(user_id, QuizType.TITLE_DETECTIVES) 71 | self.firestore_client.inc_score(user_id, QuizType.TITLE_DETECTIVES, gemini_answer.points) 72 | 73 | return TitleDetectivesResult( 74 | question=quiz_data.question, 75 | movie=quiz_data.movie, 76 | user_answer=answer, 77 | result=gemini_answer, 78 | speech=self.speech_client.synthesize_to_file(gemini_answer.answer) 79 | ) 80 | except GoogleAPIError as e: 81 | raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Google API error: {e}') 82 | except BaseException as e: 83 | raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Internal server error: {e}') 84 | 85 | @staticmethod 86 | def _parse_gemini_question(gemini_reply: str) -> TitleDetectivesGeminiQuestion: 87 | try: 88 | return TitleDetectivesGeminiQuestion.model_validate(from_json(gemini_reply)) 89 | except Exception as e: 90 | msg = f'Gemini replied with an unexpected format. Gemini reply: {gemini_reply}, error: {e}' 91 | logger.warning(msg) 92 | raise ValueError(msg) 93 | 94 | @staticmethod 95 | def _parse_gemini_answer(gemini_reply: str) -> TitleDetectivesGeminiAnswer: 96 | try: 97 | return TitleDetectivesGeminiAnswer.model_validate(from_json(gemini_reply)) 98 | except Exception as e: 99 | msg = f'Gemini replied with an unexpected format. Gemini reply: {gemini_reply}, error: {e}' 100 | logger.warning(msg) 101 | raise ValueError(msg) 102 | 103 | def _generate_question_prompt( 104 | self, 105 | personality: Personality, 106 | movie_title: str, 107 | **kwargs: Any 108 | ) -> str: 109 | personality = self.template_manager.render_personality(personality) 110 | 111 | return self.template_manager.render_template( 112 | quiz_type=QuizType.TITLE_DETECTIVES, 113 | name='prompt_question', 114 | personality=personality, 115 | title=movie_title, 116 | **kwargs 117 | ) 118 | 119 | def _generate_answer_prompt(self, answer: str) -> str: 120 | return self.template_manager.render_template(QuizType.TITLE_DETECTIVES, 'prompt_answer', answer=answer) 121 | -------------------------------------------------------------------------------- /gemini_movie_detectives_api/quiz/trivia.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Optional, Any 3 | 4 | from fastapi import HTTPException 5 | from google.api_core.exceptions import GoogleAPIError 6 | from pydantic_core import from_json 7 | from starlette import status 8 | from vertexai.generative_models import ChatSession 9 | 10 | from gemini_movie_detectives_api.model import Personality, QuizType, \ 11 | TriviaData, TriviaGeminiAnswer, \ 12 | TriviaGeminiQuestion, TriviaResult 13 | from gemini_movie_detectives_api.quiz.base import AbstractQuiz 14 | from gemini_movie_detectives_api.wiki import MovieFacts 15 | 16 | logger: logging.Logger = logging.getLogger(__name__) 17 | 18 | 19 | class Trivia(AbstractQuiz[TriviaData, TriviaResult]): 20 | 21 | def start_quiz(self, personality: Personality, chat: ChatSession) -> TriviaData: 22 | movie_facts: MovieFacts = self.wiki_client.get_random_movie_facts() 23 | movie = movie_facts.movie 24 | 25 | try: 26 | prompt = self._generate_question_prompt( 27 | personality=personality, 28 | movie_title=movie['title'], 29 | context=movie_facts.facts, 30 | tagline=movie['tagline'], 31 | overview=movie['overview'], 32 | genres=', '.join([genre['name'] for genre in movie['genres']]), 33 | budget=movie['budget'], 34 | revenue=movie['revenue'], 35 | average_rating=movie['vote_average'], 36 | rating_count=movie['vote_count'], 37 | release_date=movie['release_date'], 38 | runtime=movie['runtime'] 39 | ) 40 | 41 | logger.debug('starting quiz with generated prompt: %s', prompt) 42 | gemini_reply = self.gemini_client.get_chat_response(chat, prompt) 43 | gemini_question = self._parse_gemini_question(gemini_reply) 44 | 45 | logger.info('correct answer: %s', gemini_question.correct_answer) 46 | 47 | return TriviaData( 48 | question=gemini_question, 49 | movie=movie, 50 | speech=self.speech_client.synthesize_to_file(gemini_question.question) 51 | ) 52 | except GoogleAPIError as e: 53 | raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Google API error: {e}') 54 | except BaseException as e: 55 | raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Internal server error: {e}') 56 | 57 | def finish_quiz(self, answer: int, quiz_data: TriviaData, chat: ChatSession, user_id: Optional[str]) -> TriviaResult: 58 | try: 59 | is_correct_answer = answer == quiz_data.question.correct_answer 60 | user_answer = getattr(quiz_data.question, f'option_{answer}') 61 | 62 | prompt = self._generate_answer_prompt(answer=f'{user_answer} ({answer}) which is {"correct" if is_correct_answer else "incorrect"}') 63 | 64 | logger.debug('evaluating quiz answer with generated prompt: %s', prompt) 65 | gemini_reply = self.gemini_client.get_chat_response(chat, prompt) 66 | gemini_answer = self._parse_gemini_answer(gemini_reply) 67 | 68 | points = 3 if is_correct_answer else 0 69 | 70 | if user_id: 71 | self.firestore_client.inc_games(user_id, QuizType.TRIVIA) 72 | self.firestore_client.inc_score(user_id, QuizType.TRIVIA, points) 73 | 74 | return TriviaResult( 75 | question=quiz_data.question, 76 | movie=quiz_data.movie, 77 | user_answer=answer, 78 | result=gemini_answer, 79 | points=points, 80 | speech=self.speech_client.synthesize_to_file(gemini_answer.answer) 81 | ) 82 | except GoogleAPIError as e: 83 | raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Google API error: {e}') 84 | except BaseException as e: 85 | raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Internal server error: {e}') 86 | 87 | @staticmethod 88 | def _parse_gemini_question(gemini_reply: str) -> TriviaGeminiQuestion: 89 | try: 90 | return TriviaGeminiQuestion.model_validate(from_json(gemini_reply)) 91 | except Exception as e: 92 | msg = f'Gemini replied with an unexpected format. Gemini reply: {gemini_reply}, error: {e}' 93 | logger.warning(msg) 94 | raise ValueError(msg) 95 | 96 | @staticmethod 97 | def _parse_gemini_answer(gemini_reply: str) -> TriviaGeminiAnswer: 98 | try: 99 | return TriviaGeminiAnswer.model_validate(from_json(gemini_reply), strict=False) 100 | except Exception as e: 101 | msg = f'Gemini replied with an unexpected format. Gemini reply: {gemini_reply}, error: {e}' 102 | logger.warning(msg) 103 | raise ValueError(msg) 104 | 105 | 106 | def _generate_question_prompt( 107 | self, 108 | personality: Personality, 109 | movie_title: str, 110 | context: str, 111 | **kwargs: Any 112 | ) -> str: 113 | personality = self.template_manager.render_personality(personality) 114 | 115 | return self.template_manager.render_template( 116 | quiz_type=QuizType.TRIVIA, 117 | name='prompt_question', 118 | personality=personality, 119 | title=movie_title, 120 | context=context, 121 | **kwargs 122 | ) 123 | 124 | def _generate_answer_prompt(self, answer: int) -> str: 125 | return self.template_manager.render_template(QuizType.TRIVIA, 'prompt_answer', answer=answer) 126 | -------------------------------------------------------------------------------- /gemini_movie_detectives_api/storage.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from datetime import datetime 4 | from functools import lru_cache 5 | from typing import Optional, List 6 | 7 | import firebase_admin 8 | import pytz 9 | from fastapi import Header 10 | from firebase_admin import auth 11 | from firebase_admin import firestore 12 | from firebase_admin.credentials import Certificate 13 | from google.cloud.firestore_v1 import Transaction 14 | 15 | from gemini_movie_detectives_api.model import QuizType 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | class LimitExceededError(Exception): 21 | 22 | def __init__(self, message: str, quiz_type: QuizType, usage: int, limit: int): 23 | super().__init__(message) 24 | self.quiz_type = quiz_type 25 | self.usage = usage 26 | self.limit = limit 27 | 28 | 29 | class FirestoreClient: 30 | 31 | def __init__(self, certificate: Certificate): 32 | self.firebase_app = firebase_admin.initialize_app(certificate) 33 | self.firestore_client = firestore.client() 34 | 35 | def get_or_create_user(self, user_id: str, x_user_info: Optional[str] = Header(None)) -> dict: 36 | user_ref = self.firestore_client.collection('users').document(user_id) 37 | user_doc = user_ref.get() 38 | 39 | if user_doc.exists: 40 | return user_doc.to_dict() 41 | else: 42 | user_data = { 43 | 'user_id': user_id, 44 | 'created_at': firestore.firestore.SERVER_TIMESTAMP, 45 | 'score_total': 0, 46 | 'games_total': 0, 47 | 'score_title_detectives': 0, 48 | 'games_title_detectives': 0, 49 | 'score_sequel_salad': 0, 50 | 'games_sequel_salad': 0, 51 | 'score_bttf_trivia': 0, 52 | 'games_bttf_trivia': 0, 53 | 'score_trivia': 0, 54 | 'games_trivia': 0 55 | } 56 | 57 | if x_user_info: 58 | user_info = json.loads(x_user_info) 59 | user_data['display_name'] = user_info.get('displayName') 60 | user_data['photo_url'] = user_info.get('photoURL') 61 | 62 | user_ref.set(user_data) 63 | return user_data 64 | 65 | def get_current_user(self, authorization: Optional[str] = Header(None), x_user_info: Optional[str] = Header(None)) -> Optional[str]: 66 | if authorization: 67 | try: 68 | token = authorization.split("Bearer ")[1] 69 | decoded_token = auth.verify_id_token(token) 70 | uid = decoded_token['uid'] 71 | _ = self.get_or_create_user(uid, x_user_info) 72 | 73 | return uid 74 | except Exception as e: 75 | logger.warning(f'Error verifying token or fetching user data: {e}') 76 | pass 77 | 78 | return None # return None for unauthenticated users 79 | 80 | def inc_games(self, user_id: str, quiz_type: QuizType) -> None: 81 | try: 82 | user_ref = self.firestore_client.collection('users').document(user_id) 83 | user_doc = user_ref.get() 84 | 85 | if user_doc.exists: 86 | user_data = user_doc.to_dict() 87 | 88 | match quiz_type: 89 | case QuizType.TITLE_DETECTIVES: 90 | user_data['games_title_detectives'] += 1 91 | case QuizType.SEQUEL_SALAD: 92 | user_data['games_sequel_salad'] += 1 93 | case QuizType.BTTF_TRIVIA: 94 | user_data['games_bttf_trivia'] += 1 95 | case QuizType.TRIVIA: 96 | user_data['games_trivia'] += 1 97 | 98 | user_data['games_total'] += 1 99 | user_ref.update(user_data) 100 | except Exception as e: 101 | logger.error(f'Error increasing games: {e}') 102 | 103 | def inc_score(self, user_id: str, quiz_type: QuizType, points: int) -> None: 104 | try: 105 | user_ref = self.firestore_client.collection('users').document(user_id) 106 | user_doc = user_ref.get() 107 | 108 | if user_doc.exists: 109 | user_data = user_doc.to_dict() 110 | 111 | match quiz_type: 112 | case QuizType.TITLE_DETECTIVES: 113 | user_data['score_title_detectives'] += points 114 | case QuizType.SEQUEL_SALAD: 115 | user_data['score_sequel_salad'] += points 116 | case QuizType.BTTF_TRIVIA: 117 | user_data['score_bttf_trivia'] += points 118 | case QuizType.TRIVIA: 119 | user_data['score_trivia'] += points 120 | 121 | user_data['score_total'] += points 122 | user_ref.update(user_data) 123 | except Exception as e: 124 | logger.error(f'Error increasing score: {e}') 125 | 126 | # noinspection PyBroadException 127 | @lru_cache 128 | def get_franchises(self) -> List[str]: 129 | franchises_ref = self.firestore_client.collection('movie-data').document('franchises') 130 | franchises_doc = franchises_ref.get() 131 | 132 | if not franchises_doc.exists or 'franchises' not in franchises_doc.to_dict() or not franchises_doc.to_dict()['franchises']: 133 | logger.warning('No franchises document found, creating default franchises (ensure to add more later)') 134 | return self._init_franchises() 135 | 136 | return franchises_doc.to_dict()['franchises'] 137 | 138 | def get_limits(self) -> dict: 139 | limits_doc = self.firestore_client.collection('limits').document('limits').get() 140 | if not limits_doc.exists: 141 | logger.warning('No limits document found, creating default limits') 142 | return self._init_limits() 143 | 144 | return limits_doc.to_dict() 145 | 146 | def get_usage_counts(self) -> dict: 147 | today = datetime.now(pytz.utc).date().isoformat() 148 | usage_ref = self.firestore_client.collection('limits').document(f'usage_{today}') 149 | usage_doc = usage_ref.get() 150 | 151 | if not usage_doc.exists: 152 | return {qt.value: 0 for qt in QuizType} 153 | 154 | return usage_doc.to_dict()['counts'] 155 | 156 | def update_usage_count(self, quiz_type: QuizType) -> int: 157 | transaction = self.firestore_client.transaction() 158 | 159 | today = datetime.now(pytz.utc).date().isoformat() 160 | usage_ref = self.firestore_client.collection('limits').document(f'usage_{today}') 161 | 162 | return self._update_usage_count(transaction, self.get_limits(), quiz_type, usage_ref) 163 | 164 | def reset_usage_counts(self) -> None: 165 | today = datetime.now(pytz.utc).date().isoformat() 166 | usage_ref = self.firestore_client.collection('limits').document(f'usage_{today}') 167 | usage_doc = usage_ref.get() 168 | 169 | if usage_doc.exists: 170 | usage_ref.delete() 171 | 172 | def _init_franchises(self) -> List[str]: 173 | franchises = ['Harry Potter', 'Star Wars', 'Marvel Cinematic Universe', 'The Lord of the Rings', 'James Bond'] 174 | franchises_ref = self.firestore_client.collection('movie-data').document('franchises') 175 | franchises_ref.set({'franchises': franchises}) 176 | 177 | return franchises 178 | 179 | def _init_limits(self) -> dict: 180 | limits = { 181 | QuizType.TITLE_DETECTIVES: 10, 182 | QuizType.SEQUEL_SALAD: 10, 183 | QuizType.BTTF_TRIVIA: 10, 184 | QuizType.TRIVIA: 10 185 | } 186 | 187 | limits_ref = self.firestore_client.collection('limits').document('limits') 188 | limits_ref.set(limits) 189 | 190 | return limits 191 | 192 | @staticmethod 193 | @firestore.transactional 194 | def _update_usage_count(transaction: Transaction, limits: dict, quiz_type: QuizType, usage_ref: firestore.DocumentReference) -> int: 195 | if quiz_type not in limits: 196 | raise ValueError(f'No limit configured for {quiz_type}') 197 | 198 | usage_doc = usage_ref.get(transaction=transaction) 199 | 200 | if not usage_doc.exists: 201 | usage = {'counts': {qt.value: 0 for qt in QuizType}} 202 | transaction.set(usage_ref, usage) 203 | else: 204 | usage = usage_doc.to_dict() 205 | 206 | current_count = usage['counts'].get(quiz_type, 0) 207 | 208 | if current_count >= limits[quiz_type]: 209 | raise LimitExceededError( 210 | f'Usage limit exceeded for {quiz_type}', 211 | quiz_type, 212 | current_count, 213 | limits[quiz_type] 214 | ) 215 | 216 | updated_count = current_count + 1 217 | usage['counts'][quiz_type] = updated_count 218 | transaction.set(usage_ref, usage, merge=True) 219 | 220 | return updated_count 221 | -------------------------------------------------------------------------------- /gemini_movie_detectives_api/main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import uuid 3 | from contextlib import asynccontextmanager 4 | from datetime import datetime 5 | from functools import lru_cache 6 | from functools import wraps 7 | from pathlib import Path 8 | from time import sleep 9 | from typing import Optional 10 | 11 | import colorlog 12 | from cachetools import TTLCache 13 | from fastapi import FastAPI, Depends 14 | from fastapi import HTTPException, status 15 | from fastapi.middleware.cors import CORSMiddleware 16 | from firebase_admin import credentials as fb_credentials 17 | from google.cloud.logging_v2.handlers import CloudLoggingHandler, setup_logging 18 | from google.oauth2 import service_account 19 | from google.oauth2.service_account import Credentials 20 | from starlette.responses import FileResponse 21 | import google.cloud.logging 22 | 23 | from .cleanup import TempDirCleaner 24 | from .config import Settings, TmdbImagesConfig, load_tmdb_images_config 25 | from .gemini import GeminiClient 26 | from .imagen import ImagenClient 27 | from .model import SessionData, FinishQuizResponse, QuizType, StartQuizResponse, FinishQuizRequest, StartQuizRequest, \ 28 | LimitsResponse, ResetLimitsRequest 29 | from .quiz.bttf_trivia import BttfTrivia 30 | from .quiz.sequel_salad import SequelSalad 31 | from .quiz.title_detectives import TitleDetectives 32 | from .quiz.trivia import Trivia 33 | from .speech import SpeechClient 34 | from .storage import FirestoreClient, LimitExceededError 35 | from .template import TemplateManager 36 | from .tmdb import TmdbClient 37 | from .wiki import WikiClient 38 | 39 | # setup logging 40 | log_format = '%(log_color)s%(asctime)s [%(levelname)s] %(reset)s%(purple)s[%(name)s] %(reset)s%(blue)s%(message)s' 41 | handler = colorlog.StreamHandler() 42 | handler.setFormatter(colorlog.ColoredFormatter(log_format)) 43 | logging.basicConfig(level=logging.INFO, handlers=[handler]) 44 | 45 | logger: logging.Logger = logging.getLogger(__name__) 46 | 47 | 48 | @lru_cache 49 | def _get_settings() -> Settings: 50 | return Settings() 51 | 52 | 53 | @lru_cache 54 | def _get_tmdb_images_config() -> TmdbImagesConfig: 55 | return load_tmdb_images_config(_get_settings()) 56 | 57 | 58 | settings: Settings = _get_settings() 59 | 60 | # tmp dir for AI generated movie posters 61 | tmp_images_dir = Path(settings.tmp_images_dir) 62 | 63 | # tmp dir for speech synthesis 64 | tmp_audio_dir = Path(settings.tmp_images_dir) 65 | 66 | # takes care of creating the directories and cleaning up old files 67 | cleaner = TempDirCleaner( 68 | [tmp_images_dir, tmp_audio_dir], 69 | age_limit_seconds=settings.cleanup_file_max_age_sec, 70 | interval_minutes=settings.cleanup_interval_min 71 | ) 72 | 73 | # TMDB client 74 | tmdb_client: TmdbClient = TmdbClient(settings.tmdb_api_key, _get_tmdb_images_config()) 75 | 76 | # GCP clients (Gemini, Imagen, Cloud Logging and Text-To-Speech) 77 | credentials: Credentials = service_account.Credentials.from_service_account_file(settings.gcp_service_account_file) 78 | 79 | if settings.gcp_cloud_logging_enabled: # Google Cloud Logging can be enabled in the config 80 | client = google.cloud.logging.Client(credentials=credentials) 81 | handler = CloudLoggingHandler(client) 82 | setup_logging(handler) 83 | 84 | gemini_client: GeminiClient = GeminiClient( 85 | settings.gcp_project_id, 86 | settings.gcp_location, 87 | credentials, 88 | settings.gcp_gemini_model 89 | ) 90 | imagen_client: ImagenClient = ImagenClient( 91 | settings.gcp_project_id, 92 | settings.gcp_location, 93 | credentials, 94 | settings.gcp_imagen_model, 95 | tmp_images_dir 96 | ) 97 | speech_client: SpeechClient = SpeechClient(tmp_audio_dir, credentials, settings.gcp_tts_lang, settings.gcp_tts_voice) 98 | 99 | # Firestore client 100 | firebase_credentials = fb_credentials.Certificate(settings.firebase_service_account_file) 101 | firestore_client = FirestoreClient(firebase_credentials) 102 | 103 | # Wikipedia client 104 | wiki_client: WikiClient = WikiClient(tmdb_client) 105 | 106 | # Jinja prompt template manager 107 | template_manager: TemplateManager = TemplateManager() 108 | 109 | # Quiz handlers 110 | args = (template_manager, gemini_client, imagen_client, speech_client, firestore_client, tmdb_client, wiki_client) 111 | title_detectives = TitleDetectives(*args) 112 | sequel_salad = SequelSalad(*args) 113 | bttf_trivia = BttfTrivia(*args) 114 | trivia = Trivia(*args) 115 | 116 | 117 | @asynccontextmanager 118 | async def lifespan(_: FastAPI): 119 | cleaner.start() 120 | yield 121 | cleaner.stop() 122 | 123 | 124 | app: FastAPI = FastAPI(lifespan=lifespan) 125 | 126 | # for local development 127 | origins = [ 128 | 'http://localhost', 129 | 'http://localhost:8080', 130 | 'http://localhost:5173', 131 | ] 132 | 133 | # noinspection PyTypeChecker 134 | app.add_middleware( 135 | CORSMiddleware, 136 | allow_origins=origins, 137 | allow_credentials=True, 138 | allow_methods=['*'], 139 | allow_headers=['*'], 140 | ) 141 | 142 | # cache for quiz session, ttl = max session duration in seconds 143 | session_cache: TTLCache = TTLCache(maxsize=100, ttl=600) 144 | 145 | 146 | def retry(max_retries: int) -> callable: 147 | def decorator(func) -> callable: 148 | @wraps(func) 149 | def wrapper(*args, **kwargs): 150 | for _ in range(max_retries): 151 | try: 152 | return func(*args, **kwargs) 153 | except ValueError as e: 154 | logger.error(f'Error in {func.__name__}: {e}') 155 | if _ < max_retries - 1: 156 | logger.warning(f'Retrying {func.__name__}...') 157 | sleep(1) 158 | else: 159 | raise e 160 | 161 | return wrapper 162 | 163 | return decorator 164 | 165 | 166 | @app.get('/movies') 167 | async def get_movies(page: int = 1, vote_avg_min: float = 5.0, vote_count_min: float = 1000.0): 168 | return tmdb_client.get_movies(page, vote_avg_min, vote_count_min) 169 | 170 | 171 | @app.get('/movies/random') 172 | async def get_random_movie(page_min: int = 1, page_max: int = 3, vote_avg_min: float = 5.0, vote_count_min: float = 1000.0): 173 | return tmdb_client.get_random_movie(page_min, page_max, vote_avg_min, vote_count_min) 174 | 175 | 176 | @app.get('/limits') 177 | async def get_limits() -> LimitsResponse: 178 | return LimitsResponse( 179 | limits=firestore_client.get_limits(), 180 | usage_counts=firestore_client.get_usage_counts(), 181 | current_date=datetime.now() 182 | ) 183 | 184 | 185 | @app.post('/limits/reset') 186 | async def reset_limits(reset_limits_request: ResetLimitsRequest): 187 | if reset_limits_request.password != settings.limits_reset_password: 188 | raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='Unauthorized') 189 | 190 | firestore_client.reset_usage_counts() 191 | 192 | 193 | @app.post('/quiz/{quiz_type}') 194 | @retry(max_retries=settings.quiz_max_retries) 195 | def start_quiz(quiz_type: QuizType, request: StartQuizRequest) -> StartQuizResponse: 196 | if quiz_type != request.quiz_type: 197 | raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid start quiz request') 198 | 199 | try: 200 | firestore_client.update_usage_count(quiz_type) 201 | except LimitExceededError as e: 202 | raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=str(e)) 203 | except Exception as e: 204 | raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) 205 | 206 | quiz_id = str(uuid.uuid4()) 207 | 208 | personality = request.personality 209 | chat = gemini_client.start_chat() 210 | 211 | match quiz_type: 212 | case QuizType.TITLE_DETECTIVES: 213 | quiz_data = title_detectives.start_quiz(personality, chat) 214 | case QuizType.SEQUEL_SALAD: 215 | quiz_data = sequel_salad.start_quiz(personality, chat) 216 | case QuizType.BTTF_TRIVIA: 217 | quiz_data = bttf_trivia.start_quiz(personality, chat) 218 | case QuizType.TRIVIA: 219 | quiz_data = trivia.start_quiz(personality, chat) 220 | case _: 221 | raise HTTPException(status_code=400, detail=f'Quiz type {quiz_type} is not supported') 222 | 223 | session_cache[quiz_id] = SessionData( 224 | quiz_id=quiz_id, 225 | quiz_type=quiz_type, 226 | quiz_data=quiz_data, 227 | chat=chat, 228 | started_at=datetime.now() 229 | ) 230 | 231 | return StartQuizResponse( 232 | quiz_id=quiz_id, 233 | quiz_type=quiz_type, 234 | quiz_data=quiz_data 235 | ) 236 | 237 | 238 | @app.post('/quiz/{quiz_id}/answer') 239 | @retry(max_retries=settings.quiz_max_retries) 240 | def finish_quiz(quiz_id: str, request: FinishQuizRequest, user_id: Optional[str] = Depends(firestore_client.get_current_user)) -> FinishQuizResponse: 241 | if not quiz_id == request.quiz_id: 242 | raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid finish quiz request') 243 | 244 | session_data: SessionData = session_cache.get(quiz_id) 245 | 246 | if not session_data: 247 | logger.info('session not found: %s', quiz_id) 248 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Session not found') 249 | 250 | quiz_type = session_data.quiz_type 251 | quiz_data = session_data.quiz_data 252 | chat = session_data.chat 253 | 254 | if not chat: 255 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Could not load Gemini chat session') 256 | 257 | answer = request.answer 258 | del session_cache[quiz_id] 259 | 260 | match quiz_type: 261 | case QuizType.TITLE_DETECTIVES: 262 | result = title_detectives.finish_quiz(answer, quiz_data, chat, user_id) 263 | case QuizType.SEQUEL_SALAD: 264 | result = sequel_salad.finish_quiz(answer, quiz_data, chat, user_id) 265 | case QuizType.BTTF_TRIVIA: 266 | result = bttf_trivia.finish_quiz(answer, quiz_data, chat, user_id) 267 | case QuizType.TRIVIA: 268 | result = trivia.finish_quiz(answer, quiz_data, chat, user_id) 269 | case _: 270 | raise HTTPException(status_code=400, detail=f'Quiz type {quiz_type} is not supported') 271 | 272 | return FinishQuizResponse( 273 | quiz_id=quiz_id, 274 | quiz_result=result 275 | ) 276 | 277 | 278 | @app.get('/audio/{file_id}.mp3', response_class=FileResponse) 279 | async def get_audio(file_id: str): 280 | audio_file_path = Path(f'{speech_client.tmp_audio_dir}/{file_id}.mp3') 281 | 282 | if not audio_file_path.exists(): 283 | raise HTTPException(status_code=404, detail='Audio file not found') 284 | 285 | return FileResponse(audio_file_path) 286 | 287 | 288 | @app.get('/images/{file_id}.png', response_class=FileResponse) 289 | async def get_image(file_id: str): 290 | image_file_path = Path(f'{imagen_client.tmp_images_dir}/{file_id}.png') 291 | 292 | if not image_file_path.exists(): 293 | raise HTTPException(status_code=404, detail='Image file not found') 294 | 295 | return FileResponse(image_file_path) 296 | 297 | 298 | @app.get('/profile') 299 | async def get_profile(user_id: Optional[str] = Depends(firestore_client.get_current_user)): 300 | if not user_id: 301 | raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='Unauthorized') 302 | 303 | return firestore_client.get_or_create_user(user_id) 304 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gemini Movie Detectives API 2 | 3 | ![logo](doc/logo.png) 4 | 5 | Gemini Movie Detectives harnesses Google's AI to revolutionize educational gaming, transforming movie trivia 6 | into a proof-of-concept gateway for AI-driven, adaptive learning across all subjects, challenging your inner 7 | movie nerd while showcasing how AI can reshape education in schools and universities. 8 | 9 | **Try it yourself**: [movie-detectives.com](https://movie-detectives.com/) 10 | 11 | ## Backend 12 | 13 | The backend infrastructure is built with FastAPI and Python, employing the Retrieval-Augmented Generation (RAG) 14 | methodology to enrich queries with real-time metadata. Utilizing Jinja templating, the backend modularize 15 | prompt generation into base, personality, and data enhancement templates, enabling the generation of accurate 16 | and engaging quiz questions in different game modes. Each game mode uses a different combination of data source 17 | showcasing the broad range of possibilities how to employ advanced Gemini applications. 18 | 19 | In addition to Gemini, the application leverages Google's state-of-the-art Text-to-Speech AI to synthesize quiz 20 | questions, dramatically enhancing the immersive atmosphere of a professional trivia game show. Moreover, the 21 | Sequel Salad game mode demonstrates the power of AI integration by utilizing Gemini to generate creative prompts. 22 | These prompts are then seamlessly fed into Google's cutting-edge Imagen text-to-image diffusion model, producing 23 | fake movie posters. This sophisticated interplay of various AI models showcases the limitless potential for 24 | creating captivating and dynamic game experiences, pushing the boundaries of what's possible in interactive 25 | entertainment. 26 | 27 | The application's infrastructure is further strengthened by the integration of Google Firebase. This integration 28 | enables secure user authentication, facilitating personalized interactions within the app. Firestore is used to 29 | store and manage essential user data, powering the dynamic rendering of user profiles with game statistics. 30 | Additionally, it handles crucial metadata, including movie franchise information and game mode usage metrics 31 | together with configurable limits, allowing for precise control over daily operational costs. 32 | 33 | ## Frontend 34 | 35 | The frontend is powered by Vue 3 and Vite, supported by daisyUI and Tailwind CSS for efficient frontend 36 | development. Together, these tools provide users with a sleek and modern interface for seamless interaction 37 | with the backend. 38 | 39 | ## Summary 40 | 41 | In Movie Detectives, quiz answers are interpreted by the Language Model (LLM) once again, allowing for dynamic 42 | scoring and personalized responses. This showcases the potential of integrating LLM with RAG in game design and 43 | development, paving the way for truly individualized gaming experiences. Furthermore, it demonstrates the 44 | potential for creating engaging quiz trivia or educational games by involving LLM. Adding and changing personalities 45 | is as easy as adding more Jinja template modules. With very little effort, this can change the full game experience, 46 | reducing the effort for developers. Try it yourself and change the AI personality in the quiz configuration. 47 | 48 | Movie Detectives tackles the challenge of maintaining student interest, improving knowledge retention, and making 49 | learning enjoyable. It's not just a movie quiz; it’s a glimpse into AI-enhanced education, pushing boundaries 50 | for accessible, engaging, and effective learning experiences. 51 | 52 | ![mockup](doc/mockup.png) 53 | 54 | --- 55 | 56 | ## Examples 57 | 58 | ![demo bttf trivia](doc/demo-bttf-trivia.png) 59 | *Game mode: Back to the Future Trivia* 60 | 61 | ![demo profile](doc/demo-profile.png) 62 | *User profile* 63 | 64 | ![demo sequel salad](doc/demo-sequel-salad.png) 65 | *Game mode: Sequel Salad* 66 | 67 | --- 68 | 69 | **Frontend**: [gemini-movie-detectives-ui](https://github.com/vojay-dev/gemini-movie-detectives-ui) 70 | 71 | ## Tech stack and project overview 72 | 73 | - Python 3.12 + [FastAPI](https://fastapi.tiangolo.com/) API development 74 | - [httpx](https://www.python-httpx.org/) for TMDB client implementation 75 | - [Jinja](https://jinja.palletsprojects.com/) templating for modular prompt generation including personalities 76 | - [Pydantic](https://docs.pydantic.dev/latest/) for data modeling and validation 77 | - [Poetry](https://python-poetry.org/) for dependency management 78 | - [Docker](https://www.docker.com/) for deployment 79 | - [Firestore](https://firebase.google.com/docs/firestore) for storing user data, quiz usage and limit management as well as managing the list of franchises for the Sequel Salad game mode 80 | - [Firebase](https://firebase.google.com/) for user authentication 81 | - [TMDB API](https://www.themoviedb.org/) for movie metadata 82 | - [Wikipedia](https://pypi.org/project/wikipedia/) for fetching data from Wikipedia to add more context to the generation process, especially for movie fun facts and Back to the Future trivia 83 | - [Gemini](https://cloud.google.com/vertex-ai/docs/generative-ai/model-reference/gemini) via [VertexAI](https://cloud.google.com/vertex-ai) for generating quiz questions and evaluating answers 84 | - [Imagen](https://imagen.research.google/) via [VertexAI](https://cloud.google.com/vertex-ai) for generating fake movie posters 85 | - [Ruff](https://docs.astral.sh/ruff/) as linter and code formatter together with [pre-commit](https://pre-commit.com/) hooks 86 | - [Github Actions](https://github.com/features/actions) to automatically run tests and linter on every push 87 | 88 | ![system overview](doc/architecture.png) 89 | *Movie Detectives - System Overview* 90 | 91 | ## Makefile 92 | 93 | The project includes a `Makefile` with common tasks like setting up the virtual environment with Poetry, running the 94 | service locally and within Docker, running test, linter and more. Simply run: 95 | ```sh 96 | make help 97 | ``` 98 | to get an overview of all available tasks. 99 | 100 | ![make help](doc/make-help.png) 101 | *make help* 102 | 103 | ## Project setup 104 | 105 | **(Optional) Configure poetry to use in-project virtualenvs**: 106 | ```sh 107 | poetry config virtualenvs.in-project true 108 | ``` 109 | 110 | **Install dependencies**: 111 | ```sh 112 | poetry install 113 | ``` 114 | 115 | **Run**: 116 | 117 | *Please check the Configuration section to ensure all requirements are met.* 118 | ```sh 119 | poetry run fastapi dev gemini_movie_detectives_api/main.py 120 | curl -s localhost:8000/movies | jq . 121 | ``` 122 | 123 | ## Configuration 124 | 125 | **Prerequisite** 126 | 127 | - TMDB API key ([can be generated for free](https://developer.themoviedb.org/docs/getting-started)) 128 | - GCP project with TTS and VertexAI API enabled and access to Gemini (`gemini-1.5-pro-001` or `gemini-1.5-flash-001`) and Imagen (`imagegeneration@006`) 129 | - Firebase project with authentication and Firestore enabled 130 | - JSON credentials file for GCP Service Account with VertexAI permissions 131 | - JSON credentials file for Firebase Service Account with write permissions 132 | 133 | The API is configured via environment variables. If a `.env` file is present in the project root, it will be loaded 134 | automatically. The following variables must be set: 135 | 136 | - `TMDB_API_KEY`: The API key for The Movie Database (TMDB). 137 | - `GCP_PROJECT_ID`: The ID of the Google Cloud Platform (GCP) project used for VertexAI and Gemini. 138 | - `GCP_LOCATION`: The location used for prediction processes. 139 | - `GCP_SERVICE_ACCOUNT_FILE`: The path to the service account file used for authentication with GCP. 140 | - `FIREBASE_SERVICE_ACCOUNT_FILE`: The path to the service account file used for authentication with Firebase. 141 | 142 | There are more config variables with defaults, which can be used to adjust the default API behavior. 143 | 144 | The necessary documents within Firestore are created automatically when the API is started for the first time. The 145 | limits and franchises can be adjusted in the Firestore console. 146 | 147 | ![firestore](doc/firestore.png) 148 | 149 | **Gemini model** 150 | 151 | The default model used for Gemini is `gemini-1.5-pro-001`. To use a different model, simply adjust the `GCP_GEMINI_MODEL` 152 | in the `.env` file. For this use-case, also the Flash model delivers good results and might be a cost-efficient 153 | alternative. 154 | ``` 155 | GCP_GEMINI_MODEL=gemini-1.5-flash-001 156 | ``` 157 | 158 | ### Logging 159 | 160 | The API uses the Python logging module and optionally supports Cloud Logging to allow for log analysis and monitoring 161 | via Google Cloud. To enable Cloud Logging, set the `GCP_CLOUD_LOGGING_ENABLED` environment variable to `true`. Ensure 162 | that the provided Service Account, which belongs to the JSON credentials file set as `GCP_SERVICE_ACCOUNT_FILE`, has 163 | the necessary permissions (e.g. role: _Logs Writer_) to write logs to Cloud Logging. 164 | 165 | ![Google Cloud Logging](doc/cloud-logging.png) 166 | *Google Cloud Logging* 167 | 168 | ## Docker 169 | 170 | All Docker commands are also encapsulated in the `Makefile` for convenience. 171 | 172 | ### Build 173 | 174 | ```sh 175 | docker build -t gemini-movie-detectives-api . 176 | ``` 177 | 178 | ### Run 179 | 180 | ```sh 181 | docker run -d --rm --name gemini-movie-detectives-api -p 9091:9091 gemini-movie-detectives-api 182 | curl -s localhost:9091/movies | jq . 183 | docker stop gemini-movie-detectives-api 184 | ``` 185 | 186 | ### Save image for deployment 187 | 188 | ```sh 189 | docker save gemini-movie-detectives-api:latest | gzip > gemini-movie-detectives-api_latest.tar.gz 190 | ``` 191 | 192 | ## Gemini interaction 193 | 194 | Gemini interaction is encapsulated in the `GeminiClient` class. To ensure a high quality of prompt responses and to 195 | avoid unnecessary parsing issues. The `GeminiClient` class uses the Gemini JSON format mode. 196 | 197 | See: [https://ai.google.dev/gemini-api/docs/api-overview#json](https://ai.google.dev/gemini-api/docs/api-overview#json) 198 | 199 | This ensures Gemini replies with valid JSON, whereas the schema is attached to the individual prompt, for example: 200 | ``` 201 | Reply using this JSON schema: 202 | 203 | {"question": str, "hint1": str, "hint2": str} 204 | 205 | - question: your generated question 206 | - hint1: The first hint to help the participants 207 | - hint2: The second hint to get the title more easily 208 | ``` 209 | 210 | This approach is then combined with Pydantic models to ensure the correctness of datatypes and the overall structure: 211 | ```py 212 | @staticmethod 213 | def _parse_gemini_answer(gemini_reply: str) -> TitleDetectivesGeminiAnswer: 214 | try: 215 | return TitleDetectivesGeminiAnswer.model_validate(from_json(gemini_reply)) 216 | except Exception as e: 217 | msg = f'Gemini replied with an unexpected format. Gemini reply: {gemini_reply}, error: {e}' 218 | logger.warning(msg) 219 | raise ValueError(msg) 220 | ``` 221 | 222 | This is a great example how to programmatically interact with Gemini, ensure the quality of the responses and use a LLM 223 | to cover core business logic. 224 | 225 | ## API example usage 226 | 227 | ### Get a list of movies 228 | 229 | ```sh 230 | curl -s localhost:8000/movies | jq . 231 | ``` 232 | 233 | ### Get a random movie 234 | 235 | ```sh 236 | curl -s localhost:8000/movies/random | jq . 237 | ``` 238 | 239 | ```json 240 | { 241 | "adult": false, 242 | "backdrop_path": "/oe7mWkvYhK4PLRNAVSvonzyUXNy.jpg", 243 | "belongs_to_collection": null, 244 | "budget": 85000000, 245 | "genres": [ 246 | { 247 | "id": 28, 248 | "name": "Action" 249 | }, 250 | { 251 | "id": 53, 252 | "name": "Thriller" 253 | } 254 | ], 255 | "homepage": "https://www.amazon.com/gp/video/detail/B0CH5YQPZQ", 256 | "id": 359410, 257 | "imdb_id": "tt3359350", 258 | "original_language": "en", 259 | "original_title": "Road House", 260 | "overview": "Ex-UFC fighter Dalton takes a job as a bouncer at a Florida Keys roadhouse, only to discover that this paradise is not all it seems.", 261 | "popularity": 1880.547, 262 | "poster_path": "/bXi6IQiQDHD00JFio5ZSZOeRSBh.jpg", 263 | "production_companies": [ 264 | { 265 | "id": 21, 266 | "logo_path": "/usUnaYV6hQnlVAXP6r4HwrlLFPG.png", 267 | "name": "Metro-Goldwyn-Mayer", 268 | "origin_country": "US" 269 | }, 270 | { 271 | "id": 1885, 272 | "logo_path": "/xlvoOZr4s1PygosrwZyolIFe5xs.png", 273 | "name": "Silver Pictures", 274 | "origin_country": "US" 275 | } 276 | ], 277 | "production_countries": [ 278 | { 279 | "iso_3166_1": "US", 280 | "name": "United States of America" 281 | } 282 | ], 283 | "release_date": "2024-03-08", 284 | "revenue": 0, 285 | "runtime": 121, 286 | "spoken_languages": [ 287 | { 288 | "english_name": "English", 289 | "iso_639_1": "en", 290 | "name": "English" 291 | } 292 | ], 293 | "status": "Released", 294 | "tagline": "Take it outside.", 295 | "title": "Road House", 296 | "video": false, 297 | "vote_average": 7.14, 298 | "vote_count": 1182, 299 | "poster_url": "https://image.tmdb.org/t/p/original/bXi6IQiQDHD00JFio5ZSZOeRSBh.jpg" 300 | } 301 | ``` 302 | 303 | ### Start a Title Detectives quiz 304 | 305 | ```sh 306 | curl -s -X POST localhost:8000/quiz/title-detectives \ 307 | -H 'Content-Type: application/json' \ 308 | -d '{"quiz_type": "title-detectives"}' | jq . 309 | ``` 310 | 311 | ```json 312 | { 313 | "quiz_id": "70ce5970-65bc-40b4-8b72-31a618254c63", 314 | "quiz_type": "title-detectives", 315 | "quiz_data": { 316 | "question": { 317 | "question": "Yo, movie buffs! This one's gonna be lit! Think of a horror flick where some reckless teenagers think they've hit the jackpot by breaking into a blind man's crib. Little did they know, this dude's got some serious secrets hidden inside. What am I talking 'bout?", 318 | "hint1": "This movie proves that sometimes, it's best to leave well enough alone. Especially when it comes to messing with people's homes.", 319 | "hint2": "D_n'_t B_e_t_e" 320 | }, 321 | "movie": {...}, 322 | "speech": "/audio/fc481314-6074-4654-94d5-6e967f20a313.mp3" 323 | } 324 | } 325 | ``` 326 | 327 | With the `speech` URL, you can get the result of the Google Text-to-Speech synthesis of the quiz question. 328 | 329 | ```sh 330 | wget localhost:8000/audio/fc481314-6074-4654-94d5-6e967f20a313.mp3 331 | ``` 332 | 333 | Generated audio and image files are automatically deleted after 24 hours. 334 | 335 | ### Send answer and finish a quiz 336 | 337 | ```sh 338 | curl -s -X POST localhost:8000/quiz/70ce5970-65bc-40b4-8b72-31a618254c63/answer \ 339 | -H 'Content-Type: application/json' \ 340 | -d '{"quiz_id": "70ce5970-65bc-40b4-8b72-31a618254c63", "answer": "Don't Breathe"}' | jq . 341 | ``` 342 | 343 | ## Rate limit 344 | 345 | In order to control costs and prevent abuse, the API offers a way to limit the number of quiz sessions per game mode 346 | and per day. 347 | 348 | The limits are managed in Firestore and can be adjusted in the Firestore console. 349 | 350 | ## Cleanup 351 | 352 | The generated audio and image files by Google Text-to-Speech and Imagen are automatically deleted after 24 hours. The 353 | cleanup logic is handled in the `TempDirCleaner` class, which is scheduled via the `apscheduler` module. 354 | 355 | ## Personalities 356 | 357 | Due to the modularity of the prompt generation, it is possible to easily switch personalities of the quiz master. The 358 | personalities are defined in Jinja templates in the `gemini_movie_detectives_api/templates/personality/` directory. 359 | 360 | ### Example: Dad Jokes Dad personality 361 | 362 | ![dad jokes](doc/sequel-salad-dad-jokes.png) 363 | --------------------------------------------------------------------------------