Files
arcrun-collector/direct_quota_test.go
T
Leo f233eec9c2 feat(collector): 結構先行——掃描完幾秒內就能問「資料夾裡有什麼」,不等 LLM 不等額度(InkStoneCo#43)
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>
2026-08-15 16:39:45 +08:00

177 lines
6.6 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.
// 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 應為 1got %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 應為 0got %d results=%+v", exit2, results2)
}
if calls != 1 {
t.Fatalf("冷卻中不該再打任何一次 APIgot %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)
}
}