AI City Remix:用 Wikidata 和 OSM 自动核对 AI 写的城市脚本

给 AI City Remix 作品加一道可脚本化的事实核对:Wikidata 坐标、OpenStreetMap 名称、行政边界和带年份的说法,在维也纳和京都两份脚本上实测。

Fact-Check Your City Script 标题,旁边是一台扫描城市地图面板、发着绿光的机器

MiniMax M3.1 Flash 给我们写的京都脚本,第二个时代开头一句是:「约 960 年,一条天皇把皇宫迁到了现在的位置。」可一条天皇是 986 年到 1011 年在位的,现在的京都御所从 1331 年起才是皇宫。这句话照样通过了 Skill 的校验,字幕也干干净净地压在一张点亮的平安京地图上。整条流水线里,没有哪一步是为了发现它而设计的。

这篇要补的就是这个缺口。在 SandBase 的城市故事视频挑战赛 AI City Remix 里,准确性占 25%,AI 工作流占 30%,而工作流这一项奖励的是更少的人工干预。在写完脚本、付钱生成旁白之前插一道自动核对,两头都照顾到了。下面是我们写的核对脚本:对照 Wikidata、OpenStreetMap 和 Wikipedia 做四项检查,每座城市的模型开销约 $0.0005。我们拿 M3.1 Flash 写的两份脚本试了一遍,一份维也纳、一份京都。它抓到了什么、漏了什么、误报了什么,下面都会写清楚。

自动核对 AI 城市脚本:先说结论

  • 四项自动检查能抓住 city-through-time story.json 里大部分常见问题:地标坐标对不上 Wikidata、OSM 名称一个都匹配不到、老时代套用现代行政边界、带年份的说法在参考文章里找不到依据。
  • 维也纳脚本报了 9 个标记,覆盖了我们手工找出的 6 个问题中的 4 个,漏了 2 个。
  • 新写的京都脚本报了 13 个标记,其中有京都塔偏离真实位置 6.4 公里、960 年的天皇写错、织田信长入京写成 1573 年(实际是 1568 年)。
  • 它也有误报,两条都是真实的年份:1333 年镰仓幕府灭亡确实不在「History of Kyoto」里;维也纳 1910 年人口那条,「History of Vienna」里有,但所在句子和说法没有共同关键词。
  • 标记的意思是「去看一眼」,不是「这句错了」。它能把一份 8 个时代的脚本缩到十来条需要人读的标记说法和人工清单,而且是在花任何媒体费用之前。

只靠校验器为什么不够

city-through-time 这个 Skill 分工很清楚。导演模型写 story.json:时代、旁白、坐标、OpenStreetMap 引用和兜底轮廓。脚本负责拉取真实几何,通过 SandBase 生成旁白和插图,再渲染成片。其中 resolve.mjs 会在拉数据之前先校验格式。

它只校验格式。Skill 自己的文档就写着:语言模型会把事实和坐标弄错,兜底轮廓偏出好几公里很常见。在我们的 AI City Remix 参赛指南里,一份维也纳脚本格式全过,却还有 6 处要改。在 模型对比实测里,M3.1 Flash 开 low 时 9 次都返回了合法 JSON,但它画的城墙只框住了 15 个指定地标里的 5 个。

时机和检查本身一样重要。那份维也纳故事的旁白加插图估价约 $0.69,生成之后再改一句,就得删掉对应的音频重新付费。所以下面每一项检查,都放在 media.mjs --confirm 之前跑。

四项检查

检查数据来源标记什么成本
坐标Wikidata 坐标属性(P625)任何 events[].at 离城市附近对应条目超过 0.5 公里免费
OSM 名称Overpass API 计数查询任何匹配数为 0 的 shape.osm 引用,这时影片会退回模型自己画的多边形免费
行政边界story.json 本身最后一个时代以外,任何用了 boundary=administrative 的引用(把今天的形状套到过去)免费
带年份的说法M3.1 Flash 列出说法,再搜索 Wikipedia「History of <城市>」说法里的年份,没有出现在任何一个与说法共享关键词的句子里每座城市约 $0.0005

