ef8126201c
① 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>
146 lines
7.1 KiB
Go
146 lines
7.1 KiB
Go
// progress.go — 「你有幾份、我做完幾份、卡住的是哪幾類」(2026-08-08,Evan 封測 + leo 重新定形)。
|
||
//
|
||
// 🔴 這一層同時服務兩件事,所以獨立成檔:
|
||
//
|
||
// t210 首頁統計(使用者每天看的)
|
||
// t213 診斷檔(出事時唯一能拿到的東西)
|
||
//
|
||
// 兩邊必須是**同一組數字**——不然又會出現「畫面說 20、檔案說別的」。
|
||
//
|
||
// ── 為什麼要有分母 ────────────────────────────────────────────────────────
|
||
// leo 轉述 Evan:「我有 9000 個檔,雲端只有 101 張卡,畫面卻說『20 份沒送進知識庫』
|
||
// ——這幾個數字到底是怎麼回事?是壞了嗎?還是繼續在跑?」
|
||
// 病根:首頁每個數字都是**本輪**的(單輪上限 25 筆),使用者問的是**總量**的。
|
||
// 兩者永遠對不起來 ⇒ 他無法判斷「還在跑」還是「壞了」=會不會來找客服的分水嶺。
|
||
//
|
||
// ── 為什麼是分類而不是逐檔 ────────────────────────────────────────────────
|
||
// leo 2026-08-08:「**我不要枚舉每個檔案可能的問題和解法,應該是統計的**,
|
||
// 因為客戶的可能性太多了,以後會花很多力氣去想為什麼不能支援」
|
||
// ⇒ 分類**收斂且窮舉得完**,多出來的一律進「其他」。畫面不解釋、不給解法,
|
||
//
|
||
// 細節去 Docs FAQ。這取代 08-06 那套逐檔白話翻譯(humanizeFailure)。
|
||
package collector
|
||
|
||
import "strings"
|
||
|
||
// SyncProgress=總量進度。**單位一律是「份檔案」**(使用者丟進資料夾的東西)。
|
||
//
|
||
// 🔴 單位為什麼不是「張知識卡」(leo 原稿寫的):地端**算不出**待同步幾張卡——
|
||
// 還沒萃取的檔不知道會生出幾張。卡數只有雲端知道。而 leo 最初的痛點原話
|
||
// (「他的原檔 9000⋯⋯送到雲端 101/9000」)本來就是檔案單位。
|
||
// ⇒ 同一行內單位一致,數字才加得起來;卡與關聯另起一行當成果展示。
|
||
//
|
||
// 不變式:Total == Done + Pending + Stuck + Unreadable(t210 驗法①,測試守著)
|
||
type SyncProgress struct {
|
||
Total int `json:"total"` // 看守的資料夾裡,我認得的檔案總數
|
||
Done int `json:"done"` // 已送進知識庫且內容沒再變過
|
||
Pending int `json:"pending"` // 還沒送,會自動接著做(含退避等待中的)
|
||
Stuck int `json:"stuck"` // 連續失敗達上限、已暫停自動重試——**不會自己好**
|
||
Unreadable int `json:"unreadable"` // 格式讀不了,根本沒進 manifest(由呼叫端補進來)
|
||
}
|
||
|
||
// Add 把另一個資料夾/帳號的進度累加進來——首頁與診斷檔講的都是總量。
|
||
func (p SyncProgress) Add(o SyncProgress) SyncProgress {
|
||
return SyncProgress{
|
||
Total: p.Total + o.Total,
|
||
Done: p.Done + o.Done,
|
||
Pending: p.Pending + o.Pending,
|
||
Stuck: p.Stuck + o.Stuck,
|
||
Unreadable: p.Unreadable + o.Unreadable,
|
||
}
|
||
}
|
||
|
||
// Progress 數這份 manifest 的現況。
|
||
// manifest 每輪由 Scan() 重建、涵蓋現況所有檔(scan.go 第 7 步「現況檔全數收錄」)
|
||
// ⇒ 這裡原地數就是對的,不必另外維護計數器(也就不會有「計數器漂掉」那類病)。
|
||
func (m *Manifest) Progress() SyncProgress {
|
||
var p SyncProgress
|
||
for _, e := range m.Entries {
|
||
if e == nil {
|
||
continue
|
||
}
|
||
p.Total++
|
||
switch {
|
||
case e.IngestedHash != "" && e.IngestedHash == e.ContentHash:
|
||
p.Done++
|
||
case e.FailCount >= MaxFailBeforeSkip:
|
||
// 已經放棄自動重試的不能混在 Pending 裡假裝還在排隊——
|
||
// 使用者會一直等一件永遠不會發生的事。
|
||
p.Stuck++
|
||
default:
|
||
p.Pending++
|
||
}
|
||
}
|
||
return p
|
||
}
|
||
|
||
// ── 失敗分類(取代逐檔解釋)──────────────────────────────────────────────
|
||
//
|
||
// 🔴 收斂原則:這個清單**不准為每個新錯誤一直加**。認不出來的一律進 FailOther,
|
||
// 需要細節的人去 Docs FAQ 查。否則就會回到 leo 說的「花很多力氣去想為什麼不能支援」。
|
||
const (
|
||
FailUnsupportedFormat = "格式不支援"
|
||
FailQuotaExhausted = "今天的 AI 額度用完了"
|
||
FailNoTextInFile = "檔案讀不出文字"
|
||
FailOther = "其他"
|
||
)
|
||
|
||
// FailCategories 是分類的**固定順序**(畫面與診斷檔都照這個序,不隨 map 迭代跳動)。
|
||
var FailCategories = []string{FailUnsupportedFormat, FailQuotaExhausted, FailNoTextInFile, FailOther}
|
||
|
||
// ClassifyFailure 把 collector/雲端吐出的原始錯誤歸進上面四類。
|
||
//
|
||
// 判斷用的識別字全部來自實撞紀錄,不是猜的:
|
||
// - `4006` / `neurons`:Cloudflare Workers AI 當日免費額度用完(leo 2026-08-06 從
|
||
// Windows collector.log 挖出來的原文)
|
||
// - `沒有可抽取的文字`:掃描成圖片的 PDF(同日第二例)
|
||
// - `尚未支援的檔案格式`:白名單外的副檔名(G-6.2)
|
||
// - 退避訊息會把上游真因接在「|原因:」後面(direct.go retrySkipReason)
|
||
// ⇒ 先看得到真因就照真因分類,看不到才落到「其他」
|
||
func ClassifyFailure(raw string) string {
|
||
switch {
|
||
case strings.Contains(raw, "neurons"), strings.Contains(raw, "4006"),
|
||
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
|
||
case strings.Contains(raw, "尚未支援的檔案格式"), strings.Contains(raw, "不支援"):
|
||
return FailUnsupportedFormat
|
||
}
|
||
return FailOther
|
||
}
|
||
|
||
// FailureBreakdown 是「無法同步 F 份」展開後的內容:只有分類與份數,**沒有檔名、沒有解法**。
|
||
// 用固定順序的 slice 而非 map——JSON 輸出穩定、畫面不會每次重排。
|
||
type FailureBreakdown struct {
|
||
Total int `json:"total"`
|
||
Groups []FailureGroup `json:"groups"`
|
||
}
|
||
|
||
// FailureGroup=一類與它的份數。
|
||
type FailureGroup struct {
|
||
Category string `json:"category"`
|
||
Count int `json:"count"`
|
||
}
|
||
|
||
// BuildFailureBreakdown 把一批原始錯誤訊息歸類統計。
|
||
// 份數為 0 的分類不出現(沒事就別佔畫面,同 buildSkipped 的慣例)。
|
||
func BuildFailureBreakdown(rawErrors []string) FailureBreakdown {
|
||
counts := map[string]int{}
|
||
for _, raw := range rawErrors {
|
||
counts[ClassifyFailure(raw)]++
|
||
}
|
||
b := FailureBreakdown{Total: len(rawErrors)}
|
||
for _, cat := range FailCategories {
|
||
if n := counts[cat]; n > 0 {
|
||
b.Groups = append(b.Groups, FailureGroup{Category: cat, Count: n})
|
||
}
|
||
}
|
||
return b
|
||
}
|