beacon-mfg-mcp 1.5.3 → 1.5.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 CHANGED
@@ -13,10 +13,17 @@
13
13
  |------|------|----------|
14
14
  | `search_vendors` | 按 关键词 / 城市 / 国标码 检索,返回精简档案 | `query` `city` `gb` `limit` `offset` |
15
15
 
16
- > **搜索语义(v1.1.1)**:
17
- > - `query` 按**空格分词、要求全部命中(AND)**。例如 `query="精密 加工"` 匹配同时含「精密」和「加工」的记录;而 `query="精密加工"`(连写)只匹配四字**连续出现**的记录,召回更少。
18
- > **实测对照**:`city=苏州` 时 `"精密加工"` 连写仅 **2** 条,`"精密 加工"` 分词 **26** 条(更贴合「精密加工企业」意图);单关键词(如 `"宾馆"`)分词与连写等价(`city=贵阳` 命中 57 条)。
19
- > - **建议 agent**:把用户的多个关键词用空格拆开再传(如「上海 酒店」「精密 加工」),避免连写成一个词导致漏召回。
16
+ > **搜索语义(v1.5.5 起:原话直传)**:
17
+ > - **`query` 直接传用户原话即可**,不必自己拆词。服务端会先归一化:
18
+ > ① **抽城市**(「找一下**苏州**做AI的企业」→ `city=苏州`,城市词不再参与关键词匹配);
19
+ > ② **剔填充词**(的 / 找 / 一下 / 企业 / 有没有 …);
20
+ > ③ **认词条**(国标别名 / 国标类名 / **能力词** 如 AI·人工智能·机器学习·大模型)→ 走定向召回;
21
+ > 剩下的部分才作为通用关键词做 AND 匹配。返回体用 `query_parsed` 回显解析结果。
22
+ > 实测:`search_vendors(query="苏州做AI的企业")` → 命中 `赤兔智能工业(苏州)有限公司`(之前是 **0 条**)。
23
+ > - 仍需注意的是**未被识别的连写词**:`query="精密加工"`(四字连写、不在别名表也不在类名里)
24
+ > 仍按**连续出现**匹配,召回少于 `"精密 加工"`(`city=苏州` 实测 2 条 vs 26 条)。
25
+ > 遇到这种生词,正确做法是**先 `suggest_filters`** 拿候选国标码再改用 `gb=`,
26
+ > 而不是反复试同义词 —— 服务端会在命中 <5 条时用 `hint` / `suggested_gb` 主动提示这条路。
20
27
  > - **检索架构(v1.2.0 起:按需拉取,不再全量冷启动)**:
21
28
  > 早期实现每次查询都要把**全部 fp 分片**拉下来在客户端建索引,复杂度 O(数据总量) —— 11.8 万条约 5.8s,线性外推千万级约 **491s / 2.2GB 内存**,不可用。
22
29
  > v1.2.0 改为 **O(命中量)**:索引在**发布侧预构建**(`scripts/gen_search_index.py`,随 `postfetch` 的 `searchindex` 步骤产出 `skills/registry/index/`),
@@ -26,7 +33,7 @@
26
33
  > 千万量级下只需增大桶数(如 4096)并按城市对大分片二级切分,首次查询耗时仍与总量基本无关。
27
34
  | `get_vendor` | 按 id(+国标码)取完整中文档案 | `id`(必填) `gb`(可选,自动反查) |
28
35
  | `get_capability_card` | 按 id 取能力卡(工艺/设备/产能/认证等) | `id`(必填) |
29
- | `suggest_filters` | **用户原话 → 候选国标小类码**(拆词 + 别名 + 类名),供改用 `gb=` | `query`(必填) `city` `limit` |
36
+ | `suggest_filters` | **用户原话 → 候选国标小类码**(拆词 + 别名 + 类名 + **能力域**),供改用 `gb=` | `query`(必填) `city` `limit` |
30
37
  | `list_industries` | **国标小类货架**(只列有数据的类,附企业条数),供按语义自己挑码 | `keyword` `parent` `limit` `offset` |
31
38
  | `start_sourcing` | 【客户 agent 自动触发】识别找厂/代工/采购/询价意图 → 品类识别→宽召回→需求解析→生成 1~2 轮澄清问题,返回 `session_id` | `demand_text`(必填) `audience_id` |
32
39
  | `answer_sourcing` | 续接澄清轮次:写回客户答案,返回下一轮澄清问题或初选供应商列表 | `session_id`(必填) `answers` |
@@ -37,7 +44,7 @@
37
44
 
38
45
  所有返回都是 JSON 文本。找不到时返回 `{ "error": ... }` 或 `has_card:false`,不会抛协议错。
39
46
 
40
- ### 用户原话 → 国标码:给 agent 的调用协议(v1.5.3)
47
+ ### 用户原话 → 国标码:给 agent 的调用协议(v1.5.5)
41
48
 
42
49
  **为什么需要**:`query` 是**字面子串**匹配,而名录按国标小类归档 —— 客户说「机加工」,
43
50
  库里写的是「机械零部件加工」,字面不通。实测(`city=嘉兴`):
@@ -75,9 +82,11 @@
75
82
  **方式 0 — npm 安装(推荐:无需 clone、无需填路径)**
76
83
 
77
84
  ```bash
78
- npm i -g beacon-mfg-mcp # 或不安装直接用 npx beacon-mfg-mcp
85
+ npm i -g beacon-mfg-mcp
79
86
  ```
80
87
 
88
+ macOS / Linux:
89
+
81
90
  ```jsonc
82
91
  {
83
92
  "mcpServers": {
@@ -89,8 +98,27 @@ npm i -g beacon-mfg-mcp # 或不安装直接用 npx beacon-mfg-mcp
89
98
  }
90
99
  }
91
100
  ```
92
- > 启动器(`bin/beacon-mfg-mcp.js`)已默认注入上述两个变量;此处写出仅为显式/可覆盖。
93
- > 二者保证 python 子进程以 UTF-8 输出,否则中文 Windows 上工具描述/结果会乱码。
101
+
102
+ Windows(**必须走 `cmd /c`**):
103
+
104
+ ```jsonc
105
+ {
106
+ "mcpServers": {
107
+ "beacon-mfg": {
108
+ "command": "cmd",
109
+ "args": ["/c", "beacon-mfg-mcp"],
110
+ "env": { "PYTHONUTF8": "1", "PYTHONIOENCODING": "utf-8" }
111
+ }
112
+ }
113
+ }
114
+ ```
115
+ > **Windows 上 `"command": "beacon-mfg-mcp"` 会 ENOENT**:npm 全局装出来的是
116
+ > `beacon-mfg-mcp.cmd`(`.cmd` 包装器),无 shell 的 `spawn()` 解析不了它,客户端只会
117
+ > 表现成「连不上」。`npx -y beacon-mfg-mcp` 同理(`npx` 自身也是 `.cmd`)。
118
+ > 2026-10-06 实测:`beacon-mfg-mcp` ❌ / `npx` ❌ / `cmd /c beacon-mfg-mcp` ✅(8 工具)。
119
+ >
120
+ > `env` 里的两个变量**不必填**——启动器(`bin/beacon-mfg-mcp.js`)已默认注入,写出仅为显式/可覆盖;
121
+ > `server.py` 另有 UTF-8 兜底,所以**即使宿主把子进程直接拉起来、没带任何环境变量**也不会乱码或崩。
94
122
 
95
123
  不填 `BEACON_REPO` 时默认走 `https://beacon-mfg.pages.dev`,即开即用。
96
124
 
package/RELEASE.md CHANGED
@@ -1,4 +1,4 @@
1
- # Beacon-MFG 只读 MCP 服务 · v1.5.3
1
+ # Beacon-MFG 只读 MCP 服务 · v1.5.5
2
2
 
3
3
  让任意支持 MCP 的主流 agent(Claude Desktop / Cline / Continue / WorkBuddy 等)能够
4
4
  **检索与调用**已发布到 Cloudflare Pages(或 GitHub)的灯塔工厂供应商数据,
@@ -23,7 +23,152 @@ MCP 只做**只读检索**。后端数据采集、英文翻译、库维护等流
23
23
  后三个构成「采购寻源」多轮会话链路 `start_sourcing → answer_sourcing → refine_sourcing`,
24
24
  依赖仓库平级的 `skills/rfq-kernel` 桥接模块。
25
25
 
