1738acaca7
事故(leo 機 07-28 17:3x 總管現場抓到):t104 config 重寫吃掉機器層 extractor/key、 per-account DirectConfig 不繼承 ⇒ extractor 空 ⇒ 6 筆走 rag_ingest_direct 原文出機。 修:①config 讀改寫全程保留既有欄位(帶測試)②帳號層無值垂直繼承機器層 extractor/claude_bin/gemini_api_key/llm_model/CardIngestWF/RemovedWF(帶 fake server 測試: 必須收到 rag_ingest_card 非 direct)③契約保險絲:extractor 空時非 .md/.txt 禁直送、 標 failed「萃取器未設定,已跳過(不直送原文)」——同類 bug 永不再成外洩。 三模組 go test 全綠(總管親跑)。(實作=子 CC;驗證+commit=總管)
317 lines
12 KiB
Go
317 lines
12 KiB
Go
// direct_extract_test.go — task 6:extractor 模式端到端(本地萃卡→POST rag_ingest_card)。
|
||
package main
|
||
|
||
import (
|
||
"encoding/json"
|
||
"io"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// 完整鏈(claude stub 版):丟原稿 → 萃卡落地本地 → 只有「卡片」被 POST 到 rag_ingest_card
|
||
// → 原文從未離開本機 → manifest 標 ingested(下一輪不重送)。
|
||
func TestDirectExtractorModeE2E(t *testing.T) {
|
||
root := t.TempDir()
|
||
if err := os.WriteFile(filepath.Join(root, "報銷規則.md"), []byte("# 原稿機密內容 XYZZY"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
// 假 cypher:收 rag_ingest_card、驗 payload、記帳
|
||
var posted []map[string]any
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if !strings.HasSuffix(r.URL.Path, "/webhooks/named/demo/rag_ingest_card/trigger") {
|
||
t.Errorf("打錯端點:%s", r.URL.Path)
|
||
}
|
||
body, _ := io.ReadAll(r.Body)
|
||
var m map[string]any
|
||
_ = json.Unmarshal(body, &m)
|
||
posted = append(posted, m)
|
||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||
}))
|
||
defer srv.Close()
|
||
|
||
// stub claude:把原稿萃成卡(模擬 /wiki-capture 行為)
|
||
stubDir := t.TempDir()
|
||
stub := filepath.Join(stubDir, "claude")
|
||
script := "#!/bin/sh\nmkdir -p system-dev/wiki/cards\nprintf '# 報銷規則\\n## 一句話定義\\n測試卡\\n## 關聯\\n- 報銷規則 >> 屬於 >> 財務\\n' > 'system-dev/wiki/cards/報銷規則.md'\n"
|
||
if err := os.WriteFile(stub, []byte(script), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
cfg := &DirectConfig{
|
||
WatchFolders: []string{root},
|
||
Manifest: filepath.Join(t.TempDir(), "m.json"),
|
||
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
|
||
Library: "kb", Extractor: "claude", ClaudeBin: stub,
|
||
CardIngestWF: "rag_ingest_card", MaxRemoved: DefaultMaxRemovedRatio,
|
||
}
|
||
results, exit, _ := RunDirectOnce(cfg, false)
|
||
if exit != 0 {
|
||
t.Fatalf("exit=%d results=%+v", exit, results)
|
||
}
|
||
if len(results) != 1 || results[0].Status != "ingested" {
|
||
t.Fatalf("results=%+v", results)
|
||
}
|
||
// 卡片落地本地(用戶看得到自己的 wiki)
|
||
if _, err := os.Stat(filepath.Join(root, "system-dev", "wiki", "cards", "報銷規則.md")); err != nil {
|
||
t.Fatalf("卡片未落地:%v", err)
|
||
}
|
||
// 上雲的是卡片、不是原文
|
||
if len(posted) != 1 {
|
||
t.Fatalf("應恰好 POST 一張卡,got %d", len(posted))
|
||
}
|
||
cc, _ := posted[0]["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" {
|
||
t.Fatalf("path=%q(應為原檔路徑)", p)
|
||
}
|
||
// 第二輪:原稿沒變 → 不重萃不重送
|
||
results2, exit2, _ := RunDirectOnce(cfg, false)
|
||
if exit2 != 0 || len(results2) != 0 || len(posted) != 1 {
|
||
t.Fatalf("第二輪應零事件:results=%+v posted=%d", results2, len(posted))
|
||
}
|
||
}
|
||
|
||
// t15:extractor 模式刪原檔 → 雲端 takedown 成功後,本地萃出的卡也要被清掉。
|
||
func TestDirectExtractorRemovedClearsLocalCard(t *testing.T) {
|
||
root := t.TempDir()
|
||
if err := os.WriteFile(filepath.Join(root, "報銷規則.md"), []byte("# 原稿"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
// 假 cypher:收 rag_ingest_card 與 rag_takedown_direct
|
||
var takedowns []map[string]any
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if strings.HasSuffix(r.URL.Path, "/webhooks/named/demo/rag_takedown_direct/trigger") {
|
||
body, _ := io.ReadAll(r.Body)
|
||
var m map[string]any
|
||
_ = json.Unmarshal(body, &m)
|
||
takedowns = append(takedowns, m)
|
||
}
|
||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||
}))
|
||
defer srv.Close()
|
||
|
||
// stub claude:萃卡落地
|
||
stubDir := t.TempDir()
|
||
stub := filepath.Join(stubDir, "claude")
|
||
script := "#!/bin/sh\nmkdir -p system-dev/wiki/cards\nprintf '# 報銷規則\\n## 一句話定義\\n測試卡\\n' > 'system-dev/wiki/cards/報銷規則.md'\n"
|
||
if err := os.WriteFile(stub, []byte(script), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
cfg := &DirectConfig{
|
||
WatchFolders: []string{root},
|
||
Manifest: filepath.Join(t.TempDir(), "m.json"),
|
||
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
|
||
Library: "kb", Extractor: "claude", ClaudeBin: stub,
|
||
CardIngestWF: "rag_ingest_card", RemovedWF: "rag_takedown_direct",
|
||
// 單檔刪除=removed ratio 100%,預設 0.4 防呆會壓下事件;本測試聚焦下架路,放寬到 1.0
|
||
//(1 > 1.0×1 為 false → 事件放行)。
|
||
MaxRemoved: 1.0,
|
||
}
|
||
|
||
// 第一輪:萃卡+上雲,本地卡存在
|
||
if _, exit, _ := RunDirectOnce(cfg, false); exit != 0 {
|
||
t.Fatalf("第一輪 ingest 失敗 exit=%d", exit)
|
||
}
|
||
cardPath := filepath.Join(root, "system-dev", "wiki", "cards", "報銷規則.md")
|
||
if _, err := os.Stat(cardPath); err != nil {
|
||
t.Fatalf("前置失敗:卡片未落地 %v", err)
|
||
}
|
||
|
||
// 刪原檔 → 第二輪:takedown 打出去、本地卡也被清
|
||
if err := os.Remove(filepath.Join(root, "報銷規則.md")); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
results, exit, _ := RunDirectOnce(cfg, false)
|
||
if exit != 0 {
|
||
t.Fatalf("第二輪 exit=%d results=%+v", exit, results)
|
||
}
|
||
if len(results) != 1 || results[0].Status != "removed" {
|
||
t.Fatalf("results=%+v", results)
|
||
}
|
||
if len(takedowns) != 1 {
|
||
t.Fatalf("應恰好一次 takedown,got %d", len(takedowns))
|
||
}
|
||
if pn, _ := takedowns[0]["page_name"].(string); pn != "報銷規則" {
|
||
t.Fatalf("takedown page_name=%q", pn)
|
||
}
|
||
if _, err := os.Stat(cardPath); !os.IsNotExist(err) {
|
||
t.Fatalf("本地卡應已被清(err=%v)", err)
|
||
}
|
||
}
|
||
|
||
// t15:本地卡不存在時(存在才刪)下架照常成功,不多出 warning。
|
||
func TestDirectExtractorRemovedNoLocalCardOK(t *testing.T) {
|
||
root := t.TempDir()
|
||
if err := os.WriteFile(filepath.Join(root, "a.md"), []byte("x"), 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()
|
||
stubDir := t.TempDir()
|
||
stub := filepath.Join(stubDir, "claude")
|
||
script := "#!/bin/sh\nmkdir -p system-dev/wiki/cards\nprintf '# a\\n## 一句話定義\\n卡\\n' > system-dev/wiki/cards/a.md\n"
|
||
if err := os.WriteFile(stub, []byte(script), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
cfg := &DirectConfig{
|
||
WatchFolders: []string{root},
|
||
Manifest: filepath.Join(t.TempDir(), "m.json"),
|
||
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
|
||
Extractor: "claude", ClaudeBin: stub,
|
||
CardIngestWF: "rag_ingest_card", RemovedWF: "rag_takedown_direct",
|
||
MaxRemoved: 1.0,
|
||
}
|
||
if _, exit, _ := RunDirectOnce(cfg, false); exit != 0 {
|
||
t.Fatal("第一輪失敗")
|
||
}
|
||
// 模擬用戶已手動清走本地卡 → removed 分支「存在才刪」不應報錯或多出 warning
|
||
if err := os.Remove(filepath.Join(root, "system-dev", "wiki", "cards", "a.md")); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.Remove(filepath.Join(root, "a.md")); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
results, exit, _ := RunDirectOnce(cfg, false)
|
||
if exit != 0 || len(results) != 1 || results[0].Status != "removed" {
|
||
t.Fatalf("exit=%d results=%+v", exit, results)
|
||
}
|
||
}
|
||
|
||
// 萃取失敗=該檔標 failed、exit=1、manifest 不標(下輪重試),其他檔不受影響。
|
||
func TestDirectExtractorFailKeepsRetry(t *testing.T) {
|
||
root := t.TempDir()
|
||
if err := os.WriteFile(filepath.Join(root, "a.md"), []byte("x"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
stubDir := t.TempDir()
|
||
stub := filepath.Join(stubDir, "claude")
|
||
if err := os.WriteFile(stub, []byte("#!/bin/sh\nexit 3\n"), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
cfg := &DirectConfig{
|
||
WatchFolders: []string{root},
|
||
Manifest: filepath.Join(t.TempDir(), "m.json"),
|
||
CypherURL: "https://x.example", Namespace: "demo", APIKey: "demo",
|
||
Extractor: "claude", ClaudeBin: stub, MaxRemoved: DefaultMaxRemovedRatio,
|
||
}
|
||
results, exit, _ := RunDirectOnce(cfg, false)
|
||
if exit != 1 || len(results) != 1 || results[0].Status != "failed" {
|
||
t.Fatalf("exit=%d results=%+v", exit, results)
|
||
}
|
||
// 再跑一輪:仍是同一個事件(manifest 沒標 ingested=會重試)
|
||
results2, _, _ := RunDirectOnce(cfg, false)
|
||
if len(results2) != 1 {
|
||
t.Fatalf("失敗檔應重試:%+v", results2)
|
||
}
|
||
}
|
||
|
||
// t108 Test B:makeAccountSubConfig 必須繼承機器層 Extractor/GeminiAPIKey/CardIngestWF 等,
|
||
// 帳號層(AccountConfig)無這些欄位時一律繼承機器層——驗收到 rag_ingest_card 而非 rag_ingest_direct。
|
||
func TestMultiAccountInheritsExtractor(t *testing.T) {
|
||
root := t.TempDir()
|
||
if err := os.WriteFile(filepath.Join(root, "doc.md"), []byte("# 知識"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
// stub claude:輸出一張最簡卡片
|
||
stubDir := t.TempDir()
|
||
stub := filepath.Join(stubDir, "claude")
|
||
script := "#!/bin/sh\nmkdir -p system-dev/wiki/cards\nprintf '# doc\\n## 定義\\n測試\\n## 關聯\\n- doc >> 屬於 >> kb\\n' > 'system-dev/wiki/cards/doc.md'\n"
|
||
if err := os.WriteFile(stub, []byte(script), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
var hitCard, hitDirect bool
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if strings.Contains(r.URL.Path, "rag_ingest_card") {
|
||
hitCard = true
|
||
} else {
|
||
hitDirect = true
|
||
}
|
||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||
}))
|
||
defer srv.Close()
|
||
|
||
// 機器層有 Extractor+ClaudeBin;帳號層 AccountConfig 無這些欄位(正是 t108 場景)
|
||
cfg := &DirectConfig{
|
||
Manifest: filepath.Join(t.TempDir(), "m.json"),
|
||
Library: "kb",
|
||
Extractor: "claude",
|
||
ClaudeBin: stub,
|
||
CardIngestWF: "rag_ingest_card",
|
||
IngestWF: "rag_ingest_direct",
|
||
RemovedWF: "rag_takedown_direct",
|
||
MaxRemoved: DefaultMaxRemovedRatio,
|
||
Accounts: []AccountConfig{{
|
||
CypherURL: srv.URL,
|
||
Namespace: "demo",
|
||
APIKey: "demo",
|
||
WatchFolders: []string{root},
|
||
}},
|
||
}
|
||
results, exit, _ := RunDirectOnce(cfg, false)
|
||
if exit != 0 {
|
||
t.Fatalf("exit=%d results=%+v", exit, results)
|
||
}
|
||
if hitDirect {
|
||
t.Error("不應打 rag_ingest_direct(原文不出機,違反四步定稿)")
|
||
}
|
||
if !hitCard {
|
||
t.Error("應打 rag_ingest_card(機器層 extractor=claude 應被帳號繼承)")
|
||
}
|
||
}
|
||
|
||
// t108 Test C:extractor 空時,非 .md/.txt 檔禁止直送——標 failed 且絕不打任何 ingest 端點。
|
||
func TestExtractorEmptyBlocksNonTextDirect(t *testing.T) {
|
||
root := t.TempDir()
|
||
if err := os.WriteFile(filepath.Join(root, "report.pdf"), []byte("%PDF-1.4 機密原文"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
var serverCalled bool
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
serverCalled = true
|
||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||
}))
|
||
defer srv.Close()
|
||
|
||
cfg := &DirectConfig{
|
||
WatchFolders: []string{root},
|
||
Manifest: filepath.Join(t.TempDir(), "m.json"),
|
||
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
|
||
Library: "kb",
|
||
Extractor: "", // 舊制直送模式
|
||
IngestWF: "rag_ingest_direct",
|
||
RemovedWF: "rag_takedown_direct",
|
||
MaxRemoved: DefaultMaxRemovedRatio,
|
||
}
|
||
results, exit, _ := RunDirectOnce(cfg, false)
|
||
if exit != 1 {
|
||
t.Fatalf("exit=%d,應是 1(非文字檔無萃取器=失敗)", exit)
|
||
}
|
||
if serverCalled {
|
||
t.Error("防禦閘失效:PDF 被直送上雲(契約破壞)")
|
||
}
|
||
if len(results) != 1 || results[0].Status != "failed" {
|
||
t.Fatalf("results=%+v", results)
|
||
}
|
||
if !strings.Contains(results[0].Error, "萃取器未設定") {
|
||
t.Errorf("錯誤訊息不符:%q", results[0].Error)
|
||
}
|
||
}
|