beacon-mfg-mcp 1.7.0 → 1.9.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.
Files changed (3) hide show
  1. package/RELEASE.md +47 -2
  2. package/package.json +2 -2
  3. package/server.py +317 -15
package/RELEASE.md CHANGED
@@ -1,8 +1,53 @@
1
- # Beacon-MFG 只读 MCP 服务 · v1.7.0
1
+ # Beacon-MFG 只读 MCP 服务 · v1.9.0
2
2
 
3
3
  面向 Agent 的供应商名录检索服务:**只读**,不参与交易、不写入任何数据、不持有密钥。
4
4
 
5
- ## 本次更新(v1.7.0)
5
+ ## 本次更新(v1.9.0)
6
+
7
+ **一句话:坐标检索接入 MCP——「青创港附近的便利店」这类问法,先解析坐标再传
8
+ `lat/lng`,结果按距离排;给 `radius_km` 就只留圈内。地标名解析归调用方
9
+ (MCP 不持地图 API),距离口径归 server。**
10
+
11
+ - **新参数 `lat` / `lng` / `radius_km`**(`search_vendors`):数据来自指纹层
12
+ 98.5% 坐标覆盖(2026-10-22 坐标下沉改造的产出),无需重建索引。
13
+ - 只传 `lat/lng`:**距离为第一排序键**(等距才按召回纯度),每条结果带
14
+ `distance_km`(球面直线距离,非路程);无坐标记录 `distance_km=null` 排末位。
15
+ - 加 `radius_km`(上限 500):圈外与无坐标记录剔除——无坐标 = 无法证明在圈内,
16
+ 与项目「宁可留空不编造」同源;`total_matched` 变为圈内数,被剔除数见
17
+ `geo_filtered_out`;圈内按召回纯度分档、档内距离升序。
18
+ - 连锁品牌行报**最近门店**距离(`_collapse_brands` 透传),不是代表店。
19
+ - **参数校验全部显式报错**(不静默忽略):只传 lat/lng 之一、经纬度顺序写反
20
+ (超出中国范围 lat 3~54 / lng 73~136)、`radius_km<=0` 或超 500、非数字——
21
+ 坐标查询的失败模式必须是显式的,否则 agent 会把「按全城排的结果」当成
22
+ 「按距离排的结果」交付。
23
+ - **0 条时的 hint 换了说法**:圈内 0 条不再建议「改查 gb= 重试」(换什么码圈内
24
+ 都还是没有),改为明示「检索命中 N 条但都超出半径/无坐标」,并要求 agent
25
+ **不要对用户说「这一带没有」**——圈内无收录 ≠ 这一带真的没有。
26
+ - 返回体新增 `geo` 回显(lat/lng/radius_km/records_with_coords),agent 可核对
27
+ 服务端确实按所给坐标处理;`order_note` 动态追加坐标口径说明。
28
+
29
+ ## 本次更新(v1.8.0,已发布)
30
+
31
+ **一句话:口语词不再对不上字——「律所」「饭店」「保洁」直接命中,完全没听过的词
32
+ 也会收到货架初筛候选,语义归一这一步正式交给 LLM,但候选来源归 server。**
33
+
34
+ - **L1 高频俗称表**(+151 条):律所→7231 律师及相关法律服务、饭店→6210 正餐服务、
35
+ 奶茶→6231/6232、保洁公司→8010/8219、开锁→8290……覆盖餐饮、居民服务、房产、医疗、
36
+ 金融、法律、制造、软件、运输、零售、文娱、专业服务十二个方向。每条码都经
37
+ `gb-index.json` 校验 `records>0`——货架上没有的类不上表。
38
+ - **L2 类名机械简称**(+937 条):由 `scripts/gen_class_abbr.py` 从 GB/T 4754 全量类名
39
+ 切短生成(法律服务、正餐、快递、网吧、歌舞厅……),作为 alias 机制第三层
40
+ (data > curated > abbr,setdefault 保序,冲突自动让位前两层)。
41
+ - **L3 零命中货架兜底**(`_shelf_recall`):查询词与名录写法完全对不上字时,返回体
42
+ `suggested_gb` 直接带上按 bigram 重合度初筛的货架候选(全灭档启用类名首字单字回溯),
43
+ 附 `records` 与门类路径;agent 零额外轮次改传 `gb=`。字面零关联时给的是货架 records
44
+ 最高榜,why 字段明码标注「仅供语义参考,不对味就如实告诉用户没收录」。
45
+ - **性能修复(线上已部署)**:Pages 索引落后于数据导致每次查询回退全量扫 460 个分片
46
+ (实测 153.5 秒),已重跑部署修复;索引新鲜时查询回到 0.2~1 秒。
47
+ - **部署洁净度**:`data/` 下一级 `_` 开头的内部产物(剪枝备份等,313 MiB)不再打进
48
+ Pages 发布面。
49
+
50
+ ## 本次更新(v1.7.0,已发布)
6
51
 
