From b3f63f40fb186bae721c864ad440fa85ea812369 Mon Sep 17 00:00:00 2001 From: richblack Date: Sat, 8 Aug 2026 12:45:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(t210):=20=E9=A6=96=E9=A0=81=E6=94=B9?= =?UTF-8?q?=E7=B5=B1=E8=A8=88=EF=BC=8C=E4=B8=8D=E5=86=8D=E9=80=90=E6=AA=94?= =?UTF-8?q?=E8=A7=A3=E9=87=8B=E2=80=94=E2=80=94Evan=E3=80=8C9000/101/20=20?= =?UTF-8?q?=E5=85=9C=E4=B8=8D=E8=B5=B7=E4=BE=86=E3=80=8D=E7=9A=84=E7=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 地基(progress.go 的 SyncProgress/ClassifyFailure/BuildFailureBreakdown, eeb35ba)已經算好「總量」與「失敗分類」,但沒人接到畫面上:首頁仍在用 本輪計數(extractedOK)+逐檔白話翻譯(humanizeFailure),導致 leo 08-08 轉述的病——三個數字互相對不起來,使用者無法判斷「還在跑」還是「壞了」。 接線(collector/direct.go): - runDirectOnceRoot 現在也回傳這一根資料夾的 SyncProgress/StuckReasons (rootProgress),RunDirectOnce 跨帳號跨資料夾 Add() 累加成總量。 - G-6.2 的 SkippedDocCount(讀不了的檔,根本沒進 manifest)併進 Unreadable/Total——這是 Progress() 算不到的部分,由呼叫端補齊, 維持不變式 Total == Done+Pending+Stuck+Unreadable。 - 「送不上去」的分類統計=Stuck 的 LastError 原文+Unreadable 重用 convert.go 既有的 ErrUnsupported,一起餵給 BuildFailureBreakdown。 分類判斷全程只經過 progress.go 的 ClassifyFailure 一個接縫, direct.go/app.go/前端都不認得任何分類名稱字串(留給 t214 之後 改資料驅動時只動一個檔)。 - SyncStatus 新增 Progress/FailureBreakdown 兩個欄位,兩者都是每輪從 manifest/掃描結果原地重算的現況快照,不進 CarryForwardActivity—— 斷網或閒置一輪不會被清成 0。 畫面(cmd/arcrun-app/app.go+frontend/src/main.js): - 移除 humanizeFailure/buildFailures/UIFailures 那套逐檔白話翻譯, 改用 UIProgress(Total/Done/Pending/CantSync/Groups);前端 cardProgress 只把後端給的 category/count 陣列原樣印出,不分支、不排序、不認分類名。 - 「送不上去」預設摺疊(
),展開只有分類與份數,不逐檔列名、 不解釋、不給解法;細節導向「開啟使用說明」。 - 保留 buildSkipped 的「讀不了的檔」卡片(那是另一件事),但份數已併入 Unreadable。 - 移除「總計」卡片裡用本輪計數 extractedOK 的「份已整理」——上方狀態 時間軸的「上次 N 份」與下方矛盾(1 份 vs 0 份已整理)的病因直接消掉, 改用 cardProgress 的累計「已送上去」。 驗證: - go build ./... 與 go test ./...(collector/cmd/arcrun-app/ cmd/arcrun-tray 三個 module)全綠。 - 新增 collector/progress_wiring_test.go:真跑 RunDirectOnce 湊出 Done/Pending/Stuck/Unreadable 四種狀態同時存在,驗四數字相加等於 總數(leo 驗法①);再跑一輪「什麼都沒發生」驗數字不歸零(驗法③)。 - check-render.sh 視覺機械閘綠(lockup 底板/深色模式)。 殘項(誠實標記,未完成): - 真機驗收(leo 08-08 驗法④:拿 Evan 的情境走一遍)未做,需要 leo 或 封測者在實機驗證。 - 「開啟使用說明」目前連到既有 docs 首頁,尚無 t210 分類對應的 FAQ 頁 (tasks.md 已記為相依項)。 - t213 診斷檔尚未消費這組新欄位(tasks.md 記載該任務等本任務讓路)。 Co-Authored-By: Claude Opus 5 --- cmd/arcrun-app/app.go | 188 ++++++++------------------ cmd/arcrun-app/frontend/src/main.js | 54 +++++--- cmd/arcrun-app/frontend/src/style.css | 22 +-- direct.go | 59 +++++++- progress_wiring_test.go | 164 ++++++++++++++++++++++ sync_status.go | 16 +++ 6 files changed, 333 insertions(+), 170 deletions(-) create mode 100644 progress_wiring_test.go diff --git a/cmd/arcrun-app/app.go b/cmd/arcrun-app/app.go index 7efb583..d452e43 100644 --- a/cmd/arcrun-app/app.go +++ b/cmd/arcrun-app/app.go @@ -23,13 +23,12 @@ import ( "os" "os/exec" "path/filepath" - "regexp" runtime2 "runtime" "sort" - "strconv" "strings" "time" + collector "arcrun-rag/collector" "github.com/wailsapp/wails/v2/pkg/runtime" ) @@ -77,20 +76,10 @@ type directConfig struct { // syncStatus 對映 collector 寫的 status.json(只取 UI 要的欄位)。 type syncStatus struct { - LastSync string `json:"last_sync,omitempty"` - ExtractedOK int `json:"extracted_ok"` - ExtractFailed int `json:"extract_failed"` - // 🔴 2026-08-06:collector 早就把「哪個檔失敗、為什麼」寫進 status.json 了 - // (sync_status.go 的 Failures),但 App 一直只讀計數 ⇒ 畫面只會說「⚠ 3 份失敗」, - // 使用者與遠端的我都不知道是哪一份、什麼原因。**今天第四次同款病** - // (前三次:exit status 2 蓋掉真話/非文件檔不點名/誤判沒連線)。 - Failures []struct { - Path string `json:"path"` - Error string `json:"error"` - } `json:"failures"` + LastSync string `json:"last_sync,omitempty"` ExtractorOK bool `json:"extractor_ok"` ExtractorError string `json:"extractor_error,omitempty"` - // 「上次真的有做事」那一輪(2026-08-05)——ExtractedOK 是本輪計數、會被下一輪歸零, + // 「上次真的有做事」那一輪(2026-08-05)——本輪計數會被下一輪歸零, // 只靠它顯示成果,使用者在同步完成 15 秒後就看不到任何證據(leo 實撞)。 LastActivityAt string `json:"last_activity_at,omitempty"` LastActivityOK int `json:"last_activity_ok"` @@ -101,6 +90,12 @@ type syncStatus struct { SkippedDocCount int `json:"skipped_doc_count"` SkippedOtherCount int `json:"skipped_other_count"` SkippedOtherNames []string `json:"skipped_other_names"` + // t210(2026-08-08):總量進度快照+無法同步的分類統計——**同一組數字** + // 供首頁與診斷檔(t213)共用,不再各算各的。兩者的形狀(欄位、JSON tag) + // 都定義在 collector/progress.go,這裡只是原樣接住 status.json 裡的那一份, + // 不重新定義結構,避免兩邊的欄位定義漂移。 + Progress collector.SyncProgress `json:"progress"` + FailureBreakdown collector.FailureBreakdown `json:"failure_breakdown"` } type skippedDoc struct { @@ -226,21 +221,20 @@ type UIAccount struct { Folders []UIFolder `json:"folders"` } type UIState struct { - Version string `json:"version"` - StatusBig string `json:"statusBig"` - StatusSub string `json:"statusSub"` - Syncing bool `json:"syncing"` - Accounts []UIAccount `json:"accounts"` - Engine string `json:"engine"` // "workers-ai" | "gemma" - GeminiKey string `json:"geminiKey"` // 只回遮罩,不回真值 - ExtractedOK int `json:"extractedOK"` // 首頁「已整理幾份」 - Steps []Step `json:"steps"` // 首頁狀態時間軸(leo #6) - Skipped *UISkipped `json:"skipped"` // 讀不了的檔(沒有就是 null,前端不畫) + Version string `json:"version"` + StatusBig string `json:"statusBig"` + StatusSub string `json:"statusSub"` + Syncing bool `json:"syncing"` + Accounts []UIAccount `json:"accounts"` + Engine string `json:"engine"` // "workers-ai" | "gemma" + GeminiKey string `json:"geminiKey"` // 只回遮罩,不回真值 + Steps []Step `json:"steps"` // 首頁狀態時間軸(leo #6) + Skipped *UISkipped `json:"skipped"` // 讀不了的檔(沒有就是 null,前端不畫) // EngineTrouble=同步引擎有問題(沒在跑/一直啟動失敗)⇒ 前端才長出「回報問題」卡。 // 沒事時不顯示,避免把「哪裡看 log」變成常駐噪音。 - EngineTrouble bool `json:"engineTrouble"` - Failures *UIFailures `json:"failures"` // 沒有就是 null,前端不畫 - LogFolder string `json:"logFolder"` + EngineTrouble bool `json:"engineTrouble"` + Progress UIProgress `json:"progress"` // 首頁「你的檔案」那行(t210) + LogFolder string `json:"logFolder"` } // UISkipped=首頁那張「這些檔案現在還處理不了」的卡。 @@ -260,116 +254,45 @@ type UISkipped struct { Other string `json:"other"` // 非文件檔的一行說明(沒有就空字串) } -// UIFailures=首頁那張「有幾份沒送上去」的卡。 +// UIProgress=首頁「你的檔案」那張卡(t210,2026-08-08,取代 08-06 的逐檔白話翻譯)。 // -// 🔴 存在理由(leo 2026-08-06 實測):畫面寫「⚠ 3 份失敗」但**不說是哪一份、為什麼** -// ⇒ leo 只能回報「新的兩個檔案確實沒推到雲端」,我只能猜。 -// 而原因**本來就在 status.json 裡**,只是沒人拿出來用。 -type UIFailures struct { - Title string `json:"title"` - Note string `json:"note"` - Items []UIFailItem `json:"items"` - More int `json:"more"` -} - -type UIFailItem struct { - Name string `json:"name"` - Reason string `json:"reason"` -} - -// maxFailShown=最多列幾份(多了會洗版;總數仍照實講)。 -const maxFailShown = 8 - -// parseRetryMessage 從退避訊息裡取出「試了幾次、多久後再試、原因是什麼」。 -// 格式來源=collector 的 retrySkipReason()。解析失敗就回 ok=false,由呼叫端原樣顯示。 -func parseRetryMessage(raw string) (n int, wait, reason string, ok bool) { - m := retryMsgRe.FindStringSubmatch(raw) - if m == nil { - return 0, "", "", false - } - n, _ = strconv.Atoi(m[1]) - wait = humanizeDuration(m[2]) - if i := strings.Index(raw, "|原因:"); i >= 0 { - reason = raw[i+len("|原因:"):] - } - return n, wait, reason, true -} - -var retryMsgRe = regexp.MustCompile(`上次失敗(第 (\d+) 次),([0-9hms.]+) 後重試`) - -// humanizeDuration 把 `5h38m38s` 講成「5 小時」——使用者不需要精確到秒。 -func humanizeDuration(d string) string { - dur, err := time.ParseDuration(d) - if err != nil { - return d - } - switch { - case dur >= time.Hour: - return fmt.Sprintf("%d 小時", int(dur.Hours())) - case dur >= time.Minute: - return fmt.Sprintf("%d 分鐘", int(dur.Minutes())) - default: - return "不到 1 分鐘" - } -} - -// humanizeFailure 把 collector 的技術錯誤翻成使用者看得懂、而且**知道要不要行動**的一句話。 +// 🔴 存在理由(leo 轉述封測者 Evan 08-08 原話):「我有 9000 個檔,雲端只有 101 張卡, +// 畫面卻說『20 份沒送進知識庫』——這幾個數字到底是怎麼回事?是壞了嗎?還是繼續在跑?」 +// ⇒ 首頁要能一次講完「你有幾份、我做完幾份、剩下幾份會自動接著做」, // -// 🔴 leo 2026-08-06 實測,兩個真實案例(從他 Windows 的 collector.log 挖出來的): +// 單位一律是「份檔案」,Total/Done/Pending/CantSync 四個數字相加要等於 Total +// (leo 08-08 驗法①,源頭不變式見 collector/progress.go)。 // -// ① `4006: you have used up your daily free allocation of 10,000 neurons` -// = Cloudflare Workers AI **當日免費額度用完**。不是檔案的問題、也不是壞掉, -// 明天會自動恢復。使用者若不知道,只會以為程式壞了、反覆重丟。 -// ② `轉檔失敗(…pdf):檔案裡沒有可抽取的文字` -// = 那份 PDF 是**掃描的圖片**,沒有文字層。要嘛做 OCR,要嘛換一份。 -// -// 兩件事的處置完全相反(一個等就好、一個要動手),所以**不能都叫「失敗」了事**。 -func humanizeFailure(raw string) string { - switch { - case strings.Contains(raw, "neurons"), strings.Contains(raw, "4006"): - return "今天的免費 AI 額度用完了 ⇒ 明天會自動恢復,這些檔案會自己補上,你不用做什麼。" + - "(想馬上處理可以到 Cloudflare 升級 Workers Paid 方案)" - case strings.Contains(raw, "沒有可抽取的文字"): - return "這份 PDF 看起來是掃描的圖片,沒有文字可以讀 ⇒ 需要先做文字辨識(OCR),或換一份有文字的版本。" - case strings.Contains(raw, "尚未支援的檔案格式"): - return "這種檔案格式還讀不了 ⇒ 先另存成 PDF 或 Word(.docx)再放回同一個資料夾。" - } - - // 退避訊息:collector 給的是「上次失敗(第 5 次),5h38m38s 後重試|原因:…」。 - // - // 🔴 leo 2026-08-06:「**這個錯誤訊息我真的看不懂,明白說比較好**」 - // ——我原本只是把原文轉貼、再加一句「上面是自動重試的排程」,那是寫給我自己看的。 - // 使用者要的是三件事:**現在怎樣、為什麼、我要不要做什麼**。 - if n, wait, reason, ok := parseRetryMessage(raw); ok { - if reason != "" { - // 有原因就以原因為主,排程放後面當補充(他真正需要判斷的是原因) - return humanizeFailure(reason) + - fmt.Sprintf("(已經自動試過 %d 次,約 %s後會再試一次)", n, wait) - } - return fmt.Sprintf("已經自動試過 %d 次都沒成功,約 %s後會再試一次。"+ - "這個檔是在舊版失敗的,當時沒有記下原因;如果一直沒好,"+ - "到「版本與更新」旁邊把紀錄檔傳給我們。", n, wait) - } - return raw +// 🔴 leo 08-08 追加約束(t214 預留):**分類判斷只住在 collector/progress.go 的 +// ClassifyFailure 一個接縫**——這裡與前端 main.js 都不准認得任何一個分類名稱字串, +// Groups 原樣照後端給的 category/count 陣列畫,順序也照後端給的(FailCategories)。 +// 之後把分類改成資料驅動時,才只需要動那一個檔。 +type UIProgress struct { + Total int `json:"total"` // 你的檔案,共幾份 + Done int `json:"done"` // 已送上去 + Pending int `json:"pending"` // 排隊中(會自動接著做) + CantSync int `json:"cantSync"` // 送不上去(=卡住+讀不了,預設摺疊,展開看 Groups) + Groups []UIFailGroup `json:"groups"` // 「送不上去」展開後的分類統計;沒有就是空陣列 } -func buildFailures(s syncStatus) *UIFailures { - if len(s.Failures) == 0 { - return nil +// UIFailGroup=一個分類與它的份數,逐字接住 collector.FailureGroup(不重新判斷)。 +type UIFailGroup struct { + Category string `json:"category"` + Count int `json:"count"` +} + +// buildProgress 把 collector 已經算好的 SyncProgress/FailureBreakdown +// 轉成前端要的形狀——只搬資料,不重新判斷任何分類。 +func buildProgress(s syncStatus) UIProgress { + p := s.Progress + u := UIProgress{ + Total: p.Total, + Done: p.Done, + Pending: p.Pending, + CantSync: p.Stuck + p.Unreadable, } - u := &UIFailures{ - Title: fmt.Sprintf("有 %d 份沒有送進知識庫", len(s.Failures)), - Note: "下面是失敗的檔案與原因。這些檔案會自動重試,你不用重丟;如果一直失敗,把這段回報給我們。", - } - for i, f := range s.Failures { - if i >= maxFailShown { - u.More = len(s.Failures) - maxFailShown - break - } - u.Items = append(u.Items, UIFailItem{ - Name: filepath.Base(f.Path), - Reason: humanizeFailure(f.Error), - }) + for _, g := range s.FailureBreakdown.Groups { + u.Groups = append(u.Groups, UIFailGroup{Category: g.Category, Count: g.Count}) } return u } @@ -509,11 +432,10 @@ func (a *App) GetState() UIState { } sync := loadSyncStatus() - st.ExtractedOK = sync.ExtractedOK st.Syncing, st.StatusBig, st.StatusSub = describeStatus(sync) st.Steps = buildSteps(sync, st.Syncing) st.Skipped = buildSkipped(sync) - st.Failures = buildFailures(sync) + st.Progress = buildProgress(sync) // 引擎有問題才把「回報問題」卡叫出來(含記錄檔路徑)。 // 沒事時不顯示——否則「哪裡看 log」會變成常駐噪音,真出事時反而沒人看。 st.EngineTrouble = !collectorAlive() diff --git a/cmd/arcrun-app/frontend/src/main.js b/cmd/arcrun-app/frontend/src/main.js index 94dcd63..0cda80a 100644 --- a/cmd/arcrun-app/frontend/src/main.js +++ b/cmd/arcrun-app/frontend/src/main.js @@ -73,14 +73,13 @@ function pageHome(s) { ${cardTrouble(s)} - ${cardFailures(s.failures)} + ${cardProgress(s.progress)} ${cardSkipped(s.skipped)}

總計

${s.accounts.length}
個知識庫
${s.accounts.reduce((n,a)=>n+(a.folders||[]).length,0)}
個資料夾在看守
-
${s.extractedOK || 0}
份已整理
`; } @@ -107,28 +106,40 @@ function cardTrouble(s) { `; } -// 失敗的檔案:列出**檔名與原因**。 -// leo 2026-08-06 實測:畫面只寫「⚠ 3 份失敗」,他只能回報「沒推到雲端」, -// 而原因本來就在 status.json 裡 —— 數字不能代替原因。 -function cardFailures(f) { - if (!f || !f.items || !f.items.length) return ''; - // 🔴 leo 2026-08-06:「**可以顯示沒通過的名稱,旁邊有個三角形,點擊看到失敗細節, - // default 摺疊起來,重點是找到問題在哪裏**」 - // ——他要的是先看到「哪些檔沒過」,細節是需要時才展開。 - // 先前把原因直接攤在旁邊,害檔名欄被擠成一行一個字(實撞截圖)。 - // 用原生
:三角形、鍵盤可操作、預設收合,都不必自己寫。 +// 你的檔案:分母 + 三個分類(t210,2026-08-08,取代 08-06 逐檔白話翻譯)。 +// +// 🔴 leo 08-08 轉述封測者 Evan:「我有 9000 個檔,雲端只有 101 張卡,畫面卻說 +// 『20 份沒送進知識庫』——這幾個數字到底是怎麼回事?」病根是首頁每個數字都是 +// 本輪的,使用者問的是總量——這張卡改講總量,四個數字(分母+已送上去+排隊中+ +// 送不上去)加起來要對得起來,看完的感覺要是「我知道還沒傳,你不要擔心」。 +// +// 🔴 leo 08-08:「我不要枚舉每個檔案可能的問題和解法,應該是統計的」「不解釋細節, +// 無法上傳的也摺疊,想看細節才展開」──「送不上去」預設摺疊,展開只有分類與份數, +// 不逐檔列名、不解釋、不給解法;細節去 Docs 說明文件。 +// +// 🔴 分類判斷只住在後端 collector/progress.go 的 ClassifyFailure 一個接縫—— +// 這裡完全不認得任何分類名稱字串,`g.category` 原樣印出、順序照後端給的陣列, +// 不在前端排序或分支判斷(t214 之後分類要改成資料驅動,才只需要動後端那一個檔)。 +function cardProgress(p) { + if (!p || !p.total) return ''; return `
-

