Files
knowledge-governance/internal/releaseadapter/v071/remap.go
T
姚章浩 6440e3f679 feat(release): 发布目标知识库可选(源库 → 目标库映射)
- 迁移 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 集成
2026-10-08 15:41:13 +08:00

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, &current); 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}
}