Files
arcrun-collector/extract_gemma_test.go
T
Leo 12c2d41886 feat(collector): daemon 產出改成規範形 wiki 卡——1 文件卡+N 原子卡落 .wiki/(InkStoneCo#44 ④)
LLM 只回 JSON 判斷,格式/落點/連結閉合/索引/manifest 全由 wikishape.go 機械組裝:
- 卡形=frontmatter(tags/gloss/created/updated)+← 上層+摘要/重點/實體(帶類型)/
  關聯(內文知識關係/卡片關係/出處)——差距表 #6
- 落點 <節點>/.wiki/、檔名=H1(.wiki 隱藏目錄自身即機器標記,machinemark 例外③)——#7
- 00-INDEX 機械維護:每行五樣照抄 frontmatter;每份原稿必列——#8
- 萃取端就切 1 hub+N 原子卡(gemma 路 prompt 改 JSON 契約)——#9
- 無可萃概念標「空」+理由,上索引不產卡——#10
機械保證對著 wiki-lint.py 寫:斷連結拆殼、index 式句改寫、三元組恰三項、
雙向邊自動補反向、佔用不覆蓋、原稿永不動;同名概念跨文件先消歧(merge 歸第⑤環)。
scan/convert 白名單補 .feature/.yaml/.yml/.org/.rst(規範洞 6);
lint.go 新舊格式雙軌(workers-ai 雲端 prompt 屬 matrix/arcrun 核心,待開票);
direct.go 只送文件卡上雲(takedown 配對鍵不變)、下架收走 .wiki 產物。

實測:真資料夾+真 Gemini → 2 節點 12 卡,wiki-lint 17/17 exit 0,
原稿 sha256 前後不變;go test collector+supervisor+arcrun-app 全綠(新增 9 案)。

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

215 lines
7.4 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_gemma_test.go — task 4httptest 替身:驗 prompt 契約/thought 淨化/落卡)。
package collector
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func gemmaStub(t *testing.T, handler http.HandlerFunc) func() {
t.Helper()
srv := httptest.NewServer(handler)
old := gemmaBaseURL
gemmaBaseURL = srv.URL
return func() { gemmaBaseURL = old; srv.Close() }
}
// happy path:思考型回應(parts[0]=thought)→ 取最後非 thought part、撈 JSON、落規範形卡。
func TestExtractWithGemmaThinkingModel(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "會議記錄.md"), []byte("# 會議記錄\n\n內文"), 0o644); err != nil {
t.Fatal(err)
}
defer gemmaStub(t, func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("x-goog-api-key") != "k123" {
t.Errorf("api key 未帶到 header")
}
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
b, _ := json.Marshal(req)
if !strings.Contains(string(b), "檔名:會議記錄") {
t.Errorf("prompt 未帶頁名")
}
_ = json.NewEncoder(w).Encode(map[string]any{
"candidates": []map[string]any{{
"content": map[string]any{"parts": []map[string]any{
{"thought": true, "text": "let me think..."},
{"text": "草稿雜訊\n" + cardFixture("會議記錄", "專案")},
}},
}},
})
})()
cards, err := ExtractWithGemma("k123", "gemma-test", root, "會議記錄.md")
if err != nil {
t.Fatal(err)
}
// 文件卡(H1=會議記錄)+一張概念卡,全部落 `.wiki/`
if len(cards) != 2 || cards[0] != ".wiki/會議記錄.md" || cards[1] != ".wiki/會議記錄·概念.md" {
t.Fatalf("cards=%v", cards)
}
data, _ := os.ReadFile(filepath.Join(root, ".wiki", "會議記錄.md"))
if !strings.HasPrefix(string(data), "---\n") || !strings.Contains(string(data), "gloss: 會議記錄的測試用一句話") {
t.Fatalf("卡片不是規範形(frontmatter+gloss):%.120s", string(data))
}
if strings.Contains(string(data), "草稿雜訊") {
t.Fatal("思考草稿洩入卡片")
}
for _, sec := range []string{"← [[00-INDEX]]", "## 摘要", "## 重點", "## 實體", "## 關聯", "### 出處"} {
if !strings.Contains(string(data), sec) {
t.Fatalf("文件卡缺「%s」:\n%s", sec, data)
}
}
}
// thought-only 回應=誠實報錯。
func TestExtractWithGemmaThoughtOnly(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "x.md"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
defer 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{{"thought": true, "text": "..."}}},
}},
})
})()
if _, err := ExtractWithGemma("k", "m", root, "x.md"); err == nil {
t.Fatal("thought-only 應報錯")
}
}
// 缺 key=引導訊息。
func TestExtractWithGemmaNoKey(t *testing.T) {
if _, err := ExtractWithGemma("", "m", t.TempDir(), "x.md"); err == nil {
t.Fatal("缺 key 應報錯")
}
}
// arcrun-rag#60 驗收核心:對著一個「長得像 Logseq vault」的資料夾跑萃取,
// 卡片不能落在 pages/、journals/ 或任何非隱藏目錄——vault 的頁面數不能因為
// daemon 跑過而增加。
func TestExtractWithGemma_VaultDoesNotGainPages(t *testing.T) {
root := t.TempDir()
// 造一個像真的 Logseq vault:有 logseq/、pages/、journals/journals 裡放一篇
// leo 自己的日記(模擬「原稿」,萃取對象另外放在 vault 根目錄下)。
mustMkdir(t, filepath.Join(root, "logseq"))
mustMkdir(t, filepath.Join(root, "pages"))
mustMkdir(t, filepath.Join(root, "journals"))
journalPath := filepath.Join(root, "journals", "2026_08_08.md")
journalContent := "leo 原話原圖,不該被動"
if err := os.WriteFile(journalPath, []byte(journalContent), 0o644); err != nil {
t.Fatal(err)
}
// 監看到的來源檔(相對 vault 根)——模擬使用者丟進 vault 的一份原稿。
srcRel := "會議記錄.md"
if err := os.WriteFile(filepath.Join(root, srcRel), []byte("# 原稿"), 0o644); err != nil {
t.Fatal(err)
}
defer 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": cardFixture("會議記錄", "專案")},
}},
}},
})
})()
pagesBefore := countMD(t, filepath.Join(root, "pages")) + countMD(t, filepath.Join(root, "journals")) + countTopLevelMD(t, root)
cards, err := ExtractWithGemma("k123", "gemma-test", root, srcRel)
if err != nil {
t.Fatal(err)
}
// 原稿內容「# 原稿」⇒ 文件卡名=H1「原稿」;`.wiki/` 是隱藏目錄,Logseq 不掃
if len(cards) != 2 || cards[0] != ".wiki/原稿.md" {
t.Fatalf("vault 目標的卡片路徑不對:%vwant [.wiki/原稿.md …]", cards)
}
pagesAfter := countMD(t, filepath.Join(root, "pages")) + countMD(t, filepath.Join(root, "journals")) + countTopLevelMD(t, root)
if pagesAfter != pagesBefore {
t.Fatalf("vault 頁面數增加了:before=%d after=%ddaemon 跑完不該讓 Logseq 多任何頁)", pagesBefore, pagesAfter)
}
// 原稿(journals 裡 leo 的日記)必須原封不動。
got, err := os.ReadFile(journalPath)
if err != nil {
t.Fatal(err)
}
if string(got) != journalContent {
t.Fatalf("journals 原稿被動過:%q", got)
}
// 卡片確實落在隱藏目錄,且是「監看根底下」(呼叫端 absRoot-relative 假設仍成立)。
cardAbs := filepath.Join(root, ".wiki", "原稿.md")
if _, err := os.Stat(cardAbs); err != nil {
t.Fatalf("卡片沒有落在預期的隱藏目錄:%v", err)
}
}
// 故意在 `.wiki/` 先放一個「不是本文件產的」同名檔案:不覆蓋、誠實報錯、原檔原封不動
// (#105 的分界:本來就在的檔案一律不動——wikishape 的 writeCard 以 manifest 認擁有權)。
func TestExtractWithGemma_VaultExistingCardNotClobbered(t *testing.T) {
root := t.TempDir()
mustMkdir(t, filepath.Join(root, "logseq"))
srcRel := "x.md"
if err := os.WriteFile(filepath.Join(root, srcRel), []byte("# 既有主題\n內文"), 0o644); err != nil {
t.Fatal(err)
}
cardDir := filepath.Join(root, ".wiki")
mustMkdir(t, cardDir)
preexisting := "# 既有主題\n這份是先前就存在的內容"
cardPath := filepath.Join(cardDir, "既有主題.md")
if err := os.WriteFile(cardPath, []byte(preexisting), 0o644); err != nil {
t.Fatal(err)
}
defer 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": cardFixture("既有主題", "專案")},
}},
}},
})
})()
_, err := ExtractWithGemma("k123", "gemma-test", root, srcRel)
if err == nil {
t.Fatal("目標位置被別人佔用時應報錯,不得無聲覆蓋")
}
if !strings.Contains(err.Error(), "佔用") {
t.Fatalf("錯誤訊息看不出原因:%v", err)
}
data, _ := os.ReadFile(cardPath)
if string(data) != preexisting {
t.Fatalf("既有檔案被動過:%q", data)
}
}
func countMD(t *testing.T, dir string) int {
t.Helper()
n := 0
entries, err := os.ReadDir(dir)
if err != nil {
return 0
}
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".md") {
n++
}
}
return n
}
func countTopLevelMD(t *testing.T, dir string) int {
return countMD(t, dir)
}