Add default conversation title

This commit is contained in:
dwrz
2026-10-31 16:56:08 +00:00
parent 47badbb0d6
commit d9ce81af9c
7 changed files with 439 additions and 41 deletions
+27 -1
View File
@@ -651,6 +651,17 @@ func (inf *Inference) Run() (err error) {
slog.String("id", saved.Id),
)
// Give an untitled conversation its title before the model
// runs, so a slow completion does not delay the title. A
// failure here costs the title, not the run: the next user
// message tries again.
if err := c.setTitle(ctx, *req.Message); err != nil {
log.WarnContext(
ctx, "failed to derive conversation title",
slog.Any("error", err),
)
}
// Continue from the updated history.
history = append(history, *req.Message)
}
@@ -1147,7 +1158,22 @@ func (c *Conversation) InsertMessage(
return nil, ErrBusy
}
return c.insertMessage(ctx, nil, msg, nil, nil)
saved, err := c.insertMessage(ctx, nil, msg, nil, nil)
if err != nil {
return nil, err
}
// A user message can give an untitled conversation its title.
// A failure here costs the title, not the saved message.
if msg.Role == llm.RoleUser {
if err := c.setTitle(ctx, msg); err != nil {
c.log.WarnContext(
ctx, "failed to derive conversation title",
slog.Any("error", err),
)
}
}
return saved, nil
}
// UpdateMessage replaces a message's model-visible content; its role may
+67
View File
@@ -0,0 +1,67 @@
package conversation
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"unicode/utf8"
"code.chimeric.al/chimerical/odidere/internal/db"
"code.chimeric.al/chimerical/odidere/internal/llm"
)
// MaxTitleLength is the maximum length of a conversation title in
// Unicode code points.
const MaxTitleLength = 256
// deriveTitle returns a title from the first line of text that
// contains non-whitespace characters. It replaces runs of
// whitespace on that line with single spaces and truncates to
// MaxTitleLength runes. It returns "" if text has no
// non-whitespace characters.
func deriveTitle(text string) string {
for line := range strings.SplitSeq(text, "\n") {
title := strings.Join(strings.Fields(line), " ")
if title == "" {
continue
}
if utf8.RuneCountInString(title) <= MaxTitleLength {
return title
}
runes := []rune(title)
return string(runes[:MaxTitleLength])
}
return ""
}
// setTitle derives a title from msg and sets the conversation
// title if the conversation has no title yet. It does nothing if
// msg has no non-whitespace text or if the conversation already
// has a title.
func (c *Conversation) setTitle(
ctx context.Context,
msg llm.Message,
) error {
title := deriveTitle(msg.Text())
if title == "" {
return nil
}
_, err := c.dbw.UpdateConversationTitleIfUnset(
ctx,
db.UpdateConversationTitleIfUnsetParams{
Id: c.id,
Title: title,
},
)
// sql.ErrNoRows means the conversation already has a title (or
// does not exist).
if errors.Is(err, sql.ErrNoRows) {
return nil
}
if err != nil {
return fmt.Errorf("update conversation title: %w", err)
}
return nil
}
+202
View File
@@ -0,0 +1,202 @@
package conversation
import (
"fmt"
"net/http"
"strings"
"testing"
"code.chimeric.al/chimerical/odidere/internal/db"
"code.chimeric.al/chimerical/odidere/internal/llm"
)
func TestDeriveTitle(t *testing.T) {
tests := []struct {
name string
text string
want string
}{
{
name: "single line",
text: "Check the cameras",
want: "Check the cameras",
},
{
name: "blank lines come before the first line",
text: "\n \n\t\nCheck the cameras\nSecond line",
want: "Check the cameras",
},
{
name: "spaces inside the line collapse to one",
text: "Can you\tcheck the cameras? ",
want: "Can you check the cameras?",
},
{
name: "leading indentation is trimmed",
text: " def main():",
want: "def main():",
},
{
name: "a line of MaxTitleLength characters stays whole",
text: strings.Repeat("x", MaxTitleLength),
want: strings.Repeat("x", MaxTitleLength),
},
{
name: "a longer line cuts to MaxTitleLength characters",
text: strings.Repeat("x", 300),
want: strings.Repeat("x", MaxTitleLength),
},
{
name: "the cut counts characters, not bytes",
text: strings.Repeat("文", 300),
want: strings.Repeat("文", MaxTitleLength),
},
{
name: "empty text",
text: "",
want: "",
},
{
name: "spaces only",
text: " \n\t \n",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := deriveTitle(tt.text); got != tt.want {
t.Fatalf("deriveTitle(%q) = %q, want %q", tt.text, got, tt.want)
}
})
}
}
func title(t *testing.T, c *Conversation) *string {
t.Helper()
conv, err := c.dbr.SelectConversationId(t.Context(), c.id)
if err != nil {
t.Fatalf("SelectConversationId: %v", err)
}
return conv.Title
}
func TestInsertMessageTitlesConversation(t *testing.T) {
ctx := t.Context()
// The first user message sets the title from its first non-empty
// line.
c := newTestConversation(t, nil)
if _, err := c.InsertMessage(
ctx, *userMessage(" Check the cameras\nplease"),
); err != nil {
t.Fatalf("InsertMessage: %v", err)
}
if got := title(t, c); got == nil || *got != "Check the cameras" {
t.Fatalf("Title = %v, want Check the cameras", got)
}
// A later user message does not overwrite an existing title.
if _, err := c.InsertMessage(
ctx, *userMessage("A different subject"),
); err != nil {
t.Fatalf("InsertMessage: %v", err)
}
if got := title(t, c); got == nil || *got != "Check the cameras" {
t.Fatalf("Title = %v, want Check the cameras", got)
}
// Non-user messages do not set the title.
c2 := newTestConversation(t, nil)
assistant := llm.Message{
Role: llm.RoleAssistant,
ContentParts: []llm.ContentPart{{
Type: llm.ContentTypeText,
Text: "Hello there",
}},
}
if _, err := c2.InsertMessage(ctx, assistant); err != nil {
t.Fatalf("InsertMessage: %v", err)
}
if got := title(t, c2); got != nil {
t.Fatalf("Title = %v, want nil", got)
}
// A title set before the first user message is kept.
c3 := newTestConversation(t, nil)
if _, err := c3.dbw.UpdateConversationTitle(
ctx,
db.UpdateConversationTitleParams{
Id: c3.id,
Title: "Nightly report",
},
); err != nil {
t.Fatalf("UpdateConversationTitle: %v", err)
}
if _, err := c3.InsertMessage(
ctx, *userMessage("A different subject"),
); err != nil {
t.Fatalf("InsertMessage: %v", err)
}
if got := title(t, c3); got == nil || *got != "Nightly report" {
t.Fatalf("Title = %v, want Nightly report", got)
}
}
func TestInferenceTitlesConversation(t *testing.T) {
var (
ctx = t.Context()
client = newTestModelClient(
t,
func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{
"choices":[{
"message":{"role":"assistant","content":"ok"},
"finish_reason":"stop"
}]
}`)
},
)
)
// Inference with a new user message sets an unset title.
c := newTestConversation(t, nil)
if err := infer(ctx, c, client, InferenceRequest{
Message: userMessage("Turn off the porch light"),
}); err != nil {
t.Fatalf("infer: %v", err)
}
if got := title(t, c); got == nil || *got != "Turn off the porch light" {
t.Fatalf("Title = %v, want Turn off the porch light", got)
}
// Later inference does not overwrite the title.
if err := infer(ctx, c, client, InferenceRequest{
Message: userMessage("A different subject"),
}); err != nil {
t.Fatalf("infer: %v", err)
}
if got := title(t, c); got == nil || *got != "Turn off the porch light" {
t.Fatalf("Title = %v, want Turn off the porch light", got)
}
// Inference does not overwrite a title that was already set.
c2 := newTestConversation(t, nil)
if _, err := c2.dbw.UpdateConversationTitle(
ctx,
db.UpdateConversationTitleParams{
Id: c2.id,
Title: "Nightly report",
},
); err != nil {
t.Fatalf("UpdateConversationTitle: %v", err)
}
if err := infer(ctx, c2, client, InferenceRequest{
Message: userMessage("A different subject"),
}); err != nil {
t.Fatalf("infer: %v", err)
}
if got := title(t, c2); got == nil || *got != "Nightly report" {
t.Fatalf("Title = %v, want Nightly report", got)
}
}