beacon-mfg-mcp 1.5.4 → 1.5.6

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 (4) hide show
  1. package/README.md +43 -9
  2. package/RELEASE.md +128 -3
  3. package/package.json +1 -1
  4. package/server.py +303 -35
package/README.md CHANGED
@@ -13,10 +13,23 @@
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` 主动提示这条路。
27
+ > - **结果顺序(v1.5.6 起:档内也有序)**:`results` 先按**召回来源**分档
28
+ > (`via=text` 字面命中 → `via=cap_text` 只撞能力词面 → `via=alias` 别名定向 → `via=cap` 能力定向),
29
+ > **同一档内**再按「关键词在**厂名**里**成片**出现」优先。所以问「商务酒店」时,
30
+ > `龙翔商务酒店` 会排在 `某酒店(中央商务区店)` 前面(后者只是括号里的商圈名撞上了「商务」)。
31
+ > **`score` 是能力画像分**(有已发布能力卡才有分,无卡为 0),**不是相关度**,别拿它反推排序;
32
+ > 返回体的 `order_note` 里写死了这个口径。
20
33
  > - **检索架构(v1.2.0 起:按需拉取,不再全量冷启动)**:
21
34
  > 早期实现每次查询都要把**全部 fp 分片**拉下来在客户端建索引,复杂度 O(数据总量) —— 11.8 万条约 5.8s,线性外推千万级约 **491s / 2.2GB 内存**,不可用。
22
35
  > v1.2.0 改为 **O(命中量)**:索引在**发布侧预构建**(`scripts/gen_search_index.py`,随 `postfetch` 的 `searchindex` 步骤产出 `skills/registry/index/`),
@@ -26,7 +39,7 @@
26
39
  > 千万量级下只需增大桶数(如 4096)并按城市对大分片二级切分,首次查询耗时仍与总量基本无关。
27
40
  | `get_vendor` | 按 id(+国标码)取完整中文档案 | `id`(必填) `gb`(可选,自动反查) |
28
41
  | `get_capability_card` | 按 id 取能力卡(工艺/设备/产能/认证等) | `id`(必填) |
29
- | `suggest_filters` | **用户原话 → 候选国标小类码**(拆词 + 别名 + 类名),供改用 `gb=` | `query`(必填) `city` `limit` |
42
+ | `suggest_filters` | **用户原话 → 候选国标小类码**(拆词 + 别名 + 类名 + **能力域**),供改用 `gb=` | `query`(必填) `city` `limit` |
30
43
  | `list_industries` | **国标小类货架**(只列有数据的类,附企业条数),供按语义自己挑码 | `keyword` `parent` `limit` `offset` |
31
44
  | `start_sourcing` | 【客户 agent 自动触发】识别找厂/代工/采购/询价意图 → 品类识别→宽召回→需求解析→生成 1~2 轮澄清问题,返回 `session_id` | `demand_text`(必填) `audience_id` |
32
45
  | `answer_sourcing` | 续接澄清轮次:写回客户答案,返回下一轮澄清问题或初选供应商列表 | `session_id`(必填) `answers` |
@@ -37,7 +50,7 @@
37
50
 
38
51
  所有返回都是 JSON 文本。找不到时返回 `{ "error": ... }` 或 `has_card:false`,不会抛协议错。
39
52
 
40
- ### 用户原话 → 国标码:给 agent 的调用协议(v1.5.3)
53
+ ### 用户原话 → 国标码:给 agent 的调用协议(v1.5.6)
41
54
 
42
55
  **为什么需要**:`query` 是**字面子串**匹配,而名录按国标小类归档 —— 客户说「机加工」,
43
56
  库里写的是「机械零部件加工」,字面不通。实测(`city=嘉兴`):
@@ -75,9 +88,11 @@
75
88
  **方式 0 — npm 安装(推荐:无需 clone、无需填路径)**
76
89
 
77
90
  ```bash
78
- npm i -g beacon-mfg-mcp # 或不安装直接用 npx beacon-mfg-mcp
91
+ npm i -g beacon-mfg-mcp
79
92
  ```
80
93
 
94
+ macOS / Linux:
95
+
81
96
  ```jsonc
82
97
  {
83
98
  "mcpServers": {
@@ -89,8 +104,27 @@ npm i -g beacon-mfg-mcp # 或不安装直接用 npx beacon-mfg-mcp
89
104
  }
90
105
  }
91
106
  ```
92
- > 启动器(`bin/beacon-mfg-mcp.js`)已默认注入上述两个变量;此处写出仅为显式/可覆盖。
93
- > 二者保证 python 子进程以 UTF-8 输出,否则中文 Windows 上工具描述/结果会乱码。
107
+
108
+ Windows(**必须走 `cmd /c`**):
109
+
110
+ ```jsonc
111
+ {
112
+ "mcpServers": {
113
+ "beacon-mfg": {
114
+ "command": "cmd",
115
+ "args": ["/c", "beacon-mfg-mcp"],
116
+ "env": { "PYTHONUTF8": "1", "PYTHONIOENCODING": "utf-8" }
117
+ }
118
+ }
119
+ }
120
+ ```
121
+ > **Windows 上 `"command": "beacon-mfg-mcp"` 会 ENOENT**:npm 全局装出来的是
122
+ > `beacon-mfg-mcp.cmd`(`.cmd` 包装器),无 shell 的 `spawn()` 解析不了它,客户端只会
123
+ > 表现成「连不上」。`npx -y beacon-mfg-mcp` 同理(`npx` 自身也是 `.cmd`)。
124
+ > 2026-10-06 实测:`beacon-mfg-mcp` ❌ / `npx` ❌ / `cmd /c beacon-mfg-mcp` ✅(8 工具)。
125
+ >
126
+ > `env` 里的两个变量**不必填**——启动器(`bin/beacon-mfg-mcp.js`)已默认注入,写出仅为显式/可覆盖;
127
+ > `server.py` 另有 UTF-8 兜底,所以**即使宿主把子进程直接拉起来、没带任何环境变量**也不会乱码或崩。
94
128
 
95
129
  不填 `BEACON_REPO` 时默认走 `https://beacon-mfg.pages.dev`,即开即用。
