f233eec9c2
leo 08-15 量測:266 檔真正算的 ~20 分鐘/<$0.5,使用者卻等 ~4 天(多在等 Workers AI 每日額度);而「有哪些檔案/最近改了什麼」本地掃一遍就有答案 (1,335 檔實測 0.254s),卻因 part_of 只在雲端 parse_card(萃取之後)生成 而排同一條隊、撞同一面額度牆。 - collector/inventory.go:Scan() 後、萃取迴圈前(額度冷卻閘之外),把 manifest 現況做成機械總覽卡(檔案清單/最近改動/目錄 part_of)POST 既有 rag_ingest_card ——零 LLM、零新 workflow、既有實例直接受益;自帶同頁名 upsert 防重複 - 冪等:內容 sha256 記 manifest.inventory_hash;失敗 10 分鐘退避(防 t195 同款 每 5 秒重撞);額度失敗訊息人話化,不裸露 4006/502 - 清單 ≤200 逐檔、關聯 ≤10 條:守 Workers 免費層 50 subrequests 天花板 - 不寫本機檔案、不動使用者原稿 實測(youlin stage yuga3bse,刻意無金鑰=零萃取):總覽卡 200 送達、rag_chat 正確回答「有哪些檔案/最近改了什麼」;重掃不重複(blocks 恆 5、triplets 恆 2); go test collector+supervisor+arcrun-app 全綠(新增 inventory_test.go 6 案)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
177 lines
6.6 KiB
Go
177 lines
6.6 KiB
Go
// direct_quota_test.go — 額度用完要說人話並降速(2026-08-07 task 2)端到端驗證。
|
||
//
|
||
// 模擬 wiki mistakes.md 2026-08-06 記錄的真實上游狀況:Workers AI 每日免費額度
|
||
// 10,000 neurons 用完,HTTP 502,訊息含「4006: you have used up your daily free
|
||
// allocation of 10,000 neurons」。驗收:畫面(status.json)出現三句話,
|
||
// 沒有裸露的錯誤碼;且同一輪/下一輪不再繼續撞同一面牆。
|
||
package collector
|
||
|
||
import (
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// quotaExhaustedServer 對任何 /portal/daemon/extract 請求都回上游額度用完的原始錯誤。
|
||
// calls 只計「真的萃取」請求(body.text 非空)——ProbeWorkersAI 每輪固定會探測一次
|
||
// (送空 text,不燒 LLM 額度,見 probe_workersai.go),不是本測試要驗的「有沒有繼續撞牆」。
|
||
func quotaExhaustedServer(t *testing.T, calls *int) *httptest.Server {
|
||
t.Helper()
|
||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
var body struct {
|
||
Text string `json:"text"`
|
||
}
|
||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||
if strings.TrimSpace(body.Text) != "" {
|
||
*calls++
|
||
}
|
||
w.WriteHeader(http.StatusBadGateway) // 實據:HTTP 502
|
||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||
"error": "4006: you have used up your daily free allocation of 10,000 neurons",
|
||
})
|
||
}))
|
||
}
|
||
|
||
func TestDirect_QuotaExhausted_ShowsHumanMessageNoRawCode(t *testing.T) {
|
||
root := t.TempDir()
|
||
writeFile(t, root, "a.md", "內容 A", baseTime)
|
||
writeFile(t, root, "b.md", "內容 B", baseTime.Add(time.Minute))
|
||
|
||
var calls int
|
||
srv := quotaExhaustedServer(t, &calls)
|
||
defer srv.Close()
|
||
|
||
manifest := filepath.Join(t.TempDir(), "m.json")
|
||
cfg := &DirectConfig{
|
||
WatchFolders: []string{root},
|
||
Manifest: manifest,
|
||
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
|
||
Library: "kb", Extractor: "workers-ai", ExtractorExplicit: true,
|
||
MaxRemoved: DefaultMaxRemovedRatio,
|
||
}
|
||
|
||
results, exit, _ := RunDirectOnce(cfg, false)
|
||
if exit != 1 {
|
||
t.Fatalf("兩份都該萃取失敗,exit 應為 1,got %d", exit)
|
||
}
|
||
// 結構先行:本測試的假伺服器對**所有**端點回額度 502,總覽卡也會失敗——
|
||
// 它的錯誤同樣不准裸露錯誤碼(下方 banned 檢查涵蓋全部 results),
|
||
// 但三句話(成就/出口)是萃取路 quotaState 的契約,總覽卡只講自己的狀態。
|
||
inv, fileResults := splitInventory(results)
|
||
if len(inv) != 1 || inv[0].Status != "failed" {
|
||
t.Fatalf("inv=%+v", inv)
|
||
}
|
||
if !strings.Contains(inv[0].Error, "額度") || !strings.Contains(inv[0].Error, "不影響檔案同步") {
|
||
t.Errorf("總覽卡的額度失敗要說人話:%q", inv[0].Error)
|
||
}
|
||
if len(fileResults) != 2 {
|
||
t.Fatalf("results=%+v", fileResults)
|
||
}
|
||
|
||
banned := []string{"4006", "502", "HTTP", "neurons"}
|
||
for _, r := range results {
|
||
for _, b := range banned {
|
||
if strings.Contains(r.Error, b) {
|
||
t.Errorf("結果不該出現裸露的錯誤碼 %q:%q", b, r.Error)
|
||
}
|
||
}
|
||
}
|
||
for _, r := range fileResults {
|
||
// arcrun-rag#59:這支測試裡兩份檔案全部失敗、dailyCount 全程是 0——
|
||
// 正是 leo 實查那個「一份都沒成功、額度卻用完」的情境,buildQuotaNotice
|
||
// 改口不再承諾「自動恢復」(那是做不到的承諾)、也不再建議換模型
|
||
// (結構上做不到:嵌入不管選哪個萃取模型都走 Workers AI),只留「升級」這個真出口。
|
||
for _, want := range []string{"今天已經幫你整理了", "升級 Cloudflare"} {
|
||
if !strings.Contains(r.Error, want) {
|
||
t.Errorf("缺三句話之一 %q:%q", want, r.Error)
|
||
}
|
||
}
|
||
if strings.Contains(r.Error, "可以換一個模型") {
|
||
t.Errorf("dailyCount==0(這輪一份都沒成功)不該再建議換模型:%q", r.Error)
|
||
}
|
||
}
|
||
|
||
// status.json 也要是人話,同樣不准有裸碼
|
||
st, err := LoadSyncStatus(StatusFilePath(manifest))
|
||
if err != nil {
|
||
t.Fatalf("讀 status.json 失敗:%v", err)
|
||
}
|
||
if st.ExtractorOK {
|
||
t.Error("額度冷卻中,ExtractorOK 應為 false")
|
||
}
|
||
for _, b := range banned {
|
||
if strings.Contains(st.ExtractorError, b) {
|
||
t.Errorf("status.ExtractorError 不該有裸碼 %q:%q", b, st.ExtractorError)
|
||
}
|
||
}
|
||
acc, ok := st.AccountDetails[instanceHostOf(srv.URL)]
|
||
if !ok {
|
||
t.Fatal("找不到帳號的 status")
|
||
}
|
||
if acc.QuotaMessage == nil {
|
||
t.Fatal("應該有 QuotaMessage")
|
||
}
|
||
if acc.QuotaCooldownUntil == "" {
|
||
t.Fatal("應該記下冷卻到什麼時候")
|
||
}
|
||
for _, b := range banned {
|
||
combined := acc.QuotaMessage.Combined()
|
||
if strings.Contains(combined, b) {
|
||
t.Errorf("QuotaMessage 不該有裸碼 %q:%q", b, combined)
|
||
}
|
||
}
|
||
|
||
// 只有第一次真的打了 API(第二份的 xerr 判斷不需要——一偵測到就進冷卻,
|
||
// 第二個事件被上面的 qs.inCooldown 擋下,不再觸網)。
|
||
if calls != 1 {
|
||
t.Fatalf("偵測到額度用完後不該繼續撞牆,got %d 次呼叫", calls)
|
||
}
|
||
}
|
||
|
||
// 同一帳號、下一輪(新的 RunDirectOnce 呼叫,模擬下一次輪詢):冷卻還沒過,
|
||
// 一次網路呼叫都不該再打——這是「不要一次湧上去、也不要一直撞牆」的核心。
|
||
func TestDirect_QuotaExhausted_NextRunSkipsWithoutNetworkCall(t *testing.T) {
|
||
root := t.TempDir()
|
||
writeFile(t, root, "a.md", "內容 A", baseTime)
|
||
|
||
var calls int
|
||
srv := quotaExhaustedServer(t, &calls)
|
||
defer srv.Close()
|
||
|
||
cfg := &DirectConfig{
|
||
WatchFolders: []string{root},
|
||
Manifest: filepath.Join(t.TempDir(), "m.json"),
|
||
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
|
||
Library: "kb", Extractor: "workers-ai", ExtractorExplicit: true,
|
||
MaxRemoved: DefaultMaxRemovedRatio,
|
||
}
|
||
|
||
if _, exit, _ := RunDirectOnce(cfg, false); exit != 1 {
|
||
t.Fatal("第一輪應該偵測到額度用完")
|
||
}
|
||
if calls != 1 {
|
||
t.Fatalf("第一輪應該只打 1 次,got %d", calls)
|
||
}
|
||
|
||
results2, exit2, _ := RunDirectOnce(cfg, false)
|
||
if exit2 != 0 {
|
||
t.Fatalf("冷卻中的 skip 不是失敗,exit 應為 0,got %d results=%+v", exit2, results2)
|
||
}
|
||
if calls != 1 {
|
||
t.Fatalf("冷卻中不該再打任何一次 API,got %d 次呼叫", calls)
|
||
}
|
||
if len(results2) != 1 || results2[0].Status != "skipped" {
|
||
t.Fatalf("results2=%+v", results2)
|
||
}
|
||
// arcrun-rag#59:這支測試同樣是 dailyCount==0(單一檔案、全程沒有成功過),
|
||
// 跳過訊息改口成「升級 Cloudflare」這個真出口,不再是「自動恢復」的舊三句話。
|
||
if !strings.Contains(results2[0].Error, "今天已經幫你整理了") ||
|
||
!strings.Contains(results2[0].Error, "升級 Cloudflare") {
|
||
t.Errorf("跳過訊息也該是三句話(新版):%q", results2[0].Error)
|
||
}
|
||
}
|