Spaces:
Running
Running
Commit ·
f69a6fa
1
Parent(s): f2cb2b4
Add Reranker and Optimized Embeddings and metadata by connecting everything with postgres
Browse files- pyproject.toml +1 -1
- src/api/main.py +9 -3
- src/api/schemas.py +3 -1
- src/data_ingestion/create_embeddings.py +65 -42
- src/llm/anime_reranker.py +114 -0
- src/llm/prompts.py +24 -21
- src/retrieval/rag_pipeline.py +95 -32
- src/retrieval/vector_search.py +127 -48
- ui/gradio_app.py +654 -302
- uv.lock +84 -173
pyproject.toml
CHANGED
|
@@ -5,7 +5,7 @@ description = "Add your description here"
|
|
| 5 |
readme = "README.md"
|
| 6 |
requires-python = ">=3.11,<3.14"
|
| 7 |
dependencies = [
|
| 8 |
-
"chromadb>=
|
| 9 |
"fastapi[all]>=0.122.0",
|
| 10 |
"gradio>=6.2.0",
|
| 11 |
"langchain>=1.1.3",
|
|
|
|
| 5 |
readme = "README.md"
|
| 6 |
requires-python = ">=3.11,<3.14"
|
| 7 |
dependencies = [
|
| 8 |
+
"chromadb>=1.5.2",
|
| 9 |
"fastapi[all]>=0.122.0",
|
| 10 |
"gradio>=6.2.0",
|
| 11 |
"langchain>=1.1.3",
|
src/api/main.py
CHANGED
|
@@ -62,7 +62,9 @@ async def get_recommendations(request: RecommendationRequest, fastapi_req: Reque
|
|
| 62 |
{
|
| 63 |
"query": "Anime similar to Death Note but lighter",
|
| 64 |
"n_results": 5,
|
| 65 |
-
"min_score": 7.5
|
|
|
|
|
|
|
| 66 |
}
|
| 67 |
```
|
| 68 |
"""
|
|
@@ -77,6 +79,9 @@ async def get_recommendations(request: RecommendationRequest, fastapi_req: Reque
|
|
| 77 |
|
| 78 |
if request.genre_filter:
|
| 79 |
filters["genre_filter"] = request.genre_filter
|
|
|
|
|
|
|
|
|
|
| 80 |
|
| 81 |
start_time = time.time()
|
| 82 |
result = rag_pipeline.recommend(
|
|
@@ -86,12 +91,13 @@ async def get_recommendations(request: RecommendationRequest, fastapi_req: Reque
|
|
| 86 |
end_time = time.time()
|
| 87 |
|
| 88 |
# print(f"Retrieved anime : \n{result["retrieved_animes"][0]}")
|
| 89 |
-
print(f"Retrieved anime Count : \n{result["
|
| 90 |
print(f"Result Recommendations: \n{result["recommendations"][:20]}")
|
| 91 |
return RecommendationResponse(
|
| 92 |
query=result["query"],
|
| 93 |
recommendations=result["recommendations"],
|
| 94 |
-
retrieved_count=result["
|
|
|
|
| 95 |
metadata={
|
| 96 |
"model": settings.model_name,
|
| 97 |
"retriever_k": rag_pipeline.retriever_k,
|
|
|
|
| 62 |
{
|
| 63 |
"query": "Anime similar to Death Note but lighter",
|
| 64 |
"n_results": 5,
|
| 65 |
+
"min_score": 7.5,
|
| 66 |
+
"genre_filter": ["Comedy", "Fantasy"],
|
| 67 |
+
"anime_type": TV
|
| 68 |
}
|
| 69 |
```
|
| 70 |
"""
|
|
|
|
| 79 |
|
| 80 |
if request.genre_filter:
|
| 81 |
filters["genre_filter"] = request.genre_filter
|
| 82 |
+
|
| 83 |
+
if request.anime_type:
|
| 84 |
+
filters["anime_type"] = request.anime_type
|
| 85 |
|
| 86 |
start_time = time.time()
|
| 87 |
result = rag_pipeline.recommend(
|
|
|
|
| 91 |
end_time = time.time()
|
| 92 |
|
| 93 |
# print(f"Retrieved anime : \n{result["retrieved_animes"][0]}")
|
| 94 |
+
print(f"Retrieved anime Count : \n{result["reranked_count"]}")
|
| 95 |
print(f"Result Recommendations: \n{result["recommendations"][:20]}")
|
| 96 |
return RecommendationResponse(
|
| 97 |
query=result["query"],
|
| 98 |
recommendations=result["recommendations"],
|
| 99 |
+
retrieved_count=result["reranked_count"],
|
| 100 |
+
retrieved_animes=result["retrieved_animes"],
|
| 101 |
metadata={
|
| 102 |
"model": settings.model_name,
|
| 103 |
"retriever_k": rag_pipeline.retriever_k,
|
src/api/schemas.py
CHANGED
|
@@ -7,11 +7,13 @@ class RecommendationRequest(BaseModel):
|
|
| 7 |
5, description="Number of recommendations to return")
|
| 8 |
min_score: float | None = Field(
|
| 9 |
None, description="Minimum MyAnimeList Score filter")
|
| 10 |
-
genre_filter: str | None = Field(None, description="Filter by genre")
|
|
|
|
| 11 |
|
| 12 |
|
| 13 |
class RecommendationResponse(BaseModel):
|
| 14 |
query: str
|
| 15 |
recommendations: str
|
| 16 |
retrieved_count: int
|
|
|
|
| 17 |
metadata: dict = {}
|
|
|
|
| 7 |
5, description="Number of recommendations to return")
|
| 8 |
min_score: float | None = Field(
|
| 9 |
None, description="Minimum MyAnimeList Score filter")
|
| 10 |
+
genre_filter: list[str] | None = Field(None, description="Filter by genre")
|
| 11 |
+
anime_type: str | None = Field(None, description="Filter by type(TV, Movie, etc)")
|
| 12 |
|
| 13 |
|
| 14 |
class RecommendationResponse(BaseModel):
|
| 15 |
query: str
|
| 16 |
recommendations: str
|
| 17 |
retrieved_count: int
|
| 18 |
+
retrieved_animes: list[dict]
|
| 19 |
metadata: dict = {}
|
src/data_ingestion/create_embeddings.py
CHANGED
|
@@ -1,11 +1,15 @@
|
|
| 1 |
-
import pandas as pd
|
| 2 |
from sentence_transformers import SentenceTransformer
|
| 3 |
import chromadb
|
| 4 |
from chromadb.config import Settings
|
| 5 |
-
import
|
| 6 |
import logging
|
| 7 |
|
|
|
|
|
|
|
|
|
|
| 8 |
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
| 9 |
|
| 10 |
|
| 11 |
class EmbeddingPipeline:
|
|
@@ -50,6 +54,14 @@ class EmbeddingPipeline:
|
|
| 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
|
|
@@ -74,62 +86,73 @@ class EmbeddingPipeline:
|
|
| 74 |
else:
|
| 75 |
logger.info(f"Using existing stored embeddings.")
|
| 76 |
|
| 77 |
-
def store_in_chromadb(self, collection,
|
| 78 |
"""
|
| 79 |
Store embeddings and metadata in ChromaDB
|
| 80 |
|
| 81 |
Args:
|
| 82 |
collection: ChromaDB collection,
|
| 83 |
-
|
| 84 |
embeddings: Pre_commputed embeddings
|
| 85 |
"""
|
| 86 |
|
| 87 |
logger.info("Storing in ChromaDB...")
|
| 88 |
|
| 89 |
-
ids = [
|
| 90 |
-
documents =
|
| 91 |
-
|
| 92 |
-
# Metadata
|
| 93 |
metadatas = []
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
metadatas.append(
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
"""Run complete embedding pipeline"""
|
| 119 |
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
logger.info(f"Loaded {len(
|
| 123 |
|
| 124 |
collection = self.create_or_get_collection()
|
| 125 |
|
| 126 |
-
if self.use_existing_embeddings
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
self.store_in_chromadb(collection, df, embeddings)
|
| 130 |
|
| 131 |
-
|
|
|
|
| 132 |
|
|
|
|
| 133 |
return collection
|
| 134 |
|
| 135 |
|
|
@@ -138,13 +161,13 @@ if __name__ == "__main__":
|
|
| 138 |
collection = pipeline.run_pipeline()
|
| 139 |
|
| 140 |
print("\n--- Testing vector search ---")
|
| 141 |
-
query = "Anime
|
| 142 |
|
| 143 |
print(f"Query: {query}")
|
| 144 |
|
| 145 |
results = collection.query(query_texts=[query], n_results=15)
|
| 146 |
|
| 147 |
-
print("\n--- TOP
|
| 148 |
|
| 149 |
for i, (title, distance) in enumerate(zip(
|
| 150 |
[m["title"] for m in results["metadatas"][0]],
|
|
|
|
|
|
|
| 1 |
from sentence_transformers import SentenceTransformer
|
| 2 |
import chromadb
|
| 3 |
from chromadb.config import Settings
|
| 4 |
+
from sqlmodel import Session, select
|
| 5 |
import logging
|
| 6 |
|
| 7 |
+
from src.database.session import engine
|
| 8 |
+
from src.database.models import Animes
|
| 9 |
+
|
| 10 |
logger = logging.getLogger(__name__)
|
| 11 |
+
logging.basicConfig(level=logging.INFO,
|
| 12 |
+
format='%(asctime)s - %(levelname)s - %(message)s')
|
| 13 |
|
| 14 |
|
| 15 |
class EmbeddingPipeline:
|
|
|
|
| 54 |
|
| 55 |
return collection
|
| 56 |
|
| 57 |
+
def fetch_data_from_postgres(self):
|
| 58 |
+
"""Fetch all anime records from PostgreSQL"""
|
| 59 |
+
logger.info("Fetching data from PostgreSQL...")
|
| 60 |
+
with Session(engine) as session:
|
| 61 |
+
statement = select(Animes).where(Animes.searchable_text != None)
|
| 62 |
+
results = session.exec(statement).all()
|
| 63 |
+
return results
|
| 64 |
+
|
| 65 |
def embed_texts(self, texts: list[str], batch_size: int = 32) -> list[list[float]] | None:
|
| 66 |
"""
|
| 67 |
Create embeddings for texts
|
|
|
|
| 86 |
else:
|
| 87 |
logger.info(f"Using existing stored embeddings.")
|
| 88 |
|
| 89 |
+
def store_in_chromadb(self, collection, db_records: list[Animes], embeddings: list[list[float]]):
|
| 90 |
"""
|
| 91 |
Store embeddings and metadata in ChromaDB
|
| 92 |
|
| 93 |
Args:
|
| 94 |
collection: ChromaDB collection,
|
| 95 |
+
db_records: List of Anime data retrieved from PostgreSQL database,
|
| 96 |
embeddings: Pre_commputed embeddings
|
| 97 |
"""
|
| 98 |
|
| 99 |
logger.info("Storing in ChromaDB...")
|
| 100 |
|
| 101 |
+
ids = []
|
| 102 |
+
documents = []
|
|
|
|
|
|
|
| 103 |
metadatas = []
|
| 104 |
+
|
| 105 |
+
for row in db_records:
|
| 106 |
+
ids.append(str(row.mal_id))
|
| 107 |
+
documents.append(row.searchable_text)
|
| 108 |
+
|
| 109 |
+
# genres_str = ", ".join(row.genres) if isinstance(
|
| 110 |
+
# row.genres, list) else str(row.genres or "")
|
| 111 |
+
genres_list = row.genres if isinstance(row.genres, list) else []
|
| 112 |
+
if len(genres_list) == 0:
|
| 113 |
+
genres_list = ["Unknown"]
|
| 114 |
+
|
| 115 |
+
metadatas.append({
|
| 116 |
+
"title": row.title,
|
| 117 |
+
"genres": genres_list,
|
| 118 |
+
"score": float(row.score) if row.score else 0.0,
|
| 119 |
+
"type": row.type if row.type else "Unknown",
|
| 120 |
+
"scored_by": row.scored_by if row.scored_by else 0
|
| 121 |
+
})
|
| 122 |
+
print(f"Genre List saved: {", ".join(genres_list)}")
|
| 123 |
+
|
| 124 |
+
chunk_size = 500
|
| 125 |
+
total_chunks = (len(ids) // chunk_size) + 1
|
| 126 |
+
logger.info(f"Inserting into ChromaDB in {total_chunks} batches...")
|
| 127 |
+
|
| 128 |
+
for i in range(0, len(ids), chunk_size):
|
| 129 |
+
collection.add(
|
| 130 |
+
ids=ids[i: i + chunk_size],
|
| 131 |
+
embeddings=embeddings[i: i + chunk_size],
|
| 132 |
+
documents=documents[i: i + chunk_size],
|
| 133 |
+
metadatas=metadatas[i: i + chunk_size]
|
| 134 |
+
)
|
| 135 |
+
logger.info(f"Inserted batch {(i//chunk_size)+1}/{total_chunks}")
|
| 136 |
+
|
| 137 |
+
logger.info(f"✅ Successfully stored {len(ids)} animes in ChromaDB")
|
| 138 |
+
|
| 139 |
+
def run_pipeline(self):
|
| 140 |
"""Run complete embedding pipeline"""
|
| 141 |
|
| 142 |
+
# 1. Fetch from DB instead of CSV
|
| 143 |
+
db_records = self.fetch_data_from_postgres()
|
| 144 |
+
logger.info(f"Loaded {len(db_records)} animes from Postgres")
|
| 145 |
|
| 146 |
collection = self.create_or_get_collection()
|
| 147 |
|
| 148 |
+
if not self.use_existing_embeddings:
|
| 149 |
+
texts_to_embed = [record.searchable_text for record in db_records]
|
| 150 |
+
embeddings = self.embed_texts(texts_to_embed)
|
|
|
|
| 151 |
|
| 152 |
+
if embeddings:
|
| 153 |
+
self.store_in_chromadb(collection, db_records, embeddings)
|
| 154 |
|
| 155 |
+
logger.info("Embedding pipeline complete!")
|
| 156 |
return collection
|
| 157 |
|
| 158 |
|
|
|
|
| 161 |
collection = pipeline.run_pipeline()
|
| 162 |
|
| 163 |
print("\n--- Testing vector search ---")
|
| 164 |
+
query = "Anime similar to Attack Titan"
|
| 165 |
|
| 166 |
print(f"Query: {query}")
|
| 167 |
|
| 168 |
results = collection.query(query_texts=[query], n_results=15)
|
| 169 |
|
| 170 |
+
print("\n--- TOP 15 RESULTS ---")
|
| 171 |
|
| 172 |
for i, (title, distance) in enumerate(zip(
|
| 173 |
[m["title"] for m in results["metadatas"][0]],
|
src/llm/anime_reranker.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
from sentence_transformers import CrossEncoder
|
| 3 |
+
from sklearn.preprocessing import MinMaxScaler
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class AnimeReranker:
|
| 7 |
+
def __init__(self, model_name: str = 'cross-encoder/ms-marco-MiniLM-L-6-v2', device: str = 'cpu'):
|
| 8 |
+
"""
|
| 9 |
+
Initializes the reranker, loading the PyTorch model into memory once.
|
| 10 |
+
"""
|
| 11 |
+
print(f"Loading CrossEncoder model '{model_name}' on {device}...")
|
| 12 |
+
self.reranker = CrossEncoder(model_name, device=device)
|
| 13 |
+
self.scaler = MinMaxScaler()
|
| 14 |
+
|
| 15 |
+
# ----Postgres Global Truth
|
| 16 |
+
self.C = 7.676 # The true weighted average score
|
| 17 |
+
# 50th percentile of votes (Confidence threshold)
|
| 18 |
+
self.bayesian_m = 10875
|
| 19 |
+
|
| 20 |
+
# Hyperparameters for scoring
|
| 21 |
+
self.passion_weight = 50 # Scaling factor for favorites/scored_by
|
| 22 |
+
self.alpha = 0.75 # Weight for Semantic Relevance
|
| 23 |
+
self.beta = 0.25 # Weight for Objective Quality
|
| 24 |
+
|
| 25 |
+
def _calculate_quality_score(self, df: pd.DataFrame) -> pd.DataFrame:
|
| 26 |
+
"""Internal method to calculate the objective quality of the anime."""
|
| 27 |
+
# Calculate Bayesian Average
|
| 28 |
+
c = self.C
|
| 29 |
+
v = df['scored_by']
|
| 30 |
+
r = df['score']
|
| 31 |
+
df['bayesian_score'] = (v / (v + self.bayesian_m)) * \
|
| 32 |
+
r + (self.bayesian_m / (v + self.bayesian_m)) * c
|
| 33 |
+
|
| 34 |
+
# Calculate Passion Rate
|
| 35 |
+
df['passion_rate'] = df['favorites'] / (df['scored_by'] + 1)
|
| 36 |
+
|
| 37 |
+
# Combine into raw quality score
|
| 38 |
+
df['raw_quality_score'] = df['bayesian_score'] + \
|
| 39 |
+
(df['passion_rate'] * self.passion_weight)
|
| 40 |
+
return df
|
| 41 |
+
|
| 42 |
+
# def process(self, user_query: str, retrieved_anime: list[dict], top_k: int = 25) -> tuple[str, pd.DataFrame]:
|
| 43 |
+
def process(self, user_query: str, retrieved_anime: list[dict], top_k: int = 25) -> pd.DataFrame:
|
| 44 |
+
"""
|
| 45 |
+
Public method to rerank, filter, and format the final context for the LLM.
|
| 46 |
+
"""
|
| 47 |
+
if not retrieved_anime:
|
| 48 |
+
return pd.DataFrame()
|
| 49 |
+
|
| 50 |
+
df = pd.DataFrame(retrieved_anime)
|
| 51 |
+
|
| 52 |
+
# 1. Semantic Score (Cross-Encoder)
|
| 53 |
+
cross_input = [[user_query, text] for text in df['searchable_text']]
|
| 54 |
+
df['semantic_score'] = self.reranker.predict(cross_input)
|
| 55 |
+
|
| 56 |
+
# 2. Quality Score (Custom Math)
|
| 57 |
+
df = self._calculate_quality_score(df)
|
| 58 |
+
|
| 59 |
+
# 3. Normalize & Combine
|
| 60 |
+
df[['norm_semantic', 'norm_quality']] = self.scaler.fit_transform(
|
| 61 |
+
df[['semantic_score', 'raw_quality_score']])
|
| 62 |
+
df['final_hybrid_score'] = (
|
| 63 |
+
self.alpha * df['norm_semantic']) + (self.beta * df['norm_quality'])
|
| 64 |
+
|
| 65 |
+
# 4. Sort and Filter
|
| 66 |
+
df_sorted = df.sort_values(
|
| 67 |
+
by='final_hybrid_score', ascending=False).head(top_k)
|
| 68 |
+
|
| 69 |
+
# # 5. Format for LLM
|
| 70 |
+
# llm_context = "Here are the top retrieved anime matches:\n\n"
|
| 71 |
+
# for _, row in df_sorted.iterrows():
|
| 72 |
+
# llm_context += f"Title: {row['title']} (English: {row.get('title_english', 'N/A')})\n"
|
| 73 |
+
# llm_context += f"Genres: {row.get('genres', 'N/A')}\n"
|
| 74 |
+
# llm_context += f"Synopsis: {row.get('synopsis', 'N/A')}\n"
|
| 75 |
+
# llm_context += "-" * 40 + "\n"
|
| 76 |
+
|
| 77 |
+
# return llm_context, df_sorted
|
| 78 |
+
return df_sorted
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
# ==========================================
|
| 82 |
+
# USAGE IN PRODUCTION
|
| 83 |
+
# ==========================================
|
| 84 |
+
# 1. Initialize ONCE during app startup
|
| 85 |
+
if __name__ == "__main__":
|
| 86 |
+
from src.retrieval.vector_search import AnimeRetriever
|
| 87 |
+
reranker_service = AnimeReranker()
|
| 88 |
+
retriever = AnimeRetriever()
|
| 89 |
+
|
| 90 |
+
user_query = "Modern Romantic Comedy Anime"
|
| 91 |
+
vector_db_results = retriever.search(query=user_query,
|
| 92 |
+
n_results=50)
|
| 93 |
+
|
| 94 |
+
print("BEFORE RERANKING")
|
| 95 |
+
print("--------------------------------------------------")
|
| 96 |
+
for res in vector_db_results:
|
| 97 |
+
print(
|
| 98 |
+
f"Title: {res["title"]} (Score: {res["score"]}, Scored by: {res["scored_by"]}) .... Favorites={res["favorites"]}")
|
| 99 |
+
|
| 100 |
+
# 2. Call the `.process()` method during runtime (e.g., inside an API endpoint)
|
| 101 |
+
df = reranker_service.process(user_query,
|
| 102 |
+
vector_db_results,
|
| 103 |
+
top_k=10)
|
| 104 |
+
df_dict_list = df.to_dict(orient="records") if not df.empty else []
|
| 105 |
+
print("\nAFTER RERANKING")
|
| 106 |
+
print("--------------------------------------------------")
|
| 107 |
+
for res in df_dict_list:
|
| 108 |
+
print(
|
| 109 |
+
f"Title: {res["title"]} (Score: {res["score"]}, Scored by: {res["scored_by"]}) Favorites={res["favorites"]}")
|
| 110 |
+
print(f"-> Has Raw Quality Score: {res["raw_quality_score"]}")
|
| 111 |
+
print(f"-> Has Final normalized Quality Score: {res["norm_quality"]}")
|
| 112 |
+
print(f"-> Has Raw Semantic Score: {res["semantic_score"]}")
|
| 113 |
+
print(f"-> Has Final Semantic Score: {res["norm_semantic"]}")
|
| 114 |
+
print(f"-> Has Final Hybrid Score: {res["final_hybrid_score"]}\n")
|
src/llm/prompts.py
CHANGED
|
@@ -35,54 +35,57 @@ You are a strict Gatekeeper for an Anime Recommendation System. Your ONLY job is
|
|
| 35 |
If you are less than 90% sure what the user wants, DO NOT call the tool. Ask for clarification instead.
|
| 36 |
"""
|
| 37 |
|
|
|
|
| 38 |
def create_recommendation_prompt(
|
| 39 |
user_query: str,
|
| 40 |
retrieved_animes: list,
|
| 41 |
n_recommendations: int | None = 5
|
| 42 |
):
|
| 43 |
"""
|
| 44 |
-
Create prompt for LLM to reason about recommendations
|
| 45 |
-
|
| 46 |
-
Args:
|
| 47 |
-
user_query: Original user question,
|
| 48 |
-
retrieved_animes: List of dict from vector search,
|
| 49 |
-
n_recommendations: How many to recommend
|
| 50 |
"""
|
| 51 |
|
| 52 |
context_parts = []
|
| 53 |
-
for i, anime in enumerate(retrieved_animes
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
f"
|
| 57 |
-
f"
|
|
|
|
|
|
|
|
|
|
| 58 |
)
|
|
|
|
| 59 |
|
| 60 |
context = "\n".join(context_parts)
|
| 61 |
|
| 62 |
prompt = f"""
|
| 63 |
-
You are an expert,
|
| 64 |
|
| 65 |
User's Query: "{user_query}"
|
| 66 |
|
| 67 |
-
Available Anime Context (from vector search):
|
| 68 |
{context}
|
| 69 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
# Core Directives:
|
| 71 |
-
1.
|
| 72 |
-
2.
|
| 73 |
-
3.
|
| 74 |
-
4.
|
| 75 |
-
5.
|
| 76 |
-
6. **Honesty**: If the retrieved context doesn't have any genuinely good matches for the user's query, be honest. Tell them the database didn't have a perfect fit and suggest what kind of show they should look for instead.
|
| 77 |
|
| 78 |
# Required Format:
|
| 79 |
**[Anime Title]**
|
| 80 |
-
[Your
|
| 81 |
|
| 82 |
**[Anime Title]**
|
| 83 |
[Your pitch...]
|
| 84 |
|
| 85 |
-
(Limit to {n_recommendations} recommendations maximum.)
|
| 86 |
"""
|
| 87 |
|
| 88 |
return prompt
|
|
|
|
| 35 |
If you are less than 90% sure what the user wants, DO NOT call the tool. Ask for clarification instead.
|
| 36 |
"""
|
| 37 |
|
| 38 |
+
|
| 39 |
def create_recommendation_prompt(
|
| 40 |
user_query: str,
|
| 41 |
retrieved_animes: list,
|
| 42 |
n_recommendations: int | None = 5
|
| 43 |
):
|
| 44 |
"""
|
| 45 |
+
Create prompt for LLM to reason about recommendations with strict anti-hallucination guardrails.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
"""
|
| 47 |
|
| 48 |
context_parts = []
|
| 49 |
+
for i, anime in enumerate(retrieved_animes):
|
| 50 |
+
# We pick the most 'semantic' fields for the LLM to reason with
|
| 51 |
+
block = (
|
| 52 |
+
f"Title: {anime['title']} ({anime['year']})\n"
|
| 53 |
+
f"Type: {anime['type']} | Score: {anime['score']} | Studio: {', '.join(anime['studios'] if isinstance(anime['studios'], list) else [anime.get('studios', 'Unknown')])}\n"
|
| 54 |
+
f"Genres: {', '.join(anime['genres'] if isinstance(anime['genres'], list) else [anime.get('genres', '')])} | Themes: {', '.join(anime['themes'] if isinstance(anime['themes'], list) else [anime.get('themes', '')])}\n"
|
| 55 |
+
f"Synopsis: {anime.get('synopsis', 'No synopsis available.')}\n"
|
| 56 |
+
f"---"
|
| 57 |
)
|
| 58 |
+
context_parts.append(block)
|
| 59 |
|
| 60 |
context = "\n".join(context_parts)
|
| 61 |
|
| 62 |
prompt = f"""
|
| 63 |
+
You are an expert, casual, and friendly anime recommender. Your goal is to give personalized anime recommendations based STRICTLY on the provided database context.
|
| 64 |
|
| 65 |
User's Query: "{user_query}"
|
| 66 |
|
| 67 |
+
Available Anime Context (from vector search & reranker):
|
| 68 |
{context}
|
| 69 |
|
| 70 |
+
IF THE CONTEXT IS EMPTY:
|
| 71 |
+
Politely say you cannot find any matching anime in your current database. DO NOT make up recommendations.
|
| 72 |
+
|
| 73 |
+
ELSE:
|
| 74 |
# Core Directives:
|
| 75 |
+
1. Quality Over Quantity (CRITICAL): You are asked to provide up to {n_recommendations} recommendations. However, if only 1 or 2 anime in the context genuinely fit the user's specific vibe/theme request, ONLY recommend those. Do NOT force connections or hallucinate similarities just to reach the maximum number.
|
| 76 |
+
2. The Sequel Rule: You MUST NOT recommend direct sequels, prequels, recap movies, or specials of the exact anime the user mentioned in their query, UNLESS they explicitly ask for a watch order or more of that exact show.
|
| 77 |
+
3. Ruthless Curation: If the user asks for a specific contrast (e.g., "like Death Note but funny"), actively ignore anime in the context that do not fit that contrast.
|
| 78 |
+
4. Conversational Tone: Speak like a relaxed, highly knowledgeable anime fan chatting with a friend. Never use robotic transitions like "Based on the provided context..." or "Here are your recommendations."
|
| 79 |
+
5. The Pitch: For each pick, write a punchy 2-3 sentence pitch explaining exactly *why* it fits their request. Use specific plot points, themes, or the studio from the provided synopsis to back up your claim.
|
|
|
|
| 80 |
|
| 81 |
# Required Format:
|
| 82 |
**[Anime Title]**
|
| 83 |
+
[Your 2-3 sentence pitch on why it fits the user's exact vibe, drawing directly from the synopsis or themes.]
|
| 84 |
|
| 85 |
**[Anime Title]**
|
| 86 |
[Your pitch...]
|
| 87 |
|
| 88 |
+
(Limit to {n_recommendations} recommendations maximum, but fewer is completely fine if the matches are weak.)
|
| 89 |
"""
|
| 90 |
|
| 91 |
return prompt
|
src/retrieval/rag_pipeline.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import json
|
| 2 |
from src.retrieval.vector_search import AnimeRetriever
|
|
|
|
| 3 |
from src.llm.groq_client import GroqLLM
|
| 4 |
from src.llm.prompts import create_recommendation_prompt, create_system_prompt, ANIME_SEARCH_TOOL, ROUTER_SYSTEM_PROMPT
|
| 5 |
import logging
|
|
@@ -12,8 +13,9 @@ class AnimeRAGPipeline:
|
|
| 12 |
def __init__(
|
| 13 |
self,
|
| 14 |
retriever: AnimeRetriever | None = None,
|
|
|
|
| 15 |
llm: GroqLLM | None = None,
|
| 16 |
-
retriever_k: int =
|
| 17 |
recommendation_n: int | None = 5
|
| 18 |
):
|
| 19 |
"""
|
|
@@ -26,12 +28,14 @@ class AnimeRAGPipeline:
|
|
| 26 |
recommendation_n: How many to recommend in final output
|
| 27 |
"""
|
| 28 |
self.retriever = retriever or AnimeRetriever()
|
|
|
|
| 29 |
self.llm = llm or GroqLLM(model=settings.model_name)
|
| 30 |
self.retriever_k = retriever_k
|
| 31 |
self.recommendation_n = recommendation_n
|
| 32 |
|
| 33 |
logger.info("RAG Pipeline initialized")
|
| 34 |
logger.info(f" - Retrieve top {retriever_k} anime from vector search")
|
|
|
|
| 35 |
logger.info(
|
| 36 |
f" - LLM reasons and recommends top {recommendation_n} anime")
|
| 37 |
|
|
@@ -45,7 +49,7 @@ class AnimeRAGPipeline:
|
|
| 45 |
|
| 46 |
Args:
|
| 47 |
user_query: User's request (e.g., "Anime like death note for lighter")
|
| 48 |
-
filters: Optional filters (min_score, genre_filter)
|
| 49 |
|
| 50 |
Returns:
|
| 51 |
Dict with:
|
|
@@ -58,7 +62,7 @@ class AnimeRAGPipeline:
|
|
| 58 |
filters = filters or {}
|
| 59 |
|
| 60 |
# [STEP 1] The Agentic Decision Call
|
| 61 |
-
logger.info("[1/
|
| 62 |
|
| 63 |
initial_response = self.llm.chat_with_tools(
|
| 64 |
messages=[{"role": "user", "content": user_query}],
|
|
@@ -77,7 +81,7 @@ class AnimeRAGPipeline:
|
|
| 77 |
}
|
| 78 |
if not initial_response.tool_calls:
|
| 79 |
logger.info(
|
| 80 |
-
"[2/
|
| 81 |
return {
|
| 82 |
"query": user_query,
|
| 83 |
"retrieved_count": 0,
|
|
@@ -86,7 +90,7 @@ class AnimeRAGPipeline:
|
|
| 86 |
}
|
| 87 |
|
| 88 |
# [STEP 3] The LLM wants to search. Extract its optimized parameters.
|
| 89 |
-
logger.info("[2/
|
| 90 |
tool_call = initial_response.tool_calls[0]
|
| 91 |
tool_args = json.loads(tool_call.function.arguments)
|
| 92 |
|
|
@@ -94,21 +98,40 @@ class AnimeRAGPipeline:
|
|
| 94 |
|
| 95 |
optimized_query = tool_args.get("optimized_query", user_query)
|
| 96 |
|
|
|
|
| 97 |
retrieved_animes = self.retriever.search(
|
| 98 |
query=optimized_query,
|
| 99 |
n_results=self.retriever_k,
|
| 100 |
**filters
|
| 101 |
)
|
| 102 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
# [STEP 4] The Final Recommendation Call
|
| 104 |
-
logger.info("[
|
| 105 |
prompt = create_recommendation_prompt(
|
| 106 |
user_query=user_query,
|
| 107 |
-
retrieved_animes=
|
| 108 |
n_recommendations=self.recommendation_n
|
| 109 |
)
|
| 110 |
|
| 111 |
-
logger.info("[
|
| 112 |
system_prompt = create_system_prompt()
|
| 113 |
|
| 114 |
recommendations = self.llm.generate(
|
|
@@ -123,8 +146,9 @@ class AnimeRAGPipeline:
|
|
| 123 |
return {
|
| 124 |
"query": user_query,
|
| 125 |
"retrieved_count": len(retrieved_animes),
|
|
|
|
| 126 |
"recommendations": recommendations,
|
| 127 |
-
"retrieved_animes":
|
| 128 |
}
|
| 129 |
|
| 130 |
def recommend_streaming(self, user_query: str, filters: dict | None = None):
|
|
@@ -137,40 +161,79 @@ class AnimeRAGPipeline:
|
|
| 137 |
|
| 138 |
if __name__ == "__main__":
|
| 139 |
import json
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
|
| 141 |
pipeline = AnimeRAGPipeline(
|
| 142 |
-
retriever_k=
|
| 143 |
recommendation_n=5
|
| 144 |
)
|
| 145 |
|
| 146 |
test_queries = [
|
| 147 |
"Anime similar to Death Note but lighter in tone",
|
| 148 |
-
"
|
| 149 |
-
"
|
| 150 |
-
"Action anime with great animation and epic fights"
|
| 151 |
]
|
| 152 |
|
| 153 |
for query in test_queries:
|
| 154 |
-
|
|
|
|
|
|
|
| 155 |
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
print(f"\nRecommendations:\n---------------")
|
| 159 |
-
|
| 160 |
-
print(result["recommendations"])
|
| 161 |
-
|
| 162 |
-
# Save results for inspection
|
| 163 |
-
filename = f"test_result_{query[:30].replace(' ', '_')}.json"
|
| 164 |
-
with open(f"data/{filename}", "w") as f:
|
| 165 |
-
json.dump({
|
| 166 |
-
"query": result["query"],
|
| 167 |
-
"retrieved_titles": [a["title"] for a in result["retrieved_animes"]],
|
| 168 |
-
"recommendations": result["recommendations"]
|
| 169 |
-
}, f, indent=2)
|
| 170 |
|
| 171 |
-
|
| 172 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
|
| 174 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
import time
|
| 176 |
-
time.sleep(
|
|
|
|
| 1 |
import json
|
| 2 |
from src.retrieval.vector_search import AnimeRetriever
|
| 3 |
+
from src.llm.anime_reranker import AnimeReranker
|
| 4 |
from src.llm.groq_client import GroqLLM
|
| 5 |
from src.llm.prompts import create_recommendation_prompt, create_system_prompt, ANIME_SEARCH_TOOL, ROUTER_SYSTEM_PROMPT
|
| 6 |
import logging
|
|
|
|
| 13 |
def __init__(
|
| 14 |
self,
|
| 15 |
retriever: AnimeRetriever | None = None,
|
| 16 |
+
reranker: AnimeReranker | None = None,
|
| 17 |
llm: GroqLLM | None = None,
|
| 18 |
+
retriever_k: int = 50,
|
| 19 |
recommendation_n: int | None = 5
|
| 20 |
):
|
| 21 |
"""
|
|
|
|
| 28 |
recommendation_n: How many to recommend in final output
|
| 29 |
"""
|
| 30 |
self.retriever = retriever or AnimeRetriever()
|
| 31 |
+
self.reranker = reranker or AnimeReranker()
|
| 32 |
self.llm = llm or GroqLLM(model=settings.model_name)
|
| 33 |
self.retriever_k = retriever_k
|
| 34 |
self.recommendation_n = recommendation_n
|
| 35 |
|
| 36 |
logger.info("RAG Pipeline initialized")
|
| 37 |
logger.info(f" - Retrieve top {retriever_k} anime from vector search")
|
| 38 |
+
logger.info(f" - Rerank and filter using Cross-Encoder & Bayesian Math")
|
| 39 |
logger.info(
|
| 40 |
f" - LLM reasons and recommends top {recommendation_n} anime")
|
| 41 |
|
|
|
|
| 49 |
|
| 50 |
Args:
|
| 51 |
user_query: User's request (e.g., "Anime like death note for lighter")
|
| 52 |
+
filters: Optional filters (min_score, genre_filter, anime_type)
|
| 53 |
|
| 54 |
Returns:
|
| 55 |
Dict with:
|
|
|
|
| 62 |
filters = filters or {}
|
| 63 |
|
| 64 |
# [STEP 1] The Agentic Decision Call
|
| 65 |
+
logger.info("[1/5] Asking LLM if it needs to search...")
|
| 66 |
|
| 67 |
initial_response = self.llm.chat_with_tools(
|
| 68 |
messages=[{"role": "user", "content": user_query}],
|
|
|
|
| 81 |
}
|
| 82 |
if not initial_response.tool_calls:
|
| 83 |
logger.info(
|
| 84 |
+
"[2/5] No search needed. Returning conversational response.")
|
| 85 |
return {
|
| 86 |
"query": user_query,
|
| 87 |
"retrieved_count": 0,
|
|
|
|
| 90 |
}
|
| 91 |
|
| 92 |
# [STEP 3] The LLM wants to search. Extract its optimized parameters.
|
| 93 |
+
logger.info("[2/5] Tool called! Executing vector search...")
|
| 94 |
tool_call = initial_response.tool_calls[0]
|
| 95 |
tool_args = json.loads(tool_call.function.arguments)
|
| 96 |
|
|
|
|
| 98 |
|
| 99 |
optimized_query = tool_args.get("optimized_query", user_query)
|
| 100 |
|
| 101 |
+
# 3A: Fetch Top >=50 Anime from Chromadb
|
| 102 |
retrieved_animes = self.retriever.search(
|
| 103 |
query=optimized_query,
|
| 104 |
n_results=self.retriever_k,
|
| 105 |
**filters
|
| 106 |
)
|
| 107 |
|
| 108 |
+
# 3B: Rerank the Top 50 Using Pytorch + Math
|
| 109 |
+
logger.info("[3/5] Reranking results with Cross-Encoder")
|
| 110 |
+
|
| 111 |
+
reranked_df = self.reranker.process(
|
| 112 |
+
user_query=optimized_query,
|
| 113 |
+
retrieved_anime=retrieved_animes,
|
| 114 |
+
top_k=15
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
top_animes_list = reranked_df.to_dict(
|
| 118 |
+
orient="records") if not reranked_df.empty else []
|
| 119 |
+
print("FIRST IN TOP ANIMES LIST AFTER RERANKING \n",
|
| 120 |
+
top_animes_list[0])
|
| 121 |
+
print("SECOND IN TOP ANIMES LIST AFTER RERANKING \n",
|
| 122 |
+
top_animes_list[1])
|
| 123 |
+
print(
|
| 124 |
+
f"After reranking, fetched {len(top_animes_list)} top animes....")
|
| 125 |
+
|
| 126 |
# [STEP 4] The Final Recommendation Call
|
| 127 |
+
logger.info("[4/5] Creating prompt with retrieved content...")
|
| 128 |
prompt = create_recommendation_prompt(
|
| 129 |
user_query=user_query,
|
| 130 |
+
retrieved_animes=top_animes_list,
|
| 131 |
n_recommendations=self.recommendation_n
|
| 132 |
)
|
| 133 |
|
| 134 |
+
logger.info("[5/5] LLM generating final response...")
|
| 135 |
system_prompt = create_system_prompt()
|
| 136 |
|
| 137 |
recommendations = self.llm.generate(
|
|
|
|
| 146 |
return {
|
| 147 |
"query": user_query,
|
| 148 |
"retrieved_count": len(retrieved_animes),
|
| 149 |
+
"reranked_count": len(top_animes_list),
|
| 150 |
"recommendations": recommendations,
|
| 151 |
+
"retrieved_animes": top_animes_list
|
| 152 |
}
|
| 153 |
|
| 154 |
def recommend_streaming(self, user_query: str, filters: dict | None = None):
|
|
|
|
| 161 |
|
| 162 |
if __name__ == "__main__":
|
| 163 |
import json
|
| 164 |
+
import os
|
| 165 |
+
|
| 166 |
+
# Ensure the data directory exists
|
| 167 |
+
os.makedirs("data", exist_ok=True)
|
| 168 |
|
| 169 |
pipeline = AnimeRAGPipeline(
|
| 170 |
+
retriever_k=50,
|
| 171 |
recommendation_n=5
|
| 172 |
)
|
| 173 |
|
| 174 |
test_queries = [
|
| 175 |
"Anime similar to Death Note but lighter in tone",
|
| 176 |
+
"A really obscure and weird sci-fi mecha from the 90s", # Test the hidden gem / passion rate
|
| 177 |
+
"A generic isekai with an overpowered main character" # Test the mainstream math
|
|
|
|
| 178 |
]
|
| 179 |
|
| 180 |
for query in test_queries:
|
| 181 |
+
print(f"\n" + "="*80)
|
| 182 |
+
print(f"TESTING QUERY: '{query}'")
|
| 183 |
+
print("="*80)
|
| 184 |
|
| 185 |
+
# 1. Run the pipeline (This executes the LLM routing, retrieval, reranking, and generation)
|
| 186 |
+
result = pipeline.recommend(user_query=query)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
|
| 188 |
+
# 2. Extract the data for the diagnostic report
|
| 189 |
+
# We need to reach into the pipeline's retriever to see what the raw ChromaDB output was,
|
| 190 |
+
# since the pipeline only returns the *reranked* list.
|
| 191 |
+
# Note: If the LLM chose NOT to search, result["retrieved_count"] will be 0.
|
| 192 |
+
|
| 193 |
+
diagnostic_data = {
|
| 194 |
+
"1_initial_user_query": result["query"],
|
| 195 |
+
"2_llm_routing_decision": "Searched" if result["retrieved_count"] > 0 else "Chatted",
|
| 196 |
+
"3_total_retrieved_from_chroma": result["retrieved_count"],
|
| 197 |
+
"4_total_survived_reranking": result.get("reranked_count", 0),
|
| 198 |
+
"5_final_llm_recommendation_text": result["recommendations"],
|
| 199 |
+
|
| 200 |
+
# The "Before" state: We want to see what ChromaDB found just using embeddings
|
| 201 |
+
"6_raw_chroma_results_before_reranking": [],
|
| 202 |
+
|
| 203 |
+
# The "After" state: We want to see the exact math scores for the top survivors
|
| 204 |
+
"7_reranked_results_with_math": []
|
| 205 |
+
}
|
| 206 |
|
| 207 |
+
# If the LLM actually performed a search, let's build the detailed lists
|
| 208 |
+
if result["retrieved_count"] > 0:
|
| 209 |
+
|
| 210 |
+
# We need to get the optimized query that the LLM generated for the tool call.
|
| 211 |
+
# (In a real app, you might want to return `optimized_query` in the `result` dict from `recommend()`)
|
| 212 |
+
# For this test, we will just assume it's the original query if we can't easily grab it here.
|
| 213 |
+
|
| 214 |
+
# Let's format the top 15 Reranked items with all their math exposed
|
| 215 |
+
for rank, anime in enumerate(result["retrieved_animes"]):
|
| 216 |
+
diagnostic_data["7_reranked_results_with_math"].append({
|
| 217 |
+
"rank": rank + 1,
|
| 218 |
+
"title": anime["title"],
|
| 219 |
+
"hybrid_score_final": round(anime.get("final_hybrid_score", 0), 4),
|
| 220 |
+
"semantic_score_raw": round(anime.get("semantic_score", 0), 4),
|
| 221 |
+
"quality_score_raw": round(anime.get("raw_quality_score", 0), 4),
|
| 222 |
+
"bayesian_average": round(anime.get("bayesian_score", 0), 4),
|
| 223 |
+
"passion_rate": round(anime.get("passion_rate", 0), 5),
|
| 224 |
+
"chroma_distance": round(1 - anime.get("relevance_score", 1), 4) # Showing original vector distance
|
| 225 |
+
})
|
| 226 |
+
|
| 227 |
+
# 3. Save the diagnostic report
|
| 228 |
+
safe_filename = f"diagnostic_{query[:20].replace(' ', '_').lower()}.json"
|
| 229 |
+
filepath = os.path.join("data", safe_filename)
|
| 230 |
+
|
| 231 |
+
with open(filepath, "w") as f:
|
| 232 |
+
json.dump(diagnostic_data, f, indent=2)
|
| 233 |
+
|
| 234 |
+
print(f"\n✅ Diagnostic saved to {filepath}")
|
| 235 |
+
print(f"Generated Output:\n{result['recommendations']}")
|
| 236 |
+
|
| 237 |
+
# Pause to respect Groq API rate limits
|
| 238 |
import time
|
| 239 |
+
time.sleep(3)
|
src/retrieval/vector_search.py
CHANGED
|
@@ -1,5 +1,14 @@
|
|
| 1 |
import chromadb
|
| 2 |
from sentence_transformers import SentenceTransformer
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
|
| 5 |
class AnimeRetriever:
|
|
@@ -15,12 +24,22 @@ class AnimeRetriever:
|
|
| 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 =
|
|
|
|
| 24 |
) -> list[dict]:
|
| 25 |
"""
|
| 26 |
Search for anime similar to query
|
|
@@ -30,50 +49,94 @@ class AnimeRetriever:
|
|
| 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 |
-
|
|
|
|
| 38 |
if min_score:
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
-
|
|
|
|
|
|
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
results = self.collection.query(
|
| 44 |
query_texts=[query],
|
| 45 |
-
n_results=n_results
|
| 46 |
-
where=where_clause
|
| 47 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
anime_list = []
|
| 50 |
-
for i in
|
| 51 |
-
|
| 52 |
-
distance = results["distances"][0][i]
|
| 53 |
|
| 54 |
-
#
|
| 55 |
-
|
| 56 |
-
# type: ignore
|
| 57 |
-
if genre_filter.lower() not in metadata["genres"].lower():
|
| 58 |
-
continue
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
anime_info = {
|
| 61 |
-
"mal_id":
|
| 62 |
-
"
|
| 63 |
-
"
|
| 64 |
-
"
|
| 65 |
-
"
|
| 66 |
-
"
|
| 67 |
-
"
|
| 68 |
-
"
|
| 69 |
-
"
|
| 70 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
}
|
| 72 |
anime_list.append(anime_info)
|
| 73 |
|
| 74 |
-
if len(anime_list) >= n_results:
|
| 75 |
-
break
|
| 76 |
-
|
| 77 |
return anime_list
|
| 78 |
|
| 79 |
def get_by_title(self, title: str) -> dict | None:
|
|
@@ -86,26 +149,42 @@ class AnimeRetriever:
|
|
| 86 |
if __name__ == "__main__":
|
| 87 |
retriever = AnimeRetriever()
|
| 88 |
|
| 89 |
-
# Test queries
|
| 90 |
-
print("=== Test 1: Basic Search ===")
|
| 91 |
-
results = retriever.search("dark psychological anime", n_results=
|
| 92 |
-
for anime in results:
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
print("\n=== Test 2: Genre Filter ===")
|
| 97 |
-
results = retriever.search(
|
| 98 |
-
"high school", n_results=5, genre_filter="Comedy")
|
| 99 |
-
for anime in results:
|
| 100 |
-
print(f"- {anime['title']} ({anime['genres']})")
|
| 101 |
|
| 102 |
-
print("\n=== Test
|
| 103 |
-
results = retriever.search(
|
| 104 |
-
|
| 105 |
-
|
|
|
|
| 106 |
|
| 107 |
-
print("\n=== Test
|
| 108 |
-
results = retriever.search(
|
|
|
|
| 109 |
for anime in results:
|
| 110 |
-
print(
|
| 111 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import chromadb
|
| 2 |
from sentence_transformers import SentenceTransformer
|
| 3 |
+
from src.database.session import engine
|
| 4 |
+
from src.database.models import Animes
|
| 5 |
+
from sqlmodel import Session, select
|
| 6 |
+
import logging
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
logging.basicConfig(level=logging.INFO,
|
| 11 |
+
format='%(asctime)s - %(levelname)s - %(message)s')
|
| 12 |
|
| 13 |
|
| 14 |
class AnimeRetriever:
|
|
|
|
| 24 |
|
| 25 |
print(f"Loaded collection with {self.collection.count()} anime")
|
| 26 |
|
| 27 |
+
def fetch_anime_batch_from_postgres(self, mal_ids: list[int]) -> dict[int, Animes]:
|
| 28 |
+
"""Fetch multiple animes at once and return a dictionary mapped by mal_id"""
|
| 29 |
+
with Session(engine) as session:
|
| 30 |
+
# The .in_() operator acts like SQL's "WHERE mal_id IN (1, 2, 3)"
|
| 31 |
+
statement = select(Animes).where(Animes.mal_id.in_(mal_ids))
|
| 32 |
+
results = session.exec(statement).all()
|
| 33 |
+
|
| 34 |
+
return {anime.mal_id: anime for anime in results}
|
| 35 |
+
|
| 36 |
def search(
|
| 37 |
self,
|
| 38 |
query: str,
|
| 39 |
n_results: int = 5,
|
| 40 |
+
genre_filter: list[str] | None = None,
|
| 41 |
+
min_score: float | None = 6.0,
|
| 42 |
+
anime_type: str | None = None
|
| 43 |
) -> list[dict]:
|
| 44 |
"""
|
| 45 |
Search for anime similar to query
|
|
|
|
| 49 |
n_results: Number of results to return
|
| 50 |
genre_filter: Optional genre to filter by
|
| 51 |
min_score: Minimum MAL score (e.g., 7.0)
|
| 52 |
+
anime_type: Type of Anime (e.g. TV, Movie, etc)
|
| 53 |
|
| 54 |
Returns:
|
| 55 |
List of dicts with anime info
|
| 56 |
"""
|
| 57 |
+
# 1. Build chromadb WHERE clause for filtering
|
| 58 |
+
conditions = [{"scored_by": {"$gte": 9000}}]
|
| 59 |
if min_score:
|
| 60 |
+
logger.info(f"SCORE: Filtered based on min_score: {min_score}")
|
| 61 |
+
conditions.append({"score": {"$gte": min_score}})
|
| 62 |
+
|
| 63 |
+
if anime_type:
|
| 64 |
+
logger.info(
|
| 65 |
+
f"ANIME TYPE: Filtered based on anime_type: {anime_type}")
|
| 66 |
+
conditions.append({"type": {"$eq": anime_type}})
|
| 67 |
|
| 68 |
+
if genre_filter:
|
| 69 |
+
logger.info(
|
| 70 |
+
f"GENRE: Pre-filtering (OR) for genres: {', '.join(genre_filter)}")
|
| 71 |
|
| 72 |
+
genre_or_conditions = [
|
| 73 |
+
{"genres": {"$contains": genre}} for genre in genre_filter]
|
| 74 |
+
|
| 75 |
+
if len(genre_or_conditions) == 1:
|
| 76 |
+
conditions.append(genre_or_conditions[0])
|
| 77 |
+
else:
|
| 78 |
+
conditions.append({"$or": genre_or_conditions})
|
| 79 |
+
|
| 80 |
+
where_clause = None
|
| 81 |
+
if len(conditions) == 1:
|
| 82 |
+
where_clause = conditions[0]
|
| 83 |
+
elif len(conditions) > 1:
|
| 84 |
+
where_clause = {"$and": conditions}
|
| 85 |
+
|
| 86 |
+
# 2. Query ChromaDB
|
| 87 |
results = self.collection.query(
|
| 88 |
query_texts=[query],
|
| 89 |
+
n_results=n_results,
|
| 90 |
+
where=where_clause
|
| 91 |
)
|
| 92 |
+
if not results["ids"][0]:
|
| 93 |
+
return []
|
| 94 |
+
|
| 95 |
+
# 3. Batch fetch from PostgreSQL
|
| 96 |
+
retrieved_ids = [int(id_str) for id_str in results["ids"][0]]
|
| 97 |
+
postgres_data_map = self.fetch_anime_batch_from_postgres(retrieved_ids)
|
| 98 |
|
| 99 |
anime_list = []
|
| 100 |
+
for i, id_str in enumerate(results["ids"][0]):
|
| 101 |
+
mal_id = int(id_str)
|
| 102 |
+
distance = results["distances"][0][i]
|
| 103 |
|
| 104 |
+
# Get the rich data from our Postgres map
|
| 105 |
+
pg_anime = postgres_data_map.get(mal_id)
|
|
|
|
|
|
|
|
|
|
| 106 |
|
| 107 |
+
if not pg_anime:
|
| 108 |
+
logger.warning(
|
| 109 |
+
f"Anime ID {mal_id} found in Chroma but missing in Postgres!")
|
| 110 |
+
continue
|
| 111 |
+
|
| 112 |
+
# Merge Chroma math with Postgres truths
|
| 113 |
anime_info = {
|
| 114 |
+
"mal_id": pg_anime.mal_id,
|
| 115 |
+
"mal_url": pg_anime.url,
|
| 116 |
+
"title": pg_anime.title,
|
| 117 |
+
"title_english": pg_anime.title_english,
|
| 118 |
+
"score": pg_anime.score,
|
| 119 |
+
"scored_by": pg_anime.scored_by,
|
| 120 |
+
"type": pg_anime.type,
|
| 121 |
+
"year": pg_anime.year,
|
| 122 |
+
"genres": pg_anime.genres, # Now an actual list again!
|
| 123 |
+
"studios": pg_anime.studios,
|
| 124 |
+
"themes": pg_anime.themes,
|
| 125 |
+
"demographics": pg_anime.demographics,
|
| 126 |
+
"episodes": pg_anime.episodes,
|
| 127 |
+
"popularity": pg_anime.popularity,
|
| 128 |
+
"rating": pg_anime.rating,
|
| 129 |
+
"aired_from": pg_anime.aired_from,
|
| 130 |
+
"aired_to": pg_anime.aired_to,
|
| 131 |
+
"favorites": pg_anime.favorites,
|
| 132 |
+
"images": pg_anime.images,
|
| 133 |
+
"synopsis": pg_anime.synopsis,
|
| 134 |
+
"searchable_text": pg_anime.searchable_text,
|
| 135 |
+
# Clean rounded score
|
| 136 |
+
"relevance_score": round(1 - distance, 3),
|
| 137 |
}
|
| 138 |
anime_list.append(anime_info)
|
| 139 |
|
|
|
|
|
|
|
|
|
|
| 140 |
return anime_list
|
| 141 |
|
| 142 |
def get_by_title(self, title: str) -> dict | None:
|
|
|
|
| 149 |
if __name__ == "__main__":
|
| 150 |
retriever = AnimeRetriever()
|
| 151 |
|
| 152 |
+
# # Test queries
|
| 153 |
+
# print("=== Test 1: Basic Search ===")
|
| 154 |
+
# results = retriever.search("dark psychological anime", n_results=15)
|
| 155 |
+
# for anime in results:
|
| 156 |
+
# print(
|
| 157 |
+
# f"- {anime['title']} (score: {anime['score']}, relevance: {anime['relevance_score']:.3f})")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
|
| 159 |
+
# print("\n=== Test 2: Genre Filter ===")
|
| 160 |
+
# results = retriever.search(
|
| 161 |
+
# "high school", n_results=30, genre_filter=["Fantasy", "Action", "Comedy", "Adventure"])
|
| 162 |
+
# for anime in results:
|
| 163 |
+
# print(f"- {anime['title']} ({anime['genres']})")
|
| 164 |
|
| 165 |
+
print("\n=== Test 3: Genre Filter ===")
|
| 166 |
+
results = retriever.search(
|
| 167 |
+
"Overpowered Main character", n_results=5, genre_filter=["Adventure"])
|
| 168 |
for anime in results:
|
| 169 |
+
# print(
|
| 170 |
+
# f"- {anime['title']} ({anime['genres']}) (Score: {anime["score"]}) (Scored by: {anime["scored_by"]})")
|
| 171 |
+
print(anime)
|
| 172 |
+
break
|
| 173 |
+
|
| 174 |
+
# print("\n=== Test 3: Score Filter ===")
|
| 175 |
+
# results = retriever.search("adventure", n_results=5, min_score=9.0)
|
| 176 |
+
# for anime in results:
|
| 177 |
+
# print(f"- {anime['title']} (score: {anime['score']})")
|
| 178 |
+
|
| 179 |
+
# print("\n=== Test 4: Scored by Filter ===")
|
| 180 |
+
# results = retriever.search("adventure", n_results=5, min_score=8.0)
|
| 181 |
+
# for anime in results:
|
| 182 |
+
# print(
|
| 183 |
+
# f"- {anime['title']} (score: {anime['score']}) (scored_by: {anime['scored_by']})")
|
| 184 |
+
|
| 185 |
+
# print("\n=== Test 5: TYPE Filter ===")
|
| 186 |
+
# results = retriever.search(
|
| 187 |
+
# "Attack On Titan", n_results=5, anime_type="Special")
|
| 188 |
+
# for anime in results:
|
| 189 |
+
# print(
|
| 190 |
+
# f"- {anime['title']} (Anime Type: {anime['type']})")
|
ui/gradio_app.py
CHANGED
|
@@ -2,416 +2,768 @@ import gradio as gr
|
|
| 2 |
from gradio import themes
|
| 3 |
import requests
|
| 4 |
import os
|
|
|
|
| 5 |
|
| 6 |
API_URL = os.getenv("API_URL", "http://127.0.0.1:8000")
|
| 7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
if not query or not query.strip():
|
| 11 |
return (
|
| 12 |
-
"<div class='
|
| 13 |
-
"
|
| 14 |
)
|
| 15 |
-
|
| 16 |
try:
|
| 17 |
-
payload = {
|
| 18 |
-
"query": query.strip(),
|
| 19 |
-
"n_results": int(n_results)
|
| 20 |
-
}
|
| 21 |
if min_score > 0:
|
| 22 |
payload["min_score"] = float(min_score)
|
| 23 |
-
if genre_filter and
|
| 24 |
payload["genre_filter"] = genre_filter
|
|
|
|
|
|
|
| 25 |
|
| 26 |
response = requests.post(
|
| 27 |
f"{API_URL}/recommend", json=payload, timeout=30)
|
| 28 |
response.raise_for_status()
|
| 29 |
result = response.json()
|
| 30 |
|
| 31 |
-
|
| 32 |
-
retrieved_count = result.get("retrieved_count", 0)
|
| 33 |
-
time_taken = result.get("metadata", {}).get(
|
| 34 |
-
"Time taken for LLM + vector search", "unknown")
|
| 35 |
-
|
| 36 |
-
if not recommendations_text or recommendations_text.strip() == "":
|
| 37 |
return (
|
| 38 |
-
"<div class='
|
| 39 |
-
"Couldn't find anything
|
| 40 |
)
|
| 41 |
-
|
| 42 |
-
header_html = f"""
|
| 43 |
-
<div class='results-dashboard'>
|
| 44 |
-
<div class='metric'>
|
| 45 |
-
<span class='label'>TITLES SCANNED</span>
|
| 46 |
-
<span class='value'>{retrieved_count}</span>
|
| 47 |
-
</div>
|
| 48 |
-
<div class='metric'>
|
| 49 |
-
<span class='label'>SEARCH TIME</span>
|
| 50 |
-
<span class='value'>{time_taken}</span>
|
| 51 |
-
</div>
|
| 52 |
-
<div class='status-badge success'>Search Complete</div>
|
| 53 |
-
</div>
|
| 54 |
-
"""
|
| 55 |
-
return header_html, recommendations_text
|
| 56 |
|
| 57 |
except requests.exceptions.ConnectionError:
|
| 58 |
return (
|
| 59 |
-
"<div class='
|
| 60 |
-
"
|
| 61 |
)
|
| 62 |
except Exception as e:
|
| 63 |
-
return (
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
|
|
|
|
|
|
|
|
|
|
| 66 |
CSS = """
|
| 67 |
-
@import url('https://fonts.googleapis.com/css2?family=
|
| 68 |
|
| 69 |
:root {
|
| 70 |
-
--bg
|
| 71 |
-
--
|
| 72 |
-
--
|
| 73 |
-
--
|
| 74 |
-
--
|
| 75 |
-
--
|
| 76 |
-
--
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
}
|
| 78 |
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
| 83 |
}
|
| 84 |
|
| 85 |
.gradio-container {
|
| 86 |
-
max-width:
|
| 87 |
-
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
}
|
| 90 |
|
| 91 |
-
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
text-align: center;
|
| 94 |
-
margin-bottom: 40px;
|
| 95 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
|
| 97 |
-
.
|
| 98 |
-
font-
|
| 99 |
-
|
| 100 |
-
font-weight: 800;
|
| 101 |
-
color: #ffffff;
|
| 102 |
-
margin: 0 0 8px 0;
|
| 103 |
-
letter-spacing: -1px;
|
| 104 |
}
|
|
|
|
| 105 |
|
| 106 |
-
.
|
| 107 |
-
|
|
|
|
|
|
|
| 108 |
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
border-radius: 24px;
|
| 114 |
-
padding: 8px 8px 8px 16px;
|
| 115 |
-
margin-bottom: 16px;
|
| 116 |
-
display: flex;
|
| 117 |
-
align-items: flex-end;
|
| 118 |
-
transition: all 0.2s ease;
|
| 119 |
-
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
| 120 |
}
|
| 121 |
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
}
|
| 126 |
|
| 127 |
-
/*
|
| 128 |
-
#
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
background:
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
}
|
| 134 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
#query-input textarea {
|
|
|
|
|
|
|
|
|
|
| 136 |
background: transparent !important;
|
|
|
|
| 137 |
border: none !important;
|
| 138 |
box-shadow: none !important;
|
| 139 |
-
color: #ffffff !important;
|
| 140 |
-
font-family: 'Inter', sans-serif !important;
|
| 141 |
-
font-size: 16px !important;
|
| 142 |
-
padding: 12px 0 !important;
|
| 143 |
-
resize: none !important; /* Removes the little drag handle */
|
| 144 |
}
|
| 145 |
|
| 146 |
-
#
|
| 147 |
-
|
|
|
|
|
|
|
| 148 |
border: none !important;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
}
|
| 150 |
|
| 151 |
-
/*
|
| 152 |
-
#
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
}
|
| 170 |
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
transform:
|
| 174 |
-
|
|
|
|
| 175 |
}
|
| 176 |
|
| 177 |
-
|
| 178 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
}
|
|
|
|
| 180 |
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
margin-bottom: 30px;
|
| 185 |
}
|
| 186 |
|
| 187 |
-
.
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
}
|
| 192 |
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
|
|
|
|
|
|
| 199 |
}
|
| 200 |
|
| 201 |
-
|
| 202 |
-
.
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
}
|
| 226 |
-
|
| 227 |
-
.
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
.
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
}
|
| 255 |
-
.
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
}
|
| 260 |
"""
|
| 261 |
|
| 262 |
-
|
|
|
|
|
|
|
|
|
|
| 263 |
<script>
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
if (btn) btn.click();
|
| 279 |
-
}
|
| 280 |
}
|
| 281 |
});
|
| 282 |
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
if (e.shiftKey) {
|
| 291 |
-
// SHIFT + ENTER: We want a new line.
|
| 292 |
-
// Stop Gradio from seeing this and triggering its default "Submit"
|
| 293 |
-
e.stopPropagation();
|
| 294 |
-
e.stopImmediatePropagation();
|
| 295 |
-
// We DO NOT preventDefault, so the browser natively adds the new line.
|
| 296 |
-
}
|
| 297 |
-
else if (!e.ctrlKey && !e.metaKey) {
|
| 298 |
-
// PLAIN ENTER: We want to submit.
|
| 299 |
-
// Stop the browser from making a new line
|
| 300 |
-
e.preventDefault();
|
| 301 |
-
// Stop Gradio from seeing this event at all
|
| 302 |
-
e.stopPropagation();
|
| 303 |
-
e.stopImmediatePropagation();
|
| 304 |
-
|
| 305 |
-
// Click our send button
|
| 306 |
-
let btn = document.querySelector('#send-btn');
|
| 307 |
-
if (btn && btn.tagName !== 'BUTTON') btn = btn.querySelector('button') || btn;
|
| 308 |
-
if (btn) btn.click();
|
| 309 |
-
}
|
| 310 |
-
}
|
| 311 |
}
|
| 312 |
-
}, true);
|
| 313 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
</script>
|
| 315 |
"""
|
| 316 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
|
| 318 |
def create_interface():
|
| 319 |
-
|
|
|
|
|
|
|
|
|
|
| 320 |
|
| 321 |
-
|
| 322 |
-
<div
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
<div class="quick-tag" data-text="Fast-paced cyberpunk action with great animation">Cyberpunk Action</div>
|
| 330 |
-
</div>
|
| 331 |
-
</div>
|
| 332 |
-
""")
|
| 333 |
-
|
| 334 |
-
# 🔥 NEW UNIFIED INPUT BAR 🔥
|
| 335 |
-
with gr.Row(elem_id="chat-input-container"):
|
| 336 |
-
query_input = gr.Textbox(
|
| 337 |
-
label="",
|
| 338 |
-
show_label=False,
|
| 339 |
-
lines=2,
|
| 340 |
-
placeholder="Message the recommender... (e.g., A melancholic sci-fi)",
|
| 341 |
-
elem_id="query-input",
|
| 342 |
-
scale=10
|
| 343 |
-
)
|
| 344 |
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
|
|
|
|
|
|
| 351 |
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
genre_dropdown = gr.Dropdown(
|
| 355 |
-
choices=["Any", "Action", "Sci-Fi", "Slice of Life",
|
| 356 |
-
"Fantasy", "Psychological", "Horror", "Romance"],
|
| 357 |
-
value="Any",
|
| 358 |
-
label="Genre Filter",
|
| 359 |
-
)
|
| 360 |
-
min_score_slider = gr.Slider(
|
| 361 |
-
minimum=0, maximum=10, value=0, step=0.5,
|
| 362 |
-
label="Minimum Rating"
|
| 363 |
-
)
|
| 364 |
-
n_results_slider = gr.Slider(
|
| 365 |
-
minimum=1, maximum=8, value=3, step=1,
|
| 366 |
-
label="Number of Results"
|
| 367 |
-
)
|
| 368 |
|
| 369 |
-
|
| 370 |
-
value="<div class='results-dashboard'><div class='status-badge warning'>Waiting for input</div></div>"
|
| 371 |
-
)
|
| 372 |
|
| 373 |
-
|
| 374 |
-
value="",
|
| 375 |
-
elem_classes="markdown-output"
|
| 376 |
-
)
|
| 377 |
|
| 378 |
-
|
| 379 |
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
|
|
|
|
|
|
|
|
|
| 394 |
|
| 395 |
-
|
| 396 |
|
| 397 |
|
| 398 |
if __name__ == "__main__":
|
| 399 |
demo = create_interface()
|
| 400 |
|
| 401 |
theme = themes.Base(
|
| 402 |
-
font=[themes.GoogleFont("
|
| 403 |
-
"
|
| 404 |
).set(
|
| 405 |
body_background_fill="transparent",
|
| 406 |
block_background_fill="transparent",
|
| 407 |
block_border_width="0px",
|
| 408 |
input_background_fill="transparent",
|
|
|
|
|
|
|
|
|
|
| 409 |
)
|
| 410 |
|
| 411 |
demo.launch(
|
| 412 |
server_name="0.0.0.0",
|
| 413 |
server_port=7860,
|
| 414 |
-
share=
|
| 415 |
css=CSS,
|
| 416 |
-
theme=theme
|
| 417 |
)
|
|
|
|
| 2 |
from gradio import themes
|
| 3 |
import requests
|
| 4 |
import os
|
| 5 |
+
import re
|
| 6 |
|
| 7 |
API_URL = os.getenv("API_URL", "http://127.0.0.1:8000")
|
| 8 |
|
| 9 |
+
ANIME_TYPES = ["Any", "TV", "Movie", "OVA", "ONA",
|
| 10 |
+
"Special", "Music", "CM", "PV", "TV Special"]
|
| 11 |
+
GENRES = [
|
| 12 |
+
"Any", "Action", "Adventure", "Avant Garde", "Award Winning", "Boys Love",
|
| 13 |
+
"Comedy", "Drama", "Ecchi", "Erotica", "Fantasy", "Girls Love", "Gourmet",
|
| 14 |
+
"Hentai", "Horror", "Mystery", "Romance", "Sci-Fi", "Slice of Life", "Sports",
|
| 15 |
+
"Supernatural", "Suspense"
|
| 16 |
+
]
|
| 17 |
+
|
| 18 |
+
SUGGESTIONS = [
|
| 19 |
+
{"label": "🌸 Similar to Naruto",
|
| 20 |
+
"query": "Action packed themed anime similar to Naruto"},
|
| 21 |
+
{"label": "⚔️ Dark fantasy",
|
| 22 |
+
"query": "Dark fantasy with an unreliable narrator"},
|
| 23 |
+
{"label": "🤖 Cyberpunk action",
|
| 24 |
+
"query": "Fast-paced cyberpunk action with great animation"},
|
| 25 |
+
{"label": "💀 Psychological",
|
| 26 |
+
"query": "Psychological thriller that messes with your head"},
|
| 27 |
+
{"label": "💘 Romance drama", "query": "Bittersweet romance that makes you cry"},
|
| 28 |
+
]
|
| 29 |
+
|
| 30 |
+
# ─── HTML builders ────────────────────────────────────────────────────────────
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _img(images: dict) -> str:
|
| 34 |
+
webp = images.get("webp", {}).get("image_url", "")
|
| 35 |
+
jpg = images.get("jpg", {}).get("image_url", "")
|
| 36 |
+
return webp or jpg or ""
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def build_spotlight_card(anime: dict) -> str:
|
| 40 |
+
img = _img(anime.get("images", {}))
|
| 41 |
+
title = anime.get("title", "Unknown")
|
| 42 |
+
eng = anime.get("title_english") or ""
|
| 43 |
+
score = anime.get("score") or "—"
|
| 44 |
+
year = anime.get("year") or "—"
|
| 45 |
+
kind = anime.get("type") or "—"
|
| 46 |
+
eps = anime.get("episodes") or "—"
|
| 47 |
+
genres = anime.get("genres") or []
|
| 48 |
+
studios = anime.get("studios") or []
|
| 49 |
+
url = anime.get("mal_url", "#")
|
| 50 |
+
raw_syn = anime.get("synopsis") or ""
|
| 51 |
+
synopsis = raw_syn[:220].rstrip() + ("..." if len(raw_syn) > 220 else "")
|
| 52 |
+
|
| 53 |
+
genre_pills = "".join(f"<span class='pill'>{g}</span>" for g in genres[:4])
|
| 54 |
+
studio_str = ", ".join(studios[:2]) if studios else "Unknown Studio"
|
| 55 |
+
|
| 56 |
+
eng_html = f"<h4 class='sc-eng'>{eng}</h4>" if eng and eng.strip(
|
| 57 |
+
) and eng != title else ""
|
| 58 |
|
| 59 |
+
try:
|
| 60 |
+
s = float(score)
|
| 61 |
+
sc = "#00e5a0" if s >= 8 else "#fbbf24" if s >= 7 else "#f87171"
|
| 62 |
+
except Exception:
|
| 63 |
+
sc = "#6b7a99"
|
| 64 |
+
|
| 65 |
+
return (
|
| 66 |
+
f"<a href='{url}' target='_blank' class='sc'>"
|
| 67 |
+
f"<div class='sc-poster'>"
|
| 68 |
+
f"<img src='{img}' alt='{title}' loading='lazy' "
|
| 69 |
+
f"onerror=\"this.src='https://placehold.co/280x400/0d1520/00c896?text=?'\"/>"
|
| 70 |
+
f"<div class='sc-badge' style='color:{sc}'>★ {score}</div>"
|
| 71 |
+
f"</div>"
|
| 72 |
+
f"<div class='sc-body'>"
|
| 73 |
+
f"<div class='sc-chips'>"
|
| 74 |
+
f"<span class='mc'>{kind}</span><span class='mc'>{year}</span><span class='mc'>{eps} ep</span>"
|
| 75 |
+
f"</div>"
|
| 76 |
+
f"<h3 class='sc-title'>{title}</h3>"
|
| 77 |
+
f"{eng_html}"
|
| 78 |
+
f"<p class='sc-studio'>{studio_str}</p>"
|
| 79 |
+
f"<p class='sc-synopsis'>{synopsis}</p>"
|
| 80 |
+
f"<div class='pill-row'>{genre_pills}</div>"
|
| 81 |
+
f"<span class='mal-btn'>View on MAL ↗</span>"
|
| 82 |
+
f"</div></a>"
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def build_retrieved_row(anime: dict, idx: int) -> str:
|
| 87 |
+
img = _img(anime.get("images", {}))
|
| 88 |
+
title = anime.get("title", "Unknown")
|
| 89 |
+
score = anime.get("score") or "—"
|
| 90 |
+
year = anime.get("year") or "—"
|
| 91 |
+
kind = anime.get("type") or ""
|
| 92 |
+
genres = (anime.get("genres") or [])[:2]
|
| 93 |
+
url = anime.get("mal_url", "#")
|
| 94 |
+
raw_score = anime.get("relevance_score", 0)
|
| 95 |
+
normalized_score = (raw_score + 1) / 2
|
| 96 |
+
rel_pct = int(normalized_score * 100)
|
| 97 |
+
genre_str = " · ".join(genres)
|
| 98 |
+
|
| 99 |
+
return (
|
| 100 |
+
f"<a href='{url}' target='_blank' class='rr'>"
|
| 101 |
+
f"<span class='rr-idx'>{idx:02d}</span>"
|
| 102 |
+
f"<img src='{img}' alt='{title}' class='rr-thumb' loading='lazy' "
|
| 103 |
+
f"onerror=\"this.src='https://placehold.co/44x62/0d1520/00c896?text=?'\"/>"
|
| 104 |
+
f"<div class='rr-info'>"
|
| 105 |
+
f"<span class='rr-title'>{title}</span>"
|
| 106 |
+
f"<span class='rr-sub'>{kind} · {year} · {genre_str}</span>"
|
| 107 |
+
f"<div class='rr-bar-bg'><div class='rr-bar-fill' style='width:{rel_pct}%'></div></div>"
|
| 108 |
+
f"</div>"
|
| 109 |
+
f"<div class='rr-right'>"
|
| 110 |
+
f"<span class='rr-score'>★ {score}</span>"
|
| 111 |
+
f"<span class='rr-pct'>{rel_pct}%</span>"
|
| 112 |
+
f"</div>"
|
| 113 |
+
f"</a>"
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def format_output(result: dict) -> tuple[str, str]:
|
| 118 |
+
recs_text = result.get("recommendations", "")
|
| 119 |
+
retrieved_count = result.get("retrieved_count", 0)
|
| 120 |
+
time_str = result.get("metadata", {}).get(
|
| 121 |
+
"Time taken for LLM + vector search", "?")
|
| 122 |
+
retrieved = result.get("retrieved_animes", [])
|
| 123 |
+
|
| 124 |
+
header = (
|
| 125 |
+
"<div class='hud'>"
|
| 126 |
+
"<div class='hud-pill ready'>✦ Results ready</div>"
|
| 127 |
+
"<div class='hud-stats'>"
|
| 128 |
+
f"<div class='hud-stat'><span class='hud-n'>{retrieved_count}</span>"
|
| 129 |
+
f"<span class='hud-l'>titles scanned</span></div>"
|
| 130 |
+
"<div class='hud-sep'></div>"
|
| 131 |
+
f"<div class='hud-stat'><span class='hud-n'>{time_str[:4]}s</span>"
|
| 132 |
+
f"<span class='hud-l'>search time</span></div>"
|
| 133 |
+
"</div></div>"
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
sections = re.split(r'\*\*(.+?)\*\*', recs_text)
|
| 137 |
+
title_map = {a.get("title", "").lower(): a for a in retrieved}
|
| 138 |
+
title_map.update(
|
| 139 |
+
{(a.get("title_english") or "").lower(): a for a in retrieved})
|
| 140 |
+
|
| 141 |
+
recs_html = ""
|
| 142 |
+
if len(sections) > 1:
|
| 143 |
+
pre = sections[0].strip()
|
| 144 |
+
if pre:
|
| 145 |
+
recs_html += f"<p class='llm-intro'>{pre}</p>"
|
| 146 |
+
|
| 147 |
+
pairs = list(zip(sections[1::2], sections[2::2]))
|
| 148 |
+
for i, (rec_title, rec_body) in enumerate(pairs):
|
| 149 |
+
rec_title = rec_title.strip()
|
| 150 |
+
rec_body = rec_body.strip().lstrip("\n").rstrip("\n—-").strip()
|
| 151 |
+
|
| 152 |
+
matched = title_map.get(rec_title.lower())
|
| 153 |
+
if not matched:
|
| 154 |
+
for k, v in title_map.items():
|
| 155 |
+
if k and (k in rec_title.lower() or rec_title.lower() in k):
|
| 156 |
+
matched = v
|
| 157 |
+
break
|
| 158 |
+
|
| 159 |
+
card = build_spotlight_card(
|
| 160 |
+
matched) if matched else f"<div class='no-img-title'>{rec_title}</div>"
|
| 161 |
+
delay = f"{i * 0.09:.2f}"
|
| 162 |
+
recs_html += (
|
| 163 |
+
f"<div class='rec-block' style='animation-delay:{delay}s'>"
|
| 164 |
+
f"{card}"
|
| 165 |
+
f"<div class='llm-pitch'>{rec_body}</div>"
|
| 166 |
+
f"</div>"
|
| 167 |
+
)
|
| 168 |
+
else:
|
| 169 |
+
recs_html = f"<div class='convo-msg'>{recs_text}</div>"
|
| 170 |
+
|
| 171 |
+
sidebar = ""
|
| 172 |
+
if retrieved:
|
| 173 |
+
rows = "".join(build_retrieved_row(a, i + 1)
|
| 174 |
+
for i, a in enumerate(retrieved))
|
| 175 |
+
sidebar = (
|
| 176 |
+
"<aside class='sidebar'>"
|
| 177 |
+
"<div class='sidebar-head'>"
|
| 178 |
+
"<span class='sidebar-label'>Vector search</span>"
|
| 179 |
+
f"<span class='sidebar-count'>{retrieved_count}</span>"
|
| 180 |
+
"</div>"
|
| 181 |
+
f"<div class='rr-list'>{rows}</div>"
|
| 182 |
+
"</aside>"
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
body = (
|
| 186 |
+
"<div class='main-layout'>"
|
| 187 |
+
"<section class='recs-col'>"
|
| 188 |
+
"<div class='recs-heading'>Recommendations</div>"
|
| 189 |
+
f"{recs_html}"
|
| 190 |
+
"</section>"
|
| 191 |
+
f"{sidebar}"
|
| 192 |
+
"</div>"
|
| 193 |
+
)
|
| 194 |
+
return header, body
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def get_recommendations(query, min_score, genre_filter, anime_type, n_results):
|
| 198 |
if not query or not query.strip():
|
| 199 |
return (
|
| 200 |
+
"<div class='hud'><div class='hud-pill idle'>Waiting for your prompt...</div></div>",
|
| 201 |
+
""
|
| 202 |
)
|
|
|
|
| 203 |
try:
|
| 204 |
+
payload = {"query": query.strip(), "n_results": int(n_results)}
|
|
|
|
|
|
|
|
|
|
| 205 |
if min_score > 0:
|
| 206 |
payload["min_score"] = float(min_score)
|
| 207 |
+
if genre_filter and "Any" not in genre_filter:
|
| 208 |
payload["genre_filter"] = genre_filter
|
| 209 |
+
if anime_type and anime_type != "Any":
|
| 210 |
+
payload["anime_type"] = anime_type
|
| 211 |
|
| 212 |
response = requests.post(
|
| 213 |
f"{API_URL}/recommend", json=payload, timeout=30)
|
| 214 |
response.raise_for_status()
|
| 215 |
result = response.json()
|
| 216 |
|
| 217 |
+
if not result.get("recommendations", "").strip():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
return (
|
| 219 |
+
"<div class='hud'><div class='hud-pill err'>No matches</div></div>",
|
| 220 |
+
"<p class='convo-msg'>Couldn't find anything for that vibe. Try different keywords.</p>"
|
| 221 |
)
|
| 222 |
+
return format_output(result)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
|
| 224 |
except requests.exceptions.ConnectionError:
|
| 225 |
return (
|
| 226 |
+
"<div class='hud'><div class='hud-pill err'>Offline</div></div>",
|
| 227 |
+
"<p class='convo-msg'>Cannot reach the backend. Is FastAPI running?</p>"
|
| 228 |
)
|
| 229 |
except Exception as e:
|
| 230 |
+
return (
|
| 231 |
+
"<div class='hud'><div class='hud-pill err'>Error</div></div>",
|
| 232 |
+
f"<p class='convo-msg'>Something went wrong: {e}</p>"
|
| 233 |
+
)
|
| 234 |
|
| 235 |
|
| 236 |
+
# =============================================================================
|
| 237 |
+
# CSS (SCALED DOWN TO "AVERAGE" SIZE)
|
| 238 |
+
# =============================================================================
|
| 239 |
CSS = """
|
| 240 |
+
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap');
|
| 241 |
|
| 242 |
:root {
|
| 243 |
+
--bg: #070c12;
|
| 244 |
+
--bg2: #121b29;
|
| 245 |
+
--bg3: #1a2639;
|
| 246 |
+
--bdr: rgba(255,255,255,0.08);
|
| 247 |
+
--acc: #00c896;
|
| 248 |
+
--acc2: #00e8b0;
|
| 249 |
+
--adim: rgba(0,200,150,0.12);
|
| 250 |
+
--aglow: rgba(0,200,150,0.3);
|
| 251 |
+
--amb: #fbbf24;
|
| 252 |
+
--red: #f87171;
|
| 253 |
+
--txt: #dde3f0;
|
| 254 |
+
--mut: #6b8299;
|
| 255 |
+
--f: 'Plus Jakarta Sans', sans-serif;
|
| 256 |
+
--sw: 220px; /* Reduced sidebar width */
|
| 257 |
}
|
| 258 |
|
| 259 |
+
*, *::before, *::after { box-sizing: border-box; }
|
| 260 |
+
|
| 261 |
+
html, body {
|
| 262 |
+
background: var(--bg) !important;
|
| 263 |
+
font-family: var(--f) !important;
|
| 264 |
+
color: var(--txt) !important;
|
| 265 |
+
margin: 0; padding: 0;
|
| 266 |
}
|
| 267 |
|
| 268 |
.gradio-container {
|
| 269 |
+
max-width: 100% !important;
|
| 270 |
+
padding: 0 !important;
|
| 271 |
+
margin: 0 !important;
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
#main-content {
|
| 275 |
+
margin-left: var(--sw);
|
| 276 |
+
width: calc(100% - var(--sw));
|
| 277 |
+
padding: 0;
|
| 278 |
+
min-height: 100vh;
|
| 279 |
+
display: flex;
|
| 280 |
+
flex-direction: column;
|
| 281 |
}
|
| 282 |
|
| 283 |
+
footer { display: none !important; }
|
| 284 |
+
|
| 285 |
+
/* ── HERO BRAND ──────────────────────────────────────────────── */
|
| 286 |
+
.hero-brand {
|
| 287 |
+
display: flex;
|
| 288 |
+
flex-direction: column;
|
| 289 |
+
align-items: center;
|
| 290 |
+
justify-content: center;
|
| 291 |
+
padding: 40px 20px 20px;
|
| 292 |
text-align: center;
|
|
|
|
| 293 |
}
|
| 294 |
+
.hero-logo-row {
|
| 295 |
+
display: flex;
|
| 296 |
+
align-items: center;
|
| 297 |
+
gap: 12px;
|
| 298 |
+
margin-bottom: 6px;
|
| 299 |
+
}
|
| 300 |
+
.hero-icon {
|
| 301 |
+
width: 38px; height: 38px;
|
| 302 |
+
background: linear-gradient(135deg, var(--acc), var(--acc2));
|
| 303 |
+
border-radius: 12px;
|
| 304 |
+
display: flex; align-items: center; justify-content: center;
|
| 305 |
+
font-size: 20px; flex-shrink: 0;
|
| 306 |
+
box-shadow: 0 4px 16px var(--aglow);
|
| 307 |
+
color: #070c12;
|
| 308 |
+
}
|
| 309 |
+
.hero-name { font-size: 24px; font-weight: 800; color: #fff; letter-spacing: -.5px; }
|
| 310 |
+
.hero-name em { font-style: normal; color: var(--acc); }
|
| 311 |
+
|
| 312 |
+
/* ── FIXED SIDEBAR ───────────────────────────────────────────── */
|
| 313 |
+
#anifind-sidebar {
|
| 314 |
+
position: fixed;
|
| 315 |
+
top: 0; left: 0;
|
| 316 |
+
width: var(--sw);
|
| 317 |
+
height: 100vh;
|
| 318 |
+
background: var(--bg);
|
| 319 |
+
border-right: 1px solid var(--bdr);
|
| 320 |
+
display: flex;
|
| 321 |
+
flex-direction: column;
|
| 322 |
+
padding: 30px 20px;
|
| 323 |
+
overflow-y: auto;
|
| 324 |
+
z-index: 999;
|
| 325 |
+
scrollbar-width: none;
|
| 326 |
+
box-shadow: 4px 0 20px rgba(0,0,0,0.2);
|
| 327 |
+
}
|
| 328 |
+
#anifind-sidebar::-webkit-scrollbar { display: none; }
|
| 329 |
|
| 330 |
+
.sb-tagline {
|
| 331 |
+
font-size: 20px; font-weight: 800; color: #fff;
|
| 332 |
+
line-height: 1.3; letter-spacing: -.3px; margin-bottom: 10px;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
}
|
| 334 |
+
.sb-tagline em { font-style: normal; color: var(--acc); }
|
| 335 |
|
| 336 |
+
.sb-sub {
|
| 337 |
+
font-size: 13px; color: var(--mut);
|
| 338 |
+
line-height: 1.5; margin-bottom: 30px;
|
| 339 |
+
}
|
| 340 |
|
| 341 |
+
.sb-sect {
|
| 342 |
+
font-size: 11px; font-weight: 700;
|
| 343 |
+
letter-spacing: 1.2px; text-transform: uppercase;
|
| 344 |
+
color: var(--mut); margin-bottom: 12px;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
}
|
| 346 |
|
| 347 |
+
.chips-col { display: flex; flex-direction: column; gap: 8px; }
|
| 348 |
+
|
| 349 |
+
.chip {
|
| 350 |
+
font-family: var(--f); font-size: 13px; font-weight: 600;
|
| 351 |
+
color: var(--txt); background: var(--bg2);
|
| 352 |
+
border: 1px solid var(--bdr);
|
| 353 |
+
padding: 10px 14px; border-radius: 10px;
|
| 354 |
+
cursor: pointer; text-align: left;
|
| 355 |
+
transition: all .3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
| 356 |
+
}
|
| 357 |
+
.chip:hover {
|
| 358 |
+
color: var(--acc); background: var(--bg3);
|
| 359 |
+
border-color: rgba(0,200,150,.5);
|
| 360 |
+
transform: translateX(4px) scale(1.02);
|
| 361 |
+
box-shadow: 0 4px 12px rgba(0,0,0,0.25);
|
| 362 |
}
|
| 363 |
|
| 364 |
+
/* ── STICKY SEARCH BAR ───────────────────────────────────────── */
|
| 365 |
+
#search-zone {
|
| 366 |
+
position: sticky !important;
|
| 367 |
+
top: 0 !important; z-index: 100;
|
| 368 |
+
background: rgba(7,12,18,0.85) !important;
|
| 369 |
+
backdrop-filter: blur(16px);
|
| 370 |
+
-webkit-backdrop-filter: blur(16px);
|
| 371 |
+
border-bottom: 1px solid var(--bdr) !important;
|
| 372 |
+
padding: 16px 24px !important;
|
| 373 |
+
gap: 12px !important;
|
| 374 |
+
align-items: center !important;
|
| 375 |
}
|
| 376 |
|
| 377 |
+
#query-input {
|
| 378 |
+
background: var(--bg2) !important;
|
| 379 |
+
border: 2px solid var(--bdr) !important;
|
| 380 |
+
border-radius: 12px !important;
|
| 381 |
+
transition: all .3s cubic-bezier(0.16, 1, 0.3, 1) !important;
|
| 382 |
+
}
|
| 383 |
+
#query-input:focus-within {
|
| 384 |
+
border-color: var(--acc) !important;
|
| 385 |
+
box-shadow: 0 0 0 3px var(--adim) !important;
|
| 386 |
+
}
|
| 387 |
#query-input textarea {
|
| 388 |
+
font-family: var(--f) !important;
|
| 389 |
+
font-size: 13px !important;
|
| 390 |
+
padding: 10px !important;
|
| 391 |
background: transparent !important;
|
| 392 |
+
color: #fff !important;
|
| 393 |
border: none !important;
|
| 394 |
box-shadow: none !important;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 395 |
}
|
| 396 |
|
| 397 |
+
#send-btn { min-width: unset !important; }
|
| 398 |
+
#send-btn button {
|
| 399 |
+
background: linear-gradient(135deg, var(--acc), #00ffc8) !important;
|
| 400 |
+
color: #000 !important;
|
| 401 |
border: none !important;
|
| 402 |
+
border-radius: 12px !important;
|
| 403 |
+
height: 42px !important;
|
| 404 |
+
width: 42px !important;
|
| 405 |
+
font-size: 18px !important;
|
| 406 |
+
font-weight: 900 !important;
|
| 407 |
+
cursor: pointer;
|
| 408 |
+
transition: all .3s cubic-bezier(0.34, 1.56, 0.64, 1) !important;
|
| 409 |
+
box-shadow: 0 4px 14px rgba(0, 200, 150, 0.4), inset 0 2px 0 rgba(255,255,255,0.3) !important;
|
| 410 |
+
display: flex; align-items: center; justify-content: center;
|
| 411 |
+
}
|
| 412 |
+
#send-btn button:hover {
|
| 413 |
+
transform: translateY(-2px) scale(1.05) !important;
|
| 414 |
+
box-shadow: 0 8px 20px rgba(0, 200, 150, 0.6) !important;
|
| 415 |
+
}
|
| 416 |
+
#send-btn button:active { transform: scale(0.95) !important; }
|
| 417 |
+
|
| 418 |
+
/* ── FILTER BAR ──────────────────────────────────────────────── */
|
| 419 |
+
#filter-bar {
|
| 420 |
+
background: var(--bg2) !important;
|
| 421 |
+
border: 1px solid var(--bdr) !important;
|
| 422 |
+
border-radius: 14px !important;
|
| 423 |
+
margin: 16px 24px 0 !important;
|
| 424 |
+
padding: 16px 24px !important;
|
| 425 |
+
box-shadow: 0 4px 16px rgba(0,0,0,0.15) !important;
|
| 426 |
+
gap: 16px !important;
|
| 427 |
+
}
|
| 428 |
+
#filter-bar label span {
|
| 429 |
+
color: var(--mut) !important;
|
| 430 |
+
font-weight: 700 !important;
|
| 431 |
+
text-transform: uppercase !important;
|
| 432 |
+
letter-spacing: 1px !important;
|
| 433 |
+
font-size: 11px !important;
|
| 434 |
}
|
| 435 |
|
| 436 |
+
/* ── OUTPUT ZONE ─────────────────────────────────────────────── */
|
| 437 |
+
#output-zone {
|
| 438 |
+
padding: 24px !important;
|
| 439 |
+
flex: 1;
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
.hud {
|
| 443 |
+
display: flex; align-items: center; justify-content: space-between;
|
| 444 |
+
background: var(--bg2); border: 1px solid var(--bdr);
|
| 445 |
+
border-radius: 12px; padding: 12px 20px;
|
| 446 |
+
margin-bottom: 24px;
|
| 447 |
+
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
| 448 |
+
}
|
| 449 |
+
.hud-pill {
|
| 450 |
+
font-size: 13px; font-weight: 700;
|
| 451 |
+
padding: 6px 14px; border-radius: 100px;
|
| 452 |
+
}
|
| 453 |
+
.hud-pill.ready { background: var(--adim); color: var(--acc); border: 1px solid rgba(0,200,150,.3); }
|
| 454 |
+
.hud-pill.idle { background: rgba(251,191,36,.1); color: var(--amb); border: 1px solid rgba(251,191,36,.2); }
|
| 455 |
+
.hud-pill.err { background: rgba(248,113,113,.1); color: var(--red); border: 1px solid rgba(248,113,113,.2); }
|
| 456 |
+
|
| 457 |
+
.hud-stats { display: flex; align-items: center; gap: 16px; }
|
| 458 |
+
.hud-stat { display: flex; flex-direction: column; align-items: flex-end; }
|
| 459 |
+
.hud-n { font-size: 18px; font-weight: 800; color: #fff; line-height: 1; }
|
| 460 |
+
.hud-l { font-size: 11px; font-weight: 600; color: var(--mut); text-transform: uppercase; margin-top: 4px; }
|
| 461 |
+
.hud-sep { width: 1px; height: 28px; background: var(--bdr); }
|
| 462 |
+
|
| 463 |
+
.main-layout {
|
| 464 |
+
display: grid; grid-template-columns: 1fr 350px;
|
| 465 |
+
gap: 24px; align-items: start;
|
| 466 |
}
|
| 467 |
|
| 468 |
+
.recs-heading {
|
| 469 |
+
font-size: 12px; font-weight: 800; letter-spacing: 1.5px;
|
| 470 |
+
text-transform: uppercase; color: var(--mut);
|
| 471 |
+
margin-bottom: 18px; padding-bottom: 10px;
|
| 472 |
+
border-bottom: 2px solid var(--bdr);
|
| 473 |
}
|
| 474 |
|
| 475 |
+
/* ── RECOMMENDATION BLOCK ────────────────────────────────────── */
|
| 476 |
+
.rec-block {
|
| 477 |
+
background: var(--bg2);
|
| 478 |
+
border: 1px solid var(--bdr);
|
| 479 |
+
border-radius: 16px; overflow: hidden; margin-bottom: 20px;
|
| 480 |
+
opacity: 0; transform: translateY(15px);
|
| 481 |
+
animation: up .4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
| 482 |
+
transition: all .3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
| 483 |
+
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
|
| 484 |
+
}
|
| 485 |
+
.rec-block:hover {
|
| 486 |
+
border-color: rgba(0,200,150,.4);
|
| 487 |
+
box-shadow: 0 8px 30px rgba(0,200,150,.15);
|
| 488 |
+
transform: translateY(-4px) scale(1.01);
|
| 489 |
}
|
| 490 |
+
@keyframes up { to { opacity: 1; transform: translateY(0); } }
|
| 491 |
|
| 492 |
+
.sc {
|
| 493 |
+
display: grid; grid-template-columns: 130px 1fr;
|
| 494 |
+
text-decoration: none !important; color: inherit !important;
|
|
|
|
| 495 |
}
|
| 496 |
|
| 497 |
+
.sc-poster {
|
| 498 |
+
position: relative; overflow: hidden;
|
| 499 |
+
background: var(--bg3); min-height: 180px;
|
| 500 |
+
}
|
| 501 |
+
.sc-poster img {
|
| 502 |
+
width: 100%; height: 100%; object-fit: cover;
|
| 503 |
+
display: block; transition: transform .5s cubic-bezier(0.16, 1, 0.3, 1);
|
| 504 |
+
}
|
| 505 |
+
.sc:hover .sc-poster img { transform: scale(1.06); }
|
| 506 |
+
|
| 507 |
+
.sc-badge {
|
| 508 |
+
position: absolute; top: 10px; left: 10px;
|
| 509 |
+
background: rgba(7,12,18,.85); backdrop-filter: blur(8px);
|
| 510 |
+
font-size: 12px; font-weight: 800; padding: 4px 10px;
|
| 511 |
+
border-radius: 100px; border: 1px solid rgba(255,255,255,.15);
|
| 512 |
+
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
| 513 |
}
|
| 514 |
|
| 515 |
+
.sc-body { padding: 16px; display: flex; flex-direction: column; gap: 8px; }
|
| 516 |
+
|
| 517 |
+
.sc-chips { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 2px; }
|
| 518 |
+
.mc {
|
| 519 |
+
font-size: 10px; font-weight: 700; letter-spacing: .5px;
|
| 520 |
+
text-transform: uppercase; color: var(--acc);
|
| 521 |
+
background: var(--adim); border: 1px solid rgba(0,200,150,.25);
|
| 522 |
+
padding: 3px 8px; border-radius: 6px;
|
| 523 |
}
|
| 524 |
|
| 525 |
+
.sc-title { font-size: 17px; font-weight: 800; color: #fff; line-height: 1.2; letter-spacing: -.3px; }
|
| 526 |
+
.sc-eng { font-size: 12px; color: #a0b2c6; font-weight: 600; font-style: italic; margin-top: -2px; }
|
| 527 |
+
.sc-studio { font-size: 12px; color: #8a9bb0; font-weight: 600; }
|
| 528 |
+
.sc-synopsis { font-size: 12.5px; color: #dde3f0; line-height: 1.6; flex: 1; margin-top: 6px; }
|
| 529 |
+
|
| 530 |
+
.pill-row { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 12px; }
|
| 531 |
+
.pill {
|
| 532 |
+
font-size: 12px; font-weight: 600; color: var(--txt);
|
| 533 |
+
background: var(--bg3); border: 1px solid var(--bdr);
|
| 534 |
+
padding: 4px 12px; border-radius: 100px;
|
| 535 |
+
}
|
| 536 |
+
|
| 537 |
+
.mal-btn {
|
| 538 |
+
display: inline-block; margin-top: 14px;
|
| 539 |
+
font-size: 12px; font-weight: 700; color: var(--acc);
|
| 540 |
+
background: var(--adim); border: 1px solid rgba(0,200,150,.3);
|
| 541 |
+
padding: 8px 16px; border-radius: 8px; letter-spacing: .5px;
|
| 542 |
+
text-transform: uppercase; align-self: flex-start;
|
| 543 |
+
transition: all .2s ease;
|
| 544 |
+
}
|
| 545 |
+
.sc:hover .mal-btn {
|
| 546 |
+
background: var(--acc); color: #000;
|
| 547 |
+
transform: translateY(-2px);
|
| 548 |
+
box-shadow: 0 4px 12px rgba(0,200,150,0.3);
|
| 549 |
+
}
|
| 550 |
+
|
| 551 |
+
.llm-pitch {
|
| 552 |
+
padding: 14px 16px; font-size: 14px; color: #fff;
|
| 553 |
+
line-height: 1.6; border-top: 1px solid var(--bdr);
|
| 554 |
+
background: rgba(255,255,255,0.02);
|
| 555 |
+
}
|
| 556 |
+
|
| 557 |
+
/* ── RETRIEVED SIDEBAR ───────────────────────────────────────── */
|
| 558 |
+
.sidebar {
|
| 559 |
+
background: var(--bg2); border: 1px solid var(--bdr);
|
| 560 |
+
border-radius: 16px; padding: 18px;
|
| 561 |
+
position: sticky; top: 110px;
|
| 562 |
+
max-height: calc(100vh - 130px);
|
| 563 |
+
overflow-y: auto; scrollbar-width: thin;
|
| 564 |
+
scrollbar-color: var(--mut) transparent;
|
| 565 |
+
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
|
| 566 |
+
}
|
| 567 |
+
|
| 568 |
+
.sidebar-head {
|
| 569 |
+
display: flex; justify-content: space-between; align-items: center;
|
| 570 |
+
margin-bottom: 16px; padding-bottom: 12px; border-bottom: 1px solid var(--bdr);
|
| 571 |
+
}
|
| 572 |
+
.sidebar-label {
|
| 573 |
+
font-size: 11px; font-weight: 700; letter-spacing: 1px;
|
| 574 |
+
text-transform: uppercase; color: var(--mut);
|
| 575 |
+
}
|
| 576 |
+
.sidebar-count {
|
| 577 |
+
font-size: 12px; font-weight: 700; color: var(--acc);
|
| 578 |
+
background: var(--adim); padding: 3px 10px; border-radius: 100px;
|
| 579 |
+
}
|
| 580 |
+
|
| 581 |
+
.rr-list { display: flex; flex-direction: column; gap: 4px; }
|
| 582 |
+
.rr {
|
| 583 |
+
display: grid; grid-template-columns: 22px 44px 1fr auto;
|
| 584 |
+
gap: 12px; align-items: center; padding: 8px;
|
| 585 |
+
border-radius: 10px; text-decoration: none !important;
|
| 586 |
+
color: inherit !important; transition: all .2s;
|
| 587 |
+
}
|
| 588 |
+
.rr:hover {
|
| 589 |
+
background: var(--bg3);
|
| 590 |
+
transform: translateX(4px);
|
| 591 |
+
}
|
| 592 |
+
|
| 593 |
+
.rr-idx { font-size: 11px; font-weight: 700; color: var(--mut); text-align: center; }
|
| 594 |
+
.rr-thumb {
|
| 595 |
+
width: 44px; height: 62px; object-fit: cover;
|
| 596 |
+
border-radius: 6px; background: var(--bg3); display: block;
|
| 597 |
+
}
|
| 598 |
+
.rr-info { display: flex; flex-direction: column; gap: 4px; overflow: hidden; }
|
| 599 |
+
.rr-title {
|
| 600 |
+
font-size: 13px; font-weight: 700; color: #fff;
|
| 601 |
+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
| 602 |
+
}
|
| 603 |
+
.rr-sub {
|
| 604 |
+
font-size: 11px; font-weight: 500; color: #8a9bb0;
|
| 605 |
+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
| 606 |
}
|
| 607 |
+
.rr-bar-bg { height: 4px; background: var(--bg3); border-radius: 4px; overflow: hidden; margin-top: 2px; }
|
| 608 |
+
.rr-bar-fill {
|
| 609 |
+
height: 100%;
|
| 610 |
+
background: linear-gradient(90deg, var(--acc), var(--acc2));
|
| 611 |
+
border-radius: 4px;
|
| 612 |
+
}
|
| 613 |
+
.rr-right { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; }
|
| 614 |
+
.rr-score { font-size: 12px; font-weight: 700; color: var(--amb); }
|
| 615 |
+
.rr-pct { font-size: 11px; font-weight: 600; color: var(--mut); }
|
| 616 |
+
|
| 617 |
+
/* ── RESPONSIVE DESIGN ───────────────────────────────────────── */
|
| 618 |
+
@media (max-width: 1024px) {
|
| 619 |
+
.main-layout { grid-template-columns: 1fr; }
|
| 620 |
+
.sidebar { position: relative; top: 0; max-height: none; margin-top: 24px; }
|
| 621 |
+
}
|
| 622 |
+
@media (max-width: 860px) {
|
| 623 |
+
#anifind-sidebar { width: 100%; position: relative; height: auto; border-right: none; border-bottom: 1px solid var(--bdr); }
|
| 624 |
+
#main-content { margin-left: 0; width: 100%; }
|
| 625 |
+
.sc { grid-template-columns: 1fr; }
|
| 626 |
+
.sc-poster { min-height: 200px; }
|
| 627 |
}
|
| 628 |
"""
|
| 629 |
|
| 630 |
+
# =============================================================================
|
| 631 |
+
# JAVASCRIPT (INJECTED VIA HEAD TO GUARANTEE EXECUTION)
|
| 632 |
+
# =============================================================================
|
| 633 |
+
JS = """
|
| 634 |
<script>
|
| 635 |
+
function initAniFindJS() {
|
| 636 |
+
document.body.addEventListener('click', function(e) {
|
| 637 |
+
var chip = e.target.closest('.chip');
|
| 638 |
+
if (!chip) return;
|
| 639 |
+
var q = chip.getAttribute('data-q');
|
| 640 |
+
var ta = document.querySelector('#query-input textarea');
|
| 641 |
+
if (ta && q) {
|
| 642 |
+
// Svelte/React native value setter trigger
|
| 643 |
+
var setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
|
| 644 |
+
setter.call(ta, q);
|
| 645 |
+
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
| 646 |
+
|
| 647 |
+
setTimeout(function() {
|
| 648 |
+
var btn = document.querySelector('#send-btn button');
|
| 649 |
if (btn) btn.click();
|
| 650 |
+
}, 100);
|
| 651 |
}
|
| 652 |
});
|
| 653 |
|
| 654 |
+
document.body.addEventListener('keydown', function(e) {
|
| 655 |
+
if (e.target.tagName.toLowerCase() !== 'textarea') return;
|
| 656 |
+
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
| 657 |
+
e.preventDefault();
|
| 658 |
+
e.stopPropagation();
|
| 659 |
+
var btn = document.querySelector('#send-btn button');
|
| 660 |
+
if (btn) btn.click();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 661 |
}
|
| 662 |
+
}, true);
|
| 663 |
+
}
|
| 664 |
+
|
| 665 |
+
// Ensure the script runs after DOM is ready
|
| 666 |
+
if (document.readyState === "loading") {
|
| 667 |
+
document.addEventListener("DOMContentLoaded", initAniFindJS);
|
| 668 |
+
} else {
|
| 669 |
+
initAniFindJS();
|
| 670 |
+
}
|
| 671 |
</script>
|
| 672 |
"""
|
| 673 |
|
| 674 |
+
# =============================================================================
|
| 675 |
+
# GRADIO INTERFACE
|
| 676 |
+
# =============================================================================
|
| 677 |
+
|
| 678 |
|
| 679 |
def create_interface():
|
| 680 |
+
chips_html = "".join(
|
| 681 |
+
f"<button class='chip' data-q='{s['query']}'>{s['label']}</button>"
|
| 682 |
+
for s in SUGGESTIONS
|
| 683 |
+
)
|
| 684 |
|
| 685 |
+
sidebar_html = (
|
| 686 |
+
"<div id='anifind-sidebar'>"
|
| 687 |
+
"<p class='sb-tagline'>Find your next<br><em>favourite anime.</em></p>"
|
| 688 |
+
"<p class='sb-sub'>Describe a vibe, a mood, a feeling — we'll match it to the perfect title.</p>"
|
| 689 |
+
"<div class='sb-sect'>Quick searches</div>"
|
| 690 |
+
f"<div class='chips-col'>{chips_html}</div>"
|
| 691 |
+
"</div>"
|
| 692 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 693 |
|
| 694 |
+
hero_html = (
|
| 695 |
+
"<div class='hero-brand'>"
|
| 696 |
+
"<div class='hero-logo-row'>"
|
| 697 |
+
"<div class='hero-icon'>⛩</div>"
|
| 698 |
+
"<span class='hero-name'>Ani<em>Find</em></span>"
|
| 699 |
+
"</div>"
|
| 700 |
+
"</div>"
|
| 701 |
+
)
|
| 702 |
|
| 703 |
+
# Added `head=JS` to properly inject the event listeners globally
|
| 704 |
+
with gr.Blocks(title="AniFind", head=JS) as demo:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 705 |
|
| 706 |
+
gr.HTML(sidebar_html)
|
|
|
|
|
|
|
| 707 |
|
| 708 |
+
with gr.Column(elem_id="main-content"):
|
|
|
|
|
|
|
|
|
|
| 709 |
|
| 710 |
+
gr.HTML(hero_html)
|
| 711 |
|
| 712 |
+
with gr.Row(elem_id="search-zone"):
|
| 713 |
+
query_input = gr.Textbox(
|
| 714 |
+
label="", show_label=False, lines=2,
|
| 715 |
+
placeholder="e.g. A melancholic sci-fi about memory and loss...",
|
| 716 |
+
elem_id="query-input", scale=10,
|
| 717 |
+
)
|
| 718 |
+
submit_btn = gr.Button("➔", elem_id="send-btn", scale=1)
|
| 719 |
+
|
| 720 |
+
with gr.Row(elem_id="filter-bar"):
|
| 721 |
+
anime_type_dd = gr.Dropdown(
|
| 722 |
+
choices=ANIME_TYPES, value="Any", label="Type", scale=1)
|
| 723 |
+
genre_dd = gr.Dropdown(
|
| 724 |
+
choices=GENRES, value=["Any"], label="Genre",
|
| 725 |
+
multiselect=True, scale=2)
|
| 726 |
+
score_slider = gr.Slider(
|
| 727 |
+
minimum=0, maximum=10, value=0, step=0.5,
|
| 728 |
+
label="Min Rating", scale=1)
|
| 729 |
+
n_slider = gr.Slider(
|
| 730 |
+
minimum=1, maximum=8, value=3, step=1,
|
| 731 |
+
label="Results", scale=1)
|
| 732 |
|
| 733 |
+
with gr.Column(elem_id="output-zone"):
|
| 734 |
+
output_header = gr.HTML(
|
| 735 |
+
value="<div class='hud'><div class='hud-pill idle'>Waiting for your prompt...</div></div>"
|
| 736 |
+
)
|
| 737 |
+
output_body = gr.HTML(value="")
|
| 738 |
+
|
| 739 |
+
ins = [query_input, score_slider, genre_dd, anime_type_dd, n_slider]
|
| 740 |
+
outs = [output_header, output_body]
|
| 741 |
+
submit_btn.click(fn=get_recommendations, inputs=ins, outputs=outs)
|
| 742 |
+
query_input.submit(fn=get_recommendations, inputs=ins, outputs=outs)
|
| 743 |
|
| 744 |
+
return demo
|
| 745 |
|
| 746 |
|
| 747 |
if __name__ == "__main__":
|
| 748 |
demo = create_interface()
|
| 749 |
|
| 750 |
theme = themes.Base(
|
| 751 |
+
font=[themes.GoogleFont("Plus Jakarta Sans"),
|
| 752 |
+
"ui-sans-serif", "sans-serif"],
|
| 753 |
).set(
|
| 754 |
body_background_fill="transparent",
|
| 755 |
block_background_fill="transparent",
|
| 756 |
block_border_width="0px",
|
| 757 |
input_background_fill="transparent",
|
| 758 |
+
border_color_primary="transparent",
|
| 759 |
+
shadow_drop="none",
|
| 760 |
+
shadow_drop_lg="none",
|
| 761 |
)
|
| 762 |
|
| 763 |
demo.launch(
|
| 764 |
server_name="0.0.0.0",
|
| 765 |
server_port=7860,
|
| 766 |
+
share=True,
|
| 767 |
css=CSS,
|
| 768 |
+
theme=theme,
|
| 769 |
)
|
uv.lock
CHANGED
|
@@ -54,7 +54,7 @@ dev = [
|
|
| 54 |
|
| 55 |
[package.metadata]
|
| 56 |
requires-dist = [
|
| 57 |
-
{ name = "chromadb", specifier = ">=
|
| 58 |
{ name = "fastapi", extras = ["all"], specifier = ">=0.122.0" },
|
| 59 |
{ name = "gradio", specifier = ">=6.2.0" },
|
| 60 |
{ name = "langchain", specifier = ">=1.1.3" },
|
|
@@ -375,25 +375,16 @@ wheels = [
|
|
| 375 |
|
| 376 |
[[package]]
|
| 377 |
name = "build"
|
| 378 |
-
version = "1.
|
| 379 |
source = { registry = "https://pypi.org/simple" }
|
| 380 |
dependencies = [
|
| 381 |
{ name = "colorama", marker = "(os_name == 'nt' and platform_machine != 'aarch64' and sys_platform == 'linux') or (os_name == 'nt' and platform_python_implementation != 'CPython' and sys_platform == 'linux') or (os_name == 'nt' and sys_platform != 'darwin' and sys_platform != 'linux')" },
|
| 382 |
{ name = "packaging" },
|
| 383 |
{ name = "pyproject-hooks" },
|
| 384 |
]
|
| 385 |
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
| 386 |
-
wheels = [
|
| 387 |
-
{ url = "https://files.pythonhosted.org/packages/cb/8c/2b30c12155ad8de0cf641d76a8b396a16d2c36bc6d50b621a62b7c4567c1/build-1.3.0-py3-none-any.whl", hash = "sha256:7145f0b5061ba90a1500d60bd1b13ca0a8a4cebdd0cc16ed8adf1c0e739f43b4", size = 23382, upload-time = "2025-08-01T21:27:07.844Z" },
|
| 388 |
-
]
|
| 389 |
-
|
| 390 |
-
[[package]]
|
| 391 |
-
name = "cachetools"
|
| 392 |
-
version = "6.2.4"
|
| 393 |
-
source = { registry = "https://pypi.org/simple" }
|
| 394 |
-
sdist = { url = "https://files.pythonhosted.org/packages/bc/1d/ede8680603f6016887c062a2cf4fc8fdba905866a3ab8831aa8aa651320c/cachetools-6.2.4.tar.gz", hash = "sha256:82c5c05585e70b6ba2d3ae09ea60b79548872185d2f24ae1f2709d37299fd607", size = 31731, upload-time = "2025-12-15T18:24:53.744Z" }
|
| 395 |
wheels = [
|
| 396 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 397 |
]
|
| 398 |
|
| 399 |
[[package]]
|
|
@@ -512,7 +503,7 @@ wheels = [
|
|
| 512 |
|
| 513 |
[[package]]
|
| 514 |
name = "chromadb"
|
| 515 |
-
version = "1.
|
| 516 |
source = { registry = "https://pypi.org/simple" }
|
| 517 |
dependencies = [
|
| 518 |
{ name = "bcrypt" },
|
|
@@ -543,13 +534,13 @@ dependencies = [
|
|
| 543 |
{ name = "typing-extensions" },
|
| 544 |
{ name = "uvicorn", extra = ["standard"] },
|
| 545 |
]
|
| 546 |
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
| 547 |
wheels = [
|
| 548 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 549 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 550 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 551 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 552 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 553 |
]
|
| 554 |
|
| 555 |
[[package]]
|
|
@@ -573,18 +564,6 @@ wheels = [
|
|
| 573 |
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
| 574 |
]
|
| 575 |
|
| 576 |
-
[[package]]
|
| 577 |
-
name = "coloredlogs"
|
| 578 |
-
version = "15.0.1"
|
| 579 |
-
source = { registry = "https://pypi.org/simple" }
|
| 580 |
-
dependencies = [
|
| 581 |
-
{ name = "humanfriendly" },
|
| 582 |
-
]
|
| 583 |
-
sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520, upload-time = "2021-06-11T10:22:45.202Z" }
|
| 584 |
-
wheels = [
|
| 585 |
-
{ url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018, upload-time = "2021-06-11T10:22:42.561Z" },
|
| 586 |
-
]
|
| 587 |
-
|
| 588 |
[[package]]
|
| 589 |
name = "comm"
|
| 590 |
version = "0.2.3"
|
|
@@ -846,11 +825,10 @@ wheels = [
|
|
| 846 |
|
| 847 |
[[package]]
|
| 848 |
name = "flatbuffers"
|
| 849 |
-
version = "25.
|
| 850 |
source = { registry = "https://pypi.org/simple" }
|
| 851 |
-
sdist = { url = "https://files.pythonhosted.org/packages/9d/1f/3ee70b0a55137442038f2a33469cc5fddd7e0ad2abf83d7497c18a2b6923/flatbuffers-25.9.23.tar.gz", hash = "sha256:676f9fa62750bb50cf531b42a0a2a118ad8f7f797a511eda12881c016f093b12", size = 22067, upload-time = "2025-09-24T05:25:30.106Z" }
|
| 852 |
wheels = [
|
| 853 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 854 |
]
|
| 855 |
|
| 856 |
[[package]]
|
|
@@ -871,20 +849,6 @@ wheels = [
|
|
| 871 |
{ url = "https://files.pythonhosted.org/packages/51/c7/b64cae5dba3a1b138d7123ec36bb5ccd39d39939f18454407e5468f4763f/fsspec-2025.12.0-py3-none-any.whl", hash = "sha256:8bf1fe301b7d8acfa6e8571e3b1c3d158f909666642431cc78a1b7b4dbc5ec5b", size = 201422, upload-time = "2025-12-03T15:23:41.434Z" },
|
| 872 |
]
|
| 873 |
|
| 874 |
-
[[package]]
|
| 875 |
-
name = "google-auth"
|
| 876 |
-
version = "2.45.0"
|
| 877 |
-
source = { registry = "https://pypi.org/simple" }
|
| 878 |
-
dependencies = [
|
| 879 |
-
{ name = "cachetools" },
|
| 880 |
-
{ name = "pyasn1-modules" },
|
| 881 |
-
{ name = "rsa" },
|
| 882 |
-
]
|
| 883 |
-
sdist = { url = "https://files.pythonhosted.org/packages/e5/00/3c794502a8b892c404b2dea5b3650eb21bfc7069612fbfd15c7f17c1cb0d/google_auth-2.45.0.tar.gz", hash = "sha256:90d3f41b6b72ea72dd9811e765699ee491ab24139f34ebf1ca2b9cc0c38708f3", size = 320708, upload-time = "2025-12-15T22:58:42.889Z" }
|
| 884 |
-
wheels = [
|
| 885 |
-
{ url = "https://files.pythonhosted.org/packages/c6/97/451d55e05487a5cd6279a01a7e34921858b16f7dc8aa38a2c684743cd2b3/google_auth-2.45.0-py2.py3-none-any.whl", hash = "sha256:82344e86dc00410ef5382d99be677c6043d72e502b625aa4f4afa0bdacca0f36", size = 233312, upload-time = "2025-12-15T22:58:40.777Z" },
|
| 886 |
-
]
|
| 887 |
-
|
| 888 |
[[package]]
|
| 889 |
name = "googleapis-common-protos"
|
| 890 |
version = "1.72.0"
|
|
@@ -1012,43 +976,43 @@ wheels = [
|
|
| 1012 |
|
| 1013 |
[[package]]
|
| 1014 |
name = "grpcio"
|
| 1015 |
-
version = "1.
|
| 1016 |
source = { registry = "https://pypi.org/simple" }
|
| 1017 |
dependencies = [
|
| 1018 |
{ name = "typing-extensions" },
|
| 1019 |
]
|
| 1020 |
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
| 1021 |
-
wheels = [
|
| 1022 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1023 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1024 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1025 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1026 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1027 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1028 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1029 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1030 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1031 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1032 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1033 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1034 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1035 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1036 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1037 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1038 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1039 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1040 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1041 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1042 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1043 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1044 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1045 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1046 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1047 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1048 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1049 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1050 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1051 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1052 |
]
|
| 1053 |
|
| 1054 |
[[package]]
|
|
@@ -1158,18 +1122,6 @@ wheels = [
|
|
| 1158 |
{ url = "https://files.pythonhosted.org/packages/cb/bd/1a875e0d592d447cbc02805fd3fe0f497714d6a2583f59d14fa9ebad96eb/huggingface_hub-0.36.0-py3-none-any.whl", hash = "sha256:7bcc9ad17d5b3f07b57c78e79d527102d08313caa278a641993acddcb894548d", size = 566094, upload-time = "2025-10-23T12:11:59.557Z" },
|
| 1159 |
]
|
| 1160 |
|
| 1161 |
-
[[package]]
|
| 1162 |
-
name = "humanfriendly"
|
| 1163 |
-
version = "10.0"
|
| 1164 |
-
source = { registry = "https://pypi.org/simple" }
|
| 1165 |
-
dependencies = [
|
| 1166 |
-
{ name = "pyreadline3", marker = "sys_platform == 'win32'" },
|
| 1167 |
-
]
|
| 1168 |
-
sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" }
|
| 1169 |
-
wheels = [
|
| 1170 |
-
{ url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload-time = "2021-09-17T21:40:39.897Z" },
|
| 1171 |
-
]
|
| 1172 |
-
|
| 1173 |
[[package]]
|
| 1174 |
name = "idna"
|
| 1175 |
version = "3.11"
|
|
@@ -1181,14 +1133,14 @@ wheels = [
|
|
| 1181 |
|
| 1182 |
[[package]]
|
| 1183 |
name = "importlib-metadata"
|
| 1184 |
-
version = "8.7.
|
| 1185 |
source = { registry = "https://pypi.org/simple" }
|
| 1186 |
dependencies = [
|
| 1187 |
{ name = "zipp" },
|
| 1188 |
]
|
| 1189 |
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
| 1190 |
wheels = [
|
| 1191 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1192 |
]
|
| 1193 |
|
| 1194 |
[[package]]
|
|
@@ -1608,12 +1560,11 @@ wheels = [
|
|
| 1608 |
|
| 1609 |
[[package]]
|
| 1610 |
name = "kubernetes"
|
| 1611 |
-
version = "
|
| 1612 |
source = { registry = "https://pypi.org/simple" }
|
| 1613 |
dependencies = [
|
| 1614 |
{ name = "certifi" },
|
| 1615 |
{ name = "durationpy" },
|
| 1616 |
-
{ name = "google-auth" },
|
| 1617 |
{ name = "python-dateutil" },
|
| 1618 |
{ name = "pyyaml" },
|
| 1619 |
{ name = "requests" },
|
|
@@ -1622,9 +1573,9 @@ dependencies = [
|
|
| 1622 |
{ name = "urllib3" },
|
| 1623 |
{ name = "websocket-client" },
|
| 1624 |
]
|
| 1625 |
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
| 1626 |
wheels = [
|
| 1627 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1628 |
]
|
| 1629 |
|
| 1630 |
[[package]]
|
|
@@ -2092,10 +2043,9 @@ wheels = [
|
|
| 2092 |
|
| 2093 |
[[package]]
|
| 2094 |
name = "onnxruntime"
|
| 2095 |
-
version = "1.
|
| 2096 |
source = { registry = "https://pypi.org/simple" }
|
| 2097 |
dependencies = [
|
| 2098 |
-
{ name = "coloredlogs" },
|
| 2099 |
{ name = "flatbuffers" },
|
| 2100 |
{ name = "numpy" },
|
| 2101 |
{ name = "packaging" },
|
|
@@ -2103,23 +2053,23 @@ dependencies = [
|
|
| 2103 |
{ name = "sympy" },
|
| 2104 |
]
|
| 2105 |
wheels = [
|
| 2106 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 2107 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 2108 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 2109 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 2110 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 2111 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 2112 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 2113 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 2114 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 2115 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 2116 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 2117 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 2118 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 2119 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 2120 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 2121 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 2122 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 2123 |
]
|
| 2124 |
|
| 2125 |
[[package]]
|
|
@@ -2500,17 +2450,17 @@ wheels = [
|
|
| 2500 |
|
| 2501 |
[[package]]
|
| 2502 |
name = "protobuf"
|
| 2503 |
-
version = "6.33.
|
| 2504 |
source = { registry = "https://pypi.org/simple" }
|
| 2505 |
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
| 2506 |
wheels = [
|
| 2507 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 2508 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 2509 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 2510 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 2511 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 2512 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 2513 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 2514 |
]
|
| 2515 |
|
| 2516 |
[[package]]
|
|
@@ -2592,27 +2542,6 @@ wheels = [
|
|
| 2592 |
{ url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" },
|
| 2593 |
]
|
| 2594 |
|
| 2595 |
-
[[package]]
|
| 2596 |
-
name = "pyasn1"
|
| 2597 |
-
version = "0.6.1"
|
| 2598 |
-
source = { registry = "https://pypi.org/simple" }
|
| 2599 |
-
sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" }
|
| 2600 |
-
wheels = [
|
| 2601 |
-
{ url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" },
|
| 2602 |
-
]
|
| 2603 |
-
|
| 2604 |
-
[[package]]
|
| 2605 |
-
name = "pyasn1-modules"
|
| 2606 |
-
version = "0.4.2"
|
| 2607 |
-
source = { registry = "https://pypi.org/simple" }
|
| 2608 |
-
dependencies = [
|
| 2609 |
-
{ name = "pyasn1" },
|
| 2610 |
-
]
|
| 2611 |
-
sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" }
|
| 2612 |
-
wheels = [
|
| 2613 |
-
{ url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
|
| 2614 |
-
]
|
| 2615 |
-
|
| 2616 |
[[package]]
|
| 2617 |
name = "pybase64"
|
| 2618 |
version = "1.4.3"
|
|
@@ -2863,9 +2792,12 @@ wheels = [
|
|
| 2863 |
|
| 2864 |
[[package]]
|
| 2865 |
name = "pypika"
|
| 2866 |
-
version = "0.
|
| 2867 |
source = { registry = "https://pypi.org/simple" }
|
| 2868 |
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
|
|
|
|
|
|
|
|
|
| 2869 |
|
| 2870 |
[[package]]
|
| 2871 |
name = "pyproject-hooks"
|
|
@@ -2876,15 +2808,6 @@ wheels = [
|
|
| 2876 |
{ url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" },
|
| 2877 |
]
|
| 2878 |
|
| 2879 |
-
[[package]]
|
| 2880 |
-
name = "pyreadline3"
|
| 2881 |
-
version = "3.5.4"
|
| 2882 |
-
source = { registry = "https://pypi.org/simple" }
|
| 2883 |
-
sdist = { url = "https://files.pythonhosted.org/packages/0f/49/4cea918a08f02817aabae639e3d0ac046fef9f9180518a3ad394e22da148/pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7", size = 99839, upload-time = "2024-09-19T02:40:10.062Z" }
|
| 2884 |
-
wheels = [
|
| 2885 |
-
{ url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178, upload-time = "2024-09-19T02:40:08.598Z" },
|
| 2886 |
-
]
|
| 2887 |
-
|
| 2888 |
[[package]]
|
| 2889 |
name = "pytest"
|
| 2890 |
version = "9.0.2"
|
|
@@ -3368,18 +3291,6 @@ wheels = [
|
|
| 3368 |
{ url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" },
|
| 3369 |
]
|
| 3370 |
|
| 3371 |
-
[[package]]
|
| 3372 |
-
name = "rsa"
|
| 3373 |
-
version = "4.9.1"
|
| 3374 |
-
source = { registry = "https://pypi.org/simple" }
|
| 3375 |
-
dependencies = [
|
| 3376 |
-
{ name = "pyasn1" },
|
| 3377 |
-
]
|
| 3378 |
-
sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
|
| 3379 |
-
wheels = [
|
| 3380 |
-
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
|
| 3381 |
-
]
|
| 3382 |
-
|
| 3383 |
[[package]]
|
| 3384 |
name = "safehttpx"
|
| 3385 |
version = "0.1.7"
|
|
|
|
| 54 |
|
| 55 |
[package.metadata]
|
| 56 |
requires-dist = [
|
| 57 |
+
{ name = "chromadb", specifier = ">=1.5.2" },
|
| 58 |
{ name = "fastapi", extras = ["all"], specifier = ">=0.122.0" },
|
| 59 |
{ name = "gradio", specifier = ">=6.2.0" },
|
| 60 |
{ name = "langchain", specifier = ">=1.1.3" },
|
|
|
|
| 375 |
|
| 376 |
[[package]]
|
| 377 |
name = "build"
|
| 378 |
+
version = "1.4.0"
|
| 379 |
source = { registry = "https://pypi.org/simple" }
|
| 380 |
dependencies = [
|
| 381 |
{ name = "colorama", marker = "(os_name == 'nt' and platform_machine != 'aarch64' and sys_platform == 'linux') or (os_name == 'nt' and platform_python_implementation != 'CPython' and sys_platform == 'linux') or (os_name == 'nt' and sys_platform != 'darwin' and sys_platform != 'linux')" },
|
| 382 |
{ name = "packaging" },
|
| 383 |
{ name = "pyproject-hooks" },
|
| 384 |
]
|
| 385 |
+
sdist = { url = "https://files.pythonhosted.org/packages/42/18/94eaffda7b329535d91f00fe605ab1f1e5cd68b2074d03f255c7d250687d/build-1.4.0.tar.gz", hash = "sha256:f1b91b925aa322be454f8330c6fb48b465da993d1e7e7e6fa35027ec49f3c936", size = 50054, upload-time = "2026-01-08T16:41:47.696Z" }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
wheels = [
|
| 387 |
+
{ url = "https://files.pythonhosted.org/packages/c5/0d/84a4380f930db0010168e0aa7b7a8fed9ba1835a8fbb1472bc6d0201d529/build-1.4.0-py3-none-any.whl", hash = "sha256:6a07c1b8eb6f2b311b96fcbdbce5dab5fe637ffda0fd83c9cac622e927501596", size = 24141, upload-time = "2026-01-08T16:41:46.453Z" },
|
| 388 |
]
|
| 389 |
|
| 390 |
[[package]]
|
|
|
|
| 503 |
|
| 504 |
[[package]]
|
| 505 |
name = "chromadb"
|
| 506 |
+
version = "1.5.2"
|
| 507 |
source = { registry = "https://pypi.org/simple" }
|
| 508 |
dependencies = [
|
| 509 |
{ name = "bcrypt" },
|
|
|
|
| 534 |
{ name = "typing-extensions" },
|
| 535 |
{ name = "uvicorn", extra = ["standard"] },
|
| 536 |
]
|
| 537 |
+
sdist = { url = "https://files.pythonhosted.org/packages/9e/48/aa5906f9f817b73c9e87e085d3a64705d91b7bb4f76f4649b9379baea980/chromadb-1.5.2.tar.gz", hash = "sha256:4fc3535a0fcd45343f93d298591882f68e659f24ed319aef14094b168105f956", size = 2386235, upload-time = "2026-02-27T19:49:34.167Z" }
|
| 538 |
wheels = [
|
| 539 |
+
{ url = "https://files.pythonhosted.org/packages/6b/3b/36989e7ebfa2ee10a85deacd423989b07f9e3bd176846863ace1305e9460/chromadb-1.5.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:a898ab200f9a22a16751eed5444dac330f1f82184264e16d5420e41e0afe63e4", size = 20733964, upload-time = "2026-02-27T19:49:31.683Z" },
|
| 540 |
+
{ url = "https://files.pythonhosted.org/packages/85/b3/db3e5a8a47106d339c3e109e73859647969a81e9c54ca15bc6dde6685c1e/chromadb-1.5.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:8e6a12adb34bf441f8cc368b6460fbc9e14bee5cf926f34e752da759d68dec56", size = 19993688, upload-time = "2026-02-27T19:49:28.5Z" },
|
| 541 |
+
{ url = "https://files.pythonhosted.org/packages/c2/88/0a9b6dddac3097f321ec8b057d09a61b4edb2b42b891fce7c2bfd01cd4c3/chromadb-1.5.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b533db30303ce5a82856ded8897c3cafd3160e1f2dccf5473d0bfdee49a159b3", size = 20642212, upload-time = "2026-02-27T19:49:22.467Z" },
|
| 542 |
+
{ url = "https://files.pythonhosted.org/packages/ae/74/b8cd9d9bc72c545a579fd1f7bb44558a801ed5f5bab164a25eea16d51ad9/chromadb-1.5.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e48e5b0f300d6f709446a5d9299614e3b6bca997772d810e1298b76b0c4e7dbb", size = 21529269, upload-time = "2026-02-27T19:49:25.699Z" },
|
| 543 |
+
{ url = "https://files.pythonhosted.org/packages/e8/96/fa83f81f8b618ffca7527915f99cf054c6f8bd272bf3cf5c0616757083ba/chromadb-1.5.2-cp39-abi3-win_amd64.whl", hash = "sha256:042e746ee0c9db34eef2723c4dca30197ded3bf9d27846d996fd51715ec7b0e3", size = 21863829, upload-time = "2026-02-27T19:49:36.422Z" },
|
| 544 |
]
|
| 545 |
|
| 546 |
[[package]]
|
|
|
|
| 564 |
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
| 565 |
]
|
| 566 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 567 |
[[package]]
|
| 568 |
name = "comm"
|
| 569 |
version = "0.2.3"
|
|
|
|
| 825 |
|
| 826 |
[[package]]
|
| 827 |
name = "flatbuffers"
|
| 828 |
+
version = "25.12.19"
|
| 829 |
source = { registry = "https://pypi.org/simple" }
|
|
|
|
| 830 |
wheels = [
|
| 831 |
+
{ url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" },
|
| 832 |
]
|
| 833 |
|
| 834 |
[[package]]
|
|
|
|
| 849 |
{ url = "https://files.pythonhosted.org/packages/51/c7/b64cae5dba3a1b138d7123ec36bb5ccd39d39939f18454407e5468f4763f/fsspec-2025.12.0-py3-none-any.whl", hash = "sha256:8bf1fe301b7d8acfa6e8571e3b1c3d158f909666642431cc78a1b7b4dbc5ec5b", size = 201422, upload-time = "2025-12-03T15:23:41.434Z" },
|
| 850 |
]
|
| 851 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 852 |
[[package]]
|
| 853 |
name = "googleapis-common-protos"
|
| 854 |
version = "1.72.0"
|
|
|
|
| 976 |
|
| 977 |
[[package]]
|
| 978 |
name = "grpcio"
|
| 979 |
+
version = "1.78.0"
|
| 980 |
source = { registry = "https://pypi.org/simple" }
|
| 981 |
dependencies = [
|
| 982 |
{ name = "typing-extensions" },
|
| 983 |
]
|
| 984 |
+
sdist = { url = "https://files.pythonhosted.org/packages/06/8a/3d098f35c143a89520e568e6539cc098fcd294495910e359889ce8741c84/grpcio-1.78.0.tar.gz", hash = "sha256:7382b95189546f375c174f53a5fa873cef91c4b8005faa05cc5b3beea9c4f1c5", size = 12852416, upload-time = "2026-02-06T09:57:18.093Z" }
|
| 985 |
+
wheels = [
|
| 986 |
+
{ url = "https://files.pythonhosted.org/packages/86/c7/d0b780a29b0837bf4ca9580904dfb275c1fc321ded7897d620af7047ec57/grpcio-1.78.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2777b783f6c13b92bd7b716667452c329eefd646bfb3f2e9dabea2e05dbd34f6", size = 5951525, upload-time = "2026-02-06T09:55:01.989Z" },
|
| 987 |
+
{ url = "https://files.pythonhosted.org/packages/c5/b1/96920bf2ee61df85a9503cb6f733fe711c0ff321a5a697d791b075673281/grpcio-1.78.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:9dca934f24c732750389ce49d638069c3892ad065df86cb465b3fa3012b70c9e", size = 11830418, upload-time = "2026-02-06T09:55:04.462Z" },
|
| 988 |
+
{ url = "https://files.pythonhosted.org/packages/83/0c/7c1528f098aeb75a97de2bae18c530f56959fb7ad6c882db45d9884d6edc/grpcio-1.78.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:459ab414b35f4496138d0ecd735fed26f1318af5e52cb1efbc82a09f0d5aa911", size = 6524477, upload-time = "2026-02-06T09:55:07.111Z" },
|
| 989 |
+
{ url = "https://files.pythonhosted.org/packages/8d/52/e7c1f3688f949058e19a011c4e0dec973da3d0ae5e033909677f967ae1f4/grpcio-1.78.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:082653eecbdf290e6e3e2c276ab2c54b9e7c299e07f4221872380312d8cf395e", size = 7198266, upload-time = "2026-02-06T09:55:10.016Z" },
|
| 990 |
+
{ url = "https://files.pythonhosted.org/packages/e5/61/8ac32517c1e856677282c34f2e7812d6c328fa02b8f4067ab80e77fdc9c9/grpcio-1.78.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85f93781028ec63f383f6bc90db785a016319c561cc11151fbb7b34e0d012303", size = 6730552, upload-time = "2026-02-06T09:55:12.207Z" },
|
| 991 |
+
{ url = "https://files.pythonhosted.org/packages/bd/98/b8ee0158199250220734f620b12e4a345955ac7329cfd908d0bf0fda77f0/grpcio-1.78.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f12857d24d98441af6a1d5c87442d624411db486f7ba12550b07788f74b67b04", size = 7304296, upload-time = "2026-02-06T09:55:15.044Z" },
|
| 992 |
+
{ url = "https://files.pythonhosted.org/packages/bd/0f/7b72762e0d8840b58032a56fdbd02b78fc645b9fa993d71abf04edbc54f4/grpcio-1.78.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5397fff416b79e4b284959642a4e95ac4b0f1ece82c9993658e0e477d40551ec", size = 8288298, upload-time = "2026-02-06T09:55:17.276Z" },
|
| 993 |
+
{ url = "https://files.pythonhosted.org/packages/24/ae/ae4ce56bc5bb5caa3a486d60f5f6083ac3469228faa734362487176c15c5/grpcio-1.78.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:fbe6e89c7ffb48518384068321621b2a69cab509f58e40e4399fdd378fa6d074", size = 7730953, upload-time = "2026-02-06T09:55:19.545Z" },
|
| 994 |
+
{ url = "https://files.pythonhosted.org/packages/b5/6e/8052e3a28eb6a820c372b2eb4b5e32d195c661e137d3eca94d534a4cfd8a/grpcio-1.78.0-cp311-cp311-win32.whl", hash = "sha256:6092beabe1966a3229f599d7088b38dfc8ffa1608b5b5cdda31e591e6500f856", size = 4076503, upload-time = "2026-02-06T09:55:21.521Z" },
|
| 995 |
+
{ url = "https://files.pythonhosted.org/packages/08/62/f22c98c5265dfad327251fa2f840b591b1df5f5e15d88b19c18c86965b27/grpcio-1.78.0-cp311-cp311-win_amd64.whl", hash = "sha256:1afa62af6e23f88629f2b29ec9e52ec7c65a7176c1e0a83292b93c76ca882558", size = 4799767, upload-time = "2026-02-06T09:55:24.107Z" },
|
| 996 |
+
{ url = "https://files.pythonhosted.org/packages/4e/f4/7384ed0178203d6074446b3c4f46c90a22ddf7ae0b3aee521627f54cfc2a/grpcio-1.78.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:f9ab915a267fc47c7e88c387a3a28325b58c898e23d4995f765728f4e3dedb97", size = 5913985, upload-time = "2026-02-06T09:55:26.832Z" },
|
| 997 |
+
{ url = "https://files.pythonhosted.org/packages/81/ed/be1caa25f06594463f685b3790b320f18aea49b33166f4141bfdc2bfb236/grpcio-1.78.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3f8904a8165ab21e07e58bf3e30a73f4dffc7a1e0dbc32d51c61b5360d26f43e", size = 11811853, upload-time = "2026-02-06T09:55:29.224Z" },
|
| 998 |
+
{ url = "https://files.pythonhosted.org/packages/24/a7/f06d151afc4e64b7e3cc3e872d331d011c279aaab02831e40a81c691fb65/grpcio-1.78.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:859b13906ce098c0b493af92142ad051bf64c7870fa58a123911c88606714996", size = 6475766, upload-time = "2026-02-06T09:55:31.825Z" },
|
| 999 |
+
{ url = "https://files.pythonhosted.org/packages/8a/a8/4482922da832ec0082d0f2cc3a10976d84a7424707f25780b82814aafc0a/grpcio-1.78.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b2342d87af32790f934a79c3112641e7b27d63c261b8b4395350dad43eff1dc7", size = 7170027, upload-time = "2026-02-06T09:55:34.7Z" },
|
| 1000 |
+
{ url = "https://files.pythonhosted.org/packages/54/bf/f4a3b9693e35d25b24b0b39fa46d7d8a3c439e0a3036c3451764678fec20/grpcio-1.78.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:12a771591ae40bc65ba67048fa52ef4f0e6db8279e595fd349f9dfddeef571f9", size = 6690766, upload-time = "2026-02-06T09:55:36.902Z" },
|
| 1001 |
+
{ url = "https://files.pythonhosted.org/packages/c7/b9/521875265cc99fe5ad4c5a17010018085cae2810a928bf15ebe7d8bcd9cc/grpcio-1.78.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:185dea0d5260cbb2d224c507bf2a5444d5abbb1fa3594c1ed7e4c709d5eb8383", size = 7266161, upload-time = "2026-02-06T09:55:39.824Z" },
|
| 1002 |
+
{ url = "https://files.pythonhosted.org/packages/05/86/296a82844fd40a4ad4a95f100b55044b4f817dece732bf686aea1a284147/grpcio-1.78.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51b13f9aed9d59ee389ad666b8c2214cc87b5de258fa712f9ab05f922e3896c6", size = 8253303, upload-time = "2026-02-06T09:55:42.353Z" },
|
| 1003 |
+
{ url = "https://files.pythonhosted.org/packages/f3/e4/ea3c0caf5468537f27ad5aab92b681ed7cc0ef5f8c9196d3fd42c8c2286b/grpcio-1.78.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd5f135b1bd58ab088930b3c613455796dfa0393626a6972663ccdda5b4ac6ce", size = 7698222, upload-time = "2026-02-06T09:55:44.629Z" },
|
| 1004 |
+
{ url = "https://files.pythonhosted.org/packages/d7/47/7f05f81e4bb6b831e93271fb12fd52ba7b319b5402cbc101d588f435df00/grpcio-1.78.0-cp312-cp312-win32.whl", hash = "sha256:94309f498bcc07e5a7d16089ab984d42ad96af1d94b5a4eb966a266d9fcabf68", size = 4066123, upload-time = "2026-02-06T09:55:47.644Z" },
|
| 1005 |
+
{ url = "https://files.pythonhosted.org/packages/ad/e7/d6914822c88aa2974dbbd10903d801a28a19ce9cd8bad7e694cbbcf61528/grpcio-1.78.0-cp312-cp312-win_amd64.whl", hash = "sha256:9566fe4ababbb2610c39190791e5b829869351d14369603702e890ef3ad2d06e", size = 4797657, upload-time = "2026-02-06T09:55:49.86Z" },
|
| 1006 |
+
{ url = "https://files.pythonhosted.org/packages/05/a9/8f75894993895f361ed8636cd9237f4ab39ef87fd30db17467235ed1c045/grpcio-1.78.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:ce3a90455492bf8bfa38e56fbbe1dbd4f872a3d8eeaf7337dc3b1c8aa28c271b", size = 5920143, upload-time = "2026-02-06T09:55:52.035Z" },
|
| 1007 |
+
{ url = "https://files.pythonhosted.org/packages/55/06/0b78408e938ac424100100fd081189451b472236e8a3a1f6500390dc4954/grpcio-1.78.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:2bf5e2e163b356978b23652c4818ce4759d40f4712ee9ec5a83c4be6f8c23a3a", size = 11803926, upload-time = "2026-02-06T09:55:55.494Z" },
|
| 1008 |
+
{ url = "https://files.pythonhosted.org/packages/88/93/b59fe7832ff6ae3c78b813ea43dac60e295fa03606d14d89d2e0ec29f4f3/grpcio-1.78.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8f2ac84905d12918e4e55a16da17939eb63e433dc11b677267c35568aa63fc84", size = 6478628, upload-time = "2026-02-06T09:55:58.533Z" },
|
| 1009 |
+
{ url = "https://files.pythonhosted.org/packages/ed/df/e67e3734527f9926b7d9c0dde6cd998d1d26850c3ed8eeec81297967ac67/grpcio-1.78.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b58f37edab4a3881bc6c9bca52670610e0c9ca14e2ea3cf9debf185b870457fb", size = 7173574, upload-time = "2026-02-06T09:56:01.786Z" },
|
| 1010 |
+
{ url = "https://files.pythonhosted.org/packages/a6/62/cc03fffb07bfba982a9ec097b164e8835546980aec25ecfa5f9c1a47e022/grpcio-1.78.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:735e38e176a88ce41840c21bb49098ab66177c64c82426e24e0082500cc68af5", size = 6692639, upload-time = "2026-02-06T09:56:04.529Z" },
|
| 1011 |
+
{ url = "https://files.pythonhosted.org/packages/bf/9a/289c32e301b85bdb67d7ec68b752155e674ee3ba2173a1858f118e399ef3/grpcio-1.78.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2045397e63a7a0ee7957c25f7dbb36ddc110e0cfb418403d110c0a7a68a844e9", size = 7268838, upload-time = "2026-02-06T09:56:08.397Z" },
|
| 1012 |
+
{ url = "https://files.pythonhosted.org/packages/0e/79/1be93f32add280461fa4773880196572563e9c8510861ac2da0ea0f892b6/grpcio-1.78.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a9f136fbafe7ccf4ac7e8e0c28b31066e810be52d6e344ef954a3a70234e1702", size = 8251878, upload-time = "2026-02-06T09:56:10.914Z" },
|
| 1013 |
+
{ url = "https://files.pythonhosted.org/packages/65/65/793f8e95296ab92e4164593674ae6291b204bb5f67f9d4a711489cd30ffa/grpcio-1.78.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:748b6138585379c737adc08aeffd21222abbda1a86a0dca2a39682feb9196c20", size = 7695412, upload-time = "2026-02-06T09:56:13.593Z" },
|
| 1014 |
+
{ url = "https://files.pythonhosted.org/packages/1c/9f/1e233fe697ecc82845942c2822ed06bb522e70d6771c28d5528e4c50f6a4/grpcio-1.78.0-cp313-cp313-win32.whl", hash = "sha256:271c73e6e5676afe4fc52907686670c7cea22ab2310b76a59b678403ed40d670", size = 4064899, upload-time = "2026-02-06T09:56:15.601Z" },
|
| 1015 |
+
{ url = "https://files.pythonhosted.org/packages/4d/27/d86b89e36de8a951501fb06a0f38df19853210f341d0b28f83f4aa0ffa08/grpcio-1.78.0-cp313-cp313-win_amd64.whl", hash = "sha256:f2d4e43ee362adfc05994ed479334d5a451ab7bc3f3fee1b796b8ca66895acb4", size = 4797393, upload-time = "2026-02-06T09:56:17.882Z" },
|
| 1016 |
]
|
| 1017 |
|
| 1018 |
[[package]]
|
|
|
|
| 1122 |
{ url = "https://files.pythonhosted.org/packages/cb/bd/1a875e0d592d447cbc02805fd3fe0f497714d6a2583f59d14fa9ebad96eb/huggingface_hub-0.36.0-py3-none-any.whl", hash = "sha256:7bcc9ad17d5b3f07b57c78e79d527102d08313caa278a641993acddcb894548d", size = 566094, upload-time = "2025-10-23T12:11:59.557Z" },
|
| 1123 |
]
|
| 1124 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1125 |
[[package]]
|
| 1126 |
name = "idna"
|
| 1127 |
version = "3.11"
|
|
|
|
| 1133 |
|
| 1134 |
[[package]]
|
| 1135 |
name = "importlib-metadata"
|
| 1136 |
+
version = "8.7.1"
|
| 1137 |
source = { registry = "https://pypi.org/simple" }
|
| 1138 |
dependencies = [
|
| 1139 |
{ name = "zipp" },
|
| 1140 |
]
|
| 1141 |
+
sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" }
|
| 1142 |
wheels = [
|
| 1143 |
+
{ url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" },
|
| 1144 |
]
|
| 1145 |
|
| 1146 |
[[package]]
|
|
|
|
| 1560 |
|
| 1561 |
[[package]]
|
| 1562 |
name = "kubernetes"
|
| 1563 |
+
version = "35.0.0"
|
| 1564 |
source = { registry = "https://pypi.org/simple" }
|
| 1565 |
dependencies = [
|
| 1566 |
{ name = "certifi" },
|
| 1567 |
{ name = "durationpy" },
|
|
|
|
| 1568 |
{ name = "python-dateutil" },
|
| 1569 |
{ name = "pyyaml" },
|
| 1570 |
{ name = "requests" },
|
|
|
|
| 1573 |
{ name = "urllib3" },
|
| 1574 |
{ name = "websocket-client" },
|
| 1575 |
]
|
| 1576 |
+
sdist = { url = "https://files.pythonhosted.org/packages/2c/8f/85bf51ad4150f64e8c665daf0d9dfe9787ae92005efb9a4d1cba592bd79d/kubernetes-35.0.0.tar.gz", hash = "sha256:3d00d344944239821458b9efd484d6df9f011da367ecb155dadf9513f05f09ee", size = 1094642, upload-time = "2026-01-16T01:05:27.76Z" }
|
| 1577 |
wheels = [
|
| 1578 |
+
{ url = "https://files.pythonhosted.org/packages/0c/70/05b685ea2dffcb2adbf3cdcea5d8865b7bc66f67249084cf845012a0ff13/kubernetes-35.0.0-py2.py3-none-any.whl", hash = "sha256:39e2b33b46e5834ef6c3985ebfe2047ab39135d41de51ce7641a7ca5b372a13d", size = 2017602, upload-time = "2026-01-16T01:05:25.991Z" },
|
| 1579 |
]
|
| 1580 |
|
| 1581 |
[[package]]
|
|
|
|
| 2043 |
|
| 2044 |
[[package]]
|
| 2045 |
name = "onnxruntime"
|
| 2046 |
+
version = "1.24.2"
|
| 2047 |
source = { registry = "https://pypi.org/simple" }
|
| 2048 |
dependencies = [
|
|
|
|
| 2049 |
{ name = "flatbuffers" },
|
| 2050 |
{ name = "numpy" },
|
| 2051 |
{ name = "packaging" },
|
|
|
|
| 2053 |
{ name = "sympy" },
|
| 2054 |
]
|
| 2055 |
wheels = [
|
| 2056 |
+
{ url = "https://files.pythonhosted.org/packages/2c/4e/050c947924ffd8ff856d219d8f83ee3d4e7dc52d5a6770ff34a15675c437/onnxruntime-1.24.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:69d1c75997276106d24e65da2e69ec4302af1b117fef414e2154740cde0f6214", size = 17217298, upload-time = "2026-02-19T17:15:09.891Z" },
|
| 2057 |
+
{ url = "https://files.pythonhosted.org/packages/30/17/c814121dff4de962476ced979c402c3cce72d5d46e87099610b47a1f2622/onnxruntime-1.24.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:670d7e671af2dbd17638472f9b9ff98041889efd7150718406b9ea989312d064", size = 15027128, upload-time = "2026-02-19T17:13:19.367Z" },
|
| 2058 |
+
{ url = "https://files.pythonhosted.org/packages/2c/32/4e5921ba8b82ac37cad45f1108ca6effd430f49c7f20577d53f317d166ed/onnxruntime-1.24.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93fe190ee555ae8e9c1214bcfcf13af85cd06dd835e8d835ce5a8d01056844fe", size = 17107440, upload-time = "2026-02-19T17:14:02.932Z" },
|
| 2059 |
+
{ url = "https://files.pythonhosted.org/packages/48/55/9d13c97d912db81e81c9b369a49b36f2804fa3bb8de64462e5e6bd412d0b/onnxruntime-1.24.2-cp311-cp311-win_amd64.whl", hash = "sha256:04a3a80b28dd39739463cb1e34081eed668929ba0b8e1bc861885dcdf66b7601", size = 12506375, upload-time = "2026-02-19T17:14:57.049Z" },
|
| 2060 |
+
{ url = "https://files.pythonhosted.org/packages/b0/d4/cf0e0b3bd84e7b68fe911810f7098f414936d1ffb612faa569a3fb8a76a5/onnxruntime-1.24.2-cp311-cp311-win_arm64.whl", hash = "sha256:a845096277444670b0b52855bb4aad706003540bd34986b50868e9f29606c142", size = 12167758, upload-time = "2026-02-19T17:14:47.386Z" },
|
| 2061 |
+
{ url = "https://files.pythonhosted.org/packages/23/1c/38af1cfe82c75d2b205eb5019834b0f2b0b6647ec8a20a3086168e413570/onnxruntime-1.24.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:d8a50b422d45c0144864c0977d04ad4fa50a8a48e5153056ab1f7d06ea9fc3e2", size = 17217857, upload-time = "2026-02-19T17:15:14.297Z" },
|
| 2062 |
+
{ url = "https://files.pythonhosted.org/packages/01/8a/e2d4332ae18d6383376e75141cd914256bee12c3cc439f42260eb176ceb9/onnxruntime-1.24.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76c44fc9a89dcefcd5a4ab5c6bbbb9ff1604325ab2d5d0bc9ff5a9cba7b37f4a", size = 15027167, upload-time = "2026-02-19T17:13:21.92Z" },
|
| 2063 |
+
{ url = "https://files.pythonhosted.org/packages/35/af/ad86cfbfd65d5a86204b3a30893e92c0cf3f1a56280efc5a12e69d81f52d/onnxruntime-1.24.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:09aa6f8d766b4afc3cfba68dd10be39586b49f9462fbd1386c5d5644239461ca", size = 17106547, upload-time = "2026-02-19T17:14:05.758Z" },
|
| 2064 |
+
{ url = "https://files.pythonhosted.org/packages/ee/62/9d725326f933bf8323e309956a17e52d33fb59d35bb5dda1886f94352938/onnxruntime-1.24.2-cp312-cp312-win_amd64.whl", hash = "sha256:ebcee9276420a65e5fa08b05f18379c2271b5992617e5bdc0d0d6c5ea395c1a1", size = 12506161, upload-time = "2026-02-19T17:14:59.377Z" },
|
| 2065 |
+
{ url = "https://files.pythonhosted.org/packages/aa/a9/7b06efd5802db881860d961a7cb4efacb058ed694c1c8f096c0c1499d017/onnxruntime-1.24.2-cp312-cp312-win_arm64.whl", hash = "sha256:8d770a934513f6e17937baf3438eaaec5983a23cdaedb81c9fc0dfcf26831c24", size = 12169884, upload-time = "2026-02-19T17:14:49.962Z" },
|
| 2066 |
+
{ url = "https://files.pythonhosted.org/packages/9c/98/8f5b9ae63f7f6dd5fb2d192454b915ec966a421fdd0effeeef5be7f7221f/onnxruntime-1.24.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:038ebcd8363c3835ea83eed66129e1d11d8219438892dfb7dc7656c4d4dfa1f9", size = 17217884, upload-time = "2026-02-19T17:13:36.193Z" },
|
| 2067 |
+
{ url = "https://files.pythonhosted.org/packages/55/e6/dc4dc59565c93506c45017c0dd3f536f6d1b7bc97047821af13fba2e3def/onnxruntime-1.24.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8235cc11e118ad749c497ba93288c04073eccd8cc6cc508c8a7988ae36ab52d8", size = 15026995, upload-time = "2026-02-19T17:13:25.029Z" },
|
| 2068 |
+
{ url = "https://files.pythonhosted.org/packages/ac/62/6f2851cf3237a91bc04cdb35434293a623d4f6369f79836929600da574ba/onnxruntime-1.24.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e92b46cc6d8be4286436a05382a881c88d85a2ae1ea9cfe5e6fab89f2c3e89cc", size = 17106308, upload-time = "2026-02-19T17:14:09.817Z" },
|
| 2069 |
+
{ url = "https://files.pythonhosted.org/packages/62/5a/1e2b874daf24f26e98af14281fdbdd6ae1ed548ba471c01ea2a3084c55bb/onnxruntime-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:1fd824ee4f6fb811bc47ffec2b25f129f31a087214ca91c8b4f6fda32962b78f", size = 12506095, upload-time = "2026-02-19T17:15:02.434Z" },
|
| 2070 |
+
{ url = "https://files.pythonhosted.org/packages/2d/6f/8fac5eecb94f861d56a43ede3c2ebcdce60132952d3b72003f3e3d91483c/onnxruntime-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:d8cf0acbf90771fff012c33eb2749e8aca2a8b4c66c672f30ee77c140a6fba5b", size = 12168564, upload-time = "2026-02-19T17:14:52.28Z" },
|
| 2071 |
+
{ url = "https://files.pythonhosted.org/packages/35/e4/7dfed3f445f7289a0abff709d012439c6c901915390704dd918e5f47aad3/onnxruntime-1.24.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e22fb5d9ac51b61f50cca155ce2927576cc2c42501ede6c0df23a1aeb070bdd5", size = 15036844, upload-time = "2026-02-19T17:13:27.928Z" },
|
| 2072 |
+
{ url = "https://files.pythonhosted.org/packages/90/45/9d52397e30b0d8c1692afcec5184ca9372ff4d6b0f6039bba9ad479a2563/onnxruntime-1.24.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2956f5220e7be8b09482ae5726caabf78eb549142cdb28523191a38e57fb6119", size = 17117779, upload-time = "2026-02-19T17:14:13.862Z" },
|
| 2073 |
]
|
| 2074 |
|
| 2075 |
[[package]]
|
|
|
|
| 2450 |
|
| 2451 |
[[package]]
|
| 2452 |
name = "protobuf"
|
| 2453 |
+
version = "6.33.5"
|
| 2454 |
source = { registry = "https://pypi.org/simple" }
|
| 2455 |
+
sdist = { url = "https://files.pythonhosted.org/packages/ba/25/7c72c307aafc96fa87062aa6291d9f7c94836e43214d43722e86037aac02/protobuf-6.33.5.tar.gz", hash = "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c", size = 444465, upload-time = "2026-01-29T21:51:33.494Z" }
|
| 2456 |
wheels = [
|
| 2457 |
+
{ url = "https://files.pythonhosted.org/packages/b1/79/af92d0a8369732b027e6d6084251dd8e782c685c72da161bd4a2e00fbabb/protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b", size = 425769, upload-time = "2026-01-29T21:51:21.751Z" },
|
| 2458 |
+
{ url = "https://files.pythonhosted.org/packages/55/75/bb9bc917d10e9ee13dee8607eb9ab963b7cf8be607c46e7862c748aa2af7/protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c", size = 437118, upload-time = "2026-01-29T21:51:24.022Z" },
|
| 2459 |
+
{ url = "https://files.pythonhosted.org/packages/a2/6b/e48dfc1191bc5b52950246275bf4089773e91cb5ba3592621723cdddca62/protobuf-6.33.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5", size = 427766, upload-time = "2026-01-29T21:51:25.413Z" },
|
| 2460 |
+
{ url = "https://files.pythonhosted.org/packages/4e/b1/c79468184310de09d75095ed1314b839eb2f72df71097db9d1404a1b2717/protobuf-6.33.5-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190", size = 324638, upload-time = "2026-01-29T21:51:26.423Z" },
|
| 2461 |
+
{ url = "https://files.pythonhosted.org/packages/c5/f5/65d838092fd01c44d16037953fd4c2cc851e783de9b8f02b27ec4ffd906f/protobuf-6.33.5-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd", size = 339411, upload-time = "2026-01-29T21:51:27.446Z" },
|
| 2462 |
+
{ url = "https://files.pythonhosted.org/packages/9b/53/a9443aa3ca9ba8724fdfa02dd1887c1bcd8e89556b715cfbacca6b63dbec/protobuf-6.33.5-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0", size = 323465, upload-time = "2026-01-29T21:51:28.925Z" },
|
| 2463 |
+
{ url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" },
|
| 2464 |
]
|
| 2465 |
|
| 2466 |
[[package]]
|
|
|
|
| 2542 |
{ url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" },
|
| 2543 |
]
|
| 2544 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2545 |
[[package]]
|
| 2546 |
name = "pybase64"
|
| 2547 |
version = "1.4.3"
|
|
|
|
| 2792 |
|
| 2793 |
[[package]]
|
| 2794 |
name = "pypika"
|
| 2795 |
+
version = "0.51.1"
|
| 2796 |
source = { registry = "https://pypi.org/simple" }
|
| 2797 |
+
sdist = { url = "https://files.pythonhosted.org/packages/f8/78/cbaebba88e05e2dcda13ca203131b38d3640219f20ebb49676d26714861b/pypika-0.51.1.tar.gz", hash = "sha256:c30c7c1048fbf056fd3920c5a2b88b0c29dd190a9b2bee971fd17e4abe4d0ebe", size = 80919, upload-time = "2026-02-04T11:27:48.304Z" }
|
| 2798 |
+
wheels = [
|
| 2799 |
+
{ url = "https://files.pythonhosted.org/packages/57/83/c77dfeed04022e8930b08eedca2b6e5efed256ab3321396fde90066efb65/pypika-0.51.1-py2.py3-none-any.whl", hash = "sha256:77985b4d7ce71b9905255bf12468cf598349e98837c037541cfc240e528aec46", size = 60585, upload-time = "2026-02-04T11:27:46.251Z" },
|
| 2800 |
+
]
|
| 2801 |
|
| 2802 |
[[package]]
|
| 2803 |
name = "pyproject-hooks"
|
|
|
|
| 2808 |
{ url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" },
|
| 2809 |
]
|
| 2810 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2811 |
[[package]]
|
| 2812 |
name = "pytest"
|
| 2813 |
version = "9.0.2"
|
|
|
|
| 3291 |
{ url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" },
|
| 3292 |
]
|
| 3293 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3294 |
[[package]]
|
| 3295 |
name = "safehttpx"
|
| 3296 |
version = "0.1.7"
|