26
- ## 本次更新(v1.5.3)
26
+ ## 本次更新(v1.5.5)
27
+ **让「用户原话」真的能用。无破坏性变更:入参、三种接入方式、返回体既有字段全部不变(只增不减)。**
28
+
29
+ ### 1. 中文整句直传必然 0 条 —— 关键词切分只有 `q.split()`
30
+
31
+ 症状:`search_vendors(query="苏州做AI的企业")` 返回 **0 条**,
32
+ 而手工拆成 `query="人工智能" + city="苏州"` 就有 —— 等于把「理解自然语言」的责任
33
+ 推给了调用方(agent)和用户。
34
+
35
+ 根因:`q` 只按**空白**切分。中文没有空格,整句话是一个 token:
36
+ `["苏州做ai的企业"]`。而关键词是 **AND** 语义(每个 token 都要出现在记录的检索面里),
37
+ 没有任何一条记录会包含「苏州做ai的企业」这个串 → 必然 0 条。
38
+ 同时城市没被抽出来、能力词也没被认出来。
39
+
40
+ 修法:新增 `_normalize_query()`(详见 `server.py` 的注释),在检索前把一句话拆成
41
+ `(city, keywords, cap_hits, alias_hits, segments)`:
42
+
43
+ | 步骤 | 做法 | 例 |
44
+ |---|---|---|
45
+ | 抽城市 | 命中 `city.json` 里的城市名(取**最长**),从关键词里摘掉 | 「找一下**苏州**做AI的企业」→ `city=苏州` |
46
+ | 认词条 | 国标别名 / 国标类名 / **能力词**,最长优先、不重叠 | `ai` → 能力域 `tech_ai` |
47
+ | 剔填充词 | 的 / 找 / 一下 / 企业 / 有没有 … | 剩下 `[]` |
48
+ | 留残词 | 识别不了的才当通用关键词(仍走 AND) | — |
49
+
50
+ - 被识别出的词条**保留进关键词**(不丢弃),所以「厂名里真写了这个词」仍能被字面召回,
51
+ 既有行为不变。
52
+ - 纯结构字(`厂/店/家/司`)不作关键词:它们会参与 AND 过滤,把「XX有限公司」整批误滤掉
53
+ (实测「昆山 做CNC的厂」在 cnc 被消费后只剩一个「厂」,退化成整城扫)。
54
+ - 返回体新增 **`query_parsed`**,只在这句话确实被拆过时出现,回显 `city` /
55
+ `city_from_query` / `keywords` / `recognized`。
56
+
57
+ 实测:`search_vendors(query="苏州做AI的企业")` → **命中 `赤兔智能工业(苏州)有限公司`**(0.97s)。
58
+
59
+ ### 2. 能力词只做「整词精确」匹配,句子里永远认不出
60
+
61
+ `cap_alias_codes()` 只把 `q.split()` 出来的 token 拿去和词表精确比对,
62
+ 而**国标别名侧(`alias_codes`)一直是子串匹配** —— 两侧行为不一致本身就是 bug。
63
+ 于是「AI」这种词只在用户恰好单独输入「AI」时有效。
64
+
65
+ 修法:能力词也做子串扫描(ASCII 词加 `(?<![a-z0-9])…(?![a-z0-9])` 词边界,
66
+ 避免 `ai` 命中 `chair`、`cnc` 命中 `cncxx`),整词精确仍然优先。
67
+ 同时把**纯汉字能力词**并入切词表(ASCII 的 `c`/`3c` 这类不进,太容易把查询切碎)。
68
+
69
+ ### 3. Windows 接入配置:`"command": "beacon-mfg-mcp"` 会 ENOENT
70
+
71
+ npm 全局装出来的是 **`.cmd` 包装器**,而没有 shell 的 `spawn('beacon-mfg-mcp')`
72
+ 在 Windows 上解析不了 `.cmd`,客户端只表现为「连不上 / 工具不出现」。
73
+ `npx -y beacon-mfg-mcp` 同理(`npx` 自身也是 `.cmd`)。
74
+
75
+ | 写法 | Windows `spawn`(无 shell) |
76
+ |---|---|
77
+ | `beacon-mfg-mcp` | ❌ ENOENT |
78
+ | `npx -y beacon-mfg-mcp` | ❌ ENOENT |
79
+ | `cmd /c beacon-mfg-mcp` | ✅ 正常(8 个工具) |
80
+
81
+ **Windows 请用 `{"command":"cmd","args":["/c","beacon-mfg-mcp"]}`**;
82
+ macOS / Linux 用 `{"command":"beacon-mfg-mcp","args":[]}`。
83
+ `README.md` 与 `BeaconMFG_MCP_安装指南.md` 已按平台分别给出配置。
84
+
85
+ ### 4. `suggest_filters` 在能力域命中时不再让 agent 空手翻货架
86
+
87
+ - 能力域命中但国标侧无候选 → `cap_candidates` + `next_call=search_vendors`(原有分支,
88
+ 现在能被更多词触发,例如「手机壳」→ 能力域 `家电数码`)。
89
+ - `_attach_routing_hint` 原先只有「0 条且无候选」和「有候选」两个分支,**0 条时会先把
90
+ 能力域这条唯一可执行的线索引掉**;现在 0 条也优先给能力域提示,并在带了城市时
91
+ 补一句「去掉 city 再试」(能力域企业在空间上很稀疏)。
92
+ - `suggest_filters` 返回体新增 `city_from_query` / `keywords` / `recognized`。
93
+
94
+ ### 验收(发布前全绿)
95
+
96
+ - 协议回归 **16/16**(HTTP / stdio 换行分帧 / adapter 标准分帧 / 死代理容错)
97
+ - 语义路由验收 **14/14**
98
+ - 检索哨兵「机加工@嘉兴」**643 条**(修复前 1 条)、城市正确性 **10/10**、关键词疑似无关 **0 条**
99
+ - 冷检索 5.11s / 热 0.97s;端到端「找到嘉兴机加工厂并拿到电话」**6.81s**
100
+ - 归一化自身开销 **1.4~20ms**(相对热查询 ~1000ms 占比 ≤2%)
101
+
102
+ ---
103
+
104
+ ## 本次更新(v1.5.4)· 已发布
105
+ **把「宿主根本连不上」和「连上了但结果会误导 agent」这两类问题一次收干净。
106
+ 无破坏性变更:八个 tool 的入参、三种接入方式、返回体既有字段全部不变(只增不减)。**
107
+
108
+ ### 1. stdio 适配层:宿主是**换行分帧**,旧 adapter 只认 `Content-Length`
109
+
110
+ 症状:MCP 在宿主里**永远起不来** —— 探测日志连续 249 次 `initialize/tools/list timed out`,
111
+ 工具列表里从头到尾没有 beacon-mfg。而手工喂同样三行 JSON 却是好的。
112
+
113
+ 根因(三层,缺一层都还是坏的):
114
+
115
+ | 层 | 问题 | 修法 |
116
+ |---|---|---|
117
+ | 1 | `_read_msg()` 只认 LSP 的 `Content-Length` 分帧;宿主发的是**换行分隔 JSON** → 首条即当垃圾丢弃,回包 0 字节 | 按首条报文自动判分帧;单条坏报文不再当 EOF 结束会话 |
118
+ | 2 | 拉起子进程时没注入 `PYTHONUTF8` → 中文 Windows 的 cp936 使 `tools/list` 里那条带 `⚠` 的工具描述抛 `UnicodeEncodeError`,**进程直接崩** | adapter 注入 `PYTHONUTF8=1`/`PYTHONIOENCODING=utf-8`;`server.py` 另加 stdout/stderr reconfigure 兜底 |
119
+ | 3 | 严格「请求↔响应」配对:宿主若发来**带 id 的通知**(server 按协议不回),配对挂住 → 死锁 | 改成**纯双向透传桥**,不解析、不配对,只转字节 |
120
+
121
+ 外加收尾 drain:stdin 到 EOF 后先关子进程 stdin、等回包吐完再 terminate,否则最后几帧响应会被连坐丢掉。
122
+
123
+ 修复后宿主探测一次到位:`state=ready`,`tools/list` 5578 字节,**579 ms**。
124
+
125
+ ### 2. `suggest_filters` 补 `cap_candidates`:能力域命中不再被当成「无候选」
126
+
127
+ 「找苏州做 AI 的企业」这类需求,国标小类里**本来就没有**对应的类,
128
+ 但名录里有跨门类的**能力域**(如 `tech_ai` 人工智能与算法)。旧版只给
129
+ `candidates: []` + 一句「去 `list_industries` 自己挑」,agent 等于被推去空手翻货架。
130
+
131
+ 现在单独给一个字段(和 `candidates` 分开,避免 agent 拿能力键去当 `gb=` 用):
132
+
133
+ ```json
134
+ "cap_candidates": [{"cap": "tech_ai", "name": "人工智能与算法", "word": "人工智能", "records": 1}],
135
+ "cap_note": "这些是**能力域**(跨门类的工艺/技术标签),不是国标码,别拿去当 gb= 用。……",
136
+ "next_call": {"tool": "search_vendors", "arguments": {"query": "人工智能", "city": "苏州", "limit": 20}}
137
+ ```
138
+
139
+ ### 3. `via` 标签如实化:新增 `cap_text` / `city`
140
+
141
+ `_hay`(检索面)把**厂名/工艺**和**能力键的中文词面**混在一起 ——
142
+ 于是「query 词出现在 `_hay` 里」分不清是「厂名真写了这个词」还是「只是撞上了能力词面」。
143
+ 实测:问「苏州做人工智能的企业」,赤兔智能(能力键 `tech_ai`,厂名/工艺里**一个「人工智能」都没有**)
144
+ 被判成 `via=text`,agent 会误读成「这家厂自己写了 AI」。
145
+
146
+ 现在按**字面检索面**(`_hay_lit`,不含 cap 词面)判定:
147
+ `text`(真的字面命中)/ `cap_text`(只是撞上能力词面)/ `alias` / `cap` / `city`(纯城市筛选)。
148
+ 结果按 `text → cap_text → alias → cap` 稳定分档排序,并附 `order_note` 写明
149
+ **`score` 是能力画像分(有已发布能力卡才有分、无卡为 0),不是相关度** ——
150
+ 防止「一家餐厅 sc=0 排在持卡 L2 企业 sc=40 前面」这类误判。
151
+
152
+ ### 4. `get_vendor`:按分片归属回填 `gb`,消除自相矛盾
153
+
154
+ zh 记录自身常常**不写 `gb`**(只留 `industry`),但它就归档在那个 gb 分片里。
155
+ 结果同一家企业:`search_vendors(gb=6531)` 报 `gb:"6531"`,`get_vendor` 却报
156
+ `gb:null` + `gb_unclassified:true`。现在按分片归属回填并标 `gb_source:"shard"`。
157
+
158
+ ### 5. 命中 1~4 条时也补 `hint`
159
+
160
+ `total_matched` 落在 1~4 时旧版**不给任何提示**(只覆盖了 0 条和有候选两种情况),
161
+ agent 容易把「数据薄」读成「没有这家厂」。
162
+
163
+ ### 验收(发版前实测)
164
+
165
+ - 协议回归 `mcp/test_protocol.py --stdio --adapter`:**16/16 通过**(HTTP / 换行 stdio / 标准分帧三链路 × 握手 + 8 工具 + 三城市检索)。
166
+ - 语义路由验收 `scripts/check_semantic_routing.py`:**14/14 通过**。
167
+ - 检索哨兵「机加工@嘉兴」:**643 条**(修复前 1 条);城市正确性 10/10;关键词 0 条无关。
168
+ - 体感:冷启动首查 4.55 s、同进程热查 0.77 s、`get_capability_card` 0.01 s;
169
+ 「找到一个嘉兴机加工厂并拿到联系方式」全程 **6.48 s**。
170
+
171
+ ## 本次更新(v1.5.3),已发布
27
172
  **用户说的话和名录里的写法对不上时,不再只回 1 条 —— 加了一层「语义路由」;
