Files
arcrun-collector/convert.go
T
Leo 66d4fef045 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 主流程。
2026-07-27 20:36:02 +08:00

124 lines
4.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
}