diff --git a/cmd/arcrun-tray/main.go b/cmd/arcrun-tray/main.go index 22d5508..8649b28 100644 --- a/cmd/arcrun-tray/main.go +++ b/cmd/arcrun-tray/main.go @@ -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 萃取狀態可見性 ────────────────────────────────────────────────────────── diff --git a/cmd/arcrun-tray/main_test.go b/cmd/arcrun-tray/main_test.go index 7d9afa5..0d4edc8 100644 --- a/cmd/arcrun-tray/main_test.go +++ b/cmd/arcrun-tray/main_test.go @@ -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) + } +} diff --git a/direct.go b/direct.go index 7487368..493aeba 100644 --- a/direct.go +++ b/direct.go @@ -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, diff --git a/direct_extract_test.go b/direct_extract_test.go index a502ffc..3816c0a 100644 --- a/direct_extract_test.go +++ b/direct_extract_test.go @@ -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) + } +}