坐标:从 Wikidata 取

事件卡片带一个 at 坐标,是模型凭记忆写的。大部分地标的坐标 Wikidata 早就存着。查询按卡片标题搜索,只保留第一个坐标在城市中心 40 公里以内的结果,免得「Kyoto Tower」匹配到别的国家的同名条目:

import json, math, time, urllib.parse, urllib.request

UA = {"User-Agent": "citycheck/1.0 (your-name@your-domain)"}  # use a real contact; overpass-api.de rejects example.com (HTTP 406)

def get_json(url, data=None, timeout=60):
    req = urllib.request.Request(url, data=data, headers=UA)
    with urllib.request.urlopen(req, timeout=timeout) as r:
        return json.loads(r.read())

def km(a, b):
    """Great-circle distance between two [lat, lon] points."""
    la1, lo1, la2, lo2 = map(math.radians, (a[0], a[1], b[0], b[1]))
    h = math.sin((la2 - la1) / 2) ** 2 + math.cos(la1) * math.cos(la2) * math.sin((lo2 - lo1) / 2) ** 2
    return 2 * 6371.0088 * math.asin(math.sqrt(h))

def wikidata_coord(title, city_centre, max_km=40):
    q = urllib.parse.urlencode({"action": "wbsearchentities", "search": title,
                                "language": "en", "format": "json", "limit": 7})
    for hit in get_json("https://www.wikidata.org/w/api.php?" + q).get("search", []):
        ent = get_json(f"https://www.wikidata.org/wiki/Special:EntityData/{hit['id']}.json")["entities"][hit["id"]]
        claim = (ent.get("claims", {}).get("P625") or [{}])[0]
        c = claim.get("mainsnak", {}).get("datavalue", {}).get("value")
        if c and km((c["latitude"], c["longitude"]), city_centre) <= max_km:
            return hit["id"], c["latitude"], c["longitude"]
        time.sleep(0.2)  # stay polite to the public endpoint
    return None

如果标题描述的是事件而不是地点(比如「Electric streetcars arrive」「Heian Court」),就查不到结果。脚本不会去猜,而是把它们列进人工清单。维也纳 14 张卡片里有 9 张进了人工清单,京都 16 张里有 8 张。

OSM 名称:一个都对不上

shape.osm 引用查不到东西时,resolve.mjs 只打一行警告,然后悄悄换成模型手画的多边形。影片接着会把这个多边形的面积打在屏幕上,看起来像是量出来的。用一次计数查询,在 resolve 之前就能发现:

def overpass_count(ref, centre):
    """Number of OSM ways and relations matching a story.json osm reference near the city."""
    r = ref.get("within_km", 25) * 1000
    f = "".join(f'["{k}"="{v}"]' for k, v in (ref.get("tags") or {}).items())
    if ref.get("name"):
        f += f'["name"~"{ref["name"]}"]'
    ql = (f"[out:json][timeout:60];(way{f}(around:{r},{centre[0]},{centre[1]});"
          f"relation{f}(around:{r},{centre[0]},{centre[1]}););out count;")
    d = get_json("https://overpass-api.de/api/interpreter",
                 data=urllib.parse.urlencode({"data": ql}).encode(), timeout=90)
    t = d["elements"][0]["tags"]
    return int(t.get("ways", 0)) + int(t.get("relations", 0))

公共 Overpass 服务器经常很忙。我们的脚本会换一个镜像再试,两个都失败就报 UNKNOWN,不会误报成 0。京都这次就碰到一回,京都御所 那条报了 UNKNOWN,后来 resolve.mjs 对它也只拿到 0 个点。User-Agent 里要写真实的联系方式:我们测试时,overpass-api.de 对 example.com 这种占位地址直接返回 HTTP 406。

老时代套现代边界

这一项完全不用联网。OSM 里的行政边界是城市今天的形状,拿去给 1910 年或 1889 年用,影片就会显示一次当时还没发生的扩张。脚本会标出最后一个时代之外所有的 boundary=administrative 引用。

带年份的说法:对照参考文章

