Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d1cb83110 | |||
| 6e80296362 | |||
| 58d47d9080 | |||
| e3f1d0177d |
@@ -47,6 +47,25 @@
|
||||
|
||||
---
|
||||
|
||||
## 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、筆記的資料夾只要名字不一樣(例如一個舊專案的存檔夾),就整個被當成程式碼跳過,畫面上也不會說為什麼。現在只要一個資料夾裡完全沒有程式碼、有看得懂的文件,不管叫什麼名字都會被收進來——你不用先學會「哪個按鈕可以救回一個資料夾」,大部分情況它一開始就是對的。
|
||||
|
||||
## 0.18.40(2026-08-27)
|
||||
|
||||
- 🔴 **卡住的檔案不再擋住後面所有人**:以前只要有幾個檔一直收不上去(格式讀不了、雲端當天額度滿了…),它們就會**每一輪都繼續佔著最前面的位子**,即使那一輪根本沒去試它們。結果是排在後面、本來好好的檔案永遠輪不到——你等再久、跑再多輪都一樣,數字只會往上長不會往下掉。現在退避中的檔案會讓開,後面的檔案遞補得上來,佇列真的會往前走。
|
||||
|
||||
## 0.18.39(2026-08-27)
|
||||
|
||||
- 🔴 **「已送上去」從今以後是真的送上去了**:以前只要雲端把請求收下來,小幫手就算它成功——就算雲端其實根本沒把它寫進你的知識庫。畫面上是綠的、數字也在跑,而 AI 一句都查不到,而且那些檔案因為被蓋了「已送」的章,**永遠不會再試一次**。現在它會看雲端真正的回覆:沒寫進去就誠實標成失敗、不蓋章、下一輪重新送。(實測:一個開發資料夾 26 份標「已送達」,雲端實際只有 4 份。)
|
||||
- **太大的檔案現在會好好跟你說**:以前十幾萬字的大檔會一直送、一直失敗,而你看到的是一串沒人讀得懂的錯誤訊息,還會把每天的免費額度燒掉。現在它會先量一下,太大就不送,並且告訴你「這份檔約幾萬字、拆小一點就會自動收進來」。
|
||||
|
||||
## 0.18.38(2026-08-26)
|
||||
|
||||
- 🔴 **每一層子資料夾都看得到,不是只有第一層**:以前你只看得到自己選的那個資料夾本身,裡面到底有哪些子資料夾、哪幾層、哪些東西真的上去了,完全看不到。現在**桌面小幫手與知識庫網頁兩邊都攤得開**:小幫手裡點進那個知識庫,資料夾右邊多一顆「資料夾結構」,按下去就是一棵像檔案總管那樣、一列一列往下縮排的樹,點資料夾展開、再點收合,也可以一鍵全部展開/全部收合。
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"_algo": 4,
|
||||
"version": "0.18.38",
|
||||
"fingerprint": "7add5da65c671548",
|
||||
"version": "0.18.42",
|
||||
"fingerprint": "43e0c6ac08233f07",
|
||||
"files": {
|
||||
".gitignore": "4d56952b0fb13bf8f9b6c13a6d4c34a075bac3af447636a1df4335d7576e2f97",
|
||||
"CHANGELOG.md": "9dd02f82a4f5417520fb20642aec004aa25c241e404621aacf6f37e549602b2c",
|
||||
"CHANGELOG.md": "6cbc29aad162ab41f6db0ef6550bf6d5c44c01544598816d33b58ad585446528",
|
||||
"DAEMON_LINE": "d5019abbdc8a5f2919e9e3510391891cd7fbdf0765bf16ec83caa779f370116d",
|
||||
"README.md": "9d92cac236b20a0b183eea3e7f5e39ad492f05192c4ea602eb11c3d09967327f",
|
||||
"check-standalone.sh": "65fbce096326791c2f103a51e76d0ad79e9e510e2a7700b49ac2d2d81f73c853",
|
||||
@@ -121,22 +121,22 @@
|
||||
"convert_table.go": "5313b96e4d85d65e6c3c8b51e7c0e25aee11a47fdbda534fe8432fbea87836c5",
|
||||
"convert_table_test.go": "d0371b7566ef3152f9dd42f9f990e0dffa1c50a0c8e874a28415fa2e4c188394",
|
||||
"convert_test.go": "04f3fa30d1be5f910c0e0be3308ced2963191ab030a9eaccd986ef581fcd4e18",
|
||||
"convert_wiring_test.go": "09e97bf32ace245b55acc7d65ee5c0bb7fd84f8fb1aab61f603f9e9d18b22f13",
|
||||
"direct.go": "70ff75a04cdfff09453ff858d3e468e110057e555c402e5969221072fefe1a41",
|
||||
"convert_wiring_test.go": "3389817e596341369a3fc102846bf5b081cee668e86ebf4830eb633978de652b",
|
||||
"direct.go": "b4dc846cd62b0d57754afeb66658e35f2578afd6ca256e95ee8f72c0f03507b9",
|
||||
"direct_extract_test.go": "317d0057d2d368fabc406351a7477f72e074d2b8c8459b1dc021febdf16902f4",
|
||||
"direct_multi_test.go": "a76593fef26231e18b7402f6f7376624d2471f8a719b3ce08fc4a0f5a3d3cf1e",
|
||||
"direct_pacing.go": "2690b2fff858250028886bc3cd7793be54b4d4a03ddca41485476cb04b79f7c3",
|
||||
"direct_pacing_test.go": "ba5c140082931a50c3728078502803c654c2fb9dacecda679f8f40e6ab61a0c5",
|
||||
"direct_pacing.go": "b54838ce85f72ecd2d3388fa7cf89050c87fb10bd67dca07cd18d49568a8837c",
|
||||
"direct_pacing_test.go": "f118ec03ab0bf51871037aa51eb442b98074e218430d244cb7d54b1d9791d738",
|
||||
"direct_quota_test.go": "0d4747db434fd10f367ff88c8253263e2f2ce3559cd767de2f0b2c77bdf7a080",
|
||||
"direct_retire_test.go": "fbff728362313c546c1fc01cc74094672e22a8171d4bc0329ce1b3d118c82b3c",
|
||||
"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": "ea77c25d2a14d905e3c7f3a7caaa3dff2fa5ceb71c98c772a258ef0e770106f3",
|
||||
"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",
|
||||
@@ -150,8 +150,8 @@
|
||||
"grounding_test.go": "46f5fd98297caf45b5db494f69363f2544e1cfa5634ce24fe25ca6a87ae296d2",
|
||||
"ignorerules.go": "e6923d0fe35e377e75cfe2364a10624ca40aa6d28efe48ea725036769a48ea14",
|
||||
"ignorerules_test.go": "19675a03539e92e7fd58a46ce8105875cc5aaa42f53615348de775b6d90dbf16",
|
||||
"ingestplan.go": "0a0f170bb17db210231039e9c67f7f796a2eacd4450c54f5325d3483439d4ecb",
|
||||
"ingestplan_test.go": "1ad31f4fb69d917e3f9d46f5615f38074b7ed6ec8baa01d71bd1600d317b7878",
|
||||
"ingestplan.go": "0c78c96c3218b8ca3f15bceb3e99c087f055bbd4d2e6024fee6268e3efc46e94",
|
||||
"ingestplan_test.go": "baf57a5edc99482842c16d1ea28d1ad6afe7a236a56fc42dac8d007341b578be",
|
||||
"ingestplan_wiring_test.go": "4e5d25dace8a181418ed5857423ef1efeacdd98b7ff42b94a71c4e5fd7012e89",
|
||||
"inventory.go": "d0ba0be3e2af8df3cc6dffac5b75816b9c2a0a12f6d2eff1a786d8584ca77622",
|
||||
"inventory_test.go": "16e0f83a09d61dc4cded032fd493d30bfc747c38116b871fb02b2f0bf2b0216a",
|
||||
@@ -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",
|
||||
@@ -240,15 +243,18 @@
|
||||
"tidy_test.go": "80fd37d7abf9f9fd075006da42aea73a3843c49f969e62623687519102dfe3ea",
|
||||
"trigger.go": "f689f701bef08401f5d47f5b3d783d24a88fc9a9426fe41bdbbb3f9b152ab405",
|
||||
"trigger_test.go": "0c2482d18cea0568cd2a0f5eae5b02a0984b9b2bc83461eb882c630d6381ca02",
|
||||
"triggeroutcome.go": "25832326ecb3694d8a3a0f8a115ce8df6327b70ff61b0bec1357d57e53e81836",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,9 @@
|
||||
"0.18.35": "3bd7f3fb0124bda4",
|
||||
"0.18.36": "abb2f2cc2498f071",
|
||||
"0.18.37": "3bb5c94f68a26a06",
|
||||
"0.18.38": "7add5da65c671548"
|
||||
"0.18.38": "7add5da65c671548",
|
||||
"0.18.39": "316426f50e886003",
|
||||
"0.18.40": "a4485965c128ef9d",
|
||||
"0.18.41": "8033de0cdec3537b",
|
||||
"0.18.42": "43e0c6ac08233f07"
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ func TestWiring_docx轉檔發生在送LLM之前(t *testing.T) {
|
||||
writeDocx(t, filepath.Join(dir, rel), `<w:p><w:r><w:t>維修費用 350,000 元</w:t></w:r></w:p>`)
|
||||
|
||||
// 空 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), `<w:p><w:r><w:t> </w:t></w:r></w:p>`)
|
||||
|
||||
_, 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 不該成功")
|
||||
}
|
||||
|
||||
@@ -521,10 +521,25 @@ func (c *DirectConfig) postJSON(url string, body any) (int, string, error) {
|
||||
return 0, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
// 🔴 讀 64KB 而不是 1KB:觸發端點的回應是一層外殼包著工作流的輸出,
|
||||
// 而**失敗的證據住在殼裡面**(見 triggeroutcome.go)。1KB 會把 JSON 切斷 ⇒
|
||||
// 永遠解析不了 ⇒ 每一次失敗都被讀成「看不出來」⇒ 下面那道閘等於不存在。
|
||||
full, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||
snippet := full
|
||||
if len(snippet) > 1024 {
|
||||
snippet = snippet[:1024]
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return resp.StatusCode, string(snippet), fmt.Errorf("HTTP %d:%s", resp.StatusCode, strings.TrimSpace(string(snippet)))
|
||||
}
|
||||
// 🔴 2xx 只證明「請求送到了」,不證明「東西寫進知識庫了」。
|
||||
// named-webhook 觸發成功一律回 200,工作流內部失敗藏在 body 裡
|
||||
//(2026-08-26 實測 `InkStoneCo`:26 份蓋了「已送達」章,雲端只有 4 份)。
|
||||
// 判斷放在這裡而不是各呼叫端:**「忘了接」這個失敗模式不該存在**
|
||||
//(同 ingestplan.go 把兩張排除表收成一張的理由)。
|
||||
if msg := webhookFailure(string(full)); msg != "" {
|
||||
return resp.StatusCode, string(snippet), errors.New(msg)
|
||||
}
|
||||
return resp.StatusCode, string(snippet), nil
|
||||
}
|
||||
|
||||
@@ -1376,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 事件先觸發了下面的
|
||||
@@ -1488,13 +1523,31 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
// - 上限:巨量積壓(實據 27,164 檔)不該一輪湧完;超過上限的事件本輪不碰,
|
||||
// manifest 未標 ingested ⇒ 下一輪 Scan() 自然重新出現(且若使用者這期間
|
||||
// 寫了新檔,新檔的 mtime 更新,下一輪排序會插到最前面,不會被積壓卡住)。
|
||||
//
|
||||
// 🔴 arcrun-rag#104 comment 4480(t217,2026-08-27):cap 過去套在「排序後的原始
|
||||
// 清單」上,而不是套在「這輪真的會被嘗試」的事件上。退避中/已達重試上限的檔案
|
||||
// 不會因為正在退避就往後排(mtime 沒變、排序不變)⇒ 只要前 perRunCap 名一直是
|
||||
// 同一批持續失敗的檔案,它們就會**永久佔滿名額**,排在後面的事件不管跑幾輪都
|
||||
// 排不到——這正是 leo 實測到的「1691→1880→1936 筆從不減少」,佇列本身就是問題。
|
||||
// 修法:cap 改套在 partitionRetryEligible 分出來的 ready(會真的嘗試)事件上;
|
||||
// 退避中的事件(waiting)不佔嘗試名額,讓排在它們後面、真正還沒被嘗試過的事件
|
||||
// 有機會遞補上來。waiting 仍要展示(診斷用、不是安靜消失),但同樣設一個上限,
|
||||
// 避免巨量積壓(實據 KB 資料夾 ~1900 筆退避中)把單輪結果與 status.json 灌爆。
|
||||
orderedEvents := sortEventsNewestFirst(absRoot, payload.Events)
|
||||
perRunCap := cfg.effectiveMaxEventsPerRun()
|
||||
readyEvents, waitingEvents := partitionRetryEligible(m, orderedEvents, now, cfg.ForceSync, qs.inCooldown(runNow))
|
||||
|
||||
deferredCount := 0
|
||||
if len(orderedEvents) > perRunCap {
|
||||
deferredCount = len(orderedEvents) - perRunCap
|
||||
orderedEvents = orderedEvents[:perRunCap]
|
||||
if len(readyEvents) > perRunCap {
|
||||
deferredCount = len(readyEvents) - perRunCap
|
||||
readyEvents = readyEvents[:perRunCap]
|
||||
}
|
||||
visibleWaiting := waitingEvents
|
||||
if len(visibleWaiting) > perRunCap {
|
||||
deferredCount += len(visibleWaiting) - perRunCap
|
||||
visibleWaiting = visibleWaiting[:perRunCap]
|
||||
}
|
||||
orderedEvents = append(readyEvents, visibleWaiting...)
|
||||
|
||||
for _, ev := range orderedEvents {
|
||||
switch ev.Type {
|
||||
@@ -1545,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 {
|
||||
@@ -1557,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 有沒見過的值——誠實報錯,不要靜默跳過(禁假綠)。
|
||||
|
||||
@@ -107,3 +107,41 @@ func sortEventsNewestFirst(absRoot string, events []Event) []Event {
|
||||
func resumeAfterCapMessage(remaining int) string {
|
||||
return fmt.Sprintf("已排入佇列,下一輪會繼續處理(本輪上限已到,還有 %d 筆等待)", remaining)
|
||||
}
|
||||
|
||||
// partitionRetryEligible 把「已依 mtime 新到舊排序」的事件分成兩組:
|
||||
// - ready:這輪真的會被嘗試(會呼叫 pace()/打雲端)——沒在退避中、沒達重試上限、
|
||||
// 帳號沒在額度冷卻中。
|
||||
// - waiting:這輪不會被嘗試,只是單純交代原因——退避窗口未到、已達
|
||||
// MaxFailBeforeSkip、或整個帳號正在額度冷卻。
|
||||
//
|
||||
// removed 事件不受退避/額度冷卻管制(下架本來就不看 ShouldRetry,見 direct.go 的
|
||||
// case "removed"),一律歸 ready,維持既有行為不變。
|
||||
//
|
||||
// 🔴 為什麼要在 cap 之前先分這一刀(arcrun-rag#104 comment 4480,t217):
|
||||
// 舊版直接對排序後的原始清單套用 perRunCap(`orderedEvents[:perRunCap]`)。
|
||||
// mtime 不會因為一個檔正在退避就變新或變舊,排序因此是穩定的——只要前 perRunCap
|
||||
// 名裡有幾個持續失敗的檔案,它們會**每一輪都繼續佔著那幾個名額**(即使這一輪
|
||||
// 根本不會被嘗試,只是被跳過),排在它們後面、從沒被嘗試過的事件因此永遠排不到,
|
||||
// 不管跑幾百輪都一樣。這正是 leo 實測「1691→1880→1936 筆從不減少」的真因:
|
||||
// 不是處理得慢,是那些筆數的候補名單裡,有一大段從頭到尾沒拿到出場機會。
|
||||
//
|
||||
// 呼叫端該把 cap 套在這裡回傳的 ready 上,讓「退避中」的事件不佔嘗試名額,
|
||||
// 把機會讓給排在它們後面、真正還沒被嘗試過的事件。
|
||||
func partitionRetryEligible(m *Manifest, events []Event, now int64, force bool, coolingDown bool) (ready, waiting []Event) {
|
||||
for _, ev := range events {
|
||||
if ev.Type == "removed" {
|
||||
ready = append(ready, ev)
|
||||
continue
|
||||
}
|
||||
if coolingDown {
|
||||
waiting = append(waiting, ev)
|
||||
continue
|
||||
}
|
||||
if m.ShouldRetry(ev.Path, now, force) {
|
||||
ready = append(ready, ev)
|
||||
} else {
|
||||
waiting = append(waiting, ev)
|
||||
}
|
||||
}
|
||||
return ready, waiting
|
||||
}
|
||||
|
||||
@@ -201,6 +201,131 @@ func TestDirect_LargeBacklog_ProcessedInNewestFirstBatches(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4) 退避中的檔案不該永久佔滿單輪名額(arcrun-rag#104 comment 4480,t217)──
|
||||
//
|
||||
// 背景:leo 實測 leo21c 帳號的積壓「1691→1880→1936 筆從不減少」,
|
||||
// 「已經好久沒有加過任何檔案,哪來的這些筆數?那就是之前卡住的,就是你要解決的問題」
|
||||
// 「佇列就是問題本身」。
|
||||
//
|
||||
// 根因:舊版把 perRunCap 套在「排序後的原始清單」上。mtime 最新的幾個檔如果持續
|
||||
// 失敗(進入退避),它們不會因為在退避就往後排,於是每一輪都繼續佔著最前面的
|
||||
// perRunCap 個名額——即使這一輪根本不會被嘗試,只是被跳過。排在它們後面、
|
||||
// 從沒被嘗試過的健康檔案因此永遠排不到,不管跑幾輪都一樣。
|
||||
//
|
||||
// 這個測試重現該情境:3 個 mtime 最新的檔一直失敗(模擬持續性錯誤,例如票上量到的
|
||||
// 雲端 subrequest 上限或萃取回傳格式錯誤),5 個 mtime較舊、原本會成功的健康檔案
|
||||
// 排在它們後面。單輪上限=3。
|
||||
//
|
||||
// - 第一輪:全部檔案都還沒失敗過(FailCount=0),cap 選中 mtime 最新的 3 個
|
||||
// (也就是那 3 個會一直失敗的檔),全部失敗,記下退避(下次重試在 60 秒後)。
|
||||
// - 第二輪(緊接著呼叫,真實時間遠不到 60 秒):那 3 個檔仍在退避中。
|
||||
// 舊版行為:cap 依然套在原始排序上,選中的還是同一批退避中的檔案 ⇒
|
||||
// 這一輪 0 個健康檔案被嘗試,健康檔案永遠排不到。
|
||||
// 修好後的行為:退避中的檔案被分流到 waiting、不佔 ready 的名額,
|
||||
// cap 改套用在 ready 上 ⇒ 健康檔案的前 3 名遞補上來,這一輪就會被嘗試並成功。
|
||||
func TestDirect_StarvedBacklog_HealthyFilesEventuallyGetATurn(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
// 3 個「一直失敗」的檔,mtime 最新(若 bug 還在,會永久佔滿 cap)。
|
||||
blockers := []string{"blocker-a", "blocker-b", "blocker-c"}
|
||||
for i, n := range blockers {
|
||||
writeFile(t, root, n+".md", "持續失敗的內容 "+n, baseTime.Add(time.Duration(10+i)*time.Minute))
|
||||
}
|
||||
// 5 個「健康」的檔,mtime 較舊(排在後面,理應遞補上來)。
|
||||
healthy := []string{"h5", "h4", "h3", "h2", "h1"}
|
||||
for i, n := range healthy {
|
||||
writeFile(t, root, n+".md", "健康內容 "+n, baseTime.Add(time.Duration(4-i)*time.Minute))
|
||||
}
|
||||
|
||||
failingPages := map[string]bool{"blocker-a": true, "blocker-b": true, "blocker-c": true}
|
||||
var mu sync.Mutex
|
||||
var succeededPages []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if answeredFolderTreePost(w, r) {
|
||||
return
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(body, &m)
|
||||
pageName, _ := m["page_name"].(string)
|
||||
if strings.HasPrefix(pageName, "資料夾總覽") {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||||
return
|
||||
}
|
||||
if failingPages[pageName] {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"success":false,"error":"boom"}`))
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
succeededPages = append(succeededPages, pageName)
|
||||
mu.Unlock()
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||||
}))
|
||||
defer srv.Close()
|
||||
defer gemmaStub(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"candidates": []map[string]any{{
|
||||
"content": map[string]any{"parts": []map[string]any{{"text": cardFixture("卡", "測試")}}},
|
||||
}},
|
||||
})
|
||||
})()
|
||||
|
||||
cfg := &DirectConfig{
|
||||
WatchFolders: []string{root},
|
||||
Manifest: filepath.Join(t.TempDir(), "m.json"),
|
||||
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
|
||||
Library: "kb", Extractor: "gemma", ExtractorExplicit: true, GeminiAPIKey: "k-test",
|
||||
CardIngestWF: "rag_ingest_card", MaxRemoved: DefaultMaxRemovedRatio,
|
||||
MaxEventsPerRun: 3,
|
||||
}
|
||||
|
||||
// 第一輪:8 個檔都還沒失敗過,cap 選中 mtime 最新的 3 個(blocker-a/b/c),全部失敗。
|
||||
results1, _, _ := RunDirectOnce(cfg, false)
|
||||
if got := ingestedPaths(results1); len(got) != 0 {
|
||||
t.Fatalf("第一輪不該有任何成功(cap 選中的 3 個全會失敗),got %v", got)
|
||||
}
|
||||
var failedCount int
|
||||
for _, r := range results1 {
|
||||
if r.Status == "failed" {
|
||||
failedCount++
|
||||
}
|
||||
}
|
||||
if failedCount != 3 {
|
||||
t.Fatalf("第一輪應該有 3 筆真的被嘗試且失敗(blocker-a/b/c),got %d:%+v", failedCount, results1)
|
||||
}
|
||||
|
||||
// 第二輪:緊接著呼叫(真實時間遠不到 60 秒退避窗口)。blocker-a/b/c 仍在退避中。
|
||||
results2, _, _ := RunDirectOnce(cfg, false)
|
||||
ingested2 := ingestedPaths(results2)
|
||||
if len(ingested2) != 3 {
|
||||
t.Fatalf("🔴 第二輪應該有 3 個健康檔案遞補上來被嘗試並成功——"+
|
||||
"如果這裡是 0,代表退避中的 blocker-a/b/c 又佔滿了本輪名額,"+
|
||||
"健康檔案永遠排不到(這正是 leo 實測「佇列從不減少」的那個 bug)。got %d:%+v",
|
||||
len(ingested2), results2)
|
||||
}
|
||||
wantSecond := []string{"h5.md", "h4.md", "h3.md"}
|
||||
for i, w := range wantSecond {
|
||||
if ingested2[i] != w {
|
||||
t.Fatalf("第二輪順序=%v,want %v(健康檔案仍照 mtime 新到舊遞補)", ingested2, wantSecond)
|
||||
}
|
||||
}
|
||||
// blocker-a/b/c 這一輪不該再被真的嘗試(還在退避中)——它們只會以「skipped」出現。
|
||||
for _, r := range results2 {
|
||||
if r.Path == "blocker-a.md" || r.Path == "blocker-b.md" || r.Path == "blocker-c.md" {
|
||||
if r.Status != "skipped" {
|
||||
t.Fatalf("退避中的 %s 這一輪不該被真的嘗試,got status=%s", r.Path, r.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(succeededPages) != 3 {
|
||||
t.Fatalf("雲端應該收到 3 筆健康卡片,got %d: %v", len(succeededPages), succeededPages)
|
||||
}
|
||||
}
|
||||
|
||||
func ingestedPaths(results []DirectResult) []string {
|
||||
var out []string
|
||||
for _, r := range results {
|
||||
|
||||
+2
-2
@@ -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
|
||||
}
|
||||
|
||||
@@ -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("目標位置被別人佔用時應報錯,不得無聲覆蓋")
|
||||
}
|
||||
|
||||
+48
-2
@@ -51,10 +51,50 @@ import (
|
||||
// (選型實測 llama-4-scout 約 2.4s,但長文+冷啟動要留餘裕。)
|
||||
var workersAIHTTP = &http.Client{Timeout: 90 * time.Second}
|
||||
|
||||
// maxWorkersAIExtractBytes=送進雲端萃取的純文字上限(位元組)。
|
||||
//
|
||||
// 🔴 為什麼要有這道閘(2026-08-26 實測 `InkStoneCo`,非推測):
|
||||
// 這條路沒有任何長度判斷——整份原稿原封不動塞進 prompt。leo 的
|
||||
// `system-dev/wiki/mistakes.md`(562 KB)、`status.md`(401 KB)、
|
||||
// `status-archive-2026-08.md`(511 KB)因此每一輪都撞同一面牆,
|
||||
// 而使用者看到的是這串**沒有人讀得懂的東西**:
|
||||
//
|
||||
// 本地萃取失敗:雲端萃取失敗(HTTP 502):Workers AI 執行失敗:8007:
|
||||
// {"error":{"message":"This model's maximum context length is 131000 tokens…
|
||||
//
|
||||
// ⇒ 兩個錯:①明知一定會失敗還是送出去(每次燒一份額度、拖住整個佇列)
|
||||
// ②失敗的理由沒有翻成人話(#104 的紅線:不要讓他猜)。
|
||||
//
|
||||
// 300,000 這個數字怎麼來的:實測那份 401,526 位元組的檔,上游回報
|
||||
// 「prompt contains at least 122,xxx tokens」,而可用輸入是
|
||||
// 131,000 − 8,192(輸出) ≈ 122,800 ⇒ 中文原稿約 3.3 位元組/token。
|
||||
// 300 KB ≈ 9 萬 token,留了三成餘裕給提示詞本身與英數混排的變異。
|
||||
//
|
||||
// ⚠️ 這個上限只綁**這條路**(雲端 llama-4-scout 的 131k 視窗)。
|
||||
// gemma 路走 Gemini、視窗大一個數量級,不受此限——判準跟著模型走,
|
||||
// 不做成全域常數,免得換模型時有人以為它是產品規格。
|
||||
const maxWorkersAIExtractBytes = 300_000
|
||||
|
||||
// tooBigForWorkersAI 回傳「這份原稿太大,這條路讀不完」的人話理由;
|
||||
// 沒超過回空字串。
|
||||
//
|
||||
// 🔴 訊息是產品文案不是 debug 字串:要講**多大**、**為什麼不收**、**他能做什麼**,
|
||||
// 而且不准出現狀態碼、模型名或 token 這種只有工程師看得懂的詞。
|
||||
func tooBigForWorkersAI(srcText, relPath string) string {
|
||||
if len(srcText) <= maxWorkersAIExtractBytes {
|
||||
return ""
|
||||
}
|
||||
// 中文一個字約 3 位元組——換算成「字數」才是使用者對得上的單位。
|
||||
wan := len(srcText) / 3 / 10000
|
||||
return fmt.Sprintf(
|
||||
"這份檔太大了(約 %d 萬字),雲端的整理模型一次讀不完,所以這次沒有收它。"+
|
||||
"把它拆成幾份小一點的檔就會自動收進來。", wan)
|
||||
}
|
||||
|
||||
// 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)")
|
||||
}
|
||||
@@ -74,6 +114,12 @@ func ExtractWithWorkersAI(cypherURL, apiKey, absRoot, relPath string) ([]string,
|
||||
return nil, fmt.Errorf("轉檔失敗(%s):%w", relPath, err)
|
||||
}
|
||||
|
||||
// 🔴 明知送出去一定會失敗,就不要送(見 maxWorkersAIExtractBytes)。
|
||||
// 早一步擋下=不燒額度、不占佇列,而且使用者看到的是人話不是上游錯誤碼。
|
||||
if why := tooBigForWorkersAI(srcText, relPath); why != "" {
|
||||
return nil, fmt.Errorf("%s", why)
|
||||
}
|
||||
|
||||
pageName := pageNameOf(relPath)
|
||||
// #134:prompt=與 gemma 路同一份契約(同 package 同函式,物理上不可能漂移)。
|
||||
// page_name/text 仍照送:舊雲端不認得 prompt,會拿它們組 legacy 提示詞回舊卡。
|
||||
@@ -137,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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
+117
-21
@@ -62,6 +62,9 @@ type IngestPlan struct {
|
||||
// WikiRelDir=現成 wiki 的相對路徑(僅 IngestCuratedWiki)。
|
||||
WikiRelDir string `json:"wiki_rel_dir,omitempty"`
|
||||
// DocRelDirs=要收的文件目錄(僅 IngestDocsOnly;根層 .md 另由 keepsRootDoc 放行)。
|
||||
// 來源有兩種,合併後去重排序(見 mergeDocDirs):固定三名字(existingDocDirs)
|
||||
// + Phase 0 內容判準自動找到的非標準命名文件目錄(scanAuxDirs 的 autoDocDirs,
|
||||
// `arcrun-rag#136`)。
|
||||
DocRelDirs []string `json:"doc_rel_dirs,omitempty"`
|
||||
// Reason=一句話講給使用者聽的「為什麼只收這些」。
|
||||
Reason string `json:"reason"`
|
||||
@@ -237,7 +240,7 @@ func PlanIngest(absRoot string) IngestPlan {
|
||||
}
|
||||
|
||||
evidence := shape.Evidence()
|
||||
others := otherWikiDirs(absRoot)
|
||||
aux := scanAuxDirs(absRoot)
|
||||
if wiki := findCuratedWiki(absRoot); wiki != "" {
|
||||
return IngestPlan{
|
||||
Mode: IngestCuratedWiki,
|
||||
@@ -245,12 +248,12 @@ func PlanIngest(absRoot string) IngestPlan {
|
||||
WikiRelDir: wiki,
|
||||
Reason: "這是一個開發專案(" + evidence + "),而且你已經整理好一份知識庫(" + wiki + ")——" +
|
||||
"我直接讀那一份就好,不再把整個專案的原始碼與零散檔案重萃一次。",
|
||||
OtherWikiDirs: others,
|
||||
OtherWikiDirs: aux.otherWikiDirs,
|
||||
ignore: ignore,
|
||||
}
|
||||
}
|
||||
|
||||
docs := existingDocDirs(absRoot)
|
||||
docs := mergeDocDirs(existingDocDirs(absRoot), aux.autoDocDirs)
|
||||
reason := "這是一個開發專案(" + evidence + "),我只讀文件、不讀程式碼。"
|
||||
if len(docs) > 0 {
|
||||
reason = "這是一個開發專案(" + evidence + "),我只讀文件(" +
|
||||
@@ -261,11 +264,33 @@ func PlanIngest(absRoot string) IngestPlan {
|
||||
Shape: shape,
|
||||
DocRelDirs: docs,
|
||||
Reason: reason,
|
||||
OtherWikiDirs: others,
|
||||
OtherWikiDirs: aux.otherWikiDirs,
|
||||
ignore: ignore,
|
||||
}
|
||||
}
|
||||
|
||||
// mergeDocDirs 合併「固定三名字」與「內容判準自動找到的」兩份文件目錄清單,去重排序。
|
||||
// 兩份清單本來就可能重疊(例如根層真的叫 `docs` 又剛好零程式碼)——去重才不會讓
|
||||
// Reason 文案讀起來像「docs、docs」。
|
||||
func mergeDocDirs(fixed, auto []string) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, d := range fixed {
|
||||
if !seen[d] {
|
||||
seen[d] = true
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
for _, d := range auto {
|
||||
if !seen[d] {
|
||||
seen[d] = true
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// findCuratedWiki 回傳第一個「存在且真的有內容」的現成 wiki 目錄(相對路徑),沒有回空字串。
|
||||
//
|
||||
// 「有內容」=底下至少有一個 `.md`。空目錄不算——不然一個剛跑完 template 安裝、
|
||||
@@ -315,24 +340,38 @@ func existingDocDirs(absRoot string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// otherWikiDirs 找出監看根底下**其他**地方的 wiki(子專案自己的知識庫)。
|
||||
// auxDirScan=一次全樹走訪同時算出來的兩種結果(合併走訪,見 scanAuxDirs)。
|
||||
type auxDirScan struct {
|
||||
otherWikiDirs []string // 子專案自己的 wiki(找到但刻意不收,只講出來)
|
||||
autoDocDirs []string // Phase 0:內容判準找到的「非標準命名文件目錄」(要收)
|
||||
}
|
||||
|
||||
// scanAuxDirs 走訪一次監看根,同時算出「其他子專案的 wiki 在哪」與
|
||||
// 「Phase 0:非標準命名但整棵零程式碼的文件目錄」(`inkstone/arcrun-rag#136`
|
||||
// leo 2026-08-24 第三次追加,優先度高於樹狀 UI)。
|
||||
//
|
||||
// 🔴 為什麼找出來卻不收:leo 的 `InkStoneCo` 底下有 `products/*`、`matrix/*` 這些
|
||||
// **各自獨立的 repo**,每一個都有自己的 `system-dev/wiki`。它們是**別的專案**的知識,
|
||||
// 混進這個資料夾的知識庫裡,AR-Mira 搜一個主題就會回一堆分不清屬於誰的東西。
|
||||
// 要收哪一個,是使用者的決定——把那個子專案自己加進看守清單即可。
|
||||
// 🔴 兩者原本是兩支各自獨立的函式(otherWikiDirs/新的內容判準),這裡合併成一次
|
||||
// 走訪——理由是研究文件(`docs-only-skip-visibility-override-research.md` §2.5)
|
||||
// 明講的經濟性:兩者都要「走一次監看根、套用同一組排除規則、在沒被排除的節點上
|
||||
// 做判斷」,差別只在判斷內容。合併之後每輪同步只走一次樹,不是兩次。
|
||||
//
|
||||
// 但**一定要講出來**:使用者接了一個 monorepo 卻只看到 32 張卡,不告訴他其餘的在哪,
|
||||
// 他只會覺得東西不見了(票上的紅線:不要讓他猜)。
|
||||
// 🔴 合併還解決了一個正確性問題(不是效能問題):如果分開各自跑一次 WalkDir,
|
||||
// 一個子專案自己的 `xxx/wiki/` 會被 otherWikiDirs 判定「找到但刻意不收」,
|
||||
// 但新的內容判準若獨立走訪,會用「零程式碼+有文件」的邏輯把同一個 `wiki` 目錄
|
||||
// **又收了一次**,直接推翻 otherWikiDirs 那條「這是別人的知識,不混進來」的
|
||||
// 既有設計(該函式原本的說明就在解釋為什麼刻意不收)。同一次走訪、同一個節點只判
|
||||
// 一次,兩件事天生不會互相打架。
|
||||
//
|
||||
// 走訪時套用與 Scan 相同的跳過規則(隱藏目錄、noise、linked worktree、
|
||||
// 已知的 curated 位置自己),所以出貨用 worktree 與 templatefs 的那十幾份不會列進來。
|
||||
func otherWikiDirs(absRoot string) []string {
|
||||
seen := map[string]bool{}
|
||||
// 🔴 判準只在**通過既有排除規則、沒被任何一條攔下**的節點上跑(隱藏目錄、
|
||||
// `toolOwnedDirNames`、`ambiguousBuildDirNames`+`looksGenerated`、linked worktree)
|
||||
// ——與 `SkipsDirWhy` 的優先序一致,所以 `node_modules` 底下的任何內容
|
||||
// **根本沒有機會被走到**,不會重新引入 `#104` 的套件洩漏洞。
|
||||
func scanAuxDirs(absRoot string) auxDirScan {
|
||||
seenWiki := map[string]bool{}
|
||||
for _, rel := range curatedWikiCandidates {
|
||||
seen[rel] = true
|
||||
seenWiki[rel] = true
|
||||
}
|
||||
var out []string
|
||||
var result auxDirScan
|
||||
_ = filepath.WalkDir(absRoot, func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil || !d.IsDir() || p == absRoot {
|
||||
return nil
|
||||
@@ -351,14 +390,71 @@ func otherWikiDirs(absRoot string) []string {
|
||||
return nil
|
||||
}
|
||||
relSlash := filepath.ToSlash(rel)
|
||||
if name == "wiki" && !seen[relSlash] && dirHasMarkdown(p) {
|
||||
out = append(out, relSlash)
|
||||
|
||||
// ① 別人的 wiki——找到就講出來,但不收、不繼續往下走訪(其餘同舊行為)。
|
||||
if name == "wiki" && !seenWiki[relSlash] && dirHasMarkdown(p) {
|
||||
result.otherWikiDirs = append(result.otherWikiDirs, relSlash)
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
// ② Phase 0:這個目錄「自己直接放的檔案」零程式碼、且至少一個文件類副檔名
|
||||
// ⇒ 即使名字不叫 docs,也當文件目錄收。
|
||||
//
|
||||
// 🔴 只看「這個目錄自己直接放的檔案」,不遞迴檢查整棵子樹——
|
||||
// 研究文件原本建議整棵子樹零程式碼才算,但真實案例
|
||||
// (`pms_v1_legacy` 巢狀 `pms-backup/pms_db_backup.sql`)證明那樣會讓
|
||||
// `pms_v1_legacy` 自己直接放的 `PMS_USER_STORIES.md`/`PMS_GAP_ANALYSIS.md`
|
||||
// 因為巢狀兩層深的一個 .sql 備份檔而整批繼續被跳過——治標的判準沒解決
|
||||
// 票上真正的案例。改成逐層各自判斷之後,`pms_v1_legacy` 用自己的直接內容
|
||||
// 合格,`pms-backup`(自己直接放著 .sql)不合格但不影響外層,而巢狀更深的
|
||||
// `pms-backup/.wiki`(自己直接內容零程式碼+有 .md)又重新合格——
|
||||
// 這與 Scan/畫面本來就「每個節點只算自己直接放的檔案」(`total_files` 等
|
||||
// 欄位的既有語意,見 `collector/foldertree.go`)一致,不是另立一套算法。
|
||||
//
|
||||
// 合格就整棵收(`SkipsDirWhy`/`KeepsFile` 的 `onPathTo` 前綴比對本來就會
|
||||
// 涵蓋子孫),不必再往下走訪找子孫裡還有沒有另一個合格點。
|
||||
// 不合格則繼續遞迴——巢狀更深處仍可能有獨立合格的文件子目錄。
|
||||
if !seenWiki[relSlash] && dirQualifiesAsAutoDoc(p) {
|
||||
result.autoDocDirs = append(result.autoDocDirs, relSlash)
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
})
|
||||
sort.Strings(out)
|
||||
return out
|
||||
sort.Strings(result.otherWikiDirs)
|
||||
sort.Strings(result.autoDocDirs)
|
||||
return result
|
||||
}
|
||||
|
||||
// dirQualifiesAsAutoDoc 回答「這個目錄自己直接放的檔案,算不算文件目錄」——
|
||||
// Phase 0 的核心判準(`inkstone/arcrun-rag#136`):零程式碼副檔名+至少一個
|
||||
// 文件類副檔名。**只看直接放在這個目錄裡的檔案**,不遞迴看子目錄(見呼叫端說明)。
|
||||
//
|
||||
// 🔴 「零程式碼」而非「程式碼佔比低於門檻」:leo 08-24 第三次留言明講不要用比例——
|
||||
// 一個真正的程式碼目錄(`scripts/` 這種文件寫得多的)不該因為比例低就被誤判成文件夾。
|
||||
// 副檔名表沿用既有的(`codeFileExts`/`allowedExt`/`docLikeExt`),不重新發明一套。
|
||||
func dirQualifiesAsAutoDoc(absDir string) bool {
|
||||
entries, err := os.ReadDir(absDir)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
hasDoc := false
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if strings.HasPrefix(name, ".") {
|
||||
continue
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
if codeFileExts[ext] {
|
||||
return false // 一個程式碼副檔名就整個不合格——零門檻,見上方說明
|
||||
}
|
||||
if allowedExt[ext] || docLikeExt[ext] {
|
||||
hasDoc = true
|
||||
}
|
||||
}
|
||||
return hasDoc
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
+165
-6
@@ -229,6 +229,15 @@ func TestPlanIngest_ExclusionsAreVisible(t *testing.T) {
|
||||
}
|
||||
|
||||
// 沒有現成 wiki 的 repo:退到「只讀文件、不讀程式碼」(leo 明講的第三步)。
|
||||
//
|
||||
// 🔴 2026-08-27 Phase 0(`arcrun-rag#136`,leo 08-24 第三次追加)之後行為分兩種:
|
||||
// - `src/說明.md`:`src/` 這個目錄自己直接放的檔案裡就有 `.go`(程式碼)——
|
||||
// 零程式碼這一條不成立,繼續當程式碼目錄整棵跳過,不收。
|
||||
// - `internal/notes.md`:`internal/` 這個目錄自己直接放的檔案裡**只有這份 .md**、
|
||||
// 沒有任何程式碼副檔名——即使名字不叫 docs,Phase 0 判準也會把它當文件目錄收。
|
||||
// 這不是誤放的迴歸,是這次要的效果:leo 08-24「如果要加上很多(手動救回),
|
||||
// 那就更覺得很煩,就會棄用」——非標準命名但整棵零程式碼的資料夾預設就該收,
|
||||
// 不必等使用者自己救。
|
||||
func TestPlanIngest_RepoWithoutWikiReadsDocsOnly(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
files := codeProjectFiles("", ".go", "package main")
|
||||
@@ -236,8 +245,8 @@ func TestPlanIngest_RepoWithoutWikiReadsDocsOnly(t *testing.T) {
|
||||
"README.md": "# 專案",
|
||||
"docs/請假規則.md": "# 特休 14 天",
|
||||
"docs/報銷政策.md": "# 每日 3000 元",
|
||||
"src/說明.md": "# 散在程式碼旁邊",
|
||||
"internal/notes.md": "# 也是程式碼旁邊",
|
||||
"src/說明.md": "# 散在程式碼旁邊——但 src/ 自己直接放的檔案裡就有 .go,不合格",
|
||||
"internal/notes.md": "# 自己單獨一個資料夾,直接內容零程式碼——Phase 0 判準下合格",
|
||||
} {
|
||||
files[rel] = body
|
||||
}
|
||||
@@ -250,9 +259,11 @@ func TestPlanIngest_RepoWithoutWikiReadsDocsOnly(t *testing.T) {
|
||||
if plan.Mode != IngestDocsOnly {
|
||||
t.Fatalf("策略=%s,want %s", plan.Mode, IngestDocsOnly)
|
||||
}
|
||||
want := []string{"README.md", "docs/報銷政策.md", "docs/請假規則.md"}
|
||||
want := []string{"README.md", "docs/報銷政策.md", "docs/請假規則.md", "internal/notes.md"}
|
||||
sort.Strings(want)
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("送了 %v,want %v(程式碼旁邊的 .md 不該收)", got, want)
|
||||
t.Fatalf("送了 %v,want %v(`src/` 因為自己直接放著 .go 仍不收;`internal/` "+
|
||||
"自己直接內容零程式碼,Phase 0 判準下該收)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -571,7 +582,11 @@ func TestPlanIngest_三種收法在新判準下都還正確(t *testing.T) {
|
||||
wantGot: []string{"wiki/INDEX.md", "wiki/status.md"},
|
||||
},
|
||||
{
|
||||
// ③ 軟體專案、沒有現成 wiki ⇒ 只收文件區+根層說明檔,跳過原始碼
|
||||
// ③ 軟體專案、沒有現成 wiki ⇒ 只收文件區+根層說明檔+跳過原始碼,
|
||||
// 但 Phase 0(`arcrun-rag#136`)之後也收「非標準命名、自己直接內容零程式碼」
|
||||
// 的資料夾——`雜/` 自己只放了一份 `隨手記.md`,沒有任何程式碼副檔名,
|
||||
// 即使名字不叫 docs 也算文件目錄。這不是誤放的迴歸,見同檔
|
||||
// TestPlanIngest_RepoWithoutWikiReadsDocsOnly 上方的完整說明。
|
||||
name: "沒wiki的專案只收文件跳過源碼",
|
||||
files: func() map[string]string {
|
||||
f := merge(codeProjectFiles("", ".go", "package main"))
|
||||
@@ -580,7 +595,7 @@ func TestPlanIngest_三種收法在新判準下都還正確(t *testing.T) {
|
||||
return f
|
||||
}(),
|
||||
wantMode: IngestDocsOnly,
|
||||
wantGot: []string{"README.md", "docs/請假規則.md"},
|
||||
wantGot: []string{"README.md", "docs/請假規則.md", "雜/隨手記.md"},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -671,6 +686,150 @@ func TestPlanIngest_判成專案時要講得出憑什麼(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 🔴 Phase 0(`arcrun-rag#136`,leo 2026-08-24 第三次追加)——正面驗收:
|
||||
// 票上真正的案例(`pms_v1_legacy/pms-backup`):非標準命名、整棵沒有一套程式碼、
|
||||
// 但巢狀更深處混了一份資料庫備份檔——這正是研究文件原本「整棵子樹零程式碼」的
|
||||
// 設計會漏掉的那個真實形狀(見 `dirQualifiesAsAutoDoc` 的說明)。
|
||||
//
|
||||
// fixture 照 leo 08-24 給的 Finder 截圖形狀造:
|
||||
//
|
||||
// pms_v1_legacy/
|
||||
// PMS_USER_STORIES.md ← 自己直接放的文件(零程式碼);一旦這裡合格,
|
||||
// 整個 pms_v1_legacy 都被收(onPathTo 前綴涵蓋子孫)
|
||||
// pms-backup/
|
||||
// PMS_ASSESSMENT.md ← 自己直接放的文件
|
||||
// pms_db_backup.sql ← 巢狀更深處的「程式碼副檔名」(codeFileExts 收 .sql)
|
||||
//
|
||||
// 🔴 `pms-backup` 底下混了 .sql 這件事,**不影響** `pms_v1_legacy` 本身的合格判斷
|
||||
// (逐層判準只看每一層自己的直接內容),所以 `pms-backup/PMS_ASSESSMENT.md`
|
||||
// 也跟著被收——它是「已合格的 pms_v1_legacy」底下的子孫,不必再單獨判一次
|
||||
// (與 `docs/` 目錄底下不管有沒有子資料夾都整棵收,是同一條既有語意)。
|
||||
func TestPlanIngest_Phase0非標準命名文件目錄零程式碼即收(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
files := codeProjectFiles("", ".go", "package main")
|
||||
files["README.md"] = "# 專案"
|
||||
files["pms_v1_legacy/PMS_USER_STORIES.md"] = "# 使用者故事"
|
||||
files["pms_v1_legacy/pms-backup/PMS_ASSESSMENT.md"] = "# 評估報告"
|
||||
files["pms_v1_legacy/pms-backup/pms_db_backup.sql"] = "-- 假資料,測試只在意副檔名不在意內容\n"
|
||||
writeFixture(t, root, files)
|
||||
|
||||
payload, plan := scanWithPlan(t, root)
|
||||
got := eventPaths(payload)
|
||||
t.Logf("策略=%s(%s)", plan.Mode, plan.Reason)
|
||||
t.Logf("送出:%v", got)
|
||||
for _, d := range payload.ExcludedDirs {
|
||||
t.Logf("跳過 %s — %s", d.Path, d.Reason)
|
||||
}
|
||||
|
||||
if plan.Mode != IngestDocsOnly {
|
||||
t.Fatalf("策略=%s,want %s", plan.Mode, IngestDocsOnly)
|
||||
}
|
||||
want := []string{
|
||||
"README.md",
|
||||
"pms_v1_legacy/PMS_USER_STORIES.md", // 自己直接放的檔案零程式碼,合格
|
||||
"pms_v1_legacy/pms-backup/PMS_ASSESSMENT.md", // 已合格祖先底下的子孫,一併收
|
||||
}
|
||||
sort.Strings(want)
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("送出 %v,want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 🔴 Phase 0——正面驗收(獨立合格):`pms-backup` 若是「自己單獨掛上去的看守根」
|
||||
// (不在任何已合格祖先底下),逐層判準要能單獨判它自己——它自己直接混了 .sql,
|
||||
// 不合格,但巢狀更深處若有一個自己零程式碼的子目錄,仍要被獨立找到。
|
||||
// 用 `archive/`(非標準命名、非隱藏)取代真實案例裡的 `.wiki`——後者是
|
||||
// daemon 自己產生的隱藏快取目錄,本來就會被 Scan 的隱藏目錄規則整個擋下,
|
||||
// 不是這裡要驗的「內容判準」這件事。
|
||||
func TestPlanIngest_Phase0巢狀更深處的獨立合格目錄也找得到(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
files := codeProjectFiles("", ".go", "package main")
|
||||
files["pms_v1_legacy/pms-backup/PMS_ASSESSMENT.md"] = "# 評估報告"
|
||||
files["pms_v1_legacy/pms-backup/pms_db_backup.sql"] = "-- 假資料,測試只在意副檔名不在意內容\n"
|
||||
files["pms_v1_legacy/pms-backup/archive/OLD_NOTES.md"] = "# 更早的筆記"
|
||||
writeFixture(t, root, files)
|
||||
|
||||
payload, _ := scanWithPlan(t, root)
|
||||
got := eventPaths(payload)
|
||||
t.Logf("送出:%v", got)
|
||||
want := "pms_v1_legacy/pms-backup/archive/OLD_NOTES.md"
|
||||
found := false
|
||||
for _, p := range got {
|
||||
if p == want {
|
||||
found = true
|
||||
}
|
||||
if strings.HasPrefix(p, "pms_v1_legacy/pms-backup/") && p != want {
|
||||
t.Fatalf("`pms-backup` 自己混了 .sql 不該被收,卻收了 %s", p)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("巢狀更深處的獨立合格目錄沒被找到:%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 🔴 Phase 0——反面驗收①:`pms-backup` 自己直接放著 `.sql`(程式碼副檔名),
|
||||
// 所以它自己不合格,同層的 `PMS_ASSESSMENT.md` 目前**仍然不會被收**——
|
||||
// 這是逐層判準(而非整棵子樹判準)刻意接受的邊界:一個目錄自己混了程式碼副檔名,
|
||||
// 就當它自己是「開發用的」,不因為隔壁躺著一份文件就整個放行。使用者若真的要救
|
||||
// 這一份,仍然有第 6 節的樹狀 UI/手動覆寫(尚未實作,見票上 Phase 2)這條路。
|
||||
// 本測試把這個邊界寫清楚,不讓它在下一輪被誤改成「連 pms-backup 自己也收」。
|
||||
func TestPlanIngest_Phase0巢狀混雜程式碼的目錄自己仍不收(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
files := codeProjectFiles("", ".go", "package main")
|
||||
files["pms_v1_legacy/pms-backup/PMS_ASSESSMENT.md"] = "# 評估報告"
|
||||
files["pms_v1_legacy/pms-backup/pms_db_backup.sql"] = "-- 假資料,測試只在意副檔名不在意內容\n"
|
||||
writeFixture(t, root, files)
|
||||
|
||||
payload, _ := scanWithPlan(t, root)
|
||||
got := eventPaths(payload)
|
||||
for _, p := range got {
|
||||
if strings.Contains(p, "pms-backup/") {
|
||||
t.Fatalf("`pms-backup` 自己直接放著 .sql(程式碼副檔名),不該被 Phase 0 判準收進去:%v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🔴 Phase 0——反面驗收②:不准重新引入 `#104` 的套件洩漏洞。
|
||||
// `node_modules` 底下即使巢狀著一個「整棵零程式碼」的文件目錄,也不能被收——
|
||||
// `toolOwnedDirNames` 的優先序排在 Phase 0 判準之前,整棵 `SkipDir`,
|
||||
// 新判準根本沒有機會看到 `node_modules` 底下的任何內容。
|
||||
func TestPlanIngest_Phase0不重新引入套件洩漏洞(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
files := codeProjectFiles("", ".go", "package main")
|
||||
files["node_modules/some-pkg/docs-backup/README.md"] = "# 別人的套件文件"
|
||||
files["node_modules/some-pkg/docs-backup/GUIDE.md"] = "# 別人的套件文件"
|
||||
writeFixture(t, root, files)
|
||||
|
||||
payload, _ := scanWithPlan(t, root)
|
||||
got := eventPaths(payload)
|
||||
for _, p := range got {
|
||||
if strings.Contains(p, "node_modules/") {
|
||||
t.Fatalf("Phase 0 判準洩漏了 node_modules 底下的內容(重新打開 #104 那個洞):%v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🔴 Phase 0——反面驗收③:真正的程式碼目錄不受影響,仍走原本的 docs-only 通用跳過。
|
||||
// `workers/pms-auth` 自己直接放著 `.go`(與 `package.json`),零程式碼那一條不成立,
|
||||
// 整棵照舊被跳過——不因為新判準而多送出任何一份程式碼旁邊的檔案。
|
||||
func TestPlanIngest_Phase0真正的程式碼目錄不受影響(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
files := codeProjectFiles("", ".go", "package main")
|
||||
files["workers/pms-auth/package.json"] = `{"name":"pms-auth"}`
|
||||
files["workers/pms-auth/main.go"] = "package main"
|
||||
files["workers/pms-auth/README.md"] = "# 這個服務怎麼跑"
|
||||
writeFixture(t, root, files)
|
||||
|
||||
payload, plan := scanWithPlan(t, root)
|
||||
got := eventPaths(payload)
|
||||
t.Logf("策略=%s|送出:%v", plan.Mode, got)
|
||||
for _, p := range got {
|
||||
if strings.Contains(p, "workers/pms-auth/") {
|
||||
t.Fatalf("`workers/pms-auth` 自己直接放著 .go,不該被 Phase 0 判準收進去:%v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🔴 接線:**模式選擇的判準只有一個地方**。
|
||||
//
|
||||
// 這一票的同款形狀出現過四次(能力做好了,卻不在會被執行的那條路上)。
|
||||
|
||||
@@ -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 ⑩:
|
||||
|
||||
+213
@@ -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
|
||||
}
|
||||
@@ -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=<namespace> \
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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("新形卡被重寫了")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// triggeroutcome.go — 「雲端收下了」不等於「知識庫寫進去了」(`inkstone/arcrun-rag#104`)。
|
||||
//
|
||||
// 🔴 為什麼有這支檔(2026-08-26 實測,非推測):
|
||||
// 把 leo 真實的 `InkStoneCo` 接上 youlin 跑一輪,daemon 對 26 份檔案蓋了「已送達」章,
|
||||
// **雲端實際只有 4 份**。使用者畫面上是綠的,AR-Mira 一句都查不到。
|
||||
//
|
||||
// 真兇不是萃取、不是排除規則,是這一行:`postJSON` 只看 HTTP 狀態碼。
|
||||
// named-webhook 觸發成功一律回 **200**,而工作流內部有沒有把東西寫進 KBDB
|
||||
// 藏在 body 裡。當天的實際回應(原文照抄):
|
||||
//
|
||||
// HTTP 200
|
||||
// {"success":true,"data":{"success":false,"status":500,
|
||||
// "error":"{\"success\":false,\"error\":\"unreachable\"}"},"duration_ms":2476}
|
||||
//
|
||||
// ⇒ 外層說 success,內層說 500/unreachable。daemon 讀外層 ⇒ 蓋章 ⇒
|
||||
// content_hash 沒變就永遠不會重送 ⇒ **這份知識永久消失,而且沒有人會知道**。
|
||||
//
|
||||
// 這與 `Arcrun#135`(push_workflow 對跑不起來的定義回「部署成功!」)是同一個病:
|
||||
// **回報層與事實層分居兩處,而讀的人只讀得到回報層。**
|
||||
// 我們改不了別人回什麼,但可以改「我們信什麼」——2xx 只證明請求送達,
|
||||
// 要證明寫進去了,得看 body。
|
||||
//
|
||||
// 三條自我約束:
|
||||
// - **只在看得懂的時候才判失敗**:body 不是 JSON、或沒有任何 success 欄位
|
||||
// ⇒ 回「看不出來」=照舊當成功。寧可漏判,不可把一次格式變更變成全面停擺。
|
||||
// - **不猜語意**:只認 `success:false` 這個明確訊號,不去猜 data 裡別的欄位。
|
||||
// - **講人話**:訊息會出現在使用者畫面上,不裸露狀態碼與上游 JSON 原文
|
||||
// (同 direct_quota_test.go 那份禁字表)。
|
||||
package collector
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// triggerEnvelope=named-webhook 觸發端點的回應外殼。
|
||||
// `Data` 用 json.RawMessage:它可能是物件、陣列、字串,甚至 null——
|
||||
// 硬綁成 map 會在形狀一變時整條路 panic 或誤判。
|
||||
type triggerEnvelope struct {
|
||||
Success *bool `json:"success"`
|
||||
Error string `json:"error"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
// triggerInner=工作流最後一個節點的輸出裡,我們唯一認得的兩個欄位。
|
||||
type triggerInner struct {
|
||||
Success *bool `json:"success"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// webhookFailure 檢查「HTTP 2xx 的觸發回應裡,工作流是不是其實失敗了」。
|
||||
//
|
||||
// 回空字串=沒看出失敗(真的成功,或這個回應我們看不懂——兩者都放行)。
|
||||
// 回非空=**確定失敗**,字串是給使用者看的那句話。
|
||||
func webhookFailure(body string) string {
|
||||
trimmed := strings.TrimSpace(body)
|
||||
if trimmed == "" || (!strings.HasPrefix(trimmed, "{") && !strings.HasPrefix(trimmed, "[")) {
|
||||
return "" // 不是 JSON ⇒ 看不出來 ⇒ 放行
|
||||
}
|
||||
var env triggerEnvelope
|
||||
if err := json.Unmarshal([]byte(trimmed), &env); err != nil {
|
||||
return "" // 解析不了(含被截斷)⇒ 看不出來 ⇒ 放行
|
||||
}
|
||||
// ① 外層自己就說失敗
|
||||
if env.Success != nil && !*env.Success {
|
||||
return ingestFailureSentence(env.Error)
|
||||
}
|
||||
// ② 外層說成功,但工作流的輸出說失敗——本檔存在的理由就是這一格
|
||||
if len(env.Data) > 0 {
|
||||
var inner triggerInner
|
||||
if err := json.Unmarshal(env.Data, &inner); err == nil && inner.Success != nil && !*inner.Success {
|
||||
return ingestFailureSentence(inner.Error)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ingestFailureSentence 把上游那串技術文字換成一句使用者讀得懂的話。
|
||||
//
|
||||
// 🔴 不是 debug 訊息,是產品文案:使用者看到「已整理 26 份」卻查不到東西的當下,
|
||||
// 唯一能讓他知道發生什麼事的就是這句(#104 的紅線:不要讓他猜)。
|
||||
// 認不出來的原因不編故事,只誠實說「雲端沒有寫進去」。
|
||||
func ingestFailureSentence(raw string) string {
|
||||
const head = "雲端收下了,但你的知識庫沒有真的寫進去(這一份還查不到)"
|
||||
switch {
|
||||
case strings.Contains(raw, "unreachable"):
|
||||
return head + ":連不到知識庫的資料層。稍後會自動再試。"
|
||||
case strings.Contains(raw, "card_content 為空"):
|
||||
return head + ":這份檔萃出來是空的。"
|
||||
case strings.Contains(raw, "credential"):
|
||||
return head + ":知識庫的內部金鑰不對,要重裝一次雲端才會通。"
|
||||
default:
|
||||
return head + ",稍後會自動再試。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package collector
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// realFailingEnvelope 是 2026-08-26 從 youlin 實例抄回來的**原文**:
|
||||
// HTTP 200、外層 success=true,而工作流其實一個字都沒寫進 KBDB。
|
||||
// 這一份不是虛構的樣本,是這一票的物證——改動這裡等於改動證據。
|
||||
const realFailingEnvelope = `{"success":true,"data":{"success":false,"status":500,` +
|
||||
`"error":"{\"success\":false,\"error\":\"unreachable\"}"},"duration_ms":2476}`
|
||||
|
||||
func TestWebhookFailure_實測那份回應必須被判成失敗(t *testing.T) {
|
||||
msg := webhookFailure(realFailingEnvelope)
|
||||
if msg == "" {
|
||||
t.Fatal("外層 success=true、內層 success=false ⇒ 必須判失敗,否則就是 2026-08-26 那個假綠")
|
||||
}
|
||||
if !strings.Contains(msg, "沒有真的寫進去") {
|
||||
t.Errorf("訊息要講出「沒寫進去」這件事:%q", msg)
|
||||
}
|
||||
// 使用者看得到這句話 ⇒ 不准出現狀態碼與上游 JSON 原文(同 direct_quota_test.go 的禁字表)
|
||||
for _, bad := range []string{"500", "HTTP", "unreachable", "{"} {
|
||||
if strings.Contains(msg, bad) {
|
||||
t.Errorf("訊息裸露了技術細節 %q:%q", bad, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookFailure_只在看得懂的時候才判失敗(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
fail bool
|
||||
}{
|
||||
{"一切正常", `{"success":true,"data":{"success":true,"ok":1},"duration_ms":9}`, false},
|
||||
{"外層就說失敗", `{"success":false,"error":"workflow not found"}`, true},
|
||||
{"內層說失敗", realFailingEnvelope, true},
|
||||
{"data 沒有 success 欄位=看不出來", `{"success":true,"data":{"written":3}}`, false},
|
||||
{"data 是陣列", `{"success":true,"data":[1,2,3]}`, false},
|
||||
{"data 是字串", `{"success":true,"data":"done"}`, false},
|
||||
{"data 是 null", `{"success":true,"data":null}`, false},
|
||||
{"根本不是 JSON", `OK`, false},
|
||||
{"空回應", ``, false},
|
||||
{"被截斷的 JSON", `{"success":true,"data":{"success":false,"err`, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := webhookFailure(c.body) != ""
|
||||
if got != c.fail {
|
||||
t.Errorf("%s:判失敗=%v,預期 %v(body=%s)", c.name, got, c.fail, c.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🔴 這支是本票的核心迴歸閘:**「已送達」這個章,只能在東西真的寫進去時才准蓋。**
|
||||
// 沒有它,同一個假綠會以任何一種新的包裝再回來一次
|
||||
//(2026-08-26 實錄:26 份蓋章、雲端 4 份,而畫面全綠)。
|
||||
func TestWiring_雲端說沒寫進去就不准蓋已送達的章(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "報銷規則.md"), []byte("# 報銷規則\n\n內容"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var triggers int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/portal/daemon/folder-tree") {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||||
return
|
||||
}
|
||||
_, _ = io.ReadAll(r.Body)
|
||||
triggers++
|
||||
// 就是實例當天回的那一份:200 + 外層綠、內層紅
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, realFailingEnvelope)
|
||||
}))
|
||||
defer srv.Close()
|
||||
defer gemmaCardStub(t, cardFixture("報銷規則", "財務"))()
|
||||
|
||||
manifestPath := filepath.Join(t.TempDir(), "m.json")
|
||||
cfg := &DirectConfig{
|
||||
WatchFolders: []string{root},
|
||||
Manifest: manifestPath,
|
||||
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
|
||||
Library: "kb", Extractor: "gemma", ExtractorExplicit: true, GeminiAPIKey: "k-test",
|
||||
CardIngestWF: "rag_ingest_card", MaxRemoved: DefaultMaxRemovedRatio,
|
||||
}
|
||||
results, exit, _ := RunDirectOnce(cfg, false)
|
||||
if triggers == 0 {
|
||||
t.Fatal("測試沒打到觸發端點,這支測試等於沒驗到東西")
|
||||
}
|
||||
if exit == 0 {
|
||||
t.Errorf("一份都沒真的寫進去,exit 不該是 0:%+v", results)
|
||||
}
|
||||
_, fileResults := splitInventory(results)
|
||||
if len(fileResults) != 1 || fileResults[0].Status != "failed" {
|
||||
t.Fatalf("那份檔應該是 failed,不是「已送達」:%+v", fileResults)
|
||||
}
|
||||
if !strings.Contains(fileResults[0].Error, "沒有真的寫進去") {
|
||||
t.Errorf("失敗理由要講人話:%q", fileResults[0].Error)
|
||||
}
|
||||
// 最關鍵的一格:manifest 不准留下「已送達」的章——留了,content_hash 沒變就永遠不會重送。
|
||||
m, err := LoadManifest(manifestPath, root)
|
||||
if err != nil {
|
||||
t.Fatalf("讀 manifest:%v", err)
|
||||
}
|
||||
for rel, e := range m.Entries {
|
||||
if e.IngestedHash != "" && e.IngestedHash == e.ContentHash {
|
||||
t.Errorf("%s 蓋了「已送達」章,但雲端根本沒收到 ⇒ 這份知識會永久消失", rel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 太大的檔:明知送出去一定失敗,就不要送——而且理由要是人話。
|
||||
func TestTooBigForWorkersAI_講人話且不送出去(t *testing.T) {
|
||||
if why := tooBigForWorkersAI(strings.Repeat("a", 1000), "小檔.md"); why != "" {
|
||||
t.Errorf("一般大小的檔不該被擋:%q", why)
|
||||
}
|
||||
big := strings.Repeat("字", maxWorkersAIExtractBytes) // 中文一字 3 bytes ⇒ 一定超過
|
||||
why := tooBigForWorkersAI(big, "mistakes.md")
|
||||
if why == "" {
|
||||
t.Fatal("超過上限的檔要被擋下")
|
||||
}
|
||||
if !strings.Contains(why, "萬字") || !strings.Contains(why, "拆成") {
|
||||
t.Errorf("要講出多大、以及使用者能做什麼:%q", why)
|
||||
}
|
||||
for _, bad := range []string{"token", "131000", "HTTP", "8007", "llama"} {
|
||||
if strings.Contains(why, bad) {
|
||||
t.Errorf("訊息裸露技術細節 %q:%q", bad, why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractWithWorkersAI_太大的檔一個請求都不送(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
big := strings.Repeat("字", maxWorkersAIExtractBytes)
|
||||
if err := os.WriteFile(filepath.Join(root, "巨檔.md"), []byte("# 巨檔\n\n"+big), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var hits int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits++
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "output": "{}"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := ExtractWithWorkersAI(srv.URL, "k", root, "巨檔.md", testOrigin())
|
||||
if err == nil {
|
||||
t.Fatal("太大的檔應該直接失敗")
|
||||
}
|
||||
if hits != 0 {
|
||||
t.Errorf("擋下的檔不該還打雲端一次(燒額度+佔佇列),實際打了 %d 次", hits)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "太大") {
|
||||
t.Errorf("理由要講人話:%v", err)
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
|
||||
+89
-10
@@ -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 自己的目錄結構(卡在 `<node>/.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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
-14
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user