beacon-mfg-mcp 1.8.0 → 1.10.0
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/RELEASE.md +150 -100
- package/package.json +1 -1
- package/server.py +345 -15
package/RELEASE.md
CHANGED
|
@@ -1,100 +1,150 @@
|
|
|
1
|
-
# Beacon-MFG 只读 MCP 服务 · v1.
|
|
2
|
-
|
|
3
|
-
面向 Agent 的供应商名录检索服务:**只读**,不参与交易、不写入任何数据、不持有密钥。
|
|
4
|
-
|
|
5
|
-
## 本次更新(v1.
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
`
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
1
|
+
# Beacon-MFG 只读 MCP 服务 · v1.10.0
|
|
2
|
+
|
|
3
|
+
面向 Agent 的供应商名录检索服务:**只读**,不参与交易、不写入任何数据、不持有密钥。
|
|
4
|
+
|
|
5
|
+
## 本次更新(v1.10.0)
|
|
6
|
+
|
|
7
|
+
**一句话:Agent 终于能「把人带到正确的入口」——新增 `vendor_onboarding`,
|
|
8
|
+
用户说「我是某某公司的、想入驻」时,Agent 不再手足无措 —— 但**仍然不代填**。
|
|
9
|
+
|
|
10
|
+
- **新增工具 `vendor_onboarding`**(第 10 个工具):返回一个可直接点开的控制台
|
|
11
|
+
链接(`/console/?id=CN-xxx` 深链)+ 分步说明 + 明确的边界清单。
|
|
12
|
+
`intent` 三档:`claim`(认领已收录企业)/ `register`(库里没有、新建档)/
|
|
13
|
+
`status`(查审核进度)。
|
|
14
|
+
传了 `supplier_id` 会自动带出公司名,Agent 可以说「帮您找到 XX 公司,从这里认领」,
|
|
15
|
+
而不是甩一个 ID 让人自己去搜。
|
|
16
|
+
|
|
17
|
+
- **为什么只给链接、不代填(这是设计,不是没做完)**:
|
|
18
|
+
认领要过短信验证码与法人手机核验。如果让用户把验证码念给 Agent,等于把
|
|
19
|
+
「证明我是这家企业」的凭证交给第三方,还会落进 LLM 上下文与日志。
|
|
20
|
+
工具返回值里直接写了 `boundary` / `what_not_to_do`,约束 Agent 的说法:
|
|
21
|
+
不代办、不索取验证码、不承诺审核结果、不把未认领企业说成「已经属于你」。
|
|
22
|
+
|
|
23
|
+
- **顺带说明平台侧的这次改动**(影响 Agent 该怎么讲):
|
|
24
|
+
认领不再「填个公开的信用代码就算你的」。`/claim/confirm` 现在只提交申请
|
|
25
|
+
(`status=pending`、`can_add_skill=false`),人工复核通过才真正认领。
|
|
26
|
+
所以 Agent **不要**在提交后说「认领成功了」,只能说「申请已提交,等审核」。
|
|
27
|
+
|
|
28
|
+
- 兼容性:仅新增工具与文案,原有 9 个工具的参数、返回结构**无变化**;
|
|
29
|
+
老客户端不受影响。
|
|
30
|
+
|
|
31
|
+
## 本次更新(v1.9.0,已发布)
|
|
32
|
+
|
|
33
|
+
**一句话:坐标检索接入 MCP——「青创港附近的便利店」这类问法,先解析坐标再传
|
|
34
|
+
`lat/lng`,结果按距离排;给 `radius_km` 就只留圈内。地标名解析归调用方
|
|
35
|
+
(MCP 不持地图 API),距离口径归 server。**
|
|
36
|
+
|
|
37
|
+
- **新参数 `lat` / `lng` / `radius_km`**(`search_vendors`):数据来自指纹层
|
|
38
|
+
98.5% 坐标覆盖(2026-10-22 坐标下沉改造的产出),无需重建索引。
|
|
39
|
+
- 只传 `lat/lng`:**距离为第一排序键**(等距才按召回纯度),每条结果带
|
|
40
|
+
`distance_km`(球面直线距离,非路程);无坐标记录 `distance_km=null` 排末位。
|
|
41
|
+
- 加 `radius_km`(上限 500):圈外与无坐标记录剔除——无坐标 = 无法证明在圈内,
|
|
42
|
+
与项目「宁可留空不编造」同源;`total_matched` 变为圈内数,被剔除数见
|
|
43
|
+
`geo_filtered_out`;圈内按召回纯度分档、档内距离升序。
|
|
44
|
+
- 连锁品牌行报**最近门店**距离(`_collapse_brands` 透传),不是代表店。
|
|
45
|
+
- **参数校验全部显式报错**(不静默忽略):只传 lat/lng 之一、经纬度顺序写反
|
|
46
|
+
(超出中国范围 lat 3~54 / lng 73~136)、`radius_km<=0` 或超 500、非数字——
|
|
47
|
+
坐标查询的失败模式必须是显式的,否则 agent 会把「按全城排的结果」当成
|
|
48
|
+
「按距离排的结果」交付。
|
|
49
|
+
- **0 条时的 hint 换了说法**:圈内 0 条不再建议「改查 gb= 重试」(换什么码圈内
|
|
50
|
+
都还是没有),改为明示「检索命中 N 条但都超出半径/无坐标」,并要求 agent
|
|
51
|
+
**不要对用户说「这一带没有」**——圈内无收录 ≠ 这一带真的没有。
|
|
52
|
+
- 返回体新增 `geo` 回显(lat/lng/radius_km/records_with_coords),agent 可核对
|
|
53
|
+
服务端确实按所给坐标处理;`order_note` 动态追加坐标口径说明。
|
|
54
|
+
|
|
55
|
+
## 本次更新(v1.8.0,已发布)
|
|
56
|
+
|
|
57
|
+
**一句话:口语词不再对不上字——「律所」「饭店」「保洁」直接命中,完全没听过的词
|
|
58
|
+
也会收到货架初筛候选,语义归一这一步正式交给 LLM,但候选来源归 server。**
|
|
59
|
+
|
|
60
|
+
- **L1 高频俗称表**(+151 条):律所→7231 律师及相关法律服务、饭店→6210 正餐服务、
|
|
61
|
+
奶茶→6231/6232、保洁公司→8010/8219、开锁→8290……覆盖餐饮、居民服务、房产、医疗、
|
|
62
|
+
金融、法律、制造、软件、运输、零售、文娱、专业服务十二个方向。每条码都经
|
|
63
|
+
`gb-index.json` 校验 `records>0`——货架上没有的类不上表。
|
|
64
|
+
- **L2 类名机械简称**(+937 条):由 `scripts/gen_class_abbr.py` 从 GB/T 4754 全量类名
|
|
65
|
+
切短生成(法律服务、正餐、快递、网吧、歌舞厅……),作为 alias 机制第三层
|
|
66
|
+
(data > curated > abbr,setdefault 保序,冲突自动让位前两层)。
|
|
67
|
+
- **L3 零命中货架兜底**(`_shelf_recall`):查询词与名录写法完全对不上字时,返回体
|
|
68
|
+
`suggested_gb` 直接带上按 bigram 重合度初筛的货架候选(全灭档启用类名首字单字回溯),
|
|
69
|
+
附 `records` 与门类路径;agent 零额外轮次改传 `gb=`。字面零关联时给的是货架 records
|
|
70
|
+
最高榜,why 字段明码标注「仅供语义参考,不对味就如实告诉用户没收录」。
|
|
71
|
+
- **性能修复(线上已部署)**:Pages 索引落后于数据导致每次查询回退全量扫 460 个分片
|
|
72
|
+
(实测 153.5 秒),已重跑部署修复;索引新鲜时查询回到 0.2~1 秒。
|
|
73
|
+
- **部署洁净度**:`data/` 下一级 `_` 开头的内部产物(剪枝备份等,313 MiB)不再打进
|
|
74
|
+
Pages 发布面。
|
|
75
|
+
|
|
76
|
+
## 本次更新(v1.7.0,已发布)
|
|
77
|
+
|
|
78
|
+
**一句话:从「工厂名录」升级为「全部国标行业门类的主体名录」——服务业、
|
|
79
|
+
流通业主体现在是一等公民,不再被当成「非制造商」反向排除。**
|
|
80
|
+
|
|
81
|
+
- **新增 `etype` 参数**(`生产` / `流通` / `服务`),替换已下线的 `manufacturer_only`。
|
|
82
|
+
按国标门类推导:生产 = A/B/C/D/E(含采矿、电力、建筑),流通 = F/G,服务 = H~S。
|
|
83
|
+
旧的布尔开关在全行业口径下会把采矿/电力/建筑这些生产型主体也排掉,所以直接下线,
|
|
84
|
+
不做兼容层;传非法值返回 `bad_etype` 而不是静默忽略。
|
|
85
|
+
- **修复 `etype` 只筛计数不筛结果**:别名召回与能力召回两条路径绕过了统一过滤点,
|
|
86
|
+
表现为「`total_matched` 跟着 etype 变、`results` 却原样返回」。
|
|
87
|
+
- **能力命中提到首档排序**:查询词是能力别名时(「医院」「银行」「药店」「物流」),
|
|
88
|
+
主体本身干这个的排在「名字里恰好有这个词」的前面。此前搜「医院」前 10 条全是
|
|
89
|
+
「XX酒店(市人民医院店)」。
|
|
90
|
+
- **修复一词多码被单域抢占**:`物流` 原来只映射到零售域的 `rdm_freight`,物流服务域的
|
|
91
|
+
`log_freight` 永不命中;改为一词多码后按域并集召回。
|
|
92
|
+
- **`_name_fit` 增加连片命中分档**:关键词在品牌名里连续出现(「贵州启谋法律顾问部」)
|
|
93
|
+
得 2 分,散落命中得 1 分,缓解同名门店压过本体的老问题。
|
|
94
|
+
- 数据侧同步覆盖到 B/D/G/J/K/L/N/Q 八个原本空白门类(采矿、电力热力、住宿餐饮之外
|
|
95
|
+
的交通运输、信息服务、金融、租赁商务、科研技术、水利环境、居民服务等)。
|
|
96
|
+
|
|
97
|
+
## 本次更新(v1.6.0,已发布)
|
|
98
|
+
|
|
99
|
+
- **新增 `account` 工具**(`status` / `request` / `confirm` / `logout`):匿名档查额度、
|
|
100
|
+
绑手机号升 `verified`、解绑回匿名。此前没有任何升级入口,verified 档等于空档。
|
|
101
|
+
- **新增 CLI**:`npx beacon-mfg-mcp login | status | logout`。MCP 客户端里能用工具,
|
|
102
|
+
终端里也能自助——两条路并存,覆盖「还没接 agent 就先用起来」的场景。
|
|
103
|
+
- **修复**:所有身份请求现在显式带 `User-Agent`。此前 urllib 默认 UA 会被 Cloudflare
|
|
104
|
+
以 `error code 1010` 拒绝,且失败是**静默降级**的,看起来像「网络不好」。
|
|
105
|
+
- **修复**:`identity.py` 补进 `package.json` 的 `files`。此前 npm 包里根本没有这个文件,
|
|
106
|
+
装完即降级为「无身份」——服务端开强制鉴权后所有 npx 用户会集体 401。
|
|
107
|
+
- 服务端同步:匿名 200 次 / verified 2000 次(滚动 24h),L2 能力卡另有 300 条/日上限。
|
|
108
|
+
|
|
109
|
+
## 九个 tool(8 只读 + 1 身份自助)
|
|
110
|
+
|
|
111
|
+
| tool | 用途 |
|
|
112
|
+
|---|---|
|
|
113
|
+
| `search_vendors` | 按关键词 / 城市 / 国标码检索 |
|
|
114
|
+
| `get_vendor` | 按 id 取完整档案 |
|
|
115
|
+
| `get_capability_card` | 按 id 取能力卡 |
|
|
116
|
+
| `suggest_filters` | 把口语需求映射成候选国标小类码 |
|
|
117
|
+
| `list_industries` | 浏览有数据的国标小类货架 |
|
|
118
|
+
| `start_sourcing` / `answer_sourcing` / `refine_sourcing` | 多轮采购寻源 |
|
|
119
|
+
| `account` | 查额度 / 绑手机号升 verified / 解绑(`action`) |
|
|
120
|
+
|
|
121
|
+
## 安装
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
npm i -g beacon-mfg-mcp
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Windows 客户端配置需经 `cmd /c`:
|
|
128
|
+
|
|
129
|
+
```jsonc
|
|
130
|
+
{ "mcpServers": { "beacon-mfg": { "command": "cmd", "args": ["/c", "beacon-mfg-mcp"] } } }
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
macOS / Linux:
|
|
134
|
+
|
|
135
|
+
```jsonc
|
|
136
|
+
{ "mcpServers": { "beacon-mfg": { "command": "beacon-mfg-mcp", "args": [] } } }
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
也可直接用本 Release 的 tarball 解压运行:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
python server.py # stdio
|
|
143
|
+
python http_server.py # Streamable HTTP,端点 /mcp
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
数据默认取自公开数据源,无需密钥、无需克隆仓库。
|
|
147
|
+
|
|
148
|
+
## 本次更新
|
|
149
|
+
|
|
150
|
+
- 稳定性与接入体验改进。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "beacon-mfg-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"description": "Read-only MCP server exposing the BeaconMFG灯塔 directory of business entities across ALL GB/T 4754 industry divisions (manufacturing, services, retail, logistics, catering, IT, and more) to any MCP-compatible agent. No signup and no API key: an anonymous identity is generated on first run, phone binding is optional (only needed for L2 capability cards).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
package/server.py
CHANGED
|
@@ -289,9 +289,9 @@ def _pkg_version() -> str:
|
|
|
289
289
|
try:
|
|
290
290
|
p = os.path.join(os.path.dirname(os.path.abspath(__file__)), "package.json")
|
|
291
291
|
with open(p, "r", encoding="utf-8") as f:
|
|
292
|
-
return json.load(f).get("version", "1.
|
|
292
|
+
return json.load(f).get("version", "1.10.0")
|
|
293
293
|
except Exception:
|
|
294
|
-
return "1.
|
|
294
|
+
return "1.10.0"
|
|
295
295
|
|
|
296
296
|
|
|
297
297
|
# HTTP 基址列表(含兜底镜像)。默认部署 Cloudflare Pages 不可达时,自动回退到
|
|
@@ -1388,6 +1388,58 @@ def _shelf_recall(q: str, limit: int = 5) -> List[Dict[str, Any]]:
|
|
|
1388
1388
|
return out
|
|
1389
1389
|
|
|
1390
1390
|
|
|
1391
|
+
def _geo_rank_key(x: Dict[str, Any]) -> tuple:
|
|
1392
|
+
"""距离排序键:无坐标记录恒排档内末位(None 不参与比较,用 (1, 0.0) 占位)。"""
|
|
1393
|
+
d = x.get("distance_km")
|
|
1394
|
+
return (1, 0.0) if d is None else (0, d)
|
|
1395
|
+
|
|
1396
|
+
|
|
1397
|
+
def _geo_apply(matches: List[Dict[str, Any]], lat_v: Optional[float],
|
|
1398
|
+
lng_v: Optional[float], rad: Optional[float]) -> tuple:
|
|
1399
|
+
"""给结果算球面距离;rad 非空时按圈过滤。
|
|
1400
|
+
|
|
1401
|
+
返回 (保留列表, 圈外剔除数, 有坐标条数)。
|
|
1402
|
+
|
|
1403
|
+
无坐标记录在 rad 模式下**剔除** —— 无法证明在圈内,就是不在(与项目
|
|
1404
|
+
「宁可留空不编造」同源:拿 1.5% 的空值去猜「应该在圈内」,用户按导航
|
|
1405
|
+
过去发现是隔壁市,比没有结果更糟)。只排序模式(rad=None)下保留、
|
|
1406
|
+
distance_km=None、排档内末位。
|
|
1407
|
+
|
|
1408
|
+
调用时机:分页**之前**(与 _collapse_brands 同理,见该函数注释)。
|
|
1409
|
+
"""
|
|
1410
|
+
if lat_v is None:
|
|
1411
|
+
return matches, 0, 0
|
|
1412
|
+
kept: List[Dict[str, Any]] = []
|
|
1413
|
+
dropped = 0
|
|
1414
|
+
n_coord = 0
|
|
1415
|
+
for m in matches:
|
|
1416
|
+
la, ln = m.get("lat"), m.get("lng")
|
|
1417
|
+
if la is None or ln is None:
|
|
1418
|
+
if rad is not None:
|
|
1419
|
+
dropped += 1
|
|
1420
|
+
continue
|
|
1421
|
+
kept.append(m)
|
|
1422
|
+
continue
|
|
1423
|
+
d = _haversine_km(lat_v, lng_v, la, ln)
|
|
1424
|
+
n_coord += 1
|
|
1425
|
+
if rad is not None and d > rad:
|
|
1426
|
+
dropped += 1
|
|
1427
|
+
continue
|
|
1428
|
+
m["distance_km"] = round(d, 2)
|
|
1429
|
+
kept.append(m)
|
|
1430
|
+
return kept, dropped, n_coord
|
|
1431
|
+
|
|
1432
|
+
|
|
1433
|
+
def _geo_note(rad: Optional[float]) -> str:
|
|
1434
|
+
"""坐标查询时追加在 order_note 后的口径说明(无坐标查询返回空串)。"""
|
|
1435
|
+
if rad is not None:
|
|
1436
|
+
return (" 本次传了坐标+半径:圈内按召回纯度分档、档内按球面距离升序;"
|
|
1437
|
+
"distance_km 为直线距离(非路程);圈外与无坐标记录已剔除,"
|
|
1438
|
+
"剔除数见 geo_filtered_out。")
|
|
1439
|
+
return (" 本次传了坐标(无半径):按球面距离升序为第一键,等距才按召回纯度;"
|
|
1440
|
+
"distance_km 为直线距离(非路程);无坐标记录 distance_km=null 排末位。")
|
|
1441
|
+
|
|
1442
|
+
|
|
1391
1443
|
def _attach_routing_hint(out: Dict[str, Any], q: str, city: str) -> Dict[str, Any]:
|
|
1392
1444
|
"""命中偏少时,把「怎么把口语词换成国标码」这条链路内联进结果。
|
|
1393
1445
|
|
|
@@ -1397,6 +1449,17 @@ def _attach_routing_hint(out: Dict[str, Any], q: str, city: str) -> Dict[str, An
|
|
|
1397
1449
|
"""
|
|
1398
1450
|
if not q or out.get("total_matched", 0) >= 5:
|
|
1399
1451
|
return out
|
|
1452
|
+
# 坐标圈内 0 条(2026-10-23):先于一切候选码建议 —— 圈内没数据时,
|
|
1453
|
+
# 「改查 gb= 重试」是纯噪音(换什么码圈内都还是没有),该说的是
|
|
1454
|
+
# 「检索命中 N 条但都不在圈内 / 无坐标」。这类信息差最容易让 agent
|
|
1455
|
+
# 对用户说出「这个区域没有便利店」这种**没有数据支撑**的结论。
|
|
1456
|
+
if out.get("geo_filtered_out"):
|
|
1457
|
+
out["hint"] = ("坐标圈内 0 条:检索本身命中 %d 条,但都超出半径或无坐标"
|
|
1458
|
+
"(无坐标记录无法证明在圈内,已剔除)。**不要对用户说"
|
|
1459
|
+
"「这一带没有」**——如实说「圈内无收录,全城/全库有 N 条,"
|
|
1460
|
+
"最近的也超出半径」。可放宽 radius_km、或去掉坐标按城市查。"
|
|
1461
|
+
% out["geo_filtered_out"])
|
|
1462
|
+
return out
|
|
1400
1463
|
try:
|
|
1401
1464
|
s = suggest_filters(q, city, limit=5)
|
|
1402
1465
|
except Exception:
|
|
@@ -2199,6 +2262,12 @@ def _collapse_brands(matches: List[Dict[str, Any]], keep_all: bool = False
|
|
|
2199
2262
|
ids = row.setdefault("branch_ids", [])
|
|
2200
2263
|
if len(ids) < 20: # 有上限:别让一条结果带 267 个 id
|
|
2201
2264
|
ids.append(m.get("id"))
|
|
2265
|
+
# 坐标查询(2026-10-23):品牌行报**最近门店**的距离,不是代表店。
|
|
2266
|
+
# 「全家 2.1km」而拐角就有一家 400m 的全家,是最容易被用户当成 bug 的
|
|
2267
|
+
# 一种错。代表行本身不动(档位/name_fit 仍按它),只把距离换成最近的。
|
|
2268
|
+
dm, dr = m.get("distance_km"), row.get("distance_km")
|
|
2269
|
+
if dm is not None and (dr is None or dm < dr):
|
|
2270
|
+
row["distance_km"] = dm
|
|
2202
2271
|
return out, len(pos)
|
|
2203
2272
|
|
|
2204
2273
|
|
|
@@ -2206,6 +2275,75 @@ def _collapse_brands(matches: List[Dict[str, Any]], keep_all: bool = False
|
|
|
2206
2275
|
_ETYPE_NAMES = {0: "", 1: "生产", 2: "流通", 3: "服务"}
|
|
2207
2276
|
|
|
2208
2277
|
|
|
2278
|
+
def _haversine_km(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
|
|
2279
|
+
"""两地球面直线距离(公里)。半径取 6371.0。
|
|
2280
|
+
|
|
2281
|
+
只用于「X 公里内」这类**排序与粗筛**,不是测绘精度 —— GCJ-02(高德)与
|
|
2282
|
+
WGS-84(GPS)坐标混用时有数十到数百米偏差,对公里级半径无影响。
|
|
2283
|
+
"""
|
|
2284
|
+
p1, p2 = math.radians(lat1), math.radians(lat2)
|
|
2285
|
+
dp = p2 - p1
|
|
2286
|
+
dl = math.radians(lng2 - lng1)
|
|
2287
|
+
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
|
|
2288
|
+
return 6371.0088 * 2 * math.asin(math.sqrt(a))
|
|
2289
|
+
|
|
2290
|
+
|
|
2291
|
+
# 中国经纬度范围(与 scripts/gen_fingerprint.py 的坐标写入校验同口径)。
|
|
2292
|
+
# 超出即视为调用方传错(常见根因:经纬度顺序写反、用了海外国标 API)。
|
|
2293
|
+
_GEO_LAT_RANGE = (3.0, 54.0)
|
|
2294
|
+
_GEO_LNG_RANGE = (73.0, 136.0)
|
|
2295
|
+
_GEO_RADIUS_MAX_KM = 500.0
|
|
2296
|
+
|
|
2297
|
+
|
|
2298
|
+
def _geo_params(lat: Any, lng: Any, radius_km: Any) -> tuple:
|
|
2299
|
+
"""坐标参数校验 → (lat_v, lng_v, radius_v)。任一不合法返回 error 字典占位。
|
|
2300
|
+
|
|
2301
|
+
地标名(「青创港」)→ 坐标由**调用方**解析:MCP 不持地图 API,也不猜地名。
|
|
2302
|
+
传错时不静默忽略 —— 坐标查询的失败模式必须是显式的,否则 agent 会把
|
|
2303
|
+
「按全城排的结果」误当成「按距离排的结果」交付给用户。
|
|
2304
|
+
"""
|
|
2305
|
+
if (lat is None) != (lng is None):
|
|
2306
|
+
return None, None, None, {
|
|
2307
|
+
"error": "bad_geo",
|
|
2308
|
+
"message": "lat 与 lng 必须同时提供。「青创港」这类地标名请先用地图工具"
|
|
2309
|
+
"解析成坐标再传(MCP 不持地图 API,不做地名猜测)。",
|
|
2310
|
+
}
|
|
2311
|
+
if lat is None:
|
|
2312
|
+
return None, None, None, None
|
|
2313
|
+
try:
|
|
2314
|
+
lat_v, lng_v = float(lat), float(lng)
|
|
2315
|
+
except (TypeError, ValueError):
|
|
2316
|
+
return None, None, None, {"error": "bad_geo",
|
|
2317
|
+
"message": "lat/lng 必须是数字,收到 %r / %r" % (lat, lng)}
|
|
2318
|
+
if not (_GEO_LAT_RANGE[0] <= lat_v <= _GEO_LAT_RANGE[1]
|
|
2319
|
+
and _GEO_LNG_RANGE[0] <= lng_v <= _GEO_LNG_RANGE[1]):
|
|
2320
|
+
return None, None, None, {
|
|
2321
|
+
"error": "bad_geo",
|
|
2322
|
+
"message": "坐标超出中国范围(lat %.1f~%.1f, lng %.1f~%.1f),收到 %r / %r。"
|
|
2323
|
+
"常见根因:经纬度顺序写反。" % (
|
|
2324
|
+
_GEO_LAT_RANGE[0], _GEO_LAT_RANGE[1],
|
|
2325
|
+
_GEO_LNG_RANGE[0], _GEO_LNG_RANGE[1], lat, lng),
|
|
2326
|
+
}
|
|
2327
|
+
rad = None
|
|
2328
|
+
if radius_km is not None:
|
|
2329
|
+
try:
|
|
2330
|
+
rad = float(radius_km)
|
|
2331
|
+
except (TypeError, ValueError):
|
|
2332
|
+
return None, None, None, {"error": "bad_geo",
|
|
2333
|
+
"message": "radius_km 必须是数字,收到 %r" % (radius_km,)}
|
|
2334
|
+
if rad <= 0:
|
|
2335
|
+
return None, None, None, {"error": "bad_geo",
|
|
2336
|
+
"message": "radius_km 必须 > 0(公里)"}
|
|
2337
|
+
if rad > _GEO_RADIUS_MAX_KM:
|
|
2338
|
+
return None, None, None, {
|
|
2339
|
+
"error": "bad_geo",
|
|
2340
|
+
"message": "radius_km 上限 %.0f 公里(覆盖全国任意两点),收到 %r。"
|
|
2341
|
+
"要更大范围请去掉坐标按城市/全国查。" % (
|
|
2342
|
+
_GEO_RADIUS_MAX_KM, radius_km),
|
|
2343
|
+
}
|
|
2344
|
+
return lat_v, lng_v, rad, None
|
|
2345
|
+
|
|
2346
|
+
|
|
2209
2347
|
def _rec_summary(rec: Dict[str, Any]) -> Dict[str, Any]:
|
|
2210
2348
|
return {
|
|
2211
2349
|
"id": rec.get("id"),
|
|
@@ -2223,6 +2361,11 @@ def _rec_summary(rec: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
2223
2361
|
"process": rec.get("proc", []),
|
|
2224
2362
|
"material": rec.get("mat", []),
|
|
2225
2363
|
"cert": rec.get("cert", []),
|
|
2364
|
+
# 坐标(2026-10-23 接入):指纹层 98.5% 有值,无值为 null。
|
|
2365
|
+
# 给 agent 自己排序/换算用;检索侧的距离过滤见 search_vendors 的
|
|
2366
|
+
# lat/lng/radius_km 参数(那里才会写 distance_km)。
|
|
2367
|
+
"lat": rec.get("lat"),
|
|
2368
|
+
"lng": rec.get("lng"),
|
|
2226
2369
|
}
|
|
2227
2370
|
|
|
2228
2371
|
|
|
@@ -2798,9 +2941,10 @@ def _page_slice(matches: list, offset: int, limit: int, raw_offset: int):
|
|
|
2798
2941
|
|
|
2799
2942
|
def search_vendors(query: str = "", city: str = "", gb: str = "",
|
|
2800
2943
|
limit: int = 20, offset: int = 0,
|
|
2801
|
-
group_branches: bool = True,
|
|
2802
|
-
|
|
2803
|
-
|
|
2944
|
+
group_branches: bool = True, etype: str = "",
|
|
2945
|
+
lat: Optional[float] = None, lng: Optional[float] = None,
|
|
2946
|
+
radius_km: Optional[float] = None) -> Dict[str, Any]:
|
|
2947
|
+
"""按关键词 / 城市 / 国标码 / 坐标检索全国各行业主体。
|
|
2804
2948
|
|
|
2805
2949
|
`group_branches`:同一品牌的连锁门店是否折成一行(默认折)。连锁在名录里是一家一条,
|
|
2806
2950
|
不折的话「面馆」这类查询会被门店淹没(苏州瑞幸一家就 267 条)。
|
|
@@ -2812,6 +2956,14 @@ def search_vendors(query: str = "", city: str = "", gb: str = "",
|
|
|
2812
2956
|
服务 = H 住宿餐饮 … S 公共管理
|
|
2813
2957
|
「找能做的厂」用生产,「找附近的店/服务商」用服务,别用「非制造商」反向排除 ——
|
|
2814
2958
|
那会把六成主体混成一个筐。
|
|
2959
|
+
|
|
2960
|
+
`lat`/`lng`/`radius_km`:坐标检索(2026-10-23 接入,数据来自指纹层 98.5% 覆盖)。
|
|
2961
|
+
地标名(「青创港」)→ 坐标由**调用方**解析(MCP 不持地图 API);GCJ-02/WGS-84
|
|
2962
|
+
混用无碍(偏差远小于公里级半径)。语义:
|
|
2963
|
+
- 只传 lat/lng:结果档内按球面距离升序(无坐标记录排末位,distance_km=null);
|
|
2964
|
+
- 加 radius_km:圈外与无坐标记录剔除(无法证明在圈内=不在圈内),
|
|
2965
|
+
total_matched 变为圈内数,被剔除数见 geo_filtered_out;
|
|
2966
|
+
- 连锁品牌行报**最近门店**距离(_collapse_brands)。
|
|
2815
2967
|
"""
|
|
2816
2968
|
limit = max(1, min(int(limit), 200))
|
|
2817
2969
|
# etype 过滤:中文名 → 指纹 et 码。传了不认识的值不静默忽略,见下方校验。
|
|
@@ -2821,6 +2973,9 @@ def search_vendors(query: str = "", city: str = "", gb: str = "",
|
|
|
2821
2973
|
"error": "bad_etype",
|
|
2822
2974
|
"message": "etype 只接受 生产 / 流通 / 服务 三个值(按国标门类推导),收到 %r" % etype,
|
|
2823
2975
|
}
|
|
2976
|
+
lat_v, lng_v, rad, geo_err = _geo_params(lat, lng, radius_km)
|
|
2977
|
+
if geo_err:
|
|
2978
|
+
return geo_err
|
|
2824
2979
|
# ── 分页深度上限(2026-10-22)────────────────────────────────────────
|
|
2825
2980
|
# 这不是性能优化,是**反截取**。翻页遍历是从公开接口搬走整个库的唯一
|
|
2826
2981
|
# 可行路径:`total_matched=4684` 时 offset += 200 循环 24 次就拉干了。
|
|
@@ -2888,13 +3043,25 @@ def search_vendors(query: str = "", city: str = "", gb: str = "",
|
|
|
2888
3043
|
matches.append(s)
|
|
2889
3044
|
# 与 query 路由同一套档内排序(`gb=` 只走一档,故按厂名贴合度降序即可),
|
|
2890
3045
|
# 否则同一批记录经两条路进来顺序不同,agent 会以为「换了个写法结果就变了」。
|
|
2891
|
-
|
|
3046
|
+
# 2026-10-23:传了坐标时距离优先于 name_fit(「附近」是显式意图),
|
|
3047
|
+
# 无坐标记录排末位;档间次序该路径只有一档,无分档问题。
|
|
3048
|
+
if lat_v is not None:
|
|
3049
|
+
matches, geo_dropped, geo_ncoord = _geo_apply(matches, lat_v, lng_v, rad)
|
|
3050
|
+
matches.sort(key=lambda x: (_geo_rank_key(x), -_name_fit(x, gen_tokens),
|
|
3051
|
+
str(x.get("id") or "")))
|
|
3052
|
+
else:
|
|
3053
|
+
geo_dropped = 0
|
|
3054
|
+
geo_ncoord = 0
|
|
2892
3055
|
# 品牌归并放在**分页之前**:否则一页 20 条里可能 9 条是同一条街上的门店,
|
|
2893
3056
|
# 而第 21 条才是另一个品牌 —— 分页之后再折叠等于白折。
|
|
2894
3057
|
records_total = len(matches)
|
|
2895
3058
|
matches, brands_total = _collapse_brands(matches, keep_all=not group_branches)
|
|
3059
|
+
# 同 query 路由:折行后按距离稳定重排(品牌行距离折行时才换成最近门店,
|
|
3060
|
+
# 位置会停在代表店那)。等距组内保持 name_fit/id 序。
|
|
3061
|
+
if lat_v is not None:
|
|
3062
|
+
matches.sort(key=_geo_rank_key)
|
|
2896
3063
|
page, pmeta = _page_slice(matches, offset, limit, raw_offset)
|
|
2897
|
-
|
|
3064
|
+
out = {
|
|
2898
3065
|
"total_matched": records_total,
|
|
2899
3066
|
"total_brands": brands_total,
|
|
2900
3067
|
"returned": len(page),
|
|
@@ -2906,8 +3073,14 @@ def search_vendors(query: str = "", city: str = "", gb: str = "",
|
|
|
2906
3073
|
"order_note": ("results 按「关键词在企业名里成片出现」优先排列;同一品牌的连锁门店"
|
|
2907
3074
|
"已折成一行(`branches` = 门店数,`branch_ids` 前 20 个)。"
|
|
2908
3075
|
"score 是**能力画像分**(有已发布能力卡才有分,无卡为 0),"
|
|
2909
|
-
"**不是相关度**,不要拿它反推排序。")
|
|
2910
|
-
|
|
3076
|
+
"**不是相关度**,不要拿它反推排序。")
|
|
3077
|
+
+ (_geo_note(rad) if lat_v is not None else ""),
|
|
3078
|
+
# 坐标回显:让 agent 能核对「服务端确实按我给的坐标排了」。
|
|
3079
|
+
**({"geo": {"lat": lat_v, "lng": lng_v, "radius_km": rad,
|
|
3080
|
+
"records_with_coords": geo_ncoord}} if lat_v is not None else {}),
|
|
3081
|
+
**({"geo_filtered_out": geo_dropped} if geo_dropped else {}),
|
|
3082
|
+
}
|
|
3083
|
+
return _attach_routing_hint(out, q, city)
|
|
2911
3084
|
|
|
2912
3085
|
# 未给国标码:优先走「预构建索引 → 只拉命中分片」。
|
|
2913
3086
|
# 索引缺失或陈旧时回退进程内全量索引(与命中量相关,慢但结果等价)。
|
|
@@ -3046,16 +3219,45 @@ def search_vendors(query: str = "", city: str = "", gb: str = "",
|
|
|
3046
3219
|
# cap 与 text 同名次时仍按 name_fit 细分(品牌名命中 > 仅门店限定命中)。
|
|
3047
3220
|
_RANK = {"cap": 0, "text": 1, "city": 1, "cap_text": 2, "alias": 3}
|
|
3048
3221
|
|
|
3222
|
+
# 坐标过滤与距离计算(2026-10-23):分页之前,与 _collapse_brands 同理。
|
|
3223
|
+
# radius 模式下圈外与无坐标记录在这里就已剔除,后续所有计数(total_matched /
|
|
3224
|
+
# total_brands / 分页)都是圈内口径;被剔除数单独走 geo_filtered_out,
|
|
3225
|
+
# 让 agent 能对用户说清「全城有 176 条、圈内 3 条」而不是只说「有 3 条」。
|
|
3226
|
+
matches, geo_dropped, geo_ncoord = _geo_apply(matches, lat_v, lng_v, rad)
|
|
3227
|
+
|
|
3049
3228
|
# 档内再按厂名贴合度排(见 `_name_fit` 的说明)。这是**档内**微调,不影响档间
|
|
3050
3229
|
# 次序,也不影响 total_matched。末尾按 id 收尾,保证同档同分的顺序稳定
|
|
3051
3230
|
#(否则等于按分片路径排,H 住宿餐饮恒在 Q 卫生前面)。
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3231
|
+
# 2026-10-23:坐标查询两种语义,排序键不同(这是刻意的,不是疏漏)——
|
|
3232
|
+
# ① 只传 lat/lng(无 radius):意图 =「离我最近的先看」→ **距离第一键**,
|
|
3233
|
+
# 档序降为次级(等距时才按召回纯度分高下)。否则「便利店」会出现
|
|
3234
|
+
# 15km 的 cap 档压在 10.8km 的 text 档前面,用户只会当 bug。
|
|
3235
|
+
# ② 传 radius_km:圈已经限定候选,纯度优先、档内距离 —— 圈内都是
|
|
3236
|
+
# 「能用的」,先看最对口的。
|
|
3237
|
+
# 无坐标查询时距离键恒等,排序退化为原有 (_RANK, name_fit, id),零影响。
|
|
3238
|
+
if lat_v is not None and rad is None:
|
|
3239
|
+
def _geo_sort(x):
|
|
3240
|
+
return (_geo_rank_key(x), _RANK.get(x.get("via"), 9),
|
|
3241
|
+
-_name_fit(x, gen_tokens), str(x.get("id") or ""))
|
|
3242
|
+
else:
|
|
3243
|
+
def _geo_sort(x):
|
|
3244
|
+
return (_RANK.get(x.get("via"), 9), _geo_rank_key(x),
|
|
3245
|
+
-_name_fit(x, gen_tokens), str(x.get("id") or ""))
|
|
3246
|
+
matches.sort(key=_geo_sort)
|
|
3055
3247
|
|
|
3056
3248
|
# 品牌归并(分页之前,理由见 `_collapse_brands`)。
|
|
3057
3249
|
records_total = len(matches)
|
|
3058
3250
|
matches, brands_total = _collapse_brands(matches, keep_all=not group_branches)
|
|
3251
|
+
# 折行后按距离稳定重排(2026-10-23):品牌行的 distance_km 是在折行时才换成
|
|
3252
|
+
# 「最近门店」的,位置却停在代表店那里 —— 不重排就会出现「排第 3 的品牌比
|
|
3253
|
+
# 排第 1 的还近」,用户只会把它当 bug。键序与折行前一致(无 radius 距离
|
|
3254
|
+
# 第一、有 radius 档序第一);stable + 等距组内保持折行前序(name_fit/id)。
|
|
3255
|
+
# 无坐标查询时距离键恒等,排序结果与不传坐标完全一致(存量零影响)。
|
|
3256
|
+
if lat_v is not None:
|
|
3257
|
+
if rad is None:
|
|
3258
|
+
matches.sort(key=lambda x: (_geo_rank_key(x), _RANK.get(x.get("via"), 9)))
|
|
3259
|
+
else:
|
|
3260
|
+
matches.sort(key=lambda x: (_RANK.get(x.get("via"), 9), _geo_rank_key(x)))
|
|
3059
3261
|
page, pmeta = _page_slice(matches, offset, limit, raw_offset)
|
|
3060
3262
|
out = {
|
|
3061
3263
|
"total_matched": records_total,
|
|
@@ -3076,7 +3278,12 @@ def search_vendors(query: str = "", city: str = "", gb: str = "",
|
|
|
3076
3278
|
"via=alias(别名定向) → via=cap(能力定向);**同一档内**再按「关键词在"
|
|
3077
3279
|
"厂名里成片出现」优先(所以「商务酒店」会把 龙翔商务酒店 排在 "
|
|
3078
3280
|
"某酒店(中央商务区店) 前面);score 是**能力画像分**"
|
|
3079
|
-
"(有已发布能力卡才有分,无卡为 0),**不是相关度**,不要拿它反推排序。")
|
|
3281
|
+
"(有已发布能力卡才有分,无卡为 0),**不是相关度**,不要拿它反推排序。")
|
|
3282
|
+
+ (_geo_note(rad) if lat_v is not None else ""),
|
|
3283
|
+
# 坐标回显 + 圈外剔除数(仅坐标查询时出现)。
|
|
3284
|
+
**({"geo": {"lat": lat_v, "lng": lng_v, "radius_km": rad,
|
|
3285
|
+
"records_with_coords": geo_ncoord}} if lat_v is not None else {}),
|
|
3286
|
+
**({"geo_filtered_out": geo_dropped} if geo_dropped else {}),
|
|
3080
3287
|
}
|
|
3081
3288
|
if alias_hits:
|
|
3082
3289
|
out["alias_expanded"] = [{"word": a["word"], "gb": a["code"], "name": a["name"]}
|
|
@@ -3325,8 +3532,10 @@ TOOLS = [
|
|
|
3325
3532
|
"制造、电热燃水、建筑、批发零售、交通运输、住宿餐饮、信息软件、金融、"
|
|
3326
3533
|
"房地产、租赁商务服务、科研技术服务、水利环境、居民服务、教育、卫生、"
|
|
3327
3534
|
"文化体育娱乐、公共管理……)。可按关键词(名称/工艺/材料/认证/服务内容)、"
|
|
3328
|
-
"
|
|
3329
|
-
"
|
|
3535
|
+
"城市或区县、国标码过滤。支持坐标检索:传 lat/lng 按距离排序、"
|
|
3536
|
+
"加 radius_km 只留圈内(地标名需调用方先解析成坐标)。"
|
|
3537
|
+
"返回精简档案(id/名称/城市/区县/国标码/认证等级/"
|
|
3538
|
+
"工艺/材料/认证/是否含电话/连锁门店数/距离)。"
|
|
3330
3539
|
"⚠ 关键词是**字面子串**匹配:用户的口语词若与名录写法不同(如「机加工」"
|
|
3331
3540
|
"vs 归档用的「机械零部件加工」、「面馆」vs「苏式面馆」),命中会偏少。"
|
|
3332
3541
|
"此时不要直接报「没有」——看返回体里的 hint / suggested_gb,"
|
|
@@ -3355,6 +3564,17 @@ TOOLS = [
|
|
|
3355
3564
|
"流通 = F批发零售/G交通运输仓储邮政;"
|
|
3356
3565
|
"服务 = H住宿餐饮…S公共管理。"
|
|
3357
3566
|
"「找能做的厂」用生产,「找附近的店/服务商」用服务"},
|
|
3567
|
+
"lat": {"type": "number",
|
|
3568
|
+
"description": "纬度(GCJ-02/WGS-84 均可)。**地标名(如「青创港」)"
|
|
3569
|
+
"请先用地图工具解析成坐标再传 —— MCP 不持地图 API、"
|
|
3570
|
+
"不猜地名。与 lng 必须同时提供"},
|
|
3571
|
+
"lng": {"type": "number",
|
|
3572
|
+
"description": "经度。与 lat 同时提供后,结果档内按球面距离升序,"
|
|
3573
|
+
"每条带 distance_km(直线距离,非路程);无坐标记录排末位"},
|
|
3574
|
+
"radius_km": {"type": "number",
|
|
3575
|
+
"description": "半径(公里,上限 500)。给了就只保留圈内主体:"
|
|
3576
|
+
"圈外与无坐标记录剔除(无坐标=无法证明在圈内),"
|
|
3577
|
+
"剔除数见返回体 geo_filtered_out;连锁品牌行报最近门店距离"},
|
|
3358
3578
|
},
|
|
3359
3579
|
},
|
|
3360
3580
|
},
|
|
@@ -3479,9 +3699,109 @@ TOOLS = [
|
|
|
3479
3699
|
},
|
|
3480
3700
|
},
|
|
3481
3701
|
},
|
|
3702
|
+
{
|
|
3703
|
+
"name": "vendor_onboarding",
|
|
3704
|
+
"description": "给供应商开通「入驻 / 认领」入口:返回一个**可直接点开**的控制台链接与步骤。"
|
|
3705
|
+
"当用户说「我是某某公司的」「我想入驻 / 上架产品 / 让客户搜到我」"
|
|
3706
|
+
"「我要认领我的企业」「怎么注册成供应商」「我的审核到哪一步了」时调用。"
|
|
3707
|
+
"⚠ 本工具**只给入口,绝不代办**:短信验证码与法人手机核验必须由用户"
|
|
3708
|
+
"在自己的浏览器里输入 —— 让他把验证码念给你,等于把「证明他是这家企业」"
|
|
3709
|
+
"的凭证交给第三方,还会进入对话上下文与日志。"
|
|
3710
|
+
"⚠ 不要替用户编造企业信息,不要承诺审核结果或时效,"
|
|
3711
|
+
"也不要把未认领的企业说成「已经属于你」。",
|
|
3712
|
+
"inputSchema": {
|
|
3713
|
+
"type": "object",
|
|
3714
|
+
"properties": {
|
|
3715
|
+
"supplier_id": {"type": "string",
|
|
3716
|
+
"description": "可选。已在检索结果里拿到的企业 ID(如 CN-MFG-0020317),用于生成直达深链,省得用户再搜一遍公司名"},
|
|
3717
|
+
"company": {"type": "string", "description": "可选。企业名称,用于把步骤说得更具体"},
|
|
3718
|
+
"intent": {"type": "string", "enum": ["claim", "register", "status"],
|
|
3719
|
+
"description": "claim=认领库里已收录的企业(默认);register=库里没有、要新建档;status=查自己申请的审核进度"},
|
|
3720
|
+
},
|
|
3721
|
+
},
|
|
3722
|
+
},
|
|
3482
3723
|
]
|
|
3483
3724
|
|
|
3484
3725
|
|
|
3726
|
+
CONSOLE_BASE = "https://beacon-mfg.pages.dev/console/"
|
|
3727
|
+
|
|
3728
|
+
|
|
3729
|
+
def vendor_onboarding(supplier_id: str = "", company: str = "",
|
|
3730
|
+
intent: str = "claim") -> Dict[str, Any]:
|
|
3731
|
+
"""给供应商一个**能自己走完**的入驻入口。
|
|
3732
|
+
|
|
3733
|
+
为什么 MCP 只做到「给链接」为止
|
|
3734
|
+
------------------------------
|
|
3735
|
+
Agent 拿得到 HTML 也没用(它不会填表单)。但更要紧的是**凭证不能进对话**:
|
|
3736
|
+
认领要过短信验证码与法人手机核验,让用户把验证码念给 Agent,等于把
|
|
3737
|
+
「证明我是这家企业」的东西交给第三方,还顺带落进 LLM 上下文与日志。
|
|
3738
|
+
所以这里只返回链接 + 步骤 + 明确的边界,由用户在浏览器里自己完成。
|
|
3739
|
+
|
|
3740
|
+
同源红线:即便将来 Agent 能代填字段,也必须企业自己确认过才算「企业自述」——
|
|
3741
|
+
否则就是拿平台推断冒充企业说的话。
|
|
3742
|
+
"""
|
|
3743
|
+
from urllib.parse import quote as _q
|
|
3744
|
+
|
|
3745
|
+
sid = (supplier_id or "").strip()
|
|
3746
|
+
name = (company or "").strip()
|
|
3747
|
+
if sid and not name:
|
|
3748
|
+
# 顺带把公司名查出来,好让 Agent 说「帮您找到 XX 公司」而不是干巴巴一个 ID
|
|
3749
|
+
try:
|
|
3750
|
+
v = get_vendor(sid)
|
|
3751
|
+
# get_vendor 的外层是 {"vendor": {...}},公司名在 vendor 顶层或 identity 里
|
|
3752
|
+
vv = (v.get("vendor") if isinstance(v, dict) and v.get("vendor") else v) or {}
|
|
3753
|
+
name = str(
|
|
3754
|
+
vv.get("company")
|
|
3755
|
+
or (vv.get("identity") or {}).get("company")
|
|
3756
|
+
or ""
|
|
3757
|
+
).strip()
|
|
3758
|
+
except Exception:
|
|
3759
|
+
pass
|
|
3760
|
+
|
|
3761
|
+
url = CONSOLE_BASE + ("?id=" + _q(sid, safe="") if sid else "")
|
|
3762
|
+
intent = (intent or "claim").strip().lower()
|
|
3763
|
+
|
|
3764
|
+
if intent == "register":
|
|
3765
|
+
steps = [
|
|
3766
|
+
"打开链接,用手机号收验证码登录(同一个号在 App 与网页是同一账号,草稿也共享)",
|
|
3767
|
+
"在「找到我的企业」里搜公司名;搜不到就走「新建档」",
|
|
3768
|
+
"填主体 / 联系 / 产能 / 质量 / 服务与产品条目(单价可不填,对外显示「面议」)",
|
|
3769
|
+
"提交后进入人工审核;不通过会带**逐字段理由**退回,改完可重新提交",
|
|
3770
|
+
]
|
|
3771
|
+
elif intent == "status":
|
|
3772
|
+
steps = [
|
|
3773
|
+
"打开链接,用提交时那个手机号登录",
|
|
3774
|
+
"页面会列出「我的申请」:待审核 / 已通过 / 已驳回(驳回带理由)",
|
|
3775
|
+
]
|
|
3776
|
+
else:
|
|
3777
|
+
steps = [
|
|
3778
|
+
"打开链接,用手机号收验证码登录",
|
|
3779
|
+
"搜到自己的企业并选中(已在库里的话,公开信息会自动带成底稿,不用重填)",
|
|
3780
|
+
"第一步:填 18 位统一社会信用代码(按 GB 32100-2015 校验位当场验)",
|
|
3781
|
+
"第二步:填工商登记的法人手机号并收码",
|
|
3782
|
+
"提交认领申请 → **人工复核通过后**才算认领成功",
|
|
3783
|
+
]
|
|
3784
|
+
|
|
3785
|
+
return {
|
|
3786
|
+
"url": url,
|
|
3787
|
+
"company": name or None,
|
|
3788
|
+
"supplier_id": sid or None,
|
|
3789
|
+
"intent": intent,
|
|
3790
|
+
"steps": steps,
|
|
3791
|
+
"boundary": "验证码、法人手机号这类凭证只能由用户在本页面自己输入。"
|
|
3792
|
+
"**不要让用户把它们告诉你**,也不要替他填写。",
|
|
3793
|
+
"what_not_to_do": [
|
|
3794
|
+
"不要代替用户完成认领或注册",
|
|
3795
|
+
"不要索取、转述或回显任何验证码",
|
|
3796
|
+
"不要承诺审核结果或时效",
|
|
3797
|
+
"不要把未认领的企业说成「已经属于该用户」",
|
|
3798
|
+
],
|
|
3799
|
+
"why_not_here": "USCC 是企业公开信息,单凭它认领等于把企业送人。"
|
|
3800
|
+
"所以我们不用「填个代码就算认领」的做法,而是提交后人工复核。",
|
|
3801
|
+
"next": "把 url 原样给用户,让他在浏览器里走完;完成后可以回来让你查他企业的能力卡。",
|
|
3802
|
+
}
|
|
3803
|
+
|
|
3804
|
+
|
|
3485
3805
|
def account(action: str = "status", phone: str = "", code: str = "") -> Dict[str, Any]:
|
|
3486
3806
|
"""身份与配额自助。**任何异常都转成可读错误**,不让对话里冒出堆栈。"""
|
|
3487
3807
|
try:
|
|
@@ -3621,6 +3941,13 @@ def _dispatch_inner(name: str, args: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
3621
3941
|
offset=args.get("offset", 0),
|
|
3622
3942
|
group_branches=bool(args.get("group_branches", True)),
|
|
3623
3943
|
etype=args.get("etype", ""),
|
|
3944
|
+
# 2026-10-24 fix:v1.9.0 的坐标参数一度漏转发(schema 里有、
|
|
3945
|
+
# search_vendors 签名里也有,但 dispatch 没传 → 三个参数恒为 None,
|
|
3946
|
+
# 表现为 radius_km 静默失效、结果无 distance_km / geo、
|
|
3947
|
+
# 且「只传 lat」不报 bad_geo)。坐标必须转发。
|
|
3948
|
+
lat=args.get("lat"),
|
|
3949
|
+
lng=args.get("lng"),
|
|
3950
|
+
radius_km=args.get("radius_km"),
|
|
3624
3951
|
)
|
|
3625
3952
|
if name == "get_vendor":
|
|
3626
3953
|
return get_vendor(args.get("id", ""), args.get("gb", ""))
|
|
@@ -3641,6 +3968,9 @@ def _dispatch_inner(name: str, args: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
3641
3968
|
return refine_sourcing(args.get("session_id", ""), args.get("action", ""), args.get("value"))
|
|
3642
3969
|
if name == "account":
|
|
3643
3970
|
return account(args.get("action", "status"), args.get("phone", ""), args.get("code", ""))
|
|
3971
|
+
if name == "vendor_onboarding":
|
|
3972
|
+
return vendor_onboarding(args.get("supplier_id", ""), args.get("company", ""),
|
|
3973
|
+
args.get("intent", "claim"))
|
|
3644
3974
|
except Exception as e: # 任何异常都包成文本,避免协议崩
|
|
3645
3975
|
return {"error": f"{name} 执行异常: {e}"}
|
|
3646
3976
|
return {"error": f"未知 tool: {name}"}
|