f10d10747c
事故:daemon 完全沒有 vault 辨識,把萃取卡片寫進使用者的 Logseq vault (system-dev/wiki/cards/ 對一般資料夾沒事,對 vault=憑空多出頁面, 2026-08-10 leo 實撞:25 個簡體字卡片污染 ~/Documents/KB)。 修法(照票上指示搬現成的,不重新設計): - collector/vault.go:DetectVaultType/IsVault,判準逐條抄自 system-dev-template/scripts/install.sh:209-221(先查 logseq/、再查 .obsidian/)。 已用同一批 fixture 資料夾跑過 install.sh 與這支 Go 版,五種情境(logseq/ obsidian/一般資料夾/兩者皆有/空資料夾)IS_VAULT 判斷逐一比對一致。 - extract.go:cardsRelDirFor() 依 IsVault 決定卡片相對路徑——非 vault 不變 (system-dev/wiki/cards/),vault 改落 .arcrun-rag/wiki/cards/(點開頭隱藏 目錄,Logseq/Obsidian 預設不掃描,跟 daemon 自己 scan.go 的隱藏目錄跳過規則 一致)。extract_workersai.go/extract_gemma.go/direct.go 的落卡與下架清除 都改用這個函式,三處對同一個 absRoot 保證同一個答案。 - safewrite.go:safeWriteCard() 取代兩處無條件 os.WriteFile——目標已存在且 內容不同就先備份成 <dest>.bak-<unixnano> 才覆寫;內容相同則不動(不產生 垃圾備份);備份失敗就整個中止,不無聲蓋掉使用者機器上已有的東西。 驗證(見 PR/commit 說明附的實測輸出): - 用 fake Logseq vault fixture 重現舊行為(卡片確實落在 system-dev/wiki/cards/), 再用同一份 fixture 驗新行為(卡片改落 .arcrun-rag/wiki/cards/,vault 根目錄 非隱藏 .md 數量不變、journals/ 原稿位元不動)。 - 故意放同名既有卡片,跑完既有內容被備份、新內容確實寫入,未無聲遺失。 - 非 vault 既有測試(extract_gemma_test.go 原有三支)全數不動照過,確認 一般資料夾行為零改變。 範圍外(留給下一輪):ExtractWithClaude(extract.go 的 claude 路)與其 templatefs/.claude/commands/rag-extract-file.md 技能檔仍硬寫 system-dev/wiki/cards/, 但這條路目前在 direct.go 的 RunDirectOnce 是不支援狀態(cfg.Extractor 只認 workers-ai/gemma),非本次事故的作用路徑,故未動。 不影響:leo 機器上的 daemon(未重啟、未重新打包);此修復要生效還要 ①總管審過併 main ②重新打包桌面版裝上他機器 ③總管用新二進位實測, 三件缺一不可(見 issue #60 leo 的重啟條件)。
136 lines
5.3 KiB
Go
136 lines
5.3 KiB
Go
// 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 collector
|
||
|
||
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)
|
||
}
|
||
|
||
// 本地轉檔層(t73/t16,2026-07-27):任何格式在這裡變成「模型可讀的純文字」。
|
||
// 純文字檔原樣通過;.docx 等走對應抽取器。**LLM 只會收到文字,永遠不會收到二進位**
|
||
//(那正是這一層存在的理由——見 convert.go 檔頭與 pdf-extraction-options.md 洞 B)。
|
||
srcText, err := ConvertToText(relPath, raw)
|
||
if err != nil {
|
||
// 這裡刻意**不吞錯**:靜默略過正是 leo 撞到的病(丟檔進去沒反應)。
|
||
// ErrNoText=掃描件/空檔,ErrUnsupported=還沒支援的格式,兩者訊息不同但都要說出來。
|
||
return nil, fmt.Errorf("轉檔失敗(%s):%w", relPath, err)
|
||
}
|
||
|
||
pageName := pageNameOf(relPath)
|
||
|
||
reqBody, _ := json.Marshal(map[string]any{
|
||
"contents": []map[string]any{
|
||
{"parts": []map[string]any{{"text": gemmaPrompt(pageName, srcText)}}},
|
||
},
|
||
"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)
|
||
}
|
||
// arcrun-rag#60:非 vault 落 system-dev/wiki/cards/、vault 改落隱藏目錄,避免污染筆記軟體的頁面清單;
|
||
// 落地前先查目標存不存在、不無條件覆蓋(safeWriteCard),兩條都是同一票的紅線。
|
||
cardRel := filepath.ToSlash(filepath.Join(cardsRelDirFor(absRoot), pageName+".md"))
|
||
dest := filepath.Join(absRoot, filepath.FromSlash(cardRel))
|
||
if err := safeWriteCard(dest, []byte(card)); err != nil {
|
||
return nil, err
|
||
}
|
||
return []string{cardRel}, nil
|
||
}
|