Files
arcrun-collector/direct_extract_test.go
T
Leo 133f5add46 t182:老 config 抹除成 Workers AI+雲端探測——leo 實測「無卡」的真因
leo 08-04 實測回報(更新 v0.15.5 後仍顯示 Gemini/丟 PDF 無卡),
查出來不是 Workers AI 不通,是**根本沒走到那條路**。三顆真 bug:

① 抹除只做一半(leo:「就要抹除改成用 Workers AI,如果保持 Gemini
   它不會改掉,那就是失敗的」)
   t181 只在 RunDirectOnce 改頂層記憶體值,但
   - makeAccountSubConfig 會把**帳號層**舊值蓋回來(t126「帳號層優先」)
   - 完全**沒寫回檔案** ⇒ 托盤是另一個行程,讀檔還是念 Gemini
   leo 的 config 正是頂層+兩個帳號各留 "gemma"、無 explicit
   ⇒ 畫面顯示 Gemini、萃取也真的跑 Gemini。
   修:遷移移到 LoadDirectConfig(每次啟動必經),逐層抹除 + 寫回檔案。
   金鑰保留(Gemini 是選配不是廢除)。

② 驗證器擋掉自己的新預設
   `extractor 只能是 claude / gemma` ⇒ config 一旦寫成 workers-ai,
   daemon 直接載入失敗起不來。v0.15.5 已帶著這顆出貨。

③ 托盤標籤照 config 舊字串念(t178 的通則版)
   t178 只把 claude 這**一個**殘留值導向 Gemini,殘留 gemma 一樣脫鉤。
   改成與 direct.go 同一條判準:沒 explicit 就一律念「雲端 AI」。

+ t182 雲端探測(leo 指定設計:「會去掃一次看雲端是否裝好,沒裝好就顯示
  workers AI 還沒通,一旦通了就顯示可用」):ProbeWorkersAI 逐帳號探
  /portal/daemon/extract(送空 text,不燒 LLM 額度),結果寫進 status.json
  由托盤「狀態:」講白話。**不做靜默退回 Gemini**——那會讓用戶永遠
  不知道自己雲端沒更新。

實測證據(非 mock,打真實例):
- youlin 實例萃真卡:3.67s,產出完整知識卡(一句話定義/要點/關鍵實體/關聯)
- 連測三次:3.51s / 3.34s / 3.59s(Gemini 實測 16.87s ⇒ 快約 5 倍)
- 兩個實例都已有 /portal/daemon/extract(皆回 400=route 存在)
- 拿 leo 真 config 複本跑遷移:三層全抹成 workers-ai、金鑰留著、重讀仍是 workers-ai

測試:新增 direct_t182_test.go(抹除全層/寫回磁碟/主動選過不動/合法值/冪等);
既有三則因預設變更而失效的斷言已更新(t108 二進位不出機的契約未放寬,仍綠)。
全套綠。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:29:56 +08:00

378 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// direct_extract_test.go — task 6extractor 模式端到端(本地萃卡→POST rag_ingest_card)。
package main
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// 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()
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()
// 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: "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)
}
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))
}
}
// t15extractor 模式刪原檔 → 雲端 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 替身:萃卡落地
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: "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, "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("應恰好一次 takedowngot %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, "# a\n## 一句話定義\n卡\n")()
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
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)
}
// 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)
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 BmakeAccountSubConfig 必須繼承機器層 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) {
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+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 Cextractor 空時,非 .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)
}
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",
// 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 serverCalled {
t.Error("防禦閘失效:PDF 被直送上雲(契約破壞)")
}
if len(results) != 1 || results[0].Status != "failed" {
t.Fatalf("results=%+v", results)
}
// t182:這裡是「選了 Gemini 卻沒有金鑰」⇒ 停在萃取層、誠實報缺什麼。
// 本測真正要守的契約沒變、也仍然綠:**PDF 不得被直送上雲**(上面的 serverCalled)。
if !strings.Contains(results[0].Error, "gemini_api_key") {
t.Errorf("錯誤訊息不符:%q", results[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=%qexplicit=%v key=%q",
cfg.Extractor, c.want, c.explicit, c.key)
}
})
}
}