96
130
 
package/RELEASE.md CHANGED
@@ -1,4 +1,4 @@
1
- # Beacon-MFG 只读 MCP 服务 · v1.5.4
1
+ # Beacon-MFG 只读 MCP 服务 · v1.5.6
2
2
 
3
3
  让任意支持 MCP 的主流 agent(Claude Desktop / Cline / Continue / WorkBuddy 等)能够
4
4
  **检索与调用**已发布到 Cloudflare Pages(或 GitHub)的灯塔工厂供应商数据,
@@ -23,7 +23,132 @@ MCP 只做**只读检索**。后端数据采集、英文翻译、库维护等流
23
23
  后三个构成「采购寻源」多轮会话链路 `start_sourcing → answer_sourcing → refine_sourcing`,
24
24
  依赖仓库平级的 `skills/rfq-kernel` 桥接模块。
25
25
 
26
- ## 本次更新(v1.5.4)
26
+ ## 本次更新(v1.5.6)
27
+ **修「结果呈现」:同档内排序不再是随机的。无破坏性变更**(入参、三种接入方式、既有返回字段全不变)。
28
+
29
+ ### 1. 档内排序 = 分片记录顺序(等于随机)
30
+
31
+ 1.5.5 只做了**档位**排序(`text → cap_text → alias → cap`),档内顺序仍是分片里的记录
32
+ 顺序。实测「贵阳的商务酒店」把
33
+
34
+ ```
35
+ 1. 悦庭楠舍酒店(花果园中央商务区店) ← 只是括号里的**商圈名**撞上了「商务」
36
+ 2. 小苹果酒店(中央商务区4号店) ← 同上
37
+ 3. 龙翔商务酒店(贵阳龙洞堡机场店) ← 厂名里就写着「商务酒店」
38
+ ```
39
+
40
+ 排成了这样。两家其实都算 `text` 命中,但用户要的是后一种。
41
+
42
+ 修法:新增模块级 `_name_fit()`,按「关键词在**厂名**里**成片**出现」记 2、各自出现记 1、
43
+ 否则 0,作为**档内**次级键。修后 top-8 **全部**是真·商务酒店(龙翔 / 华宛 / 88城市优品 /
44
+ 温馨 / 远瞻 / 新时空 / 五新 / OYO豪维雅)。
45
+
46
+ > 只用「关键词是否出现在厂名里」不够 —— 两家都含「商务」和「酒店」,必须**成片**才分得开。
47
+
48
+ ### 2. `suggest_filters` 与 `search_vendors` 的解析回显字段名不一致
49
+
50
+ `search_vendors` 用 `query_parsed`,`suggest_filters` 却用扁平的
51
+ `city_from_query` / `keywords` / `recognized`。agent 从一个工具学到的字段名在另一个里
52
+ 找不到;而 `README.md` 与本指南早就统一写 `query_parsed` —— 属实现漂移。
53
+
54
+ 修后两个工具的 `query_parsed` 形状**逐字一致**。
55
+
56
+ ### 3. `gb=` 路由没走同一套排序
57
+
58
+ `gb=` 只扫一个分片、直接按分片顺序返回;`query` 路由已按档位排。同一批记录经两条路进来
59
+ 顺序不同,agent 会以为「换个写法结果就变了」。修后两条路由共用 `_name_fit()`,`gb=` 路由
60
+ 补 `order_note`。
61
+
62
+ ### 实测对照(原话直传,不手工拆词)
63
+
64
+ | 版本 | 命中 | 城市 | top-8 |
65
+ |---|---|---|---|
66
+ | 1.5.4 | **5418** | ❌ 丢失 | ❌ 全是**杭州**的度假酒店 |
67
+ | 1.5.5 | 401 | ✅ 贵阳 | ⚠ 混入 2 家「(中央商务区店)」假朋友 |
68
+ | **1.5.6** | 401 | ✅ 贵阳 | ✅ 全为真·商务酒店 |
69
+
70
+ 回归:协议 16/16、语义路由 14/14、检索哨兵「机加工@嘉兴」643 条、城市正确性 10/10、
71
+ 疑似无关 0 条、`gb=` 与 `query` 两路由同码记录 9/9 一致。
72
+
73
+ ## 本次更新(v1.5.5,已发布)
74
+ **让「用户原话」真的能用。无破坏性变更:入参、三种接入方式、返回体既有字段全部不变(只增不减)。**
75
+
76
+ ### 1. 中文整句直传必然 0 条 —— 关键词切分只有 `q.split()`
77
+
78
+ 症状:`search_vendors(query="苏州做AI的企业")` 返回 **0 条**,
79
+ 而手工拆成 `query="人工智能" + city="苏州"` 就有 —— 等于把「理解自然语言」的责任
80
+ 推给了调用方(agent)和用户。
81
+
82
+ 根因:`q` 只按**空白**切分。中文没有空格,整句话是一个 token:
83
+ `["苏州做ai的企业"]`。而关键词是 **AND** 语义(每个 token 都要出现在记录的检索面里),
84
+ 没有任何一条记录会包含「苏州做ai的企业」这个串 → 必然 0 条。
85
+ 同时城市没被抽出来、能力词也没被认出来。
86
+
87
+ 修法:新增 `_normalize_query()`(详见 `server.py` 的注释),在检索前把一句话拆成
88
+ `(city, keywords, cap_hits, alias_hits, segments)`:
89
+
90
+ | 步骤 | 做法 | 例 |
91
+ |---|---|---|
92
+ | 抽城市 | 命中 `city.json` 里的城市名(取**最长**),从关键词里摘掉 | 「找一下**苏州**做AI的企业」→ `city=苏州` |
93
+ | 认词条 | 国标别名 / 国标类名 / **能力词**,最长优先、不重叠 | `ai` → 能力域 `tech_ai` |
94
+ | 剔填充词 | 的 / 找 / 一下 / 企业 / 有没有 … | 剩下 `[]` |
95
+ | 留残词 | 识别不了的才当通用关键词(仍走 AND) | — |
96
+
97
+ - 被识别出的词条**保留进关键词**(不丢弃),所以「厂名里真写了这个词」仍能被字面召回,
98
+ 既有行为不变。
99
+ - 纯结构字(`厂/店/家/司`)不作关键词:它们会参与 AND 过滤,把「XX有限公司」整批误滤掉
100
+ (实测「昆山 做CNC的厂」在 cnc 被消费后只剩一个「厂」,退化成整城扫)。
101
+ - 返回体新增 **`query_parsed`**,只在这句话确实被拆过时出现,回显 `city` /
102
+ `city_from_query` / `keywords` / `recognized`。
103
+
104
+ 实测:`search_vendors(query="苏州做AI的企业")` → **命中 `赤兔智能工业(苏州)有限公司`**(0.97s)。
105
+
106
+ ### 2. 能力词只做「整词精确」匹配,句子里永远认不出
107
+
108
+ `cap_alias_codes()` 只把 `q.split()` 出来的 token 拿去和词表精确比对,
109
+ 而**国标别名侧(`alias_codes`)一直是子串匹配** —— 两侧行为不一致本身就是 bug。
110
+ 于是「AI」这种词只在用户恰好单独输入「AI」时有效。
111
+
112
+ 修法:能力词也做子串扫描(ASCII 词加 `(?<![a-z0-9])…(?![a-z0-9])` 词边界,
113
+ 避免 `ai` 命中 `chair`、`cnc` 命中 `cncxx`),整词精确仍然优先。
114
+ 同时把**纯汉字能力词**并入切词表(ASCII 的 `c`/`3c` 这类不进,太容易把查询切碎)。
115
+
116
+ ### 3. Windows 接入配置:`"command": "beacon-mfg-mcp"` 会 ENOENT
117
+
118
+ npm 全局装出来的是 **`.cmd` 包装器**,而没有 shell 的 `spawn('beacon-mfg-mcp')`
119
+ 在 Windows 上解析不了 `.cmd`,客户端只表现为「连不上 / 工具不出现」。
120
+ `npx -y beacon-mfg-mcp` 同理(`npx` 自身也是 `.cmd`)。
121
+
122
+ | 写法 | Windows `spawn`(无 shell) |
123
+ |---|---|
124
+ | `beacon-mfg-mcp` | ❌ ENOENT |
125
+ | `npx -y beacon-mfg-mcp` | ❌ ENOENT |
126
+ | `cmd /c beacon-mfg-mcp` | ✅ 正常(8 个工具) |
127
+
128
+ **Windows 请用 `{"command":"cmd","args":["/c","beacon-mfg-mcp"]}`**;
129
+ macOS / Linux 用 `{"command":"beacon-mfg-mcp","args":[]}`。
130
+ `README.md` 与 `BeaconMFG_MCP_安装指南.md` 已按平台分别给出配置。
131
+
132
+ ### 4. `suggest_filters` 在能力域命中时不再让 agent 空手翻货架
133
+
134
+ - 能力域命中但国标侧无候选 → `cap_candidates` + `next_call=search_vendors`(原有分支,
135
+ 现在能被更多词触发,例如「手机壳」→ 能力域 `家电数码`)。
136
+ - `_attach_routing_hint` 原先只有「0 条且无候选」和「有候选」两个分支,**0 条时会先把
137
+ 能力域这条唯一可执行的线索引掉**;现在 0 条也优先给能力域提示,并在带了城市时
138
+ 补一句「去掉 city 再试」(能力域企业在空间上很稀疏)。
139
+ - `suggest_filters` 返回体新增 `city_from_query` / `keywords` / `recognized`。
140
+
141
+ ### 验收(发布前全绿)
142
+
143
+ - 协议回归 **16/16**(HTTP / stdio 换行分帧 / adapter 标准分帧 / 死代理容错)
144
+ - 语义路由验收 **14/14**
145
+ - 检索哨兵「机加工@嘉兴」**643 条**(修复前 1 条)、城市正确性 **10/10**、关键词疑似无关 **0 条**
146
+ - 冷检索 5.11s / 热 0.97s;端到端「找到嘉兴机加工厂并拿到电话」**6.81s**
147
+ - 归一化自身开销 **1.4~20ms**(相对热查询 ~1000ms 占比 ≤2%)
148
+
149
+ ---
150
+
151
+ ## 本次更新(v1.5.4)· 已发布
27
152
  **把「宿主根本连不上」和「连上了但结果会误导 agent」这两类问题一次收干净。
