beacon-mfg-mcp 1.5.1 → 1.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -1
- package/RELEASE.md +101 -4
- package/package.json +1 -1
- package/server.py +363 -15
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
> 英文翻译(`en_backfill`)、库维护(`postfetch` / `gb_store` / `sync_assets`)等流水线**不进 MCP**、
|
|
8
8
|
> 不暴露、不持密钥;MCP 也不会落在任何写路径上。
|
|
9
9
|
|
|
10
|
-
## 提供的 tool(
|
|
10
|
+
## 提供的 tool(8 个,全部只读)
|
|
11
11
|
|
|
12
12
|
| tool | 用途 | 关键参数 |
|
|
13
13
|
|------|------|----------|
|
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
> 千万量级下只需增大桶数(如 4096)并按城市对大分片二级切分,首次查询耗时仍与总量基本无关。
|
|
27
27
|
| `get_vendor` | 按 id(+国标码)取完整中文档案 | `id`(必填) `gb`(可选,自动反查) |
|
|
28
28
|
| `get_capability_card` | 按 id 取能力卡(工艺/设备/产能/认证等) | `id`(必填) |
|
|
29
|
+
| `suggest_filters` | **用户原话 → 候选国标小类码**(拆词 + 别名 + 类名),供改用 `gb=` | `query`(必填) `city` `limit` |
|
|
30
|
+
| `list_industries` | **国标小类货架**(只列有数据的类,附企业条数),供按语义自己挑码 | `keyword` `parent` `limit` `offset` |
|
|
29
31
|
| `start_sourcing` | 【客户 agent 自动触发】识别找厂/代工/采购/询价意图 → 品类识别→宽召回→需求解析→生成 1~2 轮澄清问题,返回 `session_id` | `demand_text`(必填) `audience_id` |
|
|
30
32
|
| `answer_sourcing` | 续接澄清轮次:写回客户答案,返回下一轮澄清问题或初选供应商列表 | `session_id`(必填) `answers` |
|
|
31
33
|
| `refine_sourcing` | 推荐轮次交互:`details`(看详情与 RFQ 入口) / `more`(看更多) / `best`(看最匹配一家) | `session_id`(必填) `action` `value` |
|
|
@@ -35,6 +37,37 @@
|
|
|
35
37
|
|
|
36
38
|
所有返回都是 JSON 文本。找不到时返回 `{ "error": ... }` 或 `has_card:false`,不会抛协议错。
|
|
37
39
|
|
|
40
|
+
### 用户原话 → 国标码:给 agent 的调用协议(v1.5.3)
|
|
41
|
+
|
|
42
|
+
**为什么需要**:`query` 是**字面子串**匹配,而名录按国标小类归档 —— 客户说「机加工」,
|
|
43
|
+
库里写的是「机械零部件加工」,字面不通。实测(`city=嘉兴`):
|
|
44
|
+
|
|
45
|
+
| 调用 | 命中 |
|
|
46
|
+
|---|---|
|
|
47
|
+
| `query="机加工"` | 1 条 |
|
|
48
|
+
| `gb="3484"`(机械零部件加工) | **350 条** |
|
|
49
|
+
|
|
50
|
+
**不可能靠人工扩别名表覆盖所有说法**(精密件 / 手机壳 / 来料加工…),所以把
|
|
51
|
+
「原话 → 国标码」这一步交给**客户端 LLM 自己**(服务端不调 LLM、不需要密钥)。
|
|
52
|
+
|
|
53
|
+
推荐 agent 按这三步走:
|
|
54
|
+
|
|
55
|
+
1. 先正常检索:`search_vendors(query=<用户原话>, city=<城市>)`。
|
|
56
|
+
2. **命中 < 5 条(含 0)就换码**:返回体里的 `suggested_gb`(或 `hint`)已经给了候选码;
|
|
57
|
+
候选不够味就调 `suggest_filters(query, city)` 拿更多候选 —— 它按
|
|
58
|
+
`alias`(人工策展)> `gb_name`(类名被完整说出)> `substring` > `segmented`(拆词)
|
|
59
|
+
加权,并**优先「类名里含用户原话」的候选**(「轴承」→ 3451 滚动轴承制造)。
|
|
60
|
+
3. 用 `search_vendors(gb=<码>, city=...)` 重试(只扫对应分片,更快)。多个码的结果
|
|
61
|
+
自行合并去重。**连候选都没有**(如「手机壳」)→ 调 `list_industries` 看货架
|
|
62
|
+
(287 个有数据的类,附条数)自己挑 1~3 个码。
|
|
63
|
+
|
|
64
|
+
可直接放进 agent 系统提示词的一段:
|
|
65
|
+
|
|
66
|
+
> 检索灯塔工厂名录时,`search_vendors` 的 `query` 是字面子串匹配。若命中数少于 5 条,
|
|
67
|
+
> 说明用户说的是口语/采购词:先看返回的 `suggested_gb`,或调 `suggest_filters` 拿到
|
|
68
|
+
> 候选国标小类码,再用 `gb=` 重试;仍无候选则调 `list_industries` 按语义挑码。
|
|
69
|
+
> 不要因为命中少就回答「没有这类企业」。
|
|
70
|
+
|
|
38
71
|
## 安装 / 接入
|
|
39
72
|
|
|
40
73
|
服务是**零第三方依赖**的单文件(`server.py`,仅用 Python 标准库),只需 Python 3.8+。
|
package/RELEASE.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Beacon-MFG 只读 MCP 服务 · v1.5.
|
|
1
|
+
# Beacon-MFG 只读 MCP 服务 · v1.5.3
|
|
2
2
|
|
|
3
3
|
让任意支持 MCP 的主流 agent(Claude Desktop / Cline / Continue / WorkBuddy 等)能够
|
|
4
4
|
**检索与调用**已发布到 Cloudflare Pages(或 GitHub)的灯塔工厂供应商数据,
|
|
@@ -8,12 +8,14 @@
|
|
|
8
8
|
MCP 只做**只读检索**。后端数据采集、英文翻译、库维护等流水线**不进 MCP、不暴露、不持密钥**,
|
|
9
9
|
也不会落在任何写路径上。对 Android App 与 cron/GUI 流水线**零影响**(详见 `mcp/README.md`)。
|
|
10
10
|
|
|
11
|
-
##
|
|
11
|
+
## 八个只读 tool
|
|
12
12
|
| tool | 用途 | 关键参数 |
|
|
13
13
|
|------|------|----------|
|
|
14
14
|
| `search_vendors` | 按 关键词 / 城市 / 国标码 检索,返回精简档案 | `query` `city` `gb` `limit` `offset` |
|
|
15
15
|
| `get_vendor` | 按 id(+国标码)取完整中文档案 | `id`(必填) `gb`(可选) |
|
|
16
16
|
| `get_capability_card` | 按 id 取能力卡(工艺/设备/产能/认证) | `id`(必填) |
|
|
17
|
+
| `suggest_filters` | **把用户原话映射成候选国标小类码**(拆词 + 别名 + 类名),供改用 `gb=` | `query`(必填) `city` `limit` |
|
|
18
|
+
| `list_industries` | **国标小类货架**(只列有数据的类,附条数),供 LLM 按语义自己挑码 | `keyword` `parent` `limit` `offset` |
|
|
17
19
|
| `start_sourcing` | 【客户 agent 自动触发】识别找厂/代工/采购意图 → 品类识别→宽召回→澄清,返回 `session_id` | `demand_text`(必填) `audience_id` |
|
|
18
20
|
| `answer_sourcing` | 续接澄清轮次:写回客户答案,返回下一轮澄清或初选列表 | `session_id`(必填) `answers` |
|
|
19
21
|
| `refine_sourcing` | 推荐轮次交互:`details`/`more`/`best` | `session_id`(必填) `action` `value` |
|
|
@@ -21,7 +23,102 @@ MCP 只做**只读检索**。后端数据采集、英文翻译、库维护等流
|
|
|
21
23
|
后三个构成「采购寻源」多轮会话链路 `start_sourcing → answer_sourcing → refine_sourcing`,
|
|
22
24
|
依赖仓库平级的 `skills/rfq-kernel` 桥接模块。
|
|
23
25
|
|
|
24
|
-
## 本次更新(v1.5.
|
|
26
|
+
## 本次更新(v1.5.3)
|
|
27
|
+
**用户说的话和名录里的写法对不上时,不再只回 1 条 —— 加了一层「语义路由」;
|
|
28
|
+
顺带把 `get_capability_card` 从 42~65 秒压到 2 毫秒。**
|
|
29
|
+
|
|
30
|
+
### 1. 问题:别名表不可能穷举
|
|
31
|
+
|
|
32
|
+
拿「嘉兴 机加工」做用户侧压测(真 MCP stdio、非仓库 cwd 强制走 HTTP):
|
|
33
|
+
|
|
34
|
+
| 查询 | 命中 |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `query="机加工"` + `city="嘉兴"` | **1 条** |
|
|
37
|
+
| `query="机加工"`(全国) | 73 条 |
|
|
38
|
+
| `gb="3484"`(机械零部件加工)+ `city="嘉兴"` | **350 条** |
|
|
39
|
+
|
|
40
|
+
`search_vendors` 的关键词是**字面子串**匹配,而名录按国标小类归档:客户说的是
|
|
41
|
+
「机加工」,库里写的是「机械零部件加工」,两者字面不通 → 召回率 0.3%。
|
|
42
|
+
靠人工扩别名表补是走不通的 —— 说法千人千面(精密件 / 手机壳 / 来料加工…),
|
|
43
|
+
表永远差一行,而且加得越多误召回越多。
|
|
44
|
+
|
|
45
|
+
### 2. 解法:把「原话 → 国标码」这一步交给客户端 LLM
|
|
46
|
+
|
|
47
|
+
**MCP 客户端本身就是一个 LLM**,所以服务端不需要调 LLM、不需要密钥、不引入延迟:
|
|
48
|
+
把「货架」和「候选」交给它,让它现场做语义映射。别名表因此降级为
|
|
49
|
+
**高频词加速器**(命中就直接省一次往返),不再是唯一入口。
|
|
50
|
+
|
|
51
|
+
- 新增 **`suggest_filters(query, city)`**:三条来源按可信度加权给出候选码 ——
|
|
52
|
+
`alias`(人工策展 / 数据推导)> `gb_name`(类名被完整说出)> `substring`(类名子串)
|
|
53
|
+
> `segmented`(对原话做最长匹配拆出的词)。**名字里直接含用户原话的候选额外加权**
|
|
54
|
+
(「轴承」→ 3451 滚动轴承制造 必须压过 3399 其他未列明金属制品制造),
|
|
55
|
+
平票时按 `hits`(该词在该类下真实出现过的企业数)而不是类规模排序。
|
|
56
|
+
返回体自带 `next_call`,agent 可以直接照抄参数。
|
|
57
|
+
- 新增 **`list_industries(keyword, parent)`**:国标货架(**只列已有企业的类**,287 个,
|
|
58
|
+
附条数)。一个 0 条的类摆上货架只会误导 LLM,所以刻意不列全 1382 个。
|
|
59
|
+
这是「没有字面候选时」的兜底:让 LLM 自己从类名里挑。
|
|
60
|
+
- **`search_vendors` 命中 < 5 时内联 `hint` + `suggested_gb`**:省掉 agent 再调一次的
|
|
61
|
+
往返,看到就能改 `gb=` 重试。命中充足时**不打扰**(说明用户说的词本身就是库里的词)。
|
|
62
|
+
- 顺手补了 4 条高价值策展别名(`机加工` `数控加工` `加工中心` `精密加工`)——
|
|
63
|
+
它们是**加速器**,不是这套机制的前提。
|
|
64
|
+
|
|
65
|
+
### 3. `get_capability_card`:42~65 秒 → 2 毫秒
|
|
66
|
+
|
|
67
|
+
能力卡只落在 `skills/registry/capability/`,既不进 git 也不在 CDN 静态站,**R2 是唯一供给**
|
|
68
|
+
(由 Worker 反代)。对**没有卡**的供应商(大多数),`fetch_text` 会依次撞 3 个基址的 404,
|
|
69
|
+
其中 jsDelivr / raw.githubusercontent 是**间歇性**慢 —— 同一条命令实测三次:**2.35 s / 42.68 s / 64.60 s**。
|
|
70
|
+
|
|
71
|
+
- 清单新增 **`metadata.cap_cards`**:14587 个已发卡 id 的数字后缀(逗号串,清单 +114 KB)。
|
|
72
|
+
- `get_capability_card` 先查这张表,**不在表里直接回 `has_card=false`,一次 HTTP 都不发**;
|
|
73
|
+
在表里的才真正去取。实测 **0.002 s**。
|
|
74
|
+
- 清单里没有 `cap_cards` 时(旧快照)自动回落原路径 —— 向后兼容,不会因为数据没发而变哑。
|
|
75
|
+
|
|
76
|
+
### 4. 发布闸门:补登记门类 E
|
|
77
|
+
|
|
78
|
+
门类扩展时 E(建筑业)的记录已经进库,但 `skills/schema/gates/E.json` 没建 →
|
|
79
|
+
`validate --strict` 报 `未知 category '建筑业'` → **r2/pages/git 全被闸门拦下,云端永远收不到新数据**。
|
|
80
|
+
补上 E.json(`cap_domain` 故意留空:建筑业暂无能力码表,填一个没登记的域名会让
|
|
81
|
+
`gate_schema` 静默取到空 label,看着像配了其实没配)。
|
|
82
|
+
|
|
83
|
+
### 5. 实测(真 MCP stdio,发布后)
|
|
84
|
+
|
|
85
|
+
| 步 | 耗时 |
|
|
86
|
+
|---|---|
|
|
87
|
+
| initialize / tools/list | 0.7~1.3 s / 0.00 s |
|
|
88
|
+
| 首个检索(新进程冷缓存) | 1.8~3.7 s |
|
|
89
|
+
| 同进程再检索 | 0.7~0.9 s |
|
|
90
|
+
| `get_vendor`(热) | 0.2 s |
|
|
91
|
+
| `get_capability_card`(无卡) | **0.002 s** |
|
|
92
|
+
| 用户视角「找到一家并拿到档案」全程 | **2.9~7.1 s** |
|
|
93
|
+
|
|
94
|
+
## 本次更新(v1.5.2,已发布)
|
|
95
|
+
**搜索也走内容哈希缓存 —— 新进程首次检索 9.2 s → 3.8 s,同进程重复检索 1.2 s。**
|
|
96
|
+
|
|
97
|
+
v1.5.0 给 `get_vendor` 加的「按清单 h 缓存分片」(`_shard_text`)**漏了搜索这条路**:
|
|
98
|
+
`search_vendors` 走 `_records_from_paths` → `_read_many_text`,而 `_read_many_text` 的 HTTP
|
|
99
|
+
分支直接调 `fetch_text` —— 只有 URL 级缓存,命中也要发条件请求等 304,且**不跨进程复用**。
|
|
100
|
+
实测:同一个「模具」检索,冷启动与新进程热启动**都是 ~11 s、每次都重下 17.8 MB / 29 个请求**;
|
|
101
|
+
缓存目录里 URL 哈希文件 33 个、`shard-*.json` **一个都没有**。
|
|
102
|
+
|
|
103
|
+
- `_read_many_text` 的 HTTP 分支改走 `_shard_text`:内容没变就**完全不走网络**(连 304 都省)。
|
|
104
|
+
实测新进程热启动:**29 次 / 17.79 MB → 1 次 / 0.25 MB**(只剩 manifest)。
|
|
105
|
+
- 两张别名表(`data/gb-alias.json` + `data/gb-alias-curated.json`)原来**逐个文件串行**取,
|
|
106
|
+
各付一次 CDN RTT(实测每个约 0.7 s)。改为一次 `_read_many_text` 并发取齐。
|
|
107
|
+
- 清单新增 **`metadata.idxfiles`**:登记 512 个 terms 倒排桶 + 策展别名表的 `h`,
|
|
108
|
+
让它们也能命中内容缓存(否则每次检索都要为「关键词 → 分片」的解析表付一次 RTT)。
|
|
109
|
+
**刻意不放进 `shards`** —— App 会拉全部 `t=idx` 片(`RemoteSource.kt:419`),
|
|
110
|
+
512 个桶 / 约 35 MB 塞进去等于让手机每次同步多下 35 MB。代价只是清单 +26 KB。
|
|
111
|
+
|
|
112
|
+
**用户侧实测**(`npx -y beacon-mfg-mcp` 真实路径、HTTP 模式、隔离缓存):
|
|
113
|
+
|
|
114
|
+
| 动作 | v1.5.1 | v1.5.2 |
|
|
115
|
+
|---|---|---|
|
|
116
|
+
| `get_vendor`(按 id 取详情) | 0.2 s 量级 | 0.2 s 量级(未变) |
|
|
117
|
+
| 首次搜索(冷,全新安装) | 9.7 s | 5.0 s |
|
|
118
|
+
| 首次搜索(新进程、缓存已在) | 9.2 s | 3.8 s |
|
|
119
|
+
| 同进程第二次搜索 | 3.2 s | 1.2 s |
|
|
120
|
+
|
|
121
|
+
## 本次更新(v1.5.1,已发布)
|
|
25
122
|
**修「刚导入的企业在 MCP 上查不到」——idmap 新号守卫。**
|
|
26
123
|
|
|
27
124
|
v1.5.0 的 idmap 有个致命假设:片存在但片里没这条 id ⇒ 判「不存在」(这样才能省掉
|
|
@@ -117,7 +214,7 @@ v1.5.0 的 idmap 有个致命假设:片存在但片里没这条 id ⇒ 判「
|
|
|
117
214
|
## 安装(三选一)
|
|
118
215
|
|
|
119
216
|
### 1. 从 GitHub Release 下载(推荐,零依赖)
|
|
120
|
-
下载本 Release 的 `beacon-mfg-mcp-mcp-v1.5.
|
|
217
|
+
下载本 Release 的 `beacon-mfg-mcp-mcp-v1.5.3.tar.gz`,解压后:
|
|
121
218
|
```jsonc
|
|
122
219
|
{
|
|
123
220
|
"mcpServers": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "beacon-mfg-mcp",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.3",
|
|
4
4
|
"description": "Read-only MCP server exposing the BeaconMFG灯塔工厂 supplier directory (search vendors, get vendor detail, get capability card, multi-turn sourcing) to any MCP-compatible agent. No API key required.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
package/server.py
CHANGED
|
@@ -271,9 +271,9 @@ def _pkg_version() -> str:
|
|
|
271
271
|
try:
|
|
272
272
|
p = os.path.join(os.path.dirname(os.path.abspath(__file__)), "package.json")
|
|
273
273
|
with open(p, "r", encoding="utf-8") as f:
|
|
274
|
-
return json.load(f).get("version", "1.5.
|
|
274
|
+
return json.load(f).get("version", "1.5.3")
|
|
275
275
|
except Exception:
|
|
276
|
-
return "1.5.
|
|
276
|
+
return "1.5.3"
|
|
277
277
|
|
|
278
278
|
|
|
279
279
|
# HTTP 基址列表(含兜底镜像)。默认部署 Cloudflare Pages 不可达时,自动回退到
|
|
@@ -682,16 +682,31 @@ _shard_hash_slot: Optional[tuple] = None # (rel -> sha1, 数据版本号)
|
|
|
682
682
|
|
|
683
683
|
|
|
684
684
|
def _shard_hash_index() -> Dict[str, str]:
|
|
685
|
-
"""分片路径 → 清单登记的 sha1(h)。随数据版本重建,进程内只建一次。
|
|
685
|
+
"""分片路径 → 清单登记的 sha1(h)。随数据版本重建,进程内只建一次。
|
|
686
|
+
|
|
687
|
+
除 `shards` 外还合并 `metadata.idxfiles`:terms 倒排桶与策展别名表**刻意不在
|
|
688
|
+
shards 里** —— App 会拉全部 t=idx 片(RemoteSource.kt:419),把 512 个
|
|
689
|
+
terms 桶放进 shards 等于让手机每次同步多下 35 MB。
|
|
690
|
+
但 MCP 侧需要它们的 h 才能做内容哈希缓存,否则每次检索都各付一次 CDN RTT。
|
|
691
|
+
"""
|
|
686
692
|
global _shard_hash_slot
|
|
687
693
|
ver = _data_version()
|
|
688
694
|
if _shard_hash_slot is None or _shard_hash_slot[1] != ver:
|
|
689
695
|
m: Dict[str, str] = {}
|
|
690
696
|
try:
|
|
691
|
-
|
|
697
|
+
man = load_manifest()
|
|
698
|
+
for s in man.get("shards", []):
|
|
692
699
|
p, h = s.get("p"), s.get("h")
|
|
693
700
|
if p and h:
|
|
694
701
|
m[str(p)] = str(h)
|
|
702
|
+
ix = (man.get("metadata") or {}).get("idxfiles") or {}
|
|
703
|
+
d = ix.get("dir")
|
|
704
|
+
for name, h in (ix.get("h") or {}).items():
|
|
705
|
+
if d and name and h:
|
|
706
|
+
m["%s/%s" % (d, name)] = str(h)
|
|
707
|
+
for rel, h in (ix.get("files") or {}).items():
|
|
708
|
+
if rel and h:
|
|
709
|
+
m[str(rel)] = str(h)
|
|
695
710
|
except Exception:
|
|
696
711
|
m = {}
|
|
697
712
|
_shard_hash_slot = (m, ver)
|
|
@@ -778,9 +793,15 @@ def _zh_paths_for_gb(gb: str) -> List[str]:
|
|
|
778
793
|
# 一个字都不出现,_hay 命中不了,于是返回 0 条 —— 而 App 同一句能出 3434。
|
|
779
794
|
# 别名表把「口语词 → 国标码」补上,检索时按码直接定向对应分片。
|
|
780
795
|
# --------------------------------------------------------------------------- #
|
|
781
|
-
def _load_alias_file(rel: str) -> Dict[str, Any]:
|
|
782
|
-
"""两种历史形态都兼容:[["metadata",..],["alias",{..}]] 和直接 {word: ...}。
|
|
783
|
-
|
|
796
|
+
def _load_alias_file(rel: str, txt: Optional[str] = None) -> Dict[str, Any]:
|
|
797
|
+
"""两种历史形态都兼容:[["metadata",..],["alias",{..}]] 和直接 {word: ...}。
|
|
798
|
+
|
|
799
|
+
`txt` 可由调用方批量取好后传入(见 `load_gb_alias`)—— 免得逐文件各付一次 RTT。
|
|
800
|
+
未传入时走 `_shard_text`:`data/gb-alias.json` 登记在清单里 ⇒ 按内容哈希缓存,
|
|
801
|
+
跨进程也不重复下载。
|
|
802
|
+
"""
|
|
803
|
+
if txt is None:
|
|
804
|
+
txt = _shard_text(rel)
|
|
784
805
|
if not txt:
|
|
785
806
|
return {}
|
|
786
807
|
try:
|
|
@@ -802,9 +823,14 @@ def load_gb_alias() -> Dict[str, Any]:
|
|
|
802
823
|
global _alias_cache
|
|
803
824
|
_alias_relpaths = ("data/gb-alias.json", "data/gb-alias-curated.json")
|
|
804
825
|
if _cache_stale(_alias_cache, _alias_relpaths):
|
|
826
|
+
# ⚡ 两张表互相独立 → 一次并发取齐。逐个 fetch 各付一次 CDN RTT
|
|
827
|
+
# (实测每个约 0.7 s,串行 1.42 s —— 占「新进程首次检索」剩余耗时的一半)。
|
|
828
|
+
# 同时借 _read_many_text 落到 _shard_text:清单里登记了 h 的表按内容缓存,
|
|
829
|
+
# 内容没变就完全不走网络;没 h 的表(如 gb-alias-curated.json)自动退回 fetch。
|
|
830
|
+
raw = _read_many_text(list(_alias_relpaths))
|
|
805
831
|
merged: Dict[str, Any] = {}
|
|
806
832
|
for rel in _alias_relpaths:
|
|
807
|
-
for k, v in _load_alias_file(rel).items():
|
|
833
|
+
for k, v in _load_alias_file(rel, raw.get(rel)).items():
|
|
808
834
|
merged.setdefault(str(k).lower(), v)
|
|
809
835
|
_alias_cache = (merged, time.time(), _wt_mtime_any(_alias_relpaths))
|
|
810
836
|
return _alias_cache[0]
|
|
@@ -896,6 +922,244 @@ def _fp_paths_for_gbs(codes: List[str]) -> List[str]:
|
|
|
896
922
|
return [s.get("p") for s in _shards_of_type("fp") if s.get("c") in set(codes)]
|
|
897
923
|
|
|
898
924
|
|
|
925
|
+
# --------------------------------------------------------------------------- #
|
|
926
|
+
# 语义路由层:不要求「服务端猜对用户的原话」,而是把**国标货架 + 候选码**交给
|
|
927
|
+
# 客户端 LLM,让它现场做语义映射。
|
|
928
|
+
#
|
|
929
|
+
# 为什么这么做(2026-10-06 主人提的问题):靠人工扩别名表覆盖「机加工 / 精密件 /
|
|
930
|
+
# 手机壳…」是走不通的 —— 说法千人千面,表永远差一行,而且加得越多误召回越多。
|
|
931
|
+
# 而 **MCP 客户端本身就是一个 LLM**。把「用户原话 → 国标码」这一步交给它:
|
|
932
|
+
# · 零额外基础设施:服务端不调 LLM、不需要密钥、不引入延迟和成本;
|
|
933
|
+
# · 天然覆盖长尾:LLM 的知识面就是那张「说法表」,不必我们枚举;
|
|
934
|
+
# · 可控可解释:LLM 挑码的依据是货架上的类名 + 真实条数,不是黑盒。
|
|
935
|
+
# 别名表因此降级为**高频词加速器**(能命中就直接省一次往返),不再是唯一入口。
|
|
936
|
+
#
|
|
937
|
+
# 三个出口:
|
|
938
|
+
# list_industries() → 国标货架(只列有数据的类,带条数)
|
|
939
|
+
# suggest_filters() → 拆词 + 别名 + 类名子串,给出候选码与下一步调用
|
|
940
|
+
# search_vendors() → 命中偏少时内联 hint,把这条链路推给 agent
|
|
941
|
+
# --------------------------------------------------------------------------- #
|
|
942
|
+
|
|
943
|
+
_GB_SHELF: Optional[List[Dict[str, Any]]] = None
|
|
944
|
+
_SEG_PATTERNS: Optional[List[Tuple[str, str]]] = None
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
def _gb_shelf() -> List[Dict[str, Any]]:
|
|
948
|
+
"""国标小类货架:[{code, name, group, path, records}]。
|
|
949
|
+
|
|
950
|
+
数据源刻意选 `data/gb-index.json`(82 KB)而不是 `gb4754-full.json`(280 KB):
|
|
951
|
+
前者**只含真正有数据的类**(269 个),并直接带每类条数。对「让 LLM 挑码」
|
|
952
|
+
这个用途,列一个 0 条的类只会误导它 —— 货架上摆不出来的东西没必要上架。
|
|
953
|
+
"""
|
|
954
|
+
global _GB_SHELF
|
|
955
|
+
if _GB_SHELF is not None:
|
|
956
|
+
return _GB_SHELF
|
|
957
|
+
rows: List[Dict[str, Any]] = []
|
|
958
|
+
try:
|
|
959
|
+
txt = fetch_text("data/gb-index.json")
|
|
960
|
+
tree = (json.loads(txt) if txt else {}).get("tree") or {}
|
|
961
|
+
except Exception:
|
|
962
|
+
tree = {}
|
|
963
|
+
for dcode, dv in tree.items():
|
|
964
|
+
dname = str(dv.get("name") or "")
|
|
965
|
+
for gcode, gv in (dv.get("divisions") or {}).items():
|
|
966
|
+
gname = str(gv.get("name") or "")
|
|
967
|
+
for mcode, mv in (gv.get("groups") or {}).items():
|
|
968
|
+
mname = str(mv.get("name") or "")
|
|
969
|
+
for ccode, cv in (mv.get("classes") or {}).items():
|
|
970
|
+
rows.append({
|
|
971
|
+
"code": str(ccode),
|
|
972
|
+
"name": str(cv.get("name") or ""),
|
|
973
|
+
"group": str(mcode),
|
|
974
|
+
"path": "%s/%s/%s" % (dname, gname, mname),
|
|
975
|
+
"records": int(cv.get("count") or 0),
|
|
976
|
+
})
|
|
977
|
+
_GB_SHELF = rows
|
|
978
|
+
return rows
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
def _seg_patterns() -> List[Tuple[str, str]]:
|
|
982
|
+
"""长词优先的匹配表 [(词, 来源)],来源 ∈ alias / gb_name。"""
|
|
983
|
+
global _SEG_PATTERNS
|
|
984
|
+
if _SEG_PATTERNS is None:
|
|
985
|
+
pats: Dict[str, str] = {}
|
|
986
|
+
for w in load_gb_alias():
|
|
987
|
+
if len(w) >= 2:
|
|
988
|
+
pats.setdefault(w, "alias")
|
|
989
|
+
for r in _gb_shelf():
|
|
990
|
+
if len(r["name"]) >= 2:
|
|
991
|
+
pats.setdefault(r["name"], "gb_name")
|
|
992
|
+
_SEG_PATTERNS = sorted(pats.items(), key=lambda x: -len(x[0]))
|
|
993
|
+
return _SEG_PATTERNS
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
def _segment_terms(text: str) -> List[Tuple[str, str]]:
|
|
997
|
+
"""把一段人话按「最长优先、不重叠」切成已知词条。
|
|
998
|
+
|
|
999
|
+
这是「智能拆分需求」的确定性那一半:`不锈钢板激光切割折弯` →
|
|
1000
|
+
`激光切割 / 折弯 / 不锈钢板` 三个候选,各自再去映射国标码。
|
|
1001
|
+
剩下那一半(这几个词到底该挑哪个码、要不要并集)交给 LLM。
|
|
1002
|
+
"""
|
|
1003
|
+
hay = (text or "").lower()
|
|
1004
|
+
if not hay:
|
|
1005
|
+
return []
|
|
1006
|
+
hits: List[Tuple[str, str]] = []
|
|
1007
|
+
i, n = 0, len(hay)
|
|
1008
|
+
while i < n:
|
|
1009
|
+
for w, src in _seg_patterns():
|
|
1010
|
+
if hay.startswith(w.lower(), i):
|
|
1011
|
+
hits.append((w, src))
|
|
1012
|
+
i += len(w)
|
|
1013
|
+
break
|
|
1014
|
+
else:
|
|
1015
|
+
i += 1
|
|
1016
|
+
return hits
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def list_industries(keyword: str = "", parent: str = "", limit: int = 30,
|
|
1020
|
+
offset: int = 0) -> Dict[str, Any]:
|
|
1021
|
+
"""国标货架:给 LLM 一张「有哪些行业、各有多少家」的菜单。
|
|
1022
|
+
|
|
1023
|
+
用户原话与任何类名都不对字时(如「机加工」对不上「机械零部件加工」),
|
|
1024
|
+
唯一靠谱的办法是让 LLM 看货架自己挑 —— 这正是本工具存在的理由。
|
|
1025
|
+
"""
|
|
1026
|
+
limit = max(1, min(int(limit), 200))
|
|
1027
|
+
offset = max(0, int(offset))
|
|
1028
|
+
rows = _gb_shelf()
|
|
1029
|
+
parent = (parent or "").strip()
|
|
1030
|
+
if parent:
|
|
1031
|
+
rows = [r for r in rows
|
|
1032
|
+
if r["code"].startswith(parent) or r["group"].startswith(parent)
|
|
1033
|
+
or r["path"].startswith(parent)]
|
|
1034
|
+
keyword = (keyword or "").strip().lower()
|
|
1035
|
+
if keyword:
|
|
1036
|
+
rows = [r for r in rows
|
|
1037
|
+
if keyword in r["name"].lower() or keyword in r["code"]
|
|
1038
|
+
or keyword in r["path"].lower()]
|
|
1039
|
+
rows = sorted(rows, key=lambda r: -r["records"])
|
|
1040
|
+
return {
|
|
1041
|
+
"total": len(rows),
|
|
1042
|
+
"returned": len(rows[offset:offset + limit]),
|
|
1043
|
+
"note": "只列**已有企业**的国标小类(带条数)。请按语义挑 1~3 个最接近的 code,"
|
|
1044
|
+
"再调 search_vendors(gb=<code>, city=<城市>)。"
|
|
1045
|
+
"传 parent 可缩小货架(门类字母如 C、或大类两位码如 35)。",
|
|
1046
|
+
"industries": rows[offset:offset + limit],
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def suggest_filters(query: str, city: str = "", limit: int = 8) -> Dict[str, Any]:
|
|
1051
|
+
"""把用户原话映射成候选国标码(给 LLM 做决策依据,而不是替它做决定)。
|
|
1052
|
+
|
|
1053
|
+
三条来源按可信度排序:
|
|
1054
|
+
1 alias 别名表精确命中(人工策展 / 数据推导)—— 最可信
|
|
1055
|
+
2 gb_name 类名被完整说出(「机械零部件加工」)
|
|
1056
|
+
3 substring 类名子串(「轴承」→ 轴承制造)
|
|
1057
|
+
4 segmented 对原话做最长匹配拆出的词(覆盖「一句话多个诉求」)
|
|
1058
|
+
全都没命中时**明确告诉 agent 去查货架**,而不是返回一个空数组让它瞎猜。
|
|
1059
|
+
"""
|
|
1060
|
+
q = (query or "").strip()
|
|
1061
|
+
if not q:
|
|
1062
|
+
return {"error": "缺少必填参数 query"}
|
|
1063
|
+
limit = max(1, min(int(limit), 30))
|
|
1064
|
+
by_code = {r["code"]: r for r in _gb_shelf()}
|
|
1065
|
+
cands: Dict[str, Dict[str, Any]] = {}
|
|
1066
|
+
|
|
1067
|
+
def _add(code: str, score: int, why: str, term: str = "", hits: int = 0) -> None:
|
|
1068
|
+
row = by_code.get(code) or {}
|
|
1069
|
+
name = row.get("name") or _gb_names().get(code, "")
|
|
1070
|
+
# 名字里**直接含用户原话**是最强信号:「轴承」→ 3451 滚动轴承制造
|
|
1071
|
+
# 一定比「3399 其他未列明金属制品制造」更可能是正解。没有这一步,
|
|
1072
|
+
# 排序会被「类大 = 条数多」带偏(3399 有 4116 家,把正解压到第二名)。
|
|
1073
|
+
if term and term.lower() in (name or "").lower():
|
|
1074
|
+
score += 25
|
|
1075
|
+
cur = cands.get(code)
|
|
1076
|
+
item = {
|
|
1077
|
+
"code": code,
|
|
1078
|
+
"name": name,
|
|
1079
|
+
"path": row.get("path", ""),
|
|
1080
|
+
"records": row.get("records", 0),
|
|
1081
|
+
"why": why,
|
|
1082
|
+
"term": term or q,
|
|
1083
|
+
"hits": int(hits or 0),
|
|
1084
|
+
"_score": score,
|
|
1085
|
+
}
|
|
1086
|
+
if cur is None or score > cur["_score"]:
|
|
1087
|
+
cands[code] = item
|
|
1088
|
+
|
|
1089
|
+
for h in alias_codes(q):
|
|
1090
|
+
_add(h["code"], 100 if h["exact"] else 80, "alias", h["word"], h.get("hits", 0))
|
|
1091
|
+
ql = q.lower()
|
|
1092
|
+
for r in _gb_shelf():
|
|
1093
|
+
nm = r["name"].lower()
|
|
1094
|
+
if nm == ql:
|
|
1095
|
+
_add(r["code"], 90, "gb_name", q)
|
|
1096
|
+
elif ql in nm:
|
|
1097
|
+
_add(r["code"], 60, "substring", q)
|
|
1098
|
+
for term, src in _segment_terms(q):
|
|
1099
|
+
if term.lower() == ql:
|
|
1100
|
+
continue
|
|
1101
|
+
if src == "alias":
|
|
1102
|
+
for h in alias_codes(term):
|
|
1103
|
+
_add(h["code"], 70 if h["exact"] else 55, "segmented", term,
|
|
1104
|
+
h.get("hits", 0))
|
|
1105
|
+
for r in _gb_shelf():
|
|
1106
|
+
if r["name"] == term:
|
|
1107
|
+
_add(r["code"], 65, "segmented", term)
|
|
1108
|
+
|
|
1109
|
+
# 词条级证据(hits = 该词在该类下真实出现过的企业数)优先于类规模。
|
|
1110
|
+
out = sorted(cands.values(), key=lambda x: (-x["_score"], -x["hits"], -x["records"]))
|
|
1111
|
+
for x in out:
|
|
1112
|
+
x.pop("_score", None)
|
|
1113
|
+
out = out[:limit]
|
|
1114
|
+
|
|
1115
|
+
res: Dict[str, Any] = {
|
|
1116
|
+
"query": q,
|
|
1117
|
+
"city": city or "",
|
|
1118
|
+
"candidates": out,
|
|
1119
|
+
"how_to_use": "挑 1~3 个 code,逐个调 search_vendors(gb=<code>, city=<城市>)"
|
|
1120
|
+
"(给了 gb 只扫对应分片,最快);多个码的结果自行合并去重。"
|
|
1121
|
+
"候选为空或都不对味时,调 list_industries 看货架自己挑。",
|
|
1122
|
+
}
|
|
1123
|
+
if out:
|
|
1124
|
+
res["next_call"] = {"tool": "search_vendors",
|
|
1125
|
+
"arguments": {"gb": out[0]["code"], "city": city or "",
|
|
1126
|
+
"limit": 20}}
|
|
1127
|
+
else:
|
|
1128
|
+
res["next_call"] = {"tool": "list_industries",
|
|
1129
|
+
"arguments": {"keyword": q, "limit": 30}}
|
|
1130
|
+
res["note"] = ("没有字面/别名候选。请用你的语义理解先在货架上挑类:"
|
|
1131
|
+
"先 list_industries(keyword=\"…\") 看有哪些类,"
|
|
1132
|
+
"必要时空 keyword 拉全货架(269 类)挑最贴近的 1~3 个 code。")
|
|
1133
|
+
return res
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
def _attach_routing_hint(out: Dict[str, Any], q: str, city: str) -> Dict[str, Any]:
|
|
1137
|
+
"""命中偏少时,把「怎么把口语词换成国标码」这条链路内联进结果。
|
|
1138
|
+
|
|
1139
|
+
只在 query 非空且命中 < 5 时触发 —— 命中充足说明用户说的词本身就是数据里的
|
|
1140
|
+
词,不需要打扰 agent。内联(而不是让 agent 再调一次)是为了省一个 RTT:
|
|
1141
|
+
agent 看到 hint 就能直接改参数重试。
|
|
1142
|
+
"""
|
|
1143
|
+
if not q or out.get("total_matched", 0) >= 5:
|
|
1144
|
+
return out
|
|
1145
|
+
try:
|
|
1146
|
+
s = suggest_filters(q, city, limit=5)
|
|
1147
|
+
except Exception:
|
|
1148
|
+
return out
|
|
1149
|
+
cands = s.get("candidates") or []
|
|
1150
|
+
if out.get("total_matched", 0) == 0 and not cands:
|
|
1151
|
+
out["hint"] = ("关键词与名录里的写法对不上,且没有可推导的国标码。"
|
|
1152
|
+
"建议调 list_industries 用语义挑码后改传 gb=。")
|
|
1153
|
+
return out
|
|
1154
|
+
if cands:
|
|
1155
|
+
out["hint"] = ("命中偏少:用户说的是口语/采购词,名录里按国标小类归档。"
|
|
1156
|
+
"改用 gb= 重试通常能召回几十~几百条(见 suggested_gb)。")
|
|
1157
|
+
out["suggested_gb"] = [{"code": c["code"], "name": c["name"],
|
|
1158
|
+
"records": c["records"], "why": c["why"]}
|
|
1159
|
+
for c in cands]
|
|
1160
|
+
return out
|
|
1161
|
+
|
|
1162
|
+
|
|
899
1163
|
# --------------------------------------------------------------------------- #
|
|
900
1164
|
# 能力(cap)别名召回:把「AI / 人工智能 / 机器学习 / 大模型 / 算法 …」映射到
|
|
901
1165
|
# 能力键(如 tech_ai),按能力键定向召回。与 GB 别名不同,cap 是跨门类能力、
|
|
@@ -1636,9 +1900,17 @@ def _read_many_text(paths: List[str]) -> Dict[str, str]:
|
|
|
1636
1900
|
got = _git_read_many(paths)
|
|
1637
1901
|
if len(got) >= max(1, len(paths) // 2): # archive 正常覆盖
|
|
1638
1902
|
return got
|
|
1903
|
+
# ⚠ HTTP 分支必须走 _shard_text(按清单 h 做**内容哈希缓存**),不能裸调 fetch_text。
|
|
1904
|
+
# 这里是 _candidate_shards(倒排桶)与 _records_from_paths(fp 分片)的**公共取数口**:
|
|
1905
|
+
# 一次「模具」检索要拉约 17 MB 的 fp 分片(24 片)。裸 fetch_text 只有 URL 级缓存,
|
|
1906
|
+
# 而 URL 缓存**不跨进程复用**(每次新进程都重下),于是热启动与冷启动同速
|
|
1907
|
+
# —— 实测两次都是 ~11 s,缓存目录里 URL 哈希文件 33 个、shard-*.json 一个都没有。
|
|
1908
|
+
# 换 _shard_text 后,内容没变就完全不走网络(连 304 都省),且跨进程有效。
|
|
1909
|
+
# (没有 h 的路径——如倒排桶——_shard_text 内部自动退回 fetch_text,行为不变。)
|
|
1910
|
+
_shard_hash_index() # 预热:别让线程池里做首次构建
|
|
1639
1911
|
out: Dict[str, str] = {}
|
|
1640
1912
|
with concurrent.futures.ThreadPoolExecutor(max_workers=16) as ex:
|
|
1641
|
-
for path, txt in zip(paths, ex.map(
|
|
1913
|
+
for path, txt in zip(paths, ex.map(_shard_text, paths)):
|
|
1642
1914
|
if txt:
|
|
1643
1915
|
out[path] = txt
|
|
1644
1916
|
return out
|
|
@@ -1986,12 +2258,12 @@ def search_vendors(query: str = "", city: str = "", gb: str = "",
|
|
|
1986
2258
|
if gen_tokens and not all(tok in _hay(rec).lower() for tok in gen_tokens):
|
|
1987
2259
|
continue
|
|
1988
2260
|
matches.append(_rec_summary(rec))
|
|
1989
|
-
return {
|
|
2261
|
+
return _attach_routing_hint({
|
|
1990
2262
|
"total_matched": len(matches),
|
|
1991
2263
|
"returned": len(matches[offset:offset + limit]),
|
|
1992
2264
|
"shards_scanned": scanned,
|
|
1993
2265
|
"results": matches[offset:offset + limit],
|
|
1994
|
-
}
|
|
2266
|
+
}, q, city)
|
|
1995
2267
|
|
|
1996
2268
|
# 未给国标码:优先走「预构建倒排索引 → 只拉命中分片」(O(命中量))。
|
|
1997
2269
|
# 索引缺失或陈旧时回退进程内全量索引(O(总量),慢但结果等价)。
|
|
@@ -2000,13 +2272,13 @@ def search_vendors(query: str = "", city: str = "", gb: str = "",
|
|
|
2000
2272
|
|
|
2001
2273
|
if via_index and not cands and not alias_paths and not cap_hits:
|
|
2002
2274
|
# 索引明确判定无命中、别名也没给方向 —— 无需拉任何分片
|
|
2003
|
-
return {
|
|
2275
|
+
return _attach_routing_hint({
|
|
2004
2276
|
"total_matched": 0,
|
|
2005
2277
|
"returned": 0,
|
|
2006
2278
|
"shards_scanned": 0,
|
|
2007
2279
|
"via_index": True,
|
|
2008
2280
|
"results": [],
|
|
2009
|
-
}
|
|
2281
|
+
}, q, city)
|
|
2010
2282
|
|
|
2011
2283
|
matches: List[Dict[str, Any]] = []
|
|
2012
2284
|
seen: set = set()
|
|
@@ -2102,7 +2374,7 @@ def search_vendors(query: str = "", city: str = "", gb: str = "",
|
|
|
2102
2374
|
out["cap_expanded"] = [{"word": h["word"], "cap": h["cap"], "name": h["name"]}
|
|
2103
2375
|
for h in cap_hits]
|
|
2104
2376
|
out["cap_records_seen"] = cap_scanned
|
|
2105
|
-
return out
|
|
2377
|
+
return _attach_routing_hint(out, q, city)
|
|
2106
2378
|
|
|
2107
2379
|
|
|
2108
2380
|
def _pick(*vals: Any) -> Any:
|
|
@@ -2213,10 +2485,43 @@ def get_vendor(vid: str, gb: str = "") -> Dict[str, Any]:
|
|
|
2213
2485
|
return {"error": f"找不到 id={vid} 对应的国标码,可能该记录尚未发布"}
|
|
2214
2486
|
|
|
2215
2487
|
|
|
2488
|
+
_CAP_CARD_IDS: Optional[set] = None
|
|
2489
|
+
|
|
2490
|
+
|
|
2491
|
+
def _card_count() -> int:
|
|
2492
|
+
return int(((load_manifest().get("metadata") or {})
|
|
2493
|
+
.get("cap_cards") or {}).get("count") or 0)
|
|
2494
|
+
|
|
2495
|
+
|
|
2496
|
+
def _cap_card_ids() -> Optional[set]:
|
|
2497
|
+
"""清单里登记的「已有能力卡的 id 集合」(None = 清单没这块,走原路径)。
|
|
2498
|
+
|
|
2499
|
+
2026-10-06 实测:能力卡只存在于本地 `skills/registry/capability/`,
|
|
2500
|
+
既不进 git 也不在 CDN 静态站上 → 三个基址串行 404。对 majority 没有卡的
|
|
2501
|
+
供应商,一次 `get_capability_card` 要 **42~65 秒**才肯回 has_card=false。
|
|
2502
|
+
清单带上这张 id 表之后,没卡的直接短路,一次网络都不发。
|
|
2503
|
+
"""
|
|
2504
|
+
global _CAP_CARD_IDS
|
|
2505
|
+
if _CAP_CARD_IDS is None:
|
|
2506
|
+
ids = ((load_manifest().get("metadata") or {}).get("cap_cards") or {}).get("ids")
|
|
2507
|
+
_CAP_CARD_IDS = set(ids.split(",")) if ids else None
|
|
2508
|
+
return _CAP_CARD_IDS
|
|
2509
|
+
|
|
2510
|
+
|
|
2216
2511
|
def get_capability_card(vid: str) -> Dict[str, Any]:
|
|
2217
2512
|
vid = (vid or "").strip()
|
|
2218
2513
|
if not vid:
|
|
2219
2514
|
return {"error": "缺少必填参数 id"}
|
|
2515
|
+
# 短路:清单里查得到这张表时,没卡的 id 立刻回,别去撞三基址 404。
|
|
2516
|
+
want = _cap_card_ids()
|
|
2517
|
+
if want is not None:
|
|
2518
|
+
num = vid.split("-")[-1]
|
|
2519
|
+
if not num.isdigit() or num not in want:
|
|
2520
|
+
return {
|
|
2521
|
+
"id": vid, "has_card": False,
|
|
2522
|
+
"note": "该供应商暂无已发布能力卡(全库仅 %d 家有卡)。"
|
|
2523
|
+
"需要档案请先调 get_vendor。" % _card_count(),
|
|
2524
|
+
}
|
|
2220
2525
|
rel = f"skills/registry/capability/{vid}.json"
|
|
2221
2526
|
txt = fetch_text(rel)
|
|
2222
2527
|
if not txt:
|
|
@@ -2301,7 +2606,11 @@ TOOLS = [
|
|
|
2301
2606
|
{
|
|
2302
2607
|
"name": "search_vendors",
|
|
2303
2608
|
"description": "检索灯塔工厂供应商名录。可按关键词(企业名/工艺/材料/认证)、城市、国标码(GB/T 4754)过滤。"
|
|
2304
|
-
"返回精简档案(id/企业名/城市/国标码/认证等级/工艺/材料/认证/是否含电话)。"
|
|
2609
|
+
"返回精简档案(id/企业名/城市/国标码/认证等级/工艺/材料/认证/是否含电话)。"
|
|
2610
|
+
"⚠ 关键词是**字面子串**匹配:用户的口语词/采购词若与名录写法不同(如「机加工」"
|
|
2611
|
+
"vs 归档用的「机械零部件加工」),命中会极少。此时不要直接报「没有」——"
|
|
2612
|
+
"看返回体里的 hint / suggested_gb,或先调 suggest_filters 拿到候选码,"
|
|
2613
|
+
"再用 gb=<码> 重试(召回通常多几十倍,且只扫对应分片更快)。",
|
|
2305
2614
|
"inputSchema": {
|
|
2306
2615
|
"type": "object",
|
|
2307
2616
|
"properties": {
|
|
@@ -2331,6 +2640,8 @@ TOOLS = [
|
|
|
2331
2640
|
{
|
|
2332
2641
|
"name": "get_capability_card",
|
|
2333
2642
|
"description": "按供应商 id 取能力卡(工艺位/设备/产能/认证/起订量等)。"
|
|
2643
|
+
"全库仅少数供应商有已发布能力卡,无卡的会秒回 has_card=false"
|
|
2644
|
+
"(不必重试)。"
|
|
2334
2645
|
"无已发布卡片时返回 has_card=false 及原因说明。",
|
|
2335
2646
|
"inputSchema": {
|
|
2336
2647
|
"type": "object",
|
|
@@ -2384,6 +2695,36 @@ TOOLS = [
|
|
|
2384
2695
|
"required": ["session_id"],
|
|
2385
2696
|
},
|
|
2386
2697
|
},
|
|
2698
|
+
{
|
|
2699
|
+
"name": "suggest_filters",
|
|
2700
|
+
"description": "把用户原话(口语/采购词)映射成候选国标小类码,供你改用 search_vendors(gb=…)。"
|
|
2701
|
+
"用户说的是「机加工」,名录里按「机械零部件加工」归档——别名表覆盖不了所有说法,"
|
|
2702
|
+
"所以本工具把「拆词 + 别名 + 类名」的候选和每类条数给你,由你做最终语义选择。"
|
|
2703
|
+
"search_vendors 命中偏少时也会内联 suggested_gb。",
|
|
2704
|
+
"inputSchema": {
|
|
2705
|
+
"type": "object",
|
|
2706
|
+
"properties": {
|
|
2707
|
+
"query": {"type": "string", "description": "用户原话,如 机加工 / 不锈钢板激光切割折弯(必填)"},
|
|
2708
|
+
"city": {"type": "string", "description": "可选,用于拼出下一步调用参数"},
|
|
2709
|
+
"limit": {"type": "integer", "default": 8, "description": "候选码条数上限(1-30)"},
|
|
2710
|
+
},
|
|
2711
|
+
"required": ["query"],
|
|
2712
|
+
},
|
|
2713
|
+
},
|
|
2714
|
+
{
|
|
2715
|
+
"name": "list_industries",
|
|
2716
|
+
"description": "列出国标小类货架(只列已有企业的类,附企业条数),供你在没有字面候选时"
|
|
2717
|
+
"按语义自己挑码。可传 keyword 模糊筛、parent 按门类字母/大类码缩小范围。",
|
|
2718
|
+
"inputSchema": {
|
|
2719
|
+
"type": "object",
|
|
2720
|
+
"properties": {
|
|
2721
|
+
"keyword": {"type": "string", "description": "可选,类名/码的子串"},
|
|
2722
|
+
"parent": {"type": "string", "description": "可选,门类字母(C)或大类两位码(35)"},
|
|
2723
|
+
"limit": {"type": "integer", "default": 30, "description": "返回条数(1-200)"},
|
|
2724
|
+
"offset": {"type": "integer", "default": 0},
|
|
2725
|
+
},
|
|
2726
|
+
},
|
|
2727
|
+
},
|
|
2387
2728
|
]
|
|
2388
2729
|
|
|
2389
2730
|
|
|
@@ -2505,6 +2846,13 @@ def _dispatch_inner(name: str, args: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
2505
2846
|
return get_vendor(args.get("id", ""), args.get("gb", ""))
|
|
2506
2847
|
if name == "get_capability_card":
|
|
2507
2848
|
return get_capability_card(args.get("id", ""))
|
|
2849
|
+
if name == "suggest_filters":
|
|
2850
|
+
return suggest_filters(args.get("query", ""), args.get("city", ""),
|
|
2851
|
+
int(args.get("limit") or 8))
|
|
2852
|
+
if name == "list_industries":
|
|
2853
|
+
return list_industries(args.get("keyword", ""), args.get("parent", ""),
|
|
2854
|
+
int(args.get("limit") or 30),
|
|
2855
|
+
int(args.get("offset") or 0))
|
|
2508
2856
|
if name == "start_sourcing":
|
|
2509
2857
|
return start_sourcing(args.get("demand_text", ""), args.get("audience_id", "domestic_downstream"))
|
|
2510
2858
|
if name == "answer_sourcing":
|