Files
arcrun-collector/scan.go
T
Leo a6ced32d45 collector:積壓分批+新檔優先/額度用完講人話降速/斷點續傳/同內容多格式去重
封測事故(Evan):daemon 逐檔萃取上傳、每個檔在雲端產生一次工作流執行,
690 個檔在自己的免費 CF 帳號上短時間內衝出 1,070 次寫入,撞上免費上限 1,000,
額度爆掉、只有 8 個檔成功。雲端那一半(紀錄改走資料層 API)已修好,
daemon 這一半原本完全沒有節奏——本次補齊四件事:

1. 上傳節奏(direct_pacing.go):單輪最多處理 MaxEventsPerRun 個事件
   (預設 25)、每次觸發雲端前節流 700ms;一輪掃到的事件依檔案 mtime
   由新到舊排序,今天寫的永遠優先,積壓慢慢消化不擋日常使用。

2. 額度用完講人話(quota.go):偵測到 Workers AI「10,000 neurons」/
   「4006」等已知上游訊號後,換成三句話(今天已整理幾份/可換模型或
   升級 Cloudflare/不花錢也沒關係、今天或明天早上 8:00 會自動恢復),
   不出現裸露的錯誤碼;同帳號同輪與下一輪都不再繼續撞牆
   (quotaState 全域冷卻,跨資料夾/跨程序重啟持續,直到台灣時間
   早上 8:00 額度重置)。

3. 斷點續傳:每個事件處理完立刻寫回 manifest(不再等整輪跑完才存一次),
   process 被殺掉重開只會接著做真正還沒完成的部分;removed 事件另外
   用 preScanEntries 快照保護,下架失敗時不會被其他事件的存檔動作
   誤標成「已完成」而永遠不再重試。

4. 同內容多格式去重(scan.go):同一批來源轉出的多種格式(如 leo 給的
   資料集 27,164 檔=9,045 md+9,044 json+9,043 html,md/json 同檔名
   主幹)依檔名主幹分組,只留優先序最高的一份進事件管線,其餘標記在
   DuplicateFormats(不吃三倍額度)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:58:07 +08:00

