Spaces:
Runtime error
Runtime error
Commit ·
1a2b9e6
0
Parent(s):
Phase 1: RAG Pipeline working
Browse files- .gitignore +5 -0
- README.md +0 -0
- app.log +0 -0
- example_prompt.txt +42 -0
- info.txt +13 -0
- logger_config.py +15 -0
- main.py +13 -0
- notebooks/01_embedding_experiments.ipynb +347 -0
- output.txt +0 -0
- pyproject.toml +36 -0
- src/__init__.py +0 -0
- src/api/main.py +110 -0
- src/api/schemas.py +13 -0
- src/data_ingestion/clean_data.py +144 -0
- src/data_ingestion/create_embeddings.py +153 -0
- src/data_ingestion/fetch_anime.py +109 -0
- src/llm/groq_client.py +67 -0
- src/llm/prompts.py +124 -0
- src/retrieval/rag_pipeline.py +138 -0
- src/retrieval/vector_search.py +109 -0
- try.py +14 -0
- ui/gradio_app.py +102 -0
- uv.lock +0 -0
.gitignore
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__
|
| 2 |
+
.venv
|
| 3 |
+
.env
|
| 4 |
+
data
|
| 5 |
+
.gradio
|
README.md
ADDED
|
File without changes
|
app.log
ADDED
|
File without changes
|
example_prompt.txt
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
You are an expert anime recommendation assistant. A user has asked for recommendations, and I've retrieved some potentially relevant anime from the database.
|
| 3 |
+
Your task is to:
|
| 4 |
+
1. Analyze the user's request carefully, paying attention to specific preferences (tone, themes, etc.)
|
| 5 |
+
2. Evaluate each retrieved anime against their user's criteria
|
| 6 |
+
3. Select the 3 BEST matches that truly fit what they're asking for
|
| 7 |
+
4. Explain why each recommendation fits their request
|
| 8 |
+
|
| 9 |
+
User's Query:
|
| 10 |
+
"Anime similar to Death Note but lighter in tone"
|
| 11 |
+
|
| 12 |
+
Retrieved anime from semantic search:
|
| 13 |
+
1. **Code Geass** (Score: 8.7/10, Scored by: 10000)
|
| 14 |
+
Genres: Action, Drama, Mecha
|
| 15 |
+
Synopsis: Lelouch, an exiled prince, gains the power of Geass and leads a rebellion against the Britannian Empire...
|
| 16 |
+
|
| 17 |
+
2. **Monster** (Score: 8.9/10, Scored by: 100)
|
| 18 |
+
Genres: Mystery, Psychological, Thriller
|
| 19 |
+
Synopsis: Dr. Tenma saves a young boy's life, only to discover the boy grows up to be a dangerous serial killer...
|
| 20 |
+
|
| 21 |
+
3. **Classroom of the Elite** (Score: 7.9/10, Scored by: 200000)
|
| 22 |
+
Genres: Drama, Psychological
|
| 23 |
+
Synopsis: In an elite school, students compete using strategy and manipulation to climb social ranks...
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
Instructions:
|
| 27 |
+
- If the user mentioned specific preferences (e.g., "lighter", "darker", "more action"), prioritize those
|
| 28 |
+
- Don't just list all retrieved anime - SELECT the best 3 that truly match
|
| 29 |
+
- For each recommendation, explain in 1-2 sentences WHY it matches their request
|
| 30 |
+
- If some retrieved anime DON'T match the user's specific criteria, exclude them
|
| 31 |
+
- Be honest if none of the retrieved anime are great matches
|
| 32 |
+
|
| 33 |
+
Format your response as:
|
| 34 |
+
**Recommendation 1: [Anime Title]**
|
| 35 |
+
[1-2 sentence explanation of why it matches]
|
| 36 |
+
|
| 37 |
+
**Recommendation 2: [Anime Title]**
|
| 38 |
+
[1-2 sentence explanation]
|
| 39 |
+
|
| 40 |
+
[Continue for 3 recommendations]
|
| 41 |
+
|
| 42 |
+
If you think the retrieved anime don't match the request well, say so and explain what type of anime would be better.
|
info.txt
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
demographics meaning in api:
|
| 2 |
+
It refers to target audiences
|
| 3 |
+
Kodomo: For young children (under 13 years old).
|
| 4 |
+
Shounen: Primarily targeted at young boys (ages 13-18).
|
| 5 |
+
Shoujo: Primarily targeted at young girls (ages 13-18).
|
| 6 |
+
Seinen: Primarily targeted at adult men (ages 19-40+).
|
| 7 |
+
Josei: Primarily targeted at adult women (ages 19-40+).
|
| 8 |
+
|
| 9 |
+
Each API response can be different seasons of the same anime.
|
| 10 |
+
so one mal_id can be MHA: Season 1, other can be MHA: Season 2 and so on
|
| 11 |
+
Also, I just saw, in let's say MHA: Season 2, in synopsis, its just written, MHA anime season two, and no synpopsis.
|
| 12 |
+
So, also need to make it such that it get synopsis from whichever Mal_id's synopsis is written for that anime
|
| 13 |
+
|
logger_config.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import sys
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def setup_logging():
|
| 6 |
+
log_format = "%(asctime)s - %(levelname)s - [%(name)s - %(message)s]"
|
| 7 |
+
|
| 8 |
+
logging.basicConfig(
|
| 9 |
+
level=logging.INFO,
|
| 10 |
+
format=log_format,
|
| 11 |
+
handlers=[
|
| 12 |
+
logging.StreamHandler(sys.stdout), # Print to the console
|
| 13 |
+
logging.FileHandler("app.log")
|
| 14 |
+
]
|
| 15 |
+
)
|
main.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from logger_config import setup_logging
|
| 3 |
+
|
| 4 |
+
setup_logging()
|
| 5 |
+
logger = logging.getLogger(__name__)
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def main():
|
| 9 |
+
print("Hello from anime-rag-system!")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
if __name__ == "__main__":
|
| 13 |
+
main()
|
notebooks/01_embedding_experiments.ipynb
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cells": [
|
| 3 |
+
{
|
| 4 |
+
"cell_type": "code",
|
| 5 |
+
"execution_count": 2,
|
| 6 |
+
"id": "8e40675b",
|
| 7 |
+
"metadata": {},
|
| 8 |
+
"outputs": [],
|
| 9 |
+
"source": [
|
| 10 |
+
"from sentence_transformers import SentenceTransformer\n",
|
| 11 |
+
"import chromadb"
|
| 12 |
+
]
|
| 13 |
+
},
|
| 14 |
+
{
|
| 15 |
+
"cell_type": "code",
|
| 16 |
+
"execution_count": 3,
|
| 17 |
+
"id": "bac36205",
|
| 18 |
+
"metadata": {},
|
| 19 |
+
"outputs": [
|
| 20 |
+
{
|
| 21 |
+
"name": "stdout",
|
| 22 |
+
"output_type": "stream",
|
| 23 |
+
"text": [
|
| 24 |
+
"/home/pushkar/AllDocuments/EtE_projects/anime_rag_system\n"
|
| 25 |
+
]
|
| 26 |
+
}
|
| 27 |
+
],
|
| 28 |
+
"source": [
|
| 29 |
+
"from pathlib import Path\n",
|
| 30 |
+
"\n",
|
| 31 |
+
"# BASE_DIR = Path(__file__).resolve().parent # if python file\n",
|
| 32 |
+
"BASE_DIR = Path.cwd().parent\n",
|
| 33 |
+
"print(BASE_DIR)"
|
| 34 |
+
]
|
| 35 |
+
},
|
| 36 |
+
{
|
| 37 |
+
"cell_type": "code",
|
| 38 |
+
"execution_count": 4,
|
| 39 |
+
"id": "2878800c",
|
| 40 |
+
"metadata": {},
|
| 41 |
+
"outputs": [],
|
| 42 |
+
"source": [
|
| 43 |
+
"client = chromadb.PersistentClient(path=BASE_DIR / \"data/embeddings/chroma_db\")\n",
|
| 44 |
+
"collection = client.get_collection(name=\"anime_collection\")"
|
| 45 |
+
]
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
"cell_type": "code",
|
| 49 |
+
"execution_count": 5,
|
| 50 |
+
"id": "70b38ecc",
|
| 51 |
+
"metadata": {},
|
| 52 |
+
"outputs": [],
|
| 53 |
+
"source": [
|
| 54 |
+
"queries = [\n",
|
| 55 |
+
" \"psychological thriller anime\",\n",
|
| 56 |
+
" \"comedy slice of life school anime\",\n",
|
| 57 |
+
" \"anime like Attack on Titan\",\n",
|
| 58 |
+
" \"sad anime that will make me cry\"\n",
|
| 59 |
+
"]"
|
| 60 |
+
]
|
| 61 |
+
},
|
| 62 |
+
{
|
| 63 |
+
"cell_type": "code",
|
| 64 |
+
"execution_count": 6,
|
| 65 |
+
"id": "672b383c",
|
| 66 |
+
"metadata": {},
|
| 67 |
+
"outputs": [
|
| 68 |
+
{
|
| 69 |
+
"name": "stdout",
|
| 70 |
+
"output_type": "stream",
|
| 71 |
+
"text": [
|
| 72 |
+
"Query: psychological thriller anime: \n",
|
| 73 |
+
"1. Kimetsu no Yaiba Movie 1: Mugenjou-hen - Akaza Sairai (distance: 0.902)\n",
|
| 74 |
+
"2. Hoozuki no Reitetsu 2nd Season (distance: 0.912)\n",
|
| 75 |
+
"3. Mob Psycho 100 II (distance: 0.961)\n",
|
| 76 |
+
"4. Mob Psycho 100 III (distance: 1.003)\n",
|
| 77 |
+
"5. Akira (distance: 1.037)\n",
|
| 78 |
+
"6. Mob Psycho 100 (distance: 1.041)\n",
|
| 79 |
+
"7. Fujimoto Tatsuki 17-26 (distance: 1.056)\n",
|
| 80 |
+
"8. Guimi Zhi Zhu: Xiaochou Pian (distance: 1.057)\n",
|
| 81 |
+
"Query: comedy slice of life school anime: \n",
|
| 82 |
+
"1. 3-nen Z-gumi Ginpachi-sensei (distance: 0.759)\n",
|
| 83 |
+
"2. Kimi to Boku. 2 (distance: 0.793)\n",
|
| 84 |
+
"3. School Rumble (distance: 0.914)\n",
|
| 85 |
+
"4. Non Non Biyori Repeat (distance: 0.926)\n",
|
| 86 |
+
"5. Hidamari Sketch: Sae Hiro Sotsugyou-hen (distance: 0.930)\n",
|
| 87 |
+
"6. 5-toubun no Hanayome∽ (distance: 0.933)\n",
|
| 88 |
+
"7. Danshi Koukousei no Nichijou (distance: 0.935)\n",
|
| 89 |
+
"8. Kono Subarashii Sekai ni Shukufuku wo! 3: Bonus Stage (distance: 0.940)\n",
|
| 90 |
+
"Query: anime like Attack on Titan: \n",
|
| 91 |
+
"1. Shingeki no Kyojin Movie: Kanketsu-hen - The Last Attack (distance: 0.719)\n",
|
| 92 |
+
"2. Kidou Senshi Zeta Gundam (distance: 0.886)\n",
|
| 93 |
+
"3. Shingeki no Kyojin: The Final Season (distance: 0.937)\n",
|
| 94 |
+
"4. Shingeki no Kyojin: The Final Season Part 2 (distance: 0.941)\n",
|
| 95 |
+
"5. Shingeki no Kyojin (distance: 0.971)\n",
|
| 96 |
+
"6. Shingeki no Kyojin Season 2 (distance: 0.994)\n",
|
| 97 |
+
"7. Shingeki no Kyojin Season 3 Part 2 (distance: 1.011)\n",
|
| 98 |
+
"8. Shingeki no Kyojin OVA (distance: 1.034)\n",
|
| 99 |
+
"Query: sad anime that will make me cry: \n",
|
| 100 |
+
"1. Fujimoto Tatsuki 17-26 (distance: 0.990)\n",
|
| 101 |
+
"2. Osomatsu-san Movie (distance: 1.051)\n",
|
| 102 |
+
"3. Gintama: Dai Hanseikai (distance: 1.052)\n",
|
| 103 |
+
"4. Yuuki Yuuna wa Yuusha de Aru: Washio Sumi no Shou 3 - Yakusoku (distance: 1.094)\n",
|
| 104 |
+
"5. Tengoku Daimakyou (distance: 1.096)\n",
|
| 105 |
+
"6. Kimetsu no Yaiba Movie 1: Mugenjou-hen - Akaza Sairai (distance: 1.107)\n",
|
| 106 |
+
"7. Girls Band Cry (distance: 1.111)\n",
|
| 107 |
+
"8. Mahou Shoujo Madoka★Magica Movie 3: Hangyaku no Monogatari (distance: 1.121)\n"
|
| 108 |
+
]
|
| 109 |
+
}
|
| 110 |
+
],
|
| 111 |
+
"source": [
|
| 112 |
+
"for query in queries:\n",
|
| 113 |
+
" print(f\"Query: {query}: \")\n",
|
| 114 |
+
" results = collection.query(\n",
|
| 115 |
+
" query_texts=[query],\n",
|
| 116 |
+
" n_results=8\n",
|
| 117 |
+
" )\n",
|
| 118 |
+
"\n",
|
| 119 |
+
" for i, (title, distance) in enumerate(zip(\n",
|
| 120 |
+
" [m[\"title\"] for m in results[\"metadatas\"][0]],\n",
|
| 121 |
+
" results[\"distances\"][0]\n",
|
| 122 |
+
" )):\n",
|
| 123 |
+
" print(f\"{i+1}. {title} (distance: {distance:.3f})\")\n"
|
| 124 |
+
]
|
| 125 |
+
},
|
| 126 |
+
{
|
| 127 |
+
"cell_type": "code",
|
| 128 |
+
"execution_count": 7,
|
| 129 |
+
"id": "a9843de5",
|
| 130 |
+
"metadata": {},
|
| 131 |
+
"outputs": [],
|
| 132 |
+
"source": [
|
| 133 |
+
"def show_results(results):\n",
|
| 134 |
+
" for i, (title, distance) in enumerate(zip(\n",
|
| 135 |
+
" [m[\"title\"] for m in results[\"metadatas\"][0]],\n",
|
| 136 |
+
" results[\"distances\"][0]\n",
|
| 137 |
+
" )):\n",
|
| 138 |
+
" print(f\"{i+1}. {title} (distance: {distance:.3f})\")"
|
| 139 |
+
]
|
| 140 |
+
},
|
| 141 |
+
{
|
| 142 |
+
"cell_type": "code",
|
| 143 |
+
"execution_count": null,
|
| 144 |
+
"id": "453ed4e7",
|
| 145 |
+
"metadata": {},
|
| 146 |
+
"outputs": [],
|
| 147 |
+
"source": [
|
| 148 |
+
"que = [\"Anime about a mage who goes on adventures\"]\n",
|
| 149 |
+
"\n",
|
| 150 |
+
"res1 = collection.query(query_texts=que,\n",
|
| 151 |
+
" n_results=20)\n",
|
| 152 |
+
"\n",
|
| 153 |
+
"res2 = collection.query(query_texts=que,\n",
|
| 154 |
+
" n_results=100,\n",
|
| 155 |
+
" where={\"score\":{\n",
|
| 156 |
+
" \"$gt\": 0\n",
|
| 157 |
+
" }})"
|
| 158 |
+
]
|
| 159 |
+
},
|
| 160 |
+
{
|
| 161 |
+
"cell_type": "code",
|
| 162 |
+
"execution_count": 13,
|
| 163 |
+
"id": "985018d1",
|
| 164 |
+
"metadata": {},
|
| 165 |
+
"outputs": [
|
| 166 |
+
{
|
| 167 |
+
"name": "stdout",
|
| 168 |
+
"output_type": "stream",
|
| 169 |
+
"text": [
|
| 170 |
+
"Results without metadata filtering: \n",
|
| 171 |
+
"1. Meitantei Conan: Episode One - Chiisaku Natta Meitantei (distance: 1.009)\n",
|
| 172 |
+
"2. Magi: The Kingdom of Magic (distance: 1.035)\n",
|
| 173 |
+
"3. Guimi Zhi Zhu: Xiaochou Pian (distance: 1.072)\n",
|
| 174 |
+
"4. 3-nen Z-gumi Ginpachi-sensei (distance: 1.075)\n",
|
| 175 |
+
"5. Mahoutsukai no Yome: Hoshi Matsu Hito (distance: 1.088)\n",
|
| 176 |
+
"6. Magi: The Labyrinth of Magic (distance: 1.090)\n",
|
| 177 |
+
"7. Akagami no Shirayuki-hime 2nd Season (distance: 1.091)\n",
|
| 178 |
+
"8. Kimetsu no Yaiba Movie 1: Mugenjou-hen - Akaza Sairai (distance: 1.103)\n",
|
| 179 |
+
"9. Silent Witch: Chinmoku no Majo no Kakushigoto (distance: 1.117)\n",
|
| 180 |
+
"10. Meitantei Conan Movie 13: Shikkoku no Chaser (distance: 1.121)\n",
|
| 181 |
+
"11. Yuru Yuri San☆Hai! (distance: 1.126)\n",
|
| 182 |
+
"12. Gintama: Dai Hanseikai (distance: 1.139)\n",
|
| 183 |
+
"13. Zhu Xian 2nd Season (distance: 1.142)\n",
|
| 184 |
+
"14. Yuri!!! on Ice: Yuri Plisetsky GPF in Barcelona EX - Welcome to The Madness (distance: 1.144)\n",
|
| 185 |
+
"15. Mushoku Tensei: Isekai Ittara Honki Dasu (distance: 1.148)\n",
|
| 186 |
+
"16. Kono Subarashii Sekai ni Shukufuku wo! 3: Bonus Stage (distance: 1.149)\n",
|
| 187 |
+
"17. Lupin III vs. Meitantei Conan: The Movie (distance: 1.154)\n",
|
| 188 |
+
"18. xxxHOLiC Movie: Manatsu no Yoru no Yume (distance: 1.155)\n",
|
| 189 |
+
"19. Hoozuki no Reitetsu 2nd Season (distance: 1.156)\n",
|
| 190 |
+
"20. Tsubasa: Shunraiki (distance: 1.157)\n",
|
| 191 |
+
"Results with metadata filtering: \n",
|
| 192 |
+
"1 Meitantei Conan: Episode One - Chiisaku Natta Meitantei, \n",
|
| 193 |
+
"Genre: Adventure, Comedy, Mystery \n",
|
| 194 |
+
"Distance: 1.0094857215881348\n",
|
| 195 |
+
"1 Magi: The Kingdom of Magic, \n",
|
| 196 |
+
"Genre: Action, Adventure, Fantasy \n",
|
| 197 |
+
"Distance: 1.0348175764083862\n",
|
| 198 |
+
"1 Magi: The Labyrinth of Magic, \n",
|
| 199 |
+
"Genre: Action, Adventure, Fantasy \n",
|
| 200 |
+
"Distance: 1.0896832942962646\n",
|
| 201 |
+
"1 Zhu Xian 2nd Season, \n",
|
| 202 |
+
"Genre: Action, Adventure, Fantasy \n",
|
| 203 |
+
"Distance: 1.1424976587295532\n",
|
| 204 |
+
"1 Mushoku Tensei: Isekai Ittara Honki Dasu, \n",
|
| 205 |
+
"Genre: Adventure, Drama, Fantasy, Ecchi \n",
|
| 206 |
+
"Distance: 1.1479932069778442\n",
|
| 207 |
+
"1 Kono Subarashii Sekai ni Shukufuku wo! 3: Bonus Stage, \n",
|
| 208 |
+
"Genre: Adventure, Comedy, Fantasy \n",
|
| 209 |
+
"Distance: 1.1492812633514404\n",
|
| 210 |
+
"1 Tsubasa: Shunraiki, \n",
|
| 211 |
+
"Genre: Action, Adventure, Drama, Fantasy, Romance \n",
|
| 212 |
+
"Distance: 1.1567258834838867\n",
|
| 213 |
+
"1 Wanmei Shijie, \n",
|
| 214 |
+
"Genre: Action, Adventure, Fantasy \n",
|
| 215 |
+
"Distance: 1.1588501930236816\n",
|
| 216 |
+
"1 Meitantei Conan OVA 09: 10-nengo no Stranger, \n",
|
| 217 |
+
"Genre: Adventure, Comedy, Mystery \n",
|
| 218 |
+
"Distance: 1.1683908700942993\n",
|
| 219 |
+
"1 Meitantei Conan Movie 03: Seikimatsu no Majutsushi, \n",
|
| 220 |
+
"Genre: Adventure, Comedy, Mystery \n",
|
| 221 |
+
"Distance: 1.183741807937622\n",
|
| 222 |
+
"1 Golden Boy, \n",
|
| 223 |
+
"Genre: Adventure, Comedy, Ecchi \n",
|
| 224 |
+
"Distance: 1.1899913549423218\n",
|
| 225 |
+
"1 Mirai Shounen Conan, \n",
|
| 226 |
+
"Genre: Adventure, Drama, Sci-Fi \n",
|
| 227 |
+
"Distance: 1.192782998085022\n",
|
| 228 |
+
"1 Mushoku Tensei: Isekai Ittara Honki Dasu Part 2, \n",
|
| 229 |
+
"Genre: Adventure, Drama, Fantasy, Ecchi \n",
|
| 230 |
+
"Distance: 1.2001222372055054\n",
|
| 231 |
+
"1 Majo no Takkyuubin, \n",
|
| 232 |
+
"Genre: Adventure, Award Winning, Comedy, Drama, Fantasy \n",
|
| 233 |
+
"Distance: 1.2021398544311523\n",
|
| 234 |
+
"1 Naruto, \n",
|
| 235 |
+
"Genre: Action, Adventure, Fantasy \n",
|
| 236 |
+
"Distance: 1.2085628509521484\n",
|
| 237 |
+
"1 Sword Art Online: Progressive Movie - Hoshi Naki Yoru no Aria, \n",
|
| 238 |
+
"Genre: Action, Adventure, Fantasy \n",
|
| 239 |
+
"Distance: 1.2172694206237793\n",
|
| 240 |
+
"1 Ginga Sengoku Gunyuuden Rai, \n",
|
| 241 |
+
"Genre: Adventure, Romance, Sci-Fi \n",
|
| 242 |
+
"Distance: 1.2243961095809937\n",
|
| 243 |
+
"1 Xian Ni, \n",
|
| 244 |
+
"Genre: Action, Adventure, Fantasy \n",
|
| 245 |
+
"Distance: 1.2341101169586182\n",
|
| 246 |
+
"1 Overlord, \n",
|
| 247 |
+
"Genre: Action, Adventure, Fantasy \n",
|
| 248 |
+
"Distance: 1.2380468845367432\n",
|
| 249 |
+
"1 Akatsuki no Yona OVA, \n",
|
| 250 |
+
"Genre: Adventure, Fantasy \n",
|
| 251 |
+
"Distance: 1.2383925914764404\n",
|
| 252 |
+
"1 Mushishi Zoku Shou: Suzu no Shizuku, \n",
|
| 253 |
+
"Genre: Adventure, Mystery, Slice of Life, Supernatural \n",
|
| 254 |
+
"Distance: 1.2392939329147339\n",
|
| 255 |
+
"1 Shen Yin Wangzuo Movie: Yi Lai Ke Si Chuanqi, \n",
|
| 256 |
+
"Genre: Action, Adventure, Fantasy \n",
|
| 257 |
+
"Distance: 1.239490270614624\n",
|
| 258 |
+
"1 Dragon Ball, \n",
|
| 259 |
+
"Genre: Action, Adventure, Comedy, Fantasy \n",
|
| 260 |
+
"Distance: 1.2412399053573608\n",
|
| 261 |
+
"1 Wu Liuqi: Xuanwu Guo Pian, \n",
|
| 262 |
+
"Genre: Action, Adventure, Comedy, Drama, Mystery \n",
|
| 263 |
+
"Distance: 1.242012858390808\n",
|
| 264 |
+
"1 Meitantei Conan, \n",
|
| 265 |
+
"Genre: Adventure, Comedy, Mystery \n",
|
| 266 |
+
"Distance: 1.2530790567398071\n",
|
| 267 |
+
"1 Kono Subarashii Sekai ni Shukufuku wo! 2, \n",
|
| 268 |
+
"Genre: Adventure, Comedy, Fantasy \n",
|
| 269 |
+
"Distance: 1.2592735290527344\n",
|
| 270 |
+
"1 Fanren Xiu Xian Chuan: Xinghai Feichi Prologue, \n",
|
| 271 |
+
"Genre: Action, Adventure, Fantasy \n",
|
| 272 |
+
"Distance: 1.264496922492981\n",
|
| 273 |
+
"1 Saint Seiya: The Lost Canvas - Meiou Shinwa 2, \n",
|
| 274 |
+
"Genre: Action, Adventure, Fantasy \n",
|
| 275 |
+
"Distance: 1.267714023590088\n",
|
| 276 |
+
"1 Meitantei Conan Movie 08: Ginyoku no Magician, \n",
|
| 277 |
+
"Genre: Adventure, Comedy, Mystery \n",
|
| 278 |
+
"Distance: 1.2713207006454468\n",
|
| 279 |
+
"1 Fullmetal Alchemist: Brotherhood Specials, \n",
|
| 280 |
+
"Genre: Action, Adventure, Drama, Fantasy \n",
|
| 281 |
+
"Distance: 1.273901343345642\n",
|
| 282 |
+
"1 Naruto: Shippuuden, \n",
|
| 283 |
+
"Genre: Action, Adventure, Fantasy \n",
|
| 284 |
+
"Distance: 1.2760953903198242\n",
|
| 285 |
+
"1 Tenkuu no Shiro Laputa, \n",
|
| 286 |
+
"Genre: Adventure, Award Winning, Fantasy \n",
|
| 287 |
+
"Distance: 1.278309941291809\n",
|
| 288 |
+
"1 Ie Naki Ko Remy, \n",
|
| 289 |
+
"Genre: Adventure, Drama \n",
|
| 290 |
+
"Distance: 1.2802643775939941\n",
|
| 291 |
+
"1 Yi Nian Yong Heng 3rd Season, \n",
|
| 292 |
+
"Genre: Action, Adventure, Comedy, Fantasy \n",
|
| 293 |
+
"Distance: 1.284270167350769\n"
|
| 294 |
+
]
|
| 295 |
+
}
|
| 296 |
+
],
|
| 297 |
+
"source": [
|
| 298 |
+
"print(\"Results without metadata filtering: \")\n",
|
| 299 |
+
"show_results(res1)\n",
|
| 300 |
+
"\n",
|
| 301 |
+
"print(\"Results with metadata filtering: \")\n",
|
| 302 |
+
"i = 0;\n",
|
| 303 |
+
"for _, (title, genres, distance) in enumerate(zip([m[\"title\"] for m in res2[\"metadatas\"][0]], [m.get(\"genres\", \"Unknown\") for m in res2[\"metadatas\"][0]], res2[\"distances\"][0])):\n",
|
| 304 |
+
" if \"adventure\" in genres.lower():\n",
|
| 305 |
+
" print(f\"{i + 1} {title}, \\nGenre: {genres} \\nDistance: {distance}\")\n",
|
| 306 |
+
"\n"
|
| 307 |
+
]
|
| 308 |
+
},
|
| 309 |
+
{
|
| 310 |
+
"cell_type": "code",
|
| 311 |
+
"execution_count": null,
|
| 312 |
+
"id": "b6e41bbf",
|
| 313 |
+
"metadata": {},
|
| 314 |
+
"outputs": [],
|
| 315 |
+
"source": []
|
| 316 |
+
},
|
| 317 |
+
{
|
| 318 |
+
"cell_type": "code",
|
| 319 |
+
"execution_count": null,
|
| 320 |
+
"id": "42413036",
|
| 321 |
+
"metadata": {},
|
| 322 |
+
"outputs": [],
|
| 323 |
+
"source": []
|
| 324 |
+
}
|
| 325 |
+
],
|
| 326 |
+
"metadata": {
|
| 327 |
+
"kernelspec": {
|
| 328 |
+
"display_name": "anime-rag-system",
|
| 329 |
+
"language": "python",
|
| 330 |
+
"name": "python3"
|
| 331 |
+
},
|
| 332 |
+
"language_info": {
|
| 333 |
+
"codemirror_mode": {
|
| 334 |
+
"name": "ipython",
|
| 335 |
+
"version": 3
|
| 336 |
+
},
|
| 337 |
+
"file_extension": ".py",
|
| 338 |
+
"mimetype": "text/x-python",
|
| 339 |
+
"name": "python",
|
| 340 |
+
"nbconvert_exporter": "python",
|
| 341 |
+
"pygments_lexer": "ipython3",
|
| 342 |
+
"version": "3.14.0"
|
| 343 |
+
}
|
| 344 |
+
},
|
| 345 |
+
"nbformat": 4,
|
| 346 |
+
"nbformat_minor": 5
|
| 347 |
+
}
|
output.txt
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
pyproject.toml
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "anime-rag-system"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "Add your description here"
|
| 5 |
+
readme = "README.md"
|
| 6 |
+
requires-python = ">=3.11,<3.14"
|
| 7 |
+
dependencies = [
|
| 8 |
+
"chromadb>=0.5.0",
|
| 9 |
+
"fastapi[all]>=0.122.0",
|
| 10 |
+
"gradio>=6.2.0",
|
| 11 |
+
"langchain>=1.1.3",
|
| 12 |
+
"langchain-groq>=1.1.1",
|
| 13 |
+
"pandas>=2.3.3",
|
| 14 |
+
"python-dotenv>=1.2.1",
|
| 15 |
+
"requests>=2.32.5",
|
| 16 |
+
"sentence-transformers>=5.2.0",
|
| 17 |
+
"torch>=2.9.1",
|
| 18 |
+
"torchvision>=0.24.1",
|
| 19 |
+
"uvicorn>=0.38.0",
|
| 20 |
+
]
|
| 21 |
+
|
| 22 |
+
[tool.uv.sources]
|
| 23 |
+
torch = { index = "pytorch-cpu" }
|
| 24 |
+
torchvision = { index = "pytorch-cpu" }
|
| 25 |
+
|
| 26 |
+
[[tool.uv.index]]
|
| 27 |
+
name = "pytorch-cpu"
|
| 28 |
+
url = "https://download.pytorch.org/whl/cpu"
|
| 29 |
+
explicit = true
|
| 30 |
+
|
| 31 |
+
[dependency-groups]
|
| 32 |
+
dev = [
|
| 33 |
+
"ipykernel>=7.1.0",
|
| 34 |
+
"jupyter>=1.1.1",
|
| 35 |
+
"pytest>=9.0.2",
|
| 36 |
+
]
|
src/__init__.py
ADDED
|
File without changes
|
src/api/main.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, HTTPException, status
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from pydantic import BaseModel, Field
|
| 4 |
+
import uvicorn
|
| 5 |
+
from src.api.schemas import RecommendationRequest, RecommendationResponse
|
| 6 |
+
import time
|
| 7 |
+
|
| 8 |
+
from src.retrieval.rag_pipeline import AnimeRAGPipeline
|
| 9 |
+
|
| 10 |
+
app = FastAPI(title="Anime Recommendation API",
|
| 11 |
+
description="RAG-powered anime recommendation system",
|
| 12 |
+
version="1.0.0")
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
app.add_middleware(
|
| 16 |
+
CORSMiddleware,
|
| 17 |
+
allow_origins=["*"],
|
| 18 |
+
allow_credentials=True,
|
| 19 |
+
allow_methods=["*"],
|
| 20 |
+
allow_headers=["*"]
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
pipeline = None
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def get_pipeline():
|
| 27 |
+
"""Lazy initialization of pipeline"""
|
| 28 |
+
global pipeline
|
| 29 |
+
if pipeline is None:
|
| 30 |
+
pipeline = AnimeRAGPipeline(retriever_k=10)
|
| 31 |
+
return pipeline
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@app.get("/")
|
| 35 |
+
async def root():
|
| 36 |
+
"""Healthcheck Endpoint"""
|
| 37 |
+
return {
|
| 38 |
+
"status": "online",
|
| 39 |
+
"message": "Anime recommendation API",
|
| 40 |
+
"version": "1.0.0"
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@app.post("/recommend", response_model=RecommendationResponse)
|
| 45 |
+
async def get_recommendations(request: RecommendationRequest):
|
| 46 |
+
"""
|
| 47 |
+
Get anime recommendation based on user query
|
| 48 |
+
|
| 49 |
+
Example request:
|
| 50 |
+
```json
|
| 51 |
+
{
|
| 52 |
+
"query": "Anime similar to Death Note but lighter",
|
| 53 |
+
"n_results": 5,
|
| 54 |
+
"min_score": 7.5
|
| 55 |
+
}
|
| 56 |
+
```
|
| 57 |
+
"""
|
| 58 |
+
try:
|
| 59 |
+
rag_pipeline = get_pipeline()
|
| 60 |
+
|
| 61 |
+
rag_pipeline.recommendation_n = request.n_results
|
| 62 |
+
|
| 63 |
+
filters = {}
|
| 64 |
+
if request.min_score:
|
| 65 |
+
filters["min_score"] = request.min_score
|
| 66 |
+
|
| 67 |
+
if request.genre_filter:
|
| 68 |
+
filters["genre_filter"] = request.genre_filter
|
| 69 |
+
|
| 70 |
+
start_time = time.time()
|
| 71 |
+
result = rag_pipeline.recommend(
|
| 72 |
+
user_query=request.query,
|
| 73 |
+
filters=filters if filters else None
|
| 74 |
+
)
|
| 75 |
+
end_time = time.time()
|
| 76 |
+
return RecommendationResponse(
|
| 77 |
+
query=result["query"],
|
| 78 |
+
recommendations=result["recommendations"],
|
| 79 |
+
retrieved_count=result["retrieved_count"],
|
| 80 |
+
metadata={
|
| 81 |
+
"model": "llama-3.3-70b-versatile",
|
| 82 |
+
"retriever_k": rag_pipeline.retriever_k,
|
| 83 |
+
"Time taken for LLM + vector search": str(end_time - start_time)
|
| 84 |
+
}
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
except Exception as e:
|
| 88 |
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 89 |
+
detail=f"Error processing request: {str(e)}")
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
@app.get("/stats")
|
| 93 |
+
async def get_stats():
|
| 94 |
+
"""Get system statistics"""
|
| 95 |
+
rag_pipeline = get_pipeline()
|
| 96 |
+
|
| 97 |
+
return {
|
| 98 |
+
"total_anime": rag_pipeline.retriever.collection.count(),
|
| 99 |
+
"embedding_model": "all-MiniLM-L6-v2",
|
| 100 |
+
"llm_model": "llama-3.1-70b-versatile",
|
| 101 |
+
"retrieval_k": rag_pipeline.retriever_k
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
if __name__ == "__main__":
|
| 105 |
+
uvicorn.run(
|
| 106 |
+
"src.api.main:app",
|
| 107 |
+
host="0.0.0.0",
|
| 108 |
+
port=8000,
|
| 109 |
+
reload=True
|
| 110 |
+
)
|
src/api/schemas.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, Field
|
| 2 |
+
|
| 3 |
+
class RecommendationRequest(BaseModel):
|
| 4 |
+
query: str = Field(description="User's anime recommendation request")
|
| 5 |
+
n_results: int | None = Field(5, description="Number of recommendations to return")
|
| 6 |
+
min_score: float | None = Field(None, description="Minimum MyAnimeList Score filter")
|
| 7 |
+
genre_filter: str | None = Field(None, description="Filter by genre")
|
| 8 |
+
|
| 9 |
+
class RecommendationResponse(BaseModel):
|
| 10 |
+
query: str
|
| 11 |
+
recommendations: str
|
| 12 |
+
retrieved_count: int
|
| 13 |
+
metadata: dict = {}
|
src/data_ingestion/clean_data.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import pandas as pd
|
| 3 |
+
import logging
|
| 4 |
+
|
| 5 |
+
logger = logging.getLogger(__name__)
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class AnimeDataCleaner:
|
| 9 |
+
"""Cleans and prepares anime data for embeddings"""
|
| 10 |
+
|
| 11 |
+
@staticmethod
|
| 12 |
+
def load_raw_data(filepath: str = "data/raw/raw_anime.json") -> list[dict]:
|
| 13 |
+
"""Load raw anime data"""
|
| 14 |
+
with open(filepath, 'r', encoding='utf-8') as f:
|
| 15 |
+
return json.load(f)
|
| 16 |
+
|
| 17 |
+
@staticmethod
|
| 18 |
+
def clean_synopsis(synopsis: str) -> str:
|
| 19 |
+
"""Clean synopsis text"""
|
| 20 |
+
if not synopsis or synopsis == "":
|
| 21 |
+
return "No synopsis available."
|
| 22 |
+
|
| 23 |
+
synopsis = synopsis.replace("[Written by MAL Rewrite]", "")
|
| 24 |
+
synopsis.strip()
|
| 25 |
+
|
| 26 |
+
return synopsis
|
| 27 |
+
|
| 28 |
+
@staticmethod
|
| 29 |
+
def create_searchable_text(anime: dict) -> str:
|
| 30 |
+
"""
|
| 31 |
+
Combine multiple fields into one searchable text.
|
| 32 |
+
This is what we'll embed!
|
| 33 |
+
|
| 34 |
+
Format: Title. Genres. Synopsis.
|
| 35 |
+
"""
|
| 36 |
+
title = anime.get("title")
|
| 37 |
+
title_en = anime.get("title_english")
|
| 38 |
+
|
| 39 |
+
title_text = title
|
| 40 |
+
if title_en and title_en != title:
|
| 41 |
+
title_text = f"{title} ({title_en})"
|
| 42 |
+
|
| 43 |
+
genres = ", ".join(anime.get("genres", []))
|
| 44 |
+
themes = ", ".join(anime.get("themes", []))
|
| 45 |
+
demographics = ", ".join(anime.get("demographics", []))
|
| 46 |
+
|
| 47 |
+
genre_parts = [p for p in [genres, themes, demographics] if p]
|
| 48 |
+
genre_text = ". ".join(genre_parts) if genre_parts else ""
|
| 49 |
+
|
| 50 |
+
synopsis = AnimeDataCleaner.clean_synopsis(anime.get("synopsis", ""))
|
| 51 |
+
|
| 52 |
+
searchable_text = f"{title_text}. {genre_text}. {synopsis}"
|
| 53 |
+
|
| 54 |
+
return searchable_text.strip()
|
| 55 |
+
|
| 56 |
+
@staticmethod
|
| 57 |
+
def filter_valid_anime(anime_list: list[dict]) -> list[dict]:
|
| 58 |
+
"""Remove anime without synopsis or essential fields"""
|
| 59 |
+
valid_anime = []
|
| 60 |
+
|
| 61 |
+
for anime in anime_list:
|
| 62 |
+
if not anime.get("title"):
|
| 63 |
+
continue
|
| 64 |
+
|
| 65 |
+
synopsis = anime.get("synopsis", "")
|
| 66 |
+
if not synopsis or len(synopsis) < 50:
|
| 67 |
+
continue # Later modify this part to get custom synopsis from online sources
|
| 68 |
+
|
| 69 |
+
valid_anime.append(anime)
|
| 70 |
+
|
| 71 |
+
print(f"Filtered {len(anime_list)} -> {len(valid_anime)} animes")
|
| 72 |
+
logger.info(f"Filtered {len(anime_list)} -> {len(valid_anime)} animes")
|
| 73 |
+
|
| 74 |
+
return valid_anime
|
| 75 |
+
|
| 76 |
+
@staticmethod
|
| 77 |
+
def prepare_for_embedding(anime_list: list[dict]) -> pd.DataFrame:
|
| 78 |
+
"""
|
| 79 |
+
Prepare final dataset for embedding
|
| 80 |
+
|
| 81 |
+
Returns dataframe with columns:
|
| 82 |
+
- mal_id: unique identifier
|
| 83 |
+
- searchable_text: what to embed
|
| 84 |
+
- metadata: everything else (for filtering/display)
|
| 85 |
+
"""
|
| 86 |
+
|
| 87 |
+
records = []
|
| 88 |
+
|
| 89 |
+
for anime in anime_list:
|
| 90 |
+
record = {
|
| 91 |
+
"mal_id": anime["mal_id"],
|
| 92 |
+
"title": anime["title"],
|
| 93 |
+
"searchable_text": AnimeDataCleaner.create_searchable_text(anime),
|
| 94 |
+
# Rest is metadata for filtering and display
|
| 95 |
+
"genres": ", ".join(anime.get("genres", [])),
|
| 96 |
+
"score": anime.get("score"),
|
| 97 |
+
"episodes": anime.get("episodes"),
|
| 98 |
+
"type": anime.get("type"),
|
| 99 |
+
"year": anime.get("year"),
|
| 100 |
+
"synopsis": AnimeDataCleaner.clean_synopsis(anime.get("synopsis", "")),
|
| 101 |
+
"aired_from": anime.get("aired_from", ""),
|
| 102 |
+
"aired_to": anime.get("aired_to", ""),
|
| 103 |
+
"rating": anime.get("rating"),
|
| 104 |
+
"scored_by": anime.get("scored_by"),
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
records.append(record)
|
| 108 |
+
|
| 109 |
+
df = pd.DataFrame(records)
|
| 110 |
+
return df
|
| 111 |
+
|
| 112 |
+
@staticmethod
|
| 113 |
+
def save_processed_data(df: pd.DataFrame, filepath: str = "data/processed/anime_clean.csv"):
|
| 114 |
+
"""Save processed data"""
|
| 115 |
+
df.to_csv(filepath, index=False, encoding="utf-8")
|
| 116 |
+
logger.info(f"Saved {len(df)} anime to {filepath}")
|
| 117 |
+
|
| 118 |
+
json_path = filepath.replace(".csv", ".json")
|
| 119 |
+
df.to_json(json_path, orient="records", indent=2, force_ascii=False)
|
| 120 |
+
|
| 121 |
+
logger.info(f"Also saved to {json_path}")
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
if __name__ == "__main__":
|
| 125 |
+
cleaner = AnimeDataCleaner()
|
| 126 |
+
|
| 127 |
+
print("Loading raw data....")
|
| 128 |
+
raw_animes = cleaner.load_raw_data()
|
| 129 |
+
|
| 130 |
+
valid_animes = cleaner.filter_valid_anime(raw_animes)
|
| 131 |
+
|
| 132 |
+
print("\nPreparing data for embedding...")
|
| 133 |
+
df = cleaner.prepare_for_embedding(valid_animes)
|
| 134 |
+
|
| 135 |
+
cleaner.save_processed_data(df)
|
| 136 |
+
|
| 137 |
+
print("\nSample searchable text:")
|
| 138 |
+
print(df.iloc[0]["searchable_text"][:500])
|
| 139 |
+
|
| 140 |
+
print(f"\nDataset statistics:")
|
| 141 |
+
print(f"Total anime: {len(df)}")
|
| 142 |
+
print(
|
| 143 |
+
f"Average text length: {df['searchable_text'].str.len().mean():.0f} chars")
|
| 144 |
+
print(f"Score range: {df['score'].min():.1f} - {df['score'].max():.1f}")
|
src/data_ingestion/create_embeddings.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
from sentence_transformers import SentenceTransformer
|
| 3 |
+
import chromadb
|
| 4 |
+
from chromadb.config import Settings
|
| 5 |
+
import os
|
| 6 |
+
import logging
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class EmbeddingPipeline:
|
| 12 |
+
"""Creates embeddings and store in ChromaDB"""
|
| 13 |
+
|
| 14 |
+
def __init__(self, model_name: str = "all-MiniLM-L6-v2"):
|
| 15 |
+
"""
|
| 16 |
+
Initialize embedding model and ChromaDB client
|
| 17 |
+
|
| 18 |
+
Args:
|
| 19 |
+
model_name: HuggingFace model for embeddings
|
| 20 |
+
all-MiniLM-L6-v2: Fast, good quality, 384 dims
|
| 21 |
+
"""
|
| 22 |
+
logger.info(f"Loading embedding model: {model_name}")
|
| 23 |
+
self.model = SentenceTransformer(model_name)
|
| 24 |
+
|
| 25 |
+
self.chroma_client = chromadb.PersistentClient(
|
| 26 |
+
path="data/embeddings/chroma_db")
|
| 27 |
+
|
| 28 |
+
self.use_existing_embeddings = False
|
| 29 |
+
print("ChromaDB initialized at data/embeddings/chroma_db")
|
| 30 |
+
|
| 31 |
+
def create_or_get_collection(self, collection_name: str = "anime_collection"):
|
| 32 |
+
"""Create or get existing collection"""
|
| 33 |
+
try:
|
| 34 |
+
collection = self.chroma_client.get_collection(collection_name)
|
| 35 |
+
logger.info(f"Found existing collection: {collection_name}")
|
| 36 |
+
logger.info(f"Current count: {collection.count()} documents")
|
| 37 |
+
|
| 38 |
+
user_input = input("Reset collection? (y/n): ")
|
| 39 |
+
if user_input.lower() == "y":
|
| 40 |
+
self.chroma_client.delete_collection(collection_name)
|
| 41 |
+
collection = self.chroma_client.create_collection(
|
| 42 |
+
collection_name)
|
| 43 |
+
logger.info("Collection reset")
|
| 44 |
+
else:
|
| 45 |
+
self.use_existing_embeddings = True
|
| 46 |
+
|
| 47 |
+
except:
|
| 48 |
+
collection = self.chroma_client.create_collection(collection_name)
|
| 49 |
+
logger.info(f"Created new collection: {collection_name}")
|
| 50 |
+
|
| 51 |
+
return collection
|
| 52 |
+
|
| 53 |
+
def embed_texts(self, texts: list[str], batch_size: int = 32) -> list[list[float]] | None:
|
| 54 |
+
"""
|
| 55 |
+
Create embeddings for texts
|
| 56 |
+
|
| 57 |
+
Args:
|
| 58 |
+
texts: List of texts to embed
|
| 59 |
+
batch_size: Process in batches for efficiency
|
| 60 |
+
"""
|
| 61 |
+
|
| 62 |
+
if self.use_existing_embeddings == False:
|
| 63 |
+
logger.info(f"Embedding {len(texts)} texts...")
|
| 64 |
+
|
| 65 |
+
embeddings = self.model.encode(
|
| 66 |
+
sentences=texts,
|
| 67 |
+
batch_size=batch_size,
|
| 68 |
+
show_progress_bar=True,
|
| 69 |
+
convert_to_numpy=True
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
return embeddings.tolist()
|
| 73 |
+
|
| 74 |
+
else:
|
| 75 |
+
logger.info(f"Using existing stored embeddings.")
|
| 76 |
+
|
| 77 |
+
def store_in_chromadb(self, collection, df: pd.DataFrame, embeddings: list[list[float]]):
|
| 78 |
+
"""
|
| 79 |
+
Store embeddings and metadata in ChromaDB
|
| 80 |
+
|
| 81 |
+
Args:
|
| 82 |
+
collection: ChromaDB collection,
|
| 83 |
+
df: DataFrame with anime data,
|
| 84 |
+
embeddings: Pre_commputed embeddings
|
| 85 |
+
"""
|
| 86 |
+
|
| 87 |
+
logger.info("Storing in ChromaDB...")
|
| 88 |
+
|
| 89 |
+
ids = [str(mal_id) for mal_id in df["mal_id"].tolist()]
|
| 90 |
+
documents = df["searchable_text"].tolist()
|
| 91 |
+
|
| 92 |
+
# Metadata
|
| 93 |
+
metadatas = []
|
| 94 |
+
for _, row in df.iterrows():
|
| 95 |
+
metadata = {
|
| 96 |
+
"title": row["title"],
|
| 97 |
+
"genres": row["genres"],
|
| 98 |
+
"score": float(row["score"]) if pd.notna(row["score"]) else 0.0,
|
| 99 |
+
"type": row["type"] if pd.notna(row["type"]) else "Unknown",
|
| 100 |
+
"year": int(row["year"]) if pd.notna(row["year"]) else 0,
|
| 101 |
+
"synopsis": row["synopsis"][:500],
|
| 102 |
+
"rating": row["rating"] if pd.notna(row["rating"]) else "Rating Unspecified",
|
| 103 |
+
"scored_by": row['scored_by'],
|
| 104 |
+
}
|
| 105 |
+
metadatas.append(metadata)
|
| 106 |
+
|
| 107 |
+
collection.add(
|
| 108 |
+
ids=ids,
|
| 109 |
+
embeddings=embeddings,
|
| 110 |
+
documents=documents,
|
| 111 |
+
metadatas=metadatas
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
logger.info(f"Stored {len(ids)} animes in ChromaDB")
|
| 115 |
+
logger.info(f"Collection now has {collection.count()} documents")
|
| 116 |
+
|
| 117 |
+
def run_pipeline(self, csv_path: str = 'data/processed/anime_clean.csv'):
|
| 118 |
+
"""Run complete embedding pipeline"""
|
| 119 |
+
|
| 120 |
+
logger.info("Loading processed data...")
|
| 121 |
+
df = pd.read_csv(csv_path)
|
| 122 |
+
logger.info(f"Loaded {len(df)} animes")
|
| 123 |
+
|
| 124 |
+
collection = self.create_or_get_collection()
|
| 125 |
+
|
| 126 |
+
if self.use_existing_embeddings == False:
|
| 127 |
+
embeddings = self.embed_texts(df["searchable_text"].tolist())
|
| 128 |
+
if embeddings:
|
| 129 |
+
self.store_in_chromadb(collection, df, embeddings)
|
| 130 |
+
|
| 131 |
+
logger.info("Embedding pipeline complete !")
|
| 132 |
+
|
| 133 |
+
return collection
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
if __name__ == "__main__":
|
| 137 |
+
pipeline = EmbeddingPipeline()
|
| 138 |
+
collection = pipeline.run_pipeline()
|
| 139 |
+
|
| 140 |
+
print("\n--- Testing vector search ---")
|
| 141 |
+
query = "Anime with female main character on adventure"
|
| 142 |
+
|
| 143 |
+
print(f"Query: {query}")
|
| 144 |
+
|
| 145 |
+
results = collection.query(query_texts=[query], n_results=15)
|
| 146 |
+
|
| 147 |
+
print("\n--- TOP 5 RESULTS ---")
|
| 148 |
+
|
| 149 |
+
for i, (title, distance) in enumerate(zip(
|
| 150 |
+
[m["title"] for m in results["metadatas"][0]],
|
| 151 |
+
results["distances"][0]
|
| 152 |
+
)):
|
| 153 |
+
print(f"{i+1}. {title} (distance: {distance:.3f})")
|
src/data_ingestion/fetch_anime.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import requests
|
| 2 |
+
import time
|
| 3 |
+
import json
|
| 4 |
+
import logging
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def convert_datetime(dt: str | None):
|
| 9 |
+
if not dt:
|
| 10 |
+
return None
|
| 11 |
+
return datetime.fromisoformat(dt)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class AnimeDataFetcher:
|
| 18 |
+
"""Fetches anime data from Jikan API (Unofficial MyAnimeList API)"""
|
| 19 |
+
|
| 20 |
+
BASE_URL = "https://api.jikan.moe/v4/"
|
| 21 |
+
|
| 22 |
+
def __init__(self):
|
| 23 |
+
self.session = requests.Session()
|
| 24 |
+
|
| 25 |
+
def fetch_top_anime(self, limit: int = 100) -> list[dict]:
|
| 26 |
+
"""
|
| 27 |
+
Fetches top 'limit' animes from MyAnimeLists
|
| 28 |
+
Args:
|
| 29 |
+
limit: Number of anime to fetch(max ~500 with pagination)
|
| 30 |
+
Returns:
|
| 31 |
+
List of anime dictionaries
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
all_animes = []
|
| 35 |
+
page = 1
|
| 36 |
+
per_page = 25
|
| 37 |
+
|
| 38 |
+
while len(all_animes) < limit:
|
| 39 |
+
try:
|
| 40 |
+
response = self.session.get(
|
| 41 |
+
f"{self.BASE_URL}top/anime",
|
| 42 |
+
params={"page": page, "limit": per_page}
|
| 43 |
+
)
|
| 44 |
+
response.raise_for_status()
|
| 45 |
+
logger.info(
|
| 46 |
+
f"Response received successfully: {response.status_code} !!")
|
| 47 |
+
|
| 48 |
+
data = response.json()
|
| 49 |
+
anime_list = data.get("data", [])
|
| 50 |
+
if not anime_list:
|
| 51 |
+
break
|
| 52 |
+
|
| 53 |
+
all_animes.extend(anime_list)
|
| 54 |
+
logger.info(
|
| 55 |
+
f"Fetched page {page}: {len(anime_list)} animes. Total: {len(all_animes)}")
|
| 56 |
+
|
| 57 |
+
page += 1
|
| 58 |
+
time.sleep(1.5)
|
| 59 |
+
|
| 60 |
+
except Exception as e:
|
| 61 |
+
logger.error(f"Error fetching page {page}: {e}")
|
| 62 |
+
break
|
| 63 |
+
|
| 64 |
+
return all_animes[:limit]
|
| 65 |
+
|
| 66 |
+
def extract_relevant_fields(self, anime: dict) -> dict:
|
| 67 |
+
"""Extract only fields we need for RAG"""
|
| 68 |
+
return {
|
| 69 |
+
"mal_id": anime.get("mal_id"),
|
| 70 |
+
"title": anime.get("title"),
|
| 71 |
+
"title_english": anime.get("title_english"),
|
| 72 |
+
"synopsis": anime.get("synopsis"),
|
| 73 |
+
"genres": [g["name"] for g in anime.get("genres", [])],
|
| 74 |
+
"themes": [t["name"] for t in anime.get("themes", [])],
|
| 75 |
+
"demographics": [d["name"] for d in anime.get("demographics", [])],
|
| 76 |
+
"type": anime.get("type"),
|
| 77 |
+
"episodes": anime.get("episodes"),
|
| 78 |
+
"score": anime.get("score"),
|
| 79 |
+
"scored_by": anime.get("scored_by"),
|
| 80 |
+
"rank": anime.get("rank"),
|
| 81 |
+
"popularity": anime.get("popularity"),
|
| 82 |
+
"year": anime.get("year"),
|
| 83 |
+
"rating": anime.get("rating"),
|
| 84 |
+
"season": anime.get("season"),
|
| 85 |
+
"aired_from": anime.get("aired", {}).get("from", ""),
|
| 86 |
+
"aired_to": anime.get("aired", {}).get("to", ""),
|
| 87 |
+
"favorites": anime.get("favorites")
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
def save_raw_data(self, anime_list: list[dict], filename: str = "raw_anime.json"):
|
| 91 |
+
"""Save raw anime data to file"""
|
| 92 |
+
with open(f"data/raw/{filename}", "w", encoding="utf-8") as f:
|
| 93 |
+
json.dump(anime_list, f, indent=2, ensure_ascii=False)
|
| 94 |
+
|
| 95 |
+
print(f"Saved {len(anime_list)} anime to data/raw/{filename}")
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
if __name__ == "__main__":
|
| 99 |
+
fetcher = AnimeDataFetcher()
|
| 100 |
+
|
| 101 |
+
logger.info("Fetching top anime from MyAnimeList...")
|
| 102 |
+
raw_anime = fetcher.fetch_top_anime(limit=1000)
|
| 103 |
+
|
| 104 |
+
processed_anime = [fetcher.extract_relevant_fields(a) for a in raw_anime]
|
| 105 |
+
|
| 106 |
+
fetcher.save_raw_data(processed_anime)
|
| 107 |
+
|
| 108 |
+
print("\nSample anime: ")
|
| 109 |
+
print(json.dumps(processed_anime[0], indent=2, ensure_ascii=False))
|
src/llm/groq_client.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from groq import Groq
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
import logging
|
| 5 |
+
|
| 6 |
+
load_dotenv()
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class GroqLLM:
|
| 11 |
+
"""Wrapper for Groq API"""
|
| 12 |
+
|
| 13 |
+
def __init__(self, model: str = "llama-3.3-70b-versatile"):
|
| 14 |
+
"""
|
| 15 |
+
Initialize Groq Client
|
| 16 |
+
|
| 17 |
+
Available models:
|
| 18 |
+
- llama-3.3-70b-versatile: Best reasoning, slower
|
| 19 |
+
- llama-3.1-8b-instant: Faster, good enough for most tasks
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
self.client = Groq()
|
| 23 |
+
self.model = model
|
| 24 |
+
|
| 25 |
+
logger.info(f"Initialized Groq with model {self.model}")
|
| 26 |
+
|
| 27 |
+
def generate(
|
| 28 |
+
self,
|
| 29 |
+
prompt: str,
|
| 30 |
+
system_prompt: str = """You are a helpful anime recommendation assistant""",
|
| 31 |
+
temperature: float = 0.7,
|
| 32 |
+
max_tokens: int = 1024
|
| 33 |
+
):
|
| 34 |
+
"""
|
| 35 |
+
Generate completion from prompt
|
| 36 |
+
|
| 37 |
+
Args:
|
| 38 |
+
prompt: User message,
|
| 39 |
+
system_prompt: System instructions,
|
| 40 |
+
temperature: Creativity (0=deterministic, 1=creative),
|
| 41 |
+
max_tokens: Max response length
|
| 42 |
+
"""
|
| 43 |
+
try:
|
| 44 |
+
response = self.client.chat.completions.create(
|
| 45 |
+
model=self.model,
|
| 46 |
+
messages=[
|
| 47 |
+
{"role": "system", "content": system_prompt},
|
| 48 |
+
{"role": "user", "content": prompt}
|
| 49 |
+
],
|
| 50 |
+
temperature=temperature,
|
| 51 |
+
max_tokens=max_tokens
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
return response.choices[0].message.content
|
| 55 |
+
except Exception as e:
|
| 56 |
+
logger.error(f"Groq API error: {e}")
|
| 57 |
+
return "Sorry, I encountered an error generating the response"
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
if __name__ == '__main__':
|
| 61 |
+
llm = GroqLLM()
|
| 62 |
+
|
| 63 |
+
response = llm.generate(prompt="Explain what makes Death Note a good anime",
|
| 64 |
+
temperature=0.9
|
| 65 |
+
)
|
| 66 |
+
print("LLM Response::")
|
| 67 |
+
print(response)
|
src/llm/prompts.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Prompt Templates for anime recommendation system
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def create_recommendation_prompt(
|
| 7 |
+
user_query: str,
|
| 8 |
+
retrieved_animes: list,
|
| 9 |
+
n_recommendations: int | None = 5
|
| 10 |
+
):
|
| 11 |
+
"""
|
| 12 |
+
Create prompt for LLM to reason about recommendations
|
| 13 |
+
|
| 14 |
+
Args:
|
| 15 |
+
user_query: Original user question,
|
| 16 |
+
retrieved_animes: List of dict from vector search,
|
| 17 |
+
n_recommendations: How many to recommend
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
context_parts = []
|
| 21 |
+
for i, anime in enumerate(retrieved_animes, 1):
|
| 22 |
+
context_parts.append(
|
| 23 |
+
f"{i}. **{anime['title']}** (Score: {anime['score']}/10, Scored by: {anime['scored_by']})\n"
|
| 24 |
+
f" Genres: {anime['genres']}\n"
|
| 25 |
+
f" Synopsis: {anime['synopsis']}\n"
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
context = "\n".join(context_parts)
|
| 29 |
+
|
| 30 |
+
prompt = f"""
|
| 31 |
+
You are an expert anime recommendation assistant. A user has asked for recommendations, and I've retrieved some potentially relevant anime from the database.
|
| 32 |
+
Your task is to:
|
| 33 |
+
1. Analyze the user's request carefully, paying attention to specific preferences (tone, themes, etc.)
|
| 34 |
+
2. Evaluate each retrieved anime against their user's criteria
|
| 35 |
+
3. Select the {n_recommendations} BEST matches that truly fit what they're asking for
|
| 36 |
+
4. Explain why each recommendation fits their request
|
| 37 |
+
|
| 38 |
+
User's Query:
|
| 39 |
+
"{user_query}"
|
| 40 |
+
|
| 41 |
+
Retrieved anime from semantic search:
|
| 42 |
+
{context}
|
| 43 |
+
|
| 44 |
+
Instructions:
|
| 45 |
+
- If the user mentioned specific preferences (e.g., "lighter", "darker", "more action"), prioritize those
|
| 46 |
+
- Don't just list all retrieved anime - SELECT the best {n_recommendations} that truly match
|
| 47 |
+
- For each recommendation, explain in 1-2 sentences WHY it matches their request
|
| 48 |
+
- If some retrieved anime DON'T match the user's specific criteria, exclude them
|
| 49 |
+
- Be honest if none of the retrieved anime are great matches
|
| 50 |
+
|
| 51 |
+
Format your response as:
|
| 52 |
+
**Recommendation 1: [Anime Title]**
|
| 53 |
+
[1-2 sentence explanation of why it matches]
|
| 54 |
+
|
| 55 |
+
**Recommendation 2: [Anime Title]**
|
| 56 |
+
[1-2 sentence explanation]
|
| 57 |
+
|
| 58 |
+
[Continue for {n_recommendations} recommendations]
|
| 59 |
+
|
| 60 |
+
If you think the retrieved anime don't match the request well, say so and explain what type of anime would be better."""
|
| 61 |
+
|
| 62 |
+
return prompt
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def create_system_prompt() -> str:
|
| 66 |
+
"""
|
| 67 |
+
System prompt for the anime assistant
|
| 68 |
+
"""
|
| 69 |
+
return """
|
| 70 |
+
You are an expert anime recommendation assistant with deep knowledge of anime themes, genres, and storytelling styles.
|
| 71 |
+
|
| 72 |
+
Your strengths:
|
| 73 |
+
- Understanding nuanced preferences (tone, pacing, themes)
|
| 74 |
+
- Explaining WHY an anime matches a request
|
| 75 |
+
- Being honest when recommendations aren't perfect matches
|
| 76 |
+
|
| 77 |
+
Your approach:
|
| 78 |
+
- Prioritize the user's specific criteria over generic similarity
|
| 79 |
+
- Provide thoughtful, personalized explanations
|
| 80 |
+
- Focus on quality over quantity
|
| 81 |
+
"""
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
if __name__ == '__main__':
|
| 85 |
+
mock_animes = [
|
| 86 |
+
{
|
| 87 |
+
"title": "Code Geass",
|
| 88 |
+
"genres": "Action, Drama, Mecha",
|
| 89 |
+
"score": 8.7,
|
| 90 |
+
"scored_by": 10000,
|
| 91 |
+
"synopsis": "Lelouch, an exiled prince, gains the power of Geass and leads a rebellion against the Britannian Empire..."
|
| 92 |
+
},
|
| 93 |
+
{
|
| 94 |
+
"title": "Monster",
|
| 95 |
+
"genres": "Mystery, Psychological, Thriller",
|
| 96 |
+
"score": 8.9,
|
| 97 |
+
"scored_by": 100,
|
| 98 |
+
"synopsis": "Dr. Tenma saves a young boy's life, only to discover the boy grows up to be a dangerous serial killer..."
|
| 99 |
+
},
|
| 100 |
+
{
|
| 101 |
+
"title": "Classroom of the Elite",
|
| 102 |
+
"genres": "Drama, Psychological",
|
| 103 |
+
"score": 7.9,
|
| 104 |
+
"scored_by": 200000,
|
| 105 |
+
"synopsis": "In an elite school, students compete using strategy and manipulation to climb social ranks..."
|
| 106 |
+
}
|
| 107 |
+
]
|
| 108 |
+
|
| 109 |
+
prompt = create_recommendation_prompt(
|
| 110 |
+
user_query="Anime similar to Death Note but lighter in tone",
|
| 111 |
+
retrieved_animes=mock_animes,
|
| 112 |
+
n_recommendations=3
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
print("Generated Prompt:")
|
| 116 |
+
print("=" * 80)
|
| 117 |
+
print(prompt)
|
| 118 |
+
print("=" * 80)
|
| 119 |
+
|
| 120 |
+
# Save to file for inspection
|
| 121 |
+
with open("example_prompt.txt", "w") as f:
|
| 122 |
+
f.write(prompt)
|
| 123 |
+
|
| 124 |
+
print("\nPrompt saved to example_prompt.txt")
|
src/retrieval/rag_pipeline.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from src.retrieval.vector_search import AnimeRetriever
|
| 2 |
+
from src.llm.groq_client import GroqLLM
|
| 3 |
+
from src.llm.prompts import create_recommendation_prompt, create_system_prompt
|
| 4 |
+
import logging
|
| 5 |
+
|
| 6 |
+
logger = logging.getLogger(__name__)
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class AnimeRAGPipeline:
|
| 10 |
+
def __init__(
|
| 11 |
+
self,
|
| 12 |
+
retriever: AnimeRetriever | None = None,
|
| 13 |
+
llm: GroqLLM | None = None,
|
| 14 |
+
retriever_k: int = 10,
|
| 15 |
+
recommendation_n: int | None = 5
|
| 16 |
+
):
|
| 17 |
+
"""
|
| 18 |
+
Initialize RAG Pipeline
|
| 19 |
+
|
| 20 |
+
Args:
|
| 21 |
+
retriever: AnimeRetriever instance (created if None)
|
| 22 |
+
llm: GroqLLM instance (created if None)
|
| 23 |
+
retrieval_k: How many anime to retrieve from vector search
|
| 24 |
+
recommendation_n: How many to recommend in final output
|
| 25 |
+
"""
|
| 26 |
+
self.retriever = retriever or AnimeRetriever()
|
| 27 |
+
self.llm = llm or GroqLLM(model="llama-3.3-70b-versatile")
|
| 28 |
+
self.retriever_k = retriever_k
|
| 29 |
+
self.recommendation_n = recommendation_n
|
| 30 |
+
|
| 31 |
+
logger.info("RAG Pipeline initialized")
|
| 32 |
+
logger.info(f" - Retrieve top {retriever_k} anime from vector search")
|
| 33 |
+
logger.info(
|
| 34 |
+
f" - LLM reasons and recommends top {recommendation_n} anime")
|
| 35 |
+
|
| 36 |
+
def recommend(
|
| 37 |
+
self,
|
| 38 |
+
user_query: str,
|
| 39 |
+
filters: dict | None = None
|
| 40 |
+
) -> dict:
|
| 41 |
+
"""
|
| 42 |
+
Get anime recommendations from user query
|
| 43 |
+
|
| 44 |
+
Args:
|
| 45 |
+
user_query: User's request (e.g., "Anime like death note for lighter")
|
| 46 |
+
filters: Optional filters (min_score, genre_filter)
|
| 47 |
+
|
| 48 |
+
Returns:
|
| 49 |
+
Dict with:
|
| 50 |
+
- query: original query
|
| 51 |
+
- retrieved_count: how many retrieved
|
| 52 |
+
- recommendations: LLM Response
|
| 53 |
+
- retrieved_anime: raw retrieval results(for debugging)
|
| 54 |
+
"""
|
| 55 |
+
logger.info(f"\n----Processing query: {user_query}-----\n")
|
| 56 |
+
|
| 57 |
+
logger.info(f"\n[1/3] Retrieving from vector database...")
|
| 58 |
+
filters = filters or {}
|
| 59 |
+
|
| 60 |
+
retrieved_animes = self.retriever.search(
|
| 61 |
+
query=user_query,
|
| 62 |
+
n_results=self.retriever_k,
|
| 63 |
+
**filters
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
logger.info(f"\n[2/3] Creating prompt with retrieved content...")
|
| 67 |
+
prompt = create_recommendation_prompt(
|
| 68 |
+
user_query=user_query,
|
| 69 |
+
retrieved_animes=retrieved_animes,
|
| 70 |
+
n_recommendations=self.recommendation_n
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
logger.info(f"\n[3/3] LLM Reasoning about recommendations...")
|
| 74 |
+
system_prompt = create_system_prompt()
|
| 75 |
+
|
| 76 |
+
recommendations = self.llm.generate(
|
| 77 |
+
prompt=prompt,
|
| 78 |
+
system_prompt=system_prompt,
|
| 79 |
+
temperature=0.4,
|
| 80 |
+
max_tokens=1500
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
logger.info("\n---Generated Recommendations---")
|
| 84 |
+
|
| 85 |
+
return {
|
| 86 |
+
"query": user_query,
|
| 87 |
+
"retrieved_count": len(retrieved_animes),
|
| 88 |
+
"recommendations": recommendations,
|
| 89 |
+
"retrieved_animes": retrieved_animes
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
def recommend_streaming(self, user_query: str, filters: dict | None = None):
|
| 93 |
+
"""
|
| 94 |
+
Streaming version for real-time display
|
| 95 |
+
"""
|
| 96 |
+
# Use it in FastAPI later
|
| 97 |
+
return self.recommend(user_query, filters)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
if __name__ == "__main__":
|
| 101 |
+
import json
|
| 102 |
+
|
| 103 |
+
pipeline = AnimeRAGPipeline(
|
| 104 |
+
retriever_k=10,
|
| 105 |
+
recommendation_n=5
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
test_queries = [
|
| 109 |
+
"Anime similar to Death Note but lighter in tone",
|
| 110 |
+
"Romantic comedy set in high school",
|
| 111 |
+
"Dark psychological thriller",
|
| 112 |
+
"Action anime with great animation and epic fights"
|
| 113 |
+
]
|
| 114 |
+
|
| 115 |
+
for query in test_queries:
|
| 116 |
+
result = pipeline.recommend(user_query=query)
|
| 117 |
+
|
| 118 |
+
print(f"-------\nQuery: {query}:::\n-----")
|
| 119 |
+
|
| 120 |
+
print(f"\nRecommendations:\n---------------")
|
| 121 |
+
|
| 122 |
+
print(result["recommendations"])
|
| 123 |
+
|
| 124 |
+
# Save results for inspection
|
| 125 |
+
filename = f"test_result_{query[:30].replace(' ', '_')}.json"
|
| 126 |
+
with open(f"data/{filename}", "w") as f:
|
| 127 |
+
json.dump({
|
| 128 |
+
"query": result["query"],
|
| 129 |
+
"retrieved_titles": [a["title"] for a in result["retrieved_animes"]],
|
| 130 |
+
"recommendations": result["recommendations"]
|
| 131 |
+
}, f, indent=2)
|
| 132 |
+
|
| 133 |
+
print(f"\nSaved to data/{filename}")
|
| 134 |
+
print("\n" + "="*80 + "\n")
|
| 135 |
+
|
| 136 |
+
# Pause between queries to respect rate limits
|
| 137 |
+
import time
|
| 138 |
+
time.sleep(2)
|
src/retrieval/vector_search.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import chromadb
|
| 2 |
+
from sentence_transformers import SentenceTransformer
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class AnimeRetriever:
|
| 6 |
+
"""Handles anime retrieval from ChromaDB"""
|
| 7 |
+
|
| 8 |
+
def __init__(self,
|
| 9 |
+
chroma_path: str = "data/embeddings/chroma_db",
|
| 10 |
+
collection_name: str = "anime_collection",
|
| 11 |
+
model: str = "all-MiniLM-L6-v2"):
|
| 12 |
+
self.client = chromadb.PersistentClient(chroma_path)
|
| 13 |
+
self.collection = self.client.get_collection(collection_name)
|
| 14 |
+
self.model = SentenceTransformer(model)
|
| 15 |
+
|
| 16 |
+
print(f"Loaded collection with {self.collection.count()} anime")
|
| 17 |
+
|
| 18 |
+
def search(
|
| 19 |
+
self,
|
| 20 |
+
query: str,
|
| 21 |
+
n_results: int = 5,
|
| 22 |
+
genre_filter: str | None = None,
|
| 23 |
+
min_score: float | None = None
|
| 24 |
+
) -> list[dict]:
|
| 25 |
+
"""
|
| 26 |
+
Search for anime similar to query
|
| 27 |
+
|
| 28 |
+
Args:
|
| 29 |
+
query: User search query
|
| 30 |
+
n_results: Number of results to return
|
| 31 |
+
genre_filter: Optional genre to filter by
|
| 32 |
+
min_score: Minimum MAL score (e.g., 7.0)
|
| 33 |
+
|
| 34 |
+
Returns:
|
| 35 |
+
List of dicts with anime info
|
| 36 |
+
"""
|
| 37 |
+
where_clause = {}
|
| 38 |
+
if min_score:
|
| 39 |
+
where_clause["score"] = {"$gte": min_score}
|
| 40 |
+
|
| 41 |
+
# Genre Filtering later
|
| 42 |
+
|
| 43 |
+
results = self.collection.query(
|
| 44 |
+
query_texts=[query],
|
| 45 |
+
n_results=n_results * 2 if genre_filter else n_results,
|
| 46 |
+
where=where_clause if where_clause else None
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
anime_list = []
|
| 50 |
+
for i in range(len(results["ids"][0])):
|
| 51 |
+
metadata = results["metadatas"][0][i] # type: ignore
|
| 52 |
+
distance = results["distances"][0][i] # type: ignore
|
| 53 |
+
|
| 54 |
+
# Genre filtering (if specified)
|
| 55 |
+
if genre_filter:
|
| 56 |
+
if genre_filter.lower() not in metadata["genres"].lower(): # type: ignore
|
| 57 |
+
continue
|
| 58 |
+
|
| 59 |
+
anime_info = {
|
| 60 |
+
"mal_id": results["ids"][0][i],
|
| 61 |
+
"title": metadata["title"],
|
| 62 |
+
"genres": metadata["genres"],
|
| 63 |
+
"score": metadata["score"],
|
| 64 |
+
"type": metadata["type"],
|
| 65 |
+
"year": metadata["year"],
|
| 66 |
+
"synopsis": metadata["synopsis"],
|
| 67 |
+
"distance": distance,
|
| 68 |
+
"scored_by": metadata["scored_by"],
|
| 69 |
+
"relevance_score": 1 - distance # Convert distance to similarity
|
| 70 |
+
}
|
| 71 |
+
anime_list.append(anime_info)
|
| 72 |
+
|
| 73 |
+
if len(anime_list) >= n_results:
|
| 74 |
+
break
|
| 75 |
+
|
| 76 |
+
return anime_list
|
| 77 |
+
|
| 78 |
+
def get_by_title(self, title: str) -> dict | None:
|
| 79 |
+
"""Get anime by exact or partial title match"""
|
| 80 |
+
# Search with title as query
|
| 81 |
+
results = self.search(query=title, n_results=1)
|
| 82 |
+
return results[0] if results else None
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
if __name__ == "__main__":
|
| 86 |
+
retriever = AnimeRetriever()
|
| 87 |
+
|
| 88 |
+
# Test queries
|
| 89 |
+
print("=== Test 1: Basic Search ===")
|
| 90 |
+
results = retriever.search("dark psychological anime", n_results=5)
|
| 91 |
+
for anime in results:
|
| 92 |
+
print(
|
| 93 |
+
f"- {anime['title']} (score: {anime['score']}, relevance: {anime['relevance_score']:.3f})")
|
| 94 |
+
|
| 95 |
+
print("\n=== Test 2: Genre Filter ===")
|
| 96 |
+
results = retriever.search(
|
| 97 |
+
"high school", n_results=5, genre_filter="Comedy")
|
| 98 |
+
for anime in results:
|
| 99 |
+
print(f"- {anime['title']} ({anime['genres']})")
|
| 100 |
+
|
| 101 |
+
print("\n=== Test 3: Score Filter ===")
|
| 102 |
+
results = retriever.search("adventure", n_results=5, min_score=8.0)
|
| 103 |
+
for anime in results:
|
| 104 |
+
print(f"- {anime['title']} (score: {anime['score']})")
|
| 105 |
+
|
| 106 |
+
print("\n=== Test 4: Scored by Filter ===")
|
| 107 |
+
results = retriever.search("adventure", n_results=5, min_score=8.0)
|
| 108 |
+
for anime in results:
|
| 109 |
+
print(f"- {anime['title']} (score: {anime['score']}) (scored_by: {anime['scored_by']})")
|
try.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import requests
|
| 2 |
+
from rich import print
|
| 3 |
+
import json
|
| 4 |
+
|
| 5 |
+
url = "https://api.jikan.moe/v4/anime"
|
| 6 |
+
|
| 7 |
+
payload = {"id": {1}}
|
| 8 |
+
|
| 9 |
+
response = requests.get(url=url, params=payload)
|
| 10 |
+
|
| 11 |
+
response_json = response.json()
|
| 12 |
+
|
| 13 |
+
with open("output.json", "w") as f:
|
| 14 |
+
json.dump(response_json, f, indent=4, ensure_ascii=False)
|
ui/gradio_app.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import gradio as gr
|
| 2 |
+
import requests
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
API_URL = os.getenv("API_URL", "http://127.0.0.1:8000")
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def get_recommendations(query, min_score, genre_filter):
|
| 9 |
+
"""Call the fastapi backend"""
|
| 10 |
+
try:
|
| 11 |
+
payload = {
|
| 12 |
+
"query": query,
|
| 13 |
+
"n_results": 8
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
if min_score > 0:
|
| 17 |
+
payload["min_score"] = min_score
|
| 18 |
+
|
| 19 |
+
if genre_filter and genre_filter != None:
|
| 20 |
+
payload["genre_filter"] = genre_filter
|
| 21 |
+
|
| 22 |
+
response = requests.post(
|
| 23 |
+
f"{API_URL}/recommend",
|
| 24 |
+
json=payload,
|
| 25 |
+
timeout=30
|
| 26 |
+
)
|
| 27 |
+
response.raise_for_status()
|
| 28 |
+
|
| 29 |
+
result = response.json()
|
| 30 |
+
print(result["metadata"])
|
| 31 |
+
return result["recommendations"]
|
| 32 |
+
except requests.exceptions.RequestException as e:
|
| 33 |
+
return f"Error connecting to the API. Make sure FastAPI server is running. \nDetails: {str(e)}"
|
| 34 |
+
except Exception as e:
|
| 35 |
+
return f"Error: {str(e)}"
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
with gr.Blocks(title="Anime Recommender") as demo:
|
| 39 |
+
gr.Markdown("""
|
| 40 |
+
# Anime Recommendation System
|
| 41 |
+
|
| 42 |
+
Powered by RAG(Retrieval-Augmented Generation)
|
| 43 |
+
|
| 44 |
+
Ask for anime recommendations and get AI-powered suggestions!
|
| 45 |
+
""")
|
| 46 |
+
with gr.Row():
|
| 47 |
+
with gr.Column(scale=2):
|
| 48 |
+
query_input = gr.Textbox(
|
| 49 |
+
label="What are you looking for?",
|
| 50 |
+
placeholder="e.g., 'Anime similar to Death Note but lighter' or 'Romantic comedy set in high school'",
|
| 51 |
+
lines=3
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
with gr.Row():
|
| 55 |
+
min_score_slider = gr.Slider(
|
| 56 |
+
minimum=0,
|
| 57 |
+
maximum=10,
|
| 58 |
+
value=0,
|
| 59 |
+
step=0.5,
|
| 60 |
+
label="Minimum Rating this animes should have (0 = no filter)"
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
genre_dropdown = gr.Dropdown(
|
| 64 |
+
choices=["None", "Action", "Comedy", "Drama",
|
| 65 |
+
"Romance", "Sci-Fi", "Fantasy", "Thriller"],
|
| 66 |
+
value="None",
|
| 67 |
+
label="Genre Filter (optional)"
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
submit_btn = gr.Button("Get Recommendations",
|
| 71 |
+
variant="primary", size="lg")
|
| 72 |
+
|
| 73 |
+
with gr.Column(scale=3):
|
| 74 |
+
output = gr.Markdown(label="Recommendations")
|
| 75 |
+
|
| 76 |
+
# Examples
|
| 77 |
+
gr.Examples(
|
| 78 |
+
examples=[
|
| 79 |
+
["Anime similar to Death Note but lighter", 0, "None"],
|
| 80 |
+
["Romantic comedy set in high school", 7.5, "Comedy"],
|
| 81 |
+
["Dark psychological thriller", 8.0, "None"],
|
| 82 |
+
["Action anime with epic fights", 7.0, "Action"],
|
| 83 |
+
],
|
| 84 |
+
inputs=[query_input, min_score_slider, genre_dropdown],
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
# Connect button to function
|
| 88 |
+
submit_btn.click(
|
| 89 |
+
fn=get_recommendations,
|
| 90 |
+
inputs=[query_input, min_score_slider, genre_dropdown],
|
| 91 |
+
outputs=output
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
# Launch
|
| 95 |
+
if __name__ == "__main__":
|
| 96 |
+
print("Starting Gradio UI...")
|
| 97 |
+
print("Make sure FastAPI server is running at http://localhost:8000")
|
| 98 |
+
demo.launch(
|
| 99 |
+
server_name="0.0.0.0",
|
| 100 |
+
server_port=7860,
|
| 101 |
+
share=True # Set to True to get public URL
|
| 102 |
+
)
|
uv.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|