${esc(f.title)}

-
${esc(f.note)}
-
- ${f.items.map((it) => ` -
- ${esc(it.name)} -
${esc(it.reason)}
-
`).join('')} +

你的檔案

+
+
${p.total}
共幾份
+
${p.done}
已送上去
+
${p.pending}
排隊中
+
${p.cantSync}
送不上去
- ${f.more > 0 ? `
…還有 ${f.more} 份
` : ''} + ${p.cantSync > 0 ? ` +
+ 看看是哪些原因 +
    + ${(p.groups || []).map((g) => `
  • ${esc(g.category)}${g.count} 份
  • `).join('')} +
+
這些會自動重試,你不用重丟;細節與怎麼處理,看說明文件。
+
+
` : ''}
`; } @@ -263,6 +274,7 @@ function wire() { on('aiSave', saveAI); on('uCheck', checkUpdate); on('uDownload', downloadUpdate); on('uApply', applyUpdate); document.querySelectorAll('[data-portal]').forEach((b) => { b.onclick = () => go.OpenURL(b.dataset.portal); }); + document.querySelectorAll('[data-openurl]').forEach((b) => { b.onclick = () => go.OpenURL(b.dataset.openurl); }); document.querySelectorAll('[data-addto]').forEach((b) => { b.onclick = () => addFolder(Number(b.dataset.addto)); }); document.querySelectorAll('[data-rm]').forEach((b) => { b.onclick = () => confirmRemove(Number(b.dataset.acc), b.dataset.rm); diff --git a/cmd/arcrun-app/frontend/src/style.css b/cmd/arcrun-app/frontend/src/style.css index ec70fff..7425792 100644 --- a/cmd/arcrun-app/frontend/src/style.css +++ b/cmd/arcrun-app/frontend/src/style.css @@ -221,10 +221,9 @@ input[type=text], input[type=password] { .step .t { flex: 1; } .step .m { font-size: 12.5px; color: rgba(var(--ink-rgb), .45); } -/* 失敗清單:檔名一行、細節收在三角形裡(leo 2026-08-06: +/* 展開收合的細節區塊(
,原為 leo 2026-08-06 逐檔失敗卡所寫: 「顯示沒通過的名稱,旁邊有個三角形,點擊看到失敗細節,default 摺疊起來」)。 - 先前重用 .step 的橫向格線,害中文檔名被擠成一行一個字。 */ -.faillist { margin-top: 12px; } + t210(2026-08-08)沿用同一套三角形樣式,改包「送不上去」的分類統計。 */ .fail { border-top: 1px solid rgba(var(--ink-rgb), .10); } .fail:first-child { border-top: 0; } .fail > summary { @@ -232,14 +231,19 @@ input[type=text], input[type=password] { padding: 10px 2px; font-size: 14px; line-height: 1.5; - word-break: break-word; /* 長檔名要能換行,但不是一字一行 */ + word-break: break-word; list-style: revert; /* 保留原生三角形 */ } .fail > summary::marker { color: var(--amber); } .fail[open] > summary { font-weight: 600; } -.failwhy { - padding: 2px 2px 12px 18px; - font-size: 13px; - line-height: 1.7; - color: rgba(var(--ink-rgb), .68); + +/* 「送不上去」展開後的分類清單:只有分類名稱與份數兩欄,不逐檔列名 + (t210 leo 08-08:「不解釋細節……想看細節才展開」——展開後也只到分類為止)。 */ +.breaklist { margin: 8px 0 0; padding: 0; list-style: none; } +.breaklist li { + display: flex; justify-content: space-between; gap: 12px; + font-size: 13.5px; padding: 6px 2px; + border-top: 1px solid rgba(var(--ink-rgb), .08); } +.breaklist li:first-child { border-top: 0; } +.breaklist li span:last-child { color: rgba(var(--ink-rgb), .55); white-space: nowrap; } diff --git a/direct.go b/direct.go index b76789e..1ffd891 100644 --- a/direct.go +++ b/direct.go @@ -578,6 +578,10 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge skippedOther := 0 var skippedOtherNames []string + // t210:跨帳號、跨資料夾累加的總量進度(見 rootProgress 註解)。 + var totalProgress SyncProgress + var stuckReasons []string + accountDetails := map[string]AccountSyncStatus{} for _, acc := range accounts { if acc.CypherURL == "" || acc.Namespace == "" { @@ -627,7 +631,9 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge multi := len(accCfg.Folders()) > 1 for _, root := range accCfg.Folders() { - r, e, p := runDirectOnceRoot(accCfg, root, dryRun, qs, now) + r, e, p, rp := runDirectOnceRoot(accCfg, root, dryRun, qs, now) + totalProgress = totalProgress.Add(rp.Progress) + stuckReasons = append(stuckReasons, rp.StuckReasons...) if multi { for i := range r { r[i].Root = root @@ -718,6 +724,23 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge if len(st.SkippedDocs) > MaxSkippedListed { st.SkippedDocs = st.SkippedDocs[:MaxSkippedListed] // 總數仍在 SkippedDocCount,UI 說「等 N 個」 } + + // t210:Unreadable 由呼叫端補進來(progress.go 的欄位註解)——G-6.2 的「讀不了的檔」 + // 根本沒進過 manifest,Progress() 算不到它們。併進 Total 才守得住 leo 08-08 驗法① + // 「四個數字相加等於總數」(不變式:Total == Done+Pending+Stuck+Unreadable)。 + st.Progress = totalProgress + st.Progress.Unreadable = st.SkippedDocCount + st.Progress.Total += st.Progress.Unreadable + // 「送不上去」=Stuck+Unreadable 的分類統計——Stuck 那些的真因已在 stuckReasons, + // Unreadable 那些**本來就是**格式不支援(G-6.2 掃描白名單擋下的),直接重用 + // convert.go 的 ErrUnsupported 原文過同一個 ClassifyFailure 入口,不在這裡另造 + // 分類字串(t214 之後要把分類改成資料驅動,也只需要動 ClassifyFailure 一個接縫)。 + failReasons := append([]string{}, stuckReasons...) + for i := 0; i < st.Progress.Unreadable; i++ { + failReasons = append(failReasons, ErrUnsupported.Error()) + } + st.FailureBreakdown = BuildFailureBreakdown(failReasons) + // 頂層彙總(向後相容:單帳號時填頂層欄位讓舊版 tray 仍能讀) if cfg.Extractor != "" { for _, r := range results { @@ -863,24 +886,34 @@ func accountsConnected(cfg *DirectConfig) bool { return false } +// rootProgress=單一資料夾(一份 manifest)算出來的 t210 統計素材。 +// +// 為什麼跟 Progress 綁在一起回傳:兩者都是同一份 Manifest 快照的產物, +// 呼叫端(RunDirectOnce)要跨帳號、跨資料夾把它們累加成總量,缺一不可 +// ——Progress 少了 StuckReasons 就湊不出「送不上去」展開後的分類統計。 +type rootProgress struct { + Progress SyncProgress // 這一根的 Total/Done/Pending/Stuck(Unreadable 由呼叫端補,見 progress.go) + StuckReasons []string // 已放棄自動重試那些條目的 LastError 原文,交給 ClassifyFailure 分類 +} + // qs:這個帳號本輪共用的額度冷卻狀態(跨同帳號的多個監看根,見 quota.go)。 // runNow:整輪 RunDirectOnce 共用的時間點(排序/冷卻判斷一致、好測試)。 -func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaState, runNow time.Time) ([]DirectResult, int, *TriggerPayload) { +func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaState, runNow time.Time) ([]DirectResult, int, *TriggerPayload, rootProgress) { results := []DirectResult{} exit := 0 absRoot, err := filepath.Abs(root) if err != nil { - return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil + return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil, rootProgress{} } absManifest, err := filepath.Abs(cfg.manifestPathFor(absRoot)) if err != nil { - return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil + return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil, rootProgress{} } cfg.migrateManifestIfNeeded(absRoot, absManifest) // t86b:一次性遷移舊格式帳本 m, err := LoadManifest(absManifest, absRoot) if err != nil { - return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil + return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil, rootProgress{} } // 2026-08-07 task 3:Scan() 會把 removed 的路徑從 m.Entries 整批拿掉(rebuild 語意, // 見 scan.go 步驟 7)——但那只是「偵測到不見了」,不代表下架 POST 已經成功。 @@ -903,7 +936,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta SkipDirNames: map[string]bool{"system-dev": true}, }) if err != nil { - return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil + return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil, rootProgress{} } now := runNow.Unix() @@ -1167,7 +1200,19 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta exit = 1 } } - return results, exit, payload + + // t210:manifest 走到這裡已經是本輪最終狀態(每個事件處理完就地更新), + // 原地數一次就是對的(同 SkippedDocCount 那套「現況快照」邏輯,不必另外維護計數器)。 + rp := rootProgress{Progress: m.Progress()} + for _, e := range m.Entries { + if e != nil && e.FailCount >= MaxFailBeforeSkip { + // LastError 原文交給呼叫端彙總後過 ClassifyFailure——分類判斷只住那一個接縫, + // 這裡不判斷任何識別字,即使 LastError 是空字串也照樣送(ClassifyFailure("") 落「其他」, + // 不會漏算份數)。 + rp.StuckReasons = append(rp.StuckReasons, e.LastError) + } + } + return results, exit, payload, rp } // runDirect 是 `collector direct` 子命令主體。 diff --git a/progress_wiring_test.go b/progress_wiring_test.go new file mode 100644 index 0000000..f496545 --- /dev/null +++ b/progress_wiring_test.go @@ -0,0 +1,164 @@ +// progress_wiring_test.go — t210:把 progress.go 的地基(Progress()/ClassifyFailure/ +// BuildFailureBreakdown,已在 progress_test.go 單獨驗過)跟 RunDirectOnce 的接線也測一遍。 +// +// 驗的是 leo 08-08 驗法①③: +// +// ① 四個數字(Done/Pending/Stuck/Unreadable)相加要等於 Total——這正是 Evan 封測 +// 回報「9000/101/20 兜不起來」的病,這裡用一輪同時湊出四種狀態的真實跑法覆現+守住。 +// ③ 斷網/閒置一輪後數字不歸零:Progress/FailureBreakdown 是**現況快照**(每輪原地 +// 重算),不是靠 CarryForwardActivity 才不歸零——這裡故意驗一輪「什麼都沒發生」 +// (已放棄重試的跳過、還在退避窗內的跳過、已完成的沒有事件)之後數字原封不動。 +package collector + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestT210ProgressWiring(t *testing.T) { + root := t.TempDir() + write := func(name, content string) { + if err := os.WriteFile(filepath.Join(root, name), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + + // 雲端版本探測與畫面無關,stub 掉避免真實網路呼叫拖慢測試(同 direct_multi_test.go 手法)。 + origFetch := fetchCloudVersion + fetchCloudVersion = func(string) (string, bool) { return "", false } + defer func() { fetchCloudVersion = origFetch }() + + // Gemini 替身:pageName 是 "stuck" 或 "pending" 的一律萃取失敗(模擬「本地萃取失敗」 + // 這一種無法同步的成因),其餘("ok")成功萃出一張最簡卡片。用 prompt 裡「# 」 + // 那行分辨是哪個檔(gemmaPrompt 的契約:第一行必須是「# 」)。 + restoreGemma := gemmaStub(t, func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + switch { + case strings.Contains(string(body), "「# stuck」"), strings.Contains(string(body), "「# pending」"): + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("上游炸了(測試用)")) + default: + _ = json.NewEncoder(w).Encode(map[string]any{ + "candidates": []map[string]any{{ + "content": map[string]any{"parts": []map[string]any{{"text": cardFixture("ok", "測試")}}}, + }}, + }) + } + }) + defer restoreGemma() + + // 雲端 ingest 端點:卡片成功送達就回 200(stuck/pending 根本不會萃出卡,不會打到這裡)。 + cypher := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"success": true}) + })) + defer cypher.Close() + + manifestPath := filepath.Join(t.TempDir(), "m.json") + cfg := &DirectConfig{ + WatchFolders: []string{root}, + Manifest: manifestPath, + CypherURL: cypher.URL, Namespace: "demo", APIKey: "demo", + Library: "kb", Extractor: "gemma", ExtractorExplicit: true, GeminiAPIKey: "k-test", + CardIngestWF: "rag_ingest_card", MaxRemoved: DefaultMaxRemovedRatio, + // ForceSync:測試用真實時間跑不完 1m→5m→…→6h 的退避階梯,force 讓每輪都真的嘗試, + // 只影響「要不要重試」,不影響 FailCount 怎麼記——跟正式情境「使用者按立刻同步」是 + // 同一條路徑,不是另開後門。 + ForceSync: true, + } + + // 先讓 stuck.md 連續失敗到 MaxFailBeforeSkip(8)次,成為「已放棄自動重試」。 + write("stuck.md", "# 原稿 stuck 內容") + for i := 0; i < MaxFailBeforeSkip; i++ { + if _, exit, _ := RunDirectOnce(cfg, false); exit == 0 { + t.Fatalf("第 %d 輪應該失敗(exit!=0),卻是 0", i+1) + } + } + + // 同一輪裡湊齊四種狀態:ok.md 會成功(Done)、pending.md 只失敗這一輪 + // (Pending,FailCount=1 < 8)、skip.doc 是不支援的格式(Unreadable,根本沒進 + // manifest)、stuck.md 維持已放棄重試(Stuck,force 讓它再撞一次牆,FailCount 繼續 + // 往上加但不影響「已達上限」這個判斷)。 + write("ok.md", "# 原稿 ok 內容") + write("pending.md", "# 原稿 pending 內容") + write("skip.doc", "舊版 Word,還不支援") + + results, exit, _ := RunDirectOnce(cfg, false) + if exit == 0 { + t.Fatalf("這輪 stuck/pending 都會失敗,exit 不該是 0:results=%+v", results) + } + + st, err := LoadSyncStatus(StatusFilePath(manifestPath)) + if err != nil { + t.Fatalf("讀 status.json 失敗:%v", err) + } + + // ── 驗法①:四個數字相加等於總數(Evan 封測的病:9000/101/20 兜不起來)── + p := st.Progress + t.Logf("round1 Progress=%+v FailureBreakdown=%+v", p, st.FailureBreakdown) + if sum := p.Done + p.Pending + p.Stuck + p.Unreadable; sum != p.Total { + t.Fatalf("🔴 數字對不起來:%d+%d+%d+%d=%d ≠ 總數 %d\n完整 Progress=%+v", + p.Done, p.Pending, p.Stuck, p.Unreadable, sum, p.Total, p) + } + if p.Done != 1 { + t.Errorf("Done 應為 1(ok.md),got %d", p.Done) + } + if p.Pending != 1 { + t.Errorf("Pending 應為 1(pending.md,FailCount=1 < %d),got %d", MaxFailBeforeSkip, p.Pending) + } + if p.Stuck < 1 { + t.Errorf("Stuck 應至少 1(stuck.md 已達 %d 次失敗上限),got %d", MaxFailBeforeSkip, p.Stuck) + } + if p.Unreadable != 1 { + t.Errorf("Unreadable 應為 1(skip.doc,G-6.2 白名單擋下、根本沒進 manifest),got %d", p.Unreadable) + } + if p.Total != 4 { + t.Errorf("Total 應為 4(ok+pending+stuck+skip),got %d(%+v)", p.Total, p) + } + + // FailureBreakdown 的分組加總要等於「送不上去」(Stuck+Unreadable), + // 且只有分類與份數——不含檔名、不含解法(呼叫端/前端都不該認得分類名稱, + // 這裡只是照 collector 已經分好的結果核對總量,不重新判斷)。 + wantCantSync := p.Stuck + p.Unreadable + if st.FailureBreakdown.Total != wantCantSync { + t.Fatalf("FailureBreakdown.Total=%d,應等於 Stuck+Unreadable=%d", st.FailureBreakdown.Total, wantCantSync) + } + groupSum := 0 + for _, g := range st.FailureBreakdown.Groups { + groupSum += g.Count + } + if groupSum != wantCantSync { + t.Fatalf("FailureBreakdown.Groups 加總=%d,應等於 %d:%+v", groupSum, wantCantSync, st.FailureBreakdown.Groups) + } + // Unreadable 那一份必然分類成「格式不支援」(G-6.2 白名單擋下的定義就是這樣)。 + foundUnsupported := false + for _, g := range st.FailureBreakdown.Groups { + if g.Category == FailUnsupportedFormat { + foundUnsupported = true + } + } + if !foundUnsupported { + t.Errorf("skip.doc 應該被分類進「%s」,實際分組:%+v", FailUnsupportedFormat, st.FailureBreakdown.Groups) + } + + // ── 驗法③:斷網/閒置一輪後數字不歸零(現況快照,不是本輪計數)── + // 這輪關掉 ForceSync:stuck.md 已達上限、pending.md 還在退避窗內、ok.md 內容沒變, + // 全部不會真的觸發任何萃取/上傳動作——模擬「斷網一輪」或「什麼都沒發生的一輪」。 + // exit 值不重要(上一輪的失敗紀錄可能還在別的欄位),重點是 Progress 有沒有被清空。 + cfg.ForceSync = false + RunDirectOnce(cfg, false) + + st2, err := LoadSyncStatus(StatusFilePath(manifestPath)) + if err != nil { + t.Fatalf("讀第二次 status.json 失敗:%v", err) + } + t.Logf("round2(idle) Progress=%+v FailureBreakdown=%+v", st2.Progress, st2.FailureBreakdown) + if st2.Progress != p { + t.Fatalf("🔴 閒置一輪後數字變了(不該歸零/不該亂動):before=%+v after=%+v", p, st2.Progress) + } +} diff --git a/sync_status.go b/sync_status.go index 4f954af..dde29f0 100644 --- a/sync_status.go +++ b/sync_status.go @@ -72,6 +72,22 @@ type SyncStatus struct { // leo 08-06 封測者放了 .md 說「無法通過」,畫面只有「有 1 個不是文件的檔案」, // 沒人判斷得出那到底是什麼檔。 SkippedOtherNames []string `json:"skipped_other_names,omitempty"` + + // ── t210 統計層(2026-08-08,Evan 封測:「9000 個檔,雲端只有 101 張卡, + // 畫面卻說 20 份沒送——這幾個數字到底是怎麼回事?」)────────────────────── + // + // Progress=總量進度快照(見 progress.go 的 SyncProgress/(*Manifest).Progress())。 + // 由 RunDirectOnce 跨帳號、跨資料夾 Add() 累加,並把 G-6.2 的 SkippedDocCount + // 併進 Unreadable/Total(Progress() 算不到「根本沒進 manifest」的檔)。 + // + // FailureBreakdown=「送不上去」(Stuck+Unreadable)展開後的分類統計—— + // 只有分類與份數,沒有檔名、沒有解法(取代 08-06 那套逐檔 humanizeFailure)。 + // + // ⚠️ 與 SkippedDocCount 同類,**都不進 CarryForwardActivity**:manifest 每輪 + // 重建、涵蓋現況所有檔,這裡原地算出來就是對的——也因此斷網/閒置一輪後 + // 不會被清成 0(現況快照,不是本輪計數)。 + Progress SyncProgress `json:"progress"` + FailureBreakdown FailureBreakdown `json:"failure_breakdown"` } // MaxSkippedListed:status.json 裡最多逐檔列幾個。