From 3d1cb83110d9653097776224a446ade676474dbf Mon Sep 17 00:00:00 2001 From: richblack Date: Thu, 27 Aug 2026 21:30:21 +0800 Subject: [PATCH] =?UTF-8?q?sync:=20collector/=20=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E8=87=AA=20inkstone/arcrun-rag@7c21a3a=EF=BC=88=E6=A1=8C?= =?UTF-8?q?=E9=9D=A2=E5=B0=8F=E5=B9=AB=E6=89=8B=200.18.42=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 + cmd/arcrun-app/.version-source-files.json | 32 ++-- cmd/arcrun-app/.version-source.json | 3 +- convert_wiring_test.go | 8 +- direct.go | 33 +++- extract_gemma.go | 4 +- extract_gemma_test.go | 10 +- extract_workersai.go | 4 +- extract_workersai_test.go | 10 +- manifest.go | 7 + sourcerepair.go | 213 ++++++++++++++++++++++ sourcerepair_live_test.go | 67 +++++++ sourcerepair_test.go | 140 ++++++++++++++ triggeroutcome_test.go | 2 +- vault_footprint_test.go | 2 +- wikishape.go | 99 +++++++++- wikishape_origin_test.go | 123 +++++++++++++ wikishape_test.go | 35 ++-- 18 files changed, 737 insertions(+), 61 deletions(-) create mode 100644 sourcerepair.go create mode 100644 sourcerepair_live_test.go create mode 100644 sourcerepair_test.go create mode 100644 wikishape_origin_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c9e0f6..44fb37a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,12 @@ --- +## 0.18.42(2026-08-27) + +- 🔴 **問 AI「這份資料的原文在哪」,現在答得出你走得到的位置**:以前它會回一個像 `../某某檔.pdf` 的東西——那是程式自己擺放檔案的相對位置,你拿著它打不開任何檔案;原稿如果放在子資料夾裡,連是哪一層都看不出來。現在每張卡的「出處」寫的是**哪一台電腦 › 哪一個知識庫 › 庫內哪一個路徑**(例如 `youlinhsieh@Leo-MBA › youlinhsieh-test1 › 會議紀錄/2026/某某.md`),照著三格走就找得到那個檔。 +- **以前已經整理過的筆記也會一起換掉,不用重新整理一次**:小幫手會把本機既有的卡片改成新寫法並重新送上去一次,**不會再花一次 AI 額度、內容其餘一個字都不動**;每個資料夾只做一次,做完就不再重複。 +- **刻意不寫完整的電腦路徑**:卡片會被別台電腦上的 AI 讀到,而完整路徑只在原本那台成立——寫了反而給你一個看起來精確、實際上打不開的答案。 + ## 0.18.41(2026-08-27) - 🔴 **不叫「docs」的文件夾,現在也認得出來**:以前只有名字剛好叫 `docs`/`doc`/`documentation` 的資料夾會被讀,一個放滿 PDF、Word、筆記的資料夾只要名字不一樣(例如一個舊專案的存檔夾),就整個被當成程式碼跳過,畫面上也不會說為什麼。現在只要一個資料夾裡完全沒有程式碼、有看得懂的文件,不管叫什麼名字都會被收進來——你不用先學會「哪個按鈕可以救回一個資料夾」,大部分情況它一開始就是對的。 diff --git a/cmd/arcrun-app/.version-source-files.json b/cmd/arcrun-app/.version-source-files.json index 1faef37..06545f6 100644 --- a/cmd/arcrun-app/.version-source-files.json +++ b/cmd/arcrun-app/.version-source-files.json @@ -1,10 +1,10 @@ { "_algo": 4, - "version": "0.18.41", - "fingerprint": "8033de0cdec3537b", + "version": "0.18.42", + "fingerprint": "43e0c6ac08233f07", "files": { ".gitignore": "4d56952b0fb13bf8f9b6c13a6d4c34a075bac3af447636a1df4335d7576e2f97", - "CHANGELOG.md": "7f8a82a8a89483c09b76ed23773f8ef4dc56dae398e558130d40de4ddbdea107", + "CHANGELOG.md": "6cbc29aad162ab41f6db0ef6550bf6d5c44c01544598816d33b58ad585446528", "DAEMON_LINE": "d5019abbdc8a5f2919e9e3510391891cd7fbdf0765bf16ec83caa779f370116d", "README.md": "9d92cac236b20a0b183eea3e7f5e39ad492f05192c4ea602eb11c3d09967327f", "check-standalone.sh": "65fbce096326791c2f103a51e76d0ad79e9e510e2a7700b49ac2d2d81f73c853", @@ -121,8 +121,8 @@ "convert_table.go": "5313b96e4d85d65e6c3c8b51e7c0e25aee11a47fdbda534fe8432fbea87836c5", "convert_table_test.go": "d0371b7566ef3152f9dd42f9f990e0dffa1c50a0c8e874a28415fa2e4c188394", "convert_test.go": "04f3fa30d1be5f910c0e0be3308ced2963191ab030a9eaccd986ef581fcd4e18", - "convert_wiring_test.go": "09e97bf32ace245b55acc7d65ee5c0bb7fd84f8fb1aab61f603f9e9d18b22f13", - "direct.go": "edea1388f4305d71b7738f131ed8513ac5de7b8d04db7d98f603d70ea80bf53c", + "convert_wiring_test.go": "3389817e596341369a3fc102846bf5b081cee668e86ebf4830eb633978de652b", + "direct.go": "b4dc846cd62b0d57754afeb66658e35f2578afd6ca256e95ee8f72c0f03507b9", "direct_extract_test.go": "317d0057d2d368fabc406351a7477f72e074d2b8c8459b1dc021febdf16902f4", "direct_multi_test.go": "a76593fef26231e18b7402f6f7376624d2471f8a719b3ce08fc4a0f5a3d3cf1e", "direct_pacing.go": "b54838ce85f72ecd2d3388fa7cf89050c87fb10bd67dca07cd18d49568a8837c", @@ -132,11 +132,11 @@ "direct_sync_now_test.go": "dc7a6e3d5646a470e889d30f0224cdb86be832b8cf85ffef8629e42ec577622b", "direct_t182_test.go": "1c64a9d0f41b98504e10a20a96f28e8a47cced4923171ef40dc800b861d0275b", "extract.go": "76c40068d378d6036e330bb56e41caec2f80b9b219352586744194df560c8147", - "extract_gemma.go": "9d127fa467b8c760ec46180123ec0fdadd4336290555687f16baf0b80b24c6f2", - "extract_gemma_test.go": "1fae16cc43e3de9728b88ea7151da9c836bf32cb5bead2805fbadc5e788fbab8", + "extract_gemma.go": "53483c6ea3992759abbae45562bfc418d4df1728b44fa87b3bd30324d28fc4f4", + "extract_gemma_test.go": "439f12c1c6fb534b31f983305a4d7898ef66dfcbe697714be08a6b571e4e57c0", "extract_test.go": "63560ecca929c6507d93b3f52988bb7ce2cd6137140eb373c520eac5ee052c76", - "extract_workersai.go": "d8f192472883e7a7f5d034d9b0e0d8be46f284bef2c500f934ec740379efffde", - "extract_workersai_test.go": "188822f4c44ee5bf3b1400e1475af314ad831296538a68c5e1006341ba63765e", + "extract_workersai.go": "577ea44f3224b54cac9042dcb11edcc29404313683c3c92d4267b07b82f35097", + "extract_workersai_test.go": "8850e7128f03ecb4754a4663281800dc73d556fab1d80367924b58bae3dd8ec3", "folderindex.go": "f3dd186fd7e882ff293b3f8aa6b2582b018290ee5f14bbdc02a7ac131834fd69", "folderindex_helpers_test.go": "2cf17b70c396225f728e8e160cfc75d7bdc76ac1ce8e574d1450b020050c0734", "folderindex_test.go": "a5319eb1e92473cb5c32d3beddb7889997556227824de300dd39953b43beab70", @@ -161,7 +161,7 @@ "machineid_test.go": "5b54ac62ee1fb28b8f0a873888aeddb844af0aa74472bfb849573cfc6691a290", "machinemark.go": "b93464c2ee89a73c0e0da38bcce5dc1ba6c1deba2fc483f28c6021c3ffdee4a0", "main.go": "fe2f69739f3ee92713f8aaca925068ecf97b33d476eee826fc1e0d5def8f0d10", - "manifest.go": "61a9403a3da46f270ce6a1610b9307fd19b794521b71a7a8f22cea36a6ea35cd", + "manifest.go": "c6b6fbdd5cc6963345048aa1b0a32f61b7b0b5b84616a15827aea290bd3758e1", "manifest_extractedby_test.go": "8019fe35675a1e801b7bda829ee1d647de32db03c029b1c651f71573e644c69e", "manifest_retry_test.go": "e6b4a321ccddfeed8cdbfabe6d031d5a9c59a33b47baf0b6d21150fa8406748a", "multiaccount_connected_test.go": "ba1817b73a5dd0dd5d300750e54d5c53bd82a75ef6e43caeddfcb2303d64ff66", @@ -182,6 +182,9 @@ "scan_dedup_test.go": "580ec58596ca54aa3d29b417e7d098fd2e3455fd39bf10e7da02d453c0786df0", "scan_skipped_test.go": "983ecabb5d93388a501836d15dd445789b7f3028a7f3b966e04425ad4d5bfd9c", "scan_test.go": "d0e472686ff41da2421bfd45b63099fd75877e6fc72f26dfdd1a07dc64c9f0cd", + "sourcerepair.go": "2ac80c82ca0cb376515448ecc4c38ab542cc8c885f3cc5bc8b604a94c064c7f3", + "sourcerepair_live_test.go": "2a7f5d6fada420a7f55fa52b87361842755c68c1cb58f8c3fd0153341f709a0a", + "sourcerepair_test.go": "2a1ddddaad8ac7adc36350859e2d742c0fea12b99370a4d4831d390db1cc6720", "supervisor/hidewindow_other.go": "291b4ecb0783ff3e813c68b5464161d9af2f84d31c773c7f1f710f218bb5d610", "supervisor/hidewindow_windows.go": "29796dbe90ecb35e6f1451dacf956d42928c6a393271befc5c7f6863d5315fb3", "supervisor/supervisor.go": "cb17332ce5a7190627839e624895c56b508635edbd2c29758f794d5c3bb39388", @@ -241,16 +244,17 @@ "trigger.go": "f689f701bef08401f5d47f5b3d783d24a88fc9a9426fe41bdbbb3f9b152ab405", "trigger_test.go": "0c2482d18cea0568cd2a0f5eae5b02a0984b9b2bc83461eb882c630d6381ca02", "triggeroutcome.go": "25832326ecb3694d8a3a0f8a115ce8df6327b70ff61b0bec1357d57e53e81836", - "triggeroutcome_test.go": "fc429ea5da193e6250b10f7d87690c293c21a0b4db763eedbc04fac9e1995e96", + "triggeroutcome_test.go": "0f736ba4125cf3da8e736791400a8091d0372fc0a4d252c4a1b293a4483d7720", "upload.go": "a41bb459eac8f6c114be567a2d7c53bbdb11b0e31f0bda4b78776502664273cb", "upload_test.go": "f97d2f3bf2213e6fff94b77b7120aa4e4f23f58c7e413c0416f02be387f2cb91", "upstream_error_visible_test.go": "6b72c903594b3eba47cfdeb40654f1f5c341757f79dfbc5c05351cd5b99c9368", "vault.go": "91d61613d37da5c16b24e08c70d59cf4c9cde9375737573d65921949087f1465", - "vault_footprint_test.go": "e3909a160675f4d750c4ad0ed608c0e8872e38bc5c125b6389c6b1e5f1b4afdb", + "vault_footprint_test.go": "fcfc18b90b038f16fd12c5533aff66acf40ee44b28fd4972880d8ebed807e3b1", "vault_subdir_test.go": "ecdd3221342e6b922ae29a7f873c4c599bd22bfb716b0a5044ba2f11a1e08d32", "vault_test.go": "f7ba916d0ec4fc69ea7cc3832ebca0aee83e00f4ae93077038519d06817b4c21", "verify-60.sh": "323347b19a366d5bcf8ddb65c9800dde32027d0170675f90e0ea46d6d1fcadc4", - "wikishape.go": "b6e55f1d006c4eb4bec37448d82d56e70ad7f577d7f97dc5bb00480cc1de7f23", - "wikishape_test.go": "910d019d13302e8cab0978fd15d201f96db2a533751779f2707f6d90ac3e148e" + "wikishape.go": "75901acc73abaa9b9340f13c110d90e1972207326acf8fc9ea9d6912b88524c1", + "wikishape_origin_test.go": "83310bb8476fb2af0eee1d7924cfc02695f280ffeeae63e5fbd7c70d4a8b0543", + "wikishape_test.go": "1727392a92f16b9e0b7666d752cbdd49bdf37edf310cb841cbb90f8b1a4939a5" } } diff --git a/cmd/arcrun-app/.version-source.json b/cmd/arcrun-app/.version-source.json index f26f305..6dc9900 100644 --- a/cmd/arcrun-app/.version-source.json +++ b/cmd/arcrun-app/.version-source.json @@ -11,5 +11,6 @@ "0.18.38": "7add5da65c671548", "0.18.39": "316426f50e886003", "0.18.40": "a4485965c128ef9d", - "0.18.41": "8033de0cdec3537b" + "0.18.41": "8033de0cdec3537b", + "0.18.42": "43e0c6ac08233f07" } diff --git a/convert_wiring_test.go b/convert_wiring_test.go index 6046ce4..988e060 100644 --- a/convert_wiring_test.go +++ b/convert_wiring_test.go @@ -67,7 +67,7 @@ func TestWiring_docx轉檔發生在送LLM之前(t *testing.T) { writeDocx(t, filepath.Join(dir, rel), `維修費用 350,000 元`) // 空 API key → 函式最前面就擋下,證明不了轉檔。給假 key 讓它走到轉檔那一步。 - _, err := ExtractWithGemma("fake-key-for-test", "", dir, rel) + _, err := ExtractWithGemma("fake-key-for-test", "", dir, rel, testOrigin()) if err == nil { t.Fatal("用假 key 不該成功") } @@ -90,7 +90,7 @@ func TestWiring_抽不出文字要明確失敗不靜默(t *testing.T) { rel := "空的.docx" writeDocx(t, filepath.Join(dir, rel), ` `) - _, err := ExtractWithGemma("fake-key-for-test", "", dir, rel) + _, err := ExtractWithGemma("fake-key-for-test", "", dir, rel, testOrigin()) if err == nil { t.Fatal("抽不出文字應該失敗") } @@ -109,7 +109,7 @@ func TestWiring_未支援格式與無文字要能區分(t *testing.T) { rel := "簡報.ppt" os.WriteFile(filepath.Join(dir, rel), []byte("\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1fake"), 0o644) - _, err := ExtractWithGemma("fake-key-for-test", "", dir, rel) + _, err := ExtractWithGemma("fake-key-for-test", "", dir, rel, testOrigin()) if err == nil { t.Fatal("未支援格式應該失敗") } @@ -127,7 +127,7 @@ func TestWiring_md不受轉檔層影響(t *testing.T) { rel := "筆記.md" os.WriteFile(filepath.Join(dir, rel), []byte("# 標題\n\n內容"), 0o644) - _, err := ExtractWithGemma("fake-key-for-test", "", dir, rel) + _, err := ExtractWithGemma("fake-key-for-test", "", dir, rel, testOrigin()) if err == nil { t.Fatal("假 key 不該成功") } diff --git a/direct.go b/direct.go index ffcc0a8..fcfba5a 100644 --- a/direct.go +++ b/direct.go @@ -1391,6 +1391,26 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta saveManifest() // 拔掉的章與對帳時間當下就落盤(斷點續傳同款) } + // 🔴 既有卡片的「### 出處」就地修正(`inkstone/Arcrun#167`)——一次性、修完蓋章。 + // 舊卡的出處寫的是 `../<檔名>`(daemon 自己的目錄結構),AI 照著答、使用者走不到。 + // 修產生端只治得了新卡,票上寫死「不能只修新的」⇒ 這裡把本機既有的卡重畫那一塊 + // 並原樣重推(零 LLM,workflow 進門先刪同名舊 blocks ⇒ 取代不疊加)。見 sourcerepair.go。 + if sr := repairCardSourceBlocks(cfg, absRoot, m, dryRun, false, runNow); sr != nil { + if sr.Repushed > 0 { + results = append(results, DirectResult{ + Type: "resync", Path: absRoot, Status: "noticed", + Error: fmt.Sprintf("已修好 %d 份筆記的「原文位置」寫法(從前寫的是程式內部路徑,現在會告訴你在哪台機器、哪個知識庫、庫內哪一個檔)", sr.Repushed), + }) + } + if sr.Err != "" { + results = append(results, DirectResult{ + Type: "resync", Path: absRoot, Status: "noticed", + Error: "更新「原文位置」時有一份沒成功(不影響同步,稍後自動再試):" + sr.Err, + }) + } + saveManifest() + } + // 2026-08-07 task 3:Scan() 會把 removed 的路徑從 m.Entries 整批拿掉(rebuild 語意, // 見 scan.go 步驟 7)——但那只是「偵測到不見了」,不代表下架 POST 已經成功。 // 沒有這份快照的話,本輪只要有任何一個 added/modified 事件先觸發了下面的 @@ -1578,6 +1598,15 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta pace() // 2026-08-07:每次要觸發雲端(萃取/POST)之前先節流一下 if cfg.Extractor != "" { // 四步定稿:本地萃卡 → 每張卡 POST rag_ingest_card(原文不出機) + // 🔴 `inkstone/Arcrun#167`:卡片的「### 出處」要寫得出「哪台機器 / + // 哪個庫 / 庫內什麼路徑」,所以萃卡前先把這三件備好交給塑形層。 + // 三件與下面 cardBody 送雲端的 machine/library/path 是**同一組值** + // ——卡上寫的與雲端存的從此對得起來(不再各說各話)。 + cardOrigin := SourceOrigin{ + MachineLabel: cfg.machineIdentity().Label, + Library: cfg.libraryFor(absRoot), + LibraryPath: ev.Path, + } var cards []string var xerr error switch cfg.Extractor { @@ -1590,9 +1619,9 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta // ⇒ 探測在 RunDirectOnce(ProbeWorkersAI),結果寫進 status.json, // 托盤那行「狀態:」直接告訴用戶該做什麼。 // 靜默退回會讓用戶**永遠不知道自己的雲端還沒更新**——正是要避免的黑箱。 - cards, xerr = ExtractWithWorkersAI(cfg.CypherURL, cfg.APIKey, absRoot, ev.Path) + cards, xerr = ExtractWithWorkersAI(cfg.CypherURL, cfg.APIKey, absRoot, ev.Path, cardOrigin) case "gemma": - cards, xerr = ExtractWithGemma(cfg.GeminiAPIKey, cfg.LLMModel, absRoot, ev.Path) + cards, xerr = ExtractWithGemma(cfg.GeminiAPIKey, cfg.LLMModel, absRoot, ev.Path, cardOrigin) default: // t176:claude 路先不支援(RunDirectOnce 開頭已正規化)。 // 走到這裡代表 config 有沒見過的值——誠實報錯,不要靜默跳過(禁假綠)。 diff --git a/extract_gemma.go b/extract_gemma.go index 8eceb0e..53e81a8 100644 --- a/extract_gemma.go +++ b/extract_gemma.go @@ -88,7 +88,7 @@ func cleanGemmaCard(text, pageName string) string { // ExtractWithGemma 讀原稿 → 呼 Gemini 萃卡 → 卡片落地 system-dev/wiki/cards/。 // 回傳產出的卡片相對路徑(單檔一卡)。 -func ExtractWithGemma(apiKey, model, absRoot, relPath string) ([]string, error) { +func ExtractWithGemma(apiKey, model, absRoot, relPath string, origin SourceOrigin) ([]string, error) { if apiKey == "" { return nil, fmt.Errorf("gemma 萃取路需要 gemini_api_key(config)") } @@ -171,7 +171,7 @@ func ExtractWithGemma(apiKey, model, absRoot, relPath string) ([]string, error) if perr != nil { return nil, perr } - cards, berr := BuildWikiDoc(absRoot, relPath, srcText, ex, time.Now()) + cards, berr := BuildWikiDoc(absRoot, relPath, srcText, ex, origin, time.Now()) if berr != nil { return nil, berr } diff --git a/extract_gemma_test.go b/extract_gemma_test.go index 5767967..43ca3f8 100644 --- a/extract_gemma_test.go +++ b/extract_gemma_test.go @@ -44,7 +44,7 @@ func TestExtractWithGemmaThinkingModel(t *testing.T) { }}, }) })() - cards, err := ExtractWithGemma("k123", "gemma-test", root, "會議記錄.md") + cards, err := ExtractWithGemma("k123", "gemma-test", root, "會議記錄.md", testOrigin()) if err != nil { t.Fatal(err) } @@ -79,14 +79,14 @@ func TestExtractWithGemmaThoughtOnly(t *testing.T) { }}, }) })() - if _, err := ExtractWithGemma("k", "m", root, "x.md"); err == nil { + if _, err := ExtractWithGemma("k", "m", root, "x.md", testOrigin()); err == nil { t.Fatal("thought-only 應報錯") } } // 缺 key=引導訊息。 func TestExtractWithGemmaNoKey(t *testing.T) { - if _, err := ExtractWithGemma("", "m", t.TempDir(), "x.md"); err == nil { + if _, err := ExtractWithGemma("", "m", t.TempDir(), "x.md", testOrigin()); err == nil { t.Fatal("缺 key 應報錯") } } @@ -124,7 +124,7 @@ func TestExtractWithGemma_VaultDoesNotGainPages(t *testing.T) { pagesBefore := countMD(t, filepath.Join(root, "pages")) + countMD(t, filepath.Join(root, "journals")) + countTopLevelMD(t, root) - cards, err := ExtractWithGemma("k123", "gemma-test", root, srcRel) + cards, err := ExtractWithGemma("k123", "gemma-test", root, srcRel, testOrigin()) if err != nil { t.Fatal(err) } @@ -181,7 +181,7 @@ func TestExtractWithGemma_VaultExistingCardNotClobbered(t *testing.T) { }) })() - _, err := ExtractWithGemma("k123", "gemma-test", root, srcRel) + _, err := ExtractWithGemma("k123", "gemma-test", root, srcRel, testOrigin()) if err == nil { t.Fatal("目標位置被別人佔用時應報錯,不得無聲覆蓋") } diff --git a/extract_workersai.go b/extract_workersai.go index 4aa08c3..a161fed 100644 --- a/extract_workersai.go +++ b/extract_workersai.go @@ -94,7 +94,7 @@ func tooBigForWorkersAI(srcText, relPath string) string { // ExtractWithWorkersAI 讀原稿 → 送自己雲端的 /portal/daemon/extract 萃卡 → 卡片落地。 // cypherURL/apiKey 用的是 daemon 既有的連線憑證(送卡片上雲時同一把,見 direct.go)。 // 回傳產出的卡片相對路徑(單檔一卡),與 ExtractWithGemma 契約一致。 -func ExtractWithWorkersAI(cypherURL, apiKey, absRoot, relPath string) ([]string, error) { +func ExtractWithWorkersAI(cypherURL, apiKey, absRoot, relPath string, origin SourceOrigin) ([]string, error) { if strings.TrimSpace(cypherURL) == "" { return nil, fmt.Errorf("workers-ai 萃取路需要 cypher_url(config)") } @@ -183,7 +183,7 @@ func ExtractWithWorkersAI(cypherURL, apiKey, absRoot, relPath string) ([]string, if perr != nil { return nil, perr } - cards, berr := BuildWikiDoc(absRoot, relPath, srcText, ex, time.Now()) + cards, berr := BuildWikiDoc(absRoot, relPath, srcText, ex, origin, time.Now()) if berr != nil { return nil, berr } diff --git a/extract_workersai_test.go b/extract_workersai_test.go index a4a7760..1c50acf 100644 --- a/extract_workersai_test.go +++ b/extract_workersai_test.go @@ -45,7 +45,7 @@ func TestExtractWithWorkersAI_VaultRedirectsAndDoesNotClobber(t *testing.T) { }) defer closeFn() - cards, err := ExtractWithWorkersAI(url, "key123", root, srcRel) + cards, err := ExtractWithWorkersAI(url, "key123", root, srcRel, testOrigin()) if err != nil { t.Fatal(err) } @@ -94,7 +94,7 @@ func TestExtractWithWorkersAI_NonVaultUnchanged(t *testing.T) { }) defer closeFn() - cards, err := ExtractWithWorkersAI(url, "key123", root, srcRel) + cards, err := ExtractWithWorkersAI(url, "key123", root, srcRel, testOrigin()) if err != nil { t.Fatal(err) } @@ -131,7 +131,7 @@ func TestExtractWithWorkersAI_SendsSharedPrompt(t *testing.T) { }) defer closeFn() - if _, err := ExtractWithWorkersAI(url, "key123", root, srcName); err != nil { + if _, err := ExtractWithWorkersAI(url, "key123", root, srcName, testOrigin()); err != nil { t.Fatal(err) } srcText, err := ConvertToText(srcName, []byte(srcBody)) @@ -181,7 +181,7 @@ func TestExtractWorkersAIAndGemmaProduceIdenticalWiki(t *testing.T) { t.Fatal(err) } undo := gemmaCardStub(t, fixture) - cardsG, errG := ExtractWithGemma("k-test", "", rootG, srcName) + cardsG, errG := ExtractWithGemma("k-test", "", rootG, srcName, testOrigin()) undo() if errG != nil { t.Fatal(errG) @@ -195,7 +195,7 @@ func TestExtractWorkersAIAndGemmaProduceIdenticalWiki(t *testing.T) { url, closeFn := workersAIStub(t, func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(map[string]any{"success": true, "output": fixture}) }) - cardsW, errW := ExtractWithWorkersAI(url, "key123", rootW, srcName) + cardsW, errW := ExtractWithWorkersAI(url, "key123", rootW, srcName, testOrigin()) closeFn() if errW != nil { t.Fatal(errW) diff --git a/manifest.go b/manifest.go index 32e9031..cc73671 100644 --- a/manifest.go +++ b/manifest.go @@ -107,6 +107,13 @@ type Manifest struct { // 雲端一壞就是每 5 秒撞一次。內容變了(雜湊不同)視同新卡,立即可再試。 InventoryFailHash string `json:"inventory_fail_hash,omitempty"` InventoryNextRetry int64 `json:"inventory_next_retry,omitempty"` + // SourceOriginRepairedAt=這個監看根的「### 出處」已全部改成 + // 「機器 › 知識庫 › 庫內路徑」的時間(unix 秒,0=還沒修過)。 + // 🔴 `inkstone/Arcrun#167`:舊卡寫的是 `../<檔名>`(daemon 的內部目錄結構), + // AI 照著答,使用者走不到那個檔。修產生端只治得了新卡,既有的要就地重推 + // (見 sourcerepair.go)。這一格的存在是為了**修完就不再每輪掃全庫**—— + // 沒有它,這個修復會變成一個永遠在跑的背景工作。 + SourceOriginRepairedAt int64 `json:"source_origin_repaired_at,omitempty"` // PendingTakedowns=改名/搬移後「舊路徑」在雲端知識庫裡還沒下架成功的待辦清單 // (key=舊相對路徑,value=該路徑當時導出的頁名)。InkStoneCo#44 ⑩: diff --git a/sourcerepair.go b/sourcerepair.go new file mode 100644 index 0000000..28cfcc1 --- /dev/null +++ b/sourcerepair.go @@ -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 的舊 +// 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 +} diff --git a/sourcerepair_live_test.go b/sourcerepair_live_test.go new file mode 100644 index 0000000..3be5dfe --- /dev/null +++ b/sourcerepair_live_test.go @@ -0,0 +1,67 @@ +package collector + +// sourcerepair_live_test.go — 對**真實實例**跑一次既有資料修復(`inkstone/Arcrun#167`)。 +// +// 🔴 為什麼留在 repo 裡而不是用完就丟(同 probe_real_manual_test.go 的理由): +// 這一票的驗收條件是「**MCP client 問『原文在哪』拿到的答案能不能走到那個檔**」—— +// 那不是 fixture 答得出來的。下一個人要重跑存量修復(換機器、換庫、雲端重裝過) +// 就跑這一支,不必再從頭想一次怎麼推。 +// +// 預設 skip,不進 CI。它會**真的寫進知識庫**,所以要明講打的是哪個實例: +// +// LIVE_REPAIR=1 \ +// LIVE_CYPHER=https://arcrun-cypher-executor.youlin-hsieh-dev.workers.dev \ +// LIVE_NS= \ +// LIVE_ROOTS="/Users/x/Desktop/youlinhsieh-test1" \ +// # 加 LIVE_FORCE=1 =不看本機是不是已經新形,一律重推(雲端被清空、或上一輪中斷過時用) +// go test -run TestLiveRepairSourceBlocks -v ./ +// +// 🔴 紅線:LIVE_CYPHER 只准指測試實例。本測試不讀 config.json(那份含多個帳號, +// 誤跑會打到不該打的實例)——實例位址一律由呼叫者當場指名。 + +import ( + "os" + "strings" + "testing" + "time" +) + +func TestLiveRepairSourceBlocks(t *testing.T) { + if os.Getenv("LIVE_REPAIR") == "" { + t.Skip("設 LIVE_REPAIR=1 才跑(會真的寫進知識庫)") + } + cypher := strings.TrimSpace(os.Getenv("LIVE_CYPHER")) + ns := strings.TrimSpace(os.Getenv("LIVE_NS")) + roots := strings.Split(os.Getenv("LIVE_ROOTS"), ",") + if cypher == "" || ns == "" || len(roots) == 0 { + t.Fatal("需要 LIVE_CYPHER / LIVE_NS / LIVE_ROOTS") + } + if strings.Contains(cypher, "leo21c") { + t.Fatal("紅線:不准打 leo21c") + } + for _, root := range roots { + root = strings.TrimSpace(root) + if root == "" { + continue + } + cfg := &DirectConfig{ + CypherURL: strings.TrimSuffix(cypher, "/"), Namespace: ns, APIKey: ns, + CardIngestWF: "rag_ingest_card", + MachineLabel: os.Getenv("LIVE_MACHINE_LABEL"), + Manifest: os.Getenv("LIVE_MANIFEST"), // 空=機器身分不落檔,只在記憶體鑄 + } + // 用一份**丟棄式**的 Manifest:修復的蓋章不寫進正在跑的 daemon 的帳本, + // 免得跟它搶同一個檔(daemon 之後自己會再跑一次,重推是取代不是疊加)。 + m := &Manifest{Root: root, Entries: map[string]*ManifestEntry{}} + res := repairCardSourceBlocks(cfg, root, m, false, os.Getenv("LIVE_FORCE") != "", time.Now()) + if res == nil { + t.Logf("%s:沒有需要修的卡", root) + continue + } + t.Logf("%s:掃 %d 份、本機重畫 %d 份、重推 %d 份、err=%q", + root, res.Scanned, res.Rewrote, res.Repushed, res.Err) + if res.Err != "" { + t.Errorf("%s 有失敗:%s", root, res.Err) + } + } +} diff --git a/sourcerepair_test.go b/sourcerepair_test.go new file mode 100644 index 0000000..33039c5 --- /dev/null +++ b/sourcerepair_test.go @@ -0,0 +1,140 @@ +package collector + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +const legacyCard = `--- +tags: [測試] +gloss: 測試卡 +created: 2026-08-01 +updated: 2026-08-01 +--- +# 小果被AFTEE詐貸 + +← [[00-INDEX]] + +## 摘要 +一段摘要。 + +## 重點 +- 一條重點 + +## 實體 +- **小果**(人物)— 當事人 + +## 關聯 +### 內文知識關係 +- 小果 >` + `> 被詐貸 >` + `> AFTEE +### 卡片關係 +- [[小果被AFTEE詐貸]] >` + `> 整理出 >` + `> [[貸款爭議]] +### 出處 +- ` + "`../小果被AFTEE詐貸.pdf`" + ` >` + `> 提及 >` + `> 小果被AFTEE詐貸 +` + +// TestRepairCardSourceBlocks 驗既有資料的處置:舊形出處就地改寫+原樣重推。 +// 這是票 `inkstone/Arcrun#167` 驗收條件「不能只修新的」的機械閘。 +func TestRepairCardSourceBlocks(t *testing.T) { + root := t.TempDir() + cardRel := "子資料夾/.wiki/小果被AFTEE詐貸.md" + if err := os.MkdirAll(filepath.Join(root, "子資料夾", ".wiki"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, filepath.FromSlash(cardRel)), []byte(legacyCard), 0o644); err != nil { + t.Fatal(err) + } + wm := &wikiManifest{Version: 1, Docs: []wikiDoc{{ + Node: "子資料夾", Path: "小果被AFTEE詐貸.pdf", Status: "extracted", + Card: "小果被AFTEE詐貸", Cards: []string{cardRel}, + }}} + if err := saveWikiManifest(root, wm); err != nil { + t.Fatal(err) + } + + var got map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(b, &got) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true}`)) + })) + defer srv.Close() + + cfg := &DirectConfig{ + CypherURL: srv.URL, APIKey: "k", Namespace: "ns", + CardIngestWF: "rag_ingest_card", + MachineLabel: "教育部 Leo 的 Mac", + } + m := &Manifest{Root: root, Entries: map[string]*ManifestEntry{}} + + res := repairCardSourceBlocks(cfg, root, m, false, false, time.Now()) + if res == nil || res.Repushed != 1 { + t.Fatalf("沒有重推:%+v", res) + } + + // ① 本機卡不再有內部相對路徑,且定位得到子資料夾。 + fixed, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(cardRel))) + if err != nil { + t.Fatal(err) + } + card := string(fixed) + if strings.Contains(card, "../") { + t.Fatalf("本機卡仍帶 `../`:\n%s", card) + } + if !strings.Contains(card, "子資料夾/小果被AFTEE詐貸.pdf") { + t.Fatalf("本機卡的出處定位不到子資料夾:\n%s", card) + } + if !strings.Contains(card, "教育部 Leo 的 Mac") { + t.Fatalf("本機卡的出處沒有機器標記:\n%s", card) + } + // ② 出處以外一字不動(重萃會讓內容變樣,就地修不會)。 + if !strings.Contains(card, "## 摘要\n一段摘要。") || !strings.Contains(card, "- **小果**(人物)— 當事人") { + t.Fatalf("出處以外的內容被動到了:\n%s", card) + } + + // ③ 送上雲的 payload 與 direct.go 送新卡時同一組欄位,且路徑是**庫內完整路徑**。 + if got["path"] != "子資料夾/小果被AFTEE詐貸.pdf" { + t.Fatalf("重推的 path 不對:%v", got["path"]) + } + if got["page_name"] != "小果被AFTEE詐貸" { + t.Fatalf("重推的 page_name 不對:%v", got["page_name"]) + } + if s, _ := got["card_content"].(string); strings.Contains(s, "../") { + t.Fatalf("送上雲的卡片內容仍帶 `../`:\n%s", s) + } + + // ④ 修完蓋章 ⇒ 不會每輪重掃、更不會每輪重推。 + if m.SourceOriginRepairedAt == 0 { + t.Fatal("修完沒蓋章,會變成每輪都跑的背景工作") + } + got = nil + if again := repairCardSourceBlocks(cfg, root, m, false, false, time.Now()); again != nil { + t.Fatalf("蓋章後又跑了一次:%+v", again) + } + if got != nil { + t.Fatal("蓋章後又重推了一次") + } +} + +// TestRewriteSourceBlockLeavesNewShapeAlone:已經是新形的卡不該被再動一次 +//(避免「每輪都改一點」讓使用者的檔案 mtime 無故跳動)。 +func TestRewriteSourceBlockLeavesNewShapeAlone(t *testing.T) { + o := SourceOrigin{MachineLabel: "m", Library: "kb", LibraryPath: "a/b.md"} + var b strings.Builder + renderSourceLine(&b, o, "卡") + card := "## 關聯\n### 卡片關係\n" + b.String() + if hasLegacySourceLine(card) { + t.Fatalf("新形被誤判成舊形:\n%s", card) + } + if rewriteSourceBlock(card, o, "卡") != card { + t.Fatal("新形卡被重寫了") + } +} diff --git a/triggeroutcome_test.go b/triggeroutcome_test.go index 870918f..b84721d 100644 --- a/triggeroutcome_test.go +++ b/triggeroutcome_test.go @@ -148,7 +148,7 @@ func TestExtractWithWorkersAI_太大的檔一個請求都不送(t *testing.T) { })) defer srv.Close() - _, err := ExtractWithWorkersAI(srv.URL, "k", root, "巨檔.md") + _, err := ExtractWithWorkersAI(srv.URL, "k", root, "巨檔.md", testOrigin()) if err == nil { t.Fatal("太大的檔應該直接失敗") } diff --git a/vault_footprint_test.go b/vault_footprint_test.go index ff2d0c9..a273b3d 100644 --- a/vault_footprint_test.go +++ b/vault_footprint_test.go @@ -344,7 +344,7 @@ func TestVaultFootprint_ForeignWikiFileNotClobbered(t *testing.T) { } defer gemmaCardStub(t, cardFixture("會議記錄", "專案"))() - if _, err := ExtractWithGemma("k-test", "gemma-test", root, srcRel); err == nil { + if _, err := ExtractWithGemma("k-test", "gemma-test", root, srcRel, testOrigin()); err == nil { t.Fatal("目標被佔用時應報錯,不得無聲覆蓋") } data, _ := os.ReadFile(filepath.Join(cardDir, "會議記錄.md")) diff --git a/wikishape.go b/wikishape.go index c20234e..6f8265c 100644 --- a/wikishape.go +++ b/wikishape.go @@ -386,13 +386,93 @@ func entityFallback(ents []WikiEntity, selfName, selfGloss string) []WikiEntity return []WikiEntity{{Name: selfName, Type: "概念", Desc: firstNonEmpty(selfGloss, "本卡描述的概念")}} } -func renderSourceLine(b *strings.Builder, srcRel, cardName string) { +// SourceOrigin=「這份原稿實際在哪裡」的三件式:哪台機器 / 哪個庫 / 庫內什麼路徑。 +// +// 🔴 為什麼要有這個型別(`inkstone/Arcrun#167`,leo 2026-08-27 用 n8n 實測抓到): +// 從前「### 出處」寫的是 `../<檔名>`——那是**卡片檔相對於原檔**的路徑, +// 也就是 daemon 自己的目錄結構(卡在 `/.wiki/`,原檔在上一層)。 +// 它有兩個要命的地方: +// +// ① 它是**內部結構**,卻被寫進知識內容 ⇒ 任何讀到那塊的 AI 都照著答, +// 而使用者拿著 `../小果被AFTEE詐貸.pdf` 走不到任何地方。 +// ② 原稿在子資料夾時,`../<檔名>` 把**資料夾整段弄丟了** +// (實據:`system-dev/wiki/trees/2026-08-17-today.md` 的出處只剩 +// `../2026-08-17-today.md`)⇒ 連「同一個庫裡是哪一份」都定位不到。 +// +// 三件式是**同一份 metadata 早就在送的那三格**(library/machine/source_path, +// 見 direct.go 的 cardBody 與 rag_ingest_card 的 post_block)—— +// 這裡不是新增資料,是把已經有的東西寫成人看得懂的樣子。 +// +// 🔴 為什麼**不寫絕對路徑**(票上要求說明判斷):卡會離開這台機器(上雲、被別台的 +// AI 讀到),而絕對路徑只在鑄它的那台成立。給了它,跨機器問「原文在哪」拿到的 +// 會是一個**看起來最精確、實際上打不開**的答案——那正是 `../` 這枚坑的同一個形狀。 +// 「機器 + 庫 + 庫內路徑」才是三台機器上都成立的座標:認出機器 → 打開那個庫的 +// 資料夾 → 走庫內路徑。絕對路徑該由**知道監看根在哪的那一端**(daemon/portal) +// 現場組,不該固化進知識內容。 +type SourceOrigin struct { + // MachineLabel=人看得懂的機器稱呼(daemon 的 machine.json / config 的 machine_label)。 + MachineLabel string + // Library=這份原稿所屬的知識庫名(=監看根,daemon 的 libraryFor)。 + Library string + // LibraryPath=**庫內**相對路徑,含子資料夾,一律 forward slash。 + // 與 metadata 的 source_path 同一個值 ⇒ 卡上寫的與雲端存的對得起來。 + LibraryPath string +} + +// unknownOriginMark=三件式缺格時的誠實標記。 +// 寧可讓使用者看到「(未知)」,也不要給一個看起來精確、實際上走不到的路徑 +// ——那正是 `../` 這枚坑的形狀。 +const unknownOriginMark = "(未知)" + +// originSep=三件式之間的分隔符,與 portal 的來源顯示同一種 +//(`youlinhsieh@Leo-MBA › rt-lib › 檔名 第 4 段`)⇒ 使用者在兩個地方看到同一種形狀。 +const originSep = " › " + +// Human 回一句「這份原文在哪」:`機器 › 知識庫 › 庫內路徑`。 +func (o SourceOrigin) Human() string { + mach := strings.TrimSpace(o.MachineLabel) + if mach == "" { + mach = "機器" + unknownOriginMark + } + lib := strings.TrimSpace(o.Library) + if lib == "" { + lib = "知識庫" + unknownOriginMark + } + return mach + originSep + lib + originSep + o.pathOrUnknown() +} + +// Ref 回三元組主詞用的那個字串=`庫名/庫內路徑`。 +// 庫名未知時退回純庫內路徑——**任何情況下都不帶 `../`**。 +func (o SourceOrigin) Ref() string { + lib := strings.TrimSpace(o.Library) + if lib == "" { + return o.pathOrUnknown() + } + return lib + "/" + o.pathOrUnknown() +} + +func (o SourceOrigin) pathOrUnknown() string { + p := strings.TrimSpace(filepath.ToSlash(o.LibraryPath)) + p = strings.TrimPrefix(p, "./") + if p == "" { + return "庫內路徑" + unknownOriginMark + } + return p +} + +// renderSourceLine 寫「### 出處」那一塊:**先一行人話位置,再一行三元組**。 +// +// 人話那行刻意不含 `>>`:`## 關聯` 段的解析(`rag_ingest_card` 的 parse_card) +// 只把含兩個 `>>` 的行收成三元組 ⇒ 它進得了知識內容、進不了圖, +// 正好是我們要的(給人讀的句子不該變成一條邊)。 +func renderSourceLine(b *strings.Builder, o SourceOrigin, cardName string) { b.WriteString("### 出處\n") - b.WriteString("- `" + srcRel + "`" + triSep + "提及" + triSep + cardName + "\n") + b.WriteString("- 原文位置(機器" + originSep + "知識庫" + originSep + "庫內路徑):`" + o.Human() + "`\n") + b.WriteString("- `" + o.Ref() + "`" + triSep + "提及" + triSep + cardName + "\n") } // renderDocCard 文件卡(=這份文件的 hub):重點是判斷句、行內連到概念卡。 -func renderDocCard(d *wikiDoc, ex *DocExtract, conceptNames []string, srcRel string) string { +func renderDocCard(d *wikiDoc, ex *DocExtract, conceptNames []string, origin SourceOrigin) string { allowed := map[string]bool{"00-INDEX": true, d.Card: true} for _, c := range conceptNames { allowed[c] = true @@ -442,12 +522,12 @@ func renderDocCard(d *wikiDoc, ex *DocExtract, conceptNames []string, srcRel str for _, c := range conceptNames { b.WriteString("- [[" + d.Card + "]]" + triSep + "整理出" + triSep + "[[" + c + "]]\n") } - renderSourceLine(&b, srcRel, d.Card) + renderSourceLine(&b, origin, d.Card) return b.String() } // renderConceptCard 原子概念卡。extraRels=機械補上的反向邊(雙向連結保證)。 -func renderConceptCard(c WikiConcept, cardName, docCard, srcRel, created, updated string, +func renderConceptCard(c WikiConcept, cardName, docCard string, origin SourceOrigin, created, updated string, allowed map[string]bool, extraRels []string) string { var b strings.Builder renderFrontmatter(&b, cleanTags(c.Tags), firstNonEmpty(sanitizeProse(c.Gloss, map[string]bool{}), cardName), created, updated) @@ -497,7 +577,7 @@ func renderConceptCard(c WikiConcept, cardName, docCard, srcRel, created, update for _, line := range extraRels { b.WriteString(line + "\n") } - renderSourceLine(&b, srcRel, cardName) + renderSourceLine(&b, origin, cardName) return b.String() } @@ -525,7 +605,7 @@ func writeWikiFile(absRoot, dest string, content []byte) error { // BuildWikiDoc 把一份文件的萃取結果落成規範形的 `.wiki/` 產物。 // 回傳本次產出的卡(相對監看根、文件卡在第一個)。 // ex.NoConcept 為真(或概念數 0)時不產卡,改走「標空」路徑(回傳空清單)。 -func BuildWikiDoc(absRoot, relPath, srcText string, ex *DocExtract, now time.Time) ([]string, error) { +func BuildWikiDoc(absRoot, relPath, srcText string, ex *DocExtract, origin SourceOrigin, now time.Time) ([]string, error) { if ex == nil { return nil, fmt.Errorf("BuildWikiDoc: 沒有萃取結果") } @@ -651,7 +731,6 @@ func BuildWikiDoc(absRoot, relPath, srcText string, ex *DocExtract, now time.Tim } // 渲染+寫檔(先清掉這份文件上一輪產的、這一輪不再存在的卡)。 - srcRel := "../" + base var newCards []string rel := func(cardName string) string { return filepath.ToSlash(filepath.Join(nodeFromKey(nodeKey), wikiRelDir, cardName+".md")) @@ -684,14 +763,14 @@ func BuildWikiDoc(absRoot, relPath, srcText string, ex *DocExtract, now time.Tim return r, writeWikiFile(absRoot, destAbs, []byte(content)) } - docRel, err := writeCard(docCard, renderDocCard(&entry, ex, conceptNames, srcRel)) + docRel, err := writeCard(docCard, renderDocCard(&entry, ex, conceptNames, origin)) if err != nil { return nil, err } newCards = append(newCards, docRel) for i, c := range concepts { name := conceptNames[i] - content := renderConceptCard(c, name, docCard, srcRel, created, todayOf(now), allowed, extraRels[name]) + content := renderConceptCard(c, name, docCard, origin, created, todayOf(now), allowed, extraRels[name]) r, werr := writeCard(name, content) if werr != nil { return nil, werr diff --git a/wikishape_origin_test.go b/wikishape_origin_test.go new file mode 100644 index 0000000..f204b36 --- /dev/null +++ b/wikishape_origin_test.go @@ -0,0 +1,123 @@ +package collector + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// testOrigin=測試用的三件式(機器/庫/庫內路徑)。 +// 內容不重要,重要的是**每個呼叫端都必須交出它**——`inkstone/Arcrun#167` 的教訓是 +// 「出處寫的是什麼」不能靠預設值,得由知道自己在哪一台、哪個庫的那一端說出來。 +func testOrigin() SourceOrigin { + return SourceOrigin{MachineLabel: "test@Machine", Library: "test-lib", LibraryPath: "測試文件.md"} +} + +// wsOrigin=wikishape 測試用:庫名/機器固定,庫內路徑跟著受測的 relPath 走 +//(子資料夾的檔要驗「資料夾沒被弄丟」,所以這一格不能寫死)。 +func wsOrigin(relPath string) SourceOrigin { + return SourceOrigin{MachineLabel: "test@Machine", Library: "test-lib", LibraryPath: relPath} +} + +// TestSourceOriginNeverEmitsInternalPath 是 `inkstone/Arcrun#167` 的迴歸閘。 +// +// leo 2026-08-27 用 n8n 實測:問「原文 wiki 的位置在哪裏?」,AI 照著卡片內文答 +// `../小果被AFTEE詐貸.pdf`——**使用者拿著它走不到任何地方**。 +// 這裡把那個形狀鎖死:不管三件式缺哪一格,輸出都不准出現 `../`。 +func TestSourceOriginNeverEmitsInternalPath(t *testing.T) { + cases := []struct { + name string + origin SourceOrigin + want []string + }{ + { + "三件齊全", + SourceOrigin{MachineLabel: "youlinhsieh@Leo-MBA", Library: "youlinhsieh-test1", LibraryPath: "小果被AFTEE詐貸.pdf"}, + []string{"youlinhsieh@Leo-MBA › youlinhsieh-test1 › 小果被AFTEE詐貸.pdf", "`youlinhsieh-test1/小果被AFTEE詐貸.pdf`"}, + }, + { + // 🔴 票上的驗收條件三:子資料夾裡的檔要定位得到。 + // 舊寫法 `../<檔名>` 會把整段資料夾弄丟(實據:inkstoneco 庫的 + // system-dev/wiki/trees/2026-08-17-today.md 出處只剩 `../2026-08-17-today.md`)。 + "子資料夾不准被弄丟", + SourceOrigin{MachineLabel: "youlinhsieh@Leo-MBA", Library: "inkstoneco", LibraryPath: "system-dev/wiki/trees/2026-08-17-today.md"}, + []string{"inkstoneco › system-dev/wiki/trees/2026-08-17-today.md", "`inkstoneco/system-dev/wiki/trees/2026-08-17-today.md`"}, + }, + { + "機器未知也要誠實,不得退回相對路徑", + SourceOrigin{Library: "kb", LibraryPath: "a/b.md"}, + []string{"機器(未知) › kb › a/b.md"}, + }, + { + "庫未知", + SourceOrigin{MachineLabel: "m", LibraryPath: "a/b.md"}, + []string{"m › 知識庫(未知) › a/b.md", "`a/b.md`"}, + }, + { + "路徑未知", + SourceOrigin{MachineLabel: "m", Library: "kb"}, + []string{"m › kb › 庫內路徑(未知)"}, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var b strings.Builder + renderSourceLine(&b, c.origin, "某卡") + got := b.String() + if strings.Contains(got, "../") { + t.Fatalf("出處帶內部相對路徑:\n%s", got) + } + for _, w := range c.want { + if !strings.Contains(got, w) { + t.Fatalf("出處少了「%s」:\n%s", w, got) + } + } + // 人話那行不准變成三元組(`## 關聯` 的解析只收含兩個分隔符的行)。 + human := strings.Split(strings.TrimSpace(got), "\n")[1] + if strings.Contains(human, triSep) { + t.Fatalf("人話位置那行含三元組分隔符,會被當成一條邊:%s", human) + } + }) + } +} + +// TestBuildWikiDocSourceLocatesSubfolderDoc 走完整落地路徑(不是只測 renderer): +// 子資料夾裡的原稿,產出的文件卡與概念卡都要寫得出「庫內完整路徑」。 +func TestBuildWikiDocSourceLocatesSubfolderDoc(t *testing.T) { + root := t.TempDir() + rel := "專案/會議/週會.md" + if err := os.MkdirAll(filepath.Join(root, "專案", "會議"), 0o755); err != nil { + t.Fatal(err) + } + src := "# 週會\n\n甲和乙討論了進度。\n" + if err := os.WriteFile(filepath.Join(root, filepath.FromSlash(rel)), []byte(src), 0o644); err != nil { + t.Fatal(err) + } + ex := &DocExtract{ + Gloss: "週會記錄", Summary: "甲乙討論進度", Points: []string{"討論了進度"}, + Concepts: []WikiConcept{{Name: "進度", Gloss: "專案進度", Summary: "專案進度的討論"}}, + } + origin := SourceOrigin{MachineLabel: "youlinhsieh@Leo-MBA", Library: "inkstoneco", LibraryPath: rel} + cards, err := BuildWikiDoc(root, rel, src, ex, origin, time.Date(2026, 8, 27, 0, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatal(err) + } + if len(cards) == 0 { + t.Fatal("沒產出卡") + } + for _, c := range cards { + body, rerr := os.ReadFile(filepath.Join(root, filepath.FromSlash(c))) + if rerr != nil { + t.Fatal(rerr) + } + card := string(body) + if strings.Contains(card, "../") { + t.Fatalf("%s 仍帶 `../`:\n%s", c, card) + } + if !strings.Contains(card, "inkstoneco › 專案/會議/週會.md") { + t.Fatalf("%s 的出處定位不到子資料夾:\n%s", c, card) + } + } +} diff --git a/wikishape_test.go b/wikishape_test.go index 782c8e9..7740247 100644 --- a/wikishape_test.go +++ b/wikishape_test.go @@ -40,7 +40,7 @@ func TestBuildWikiDoc_CardShapeMatchesSpec(t *testing.T) { src := "# 測試文件\n\n內文若干" mustWrite(t, filepath.Join(root, "測試文件.md"), src) - cards, err := BuildWikiDoc(root, "測試文件.md", src, wsExtract(), wsNow) + cards, err := BuildWikiDoc(root, "測試文件.md", src, wsExtract(), wsOrigin("測試文件.md"), wsNow) if err != nil { t.Fatal(err) } @@ -58,9 +58,16 @@ func TestBuildWikiDoc_CardShapeMatchesSpec(t *testing.T) { t.Fatalf("文件卡缺「%s」:\n%s", want, doc) } } - // 出處路徑相對 .wiki,真的指得到原稿(lint「出處路徑指不到原稿」項) - if !strings.Contains(doc, "`../測試文件.md`") { - t.Fatalf("出處路徑不對:\n%s", doc) + // 🔴 `inkstone/Arcrun#167`:出處寫的是「哪台機器/哪個庫/庫內什麼路徑」, + // **不再是 `../`**(那是卡片檔相對原檔的內部結構,使用者拿著它走不到任何地方)。 + if !strings.Contains(doc, "`test@Machine \u203a test-lib \u203a 測試文件.md`") { + t.Fatalf("出處沒寫成「機器 › 庫 › 庫內路徑」:\n%s", doc) + } + if !strings.Contains(doc, "`test-lib/測試文件.md`"+triSep+"提及") { + t.Fatalf("出處三元組主詞不是庫內定位:\n%s", doc) + } + if strings.Contains(doc, "../") { + t.Fatalf("出處仍帶內部相對路徑 `../`:\n%s", doc) } ca := mustRead(t, filepath.Join(root, ".wiki", "概念甲.md")) for _, want := range []string{ @@ -84,7 +91,7 @@ func TestBuildWikiDoc_EdgesAreBidirectional(t *testing.T) { root := t.TempDir() src := "# 測試文件\n內文" mustWrite(t, filepath.Join(root, "測試文件.md"), src) - if _, err := BuildWikiDoc(root, "測試文件.md", src, wsExtract(), wsNow); err != nil { + if _, err := BuildWikiDoc(root, "測試文件.md", src, wsExtract(), wsOrigin("測試文件.md"), wsNow); err != nil { t.Fatal(err) } cb := mustRead(t, filepath.Join(root, ".wiki", "概念乙.md")) @@ -100,7 +107,7 @@ func TestBuildWikiDoc_LinksClosedAndIndexishFixed(t *testing.T) { mustWrite(t, filepath.Join(root, "文件.md"), src) ex := wsExtract() ex.Points = []string{"[[概念甲]] — 這行是 index 式,要被改寫", "這裡提到 [[不存在的卡]] 應拆殼"} - if _, err := BuildWikiDoc(root, "文件.md", src, ex, wsNow); err != nil { + if _, err := BuildWikiDoc(root, "文件.md", src, ex, wsOrigin("文件.md"), wsNow); err != nil { t.Fatal(err) } doc := mustRead(t, filepath.Join(root, ".wiki", "文件.md")) @@ -117,7 +124,7 @@ func TestMarkDocNoConcept_ListedOnIndex(t *testing.T) { root := t.TempDir() mustWrite(t, filepath.Join(root, "發票.txt"), "金額 100") ex := &DocExtract{NoConcept: true, Reason: "純紀錄(發票),沒有可獨立成立的判斷"} - cards, err := BuildWikiDoc(root, "發票.txt", "金額 100", ex, wsNow) + cards, err := BuildWikiDoc(root, "發票.txt", "金額 100", ex, wsOrigin("發票.txt"), wsNow) if err != nil { t.Fatal(err) } @@ -139,7 +146,7 @@ func TestBuildWikiDoc_NestedNodeAndAncestors(t *testing.T) { src := "# 週會決議\n內容" mustWrite(t, filepath.Join(root, rel, "週會.md"), src) - if _, err := BuildWikiDoc(root, "專案/會議/週會.md", src, wsExtract(), wsNow); err != nil { + if _, err := BuildWikiDoc(root, "專案/會議/週會.md", src, wsExtract(), wsOrigin("專案/會議/週會.md"), wsNow); err != nil { t.Fatal(err) } if _, err := os.Stat(filepath.Join(root, "專案", "會議", ".wiki", "週會決議.md")); err != nil { @@ -173,14 +180,14 @@ func TestBuildWikiDoc_ReextractCleansOldCards(t *testing.T) { root := t.TempDir() src := "# 文件\n內文" mustWrite(t, filepath.Join(root, "文件.md"), src) - if _, err := BuildWikiDoc(root, "文件.md", src, wsExtract(), wsNow); err != nil { + if _, err := BuildWikiDoc(root, "文件.md", src, wsExtract(), wsOrigin("文件.md"), wsNow); err != nil { t.Fatal(err) } // 第二輪:概念換名 ex2 := wsExtract() ex2.Concepts = []WikiConcept{{Name: "全新概念", Gloss: "新的一句話", Summary: "新摘要", Points: []string{"新判斷"}}} ex2.Points = []string{"重點換成 [[全新概念]] 了"} - if _, err := BuildWikiDoc(root, "文件.md", src, ex2, wsNow.Add(24*time.Hour)); err != nil { + if _, err := BuildWikiDoc(root, "文件.md", src, ex2, wsOrigin("文件.md"), wsNow.Add(24*time.Hour)); err != nil { t.Fatal(err) } if _, err := os.Stat(filepath.Join(root, ".wiki", "概念甲.md")); !os.IsNotExist(err) { @@ -201,7 +208,7 @@ func TestRemoveWikiDoc_CleansEverything(t *testing.T) { root := t.TempDir() src := "# 文件\n內文" mustWrite(t, filepath.Join(root, "文件.md"), src) - if _, err := BuildWikiDoc(root, "文件.md", src, wsExtract(), wsNow); err != nil { + if _, err := BuildWikiDoc(root, "文件.md", src, wsExtract(), wsOrigin("文件.md"), wsNow); err != nil { t.Fatal(err) } if err := RemoveWikiDoc(root, "文件.md"); err != nil { @@ -230,10 +237,10 @@ func TestBuildWikiDoc_SameConceptFromTwoDocsDisambiguated(t *testing.T) { Points: []string{"重點連到 [[迭代]]"}, Concepts: []WikiConcept{{Name: "迭代", Gloss: "同名概念", Summary: "摘要", Points: []string{"判斷"}}}} } - if _, err := BuildWikiDoc(root, "a.md", srcA, ex(), wsNow); err != nil { + if _, err := BuildWikiDoc(root, "a.md", srcA, ex(), wsOrigin("a.md"), wsNow); err != nil { t.Fatal(err) } - if _, err := BuildWikiDoc(root, "b.md", srcB, ex(), wsNow); err != nil { + if _, err := BuildWikiDoc(root, "b.md", srcB, ex(), wsOrigin("b.md"), wsNow); err != nil { t.Fatal(err) } first := mustRead(t, filepath.Join(root, ".wiki", "迭代.md")) @@ -255,7 +262,7 @@ func TestNodeIndex_FiveFieldsPerDocLine(t *testing.T) { root := t.TempDir() src := "# 測試文件\n內文" mustWrite(t, filepath.Join(root, "測試文件.md"), src) - if _, err := BuildWikiDoc(root, "測試文件.md", src, wsExtract(), wsNow); err != nil { + if _, err := BuildWikiDoc(root, "測試文件.md", src, wsExtract(), wsOrigin("測試文件.md"), wsNow); err != nil { t.Fatal(err) } idx := mustRead(t, filepath.Join(root, ".wiki", "00-INDEX.md"))