Files
arcrun-collector/extract_workersai_test.go
T
Leo 6c74b97718 feat(collector): workers-ai 路改吃共用契約——免金鑰預設路的卡跟 BYOK 一樣完整(Arcrun#134)
InkStoneCo#44 ④ 只修了 gemma BYOK 路(JSON 契約+wikishape 機械組卡),
免金鑰預設路(多數用戶實際走的)仍吃雲端舊 prompt 的舊格式卡 ⇒ 走預設路
拿到次級知識庫。

修法=契約單一真相源:wikiExtractPrompt(原 gemmaPrompt 改名,因為它已是
兩條路共用)由 daemon 整段帶上雲(request prompt 欄位),雲端只當執行器回
模型原文(response output),解析(parseWikiExtractJSON)與組卡(BuildWikiDoc)
回到本 package 與 gemma 路同一段程式碼——同形不再靠「兩邊要一起改」的叮嚀。

版本歪斜兩向都有路:舊雲端忽略 prompt 回 card ⇒ fallback 走 legacy 落卡
(#60 前綴/不覆蓋保護原封不動,收端 lint 新舊雙軌);舊 daemon 不帶 prompt
⇒ 雲端 legacy 行為不變(對向修法在 Arcrun work/extract-wiki-json-0815)。

測試:+2——①request 的 prompt 必須是 wikiExtractPrompt 本人(不是手抄第二份)
②同原稿+同判斷走兩條路,.wiki/ 產物逐位元組相同(卡+00-INDEX+manifest)。
既有兩則(stub 回 card)自動變成舊雲端 fallback 的守衛。go test ./... 全綠。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 21:26:30 +08:00

227 lines
7.6 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.
// extract_workersai_test.go — arcrun-rag#60workers-ai 是預設/主線萃取路(t181),
// 同一套 vault 保護要對這條路也成立,不能只顧 gemma。
package collector
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func workersAIStub(t *testing.T, handler http.HandlerFunc) (url string, closeFn func()) {
t.Helper()
srv := httptest.NewServer(handler)
return srv.URL, srv.Close
}
func TestExtractWithWorkersAI_VaultRedirectsAndDoesNotClobber(t *testing.T) {
root := t.TempDir()
mustMkdir(t, filepath.Join(root, ".obsidian")) // Obsidian vault
srcRel := "note.md"
if err := os.WriteFile(filepath.Join(root, srcRel), []byte("# 原稿"), 0o644); err != nil {
t.Fatal(err)
}
// 隱藏卡片目錄裡先放一份既有內容,驗證不會被無聲蓋掉。
// 檔名帶 arcrun- 前綴=第二輪之後卡片真正的名字(machinemark.go)。
cardDir := filepath.Join(root, ".arcrun-rag", "wiki", "cards")
mustMkdir(t, cardDir)
preexisting := "# note\n既有內容"
if err := os.WriteFile(filepath.Join(cardDir, "arcrun-note.md"), []byte(preexisting), 0o644); err != nil {
t.Fatal(err)
}
url, closeFn := workersAIStub(t, func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Arcrun-API-Key") != "key123" {
t.Errorf("api key 未帶到 header")
}
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"card": "# note\n## 一句話定義\n新卡\n",
})
})
defer closeFn()
cards, err := ExtractWithWorkersAI(url, "key123", root, srcRel)
if err != nil {
t.Fatal(err)
}
if len(cards) != 1 || cards[0] != ".arcrun-rag/wiki/cards/arcrun-note.md" {
t.Fatalf("vault 目標的卡片路徑不對:%vwant [.arcrun-rag/wiki/cards/arcrun-note.md]", cards)
}
// pages/.obsidian 之外沒有新增任何非隱藏 .mdObsidian 不掃描 .arcrun-rag/)。
topLevel := countMD(t, root)
if topLevel != 1 { // 只有原本的 srcRel
t.Fatalf("vault 根目錄多出非預期的 .mdcount=%d", topLevel)
}
// 既有卡片必須被備份,不能無聲覆蓋。
entries, err := os.ReadDir(cardDir)
if err != nil {
t.Fatal(err)
}
var foundBackup bool
for _, e := range entries {
if strings.HasPrefix(e.Name(), "arcrun-note.md.bak-") {
foundBackup = true
}
}
if !foundBackup {
t.Fatalf("既有卡片沒有被備份,目錄內容:%v", entries)
}
}
// 非 vault:卡片仍落 system-dev/wiki/cards/(目錄不變),但檔名同樣帶標記。
//
// 🔴 第二輪刻意讓「vault 與非 vault 用同一條命名規則」:紅線是「前綴只准一種、
// 不要一部分加一部分不加」。若只在 vault 加前綴,一般資料夾的使用者照樣分不出
// 哪些檔是機器寫的——而 system-dev/wiki/cards/ 在他的檔案總管裡是**看得見**的。
func TestExtractWithWorkersAI_NonVaultUnchanged(t *testing.T) {
root := t.TempDir()
srcRel := "note.md"
if err := os.WriteFile(filepath.Join(root, srcRel), []byte("# 原稿"), 0o644); err != nil {
t.Fatal(err)
}
url, closeFn := workersAIStub(t, func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"card": "# note\n## 一句話定義\n新卡\n",
})
})
defer closeFn()
cards, err := ExtractWithWorkersAI(url, "key123", root, srcRel)
if err != nil {
t.Fatal(err)
}
if len(cards) != 1 || cards[0] != "system-dev/wiki/cards/arcrun-note.md" {
t.Fatalf("非 vault 卡片路徑不對:%vwant [system-dev/wiki/cards/arcrun-note.md]", cards)
}
}
// ── Arcrun#134workers-ai 路與 gemma 路共用同一份契約 ─────────────────────────
//
// 修法=daemon 把 wikiExtractPrompt 整段帶去雲端(request `prompt` 欄位),
// 雲端只回模型原文(response `output`),解析與組卡回到本 package 與 gemma 路
// 同一段程式碼。⇒ 「兩條路的卡同形」不再是兩份 prompt 各自維持的巧合,
// 是同一份程式碼的必然。以下兩則就是這句話的機械守衛;
// 檔案上方兩則既有測試(stub 只回 `card`)則守住「舊雲端 fallback 不斷炊」。
// request 必帶 prompt,且必須就是 wikiExtractPrompt 本人——不是另一份手抄。
func TestExtractWithWorkersAI_SendsSharedPrompt(t *testing.T) {
root := t.TempDir()
const srcName = "報銷規則.md"
const srcBody = "# 報銷規則\n\n內文"
if err := os.WriteFile(filepath.Join(root, srcName), []byte(srcBody), 0o644); err != nil {
t.Fatal(err)
}
var gotPrompt string
url, closeFn := workersAIStub(t, func(w http.ResponseWriter, r *http.Request) {
var req map[string]string
_ = json.NewDecoder(r.Body).Decode(&req)
gotPrompt = req["prompt"]
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"output": cardFixture("報銷規則", "財務"),
})
})
defer closeFn()
if _, err := ExtractWithWorkersAI(url, "key123", root, srcName); err != nil {
t.Fatal(err)
}
srcText, err := ConvertToText(srcName, []byte(srcBody))
if err != nil {
t.Fatal(err)
}
if want := wikiExtractPrompt("報銷規則", srcText); gotPrompt != want {
t.Fatalf("送上雲的 prompt 不是共用那份 wikiExtractPromptlen got=%d want=%d", len(gotPrompt), len(want))
}
}
// wikiTreeOf 收齊 root 底下所有 .wiki/ 產物(相對路徑 → 內容)。
func wikiTreeOf(t *testing.T, root string) map[string]string {
t.Helper()
out := map[string]string{}
err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return err
}
rel, _ := filepath.Rel(root, p)
if !strings.Contains(filepath.ToSlash(rel), ".wiki/") {
return nil
}
b, rerr := os.ReadFile(p)
if rerr != nil {
return rerr
}
out[filepath.ToSlash(rel)] = string(b)
return nil
})
if err != nil {
t.Fatal(err)
}
return out
}
// 🔴 本票的驗收本體:同一份原稿+同一份模型判斷,走 gemma 路與 workers-ai 路,
// `.wiki/` 產物(卡、00-INDEX、manifest)必須**逐位元組相同**。
func TestExtractWorkersAIAndGemmaProduceIdenticalWiki(t *testing.T) {
const srcName = "報銷規則.md"
const srcBody = "# 報銷規則\n\n機密內容 XYZZY"
fixture := cardFixture("報銷規則", "財務")
// gemma 路
rootG := t.TempDir()
if err := os.WriteFile(filepath.Join(rootG, srcName), []byte(srcBody), 0o644); err != nil {
t.Fatal(err)
}
undo := gemmaCardStub(t, fixture)
cardsG, errG := ExtractWithGemma("k-test", "", rootG, srcName)
undo()
if errG != nil {
t.Fatal(errG)
}
// workers-ai 路
rootW := t.TempDir()
if err := os.WriteFile(filepath.Join(rootW, srcName), []byte(srcBody), 0o644); err != nil {
t.Fatal(err)
}
url, closeFn := workersAIStub(t, func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "output": fixture})
})
cardsW, errW := ExtractWithWorkersAI(url, "key123", rootW, srcName)
closeFn()
if errW != nil {
t.Fatal(errW)
}
if strings.Join(cardsG, "|") != strings.Join(cardsW, "|") {
t.Fatalf("兩條路回報的卡片清單不同:gemma=%v workers-ai=%v", cardsG, cardsW)
}
treeG, treeW := wikiTreeOf(t, rootG), wikiTreeOf(t, rootW)
if len(treeG) == 0 {
t.Fatal("gemma 路沒有產出任何 .wiki 檔案(測試前提壞了)")
}
for rel, want := range treeG {
got, ok := treeW[rel]
if !ok {
t.Errorf("workers-ai 路缺檔:%s", rel)
continue
}
if got != want {
t.Errorf("兩條路的 %s 內容不同(前 200 字):\n─ gemma ─\n%.200s\n─ workers-ai ─\n%.200s", rel, want, got)
}
}
for rel := range treeW {
if _, ok := treeG[rel]; !ok {
t.Errorf("workers-ai 路多出 gemma 路沒有的檔:%s", rel)
}
}
}