vomebook commited on
Commit
c76eef5
·
1 Parent(s): c7924d4

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +124 -16
app.py CHANGED
@@ -3,34 +3,33 @@
3
  from __future__ import annotations
4
 
5
  import gzip
 
6
  import json
7
- import posixpath
8
  import random
9
  import re
 
10
  import time
11
  from contextlib import asynccontextmanager
12
- from datetime import datetime
13
  from pathlib import Path
14
  from typing import Optional
15
  from urllib.parse import quote
16
 
17
  from fastapi import FastAPI, Query
18
- from fastapi import HTTPException
19
  from fastapi.middleware.cors import CORSMiddleware
20
  from fastapi.middleware.gzip import GZipMiddleware
21
  from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, StreamingResponse
22
  from fastapi.staticfiles import StaticFiles
23
  from pydantic import BaseModel
24
 
25
-
26
  BASE_DIR = Path(__file__).resolve().parent
27
  DATA_PATH = BASE_DIR / "data/search_data.json.gz"
28
  FOLDER_TREE_PATH = BASE_DIR / "data/folder_tree.json.gz"
29
  FOLDER_BROWSER_PATH = BASE_DIR / "data/folder_browser.json.gz"
30
- FULLTEXT_MANIFEST_PATH = BASE_DIR / "data/fulltext_manifest.json.gz"
31
 
32
  records: list[dict] = []
33
  record_map: dict[str, dict] = {}
 
34
  sources: list[dict] = []
35
  source_counts: dict[str, int] = {}
36
  folder_tree_data: dict[str, list[dict]] = {}
@@ -42,6 +41,72 @@ word_index: dict[str, set[int]] = {}
42
  did_you_mean_vocab: dict[str, int] = {}
43
 
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  def tokenize(text: str) -> list[str]:
46
  text_lower = (text or "").lower()
47
  return list(set(re.findall(r"[a-z0-9]+|[\u4e00-\u9fff\u3400-\u4dbf]+", text_lower)))
@@ -125,13 +190,14 @@ def build_indexes() -> None:
125
 
126
 
127
  def load_data() -> None:
128
- global records, record_map, sources, source_counts, folder_tree_data, folder_browser_data
129
  start = time.time()
130
  payload = load_json_gz(DATA_PATH)
131
  records = payload.get("records", [])
132
  sources = payload.get("sources", [])
133
  source_counts = {item["slug"]: item.get("count", 0) for item in sources}
134
  record_map = {rec["doc_id"]: rec for rec in records}
 
135
  folder_tree_data = load_json_gz(FOLDER_TREE_PATH)
136
  folder_browser_data = load_json_gz(FOLDER_BROWSER_PATH)
137
  build_indexes()
@@ -184,6 +250,15 @@ def trim_record(rec: dict) -> dict:
184
  }
185
 
186
 
 
 
 
 
 
 
 
 
 
187
  def search(q="", sources_filter=None, folders=None, min_size=None, max_size=None, page=1, page_size=100, sort="relevance", exact=False, search_paths=True):
188
  q = q.strip()
189
  if not q:
