beacon-mfg-mcp 1.5.0 → 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 +259 -143
- package/package.json +1 -1
- package/server.py +386 -16
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,143 +1,259 @@
|
|
|
1
|
-
# Beacon-MFG 只读 MCP 服务 · v1.5.
|
|
2
|
-
|
|
3
|
-
让任意支持 MCP 的主流 agent(Claude Desktop / Cline / Continue / WorkBuddy 等)能够
|
|
4
|
-
**检索与调用**已发布到 Cloudflare Pages(或 GitHub)的灯塔工厂供应商数据,
|
|
5
|
-
**无需 clone 整个仓库、无需任何密钥**。
|
|
6
|
-
|
|
7
|
-
## 设计边界
|
|
8
|
-
MCP 只做**只读检索**。后端数据采集、英文翻译、库维护等流水线**不进 MCP、不暴露、不持密钥**,
|
|
9
|
-
也不会落在任何写路径上。对 Android App 与 cron/GUI 流水线**零影响**(详见 `mcp/README.md`)。
|
|
10
|
-
|
|
11
|
-
##
|
|
12
|
-
| tool | 用途 | 关键参数 |
|
|
13
|
-
|------|------|----------|
|
|
14
|
-
| `search_vendors` | 按 关键词 / 城市 / 国标码 检索,返回精简档案 | `query` `city` `gb` `limit` `offset` |
|
|
15
|
-
| `get_vendor` | 按 id(+国标码)取完整中文档案 | `id`(必填) `gb`(可选) |
|
|
16
|
-
| `get_capability_card` | 按 id 取能力卡(工艺/设备/产能/认证) | `id`(必填) |
|
|
17
|
-
| `
|
|
18
|
-
| `
|
|
19
|
-
| `
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
1
|
+
# Beacon-MFG 只读 MCP 服务 · v1.5.3
|
|
2
|
+
|
|
3
|
+
让任意支持 MCP 的主流 agent(Claude Desktop / Cline / Continue / WorkBuddy 等)能够
|
|
4
|
+
**检索与调用**已发布到 Cloudflare Pages(或 GitHub)的灯塔工厂供应商数据,
|
|
5
|
+
**无需 clone 整个仓库、无需任何密钥**。
|
|
6
|
+
|
|
7
|
+
## 设计边界
|
|
8
|
+
MCP 只做**只读检索**。后端数据采集、英文翻译、库维护等流水线**不进 MCP、不暴露、不持密钥**,
|
|
9
|
+
也不会落在任何写路径上。对 Android App 与 cron/GUI 流水线**零影响**(详见 `mcp/README.md`)。
|
|
10
|
+
|
|
11
|
+
## 八个只读 tool
|
|
12
|
+
| tool | 用途 | 关键参数 |
|
|
13
|
+
|------|------|----------|
|
|
14
|
+
| `search_vendors` | 按 关键词 / 城市 / 国标码 检索,返回精简档案 | `query` `city` `gb` `limit` `offset` |
|
|
15
|
+
| `get_vendor` | 按 id(+国标码)取完整中文档案 | `id`(必填) `gb`(可选) |
|
|
16
|
+
| `get_capability_card` | 按 id 取能力卡(工艺/设备/产能/认证) | `id`(必填) |
|
|
17
|
+
| `suggest_filters` | **把用户原话映射成候选国标小类码**(拆词 + 别名 + 类名),供改用 `gb=` | `query`(必填) `city` `limit` |
|
|
18
|
+
| `list_industries` | **国标小类货架**(只列有数据的类,附条数),供 LLM 按语义自己挑码 | `keyword` `parent` `limit` `offset` |
|
|
19
|
+
| `start_sourcing` | 【客户 agent 自动触发】识别找厂/代工/采购意图 → 品类识别→宽召回→澄清,返回 `session_id` | `demand_text`(必填) `audience_id` |
|
|
20
|
+
| `answer_sourcing` | 续接澄清轮次:写回客户答案,返回下一轮澄清或初选列表 | `session_id`(必填) `answers` |
|
|
21
|
+
| `refine_sourcing` | 推荐轮次交互:`details`/`more`/`best` | `session_id`(必填) `action` `value` |
|
|
22
|
+
|
|
23
|
+
后三个构成「采购寻源」多轮会话链路 `start_sourcing → answer_sourcing → refine_sourcing`,
|
|
24
|
+
依赖仓库平级的 `skills/rfq-kernel` 桥接模块。
|
|
25
|
+
|
|
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,已发布)
|
|
122
|
+
**修「刚导入的企业在 MCP 上查不到」——idmap 新号守卫。**
|
|
123
|
+
|
|
124
|
+
v1.5.0 的 idmap 有个致命假设:片存在但片里没这条 id ⇒ 判「不存在」(这样才能省掉
|
|
125
|
+
18.4 MB 的 name-index 兜底)。但**抓取流水线与政府信源导入每天都在发新号**,
|
|
126
|
+
而 idmap 只在 manifest 步重算、只在 pages 步上云 —— 于是「刚导入、还没发布」的新 id
|
|
127
|
+
天然不在已发布快照里,会被这条判定**静默误杀**(政府名录一次能进上千家,症状是查不到、不报错)。
|
|
128
|
+
|
|
129
|
+
- `idmap/meta.json` 与 `manifest.metadata.idmap` 新增 **`max_id`**(已发布的最大 id 数字)。
|
|
130
|
+
客户端据此区分:`id <= max_id` 且片里没有 ⇒ 真不存在(照样省掉兜底);
|
|
131
|
+
`id > max_id` ⇒ 尚未发布的新号,老老实实走兜底。**正确性优先于那点延迟。**
|
|
132
|
+
- 两个守卫的**顺序不能反**:`max_id` 比 `buckets` 精确(后者只到千位)。
|
|
133
|
+
实测踩过:buckets=183 / max_id=182302 时,id=0187302 的 bucket=187 已越界,
|
|
134
|
+
但它只是「比最大号大 5000」的新号 —— 先走 buckets 守卫就被误杀了。
|
|
135
|
+
- 生成器侧加了**条数对账**(`n_rec + other != len(rows)` 直接抛错)——
|
|
136
|
+
少一条就是一个 id 永远查不到,这种丢失是静默的,必须在生成时拦住。
|
|
137
|
+
- 顺带加了 idmap 残留片清理(dedup 真删实体 id 时,旧的高位片会被 manifest 哈希并上传,
|
|
138
|
+
白占流量与仓库体积)。
|
|
139
|
+
|
|
140
|
+
## 本次更新(v1.5.0,已发布)
|
|
141
|
+
**`get_vendor` 从 4.5 分钟降到 6 秒(45×)。** 起因是外部实测反馈「HTTP 模式比本地 git 模式慢约 100 倍,
|
|
142
|
+
按 id 取一家企业要 270 秒」。定位结论:慢不在计算,而在**为了找一个 id 而下载整张 18.4 MB 的
|
|
143
|
+
`name-index.jsonl`**——id 只是其中一行。
|
|
144
|
+
|
|
145
|
+
- **idmap 定址分片**:新增 `skills/registry/index/idmap/`,`id → 国标码` 按 id 千位切片(183 片 × 1000 id,
|
|
146
|
+
命中那片约 20 KB)。片名由 id 直接算出(`CN-MFG-0093680` → `93680 // 1000` → `093.jsonl`),
|
|
147
|
+
**不需要目录表、不需要额外请求**。
|
|
148
|
+
- **零 404 的开关设计**:idmap 的元信息放在 `manifest.metadata.idmap` 里——清单本来就要拉,所以是零额外请求。
|
|
149
|
+
清单里**没有**这个块 = 该快照尚未发布 idmap → 整体走老路径。这样「MCP 先升级、数据后发布」的窗口期
|
|
150
|
+
不会变成满屏 404(实测过:一次 404 的冷启动兜底要 44 秒)。
|
|
151
|
+
- **按内容哈希缓存分片**:清单里每片都登记了内容 sha1,用它当缓存键——内容不变则**完全不走网络**,
|
|
152
|
+
取到后顺手校验,不符就不写缓存。
|
|
153
|
+
- **zh 详情分片改并发取**;**HTTP 超时 15→30 s**;**显式声明 gzip 并手动解压**
|
|
154
|
+
(urllib 不会自动解压,JSON 压缩 6~20×,此前一直是裸传)。
|
|
155
|
+
- **实测**(`scripts/bench_get_vendor.py`,缓存目录强制隔离):
|
|
156
|
+
|
|
157
|
+
| 场景 | 冷启动 | 复用缓存 |
|
|
158
|
+
|------|--------|----------|
|
|
159
|
+
| 线上 CDN(本次前) | 270.6 s | — |
|
|
160
|
+
| 线上 CDN(本次后) | **5.95 s** | **1.22 s** |
|
|
161
|
+
| 本地快照 | 1.77 s | 0.80 s |
|
|
162
|
+
|
|
163
|
+
- **恢复 `mcp/test_protocol.py`**:上一个提交误删了发布前必跑的协议回归测试,本次还原(313 行)。
|
|
164
|
+
发布前实测三条链路 **16/16 通过**(HTTP / stdio / 标准分帧适配层 + 死代理容错)。
|
|
165
|
+
- 数据侧(`skills/registry/index/idmap/**` 与含 `metadata.idmap` 的 `data/manifest.json`)已同步发布到 Pages,
|
|
166
|
+
旧版 MCP 不受影响(读不到 idmap 块就走老路径)。
|
|
167
|
+
|
|
168
|
+
## 本次更新(v1.4.2,已发布)
|
|
169
|
+
- **自动升级(自更新)**:新增 `bin/selfupdate.js`,MCP 启动时以**独立 detached 子进程**拉起(绝不阻塞 MCP 启动),
|
|
170
|
+
节流查 npm registry `latest`(默认 24h 一次,可用 `BEACON_MCP_UPDATE_INTERVAL_H` 调),发现新版本即执行
|
|
171
|
+
`npm i -g beacon-mfg-mcp@<新版本>`(Windows 上正确走 `npm.cmd + shell`)。
|
|
172
|
+
- **跳过条件**:设 `BEACON_MCP_NO_UPDATE=1`、或已设 `BEACON_SOURCE` / `BEACON_REPO`(离线 / 本地仓库用户)时**不联网升级**,避免对本地仓库用户添乱。
|
|
173
|
+
- **并发保护**:原子目录锁 + 残留锁 10 分钟过期自愈(踩过死锁坑——残留锁会让之后所有启动永久跳过自更新)。
|
|
174
|
+
- **测试**:`test_update.js` 单元测试 23/23 通过,并以 fake-npm 拦截验证安装命令形态正确(`npm.cmd i -g beacon-mfg-mcp@1.4.2`)。
|
|
175
|
+
- **前向生效说明**:自更新是「向前生效」的——已装的 ≤1.4.1 旧版代码里没有 `selfupdate.js`,需**一次性手动** `npm i -g beacon-mfg-mcp@latest` 进入自动升级基线;之后永久自动。`npx beacon-mfg-mcp` 用户天然永远最新。
|
|
176
|
+
|
|
177
|
+
## 本次更新(v1.4.1,已发布)
|
|
178
|
+
- **直连优先 + 死代理容错**:修复「失效代理导致检索归零」——默认源不可达时直连兜底镜像,且报错信息直指真因(是代理挂了而非数据问题),`npm i -g` 装一次即享受(无需重启客户端)。
|
|
179
|
+
|
|
180
|
+
## 本次更新(v1.4.0,已发布)
|
|
181
|
+
- **标准分帧适配层 `mcp_adapter.py`**:补齐「严格分帧」客户端接入路径,与「换行 stdio」「Streamable HTTP」三条传输链路统一。
|
|
182
|
+
- **协议回归测试 `test_protocol.py`**:一条命令覆盖三条链路(握手 + 6 工具齐全 + 三城市真实检索 `via_index`),发版前必跑。
|
|
183
|
+
|
|
184
|
+
## 本次更新(v1.3.3,已发布)
|
|
185
|
+
- **本地检索免提交、免重启即时可见**:这是 v1.3.1「worktree 兜底」的收尾。v1.3.1 只让**单文件读**走了工作树,
|
|
186
|
+
但关键词/城市检索的**召回**走的是**批量读** `_read_many_text()`——它在 git 模式下恒走 `git archive HEAD`
|
|
187
|
+
(已提交快照),于是**改完数据、重建索引后,关键词检索仍读旧索引、必须 commit 才可见**。
|
|
188
|
+
本次把批量读也改为「**工作树优先 → `git archive HEAD` 兜底 → HTTP**」。
|
|
189
|
+
- **进程内 + 磁盘缓存按文件新鲜度失效**:新增 `_wt_mtime` / `_cache_stale`,各 loader
|
|
190
|
+
(manifest / 别名 / 国标名表 / cap 索引 / city / 索引桶 / fp 分片 / 能力词)改为
|
|
191
|
+
**按工作树文件 mtime 判新**——派生层一重建,MCP 立刻重读,**无需重启进程**;
|
|
192
|
+
磁盘索引缓存(`idx-*`)加 **mtime 印记**(`repr` 精确往返),同秒重写也能识别。
|
|
193
|
+
- **可选 TTL**:`BEACON_CACHE_TTL`(秒,默认 `0`=关闭)可让进程内缓存周期性刷新;
|
|
194
|
+
非工作树模式(`BEACON_WORKTREE=0`)保持旧的「提交后 / 重启后生效」行为不变。
|
|
195
|
+
- 已用**真实注入实验**验证:单进程、不 commit、只 sleep,`data/gb` 新记录经本地重建后
|
|
196
|
+
关键词检索由 `0` 命中变 `1` 命中。
|
|
197
|
+
|
|
198
|
+
## 本次更新(v1.3.2,已发布)
|
|
199
|
+
- **npm 启动器锁定 UTF-8**:`bin/beacon-mfg-mcp.js` 拉起 python 时注入 `PYTHONUTF8=1` / `PYTHONIOENCODING=utf-8`,
|
|
200
|
+
修复中文 Windows 上 python 按 GBK 写 stdout、被客户端按 UTF-8 解码导致的工具描述/结果乱码。
|
|
201
|
+
- **HTTP 兜底镜像**:默认源 `beacon-mfg.pages.dev` 不可达时,自动回退到 GitHub 镜像
|
|
202
|
+
`cdn.jsdelivr.net/gh/eiry16/beacon-mfg@main` 与 `raw.githubusercontent.com/eiry16/beacon-mfg/main`
|
|
203
|
+
(与 Android App 同策略),保证「免 clone 检索」在单一 CDN 失效时仍可工作;可用 `BEACON_MIRRORS` 追加。
|
|
204
|
+
- **serverInfo 版本对齐**:服务端自报版本改为读 `package.json`,不再硬编码,消除与 npm 包版本漂移。
|
|
205
|
+
|
|
206
|
+
## 本次更新(v1.3.1,已发布)
|
|
207
|
+
- **npm 包自带 rfq-kernel**:`prepack` 会把 `skills/rfq-kernel` 打进包内,因此 `npm i -g beacon-mfg-mcp`
|
|
208
|
+
安装后 6 个 tool 全部可用(早于此版本的 npm 包不含该桥接,3 个寻源 tool 会降级)。
|
|
209
|
+
GitHub Release tarball 同步包含 `rfq-kernel/`。
|
|
210
|
+
- **闭合检索缺口**:本地 git 模式默认 `BEACON_WORKTREE=1`,优先读工作树文件(含未提交/刚修改的数据),
|
|
211
|
+
仅在文件缺失或单对象 JSON 解析失败(半成品)时退回 `git show HEAD`,再否则走 HTTP。
|
|
212
|
+
设 `BEACON_WORKTREE=0` 可恢复「只读已提交快照」的严格行为。
|
|
213
|
+
|
|
214
|
+
## 安装(三选一)
|
|
215
|
+
|
|
216
|
+
### 1. 从 GitHub Release 下载(推荐,零依赖)
|
|
217
|
+
下载本 Release 的 `beacon-mfg-mcp-mcp-v1.5.3.tar.gz`,解压后:
|
|
218
|
+
```jsonc
|
|
219
|
+
{
|
|
220
|
+
"mcpServers": {
|
|
221
|
+
"beacon-mfg": {
|
|
222
|
+
"command": "python",
|
|
223
|
+
"args": ["/解压目录/server.py"],
|
|
224
|
+
"env": { "BEACON_REPO": "/你的/beacon-mfg仓库路径" } // 可选:离线 + 隐私安全
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
### 2. npm(需先 `npm login`,仓库已配 `NPM_TOKEN` 后自动发布)
|
|
231
|
+
```bash
|
|
232
|
+
npm install -g beacon-mfg-mcp
|
|
233
|
+
# 或一次性运行:
|
|
234
|
+
npx beacon-mfg-mcp
|
|
235
|
+
```
|
|
236
|
+
装好后客户端配置:
|
|
237
|
+
```jsonc
|
|
238
|
+
{
|
|
239
|
+
"mcpServers": {
|
|
240
|
+
"beacon-mfg": { "command": "beacon-mfg-mcp", "args": [], "env": { "PYTHONUTF8": "1", "PYTHONIOENCODING": "utf-8" } }
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### 3. 从源码(git clone)
|
|
246
|
+
```bash
|
|
247
|
+
git clone https://github.com/eiry16/beacon-mfg.git
|
|
248
|
+
# 客户端 command 指向 beacon-mfg/mcp/server.py
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
## 环境变量
|
|
252
|
+
- `BEACON_REPO`:本地 git 仓库路径;设了就用 `git show HEAD:` + 工作树 读(推荐:离线 + 掩码态隐私安全)。
|
|
253
|
+
- `BEACON_SOURCE`:HTTP 基址,默认 `https://beacon-mfg.pages.dev`(可改自定义域名)。
|
|
254
|
+
- `BEACON_WORKTREE`:本地模式是否优先读工作树(默认 `1`;设 `0` 恢复「只读已提交快照」)。
|
|
255
|
+
- `BEACON_MIRRORS`:HTTP 模式追加的兜底镜像基址(逗号分隔);默认源不可达时除内置的 jsDelivr/raw 外,可在此追加自有镜像。
|
|
256
|
+
- `HTTPS_PROXY` / `HTTP_PROXY`:python 子进程的代理;在中国大陆等直连 GitHub 困难的网络,设 `HTTPS_PROXY=http://127.0.0.1:7890` 可让镜像链路走代理。
|
|
257
|
+
|
|
258
|
+
## 许可
|
|
259
|
+
MIT
|
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 是跨门类能力、
|
|
@@ -1137,13 +1401,35 @@ def _idmap_lookup(vid: str) -> Tuple[Optional[str], bool]:
|
|
|
1137
1401
|
客户端必须照样按空串走 _unclassified 那条路,而不是当成「没找到」)。
|
|
1138
1402
|
shard_ok = 该片的**范围已被覆盖** → 此时 id 缺席可判定为「不存在」,
|
|
1139
1403
|
调用方不必再去付 18.4 MB / 320 个分片的兜底代价。
|
|
1404
|
+
|
|
1405
|
+
⚠ 「片里没有」要**先过 max_id 这道闸**才能判不存在。抓取流水线与政府信源
|
|
1406
|
+
导入每天都在发新号,而 idmap 只在 manifest 步重算、只在 pages 步上云 ——
|
|
1407
|
+
于是「刚导入、还没发布」的新 id 天然不在已发布的 idmap 里。
|
|
1408
|
+
若只看「片存在但片里没这条」就判不存在,等于**把新导入的企业全部误杀**
|
|
1409
|
+
(政府名录一次能进上千家,症状还是静默的:查不到,不报错)。
|
|
1410
|
+
判据:id 数字 <= meta.max_id 才敢判「真不存在」;超过就退回慢路径兜底。
|
|
1140
1411
|
"""
|
|
1141
1412
|
meta = _idmap_meta()
|
|
1142
1413
|
nb = meta.get("buckets")
|
|
1143
1414
|
if not isinstance(nb, int) or nb <= 0:
|
|
1144
1415
|
return None, False # 快照里没有 idmap(尚未发布)→ 调用方按老路径走
|
|
1145
1416
|
|
|
1146
|
-
#
|
|
1417
|
+
# 已发布的最大 id 数字。老版本快照没有这个字段 → 退化成「只能靠 buckets 守卫」,
|
|
1418
|
+
# 此时新 id 会被误判,所以**取不到就整体不走 idmap**(宁可慢,不可错)。
|
|
1419
|
+
mx = meta.get("max_id")
|
|
1420
|
+
if not isinstance(mx, int) or mx < 0:
|
|
1421
|
+
return None, False
|
|
1422
|
+
|
|
1423
|
+
# ⚠ 两个守卫的**顺序不能反**。
|
|
1424
|
+
# max_id 是比 buckets 更精确的上界(buckets 只精确到千位):
|
|
1425
|
+
# 例如 buckets=183 / max_id=182302 时,id=0187302 的 bucket=187 已越界,
|
|
1426
|
+
# 但它其实只是「比已发布最大号大 5000」的新号 —— 若先走 buckets 守卫就会
|
|
1427
|
+
# 被判成不存在(误杀)。所以先用 max_id 收口,再谈 buckets。
|
|
1428
|
+
m_vid = _VENDOR_ID_RE.match(vid or "")
|
|
1429
|
+
if m_vid is not None and int(m_vid.group(1)) > mx:
|
|
1430
|
+
return None, False # 尚未发布的新号 → 交回调用方走兜底
|
|
1431
|
+
|
|
1432
|
+
# 范围守卫:连千位片都还没生成 = 远在未来的号,直接判定,
|
|
1147
1433
|
# 不去请求那个必定 404 的分片。
|
|
1148
1434
|
b = _idmap_bucket(vid)
|
|
1149
1435
|
if b is not None and b >= nb:
|
|
@@ -1614,9 +1900,17 @@ def _read_many_text(paths: List[str]) -> Dict[str, str]:
|
|
|
1614
1900
|
got = _git_read_many(paths)
|
|
1615
1901
|
if len(got) >= max(1, len(paths) // 2): # archive 正常覆盖
|
|
1616
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() # 预热:别让线程池里做首次构建
|
|
1617
1911
|
out: Dict[str, str] = {}
|
|
1618
1912
|
with concurrent.futures.ThreadPoolExecutor(max_workers=16) as ex:
|
|
1619
|
-
for path, txt in zip(paths, ex.map(
|
|
1913
|
+
for path, txt in zip(paths, ex.map(_shard_text, paths)):
|
|
1620
1914
|
if txt:
|
|
1621
1915
|
out[path] = txt
|
|
1622
1916
|
return out
|
|
@@ -1964,12 +2258,12 @@ def search_vendors(query: str = "", city: str = "", gb: str = "",
|
|
|
1964
2258
|
if gen_tokens and not all(tok in _hay(rec).lower() for tok in gen_tokens):
|
|
1965
2259
|
continue
|
|
1966
2260
|
matches.append(_rec_summary(rec))
|
|
1967
|
-
return {
|
|
2261
|
+
return _attach_routing_hint({
|
|
1968
2262
|
"total_matched": len(matches),
|
|
1969
2263
|
"returned": len(matches[offset:offset + limit]),
|
|
1970
2264
|
"shards_scanned": scanned,
|
|
1971
2265
|
"results": matches[offset:offset + limit],
|
|
1972
|
-
}
|
|
2266
|
+
}, q, city)
|
|
1973
2267
|
|
|
1974
2268
|
# 未给国标码:优先走「预构建倒排索引 → 只拉命中分片」(O(命中量))。
|
|
1975
2269
|
# 索引缺失或陈旧时回退进程内全量索引(O(总量),慢但结果等价)。
|
|
@@ -1978,13 +2272,13 @@ def search_vendors(query: str = "", city: str = "", gb: str = "",
|
|
|
1978
2272
|
|
|
1979
2273
|
if via_index and not cands and not alias_paths and not cap_hits:
|
|
1980
2274
|
# 索引明确判定无命中、别名也没给方向 —— 无需拉任何分片
|
|
1981
|
-
return {
|
|
2275
|
+
return _attach_routing_hint({
|
|
1982
2276
|
"total_matched": 0,
|
|
1983
2277
|
"returned": 0,
|
|
1984
2278
|
"shards_scanned": 0,
|
|
1985
2279
|
"via_index": True,
|
|
1986
2280
|
"results": [],
|
|
1987
|
-
}
|
|
2281
|
+
}, q, city)
|
|
1988
2282
|
|
|
1989
2283
|
matches: List[Dict[str, Any]] = []
|
|
1990
2284
|
seen: set = set()
|
|
@@ -2080,7 +2374,7 @@ def search_vendors(query: str = "", city: str = "", gb: str = "",
|
|
|
2080
2374
|
out["cap_expanded"] = [{"word": h["word"], "cap": h["cap"], "name": h["name"]}
|
|
2081
2375
|
for h in cap_hits]
|
|
2082
2376
|
out["cap_records_seen"] = cap_scanned
|
|
2083
|
-
return out
|
|
2377
|
+
return _attach_routing_hint(out, q, city)
|
|
2084
2378
|
|
|
2085
2379
|
|
|
2086
2380
|
def _pick(*vals: Any) -> Any:
|
|
@@ -2191,10 +2485,43 @@ def get_vendor(vid: str, gb: str = "") -> Dict[str, Any]:
|
|
|
2191
2485
|
return {"error": f"找不到 id={vid} 对应的国标码,可能该记录尚未发布"}
|
|
2192
2486
|
|
|
2193
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
|
+
|
|
2194
2511
|
def get_capability_card(vid: str) -> Dict[str, Any]:
|
|
2195
2512
|
vid = (vid or "").strip()
|
|
2196
2513
|
if not vid:
|
|
2197
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
|
+
}
|
|
2198
2525
|
rel = f"skills/registry/capability/{vid}.json"
|
|
2199
2526
|
txt = fetch_text(rel)
|
|
2200
2527
|
if not txt:
|
|
@@ -2279,7 +2606,11 @@ TOOLS = [
|
|
|
2279
2606
|
{
|
|
2280
2607
|
"name": "search_vendors",
|
|
2281
2608
|
"description": "检索灯塔工厂供应商名录。可按关键词(企业名/工艺/材料/认证)、城市、国标码(GB/T 4754)过滤。"
|
|
2282
|
-
"返回精简档案(id/企业名/城市/国标码/认证等级/工艺/材料/认证/是否含电话)。"
|
|
2609
|
+
"返回精简档案(id/企业名/城市/国标码/认证等级/工艺/材料/认证/是否含电话)。"
|
|
2610
|
+
"⚠ 关键词是**字面子串**匹配:用户的口语词/采购词若与名录写法不同(如「机加工」"
|
|
2611
|
+
"vs 归档用的「机械零部件加工」),命中会极少。此时不要直接报「没有」——"
|
|
2612
|
+
"看返回体里的 hint / suggested_gb,或先调 suggest_filters 拿到候选码,"
|
|
2613
|
+
"再用 gb=<码> 重试(召回通常多几十倍,且只扫对应分片更快)。",
|
|
2283
2614
|
"inputSchema": {
|
|
2284
2615
|
"type": "object",
|
|
2285
2616
|
"properties": {
|
|
@@ -2309,6 +2640,8 @@ TOOLS = [
|
|
|
2309
2640
|
{
|
|
2310
2641
|
"name": "get_capability_card",
|
|
2311
2642
|
"description": "按供应商 id 取能力卡(工艺位/设备/产能/认证/起订量等)。"
|
|
2643
|
+
"全库仅少数供应商有已发布能力卡,无卡的会秒回 has_card=false"
|
|
2644
|
+
"(不必重试)。"
|
|
2312
2645
|
"无已发布卡片时返回 has_card=false 及原因说明。",
|
|
2313
2646
|
"inputSchema": {
|
|
2314
2647
|
"type": "object",
|
|
@@ -2362,6 +2695,36 @@ TOOLS = [
|
|
|
2362
2695
|
"required": ["session_id"],
|
|
2363
2696
|
},
|
|
2364
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
|
+
},
|
|
2365
2728
|
]
|
|
2366
2729
|
|
|
2367
2730
|
|
|
@@ -2483,6 +2846,13 @@ def _dispatch_inner(name: str, args: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
2483
2846
|
return get_vendor(args.get("id", ""), args.get("gb", ""))
|
|
2484
2847
|
if name == "get_capability_card":
|
|
2485
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))
|
|
2486
2856
|
if name == "start_sourcing":
|
|
2487
2857
|
return start_sourcing(args.get("demand_text", ""), args.get("audience_id", "domestic_downstream"))
|
|
2488
2858
|
if name == "answer_sourcing":
|