Files

444 lines
15 KiB
Go

package docrevision
import (
"chat-agent-system/internal/app/uow"
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
// EmptyGenerationBaseline is an explicit pre-agent empty prior used only when the document
// has no prior ledger and no distinct prior markdown. It is not fabricated business content;
// it exists so the first agent materialization can produce a real before→after change report.
const EmptyGenerationBaseline = "# (生成前空基线)\n\n"
// AgentMaterializeRequest drives first-or-subsequent agent/squad ledger writes with a visible report.
type AgentMaterializeRequest struct {
DocID string
RunID string
ProposalID string
OutputMarkdown string
PriorMarkdown string // optional; when absent/equal to output, EmptyGenerationBaseline is used for first gen
ReportJSON string // optional; empty → deterministic fallback report (never invented PI prose)
ProjectID int64
WorkDir string
ActorKind string
ActorSnapshot ActorSnapshot
OnBehalfOf *int64
AutoCommitUserID int64
}
// AgentMaterializeResult returns ledger state plus the candidate/report produced for this run.
type AgentMaterializeResult struct {
State *RevisionState
Candidate *AgentCandidate
Report *ChangeReport
FirstGeneration bool
}
// MaterializeAgentOutput ensures an open ledger, creates an agent candidate with a deterministic
// change report (analysis_status=fallback when no PI narrative JSON), and optionally auto-commits
// when risk does not require confirmation. First generation always yields a visible report.
// Invalid or unassessed reports are never auto-committed even if a candidate row exists.
func (s *Service) MaterializeAgentOutput(ctx context.Context, req AgentMaterializeRequest) (*AgentMaterializeResult, error) {
if err := requireDocID(req.DocID); err != nil {
return nil, err
}
if strings.TrimSpace(req.OutputMarkdown) == "" {
return nil, ErrNeedsContent
}
if req.ActorKind == "" {
req.ActorKind = ActorAgent
}
if req.RunID == "" {
req.RunID = fmt.Sprintf("agent-%d", time.Now().UnixNano())
}
first := false
st, err := s.GetRevisionState(ctx, req.DocID)
if errors.Is(err, ErrNotFound) {
first = true
prior := req.PriorMarkdown
priorCanon := CanonicalizeMarkdown(prior)
outCanon := CanonicalizeMarkdown(req.OutputMarkdown)
if strings.TrimSpace(priorCanon) == "" || SHA256Hex(priorCanon) == SHA256Hex(outCanon) {
prior = EmptyGenerationBaseline
}
if _, err := s.OpenTemplateVersion(ctx, req.DocID, prior, req.ActorSnapshot, req.ProjectID, req.WorkDir); err != nil {
return nil, err
}
st, err = s.GetRevisionState(ctx, req.DocID)
if err != nil {
return nil, err
}
} else if err != nil {
return nil, err
}
if strings.TrimSpace(req.WorkDir) != "" {
if err := s.SetDocWorkDir(ctx, req.DocID, req.WorkDir); err != nil {
return nil, err
}
st, err = s.GetRevisionState(ctx, req.DocID)
if err != nil {
return nil, err
}
}
cand, rep, err := s.CreateAgentCandidate(ctx, AgentCandidateRequest{
DocID: req.DocID, RunID: req.RunID, ProposalID: req.ProposalID,
InputSnapshotID: st.HeadSnapshotID, OutputMarkdown: req.OutputMarkdown, ReportJSON: req.ReportJSON,
ActorKind: req.ActorKind, ActorSnapshot: req.ActorSnapshot, OnBehalfOf: req.OnBehalfOf,
})
if cand == nil {
return nil, err
}
out := &AgentMaterializeResult{Candidate: cand, Report: rep, FirstGeneration: first, State: st}
// Never auto-commit when report validation failed / unassessed, even if cand != nil.
if err != nil {
return out, err
}
if rep != nil && rep.AnalysisStatus == AnalysisUnassessed {
return out, fmt.Errorf("%w: unassessed report cannot auto-commit", ErrReportInvalid)
}
if cand.Status == CandidateStale {
return out, ErrCandidateStale
}
if cand.Status == CandidateNeedsConfirmation {
// Report is visible via candidate/change-reports; do not auto-commit.
return out, nil
}
if req.AutoCommitUserID > 0 && cand.Status == CandidateReady {
st2, cErr := s.CommitAgentVersion(ctx, req.DocID, cand.ID, CommitAgentDecision{
Decision: "commit", Confirmed: true, ActorUserID: req.AutoCommitUserID,
ExpectedInputSHA: st.HeadSHA256,
})
if cErr != nil {
return out, cErr
}
out.State = st2
if c2, gErr := s.GetCandidate(ctx, req.DocID, cand.ID); gErr == nil {
out.Candidate = c2
}
if rep != nil && rep.ID > 0 {
if r2, gErr := s.GetChangeReport(ctx, req.DocID, rep.ID); gErr == nil {
out.Report = r2
}
}
}
return out, nil
}
func (s *Service) CreateAgentCandidate(ctx context.Context, req AgentCandidateRequest) (*AgentCandidate, *ChangeReport, error) {
if err := requireDocID(req.DocID); err != nil {
return nil, nil, err
}
if !s.AgentCommit {
return nil, nil, fmt.Errorf("docrevision: agent commit disabled")
}
canon := CanonicalizeMarkdown(req.OutputMarkdown)
if len(canon) > MaxMarkdownBytes {
return nil, nil, ErrTooLarge
}
outSHA := SHA256Hex(canon)
g := s.lockDoc(req.DocID)
defer g.Release()
s.mu.Lock()
defer s.mu.Unlock()
if err := s.hydrateDocLocked(ctx, req.DocID); err != nil {
return nil, nil, err
}
root := s.roots[req.DocID]
if root == nil || root.LedgerVersionID == nil || root.HeadSnapshotID == nil {
return nil, nil, ErrNoOpenVersion
}
if *root.HeadSnapshotID != req.InputSnapshotID {
// still create stale candidate
}
inSnap := s.snapshots[req.InputSnapshotID]
if inSnap == nil {
return nil, nil, ErrNotFound
}
inMD := s.blobs[inSnap.ContentSHA256].ContentMarkdown
outSnap := s.putSnapshotLocked(req.DocID, canon, outSHA, &req.InputSnapshotID, req.ActorKind, req.OnBehalfOf, req.RunID, req.ActorSnapshot, req.RunID, nil)
diff := ComputeDiff(inMD, canon, inSnap.BlockMap, outSnap.BlockMap)
impacts := s.impactLocked(ctx, req.DocID)
ruleRisk := RuleRiskFloor(diff.Hunks, impacts)
reportJSON := req.ReportJSON
if PIChangeReportNarrativeEnabled() && strings.TrimSpace(reportJSON) == "" && s.NarrativeFetcher != nil {
digest := NarrativeInputDigest(inSnap.ContentSHA256, outSHA, ruleRisk, impacts, diff.Hunks)
raw, ferr := s.NarrativeFetcher(ctx, NarrativeFetchSpec{
FromSHA: inSnap.ContentSHA256, ToSHA: outSHA, RuleRisk: ruleRisk,
InputDigest: digest, Impacts: impacts, Hunks: append([]DiffHunk(nil), diff.Hunks...),
})
if ferr == nil && strings.TrimSpace(raw) != "" {
reportJSON = raw
}
}
rep, err := ResolveChangeReport(reportJSON, inSnap.ContentSHA256, outSHA, ruleRisk, impacts, diff)
status := CandidateReady
if err != nil {
// Invalid reports stay visible as candidates but must not look "ready" for auto-commit.
// (Flag-off legacy path only; flag-on narrative failures already full-fallback with err=nil.)
status = CandidateNeedsConfirmation
if rep != nil {
rep.AnalysisStatus = AnalysisUnassessed
}
}
if NeedsConfirmation(rep.RiskLevel) {
status = CandidateNeedsConfirmation
}
rep.ID = nextFreeID(&s.nextRepID, func(id int64) bool { return s.reports[id] != nil })
rep.DocID = req.DocID
rep.FromSnapshotID = req.InputSnapshotID
rep.ToSnapshotID = outSnap.ID
rep.CreatedAt = time.Now().UTC()
rep.Authority = ReportProposed
rep.Lifecycle = ReportActive
var superseded []*ChangeReport
if *root.HeadSnapshotID != req.InputSnapshotID {
status = CandidateStale
rep.Lifecycle = ReportExpired
} else {
superseded = s.supersedeActiveProposedLocked(req.DocID)
}
stampReportDisposition(rep)
s.reports[rep.ID] = rep
c := &AgentCandidate{
ID: nextFreeID(&s.nextCandID, func(id int64) bool { return s.candidates[id] != nil }), DocID: req.DocID, InputSnapshotID: req.InputSnapshotID,
OutputSnapshotID: &outSnap.ID, RunID: req.RunID, ProposalID: req.ProposalID,
Status: status, AuthorizedScope: req.AuthorizedScope, ChangeReportID: &rep.ID,
ActorKind: req.ActorKind, ActorSnapshot: req.ActorSnapshot, CreatedAt: time.Now().UTC(),
}
s.candidates[c.ID] = c
if err := s.persistAgentCandidateLocked(ctx, outSnap, rep, c, superseded); err != nil {
s.forgetDocLocked(req.DocID)
return nil, nil, err
}
if s.Notify && s.OnOutbox != nil && status == CandidateNeedsConfirmation {
_ = s.OnOutbox(ctx, "document.agent_candidate_ready", map[string]any{
"doc_id": req.DocID, "candidate_id": c.ID, "risk": rep.RiskLevel,
})
}
if err != nil && status != CandidateStale {
return c, rep, err
}
return c, rep, nil
}
func (s *Service) CommitAgentVersion(ctx context.Context, docID string, candidateID int64, dec CommitAgentDecision) (*RevisionState, error) {
if err := requireDocID(docID); err != nil {
return nil, err
}
g := s.lockDoc(docID)
defer g.Release()
s.mu.Lock()
defer s.mu.Unlock()
if err := s.hydrateDocLocked(ctx, docID); err != nil {
return nil, err
}
c, ok := s.candidates[candidateID]
if !ok || c.DocID != docID {
return nil, ErrNotFound
}
if c.Status == CandidateCommitted {
// Idempotent retry after projection/governance failure: same candidate + matching
// hashes may continue ProjectOutputMarkdown / MarkProposalApplied.
if dec.Decision == "reject" {
return nil, fmt.Errorf("docrevision: candidate already committed")
}
if c.OutputSnapshotID == nil {
return nil, ErrNotFound
}
outSnap := s.snapshots[*c.OutputSnapshotID]
inSnap := s.snapshots[c.InputSnapshotID]
if outSnap == nil || inSnap == nil {
return nil, ErrNotFound
}
if dec.ExpectedInputSHA != "" && dec.ExpectedInputSHA != inSnap.ContentSHA256 {
return nil, ErrCandidateStale
}
if dec.ExpectedOutputSHA != "" && dec.ExpectedOutputSHA != outSnap.ContentSHA256 {
return nil, ErrCandidateStale
}
return s.mustStateLocked(c.DocID), nil
}
if c.Status == CandidateStale {
return nil, ErrCandidateStale
}
if c.Status == CandidateRejected {
return nil, fmt.Errorf("docrevision: candidate rejected")
}
root := s.roots[c.DocID]
if root == nil || root.HeadSnapshotID == nil {
return nil, ErrNoOpenVersion
}
if *root.HeadSnapshotID != c.InputSnapshotID {
c.Status = CandidateStale
if c.ChangeReportID != nil {
s.closeProposedLocked(s.reports[*c.ChangeReportID], ReportExpired)
}
if s.usingPG() {
if err := s.withTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
if err := s.updateCandidateTX(ctx, tx, c); err != nil {
return err
}
if c.ChangeReportID == nil {
return nil
}
return s.updateReportFactsTX(ctx, tx, s.reports[*c.ChangeReportID])
}); err != nil {
s.forgetDocLocked(docID)
return nil, err
}
}
return nil, ErrCandidateStale
}
if root.LifecycleState != "" && root.LifecycleState != "drafting" {
return nil, ErrLifecycleLocked
}
if c.ChangeReportID == nil || c.OutputSnapshotID == nil {
return nil, ErrNotFound
}
rep := s.reports[*c.ChangeReportID]
outSnap := s.snapshots[*c.OutputSnapshotID]
inSnap := s.snapshots[c.InputSnapshotID]
if rep == nil || outSnap == nil || inSnap == nil {
return nil, ErrNotFound
}
if dec.ExpectedInputSHA != "" && dec.ExpectedInputSHA != inSnap.ContentSHA256 {
return nil, ErrCandidateStale
}
if dec.ExpectedOutputSHA != "" && dec.ExpectedOutputSHA != outSnap.ContentSHA256 {
return nil, ErrCandidateStale
}
if dec.Decision == "reject" {
now := time.Now().UTC()
c.Status = CandidateRejected
c.DecidedAt = &now
if c.ChangeReportID != nil {
s.closeProposedLocked(s.reports[*c.ChangeReportID], ReportRejected)
}
if s.usingPG() {
if err := s.withTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
if err := s.updateCandidateTX(ctx, tx, c); err != nil {
return err
}
if c.ChangeReportID == nil {
return nil
}
return s.updateReportFactsTX(ctx, tx, s.reports[*c.ChangeReportID])
}); err != nil {
s.forgetDocLocked(docID)
return nil, err
}
}
return s.mustStateLocked(c.DocID), nil
}
if NeedsConfirmation(rep.RiskLevel) && !dec.Confirmed {
return nil, ErrNeedsConfirmation
}
if inSnap.ContentSHA256 == outSnap.ContentSHA256 {
ver := s.versions[*root.LedgerVersionID]
eventNo := s.nextEventNoLocked(c.DocID)
ev := s.putEventLocked(c.DocID, ver.ID, eventNo, &c.InputSnapshotID, outSnap.ID, ChangeAgentNoop, c.ActorKind, dec.ActorUserIDPtr(), c.RunID, c.ActorSnapshot, nil, "", "", fmt.Sprintf("agent_noop:%s:%s", c.RunID, outSnap.ContentSHA256))
now := time.Now().UTC()
c.Status = CandidateCommitted
c.DecidedAt = &now
if c.ChangeReportID != nil {
s.closeProposedLocked(s.reports[*c.ChangeReportID], ReportCommitted)
}
if s.usingPG() {
if err := s.withTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
eid, err := s.insertEventTX(ctx, tx, ev)
if err != nil {
return err
}
s.adoptEventID(ev, eid)
if err := s.updateCandidateTX(ctx, tx, c); err != nil {
return err
}
if c.ChangeReportID == nil {
return nil
}
return s.updateReportFactsTX(ctx, tx, s.reports[*c.ChangeReportID])
}); err != nil {
s.forgetDocLocked(docID)
return nil, err
}
}
return s.mustStateLocked(c.DocID), nil
}
oldVer := s.versions[*root.LedgerVersionID]
now := time.Now().UTC()
oldVer.Status = VersionSealed
oldVer.SealedAt = &now
oldVer.HeadSnapshotID = c.InputSnapshotID
origin := OriginAgent
if c.ActorKind == ActorAgentSquad {
origin = OriginAgentSquad
}
newVer := s.openVersionLocked(c.DocID, origin, outSnap.ID, outSnap.ID, c.RunID)
auth := s.sealProposedWithSuccessorLocked(rep)
s.expireOtherProposedLocked(c.DocID, rep.ID)
newVer.ChangeReportID = &auth.ID
root.LedgerVersionID = &newVer.ID
root.HeadSnapshotID = &outSnap.ID
root.HeadRevisionNo = 0
root.ContentSHA256 = outSnap.ContentSHA256
root.RowRevision++
inMD := s.blobs[inSnap.ContentSHA256].ContentMarkdown
outMD := s.blobs[outSnap.ContentSHA256].ContentMarkdown
diff := ComputeDiff(inMD, outMD, inSnap.BlockMap, outSnap.BlockMap)
eventNo := s.nextEventNoLocked(c.DocID)
ev := s.putEventLocked(c.DocID, newVer.ID, eventNo, &c.InputSnapshotID, outSnap.ID, ChangeAgentCommit, c.ActorKind, dec.ActorUserIDPtr(), c.RunID, c.ActorSnapshot, diff.Hunks, "", "", fmt.Sprintf("agent_commit:%s:%s", c.RunID, outSnap.ContentSHA256))
c.Status = CandidateCommitted
c.DecidedAt = &now
if err := s.persistAgentCommitLocked(ctx, root, oldVer, newVer, outSnap, ev, c); err != nil {
s.forgetDocLocked(docID)
return nil, err
}
if s.OnFinalArtifact != nil && (!s.usingPG() || s.OnFinalArtifactTX == nil) {
eventID := fmt.Sprintf("ev:%d", ev.ID)
s.OnFinalArtifact(ctx, "agent_commit", c.DocID, root.ProjectID, outSnap.ID, eventID, []byte(outMD), nil)
if oldVer != nil && oldVer.SealedAt != nil {
s.OnFinalArtifact(ctx, "version_sealed", c.DocID, root.ProjectID, oldVer.HeadSnapshotID, eventID+":sealed", []byte(inMD), oldVer.SealedAt)
}
}
if _, joined := uow.ScopeFrom(ctx); !joined && s.Notify && s.OnOutbox != nil && auth.ID != 0 {
// document.agent_version_committed is owned by events.EventSink (U6) and is not emitted here.
// Downstream notices belong to the authoritative report, never to the proposed estimate.
for _, imp := range auth.DocumentImpacts {
_ = s.OnOutbox(ctx, "document.downstream_impact_detected", map[string]any{
"doc_id": c.DocID, "impacted_doc_id": imp.DocID, "version_no": newVer.VersionNo,
})
}
if auth.AnalysisStatus == AnalysisUnassessed || auth.AnalysisStatus == AnalysisFallback {
_ = s.OnOutbox(ctx, "document.change_report_degraded", map[string]any{
"doc_id": c.DocID, "report_id": auth.ID,
})
}
}
return s.mustStateLocked(c.DocID), nil
}
func (d CommitAgentDecision) ActorUserIDPtr() *int64 {
if d.ActorUserID == 0 {
return nil
}
id := d.ActorUserID
return &id
}