iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0
AI Engineering

從 LLM 到 Harness: 打造隱私與可信任的繁中進階 OCR Agent系列 第 17 篇

Day 17 - 領域語意 Verifier 設計:用 MOPS 既有結構化資料當校驗來源

  • 分享至 

  • xImage
  •  

昨天那支 semantic_field_validator.py 我其實滿喜歡的。它會抓 NT$1,23元 這種千分位壞掉的金額,也會發現同一個簽約日在兩頁寫得不一樣。

但它有個抓不到的東西,而且這東西剛好是整個系列最早就出現的錯。

Day 2 那條:

原文:一年內到期長期負債 4,455
輸出:短期負債 4,555

4,555 格式完全合法。千分位對、沒有括號問題、也沒有跨頁矛盾。昨天的規則會很開心地放它過去。格式檢查只能問「這長得像不像一個金額」,沒辦法問「這是不是那個金額」。

要回答後面那個問題,手上得有另一份答案。

答案其實一直放在 MOPS 上

上市櫃公司申報財報時,除了大家看的 PDF,還要交一份 XBRL。XBRL 是一種 XML 格式的財報,每一個數字都被拆成獨立的 fact,旁邊綁著它屬於哪家公司、哪個期間、什麼單位、什麼會計科目。

換句話說,年報 PDF 第幾頁那張綜合損益表上的「營業收入」,在 XBRL 裡是一筆長這樣的東西(示意,不是從真實檔案複製的):

<xbrli:context id="CurrentYearDuration">
  <xbrli:entity>
    <xbrli:identifier scheme="http://www.twse.com.tw">2330</xbrli:identifier>
  </xbrli:entity>
  <xbrli:period>
    <xbrli:startDate>2025-01-01</xbrli:startDate>
    <xbrli:endDate>2025-12-31</xbrli:endDate>
  </xbrli:period>
</xbrli:context>

<xbrli:unit id="TWD">
  <xbrli:measure>iso4217:TWD</xbrli:measure>
</xbrli:unit>

<ifrs-full:Revenue contextRef="CurrentYearDuration" unitRef="TWD" decimals="-3">1234567000</ifrs-full:Revenue>

公司、期間、單位、科目,四樣東西都在。這就是規劃書裡那句「不用自建詞彙表或 RAG」的意思。

我原本以為這一段要自己做:蒐集會計科目、建同義詞表、處理「營業收入」「營收」「收入淨額」這些寫法,說不定還要弄個向量索引去查。做到一半大概會懷疑人生。結果主管機關早就要求公司用標準 taxonomy 申報了,科目的標準名稱不用我發明。

我對 RAG 在這裡的態度可能跟一般看法不太一樣:財報數字這種東西,檢索出「相似」的段落完全沒用。我要的是「同一家、同一年、同一個科目」那一個值,差一點都不行。相似度越高,越容易拿錯年度的數字來背書。

我抓不到那份 instance

先把尷尬的事講完。

我要的是台積電(2330)民國 114 年第 4 季的 XBRL,MOPS 的查詢入口是:

https://mops.twse.com.tw/mops/web/t164sb01?TYPEK=sii&year=114&season=4&co_id=2330

受管環境的網路把這條擋掉了,所以我手上沒有真實的 instance 檔。這篇的比對程式是用合成 fixture 測的,下面講到的 concept 名稱也是示範用,還沒拿真實檔案對過。等我在能連線的環境把 instance 拿下來,會再回頭補真實比對結果。

這不影響今天要做的事,因為比對規則本身跟資料從哪來是兩件事。但它確實讓我少了一個很想展示的畫面。

比對鍵要四樣都對

這是今天最花時間想的部分。

直覺做法是「OCR 抓到一個數字,去 XBRL 裡找有沒有一樣的數字」。這很危險。一份年報裡會出現本期、上期、合併、個體,同一個科目可能有四個值,單位還分元跟千元。只比數字的話,1,234 在某個地方找到一個 1234 就過了,可能根本是另一個科目。

所以 harness/mops_xbrl_verifier.py 的比對鍵一定是四樣一起:

鍵 例子 為什麼不能少
公司 臺灣示範股份有限公司 集團年報裡會出現子公司數字
期間 2025-12-31 年報幾乎每張表都有本期和上期兩欄
單位 元/千元 財報表格常用千元,XBRL 的值是以元記
科目 Revenue 同一個數字可能剛好同時是兩個科目的值

核心就這幾行:

