// Wiki 贡献级差异:页面身份是 slug,语义拆成「正文 / 来源文档 / 引用分块 / 出边 / 入边 / 目录」。 // // 为什么不能只比页面级:删除一篇与别人共同贡献同一页的文档后,页面还在、名字还在, // 只有那篇文档的正文与引用被摘掉。页面级差异会把这种"语义重算"显示成"无变化", // 操作员据此会误判撤销没有生效。这里把每类语义单独取集合再比,撤销的那份贡献 // 必须能在 sources/citations/links/content 上被指出来。 // // 边界:本包只读归档里的 wiki_pages 行,**不重算** WeKnora 的实体/概念/边。 // 语义重算发生在源侧,治理侧能做的是把源侧算完的结果如实比对与保留。 package librarydiff import ( "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "sort" "strings" ) // volatileWikiFields 是每次保存都会变、但不代表页面语义变化的列。 var volatileWikiFields = []string{"updated_at", "updated_by", "created_at", "view_count"} type WikiContribution struct { Slug string `json:"slug"` Title string `json:"title"` PageType string `json:"page_type"` Status string `json:"status"` // added | updated | removed // Changed 是发生变化(或被删除)的语义类别:content / sources / citations / // out_links / in_links / category / status / page_type。撤销一份贡献时, // sources 与 citations 必须出现,正文没变也要标出来。 Changed []string `json:"changed"` SourcesAdded []string `json:"sources_added"` SourcesRemoved []string `json:"sources_removed"` CitationsBefore int `json:"citations_before"` CitationsAfter int `json:"citations_after"` CitationsAdded []string `json:"citations_added,omitempty"` CitationsRemoved []string `json:"citations_removed,omitempty"` LinksAdded []string `json:"links_added,omitempty"` LinksRemoved []string `json:"links_removed,omitempty"` // ContentDigestBefore/After 让证据可以复核"正文真的变了",而不是只凭长度。 ContentDigestBefore string `json:"content_digest_before,omitempty"` ContentDigestAfter string `json:"content_digest_after,omitempty"` } type WikiResult struct { State string `json:"state"` // computed Added int `json:"added"` Updated int `json:"updated"` Removed int `json:"removed"` // SourcesAdded/Removed 是所有页面来源变化的合计,便于一眼看到"撤销了谁的贡献"。 SourcesAdded int `json:"sources_added"` SourcesRemoved int `json:"sources_removed"` BaseCount int `json:"base_count"` TargetCount int `json:"target_count"` Pages []WikiContribution `json:"pages"` } type wikiPage struct { slug string title string pageType string status string content string sources []string citations []string outLinks []string inLinks []string category string } // CompareWiki 以 base 为基准(当前生产版本),target 为拟发布版本,逐页比对语义。 func CompareWiki(base, target []map[string]any) (WikiResult, error) { basePages, err := indexWiki(base) if err != nil { return WikiResult{}, err } targetPages, err := indexWiki(target) if err != nil { return WikiResult{}, err } result := WikiResult{State: "computed", Pages: []WikiContribution{}, BaseCount: len(basePages), TargetCount: len(targetPages)} for slug, next := range targetPages { previous, ok := basePages[slug] if !ok { result.Added++ result.Pages = append(result.Pages, WikiContribution{ Slug: next.slug, Title: next.title, PageType: next.pageType, Status: "added", Changed: []string{"added"}, SourcesAdded: next.sources, CitationsAfter: len(next.citations), ContentDigestAfter: digestOf(next.content), }) result.SourcesAdded += len(next.sources) continue } change, changed := diffWikiPage(previous, next) if !changed { continue } result.Updated++ result.SourcesAdded += len(change.SourcesAdded) result.SourcesRemoved += len(change.SourcesRemoved) result.Pages = append(result.Pages, change) } for slug, previous := range basePages { if _, ok := targetPages[slug]; ok { continue } result.Removed++ result.Pages = append(result.Pages, WikiContribution{ Slug: previous.slug, Title: previous.title, PageType: previous.pageType, Status: "removed", Changed: []string{"removed"}, SourcesRemoved: previous.sources, CitationsBefore: len(previous.citations), ContentDigestBefore: digestOf(previous.content), }) result.SourcesRemoved += len(previous.sources) } sort.Slice(result.Pages, func(i, j int) bool { return result.Pages[i].Slug < result.Pages[j].Slug }) return result, nil } func diffWikiPage(previous, next wikiPage) (WikiContribution, bool) { change := WikiContribution{Slug: next.slug, Title: next.title, PageType: next.pageType, Status: "updated", CitationsBefore: len(previous.citations), CitationsAfter: len(next.citations), ContentDigestBefore: digestOf(previous.content), ContentDigestAfter: digestOf(next.content)} change.SourcesAdded, change.SourcesRemoved = setDiff(next.sources, previous.sources), setDiff(previous.sources, next.sources) change.CitationsAdded, change.CitationsRemoved = setDiff(next.citations, previous.citations), setDiff(previous.citations, next.citations) change.LinksAdded, change.LinksRemoved = setDiff(next.outLinks, previous.outLinks), setDiff(previous.outLinks, next.outLinks) if previous.content != next.content { change.Changed = append(change.Changed, "content") } if len(change.SourcesAdded)+len(change.SourcesRemoved) > 0 { change.Changed = append(change.Changed, "sources") } if len(change.CitationsAdded)+len(change.CitationsRemoved) > 0 { change.Changed = append(change.Changed, "citations") } if len(change.LinksAdded)+len(change.LinksRemoved) > 0 { change.Changed = append(change.Changed, "out_links") } if added, removed := setDiff(next.inLinks, previous.inLinks), setDiff(previous.inLinks, next.inLinks); len(added)+len(removed) > 0 { change.Changed = append(change.Changed, "in_links") } if previous.category != next.category { change.Changed = append(change.Changed, "category") } if previous.status != next.status { change.Changed = append(change.Changed, "status") } if previous.pageType != next.pageType { change.Changed = append(change.Changed, "page_type") } return change, len(change.Changed) > 0 } func indexWiki(rows []map[string]any) (map[string]wikiPage, error) { pages := make(map[string]wikiPage, len(rows)) for _, row := range rows { slug, _ := row["slug"].(string) slug = strings.TrimSpace(slug) if slug == "" { return nil, errors.New("wiki row without slug") } if _, exists := pages[slug]; exists { return nil, fmt.Errorf("duplicate wiki slug %s", slug) } page := wikiPage{slug: slug} page.title, _ = row["title"].(string) page.pageType, _ = row["page_type"].(string) page.status, _ = row["status"].(string) page.content, _ = row["content"].(string) var err error if page.sources, err = stringList(row["source_refs"]); err != nil { return nil, fmt.Errorf("wiki %s source_refs: %w", slug, err) } if page.citations, err = stringList(row["chunk_refs"]); err != nil { return nil, fmt.Errorf("wiki %s chunk_refs: %w", slug, err) } if page.outLinks, err = stringList(row["out_links"]); err != nil { return nil, fmt.Errorf("wiki %s out_links: %w", slug, err) } if page.inLinks, err = stringList(row["in_links"]); err != nil { return nil, fmt.Errorf("wiki %s in_links: %w", slug, err) } category, err := stringList(row["category_path"]) if err != nil { return nil, fmt.Errorf("wiki %s category_path: %w", slug, err) } if folder, ok := row["folder_id"].(string); ok && folder != "" { category = append(category, folder) } page.category = strings.Join(category, "/") pages[slug] = page } return pages, nil } // stringList 同时接受 JSON 数组与字符串化数组;其他形状一律报错, // 避免"读不出来就当空集合"把撤销误判成没有来源。 func stringList(value any) ([]string, error) { switch typed := value.(type) { case nil: return nil, nil case []string: return append([]string{}, typed...), nil case []any: out := make([]string, 0, len(typed)) for _, item := range typed { text, ok := item.(string) if !ok { return nil, fmt.Errorf("non-string item %T", item) } out = append(out, text) } return out, nil case string: if strings.TrimSpace(typed) == "" { return nil, nil } var out []string if err := json.Unmarshal([]byte(typed), &out); err != nil { return nil, err } return out, nil default: return nil, fmt.Errorf("unsupported value %T", value) } } // setDiff 返回 left 有、right 没有的元素(升序)。 func setDiff(left, right []string) []string { seen := make(map[string]bool, len(right)) for _, item := range right { seen[item] = true } out := make([]string, 0) for _, item := range left { if seen[item] { continue } seen[item] = true out = append(out, item) } sort.Strings(out) return out } func digestOf(text string) string { sum := sha256.Sum256([]byte(text)) return hex.EncodeToString(sum[:]) } // changedLabels 把语义类别翻成给操作员看的词,界面不用各自维护一份。 func ChangedLabels(changed []string) []string { labels := map[string]string{ "content": "正文", "sources": "来源文档", "citations": "引用分块", "out_links": "出边", "in_links": "入边", "category": "目录", "status": "状态", "page_type": "页面类型", "added": "新增", "removed": "整页撤销", } out := make([]string, 0, len(changed)) for _, name := range changed { if label, ok := labels[name]; ok { out = append(out, label) } else { out = append(out, name) } } return out }