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 字截斷。
This commit is contained in:
2026-07-27 20:50:45 +08:00
parent 09bf454402
commit 12c8098b5f
4 changed files with 467 additions and 2 deletions
+14 -1
View File
@@ -42,10 +42,23 @@ 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/.xlsx → 同 docx 的 ZIP+XML 路數,數十行,體積幾乎不變POC 實測 +0.31MB
// .pptx → 同 docx 的 ZIP+XML 路數,數十行,體積幾乎不變
}
// IsPlainText 回報這個副檔名是否本來就是純文字(不需要轉檔)。
+321
View File
@@ -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 XMLZIP+XML),但多一層轉折:
// 字串不直接寫在儲存格裡,而是集中放在 sharedStrings.xml,儲存格用索引指過去
//t="s" 代表這格的值是 sharedStrings 的索引)。所以要先讀字串表再讀工作表。
type xlsxSST struct {
Items []struct {
// <si> 底下可能是單一 <t>,也可能被拆成多個 <r><t>(同格內有不同格式時)
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
}
+125
View File
@@ -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)
}
}
+7 -1
View File
@@ -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----