WIP(#44 ⑦⑩):關聯段擷取式不再被 H3 打斷;改名/搬移偵測施工中

⑦(總管已複驗):雲端 parse_card 的邊界正規式 lookahead 是 \n#,
會停在「## 關聯」底下第一個 ### 之前。而規範形卡(ef5e6c5)正是
### 內文知識關係 開頭 ⇒ 除機械補的 part_of 外,關係一條都收不到。
agent 對 youlin 實際部署的 rag_ingest_card POST 真卡:卡上 4 條/落地 1 條。
改成只認下一個 H1/H2 後離線重跑同一段 JS:解析出 4 條。
併同重編 installer 兩份 precompiled workflows(只改 yaml 不重編=新用戶拿不到)。

⑩(施工中):direct.go/manifest.go 的改名搬移下架,尚未驗收。

🔴 尚未送達:這一版還沒部署到 youlin,live after 數字還沒拿到。
不得標 。出貨 CP 步驟①。

未納入本次 commit:collector/cmd/arcrun-app/arcrun-app(20MB 編譯產物)
與兩個探針目錄——那是驗證用的臨時物,不該跟修法一起進版控。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 13:13:59 +08:00
parent 6c74b97718
commit febc6708ef
2 changed files with 90 additions and 1 deletions
+61 -1
View File
@@ -1067,6 +1067,21 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
}
}
// InkStoneCo#44 ⑩:改名/搬移後,舊路徑在雲端知識庫裡要跟著下架,否則舊卡永久留著
// (純改檔名)或同一份文件在庫裡變兩套(搬到別的資料夾,新舊頁名相同)。
// renamed 事件只在 removed×added 配對到的那一輪出現一次(見 scan.go 步驟 3),
// 下一輪不會再有機會補發——所以「這個舊路徑要下架」必須在偵測到的當下就
// 寫進 manifestQueueTakedown,持久化),不能只靠事件迴圈處理到才記,否則單輪
// 上限(perRunCap)把它排除在外、或這輪下架失敗時就會永久遺失這個待辦。
for _, ev := range payload.Events {
if ev.Type == "renamed" && ev.OldPath != "" {
m.QueueTakedown(ev.OldPath, pageNameOf(ev.OldPath))
}
}
if len(m.PendingTakedowns) > 0 && !dryRun {
saveManifest()
}
// 2026-08-07 pacing task 1:新改的檔優先+單輪上限。
// - 排序:把 payload.Events 依檔案 mtime 由新到舊重排(不動送雲端的 payload 本身,
// 只重排這裡的本機處理佇列——見 sortEventsNewestFirst 註解)。
@@ -1085,7 +1100,9 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
switch ev.Type {
case "added", "modified", "renamed":
// renamed 在 direct 模式視同 added:內容未變但為求 kbdb 有這頁名的卡,重送一次萃取
//(頁名可能改變=要新頁名的卡)。冪等由 kbdb 端承擔(同頁名覆蓋語意)
//(頁名可能改變=要新頁名的卡)。新路徑這邊的冪等由 kbdb 端承擔(同頁名覆蓋語意)
// 舊路徑那邊不會自動消失——上面已經把 ev.OldPath 排進 m.PendingTakedowns
// 這裡送完新卡之後、本函式結尾會補打下架(InkStoneCo#44 ⑩)。
res := DirectResult{Type: ev.Type, Path: ev.Path}
// 2026-08-07 pacing task 2:帳號還在額度冷卻中 → 這輪連試都不試。
// 這不是這個檔的問題(不記 FailCount/退避——那是「這個檔」的病歷,
@@ -1320,6 +1337,49 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
}
}
// InkStoneCo#44 ⑩:補打「改名/搬移後還沒下架成功」的舊路徑——包含本輪剛
// 上面排進去的,以及之前輪次失敗留下的(同一個待辦清單,一次處理完)。
// 與 orderedEvents 共用同一個節流器(pace),避免一輪多筆改名瞬間打爆雲端。
if len(m.PendingTakedowns) == 0 {
// no-op:沒有待辦
} else if dryRun {
for oldPath := range m.PendingTakedowns {
results = append(results, DirectResult{Type: "renamed_takedown", Path: oldPath, Status: "planned"})
}
} else {
for oldPath, pageName := range m.PendingTakedowns {
pace()
res := DirectResult{Type: "renamed_takedown", Path: oldPath}
status, _, perr := cfg.postJSON(cfg.triggerURL(cfg.RemovedWF), map[string]any{
"page_name": pageName,
"path": oldPath,
})
res.HTTPStatus = status
if perr != nil {
res.Status, res.Error = "failed", "改名/搬移後舊頁下架失敗(下輪重試):"+perr.Error()
exit = 1
} else {
res.Status = "removed"
m.ClearTakedown(oldPath)
// 同步清掉本地舊卡(若還存在)——與「removed」分支同一套清理,見下方。
if cfg.Extractor != "" {
cardAbs := filepath.Join(absRoot, filepath.FromSlash(cardRelFor(absRoot, pageName)))
if _, serr := os.Stat(cardAbs); serr == nil {
_ = os.Remove(cardAbs)
}
if werr := RemoveWikiDoc(absRoot, oldPath); werr != nil {
results = append(results, DirectResult{
Type: "warning", Path: oldPath, Status: "skipped",
Error: "wiki 卡收走失敗(不擋下架):" + werr.Error(),
})
}
}
}
saveManifest()
results = append(results, res)
}
}
// 防呆警告輪:Scan 已壓下 removed 事件,這裡只回報警告不下架。
for _, w := range payload.Warnings {
results = append(results, DirectResult{Type: "warning", Status: "skipped", Error: w.Code + ": " + w.Message})
+29
View File
@@ -81,6 +81,35 @@ type Manifest struct {
// 雲端一壞就是每 5 秒撞一次。內容變了(雜湊不同)視同新卡,立即可再試。
InventoryFailHash string `json:"inventory_fail_hash,omitempty"`
InventoryNextRetry int64 `json:"inventory_next_retry,omitempty"`
// PendingTakedowns=改名/搬移後「舊路徑」在雲端知識庫裡還沒下架成功的待辦清單
// key=舊相對路徑,value=該路徑當時導出的頁名)。InkStoneCo#44 ⑩:
//
// direct 模式把 renamed 事件當 added 處理(用新路徑重送一次萃取),但從未告訴
// 雲端「舊頁名/舊路徑那份已經死了」——純改檔名時舊頁名的舊卡永久留著;搬到
// 別的資料夾時(basename 不變 ⇒ 新舊頁名相同)舊的不刪、新的照寫,kbdb 裡同一份
// 文件變兩套,其中一套指向已不存在的路徑,而且沒有任何機制會回頭發現它。
//
// 為什麼要持久化而不是「失敗了下一輪自然重試」(removed 事件的作法):removed
// 事件靠「檔案仍然不在」讓 Scan() 每輪重新偵測、重新補發;但 renamed 的配對
// removed×added 以 content_hash 配對)只在偵測到的那一輪出現一次,舊路徑已經
// 不在任何一邊的掃描結果裡,下一輪不會再有 renamed 事件把它帶出來。不記住它,
// 一次下架失敗(雲端剛好那幾秒掛掉)就永久遺失,舊卡從此不會再被清。
PendingTakedowns map[string]string `json:"pending_takedowns,omitempty"`
}
// QueueTakedown 記一筆「這個舊路徑(連同當時的頁名)還沒在雲端下架」的待辦。
// 冪等:同一路徑重複呼叫只覆蓋頁名(理論上不會變,但不假設呼叫端不會重複觸發)。
func (m *Manifest) QueueTakedown(oldPath, pageName string) {
if m.PendingTakedowns == nil {
m.PendingTakedowns = map[string]string{}
}
m.PendingTakedowns[oldPath] = pageName
}
// ClearTakedown 下架成功後從待辦清單移除。
func (m *Manifest) ClearTakedown(oldPath string) {
delete(m.PendingTakedowns, oldPath)
}
// newUUID 產生 RFC 4122 v4 UUID(純 stdlib)。