f233eec9c2
leo 08-15 量測:266 檔真正算的 ~20 分鐘/<$0.5,使用者卻等 ~4 天(多在等 Workers AI 每日額度);而「有哪些檔案/最近改了什麼」本地掃一遍就有答案 (1,335 檔實測 0.254s),卻因 part_of 只在雲端 parse_card(萃取之後)生成 而排同一條隊、撞同一面額度牆。 - collector/inventory.go:Scan() 後、萃取迴圈前(額度冷卻閘之外),把 manifest 現況做成機械總覽卡(檔案清單/最近改動/目錄 part_of)POST 既有 rag_ingest_card ——零 LLM、零新 workflow、既有實例直接受益;自帶同頁名 upsert 防重複 - 冪等:內容 sha256 記 manifest.inventory_hash;失敗 10 分鐘退避(防 t195 同款 每 5 秒重撞);額度失敗訊息人話化,不裸露 4006/502 - 清單 ≤200 逐檔、關聯 ≤10 條:守 Workers 免費層 50 subrequests 天花板 - 不寫本機檔案、不動使用者原稿 實測(youlin stage yuga3bse,刻意無金鑰=零萃取):總覽卡 200 送達、rag_chat 正確回答「有哪些檔案/最近改了什麼」;重掃不重複(blocks 恆 5、triplets 恆 2); go test collector+supervisor+arcrun-app 全綠(新增 inventory_test.go 6 案)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
245 lines
8.9 KiB
Go
245 lines
8.9 KiB
Go
// inventory_test.go — 結構先行(InkStoneCo#43):資料夾總覽卡的單元與整合測試。
|
||
package collector
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// splitInventory 把 results 拆成(總覽卡, 其餘)。既有測試斷言「幾個檔案事件」時
|
||
// 用 rest;要驗結構先行本身時用 inv。
|
||
func splitInventory(results []DirectResult) (inv, rest []DirectResult) {
|
||
for _, r := range results {
|
||
if r.Type == "inventory" {
|
||
inv = append(inv, r)
|
||
} else {
|
||
rest = append(rest, r)
|
||
}
|
||
}
|
||
return inv, rest
|
||
}
|
||
|
||
func invEntries(paths map[string]int64) map[string]*ManifestEntry {
|
||
m := map[string]*ManifestEntry{}
|
||
for p, mt := range paths {
|
||
m[p] = &ManifestEntry{ContentHash: "sha256:x", Size: 1, Mtime: mt}
|
||
}
|
||
return m
|
||
}
|
||
|
||
// 基本結構:四段齊全、逐檔點名、格式統計、目錄 part_of 三元組。
|
||
func TestBuildInventoryCard_基本結構(t *testing.T) {
|
||
day := time.Date(2026, 8, 10, 12, 0, 0, 0, time.Local)
|
||
entries := invEntries(map[string]int64{
|
||
"報銷規則.md": day.Unix(),
|
||
"docs/流程.md": day.Add(24 * time.Hour).Unix(),
|
||
"docs/簡報.pptx": day.Add(48 * time.Hour).Unix(),
|
||
})
|
||
page, card := BuildInventoryCard("/Users/x/我的筆記", entries, "kb")
|
||
if page != "資料夾總覽:我的筆記" {
|
||
t.Fatalf("page=%q", page)
|
||
}
|
||
for _, want := range []string{
|
||
"# 資料夾總覽:我的筆記",
|
||
"## 一句話定義",
|
||
"這個資料夾目前有 3 份文件",
|
||
"md 2 份",
|
||
"pptx 1 份",
|
||
"## 最近改了什麼",
|
||
"- 2026-08-12 docs/簡報.pptx", // 最新在最上面
|
||
"## 檔案清單",
|
||
"- docs/流程.md(2026-08-11)",
|
||
"- 報銷規則.md(2026-08-10)",
|
||
"## 關聯",
|
||
"- docs " + strings.Repeat(">", 2) + " part_of " + strings.Repeat(">", 2) + " 資料夾總覽:我的筆記",
|
||
} {
|
||
if !strings.Contains(card, want) {
|
||
t.Errorf("卡片缺 %q\n----\n%s", want, card)
|
||
}
|
||
}
|
||
// 最近改動的排序:簡報(08-12)要出現在 流程(08-11)之前
|
||
if strings.Index(card, "docs/簡報.pptx") > strings.Index(card, "docs/流程.md") {
|
||
t.Error("「最近改了什麼」未按 mtime 新到舊排序")
|
||
}
|
||
}
|
||
|
||
// 確定性:同一份輸入永遠同一份輸出(冪等雜湊的前提)。
|
||
func TestBuildInventoryCard_確定性(t *testing.T) {
|
||
entries := invEntries(map[string]int64{"a.md": 100, "b/c.md": 200, "b/d.pdf": 200})
|
||
_, c1 := BuildInventoryCard("/x/root", entries, "kb")
|
||
for i := 0; i < 10; i++ {
|
||
_, c2 := BuildInventoryCard("/x/root", entries, "kb")
|
||
if c1 != c2 {
|
||
t.Fatal("同一份輸入產出不同卡片(map 迭代順序洩漏)")
|
||
}
|
||
}
|
||
}
|
||
|
||
// 巨量資料夾:不逐檔點名(改列各目錄份數),關聯不超上限——守雲端 subrequest 天花板。
|
||
func TestBuildInventoryCard_巨量只列摘要(t *testing.T) {
|
||
entries := map[string]*ManifestEntry{}
|
||
for d := 0; d < 40; d++ {
|
||
for f := 0; f < 10; f++ {
|
||
p := fmt.Sprintf("dir%02d/f%02d.md", d, f)
|
||
entries[p] = &ManifestEntry{Mtime: int64(1000 + d + f)}
|
||
}
|
||
}
|
||
_, card := BuildInventoryCard("/x/big", entries, "kb")
|
||
if !strings.Contains(card, "共 400 份,檔案較多") {
|
||
t.Error("巨量模式應改列摘要")
|
||
}
|
||
if strings.Count(card, "- dir")-strings.Count(card, "- dir00 ") > maxInventoryDirLines+maxInventoryRels {
|
||
t.Error("目錄行數超出上限")
|
||
}
|
||
sep := " " + strings.Repeat(">", 2) + " "
|
||
if n := strings.Count(card, sep+"part_of"+sep); n > maxInventoryRels {
|
||
t.Errorf("關聯 %d 條超過上限 %d(會撞雲端 subrequest 上限)", n, maxInventoryRels)
|
||
}
|
||
if !strings.Contains(card, "其餘") {
|
||
t.Error("被截斷的目錄應誠實說有其餘幾個(不安靜略過)")
|
||
}
|
||
}
|
||
|
||
// 整合:總覽卡必須先於任何萃取/收卡 POST 送達(結構不跟 LLM 排同一條隊),
|
||
// 且第二輪(無變動)不重送=冪等。
|
||
func TestDirectInventory_先於萃取且冪等(t *testing.T) {
|
||
root := t.TempDir()
|
||
if err := os.WriteFile(filepath.Join(root, "規則.md"), []byte("# 內容"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var order []string // 按抵達順序記 page_name
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
body, _ := io.ReadAll(r.Body)
|
||
var m map[string]any
|
||
_ = json.Unmarshal(body, &m)
|
||
pn, _ := m["page_name"].(string)
|
||
order = append(order, pn)
|
||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||
}))
|
||
defer srv.Close()
|
||
defer gemmaCardStub(t, cardFixture("規則", "kb"))()
|
||
|
||
cfg := &DirectConfig{
|
||
WatchFolders: []string{root},
|
||
Manifest: filepath.Join(t.TempDir(), "m.json"),
|
||
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
|
||
Library: "kb", Extractor: "gemma", ExtractorExplicit: true, GeminiAPIKey: "k-test",
|
||
CardIngestWF: "rag_ingest_card", MaxRemoved: DefaultMaxRemovedRatio,
|
||
}
|
||
results, exit, _ := RunDirectOnce(cfg, false)
|
||
if exit != 0 {
|
||
t.Fatalf("exit=%d results=%+v", exit, results)
|
||
}
|
||
if len(order) != 2 {
|
||
t.Fatalf("應恰好兩個 POST(總覽卡+內容卡),got %v", order)
|
||
}
|
||
if !strings.HasPrefix(order[0], "資料夾總覽:") {
|
||
t.Fatalf("總覽卡必須先送(結構先行),實際順序:%v", order)
|
||
}
|
||
if order[1] != "規則" {
|
||
t.Fatalf("內容卡未送達:%v", order)
|
||
}
|
||
inv, _ := splitInventory(results)
|
||
if len(inv) != 1 || inv[0].Status != "ingested" {
|
||
t.Fatalf("inv=%+v", inv)
|
||
}
|
||
// 第二輪:什麼都沒變 → 總覽不重送、也沒有檔案事件
|
||
results2, _, _ := RunDirectOnce(cfg, false)
|
||
if len(results2) != 0 || len(order) != 2 {
|
||
t.Fatalf("無變動輪不該重送:results2=%+v order=%v", results2, order)
|
||
}
|
||
// 新增一個檔 → 總覽跟著更新(清單多一筆)+新檔照常萃取。
|
||
// (附帶驗證:同一天內改同一個檔,總覽內容在日粒度下不變=不重送,是冪等的正確行為)
|
||
if err := os.WriteFile(filepath.Join(root, "新頁.md"), []byte("# 新內容"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, exit3, _ := RunDirectOnce(cfg, false); exit3 != 0 {
|
||
t.Fatal("第三輪失敗")
|
||
}
|
||
if len(order) != 4 || !strings.HasPrefix(order[2], "資料夾總覽:") {
|
||
t.Fatalf("變動後總覽應更新且仍先送:%v", order)
|
||
}
|
||
}
|
||
|
||
// 整合:萃取整條路壞掉(連 Gemini 都連不上)時,總覽卡照樣送達——
|
||
// 這正是「不必等 LLM、不必等額度」要保的那條路。
|
||
func TestDirectInventory_萃取壞掉照樣送達(t *testing.T) {
|
||
root := t.TempDir()
|
||
if err := os.WriteFile(filepath.Join(root, "a.md"), []byte("x"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var invPosted int
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
body, _ := io.ReadAll(r.Body)
|
||
if strings.Contains(string(body), "資料夾總覽") {
|
||
invPosted++
|
||
}
|
||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||
}))
|
||
defer srv.Close()
|
||
// Gemini 替身回 500=萃取全滅
|
||
defer gemmaStub(t, func(w http.ResponseWriter, r *http.Request) {
|
||
w.WriteHeader(http.StatusInternalServerError)
|
||
_, _ = w.Write([]byte(`{"error":"boom"}`))
|
||
})()
|
||
cfg := &DirectConfig{
|
||
WatchFolders: []string{root},
|
||
Manifest: filepath.Join(t.TempDir(), "m.json"),
|
||
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
|
||
Extractor: "gemma", ExtractorExplicit: true, GeminiAPIKey: "k-test",
|
||
MaxRemoved: DefaultMaxRemovedRatio,
|
||
}
|
||
results, exit, _ := RunDirectOnce(cfg, false)
|
||
if exit != 1 {
|
||
t.Fatalf("萃取失敗 exit 應為 1,got %d", exit)
|
||
}
|
||
if invPosted != 1 {
|
||
t.Fatalf("萃取壞掉時總覽卡應照樣送達,got %d", invPosted)
|
||
}
|
||
inv, rest := splitInventory(results)
|
||
if len(inv) != 1 || inv[0].Status != "ingested" {
|
||
t.Fatalf("inv=%+v", inv)
|
||
}
|
||
if len(rest) != 1 || rest[0].Status != "failed" {
|
||
t.Fatalf("rest=%+v", rest)
|
||
}
|
||
}
|
||
|
||
// 失敗退避:總覽送失敗後,同一份內容在退避窗口內不重撞(積壓輪每 5 秒都有事件,
|
||
// 沒有這道就是 t195 的 1387 輪重演);內容變了立即可再試。
|
||
func TestSyncInventory_失敗退避(t *testing.T) {
|
||
root := t.TempDir()
|
||
m := &Manifest{Entries: invEntries(map[string]int64{"a.md": 100})}
|
||
cfg := &DirectConfig{CypherURL: "https://x.invalid", Namespace: "demo", APIKey: "demo", Library: "kb"}
|
||
now := time.Date(2026, 8, 15, 10, 0, 0, 0, time.UTC)
|
||
|
||
res := syncInventory(cfg, root, m, true, false, now)
|
||
if res == nil || res.Status != "failed" {
|
||
t.Fatalf("res=%+v", res)
|
||
}
|
||
if m.InventoryFailHash == "" || m.InventoryNextRetry == 0 {
|
||
t.Fatal("失敗應記退避")
|
||
}
|
||
// 同內容、退避窗口內 → 不重試
|
||
if res2 := syncInventory(cfg, root, m, true, false, now.Add(time.Minute)); res2 != nil {
|
||
t.Fatalf("退避窗口內不該重試:%+v", res2)
|
||
}
|
||
// 退避窗口過了 → 重試
|
||
if res3 := syncInventory(cfg, root, m, true, false, now.Add(inventoryRetryDelay+time.Minute)); res3 == nil {
|
||
t.Fatal("退避過期後應重試")
|
||
}
|
||
// 內容變了 → 立即可再試
|
||
m.Entries["b.md"] = &ManifestEntry{Mtime: 200}
|
||
if res4 := syncInventory(cfg, root, m, true, false, now.Add(time.Minute)); res4 == nil {
|
||
t.Fatal("內容變了應立即重試(新卡不受舊卡退避拖累)")
|
||
}
|
||
}
|