6440e3f679
- 迁移 000044:production_kb_bindings + release_item_executions.target_knowledge_base_id - 系统设置新增「生产知识库绑定(对内/对外)」:从生产 WeKnora 实际列表选库, 保存时真校验(不存在拒收;生产端不可达只提示并如实回报),仅管理员可改 - 执行计划按绑定解析目标库并冻结进 release_item_executions;空目标直接拒收; 已发布过的条目保留原目标(重试/回滚回到同一处) - DocumentBundle.RemapKnowledgeBase:knowledges/chunks/knowledge_tags/wiki_pages/ wiki_folders 的 knowledge_base_id 与 ES 文档字段改写为目标库,重算表/文档摘要并重新封装 - 契约比对改为「声明的映射对」(KnowledgeBaseMapping),其余字段继续 fail-closed - 契约检查器 / 目标身份 / 验证器全部走映射后的目标库;对象通道无需改写 - Compose 拆分:数据面接线 compose.release-sim.yaml + 两道门禁 compose.release-gates.yaml - 测试:RemapKnowledgeBase 全通道/无操作/fail-closed、映射契约、绑定接口、RBAC、真实 PG 集成
208 lines
6.9 KiB
Go
208 lines
6.9 KiB
Go
package v071
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"knowledge-governance/internal/platform/weknora"
|
|
releaseworkflow "knowledge-governance/internal/workflows/release"
|
|
)
|
|
|
|
type WeKnoraVerificationClient interface {
|
|
GetKnowledge(ctx context.Context, knowledgeID string) (weknora.Knowledge, error)
|
|
ListChunks(ctx context.Context, knowledgeID string, page, pageSize int, chunkTypes []string) ([]weknora.Chunk, int64, error)
|
|
SearchKnowledge(ctx context.Context, request weknora.SearchRequest) ([]weknora.SearchResult, error)
|
|
ListWikiPages(ctx context.Context, knowledgeBaseID string, page, pageSize int) ([]weknora.WikiPageSummary, int64, error)
|
|
}
|
|
|
|
type WeKnoraVerifier struct {
|
|
client WeKnoraVerificationClient
|
|
}
|
|
|
|
func NewWeKnoraVerifier(client WeKnoraVerificationClient) (*WeKnoraVerifier, error) {
|
|
if client == nil {
|
|
return nil, errors.New("WeKnora verification client is required")
|
|
}
|
|
return &WeKnoraVerifier{client: client}, nil
|
|
}
|
|
|
|
func (verifier *WeKnoraVerifier) Verify(ctx context.Context, item releaseworkflow.PlanItem, bundle *DocumentBundle) error {
|
|
if bundle == nil || validateDocumentBundle(bundle) != nil || bundle.Manifest.Contract.KnowledgeID != item.KnowledgeID || bundle.Manifest.Contract.KnowledgeBaseID != item.TestKnowledgeBaseID {
|
|
return errors.New("invalid WeKnora verification bundle")
|
|
}
|
|
knowledge, err := verifier.client.GetKnowledge(ctx, item.KnowledgeID)
|
|
if err != nil || knowledge.ID != item.KnowledgeID || knowledge.KnowledgeBaseID != item.TargetBase() || knowledge.ParseStatus != "completed" {
|
|
return errors.New("production WeKnora knowledge verification failed")
|
|
}
|
|
_, chunkTotal, err := verifier.client.ListChunks(ctx, item.KnowledgeID, 1, 1, bundleChunkTypes(bundle))
|
|
expectedChunks := bundle.Postgres.Table("chunks").RowCount
|
|
if err != nil || expectedChunks < 1 || chunkTotal != int64(expectedChunks) {
|
|
return errors.New("production WeKnora chunk verification failed")
|
|
}
|
|
query := bundleProbeQuery(bundle)
|
|
if query == "" {
|
|
return errors.New("production WeKnora retrieval probe is unavailable")
|
|
}
|
|
results, err := verifier.client.SearchKnowledge(ctx, weknora.SearchRequest{Query: query, KnowledgeIDs: []string{item.KnowledgeID}})
|
|
if err != nil {
|
|
return errors.New("production WeKnora retrieval verification failed")
|
|
}
|
|
for _, result := range results {
|
|
if result.KnowledgeID == item.KnowledgeID {
|
|
return nil
|
|
}
|
|
}
|
|
return errors.New("production WeKnora retrieval verification failed")
|
|
}
|
|
|
|
func (verifier *WeKnoraVerifier) VerifyDisabled(ctx context.Context, item releaseworkflow.PlanItem) error {
|
|
if !validSearchIdentifier(item.KnowledgeID) {
|
|
return errors.New("invalid disabled WeKnora verification plan")
|
|
}
|
|
results, err := verifier.client.SearchKnowledge(ctx, weknora.SearchRequest{
|
|
Query: "governance retrieval disable verification sentinel",
|
|
KnowledgeIDs: []string{item.KnowledgeID},
|
|
})
|
|
if err != nil {
|
|
return errors.New("disabled WeKnora retrieval verification failed")
|
|
}
|
|
for _, result := range results {
|
|
if result.KnowledgeID == item.KnowledgeID {
|
|
return errors.New("disabled WeKnora knowledge remains retrievable")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// bundleChunkTypes lists the distinct chunk_type values sealed in the bundle.
|
|
// WeKnora's chunk list endpoint defaults to text-only results, so verification
|
|
// must ask for exactly the types the released document contains.
|
|
func bundleChunkTypes(bundle *DocumentBundle) []string {
|
|
seen := map[string]bool{}
|
|
types := make([]string, 0, 3)
|
|
for _, raw := range bundle.Postgres.Table("chunks").Rows {
|
|
var row struct {
|
|
ChunkType string `json:"chunk_type"`
|
|
}
|
|
if json.Unmarshal(raw, &row) != nil {
|
|
continue
|
|
}
|
|
chunkType := strings.TrimSpace(row.ChunkType)
|
|
if chunkType == "" || seen[chunkType] || !validSearchIdentifier(chunkType) {
|
|
continue
|
|
}
|
|
seen[chunkType] = true
|
|
types = append(types, chunkType)
|
|
}
|
|
return types
|
|
}
|
|
|
|
func bundleProbeQuery(bundle *DocumentBundle) string {
|
|
for _, document := range bundle.Search.Documents {
|
|
var value map[string]any
|
|
if json.Unmarshal(document.Source, &value) != nil {
|
|
continue
|
|
}
|
|
for _, key := range []string{"content", "question", "answer", "title"} {
|
|
if text, ok := value[key].(string); ok {
|
|
if query := boundedProbeText(text); query != "" {
|
|
return query
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for _, row := range bundle.Postgres.Table("knowledges").Rows {
|
|
var value struct {
|
|
Title string `json:"title"`
|
|
}
|
|
if json.Unmarshal(row, &value) == nil {
|
|
if query := boundedProbeText(value.Title); query != "" {
|
|
return query
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func boundedProbeText(value string) string {
|
|
value = strings.Join(strings.Fields(value), " ")
|
|
if value == "" {
|
|
return ""
|
|
}
|
|
if utf8.RuneCountInString(value) <= 128 {
|
|
return value
|
|
}
|
|
runes := []rune(value)
|
|
return string(runes[:128])
|
|
}
|
|
|
|
// VerifyWiki 校验生产端 wiki 与本次快照一致:
|
|
// - 页面 slug 集合必须与快照完全相同(多一个少一个都算失败,这样“撤权后
|
|
// 生产还留着已撤权文档的页面”会被抓住);
|
|
// - 撤权(delete)时生产 wiki 任何页面都不得再引用被撤权的文档。
|
|
//
|
|
// 老包 / 未启用 wiki 的库不携带 wiki 快照,直接跳过(生产 wiki 保持原样)。
|
|
func (verifier *WeKnoraVerifier) VerifyWiki(ctx context.Context, item releaseworkflow.PlanItem, bundle *DocumentBundle) error {
|
|
if bundle == nil || validateDocumentBundle(bundle) != nil {
|
|
return errors.New("invalid WeKnora wiki verification bundle")
|
|
}
|
|
if !hasWikiSnapshot(bundle.Postgres) {
|
|
return nil
|
|
}
|
|
expected := map[string]bool{}
|
|
for _, raw := range bundle.Postgres.Table("wiki_pages").Rows {
|
|
var row struct {
|
|
Slug string `json:"slug"`
|
|
}
|
|
if json.Unmarshal(raw, &row) != nil || strings.TrimSpace(row.Slug) == "" {
|
|
return errors.New("invalid WeKnora wiki verification bundle")
|
|
}
|
|
expected[row.Slug] = true
|
|
}
|
|
const pageSize = 20
|
|
const maxPageBudget = 1000
|
|
seen := map[string]bool{}
|
|
scanned := 0
|
|
for page := 1; ; page++ {
|
|
pages, total, err := verifier.client.ListWikiPages(ctx, item.TargetBase(), page, pageSize)
|
|
if err != nil {
|
|
return errors.New("production WeKnora wiki verification failed")
|
|
}
|
|
for _, wikiPage := range pages {
|
|
if strings.TrimSpace(wikiPage.Slug) == "" {
|
|
continue
|
|
}
|
|
seen[wikiPage.Slug] = true
|
|
if item.Action != "delete" {
|
|
continue
|
|
}
|
|
for _, reference := range wikiPage.SourceRefs {
|
|
if referencesKnowledge(reference, item.KnowledgeID) {
|
|
return errors.New("production WeKnora wiki still references the withdrawn document")
|
|
}
|
|
}
|
|
}
|
|
scanned += len(pages)
|
|
if len(pages) == 0 || int64(scanned) >= total {
|
|
break
|
|
}
|
|
if page >= maxPageBudget {
|
|
return errors.New("production WeKnora wiki verification exceeded its page budget")
|
|
}
|
|
}
|
|
if len(seen) != len(expected) {
|
|
return errors.New("production WeKnora wiki page set mismatch")
|
|
}
|
|
for slug := range expected {
|
|
if !seen[slug] {
|
|
return errors.New("production WeKnora wiki page set mismatch")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var _ ProductionVerifier = (*WeKnoraVerifier)(nil)
|