7
52
  **一句话:从「工厂名录」升级为「全部国标行业门类的主体名录」——服务业、
8
53
  流通业主体现在是一等公民,不再被当成「非制造商」反向排除。**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "beacon-mfg-mcp",
3
- "version": "1.7.0",
3
+ "version": "1.9.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",
@@ -46,4 +46,4 @@
46
46
  "node": ">=16"
47
47
  },
48
48
  "dependencies": {}
49
- }
49
+ }
package/server.py CHANGED
@@ -848,9 +848,14 @@ def _load_alias_file(rel: str, txt: Optional[str] = None) -> Dict[str, Any]:
848
848
 
849
849
 
850
850
  def load_gb_alias() -> Dict[str, Any]:
851
- """词 → 条目。两层表合并:gb-alias.json(数据推导)+ gb-alias-curated.json(人工策展)。"""
851
+ """词 → 条目。三层表合并(data > curated > abbr,setdefault 保序):
852
+ gb-alias.json 数据推导层(企业关键词实际落在哪些小类)
853
+ gb-alias-curated.json 人工策展层(口语俗称:律所、饭店、保洁…)
854
+ gb-class-abbr.json 机械简称层(类名切短:法律服务、正餐、快递…)
855
+ 前两层优先级更高:abbr 层显式跳过与前两层冲突的词(见 gen_class_abbr.py)。"""
852
856
  global _alias_cache
853
- _alias_relpaths = ("data/gb-alias.json", "data/gb-alias-curated.json")
857
+ _alias_relpaths = ("data/gb-alias.json", "data/gb-alias-curated.json",
858
+ "data/gb-class-abbr.json")
854
859
  if _cache_stale(_alias_cache, _alias_relpaths):
855
860
  # ⚡ 两张表互相独立 → 一次并发取齐。逐个 fetch 各付一次 CDN RTT
856
861
  # (并发取齐可省下数次串行往返)。
