beacon-mfg-mcp 1.12.3 → 1.12.5
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.
- package/README.md +10 -1
- package/package.json +1 -1
- package/rfq-kernel/industry/material_handling.json +1 -1
- package/rfq-kernel/industry/mattress.json +1 -1
- package/rfq-kernel/industry/sheet_metal.json +1 -1
- package/rfq-kernel/src/beacon_adapter.py +1 -1
- package/rfq-kernel/src/mcp_bridge.py +2 -2
- package/server.py +124 -101
- package/vid_token.py +11 -14
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Beacon-MFG 只读 MCP 服务
|
|
2
2
|
|
|
3
|
-
**English**: Read-only MCP server for finding **Chinese suppliers, manufacturers and business entities** — 210k+ records across all GB/T 4754 industries, bilingual CN/EN, USCC-verified, with an RFQ sourcing kernel. Free, no API key
|
|
3
|
+
**English**: Read-only MCP server for finding **Chinese suppliers, manufacturers and business entities** — 210k+ records across all GB/T 4754 industries, bilingual CN/EN, USCC-verified, with an RFQ sourcing kernel. Free, no API key. **No install**: connect to the hosted Streamable HTTP endpoint `https://beacon-mfg.pages.dev/mcp`; or run locally with `npx -y beacon-mfg-mcp`.
|
|
4
4
|
|
|
5
5
|
让任意支持 MCP 的主流 agent(Claude Desktop / Cline / Continue / 常见 MCP 客户端 等)能够**检索与调用**
|
|
6
6
|
灯塔工厂供应商名录,**无需 clone 整个仓库、无需任何密钥、无需第三方依赖**。
|
|
@@ -10,6 +10,15 @@
|
|
|
10
10
|
> 只记「怎么绕开」,不评数据对错;repo 模式存 `<repo>/mcp/notes/` 由操作员托管,
|
|
11
11
|
> 联网模式存本机 `~/.beacon-mfg/notes/` 不上传云端)。
|
|
12
12
|
|
|
13
|
+
> **两种接入形态,工具数不同**:
|
|
14
|
+
> - **远程端点**(`https://beacon-mfg.pages.dev/mcp`,Streamable HTTP,无需安装)——
|
|
15
|
+
> 提供下表中 3 个只读检索工具:`search_vendors` / `get_vendor` / `get_capability_card`。
|
|
16
|
+
> 额度按**出口 IP** 计(1,000 条记录/24h),不要注册、不要 API Key。
|
|
17
|
+
> - **本地 stdio**(`npx -y beacon-mfg-mcp`)—— 下表全部 12 个,含多轮寻源与留言区。
|
|
18
|
+
>
|
|
19
|
+
> 远程端点没上的工具**不会出现在 `tools/list` 里**(让 agent 看不到,比让它调了才报错好)。
|
|
20
|
+
> 契约与验收清单见 [MCP_HTTP.md](MCP_HTTP.md)。
|
|
21
|
+
|
|
13
22
|
## 提供的 tool(共 12 个:8 个只读检索 + 1 个身份自助 + 1 个入驻入口 + 2 个留言区读写)
|
|
14
23
|
|
|
15
24
|
| tool | 用途 | 关键参数 |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "beacon-mfg-mcp",
|
|
3
|
-
"version": "1.12.
|
|
3
|
+
"version": "1.12.5",
|
|
4
4
|
"mcpName": "io.github.eiry16/beacon-mfg-mcp",
|
|
5
5
|
"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).",
|
|
6
6
|
"keywords": [
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"display_name": "输送/物流搬运设备",
|
|
4
4
|
"version": "0.2.0",
|
|
5
5
|
"extends": "_base",
|
|
6
|
-
"description": "输送线 / 输送机 / 传送带 / 提升机 /
|
|
6
|
+
"description": "输送线 / 输送机 / 传送带 / 提升机 / 分拣线等连续搬运与物料搬运设备。对应 GB 3434 连续搬运设备制造(输送机械 / 装卸机械 / 给料机械)。",
|
|
7
7
|
|
|
8
8
|
"attributes": {
|
|
9
9
|
"product_type": {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"display_name": "钣金冲压",
|
|
4
4
|
"version": "0.2.0",
|
|
5
5
|
"extends": "_base",
|
|
6
|
-
"description": "
|
|
6
|
+
"description": "钣金 / 冲压 / 机箱机柜 / 金属结构件等金属板材成形加工,覆盖材料、厚度、精度等询价属性。",
|
|
7
7
|
|
|
8
8
|
"attributes": {
|
|
9
9
|
"material": {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
自报(self_declared)或无证书号 → verified=false → 不作数。
|
|
12
12
|
这正是「ISO9001 需上传证书号、且平台/现场核验过才作数」。
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
真实卡权威位置:
|
|
15
15
|
skills/registry/capability/{sid}.json
|
|
16
16
|
skills/vendors/{id}/capability.json 作为兜底(两者内容同源)。
|
|
17
17
|
生产环境应优先 registry/capability,不要遍历 data/gb 分片。
|
|
@@ -42,7 +42,7 @@ def _all_packs() -> dict:
|
|
|
42
42
|
|
|
43
43
|
# 名称感知防误删:当候选供应商的厂名含本 pack 的具体产品词组时,
|
|
44
44
|
# 即使其国标码(gb)被 pack_of_gb 归到「另一个 pack」,也视为本行业、绝不误删。
|
|
45
|
-
#
|
|
45
|
+
# 这是「真实企业必须被看见」原则的兜底:许多长尾厂把品类写在厂名里,
|
|
46
46
|
# 国标码却落在别的类(如输送厂 gb 常是 3360/3399/3451/3484 而非 3434)。
|
|
47
47
|
# 关键词只收「具体产品词组」,避免歧义单字(如 滚筒 会撞 滚筒洗衣机、物流/设备 过宽)。
|
|
48
48
|
_PACK_NAME_HINTS = {
|
|
@@ -263,7 +263,7 @@ def build_session(demand_text: str, recs: list, pack_id: str | None,
|
|
|
263
263
|
# 若检测到行业,用国标码(GB)把召回池收敛到本行业,剔除跨行业噪声
|
|
264
264
|
# (如注塑厂/POM、表面处理/anodizing 混入「钣金冲压」查询)。
|
|
265
265
|
# 判别规则保守:仅当记录的 gb 明确映射到「另一个 pack」时才剔除;
|
|
266
|
-
# gb
|
|
266
|
+
# gb 为空或未知的一律保留,绝不误删真实企业。
|
|
267
267
|
# 若剔除后池将变空(极端情况),则放弃剔除,保留原池。
|
|
268
268
|
if pack_id:
|
|
269
269
|
hints = _PACK_NAME_HINTS.get(pack_id, [])
|
package/server.py
CHANGED
|
@@ -97,15 +97,14 @@ except Exception: # 桥接缺失/异常 → 降级,MCP 其余 3 个只读 too
|
|
|
97
97
|
_bridge = None
|
|
98
98
|
|
|
99
99
|
# --------------------------------------------------------------------------- #
|
|
100
|
-
# id token
|
|
100
|
+
# id token
|
|
101
101
|
#
|
|
102
|
-
# 对外 id 是 HMAC 置换后的 8 字符 token
|
|
103
|
-
#
|
|
102
|
+
# 对外 id 是 HMAC 置换后的 8 字符 token,不是顺序编号 —— 顺序编号可被枚举遍历,
|
|
103
|
+
# 等于把整库暴露给批量抓取。
|
|
104
104
|
#
|
|
105
105
|
# 为什么客户端也要有这份算法:MCP 的检索是**本地**跑的(拉分片、本地打分),
|
|
106
|
-
# 它得把结果里的内部 id 换成 token
|
|
107
|
-
#
|
|
108
|
-
# BEACON_VID_KEY。没有密钥时(npm 客户端默认情况)**不改写** —— 见 has_key()。
|
|
106
|
+
# 它得把结果里的内部 id 换成 token 才能对外给。同目录的算法副本必须保持同步;
|
|
107
|
+
# 没有密钥时(npm 客户端默认情况)**不改写** —— 见 has_key()。
|
|
109
108
|
# --------------------------------------------------------------------------- #
|
|
110
109
|
try:
|
|
111
110
|
from vid_token import ( # 同目录副本(npm 只分发 mcp/)
|
|
@@ -313,7 +312,7 @@ def _recall_for_sourcing(text: str, top_k: int = 200, pack_id: str | None = None
|
|
|
313
312
|
# 国标码命中检测行业 / 厂名含本 pack 产品词:本行业强信号,即便需求词全为
|
|
314
313
|
# 稀有词(df<2,如「风送线」)导致 token 打分全为 0,也要保留——这些是
|
|
315
314
|
# 真实供应商(GB 种子加法补召的 3434 搬运设备厂、或厂名含产品词的长尾厂),
|
|
316
|
-
# 不能因为「没有高频区分词」就被 w<=0
|
|
315
|
+
# 不能因为「没有高频区分词」就被 w<=0 一概滤除(真实企业必须被看见,不能漏召回)。
|
|
317
316
|
gb_hit = pack_id and _bridge is not None and _bridge.pack_of_gb(r.get("gb")) == pack_id
|
|
318
317
|
name_hit = pack_id and name_surfaces and any(s in name_hay for s in name_surfaces)
|
|
319
318
|
if w <= 0 and not (gb_hit or name_hit):
|
|
@@ -323,7 +322,7 @@ def _recall_for_sourcing(text: str, top_k: int = 200, pack_id: str | None = None
|
|
|
323
322
|
w += 1000.0
|
|
324
323
|
# 厂名含本 pack 产品词、但 gb 未映射本行业的长尾真实企业(如输送厂 gb=3360/3451
|
|
325
324
|
# 而非 3434):同样视为本行业强信号给次高加成,避免被 GB 种子挤到池底
|
|
326
|
-
#
|
|
325
|
+
# (真实企业必须被看见,不能漏召回)。
|
|
327
326
|
elif name_hit:
|
|
328
327
|
w += 500.0
|
|
329
328
|
r2 = dict(r)
|
|
@@ -338,10 +337,10 @@ def _recall_for_sourcing(text: str, top_k: int = 200, pack_id: str | None = None
|
|
|
338
337
|
# --------------------------------------------------------------------------- #
|
|
339
338
|
# 版本号单一来源:同目录 package.json(npm 发布包的 version)。
|
|
340
339
|
# 全项目所有版本号(MCP / HTTP 服务 / Python API / Android APK)
|
|
341
|
-
#
|
|
340
|
+
# 都对齐到这个值,发布前的一致性闸门会校验它们没有漂移。
|
|
342
341
|
# 读不到 package.json 时的兜底值 —— 改版本时**必须**和 package.json 一起改,
|
|
343
342
|
# 否则 check_version.py 会报错(这就是它的用途)。
|
|
344
|
-
VERSION_FALLBACK = "1.12.
|
|
343
|
+
VERSION_FALLBACK = "1.12.4"
|
|
345
344
|
|
|
346
345
|
|
|
347
346
|
def _pkg_version() -> str:
|
|
@@ -557,8 +556,7 @@ def _header_value(v: Any) -> str:
|
|
|
557
556
|
|
|
558
557
|
后果远比「少记一条埋点」严重:`_beacon_headers()` 是**所有** HTTP 读共用
|
|
559
558
|
(manifest、索引桶、指纹分片全走它),于是任何含中文关键词的检索在冷缓存下
|
|
560
|
-
|
|
561
|
-
机器上实测定位(1.4.2 → 1.11.0 均存在;本机因 manifest 已进缓存而未复现)。
|
|
559
|
+
**整体不可用**,而报错文案指向「网络/代理不可达」,容易误导排查方向。
|
|
562
560
|
|
|
563
561
|
处理方式取 **percent-encode** 而不是丢弃:中文产品词正是要观测的主体,
|
|
564
562
|
过滤掉等于把中文场景的遥测清零。worker 侧用 `decodeURIComponent` 还原。
|
|
@@ -580,9 +578,8 @@ def _quote_path(relpath: str) -> str:
|
|
|
580
578
|
`http.client` 组装请求行时按 `'ascii'` 编码,路径里出现原始 UTF-8 字节
|
|
581
579
|
就在**建连之前**抛 `UnicodeEncodeError: 'ascii' codec can't encode ...`。
|
|
582
580
|
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
中文城市 + 冷缓存首个查询必定在这一跳失败。
|
|
581
|
+
`_city_postings()` 拼出的城市索引使用发布侧的**原始中文文件名**(未做哈希化)——
|
|
582
|
+
中文城市 + 冷缓存首个查询必定在这一跳失败。
|
|
586
583
|
|
|
587
584
|
为什么不在 `_city_postings()` 里就地 quote:`fetch_text()` 会**先用 relpath
|
|
588
585
|
读本地工作树/dirgit HEAD**(本地仓库模式),把 relpath 提前编码会让本地读
|
|
@@ -617,15 +614,11 @@ def _beacon_headers() -> Dict[str, str]:
|
|
|
617
614
|
h["X-Beacon-Cid"] = _cid()
|
|
618
615
|
|
|
619
616
|
cur = _cur_get() or {}
|
|
620
|
-
# op = 一次 tool 调用。服务端按 op
|
|
621
|
-
#
|
|
617
|
+
# op = 一次 tool 调用。服务端按 op 去重计量配额:一次检索会拉很多分片,
|
|
618
|
+
# 按请求计费会把正常用户直接打死。
|
|
622
619
|
#
|
|
623
|
-
# ⚠
|
|
624
|
-
#
|
|
625
|
-
# 而 op 只存在于 tool 调用期间(`_cur_set`),**启动预热、跨线程池、
|
|
626
|
-
# 以及任何非 tool 触发的取数都没有它**。缺了就整片 401:
|
|
627
|
-
# · /data/manifest.json → 冷启动 8 次 401 重试(实测 67.7s)
|
|
628
|
-
# · /data/gb/** → get_vendor 全部 fetch_failed
|
|
620
|
+
# ⚠ op 只存在于 tool 调用期间(`_cur_set`):**启动预热、跨线程池、
|
|
621
|
+
# 以及任何非 tool 触发的取数都没有它**,缺了会被服务端当成旧版客户端拒绝。
|
|
629
622
|
# 所以这里必须有兜底 op。用 `boot-<pid>` 而不是给所有请求塞同一个常量:
|
|
630
623
|
# 服务端按 op 去重,同一进程的多条预热/兜底请求计作一次操作,
|
|
631
624
|
# 既不会把配额打爆,也不会让「一个进程」在台账里看起来像几百次调用。
|
|
@@ -1094,11 +1087,10 @@ def _fetch_many(paths: List[str], workers: int = 0) -> List[Optional[str]]:
|
|
|
1094
1087
|
return [_shard_text(p) for p in paths]
|
|
1095
1088
|
n = max(1, int(workers or _FETCH_WORKERS))
|
|
1096
1089
|
out: List[Optional[str]] = [None] * len(paths)
|
|
1097
|
-
# ⚠ ContextVar
|
|
1090
|
+
# ⚠ ContextVar 不会自动继承到线程池的工作线程。
|
|
1098
1091
|
# 本次 tool 调用的 op 在 `_cur_get()` 里,`X-Beacon-Op` 头靠它发出;
|
|
1099
|
-
# 丢了 op
|
|
1100
|
-
#
|
|
1101
|
-
# /data/gb/** 全 401 → 报 "fetch_failed"(看起来像企业不存在或网络抖)。
|
|
1092
|
+
# 丢了 op 会被服务端当成旧版客户端拒绝,整批取数失败。
|
|
1093
|
+
# 症状极具误导性:看起来像企业不存在或网络抖动,排查方向容易跑偏。
|
|
1102
1094
|
# 每任务一份 `copy()`,因为同一个 Context 不能并发进入。
|
|
1103
1095
|
import contextvars
|
|
1104
1096
|
_base = contextvars.copy_context()
|
|
@@ -2372,20 +2364,18 @@ _fp_index: Optional[List[Dict[str, Any]]] = None
|
|
|
2372
2364
|
_fp_index_key: Optional[str] = None
|
|
2373
2365
|
_name_gb_slot: Optional[tuple] = None # (id -> gb 映射, loaded_at, mtime)
|
|
2374
2366
|
|
|
2375
|
-
#
|
|
2376
|
-
#
|
|
2367
|
+
# 全量兜底的**上限**。`_build_fp_index` 是「预构建索引不可用」时的
|
|
2368
|
+
# 最后一道网:一次批量读取全部分片,耗时随数据量线性增长。
|
|
2377
2369
|
#
|
|
2378
2370
|
# 它本来只在「索引缺失/陈旧」时才会被走到,但 `get_vendor` 在 id 反查失败时会拿它
|
|
2379
|
-
#
|
|
2380
|
-
# 直接调 `get_vendor(token_id)` 就掉进来了:31s / 38s 两次复现,客户端表现为「卡死」。
|
|
2371
|
+
# 兜底——那是一条**用户正在等着看点开的那一家**的同步路径,必须设上限。
|
|
2381
2372
|
#
|
|
2382
2373
|
# 所以给它两道闸:时间闸(超时即返回已拿到的部分,并如实标记截断)+ 条数闸。
|
|
2383
2374
|
# 两道闸都在**返回前**判定,且截断结果绝不写进 `_fp_index`(进程级缓存,写脏等于
|
|
2384
2375
|
# 让后续每次查询都拿半份数据,见下方 fallback 分支的同类注释)。
|
|
2385
2376
|
#
|
|
2386
|
-
#
|
|
2387
|
-
#
|
|
2388
|
-
# 8s 的初版实测会把索引截成 6.1 万条(29%),普通检索于是悄悄少 2/3 记录。
|
|
2377
|
+
# 默认值刻意**高于**正常全量构建耗时:这道闸的职责是拦「系统病态」,
|
|
2378
|
+
# 不是把正常构建切残 —— 预算过紧会把索引截掉一大截,普通检索于是悄悄少记录。
|
|
2389
2379
|
# 需要更紧预算的地方自己传 deadline_s(`get_vendor` 就传 5s,见
|
|
2390
2380
|
# `_VENDOR_FP_DEADLINE`)。
|
|
2391
2381
|
_FP_BUILD_DEADLINE = float(os.environ.get("BEACON_FP_BUILD_DEADLINE", "40") or "0")
|
|
@@ -2426,10 +2416,9 @@ def _data_version() -> str:
|
|
|
2426
2416
|
git 模式 = HEAD sha(提交变则版本变);
|
|
2427
2417
|
HTTP 模式 = 清单**内容指纹**。
|
|
2428
2418
|
|
|
2429
|
-
|
|
2430
|
-
同一天内重复发布(重跑索引、补数据)版本号不变 →
|
|
2431
|
-
|
|
2432
|
-
467 个分片(单次 130s+),重跑 gen_search_index 也「看不出效果」。
|
|
2419
|
+
HTTP 模式原先只取 `generated_at`,它是 `date.today()`(**日期精度**)。
|
|
2420
|
+
同一天内重复发布(重跑索引、补数据)版本号不变 → 落盘缓存永远命中旧快照,
|
|
2421
|
+
重跑索引也「看不出效果」。
|
|
2433
2422
|
改为「generated_at + 各类型记录数 + 索引桶哈希聚合」,数据或索引任一变化即失效。
|
|
2434
2423
|
|
|
2435
2424
|
取不到就返回空串,调用方据此**禁用**缓存(宁慢不脏)。
|
|
@@ -2512,8 +2501,8 @@ def _idmap_rel(vid: str) -> Optional[str]:
|
|
|
2512
2501
|
"""id 落在哪一片。
|
|
2513
2502
|
|
|
2514
2503
|
token 寻址:片名 = token 的前 2 字符(32×32 = 1024 片)。token 是随机
|
|
2515
|
-
排列的,前 2 字符在其上是**均匀**的 →
|
|
2516
|
-
|
|
2504
|
+
排列的,前 2 字符在其上是**均匀**的 → 全量记录散进去每片行数很少,
|
|
2505
|
+
而且不需要任何目录表。
|
|
2517
2506
|
"""
|
|
2518
2507
|
if _idmap_addr() == _IDMAP_ADDR_TOKEN:
|
|
2519
2508
|
if vid and _TOKEN_ID_RE.match(vid):
|
|
@@ -2896,9 +2885,9 @@ def _build_fp_index(deadline_s: Optional[float] = None) -> List[Dict[str, Any]]:
|
|
|
2896
2885
|
# fallback:逐分片读取(HTTP 模式,或 git archive 异常时)
|
|
2897
2886
|
recs = []
|
|
2898
2887
|
# git show / HTTP GET 均为 I/O 密集,线程池并发拉取可大幅缩短首次构建耗时。
|
|
2899
|
-
# 分批 +
|
|
2900
|
-
# (401/429
|
|
2901
|
-
#
|
|
2888
|
+
# 分批 + 失败过半即停:全量回退要拉的分片很多,若故障是系统性的
|
|
2889
|
+
# (401/429/断网),挨个试完只会把一次失败放大成几百次重试,
|
|
2890
|
+
# 用户只看到「卡住然后没有结果」。
|
|
2902
2891
|
_BATCH = 16
|
|
2903
2892
|
_tried = 0
|
|
2904
2893
|
_ok = 0
|
|
@@ -3121,7 +3110,7 @@ def _brand_of(name: str) -> str:
|
|
|
3121
3110
|
|
|
3122
3111
|
刻意**不做**更激进的归并(去「店/馆/楼」后缀、模糊匹配):那会把「陆振兴」和
|
|
3123
3112
|
「陆正兴」这种一字之差的不同主体并成一家 —— 错比噪声严重。
|
|
3124
|
-
|
|
3113
|
+
品牌口径与发布侧索引保持一致。
|
|
3125
3114
|
"""
|
|
3126
3115
|
s = (name or "").strip()
|
|
3127
3116
|
prev = None
|
|
@@ -3199,7 +3188,7 @@ def _haversine_km(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
|
|
|
3199
3188
|
return 6371.0088 * 2 * math.asin(math.sqrt(a))
|
|
3200
3189
|
|
|
3201
3190
|
|
|
3202
|
-
#
|
|
3191
|
+
# 中国经纬度范围(与发布侧坐标写入校验同口径)。
|
|
3203
3192
|
# 超出即视为调用方传错(常见根因:经纬度顺序写反、用了海外国标 API)。
|
|
3204
3193
|
_GEO_LAT_RANGE = (3.0, 54.0)
|
|
3205
3194
|
_GEO_LNG_RANGE = (73.0, 136.0)
|
|
@@ -3275,24 +3264,21 @@ def _rec_summary(rec: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
3275
3264
|
"score": rec.get("sc"),
|
|
3276
3265
|
"has_phone": bool(rec.get("tel")),
|
|
3277
3266
|
"process": rec.get("proc", []),
|
|
3278
|
-
#
|
|
3267
|
+
# 工艺来源:
|
|
3279
3268
|
# None = 能力卡 / 企业自述 / 类默认但工艺码少于 4 个
|
|
3280
|
-
# "inferred:A1" =
|
|
3281
|
-
# A3 94.3% / B0 单路 96.0%,见 docs/PROCESS_INFERENCE.md)
|
|
3269
|
+
# "inferred:A1" = 零采集推断,字母按证据强度分档(A1 最强,B0 单路)
|
|
3282
3270
|
# "class_default" = 该记录挂了 ≥4 个「国标小类默认工艺码」,不是这家企业
|
|
3283
|
-
#
|
|
3284
|
-
# 12,970 条 pv=derived)。必须让 Agent 看得见:否则它会把
|
|
3271
|
+
# 的事实工艺,只是同类众数。必须让 Agent 看得见:否则它会把
|
|
3285
3272
|
# 「这一类大概都做」当成「这家确实做」,严苛场景直接下单。
|
|
3286
|
-
#
|
|
3287
|
-
# 12,988 条里只有 838 条有推断候选,其余 12,150 条没有任何强证据可换。
|
|
3273
|
+
# 工艺码保持原样(召回不变),只加证据标签 —— 不编造也不减召回。
|
|
3288
3274
|
"process_evidence": (
|
|
3289
3275
|
"inferred:" + rec["pinf"] if rec.get("pinf")
|
|
3290
3276
|
else ("class_default" if (rec.get("pv") == "derived"
|
|
3291
3277
|
and len(rec.get("proc") or []) >= 4) else None)),
|
|
3292
|
-
#
|
|
3278
|
+
# 业务能力码:非制造业门类的「能干什么」,如 fmt_bar 酒吧 /
|
|
3293
3279
|
# edu_vocational 职业技能培训 / con_steel 钢结构。此前它只参与匹配不返回,
|
|
3294
3280
|
# 于是「搜酒吧」的结果里看不出哪家真是酒吧。现在一并返回,agent 可据此
|
|
3295
|
-
#
|
|
3281
|
+
# 复核与二次筛选。
|
|
3296
3282
|
"capability": rec.get("cap", []),
|
|
3297
3283
|
"material": rec.get("mat", []),
|
|
3298
3284
|
"cert": rec.get("cert", []),
|
|
@@ -3315,7 +3301,7 @@ _RE_WORD = re.compile(r"[a-z0-9]+")
|
|
|
3315
3301
|
|
|
3316
3302
|
|
|
3317
3303
|
def _tokenize(text: str) -> List[str]:
|
|
3318
|
-
"""
|
|
3304
|
+
"""切成可索引的词。必须与发布侧索引的 tokenize 保持一致。
|
|
3319
3305
|
|
|
3320
3306
|
CJK 取 1-gram + 2-gram;拉丁/数字取整词 + 长度 >= 2 的前缀。
|
|
3321
3307
|
"""
|
|
@@ -3490,13 +3476,12 @@ def _read_many_text(paths: List[str]) -> Dict[str, str]:
|
|
|
3490
3476
|
def _one(p: str) -> Optional[str]:
|
|
3491
3477
|
return _ctx.copy().run(_shard_text, p) # 每个任务一份副本,可并发进入
|
|
3492
3478
|
|
|
3493
|
-
# 分批提交 +
|
|
3494
|
-
#
|
|
3479
|
+
# 分批提交 + **快速放弃**。
|
|
3480
|
+
# 为什么必须分批:全量回退要拉的分片很多。若失败是**系统性**的
|
|
3495
3481
|
# (断网、代理死、身份 401、429 限流),挨个试完只是把「一次失败」放大成
|
|
3496
|
-
#
|
|
3497
|
-
#
|
|
3498
|
-
#
|
|
3499
|
-
# 判据:跑满 32 个后统计**失败占比**,超过一半就停 —— 此时哪怕剩下的全成功,
|
|
3482
|
+
# 几百次重试,用户侧只看到「工具卡很久然后返回 0 条」,
|
|
3483
|
+
# 而且完全不知道是真没有这家企业还是取数挂了。
|
|
3484
|
+
# 判据:跑满一批后统计**失败占比**,超过一半就停 —— 此时哪怕剩下的全成功,
|
|
3500
3485
|
# 结果也已经残缺不全,继续拉没有意义,不如把故障如实报给调用方。
|
|
3501
3486
|
_BATCH = 16
|
|
3502
3487
|
_tried = 0
|
|
@@ -3556,9 +3541,8 @@ def _index_fresh() -> bool:
|
|
|
3556
3541
|
def _index_reason() -> str:
|
|
3557
3542
|
"""索引为什么没被用上 —— 回给调用方,别再让人靠猜。
|
|
3558
3543
|
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
还是「落后于数据」。排查要靠翻代码 —— 这个字段就是为这件事加的。
|
|
3544
|
+
只回 `via_index:false` 一个布尔,看不出是「索引没部署」「版本不符」
|
|
3545
|
+
还是「落后于数据」,排查要靠翻代码 —— 这个字段就是为这件事加的。
|
|
3562
3546
|
"""
|
|
3563
3547
|
meta = _index_meta()
|
|
3564
3548
|
if not meta:
|
|
@@ -3589,18 +3573,15 @@ _RECALL_DF_MIN = 2
|
|
|
3589
3573
|
|
|
3590
3574
|
# --- 候选分片硬上限(保险丝,不是性能方案)--------------------------------- #
|
|
3591
3575
|
# 老实现是「并集 >= 60% 分片就 return None → 调用方全量扫」。那个阈值是**相对量**,
|
|
3592
|
-
#
|
|
3593
|
-
#
|
|
3594
|
-
#
|
|
3595
|
-
# 13~16 MB,因为倒排粒度是分片级,不是行级。这件事只能靠行级倒排解决(见
|
|
3596
|
-
# docs/两步走落地方案.md 的 P2),本上限只负责让「最坏情况」有界:
|
|
3576
|
+
# 分片数一涨就必然触发,而触发代价是把整库拉下来。
|
|
3577
|
+
# 倒排粒度是分片级、不是行级,索引路径本身也可能拉回很大的体积;
|
|
3578
|
+
# 这件事只能靠行级倒排解决,本上限只负责让「最坏情况」有界:
|
|
3597
3579
|
# 宁可少拉几片并如实上报,也绝不静默全量。
|
|
3598
|
-
#
|
|
3580
|
+
# 上限取分片总量的大部分,给数据增长留出不误伤的余量。
|
|
3599
3581
|
_RECALL_SHARD_CAP = 400
|
|
3600
3582
|
|
|
3601
3583
|
# 本进程最近一次召回是否被截断。用 threading.local:工具调用是并发处理的,
|
|
3602
|
-
# 用模块级字典会让 A 线程的截断标记串到 B
|
|
3603
|
-
# 已经踩过一次)。
|
|
3584
|
+
# 用模块级字典会让 A 线程的截断标记串到 B 线程的回执里。
|
|
3604
3585
|
_recall_tls = threading.local()
|
|
3605
3586
|
|
|
3606
3587
|
|
|
@@ -3613,7 +3594,7 @@ def _cap_shards(picked, weights: Dict[str, float], where: str) -> List[str]:
|
|
|
3613
3594
|
"""候选分片超过 `_RECALL_SHARD_CAP` 时按命中密度截断,并记下截断事实。
|
|
3614
3595
|
|
|
3615
3596
|
按权重(`idf × 该片命中数`)降序,而不是按路径字典序:密度高的片更可能给出
|
|
3616
|
-
|
|
3597
|
+
相关记录,按路径截断等于随机丢弃真实企业。
|
|
3617
3598
|
|
|
3618
3599
|
返回排序后的路径列表;截断与否都返回,调用方**不需要**分支。
|
|
3619
3600
|
"""
|
|
@@ -4213,16 +4194,14 @@ def search_vendors(query: str = "", city: str = "", gb: str = "",
|
|
|
4213
4194
|
lat_v, lng_v, rad, geo_err = _geo_params(lat, lng, radius_km)
|
|
4214
4195
|
if geo_err:
|
|
4215
4196
|
return geo_err
|
|
4216
|
-
# ──
|
|
4197
|
+
# ── 分页深度上限 ─────────────────────────────────────────────────────
|
|
4217
4198
|
# 这不是性能优化,是**反截取**。翻页遍历是从公开接口搬走整个库的唯一
|
|
4218
|
-
#
|
|
4219
|
-
# 而真实用户翻不到第 11 页 —— 他要的是「找几家能做的」,不是「第 4000 条是谁」。
|
|
4199
|
+
# 可行路径,而真实用户要的是「找几家能做的」,不会翻到很深的结果页。
|
|
4220
4200
|
# ⚠ 这条只是体验层的一道闸:search 本身在客户端本地过滤,改客户端即可绕过。
|
|
4221
|
-
#
|
|
4201
|
+
# 真正的防线在服务端的额度与鉴权层。
|
|
4222
4202
|
raw_offset = max(0, int(offset))
|
|
4223
4203
|
offset = min(raw_offset, OFFSET_MAX)
|
|
4224
|
-
#
|
|
4225
|
-
# 压成一批。见 `_WARM_INDEX_PATHS` 上方的时间线实测。
|
|
4204
|
+
# 固定索引并发预热:放在任何取数之前,把串行 RTT 压成一批。
|
|
4226
4205
|
_warm_indexes()
|
|
4227
4206
|
q = (query or "").strip().lower()
|
|
4228
4207
|
# 原话归一化:用户输入的是**一句人话**,不是空格分隔的关键词表。
|
|
@@ -4855,10 +4834,16 @@ _SEARCH_PROBE_UNTIL = [0.0]
|
|
|
4855
4834
|
# ≤ ~80KB,差两个数量级。
|
|
4856
4835
|
SERVER_SEARCH_WINDOW = 200
|
|
4857
4836
|
|
|
4837
|
+
# 服务端 AND 下沉的灰度开关(2026-11-13)。缺省关 = 线上行为与改动前逐字节一致。
|
|
4838
|
+
# 打开后 worker 只回「全部多字 term 都命中」的候选,窗口可从 200 收到 20 而不少召回
|
|
4839
|
+
# —— 这正是「降负载」的终局;但它是收紧召回,必须先过影子比对(scripts/shadow_and_compare.py)。
|
|
4840
|
+
_SERVER_AND = str(os.environ.get("BEACON_SERVER_AND", "0")).lower() in ("1", "true", "yes")
|
|
4841
|
+
|
|
4858
4842
|
|
|
4859
4843
|
def _server_search(q: str = "", city: str = "", gb: Any = "", et: int = 0,
|
|
4860
4844
|
limit: int = SERVER_SEARCH_WINDOW,
|
|
4861
|
-
cap: Optional[List[str]] = None
|
|
4845
|
+
cap: Optional[List[str]] = None,
|
|
4846
|
+
and_: Optional[bool] = None) -> Optional[Dict[str, Any]]:
|
|
4862
4847
|
"""POST /v1/search —— 服务端检索(「按条来拉」的检索面,2026-11-03)。
|
|
4863
4848
|
|
|
4864
4849
|
返回 {"total": int, "records": [原始 fp 记录]};任何失败返回 None,
|
|
@@ -4887,6 +4872,15 @@ def _server_search(q: str = "", city: str = "", gb: Any = "", et: int = 0,
|
|
|
4887
4872
|
except Exception:
|
|
4888
4873
|
pass
|
|
4889
4874
|
payload: Dict[str, Any] = {"q": str(q or ""), "limit": int(limit), "offset": 0}
|
|
4875
|
+
# 服务端 AND 下沉(2026-11-13):`and=1` 让 worker 只回「全部多字 term 都命中」
|
|
4876
|
+
# 的候选,窗口里剩下的就是客户端 AND 之后大概率仍活着的那批。
|
|
4877
|
+
#
|
|
4878
|
+
# 默认跟着 `BEACON_SERVER_AND` 走(缺省关):这是一条**收紧召回**的改动,
|
|
4879
|
+
# 而老客户端把 /v1/search 当 OR 窗口用。必须先影子比对确认「只少不多、
|
|
4880
|
+
# 且少的都能被客户端的 AND 规则解释」再打开 —— 见 scripts/shadow_and_compare.py。
|
|
4881
|
+
_and = _SERVER_AND if and_ is None else bool(and_)
|
|
4882
|
+
if _and:
|
|
4883
|
+
payload["and"] = 1
|
|
4890
4884
|
if str(city or "").strip():
|
|
4891
4885
|
payload["city"] = str(city).strip()
|
|
4892
4886
|
if gb_list:
|
|
@@ -4926,7 +4920,8 @@ def _server_search(q: str = "", city: str = "", gb: Any = "", et: int = 0,
|
|
|
4926
4920
|
return {"total": int(o.get("total_matched") or 0),
|
|
4927
4921
|
"cap_matched": int(o.get("cap_matched") or 0),
|
|
4928
4922
|
"records": hits,
|
|
4929
|
-
"gbs_supported": bool(o.get("gbs_supported"))
|
|
4923
|
+
"gbs_supported": bool(o.get("gbs_supported")),
|
|
4924
|
+
"and_supported": bool(o.get("and_supported"))}
|
|
4930
4925
|
continue
|
|
4931
4926
|
except _ue.HTTPError as e:
|
|
4932
4927
|
st[0] = e.code
|
|
@@ -4984,10 +4979,9 @@ def _v1_rec(vid: str) -> Optional[Dict[str, Any]]:
|
|
|
4984
4979
|
return None
|
|
4985
4980
|
# 端点还不存在时的静默期。
|
|
4986
4981
|
#
|
|
4987
|
-
#
|
|
4988
|
-
#
|
|
4989
|
-
#
|
|
4990
|
-
# 探测到端点不存在后安静 5 分钟:既不让每条详情付钱,又能在部署后
|
|
4982
|
+
# 为什么必须有:目标端点不存在时,每次 get_vendor 都会先付一次 404 往返,
|
|
4983
|
+
# 而这一跳换不到任何东西 —— 是纯粹的回归。
|
|
4984
|
+
# 探测到端点不存在后安静一段时间:既不让每条详情付钱,又能在部署后
|
|
4991
4985
|
# 自己恢复(不需要重启 MCP 子进程)。
|
|
4992
4986
|
if time.time() < _REC_PROBE_UNTIL[0]:
|
|
4993
4987
|
return None
|
|
@@ -5297,6 +5291,38 @@ def _cap_card_ids() -> Optional[set]:
|
|
|
5297
5291
|
return _CAP_CARD_IDS
|
|
5298
5292
|
|
|
5299
5293
|
|
|
5294
|
+
def _card_id_keys(vid: str) -> set:
|
|
5295
|
+
"""入参 id 在清单白名单里可能出现的**全部形态**。
|
|
5296
|
+
|
|
5297
|
+
白名单的元素形态取决于清单是哪一代产物:token 化之后是 8 字符 token,
|
|
5298
|
+
之前是内部编号的数字后缀。而入参同样有两种来源:App/搜索结果给 token,
|
|
5299
|
+
控制台与本地调试给 `CN-MFG-…` 内部 id。
|
|
5300
|
+
|
|
5301
|
+
两边形态一旦对不上,结果不是报错而是**静默答"没卡"**:一次网络都不发,
|
|
5302
|
+
客户端 Agent 直接失去 L1 能力卡。所以这里不做"猜一种形态",而是把三种
|
|
5303
|
+
可能都摆出来取交集 —— 多算两个字符串远比漏判安全。
|
|
5304
|
+
"""
|
|
5305
|
+
keys = {vid}
|
|
5306
|
+
tail = vid.rsplit("-", 1)[-1]
|
|
5307
|
+
if tail:
|
|
5308
|
+
keys.add(tail) # 内部 id 的数字后缀(老快照)
|
|
5309
|
+
try:
|
|
5310
|
+
keys.add(_vid_url(vid)) # 内部 id → 对外 token(现行快照)
|
|
5311
|
+
except Exception:
|
|
5312
|
+
pass
|
|
5313
|
+
if _TOKEN_ID_RE.match(vid) and _vid_ready():
|
|
5314
|
+
# token 入参 × 老快照(白名单还是数字后缀)这一格:只能解回去。
|
|
5315
|
+
# 反解失败(无密钥 / 伪 token)就静默跳过,交给下面真正的取卡去判。
|
|
5316
|
+
try:
|
|
5317
|
+
from vid_token import decode as _dec
|
|
5318
|
+
inner = _dec(vid, _vid_secret())
|
|
5319
|
+
if inner:
|
|
5320
|
+
keys.add(inner.rsplit("-", 1)[-1])
|
|
5321
|
+
except Exception:
|
|
5322
|
+
pass
|
|
5323
|
+
return {k for k in keys if k}
|
|
5324
|
+
|
|
5325
|
+
|
|
5300
5326
|
def get_capability_card(vid: str) -> Dict[str, Any]:
|
|
5301
5327
|
vid = (vid or "").strip()
|
|
5302
5328
|
if not vid:
|
|
@@ -5304,18 +5330,17 @@ def get_capability_card(vid: str) -> Dict[str, Any]:
|
|
|
5304
5330
|
# 短路:清单里查得到这张表时,没卡的 id 立刻回,别去撞三基址 404。
|
|
5305
5331
|
want = _cap_card_ids()
|
|
5306
5332
|
if want is not None:
|
|
5307
|
-
#
|
|
5308
|
-
#
|
|
5333
|
+
# 集合里的元素是**发布面上的对外 id**:token 化发布之后是 8 字符 token,
|
|
5334
|
+
# 之前是内部编号的数字后缀;入参也可能是这两种之一。
|
|
5309
5335
|
#
|
|
5310
|
-
# ⚠
|
|
5311
|
-
#
|
|
5312
|
-
#
|
|
5313
|
-
#
|
|
5314
|
-
#
|
|
5315
|
-
#
|
|
5316
|
-
#
|
|
5317
|
-
|
|
5318
|
-
if key not in want:
|
|
5336
|
+
# ⚠ 这里出过两次事故,都是"只猜一种形态"造成的:
|
|
5337
|
+
# · 一律取数字后缀 → token 入参(没有连字符)被判成"没卡",一次请求都不发;
|
|
5338
|
+
# · 只认 token 形态 → 内部 id 入参(`CN-MFG-…` 取后缀得 `0020317`)与
|
|
5339
|
+
# token 化后的白名单永远不相交,**任何有卡企业都回 has_card=false**。
|
|
5340
|
+
# 2026-11-13 实测:耐特斯 CN-MFG-0020317 的
|
|
5341
|
+
# `/skills/registry/capability/<token>.json` 明明 200,MCP 却答没卡。
|
|
5342
|
+
# 判据改为"候选集取交集",两种入参 × 两代清单都能命中。
|
|
5343
|
+
if not (_card_id_keys(vid) & want):
|
|
5319
5344
|
return {
|
|
5320
5345
|
"id": vid, "has_card": False,
|
|
5321
5346
|
"note": "该供应商暂无已发布能力卡(全库仅 %d 家有卡)。"
|
|
@@ -5574,7 +5599,7 @@ TOOLS = [
|
|
|
5574
5599
|
"type": "object",
|
|
5575
5600
|
"properties": {
|
|
5576
5601
|
"id": {"type": "string",
|
|
5577
|
-
"description": "单个主体 id
|
|
5602
|
+
"description": "单个主体 id(与 ids 二选一,只传它时回执是 {\"vendor\": …})"},
|
|
5578
5603
|
"ids": {"type": "array", "items": {"type": "string"}, "maxItems": 20,
|
|
5579
5604
|
"description": "批量主体 id,最多 20 个;自动去重、按传入顺序返回。"
|
|
5580
5605
|
"回执是 {\"vendors\": [...], \"count\": n, \"failed\": [...]},"
|
|
@@ -5813,10 +5838,8 @@ def vendor_onboarding(supplier_id: str = "", company: str = "",
|
|
|
5813
5838
|
"auth_required": True,
|
|
5814
5839
|
"contract_url": AGENT_CONTRACT_URL,
|
|
5815
5840
|
"api_base": AGENT_API_BASE,
|
|
5816
|
-
"docs": "契约 JSON
|
|
5817
|
-
|
|
5818
|
-
"client": "按契约用任意 HTTP 客户端 / curl 实现即可;平台工作副本另有 scripts/agent_onboard.py "
|
|
5819
|
-
"CLI(同样不在公开仓库)",
|
|
5841
|
+
"docs": "契约 JSON 本身即完整文档,按其中字段即可完成接入",
|
|
5842
|
+
"client": "按契约用任意 HTTP 客户端 / curl 实现即可",
|
|
5820
5843
|
"contract_version": "3.1",
|
|
5821
5844
|
"auth": {
|
|
5822
5845
|
"login": "POST /auth/sms/send → /auth/sms/verify(腾讯云短信真实下发,"
|
package/vid_token.py
CHANGED
|
@@ -1,15 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
# -*- coding: utf-8 -*-
|
|
3
|
-
"""供应商 id 的对外 token
|
|
3
|
+
"""供应商 id 的对外 token 化。
|
|
4
4
|
|
|
5
5
|
问题
|
|
6
6
|
====
|
|
7
|
-
内部 id
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
A(manifest 需要身份)+ B(R2 私有)+ C(服务端签发令牌)把「拖库」从 5 分钟
|
|
11
|
-
抬到几十小时,但**枚举本身仍然成立**:只要 id 可预测,攻击者不需要任何
|
|
12
|
-
目录信息,盲扫 URL 就够了。D 要终结的正是这一点。
|
|
7
|
+
内部 id 是顺序编号,存在性可枚举:只要 id 可预测,不需要任何目录信息,
|
|
8
|
+
盲扫 URL 就能逐条试探。服务端门禁只能抬高成本,不能消除枚举本身。
|
|
9
|
+
对外标识必须是不可预测的置换结果,而不是可推导的编号。
|
|
13
10
|
|
|
14
11
|
做法
|
|
15
12
|
====
|
|
@@ -19,10 +16,10 @@ A(manifest 需要身份)+ B(R2 私有)+ C(服务端签发令牌)把
|
|
|
19
16
|
为什么是「置换」而不是「哈希」
|
|
20
17
|
------------------------------
|
|
21
18
|
哈希(SHA/HMAC)不可逆 → 服务端收到 token 后无法还原内部 id,只能建一张
|
|
22
|
-
|
|
19
|
+
全量映射表并每次查库 —— 那是给每个请求加一次数据库往返。
|
|
23
20
|
置换(双射)可逆,还原是纯计算、零 IO。这在这里是决定性的:
|
|
24
|
-
·
|
|
25
|
-
·
|
|
21
|
+
· 对象路径要还原后才知道去存储取哪个文件
|
|
22
|
+
· 记账要还原成内部 id 才能和存量数据对齐
|
|
26
23
|
|
|
27
24
|
为什么是 Feistel 而不是「XOR 一个常数」
|
|
28
25
|
--------------------------------------
|
|
@@ -42,10 +39,10 @@ Crockford base32(去掉 I/L/O/U,避免和 1/0 混淆),8 字符 = 40 bit
|
|
|
42
39
|
|
|
43
40
|
用法
|
|
44
41
|
====
|
|
45
|
-
python
|
|
46
|
-
python
|
|
47
|
-
python
|
|
48
|
-
python
|
|
42
|
+
python vid_token.py selftest
|
|
43
|
+
python vid_token.py enc <内部 id>
|
|
44
|
+
python vid_token.py dec <token>
|
|
45
|
+
python vid_token.py bench # 全量往返校验
|
|
49
46
|
"""
|
|
50
47
|
from __future__ import annotations
|
|
51
48
|
|