Upload app.py
Browse files
app.py
CHANGED
|
@@ -46,7 +46,11 @@ source_records_map: dict[str, list[int]] = {}
|
|
| 46 |
extension_counts: dict[str, int] = {}
|
| 47 |
source_extension_counts: dict[str, dict[str, int]] = {}
|
| 48 |
word_index: dict[str, set[int]] = {}
|
|
|
|
|
|
|
|
|
|
| 49 |
TOKEN_RE = re.compile(r"[a-z0-9]+|[\u4e00-\u9fff\u3400-\u4dbf]+")
|
|
|
|
| 50 |
TOKENIZER_VERSION = "cjk-bigram-boundary-fts5-v6-snippet-anchors"
|
| 51 |
LITERAL_PREFIX = "\0literal:"
|
| 52 |
API_CACHE_TTL_SECONDS = 120
|
|
@@ -81,6 +85,7 @@ def index_tokens(text: str) -> set[str]:
|
|
| 81 |
tokens.add(part)
|
| 82 |
return tokens
|
| 83 |
|
|
|
|
| 84 |
def query_tokens(text: str) -> list[str]:
|
| 85 |
tokens = []
|
| 86 |
for part in query_terms(text):
|
|
@@ -90,6 +95,7 @@ def query_tokens(text: str) -> list[str]:
|
|
| 90 |
tokens.append(part)
|
| 91 |
return list(dict.fromkeys(tokens))
|
| 92 |
|
|
|
|
| 93 |
def literal_query_tokens(text: str) -> list[str]:
|
| 94 |
normalized = normalize_text(text)
|
| 95 |
return list(dict.fromkeys(
|
|
@@ -98,6 +104,7 @@ def literal_query_tokens(text: str) -> list[str]:
|
|
| 98 |
if not TOKEN_RE.fullmatch(normalized[index + 1])
|
| 99 |
))
|
| 100 |
|
|
|
|
| 101 |
def wildcard_required_tokens(text: str) -> list[str]:
|
| 102 |
tokens = []
|
| 103 |
for fixed_part in re.split(r"[*?]+", normalize_text(text)):
|
|
@@ -724,8 +731,11 @@ def load_json_gz(path: Path):
|
|
| 724 |
return json.loads(gzip.decompress(path.read_bytes()).decode("utf-8"))
|
| 725 |
|
| 726 |
def build_indexes() -> None:
|
| 727 |
-
global word_index, source_records_map, extension_counts, source_extension_counts, name_order, size_order, source_name_order, source_size_order
|
| 728 |
word_index = {}
|
|
|
|
|
|
|
|
|
|
| 729 |
source_records_map = {}
|
| 730 |
extension_counts = {}
|
| 731 |
source_extension_counts = {}
|
|
@@ -745,6 +755,12 @@ def build_indexes() -> None:
|
|
| 745 |
rec["_rank_path_key"] = rec["display_rel_path"].lower()
|
| 746 |
for token in tokens:
|
| 747 |
word_index.setdefault(token, set()).add(idx)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 748 |
name_order = sorted(
|
| 749 |
range(len(records)),
|
| 750 |
key=lambda idx: (records[idx]["display_rel_path"].lower(), records[idx]["doc_id"]),
|
|
@@ -900,15 +916,45 @@ def add_summaries(items: list[dict], query: str = "", matched_snippets: bool = F
|
|
| 900 |
item["snippet_complete"] = bool(payload) or (not needs_matched_snippet and item["doc_id"] in summaries)
|
| 901 |
return items
|
| 902 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 903 |
def metadata_matches(q: str, exact: bool, search_paths: bool) -> set[int]:
|
| 904 |
field = "_search_text" if search_paths else "_file_search_text"
|
| 905 |
if exact:
|
| 906 |
terms = query_terms(q)
|
| 907 |
normalized_query, pattern = compile_exact_query(q)
|
| 908 |
-
#
|
| 909 |
-
#
|
| 910 |
-
#
|
| 911 |
-
#
|
| 912 |
if has_wildcard_query(q):
|
| 913 |
candidate_set = None
|
| 914 |
for token in wildcard_required_tokens(normalized_query):
|
|
@@ -917,16 +963,10 @@ def metadata_matches(q: str, exact: bool, search_paths: bool) -> set[int]:
|
|
| 917 |
if not candidate_set:
|
| 918 |
return set()
|
| 919 |
candidates = candidate_set if candidate_set is not None else range(len(records))
|
| 920 |
-
elif not terms
|
| 921 |
candidates = range(len(records))
|
| 922 |
else:
|
| 923 |
-
|
| 924 |
-
for token in query_tokens(q):
|
| 925 |
-
token_indices = word_index.get(token, set())
|
| 926 |
-
candidate_set = set(token_indices) if candidate_set is None else candidate_set & token_indices
|
| 927 |
-
if not candidate_set:
|
| 928 |
-
return set()
|
| 929 |
-
candidates = candidate_set or set()
|
| 930 |
return {
|
| 931 |
idx for idx in candidates
|
| 932 |
if matches_normalized_exact_query(records[idx][field], normalized_query, pattern)
|
|
@@ -934,19 +974,11 @@ def metadata_matches(q: str, exact: bool, search_paths: bool) -> set[int]:
|
|
| 934 |
terms = query_terms(q)
|
| 935 |
if not terms:
|
| 936 |
return set()
|
| 937 |
-
|
| 938 |
-
|
| 939 |
-
|
| 940 |
-
if all(term in rec[field] for term in terms)
|
| 941 |
-
}
|
| 942 |
-
candidates = None
|
| 943 |
-
for token in query_tokens(q):
|
| 944 |
-
token_indices = word_index.get(token, set())
|
| 945 |
-
candidates = set(token_indices) if candidates is None else candidates & token_indices
|
| 946 |
-
if not candidates:
|
| 947 |
-
return set()
|
| 948 |
return {
|
| 949 |
-
idx for idx in
|
| 950 |
if all(term in records[idx][field] for term in terms)
|
| 951 |
}
|
| 952 |
|
|
@@ -1276,24 +1308,27 @@ def run_search(body: SearchRequest, sources_filter=None, timings=None):
|
|
| 1276 |
body.search_paths,
|
| 1277 |
body.fulltext,
|
| 1278 |
)
|
| 1279 |
-
|
| 1280 |
-
|
| 1281 |
-
|
| 1282 |
-
|
| 1283 |
-
|
| 1284 |
-
|
| 1285 |
-
|
| 1286 |
-
|
| 1287 |
-
|
| 1288 |
-
|
| 1289 |
-
|
| 1290 |
-
|
| 1291 |
-
|
| 1292 |
-
|
| 1293 |
-
|
| 1294 |
-
|
| 1295 |
-
|
| 1296 |
-
|
|
|
|
|
|
|
|
|
|
| 1297 |
|
| 1298 |
@app.post("/api/search")
|
| 1299 |
|
|
|
|
| 46 |
extension_counts: dict[str, int] = {}
|
| 47 |
source_extension_counts: dict[str, dict[str, int]] = {}
|
| 48 |
word_index: dict[str, set[int]] = {}
|
| 49 |
+
latin_all_index: dict[str, set[int]] = {}
|
| 50 |
+
latin_file_index: dict[str, set[int]] = {}
|
| 51 |
+
latin_vocabulary_all: set[str] = set()
|
| 52 |
TOKEN_RE = re.compile(r"[a-z0-9]+|[\u4e00-\u9fff\u3400-\u4dbf]+")
|
| 53 |
+
LATIN_RE = re.compile(r"[a-z0-9]+")
|
| 54 |
TOKENIZER_VERSION = "cjk-bigram-boundary-fts5-v6-snippet-anchors"
|
| 55 |
LITERAL_PREFIX = "\0literal:"
|
| 56 |
API_CACHE_TTL_SECONDS = 120
|
|
|
|
| 85 |
tokens.add(part)
|
| 86 |
return tokens
|
| 87 |
|
| 88 |
+
@lru_cache(maxsize=4096)
|
| 89 |
def query_tokens(text: str) -> list[str]:
|
| 90 |
tokens = []
|
| 91 |
for part in query_terms(text):
|
|
|
|
| 95 |
tokens.append(part)
|
| 96 |
return list(dict.fromkeys(tokens))
|
| 97 |
|
| 98 |
+
@lru_cache(maxsize=4096)
|
| 99 |
def literal_query_tokens(text: str) -> list[str]:
|
| 100 |
normalized = normalize_text(text)
|
| 101 |
return list(dict.fromkeys(
|
|
|
|
| 104 |
if not TOKEN_RE.fullmatch(normalized[index + 1])
|
| 105 |
))
|
| 106 |
|
| 107 |
+
@lru_cache(maxsize=4096)
|
| 108 |
def wildcard_required_tokens(text: str) -> list[str]:
|
| 109 |
tokens = []
|
| 110 |
for fixed_part in re.split(r"[*?]+", normalize_text(text)):
|
|
|
|
| 731 |
return json.loads(gzip.decompress(path.read_bytes()).decode("utf-8"))
|
| 732 |
|
| 733 |
def build_indexes() -> None:
|
| 734 |
+
global word_index, latin_all_index, latin_file_index, latin_vocabulary_all, source_records_map, extension_counts, source_extension_counts, name_order, size_order, source_name_order, source_size_order
|
| 735 |
word_index = {}
|
| 736 |
+
latin_all_index = {}
|
| 737 |
+
latin_file_index = {}
|
| 738 |
+
latin_vocabulary_all = set()
|
| 739 |
source_records_map = {}
|
| 740 |
extension_counts = {}
|
| 741 |
source_extension_counts = {}
|
|
|
|
| 755 |
rec["_rank_path_key"] = rec["display_rel_path"].lower()
|
| 756 |
for token in tokens:
|
| 757 |
word_index.setdefault(token, set()).add(idx)
|
| 758 |
+
if LATIN_RE.fullmatch(token):
|
| 759 |
+
latin_all_index.setdefault(token, set()).add(idx)
|
| 760 |
+
latin_vocabulary_all.add(token)
|
| 761 |
+
for token in TOKEN_RE.findall(rec["_file_search_text"]):
|
| 762 |
+
if LATIN_RE.fullmatch(token):
|
| 763 |
+
latin_file_index.setdefault(token, set()).add(idx)
|
| 764 |
name_order = sorted(
|
| 765 |
range(len(records)),
|
| 766 |
key=lambda idx: (records[idx]["display_rel_path"].lower(), records[idx]["doc_id"]),
|
|
|
|
| 916 |
item["snippet_complete"] = bool(payload) or (not needs_matched_snippet and item["doc_id"] in summaries)
|
| 917 |
return items
|
| 918 |
|
| 919 |
+
def latin_substring_candidates(term: str, search_paths: bool) -> set[int]:
|
| 920 |
+
index = latin_all_index if search_paths else latin_file_index
|
| 921 |
+
candidates: set[int] = set()
|
| 922 |
+
for token in latin_vocabulary_all:
|
| 923 |
+
if term in token:
|
| 924 |
+
candidates |= index.get(token, ())
|
| 925 |
+
return candidates
|
| 926 |
+
|
| 927 |
+
def metadata_prefilter(terms: list[str], search_paths: bool) -> set[int] | None:
|
| 928 |
+
candidates: set[int] | None = None
|
| 929 |
+
for term in terms:
|
| 930 |
+
if LATIN_RE.fullmatch(term):
|
| 931 |
+
term_candidates = latin_substring_candidates(term, search_paths)
|
| 932 |
+
else:
|
| 933 |
+
term_candidates = None
|
| 934 |
+
for token in query_tokens(term):
|
| 935 |
+
indices = word_index.get(token)
|
| 936 |
+
if not indices:
|
| 937 |
+
term_candidates = set()
|
| 938 |
+
break
|
| 939 |
+
term_candidates = set(indices) if term_candidates is None else term_candidates & indices
|
| 940 |
+
if not term_candidates:
|
| 941 |
+
break
|
| 942 |
+
if term_candidates is None:
|
| 943 |
+
continue
|
| 944 |
+
candidates = term_candidates if candidates is None else candidates & term_candidates
|
| 945 |
+
if not candidates:
|
| 946 |
+
return set()
|
| 947 |
+
return candidates
|
| 948 |
+
|
| 949 |
def metadata_matches(q: str, exact: bool, search_paths: bool) -> set[int]:
|
| 950 |
field = "_search_text" if search_paths else "_file_search_text"
|
| 951 |
if exact:
|
| 952 |
terms = query_terms(q)
|
| 953 |
normalized_query, pattern = compile_exact_query(q)
|
| 954 |
+
# CJK literals necessarily contain all of their indexed unigram/bigram
|
| 955 |
+
# tokens, so candidate intersection is lossless there. Latin substrings
|
| 956 |
+
# are resolved through the per-field Latin token index, and
|
| 957 |
+
# punctuation-only queries retain the full-range scan.
|
| 958 |
if has_wildcard_query(q):
|
| 959 |
candidate_set = None
|
| 960 |
for token in wildcard_required_tokens(normalized_query):
|
|
|
|
| 963 |
if not candidate_set:
|
| 964 |
return set()
|
| 965 |
candidates = candidate_set if candidate_set is not None else range(len(records))
|
| 966 |
+
elif not terms:
|
| 967 |
candidates = range(len(records))
|
| 968 |
else:
|
| 969 |
+
candidates = metadata_prefilter(terms, search_paths) or set()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 970 |
return {
|
| 971 |
idx for idx in candidates
|
| 972 |
if matches_normalized_exact_query(records[idx][field], normalized_query, pattern)
|
|
|
|
| 974 |
terms = query_terms(q)
|
| 975 |
if not terms:
|
| 976 |
return set()
|
| 977 |
+
candidates = metadata_prefilter(terms, search_paths)
|
| 978 |
+
if candidates is None:
|
| 979 |
+
candidates = range(len(records))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 980 |
return {
|
| 981 |
+
idx for idx in candidates
|
| 982 |
if all(term in records[idx][field] for term in terms)
|
| 983 |
}
|
| 984 |
|
|
|
|
| 1308 |
body.search_paths,
|
| 1309 |
body.fulltext,
|
| 1310 |
)
|
| 1311 |
+
def build_payload():
|
| 1312 |
+
result = (
|
| 1313 |
+
fulltext_search(
|
| 1314 |
+
body.q, selected_sources, body.folders, body.min_size, body.max_size,
|
| 1315 |
+
body.page, body.page_size, body.sort, body.exact, body.search_paths,
|
| 1316 |
+
timings,
|
| 1317 |
+
)
|
| 1318 |
+
if body.fulltext
|
| 1319 |
+
else search(
|
| 1320 |
+
body.q, selected_sources, body.folders, body.min_size, body.max_size,
|
| 1321 |
+
body.page, body.page_size, body.sort, body.exact, body.search_paths,
|
| 1322 |
+
timings,
|
| 1323 |
+
)
|
| 1324 |
+
)
|
| 1325 |
+
payload = dict(result)
|
| 1326 |
+
requested_sources = set(selected_sources or source_counts)
|
| 1327 |
+
payload["indexing"] = bool(body.fulltext and not requested_sources.issubset(ready_fulltext_sources()))
|
| 1328 |
+
payload["index_generation"] = fulltext_generation
|
| 1329 |
+
return payload
|
| 1330 |
+
|
| 1331 |
+
return cached_search_payload(key, build_payload, timings)
|
| 1332 |
|
| 1333 |
@app.post("/api/search")
|
| 1334 |
|