Files
knowledge-governance/internal/librarydiff/wiki.go
姚章浩 98294b9583
build / build (push) Failing after 24s
fix(library): 统一执行身份、终态收尾、准入与数据身份
why:审计 19 项(12 P1 / 7 P2)来自同一批根因,逐点试错会把协议撕成十几份:
  · 意图与执行混为一谈(F01/F15):`library.sync:<scope>:<快照>` 是终身幂等键,
    A→B→A 的第三次发布拿到第一次的历史成功作业,什么都不做;
  · 版本行既是实体又是事件(F02/F09):主键只由 (kind,scope,快照) 决定、写入是
    upsert,第二个作业覆盖 `source_job_id`/`evidence`/`created_at`;
  · 接管没有代次(F13):两个 worker 都能认领同一 pending 作业,失租的旧执行还能
    写业务状态、释放别人的维护锁;
  · 早退与终态没有统一政策(F03/F04/F05/F06):半写窗口的前置检查失败会重新开门、
    服务起不来也照样开入口、journal 终态后收尾不可重建、恢复成功不回写 current;
  · 数据身份与准入各写一套(F07/F08/F14/F17):接管丢了复用语义、全库搬运不改
    location_hash、通用入口可造 library.* 作业、目标模型从没被问过;
  · 存储/容量/UI/CI 判据各自为政(F10/F11/F12/F16/F18/F19)。

what:按批准的一套协议集中实施(详见 handoffs/release-consolidated-implementation/
current-contract.md):
  · operation(一次明确意图)/ attempt(epoch 代次)/ 不可变版本事件 / current 指针;
    治理库迁移 76,只动治理库,不碰 WeKnora Schema/源码/镜像;
  · 所有业务状态、阶段、版本事件、维护锁写入带 epoch 守卫(失租代次写不进去);
    心跳失租立刻取消 handler;
  · 半写窗口早退保持关闭;服务未全部健康绝不开入口;journal 终态可从持久事实重建
    收尾;恢复成功登记 recovery 事件并推进 current;
  · worker 在动维护前核对业务行/kind/scope/队列关联;通用入口拒绝 library.*;
  · 全库搬运重算 location_hash 并与文档级导入共用实现;接管消费写集合的复用决定;
    发布前核目标实时模型契约、容量门禁前置;
  · 存储保护闭包改为全量依赖、类型只认显式登记、保护未知则不出可删候选;
  · 页面核 HTTP 状态与结构、恢复纳入轮询、Wiki 差异覆盖标题/摘要/父级。

how:`make verify` rc=0(16 项必需检查全绿,含 go test 全量与前端 485 例);
`go vet ./...` rc=0;`npx tsc -p tsconfig.json --noEmit` rc=0;逐项负例见各包测试
(F03/F04/F08/F12/F13/F14/F16/F18/F19 都有先红后绿记录)。
未执行:治理库迁移到任何已有库、真实 PG/ES/S3/WeKnora 集成、故障注入、部署。
2026-10-30 16:14:54 +08:00

287 lines
10 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
// summary / parentSlug 是持久业务字段:只改它们同样是"内容变了",
// 旧的比较结构根本没读这两个字段,于是发布前预览会漏报(F18)。
summary string
parentSlug 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")
}
// 标题/摘要/父级是页面身份与导航的一部分:它们在持久列里,改了就是差异,
// 不能被"正文没变"掩掉(F18)。
if previous.title != next.title {
change.Changed = append(change.Changed, "title")
}
if previous.summary != next.summary {
change.Changed = append(change.Changed, "summary")
}
if previous.parentSlug != next.parentSlug {
change.Changed = append(change.Changed, "parent_slug")
}
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)
page.summary, _ = row["summary"].(string)
page.parentSlug, _ = row["parent_slug"].(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
}