首頁「沒被整理的檔案」修兩病:同一件事講兩次/不說是哪一個檔

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 全綠
This commit is contained in:
2026-08-06 16:12:01 +08:00
parent 4d3a6a09a6
commit 67effe3b7f
5 changed files with 106 additions and 33 deletions
+22 -6
View File
@@ -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
}
+28 -2
View File
@@ -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)
}
}
+22 -14
View File
@@ -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"` // 輪詢間隔秒(空/05
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"` // 輪詢間隔秒(空/05
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 {
+29 -10
View File
@@ -48,6 +48,9 @@ var allowedExt = map[string]bool{
//
// 加新格式的順序:先列在這裡(使用者立刻看得到「還不支援」),
// 等 convert.go 真的接上抽取器,再把它從這裡搬去 allowedExt。
// maxOtherNames=非文件檔最多點名幾個。超過就只報總數(避免幾百張圖洗版)。
const maxOtherNames = 5
var docLikeExt = map[string]bool{
// 舊版 OfficeOLE2 二進位,與 .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-06leo 封測):封測者放了一個 .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
}
+5 -1
View File
@@ -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"`
}
// MaxSkippedListedstatus.json 裡最多逐檔列幾個。