最后一项是唯一用到模型的检查,也是唯一要判断文字的检查。我们通过 SandBase 调用 M3.1 Flash(reasoning_effort: "low"),一次请求把所有旁白都发过去,让它以 JSON 列出带年份的说法。提示词只要求列出,不要求判断真假。脚本再去取英文 Wikipedia 的「History of <城市>」。只有当年份和说法里至少一个实词出现在同一个句子里,才算找到依据。我们第一版只要年份出现在文章任何地方就放行,结果页面别处一个孤零零的「100」或「1910」就让不该过的说法过了。

这个检查刻意做得很窄。它能找出在某一篇参考文章里找不到依据的年份,但没法证明一句话是对的,也分不清是「没写到」还是「写错了」。

两份真实脚本的结果

两份脚本都是 M3.1 Flash 开 low、照 Skill 的「一次一个时代」写法写的。维也纳是参赛指南里那一份。京都是 10 月 24 日新写的:9 次调用,245 秒,$0.011。然后对两份各跑一次核对。

核对脚本输出:维也纳 9 个标记、京都 13 个标记,列出与 Wikidata 的坐标偏差、匹配数为 0 的 OSM 引用、过去时代套用的现代边界,以及在城市历史文章里找不到的带年份说法 两份脚本的核对输出,由 vienna_report.json 和 kyoto_report.json 渲染(2026-10-24)。只列出了标记项。「not in History of …」的意思是,没有哪个句子同时包含这个年份和说法里的关键词。每块最后两行是坐标、说法的通过数,以及那一次模型调用的费用。

之后,我们对照 Wikipedia 和 Wikidata,把每个标记都人工核了一遍。

城市标记查过来源后的结论
京都京都塔卡片离 Wikidata 坐标 6.4 公里真错:京都塔就在京都站对面,卡片却钉在了城市北部
京都「约 960 年,一条天皇迁皇宫」真错:一条天皇 986–1011 年在位;京都御所 从 1331 年起才用作皇宫
京都「织田信长 1573 年入京之后」真错:信长 1568 年 11 月 9 日入京;足利幕府 1573 年才结束
京都御所卡片偏 1.3 公里(两个时代都是);银阁寺偏 1.4 公里真错:御所卡片偏到了真实御所以西,银阁寺卡片偏到了寺北
京都朱雀门偏 1.0 公里说不清:城门早已不存在,Wikidata 的点和模型推测的位置本来就不同
京都二条御城、^(Heian-kyo|平安京)$ 和 6 级的 ^京都市$ 都匹配不到属实:OSM 里叫 二条城,没有平安京轮廓,京都市边界在 OSM 里是 admin_level 7
京都1889–1973 时代用了 ^京都市$ 边界属实:那是今天的市界
京都「1970 年代以来京都核心区人口下降」待补出处:我们既没证实也没证伪
京都「镰仓幕府 1333 年灭亡」误报:说法正确,只是「History of Kyoto」里没写
维也纳1143 年亨利二世公爵授予城市权真错(手工也找到了):1155 年定都,1156 年升公国,1221 年获城市权
维也纳1857 年扩张「并入 19 个郊区」真错(手工也找到了):郊区是 1850 年并入的
维也纳公元前 500 年左右的凯尔特部落待补出处(手工也找到了)
维也纳Wiener Stadtmauer 三处,以及 1910 年用 Wien 边界属实:OSM 匹配不到,且把今天的边界用在了过去
维也纳《奥地利国家条约》卡片偏 2.1 公里说不清:条约不是一个地点,Wikidata 的点和模型的点本来就不同
维也纳「1910 年人口超过 200 万」误报:说法正确,「History of Vienna」里也有 1910 年 2,031,000 人这一句,只是和说法没有共同关键词(population 对 million、people)

维也纳那边,有两个手工找到的问题从核对里漏过去了。一个是罗马时期的「公元前一世纪之交」被判为找到依据,因为文章里「during the 1st century」那句的「1」被当成了公元 1 年,句子里又恰好有「Romans」这个词。可文多波纳的军团营地其实是约公元 97 年修的。另一个是议会卡片,钉在离议会大厦 11 公里的地方,但它的标题是描述性的(「A New Parliamentary Vienna」),没匹配到任何 Wikidata 条目,于是进了人工清单,没有被标记。