def comparison_key(
    *, company_name: str, period: str, unit: str, concept: str
) -> tuple[str, str, str, str]:
    """完整鍵必含公司、期間、單位與 XBRL 科目/概念。"""
    return (company_comparison_key(company_name), period, unit, concept)

公司名稱只做一件正規化:臺 折成 台,而且只拿來比對。

_COMPANY_FOLD = str.maketrans({"臺": "台"})

def company_comparison_key(name: str) -> str:
    """僅供相等比對:只把明確等價的臺折成台,絕不回寫公司原文。"""
    return unicodedata.normalize("NFC", name).translate(_COMPANY_FOLD)

我刻意不做更多。Day 7 花了一整篇講異體字,結論是折疊規則寫越多,誤合併的機率就越高。「臺灣示範股份有限公司」跟「臺灣示範工業股份有限公司」只差兩個字,但它們是兩家公司。如果我為了容錯去做模糊比對,第一個被犧牲的就是這種情況。

然後是比對本身:

def compare_ocr_fields(facts: list[XbrlFact], fields: list[OcrField]) -> list[ComparisonResult]:
    """以完整領域鍵比對,所有不相符情況皆為 FLAG,從不自動修正 OCR。"""
    fact_index: dict[tuple[str, str, str, str], list[XbrlFact]] = {}
    for fact in facts:
        fact_index.setdefault(
            comparison_key(company_name=fact.company_name, period=fact.period,
                           unit=fact.unit, concept=fact.concept),
            [],
        ).append(fact)

    results: list[ComparisonResult] = []
    for field in fields:
        key = comparison_key(company_name=field.company_name, period=field.period,
                             unit=field.unit, concept=field.concept)
        candidates = fact_index.get(key, [])
        ocr_value = _decimal(field.original_value)
        fact_values = {_decimal(fact.value) for fact in candidates}
        if candidates and ocr_value is not None and ocr_value in fact_values:
            results.append(ComparisonResult(Verdict.PASS, field))
        elif candidates:
            results.append(ComparisonResult(
                Verdict.FLAG, field,
                "完整鍵相符,但原始 OCR 值與 XBRL fact 數值不一致或無法解析。"))
        else:
            results.append(ComparisonResult(Verdict.FLAG, field, _mismatch_reason(field, facts)))
    return results

(排版略為壓縮,邏輯跟 repo 裡的檔案一致。)

數值用 Decimal 比,不用 float。財報數字動輒十位數以上,我不想在這種地方跟浮點誤差吵架。

有一個地方我想了很久:找不到完整鍵的時候,要不要告訴呼叫端「為什麼找不到」?最後決定要。_mismatch_reason 會分辨三種情況:同公司同科目但單位不同、同公司同科目但期間不同、完全找不到。這三種在實務上意義差很多。單位不同通常是我的轉接層寫錯了,期間不同多半是 OCR 把上期欄讀成本期欄,完全找不到則可能是科目對應表缺一條。同樣是 FLAG,後面要找的人不一樣。

fixture 跑了什麼

python harness/mops_xbrl_verifier.py 直接執行會跑六個情境,全部 assertion 過了才印 PASS:

情境 OCR 欄位 結果
台/臺等價,完整鍵與數值相符 台灣示範…/2025-12-31/元/Revenue/1,234 PASS
數值差 1 …/Revenue/1,235 FLAG,數值不一致
單位寫成千元 …/千元/Revenue/1 FLAG,單位不同
期間是上一年 2024-12-31/…/1,234 FLAG,期間不同
科目不存在 …/GrossProfit/100 FLAG,找不到
相近公司名 臺灣示範工業股份有限公司/…/1,234 FLAG,相近公司名稱不合併

第五列有個細節:數值 1,234 在 fixture 裡是真的存在的,只是掛在另一個公司名下。如果比對只看數字,它會過。

測試最後兩行 assertion 我特別在意:

assert matching.original_value == "1,234"
assert matching.company_name == "台灣示範股份有限公司"

比完之後,OCR 的原文一個字都不能被動到。公司名稱在比對時被折成了 台,但欄位裡保留的還是 OCR 讀到的樣子。

Tip:我看過一些「OCR + 知識庫校正」的做法,比對到就直接把 OCR 結果換成資料庫的值。聽起來很聰明,出事的時候你會發現你已經分不出哪些數字是從文件讀的、哪些是被覆寫的。這個 verifier 只准說 PASS 或 FLAG,不准給「建議值」。建議值一旦出現,下游遲早有人拿它當答案。

從 instance 到 XbrlFact

