Files
arcrun-collector/extract_workersai.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

134 lines
5.3 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_workersai.go — 走「自己雲端實例的 Workers AI」萃卡(t181**免金鑰**)。
//
// 🔴 為什麼有這條路(leo 2026-08-04 列為最優先):
//
// 「daemon 的 AI 改用 workers AI」——「這是我的用戶**最大障礙**,
// 造成首輪測試用戶的**好評或惡評**」。
//
// 舊的 gemma 路要用戶自己去 Google 申請 API Key,實測撞到三種災難:
// 1. 完全不知道要去哪裡設定(封測者是台大資工碩士都卡住 ⇒ leo:「一般人就完蛋了」)
// 2. 拿到的金鑰所屬 Google 帳號被 flag ⇒ 403 PERMISSION_DENIED;換專案無效、
// 申訴要 billing account 而 free tier 進不去=死結
// 3. 52 檔全滅、零產出,還要把金鑰傳給別人實打才查得出真因
//
// ⇒ 本路徑改打**用戶自己雲端實例**的 `/portal/daemon/extract`
//
// 那端用 `env.AI` bindingWorkers AI)⇒ **完全不需要任何金鑰**。
// 用的是用戶自己 CF 帳號內建的 AI;他的 Google 帳號被封也不受影響。
//
// 架構上與 gemma 路完全對稱(leo 的判斷:「不論用 ClaudeGeminiWorkers AI
// 都是把原文經過 daemon 變成文字送到一個 API,**應該是同一件事**」——是):
//
// 讀原檔 → ConvertToText(本機轉檔)→ 送 API → 淨化 → 落卡
// ↑ 只有這一步不同
//
// ⚠️ 隱私邊界不變:送出去的是**已在本機轉成純文字的原稿**,回來的是知識卡;
// 原始檔案(docx/pdf/xlsx)仍然不出用戶的電腦。
package collector
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
// workersAIHTTP 逾時放寬到 90s:雲端要跑 LLM 生成,比一般 API 慢。
// (選型實測 llama-4-scout 約 2.4s,但長文+冷啟動要留餘裕。)
var workersAIHTTP = &http.Client{Timeout: 90 * time.Second}
// ExtractWithWorkersAI 讀原稿 → 送自己雲端的 /portal/daemon/extract 萃卡 → 卡片落地。
// cypherURL/apiKey 用的是 daemon 既有的連線憑證(送卡片上雲時同一把,見 direct.go)。
// 回傳產出的卡片相對路徑(單檔一卡),與 ExtractWithGemma 契約一致。
func ExtractWithWorkersAI(cypherURL, apiKey, absRoot, relPath string) ([]string, error) {
if strings.TrimSpace(cypherURL) == "" {
return nil, fmt.Errorf("workers-ai 萃取路需要 cypher_urlconfig")
}
if strings.TrimSpace(apiKey) == "" {
return nil, fmt.Errorf("workers-ai 萃取路需要 api_keyconfig")
}
raw, err := os.ReadFile(filepath.Join(absRoot, filepath.FromSlash(relPath)))
if err != nil {
return nil, fmt.Errorf("讀原稿失敗:%w", err)
}
// 本地轉檔層與 gemma 路共用同一份(t73/t16):任何格式在這裡變成純文字。
// **LLM 只會收到文字,永遠不會收到二進位**。刻意不吞錯——靜默略過正是 leo 撞過的病。
srcText, err := ConvertToText(relPath, raw)
if err != nil {
return nil, fmt.Errorf("轉檔失敗(%s):%w", relPath, err)
}
pageName := pageNameOf(relPath)
reqBody, _ := json.Marshal(map[string]string{
"page_name": pageName,
"text": srcText,
})
url := strings.TrimSuffix(strings.TrimSpace(cypherURL), "/") + "/portal/daemon/extract"
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-Arcrun-API-Key", apiKey)
resp, err := workersAIHTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("連不上你的知識庫:%w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if resp.StatusCode == http.StatusNotFound {
// 舊實例還沒有這條 route ⇒ 講人話,別讓用戶看到裸 404
return nil, fmt.Errorf("你的知識庫還是舊版(沒有雲端萃取功能)⇒ 請到 portal 按「立即更新」重裝一次")
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
var e struct {
Error string `json:"error"`
}
_ = json.Unmarshal(body, &e)
if e.Error != "" {
return nil, fmt.Errorf("雲端萃取失敗(HTTP %d):%s", resp.StatusCode, e.Error)
}
return nil, fmt.Errorf("雲端萃取失敗(HTTP %d):%.200s", resp.StatusCode, string(body))
}
var parsed struct {
Success bool `json:"success"`
Card string `json:"card"`
Error string `json:"error"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
return nil, fmt.Errorf("雲端回應解析失敗:%w", err)
}
if !parsed.Success || strings.TrimSpace(parsed.Card) == "" {
if parsed.Error != "" {
return nil, fmt.Errorf("雲端萃取失敗:%s", parsed.Error)
}
return nil, fmt.Errorf("雲端沒有回傳卡片內容")
}
// 與 gemma 路共用同一套淨化與落卡(卡片格式契約一致:第一行必須是「# <頁名>」)
card := cleanGemmaCard(parsed.Card, 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
}