@@ -1324,6 +1329,117 @@ def suggest_filters(query: str, city: str = "", limit: int = 8) -> Dict[str, Any
1324
1329
  return res
1325
1330
 
1326
1331
 
1332
+ # _shelf_recall 单字回溯用的泛字:这些字在国标类名里随处可见,命中了不说明
1333
+ # 查询词与该类有任何关系(「店」命中正餐服务、咖啡馆服务……全部)。
1334
+ _SHELF_STOP_CHARS = set("服务业公厂商中心部所集团有限材料产品设备加工制造技术"
1335
+ "供销买卖经营承包租赁代理经纪咨询开发建设设计安装"
1336
+ "装修装饰工程施工承包勘察监理检测检验")
1337
+
1338
+
1339
+ def _shelf_recall(q: str, limit: int = 5) -> List[Dict[str, Any]]:
1340
+ """L3 兜底:查询词与任何类名/别名都不对字时,把货架初筛递到 agent 手边。
1341
+
1342
+ 与 suggest_filters 的分工:那条链路只认「子串完整对上」(ql in nm);
1343
+ 这里用 **bigram 重合度** ——「法律咨询」对「律师及相关法律服务」共享
1344
+ bigram「法律」,子串链路全军覆没时这里还能给方向。完全无重合时退化为货架
1345
+ records top(why 里明码标注「字面无关联,仅供语义参考」),让 agent 至少
1346
+ 知道名录里有什么、按语义挑一个 gb=,或者如实告诉用户「该查询不在覆盖范围」。
1347
+ """
1348
+ q = (q or "").strip()
1349
+ if not q:
1350
+ return []
1351
+ ql = q.lower()
1352
+ grams = {g for g in _grams(q, query_mode=True) if len(g) >= 2 and g != ql}
1353
+ # 单字回溯:bigram 全灭时启用(「律政」→ 7231 律师及相关法律服务共享单字「律」)。
1354
+ # 泛字单字(服/务/业/公/司…)在类名里到处都是,命中了也不说明问题 —— 剔除。
1355
+ chars = {c for c in q if "\u4e00" <= c <= "\u9fff" and c not in _SHELF_STOP_CHARS}
1356
+ scored: List[tuple] = []
1357
+ for r in _gb_shelf():
1358
+ nm = (r["name"] or "").lower()
1359
+ path = (r["path"] or "").lower()
1360
+ s = 100 if (ql and ql in nm) else 0
1361
+ bi = sum(1 for g in grams if g in nm) if grams else 0
1362
+ if bi:
1363
+ s += min(bi * 10, 40)
1364
+ elif chars:
1365
+ # 单字回溯分级:类名**首字**命中是强信号(「律师…」对「律政」的律),
1366
+ # 腰部命中降权(「邮政/市政」里的政与「律政」无关)。不叠加,取最高。
1367
+ head = nm[:1] if nm else ""
1368
+ if any(c in chars for c in head):
1369
+ s += 25
1370
+ elif any(c in nm for c in chars):
1371
+ s += 8
1372
+ s += min(r["records"] / 5000.0, 4)
1373
+ if s > 0:
1374
+ scored.append((s, r))
1375
+ scored.sort(key=lambda x: -x[0])
1376
+ out: List[Dict[str, Any]] = []
1377
+ for s, r in scored[:limit]:
1378
+ out.append({"code": r["code"], "name": r["name"], "path": r["path"],
1379
+ "records": r["records"],
1380
+ "why": "货架字面初筛(bigram 重合,非精确子串):请按语义判断是否对口"})
1381
+ if not out:
1382
+ # 完全不对字 → 货架热销榜。给信息但不装懂:why 明说这只是 records 最高。
1383
+ for r in sorted(_gb_shelf(), key=lambda r: -r["records"])[:limit]:
1384
+ out.append({"code": r["code"], "name": r["name"], "path": r["path"],
1385
+ "records": r["records"],
1386
+ "why": "查询词与货架字面零关联 —— 这只是名录里 records 最高的类,"
1387
+ "仅供语义参考,不对味就如实告诉用户没收录"})
1388
+ return out
1389
+
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
+
1327
1443
  def _attach_routing_hint(out: Dict[str, Any], q: str, city: str) -> Dict[str, Any]:
1328
1444
  """命中偏少时,把「怎么把口语词换成国标码」这条链路内联进结果。
1329
1445
 
@@ -1333,6 +1449,17 @@ def _attach_routing_hint(out: Dict[str, Any], q: str, city: str) -> Dict[str, An
1333
1449
  """
1334
1450
  if not q or out.get("total_matched", 0) >= 5:
1335
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
1336
1463
  try:
1337
1464
  s = suggest_filters(q, city, limit=5)
1338
1465
  except Exception:
@@ -1365,6 +1492,17 @@ def _attach_routing_hint(out: Dict[str, Any], q: str, city: str) -> Dict[str, An
1365
1492
  if caps:
1366
1493
  _emit_cap()
1367
1494
  return out
1495
+ # L3:货架初筛兜底。别名/类名子串都推不出码时,把 bigram 初筛(或热销榜)
1496
+ # 直接递给 agent —— 语义归一这一步归 LLM,但「候选从哪来」不必再让它
1497
+ # 多花一轮 list_industries。
1498
+ shelf = _shelf_recall(q, limit=5)
1499
+ if shelf:
1500
+ out["suggested_gb"] = shelf
1501
+ out["hint"] = ("0 条:你的词与名录里的写法(企业名/国标类名/别名表)都对不上字。"
1502
+ "名录按国标小类归档,我已按字面重合度初筛了货架上最接近的类目"
1503
+ "(见 suggested_gb)—— 请按语义挑 1~3 个 code 改传 gb= 重试;"
1504
+ "都不对味就如实告诉用户该查询不在名录覆盖范围,不要换词硬猜。")
1505
+ return out
1368
1506
  out["hint"] = ("关键词与名录里的写法对不上,且没有可推导的国标码。"
1369
1507
  "建议调 list_industries 用语义挑码后改传 gb=。")
1370
1508
  return out
@@ -1381,6 +1519,14 @@ def _attach_routing_hint(out: Dict[str, Any], q: str, city: str) -> Dict[str, An
1381
1519
  if caps:
1382
1520
  _emit_cap()
1383
1521
  return out
1522
+ # L3 同上:货架初筛兜底(这批词多半只是字面撞词,真正的行业要 agent 按语义挑)。
1523
+ shelf = _shelf_recall(q, limit=5)
1524
+ if shelf:
1525
+ out["suggested_gb"] = shelf
1526
+ out["hint"] = ("命中偏少且推不出国标码:这几条多半只是**字面撞词**,未必真是你要的行业。"
1527
+ "我已按字面重合度初筛了货架上最接近的类目(见 suggested_gb)—— 请按语义挑 "
1528
+ "1~3 个 code 改传 gb= 重试,召回和精度都会好很多。")
1529
+ return out
1384
1530
  out["hint"] = ("命中偏少且推不出国标码:这几条多半只是**字面撞词**,未必真是你要的行业。"
1385
1531
  "建议调 list_industries(keyword=\"…\") 用语义挑 1~3 个国标小类,"
1386
1532
  "再改传 gb= 重试;这样召回和精度都会好很多。")
@@ -2116,6 +2262,12 @@ def _collapse_brands(matches: List[Dict[str, Any]], keep_all: bool = False
2116
2262
  ids = row.setdefault("branch_ids", [])
2117
2263
  if len(ids) < 20: # 有上限:别让一条结果带 267 个 id
2118
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
2119
2271
  return out, len(pos)
2120
2272
 
2121
2273
 
@@ -2123,6 +2275,75 @@ def _collapse_brands(matches: List[Dict[str, Any]], keep_all: bool = False
2123
2275
  _ETYPE_NAMES = {0: "", 1: "生产", 2: "流通", 3: "服务"}
2124
2276
 
2125
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
+
2126
2347
  def _rec_summary(rec: Dict[str, Any]) -> Dict[str, Any]:
2127
2348
  return {
2128
2349
  "id": rec.get("id"),
@@ -2140,6 +2361,11 @@ def _rec_summary(rec: Dict[str, Any]) -> Dict[str, Any]:
2140
2361
  "process": rec.get("proc", []),
2141
2362
  "material": rec.get("mat", []),
2142
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"),
2143
2369
  }
2144
2370
 
2145
2371
 
@@ -2715,9 +2941,10 @@ def _page_slice(matches: list, offset: int, limit: int, raw_offset: int):
2715
2941
 
2716
2942
  def search_vendors(query: str = "", city: str = "", gb: str = "",
2717
2943
  limit: int = 20, offset: int = 0,
2718
- group_branches: bool = True,
2719
- etype: str = "") -> Dict[str, Any]:
2720
- """按关键词 / 城市 / 国标码检索全国各行业主体。
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
+ """按关键词 / 城市 / 国标码 / 坐标检索全国各行业主体。
2721
2948
 
2722
2949
  `group_branches`:同一品牌的连锁门店是否折成一行(默认折)。连锁在名录里是一家一条,
2723
2950
  不折的话「面馆」这类查询会被门店淹没(苏州瑞幸一家就 267 条)。
@@ -2729,6 +2956,14 @@ def search_vendors(query: str = "", city: str = "", gb: str = "",
2729
2956
  服务 = H 住宿餐饮 … S 公共管理
2730
2957
  「找能做的厂」用生产,「找附近的店/服务商」用服务,别用「非制造商」反向排除 ——
2731
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)。
2732
2967
  """
2733
2968
  limit = max(1, min(int(limit), 200))
2734
2969
  # etype 过滤:中文名 → 指纹 et 码。传了不认识的值不静默忽略,见下方校验。
@@ -2738,6 +2973,9 @@ def search_vendors(query: str = "", city: str = "", gb: str = "",
2738
2973
  "error": "bad_etype",
2739
2974
  "message": "etype 只接受 生产 / 流通 / 服务 三个值(按国标门类推导),收到 %r" % etype,
2740
2975
  }
2976
+ lat_v, lng_v, rad, geo_err = _geo_params(lat, lng, radius_km)
2977
+ if geo_err:
2978
+ return geo_err
2741
2979
  # ── 分页深度上限(2026-10-22)────────────────────────────────────────
2742
2980
  # 这不是性能优化,是**反截取**。翻页遍历是从公开接口搬走整个库的唯一
2743
2981
  # 可行路径:`total_matched=4684` 时 offset += 200 循环 24 次就拉干了。
@@ -2805,13 +3043,25 @@ def search_vendors(query: str = "", city: str = "", gb: str = "",
2805
3043
  matches.append(s)
2806
3044
  # 与 query 路由同一套档内排序(`gb=` 只走一档,故按厂名贴合度降序即可),
2807
3045
  # 否则同一批记录经两条路进来顺序不同,agent 会以为「换了个写法结果就变了」。
2808
- matches.sort(key=lambda x: -_name_fit(x, gen_tokens))
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
2809
3055
  # 品牌归并放在**分页之前**:否则一页 20 条里可能 9 条是同一条街上的门店,
2810
3056
  # 而第 21 条才是另一个品牌 —— 分页之后再折叠等于白折。
2811
3057
  records_total = len(matches)
2812
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)
2813
3063
  page, pmeta = _page_slice(matches, offset, limit, raw_offset)
2814
- return _attach_routing_hint({
3064
+ out = {
2815
3065
  "total_matched": records_total,
2816
3066
  "total_brands": brands_total,
2817
3067
  "returned": len(page),
@@ -2823,8 +3073,13 @@ def search_vendors(query: str = "", city: str = "", gb: str = "",
2823
3073
  "order_note": ("results 按「关键词在企业名里成片出现」优先排列;同一品牌的连锁门店"
2824
3074
  "已折成一行(`branches` = 门店数,`branch_ids` 前 20 个)。"
2825
3075
  "score 是**能力画像分**(有已发布能力卡才有分,无卡为 0),"
2826
- "**不是相关度**,不要拿它反推排序。"),
2827
- }, q, city)
3076
+ "**不是相关度**,不要拿它反推排序。") + _geo_note(rad),
3077
+ # 坐标回显:让 agent 能核对「服务端确实按我给的坐标排了」。
3078
+ **({"geo": {"lat": lat_v, "lng": lng_v, "radius_km": rad,
3079
+ "records_with_coords": geo_ncoord}} if lat_v is not None else {}),
3080
+ **({"geo_filtered_out": geo_dropped} if geo_dropped else {}),
3081
+ }
3082
+ return _attach_routing_hint(out, q, city)
2828
3083
 
2829
3084
  # 未给国标码:优先走「预构建索引 → 只拉命中分片」。
2830
3085
  # 索引缺失或陈旧时回退进程内全量索引(与命中量相关,慢但结果等价)。
@@ -2963,16 +3218,45 @@ def search_vendors(query: str = "", city: str = "", gb: str = "",
2963
3218
  # cap 与 text 同名次时仍按 name_fit 细分(品牌名命中 > 仅门店限定命中)。
2964
3219
  _RANK = {"cap": 0, "text": 1, "city": 1, "cap_text": 2, "alias": 3}
2965
3220
 
3221
+ # 坐标过滤与距离计算(2026-10-23):分页之前,与 _collapse_brands 同理。
3222
+ # radius 模式下圈外与无坐标记录在这里就已剔除,后续所有计数(total_matched /
3223
+ # total_brands / 分页)都是圈内口径;被剔除数单独走 geo_filtered_out,
3224
+ # 让 agent 能对用户说清「全城有 176 条、圈内 3 条」而不是只说「有 3 条」。
3225
+ matches, geo_dropped, geo_ncoord = _geo_apply(matches, lat_v, lng_v, rad)
3226
+
2966
3227
  # 档内再按厂名贴合度排(见 `_name_fit` 的说明)。这是**档内**微调,不影响档间
2967
3228
  # 次序,也不影响 total_matched。末尾按 id 收尾,保证同档同分的顺序稳定
2968
3229
  #(否则等于按分片路径排,H 住宿餐饮恒在 Q 卫生前面)。
2969
- matches.sort(key=lambda x: (_RANK.get(x.get("via"), 9),
2970
- -_name_fit(x, gen_tokens),
2971
- str(x.get("id") or "")))
3230
+ # 2026-10-23:坐标查询两种语义,排序键不同(这是刻意的,不是疏漏)——
3231
+ # ① 只传 lat/lng(无 radius):意图 =「离我最近的先看」→ **距离第一键**,
3232
+ # 档序降为次级(等距时才按召回纯度分高下)。否则「便利店」会出现
3233
+ # 15km 的 cap 档压在 10.8km 的 text 档前面,用户只会当 bug。
3234
+ # ② 传 radius_km:圈已经限定候选,纯度优先、档内距离 —— 圈内都是
3235
+ # 「能用的」,先看最对口的。
3236
+ # 无坐标查询时距离键恒等,排序退化为原有 (_RANK, name_fit, id),零影响。
3237
+ if lat_v is not None and rad is None:
3238
+ def _geo_sort(x):
3239
+ return (_geo_rank_key(x), _RANK.get(x.get("via"), 9),
3240
+ -_name_fit(x, gen_tokens), str(x.get("id") or ""))
3241
+ else:
3242
+ def _geo_sort(x):
3243
+ return (_RANK.get(x.get("via"), 9), _geo_rank_key(x),
3244
+ -_name_fit(x, gen_tokens), str(x.get("id") or ""))
3245
+ matches.sort(key=_geo_sort)
2972
3246
 
2973
3247
  # 品牌归并(分页之前,理由见 `_collapse_brands`)。
2974
3248
  records_total = len(matches)
2975
3249
  matches, brands_total = _collapse_brands(matches, keep_all=not group_branches)
3250
+ # 折行后按距离稳定重排(2026-10-23):品牌行的 distance_km 是在折行时才换成
3251
+ # 「最近门店」的,位置却停在代表店那里 —— 不重排就会出现「排第 3 的品牌比
3252
+ # 排第 1 的还近」,用户只会把它当 bug。键序与折行前一致(无 radius 距离
3253
+ # 第一、有 radius 档序第一);stable + 等距组内保持折行前序(name_fit/id)。
3254
+ # 无坐标查询时距离键恒等,排序结果与不传坐标完全一致(存量零影响)。
3255
+ if lat_v is not None:
3256
+ if rad is None:
3257
+ matches.sort(key=lambda x: (_geo_rank_key(x), _RANK.get(x.get("via"), 9)))
3258
+ else:
3259
+ matches.sort(key=lambda x: (_RANK.get(x.get("via"), 9), _geo_rank_key(x)))
2976
3260
  page, pmeta = _page_slice(matches, offset, limit, raw_offset)
2977
3261
  out = {
2978
3262
  "total_matched": records_total,
@@ -2993,7 +3277,12 @@ def search_vendors(query: str = "", city: str = "", gb: str = "",
2993
3277
  "via=alias(别名定向) → via=cap(能力定向);**同一档内**再按「关键词在"
2994
3278
  "厂名里成片出现」优先(所以「商务酒店」会把 龙翔商务酒店 排在 "
2995
3279
  "某酒店(中央商务区店) 前面);score 是**能力画像分**"
2996
- "(有已发布能力卡才有分,无卡为 0),**不是相关度**,不要拿它反推排序。"),
3280
+ "(有已发布能力卡才有分,无卡为 0),**不是相关度**,不要拿它反推排序。")
3281
+ + _geo_note(rad),
3282
+ # 坐标回显 + 圈外剔除数(仅坐标查询时出现)。
3283
+ **({"geo": {"lat": lat_v, "lng": lng_v, "radius_km": rad,
3284
+ "records_with_coords": geo_ncoord}} if lat_v is not None else {}),
3285
+ **({"geo_filtered_out": geo_dropped} if geo_dropped else {}),
2997
3286
  }
2998
3287
  if alias_hits:
2999
3288
  out["alias_expanded"] = [{"word": a["word"], "gb": a["code"], "name": a["name"]}
@@ -3242,8 +3531,10 @@ TOOLS = [
3242
3531
  "制造、电热燃水、建筑、批发零售、交通运输、住宿餐饮、信息软件、金融、"
3243
3532
  "房地产、租赁商务服务、科研技术服务、水利环境、居民服务、教育、卫生、"
3244
3533
  "文化体育娱乐、公共管理……)。可按关键词(名称/工艺/材料/认证/服务内容)、"
3245
- "城市或区县、国标码过滤。返回精简档案(id/名称/城市/区县/国标码/认证等级/"
3246
- "工艺/材料/认证/是否含电话/连锁门店数)。"
3534
+ "城市或区县、国标码过滤。支持坐标检索:传 lat/lng 按距离排序、"
3535
+ "加 radius_km 只留圈内(地标名需调用方先解析成坐标)。"
3536
+ "返回精简档案(id/名称/城市/区县/国标码/认证等级/"
3537
+ "工艺/材料/认证/是否含电话/连锁门店数/距离)。"
3247
3538
  "⚠ 关键词是**字面子串**匹配:用户的口语词若与名录写法不同(如「机加工」"
3248
3539
  "vs 归档用的「机械零部件加工」、「面馆」vs「苏式面馆」),命中会偏少。"
3249
3540
  "此时不要直接报「没有」——看返回体里的 hint / suggested_gb,"
@@ -3272,6 +3563,17 @@ TOOLS = [
3272
3563
  "流通 = F批发零售/G交通运输仓储邮政;"
3273
3564
  "服务 = H住宿餐饮…S公共管理。"
3274
3565
  "「找能做的厂」用生产,「找附近的店/服务商」用服务"},
3566
+ "lat": {"type": "number",
3567
+ "description": "纬度(GCJ-02/WGS-84 均可)。**地标名(如「青创港」)"
3568
+ "请先用地图工具解析成坐标再传 —— MCP 不持地图 API、"
3569
+ "不猜地名。与 lng 必须同时提供"},
3570
+ "lng": {"type": "number",
3571
+ "description": "经度。与 lat 同时提供后,结果档内按球面距离升序,"
3572
+ "每条带 distance_km(直线距离,非路程);无坐标记录排末位"},
3573
+ "radius_km": {"type": "number",
3574
+ "description": "半径(公里,上限 500)。给了就只保留圈内主体:"
3575
+ "圈外与无坐标记录剔除(无坐标=无法证明在圈内),"
3576
+ "剔除数见返回体 geo_filtered_out;连锁品牌行报最近门店距离"},
3275
3577
  },
3276
3578
  },
3277
3579
  },