@@ -227,7 +302,33 @@ def search(q="", sources_filter=None, folders=None, min_size=None, max_size=None
227
 
228
  total = len(filtered)
229
  start = (page - 1) * page_size
230
- result_items = [trim_record(records[idx]) for idx in filtered[start:start + page_size]]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
  return {"results": result_items, "total": total, "page": page, "page_size": page_size, "did_you_mean": None}
232
 
233
 
@@ -246,8 +347,13 @@ def get_folder_contents(source_slug: str, path: str) -> dict:
246
 
247
  @asynccontextmanager
248
  async def lifespan(app: FastAPI):
 
249
  load_data()
250
- yield
 
 
 
 
251
 
252
 
253
  app = FastAPI(title="VOMEBOOK Search", version="1.0", lifespan=lifespan)
@@ -266,22 +372,30 @@ class SearchRequest(BaseModel):
266
  sort: str = "relevance"
267
  exact: bool = False
268
  search_paths: bool = True
 
269
 
270
 
271
  class ZipRequest(BaseModel):
272
  doc_ids: list[str] = []
273
 
274
 
 
 
 
 
 
 
 
275
  @app.post("/api/search")
276
  def api_search(body: SearchRequest):
277
- return JSONResponse(search(body.q, body.sources, body.folders, body.min_size, body.max_size, body.page, body.page_size, body.sort, body.exact, body.search_paths))
278
 
279
 
280
  @app.post("/api/search/{source_slug}")
281
  def api_search_source(source_slug: str, body: SearchRequest):
282
  if source_slug not in source_counts:
283
  return JSONResponse({"error": "source not found", "results": [], "total": 0}, status_code=404)
284
- return JSONResponse(search(body.q, [source_slug], body.folders, body.min_size, body.max_size, body.page, body.page_size, body.sort, body.exact, body.search_paths))
285
 
286
 
287
  @app.get("/api/sources")
@@ -397,12 +511,6 @@ def api_zip(req: ZipRequest):
397
  return JSONResponse({"error": f"zip failed: {exc}"}, status_code=500)
398
 
399
 
400
- @app.get("/api/fulltext-manifest")
401
- def api_fulltext_manifest():
402
- return JSONResponse(load_json_gz(FULLTEXT_MANIFEST_PATH))
403
-
404
-
405
- app.mount("/data", StaticFiles(directory=str(BASE_DIR / "data")), name="data")
406
  app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static"), html=True), name="static")
407
  app.mount("/icons", StaticFiles(directory=str(BASE_DIR / "static/icons")), name="icons")
408
 
 
3
  from __future__ import annotations
4
 
5
  import gzip
6
+ import hashlib
7
  import json
 
8
  import random
9
  import re
10
+ import sqlite3
11
  import time
12
  from contextlib import asynccontextmanager
 
13
  from pathlib import Path
14
  from typing import Optional
15
  from urllib.parse import quote
16
 
17
  from fastapi import FastAPI, Query
 
18
  from fastapi.middleware.cors import CORSMiddleware
19
  from fastapi.middleware.gzip import GZipMiddleware
20
  from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, StreamingResponse
21
  from fastapi.staticfiles import StaticFiles
22
  from pydantic import BaseModel
23
 
 
24
  BASE_DIR = Path(__file__).resolve().parent
25
  DATA_PATH = BASE_DIR / "data/search_data.json.gz"
26
  FOLDER_TREE_PATH = BASE_DIR / "data/folder_tree.json.gz"
27
  FOLDER_BROWSER_PATH = BASE_DIR / "data/folder_browser.json.gz"
28
+ FULLTEXT_DIR = BASE_DIR / "data/fulltext"
29
 
30
  records: list[dict] = []
31
  record_map: dict[str, dict] = {}
32
+ record_map_index: dict[str, int] = {}
33
  sources: list[dict] = []
34
  source_counts: dict[str, int] = {}
35
  folder_tree_data: dict[str, list[dict]] = {}
 
41
  did_you_mean_vocab: dict[str, int] = {}
42
 
43
 
44
+ def query_tokens(text: str) -> list[str]:
45
+ return list(dict.fromkeys(re.findall(r"[a-z0-9]+|[\u4e00-\u9fff\u3400-\u4dbf]+", (text or "").lower())))
46
+
47
+
48
+ def decode_doc_ids(payload: bytes, source: str) -> set[str]:
49
+ results = set()
50
+ number = shift = previous = 0
51
+ for byte in payload:
52
+ number |= (byte & 0x7F) << shift
53
+ if byte & 0x80:
54
+ shift += 7
55
+ continue
56
+ previous += number
57
+ results.add(f"{source}:{previous}")
58
+ number = shift = 0
59
+ return results
60
+
61
+
62
+ class FulltextDatabases:
63
+ def __init__(self, directory: Path):
64
+ self.connections = {}
65
+ for path in directory.glob("*.sqlite3"):
66
+ uri = f"file:{path.resolve()}?mode=ro&immutable=1"
67
+ self.connections[path.stem] = sqlite3.connect(uri, uri=True, check_same_thread=False)
68
+
69
+ def close(self) -> None:
70
+ for connection in self.connections.values():
71
+ connection.close()
72
+
73
+ def search_source(self, source: str, query: str) -> set[str]:
74
+ connection = self.connections.get(source)
75
+ tokens = query_tokens(query)
76
+ if connection is None or not tokens:
77
+ return set()
78
+ matched = None
79
+ for token in tokens:
80
+ token_hash = hashlib.sha256(token.encode("utf-8")).digest()[:16]
81
+ row = connection.execute("SELECT docs FROM postings WHERE token_hash = ?", (token_hash,)).fetchone()
82
+ token_matches = decode_doc_ids(row[0], source) if row else set()
83
+ matched = token_matches if matched is None else matched & token_matches
84
+ if not matched:
85
+ return set()
86
+ return matched or set()
87
+
88
+ def summaries(self, doc_ids: list[str]) -> dict[str, str]:
89
+ grouped = {}
90
+ for doc_id in doc_ids:
91
+ grouped.setdefault(doc_id.split(":", 1)[0], []).append(doc_id)
92
+ summaries = {}
93
+ for source, source_doc_ids in grouped.items():
94
+ connection = self.connections.get(source)
95
+ if connection is None:
96
+ continue
97
+ for offset in range(0, len(source_doc_ids), 500):
98
+ batch = source_doc_ids[offset:offset + 500]
99
+ rows = connection.execute(
100
+ f"SELECT doc_id, summary FROM documents WHERE doc_id IN ({','.join('?' for _ in batch)})",
101
+ batch,
102
+ )
103
+ summaries.update(rows)
104
+ return summaries
105
+
106
+
107
+ fulltext_databases: Optional[FulltextDatabases] = None
108
+
109
+
110
  def tokenize(text: str) -> list[str]:
111
  text_lower = (text or "").lower()
112
  return list(set(re.findall(r"[a-z0-9]+|[\u4e00-\u9fff\u3400-\u4dbf]+", text_lower)))
 
190
 
191
 
192
  def load_data() -> None:
193
+ global records, record_map, record_map_index, sources, source_counts, folder_tree_data, folder_browser_data
194
  start = time.time()
195
  payload = load_json_gz(DATA_PATH)
196
  records = payload.get("records", [])
197
  sources = payload.get("sources", [])
198
  source_counts = {item["slug"]: item.get("count", 0) for item in sources}
199
  record_map = {rec["doc_id"]: rec for rec in records}
200
+ record_map_index = {rec["doc_id"]: idx for idx, rec in enumerate(records)}
201
  folder_tree_data = load_json_gz(FOLDER_TREE_PATH)
202
  folder_browser_data = load_json_gz(FOLDER_BROWSER_PATH)
203
  build_indexes()
 
250
  }