verifier 吃的是已經載好的 XbrlFact。從 XML 到 XbrlFact 這一段,是今天實作的另一半。

這段程式我寫成設計範例,因為還沒有真實檔案可以測,namespace 和 identifier scheme 要拿到檔案後再對一次:

from __future__ import annotations

import xml.etree.ElementTree as ET
from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_UP
from pathlib import Path

from mops_xbrl_verifier import XbrlFact

XBRLI = "{http://www.xbrl.org/2003/instance}"


@dataclass(frozen=True)
class RawFact:
    concept: str        # 例如 "ifrs-full:Revenue"
    context_id: str
    unit_id: str | None
    decimals: str | None
    value: str


def _parse_contexts(root: ET.Element) -> dict[str, tuple[str, str]]:
    """context id -> (公司識別碼, 期間字串)。期間字串 instant 用日期,duration 用 start~end。"""
    out: dict[str, tuple[str, str]] = {}
    for ctx in root.iter(f"{XBRLI}context"):
        ident = ctx.find(f"{XBRLI}entity/{XBRLI}identifier")
        period = ctx.find(f"{XBRLI}period")
        if ident is None or period is None:
            continue
        instant = period.find(f"{XBRLI}instant")
        if instant is not None:
            period_str = instant.text.strip()
        else:
            start = period.find(f"{XBRLI}startDate").text.strip()
            end = period.find(f"{XBRLI}endDate").text.strip()
            period_str = f"{start}~{end}"
        out[ctx.get("id")] = (ident.text.strip(), period_str)
    return out


def _parse_units(root: ET.Element) -> dict[str, str]:
    out: dict[str, str] = {}
    for unit in root.iter(f"{XBRLI}unit"):
        measure = unit.find(f"{XBRLI}measure")
        if measure is not None:
            out[unit.get("id")] = measure.text.strip()   # 例如 "iso4217:TWD"
    return out


def load_raw_facts(path: Path) -> tuple[list[RawFact], dict, dict]:
    root = ET.parse(path).getroot()
    contexts, units = _parse_contexts(root), _parse_units(root)
    facts = []
    for el in root:
        ctx = el.get("contextRef")
        if ctx is None or el.text is None:
            continue                      # 不是 fact(context、unit、schemaRef 等)
        ns, _, local = el.tag[1:].partition("}")
        facts.append(RawFact(f"{ns}#{local}", ctx, el.get("unitRef"), el.get("decimals"), el.text.strip()))
    return facts, contexts, units

然後是轉接層。這一段才是真正會踩坑的地方:

# 公司識別碼 -> 法定名稱。名稱來源要可追溯,不能靠模型猜。
COMPANY_NAMES = {"2330": "台灣積體電路製造股份有限公司"}

# XBRL concept -> verifier 使用的科目鍵。只收有人工確認過的對應,示範用。
CONCEPT_MAP = {
    "http://xbrl.ifrs.org/taxonomy/2021-03-24/ifrs-full#Revenue": "Revenue",
}

MEASURE_TO_UNIT = {"iso4217:TWD": "元"}


def to_xbrl_facts(raw: list[RawFact], contexts: dict, units: dict,
                  target_unit: str = "元") -> list[XbrlFact]:
    out: list[XbrlFact] = []
    for f in raw:
        concept = CONCEPT_MAP.get(f.concept)
        if concept is None or f.unit_id is None:
            continue                                  # 沒對應到的科目,寧可不比
        company_id, period = contexts[f.context_id]
        unit = MEASURE_TO_UNIT.get(units.get(f.unit_id, ""))
        if unit is None:
            continue
        value = Decimal(f.value)
        if target_unit == "千元" and unit == "元":
            # 縮放 XBRL 這一側,絕不縮放 OCR 那一側
            value = (value / 1000).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
            unit = "千元"
        out.append(XbrlFact(
            company_name=COMPANY_NAMES[company_id],
            period=period.split("~")[-1],   # 暫以期末日當期間鍵,duration 另外標記
            unit=unit, concept=concept, value=str(value),
        ))
    return out

幾個我自己很在意的決定:

單位換算做在 XBRL 那一側。年報表格上寫「單位:新台幣千元」,OCR 讀到的是 1,234,567。我可以把 OCR 的值乘 1000 去比,也可以把 XBRL 的值除 1000 去比。數學上一樣,但前者等於改了 OCR 的數字,就算只是在記憶體裡改,log 裡出現的也會是一個文件上從沒出現過的值。我選後者。

