davanstrien HF Staff commited on
Commit
a10a183
·
verified ·
1 Parent(s): 252f72f

Deploy DuckDB backend v3 (dev)

Browse files
Files changed (4) hide show
  1. Dockerfile +16 -0
  2. README.md +23 -5
  3. app.py +483 -0
  4. requirements.txt +14 -0
Dockerfile ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
2
+
3
+ COPY ./requirements.txt /code/requirements.txt
4
+ RUN uv pip install --system --no-cache-dir -r /code/requirements.txt
5
+
6
+ RUN useradd -m -u 1000 user
7
+ USER user
8
+ ENV HOME=/home/user \
9
+ PATH=/home/user/.local/bin:$PATH \
10
+ HF_HOME=/home/user/.cache/huggingface \
11
+ HF_HUB_ENABLE_HF_TRANSFER=1
12
+
13
+ WORKDIR /code
14
+ COPY --chown=user:user . .
15
+
16
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,10 +1,28 @@
1
  ---
2
- title: Hub Search V3 Dev
3
- emoji: 👀
4
- colorFrom: pink
5
- colorTo: gray
6
  sdk: docker
 
7
  pinned: false
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Hub Search v3 Dev
3
+ emoji: 🔎
4
+ colorFrom: indigo
5
+ colorTo: blue
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
+ private: true
10
+ short_description: DuckDB vector search backend (dev)
11
  ---
12
 
