舞台工艺审图 × AI 智能体:设备清单两册逐行 diff 前,先把「分母覆盖率」算清楚

一、背景:比得很细,却可能比错了

某音乐厅项目舞台音视频初步设计(20240906 报审版)覆盖五个厅:音乐厅、多功能剧场、多功能厅、录音棚及转播、表演厅及培训教室。这一版报审之后形成的审查意见共 89 条,业主方复核 82/89。设备清单按齐套性要求分册装订,随系统图一起提资给下游工序:调光柜的回路划分、吊杆的点位与荷载、DMX512 的地址分配,都要从这两册清单里取数。图面表达上能看到 JGJ 57-2016《剧场建筑设计规范》、WH/T 系列以及 GB/T 50001 的成例——设备表怎么排、标题栏里图号与版本怎么标,都有章法可循,这里只作为背景点到,不展开。

清单两册逐行 diff 之前先算清分母,分母不对,逐行比得再细也是假对账。

这句话不是一句漂亮的口号,是这一批清单真的出过问题之后才补上的一条工程约束。当时对两册清单做逐行 diff,校验命中 11 条,落在音乐厅 #20/#21、多功能厅 #10、录音棚及转播 #11/#12 等位置。11 条本身并不难处理,难的是处理完之后怎么复述这个结果:这 11 条究竟是"全部"失配,还是"当前这套对齐方式能够看见"的失配?如果对齐方式本身会吞行、会错位、会把合计行也当成一台设备拉进来配对,那么 11 这个数字就是被污染过的分子,而分母从头到尾没有被摊开看过。分母没摊开,逐行比得再细也只是在一小块被切过的样本上做精雕。

朴素做法长什么样,做过表的人都熟:把两册各读成一张表,拿册内序号当连接键一拼,然后逐列比字符串,不一样的打出来。这套动作在 AI 智能体检查里特别容易被当成"已经完成对账"——因为它确实逐行比了,确实输出了差异清单,看起来很勤劳。但这一批清单上,它同时踩了三个软肋。

软肋一:册内序号不是实体标识。 序号是分册装订时各厅各自起号的流水号,跨册不可靠,同册内部还出现了两条不同设备共用一个序号的情况。按序号做连接,重号的两行要么被后写覆盖先写、静默吞掉一行,要么两两相乘鼓出多余的配对行。无论哪一种,分母都在没人报错的情况下被改了。

软肋二:数量列与单位列会被互换。 同一台设备,一册写"数量 2、单位 台",另一册导出时列序被重排成"数量 台、单位 2",甚至整列错位。字符串层面的逐列比对会把这种列位差异报成"数量不一致+单位不一致"两条假失配;反过来,如果比的时候只认列名不认内容,又可能把真正的数量差错一起放过去。

软肋三:合计行混在明细里。 分项数量求和与合计行登记数对不上是另一类问题;但如果合计行没被剥出来,它会被当成一台普通设备参与配对,制造一条根本不存在的孤儿行,把分母再搅一次。

这三处都不产生异常、不抛错、不留日志,只是悄悄改变"到底比了多少行"。所以这一篇把范围收得很窄,只讲一条主流程链:两册读取 → 复合对齐键构造 → 分母覆盖率计算 → 孤儿行先报 → 字段级 diff → 合计闭合断言。不谈别的环节,也不把这条链包装成什么通用的审核框架。核心动作只有一个——把分母显式化:在说"两册一致"或者"命中 11 条"之前,先让脚本把"应比对多少行、实际配上多少行、哪些行没配上"三个量打印出来。AI 智能体在这件事上真正该干的,不是把字段比得更细,而是把"比了多少行"变成可打印、可复核、可追溯的量。

对舞台同行来说,这条链的价值很直接:齐套性核对、提资取数、系统图下游的点位与回路分配,吃的都是这两册清单;被吞过行的清单流到现场,返工的成本不在脚本里,在吊杆和机柜上。对做 AI 工程的读者来说,这是个更通用的模式:任何两源表对账,集合运算必须前置于字段比较,覆盖率是前置必要条件而不是结论。跨行业读者不熟的少数术语,我在首次出现处各补一句白话说明;说明是桥,不会把专业内容削平。

二、环境与前置依赖

