diff --git a/convert.go b/convert.go new file mode 100644 index 0000000..07b2893 --- /dev/null +++ b/convert.go @@ -0,0 +1,123 @@ +// convert.go — 本地轉檔層(「收集端 Markitdown」,repo 定位 CLAUDE.md:13) +// +// leo 2026-07-27 定的形狀: +// +// 「本地任何檔案都透過一個機制把它轉成模型可讀,再把模型可讀內容發給它」 +// 「能不能讀 PDF 根本不是 Arcrun 的工作」 +// +// 所以這一層是**調度器(工頭)**:認副檔名 → 派給對應的抽取器(師傅)→ 統一吐出純文字。 +// 好處是 Arcrun 那條管線永遠只處理文字,不必為每種格式去改框架(繞開 host fn 的 +// 64KB/UTF-8 文字通道限制,見 rag-wave1/pdf-extraction-options.md 洞 B)。 +// +// 硬前提(daemon-beta/tasks.md:469,leo 定):**不裝 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) +} diff --git a/convert_docx.go b/convert_docx.go new file mode 100644 index 0000000..e4f27d1 --- /dev/null +++ b/convert_docx.go @@ -0,0 +1,82 @@ +// convert_docx.go — .docx 抽文字(純 Go 標準庫,零外部依賴) +// +// 為什麼不需要外部引擎(2026-07-27 POC 實測後定案): +// +// .docx/.xlsx/.pptx = Office Open XML = **本質是 ZIP,裡面是 XML**, +// 文字明明白白寫在 標籤裡。Go 標準庫 archive/zip + encoding/xml 直接讀得到。 +// 實測體積代價 **+0.31 MB**(對照 PDF 走 PDFium 是 +10.43 MB,差 33 倍)。 +// +// 對比 PDF 為何非要引擎不可:PDF 是**印刷格式**,記的是「這個字畫在第 3 頁座標 (120,400)」, +// 要把散落字元還原成通順文字=需要整顆排版還原引擎。兩者不是同一個難度等級。 +package main + +import ( + "archive/zip" + "bytes" + "encoding/xml" + "fmt" + "io" + "strings" +) + +// docx 內文的位置。註:頁首/頁尾/註腳另有 header*.xml/footnotes.xml, +// 目前只取主文——知識萃取要的是內容,頁首頁尾多半是雜訊(頁碼、公司名)。 +const docxMainPart = "word/document.xml" + +func extractDocx(data []byte) (string, error) { + zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + // .docx 不是 zip = 檔案壞了,或副檔名騙人(例如舊版 .doc 改名成 .docx)。 + // 舊版 .doc 是完全不同的二進位格式(OLE),本抽取器讀不了。 + return "", fmt.Errorf("docx 解壓失敗(可能是舊版 .doc 或檔案損壞):%w", err) + } + for _, f := range zr.File { + if f.Name != docxMainPart { + continue + } + rc, err := f.Open() + if err != nil { + return "", fmt.Errorf("讀 %s 失敗:%w", docxMainPart, err) + } + defer rc.Close() + return docxXMLText(rc) + } + return "", fmt.Errorf("docx 缺少 %s", docxMainPart) +} + +// docxXMLText 串流解 XML 取文字。 +// +// 只認三個標籤就夠: +// - w:p 段落 → 換行 +// - w:tab 定位點 → tab +// - w:br 斷行 → 換行 +// +// 其餘一律當文字節點收集。用 Local name 比對(不看 namespace 前綴), +// 因為不同產生器的前綴可能不同(w: / w14: 等)。 +func docxXMLText(r io.Reader) (string, error) { + dec := xml.NewDecoder(r) + var sb strings.Builder + for { + tok, err := dec.Token() + if err == io.EOF { + break + } + if err != nil { + return "", fmt.Errorf("解析 document.xml 失敗:%w", err) + } + switch t := tok.(type) { + case xml.StartElement: + switch t.Name.Local { + case "p": + sb.WriteString("\n") + case "tab": + sb.WriteString("\t") + case "br": + sb.WriteString("\n") + } + case xml.CharData: + sb.Write(t) + } + } + return sb.String(), nil +} diff --git a/convert_test.go b/convert_test.go new file mode 100644 index 0000000..25d1dba --- /dev/null +++ b/convert_test.go @@ -0,0 +1,151 @@ +package main + +import ( + "archive/zip" + "bytes" + "errors" + "strings" + "testing" +) + +// 造一個結構正確的最小 .docx(與真 Word 檔同結構:ZIP + word/document.xml)。 +func makeDocx(t *testing.T, bodyXML string) []byte { + t.Helper() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + w, err := zw.Create("word/document.xml") + if err != nil { + t.Fatalf("建 zip 失敗: %v", err) + } + xml := ` + +` + bodyXML + `` + if _, err := w.Write([]byte(xml)); err != nil { + t.Fatalf("寫 zip 失敗: %v", err) + } + // 真 docx 還有這些,一併放進去確保我們不會誤讀到它們 + for _, extra := range []string{"[Content_Types].xml", "word/theme/theme1.xml"} { + e, _ := zw.Create(extra) + e.Write([]byte(`不該被抽到的雜訊`)) + } + if err := zw.Close(); err != nil { + t.Fatalf("關 zip 失敗: %v", err) + } + return buf.Bytes() +} + +func TestExtractDocx_中文與段落(t *testing.T) { + body := `船舶維修合約` + + `維修費用為新台幣 350,000 元整。` + got, err := ConvertToText("a.docx", makeDocx(t, body)) + if err != nil { + t.Fatalf("不該出錯: %v", err) + } + for _, want := range []string{"船舶維修合約", "350,000", "維修費用"} { + if !strings.Contains(got, want) { + t.Errorf("抽出的文字缺少 %q,實得:\n%s", want, got) + } + } + // 主文以外的檔案不可被抽進來 + if strings.Contains(got, "不該被抽到的雜訊") { + t.Errorf("抽到了 document.xml 以外的內容:\n%s", got) + } +} + +func TestExtractDocx_同段落多個run要相連(t *testing.T) { + // Word 常把一句話因格式切成多個 ,它們必須黏在一起而不是被拆行 + body := `合約金額三十五萬` + got, err := ConvertToText("a.docx", makeDocx(t, body)) + if err != nil { + t.Fatalf("不該出錯: %v", err) + } + if !strings.Contains(got, "合約金額三十五萬") { + t.Errorf("同段落的 run 應相連,實得: %q", got) + } +} + +func TestConvertToText_純文字直接過(t *testing.T) { + for _, p := range []string{"a.md", "b.markdown", "c.txt", "D.TXT"} { + got, err := ConvertToText(p, []byte("純文字內容")) + if err != nil { + t.Errorf("%s 不該出錯: %v", p, err) + } + if got != "純文字內容" { + t.Errorf("%s 應原樣回傳,實得 %q", p, got) + } + } +} + +func TestConvertToText_不支援的格式要明確報錯(t *testing.T) { + _, err := ConvertToText("a.xyz", []byte("x")) + if !errors.Is(err, ErrUnsupported) { + t.Errorf("應回 ErrUnsupported,實得 %v", err) + } +} + +// 這是掃描件 PDF 的行為契約:抽不出字要回 ErrNoText,不能「成功但空字串」, +// 否則會退化成 leo 撞到的靜默略過(用戶以為進去了、其實是空的)。 +func TestConvertToText_抽不出文字要回ErrNoText(t *testing.T) { + body := ` ` // 只有空白 + _, err := ConvertToText("a.docx", makeDocx(t, body)) + if !errors.Is(err, ErrNoText) { + t.Errorf("應回 ErrNoText,實得 %v", err) + } +} + +func TestExtractDocx_壞檔要報錯不當機(t *testing.T) { + _, err := ConvertToText("a.docx", []byte("這不是 zip,是舊版 .doc 改名")) + if err == nil { + t.Error("壞檔應該報錯") + } + if errors.Is(err, ErrNoText) { + t.Error("壞檔不該被歸類成 ErrNoText(訊息要能區分「檔壞了」和「沒有文字」)") + } +} + +func TestNormalizeText_NFKC修康熙部首(t *testing.T) { + // U+2F02 康熙部首「丶」vs 正常的 U+4E36。PDF 抽中文常出這種變體, + // 長得一樣但碼位不同 → 使用者會搜不到自己的檔案。 + got := normalizeText("⼀") // 康熙部首「一」 + if got != "一" { // 應摺成正常的「一」 + t.Errorf("NFKC 應把康熙部首摺回正常字,實得 %q (%U)", got, []rune(got)) + } +} + +func TestNormalizeText_去控制字元與壓空行(t *testing.T) { + got := normalizeText("第一行\x00\n\n\n\n第二行\f") + if strings.ContainsRune(got, '\x00') || strings.ContainsRune(got, '\f') { + t.Errorf("控制字元應被去掉,實得 %q", got) + } + if strings.Contains(got, "\n\n\n") { + t.Errorf("過量空行應被壓掉,實得 %q", got) + } + if !strings.Contains(got, "第一行") || !strings.Contains(got, "第二行") { + t.Errorf("內容不該遺失,實得 %q", got) + } +} + +func TestNormalizeText_保留tab與單一換行(t *testing.T) { + got := normalizeText("欄一\t欄二\n下一行") + if !strings.Contains(got, "\t") { + t.Errorf("tab 應保留(表格語意),實得 %q", got) + } + if !strings.Contains(got, "\n") { + t.Errorf("換行應保留,實得 %q", got) + } +} + +func TestCanConvert與IsPlainText(t *testing.T) { + if !IsPlainText("a.md") || !IsPlainText("b.TXT") { + t.Error("md/txt 應被認為純文字") + } + if IsPlainText("a.docx") { + t.Error("docx 不是純文字") + } + if !CanConvert("a.docx") { + t.Error("docx 應該轉得了") + } + if CanConvert("a.md") { + t.Error("純文字不走 extractors(走 passthrough)") + } +} diff --git a/go.mod b/go.mod index 884034b..8b2033d 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module arcrun-rag/collector -go 1.22 +go 1.25.0 + +require golang.org/x/text v0.40.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7ae431d --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=