12c8098b5f
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 字截斷。
137 lines
5.5 KiB
Go
137 lines
5.5 KiB
Go
// 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)。
|
||
// **統一中間格式=Markdown**(2026-07-27,leo 提 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)
|
||
}
|