a6ced32d45
封測事故(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>
142 lines
4.6 KiB
Go
142 lines
4.6 KiB
Go
// scan_dedup_test.go — 同內容多格式去重(2026-08-07,見 scan.go FormatDuplicate 註解)。
|
||
//
|
||
// 實據:leo 給的封測資料集 27,164 檔=9,045 md+9,044 json+9,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("優先序應留 docx,got %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)
|
||
}
|
||
}
|