运行环境

  • Python 3.10 及以上(本机实测 3.13)。类型标注用到内置泛型与 str | Path 写法,3.10 起可直接用。
  • 只用标准库:csv、re、collections.Counter、dataclasses、pathlib、typing。全程不联网、不装第三方包、不连数据库。
  • 不引入额外的表格计算库。这一批清单的行规模是几十到几百行量级,集合运算用标准库足够;把依赖压到零,脚本才能直接在报审现场一台没配环境的机器上跑起来。

先确认版本:

python --version
# Python 3.13.x
mkdir -p ./books && ls ./books
# 第一册.csv  第二册.csv

输入从哪来

两册清单各导出一份 CSV,放在同一目录。导出路径只说结果、不展开过程:DWG 里那种行列结构本身就是数据的表格对象(ACAD_TABLE,白话注解:AutoCAD 里带行列线的表格对象,行列是真实的数据单元,不是画上去的线)能直接导出成 CSV;能直接读出字符的那类文字对象(文字层,白话注解:DWG 里程序可以直接取出字符的内容,不是打印出来才看得见的笔画)也走同一份导出;只有被转成像素图的页面(栅格化,白话注解:把矢量线条转成一格格像素、只剩图像没有字符的过程)另行处理,不进本篇脚本。标题栏(白话注解:图纸右下角写图名、图号、比例、版本日期的那一栏信息)里的图号与版本号,是复合对齐键里"图号"一项的来源。这些名词只是交代输入出处,不涉及任何取证方法。

CSV 字段约定(八列,表头固定)

  • hall — 厅别,取五个厅之一。
  • drawing — 图号,来自标题栏。
  • tag — 设备位号,清单里唯一标识一台设备的编号。
  • seq — 册内序号,仅用于展示与重号检测,不作对齐键。
  • name — 设备名称。
  • qty — 数量列原文,可能是数值,也可能被互换成了单位词。
  • unit — 单位列原文,可能互换。
  • model — 型号/规格。

编码与空白约定

  • 一律以 utf-8-sig 读取。CAD 导出的 CSV 常带 BOM,用普通 utf-8 读会让首列表头带上看不见的前缀字符,键字段莫名其妙对不上。
  • 复合键的三个字段一律 .strip()。手工补录常带全角空格或尾部空格,不清理会把本来该配上对的两行判成两条孤儿。
  • 合计行按 tag 为 SUBTOTAL/TOTAL/合计 识别,读取阶段只打标、不删除;删不删由后面的剥离步骤决定。

为什么对齐键不用序号

序号在册内是给人看的流水号,跨册没有约定,同册还能重号。真正稳定的是设备在图面上的身份:哪个厅、哪张图、哪个位号。于是本篇把对齐键定成三元组 (厅别, 图号, 设备位号)。这个决定是后面所有计算的起点——分母是不是算得清,第一眼就看键选得对不对。键选错了,后面覆盖率算得再漂亮也只是在错误的集合上做算术。

本篇的边界

脚本只在内存里做集合运算与字段比较,输出是标准输出上的几行诊断。它不写库、不做重放、不留审计痕迹、不设任何回读环节;跑完即止,结论只对应这一次输入的两册文件。把这个边界划清楚,是为了让"分母覆盖率"这一件事被讲透,而不是把一堆相邻环节混在一起稀释掉。

三、步骤:把分母显式化的主流程链

下面按执行顺序拆这条链。每一步都写清输入、输出、以及跳过去会发生什么——跳步的后果比步骤本身更重要,因为假对账几乎全是跳步跳出来的。

步骤 1 两册读取与合计行剥离

  • 输入:两份 CSV 路径。
  • 动作:csv.DictReader 逐行读成 Row,八个字段统一 strip(),tag 为空的行跳过,命中合计标识的行打上 is_total 标记。
  • 输出:两个 Row 列表,以及随后被拆开的"明细"与"合计"两组。
  • 为什么不能跳:合计行与明细共用同一张表、同一套列,导出时没有类型标记。不剥离,它就会以一台"设备"的身份进入唯一键集合,凭空多出一个分母单位,还常常配不上对,变成一条假孤儿行。
  • 注意:剥离只影响"参与配对的行",不影响"被核对的行"。合计行不是丢掉,是挪到链尾单独走合计闭合断言。

步骤 2 复合对齐键构造

  • 输入:明细行。
  • 动作:每行取 (hall, drawing, tag) 三元组,作为该行在集合运算里的唯一身份;seq 降级为展示字段,只用于打印和重号检测。
  • 输出:两册各自的键列表 keys_a、keys_b(保留重复,不去重)。
  • 为什么不能跳:这一步决定了"行"的定义。用序号当键,重号的两条不同设备会被合并成一行;用三元组当键,它们是两行,各自都要在分母里被交代。
  • 关键取舍:键列表保留重复、集合另算。先保留重复才能用 Counter 把重号报出来;一上来就 set() 等于把重号证据抹掉,正是"静默吞行"的脚本版。

