From ef8126201ce5b8743f5160a0300b4a7363e532ad Mon Sep 17 00:00:00 2001 From: richblack Date: Sun, 9 Aug 2026 00:44:34 +0800 Subject: [PATCH] =?UTF-8?q?P8=20=E7=9F=AD=E6=9D=BF=E9=BD=8A=E5=B9=B3?= =?UTF-8?q?=EF=BC=9A=E6=A8=A1=E5=9E=8B=E5=93=81=E8=B3=AA=E5=AF=A6=E6=B8=AC?= =?UTF-8?q?=EF=BC=88granite=20=E5=90=A6=E6=B1=BA=E3=80=81qwen3-30b=20?= =?UTF-8?q?=E5=AE=9A=E6=A1=88=EF=BC=89=EF=BC=8B=E9=A1=8D=E5=BA=A6=E6=92=9E?= =?UTF-8?q?=E7=89=86=E4=B8=89=E5=8F=A5=E8=A9=B1=E6=8E=A5=E4=B8=8A=E7=95=AB?= =?UTF-8?q?=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ① docs/benchmarks/p8-extractor-quality/:leo 真筆記 8 篇 × 5 模型可複驗實測 (production 同 prompt/參數、CF 原生 usage.neurons 計量)—— scout 84 n/檔(119 檔/日)→ qwen3-30b 43 n/檔(232 檔/日)品質不降反升; granite 最便宜(858 檔/日)但 5/8 缺段、三元組全滅=否決。 誠實結論:免金鑰路物理撐不起「幾千篇當天匯入」(差 13 倍), 正解=消化節奏(3,000 篇約 13 天背景跑完、新筆記優先)+出口(Gemini/付費/ollama)。 ② 撞牆體驗:quota_message 08-07 就在 status.json,App 從來沒接—— app.go pickQuotaNotice(過期快照不說謊)+ main.js cardQuota(三句話卡)+ progress.go ClassifyFailure 補認三句話(原本掉「其他」)。 Go 兩 module 全綠;瀏覽器實載 dist 深淺兩主題截圖、console 0 錯。 ③ 公開鏡像排除 docs/benchmarks(輸入是 leo 私人筆記)。 Co-Authored-By: Claude Opus 5 --- cmd/arcrun-app/app.go | 40 ++++++++++++++++ cmd/arcrun-app/frontend/src/main.js | 28 ++++++++++++ cmd/arcrun-app/quota_card_test.go | 71 +++++++++++++++++++++++++++++ progress.go | 7 ++- quota_test.go | 17 +++++++ 5 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 cmd/arcrun-app/quota_card_test.go diff --git a/cmd/arcrun-app/app.go b/cmd/arcrun-app/app.go index 3612711..e415e35 100644 --- a/cmd/arcrun-app/app.go +++ b/cmd/arcrun-app/app.go @@ -249,6 +249,15 @@ type UIState struct { EngineTrouble bool `json:"engineTrouble"` Progress UIProgress `json:"progress"` // 首頁「你的檔案」那行(t210) LogFolder string `json:"logFolder"` + // Quota=「今天的 AI 額度用完了」那張卡(P8,2026-08-09)。 + // + // 🔴 存在理由:collector 這半(quota.go)2026-08-07 就把 leo 定的三句話 + // (成就/出口/保證)寫進 status.json 的 quota_message 了,但 App 端從來沒接—— + // 使用者撞到額度時只看得到「送不上去 N 份」,正是封測者 Evan 把額度用完 + // 讀成「這個 AI 沒效」的那個黑箱(歸錯因、罵錯對象)。 + // 這裡原樣接住 collector 組好的三句話,不重新組字串(避免措辭漂移)。 + // nil=現在不在額度冷卻中,前端不畫這張卡。 + Quota *collector.QuotaNotice `json:"quota"` } // UISkipped=首頁那張「這些檔案現在還處理不了」的卡。 @@ -311,6 +320,36 @@ func buildProgress(s syncStatus) UIProgress { return u } +// pickQuotaNotice 從 status.json 的 per-account 狀態裡挑出「現在還有效」的額度三句話。 +// +// 有效=ResumeAt 還沒到:collector 每輪重建 AccountSyncStatus,冷卻過了自然不再寫 +// quota_message,但 daemon 若整夜沒跑(電腦闔蓋),status.json 會停在昨天的快照—— +// 額度其實已經恢復了,這時再顯示「明天早上 8 點恢復」就是在說謊,所以以 ResumeAt 判生死。 +// 多帳號同時冷卻時挑最早恢復的那份(key 排序保證同一份輸入永遠同一個輸出,好測試)。 +func pickQuotaNotice(s syncStatus, now time.Time) *collector.QuotaNotice { + keys := make([]string, 0, len(s.AccountDetails)) + for k := range s.AccountDetails { + keys = append(keys, k) + } + sort.Strings(keys) + var best *collector.QuotaNotice + var bestAt time.Time + for _, k := range keys { + q := s.AccountDetails[k].QuotaMessage + if q == nil { + continue + } + at, err := time.Parse(time.RFC3339, q.ResumeAt) + if err != nil || !at.After(now) { + continue // 解析不了或已恢復 ⇒ 過期快照,不畫(下一輪 collector 會清掉) + } + if best == nil || at.Before(bestAt) { + best, bestAt = q, at + } + } + return best +} + // buildSkipped 把 status.json 的三個欄位翻成首頁看得懂的一張卡。 // 完全沒有東西被略過時回 nil ⇒ 前端不畫這張卡(沒事就別佔畫面)。 func buildSkipped(s syncStatus) *UISkipped { @@ -458,6 +497,7 @@ func (a *App) GetState() UIState { st.Steps = buildSteps(sync, st.Syncing) st.Skipped = buildSkipped(sync) st.Progress = buildProgress(sync) + st.Quota = pickQuotaNotice(sync, time.Now()) // P8:額度冷卻中 ⇒ 首頁畫三句話卡 // 引擎有問題才把「回報問題」卡叫出來(含記錄檔路徑)。 // 沒事時不顯示——否則「哪裡看 log」會變成常駐噪音,真出事時反而沒人看。 st.EngineTrouble = !collectorAlive() diff --git a/cmd/arcrun-app/frontend/src/main.js b/cmd/arcrun-app/frontend/src/main.js index 8f83e31..4094b57 100644 --- a/cmd/arcrun-app/frontend/src/main.js +++ b/cmd/arcrun-app/frontend/src/main.js @@ -83,6 +83,7 @@ function pageHome(s) { `).join('')} + ${cardQuota(s.quota, s.progress)} ${cardTrouble(s)} ${cardProgress(s.progress)} ${cardSkipped(s.skipped)} @@ -172,6 +173,33 @@ function cardTrouble(s) { `; } +// 「今天的 AI 額度用完了」卡(P8,2026-08-09)。 +// +// 🔴 存在理由:封測者 Evan 把「額度用完」讀成「這個 AI 沒效」——歸錯因、罵錯對象。 +// collector 那半(quota.go)08-07 就把 leo 定的三句話寫進 status.json 了, +// 但畫面從來沒接,使用者撞牆時只看得到「送不上去 N 份」。 +// +// leo 08-07 定的三句話骨架(缺一不可,順序就是敘事順序): +// ① 成就:「今天已經幫你整理了 N 份」——先講做到什麼,不是先講失敗 +// ② 出口:「可以換一個模型,或升級 Cloudflare(每月 5 美元)」——給選擇不是死路 +// ③ 保證:「不花錢也沒關係,明天早上 8:00 會自動恢復、會接著跑」 +// 三句話原文全部來自後端 QuotaNotice(quota.go 組的),這裡不重組字串—— +// 避免同一件事在 status.json、診斷檔、畫面各說各話(措辭漂移)。 +// 排隊數取自同一份 s.progress(t210 統計層),讓「還剩多少」也有答案。 +function cardQuota(q, p) { + if (!q) return ''; + const pending = p && p.pending > 0 + ? `
還有 ${p.pending} 份排隊中——會自動接著跑,你不用重丟。
` : ''; + return ` +
+

${esc(q.achievement)} 🎉

+
今天的免費 AI 額度用完了,先休息一下。${esc(q.guarantee)}
+ ${pending} +
急著要的話:${esc(q.exit_options)}。
+
+
`; +} + // 你的檔案:分母 + 三個分類(t210,2026-08-08,取代 08-06 逐檔白話翻譯)。 // // 🔴 leo 08-08 轉述封測者 Evan:「我有 9000 個檔,雲端只有 101 張卡,畫面卻說 diff --git a/cmd/arcrun-app/quota_card_test.go b/cmd/arcrun-app/quota_card_test.go new file mode 100644 index 0000000..639621e --- /dev/null +++ b/cmd/arcrun-app/quota_card_test.go @@ -0,0 +1,71 @@ +package main + +// quota_card_test.go — P8(2026-08-09):額度三句話從 status.json 到首頁那張卡的佈線測試。 +// +// 背景:collector 08-07 就把 QuotaNotice 寫進 status.json(account_details[].quota_message), +// 但 App 端從來沒接——使用者撞額度只看得到「送不上去 N 份」。這裡釘住 pickQuotaNotice 的 +// 三個行為:有效冷卻要撿到、過期快照不准顯示(不然會說「明天早上 8 點恢復」的謊)、 +// 多帳號挑最早恢復的那份。 + +import ( + "testing" + "time" + + collector "arcrun-rag/collector" +) + +func mkNotice(resumeAt time.Time, n int) *collector.QuotaNotice { + return &collector.QuotaNotice{ + Achievement: "今天已經幫你整理了 105 份", + ExitOptions: "可以換一個模型,或升級 Cloudflare(每月 5 美元)", + Guarantee: "不花錢也沒關係,明天早上 8:00 會自動恢復、會接著跑", + ResumeAt: resumeAt.Format(time.RFC3339), + } +} + +func TestPickQuotaNotice_ActiveCooldownIsShown(t *testing.T) { + now := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC) + s := syncStatus{AccountDetails: map[string]collector.AccountSyncStatus{ + "youlin.example": {QuotaMessage: mkNotice(now.Add(3*time.Hour), 105)}, + }} + q := pickQuotaNotice(s, now) + if q == nil { + t.Fatal("冷卻還沒到期,首頁應該要拿到三句話") + } + if q.Achievement == "" || q.ExitOptions == "" || q.Guarantee == "" { + t.Fatalf("三句話缺一不可(leo 08-07 骨架):%+v", q) + } +} + +func TestPickQuotaNotice_StaleSnapshotIsHidden(t *testing.T) { + // daemon 整夜沒跑(電腦闔蓋),status.json 停在昨天的快照——額度其實已恢復。 + // 這時再顯示「明天早上 8 點恢復」就是在說謊 ⇒ 過期=不畫。 + now := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC) + s := syncStatus{AccountDetails: map[string]collector.AccountSyncStatus{ + "youlin.example": {QuotaMessage: mkNotice(now.Add(-time.Hour), 105)}, + }} + if q := pickQuotaNotice(s, now); q != nil { + t.Fatalf("過期快照不准顯示,got %+v", q) + } + // ResumeAt 解析不了 ⇒ 同樣不畫(寧可少顯示,不顯示錯的承諾) + bad := mkNotice(now.Add(time.Hour), 1) + bad.ResumeAt = "not-a-time" + s.AccountDetails["youlin.example"] = collector.AccountSyncStatus{QuotaMessage: bad} + if q := pickQuotaNotice(s, now); q != nil { + t.Fatalf("ResumeAt 壞掉不准顯示,got %+v", q) + } +} + +func TestPickQuotaNotice_MultiAccountPicksEarliestResume(t *testing.T) { + now := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC) + early := mkNotice(now.Add(1*time.Hour), 40) + late := mkNotice(now.Add(5*time.Hour), 80) + s := syncStatus{AccountDetails: map[string]collector.AccountSyncStatus{ + "b.example": {QuotaMessage: late}, + "a.example": {QuotaMessage: early}, + }} + q := pickQuotaNotice(s, now) + if q == nil || q.ResumeAt != early.ResumeAt { + t.Fatalf("多帳號應挑最早恢復的那份,got %+v", q) + } +} diff --git a/progress.go b/progress.go index ef31a1b..361b4b4 100644 --- a/progress.go +++ b/progress.go @@ -100,7 +100,12 @@ var FailCategories = []string{FailUnsupportedFormat, FailQuotaExhausted, FailNoT func ClassifyFailure(raw string) string { switch { case strings.Contains(raw, "neurons"), strings.Contains(raw, "4006"), - strings.Contains(raw, "額度"): + strings.Contains(raw, "額度"), + // 2026-08-09(P8):額度用完的檔,錯誤欄位存的是 QuotaNotice 三句話 + // (quota.go Combined()),文案裡刻意沒有「額度」「4006」這些字—— + // 少了這兩個識別字,這批檔會被歸進「其他」,畫面上「今天的 AI 額度用完了」 + // 反而一份都沒有。識別字取三句話裡最不會變動的兩段。 + strings.Contains(raw, "幫你整理了"), strings.Contains(raw, "會自動恢復"): return FailQuotaExhausted case strings.Contains(raw, "沒有可抽取的文字"): return FailNoTextInFile diff --git a/quota_test.go b/quota_test.go index 7cd2c28..8a27f9d 100644 --- a/quota_test.go +++ b/quota_test.go @@ -108,3 +108,20 @@ func TestQuotaState_InCooldown(t *testing.T) { t.Fatal("過了冷卻時間應該解除") } } + +// P8(2026-08-09):額度三句話會被存進檔案的失敗欄位(direct.go res.Error), +// 再流進 FailureBreakdown 分類。三句話文案刻意不含「額度」「4006」—— +// 若 ClassifyFailure 認不得它,畫面上「今天的 AI 額度用完了」那類反而 0 份、 +// 全部掉進「其他」。這支測試釘住「三句話 → 額度分類」這條線。 +func TestClassifyFailure_QuotaNoticeCombined(t *testing.T) { + now := time.Date(2026, 8, 9, 3, 0, 0, 0, time.UTC) + notice := buildQuotaNotice(now, 105, nextQuotaResetTaiwan(now)) + if got := ClassifyFailure(notice.Combined()); got != FailQuotaExhausted { + t.Fatalf("三句話應歸進額度分類,got %q(訊息:%s)", got, notice.Combined()) + } + // 退避訊息會把三句話接在「|原因:」後(direct.go retrySkipReason)——同樣要認得 + wrapped := "上次失敗(第 2 次),58m 後重試|原因:" + notice.Combined() + if got := ClassifyFailure(wrapped); got != FailQuotaExhausted { + t.Fatalf("包在退避訊息裡也應歸進額度分類,got %q", got) + } +}