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:
2026-07-08 10:56:55 +08:00
parent 1a1c0a6544
commit ad67fe1ebe
5 changed files with 318 additions and 44 deletions
@@ -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)
}
}