步骤 3 各自唯一键集合与交集

  • 输入:keys_a、keys_b。
  • 动作:set_a = set(keys_a)、set_b = set(keys_b)、inter = set_a & set_b。
  • 输出:三个集合,外加两组差集 set_a - set_b、set_b - set_a。
  • 为什么不能跳:交集是"成功配对行数"的来源,两个差集是孤儿行清单。这三样东西都不依赖任何字段比较,纯集合运算,几行代码就能算完,却恰恰是朴素做法里完全缺席的一步。
  • 顺序意义:这一步必须排在字段级 diff 之前。先看能不能配上对,再谈配上对的行里字段一不一样。

步骤 4 分母覆盖率计算

  • 输入:三个集合。
  • 公式:覆盖率 = 成功配对行数 / 应比对行数,其中成功配对行数取 len(inter)。
  • 「应比对行数」的口径要写死,不能含糊:本篇取 并集 len(set_a | set_b)。理由是任何一册出现过的行都必须被交代,只在一册出现的行不是"不用比",而是"该比却没配上",必须计入分母。
  • 同时打印第二口径:以报审主册为基准的 len(inter) / len(set_a)。两个口径并列打印,供人复核分母到底该按哪本账算;主判据用更严的并集口径。
  • 输出:denom、paired、rate、rate_by_a 四个量,全部打印,不藏在变量里。
  • 为什么不能跳:没有这一步,"命中 11 条"就只是个孤零零的分子。11 条失配到底发生在 100 行的账上还是 60 行的账上,含义完全不同;分母不打印,这个数字无法被复核。

步骤 5 孤儿行先报,覆盖率未满一律不许下结论

  • 输入:rate 与两组差集、两组重号清单。
  • 动作:先打印三个量;再打印 Counter 检出的复合键重复与册内序号重复;若 rate < 1.0,直接返回失败并打印全部孤儿行,不进入任何字段比较。
  • 输出:要么是"覆盖率 100%,允许进入字段级 diff",要么是"禁止输出一致结论 + 仅 A 册/仅 B 册的孤儿行清单"。
  • 为什么不能跳:这是整条链的硬约束。覆盖率未满时,无论字段级 diff 跑出什么结果,都不构成对账结论——因为还有行根本没被比对过。先把没配上对的行摆出来,人才能去补册、拆重号、核图号,补完重跑。
  • 措辞上的讲究:这里禁止的只是"一致"这一类结论,不禁止诊断信息。合计闭合诊断、重号清单照样打印,但都标注为诊断,不构成对账结论。

步骤 6 已配对行的字段级 diff 与合计闭合断言

  • 输入:覆盖率 100% 之后的两册明细、两册合计行。
  • 动作一:数量/单位两列先做归一,把 (qty, unit) 当无序二元组处理,"2"+"台" 与 "台"+"2" 归一成同一个值;两边都判不出数值时保留原序、打待人工核标记,不擅自补零。归一之后再比 name、qty_unit、model 三个字段,逐条输出差异。
  • 动作二:合计闭合断言。按 (厅别, 图号) 把分项归堆,只把能判成数值的分项求和,与合计行登记数比较;非数值分项跳过并计数、明确提示"有若干行未按数值计入",不把跳过当成零。
  • 输出:字段级失配清单 + 合计异常清单 + 一行汇总(分母覆盖率、字段级失配条数、合计异常条数)。
  • 为什么不能跳:覆盖率 100% 只是"行都配上了对",不是"字段都相同",更不是"分项加得起来"。这一步才是逐行 diff 真正该待的位置——在分母闭合之后。

整条链走完,退出码也分了层:分母没闭合返回 2,字段或合计有问题返回 1,全清返回 0。这样脚本能被外层调度直接接住,不需要人去读输出猜状态。

四、代码:可复制的 Python 实现

下面五段按序拼成同一个 diff_denominator.py 即可运行;每段语法独立成立,合起来是一个完整文件。段后各附一句说明,第六段给一个不依赖外部 CSV、复制即跑的最小样例。

第一段:数据结构、复合键与两册读取

