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:
+123
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// convert_docx.go — .docx 抽文字(純 Go 標準庫,零外部依賴)
|
||||
//
|
||||
// 為什麼不需要外部引擎(2026-07-27 POC 實測後定案):
|
||||
//
|
||||
// .docx/.xlsx/.pptx = Office Open XML = **本質是 ZIP,裡面是 XML**,
|
||||
// 文字明明白白寫在 <w:t> 標籤裡。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
|
||||
}
|
||||
+151
@@ -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 := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body>` + bodyXML + `</w:body></w:document>`
|
||||
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(`<?xml version="1.0"?><x>不該被抽到的雜訊</x>`))
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("關 zip 失敗: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestExtractDocx_中文與段落(t *testing.T) {
|
||||
body := `<w:p><w:r><w:t>船舶維修合約</w:t></w:r></w:p>` +
|
||||
`<w:p><w:r><w:t>維修費用為新台幣 350,000 元整。</w:t></w:r></w:p>`
|
||||
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 常把一句話因格式切成多個 <w:r>,它們必須黏在一起而不是被拆行
|
||||
body := `<w:p><w:r><w:t>合約金額</w:t></w:r><w:r><w:t>三十五萬</w:t></w:r></w:p>`
|
||||
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 := `<w:p><w:r><w:t> </w:t></w:r></w:p>` // 只有空白
|
||||
_, 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)")
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
module arcrun-rag/collector
|
||||
|
||||
go 1.22
|
||||
go 1.25.0
|
||||
|
||||
require golang.org/x/text v0.40.0 // indirect
|
||||
|
||||
Reference in New Issue
Block a user