Upload app.py
Browse files
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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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(
|
| 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 |
|