28
153
  无破坏性变更:八个 tool 的入参、三种接入方式、返回体既有字段全部不变(只增不减)。**
29
154
 
@@ -281,7 +406,7 @@ v1.5.0 的 idmap 有个致命假设:片存在但片里没这条 id ⇒ 判「
281
406
  ## 安装(三选一)
282
407
 
283
408
  ### 1. 从 GitHub Release 下载(推荐,零依赖)
284
- 下载本 Release 的 `beacon-mfg-mcp-mcp-v1.5.4.tar.gz`,解压后:
409
+ 下载本 Release 的 `beacon-mfg-mcp-mcp-v1.5.6.tar.gz`,解压后:
285
410
  ```jsonc
286
411
  {
287
412
  "mcpServers": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "beacon-mfg-mcp",
3
- "version": "1.5.4",
3
+ "version": "1.5.6",
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
@@ -283,9 +283,9 @@ def _pkg_version() -> str:
283
283
  try:
284
284
  p = os.path.join(os.path.dirname(os.path.abspath(__file__)), "package.json")
285
285
  with open(p, "r", encoding="utf-8") as f:
286
- return json.load(f).get("version", "1.5.4")
286
+ return json.load(f).get("version", "1.5.6")
287
287
  except Exception:
288
- return "1.5.4"
288
+ return "1.5.6"
289
289
 
290
290
 
291
291
  # HTTP 基址列表(含兜底镜像)。默认部署 Cloudflare Pages 不可达时,自动回退到
@@ -991,7 +991,7 @@ def _gb_shelf() -> List[Dict[str, Any]]:
991
991
 
992
992
 
993
993
  def _seg_patterns() -> List[Tuple[str, str]]:
994
- """长词优先的匹配表 [(词, 来源)],来源 ∈ alias / gb_name。"""
994
+ """长词优先的匹配表 [(词, 来源)],来源 ∈ alias / gb_name / cap。"""
995
995
  global _SEG_PATTERNS
996
996
  if _SEG_PATTERNS is None:
997
997
  pats: Dict[str, str] = {}
@@ -1001,6 +1001,16 @@ def _seg_patterns() -> List[Tuple[str, str]]:
1001
1001
  for r in _gb_shelf():
1002
1002
  if len(r["name"]) >= 2:
1003
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")
1004
1014
  _SEG_PATTERNS = sorted(pats.items(), key=lambda x: -len(x[0]))
1005
1015
  return _SEG_PATTERNS
1006
1016
 
@@ -1028,6 +1038,120 @@ def _segment_terms(text: str) -> List[Tuple[str, str]]:
1028
1038
  return hits
1029
1039
 
1030
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
+
1031
1155
  def list_industries(keyword: str = "", parent: str = "", limit: int = 30,
1032
1156
  offset: int = 0) -> Dict[str, Any]:
1033
1157
  """国标货架:给 LLM 一张「有哪些行业、各有多少家」的菜单。
@@ -1072,6 +1196,10 @@ def suggest_filters(query: str, city: str = "", limit: int = 8) -> Dict[str, Any
1072
1196
  q = (query or "").strip()
1073
1197
  if not q:
1074
1198
  return {"error": "缺少必填参数 query"}
1199
+ # 原话归一化:抽城市(调用方没给才抽)+ 剔停用词。用户给的是一句话时,
1200
+ # 「苏州做AI的企业」里的「苏州」应当变成 city 条件,而不是当成关键词去 AND。
1201
+ norm = _normalize_query(q, city)
1202
+ city = norm["city"] or city
1075
1203
  limit = max(1, min(int(limit), 30))
1076
1204
  by_code = {r["code"]: r for r in _gb_shelf()}
1077
1205
  cands: Dict[str, Dict[str, Any]] = {}
@@ -1131,7 +1259,7 @@ def suggest_filters(query: str, city: str = "", limit: int = 8) -> Dict[str, Any
1131
1259
  # 国标侧没类、能力侧不问 —— 两边都空,agent 只能拿到 candidates:[] 瞎猜。
1132
1260
  cap_cands: List[Dict[str, Any]] = []
1133
1261
  try:
1134
- for c in cap_alias_codes(q):
1262
+ for c in (norm.get("cap_hits") or cap_alias_codes(q)):
1135
1263
  cap_cands.append({"cap": c["cap"], "name": c["name"], "word": c["word"],
1136
1264
  "records": _cap_count(c["cap"])})
1137
1265
  except Exception:
@@ -1145,6 +1273,18 @@ def suggest_filters(query: str, city: str = "", limit: int = 8) -> Dict[str, Any
1145
1273
  "(给了 gb 只扫对应分片,最快);多个码的结果自行合并去重。"
1146
1274
  "候选为空或都不对味时,调 list_industries 看货架自己挑。",
1147
1275
  }
1276
+ # 把「MCP 是怎么理解这句话的」摊开给 agent 看:抽走了哪个城市、剔了哪些停用词、
1277
+ # 认出了哪些词条。不透明的话,agent 无法判断候选为空到底是「没这行业」还是
1278
+ # 「这句话没被读懂」,只能瞎猜着换词重试。
1279
+ # 字段形状与 search_vendors 的 query_parsed **逐字一致** —— agent 从任一工具学到的
1280
+ # 字段名在另一个里都找得到,不必记两套形状(此前这里是扁平的 city_from_query /
1281
+ # keywords / recognized,与 search_vendors 的 query_parsed 不一致,属实现漂移)。
1282
+ res["query_parsed"] = {
1283
+ "city": city or "",
1284
+ "city_from_query": norm.get("city_from_query") or "",
1285
+ "keywords": norm.get("tokens") or [],
1286
+ "recognized": norm.get("segments") or [],
1287
+ }
1148
1288
  if cap_cands:
1149
1289
  res["cap_candidates"] = cap_cands
1150
1290
  res["cap_note"] = ("这些是**能力域**(跨门类的工艺/技术标签),不是国标码,"
@@ -1161,8 +1301,11 @@ def suggest_filters(query: str, city: str = "", limit: int = 8) -> Dict[str, Any
1161
1301
  res["note"] = ("国标小类里没有这个词(GB/T 4754 本来就不保证有「%s」这一类),"
1162
1302
  "但它命中能力域 %s。直接调 search_vendors(query=\"%s\") 即可,"
1163
1303
  "能力域会被自动展开;结果偏少属正常 —— 那是数据厚度问题,"
1164
- "不是查询写法问题。"
1165
- % (q, "、".join(c["name"] for c in cap_cands), q))
1304
+ "不是查询写法问题。%s"
1305
+ % (q, "、".join(c["name"] for c in cap_cands), q,
1306
+ ("另外能力域企业在空间上很稀疏(全库常只有几十家),"
1307
+ "按 city=%s 过滤后若为 0 条,去掉 city 再试。" % city)
1308
+ if city else ""))
1166
1309
  else:
1167
1310
  res["next_call"] = {"tool": "list_industries",
1168
1311
  "arguments": {"keyword": q, "limit": 30}}
@@ -1184,9 +1327,37 @@ def _attach_routing_hint(out: Dict[str, Any], q: str, city: str) -> Dict[str, An
1184
1327
  try:
1185
1328
  s = suggest_filters(q, city, limit=5)
1186
1329
  except Exception:
1187
- return out
1330
+ s = {}
1188
1331
  cands = s.get("candidates") or []
1332
+ try:
1333
+ caps = cap_alias_codes(q)
1334
+ except Exception:
1335
+ caps = []
1336
+
1337
+ def _emit_cap() -> None:
1338
+ out["cap_expanded"] = [{"cap": c["cap"], "name": c["name"], "word": c["word"],
1339
+ "records": _cap_count(c["cap"])} for c in caps]
1340
+ txt = ("这个词命中能力域 %s(跨门类的工艺/技术标签,不是国标小类),"
1341
+ "search_vendors(query=…) 已自动展开并计入结果。命中偏少说明该能力"
1342
+ "在名录里确实很薄,**不要理解成「没这家厂」**。放宽可调 "
1343
+ "list_industries(keyword=…) 换近义类目再传 gb=。"
1344
+ % "、".join("%s %d 家" % (c["name"], _cap_count(c["cap"]))
1345
+ for c in caps))
1346
+ # 0 条且带了城市时,最可能的元凶是城市而不是关键词 —— 能力域企业在空间上
1347
+ # 很稀疏(全库可能就十几家),叠一个城市筛掉全部是常态。这一句能省掉 agent
1348
+ # 一整轮「换个城市试」的瞎猜。
1349
+ if city and not out.get("total_matched", 0):
1350
+ txt += ("本次已按 city=%s 过滤;去掉 city 再试一次往往就有。" % city)
1351
+ out["hint"] = txt
1352
+
1189
1353
  if out.get("total_matched", 0) == 0 and not cands:
1354
+ # 0 条 + 国标侧无候选 —— 但**能力域可能命中**,这是唯一还可执行的线索。
1355
+ # 旧实现这里直接 return 一句「关键词对不上」:2026-10-06 实测查
1356
+ # 「苏州做AI的企业」时 caps=tech_ai 明明存在,hint 却在更早的分支出局,
1357
+ # agent 于是完全看不到「换个说法/加城市就能召回」这条信息。
1358
+ if caps:
1359
+ _emit_cap()
1360
+ return out
1190
1361
  out["hint"] = ("关键词与名录里的写法对不上,且没有可推导的国标码。"
1191
1362
  "建议调 list_industries 用语义挑码后改传 gb=。")
1192
1363
  return out
@@ -1202,18 +1373,8 @@ def _attach_routing_hint(out: Dict[str, Any], q: str, city: str) -> Dict[str, An
1202
1373
  # 2026-10-06 实测:查「苏州做AI的企业」只回 2 条(其中一条是名字里带「人工智能」的
1203
1374
  # 餐厅),hint 全程不出现 —— 因为旧逻辑只有「0 条且无候选」和「有候选」两个分支,
1204
1375
  # 1~4 条无候选直接掉进空档 return。必须补上。
1205
- try:
1206
- caps = cap_alias_codes(q)
1207
- except Exception:
1208
- caps = []
1209
1376
  if caps:
1210
- out["cap_expanded"] = [{"cap": c["cap"], "name": c["name"], "word": c["word"],
1211
- "records": _cap_count(c["cap"])} for c in caps]
1212
- out["hint"] = ("这个词命中能力域 %s(跨门类的工艺/技术标签,不是国标小类),"
1213
- "search_vendors(query=…) 已自动展开并计入结果。命中偏少说明该能力"
1214
- "在名录里确实很薄,**不要理解成「没这家厂」**。放宽可调 "
1215
- "list_industries(keyword=…) 换近义类目再传 gb=。"
1216
- % "、".join("%s %d 家" % (c["name"], _cap_count(c["cap"])) for c in caps))
1377
+ _emit_cap()
1217
1378
  return out
1218
1379
  out["hint"] = ("命中偏少且推不出国标码:这几条多半只是**字面撞词**,未必真是你要的行业。"
1219
1380
  "建议调 list_industries(keyword=\"…\") 用语义挑 1~3 个国标小类,"
@@ -1258,17 +1419,72 @@ def _cap_term_index() -> Dict[str, str]:
1258
1419
  return idx
1259
1420
 
1260
1421
 
1422
+ _CAP_SUBSTR_WORDS: Optional[List[Tuple[str, str, Any]]] = None
1423
+
1424
+
1425
+ def _cap_substr_words() -> List[Tuple[str, str, Any]]:
1426
+ """能力词表里**可做子串扫描**的词 [(词, 键, 预编译边界正则|None)],长词在前。
1427
+
1428
+ 过滤规则都是为了不误命中:
1429
+ · 长度 < 2 一律不要 —— 表里有 'c'、'/' 这种单字符键,子串扫会把任何含 c 的
1430
+ 查询都映射成 tst_cpp(一个字符毁掉一条链路);
1431
+ · 只保留「纯字母/数字/汉字」构成的词,带符号的(`.net` / `c++` / `250m汽车配件`
1432
+ 里的斜杠)留给整词精确那一档;
1433
+ · ASCII 词预编译 `(?<![a-z0-9])词(?![a-z0-9])` 词边界 —— 否则 'ai' 会命中
1434
+ 'chair'、'cnc' 会命中 'cncxx'。汉字不分词、天然成词,无需边界。
1435
+ """
1436
+ global _CAP_SUBSTR_WORDS
1437
+ if _CAP_SUBSTR_WORDS is None:
1438
+ rows: List[Tuple[str, str, Any]] = []
1439
+ for w, code in _cap_term_index().items():
1440
+ if len(w) < 2:
1441
+ continue
1442
+ if not re.fullmatch(r"[a-z0-9\u4e00-\u9fff]+", w):
1443
+ continue
1444
+ rx = (re.compile(r"(?<![a-z0-9])" + re.escape(w) + r"(?![a-z0-9])")
1445
+ if w.isascii() else None)
1446
+ rows.append((w, code, rx))
1447
+ rows.sort(key=lambda x: -len(x[0]))
1448
+ _CAP_SUBSTR_WORDS = rows
1449
+ return _CAP_SUBSTR_WORDS
1450
+
1451
+
1261
1452
  def cap_alias_codes(q: str) -> List[Dict[str, Any]]:
1262
- """能力口语词 → 能力键。整词精确匹配(不分词组子串)。返回 [{cap, word, name}]。"""
1453
+ """能力口语词 → 能力键。返回 [{cap, word, name, match}]。
1454
+
1455
+ 两档匹配,整词精确优先:
1456
+ ① exact 查询按空白切开后某个 token 完全等于能力词(原行为)
1457
+ ② substr 能力词出现在查询里的**任意位置**(ASCII 词另要求词边界)
1458
+
1459
+ 2026-10-06 补 ②。在此之前只做 ①,而真实用户说的是**一整句人话**:
1460
+ 「苏州做AI的企业」按空白切只有一个 token,'AI' 永远匹配不上 —— 能力域召回
1461
+ 整条链路对自然人话失效,只能回 0 条并把 agent 赶去翻货架。
1462
+ 国标别名侧(`alias_codes`)一直是子串匹配的,两侧行为不一致本身就是 bug。
1463
+ """
1263
1464
  q = (q or "").strip().lower()
1264
1465
  if not q:
1265
1466
  return []
1266
1467
  idx = _cap_term_index()
1267
1468
  hits: Dict[str, Dict[str, Any]] = {}
1469
+ # ① 整词
1268
1470
  for tok in dict.fromkeys(t for t in q.split() if t):
1269
1471
  code = idx.get(tok)
1270
1472
  if code:
1271
- hits[code] = {"cap": code, "word": tok, "name": _cap_name(code)}
1473
+ hits[code] = {"cap": code, "word": tok, "name": _cap_name(code),
1474
+ "match": "exact"}
1475
+ # ② 子串(长词优先,一份查询最多认 6 个能力域,免得长句把货架扫爆)
1476
+ for w, code, rx in _cap_substr_words():
1477
+ if code in hits:
1478
+ continue
1479
+ if rx is not None:
1480
+ if not rx.search(q):
1481
+ continue
1482
+ elif w not in q:
1483
+ continue
1484
+ hits[code] = {"cap": code, "word": w, "name": _cap_name(code),
1485
+ "match": "substr"}
1486
+ if len(hits) >= 6:
1487
+ break
1272
1488
  return list(hits.values())
1273
1489
 
1274
1490
 
@@ -1802,6 +2018,26 @@ def _hay_lit(rec: Dict[str, Any]) -> str:
1802
2018
  return _hay_parts(rec, False)
1803
2019
 
1804
2020
 
2021
+ def _name_fit(rec_or_sum: Dict[str, Any], tokens: List[str]) -> int:
2022
+ """关键词在**厂名**里的贴合度,0/1/2。用于同一召回档内部的次级排序。
2023
+
2024
+ 只有档位是不够的:档内顺序原本就是分片里的记录顺序 —— 等于随机。实测
2025
+ 「贵阳的商务酒店」把 悦庭楠舍酒店(花果园**中央商务区**店) 排在了 龙翔商务酒店
2026
+ 前面:两者都算 text 命中,但前者只是**括号里的商圈名**撞上了「商务」,后者厂名里
2027
+ 就写着「商务酒店」。只用「关键词是否出现在厂名里」也不够(两家都含「商务」和
2028
+ 「酒店」),必须是**成片出现**才区分得开。
2029
+
2030
+ `query+city` 与 `gb=` 两条路由共用本函数,保证同一批记录两条路排出来的顺序一致
2031
+ (体检的「C 路由一致性」就是查这个)。
2032
+ """
2033
+ if not tokens:
2034
+ return 0
2035
+ co = str(rec_or_sum.get("company") or rec_or_sum.get("co") or "").lower()
2036
+ if "".join(tokens) in co:
2037
+ return 2 # 关键词在厂名里成片出现 —— 最强
2038
+ return 1 if all(t in co for t in tokens) else 0
2039
+
2040
+
1805
2041
  def _rec_summary(rec: Dict[str, Any]) -> Dict[str, Any]:
1806
2042
  return {
1807
2043
  "id": rec.get("id"),
@@ -2306,29 +2542,46 @@ def search_vendors(query: str = "", city: str = "", gb: str = "",
2306
2542
  limit = max(1, min(int(limit), 200))
2307
2543
  offset = max(0, int(offset))
2308
2544
  q = (query or "").strip().lower()
2309
- # 多词按空格分词,要求全部命中(AND)——这样「上海 酒店」也能正确匹配,
2310
- # 而非必须作为连续子串出现。单关键词时退化为原行为。
2311
- tokens = [t for t in q.split() if t]
2312
-
2313
- # 能力(cap)别名:把口语词(AI / 人工智能 / 机器学习 / 大模型 / 算法 …)映射到
2314
- # 能力键(如 tech_ai),并**消费**掉该 token(不再做通用子串 AND 校验),只走 cap
2315
- # 定向召回 —— 否则「AI」会作为子串命中 algebraist / Ashore 等英文名咖啡店造成噪声。
2316
- cap_hits = cap_alias_codes(q) if q else []
2317
- # 只消费「会造成子串噪声的短 ASCII token」(如 ai 会子串命中 algebraist 等英文名),
2318
- # 中文/较长 token 不消费 —— 保留其文本匹配(厂名含「人工智能」的企业不被误丢),
2319
- # 同时下方 cap 召回仍会叠加,最终是「文本 ∪ 能力」的并集。
2545
+ # 原话归一化(2026-10-06):用户输入的是**一句人话**,不是空格分隔的关键词表。
2546
+ # 旧实现直接 `q.split()`,中文整句会变成一个 token,而关键词是 AND 语义 —— 没有
2547
+ # 任何记录会包含「苏州做ai的企业」这个串,必然 0 条。归一化顺带把原话里的城市
2548
+ # 抽成 city 过滤条件。详见 `_normalize_query`。
2549
+ norm = _normalize_query(query, city) if q else None
2550
+ if norm:
2551
+ city = norm["city"] or city
2552
+ tokens = norm["tokens"]
2553
+ cap_hits = norm["cap_hits"]
2554
+ alias_hits = norm["alias_hits"]
2555
+ else:
2556
+ tokens, cap_hits, alias_hits = [], [], []
2557
+ # 能力(cap)别名:映射到能力键(如 tech_ai)后**消费**掉该 token(不再做通用
2558
+ # 子串 AND 校验),只走 cap 定向召回 —— 否则「AI」会子串命中 algebraist /
2559
+ # Ashore 等英文名咖啡店造成噪声。
2560
+ # 只消费「会造成子串噪声的短 ASCII token」(ai / cnc …),中文/较长 token 不消费
2561
+ # —— 保留其文本匹配(厂名含「人工智能」的企业不被误丢),最终是「文本 ∪ 能力」并集。
2320
2562
  cap_tokens = {h["word"].lower() for h in cap_hits
2321
2563
  if len(h["word"]) <= 2 and h["word"].isascii()}
2322
2564
  gen_tokens = [t for t in tokens if t not in cap_tokens]
2323
-
2324
2565
  # 口语词 → 国标码(2026-09-24 接入别名表)。
2325
2566
  # 「输送线/流水线/PCB」这类词在任何记录的厂名/工艺/材料里都不出现,纯子串匹配
2326
2567
  # 必然 0 条。别名命中的记录按码定向召回,**豁免 AND token 校验** —— 不豁免的话
2327
2568
  # 刚拉进来就又被 _hay 判定「不含输送线」而滤掉,等于白接。
2328
- alias_hits = alias_codes(q) if q else []
2329
2569
  alias_by_code = {a["code"]: a for a in alias_hits}
2330
2570
  alias_paths = _fp_paths_for_gbs(list(alias_by_code)) if alias_by_code else []
2331
2571
 
2572
+ # 只有「原话确实被拆过」时才回显解析结果:一句话查询(抽了城市 / 剔了停用词 /
2573
+ # 识别出词条)必须让 agent 看见 MCP 是怎么理解的,否则它无法判断结果为何是这样。
2574
+ # 普通关键词查询不额外增加返回体字节。
2575
+ parsed: Dict[str, Any] = {}
2576
+ if norm and (norm["city_from_query"] or norm["segments"]
2577
+ or tokens != [t for t in q.split() if t]):
2578
+ parsed["query_parsed"] = {
2579
+ "city": city or "",
2580
+ "city_from_query": norm["city_from_query"] or "",
2581
+ "keywords": gen_tokens,
2582
+ "recognized": norm["segments"],
2583
+ }
2584
+
2332
2585
  # 给了国标码:只扫对应分片(最快路径,不构建全量索引)
2333
2586
  if gb:
2334
2587
  fp_shards = [s for s in _shards_of_type("fp") if s.get("c") == gb]
@@ -2344,11 +2597,19 @@ def search_vendors(query: str = "", city: str = "", gb: str = "",
2344
2597
  s = _rec_summary(rec)
2345
2598
  s["via"] = "gb"
2346
2599
  matches.append(s)
2600
+ # 与 query 路由同一套档内排序(`gb=` 只走一档,故按厂名贴合度降序即可),
2601
+ # 否则同一批记录经两条路进来顺序不同,agent 会以为「换了个写法结果就变了」。
2602
+ matches.sort(key=lambda x: -_name_fit(x, gen_tokens))
2347
2603
  return _attach_routing_hint({
2348
2604
  "total_matched": len(matches),
2349
2605
  "returned": len(matches[offset:offset + limit]),
2350
2606
  "shards_scanned": scanned,
2351
2607
  "results": matches[offset:offset + limit],
2608
+ **parsed,
2609
+ # 同 query 路由的口径:档内按「关键词在厂名里成片出现」优先,score 不是相关度。
2610
+ "order_note": ("results 按「关键词在厂名里成片出现」优先排列;score 是"
2611
+ "**能力画像分**(有已发布能力卡才有分,无卡为 0),"
2612
+ "**不是相关度**,不要拿它反推排序。"),
2352
2613
  }, q, city)
2353
2614
 
2354
2615
  # 未给国标码:优先走「预构建倒排索引 → 只拉命中分片」(O(命中量))。
@@ -2364,6 +2625,7 @@ def search_vendors(query: str = "", city: str = "", gb: str = "",
2364
2625
  "shards_scanned": 0,
2365
2626
  "via_index": True,
2366
2627
  "results": [],
2628
+ **parsed,
2367
2629
  }, q, city)
2368
2630
 
2369
2631
  matches: List[Dict[str, Any]] = []
@@ -2460,7 +2722,10 @@ def search_vendors(query: str = "", city: str = "", gb: str = "",
2460
2722
  # 为什么必须显式排:cap_text 与 text 是**同一趟**文本召回里 append 进去的,
2461
2723
  # 不重排两档会交错,order_note 里「按召回来源分档排列」就成了假承诺。
2462
2724
  _RANK = {"text": 0, "city": 0, "cap_text": 1, "alias": 2, "cap": 3}
2463
- matches.sort(key=lambda x: _RANK.get(x.get("via"), 9))
2725
+
2726
+ # 档内再按厂名贴合度排(见 `_name_fit` 的说明)。这是**档内**微调,不影响档间
2727
+ # 次序,也不影响 total_matched。
2728
+ matches.sort(key=lambda x: (_RANK.get(x.get("via"), 9), -_name_fit(x, gen_tokens)))
2464
2729
 
2465
2730
  out = {
2466
2731
  "total_matched": len(matches),
@@ -2468,13 +2733,16 @@ def search_vendors(query: str = "", city: str = "", gb: str = "",
2468
2733
  "shards_scanned": scanned,
2469
2734
  "via_index": via_index,
2470
2735
  "results": matches[offset:offset + limit],
2736
+ **parsed,
2471
2737
  # 结果顺序是**有意设计的分档**,不是随机的:按召回纯度递减排列。
2472
2738
  # 而 `score` 是**能力画像分**(有已发布能力卡才有分,无卡为 0),**不是相关度**。
2473
2739
  # 两者一起看极易误判(实测:一家餐厅 sc=0 排在持卡 L2 企业 sc=40 前面),
2474
2740
  # 所以在这里把口径写死,让 agent 自己能分辨「谁更值得先看」。
2475
2741
  "order_note": ("results 按召回来源分档排列:via=text(厂名/工艺/材料等**字面**命中) → "
2476
2742
  "via=cap_text(厂名里其实没这个词,只是撞上了能力键的中文词面) → "
2477
- "via=alias(别名定向) → via=cap(能力定向);score 是**能力画像分**"
2743
+ "via=alias(别名定向) → via=cap(能力定向);**同一档内**再按「关键词在"
2744
+ "厂名里成片出现」优先(所以「商务酒店」会把 龙翔商务酒店 排在 "
2745
+ "某酒店(中央商务区店) 前面);score 是**能力画像分**"
2478
2746
  "(有已发布能力卡才有分,无卡为 0),**不是相关度**,不要拿它反推排序。"),
2479
2747
  }
2480
2748
  if alias_hits: