a50cc37949
leo 07-27 三問查證後補的缺口。查證結果(已用測試釘死,下次不必再讀碼推論): ① 已萃過它知道嗎 → 知道。ingested_hash vs content_hash,相同就跳過; 只有 2xx 成功才回寫(direct.go:405),失敗下輪自動重試不漏檔。 ② gemma 會重萃 claude 萃過的嗎 → 不會重萃(hash 相同),但原本**分辨不出誰萃的** =換萃取器後兩種品質的卡混在同一知識庫,想重萃也不知該重萃哪些。← 這次補的 ③ 誰負責萃 → cfg.Extractor 一個資料夾一個設定(direct.go:346),無自動判斷。 做法(純記錄不改行為、可逆,故自裁): - ManifestEntry 加 ExtractedBy(omitempty) - 另開 MarkIngestedBy 而非改 MarkIngested 簽名——後者有多處呼叫(direct 兩處+ trigger.go MarkIngestedEvents+測試),改簽名會擴散破壞 測試 7/7:萃過不重萃/改了要重萃/失敗下輪重試/記得誰萃的/ 舊簽名仍可用/舊 manifest 讀得進來且空值不污染 JSON/有值真的存進檔案。 未做(屬品味題待 leo 裁):B 自動偵測有無 claude 優先用用戶訂閱、C ChatGPT 路。
139 lines
5.0 KiB
Go
139 lines
5.0 KiB
Go
// manifest.go — collector 本機 manifest 讀寫(SDD ingest-hash-trigger design §2)。
|
||
// manifest 只由 collector 寫入,雲端不得回寫(design §6-4)。
|
||
package main
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
)
|
||
|
||
// 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"`
|
||
}
|
||
|
||
// Manifest 對應一個被勾選的資料夾。
|
||
type Manifest struct {
|
||
FolderID string `json:"folder_id"`
|
||
Root string `json:"root"`
|
||
Entries map[string]*ManifestEntry `json:"entries"`
|
||
}
|
||
|
||
// 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
|
||
return true
|
||
}
|
||
|
||
// 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)
|
||
}
|