beacon-mfg-mcp 1.3.2 → 1.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/RELEASE.md +17 -3
  2. package/package.json +1 -1
  3. package/server.py +194 -55
package/RELEASE.md CHANGED
@@ -1,4 +1,4 @@
1
- # Beacon-MFG 只读 MCP 服务 · v1.3.2
1
+ # Beacon-MFG 只读 MCP 服务 · v1.3.3
2
2
 
3
3
  让任意支持 MCP 的主流 agent(Claude Desktop / Cline / Continue / WorkBuddy 等)能够
4
4
  **检索与调用**已发布到 Cloudflare Pages(或 GitHub)的灯塔工厂供应商数据,
@@ -21,7 +21,21 @@ MCP 只做**只读检索**。后端数据采集、英文翻译、库维护等流
21
21
  后三个构成「采购寻源」多轮会话链路 `start_sourcing → answer_sourcing → refine_sourcing`,
22
22
  依赖仓库平级的 `skills/rfq-kernel` 桥接模块。
23
23
 
24
- ## 本次更新(v1.3.2)
24
+ ## 本次更新(v1.3.3)
25
+ - **本地检索免提交、免重启即时可见**:这是 v1.3.1「worktree 兜底」的收尾。v1.3.1 只让**单文件读**走了工作树,
26
+ 但关键词/城市检索的**召回**走的是**批量读** `_read_many_text()`——它在 git 模式下恒走 `git archive HEAD`
27
+ (已提交快照),于是**改完数据、重建索引后,关键词检索仍读旧索引、必须 commit 才可见**。
28
+ 本次把批量读也改为「**工作树优先 → `git archive HEAD` 兜底 → HTTP**」。
29
+ - **进程内 + 磁盘缓存按文件新鲜度失效**:新增 `_wt_mtime` / `_cache_stale`,各 loader
30
+ (manifest / 别名 / 国标名表 / cap 索引 / city / 索引桶 / fp 分片 / 能力词)改为
31
+ **按工作树文件 mtime 判新**——派生层一重建,MCP 立刻重读,**无需重启进程**;
32
+ 磁盘索引缓存(`idx-*`)加 **mtime 印记**(`repr` 精确往返),同秒重写也能识别。
33
+ - **可选 TTL**:`BEACON_CACHE_TTL`(秒,默认 `0`=关闭)可让进程内缓存周期性刷新;
34
+ 非工作树模式(`BEACON_WORKTREE=0`)保持旧的「提交后 / 重启后生效」行为不变。
35
+ - 已用**真实注入实验**验证:单进程、不 commit、只 sleep,`data/gb` 新记录经本地重建后
36
+ 关键词检索由 `0` 命中变 `1` 命中。
37
+
38
+ ## 本次更新(v1.3.2,已发布)
25
39
  - **npm 启动器锁定 UTF-8**:`bin/beacon-mfg-mcp.js` 拉起 python 时注入 `PYTHONUTF8=1` / `PYTHONIOENCODING=utf-8`,
26
40
  修复中文 Windows 上 python 按 GBK 写 stdout、被客户端按 UTF-8 解码导致的工具描述/结果乱码。
27
41
  - **HTTP 兜底镜像**:默认源 `beacon-mfg.pages.dev` 不可达时,自动回退到 GitHub 镜像
@@ -40,7 +54,7 @@ MCP 只做**只读检索**。后端数据采集、英文翻译、库维护等流
40
54
  ## 安装(三选一)
41
55
 
42
56
  ### 1. 从 GitHub Release 下载(推荐,零依赖)
