mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: Add full text search over chat messages (#27126)
Closes CODAGT-721 Closes CODAGT-722 Closes CODAGT-723 Closes CODAGT-724 Closes CODAGT-725 This PR adds the database and API pieces necessary to support full-text chat message search. - Adds required chat schema for full-text search - Adds dbpurge job to populate search_tsv in the background - Adds `search` parameter to GetChats query - Adds `search` filter to `searchquery.Chats` - Wires chat search filter into chats API > Implemented by Coder Agents, reviewed and tested by a human.
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
||||
"net"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -15344,6 +15345,237 @@ func TestGetChatsFilter(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetChatsSearch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store, _, sqlDB := dbtestutil.NewDBWithSQLDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
org := dbgen.Organization(t, store, database.Organization{})
|
||||
user := dbgen.User(t, store, database.User{})
|
||||
dbgen.OrganizationMember(t, store, database.OrganizationMember{UserID: user.ID, OrganizationID: org.ID})
|
||||
|
||||
provider := dbgen.AIProviderWithOptionalKey(t, store, database.AIProvider{
|
||||
Type: database.AIProviderTypeOpenai,
|
||||
}, "test-key")
|
||||
|
||||
modelCfg, err := store.InsertChatModelConfig(ctx, database.InsertChatModelConfigParams{
|
||||
AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true},
|
||||
Model: "test-model-" + uuid.NewString(),
|
||||
DisplayName: "Test Model",
|
||||
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
|
||||
UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
|
||||
Enabled: true,
|
||||
IsDefault: true,
|
||||
ContextLimit: 128000,
|
||||
CompressionThreshold: 80,
|
||||
Options: json.RawMessage(`{}`),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
createRoot := func(title string) database.Chat {
|
||||
t.Helper()
|
||||
chat, err := store.InsertChat(ctx, database.InsertChatParams{
|
||||
OrganizationID: org.ID,
|
||||
Status: database.ChatStatusWaiting,
|
||||
ClientType: database.ChatClientTypeUi,
|
||||
OwnerID: user.ID,
|
||||
LastModelConfigID: modelCfg.ID,
|
||||
Title: title,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return chat
|
||||
}
|
||||
|
||||
createChild := func(root database.Chat, title string) database.Chat {
|
||||
t.Helper()
|
||||
chat, err := store.InsertChat(ctx, database.InsertChatParams{
|
||||
OrganizationID: org.ID,
|
||||
Status: database.ChatStatusWaiting,
|
||||
ClientType: database.ChatClientTypeUi,
|
||||
OwnerID: user.ID,
|
||||
LastModelConfigID: modelCfg.ID,
|
||||
Title: title,
|
||||
ParentChatID: uuid.NullUUID{UUID: root.ID, Valid: true},
|
||||
RootChatID: uuid.NullUUID{UUID: root.ID, Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return chat
|
||||
}
|
||||
|
||||
insertMsg := func(chatID uuid.UUID, role database.ChatMessageRole, visibility database.ChatMessageVisibility, text string) database.ChatMessage {
|
||||
t.Helper()
|
||||
msgs, err := store.InsertChatMessages(ctx, database.InsertChatMessagesParams{
|
||||
ChatID: chatID,
|
||||
CreatedBy: []uuid.UUID{user.ID},
|
||||
ModelConfigID: []uuid.UUID{modelCfg.ID},
|
||||
Role: []database.ChatMessageRole{role},
|
||||
Content: []string{`[{"type":"text","text":` + strconv.Quote(text) + `}]`},
|
||||
ContentVersion: []int16{1},
|
||||
Visibility: []database.ChatMessageVisibility{visibility},
|
||||
InputTokens: []int64{0},
|
||||
OutputTokens: []int64{0},
|
||||
TotalTokens: []int64{0},
|
||||
ReasoningTokens: []int64{0},
|
||||
CacheCreationTokens: []int64{0},
|
||||
CacheReadTokens: []int64{0},
|
||||
ContextLimit: []int64{0},
|
||||
Compressed: []bool{false},
|
||||
TotalCostMicros: []int64{0},
|
||||
RuntimeMs: []int64{0},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, msgs, 1)
|
||||
return msgs[0]
|
||||
}
|
||||
|
||||
linkPR := func(chatID uuid.UUID, url, state, prTitle string, prNumber int32, gitRemoteOrigin string) {
|
||||
t.Helper()
|
||||
now := time.Now()
|
||||
_, err := store.UpsertChatDiffStatusReference(ctx, database.UpsertChatDiffStatusReferenceParams{
|
||||
ChatID: chatID,
|
||||
Url: sql.NullString{String: url, Valid: true},
|
||||
GitBranch: "main",
|
||||
GitRemoteOrigin: gitRemoteOrigin,
|
||||
StaleAt: now.Add(time.Hour),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = store.UpsertChatDiffStatus(ctx, database.UpsertChatDiffStatusParams{
|
||||
ChatID: chatID,
|
||||
Url: sql.NullString{String: url, Valid: true},
|
||||
PullRequestState: sql.NullString{String: state, Valid: true},
|
||||
PullRequestTitle: prTitle,
|
||||
PrNumber: sql.NullInt32{Int32: prNumber, Valid: prNumber > 0},
|
||||
Additions: 1,
|
||||
Deletions: 1,
|
||||
ChangedFiles: 1,
|
||||
RefreshedAt: now,
|
||||
StaleAt: now.Add(time.Hour),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
titleChat := createRoot("deploy pipeline alpha")
|
||||
|
||||
archivedChat := createRoot("deploy pipeline beta")
|
||||
|
||||
prTitleChat := createRoot("widget work")
|
||||
linkPR(prTitleChat.ID, "https://github.com/acme/widget/pull/42", "open", "Fix authentication bug", 42, "https://github.com/acme/widget.git")
|
||||
|
||||
mergedChat := createRoot("other work")
|
||||
linkPR(mergedChat.ID, "https://github.com/acme/other-repo/pull/7", "merged", "Fix authentication flow", 7, "https://github.com/acme/other-repo.git")
|
||||
|
||||
msgChat := createRoot("plain one")
|
||||
insertMsg(msgChat.ID, database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth, "kubernetes cluster restart")
|
||||
|
||||
assistantMsgChat := createRoot("plain assistant")
|
||||
insertMsg(assistantMsgChat.ID, database.ChatMessageRoleAssistant, database.ChatMessageVisibilityBoth, "grafana dashboard tuning")
|
||||
|
||||
userVisMsgChat := createRoot("plain uservis")
|
||||
insertMsg(userVisMsgChat.ID, database.ChatMessageRoleUser, database.ChatMessageVisibilityUser, "vault token rotation")
|
||||
|
||||
assistantUserVisMsgChat := createRoot("plain assistant uservis")
|
||||
insertMsg(assistantUserVisMsgChat.ID, database.ChatMessageRoleAssistant, database.ChatMessageVisibilityUser, "redis eviction policy")
|
||||
|
||||
deletedMsgChat := createRoot("plain two")
|
||||
deletedMsg := insertMsg(deletedMsgChat.ID, database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth, "terraform apply failure")
|
||||
|
||||
childParent := createRoot("plain parent")
|
||||
childChat := createChild(childParent, "plain child")
|
||||
insertMsg(childChat.ID, database.ChatMessageRoleAssistant, database.ChatMessageVisibilityUser, "orchestrator saga")
|
||||
|
||||
ineligibleChat := createRoot("plain three")
|
||||
toolMsg := insertMsg(ineligibleChat.ID, database.ChatMessageRoleTool, database.ChatMessageVisibilityBoth, "forbidden secret token")
|
||||
modelOnlyMsg := insertMsg(ineligibleChat.ID, database.ChatMessageRoleUser, database.ChatMessageVisibilityModel, "forbidden secret token")
|
||||
|
||||
// Ineligible rows keep search_tsv NULL after backfill.
|
||||
_, err = store.BackfillChatMessagesSearchTsv(ctx, 1000)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Soft-deleted rows stay excluded even though search_tsv remains
|
||||
// populated.
|
||||
err = store.SoftDeleteChatMessageByID(ctx, deletedMsg.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Inserted after backfill: search_tsv IS NULL, must match nothing.
|
||||
pendingChat := createRoot("plain four")
|
||||
insertMsg(pendingChat.ID, database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth, "elasticsearch indexing")
|
||||
|
||||
// Prove role/visibility predicates exclude rows even when search_tsv
|
||||
// is set.
|
||||
_, err = sqlDB.ExecContext(ctx,
|
||||
`UPDATE chat_messages SET search_tsv = to_tsvector('simple', 'forbidden secret token') WHERE id = ANY($1)`,
|
||||
pq.Array([]int64{toolMsg.ID, modelOnlyMsg.ID}))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = store.ArchiveChatByID(ctx, archivedChat.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
allRootIDs := []uuid.UUID{
|
||||
titleChat.ID, archivedChat.ID, prTitleChat.ID, mergedChat.ID,
|
||||
msgChat.ID, assistantMsgChat.ID, userVisMsgChat.ID,
|
||||
assistantUserVisMsgChat.ID, deletedMsgChat.ID, childParent.ID,
|
||||
ineligibleChat.ID, pendingChat.ID,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
params database.GetChatsParams
|
||||
want []uuid.UUID
|
||||
}{
|
||||
{"Title/Match", database.GetChatsParams{Search: "pipeline alpha"}, []uuid.UUID{titleChat.ID}},
|
||||
{"Title/CaseInsensitiveMultiWord", database.GetChatsParams{Search: "ALPHA DEPLOY"}, []uuid.UUID{titleChat.ID}},
|
||||
{"Title/AndSemantics", database.GetChatsParams{Search: "deploy nonexistent"}, nil},
|
||||
{"PRTitle/Match", database.GetChatsParams{Search: "authentication"}, []uuid.UUID{prTitleChat.ID, mergedChat.ID}},
|
||||
{"Message/Match", database.GetChatsParams{Search: "kubernetes restart"}, []uuid.UUID{msgChat.ID}},
|
||||
{"Message/AssistantRoleMatch", database.GetChatsParams{Search: "grafana tuning"}, []uuid.UUID{assistantMsgChat.ID}},
|
||||
{"Message/UserVisibilityMatch", database.GetChatsParams{Search: "vault rotation"}, []uuid.UUID{userVisMsgChat.ID}},
|
||||
{"Message/AssistantUserVisibilityMatch", database.GetChatsParams{Search: "redis eviction"}, []uuid.UUID{assistantUserVisMsgChat.ID}},
|
||||
{"PRNumber/Match", database.GetChatsParams{Search: "42"}, []uuid.UUID{prTitleChat.ID}},
|
||||
{"PRNumber/NonNumericNoMatch", database.GetChatsParams{Search: "42abc"}, nil},
|
||||
{"PRNumber/OversizedDigitsNoError", database.GetChatsParams{Search: "1111111111111111111111111"}, nil},
|
||||
{"NoMatch", database.GetChatsParams{Search: "zzzqqq"}, nil},
|
||||
{"Message/PendingBackfillNoMatch", database.GetChatsParams{Search: "elasticsearch"}, nil},
|
||||
{"Message/DeletedNoMatch", database.GetChatsParams{Search: "terraform"}, nil},
|
||||
// Parent also excluded: EXISTS is per-chat, not per-tree.
|
||||
{"Message/ChildNotSurfaced", database.GetChatsParams{Search: "orchestrator saga"}, nil},
|
||||
{"Message/IneligibleMessagesNoMatch", database.GetChatsParams{Search: "forbidden secret"}, nil},
|
||||
{"Composed/ArchivedDefaultIncludesAll", database.GetChatsParams{Search: "deploy pipeline"}, []uuid.UUID{titleChat.ID, archivedChat.ID}},
|
||||
{"Composed/ArchivedFalseExcludes", database.GetChatsParams{Search: "deploy pipeline", Archived: sql.NullBool{Bool: false, Valid: true}}, []uuid.UUID{titleChat.ID}},
|
||||
{"Composed/ArchivedTrueOnly", database.GetChatsParams{Search: "deploy pipeline", Archived: sql.NullBool{Bool: true, Valid: true}}, []uuid.UUID{archivedChat.ID}},
|
||||
{"Composed/SearchAndRepo", database.GetChatsParams{Search: "authentication", RepoQuery: "acme/widget"}, []uuid.UUID{prTitleChat.ID}},
|
||||
{"Composed/SearchAndPRStatus", database.GetChatsParams{Search: "authentication", PullRequestStatuses: []string{"merged"}}, []uuid.UUID{mergedChat.ID}},
|
||||
{"EmptySearch/ReturnsAll", database.GetChatsParams{Search: ""}, allRootIDs},
|
||||
{"WhitespaceSearch/ReturnsNothing", database.GetChatsParams{Search: " "}, nil},
|
||||
{"TabOnlySearch/ReturnsNothing", database.GetChatsParams{Search: "\t\t"}, nil},
|
||||
{"EmptySearch/TitleQueryStillWorks", database.GetChatsParams{Search: "", TitleQuery: "pipeline alpha"}, []uuid.UUID{titleChat.ID}},
|
||||
{"EmptySearch/PRTitleQueryStillWorks", database.GetChatsParams{Search: "", PrTitleQuery: "authentication bug"}, []uuid.UUID{prTitleChat.ID}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
params := tt.params
|
||||
params.OwnedOnly = true
|
||||
params.ViewerID = user.ID
|
||||
|
||||
rows, err := store.GetChats(ctx, params)
|
||||
require.NoError(t, err)
|
||||
|
||||
got := make([]uuid.UUID, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
got = append(got, row.Chat.ID)
|
||||
}
|
||||
|
||||
if tt.want == nil {
|
||||
require.Empty(t, got)
|
||||
} else {
|
||||
require.ElementsMatch(t, tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatHasUnread(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user