feat(agent): clarify vague briefs before generating and harden prompts
Let the planner return a clarify/chat intent that asks 2-4 targeted questions (imageCount 0) when essentials are missing, instead of forging ahead. Tighten prompt guidance to preserve user wording and stated reference-image roles, avoid inventing copy/specs, keep durable brand constraints, and treat vague taste feedback as clarification. Also fix the generation heartbeat goroutine to stop cleanly via defer, add research-query fallbacks, and refresh image-only capability copy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -319,6 +319,37 @@ func (a incrementalCreateProjectAgent) Replay(ctx context.Context, project desig
|
||||
return design.AgentReplay{}, nil
|
||||
}
|
||||
|
||||
type staleOverwriteIncrementalCreateProjectAgent struct {
|
||||
repo design.Repository
|
||||
t *testing.T
|
||||
}
|
||||
|
||||
func (a staleOverwriteIncrementalCreateProjectAgent) Plan(ctx context.Context, req design.AgentRequest) (design.AgentPlan, error) {
|
||||
return design.AgentPlan{}, errors.New("expected incremental generation")
|
||||
}
|
||||
|
||||
func (a staleOverwriteIncrementalCreateProjectAgent) PlanIncremental(ctx context.Context, req design.AgentRequest, onResult func(design.AgentImageGenerationResult) error) (design.AgentPlan, error) {
|
||||
if len(req.Project.Nodes) == 0 {
|
||||
a.t.Fatal("expected planned placeholder before image generation")
|
||||
}
|
||||
staleProject := req.Project
|
||||
node := design.Node{ID: req.Project.Nodes[0].ID, Type: design.NodeTypeImage, Title: "盲盒IP视觉体系", Content: "https://example.com/blind-box-system.png", Status: "success", Width: 1024, Height: 1024}
|
||||
if err := onResult(design.AgentImageGenerationResult{Index: 0, Node: node}); err != nil {
|
||||
return design.AgentPlan{}, err
|
||||
}
|
||||
if err := a.repo.Save(ctx, staleProject); err != nil {
|
||||
return design.AgentPlan{}, err
|
||||
}
|
||||
return design.AgentPlan{
|
||||
Message: design.Message{Role: "assistant", Type: "assistant", Title: "图片生成完成", Content: "盲盒IP视觉体系已生成。"},
|
||||
GeneratedNodes: []design.Node{node},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a staleOverwriteIncrementalCreateProjectAgent) Replay(ctx context.Context, project design.Project) (design.AgentReplay, error) {
|
||||
return design.AgentReplay{}, nil
|
||||
}
|
||||
|
||||
type partialFailingIncrementalCreateProjectAgent struct{}
|
||||
|
||||
func (a partialFailingIncrementalCreateProjectAgent) Plan(ctx context.Context, req design.AgentRequest) (design.AgentPlan, error) {
|
||||
@@ -541,6 +572,54 @@ func TestAgentChatCreatesPlaceholdersFromModelPlannedImageTasks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentChatAllowsPlannerToClarifyVisualBriefBeforeGenerating(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := memory.NewProjectRepository()
|
||||
queue := &fakeJobQueue{}
|
||||
service := NewDesignService(repo, nil, nil, nil, nil, fakeCreativeChatAgent{
|
||||
decision: design.AgentDecision{Intent: "image", Reason: "visual request"},
|
||||
taskPlan: design.AgentTaskPlan{
|
||||
Intent: "clarify",
|
||||
UserFacingResponse: "开始生图前我需要确认:Logo 是否已有?主视觉偏大胆色彩还是极简?首页要优先展示哪些品类?",
|
||||
ImageCount: 0,
|
||||
},
|
||||
response: "开始生图前我需要确认:Logo 是否已有?主视觉偏大胆色彩还是极简?首页要优先展示哪些品类?",
|
||||
})
|
||||
service.SetJobQueue(queue)
|
||||
project := design.Project{
|
||||
ID: "project-clarify-before-generate",
|
||||
Title: "Clarify before generate",
|
||||
Status: design.StatusReady,
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
if err := repo.Save(ctx, project); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
thread, err := service.AgentChat(ctx, project.ID, design.AgentChatRequest{
|
||||
Messages: []design.AgentChatMessage{
|
||||
{
|
||||
Role: "user",
|
||||
Contents: []design.AgentContent{
|
||||
{Type: "text", Text: "still day 品牌官网设计,主打 mood 配色和舒适针织。"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(thread.Project.Nodes) != 0 {
|
||||
t.Fatalf("expected no image placeholders while clarifying, got %#v", thread.Project.Nodes)
|
||||
}
|
||||
if len(queue.jobs) != 1 || queue.jobs[0].Kind != design.JobAgentConversation {
|
||||
t.Fatalf("expected clarification to enqueue chat job, got %#v", queue.jobs)
|
||||
}
|
||||
if queue.jobs[0].Conversation.TaskPlan.Intent != "chat" {
|
||||
t.Fatalf("expected clarification plan normalized to chat, got %#v", queue.jobs[0].Conversation.TaskPlan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProjectAsyncReturnsBeforeTaskPlanning(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := memory.NewProjectRepository()
|
||||
@@ -702,6 +781,62 @@ func TestCreateProjectGenerationStreamsEachImageAsItFinishes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProjectGenerationFinalizesAfterStaleHeartbeatOverwrite(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := memory.NewProjectRepository()
|
||||
creative := fakeCreativeChatAgent{
|
||||
decision: design.AgentDecision{Intent: "image"},
|
||||
taskPlan: design.AgentTaskPlan{
|
||||
Intent: "image",
|
||||
UserFacingResponse: "我将生成一张盲盒IP视觉体系图。",
|
||||
ImageCount: 1,
|
||||
ImageTasks: []design.AgentImageTask{
|
||||
{Title: "盲盒IP视觉体系", Brief: "角色、包装、展示台、贴纸和发售海报总览"},
|
||||
},
|
||||
},
|
||||
}
|
||||
service := NewDesignService(repo, staleOverwriteIncrementalCreateProjectAgent{repo: repo, t: t}, nil, nil, nil, creative)
|
||||
service.SetJobQueue(&fakeJobQueue{})
|
||||
project, err := service.CreateProjectAsync(ctx, "", "设计一张盲盒IP视觉体系。", "gpt-image-2", "", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := service.ProcessJob(ctx, design.Job{
|
||||
Kind: design.JobCreateProjectGeneration,
|
||||
ProjectID: project.ID,
|
||||
UserID: project.UserID,
|
||||
ThreadID: project.LastThreadID,
|
||||
Prompt: project.Brief,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
saved, err := repo.Get(ctx, project.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.Status != design.StatusReady {
|
||||
t.Fatalf("expected project ready after stale heartbeat overwrite, got %s", saved.Status)
|
||||
}
|
||||
if len(saved.Nodes) != 1 || saved.Nodes[0].Status != "success" || saved.Nodes[0].Content != "https://example.com/blind-box-system.png" {
|
||||
t.Fatalf("expected final generated node restored, got %#v", saved.Nodes)
|
||||
}
|
||||
foundToolSuccess := false
|
||||
foundArtifact := false
|
||||
for _, message := range saved.Messages {
|
||||
if message.Title == "GPT Image 2" && message.ThreadID == project.LastThreadID && message.Status == "success" {
|
||||
foundToolSuccess = true
|
||||
}
|
||||
if message.Name == "generate_media" && strings.Contains(message.Content, "blind-box-system.png") {
|
||||
foundArtifact = true
|
||||
}
|
||||
}
|
||||
if !foundToolSuccess || !foundArtifact {
|
||||
t.Fatalf("expected final tool success and restored artifact message, got %#v", saved.Messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProjectGenerationPartialFailurePreservesSuccessfulImagesAndClosesTask(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := memory.NewProjectRepository()
|
||||
@@ -1147,7 +1282,7 @@ func TestGenerationStepHeartbeatUpdatesLatestToolMessage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSearchRunsOnlyWhenModelDecisionNeedsIt(t *testing.T) {
|
||||
func TestWebSearchRunsWhenEnabledEvenIfModelSkips(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := memory.NewProjectRepository()
|
||||
project := design.Project{
|
||||
@@ -1172,13 +1307,18 @@ func TestWebSearchRunsOnlyWhenModelDecisionNeedsIt(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if searcher.called {
|
||||
t.Fatal("expected web search to stay off when model decision says no")
|
||||
if !searcher.called {
|
||||
t.Fatal("expected enabled web search to run even when model decision says no")
|
||||
}
|
||||
if searcher.query != fallbackResearchQuery("优化当前首页视觉", "design") {
|
||||
t.Fatalf("expected fallback search query, got %q", searcher.query)
|
||||
}
|
||||
if agent.memory.ShortTerm == "" || agent.memory.LongTerm == "" {
|
||||
t.Fatalf("expected short and long memory in research decision, got %#v", agent.memory)
|
||||
}
|
||||
|
||||
searcher.called = false
|
||||
searcher.query = ""
|
||||
agent.decision = design.AgentResearchDecision{ShouldSearch: true, Query: "still day ecommerce homepage reference", Reason: "needs external references"}
|
||||
err = service.maybeAddResearchStep(ctx, project.ID, "thread-1", project, "优化当前首页视觉", "design", nil, true)
|
||||
if err != nil {
|
||||
@@ -1262,7 +1402,7 @@ func TestAgentChatUsesPlannerSearchQueryInsteadOfRawPrompt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitialWebSearchDoesNotFallbackToRawPromptWhenModelSkips(t *testing.T) {
|
||||
func TestInitialWebSearchFallsBackToSearchQueryWhenModelSkips(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := memory.NewProjectRepository()
|
||||
threadID := "thread-initial-search"
|
||||
@@ -1288,15 +1428,18 @@ func TestInitialWebSearchDoesNotFallbackToRawPromptWhenModelSkips(t *testing.T)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if searcher.called {
|
||||
t.Fatalf("expected no raw-prompt fallback search, got query=%q", searcher.query)
|
||||
if !searcher.called {
|
||||
t.Fatal("expected enabled initial web search to run")
|
||||
}
|
||||
if searcher.query != fallbackResearchQuery("做一个美漫风格分镜", "design") {
|
||||
t.Fatalf("expected fallback search query, got %q", searcher.query)
|
||||
}
|
||||
saved, err := repo.Get(ctx, project.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(saved.Messages) != len(project.Messages) {
|
||||
t.Fatalf("expected no research message, got %#v", saved.Messages)
|
||||
if len(saved.Messages) == len(project.Messages) || saved.Messages[len(saved.Messages)-1].Title != "Synthesize Skills" {
|
||||
t.Fatalf("expected research message, got %#v", saved.Messages)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user