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 集成
140 lines
5.0 KiB
Go
140 lines
5.0 KiB
Go
package v071
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
|
|
"knowledge-governance/internal/releasebundle"
|
|
)
|
|
|
|
// knowledgeBaseField is the single place a knowledge-base identity lives inside
|
|
// an exported artefact. Verified by scanning every exported row of a real
|
|
// knowledge: the base id appears exactly once per row, in this field.
|
|
const knowledgeBaseField = "knowledge_base_id"
|
|
|
|
// remappableTables are the exported tables whose rows carry that identity.
|
|
// resources / resource_bindings / knowledge_tag_relations carry none: they hang
|
|
// off the knowledge id, the resource handle and the tag id respectively.
|
|
var remappableTables = []string{"knowledges", "chunks", "knowledge_tags", "wiki_pages", "wiki_folders"}
|
|
|
|
// RemapKnowledgeBase rewrites the bundle's knowledge-base identity from the
|
|
// tested base to the target base and re-seals it. Without this, a cross-base
|
|
// release would be rejected by the fail-closed guards that assert every row and
|
|
// document sits inside the identity scope it is written under.
|
|
//
|
|
// The manifest keeps the SOURCE base on purpose — that is where the content came
|
|
// from, and it is what the release item's tested base is checked against. The
|
|
// target base travels with the release item and is asserted separately against
|
|
// the target contract.
|
|
func (bundle *DocumentBundle) RemapKnowledgeBase(targetKnowledgeBaseID string) error {
|
|
if bundle == nil {
|
|
return errors.New("release bundle is required")
|
|
}
|
|
if !validSearchIdentifier(targetKnowledgeBaseID) {
|
|
return errors.New("invalid remap target knowledge base")
|
|
}
|
|
source := bundle.Manifest.Contract.KnowledgeBaseID
|
|
if targetKnowledgeBaseID == source {
|
|
return nil
|
|
}
|
|
digests := make(map[string]releasebundle.TableDigest, len(remappableTables))
|
|
for _, name := range remappableTables {
|
|
table := bundle.Postgres.Table(name)
|
|
if len(table.Rows) == 0 {
|
|
continue
|
|
}
|
|
rows := make([]json.RawMessage, 0, len(table.Rows))
|
|
for _, row := range table.Rows {
|
|
rewritten, err := remapKnowledgeBaseField(row, source, targetKnowledgeBaseID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rows = append(rows, rewritten)
|
|
}
|
|
remapped := newTableSnapshot(name, rows)
|
|
digests[name] = releasebundle.TableDigest{Name: remapped.Name, RowCount: remapped.RowCount, SHA256: remapped.SHA256}
|
|
bundle.Postgres = replaceTable(bundle.Postgres, remapped)
|
|
}
|
|
searchDigest := bundle.Search.DocumentsSHA256
|
|
if len(bundle.Search.Documents) > 0 {
|
|
documents := make([]SearchDocument, 0, len(bundle.Search.Documents))
|
|
for _, document := range bundle.Search.Documents {
|
|
rewritten, err := remapKnowledgeBaseField(document.Source, source, targetKnowledgeBaseID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
documents = append(documents, SearchDocument{ID: document.ID, Source: rewritten})
|
|
}
|
|
recomputed, err := searchDocumentsDigest(documents)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
bundle.Search.Documents = documents
|
|
bundle.Search.DocumentsSHA256 = recomputed
|
|
searchDigest = recomputed
|
|
}
|
|
|
|
manifest := bundle.Manifest
|
|
tables := make([]releasebundle.TableDigest, 0, len(manifest.Tables))
|
|
for _, table := range manifest.Tables {
|
|
if remapped, ok := digests[table.Name]; ok {
|
|
tables = append(tables, remapped)
|
|
continue
|
|
}
|
|
tables = append(tables, table)
|
|
}
|
|
manifest.Tables = tables
|
|
manifest.Search = releasebundle.SearchDigest{DocumentCount: len(bundle.Search.Documents), SHA256: searchDigest}
|
|
sealed, err := releasebundle.Seal(manifest)
|
|
if err != nil {
|
|
return errors.New("reseal remapped release bundle")
|
|
}
|
|
bundle.Manifest = sealed
|
|
return nil
|
|
}
|
|
|
|
// remapKnowledgeBaseField rewrites one JSON object's base identity. Every other
|
|
// field is carried through as its original bytes (RawMessage values are never
|
|
// re-encoded), so embeddings and other payloads cannot be perturbed by a remap.
|
|
func remapKnowledgeBaseField(raw json.RawMessage, source, target string) (json.RawMessage, error) {
|
|
var object map[string]json.RawMessage
|
|
if err := json.Unmarshal(raw, &object); err != nil {
|
|
return nil, errors.New("invalid release row for knowledge base remap")
|
|
}
|
|
encoded, ok := object[knowledgeBaseField]
|
|
if !ok {
|
|
return nil, errors.New("release row is missing its knowledge base identity")
|
|
}
|
|
var current string
|
|
if err := json.Unmarshal(encoded, ¤t); err != nil || current != source {
|
|
return nil, errors.New("release row is outside the remapped knowledge base")
|
|
}
|
|
targetEncoded, err := json.Marshal(target)
|
|
if err != nil {
|
|
return nil, errors.New("encode remapped knowledge base")
|
|
}
|
|
object[knowledgeBaseField] = targetEncoded
|
|
payload, err := json.Marshal(object)
|
|
if err != nil {
|
|
return nil, errors.New("encode remapped release row")
|
|
}
|
|
return json.RawMessage(payload), nil
|
|
}
|
|
|
|
func replaceTable(snapshot PostgresSnapshot, replacement TableSnapshot) PostgresSnapshot {
|
|
tables := make([]TableSnapshot, 0, len(snapshot.Tables))
|
|
replaced := false
|
|
for _, table := range snapshot.Tables {
|
|
if table.Name == replacement.Name {
|
|
tables = append(tables, replacement)
|
|
replaced = true
|
|
continue
|
|
}
|
|
tables = append(tables, table)
|
|
}
|
|
if !replaced {
|
|
tables = append(tables, replacement)
|
|
}
|
|
return PostgresSnapshot{Tables: tables}
|
|
}
|