beacon-mfg-mcp 1.11.8 → 1.11.9

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/README.md +1 -1
  2. package/package.json +1 -1
  3. package/server.py +153 -23
package/README.md CHANGED
@@ -10,7 +10,7 @@
10
10
  | tool | 用途 | 关键参数 |
11
11
  |------|------|----------|
12
12
  | `search_vendors` | 按 关键词 / 城市 / 国标码 检索,返回精简档案 | `query` `city` `gb` `limit` `offset` |
13
- | `get_vendor` | 按 id(+国标码)取完整中文档案 | `id`(必填) `gb`(可选,自动反查) |
13
+ | `get_vendor` | 取一家或多家的完整中文档案。**看多家务必用 `ids` 一次拿**:单条约 2.3 s(往返受限,数据仅 ~1.4 KB),串行 6 家要 15 s,批量约 4.4 s | `id` 或 `ids`(二选一,`ids` 最多 20 个) `gb`(可选,自动反查) |
14
14
  | `get_capability_card` | 按 id 取能力卡(工艺/设备/产能/认证等) | `id`(必填) |
15
15
  | `suggest_filters` | **把用户原话映射成候选国标小类码**,供改用 `gb=` | `query`(必填) `city` `limit` |
16
16
  | `list_industries` | **国标小类货架**(只列有数据的类,附企业条数),供按语义自己挑码 | `keyword` `parent` `limit` `offset` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "beacon-mfg-mcp",
3
- "version": "1.11.8",
3
+ "version": "1.11.9",
4
4
  "description": "Read-only MCP server exposing the BeaconMFG灯塔 directory of business entities across ALL GB/T 4754 industry divisions (manufacturing, services, retail, logistics, catering, IT, and more) to any MCP-compatible agent. No signup and no API key: an anonymous identity is generated on first run, phone binding is optional (only needed for L2 capability cards).",
