diff --git a/extract.go b/extract.go new file mode 100644 index 0000000..0a26a60 --- /dev/null +++ b/extract.go @@ -0,0 +1,90 @@ +// extract.go — 可插拔本地萃取器(daemon-beta task 3/4,四步定稿第 3 步)。 +// +// leo 定稿:「由現在的 gemma4 幫你本地萃,或是用你自己的訂閱帳號,例如叫起 claude 幫你萃。」 +// - claude 路(task 3):執行 `claude -p "/wiki-capture <檔>"`,cwd=監看根—— +// template 既有萃取流**原樣重用**(prompt 都不搬),卡片由 CC 寫進 system-dev/wiki/cards/。 +// 用戶自己的訂閱=我們零 API 成本。 +// - gemma 路(task 4):Go 內直呼 Gemini API,卡片由 daemon 自己寫進 cards/。 +// +// 兩路的產出契約相同:回傳「本次新增/變動的卡片相對路徑」,交 task 6 推 ingest。 +// 原文永不出本函式(claude 路整段在用戶機器;gemma 路內容過境 API 但不落任何雲儲存)。 +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "time" +) + +// cardsRelDir 是 template 規約的卡片產物區(相對監看根)。 +const cardsRelDir = "system-dev/wiki/cards" + +// snapshotCards 記 cards/ 目前每檔 mtime+size(偵測萃取後的新卡/變卡)。 +func snapshotCards(absRoot string) map[string]fileState { + out := map[string]fileState{} + base := filepath.Join(absRoot, filepath.FromSlash(cardsRelDir)) + _ = filepath.Walk(base, func(p string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() || filepath.Ext(p) != ".md" { + return nil + } + rel, rerr := filepath.Rel(absRoot, p) + if rerr != nil { + return nil + } + out[filepath.ToSlash(rel)] = fileState{size: info.Size(), mtime: info.ModTime().UnixNano()} + return nil + }) + return out +} + +// diffCards 比對兩次快照,回傳新增或內容變動的卡片相對路徑(排序穩定交由呼叫端)。 +func diffCards(before, after map[string]fileState) []string { + var changed []string + for p, st := range after { + if b, ok := before[p]; !ok || b.size != st.size || b.mtime != st.mtime { + changed = append(changed, p) + } + } + return changed +} + +// claudeExtractTimeout:單檔萃取上限。CC 冷啟+讀檔+寫卡實測分鐘級,給足裕度。 +const claudeExtractTimeout = 10 * time.Minute + +// ExtractWithClaude 叫起用戶自己的 claude 跑 template 的 /wiki-capture(task 3)。 +// binPath 空="claude"(PATH 尋找)。回傳本次產出的卡片相對路徑。 +func ExtractWithClaude(binPath, absRoot, relPath string) ([]string, error) { + if binPath == "" { + binPath = "claude" + } + if _, err := exec.LookPath(binPath); err != nil { + return nil, fmt.Errorf("找不到 claude 執行檔(%s):%w——請確認已安裝 Claude Code,或改用 gemma 萃取路", binPath, err) + } + before := snapshotCards(absRoot) + + ctx, cancel := context.WithTimeout(context.Background(), claudeExtractTimeout) + defer cancel() + // /wiki-capture=template 既有萃取 skill;cwd=監看根(template 已代裝,skill 就在 .claude/commands/)。 + cmd := exec.CommandContext(ctx, binPath, "-p", fmt.Sprintf("/wiki-capture %s", relPath)) + cmd.Dir = absRoot + out, err := cmd.CombinedOutput() + if ctx.Err() == context.DeadlineExceeded { + return nil, fmt.Errorf("claude 萃取逾時(%s)", claudeExtractTimeout) + } + if err != nil { + snippet := string(out) + if len(snippet) > 400 { + snippet = snippet[len(snippet)-400:] + } + return nil, fmt.Errorf("claude 萃取失敗:%w;輸出尾段:%s", err, snippet) + } + + cards := diffCards(before, snapshotCards(absRoot)) + if len(cards) == 0 { + return nil, fmt.Errorf("claude 跑完但 %s 沒有新卡片(輸出尾段:%.200s)", cardsRelDir, string(out)) + } + return cards, nil +} diff --git a/extract_gemma.go b/extract_gemma.go new file mode 100644 index 0000000..69452c8 --- /dev/null +++ b/extract_gemma.go @@ -0,0 +1,125 @@ +// extract_gemma.go — gemma 本地萃取路(daemon-beta task 4)。 +// +// prompt 與淨化規則抄自實戰版 rag_extract_one workflow(同一套格式:一句話定義/要點/ +// 關鍵實體/關聯),差別只在跑的位置:workflow 在雲端實例跑、這裡在用戶機器上由 daemon 直呼。 +// 原文內容過境 Gemini API 一次,不落任何雲端儲存(四步定稿邊界)。 +// +// gemma-4-31b 是思考型模型:parts[0] 常是 thought=true 的思考草稿,真答案在最後一個 +// 非 thought 的 text part(agent-memory §7.6 實撞教訓,讀法照抄)。 +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +// gemmaBaseURL 可注入(測試用 httptest 替身)。 +var gemmaBaseURL = "https://generativelanguage.googleapis.com" + +const defaultLLMModel = "gemma-4-31b-it" + +var gemmaHTTP = &http.Client{Timeout: 120 * time.Second} + +// gemmaPrompt 組出與 rag_extract_one 同款的萃卡指令。 +func gemmaPrompt(pageName, content string) string { + return "把以下原稿重寫成定稿知識卡(正體中文)。直接輸出卡片本身:第一行必須是「# " + pageName + + "」,不要任何前言、思考過程、英文草稿或說明。格式:\n# " + pageName + + "\n## 一句話定義\n(一行)\n## 要點\n- (3-12 條,具體、含數字條件)\n## 關鍵實體\n- **實體名** — 一句說明\n## 關聯\n- 實體A >> 謂詞 >> 實體B(3-8 行,用上面實體名)\n\n原稿:\n" + content +} + +// cleanGemmaCard 淨化思考型模型輸出:取最後一個「# 」起的內容(前面全是草稿)。 +func cleanGemmaCard(text, pageName string) string { + marker := "# " + pageName + if i := strings.LastIndex(text, marker); i >= 0 { + return strings.TrimSpace(text[i:]) + "\n" + } + return strings.TrimSpace(text) + "\n" +} + +// ExtractWithGemma 讀原稿 → 呼 Gemini 萃卡 → 卡片落地 system-dev/wiki/cards/。 +// 回傳產出的卡片相對路徑(單檔一卡)。 +func ExtractWithGemma(apiKey, model, absRoot, relPath string) ([]string, error) { + if apiKey == "" { + return nil, fmt.Errorf("gemma 萃取路需要 gemini_api_key(config)") + } + if model == "" { + model = defaultLLMModel + } + raw, err := os.ReadFile(filepath.Join(absRoot, filepath.FromSlash(relPath))) + if err != nil { + return nil, fmt.Errorf("讀原稿失敗:%w", err) + } + pageName := pageNameOf(relPath) + + reqBody, _ := json.Marshal(map[string]any{ + "contents": []map[string]any{ + {"parts": []map[string]any{{"text": gemmaPrompt(pageName, string(raw))}}}, + }, + "generationConfig": map[string]any{"temperature": 0.2, "maxOutputTokens": 8192}, + }) + url := fmt.Sprintf("%s/v1beta/models/%s:generateContent", gemmaBaseURL, model) + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(reqBody)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-goog-api-key", apiKey) + resp, err := gemmaHTTP.Do(req) + if err != nil { + return nil, fmt.Errorf("Gemini API 連線失敗:%w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("Gemini API HTTP %d:%.300s", resp.StatusCode, string(body)) + } + + // 解析:candidates[0].content.parts → 最後一個非 thought 的 text part + var parsed struct { + Candidates []struct { + Content struct { + Parts []struct { + Thought bool `json:"thought"` + Text string `json:"text"` + } `json:"parts"` + } `json:"content"` + } `json:"candidates"` + } + if err := json.Unmarshal(body, &parsed); err != nil { + return nil, fmt.Errorf("Gemini 回應解析失敗:%w", err) + } + var text string + if len(parsed.Candidates) > 0 { + parts := parsed.Candidates[0].Content.Parts + for i := len(parts) - 1; i >= 0; i-- { + if !parts[i].Thought && parts[i].Text != "" { + text = parts[i].Text + break + } + } + } + if text == "" { + return nil, fmt.Errorf("Gemini 回應沒有可用文字(thought-only 或空回應)") + } + + card := cleanGemmaCard(text, pageName) + if !strings.HasPrefix(card, "# ") { + return nil, fmt.Errorf("萃出內容不像卡片(未以 # 開頭):%.120s", card) + } + cardRel := filepath.ToSlash(filepath.Join(cardsRelDir, pageName+".md")) + dest := filepath.Join(absRoot, filepath.FromSlash(cardRel)) + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return nil, err + } + if err := os.WriteFile(dest, []byte(card), 0o644); err != nil { + return nil, err + } + return []string{cardRel}, nil +} diff --git a/extract_gemma_test.go b/extract_gemma_test.go new file mode 100644 index 0000000..93ab65a --- /dev/null +++ b/extract_gemma_test.go @@ -0,0 +1,86 @@ +// extract_gemma_test.go — task 4(httptest 替身:驗 prompt 契約/thought 淨化/落卡)。 +package main + +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、去草稿、落卡。 +func TestExtractWithGemmaThinkingModel(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "會議記錄.md"), []byte("# 原稿"), 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# 會議記錄\n## 一句話定義\n測試卡\n"}, + }}, + }}, + }) + })() + cards, err := ExtractWithGemma("k123", "gemma-test", root, "會議記錄.md") + if err != nil { + t.Fatal(err) + } + if len(cards) != 1 || cards[0] != "system-dev/wiki/cards/會議記錄.md" { + t.Fatalf("cards=%v", cards) + } + data, _ := os.ReadFile(filepath.Join(root, "system-dev", "wiki", "cards", "會議記錄.md")) + if !strings.HasPrefix(string(data), "# 會議記錄") { + t.Fatalf("卡片未淨化(應從最後的 # 頁名 起):%.80s", string(data)) + } + if strings.Contains(string(data), "草稿雜訊") { + t.Fatal("思考草稿洩入卡片") + } +} + +// 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 應報錯") + } +} diff --git a/extract_test.go b/extract_test.go new file mode 100644 index 0000000..7f4fedd --- /dev/null +++ b/extract_test.go @@ -0,0 +1,61 @@ +// extract_test.go — task 3 claude 萃取路(用 stub 執行檔驗呼叫契約,不需真 claude)。 +package main + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "testing" +) + +// writeStubClaude 產生一支假 claude:驗證收到的參數與 cwd,並寫一張卡進 cards/。 +func writeStubClaude(t *testing.T, dir string, script string) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("stub 腳本測試僅跑 unix") + } + p := filepath.Join(dir, "claude") + if err := os.WriteFile(p, []byte("#!/bin/sh\n"+script), 0o755); err != nil { + t.Fatal(err) + } + return p +} + +// 正常路:stub 收到 -p "/wiki-capture <檔>"、cwd=監看根,寫卡後被 diff 偵測到。 +func TestExtractWithClaudeHappyPath(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "system-dev", "wiki", "cards"), 0o755); err != nil { + t.Fatal(err) + } + stub := writeStubClaude(t, t.TempDir(), fmt.Sprintf(` +[ "$1" = "-p" ] || { echo "第一參數應為 -p,got $1" >&2; exit 9; } +case "$2" in "/wiki-capture 我的筆記.md") ;; *) echo "prompt 錯:$2" >&2; exit 9;; esac +[ "$(pwd)" = "%s" ] || { echo "cwd 錯:$(pwd)" >&2; exit 9; } +mkdir -p system-dev/wiki/cards +printf '# 我的筆記\n\n## 摘要\nstub 卡\n' > "system-dev/wiki/cards/我的筆記.md" +`, root)) + cards, err := ExtractWithClaude(stub, root, "我的筆記.md") + if err != nil { + t.Fatal(err) + } + if len(cards) != 1 || cards[0] != "system-dev/wiki/cards/我的筆記.md" { + t.Fatalf("cards=%v", cards) + } +} + +// claude 跑完但沒寫卡=誠實報錯(不假綠)。 +func TestExtractWithClaudeNoCard(t *testing.T) { + root := t.TempDir() + stub := writeStubClaude(t, t.TempDir(), "exit 0\n") + if _, err := ExtractWithClaude(stub, root, "x.md"); err == nil { + t.Fatal("沒寫卡應報錯") + } +} + +// 找不到執行檔=引導改用 gemma 路的錯誤訊息。 +func TestExtractWithClaudeMissingBin(t *testing.T) { + if _, err := ExtractWithClaude("/no/such/claude-bin", t.TempDir(), "x.md"); err == nil { + t.Fatal("缺執行檔應報錯") + } +}