251
 
252
 
253
+ def add_summaries(items: list[dict]) -> list[dict]:
254
+ if fulltext_databases is None or not items:
255
+ return items
256
+ summaries = fulltext_databases.summaries([item["doc_id"] for item in items])
257
+ for item in items:
258
+ item["snippet"] = summaries.get(item["doc_id"], "")
259
+ return items
260
+
261
+
262
  def search(q="", sources_filter=None, folders=None, min_size=None, max_size=None, page=1, page_size=100, sort="relevance", exact=False, search_paths=True):
263
  q = q.strip()
264
  if not q:
 
302
 
303
  total = len(filtered)
304
  start = (page - 1) * page_size
305
+ result_items = add_summaries([trim_record(records[idx]) for idx in filtered[start:start + page_size]])
306
+ return {"results": result_items, "total": total, "page": page, "page_size": page_size, "did_you_mean": None}
307
+
308
+
309
+ def fulltext_search(q="", sources_filter=None, folders=None, min_size=None, max_size=None, page=1, page_size=100, sort="relevance", exact=False):
310
+ q = q.strip()
311
+ if not q or fulltext_databases is None:
312
+ return search(q, sources_filter, folders, min_size, max_size, page, page_size, sort, exact, True)
313
+
314
+ source_slugs = sources_filter or list(source_counts)
315
+ matched_doc_ids = set()
316
+ for source_slug in source_slugs:
317
+ matched_doc_ids.update(fulltext_databases.search_source(source_slug, q))
318
+ indices = [idx for doc_id in matched_doc_ids if (idx := record_map_index.get(doc_id)) is not None]
319
+ filtered = apply_filters(indices, sources_filter, folders, min_size, max_size)
320
+
321
+ tokens = query_tokens(q)
322
+ if sort == "relevance":
323
+ filtered.sort(key=lambda idx: (-score_record(idx, tokens, True), records[idx]["display_rel_path"].lower()))
324
+ elif sort == "name":
325
+ filtered.sort(key=lambda idx: records[idx]["display_rel_path"].lower())
326
+ elif sort == "size":
327
+ filtered.sort(key=lambda idx: (-records[idx].get("size", 0), records[idx]["display_rel_path"].lower()))
328
+
329
+ total = len(filtered)
330
+ start = (page - 1) * page_size
331
+ result_items = add_summaries([trim_record(records[idx]) for idx in filtered[start:start + page_size]])
332
  return {"results": result_items, "total": total, "page": page, "page_size": page_size, "did_you_mean": None}
