Add default conversation title
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user