Files
姚章浩 1f482e4b49
build / build (push) Successful in 1m18s
feat(library): Wiki 撤销按贡献比对,维护窗口在页面如实呈现
为什么:删除一篇与别人共同贡献同一 Wiki 页的文档后,页面还在、名字也在,
只有那篇文档的正文与引用被摘掉。原来的页面级差异会把这种语义重算显示成
"无变化",操作员据此会误判撤销没有生效。

怎么改:librarydiff 新增 CompareWiki,以 slug 为身份把正文/来源文档/引用分块/
出入边/目录分别取集合比较,输出 sources_removed、citations 变化与具体类别;
overview 的 diff.wiki 增加逐页明细(最多 50 页,超出置 pages_truncated),
说明文字明确"重算发生在源侧,本平台只如实比对与保留结果"。

页面:新增目标维护面板(实例级/库级、持有、入口开关、阶段、进度、恢复错误),
只有窗口仍持有且作业失败时才给"按预备份恢复";没有维护记录时写未接入 + fail closed。
变更页把 Wiki 逐页差异列出来源/引用怎么变。kind 补上 restore。

验过:go test ./internal/librarydiff ./internal/httpapi;web vitest 453 项;
make verify 全绿(15 项,无未运行项)。真实撤销语义验收见交付报告。
2026-10-29 04:45:34 +08:00

270 lines
9.7 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}