5
5
  "keywords": [
6
6
  "mcp",
package/server.py CHANGED
@@ -322,7 +322,7 @@ def _recall_for_sourcing(text: str, top_k: int = 200, pack_id: str | None = None
322
322
  # 都对齐到这个值,`scripts/check_version.py` 在发布前校验它们没有漂移。
323
323
  # 读不到 package.json 时的兜底值 —— 改版本时**必须**和 package.json 一起改,
324
324
  # 否则 check_version.py 会报错(这就是它的用途)。
325
- VERSION_FALLBACK = "1.11.8"
325
+ VERSION_FALLBACK = "1.11.9"
326
326
 
327
327
 
328
328
  def _pkg_version() -> str:
@@ -4545,6 +4545,14 @@ _VENDOR_CACHE_MAX = int(os.environ.get("BEACON_VENDOR_CACHE_MAX", "1024") or "0"
4545
4545
  # 等着看「点开的那一家」,通用预算下 26.7s 的构建会原样砸到交互上。
4546
4546
  _VENDOR_FP_DEADLINE = float(os.environ.get("BEACON_VENDOR_FP_DEADLINE", "5") or "0")
4547
4547
 
4548
+ # 批量详情的上限与并发度(2026-10-30)。
4549
+ # 上限 20:一次调用就吃 20 个 /v1/rec,再多会变成"变相全量导出"的入口,
4550
+ # 与「防一次性整体拉走」的设计相冲突。
4551
+ # 并发 6:服务端 worker 同 colo 读 R2 很快,瓶颈是链路 RTT,6 路足够把
4552
+ # 5~20 家压进一个往返量级;再高对 Cloudflare Pages 免费额度不友好。
4553
+ _BATCH_VENDOR_MAX = int(os.environ.get("BEACON_BATCH_VENDOR_MAX", "20") or "20")
4554
+ _BATCH_VENDOR_WORKERS = int(os.environ.get("BEACON_BATCH_VENDOR_WORKERS", "6") or "6")
4555
+
4548
4556
 
4549
4557
  def _vendor_cache_sig() -> float:
4550
4558
  m = _wt_mtime("data/manifest.json")
@@ -4660,32 +4668,141 @@ def _rec_gb(rec: Dict[str, Any]) -> str:
4660
4668
  return ""
4661
4669
 
4662
4670
 
4671
+ def _vendor_fast(vid: str, ck: tuple, sig: float) -> Optional[Dict[str, Any]]:
4672
+ """详情快路径:缓存 → `/v1/rec` 按条取 → 回写缓存;取不到返回 None。
4673
+
4674
+ 为什么抽出来(2026-10-30):批量详情要并发这一段。单条详情稳定在
4675
+ 2.3s,而它是**往返受限**的 —— 每次只搬 1.4KB,链路 RTT 占总耗时九成。
4676
+ 串行取 5 家就是 11.7s,用户会把「推荐几家」直接当成卡顿。
4677
+
4678
+ 一次 HTTP 拿 1 条 ≈ 1.1KB,替代「定国标码 → 拉 2~3 个 zh 续片
4679
+ (5~7MB)→ 解析整片 → 线性扫」。走不通时返回 None,由 `get_vendor`
4680
+ 静默回落到原路径 —— 这一步失败**不会**让详情不可用,只会慢回去。
4681
+ """
4682
+ _hit = _vendor_cache_get(ck, sig)
4683
+ if _hit is not None:
4684
+ return _hit
4685
+ _fast = _v1_rec(vid)
4686
+ if _fast is None:
4687
+ return None
4688
+ _rgb = _rec_gb(_fast)
4689
+ if _rgb and not _fast.get("gb"):
4690
+ # zh 记录常常不写顶层 gb(只留 industry.code),但它确实归档在这个
4691
+ # 国标码下。不回填就会出现「search 能搜到、get_vendor 说 gb=null」。
4692
+ _fast["gb"] = _rgb
4693
+ _fast["gb_source"] = "record"
4694
+ _resp = {"vendor": normalize_vendor(_fast)}
4695
+ _vendor_cache_put(ck, sig, _resp)
4696
+ return _resp
4697
+
4698
+
4699
+ def get_vendors(ids: Any, gb: str = "") -> Dict[str, Any]:
4700
+ """批量取详情:一次调用拿回 N 家。
4701
+
4702
+ 两条泳道,这是关键设计:
4703
+ ① **快路径**(缓存 / `/v1/rec`)并发跑。这是绝大多数情况,也是慢的
4704
+ 那个 —— 5 家串行 11.7s → 并发约 2.5s。
4705
+ ② 快路径没命中的,退回 `get_vendor` 的慢路径,**串行**跑。
4706
+ 不能并发:慢路径会构建全量指纹索引(`_build_fp_index`),N 个线程
4707
+ 同时撞上去等于把一次重建变成 N 次,内存和出网都会爆。
4708
+
4709
+ `gb` 对整批生效;不传则每条各自反查(更慢,但结果更准)。
4710
+ """
4711
+ raw = ids
4712
+ if isinstance(raw, str):
4713
+ raw = [raw]
4714
+ if not isinstance(raw, (list, tuple)):
4715
+ return {"error": "参数 ids 需要是字符串数组"}
4716
+
4717
+ # 去重保序:调用方经常把 search 结果的 id 和别处来的 id 混在一起传。
4718
+ seen: set = set()
4719
+ uniq: List[str] = []
4720
+ for _x in raw:
4721
+ _v = str(_x or "").strip()
4722
+ if _v and _v not in seen:
4723
+ seen.add(_v)
4724
+ uniq.append(_v)
4725
+ if not uniq:
4726
+ return {"error": "缺少必填参数 ids(或 id)"}
4727
+
4728
+ dropped = 0
4729
+ if len(uniq) > _BATCH_VENDOR_MAX:
4730
+ dropped = len(uniq) - _BATCH_VENDOR_MAX
4731
+ uniq = uniq[:_BATCH_VENDOR_MAX]
4732
+
4733
+ gb = (gb or "").strip()
4734
+ sig = _vendor_cache_sig()
4735
+ got: Dict[str, Dict[str, Any]] = {}
4736
+
4737
+ if len(uniq) > 1:
4738
+ # 每个任务一份 context 副本:同一 Context 不能并发进入,且
4739
+ # X-Beacon-Op 是靠 ContextVar 传的(缺了服务端按 401 处理)。
4740
+ import contextvars
4741
+ _base = contextvars.copy_context()
4742
+ _n = max(1, min(_BATCH_VENDOR_WORKERS, len(uniq)))
4743
+ with concurrent.futures.ThreadPoolExecutor(max_workers=_n) as ex:
4744
+ futs = {ex.submit(_base.copy().run, _vendor_fast, vid, (vid, gb), sig): vid
4745
+ for vid in uniq}
4746
+ for fut in concurrent.futures.as_completed(futs):
4747
+ _vid = futs[fut]
4748
+ try:
4749
+ _r = fut.result()
4750
+ except Exception:
4751
+ _r = None # 单条炸掉不能拖垮整批
4752
+ if _r is not None:
4753
+ got[_vid] = _r
4754
+ else:
4755
+ _r = _vendor_fast(uniq[0], (uniq[0], gb), sig)
4756
+ if _r is not None:
4757
+ got[uniq[0]] = _r
4758
+
4759
+ vendors: List[Dict[str, Any]] = []
4760
+ failed: List[Dict[str, Any]] = []
4761
+ for vid in uniq:
4762
+ if vid in got:
4763
+ vendors.append(got[vid]["vendor"])
4764
+ continue
4765
+ # 慢路径:串行。注意这里可能很慢(要拉分片),所以只在快路径没命中时走。
4766
+ try:
4767
+ r = get_vendor(vid, gb)
4768
+ except Exception as e: # noqa: BLE001
4769
+ r = {"error": "get_vendor 异常: %s" % e}
4770
+ if isinstance(r, dict) and r.get("vendor"):
4771
+ vendors.append(r["vendor"])
4772
+ else:
4773
+ item: Dict[str, Any] = {"id": vid,
4774
+ "error": (r or {}).get("error") if isinstance(r, dict) else "unknown"}
4775
+ if isinstance(r, dict) and r.get("message"):
4776
+ item["message"] = r["message"]
4777
+ failed.append(item)
4778
+
4779
+ out: Dict[str, Any] = {
4780
+ "vendors": vendors,
4781
+ "count": len(vendors),
4782
+ "requested": len(uniq),
4783
+ }
4784
+ if failed:
4785
+ out["failed"] = failed
4786
+ if dropped:
4787
+ out["truncated"] = dropped
4788
+ out["note"] = ("一次最多取 %d 家,本次多出的 %d 个已忽略;"
4789
+ "请分批调用或先用 search_vendors 缩小范围。"
4790
+ % (_BATCH_VENDOR_MAX, dropped))
4791
+ return out
4792
+
4793
+
4663
4794
  def get_vendor(vid: str, gb: str = "") -> Dict[str, Any]:
4664
4795
  vid = (vid or "").strip()
4665
4796
  if not vid:
4666
4797
  return {"error": "缺少必填参数 id"}
4667
4798
  gb = (gb or "").strip()
4668
4799
  # 命中缓存就直接回,省掉 id 反查 + 分片解析(三次实测均为 970ms 量级)。
4800
+ # ⓿ 缓存 + 按条取(2026-10-30):详情的主路径。逻辑抽到 `_vendor_fast`,
4801
+ # 批量详情(get_vendors)要并发复用同一段;这里失败会返回 None 继续往下走慢路径。
4669
4802
  _ck = (vid, gb)
4670
4803
  _sig = _vendor_cache_sig()
4671
- _hit = _vendor_cache_get(_ck, _sig)
4672
- if _hit is not None:
4673
- return _hit
4674
- # ⓿ 按条取(2026-10-30):详情的主路径。
4675
- #
4676
- # 一次 HTTP 拿 1 条 ≈ 1.1KB,替代下面「定国标码 → 拉 2~3 个 zh 续片
4677
- # (5~7MB)→ 解析整片 → 线性扫」。走不通就静默回落到原路径,
4678
- # 因此这一步失败**不会**让详情不可用,只会慢回去。
4679
- _fast = _v1_rec(vid)
4680
- if _fast is not None:
4681
- _rgb = _rec_gb(_fast)
4682
- if _rgb and not _fast.get("gb"):
4683
- # zh 记录常常不写顶层 gb(只留 industry.code),但它确实归档在这个
4684
- # 国标码下。不回填就会出现「search 能搜到、get_vendor 说 gb=null」。
4685
- _fast["gb"] = _rgb
4686
- _fast["gb_source"] = "record"
4687
- _resp = {"vendor": normalize_vendor(_fast)}
4688
- _vendor_cache_put(_ck, _sig, _resp)
4804
+ _resp = _vendor_fast(vid, _ck, _sig)
4805
+ if _resp is not None:
4689
4806
  return _resp
4690
4807
  # 没给国标码就先解析 id -> gb。
4691
4808
  #
@@ -4964,14 +5081,22 @@ TOOLS = [
4964
5081
  },
4965
5082
  {
4966
5083
  "name": "get_vendor",
4967
- "description": "按主体 id(+可选国标码)取完整中文档案:名称/地址/经纬度/电话/行业路径/认证/关键词等。",
5084
+ "description": "按主体 id 取完整中文档案:名称/地址/经纬度/电话/行业路径/认证/关键词等。"
5085
+ "**取多家请用 ids 一次批量拿**:单条固定约 2.3 秒(往返受限,"
5086
+ "数据本身只有 ~1.4KB),串行取 5 家要 11~12 秒,批量并发后约 2~3 秒。"
5087
+ "典型用法:search_vendors 拿到一批 id 后,把要展示的几家一起传给 ids。",
4968
5088
  "inputSchema": {
4969
5089
  "type": "object",
4970
5090
  "properties": {
4971
- "id": {"type": "string", "description": "主体 id,如 CN-MFG-0000163(必填)"},
4972
- "gb": {"type": "string", "description": "国标码;不填会自动从指纹分片反查(稍慢)"},
5091
+ "id": {"type": "string",
5092
+ "description": "单个主体 id,如 CN-MFG-0000163(与 ids 二选一,只传它时回执是 {\"vendor\": …})"},
5093
+ "ids": {"type": "array", "items": {"type": "string"}, "maxItems": 20,
5094
+ "description": "批量主体 id,最多 20 个;自动去重、按传入顺序返回。"
5095
+ "回执是 {\"vendors\": [...], \"count\": n, \"failed\": [...]},"
5096
+ "单条取不到只进 failed,不影响其余"},
5097
+ "gb": {"type": "string",
5098
+ "description": "国标码(可选);批量时对全部 id 生效。不填会自动反查,稍慢"},
4973
5099
  },
4974
- "required": ["id"],
4975
5100
  },
4976
5101
  },
4977
5102
  {
@@ -5340,6 +5465,11 @@ def _dispatch_inner(name: str, args: Dict[str, Any]) -> Dict[str, Any]:
5340
5465
  radius_km=args.get("radius_km"),
5341
5466
  )
5342
5467
  if name == "get_vendor":
5468
+ # 批量优先:传了 ids 就整批取(并发后总耗时≈一个往返),
5469
+ # 只传 id 仍走单条,回执结构保持 {"vendor": …} 不变(向后兼容)。
5470
+ _ids = args.get("ids")
5471
+ if _ids:
5472
+ return get_vendors(_ids, args.get("gb", ""))
5343
5473
  return get_vendor(args.get("id", ""), args.get("gb", ""))
5344
5474
  if name == "get_capability_card":
5345
5475
  return get_capability_card(args.get("id", ""))