# -*- coding: utf-8 -*-
"""diff_denominator.py
设备清单两册逐行 diff:先算分母覆盖率,再下对账结论。
只用标准库,不依赖第三方包。
"""
from __future__ import annotations

import csv
import re
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable

# 复合对齐键:不使用册内序号,改用 (厅别, 图号, 设备位号)
AlignKey = tuple[str, str, str]

FIELDS = ("hall", "drawing", "tag", "seq", "name", "qty", "unit", "model")


@dataclass(frozen=True)
class Row:
    hall: str
    drawing: str
    tag: str
    seq: str
    name: str
    qty: str
    unit: str
    model: str
    is_total: bool = False

    @property
    def key(self) -> AlignKey:
        return (self.hall.strip(), self.drawing.strip(), self.tag.strip())


def load_book(path: str | Path) -> list[Row]:
    """把一册清单 CSV 读成 Row 列表;合计行在此打标,不在这里删。"""
    rows: list[Row] = []
    with open(path, "r", encoding="utf-8-sig", newline="") as fh:
        for raw in csv.DictReader(fh):
            item = {k: (raw.get(k) or "").strip() for k in FIELDS}
            if not item["tag"]:
                continue
            total = item["tag"].upper() in {"SUBTOTAL", "TOTAL", "合计"}
            rows.append(Row(**item, is_total=total))
    return rows

frozen=True 让 Row 不可变,键在集合里不会被后续改动悄悄带偏;utf-8-sig 专门吃掉 BOM;is_total 只打标不删行,把处置权留给下一函数。

第二段:合计行剥离、唯一键集合与分母覆盖率

def split_total(rows: Iterable[Row]) -> tuple[list[Row], list[Row]]:
    """把合计行从明细里剥出来,别让它污染比对分母。"""
    detail: list[Row] = []
    totals: list[Row] = []
    for r in rows:
        (totals if r.is_total else detail).append(r)
    return detail, totals


def coverage(book_a: list[Row], book_b: list[Row]) -> dict:
    """两册各自唯一键集合、交集与分母覆盖率。"""
    detail_a, total_a = split_total(book_a)
    detail_b, total_b = split_total(book_b)

    keys_a = [r.key for r in detail_a]
    keys_b = [r.key for r in detail_b]
    set_a, set_b = set(keys_a), set(keys_b)
    inter = set_a & set_b

    # 分母 = 并集:任何一册出现过的行都必须被交代
    denom = len(set_a | set_b)
    paired = len(inter)
    rate = paired / denom if denom else 0.0

    return {
        "detail_a": detail_a, "detail_b": detail_b,
        "total_a": total_a, "total_b": total_b,
        "set_a": set_a, "set_b": set_b,
        "denom": denom, "paired": paired, "rate": rate,
        # 以报审主册为基准的第二种口径,一并打印供复核
        "rate_by_a": paired / len(set_a) if set_a else 0.0,
        "orphan_a": sorted(set_a - set_b),
        "orphan_b": sorted(set_b - set_a),
        "dup_a": [k for k, c in Counter(keys_a).items() if c > 1],
        "dup_b": [k for k, c in Counter(keys_b).items() if c > 1],
    }

注意 keys_a 是列表、不是集合:保留重复才能算出 dup_a。denom 取并集而不是交集——取交集做分母会得到恒等于 100% 的自证结果,这是最隐蔽也最该防的一种写法。

第三段:覆盖率前置判定与孤儿行先报

def check_coverage(cov: dict) -> bool:
    """覆盖率前置判定:<100% 一律不许下『一致』结论,先把孤儿行摆出来。"""
    print(f"应比对行数(分母) = {cov['denom']}")
    print(f"成功配对行数     = {cov['paired']}")
    print(f"分母覆盖率       = {cov['rate']:.2%}")
    print(f"主册口径覆盖率   = {cov['rate_by_a']:.2%}")

    if cov["dup_a"] or cov["dup_b"]:
        print(f"[复合键重复] A册 {len(cov['dup_a'])} 处:{cov['dup_a']}")
        print(f"[复合键重复] B册 {len(cov['dup_b'])} 处:{cov['dup_b']}")
        print("重复行不许静默去重——静默去重等于把分母偷偷改小。")

    if cov["rate"] < 1.0:
        print("覆盖率 <100%,禁止输出『一致』结论。先处理孤儿行:")
        for k in cov["orphan_a"]:
            print("  仅 A 册:", k)
        for k in cov["orphan_b"]:
            print("  仅 B 册:", k)
        return False
    print("覆盖率 100%,允许进入字段级 diff。")
    return True

