feat(daemon-beta t3/t4): 可插拔本地萃取器——claude 路(叫起用戶訂閱跑 /wiki-capture、stub 驗呼叫契約)+gemma 路(實戰 prompt/thought 淨化/httptest 替身);測試 6 支全綠
This commit is contained in:
+90
@@ -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
|
||||
}
|
||||
@@ -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 淨化思考型模型輸出:取最後一個「# <pageName>」起的內容(前面全是草稿)。
|
||||
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
|
||||
}
|
||||
@@ -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 應報錯")
|
||||
}
|
||||
}
|
||||
@@ -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("缺執行檔應報錯")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user