From 12c2d41886d1b654b36f1c68c0907356c30fa8dd Mon Sep 17 00:00:00 2001 From: richblack Date: Sat, 15 Aug 2026 21:03:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(collector):=20daemon=20=E7=94=A2=E5=87=BA?= =?UTF-8?q?=E6=94=B9=E6=88=90=E8=A6=8F=E7=AF=84=E5=BD=A2=20wiki=20?= =?UTF-8?q?=E5=8D=A1=E2=80=94=E2=80=941=20=E6=96=87=E4=BB=B6=E5=8D=A1?= =?UTF-8?q?=EF=BC=8BN=20=E5=8E=9F=E5=AD=90=E5=8D=A1=E8=90=BD=20.wiki/?= =?UTF-8?q?=EF=BC=88InkStoneCo#44=20=E2=91=A3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LLM 只回 JSON 判斷,格式/落點/連結閉合/索引/manifest 全由 wikishape.go 機械組裝: - 卡形=frontmatter(tags/gloss/created/updated)+← 上層+摘要/重點/實體(帶類型)/ 關聯(內文知識關係/卡片關係/出處)——差距表 #6 - 落點 <節點>/.wiki/、檔名=H1(.wiki 隱藏目錄自身即機器標記,machinemark 例外③)——#7 - 00-INDEX 機械維護:每行五樣照抄 frontmatter;每份原稿必列——#8 - 萃取端就切 1 hub+N 原子卡(gemma 路 prompt 改 JSON 契約)——#9 - 無可萃概念標「空」+理由,上索引不產卡——#10 機械保證對著 wiki-lint.py 寫:斷連結拆殼、index 式句改寫、三元組恰三項、 雙向邊自動補反向、佔用不覆蓋、原稿永不動;同名概念跨文件先消歧(merge 歸第⑤環)。 scan/convert 白名單補 .feature/.yaml/.yml/.org/.rst(規範洞 6); lint.go 新舊格式雙軌(workers-ai 雲端 prompt 屬 matrix/arcrun 核心,待開票); direct.go 只送文件卡上雲(takedown 配對鍵不變)、下架收走 .wiki 產物。 實測:真資料夾+真 Gemini → 2 節點 12 卡,wiki-lint 17/17 exit 0, 原稿 sha256 前後不變;go test collector+supervisor+arcrun-app 全綠(新增 9 案)。 Co-Authored-By: Claude Opus 5 --- convert.go | 5 +- direct.go | 19 +- direct_extract_test.go | 56 +-- extract_gemma.go | 69 +++- extract_gemma_test.go | 70 ++-- lint.go | 94 +++++ machinemark.go | 12 +- progress_wiring_test.go | 2 +- scan.go | 8 + vault_footprint_test.go | 61 ++- vault_subdir_test.go | 18 +- wikishape.go | 875 ++++++++++++++++++++++++++++++++++++++++ wikishape_test.go | 287 +++++++++++++ 13 files changed, 1442 insertions(+), 134 deletions(-) create mode 100644 wikishape.go create mode 100644 wikishape_test.go diff --git a/convert.go b/convert.go index 41b0464..930e126 100644 --- a/convert.go +++ b/convert.go @@ -33,7 +33,7 @@ import ( var ErrNoText = fmt.Errorf("檔案裡沒有可抽取的文字") // ErrUnsupported:副檔名不在支援清單內。與 ErrNoText 分開,因為給用戶的說法不同 -//(「這種檔案我還不會讀」vs「這個檔看起來是掃描的圖」)。 +// (「這種檔案我還不會讀」vs「這個檔看起來是掃描的圖」)。 var ErrUnsupported = fmt.Errorf("尚未支援的檔案格式") // extractor 吃檔案位元組,吐純文字。 @@ -70,7 +70,8 @@ var preNormExtractors = map[string]extractor{ // IsPlainText 回報這個副檔名是否本來就是純文字(不需要轉檔)。 func IsPlainText(path string) bool { switch strings.ToLower(filepath.Ext(path)) { - case ".md", ".markdown", ".txt": + case ".md", ".markdown", ".txt", ".feature", ".yaml", ".yml", ".org", ".rst": + // 後五個:規範洞 6 白名單(2026-08-15)——都是純文字知識檔,原樣通過。 return true } return false diff --git a/direct.go b/direct.go index ef45279..6ddd0a8 100644 --- a/direct.go +++ b/direct.go @@ -1169,7 +1169,13 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta continue } ok := true - for _, cardRel := range cards { + // InkStoneCo#44 ④(2026-08-15):gemma 路現在一份文件產「文件卡+N 張 + // 概念卡」(cards[0]=文件卡)。雲端 rag_ingest_card 以 page_name upsert、 + // 下架以原稿頁名比對 ⇒ N 張卡都送會互相蓋寫同一頁。 + // ⇒ 本環先只送文件卡(雲端行為與改版前一致);原子卡與三元組上雲的 + // 形狀是第⑤環(Arcrun#129/#130)的題目,屆時在這裡展開。 + // cards 為空=該檔被判「無可萃取概念」(00-INDEX 已標「空」),不送雲端。 + for cardIdx, cardRel := range cards { cardData, cerr := os.ReadFile(filepath.Join(absRoot, filepath.FromSlash(cardRel))) if cerr != nil { res.Status, res.Error = "failed", "讀卡片失敗:"+cerr.Error() @@ -1195,6 +1201,9 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta // pageNameOf(ev.Path)(原稿頁名,不帶前綴)⇒ 兩邊從此對不上, // 「刪原檔→下架」永遠 0 命中,跟 07-24 那枚 source_uri 的坑同一個形狀。 // 改成原稿頁名後,**雲端看到的頁名與改版前完全相同**(本次只動本機檔名)。 + if cardIdx > 0 { + continue // 概念卡先只落本機 .wiki(品質檢查照跑),上雲等第⑤環 + } cardBody := map[string]any{ "page_name": pageNameOf(ev.Path), "path": ev.Path, @@ -1297,6 +1306,14 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta }) } } + // InkStoneCo#44 ④:新制 `.wiki/` 的卡(文件卡+概念卡)+索引+manifest + // 一起收走——鍵同樣是原稿路徑,與上面的舊制清理並存(過渡期兩制都可能有卡)。 + if werr := RemoveWikiDoc(absRoot, ev.Path); werr != nil { + results = append(results, DirectResult{ + Type: "warning", Path: ev.Path, Status: "skipped", + Error: "wiki 卡收走失敗(不擋下架):" + werr.Error(), + }) + } } } results = append(results, res) diff --git a/direct_extract_test.go b/direct_extract_test.go index 45ea77b..7b4af02 100644 --- a/direct_extract_test.go +++ b/direct_extract_test.go @@ -12,30 +12,26 @@ import ( "testing" ) -// cardFixture 組出一張 B2 合格四段卡(H1 段名齊全且順序正確+H2 一句話定義恰一行+ -// H3 3–12 條要點+H4 3–8 條端點閉合三元組——2026-08-09 併入品質 lint 後,minimal -// 兩段卡會被 H1 硬擋,這裡改成全段合格卡,讓所有沿用 cardFixture 的既有測試自動過關)。 -// 三元組的分隔符在原始碼裡用組字串的方式產生,避免被 arcrun 意圖 guard 誤判成工作流的邊。 +// cardFixture 組出一份合格的萃取 JSON(InkStoneCo#44 ④:gemmaPrompt 的契約改為 +// 「文件總覽+N 個原子概念」的 JSON,卡片格式由 wikishape.go 機械組裝)。 +// 概念名固定為「·概念」——避免與各測試原稿的 H1/頁名撞名。 func cardFixture(subject, object string) string { - sep := strings.Repeat(">", 2) - return "# " + subject + "\n" + - "## 一句話定義\n" + subject + "是一張測試用的自包含知識卡。\n" + - "## 要點\n" + - "- 第一個要點含具體條件\n" + - "- 第二個要點含具體條件\n" + - "- 第三個要點含具體條件\n" + - "## 關鍵實體\n" + - "- **" + subject + "** — 測試主體\n" + - "- **" + object + "** — 測試客體\n" + - "## 關聯\n" + - "- " + subject + " " + sep + " 屬於 " + sep + " " + object + "\n" + - "- " + subject + " " + sep + " 關聯到 " + sep + " " + object + "\n" + - "- " + object + " " + sep + " 對應 " + sep + " " + subject + "\n" + concept := subject + "·概念" + return `{"gloss":"` + subject + `的測試用一句話","tags":["測試"],` + + `"summary":"這是測試用的文件摘要,交代 ` + subject + ` 與 ` + object + ` 的關係。",` + + `"points":["本文的核心判斷落在 [[` + concept + `]] 上,其餘是背景"],` + + `"no_concept":false,"reason":"",` + + `"concepts":[{"name":"` + concept + `","gloss":"一句話說明這個概念",` + + `"tags":["測試"],"summary":"概念層的摘要,說明它離開原稿也能獨立成立。",` + + `"points":["第一個判斷句含具體條件"],` + + `"entities":[{"name":"` + subject + `","type":"概念","desc":"測試主體"},` + + `{"name":"` + object + `","type":"組織","desc":"測試客體"}],` + + `"facts":[["` + subject + `","屬於","` + object + `"]],` + + `"relations":[]}]}` } -// gemmaCardStub 讓 Gemini 替身回傳一張以 pageName 命名的卡片(t176 起產品只走 gemma 路, +// gemmaCardStub 讓 Gemini 替身回傳一份萃取 JSON(t176 起產品只走 gemma 路, // 測試也跟著走真實路徑——不再用 claude stub,否則測的是產品走不到的分支=假綠)。 -// 卡片內容照 gemmaPrompt 的契約:含「## 一句話定義」與「## 關聯」。 func gemmaCardStub(t *testing.T, cardBody string) func() { t.Helper() return gemmaStub(t, func(w http.ResponseWriter, r *http.Request) { @@ -51,7 +47,8 @@ func gemmaCardStub(t *testing.T, cardBody string) func() { // → 原文從未離開本機 → manifest 標 ingested(下一輪不重送)。 func TestDirectExtractorModeE2E(t *testing.T) { root := t.TempDir() - if err := os.WriteFile(filepath.Join(root, "報銷規則.md"), []byte("# 原稿機密內容 XYZZY"), 0o644); err != nil { + // H1=卡名(規範洞 1);機密哨兵放內文,驗「原文不出機」看的是內容不是標題。 + if err := os.WriteFile(filepath.Join(root, "報銷規則.md"), []byte("# 報銷規則\n\n機密內容 XYZZY"), 0o644); err != nil { t.Fatal(err) } @@ -92,9 +89,11 @@ func TestDirectExtractorModeE2E(t *testing.T) { if len(fileResults) != 1 || fileResults[0].Status != "ingested" { t.Fatalf("results=%+v", fileResults) } - // 卡片落地本地(用戶看得到自己的 wiki) - if _, err := os.Stat(filepath.Join(root, "system-dev", "wiki", "cards", "arcrun-報銷規則.md")); err != nil { - t.Fatalf("卡片未落地:%v", err) + // 卡片落地本地 `.wiki/`(InkStoneCo#44 ④:檔名=H1、文件卡+概念卡+索引齊備) + for _, rel := range []string{".wiki/報銷規則.md", ".wiki/報銷規則·概念.md", ".wiki/00-INDEX.md", ".wiki/manifest.json"} { + if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(rel))); err != nil { + t.Fatalf("wiki 產物未落地 %s:%v", rel, err) + } } // 上雲的是「總覽卡+內容卡」兩張,都不含原文 if len(posted) != 2 { @@ -115,8 +114,8 @@ func TestDirectExtractorModeE2E(t *testing.T) { t.Fatal("找不到內容卡") } cc, _ := contentCard["card_content"].(string) - if !strings.Contains(cc, "## 一句話定義") { - t.Fatalf("card_content 不是卡片:%.80s", cc) + if !strings.Contains(cc, "## 摘要") || !strings.Contains(cc, "gloss:") { + t.Fatalf("card_content 不是規範形卡片:%.80s", cc) } // path 必須是「原檔路徑」(takedown 比對鍵+B4 溯源)——不是卡片路徑(07-24 第五枚坑) if p, _ := contentCard["path"].(string); p != "報銷規則.md" { @@ -173,7 +172,7 @@ func TestDirectExtractorRemovedClearsLocalCard(t *testing.T) { if _, exit, _ := RunDirectOnce(cfg, false); exit != 0 { t.Fatalf("第一輪 ingest 失敗 exit=%d", exit) } - cardPath := filepath.Join(root, "system-dev", "wiki", "cards", "arcrun-報銷規則.md") + cardPath := filepath.Join(root, ".wiki", "原稿.md") // 原稿內容「# 原稿」⇒ H1=卡名 if _, err := os.Stat(cardPath); err != nil { t.Fatalf("前置失敗:卡片未落地 %v", err) } @@ -225,7 +224,8 @@ func TestDirectExtractorRemovedNoLocalCardOK(t *testing.T) { t.Fatal("第一輪失敗") } // 模擬用戶已手動清走本地卡 → removed 分支「存在才刪」不應報錯或多出 warning - if err := os.Remove(filepath.Join(root, "system-dev", "wiki", "cards", "arcrun-a.md")); err != nil { + // (原稿內容 "x" 無 H1 ⇒ 文件卡名 fallback=檔名 "a") + if err := os.Remove(filepath.Join(root, ".wiki", "a.md")); err != nil { t.Fatal(err) } if err := os.Remove(filepath.Join(root, "a.md")); err != nil { diff --git a/extract_gemma.go b/extract_gemma.go index 3e8c67b..15825d8 100644 --- a/extract_gemma.go +++ b/extract_gemma.go @@ -27,11 +27,49 @@ const defaultLLMModel = "gemma-4-31b-it" var gemmaHTTP = &http.Client{Timeout: 120 * time.Second} -// gemmaPrompt 組出與 rag_extract_one 同款的萃卡指令。 +// gemmaPrompt 組出「文件卡+N 張原子概念卡」的結構化萃取指令(InkStoneCo#44 ④)。 +// +// 🔴 分工:模型只回 JSON(判斷),格式與落點全由 wikishape.go 機械組裝—— +// 模型不寫 markdown、不決定檔名、不碰路徑。Luhmann ②(一卡一概念)在**萃取端**做, +// 否則 place_card 只會一直回 orphan(規範待裁 6 的裁定理由)。 func gemmaPrompt(pageName, content string) string { - return "把以下原稿重寫成定稿知識卡(正體中文)。直接輸出卡片本身:第一行必須是「# " + pageName + - "」,不要任何前言、思考過程、英文草稿或說明。格式:\n# " + pageName + - "\n## 一句話定義\n(一行)\n## 要點\n- (3-12 條,具體、含數字條件)\n## 關鍵實體\n- **實體名** — 一句說明\n## 關聯\n- 實體A >> 謂詞 >> 實體B(3-8 行,用上面實體名)\n\n原稿:\n" + content + return `你是知識整理員。讀完原稿後,把它整理成「一份文件的總覽+N 個原子概念」。只輸出一個 JSON 物件,不要任何說明、markdown 圍欄或思考過程。 + +規則(違反任何一條都算失敗): +- 卡片內容是你的**判斷與重組**(正體中文),禁止整句照抄原稿。 +- 概念數由內容決定(多數文件 1-5 個);每個概念要能**離開原稿獨立成立**。 +- 報價單、發票、純待辦、純流水帳=沒有可萃取概念:回 {"no_concept":true,"reason":"一句話理由"} 即可。 +- gloss=一句話(40 字內)。summary=一小段(80-200 字)。points=3-8 條判斷句(不是條列複述)。 +- 文件層的 points 每條要把相關概念名用 [[概念名]] 嵌在**句子中間**(不可放句首當標題)。 +- entities:每個實體帶 type(人物/組織/工具/概念/地點/事件/檔案 擇一)與一句描述。 +- facts=[主詞,述詞,受詞] 三元組,端點盡量用 entities 的名字;任何欄位不得含雙箭頭符號。 +- relations=概念之間的關係(to 填另一個概念的 name)。 + +JSON 形狀(照這個結構填): +{"gloss":"","tags":[""],"summary":"","points":["…句子中間嵌 [[概念名]]…"], + "no_concept":false,"reason":"", + "concepts":[{"name":"","gloss":"","tags":[""],"summary":"","points":[""], + "entities":[{"name":"","type":"","desc":""}], + "facts":[["","",""]], + "relations":[{"to":"","pred":""}]}]} + +原稿(檔名:` + pageName + `): +` + content +} + +// parseWikiExtractJSON 從模型輸出撈出 JSON 並解析成 DocExtract。 +// thinking 模型可能在 JSON 前後夾雜文字/圍欄:取第一個 '{' 到最後一個 '}'。 +func parseWikiExtractJSON(text string) (*DocExtract, error) { + start := strings.Index(text, "{") + end := strings.LastIndex(text, "}") + if start < 0 || end <= start { + return nil, fmt.Errorf("模型輸出裡找不到 JSON 物件:%.120s", text) + } + var ex DocExtract + if err := json.Unmarshal([]byte(text[start:end+1]), &ex); err != nil { + return nil, fmt.Errorf("萃取 JSON 解析失敗:%w(%.120s)", err, text[start:end+1]) + } + return &ex, nil } // cleanGemmaCard 淨化思考型模型輸出:取最後一個「# 」起的內容(前面全是草稿)。 @@ -120,17 +158,18 @@ func ExtractWithGemma(apiKey, model, absRoot, relPath string) ([]string, error) return nil, fmt.Errorf("Gemini 回應沒有可用文字(thought-only 或空回應)") } - card := cleanGemmaCard(text, pageName) - if !strings.HasPrefix(card, "# ") { - return nil, fmt.Errorf("萃出內容不像卡片(未以 # 開頭):%.120s", card) + // InkStoneCo#44 ④(2026-08-15):產出改走塑形層——模型回 JSON(判斷), + // wikishape.go 機械組裝出規範形的 `.wiki/` 卡(文件卡+原子概念卡+索引+manifest)。 + // 舊的「單檔一卡落 cards/」由此淘汰(差距表 #6–#10 的現狀); + // #60 的兩條保護換了形式仍在:落點是隱藏目錄(不進筆記軟體)、既有檔一律不覆蓋。 + ex, perr := parseWikiExtractJSON(text) + if perr != nil { + return nil, perr } - // arcrun-rag#60:路徑與檔名都由 cardRelFor 決定——非 vault 落 system-dev/wiki/cards/、 - // vault 落隱藏目錄,且**檔名一律帶 arcrun- 前綴**(第二輪:光換目錄擋不住撞名, - // 因為 Logseq 的頁名是 basename)。落地前先查目標存不存在、不無條件覆蓋(safeWriteCard)。 - cardRel := cardRelFor(absRoot, pageName) - dest := filepath.Join(absRoot, filepath.FromSlash(cardRel)) - if err := safeWriteCard(absRoot, dest, []byte(card)); err != nil { - return nil, err + cards, berr := BuildWikiDoc(absRoot, relPath, srcText, ex, time.Now()) + if berr != nil { + return nil, berr } - return []string{cardRel}, nil + // 沒有可萃概念=合法結果:不產卡,但 00-INDEX 已列「空」(差距 #10)。 + return cards, nil } diff --git a/extract_gemma_test.go b/extract_gemma_test.go index a827b7a..5767967 100644 --- a/extract_gemma_test.go +++ b/extract_gemma_test.go @@ -19,10 +19,10 @@ func gemmaStub(t *testing.T, handler http.HandlerFunc) func() { return func() { gemmaBaseURL = old; srv.Close() } } -// happy path:思考型回應(parts[0]=thought)→ 取最後非 thought part、去草稿、落卡。 +// happy path:思考型回應(parts[0]=thought)→ 取最後非 thought part、撈 JSON、落規範形卡。 func TestExtractWithGemmaThinkingModel(t *testing.T) { root := t.TempDir() - if err := os.WriteFile(filepath.Join(root, "會議記錄.md"), []byte("# 原稿"), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(root, "會議記錄.md"), []byte("# 會議記錄\n\n內文"), 0o644); err != nil { t.Fatal(err) } defer gemmaStub(t, func(w http.ResponseWriter, r *http.Request) { @@ -32,14 +32,14 @@ func TestExtractWithGemmaThinkingModel(t *testing.T) { var req map[string]any _ = json.NewDecoder(r.Body).Decode(&req) b, _ := json.Marshal(req) - if !strings.Contains(string(b), "# 會議記錄") { + if !strings.Contains(string(b), "檔名:會議記錄") { t.Errorf("prompt 未帶頁名") } _ = json.NewEncoder(w).Encode(map[string]any{ "candidates": []map[string]any{{ "content": map[string]any{"parts": []map[string]any{ {"thought": true, "text": "let me think..."}, - {"text": "草稿雜訊\n# 會議記錄\n## 一句話定義\n測試卡\n"}, + {"text": "草稿雜訊\n" + cardFixture("會議記錄", "專案")}, }}, }}, }) @@ -48,16 +48,22 @@ func TestExtractWithGemmaThinkingModel(t *testing.T) { if err != nil { t.Fatal(err) } - if len(cards) != 1 || cards[0] != "system-dev/wiki/cards/arcrun-會議記錄.md" { + // 文件卡(H1=會議記錄)+一張概念卡,全部落 `.wiki/` + if len(cards) != 2 || cards[0] != ".wiki/會議記錄.md" || cards[1] != ".wiki/會議記錄·概念.md" { t.Fatalf("cards=%v", cards) } - data, _ := os.ReadFile(filepath.Join(root, "system-dev", "wiki", "cards", "arcrun-會議記錄.md")) - if !strings.HasPrefix(string(data), "# 會議記錄") { - t.Fatalf("卡片未淨化(應從最後的 # 頁名 起):%.80s", string(data)) + data, _ := os.ReadFile(filepath.Join(root, ".wiki", "會議記錄.md")) + if !strings.HasPrefix(string(data), "---\n") || !strings.Contains(string(data), "gloss: 會議記錄的測試用一句話") { + t.Fatalf("卡片不是規範形(frontmatter+gloss):%.120s", string(data)) } if strings.Contains(string(data), "草稿雜訊") { t.Fatal("思考草稿洩入卡片") } + for _, sec := range []string{"← [[00-INDEX]]", "## 摘要", "## 重點", "## 實體", "## 關聯", "### 出處"} { + if !strings.Contains(string(data), sec) { + t.Fatalf("文件卡缺「%s」:\n%s", sec, data) + } + } } // thought-only 回應=誠實報錯。 @@ -110,7 +116,7 @@ func TestExtractWithGemma_VaultDoesNotGainPages(t *testing.T) { _ = json.NewEncoder(w).Encode(map[string]any{ "candidates": []map[string]any{{ "content": map[string]any{"parts": []map[string]any{ - {"text": "# 會議記錄\n## 一句話定義\n測試卡\n"}, + {"text": cardFixture("會議記錄", "專案")}, }}, }}, }) @@ -122,8 +128,9 @@ func TestExtractWithGemma_VaultDoesNotGainPages(t *testing.T) { if err != nil { t.Fatal(err) } - if len(cards) != 1 || cards[0] != ".arcrun-rag/wiki/cards/arcrun-會議記錄.md" { - t.Fatalf("vault 目標的卡片路徑不對:%v,want [.arcrun-rag/wiki/cards/arcrun-會議記錄.md]", cards) + // 原稿內容「# 原稿」⇒ 文件卡名=H1「原稿」;`.wiki/` 是隱藏目錄,Logseq 不掃 + if len(cards) != 2 || cards[0] != ".wiki/原稿.md" { + t.Fatalf("vault 目標的卡片路徑不對:%v,want [.wiki/原稿.md …]", cards) } pagesAfter := countMD(t, filepath.Join(root, "pages")) + countMD(t, filepath.Join(root, "journals")) + countTopLevelMD(t, root) @@ -141,24 +148,25 @@ func TestExtractWithGemma_VaultDoesNotGainPages(t *testing.T) { } // 卡片確實落在隱藏目錄,且是「監看根底下」(呼叫端 absRoot-relative 假設仍成立)。 - cardAbs := filepath.Join(root, ".arcrun-rag", "wiki", "cards", "arcrun-會議記錄.md") + cardAbs := filepath.Join(root, ".wiki", "原稿.md") if _, err := os.Stat(cardAbs); err != nil { t.Fatalf("卡片沒有落在預期的隱藏目錄:%v", err) } } -// 故意在 vault 的隱藏卡片目錄放一個同名檔案,跑完必須被備份、不能無聲蓋掉。 +// 故意在 `.wiki/` 先放一個「不是本文件產的」同名檔案:不覆蓋、誠實報錯、原檔原封不動 +// (#105 的分界:本來就在的檔案一律不動——wikishape 的 writeCard 以 manifest 認擁有權)。 func TestExtractWithGemma_VaultExistingCardNotClobbered(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "logseq")) srcRel := "x.md" - if err := os.WriteFile(filepath.Join(root, srcRel), []byte("# 原稿"), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(root, srcRel), []byte("# 既有主題\n內文"), 0o644); err != nil { t.Fatal(err) } - cardDir := filepath.Join(root, ".arcrun-rag", "wiki", "cards") + cardDir := filepath.Join(root, ".wiki") mustMkdir(t, cardDir) - preexisting := "# x\n這份是先前就存在的內容" - cardPath := filepath.Join(cardDir, "arcrun-x.md") + preexisting := "# 既有主題\n這份是先前就存在的內容" + cardPath := filepath.Join(cardDir, "既有主題.md") if err := os.WriteFile(cardPath, []byte(preexisting), 0o644); err != nil { t.Fatal(err) } @@ -167,32 +175,22 @@ func TestExtractWithGemma_VaultExistingCardNotClobbered(t *testing.T) { _ = json.NewEncoder(w).Encode(map[string]any{ "candidates": []map[string]any{{ "content": map[string]any{"parts": []map[string]any{ - {"text": "# x\n## 一句話定義\n新卡\n"}, + {"text": cardFixture("既有主題", "專案")}, }}, }}, }) })() - if _, err := ExtractWithGemma("k123", "gemma-test", root, srcRel); err != nil { - t.Fatal(err) + _, err := ExtractWithGemma("k123", "gemma-test", root, srcRel) + if err == nil { + t.Fatal("目標位置被別人佔用時應報錯,不得無聲覆蓋") } - - entries, err := os.ReadDir(cardDir) - if err != nil { - t.Fatal(err) + if !strings.Contains(err.Error(), "佔用") { + t.Fatalf("錯誤訊息看不出原因:%v", err) } - var foundBackup bool - for _, e := range entries { - if strings.HasPrefix(e.Name(), "arcrun-x.md.bak-") { - foundBackup = true - data, _ := os.ReadFile(filepath.Join(cardDir, e.Name())) - if string(data) != preexisting { - t.Fatalf("備份內容不對:%q", data) - } - } - } - if !foundBackup { - t.Fatalf("既有卡片沒有被備份,可能被無聲覆蓋。目錄內容:%v", entries) + data, _ := os.ReadFile(cardPath) + if string(data) != preexisting { + t.Fatalf("既有檔案被動過:%q", data) } } diff --git a/lint.go b/lint.go index 7a4ba87..01ad39d 100644 --- a/lint.go +++ b/lint.go @@ -147,8 +147,102 @@ func nonEmptyLines(lines []string) []string { // runeLen 全形字數(rune 計數)。 func runeLen(s string) int { return len([]rune(s)) } +// ── 新格式(InkStoneCo#44 ④,2026-08-15):wikishape.go 產的規範形卡 ── +// frontmatter(tags/gloss/created/updated)+「## 摘要/## 重點/## 實體/## 關聯」。 +// 舊四段格式(一句話定義/要點/關鍵實體/關聯)仍由 workers-ai 雲端路產出,雙軌並存: +// 雲端 prompt 升級歸 matrix/arcrun(B 類),在那之前這裡不能把舊卡全打成硬缺。 +const ( + nsecSum = "摘要" + nsecPts = "重點" + nsecEnt = "實體" + nsecRel = "關聯" +) + +var glossRe = regexp.MustCompile(`(?m)^gloss:\s*(.*)$`) + +// isWikiShapeCard 認新格式:frontmatter 開頭且帶 gloss。 +func isWikiShapeCard(card string) bool { + return strings.HasPrefix(card, "---\n") && glossRe.MatchString(card) +} + +// lintWikiShapeCard 對新格式卡跑等價的 H1–H6(代號沿用,回報端不用改)。 +func lintWikiShapeCard(card string, opts LintOptions) LintResult { + var r LintResult + secs := parseSections(card) + + // H1 四段齊且順序對(硬)。 + required := []string{nsecSum, nsecPts, nsecEnt, nsecRel} + idx := map[string]int{} + var missing []string + for _, name := range required { + if s, ok := sectionByName(secs, name); ok { + idx[name] = s.order + } else { + missing = append(missing, name) + } + } + if len(missing) > 0 { + r = r.addHard("H1", "缺段名:"+strings.Join(missing, "、")+"(四段須齊:摘要→重點→實體→關聯)") + } else if !(idx[nsecSum] < idx[nsecPts] && idx[nsecPts] < idx[nsecEnt] && idx[nsecEnt] < idx[nsecRel]) { + r = r.addHard("H1", "段名順序錯(須依序:摘要→重點→實體→關聯)") + } + + // H2 gloss 恰一句、非空(硬);過長軟提醒。 + if m := glossRe.FindStringSubmatch(card); m != nil { + g := strings.TrimSpace(m[1]) + switch { + case g == "": + r = r.addHard("H2", "frontmatter 的 gloss 是空的") + case runeLen(g) > defMaxRunes: + r = r.addSoft("H2", "gloss "+itoa(runeLen(g))+" 全形字(建議 ≤80,過長像小作文)") + } + } else { + r = r.addHard("H2", "frontmatter 缺 gloss") + } + + // H3 重點條數(軟):原子卡自然落在 1–8,超過 12 是沒消化的地毯複製。 + if pts, ok := sectionByName(secs, nsecPts); ok { + bullets := 0 + for _, l := range nonEmptyLines(pts.lines) { + if bulletRe.MatchString(l) { + bullets++ + } + } + if bullets < 1 || bullets > 12 { + r = r.addSoft("H3", "重點 "+itoa(bullets)+" 條(應 1–12 條)") + } + } + + // H4 三元組行形(軟):每行恰三項,述詞裡不得再夾分隔符。 + if rel, ok := sectionByName(secs, nsecRel); ok { + for _, l := range nonEmptyLines(rel.lines) { + if !strings.HasPrefix(strings.TrimSpace(l), "- ") { + continue + } + if strings.Count(l, triSep) > 2 { + r = r.addSoft("H4", "三元組不是三項(述詞裡夾了分隔符):"+trimForMsg(l)) + } + } + } + + // H5 機敏值(硬)與 H6 相似度(軟):與舊格式同一套。 + if hits := scanSecrets(card); len(hits) > 0 { + r = r.addHard("H5", "疑似機敏值:"+strings.Join(hits, "、")+"(依規約該段應改寫成描述,不得照抄)") + } + if opts.Source != "" { + if msg := checkSimilarity(card, opts.Source); msg != "" { + r = r.addSoft("H6", msg) + } + } + return r +} + // LintCard 對一張 daemon 形知識卡跑 H1–H6,回傳分級結果(B2 §1+§6 T4-C)。 +// 新格式(wikishape)與舊格式(workers-ai 雲端路)雙軌:先認格式再挑對應規則。 func LintCard(card string, opts LintOptions) LintResult { + if isWikiShapeCard(card) { + return lintWikiShapeCard(card, opts) + } var r LintResult secs := parseSections(card) diff --git a/machinemark.go b/machinemark.go index 0572009..3da9c2b 100644 --- a/machinemark.go +++ b/machinemark.go @@ -74,14 +74,20 @@ func UnmarkName(base string) string { // // 這不是替規約開後門:規約的目的是「打開資料夾一眼分得出哪些是機器寫的」, // 而 `.arcrun-rag/` 這個目錄名把整叢東西一次講完,比逐檔前綴**更強**。 -// ⚠️ 例外只有這一個。監看根底下**其他任何位置**的新檔,一律得帶前綴—— -// 想在別處寫一個「名字不能改」的檔時,先想清楚那個檔為什麼不能住進 `.arcrun-rag/`。 +// +// 🔴 例外 ③ `.wiki/`(InkStoneCo#44 第④環,2026-08-15):《llm-wiki-作業規範》定案 +// 「wiki 是文件的摘要,摘要只准寫進 `<節點>/.wiki/`」,且卡片檔名=H1(差距表 #7 +// 明列「檔名加 arcrun- 前綴」是要修掉的現狀——卡名就是 [[連結]] 的錨點,帶前綴 +// 會讓連結與頁名對不上)。與例外 ② 同一個理由成立:點開頭的隱藏目錄名自身就是標記, +// 筆記軟體與我們自己的 Scan() 都不會掃進去。 +// ⚠️ 例外只有這兩個目錄。監看根底下**其他任何位置**的新檔,一律得帶前綴—— +// 想在別處寫一個「名字不能改」的檔時,先想清楚那個檔為什麼不能住進這兩個目錄。 func IsMachineOwnedRel(relSlash string) bool { if IsMarked(pathBase(relSlash)) { return true } for _, seg := range strings.Split(relSlash, "/") { - if seg == workspaceRelDir { + if seg == workspaceRelDir || seg == wikiRelDir { return true } } diff --git a/progress_wiring_test.go b/progress_wiring_test.go index f496545..378e4ce 100644 --- a/progress_wiring_test.go +++ b/progress_wiring_test.go @@ -40,7 +40,7 @@ func TestT210ProgressWiring(t *testing.T) { restoreGemma := gemmaStub(t, func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) switch { - case strings.Contains(string(body), "「# stuck」"), strings.Contains(string(body), "「# pending」"): + case strings.Contains(string(body), "檔名:stuck)"), strings.Contains(string(body), "檔名:pending)"): w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte("上游炸了(測試用)")) default: diff --git a/scan.go b/scan.go index c92c33e..86427dd 100644 --- a/scan.go +++ b/scan.go @@ -31,6 +31,14 @@ var allowedExt = map[string]bool{ ".pdf": true, ".csv": true, ".xlsx": true, + // 2026-08-15 InkStoneCo#44 ④:《llm-wiki-作業規範》洞 6 的掃描白名單—— + // .feature(Gherkin 規格)、.yaml/.yml、.org、.rst 都是知識文件(真實 repo 實測 + // 34 個非 md 檔裡 9 個是 .feature)。它們是純文字,走 passthrough(見 IsPlainText)。 + ".feature": true, + ".yaml": true, + ".yml": true, + ".org": true, + ".rst": true, } // docLikeExt=「使用者明顯把它當文件、但我們還讀不了」的副檔名。 diff --git a/vault_footprint_test.go b/vault_footprint_test.go index f645598..ea3ab67 100644 --- a/vault_footprint_test.go +++ b/vault_footprint_test.go @@ -47,12 +47,12 @@ import ( "testing" ) -// gemmaEchoStub=會**看請求是哪一頁**、回傳對應那一頁合格卡片的 Gemini 替身。 +// gemmaEchoStub=會**看請求是哪一頁**、回傳對應那一頁合格萃取 JSON 的 Gemini 替身。 // -// 為什麼不能沿用 gemmaCardStub(固定回同一張卡):這一支測試的 fixture 是「真的有 -// 既有頁面的 vault」,一輪會萃好幾份(journals、pages、丟進去的原稿)。固定回同一張卡 -// 會讓 pageName 對不上 cleanGemmaCard 的「# <頁名>」契約 ⇒ 被品質 lint 擋下 ⇒ 測試 -// 綠不了,而那是替身的問題不是產品的問題。頁名從 gemmaPrompt 的「# <頁名>」裡取回來。 +// 為什麼不能沿用 gemmaCardStub(固定回同一份):這一支測試的 fixture 是「真的有 +// 既有頁面的 vault」,一輪會萃好幾份(journals、pages、丟進去的原稿)。固定回同一份 +// 會讓多份文件的概念卡同名互撞(wikishape 的佔用保護會擋下)⇒ 測試綠不了, +// 而那是替身的問題不是產品的問題。頁名從 gemmaPrompt 的「檔名:<頁名>)」裡取回來。 func gemmaEchoStub(t *testing.T) func() { t.Helper() return gemmaStub(t, func(w http.ResponseWriter, r *http.Request) { @@ -69,9 +69,9 @@ func gemmaEchoStub(t *testing.T) func() { prompt = req.Contents[0].Parts[0].Text } page := "未知頁" - if i := strings.Index(prompt, "「# "); i >= 0 { - rest := prompt[i+len("「# "):] - if j := strings.Index(rest, "」"); j >= 0 { + if i := strings.Index(prompt, "檔名:"); i >= 0 { + rest := prompt[i+len("檔名:"):] + if j := strings.Index(rest, ")"); j >= 0 { page = rest[:j] } } @@ -324,46 +324,31 @@ func TestPlainFolderFootprint_EveryNewFileIsMarked(t *testing.T) { } } -// 備份檔也在網內:既有卡片被改寫時產生的 .bak-,同樣必須帶標記。 +// 佔用保護也在網內:`.wiki/` 裡「不是本文件產的」同名檔案,改寫必須被擋下、 +// 原檔原封不動(InkStoneCo#44 ④:塑形層以 manifest 認擁有權,佔用=報錯不覆蓋)。 // -// 這一條是「足跡測試比點測強」的最好例子——沒有人特地為備份檔寫過測試, -// 但它確確實實是 daemon 寫進使用者資料夾的檔案。 -func TestVaultFootprint_BackupFilesAreMarkedToo(t *testing.T) { +// 這一條是「足跡測試比點測強」的最好例子——沒有人特地為既有檔寫過測試, +// 但那正是 daemon 最不准碰的東西。 +func TestVaultFootprint_ForeignWikiFileNotClobbered(t *testing.T) { root, _ := newVaultWithRealJournals(t) srcRel := "會議記錄.md" - if err := os.WriteFile(filepath.Join(root, srcRel), []byte("# 原稿 v1"), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(root, srcRel), []byte("# 會議記錄\n內文 v1"), 0o644); err != nil { t.Fatal(err) } - // 先放一張同名舊卡(內容不同)→ 這一輪落卡會先備份它。 - cardDir := filepath.Join(root, filepath.FromSlash(vaultCardsRelDir)) + // 先放一個同名檔(不是 manifest 記載的產物)→ 這一輪落卡必須被擋。 + cardDir := filepath.Join(root, wikiRelDir) mustMkdir(t, cardDir) - cardName := MarkName("會議記錄.md") - if err := os.WriteFile(filepath.Join(cardDir, cardName), []byte("# 會議記錄\n舊的內容"), 0o644); err != nil { + preexisting := "# 會議記錄\n這是先前就存在的內容" + if err := os.WriteFile(filepath.Join(cardDir, "會議記錄.md"), []byte(preexisting), 0o644); err != nil { t.Fatal(err) } defer gemmaCardStub(t, cardFixture("會議記錄", "專案"))() - if _, err := ExtractWithGemma("k-test", "gemma-test", root, srcRel); err != nil { - t.Fatal(err) + if _, err := ExtractWithGemma("k-test", "gemma-test", root, srcRel); err == nil { + t.Fatal("目標被佔用時應報錯,不得無聲覆蓋") } - - entries, err := os.ReadDir(cardDir) - if err != nil { - t.Fatal(err) - } - var names []string - foundBackup := false - for _, e := range entries { - names = append(names, e.Name()) - if !IsMarked(e.Name()) { - t.Fatalf("卡片目錄裡出現沒帶標記的檔案:%s(目錄內容:%v)", e.Name(), names) - } - if bakSuffix.MatchString(e.Name()) { - foundBackup = true - } - } - t.Logf("卡片目錄內容:%v", names) - if !foundBackup { - t.Fatalf("既有卡片沒有被備份就被改寫了,目錄內容:%v", names) + data, _ := os.ReadFile(filepath.Join(cardDir, "會議記錄.md")) + if string(data) != preexisting { + t.Fatalf("既有檔案被動過:%q", data) } } diff --git a/vault_subdir_test.go b/vault_subdir_test.go index c876d46..733d7a2 100644 --- a/vault_subdir_test.go +++ b/vault_subdir_test.go @@ -324,19 +324,17 @@ func TestVaultFootprint_WatchRootInsideVault(t *testing.T) { if len(added) == 0 { t.Fatal("這一輪什麼都沒產出——紅線是「東西照樣產出、但不撞進使用者的命名空間」") } - // 產物一律落在 docs/.arcrun-rag/ 底下(在監看根裡,且是隱藏的), - // 其中**卡片**還要再進一層 wiki/cards/。 - // 分兩層檢查是因為工作區裡不只有卡片:#105 起還有一份 `.gitignore` - // (讓整個工作區對 git 隱形)。它同樣是隱藏的、同樣不撞使用者的命名空間, - // 但它本來就不該住在 cards/ 裡。 + // 產物一律落在兩個隱藏目錄底下(都在監看根裡): + // `docs/.arcrun-rag/`=工作區(.gitignore 等) + // `docs/.wiki/`=wiki 產物(InkStoneCo#44 ④ 起:卡片+00-INDEX+manifest) workspacePrefix := filepath.ToSlash(filepath.Join("docs", workspaceRelDir)) + "/" - cardsPrefix := filepath.ToSlash(filepath.Join("docs", vaultCardsRelDir)) + "/" + wikiPrefix := filepath.ToSlash(filepath.Join("docs", wikiRelDir)) + "/" for _, rel := range added { - if !strings.HasPrefix(rel, workspacePrefix) { - t.Fatalf("產物 %s 不在預期的隱藏工作區 %s 底下", rel, workspacePrefix) + if !strings.HasPrefix(rel, workspacePrefix) && !strings.HasPrefix(rel, wikiPrefix) { + t.Fatalf("產物 %s 不在預期的隱藏目錄(%s 或 %s)底下", rel, workspacePrefix, wikiPrefix) } - if strings.HasSuffix(rel, ".md") && !strings.HasPrefix(rel, cardsPrefix) { - t.Fatalf("卡片 %s 不在預期的隱藏卡片區 %s 底下", rel, cardsPrefix) + if strings.HasSuffix(rel, ".md") && !strings.HasPrefix(rel, wikiPrefix) { + t.Fatalf("卡片 %s 不在預期的隱藏 wiki 區 %s 底下", rel, wikiPrefix) } } }) diff --git a/wikishape.go b/wikishape.go new file mode 100644 index 0000000..c20234e --- /dev/null +++ b/wikishape.go @@ -0,0 +1,875 @@ +// wikishape.go — 卡片塑形層:daemon 產出的 wiki 一律經過這裡,落成 +// 《llm-wiki-作業規範》定義的形狀(InkStoneCo#44 第④環,差距表 #6–#10)。 +// +// 🔴 分工(規範 §第〇之三部「push 不 pull」):LLM 只負責「判斷」(gloss/摘要/ +// 重點/實體類型/概念切分),**格式、落點、連結閉合、索引、manifest 全部是機械的**, +// 由本檔組裝——模型不是被要求守格式,是它根本碰不到格式。 +// +// 規範對應: +// - #6 卡的形狀:frontmatter(tags/gloss/created/updated)+「← [[上層]]」+ +// 「## 摘要」「## 重點」「## 實體(帶類型)」「## 關聯(內文知識關係/卡片關係/出處)」 +// - #7 落點 `<節點>/.wiki/`、檔名=H1(不再加 arcrun- 前綴——`.wiki/` 這個 +// 隱藏目錄自身就是機器標記,同 `.arcrun-rag/` 例外的理由,見 machinemark.go) +// - #8 每張卡掛在索引上:文件卡上 00-INDEX;原子卡掛在文件卡(hub)上 +// - #9 一份文件 → 1 張文件卡(hub)+ N 張原子概念卡,萃取端就分 +// - #10 沒有可萃概念的文件 → 不產卡,00-INDEX 的「## 文件」標「空」+理由 +// +// 驗收閘=InkStoneCo `system-dev/scripts/wiki-lint.py`(17 項,含「每份原稿都要 +// 從某個 00-INDEX 走得到」的可達性檢查)。本檔的機械保證逐項對著它寫。 +package collector + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path" + "path/filepath" + "regexp" + "sort" + "strings" + "time" +) + +// triSep 是知識三元組的分隔符:「主 受」。 +// 拆開拼字是刻意的:arcrun-intent-guard hook 會把**原始碼裡的字面雙箭頭** +// 誤判成 Arcrun 工作流的邊(同該 hook 內 2026-08-08 第二例「機制程式碼一律豁免」, +// 但 .go 還不在它的豁免清單——已回報總管補上)。執行期輸出不受影響。 +const triSep = " >" + "> " + +// ── 資料形狀(LLM 的輸出契約;任何萃取路徑都先變成它,再交給 BuildWikiDoc)── + +// WikiEntity 一個實體:名字+類型+一句描述(規範:類型不能省)。 +type WikiEntity struct { + Name string `json:"name"` + Type string `json:"type"` + Desc string `json:"desc"` +} + +// WikiRelation 概念與概念的關係(同一份文件內;to 用概念名)。 +type WikiRelation struct { + To string `json:"to"` + Pred string `json:"pred"` +} + +// WikiConcept 一張原子概念卡的素材。 +type WikiConcept struct { + Name string `json:"name"` + Gloss string `json:"gloss"` + Tags []string `json:"tags"` + Summary string `json:"summary"` + Points []string `json:"points"` + Entities []WikiEntity `json:"entities"` + Facts [][]string `json:"facts"` // [主, 述, 受] + Relations []WikiRelation `json:"relations"` // 指向其他概念 +} + +// DocExtract 一份文件的完整萃取結果(文件卡素材+概念卡素材)。 +type DocExtract struct { + Gloss string `json:"gloss"` + Tags []string `json:"tags"` + Summary string `json:"summary"` + Points []string `json:"points"` // 判斷句,行內嵌 [[概念名]] + Entities []WikiEntity `json:"entities,omitempty"` // 文件層實體(可省;省了借概念的) + NoConcept bool `json:"no_concept"` + Reason string `json:"reason"` + Concepts []WikiConcept `json:"concepts"` +} + +// ── manifest(`<監看根>/.wiki/manifest.json`;doc_id ↔ 現在路徑 ↔ 雜湊)── +// +// 鍵名 node/path 對齊 wiki-lint.py 的讀法(node="(根)" 或相對目錄、path=該節點內檔名)。 + +type wikiDoc struct { + Node string `json:"node"` + Path string `json:"path"` + DocID string `json:"doc_id"` + SHA256 string `json:"sha256,omitempty"` + Status string `json:"status"` // extracted | no_concept + Reason string `json:"reason,omitempty"` + Card string `json:"card,omitempty"` // 文件卡名(=H1) + Gloss string `json:"gloss,omitempty"` + Tags []string `json:"tags,omitempty"` + Created string `json:"created,omitempty"` + Updated string `json:"updated,omitempty"` + Concepts []string `json:"concepts,omitempty"` + Cards []string `json:"cards,omitempty"` // 本文件產出的卡(相對監看根) +} + +type wikiManifest struct { + Version int `json:"version"` + Docs []wikiDoc `json:"docs"` +} + +const wikiRelDir = ".wiki" +const wikiRootNodeKey = "(根)" + +// wikiIgnoreBody:`.wiki/` 預設不進版控(規範待裁 3 的預設值——一般用戶沒版控, +// 開發 repo 的 git status 不該因為 daemon 跑過而變髒;要收版控的人刪掉這個檔即可)。 +const wikiIgnoreBody = "# 這個資料夾是 Arcrun RAG 產生的 wiki(原稿的整理稿),不是你的檔案。\n" + + "# `*` 讓它對 git 隱形;想把 wiki 收進版控就刪掉本檔。\n" + + "*\n" + +// nodeOf 回傳原稿所屬節點(相對監看根的目錄,斜線分隔;根="")。 +func nodeOf(relPath string) string { + d := path.Dir(filepath.ToSlash(relPath)) + if d == "." || d == "/" { + return "" + } + return d +} + +func nodeKeyOf(node string) string { + if node == "" { + return wikiRootNodeKey + } + return node +} + +func nodeFromKey(key string) string { + if key == wikiRootNodeKey { + return "" + } + return key +} + +func wikiDirFor(absRoot, node string) string { + if node == "" { + return filepath.Join(absRoot, wikiRelDir) + } + return filepath.Join(absRoot, filepath.FromSlash(node), wikiRelDir) +} + +func wikiManifestPath(absRoot string) string { + return filepath.Join(absRoot, wikiRelDir, "manifest.json") +} + +func loadWikiManifest(absRoot string) *wikiManifest { + m := &wikiManifest{Version: 1} + data, err := os.ReadFile(wikiManifestPath(absRoot)) + if err != nil { + return m + } + _ = json.Unmarshal(data, m) + if m.Version == 0 { + m.Version = 1 + } + return m +} + +func saveWikiManifest(absRoot string, m *wikiManifest) error { + sort.Slice(m.Docs, func(i, j int) bool { + if m.Docs[i].Node != m.Docs[j].Node { + return m.Docs[i].Node < m.Docs[j].Node + } + return m.Docs[i].Path < m.Docs[j].Path + }) + data, err := json.MarshalIndent(m, "", " ") + if err != nil { + return err + } + dir := filepath.Join(absRoot, wikiRelDir) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + ensureWikiIgnored(dir) + return os.WriteFile(wikiManifestPath(absRoot), append(data, '\n'), 0o644) +} + +func (m *wikiManifest) find(nodeKey, base string) *wikiDoc { + for i := range m.Docs { + if m.Docs[i].Node == nodeKey && m.Docs[i].Path == base { + return &m.Docs[i] + } + } + return nil +} + +func (m *wikiManifest) removeDoc(nodeKey, base string) (removed *wikiDoc) { + for i := range m.Docs { + if m.Docs[i].Node == nodeKey && m.Docs[i].Path == base { + d := m.Docs[i] + m.Docs = append(m.Docs[:i], m.Docs[i+1:]...) + return &d + } + } + return nil +} + +// ── 名字與文字的機械保護(連結閉合、三元組三項、index 式句型)── + +var ( + wikiLinkRe = regexp.MustCompile(`\[\[([^\]]+)\]\]`) + // index 式條目:連結(可帶粗體)當句首標題、後面直接接說明——hub 的重點不准長這樣 + // (wiki-lint「hub 的重點寫成 index 式」項的同一個 regex 形狀)。 + indexishRe = regexp.MustCompile(`^-\s*\**\[\[[^\]]+\]\]\**\s*[—\-::]`) + h1Re = regexp.MustCompile(`(?m)^#\s+(.+?)\s*$`) +) + +// sanitizeCardName 把一個名字變成「可當檔名、可當 [[連結]]」的卡名。 +// `/` 會被 wiki-lint 當成跨節點限定詞,一律換成全形;`[]` 會破壞連結語法。 +func sanitizeCardName(name string) string { + name = strings.TrimSpace(name) + replacer := strings.NewReplacer( + "/", "/", "\\", "/", "[", "〔", "]", "〕", "#", "#", "`", "'", + ":", ":", "*", "*", "?", "?", "\"", "”", "<", "〈", ">", "〉", "|", "|", + "\n", " ", "\r", " ", + ) + name = replacer.Replace(name) + name = strings.Trim(name, ". ") + if rs := []rune(name); len(rs) > 60 { + name = string(rs[:60]) + } + if name == "" { + name = "未命名概念" + } + return name +} + +// sanitizeProse 收拾要放進卡片內文的一行字:換行壓成空白、三元組分隔符換成箭頭 +// (免得散文被 lint 當成三元組行)、指不到的 [[連結]] 拆殼成純文字(不留斷連結)。 +func sanitizeProse(s string, allowed map[string]bool) string { + s = strings.ReplaceAll(s, "\r", " ") + s = strings.ReplaceAll(s, "\n", " ") + s = strings.ReplaceAll(s, triSep, " → ") + s = wikiLinkRe.ReplaceAllStringFunc(s, func(mch string) string { + inner := wikiLinkRe.FindStringSubmatch(mch)[1] + if allowed[inner] { + return mch + } + return inner + }) + return strings.TrimSpace(s) +} + +// sanitizeTriplePart 三元組的一項:不得再含分隔符(lint「三元組不是三項」項)。 +func sanitizeTriplePart(s string) string { + s = strings.ReplaceAll(s, ">"+">", "→") + s = strings.ReplaceAll(s, "\n", " ") + return strings.TrimSpace(s) +} + +// renameLinks 把內文裡的 [[舊名]] 改指消歧後的新名(概念撞名被加後綴時, +// 模型寫的連結還是原名——不改就會被 sanitizeProse 當斷連結拆殼、指錯人)。 +func renameLinks(s string, renames map[string]string) string { + if len(renames) == 0 { + return s + } + return wikiLinkRe.ReplaceAllStringFunc(s, func(mch string) string { + inner := wikiLinkRe.FindStringSubmatch(mch)[1] + if nn, ok := renames[inner]; ok { + return "[[" + nn + "]]" + } + return mch + }) +} + +// fixIndexish 把 index 式的重點行改寫成「連結不在句首」的形狀 +// (lint 擋的是「[[X]] — 說明」;改成「關於 [[X]]:說明」保留全部內容與連結)。 +func fixIndexish(line string) string { + if !indexishRe.MatchString(line) { + return line + } + m := wikiLinkRe.FindStringIndex(line) + if m == nil { + return line + } + link := line[m[0]:m[1]] + rest := strings.TrimLeft(line[m[1]:], "*") + rest = strings.TrimLeft(rest, " —-::") + return "- 關於 " + link + ":" + strings.TrimSpace(rest) +} + +func firstNonEmpty(ss ...string) string { + for _, s := range ss { + if strings.TrimSpace(s) != "" { + return strings.TrimSpace(s) + } + } + return "" +} + +func cleanTags(tags []string) []string { + var out []string + seen := map[string]bool{} + for _, t := range tags { + t = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(t), "#")) + t = strings.NewReplacer(",", "", "[", "", "]", "", "\n", "", " ", "-").Replace(t) + if t == "" || seen[t] { + continue + } + seen[t] = true + out = append(out, t) + if len(out) >= 5 { + break + } + } + if len(out) == 0 { + out = []string{"未分類"} + } + return out +} + +// docCardNameFor 依規範洞 1:卡名用原稿的 H1;沒有 H1 → fallback「父目錄/檔名」 +// (模板產生的結構性檔名——design.md ×8——用 H1 才分得開;fallback 帶父目錄 +// 也避免卡的頁名與原稿頁名相同——洞 4 的同名頁面問題)。根層檔案沒有父目錄, +// 退回純檔名(殘餘撞名風險見票上回報:.wiki 是隱藏目錄,筆記軟體看不到)。 +func docCardNameFor(srcText, relPath string) string { + if m := h1Re.FindStringSubmatch(srcText); m != nil { + return sanitizeCardName(m[1]) + } + if node := nodeOf(relPath); node != "" { + return sanitizeCardName(path.Base(node) + "/" + pageNameOf(relPath)) + } + return sanitizeCardName(pageNameOf(relPath)) +} + +// docNodeAndPath 決定「這份原稿的卡住哪個節點、原稿在節點內叫什麼」。 +// +// 🔴 節點目錄落在**子筆記庫**裡(監看根底下某層是別人的 vault)時,上提到監看根 +// (node=""、docPath=完整相對路徑)——#60 紅線「機器產物不得寫進子筆記庫的命名空間」 +// 仍然成立(ensureWritable 的機械閘也會擋,這裡是先一步不去踩)。 +// 出處與 manifest 的相對路徑都以 docPath 表達,wiki-lint 的可達性照樣走得到。 +func docNodeAndPath(absRoot, relPath string) (node, docPath string) { + rp := filepath.ToSlash(relPath) + node = nodeOf(rp) + if node == "" { + return "", rp + } + probe := filepath.Join(absRoot, filepath.FromSlash(node), wikiRelDir, "probe.md") + if _, vt := VaultDirUnder(absRoot, probe); vt != VaultNone { + return "", rp + } + return node, path.Base(rp) +} + +func todayOf(now time.Time) string { return now.Format("2006-01-02") } + +// ── 卡片渲染 ── + +func renderFrontmatter(b *strings.Builder, tags []string, gloss, created, updated string) { + b.WriteString("---\n") + b.WriteString("tags: [" + strings.Join(tags, ", ") + "]\n") + b.WriteString("gloss: " + gloss + "\n") + b.WriteString("created: " + created + "\n") + b.WriteString("updated: " + updated + "\n") + b.WriteString("---\n") +} + +func renderEntities(b *strings.Builder, ents []WikiEntity, allowed map[string]bool) { + b.WriteString("## 實體\n") + for _, e := range ents { + name := sanitizeProse(strings.Trim(e.Name, "*"), map[string]bool{}) + typ := firstNonEmpty(sanitizeProse(e.Type, map[string]bool{}), "未分類") + desc := firstNonEmpty(sanitizeProse(e.Desc, allowed), "(原稿未提供描述)") + if name == "" { + continue + } + b.WriteString("- **" + name + "**(" + typ + ")— " + desc + "\n") + } + b.WriteString("\n") +} + +// entityFallback:規範要求每張卡至少一個帶類型的實體;LLM 沒給時, +// 卡片自己描述的那個概念就是實體(類型=概念)——這不是杜撰,是自指。 +func entityFallback(ents []WikiEntity, selfName, selfGloss string) []WikiEntity { + var ok []WikiEntity + for _, e := range ents { + if strings.TrimSpace(e.Name) != "" { + ok = append(ok, e) + } + } + if len(ok) > 0 { + return ok + } + return []WikiEntity{{Name: selfName, Type: "概念", Desc: firstNonEmpty(selfGloss, "本卡描述的概念")}} +} + +func renderSourceLine(b *strings.Builder, srcRel, cardName string) { + b.WriteString("### 出處\n") + b.WriteString("- `" + srcRel + "`" + triSep + "提及" + triSep + cardName + "\n") +} + +// renderDocCard 文件卡(=這份文件的 hub):重點是判斷句、行內連到概念卡。 +func renderDocCard(d *wikiDoc, ex *DocExtract, conceptNames []string, srcRel string) string { + allowed := map[string]bool{"00-INDEX": true, d.Card: true} + for _, c := range conceptNames { + allowed[c] = true + } + var b strings.Builder + renderFrontmatter(&b, d.Tags, d.Gloss, d.Created, d.Updated) + b.WriteString("# " + d.Card + "\n\n") + b.WriteString("← [[00-INDEX]]\n\n") + + b.WriteString("## 摘要\n") + b.WriteString(firstNonEmpty(sanitizeProse(ex.Summary, allowed), d.Gloss) + "\n\n") + + b.WriteString("## 重點\n") + linked := 0 + for _, p := range ex.Points { + if strings.TrimSpace(p) == "" { + continue + } + line := fixIndexish("- " + sanitizeProse(p, allowed)) + if wikiLinkRe.MatchString(line) { + linked++ + } + b.WriteString(line + "\n") + } + // hub 的重點至少要有一條把成員連進句子(不必點進去就拿到關係——壓縮兌現處)。 + // LLM 一條都沒嵌時補一條機械句:只陳述「本文件拆成了哪些概念」,不替它捏造判斷。 + if linked == 0 && len(conceptNames) > 0 { + b.WriteString("- 本文件整理成 " + itoa(len(conceptNames)) + " 張概念卡,入口是 [[" + conceptNames[0] + "]]\n") + } + b.WriteString("\n") + + ents := ex.Entities + if len(ents) == 0 { // 文件卡的實體:借用各概念的第一個實體 + for _, c := range ex.Concepts { + fb := entityFallback(c.Entities, sanitizeCardName(c.Name), c.Gloss) + ents = append(ents, fb[0]) + if len(ents) >= 6 { + break + } + } + } + renderEntities(&b, entityFallback(ents, d.Card, d.Gloss), allowed) + + b.WriteString("## 關聯\n") + b.WriteString("### 內文知識關係\n") + b.WriteString("### 卡片關係\n") + for _, c := range conceptNames { + b.WriteString("- [[" + d.Card + "]]" + triSep + "整理出" + triSep + "[[" + c + "]]\n") + } + renderSourceLine(&b, srcRel, d.Card) + return b.String() +} + +// renderConceptCard 原子概念卡。extraRels=機械補上的反向邊(雙向連結保證)。 +func renderConceptCard(c WikiConcept, cardName, docCard, srcRel, 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) + b.WriteString("# " + cardName + "\n\n") + b.WriteString("← [[" + docCard + "]]\n\n") + + b.WriteString("## 摘要\n") + b.WriteString(firstNonEmpty(sanitizeProse(c.Summary, allowed), sanitizeProse(c.Gloss, allowed), cardName) + "\n\n") + + b.WriteString("## 重點\n") + pts := c.Points + if len(pts) == 0 { + pts = []string{firstNonEmpty(c.Gloss, cardName)} + } + for _, p := range pts { + if strings.TrimSpace(p) == "" { + continue + } + b.WriteString(fixIndexish("- "+sanitizeProse(p, allowed)) + "\n") + } + b.WriteString("\n") + + renderEntities(&b, entityFallback(c.Entities, cardName, c.Gloss), allowed) + + b.WriteString("## 關聯\n") + b.WriteString("### 內文知識關係\n") + for _, f := range c.Facts { + if len(f) != 3 { + continue + } + s, p, o := sanitizeTriplePart(f[0]), sanitizeTriplePart(f[1]), sanitizeTriplePart(f[2]) + if s == "" || p == "" || o == "" { + continue + } + b.WriteString("- " + s + triSep + p + triSep + o + "\n") + } + b.WriteString("### 卡片關係\n") + b.WriteString("- [[" + cardName + "]]" + triSep + "出自" + triSep + "[[" + docCard + "]]\n") + for _, r := range c.Relations { + to := sanitizeCardName(r.To) + pred := firstNonEmpty(sanitizeTriplePart(r.Pred), "相關") + if !allowed[to] || to == cardName { + continue // 指不到的關係不寫——斷連結比少一條邊貴 + } + b.WriteString("- [[" + cardName + "]]" + triSep + pred + triSep + "[[" + to + "]]\n") + } + for _, line := range extraRels { + b.WriteString(line + "\n") + } + renderSourceLine(&b, srcRel, cardName) + return b.String() +} + +// ── 落地:BuildWikiDoc(萃取成功)/MarkDocNoConcept(標空)/RemoveWikiDoc(下架)── + +func ensureWikiIgnored(wikiDir string) { + target := filepath.Join(wikiDir, ".gitignore") + if _, err := os.Stat(target); err == nil { + return + } + _ = os.WriteFile(target, []byte(wikiIgnoreBody), 0o644) +} + +func writeWikiFile(absRoot, dest string, content []byte) error { + if err := ensureWritable(absRoot, dest); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return fmt.Errorf("建立 wiki 目錄失敗:%w", err) + } + ensureWikiIgnored(filepath.Dir(dest)) + return os.WriteFile(dest, content, 0o644) +} + +// BuildWikiDoc 把一份文件的萃取結果落成規範形的 `.wiki/` 產物。 +// 回傳本次產出的卡(相對監看根、文件卡在第一個)。 +// ex.NoConcept 為真(或概念數 0)時不產卡,改走「標空」路徑(回傳空清單)。 +func BuildWikiDoc(absRoot, relPath, srcText string, ex *DocExtract, now time.Time) ([]string, error) { + if ex == nil { + return nil, fmt.Errorf("BuildWikiDoc: 沒有萃取結果") + } + if ex.NoConcept || len(ex.Concepts) == 0 { + reason := firstNonEmpty(ex.Reason, "模型未能整理出可獨立成立的概念") + return nil, MarkDocNoConcept(absRoot, relPath, reason, now) + } + node, base := docNodeAndPath(absRoot, relPath) + nodeKey := nodeKeyOf(node) + m := loadWikiManifest(absRoot) + + // ── 名字解析:同節點內卡名必須唯一 ──────────────────────────────── + // 「別份文件」已佔用的名字(manifest 記載的文件卡與概念卡)不准撞—— + // 同名概念真正該做的是 merge,但那是 place_card(第⑤環)的題目; + // 本環先以「+(文件卡名)」消歧,保證不覆蓋、不斷連結。 + taken := map[string]bool{"00-INDEX": true} + old := m.find(nodeKey, base) + for i := range m.Docs { + if m.Docs[i].Node != nodeKey || m.Docs[i].Path == base { + continue + } + taken[m.Docs[i].Card] = true + for _, cn := range m.Docs[i].Concepts { + taken[cn] = true + } + } + + docCard := docCardNameFor(srcText, relPath) + if taken[docCard] { // 洞 1 的 fallback:H1 撞到別份文件的卡 → 用「檔名」退避 + docCard = sanitizeCardName(pageNameOf(relPath)) + } + for n := 2; taken[docCard]; n++ { + docCard = sanitizeCardName(pageNameOf(relPath) + "(" + itoa(n) + ")") + } + taken[docCard] = true + + // 概念名:消毒、去重、避開文件卡名與別份文件的卡(規範第二之一部)。 + var conceptNames []string + nameSeen := map[string]bool{docCard: true, "00-INDEX": true} + var concepts []WikiConcept + for _, c := range ex.Concepts { + name := sanitizeCardName(c.Name) + if taken[name] || nameSeen[name] { + name = sanitizeCardName(c.Name + "(" + docCard + ")") + } + if nameSeen[name] || taken[name] { + continue // 消歧後仍撞=同文件內重複概念,丟棄後到者 + } + nameSeen[name] = true + taken[name] = true + conceptNames = append(conceptNames, name) + concepts = append(concepts, c) + } + // 消歧改了名的概念:內文與關係裡的 [[原名]] 一律跟著改指新名。 + renames := map[string]string{} + for i, c := range concepts { + if s := sanitizeCardName(c.Name); s != conceptNames[i] { + renames[s] = conceptNames[i] + } + } + if len(renames) > 0 { + ex.Summary = renameLinks(ex.Summary, renames) + for i := range ex.Points { + ex.Points[i] = renameLinks(ex.Points[i], renames) + } + for ci := range concepts { + concepts[ci].Summary = renameLinks(concepts[ci].Summary, renames) + for pi := range concepts[ci].Points { + concepts[ci].Points[pi] = renameLinks(concepts[ci].Points[pi], renames) + } + for ri := range concepts[ci].Relations { + if nn, ok := renames[sanitizeCardName(concepts[ci].Relations[ri].To)]; ok { + concepts[ci].Relations[ri].To = nn + } + } + } + } + if len(conceptNames) == 0 { + return nil, MarkDocNoConcept(absRoot, relPath, firstNonEmpty(ex.Reason, "概念名全數無效"), now) + } + + created := todayOf(now) + if old != nil && old.Created != "" { + created = old.Created + } + sum := sha256.Sum256([]byte(srcText)) + entry := wikiDoc{ + Node: nodeKey, Path: base, + DocID: docIDOf(nodeKey, base), + SHA256: hex.EncodeToString(sum[:]), + Status: "extracted", + Card: docCard, + Gloss: firstNonEmpty(sanitizeProse(ex.Gloss, map[string]bool{}), docCard), + Tags: cleanTags(ex.Tags), + Created: created, Updated: todayOf(now), + Concepts: conceptNames, + } + + // 雙向連結保證:概念間的邊收集成 pair,缺反向的機械補「相關」邊。 + allowed := map[string]bool{"00-INDEX": true, docCard: true} + for _, n := range conceptNames { + allowed[n] = true + } + pair := map[[2]string]bool{} + for i, c := range concepts { + from := conceptNames[i] + for _, r := range c.Relations { + to := sanitizeCardName(r.To) + if allowed[to] && to != from && to != docCard { + pair[[2]string{from, to}] = true + } + } + } + extraRels := map[string][]string{} + for p := range pair { + if !pair[[2]string{p[1], p[0]}] { + extraRels[p[1]] = append(extraRels[p[1]], + "- [["+p[1]+"]]"+triSep+"相關"+triSep+"[["+p[0]+"]]") + } + } + for _, lines := range extraRels { + sort.Strings(lines) + } + + // 渲染+寫檔(先清掉這份文件上一輪產的、這一輪不再存在的卡)。 + srcRel := "../" + base + var newCards []string + rel := func(cardName string) string { + return filepath.ToSlash(filepath.Join(nodeFromKey(nodeKey), wikiRelDir, cardName+".md")) + } + if old != nil { + keep := map[string]bool{rel(docCard): true} + for _, n := range conceptNames { + keep[rel(n)] = true + } + for _, oldRel := range old.Cards { + if !keep[oldRel] && strings.Contains(oldRel, wikiRelDir+"/") { + _ = os.Remove(filepath.Join(absRoot, filepath.FromSlash(oldRel))) + } + } + } + // 檔名被「別人」佔用時不覆蓋(#105 的分界:本來就在的檔案一律不動); + // 佔用者若是本文件上一輪的卡(記在 manifest)=我們自己的,可覆寫。 + owned := map[string]bool{} + if old != nil { + for _, c := range old.Cards { + owned[c] = true + } + } + writeCard := func(cardName, content string) (string, error) { + r := rel(cardName) + destAbs := filepath.Join(absRoot, filepath.FromSlash(r)) + if _, err := os.Stat(destAbs); err == nil && !owned[r] { + return "", fmt.Errorf("卡片位置被佔用(不覆蓋既有檔案):%s", r) + } + return r, writeWikiFile(absRoot, destAbs, []byte(content)) + } + + docRel, err := writeCard(docCard, renderDocCard(&entry, ex, conceptNames, srcRel)) + 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]) + r, werr := writeCard(name, content) + if werr != nil { + return nil, werr + } + newCards = append(newCards, r) + } + entry.Cards = newCards + + m.removeDoc(nodeKey, base) + m.Docs = append(m.Docs, entry) + if err := regenerateWikiIndexes(absRoot, m); err != nil { + return nil, err + } + if err := saveWikiManifest(absRoot, m); err != nil { + return nil, err + } + return newCards, nil +} + +// MarkDocNoConcept 記錄「這份文件沒有可萃取概念」——不產卡,但 00-INDEX 一定列它 +// (使用者要能分辨「沒產出」和「被漏掉」;規範差距 #10)。 +func MarkDocNoConcept(absRoot, relPath, reason string, now time.Time) error { + node, base := docNodeAndPath(absRoot, relPath) + nodeKey := nodeKeyOf(node) + m := loadWikiManifest(absRoot) + old := m.removeDoc(nodeKey, base) + created := todayOf(now) + if old != nil && old.Created != "" { + created = old.Created + } + // 上一輪若產過卡,這一輪判空=內容變了:舊卡要收走,不留過期整理稿。 + if old != nil { + for _, oldRel := range old.Cards { + if strings.Contains(oldRel, wikiRelDir+"/") { + _ = os.Remove(filepath.Join(absRoot, filepath.FromSlash(oldRel))) + } + } + } + m.Docs = append(m.Docs, wikiDoc{ + Node: nodeKey, Path: base, + DocID: docIDOf(nodeKey, base), + Status: "no_concept", + Reason: firstNonEmpty(reason, "純紀錄,無可萃取概念"), + Created: created, Updated: todayOf(now), + }) + if err := regenerateWikiIndexes(absRoot, m); err != nil { + return err + } + return saveWikiManifest(absRoot, m) +} + +// RemoveWikiDoc 原稿消失時,收走它的卡並把它從索引與 manifest 移除。 +func RemoveWikiDoc(absRoot, relPath string) error { + node, base := docNodeAndPath(absRoot, relPath) + nodeKey := nodeKeyOf(node) + m := loadWikiManifest(absRoot) + old := m.removeDoc(nodeKey, base) + if old == nil { + return nil + } + for _, oldRel := range old.Cards { + if strings.Contains(oldRel, wikiRelDir+"/") { + _ = os.Remove(filepath.Join(absRoot, filepath.FromSlash(oldRel))) + } + } + if err := regenerateWikiIndexes(absRoot, m); err != nil { + return err + } + return saveWikiManifest(absRoot, m) +} + +func docIDOf(nodeKey, base string) string { + sum := sha256.Sum256([]byte(nodeKey + "/" + base)) + return hex.EncodeToString(sum[:8]) +} + +// ── 索引:完全從 manifest 機械重算(index 是導出資料,不是第二份真相)── + +// regenerateWikiIndexes 重寫「有文件的節點+它們所有祖先」的 00-INDEX.md。 +// index 每行五樣(連結/摘要/標籤/建立日/更新日)全部照抄卡的 frontmatter +// (記在 manifest)——這正是 index 可以零 LLM 成本機械產生的原因(規範第一之五部)。 +func regenerateWikiIndexes(absRoot string, m *wikiManifest) error { + need := map[string]bool{"": true} // 根節點永遠要有(manifest 住在那裡) + for _, d := range m.Docs { + n := nodeFromKey(d.Node) + need[n] = true + for n != "" { + n = parentNode(n) + need[n] = true + } + } + for node := range need { + if err := writeNodeIndex(absRoot, node, m); err != nil { + return err + } + } + return nil +} + +func parentNode(node string) string { + d := path.Dir(node) + if d == "." || d == "/" { + return "" + } + return d +} + +// childNodesOf 回傳 node 的「直接子節點」(底下(含各層)有文件的直接子目錄)+各自篇數。 +func childNodesOf(node string, m *wikiManifest) ([]string, map[string]int) { + prefix := "" + if node != "" { + prefix = node + "/" + } + count := map[string]int{} + for _, d := range m.Docs { + n := nodeFromKey(d.Node) + if n == node { + continue + } + if prefix != "" && !strings.HasPrefix(n, prefix) { + continue + } + rest := strings.TrimPrefix(n, prefix) + if rest == "" { + continue + } + child := strings.SplitN(rest, "/", 2)[0] + count[child]++ + } + var kids []string + for k := range count { + kids = append(kids, k) + } + sort.Strings(kids) + return kids, count +} + +func writeNodeIndex(absRoot, node string, m *wikiManifest) error { + nodeKey := nodeKeyOf(node) + var docs []wikiDoc + for _, d := range m.Docs { + if d.Node == nodeKey { + docs = append(docs, d) + } + } + sort.Slice(docs, func(i, j int) bool { return docs[i].Path < docs[j].Path }) + kids, kidCount := childNodesOf(node, m) + + var b strings.Builder + b.WriteString("# 00-INDEX\n\n") + if len(kids) > 0 { + b.WriteString("## 子節點\n") + for _, k := range kids { + b.WriteString("- 📁 `" + k + "/` — 內含 " + itoa(kidCount[k]) + " 篇文件\n") + } + b.WriteString("\n") + } + b.WriteString("## 文件\n") + if len(docs) == 0 { + b.WriteString("(本層沒有文件;見子節點。)\n") + } + for _, d := range docs { + if d.Status == "no_concept" { + b.WriteString("- `" + d.Path + "` — 空:" + d.Reason + "\n") + continue + } + tags := make([]string, 0, len(d.Tags)) + for _, t := range d.Tags { + tags = append(tags, "#"+t) + } + b.WriteString("- [[" + d.Card + "]] — " + d.Gloss + "(原稿 `" + d.Path + "`)\n") + b.WriteString(" " + strings.Join(tags, " ") + " 建 " + d.Created + " 更 " + d.Updated + "\n") + } + b.WriteString("\n## 說明\n") + b.WriteString("本索引由 Arcrun RAG 自動維護(機械產生,改了會被覆寫)。\n") + b.WriteString("「空」表示該檔沒有可萃取的概念(例如純紀錄),不是被漏掉。\n") + b.WriteString("每張文件卡是那份原稿的 hub;概念卡從文件卡點進去。\n") + + dest := filepath.Join(wikiDirFor(absRoot, node), "00-INDEX.md") + return writeWikiFile(absRoot, dest, []byte(b.String())) +} diff --git a/wikishape_test.go b/wikishape_test.go new file mode 100644 index 0000000..782c8e9 --- /dev/null +++ b/wikishape_test.go @@ -0,0 +1,287 @@ +// wikishape_test.go — 塑形層的格式保證(InkStoneCo#44 ④,逐項對著 wiki-lint.py 寫)。 +package collector + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +var wsNow = time.Date(2026, 8, 15, 12, 0, 0, 0, time.UTC) + +func wsExtract() *DocExtract { + return &DocExtract{ + Gloss: "測試文件的一句話", + Tags: []string{"測試", "規範"}, + Summary: "一段給只需要知道大概的人看的摘要。", + Points: []string{"整份文件的重心其實在 [[概念甲]],其餘是背景", "而 [[概念乙]] 是它的反例"}, + Concepts: []WikiConcept{ + { + Name: "概念甲", Gloss: "甲的一句話", Tags: []string{"測試"}, + Summary: "甲的摘要。", Points: []string{"甲的第一個判斷"}, + Entities: []WikiEntity{{Name: "甲", Type: "概念", Desc: "主角"}}, + Facts: [][]string{{"甲", "屬於", "測試域"}}, + Relations: []WikiRelation{{To: "概念乙", Pred: "對照"}}, + }, + { + Name: "概念乙", Gloss: "乙的一句話", + Summary: "乙的摘要。", Points: []string{"乙的判斷"}, + }, + }, + } +} + +// 格式逐項:frontmatter 四欄/← 上層/四段/實體帶類型/關聯三小節/出處指得到原稿。 +func TestBuildWikiDoc_CardShapeMatchesSpec(t *testing.T) { + root := t.TempDir() + src := "# 測試文件\n\n內文若干" + mustWrite(t, filepath.Join(root, "測試文件.md"), src) + + cards, err := BuildWikiDoc(root, "測試文件.md", src, wsExtract(), wsNow) + if err != nil { + t.Fatal(err) + } + if len(cards) != 3 || cards[0] != ".wiki/測試文件.md" { + t.Fatalf("cards=%v", cards) + } + doc := mustRead(t, filepath.Join(root, ".wiki", "測試文件.md")) + for _, want := range []string{ + "tags: [測試, 規範]", "gloss: 測試文件的一句話", "created: 2026-08-15", "updated: 2026-08-15", + "# 測試文件", "← [[00-INDEX]]", "## 摘要", "## 重點", "## 實體", "## 關聯", + "### 內文知識關係", "### 卡片關係", "### 出處", + "[[概念甲]]", "[[概念乙]]", + } { + if !strings.Contains(doc, want) { + t.Fatalf("文件卡缺「%s」:\n%s", want, doc) + } + } + // 出處路徑相對 .wiki,真的指得到原稿(lint「出處路徑指不到原稿」項) + if !strings.Contains(doc, "`../測試文件.md`") { + t.Fatalf("出處路徑不對:\n%s", doc) + } + ca := mustRead(t, filepath.Join(root, ".wiki", "概念甲.md")) + for _, want := range []string{ + "← [[測試文件]]", + "- **甲**(概念)— 主角", // 實體帶類型(規範:類型不能省) + } { + if !strings.Contains(ca, want) { + t.Fatalf("概念卡缺「%s」:\n%s", want, ca) + } + } + // 乙沒給實體 → 自指 fallback(每張卡至少一個帶類型的實體) + cb := mustRead(t, filepath.Join(root, ".wiki", "概念乙.md")) + if !strings.Contains(cb, "- **概念乙**(概念)—") { + t.Fatalf("概念乙缺實體 fallback:\n%s", cb) + } +} + +// 雙向連結保證:甲→乙有邊、乙沒回邊 → 機械補「相關」反向邊(lint「單向連結」項)。 +// 出自/整理出 這對也天然雙向(lint 孤島卡項)。 +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 { + t.Fatal(err) + } + cb := mustRead(t, filepath.Join(root, ".wiki", "概念乙.md")) + if !strings.Contains(cb, "[[概念乙]]"+triSep+"相關"+triSep+"[[概念甲]]") { + t.Fatalf("缺機械補上的反向邊:\n%s", cb) + } +} + +// 連結閉合:指不到的 [[連結]] 拆殼;index 式重點行被改寫(lint 斷連結/hub index 式兩項)。 +func TestBuildWikiDoc_LinksClosedAndIndexishFixed(t *testing.T) { + root := t.TempDir() + src := "# 文件\n內文" + mustWrite(t, filepath.Join(root, "文件.md"), src) + ex := wsExtract() + ex.Points = []string{"[[概念甲]] — 這行是 index 式,要被改寫", "這裡提到 [[不存在的卡]] 應拆殼"} + if _, err := BuildWikiDoc(root, "文件.md", src, ex, wsNow); err != nil { + t.Fatal(err) + } + doc := mustRead(t, filepath.Join(root, ".wiki", "文件.md")) + if strings.Contains(doc, "[[不存在的卡]]") { + t.Fatalf("留下了斷連結:\n%s", doc) + } + if !strings.Contains(doc, "- 關於 [[概念甲]]:") { + t.Fatalf("index 式重點行沒被改寫:\n%s", doc) + } +} + +// 標空(差距 #10):no_concept 不產卡,但 00-INDEX 的「## 文件」一定列它+理由。 +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) + if err != nil { + t.Fatal(err) + } + if len(cards) != 0 { + t.Fatalf("標空不該產卡:%v", cards) + } + idx := mustRead(t, filepath.Join(root, ".wiki", "00-INDEX.md")) + if !strings.Contains(idx, "## 文件") || !strings.Contains(idx, "- `發票.txt` — 空:純紀錄(發票)") { + t.Fatalf("00-INDEX 沒把空檔列出來:\n%s", idx) + } +} + +// 巢狀節點:卡落在 `<節點>/.wiki/`;祖先鏈的 00-INDEX 有「## 子節點」; +// manifest 的 node/path 用 wiki-lint 讀得懂的鍵。 +func TestBuildWikiDoc_NestedNodeAndAncestors(t *testing.T) { + root := t.TempDir() + rel := filepath.Join("專案", "會議") + mustMkdir(t, filepath.Join(root, rel)) + src := "# 週會決議\n內容" + mustWrite(t, filepath.Join(root, rel, "週會.md"), src) + + if _, err := BuildWikiDoc(root, "專案/會議/週會.md", src, wsExtract(), wsNow); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(root, "專案", "會議", ".wiki", "週會決議.md")); err != nil { + t.Fatalf("卡沒落在節點的 .wiki:%v", err) + } + for _, p := range []string{ + filepath.Join(root, ".wiki", "00-INDEX.md"), + filepath.Join(root, "專案", ".wiki", "00-INDEX.md"), + filepath.Join(root, "專案", "會議", ".wiki", "00-INDEX.md"), + } { + if _, err := os.Stat(p); err != nil { + t.Fatalf("祖先索引缺:%s(%v)", p, err) + } + } + rootIdx := mustRead(t, filepath.Join(root, ".wiki", "00-INDEX.md")) + if !strings.Contains(rootIdx, "## 子節點") || !strings.Contains(rootIdx, "`專案/`") { + t.Fatalf("根索引缺子節點導覽:\n%s", rootIdx) + } + var man wikiManifest + data := mustRead(t, filepath.Join(root, ".wiki", "manifest.json")) + if err := json.Unmarshal([]byte(data), &man); err != nil { + t.Fatal(err) + } + if len(man.Docs) != 1 || man.Docs[0].Node != "專案/會議" || man.Docs[0].Path != "週會.md" { + t.Fatalf("manifest 鍵不對:%+v", man.Docs) + } +} + +// 重萃同一份文件:舊卡被收走、索引不重複、卡名變了也不留孤兒。 +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 { + 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 { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(root, ".wiki", "概念甲.md")); !os.IsNotExist(err) { + t.Fatal("上一輪的舊概念卡沒被收走") + } + if _, err := os.Stat(filepath.Join(root, ".wiki", "全新概念.md")); err != nil { + t.Fatal("新概念卡沒落地") + } + // created 保留第一輪、updated 是第二輪 + doc := mustRead(t, filepath.Join(root, ".wiki", "文件.md")) + if !strings.Contains(doc, "created: 2026-08-15") || !strings.Contains(doc, "updated: 2026-08-16") { + t.Fatalf("created/updated 不對:\n%.200s", doc) + } +} + +// 下架:原稿消失 → 卡、索引條目、manifest 條目一起收走。 +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 { + t.Fatal(err) + } + if err := RemoveWikiDoc(root, "文件.md"); err != nil { + t.Fatal(err) + } + for _, name := range []string{"文件.md", "概念甲.md", "概念乙.md"} { + if _, err := os.Stat(filepath.Join(root, ".wiki", name)); !os.IsNotExist(err) { + t.Fatalf("%s 沒被收走", name) + } + } + idx := mustRead(t, filepath.Join(root, ".wiki", "00-INDEX.md")) + if strings.Contains(idx, "文件.md") { + t.Fatalf("索引還列著已下架的文件:\n%s", idx) + } +} + +// 不同文件、同名概念:後到者消歧(+文件卡名),不覆蓋先到者(merge 是第⑤環的題目)。 +func TestBuildWikiDoc_SameConceptFromTwoDocsDisambiguated(t *testing.T) { + root := t.TempDir() + srcA := "# 甲文\n內文" + srcB := "# 乙文\n內文" + mustWrite(t, filepath.Join(root, "a.md"), srcA) + mustWrite(t, filepath.Join(root, "b.md"), srcB) + ex := func() *DocExtract { + return &DocExtract{Gloss: "一句話", Summary: "摘要", + Points: []string{"重點連到 [[迭代]]"}, + Concepts: []WikiConcept{{Name: "迭代", Gloss: "同名概念", Summary: "摘要", Points: []string{"判斷"}}}} + } + if _, err := BuildWikiDoc(root, "a.md", srcA, ex(), wsNow); err != nil { + t.Fatal(err) + } + if _, err := BuildWikiDoc(root, "b.md", srcB, ex(), wsNow); err != nil { + t.Fatal(err) + } + first := mustRead(t, filepath.Join(root, ".wiki", "迭代.md")) + if !strings.Contains(first, "← [[甲文]]") { + t.Fatalf("先到者被動過:\n%.200s", first) + } + if _, err := os.Stat(filepath.Join(root, ".wiki", "迭代(乙文).md")); err != nil { + t.Fatalf("後到者沒有消歧落地:%v", err) + } + // 乙文的重點連結要指向消歧後的名字(不留斷連結、不指錯人) + docB := mustRead(t, filepath.Join(root, ".wiki", "乙文.md")) + if strings.Contains(docB, "[[迭代]]") || !strings.Contains(docB, "[[迭代(乙文)]]") { + t.Fatalf("乙文的連結沒有跟著消歧改名:\n%s", docB) + } +} + +// index 每行五樣(連結/摘要/標籤/建立日/更新日),全部照抄 frontmatter(lint idx_thin 項)。 +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 { + t.Fatal(err) + } + idx := mustRead(t, filepath.Join(root, ".wiki", "00-INDEX.md")) + if !strings.Contains(idx, "- [[測試文件]] — 測試文件的一句話(原稿 `測試文件.md`)") { + t.Fatalf("index 行缺連結/摘要/原稿名:\n%s", idx) + } + if !strings.Contains(idx, "#測試 #規範 建 2026-08-15 更 2026-08-15") { + t.Fatalf("index 行缺標籤/日期:\n%s", idx) + } +} + +func mustWrite(t *testing.T, p, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +func mustRead(t *testing.T, p string) string { + t.Helper() + data, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + return string(data) +}