From 687f930f403685a982761ebe99fab6cd45205a5c Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 05:19:43 +0000 Subject: [PATCH 1/4] 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 --- cmd/events/main.go | 2 + internal/app/app.go | 3 +- internal/app/docnotify/chat_notice.go | 45 ++++++++++++ internal/app/docnotify/chat_notice_test.go | 51 ++++++++++++++ internal/app/docnotify/consumer.go | 11 ++- internal/app/docnotify/subscriber.go | 7 +- internal/app/trace_bridge.go | 81 +++++++++++++++++++++- internal/ctxmgr/message_update.go | 42 +++++++++++ internal/intent/recognizer.go | 7 +- internal/intent/recognizer_test.go | 13 ++++ internal/server/trace_handlers.go | 10 ++- internal/traceimpact/service.go | 45 ++++++++++-- internal/traceimpact/service_test.go | 18 +++++ 13 files changed, 320 insertions(+), 15 deletions(-) create mode 100644 internal/app/docnotify/chat_notice.go create mode 100644 internal/app/docnotify/chat_notice_test.go diff --git a/cmd/events/main.go b/cmd/events/main.go index a14fef81..39c9c00c 100644 --- a/cmd/events/main.go +++ b/cmd/events/main.go @@ -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, diff --git a/internal/app/app.go b/internal/app/app.go index e7a18a86..af8e8ca5 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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) } diff --git a/internal/app/docnotify/chat_notice.go b/internal/app/docnotify/chat_notice.go new file mode 100644 index 00000000..7b85c7f3 --- /dev/null +++ b/internal/app/docnotify/chat_notice.go @@ -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 "有文档变更,下游内容可能受影响。需要时可以说「分析影响」。" + } +} diff --git a/internal/app/docnotify/chat_notice_test.go b/internal/app/docnotify/chat_notice_test.go new file mode 100644 index 00000000..12ef7854 --- /dev/null +++ b/internal/app/docnotify/chat_notice_test.go @@ -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) + } +} diff --git a/internal/app/docnotify/consumer.go b/internal/app/docnotify/consumer.go index ebaebb39..8d70d6d8 100644 --- a/internal/app/docnotify/consumer.go +++ b/internal/app/docnotify/consumer.go @@ -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 { diff --git a/internal/app/docnotify/subscriber.go b/internal/app/docnotify/subscriber.go index 1e516c85..15f45dfc 100644 --- a/internal/app/docnotify/subscriber.go +++ b/internal/app/docnotify/subscriber.go @@ -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 diff --git a/internal/app/trace_bridge.go b/internal/app/trace_bridge.go index 0b682b5c..409308f7 100644 --- a/internal/app/trace_bridge.go +++ b/internal/app/trace_bridge.go @@ -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") diff --git a/internal/ctxmgr/message_update.go b/internal/ctxmgr/message_update.go index e4e655b0..c57a03c8 100644 --- a/internal/ctxmgr/message_update.go +++ b/internal/ctxmgr/message_update.go @@ -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 diff --git a/internal/intent/recognizer.go b/internal/intent/recognizer.go index 76ecb93a..d2c1ba33 100644 --- a/internal/intent/recognizer.go +++ b/internal/intent/recognizer.go @@ -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{}, } } diff --git a/internal/intent/recognizer_test.go b/internal/intent/recognizer_test.go index ef747b2e..e0f4e2f6 100644 --- a/internal/intent/recognizer_test.go +++ b/internal/intent/recognizer_test.go @@ -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) + } + } +} diff --git a/internal/server/trace_handlers.go b/internal/server/trace_handlers.go index 43fdb2e1..ecda0b2e 100644 --- a/internal/server/trace_handlers.go +++ b/internal/server/trace_handlers.go @@ -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"}) } diff --git a/internal/traceimpact/service.go b/internal/traceimpact/service.go index 43844786..eee97e04 100644 --- a/internal/traceimpact/service.go +++ b/internal/traceimpact/service.go @@ -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) + } } } } diff --git a/internal/traceimpact/service_test.go b/internal/traceimpact/service_test.go index 1847a4a3..3157dd35 100644 --- a/internal/traceimpact/service_test.go +++ b/internal/traceimpact/service_test.go @@ -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) } From d865342ae2bfdee0c436eae015bb62ab99f1d780 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 05:19:43 +0000 Subject: [PATCH 2/4] feat(web): replace the standing trace panel with a chat card The requirements and impact summary no longer occupies the chat column. Open recheck marks arrive as an assistant card with a no-change action. Co-authored-by: Cursor --- web/src/App.tsx | 2 - web/src/api/projectTrace.ts | 9 ++- web/src/components/chat/InteractiveCard.tsx | 10 +++ .../components/workflow/ProjectTracePanel.tsx | 78 ------------------- .../workflow/tracePanelRetired.test.ts | 10 +++ web/src/hooks/useCardActions.ts | 27 +++++++ web/src/types/workbench.ts | 3 +- 7 files changed, 56 insertions(+), 83 deletions(-) delete mode 100644 web/src/components/workflow/ProjectTracePanel.tsx create mode 100644 web/src/components/workflow/tracePanelRetired.test.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index abddb5ae..fa5d6f48 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -34,7 +34,6 @@ import { import { DocumentLineage } from './types/projectWorkflow'; import { StaleDocumentNotice } from './components/workflow/StaleDocumentNotice'; import { StageAdvanceBanner } from './components/workflow/StageAdvanceBanner'; -import { ProjectTracePanel } from './components/workflow/ProjectTracePanel'; import { OnDemand } from './components/lazy/OnDemand'; import { fetchProjectSettings } from './api/projectSettings'; import { LoginView } from './components/auth/LoginView'; @@ -435,7 +434,6 @@ export default function App() { isCreator={currentWfState.creatorId === currentUser.id} /> )} - {currentProjectId > 0 && } { diff --git a/web/src/api/projectTrace.ts b/web/src/api/projectTrace.ts index af0263d6..da4a1273 100644 --- a/web/src/api/projectTrace.ts +++ b/web/src/api/projectTrace.ts @@ -49,11 +49,16 @@ export async function fetchProjectTrace(projectId: number): Promise { +export async function clearRecheckMark( + projectId: number, + markId: number, + reason: 'no_change' | 'revision_started', + messageId?: number, +): Promise { const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/trace/recheck/${markId}/clear`, { method: 'POST', headers: getBearerAuthHeaders('application/json'), - body: JSON.stringify({ reason }), + body: JSON.stringify({ reason, message_id: messageId || 0 }), }); if (!res.ok) { throw new Error(`关闭待复核失败: ${res.status}`); diff --git a/web/src/components/chat/InteractiveCard.tsx b/web/src/components/chat/InteractiveCard.tsx index 97f8bf43..443648ee 100644 --- a/web/src/components/chat/InteractiveCard.tsx +++ b/web/src/components/chat/InteractiveCard.tsx @@ -82,6 +82,16 @@ export function getCardTypeMeta(type: InteractiveCardType) { badgeColor: 'var(--info)', badgeBorder: 'rgba(43, 108, 201, 0.2)', }; + case 'RECHECK_NOTICE': + return { + label: '待复核', + badgeText: '待复核', + icon: AlertTriangle, + accentColor: 'var(--amber-500)', + badgeBg: 'var(--amber-50)', + badgeColor: 'var(--amber-600)', + badgeBorder: 'rgba(242, 169, 59, 0.3)', + }; default: return { label: '协同卡片', diff --git a/web/src/components/workflow/ProjectTracePanel.tsx b/web/src/components/workflow/ProjectTracePanel.tsx deleted file mode 100644 index 8d4a3bf4..00000000 --- a/web/src/components/workflow/ProjectTracePanel.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import React, { useCallback, useEffect, useState } from 'react'; -import { clearRecheckMark, fetchProjectTrace, ProjectTraceView } from '../../api/projectTrace'; - -interface ProjectTracePanelProps { - projectId: number; -} - -export const ProjectTracePanel: React.FC = ({ projectId }) => { - const [view, setView] = useState(null); - const [error, setError] = useState(null); - - const load = useCallback(async () => { - try { - setView(await fetchProjectTrace(projectId)); - setError(null); - } catch (err: unknown) { - setError(err instanceof Error ? err.message : '加载失败'); - } - }, [projectId]); - - useEffect(() => { - load(); - }, [load]); - - if (error) { - return
需求与影响暂时不可用:{error}
; - } - if (!view) return null; - - return ( -
-
需求与影响
-
- 已索引 {view.index.length} 份文档 · 条目 {view.items.length} - {view.hidden_count > 0 ? ` · 另有 ${view.hidden_count} 条不可见` : ''} -
- {view.recheck_marks.length > 0 && ( -
    - {view.recheck_marks.map((mark) => ( -
  • - 阶段 {mark.stage_key} 待复核({mark.reason || mark.source_doc_id}) - -
  • - ))} -
- )} - {view.items.length > 0 && ( -
    - {view.items.slice(0, 8).map((item) => ( -
  • - {item.business_key} - {item.statement ? ` · ${item.statement}` : ''} -
  • - ))} -
- )} -
- ); -}; - -const boxStyle: React.CSSProperties = { - margin: '8px 12px 0', - padding: '10px 12px', - borderRadius: 8, - border: '1px solid var(--border, #e2e8f0)', - background: 'var(--bg-subtle, #f8fafc)', - fontSize: 12, -}; diff --git a/web/src/components/workflow/tracePanelRetired.test.ts b/web/src/components/workflow/tracePanelRetired.test.ts new file mode 100644 index 00000000..3a26c2d8 --- /dev/null +++ b/web/src/components/workflow/tracePanelRetired.test.ts @@ -0,0 +1,10 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +test('trace panel is not mounted above the chat stream', () => { + const app = readFileSync(new URL('../../App.tsx', import.meta.url), 'utf8'); + assert.equal(app.includes('ProjectTracePanel'), false); + const card = readFileSync(new URL('../chat/InteractiveCard.tsx', import.meta.url), 'utf8'); + assert.equal(card.includes("case 'RECHECK_NOTICE'"), true); +}); diff --git a/web/src/hooks/useCardActions.ts b/web/src/hooks/useCardActions.ts index 2fb78fe5..ae61d44b 100644 --- a/web/src/hooks/useCardActions.ts +++ b/web/src/hooks/useCardActions.ts @@ -1,4 +1,5 @@ import type { Dispatch, SetStateAction } from 'react'; +import { clearRecheckMark } from '../api/projectTrace'; import { fetchIntentPlan, submitIntentPlanDecision, @@ -69,6 +70,32 @@ export function useCardActions(input: CardActionsInput) { } } + if (!targetCard) return; + + if (targetCard.cardType === 'RECHECK_NOTICE' && actionId === 'recheck_no_change') { + const projectId = Number(payload?.project_id || currentProjectId); + const markId = Number(payload?.mark_id); + const serverId = messages.find((m) => m.interactiveCard?.cardId === cardId)?.serverId; + void clearRecheckMark(projectId, markId, 'no_change', serverId) + .then(() => { + setMessages((prev) => resolveCardInMessages(prev, cardId, '已标记为无需变更', currentUser.name)); + }) + .catch(() => { + setMessages((prev) => [ + ...prev, + { + id: 'recheck-fail-' + Date.now(), + sessionId: activeSessionId, + role: 'assistant', + authorName: '项目助手', + content: '没能关闭这条待复核,请稍后再试。', + timestamp: new Date().toTimeString().substring(0, 5), + }, + ]); + }); + return; + } + if (isPlanConfirmCard(targetCard, payload)) { const planId = String(payload?.plan_id || targetCard?.planId || ''); const expectedRevision = Number(payload?.expected_revision || targetCard?.planRevision || 0); diff --git a/web/src/types/workbench.ts b/web/src/types/workbench.ts index b83b0b0a..ba171e69 100644 --- a/web/src/types/workbench.ts +++ b/web/src/types/workbench.ts @@ -73,7 +73,8 @@ export type InteractiveCardType = | 'PLAN_CONFIRM' | 'SLOT_FILLING' | 'DIFF_PROPOSAL' - | 'REMINDER_NOTICE'; + | 'REMINDER_NOTICE' + | 'RECHECK_NOTICE'; export type CardActionStatus = | 'active' From f85b861a18f7780369be8e469617130bf69d2aef Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 05:20:30 +0000 Subject: [PATCH 3/4] docs(ops): claim the trace-to-chat change and the dev smoke slot Co-authored-by: Cursor --- .doc/ops/CURRENT.md | 8 ++-- .../2026-09-21-trace-panel-to-chat.md | 45 +++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 .doc/ops/short-term/2026-09-21-trace-panel-to-chat.md diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index f7105f02..e351c131 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -5,15 +5,15 @@ ## 焦点 -1. **任务作用域 409 与对话丢失已合入准备**:旧键 `intake` 对 `bd_intake` 快照可创建任务;陈威项目 6579 实测 202,窗口可读回对话(见 [报告](../test-reports/2026-09-21-taskrun-scope-persist-test-report.md))。test/main 未晋升。 -2. **任务错误透传已合入 dev**:`RunRecord` snake_case 与前端真实失败原因(见 [报告](../test-reports/2026-09-21-taskrun-error-transparency-test-report.md))。test/main 未晋升。 -2. **阶段回溯与影响闭环已合入 dev**:T1–T3 与 B1–B3 在 `dev`。空追踪 JSON `null` 已修复,登录后显示「已索引 0 份文档 · 条目 0」(见 [报告](../test-reports/2026-09-21-trace-panel-null-crash-test-report.md))。test/main 未晋升。不改 `production_ready`,不签 D7 对话 ADR。 +1. **需求与影响改为对话**:`feature/trace-panel-to-chat-2026-09-21` 进行中。常驻追踪面板移除,待复核与下游影响改由助手消息给出。见 [short-term](./short-term/2026-09-21-trace-panel-to-chat.md)。test/main 未晋升。 +2. **任务作用域 409 与对话丢失已合入 dev**:旧键 `intake` 对 `bd_intake` 快照可创建任务;陈威项目 6579 实测 202,窗口可读回对话(见 [报告](../test-reports/2026-09-21-taskrun-scope-persist-test-report.md))。test/main 未晋升。 3. **红线**:禁止整支 merge FAV;不默认升 `MULTI_INTENT_PLAN_TIER=full`;`SEMANTIC_SUMMARY_REPLACE` 不随 W2 开启;FAV live 观察窗证据驱动;`FAV_DEGRADED_READ=false`;项目 143 只读;SWBOOT-10 保持 fail-closed stub;D7 对话 ADR 仍待合规签字(追踪表留存类别已定为 `record`,不授权物理删除);**不改 `production_ready`**;模块解耦批次 A–G 与回溯实施切片未单独认领前保持 `planned`。 ## 并行认领 | owner | task | branch | worktree | mutex | status | |-------|------|--------|----------|-------|--------| +| Cursor | 2026-09-21-trace-panel-to-chat | feature/trace-panel-to-chat-2026-09-21 | - | trace-impact+web | in_progress | | Cursor | 2026-09-21-taskrun-scope-persist | fix/taskrun-scope-persist-2026-09-21 | - | projectinstance+taskrun | done | | Cursor | 2026-09-21-taskrun-error-transparency | fix/taskrun-error-transparency-2026-09-21 | - | chat+web | done | | Cursor | 2026-09-21-trace-panel-null-crash | fix/trace-panel-null-crash-2026-09-21 | - | trace-index+web | done | @@ -174,7 +174,7 @@ | holder | phase | since | scope | queue | |--------|-------|-------|-------|-------| -| - | idle | 2026-09-21 | 任务作用域键归一化已在 dev 容器验收;`/health` schema_version=59;项目 6579 任务 `run_1789967333594919025` 为 running | - | +| Cursor | smoke | 2026-09-21 | feature/trace-panel-to-chat-2026-09-21:dev rebuild,确认聊天区不再常驻需求与影响 | - | **phase**: `idle` | `merging` | `smoke` | `e2e` diff --git a/.doc/ops/short-term/2026-09-21-trace-panel-to-chat.md b/.doc/ops/short-term/2026-09-21-trace-panel-to-chat.md new file mode 100644 index 00000000..715ff4d5 --- /dev/null +++ b/.doc/ops/short-term/2026-09-21-trace-panel-to-chat.md @@ -0,0 +1,45 @@ +--- +title: "需求与影响改为对话输出" +status: in_progress +created: 2026-09-21 +updated: 2026-09-21 +owner: Cursor +branch: feature/trace-panel-to-chat-2026-09-21 +worktree: "-" +mutex: trace-impact+web +--- + +# 需求与影响改为对话输出 + +## 目标 + +聊天栏不再常驻「需求与影响」。待复核标记和新的下游影响通过助手消息给出;用户也可以说「分析影响」。 + +## 范围 + +- 移除 `ProjectTracePanel` +- 新的 recheck mark 写入操作者已有项目会话,卡片类型 `RECHECK_NOTICE`,「无需变更」仍调用原 clear 接口 +- `document.downstream_impact_detected` 向文档编制人的已有会话写一条助手消息 +- 影响意图增加「影响哪些 / 改动影响 / 变更影响」 + +## 非目标 + +- 无会话时不新建会话 +- 不改阶段推进必须确认的门禁 +- 不晋升 test/main + +## 步骤 + +1. [x] 面板移除与卡片 +2. [x] 后端通知与意图 +3. [ ] dev rebuild 与页面确认 + +## 验收标准 + +- [ ] 已选项目时聊天区顶部没有「需求与影响」 +- [ ] 新标记会进入已有会话;重复分析不重复发 +- [ ] 「这次改动影响哪些文档」识别为影响分析 + +## 相关链接 + +- Test report: `.doc/test-reports/2026-09-21-trace-panel-to-chat-test-report.md` From 14d1c7d0f77840aa15c22a76fc452b1e652e3e1e Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 05:24:16 +0000 Subject: [PATCH 4/4] docs(test): record trace panel retirement on dev Co-authored-by: Cursor --- .doc/ops/CURRENT.md | 6 +- .../2026-09-21-trace-panel-to-chat.md | 10 +- .doc/status/FEATURE-MATRIX.md | 2 +- ...6-09-21-trace-panel-to-chat-test-report.md | 104 ++++++++++++++++++ 4 files changed, 113 insertions(+), 9 deletions(-) create mode 100644 .doc/test-reports/2026-09-21-trace-panel-to-chat-test-report.md diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index e351c131..ede58738 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -5,7 +5,7 @@ ## 焦点 -1. **需求与影响改为对话**:`feature/trace-panel-to-chat-2026-09-21` 进行中。常驻追踪面板移除,待复核与下游影响改由助手消息给出。见 [short-term](./short-term/2026-09-21-trace-panel-to-chat.md)。test/main 未晋升。 +1. **需求与影响已改为对话**:常驻面板已从 dev 页面移除;新待复核与下游影响写入已有项目会话。见 [报告](../test-reports/2026-09-21-trace-panel-to-chat-test-report.md)。test/main 未晋升。 2. **任务作用域 409 与对话丢失已合入 dev**:旧键 `intake` 对 `bd_intake` 快照可创建任务;陈威项目 6579 实测 202,窗口可读回对话(见 [报告](../test-reports/2026-09-21-taskrun-scope-persist-test-report.md))。test/main 未晋升。 3. **红线**:禁止整支 merge FAV;不默认升 `MULTI_INTENT_PLAN_TIER=full`;`SEMANTIC_SUMMARY_REPLACE` 不随 W2 开启;FAV live 观察窗证据驱动;`FAV_DEGRADED_READ=false`;项目 143 只读;SWBOOT-10 保持 fail-closed stub;D7 对话 ADR 仍待合规签字(追踪表留存类别已定为 `record`,不授权物理删除);**不改 `production_ready`**;模块解耦批次 A–G 与回溯实施切片未单独认领前保持 `planned`。 @@ -13,7 +13,7 @@ | owner | task | branch | worktree | mutex | status | |-------|------|--------|----------|-------|--------| -| Cursor | 2026-09-21-trace-panel-to-chat | feature/trace-panel-to-chat-2026-09-21 | - | trace-impact+web | in_progress | +| Cursor | 2026-09-21-trace-panel-to-chat | feature/trace-panel-to-chat-2026-09-21 | - | trace-impact+web | done | | Cursor | 2026-09-21-taskrun-scope-persist | fix/taskrun-scope-persist-2026-09-21 | - | projectinstance+taskrun | done | | Cursor | 2026-09-21-taskrun-error-transparency | fix/taskrun-error-transparency-2026-09-21 | - | chat+web | done | | Cursor | 2026-09-21-trace-panel-null-crash | fix/trace-panel-null-crash-2026-09-21 | - | trace-index+web | done | @@ -174,7 +174,7 @@ | holder | phase | since | scope | queue | |--------|-------|-------|-------|-------| -| Cursor | smoke | 2026-09-21 | feature/trace-panel-to-chat-2026-09-21:dev rebuild,确认聊天区不再常驻需求与影响 | - | +| - | idle | 2026-09-21 | 需求与影响面板已从 dev 前端包移除;`/health` schema_version=59;pi-agent 为 MiniMax-M2.7-highspeed | - | **phase**: `idle` | `merging` | `smoke` | `e2e` diff --git a/.doc/ops/short-term/2026-09-21-trace-panel-to-chat.md b/.doc/ops/short-term/2026-09-21-trace-panel-to-chat.md index 715ff4d5..5324e8ec 100644 --- a/.doc/ops/short-term/2026-09-21-trace-panel-to-chat.md +++ b/.doc/ops/short-term/2026-09-21-trace-panel-to-chat.md @@ -1,6 +1,6 @@ --- title: "需求与影响改为对话输出" -status: in_progress +status: done created: 2026-09-21 updated: 2026-09-21 owner: Cursor @@ -32,13 +32,13 @@ mutex: trace-impact+web 1. [x] 面板移除与卡片 2. [x] 后端通知与意图 -3. [ ] dev rebuild 与页面确认 +3. [x] dev rebuild 与页面确认 ## 验收标准 -- [ ] 已选项目时聊天区顶部没有「需求与影响」 -- [ ] 新标记会进入已有会话;重复分析不重复发 -- [ ] 「这次改动影响哪些文档」识别为影响分析 +- [x] 已选项目时聊天区顶部没有「需求与影响」 +- [x] 新标记会进入已有会话;重复分析不重复发 +- [x] 「这次改动影响哪些文档」识别为影响分析 ## 相关链接 diff --git a/.doc/status/FEATURE-MATRIX.md b/.doc/status/FEATURE-MATRIX.md index 12672f31..7f3a0385 100644 --- a/.doc/status/FEATURE-MATRIX.md +++ b/.doc/status/FEATURE-MATRIX.md @@ -62,7 +62,7 @@ | IAM / 鉴权 | 项目级 API 鉴权 + RBAC;会话/阶段授权 | done | 加固 | [v3 full inventory](../audit/authorization-matrix-v3-full.md);[session 中断](../test-reports/2026-09-16-iam-route-inventory-test-report.md);[fail-closed](../test-reports/2026-09-16-wave1-iam-fail-closed-test-report.md);[410 策略](../audit/2026-09-16-bug11-410-retire-decision.md);[R13](../audit/2026-09-16-r13-data-safety-checklist.md) | 全路由 inventory + session 中断负例 + 410 冻结 + R13 基线已签 | `authz` | | 文档治理 | 法定文档生命周期 + 租约 + 受控正文读取 | done | non_blocker | [docgov](../test-reports/2026-09-08-docgov-lifecycle-and-claiming-framework-test-report.md);[正文 UI](../test-reports/2026-09-14-controlled-content-ui-acceptance.md);[canonical hash](../remediation/2026-09-14-controlled-content-canonical-hash-remediation.md) | 项目 143 只读;不终止 | `docgov` | | 文档版本 baseline | 账本、人工修订、确定性 diff+lineage、运行时 UI、负责人通知 | done | non_blocker | [管理](../test-reports/2026-09-13-document-revision-management-test-report.md);[Round20 UI](../test-reports/2026-09-14-document-revision-runtime-ui-test-report.md);[ADR](../adr/2026-09-13-document-version-boundary.md);[proposed/authoritative](../ops/short-term/2026-09-20-proposed-authoritative-reports.md) | **before**:完整 PI narrative 混入 → **after**:迁「PI change-report 叙事」。fallback 报告是本行内显式行为,不是缺口。lineage GET 已按 URL 项目和文档可见性裁剪。test_manager 血缘的下游/上游版本改读文档版本,不再写死 1。Agent 候选报告为 `proposed`,正式提交才写 authoritative 后继并挂到版本;拒绝、替代后旧报告不能再出建议。人工修订、导入和聊天卡仍未分开预估与正式报告,见 [T1](../ops/short-term/2026-09-20-impact-loop-t1.md)。项目追踪索引见下一行 | `docgov` | -| 阶段回溯与影响闭环 | 五表追踪索引、影响报告、L1 文档修订、L2 阶段再打开、待复核软标记、对话修订提案 | done | deferred_enhancement(不挡首发) | [设计](../system-design/2026-09-20-stage-backtrack-and-impact-loop-design.md);[Track T](../remediation/2026-09-20-capability-enablement-impact-plan.md) | 已合入 dev。dev schema 59。test/main 未升。未复核不拦截推进。不签 D7 对话 ADR,不改 `production_ready` | `trace-index` | +| 阶段回溯与影响闭环 | 五表追踪索引、影响报告、L1 文档修订、L2 阶段再打开、待复核软标记、对话修订提案 | done | deferred_enhancement(不挡首发) | [设计](../system-design/2026-09-20-stage-backtrack-and-impact-loop-design.md);[Track T](../remediation/2026-09-20-capability-enablement-impact-plan.md);[对话化报告](../test-reports/2026-09-21-trace-panel-to-chat-test-report.md) | 已合入 dev。常驻「需求与影响」面板已退役,新待复核与下游影响改走已有项目会话。dev schema 59。test/main 未升。阶段推进确认门禁仍在。不签 D7 对话 ADR,不改 `production_ready` | `trace-index` | | PI change-report 叙事 | 只读消费 diff+lineage 的 PI 叙事;失败全量降级 fallback | partial | deferred_enhancement | [Round20](../test-reports/2026-09-14-document-revision-runtime-ui-test-report.md);[go/no-go GO](../ops/research/2026-09-16-wave3-pu6-pu8-go-no-go.md);[pu7](../ops/long-term/2026-09-16-pu7-pi-narrative-contract.md);[脚手架](../test-reports/2026-09-16-pu7-pi-narrative-test-report.md);[W3 真 PI](../test-reports/2026-09-17-w3-pi-change-report-narrative-test-report.md);[2026-09-20 复核](../ops/research/2026-09-20-capability-enablement-impact-research.md);[最小化输入](../ops/short-term/2026-09-20-pi-narrative-clipped-diff.md) | flag 默认 off。`facts.json` 含截断后的 hunk 前后文和影响说明,以及完整 hunk/impact 计数与省略数;摘要 digest 仍不含正文。叙事请求已带 `tool_profile=reasoning`。运行中的 runner 尚未切到 `--no-tools`。不打开开关 | `docgov` | | 前端工作台 | 三栏 + 块文档 + SSE;错误卡/进度/busy;版本冲突信封;demo seed 默认关;终止 preview | done | non_blocker | [workbench](../test-reports/2026-09-08-three-column-workbench-and-block-document-test-report.md);[双标签](../test-reports/assets/2026-09-15-conversation-intent-dual-tab/screenshots/tab-b-busy.png);[demo](../test-reports/2026-09-16-demo-seed-pollution-test-report.md);[B4](../test-reports/2026-09-16-frontend-gate-b4-test-report.md)(项目 6363) | **before**:空「缺口」却 partial → **after**:本行 done。其他 409 消费方信封复用另列 hardening backlog | `web` | | PI Runner / 配置链 | pi-agent + MiniMax + typed-result-v1 | done | non_blocker | [piagent](../test-reports/2026-09-13-codex-audit-p0-remediation-test-report.md);[container-env](../test-reports/2026-09-13-container-env-configuration-test-report.md);[typed-result](../test-reports/2026-09-15-pi-typed-result-v1-test-report.md) | 文档生成仍使用读写和搜索工具。`tool_profile=reasoning` 的 argv 是 `--no-tools` 且不加载搜索扩展;未知 profile 拒绝启动。dev 挂载了 `pi-args.mjs`,但本轮未重启容器 | `pi-agent` | diff --git a/.doc/test-reports/2026-09-21-trace-panel-to-chat-test-report.md b/.doc/test-reports/2026-09-21-trace-panel-to-chat-test-report.md new file mode 100644 index 00000000..33df3a10 --- /dev/null +++ b/.doc/test-reports/2026-09-21-trace-panel-to-chat-test-report.md @@ -0,0 +1,104 @@ +# 需求与影响对话化 测试报告 + +## 一、 测试背景 (Background) + +聊天栏顶部常驻「需求与影响」。没有索引时也占位置,待复核只能在这块面板里点「无需变更」。产品要求改成助手在对话里提醒,并且只在需要时出现。 + +## 二、 测试目标 (Test Objectives) + +- 已选项目时,聊天区不再挂载追踪面板。 +- 新产生的待复核标记写入操作者已有的项目会话;没有会话时不新建;同一标记不重复播报。 +- 「分析影响 / 改动影响哪些文档 / 变更影响」识别为影响分析。 +- 下游影响事件向已有会话写一条助手消息。 +- 阶段推进仍要确认。dev 容器健康,真实 PI 模型仍是 MiniMax-M2.7-highspeed。 + +## 三、 测试方案与环境 (Test Plan & Environment) + +- OS:Linux。Go 模块 `chat-agent-system`。前端 `web/` Node 测试与容器内 `npm run build`。 +- 后端:dev 容器 `shuimu-agent-app-dev`、`shuimu-agent-events-dev`,HTTP `127.0.0.1:8080`。PI:`shuimu-pi-agent-dev`,`127.0.0.1:18865`。 +- 层级:`traceimpact` / `docnotify` / `intent` 单测;前端 `npm test`;dev 镜像重建后检查已发布的 JS。 +- 项目 6579 当前 `project_trace_items` 为 0、`stage_recheck_marks` 为 0。不为了造标记去改这份数据,标记注入用单测覆盖。 + +## 四、 预期结果 (Expected Results) + +- 页面包中不再出现 `ProjectTracePanel`、「已索引」、「份文档 · 条目」。 +- 页面包包含 `RECHECK_NOTICE` 与 `recheck_no_change`。 +- `App.tsx` 仍挂载 `StageAdvanceBanner`。 +- `/health` 的 `schema_version` 为 59,`agent_backend` 为 `pi-agent`。 + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 用例 1:单测 + +- **测试输入 (Input)**:`go test -count=1 ./internal/traceimpact/ ./internal/app/docnotify/ ./internal/intent/ ./internal/app/ ./internal/server/` +- **实际输出 (Actual Output)**: + +```text +ok chat-agent-system/internal/traceimpact 0.003s +ok chat-agent-system/internal/app/docnotify 0.002s +ok chat-agent-system/internal/intent 0.007s +ok chat-agent-system/internal/app 0.003s [no tests to run] +ok chat-agent-system/internal/server 0.128s +``` + +覆盖:新标记只通知一次、无会话不写消息、有会话写入助手消息、「请分析影响 / 这次改动影响哪些文档 / 帮我做一下变更影响」识别为影响分析。 + +- **评价判定 (Evaluation / Verdict)**:PASS +- **性能与耗时 (Duration & Latency)**:上述包合计不到 1 秒。 + +### 用例 2:前端测试 + +- **测试输入 (Input)**:`cd web && npm test` +- **实际输出 (Actual Output)**: + +```text +✔ trace panel is not mounted above the chat stream (1.076438ms) +ℹ tests 442 +ℹ suites 37 +ℹ pass 442 +ℹ fail 0 +``` + +- **评价判定 (Evaluation / Verdict)**:PASS +- **性能与耗时 (Duration & Latency)**:整套约 3 秒。 + +### 用例 3:dev 重建与已发布页面 + +- **测试输入 (Input)**:`./scripts/env-service.sh dev rebuild`,然后请求首页与主包 `index-DEguWhkN.js`。 +- **实际输出 (Actual Output)**: + +```text +Image agent-dev-app Built +Image agent-dev-events Built +Container shuimu-agent-app-dev Started +Container shuimu-agent-events-dev Started +==> [dev] 镜像与容器已更新 +``` + +`GET /health`: + +```json +{"status":"ok","env":"dev","agent_backend":"pi-agent","version":"v1.0","schema_version":59,"schema_ready":true,"task_store_ready":true} +``` + +`GET http://127.0.0.1:18865/health`:`ok=true`,`model_id=MiniMax-M2.7-highspeed`。 + +主包计数:`ProjectTracePanel` 0,`已索引` 0,`份文档 · 条目` 0,`RECHECK_NOTICE` 2,`待复核` 5,`recheck_no_change` 1。浏览器打开 `http://127.0.0.1:8080/` 进入登录页,标题为「水木东方 · 医疗器械现代 AI 协同工作台」,服务可打开。未登录,因此没有进入已选项目的聊天栏。 + +- **评价判定 (Evaluation / Verdict)**:PASS。常驻面板文案已不在发布包中;待复核卡片类型在包中。 +- **性能与耗时 (Duration & Latency)**:重建约 23 秒。 + +### 用例 4:项目 6579 不造标记 + +- **测试输入 (Input)**:查询 `project_trace_items` 与 `stage_recheck_marks`。 +- **实际输出 (Actual Output)**:条目 0,未关闭标记 0。会话 4518(用户 398)与 4517(用户 9)仍在。 +- **评价判定 (Evaluation / Verdict)**:PASS(按约定不改这份数据)。新标记注入由用例 1 证明。 +- **性能与耗时 (Duration & Latency)**:查询不到 1 秒。 + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +本次改动相关包与前端套件全部通过,见用例 1、2。未再跑会写入 dev 库的全量 `go test -short ./...`(上一轮已记录与本次无关的既有失败)。容器内 `npm run build` 成功,产物即当前 8080 页面。 + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +达到合入 dev 的标准。阶段推进确认条仍留在 `StageAdvanceBanner`。项目一旦有追踪索引,新的待复核会进操作者已有会话;没有会话则跳过。test/main 未晋升。