这个函数是整篇的落点。return False 不只是控制流,它把"分母没闭合"变成一种不可绕过的状态:外层拿到 False 就必须停,没有别的分支能偷偷溜到字段比较去。

第四段:数量/单位归一与字段级 diff

UNIT_WORDS = {"台", "套", "只", "个", "件", "组", "路", "回路", "米", "m", "批"}


def is_number(s: str) -> bool:
    return bool(re.fullmatch(r"\d+(\.\d+)?", s.strip()))


def normalize_qty_unit(qty: str, unit: str) -> tuple[str, str]:
    """把可能被互换的数量/单位两列归一成 (数值, 单位)。
    '2'+'台' 与 '台'+'2' 归一后相同;判不出时保持原序,交人工核。
    """
    a, b = qty.strip(), unit.strip()
    if is_number(a) and b in UNIT_WORDS:
        return a, b
    if is_number(b) and a in UNIT_WORDS:
        return b, a
    if is_number(a):
        return a, b
    if is_number(b):
        return b, a
    return a, b


def diff_paired(cov: dict) -> list[dict]:
    """只对已配对的行做字段级 diff;数量/单位先归一再比。"""
    idx_b = {r.key: r for r in cov["detail_b"]}
    findings: list[dict] = []
    for r in cov["detail_a"]:
        other = idx_b.get(r.key)
        if other is None:
            continue
        diffs: dict = {}
        if r.name.strip() != other.name.strip():
            diffs["name"] = (r.name, other.name)
        if normalize_qty_unit(r.qty, r.unit) != normalize_qty_unit(other.qty, other.unit):
            diffs["qty_unit"] = ((r.qty, r.unit), (other.qty, other.unit))
        if r.model.strip() != other.model.strip():
            diffs["model"] = (r.model, other.model)
        if diffs:
            findings.append({"key": r.key, "seq": r.seq, "diffs": diffs})
    return findings

归一函数的核心是"两个都判不出就原样返回",绝不把可疑值补成 0 或空串。清单里的可疑值就该作为可疑值报出来,脚本擅自替人做决定,等于又造了一处看不见的分母污染。差异报告里同时保留原始两列,方便回看导出时到底哪一列出的问题。

第五段:合计闭合断言与主流程装配

def assert_total_closed(detail: list[Row], totals: list[Row], side: str) -> list[dict]:
    """合计闭合断言:分项数量求和必须等于合计行登记数。"""
    problems: list[dict] = []
    for t in totals:
        amount, skipped = 0.0, 0
        for r in detail:
            if r.hall != t.hall or r.drawing != t.drawing:
                continue
            q, _ = normalize_qty_unit(r.qty, r.unit)
            if is_number(q):
                amount += float(q)
            else:
                skipped += 1
        declared, _ = normalize_qty_unit(t.qty, t.unit)
        if not is_number(declared):
            problems.append({"side": side, "hall": t.hall, "reason": "合计行非数值"})
            continue
        if skipped:
            print(f"[{side}册] {t.hall}/{t.drawing} 有 {skipped} 行未按数值计入,需人工核")
        if abs(amount - float(declared)) > 1e-9:
            problems.append({
                "side": side, "hall": t.hall, "drawing": t.drawing,
                "sum": amount, "declared": float(declared),
            })
    return problems


def main(a_path: str, b_path: str) -> int:
    cov = coverage(load_book(a_path), load_book(b_path))
    if not check_coverage(cov):
        return 2                      # 分母没闭合,直接停,不进 diff

    findings = diff_paired(cov)
    for f in findings:
        print("字段级失配", f["key"], "册内序号", f["seq"], f["diffs"])
    problems = []
    problems += assert_total_closed(cov["detail_a"], cov["total_a"], "A")
    problems += assert_total_closed(cov["detail_b"], cov["total_b"], "B")
    for p in problems:
        print("合计不闭合", p)
    print(f"分母覆盖率 {cov['rate']:.2%},字段级失配 {len(findings)} 条,合计异常 {len(problems)} 条")
    return 0 if not findings and not problems else 1


if __name__ == "__main__":
    import sys
    if len(sys.argv) != 3:
        print("用法: python diff_denominator.py 第一册.csv 第二册.csv")
        raise SystemExit(64)
    raise SystemExit(main(sys.argv[1], sys.argv[2]))

运行方式:

python diff_denominator.py ./books/第一册.csv ./books/第二册.csv
echo "退出码: $?"

退出码 2 表示分母没闭合、孤儿行已打印,此时不要看后面的任何结论;退出码 1 表示行都配上了但字段或合计有问题;0 才是全清。

第六段:不依赖外部文件的最小可跑样例

把下面整段另存为 demo_denominator.py,直接 python demo_denominator.py 就能看到分母覆盖率、序号重号、孤儿行先报、数量/单位归一自检与合计闭合诊断。样例里的行都是为验证脚本逻辑而构造的演示数据,不对应任何真实图纸。

# demo_denominator.py —— 复制即跑,不依赖外部 CSV 文件
import re
from collections import Counter

UNIT_WORDS = {"台", "套", "只", "个", "件", "组", "路", "回路", "米", "批"}


def is_number(s):
    return bool(re.fullmatch(r"\d+(\.\d+)?", s.strip()))


def norm(qty, unit):
    """数量/单位两列互换后仍归一成同一个 (数值, 单位)。"""
    a, b = qty.strip(), unit.strip()
    if is_number(b) and a in UNIT_WORDS:
        return b, a
    if is_number(a):
        return a, b
    return a, b


def key(r):
    return (r[0].strip(), r[1].strip(), r[2].strip())


def is_total(r):
    return r[2].strip().upper() in {"SUBTOTAL", "TOTAL", "合计"}


# 构造数据:厅别/图号/设备位号/册内序号/名称/数量列/单位列/型号
# 均为演示用构造行,不对应任何真实图纸,只用于验证脚本逻辑
A = [
    ("音乐厅", "AV-示意01", "SPK-A01", "#20", "主扩音箱", "2", "台", "X-12"),
    ("音乐厅", "AV-示意01", "SPK-A02", "#21", "补声音箱", "4", "台", "X-8"),
    ("多功能厅", "AV-示意02", "AMP-B01", "#10", "功放", "台", "2", "P-4"),
    ("多功能剧场", "AV-示意05", "CAM-E01", "#02", "摄像机", "2", "台", "E-1"),
    ("录音棚及转播", "AV-示意03", "MIC-C01", "#11", "电容话筒", "6", "只", "C-3"),
    ("录音棚及转播", "AV-示意03", "MIC-C02", "#11", "话筒架", "6", "只", "S-3"),
    ("音乐厅", "AV-示意01", "SUBTOTAL", "合计", "小计", "6", "台", ""),
]
B = [
    ("音乐厅", "AV-示意01", "SPK-A01", "#20", "主扩音箱", "2", "台", "X-12"),
    ("音乐厅", "AV-示意01", "SPK-A02", "#21", "补声音箱", "4", "台", "X-8"),
    ("多功能厅", "AV-示意02", "AMP-B01", "#10", "功放", "2", "台", "P-4"),
    ("录音棚及转播", "AV-示意03", "MIC-C01", "#11", "电容话筒", "6", "只", "C-3"),
    ("表演厅及培训教室", "AV-示意04", "DMX-D01", "#01", "信号分配器", "1", "台", "D-2"),
]


def run(A, B):
    da = [r for r in A if not is_total(r)]
    db = [r for r in B if not is_total(r)]
    ka, kb = [key(r) for r in da], [key(r) for r in db]
    sa, sb = set(ka), set(kb)
    denom, paired = len(sa | sb), len(sa & sb)
    rate = paired / denom if denom else 0.0

    print("归一自检:", norm("台", "2"), "vs", norm("2", "台"))
    print(f"应比对行数(分母) = {denom}")
    print(f"成功配对行数     = {paired}")
    print(f"分母覆盖率       = {rate:.2%}")
    print("A册复合键重复   =", [k for k, c in Counter(ka).items() if c > 1])
    print("A册内序号重复   =", [s for s, c in Counter(r[3] for r in da).items() if c > 1])

    for t in [r for r in A if is_total(r)]:
        parts = [float(norm(r[5], r[6])[0]) for r in da
                 if r[0] == t[0] and r[1] == t[1] and is_number(norm(r[5], r[6])[0])]
        closed = abs(sum(parts) - float(norm(t[5], t[6])[0])) < 1e-9
        print(f"合计闭合诊断:分项 {sum(parts)} / 合计行 {t[5]} → {closed}")

    if rate < 1.0:
        print("覆盖率 <100%,禁止输出『一致』结论;孤儿行先报:")
        print("  仅 A 册:", sorted(sa - sb))
        print("  仅 B 册:", sorted(sb - sa))
        return
    print("覆盖率 100%,允许进入字段级 diff 与合计闭合断言。")