13
+ # hub-search-v3 (dev backend)
14
+
15
+ Replacement backend for the Hub semantic-search Space. FastAPI + in-memory DuckDB
16
+ brute-force cosine search over Qwen3-Embedding-0.6B vectors. Drop-in compatible
17
+ with the old `davanstrien/huggingface-datasets-search-v2` API.
18
+
19
+ - Boot: loads `davanstrien/search-v2-embeddings` (both configs) into in-memory
20
+ DuckDB `FLOAT[dim]` tables; loads the query model on CPU.
21
+ - Query encoding replicates the old backend so query vectors align with the seed docs.
22
+ - Endpoints: `/search/{datasets,models}`, `/similarity/{datasets,models}`,
23
+ `/trending/{datasets,models}`, `/suggest/{type}`, `/health`.
24
+
25
+ Config via env: `EMB_DIM` (default 256, MRL truncation), `DUCKDB_THREADS`,
26
+ `EMBEDDINGS_REPO`. Needs `HF_TOKEN` secret for the private seed dataset.
27
+
28
+ Private dev Space — see the revival plan note for context.
app.py ADDED
@@ -0,0 +1,483 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """hub-search-v3 backend — FastAPI + in-memory DuckDB brute-force vector search.
2
+
3
+ Drop-in compatible with the old davanstrien/huggingface-datasets-search-v2 API
4
+ (same routes / params / response shapes) so the librarian-bots front-end can be
5
+ repointed with no code change. Replaces the ChromaDB boot-time HNSW rebuild
6
+ (which timed out) with an in-memory DuckDB FLOAT[dim] table loaded once at boot.
7
+
8
+ See bench: duckdb-vss-bench-2026-07-18. Query encoding replicates the old
9
+ backend so query vectors stay aligned with the seed document embeddings.
10
+ """
11
+ import logging
12
+ import os
13
+ import time
14
+ from contextlib import asynccontextmanager
15
+ from typing import List, Optional
16
+
17
+ import duckdb
18
+ import httpx
19
+ from cashews import cache
20
+ from fastapi import FastAPI, HTTPException, Query
21
+ from fastapi.middleware.cors import CORSMiddleware
22
+ from huggingface_hub import HfApi, hf_hub_download, list_repo_files, login
23
+ from pydantic import BaseModel
24
+
25
+ os.environ.setdefault("HF_HUB_ENABLE_HF_TRANSFER", "1")
26
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
27
+ logger = logging.getLogger("hub-search-v3")
28
+
29
+ # --- config -----------------------------------------------------------------
30
+ EMBEDDING_MODEL = "Qwen/Qwen3-Embedding-0.6B"
31
+ EMBEDDINGS_REPO = os.getenv("EMBEDDINGS_REPO", "davanstrien/search-v2-embeddings")
32
+ EMB_DIM = int(os.getenv("EMB_DIM", "256")) # MRL truncation dim; 1024 = full
33
+ FULL_DIM = 1024
34
+ THREADS = int(os.getenv("DUCKDB_THREADS", "2"))
35
+ CACHE_TTL = "24h"
36
+ TRENDING_CACHE_TTL = "1h"
37
+
38
+ DS_TASK = "Given a search query, retrieve relevant model and dataset summaries that match the query. "
39
+
40
+ # config dir names inside the embeddings repo (datasets vs models slices)
41
+ DATASET_CFG = os.getenv("DATASET_CONFIG", "dataset_cards")
42
+ MODEL_CFG = os.getenv("MODEL_CONFIG", "model_cards")
43
+
44
+
45
+ def config_files(cfg: str) -> List[str]:
46
+ """Discover the parquet shards for a config dir (shard count varies across seed/v3)."""
47
+ files = [f for f in list_repo_files(EMBEDDINGS_REPO, repo_type="dataset")
48
+ if f.startswith(f"{cfg}/") and f.endswith(".parquet")]
49
+ if not files:
50
+ raise RuntimeError(f"no parquet files found under {cfg}/ in {EMBEDDINGS_REPO}")
51
+ return sorted(files)
52
+
53
+ HF_TOKEN = os.getenv("HF_TOKEN")
54
+ if HF_TOKEN:
55
+ login(token=HF_TOKEN)
56
+
57
+ cache.setup("mem://", size_limit="1gb")
58
+
59
+ # in-memory DuckDB (single connection, read-only after boot)
60
+ con = duckdb.connect(":memory:")
61
+ con.execute(f"SET threads={THREADS}")
62
+
63
+ _query_model = None
64
+ STATE = {"ready": False, "boot_seconds": None, "counts": {}, "revision": None,
65
+ "dim": EMB_DIM, "model": EMBEDDING_MODEL}
66
+
67
+
68
+ def get_query_model():
69
+ global _query_model
70
+ if _query_model is None:
71
+ from sentence_transformers import SentenceTransformer
72
+ logger.info(f"Loading query model {EMBEDDING_MODEL} on CPU")
73
+ _query_model = SentenceTransformer(EMBEDDING_MODEL, device="cpu")
74
+ return _query_model
75
+
76
+
77
+ def embed_query(text: str) -> List[float]:
78
+ """Replicate the old backend's query encoding (prompt_name='query')."""
79
+ model = get_query_model()
80
+ vec = model.encode(text, prompt_name="query", normalize_embeddings=False)
81
+ vec = vec[:EMB_DIM]
82
+ # L2 normalise (matches the stored-vector normalisation; makes cosine stable)
83
+ import numpy as np
84
+ n = np.linalg.norm(vec)
85
+ if n > 0:
86
+ vec = vec / n
87
+ return vec.astype(float).tolist()
88
+
89
+
90
+ def load_table(cfg: str, table: str, has_param: bool):
91
+ """Load one config into an in-memory FLOAT[EMB_DIM] table (MRL trunc + renorm).
92
+
93
+ Filters NULL embeddings (v3 refusal rows have no embedding). Discovers shards
94
+ dynamically so the seed and the ~1.17M-row search-v3 dataset both work.
95
+ """
96
+ paths = [hf_hub_download(EMBEDDINGS_REPO, f, repo_type="dataset") for f in config_files(cfg)]
97
+ plist = "[" + ",".join(f"'{p}'" for p in paths) + "]"
98
+ param_sel = "COALESCE(param_count,0)::BIGINT AS param_count" if has_param else "0::BIGINT AS param_count"
99
+ # slice to EMB_DIM, L2-renormalise, cast to fixed-size FLOAT[] array (nested, no correlated subquery)
100
+ con.execute(f"""
101
+ CREATE OR REPLACE TABLE {table} AS
102
+ SELECT id, summary,
103
+ COALESCE(likes,0)::BIGINT AS likes,
104
+ COALESCE(downloads,0)::BIGINT AS downloads,
105
+ last_modified,
106
+ {param_sel},
107
+ list_transform(s, x -> (x / nrm))::FLOAT[{EMB_DIM}] AS emb
108
+ FROM (
109
+ SELECT *, sqrt(list_dot_product(s, s)) AS nrm
110
+ FROM (
111
+ SELECT *, embedding[1:{EMB_DIM}] AS s
112
+ FROM read_parquet({plist})
113
+ WHERE embedding IS NOT NULL
114
+ )
115
+ )
116
+ """)
117
+ n = con.execute(f"SELECT count(*) FROM {table}").fetchone()[0]
118
+ STATE["counts"][table] = n
119
+ logger.info(f"loaded {table} (from {cfg}): {n:,} rows @ {EMB_DIM}d")
120
+
121
+
122
+ @asynccontextmanager
123
+ async def lifespan(app: FastAPI):
124
+ t0 = time.time()
125
+ logger.info(f"boot: loading model + embeddings (dim={EMB_DIM})")
126
+ get_query_model() # load model up front so first query isn't slow
127
+ try:
128
+ one = hf_hub_download(EMBEDDINGS_REPO, config_files(MODEL_CFG)[0], repo_type="dataset")
129
+ cols = con.execute(f"DESCRIBE SELECT * FROM read_parquet('{one}')").fetchall()
130
+ has_param = any(c[0] == "param_count" for c in cols)
131
+ except Exception as e:
132
+ logger.warning(f"param_count detection failed ({e}); assuming absent")
133
+ has_param = False
134
+ load_table(DATASET_CFG, "dataset_cards", has_param=False)
135
+ load_table(MODEL_CFG, "model_cards", has_param=has_param)
136
+ try:
137
+ info = HfApi().dataset_info(EMBEDDINGS_REPO)
138
+ STATE["revision"] = info.sha[:8] if info.sha else None
139
+ STATE["last_modified"] = str(getattr(info, "last_modified", None))
140
+ except Exception:
141
+ pass
142
+ STATE["boot_seconds"] = round(time.time() - t0, 1)
143
+ STATE["ready"] = True
144
+ logger.info(f"boot complete in {STATE['boot_seconds']}s; ready")
145
+ yield
146
+ await cache.close()
147
+
148
+
149
+ app = FastAPI(title="hub-search-v3", lifespan=lifespan)
150
+ app.add_middleware(
151
+ CORSMiddleware,
152
+ allow_origin_regex=r"https://.*\.(hf\.space|huggingface\.co)",
153
+ allow_credentials=True,
154
+ allow_methods=["*"],
155
+ allow_headers=["*"],
156
+ )
157
+
158
+
159
+ # --- response models (identical to old backend) -----------------------------
160
+ class QueryResult(BaseModel):
161
+ dataset_id: str
162
+ similarity: float
163
+ summary: str
164
+ likes: int
165
+ downloads: int
166
+
167
+
168
+ class QueryResponse(BaseModel):
169
+ results: List[QueryResult]
170
+
171
+
172
+ class ModelQueryResult(BaseModel):
173
+ model_id: str
174
+ similarity: float
175
+ summary: str
176
+ likes: int
177
+ downloads: int
178
+ param_count: Optional[int] = None
179
+
180
+
181
+ class ModelQueryResponse(BaseModel):
182
+ results: List[ModelQueryResult]
183
+
184
+
185
+ # --- core search -------------------------------------------------------------
186
+ def _where(min_likes, min_downloads, min_param=0, max_param=None, use_param=False):
187
+ conds = []
188
+ if min_likes > 0:
189
+ conds.append(f"likes >= {int(min_likes)}")
190
+ if min_downloads > 0:
191
+ conds.append(f"downloads >= {int(min_downloads)}")
192
+ if use_param:
193
+ conds.append("param_count > 0")
194
+ if min_param > 0:
195
+ conds.append(f"param_count >= {int(min_param)}")
196
+ if max_param is not None:
197
+ conds.append(f"param_count <= {int(max_param)}")
198
+ return ("WHERE " + " AND ".join(conds)) if conds else ""
199
+
200
+
201
+ def _vec_literal(vec):
202
+ return "[" + ",".join(repr(float(x)) for x in vec) + f"]::FLOAT[{EMB_DIM}]"
203
+
204
+
205
+ def _knn(table, qvec, k, where_sql, cols):
206
+ q = f"""SELECT {cols}, array_cosine_similarity(emb, {_vec_literal(qvec)}) AS sim
207
+ FROM {table} {where_sql} ORDER BY sim DESC LIMIT {int(k)}"""
208
+ return con.execute(q).fetchall()
209
+
210
+
211
+ def _emb_of(table, repo_id):
212
+ row = con.execute(f"SELECT emb FROM {table} WHERE id = ?", [repo_id]).fetchone()
213
+ return row[0] if row else None
214
+
215
+
216
+ async def _sort_results(rows, id_field, sort_by, k):
217
+ """rows: list of dicts with keys id, similarity, summary, likes, downloads, [param_count]."""
218
+ if sort_by == "trending":
219
+ scores = {}
220
+ tasks = [get_trending_score(r["_id"], id_field) for r in rows]
221
+ import asyncio
222
+ vals = await asyncio.gather(*tasks)
223
+ scores = {r["_id"]: v for r, v in zip(rows, vals)}
224
+ rows.sort(key=lambda r: scores.get(r["_id"], 0), reverse=True)
225
+ rows = rows[:k]
226
+ elif sort_by in ("likes", "downloads"):
227
+ rows.sort(key=lambda r: r[sort_by], reverse=True)
228
+ rows = rows[:k]
229
+ else:
230
+ rows = rows[:k]
231
+ return rows
232
+
233
+
234
+ def _rows_to_dataset(raw):
235
+ out = []
236
+ for id_, summary, likes, downloads, param, sim in raw:
237
+ out.append({"_id": id_, "dataset_id": id_, "similarity": float(sim),
238
+ "summary": summary, "likes": int(likes), "downloads": int(downloads)})
239
+ return out
240
+
241
+
242
+ def _rows_to_model(raw):
243
+ out = []
244
+ for id_, summary, likes, downloads, param, sim in raw:
245
+ out.append({"_id": id_, "model_id": id_, "similarity": float(sim),
246
+ "summary": summary, "likes": int(likes), "downloads": int(downloads),
247
+ "param_count": int(param)})
248
+ return out
249
+
250
+
251
+ COLS = "id, summary, likes, downloads, param_count"
252
+
253
+
254
+ # --- routes ------------------------------------------------------------------
255
+ @app.get("/")
256
+ async def root():
257
+ from fastapi.responses import RedirectResponse
258
+ return RedirectResponse(url="/docs")
259
+
260
+
261
+ @app.get("/health")
262
+ async def health():
263
+ return {
264
+ "ready": STATE["ready"],
265
+ "boot_seconds": STATE["boot_seconds"],
266
+ "counts": STATE["counts"],
267
+ "index_revision": STATE.get("revision"),
268
+ "index_last_modified": STATE.get("last_modified"),
269
+ "embedding_model": STATE["model"],
270
+ "dim": STATE["dim"],
271
+ "embeddings_repo": EMBEDDINGS_REPO,
272
+ }
273
+
274
+
275
+ @app.get("/search/datasets", response_model=QueryResponse)
276
+ @cache(ttl=CACHE_TTL, key="search:d:{query}:{k}:{sort_by}:{min_likes}:{min_downloads}")
277
+ async def search_datasets(
278
+ query: str,
279
+ k: int = Query(default=5, ge=1, le=100),
280
+ sort_by: str = Query(default="similarity", enum=["similarity", "likes", "downloads", "trending"]),
281
+ min_likes: int = Query(default=0, ge=0),
282
+ min_downloads: int = Query(default=0, ge=0),
283
+ ):
284
+ try:
285
+ qvec = embed_query(f"Instruct: {DS_TASK}\nQuery:{query}")
286
+ n = k * 4 if sort_by != "similarity" else k
287
+ raw = _knn("dataset_cards", qvec, n, _where(min_likes, min_downloads), COLS)
288
+ rows = await _sort_results(_rows_to_dataset(raw), "dataset", sort_by, k)
289
+ return QueryResponse(results=[QueryResult(**{k2: v for k2, v in r.items() if k2 != "_id"}) for r in rows])
290
+ except Exception as e:
291
+ logger.error(f"search datasets: {e}")
292
+ raise HTTPException(status_code=500, detail="Search failed")
293
+
294
+
295
+ @app.get("/similarity/datasets", response_model=QueryResponse)
296
+ @cache(ttl=CACHE_TTL, key="sim:d:{dataset_id}:{k}:{sort_by}:{min_likes}:{min_downloads}")
297
+ async def find_similar_datasets(
298
+ dataset_id: str,
299
+ k: int = Query(default=5, ge=1, le=100),
300
+ sort_by: str = Query(default="similarity", enum=["similarity", "likes", "downloads", "trending"]),
301
+ min_likes: int = Query(default=0, ge=0),
302
+ min_downloads: int = Query(default=0, ge=0),
303
+ ):
304
+ emb = _emb_of("dataset_cards", dataset_id)
305
+ if emb is None:
306
+ raise HTTPException(status_code=404, detail=f"Dataset ID '{dataset_id}' not found")
307
+ try:
308
+ n = k * 4 if sort_by != "similarity" else k + 1
309
+ raw = _knn("dataset_cards", emb, n, _where(min_likes, min_downloads), COLS)
310
+ rows = [r for r in _rows_to_dataset(raw) if r["_id"] != dataset_id]
311
+ rows = await _sort_results(rows, "dataset", sort_by, k)
312
+ return QueryResponse(results=[QueryResult(**{k2: v for k2, v in r.items() if k2 != "_id"}) for r in rows])
313
+ except HTTPException:
314
+ raise
315
+ except Exception as e:
316
+ logger.error(f"similarity datasets: {e}")
317
+ raise HTTPException(status_code=500, detail="Similarity search failed")
318
+
319
+
320
+ @app.get("/search/models", response_model=ModelQueryResponse)
321
+ @cache(ttl=CACHE_TTL, key="search:m:{query}:{k}:{sort_by}:{min_likes}:{min_downloads}:{min_param_count}:{max_param_count}")
322
+ async def search_models(
323
+ query: str,
324
+ k: int = Query(default=5, ge=1, le=100),
325
+ sort_by: str = Query(default="similarity", enum=["similarity", "likes", "downloads", "trending"]),
326
+ min_likes: int = Query(default=0, ge=0),
327
+ min_downloads: int = Query(default=0, ge=0),
328
+ min_param_count: int = Query(default=0, ge=0),
329
+ max_param_count: Optional[int] = Query(default=None, ge=0),
330
+ ):
331
+ try:
332
+ use_param = min_param_count > 0 or max_param_count is not None
333
+ qvec = embed_query(f"search_query: {query}")
334
+ n = k * 4 if sort_by != "similarity" else k
335
+ raw = _knn("model_cards", qvec, n,
336
+ _where(min_likes, min_downloads, min_param_count, max_param_count, use_param), COLS)
337
+ rows = await _sort_results(_rows_to_model(raw), "model", sort_by, k)
338
+ return ModelQueryResponse(results=[ModelQueryResult(**{k2: v for k2, v in r.items() if k2 != "_id"}) for r in rows])
339
+ except Exception as e:
340
+ logger.error(f"search models: {e}")
341
+ raise HTTPException(status_code=500, detail="Model search failed")
342
+
343
+
344
+ @app.get("/similarity/models", response_model=ModelQueryResponse)
345
+ @cache(ttl=CACHE_TTL, key="sim:m:{model_id}:{k}:{sort_by}:{min_likes}:{min_downloads}:{min_param_count}:{max_param_count}")
346
+ async def find_similar_models(
347
+ model_id: str,
348
+ k: int = Query(default=5, ge=1, le=100),
349
+ sort_by: str = Query(default="similarity", enum=["similarity", "likes", "downloads", "trending"]),
350
+ min_likes: int = Query(default=0, ge=0),
351
+ min_downloads: int = Query(default=0, ge=0),
352
+ min_param_count: int = Query(default=0, ge=0),
353
+ max_param_count: Optional[int] = Query(default=None, ge=0),
354
+ ):
355
+ emb = _emb_of("model_cards", model_id)
356
+ if emb is None:
357
+ raise HTTPException(status_code=404, detail=f"Model ID '{model_id}' not found")
358
+ try:
359
+ use_param = min_param_count > 0 or max_param_count is not None
360
+ n = k * 4 if sort_by != "similarity" else k + 1
361
+ raw = _knn("model_cards", emb, n,
362
+ _where(min_likes, min_downloads, min_param_count, max_param_count, use_param), COLS)
363
+ rows = [r for r in _rows_to_model(raw) if r["_id"] != model_id]
364
+ rows = await _sort_results(rows, "model", sort_by, k)
365
+ return ModelQueryResponse(results=[ModelQueryResult(**{k2: v for k2, v in r.items() if k2 != "_id"}) for r in rows])
366
+ except HTTPException:
367
+ raise
368
+ except Exception as e:
369
+ logger.error(f"similarity models: {e}")
370
+ raise HTTPException(status_code=500, detail="Model similarity search failed")
371
+
372
+
373
+ # --- trending (proxies HF API, joins summaries from the in-memory table) -----
374
+ @cache(ttl=TRENDING_CACHE_TTL, key="tscore:{item_type}:{item_id}")
375
+ async def get_trending_score(item_id: str, item_type: str) -> float:
376
+ try:
377
+ endpoint = "models" if item_type == "model" else "datasets"
378
+ async with httpx.AsyncClient(timeout=10) as c:
379
+ r = await c.get(f"https://huggingface.co/api/{endpoint}/{item_id}?expand=trendingScore")
380
+ r.raise_for_status()
381
+ return r.json().get("trendingScore", 0)
382
+ except Exception:
383
+ return 0
384
+
385
+
386
+ @app.get("/trending/datasets", response_model=QueryResponse)
387
+ @cache(ttl=TRENDING_CACHE_TTL, key="trend:d:{limit}:{min_likes}:{min_downloads}")
388
+ async def trending_datasets(
389
+ limit: int = Query(default=10, ge=1, le=100),
390
+ min_likes: int = Query(default=0, ge=0),
391
+ min_downloads: int = Query(default=0, ge=0),
392
+ ):
393
+ try:
394
+ async with httpx.AsyncClient(timeout=15) as c:
395
+ r = await c.get("https://huggingface.co/api/datasets?sort=trendingScore&limit=200")
396
+ r.raise_for_status()
397
+ items = r.json()
398
+ except Exception as e:
399
+ logger.error(f"trending datasets fetch: {e}")
400
+ raise HTTPException(status_code=502, detail="Failed to fetch trending datasets")
401
+ items = [d for d in items if d.get("likes", 0) >= min_likes and d.get("downloads", 0) >= min_downloads]
402
+ ids = [d["id"] for d in items[: limit * 3]]
403
+ if not ids:
404
+ return QueryResponse(results=[])
405
+ placeholders = ",".join("?" for _ in ids)
406
+ found = {r[0]: r for r in con.execute(
407
+ f"SELECT id, summary, likes, downloads FROM dataset_cards WHERE id IN ({placeholders})", ids
408
+ ).fetchall()}
409
+ out = []
410
+ for d in items:
411
+ row = found.get(d["id"])
412
+ if row:
413
+ out.append(QueryResult(dataset_id=d["id"], similarity=1.0, summary=row[1],
414
+ likes=d.get("likes", 0), downloads=d.get("downloads", 0)))
415
+ if len(out) >= limit:
416
+ break
417
+ return QueryResponse(results=out)
418
+
419
+
420
+ @app.get("/trending/models", response_model=ModelQueryResponse)
421
+ @cache(ttl=TRENDING_CACHE_TTL, key="trend:m:{limit}:{min_likes}:{min_downloads}:{min_param_count}:{max_param_count}")
422
+ async def trending_models(
423
+ limit: int = Query(default=10, ge=1, le=100),
424
+ min_likes: int = Query(default=0, ge=0),
425
+ min_downloads: int = Query(default=0, ge=0),
426
+ min_param_count: int = Query(default=0, ge=0),
427
+ max_param_count: Optional[int] = Query(default=None, ge=0),
428
+ ):
429
+ try:
430
+ async with httpx.AsyncClient(timeout=15) as c:
431
+ r = await c.get("https://huggingface.co/api/models?sort=trendingScore&limit=200")
432
+ r.raise_for_status()
433
+ items = r.json()
434
+ except Exception as e:
435
+ logger.error(f"trending models fetch: {e}")
436
+ raise HTTPException(status_code=502, detail="Failed to fetch trending models")
437
+ items = [m for m in items if m.get("likes", 0) >= min_likes and m.get("downloads", 0) >= min_downloads]
438
+ ids = [m["id"] for m in items[: limit * 3]]
439
+ if not ids:
440
+ return ModelQueryResponse(results=[])
441
+ placeholders = ",".join("?" for _ in ids)
442
+ found = {r[0]: r for r in con.execute(
443
+ f"SELECT id, summary, likes, downloads, param_count FROM model_cards WHERE id IN ({placeholders})", ids
444
+ ).fetchall()}
445
+ use_param = min_param_count > 0 or max_param_count is not None
446
+ out = []
447
+ for m in items:
448
+ row = found.get(m["id"])
449
+ if not row:
450
+ continue
451
+ param = int(row[4])
452
+ if use_param:
453
+ if param == 0:
454
+ continue
455
+ if min_param_count > 0 and param < min_param_count:
456
+ continue
457
+ if max_param_count is not None and param > max_param_count:
458
+ continue
459
+ out.append(ModelQueryResult(model_id=m["id"], similarity=1.0, summary=row[1],
460
+ likes=m.get("likes", 0), downloads=m.get("downloads", 0),
461
+ param_count=param))
462
+ if len(out) >= limit:
463
+ break
464
+ return ModelQueryResponse(results=out)
465
+
466
+
467
+ # --- suggest (autocomplete; old backend never implemented it — bonus) --------
468
+ @app.get("/suggest/{repo_type}")
469
+ @cache(ttl="1h", key="suggest:{repo_type}:{q}")
470
+ async def suggest(repo_type: str, q: str):
471
+ if len(q) < 2:
472
+ return {"suggestions": []}
473
+ table = "dataset_cards" if repo_type == "datasets" else "model_cards"
474
+ rows = con.execute(
475
+ f"SELECT id FROM {table} WHERE id ILIKE ? ORDER BY downloads DESC LIMIT 10",
476
+ [f"%{q}%"],
477
+ ).fetchall()
478
+ return {"suggestions": [r[0] for r in rows]}
479
+
480
+
481
+ if __name__ == "__main__":
482
+ import uvicorn
483
+ uvicorn.run(app, host="0.0.0.0", port=7860)
requirements.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ --extra-index-url https://download.pytorch.org/whl/cpu
2
+ torch==2.6.0
3
+ fastapi==0.115.9
4
+ uvicorn[standard]==0.34.3
5
+ duckdb==1.1.3
6
+ sentence-transformers==4.1.0
7
+ transformers==4.52.4
8
+ tokenizers==0.21.1
9
+ safetensors==0.5.3
10
+ huggingface-hub[hf-transfer]==0.32.4
11
+ hf-xet==1.1.3
12
+ numpy==2.2.6
13
+ cashews==7.4.0
14
+ httpx==0.28.1