t181 daemon 端:萃取預設走 Workers AI(免金鑰)+托盤可切換 AI 來源
【leo 08-04 列為最優先】「daemon 的 AI 改用 workers AI」 ——「這是我的用戶**最大障礙**,造成首輪測試用戶的**好評或惡評**」。 【新增】collector/extract_workersai.go:打自己雲端實例的 /portal/daemon/extract, 那端用 env.AI binding ⇒ **完全不需要任何金鑰**。與 gemma 路架構完全對稱 (leo 的判斷:「不論用 Claude/Gemini/Workers AI…應該是同一件事」——是, 只有「送到哪」不同,讀檔/轉檔/淨化/落卡全共用)。 舊實例回 404 時講人話:「你的知識庫還是舊版 ⇒ 請到 portal 按『立即更新』」。 【預設改為一律 Workers AI】leo 特別交代: 「default 用 Workers AI,你要用 Gemini 要**特別去選取**,**不管你現在是否有填金鑰**」 「只要更新版本,就已經 default workers AI 了,除非去一個地方切換」 「不然我會有很多質疑,**花在解釋為什麼 Gemini 不管用上**」 ⇒ 判準是新欄位 ExtractorExplicit(使用者主動選過),**不是「有沒有金鑰」**。 舊 config 沒這欄=false ⇒ 更新版本後自動走 Workers AI;金鑰留著不動,改選 Gemini 立刻可用。 【托盤=唯一切換處】「AI 設定…」改成引擎選擇:雲端 AI(預設)/Gemini(選配)。 選 Gemini 才顯示金鑰欄,並附「Billing Tier: Unavailable ⇒ 換 Google 帳號」的排難提示 (今天 oscar 撞的那題)。Gemini 不推廣(leo:「特定人告訴他怎麼做就好」),文案只說明不慫恿。 【標籤】leo:「用 workers AI 就**不顯示**,用 Gemini 會顯示 Gemini」 ⇒ 預設路徑不佔版面;只有主動選了別的才標出來(延續 t178「標籤必須與實際行為一致」)。 【測試】新增 TestT181DefaultsToWorkersAI 六則,含最關鍵的 **「有金鑰但沒主動選 ⇒ 仍走 workers-ai」**;既有測試補 ExtractorExplicit 以隔離變因 (它們測的是萃取管線,不是預設邏輯)。兩模組全綠。 ⚠️ 未送達:要打包 v0.15.5 並部署雲端端點才會到用戶手上。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+67
-15
@@ -88,7 +88,11 @@ type directConfig struct {
|
||||
Email string `json:"email,omitempty"`
|
||||
InstanceName string `json:"instance_name,omitempty"`
|
||||
Library string `json:"library,omitempty"`
|
||||
Extractor string `json:"extractor,omitempty"` // 機器層級:claude/gemma
|
||||
Extractor string `json:"extractor,omitempty"` // "workers-ai"(預設,免金鑰)|"gemma"
|
||||
// t181:使用者主動在「AI 設定…」選過才 true。false/舊 config 沒這欄 ⇒ 一律走 workers-ai。
|
||||
// leo 08-04:「只要更新版本,就已經 default workers AI 了,
|
||||
// 除非去一個地方切換你指定的 AI 來源」。
|
||||
ExtractorExplicit bool `json:"extractor_explicit,omitempty"`
|
||||
ClaudeBin string `json:"claude_bin,omitempty"` // t92:collector fallback 找到後回寫
|
||||
GeminiAPIKey string `json:"gemini_api_key,omitempty"` // t108:gemma 路 key
|
||||
LLMModel string `json:"llm_model,omitempty"` // gemma 路模型
|
||||
@@ -726,11 +730,22 @@ func accountEngineLabel(acc accountCfg, defaultExtractor string) string {
|
||||
extractor = strings.TrimSpace(defaultExtractor)
|
||||
}
|
||||
switch extractor {
|
||||
// t181:走雲端 Workers AI(**免金鑰,新的預設**)。
|
||||
// 空值也走這條——`direct.go` 的預設邏輯:沒設 extractor=新用戶=走 workers-ai,
|
||||
// 標籤跟著一致,才不會重演 t178「畫面說 A、實際跑 B」的矛盾。
|
||||
// t181(leo 08-04):「上方如果用 workers AI 就**不顯示**,如果用 Gemini 會顯示 Gemini」
|
||||
// ⇒ 預設路徑不佔版面(那是常態,不需要標註);只有主動選了別的引擎才標出來。
|
||||
// 空值也走這條——沒設 extractor=新用戶=走 workers-ai(direct.go 預設邏輯)。
|
||||
case "workers-ai", "":
|
||||
return ""
|
||||
// t178(leo 08-04 實撞:朋友的托盤顯示「oscar · Claude」,但他根本沒有 Claude):
|
||||
// `claude` 也顯示 Gemini——因為 **direct.go 早就把 claude 正規化成 gemma**
|
||||
// (t176:地端先只支援 Gemini)。標籤若照著 config 的舊字串念,就與實際行為不符
|
||||
// ⇒ 用戶看到 Claude、卻收到「Gemini 金鑰是空的」,兩個訊息互相矛盾、更難懂。
|
||||
// 舊值來源=雲端舊版下發後留在 config.json 的殘留(t176 擋了新寫入,沒洗掉舊的)。
|
||||
//
|
||||
// 📌 Gemini 仍是**選配**(leo 08-04:「客戶說他要用 Gemini,但現在變成選配,
|
||||
// 不需要推廣,特定人告訴他怎麼做就好」)⇒ 已填金鑰的用戶維持這條路、標籤照實顯示。
|
||||
case "gemma", "claude":
|
||||
return " · Gemini"
|
||||
}
|
||||
@@ -874,43 +889,80 @@ func main() {
|
||||
// 之後要擴充成「任何用戶想用的模型」時,這裡加 provider 選單即可(欄位已預留 LLMModel)。
|
||||
// ⚠️ 這是「緊急先加上輸入位置」的版本——leo 已指出托盤下拉選單塞不下未來的設定量
|
||||
//(30+ 資料夾根本認不出),後續走本地 HTML 設定頁(像 Google Drive)。
|
||||
// t181(leo 08-04):這裡是「**唯一可以切換 AI 來源的地方**」。
|
||||
// leo:「只要更新版本,就已經 default workers AI 了,除非**去一個地方切換**你指定的 AI 來源」
|
||||
// 「default 用 Workers AI,你要用 Gemini 要**特別去選取**,不管你現在是否有填金鑰」
|
||||
// ⇒ 預設選項固定在「雲端 AI」,除非使用者**主動**改選 Gemini(才寫 ExtractorExplicit=true)。
|
||||
// Gemini 是選配、不推廣(leo:「特定人告訴他怎麼做就好」),故文案不慫恿、只說明。
|
||||
showAISettings = func() {
|
||||
const (
|
||||
optCloud = "雲端 AI(推薦・不必申請任何金鑰)"
|
||||
optGemini = "Google Gemini(需要自己申請金鑰)"
|
||||
)
|
||||
keyEntry := widget.NewPasswordEntry()
|
||||
keyEntry.SetPlaceHolder("貼上你的 Gemini API Key")
|
||||
keyEntry.SetText(cfg.GeminiAPIKey) // 帶入現值,讓他看得到「已經設過了」
|
||||
hint := widget.NewLabel("")
|
||||
hint.Wrapping = fyne.TextWrapWord
|
||||
note := widget.NewLabel("")
|
||||
note.Wrapping = fyne.TextWrapWord
|
||||
|
||||
// 只有「主動選過 Gemini」才預選 Gemini;其餘一律停在雲端 AI(含已填金鑰但沒選過的人)
|
||||
selected := optCloud
|
||||
if cfg.ExtractorExplicit && cfg.Extractor == "gemma" {
|
||||
selected = optGemini
|
||||
}
|
||||
radio := widget.NewRadioGroup([]string{optCloud, optGemini}, func(s string) {
|
||||
if s == optGemini {
|
||||
keyEntry.Show()
|
||||
note.SetText("到 aistudio.google.com/apikey 免費申請。\n" +
|
||||
"若顯示 Billing Tier: Unavailable,代表那個 Google 帳號被 Google 限制了,換一個帳號申請。")
|
||||
} else {
|
||||
keyEntry.Hide()
|
||||
note.SetText("用你自己 Cloudflare 帳號內建的 AI,不需要任何金鑰、不必去別的網站申請。")
|
||||
}
|
||||
})
|
||||
radio.SetSelected(selected)
|
||||
|
||||
form := container.NewVBox(
|
||||
widget.NewLabel("萃取用的 AI 設定"),
|
||||
widget.NewLabel("目前使用 Google Gemini。到 aistudio.google.com 免費申請金鑰,貼在下面:"),
|
||||
keyEntry, hint,
|
||||
widget.NewLabel("用哪個 AI 幫你整理文件?"),
|
||||
radio, keyEntry, note,
|
||||
)
|
||||
d := dialog.NewCustomConfirm("AI 設定", "儲存", "取消", form, func(ok bool) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
useGemini := radio.Selected == optGemini
|
||||
key := strings.TrimSpace(keyEntry.Text)
|
||||
if key == "" {
|
||||
dialog.ShowError(errors.New("金鑰是空的——請貼上 Gemini API Key"), win)
|
||||
if useGemini && key == "" {
|
||||
dialog.ShowError(errors.New("選了 Gemini 就要貼上金鑰;不想申請的話請改選「雲端 AI」"), win)
|
||||
return
|
||||
}
|
||||
engine := "workers-ai"
|
||||
if useGemini {
|
||||
engine = "gemma"
|
||||
cfg.GeminiAPIKey = key
|
||||
}
|
||||
// 機器層寫一份當預設;各帳號層一併寫,避免舊 config 殘留的空欄位繼承到別的值。
|
||||
cfg.GeminiAPIKey = key
|
||||
cfg.Extractor = "gemma"
|
||||
cfg.Extractor = engine
|
||||
cfg.ExtractorExplicit = true // ← 使用者主動選過,之後不再被預設覆蓋
|
||||
for i := range cfg.Accounts {
|
||||
cfg.Accounts[i].GeminiAPIKey = key
|
||||
cfg.Accounts[i].Extractor = "gemma"
|
||||
cfg.Accounts[i].Extractor = engine
|
||||
if useGemini {
|
||||
cfg.Accounts[i].GeminiAPIKey = key
|
||||
}
|
||||
}
|
||||
if err := saveConfig(cfg); err != nil {
|
||||
dialog.ShowError(err, win)
|
||||
return
|
||||
}
|
||||
restartWatch() // 讓新金鑰立刻生效,不必等下一輪
|
||||
restartWatch() // 立刻生效,不必等下一輪
|
||||
rebuildTray()
|
||||
dialog.ShowInformation("已儲存", "AI 設定已更新,接下來的同步會用這把金鑰萃取。", win)
|
||||
msg := "接下來的同步會用你自己 Cloudflare 帳號內建的 AI 整理文件(不需要金鑰)。"
|
||||
if useGemini {
|
||||
msg = "接下來的同步會用 Google Gemini 整理文件。"
|
||||
}
|
||||
dialog.ShowInformation("已儲存", msg, win)
|
||||
}, win)
|
||||
d.Resize(fyne.NewSize(460, 260))
|
||||
d.Resize(fyne.NewSize(520, 320))
|
||||
win.Show()
|
||||
win.RequestFocus()
|
||||
d.Show()
|
||||
|
||||
@@ -502,7 +502,10 @@ func TestAccountEngineLabel(t *testing.T) {
|
||||
{"帳號有claude→仍顯示 Gemini(實際就是走 gemma)", accountCfg{Extractor: "claude"}, "", " · Gemini"},
|
||||
{"帳號空退回default-gemma", accountCfg{}, "gemma", " · Gemini"},
|
||||
{"帳號空退回default-claude→仍顯示 Gemini", accountCfg{}, "claude", " · Gemini"},
|
||||
{"兩者皆空無標籤", accountCfg{}, "", ""},
|
||||
// t181:兩者皆空=新用戶=走 workers-ai(免金鑰預設)⇒ **不顯示標籤**
|
||||
// (leo 08-04:「上方如果用 workers AI 就不顯示」——預設路徑是常態,不佔版面)
|
||||
{"兩者皆空=預設雲端 AI=不顯示", accountCfg{}, "", ""},
|
||||
{"t181 明確 workers-ai 也不顯示", accountCfg{Extractor: "workers-ai"}, "", ""},
|
||||
{"帳號層優先於default", accountCfg{Extractor: "gemma"}, "claude", " · Gemini"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
|
||||
@@ -70,7 +70,12 @@ type DirectConfig struct {
|
||||
IngestWF string `json:"ingest_workflow"` // 直送萃取 workflow 名(空=rag_ingest_direct)
|
||||
RemovedWF string `json:"removed_workflow"` // 下架 workflow 名(空=rag_takedown_direct)
|
||||
// —— 四步定稿(daemon-beta t3/t4/t6):本地萃卡模式 ——
|
||||
Extractor string `json:"extractor,omitempty"` // "claude"|"gemma";空=舊制(內容直送雲端萃)
|
||||
Extractor string `json:"extractor,omitempty"` // "workers-ai"(預設,免金鑰)|"gemma"|"claude"(已停用)
|
||||
// t181:使用者**主動在托盤選過**萃取引擎才為 true。false=一律走 workers-ai。
|
||||
// 判準刻意不是「有沒有金鑰」——leo 08-04:「不管你現在是否有填金鑰」都要先 default
|
||||
// Workers AI,否則他得「花在解釋為什麼 Gemini 不管用上」。
|
||||
// 有金鑰但沒主動選 ⇒ 金鑰留著不動,之後選 Gemini 立刻可用。
|
||||
ExtractorExplicit bool `json:"extractor_explicit,omitempty"`
|
||||
ClaudeBin string `json:"claude_bin,omitempty"` // claude 執行檔(空=PATH 找 claude)
|
||||
GeminiAPIKey string `json:"gemini_api_key,omitempty"` // gemma 路的用戶 key
|
||||
LLMModel string `json:"llm_model,omitempty"` // gemma 路模型(空=gemma-4-31b-it)
|
||||
@@ -439,6 +444,27 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
|
||||
if cfg.Extractor == "claude" {
|
||||
cfg.Extractor = "gemma"
|
||||
}
|
||||
// 🔴 t181(leo 08-04 最優先):**所有人一律預設 Workers AI(免金鑰)**。
|
||||
// leo:「daemon 的 AI 改用 workers AI」——「這是我的用戶最大障礙,
|
||||
// 造成首輪測試用戶的好評或惡評」。
|
||||
//
|
||||
// ⚠️ 包含**已經填過金鑰的老用戶**(leo 08-04 特別交代):
|
||||
// 「default 用 Workers AI,你要用 Gemini 要**特別去選取**,
|
||||
// 不管你現在是否有填金鑰……不然我會有很多質疑,
|
||||
// **花在解釋為什麼 Gemini 不管用上**」
|
||||
// ⇒ 判準是 `extractor_explicit`(使用者在托盤主動選過)而不是「有沒有金鑰」。
|
||||
// 有金鑰但沒主動選 ⇒ 仍走 Workers AI,金鑰留著不動、之後選 Gemini 立刻可用。
|
||||
// Gemini 仍是選配(leo:「不需要推廣,特定人告訴他怎麼做就好」)。
|
||||
if !cfg.ExtractorExplicit {
|
||||
cfg.Extractor = "workers-ai"
|
||||
}
|
||||
if cfg.Extractor == "workers-ai" {
|
||||
// 這條路要的不是金鑰,是「連得上自己的知識庫」——沒連線才是真的沒法做事。
|
||||
if strings.TrimSpace(cfg.CypherURL) == "" || strings.TrimSpace(cfg.APIKey) == "" {
|
||||
extractorOK = false
|
||||
extractorError = "還沒連上知識庫 ⇒ 點托盤選單的「+ 新增帳號…」輸入網址與帳密"
|
||||
}
|
||||
}
|
||||
if cfg.Extractor == "gemma" {
|
||||
if strings.TrimSpace(cfg.GeminiAPIKey) == "" {
|
||||
extractorOK = false
|
||||
@@ -643,12 +669,16 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool) ([]DirectRes
|
||||
var cards []string
|
||||
var xerr error
|
||||
switch cfg.Extractor {
|
||||
case "workers-ai":
|
||||
// t181(leo 08-04 最優先):走自己雲端實例的 Workers AI ⇒ **免金鑰**。
|
||||
// 用戶不必去 Google 申請、也不受 Google 帳號被 flag 影響。
|
||||
cards, xerr = ExtractWithWorkersAI(cfg.CypherURL, cfg.APIKey, absRoot, ev.Path)
|
||||
case "gemma":
|
||||
cards, xerr = ExtractWithGemma(cfg.GeminiAPIKey, cfg.LLMModel, absRoot, ev.Path)
|
||||
default:
|
||||
// t176:claude 路先不支援(RunDirectOnce 開頭已把 "claude" 正規化為 "gemma")。
|
||||
// t176:claude 路先不支援(RunDirectOnce 開頭已正規化)。
|
||||
// 走到這裡代表 config 有沒見過的值——誠實報錯,不要靜默跳過(禁假綠)。
|
||||
xerr = fmt.Errorf("不支援的萃取方式 %q(目前只支援 Gemini)", cfg.Extractor)
|
||||
xerr = fmt.Errorf("不支援的萃取方式 %q(支援:workers-ai/gemma)", cfg.Extractor)
|
||||
}
|
||||
if xerr != nil {
|
||||
res.Status, res.Error = "failed", "本地萃取失敗:"+xerr.Error()
|
||||
|
||||
+55
-6
@@ -63,7 +63,7 @@ func TestDirectExtractorModeE2E(t *testing.T) {
|
||||
WatchFolders: []string{root},
|
||||
Manifest: filepath.Join(t.TempDir(), "m.json"),
|
||||
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
|
||||
Library: "kb", Extractor: "gemma", GeminiAPIKey: "k-test",
|
||||
Library: "kb", Extractor: "gemma", ExtractorExplicit: true, GeminiAPIKey: "k-test",
|
||||
CardIngestWF: "rag_ingest_card", MaxRemoved: DefaultMaxRemovedRatio,
|
||||
}
|
||||
results, exit, _ := RunDirectOnce(cfg, false)
|
||||
@@ -126,7 +126,7 @@ func TestDirectExtractorRemovedClearsLocalCard(t *testing.T) {
|
||||
WatchFolders: []string{root},
|
||||
Manifest: filepath.Join(t.TempDir(), "m.json"),
|
||||
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
|
||||
Library: "kb", Extractor: "gemma", GeminiAPIKey: "k-test",
|
||||
Library: "kb", Extractor: "gemma", ExtractorExplicit: true, GeminiAPIKey: "k-test",
|
||||
CardIngestWF: "rag_ingest_card", RemovedWF: "rag_takedown_direct",
|
||||
// 單檔刪除=removed ratio 100%,預設 0.4 防呆會壓下事件;本測試聚焦下架路,放寬到 1.0
|
||||
//(1 > 1.0×1 為 false → 事件放行)。
|
||||
@@ -179,7 +179,7 @@ func TestDirectExtractorRemovedNoLocalCardOK(t *testing.T) {
|
||||
WatchFolders: []string{root},
|
||||
Manifest: filepath.Join(t.TempDir(), "m.json"),
|
||||
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
|
||||
Extractor: "gemma", GeminiAPIKey: "k-test",
|
||||
Extractor: "gemma", ExtractorExplicit: true, GeminiAPIKey: "k-test",
|
||||
CardIngestWF: "rag_ingest_card", RemovedWF: "rag_takedown_direct",
|
||||
MaxRemoved: 1.0,
|
||||
}
|
||||
@@ -214,7 +214,7 @@ func TestDirectExtractorFailKeepsRetry(t *testing.T) {
|
||||
WatchFolders: []string{root},
|
||||
Manifest: filepath.Join(t.TempDir(), "m.json"),
|
||||
CypherURL: "https://x.example", Namespace: "demo", APIKey: "demo",
|
||||
Extractor: "gemma", GeminiAPIKey: "k-test", MaxRemoved: DefaultMaxRemovedRatio,
|
||||
Extractor: "gemma", ExtractorExplicit: true, GeminiAPIKey: "k-test", MaxRemoved: DefaultMaxRemovedRatio,
|
||||
}
|
||||
results, exit, _ := RunDirectOnce(cfg, false)
|
||||
if exit != 1 || len(results) != 1 || results[0].Status != "failed" {
|
||||
@@ -253,7 +253,7 @@ func TestMultiAccountInheritsExtractor(t *testing.T) {
|
||||
cfg := &DirectConfig{
|
||||
Manifest: filepath.Join(t.TempDir(), "m.json"),
|
||||
Library: "kb",
|
||||
Extractor: "gemma",
|
||||
Extractor: "gemma", ExtractorExplicit: true,
|
||||
GeminiAPIKey: "k-test",
|
||||
CardIngestWF: "rag_ingest_card",
|
||||
IngestWF: "rag_ingest_direct",
|
||||
@@ -312,7 +312,56 @@ func TestExtractorEmptyBlocksNonTextDirect(t *testing.T) {
|
||||
if len(results) != 1 || results[0].Status != "failed" {
|
||||
t.Fatalf("results=%+v", results)
|
||||
}
|
||||
if !strings.Contains(results[0].Error, "萃取器未設定") {
|
||||
// t181:預設改走 workers-ai(免金鑰)後,「沒設 extractor」不再是失敗原因
|
||||
// ⇒ 這隻壞 PDF 會停在**轉檔層**(更早、更正確的把關點)。
|
||||
// 本測真正要守的契約沒變、也仍然綠:**PDF 不得被直送上雲**(上面的 serverCalled)。
|
||||
if !strings.Contains(results[0].Error, "轉檔失敗") {
|
||||
t.Errorf("錯誤訊息不符:%q", results[0].Error)
|
||||
}
|
||||
}
|
||||
|
||||
// ── t181:預設一律走 Workers AI(免金鑰)──────────────────────────────────────
|
||||
//
|
||||
// leo 2026-08-04 特別交代(這是本測存在的理由):
|
||||
// 「default 用 Workers AI,你要用 Gemini 要**特別去選取**,**不管你現在是否有填金鑰**」
|
||||
// 「只要更新版本,就已經 default workers AI 了,除非去一個地方切換你指定的 AI 來源」
|
||||
// 「不然我會有很多質疑,**花在解釋為什麼 Gemini 不管用上**」
|
||||
// ⇒ 判準是 ExtractorExplicit(使用者主動選過),**不是**「有沒有金鑰」。
|
||||
func TestT181DefaultsToWorkersAI(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
extractor string
|
||||
explicit bool
|
||||
key string
|
||||
want string
|
||||
}{
|
||||
{"新用戶(什麼都沒設)", "", false, "", "workers-ai"},
|
||||
{"舊 config 有 gemma 但沒主動選", "gemma", false, "", "workers-ai"},
|
||||
// 🔴 最關鍵的一則:**有金鑰也照樣先走 Workers AI**
|
||||
{"有金鑰但沒主動選=仍走雲端 AI", "gemma", false, "AIza-xxx", "workers-ai"},
|
||||
{"殘留 claude 且沒主動選", "claude", false, "", "workers-ai"},
|
||||
// 主動選過才尊重他的選擇
|
||||
{"主動選了 Gemini", "gemma", true, "AIza-xxx", "gemma"},
|
||||
{"主動選了雲端 AI", "workers-ai", true, "", "workers-ai"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
cfg := &DirectConfig{
|
||||
WatchFolders: []string{t.TempDir()},
|
||||
Manifest: filepath.Join(t.TempDir(), "m.json"),
|
||||
CypherURL: "https://unused.example",
|
||||
Namespace: "demo",
|
||||
APIKey: "demo",
|
||||
Extractor: c.extractor,
|
||||
ExtractorExplicit: c.explicit,
|
||||
GeminiAPIKey: c.key,
|
||||
MaxRemoved: DefaultMaxRemovedRatio,
|
||||
}
|
||||
RunDirectOnce(cfg, false) // 空資料夾=零事件,只看預設邏輯把 Extractor 定成什麼
|
||||
if cfg.Extractor != c.want {
|
||||
t.Errorf("Extractor=%q want=%q(explicit=%v key=%q)",
|
||||
cfg.Extractor, c.want, c.explicit, c.key)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,6 +459,10 @@ func TestRunDirectOnceMultiAccount(t *testing.T) {
|
||||
{CypherURL: serverB.URL, Namespace: "nsB", WatchFolders: []string{rootB}},
|
||||
},
|
||||
MaxRemoved: DefaultMaxRemovedRatio,
|
||||
// t181:本測驗的是「雙帳號各自送到自己的實例」,不是萃取。
|
||||
// 預設已改成 workers-ai(會打雲端萃取端點),為隔離變因這裡明確走舊制直送
|
||||
// (Extractor 空+ExtractorExplicit=true ⇒ 不被預設覆蓋)。
|
||||
ExtractorExplicit: true,
|
||||
}
|
||||
results, exit, _ := RunDirectOnce(cfg, false)
|
||||
if exit != 0 {
|
||||
@@ -551,7 +555,7 @@ func TestAccountDataStructureFunctions(t *testing.T) {
|
||||
parent := &DirectConfig{
|
||||
Manifest: "/tmp/m.json",
|
||||
PollSec: 10,
|
||||
Extractor: "claude",
|
||||
Extractor: "claude", ExtractorExplicit: true,
|
||||
Accounts: []AccountConfig{
|
||||
{CypherURL: "https://a.example", Namespace: "nsA", WatchFolders: []string{"/folder1"}},
|
||||
},
|
||||
@@ -628,7 +632,7 @@ func TestRunDirectOnceAccountResultsTagged(t *testing.T) {
|
||||
func TestMakeAccountSubConfigExtractorOverride(t *testing.T) {
|
||||
parent := &DirectConfig{
|
||||
Manifest: "/tmp/m.json",
|
||||
Extractor: "claude",
|
||||
Extractor: "claude", ExtractorExplicit: true,
|
||||
GeminiAPIKey: "machine-key",
|
||||
LLMModel: "model-machine",
|
||||
PollSec: 5,
|
||||
@@ -638,7 +642,7 @@ func TestMakeAccountSubConfigExtractorOverride(t *testing.T) {
|
||||
CypherURL: "https://a.example",
|
||||
Namespace: "ns",
|
||||
WatchFolders: []string{"/tmp"},
|
||||
Extractor: "gemma", // 帳號層覆蓋
|
||||
Extractor: "gemma", // 帳號層覆蓋(AccountConfig 無 ExtractorExplicit,那是機器層旗標)
|
||||
GeminiAPIKey: "account-key", // 帳號層覆蓋
|
||||
LLMModel: "model-acc", // 帳號層覆蓋
|
||||
}
|
||||
@@ -658,7 +662,7 @@ func TestMakeAccountSubConfigExtractorOverride(t *testing.T) {
|
||||
func TestMakeAccountSubConfigExtractorFallback(t *testing.T) {
|
||||
parent := &DirectConfig{
|
||||
Manifest: "/tmp/m.json",
|
||||
Extractor: "gemma",
|
||||
Extractor: "gemma", ExtractorExplicit: true,
|
||||
GeminiAPIKey: "machine-key",
|
||||
LLMModel: "model-machine",
|
||||
PollSec: 5,
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
// 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 main
|
||||
|
||||
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)
|
||||
}
|
||||
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
|
||||
}
|
||||
+2
-2
@@ -172,7 +172,7 @@ func TestRunDirectOnceWritesStatus(t *testing.T) {
|
||||
WatchFolders: []string{root},
|
||||
Manifest: filepath.Join(manifestDir, "manifest.json"),
|
||||
CypherURL: "https://unused.example", Namespace: "demo",
|
||||
Extractor: "claude", GeminiAPIKey: "k-test",
|
||||
Extractor: "claude", ExtractorExplicit: true, GeminiAPIKey: "k-test",
|
||||
MaxRemoved: DefaultMaxRemovedRatio,
|
||||
}
|
||||
RunDirectOnce(cfg, false)
|
||||
@@ -209,7 +209,7 @@ func TestRunDirectOnceWritesStatusExtractorFail(t *testing.T) {
|
||||
WatchFolders: []string{root},
|
||||
Manifest: filepath.Join(manifestDir, "manifest.json"),
|
||||
CypherURL: "https://unused.example", Namespace: "demo",
|
||||
Extractor: "claude", ClaudeBin: "/no/such/claude",
|
||||
Extractor: "claude", ExtractorExplicit: true, ClaudeBin: "/no/such/claude",
|
||||
MaxRemoved: DefaultMaxRemovedRatio,
|
||||
}
|
||||
RunDirectOnce(cfg, false)
|
||||
|
||||
Reference in New Issue
Block a user