diff --git a/direct.go b/direct.go index 0caefe8..ef45279 100644 --- a/direct.go +++ b/direct.go @@ -1040,6 +1040,22 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta } } + // 結構先行(InkStoneCo#43,2026-08-15):掃描一結束(純本機、免費、秒級)就先把 + // 「這個資料夾有哪些檔案/最近改了什麼」送上知識庫,**不等 LLM 萃取、不受額度影響** + // ——走 rag_ingest_card(零 LLM 的機械收口),所以刻意放在: + // ① 逐檔萃取迴圈之前——萃取可能要跑幾小時(積壓)甚至幾天(等額度), + // 結構性問題不該跟它排同一條隊; + // ② qs.inCooldown 的閘之外——額度撞牆期間這正是使用者唯一還能問的東西; + // ③ 「removed 暫時放回」之前——總覽反映檔案系統**現況**,剛刪掉的檔 + // 不該還列在清單上(放回只是下架重試的記帳,不是現況)。 + // 冪等與失敗處理見 syncInventory 註解;失敗不設 exit(加值層壞了不擋檔案同步)。 + if invRes := syncInventory(cfg, absRoot, m, len(payload.Events) > 0, dryRun, runNow); invRes != nil { + results = append(results, *invRes) + if invRes.Status != "planned" { + saveManifest() // 記住 inventory_hash/失敗退避(斷點續傳同款:當下就落盤) + } + } + // 承上:把本輪偵測到的 removed 路徑暫時放回 m.Entries,直到迴圈裡真的處理到它、 // POST 成功才由「removed」分支明確刪除。失敗或本輪還沒輪到(單輪上限)都維持放回的狀態, // 下一輪自然重新偵測、重新嘗試下架——不會因為別的事件先存檔而被誤永久跳過。 diff --git a/direct_extract_test.go b/direct_extract_test.go index de6d3a5..45ea77b 100644 --- a/direct_extract_test.go +++ b/direct_extract_test.go @@ -84,37 +84,53 @@ func TestDirectExtractorModeE2E(t *testing.T) { if exit != 0 { t.Fatalf("exit=%d results=%+v", exit, results) } - if len(results) != 1 || results[0].Status != "ingested" { - t.Fatalf("results=%+v", results) + // 結構先行(InkStoneCo#43):每輪多一張機械總覽卡(零 LLM),檔案事件另計 + inv, fileResults := splitInventory(results) + if len(inv) != 1 || inv[0].Status != "ingested" { + t.Fatalf("總覽卡應送達:%+v", inv) + } + if len(fileResults) != 1 || fileResults[0].Status != "ingested" { + t.Fatalf("results=%+v", fileResults) } // 卡片落地本地(用戶看得到自己的 wiki) if _, err := os.Stat(filepath.Join(root, "system-dev", "wiki", "cards", "arcrun-報銷規則.md")); err != nil { t.Fatalf("卡片未落地:%v", err) } - // 上雲的是卡片、不是原文 - if len(posted) != 1 { - t.Fatalf("應恰好 POST 一張卡,got %d", len(posted)) + // 上雲的是「總覽卡+內容卡」兩張,都不含原文 + if len(posted) != 2 { + t.Fatalf("應恰好 POST 兩張卡(總覽+內容),got %d", len(posted)) } - cc, _ := posted[0]["card_content"].(string) + for _, p := range posted { + if cc, _ := p["card_content"].(string); strings.Contains(cc, "XYZZY") { + t.Fatal("原文內容洩上雲=違反四步定稿邊界") + } + } + var contentCard map[string]any + for _, p := range posted { + if pn, _ := p["page_name"].(string); !strings.HasPrefix(pn, "資料夾總覽") { + contentCard = p + } + } + if contentCard == nil { + t.Fatal("找不到內容卡") + } + cc, _ := contentCard["card_content"].(string) if !strings.Contains(cc, "## 一句話定義") { t.Fatalf("card_content 不是卡片:%.80s", cc) } - if strings.Contains(cc, "XYZZY") { - t.Fatal("原文內容洩上雲=違反四步定稿邊界") - } // path 必須是「原檔路徑」(takedown 比對鍵+B4 溯源)——不是卡片路徑(07-24 第五枚坑) - if p, _ := posted[0]["path"].(string); p != "報銷規則.md" { + if p, _ := contentCard["path"].(string); p != "報銷規則.md" { t.Fatalf("path=%q(應為原檔路徑)", p) } // 🔴 arcrun-rag#60 第二輪:本機卡片檔名加了 arcrun- 前綴,但**上雲的 page_name 不准跟著變**。 // 下架分支用的是原稿頁名(見下一支測試斷言 takedown page_name=="報銷規則"), // 這裡若跟著卡片檔名變成 "arcrun-報銷規則",兩邊就永遠對不上、刪原檔再也下架不掉。 - if pn, _ := posted[0]["page_name"].(string); pn != "報銷規則" { + if pn, _ := contentCard["page_name"].(string); pn != "報銷規則" { t.Fatalf("page_name=%q(應為原稿頁名,不含 arcrun- 前綴,否則下架對不上)", pn) } - // 第二輪:原稿沒變 → 不重萃不重送 + // 第二輪:原稿沒變 → 不重萃不重送(總覽卡雜湊相同也不重送) results2, exit2, _ := RunDirectOnce(cfg, false) - if exit2 != 0 || len(results2) != 0 || len(posted) != 1 { + if exit2 != 0 || len(results2) != 0 || len(posted) != 2 { t.Fatalf("第二輪應零事件:results=%+v posted=%d", results2, len(posted)) } } @@ -170,8 +186,10 @@ func TestDirectExtractorRemovedClearsLocalCard(t *testing.T) { if exit != 0 { t.Fatalf("第二輪 exit=%d results=%+v", exit, results) } - if len(results) != 1 || results[0].Status != "removed" { - t.Fatalf("results=%+v", results) + // 結構先行:刪檔輪總覽卡也會更新(清單不該還列著剛刪的檔),檔案事件另計 + _, fileResults := splitInventory(results) + if len(fileResults) != 1 || fileResults[0].Status != "removed" { + t.Fatalf("results=%+v", fileResults) } if len(takedowns) != 1 { t.Fatalf("應恰好一次 takedown,got %d", len(takedowns)) @@ -214,7 +232,8 @@ func TestDirectExtractorRemovedNoLocalCardOK(t *testing.T) { t.Fatal(err) } results, exit, _ := RunDirectOnce(cfg, false) - if exit != 0 || len(results) != 1 || results[0].Status != "removed" { + _, fileResults := splitInventory(results) // 結構先行:總覽卡另計 + if exit != 0 || len(fileResults) != 1 || fileResults[0].Status != "removed" { t.Fatalf("exit=%d results=%+v", exit, results) } } @@ -237,13 +256,15 @@ func TestDirectExtractorFailKeepsRetry(t *testing.T) { Extractor: "gemma", ExtractorExplicit: true, GeminiAPIKey: "k-test", MaxRemoved: DefaultMaxRemovedRatio, } results, exit, _ := RunDirectOnce(cfg, false) - if exit != 1 || len(results) != 1 || results[0].Status != "failed" { + _, fileResults := splitInventory(results) // 結構先行:總覽卡另計(此處 cypher 不通,總覽也 failed) + if exit != 1 || len(fileResults) != 1 || fileResults[0].Status != "failed" { t.Fatalf("exit=%d results=%+v", exit, results) } - // 再跑一輪:仍是同一個事件(manifest 沒標 ingested=會重試) + // 再跑一輪:仍是同一個事件(manifest 沒標 ingested=會重試); + // 總覽卡則在自己的失敗退避窗口內,不重撞 results2, _, _ := RunDirectOnce(cfg, false) - if len(results2) != 1 { - t.Fatalf("失敗檔應重試:%+v", results2) + if inv2, fileResults2 := splitInventory(results2); len(fileResults2) != 1 || len(inv2) != 0 { + t.Fatalf("失敗檔應重試、總覽應退避:%+v", results2) } } @@ -314,9 +335,18 @@ func TestExtractorEmptyBlocksNonTextDirect(t *testing.T) { t.Fatal(err) } - var serverCalled bool + // 結構先行後,總覽卡(只含檔名、零原文)照常會 POST 到 rag_ingest_card—— + // 本測試守的契約是「PDF 位元組/原文不出機」,改成逐請求驗內容與端點。 + var ingestDirectCalled bool + var leaked bool srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - serverCalled = true + body, _ := io.ReadAll(r.Body) + if strings.HasSuffix(r.URL.Path, "/rag_ingest_direct/trigger") { + ingestDirectCalled = true + } + if strings.Contains(string(body), "%PDF") || strings.Contains(string(body), "機密原文") { + leaked = true + } _ = json.NewEncoder(w).Encode(map[string]any{"success": true}) })) defer srv.Close() @@ -337,16 +367,20 @@ func TestExtractorEmptyBlocksNonTextDirect(t *testing.T) { if exit != 1 { t.Fatalf("exit=%d,應是 1(非文字檔無萃取器=失敗)", exit) } - if serverCalled { - t.Error("防禦閘失效:PDF 被直送上雲(契約破壞)") + if ingestDirectCalled { + t.Error("防禦閘失效:走了舊制直送端點(契約破壞)") } - if len(results) != 1 || results[0].Status != "failed" { - t.Fatalf("results=%+v", results) + if leaked { + t.Error("防禦閘失效:PDF 位元組/原文被送上雲(契約破壞)") + } + _, fileResults := splitInventory(results) // 結構先行:總覽卡(只含檔名)另計 + if len(fileResults) != 1 || fileResults[0].Status != "failed" { + t.Fatalf("results=%+v", fileResults) } // t182:這裡是「選了 Gemini 卻沒有金鑰」⇒ 停在萃取層、誠實報缺什麼。 - // 本測真正要守的契約沒變、也仍然綠:**PDF 不得被直送上雲**(上面的 serverCalled)。 - if !strings.Contains(results[0].Error, "gemini_api_key") { - t.Errorf("錯誤訊息不符:%q", results[0].Error) + // 本測真正要守的契約沒變、也仍然綠:**PDF 不得被直送上雲**(上面的 leaked)。 + if !strings.Contains(fileResults[0].Error, "gemini_api_key") { + t.Errorf("錯誤訊息不符:%q", fileResults[0].Error) } } diff --git a/direct_multi_test.go b/direct_multi_test.go index dadc7e7..8d01d6d 100644 --- a/direct_multi_test.go +++ b/direct_multi_test.go @@ -133,6 +133,9 @@ func TestRunDirectOnceMultiRootDryRun(t *testing.T) { if r.Status != "planned" { t.Fatalf("dry-run 事件應為 planned:%+v", r) } + if r.Type == "inventory" { + continue // 結構先行:每根各有一張總覽卡(planned),檔案事件另計 + } byRoot[r.Root] = append(byRoot[r.Root], r.Path) } if len(byRoot[rootA]) != 1 || byRoot[rootA][0] != "a.md" { diff --git a/direct_pacing_test.go b/direct_pacing_test.go index 224e0f7..97566a3 100644 --- a/direct_pacing_test.go +++ b/direct_pacing_test.go @@ -182,18 +182,24 @@ func TestDirect_LargeBacklog_ProcessedInNewestFirstBatches(t *testing.T) { t.Error("全部處理完不該再有延後交代") } - // 全部 8 個都真的送到雲端了(沒有一個被永久漏掉) + // 全部 8 個都真的送到雲端了(沒有一個被永久漏掉);總覽卡(結構先行)另計 mu.Lock() defer mu.Unlock() - if len(order) != 8 { - t.Fatalf("雲端總共應收到 8 次卡片,got %d: %v", len(order), order) + var contentCards []string + for _, pn := range order { + if !strings.HasPrefix(pn, "資料夾總覽") { + contentCards = append(contentCards, pn) + } + } + if len(contentCards) != 8 { + t.Fatalf("雲端總共應收到 8 次卡片,got %d: %v", len(contentCards), order) } } func ingestedPaths(results []DirectResult) []string { var out []string for _, r := range results { - if r.Status == "ingested" { + if r.Status == "ingested" && r.Type != "inventory" { // 結構先行:總覽卡另計 out = append(out, r.Path) } } @@ -228,7 +234,9 @@ func TestDirect_ResumeAfterInterruption(t *testing.T) { var m map[string]any _ = json.Unmarshal(body, &m) mu.Lock() - posted = append(posted, m["path"].(string)) + if p, _ := m["path"].(string); !strings.HasPrefix(p, ".arcrun-rag/") { + posted = append(posted, p) // 結構先行:總覽卡(合成路徑)另計 + } mu.Unlock() _ = json.NewEncoder(w).Encode(map[string]any{"success": true}) })) diff --git a/direct_quota_test.go b/direct_quota_test.go index c2d50b6..eb9215a 100644 --- a/direct_quota_test.go +++ b/direct_quota_test.go @@ -58,8 +58,18 @@ func TestDirect_QuotaExhausted_ShowsHumanMessageNoRawCode(t *testing.T) { if exit != 1 { t.Fatalf("兩份都該萃取失敗,exit 應為 1,got %d", exit) } - if len(results) != 2 { - t.Fatalf("results=%+v", results) + // 結構先行:本測試的假伺服器對**所有**端點回額度 502,總覽卡也會失敗—— + // 它的錯誤同樣不准裸露錯誤碼(下方 banned 檢查涵蓋全部 results), + // 但三句話(成就/出口)是萃取路 quotaState 的契約,總覽卡只講自己的狀態。 + inv, fileResults := splitInventory(results) + if len(inv) != 1 || inv[0].Status != "failed" { + t.Fatalf("inv=%+v", inv) + } + if !strings.Contains(inv[0].Error, "額度") || !strings.Contains(inv[0].Error, "不影響檔案同步") { + t.Errorf("總覽卡的額度失敗要說人話:%q", inv[0].Error) + } + if len(fileResults) != 2 { + t.Fatalf("results=%+v", fileResults) } banned := []string{"4006", "502", "HTTP", "neurons"} @@ -69,6 +79,8 @@ func TestDirect_QuotaExhausted_ShowsHumanMessageNoRawCode(t *testing.T) { t.Errorf("結果不該出現裸露的錯誤碼 %q:%q", b, r.Error) } } + } + for _, r := range fileResults { // arcrun-rag#59:這支測試裡兩份檔案全部失敗、dailyCount 全程是 0—— // 正是 leo 實查那個「一份都沒成功、額度卻用完」的情境,buildQuotaNotice // 改口不再承諾「自動恢復」(那是做不到的承諾)、也不再建議換模型 diff --git a/inventory.go b/inventory.go new file mode 100644 index 0000000..28bcb86 --- /dev/null +++ b/inventory.go @@ -0,0 +1,298 @@ +// inventory.go — 結構先行:掃描一結束就把「這個資料夾有哪些檔案/最近改了什麼」 +// 做成**機械總覽卡**送上知識庫(InkStoneCo#43,2026-08-15)。 +// +// 為什麼要有這個(leo 2026-08-15 量測):266 個可萃檔真正花在算的只有 ~20 分鐘、 +// 成本 < $0.5,但使用者實際等了 ~4 天——多數時間在等 Workers AI 每日額度隔天恢復。 +// 而「這裡面有哪些檔案/最近改了什麼」這種**本地掃一遍就有答案、免費、秒級**的問題 +// (實測 1,335 檔含逐檔 SHA256 只要 0.254 秒),原本跟「這份文件在講什麼」排同一條隊、 +// 撞同一面額度牆——因為 part_of 目錄邊只在雲端 parse_card(LLM 萃取之後)才會長出來。 +// +// 設計取捨(為什麼走 rag_ingest_card、不開新 workflow): +// - 每個用戶實例**本來就裝著** rag_ingest_card(compile-workflows.mjs 的四條之一), +// 走它=今天所有既有實例直接受益,不需要任何雲端部署/升級。 +// - 它自帶同頁名 upsert(pick_stale 先刪舊再寫)=重送不堆副本,冪等免費拿到。 +// - 它零 LLM(機械切塊+寫 KBDB)=不吃 neurons、額度冷卻期間照常可用。 +// - 卡片的「## 關聯」段天生會被 parse 成 triplets ⇒ 目錄結構直接長進圖譜, +// rag-chat 的 fetch_triplets/kw_search 都查得到,檢索端零改動。 +// +// 紅線對齊:本檔**不寫任何本機檔案**(總覽卡只存在於雲端),不動使用者原稿; +// 資料一律走 webhook API(KBDB API-as-Wall,D38)。 +package collector + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "path" + "sort" + "strings" + "time" +) + +const ( + // maxInventoryListed:「## 檔案清單」逐檔點名的上限。超過就改列各目錄份數—— + // 與 scan.go maxOtherNames 同一個判準:少量點名有資訊量,巨量點名是噪音; + // 也守住 rag_ingest_card 端的量(block 過大/foreach 過多都會踩雲端上限, + // 見 wiki mistakes「http_request 零件抓大回應直接爆」)。 + maxInventoryListed = 200 + // maxInventoryRecent:「## 最近改了什麼」條數上限。 + maxInventoryRecent = 20 + // maxInventoryRels:「## 關聯」(目錄 part_of)條數上限。刻意壓低: + // rag_ingest_card 對每條 rel 各發一個 POST(foreach),加上 upsert 前置的 + // 逐筆 DELETE,rel 太多會撞 Workers 免費層 50 subrequests/請求的天花板。 + maxInventoryRels = 10 + // maxInventoryDirLines:巨量模式下「各目錄份數」最多列幾行。 + maxInventoryDirLines = 30 +) + +// inventoryPageName 回傳總覽卡的頁名。含資料夾 basename=人看得懂「這是哪個資料夾」; +// 不同監看根的 basename 相同時靠 library(每根唯一)分頁,見 inventoryCardPath。 +func inventoryPageName(absRoot string) string { + base := path.Base(strings.ReplaceAll(strings.TrimRight(absRoot, "/\\"), "\\", "/")) + if base == "" || base == "." || base == "/" { + base = "知識資料夾" + } + return "資料夾總覽:" + base +} + +// inventoryCardPath 回傳總覽卡的合成路徑(雲端 source_uri 用的鍵,**不落地本機**)。 +// 掛在 .arcrun-rag/(機器工作區目錄,machinemark.go 的既有標記慣例)底下=一眼可辨 +// 「這是 Arcrun 產的、不是使用者的檔」;帶 library(每個監看根唯一,librarySlug 有 +// 雜湊後備)=同帳號多資料夾時 source_uri 不互撞。 +func inventoryCardPath(library string) string { + return ".arcrun-rag/wiki/cards/arcrun-資料夾總覽-" + library + ".md" +} + +// invFile 是總覽卡的最小輸入(從 manifest entries 抽出,便於排序與測試)。 +type invFile struct { + path string + mtime int64 +} + +// invDateOf 把 mtime 格式化成使用者看的日期(本機時區;使用者要的是「哪天改的」量級)。 +func invDateOf(mtime int64) string { + if mtime <= 0 { + return "(日期不明)" + } + return time.Unix(mtime, 0).Local().Format("2006-01-02") +} + +// invTopDirOf 回傳相對路徑的第一層目錄;根層檔案回「(根目錄)」。 +func invTopDirOf(rel string) string { + rel = strings.ReplaceAll(rel, "\\", "/") + if i := strings.IndexByte(rel, '/'); i > 0 { + return rel[:i] + } + return "(根目錄)" +} + +// BuildInventoryCard 把 manifest entries(Scan 後=檔案系統現況)做成一張機械總覽卡。 +// 純函式、零 IO、輸出確定(同一份輸入永遠同一份輸出)——冪等判斷(內容雜湊)與 +// 測試都靠這一點。回傳(頁名, 卡片 markdown)。 +func BuildInventoryCard(absRoot string, entries map[string]*ManifestEntry, library string) (string, string) { + page := inventoryPageName(absRoot) + + files := make([]invFile, 0, len(entries)) + for p, e := range entries { + if e == nil { + continue + } + files = append(files, invFile{path: strings.ReplaceAll(p, "\\", "/"), mtime: e.Mtime}) + } + sort.Slice(files, func(i, j int) bool { return files[i].path < files[j].path }) + + var b strings.Builder + sep := " " + strings.Repeat(">", 2) + " " // 三元組分隔符(組字串產生,同 lint_test 慣例) + + b.WriteString("# " + page + "\n\n") + + // ── 一句話定義:總數+格式分佈 ── + extCount := map[string]int{} + for _, f := range files { + ext := strings.ToLower(path.Ext(f.path)) + if ext == "" { + ext = "(無副檔名)" + } + extCount[ext]++ + } + exts := make([]string, 0, len(extCount)) + for e := range extCount { + exts = append(exts, e) + } + sort.Slice(exts, func(i, j int) bool { + if extCount[exts[i]] != extCount[exts[j]] { + return extCount[exts[i]] > extCount[exts[j]] + } + return exts[i] < exts[j] + }) + extParts := []string{} + for i, e := range exts { + if i >= 5 { + rest := 0 + for _, e2 := range exts[i:] { + rest += extCount[e2] + } + extParts = append(extParts, fmt.Sprintf("其他 %d 份", rest)) + break + } + extParts = append(extParts, fmt.Sprintf("%s %d 份", strings.TrimPrefix(e, "."), extCount[e])) + } + b.WriteString("## 一句話定義\n") + if len(files) == 0 { + b.WriteString("這個資料夾目前沒有可收錄的文件(本頁由掃描自動整理,隨檔案變動即時更新,不經 AI 萃取)。\n\n") + } else { + b.WriteString(fmt.Sprintf("這個資料夾目前有 %d 份文件(%s),本頁由掃描自動整理,隨檔案變動即時更新,不經 AI 萃取。\n\n", + len(files), strings.Join(extParts, "、"))) + } + + // ── 最近改了什麼:mtime 新到舊,路徑字母序破平 ── + recent := make([]invFile, len(files)) + copy(recent, files) + sort.Slice(recent, func(i, j int) bool { + if recent[i].mtime != recent[j].mtime { + return recent[i].mtime > recent[j].mtime + } + return recent[i].path < recent[j].path + }) + if n := len(recent); n > maxInventoryRecent { + recent = recent[:maxInventoryRecent] + } + b.WriteString("## 最近改了什麼\n") + if len(recent) == 0 { + b.WriteString("(目前沒有檔案)\n") + } + for _, f := range recent { + b.WriteString("- " + invDateOf(f.mtime) + " " + f.path + "\n") + } + b.WriteString("\n") + + // ── 檔案清單:少量逐檔點名(含日期),巨量改列各目錄份數+每目錄最新一檔 ── + dirCount := map[string]int{} + dirNewest := map[string]invFile{} + for _, f := range files { + d := invTopDirOf(f.path) + dirCount[d]++ + if cur, ok := dirNewest[d]; !ok || f.mtime > cur.mtime || (f.mtime == cur.mtime && f.path < cur.path) { + dirNewest[d] = f + } + } + dirs := make([]string, 0, len(dirCount)) + for d := range dirCount { + dirs = append(dirs, d) + } + sort.Slice(dirs, func(i, j int) bool { + if dirCount[dirs[i]] != dirCount[dirs[j]] { + return dirCount[dirs[i]] > dirCount[dirs[j]] + } + return dirs[i] < dirs[j] + }) + + b.WriteString("## 檔案清單\n") + switch { + case len(files) == 0: + b.WriteString("(目前沒有檔案)\n") + case len(files) <= maxInventoryListed: + for _, f := range files { + b.WriteString("- " + f.path + "(" + invDateOf(f.mtime) + ")\n") + } + default: + b.WriteString(fmt.Sprintf("共 %d 份,檔案較多,以下列各目錄的份數與最新一檔:\n", len(files))) + for i, d := range dirs { + if i >= maxInventoryDirLines { + rest := 0 + for _, d2 := range dirs[i:] { + rest += dirCount[d2] + } + b.WriteString(fmt.Sprintf("- (其餘 %d 個目錄共 %d 份)\n", len(dirs)-i, rest)) + break + } + nf := dirNewest[d] + b.WriteString(fmt.Sprintf("- %s:%d 份(最新:%s,%s)\n", d, dirCount[d], nf.path, invDateOf(nf.mtime))) + } + } + b.WriteString("\n") + + // ── 關聯:目錄 part_of 總覽頁(parse_card 會再自動補「總覽頁 part_of knowledge-base」)── + b.WriteString("## 關聯\n") + relDirs := dirs + if len(relDirs) > maxInventoryRels { + relDirs = relDirs[:maxInventoryRels] + } + for _, d := range relDirs { + if d == "(根目錄)" { + continue + } + b.WriteString("- " + d + sep + "part_of" + sep + page + "\n") + } + + return page, b.String() +} + +// inventoryRetryDelay:總覽卡送失敗後,同一份內容多久以後才准再試。 +// 積壓卡住時每輪都有事件(hasEvents 恆真),沒有這道退避,雲端一壞就是 +// 每 5 秒撞一次(t195 的 1387 輪教訓,同款病)。內容變了(雜湊不同)不受此限。 +const inventoryRetryDelay = 10 * time.Minute + +// syncInventory 在每輪掃描後、逐檔萃取**之前**送總覽卡。回 nil=這輪不必送。 +// +// 冪等/防撞四層:①卡片內容 sha256 記在 manifest.inventory_hash,內容沒變不送; +// ②同一份內容剛送失敗過(inventory_fail_hash)→ 10 分鐘內不重試; +// ③已有成功版本且本輪無事件 → 不送(總覽跟著變動走,不跟著輪詢走); +// ④雲端 rag_ingest_card 自帶同頁名 upsert,就算重送也不堆副本。 +// +// 失敗不擋同步主流程(不設 exit):總覽是加值層,它壞了檔案同步照常走; +// 但誠實回報 failed result,不假綠。額度用完的失敗換成人話(不裸露 4006/502, +// 同 2026-08-07 task 2 的判準),其餘失敗保留原文(遠端查得到真因,08-06 教訓)。 +func syncInventory(cfg *DirectConfig, absRoot string, m *Manifest, hasEvents, dryRun bool, now time.Time) *DirectResult { + lib := cfg.libraryFor(absRoot) + page, card := BuildInventoryCard(absRoot, m.Entries, lib) + if len(m.Entries) == 0 && m.InventoryHash == "" { + return nil // 空資料夾且從沒公告過=沒東西好說 + } + sum := sha256.Sum256([]byte(card)) + h := "sha256:" + hex.EncodeToString(sum[:]) + if h == m.InventoryHash { + return nil // 冪等①:內容沒變(也涵蓋大量刪除防呆輪:entries 保留=雜湊不變) + } + if h == m.InventoryFailHash && now.Unix() < m.InventoryNextRetry { + return nil // 防撞②:同一份內容剛失敗過,退避窗口內不重撞 + } + if !hasEvents && m.InventoryHash != "" { + return nil // ③:已有成功版本的靜止輪不重送 + } + res := &DirectResult{Type: "inventory", Path: inventoryCardPath(lib)} + if dryRun { + res.Status = "planned" + return res + } + pace() // 觸發雲端前一律節流(2026-08-07 pacing 慣例) + wf := cfg.CardIngestWF + if wf == "" { + wf = "rag_ingest_card" + } + status, _, err := cfg.postJSON(cfg.triggerURL(wf), map[string]any{ + "page_name": page, + "path": res.Path, + "card_content": card, + "library": lib, + }) + res.HTTPStatus = status + if err != nil { + res.Status = "failed" + if isQuotaExhausted(err.Error()) { + // 額度訊息不裸露錯誤碼(同 task 2 判準);額度的完整三句話由萃取路的 + // quotaState 負責,這裡只講「這件事」的狀態與下一步。 + res.Error = "雲端今天的額度用完了,資料夾總覽會稍後自動補送(不影響檔案同步)" + } else { + res.Error = "資料夾總覽上傳失敗(不影響檔案同步):" + err.Error() + } + m.InventoryFailHash = h + m.InventoryNextRetry = now.Add(inventoryRetryDelay).Unix() + return res + } + res.Status = "ingested" + m.InventoryHash = h + m.InventoryFailHash = "" + m.InventoryNextRetry = 0 + return res +} diff --git a/inventory_test.go b/inventory_test.go new file mode 100644 index 0000000..bdc70fe --- /dev/null +++ b/inventory_test.go @@ -0,0 +1,244 @@ +// 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("內容變了應立即重試(新卡不受舊卡退避拖累)") + } +} diff --git a/manifest.go b/manifest.go index 1073f86..f0d8b66 100644 --- a/manifest.go +++ b/manifest.go @@ -72,6 +72,15 @@ type Manifest struct { FolderID string `json:"folder_id"` Root string `json:"root"` Entries map[string]*ManifestEntry `json:"entries"` + // InventoryHash=最後一次**成功送達雲端**的資料夾總覽卡內容雜湊(結構先行, + // InkStoneCo#43,見 inventory.go)。與 entries 的跨輪 carry 陷阱無關——這是 + // Manifest 層欄位,Scan() 的 rebuild 只重建 Entries,不會碰它,天然跨輪存活。 + InventoryHash string `json:"inventory_hash,omitempty"` + // InventoryFailHash/InventoryNextRetry=總覽卡上一次送失敗的內容雜湊與下次可重試 + // 時間(unix)。存在理由=t195 同款:積壓卡住時每輪都有事件,沒有這道退避, + // 雲端一壞就是每 5 秒撞一次。內容變了(雜湊不同)視同新卡,立即可再試。 + InventoryFailHash string `json:"inventory_fail_hash,omitempty"` + InventoryNextRetry int64 `json:"inventory_next_retry,omitempty"` } // newUUID 產生 RFC 4122 v4 UUID(純 stdlib)。 diff --git a/template_install_test.go b/template_install_test.go index 21802f6..0e3ec81 100644 --- a/template_install_test.go +++ b/template_install_test.go @@ -76,7 +76,8 @@ func TestScanSkipsTemplateArtifacts(t *testing.T) { if exit != 0 { t.Fatalf("dry-run 失敗:%+v", results) } - if len(results) != 1 || results[0].Path != "我的筆記.md" { + _, fileResults := splitInventory(results) // 結構先行:總覽卡另計 + if len(fileResults) != 1 || fileResults[0].Path != "我的筆記.md" { t.Fatalf("應只掃到用戶檔(template 產物須跳過),got %+v", results) } }