feat(t210): 統計層地基——分母與失敗分類(首頁與診斷檔共用同一組數字)
leo 08-08:「我不要枚舉每個檔案可能的問題和解法,應該是統計的」。 SyncProgress 單位裁定為「份檔案」(地端算不出待同步幾張卡)。 不變式 Total == Done+Pending+Stuck+Unreadable 由測試守著。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+140
@@ -0,0 +1,140 @@
|
|||||||
|
// 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, "額度"):
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
// progress_test.go — t210 統計層。守的是 leo 08-08 定的兩條驗法:
|
||||||
|
//
|
||||||
|
// ① 四個數字相加要等於總數(「不准有對不起來的組合」——這整批的起因就是數字對不起來)
|
||||||
|
// ② 分類收斂:認不出來的一律進「其他」,不准為新錯誤一直長文案
|
||||||
|
package collector
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func mkEntry(content, ingested string, failCount int) *ManifestEntry {
|
||||||
|
return &ManifestEntry{ContentHash: content, IngestedHash: ingested, FailCount: failCount}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔴 leo 08-08 驗法①:相加必須等於總數。
|
||||||
|
func TestProgress_SumEqualsTotal(t *testing.T) {
|
||||||
|
m := &Manifest{Entries: map[string]*ManifestEntry{
|
||||||
|
"done1.md": mkEntry("h1", "h1", 0), // 已完成
|
||||||
|
"done2.md": mkEntry("h2", "h2", 0), // 已完成
|
||||||
|
"changed.md": mkEntry("h3new", "h3old", 0), // 改過了,要重送 → 待處理
|
||||||
|
"fresh.md": mkEntry("h4", "", 0), // 從沒送過 → 待處理
|
||||||
|
"retrying.md": mkEntry("h5", "", 3), // 退避中,但還沒放棄 → 待處理
|
||||||
|
"givenup.md": mkEntry("h6", "", MaxFailBeforeSkip), // 已放棄自動重試 → 卡住
|
||||||
|
}}
|
||||||
|
p := m.Progress()
|
||||||
|
|
||||||
|
if p.Total != 6 {
|
||||||
|
t.Fatalf("總數應為 6,got %d", p.Total)
|
||||||
|
}
|
||||||
|
if p.Done != 2 || p.Pending != 3 || p.Stuck != 1 {
|
||||||
|
t.Fatalf("分佈錯:Done=%d Pending=%d Stuck=%d(want 2/3/1)", p.Done, p.Pending, p.Stuck)
|
||||||
|
}
|
||||||
|
if sum := p.Done + p.Pending + p.Stuck + p.Unreadable; sum != p.Total {
|
||||||
|
t.Fatalf("🔴 數字對不起來:%d+%d+%d+%d=%d ≠ 總數 %d\n"+
|
||||||
|
"(這正是 Evan 2026-08-08 回報的病,不准再發生)",
|
||||||
|
p.Done, p.Pending, p.Stuck, p.Unreadable, sum, p.Total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已放棄重試的不可以混進「待處理」——否則使用者會一直等一件永遠不會發生的事。
|
||||||
|
func TestProgress_GivenUpIsNotPending(t *testing.T) {
|
||||||
|
m := &Manifest{Entries: map[string]*ManifestEntry{
|
||||||
|
"givenup.md": mkEntry("h", "", MaxFailBeforeSkip),
|
||||||
|
}}
|
||||||
|
p := m.Progress()
|
||||||
|
if p.Pending != 0 || p.Stuck != 1 {
|
||||||
|
t.Fatalf("放棄重試的該算 Stuck 不是 Pending:Pending=%d Stuck=%d", p.Pending, p.Stuck)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 多資料夾/多帳號時首頁講的是總量。
|
||||||
|
func TestProgress_Add(t *testing.T) {
|
||||||
|
a := SyncProgress{Total: 9000, Done: 101, Pending: 8879, Stuck: 20}
|
||||||
|
b := SyncProgress{Total: 10, Done: 10}
|
||||||
|
got := a.Add(b)
|
||||||
|
if got.Total != 9010 || got.Done != 111 || got.Pending != 8879 || got.Stuck != 20 {
|
||||||
|
t.Fatalf("累加錯:%+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分類用的是實撞過的錯誤原文,不是猜的。
|
||||||
|
func TestClassifyFailure_RealWorldMessages(t *testing.T) {
|
||||||
|
cases := []struct{ raw, want string }{
|
||||||
|
{"4006: you have used up your daily free allocation of 10,000 neurons", FailQuotaExhausted},
|
||||||
|
{"轉檔失敗(a.pdf):檔案裡沒有可抽取的文字", FailNoTextInFile},
|
||||||
|
{"尚未支援的檔案格式:.pages", FailUnsupportedFormat},
|
||||||
|
{"上次失敗(第 5 次),5h38m38s 後重試|原因:4006 neurons", FailQuotaExhausted},
|
||||||
|
{"connection reset by peer", FailOther},
|
||||||
|
{"", FailOther},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := ClassifyFailure(c.raw); got != c.want {
|
||||||
|
t.Errorf("ClassifyFailure(%q) = %q, want %q", c.raw, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 統計只出分類與份數;份數 0 的分類不佔畫面。
|
||||||
|
func TestBuildFailureBreakdown(t *testing.T) {
|
||||||
|
b := BuildFailureBreakdown([]string{
|
||||||
|
"4006 neurons", "4006 neurons",
|
||||||
|
"尚未支援的檔案格式:.pages",
|
||||||
|
"something we have never seen",
|
||||||
|
})
|
||||||
|
if b.Total != 4 {
|
||||||
|
t.Fatalf("總數應為 4,got %d", b.Total)
|
||||||
|
}
|
||||||
|
if len(b.Groups) != 3 {
|
||||||
|
t.Fatalf("應有 3 個非零分類(0 的不出現),got %d:%+v", len(b.Groups), b.Groups)
|
||||||
|
}
|
||||||
|
// 順序固定,畫面才不會每次重排
|
||||||
|
want := []FailureGroup{
|
||||||
|
{FailUnsupportedFormat, 1},
|
||||||
|
{FailQuotaExhausted, 2},
|
||||||
|
{FailOther, 1},
|
||||||
|
}
|
||||||
|
for i, w := range want {
|
||||||
|
if b.Groups[i] != w {
|
||||||
|
t.Fatalf("第 %d 組 = %+v, want %+v(順序須照 FailCategories)", i, b.Groups[i], w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 分組份數加起來要等於總數(同「數字對得起來」的不變式)
|
||||||
|
sum := 0
|
||||||
|
for _, g := range b.Groups {
|
||||||
|
sum += g.Count
|
||||||
|
}
|
||||||
|
if sum != b.Total {
|
||||||
|
t.Fatalf("分組加總 %d ≠ 總數 %d", sum, b.Total)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user