Files
knowledge-governance/internal/workflows/testweknora/validate.go
姚章浩 acf053faf3 feat(release): 建批时冻结 + 验收通过自动批准 —— 把"每份文档 8 步"砍掉两步并去掉两次卡死
背景:发布管理把"逐条核验"的仪式强加给每一份文档。实测一份文档要 9~11 次交互,
且有两处顺序陷阱(冻结后批准 → 建批 422;生成即固化 → 批准被永久钉住)。

P0-1 建批时冻结:
- CreateReleaseBatch 在未指定版本时,自己把当前实时计划冻结成一份**已确认**版本并绑定
  (同一事务;同摘要复用最近版本,planned 或 confirmed 都可复用,因此同一份计划不堆版本)。
- 语义不变:批一旦建立,清单/摘要/验收轮次/目标库绑定全部不可变、回滚按它走;变的只是
  "谁在什么时候按固化键"——从操作员的手动两步变成建批这一下。
- 效果:去掉「生成新版本」「确认冻结」两步;消灭"批准晚于冻结 → 422"这类顺序陷阱;
  前端不再传版本 id,也不再有"所选知识不在冻结清单里"的阻断。
- 计划→清单项的映射抽成 ChangeSetItemsFromPlan 供两处共用,否则两处算出的摘要不同,
  "同一计划复用同一版本"永远不命中。

P0-2 验收通过自动批准:
- 验收三项结论达发布门槛(knowledge_exists/chunks_present=passed,retrieval_hit 与门槛同
  口径)时,由 worker 写入 reviewer_ref='system:auto' 的批准记录,替代"每份文档点一次批准"。
- 策略 TEST_AUTO_REVIEW_POLICY = off | internal(默认,仅对内)| all;值不认识拒绝启动。
  只影响审核这一步:生产授权、双闸与发布前快照门禁不受影响。界面上只读展示当前策略。
- 安全边界(写死在审核 SQL 里):系统只允许写"第一条"审核——人工一旦表过态,机器不得用一条
  更晚的 approved 把人工结论翻回去;人工随时可再审。集成测试去掉该约束即 FAIL(已实测)。
- 自动批准自身的任何失败都不影响验收结论,只上报。

顺带(P1-1 前半,已被 P0-1 取代的部分已删除):
- 写操作失败把服务端 {"error": ...} 的原因带出来,并对已知原因给出下一步(不再只给 HTTP 状态码)。
- 当前的"不在已冻结清单里"标注与就地修复按钮随 P0-1 一起移除(该失败模式已不存在)。

测试:bind_time_freeze_integration_test.go(绑定已确认版本/同计划复用/冻结后不可变/
动作不符仍 NotEligible)、auto_review_integration_test.go(系统只写第一条、人工可再审)、
validate_test.go(对内自动/对外不自动/策略关闭/验收未过不批/自身失败不影响验收)、
auto_review_test.go、config_test 及前端能力解析测试。
实测耗时:本机 Go 全量 26 包通过、前端 178 条通过。
2026-10-10 15:05:20 +08:00