if __name__ == "__main__":
    run(A, B)

样例里 A 册的 #11 被两条不同设备共用,正是"序号重号";AMP-B01 那行的数量列与单位列被互换,归一自检会打印出两个相同的 ('2', '台');两册各有一条独有行,所以覆盖率停在 57.14%,脚本在打印完孤儿行之后就停住,绝不往下走字段级 diff。把两册各自缺的行补齐、按位号拆开重号设备之后再跑一遍生产版脚本,覆盖率到 100%,第四段与第五段才真正开始工作。

五、踩坑:现象、成因、解法

这一节按"现象 → 成因 → 解法"逐条写,只谈脚本与数据层面能直接复现的坑。

坑 1 数量/单位列互换,逐列比出两条假失配

  • 现象:同一台设备,一册是 数量=2、单位=台,另一册导出成 数量=台、单位=2。朴素逐列比对同时报"数量不一致"和"单位不一致"两条;若比对逻辑只认列名、把两列拼成一个字符串再比,两册字符串完全不同,也照样报差异。反过来,如果为了压噪声把这两列整列跳过,真正的数量差错又会被一起放过。
  • 成因:分册装订时两册用的表格模板不同,导出时列序被重排;CSV 里两列都是字符串,没有类型信息,"2" 和 "台" 在程序眼里没有任何区别。列位是排版属性,不是数据语义,用列位承载语义就一定会错。
  • 解法:把 (qty, unit) 当无序二元组归一。用正则判哪个是数值、用单位词表判哪个是单位,交换列位后归成同一个 (数值, 单位);两边都判不出时保留原序并打待人工核标记,绝不补零、不置空。归一之后再比,差异报告里同时打印原始两列,方便回查是哪一册的导出模板出了问题。第四段的 normalize_qty_unit 就是这个做法,第六段的归一自检可以直接看到效果。

坑 2 序号重号,连接时静默吞行

  • 现象:同一册里两条不同设备共用一个册内序号。按序号做连接,重号的两行要么被后写覆盖先写、少掉一行,要么两两相乘、多出配对行。整个过程不抛异常、不打日志、退出码还是 0,输出看起来完全正常,只是分母已经不是原来那本账了。
  • 成因:五个厅各自起号,分册合并时没有重排;序号从设计上就是"给人看的册内流水号",不是设备实体的身份。拿展示字段当连接键,等于把数据的正确性押在排版习惯上。
  • 解法:对齐键改取 (厅别, 图号, 设备位号) 三元组,序号降级为展示与重号检测字段。键列表保留重复,用 Counter 把复合键重复和册内序号重复分别报出来;检测到重复只报不修,绝不静默去重——静默去重就是把分母偷偷改小的另一种写法。这一步正是"先算分母"的意义所在:分母必须来自去重前的真实行数,不能来自连接结果。第二段的 keys_a 故意用列表而不是集合,就是为了留住这份证据。

坑 3 合计不闭合,且合计行混进明细制造假孤儿

  • 现象:两种表现叠在一起。一是分项数量求和与合计行登记数对不上;二是合计行没被剥离,被当成一台设备进入唯一键集合,因为另一册没有对应位号,凭空多出一条孤儿行,把覆盖率往下拽。
  • 成因:合计行与明细共用同一张表、同一套八列,导出时没有类型标记,程序无从区分"这是一台设备"还是"这是一行小计"。而分项里若混着非数值写法(比如"若干"“按实”),求和时又容易被当成 0 处理,误差被掩盖。
  • 解法:读取阶段按 tag 识别合计行、只打标不删除;剥离步骤把它挪到明细之外;链尾单独走合计闭合断言。断言按 (厅别, 图号) 归堆,只把能判成数值的分项计入求和,非数值分项跳过并计数、明确打印"有若干行未按数值计入,需人工核"——跳过不等于零。第五段的 assert_total_closed 里 skipped 这个计数就是防这一手。

