Files
arcrun-collector/extract_gemma.go
T
Leo 4d3a6a09a6 v0.18.9:collector 併進同一支執行檔——磁碟上不再攤出第二支 exe
leo 08-06 裁決:「不要兩支,寫成一支檔案」。

## 為什麼
v0.18.7-8 的「單一 exe」其實是**一支包著另一支**:collector.exe 被 go:embed
進 Arcrun.exe,執行時攤到 ~/.arcrun-rag/bin/ 再跑。
那正是防毒軟體眼中的 dropper 特徵 —— 封測者實撞
`Trojan:Win32/Sabsik.FL.A!ml`,檔案當場被隔離、自動刪除。

⚠️ 誠實界定:`!ml` 結尾=**機器學習判定**,Sabsik 是最常見的通用誤判家族,
   主因是「未簽章+下載次數少」,**不是**特別指向 dropper 行為。
   所以本次改動**不保證**解除誤判——真正的解是上架 MS Store(微軟簽章)。
   但「執行時把第二支 PE 寫到磁碟再執行」本來就該拿掉,這是對的方向且順手變小。

## 怎麼做
- `collector/` 39 個檔 `package main` → `package collector`,`main()` → 匯出的 `Run(args) int`
- 新增 `collector/cmd/collector/`(薄殼 CLI,讓單獨跑 collector 這條路仍可用)
- App 直接 import 該套件;`main()` 第一件事就判 `--collector`,是的話走 `collector.Run` 不碰 GUI
- `supervisor` 加 `ArgPrefix`,App 把 `BinPath` 指向 `os.Executable()` 自己
- 刪掉 `bundled_collector_{windows,other}.go`(embed + 攤檔那套)
- 三支打包腳本不再編/複製第二支;版本注入同時打到兩個 package
- build-win.sh 的機械閘改成**直接問它**:`--collector --version` 回得出版本才放行
  (舊閘是比大小,只能證明「有 embed」,證明不了「分派是對的」)

## 驗(真機實跑)
· `.app/Contents/MacOS/` 只有 **一個** 執行檔(原本兩個)
· 跑起來兩個行程是**同一個 exe**:
    …/MacOS/arcrun-app
    …/MacOS/arcrun-app --collector direct --config …
· `~/.arcrun-rag/bin` **不存在**(沒有任何東西被攤出來)
· 端到端:丟檔進看守資料夾 → collector.log `"status":"ingested","http_status":200`
· `lsappinfo` 仍是 `type="UIElement"`、`Version="0.18.9"`
· collector 39 檔測試全過;app 測試過;go vet 全綠;mac + windows 交叉編譯皆過
· 單檔 26MB → 22MB(不再夾帶第二份完整程式)
2026-08-06 16:00:47 +08:00

137 lines
5.2 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.go — gemma 本地萃取路(daemon-beta task 4)。
//
// prompt 與淨化規則抄自實戰版 rag_extract_one workflow(同一套格式:一句話定義/要點/
// 關鍵實體/關聯),差別只在跑的位置:workflow 在雲端實例跑、這裡在用戶機器上由 daemon 直呼。
// 原文內容過境 Gemini API 一次,不落任何雲端儲存(四步定稿邊界)。
//
// gemma-4-31b 是思考型模型:parts[0] 常是 thought=true 的思考草稿,真答案在最後一個
// 非 thought 的 text partagent-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 >> 謂詞 >> 實體B3-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_keyconfig")
}
if model == "" {
model = defaultLLMModel
}
raw, err := os.ReadFile(filepath.Join(absRoot, filepath.FromSlash(relPath)))
if err != nil {
return nil, fmt.Errorf("讀原稿失敗:%w", err)
}
// 本地轉檔層(t73/t162026-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)
}
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
}