From 12c8098b5faf44c01e8a9e0203d8df096a99d65a Mon Sep 17 00:00:00 2001 From: richblack Date: Mon, 27 Jul 2026 20:50:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(t73):=20Excel/CSV=20=E6=8A=BD=E5=8F=96?= =?UTF-8?q?=E5=99=A8=E2=80=94=E2=80=94=E4=BC=81=E6=A5=AD=E7=94=A8=E5=BE=88?= =?UTF-8?q?=E5=A4=9A=EF=BC=88leo=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 字截斷。 --- convert.go | 15 +- convert_table.go | 321 ++++++++++++++++++++++++++++++++++++++++++ convert_table_test.go | 125 ++++++++++++++++ scan.go | 8 +- 4 files changed, 467 insertions(+), 2 deletions(-) create mode 100644 convert_table.go create mode 100644 convert_table_test.go diff --git a/convert.go b/convert.go index 07b2893..46e60df 100644 --- a/convert.go +++ b/convert.go @@ -42,10 +42,23 @@ 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/.xlsx → 同 docx 的 ZIP+XML 路數,各數十行,體積幾乎不變(POC 實測 +0.31MB) + // .pptx → 同 docx 的 ZIP+XML 路數,數十行,體積幾乎不變 } // IsPlainText 回報這個副檔名是否本來就是純文字(不需要轉檔)。 diff --git a/convert_table.go b/convert_table.go new file mode 100644 index 0000000..1f66d3a --- /dev/null +++ b/convert_table.go @@ -0,0 +1,321 @@ +// convert_table.go — 表格類抽取(.csv / .xlsx) +// +// leo 2026-07-27 定調: +// +// 「企業的 excel 通常不會是 1 萬行,人工做不出這麼多,**但你可以轉結構資料丟進去**。」 +// 「要思考 Excel 和 csv 的問題,因為**企業用很多**。」 +// +// 這修正了先前「CSV 會變成數字牆、先不做」的判斷——那是拿「機器產生的百萬列資料」 +// 當前提,但**人工維護的企業表格**(維修紀錄、報價單、料號表、值班表) +// 通常幾十到幾百列。實測:60 列 × 6 欄 ≈ 2,200 token,對 LLM 完全不是問題。 +// +// **為什麼轉成 Markdown 表格**:LLM 對 Markdown 表格的理解遠優於 CSV 原文 +//(欄位對齊、標題行明確);且與現有 .md 卡片格式同語言,萃出來的卡自然帶得走表格。 +// +// 防呆:超大表格(機器產生的那種)仍會截斷並明說截斷了——不能讓一個檔案吃掉 +// 整個 context,也不能靜默丟資料(靜默是本專案一再犯的病)。 +package main + +import ( + "archive/zip" + "bytes" + "encoding/csv" + "encoding/xml" + "fmt" + "io" + "strconv" + "strings" +) + +// maxTableRows:超過就截斷。 +// 依據=人工維護的表格幾乎不會超過這個量(leo 判斷);超過多半是機器匯出的原始資料, +// 那種東西整份塞進知識庫本來就沒意義。截斷會在輸出尾端明講。 +const maxTableRows = 500 + +// maxCellRunes:單格過長(有人把整篇文章塞進一格)截斷,避免一格撐爆整張表。 +const maxCellRunes = 500 + +func extractCSV(data []byte) (string, error) { + // 去 UTF-8 BOM——Excel 另存 CSV 幾乎一定帶 BOM,不去掉第一個欄名會多出看不見的字元, + // 導致標題對不上(實務上很常見的坑)。 + data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF}) + + r := csv.NewReader(bytes.NewReader(data)) + r.FieldsPerRecord = -1 // 允許每列欄數不同(手工表格常見尾列缺欄) + r.LazyQuotes = true // 容忍不規範的引號,別為了格式潔癖整份讀不到 + + var rows [][]string + for { + rec, err := r.Read() + if err == io.EOF { + break + } + if err != nil { + // 讀到一半壞掉:保留已讀到的部分而不是整份放棄—— + // 有內容總比沒有好,但要在輸出裡說明。 + if len(rows) == 0 { + return "", fmt.Errorf("CSV 解析失敗:%w", err) + } + rows = append(rows, []string{fmt.Sprintf("(後續內容解析失敗:%v)", err)}) + break + } + rows = append(rows, rec) + if len(rows) > maxTableRows { + break + } + } + return rowsToMarkdown(rows, len(rows) > maxTableRows), nil +} + +// ── .xlsx ──────────────────────────────────────────────────────────────── +// +// xlsx 與 docx 同為 Office Open XML(ZIP+XML),但多一層轉折: +// 字串不直接寫在儲存格裡,而是集中放在 sharedStrings.xml,儲存格用索引指過去 +//(t="s" 代表這格的值是 sharedStrings 的索引)。所以要先讀字串表再讀工作表。 + +type xlsxSST struct { + Items []struct { + // 底下可能是單一 ,也可能被拆成多個 (同格內有不同格式時) + T string `xml:"t"` + R []string `xml:"r>t"` + } `xml:"si"` +} + +// 分頁名住在 xl/workbook.xml,靠 r:id 對應到 xl/worksheets/sheetN.xml +//(對應關係在 xl/_rels/workbook.xml.rels)。 +// +// **為什麼非做這個對應不可**:企業表格的分頁名本身就是語意——「維修紀錄」「報價單」 +// 「2026 Q3 預算」。直接用檔名會輸出 `## sheet1`,LLM 就少掉了「這張表是什麼」的關鍵脈絡。 +type xlsxWorkbook struct { + Sheets []struct { + Name string `xml:"name,attr"` + // r:id 帶 namespace。Go 的 encoding/xml 用「namespace URI 空格 屬性名」比對; + // 寫成 `xml:"id,attr"` 在有 namespace 前綴時抓不到(實測踩過)。 + ID string `xml:"http://schemas.openxmlformats.org/officeDocument/2006/relationships id,attr"` + } `xml:"sheets>sheet"` +} + +type xlsxRels struct { + Rels []struct { + ID string `xml:"Id,attr"` + Target string `xml:"Target,attr"` + } `xml:"Relationship"` +} + +type xlsxSheet struct { + Rows []struct { + Cells []struct { + Ref string `xml:"r,attr"` + Type string `xml:"t,attr"` + Value string `xml:"v"` + // inlineStr 形態(有些產生器不用 sharedStrings) + IS struct { + T string `xml:"t"` + R []string `xml:"r>t"` + } `xml:"is"` + } `xml:"c"` + } `xml:"sheetData>row"` +} + +func extractXLSX(data []byte) (string, error) { + zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return "", fmt.Errorf("xlsx 解壓失敗(可能是舊版 .xls 或檔案損壞):%w", err) + } + + var sst xlsxSST + var wb xlsxWorkbook + var rels xlsxRels + sheets := map[string][]byte{} + for _, f := range zr.File { + switch { + case f.Name == "xl/sharedStrings.xml": + b, err := readZipFile(f) + if err != nil { + return "", err + } + if err := xml.Unmarshal(b, &sst); err != nil { + return "", fmt.Errorf("讀 sharedStrings 失敗:%w", err) + } + case f.Name == "xl/workbook.xml": + if b, err := readZipFile(f); err == nil { + xml.Unmarshal(b, &wb) // 失敗只是拿不到分頁名,退回檔名,不該讓整份失敗 + } + case f.Name == "xl/_rels/workbook.xml.rels": + if b, err := readZipFile(f); err == nil { + xml.Unmarshal(b, &rels) + } + case strings.HasPrefix(f.Name, "xl/worksheets/sheet") && strings.HasSuffix(f.Name, ".xml"): + b, err := readZipFile(f) + if err != nil { + return "", err + } + sheets[f.Name] = b + } + } + + // r:id → 檔案路徑 → 使用者取的分頁名。 + // + // ⚠️ Target 有兩種寫法,兩種都要吃(openpyxl 用前者、Excel 常用後者,實測踩過): + // 絕對:/xl/worksheets/sheet1.xml → 去掉開頭的 / 就是 zip 內路徑 + // 相對:worksheets/sheet1.xml → 相對於 xl/,要補前綴 + relTarget := map[string]string{} + for _, r := range rels.Rels { + t := r.Target + if strings.HasPrefix(t, "/") { + t = strings.TrimPrefix(t, "/") + } else if !strings.HasPrefix(t, "xl/") { + t = "xl/" + t + } + relTarget[r.ID] = t + } + sheetNames := map[string]string{} + for _, s := range wb.Sheets { + if p, ok := relTarget[s.ID]; ok && s.Name != "" { + sheetNames[p] = s.Name + } + } + if len(sheets) == 0 { + return "", fmt.Errorf("xlsx 找不到任何工作表") + } + + // 字串表攤平成 []string 供索引 + strs := make([]string, 0, len(sst.Items)) + for _, si := range sst.Items { + if si.T != "" { + strs = append(strs, si.T) + } else { + strs = append(strs, strings.Join(si.R, "")) + } + } + + // 多工作表都輸出,各自標名稱——企業表格常一個檔多個分頁,只取第一頁會漏資料。 + names := sortedKeys(sheets) + var out strings.Builder + for _, name := range names { + var sh xlsxSheet + if err := xml.Unmarshal(sheets[name], &sh); err != nil { + continue // 單一工作表壞掉不該讓整份失敗 + } + var rows [][]string + truncated := false + for _, r := range sh.Rows { + if len(rows) >= maxTableRows { + truncated = true + break + } + var row []string + for _, c := range r.Cells { + row = append(row, xlsxCellText(c.Type, c.Value, strings.Join(append([]string{c.IS.T}, c.IS.R...), ""), strs)) + } + rows = append(rows, row) + } + if len(rows) == 0 { + continue + } + // 分頁名優先用使用者取的(「維修紀錄」),拿不到才退回檔名(sheet1)。 + // 單一工作表也標名稱——那個名字常常就是這張表的主題,對萃卡很有用。 + title := sheetNames[name] + if title == "" { + title = sheetTitle(name) + } + out.WriteString("\n## " + title + "\n\n") + out.WriteString(rowsToMarkdown(rows, truncated)) + out.WriteString("\n") + } + return out.String(), nil +} + +func xlsxCellText(typ, val, inline string, strs []string) string { + if inline != "" { + return inline + } + if typ == "s" { // sharedStrings 索引 + if i, err := strconv.Atoi(val); err == nil && i >= 0 && i < len(strs) { + return strs[i] + } + return "" + } + return val +} + +func readZipFile(f *zip.File) ([]byte, error) { + rc, err := f.Open() + if err != nil { + return nil, fmt.Errorf("讀 %s 失敗:%w", f.Name, err) + } + defer rc.Close() + return io.ReadAll(rc) +} + +func sortedKeys(m map[string][]byte) []string { + ks := make([]string, 0, len(m)) + for k := range m { + ks = append(ks, k) + } + // sheet1, sheet2… 字串排序即可(sheet10 會排在 sheet2 前,可接受) + for i := 0; i < len(ks); i++ { + for j := i + 1; j < len(ks); j++ { + if ks[j] < ks[i] { + ks[i], ks[j] = ks[j], ks[i] + } + } + } + return ks +} + +func sheetTitle(path string) string { + n := strings.TrimSuffix(strings.TrimPrefix(path, "xl/worksheets/"), ".xml") + return n +} + +// rowsToMarkdown 把二維字串轉成 Markdown 表格。 +// 第一列當標題(企業表格慣例);只有一列時就不畫分隔線。 +func rowsToMarkdown(rows [][]string, truncated bool) string { + if len(rows) == 0 { + return "" + } + width := 0 + for _, r := range rows { + if len(r) > width { + width = len(r) + } + } + var b strings.Builder + for i, r := range rows { + b.WriteString("|") + for c := 0; c < width; c++ { + cell := "" + if c < len(r) { + cell = clampCell(r[c]) + } + b.WriteString(" " + cell + " |") + } + b.WriteString("\n") + if i == 0 && len(rows) > 1 { + b.WriteString("|") + for c := 0; c < width; c++ { + b.WriteString(" --- |") + } + b.WriteString("\n") + } + } + if truncated { + // 明說截斷——靜默丟資料是本專案一再犯的病 + b.WriteString(fmt.Sprintf("\n(表格過大,只取前 %d 列)\n", maxTableRows)) + } + return b.String() +} + +// clampCell 清掉會破壞 Markdown 表格的字元,並限制單格長度。 +func clampCell(s string) string { + s = strings.ReplaceAll(s, "|", "\\|") // 跳脫,否則欄位會錯位 + s = strings.ReplaceAll(s, "\n", " ") + s = strings.ReplaceAll(s, "\r", " ") + s = strings.TrimSpace(s) + rs := []rune(s) + if len(rs) > maxCellRunes { + return string(rs[:maxCellRunes]) + "…(截斷)" + } + return s +} diff --git a/convert_table_test.go b/convert_table_test.go new file mode 100644 index 0000000..c30a3be --- /dev/null +++ b/convert_table_test.go @@ -0,0 +1,125 @@ +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) + } +} diff --git a/scan.go b/scan.go index 9be8ba2..3d30e00 100644 --- a/scan.go +++ b/scan.go @@ -17,7 +17,11 @@ import ( "time" ) -// 先只認 .md 與常見文件檔(transform/其他格式是之後的 task)。 +// 收檔白名單。**注意這只是「收不收」,能不能讀由 convert.go 的 extractors 決定**—— +// 兩者要一起看(2026-07-27 t73:`.pdf` 早就在這裡,但 ingest 端擋著=檔案上了 R2 卻進不了 +// 知識庫,使用者看到的是「丟檔進去沒反應」)。 +// +// .csv/.xlsx 於 2026-07-27 加入——leo:「要思考 Excel 和 csv 的問題,**因為企業用很多**」。 var allowedExt = map[string]bool{ ".md": true, ".markdown": true, @@ -25,6 +29,8 @@ var allowedExt = map[string]bool{ ".docx": true, ".pptx": true, ".pdf": true, + ".csv": true, + ".xlsx": true, } // ---- 輸出 payload(對應 schemas/collector-trigger.v1.schema.json)----