fix(t108 🔴🔴): 逐帳號同步遺失萃取設定→原文直送——三層修+契約保險絲
事故(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=總管)
This commit is contained in:
+10
-7
@@ -83,13 +83,16 @@ type directConfig struct {
|
||||
Email string `json:"email,omitempty"`
|
||||
InstanceName string `json:"instance_name,omitempty"`
|
||||
Library string `json:"library,omitempty"`
|
||||
Extractor string `json:"extractor,omitempty"` // 機器層級:claude/gemma
|
||||
ClaudeBin string `json:"claude_bin,omitempty"` // t92:collector fallback 找到後回寫
|
||||
Libraries map[string]string `json:"libraries,omitempty"` // t52 舊制:資料夾→庫對映
|
||||
IngestWF string `json:"ingest_workflow,omitempty"`
|
||||
RemovedWF string `json:"removed_workflow,omitempty"`
|
||||
PollSec int `json:"poll_interval_sec,omitempty"`
|
||||
MaxRemoved float64 `json:"max_removed_ratio,omitempty"`
|
||||
Extractor string `json:"extractor,omitempty"` // 機器層級:claude/gemma
|
||||
ClaudeBin string `json:"claude_bin,omitempty"` // t92:collector fallback 找到後回寫
|
||||
GeminiAPIKey string `json:"gemini_api_key,omitempty"` // t108:gemma 路 key
|
||||
LLMModel string `json:"llm_model,omitempty"` // gemma 路模型
|
||||
CardIngestWF string `json:"card_ingest_workflow,omitempty"` // 收卡 workflow
|
||||
Libraries map[string]string `json:"libraries,omitempty"` // t52 舊制:資料夾→庫對映
|
||||
IngestWF string `json:"ingest_workflow,omitempty"`
|
||||
RemovedWF string `json:"removed_workflow,omitempty"`
|
||||
PollSec int `json:"poll_interval_sec,omitempty"`
|
||||
MaxRemoved float64 `json:"max_removed_ratio,omitempty"`
|
||||
}
|
||||
|
||||
// ── t91 萃取狀態可見性 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -441,3 +441,65 @@ func TestDirectConfigFolders_MultiAccount(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// t108:directConfig JSON round-trip 必須保留 GeminiAPIKey/LLMModel/CardIngestWF——
|
||||
// 這三欄是 t104 前缺失的;若托盤 loadConfig→saveConfig 吃掉它們,collector 讀到空值就走直送路。
|
||||
func TestDirectConfigGeminiFieldsPreserved(t *testing.T) {
|
||||
original := &directConfig{
|
||||
Extractor: "gemma",
|
||||
GeminiAPIKey: "AIzaSy-test-key",
|
||||
LLMModel: "gemma-4-31b-it",
|
||||
CardIngestWF: "rag_ingest_card",
|
||||
}
|
||||
data, err := json.Marshal(original)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal 失敗:%v", err)
|
||||
}
|
||||
var back directConfig
|
||||
if err := json.Unmarshal(data, &back); err != nil {
|
||||
t.Fatalf("unmarshal 失敗:%v", err)
|
||||
}
|
||||
if back.GeminiAPIKey != original.GeminiAPIKey {
|
||||
t.Errorf("GeminiAPIKey 消失:got %q", back.GeminiAPIKey)
|
||||
}
|
||||
if back.LLMModel != original.LLMModel {
|
||||
t.Errorf("LLMModel 消失:got %q", back.LLMModel)
|
||||
}
|
||||
if back.CardIngestWF != original.CardIngestWF {
|
||||
t.Errorf("CardIngestWF 消失:got %q", back.CardIngestWF)
|
||||
}
|
||||
}
|
||||
|
||||
// t108:addOrUpdateAccount 後機器層 GeminiAPIKey 仍存在(遷移/新增帳號不吃掉機器層欄位)。
|
||||
func TestAddOrUpdateAccountPreservesMachineLayer(t *testing.T) {
|
||||
cfg := &directConfig{
|
||||
Extractor: "gemma",
|
||||
GeminiAPIKey: "AIzaSy-existing-key",
|
||||
LLMModel: "gemma-4-31b-it",
|
||||
CardIngestWF: "rag_ingest_card",
|
||||
}
|
||||
resp := &daemonConfigResp{
|
||||
Success: true,
|
||||
Config: struct {
|
||||
CypherURL string `json:"cypher_url"`
|
||||
Namespace string `json:"namespace"`
|
||||
Library string `json:"library"`
|
||||
Extractor string `json:"extractor"`
|
||||
Email string `json:"email"`
|
||||
InstanceName string `json:"instance_name"`
|
||||
}{
|
||||
CypherURL: "https://new.workers.dev",
|
||||
Namespace: "newns",
|
||||
},
|
||||
}
|
||||
addOrUpdateAccount(cfg, resp)
|
||||
if cfg.GeminiAPIKey != "AIzaSy-existing-key" {
|
||||
t.Errorf("GeminiAPIKey 被清空:got %q", cfg.GeminiAPIKey)
|
||||
}
|
||||
if cfg.LLMModel != "gemma-4-31b-it" {
|
||||
t.Errorf("LLMModel 被清空:got %q", cfg.LLMModel)
|
||||
}
|
||||
if cfg.CardIngestWF != "rag_ingest_card" {
|
||||
t.Errorf("CardIngestWF 被清空:got %q", cfg.CardIngestWF)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -648,6 +648,15 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool) ([]DirectRes
|
||||
results = append(results, res)
|
||||
continue
|
||||
}
|
||||
// t108 防禦閘:extractor 未設定時,非 .md/.txt 檔禁止直送原文(原文外洩保險絲)。
|
||||
// 讓同類 bug 永遠不再變成內容外洩,而是明確的 failed 狀態。
|
||||
if ext := strings.ToLower(filepath.Ext(ev.Path)); ext != ".md" && ext != ".txt" {
|
||||
res.Status = "failed"
|
||||
res.Error = "萃取器未設定,已跳過(不直送原文)"
|
||||
results = append(results, res)
|
||||
exit = 1
|
||||
continue
|
||||
}
|
||||
status, _, perr := cfg.postJSON(cfg.triggerURL(cfg.IngestWF), map[string]any{
|
||||
"page_name": pageNameOf(ev.Path),
|
||||
"path": ev.Path,
|
||||
|
||||
@@ -219,3 +219,98 @@ func TestDirectExtractorFailKeepsRetry(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user