测试范围先说清楚:两座城市,每座一份脚本,同一天。标记数量取决于模型怎么写卡片标题,也取决于每篇「History of」文章写得全不全。把它当成一次观察就好,不是评测基准。

标记落在画面上是什么样

看到标记落在片子的哪一帧,更容易排优先级。下面几张是未经修改的京都脚本只渲染静帧的结果,一分钱没花。

京都影片静帧:标注 c. 960 Imperial Palace,点亮的街道网格上方字幕写着一条天皇约 960 年把皇宫迁到现址 第 20 秒静帧:c. 960 这一段,字幕就是被说法检查标出来的那句一条天皇。背景的街道网格是真实 OSM 数据,但年份和天皇都是错的。

京都影片静帧:1973 年至今这一段,轮廓约 444 平方公里,京都塔卡片钉在市中心以北 第 67 秒静帧:京都塔卡片离真塔 6.4 公里。现代轮廓也是模型的兜底多边形,因为 6 级边界在 OSM 里一个都没匹配到。

京都影片静帧:1603 到 1868 年这一段,二条城周围一团约 20 平方公里的柔光,下方是江户时代人口的统计行 第 40 秒静帧:二条城卡片通过了坐标检查(离 Wikidata 50 米)。也有卡片是对的,核对脚本不会去动它们。

接进参赛流程

完整的核对脚本大约 200 行 Python,只用标准库。参加 AI City Remix 的话,我们建议按这个顺序跑:

  1. 用 M3.1 Flash 写 story.json,一次一个时代,effort 开 low。
  2. 跑核对。被标记的坐标,全部换成它打印出来的 Wikidata 值;匹配数为 0 的 OSM 引用,改名或换引用,直到计数查询至少返回 1。
  3. 读被标记的说法和人工清单。这是唯一需要人的一步,而现在只剩十来行,不是整份脚本。
  4. 跑 resolve.mjs,再在改过的时代上跑 film.mjs --stills。
  5. 最后才跑 media.mjs --confirm。

如果你把这个核对脚本连同作品一起开源,它也正好对上工作流评分:人工更少、有新工具,按活动规则开源还额外加分。但第 3 步的人工阅读别省。在我们这两份脚本里,核对漏了两处真错、误报了两处。要是完全无人值守,那两处漏掉的错就直接上片了。

常见问题

需要付费 API 吗?

只有说法检查会调用模型:每座城市通过 SandBase 调用一次 M3.1 Flash,我们实测花费 $0.0004–$0.0005。Wikidata、Wikipedia 和 Overpass 都是免费的公共服务,请控制请求频率,并带上能说明来意的 User-Agent。

它能告诉我一句话是真的吗?

不能。它只告诉你:某个年份有没有在一篇参考文章里、和匹配词出现在同一句话中。「找到」的说法照样可能是错的,文多波纳那条就是例子;被标记的说法也可能是对的。用它来决定该读哪几行。

为什么不让模型自己核对自己?

因为这次效果最好的几项检查根本不信任模型:坐标来自 Wikidata,轮廓来自 OpenStreetMap。唯一那次模型调用只负责列出说法,判断真假交给代码和人。

维也纳和京都以外的城市也能用吗?

坐标、OSM 和边界三项检查跟城市无关。说法检查需要一篇英文 Wikipedia「History of <城市>」条目;小地方没有的话,就换成城市主条目,或者你信得过的其他参考来源。

用这个会违反 AI City Remix 的规则吗?

不会。规则要求由 M3.1 Flash 导演,并允许围绕它搭任意工具链。这个核对脚本只是加了数据查询,唯一的模型步骤用的也是 M3.1 Flash。

城市脚本写起来便宜,错在屏幕上却很贵。对照开放数据做四项检查,就能把「每一行都要读」变成「只读十来行」。这是参赛流程里最适合自动化的部分;剩下要读的内容短到可以认真读完,准确性的分数就是从这里来的。