feat(daemon-beta t3/t4): 可插拔本地萃取器——claude 路(叫起用戶訂閱跑 /wiki-capture、stub 驗呼叫契約)+gemma 路(實戰 prompt/thought 淨化/httptest 替身);測試 6 支全綠

This commit is contained in:
2026-07-24 12:21:41 +08:00
parent 73a5a67b72
commit be47afe26e
4 changed files with 362 additions and 0 deletions
+90
View File
@@ -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-capturetask 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-capturetemplate 既有萃取 skillcwd=監看根(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
}