昨天把 Subagent 的邊界畫完,MOPS Domain Verifier 有了自己的位置,跟 Field Checker、Text Verifier 並排。可是 Day 8 規劃的是四層:Checksum、Logprob、Tesseract、MOPS。另外三層還沒有座位,而且昨天最後那個問題還懸著:便宜的已經 FLAG 了,貴的還要不要跑?
四個都跑,順序怎麼排?
每一層都跑全量,一頁要多等好幾秒。只跑其中一層,又回到 Day 10 那句話:任何單一指標都有它系統性看不到的失敗模式。
Day 6 的 page_24,模型把「阻燃」讀成「防燃」。
這個錯我到現在還覺得很陰。兩個都是合法的詞,句子一樣通順,finish_reason 是 stop,沒有任何欄位在喊救命。同一頁還有「綠→線」「載具→製具」「補強→補償」,全部是同一種味道:形近字被語言模型的先驗「修」成一個更常見的詞。
Day 5 叫它同源盲點。你叫同一顆模型再看一次,它會很有共識地錯第二次。
所以想四重驗證的時候,我腦中先浮出來的是一張「哪一層抓得到哪一種錯」的對照表,檢查項目清單反而是後來才整理的。把前面幾天真的踩過的錯放進去:
| 實際錯誤(出處) | Checksum | Logprob | Tesseract | MOPS 語意 |
|---|---|---|---|---|
| 輸出開頭多一句「這張圖片中的文字內容如下」(Day 2 的 page_01、Day 15 的緊裁輸出都有) | 抓得到 | 抓不到 | 抓不到 | 抓不到 |
(1,678) 變成 1,678,負號不見(Day 2) |
有合計列時抓得到 | 看運氣 | 可能 | 有 XBRL 對應科目時抓得到 |
| 阻燃 → 防燃(Day 6 page_24) | 抓不到 | 部分 | 抓得到 | 抓不到 |
| 一年內到期長期負債 → 短期負債(Day 5) | 抓不到 | 部分 | 看字級 | 抓得到 |
page_19 撞 max_tokens 後重複退化(Day 6) |
抓得到(輸出不是合法 JSON) | 抓不到 | 抓得到 | 抓不到 |
這張表我畫了三次。第一次把 Logprob 那欄幾乎全填「抓得到」,後來才改掉,原因後面講。
橫著看,沒有一列四格全空,這讓我安心一點。直著看,也沒有一欄是全滿的。
規劃書寫 Checksum,我一開始也以為就是「驗 JSON 格式」。寫下去才發現它應該包三件事:
第三件是 Day 15 裁 page_61 時踩出來的:座標抓錯,隔壁欄的文字被一起裁進去。那次是人發現的。Harness 一次派出幾十個區塊,結果配錯了靠人眼是看不出來的,所以送出去時就算好 sha256,回來要對上。
Day 10 說過「格式越簡單,Pydantic 越好驗」。所以表格區輸出壓成最扁:一列一個標籤、一個原始值,不要 Markdown 框線。純文字區則只收一個 text 欄位。這也是 Day 18 留給今天的那個尾巴:prompt_for(kind) 要吐的格式,就定在這裡。
from __future__ import annotations
import hashlib
import re
from decimal import Decimal, InvalidOperation
from pydantic import (
BaseModel, ConfigDict, Field, ValidationError,
field_validator, model_validator,
)
_AMOUNT_RE = re.compile(r"^\(?-?\d{1,3}(,\d{3})*\)?$|^\(?-?\d+\)?$")
def parse_amount(raw: str) -> Decimal:
"""財報金額:括號代表負數。只在驗證時解析,原字串不動。"""
s = raw.strip()
negative = s.startswith("(") and s.endswith(")")
s = s.strip("()").replace(",", "")
try:
value = Decimal(s)
except InvalidOperation as exc:
raise ValueError(f"金額無法解析:{raw!r}") from exc
return -value if negative else value
class TableRow(BaseModel):
model_config = ConfigDict(extra="forbid")
label: str = Field(min_length=1)
raw_value: str # 保留模型原樣輸出,例如 "(1,678)"
@field_validator("raw_value")
@classmethod
def looks_like_amount(cls, v: str) -> str:
if not _AMOUNT_RE.match(v.strip()):
raise ValueError(f"不像金額格式:{v!r}")
return v
class TableRegionOutput(BaseModel):
model_config = ConfigDict(extra="forbid") # 多一個欄位就算錯
region_id: str
source_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
unit: str # 千元、元……不讓模型省略
rows: list[TableRow] = Field(min_length=1)
total_label: str | None = None
total_raw: str | None = None
@model_validator(mode="after")
def detail_sums_to_total(self) -> "TableRegionOutput":
if self.total_raw is None:
return self
detail = sum(parse_amount(r.raw_value) for r in self.rows)
total = parse_amount(self.total_raw)
if detail != total:
raise ValueError(f"明細加總 {detail} 與合計 {total} 不符")
return self
class TextRegionOutput(BaseModel):
model_config = ConfigDict(extra="forbid")
region_id: str
source_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
text: str = Field(min_length=1)
@field_validator("text")
@classmethod
def no_preamble(cls, v: str) -> str:
if v.lstrip().startswith(("這張圖片", "以下是")):
raise ValueError("輸出開頭是模型自己加的說明文字")
return v
def checksum_layer(kind: str, raw_json: str, crop_bytes: bytes):
"""kind 是 Day 18 Region.kind。回傳 (解析後物件或 None, 問題清單)。"""
schema = TableRegionOutput if kind == "table" else TextRegionOutput
try:
out = schema.model_validate_json(raw_json)
except ValidationError as exc:
return None, [err["msg"] for err in exc.errors()]
if out.source_sha256 != hashlib.sha256(crop_bytes).hexdigest():
return None, ["輸出對應的圖片雜湊不符,可能配錯區塊"]
return out, []
我拿一個手做的小例子試過:兩列明細 3,000 和 (1,678),合計 1,322。原樣丟進去是空清單,通過。把 (1,678) 改成 1,678,也就是 Day 2 那個負號消失的錯,回來的是:
['Value error, 明細加總 4678 與合計 1322 不符']
再多塞一個 "note" 欄位進去,extra="forbid" 直接擋下:Extra inputs are not permitted。模型想在 JSON 裡偷偷補一句「註:圖片右下角另有數字」(Day 6 錯誤分類表裡 page_09 那筆 hallucination 就長這樣),沒有地方放。
例子裡的數字是我編的,只用來確認驗證器的行為。
Tip:加總檢查只在區塊裡同時有明細和合計時才有意義。Day 2 那個負號錯誤,所在的表要是有合計列就抓得到;換成沒有合計列的附註表,這條規則就是空的。我寧願讓
total_raw可以是None,也不要讓模型為了過關自己「算」一個合計出來,那等於把答案卷交給考生寫。
這層便宜、結果固定,格式錯就是錯。但它只管長相和內部算術,「阻燃」寫成「防燃」,JSON 還是完美的。
先說一個有點丟臉的事實。
我回頭翻了 experiments/ 底下所有 raw response,每一份都寫著 "logprobs": null。不是端點不給,endpoint_probe/models.response.json 裡 gemma-4-26b 的 permission 明明寫著 "allow_logprobs": true。是我從 Day 2 開始打請求就沒要過。
所以這一層目前沒有任何實測數字,我手上有的只有設計。
要拿到它很簡單,vLLM 的 OpenAI 相容介面在 chat.completions.create 多帶 logprobs=True, top_logprobs=5,回應的 choices[0].logprobs.content 就是每個 token 一筆。
每一筆有 token、logprob 和前 5 名候選。我除了看機率高低,也看第一名跟第二名差多少。Day 8 寫過「形近字誤判時,兩個候選的機率常常很接近」,那是推論,沒量過,函式就照這個想法寫:
import math
def suspicious_spans(content, min_prob: float = 0.80, min_margin: float = 0.25):
"""找出模型自己也猶豫的位置。
content: resp.choices[0].logprobs.content 轉成 dict 後的 list
min_prob / min_margin: 起始值,是我先抓的,不是量出來的
"""
spans = []
pos = 0
for tok in content:
text = tok["token"]
p1 = math.exp(tok["logprob"])
alts = sorted(
(t for t in tok.get("top_logprobs", []) if t["token"] != text),
key=lambda t: t["logprob"],
reverse=True,
)
p2 = math.exp(alts[0]["logprob"]) if alts else 0.0
if p1 < min_prob or (p1 - p2) < min_margin:
spans.append({
"start": pos,
"end": pos + len(text),
"token": text,
"p1": round(p1, 3),
"runner_up": alts[0]["token"] if alts else None,
"p2": round(p2, 3),
})
pos += len(text)
return spans
用假資料跑,假設「阻」只有 0.52、「防」有 0.41,函式會回一筆 {"token": "阻", "p1": 0.52, "runner_up": "防", "p2": 0.41, ...}。機率是我填的,只證明函式邏輯通。
然後說說為什麼它是我最不信任的一層。
logprob 是模型對自己的看法,而同源盲點講的正是這種看法不可靠。模型的先驗強到「確信」這裡是「防燃」的話,機率會很高,這層就安靜放行。我第一次把對照表的 Logprob 欄填滿,就是忘了這件事。它抓得到模型「自己也覺得怪」的錯,抓不到模型「覺得很對」的錯,偏偏後者最危險。
實作上還有個坑:token 不等於字。好幾個字可能黏成一個 token,罕用字則可能被 tokenizer 拆成 byte 片段,這時 len() 算出來的位置就歪了。回傳實際長什麼樣子我還沒驗證,列為待查。
所以 Logprob 在我的設計裡只做一件事:當觸發器。它標出來的位置,交給第三層去看。它自己不判任何人死刑。
Tip:
0.80和0.25拜託不要直接抄,那是我拍腦袋的起始值。要等 Day 25 的飛輪跑起來,拿「人工確認是錯的 token」的機率分布回頭調。沒有資料撐著的門檻,寫得越精確越假。
Day 8 我寫過一句我很喜歡的話:它的錯誤是吵的,不是安靜的。
Tesseract 沒有「這樣寫比較通順」的偏好,看到「阻」就輸出「阻」,看不清楚就吐亂碼或空白。整體準確率它比不上 VLM,這我承認。但我要的不是一個比較準的第二意見,是一個錯法不一樣的第二意見。
兩個錯法不一樣的系統,意見分歧的地方,就是該看的地方。
但 Day 8 也寫了擔心:Tesseract 中文小字本來就差,page_24 那種密度它可能自己噴一堆錯,變成假陽性工廠。所以程式碼的重點全在「怎麼少比一點」:
import difflib
import unicodedata
import pytesseract
from PIL import Image
# Day 7 的比較鍵:只折明確等價的異體字,結果不寫回原文
VARIANT_FOLD = str.maketrans({"臺": "台", "裏": "裡", "爲": "為", "綫": "線"})
def fold(s: str) -> str:
s = unicodedata.normalize("NFC", s)
return "".join(s.split()).translate(VARIANT_FOLD)
def tesseract_words(crop: Image.Image, min_conf: float = 60.0) -> str:
"""只收 Tesseract 自己有把握的字,低信心的直接丟掉不比。"""
data = pytesseract.image_to_data(
crop,
lang="chi_tra",
config="--psm 6",
output_type=pytesseract.Output.DICT,
)
kept = [
text for text, conf in zip(data["text"], data["conf"])
if text.strip() and float(conf) >= min_conf
]
return "".join(kept)
def cross_check_digits(vlm_text: str, crop: Image.Image):
"""表格區:只比數字與符號。"""
keep = set("0123456789(),.-")
tess = "".join(c for c in fold(tesseract_words(crop)) if c in keep)
vlm = "".join(c for c in fold(vlm_text) if c in keep)
sm = difflib.SequenceMatcher(a=vlm, b=tess, autojunk=False)
return [
{"tag": tag, "vlm": vlm[i1:i2], "tesseract": tess[j1:j2]}
for tag, i1, i2, j1, j2 in sm.get_opcodes()
if tag != "equal"
]
def cross_check_spans(vlm_text: str, spans: list[dict], crop: Image.Image, ctx: int = 2):
"""純文字區:logprob 標出的字連同前後 ctx 個字,Tesseract 那邊找不找得到。"""
tess = fold(tesseract_words(crop))
misses = []
for sp in spans:
piece = fold(vlm_text[max(0, sp["start"] - ctx): sp["end"] + ctx])
if piece and piece not in tess:
misses.append({"tag": "span", "vlm": piece, "tesseract": None})
return misses
幾個我自己很在意的決定:
min_conf=60 把 Tesseract 自己都沒把握的字丟掉(image_to_data 每個詞有信心值,非文字框是 -1),等於讓它棄權。第三層會因此漏一些東西,但我寧可漏,也不要每頁噴二十個假分歧把複核的人搞到麻木。人一麻木,真的紅旗也會被按掉。
表格區只比數字。字元集小,照理說比中文好認(我的預期,沒量過),而財報上最貴的錯就是數字錯。負號那個案例,Tesseract 只要看得到括號,(1,678) 和 1,678 在 diff 裡就分得開。
純文字區不做全文 diff,只檢查 logprob 標出來的字(連同前後兩個字)在 Tesseract 的結果裡找不找得到。第二層跟第三層就是在這裡串起來的。
比較前兩邊都過 Day 7 的 fold,「臺」和「台」不算分歧,原文也不動。
lang="chi_tra" 要另外裝繁中的 traineddata,--psm 6 是「當成一整塊文字」的模式。這組參數和門檻 60 都還沒在 page_24 上實際比較過,是起始值。
Tip:交叉比對最怕抓到太多「錯」。第一天就讓分歧全進人工佇列,一週後大家就會開始批次按通過。先只開數字模式,確認人工消化得了,再慢慢放寬。
這層不用重寫。Day 21 已經把 Day 16 的規則包成 Field Checker、把 Day 17 的 verifier 包成 MopsDomainVerifier,都吃 SubagentRequest、吐 SubagentResult。第四層只要透過 Day 21 的 call() 叫它們,邊界檢查照樣生效。
這層只回 PASS、FLAG 或 N/A,不回 FAIL:領域規則只能說「這裡怪」,不能動手改。NT$1,23元 被標出千分位問題,原文留在 original_text,沒人會替它猜成 1,230。
限制要講清楚:Day 17 沒拿到真實的 MOPS XBRL instance,那支 verifier 只跑過合成 fixture,這層在真實文件上能擋多少錯,現在沒有數字。
| 順序 | 層 | 成本量級 | 抓的是什麼 | 結果 |
|---|---|---|---|---|
| 1 | Checksum | 毫秒級,純 CPU | 格式、內部算術、配錯區塊 | 不過就 FAIL,後面不跑 |
| 2 | Logprob | 幾乎零,回應裡本來就有 | 模型自己猶豫的位置 | 不判,只產生「要看的位置」 |
| 3 | Tesseract | 每個區塊秒級,CPU | 兩套系統的分歧 | PASS / FLAG / 沒得比就 N/A |
| 4 | MOPS 語意 | 毫秒級,只對表格區 | 領域規則、外部事實 | PASS / FLAG / N/A |
成本那欄是推估的量級,沒實際計時。排序只有一條道理:會讓整份輸出作廢的檢查放最前面。Checksum 過不了,後面拿它去比 Tesseract、對 XBRL 全是浪費,還會產生一堆沒意義的 FLAG 污染紀錄。
Checksum 不過給 FAIL 不給 FLAG,是照 Day 21 的定義:FAIL 是「沒做完」。格式都不對的輸出,等於 Reader 那一步沒完成,該重來,不是送人工看疑點。
主函式的輸入全是前幾天就有的東西:Day 18 的 Region、Reader 的輸出、裁切圖、Day 21 的角色:
from dataclasses import dataclass, field
from pipeline import Region # Day 18
from subagents import Outcome, Subagent, SubagentRequest, call # Day 21
from verifier_protocol import VerifierIssue # Day 11
@dataclass
class LayerReport:
layer: str
outcome: Outcome | None # None:這層只產生位置,不下判定(Logprob)
issues: list[VerifierIssue] = field(default_factory=list)
note: str | None = None
def _issues(region: Region, diffs: list[dict]) -> list[VerifierIssue]:
return [VerifierIssue(location=region.region_id, original_text=d["vlm"],
concern=f"Tesseract 讀到 {d['tesseract']!r}", confidence=0.6)
for d in diffs]
def run_four_layers(region: Region, raw_json: str, crop_png: bytes, crop_img,
logprob_content: list[dict] | None,
domain_agents: list[Subagent], domain_req: SubagentRequest | None):
reports: list[LayerReport] = []
# 1. Checksum:過不了就停
parsed, problems = checksum_layer(region.kind, raw_json, crop_png)
if parsed is None:
reports.append(LayerReport("checksum", Outcome.FAIL, [
VerifierIssue(location=region.region_id, original_text=raw_json[:200],
concern=p, confidence=1.0) for p in problems]))
return Outcome.FAIL, reports
reports.append(LayerReport("checksum", Outcome.PASS))
# 2. Logprob:只產生位置
spans = suspicious_spans(logprob_content) if logprob_content else []
reports.append(LayerReport("logprob", None,
note=None if logprob_content else "回應沒有 logprobs"))
# 3. Tesseract:表格比數字;純文字只查被標出的位置
if region.kind == "table":
text = " ".join(f"{r.label} {r.raw_value}" for r in parsed.rows)
diffs = cross_check_digits(text, crop_img)
reports.append(LayerReport("tesseract", Outcome.FLAG if diffs else Outcome.PASS,
_issues(region, diffs)))
elif logprob_content is None:
reports.append(LayerReport("tesseract", Outcome.NOT_APPLICABLE,
note="沒有 logprob 指路,純文字區等於沒查"))
else:
diffs = cross_check_spans(parsed.text, spans, crop_img)
reports.append(LayerReport("tesseract", Outcome.FLAG if diffs else Outcome.PASS,
_issues(region, diffs)))
# 4. MOPS 語意:只有表格區才跑,透過 Day 21 的 call() 叫角色
if region.kind == "table" and domain_req is not None:
for agent in domain_agents:
res = call(agent, domain_req)
reports.append(LayerReport(f"domain:{agent.role}", res.outcome, res.issues))
else:
reports.append(LayerReport("domain", Outcome.NOT_APPLICABLE, note="不是表格區"))
return aggregate(reports), reports
def aggregate(reports: list[LayerReport]) -> Outcome:
outcomes = [r.outcome for r in reports if r.outcome is not None]
if Outcome.FAIL in outcomes:
return Outcome.FAIL
if Outcome.FLAG in outcomes:
return Outcome.FLAG
# 第三層沒得比,不准給 PASS
if any(r.layer == "tesseract" and r.outcome is Outcome.NOT_APPLICABLE for r in reports):
return Outcome.FLAG
return Outcome.PASS
aggregate 最後那個判斷是後來補的。第一版是「沒有 FAIL、沒有 FLAG 就 PASS」,看起來合理,但有個洞:如果回應裡沒有 logprobs(就像我現在所有的舊資料),純文字區的第三層根本沒東西可比,結果只過了 Checksum 就拿到 PASS。
沒被檢查過,跟檢查過沒問題,是兩回事。但第四層的 N/A 我沒升級成 FLAG:XBRL 本來就只蓋一小部分欄位,N/A 都算疑點的話人工佇列會被淹掉,覆蓋率交給 Day 21 的 skipped 計數記。
Day 21 的 Text Verifier(gpt-oss:20b)還是 design-only, not yet wired,所以沒排進來,接上後會放在第三、四層之間。
Tip:別把四層結果平均成一個總分。0.9、0.8、0.95、0.7 平均 0.84 很好看,但那個 0.7 可能就是「金額跟 XBRL 對不起來」。只看最嚴格的那一層。
不能。四層只是把「安靜的錯」變吵的機率拉高。有幾種錯我還想不到哪一層會抓:
gpt-oss:20b 或人。所以這四層吐出來的,比較像一份「哪裡要人看」的清單。
寫完我把 run_four_layers 的回傳值盯了很久。一個 Outcome,一串 LayerReport。Day 20 的 Commander 看得懂的是 TaskResult 的 status 和 signals,它不認識 LayerReport;Checksum 那個 FAIL 傳回去,Commander 的規則表裡有沒有哪一條會接?如果沒有,這個 FAIL 會不會就只是 log 裡安安靜靜的一行 no_rule?