12c2d41886
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 <noreply@anthropic.com>
876 lines
30 KiB
Go
876 lines
30 KiB
Go
// 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 是知識三元組的分隔符:「主 <sep> 述 <sep> 受」。
|
||
// 拆開拼字是刻意的: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()))
|
||
}
|