febc6708ef
⑦(總管已複驗):雲端 parse_card 的邊界正規式 lookahead 是 \n#, 會停在「## 關聯」底下第一個 ### 之前。而規範形卡(ef5e6c5)正是 ### 內文知識關係 開頭 ⇒ 除機械補的 part_of 外,關係一條都收不到。 agent 對 youlin 實際部署的 rag_ingest_card POST 真卡:卡上 4 條/落地 1 條。 改成只認下一個 H1/H2 後離線重跑同一段 JS:解析出 4 條。 併同重編 installer 兩份 precompiled workflows(只改 yaml 不重編=新用戶拿不到)。 ⑩(施工中):direct.go/manifest.go 的改名搬移下架,尚未驗收。 🔴 尚未送達:這一版還沒部署到 youlin,live after 數字還沒拿到。 不得標 ✅。出貨 CP 步驟①。 未納入本次 commit:collector/cmd/arcrun-app/arcrun-app(20MB 編譯產物) 與兩個探針目錄——那是驗證用的臨時物,不該跟修法一起進版控。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
259 lines
12 KiB
Go
259 lines
12 KiB
Go
// manifest.go — collector 本機 manifest 讀寫(SDD ingest-hash-trigger design §2)。
|
||
// manifest 只由 collector 寫入,雲端不得回寫(design §6-4)。
|
||
package collector
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// ManifestEntry 是單一檔案在 manifest 裡的狀態。
|
||
// - mtime 只決定「要不要重算 hash」(fast-path),絕不作為內容變更判準。
|
||
// - ingested_hash 與 content_hash 分開存=可表達「已變更但尚未成功 ingest」;
|
||
// 上傳失敗不更新 ingested_hash,天然可重試。本階段(不接網路)永不寫 ingested_hash,
|
||
// 留給之後的上傳/webhook task 在成功後回寫。
|
||
type ManifestEntry struct {
|
||
ContentHash string `json:"content_hash"`
|
||
Size int64 `json:"size"`
|
||
Mtime int64 `json:"mtime"`
|
||
IngestedHash string `json:"ingested_hash,omitempty"`
|
||
IngestedAt int64 `json:"ingested_at,omitempty"`
|
||
// ExtractedBy=這張卡是誰萃的("claude"/"gemma"/""=無萃取器的直送路)。
|
||
//
|
||
// 為什麼要記(leo 2026-07-27 問「如果已經萃過了它知道嗎?」時發現的缺口):
|
||
// 原本 manifest 只記「萃過了」不記「誰萃的」。防重複本來就成立(hash 相同就跳過),
|
||
// 但**換萃取器時無從分辨哪些卡是舊萃取器產的**——claude 萃的卡和 gemma 萃的卡
|
||
// 品質不同卻混在同一個知識庫裡,想重萃也不知道該重萃哪些。
|
||
//
|
||
// 本欄位**不改變任何現有行為**(純記錄),但它是「換萃取器要不要重萃」這個決定的前提:
|
||
// 沒有它,之後想分辨就永遠分辨不了(舊資料補不回來)。
|
||
ExtractedBy string `json:"extracted_by,omitempty"`
|
||
|
||
// ── 失敗退避(t195,2026-08-05)────────────────────────────────────────────
|
||
// 病(leo 實撞):`小果被AFTEE詐貸.pdf` 因雲端 401 失敗後,**manifest 完全不記失敗**
|
||
// ⇒ 下一輪掃描又把它當「新檔」⇒ 無退避、無上限地重試。
|
||
// 實測:**1387 輪、跨 11 小時**,每輪 3.2~3.8 秒全花在撞同一面牆上;
|
||
// 而且它排在佇列前面 ⇒ **一個壞檔就把整個資料夾的同步拖住**。
|
||
// leo:「不只推上去慢,原先萃檔案速度也快,現在也花了十幾分才萃完」——
|
||
// **萃取根本沒變慢,慢的是重試**(log 的 `FOREACH 所有 5 項目均失敗` 證明卡早就萃好了)。
|
||
//
|
||
// 解:記下失敗次數與下次可重試時間,指數退避+次數上限。
|
||
// 這是**獨立於當次錯誤**的架構缺陷——401 修好了,下次換別的錯照樣卡死,所以要修這裡。
|
||
FailCount int `json:"fail_count,omitempty"` // 連續失敗次數(成功即歸零)
|
||
LastFailAt int64 `json:"last_fail_at,omitempty"` // 最後一次失敗的 unix 秒
|
||
NextRetry int64 `json:"next_retry,omitempty"` // 早於這個時間不重試(0=可立即重試)
|
||
// LastError=最後一次失敗的**真正原因**(原文,不改寫)。
|
||
//
|
||
// 🔴 leo 2026-08-06 立的原則:「**別人的錯誤一律要顯示給用戶看,
|
||
// 不然就會變成我的錯誤,導致客服**」。
|
||
// 先前退避一開始,畫面就只剩「上次失敗(第 4 次),58m 後重試」,
|
||
// 真因(Cloudflare「當日免費額度用完」/「這份 PDF 沒有文字層」)**當場消失**
|
||
// ⇒ 使用者以為是我們壞掉。原因必須跟著 entry 存活到下次成功為止。
|
||
LastError string `json:"last_error,omitempty"`
|
||
}
|
||
|
||
// retryBackoff 退避階梯:1m → 5m → 15m → 1h → 6h,之後每次 6h。
|
||
var retryBackoff = []time.Duration{
|
||
1 * time.Minute, 5 * time.Minute, 15 * time.Minute, 1 * time.Hour, 6 * time.Hour,
|
||
}
|
||
|
||
// MaxFailBeforeSkip 連續失敗達此次數 → 該檔暫停自動重試,不再拖住佇列。
|
||
// 使用者改檔(content hash 變)或按「立刻同步」時仍會重試(見 MarkFailed/ShouldRetry)。
|
||
const MaxFailBeforeSkip = 8
|
||
|
||
// Manifest 對應一個被勾選的資料夾。
|
||
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"`
|
||
|
||
// PendingTakedowns=改名/搬移後「舊路徑」在雲端知識庫裡還沒下架成功的待辦清單
|
||
// (key=舊相對路徑,value=該路徑當時導出的頁名)。InkStoneCo#44 ⑩:
|
||
//
|
||
// direct 模式把 renamed 事件當 added 處理(用新路徑重送一次萃取),但從未告訴
|
||
// 雲端「舊頁名/舊路徑那份已經死了」——純改檔名時舊頁名的舊卡永久留著;搬到
|
||
// 別的資料夾時(basename 不變 ⇒ 新舊頁名相同)舊的不刪、新的照寫,kbdb 裡同一份
|
||
// 文件變兩套,其中一套指向已不存在的路徑,而且沒有任何機制會回頭發現它。
|
||
//
|
||
// 為什麼要持久化而不是「失敗了下一輪自然重試」(removed 事件的作法):removed
|
||
// 事件靠「檔案仍然不在」讓 Scan() 每輪重新偵測、重新補發;但 renamed 的配對
|
||
// (removed×added 以 content_hash 配對)只在偵測到的那一輪出現一次,舊路徑已經
|
||
// 不在任何一邊的掃描結果裡,下一輪不會再有 renamed 事件把它帶出來。不記住它,
|
||
// 一次下架失敗(雲端剛好那幾秒掛掉)就永久遺失,舊卡從此不會再被清。
|
||
PendingTakedowns map[string]string `json:"pending_takedowns,omitempty"`
|
||
}
|
||
|
||
// QueueTakedown 記一筆「這個舊路徑(連同當時的頁名)還沒在雲端下架」的待辦。
|
||
// 冪等:同一路徑重複呼叫只覆蓋頁名(理論上不會變,但不假設呼叫端不會重複觸發)。
|
||
func (m *Manifest) QueueTakedown(oldPath, pageName string) {
|
||
if m.PendingTakedowns == nil {
|
||
m.PendingTakedowns = map[string]string{}
|
||
}
|
||
m.PendingTakedowns[oldPath] = pageName
|
||
}
|
||
|
||
// ClearTakedown 下架成功後從待辦清單移除。
|
||
func (m *Manifest) ClearTakedown(oldPath string) {
|
||
delete(m.PendingTakedowns, oldPath)
|
||
}
|
||
|
||
// newUUID 產生 RFC 4122 v4 UUID(純 stdlib)。
|
||
func newUUID() (string, error) {
|
||
var b [16]byte
|
||
if _, err := rand.Read(b[:]); err != nil {
|
||
return "", err
|
||
}
|
||
b[6] = (b[6] & 0x0f) | 0x40 // version 4
|
||
b[8] = (b[8] & 0x3f) | 0x80 // variant 10
|
||
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
|
||
}
|
||
|
||
// LoadManifest 讀 manifest 檔;不存在=新資料夾,生成 folder_id。
|
||
func LoadManifest(path, root string) (*Manifest, error) {
|
||
data, err := os.ReadFile(path)
|
||
if err != nil {
|
||
if errors.Is(err, os.ErrNotExist) {
|
||
id, uerr := newUUID()
|
||
if uerr != nil {
|
||
return nil, uerr
|
||
}
|
||
return &Manifest{FolderID: id, Root: root, Entries: map[string]*ManifestEntry{}}, nil
|
||
}
|
||
return nil, err
|
||
}
|
||
var m Manifest
|
||
if err := json.Unmarshal(data, &m); err != nil {
|
||
return nil, fmt.Errorf("manifest %s 解析失敗(不覆寫、直接報錯): %w", path, err)
|
||
}
|
||
if m.Entries == nil {
|
||
m.Entries = map[string]*ManifestEntry{}
|
||
}
|
||
if m.FolderID == "" {
|
||
id, uerr := newUUID()
|
||
if uerr != nil {
|
||
return nil, uerr
|
||
}
|
||
m.FolderID = id
|
||
}
|
||
if root != "" {
|
||
m.Root = root
|
||
}
|
||
return &m, nil
|
||
}
|
||
|
||
// MarkIngested 是「整條 ingest 鏈成功」後的回寫鉤子(design §2):把該路徑的
|
||
// ingested_hash 接上當時送出的 source_hash。**R2 上傳成功不呼叫它**——上傳只是鏈的
|
||
// 第一環,要等 task 4(named-webhook → ingest workflow)確認成功才回寫;在那之前
|
||
// 同檔每輪重發 added/modified=設計內重試,R2 端靠 HEAD no-op 天然冪等、零浪費。
|
||
// 回傳 false=路徑已不在 manifest(例如回報前檔案又被改名/刪除),呼叫端自行決定忽略或告警。
|
||
func (m *Manifest) MarkIngested(path, sourceHash string, at int64) bool {
|
||
return m.MarkIngestedBy(path, sourceHash, at, "")
|
||
}
|
||
|
||
// MarkIngestedBy 同 MarkIngested,另記「這輪是誰萃的」(extractor="claude"/"gemma"/"")。
|
||
//
|
||
// 為什麼另開一支而不是改 MarkIngested 的簽名:MarkIngested 有多個呼叫點
|
||
// (direct 兩處+trigger.go 的 MarkIngestedEvents+測試),改簽名會擴散破壞。
|
||
// 走無萃取器路徑(sync/直送)的呼叫端不必知道這個欄位,維持原簽名最小侵入。
|
||
func (m *Manifest) MarkIngestedBy(path, sourceHash string, at int64, extractor string) bool {
|
||
e, ok := m.Entries[path]
|
||
if !ok {
|
||
return false
|
||
}
|
||
e.IngestedHash = sourceHash
|
||
e.IngestedAt = at
|
||
e.ExtractedBy = extractor
|
||
// 成功即清掉失敗狀態(t195):下次再壞會從第一階退避重新算起。
|
||
e.FailCount, e.LastFailAt, e.NextRetry = 0, 0, 0
|
||
e.LastError = ""
|
||
return true
|
||
}
|
||
|
||
// MarkFailed 記錄一次失敗並排定下次可重試時間(t195 指數退避)。
|
||
//
|
||
// 為什麼要記在 manifest 而不是記憶體:collector 每輪是獨立 process
|
||
// (`direct --once` 由看守器反覆拉起),記憶體狀態一輪就沒了——
|
||
// 這正是原本「1387 輪重試同一個檔」的原因:每輪都以為自己是第一次。
|
||
func (m *Manifest) MarkFailed(path string, at int64, reason string) bool {
|
||
e, ok := m.Entries[path]
|
||
if !ok {
|
||
return false
|
||
}
|
||
e.FailCount++
|
||
e.LastFailAt = at
|
||
if strings.TrimSpace(reason) != "" {
|
||
e.LastError = reason // 存真因;退避訊息由呼叫端另外組,不覆蓋這裡
|
||
}
|
||
idx := e.FailCount - 1
|
||
if idx >= len(retryBackoff) {
|
||
idx = len(retryBackoff) - 1
|
||
}
|
||
e.NextRetry = at + int64(retryBackoff[idx].Seconds())
|
||
return true
|
||
}
|
||
|
||
// ShouldRetry 回報「這個檔現在該不該送」。
|
||
//
|
||
// - 從沒失敗過 → true(行為與 t195 前一字不變)
|
||
// - 還在退避窗口內 → false(**這是止血點:壞檔不再每 4 秒撞一次牆拖住整個佇列**)
|
||
// - 連續失敗 ≥ MaxFailBeforeSkip → false(暫停自動重試)
|
||
//
|
||
// force=使用者按「立刻同步」:忽略退避與上限一律重送
|
||
// (人明確要求時不該被機器的退避擋住)。
|
||
// 另:使用者改檔會讓 content hash 變 → 走的是「內容變更」路徑,本函式不介入。
|
||
func (m *Manifest) ShouldRetry(path string, now int64, force bool) bool {
|
||
e, ok := m.Entries[path]
|
||
if !ok || e.FailCount == 0 {
|
||
return true
|
||
}
|
||
if force {
|
||
return true
|
||
}
|
||
if e.FailCount >= MaxFailBeforeSkip {
|
||
return false
|
||
}
|
||
return now >= e.NextRetry
|
||
}
|
||
|
||
// Save 原子寫入(temp + rename),避免掃描中斷留半個 JSON。
|
||
func (m *Manifest) Save(path string) error {
|
||
data, err := json.MarshalIndent(m, "", " ")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
dir := filepath.Dir(path)
|
||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||
return err
|
||
}
|
||
tmp, err := os.CreateTemp(dir, ".manifest-*.tmp")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
tmpName := tmp.Name()
|
||
if _, err := tmp.Write(append(data, '\n')); err != nil {
|
||
tmp.Close()
|
||
os.Remove(tmpName)
|
||
return err
|
||
}
|
||
if err := tmp.Close(); err != nil {
|
||
os.Remove(tmpName)
|
||
return err
|
||
}
|
||
return os.Rename(tmpName, path)
|
||
}
|