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>
This commit is contained in:
+298
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user