333
 
334
 
 
347
 
348
  @asynccontextmanager
349
  async def lifespan(app: FastAPI):
350
+ global fulltext_databases
351
  load_data()
352
+ fulltext_databases = FulltextDatabases(FULLTEXT_DIR)
353
+ try:
354
+ yield
355
+ finally:
356
+ fulltext_databases.close()
357
 
358
 
359
  app = FastAPI(title="VOMEBOOK Search", version="1.0", lifespan=lifespan)
 
372
  sort: str = "relevance"
373
  exact: bool = False
374
  search_paths: bool = True
375
+ fulltext: bool = False
376
 
377
 
378
  class ZipRequest(BaseModel):
379
  doc_ids: list[str] = []
380
 
381
 
382
+ def run_search(body: SearchRequest, sources_filter=None):
383
+ selected_sources = sources_filter if sources_filter is not None else body.sources
384
+ if body.fulltext:
385
+ return fulltext_search(body.q, selected_sources, body.folders, body.min_size, body.max_size, body.page, body.page_size, body.sort, body.exact)
386
+ return search(body.q, selected_sources, body.folders, body.min_size, body.max_size, body.page, body.page_size, body.sort, body.exact, body.search_paths)
387
+
388
+
389
  @app.post("/api/search")
390
  def api_search(body: SearchRequest):
391
+ return JSONResponse(run_search(body))
392
 
393
 
394
  @app.post("/api/search/{source_slug}")
395
  def api_search_source(source_slug: str, body: SearchRequest):
396
  if source_slug not in source_counts:
397
  return JSONResponse({"error": "source not found", "results": [], "total": 0}, status_code=404)
398
+ return JSONResponse(run_search(body, [source_slug]))
399
 
400
 
401
  @app.get("/api/sources")
 
511
  return JSONResponse({"error": f"zip failed: {exc}"}, status_code=500)
512
 
513
 
 
 
 
 
 
 
514
  app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static"), html=True), name="static")
515
  app.mount("/icons", StaticFiles(directory=str(BASE_DIR / "static/icons")), name="icons")
516