Pushkar02-n commited on
Commit
1a2b9e6
·
0 Parent(s):

Phase 1: RAG Pipeline working

Browse files
.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