diff --git a/internal/docrevision/commit_report.go b/internal/docrevision/commit_report.go new file mode 100644 index 00000000..630dff87 --- /dev/null +++ b/internal/docrevision/commit_report.go @@ -0,0 +1,50 @@ +package docrevision + +import "context" + +// CoverageResolver reports whether the trace index can support a no-impact claim. +// nil or an empty result means the index has not run; that must not be shown as no impact. +func (s *Service) formalCommitReportLocked(ctx context.Context, docID string, fromID, toID int64, hunks []DiffHunk) *ChangeReport { + impacts := s.impactLocked(ctx, docID) + coverage := ImpactIndexPending + if s.CoverageResolver != nil { + if got := s.CoverageResolver(ctx, docID); got != "" { + coverage = got + } + } + rep := fallbackReport("", "", RuleRiskFloor(hunks, impacts), impacts, hunks, "formal commit") + rep.DocID = docID + rep.FromSnapshotID = fromID + rep.ToSnapshotID = toID + rep.AnalysisStatus = AnalysisFallback + rep.Authority = ReportAuthoritative + rep.Lifecycle = ReportCommitted + if rep.DeterministicFacts == nil { + rep.DeterministicFacts = map[string]any{} + } + rep.DeterministicFacts["coverage_status"] = coverage + rep.DeterministicFacts["suggested_actions"] = formalSuggestions(coverage, len(impacts) > 0) + switch coverage { + case ImpactNoKnown: + rep.Summary = append(rep.Summary, "索引已覆盖,未发现已知关联") + case ImpactConfirmed, ImpactPossible: + rep.Summary = append(rep.Summary, "这次修改可能影响其他文档,见 document_impacts") + default: + rep.Summary = append(rep.Summary, "分析尚未完成或失败,不能判断为无影响") + rep.OpenQuestions = append(rep.OpenQuestions, "不能判断为无影响") + } + stampReportDisposition(rep) + return rep +} + +func formalSuggestions(coverage string, hasImpact bool) []string { + switch coverage { + case ImpactIndexPending, ImpactPartial, ImpactFailed: + return []string{"wait_index"} + } + out := []string{"no_change"} + if hasImpact || coverage == ImpactPossible || coverage == ImpactConfirmed { + out = append(out, "l1_revise", "l2_reopen") + } + return out +} diff --git a/internal/docrevision/commit_report_test.go b/internal/docrevision/commit_report_test.go new file mode 100644 index 00000000..96294806 --- /dev/null +++ b/internal/docrevision/commit_report_test.go @@ -0,0 +1,45 @@ +package docrevision + +import ( + "context" + "strings" + "testing" +) + +func TestHumanRevision_ReportDoesNotClaimNoImpactWithoutIndex(t *testing.T) { + svc := NewService(nil) + res, err := svc.CommitHumanRevision(t.Context(), HumanRevisionRequest{ + DocID: "doc-1", Markdown: "UR-001 需要记录", IdempotencyKey: "k1", + ActorUserID: 1, AllowCreateBaseline: true, ProjectID: 3, + }) + if err != nil { + t.Fatal(err) + } + if res.Report == nil || res.Report.Authority != ReportAuthoritative { + t.Fatalf("report %#v", res.Report) + } + if res.Report.DeterministicFacts["coverage_status"] != ImpactIndexPending { + t.Fatalf("coverage %#v", res.Report.DeterministicFacts["coverage_status"]) + } + for _, line := range res.Report.Summary { + if strings.Contains(line, "未发现已知关联") { + t.Fatal(line) + } + } +} + +func TestHumanRevision_IndexedNoKnown(t *testing.T) { + svc := NewService(nil) + svc.CoverageResolver = func(ctx context.Context, docID string) string { return ImpactNoKnown } + res, err := svc.CommitHumanRevision(t.Context(), HumanRevisionRequest{ + DocID: "doc-2", Markdown: "没有条目", IdempotencyKey: "k2", + ActorUserID: 1, AllowCreateBaseline: true, ProjectID: 3, + }) + if err != nil { + t.Fatal(err) + } + joined := strings.Join(res.Report.Summary, " ") + if !strings.Contains(joined, "未发现已知关联") { + t.Fatalf("summary %s", joined) + } +} diff --git a/internal/docrevision/pg_mutate.go b/internal/docrevision/pg_mutate.go index 9d93a0c4..c45a49e9 100644 --- a/internal/docrevision/pg_mutate.go +++ b/internal/docrevision/pg_mutate.go @@ -66,7 +66,7 @@ func (s *Service) openTemplatePGLocked(ctx context.Context, docID, canon, sha st return s.mustStateLocked(docID), nil } -func (s *Service) persistHumanRevisionLocked(ctx context.Context, root *DocRootPointers, ver *Version, snap *Snapshot, ev *ChangeEvent, isBaseline bool) error { +func (s *Service) persistHumanRevisionLocked(ctx context.Context, root *DocRootPointers, ver *Version, snap *Snapshot, ev *ChangeEvent, isBaseline bool, rep *ChangeReport) error { if !s.usingPG() { if root != nil && snap != nil { key := "" @@ -75,6 +75,10 @@ func (s *Service) persistHumanRevisionLocked(ctx context.Context, root *DocRootP } s.noteContentProjectionLocked(root.DocID, snap.ID, root.HeadRevisionNo, key) } + if rep != nil { + rep.ToSnapshotID = snap.ID + s.assignReportIDLocked(rep) + } return nil } blob := s.blobs[snap.ContentSHA256] @@ -127,6 +131,15 @@ func (s *Service) persistHumanRevisionLocked(ctx context.Context, root *DocRootP if err := s.insertContentProjectionTX(ctx, tx, root.DocID, snap.ID, root.HeadRevisionNo, key); err != nil { return err } + if rep != nil { + rep.ToSnapshotID = snap.ID + rid, err := s.insertReportTX(ctx, tx, rep) + if err != nil { + return err + } + s.adoptReportID(rep, rid) + s.reports[rep.ID] = rep + } s.noteContentProjectionLocked(root.DocID, snap.ID, root.HeadRevisionNo, key) return s.projectRootPointersTX(ctx, tx, root) }) diff --git a/internal/docrevision/reconcile.go b/internal/docrevision/reconcile.go index 4e84580e..c51a9906 100644 --- a/internal/docrevision/reconcile.go +++ b/internal/docrevision/reconcile.go @@ -174,7 +174,7 @@ func (s *Service) CommitMigrationRepair(ctx context.Context, req MigrationRepair root.RowRevision++ eventNo := s.nextEventNoLocked(req.DocID) ev := s.putEventLocked(req.DocID, ver.ID, eventNo, parentPtr, snap.ID, ChangeMigrationRepair, ActorSystem, nil, "migration_repair", actor, nil, "", "", idemp) - if err := s.persistHumanRevisionLocked(ctx, root, ver, snap, ev, false); err != nil { + if err := s.persistHumanRevisionLocked(ctx, root, ver, snap, ev, false, nil); err != nil { s.forgetDocLocked(req.DocID) return nil, err } diff --git a/internal/docrevision/service.go b/internal/docrevision/service.go index bd6227cd..af9723fa 100644 --- a/internal/docrevision/service.go +++ b/internal/docrevision/service.go @@ -41,6 +41,10 @@ type Service struct { // ImpactResolver returns deterministic downstream impacts (lineage); optional. ImpactResolver func(ctx context.Context, docID string) []DocImpact + // CoverageResolver classifies trace-index coverage for a formal commit. + // When unset, reports stay index_pending and must not claim no impact. + CoverageResolver func(ctx context.Context, docID string) string + // NarrativeFetcher optionally loads typed PI narrative JSON when // PI_CHANGE_REPORT_NARRATIVE is on and ReportJSON is empty. NarrativeFetcher func(ctx context.Context, spec NarrativeFetchSpec) (string, error) @@ -249,12 +253,13 @@ func (s *Service) CommitHumanRevision(ctx context.Context, req HumanRevisionRequ root.ContentSHA256 = sha root.RowRevision++ ev := s.putEventLocked(req.DocID, ver.ID, 1, nil, snap.ID, ChangeImport, ActorHuman, &req.ActorUserID, "", req.ActorSnapshot, nil, req.EditSessionID, req.RequestID, req.IdempotencyKey) - if err := s.persistHumanRevisionLocked(ctx, root, ver, snap, ev, true); err != nil { + rep := s.formalCommitReportLocked(ctx, req.DocID, 0, snap.ID, nil) + if err := s.persistHumanRevisionLocked(ctx, root, ver, snap, ev, true, rep); err != nil { s.forgetDocLocked(req.DocID) return nil, err } st := s.mustStateLocked(req.DocID) - return &HumanRevisionResult{State: *st, Event: *ev}, nil + return &HumanRevisionResult{State: *st, Event: *ev, Report: rep}, nil } ver := s.versions[*root.LedgerVersionID] @@ -300,7 +305,8 @@ func (s *Service) CommitHumanRevision(ctx context.Context, req HumanRevisionRequ root.RowRevision++ eventNo := s.nextEventNoLocked(req.DocID) ev := s.putEventLocked(req.DocID, ver.ID, eventNo, &parentID, snap.ID, ChangeHumanRevision, ActorHuman, &req.ActorUserID, "", req.ActorSnapshot, diff.Hunks, req.EditSessionID, req.RequestID, req.IdempotencyKey) - if err := s.persistHumanRevisionLocked(ctx, root, ver, snap, ev, false); err != nil { + rep := s.formalCommitReportLocked(ctx, req.DocID, parentID, snap.ID, diff.Hunks) + if err := s.persistHumanRevisionLocked(ctx, root, ver, snap, ev, false, rep); err != nil { s.forgetDocLocked(req.DocID) return nil, err } @@ -309,7 +315,7 @@ func (s *Service) CommitHumanRevision(ctx context.Context, req HumanRevisionRequ overlay := ComputeDiff(baseMD, canon, s.snapshots[ver.BaselineSnapshotID].BlockMap, snap.BlockMap) overlay.FromSnapshotID = ver.BaselineSnapshotID overlay.ToSnapshotID = snap.ID - return &HumanRevisionResult{State: *st, Event: *ev, Overlay: overlay}, nil + return &HumanRevisionResult{State: *st, Event: *ev, Overlay: overlay, Report: rep}, nil } func (s *Service) OpenTemplateVersion(ctx context.Context, docID string, markdown string, actor ActorSnapshot, projectID int64, workDir string) (*RevisionState, error) { diff --git a/internal/docrevision/types.go b/internal/docrevision/types.go index 0a62220f..8ccfb810 100644 --- a/internal/docrevision/types.go +++ b/internal/docrevision/types.go @@ -56,6 +56,14 @@ const ( AnalysisUnassessed = "unassessed" AnalysisCorrected = "corrected" + // Trace coverage carried on a formal commit report. These are not PI analysis statuses. + ImpactConfirmed = "confirmed_impact" + ImpactPossible = "possible_impact" + ImpactNoKnown = "no_known_dependencies" + ImpactIndexPending = "index_pending" + ImpactPartial = "partial" + ImpactFailed = "failed" + // Report authority separates a candidate estimate from the report written at formal commit. ReportProposed = "proposed" ReportAuthoritative = "authoritative" @@ -318,6 +326,7 @@ type HumanRevisionResult struct { Event ChangeEvent `json:"event"` Overlay DiffResult `json:"manual_overlay"` Conflict *ConflictInfo `json:"conflict,omitempty"` + Report *ChangeReport `json:"report,omitempty"` } type ConflictInfo struct {