坑 4 拿交集当分母,算出恒等于 100% 的自证

  • 现象:有人把覆盖率写成 len(交集) / len(交集),或者用"连接成功的行数"当分母。结果永远是 100%,脚本每次都打印"已全量比对",但只在一册出现的行从来没进过这个分母。
  • 成因:分母取自配对成功的集合,等于先把没配上对的行排除掉,再宣布全部配上对了。这是循环自证,不是覆盖率。
  • 解法:分母必须外生于配对结果。本篇取并集 len(set_a | set_b),并额外打印以主册为基准的第二种口径,两个数字并列摆出来,让人一眼看出分母是按哪本账算的。第二段的 denom 与 rate_by_a 就是这两条口径。凡是分母里出现"成功配对"字样的写法,都要回头重查。

坑 5 编码、空白与全角字符,让覆盖率莫名掉下来

  • 现象:明明两册内容一致,覆盖率却只有六七成,孤儿行清单里一堆看起来完全一样的键。
  • 成因:CAD 导出的 CSV 带 BOM,首列表头前面挂了看不见的字符;手工补录的图号尾部带空格或全角空格;图号大小写不统一。这些差异人眼看不出,集合运算里就是两个不同的键。
  • 解法:一律 utf-8-sig 读取;复合键三个字段统一 .strip();图号是否统一大写要谨慎,别为了对齐把有语义的大小写改掉,宁可先打印差异让人确认。第一段的 encoding="utf-8-sig" 与 Row.key 里的 .strip() 就是干这个的。别小看这一条,它造成的假孤儿行比真正的清单差错更常见,也最容易让人误判"两册差得很厉害"。

坑 6 把"覆盖率 100%“当成"两册一致”

  • 现象:覆盖率满了就直接打印"一致"收工,字段差异、型号差异、合计不闭合全被这句结论盖住。
  • 成因:把前置必要条件当成了充分条件。覆盖率只回答"行都配上了对没有",不回答"配上对的行里字段一不一样",更不回答"分项加不加得起来"。
  • 解法:把结论拆成三层——覆盖率是入口,字段级 diff 是主体,合计闭合断言是收口,三层都过才允许输出全清。main 里的 return 2 / 1 / 0 三档退出码就是把这个层次固化进控制流,避免有人只盯第一行输出就下结论。

六、总结

回到开头那句:清单两册逐行 diff 之前先算清分母,分母不对,逐行比得再细也是假对账。

这一批清单上,两册逐行 diff 校验命中 11 条,落在音乐厅 #20/#21、多功能厅 #10、录音棚及转播 #11/#12 等位置。11 是分子。分子能不能被信任,取决于分母有没有被摊开:两册各有多少唯一键、交集多大、并集多大、哪些行只在一册出现、册内有没有重号、合计行有没有混进明细。这些量不打印出来,11 条就只是一个孤零零的数字,既无法复核,也无法排除"还有行根本没被比对过"这个可能。

本篇把这条主链从头到尾走了一遍:两册读取与合计行剥离 → 复合对齐键 (厅别, 图号, 设备位号) → 各自唯一键集合与交集 → 覆盖率 = 成功配对行数 / 应比对行数(分母取并集,另报主册口径)→ 覆盖率未满时孤儿行先报、禁止下一致结论 → 覆盖率满额后才做数量/单位归一后的字段级 diff 与合计闭合断言。三个硬货都在这条链上:分母覆盖率把"比了多少行"变成可打印的量,孤儿行先报把没配上对的行在结论之前摆出来,复合对齐键让重号与列位错位不再悄悄改变分母。它们解决的是同一件事——不让对齐方式本身污染账本。

对 AI 智能体检查而言,这里有个值得记取的取舍:把"比得更细"当卖点,很容易滑进假对账;把"比了多少行"先钉死,反而才是真验证的起点。集合运算前置于字段比较,是任何两源表对账都成立的工程模式,跟具体是设备清单还是别的台账关系不大。脚本只用标准库、退出码分三档、输出全是人能直接读的几行诊断,就是为了让它能在报审现场那台没配环境的机器上跑起来,跑完即止。

对舞台同行,这条链护住的是齐套性与提资的下游:调光柜回路、吊杆点位、DMX512 地址、系统图取数,都吃这两册清单,被吞过行的清单流到现场,返工不在脚本里,在机柜和栅顶上。对做 AI 工程的读者,可以直接把 coverage 与 check_coverage 两个函数搬去任何双源表场景,把"分母"换成自己业务里的实体键即可。跨行业读者不熟的几个名词,前面各补过一句白话说明;说明只用来搭桥,专业内容没有为迁就谁而削平——清单该怎么读、键该怎么选、账该怎么合,仍按舞台工艺这一行的原本口径写。

Logo

更多推荐