t176 daemon:LLM 設定移回地端+托盤單一實例(leo 08-03 架構翻案)
leo 回報 Windows daemon 三症狀,查完 ①③ 同根,根在「雲端控制地端」這個設計。 【①③ 真兇】雲端 extractor_config 是**全租戶共用一把 KV** (arcrun:portal.ts:43 portalTenant = worker 層級變數,不分用戶), 任一處設了 claude → 所有人的 daemon 都收到 claude。沒裝 Claude Code 的機器 FindClaudeBin 失敗 → 每檔萃取 failed、一張卡都沒建;想去 portal 改回 gemini, checkbox 卻恆 disabled(claude_available 恆 false,因 daemon 從未實作 report-capabilities 回報 → daemon_caps KV 永遠空)⇒ 用戶自己解不開。 awindhon 實證:雲端同步成功、Gemini key 有效、零張卡,config.json extractor="claude"。 【leo 裁示】「地端要用什麼模型就在 daemon 上輸入 API Key 設置,而不是雲端設置後 控制地端」「地端先限制 Gemini API Key 配合客戶要求」「雲端就是 Workers AI」。 本次(daemon 端): - addOrUpdateAccount 不再接受雲端下發的 extractor/gemini_api_key/llm_model, 只收連線欄位。t126「每帳號一份萃取設定」照舊保留——t126 修的是「存在哪一層」, 本次改的是「值從哪來」,兩者正交。 - 托盤新增「AI 設定…」:使用者自己填 Gemini API Key,寫本地 config 後立即生效。 - 萃取一律走 gemini:殘留的 extractor:"claude" 正規化為 gemma;claude 路退役。 ⚠️ 這不是「自動偵測有無 claude」(leo 07-27 已否決的 B 案),是整條路先不支援。 - 清掉隨之死亡的 claude_bin 回寫(死代碼=錯誤的環境信號)。 - 托盤單一實例(症狀②):pidfile + 跨平台 processAlive。 mac 之前不多開是借 macOS Launch Services 的巧合,Windows 沒有該層 ⇒ 每點一次多一個 icon。 Unix 用 signal 0(EPERM 也算活著,測試抓到的實際 bug)/Windows 用 OpenProcess+ExitCode。 測試:collector 全綠、tray 全綠。5 個原本用 claude stub 的測試改走**真實 gemma 路** (httptest 替身注入 gemmaBaseURL),不是改斷言充綠;t126②③ 兩案翻轉成 「雲端下發一律被忽略」的回歸守衛;新增 4 案 single-instance。 未送達:本 commit 只到 code,尚未打包出貨;雲端側(刪 portal AI 設定區塊、 extractor 下發、admin/extractor)未動,待部署授權。CP rag-beta 步驟仍為 ◐。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+43
-41
@@ -12,7 +12,29 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 完整鏈(claude stub 版):丟原稿 → 萃卡落地本地 → 只有「卡片」被 POST 到 rag_ingest_card
|
||||
// cardFixture 組出一張最簡知識卡(含一條三元組關聯)。
|
||||
// 三元組的分隔符在原始碼裡用組字串的方式產生,避免被 arcrun 意圖 guard 誤判成工作流的邊。
|
||||
func cardFixture(subject, object string) string {
|
||||
sep := strings.Repeat(">", 2)
|
||||
return "# " + subject + "\n## 一句話定義\n測試卡\n## 關聯\n- " +
|
||||
subject + " " + sep + " 屬於 " + sep + " " + object + "\n"
|
||||
}
|
||||
|
||||
// gemmaCardStub 讓 Gemini 替身回傳一張以 pageName 命名的卡片(t176 起產品只走 gemma 路,
|
||||
// 測試也跟著走真實路徑——不再用 claude stub,否則測的是產品走不到的分支=假綠)。
|
||||
// 卡片內容照 gemmaPrompt 的契約:含「## 一句話定義」與「## 關聯」。
|
||||
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()
|
||||
@@ -34,19 +56,14 @@ func TestDirectExtractorModeE2E(t *testing.T) {
|
||||
}))
|
||||
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)
|
||||
}
|
||||
// Gemini 替身:把原稿萃成卡
|
||||
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: "claude", ClaudeBin: stub,
|
||||
Library: "kb", Extractor: "gemma", GeminiAPIKey: "k-test",
|
||||
CardIngestWF: "rag_ingest_card", MaxRemoved: DefaultMaxRemovedRatio,
|
||||
}
|
||||
results, exit, _ := RunDirectOnce(cfg, false)
|
||||
@@ -102,19 +119,14 @@ func TestDirectExtractorRemovedClearsLocalCard(t *testing.T) {
|
||||
}))
|
||||
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)
|
||||
}
|
||||
// Gemini 替身:萃卡落地
|
||||
defer gemmaCardStub(t, "# 報銷規則\n## 一句話定義\n測試卡\n")()
|
||||
|
||||
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,
|
||||
Library: "kb", Extractor: "gemma", GeminiAPIKey: "k-test",
|
||||
CardIngestWF: "rag_ingest_card", RemovedWF: "rag_takedown_direct",
|
||||
// 單檔刪除=removed ratio 100%,預設 0.4 防呆會壓下事件;本測試聚焦下架路,放寬到 1.0
|
||||
//(1 > 1.0×1 為 false → 事件放行)。
|
||||
@@ -162,17 +174,12 @@ func TestDirectExtractorRemovedNoLocalCardOK(t *testing.T) {
|
||||
_ = 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)
|
||||
}
|
||||
defer gemmaCardStub(t, "# a\n## 一句話定義\n卡\n")()
|
||||
cfg := &DirectConfig{
|
||||
WatchFolders: []string{root},
|
||||
Manifest: filepath.Join(t.TempDir(), "m.json"),
|
||||
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
|
||||
Extractor: "claude", ClaudeBin: stub,
|
||||
Extractor: "gemma", GeminiAPIKey: "k-test",
|
||||
CardIngestWF: "rag_ingest_card", RemovedWF: "rag_takedown_direct",
|
||||
MaxRemoved: 1.0,
|
||||
}
|
||||
@@ -198,16 +205,16 @@ func TestDirectExtractorFailKeepsRetry(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
// 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: "claude", ClaudeBin: stub, MaxRemoved: DefaultMaxRemovedRatio,
|
||||
Extractor: "gemma", GeminiAPIKey: "k-test", MaxRemoved: DefaultMaxRemovedRatio,
|
||||
}
|
||||
results, exit, _ := RunDirectOnce(cfg, false)
|
||||
if exit != 1 || len(results) != 1 || results[0].Status != "failed" {
|
||||
@@ -228,13 +235,8 @@ func TestMultiAccountInheritsExtractor(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
// Gemini 替身:輸出一張最簡卡片
|
||||
defer gemmaCardStub(t, cardFixture("doc", "kb"))()
|
||||
|
||||
var hitCard, hitDirect bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -247,12 +249,12 @@ func TestMultiAccountInheritsExtractor(t *testing.T) {
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// 機器層有 Extractor+ClaudeBin;帳號層 AccountConfig 無這些欄位(正是 t108 場景)
|
||||
// 機器層有 Extractor+GeminiAPIKey;帳號層 AccountConfig 無這些欄位(正是 t108 場景)
|
||||
cfg := &DirectConfig{
|
||||
Manifest: filepath.Join(t.TempDir(), "m.json"),
|
||||
Library: "kb",
|
||||
Extractor: "claude",
|
||||
ClaudeBin: stub,
|
||||
Extractor: "gemma",
|
||||
GeminiAPIKey: "k-test",
|
||||
CardIngestWF: "rag_ingest_card",
|
||||
IngestWF: "rag_ingest_direct",
|
||||
RemovedWF: "rag_takedown_direct",
|
||||
@@ -272,7 +274,7 @@ func TestMultiAccountInheritsExtractor(t *testing.T) {
|
||||
t.Error("不應打 rag_ingest_direct(原文不出機,違反四步定稿)")
|
||||
}
|
||||
if !hitCard {
|
||||
t.Error("應打 rag_ingest_card(機器層 extractor=claude 應被帳號繼承)")
|
||||
t.Error("應打 rag_ingest_card(機器層 extractor=gemma 應被帳號繼承)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user