43
- 下载本 Release 的 `beacon-mfg-mcp-mcp-v1.3.2.tar.gz`,解压后:
57
+ 下载本 Release 的 `beacon-mfg-mcp-mcp-v1.3.3.tar.gz`,解压后:
44
58
  ```jsonc
45
59
  {
46
60
  "mcpServers": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "beacon-mfg-mcp",
3
- "version": "1.3.2",
3
+ "version": "1.3.3",
4
4
  "description": "Read-only MCP server exposing the BeaconMFG灯塔工厂 supplier directory (search vendors, get vendor detail, get capability card, multi-turn sourcing) to any MCP-compatible agent. No API key required.",
5
5
  "keywords": [
6
6
  "mcp",
package/server.py CHANGED
@@ -208,14 +208,71 @@ _alias_cache: Optional[Dict[str, Any]] = None
208
208
  _gb_name_cache: Optional[Dict[str, str]] = None
209
209
 
210
210
 
211
+ # ───────────────────────────────────────────────────────────────
212
+ # 派生层缓存失效(让「后台上传 → 立刻可见」无需提交 / 无需重启)
213
+ #
214
+ # BEACON_WORKTREE=1(默认)下,postfetch 会把 fp / 倒排索引 / 清单等派生层重建到
215
+ # 工作树。下面这组机制让 MCP 在不提交、不重启的前提下读到新版本:
216
+ # - 磁盘索引缓存(idx-*)按「HEAD sha + 工作树 mtime」判新(见 _index_text)
217
+ # - 进程内缓存(manifest/alias/cap/gb_name/fp_index/桶/分片)按文件 mtime 判新
218
+ # (见 _cache_stale),或按 BEACON_CACHE_TTL(秒)周期刷新。
219
+ # - 批量读(_read_many_text,被 _candidate_shards / _records_from_paths 使用)
220
+ # 改为**工作树优先**、git archive HEAD 兜底 —— 否则这两条关键词 / 城市检索的
221
+ # 关键路径会绕过上面的失效机制,永远读到已提交快照。
222
+ # 非工作树模式(BEACON_WORKTREE=0)或 HTTP 模式:恢复旧的「提交后 / 重启后生效」。
223
+ BEACON_CACHE_TTL = float(os.environ.get("BEACON_CACHE_TTL", "0") or "0")
224
+
225
+ _mtime_cache: Dict[str, tuple] = {} # relpath -> (checked_at, mtime),2s 内复用避免热路径狂 stat
226
+
227
+
228
+ def _wt_mtime(relpath: str) -> Optional[float]:
229
+ """工作树文件 mtime(秒)。非工作树模式或文件不存在 → None(不按 mtime 判新)。"""
230
+ if not REPO or os.environ.get("BEACON_WORKTREE", "1") == "0":
231
+ return None
232
+ now = time.time()
233
+ c = _mtime_cache.get(relpath)
234
+ if c is not None and (now - c[0]) < 2.0:
235
+ return c[1]
236
+ p = os.path.join(REPO, relpath)
237
+ try:
238
+ m = os.stat(p).st_mtime
239
+ except OSError:
240
+ m = None
241
+ _mtime_cache[relpath] = (now, m)
242
+ return m
243
+
244
+
245
+ def _wt_mtime_any(relpaths) -> Optional[float]:
246
+ """多文件取最大 mtime(任一不存在则忽略),全不存在 → None。"""
247
+ mts = [m for m in (_wt_mtime(r) for r in relpaths) if m is not None]
248
+ return max(mts) if mts else None
249
+
250
+
251
+ def _cache_stale(slot: tuple, relpath: Optional[str]) -> bool:
252
+ """进程内缓存是否该失效。slot = (value, loaded_at, sig_mtime)。
253
+ value 为空 / 超 BEACON_CACHE_TTL / 工作树 mtime 变化(postfetch 重建)→ 失效。
254
+ relpath 可为单路径或路径序列(取最大 mtime)。非工作树模式(mtime 为 None)则只受
255
+ TTL 与首次加载约束,即旧行为。
256
+ """
257
+ if slot is None or slot[0] is None:
258
+ return True
259
+ if BEACON_CACHE_TTL > 0 and (time.time() - slot[1]) > BEACON_CACHE_TTL:
260
+ return True
261
+ if relpath is not None:
262
+ m = _wt_mtime_any(relpath) if isinstance(relpath, (list, tuple, set)) else _wt_mtime(relpath)
263
+ if m is not None and slot[2] != m:
264
+ return True
265
+ return False
266
+
267
+
211
268
  # 版本号单一来源:直接读同目录 package.json,避免 MCP 内部版本与发布包版本漂移。
212
269
  def _pkg_version() -> str:
213
270
  try:
214
271
  p = os.path.join(os.path.dirname(os.path.abspath(__file__)), "package.json")
215
272
  with open(p, "r", encoding="utf-8") as f:
216
- return json.load(f).get("version", "1.3.2")
273
+ return json.load(f).get("version", "1.3.3")
217
274
  except Exception:
218
- return "1.3.2"
275
+ return "1.3.3"
219
276
 
220
277
 
221
278
  # HTTP 基址列表(含兜底镜像)。默认部署 Cloudflare Pages 不可达时,自动回退到
@@ -505,12 +562,12 @@ def fetch_text(relpath: str) -> Optional[str]:
505
562
  # --------------------------------------------------------------------------- #
506
563
  def load_manifest() -> Dict[str, Any]:
507
564
  global _manifest_cache
508
- if _manifest_cache is None:
565
+ if _cache_stale(_manifest_cache, "data/manifest.json"):
509
566
  txt = fetch_text("data/manifest.json")
510
567
  if not txt:
511
568
  raise RuntimeError("无法取得 manifest.json(检查 BEACON_SOURCE / BEACON_REPO / 网络)")
512
- _manifest_cache = json.loads(txt)
513
- return _manifest_cache
569
+ _manifest_cache = (json.loads(txt), time.time(), _wt_mtime("data/manifest.json"))
570
+ return _manifest_cache[0]
514
571
 
515
572
 
516
573
  def _shards_of_type(t: str) -> List[Dict[str, Any]]:
@@ -551,13 +608,14 @@ def _load_alias_file(rel: str) -> Dict[str, Any]:
551
608
  def load_gb_alias() -> Dict[str, Any]:
552
609
  """词 → 条目。两层表合并:gb-alias.json(数据推导)+ gb-alias-curated.json(人工策展)。"""
553
610
  global _alias_cache
554
- if _alias_cache is None:
611
+ _alias_relpaths = ("data/gb-alias.json", "data/gb-alias-curated.json")
612
+ if _cache_stale(_alias_cache, _alias_relpaths):
555
613
  merged: Dict[str, Any] = {}
556
- for rel in ("data/gb-alias.json", "data/gb-alias-curated.json"):
614
+ for rel in _alias_relpaths:
557
615
  for k, v in _load_alias_file(rel).items():
558
616
  merged.setdefault(str(k).lower(), v)
559
- _alias_cache = merged
560
- return _alias_cache
617
+ _alias_cache = (merged, time.time(), _wt_mtime_any(_alias_relpaths))
618
+ return _alias_cache[0]
561
619
 
562
620
 
563
621
  def _alias_entries(v: Any) -> List[Dict[str, Any]]:
@@ -584,7 +642,7 @@ def _alias_entries(v: Any) -> List[Dict[str, Any]]:
584
642
  def _gb_names() -> Dict[str, str]:
585
643
  """国标码 → 中文名。取不到(离线/文件缺失)就返回空表,不影响主链路。"""
586
644
  global _gb_name_cache
587
- if _gb_name_cache is None:
645
+ if _cache_stale(_gb_name_cache, "data/gb4754-full.json"):
588
646
  names: Dict[str, str] = {}
589
647
  txt = fetch_text("data/gb4754-full.json")
590
648
  if txt:
@@ -599,8 +657,8 @@ def _gb_names() -> Dict[str, str]:
599
657
  if key in ("classes", "groups", "divisions") and isinstance(body, dict):
600
658
  for k, v in body.items():
601
659
  names[str(k)] = str(v.get("name", v)) if isinstance(v, dict) else str(v)
602
- _gb_name_cache = names
603
- return _gb_name_cache
660
+ _gb_name_cache = (names, time.time(), _wt_mtime("data/gb4754-full.json"))
661
+ return _gb_name_cache[0]
604
662
 
605
663
 
606
664
  def alias_codes(q: str, max_codes: int = 6) -> List[Dict[str, Any]]:
@@ -662,13 +720,14 @@ def _load_cap_index() -> Optional[Dict[str, Any]]:
662
720
  """skills/registry/index/cap.json —— 发布产物(与 city.json 同构):
663
721
  {terms:{code:词面串}, shards:{code:{shard_name:count}}}。"""
664
722
  global _cap_index_cache
665
- if _cap_index_cache is None:
723
+ if _cache_stale(_cap_index_cache, "skills/registry/index/cap.json"):
666
724
  txt = _index_text("skills/registry/index/cap.json")
667
725
  try:
668
- _cap_index_cache = json.loads(txt) if txt else {}
726
+ _cap_index_cache = (json.loads(txt) if txt else {},
727
+ time.time(), _wt_mtime("skills/registry/index/cap.json"))
669
728
  except Exception:
670
- _cap_index_cache = {}
671
- return _cap_index_cache
729
+ _cap_index_cache = ({}, time.time(), _wt_mtime("skills/registry/index/cap.json"))
730
+ return _cap_index_cache[0]
672
731
 
673
732
 
674
733
  def _cap_term_index() -> Dict[str, str]:
@@ -867,6 +926,11 @@ def _build_fp_index() -> List[Dict[str, Any]]:
867
926
  cp = _index_cache_path()
868
927
  wt = os.environ.get("BEACON_WORKTREE", "1") != "0"
869
928
  key = (cp or "mem") + ("|wt" if wt else "")
929
+ if wt:
930
+ # 工作树模式:manifest 由 postfetch 每次重建(mtime 随之变),把它并进键 →
931
+ # fp 分片被重建后 fp_index 自动翻新,无需提交 / 重启。
932
+ mm = _wt_mtime("data/manifest.json")
933
+ key += "|m%.0f" % (mm if mm is not None else 0)
870
934
  if _fp_index is not None and _fp_index_key == key:
871
935
  return _fp_index
872
936
  if cp and not wt and os.path.exists(cp):
@@ -926,7 +990,7 @@ def _build_fp_index() -> List[Dict[str, Any]]:
926
990
  return recs
927
991
 
928
992
 
929
- _CAP_TERMS_CACHE: Dict[str, Any] = {}
993
+ _CAP_TERMS_SLOT: Optional[tuple] = None
930
994
 
931
995
 
932
996
  def _cap_term_of(code: str) -> str:
@@ -939,7 +1003,8 @@ def _cap_term_of(code: str) -> str:
939
1003
  取不到时退回键本身:`caps_index` 允许出现码表之外的裸值(材料「不锈钢」、
940
1004
  品类「正餐」),它们本身就是可检索词,丢掉等于让这类查询瞎掉。
941
1005
  """
942
- if "v" not in _CAP_TERMS_CACHE:
1006
+ global _CAP_TERMS_SLOT
1007
+ if _cache_stale(_CAP_TERMS_SLOT, "skills/registry/index/cap.json"):
943
1008
  terms: Dict[str, Any] = {}
944
1009
  try:
945
1010
  txt = _index_text("skills/registry/index/cap.json")
@@ -947,8 +1012,8 @@ def _cap_term_of(code: str) -> str:
947
1012
  terms = (json.loads(txt).get("terms") or {})
948
1013
  except Exception:
949
1014
  terms = {}
950
- _CAP_TERMS_CACHE["v"] = terms
951
- return str(_CAP_TERMS_CACHE["v"].get(code) or code or "")
1015
+ _CAP_TERMS_SLOT = (terms, time.time(), _wt_mtime("skills/registry/index/cap.json"))
1016
+ return str(_CAP_TERMS_SLOT[0].get(code) or code or "")
952
1017
 
953
1018
 
954
1019
  def _hay(rec: Dict[str, Any]) -> str:
@@ -992,8 +1057,8 @@ def _rec_summary(rec: Dict[str, Any]) -> Dict[str, Any]:
992
1057
  INDEX_DIR = "skills/registry/index"
993
1058
  INDEX_VERSION = 3 # 倒排 key 为分片路径;v2 用国标码会漏掉 gb=null 的记录
994
1059
  _index_meta_cache: Optional[Dict[str, Any]] = None
995
- _bucket_cache: Dict[str, Dict[str, Any]] = {}
996
- _shard_rec_cache: Dict[str, List[Dict[str, Any]]] = {}
1060
+ _bucket_cache: Dict[str, tuple] = {}
1061
+ _shard_rec_cache: Dict[str, tuple] = {}
997
1062
 
998
1063
  _RE_CJK = re.compile("[\u4e00-\u9fff\u3400-\u4dbf]+")
999
1064
  _RE_WORD = re.compile(r"[a-z0-9]+")
@@ -1036,13 +1101,18 @@ def _grams(text: str, query_mode: bool = False) -> List[str]:
1036
1101
  def _index_text(relpath: str) -> Optional[str]:
1037
1102
  """读索引文件。git 模式按 HEAD sha 落盘缓存(桶均 11KB),
1038
1103
  缓存后跨进程重复查询几乎零成本。
1104
+
1105
+ BEACON_WORKTREE=1(默认):磁盘缓存额外按工作树文件 mtime 判新 —— postfetch
1106
+ 重建索引后 mtime 变化,下次查询即重读工作树版本,无需提交 HEAD 即可让关键词 /
1107
+ 城市检索看到新数据(修 DSH 发现的「不提交就永远看不到」坑)。非工作树模式保持
1108
+ 旧的「HEAD 变化才刷新」行为。
1039
1109
  """
1040
1110
  cp = None
1041
1111
  if REPO:
1042
1112
  h = _head_sha() or "nosha"
1043
1113
  cp = os.path.join(CACHE_DIR, "idx-" +
1044
1114
  hashlib.sha1((h + "|" + relpath).encode("utf-8")).hexdigest() + ".json")
1045
- if os.path.exists(cp):
1115
+ if os.path.exists(cp) and _index_sig_ok(cp, relpath):
1046
1116
  try:
1047
1117
  with open(cp, "r", encoding="utf-8") as f:
1048
1118
  return f.read()
@@ -1054,11 +1124,43 @@ def _index_text(relpath: str) -> Optional[str]:
1054
1124
  os.makedirs(CACHE_DIR, exist_ok=True)
1055
1125
  with open(cp, "w", encoding="utf-8") as f:
1056
1126
  f.write(txt)
1127
+ _write_index_sig(cp, relpath)
1057
1128
  except Exception:
1058
1129
  pass
1059
1130
  return txt
1060
1131
 
1061
1132
 
1133
+ def _index_sig_ok(cp: str, relpath: str) -> bool:
1134
+ """工作树模式下,磁盘缓存里的 mtime 印记是否与当前工作树文件一致。
1135
+
1136
+ 印记用 repr 精确往返(浮点不丢精度),避免 postfetch 在同一秒内重写索引文件时
1137
+ 截断判成「未变」而误服陈旧缓存。
1138
+ """
1139
+ sig = _wt_mtime(relpath)
1140
+ if sig is None:
1141
+ return True # 非工作树:不按 mtime 判新
1142
+ sf = cp + ".sig"
1143
+ try:
1144
+ with open(sf, "r", encoding="utf-8") as f:
1145
+ return float(f.read().strip()) == sig
1146
+ except Exception:
1147
+ return False # 缺印记 / 损坏 → 重建
1148
+
1149
+
1150
+ def _write_index_sig(cp: str, relpath: str) -> None:
1151
+ sig = _wt_mtime(relpath)
1152
+ sf = cp + ".sig"
1153
+ try:
1154
+ if sig is None:
1155
+ if os.path.exists(sf):
1156
+ os.remove(sf)
1157
+ return
1158
+ with open(sf, "w", encoding="utf-8") as f:
1159
+ f.write(repr(sig))
1160
+ except Exception:
1161
+ pass
1162
+
1163
+
1062
1164
  def _git_read_many(paths: List[str]) -> Dict[str, str]:
1063
1165
  """git 模式:一次 `git archive` 批量取出多个文件。
1064
1166
 
@@ -1088,10 +1190,29 @@ def _git_read_many(paths: List[str]) -> Dict[str, str]:
1088
1190
 
1089
1191
 
1090
1192
  def _read_many_text(paths: List[str]) -> Dict[str, str]:
1091
- """批量取文本:git 模式 1 次 archive;HTTP 模式线程池并发。"""
1193
+ """批量取文本:工作树优先 → git archive 兜底;HTTP 模式线程池并发。
1194
+
1195
+ BEACON_WORKTREE=1(默认):**必须工作树优先**。`_candidate_shards`(索引桶)与
1196
+ `_records_from_paths`(fp 分片)都走这里 —— 若沿用「git archive HEAD」就会永远
1197
+ 读到**已提交快照**,postfetch 刚重建、尚未提交的索引 / 分片完全看不见(DSH 实测
1198
+ 「关键词 / 城市检索不提交就搜不到」的真凶)。工作树缺的文件再走 archive 已提交
1199
+ 快照兜底,最后 HTTP。非工作树模式(BEACON_WORKTREE=0)保持旧行为。
1200
+ """
1092
1201
  if not paths:
1093
1202
  return {}
1094
1203
  if REPO:
1204
+ if os.environ.get("BEACON_WORKTREE", "1") != "0":
1205
+ out: Dict[str, str] = {}
1206
+ missing: List[str] = []
1207
+ for p in paths:
1208
+ w = _worktree_read(p)
1209
+ if w is not None:
1210
+ out[p] = w
1211
+ else:
1212
+ missing.append(p)
1213
+ if missing: # 工作树缺失/半成品 → 已提交快照兜底
1214
+ out.update(_git_read_many(missing))
1215
+ return out
1095
1216
  got = _git_read_many(paths)
1096
1217
  if len(got) >= max(1, len(paths) // 2): # archive 正常覆盖
1097
1218
  return got
@@ -1105,16 +1226,17 @@ def _read_many_text(paths: List[str]) -> Dict[str, str]:
1105
1226
 
1106
1227
  def _index_meta() -> Optional[Dict[str, Any]]:
1107
1228
  global _index_meta_cache
1108
- if _index_meta_cache is not None:
1109
- return _index_meta_cache
1110
- txt = _index_text(INDEX_DIR + "/meta.json")
1111
- if not txt:
1112
- return None
1113
- try:
1114
- _index_meta_cache = json.loads(txt)
1115
- except Exception:
1116
- return None
1117
- return _index_meta_cache
1229
+ if _cache_stale(_index_meta_cache, INDEX_DIR + "/meta.json"):
1230
+ txt = _index_text(INDEX_DIR + "/meta.json")
1231
+ if not txt:
1232
+ _index_meta_cache = (None, time.time(), _wt_mtime(INDEX_DIR + "/meta.json"))
1233
+ else:
1234
+ try:
1235
+ _index_meta_cache = (json.loads(txt), time.time(),
1236
+ _wt_mtime(INDEX_DIR + "/meta.json"))
1237
+ except Exception:
1238
+ _index_meta_cache = (None, time.time(), _wt_mtime(INDEX_DIR + "/meta.json"))
1239
+ return _index_meta_cache[0]
1118
1240
 
1119
1241
 
1120
1242
  def _index_fresh() -> bool:
@@ -1155,17 +1277,16 @@ _city_names_cache: Optional[set] = None
1155
1277
  def _city_names() -> set:
1156
1278
  """倒排索引里出现过的城市名集合:地区词不作为相关性证据(只作筛选偏好)。"""
1157
1279
  global _city_names_cache
1158
- if _city_names_cache is not None:
1159
- return _city_names_cache
1160
- names: set = set()
1161
- txt = _index_text(INDEX_DIR + "/city.json")
1162
- if txt:
1163
- try:
1164
- names = set(json.loads(txt).keys())
1165
- except Exception:
1166
- names = set()
1167
- _city_names_cache = names
1168
- return names
1280
+ if _cache_stale(_city_names_cache, INDEX_DIR + "/city.json"):
1281
+ names: set = set()
1282
+ txt = _index_text(INDEX_DIR + "/city.json")
1283
+ if txt:
1284
+ try:
1285
+ names = set(json.loads(txt).keys())
1286
+ except Exception:
1287
+ names = set()
1288
+ _city_names_cache = (names, time.time(), _wt_mtime(INDEX_DIR + "/city.json"))
1289
+ return _city_names_cache[0]
1169
1290
 
1170
1291
 
1171
1292
  def _is_cjk(s: str) -> bool:
@@ -1202,16 +1323,23 @@ def _collapse_prefixes(toks: set) -> set:
1202
1323
 
1203
1324
 
1204
1325
  def _bucket_postings(gram: str, buckets: int) -> Dict[str, Any]:
1205
- """取倒排索引里某词的 postings {分片路径: 命中条数};缺失/异常返回空 dict。"""
1326
+ """取倒排索引里某词的 postings {分片路径: 命中条数};缺失/异常返回空 dict。
1327
+
1328
+ 桶缓存按工作树 mtime 判新(见 _wt_mtime):postfetch 重建索引后,对应桶文件
1329
+ mtime 变化即自动重读,无需重启。
1330
+ """
1206
1331
  rel = _bucket_rel(gram, buckets)
1207
- b = _bucket_cache.get(rel)
1208
- if b is None:
1332
+ m = _wt_mtime(rel)
1333
+ slot = _bucket_cache.get(rel)
1334
+ if slot is not None and (m is None or slot[0] == m):
1335
+ b = slot[1]
1336
+ else:
1209
1337
  txt = _index_text(rel)
1210
1338
  try:
1211
1339
  b = json.loads(txt) if txt else {}
1212
1340
  except Exception:
1213
1341
  b = {}
1214
- _bucket_cache[rel] = b
1342
+ _bucket_cache[rel] = (m, b)
1215
1343
  post = b.get(gram) if isinstance(b, dict) else None
1216
1344
  return post if isinstance(post, dict) else {}
1217
1345
 
@@ -1354,13 +1482,22 @@ def _candidate_shards(tokens: List[str], city: str) -> Optional[List[str]]:
1354
1482
 
1355
1483
 
1356
1484
  def _records_from_paths(paths: List[str]) -> List[Dict[str, Any]]:
1357
- """批量读取若干 fp 分片的记录,并在进程内按路径缓存(重复查询近乎零成本)。"""
1485
+ """批量读取若干 fp 分片的记录,并在进程内按路径缓存(重复查询近乎零成本)。
1486
+
1487
+ 缓存按工作树 mtime 判新(见 _wt_mtime):postfetch 重建 fp 分片后,对应分片 mtime
1488
+ 变化即重读,无需重启;非工作树模式保持旧行为(按路径存在性判新)。
1489
+ """
1358
1490
  out: List[Dict[str, Any]] = []
1359
1491
  if not paths:
1360
1492
  return out
1361
- todo = [p for p in paths if p not in _shard_rec_cache]
1362
- for p in todo:
1363
- _shard_rec_cache[p] = []
1493
+ todo: List[str] = []
1494
+ for p in paths:
1495
+ m = _wt_mtime(p)
1496
+ slot = _shard_rec_cache.get(p)
1497
+ if slot is not None and (m is None or slot[0] == m):
1498
+ continue
1499
+ todo.append(p)
1500
+ _shard_rec_cache[p] = (m, [])
1364
1501
  for _path, txt in _read_many_text(todo).items():
1365
1502
  recs: List[Dict[str, Any]] = []
1366
1503
  for line in txt.splitlines():
@@ -1371,9 +1508,11 @@ def _records_from_paths(paths: List[str]) -> List[Dict[str, Any]]:
1371
1508
  recs.append(json.loads(line))
1372
1509
  except Exception:
1373
1510
  continue
1374
- _shard_rec_cache[_path] = recs
1511
+ _shard_rec_cache[_path] = (_wt_mtime(_path), recs)
1375
1512
  for p in paths:
1376
- out.extend(_shard_rec_cache.get(p, []))
1513
+ slot = _shard_rec_cache.get(p)
1514
+ if slot:
1515
+ out.extend(slot[1])
1377
1516
  return out
1378
1517
 
1379
1518