Files
arcrun-collector/folderindex.go
T

365 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
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.
// folderindex.go — 讓目錄索引變成**碎形**:每一層資料夾各有自己的一格,父子接得起來
// `inkstone/Arcrun#146`leo 2026-08-19)。
//
// leo 的原話(兩句,就是本檔的全部規格):
//
// 「daemon 可以**碎形**的在每個巢狀資料夾中產生 index & wiki」
// 「**每一層目錄指向子層次目錄**」
//
// 🔴 為什麼不是把 inventory.go 的上限調大:
//
// `inventory.go` 的資料模型只有**兩層**——`inventoryCardPath(library)` 讓一個監看根
// 只能有一張卡,`invTopDirOf(rel)` 只取相對路徑第一段。實測(14 檔/4 層巢狀)產出
// 1 張卡、3 條「第一層 >> part_of >> 總覽頁」,第二層以下一個節點都沒有。
// 深度是**寫死在模型裡的**,不是參數。
//
// 🔴 為什麼是「一個資料夾一張卡」而不是「一張卡帶全部關聯」:
//
// `rag_ingest_card` 對每條 rel 各發一個 POSTforeach)+upsert 前置的逐筆 DELETE。
// 把 N 個目錄的關聯塞進同一張卡 ⇒ 單一請求 N 條 rel ⇒ 撞 Workers 免費層
// **50 subrequests/請求**的天花板(`inventory.go` 的 `maxInventoryRels = 10`
// 就是為了這面牆才壓的)。拆成一卡一關聯後,**每個請求恆定 1 條 rel**,
// 天花板與資料夾數量脫鉤;變多的是請求「次數」,那個由既有的 `pace()` 節流吸收。
//
// 誠實界線(不要讓讀的人以為它做了沒做的事):
// - **空目錄不會有卡**。目錄集合是從 manifest 的檔案路徑反推的,而 `scan.go` 從來
// 沒有記錄過目錄本身(`d.IsDir()` 只用來決定要不要整棵跳過)。
// 「空資料夾也要看得見」是 `inkstone/arcrun-rag#106`,要先讓掃描記得目錄才做得到。
// - 卡片內容是**機械統計**(檔數、子資料夾、最近改了什麼),不經 LLM ——
// 與 `inventory.go` 同一個理由:這種問題本機掃一遍就有答案,不該跟萃取排同一條隊。
package collector
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"path"
"sort"
"strings"
"time"
)
const (
// maxFolderCards=單一監看根最多產幾張資料夾卡。
//
// 🔴 這是一道**會被講出來**的上限,不是安靜截斷(principles「no silent caps」):
// 超過時 `syncFolderCards` 會在結果訊息裡指名少送了幾個、從哪一層以下開始少。
// 200 的由來:一次同步 200 個請求已經是既有 `MaxEventsPerRun`(預設同量級)的規模,
// 再多就該讓使用者知道「你的資料夾深到要分批」,而不是我們自己吞掉。
maxFolderCards = 200
// maxFolderChildrenListed=卡片上「子資料夾」段最多列幾個。純顯示上限,
// 不影響關聯(關聯永遠由子卡自己宣告,見檔頭)。
maxFolderChildrenListed = 50
// maxFolderFilesListed=卡片上「本層檔案」最多列幾個。
maxFolderFilesListed = 100
)
// FolderCard=一個資料夾在雲端的那一格。
type FolderCard struct {
// Rel=相對監看根的路徑(`""`=根本身,根不由本檔產卡,見 BuildFolderCards)。
Rel string
// Page=雲端頁名(也是三元組裡的節點名)。
Page string
// Path=合成的 source_uri 鍵(不落地本機,同 inventoryCardPath 的慣例)。
Path string
// Parent=父資料夾的頁名。這就是「每一層指向子層次目錄」的那條邊的另一端。
Parent string
// Content=卡片全文(markdown)。
Content string
}
// folderCardPageName 回傳某個相對路徑的卡片頁名。
// 根用 inventoryPageName(沿用既有那張總覽卡,不另開一張打對台);
// 子層用 `資料夾:<根名>/<相對路徑>`——帶根名是因為不同監看根可能有同名子資料夾。
func folderCardPageName(absRoot, rel string) string {
if rel == "" {
return inventoryPageName(absRoot)
}
base := path.Base(strings.ReplaceAll(strings.TrimRight(absRoot, "/\\"), "\\", "/"))
if base == "" || base == "." || base == "/" {
base = "知識資料夾"
}
return "資料夾:" + base + "/" + rel
}
// folderCardPath 回傳子資料夾卡的合成路徑。
// 帶 library(每個監看根唯一)+相對路徑的 slug ⇒ 同帳號多資料夾不互撞。
// slug 為空(例如純中文路徑)時退成路徑雜湊,理由同 librarySlug 的 t89。
func folderCardPath(library, rel string) string {
slug := pathSlug(rel)
return ".arcrun-rag/wiki/cards/arcrun-資料夾-" + library + "-" + slug + ".md"
}
// pathSlug 把相對路徑轉成檔名安全的鍵。非 ASCII 一律轉底線分段(同 librarySlug 的規則),
// 全轉光就退成 sha256 前 8 hex——**路徑穩定所以鍵穩定,兩個不同路徑必然不同鍵**。
func pathSlug(rel string) string {
var b strings.Builder
lastUnderscore := false
for _, r := range rel {
switch {
case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_':
b.WriteRune(r)
lastUnderscore = false
case r >= 'A' && r <= 'Z':
b.WriteRune(r + 32)
lastUnderscore = false
default:
if !lastUnderscore && b.Len() > 0 {
b.WriteRune('_')
lastUnderscore = true
}
}
}
s := strings.Trim(b.String(), "_")
sum := sha256.Sum256([]byte(rel))
short := hex.EncodeToString(sum[:])[:8]
if s == "" {
return "d_" + short
}
// 一律接上雜湊尾碼:slug 會把「A/B」與「A_B」壓成同一個字串,
// 只靠 slug 會讓兩個不同資料夾共用一張卡(靜默混層,同 t89 的形狀)。
return s + "-" + short
}
// dirsFromEntries 從 manifest 的檔案路徑反推出**所有祖先目錄**(含中間層)。
// 回傳排序過的相對路徑(不含根的 "")。
func dirsFromEntries(entries map[string]*ManifestEntry) []string {
set := map[string]bool{}
for p, e := range entries {
if e == nil {
continue
}
rel := strings.ReplaceAll(p, "\\", "/")
seg := strings.Split(rel, "/")
if len(seg) < 2 {
continue // 根層檔案,沒有祖先目錄
}
for i := 1; i < len(seg); i++ {
set[strings.Join(seg[:i], "/")] = true
}
}
out := make([]string, 0, len(set))
for d := range set {
out = append(out, d)
}
sort.Strings(out)
return out
}
// parentRel 回傳某個相對目錄的父目錄(頂層目錄的父是根,回 "")。
func parentRel(rel string) string {
if i := strings.LastIndexByte(rel, '/'); i > 0 {
return rel[:i]
}
return ""
}
// BuildFolderCards 為每一個(含有檔案的)子資料夾各做一張卡。純函式、零 IO、輸出確定。
//
// 根本身不在回傳值裡——根那張是 `inventory.go` 的總覽卡,兩者分工不重疊:
// 總覽卡回答「這個監看根整體有什麼」,資料夾卡回答「這一層有什麼、它掛在誰底下」。
func BuildFolderCards(absRoot string, entries map[string]*ManifestEntry, library string) []FolderCard {
dirs := dirsFromEntries(entries)
// 每層直屬檔案 / 每層子目錄 / 每層含子孫的總檔數
direct := map[string][]invFile{}
subtotal := map[string]int{}
children := map[string][]string{}
for p, e := range entries {
if e == nil {
continue
}
rel := strings.ReplaceAll(p, "\\", "/")
d := path.Dir(rel)
if d == "." {
d = ""
}
direct[d] = append(direct[d], invFile{path: path.Base(rel), mtime: e.Mtime})
for cur := d; ; cur = parentRel(cur) {
subtotal[cur]++
if cur == "" {
break
}
}
}
for _, d := range dirs {
p := parentRel(d)
children[p] = append(children[p], d)
}
for k := range children {
sort.Strings(children[k])
}
cards := make([]FolderCard, 0, len(dirs))
for _, rel := range dirs {
page := folderCardPageName(absRoot, rel)
var b strings.Builder
sep := " " + strings.Repeat(">", 2) + " "
b.WriteString("# " + page + "\n\n")
b.WriteString("## 一句話定義\n")
b.WriteString(fmt.Sprintf("這是「%s」底下的資料夾 `%s`:本層直接放了 %d 份文件,連同子資料夾共 %d 份。本頁由掃描自動整理,隨檔案變動即時更新,不經 AI 萃取。\n\n",
path.Base(strings.ReplaceAll(strings.TrimRight(absRoot, "/\\"), "\\", "/")),
rel, len(direct[rel]), subtotal[rel]))
// 子資料夾:這一段就是「每一層目錄指向子層次目錄」給人看的那一面
// (給機器看的那一面是子卡自己的「## 關聯」)。
b.WriteString("## 子資料夾\n")
kids := children[rel]
if len(kids) == 0 {
b.WriteString("(沒有子資料夾)\n")
}
for i, c := range kids {
if i >= maxFolderChildrenListed {
b.WriteString(fmt.Sprintf("- (其餘 %d 個子資料夾未列出)\n", len(kids)-i))
break
}
b.WriteString(fmt.Sprintf("- %s%d 份)\n", path.Base(c), subtotal[c]))
}
b.WriteString("\n")
b.WriteString("## 本層檔案\n")
files := direct[rel]
sort.Slice(files, func(i, j int) bool { return files[i].path < files[j].path })
if len(files) == 0 {
b.WriteString("(本層沒有直接放文件,內容都在子資料夾裡)\n")
}
for i, f := range files {
if i >= maxFolderFilesListed {
b.WriteString(fmt.Sprintf("- (其餘 %d 份未列出)\n", len(files)-i))
break
}
b.WriteString("- " + f.path + "" + invDateOf(f.mtime) + "\n")
}
b.WriteString("\n")
// ── 關聯:**恰好一條**,指向父層。天花板與資料夾數量脫鉤的關鍵就在這裡。──
parent := folderCardPageName(absRoot, parentRel(rel))
b.WriteString("## 關聯\n")
b.WriteString("- " + page + sep + "part_of" + sep + parent + "\n")
cards = append(cards, FolderCard{
Rel: rel, Page: page, Path: folderCardPath(library, rel),
Parent: parent, Content: b.String(),
})
}
return cards
}
// syncFolderCards 在總覽卡之後送資料夾卡。回 nil=這輪不必送。
//
// 冪等:每張卡的內容雜湊記在 `manifest.folder_card_hashes[rel]`,沒變不送。
// 失敗不擋主流程(同 syncInventory);額度訊息換成人話。
// 上限:`maxFolderCards`,超過的**指名講出來**,不安靜吞掉。
func syncFolderCards(cfg *DirectConfig, absRoot string, m *Manifest, hasEvents, dryRun bool, now time.Time) []DirectResult {
if !hasEvents && len(m.FolderCardHashes) > 0 {
return nil // 靜止輪不重送(同 syncInventory 的③)
}
lib := cfg.libraryFor(absRoot)
cards := BuildFolderCards(absRoot, m.Entries, lib)
if len(cards) == 0 {
return nil
}
if m.FolderCardHashes == nil {
m.FolderCardHashes = map[string]string{}
}
// 先刪掉「這輪已經不存在的資料夾」的記帳,避免雜湊表無限長大。
live := map[string]bool{}
for _, c := range cards {
live[c.Rel] = true
}
for rel := range m.FolderCardHashes {
if !live[rel] {
delete(m.FolderCardHashes, rel)
}
}
// 🔴 先挑「這輪真的要送的」,**再**套上限——順序反過來就會變成永遠只重試前 200 個,
// 後面的資料夾一輩子送不上去(而且畫面上看起來一切正常)。
type pending struct {
card FolderCard
hash string
}
var todo []pending
for _, c := range cards {
sum := sha256.Sum256([]byte(c.Content))
h := "sha256:" + hex.EncodeToString(sum[:])
if m.FolderCardHashes[c.Rel] == h {
continue // 冪等:內容沒變
}
todo = append(todo, pending{card: c, hash: h})
}
if len(todo) == 0 {
return nil
}
var dropped []string
if len(todo) > maxFolderCards {
for _, t := range todo[maxFolderCards:] {
dropped = append(dropped, t.card.Rel)
}
todo = todo[:maxFolderCards]
}
var out []DirectResult
wf := cfg.CardIngestWF
if wf == "" {
wf = "rag_ingest_card"
}
mach := cfg.machineIdentity()
for _, t := range todo {
c := t.card
res := DirectResult{Type: "folder", Path: c.Path}
if dryRun {
res.Status = "planned"
out = append(out, res)
continue
}
pace()
status, _, err := cfg.postJSON(stepFolderCard, cfg.triggerURL(wf), map[string]any{
"page_name": c.Page,
"path": c.Path,
"card_content": c.Content,
"library": lib,
"machine": mach.ID,
"machine_label": mach.Label,
})
res.HTTPStatus = status
if err != nil {
res.Status = "failed"
if isQuotaExhausted(err.Error()) {
res.Error = "雲端今天的額度用完了,資料夾索引會稍後自動補送(不影響檔案同步)"
} else {
res.Error = "資料夾索引上傳失敗(不影響檔案同步):" + err.Error()
}
out = append(out, res)
// 這一張失敗就停手:後面幾百張多半會撞同一面牆,繼續撞只是把
// 「一個壞掉的雲端」變成「幾百筆一樣的錯誤」(t195 的 1387 輪教訓)。
break
}
res.Status = "ingested"
m.FolderCardHashes[c.Rel] = t.hash
out = append(out, res)
}
if len(dropped) > 0 {
out = append(out, DirectResult{
Type: "folder",
Status: "skipped",
Error: fmt.Sprintf("資料夾超過單輪上限 %d 個,這輪少送 %d 個(例:%s)——下一輪會接著送",
maxFolderCards, len(dropped), strings.Join(firstN(dropped, 3), "、")),
})
}
return out
}
// firstN 取前 n 個(不足就全給)。只給訊息用。
func firstN(ss []string, n int) []string {
if len(ss) <= n {
return ss
}
return ss[:n]
}