443 lines
19 KiB
Go
443 lines
19 KiB
Go
// direct_extract_test.go — task 6:extractor 模式端到端(本地萃卡→POST rag_ingest_card)。
|
||
package collector
|
||
|
||
import (
|
||
"encoding/json"
|
||
"io"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// cardFixture 組出一份合格的萃取 JSON(InkStoneCo#44 ④:wikiExtractPrompt 的契約改為
|
||
// 「文件總覽+N 個原子概念」的 JSON,卡片格式由 wikishape.go 機械組裝)。
|
||
// 概念名固定為「<subject>·概念」——避免與各測試原稿的 H1/頁名撞名。
|
||
func cardFixture(subject, object string) string {
|
||
concept := subject + "·概念"
|
||
return `{"gloss":"` + subject + `的測試用一句話","tags":["測試"],` +
|
||
`"summary":"這是測試用的文件摘要,交代 ` + subject + ` 與 ` + object + ` 的關係。",` +
|
||
`"points":["本文的核心判斷落在 [[` + concept + `]] 上,其餘是背景"],` +
|
||
`"no_concept":false,"reason":"",` +
|
||
`"concepts":[{"name":"` + concept + `","gloss":"一句話說明這個概念",` +
|
||
`"tags":["測試"],"summary":"概念層的摘要,說明它離開原稿也能獨立成立。",` +
|
||
`"points":["第一個判斷句含具體條件"],` +
|
||
`"entities":[{"name":"` + subject + `","type":"概念","desc":"測試主體"},` +
|
||
`{"name":"` + object + `","type":"組織","desc":"測試客體"}],` +
|
||
`"facts":[["` + subject + `","屬於","` + object + `"]],` +
|
||
`"relations":[]}]}`
|
||
}
|
||
|
||
// gemmaCardStub 讓 Gemini 替身回傳一份萃取 JSON(t176 起產品只走 gemma 路,
|
||
// 測試也跟著走真實路徑——不再用 claude stub,否則測的是產品走不到的分支=假綠)。
|
||
func gemmaCardStub(t *testing.T, cardBody string) func() {
|
||
t.Helper()
|
||
return gemmaStub(t, func(w http.ResponseWriter, r *http.Request) {
|
||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||
"candidates": []map[string]any{{
|
||
"content": map[string]any{"parts": []map[string]any{{"text": cardBody}}},
|
||
}},
|
||
})
|
||
})
|
||
}
|
||
|
||
// 完整鏈(gemma 替身版):丟原稿 → 萃卡落地本地 → 只有「卡片」被 POST 到 rag_ingest_card
|
||
// → 原文從未離開本機 → manifest 標 ingested(下一輪不重送)。
|
||
func TestDirectExtractorModeE2E(t *testing.T) {
|
||
root := t.TempDir()
|
||
// H1=卡名(規範洞 1);機密哨兵放內文,驗「原文不出機」看的是內容不是標題。
|
||
if err := os.WriteFile(filepath.Join(root, "報銷規則.md"), []byte("# 報銷規則\n\n機密內容 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) {
|
||
// InkStoneCo#44:資料夾樹走 portal 登記端點(不是 workflow webhook),
|
||
// 它不是「卡片」也不含任何原文 ⇒ 不算進 posted,也不算打錯端點。
|
||
if strings.HasSuffix(r.URL.Path, "/portal/daemon/folder-tree") {
|
||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||
return
|
||
}
|
||
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()
|
||
|
||
// Gemini 替身:把原稿萃成卡(B2 合格四段卡,否則新增的品質 lint 會擋下——
|
||
// 本測試聚焦 ingest 路,非 lint,lint 自身測試見 lint_test.go)
|
||
defer gemmaCardStub(t, cardFixture("報銷規則", "財務"))()
|
||
|
||
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)
|
||
}
|
||
// 結構先行(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/`(InkStoneCo#44 ④:檔名=H1、文件卡+概念卡+索引齊備)
|
||
for _, rel := range []string{".wiki/報銷規則.md", ".wiki/報銷規則·概念.md", ".wiki/00-INDEX.md", ".wiki/manifest.json"} {
|
||
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(rel))); err != nil {
|
||
t.Fatalf("wiki 產物未落地 %s:%v", rel, err)
|
||
}
|
||
}
|
||
// 上雲的是「總覽卡+內容卡」兩張,都不含原文
|
||
if len(posted) != 2 {
|
||
t.Fatalf("應恰好 POST 兩張卡(總覽+內容),got %d", len(posted))
|
||
}
|
||
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, "## 摘要") || !strings.Contains(cc, "gloss:") {
|
||
t.Fatalf("card_content 不是規範形卡片:%.80s", cc)
|
||
}
|
||
// path 必須是「原檔路徑」(takedown 比對鍵+B4 溯源)——不是卡片路徑(07-24 第五枚坑)
|
||
if p, _ := contentCard["path"].(string); p != "報銷規則.md" {
|
||
t.Fatalf("path=%q(應為原檔路徑)", p)
|
||
}
|
||
// 🔴 arcrun-rag#60 第二輪:本機卡片檔名加了 arcrun- 前綴,但**上雲的 page_name 不准跟著變**。
|
||
// 下架分支用的是原稿頁名(見下一支測試斷言 takedown page_name=="報銷規則"),
|
||
// 這裡若跟著卡片檔名變成 "arcrun-報銷規則",兩邊就永遠對不上、刪原檔再也下架不掉。
|
||
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) != 2 {
|
||
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()
|
||
|
||
// Gemini 替身:萃卡落地(B2 合格四段卡,過品質 lint)
|
||
defer gemmaCardStub(t, cardFixture("報銷規則", "財務"))()
|
||
|
||
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", 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, ".wiki", "原稿.md") // 原稿內容「# 原稿」⇒ H1=卡名
|
||
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)
|
||
}
|
||
// 結構先行:刪檔輪總覽卡也會更新(清單不該還列著剛刪的檔),檔案事件另計
|
||
_, 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))
|
||
}
|
||
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()
|
||
defer gemmaCardStub(t, cardFixture("a", "b"))()
|
||
cfg := &DirectConfig{
|
||
WatchFolders: []string{root},
|
||
Manifest: filepath.Join(t.TempDir(), "m.json"),
|
||
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
|
||
Extractor: "gemma", ExtractorExplicit: true, GeminiAPIKey: "k-test",
|
||
CardIngestWF: "rag_ingest_card", RemovedWF: "rag_takedown_direct",
|
||
MaxRemoved: 1.0,
|
||
}
|
||
if _, exit, _ := RunDirectOnce(cfg, false); exit != 0 {
|
||
t.Fatal("第一輪失敗")
|
||
}
|
||
// 模擬用戶已手動清走本地卡 → removed 分支「存在才刪」不應報錯或多出 warning
|
||
// (原稿內容 "x" 無 H1 ⇒ 文件卡名 fallback=檔名 "a")
|
||
if err := os.Remove(filepath.Join(root, ".wiki", "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)
|
||
_, fileResults := splitInventory(results) // 結構先行:總覽卡另計
|
||
if exit != 0 || len(fileResults) != 1 || fileResults[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)
|
||
}
|
||
// Gemini 替身回 500=萃取失敗(真實失敗模式:模型端出錯)
|
||
defer gemmaStub(t, func(w http.ResponseWriter, r *http.Request) {
|
||
w.WriteHeader(http.StatusInternalServerError)
|
||
_, _ = w.Write([]byte(`{"error":"boom"}`))
|
||
})()
|
||
cfg := &DirectConfig{
|
||
WatchFolders: []string{root},
|
||
Manifest: filepath.Join(t.TempDir(), "m.json"),
|
||
CypherURL: "https://x.example", Namespace: "demo", APIKey: "demo",
|
||
Extractor: "gemma", ExtractorExplicit: true, GeminiAPIKey: "k-test", MaxRemoved: DefaultMaxRemovedRatio,
|
||
}
|
||
results, exit, _ := RunDirectOnce(cfg, false)
|
||
_, 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=會重試);
|
||
// 總覽卡則在自己的失敗退避窗口內,不重撞
|
||
results2, _, _ := RunDirectOnce(cfg, false)
|
||
if inv2, fileResults2 := splitInventory(results2); len(fileResults2) != 1 || len(inv2) != 0 {
|
||
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)
|
||
}
|
||
|
||
// Gemini 替身:輸出一張最簡卡片
|
||
defer gemmaCardStub(t, cardFixture("doc", "kb"))()
|
||
|
||
var hitCard, hitDirect bool
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
// 🔴 這裡以前寫的是 `else { hitDirect = true }`——「不是卡片端點就當成直送端點」。
|
||
// InkStoneCo#44 加了第三個端點(資料夾樹)之後,那個 else 就開始說謊。
|
||
// 改成指名要驗的那條路:本測要守的契約是「**原文不出機**」=不准打 rag_ingest_direct。
|
||
if strings.Contains(r.URL.Path, "rag_ingest_card") {
|
||
hitCard = true
|
||
} else if strings.Contains(r.URL.Path, "rag_ingest_direct") {
|
||
hitDirect = true
|
||
}
|
||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||
}))
|
||
defer srv.Close()
|
||
|
||
// 機器層有 Extractor+GeminiAPIKey;帳號層 AccountConfig 無這些欄位(正是 t108 場景)
|
||
cfg := &DirectConfig{
|
||
Manifest: filepath.Join(t.TempDir(), "m.json"),
|
||
Library: "kb",
|
||
Extractor: "gemma", ExtractorExplicit: true,
|
||
GeminiAPIKey: "k-test",
|
||
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=gemma 應被帳號繼承)")
|
||
}
|
||
}
|
||
|
||
// t108 Test C:extractor 空時,非 .md/.txt 檔禁止直送——標 failed 且絕不打任何 ingest 端點。
|
||
//
|
||
// 🔴 t182 更新(leo 08-04 起 workers-ai 成為預設):`extractor:""` 已**不再**代表
|
||
// 「舊制直送」——LoadDirectConfig/RunDirectOnce 會把它正規化成 workers-ai。
|
||
// 要測「舊制直送模式擋二進位」,必須把 config 逼進那條路:這裡用 ExtractorExplicit
|
||
// 明示、且不給任何可用引擎,才是真正的「無萃取器」狀態。
|
||
//
|
||
// ⚠️ 本測試守的契約沒變、也不准放寬:**原始二進位永遠不出用戶的電腦**。
|
||
// workers-ai 路一樣守——它送的是 ConvertToText 之後的純文字(extract_workersai.go),
|
||
// 不是 PDF 位元組本身。
|
||
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)
|
||
}
|
||
|
||
// 結構先行後,總覽卡(只含檔名、零原文)照常會 POST 到 rag_ingest_card——
|
||
// 本測試守的契約是「PDF 位元組/原文不出機」,改成逐請求驗內容與端點。
|
||
var ingestDirectCalled bool
|
||
var leaked bool
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
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()
|
||
|
||
cfg := &DirectConfig{
|
||
WatchFolders: []string{root},
|
||
Manifest: filepath.Join(t.TempDir(), "m.json"),
|
||
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
|
||
Library: "kb",
|
||
// t182:明示「使用者選過、但沒有可用引擎」=真正的無萃取器狀態。
|
||
// (不能只寫 Extractor:""——那現在會被正規化成 workers-ai,測不到這條防禦閘。)
|
||
Extractor: "gemma", ExtractorExplicit: true, GeminiAPIKey: "",
|
||
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 ingestDirectCalled {
|
||
t.Error("防禦閘失效:走了舊制直送端點(契約破壞)")
|
||
}
|
||
if leaked {
|
||
t.Error("防禦閘失效:PDF 位元組/原文被送上雲(契約破壞)")
|
||
}
|
||
_, fileResults := splitInventory(results) // 結構先行:總覽卡(只含檔名)另計
|
||
if len(fileResults) != 1 || fileResults[0].Status != "failed" {
|
||
t.Fatalf("results=%+v", fileResults)
|
||
}
|
||
// t182:這裡是「選了 Gemini 卻沒有金鑰」⇒ 停在萃取層、誠實報缺什麼。
|
||
// 本測真正要守的契約沒變、也仍然綠:**PDF 不得被直送上雲**(上面的 leaked)。
|
||
if !strings.Contains(fileResults[0].Error, "gemini_api_key") {
|
||
t.Errorf("錯誤訊息不符:%q", fileResults[0].Error)
|
||
}
|
||
}
|
||
|
||
// ── t181:預設一律走 Workers AI(免金鑰)──────────────────────────────────────
|
||
//
|
||
// leo 2026-08-04 特別交代(這是本測存在的理由):
|
||
//
|
||
// 「default 用 Workers AI,你要用 Gemini 要**特別去選取**,**不管你現在是否有填金鑰**」
|
||
// 「只要更新版本,就已經 default workers AI 了,除非去一個地方切換你指定的 AI 來源」
|
||
// 「不然我會有很多質疑,**花在解釋為什麼 Gemini 不管用上**」
|
||
//
|
||
// ⇒ 判準是 ExtractorExplicit(使用者主動選過),**不是**「有沒有金鑰」。
|
||
func TestT181DefaultsToWorkersAI(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
extractor string
|
||
explicit bool
|
||
key string
|
||
want string
|
||
}{
|
||
{"新用戶(什麼都沒設)", "", false, "", "workers-ai"},
|
||
{"舊 config 有 gemma 但沒主動選", "gemma", false, "", "workers-ai"},
|
||
// 🔴 最關鍵的一則:**有金鑰也照樣先走 Workers AI**
|
||
{"有金鑰但沒主動選=仍走雲端 AI", "gemma", false, "AIza-xxx", "workers-ai"},
|
||
{"殘留 claude 且沒主動選", "claude", false, "", "workers-ai"},
|
||
// 主動選過才尊重他的選擇
|
||
{"主動選了 Gemini", "gemma", true, "AIza-xxx", "gemma"},
|
||
{"主動選了雲端 AI", "workers-ai", true, "", "workers-ai"},
|
||
}
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
cfg := &DirectConfig{
|
||
WatchFolders: []string{t.TempDir()},
|
||
Manifest: filepath.Join(t.TempDir(), "m.json"),
|
||
CypherURL: "https://unused.example",
|
||
Namespace: "demo",
|
||
APIKey: "demo",
|
||
Extractor: c.extractor,
|
||
ExtractorExplicit: c.explicit,
|
||
GeminiAPIKey: c.key,
|
||
MaxRemoved: DefaultMaxRemovedRatio,
|
||
}
|
||
RunDirectOnce(cfg, false) // 空資料夾=零事件,只看預設邏輯把 Extractor 定成什麼
|
||
if cfg.Extractor != c.want {
|
||
t.Errorf("Extractor=%q want=%q(explicit=%v key=%q)",
|
||
cfg.Extractor, c.want, c.explicit, c.key)
|
||
}
|
||
})
|
||
}
|
||
}
|