除 1000 之後的四捨五入規則,我用 ROUND_HALF_UP。老實說這是我猜的,財報編製時實際用什麼進位方式、會不會有尾差調整,我沒查證過。這條如果猜錯,會出現一批「差 1」的 FLAG,到時候看 FLAG 的分布就知道了。這種錯我寧可它吵,也不要它安靜。

CONCEPT_MAP 只收人工確認過的對應,找不到就跳過。這代表很多欄位根本不會被比,覆蓋率會很難看。但反過來想:一個錯的對應會讓 verifier 系統性地 FLAG 一整排正確的數字,或更糟,PASS 掉錯的。

它能管什麼,管不到什麼

這是我覺得最容易被高估的地方,所以拿 Day 14 挑的台積電年報頁面實際對一遍:

頁面內容 例子(出自本地年報文字層) XBRL 能不能驗
財報主表數字 綜合損益表、資產負債表的科目金額 可以,這是它的本業
股東信裡的敘述數字 page 5:「若以美元計,台積公司的營收年增35.9%」 不行
限制員工權利新股 page 44:「已發行限制員工權利新股股數 2,110,000股」「0.00814%」 不行,財務報表 XBRL 不涵蓋這張表
勞檢裁罰 page 61:「竹環字第1140012798號」「新台幣40萬元」 不行
公司識別 page 9 提到股票代碼 2330 可以拿來確認查詢對象

page 5 那個 35.9% 我特別想講一下。它看起來是個財務數字,而且跟營收有關,很容易讓人以為「XBRL 裡有營收,應該驗得了吧」。但人家寫的是「以美元計」的年增率,XBRL 裡的營收是新台幣。兩年的新台幣營收算出來的成長率,不會等於美元的成長率,因為中間隔著匯率。硬要比的話,verifier 會很有信心地 FLAG 一個完全正確的數字。

所以這個 verifier 碰到不在範圍內的欄位,正確反應是「我沒有意見」,不是 PASS 也不是 FLAG。這件事現在的 Verdict 只有兩個值,表達不出來。Day 21 分工的時候我會把它補上。

page 61 那三筆罰鍰也一樣。它們的正確性要靠 Day 15 的 citation_extractor.py 和 Day 16 的格式規則,XBRL 幫不上忙。

從 OCR 表格到 OcrField

最後一塊:OCR 的輸出要怎麼變成 OcrField?

OcrField 需要的東西比想像中多:

@dataclass(frozen=True)
class OcrField:
    location: str        # "page_11 / 表格 / 營收"
    company_name: str
    period: str
    unit: str
    concept: str
    original_value: str

original_value 最簡單,就是 OCR 讀到的字串。麻煩的是其他四個欄位,它們幾乎都不在同一個儲存格裡:

  • 公司名在頁首或封面
  • 期間在表頭,「114年度」「113年度」兩欄
  • 單位在表格右上角那行小字「單位:新台幣千元」
  • 科目在同一列最左邊那格

也就是說,一個 OcrField 要從一頁的四個不同位置拼起來。只要表頭那一欄讀錯,本期跟上期對調,整欄數字都會變成「期間不同」的 FLAG。這其實是好事,它會一次暴露出來,不會零零星星地漏。

民國年要轉西元。「民國一百一十四年」是 2025。Day 15 緊裁 OCR 那次,模型把它讀成「民國一百十四年」少了一個「一」,這個寫法在中文數字裡還是 114,轉換器要吃得下;但如果讀成「一百四年」就是另一回事了。這種轉換規則我會寫成獨立的小函式,配自己的測試,不會塞進 prompt 裡叫模型幫忙轉。

這些拼裝的前提是:我得知道「表頭在哪」「單位那行字在哪」「這一格屬於哪一列」。這就不是 verifier 的事了,是更前面的事。

Phase 4 四天下來,我手上有字號抽取、欄位格式規則、XBRL 比對三支程式,但它們都假設輸入已經是切好、帶定位的欄位。現實中從 PDF 到欄位這一段,Day 14 已經示範過會崩成什麼樣子。明天回頭把 PyMuPDF、Docling 跟 Region Routing 接起來,讓這三支程式有東西可以吃。


上一篇
Day 16 - 關鍵欄位語意驗證:金額、日期、簽約對象一致性檢查
下一篇
Day 18 - Pipeline 組裝:PyMuPDF、Docling、Region Routing
系列文
從 LLM 到 Harness: 打造隱私與可信任的繁中進階 OCR Agent 共 18 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言