From 67effe3b7f0e5153b6cb066c00a37fe413ad99a6 Mon Sep 17 00:00:00 2001 From: richblack Date: Thu, 6 Aug 2026 16:12:01 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A6=96=E9=A0=81=E3=80=8C=E6=B2=92=E8=A2=AB?= =?UTF-8?q?=E6=95=B4=E7=90=86=E7=9A=84=E6=AA=94=E6=A1=88=E3=80=8D=E4=BF=AE?= =?UTF-8?q?=E5=85=A9=E7=97=85=EF=BC=9A=E5=90=8C=E4=B8=80=E4=BB=B6=E4=BA=8B?= =?UTF-8?q?=E8=AC=9B=E5=85=A9=E6=AC=A1=EF=BC=8F=E4=B8=8D=E8=AA=AA=E6=98=AF?= =?UTF-8?q?=E5=93=AA=E4=B8=80=E5=80=8B=E6=AA=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leo 08-06 封測回報(附截圖+「他放一個 md 檔無法通過」)。 ## ① 同一件事講兩次,而且「另外」前面沒有東西 截圖實況: 「看起來不是文件,所以跳過了。」 ← 底下是空的(非文件檔不逐檔點名) 「**另外**有 1 個不是文件的檔案(圖片、影片、壓縮檔之類)也沒有處理。」 真兇:app.go buildSkipped 的 else 分支先寫一句通用說明, 接著無條件再寫 Other 那句——後者的「另外」是為「兩種都有」寫的, 在「只有非文件檔」時就變成前面沒有東西可以「另外」。 解:該分支只講一句、自己帶數量,且不留 Other。「另外」只在兩種都有時出現。 ## ② 只報總數=等於沒說(這才是 md 那題卡住的原因) 封測者放 .md 說「無法通過」,但畫面只寫「有 1 個不是文件的檔案」, **沒說是哪一個** ⇒ 誰也判斷不出發生什麼事。 而 `.md` 明明在 allowedExt 白名單裡(scan.go:26),我在本機實測也一次就過: "path":"arcrun-md-test.md","status":"ingested","http_status":200 ⇒ 那個「1 個」必然不是他以為的那個檔(副檔名被 Windows 藏起來、存成別的格式…), 但沒有檔名就永遠查不出來。 解:scan 收集非文件檔的檔名(上限 5 個),一路帶到 status.json 與首頁。 「只報總數」在幾百張圖時是對的,在 1 個時是失職——少量就點名。 ## 驗 · 兩支新測試釘住規則:只有非文件檔時不准出現第二句、不准出現「另外」; 兩種都有時「另外」才成立並帶數量 · 實際文案打出來看過(見下),不是只看測試綠 有 1 個檔案沒有被整理 看起來不是文件(圖片、影片、壓縮檔之類),所以跳過了。這是正常的,你不用做什麼。 · 我的筆記.md.txt · 312 張圖的情況:列 5 個 +「…還有 307 個」,不洗版 · collector 全測試過、app 測試過、go vet 全綠 --- cmd/arcrun-app/app.go | 28 ++++++++++++++++++------ cmd/arcrun-app/skipped_test.go | 30 ++++++++++++++++++++++++-- direct.go | 36 +++++++++++++++++++------------ scan.go | 39 +++++++++++++++++++++++++--------- sync_status.go | 6 +++++- 5 files changed, 106 insertions(+), 33 deletions(-) diff --git a/cmd/arcrun-app/app.go b/cmd/arcrun-app/app.go index d4a384b..8a70974 100644 --- a/cmd/arcrun-app/app.go +++ b/cmd/arcrun-app/app.go @@ -88,6 +88,7 @@ type syncStatus struct { SkippedDocs []skippedDoc `json:"skipped_docs,omitempty"` SkippedDocCount int `json:"skipped_doc_count"` SkippedOtherCount int `json:"skipped_other_count"` + SkippedOtherNames []string `json:"skipped_other_names"` } type skippedDoc struct { @@ -222,14 +223,29 @@ func buildSkipped(s syncStatus) *UISkipped { // 誠實地告訴他「這不是你的錯,也不用你動手」——否則使用者會反覆重丟同一個檔。 u.Note = "這些格式我們還沒支援,所以沒有進你的知識庫。等支援了會自動補上,你不用重丟。" + "急著要的話,先用原本的軟體另存成 PDF 或 Word(.docx)放進同一個資料夾就行。" + // 兩種都有時,非文件檔那句用「另外」接在讀不了的檔之後(見下)。 + if s.SkippedOtherCount > 0 { + u.Other = fmt.Sprintf("另外有 %d 個不是文件的檔案(圖片、影片、壓縮檔之類)也沒有處理。", + s.SkippedOtherCount) + } } else { // 只有非文件檔的情況(例如整個資料夾都是照片)——不需要驚動他,但也不能不說。 - u.Title = "有些檔案沒有被整理" - u.Note = "看起來不是文件,所以跳過了。" - } - if s.SkippedOtherCount > 0 { - u.Other = fmt.Sprintf("另外有 %d 個不是文件的檔案(圖片、影片、壓縮檔之類)也沒有處理。", - s.SkippedOtherCount) + // + // 🔴 2026-08-06 leo 封測截圖:這張卡長成 + // 「看起來不是文件,所以跳過了。」(底下空的) + // 「**另外**有 1 個不是文件的檔案(圖片、影片、壓縮檔之類)也沒有處理。」 + // 兩個病:① 同一件事講兩次 ② 「另外」前面沒有東西可以「另外」—— + // 因為 Files 是空的(非文件檔不逐檔點名),通用句下面什麼都沒有。 + // ⇒ 這個分支**只講一句**,把數量與例子併進來,且不留 Other。 + u.Title = fmt.Sprintf("有 %d 個檔案沒有被整理", s.SkippedOtherCount) + u.Note = "看起來不是文件(圖片、影片、壓縮檔之類),所以跳過了。這是正常的,你不用做什麼。" + // 🔴 少量時把檔名列出來(leo 08-06 封測):封測者放了 .md 進去說「無法通過」, + // 而畫面只寫「有 1 個不是文件的檔案」——沒說是哪一個,誰都判斷不出發生什麼事。 + // `.md` 明明在支援清單裡 ⇒ 看到檔名才知道真相(副檔名被 Windows 藏起來、存錯格式…)。 + u.Files = append(u.Files, s.SkippedOtherNames...) + if n := s.SkippedOtherCount - len(s.SkippedOtherNames); n > 0 { + u.More = n + } } return u } diff --git a/cmd/arcrun-app/skipped_test.go b/cmd/arcrun-app/skipped_test.go index 424fb3c..1051435 100644 --- a/cmd/arcrun-app/skipped_test.go +++ b/cmd/arcrun-app/skipped_test.go @@ -68,6 +68,11 @@ func TestBuildSkippedShowsRemainderCount(t *testing.T) { } // 整個資料夾都是照片這種情況:不必驚動他,但也不能一個字都不說。 +// +// 🔴 2026-08-06 leo 封測截圖抓到的病:這張卡把同一件事講了兩次—— +// 先一句通用的「看起來不是文件,所以跳過了。」(底下是空的,因為非文件檔不點名), +// 再一句「**另外**有 1 個…也沒有處理。」而「另外」前面根本沒有東西。 +// ⇒ 只有非文件檔時**只准講一句**,而且那一句要自己帶數量。 func TestBuildSkippedOtherOnly(t *testing.T) { u := buildSkipped(syncStatus{SkippedOtherCount: 312}) if u == nil { @@ -76,8 +81,29 @@ func TestBuildSkippedOtherOnly(t *testing.T) { if len(u.Files) != 0 { t.Error("非文件檔不逐檔點名(幾百張圖會變成噪音)") } - if !strings.Contains(u.Other, "312") { - t.Errorf("要講出數量,實得 %q", u.Other) + if !strings.Contains(u.Title, "312") { + t.Errorf("只有非文件檔時,數量要出現在標題(那是唯一會被看到的一句),實得 %q", u.Title) + } + if u.Other != "" { + t.Errorf("同一件事不准講兩次——沒有讀不了的文件時不該再出現一句,實得 %q", u.Other) + } + if strings.Contains(u.Title+u.Note, "另外") { + t.Errorf("前面沒有東西可以「另外」,實得 %q / %q", u.Title, u.Note) + } +} + +// 兩種都有時,「另外」才成立——它接在「讀不了的檔」那段之後。 +func TestBuildSkippedBothKinds(t *testing.T) { + u := buildSkipped(syncStatus{ + SkippedDocs: []skippedDoc{{Path: "舊報告.doc", Ext: ".doc"}}, + SkippedDocCount: 1, + SkippedOtherCount: 3, + }) + if !strings.Contains(u.Title, "讀不了") { + t.Errorf("有讀不了的文件時,標題要講那件事,實得 %q", u.Title) + } + if !strings.Contains(u.Other, "另外") || !strings.Contains(u.Other, "3") { + t.Errorf("兩種都有時才用「另外」並帶數量,實得 %q", u.Other) } } diff --git a/direct.go b/direct.go index d351621..6b8adf5 100644 --- a/direct.go +++ b/direct.go @@ -67,22 +67,22 @@ type DirectConfig struct { InstanceName string `json:"instance_name,omitempty"` // 暱稱(舊制) Library string `json:"library"` // 藏書地圖歸庫鍵(空=kb;per-folder 未指定時的後備) // t52:每個看守資料夾對應自己的庫(key=絕對路徑,value=庫名);新制走 AccountConfig.Libraries。 - Libraries map[string]string `json:"libraries,omitempty"` - IngestWF string `json:"ingest_workflow"` // 直送萃取 workflow 名(空=rag_ingest_direct) - RemovedWF string `json:"removed_workflow"` // 下架 workflow 名(空=rag_takedown_direct) + Libraries map[string]string `json:"libraries,omitempty"` + IngestWF string `json:"ingest_workflow"` // 直送萃取 workflow 名(空=rag_ingest_direct) + RemovedWF string `json:"removed_workflow"` // 下架 workflow 名(空=rag_takedown_direct) // —— 四步定稿(daemon-beta t3/t4/t6):本地萃卡模式 —— - Extractor string `json:"extractor,omitempty"` // "workers-ai"(預設,免金鑰)|"gemma"|"claude"(已停用) + Extractor string `json:"extractor,omitempty"` // "workers-ai"(預設,免金鑰)|"gemma"|"claude"(已停用) // t181:使用者**主動在托盤選過**萃取引擎才為 true。false=一律走 workers-ai。 // 判準刻意不是「有沒有金鑰」——leo 08-04:「不管你現在是否有填金鑰」都要先 default // Workers AI,否則他得「花在解釋為什麼 Gemini 不管用上」。 // 有金鑰但沒主動選 ⇒ 金鑰留著不動,之後選 Gemini 立刻可用。 - ExtractorExplicit bool `json:"extractor_explicit,omitempty"` - ClaudeBin string `json:"claude_bin,omitempty"` // claude 執行檔(空=PATH 找 claude) - GeminiAPIKey string `json:"gemini_api_key,omitempty"` // gemma 路的用戶 key - LLMModel string `json:"llm_model,omitempty"` // gemma 路模型(空=gemma-4-31b-it) - CardIngestWF string `json:"card_ingest_workflow,omitempty"` // 收卡 workflow(空=rag_ingest_card) - PollSec int `json:"poll_interval_sec"` // 輪詢間隔秒(空/0=5) - MaxRemoved float64 `json:"max_removed_ratio"` // 大量刪除防呆門檻(空/0=0.4) + ExtractorExplicit bool `json:"extractor_explicit,omitempty"` + ClaudeBin string `json:"claude_bin,omitempty"` // claude 執行檔(空=PATH 找 claude) + GeminiAPIKey string `json:"gemini_api_key,omitempty"` // gemma 路的用戶 key + LLMModel string `json:"llm_model,omitempty"` // gemma 路模型(空=gemma-4-31b-it) + CardIngestWF string `json:"card_ingest_workflow,omitempty"` // 收卡 workflow(空=rag_ingest_card) + PollSec int `json:"poll_interval_sec"` // 輪詢間隔秒(空/0=5) + MaxRemoved float64 `json:"max_removed_ratio"` // 大量刪除防呆門檻(空/0=0.4) // ForceSync=這一輪是使用者按「立刻同步」觸發的(t195)。 // 為真時忽略失敗退避與次數上限,一律重送——**人明確要求時不該被機器的退避擋住**。 @@ -430,8 +430,8 @@ func pageNameOf(relPath string) string { // DirectResult 是單一事件的直送結果(隨每輪 log 輸出)。 type DirectResult struct { - Account string `json:"account,omitempty"` // t104: cypher_url host(多帳號標的) - Root string `json:"root,omitempty"` // 多資料夾時標明事件屬於哪個根 + Account string `json:"account,omitempty"` // t104: cypher_url host(多帳號標的) + Root string `json:"root,omitempty"` // 多資料夾時標明事件屬於哪個根 Type string `json:"type"` Path string `json:"path"` Status string `json:"status"` // ingested | removed | planned | failed | skipped @@ -553,6 +553,7 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge // 使用者不該因為我們的內部結構而看到同一個檔名列兩次。 skippedSeen := map[string]SkippedFile{} skippedOther := 0 + var skippedOtherNames []string accountDetails := map[string]AccountSyncStatus{} for _, acc := range accounts { @@ -618,6 +619,8 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge skippedSeen[sf.Path] = sf } skippedOther += p.SkippedOther + // 同理,每一根的檔名都要收(上限在寫進 status 時才裁)。 + skippedOtherNames = append(skippedOtherNames, p.SkippedOtherNames...) } } accountDetails[accHost] = accSt @@ -634,6 +637,12 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge // G-6.2:把「讀不了的檔」寫進狀態檔,App 首頁才有東西可以講。 // 排序=畫面每輪穩定(map 迭代順序隨機,不排的話清單會自己跳動)。 st.SkippedOtherCount = skippedOther + // 少量時點名(maxOtherNames 個以內)——leo 08-06 封測:只報「1 個」等於沒說。 + sort.Strings(skippedOtherNames) + if len(skippedOtherNames) > maxOtherNames { + skippedOtherNames = skippedOtherNames[:maxOtherNames] + } + st.SkippedOtherNames = skippedOtherNames st.SkippedDocCount = len(skippedSeen) for _, sf := range skippedSeen { st.SkippedDocs = append(st.SkippedDocs, sf) @@ -994,7 +1003,6 @@ func runDirect(args []string) int { return exit } - // 四步定稿第 1 步:daemon 代裝 template——常駐看守前確保每根都鋪好(冪等,不覆寫既有檔)。 // dry-run/--once 測試情境不代裝(不留副作用),由 template-install 子命令顯式做。 if !*once && !*dryRun { diff --git a/scan.go b/scan.go index 13d36e1..959d2a5 100644 --- a/scan.go +++ b/scan.go @@ -48,6 +48,9 @@ var allowedExt = map[string]bool{ // // 加新格式的順序:先列在這裡(使用者立刻看得到「還不支援」), // 等 convert.go 真的接上抽取器,再把它從這裡搬去 allowedExt。 +// maxOtherNames=非文件檔最多點名幾個。超過就只報總數(避免幾百張圖洗版)。 +const maxOtherNames = 5 + var docLikeExt = map[string]bool{ // 舊版 Office(OLE2 二進位,與 .docx/.xlsx/.pptx 是完全不同的格式) ".doc": true, ".xls": true, ".ppt": true, @@ -63,7 +66,8 @@ var docLikeExt = map[string]bool{ // // ⚠️ 刻意**不寫進 manifest**:它每輪由檔案系統重算,永遠反映現況。 // (對照 t195 的坑:凡是存進 ManifestEntry 的跨輪欄位都得記得在 carry 段補一行, -// 漏了就靜默歸零。這裡不建立那份債。) +// +// 漏了就靜默歸零。這裡不建立那份債。) type SkippedFile struct { Path string `json:"path"` Ext string `json:"ext"` @@ -102,7 +106,14 @@ type TriggerPayload struct { //(BuildSendablePayload 是 `sendable := *p` 淺拷貝,有 tag 就會一起送出去)。 // ⇒ 留在記憶體裡,由 direct.go 收進 status.json,給 App 首頁用。 Skipped []SkippedFile `json:"-"` // 像文件、但還讀不了的(逐檔點名) - SkippedOther int `json:"-"` // 其餘非文件檔(圖片/影音/程式碼…)只計數 + SkippedOther int `json:"-"` // 其餘非文件檔(圖片/影音/程式碼…)總數 + // SkippedOtherNames=上面那些檔的檔名,**最多 maxOtherNames 個**。 + // 🔴 2026-08-06(leo 封測):封測者放了一個 .md 進去說「無法通過」,而畫面只寫 + // 「有 1 個不是文件的檔案」——**沒說是哪一個**,於是誰也判斷不出發生什麼事 + // (.md 明明在白名單裡,所以那個 1 一定是別的東西:可能副檔名被 Windows 藏起來、 + // 可能存成了別的格式)。只報總數在「幾百張圖」時是對的,在「1 個」時等於沒說。 + // ⇒ 少量時就點名,讓使用者自己一眼看出「喔,我存錯格式了」。 + SkippedOtherNames []string `json:"-"` } type ScanOptions struct { @@ -154,6 +165,7 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) { current := map[string]fileState{} var skipped []SkippedFile skippedOther := 0 + var skippedOtherNames []string err := filepath.WalkDir(root, func(p string, d fs.DirEntry, werr error) error { if werr != nil { return werr @@ -184,6 +196,12 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) { } } else { skippedOther++ + // 只留前幾個:多了就變噪音(Obsidian 附件庫可能有幾百張 .png)。 + if len(skippedOtherNames) < maxOtherNames { + if rel, rerr := filepath.Rel(root, p); rerr == nil { + skippedOtherNames = append(skippedOtherNames, filepath.ToSlash(rel)) + } + } } return nil } @@ -357,13 +375,14 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) { } sort.Slice(skipped, func(i, j int) bool { return skipped[i].Path < skipped[j].Path }) return &TriggerPayload{ - SchemaVersion: 1, - FolderID: m.FolderID, - Root: root, - GeneratedAt: time.Now().Unix(), - Events: events, - Warnings: warnings, - Skipped: skipped, - SkippedOther: skippedOther, + SchemaVersion: 1, + FolderID: m.FolderID, + Root: root, + GeneratedAt: time.Now().Unix(), + Events: events, + Warnings: warnings, + Skipped: skipped, + SkippedOther: skippedOther, + SkippedOtherNames: skippedOtherNames, }, nil } diff --git a/sync_status.go b/sync_status.go index 6ff76fc..f0bf0b1 100644 --- a/sync_status.go +++ b/sync_status.go @@ -57,7 +57,11 @@ type SyncStatus struct { // 檔案還躺在資料夾裡,每輪都會被重新數到,所以原地重算就是對的。 SkippedDocs []SkippedFile `json:"skipped_docs,omitempty"` // 逐檔點名(已排序,上限 MaxSkippedListed) SkippedDocCount int `json:"skipped_doc_count"` // 文件類被略過的**總數**(可能大於清單長度) - SkippedOtherCount int `json:"skipped_other_count"` // 其餘非文件檔(圖片/影音/程式碼…)只給總數 + SkippedOtherCount int `json:"skipped_other_count"` // 其餘非文件檔(圖片/影音/程式碼…)總數 + // 少量時附上檔名(上限 maxOtherNames)。只報總數在「1 個」時等於沒說—— + // leo 08-06 封測者放了 .md 說「無法通過」,畫面只有「有 1 個不是文件的檔案」, + // 沒人判斷得出那到底是什麼檔。 + SkippedOtherNames []string `json:"skipped_other_names,omitempty"` } // MaxSkippedListed:status.json 裡最多逐檔列幾個。