Files
weichen b5a782bb38 feat(chat): dialogue triggers for comments/reopen/pending-supplements with mistouch guards
Wire intent→coordinator DocResolver confirm cards→action_executor kinds for comment, reopen, pending-supplements query, and real submit-review execution.
2026-09-25 20:14:10 +00:00

537 lines
17 KiB
Go

package traceimpact
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
)
// StageReopener applies an approved reopen to the workflow instance.
// It must not pause the instance or rewrite downstream stages.
type StageReopener interface {
ReopenCompletedStage(ctx context.Context, instanceID int64, stageKey string, docTaskKeys []string, operatorID int64) error
}
// DocUnlocker moves selected approved documents back to drafting.
type DocUnlocker interface {
ReopenApprovedDocs(ctx context.Context, projectID int64, docTaskKeys []string, actorID int64, reason string) error
}
// Service is the project trace index and the stage-backtrack ledger.
type Service struct {
store Store
reopen StageReopener
unlock DocUnlocker
onRecheck func(ctx context.Context, mark RecheckMark)
}
func NewService(store Store) *Service {
if store == nil {
store = newMemStore()
}
return &Service{store: store}
}
func NewMemoryService() *Service {
return NewService(newMemStore())
}
func (s *Service) SetStageReopener(r StageReopener) { s.reopen = r }
func (s *Service) SetDocUnlocker(u DocUnlocker) { s.unlock = u }
// SetRecheckHook is called once when a new open recheck mark is stored.
// Repeat analyzes of the same source do not call it again.
func (s *Service) SetRecheckHook(fn func(ctx context.Context, mark RecheckMark)) {
if s != nil {
s.onRecheck = fn
}
}
type actorCtxKey struct{}
// WithActor records who triggered this analysis so a recheck notice can reach their chat.
func WithActor(ctx context.Context, userID int64) context.Context {
if userID <= 0 {
return ctx
}
return context.WithValue(ctx, actorCtxKey{}, userID)
}
// ActorID is the user from WithActor, or 0.
func ActorID(ctx context.Context) int64 {
id, _ := ctx.Value(actorCtxKey{}).(int64)
return id
}
// IndexDocument projects explicit keys into items and same-line pairs into confirmed edges.
// Untyped IDs become candidate items only.
func (s *Service) IndexDocument(ctx context.Context, projectID int64, doc DocRef) (IndexState, error) {
if projectID <= 0 || doc.DocID == "" {
return IndexState{}, fmt.Errorf("traceimpact: project and doc are required")
}
keys := ExtractKeys(doc.Markdown)
itemByKey := map[string]Item{}
for _, k := range keys {
review := ReviewConfirmed
if !k.Explicit {
review = ReviewCandidate
}
item, err := s.store.UpsertItem(ctx, Item{
ProjectID: projectID, BusinessKey: k.Key, Kind: k.Kind,
ReviewState: review, Statement: strings.TrimSpace(k.Line), SourceDocID: doc.DocID,
})
if err != nil {
st := IndexState{ProjectID: projectID, DocID: doc.DocID, SnapshotID: doc.SnapshotID, Status: ImpactFailed, Error: err.Error()}
_ = s.store.PutIndex(ctx, st)
return st, err
}
sum := sha256.Sum256([]byte(k.Key + "\n" + strings.TrimSpace(k.Line)))
if err := s.store.AppendRevision(ctx, ItemRevision{
ItemID: item.ID, SourceDocID: doc.DocID, SnapshotID: doc.SnapshotID,
ContentHash: hex.EncodeToString(sum[:]), Statement: strings.TrimSpace(k.Line),
Extractor: ExtractorVersion,
}); err != nil {
return IndexState{}, err
}
itemByKey[k.Key] = item
}
for _, pair := range ExplicitPairsOnLine(doc.Markdown) {
from, okA := itemByKey[pair[0]]
to, okB := itemByKey[pair[1]]
if !okA || !okB {
continue
}
if _, err := s.store.UpsertEdge(ctx, Edge{
ProjectID: projectID, FromItemID: from.ID, ToItemID: to.ID,
RelationType: RelationRefines, Provenance: ProvenanceExplicit,
ReviewState: ReviewConfirmed, EvidenceDoc: doc.DocID,
FromKey: from.BusinessKey, ToKey: to.BusinessKey,
}); err != nil {
return IndexState{}, err
}
}
st := IndexState{
ProjectID: projectID, DocID: doc.DocID, SnapshotID: doc.SnapshotID,
Status: "complete", Extractor: ExtractorVersion, ItemCount: len(keys),
}
if err := s.store.PutIndex(ctx, st); err != nil {
return IndexState{}, err
}
return st, nil
}
// CoverageForDoc is what a change report should show before claiming no impact.
func (s *Service) CoverageForDoc(ctx context.Context, projectID int64, docID string) string {
st, ok, err := s.store.IndexStatus(ctx, projectID, docID)
if err != nil || !ok || st.Status == "" {
return ImpactIndexPending
}
switch st.Status {
case "complete":
return ImpactNoKnown
case "failed":
return ImpactFailed
case "partial":
return ImpactPartial
default:
return ImpactIndexPending
}
}
// Analyze walks confirmed edges from keys mentioned in the changed markdown.
// Missing index coverage never becomes "no impact".
func (s *Service) Analyze(ctx context.Context, projectID int64, source DocRef, others []DocRef) (*Report, error) {
rep := &Report{ProjectID: projectID, SourceDocID: source.DocID, Impacts: []Impact{}}
if projectID <= 0 || source.DocID == "" {
rep.CoverageStatus = ImpactFailed
rep.Summary = SummaryForCoverage(rep.CoverageStatus)
rep.Suggestions = Suggest(rep.CoverageStatus, false)
return rep, nil
}
srcIndex, ok, err := s.store.IndexStatus(ctx, projectID, source.DocID)
if err != nil {
rep.CoverageStatus = ImpactFailed
rep.Summary = SummaryForCoverage(rep.CoverageStatus)
rep.Suggestions = Suggest(rep.CoverageStatus, false)
return rep, nil
}
if !ok || srcIndex.Status != "complete" {
rep.CoverageStatus = ImpactIndexPending
if ok && srcIndex.Status == "failed" {
rep.CoverageStatus = ImpactFailed
}
rep.Summary = SummaryForCoverage(rep.CoverageStatus)
rep.Suggestions = Suggest(rep.CoverageStatus, false)
return rep, nil
}
items, err := s.store.ListItems(ctx, projectID)
if err != nil {
rep.CoverageStatus = ImpactFailed
rep.Summary = SummaryForCoverage(rep.CoverageStatus)
rep.Suggestions = Suggest(rep.CoverageStatus, false)
return rep, nil
}
byID := map[int64]Item{}
byKey := map[string]Item{}
for _, it := range items {
byID[it.ID] = it
byKey[it.BusinessKey] = it
}
var start []int64
for _, k := range ExtractKeys(source.Markdown) {
if it, ok := byKey[k.Key]; ok && it.ReviewState == ReviewConfirmed {
start = append(start, it.ID)
}
}
edges, err := s.store.ListEdges(ctx, projectID)
if err != nil {
rep.CoverageStatus = ImpactFailed
rep.Summary = SummaryForCoverage(rep.CoverageStatus)
rep.Suggestions = Suggest(rep.CoverageStatus, false)
return rep, nil
}
hits, truncated := WalkReverse(edges, start, MaxWalkDepth, MaxWalkNodes)
docByKey := map[string]DocRef{source.DocID: source}
for _, d := range others {
docByKey[d.DocID] = d
}
for _, hit := range hits {
it := byID[hit.ItemID]
if !it.Tombstone && it.SourceDocID != "" && it.SourceDocID != source.DocID {
target := docByKey[it.SourceDocID]
if target.DocID != "" && !target.Visible {
continue
}
rep.Impacts = append(rep.Impacts, Impact{
DocID: it.SourceDocID, ItemKey: it.BusinessKey, StageKey: target.StageKey,
ImpactType: ImpactConfirmed, Depth: hit.Depth,
Reason: fmt.Sprintf("%s 经已确认关系受本次修改影响", it.BusinessKey),
})
if target.StageStatus == "completed" && target.StageKey != "" {
already := false
if existing, listErr := s.store.ListOpenMarks(ctx, projectID, target.StageKey); listErr == nil {
for _, m := range existing {
if m.SourceDoc == source.DocID {
already = true
break
}
}
}
saved, putErr := s.store.PutMark(ctx, RecheckMark{
ProjectID: projectID, StageKey: target.StageKey, SourceDoc: source.DocID,
Status: MarkOpen, Reason: "上游或同项目条目已变化",
})
if putErr == nil && !already && s.onRecheck != nil {
s.onRecheck(ctx, saved)
}
}
}
}
for _, e := range edges {
if e.ReviewState != ReviewCandidate {
continue
}
for _, id := range start {
if e.ToItemID == id || e.FromItemID == id {
rep.Impacts = append(rep.Impacts, Impact{
ItemKey: byID[e.FromItemID].BusinessKey, ImpactType: ImpactPossible, Depth: 1,
Reason: "候选关系尚未确认",
})
break
}
}
}
switch {
case truncated:
rep.CoverageStatus = ImpactPartial
case len(rep.Impacts) == 0:
rep.CoverageStatus = ImpactNoKnown
default:
rep.CoverageStatus = ImpactConfirmed
for _, imp := range rep.Impacts {
if imp.ImpactType == ImpactPossible && rep.CoverageStatus != ImpactConfirmed {
rep.CoverageStatus = ImpactPossible
}
if imp.ImpactType == ImpactConfirmed {
rep.CoverageStatus = ImpactConfirmed
break
}
}
onlyPossible := true
for _, imp := range rep.Impacts {
if imp.ImpactType == ImpactConfirmed {
onlyPossible = false
}
}
if onlyPossible {
rep.CoverageStatus = ImpactPossible
}
}
rep.Summary = SummaryForCoverage(rep.CoverageStatus)
if rep.CoverageStatus == ImpactConfirmed {
rep.Summary = fmt.Sprintf("发现 %d 条影响", len(rep.Impacts))
}
rep.Suggestions = Suggest(rep.CoverageStatus, len(rep.Impacts) > 0)
return rep, nil
}
// DecideCandidate confirms or rejects a model/candidate edge.
func (s *Service) DecideCandidate(ctx context.Context, edgeID, actorID int64, creator bool, downstreamDoc string, downstreamAuthor int64, decision, reason string) error {
if err := CanDecide(Actor{UserID: actorID, Creator: creator}, downstreamDoc, downstreamAuthor); err != nil {
return err
}
if decision != DecisionConfirm && decision != DecisionReject {
return fmt.Errorf("traceimpact: decision must be confirm or reject")
}
edge, err := s.store.GetEdge(ctx, edgeID)
if err != nil {
return err
}
review := ReviewConfirmed
if decision == DecisionReject {
review = ReviewRejected
}
if err := s.store.SetEdgeReview(ctx, edgeID, review); err != nil {
return err
}
_, err = s.store.AddDecision(ctx, Decision{
ProjectID: edge.ProjectID, EdgeID: edgeID, Decision: decision, Reason: reason, DecidedBy: actorID,
})
return err
}
// UpsertModelCandidate stores a model_candidate edge that stays out of confirmed walks until decided.
func (s *Service) UpsertModelCandidate(ctx context.Context, projectID int64, fromKey, toKey, evidenceDoc string) (Edge, error) {
if projectID <= 0 || fromKey == "" || toKey == "" {
return Edge{}, fmt.Errorf("traceimpact: project and both keys are required")
}
items, err := s.store.ListItems(ctx, projectID)
if err != nil {
return Edge{}, err
}
var from, to Item
for _, it := range items {
if it.BusinessKey == fromKey {
from = it
}
if it.BusinessKey == toKey {
to = it
}
}
if from.ID == 0 || to.ID == 0 {
return Edge{}, fmt.Errorf("traceimpact: both items must already be indexed")
}
return s.store.UpsertEdge(ctx, Edge{
ProjectID: projectID, FromItemID: from.ID, ToItemID: to.ID,
RelationType: RelationDependsOn, Provenance: ProvenanceModel,
ReviewState: ReviewCandidate, EvidenceDoc: evidenceDoc,
FromKey: from.BusinessKey, ToKey: to.BusinessKey,
})
}
// ListCandidateEdges returns undecided candidate edges with business keys filled in.
func (s *Service) ListCandidateEdges(ctx context.Context, projectID int64) ([]Edge, error) {
edges, err := s.store.ListEdges(ctx, projectID)
if err != nil {
return nil, err
}
items, err := s.store.ListItems(ctx, projectID)
if err != nil {
return nil, err
}
byID := map[int64]Item{}
for _, it := range items {
byID[it.ID] = it
}
var out []Edge
for _, e := range edges {
if e.ReviewState != ReviewCandidate {
continue
}
if e.FromKey == "" {
e.FromKey = byID[e.FromItemID].BusinessKey
}
if e.ToKey == "" {
e.ToKey = byID[e.ToItemID].BusinessKey
}
out = append(out, e)
}
if out == nil {
out = []Edge{}
}
return out, nil
}
// ReplayDecisions re-applies the decision ledger onto edge review_state.
// Latest decision per edge wins. Repair rebuilds must call this so rejected
// candidates cannot re-enter confirmed walks.
func (s *Service) ReplayDecisions(ctx context.Context, projectID int64) (applied int, err error) {
decisions, err := s.store.ListDecisions(ctx, projectID)
if err != nil {
return 0, err
}
latest := map[int64]Decision{}
for _, d := range decisions {
latest[d.EdgeID] = d
}
for edgeID, d := range latest {
review := ReviewConfirmed
if d.Decision == DecisionReject {
review = ReviewRejected
}
if err := s.store.SetEdgeReview(ctx, edgeID, review); err != nil {
return applied, err
}
applied++
}
return applied, nil
}
// ListDecisions returns the authoritative decision ledger for a project.
func (s *Service) ListDecisions(ctx context.Context, projectID int64) ([]Decision, error) {
out, err := s.store.ListDecisions(ctx, projectID)
if err == nil && out == nil {
out = []Decision{}
}
return out, err
}
// NeedsRecheckAck is true when the stage still has an open mark.
// Acknowledgement does not clear the mark.
func (s *Service) NeedsRecheckAck(ctx context.Context, projectID int64, stageKey string) (bool, error) {
marks, err := s.store.ListOpenMarks(ctx, projectID, stageKey)
if err != nil {
return false, err
}
return len(marks) > 0, nil
}
// AcknowledgeRecheck records that the creator saw the marks. Marks stay open.
func (s *Service) AcknowledgeRecheck(ctx context.Context, projectID int64, stageKey string, userID int64) error {
marks, err := s.store.ListOpenMarks(ctx, projectID, stageKey)
if err != nil {
return err
}
for _, m := range marks {
if err := s.store.AckMark(ctx, m.ID, userID); err != nil {
return err
}
}
return nil
}
// ClearRecheck closes a mark only for an explicit human reason.
func (s *Service) ClearRecheck(ctx context.Context, markID int64, reason string) error {
switch reason {
case ClearNoChange, ClearRevision:
default:
return fmt.Errorf("traceimpact: recheck marks close only by no_change or revision_started")
}
return s.store.SetMarkStatus(ctx, markID, MarkCleared, reason)
}
// ReopenMark opens a previously cleared mark again, for example after a rejected proposal.
func (s *Service) ReopenMark(ctx context.Context, markID int64, reason string) error {
return s.store.SetMarkStatus(ctx, markID, MarkOpen, reason)
}
// RequestReopen stores a request. Creators are approved immediately and applied.
func (s *Service) RequestReopen(ctx context.Context, req ReopenRequest, creator bool) (ReopenRequest, error) {
if req.StageKey == "" || len(req.DocTasks) == 0 || strings.TrimSpace(req.Reason) == "" {
return ReopenRequest{}, fmt.Errorf("traceimpact: stage, documents, and reason are required")
}
req.Status = ReopenPending
stored, err := s.store.PutReopen(ctx, req)
if err != nil {
return ReopenRequest{}, err
}
if creator {
return s.ApproveReopen(ctx, stored.ID, req.RequestedBy)
}
return stored, nil
}
// ApproveReopen lets the creator apply a pending request.
func (s *Service) ApproveReopen(ctx context.Context, requestID, creatorID int64) (ReopenRequest, error) {
req, err := s.store.GetReopen(ctx, requestID)
if err != nil {
return ReopenRequest{}, err
}
if req.Status == ReopenApproved {
return req, nil
}
if s.reopen == nil {
return ReopenRequest{}, fmt.Errorf("traceimpact: stage reopen is not configured")
}
if err := s.reopen.ReopenCompletedStage(ctx, req.InstanceID, req.StageKey, req.DocTasks, creatorID); err != nil {
return ReopenRequest{}, err
}
if s.unlock != nil {
if err := s.unlock.ReopenApprovedDocs(ctx, req.ProjectID, req.DocTasks, creatorID, req.Reason); err != nil {
return ReopenRequest{}, err
}
}
req.Status = ReopenApproved
return s.store.PutReopen(ctx, req)
}
func (s *Service) ListItems(ctx context.Context, projectID int64) ([]Item, error) {
out, err := s.store.ListItems(ctx, projectID)
if err == nil && out == nil {
out = []Item{}
}
return out, err
}
func (s *Service) ListIndex(ctx context.Context, projectID int64) ([]IndexState, error) {
out, err := s.store.ListIndex(ctx, projectID)
if err == nil && out == nil {
out = []IndexState{}
}
return out, err
}
func (s *Service) ListOpenMarks(ctx context.Context, projectID int64, stageKey string) ([]RecheckMark, error) {
out, err := s.store.ListOpenMarks(ctx, projectID, stageKey)
if err == nil && out == nil {
out = []RecheckMark{}
}
return out, err
}
func (s *Service) GetReopen(ctx context.Context, id int64) (ReopenRequest, error) {
return s.store.GetReopen(ctx, id)
}
func (s *Service) ListPendingReopens(ctx context.Context, projectID int64) ([]ReopenRequest, error) {
if s == nil || s.store == nil {
return nil, nil
}
out, err := s.store.ListPendingReopens(ctx, projectID)
if err == nil && out == nil {
out = []ReopenRequest{}
}
return out, err
}
// ErrNeedsConfirm is returned when a revision proposal is requested before confirmation.
var ErrNeedsConfirm = fmt.Errorf("traceimpact: confirmation required")
// ProposeRevision records a proposal for a human author. It never edits controlled content.
func (s *Service) ProposeRevision(ctx context.Context, projectID, userID int64, text string, confirmed bool) (string, error) {
if !confirmed {
return "", ErrNeedsConfirm
}
if projectID <= 0 || strings.TrimSpace(text) == "" {
return "", fmt.Errorf("traceimpact: project and proposal text are required")
}
id, err := s.store.AddProposal(ctx, projectID, userID, strings.TrimSpace(text))
if err != nil {
return "", err
}
return fmt.Sprintf("proposal-%d-by-%d", id, userID), nil
}