Files
planet/docs/technical/zh/location-pipeline-development.md
rayd1o eb4c4b7904
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
release: bump version to 0.66.2
2026-05-26 08:45:33 +08:00

12 KiB
Raw Blame History

通用位置估算管线开发说明

backend/app/services/location/ 是所有“给定一条记录,决定它的 lat/lon”业务的共享抽象。算力中心、BGP 观测站、BGP 事件目前都跑在这条管线上。未来需要位置估算的实体例如卫星地面站、用户认领点位、IXP 设施,也应接入这里,而不是各自再写地理解析逻辑。

用户侧流程见 智能星球使用手册 的 Earth 位置候选采集章节。

设计目标

历史上算力中心有自己的 4 层链路BGP 观测站使用写死字典BGP 事件继承 collector。三套实现互不复用新算法也没有稳定挂入点。

重构后的原则:

  • 共享 LocationResolver 协议和 LocationPipeline 编排器。
  • 各领域只负责构造 LocationQuery 和选择 resolver 顺序。
  • 新算法通过新增 resolver 类接入,不改 ingestion、API 和前端 envelope。
  • 只有达到城市级或更高精度的位置能渲染到 Earth。
  • 本地 JSON registry 不作为算力中心或 BGP 观测站的运行时候选来源;持久事实写入数据库维表。

核心接口

@dataclass(frozen=True)
class LocationQuery:
    name: str | None
    aliases: tuple[str, ...]
    city: str | None
    country: str | None
    region: str | None
    source_latitude: float | None
    source_longitude: float | None
    extra: Mapping[str, Any]
@dataclass(frozen=True)
class LocationCandidate:
    latitude: float
    longitude: float
    display_name: str
    precision: str
    confidence: float
    source: str
    needs_confirmation: bool
    matched_fields: tuple[str, ...]
    suggested_registry_entry: dict | None
class LocationResolver(Protocol):
    name: str
    def resolve(self, query: LocationQuery) -> ResolverOutput: ...

LocationPipeline.collect_candidates() 返回排序后的候选和 attempted_queriesresolve_best() 返回最佳候选及诊断信息。默认排序按 source rank、precision rank、confidence且对同 source 和同坐标候选去重。

内置 resolver

Resolver 文件 职责
SourceCoordinatesResolver resolvers/source_coordinates.py 源记录已有 lat/lon 时直接产出 precision="precise"
RegistryResolver resolvers/registry.py 遗留通用 resolver当前算力中心和 BGP 运行时链路不使用它生成候选
NominatimResolver resolvers/nominatim.py 按领域 query plan 调 Nominatim带 LRU 缓存和速率限制
InheritFromAnotherEntityResolver resolvers/inherit.py 把外部实体的已解析位置包装为候选
LocationLLMFallback location/llm_fallback.py 用户触发候选采集且常规候选为空时,通过当前默认 AI Provider 生成待确认候选

Nominatim 是 OpenStreetMap 生态里的地理编码服务:给它一个地点名称、城市、国家或机构查询文本,它会返回可能匹配的经纬度、展示名称和地址结构。它适合把“城市/机构/园区名称”转成候选坐标,但不是权威事实库,可能命中同名地点或过宽泛的行政区,所以本项目只把它作为待确认候选来源,并带缓存和速率限制使用。

RegistryResolver 仍保留给后续可能的受控导入场景,但它不应被重新接入算力中心或 BGP 作为“硬编码 hint”候选源。过去仅凭 operatorcity 等通用字段匹配 registry 容易把多个实体落到同一个点,这是这次下线 registry 候选链路的主要原因。

当前领域管线

算力中心

入口文件:

管线顺序:

SourceCoordinatesResolver()
StoredComputeCenterLocationResolver()

主地图启动链路只做“源坐标优先,其次数据库维表坐标”。数据库表为 compute_center_locations,唯一键是 (source, source_id),用于保存人工确认或从源记录真实坐标迁入的位置。init_db() 只幂等迁入源记录里已有的真实经纬度,不迁入旧硬编码 hint不在启动期批量调用 ROR、Nominatim 或 LLM。

手动候选采集链路和渲染链路分开。collect_location_candidates() 使用源字段构造 ROR 和 Nominatim/OpenStreetMap 查询,但不会把 compute_center_locations 当前坐标当候选返回。如果这些常规候选为空API 层会调用 LocationLLMFallback,通过当前默认 AI Provider 进行位置 factcheck并只返回 source="llm_location_factcheck"needs_confirmation=true 的候选。LLM 候选使用“模型自评分 + 后端证据评分”的组合阈值;如果 LLM 只给出可信 city/country 而没有坐标,后端会用 Nominatim 补城市级坐标,但不会因此提高证据分。用户在前端确认某个候选后,通过保存接口写入维表;之后地图刷新时由 StoredComputeCenterLocationResolver 渲染。

resolve_compute_center_location()resolve_compute_center_location_full()collect_location_candidates() 保留为领域 API。visualization.py 只消费领域 API不再持有坐标提示常量、国家质心兜底或 Nominatim 细节。

GeoJSON 输出只包含 RENDERABLE_PRECISIONS 内的位置。未解析记录进入 unresolved,并带上 failure_reasonattempted_queriessource_idrecord_id 等诊断字段。

BGP 观测站

入口文件:

管线顺序:

SourceCoordinatesResolver()
StoredCollectorLocationResolver()
NominatimResolver(_bgp_collector_query_plan)

