Files
arcrun-collector/inventory.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

299 lines
12 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.go — 結構先行:掃描一結束就把「這個資料夾有哪些檔案/最近改了什麼」
// 做成**機械總覽卡**送上知識庫(InkStoneCo#432026-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_cardcompile-workflows.mjs 的四條之一),
// 走它=今天所有既有實例直接受益,不需要任何雲端部署/升級。
// - 它自帶同頁名 upsertpick_stale 先刪舊再寫)=重送不堆副本,冪等免費拿到。
// - 它零 LLM(機械切塊+寫 KBDB)=不吃 neurons、額度冷卻期間照常可用。
// - 卡片的「## 關聯」段天生會被 parse 成 triplets ⇒ 目錄結構直接長進圖譜,
// rag-chat 的 fetch_tripletskw_search 都查得到,檢索端零改動。
//
// 紅線對齊:本檔**不寫任何本機檔案**(總覽卡只存在於雲端),不動使用者原稿;
// 資料一律走 webhook APIKBDB API-as-WallD38)。
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 各發一個 POSTforeach),加上 upsert 前置的
// 逐筆 DELETErel 太多會撞 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 entriesScan 後=檔案系統現況)做成一張機械總覽卡。
// 純函式、零 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
}