Enhance workflow execution with checkpoint data handling and introduce tests for human confirmation and ticket creation
This commit is contained in:
+1
-1
Submodule docs updated: 0b30092a2d...d75c9282f9
@@ -3,6 +3,7 @@ package runtime
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"agent-desk/internal/ai/runtime/executor"
|
"agent-desk/internal/ai/runtime/executor"
|
||||||
@@ -53,11 +54,23 @@ func (s *Service) Run(ctx context.Context, req Request) (*Summary, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Resume(ctx context.Context, req ResumeRequest) (*Summary, error) {
|
func (s *Service) Resume(ctx context.Context, req ResumeRequest) (*Summary, error) {
|
||||||
aiAgent, _, err := prepareWorkflowAgent(req.AIAgent)
|
aiAgent, workflow, err := prepareWorkflowAgent(req.AIAgent)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
req.AIAgent = aiAgent
|
req.AIAgent = aiAgent
|
||||||
|
if interrupt := repositories.ConversationInterruptRepository.GetByCheckPointID(sqls.DB(), req.CheckPointID); interrupt != nil && strings.TrimSpace(interrupt.RequestData) != "" {
|
||||||
|
workflowResult, err := workflowexecutor.NewExecutor().Resume(ctx, workflowexecutor.Input{
|
||||||
|
Definition: workflow.Definition,
|
||||||
|
Conversation: req.Conversation,
|
||||||
|
AIAgent: req.AIAgent,
|
||||||
|
AIConfig: req.AIConfig,
|
||||||
|
}, interrupt.RequestData, firstWorkflowResumeText(req.ResumeData))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return toWorkflowSummary(workflowResult, req.AIConfig.ModelName), nil
|
||||||
|
}
|
||||||
toolSet, err := s.prepare.prepareToolsForResume(req)
|
toolSet, err := s.prepare.prepareToolsForResume(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -77,6 +90,15 @@ func (s *Service) Resume(ctx context.Context, req ResumeRequest) (*Summary, erro
|
|||||||
return toSummary(summary), nil
|
return toSummary(summary), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func firstWorkflowResumeText(data map[string]string) string {
|
||||||
|
for _, value := range data {
|
||||||
|
if strings.TrimSpace(value) != "" {
|
||||||
|
return strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func toWorkflowSummary(result *workflowexecutor.Result, modelName string) *Summary {
|
func toWorkflowSummary(result *workflowexecutor.Result, modelName string) *Summary {
|
||||||
if result == nil {
|
if result == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -94,9 +116,28 @@ func toWorkflowSummary(result *workflowexecutor.Result, modelName string) *Summa
|
|||||||
CompletionTokens: result.CompletionTokens,
|
CompletionTokens: result.CompletionTokens,
|
||||||
RetrieverCount: result.RetrieverCount,
|
RetrieverCount: result.RetrieverCount,
|
||||||
TraceData: string(traceData),
|
TraceData: string(traceData),
|
||||||
|
CheckPointID: result.CheckPointID,
|
||||||
|
CheckPointData: result.CheckPointData,
|
||||||
|
Interrupted: result.Interrupted,
|
||||||
|
Interrupts: toWorkflowInterruptSummaries(result.Interrupts),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func toWorkflowInterruptSummaries(items []workflowexecutor.InterruptSummary) []InterruptContextSummary {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ret := make([]InterruptContextSummary, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
ret = append(ret, InterruptContextSummary{
|
||||||
|
Type: item.Type,
|
||||||
|
ID: item.ID,
|
||||||
|
InfoPreview: item.InfoPreview,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
func writeWorkflowRun(req Request, workflow resolvedWorkflow, result *workflowexecutor.Result, errorMessage string) error {
|
func writeWorkflowRun(req Request, workflow resolvedWorkflow, result *workflowexecutor.Result, errorMessage string) error {
|
||||||
if result == nil {
|
if result == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ type Summary struct {
|
|||||||
ToolCodes []string
|
ToolCodes []string
|
||||||
InvokedToolCodes []string
|
InvokedToolCodes []string
|
||||||
CheckPointID string
|
CheckPointID string
|
||||||
|
CheckPointData string
|
||||||
Interrupted bool
|
Interrupted bool
|
||||||
Interrupts []InterruptContextSummary
|
Interrupts []InterruptContextSummary
|
||||||
TraceData string
|
TraceData string
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package runtime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
workflowexecutor "agent-desk/internal/ai/runtime/workflow"
|
||||||
|
"agent-desk/internal/ai/workflow/dsl"
|
||||||
|
workflowregistry "agent-desk/internal/ai/workflow/registry"
|
||||||
|
"agent-desk/internal/models"
|
||||||
|
"agent-desk/internal/pkg/enums"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"github.com/mlogclub/simple/sqls"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestToWorkflowSummaryPreservesInterruptCheckpoint(t *testing.T) {
|
||||||
|
summary := toWorkflowSummary(&workflowexecutor.Result{
|
||||||
|
Status: "interrupted",
|
||||||
|
CheckPointID: "workflow:1:2:confirm_1",
|
||||||
|
CheckPointData: `{"confirmNodeId":"confirm_1"}`,
|
||||||
|
Interrupted: true,
|
||||||
|
Interrupts: []workflowexecutor.InterruptSummary{
|
||||||
|
{Type: "human_confirm", ID: "confirm_1", InfoPreview: `{"message":"请确认"}`},
|
||||||
|
},
|
||||||
|
}, "test-model")
|
||||||
|
|
||||||
|
if summary == nil || !summary.Interrupted {
|
||||||
|
t.Fatalf("expected interrupted summary, got %#v", summary)
|
||||||
|
}
|
||||||
|
if summary.CheckPointID != "workflow:1:2:confirm_1" {
|
||||||
|
t.Fatalf("unexpected checkpoint id: %q", summary.CheckPointID)
|
||||||
|
}
|
||||||
|
if summary.CheckPointData == "" {
|
||||||
|
t.Fatalf("expected checkpoint data")
|
||||||
|
}
|
||||||
|
if len(summary.Interrupts) != 1 || summary.Interrupts[0].ID != "confirm_1" {
|
||||||
|
t.Fatalf("unexpected interrupts: %#v", summary.Interrupts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceResumeUsesWorkflowCheckpointData(t *testing.T) {
|
||||||
|
db := setupWorkflowResumeTestDB(t)
|
||||||
|
def := runtimeHumanConfirmDefinition()
|
||||||
|
definitionJSON := mustMarshalDefinition(t, def)
|
||||||
|
version := models.AIWorkflowVersion{
|
||||||
|
WorkflowID: 1,
|
||||||
|
Version: 1,
|
||||||
|
Status: enums.StatusOk,
|
||||||
|
Definition: definitionJSON,
|
||||||
|
}
|
||||||
|
if err := db.Create(&version).Error; err != nil {
|
||||||
|
t.Fatalf("create workflow version: %v", err)
|
||||||
|
}
|
||||||
|
checkpointData := mustMarshalWorkflowCheckpoint(t, def)
|
||||||
|
if err := db.Create(&models.ConversationInterrupt{
|
||||||
|
ConversationID: 1,
|
||||||
|
AIAgentID: 1,
|
||||||
|
CheckPointID: "workflow:1:2:confirm_1",
|
||||||
|
RequestData: checkpointData,
|
||||||
|
Status: "pending",
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("create interrupt: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, err := NewService().Resume(context.Background(), ResumeRequest{
|
||||||
|
Conversation: models.Conversation{ID: 1},
|
||||||
|
AIAgent: models.AIAgent{
|
||||||
|
ID: 1,
|
||||||
|
WorkflowVersionID: version.ID,
|
||||||
|
},
|
||||||
|
AIConfig: models.AIConfig{ModelName: "test-model"},
|
||||||
|
CheckPointID: "workflow:1:2:confirm_1",
|
||||||
|
ResumeData: map[string]string{
|
||||||
|
"confirm_1": "确认",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resume workflow: %v", err)
|
||||||
|
}
|
||||||
|
if summary == nil || summary.Status != "completed" || summary.Interrupted {
|
||||||
|
t.Fatalf("unexpected summary: %#v", summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupWorkflowResumeTestDB(t *testing.T) *gorm.DB {
|
||||||
|
t.Helper()
|
||||||
|
dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name())
|
||||||
|
db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{
|
||||||
|
NamingStrategy: schema.NamingStrategy{
|
||||||
|
TablePrefix: "t_",
|
||||||
|
SingularTable: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err == nil {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err := db.AutoMigrate(&models.AIWorkflowVersion{}, &models.ConversationInterrupt{}); err != nil {
|
||||||
|
t.Fatalf("auto migrate: %v", err)
|
||||||
|
}
|
||||||
|
sqls.SetDB(db)
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func runtimeHumanConfirmDefinition() dsl.Definition {
|
||||||
|
return dsl.Definition{
|
||||||
|
SchemaVersion: 1,
|
||||||
|
EntryNodeID: "start_1",
|
||||||
|
Nodes: []dsl.Node{
|
||||||
|
{ID: "start_1", Type: workflowregistry.NodeTypeStart, Name: "Start"},
|
||||||
|
{ID: "prompt_1", Type: workflowregistry.NodeTypeLLMReply, Name: "Prompt", Config: []byte(`{"staticReply":"请确认"}`)},
|
||||||
|
{ID: "confirm_1", Type: workflowregistry.NodeTypeHumanConfirm, Name: "Confirm", Inputs: map[string]dsl.VariableSelector{
|
||||||
|
"prompt": {NodeID: "prompt_1", Field: "replyText"},
|
||||||
|
}},
|
||||||
|
{ID: "end_1", Type: workflowregistry.NodeTypeEnd, Name: "End"},
|
||||||
|
},
|
||||||
|
Edges: []dsl.Edge{
|
||||||
|
{ID: "edge_start_prompt", Source: "start_1", Target: "prompt_1"},
|
||||||
|
{ID: "edge_prompt_confirm", Source: "prompt_1", Target: "confirm_1"},
|
||||||
|
{ID: "edge_confirm_end", Source: "confirm_1", Target: "end_1"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustMarshalDefinition(t *testing.T, def dsl.Definition) string {
|
||||||
|
t.Helper()
|
||||||
|
buf, err := json.Marshal(def)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal definition: %v", err)
|
||||||
|
}
|
||||||
|
return string(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustMarshalWorkflowCheckpoint(t *testing.T, def dsl.Definition) string {
|
||||||
|
t.Helper()
|
||||||
|
buf, err := json.Marshal(struct {
|
||||||
|
Definition dsl.Definition `json:"definition"`
|
||||||
|
ConfirmNodeID string `json:"confirmNodeId"`
|
||||||
|
Vars map[string]map[string]any `json:"vars"`
|
||||||
|
}{
|
||||||
|
Definition: def,
|
||||||
|
ConfirmNodeID: "confirm_1",
|
||||||
|
Vars: map[string]map[string]any{
|
||||||
|
"start_1": {"userMessage": "创建工单"},
|
||||||
|
"prompt_1": {"replyText": "请确认"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal checkpoint: %v", err)
|
||||||
|
}
|
||||||
|
return string(buf)
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
applicationruntime "agent-desk/internal/ai/application/runtime"
|
applicationruntime "agent-desk/internal/ai/application/runtime"
|
||||||
|
"agent-desk/internal/models"
|
||||||
"agent-desk/internal/pkg/toolx"
|
"agent-desk/internal/pkg/toolx"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -66,6 +67,22 @@ func TestExtractInterruptMessageAndCheckpointError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildConversationInterruptStoresWorkflowCheckpointData(t *testing.T) {
|
||||||
|
item := buildConversationInterrupt(testConversation(1), testMessage(2), testAIAgent(3), &applicationruntime.Summary{
|
||||||
|
CheckPointData: `{"confirmNodeId":"confirm_1"}`,
|
||||||
|
Interrupted: true,
|
||||||
|
Interrupts: []applicationruntime.InterruptContextSummary{
|
||||||
|
{Type: "human_confirm", ID: "confirm_1", InfoPreview: `{"message":"请确认"}`},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if item == nil {
|
||||||
|
t.Fatalf("expected interrupt item")
|
||||||
|
}
|
||||||
|
if item.RequestData != `{"confirmNodeId":"confirm_1"}` {
|
||||||
|
t.Fatalf("unexpected request data: %q", item.RequestData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGraphPlanReason(t *testing.T) {
|
func TestGraphPlanReason(t *testing.T) {
|
||||||
summary := &applicationruntime.Summary{
|
summary := &applicationruntime.Summary{
|
||||||
TraceData: `{
|
TraceData: `{
|
||||||
@@ -109,3 +126,15 @@ type fakeErr string
|
|||||||
func (e fakeErr) Error() string {
|
func (e fakeErr) Error() string {
|
||||||
return string(e)
|
return string(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testConversation(id int64) models.Conversation {
|
||||||
|
return models.Conversation{ID: id}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMessage(id int64) models.Message {
|
||||||
|
return models.Message{ID: id}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAIAgent(id int64) models.AIAgent {
|
||||||
|
return models.AIAgent{ID: id}
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ func buildConversationInterrupt(conversation models.Conversation, message models
|
|||||||
item.InterruptType = firstInterruptType(summary)
|
item.InterruptType = firstInterruptType(summary)
|
||||||
item.Status = "pending"
|
item.Status = "pending"
|
||||||
item.PromptText = resolveInterruptPrompt(summary)
|
item.PromptText = resolveInterruptPrompt(summary)
|
||||||
|
item.RequestData = strings.TrimSpace(summary.CheckPointData)
|
||||||
item.UpdatedAt = now
|
item.UpdatedAt = now
|
||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import (
|
|||||||
"agent-desk/internal/ai/workflow/dsl"
|
"agent-desk/internal/ai/workflow/dsl"
|
||||||
workflowregistry "agent-desk/internal/ai/workflow/registry"
|
workflowregistry "agent-desk/internal/ai/workflow/registry"
|
||||||
"agent-desk/internal/models"
|
"agent-desk/internal/models"
|
||||||
|
"agent-desk/internal/pkg/dto"
|
||||||
|
"agent-desk/internal/pkg/dto/request"
|
||||||
"agent-desk/internal/pkg/utils"
|
"agent-desk/internal/pkg/utils"
|
||||||
"agent-desk/internal/services"
|
"agent-desk/internal/services"
|
||||||
)
|
)
|
||||||
@@ -36,6 +38,16 @@ type Result struct {
|
|||||||
CompletionTokens int
|
CompletionTokens int
|
||||||
RetrieverCount int
|
RetrieverCount int
|
||||||
TraceData string
|
TraceData string
|
||||||
|
CheckPointID string
|
||||||
|
CheckPointData string
|
||||||
|
Interrupted bool
|
||||||
|
Interrupts []InterruptSummary
|
||||||
|
}
|
||||||
|
|
||||||
|
type InterruptSummary struct {
|
||||||
|
Type string
|
||||||
|
ID string
|
||||||
|
InfoPreview string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Executor struct{}
|
type Executor struct{}
|
||||||
@@ -52,12 +64,66 @@ type runState struct {
|
|||||||
result Result
|
result Result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type workflowCheckPoint struct {
|
||||||
|
Definition dsl.Definition `json:"definition"`
|
||||||
|
ConfirmNodeID string `json:"confirmNodeId"`
|
||||||
|
Vars map[string]map[string]any `json:"vars"`
|
||||||
|
}
|
||||||
|
|
||||||
func (e *Executor) Execute(ctx context.Context, input Input) (*Result, error) {
|
func (e *Executor) Execute(ctx context.Context, input Input) (*Result, error) {
|
||||||
state := newRunState(input)
|
state := newRunState(input)
|
||||||
currentID := strings.TrimSpace(input.Definition.EntryNodeID)
|
currentID := strings.TrimSpace(input.Definition.EntryNodeID)
|
||||||
if currentID == "" {
|
if currentID == "" {
|
||||||
return nil, fmt.Errorf("workflow entry node is required")
|
return nil, fmt.Errorf("workflow entry node is required")
|
||||||
}
|
}
|
||||||
|
return e.executeFrom(ctx, state, currentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Executor) Resume(ctx context.Context, input Input, checkPointData string, resumeText string) (*Result, error) {
|
||||||
|
var checkpoint workflowCheckPoint
|
||||||
|
if err := json.Unmarshal([]byte(strings.TrimSpace(checkPointData)), &checkpoint); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid workflow checkpoint: %w", err)
|
||||||
|
}
|
||||||
|
if len(checkpoint.Definition.Nodes) > 0 {
|
||||||
|
input.Definition = checkpoint.Definition
|
||||||
|
}
|
||||||
|
state := newRunState(input)
|
||||||
|
state.vars = checkpoint.Vars
|
||||||
|
if state.vars == nil {
|
||||||
|
state.vars = make(map[string]map[string]any)
|
||||||
|
}
|
||||||
|
confirmNodeID := strings.TrimSpace(checkpoint.ConfirmNodeID)
|
||||||
|
if confirmNodeID == "" {
|
||||||
|
return nil, fmt.Errorf("workflow checkpoint confirm node is required")
|
||||||
|
}
|
||||||
|
decision := graphs.ParseConfirmationDecision(resumeText)
|
||||||
|
if decision == "" {
|
||||||
|
node, ok := state.nodesByID[confirmNodeID]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("workflow node does not exist: %s", confirmNodeID)
|
||||||
|
}
|
||||||
|
if err := e.executeHumanConfirm(state, node); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
state.result.Status = "interrupted"
|
||||||
|
return &state.result, nil
|
||||||
|
}
|
||||||
|
state.setNodeVars(confirmNodeID, map[string]any{
|
||||||
|
"confirmed": decision == graphs.ConfirmationDecisionConfirm,
|
||||||
|
"responseText": strings.TrimSpace(resumeText),
|
||||||
|
})
|
||||||
|
nextID, ok, err := state.nextNodeID(confirmNodeID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
state.result.Status = "completed"
|
||||||
|
return &state.result, nil
|
||||||
|
}
|
||||||
|
return e.executeFrom(ctx, state, nextID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Executor) executeFrom(ctx context.Context, state *runState, currentID string) (*Result, error) {
|
||||||
for step := 0; step < maxWorkflowSteps; step++ {
|
for step := 0; step < maxWorkflowSteps; step++ {
|
||||||
node, ok := state.nodesByID[currentID]
|
node, ok := state.nodesByID[currentID]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -67,6 +133,10 @@ func (e *Executor) Execute(ctx context.Context, input Input) (*Result, error) {
|
|||||||
if err := e.executeNode(ctx, state, node); err != nil {
|
if err := e.executeNode(ctx, state, node); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if state.result.Interrupted {
|
||||||
|
state.result.Status = "interrupted"
|
||||||
|
return &state.result, nil
|
||||||
|
}
|
||||||
if node.Type == workflowregistry.NodeTypeEnd {
|
if node.Type == workflowregistry.NodeTypeEnd {
|
||||||
state.result.Status = "completed"
|
state.result.Status = "completed"
|
||||||
return &state.result, nil
|
return &state.result, nil
|
||||||
@@ -127,6 +197,12 @@ func (e *Executor) executeNode(ctx context.Context, state *runState, node dsl.No
|
|||||||
state.setNodeVars(node.ID, map[string]any{"matched": true})
|
state.setNodeVars(node.ID, map[string]any{"matched": true})
|
||||||
case workflowregistry.NodeTypeAnalyzeConversation:
|
case workflowregistry.NodeTypeAnalyzeConversation:
|
||||||
return e.executeAnalyzeConversation(ctx, state, node)
|
return e.executeAnalyzeConversation(ctx, state, node)
|
||||||
|
case workflowregistry.NodeTypePrepareTicketDraft:
|
||||||
|
return e.executePrepareTicketDraft(ctx, state, node)
|
||||||
|
case workflowregistry.NodeTypeHumanConfirm:
|
||||||
|
return e.executeHumanConfirm(state, node)
|
||||||
|
case workflowregistry.NodeTypeCreateTicket:
|
||||||
|
return e.executeCreateTicket(state, node)
|
||||||
case workflowregistry.NodeTypeLLMReply:
|
case workflowregistry.NodeTypeLLMReply:
|
||||||
return e.executeLLMReply(ctx, state, node)
|
return e.executeLLMReply(ctx, state, node)
|
||||||
case workflowregistry.NodeTypeSendReply:
|
case workflowregistry.NodeTypeSendReply:
|
||||||
@@ -146,6 +222,125 @@ func (e *Executor) executeNode(ctx context.Context, state *runState, node dsl.No
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *Executor) executeCreateTicket(state *runState, node dsl.Node) error {
|
||||||
|
confirmed := truthy(state.resolveInput(node, "confirmed"))
|
||||||
|
if !confirmed {
|
||||||
|
state.setNodeVars(node.ID, map[string]any{
|
||||||
|
"ticketId": int64(0),
|
||||||
|
"created": false,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
draft := asMap(state.resolveInput(node, "ticketDraft"))
|
||||||
|
title := strings.TrimSpace(toString(draft["title"]))
|
||||||
|
description := strings.TrimSpace(toString(draft["description"]))
|
||||||
|
item, err := services.TicketService.CreateFromConversation(request.CreateTicketFromConversationRequest{
|
||||||
|
ConversationID: state.input.Conversation.ID,
|
||||||
|
Title: title,
|
||||||
|
Description: description,
|
||||||
|
}, workflowAIPrincipal(state.input.AIAgent))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
state.setNodeVars(node.ID, map[string]any{
|
||||||
|
"ticketId": item.ID,
|
||||||
|
"ticketNo": item.TicketNo,
|
||||||
|
"created": true,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func workflowAIPrincipal(aiAgent models.AIAgent) *dto.AuthPrincipal {
|
||||||
|
username := strings.TrimSpace(aiAgent.Name)
|
||||||
|
if username == "" {
|
||||||
|
username = "AI"
|
||||||
|
}
|
||||||
|
return &dto.AuthPrincipal{
|
||||||
|
UserID: 0,
|
||||||
|
Username: username,
|
||||||
|
Nickname: username,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Executor) executeHumanConfirm(state *runState, node dsl.Node) error {
|
||||||
|
prompt := strings.TrimSpace(toString(state.resolveInput(node, "prompt")))
|
||||||
|
if prompt == "" {
|
||||||
|
prompt = "请确认是否继续。"
|
||||||
|
}
|
||||||
|
infoPreview, err := json.Marshal(map[string]string{"message": prompt})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
state.result.Interrupted = true
|
||||||
|
state.result.CheckPointID = buildWorkflowCheckPointID(state.input, node.ID)
|
||||||
|
checkpoint, err := json.Marshal(workflowCheckPoint{
|
||||||
|
Definition: state.input.Definition,
|
||||||
|
ConfirmNodeID: node.ID,
|
||||||
|
Vars: state.vars,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
state.result.CheckPointData = string(checkpoint)
|
||||||
|
state.result.Interrupts = []InterruptSummary{
|
||||||
|
{
|
||||||
|
Type: workflowregistry.NodeTypeHumanConfirm,
|
||||||
|
ID: node.ID,
|
||||||
|
InfoPreview: string(infoPreview),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildWorkflowCheckPointID(input Input, nodeID string) string {
|
||||||
|
return fmt.Sprintf("workflow:%d:%d:%s", input.Conversation.ID, input.UserMessage.ID, strings.TrimSpace(nodeID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Executor) executePrepareTicketDraft(ctx context.Context, state *runState, node dsl.Node) error {
|
||||||
|
issue := strings.TrimSpace(toString(state.resolveInput(node, "issue")))
|
||||||
|
input := graphs.PrepareTicketDraftInput{
|
||||||
|
Issue: issue,
|
||||||
|
}
|
||||||
|
if title := strings.TrimSpace(readStringConfig(node.Config, "title")); title != "" {
|
||||||
|
input.Title = title
|
||||||
|
}
|
||||||
|
if description := strings.TrimSpace(readStringConfig(node.Config, "description")); description != "" {
|
||||||
|
input.Description = description
|
||||||
|
}
|
||||||
|
if impact := strings.TrimSpace(readStringConfig(node.Config, "impact")); impact != "" {
|
||||||
|
input.Impact = impact
|
||||||
|
}
|
||||||
|
if expectedOutcome := strings.TrimSpace(readStringConfig(node.Config, "expectedOutcome")); expectedOutcome != "" {
|
||||||
|
input.ExpectedOutcome = expectedOutcome
|
||||||
|
}
|
||||||
|
if currentAttempt := strings.TrimSpace(readStringConfig(node.Config, "currentAttempt")); currentAttempt != "" {
|
||||||
|
input.CurrentAttempt = currentAttempt
|
||||||
|
}
|
||||||
|
args, err := json.Marshal(input)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
raw, err := graphs.NewPrepareTicketDraftGraph(state.input.Conversation).Run(ctx, string(args))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var result graphs.PrepareTicketDraftResult
|
||||||
|
if err := json.Unmarshal([]byte(raw), &result); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
state.setNodeVars(node.ID, map[string]any{
|
||||||
|
"ticketDraft": map[string]any{
|
||||||
|
"ready": result.Ready,
|
||||||
|
"title": strings.TrimSpace(result.Title),
|
||||||
|
"description": strings.TrimSpace(result.Description),
|
||||||
|
"missingFields": result.MissingFields,
|
||||||
|
"followUpQuestions": result.FollowUpQuestions,
|
||||||
|
"conversationFacts": result.ConversationFacts,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (e *Executor) executeAnalyzeConversation(ctx context.Context, state *runState, node dsl.Node) error {
|
func (e *Executor) executeAnalyzeConversation(ctx context.Context, state *runState, node dsl.Node) error {
|
||||||
userMessage := strings.TrimSpace(toString(state.resolveInput(node, "userMessage")))
|
userMessage := strings.TrimSpace(toString(state.resolveInput(node, "userMessage")))
|
||||||
input := graphs.AnalyzeConversationInput{
|
input := graphs.AnalyzeConversationInput{
|
||||||
@@ -445,6 +640,25 @@ func toFloat(value any) float64 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func asMap(value any) map[string]any {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
return v
|
||||||
|
case map[string]string:
|
||||||
|
ret := make(map[string]any, len(v))
|
||||||
|
for key, item := range v {
|
||||||
|
ret[key] = item
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
case string:
|
||||||
|
var ret map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(strings.TrimSpace(v)), &ret); err == nil {
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
|
||||||
func truthy(value any) bool {
|
func truthy(value any) bool {
|
||||||
switch v := value.(type) {
|
switch v := value.(type) {
|
||||||
case nil:
|
case nil:
|
||||||
|
|||||||
@@ -110,6 +110,131 @@ func TestExecutorAnalyzeConversationOutputsBranchVariables(t *testing.T) {
|
|||||||
assertPath(t, result.NodePath, []string{"start_1", "analyze_1", "handoff_end"})
|
assertPath(t, result.NodePath, []string{"start_1", "analyze_1", "handoff_end"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExecutorPrepareTicketDraftOutputsDraftVariable(t *testing.T) {
|
||||||
|
db := setupWorkflowExecutorHandoffDB(t)
|
||||||
|
aiAgent := createWorkflowExecutorHandoffAIAgent(t, db, "1")
|
||||||
|
conversation := createWorkflowExecutorHandoffConversation(t, db, aiAgent.ID)
|
||||||
|
userMessage := createWorkflowExecutorCustomerMessage(t, db, conversation.ID, "订单支付失败,请帮我登记工单")
|
||||||
|
|
||||||
|
result, err := NewExecutor().Execute(context.Background(), Input{
|
||||||
|
Definition: prepareTicketDraftWorkflowDefinition(),
|
||||||
|
Conversation: conversation,
|
||||||
|
UserMessage: userMessage,
|
||||||
|
AIAgent: aiAgent,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("execute workflow: %v", err)
|
||||||
|
}
|
||||||
|
assertPath(t, result.NodePath, []string{"start_1", "draft_1", "ready_end"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutorHumanConfirmInterruptsWithCheckpoint(t *testing.T) {
|
||||||
|
result, err := NewExecutor().Execute(context.Background(), Input{
|
||||||
|
Definition: humanConfirmWorkflowDefinition(),
|
||||||
|
Conversation: models.Conversation{
|
||||||
|
ID: 11,
|
||||||
|
},
|
||||||
|
UserMessage: models.Message{
|
||||||
|
ID: 22,
|
||||||
|
Content: "创建工单",
|
||||||
|
},
|
||||||
|
AIAgent: models.AIAgent{
|
||||||
|
ID: 33,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("execute workflow: %v", err)
|
||||||
|
}
|
||||||
|
if !result.Interrupted {
|
||||||
|
t.Fatalf("expected workflow to interrupt")
|
||||||
|
}
|
||||||
|
if result.CheckPointID == "" {
|
||||||
|
t.Fatalf("expected checkpoint id")
|
||||||
|
}
|
||||||
|
if len(result.Interrupts) != 1 {
|
||||||
|
t.Fatalf("expected one interrupt, got %#v", result.Interrupts)
|
||||||
|
}
|
||||||
|
if result.Interrupts[0].Type != "human_confirm" || result.Interrupts[0].ID != "confirm_1" {
|
||||||
|
t.Fatalf("unexpected interrupt summary: %#v", result.Interrupts[0])
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.Interrupts[0].InfoPreview, "请确认创建工单") {
|
||||||
|
t.Fatalf("expected confirmation prompt, got %q", result.Interrupts[0].InfoPreview)
|
||||||
|
}
|
||||||
|
assertPath(t, result.NodePath, []string{"start_1", "prompt_1", "confirm_1"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutorResumeHumanConfirmContinuesWithConfirmedVariable(t *testing.T) {
|
||||||
|
executor := NewExecutor()
|
||||||
|
input := Input{
|
||||||
|
Definition: humanConfirmWorkflowDefinition(),
|
||||||
|
Conversation: models.Conversation{
|
||||||
|
ID: 11,
|
||||||
|
},
|
||||||
|
UserMessage: models.Message{
|
||||||
|
ID: 22,
|
||||||
|
Content: "创建工单",
|
||||||
|
},
|
||||||
|
AIAgent: models.AIAgent{
|
||||||
|
ID: 33,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
interrupted, err := executor.Execute(context.Background(), input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("execute workflow: %v", err)
|
||||||
|
}
|
||||||
|
result, err := executor.Resume(context.Background(), input, interrupted.CheckPointData, "确认")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resume workflow: %v", err)
|
||||||
|
}
|
||||||
|
if result.Interrupted {
|
||||||
|
t.Fatalf("expected workflow resume to complete")
|
||||||
|
}
|
||||||
|
assertPath(t, result.NodePath, []string{"end_1"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutorResumeCreatesTicketAfterHumanConfirmation(t *testing.T) {
|
||||||
|
db := setupWorkflowExecutorHandoffDB(t)
|
||||||
|
aiAgent := createWorkflowExecutorHandoffAIAgent(t, db, "1")
|
||||||
|
conversation := createWorkflowExecutorHandoffConversation(t, db, aiAgent.ID)
|
||||||
|
userMessage := createWorkflowExecutorCustomerMessage(t, db, conversation.ID, "订单支付失败,请帮我登记工单")
|
||||||
|
executor := NewExecutor()
|
||||||
|
|
||||||
|
interrupted, err := executor.Execute(context.Background(), Input{
|
||||||
|
Definition: createTicketWorkflowDefinition(),
|
||||||
|
Conversation: conversation,
|
||||||
|
UserMessage: userMessage,
|
||||||
|
AIAgent: aiAgent,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("execute workflow: %v", err)
|
||||||
|
}
|
||||||
|
if !interrupted.Interrupted {
|
||||||
|
t.Fatalf("expected workflow to interrupt before creating ticket")
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := executor.Resume(context.Background(), Input{
|
||||||
|
Definition: createTicketWorkflowDefinition(),
|
||||||
|
Conversation: conversation,
|
||||||
|
UserMessage: userMessage,
|
||||||
|
AIAgent: aiAgent,
|
||||||
|
}, interrupted.CheckPointData, "确认")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resume workflow: %v", err)
|
||||||
|
}
|
||||||
|
if result.Interrupted {
|
||||||
|
t.Fatalf("expected workflow to complete")
|
||||||
|
}
|
||||||
|
assertPath(t, result.NodePath, []string{"create_ticket_1", "end_1"})
|
||||||
|
|
||||||
|
var ticket models.Ticket
|
||||||
|
if err := db.First(&ticket, "conversation_id = ?", conversation.ID).Error; err != nil {
|
||||||
|
t.Fatalf("expected created ticket: %v", err)
|
||||||
|
}
|
||||||
|
if ticket.Title == "" || !strings.Contains(ticket.Description, "订单支付失败") {
|
||||||
|
t.Fatalf("unexpected ticket: %+v", ticket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func conditionalReplyDefinition() dsl.Definition {
|
func conditionalReplyDefinition() dsl.Definition {
|
||||||
return dsl.Definition{
|
return dsl.Definition{
|
||||||
SchemaVersion: 1,
|
SchemaVersion: 1,
|
||||||
@@ -148,6 +273,103 @@ func conditionalReplyDefinition() dsl.Definition {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func createTicketWorkflowDefinition() dsl.Definition {
|
||||||
|
return dsl.Definition{
|
||||||
|
SchemaVersion: 1,
|
||||||
|
EntryNodeID: "start_1",
|
||||||
|
Nodes: []dsl.Node{
|
||||||
|
{ID: "start_1", Type: workflowregistry.NodeTypeStart, Name: "Start"},
|
||||||
|
{ID: "draft_1", Type: workflowregistry.NodeTypePrepareTicketDraft, Name: "Draft", Inputs: map[string]dsl.VariableSelector{
|
||||||
|
"issue": {NodeID: "start_1", Field: "userMessage"},
|
||||||
|
}},
|
||||||
|
{ID: "prompt_1", Type: workflowregistry.NodeTypeLLMReply, Name: "Prompt", Config: []byte(`{"staticReply":"请确认创建工单"}`)},
|
||||||
|
{ID: "confirm_1", Type: workflowregistry.NodeTypeHumanConfirm, Name: "Confirm", Inputs: map[string]dsl.VariableSelector{
|
||||||
|
"prompt": {NodeID: "prompt_1", Field: "replyText"},
|
||||||
|
}},
|
||||||
|
{ID: "create_ticket_1", Type: workflowregistry.NodeTypeCreateTicket, Name: "Create Ticket", Inputs: map[string]dsl.VariableSelector{
|
||||||
|
"ticketDraft": {NodeID: "draft_1", Field: "ticketDraft"},
|
||||||
|
"confirmed": {NodeID: "confirm_1", Field: "confirmed"},
|
||||||
|
}},
|
||||||
|
{ID: "end_1", Type: workflowregistry.NodeTypeEnd, Name: "End"},
|
||||||
|
{ID: "cancel_end", Type: workflowregistry.NodeTypeEnd, Name: "Cancel"},
|
||||||
|
},
|
||||||
|
Edges: []dsl.Edge{
|
||||||
|
{ID: "edge_start_draft", Source: "start_1", Target: "draft_1"},
|
||||||
|
{ID: "edge_draft_prompt", Source: "draft_1", Target: "prompt_1"},
|
||||||
|
{ID: "edge_prompt_confirm", Source: "prompt_1", Target: "confirm_1"},
|
||||||
|
{
|
||||||
|
ID: "edge_confirm_create",
|
||||||
|
Source: "confirm_1",
|
||||||
|
Target: "create_ticket_1",
|
||||||
|
Condition: &dsl.Condition{
|
||||||
|
Left: &dsl.VariableSelector{NodeID: "confirm_1", Field: "confirmed"},
|
||||||
|
Operator: "is_true",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ID: "edge_confirm_cancel", Source: "confirm_1", Target: "cancel_end"},
|
||||||
|
{ID: "edge_create_end", Source: "create_ticket_1", Target: "end_1"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func humanConfirmWorkflowDefinition() dsl.Definition {
|
||||||
|
return dsl.Definition{
|
||||||
|
SchemaVersion: 1,
|
||||||
|
EntryNodeID: "start_1",
|
||||||
|
Nodes: []dsl.Node{
|
||||||
|
{ID: "start_1", Type: workflowregistry.NodeTypeStart, Name: "Start"},
|
||||||
|
{ID: "prompt_1", Type: workflowregistry.NodeTypeLLMReply, Name: "Prompt", Config: []byte(`{"staticReply":"请确认创建工单"}`)},
|
||||||
|
{ID: "confirm_1", Type: workflowregistry.NodeTypeHumanConfirm, Name: "Confirm", Inputs: map[string]dsl.VariableSelector{
|
||||||
|
"prompt": {NodeID: "prompt_1", Field: "replyText"},
|
||||||
|
}},
|
||||||
|
{ID: "end_1", Type: workflowregistry.NodeTypeEnd, Name: "End"},
|
||||||
|
{ID: "cancel_end", Type: workflowregistry.NodeTypeEnd, Name: "Cancel"},
|
||||||
|
},
|
||||||
|
Edges: []dsl.Edge{
|
||||||
|
{ID: "edge_start_prompt", Source: "start_1", Target: "prompt_1"},
|
||||||
|
{ID: "edge_prompt_confirm", Source: "prompt_1", Target: "confirm_1"},
|
||||||
|
{
|
||||||
|
ID: "edge_confirm_yes",
|
||||||
|
Source: "confirm_1",
|
||||||
|
Target: "end_1",
|
||||||
|
Condition: &dsl.Condition{
|
||||||
|
Left: &dsl.VariableSelector{NodeID: "confirm_1", Field: "confirmed"},
|
||||||
|
Operator: "is_true",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ID: "edge_confirm_cancel", Source: "confirm_1", Target: "cancel_end"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareTicketDraftWorkflowDefinition() dsl.Definition {
|
||||||
|
return dsl.Definition{
|
||||||
|
SchemaVersion: 1,
|
||||||
|
EntryNodeID: "start_1",
|
||||||
|
Nodes: []dsl.Node{
|
||||||
|
{ID: "start_1", Type: workflowregistry.NodeTypeStart, Name: "Start"},
|
||||||
|
{ID: "draft_1", Type: workflowregistry.NodeTypePrepareTicketDraft, Name: "Draft", Inputs: map[string]dsl.VariableSelector{
|
||||||
|
"issue": {NodeID: "start_1", Field: "userMessage"},
|
||||||
|
}},
|
||||||
|
{ID: "ready_end", Type: workflowregistry.NodeTypeEnd, Name: "Ready"},
|
||||||
|
{ID: "default_end", Type: workflowregistry.NodeTypeEnd, Name: "Default"},
|
||||||
|
},
|
||||||
|
Edges: []dsl.Edge{
|
||||||
|
{ID: "edge_start_draft", Source: "start_1", Target: "draft_1"},
|
||||||
|
{
|
||||||
|
ID: "edge_draft_ready",
|
||||||
|
Source: "draft_1",
|
||||||
|
Target: "ready_end",
|
||||||
|
Condition: &dsl.Condition{
|
||||||
|
Left: &dsl.VariableSelector{NodeID: "draft_1", Field: "ticketDraft"},
|
||||||
|
Operator: "exists",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ID: "edge_draft_default", Source: "draft_1", Target: "default_end"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func analyzeConversationWorkflowDefinition() dsl.Definition {
|
func analyzeConversationWorkflowDefinition() dsl.Definition {
|
||||||
return dsl.Definition{
|
return dsl.Definition{
|
||||||
SchemaVersion: 1,
|
SchemaVersion: 1,
|
||||||
@@ -225,16 +447,23 @@ func setupWorkflowExecutorHandoffDB(t *testing.T) *gorm.DB {
|
|||||||
})
|
})
|
||||||
if err := db.AutoMigrate(
|
if err := db.AutoMigrate(
|
||||||
&models.User{},
|
&models.User{},
|
||||||
|
&models.Customer{},
|
||||||
|
&models.CustomerIdentity{},
|
||||||
&models.AIAgent{},
|
&models.AIAgent{},
|
||||||
&models.AgentTeam{},
|
&models.AgentTeam{},
|
||||||
&models.AgentTeamSchedule{},
|
&models.AgentTeamSchedule{},
|
||||||
&models.AgentProfile{},
|
&models.AgentProfile{},
|
||||||
|
&models.Channel{},
|
||||||
&models.Conversation{},
|
&models.Conversation{},
|
||||||
&models.ConversationAssignment{},
|
&models.ConversationAssignment{},
|
||||||
&models.ConversationEventLog{},
|
&models.ConversationEventLog{},
|
||||||
&models.ConversationReadState{},
|
&models.ConversationReadState{},
|
||||||
&models.Message{},
|
&models.Message{},
|
||||||
&models.ChannelMessageOutbox{},
|
&models.ChannelMessageOutbox{},
|
||||||
|
&models.Ticket{},
|
||||||
|
&models.TicketNoSequence{},
|
||||||
|
&models.TicketTag{},
|
||||||
|
&models.TicketProgress{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
t.Fatalf("auto migrate error = %v", err)
|
t.Fatalf("auto migrate error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -303,6 +532,13 @@ func createWorkflowExecutorHandoffAgentProfile(t *testing.T, db *gorm.DB, userID
|
|||||||
func createWorkflowExecutorHandoffConversation(t *testing.T, db *gorm.DB, aiAgentID int64) models.Conversation {
|
func createWorkflowExecutorHandoffConversation(t *testing.T, db *gorm.DB, aiAgentID int64) models.Conversation {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
if err := db.FirstOrCreate(&models.Customer{
|
||||||
|
ID: 1,
|
||||||
|
Name: "测试访客",
|
||||||
|
Status: enums.StatusOk,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("create customer error = %v", err)
|
||||||
|
}
|
||||||
item := models.Conversation{
|
item := models.Conversation{
|
||||||
AIAgentID: aiAgentID,
|
AIAgentID: aiAgentID,
|
||||||
ChannelID: 1,
|
ChannelID: 1,
|
||||||
|
|||||||
Reference in New Issue
Block a user