23 个 RIPE RIS collector 坐标从旧表迁入 bgp_collector_locations 维表,默认 source=legacy_seedneeds_confirmation=true。旧字典仍由 DB-backed cache 维护,保证下游接口兼容;手动候选采集不会把这份维表坐标当作候选,只用它补齐 site/city/country 查询上下文。若 Nominatim 也无法产出城市级候选,采集接口会用当前默认 AI Provider 做 LLM factcheck 兜底,返回待确认候选而不是自动保存。

BGP 事件

入口文件:

管线顺序:

SourceCoordinatesResolver()
InheritFromAnotherEntityResolver(_inherit_from_owning_collector)

事件继承使用所属 collector 的严格查找,不跑完整 collector registry 模糊匹配。后续 ASN 设施、PrefixGeo 或 PeeringDB resolver 可以挂在继承 resolver 之后。

API envelope

POST /api/v1/visualization/compute-centers/{source_id}/collect-location
POST /api/v1/visualization/compute-centers/{source_id}/location
POST /api/v1/bgp/collectors/{collector_id}/collect-location

collect-location 返回统一 envelope

{
  "success": true,
  "candidates": [],
  "best_candidate": {},
  "attempted_queries": [],
  "context": {}
}

LLM 兜底只发生在用户触发的 collect-location 请求中,并且只在常规候选为空时运行。它不会在 /geo/compute-centers 启动渲染、定时采集或批量入库流程中自动调用,也不会直接写入 compute_center_locationsbgp_collector_locations。LLM 兜底内部不是“一次严格 JSON 成败”的单点链路,而是小型结构化管线:先请求 LLM 做位置 factcheck若返回不是 JSON再发起一次“只从原文抽取、不新增事实”的结构化修复若修复仍失败则只从原文中保守抽取 city/country。随后统一由后端补坐标、算综合分并决定是否生成候选。

这条链路允许 LLM 只给出“DeepL Mercury 位于 Falun, Sweden”这类城市级事实由后端用 Nominatim 补城市坐标;也允许模型第一轮输出自然语言,第二轮再归一化成 JSON。无论哪条路径只有 precisesitecity 精度、非零坐标和足够综合分的结果会被转换成候选;失败、低分、只有国家级信息或无法抽出城市的响应会保留为诊断信息。

LLM 返回的 confidence 只是模型自评,后端会重新计算综合分并把它作为候选 confidence

combined =
  0.25 * model_confidence
  + source_quality
  + entity_match
  + geography_match
  + precision_quality
  + name_location_hint
  - conflict_penalty
  - weak_evidence_penalty

当前分项上限:权威/政府/高校来源最高 0.35,可信数据库/新闻最高 0.25,普通网页最高 0.15;证据明确命中实体名最高 0.25;城市+国家匹配 0.20,只有国家匹配 0.05;精度项 precise=0.15site=0.12city=0.08;实体名与候选城市互相命中时增加 name_location_hint,例如 TAIPEI-1Taipei;明确冲突最多扣 0.45,普通弱证据措辞最多扣 0.30,在实体和城市国家都已命中且无冲突时弱证据扣分封顶 0.15。综合分低于 0.55 的候选会被拒绝。这样 Alem.Cloud、TAIPEI-1 这类“模型自评分偏低,但实体和城市证据一致”的结果可以被后端公式拉回到可确认候选;真正证据弱或有冲突的结果仍会被拒绝。

POST /api/v1/visualization/compute-centers/{source_id}/location 把前端选中的候选 upsert 到 compute_center_locations。人工保存默认 needs_confirmation=falseverification_status="verified" 并写入 verified_at;如果后续接入自动暂存,也可以显式传 needs_confirmation=true

前端 info-card.js 使用通用候选列表和预览事件渲染对象详情卡。算力中心图层按钮左上角会显示 unresolved 数量;点击角标打开待定位列表。列表中的 采集 只拉候选,一键采用 会逐条调用候选采集接口,选择最高置信且有有效经纬度的候选保存。保存成功一条就从列表移除并重新编号,同时通过 earth:compute-center-unresolved-count-change 同步角标;批量结束后再触发 earth:compute-center-location-saved 刷新真实图层。

如果剩余记录没有任何 city-level 候选,批量采用不会伪造坐标。前端会保留这些记录并展示后端返回的 failure_reason 和已尝试查询。

新增 resolver

resolver 只需要实现 nameresolve(),返回 ResolverOutput

class PeeringDBFacilityResolver:
    name = "peeringdb_facility"

    def __init__(self, client):
        self._client = client

    def resolve(self, query):
        asn = query.extra.get("origin_asn")
        if not asn:
            return ResolverOutput()
        return ResolverOutput(candidates=tuple(
            LocationCandidate(
                latitude=f.latitude,
                longitude=f.longitude,
                display_name=f.name,
                precision="site",
                confidence=0.78,
                query=f"peeringdb::{asn}",
                source=self.name,
                source_note=f"PeeringDB facility for AS{asn}",
                matched_fields=("origin_asn",),
                needs_confirmation=False,
                city=f.city,
                country=f.country,
            )
            for f in self._client.facilities_for_asn(asn)
        ))

挂入:

BGP_EVENT_PIPELINE = LocationPipeline([
    SourceCoordinatesResolver(),
    InheritFromAnotherEntityResolver(source_lookup=...),
    PeeringDBFacilityResolver(client=peeringdb_client),
])

测试覆盖

相关测试:

测试重点包括 resolver 可插拔性、注册表 alias 约束、BGP collector 兼容字典、算力中心公共 API 兼容、不可渲染位置进入 unresolved