1f482e4b49
build / build (push) Successful in 1m18s
为什么:删除一篇与别人共同贡献同一 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 项,无未运行项)。真实撤销语义验收见交付报告。
270 lines
9.7 KiB
Go
270 lines
9.7 KiB
Go
// 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
|
||
}
|