192 lines
7.0 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.
package testweknora
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"time"
"knowledge-governance/internal/governance"
"knowledge-governance/internal/jobs"
postgresstore "knowledge-governance/internal/platform/postgres"
"knowledge-governance/internal/platform/weknora"
)
var (
ErrInvalidValidateJobPayload = errors.New("test_validate_invalid_payload")
ErrValidateStateConflict = errors.New("test_validate_state_conflict")
ErrValidateRecordFailed = errors.New("test_validate_record_failed")
)
type ValidateStore interface {
LoadPublicationForValidation(context.Context, string) (postgresstore.PublicationForValidation, error)
AppendAcceptanceResult(context.Context, governance.AcceptanceResult) error
// ReviewPublication 复用人工审核同一条写入路径:同一套合法性校验与"最新验收轮次"绑定,
// 自动批准不另开一条 SQL,避免两套口径。
ReviewPublication(context.Context, governance.ReviewPublicationRequest) (governance.PublicationReview, error)
}
type ValidateClient interface {
GetKnowledge(context.Context, string) (weknora.Knowledge, error)
ListChunks(context.Context, string, int, int, []string) ([]weknora.Chunk, int64, error)
}
type ValidateHandlerConfig struct {
Store ValidateStore
Client ValidateClient
RunID string
PolicyVersion string
// AutoReviewPolicy:验收通过后是否由系统写入批准。零值按"仅对内"处理(与 env 默认一致)。
AutoReviewPolicy governance.AutoReviewPolicy
// Report 只用于记录自动批准自身的失败:它绝不能把验收判成失败。
Report func(error)
Now func() time.Time
}
type ValidateHandler struct {
store ValidateStore
client ValidateClient
runID string
policyVersion string
autoReviewPolicy governance.AutoReviewPolicy
report func(error)
now func() time.Time
}
// autoReviewerRef:自动批准在审计里必须一眼可辨,绝不冒用某个人的身份。
const autoReviewerRef = "system:auto"
type validatePayload struct {
PublicationID string `json:"publication_id"`
}
func NewValidateHandler(config ValidateHandlerConfig) (*ValidateHandler, error) {
if config.Store == nil || config.Client == nil || config.PolicyVersion == "" {
return nil, errors.New("invalid test validate handler configuration")
}
if config.RunID == "" {
config.RunID = "default"
}
if config.Now == nil {
config.Now = time.Now
}
if config.AutoReviewPolicy == "" {
config.AutoReviewPolicy = governance.AutoReviewInternal
}
if config.Report == nil {
config.Report = func(error) {}
}
return &ValidateHandler{
store: config.Store, client: config.Client, runID: config.RunID,
policyVersion: config.PolicyVersion, autoReviewPolicy: config.AutoReviewPolicy,
report: config.Report, now: config.Now,
}, nil
}
func (handler *ValidateHandler) Handle(ctx context.Context, job jobs.Job) error {
var payload validatePayload
if err := decodeStrictJSON(job.Payload, &payload); err != nil || payload.PublicationID == "" {
return ErrInvalidValidateJobPayload
}
publication, err := handler.store.LoadPublicationForValidation(ctx, payload.PublicationID)
if err != nil {
return ErrValidateStateConflict
}
now := handler.now()
runID := handler.runID
if job.ID != "" {
runID = job.ID
}
knowledge, knowledgeErr := handler.client.GetKnowledge(ctx, publication.KnowledgeID)
knowledgeState := governance.AcceptancePassed
knowledgeEvidence := json.RawMessage(`{"matched":true}`)
if knowledgeErr != nil || knowledge.ID != publication.KnowledgeID || knowledge.KnowledgeBaseID != publication.TestKBID {
knowledgeState = governance.AcceptanceFailed
knowledgeEvidence = json.RawMessage(`{"matched":false}`)
}
if err := handler.append(ctx, publication.ID, runID, "knowledge_exists", knowledgeState, knowledgeEvidence, now); err != nil {
return err
}
_, chunkTotal, chunksErr := handler.client.ListChunks(ctx, publication.KnowledgeID, 1, 1, nil)
chunksState := governance.AcceptancePassed
if chunksErr != nil || chunkTotal <= 0 {
chunksState = governance.AcceptanceFailed
}
if err := handler.append(ctx, publication.ID, runID, "chunks_present", chunksState, mustJSON(map[string]any{"chunk_total": chunkTotal}), now); err != nil {
return err
}
if err := handler.append(ctx, publication.ID, runID, "retrieval_hit", governance.AcceptanceManualRequired, json.RawMessage(`{"reason":"thresholds_unresolved"}`), now); err != nil {
return err
}
handler.autoApprove(ctx, publication, runID, knowledgeState, chunksState, now)
return nil
}
// autoApprove:验收通过的知识由系统写入批准,替代"每份文档点一次批准"。
//
// 触发条件与发布门槛同口径:knowledge_exists / chunks_present 必须 passed
// (retrieval_hit 固定为 manual_required,发布门槛同样接受)。
// 自动批准自身的任何失败都不影响验收结论——只上报,不返回错误。
func (handler *ValidateHandler) autoApprove(ctx context.Context, publication postgresstore.PublicationForValidation, runID string, knowledgeState, chunksState governance.AcceptanceState, now time.Time) {
if !handler.autoReviewPolicy.Allows(publication.TargetScope) {
return
}
if knowledgeState != governance.AcceptancePassed || chunksState != governance.AcceptancePassed {
return
}
_, err := handler.store.ReviewPublication(ctx, governance.ReviewPublicationRequest{
PublicationID: publication.ID,
Decision: governance.ReviewApproved,
DecidedVisibility: governance.Visibility(publication.TargetScope),
ReviewerRef: autoReviewerRef,
DecisionCode: "approved_for_release",
Now: now,
})
if err == nil || errors.Is(err, postgresstore.ErrPublicationReviewNotEligible) {
// 已经有人审过(或不满足审核前置):不覆盖人工结论。
return
}
handler.report(fmt.Errorf("auto review %s: %w", publication.ID, err))
}
func (handler *ValidateHandler) append(ctx context.Context, publicationID, runID, checkType string, state governance.AcceptanceState, evidence json.RawMessage, observedAt time.Time) error {
result := governance.AcceptanceResult{
ID: acceptanceResultID(publicationID, runID, checkType),
PublicationID: publicationID,
RunID: runID,
CheckType: checkType,
State: state,
PolicyVersion: handler.policyVersion,
EvidenceSummary: evidence,
ObservedAt: observedAt,
}
if err := handler.store.AppendAcceptanceResult(ctx, result); err != nil {
return ErrValidateRecordFailed
}
return nil
}
func acceptanceResultID(publicationID, runID, checkType string) string {
digest := sha256.New()
_, _ = digest.Write([]byte(publicationID))
_, _ = digest.Write([]byte{0})
_, _ = digest.Write([]byte(runID))
_, _ = digest.Write([]byte{0})
_, _ = digest.Write([]byte(checkType))
return "acc-" + hex.EncodeToString(digest.Sum(nil))[:32]
}
func mustJSON(value any) json.RawMessage {
data, err := json.Marshal(value)
if err != nil {
return json.RawMessage(`{}`)
}
return data
}