Files
arcrun-collector/convert.go
T
Leo 12c8098b5f feat(t73): Excel/CSV 抽取器——企業用很多(leo)
leo 兩句定調:
①「要思考 Excel 和 csv 的問題,因為企業用很多」
②「企業的 excel 通常不會是 1 萬行,人工做不出這麼多,但你可以轉結構資料丟進去」
→ 修正我先前「CSV 會變數字牆、先不加」的判斷——那是拿機器產生的百萬列當前提。
   實測 60列x6欄 ≈ 2,200 token,對 LLM 完全不是問題。

統一中間格式=Markdown(leo 提 n8n 對照後定調):
n8n 一切轉 JSON 是對的,因為它下游是程式(.欄位 取值);
我們下游是 LLM ⇒ Markdown 表格更好。實測同一份表格
JSON 6,872 字元 vs Markdown 3,341=JSON 多花 2.1 倍 token(每列重複寫欄位名)。

測試 8/8,用 openpyxl 產的真 Excel 檔(非自捏 XML):
   測試抓到兩個真 bug(讀原始碼看不出來):
     ① rels 的 Target 是絕對路徑 /xl/... 我卻又補 xl/ 前綴 → 變 xl/xl/...
     ② r:id 帶 namespace,寫 xml:"id,attr" 抓不到,要用完整 namespace URI
     修好後分頁名從 ## sheet1 變成 ## 維修紀錄(企業分頁名本身就是語意)
  CSV:去 BOM(Excel 另存必帶)/引號內逗號不拆欄/| 跳脫/欄數不齊不整份失敗
  XLSX:多工作表全讀(只取第一頁會漏)/sharedStrings 索引/壞檔報錯

防呆:超過 500 列截斷並明說截斷了(不靜默丟資料);單格超 500 字截斷。
2026-07-27 20:50:45 +08:00

137 lines
5.5 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)。
// **統一中間格式=Markdown**2026-07-27leo 提 n8n 對照後定調)。
//
// leo:「其實 n8n 把一切 extract 都轉成 json,因為**它內部跑 json**」——這個觀察是對的,
// 而且點出「**統一中間格式**」本身就是價值。差別在下游是誰:
//
// n8n 的下游是**程式**(節點之間要 `$json.欄位` 取值)→ JSON 可定址,正確。
// 我們的下游是 **LLM**(讀完寫知識卡)→ Markdown 表格更好。
//
// 實測(60 列 × 6 欄的維修紀錄表):JSON 6,872 字元 vs Markdown 3,341 字元,
// **JSON 多花 2.1 倍 token**——因為每一列都要重複寫一次欄位名。
// 且 Markdown 與卡片格式同語言,萃出來的卡自然帶得走表格。
var extractors = map[string]extractor{
".docx": extractDocx,
".csv": extractCSV,
".xlsx": extractXLSX,
// .pdf → PDFium-via-wazero(下一步接;體積 +10.43MB,實測 15頁/2.2MB=134ms
// .pptx → 同 docx 的 ZIP+XML 路數,數十行,體積幾乎不變
}
// 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)
}