beacon-mfg-mcp 1.8.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 +26 -2
  2. package/package.json +1 -1
  3. package/server.py +232 -13
package/RELEASE.md CHANGED
@@ -1,8 +1,32 @@
1
- # Beacon-MFG 只读 MCP 服务 · v1.8.0
1
+ # Beacon-MFG 只读 MCP 服务 · v1.9.0
2
2
 
3
3
  面向 Agent 的供应商名录检索服务:**只读**,不参与交易、不写入任何数据、不持有密钥。
4
4
 
5
- ## 本次更新(v1.8.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,已发布)
6
30
 
7
31
  **一句话:口语词不再对不上字——「律所」「饭店」「保洁」直接命中,完全没听过的词
8
32
  也会收到货架初筛候选,语义归一这一步正式交给 LLM,但候选来源归 server。**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "beacon-mfg-mcp",
3
- "version": "1.8.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",
package/server.py CHANGED
@@ -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
- etype: str = "") -> Dict[str, Any]:
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
- 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
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
- return _attach_routing_hint({
3064
+ out = {
2898
3065
  "total_matched": records_total,
2899
3066
  "total_brands": brands_total,
2900
3067
  "returned": len(page),
@@ -2906,8 +3073,13 @@ 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
- }, 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)
2911
3083
 
2912
3084
  # 未给国标码:优先走「预构建索引 → 只拉命中分片」。
2913
3085
  # 索引缺失或陈旧时回退进程内全量索引(与命中量相关,慢但结果等价)。
@@ -3046,16 +3218,45 @@ def search_vendors(query: str = "", city: str = "", gb: str = "",
3046
3218
  # cap 与 text 同名次时仍按 name_fit 细分(品牌名命中 > 仅门店限定命中)。
3047
3219
  _RANK = {"cap": 0, "text": 1, "city": 1, "cap_text": 2, "alias": 3}
3048
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
+
3049
3227
  # 档内再按厂名贴合度排(见 `_name_fit` 的说明)。这是**档内**微调,不影响档间
3050
3228
  # 次序,也不影响 total_matched。末尾按 id 收尾,保证同档同分的顺序稳定
3051
3229
  #(否则等于按分片路径排,H 住宿餐饮恒在 Q 卫生前面)。
3052
- matches.sort(key=lambda x: (_RANK.get(x.get("via"), 9),
3053
- -_name_fit(x, gen_tokens),
3054
- 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)
3055
3246
 
3056
3247
  # 品牌归并(分页之前,理由见 `_collapse_brands`)。
3057
3248
  records_total = len(matches)
3058
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)))
3059
3260
  page, pmeta = _page_slice(matches, offset, limit, raw_offset)
3060
3261
  out = {
3061
3262
  "total_matched": records_total,
@@ -3076,7 +3277,12 @@ def search_vendors(query: str = "", city: str = "", gb: str = "",
3076
3277
  "via=alias(别名定向) → via=cap(能力定向);**同一档内**再按「关键词在"
3077
3278
  "厂名里成片出现」优先(所以「商务酒店」会把 龙翔商务酒店 排在 "
3078
3279
  "某酒店(中央商务区店) 前面);score 是**能力画像分**"
3079
- "(有已发布能力卡才有分,无卡为 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 {}),
3080
3286
  }
3081
3287
  if alias_hits:
3082
3288
  out["alias_expanded"] = [{"word": a["word"], "gb": a["code"], "name": a["name"]}
@@ -3325,8 +3531,10 @@ TOOLS = [
3325
3531
  "制造、电热燃水、建筑、批发零售、交通运输、住宿餐饮、信息软件、金融、"
3326
3532
  "房地产、租赁商务服务、科研技术服务、水利环境、居民服务、教育、卫生、"
3327
3533
  "文化体育娱乐、公共管理……)。可按关键词(名称/工艺/材料/认证/服务内容)、"
3328
- "城市或区县、国标码过滤。返回精简档案(id/名称/城市/区县/国标码/认证等级/"
3329
- "工艺/材料/认证/是否含电话/连锁门店数)。"
3534
+ "城市或区县、国标码过滤。支持坐标检索:传 lat/lng 按距离排序、"
3535
+ "加 radius_km 只留圈内(地标名需调用方先解析成坐标)。"
3536
+ "返回精简档案(id/名称/城市/区县/国标码/认证等级/"
3537
+ "工艺/材料/认证/是否含电话/连锁门店数/距离)。"
3330
3538
  "⚠ 关键词是**字面子串**匹配:用户的口语词若与名录写法不同(如「机加工」"
3331
3539
  "vs 归档用的「机械零部件加工」、「面馆」vs「苏式面馆」),命中会偏少。"
3332
3540
  "此时不要直接报「没有」——看返回体里的 hint / suggested_gb,"
@@ -3355,6 +3563,17 @@ TOOLS = [
3355
3563
  "流通 = F批发零售/G交通运输仓储邮政;"
3356
3564
  "服务 = H住宿餐饮…S公共管理。"
3357
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;连锁品牌行报最近门店距离"},
3358
3577
  },
3359
3578
  },
3360
3579
  },