Files
arcrun-collector/convert_table_test.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

126 lines
4.0 KiB
Go

package main
import (
"os"
"strings"
"testing"
)
const realXLSX = "/private/tmp/claude-501/-Users-youlinhsieh-Documents-tech-projects-InkStoneCo/92a75156-c295-4c79-bfeb-de1a20e4ed26/scratchpad/office-poc/real.xlsx"
func TestCSV_轉成Markdown表格(t *testing.T) {
csv := "工單號,船名,金額\nWO-1001,海運三號,\"350,000\"\nWO-1002,長榮七號,\"128,500\"\n"
got, err := ConvertToText("維修.csv", []byte(csv))
if err != nil {
t.Fatalf("不該出錯: %v", err)
}
// 標題行 + 分隔線 = Markdown 表格的辨識特徵
if !strings.Contains(got, "| 工單號 | 船名 | 金額 |") {
t.Errorf("應有標題行,實得:\n%s", got)
}
if !strings.Contains(got, "| --- |") {
t.Errorf("應有分隔線(否則 LLM 認不出是表格),實得:\n%s", got)
}
// 引號包住的逗號數字不可被拆成兩欄
if !strings.Contains(got, "350,000") {
t.Errorf("引號內的逗號不該被當欄位分隔,實得:\n%s", got)
}
t.Logf("CSV 轉出:\n%s", got)
}
func TestCSV_去BOM(t *testing.T) {
// Excel 另存 CSV 幾乎一定帶 BOM,不去掉第一個欄名會多出看不見的字元 → 標題對不上
withBOM := append([]byte{0xEF, 0xBB, 0xBF}, []byte("欄一,欄二\n值1,值2\n")...)
got, err := ConvertToText("a.csv", withBOM)
if err != nil {
t.Fatalf("不該出錯: %v", err)
}
if !strings.Contains(got, "| 欄一 |") {
t.Errorf("BOM 應被去掉,實得: %q", got[:min(60, len(got))])
}
}
func TestCSV_欄位含直線要跳脫(t *testing.T) {
// 儲存格內容有 | 會把 Markdown 表格欄位切錯位
got, err := ConvertToText("a.csv", []byte("名稱,備註\n產品A,\"甲|乙\"\n"))
if err != nil {
t.Fatalf("不該出錯: %v", err)
}
if !strings.Contains(got, `甲\|乙`) {
t.Errorf("儲存格內的 | 應被跳脫,實得:\n%s", got)
}
}
func TestCSV_每列欄數不同不該整份失敗(t *testing.T) {
// 手工表格常見:尾列缺欄
got, err := ConvertToText("a.csv", []byte("A,B,C\n1,2,3\n4,5\n"))
if err != nil {
t.Fatalf("欄數不齊不該整份失敗: %v", err)
}
if !strings.Contains(got, "4") || !strings.Contains(got, "5") {
t.Errorf("缺欄的列仍應保留內容,實得:\n%s", got)
}
}
func TestXLSX_真Excel檔含多工作表(t *testing.T) {
data, err := os.ReadFile(realXLSX)
if err != nil {
t.Skip("無真 xlsx 測資,跳過")
}
got, err := ConvertToText("real.xlsx", data)
if err != nil {
t.Fatalf("真 Excel 檔應該讀得了: %v", err)
}
// 第一個工作表的內容
for _, want := range []string{"工單號", "海運三號", "主機大修", "350,000"} {
if !strings.Contains(got, want) {
t.Errorf("缺少第一工作表的 %q,實得:\n%s", want, got)
}
}
// 第二個工作表——只取第一頁會漏資料,企業檔案常一檔多分頁
for _, want := range []string{"料號", "主軸承", "45,000"} {
if !strings.Contains(got, want) {
t.Errorf("缺少第二工作表的 %q(多工作表沒被讀到),實得:\n%s", want, got)
}
}
t.Logf("真 Excel 轉出:\n%s", got)
}
func TestXLSX_壞檔要報錯不當機(t *testing.T) {
_, err := ConvertToText("a.xlsx", []byte("這不是 zip,是舊版 .xls 改名"))
if err == nil {
t.Error("壞檔應該報錯")
}
}
func TestTable_空表格回ErrNoText(t *testing.T) {
_, err := ConvertToText("empty.csv", []byte(""))
if err == nil {
t.Error("空 CSV 應回錯而不是空字串(否則會靜默送空卡給 LLM)")
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func TestXLSX_要用使用者取的分頁名而非sheet1(t *testing.T) {
data, err := os.ReadFile(realXLSX)
if err != nil {
t.Skip("無真 xlsx 測資")
}
got, _ := ConvertToText("real.xlsx", data)
// 企業分頁名本身就是語意(「維修紀錄」「報價單」),用 sheet1 會讓 LLM 少掉脈絡
for _, want := range []string{"## 維修紀錄", "## 報價單"} {
if !strings.Contains(got, want) {
t.Errorf("應使用分頁名 %q,實得:\n%s", want, got)
}
}
if strings.Contains(got, "## sheet1") {
t.Errorf("不該退回檔名 sheet1,實得:\n%s", got)
}
}