diff --git a/direct.go b/direct.go index ebff096..12f1235 100644 --- a/direct.go +++ b/direct.go @@ -648,6 +648,18 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge // 排序=畫面每輪穩定(map 迭代順序隨機,不排的話清單會自己跳動)。 st.SkippedOtherCount = skippedOther // 少量時點名(maxOtherNames 個以內)——leo 08-06 封測:只報「1 個」等於沒說。 + // 🔴 leo 2026-08-06 截圖:「有 2 個檔案沒有被整理」底下同一個檔名出現**兩次**。 + // 真兇:多帳號時同一個資料夾會被掃很多輪,每輪都把檔名 append 進來。 + // ⇒ 去重(順帶讓總數與清單一致,不然使用者會覺得我們在亂數)。 + seen := map[string]bool{} + uniq := skippedOtherNames[:0] + for _, n := range skippedOtherNames { + if !seen[n] { + seen[n] = true + uniq = append(uniq, n) + } + } + skippedOtherNames = uniq sort.Strings(skippedOtherNames) if len(skippedOtherNames) > maxOtherNames { skippedOtherNames = skippedOtherNames[:maxOtherNames] @@ -744,13 +756,24 @@ func retrySkipReason(m *Manifest, path string, now int64) string { return "暫時跳過" } if e.FailCount >= MaxFailBeforeSkip { - return fmt.Sprintf("連續失敗 %d 次,已暫停自動重試(改檔或按「立刻同步」會再試)", e.FailCount) + msg := fmt.Sprintf("連續失敗 %d 次,已暫停自動重試(改檔或按「立刻同步」會再試)", e.FailCount) + if e.LastError != "" { + msg += "|原因:" + e.LastError + } + return msg } wait := e.NextRetry - now if wait < 0 { wait = 0 } - return fmt.Sprintf("上次失敗(第 %d 次),%s 後重試", e.FailCount, (time.Duration(wait) * time.Second).String()) + msg := fmt.Sprintf("上次失敗(第 %d 次),%s 後重試", e.FailCount, (time.Duration(wait) * time.Second).String()) + // 🔴 leo 2026-08-06:「別人的錯誤一律要顯示給用戶看,不然就會變成我的錯誤,導致客服」。 + // 只講「幾分鐘後重試」等於把上游的錯(Cloudflare 額度用完、檔案本身沒文字) + // 藏起來 ⇒ 使用者只看得到我們在失敗。真因要一路帶到畫面上。 + if e.LastError != "" { + msg += "|原因:" + e.LastError + } + return msg } // accountsConnected 回答「這份設定連得上知識庫嗎」——**根層有 或 任一帳號有**。 @@ -822,7 +845,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool) ([]DirectRes content, rerr := os.ReadFile(full) if rerr != nil { res.Status, res.Error = "failed", "讀檔失敗:"+rerr.Error() - m.MarkFailed(ev.Path, now) // t195:讀不到的檔也退避(權限/被鎖/壞掉的外接碟) + m.MarkFailed(ev.Path, now, res.Error) // t195:讀不到的檔也退避(權限/被鎖/壞掉的外接碟) results = append(results, res) exit = 1 continue @@ -858,7 +881,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool) ([]DirectRes res.Status, res.Error = "failed", "本地萃取失敗:"+xerr.Error() // t195:萃取階段失敗同樣要記退避。**這條路徑比上傳更早**, // 漏記的話(連不上知識庫、金鑰壞、模型錯)照樣每輪重撞。 - m.MarkFailed(ev.Path, now) + m.MarkFailed(ev.Path, now, res.Error) results = append(results, res) exit = 1 continue @@ -894,7 +917,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool) ([]DirectRes } else { // t195:記下失敗並排定退避,否則下輪又把它當新檔重試 //(實撞:1387 輪 × 11 小時全在撞同一面 401 的牆,還拖住整個佇列)。 - m.MarkFailed(ev.Path, now) + m.MarkFailed(ev.Path, now, res.Error) exit = 1 } results = append(results, res) @@ -1027,20 +1050,19 @@ func runDirect(args []string) int { return exit } - // 四步定稿第 1 步:daemon 代裝 template——常駐看守前確保每根都鋪好(冪等,不覆寫既有檔)。 - // dry-run/--once 測試情境不代裝(不留副作用),由 template-install 子命令顯式做。 - if !*once && !*dryRun { - for _, root := range cfg.Folders() { - if TemplateInstalled(root) { - continue - } - if res, ierr := InstallTemplate(root); ierr != nil { - fmt.Fprintf(os.Stderr, "template 代裝失敗(%s):%v\n", root, ierr) - } else { - fmt.Fprintf(os.Stderr, "template v%s 已鋪進 %s(新 %d 檔)\n", res.Version, root, len(res.Installed)) - } - } - } + // 🔴 2026-08-06 leo 拍板:**「拿來開發一般人用不到的根本別安裝」** + // + // 原本 daemon 會把 system-dev template(`CLAUDE.md`/`scripts/`/`system-dev/` + // 共 37 檔)代裝進**使用者的文件資料夾**。那是給開發者寫 SDD/wiki 用的東西, + // RAG 的一般使用者完全用不到,而且兩層傷害: + // ① 把人家的資料夾弄亂(leo:「他原本的資料夾就不會看起來亂掉」) + // ② 那些檔案會被當成知識吃進去 ⇒ 知識庫長出 `kb`/`t195-watch` 這種 + // 不是使用者內容的庫(leo 實撞,見庫目錄管理截圖) + // ⇒ **看守資料夾一律不代裝**。要裝的人自己跑 `collector template-install` + // (子命令仍在,開發者情境不受影響)。 + // + // 另外還要把「已經被鋪進去的」擋在知識之外——那不是靠隱藏檔判斷, + // 因為 leo 自己的 repo 裡 template 本來就不是隱藏的。判準是**路徑身分**,見 scan.go。 if *once { return runOne() diff --git a/manifest.go b/manifest.go index 35f48d5..1073f86 100644 --- a/manifest.go +++ b/manifest.go @@ -9,6 +9,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "time" ) @@ -47,6 +48,14 @@ type ManifestEntry struct { FailCount int `json:"fail_count,omitempty"` // 連續失敗次數(成功即歸零) LastFailAt int64 `json:"last_fail_at,omitempty"` // 最後一次失敗的 unix 秒 NextRetry int64 `json:"next_retry,omitempty"` // 早於這個時間不重試(0=可立即重試) + // LastError=最後一次失敗的**真正原因**(原文,不改寫)。 + // + // 🔴 leo 2026-08-06 立的原則:「**別人的錯誤一律要顯示給用戶看, + // 不然就會變成我的錯誤,導致客服**」。 + // 先前退避一開始,畫面就只剩「上次失敗(第 4 次),58m 後重試」, + // 真因(Cloudflare「當日免費額度用完」/「這份 PDF 沒有文字層」)**當場消失** + // ⇒ 使用者以為是我們壞掉。原因必須跟著 entry 存活到下次成功為止。 + LastError string `json:"last_error,omitempty"` } // retryBackoff 退避階梯:1m → 5m → 15m → 1h → 6h,之後每次 6h。 @@ -133,6 +142,7 @@ func (m *Manifest) MarkIngestedBy(path, sourceHash string, at int64, extractor s e.ExtractedBy = extractor // 成功即清掉失敗狀態(t195):下次再壞會從第一階退避重新算起。 e.FailCount, e.LastFailAt, e.NextRetry = 0, 0, 0 + e.LastError = "" return true } @@ -141,13 +151,16 @@ func (m *Manifest) MarkIngestedBy(path, sourceHash string, at int64, extractor s // 為什麼要記在 manifest 而不是記憶體:collector 每輪是獨立 process // (`direct --once` 由看守器反覆拉起),記憶體狀態一輪就沒了—— // 這正是原本「1387 輪重試同一個檔」的原因:每輪都以為自己是第一次。 -func (m *Manifest) MarkFailed(path string, at int64) bool { +func (m *Manifest) MarkFailed(path string, at int64, reason string) bool { e, ok := m.Entries[path] if !ok { return false } e.FailCount++ e.LastFailAt = at + if strings.TrimSpace(reason) != "" { + e.LastError = reason // 存真因;退避訊息由呼叫端另外組,不覆蓋這裡 + } idx := e.FailCount - 1 if idx >= len(retryBackoff) { idx = len(retryBackoff) - 1 diff --git a/manifest_retry_test.go b/manifest_retry_test.go index 4ec526b..bdf60b7 100644 --- a/manifest_retry_test.go +++ b/manifest_retry_test.go @@ -30,7 +30,7 @@ func TestShouldRetry_NeverFailed(t *testing.T) { // 核心止血:失敗後在退避窗口內不再重試。 func TestShouldRetry_BackoffWindow(t *testing.T) { m := newRetryTestManifest("a.pdf") - m.MarkFailed("a.pdf", 1000) // 第 1 次失敗 → 退避 60s + m.MarkFailed("a.pdf", 1000, "測試用失敗原因") // 第 1 次失敗 → 退避 60s if m.ShouldRetry("a.pdf", 1030, false) { t.Fatal("退避窗口內(+30s)不該重試——這正是 1387 輪的病根") @@ -46,7 +46,7 @@ func TestMarkFailed_ExponentialBackoff(t *testing.T) { want := []int64{60, 300, 900, 3600, 21600, 21600} // 1m,5m,15m,1h,6h,之後維持 6h at := int64(1000) for i, w := range want { - m.MarkFailed("a.pdf", at) + m.MarkFailed("a.pdf", at, "測試用失敗原因") got := m.Entries["a.pdf"].NextRetry - at if got != w { t.Fatalf("第 %d 次失敗:退避 %ds,預期 %ds", i+1, got, w) @@ -59,7 +59,7 @@ func TestShouldRetry_MaxFailStops(t *testing.T) { m := newRetryTestManifest("a.pdf") at := int64(1000) for i := 0; i < MaxFailBeforeSkip; i++ { - m.MarkFailed("a.pdf", at) + m.MarkFailed("a.pdf", at, "測試用失敗原因") } // 就算等再久也不自動重試 if m.ShouldRetry("a.pdf", at+999999, false) { @@ -74,8 +74,8 @@ func TestShouldRetry_MaxFailStops(t *testing.T) { // 成功後要清掉失敗狀態,否則下次再壞會從高階退避起跳(等太久)。 func TestMarkIngested_ResetsFailState(t *testing.T) { m := newRetryTestManifest("a.pdf") - m.MarkFailed("a.pdf", 1000) - m.MarkFailed("a.pdf", 1100) + m.MarkFailed("a.pdf", 1000, "測試用失敗原因") + m.MarkFailed("a.pdf", 1100, "測試用失敗原因") if m.Entries["a.pdf"].FailCount != 2 { t.Fatal("失敗次數應累計") } @@ -95,7 +95,7 @@ func TestMarkIngested_ResetsFailState(t *testing.T) { func TestShouldRetry_OtherFilesUnaffected(t *testing.T) { m := newRetryTestManifest("bad.pdf") m.Entries["good.md"] = &ManifestEntry{ContentHash: "h2", Size: 1, Mtime: 1} - m.MarkFailed("bad.pdf", 1000) + m.MarkFailed("bad.pdf", 1000, "測試用失敗原因") if m.ShouldRetry("bad.pdf", 1010, false) { t.Fatal("壞檔應在退避中") diff --git a/scan.go b/scan.go index 959d2a5..c059f3a 100644 --- a/scan.go +++ b/scan.go @@ -186,6 +186,14 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) { if abs, aerr := filepath.Abs(p); aerr == nil && opts.SkipPaths[abs] { return nil } + // 🔴 2026-08-06 leo:template 的東西不是使用者的知識,一律不收。 + // **用路徑身分認,不用「有沒有以點開頭」認**——leo 自己的 repo 裡 + // template 本來就不是隱藏的,靠隱藏判斷會漏掉一大半。 + // 也不計進「有 N 個檔案沒有被整理」——那是給使用者看他自己的檔案的, + // 我們自己鋪的東西不該佔用他的注意力。 + if rel, rerr := filepath.Rel(root, p); rerr == nil && TemplateOwns(filepath.ToSlash(rel)) { + return nil + } ext := strings.ToLower(filepath.Ext(name)) if !allowedExt[ext] { // G-6.2:**這裡以前是條死巷**——`return nil` 之後這個檔就從世界上消失了。 diff --git a/template_install.go b/template_install.go index e05e890..75b9c31 100644 --- a/template_install.go +++ b/template_install.go @@ -14,6 +14,7 @@ import ( "io/fs" "os" "path/filepath" + "strings" ) // templateFS 是 system-dev-template 的 vendored 快照(版本見 templatefs/system-dev/VERSION)。 @@ -43,6 +44,49 @@ func TemplateVersion() string { return v } +// templateOwnedPaths 回傳「這份 template 擁有的相對路徑」集合(斜線分隔)。 +// +// 🔴 leo 2026-08-06:「**它也不能只看隱藏檔內,因為 template 在我所有的 repo 裡不是隱藏的**」 +// +// ⇒ 判準必須是**路徑身分**(這些路徑本來就是 template 的),不是「有沒有以點開頭」。 +// 用途:掃描時把它們排除在知識之外——不管它是 daemon 舊版鋪的、 +// 還是使用者自己的開發 repo 本來就有的,那都不是他想搜尋的內容。 +func templateOwnedPaths() map[string]bool { + owned := map[string]bool{} + _ = fs.WalkDir(templateFS, templateFSRoot, func(p string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + if rel, rerr := filepath.Rel(templateFSRoot, p); rerr == nil { + owned[filepath.ToSlash(rel)] = true + } + return nil + }) + return owned +} + +// TemplateOwns 回答「這個相對路徑是不是 template 的東西」。 +// 也認**目錄前綴**:template 的 `system-dev/` 底下使用者後來自己加的檔 +// (例如他寫的 wiki)同樣是開發用的,不該進知識庫。 +func TemplateOwns(rel string) bool { + rel = filepath.ToSlash(rel) + if templateOwnedCache[rel] { + return true + } + for _, prefix := range templateOwnedDirs { + if strings.HasPrefix(rel, prefix) { + return true + } + } + return false +} + +var ( + templateOwnedCache = templateOwnedPaths() + // 這些目錄整棵都是開發用的(template 鋪的、或使用者自己長出來的都一樣)。 + templateOwnedDirs = []string{"system-dev/", "scripts/"} +) + // InstallTemplate 把內嵌 template 鋪進 root。冪等:既有檔案跳過不覆寫。 func InstallTemplate(root string) (*TemplateInstallResult, error) { res := &TemplateInstallResult{Version: TemplateVersion()} diff --git a/template_not_knowledge_test.go b/template_not_knowledge_test.go new file mode 100644 index 0000000..505a611 --- /dev/null +++ b/template_not_knowledge_test.go @@ -0,0 +1,60 @@ +package collector + +import ( + "os" + "path/filepath" + "testing" +) + +// TestTemplateFilesAreNotKnowledge 釘住 leo 2026-08-06 的兩條裁決: +// +// ①「拿來開發一般人用不到的**根本別安裝**」 +// ②「它也不能只看隱藏檔內,因為 **template 在我所有的 repo 裡不是隱藏的**」 +// +// 事故:daemon 把 system-dev template(CLAUDE.md/scripts//system-dev/,37 檔) +// 代裝進使用者的文件資料夾,然後又把它們當知識吃進去 +// ⇒ 知識庫長出 `kb`/`t195-watch` 這種不是使用者內容的庫(leo 實撞)。 +// +// 這支測試刻意把 template 檔**放成不隱藏**(就像 leo 的 repo),驗它仍被排除。 +func TestTemplateFilesAreNotKnowledge(t *testing.T) { + root := t.TempDir() + write := func(rel, body string) { + p := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + // template 的東西(**沒有隱藏**) + write("CLAUDE.md", "開發用設定") + write("scripts/sdd-active-check.sh", "#!/bin/sh") + write("system-dev/wiki/status.md", "開發用 wiki") + // 使用者真正的內容 + write("我的筆記.md", "這是我要搜尋的東西") + + m := &Manifest{Entries: map[string]*ManifestEntry{}} + res, err := Scan(root, m, ScanOptions{}) + if err != nil { + t.Fatalf("掃描失敗:%v", err) + } + + got := map[string]bool{} + for _, e := range res.Events { + got[e.Path] = true + } + if !got["我的筆記.md"] { + t.Error("使用者自己的檔案不見了——排除規則太寬") + } + for _, dev := range []string{"CLAUDE.md", "scripts/sdd-active-check.sh", "system-dev/wiki/status.md"} { + if got[dev] { + t.Errorf("%s 是 template 的開發用檔,不該被當成使用者知識", dev) + } + } + // 也不該計進「有 N 個檔案沒有被整理」——那欄是給使用者看他自己的檔案的 + if res.SkippedOther > 0 { + t.Errorf("template 檔不該計進『沒被整理』(會佔用使用者的注意力),實得 %d:%v", + res.SkippedOther, res.SkippedOtherNames) + } +} diff --git a/upstream_error_visible_test.go b/upstream_error_visible_test.go new file mode 100644 index 0000000..5ae6f19 --- /dev/null +++ b/upstream_error_visible_test.go @@ -0,0 +1,44 @@ +package collector + +import ( + "strings" + "testing" +) + +// TestUpstreamErrorSurvivesBackoff 釘住 leo 2026-08-06 立的原則: +// +// 「**別人的錯誤一律要顯示給用戶看,不然就會變成我的錯誤,導致客服**」 +// +// 事故現場:Cloudflare 回「當日免費額度用完」、某 PDF「沒有可抽取的文字」, +// 但檔案一進退避,畫面就只剩「上次失敗(第 4 次),58m 後重試」 +// ⇒ 上游的錯被我們吞掉,使用者只看得到「Arcrun 在失敗」。 +// +// 這支測試從**上游錯誤字串**出發,驗它一路活到給使用者看的那句話裡。 +func TestUpstreamErrorSurvivesBackoff(t *testing.T) { + const upstream = "雲端萃取失敗(HTTP 502):Workers AI 執行失敗:4006: you have used up your daily free allocation of 10,000 neurons" + + m := &Manifest{Entries: map[string]*ManifestEntry{"報告.pdf": {}}} + if !m.MarkFailed("報告.pdf", 1000, upstream) { + t.Fatal("MarkFailed 應該成功") + } + + // ① 原因要存得住(退避跨輪次,不能只活在當次記憶體裡) + if got := m.Entries["報告.pdf"].LastError; got != upstream { + t.Fatalf("上游原因沒存下來,實得 %q", got) + } + + // ② 使用者看到的那句話裡要有它 + reason := retrySkipReason(m, "報告.pdf", 1010) + if !strings.Contains(reason, "neurons") { + t.Fatalf("退避訊息吞掉了上游的錯 ⇒ 使用者會以為是我們壞掉。實得:%s", reason) + } + if !strings.Contains(reason, "後重試") { + t.Errorf("重試排程也要講(兩件事都要說),實得:%s", reason) + } + + // ③ 成功之後要清乾淨,不能一直掛著舊錯誤嚇人 + m.MarkIngestedBy("報告.pdf", "hash", 2000, "workers-ai") + if m.Entries["報告.pdf"].LastError != "" { + t.Error("成功後舊的失敗原因應該清掉") + } +}