214 lines
8.3 KiB
Go
214 lines
8.3 KiB
Go
// 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 的舊
|
||
// blocks/triplets 再寫(見該 workflow 的 list_old_blocks/pick_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
|
||
}
|