504 lines
20 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.
// scan.go — 掃描迴圈與差異分類(SDD ingest-hash-trigger design §3)。
// 事件順序:先把本輪 removed×added 以 content_hash 配對成 renamed(只更新路徑映射),
// 再分類其餘 added/modified/removedremoved 數 > manifest 條目 × 門檻(預設 40%)
// → removed 全部不執行、改發警告(R6)。本階段不接網路,事件輸出到 stdout。
package collector
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// 收檔白名單。**注意這只是「收不收」,能不能讀由 convert.go 的 extractors 決定**——
// 兩者要一起看(2026-07-27 t73`.pdf` 早就在這裡,但 ingest 端擋著=檔案上了 R2 卻進不了
// 知識庫,使用者看到的是「丟檔進去沒反應」)。
//
// .csv/.xlsx 於 2026-07-27 加入——leo:「要思考 Excel 和 csv 的問題,**因為企業用很多**」。
var allowedExt = map[string]bool{
".md": true,
".markdown": true,
".txt": true,
".docx": true,
".pptx": true,
".pdf": true,
".csv": true,
".xlsx": true,
}
// docLikeExt=「使用者明顯把它當文件、但我們還讀不了」的副檔名。
//
// 🔴 為什麼要有這張表(J-1/S6 考題 G-6.22026-08-06):
// 下面那道 `!allowedExt[...]` 的閘**直接 return nil**——不進事件、不進 manifest、
// 不進 status、不進畫面。使用者把一份 `.doc` 丟進資料夾,**整個系統從頭到尾一個字都不說**,
// 他只會覺得「這東西壞了」。G-6.2 的判準是:要嘛查得到,**要嘛當場被告知不支援**;
// 「安靜地略過」不是可接受的第三種結果。
//
// 為什麼是白名單、而不是「非 allowedExt 一律點名」:後者在 Obsidian 附件庫(幾百張 .png
// 或程式碼資料夾裡會炸出幾百行「處理不了」=噪音,使用者反而學會忽略整塊訊息。
// ⇒ **像文件的逐檔點名,其餘只報一個總數**(見 Scan 的 SkippedOther)。兩種都不沉默,
// 但只有前者值得佔用他的注意力。
//
// 加新格式的順序:先列在這裡(使用者立刻看得到「還不支援」),
// 等 convert.go 真的接上抽取器,再把它從這裡搬去 allowedExt。
// maxOtherNames=非文件檔最多點名幾個。超過就只報總數(避免幾百張圖洗版)。
const maxOtherNames = 5
var docLikeExt = map[string]bool{
// 舊版 OfficeOLE2 二進位,與 .docx/.xlsx/.pptx 是完全不同的格式)
".doc": true, ".xls": true, ".ppt": true,
// Apple iWork
".pages": true, ".numbers": true, ".key": true,
// OpenDocumentLibreOffice
".odt": true, ".ods": true, ".odp": true,
// 其他常見文件容器
".rtf": true, ".epub": true, ".wpd": true, ".msg": true, ".eml": true,
}
// SkippedFile=這一輪被略過、且值得對使用者逐檔點名的檔案。
//
// ⚠️ 刻意**不寫進 manifest**:它每輪由檔案系統重算,永遠反映現況。
// (對照 t195 的坑:凡是存進 ManifestEntry 的跨輪欄位都得記得在 carry 段補一行,
//
// 漏了就靜默歸零。這裡不建立那份債。)
type SkippedFile struct {
Path string `json:"path"`
Ext string `json:"ext"`
}
// ---- 輸出 payload(對應 schemas/collector-trigger.v1.schema.json----
type Event struct {
Type string `json:"type"`
Path string `json:"path"`
OldPath string `json:"old_path,omitempty"`
SourceHash string `json:"source_hash"`
Size *int64 `json:"size,omitempty"`
R2Key string `json:"r2_key,omitempty"`
}
type Warning struct {
Code string `json:"code"`
Message string `json:"message"`
RemovedCount int `json:"removed_count,omitempty"`
ManifestCount int `json:"manifest_count,omitempty"`
ThresholdRatio float64 `json:"threshold_ratio,omitempty"`
}
type TriggerPayload struct {
SchemaVersion int `json:"schema_version"`
FolderID string `json:"folder_id"`
Root string `json:"root,omitempty"`
GeneratedAt int64 `json:"generated_at,omitempty"`
Events []Event `json:"events"`
Warnings []Warning `json:"warnings,omitempty"`
// 🔴 兩個 `json:"-"`G-6.22026-08-06):被略過的檔案是**給本機使用者看的**,
// 不是給雲端 ingest 的料。collector-trigger.v1.schema.json 頂層寫死
// `additionalProperties: false`,多帶一個欄位上線就會被 schema 擋掉
//BuildSendablePayload 是 `sendable := *p` 淺拷貝,有 tag 就會一起送出去)。
// ⇒ 留在記憶體裡,由 direct.go 收進 status.json,給 App 首頁用。
Skipped []SkippedFile `json:"-"` // 像文件、但還讀不了的(逐檔點名)
SkippedOther int `json:"-"` // 其餘非文件檔(圖片/影音/程式碼…)總數
// SkippedOtherNames=上面那些檔的檔名,**最多 maxOtherNames 個**。
// 🔴 2026-08-06leo 封測):封測者放了一個 .md 進去說「無法通過」,而畫面只寫
// 「有 1 個不是文件的檔案」——**沒說是哪一個**,於是誰也判斷不出發生什麼事
// (.md 明明在白名單裡,所以那個 1 一定是別的東西:可能副檔名被 Windows 藏起來、
// 可能存成了別的格式)。只報總數在「幾百張圖」時是對的,在「1 個」時等於沒說。
// ⇒ 少量時就點名,讓使用者自己一眼看出「喔,我存錯格式了」。
SkippedOtherNames []string `json:"-"`
// DuplicateFormats=本輪偵測到、同檔名主幹的多格式重複(2026-08-07,見 FormatDuplicate)。
// 同 Skipped:只給本機使用者看,不隨 payload 送雲端(schema additionalProperties:false 會擋)。
DuplicateFormats []FormatDuplicate `json:"-"`
}
// FormatDuplicate=同一份內容被偵測到有多種格式並存(同檔名主幹、不同副檔名)。
//
// 🔴 為什麼要有這個(2026-08-07,leo 實據):封測者 Evan 給的資料集
// 27,164 檔=9,045 個 md9,044 個 json9,043 個 html,是同一批來源轉出的三種格式
// `markdown/160-00F3_001.md` 與 `json/160-00F3_001.json` 逐一對應,同錯誤碼、同變體號)。
// 現行設計不知道這件事,會把三種格式各當一份新檔,萃三次、吃三倍額度——
// 使用者不會知道要挑一種,這是我們該擋的,不是他該懂的。
//
// 判準:檔名主幹(去副檔名、轉小寫)在整個看守根內相同 → 視為同一份內容的不同格式匯出,
// 只留優先序最高的一份進事件管線,其餘標記在這裡(不產生 added/modified/renamed 事件)。
//
// ⚠️ 已知的取捨:純靠檔名主幹比對,不比對內容——兩個不相干的檔恰好同名不同副檔名
// (如兩個專案各自的 `README.md``README.pdf`)會被誤判成同一份。刻意接受這個風險,
// 因為:①這不是靜默丟棄——DuplicateFormats 會被 direct.go 收進 status.json 讓使用者看到
// 「跳過:與 X 視為同一格式」,看起來不對可以改檔名破解誤判;②不比對,就是 leo 實據的
// md/json 案例(不同目錄、不同副檔名,唯一共同點正是檔名主幹)根本擋不掉。
//
// 🔴 刻意不寫進 manifest(同 Skipped 的理由,見上方 SkippedFile 註解):每輪由檔案系統
// 重算,勝出者若之後消失,下一輪換另一份自然遞補,不留跨輪狀態要維護。
type FormatDuplicate struct {
Path string `json:"path"` // 被跳過的那份
KeptPath string `json:"kept_path"` // 真正進事件管線的那份
Stem string `json:"stem"` // 判定依據:去副檔名、轉小寫後的檔名主幹
}
// dedupFormatPriority:格式去重時「留誰」的優先序(越前面越優先留下)。
// 原則:越接近「使用者原始編輯」的格式排越前面(docx/pptx 是可編輯原稿),
// md/markdown 常是**從別的格式轉出的產物**(leo 實據的資料集正是 html→chm 轉出 md/json),
// 排在轉檔產物之前但在原生辦公格式之後。不在表內的副檔名排最後(理論上不會發生,
// current 只收 allowedExt)。
var dedupFormatPriority = []string{".docx", ".pptx", ".xlsx", ".csv", ".pdf", ".md", ".markdown", ".txt"}
func formatPriority(relPath string) int {
ext := strings.ToLower(filepath.Ext(relPath))
for i, e := range dedupFormatPriority {
if e == ext {
return i
}
}
return len(dedupFormatPriority)
}
// dedupStemOf 回傳去重判準:檔名主幹(basename 去副檔名),轉小寫(跨平台大小寫不敏感)。
// 刻意只看 basename、不看目錄——leo 實據的 md/json 恰好活在不同的兄弟目錄
// markdown/160-00F3_001.md vs json/160-00F3_001.json),只有 basename 主幹相同。
func dedupStemOf(relPath string) string {
base := filepath.Base(relPath)
stem := strings.TrimSuffix(base, filepath.Ext(base))
return strings.ToLower(stem)
}
// detectFormatDuplicates 在 current(本輪掃到、通過 allowedExt 的檔)裡找出同檔名主幹的分組,
// 每組留優先序最高的一份,其餘回報為 loserpath -> winner path)。
// 走訪用 stem 字母序=確定性輸出(map 迭代順序不穩定)。
func detectFormatDuplicates(current map[string]fileState) (map[string]string, []FormatDuplicate) {
byStem := map[string][]string{}
for p := range current {
stem := dedupStemOf(p)
byStem[stem] = append(byStem[stem], p)
}
stems := make([]string, 0, len(byStem))
for s := range byStem {
stems = append(stems, s)
}
sort.Strings(stems)
loserOf := map[string]string{}
var dups []FormatDuplicate
for _, stem := range stems {
group := byStem[stem]
if len(group) < 2 {
continue
}
sort.Slice(group, func(i, j int) bool {
pi, pj := formatPriority(group[i]), formatPriority(group[j])
if pi != pj {
return pi < pj
}
return group[i] < group[j] // 同優先序時字母序,確定性
})
winner := group[0]
for _, loser := range group[1:] {
loserOf[loser] = winner
dups = append(dups, FormatDuplicate{Path: loser, KeptPath: winner, Stem: stem})
}
}
return loserOf, dups
}
type ScanOptions struct {
// MaxRemovedRatio:單輪 removed 數 > manifest 條目數 × 本值 → 觸發大量刪除防呆(R6)。
MaxRemovedRatio float64
// SkipPaths:絕對路徑黑名單(如 manifest 檔自己住在 root 底下時)。
SkipPaths map[string]bool
// SkipDirNames:目錄名黑名單(任一層命中整棵跳過)。daemon-beta task 2
// template 代裝後 `system-dev/`wiki 產物區)不得被當成原稿掃進 ingest。
SkipDirNames map[string]bool
}
const DefaultMaxRemovedRatio = 0.4
type fileState struct {
hash string // sha256:<hex>
size int64
mtime int64
}
func hashFile(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return "sha256:" + hex.EncodeToString(h.Sum(nil)), nil
}
func r2KeyOf(sourceHash string) string {
return "raw/" + strings.TrimPrefix(sourceHash, "sha256:")
}
// Scan 走訪 root、對照並更新 manifest、產出一輪事件。
// manifest 更新原則:content_hash/size/mtime 反映現況;ingested_hash/ingested_at
// 只搬運(renamed)與保留(modified),本函式永不設值——那是上傳成功後的事。
func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
if opts.MaxRemovedRatio <= 0 {
opts.MaxRemovedRatio = DefaultMaxRemovedRatio
}
orig := m.Entries
manifestCountBefore := len(orig)
// 1) 走訪檔案系統,建立現況(mtime+size fast-path:沒變→沿用 manifest hash,變了才算 sha256)。
current := map[string]fileState{}
var skipped []SkippedFile
skippedOther := 0
var skippedOtherNames []string
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, werr error) error {
if werr != nil {
return werr
}
name := d.Name()
if d.IsDir() {
if p != root && strings.HasPrefix(name, ".") {
return filepath.SkipDir // 隱藏目錄(.git、.obsidian…)整棵跳過
}
if p != root && opts.SkipDirNames[name] {
return filepath.SkipDir // 名單目錄(system-dev…)整棵跳過
}
return nil
}
if strings.HasPrefix(name, ".") {
return nil
}
if abs, aerr := filepath.Abs(p); aerr == nil && opts.SkipPaths[abs] {
return nil
}
// 🔴 2026-08-06 leotemplate 的東西不是使用者的知識,一律不收。
// **用路徑身分認,不用「有沒有以點開頭」認**——leo 自己的 repo 裡
// template 本來就不是隱藏的,靠隱藏判斷會漏掉一大半。
// 也不計進「有 N 個檔案沒有被整理」——那是給使用者看他自己的檔案的,
// 我們自己鋪的東西不該佔用他的注意力。
if rel, rerr := filepath.Rel(root, p); rerr == nil && TemplateOwns(filepath.ToSlash(rel)) {
return nil
}
ext := strings.ToLower(filepath.Ext(name))
if !allowedExt[ext] {
// G-6.2**這裡以前是條死巷**——`return nil` 之後這個檔就從世界上消失了。
// 現在留個名,讓 direct.go 有東西可以寫進 status.json、App 有東西可以顯示。
if docLikeExt[ext] {
if rel, rerr := filepath.Rel(root, p); rerr == nil {
skipped = append(skipped, SkippedFile{Path: filepath.ToSlash(rel), Ext: ext})
}
} else {
skippedOther++
// 只留前幾個:多了就變噪音(Obsidian 附件庫可能有幾百張 .png)。
if len(skippedOtherNames) < maxOtherNames {
if rel, rerr := filepath.Rel(root, p); rerr == nil {
skippedOtherNames = append(skippedOtherNames, filepath.ToSlash(rel))
}
}
}
return nil
}
info, ierr := d.Info()
if ierr != nil {
return ierr
}
rel, rerr := filepath.Rel(root, p)
if rerr != nil {
return rerr
}
rel = filepath.ToSlash(rel)
st := fileState{size: info.Size(), mtime: info.ModTime().Unix()}
if e, ok := orig[rel]; ok && e.ContentHash != "" && e.Mtime == st.mtime && e.Size == st.size {
st.hash = e.ContentHash // fast-pathmtime+size 沒變,跳過重算
} else {
h, herr := hashFile(p)
if herr != nil {
return herr
}
st.hash = h
}
current[rel] = st
return nil
})
if err != nil {
return nil, err
}
// 1.5) 同內容多格式去重(2026-08-07):先決定誰是 loser,事件管線全程跳過它們。
dupLoser, duplicateFormats := detectFormatDuplicates(current)
// 2) 初分:added 候選(現況有、manifest 無)與 removed 候選(manifest 有、現況無)。
// loser 不進候選——它不該被當成新檔,也不該被當成 rename 的另一端。
var addedPaths, removedPaths []string
for p := range current {
if dupLoser[p] != "" {
continue
}
if _, ok := orig[p]; !ok {
addedPaths = append(addedPaths, p)
}
}
for p := range orig {
if _, ok := current[p]; !ok {
removedPaths = append(removedPaths, p)
}
}
sort.Strings(addedPaths)
sort.Strings(removedPaths)
// 3) 先配對 renameddesign §3 順序 1):removed×added 以 content_hash 配對,
// 配上=只更新路徑映射,不 retire、不重萃、不重傳。同 hash 多候選→排序後貪婪配對(確定性)。
removedByHash := map[string][]string{}
for _, p := range removedPaths {
h := orig[p].ContentHash
removedByHash[h] = append(removedByHash[h], p)
}
renamedOldOf := map[string]string{} // newPath -> oldPath
pairedOld := map[string]bool{}
var events []Event
for _, np := range addedPaths {
h := current[np].hash
cands := removedByHash[h]
if len(cands) == 0 {
continue
}
op := cands[0]
removedByHash[h] = cands[1:]
pairedOld[op] = true
renamedOldOf[np] = op
events = append(events, Event{Type: "renamed", Path: np, OldPath: op, SourceHash: h})
}
// 4) added:真新檔+「曾偵測但從未成功 ingest」的檔(重試語意,design §2)。
sortedCurrent := make([]string, 0, len(current))
for p := range current {
sortedCurrent = append(sortedCurrent, p)
}
sort.Strings(sortedCurrent)
addedEvent := func(p string) Event {
st := current[p]
size := st.size
return Event{Type: "added", Path: p, SourceHash: st.hash, Size: &size, R2Key: r2KeyOf(st.hash)}
}
for _, p := range sortedCurrent {
if dupLoser[p] != "" { // 同內容的另一格式已在事件管線,這份跳過(1.5)
continue
}
if op, isRenamed := renamedOldOf[p]; isRenamed {
if orig[op].IngestedHash == "" { // 改名的檔其實從未 ingest 成功 → 補一發 added
events = append(events, addedEvent(p))
}
continue
}
if _, existed := orig[p]; !existed {
events = append(events, addedEvent(p)) // 真新檔
} else if orig[p].IngestedHash == "" {
events = append(events, addedEvent(p)) // 上輪偵測過但 ingest 未成功 → 重試
}
}
// 5) modifiedmanifest 有、現況有、content_hash != ingested_hashdesign §3 順序 3)。
for _, p := range sortedCurrent {
if dupLoser[p] != "" { // 同內容的另一格式已在事件管線,這份跳過(1.5)
continue
}
e, existed := orig[p]
if !existed {
continue
}
if _, isRenamed := renamedOldOf[p]; isRenamed {
continue
}
if e.IngestedHash != "" && current[p].hash != e.IngestedHash {
st := current[p]
size := st.size
events = append(events, Event{Type: "modified", Path: p, SourceHash: st.hash, Size: &size, R2Key: r2KeyOf(st.hash)})
}
}
// 6) removed(扣掉已配對走的)+大量刪除防呆(R6)。
var finalRemoved []string
for _, p := range removedPaths {
if !pairedOld[p] {
finalRemoved = append(finalRemoved, p)
}
}
var warnings []Warning
guardTripped := manifestCountBefore > 0 &&
float64(len(finalRemoved)) > opts.MaxRemovedRatio*float64(manifestCountBefore)
if guardTripped {
warnings = append(warnings, Warning{
Code: "mass_delete_guard",
Message: fmt.Sprintf(
"本輪偵測到 %d/%d 個檔案消失(超過 %.0f%% 門檻)——可能是資料夾未掛載或同步半途。本輪全部「不」下架,請確認資料夾完好後再放行。",
len(finalRemoved), manifestCountBefore, opts.MaxRemovedRatio*100),
RemovedCount: len(finalRemoved),
ManifestCount: manifestCountBefore,
ThresholdRatio: opts.MaxRemovedRatio,
})
} else {
for _, p := range finalRemoved {
events = append(events, Event{Type: "removed", Path: p, SourceHash: orig[p].ContentHash})
}
}
// 7) 更新 manifestrebuild):現況檔全數收錄;ingested_* 由舊 entry(或 renamed 的舊路徑)搬運。
// 防呆觸發時 removed 條目保留(下輪重評、警告會再響,直到人確認或檔案回來)。
newEntries := make(map[string]*ManifestEntry, len(current))
for p, st := range current {
ne := &ManifestEntry{ContentHash: st.hash, Size: st.size, Mtime: st.mtime}
var carry *ManifestEntry
if op, isRenamed := renamedOldOf[p]; isRenamed {
carry = orig[op]
} else if e, ok := orig[p]; ok {
carry = e
}
if carry != nil {
ne.IngestedHash = carry.IngestedHash
ne.IngestedAt = carry.IngestedAt
// 🔴 t195:掃描每輪都**重建** entry,原本只 carry 上面兩欄 ⇒ 其餘欄位靜默歸零。
// 實撞:失敗退避(fail_count/next_retry)寫進去了,下一輪掃描卻被抹掉
// ⇒ 退避永遠停在「第 1 次失敗」,等同沒有退避(1387 輪的病根之一)。
// ExtractedBy(t73 記的「誰萃的」)原本也一樣悄悄丟失。
// ⚠️ 之後在 ManifestEntry 新增任何「跨輪要記住」的欄位,都必須加在這裡。
ne.ExtractedBy = carry.ExtractedBy
ne.FailCount = carry.FailCount
ne.LastFailAt = carry.LastFailAt
ne.NextRetry = carry.NextRetry
}
newEntries[p] = ne
}
if guardTripped {
for _, p := range finalRemoved {
newEntries[p] = orig[p]
}
}
m.Entries = newEntries
m.Root = root
if events == nil {
events = []Event{}
}
sort.Slice(skipped, func(i, j int) bool { return skipped[i].Path < skipped[j].Path })
return &TriggerPayload{
SchemaVersion: 1,
FolderID: m.FolderID,
Root: root,
GeneratedAt: time.Now().Unix(),
Events: events,
Warnings: warnings,
Skipped: skipped,
SkippedOther: skippedOther,
SkippedOtherNames: skippedOtherNames,
DuplicateFormats: duplicateFormats,
}, nil
}