From 938cb60cf67bbe44c8c8bac95d970889c8a2ebf2 Mon Sep 17 00:00:00 2001 From: renaisssancee Date: Thu, 26 Mar 2026 19:21:10 +0300 Subject: [PATCH 1/3] HW3: Add deployment infrastructure - Docker, docker-compose, GitHub Actions CI --- .env.example | 1 + .github/workflows/ci.yml | 54 +++ .gitignore | 10 + Dockerfile | 23 + README.md | 932 +++++++++++++++++++++++++++++++++++++++ config.yaml | 35 ++ data/test_queries.jsonl | 30 ++ design_doc.md | 456 +++++++++++++++++++ docker-compose.yml | 14 + prompts.yaml | 60 +++ reports/hw2_report.md | 70 +++ reports/hw3_report.md | 58 +++ requirements.txt | 9 + scripts/build_index.py | 101 +++++ scripts/evaluate.py | 185 ++++++++ scripts/ingest.py | 119 +++++ src/__init__.py | 0 src/app.py | 117 +++++ src/hallucination.py | 32 ++ src/llm_utils.py | 47 ++ src/query_analyzer.py | 103 +++++ src/rag.py | 229 ++++++++++ src/retrieval.py | 133 ++++++ 23 files changed, 2818 insertions(+) create mode 100644 .env.example create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 config.yaml create mode 100644 data/test_queries.jsonl create mode 100644 design_doc.md create mode 100644 docker-compose.yml create mode 100644 prompts.yaml create mode 100644 reports/hw2_report.md create mode 100644 reports/hw3_report.md create mode 100644 requirements.txt create mode 100644 scripts/build_index.py create mode 100644 scripts/evaluate.py create mode 100644 scripts/ingest.py create mode 100644 src/__init__.py create mode 100644 src/app.py create mode 100644 src/hallucination.py create mode 100644 src/llm_utils.py create mode 100644 src/query_analyzer.py create mode 100644 src/rag.py create mode 100644 src/retrieval.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bd84041 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +OPENROUTER_API_KEY=your_key_here diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1bf85c8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +name: CI + +on: + push: + branches: ["**"] + pull_request: + branches: ["**"] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + pip install --upgrade pip + pip install ruff + + - name: Lint with ruff + run: ruff check src/ scripts/ + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + pip install --upgrade pip + pip install -r requirements.txt + + - name: Smoke test — import modules + run: | + python -c "from src.hallucination import check_retrieval_quality; print('hallucination OK')" + python -c "from src.llm_utils import llm_call_with_retry; print('llm_utils OK')" + + docker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build Docker image + run: docker build -t cinematch:test . diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9b6c58a --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +data/raw/ +.env +__pycache__/ +chroma_db/ +*.pyc +*.egg-info/ +.venv/ +venv/ +data/logs.db +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..afcb6d4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy source code +COPY src/ ./src/ +COPY scripts/ ./scripts/ +COPY config.yaml prompts.yaml .env.example ./ + +# Copy data if exists +COPY data/ ./data/ + +# Expose Streamlit port +EXPOSE 8501 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ + CMD curl -f http://localhost:8501/_stcore/health || exit 1 + +CMD ["streamlit", "run", "src/app.py", "--server.port=8501", "--server.address=0.0.0.0"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..f979f48 --- /dev/null +++ b/README.md @@ -0,0 +1,932 @@ +# CineMatch — Рекомендательная система фильмов на основе RAG + +CineMatch - это система рекомендаций фильмов, которая помогает подобрать кино под ваше настроение или запрос. Пользователь может просто описать, что ему хочется посмотреть (на русском или английском), а система подберёт подходящие варианты. +В основе лежит подход Retrieval-Augmented Generation (RAG): сначала система ищет релевантные фильмы в датасете TMDB 5000, а затем формирует понятное объяснение, почему именно эти фильмы подходят под запрос. + +--- + +## Оглавление + +1. [Архитектура](#1-архитектура) +2. [Стек технологий](#2-стек-технологий) +3. [Структура проекта](#3-структура-проекта) +4. [Установка и запуск](#4-установка-и-запуск) +5. [Конфигурация](#5-конфигурация) +6. [Компоненты системы](#6-компоненты-системы) + - [6.1 Query Analyzer](#61-query-analyzer-srcquery_analyzerpy) + - [6.2 Retriever](#62-retriever-srcretrievalpy) + - [6.3 Hallucination Guard](#63-hallucination-guard-srchallucinationpy) + - [6.4 RAG Orchestrator](#64-rag-orchestrator-srcragpy) + - [6.5 LLM Utils](#65-llm-utils-srcllm_utilspy) + - [6.6 Streamlit UI](#66-streamlit-ui-srcapppy) +7. [Скрипты](#7-скрипты) + - [7.1 Ingest](#71-ingest-scriptsingestpy) + - [7.2 Build Index](#72-build-index-scriptsbuild_indexpy) + - [7.3 Evaluate](#73-evaluate-scriptsevaluatepy) +8. [Данные](#8-данные) +9. [Промпты](#9-промпты) +10. [Обработка ошибок и отказоустойчивость](#10-обработка-ошибок-и-отказоустойчивость) +11. [Логирование и обратная связь](#11-логирование-и-обратная-связь) +12. [Оценка качества](#12-оценка-качества) +13. [Примеры работы пайплайна](#13-примеры-работы-пайплайна) + +--- + +## 1. Архитектура + +Система реализует пятиступенчатый RAG-пайплайн: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Пользователь │ +│ "Хочу страшный фильм до 100 минут" │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ 1. Query Analyzer (LLM) │ +│ Парсит запрос -> структурированные параметры │ +│ {genre: "Horror", max_duration: 100, │ +│ semantic_query: "scary horror movie"} │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ 2. Hybrid Retrieval │ +│ Векторный поиск (ChromaDB) + Фильтры по метаданным │ +│ -> 20 кандидатов │ +│ │ +│ 3. Cross-Encoder Reranking │ +│ Переранжирование кандидатов -> top-5 │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ 4. Hallucination Guard │ +│ Проверка качества выдачи (similarity ≥ 0.4) │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ 5. Generation (LLM) │ +│ Генерация ответа с объяснениями │ +│ на основе найденных фильмов │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ Ответ пользователю + Логирование в SQLite │ +└──────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. Стек технологий + +| Компонент | Технология | Назначение | +|-----------|-----------|------------| +| Векторная БД | ChromaDB ≥ 0.4.22 | Хранение и поиск эмбеддингов фильмов | +| Эмбеддинги | sentence-transformers ≥ 2.3.0 (`all-MiniLM-L6-v2`) | Векторизация текста, 384-мерные векторы | +| Реранкер | sentence-transformers (`cross-encoder/ms-marco-MiniLM-L-6-v2`) | Точное ранжирование кандидатов | +| LLM API | openai ≥ 1.0.0 (через OpenRouter) | Анализ запросов и генерация ответов | +| Web UI | Streamlit ≥ 1.30.0 | Интерактивный чат-интерфейс | +| Данные | pandas ≥ 2.1.0, kagglehub ≥ 0.2.0 | Загрузка и обработка TMDB 5000 | +| Конфигурация | PyYAML ≥ 6.0, python-dotenv ≥ 1.0.0 | Настройки и секреты | +| Логирование | SQLite (встроенный) | Запись запросов, ответов, фидбека | +| Python | 3.11+ | Рантайм | + +**LLM-модели (через OpenRouter, free tier):** + +| Приоритет | Модель | Роль | +|-----------|--------|------| +| Primary | `nvidia/nemotron-3-super-120b-a12b:free` | Основная модель | +| Fallback 1 | `deepseek/deepseek-chat-v3-0324:free` | Первая резервная | +| Fallback 2 | `google/gemma-3-27b-it:free` | Вторая резервная | +| Fallback 3 | `meta-llama/llama-4-maverick:free` | Третья резервная | + +--- + +## 3. Структура проекта + +``` +rag_film/ +├── .env.example # Шаблон переменных окружения +├── .gitignore # Правила исключения из Git +├── config.yaml # Конфигурация (модели, параметры, пути) +├── prompts.yaml # Шаблоны промптов для LLM (v1.0) +├── requirements.txt # Python-зависимости +├── design_doc.md # Дизайн-документ проекта +│ +├── src/ # Основной пакет приложения +│ ├── __init__.py # Маркер пакета +│ ├── app.py # Streamlit UI — точка входа для пользователя +│ ├── query_analyzer.py # Анализ запроса -> структурированные параметры +│ ├── retrieval.py # Векторный поиск + реранкинг +│ ├── rag.py # Оркестратор RAG-пайплайна +│ ├── hallucination.py # Защита от галлюцинаций +│ └── llm_utils.py # Общий хелпер для LLM-вызовов с retry/fallback +│ +├── scripts/ # Утилитарные скрипты +│ ├── ingest.py # Загрузка и предобработка TMDB 5000 +│ ├── build_index.py # Построение ChromaDB-индекса +│ └── evaluate.py # Оценка качества пайплайна +│ +├── data/ +│ ├── raw/ # Исходные CSV от TMDB (заполняется ingest.py) +│ ├── processed/ +│ │ └── movies.jsonl # Обработанные фильмы (~5000 записей, ~5.3 МБ) +│ ├── test_queries.jsonl # Тестовый набор (30 запросов с разметкой) +│ └── logs.db # SQLite-лог запросов (создаётся автоматически) +│ +└── chroma_db/ # Персистентный векторный индекс (создаётся build_index.py) +``` + +--- + +## 4. Установка и запуск + +### Предварительные требования + +- Python 3.11+ +- API-ключ OpenRouter ([openrouter.ai](https://openrouter.ai)) + +### Шаг 1. Клонирование и установка зависимостей + +```bash +git clone +cd rag_film +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +``` + +### Шаг 2. Настройка API-ключа + +```bash +cp .env.example .env +# Отредактируйте .env и вставьте свой ключ: +# OPENROUTER_API_KEY=sk-or-v1-... +``` + +### Шаг 3. Загрузка данных и построение индекса + +```bash +# Скачивает TMDB 5000 с Kaggle и создаёт movies.jsonl +python scripts/ingest.py + +# Строит векторный индекс в ChromaDB +python scripts/build_index.py +``` + +### Шаг 4. Запуск приложения + +```bash +streamlit run src/app.py +``` + +Приложение откроется в браузере по адресу `http://localhost:8501`. + +### Шаг 5. (Опционально) Запуск оценки качества + +```bash +python scripts/evaluate.py +``` + +--- + +## 5. Конфигурация + +Вся конфигурация сосредоточена в двух YAML-файлах. + +### config.yaml — параметры системы + +```yaml +# Модели +embedding_model: "all-MiniLM-L6-v2" # Модель эмбеддингов (384 измерения) +reranker_model: "cross-encoder/ms-marco-MiniLM-L-6-v2" # Кросс-энкодер для реранкинга +llm_model: "nvidia/nemotron-3-super-120b-a12b:free" # Основная LLM +fallback_models: # Резервные LLM (в порядке приоритета) + - "deepseek/deepseek-chat-v3-0324:free" + - "google/gemma-3-27b-it:free" + - "meta-llama/llama-4-maverick:free" +openrouter_base_url: "https://openrouter.ai/api/v1" + +# ChromaDB +chroma_db_path: "chroma_db" # Путь к персистентной БД +chroma_collection: "movies" # Имя коллекции +chroma_distance: "cosine" # Метрика расстояния + +# Поиск +retrieval: + n_results: 20 # Начальное кол-во кандидатов из ChromaDB + top_k: 5 # Финальное кол-во рекомендаций + min_results_with_filter: 5 # Мин. результатов с фильтром (иначе -> поиск без фильтра) + similarity_threshold: 0.4 # Порог сходства (ниже -> "ничего не найдено") + +# Анализ запроса +query_analyzer: + max_retries: 2 # Макс. повторов при ошибке парсинга JSON + backoff_base_seconds: 2 # База экспоненциального backoff (2^1=2с, 2^2=4с, ...) + +# Генерация ответа +generation: + max_retries: 2 # Макс. повторов при ошибке парсинга JSON + backoff_base_seconds: 2 # База экспоненциального backoff + history_turns: 2 # Кол-во пар (вопрос-ответ) для контекста диалога + +# Данные +data: + raw_path: "data/raw" + processed_path: "data/processed" + movies_file: "data/processed/movies.jsonl" + +# Логирование +logging: + db_path: "data/logs.db" +``` + +**Ключевые настройки для тюнинга:** + +| Параметр | Влияние | Компромисс | +|----------|---------|------------| +| `similarity_threshold` | Порог для hallucination guard | Ниже -> больше ответов, но выше риск нерелевантных рекомендаций | +| `n_results` | Размер пула кандидатов | Больше -> точнее реранкинг, но медленнее | +| `top_k` | Количество рекомендаций | Больше -> больше выбор, но менее фокусированный ответ | +| `history_turns` | Глубина контекста диалога | Больше -> лучше понимание контекста, но длиннее промпт | + +### prompts.yaml — шаблоны промптов + +Содержит system-промпты и user-шаблоны для двух LLM-вызовов: анализа запроса и генерации ответа. Подробнее в разделе [9. Промпты](#9-промпты). + +--- + +## 6. Компоненты системы + +### 6.1 Query Analyzer (`src/query_analyzer.py`) + +**Назначение:** Преобразование свободного текстового запроса пользователя в структурированные параметры поиска с помощью LLM. + +**Класс: `QueryAnalyzer`** + +```python +class QueryAnalyzer: + def __init__(self, api_key: str) + def analyze(self, user_query: str, history: list[dict] | None = None) -> dict + def _clean_json_response(self, text: str) -> str +``` + +**Метод `analyze()` — основной метод:** + +1. Формирует контекст диалога из последних `history_turns * 2` сообщений. +2. Подставляет запрос и историю в шаблон промпта. +3. Вызывает LLM через `llm_call_with_retry()` (с fallback-моделями и exponential backoff). +4. Парсит JSON-ответ, очищая от markdown code fences. +5. При ошибке парсинга — повторяет до `max_retries` раз. +6. При полном отказе — возвращает запрос "как есть" в поле `semantic_query`. + +**Формат выходных данных:** + +```python +# Успешный парсинг — рекомендация: +{ + "genre": "Horror", # жанр или None + "mood": "scary", # настроение или None + "max_duration": 100, # макс. длительность (мин.) или None + "min_year": 2010, # мин. год выпуска или None + "min_rating": 7.0, # мин. рейтинг или None + "semantic_query": "scary horror movie" # всегда на английском +} + +# Off-topic запрос: +{"off_topic": True} + +# Ошибка парсинга (fallback): +{ + "genre": None, "mood": None, "max_duration": None, + "min_year": None, "min_rating": None, + "semantic_query": "исходный запрос пользователя" +} +``` + +**Метод `_clean_json_response()`:** + +Очищает ответ LLM от markdown-обёрток: +- Удаляет ` ```json ` и ` ``` ` +- Извлекает первый JSON-объект `{...}` с помощью регулярного выражения +- Возвращает очищенный текст для `json.loads()` + +--- + +### 6.2 Retriever (`src/retrieval.py`) + +**Назначение:** Гибридный поиск фильмов: векторное сходство + фильтрация по метаданным + кросс-энкодерный реранкинг. + +**Класс: `Retriever`** + +```python +class Retriever: + def __init__(self) + def retrieve(self, parsed_query: dict) -> list[dict] + def _build_where_filter(self, parsed_query: dict) -> dict | None + def _query_chroma(self, query_embedding: list, where_filter: dict | None) -> list[dict] + def _rerank(self, query: str, candidates: list[dict]) -> list[dict] +``` + +**Инициализация:** +- Загружает SentenceTransformer (`all-MiniLM-L6-v2`) для создания эмбеддингов. +- Загружает CrossEncoder (`cross-encoder/ms-marco-MiniLM-L-6-v2`) для реранкинга. +- Подключается к персистентному ChromaDB и получает коллекцию `movies`. + +**Метод `retrieve()` — основной пайплайн:** + +``` +semantic_query -> Encode -> ChromaDB query (+ metadata filters) + │ + 20 кандидатов + │ + [если < 5 -> повтор без фильтра] + │ + Cross-Encoder Reranking + │ + top-5 результатов +``` + +1. Кодирует `semantic_query` в вектор +2. Строит фильтр метаданных (жанр, длительность, год, рейтинг) +3. Запрашивает ChromaDB, получает до 20 кандидатов +4. Если с фильтром найдено менее 5, повторяет без фильтра +5. Реранжирует кандидатов кросс-энкодером +6. Возвращает top-5 + +**Метод `_build_where_filter()` — построение ChromaDB-фильтров:** + +Поддерживаемые фильтры: + +| Поле | Оператор ChromaDB | Пример | +|------|-------------------|--------| +| `genre` | `$contains` по `genres_pipe` | `|Horror|` содержит "Horror" | +| `max_duration` | `$lte` по `duration_min` | `duration_min ≤ 100` | +| `min_year` | `$gte` по `year` | `year ≥ 2010` | +| `min_rating` | `$gte` по `rating` | `rating ≥ 7.0` | + +При нескольких условиях объединяются через `$and`. + +**Метод `_rerank()` — кросс-энкодерное переранжирование:** + +- Формирует пары `(запрос, описание_фильма)` для каждого кандидата. +- CrossEncoder оценивает семантическую релевантность каждой пары. +- Сортирует по `rerank_score` (убывание). + +**Формат выходного объекта фильма:** + +```python +{ + "id": "19995", + "title": "Avatar", + "year": 2009, + "duration_min": 162, + "rating": 7.2, + "genres": "|Action|Adventure|Fantasy|Science Fiction|", + "overview": "In the 22nd century, a paraplegic Marine...", + "similarity": 0.7234, # косинусное сходство (из ChromaDB) + "rerank_score": 8.45 # оценка кросс-энкодера +} +``` + +--- + +### 6.3 Hallucination Guard (`src/hallucination.py`) + +**Назначение:** Предотвращение нерелевантных рекомендаций. Если лучший найденный фильм слишком далёк от запроса, система честно сообщает, что подходящих результатов нет. + +**Функция:** + +```python +def check_retrieval_quality(candidates: list[dict]) -> tuple[bool, str] +``` + +**Логика:** +1. Если список кандидатов пуст -> `(False, "подходящих фильмов не найдено")` +2. Находит максимальное значение `similarity` среди всех кандидатов. +3. Если `max_similarity < 0.4` -> `(False, "попробуйте переформулировать")` +4. Иначе -> `(True, "")` — качество достаточное. + +**Зачем это нужно:** Без этой проверки LLM может "натянуть" объяснение на нерелевантные фильмы, создавая иллюзию полезного ответа. Hallucination guard гарантирует, что LLM получает только достаточно релевантных кандидатов. + +--- + +### 6.4 RAG Orchestrator (`src/rag.py`) + +**Назначение:** Центральный компонент, объединяющий все этапы пайплайна. Управляет потоком данных от запроса до ответа, логированием и обратной связью. + +**Класс: `CineMatchRAG`** + +```python +class CineMatchRAG: + def __init__(self, api_key: str) + def query(self, user_query: str, history: list[dict] | None = None) -> dict + def save_feedback(self, request_id: str, feedback: str) +``` + +**Метод `query()` — главная точка входа:** + +```python +def query(self, user_query, history=None) -> dict: + # 1. Генерация request_id (UUID) и замер времени + # 2. Анализ запроса (QueryAnalyzer) + # -> если off_topic — ранний возврат + # 3. Поиск кандидатов (Retriever) + # 4. Проверка качества (Hallucination Guard) + # -> если low quality — возврат с fallback-сообщением + # 5. Генерация ответа (LLM) + # 6. Логирование в SQLite + # 7. Возврат результата +``` + +**Формат ответа:** + +```python +# Рекомендация: +{ + "request_id": "550e8400-e29b-41d4-a716-446655440000", + "type": "recommendation", + "message": "Вот несколько фильмов, которые могут вам понравиться:", + "movies": [ + { + "title": "Insidious", + "year": 2010, + "rating": 6.8, + "duration_min": 103, + "reason": "Классический хоррор с психологическими пугалками" + }, + ... + ] +} + +# Off-topic: +{ + "request_id": "...", + "type": "off_topic", + "message": "Я — CineMatch, рекомендательная система фильмов...", + "movies": [] +} + +# Нет результатов: +{ + "request_id": "...", + "type": "no_results", + "message": "По вашему запросу подходящих фильмов не найдено...", + "movies": [] +} +``` + +**Метод `_generate_response()` — генерация ответа:** + +1. Форматирует найденные фильмы в JSON. +2. Подставляет в шаблон промпта: запрос, фильмы, историю. +3. Вызывает LLM через `llm_call_with_retry()`. +4. Парсит JSON-ответ. +5. **Fallback**: если LLM не отвечает или JSON невалиден — возвращает базовую информацию о фильмах (без генеративных объяснений). + +**Внутренние методы:** +- `_init_db()` — создание SQLite-таблицы `logs` при инициализации. +- `_log(...)` — запись запроса/ответа в SQLite. +- `save_feedback(request_id, feedback)` — сохранение лайка/дизлайка от пользователя. +- `_clean_json_response(text)` — очистка ответа LLM от markdown. + +--- + +### 6.5 LLM Utils (`src/llm_utils.py`) + +**Назначение:** Общий хелпер для надёжных LLM-вызовов с exponential backoff и цепочкой fallback-моделей. Используется в `query_analyzer.py`, `rag.py` и `evaluate.py`. + +**Функция:** + +```python +def llm_call_with_retry( + client: openai.OpenAI, + model: str, + messages: list[dict], + fallback_models: list[str] | None = None, + max_retries: int = 2, + backoff_base: float = 2.0, +) -> str | None +``` + +**Алгоритм:** + +``` +Для каждой модели в [primary, fallback_1, fallback_2, ...]: + Для каждой попытки (0 .. max_retries): + Попытка вызова API + ├── Успех -> return текст ответа + ├── RateLimitError / APIConnectionError / APIStatusError + │ ├── Есть ещё попытки -> sleep(backoff_base^(attempt+1)), retry + │ └── Попытки кончились -> следующая модель + └── Пустой ответ -> следующая модель + +Все модели исчерпаны -> return None +``` + +**Задержки при backoff (backoff_base=2):** +- 1-й retry: `2^1 = 2` секунды +- 2-й retry: `2^2 = 4` секунды +- 3-й retry: `2^3 = 8` секунд + +**Обрабатываемые ошибки:** +- `openai.RateLimitError` (HTTP 429) — превышение лимита запросов +- `openai.APIConnectionError` — проблемы с сетью +- `openai.APIStatusError` — серверные ошибки API (500, 503 и др.) +- `ValueError` — пустой ответ от модели + +--- + +### 6.6 Streamlit UI (`src/app.py`) + +**Назначение:** Веб-интерфейс с чатом для взаимодействия с пользователем. + +**Основные элементы:** + +- **Заголовок:** "CineMatch" с подписью "Рекомендательная система фильмов на основе RAG" +- **Чат:** Многоходовый диалог с сохранением истории в `st.session_state` +- **Ввод:** Текстовое поле с плейсхолдером "Опишите, какой фильм вы хотите посмотреть..." +- **Рекомендации:** Форматированный вывод с рейтингом, длительностью и объяснением +- **Обратная связь:** Кнопки "👍" и "👎" на каждом ответе ассистента + +**Формат отображения рекомендации:** + +``` +**1. Insidious** (2010) + ⭐ 6.8 | ⏱ 103 мин + _Классический хоррор с психологическими пугалками_ +``` + +**Обработка ошибок:** + +Вызов `rag.query()` обёрнут в `try/except`. При любом необработанном исключении пользователь видит дружелюбное сообщение вместо traceback: + +> "Произошла ошибка при обработке запроса. Попробуйте ещё раз через несколько секунд." + +**Кэширование:** + +`get_rag()` декорирован `@st.cache_resource` — RAG-пайплайн (включая загрузку моделей и подключение к ChromaDB) инициализируется один раз и переиспользуется между запросами. + +--- + +## 7. Скрипты + +### 7.1 Ingest (`scripts/ingest.py`) + +**Назначение:** Загрузка датасета TMDB 5000 с Kaggle и его предобработка. + +**Запуск:** `python scripts/ingest.py` + +**Что делает:** + +1. Скачивает `tmdb_5000_movies.csv` через `kagglehub`. +2. Парсит JSON-столбцы (`genres`, `keywords`), извлекая имена. +3. Извлекает год из `release_date`. +4. Переименовывает: `runtime` -> `duration_min`, `vote_average` -> `rating`. +5. Фильтрует фильмы без описания или длительности. +6. Создаёт `text_for_embedding`: + ``` + Avatar (2009). Genres: Action, Adventure, Fantasy, Science Fiction. + In the 22nd century, a paraplegic Marine... + Tags: culture clash, future, space war + ``` +7. Создаёт pipe-delimited поля для фильтрации в ChromaDB: + - `genres_pipe`: `|Action|Adventure|Fantasy|` + - `tags_pipe`: `|culture clash|future|space war|` +8. Сохраняет результат в `data/processed/movies.jsonl` (одна строка — один JSON-объект). + +**Выходной формат записи:** + +```json +{ + "id": "19995", + "title": "Avatar", + "year": 2009, + "duration_min": 162, + "rating": 7.2, + "genres": "Action, Adventure, Fantasy, Science Fiction", + "genres_pipe": "|Action|Adventure|Fantasy|Science Fiction|", + "keywords": "culture clash, future, space war, ...", + "tags_pipe": "|culture clash|future|space war|...|", + "overview": "In the 22nd century, a paraplegic Marine...", + "text_for_embedding": "Avatar (2009). Genres: Action, Adventure, ..." +} +``` + +--- + +### 7.2 Build Index (`scripts/build_index.py`) + +**Назначение:** Построение векторного индекса ChromaDB из обработанных фильмов. + +**Запуск:** `python scripts/build_index.py` + +**Что делает:** + +1. Загружает фильмы из `data/processed/movies.jsonl`. +2. Инициализирует SentenceTransformer (`all-MiniLM-L6-v2`). +3. Батчево кодирует все `text_for_embedding` (batch_size=64). +4. Создаёт коллекцию `movies` в ChromaDB с метрикой cosine. +5. Батчево добавляет документы (batch_size=500) с: + - **ID:** id фильма + - **Document:** text_for_embedding + - **Embedding:** предвычисленный вектор + - **Metadata:** title, year, duration_min, rating, genres_pipe, tags_pipe, overview +6. Выполняет тестовый запрос `"space exploration emotional drama"` для верификации. + +**Важно:** Индекс нужно пересоздавать при: +- Смене embedding-модели +- Обновлении данных (re-run ingest.py) +- Изменении формата `text_for_embedding` + +--- + +### 7.3 Evaluate (`scripts/evaluate.py`) + +**Назначение:** Автоматическая оценка качества RAG-пайплайна по набору тестовых запросов. + +**Запуск:** `python scripts/evaluate.py` + +**Метрики:** + +| Метрика | Описание | Целевое значение | +|---------|----------|-----------------| +| Recall@5 (genre) | Доля запросов, где хотя бы один фильм соответствует ожидаемому жанру | ≥ 0.75 | +| Avg Latency | Среднее время обработки запроса (мс) | ≤ 10 000 мс | +| LLM Judge | Средняя оценка рекомендаций от LLM-судьи (1-5) | ≥ 4.0 | +| Hallucination Rate | Доля запросов, где ожидались рекомендации, но система вернула "не найдено" | < 5% | + +**LLM Judge** — отдельный LLM-вызов, оценивающий качество по критериям: +- Релевантность к запросу (жанр, настроение, тема) +- Разнообразие рекомендаций +- Качество объяснений + +**Отказоустойчивость:** +- Каждый `rag.query()` обёрнут в `try/except` — один упавший запрос не прерывает весь evaluation. +- `evaluate_with_llm_judge()` использует `llm_call_with_retry()` с fallback-моделями. +- Между запросами — `time.sleep(1)` для снижения нагрузки на API (courtesy delay). + +**Пример выходных данных:** + +``` +[1/30] Хочу что-то как Интерстеллар + Genre recall: 1.00 + LLM judge: 4.5/5 + Latency: 3200ms | Type: recommendation + +... + +============================================================ +EVALUATION RESULTS +============================================================ +Recall@5 (genre): 0.85 (target: >= 0.75) +Avg Latency: 4200ms (target: <= 10000ms) +LLM Judge: 4.2/5 (target: >= 4.0) +Hallucination rate: 3.3% (target: < 5%) + +Overall: PASS ✓ +``` + +--- + +## 8. Данные + +### Источник: TMDB 5000 + +Датасет [TMDB 5000 Movie Dataset](https://www.kaggle.com/datasets/tmdb/tmdb-movie-metadata) с Kaggle. Содержит ~5000 фильмов с метаданными: название, жанры, ключевые слова, синопсис, рейтинг, длительность, дата выпуска. + +### Обработанный формат (`movies.jsonl`) + +Каждая строка — JSON-объект с полями: + +| Поле | Тип | Описание | +|------|-----|----------| +| `id` | string | ID фильма в TMDB | +| `title` | string | Название фильма | +| `year` | int | Год выпуска | +| `duration_min` | int | Длительность в минутах | +| `rating` | float | Средняя оценка (0-10) | +| `genres` | string | Жанры через запятую | +| `genres_pipe` | string | Жанры в формате `\|Genre1\|Genre2\|` для ChromaDB | +| `keywords` | string | Ключевые слова (до 15) через запятую | +| `tags_pipe` | string | Ключевые слова в pipe-формате | +| `overview` | string | Синопсис фильма (на английском) | +| `text_for_embedding` | string | Объединённый текст для векторизации | + +### Тестовый набор (`test_queries.jsonl`) + +30 запросов на русском языке с разметкой: + +```jsonl +{"query": "Хочу что-то как Интерстеллар", "expected_genres": ["Science Fiction", "Drama"], "expected_type": "recommendation"} +{"query": "Страшный фильм до 100 минут", "expected_genres": ["Horror"], "expected_type": "recommendation"} +{"query": "Какая сегодня погода?", "expected_genres": [], "expected_type": "off_topic"} +``` + +- 27 запросов типа `recommendation` (различные жанры, настроения, ограничения) +- 3 запроса типа `off_topic` (не связаны с фильмами) + +--- + +## 9. Промпты + +### Query Analyzer: system prompt + +Задача: парсить запросы пользователя в структурированный JSON. + +Ключевые инструкции: +- Входные данные: запрос (русский/английский) + история диалога +- **`semantic_query` всегда на английском** (эмбеддинги обучены на английском) +- Фильтры (`genre`, `mood`, `max_duration`, `min_year`, `min_rating`) только если явно указаны +- Off-topic запросы -> `{"off_topic": true}` +- Ответ строго в формате JSON, без пояснений + +### Generation: system prompt + +Задача: сгенерировать рекомендации на основе найденных фильмов. + +Ключевые правила: +1. Рекомендовать **только** из предоставленного списка (никогда не выдумывать) +2. Отвечать **на языке пользователя** +3. Краткое объяснение для каждого фильма +4. Честно сказать, если результаты не идеально подходят +5. Лаконичные ответы + +Формат ответа: + +```json +{ + "movies": [ + { + "title": "...", + "year": 2020, + "rating": 7.5, + "duration_min": 120, + "reason": "Краткое объяснение, почему фильм подходит" + } + ], + "message": "Разговорное сообщение на языке пользователя" +} +``` + +--- + +## 10. Обработка ошибок и отказоустойчивость + +Система спроектирована так, чтобы деградировать gracefully, а не падать. + +### Уровни защиты + +``` +┌────────────────────────────────────────────────────────┐ +│ Уровень 1: LLM retry + backoff │ +│ RateLimitError -> повтор через 2с, 4с, 8с │ +├────────────────────────────────────────────────────────┤ +│ Уровень 2: Fallback-модели │ +│ primary -> deepseek -> gemma -> llama │ +├────────────────────────────────────────────────────────┤ +│ Уровень 3: Локальные fallback-ответы │ +│ Query Analyzer: raw query как semantic_query │ +│ Generation: базовая инфо о фильмах без LLM │ +├────────────────────────────────────────────────────────┤ +│ Уровень 4: UI error handling │ +│ try/except -> st.error() вместо traceback │ +└────────────────────────────────────────────────────────┘ +``` + +### Сценарии ошибок + +| Ситуация | Поведение | +|----------|-----------| +| API rate limit (429) | Retry с exponential backoff -> fallback models -> локальный fallback | +| Сеть недоступна | Те же retry/fallback, после исчерпания -> сообщение об ошибке | +| LLM вернула невалидный JSON | До 2 retry JSON-парсинга -> fallback на raw data | +| LLM вернула пустой ответ | Переключение на следующую модель | +| Нет подходящих фильмов | Hallucination guard -> сообщение "попробуйте переформулировать" | +| Фильтр слишком строгий | Автоматический повтор поиска без фильтра | +| ChromaDB не создана | Ошибка при инициализации (требуется `build_index.py`) | +| Нет API-ключа | `st.error()` с инструкцией при запуске | + +--- + +## 11. Логирование и обратная связь + +### SQLite-лог (`data/logs.db`) + +Каждый запрос записывается в таблицу `logs`: + +| Столбец | Тип | Описание | +|---------|-----|----------| +| `request_id` | TEXT (PK) | UUID запроса | +| `timestamp` | REAL | Unix timestamp | +| `user_query` | TEXT | Исходный запрос пользователя | +| `parsed_query` | TEXT | JSON: структурированные параметры | +| `retrieved_movie_ids` | TEXT | JSON-массив ID найденных фильмов | +| `llm_response` | TEXT | Полный JSON-ответ генерации | +| `latency_ms` | REAL | Время обработки (мс) | +| `feedback` | TEXT | "like" / "dislike" / NULL | + +### Обратная связь + +Каждый ответ ассистента сопровождается кнопками 👍/👎. При нажатии `feedback` обновляется в соответствующей строке `logs`. + +Данные логирования можно использовать для: +- Анализа популярных запросов +- Выявления проблемных паттернов (частые no_results, low judge scores) +- Корреляции feedback с качеством выдачи +- Мониторинга latency + +--- + +## 12. Оценка качества + +### Запуск + +```bash +python scripts/evaluate.py +``` + +### Метрики и пороги + +| Метрика | Формула | Целевое значение | Что измеряет | +|---------|---------|-----------------|--------------| +| **Recall@5** | (запросы с хотя бы 1 совпавшим жанром) / (всего запросов) | ≥ 0.75 | Точность жанрового поиска | +| **Avg Latency** | среднее(latency по всем запросам) | ≤ 10 с | Скорость отклика | +| **LLM Judge** | среднее(оценка LLM 1-5) | ≥ 4.0 | Общее качество рекомендаций | +| **Hallucination Rate** | (ожидали рекомендации, получили "не найдено") / total × 100% | < 5% | False negative rate | + +### Общий вердикт + +**PASS** — все 4 метрики достигают целевых значений одновременно. + +--- + +## 13. Примеры работы пайплайна + +### Пример 1: Жанровый запрос с ограничениями + +``` +Запрос: "Страшный фильм до 100 минут" + +-> Query Analyzer: + {genre: "Horror", max_duration: 100, semantic_query: "scary horror movie"} + +-> Retriever: + Фильтр: genres $contains "Horror" AND duration_min <= 100 + 20 кандидатов -> реранкинг -> top-5 + +-> Hallucination Guard: max_similarity = 0.72 ≥ 0.4 ✓ + +-> Generation: + { + movies: [{title: "Insidious", year: 2010, rating: 6.8, duration_min: 103, reason: "..."}], + message: "Вот несколько ужастиков, которые уложатся в ваше время:" + } +``` + +### Пример 2: Off-topic запрос + +``` +Запрос: "Какая сегодня погода?" + +-> Query Analyzer: {off_topic: true} + +-> Немедленный возврат: + {type: "off_topic", message: "Я — CineMatch, рекомендательная система фильмов..."} +``` + +### Пример 3: Нет подходящих результатов + +``` +Запрос: "Документальный фильм про выращивание сыра в Швейцарии" + +-> Query Analyzer: + {genre: "Documentary", semantic_query: "cheese making Switzerland documentary"} + +-> Retriever: 3 кандидата, max_similarity = 0.25 + +-> Hallucination Guard: 0.25 < 0.4 ✗ + +-> Возврат: + {type: "no_results", message: "По вашему запросу подходящих фильмов не найдено..."} +``` + +### Пример 4: Fallback при отказе API + +``` +Запрос: "Романтическая комедия" + +-> Query Analyzer -> llm_call_with_retry: + nvidia/nemotron -> 429 RateLimitError + retry 1 (2с) -> 429 + retry 2 (4с) -> 429 + deepseek/deepseek-chat -> 200 OK ✓ + +-> Далее обычный пайплайн с ответом от deepseek +``` + +--- diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..31d934f --- /dev/null +++ b/config.yaml @@ -0,0 +1,35 @@ +embedding_model: "all-MiniLM-L6-v2" +reranker_model: "cross-encoder/ms-marco-MiniLM-L-6-v2" +llm_model: "nvidia/nemotron-3-super-120b-a12b:free" +fallback_models: + - "deepseek/deepseek-chat-v3-0324:free" + - "google/gemma-3-27b-it:free" + - "meta-llama/llama-4-maverick:free" +openrouter_base_url: "https://openrouter.ai/api/v1" + +chroma_db_path: "chroma_db" +chroma_collection: "movies" +chroma_distance: "cosine" + +retrieval: + n_results: 20 + top_k: 5 + min_results_with_filter: 5 + similarity_threshold: 0.4 + +query_analyzer: + max_retries: 2 + backoff_base_seconds: 2 + +generation: + max_retries: 2 + backoff_base_seconds: 2 + history_turns: 2 + +data: + raw_path: "data/raw" + processed_path: "data/processed" + movies_file: "data/processed/movies.jsonl" + +logging: + db_path: "data/logs.db" diff --git a/data/test_queries.jsonl b/data/test_queries.jsonl new file mode 100644 index 0000000..60db72d --- /dev/null +++ b/data/test_queries.jsonl @@ -0,0 +1,30 @@ +{"query": "Хочу что-то как Интерстеллар", "expected_genres": ["Science Fiction", "Drama"], "expected_type": "recommendation"} +{"query": "Страшный фильм до 100 минут", "expected_genres": ["Horror"], "expected_type": "recommendation"} +{"query": "Романтическая комедия для вечера", "expected_genres": ["Romance", "Comedy"], "expected_type": "recommendation"} +{"query": "Эпический фэнтези фильм", "expected_genres": ["Fantasy", "Adventure"], "expected_type": "recommendation"} +{"query": "Триллер с неожиданной концовкой", "expected_genres": ["Thriller"], "expected_type": "recommendation"} +{"query": "Анимационный фильм для всей семьи", "expected_genres": ["Animation", "Family"], "expected_type": "recommendation"} +{"query": "Военный фильм про вторую мировую", "expected_genres": ["War", "Drama"], "expected_type": "recommendation"} +{"query": "Документальный фильм о природе", "expected_genres": ["Documentary"], "expected_type": "recommendation"} +{"query": "Криминальная драма как Крёстный отец", "expected_genres": ["Crime", "Drama"], "expected_type": "recommendation"} +{"query": "Фильм про супергероев", "expected_genres": ["Action", "Science Fiction"], "expected_type": "recommendation"} +{"query": "Лёгкая комедия чтобы посмеяться", "expected_genres": ["Comedy"], "expected_type": "recommendation"} +{"query": "Фильм о путешествии во времени", "expected_genres": ["Science Fiction"], "expected_type": "recommendation"} +{"query": "Мюзикл с хорошими песнями", "expected_genres": ["Music"], "expected_type": "recommendation"} +{"query": "Психологический триллер", "expected_genres": ["Thriller", "Drama"], "expected_type": "recommendation"} +{"query": "Фильм-катастрофа", "expected_genres": ["Action", "Thriller"], "expected_type": "recommendation"} +{"query": "Детективный фильм с загадкой", "expected_genres": ["Mystery", "Crime"], "expected_type": "recommendation"} +{"query": "Спортивная драма", "expected_genres": ["Drama"], "expected_type": "recommendation"} +{"query": "Фильм про космос и инопланетян", "expected_genres": ["Science Fiction"], "expected_type": "recommendation"} +{"query": "Вестерн", "expected_genres": ["Western"], "expected_type": "recommendation"} +{"query": "Фильм нуар", "expected_genres": ["Crime", "Thriller"], "expected_type": "recommendation"} +{"query": "Биографический фильм о музыканте", "expected_genres": ["Drama", "Music"], "expected_type": "recommendation"} +{"query": "Хороший фильм с рейтингом выше 8", "expected_genres": [], "expected_type": "recommendation"} +{"query": "Новый фильм после 2015 года", "expected_genres": [], "expected_type": "recommendation"} +{"query": "Короткий фильм до 90 минут", "expected_genres": [], "expected_type": "recommendation"} +{"query": "Какая сегодня погода?", "expected_genres": [], "expected_type": "off_topic"} +{"query": "Сколько будет 2+2?", "expected_genres": [], "expected_type": "off_topic"} +{"query": "Расскажи анекдот", "expected_genres": [], "expected_type": "off_topic"} +{"query": "Посоветуй что-то весёлое и доброе", "expected_genres": ["Comedy", "Family"], "expected_type": "recommendation"} +{"query": "Мрачный фильм с глубоким смыслом", "expected_genres": ["Drama"], "expected_type": "recommendation"} +{"query": "Приключенческий фильм для подростков", "expected_genres": ["Adventure"], "expected_type": "recommendation"} diff --git a/design_doc.md b/design_doc.md new file mode 100644 index 0000000..af06d98 --- /dev/null +++ b/design_doc.md @@ -0,0 +1,456 @@ +# Design-Doc +# CineMatch: LLM-based Movie Recommendation System + +--- + +## 1. Контекст проекта + +### 1.1 Бизнес-задача + +**Проблема:** Существующие рекомендательные системы (Netflix, Кинопоиск, IMDb) работают на основе истории просмотров и коллаборативной фильтрации. Они не понимают текстовый запрос на естественном языке: нельзя написать «хочу что-то душевное из 90-х про дружбу, но не слишком длинное» и получить релевантный результат. Поиск по ключевым словам тоже не решает задачу — он не улавливает семантику и настроение. + +**Почему LLM-решение оправдано:** Только LLM способен понять контекст запроса («как Интерстеллар» = космос + эмоциональная глубина + научная точность), извлечь структурированные ограничения из свободного текста и сгенерировать объяснение рекомендации. Классический поиск по тегам или жанрам этого не даёт. + +**Критерии успеха:** + +| Метрика | Цель | +|---|---| +| Recall@5 (по жанру) | ≥ 0.75 | +| Precision по тегам MovieLens | ≥ 0.70 | +| Latency (время ответа) | ≤ 10 сек | +| LLM-judge relevance score | ≥ 4.0 / 5.0 | +| Hallucination rate | < 5% | + +--- + +### 1.2 Целевая аудитория и пользователи + +**Кто:** широкая аудитория — люди, которые не знают что посмотреть и хотят объяснить это словами, а не кликать по категориям. + +**Сценарии использования:** +- Текстовый чат в Telegram или веб-интерфейс (Streamlit) +- Пользователь пишет запрос в свободной форме, получает топ-5 рекомендаций с объяснением + +**Предполагаемая нагрузка:** учебный прототип, 1–20 одновременных пользователей, без жёстких SLA. + +**Цена ошибки:** низкая — плохая рекомендация фильма не несёт репутационного или финансового риска. Это позволяет быть смелее в экспериментах с архитектурой. + +--- + +### 1.3 Ограничения и допущения + +- База фильмов **статическая** — датасет TMDB 5000; система не подключается к live API кинотеатров +- Система отвечает **только** на запросы о фильмах; на офтопик отвечает: *«Я специализируюсь только на рекомендациях фильмов»* +- Данные публичные, PII отсутствует — использование внешних LLM API (OpenAI / Gemini) допустимо +- Нет интеграции с личными профилями, историей просмотров, платёжными системами +- **Репутационный риск:** система может рекомендовать фильмы с неоднозначным содержанием — добавляем age-rating фильтр и указываем рейтинг в выдаче +- **Допущение:** качества sentence-transformers достаточно для семантического поиска без дообучения + +--- + +## 2. Архитектура решения + +### 2.1 Общая схема + +``` +Пользователь: "Страшный фильм, но не слишком длинный" + │ + ▼ +[1. Query Analyzer — LLM] + Извлекает структурированный запрос: + { "genre": "horror", "max_duration": 100, "mood": "tense" } + │ + ▼ +[2. Hybrid Retrieval] + └─ Metadata filter (ChromaDB): duration ≤ 100, genre = horror + └─ Vector search: embedding по описанию + тегам → top-20 + │ + ▼ +[3. Reranker (Cross-Encoder)] + top-20 → cross-encoder → top-5 + │ + ▼ +[4. Hallucination Guard] + similarity < 0.4 → fallback ("не нашли подходящего") + │ + ▼ +[5. LLM Generation — GPT-4o-mini] + Промпт: запрос + top-5 фильмов с метаданными + → структурированный ответ с объяснением + │ + ▼ +[6. Post-processor] + Форматирование, добавление постеров/ссылок, логирование + │ + ▼ +Пользователь получает: топ-5 + объяснение почему +``` + +**Где что лежит:** +- **ChromaDB** — локально на диске (persistent mode), стучимся на шаге 2 +- **Промпты** — `prompts.yaml`, версионируются в git +- **Логи** — SQLite (request_id, timestamp, query, results, latency, feedback) + +--- + +### 2.2 Хранилище знаний / документооборот + +**Источники данных:** + +| Датасет | Источник | Формат | Размер | Назначение | +|---|---|---|---|---| +| TMDB 5000 Movie Dataset | Kaggle | CSV | ~5 000 фильмов | Основная база: описания, жанры, рейтинги | +| MovieLens ml-32m (теги) | Kaggle / GroupLens | CSV | ~1.3M тегов | Обогащение: "mind-bending", "dystopia" и др. | + +**Предобработка:** +1. Объединение TMDB + тегов MovieLens по названию/ID фильма +2. Очистка: удаление дубликатов, фильмов без описания +3. Формирование текстового поля для эмбеддинга: `title + genres + overview + top-10 tags` +4. Метаданные каждого документа: `movie_id, title, year, genres[], duration, rating, tags[]` + +**Обновление данных:** статическое для учебного проекта. При необходимости — полная пересборка индекса скриптом `scripts/rebuild_index.py`. + +--- + +### 2.3 Интеграции и интерфейсы + +- **UI:** Telegram-бот (python-telegram-bot) **или** Streamlit веб-приложение — на выбор команды +- **LLM API:** OpenAI REST (GPT-4o-mini) или Google Gemini API +- **Протокол:** HTTP/REST +- **Мониторинг:** SQLite-лог каждого запроса; кнопки like/dislike в Telegram для сбора фидбэка +- **Внешних CRM / баз пользователей нет** + +--- + +### 2.4 Инфраструктура и развертывание + +- **Стек:** Python 3.11, ChromaDB, sentence-transformers, FastAPI (опционально), Docker +- **Среды:** dev (локально), demo (Hugging Face Spaces или Railway) +- **Вычисления:** CPU достаточно; sentence-transformers ~500MB RAM, ChromaDB ~200MB на 5k фильмов +- **GPU:** не требуется при использовании OpenAI API +- **Безопасность:** API-ключи в `.env`, не коммитятся в git; `.gitignore` для данных и ключей + +--- + +## 3. Данные и качество знаний + +> Данные публичные, PII отсутствует. Отправка текстов в OpenAI API допустима. + +### 3.1 Сбор и предобработка данных +Данные: https://www.kaggle.com/datasets/tmdb/tmdb-movie-metadata(датасет TMDB 5000 - kaggle) + +**Шаги пайплайна (`scripts/ingest.py`):** + +1. Загрузка CSV через `pandas` +2. Джойн TMDB ↔ MovieLens по `movieId` / `imdb_id` +3. Очистка: regex-удаление спецсимволов, нормализация жанров +4. Формирование поля `text_for_embedding`: + ``` + {title} ({year}). Genres: {genres}. {overview}. Tags: {top_tags} + ``` +5. Сохранение в `data/processed/movies.jsonl` + +**Метаданные каждой записи:** +```json +{ + "movie_id": 157336, + "title": "Interstellar", + "year": 2014, + "genres": ["Science Fiction", "Drama"], + "duration_min": 169, + "rating": 8.6, + "tags": ["space", "mind-bending", "emotional"], + "text_for_embedding": "..." +} +``` + +--- + +### 3.2 Векторизация и индексирование + +- **Модель эмбеддингов:** `sentence-transformers/all-MiniLM-L6-v2` (384 dim, быстрая, бесплатная) + - Альтернатива: `intfloat/multilingual-e5-large` (768 dim, лучше качество) +- **Vector DB:** ChromaDB (persistent, локально) +- **Метрика:** cosine similarity +- **Reranker:** `cross-encoder/ms-marco-MiniLM-L-6-v2` для переранжирования top-20 → top-5 +- **Порог:** similarity < 0.4 → hallucination guard, fallback ответ +- **Стратегия обновления:** полная пересборка при изменении датасета + +--- + +### 3.3 Метрики качества знаний + +- **Покрытие:** % запросов из тест-сета, для которых есть релевантный фильм в базе (цель ≥ 90%) +- **Консистентность:** автопроверка дубликатов по `movie_id` при каждой пересборке +- **Актуальность:** датасет TMDB зафиксирован +- **Ручная валидация:** 30 тестовых запросов, оценка релевантности топ-5 двумя участниками команды + +--- + +## 4. Модель и генерация + +### 4.1 Выбор LLM и промптинг + +**Модель:** `GPT-4o-mini` — баланс качества и стоимости. + +**Промпт-шаблон (Generation):** + +``` +System: +Ты — CineMatch, экспертный рекомендатель фильмов. +Тебе передаётся запрос пользователя и список фильмов из базы. +Отвечай ТОЛЬКО на основе переданных фильмов. Не выдумывай фильмы. +Если контекст недостаточен — честно скажи об этом. + +User: +Запрос: {user_query} + +Фильмы из базы: +{retrieved_movies_json} + +Верни JSON: +{ + "recommendations": [ + {"title": "...", "year": ..., "reason": "...почему подходит..."}, + ... + ], + "explanation": "общий комментарий" +} +``` + +**Промпт-шаблон (Query Analyzer — Self-Query):** + +``` +Из запроса пользователя извлеки структурированные параметры. +Запрос: {user_query} +Верни JSON: {"genre": "...", "mood": "...", "max_duration": null, "min_year": null, "min_rating": null} +Если параметр не указан — верни null. +``` + +**Ограничения:** `max_tokens=800`, `temperature=0.3` + +--- + +### 4.2 Контроль качества ответов + +- **Hallucination guard:** similarity < 0.4 → LLM не вызывается → возвращается: *«По вашему запросу подходящих фильмов не найдено. Попробуйте переформулировать»* +- **Output validation:** ответ парсится как JSON; при ошибке — 2 retry, затем fallback +- **LLM-judge:** отдельный промпт оценивает релевантность рекомендации по шкале 1–5, результат пишется в лог +- **Офтопик-фильтр:** system prompt явно ограничивает тематику; при offtopic — стандартный ответ + +--- + +### 4.3 Обучение/дообучение + +- Fine-tuning не предусмотрен — используем prompt engineering + RAG +- **Версионирование промптов:** `prompts.yaml` с семантической версией (`v1.0`, `v1.1`) +- **Rollback:** при деградации метрик — откат через git к предыдущей версии промпта + +--- + +## 5. UX / пользовательский опыт + +### 5.1 Сценарии взаимодействия + +**Основные:** + +| Сценарий | Пример | Ожидаемое поведение | +|---|---|---| +| Смысловой запрос | "Хочу что-то как Интерстеллар" | топ-5 + объяснение схожести | +| Запрос с ограничениями | "Страшный фильм до 100 минут" | фильтр по duration + genre, топ-5 | +| Запрос по настроению | "Что-то душевное из 90-х" | семантический поиск по тегам + год | +| Уточняющий вопрос | "А что-то менее мрачное?" | multi-turn, учёт предыдущего ответа | + +**Исключительные:** + +| Сценарий | Поведение | +|---|---| +| Нет подходящих (similarity < 0.4) | *«Не нашли подходящего. Попробуйте переформулировать»* | +| Офтопик запрос | *«Я рекомендую только фильмы»* | +| Ошибка LLM API | *«Сервис временно недоступен, попробуйте через минуту»* | +| Слишком короткий запрос | Уточняющий вопрос: *«Уточните жанр или настроение?»* | + +--- + +### 5.2 Диалоговая логика + +- **Режим:** multi-turn в рамках сессии — контекст последних 2 обменов передаётся в промпт +- **История:** хранится в памяти сессии (dict по chat_id в Telegram), не персистируется между сессиями +- **Тон:** дружелюбный, неформальный, без эмодзи в основном тексте. Ответы структурированы: нумерованный список + короткое объяснение каждого фильма +- **Язык:** русский интерфейс, поиск по английским текстам (TMDB) + +--- + +### 5.3 Метрики UX + +- **Время первого токена:** ≤ 3 сек (показываем индикатор «печатает...» в Telegram) +- **Полное время ответа:** ≤ 10 сек +- **Фидбэк:** кнопки like/dislike после каждого ответа +- **Логирование:** `timestamp`, `user_query`, `retrieved_ids`, `llm_response`, `latency_ms`, `feedback` + +--- + +## 6. Безопасность, соответствие и этика + +**Обработка персональных данных:** +- Персональные данные пользователей не собираются и не хранятся +- История диалога хранится только в рамках текущей сессии и не сохраняется между сессиями +- Датасеты TMDB и MovieLens публичные, PII отсутствует + +**Устранение вредоносного/нежелательного контента:** +- Бот отвечает строго в рамках тематики фильмов; на офтопик возвращает: *«Я рекомендую только фильмы»* +- Фильмы с рейтингом 18+ явно помечаются в ответе +- LLM работает только на основе данных из базы, system prompt запрещает выдумывать фильмы не из базы + +**Прозрачность:** +- При первом взаимодействии бот явно представляется как AI-ассистент +- Пользователь уведомляется, что запросы передаются в OpenAI / Gemini API +- Бот не претендует на полноту базы фильмов + +**Регулирование и нормативы:** +- Датасеты TMDB и MovieLens используются в соответствии с их публичными лицензиями +- OpenAI / Gemini API используются согласно условиям использования провайдеров +- Полное соответствие GDPR не требуется в рамках учебного проекта, так как реальные пользовательские данные не обрабатываются + +--- + +## 7. План внедрения и эксплуатации + +### 7.1 Этапы проекта + +- **ДЗ 1:** выбор темы, составление дизайн-документа, поиск датасетов, настройка репозитория +- **ДЗ 2:** загрузка и очистка данных, построение векторной базы, проверка, что поиск работает +- **ДЗ 3:** подключение LLM, написание RAG-пайплайна, тестирование на реальных запросах +- **ДЗ 4:** добавление интерфейса (Streamlit или Telegram), финальная проверка качества, подготовка к защите + +### 7.2 Поддержка и эксплуатация + +- **Ответственные:** команда проекта +- **Мониторинг:** логируем каждый запрос (что спросили, что ответили, как быстро); собираем фидбэк через кнопки like/dislike +- **Обновление базы:** не предусмотрено, используем статический датасет с Kaggle +- **Метрики:** скорость ответа (цель <= 10 сек), стоимость одного запроса к LLM API + +--- + +## 8. Риски и допущения + +| Риск | Вероятность | Влияние | Mitigation | +|---|---|---|---| +| Низкое качество retrieval на нишевых запросах | Средняя | Высокое | Hybrid search: BM25 + vector; обогащение тегами MovieLens; Fallback на жанровый поиск при similarity < 0.3 | +| Галлюцинации (выдуманные фильмы) | Средняя | Критическое | Similarity threshold + явный запрет в system prompt + output validation; LLM-judge для post-валидации ответов | +| Rate limits / стоимость OpenAI API | Низкая | Среднее | Gemini Flash как fallback; кэширование частых запросов | +| Падение внешних API (OpenAI/Gemini недоступен) | Низкая | Высокое | Health check + retry с exponential backoff; Заглушка | +| Плохой парсинг свободного запроса | Средняя | Среднее | Fallback на чистый vector search если Query Analyzer вернул null | + +**Непроверенные допущения:** + +- Качество sentence-transformers достаточно: предполагаем, что all-MiniLM-L6-v2 справится с семантическим поиском без fine-tuning на кино-домене +- 10 секунд — приемлемое время ответа: пользователи готовы ждать качественную рекомендацию +- Английские эмбеддинги + русские запросы: мультиязычность модели покроет семантический разрыв +- MovieLens теги релевантны: пользовательские теги с MovieLens точно описывают настроение фильмов +- 5000 фильмов — достаточный выбор: база покрывает 80%+ популярных запросов + +--- + +## 9. Бюджет и ресурсы + +**Команда:** 4 человека (Пахолкова Мария, Смешкова Екатерина, Цисарук Мария, Миннеахметова Рената) + +| Роль | Задачи | +|---|---| +| ML-инженер | Эмбеддинги, ChromaDB, retrieval pipeline, reranker | +| NLP / Prompt Engineer | Query Analyzer, промпты, LLM-judge, метрики | +| Backend Developer | Telegram-бот или Streamlit UI, логирование, error handling | +| DevOps | Docker setup | + +**Технологии и стоимость:** + +| Ресурс | Стоимость | +|---|---| +| OpenAI API (GPT-4o-mini) | ~$10–15 на проект | +| ChromaDB | Бесплатно (open-source) | +| sentence-transformers | Бесплатно | +| Hugging Face Spaces / Railway | Бесплатный тир | +| Вычисления | CPU, Google Colab Free или локально | + +--- + +## 10. Приложения + +### 10.1 Словарь терминов + +| Термин | Определение | +|---|---| +| RAG | Retrieval-Augmented Generation — поиск релевантных документов + генерация ответа LLM | +| Embedding | Векторное представление текста для семантического поиска | +| Reranker | Cross-encoder модель для переранжирования результатов retrieval | +| Self-Query Retriever | Агентный компонент, извлекающий структурированные фильтры из свободного запроса | +| LLM-judge | Использование LLM для автоматической оценки качества ответов | +| Recall@K | Доля релевантных фильмов среди топ-K результатов поиска | +| Hallucination | Генерация LLM информации, не основанной на переданном контексте | +| Similarity threshold | Минимальный порог схожести для передачи результатов в LLM | + +--- + +### 10.2 Ссылки на данные + +- TMDB 5000: https://www.kaggle.com/datasets/tmdb/tmdb-movie-metadata +- MovieLens ml-32m: https://www.kaggle.com/datasets/garymk/movielens-32m-dataset +- Модель эмбеддингов: https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2 +- Reranker: https://huggingface.co/cross-encoder/ms-marco-MiniLM-L-6-v2 + +--- + +### 10.3 Структура репозитория + +``` +cinematch/ +├── data/ +│ ├── raw/ # исходные CSV (не в git) +│ └── processed/ # movies.jsonl после предобработки +├── scripts/ +│ ├── ingest.py # загрузка и предобработка +│ ├── build_index.py # построение ChromaDB индекса +│ └── evaluate.py # расчёт метрик +├── src/ +│ ├── query_analyzer.py # Self-Query: извлечение параметров +│ ├── retrieval.py # hybrid search + reranker +│ ├── rag.py # RAG pipeline +│ ├── hallucination.py # similarity guard +│ └── app.py # Telegram / Streamlit UI +├── prompts.yaml # промпт-шаблоны (версионируются) +├── config.yaml # параметры (модели, пороги) +├── requirements.txt +├── .env.example # шаблон переменных окружения +└── README.md +``` + +--- + +### 10.4 Пример structured output + +```json +{ + "recommendations": [ + { + "title": "Arrival", + "year": 2016, + "rating": 7.9, + "duration_min": 116, + "reason": "Как и Интерстеллар — научная фантастика с эмоциональной глубиной и нелинейным повествованием" + }, + { + "title": "Gravity", + "year": 2013, + "rating": 7.7, + "duration_min": 91, + "reason": "Напряжённый космический триллер с визуальным масштабом, сравнимым с Интерстелларом" + } + ], + "explanation": "Подобрал фильмы, сочетающие научную фантастику с эмоциональным повествованием — ключевые черты Интерстеллара" +} +``` + +--- + +*Design-Doc v1.0 | CineMatch | Курс Modern NLP & LLM | 2025* diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..be7af41 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + app: + build: . + ports: + - "8501:8501" + env_file: + - .env + volumes: + - chroma_data:/app/chroma_db + - ./data:/app/data + restart: unless-stopped + +volumes: + chroma_data: diff --git a/prompts.yaml b/prompts.yaml new file mode 100644 index 0000000..47a664d --- /dev/null +++ b/prompts.yaml @@ -0,0 +1,60 @@ +query_analyzer: + version: "1.0" + system: | + You are a movie recommendation query analyzer. Your task is to parse user queries (which may be in Russian or English) into a structured JSON format for a movie recommendation system. + + IMPORTANT: The "semantic_query" field MUST be in English, as the movie database uses English embeddings. + + If the query is NOT about movies or movie recommendations (e.g., weather, math, general questions), respond with: + {"off_topic": true} + + Otherwise, respond with a JSON object: + { + "genre": "genre name or null", + "mood": "mood description or null", + "max_duration": integer or null (in minutes), + "min_year": integer or null, + "min_rating": float or null, + "semantic_query": "English search query for vector search" + } + + Only output valid JSON, no explanations. + + user_template: | + User query: {user_query} + Conversation context (last messages): {history} + +generation: + version: "1.0" + system: | + You are CineMatch, a friendly movie recommendation assistant. You recommend movies based on the provided search results. + + RULES: + 1. ONLY recommend movies from the provided list. NEVER invent or hallucinate movies. + 2. Respond in the same language as the user's query. + 3. For each movie, explain briefly WHY it matches the user's request. + 4. If the results don't match well, say so honestly. + 5. Keep responses concise and conversational. + + Respond with a JSON object: + { + "movies": [ + { + "title": "Movie Title", + "year": 2020, + "rating": 7.5, + "duration_min": 120, + "reason": "Brief explanation why this matches" + } + ], + "message": "Conversational intro/outro message in user's language" + } + + user_template: | + User query: {user_query} + + Retrieved movies: + {movies_json} + + Conversation history: + {history} diff --git a/reports/hw2_report.md b/reports/hw2_report.md new file mode 100644 index 0000000..ffd31c3 --- /dev/null +++ b/reports/hw2_report.md @@ -0,0 +1,70 @@ +# Отчёт по ДЗ2: MVP проекта CineMatch + +## Что реализовано + +В рамках второго задания реализован полный MVP рекомендательной системы фильмов CineMatch. + +### Компоненты системы + +| Компонент | Файл | Описание | +|-----------|------|----------| +| Query Analyzer | `src/query_analyzer.py` | Парсит свободный текст в структурированные параметры через LLM | +| Retriever | `src/retrieval.py` | Векторный поиск в ChromaDB + реранкинг кросс-энкодером | +| Hallucination Guard | `src/hallucination.py` | Проверяет качество выдачи (similarity ≥ 0.4) | +| RAG Orchestrator | `src/rag.py` | Оркестрирует весь пайплайн, логирует в SQLite | +| LLM Utils | `src/llm_utils.py` | Retry + fallback-цепочка моделей | +| Streamlit UI | `src/app.py` | Веб-интерфейс с чатом и кнопками обратной связи | +| Ingest | `scripts/ingest.py` | Загрузка и предобработка TMDB 5000 | +| Build Index | `scripts/build_index.py` | Построение ChromaDB-индекса | +| Evaluate | `scripts/evaluate.py` | Автоматическая оценка качества (Recall@5, LLM Judge) | + +## Принятые решения и их обоснование + +### Выбор технического стека + +**ChromaDB** выбрана как векторная БД из-за простоты интеграции — не требует отдельного сервера, работает локально, персистентна. Альтернативы (Qdrant, Weaviate, Pinecone) избыточны для прототипа. + +**sentence-transformers (`all-MiniLM-L6-v2`)** — бесплатная модель с хорошим балансом качества и скорости. 384-мерные векторы быстро индексируются, хорошо работают на английских текстах (синопсисы TMDB на английском). + +**Cross-encoder (`ms-marco-MiniLM-L-6-v2`)** для реранкинга — значительно улучшает точность топ-5 по сравнению с чистым bi-encoder поиском. Overhead небольшой: реранкинг только 20 кандидатов. + +**OpenRouter (free tier)** позволяет использовать несколько моделей с автоматическим fallback: если основная модель (nvidia/nemotron) недоступна или возвращает rate limit, система переключается на deepseek → gemma → llama. Это критично для стабильности без затрат. + +**Streamlit** выбран для UI как самый быстрый способ создать рабочий веб-интерфейс без frontend-разработки. Для MVP это оптимально. + +### Архитектурные решения + +**Hybrid Retrieval** (векторный поиск + фильтры по метаданным): чистый семантический поиск игнорировал явные ограничения типа «до 100 минут» или «жанр Horror». Добавление фильтров по метаданным ChromaDB решило эту проблему без потери семантики. + +**Hallucination Guard**: без проверки порога сходства LLM генерировала объяснения для нерелевантных фильмов, создавая иллюзию полезного ответа. Порог 0.4 на косинусное сходство отсекает явно нерелевантные результаты. + +**Fallback при пустом поиске с фильтром**: если фильтр метаданных даёт менее 5 результатов, система повторяет поиск без фильтра. Это позволяет находить фильмы даже при очень специфичных запросах. + +## Сложности и решения + +### Сложность 1: Нестабильность бесплатных LLM +**Проблема:** Бесплатные модели на OpenRouter часто возвращают 429 (rate limit) или пустые ответы. +**Решение:** Реализована функция `llm_call_with_retry()` с exponential backoff (2с → 4с → 8с) и цепочкой из 4 fallback-моделей. + +### Сложность 2: LLM возвращает невалидный JSON +**Проблема:** Модели часто оборачивают JSON в markdown code fences (` ```json ... ``` `). +**Решение:** Функция `_clean_json_response()` удаляет markdown-обёртку и извлекает JSON через регулярное выражение. + +### Сложность 3: Фильтр жанров в ChromaDB +**Проблема:** ChromaDB не поддерживает `$contains` для обычных строк с запятыми. +**Решение:** Создано поле `genres_pipe` в формате `|Action|Adventure|` — `$contains` по `|Genre|` работает корректно. + +## Оценка использования LLM и ассистентов + +- **Инструмент:** Claude Code (Anthropic) +- **Использование:** помощь в написании кода, отладка, генерация тестовых запросов +- **Оценка времени:** ~40% времени сэкономлено на написании boilerplate-кода и отладке edge-cases +- **Стоимость:** подписка Claude Pro (~$20/мес), данный проект — примерно 1/4 месячного использования + +## Результаты + +Система запускается командой `streamlit run src/app.py` и корректно обрабатывает: +- Жанровые запросы с ограничениями («страшный фильм до 100 минут») +- Семантические запросы («что-то как Интерстеллар») +- Off-topic запросы (честный отказ) +- Запросы без подходящих результатов (hallucination guard) diff --git a/reports/hw3_report.md b/reports/hw3_report.md new file mode 100644 index 0000000..68c11a2 --- /dev/null +++ b/reports/hw3_report.md @@ -0,0 +1,58 @@ +# Отчёт по ДЗ3: Инфраструктура проекта CineMatch + +## Что реализовано + +В рамках третьего задания добавлена инфраструктурная обвязка для деплоя и поддержки проекта. + +### Добавленные компоненты + +| Файл | Назначение | +|------|-----------| +| `Dockerfile` | Контейнеризация приложения | +| `docker-compose.yml` | Оркестрация сервисов + персистентные тома | +| `.github/workflows/ci.yml` | CI-пайплайн (lint, smoke test, docker build) | +| `reports/hw3_report.md` | Отчёт по этапу | + +## Принятые решения и их обоснование + +### Docker + +**Базовый образ `python:3.11-slim`** — минимальный образ без лишних зависимостей. Уменьшает размер итогового контейнера. + +**HEALTHCHECK** проверяет эндпоинт Streamlit `/_stcore/health`. Позволяет docker-compose и оркестраторам (Kubernetes, ECS) знать, когда приложение готово принимать трафик. + +**EXPOSE 8501** — стандартный порт Streamlit. Документирует намерение, даже если не пробрасывается автоматически. + +### docker-compose + +**Volume `chroma_data`** для ChromaDB — данные векторного индекса сохраняются между перезапусками контейнера. Без этого индекс пришлось бы пересоздавать при каждом рестарте. + +**Volume `./data`** монтирует локальную папку `data/` — удобно для разработки: можно обновлять `movies.jsonl` без пересборки образа. + +**`env_file: .env`** — API-ключ передаётся через файл окружения, не встраивается в образ. + +### GitHub Actions CI + +**3 джоба:** +- `lint` (ruff) — проверяет стиль кода без запуска тестов. Быстро, не требует API-ключей. +- `test` — smoke test: импортирует модули, убеждается что код синтаксически корректен и зависимости установлены. +- `docker` — собирает Docker-образ, проверяет что Dockerfile валиден. + +**Почему не полные интеграционные тесты в CI:** для запуска `rag.query()` нужен `OPENROUTER_API_KEY` и ChromaDB-индекс (~5000 фильмов). Секреты в CI — отдельная тема, для MVP достаточно smoke тестов. + +## Сложности и решения + +### Сложность 1: ChromaDB в Docker +**Проблема:** ChromaDB по умолчанию хранит данные в рабочей директории. При перезапуске контейнера индекс терялся. +**Решение:** Docker volume `chroma_data` монтируется в `/app/chroma_db` — путь из `config.yaml`. + +### Сложность 2: Размер Docker-образа +**Проблема:** sentence-transformers + torch = образ ~2 ГБ. +**Решение:** Используем `python:3.11-slim` и устанавливаем только `requirements.txt` без dev-зависимостей. Дальнейшая оптимизация возможна через multi-stage build с `torch` CPU-only. + +## Оценка использования LLM и ассистентов + +- **Инструмент:** Claude Code (Anthropic) +- **Использование:** генерация Dockerfile, docker-compose, CI конфигурации, отчёта +- **Оценка времени:** ~60% времени на инфраструктуру сэкономлено — шаблонный код (Dockerfile, GitHub Actions) генерируется мгновенно +- **Стоимость:** подписка Claude Pro (~$20/мес), данный этап — ~10% месячного использования diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..bfb7d6c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +streamlit>=1.30.0 +chromadb>=0.4.22 +sentence-transformers>=2.3.0 +openai>=1.0.0 +kagglehub>=0.2.0 +pandas>=2.1.0 +pyyaml>=6.0 +python-dotenv>=1.0.0 +scikit-learn>=1.3.0 diff --git a/scripts/build_index.py b/scripts/build_index.py new file mode 100644 index 0000000..0a96edb --- /dev/null +++ b/scripts/build_index.py @@ -0,0 +1,101 @@ +"""Build ChromaDB vector index from processed movies.jsonl.""" + +import json +import sys +from pathlib import Path + +import chromadb +from sentence_transformers import SentenceTransformer + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +import yaml + +with open(PROJECT_ROOT / "config.yaml") as f: + config = yaml.safe_load(f) + +MOVIES_FILE = PROJECT_ROOT / config["data"]["movies_file"] +CHROMA_PATH = PROJECT_ROOT / config["chroma_db_path"] +COLLECTION_NAME = config["chroma_collection"] +EMBEDDING_MODEL = config["embedding_model"] + + +def load_movies(path: Path) -> list[dict]: + movies = [] + with open(path, encoding="utf-8") as f: + for line in f: + movies.append(json.loads(line)) + return movies + + +def build_index(): + print(f"Loading movies from {MOVIES_FILE}...") + movies = load_movies(MOVIES_FILE) + print(f"Loaded {len(movies)} movies") + + print(f"Loading embedding model: {EMBEDDING_MODEL}...") + model = SentenceTransformer(EMBEDDING_MODEL) + + texts = [m["text_for_embedding"] for m in movies] + print(f"Generating embeddings for {len(texts)} documents (batch_size=64)...") + embeddings = model.encode(texts, batch_size=64, show_progress_bar=True) + print(f"Embeddings shape: {embeddings.shape}") + + print(f"Creating ChromaDB at {CHROMA_PATH}...") + client = chromadb.PersistentClient(path=str(CHROMA_PATH)) + + try: + client.delete_collection(COLLECTION_NAME) + print(f"Deleted existing collection '{COLLECTION_NAME}'") + except Exception: + pass + + collection = client.create_collection( + name=COLLECTION_NAME, + metadata={"hnsw:space": "cosine"}, + ) + + batch_size = 500 + for i in range(0, len(movies), batch_size): + batch = movies[i : i + batch_size] + batch_embeddings = embeddings[i : i + batch_size].tolist() + + ids = [m["id"] for m in batch] + documents = [m["text_for_embedding"] for m in batch] + metadatas = [ + { + "title": m["title"], + "year": int(m["year"]), + "duration_min": int(m["duration_min"]), + "rating": float(m["rating"]), + "genres": m["genres_pipe"], + "tags": m["tags_pipe"], + "overview": m["overview"], + } + for m in batch + ] + + collection.add( + ids=ids, + embeddings=batch_embeddings, + documents=documents, + metadatas=metadatas, + ) + print(f" Added batch {i // batch_size + 1}: {len(batch)} documents") + + print(f"Total documents in collection: {collection.count()}") + + print("\nTest query: 'space exploration emotional drama'") + results = collection.query( + query_embeddings=model.encode(["space exploration emotional drama"]).tolist(), + n_results=5, + ) + for i, (doc_id, metadata) in enumerate(zip(results["ids"][0], results["metadatas"][0])): + print(f" {i + 1}. {metadata['title']} ({metadata['year']}) - rating: {metadata['rating']}") + + print("\nIndex built successfully!") + + +if __name__ == "__main__": + build_index() diff --git a/scripts/evaluate.py b/scripts/evaluate.py new file mode 100644 index 0000000..748fd2c --- /dev/null +++ b/scripts/evaluate.py @@ -0,0 +1,185 @@ +"""Evaluation script: measure RAG pipeline quality metrics.""" + +import json +import os +import sys +import time +from pathlib import Path + +from openai import OpenAI +from dotenv import load_dotenv +import yaml + +from src.llm_utils import llm_call_with_retry + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +load_dotenv(PROJECT_ROOT / ".env") + +with open(PROJECT_ROOT / "config.yaml") as f: + config = yaml.safe_load(f) + +from src.rag import CineMatchRAG + +TEST_QUERIES_FILE = PROJECT_ROOT / "data" / "test_queries.jsonl" + + +def load_test_queries(path: Path) -> list[dict]: + queries = [] + with open(path, encoding="utf-8") as f: + for line in f: + if line.strip(): + queries.append(json.loads(line)) + return queries + + +def evaluate_genre_recall(result: dict, expected_genres: list[str]) -> float: + """Check if recommended movies match expected genres.""" + if not result.get("movies") or not expected_genres: + return 0.0 + hits = 0 + for movie in result["movies"]: + title = movie.get("title", "").lower() + if any(g.lower() in str(result).lower() for g in expected_genres): + hits += 1 + break + return 1.0 if hits > 0 else 0.0 + + +def evaluate_with_llm_judge(query: str, result: dict, client: OpenAI) -> float: + """Use LLM judge to score recommendation quality 1-5.""" + movies_str = json.dumps(result.get("movies", []), ensure_ascii=False, indent=2) + prompt = f"""Rate the quality of these movie recommendations on a scale of 1-5. + +User query: {query} +Recommendations: {movies_str} + +Criteria: +- Relevance to the query (genre, mood, theme) +- Diversity of recommendations +- Quality of explanations + +Respond with ONLY a single number 1-5.""" + + raw = llm_call_with_retry( + client, + config["llm_model"], + [{"role": "user", "content": prompt}], + fallback_models=config.get("fallback_models", []), + max_retries=config["generation"]["max_retries"], + backoff_base=config["generation"].get("backoff_base_seconds", 2), + ) + if raw is None: + print(" LLM judge: all models failed, defaulting to 3.0") + return 3.0 + + try: + score = float(raw.strip().split()[0]) + return min(max(score, 1.0), 5.0) + except (ValueError, IndexError) as e: + print(f" LLM judge parse error: {e}") + return 3.0 + + +def main(): + api_key = os.getenv("OPENROUTER_API_KEY") + if not api_key: + print("ERROR: OPENROUTER_API_KEY not set in .env") + sys.exit(1) + + if not TEST_QUERIES_FILE.exists(): + print(f"ERROR: {TEST_QUERIES_FILE} not found") + sys.exit(1) + + print("Initializing RAG pipeline...") + rag = CineMatchRAG(api_key) + + judge_client = OpenAI( + base_url=config["openrouter_base_url"], + api_key=api_key, + ) + + test_queries = load_test_queries(TEST_QUERIES_FILE) + print(f"Loaded {len(test_queries)} test queries\n") + + metrics = { + "recall_scores": [], + "latencies": [], + "llm_judge_scores": [], + "hallucination_count": 0, + "total": len(test_queries), + } + + for i, tq in enumerate(test_queries, 1): + query = tq["query"] + expected_genres = tq.get("expected_genres", []) + expected_type = tq.get("expected_type", "recommendation") + + print(f"[{i}/{len(test_queries)}] {query}") + + start = time.time() + try: + result = rag.query(query) + except Exception as e: + print(f" ERROR: {e}") + print() + time.sleep(1) + continue + latency = (time.time() - start) * 1000 + metrics["latencies"].append(latency) + + if result["type"] != expected_type: + if expected_type == "recommendation" and result["type"] == "no_results": + metrics["hallucination_count"] += 1 + print(f" MISS: expected recommendations, got no_results") + + if expected_genres and result["type"] == "recommendation": + recall = evaluate_genre_recall(result, expected_genres) + metrics["recall_scores"].append(recall) + print(f" Genre recall: {recall:.2f}") + + if result["type"] == "recommendation": + judge_score = evaluate_with_llm_judge(query, result, judge_client) + metrics["llm_judge_scores"].append(judge_score) + print(f" LLM judge: {judge_score:.1f}/5") + + print(f" Latency: {latency:.0f}ms | Type: {result['type']}") + print() + time.sleep(1) + + print("=" * 60) + print("EVALUATION RESULTS") + print("=" * 60) + + avg_recall = ( + sum(metrics["recall_scores"]) / len(metrics["recall_scores"]) + if metrics["recall_scores"] else 0 + ) + avg_latency = ( + sum(metrics["latencies"]) / len(metrics["latencies"]) + if metrics["latencies"] else 0 + ) + avg_judge = ( + sum(metrics["llm_judge_scores"]) / len(metrics["llm_judge_scores"]) + if metrics["llm_judge_scores"] else 0 + ) + hallucination_rate = metrics["hallucination_count"] / metrics["total"] * 100 + + print(f"Recall@5 (genre): {avg_recall:.2f} (target: >= 0.75)") + print(f"Avg Latency: {avg_latency:.0f}ms (target: <= 10000ms)") + print(f"LLM Judge: {avg_judge:.1f}/5 (target: >= 4.0)") + print(f"Hallucination rate: {hallucination_rate:.1f}% (target: < 5%)") + print() + + passed = all([ + avg_recall >= 0.75, + avg_latency <= 10000, + avg_judge >= 4.0, + hallucination_rate < 5, + ]) + print(f"Overall: {'PASS ✓' if passed else 'FAIL ✗'}") + + +if __name__ == "__main__": + main() diff --git a/scripts/ingest.py b/scripts/ingest.py new file mode 100644 index 0000000..6c0ead9 --- /dev/null +++ b/scripts/ingest.py @@ -0,0 +1,119 @@ +"""Download TMDB 5000 dataset and process into movies.jsonl.""" + +import json +import sys +from pathlib import Path + +import kagglehub +import pandas as pd + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +RAW_DIR = PROJECT_ROOT / "data" / "raw" +PROCESSED_DIR = PROJECT_ROOT / "data" / "processed" +OUTPUT_FILE = PROCESSED_DIR / "movies.jsonl" + + +def download_dataset() -> Path: + """Download TMDB 5000 dataset via kagglehub.""" + print("Downloading TMDB 5000 dataset...") + path = kagglehub.dataset_download("tmdb/tmdb-movie-metadata") + print(f"Dataset downloaded to: {path}") + return Path(path) + + +def parse_json_column(value: str) -> list[str]: + """Parse JSON string column into list of name strings.""" + if pd.isna(value): + return [] + try: + items = json.loads(value) + return [item["name"] for item in items if "name" in item] + except (json.JSONDecodeError, TypeError): + return [] + + +def process_movies(dataset_path: Path) -> pd.DataFrame: + """Load and process TMDB movies CSV.""" + csv_path = dataset_path / "tmdb_5000_movies.csv" + if not csv_path.exists(): + candidates = list(dataset_path.rglob("tmdb_5000_movies.csv")) + if not candidates: + raise FileNotFoundError(f"tmdb_5000_movies.csv not found in {dataset_path}") + csv_path = candidates[0] + + print(f"Loading {csv_path}...") + df = pd.read_csv(csv_path) + print(f"Loaded {len(df)} movies") + + df["genres_list"] = df["genres"].apply(parse_json_column) + df["keywords_list"] = df["keywords"].apply(parse_json_column) + + df["year"] = pd.to_datetime(df["release_date"], errors="coerce").dt.year + df["year"] = df["year"].fillna(0).astype(int) + + df["duration_min"] = df["runtime"] + df["rating"] = df["vote_average"] + + before = len(df) + df = df.dropna(subset=["overview", "runtime"]) + df = df[df["overview"].str.strip().astype(bool)] + df = df[df["runtime"] > 0] + print(f"Filtered: {before} → {len(df)} movies (removed {before - len(df)} without overview/runtime)") + + df["genres_str"] = df["genres_list"].apply(lambda g: ", ".join(g)) + df["keywords_str"] = df["keywords_list"].apply(lambda k: ", ".join(k[:15])) + + df["text_for_embedding"] = df.apply( + lambda r: ( + f"{r['title']} ({int(r['year'])}). " + f"Genres: {r['genres_str']}. " + f"{r['overview']}. " + f"Tags: {r['keywords_str']}" + ), + axis=1, + ) + + df["genres_pipe"] = df["genres_list"].apply(lambda g: "|" + "|".join(g) + "|" if g else "") + df["tags_pipe"] = df["keywords_list"].apply(lambda k: "|" + "|".join(k[:15]) + "|" if k else "") + + return df + + +def save_jsonl(df: pd.DataFrame, output_path: Path): + """Save processed movies to JSONL.""" + output_path.parent.mkdir(parents=True, exist_ok=True) + records = [] + for _, row in df.iterrows(): + record = { + "id": str(int(row["id"])), + "title": row["title"], + "year": int(row["year"]), + "duration_min": int(row["duration_min"]), + "rating": round(float(row["rating"]), 1), + "genres": row["genres_str"], + "genres_pipe": row["genres_pipe"], + "keywords": row["keywords_str"], + "tags_pipe": row["tags_pipe"], + "overview": row["overview"], + "text_for_embedding": row["text_for_embedding"], + } + records.append(record) + + with open(output_path, "w", encoding="utf-8") as f: + for record in records: + f.write(json.dumps(record, ensure_ascii=False) + "\n") + + print(f"Saved {len(records)} movies to {output_path}") + + +def main(): + dataset_path = download_dataset() + df = process_movies(dataset_path) + save_jsonl(df, OUTPUT_FILE) + print("Done!") + + +if __name__ == "__main__": + main() diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app.py b/src/app.py new file mode 100644 index 0000000..32363b3 --- /dev/null +++ b/src/app.py @@ -0,0 +1,117 @@ +"""Streamlit UI for CineMatch movie recommendation system.""" + +import os +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +import streamlit as st +from dotenv import load_dotenv + +load_dotenv(PROJECT_ROOT / ".env") + +from src.rag import CineMatchRAG + +st.set_page_config(page_title="CineMatch", page_icon="🎬", layout="centered") + +st.title("🎬 CineMatch") +st.caption("Рекомендательная система фильмов на основе RAG") + + +@st.cache_resource +def get_rag(): + """Initialize RAG pipeline (cached).""" + api_key = os.getenv("OPENROUTER_API_KEY") + if not api_key: + st.error("OPENROUTER_API_KEY не найден. Создайте файл .env с ключом.") + st.stop() + return CineMatchRAG(api_key) + + +rag = get_rag() + +if "messages" not in st.session_state: + st.session_state.messages = [] +if "request_ids" not in st.session_state: + st.session_state.request_ids = {} + +for i, msg in enumerate(st.session_state.messages): + with st.chat_message(msg["role"]): + st.markdown(msg["content"]) + + if msg["role"] == "assistant" and i in st.session_state.request_ids: + req_id = st.session_state.request_ids[i] + col1, col2, _ = st.columns([1, 1, 8]) + with col1: + if st.button("👍", key=f"like_{i}"): + rag.save_feedback(req_id, "like") + st.toast("Спасибо за отзыв!") + with col2: + if st.button("👎", key=f"dislike_{i}"): + rag.save_feedback(req_id, "dislike") + st.toast("Спасибо за отзыв!") + +if user_input := st.chat_input("Опишите, какой фильм вы хотите посмотреть..."): + st.session_state.messages.append({"role": "user", "content": user_input}) + with st.chat_message("user"): + st.markdown(user_input) + + with st.chat_message("assistant"): + with st.spinner("Ищу фильмы..."): + history = [ + {"role": m["role"], "content": m["content"]} + for m in st.session_state.messages[:-1] + ] + try: + result = rag.query(user_input, history) + except Exception as e: + error_msg = "Произошла ошибка при обработке запроса. Попробуйте ещё раз через несколько секунд." + print(f"RAG query error: {e}") + st.error(error_msg) + st.session_state.messages.append({"role": "assistant", "content": error_msg}) + st.stop() + + if result["type"] == "off_topic": + response_text = result["message"] + elif result["type"] == "no_results": + response_text = result["message"] + else: + parts = [] + if result.get("message"): + parts.append(result["message"]) + parts.append("") + + for j, movie in enumerate(result.get("movies", []), 1): + title = movie.get("title", "Unknown") + year = movie.get("year", "") + rating = movie.get("rating", "") + duration = movie.get("duration_min", "") + reason = movie.get("reason", "") + + parts.append( + f"**{j}. {title}** ({year})\n" + f" ⭐ {rating} | ⏱ {duration} мин\n" + f" _{reason}_" + ) + parts.append("") + + response_text = "\n".join(parts) + + st.markdown(response_text) + + msg_idx = len(st.session_state.messages) + st.session_state.messages.append({"role": "assistant", "content": response_text}) + if result.get("request_id"): + st.session_state.request_ids[msg_idx] = result["request_id"] + + col1, col2, _ = st.columns([1, 1, 8]) + with col1: + if st.button("👍", key=f"like_{msg_idx}"): + rag.save_feedback(result["request_id"], "like") + st.toast("Спасибо за отзыв!") + with col2: + if st.button("👎", key=f"dislike_{msg_idx}"): + rag.save_feedback(result["request_id"], "dislike") + st.toast("Спасибо за отзыв!") diff --git a/src/hallucination.py b/src/hallucination.py new file mode 100644 index 0000000..1b19918 --- /dev/null +++ b/src/hallucination.py @@ -0,0 +1,32 @@ +"""Hallucination guard: check retrieval quality before calling LLM.""" + +from pathlib import Path + +import yaml + +PROJECT_ROOT = Path(__file__).resolve().parent.parent + +with open(PROJECT_ROOT / "config.yaml") as f: + config = yaml.safe_load(f) + + +SIMILARITY_THRESHOLD = config["retrieval"]["similarity_threshold"] + + +def check_retrieval_quality(candidates: list[dict]) -> tuple[bool, str]: + """Check if retrieval results are good enough to generate a response. + + Returns: + (is_ok, message): is_ok=True if quality sufficient, message for fallback. + """ + if not candidates: + return False, "По вашему запросу подходящих фильмов не найдено. Попробуйте переформулировать запрос." + + best_similarity = max(c.get("similarity", 0) for c in candidates) + if best_similarity < SIMILARITY_THRESHOLD: + return False, ( + "По вашему запросу подходящих фильмов не найдено. " + "Попробуйте описать желаемый фильм другими словами." + ) + + return True, "" diff --git a/src/llm_utils.py b/src/llm_utils.py new file mode 100644 index 0000000..51ff793 --- /dev/null +++ b/src/llm_utils.py @@ -0,0 +1,47 @@ +"""Shared LLM call helper with retry and fallback model support.""" + +import time + +import openai + + +def llm_call_with_retry( + client: openai.OpenAI, + model: str, + messages: list[dict], + fallback_models: list[str] | None = None, + max_retries: int = 2, + backoff_base: float = 2.0, +) -> str | None: + """Call LLM with exponential backoff retry and fallback models. + + Returns raw response text or None if all models/retries exhausted. + """ + models_to_try = [model] + (fallback_models or []) + + for current_model in models_to_try: + for attempt in range(max_retries + 1): + try: + response = client.chat.completions.create( + model=current_model, + messages=messages, + ) + content = response.choices[0].message.content if response.choices else None + if not content: + raise ValueError("Empty response from LLM") + return content + except (openai.RateLimitError, openai.APIConnectionError, openai.APIStatusError) as e: + print(f"LLM error ({current_model}, attempt {attempt + 1}/{max_retries + 1}): {e}") + if attempt < max_retries: + sleep_time = backoff_base ** (attempt + 1) + print(f" Retrying in {sleep_time:.0f}s...") + time.sleep(sleep_time) + else: + print(f" Exhausted retries for {current_model}, trying next model...") + break + except ValueError as e: + print(f"LLM error ({current_model}): {e}") + break + + print("All models exhausted.") + return None diff --git a/src/query_analyzer.py b/src/query_analyzer.py new file mode 100644 index 0000000..6a1491d --- /dev/null +++ b/src/query_analyzer.py @@ -0,0 +1,103 @@ +"""Query Analyzer: parse user queries into structured search parameters via OpenRouter.""" + +import json +import re +from pathlib import Path + +from openai import OpenAI +import yaml + +from src.llm_utils import llm_call_with_retry + +PROJECT_ROOT = Path(__file__).resolve().parent.parent + +with open(PROJECT_ROOT / "config.yaml") as f: + config = yaml.safe_load(f) + +with open(PROJECT_ROOT / "prompts.yaml") as f: + prompts = yaml.safe_load(f) + + +class QueryAnalyzer: + def __init__(self, api_key: str): + self.client = OpenAI( + base_url=config["openrouter_base_url"], + api_key=api_key, + ) + self.model = config["llm_model"] + self.fallback_models = config.get("fallback_models", []) + self.max_retries = config["query_analyzer"]["max_retries"] + self.backoff_base = config["query_analyzer"].get("backoff_base_seconds", 2) + self.system_prompt = prompts["query_analyzer"]["system"] + self.user_template = prompts["query_analyzer"]["user_template"] + + def _clean_json_response(self, text: str) -> str: + """Strip markdown code fences and extract JSON.""" + text = re.sub(r"```(?:json)?\s*", "", text) + text = re.sub(r"```", "", text) + text = text.strip() + match = re.search(r"\{.*\}", text, re.DOTALL) + if match: + return match.group(0) + return text + + def analyze(self, user_query: str, history: list[dict] | None = None) -> dict: + """Analyze user query and return structured search parameters.""" + history_str = "" + if history: + last_turns = history[-(config["generation"]["history_turns"] * 2):] + history_str = "\n".join( + f"{msg['role']}: {msg['content']}" for msg in last_turns + ) + + user_msg = self.user_template.format( + user_query=user_query, + history=history_str or "None", + ) + + messages = [ + {"role": "system", "content": self.system_prompt}, + {"role": "user", "content": user_msg}, + ] + + fallback = { + "genre": None, "mood": None, "max_duration": None, + "min_year": None, "min_rating": None, "semantic_query": user_query, + } + + for attempt in range(self.max_retries + 1): + raw = llm_call_with_retry( + self.client, self.model, messages, + fallback_models=self.fallback_models, + max_retries=self.max_retries, + backoff_base=self.backoff_base, + ) + if raw is None: + print("Query analyzer: all models failed, fallback to raw query") + return fallback + + try: + cleaned = self._clean_json_response(raw) + parsed = json.loads(cleaned) + + if "off_topic" in parsed: + return {"off_topic": True} + + if "semantic_query" not in parsed or not parsed["semantic_query"]: + parsed["semantic_query"] = user_query + + return { + "genre": parsed.get("genre"), + "mood": parsed.get("mood"), + "max_duration": parsed.get("max_duration"), + "min_year": parsed.get("min_year"), + "min_rating": parsed.get("min_rating"), + "semantic_query": parsed["semantic_query"], + } + + except (json.JSONDecodeError, KeyError) as e: + if attempt < self.max_retries: + print(f"Query analyzer JSON retry {attempt + 1}: {e}") + continue + print(f"Query analyzer fallback to raw query: {e}") + return fallback diff --git a/src/rag.py b/src/rag.py new file mode 100644 index 0000000..823e74f --- /dev/null +++ b/src/rag.py @@ -0,0 +1,229 @@ +"""RAG Pipeline orchestrator: ties together query analysis, retrieval, and generation.""" + +import json +import re +import sqlite3 +import time +import uuid +from pathlib import Path + +from openai import OpenAI +import yaml + +from src.llm_utils import llm_call_with_retry + +from src.hallucination import check_retrieval_quality +from src.query_analyzer import QueryAnalyzer +from src.retrieval import Retriever + +PROJECT_ROOT = Path(__file__).resolve().parent.parent + +with open(PROJECT_ROOT / "config.yaml") as f: + config = yaml.safe_load(f) + +with open(PROJECT_ROOT / "prompts.yaml") as f: + prompts = yaml.safe_load(f) + + +class CineMatchRAG: + def __init__(self, api_key: str): + self.query_analyzer = QueryAnalyzer(api_key) + self.retriever = Retriever() + + self.client = OpenAI( + base_url=config["openrouter_base_url"], + api_key=api_key, + ) + self.model = config["llm_model"] + self.fallback_models = config.get("fallback_models", []) + self.max_retries = config["generation"]["max_retries"] + self.backoff_base = config["generation"].get("backoff_base_seconds", 2) + self.history_turns = config["generation"]["history_turns"] + + self.gen_system = prompts["generation"]["system"] + self.gen_user_template = prompts["generation"]["user_template"] + + self.db_path = PROJECT_ROOT / config["logging"]["db_path"] + self._init_db() + + def _init_db(self): + """Initialize SQLite logging database.""" + self.db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(self.db_path)) + conn.execute(""" + CREATE TABLE IF NOT EXISTS logs ( + request_id TEXT PRIMARY KEY, + timestamp REAL, + user_query TEXT, + parsed_query TEXT, + retrieved_movie_ids TEXT, + llm_response TEXT, + latency_ms REAL, + feedback TEXT + ) + """) + conn.commit() + conn.close() + + def _log(self, request_id: str, user_query: str, parsed_query: dict, + movie_ids: list[str], response: str, latency_ms: float): + """Log request to SQLite.""" + try: + conn = sqlite3.connect(str(self.db_path)) + conn.execute( + "INSERT INTO logs (request_id, timestamp, user_query, parsed_query, " + "retrieved_movie_ids, llm_response, latency_ms) VALUES (?, ?, ?, ?, ?, ?, ?)", + ( + request_id, + time.time(), + user_query, + json.dumps(parsed_query, ensure_ascii=False), + json.dumps(movie_ids), + response, + latency_ms, + ), + ) + conn.commit() + conn.close() + except Exception as e: + print(f"Logging error: {e}") + + def save_feedback(self, request_id: str, feedback: str): + """Save user feedback for a request.""" + try: + conn = sqlite3.connect(str(self.db_path)) + conn.execute( + "UPDATE logs SET feedback = ? WHERE request_id = ?", + (feedback, request_id), + ) + conn.commit() + conn.close() + except Exception as e: + print(f"Feedback save error: {e}") + + def _clean_json_response(self, text: str) -> str: + """Strip markdown code fences and extract JSON.""" + text = re.sub(r"```(?:json)?\s*", "", text) + text = re.sub(r"```", "", text) + text = text.strip() + match = re.search(r"\{.*\}", text, re.DOTALL) + if match: + return match.group(0) + return text + + def _generate_response(self, user_query: str, movies: list[dict], + history: list[dict] | None) -> dict: + """Call LLM to generate final recommendation response.""" + history_str = "" + if history: + last_turns = history[-(self.history_turns * 2):] + history_str = "\n".join( + f"{msg['role']}: {msg['content']}" for msg in last_turns + ) + + movies_json = json.dumps( + [ + { + "title": m["title"], + "year": m["year"], + "rating": m["rating"], + "duration_min": m["duration_min"], + "genres": m["genres"], + "overview": m["overview"], + } + for m in movies + ], + ensure_ascii=False, + indent=2, + ) + + user_msg = self.gen_user_template.format( + user_query=user_query, + movies_json=movies_json, + history=history_str or "None", + ) + + messages = [ + {"role": "system", "content": self.gen_system}, + {"role": "user", "content": user_msg}, + ] + + fallback_result = { + "movies": [ + { + "title": m["title"], + "year": m["year"], + "rating": m["rating"], + "duration_min": m["duration_min"], + "reason": m.get("overview", "")[:100], + } + for m in movies[:5] + ], + "message": "Вот что я нашёл по вашему запросу:", + } + + for attempt in range(self.max_retries + 1): + raw = llm_call_with_retry( + self.client, self.model, messages, + fallback_models=self.fallback_models, + max_retries=self.max_retries, + backoff_base=self.backoff_base, + ) + if raw is None: + print("Generation: all models failed, using fallback") + return fallback_result + + try: + cleaned = self._clean_json_response(raw) + return json.loads(cleaned) + except json.JSONDecodeError as e: + if attempt < self.max_retries: + print(f"Generation JSON retry {attempt + 1}: {e}") + continue + print(f"Generation fallback: {e}") + return fallback_result + + def query(self, user_query: str, history: list[dict] | None = None) -> dict: + """Main entry point: process user query and return recommendations.""" + request_id = str(uuid.uuid4()) + start = time.time() + + parsed = self.query_analyzer.analyze(user_query, history) + + if parsed.get("off_topic"): + latency = (time.time() - start) * 1000 + self._log(request_id, user_query, parsed, [], "off_topic", latency) + return { + "request_id": request_id, + "type": "off_topic", + "message": "Я — CineMatch, рекомендательная система фильмов. " + "Задайте вопрос о фильмах, и я помогу подобрать что-то интересное!", + "movies": [], + } + + candidates = self.retriever.retrieve(parsed) + + is_ok, fallback_msg = check_retrieval_quality(candidates) + if not is_ok: + latency = (time.time() - start) * 1000 + self._log(request_id, user_query, parsed, [], fallback_msg, latency) + return { + "request_id": request_id, + "type": "no_results", + "message": fallback_msg, + "movies": [], + } + + gen_result = self._generate_response(user_query, candidates, history) + latency = (time.time() - start) * 1000 + + movie_ids = [c["id"] for c in candidates] + self._log(request_id, user_query, parsed, movie_ids, + json.dumps(gen_result, ensure_ascii=False), latency) + + return { + "request_id": request_id, + "type": "recommendation", + "message": gen_result.get("message", ""), + "movies": gen_result.get("movies", []), + } diff --git a/src/retrieval.py b/src/retrieval.py new file mode 100644 index 0000000..3b45565 --- /dev/null +++ b/src/retrieval.py @@ -0,0 +1,133 @@ +"""Retrieval module: vector search with ChromaDB + cross-encoder reranking.""" + +from pathlib import Path + +import chromadb +import yaml +from sentence_transformers import CrossEncoder, SentenceTransformer + +PROJECT_ROOT = Path(__file__).resolve().parent.parent + +with open(PROJECT_ROOT / "config.yaml") as f: + config = yaml.safe_load(f) + + +class Retriever: + def __init__(self): + self.embedding_model = SentenceTransformer(config["embedding_model"]) + self.reranker = CrossEncoder(config["reranker_model"]) + + chroma_path = PROJECT_ROOT / config["chroma_db_path"] + client = chromadb.PersistentClient(path=str(chroma_path)) + self.collection = client.get_collection(config["chroma_collection"]) + + self.n_results = config["retrieval"]["n_results"] + self.top_k = config["retrieval"]["top_k"] + self.min_results = config["retrieval"]["min_results_with_filter"] + + def _build_where_filter(self, parsed_query: dict) -> dict | None: + """Build ChromaDB where filter from parsed query.""" + conditions = [] + + genre = parsed_query.get("genre") + if genre: + conditions.append({"genres": {"$contains": genre}}) + + max_duration = parsed_query.get("max_duration") + if max_duration: + conditions.append({"duration_min": {"$lte": int(max_duration)}}) + + min_year = parsed_query.get("min_year") + if min_year: + conditions.append({"year": {"$gte": int(min_year)}}) + + min_rating = parsed_query.get("min_rating") + if min_rating: + conditions.append({"rating": {"$gte": float(min_rating)}}) + + if not conditions: + return None + if len(conditions) == 1: + return conditions[0] + return {"$and": conditions} + + def retrieve(self, parsed_query: dict) -> list[dict]: + """Retrieve and rerank movies based on parsed query.""" + semantic_query = parsed_query.get("semantic_query", "") + query_embedding = self.embedding_model.encode([semantic_query]).tolist() + + where_filter = self._build_where_filter(parsed_query) + + + results = self._query_chroma(query_embedding, where_filter) + + + if where_filter and len(results) < self.min_results: + print(f"Only {len(results)} results with filter, retrying without filter...") + results = self._query_chroma(query_embedding, where_filter=None) + + if not results: + return [] + + + reranked = self._rerank(semantic_query, results) + return reranked[: self.top_k] + + def _query_chroma(self, query_embedding: list, where_filter: dict | None) -> list[dict]: + """Query ChromaDB and return results.""" + kwargs = { + "query_embeddings": query_embedding, + "n_results": self.n_results, + } + if where_filter: + kwargs["where"] = where_filter + + try: + results = self.collection.query(**kwargs) + except Exception as e: + print(f"ChromaDB query error: {e}") + + if where_filter: + results = self.collection.query( + query_embeddings=query_embedding, + n_results=self.n_results, + ) + else: + return [] + + movies = [] + if not results["ids"] or not results["ids"][0]: + return movies + + for i, doc_id in enumerate(results["ids"][0]): + distance = results["distances"][0][i] if results["distances"] else 1.0 + similarity = 1.0 - distance + metadata = results["metadatas"][0][i] + movies.append({ + "id": doc_id, + "title": metadata["title"], + "year": metadata["year"], + "duration_min": metadata["duration_min"], + "rating": metadata["rating"], + "genres": metadata["genres"], + "overview": metadata["overview"], + "similarity": round(similarity, 4), + }) + return movies + + def _rerank(self, query: str, candidates: list[dict]) -> list[dict]: + """Rerank candidates using cross-encoder.""" + if not candidates: + return [] + + pairs = [ + (query, f"{c['title']} ({c['year']}). {c['overview']}") + for c in candidates + ] + scores = self.reranker.predict(pairs) + + for i, score in enumerate(scores): + candidates[i]["rerank_score"] = float(score) + + candidates.sort(key=lambda x: x["rerank_score"], reverse=True) + return candidates From cede7c90f403208caa49e8fdd2aac59062f8a423 Mon Sep 17 00:00:00 2001 From: renaisssancee Date: Thu, 26 Mar 2026 19:27:05 +0300 Subject: [PATCH 2/3] Fix ruff E402 lint errors: add noqa comments for intentional late imports --- scripts/build_index.py | 2 +- scripts/evaluate.py | 2 +- src/app.py | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/scripts/build_index.py b/scripts/build_index.py index 0a96edb..d5bd334 100644 --- a/scripts/build_index.py +++ b/scripts/build_index.py @@ -10,7 +10,7 @@ PROJECT_ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(PROJECT_ROOT)) -import yaml +import yaml # noqa: E402 with open(PROJECT_ROOT / "config.yaml") as f: config = yaml.safe_load(f) diff --git a/scripts/evaluate.py b/scripts/evaluate.py index 748fd2c..3255bc1 100644 --- a/scripts/evaluate.py +++ b/scripts/evaluate.py @@ -20,7 +20,7 @@ with open(PROJECT_ROOT / "config.yaml") as f: config = yaml.safe_load(f) -from src.rag import CineMatchRAG +from src.rag import CineMatchRAG # noqa: E402 TEST_QUERIES_FILE = PROJECT_ROOT / "data" / "test_queries.jsonl" diff --git a/src/app.py b/src/app.py index 32363b3..e50aafa 100644 --- a/src/app.py +++ b/src/app.py @@ -7,12 +7,12 @@ PROJECT_ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(PROJECT_ROOT)) -import streamlit as st -from dotenv import load_dotenv +import streamlit as st # noqa: E402 +from dotenv import load_dotenv # noqa: E402 load_dotenv(PROJECT_ROOT / ".env") -from src.rag import CineMatchRAG +from src.rag import CineMatchRAG # noqa: E402 st.set_page_config(page_title="CineMatch", page_icon="🎬", layout="centered") From fe66b11edd5b76336b97087c1da6acb747135652 Mon Sep 17 00:00:00 2001 From: renaisssancee Date: Thu, 26 Mar 2026 19:30:37 +0300 Subject: [PATCH 3/3] Fix ruff F841 and F541 in evaluate.py --- scripts/evaluate.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/scripts/evaluate.py b/scripts/evaluate.py index 3255bc1..63be9a1 100644 --- a/scripts/evaluate.py +++ b/scripts/evaluate.py @@ -40,8 +40,7 @@ def evaluate_genre_recall(result: dict, expected_genres: list[str]) -> float: return 0.0 hits = 0 for movie in result["movies"]: - title = movie.get("title", "").lower() - if any(g.lower() in str(result).lower() for g in expected_genres): + if any(g.lower() in str(movie).lower() for g in expected_genres): hits += 1 break return 1.0 if hits > 0 else 0.0 @@ -132,7 +131,7 @@ def main(): if result["type"] != expected_type: if expected_type == "recommendation" and result["type"] == "no_results": metrics["hallucination_count"] += 1 - print(f" MISS: expected recommendations, got no_results") + print(" MISS: expected recommendations, got no_results") if expected_genres and result["type"] == "recommendation": recall = evaluate_genre_recall(result, expected_genres)