P8 短板齊平:模型品質實測(granite 否決、qwen3-30b 定案)+額度撞牆三句話接上畫面

① 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 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 00:44:34 +08:00
parent 779a1b801e
commit ef8126201c
5 changed files with 162 additions and 1 deletions
+40
View File
@@ -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.go2026-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()
+28
View File
@@ -83,6 +83,7 @@ function pageHome(s) {
</div>`).join('')}
</div>
</div>
${cardQuota(s.quota, s.progress)}
${cardTrouble(s)}
${cardProgress(s.progress)}
${cardSkipped(s.skipped)}
@@ -172,6 +173,33 @@ function cardTrouble(s) {
</div>`;
}
// 「今天的 AI 額度用完了」卡(P82026-08-09)。
//
// 🔴 存在理由:封測者 Evan 把「額度用完」讀成「這個 AI 沒效」——歸錯因、罵錯對象。
// collector 那半(quota.go08-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
? `<div class="d" style="margin-top:6px">還有 <b>${p.pending}</b> 份排隊中——會自動接著跑,你不用重丟。</div>` : '';
return `
<div class="card">
<h3>${esc(q.achievement)} 🎉</h3>
<div class="d" style="margin-top:6px">今天的免費 AI 額度用完了,先休息一下。<b>${esc(q.guarantee)}</b>。</div>
${pending}
<div class="d" style="margin-top:6px">急著要的話:${esc(q.exit_options)}。</div>
<div class="acts"><button class="ghost" data-openurl="https://rag.arcrun.dev/docs/">看看怎麼做</button></div>
</div>`;
}
// 你的檔案:分母 + 三個分類(t2102026-08-08,取代 08-06 逐檔白話翻譯)。
//
// 🔴 leo 08-08 轉述封測者 Evan:「我有 9000 個檔,雲端只有 101 張卡,畫面卻說
+71
View File
@@ -0,0 +1,71 @@
package main
// quota_card_test.go — P82026-08-09):額度三句話從 status.json 到首頁那張卡的佈線測試。
//
// 背景:collector 08-07 就把 QuotaNotice 寫進 status.jsonaccount_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)
}
}