package app import ( "context" "errors" "fmt" "strconv" "strings" "chat-agent-system/internal/app/docnotify" "chat-agent-system/internal/contracts/vocabulary" "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" ) type traceRecheck struct{ svc *traceimpact.Service } func (t traceRecheck) StageNeedsAck(ctx context.Context, projectID int64, stageKey string) (bool, error) { if t.svc == nil { return false, nil } return t.svc.NeedsRecheckAck(ctx, projectID, stageKey) } func (t traceRecheck) Acknowledge(ctx context.Context, projectID int64, stageKey string, userID int64) error { if t.svc == nil { return nil } return t.svc.AcknowledgeRecheck(ctx, projectID, stageKey, userID) } type traceStage struct{ inst *projectinstance.Service } func (t traceStage) ReopenCompletedStage(ctx context.Context, instanceID int64, stageKey string, docTaskKeys []string, operatorID int64) error { if t.inst == nil { return fmt.Errorf("trace: instance service unavailable") } return t.inst.ReopenStage(ctx, instanceID, stageKey, docTaskKeys, operatorID) } type traceUnlock struct{ gov *docgov.GovernanceEngine } func (t traceUnlock) ReopenApprovedDocs(ctx context.Context, projectID int64, docTaskKeys []string, actorID int64, reason string) error { if t.gov == nil { return nil } actorName := "reopen" if actorID > 0 { actorName = fmt.Sprintf("user:%d", actorID) } for _, key := range docTaskKeys { docID := fmt.Sprintf("doc_proj%d_%s", projectID, key) _, err := t.gov.ReopenForRevision(ctx, docID, actorID, actorName, reason) if err == nil || errors.Is(err, vocabulary.ErrDocumentNotFound) { continue } if errors.Is(err, vocabulary.ErrInvalidStateTransition) { continue } return err } return nil } type traceChat struct{ svc *traceimpact.Service } 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 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 { rest := strings.TrimPrefix(docID, "doc_proj") if rest == docID { return 0 } i := strings.Index(rest, "_") if i <= 0 { return 0 } n, err := strconv.ParseInt(rest[:i], 10, 64) if err != nil { return 0 } 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") } id, err := t.svc.ProposeRevision(ctx, projectID, userID, text, confirmed) if errors.Is(err, traceimpact.ErrNeedsConfirm) { return "", coordinator.ErrImpactNeedsConfirm } return id, err }