feat(collector): 結構先行——掃描完幾秒內就能問「資料夾裡有什麼」,不等 LLM 不等額度(InkStoneCo#43)
leo 08-15 量測:266 檔真正算的 ~20 分鐘/<$0.5,使用者卻等 ~4 天(多在等 Workers AI 每日額度);而「有哪些檔案/最近改了什麼」本地掃一遍就有答案 (1,335 檔實測 0.254s),卻因 part_of 只在雲端 parse_card(萃取之後)生成 而排同一條隊、撞同一面額度牆。 - collector/inventory.go:Scan() 後、萃取迴圈前(額度冷卻閘之外),把 manifest 現況做成機械總覽卡(檔案清單/最近改動/目錄 part_of)POST 既有 rag_ingest_card ——零 LLM、零新 workflow、既有實例直接受益;自帶同頁名 upsert 防重複 - 冪等:內容 sha256 記 manifest.inventory_hash;失敗 10 分鐘退避(防 t195 同款 每 5 秒重撞);額度失敗訊息人話化,不裸露 4006/502 - 清單 ≤200 逐檔、關聯 ≤10 條:守 Workers 免費層 50 subrequests 天花板 - 不寫本機檔案、不動使用者原稿 實測(youlin stage yuga3bse,刻意無金鑰=零萃取):總覽卡 200 送達、rag_chat 正確回答「有哪些檔案/最近改了什麼」;重掃不重複(blocks 恆 5、triplets 恆 2); go test collector+supervisor+arcrun-app 全綠(新增 inventory_test.go 6 案)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+63
-29
@@ -84,37 +84,53 @@ func TestDirectExtractorModeE2E(t *testing.T) {
|
||||
if exit != 0 {
|
||||
t.Fatalf("exit=%d results=%+v", exit, results)
|
||||
}
|
||||
if len(results) != 1 || results[0].Status != "ingested" {
|
||||
t.Fatalf("results=%+v", results)
|
||||
// 結構先行(InkStoneCo#43):每輪多一張機械總覽卡(零 LLM),檔案事件另計
|
||||
inv, fileResults := splitInventory(results)
|
||||
if len(inv) != 1 || inv[0].Status != "ingested" {
|
||||
t.Fatalf("總覽卡應送達:%+v", inv)
|
||||
}
|
||||
if len(fileResults) != 1 || fileResults[0].Status != "ingested" {
|
||||
t.Fatalf("results=%+v", fileResults)
|
||||
}
|
||||
// 卡片落地本地(用戶看得到自己的 wiki)
|
||||
if _, err := os.Stat(filepath.Join(root, "system-dev", "wiki", "cards", "arcrun-報銷規則.md")); err != nil {
|
||||
t.Fatalf("卡片未落地:%v", err)
|
||||
}
|
||||
// 上雲的是卡片、不是原文
|
||||
if len(posted) != 1 {
|
||||
t.Fatalf("應恰好 POST 一張卡,got %d", len(posted))
|
||||
// 上雲的是「總覽卡+內容卡」兩張,都不含原文
|
||||
if len(posted) != 2 {
|
||||
t.Fatalf("應恰好 POST 兩張卡(總覽+內容),got %d", len(posted))
|
||||
}
|
||||
cc, _ := posted[0]["card_content"].(string)
|
||||
for _, p := range posted {
|
||||
if cc, _ := p["card_content"].(string); strings.Contains(cc, "XYZZY") {
|
||||
t.Fatal("原文內容洩上雲=違反四步定稿邊界")
|
||||
}
|
||||
}
|
||||
var contentCard map[string]any
|
||||
for _, p := range posted {
|
||||
if pn, _ := p["page_name"].(string); !strings.HasPrefix(pn, "資料夾總覽") {
|
||||
contentCard = p
|
||||
}
|
||||
}
|
||||
if contentCard == nil {
|
||||
t.Fatal("找不到內容卡")
|
||||
}
|
||||
cc, _ := contentCard["card_content"].(string)
|
||||
if !strings.Contains(cc, "## 一句話定義") {
|
||||
t.Fatalf("card_content 不是卡片:%.80s", cc)
|
||||
}
|
||||
if strings.Contains(cc, "XYZZY") {
|
||||
t.Fatal("原文內容洩上雲=違反四步定稿邊界")
|
||||
}
|
||||
// path 必須是「原檔路徑」(takedown 比對鍵+B4 溯源)——不是卡片路徑(07-24 第五枚坑)
|
||||
if p, _ := posted[0]["path"].(string); p != "報銷規則.md" {
|
||||
if p, _ := contentCard["path"].(string); p != "報銷規則.md" {
|
||||
t.Fatalf("path=%q(應為原檔路徑)", p)
|
||||
}
|
||||
// 🔴 arcrun-rag#60 第二輪:本機卡片檔名加了 arcrun- 前綴,但**上雲的 page_name 不准跟著變**。
|
||||
// 下架分支用的是原稿頁名(見下一支測試斷言 takedown page_name=="報銷規則"),
|
||||
// 這裡若跟著卡片檔名變成 "arcrun-報銷規則",兩邊就永遠對不上、刪原檔再也下架不掉。
|
||||
if pn, _ := posted[0]["page_name"].(string); pn != "報銷規則" {
|
||||
if pn, _ := contentCard["page_name"].(string); pn != "報銷規則" {
|
||||
t.Fatalf("page_name=%q(應為原稿頁名,不含 arcrun- 前綴,否則下架對不上)", pn)
|
||||
}
|
||||
// 第二輪:原稿沒變 → 不重萃不重送
|
||||
// 第二輪:原稿沒變 → 不重萃不重送(總覽卡雜湊相同也不重送)
|
||||
results2, exit2, _ := RunDirectOnce(cfg, false)
|
||||
if exit2 != 0 || len(results2) != 0 || len(posted) != 1 {
|
||||
if exit2 != 0 || len(results2) != 0 || len(posted) != 2 {
|
||||
t.Fatalf("第二輪應零事件:results=%+v posted=%d", results2, len(posted))
|
||||
}
|
||||
}
|
||||
@@ -170,8 +186,10 @@ func TestDirectExtractorRemovedClearsLocalCard(t *testing.T) {
|
||||
if exit != 0 {
|
||||
t.Fatalf("第二輪 exit=%d results=%+v", exit, results)
|
||||
}
|
||||
if len(results) != 1 || results[0].Status != "removed" {
|
||||
t.Fatalf("results=%+v", results)
|
||||
// 結構先行:刪檔輪總覽卡也會更新(清單不該還列著剛刪的檔),檔案事件另計
|
||||
_, fileResults := splitInventory(results)
|
||||
if len(fileResults) != 1 || fileResults[0].Status != "removed" {
|
||||
t.Fatalf("results=%+v", fileResults)
|
||||
}
|
||||
if len(takedowns) != 1 {
|
||||
t.Fatalf("應恰好一次 takedown,got %d", len(takedowns))
|
||||
@@ -214,7 +232,8 @@ func TestDirectExtractorRemovedNoLocalCardOK(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
results, exit, _ := RunDirectOnce(cfg, false)
|
||||
if exit != 0 || len(results) != 1 || results[0].Status != "removed" {
|
||||
_, fileResults := splitInventory(results) // 結構先行:總覽卡另計
|
||||
if exit != 0 || len(fileResults) != 1 || fileResults[0].Status != "removed" {
|
||||
t.Fatalf("exit=%d results=%+v", exit, results)
|
||||
}
|
||||
}
|
||||
@@ -237,13 +256,15 @@ func TestDirectExtractorFailKeepsRetry(t *testing.T) {
|
||||
Extractor: "gemma", ExtractorExplicit: true, GeminiAPIKey: "k-test", MaxRemoved: DefaultMaxRemovedRatio,
|
||||
}
|
||||
results, exit, _ := RunDirectOnce(cfg, false)
|
||||
if exit != 1 || len(results) != 1 || results[0].Status != "failed" {
|
||||
_, fileResults := splitInventory(results) // 結構先行:總覽卡另計(此處 cypher 不通,總覽也 failed)
|
||||
if exit != 1 || len(fileResults) != 1 || fileResults[0].Status != "failed" {
|
||||
t.Fatalf("exit=%d results=%+v", exit, results)
|
||||
}
|
||||
// 再跑一輪:仍是同一個事件(manifest 沒標 ingested=會重試)
|
||||
// 再跑一輪:仍是同一個事件(manifest 沒標 ingested=會重試);
|
||||
// 總覽卡則在自己的失敗退避窗口內,不重撞
|
||||
results2, _, _ := RunDirectOnce(cfg, false)
|
||||
if len(results2) != 1 {
|
||||
t.Fatalf("失敗檔應重試:%+v", results2)
|
||||
if inv2, fileResults2 := splitInventory(results2); len(fileResults2) != 1 || len(inv2) != 0 {
|
||||
t.Fatalf("失敗檔應重試、總覽應退避:%+v", results2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,9 +335,18 @@ func TestExtractorEmptyBlocksNonTextDirect(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var serverCalled bool
|
||||
// 結構先行後,總覽卡(只含檔名、零原文)照常會 POST 到 rag_ingest_card——
|
||||
// 本測試守的契約是「PDF 位元組/原文不出機」,改成逐請求驗內容與端點。
|
||||
var ingestDirectCalled bool
|
||||
var leaked bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
serverCalled = true
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if strings.HasSuffix(r.URL.Path, "/rag_ingest_direct/trigger") {
|
||||
ingestDirectCalled = true
|
||||
}
|
||||
if strings.Contains(string(body), "%PDF") || strings.Contains(string(body), "機密原文") {
|
||||
leaked = true
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||||
}))
|
||||
defer srv.Close()
|
||||
@@ -337,16 +367,20 @@ func TestExtractorEmptyBlocksNonTextDirect(t *testing.T) {
|
||||
if exit != 1 {
|
||||
t.Fatalf("exit=%d,應是 1(非文字檔無萃取器=失敗)", exit)
|
||||
}
|
||||
if serverCalled {
|
||||
t.Error("防禦閘失效:PDF 被直送上雲(契約破壞)")
|
||||
if ingestDirectCalled {
|
||||
t.Error("防禦閘失效:走了舊制直送端點(契約破壞)")
|
||||
}
|
||||
if len(results) != 1 || results[0].Status != "failed" {
|
||||
t.Fatalf("results=%+v", results)
|
||||
if leaked {
|
||||
t.Error("防禦閘失效:PDF 位元組/原文被送上雲(契約破壞)")
|
||||
}
|
||||
_, fileResults := splitInventory(results) // 結構先行:總覽卡(只含檔名)另計
|
||||
if len(fileResults) != 1 || fileResults[0].Status != "failed" {
|
||||
t.Fatalf("results=%+v", fileResults)
|
||||
}
|
||||
// t182:這裡是「選了 Gemini 卻沒有金鑰」⇒ 停在萃取層、誠實報缺什麼。
|
||||
// 本測真正要守的契約沒變、也仍然綠:**PDF 不得被直送上雲**(上面的 serverCalled)。
|
||||
if !strings.Contains(results[0].Error, "gemini_api_key") {
|
||||
t.Errorf("錯誤訊息不符:%q", results[0].Error)
|
||||
// 本測真正要守的契約沒變、也仍然綠:**PDF 不得被直送上雲**(上面的 leaked)。
|
||||
if !strings.Contains(fileResults[0].Error, "gemini_api_key") {
|
||||
t.Errorf("錯誤訊息不符:%q", fileResults[0].Error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user