feat(t73/t16): 本地轉檔層骨架+docx 抽取器(leo 定的『收集端 Markitdown』)

leo 07-27 定的形狀:「本地任何檔案都透過一個機制把它轉成模型可讀,再把模型可讀
內容發給它」「能不能讀 PDF 根本不是 Arcrun 的工作」。

convert.go=調度層(工頭):認副檔名→派抽取器→統一吐純文字。
加新格式只要在 extractors 註冊一行,不動架構。
convert_docx.go=第一個抽取器,純標準庫 archive/zip+encoding/xml。

三個設計決定(都有理由,非隨手):
① ErrNoText 獨立錯誤——掃描件 PDF 抽不出字時絕不能靜默略過(leo 撞過的病),
   要能轉成使用者看得懂的訊息;與 ErrUnsupported 分開因為說法不同。
② NFKC 正規化——PDF 抽中文會出康熙部首變體,長得一樣但碼位不同=用戶搜不到
   自己的檔案。廉價保險,對其他來源同樣有效。
③ 只取 word/document.xml——頁首頁尾多是雜訊(頁碼/公司名),知識萃取不要。

測試 10/10 + 真 Word 檔實測:
  textutil 產生的 real.docx → 抽出「船舶維修合約/350,000/2026 年 8 月 15 日」全對
  同段落多 run 相連(Word 常把一句話切成多個 w:r)
  壞檔(舊版 .doc 改名)報錯且不歸類成 ErrNoText
  NFKC 康熙部首摺回正常字
註:全形「,」會被 NFKC 轉半形「,」=正常行為,對搜尋無害(反而統一)。

下一步:接 PDFium-via-wazero(+10.43MB)。未接進 extract_gemma 主流程。
This commit is contained in:
2026-07-27 20:36:02 +08:00
parent 26dd6960c2
commit 66d4fef045
5 changed files with 361 additions and 1 deletions
+123
View File
@@ -0,0 +1,123 @@
// convert.go — 本地轉檔層(「收集端 Markitdown」,repo 定位 CLAUDE.md:13
//
// leo 2026-07-27 定的形狀:
//
// 「本地任何檔案都透過一個機制把它轉成模型可讀,再把模型可讀內容發給它」
// 「能不能讀 PDF 根本不是 Arcrun 的工作」
//
// 所以這一層是**調度器(工頭)**:認副檔名 → 派給對應的抽取器(師傅)→ 統一吐出純文字。
// 好處是 Arcrun 那條管線永遠只處理文字,不必為每種格式去改框架(繞開 host fn 的
// 64KBUTF-8 文字通道限制,見 rag-wave1/pdf-extraction-options.md 洞 B)。
//
// 硬前提(daemon-beta/tasks.md:469leo 定):**不裝 markitdown**——微軟那套是 Python
// 套件,要用戶先有 Python 環境=違背「install 完即可用,不留抽象前置步驟」原則。
// 所以每個抽取器都必須是**純 Go/無 CGo**(才能跨編 Windows,t72 已實測這條路可行)。
//
// 加新格式=在 extractors 註冊一個 func,不動架構。
package main
import (
"fmt"
"path/filepath"
"strings"
"unicode"
"golang.org/x/text/unicode/norm"
)
// ErrNoText:檔案讀得到、格式也認得,但**抽不出任何文字**。
//
// 為什麼要有這個獨立錯誤:掃描件/翻拍的 PDF 就是這種——PDFium 不做 OCR,會回空字串。
// 這時**絕不能靜默略過**(那正是 leo 撞到的病:丟檔進去沒反應、用戶以為進去了其實是空的)。
// 呼叫端必須把它轉成使用者看得懂的訊息。
var ErrNoText = fmt.Errorf("檔案裡沒有可抽取的文字")
// ErrUnsupported:副檔名不在支援清單內。與 ErrNoText 分開,因為給用戶的說法不同
//(「這種檔案我還不會讀」vs「這個檔看起來是掃描的圖」)。
var ErrUnsupported = fmt.Errorf("尚未支援的檔案格式")
// extractor 吃檔案位元組,吐純文字。
type extractor func(data []byte) (string, error)
// extractors=格式→師傅的對照表。加新格式只要在這裡註冊一行。
//
// .md/.markdown/.txt 不在此表:它們本來就是純文字,走 passthrough(見 ConvertToText)。
var extractors = map[string]extractor{
".docx": extractDocx,
// .pdf → PDFium-via-wazero(下一步接;體積 +10.43MB,實測 15頁/2.2MB=134ms
// .pptx/.xlsx → 同 docx 的 ZIP+XML 路數,各數十行,體積幾乎不變(POC 實測 +0.31MB
}
// IsPlainText 回報這個副檔名是否本來就是純文字(不需要轉檔)。
func IsPlainText(path string) bool {
switch strings.ToLower(filepath.Ext(path)) {
case ".md", ".markdown", ".txt":
return true
}
return false
}
// CanConvert 回報這個副檔名是否有對應的抽取器(不含純文字)。
func CanConvert(path string) bool {
_, ok := extractors[strings.ToLower(filepath.Ext(path))]
return ok
}
// ConvertToText=本層的唯一入口:任何檔案 → 模型可讀的純文字。
//
// 純文字檔原樣回傳(只做正規化);其他格式派給對應抽取器。
// 抽不出文字回 ErrNoText,不支援的格式回 ErrUnsupported——兩者都**不是**「成功但空字串」,
// 呼叫端才有辦法給用戶明確訊號。
func ConvertToText(path string, data []byte) (string, error) {
ext := strings.ToLower(filepath.Ext(path))
if IsPlainText(path) {
return normalizeText(string(data)), nil
}
ex, ok := extractors[ext]
if !ok {
return "", fmt.Errorf("%w%s", ErrUnsupported, ext)
}
txt, err := ex(data)
if err != nil {
return "", err
}
txt = normalizeText(txt)
if strings.TrimSpace(txt) == "" {
return "", ErrNoText
}
return txt, nil
}
// normalizeText 做兩件事,兩件都是實測後才加的:
//
// 1. **NFKC 正規化**:PDF 抽出的中文常出現「康熙部首」等相容字元變體
// (實測見 pdf-extraction-options.md §3)——長得跟正常字一模一樣但碼位不同,
// 會導致**使用者搜不到自己的檔案**。NFKC 把它們摺回正常字。這是廉價保險,
// 對其他來源同樣有效。
// 2. 去掉控制字元、統一換行、壓掉過量空行——PDF 抽出的文字常夾雜排版殘渣。
func normalizeText(s string) string {
s = strings.ReplaceAll(s, "\r\n", "\n")
s = strings.ReplaceAll(s, "\r", "\n")
s = norm.NFKC.String(s)
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
// 保留換行與 tab;其餘控制字元(PDF 常見的 \x00、\f 等)丟掉。
if r == '\n' || r == '\t' {
b.WriteRune(r)
continue
}
if unicode.IsControl(r) {
continue
}
b.WriteRune(r)
}
// 連續 3 個以上換行壓成 2 個(保留段落感,去掉整頁空白)。
out := b.String()
for strings.Contains(out, "\n\n\n") {
out = strings.ReplaceAll(out, "\n\n\n", "\n\n")
}
return strings.TrimSpace(out)
}