28
173
  顺带把 `get_capability_card` 从 42~65 秒压到 2 毫秒。**
29
174
 
@@ -214,7 +359,7 @@ v1.5.0 的 idmap 有个致命假设:片存在但片里没这条 id ⇒ 判「
214
359
  ## 安装(三选一)
215
360
 
216
361
  ### 1. 从 GitHub Release 下载(推荐,零依赖)
217
- 下载本 Release 的 `beacon-mfg-mcp-mcp-v1.5.3.tar.gz`,解压后:
362
+ 下载本 Release 的 `beacon-mfg-mcp-mcp-v1.5.5.tar.gz`,解压后:
218
363
  ```jsonc
219
364
  {
220
365
  "mcpServers": {
package/mcp_adapter.py CHANGED
@@ -3,93 +3,211 @@
3
3
  """
4
4
  BeaconMFG MCP —— 标准协议适配层(adapter / shim)。
5
5
 
6
- 为什么需要它:
7
- beacon-mfg 的 mcp/server.py 用「换行分隔 JSON-RPC 子集」做传输
8
- (`for raw in sys.stdin` + `json + "\n"`),并不是标准 MCP 的
9
- `Content-Length` 分帧。标准 MCP 客户端(WorkBuddy 连接器、Claude Desktop
10
- 等)按 LSP 分帧跟它握手会解析失败。
11
-
12
- 本适配层做什么:
13
- - 对外(父进程 = MCP host):讲标准 MCP stdio(Content-Length 分帧)。
14
- - 对内(子进程 = server.py):把请求转成换行分隔 JSON 转发,并把 server.py
15
- 的换行回包重新分帧后返回。
16
- - 不修改 server.py 任何代码;server.py 的「通知无回包」语义天然保留
17
- (转发通知后不等待回包)。
6
+ 【为什么存在】
7
+ mcp/server.py 用**换行分隔 JSON-RPC**(`for raw in sys.stdin` + `json + "\n"`),
8
+ 这正是 **MCP stdio 规范**的分帧方式。但有一类宿主用 LSP 式的
9
+ `Content-Length: N\\r\\n\\r\\n{...}` 分帧。本适配层让同一个 server 同时吃两种帧。
10
+
11
+ 【2026-10-06 两处致命缺陷(实测代价:WorkBuddy 里 249 轮探测全部超时,0 个工具)】
12
+
13
+ ① **只认 Content-Length**。旧版断言「标准 MCP 客户端用 Content-Length 分帧」,
14
+ 于是收到换行的 `{...}\\n` 时不认为一条消息结束,一直阻塞等一个永远不来的空行;
15
+ 宿主 15s 判 `initialize timed out`,杀进程重试。实测两端分歧:
16
+ printf '{...}\\n' | python server.py → 正常回包 ✅
17
+ printf '{...}\\n' | python mcp_adapter.py → stdout 0 字节、静默退出 ❌
18
+ Content-Length 分帧 | 两者都正常 ✅
19
+
20
+ ② **「一请求等一响应」的严格配对会死锁**。旧版每转发一个带 id 的请求,就阻塞等子进程
21
+ 回一条;而 server.py 里 `notifications/initialized` 是**不回包**的。宿主只要把这条
22
+ 通知带上 id 发出来(部分宿主确实这么干),adapter 就永久卡在等待上,
23
+ 之后所有请求(含 tools/list)都拿不到响应 → 宿主报 `tools/list timed out`。
24
+ 现象与 ① 极像(都是超时),但根因完全不同,只修 ① 会停在 tools/list 上原地打转。
25
+
26
+ 【现在的设计:纯分帧桥】
27
+ 不再做请求/响应的配对,**双向直通**:
28
+ 主线程 客户端 → 子进程(自动识别帧,统一转成换行分隔)
29
+ 读线程 子进程 → 客户端(按客户端用的那一种帧回写,逐条即时转发)
30
+ 唯一的状态是「客户端请求的 protocolVersion」——server.py 回的是固定版本号,
31
+ 严格客户端会因版本不一致卡住握手,所以回包时按 id 认领并改写。
32
+ 这样无论宿主发通知带不带 id、发不发未知方法,都不可能死锁。
18
33
 
19
34
  用法:
20
35
  python mcp_adapter.py
21
36
  环境变量(可选):BEACON_REPO / BEACON_MASK_PHONE / BEACON_SOURCE
37
+ 诊断(可选):BEACON_ADAPTER_TRACE=1 把收发实录写进 %TEMP%/beacon_adapter_trace.log
22
38
  """
23
39
  import json
24
40
  import os
25
41
  import sys
26
42
  import subprocess
43
+ import tempfile
27
44
  import threading
28
- import queue
45
+ import time
29
46
 
30
47
  _HERE = os.path.dirname(os.path.abspath(__file__))
31
48
  REPO = os.environ.get("BEACON_REPO") or os.path.dirname(_HERE)
32
49
  SERVER = os.path.join(_HERE, "server.py")
33
50
  MASK = os.environ.get("BEACON_MASK_PHONE", "1")
34
51
 
52
+ # 输入缓冲(字节级,跨次 read 保留)
53
+ _BUF = b""
54
+ # 客户端用的帧格式:None=未知,'lsp'=Content-Length,'lines'=换行分隔
55
+ _FRAMING = {"kind": None}
56
+ # initialize 的 id 与客户端请求的协议版本(回包时按 id 认领并改写)
57
+ _INIT = {"id": None, "proto": None}
58
+ # 诊断开关:默认**关**(排查「宿主不吭声」时设 BEACON_ADAPTER_TRACE=1 打开,
59
+ # 收发实录会写进 %TEMP%/beacon_adapter_trace.log)。2026-10-06 定位 tools/list 超时
60
+ # 就是靠它一眼看出「子进程吐到一半就死了」。
61
+ _TRACE_ON = os.environ.get("BEACON_ADAPTER_TRACE") == "1"
62
+
63
+
64
+ def _trace(msg: str) -> None:
65
+ if not _TRACE_ON:
66
+ return
67
+ try:
68
+ p = os.path.join(tempfile.gettempdir(), "beacon_adapter_trace.log")
69
+ with open(p, "a", encoding="utf-8") as f:
70
+ f.write("%.3f %d %s\n" % (time.time(), os.getpid(), msg[:400]))
71
+ except Exception:
72
+ pass
35
73
 
36
- def _spawn_server():
37
- return subprocess.Popen(
38
- [sys.executable, "-u", SERVER],
39
- stdin=subprocess.PIPE,
40
- stdout=subprocess.PIPE,
41
- env={**os.environ, "BEACON_REPO": REPO, "BEACON_MASK_PHONE": MASK},
42
- bufsize=0, text=True, encoding="utf-8",
43
- )
44
74
 
75
+ # --------------------------------------------------------------------------- #
76
+ # 读:客户端 → 我们(自动识别两种帧)
77
+ # --------------------------------------------------------------------------- #
78
+ def _read_some() -> bytes:
79
+ """非阻塞语义地取一块可用字节(单次 os.read,有多少拿多少)。EOF 返回 b''。"""
80
+ try:
81
+ return sys.stdin.buffer.raw.read(65536)
82
+ except Exception:
83
+ return b""
84
+
85
+
86
+ def _read_line() -> bytes | None:
87
+ """读到 \\n 为止,返回不含换行的 bytes;EOF 且无残留返回 None。"""
88
+ global _BUF
89
+ while b"\n" not in _BUF:
90
+ chunk = _read_some()
91
+ if not chunk:
92
+ if _BUF:
93
+ line, _BUF = _BUF, b""
94
+ return line
95
+ return None
96
+ _BUF += chunk
97
+ line, _, _BUF = _BUF.partition(b"\n")
98
+ return line
45
99
 
46
- def _read_stdin_framed():
47
- """从父进程(stdio)读取一条标准 MCP 分帧消息,返回 dict;EOF 返回 None。"""
48
- buf = b""
100
+
101
+ def _read_exact(n: int) -> bytes | None:
102
+ global _BUF
103
+ while len(_BUF) < n:
104
+ chunk = _read_some()
105
+ if not chunk:
106
+ return None
107
+ _BUF += chunk
108
+ body, _BUF = _BUF[:n], _BUF[n:]
109
+ return body
110
+
111
+
112
+ def _read_msg() -> dict | None:
113
+ """读一条客户端消息,自动识别 Content-Length 分帧 / 换行分隔。真 EOF → None。
114
+
115
+ 单条脏数据**不终止会话**(旧版把它当 EOF 直接退,宿主只会看到超时)。
116
+ """
49
117
  while True:
50
- c = sys.stdin.buffer.read(1)
51
- if not c:
118
+ line = _read_line()
119
+ while line is not None and not line.strip():
120
+ line = _read_line() # 跳过空行
121
+ if line is None:
122
+ _trace("RECV eof")
52
123
  return None
53
- buf += c
54
- if buf.endswith(b"\r\n\r\n") or buf.endswith(b"\n\n"):
55
- break
56
- clen = None
57
- for line in buf.split(b"\n"):
58
- low = line.strip().lower()
59
- if low.startswith(b"content-length:"):
124
+ head = line.strip()
125
+
126
+ if head.lower().startswith(b"content-length:"):
127
+ _FRAMING["kind"] = "lsp"
128
+ clen = None
60
129
  try:
61
- clen = int(low.split(b":", 1)[1].strip())
130
+ clen = int(head.split(b":", 1)[1].strip())
62
131
  except ValueError:
63
132
  pass
64
- if clen is None:
65
- return None
66
- body = b""
67
- while len(body) < clen:
68
- chunk = sys.stdin.buffer.read(clen - len(body))
69
- if not chunk:
70
- return None
71
- body += chunk
72
- try:
73
- return json.loads(body.decode("utf-8"))
74
- except Exception:
75
- return None
133
+ while True: # 吃掉剩余头直到空行
134
+ h = _read_line()
135
+ if h is None:
136
+ _trace("RECV eof(header)")
137
+ return None
138
+ hs = h.strip()
139
+ if not hs:
140
+ break
141
+ if clen is None and hs.lower().startswith(b"content-length:"):
142
+ try:
143
+ clen = int(hs.split(b":", 1)[1].strip())
144
+ except ValueError:
145
+ pass
146
+ if clen is None:
147
+ _trace("DROP lsp 头里没有 Content-Length")
148
+ continue
149
+ body = _read_exact(clen)
150
+ if body is None:
151
+ _trace("RECV eof(body) 期待 %d 字节" % clen)
152
+ return None
153
+ try:
154
+ obj = json.loads(body.decode("utf-8"))
155
+ except Exception as e:
156
+ _trace("DROP lsp body 解析失败 %s raw=%r" % (e, body[:200]))
157
+ continue
158
+ _trace("RECV lsp method=%s id=%s" % (obj.get("method"), obj.get("id")))
159
+ return obj
160
+
161
+ # MCP stdio 规范:一条消息 = 一行 JSON(不带 Content-Length)
162
+ if _FRAMING["kind"] is None:
163
+ _FRAMING["kind"] = "lines"
164
+ try:
165
+ obj = json.loads(head.decode("utf-8"))
166
+ except Exception as e:
167
+ _trace("DROP lines 解析失败 %s raw=%r" % (e, head[:200]))
168
+ continue
169
+ _trace("RECV lines method=%s id=%s" % (obj.get("method"), obj.get("id")))
170
+ return obj
171
+
76
172
 
173
+ # --------------------------------------------------------------------------- #
174
+ # 写:我们 → 客户端(按客户端进来的那一种帧回写)
175
+ # --------------------------------------------------------------------------- #
176
+ def _write_msg(obj: dict) -> None:
177
+ """两种帧**不能混**,且 LSP 帧的 body 一个字节都不能少。
77
178
 
78
- def _write_stdout_framed(obj):
79
- """向父进程(stdio)写一条标准 MCP 分帧消息。"""
179
+ 只写 `Content-Length` 头、漏掉 body,客户端会一直等那 N 个字节,
180
+ 表现同样是「timed out」——极难从现成日志里看出来,务必别手滑。
181
+ """
80
182
  data = json.dumps(obj, ensure_ascii=False).encode("utf-8")
81
- sys.stdout.buffer.write(b"Content-Length: %d\r\n\r\n" % len(data))
82
- sys.stdout.buffer.write(data)
183
+ if _FRAMING["kind"] == "lsp":
184
+ sys.stdout.buffer.write(b"Content-Length: %d\r\n\r\n" % len(data) + data)
185
+ else:
186
+ sys.stdout.buffer.write(data + b"\n")
83
187
  sys.stdout.buffer.flush()
188
+ _trace("SEND framing=%s id=%s bytes=%d" % (_FRAMING["kind"], obj.get("id"), len(data)))
189
+
190
+
191
+ def _spawn_server():
192
+ return subprocess.Popen(
193
+ [sys.executable, "-u", SERVER],
194
+ stdin=subprocess.PIPE,
195
+ stdout=subprocess.PIPE,
196
+ # ⚠ 必须显式注入 UTF-8(npm 启动器 bin/beacon-mfg-mcp.js 就是这么干的,本适配层
197
+ # 曾漏掉):中文 Windows 上 python 默认按 cp936 写 stdout,而 tool 描述里含
198
+ # `⚠`(U+26A0) 这类 GBK 编不出的字符 —— initialize 能过,`tools/list` 必崩,
199
+ # 宿主只看到「tools/list timed out」。2026-10-06 实测:WorkBuddy 249 轮探测全废。
200
+ env={**os.environ, "BEACON_REPO": REPO, "BEACON_MASK_PHONE": MASK,
201
+ "PYTHONUTF8": "1", "PYTHONIOENCODING": "utf-8"},
202
+ bufsize=0, text=True, encoding="utf-8",
203
+ )
84
204
 
85
205
 
86
206
  def main():
87
207
  child = _spawn_server()
88
- responses: "queue.Queue" = queue.Queue()
89
- client_protocol = None # 客户端在 initialize 里请求的协议版本
90
208
 
91
- def reader():
92
- # 子进程用换行分隔 JSON;逐行读、入队。
209
+ def pump_down():
210
+ """子进程 → 客户端。逐条即时转发,不做任何配对判断。"""
93
211
  try:
94
212
  for line in child.stdout:
95
213
  line = line.strip()
@@ -97,54 +215,49 @@ def main():
97
215
  continue
98
216
  try:
99
217
  obj = json.loads(line)
100
- except Exception:
218
+ except Exception as e:
219
+ _trace("DROP child 输出解析失败 %s raw=%r" % (e, line[:200]))
101
220
  continue
102
- responses.put(obj)
103
- except Exception:
104
- pass
221
+ # 按 id 认领 initialize 回包,改写回客户端请求的协议版本
222
+ if (_INIT["proto"] and obj.get("id") is not None
223
+ and obj.get("id") == _INIT["id"]
224
+ and isinstance(obj.get("result"), dict)):
225
+ obj["result"]["protocolVersion"] = _INIT["proto"]
226
+ _write_msg(obj)
227
+ except Exception as e:
228
+ _trace("child 读线程异常 %s" % e)
229
+ finally:
230
+ _trace("child stdout 关闭")
105
231
 
106
- threading.Thread(target=reader, daemon=True).start()
232
+ pump = threading.Thread(target=pump_down, daemon=True)
233
+ pump.start()
107
234
 
108
235
  try:
109
236
  while True:
110
- msg = _read_stdin_framed()
237
+ msg = _read_msg()
111
238
  if msg is None:
112
239
  break
113
- method = msg.get("method")
114
- mid = msg.get("id")
115
- is_notification = (mid is None) or (isinstance(method, str) and method.startswith("notifications/"))
116
-
117
- if method == "initialize":
118
- # 记下客户端请求版本,用于回包时回写(兼容严格客户端)
119
- client_protocol = (msg.get("params") or {}).get("protocolVersion")
120
-
121
- # 转发给子进程(换行分隔)
240
+ if msg.get("method") == "initialize":
241
+ _INIT["id"] = msg.get("id")
242
+ _INIT["proto"] = (msg.get("params") or {}).get("protocolVersion")
122
243
  child.stdin.write(json.dumps(msg, ensure_ascii=False) + "\n")
123
244
  child.stdin.flush()
124
-
125
- if is_notification:
126
- continue # 通知无需回包,server.py 自身也是 continue
127
-
128
- # 请求:等子进程回包(按到达顺序,server.py 严格顺序响应)
129
- try:
130
- resp = responses.get(timeout=60)
131
- except queue.Empty:
132
- if mid is not None:
133
- _write_stdout_framed({
134
- "jsonrpc": "2.0", "id": mid,
135
- "error": {"code": -32000, "message": "no response from server.py"},
136
- })
137
- continue
138
-
139
- # 兼容严格客户端:server.py 回的是固定 protocolVersion,
140
- # 改写回客户端在 initialize 里请求的版本,避免握手因版本不一致卡住。
141
- if method == "initialize" and client_protocol and isinstance(resp.get("result"), dict):
142
- resp["result"]["protocolVersion"] = client_protocol
143
-
144
- _write_stdout_framed(resp)
245
+ except Exception as e:
246
+ _trace("主循环异常 %s" % e)
145
247
  finally:
248
+ # ⚠ 别急着 terminate:客户端 stdin 一关就可能还有回包在子进程/管道里,
249
+ # 直接杀进程会把最后几条响应丢掉(宿主侧表现还是「超时」,白折腾)。
250
+ # 正确顺序:关子进程 stdin(它读完就自然退)→ 等读线程吐完 → 再收尾。
251
+ _trace("客户端 stdin 结束,等待子进程吐完剩余回包")
252
+ try:
253
+ child.stdin.close()
254
+ except Exception:
255
+ pass
256
+ pump.join(timeout=60)
257
+ _trace("读线程结束,收尾")
146
258
  try:
147
- child.terminate()
259
+ if child.poll() is None:
260
+ child.terminate()
148
261
  except Exception:
149
262
  pass
150
263
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "beacon-mfg-mcp",
3
- "version": "1.5.3",
3
+ "version": "1.5.5",
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
@@ -43,6 +43,18 @@ import time
43
43
  import hmac
44
44
  import urllib.request
45
45
  import urllib.error
46
+
47
+ # [utf8-guard] 中文 Windows 上,宿主若没注入 PYTHONUTF8/PYTHONIOENCODING,stdout 会落回
48
+ # cp936;而 TOOLS 的 tool 描述里含 `⚠`(U+26A0) 这类 GBK 根本编不出的字符 ——
49
+ # initialize 全是 ASCII 能过,一到 `tools/list` 就 UnicodeEncodeError **当场崩进程**。
50
+ # 宿主侧只能看到「tools/list timed out」,日志里毫无线索。
51
+ # 2026-10-06 实测:WorkBuddy 里连续 249 轮探测全部卡死于此。
52
+ # 强制 UTF-8 + 出错降级替换:宁可显示成 '?',也绝不因为一次打印而断开协议。
53
+ for _s in (sys.stdout, sys.stderr):
54
+ try:
55
+ _s.reconfigure(encoding="utf-8", errors="replace")
56
+ except Exception:
57
+ pass
46
58
  import socket
47
59
  from typing import Any, Dict, List, Optional, Tuple
48
60
  import sys as _sys
@@ -271,9 +283,9 @@ def _pkg_version() -> str:
271
283
  try:
272
284
  p = os.path.join(os.path.dirname(os.path.abspath(__file__)), "package.json")
273
285
  with open(p, "r", encoding="utf-8") as f:
274
- return json.load(f).get("version", "1.5.3")
286
+ return json.load(f).get("version", "1.5.5")
275
287
  except Exception:
276
- return "1.5.3"
288
+ return "1.5.5"
277
289
 
278
290
 
279
291
  # HTTP 基址列表(含兜底镜像)。默认部署 Cloudflare Pages 不可达时,自动回退到
@@ -979,7 +991,7 @@ def _gb_shelf() -> List[Dict[str, Any]]:
979
991
 
980
992
 
981
993
  def _seg_patterns() -> List[Tuple[str, str]]:
982
- """长词优先的匹配表 [(词, 来源)],来源 ∈ alias / gb_name。"""
994
+ """长词优先的匹配表 [(词, 来源)],来源 ∈ alias / gb_name / cap。"""
983
995
  global _SEG_PATTERNS
984
996
  if _SEG_PATTERNS is None:
985
997
  pats: Dict[str, str] = {}
@@ -989,6 +1001,16 @@ def _seg_patterns() -> List[Tuple[str, str]]:
989
1001
  for r in _gb_shelf():
990
1002
  if len(r["name"]) >= 2:
991
1003
  pats.setdefault(r["name"], "gb_name")
1004
+ # 2026-10-06 补 cap(能力词)。在此之前这里只收「国标别名 + 国标类名」,
1005
+ # 而「人工智能 / 机器学习 / 大模型」是**跨门类能力词**,两边都不在 —— 于是
1006
+ # 「找苏州做人工智能的企业」在这里一个字都切不出来,`suggest_filters` 只能
1007
+ # 返回空候选把 agent 赶去翻货架。
1008
+ # 只收 **纯汉字且长度 >= 2** 的能力词:ASCII 侧(ai / cnc / c / 3d…)数量大,
1009
+ # 且「c」「3c」这类单双字符做子串切分会把任意含 c 的串切碎。ASCII 能力词由
1010
+ # `_cap_terms_in` 用词边界单独扫,不进这张切分表。
1011
+ for w in _cap_term_index():
1012
+ if len(w) >= 2 and _is_cjk(w):
1013
+ pats.setdefault(w, "cap")
992
1014
  _SEG_PATTERNS = sorted(pats.items(), key=lambda x: -len(x[0]))
993
1015
  return _SEG_PATTERNS
994
1016
 
@@ -1016,6 +1038,120 @@ def _segment_terms(text: str) -> List[Tuple[str, str]]:
1016
1038
  return hits
1017
1039
 
1018
1040
 
1041
+ # --------------------------------------------------------------------------- #
1042
+ # 原话归一化:把「一句人话」切成可执行的检索参数
1043
+ #
1044
+ # 为什么必须有这一层(2026-10-06 实测):`search_vendors` 的关键词切分原本只有
1045
+ # `q.split()` —— 按空白切。中文用户输入的自然语言没有空格,整句话就是一个 token:
1046
+ # 「苏州做AI的企业」 → tokens = ["苏州做ai的企业"]
1047
+ # 而关键词走的是「每个 token 都必须出现在记录检索面里」(AND)语义,**没有任何一条
1048
+ # 记录**会包含「苏州做ai的企业」这个串,于是必然 0 条。同时城市没被提取、能力词
1049
+ # 'AI' 也认不出。三层叠起来的结果:用户**原话**问什么都是 0 条,必须由 agent 先手工
1050
+ # 拆成 query + city 才行 —— 这是把「理解自然语言」的责任推给了调用方,MCP 该自己扛住。
1051
+ # --------------------------------------------------------------------------- #
1052
+ _STOPWORDS: Tuple[str, ...] = (
1053
+ # 通名/机构词:出现即无信息量(「企业」「公司」本身不是产品词)
1054
+ "生产厂家", "制造商", "加工厂", "制造厂", "供应商", "厂商",
1055
+ "企业", "公司", "工厂", "厂家",
1056
+ # 诉求词
1057
+ "有哪些", "哪几家", "哪家", "几家", "一家",
1058
+ "推荐", "介绍", "查找", "找一下", "查一下", "看一下", "看看",
1059
+ "找找", "查询", "搜索", "检索",
1060
+ # 指代/范围
1061
+ "有没有", "什么样的", "怎么样", "什么", "附近", "周边",
1062
+ "当地", "本地", "这边", "那边", "相关的", "有关的", "有关",
1063
+ # 单字诉求(整词替换,不会伤到词内部)
1064
+ "帮我", "给我", "我要", "我想", "一下",
1065
+ )
1066
+ _STOPWORDS_SET = frozenset(_STOPWORDS)
1067
+ # 只削**两端**的功能字。刻意不含 厂/家/店/司(「酒厂」「酒店」「家电」是合法词),
1068
+ # 也不含 有/用/能/会/可(「有色」「用友」会被削坏)。
1069
+ _EDGE_FILLER = set("的了找做想要请给我你在和与或及是这那们吧呢吗把被让")
1070
+ _RE_RUNSPLIT = re.compile(r"[^0-9a-z\u4e00-\u9fff]+")
1071
+
1072
+
1073
+ def _trim_edges(t: str) -> str:
1074
+ """削掉 token 两端的功能字,只削两端、不动中间(「做ai的」→「ai」)。"""
1075
+ i, j = 0, len(t)
1076
+ while i < j and t[i] in _EDGE_FILLER:
1077
+ i += 1
1078
+ while j > i and t[j - 1] in _EDGE_FILLER:
1079
+ j -= 1
1080
+ return t[i:j]
1081
+
1082
+
1083
+ def _normalize_query(query: str, city: str = "") -> Dict[str, Any]:
1084
+ """把用户原话归一成 (city, keywords, cap_hits, alias_hits, segments)。
1085
+
1086
+ 一句话(「找一下苏州做AI的企业」)里混着四类东西:
1087
+ ① 城市 → 抽出来当 city 过滤(调用方没显式给 city 才抽)
1088
+ ② 已识别词 → 国标别名 / 国标类名 / 能力词(`_segment_terms` 切出)
1089
+ ③ 停用词 → 「的」「找」「企业」这类无信息量的填充
1090
+ ④ 真关键词 → 剔掉 ①②③ 后剩下的
1091
+ ② 保留进 keywords(不丢弃):「厂名里真写了这个词」仍能被字面召回,与既有行为
1092
+ 一致;③ 必须剔掉,否则「的企业」会参与 AND 匹配,必然 0 条。
1093
+ """
1094
+ raw = (query or "").strip()
1095
+ low = raw.lower()
1096
+ out: Dict[str, Any] = {"raw": raw, "city": city or "", "city_from_query": "",
1097
+ "tokens": [], "cap_hits": [], "alias_hits": [],
1098
+ "segments": []}
1099
+ if not low:
1100
+ return out
1101
+
1102
+ # 停用词不参与任何「识别」——否则 '企业' 若恰好也是能力词/别名,会被当成
1103
+ # 一个正式检索词塞进 tokens,反而把查询扩大成「整城扫」。
1104
+ cap_hits = [h for h in cap_alias_codes(low)
1105
+ if str(h.get("word") or "").lower() not in _STOPWORDS_SET]
1106
+ alias_hits = [h for h in alias_codes(raw)
1107
+ if str(h.get("word") or "").lower() not in _STOPWORDS_SET]
1108
+ segs = [(w, s) for (w, s) in _segment_terms(low)
1109
+ if w.lower() not in _STOPWORDS_SET]
1110
+
1111
+ # ① 城市就地抽取(只在调用方没显式给 city 时)。取**最长**命中:「苏州」与「昆山」
1112
+ # 同时出现时长者信息量更大。省/自治区不在 city.json(那里只放市/县级),故
1113
+ # 「贵州的酒厂」这类由 agent 走 list_industries 语义挑码。
1114
+ city_found = ""
1115
+ if not out["city"]:
1116
+ try:
1117
+ names = [n for n in _city_names() if n and n.lower() in low]
1118
+ except Exception:
1119
+ names = []
1120
+ if names:
1121
+ city_found = max(names, key=len)
1122
+ out["city"] = city_found
1123
+ out["city_from_query"] = city_found
1124
+
1125
+ # ② 把「已识别的」从原话里剔掉,剩下的才是通用关键词
1126
+ resid = low
1127
+ if city_found:
1128
+ resid = resid.replace(city_found.lower(), " ")
1129
+ for h in cap_hits + alias_hits:
1130
+ w = str(h.get("word") or "").lower()
1131
+ if w:
1132
+ resid = resid.replace(w, " ")
1133
+ for w, _src in segs:
1134
+ resid = resid.replace(w.lower(), " ")
1135
+ for w in sorted(_STOPWORDS, key=len, reverse=True):
1136
+ resid = resid.replace(w, " ")
1137
+ toks: List[str] = []
1138
+ # 纯结构字(厂/店/家/司…)不成词、没有产品信息,不能当关键词:它们会进入
1139
+ # **AND 过滤**,把厂名里恰好不写「厂」的「XX有限公司」整批误滤掉。
1140
+ # 2026-10-06 实测:「昆山 做CNC的厂」里 cnc 被消费后只剩一个「厂」,
1141
+ # 结果文本召回退化成「整城扫所有含厂字的记录」。
1142
+ _noinfo = _FUNC_CHARS | _ORG_SUFFIX
1143
+ for t in _RE_RUNSPLIT.sub(" ", resid).split():
1144
+ t = _trim_edges(t)
1145
+ if t and not all(ch in _noinfo for ch in t):
1146
+ toks.append(t)
1147
+
1148
+ out["tokens"] = list(dict.fromkeys(toks + [w.lower() for w, _ in segs]))
1149
+ out["cap_hits"] = cap_hits
1150
+ out["alias_hits"] = alias_hits
1151
+ out["segments"] = [{"term": w, "source": s} for w, s in segs]
1152
+ return out
1153
+
1154
+
1019
1155
  def list_industries(keyword: str = "", parent: str = "", limit: int = 30,
1020
1156
  offset: int = 0) -> Dict[str, Any]:
1021
1157
  """国标货架:给 LLM 一张「有哪些行业、各有多少家」的菜单。
@@ -1060,6 +1196,10 @@ def suggest_filters(query: str, city: str = "", limit: int = 8) -> Dict[str, Any
1060
1196
  q = (query or "").strip()
1061
1197
  if not q:
1062
1198
  return {"error": "缺少必填参数 query"}
1199
+ # 原话归一化:抽城市(调用方没给才抽)+ 剔停用词。用户给的是一句话时,
1200
+ # 「苏州做AI的企业」里的「苏州」应当变成 city 条件,而不是当成关键词去 AND。
1201
+ norm = _normalize_query(q, city)
1202
+ city = norm["city"] or city
1063
1203
  limit = max(1, min(int(limit), 30))
1064
1204
  by_code = {r["code"]: r for r in _gb_shelf()}
1065
1205
  cands: Dict[str, Dict[str, Any]] = {}
@@ -1112,18 +1252,54 @@ def suggest_filters(query: str, city: str = "", limit: int = 8) -> Dict[str, Any
1112
1252
  x.pop("_score", None)
1113
1253
  out = out[:limit]
1114
1254
 
1255
+ # 能力域候选(跨门类的工艺/技术标签,如 tech_ai=人工智能与算法)。
1256
+ # **绝不能混进 candidates**:那串是国标码,agent 会照着拿去调 search_vendors(gb=…);
1257
+ # 把「tech_ai」塞进去就会被当成一个国标码用,必然 0 条。所以单独字段 + 单独说明。
1258
+ # 2026-10-06 补:在此之前 suggest_filters 完全不看能力维,于是「人工智能」这种词
1259
+ # 国标侧没类、能力侧不问 —— 两边都空,agent 只能拿到 candidates:[] 瞎猜。
1260
+ cap_cands: List[Dict[str, Any]] = []
1261
+ try:
1262
+ for c in (norm.get("cap_hits") or cap_alias_codes(q)):
1263
+ cap_cands.append({"cap": c["cap"], "name": c["name"], "word": c["word"],
1264
+ "records": _cap_count(c["cap"])})
1265
+ except Exception:
1266
+ cap_cands = []
1267
+
1115
1268
  res: Dict[str, Any] = {
1116
1269
  "query": q,
1117
1270
  "city": city or "",
1271
+ # 把「MCP 是怎么理解这句话的」摊开给 agent 看:抽走了哪个城市、剔了哪些停用词、
1272
+ # 认出了哪些词条。不透明的话,agent 无法判断候选为空到底是「没这行业」还是
1273
+ # 「这句话没被读懂」,只能瞎猜着换词重试。
1274
+ "city_from_query": norm.get("city_from_query") or "",
1275
+ "keywords": norm.get("tokens") or [],
1276
+ "recognized": norm.get("segments") or [],
1118
1277
  "candidates": out,
1119
1278
  "how_to_use": "挑 1~3 个 code,逐个调 search_vendors(gb=<code>, city=<城市>)"
1120
1279
  "(给了 gb 只扫对应分片,最快);多个码的结果自行合并去重。"
1121
1280
  "候选为空或都不对味时,调 list_industries 看货架自己挑。",
1122
1281
  }
1282
+ if cap_cands:
1283
+ res["cap_candidates"] = cap_cands
1284
+ res["cap_note"] = ("这些是**能力域**(跨门类的工艺/技术标签),不是国标码,"
1285
+ "别拿去当 gb= 用。search_vendors(query=…) 已自动按它们展开并计数;"
1286
+ "这里的用处是告诉你「该能力在名录里有多厚」——records 很小就是很薄。")
1123
1287
  if out:
1124
1288
  res["next_call"] = {"tool": "search_vendors",
1125
1289
  "arguments": {"gb": out[0]["code"], "city": city or "",
1126
1290
  "limit": 20}}
1291
+ elif cap_cands:
1292
+ # 国标侧没候选、但能力域命中了 —— 别让 agent 空着手去翻货架
1293
+ res["next_call"] = {"tool": "search_vendors",
1294
+ "arguments": {"query": q, "city": city or "", "limit": 20}}
1295
+ res["note"] = ("国标小类里没有这个词(GB/T 4754 本来就不保证有「%s」这一类),"
1296
+ "但它命中能力域 %s。直接调 search_vendors(query=\"%s\") 即可,"
1297
+ "能力域会被自动展开;结果偏少属正常 —— 那是数据厚度问题,"
1298
+ "不是查询写法问题。%s"
1299
+ % (q, "、".join(c["name"] for c in cap_cands), q,
1300
+ ("另外能力域企业在空间上很稀疏(全库常只有几十家),"
1301
+ "按 city=%s 过滤后若为 0 条,去掉 city 再试。" % city)
1302
+ if city else ""))
1127
1303
  else:
1128
1304
  res["next_call"] = {"tool": "list_industries",
1129
1305
  "arguments": {"keyword": q, "limit": 30}}
@@ -1145,9 +1321,37 @@ def _attach_routing_hint(out: Dict[str, Any], q: str, city: str) -> Dict[str, An
1145
1321
  try:
1146
1322
  s = suggest_filters(q, city, limit=5)
1147
1323
  except Exception:
1148
- return out
1324
+ s = {}
1149
1325
  cands = s.get("candidates") or []
1326
+ try:
1327
+ caps = cap_alias_codes(q)
1328
+ except Exception:
1329
+ caps = []
1330
+
1331
+ def _emit_cap() -> None:
1332
+ out["cap_expanded"] = [{"cap": c["cap"], "name": c["name"], "word": c["word"],
1333
+ "records": _cap_count(c["cap"])} for c in caps]
1334
+ txt = ("这个词命中能力域 %s(跨门类的工艺/技术标签,不是国标小类),"
1335
+ "search_vendors(query=…) 已自动展开并计入结果。命中偏少说明该能力"
1336
+ "在名录里确实很薄,**不要理解成「没这家厂」**。放宽可调 "
1337
+ "list_industries(keyword=…) 换近义类目再传 gb=。"
1338
+ % "、".join("%s %d 家" % (c["name"], _cap_count(c["cap"]))
1339
+ for c in caps))
1340
+ # 0 条且带了城市时,最可能的元凶是城市而不是关键词 —— 能力域企业在空间上
1341
+ # 很稀疏(全库可能就十几家),叠一个城市筛掉全部是常态。这一句能省掉 agent
1342
+ # 一整轮「换个城市试」的瞎猜。
1343
+ if city and not out.get("total_matched", 0):
1344
+ txt += ("本次已按 city=%s 过滤;去掉 city 再试一次往往就有。" % city)
1345
+ out["hint"] = txt
1346
+
1150
1347
  if out.get("total_matched", 0) == 0 and not cands:
1348
+ # 0 条 + 国标侧无候选 —— 但**能力域可能命中**,这是唯一还可执行的线索。
1349
+ # 旧实现这里直接 return 一句「关键词对不上」:2026-10-06 实测查
1350
+ # 「苏州做AI的企业」时 caps=tech_ai 明明存在,hint 却在更早的分支出局,
1351
+ # agent 于是完全看不到「换个说法/加城市就能召回」这条信息。
1352
+ if caps:
1353
+ _emit_cap()
1354
+ return out
1151
1355
  out["hint"] = ("关键词与名录里的写法对不上,且没有可推导的国标码。"
1152
1356
  "建议调 list_industries 用语义挑码后改传 gb=。")
1153
1357
  return out
@@ -1157,6 +1361,18 @@ def _attach_routing_hint(out: Dict[str, Any], q: str, city: str) -> Dict[str, An
1157
1361
  out["suggested_gb"] = [{"code": c["code"], "name": c["name"],
1158
1362
  "records": c["records"], "why": c["why"]}
1159
1363
  for c in cands]
1364
+ return out
1365
+ # 命中 1~4 条、别名与类名一条候选都推不出来 —— 最糟的一档,也是最容易被漏掉的一档:
1366
+ # 既有几条噪音(agent 会以为「就这些」直接交付),又没有任何可执行的下一步。
1367
+ # 2026-10-06 实测:查「苏州做AI的企业」只回 2 条(其中一条是名字里带「人工智能」的
1368
+ # 餐厅),hint 全程不出现 —— 因为旧逻辑只有「0 条且无候选」和「有候选」两个分支,
1369
+ # 1~4 条无候选直接掉进空档 return。必须补上。
1370
+ if caps:
1371
+ _emit_cap()
1372
+ return out
1373
+ out["hint"] = ("命中偏少且推不出国标码:这几条多半只是**字面撞词**,未必真是你要的行业。"
1374
+ "建议调 list_industries(keyword=\"…\") 用语义挑 1~3 个国标小类,"
1375
+ "再改传 gb= 重试;这样召回和精度都会好很多。")
1160
1376
  return out
1161
1377
 
1162
1378
 
@@ -1197,17 +1413,72 @@ def _cap_term_index() -> Dict[str, str]:
1197
1413
  return idx
1198
1414
 
1199
1415
 
1416
+ _CAP_SUBSTR_WORDS: Optional[List[Tuple[str, str, Any]]] = None
1417
+
1418
+
1419
+ def _cap_substr_words() -> List[Tuple[str, str, Any]]:
1420
+ """能力词表里**可做子串扫描**的词 [(词, 键, 预编译边界正则|None)],长词在前。
1421
+
1422
+ 过滤规则都是为了不误命中:
1423
+ · 长度 < 2 一律不要 —— 表里有 'c'、'/' 这种单字符键,子串扫会把任何含 c 的
1424
+ 查询都映射成 tst_cpp(一个字符毁掉一条链路);
1425
+ · 只保留「纯字母/数字/汉字」构成的词,带符号的(`.net` / `c++` / `250m汽车配件`
1426
+ 里的斜杠)留给整词精确那一档;
1427
+ · ASCII 词预编译 `(?<![a-z0-9])词(?![a-z0-9])` 词边界 —— 否则 'ai' 会命中
1428
+ 'chair'、'cnc' 会命中 'cncxx'。汉字不分词、天然成词,无需边界。
1429
+ """
1430
+ global _CAP_SUBSTR_WORDS
1431
+ if _CAP_SUBSTR_WORDS is None:
1432
+ rows: List[Tuple[str, str, Any]] = []
1433
+ for w, code in _cap_term_index().items():
1434
+ if len(w) < 2:
1435
+ continue
1436
+ if not re.fullmatch(r"[a-z0-9\u4e00-\u9fff]+", w):
1437
+ continue
1438
+ rx = (re.compile(r"(?<![a-z0-9])" + re.escape(w) + r"(?![a-z0-9])")
1439
+ if w.isascii() else None)
1440
+ rows.append((w, code, rx))
1441
+ rows.sort(key=lambda x: -len(x[0]))
1442
+ _CAP_SUBSTR_WORDS = rows
1443
+ return _CAP_SUBSTR_WORDS
1444
+
1445
+
1200
1446
  def cap_alias_codes(q: str) -> List[Dict[str, Any]]:
1201
- """能力口语词 → 能力键。整词精确匹配(不分词组子串)。返回 [{cap, word, name}]。"""
1447
+ """能力口语词 → 能力键。返回 [{cap, word, name, match}]。
1448
+
1449
+ 两档匹配,整词精确优先:
1450
+ ① exact 查询按空白切开后某个 token 完全等于能力词(原行为)
1451
+ ② substr 能力词出现在查询里的**任意位置**(ASCII 词另要求词边界)
1452
+
1453
+ 2026-10-06 补 ②。在此之前只做 ①,而真实用户说的是**一整句人话**:
1454
+ 「苏州做AI的企业」按空白切只有一个 token,'AI' 永远匹配不上 —— 能力域召回
1455
+ 整条链路对自然人话失效,只能回 0 条并把 agent 赶去翻货架。
1456
+ 国标别名侧(`alias_codes`)一直是子串匹配的,两侧行为不一致本身就是 bug。
1457
+ """
1202
1458
  q = (q or "").strip().lower()
1203
1459
  if not q:
1204
1460
  return []
1205
1461
  idx = _cap_term_index()
1206
1462
  hits: Dict[str, Dict[str, Any]] = {}
1463
+ # ① 整词
1207
1464
  for tok in dict.fromkeys(t for t in q.split() if t):
1208
1465
  code = idx.get(tok)
1209
1466
  if code:
1210
- hits[code] = {"cap": code, "word": tok, "name": _cap_name(code)}
1467
+ hits[code] = {"cap": code, "word": tok, "name": _cap_name(code),
1468
+ "match": "exact"}
1469
+ # ② 子串(长词优先,一份查询最多认 6 个能力域,免得长句把货架扫爆)
1470
+ for w, code, rx in _cap_substr_words():
1471
+ if code in hits:
1472
+ continue
1473
+ if rx is not None:
1474
+ if not rx.search(q):
1475
+ continue
1476
+ elif w not in q:
1477
+ continue
1478
+ hits[code] = {"cap": code, "word": w, "name": _cap_name(code),
1479
+ "match": "substr"}
1480
+ if len(hits) >= 6:
1481
+ break
1211
1482
  return list(hits.values())
1212
1483
 
1213
1484
 
@@ -1218,6 +1489,12 @@ def _cap_name(code: str) -> str:
1218
1489
  return str(t).split()[0] if t else code
1219
1490
 
1220
1491
 
1492
+ def _cap_count(code: str) -> int:
1493
+ """能力键 → 全库企业数(cap.json 的 shards[code] 是一堆「分片名 → 条数」,求和)。"""
1494
+ cap = _load_cap_index() or {}
1495
+ return sum((((cap.get("shards") or {}).get(code) or {})).values())
1496
+
1497
+
1221
1498
  def _cap_shard_paths(cap_code: str) -> List[str]:
1222
1499
  """能力键 → 指纹分片路径(一个逻辑桶可能拆成多个物理分片,必须全收)。
1223
1500
 
@@ -1696,14 +1973,7 @@ def _cap_term_of(code: str) -> str:
1696
1973
  return str(_CAP_TERMS_SLOT[0].get(code) or code or "")
1697
1974
 
1698
1975
 
1699
- def _hay(rec: Dict[str, Any]) -> str:
1700
- """检索面:query 的每个词都必须出现在这里(AND 全命中)。
1701
-
1702
- 2026-09-23 加入 `cap`(跨门类能力键)—— 在此之前的六个字段
1703
- (co/city/dist/gb/proc/mat/cert/products)全都表达不了「能力」:
1704
- 赤兔智能的键是 `tech_ai`,而它的厂名/工艺/材料里一个「人工智能」都没有,
1705
- 客户问「苏州做人工智能的企业」就永远 0 条。键必须摊成中文词面才能被中文命中。
1706
- """
1976
+ def _hay_parts(rec: Dict[str, Any], with_cap: bool) -> str:
1707
1977
  parts = [
1708
1978
  # `or ""` 不是多余:gb 为 None(未归类)时 str() 会产出字面量 "None",
1709
1979
  # 2271 条 gb=null 的记录于是每条都带一个 "None" 词面 —— 查 "none" 能命中
@@ -1713,11 +1983,35 @@ def _hay(rec: Dict[str, Any]) -> str:
1713
1983
  " ".join(rec.get("mat", []) or []),
1714
1984
  " ".join(rec.get("cert", []) or []),
1715
1985
  " ".join(rec.get("products", []) or []),
1716
- " ".join(_cap_term_of(c) for c in (rec.get("cap") or [])),
1717
1986
  ]
1987
+ if with_cap:
1988
+ parts.append(" ".join(_cap_term_of(c) for c in (rec.get("cap") or [])))
1718
1989
  return " ".join(p for p in parts if p)
1719
1990
 
1720
1991
 
1992
+ def _hay(rec: Dict[str, Any]) -> str:
1993
+ """检索面:query 的每个词都必须出现在这里(AND 全命中)。
1994
+
1995
+ 2026-09-23 加入 `cap`(跨门类能力键)—— 在此之前的六个字段
1996
+ (co/city/dist/gb/proc/mat/cert/products)全都表达不了「能力」:
1997
+ 赤兔智能的键是 `tech_ai`,而它的厂名/工艺/材料里一个「人工智能」都没有,
1998
+ 客户问「苏州做人工智能的企业」就永远 0 条。键必须摊成中文词面才能被中文命中。
1999
+ """
2000
+ return _hay_parts(rec, True)
2001
+
2002
+
2003
+ def _hay_lit(rec: Dict[str, Any]) -> str:
2004
+ """**字面**检索面 = `_hay` 去掉 cap 词面。
2005
+
2006
+ 存在的理由:`_hay` 把厂名/工艺与 cap 词面混在一起,于是「query 词出现在
2007
+ `_hay` 里」这一条**分不清**是「厂名/工艺真写了这个词」还是「只是能力键的中文
2008
+ 词面撞上了」。2026-10-06 实测:问「苏州做人工智能的企业」,赤兔智能(键
2009
+ tech_ai)被判成 `via=text`,而它厂名/工艺里一个「人工智能」都没有 —— agent
2010
+ 读到 text 会误以为「这家厂自己写了 AI」。所以 text 的判定改走这里。
2011
+ """
2012
+ return _hay_parts(rec, False)
2013
+
2014
+
1721
2015
  def _rec_summary(rec: Dict[str, Any]) -> Dict[str, Any]:
1722
2016
  return {
1723
2017
  "id": rec.get("id"),
@@ -2222,29 +2516,46 @@ def search_vendors(query: str = "", city: str = "", gb: str = "",
2222
2516
  limit = max(1, min(int(limit), 200))
2223
2517
  offset = max(0, int(offset))
2224
2518
  q = (query or "").strip().lower()
2225
- # 多词按空格分词,要求全部命中(AND)——这样「上海 酒店」也能正确匹配,
2226
- # 而非必须作为连续子串出现。单关键词时退化为原行为。
2227
- tokens = [t for t in q.split() if t]
2228
-
2229
- # 能力(cap)别名:把口语词(AI / 人工智能 / 机器学习 / 大模型 / 算法 …)映射到
2230
- # 能力键(如 tech_ai),并**消费**掉该 token(不再做通用子串 AND 校验),只走 cap
2231
- # 定向召回 —— 否则「AI」会作为子串命中 algebraist / Ashore 等英文名咖啡店造成噪声。
2232
- cap_hits = cap_alias_codes(q) if q else []
2233
- # 只消费「会造成子串噪声的短 ASCII token」(如 ai 会子串命中 algebraist 等英文名),
2234
- # 中文/较长 token 不消费 —— 保留其文本匹配(厂名含「人工智能」的企业不被误丢),
2235
- # 同时下方 cap 召回仍会叠加,最终是「文本 ∪ 能力」的并集。
2519
+ # 原话归一化(2026-10-06):用户输入的是**一句人话**,不是空格分隔的关键词表。
2520
+ # 旧实现直接 `q.split()`,中文整句会变成一个 token,而关键词是 AND 语义 —— 没有
2521
+ # 任何记录会包含「苏州做ai的企业」这个串,必然 0 条。归一化顺带把原话里的城市
2522
+ # 抽成 city 过滤条件。详见 `_normalize_query`。
2523
+ norm = _normalize_query(query, city) if q else None
2524
+ if norm:
2525
+ city = norm["city"] or city
2526
+ tokens = norm["tokens"]
2527
+ cap_hits = norm["cap_hits"]
2528
+ alias_hits = norm["alias_hits"]
2529
+ else:
2530
+ tokens, cap_hits, alias_hits = [], [], []
2531
+ # 能力(cap)别名:映射到能力键(如 tech_ai)后**消费**掉该 token(不再做通用
2532
+ # 子串 AND 校验),只走 cap 定向召回 —— 否则「AI」会子串命中 algebraist /
2533
+ # Ashore 等英文名咖啡店造成噪声。
2534
+ # 只消费「会造成子串噪声的短 ASCII token」(ai / cnc …),中文/较长 token 不消费
2535
+ # —— 保留其文本匹配(厂名含「人工智能」的企业不被误丢),最终是「文本 ∪ 能力」并集。
2236
2536
  cap_tokens = {h["word"].lower() for h in cap_hits
2237
2537
  if len(h["word"]) <= 2 and h["word"].isascii()}
2238
2538
  gen_tokens = [t for t in tokens if t not in cap_tokens]
2239
-
2240
2539
  # 口语词 → 国标码(2026-09-24 接入别名表)。
2241
2540
  # 「输送线/流水线/PCB」这类词在任何记录的厂名/工艺/材料里都不出现,纯子串匹配
2242
2541
  # 必然 0 条。别名命中的记录按码定向召回,**豁免 AND token 校验** —— 不豁免的话
2243
2542
  # 刚拉进来就又被 _hay 判定「不含输送线」而滤掉,等于白接。
2244
- alias_hits = alias_codes(q) if q else []
2245
2543
  alias_by_code = {a["code"]: a for a in alias_hits}
2246
2544
  alias_paths = _fp_paths_for_gbs(list(alias_by_code)) if alias_by_code else []
2247
2545
 
2546
+ # 只有「原话确实被拆过」时才回显解析结果:一句话查询(抽了城市 / 剔了停用词 /
2547
+ # 识别出词条)必须让 agent 看见 MCP 是怎么理解的,否则它无法判断结果为何是这样。
2548
+ # 普通关键词查询不额外增加返回体字节。
2549
+ parsed: Dict[str, Any] = {}
2550
+ if norm and (norm["city_from_query"] or norm["segments"]
2551
+ or tokens != [t for t in q.split() if t]):
2552
+ parsed["query_parsed"] = {
2553
+ "city": city or "",
2554
+ "city_from_query": norm["city_from_query"] or "",
2555
+ "keywords": gen_tokens,
2556
+ "recognized": norm["segments"],
2557
+ }
2558
+
2248
2559
  # 给了国标码:只扫对应分片(最快路径,不构建全量索引)
2249
2560
  if gb:
2250
2561
  fp_shards = [s for s in _shards_of_type("fp") if s.get("c") == gb]
@@ -2257,12 +2568,15 @@ def search_vendors(query: str = "", city: str = "", gb: str = "",
2257
2568
  continue
2258
2569
  if gen_tokens and not all(tok in _hay(rec).lower() for tok in gen_tokens):
2259
2570
  continue
2260
- matches.append(_rec_summary(rec))
2571
+ s = _rec_summary(rec)
2572
+ s["via"] = "gb"
2573
+ matches.append(s)
2261
2574
  return _attach_routing_hint({
2262
2575
  "total_matched": len(matches),
2263
2576
  "returned": len(matches[offset:offset + limit]),
2264
2577
  "shards_scanned": scanned,
2265
2578
  "results": matches[offset:offset + limit],
2579
+ **parsed,
2266
2580
  }, q, city)
2267
2581
 
2268
2582
  # 未给国标码:优先走「预构建倒排索引 → 只拉命中分片」(O(命中量))。
@@ -2278,6 +2592,7 @@ def search_vendors(query: str = "", city: str = "", gb: str = "",
2278
2592
  "shards_scanned": 0,
2279
2593
  "via_index": True,
2280
2594
  "results": [],
2595
+ **parsed,
2281
2596
  }, q, city)
2282
2597
 
2283
2598
  matches: List[Dict[str, Any]] = []
@@ -2287,13 +2602,23 @@ def search_vendors(query: str = "", city: str = "", gb: str = "",
2287
2602
  for rec in recs:
2288
2603
  if not _city_ok(rec, city):
2289
2604
  continue
2290
- if not relax_tokens and gen_tokens and not all(tok in _hay(rec).lower() for tok in gen_tokens):
2605
+ # 只在有关键词时才算检索面(纯城市查询不需要,省一次 join/条)。
2606
+ hay = _hay(rec).lower() if gen_tokens else ""
2607
+ if not relax_tokens and gen_tokens and not all(tok in hay for tok in gen_tokens):
2291
2608
  continue
2292
2609
  rid = rec.get("id")
2293
2610
  if rid in seen: # 别名召回与文本召回的并集要去重
2294
2611
  continue
2295
2612
  seen.add(rid)
2296
2613
  s = _rec_summary(rec)
2614
+ # 只有落在**字面**检索面(厂名/城市/工艺/材料/认证/产品)上才算 text;
2615
+ # 全靠 cap 词面撞上的如实标 cap_text —— 见 `_hay_lit` 的说明。
2616
+ # 没挂 cap 键的记录两张面完全等价(绝大多数),直接复用 hay 不额外算。
2617
+ if gen_tokens:
2618
+ lit = hay if not rec.get("cap") else _hay_lit(rec).lower()
2619
+ s["via"] = "text" if all(tok in lit for tok in gen_tokens) else "cap_text"
2620
+ else:
2621
+ s["via"] = "city" # 没给关键词,纯城市筛选
2297
2622
  if relax_tokens:
2298
2623
  a = alias_by_code.get(rec.get("gb") or "")
2299
2624
  if a:
@@ -2328,6 +2653,7 @@ def search_vendors(query: str = "", city: str = "", gb: str = "",
2328
2653
  continue
2329
2654
  seen.add(rid)
2330
2655
  s = _rec_summary(rec)
2656
+ s["via"] = "alias" # 别名定向召回(企业自己没写过这个词)
2331
2657
  a = alias_by_code.get(rec.get("gb") or "")
2332
2658
  if a:
2333
2659
  s["alias_match"] = {"word": a["word"], "gb": a["code"], "name": a["name"]}
@@ -2354,16 +2680,32 @@ def search_vendors(query: str = "", city: str = "", gb: str = "",
2354
2680
  continue
2355
2681
  seen.add(rid)
2356
2682
  s = _rec_summary(rec)
2683
+ s["via"] = "cap" # 能力定向召回(跨门类能力键,如 tech_ai)
2357
2684
  s["alias_match"] = {"word": h["word"], "cap": h["cap"],
2358
2685
  "name": h["name"], "type": "cap"}
2359
2686
  matches.append(s)
2360
2687
 
2688
+ # 分档排序(稳定):text → cap_text → alias → cap。
2689
+ # 为什么必须显式排:cap_text 与 text 是**同一趟**文本召回里 append 进去的,
2690
+ # 不重排两档会交错,order_note 里「按召回来源分档排列」就成了假承诺。
2691
+ _RANK = {"text": 0, "city": 0, "cap_text": 1, "alias": 2, "cap": 3}
2692
+ matches.sort(key=lambda x: _RANK.get(x.get("via"), 9))
2693
+
2361
2694
  out = {
2362
2695
  "total_matched": len(matches),
2363
2696
  "returned": len(matches[offset:offset + limit]),
2364
2697
  "shards_scanned": scanned,
2365
2698
  "via_index": via_index,
2366
2699
  "results": matches[offset:offset + limit],
2700
+ **parsed,
2701
+ # 结果顺序是**有意设计的分档**,不是随机的:按召回纯度递减排列。
2702
+ # 而 `score` 是**能力画像分**(有已发布能力卡才有分,无卡为 0),**不是相关度**。
2703
+ # 两者一起看极易误判(实测:一家餐厅 sc=0 排在持卡 L2 企业 sc=40 前面),
2704
+ # 所以在这里把口径写死,让 agent 自己能分辨「谁更值得先看」。
2705
+ "order_note": ("results 按召回来源分档排列:via=text(厂名/工艺/材料等**字面**命中) → "
2706
+ "via=cap_text(厂名里其实没这个词,只是撞上了能力键的中文词面) → "
2707
+ "via=alias(别名定向) → via=cap(能力定向);score 是**能力画像分**"
2708
+ "(有已发布能力卡才有分,无卡为 0),**不是相关度**,不要拿它反推排序。"),
2367
2709
  }
2368
2710
  if alias_hits:
2369
2711
  out["alias_expanded"] = [{"word": a["word"], "gb": a["code"], "name": a["name"]}
@@ -2462,7 +2804,16 @@ def get_vendor(vid: str, gb: str = "") -> Dict[str, Any]:
2462
2804
  return {"error": f"分片 {zh_path} 解析失败: {e}"}
2463
2805
  for rec in arr:
2464
2806
  if rec.get("id") == vid:
2465
- return {"vendor": normalize_vendor(rec)}
2807
+ v = normalize_vendor(rec)
2808
+ # zh 记录自身常常**不写 gb**(只留 industry.code),但它就归档在
2809
+ # 这个 gb 分片里。不按分片归属回填的话会出现自相矛盾:
2810
+ # search_vendors(gb=6531) 能搜到它、get_vendor 却说
2811
+ # gb=null + gb_unclassified=true(2026-10-06 实测 CN-I-0000001)。
2812
+ if not v.get("gb"):
2813
+ v["gb"] = gb
2814
+ v["gb_source"] = "shard" # 记录没写,取自分片归属
2815
+ v.pop("gb_unclassified", None)
2816
+ return {"vendor": v}
2466
2817
  return {"error": f"国标码 {gb} 的全部 zh 分片(共{len(zh_paths)}个)中均未找到 id={vid}"}
2467
2818
 
2468
2819
  # 兜底:gb 为 null(未归类)—— 这些记录躺在 c=='' 的 zh 分片里