Files
arcrun-collector/vault_footprint_test.go
Leo 6c74b97718 feat(collector): workers-ai 路改吃共用契約——免金鑰預設路的卡跟 BYOK 一樣完整(Arcrun#134)
InkStoneCo#44 ④ 只修了 gemma BYOK 路(JSON 契約+wikishape 機械組卡),
免金鑰預設路(多數用戶實際走的)仍吃雲端舊 prompt 的舊格式卡 ⇒ 走預設路
拿到次級知識庫。

修法=契約單一真相源:wikiExtractPrompt(原 gemmaPrompt 改名,因為它已是
兩條路共用)由 daemon 整段帶上雲(request prompt 欄位),雲端只當執行器回
模型原文(response output),解析(parseWikiExtractJSON)與組卡(BuildWikiDoc)
回到本 package 與 gemma 路同一段程式碼——同形不再靠「兩邊要一起改」的叮嚀。

版本歪斜兩向都有路:舊雲端忽略 prompt 回 card ⇒ fallback 走 legacy 落卡
(#60 前綴/不覆蓋保護原封不動,收端 lint 新舊雙軌);舊 daemon 不帶 prompt
⇒ 雲端 legacy 行為不變(對向修法在 Arcrun work/extract-wiki-json-0815)。

測試:+2——①request 的 prompt 必須是 wikiExtractPrompt 本人(不是手抄第二份)
②同原稿+同判斷走兩條路,.wiki/ 產物逐位元組相同(卡+00-INDEX+manifest)。
既有兩則(stub 回 card)自動變成舊雲端 fallback 的守衛。go test ./... 全綠。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 21:26:30 +08:00

355 lines
14 KiB
Go
Raw Permalink 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.
// vault_footprint_test.go — arcrun-rag#60 第二輪,驗收條件④「回頭看:上一批為什麼沒抓到」。
//
// ══════════════════════════════════════════════════════════════════════════
// 上一批(1e36bb1)為什麼漏掉這次這個?
// ══════════════════════════════════════════════════════════════════════════
//
// 上一批問的問題是:「**卡片落在哪個目錄?**」
// 於是它加了 cardsRelDirFor(),並且只寫了一支點測(TestCardsRelDirFor)去驗
// 「這個函式回傳的常數對不對」。它從頭到尾**沒有問過「檔名叫什麼」**——
// 而 Logseq/Obsidian 的頁名正是從 basename 來的,撞名撞的是名字不是目錄。
//
// 更根本的形狀問題:那批測試是**點測**(挑一個已知的寫檔點,驗它的回傳值)。
// 點測只能保護「測試作者當時想得到的那幾個寫檔點」,於是這些全部漏在網外:
//
// · snapshotCards() 仍硬寫 cardsRelDirvault 上 snapshot 到空目錄)
// · ExtractWithClaude 的檔名由 prompt 決定,Go 測試碰不到
// · safeWriteCard 的 .bak-<ts> 備份檔沒人問過它叫什麼名字
// · 之後任何人新增第 N 個寫檔點——沒有任何機制會提醒他要帶標記
//
// ⇒ 這正是「規則寫了、卻沒有機制驗證有沒有照做」的同款(頂層 CLAUDE.md 記過
//
// history-firstKBDB-firststage-first 三個同形狀的事故)。
//
// ══════════════════════════════════════════════════════════════════════════
// 所以這一批補的是「足跡測試」,不是再多一支點測
// ══════════════════════════════════════════════════════════════════════════
//
// 判準從「某個函式回傳什麼」換成「**跑完一輪之後,這個資料夾多出了什麼**」:
//
// 跑之前 walk 整個監看根拍一張全景快照 → 跑一輪真的同步 → 再拍一張 →
// ① 所有**新出現**的檔案,basename 一律要帶 MachineMark
// ② 所有**本來就在**的檔案,內容與 mtime 一個位元都不能變
//
// 這張網**與寫檔點的數量無關**:以後誰新增一個沒帶標記的寫檔點,不管它藏在
// 哪支檔案、哪條分支,只要它真的寫進了使用者的資料夾就會被這裡抓到。
package collector
import (
"encoding/json"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sort"
"strings"
"testing"
)
// gemmaEchoStub=會**看請求是哪一頁**、回傳對應那一頁合格萃取 JSON 的 Gemini 替身。
//
// 為什麼不能沿用 gemmaCardStub(固定回同一份):這一支測試的 fixture 是「真的有
// 既有頁面的 vault」,一輪會萃好幾份(journals、pages、丟進去的原稿)。固定回同一份
// 會讓多份文件的概念卡同名互撞(wikishape 的佔用保護會擋下)⇒ 測試綠不了,
// 而那是替身的問題不是產品的問題。頁名從 wikiExtractPrompt 的「檔名:<頁名>)」裡取回來。
func gemmaEchoStub(t *testing.T) func() {
t.Helper()
return gemmaStub(t, func(w http.ResponseWriter, r *http.Request) {
var req struct {
Contents []struct {
Parts []struct {
Text string `json:"text"`
} `json:"parts"`
} `json:"contents"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
prompt := ""
if len(req.Contents) > 0 && len(req.Contents[0].Parts) > 0 {
prompt = req.Contents[0].Parts[0].Text
}
page := "未知頁"
if i := strings.Index(prompt, "檔名:"); i >= 0 {
rest := prompt[i+len("檔名:"):]
if j := strings.Index(rest, ""); j >= 0 {
page = rest[:j]
}
}
_ = json.NewEncoder(w).Encode(map[string]any{
"candidates": []map[string]any{{
"content": map[string]any{"parts": []map[string]any{
{"text": cardFixture(page, "測試客體")},
}},
}},
})
})
}
// footprintEntry=快照裡的一筆(內容雜湊足以同時偵測「被改了」與「被截斷了」)。
type footprintEntry struct {
size int64
mtime int64
hash string
}
// snapshotTree walk 整個 root(含隱藏目錄——機器產物就藏在那裡),回傳 rel → 狀態。
func snapshotTree(t *testing.T, root string) map[string]footprintEntry {
t.Helper()
out := map[string]footprintEntry{}
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
rel, rerr := filepath.Rel(root, p)
if rerr != nil {
return rerr
}
info, ierr := d.Info()
if ierr != nil {
return ierr
}
h, herr := hashFile(p)
if herr != nil {
return herr
}
out[filepath.ToSlash(rel)] = footprintEntry{size: info.Size(), mtime: info.ModTime().UnixNano(), hash: h}
return nil
})
if err != nil {
t.Fatalf("快照失敗:%v", err)
}
return out
}
// sortedFootprintKeys 讓 t.Log 印出來的清單順序穩定(人要拿它當實測輸出貼進 issue)。
// 名字不叫 sortedKeysconvert_table.go 已經有一支同名的(吃 map[string][]byte),
// 同一個 package 裡不能重名。
func sortedFootprintKeys(m map[string]footprintEntry) []string {
ks := make([]string, 0, len(m))
for k := range m {
ks = append(ks, k)
}
sort.Strings(ks)
return ks
}
func logTree(t *testing.T, title string, snap map[string]footprintEntry) {
t.Helper()
t.Logf("──── %s%d 個檔案)────", title, len(snap))
for _, k := range sortedFootprintKeys(snap) {
t.Logf(" %s", k)
}
}
// newVaultWithRealJournals 造一個「真的有既有日誌檔」的 Logseq vault。
//
// 🔴 日期檔是這一票的核心:使用者的 journals/2026_08_10.md 一旦被當成原稿萃取,
// 舊版產出的卡片就叫 2026_08_10.md ⇒ 在 Logseq 眼裡跟他自己的日誌**同一頁**。
// 所以 fixture 必須真的擺日期檔,否則測不到 leo 抱怨的那一類。
func newVaultWithRealJournals(t *testing.T) (root string, preexisting map[string]string) {
t.Helper()
root = t.TempDir()
mustMkdir(t, filepath.Join(root, "logseq"))
mustMkdir(t, filepath.Join(root, "pages"))
mustMkdir(t, filepath.Join(root, "journals"))
preexisting = map[string]string{
"logseq/config.edn": "{:default-home {:page \"contents\"}}",
"pages/contents.md": "- 我的目錄頁",
"pages/讀書筆記.md": "- leo 自己寫的讀書筆記",
"journals/2026_08_10.md": "- 今天想到的事,這是我自己寫的日記",
"journals/2026_08_11.md": "- 昨天的日記",
}
for rel, body := range preexisting {
p := filepath.Join(root, filepath.FromSlash(rel))
mustMkdir(t, filepath.Dir(p))
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
return root, preexisting
}
// 驗收條件①②:在一個真的有既有日誌檔的 vault 上跑一輪,
// 新檔全部帶標記、他原本的檔一個都沒被動到。
func TestVaultFootprint_EveryNewFileIsMarked(t *testing.T) {
root, preexisting := newVaultWithRealJournals(t)
// 使用者丟進 vault 的原稿,**刻意用日期當檔名**——這是最會撞的那一種:
// 舊行為會萃出 `2026_08_10.md`,跟 journals/2026_08_10.md 同頁名。
srcRel := "2026_08_10.md"
if err := os.WriteFile(filepath.Join(root, srcRel), []byte("# 要被萃取的原稿"), 0o644); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
}))
defer srv.Close()
defer gemmaEchoStub(t)()
before := snapshotTree(t, root)
logTree(t, "跑之前", before)
cfg := &DirectConfig{
WatchFolders: []string{root},
Manifest: filepath.Join(t.TempDir(), "m.json"),
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
Library: "kb", Extractor: "gemma", ExtractorExplicit: true, GeminiAPIKey: "k-test",
CardIngestWF: "rag_ingest_card", MaxRemoved: DefaultMaxRemovedRatio,
}
results, exit, _ := RunDirectOnce(cfg, false)
if exit != 0 {
t.Fatalf("同步失敗 exit=%d results=%+v", exit, results)
}
after := snapshotTree(t, root)
logTree(t, "跑之後", after)
// ── ① 所有新出現的檔案,basename 一律帶標記 ──────────────────────────
var unmarked, added []string
for rel := range after {
if _, existed := before[rel]; existed {
continue
}
added = append(added, rel)
if !IsMachineOwnedRel(rel) {
unmarked = append(unmarked, rel)
}
}
sort.Strings(added)
sort.Strings(unmarked)
t.Logf("新增的檔案:%v", added)
if len(added) == 0 {
t.Fatal("這一輪什麼都沒產出——紅線是「東西照樣產出、但不撞名」,沒產出等於沒測到")
}
if len(unmarked) > 0 {
t.Fatalf("有 %d 個新檔沒帶 %q 標記,使用者分不出是誰寫的:%v",
len(unmarked), MachineMark, unmarked)
}
// ── ② 他原本的檔,一個位元都不能變 ────────────────────────────────
for rel, want := range before {
got, still := after[rel]
if !still {
t.Fatalf("使用者原本的檔案不見了:%s", rel)
}
if got.hash != want.hash || got.size != want.size {
t.Fatalf("使用者原本的檔案被改了:%ssize %d→%d", rel, want.size, got.size)
}
if got.mtime != want.mtime {
t.Fatalf("使用者原本的檔案 mtime 被動過:%s", rel)
}
}
// 內容也逐字核對一次(hash 相同已足夠,這裡是給人看的雙保險)。
for rel, want := range preexisting {
data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(rel)))
if err != nil {
t.Fatalf("讀不回原本的檔案 %s%v", rel, err)
}
if string(data) != want {
t.Fatalf("原本的檔案內容變了:%s\n want %q\n got %q", rel, want, data)
}
}
// ── ③ 撞名這件事本身:不准有任何機器產物的頁名等於使用者的頁名 ──────
userPages := map[string]string{}
for rel := range preexisting {
if strings.HasSuffix(rel, ".md") {
userPages[pageNameOf(rel)] = rel
}
}
userPages[pageNameOf(srcRel)] = srcRel
for _, rel := range added {
if !strings.HasSuffix(rel, ".md") {
continue
}
if owner, clash := userPages[pageNameOf(rel)]; clash {
t.Fatalf("機器產物 %s 的頁名與使用者的 %s 撞在一起(Logseq 會當成同一頁)", rel, owner)
}
}
}
// 非 vault 的一般資料夾,同一條規則同樣成立。
//
// 🔴 為什麼要有這一支:紅線是「前綴只准一種,不要一部分加一部分不加」。
// 若只在 vault 加標記,一般資料夾的使用者照樣分不出哪些是機器寫的——
// 而 system-dev/wiki/cards/ 在他的檔案總管裡是**看得見的**,不像 vault 那條是隱藏目錄。
func TestPlainFolderFootprint_EveryNewFileIsMarked(t *testing.T) {
root := t.TempDir()
mine := filepath.Join(root, "我的報告.md")
if err := os.WriteFile(mine, []byte("我自己的內容"), 0o644); err != nil {
t.Fatal(err)
}
srcRel := "報銷規則.md"
if err := os.WriteFile(filepath.Join(root, srcRel), []byte("# 原稿"), 0o644); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
}))
defer srv.Close()
defer gemmaEchoStub(t)()
before := snapshotTree(t, root)
cfg := &DirectConfig{
WatchFolders: []string{root},
Manifest: filepath.Join(t.TempDir(), "m.json"),
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
Library: "kb", Extractor: "gemma", ExtractorExplicit: true, GeminiAPIKey: "k-test",
CardIngestWF: "rag_ingest_card", MaxRemoved: DefaultMaxRemovedRatio,
}
if results, exit, _ := RunDirectOnce(cfg, false); exit != 0 {
t.Fatalf("同步失敗 exit=%d results=%+v", exit, results)
}
after := snapshotTree(t, root)
logTree(t, "一般資料夾・跑之後", after)
newCount := 0
for rel := range after {
if _, existed := before[rel]; existed {
continue
}
newCount++
if !IsMachineOwnedRel(rel) {
t.Fatalf("一般資料夾也不准有沒帶標記的新檔:%s", rel)
}
}
if newCount == 0 {
t.Fatal("沒有任何產出,測不到東西")
}
if data, _ := os.ReadFile(mine); string(data) != "我自己的內容" {
t.Fatalf("使用者原本的檔被動過:%q", data)
}
}
// 佔用保護也在網內:`.wiki/` 裡「不是本文件產的」同名檔案,改寫必須被擋下、
// 原檔原封不動(InkStoneCo#44 ④:塑形層以 manifest 認擁有權,佔用=報錯不覆蓋)。
//
// 這一條是「足跡測試比點測強」的最好例子——沒有人特地為既有檔寫過測試,
// 但那正是 daemon 最不准碰的東西。
func TestVaultFootprint_ForeignWikiFileNotClobbered(t *testing.T) {
root, _ := newVaultWithRealJournals(t)
srcRel := "會議記錄.md"
if err := os.WriteFile(filepath.Join(root, srcRel), []byte("# 會議記錄\n內文 v1"), 0o644); err != nil {
t.Fatal(err)
}
// 先放一個同名檔(不是 manifest 記載的產物)→ 這一輪落卡必須被擋。
cardDir := filepath.Join(root, wikiRelDir)
mustMkdir(t, cardDir)
preexisting := "# 會議記錄\n這是先前就存在的內容"
if err := os.WriteFile(filepath.Join(cardDir, "會議記錄.md"), []byte(preexisting), 0o644); err != nil {
t.Fatal(err)
}
defer gemmaCardStub(t, cardFixture("會議記錄", "專案"))()
if _, err := ExtractWithGemma("k-test", "gemma-test", root, srcRel); err == nil {
t.Fatal("目標被佔用時應報錯,不得無聲覆蓋")
}
data, _ := os.ReadFile(filepath.Join(cardDir, "會議記錄.md"))
if string(data) != preexisting {
t.Fatalf("既有檔案被動過:%q", data)
}
}