昨天那支 semantic_field_validator.py 我其實滿喜歡的。它會抓 NT$1,23元 這種千分位壞掉的金額,也會發現同一個簽約日在兩頁寫得不一樣。
但它有個抓不到的東西,而且這東西剛好是整個系列最早就出現的錯。
Day 2 那條:
原文:一年內到期長期負債 4,455
輸出:短期負債 4,555
4,555 格式完全合法。千分位對、沒有括號問題、也沒有跨頁矛盾。昨天的規則會很開心地放它過去。格式檢查只能問「這長得像不像一個金額」,沒辦法問「這是不是那個金額」。
要回答後面那個問題,手上得有另一份答案。
上市櫃公司申報財報時,除了大家看的 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 在這裡的態度可能跟一般看法不太一樣:財報數字這種東西,檢索出「相似」的段落完全沒用。我要的是「同一家、同一年、同一個科目」那一個值,差一點都不行。相似度越高,越容易拿錯年度的數字來背書。
先把尷尬的事講完。
我要的是台積電(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,後面要找的人不一樣。
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,不准給「建議值」。建議值一旦出現,下游遲早有人拿它當答案。
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?
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 讀到的字串。麻煩的是其他四個欄位,它們幾乎都不在同一個儲存格裡:
也就是說,一個 OcrField 要從一頁的四個不同位置拼起來。只要表頭那一欄讀錯,本期跟上期對調,整欄數字都會變成「期間不同」的 FLAG。這其實是好事,它會一次暴露出來,不會零零星星地漏。
民國年要轉西元。「民國一百一十四年」是 2025。Day 15 緊裁 OCR 那次,模型把它讀成「民國一百十四年」少了一個「一」,這個寫法在中文數字裡還是 114,轉換器要吃得下;但如果讀成「一百四年」就是另一回事了。這種轉換規則我會寫成獨立的小函式,配自己的測試,不會塞進 prompt 裡叫模型幫忙轉。
這些拼裝的前提是:我得知道「表頭在哪」「單位那行字在哪」「這一格屬於哪一列」。這就不是 verifier 的事了,是更前面的事。
Phase 4 四天下來,我手上有字號抽取、欄位格式規則、XBRL 比對三支程式,但它們都假設輸入已經是切好、帶定位的欄位。現實中從 PDF 到欄位這一段,Day 14 已經示範過會崩成什麼樣子。明天回頭把 PyMuPDF、Docling 跟 Region Routing 接起來,讓這三支程式有東西可以吃。