Files
Leo a6ced32d45 collector:積壓分批+新檔優先/額度用完講人話降速/斷點續傳/同內容多格式去重
封測事故(Evan):daemon 逐檔萃取上傳、每個檔在雲端產生一次工作流執行,
690 個檔在自己的免費 CF 帳號上短時間內衝出 1,070 次寫入,撞上免費上限 1,000,
額度爆掉、只有 8 個檔成功。雲端那一半(紀錄改走資料層 API)已修好,
daemon 這一半原本完全沒有節奏——本次補齊四件事:

1. 上傳節奏(direct_pacing.go):單輪最多處理 MaxEventsPerRun 個事件
   (預設 25)、每次觸發雲端前節流 700ms;一輪掃到的事件依檔案 mtime
   由新到舊排序,今天寫的永遠優先,積壓慢慢消化不擋日常使用。

2. 額度用完講人話(quota.go):偵測到 Workers AI「10,000 neurons」/
   「4006」等已知上游訊號後,換成三句話(今天已整理幾份/可換模型或
   升級 Cloudflare/不花錢也沒關係、今天或明天早上 8:00 會自動恢復),
   不出現裸露的錯誤碼;同帳號同輪與下一輪都不再繼續撞牆
   (quotaState 全域冷卻,跨資料夾/跨程序重啟持續,直到台灣時間
   早上 8:00 額度重置)。

3. 斷點續傳:每個事件處理完立刻寫回 manifest(不再等整輪跑完才存一次),
   process 被殺掉重開只會接著做真正還沒完成的部分;removed 事件另外
   用 preScanEntries 快照保護,下架失敗時不會被其他事件的存檔動作
   誤標成「已完成」而永遠不再重試。

4. 同內容多格式去重(scan.go):同一批來源轉出的多種格式(如 leo 給的
   資料集 27,164 檔=9,045 md+9,044 json+9,043 html,md/json 同檔名
   主幹)依檔名主幹分組,只留優先序最高的一份進事件管線,其餘標記在
   DuplicateFormats(不吃三倍額度)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:58:07 +08:00

