mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix(coderd/x/chatd): use structured output for chat title generation (#23909)
Chat title generation used free-form text completion, which let models respond conversationally instead of producing a title. Review chats started with GitHub URLs were especially affected — models would say "I don't have the ability to browse external links" and that string became the persisted title. Replace the raw-text `generateShortText` path with structured output via `object.Generate[generatedTitle]`. Both auto-title and manual retitle now go through the same typed contract: the model must return a JSON object with a `title` field, validated and normalized before persistence. Invalid outputs (empty, too long) are rejected and retried through the existing candidate-model fallback loop.
This commit is contained in:
@@ -62,8 +62,8 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts(t *testing.T) {
|
||||
}
|
||||
modelConfig := database.ChatModelConfig{
|
||||
ID: modelConfigID,
|
||||
Provider: "anthropic",
|
||||
Model: "claude-haiku-4-5",
|
||||
Provider: "openai",
|
||||
Model: "gpt-4o-mini",
|
||||
ContextLimit: 8192,
|
||||
}
|
||||
updatedChat := chat
|
||||
@@ -85,9 +85,9 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
defer cancelSub()
|
||||
|
||||
serverURL := chattest.NewAnthropic(t, func(req *chattest.AnthropicRequest) chattest.AnthropicResponse {
|
||||
require.Equal(t, "claude-haiku-4-5", req.Model)
|
||||
return chattest.AnthropicNonStreamingResponse(wantTitle)
|
||||
serverURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
|
||||
require.Equal(t, "gpt-4o-mini", req.Model)
|
||||
return chattest.OpenAINonStreamingResponse("{\"title\":\"" + wantTitle + "\"}")
|
||||
})
|
||||
|
||||
server := &Server{
|
||||
@@ -99,7 +99,7 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts(t *testing.T) {
|
||||
|
||||
db.EXPECT().GetChatModelConfigByID(gomock.Any(), modelConfigID).Return(modelConfig, nil)
|
||||
db.EXPECT().GetEnabledChatProviders(gomock.Any()).Return([]database.ChatProvider{{
|
||||
Provider: "anthropic",
|
||||
Provider: "openai",
|
||||
APIKey: "test-key",
|
||||
BaseUrl: serverURL,
|
||||
}}, nil)
|
||||
@@ -221,8 +221,8 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts_IdleChatReleasesManualLock(t
|
||||
lockedChat.StartedAt = sql.NullTime{Time: time.Now(), Valid: true}
|
||||
modelConfig := database.ChatModelConfig{
|
||||
ID: modelConfigID,
|
||||
Provider: "anthropic",
|
||||
Model: "claude-haiku-4-5",
|
||||
Provider: "openai",
|
||||
Model: "gpt-4o-mini",
|
||||
ContextLimit: 8192,
|
||||
}
|
||||
updatedChat := lockedChat
|
||||
@@ -247,9 +247,9 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts_IdleChatReleasesManualLock(t
|
||||
require.NoError(t, err)
|
||||
defer cancelSub()
|
||||
|
||||
serverURL := chattest.NewAnthropic(t, func(req *chattest.AnthropicRequest) chattest.AnthropicResponse {
|
||||
require.Equal(t, "claude-haiku-4-5", req.Model)
|
||||
return chattest.AnthropicNonStreamingResponse(wantTitle)
|
||||
serverURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
|
||||
require.Equal(t, "gpt-4o-mini", req.Model)
|
||||
return chattest.OpenAINonStreamingResponse("{\"title\":\"" + wantTitle + "\"}")
|
||||
})
|
||||
|
||||
server := &Server{
|
||||
@@ -261,7 +261,7 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts_IdleChatReleasesManualLock(t
|
||||
|
||||
db.EXPECT().GetChatModelConfigByID(gomock.Any(), modelConfigID).Return(modelConfig, nil)
|
||||
db.EXPECT().GetEnabledChatProviders(gomock.Any()).Return([]database.ChatProvider{{
|
||||
Provider: "anthropic",
|
||||
Provider: "openai",
|
||||
APIKey: "test-key",
|
||||
BaseUrl: serverURL,
|
||||
}}, nil)
|
||||
|
||||
@@ -670,7 +670,11 @@ func (s *openAIServer) writeResponsesAPINonStreaming(w http.ResponseWriter, resp
|
||||
"created": resp.Created,
|
||||
"model": resp.Model,
|
||||
"output": outputs,
|
||||
"usage": resp.Usage,
|
||||
"usage": map[string]interface{}{
|
||||
"input_tokens": resp.Usage.PromptTokens,
|
||||
"output_tokens": resp.Usage.CompletionTokens,
|
||||
"total_tokens": resp.Usage.TotalTokens,
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
|
||||
+79
-13
@@ -2,12 +2,14 @@ package chatd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"charm.land/fantasy/object"
|
||||
fantasyanthropic "charm.land/fantasy/providers/anthropic"
|
||||
fantasyazure "charm.land/fantasy/providers/azure"
|
||||
fantasybedrock "charm.land/fantasy/providers/bedrock"
|
||||
@@ -27,6 +29,7 @@ import (
|
||||
)
|
||||
|
||||
const titleGenerationPrompt = "Write a short title for the user's message. " +
|
||||
"Populate the title field with the result. " +
|
||||
"Return only the title text in 2-8 words. " +
|
||||
"Do not answer the user or describe the title-writing task. " +
|
||||
"Preserve specific identifiers such as PR numbers, repo names, file paths, function names, and error messages. " +
|
||||
@@ -89,6 +92,10 @@ func normalizeShortTextOutput(text string) string {
|
||||
return strings.Join(strings.Fields(text), " ")
|
||||
}
|
||||
|
||||
type generatedTitle struct {
|
||||
Title string `json:"title" description:"Short descriptive chat title"`
|
||||
}
|
||||
|
||||
// maybeGenerateChatTitle generates an AI title for the chat when
|
||||
// appropriate (first user message, no assistant reply yet, and the
|
||||
// current title is either empty or still the fallback truncation).
|
||||
@@ -173,17 +180,79 @@ func generateTitle(
|
||||
model fantasy.LanguageModel,
|
||||
input string,
|
||||
) (string, error) {
|
||||
title, _, err := generateShortText(ctx, model, titleGenerationPrompt, input)
|
||||
title, _, err := generateStructuredTitle(ctx, model, titleGenerationPrompt, input)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
title = normalizeTitleOutput(title)
|
||||
if title == "" {
|
||||
return "", xerrors.New("generated title was empty")
|
||||
}
|
||||
return title, nil
|
||||
}
|
||||
|
||||
func generateStructuredTitle(
|
||||
ctx context.Context,
|
||||
model fantasy.LanguageModel,
|
||||
systemPrompt string,
|
||||
userInput string,
|
||||
) (string, fantasy.Usage, error) {
|
||||
userInput = strings.TrimSpace(userInput)
|
||||
if userInput == "" {
|
||||
return "", fantasy.Usage{}, xerrors.New("title input was empty")
|
||||
}
|
||||
|
||||
prompt := fantasy.Prompt{
|
||||
{
|
||||
Role: fantasy.MessageRoleSystem,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: systemPrompt},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: userInput},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var maxOutputTokens int64 = 256
|
||||
var result *fantasy.ObjectResult[generatedTitle]
|
||||
err := chatretry.Retry(ctx, func(retryCtx context.Context) error {
|
||||
var genErr error
|
||||
result, genErr = object.Generate[generatedTitle](retryCtx, model, fantasy.ObjectCall{
|
||||
Prompt: prompt,
|
||||
SchemaName: "propose_title",
|
||||
SchemaDescription: "Propose a short chat title.",
|
||||
MaxOutputTokens: &maxOutputTokens,
|
||||
})
|
||||
return genErr
|
||||
}, nil)
|
||||
if err != nil {
|
||||
// Extract usage from the error when available so that
|
||||
// failed attempts are still accounted for in usage tracking.
|
||||
var usage fantasy.Usage
|
||||
var noObjErr *fantasy.NoObjectGeneratedError
|
||||
if errors.As(err, &noObjErr) {
|
||||
usage = noObjErr.Usage
|
||||
}
|
||||
return "", usage, xerrors.Errorf("generate structured title: %w", err)
|
||||
}
|
||||
|
||||
title := normalizeTitleOutput(result.Object.Title)
|
||||
if err := validateGeneratedTitle(title); err != nil {
|
||||
return "", result.Usage, err
|
||||
}
|
||||
return title, result.Usage, nil
|
||||
}
|
||||
|
||||
func validateGeneratedTitle(title string) error {
|
||||
if title == "" {
|
||||
return xerrors.New("generated title was empty")
|
||||
}
|
||||
if len(strings.Fields(title)) > 8 {
|
||||
return xerrors.New("generated title exceeded 8 words")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// titleInput returns the first user message text and whether title
|
||||
// generation should proceed. It returns false when the chat already
|
||||
// has assistant/tool replies, has more than one visible user message,
|
||||
@@ -400,7 +469,8 @@ func renderManualTitlePrompt(
|
||||
_, _ = prompt.WriteString(value)
|
||||
}
|
||||
|
||||
write("Write a short title for this AI coding conversation.\n\n")
|
||||
write("Write a short title for this AI coding conversation.\n")
|
||||
write("Populate the title field with the result.\n\n")
|
||||
write("Primary user objective:\n<primary_objective>\n")
|
||||
write(firstUserText)
|
||||
write("\n</primary_objective>")
|
||||
@@ -420,6 +490,7 @@ func renderManualTitlePrompt(
|
||||
|
||||
write("\n\nRequirements:\n")
|
||||
write("- Return only the title text in 2-8 words.\n")
|
||||
write("- Populate the title field only.\n")
|
||||
write("- Do not answer the user or describe the title-writing task.\n")
|
||||
write("- Preserve specific identifiers (PR numbers, repo names, file paths, function names, error messages).\n")
|
||||
write("- If the conversation is short or vague, stay close to the user's wording.\n")
|
||||
@@ -458,19 +529,14 @@ func generateManualTitle(
|
||||
userInput = strings.TrimSpace(firstUserText)
|
||||
}
|
||||
|
||||
title, usage, err := generateShortText(
|
||||
title, usage, err := generateStructuredTitle(
|
||||
titleCtx,
|
||||
fallbackModel,
|
||||
systemPrompt,
|
||||
userInput,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fantasy.Usage{}, err
|
||||
}
|
||||
|
||||
title = normalizeTitleOutput(title)
|
||||
if title == "" {
|
||||
return "", usage, xerrors.New("generated title was empty")
|
||||
return "", usage, err
|
||||
}
|
||||
|
||||
return title, usage, nil
|
||||
|
||||
@@ -376,7 +376,7 @@ func Test_generateManualTitle_UsesTimeout(t *testing.T) {
|
||||
}
|
||||
|
||||
model := &stubModel{
|
||||
generateFn: func(ctx context.Context, call fantasy.Call) (*fantasy.Response, error) {
|
||||
generateObjectFn: func(ctx context.Context, call fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
|
||||
deadline, ok := ctx.Deadline()
|
||||
require.True(t, ok, "manual title generation should set a deadline")
|
||||
require.WithinDuration(
|
||||
@@ -386,11 +386,8 @@ func Test_generateManualTitle_UsesTimeout(t *testing.T) {
|
||||
2*time.Second,
|
||||
)
|
||||
require.Len(t, call.Prompt, 2)
|
||||
return &fantasy.Response{
|
||||
Content: fantasy.ResponseContent{
|
||||
fantasy.TextContent{Text: "Refresh title"},
|
||||
},
|
||||
}, nil
|
||||
require.Equal(t, "propose_title", call.SchemaName)
|
||||
return &fantasy.ObjectResponse{Object: map[string]any{"title": "Refresh title"}}, nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -417,7 +414,7 @@ func Test_generateManualTitle_TruncatesFirstUserInput(t *testing.T) {
|
||||
}
|
||||
|
||||
model := &stubModel{
|
||||
generateFn: func(_ context.Context, call fantasy.Call) (*fantasy.Response, error) {
|
||||
generateObjectFn: func(_ context.Context, call fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
|
||||
require.Len(t, call.Prompt, 2)
|
||||
systemText, ok := call.Prompt[0].Content[0].(fantasy.TextPart)
|
||||
require.True(t, ok)
|
||||
@@ -426,11 +423,7 @@ func Test_generateManualTitle_TruncatesFirstUserInput(t *testing.T) {
|
||||
userText, ok := call.Prompt[1].Content[0].(fantasy.TextPart)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, truncateRunes(longFirstUserText, 1000), userText.Text)
|
||||
return &fantasy.Response{
|
||||
Content: fantasy.ResponseContent{
|
||||
fantasy.TextContent{Text: "Refresh title"},
|
||||
},
|
||||
}, nil
|
||||
return &fantasy.ObjectResponse{Object: map[string]any{"title": "Refresh title"}}, nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -455,11 +448,9 @@ func Test_generateManualTitle_ReturnsUsageForEmptyNormalizedTitle(t *testing.T)
|
||||
}
|
||||
|
||||
model := &stubModel{
|
||||
generateFn: func(_ context.Context, _ fantasy.Call) (*fantasy.Response, error) {
|
||||
return &fantasy.Response{
|
||||
Content: fantasy.ResponseContent{
|
||||
fantasy.TextContent{Text: "\"\""},
|
||||
},
|
||||
generateObjectFn: func(_ context.Context, _ fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
|
||||
return &fantasy.ObjectResponse{
|
||||
Object: map[string]any{"title": "\"\""},
|
||||
Usage: fantasy.Usage{
|
||||
InputTokens: 11,
|
||||
OutputTokens: 7,
|
||||
@@ -533,13 +524,17 @@ func Test_generateShortText_NormalizesQuotedOutput(t *testing.T) {
|
||||
}
|
||||
|
||||
type stubModel struct {
|
||||
generateFn func(context.Context, fantasy.Call) (*fantasy.Response, error)
|
||||
generateFn func(context.Context, fantasy.Call) (*fantasy.Response, error)
|
||||
generateObjectFn func(context.Context, fantasy.ObjectCall) (*fantasy.ObjectResponse, error)
|
||||
}
|
||||
|
||||
func (m *stubModel) Generate(
|
||||
ctx context.Context,
|
||||
call fantasy.Call,
|
||||
) (*fantasy.Response, error) {
|
||||
if m.generateFn == nil {
|
||||
return nil, xerrors.New("generate not implemented")
|
||||
}
|
||||
return m.generateFn(ctx, call)
|
||||
}
|
||||
|
||||
@@ -550,11 +545,14 @@ func (*stubModel) Stream(
|
||||
return nil, xerrors.New("stream not implemented")
|
||||
}
|
||||
|
||||
func (*stubModel) GenerateObject(
|
||||
context.Context,
|
||||
fantasy.ObjectCall,
|
||||
func (m *stubModel) GenerateObject(
|
||||
ctx context.Context,
|
||||
call fantasy.ObjectCall,
|
||||
) (*fantasy.ObjectResponse, error) {
|
||||
return nil, xerrors.New("generate object not implemented")
|
||||
if m.generateObjectFn == nil {
|
||||
return nil, xerrors.New("generate object not implemented")
|
||||
}
|
||||
return m.generateObjectFn(ctx, call)
|
||||
}
|
||||
|
||||
func (*stubModel) StreamObject(
|
||||
|
||||
Reference in New Issue
Block a user