Files
arcrun-collector/direct_extract_test.go
T
Leo 4d3a6a09a6 v0.18.9:collector 併進同一支執行檔——磁碟上不再攤出第二支 exe
leo 08-06 裁決:「不要兩支,寫成一支檔案」。

## 為什麼
v0.18.7-8 的「單一 exe」其實是**一支包著另一支**:collector.exe 被 go:embed
進 Arcrun.exe,執行時攤到 ~/.arcrun-rag/bin/ 再跑。
那正是防毒軟體眼中的 dropper 特徵 —— 封測者實撞
`Trojan:Win32/Sabsik.FL.A!ml`,檔案當場被隔離、自動刪除。

⚠️ 誠實界定:`!ml` 結尾=**機器學習判定**,Sabsik 是最常見的通用誤判家族,
   主因是「未簽章+下載次數少」,**不是**特別指向 dropper 行為。
   所以本次改動**不保證**解除誤判——真正的解是上架 MS Store(微軟簽章)。
   但「執行時把第二支 PE 寫到磁碟再執行」本來就該拿掉,這是對的方向且順手變小。

## 怎麼做
- `collector/` 39 個檔 `package main` → `package collector`,`main()` → 匯出的 `Run(args) int`
- 新增 `collector/cmd/collector/`(薄殼 CLI,讓單獨跑 collector 這條路仍可用)
- App 直接 import 該套件;`main()` 第一件事就判 `--collector`,是的話走 `collector.Run` 不碰 GUI
- `supervisor` 加 `ArgPrefix`,App 把 `BinPath` 指向 `os.Executable()` 自己
- 刪掉 `bundled_collector_{windows,other}.go`(embed + 攤檔那套)
- 三支打包腳本不再編/複製第二支;版本注入同時打到兩個 package
- build-win.sh 的機械閘改成**直接問它**:`--collector --version` 回得出版本才放行
  (舊閘是比大小,只能證明「有 embed」,證明不了「分派是對的」)

## 驗(真機實跑)
· `.app/Contents/MacOS/` 只有 **一個** 執行檔(原本兩個)
· 跑起來兩個行程是**同一個 exe**:
    …/MacOS/arcrun-app
    …/MacOS/arcrun-app --collector direct --config …
· `~/.arcrun-rag/bin` **不存在**(沒有任何東西被攤出來)
· 端到端:丟檔進看守資料夾 → collector.log `"status":"ingested","http_status":200`
· `lsappinfo` 仍是 `type="UIElement"`、`Version="0.18.9"`
· collector 39 檔測試全過;app 測試過;go vet 全綠;mac + windows 交叉編譯皆過
· 單檔 26MB → 22MB(不再夾帶第二份完整程式)
2026-08-06 16:00:47 +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 collector
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)
}
})
}
}