sync: collector/ 同步自 inkstone/arcrun-rag@7c21a3a(桌面小幫手 0.18.42)

This commit is contained in:
2026-08-27 21:30:21 +08:00
parent 6e80296362
commit 3d1cb83110
18 changed files with 737 additions and 61 deletions
+213
View File
@@ -0,0 +1,213 @@
// sourcerepair.go — 既有卡片的「### 出處」就地修正(`inkstone/Arcrun#167`)。
//
// 病(leo 2026-08-27 用 n8n 實測抓到):卡片內文的「### 出處」寫的是 `../<檔名>`
// 那是**卡片檔相對於原檔**的內部目錄結構。它被當成知識內容存進 KBDB,於是任何
// 接上 MCP 的 AI 問「原文在哪」都照著答——而使用者拿著 `../小果被AFTEE詐貸.pdf`
// 走不到任何地方。原稿在子資料夾時更慘:資料夾整段被弄丟。
//
// 修在產生端(wikishape.go 的 renderSourceLine)只治得了**新卡**。
// 票上寫死了「不能只修新的」⇒ 這支檔是對**既有資料**的處置。
//
// 為什麼是「就地修 + 重推」而不是「重萃」(三選一的取捨):
//
// 重萃 每份檔要再燒一次 AI 額度,而且模型每次判斷不同 ⇒ 使用者眼前的卡會無故變樣。
// 出處那一塊**根本不是模型寫的**(它是機械組裝的),為它重萃是白花錢。
// 留著 票上明文禁止。
// 就地修 ✅ 卡片檔在本機還在 ⇒ 重畫那一塊、原樣重推。零 LLM、內容其餘一字不動。
//
// 重推為什麼是安全的:`rag_ingest_card` 進門先刪同 page_name+同 source_path 的舊
// blockstriplets 再寫(見該 workflow 的 list_old_blockspick_stale)⇒ 同一張卡
// 推第二次是取代,不是疊加。
//
// 三條自我約束:
// - **一次只修一批**sourceRepairBatch):巨量資料夾不該一輪湧完。
// - **修完蓋章就不再跑**Manifest.SourceOriginRepairedAt):不是每輪掃一次全庫。
// - **推失敗不蓋章**:下一輪繼續,但也不重推已經成功的(章是逐檔記在 wiki manifest 上)。
package collector
import (
"os"
"path"
"path/filepath"
"strings"
"time"
)
// sourceRepairBatch=單輪最多修幾份文件(含重推)。
// 與 cloudAuditBatch 同一個精神:積壓再多也不該一輪湧完。
const sourceRepairBatch = 20
// legacySourceMark=舊形出處的指紋。只認這一個字面,認不到就不動它
//(已經是新形的、或使用者自己手改過的,一律不碰)。
const legacySourceMark = "- `../"
// sourceHeading=出處那一塊的標題行。
const sourceHeading = "### 出處"
type sourceRepairResult struct {
Scanned int // 這一輪看過幾份文件
Rewrote int // 本機卡片被重畫的份數
Repushed int // 成功重推上雲的份數
Err string // 最後一個錯誤(原文照留,leo 2026-08-06 的原則)
Done bool // 這個監看根已經全部處理完
}
// hasLegacySourceLine 回答「這張卡的出處還是舊形嗎」。
func hasLegacySourceLine(card string) bool {
i := strings.Index(card, sourceHeading)
if i < 0 {
return false
}
return strings.Contains(card[i:], legacySourceMark)
}
// rewriteSourceBlock 把卡片裡的「### 出處」整塊換成新形。
// 找不到那一塊=不是本塑形層產的卡,原樣回傳(不亂加東西)。
func rewriteSourceBlock(card string, o SourceOrigin, cardName string) string {
i := strings.Index(card, sourceHeading)
if i < 0 {
return card
}
rest := card[i+len(sourceHeading):]
// 區塊結束=下一個標題行或檔尾。出處是「## 關聯」的最後一個 H3,
// 實務上就是檔尾,但仍照界線處理(之後有人在它後面加段落也不會被吃掉)。
end := len(card)
for off := 0; ; {
nl := strings.Index(rest[off:], "\n")
if nl < 0 {
break
}
off += nl + 1
line := rest[off:]
if j := strings.Index(line, "\n"); j >= 0 {
line = line[:j]
}
if strings.HasPrefix(line, "#") {
end = i + len(sourceHeading) + off
break
}
}
var b strings.Builder
renderSourceLine(&b, o, cardName)
return card[:i] + b.String() + card[end:]
}
// cardNameOfFile 從卡片相對路徑取卡名(=檔名去副檔名;BuildWikiDoc 的落點規則)。
func cardNameOfFile(rel string) string {
return strings.TrimSuffix(path.Base(filepath.ToSlash(rel)), ".md")
}
// repairCardSourceBlocks 掃一個監看根的 wiki manifest,把還是舊形出處的卡就地修好,
// 並把**文件卡**(唯一上雲的那張)原樣重推一次。
//
// dryRun 時只看不改(與整條 direct 路徑的 --dry-run 語意一致)。
// force=不看「本機卡是不是還是舊形」,一律重推一次。
// 這是給**手動補救**用的(雲端被清空過、或某一輪推到一半被中斷 ⇒ 本機新形、雲端舊形
// ⇒ 常規判準會判「不用修」而永遠不補推)。daemon 常規路徑一律 false。
func repairCardSourceBlocks(cfg *DirectConfig, absRoot string, m *Manifest, dryRun, force bool, now time.Time) *sourceRepairResult {
if m == nil || (!force && m.SourceOriginRepairedAt > 0) {
return nil // 這個根已經修完,不再每輪掃
}
wm := loadWikiManifest(absRoot)
if len(wm.Docs) == 0 {
if !dryRun {
m.SourceOriginRepairedAt = now.Unix() // 沒有 .wiki 產物=沒東西可修,蓋章收工
}
return nil
}
mach := cfg.machineIdentity()
library := cfg.libraryFor(absRoot)
res := &sourceRepairResult{}
remaining := 0
for di := range wm.Docs {
d := &wm.Docs[di]
if len(d.Cards) == 0 {
continue
}
libPath := path.Join(nodeFromKey(d.Node), filepath.ToSlash(d.Path))
// 這一份的卡有沒有舊形出處?先看文件卡(第一張),它就是上雲的那張。
docAbs := filepath.Join(absRoot, filepath.FromSlash(d.Cards[0]))
body, rerr := os.ReadFile(docAbs)
if rerr != nil {
continue // 卡不見了(使用者刪過/被搬走)=不是這支檔的題目
}
// 🔴 判準只看**文件卡**(=唯一上雲的那張),而且它是「已修好**且已推上去**」的
// 唯一憑據。實撞(2026-08-27,第一輪跑到一半被中斷):先改本機再推,被中斷之後
// 本機已是新形、雲端還是舊的 ⇒ 下一輪看本機判「不用修」⇒ **那份永遠不會補推**。
// 所以順序是**先推、推成功才寫本機**——推失敗的話本機留著舊形,下一輪自然重試。
if !force && !hasLegacySourceLine(string(body)) {
// 文件卡已是新形(代表也推過了);概念卡只落本機、不上雲,順手補齊即可。
for _, c := range d.Cards[1:] {
abs := filepath.Join(absRoot, filepath.FromSlash(c))
raw, e := os.ReadFile(abs)
if e != nil || !hasLegacySourceLine(string(raw)) {
continue
}
fixed := rewriteSourceBlock(string(raw), SourceOrigin{
MachineLabel: mach.Label, Library: library, LibraryPath: libPath,
}, cardNameOfFile(c))
if werr := writeWikiFile(absRoot, abs, []byte(fixed)); werr != nil {
res.Err = werr.Error()
}
}
continue
}
res.Scanned++
if res.Scanned > sourceRepairBatch {
remaining++
continue
}
if dryRun {
continue
}
origin := SourceOrigin{MachineLabel: mach.Label, Library: library, LibraryPath: libPath}
docCard := rewriteSourceBlock(string(body), origin, cardNameOfFile(d.Cards[0]))
// ① 先重推(零 LLM)。欄位與 direct.go 送新卡時逐欄一致,
// workflow 進門會先刪同 page_name+同 source_path 的舊 blocks ⇒ 取代不疊加。
status, _, perr := cfg.postJSON(cfg.triggerURL(cfg.CardIngestWF), map[string]any{
"page_name": pageNameOf(libPath),
"path": libPath,
"card_content": docCard,
"library": library,
"machine": mach.ID,
"machine_label": mach.Label,
})
if perr != nil || status < 200 || status >= 300 {
if perr != nil {
res.Err = perr.Error()
} else {
res.Err = "重推知識庫失敗(HTTP " + itoa(status) + ""
}
remaining++ // 這一份沒推成功 ⇒ 本機不動(留著舊形當重試訊號)、這個根不能蓋章
continue
}
// ② 推成功了才寫本機(文件卡+概念卡)。
for _, c := range d.Cards {
abs := filepath.Join(absRoot, filepath.FromSlash(c))
raw, e := os.ReadFile(abs)
if e != nil {
continue
}
fixed := rewriteSourceBlock(string(raw), origin, cardNameOfFile(c))
if fixed == string(raw) {
continue
}
if werr := writeWikiFile(absRoot, abs, []byte(fixed)); werr != nil {
res.Err = werr.Error()
}
}
res.Rewrote++
res.Repushed++
}
if !dryRun && remaining == 0 {
m.SourceOriginRepairedAt = now.Unix()
res.Done = true
}
if res.Scanned == 0 && res.Rewrote == 0 {
return nil
}
return res
}