2f33324d3f
真正的根因不是「vault 判斷漏了子庫」,是**判斷的方向搞反了**: 前兩輪問的都是「監看根**這一層**是不是 vault」,但 daemon 的產物一律落在 監看根底下——這兩件事只有在「監看根 == 庫根」時才等價,而那正好是前兩輪 唯一測過、也唯一不會出事的擺法。 使用者只要把庫底下的某一層加進監看(`KB/docs`、`KB/pages`、Obsidian 庫裡的 某個專案夾——很自然的用法),DetectVaultType 就回 VaultNone,整套保護退回 一般資料夾模式,卡片落在 `<監看根>/system-dev/wiki/cards/`:那個路徑就在 使用者的 graph 裡面,而且看得見,Logseq/Obsidian 每一張卡都收編成一頁。 前綴(第二輪)只擋得住撞名,擋不住「多出一堆機器頁」。 改法:把「這一層是不是庫」與「我寫的東西會不會落進誰的庫」拆成兩個判準。 - vault.go:新增 DetectVaultContext(往上找到最近的庫根)與 VaultDirUnder (往下擋:寫入目標會不會踩進子庫)。DetectVaultType 一字未改,繼續與 install.sh 對齊——往上找用較嚴的判準(logseq/ 要有 config.edn 或 journals//pages/ 佐證),因為那是替使用者猜、而且一次猜好幾層。 停在家目錄與檔案系統根,避免 `~/logseq` 這種常見資料夾把整個家目錄判成庫。 - extract.go:cardsRelDirFor 改用 DetectVaultContext。 - safewrite.go:落卡前過 ensureWritable 機械閘——目標踩進子庫就中止, 不靜靜寫進去。今天不會觸發,它防的是以後新增的寫檔點。 - tidy.go:收拾判準從「有沒有帶標記」擴充成「位置對不對 + 有沒有帶標記」, 舊版留在看得見位置的卡會被搬進隱藏目錄;MigrateCardNames 每輪自動做, 使用者不必下任何指令。報告多一個 VaultRoot,說清楚是誰的庫。 leo 派工單上的線索(庫在監看根**底下**)實測不成立:產物一律錨在監看根, 不會落進子庫。但那個「本來就沒破」原本沒有任何機制保證,所以照樣把兩種 格式的子庫情境永久寫進測試,加上 ensureWritable 當第二道保險。 驗證缺口(票上第 6 條):第二輪的足跡測試方向是對的,漏的是**觀測窗**—— snapshotTree 只拍監看根,而災情發生在監看根外面、庫裡面;且 fixture 只有 `root := vault` 一種擺法,測試與被測程式犯了同一個假設,所以永遠是綠的。 vault_subdir_test.go 把快照邊界改成筆記庫,並把「監看根與庫根的關係」升格 成測試維度(庫在上/庫在下/庫就是它/沒有庫 × Logseq/Obsidian)。 全程只用 t.TempDir() 與 mktemp -d;沒碰任何真實筆記庫、沒重啟任何 daemon。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
134 lines
5.6 KiB
Go
134 lines
5.6 KiB
Go
// 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` binding(Workers AI)⇒ **完全不需要任何金鑰**。
|
||
// 用的是用戶自己 CF 帳號內建的 AI;他的 Google 帳號被封也不受影響。
|
||
//
|
||
// 架構上與 gemma 路完全對稱(leo 的判斷:「不論用 Claude/Gemini/Workers 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_url(config)")
|
||
}
|
||
if strings.TrimSpace(apiKey) == "" {
|
||
return nil, fmt.Errorf("workers-ai 萃取路需要 api_key(config)")
|
||
}
|
||
|
||
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)
|
||
}
|
||
// arcrun-rag#60:路徑與檔名都由 cardRelFor 決定——非 vault 落 system-dev/wiki/cards/、
|
||
// vault 落隱藏目錄,且**檔名一律帶 arcrun- 前綴**(第二輪:光換目錄擋不住撞名,
|
||
// 因為 Logseq 的頁名是 basename)。落地前先查目標存不存在、不無條件覆蓋(safeWriteCard)。
|
||
cardRel := cardRelFor(absRoot, pageName)
|
||
dest := filepath.Join(absRoot, filepath.FromSlash(cardRel))
|
||
if err := safeWriteCard(absRoot, dest, []byte(card)); err != nil {
|
||
return nil, err
|
||
}
|
||
return []string{cardRel}, nil
|
||
}
|