feat(trace): deliver recheck and impact notices in chat
A new recheck mark is posted into the actor's existing project session, and a downstream-impact event does the same. Asking about what a change affects still resolves to impact analysis. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -16,6 +16,7 @@ import (
|
||||
"chat-agent-system/internal/app/docproject"
|
||||
"chat-agent-system/internal/app/intentflow"
|
||||
"chat-agent-system/internal/config"
|
||||
"chat-agent-system/internal/ctxmgr"
|
||||
"chat-agent-system/internal/db"
|
||||
"chat-agent-system/internal/docgov"
|
||||
"chat-agent-system/internal/events"
|
||||
@@ -75,6 +76,7 @@ func main() {
|
||||
)
|
||||
_ = disp.Register(events.ConsumerNotify, (&docnotify.EventConsumer{
|
||||
Notify: notify, Docs: gov, Env: cfg.Env,
|
||||
Chat: ctxmgr.NewContextManager(pool, nil, ctxmgr.TimeDecayConfig{}),
|
||||
}).HandleLease)
|
||||
_ = disp.Register(events.ConsumerWorkflowAdvance, (&docadvance.EventConsumer{
|
||||
Inst: instSvc, Docs: gov, PW: pwSvc, Notify: notify, Env: cfg.Env,
|
||||
|
||||
+2
-1
@@ -196,6 +196,7 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) {
|
||||
traceSvc := traceimpact.Open(pool)
|
||||
traceSvc.SetStageReopener(traceStage{inst: instSvc})
|
||||
traceSvc.SetDocUnlocker(traceUnlock{gov: parts.Gov})
|
||||
bindRecheckChat(traceSvc, parts.Ctx)
|
||||
if instSvc != nil {
|
||||
instSvc.SetRecheckGate(traceRecheck{svc: traceSvc})
|
||||
}
|
||||
@@ -255,7 +256,7 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) {
|
||||
instSvc.SetAuthorOnlyPolicySource(&sysadminAuthorOnlyPolicyAdapter{svc: rt.SysAdmin})
|
||||
svc.SetInstanceDutySource(&projectInstanceDutyAdapter{inst: instSvc})
|
||||
}
|
||||
rt.DocNotify = &docnotify.Subscriber{Notify: rt.Notify, Docs: parts.Gov}
|
||||
rt.DocNotify = &docnotify.Subscriber{Notify: rt.Notify, Docs: parts.Gov, Chat: parts.Ctx}
|
||||
rt.DocNotify.SkipWrite = func(ctx context.Context) bool {
|
||||
return rt.WriterLive(ctx, events.ConsumerNotify)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package docnotify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"chat-agent-system/internal/ctxmgr"
|
||||
)
|
||||
|
||||
// PostProjectNotice writes an assistant message into the actor's latest project session.
|
||||
// It does not create a session. Missing chat, user, or session is a no-op.
|
||||
func PostProjectNotice(ctx context.Context, chat *ctxmgr.ContextManager, projectID, userID int64, content string, meta map[string]any) error {
|
||||
if chat == nil || projectID <= 0 || userID <= 0 || content == "" {
|
||||
return nil
|
||||
}
|
||||
sessions, err := chat.ListChatSessions(ctx, ctxmgr.SessionFilter{UserID: userID, ProjectID: projectID, Limit: 1})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(sessions) == 0 || sessions[0].SessionID == "" {
|
||||
return nil
|
||||
}
|
||||
if meta == nil {
|
||||
meta = map[string]any{}
|
||||
}
|
||||
if _, ok := meta["project_id"]; !ok {
|
||||
meta["project_id"] = projectID
|
||||
}
|
||||
_, err = chat.RecordUserMessageWithID(ctx, sessions[0].SessionID, 0, "assistant", content, "IMPACT_ANALYZE", "", meta)
|
||||
if err != nil {
|
||||
return fmt.Errorf("post project notice: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func downstreamChatBody(docID, impacted string) string {
|
||||
switch {
|
||||
case docID != "" && impacted != "":
|
||||
return fmt.Sprintf("文档 %s 的变更可能影响 %s。需要时可以说「分析影响」。", docID, impacted)
|
||||
case docID != "":
|
||||
return fmt.Sprintf("文档 %s 有变更,下游内容可能受影响。需要时可以说「分析影响」。", docID)
|
||||
default:
|
||||
return "有文档变更,下游内容可能受影响。需要时可以说「分析影响」。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package docnotify
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/ctxmgr"
|
||||
)
|
||||
|
||||
func TestPostProjectNoticeSkipsWithoutSession(t *testing.T) {
|
||||
chat := ctxmgr.NewContextManager(nil, nil, ctxmgr.TimeDecayConfig{})
|
||||
if err := PostProjectNotice(t.Context(), chat, 7, 3, "上游有变更", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
history, err := chat.GetChatHistory(t.Context(), "sess-missing", 10, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(history) != 0 {
|
||||
t.Fatalf("must not create a session, history=%d", len(history))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostProjectNoticeWritesActorSession(t *testing.T) {
|
||||
chat := ctxmgr.NewContextManager(nil, nil, ctxmgr.TimeDecayConfig{})
|
||||
if _, err := chat.EnsureChatSession(t.Context(), "sess-p7-u3", 3, 7, "项目对话"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := PostProjectNotice(t.Context(), chat, 7, 3, "文档 d 的变更可能影响 down。", map[string]any{
|
||||
"interactive_card": map[string]any{"cardType": "RECHECK_NOTICE"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
history, err := chat.GetChatHistory(t.Context(), "sess-p7-u3", 10, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(history) != 1 || history[0].Role != "assistant" {
|
||||
t.Fatalf("history=%+v", history)
|
||||
}
|
||||
if history[0].Metadata["interactive_card"] == nil {
|
||||
t.Fatalf("metadata=%+v", history[0].Metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownstreamChatBody(t *testing.T) {
|
||||
got := downstreamChatBody("doc-a", "doc-b")
|
||||
if !strings.Contains(got, "doc-a") || !strings.Contains(got, "doc-b") || !strings.Contains(got, "分析影响") {
|
||||
t.Fatalf("body=%q", got)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"chat-agent-system/internal/ctxmgr"
|
||||
"chat-agent-system/internal/events"
|
||||
"chat-agent-system/internal/notification"
|
||||
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
type EventConsumer struct {
|
||||
Notify *notification.Service
|
||||
Docs DocumentLookup
|
||||
Chat *ctxmgr.ContextManager
|
||||
Env string
|
||||
}
|
||||
|
||||
@@ -57,7 +59,14 @@ func (c *EventConsumer) HandleLease(ctx context.Context, lease events.Lease, tx
|
||||
return events.ErrQuarantine
|
||||
}
|
||||
p := notification.DocumentEventPayload{ProjectID: projectID, DocID: docID}
|
||||
return c.Notify.EmitVisibleDocumentEvent(ctx, tx, lease.EventID, kind, title, body, recipient, p)
|
||||
if err := c.Notify.EmitVisibleDocumentEvent(ctx, tx, lease.EventID, kind, title, body, recipient, p); err != nil {
|
||||
return err
|
||||
}
|
||||
if lease.EventName == events.EventDownstreamImpact && c.Chat != nil {
|
||||
impacted, _ := payload["impacted_doc_id"].(string)
|
||||
_ = PostProjectNotice(ctx, c.Chat, projectID, recipient, downstreamChatBody(docID, impacted), nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func IntendedNotifyDigest(eventID, kind string, recipient int64) string {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"chat-agent-system/internal/ctxmgr"
|
||||
"chat-agent-system/internal/docgov"
|
||||
"chat-agent-system/internal/docrevision"
|
||||
"chat-agent-system/internal/notification"
|
||||
@@ -19,6 +20,7 @@ type DocumentLookup interface {
|
||||
type Subscriber struct {
|
||||
Notify notificationEmitter
|
||||
Docs DocumentLookup
|
||||
Chat *ctxmgr.ContextManager
|
||||
// SkipWrite is true when the durable notify consumer is live.
|
||||
// Bind stays registered while mode=off; the closure must not dual-write after switch.
|
||||
SkipWrite func(ctx context.Context) bool
|
||||
@@ -88,7 +90,10 @@ func (s *Subscriber) Handle(ctx context.Context, eventType string, payload map[s
|
||||
_, err := s.Notify.EmitDocumentEvent(ctx, eventType, notification.TypeDocDownstreamImpact,
|
||||
"下游文档受影响", "上游文档变更,请复核关联文档。",
|
||||
fmt.Sprintf("doc_impact:%s:%s:%d", docID, impacted, recipient), recipient, p)
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return PostProjectNotice(ctx, s.Chat, projectID, recipient, downstreamChatBody(docID, impacted), nil)
|
||||
case notification.EventDocChangeReportDegraded:
|
||||
if v, ok := payload["report_id"].(int64); ok {
|
||||
p.ReportID = v
|
||||
|
||||
@@ -7,7 +7,9 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"chat-agent-system/internal/app/docnotify"
|
||||
"chat-agent-system/internal/coordinator"
|
||||
"chat-agent-system/internal/ctxmgr"
|
||||
"chat-agent-system/internal/docgov"
|
||||
"chat-agent-system/internal/projectinstance"
|
||||
"chat-agent-system/internal/traceimpact"
|
||||
@@ -60,15 +62,54 @@ func (t traceUnlock) ReopenApprovedDocs(ctx context.Context, projectID int64, do
|
||||
|
||||
type traceChat struct{ svc *traceimpact.Service }
|
||||
|
||||
func (t traceChat) AnalyzeImpact(ctx context.Context, projectID, _ int64, text string) (string, error) {
|
||||
func (t traceChat) AnalyzeImpact(ctx context.Context, projectID, userID int64, text string) (string, error) {
|
||||
if t.svc == nil {
|
||||
return "", fmt.Errorf("trace: unavailable")
|
||||
}
|
||||
ctx = traceimpact.WithActor(ctx, userID)
|
||||
rep, err := t.svc.Analyze(ctx, projectID, traceimpact.DocRef{DocID: "chat", Markdown: text, Visible: true}, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return rep.Summary, nil
|
||||
return formatImpactReply(rep), nil
|
||||
}
|
||||
|
||||
func formatImpactReply(rep *traceimpact.Report) string {
|
||||
if rep == nil || len(rep.Impacts) == 0 {
|
||||
if rep.Summary != "" {
|
||||
return rep.Summary
|
||||
}
|
||||
return "目前没有已确认的影响。可以说「分析影响」并带上条目编号,例如 UR-001。"
|
||||
}
|
||||
var b strings.Builder
|
||||
if rep.Summary != "" {
|
||||
b.WriteString(rep.Summary)
|
||||
b.WriteString("。")
|
||||
}
|
||||
limit := len(rep.Impacts)
|
||||
if limit > 8 {
|
||||
limit = 8
|
||||
}
|
||||
for i := 0; i < limit; i++ {
|
||||
imp := rep.Impacts[i]
|
||||
label := imp.ItemKey
|
||||
if label == "" {
|
||||
label = imp.DocID
|
||||
}
|
||||
if label == "" {
|
||||
continue
|
||||
}
|
||||
b.WriteString("\n- ")
|
||||
b.WriteString(label)
|
||||
if imp.Reason != "" {
|
||||
b.WriteString(":")
|
||||
b.WriteString(imp.Reason)
|
||||
}
|
||||
}
|
||||
if len(rep.Impacts) > limit {
|
||||
fmt.Fprintf(&b, "\n另有 %d 条未展开。", len(rep.Impacts)-limit)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func projectIDFromControlledDoc(docID string) int64 {
|
||||
@@ -86,6 +127,42 @@ func projectIDFromControlledDoc(docID string) int64 {
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func bindRecheckChat(svc *traceimpact.Service, chat *ctxmgr.ContextManager) {
|
||||
if svc == nil || chat == nil {
|
||||
return
|
||||
}
|
||||
svc.SetRecheckHook(func(ctx context.Context, mark traceimpact.RecheckMark) {
|
||||
userID := traceimpact.ActorID(ctx)
|
||||
reason := mark.Reason
|
||||
if reason == "" {
|
||||
reason = mark.SourceDoc
|
||||
}
|
||||
content := fmt.Sprintf("阶段 %s 待复核(%s)。需要细节可以说「分析影响」。", mark.StageKey, reason)
|
||||
card := map[string]any{
|
||||
"cardId": fmt.Sprintf("recheck-%d", mark.ID),
|
||||
"cardType": "RECHECK_NOTICE",
|
||||
"title": "阶段待复核",
|
||||
"proposedContent": content,
|
||||
"status": "active",
|
||||
"ttlTurns": 8,
|
||||
"actions": []any{
|
||||
map[string]any{
|
||||
"actionId": "recheck_no_change",
|
||||
"label": "无需变更",
|
||||
"style": "primary",
|
||||
"payload": map[string]string{
|
||||
"project_id": strconv.FormatInt(mark.ProjectID, 10),
|
||||
"mark_id": strconv.FormatInt(mark.ID, 10),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
_ = docnotify.PostProjectNotice(ctx, chat, mark.ProjectID, userID, content, map[string]any{
|
||||
"interactive_card": card,
|
||||
})
|
||||
})
|
||||
}
|
||||
func (t traceChat) ProposeRevision(ctx context.Context, projectID, userID int64, text string, confirmed bool) (string, error) {
|
||||
if t.svc == nil {
|
||||
return "", fmt.Errorf("trace: unavailable")
|
||||
|
||||
@@ -108,6 +108,48 @@ func (m *ContextManager) FinalizeAssistantTaskMessage(ctx context.Context, messa
|
||||
return errors.New("message not found")
|
||||
}
|
||||
|
||||
// SetInteractiveCardStatus marks a stored chat card resolved or otherwise closed.
|
||||
func (m *ContextManager) SetInteractiveCardStatus(ctx context.Context, messageID int64, status, summary string) error {
|
||||
if m == nil || messageID <= 0 || status == "" {
|
||||
return errors.New("invalid card status update")
|
||||
}
|
||||
if m.pool != nil || hasUoW(ctx) {
|
||||
_, err := execChat(ctx, m.pool, `
|
||||
UPDATE chat_messages
|
||||
SET metadata = jsonb_set(
|
||||
jsonb_set(COALESCE(metadata, '{}'::jsonb), '{interactive_card,status}', to_jsonb($2::text), true),
|
||||
'{interactive_card,resolutionSummary}', to_jsonb($3::text), true)
|
||||
WHERE id = $1
|
||||
`, messageID, status, summary)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set interactive card status: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for sid, msgs := range m.messages {
|
||||
for i := range msgs {
|
||||
if msgs[i].ID != messageID {
|
||||
continue
|
||||
}
|
||||
card, _ := msgs[i].Metadata["interactive_card"].(map[string]any)
|
||||
if card == nil {
|
||||
card = map[string]any{}
|
||||
}
|
||||
card["status"] = status
|
||||
card["resolutionSummary"] = summary
|
||||
if msgs[i].Metadata == nil {
|
||||
msgs[i].Metadata = map[string]any{}
|
||||
}
|
||||
msgs[i].Metadata["interactive_card"] = card
|
||||
m.messages[sid][i] = msgs[i]
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New("message not found")
|
||||
}
|
||||
|
||||
func hasUoW(ctx context.Context) bool {
|
||||
_, ok := uow.ScopeFrom(ctx)
|
||||
return ok
|
||||
|
||||
@@ -1240,7 +1240,8 @@ func isGreetingOrHelp(input string) bool {
|
||||
|
||||
func matchImpactIntent(input string) *CandidateIntent {
|
||||
propose := strings.Contains(input, "修订提案") || strings.Contains(input, "生成修订") || strings.Contains(input, "提议修改文档")
|
||||
analyze := strings.Contains(input, "分析影响") || strings.Contains(input, "影响分析") || strings.Contains(input, "波及哪些")
|
||||
analyze := strings.Contains(input, "分析影响") || strings.Contains(input, "影响分析") || strings.Contains(input, "波及哪些") ||
|
||||
strings.Contains(input, "影响哪些") || strings.Contains(input, "改动影响") || strings.Contains(input, "变更影响")
|
||||
if !propose && !analyze {
|
||||
return nil
|
||||
}
|
||||
@@ -1249,8 +1250,8 @@ func matchImpactIntent(input string) *CandidateIntent {
|
||||
intent = IntentRevisePropose
|
||||
}
|
||||
return &CandidateIntent{
|
||||
Intent: intent,
|
||||
Confidence: 0.9,
|
||||
Intent: intent,
|
||||
Confidence: 0.9,
|
||||
FilledSlots: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -773,3 +773,16 @@ func TestIntentRecognition_PermissionMismatch_NotHijackedBySupplement(t *testing
|
||||
t.Errorf("expected branch NEW_INTENT_PREEMPT for permission mismatch, got %s", res.Branch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntentRecognition_ImpactPhrases(t *testing.T) {
|
||||
rec := intent.NewRecognizer()
|
||||
for _, text := range []string{"请分析影响", "这次改动影响哪些文档", "帮我做一下变更影响"} {
|
||||
res, err := rec.Recognize(context.Background(), text, &intent.ContextSnapshot{SnapshotID: "impact"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.TopIntent.Intent != intent.IntentImpactAnalyze {
|
||||
t.Fatalf("%q intent=%s", text, res.TopIntent.Intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ func (s *Server) handleProjectTraceIndex(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
func (s *Server) handleProjectTraceAnalyze(w http.ResponseWriter, r *http.Request) {
|
||||
projectID, _, ok := s.traceProject(w, r)
|
||||
projectID, user, ok := s.traceProject(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -72,7 +72,7 @@ func (s *Server) handleProjectTraceAnalyze(w http.ResponseWriter, r *http.Reques
|
||||
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`)
|
||||
return
|
||||
}
|
||||
rep, err := s.rt.Trace.Analyze(r.Context(), projectID, body.DocRef, body.Others)
|
||||
rep, err := s.rt.Trace.Analyze(traceimpact.WithActor(r.Context(), user.ID), projectID, body.DocRef, body.Others)
|
||||
if err != nil {
|
||||
writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
|
||||
return
|
||||
@@ -176,7 +176,8 @@ func (s *Server) handleProjectTraceRecheckClear(w http.ResponseWriter, r *http.R
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Reason string `json:"reason"`
|
||||
Reason string `json:"reason"`
|
||||
MessageID int64 `json:"message_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && err != io.EOF {
|
||||
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`)
|
||||
@@ -202,6 +203,9 @@ func (s *Server) handleProjectTraceRecheckClear(w http.ResponseWriter, r *http.R
|
||||
writeJSONResponse(w, http.StatusBadRequest, map[string]string{"error": "TRACE_RECHECK_CLEAR_FAILED", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
if body.MessageID > 0 && s.ctxMgr != nil {
|
||||
_ = s.ctxMgr.SetInteractiveCardStatus(r.Context(), body.MessageID, "resolved", "已标记为无需变更")
|
||||
}
|
||||
writeJSONResponse(w, http.StatusOK, map[string]string{"status": "cleared"})
|
||||
}
|
||||
|
||||
|
||||
@@ -21,9 +21,10 @@ type DocUnlocker interface {
|
||||
|
||||
// Service is the project trace index and the stage-backtrack ledger.
|
||||
type Service struct {
|
||||
store Store
|
||||
reopen StageReopener
|
||||
unlock DocUnlocker
|
||||
store Store
|
||||
reopen StageReopener
|
||||
unlock DocUnlocker
|
||||
onRecheck func(ctx context.Context, mark RecheckMark)
|
||||
}
|
||||
|
||||
func NewService(store Store) *Service {
|
||||
@@ -40,6 +41,30 @@ func NewMemoryService() *Service {
|
||||
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) {
|
||||
@@ -186,10 +211,22 @@ func (s *Service) Analyze(ctx context.Context, projectID int64, source DocRef, o
|
||||
Reason: fmt.Sprintf("%s 经已确认关系受本次修改影响", it.BusinessKey),
|
||||
})
|
||||
if target.StageStatus == "completed" && target.StageKey != "" {
|
||||
_, _ = s.store.PutMark(ctx, RecheckMark{
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,13 @@ func TestAnalyze_PendingIsNotNoImpact(t *testing.T) {
|
||||
func TestAnalyze_ConfirmedDownstreamAndRecheck(t *testing.T) {
|
||||
svc := NewMemoryService()
|
||||
ctx := t.Context()
|
||||
var notices int
|
||||
svc.SetRecheckHook(func(_ context.Context, mark RecheckMark) {
|
||||
notices++
|
||||
if mark.StageKey != "V0.5" || mark.ID <= 0 {
|
||||
t.Fatalf("mark %+v", mark)
|
||||
}
|
||||
})
|
||||
if _, err := svc.IndexDocument(ctx, 7, DocRef{DocID: "up", Markdown: "UR-001 用户需要记录"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -98,6 +105,17 @@ func TestAnalyze_ConfirmedDownstreamAndRecheck(t *testing.T) {
|
||||
if err := svc.ClearRecheck(ctx, marks[0].ID, "auto"); err == nil {
|
||||
t.Fatal("auto clear must fail")
|
||||
}
|
||||
if notices != 1 {
|
||||
t.Fatalf("recheck hook calls=%d", notices)
|
||||
}
|
||||
if _, err := svc.Analyze(ctx, 7, DocRef{DocID: "up", Markdown: "UR-001 改为必须记录"}, []DocRef{{
|
||||
DocID: "down", StageKey: "V0.5", StageStatus: "completed", Visible: true,
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if notices != 1 {
|
||||
t.Fatalf("repeat analyze must not notify again, calls=%d", notices)
|
||||
}
|
||||
if err := svc.ClearRecheck(ctx, marks[0].ID, ClearNoChange); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user