Files
arcrun-collector/inventory_test.go
T
Leo f233eec9c2 feat(collector): 結構先行——掃描完幾秒內就能問「資料夾裡有什麼」,不等 LLM 不等額度(InkStoneCo#43)
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>
2026-08-15 16:39:45 +08:00

245 lines
8.9 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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/流程.md2026-08-11",
"- 報銷規則.md2026-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 應為 1got %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("內容變了應立即重試(新卡不受舊卡退避拖累)")
}
}