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:
+14
-1
@@ -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 回報這個副檔名是否本來就是純文字(不需要轉檔)。
|
||||
|
||||
@@ -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 {
|
||||
// <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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)----
|
||||
|
||||
Reference in New Issue
Block a user