diff --git a/internal/repo/ai_conversation/ai_conversation_repo.go b/internal/repo/ai_conversation/ai_conversation_repo.go index 9947eb6cc..35ea1ae56 100644 --- a/internal/repo/ai_conversation/ai_conversation_repo.go +++ b/internal/repo/ai_conversation/ai_conversation_repo.go @@ -115,7 +115,7 @@ func (r *aiConversationRepo) GetRecordsByConversationID(ctx context.Context, con records := make([]*entity.AIConversationRecord, 0) err := r.data.DB.Context(ctx). Where(builder.Eq{"conversation_id": conversationID}). - OrderBy("created_at ASC"). + OrderBy("created_at ASC, id ASC"). Find(&records) if err != nil { log.Errorf("get ai conversation records failed: %v", err) diff --git a/internal/repo/repo_test/ai_conversation_repo_test.go b/internal/repo/repo_test/ai_conversation_repo_test.go new file mode 100644 index 000000000..9becef739 --- /dev/null +++ b/internal/repo/repo_test/ai_conversation_repo_test.go @@ -0,0 +1,72 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package repo_test + +import ( + "context" + "testing" + "time" + + "github.com/apache/answer/internal/entity" + "github.com/apache/answer/internal/repo/ai_conversation" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_aiConversationRepo_GetRecordsByConversationID_KeepsInsertionOrder(t *testing.T) { + repo := ai_conversation.NewAIConversationRepo(testDataSource) + conversationID := "conversation-record-order" + + err := repo.CreateConversation(context.TODO(), &entity.AIConversation{ + ConversationID: conversationID, + Topic: "question one", + UserID: "1", + }) + require.NoError(t, err) + defer func() { + require.NoError(t, repo.DeleteConversation(context.TODO(), conversationID)) + }() + + // A turn's two records are written within the same second, so every + // record here shares one timestamp and only the primary key separates them. + sameSecond := time.Now().Truncate(time.Second) + roles := []string{"user", "assistant", "user", "assistant"} + for i, role := range roles { + _, err = testDataSource.DB.Context(context.TODO()).NoAutoTime().Insert(&entity.AIConversationRecord{ + CreatedAt: sameSecond, + UpdatedAt: sameSecond, + ConversationID: conversationID, + ChatCompletionID: "chatcmpl-" + string(rune('a'+i/2)), + Role: role, + Content: role, + }) + require.NoError(t, err) + } + + records, err := repo.GetRecordsByConversationID(context.TODO(), conversationID) + require.NoError(t, err) + require.Len(t, records, len(roles)) + for i, record := range records { + assert.Equal(t, roles[i], record.Role, "record %d", i) + if i > 0 { + assert.Greater(t, record.ID, records[i-1].ID, "record %d", i) + } + } +} diff --git a/internal/service/ai_conversation/ai_conversation_service.go b/internal/service/ai_conversation/ai_conversation_service.go index b7ddc6b10..e7aee95e2 100644 --- a/internal/service/ai_conversation/ai_conversation_service.go +++ b/internal/service/ai_conversation/ai_conversation_service.go @@ -189,8 +189,9 @@ func (s *aiConversationService) GetConversationDetail(ctx context.Context, req * } recordList := make([]*schema.AIConversationRecord, 0, len(records)) - for i, record := range records { - if i == 0 { + openingID := firstUserRecordID(records) + for _, record := range records { + if record.ID == openingID { record.Content = conversation.Topic } recordList = append(recordList, &schema.AIConversationRecord{ @@ -319,8 +320,9 @@ func (s *aiConversationService) GetConversationDetailForAdmin(ctx context.Contex } recordList := make([]schema.AIConversationRecord, 0, len(records)) - for i, record := range records { - if i == 0 { + openingID := firstUserRecordID(records) + for _, record := range records { + if record.ID == openingID { record.Content = conversation.Topic } recordList = append(recordList, schema.AIConversationRecord{ @@ -343,6 +345,16 @@ func (s *aiConversationService) GetConversationDetailForAdmin(ctx context.Contex }, nil } +// firstUserRecordID returns the ID of the record holding the opening question, or zero. +func firstUserRecordID(records []*entity.AIConversationRecord) int { + for _, record := range records { + if record.Role == "user" { + return record.ID + } + } + return 0 +} + // getUserInfo func (s *aiConversationService) getUserInfo(ctx context.Context, userID string) (schema.AIConversationUserInfo, error) { userInfo := schema.AIConversationUserInfo{} diff --git a/internal/service/ai_conversation/ai_conversation_service_test.go b/internal/service/ai_conversation/ai_conversation_service_test.go new file mode 100644 index 000000000..cafaaf339 --- /dev/null +++ b/internal/service/ai_conversation/ai_conversation_service_test.go @@ -0,0 +1,85 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package ai_conversation + +import ( + "context" + "testing" + + "github.com/apache/answer/internal/entity" + "github.com/apache/answer/internal/repo/ai_conversation" + "github.com/apache/answer/internal/schema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeRepo serves one conversation and its records in whatever order the +// test hands them over, standing in for the database. +type fakeRepo struct { + ai_conversation.AIConversationRepo + conversation *entity.AIConversation + records []*entity.AIConversationRecord +} + +func (f *fakeRepo) GetConversation(_ context.Context, conversationID string) (*entity.AIConversation, bool, error) { + if f.conversation == nil || f.conversation.ConversationID != conversationID { + return nil, false, nil + } + return f.conversation, true, nil +} + +func (f *fakeRepo) GetRecordsByConversationID(_ context.Context, _ string) ([]*entity.AIConversationRecord, error) { + return f.records, nil +} + +func TestGetConversationDetail_TopicReplacesOpeningQuestionNotFirstRecord(t *testing.T) { + const prompt = "You are an assistant. User question: what is the topic?" + repo := &fakeRepo{ + conversation: &entity.AIConversation{ConversationID: "c1", Topic: "what is the topic?", UserID: "u1"}, + records: []*entity.AIConversationRecord{ + {ID: 2, Role: "assistant", Content: "the answer"}, + {ID: 1, Role: "user", Content: prompt}, + {ID: 3, Role: "user", Content: "a follow-up"}, + {ID: 4, Role: "assistant", Content: "another answer"}, + }, + } + service := NewAIConversationService(repo, nil) + + resp, exist, err := service.GetConversationDetail(context.TODO(), &schema.AIConversationDetailReq{ + ConversationID: "c1", + UserID: "u1", + }) + require.NoError(t, err) + require.True(t, exist) + require.Len(t, resp.Records, 4) + + assert.Equal(t, "the answer", resp.Records[0].Content, "an answer that sorts first must keep its content") + assert.Equal(t, "what is the topic?", resp.Records[1].Content, "the opening question shows the topic, not the prompt") + assert.Equal(t, "a follow-up", resp.Records[2].Content) + assert.Equal(t, "another answer", resp.Records[3].Content) +} + +func TestFirstUserRecordID(t *testing.T) { + assert.Equal(t, 0, firstUserRecordID(nil)) + assert.Equal(t, 0, firstUserRecordID([]*entity.AIConversationRecord{{ID: 1, Role: "assistant"}})) + assert.Equal(t, 5, firstUserRecordID([]*entity.AIConversationRecord{ + {ID: 4, Role: "assistant"}, {ID: 5, Role: "user"}, {ID: 6, Role: "user"}, + })) +} diff --git a/ui/build/favicon.ico b/ui/build/favicon.ico deleted file mode 100644 index 6ab1fbdad..000000000 Binary files a/ui/build/favicon.ico and /dev/null differ