6c74b97718
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>
227 lines
7.6 KiB
Go
227 lines
7.6 KiB
Go
// extract_workersai_test.go — arcrun-rag#60:workers-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 目標的卡片路徑不對:%v,want [.arcrun-rag/wiki/cards/arcrun-note.md]", cards)
|
||
}
|
||
|
||
// pages/.obsidian 之外沒有新增任何非隱藏 .md(Obsidian 不掃描 .arcrun-rag/)。
|
||
topLevel := countMD(t, root)
|
||
if topLevel != 1 { // 只有原本的 srcRel
|
||
t.Fatalf("vault 根目錄多出非預期的 .md:count=%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 卡片路徑不對:%v,want [system-dev/wiki/cards/arcrun-note.md]", cards)
|
||
}
|
||
}
|
||
|
||
// ── Arcrun#134:workers-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 不是共用那份 wikiExtractPrompt(len 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)
|
||
}
|
||
}
|
||
}
|