142 lines
4.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// scan_dedup_test.go — 同內容多格式去重(2026-08-07,見 scan.go FormatDuplicate 註解)。
//
// 實據:leo 給的封測資料集 27,164 檔=9,045 md9,044 json9,043 html
// 是同一批來源轉出的三種格式,md/json 逐一同檔名主幹(不同目錄)。
// 驗收:「同內容三種格式並存 → 只產一張卡」。
package collector
import (
"os"
"path/filepath"
"testing"
)
// 三種格式、同檔名主幹、活在兄弟目錄(照實據的 markdown/ vs json/ 結構)
// → 只有一份(依優先序 docx > pptx > xlsx > csv > pdf > md > markdown > txt)進事件管線。
func TestScan_FormatDuplicate_OnlyOneEventPerStem(t *testing.T) {
root := t.TempDir()
writeFile(t, root, "markdown/160-00F3_001.md", "# 錯誤說明\nM118/M128 不可同時使用", baseTime)
writeFile(t, root, "csv/160-00F3_001.csv", "code,msg\n160-00F3,M118/M128 不可同時使用", baseTime)
writeFile(t, root, "doc/160-00F3_001.docx", "docx 二進位占位", baseTime)
m := newTestManifest()
payload, err := Scan(root, m, ScanOptions{})
if err != nil {
t.Fatal(err)
}
var added []Event
for _, ev := range payload.Events {
if ev.Type == "added" {
added = append(added, ev)
}
}
if len(added) != 1 {
t.Fatalf("三種格式應只產生 1 個 added 事件,got %d: %+v", len(added), added)
}
if added[0].Path != "doc/160-00F3_001.docx" {
t.Fatalf("優先序應留 docxgot %q", added[0].Path)
}
if len(payload.DuplicateFormats) != 2 {
t.Fatalf("應回報 2 份被跳過的重複格式,got %d: %+v", len(payload.DuplicateFormats), payload.DuplicateFormats)
}
for _, d := range payload.DuplicateFormats {
if d.KeptPath != "doc/160-00F3_001.docx" {
t.Errorf("KeptPath=%q,應指向留下的那份", d.KeptPath)
}
if d.Stem != "160-00f3_001" {
t.Errorf("Stem=%q", d.Stem)
}
}
}
// 不同檔名主幹的檔案(真正不相干的內容)不受影響——去重不能誤傷正常檔案。
func TestScan_FormatDuplicate_DifferentStemsUnaffected(t *testing.T) {
root := t.TempDir()
writeFile(t, root, "a.md", "內容 A", baseTime)
writeFile(t, root, "b.pdf", "內容 B", baseTime)
m := newTestManifest()
payload, err := Scan(root, m, ScanOptions{})
if err != nil {
t.Fatal(err)
}
added := 0
for _, ev := range payload.Events {
if ev.Type == "added" {
added++
}
}
if added != 2 {
t.Fatalf("不同主幹的檔案應各自產生事件,got %d", added)
}
if len(payload.DuplicateFormats) != 0 {
t.Fatalf("不該誤判為重複:%+v", payload.DuplicateFormats)
}
}
// loser 仍留在 manifest(讓下一輪 mtime/size fast-path 照常運作),只是沒有事件、
// 且 ingested_hash 永遠不會被設定——不會在後續掃描裡被誤判成「新檔」而不斷回報。
func TestScan_FormatDuplicate_LoserStaysInManifestNoRepeatedEvents(t *testing.T) {
root := t.TempDir()
writeFile(t, root, "x.docx", "docx 內容", baseTime)
writeFile(t, root, "x.md", "md 內容(同主幹)", baseTime)
m := newTestManifest()
if _, err := Scan(root, m, ScanOptions{}); err != nil {
t.Fatal(err)
}
if _, ok := m.Entries["x.md"]; !ok {
t.Fatal("loser 應仍被記錄在 manifest(供 fast-path 用)")
}
if m.Entries["x.md"].IngestedHash != "" {
t.Fatal("loser 不該有 ingested_hash(它從未真的被送出)")
}
// 模擬 winner 已成功 ingest
m.Entries["x.docx"].IngestedHash = m.Entries["x.docx"].ContentHash
// 第二輪:兩份檔案都沒變 → loser 不該又跑出一個 added 事件
payload2, err := Scan(root, m, ScanOptions{})
if err != nil {
t.Fatal(err)
}
for _, ev := range payload2.Events {
if ev.Path == "x.md" {
t.Fatalf("loser 不該重複產生事件:%+v", ev)
}
}
}
// winner 消失後,loser 自然遞補(下一輪重算分組時 loser 變成該 stem 唯一成員)。
func TestScan_FormatDuplicate_WinnerRemovedLoserPromoted(t *testing.T) {
root := t.TempDir()
writeFile(t, root, "y.docx", "docx 內容", baseTime)
writeFile(t, root, "y.pdf", "pdf 內容", baseTime)
m := newTestManifest()
if _, err := Scan(root, m, ScanOptions{}); err != nil {
t.Fatal(err)
}
m.Entries["y.docx"].IngestedHash = m.Entries["y.docx"].ContentHash
// docx 被刪除
if err := os.Remove(filepath.Join(root, "y.docx")); err != nil {
t.Fatal(err)
}
payload2, err := Scan(root, m, ScanOptions{MaxRemovedRatio: 1.0})
if err != nil {
t.Fatal(err)
}
var addedPdf bool
for _, ev := range payload2.Events {
if ev.Type == "added" && ev.Path == "y.pdf" {
addedPdf = true
}
}
if !addedPdf {
t.Fatalf("winner 消失後 loser 應遞補產生 added 事件:%+v", payload2.Events)
}
}