Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 9 additions & 23 deletions backend/internal/application/conversation/context_artifact.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,6 @@ type snapshotContextArtifactInput struct {
type historicalContextArtifactInput struct {
CurrentMessageID uint
HasCurrentSnapshot bool
CoveredUntilID uint
AllowedMessageIDs map[uint]struct{}
Query string
Candidates []domainconversation.ContextArtifact
CurrentRAGChunks []domainconversation.RAGChunk
Expand Down Expand Up @@ -167,17 +165,14 @@ func (s *Service) applyContextArtifactRetention(items []domainconversation.Conte
// recallHistoricalContextArtifacts 读取近期上下文证据并按当前问题筛选。
func (s *Service) recallHistoricalContextArtifacts(
ctx context.Context,
conversationID uint,
currentMessageID uint,
scope repository.HistoricalMessageScope,
hasCurrentSnapshot bool,
coveredUntilID uint,
allowedMessageIDs map[uint]struct{},
query string,
currentRAGChunks []domainconversation.RAGChunk,
currentFallbacks []AttachmentInput,
currentRecall []domainconversation.MessageChunk,
) []domainconversation.ContextArtifact {
if strings.TrimSpace(query) == "" {
if !scope.Valid() || strings.TrimSpace(query) == "" {
return nil
}
kinds := []domainconversation.ContextArtifactKind{
Expand All @@ -190,22 +185,24 @@ func (s *Service) recallHistoricalContextArtifacts(
if !hasCurrentSnapshot {
kinds = append(kinds, domainconversation.ContextArtifactSummary)
}
candidates, err := s.repo.ListRecentContextArtifacts(ctx, conversationID, kinds, historicalArtifactScanLimit)
candidates, err := s.repo.ListRecentContextArtifacts(ctx, repository.ContextArtifactListFilter{
Scope: scope,
Kinds: kinds,
Limit: historicalArtifactScanLimit,
})
if err != nil {
if s.logger != nil {
s.logger.Warn("historical_context_artifact_recall_failed",
zap.String("trace_id", traceid.FromContext(ctx)),
zap.Uint("conversation_id", conversationID),
zap.Uint("conversation_id", scope.ConversationID),
zap.Error(err),
)
}
return nil
}
return selectHistoricalContextArtifacts(historicalContextArtifactInput{
CurrentMessageID: currentMessageID,
CurrentMessageID: scope.LeafMessageID,
HasCurrentSnapshot: hasCurrentSnapshot,
CoveredUntilID: coveredUntilID,
AllowedMessageIDs: allowedMessageIDs,
Query: query,
Candidates: candidates,
CurrentRAGChunks: currentRAGChunks,
Expand Down Expand Up @@ -441,17 +438,6 @@ func selectHistoricalContextArtifacts(input historicalContextArtifactInput) []do
if input.HasCurrentSnapshot && item.Kind == domainconversation.ContextArtifactSummary {
continue
}
if input.CoveredUntilID > 0 && item.MessageID > 0 && item.MessageID <= input.CoveredUntilID {
continue
}
if len(input.AllowedMessageIDs) > 0 {
if item.MessageID == 0 {
continue
}
if _, ok := input.AllowedMessageIDs[item.MessageID]; !ok {
continue
}
}
content := strings.TrimSpace(item.Content)
if content == "" || item.MessageID == input.CurrentMessageID {
continue
Expand Down
98 changes: 45 additions & 53 deletions backend/internal/application/conversation/context_artifact_test.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,33 @@
package conversation

import (
"context"
"encoding/json"
"strings"
"testing"

model "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/conversation"
domainmemory "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/memory"
"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/config"
"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository"
)

type toolArtifactCaptureRepository struct {
repository.ConversationRepository
toolCalls []model.ToolCall
artifacts []model.ContextArtifact
}

func (r *toolArtifactCaptureRepository) CreateConversationToolCalls(_ context.Context, items []model.ToolCall) error {
r.toolCalls = append(r.toolCalls, items...)
return nil
}

func (r *toolArtifactCaptureRepository) CreateContextArtifacts(_ context.Context, items []model.ContextArtifact) error {
r.artifacts = append(r.artifacts, items...)
return nil
}

func TestBuildPromptContextArtifactsRecordsRAGFallbackAndRecall(t *testing.T) {
items := buildPromptContextArtifacts(promptContextArtifactInput{
ConversationID: 7,
Expand Down Expand Up @@ -79,6 +97,33 @@ func TestBuildPromptContextArtifactsRecordsRAGFallbackAndRecall(t *testing.T) {
}
}

func TestPersistMessageToolCallsAnchorsEvidenceToAssistantMessage(t *testing.T) {
repo := &toolArtifactCaptureRepository{}
service := &Service{repo: repo}

err := service.persistMessageToolCalls(context.Background(), persistMessageToolCallsInput{
SendInput: SendMessageInput{ConversationID: 7, UserID: 11},
AssistantMessageID: 22,
RunID: "run_tool",
Rows: []model.ToolCall{{
ToolCallID: "call_1",
ToolType: "mcp",
ToolName: "lookup",
Status: "completed",
OutputJSON: `{"result":"ok"}`,
}},
})
if err != nil {
t.Fatalf("persistMessageToolCalls() error = %v", err)
}
if len(repo.toolCalls) != 1 || repo.toolCalls[0].MessageID != 22 {
t.Fatalf("expected tool call on assistant message, got %#v", repo.toolCalls)
}
if len(repo.artifacts) != 1 || repo.artifacts[0].MessageID != 22 {
t.Fatalf("expected tool evidence on assistant message, got %#v", repo.artifacts)
}
}

func hasContextArtifact(items []model.ContextArtifact, kind model.ContextArtifactKind, sourceID string) bool {
for _, item := range items {
if item.Kind == kind && item.SourceID == sourceID {
Expand Down Expand Up @@ -301,59 +346,6 @@ func TestSelectHistoricalContextArtifactsSkipsSummaryWhenCurrentSnapshotExists(t
}
}

func TestSelectHistoricalContextArtifactsRespectsSnapshotScope(t *testing.T) {
items := selectHistoricalContextArtifacts(historicalContextArtifactInput{
CurrentMessageID: 9,
HasCurrentSnapshot: true,
CoveredUntilID: 4,
AllowedMessageIDs: map[uint]struct{}{
6: {},
},
Query: "继续部署测试",
Candidates: []model.ContextArtifact{
{
MessageID: 3,
Kind: model.ContextArtifactToolResult,
SourceTitle: "covered",
Content: "已被摘要覆盖的部署测试结果",
TokenEstimate: 10,
Score: 1,
},
{
MessageID: 6,
Kind: model.ContextArtifactToolResult,
SourceTitle: "retained",
Content: "保留窗口内的部署测试结果",
TokenEstimate: 10,
Score: 1,
},
{
MessageID: 8,
Kind: model.ContextArtifactToolResult,
SourceTitle: "sibling",
Content: "其他分支的部署测试结果",
TokenEstimate: 10,
Score: 1,
},
{
MessageID: 0,
Kind: model.ContextArtifactToolResult,
SourceTitle: "unanchored",
Content: "没有消息锚点的部署测试结果",
TokenEstimate: 10,
Score: 1,
},
},
})

if len(items) != 1 {
t.Fatalf("expected one retained-scope artifact, got %#v", items)
}
if items[0].SourceTitle != "retained" {
t.Fatalf("expected retained artifact, got %#v", items[0])
}
}

func TestSelectHistoricalContextArtifactsRequiresRelevanceWithoutFollowUp(t *testing.T) {
items := selectHistoricalContextArtifacts(historicalContextArtifactInput{
Query: "部署 测试",
Expand Down
54 changes: 15 additions & 39 deletions backend/internal/application/conversation/prompt_scope.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
appcompact "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/compact"
model "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/conversation"
"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/llm"
"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository"
)

type promptScope struct {
Expand All @@ -12,7 +13,6 @@ type promptScope struct {
RetainedMessages []model.Message
Snapshot *model.ContextSnapshot
CoveredUntilID uint
retainedMessageIDs map[uint]struct{}
}

func buildPromptScope(messages []model.Message, snapshot *model.ContextSnapshot, policy contextCompactionPolicy) promptScope {
Expand All @@ -34,7 +34,6 @@ func buildPromptScope(messages []model.Message, snapshot *model.ContextSnapshot,
scope.CoveredMessages = append([]model.Message(nil), messages[:boundaryIndex+1]...)
scope.RetainedMessages = append([]model.Message(nil), messages[boundaryIndex+1:]...)
scope.CoveredUntilID = snapshot.CoveredUntilMessageID
scope.retainedMessageIDs = messageIDSet(scope.RetainedMessages)
return scope
}

Expand All @@ -45,48 +44,25 @@ func (s promptScope) activeMessages() []model.Message {
return s.FullBranchMessages
}

func (s promptScope) filterRecallChunks(chunks []model.MessageChunk) []model.MessageChunk {
if len(chunks) == 0 || s.CoveredUntilID == 0 {
return chunks
func (s promptScope) historicalMessageScope(conversationID uint, userID uint, currentMessageID uint) repository.HistoricalMessageScope {
if conversationID == 0 || userID == 0 || currentMessageID == 0 {
return repository.HistoricalMessageScope{}
}
result := make([]model.MessageChunk, 0, len(chunks))
for _, chunk := range chunks {
if chunk.MessageID > 0 && chunk.MessageID <= s.CoveredUntilID {
continue
}
if len(s.retainedMessageIDs) > 0 && chunk.MessageID > 0 {
if _, ok := s.retainedMessageIDs[chunk.MessageID]; !ok {
continue
}
}
result = append(result, chunk)
}
return result
}

func (s promptScope) retainedMessageIDSet() map[uint]struct{} {
if len(s.retainedMessageIDs) == 0 {
return nil
messages := s.FullBranchMessages
if s.Snapshot != nil {
messages = s.RetainedMessages
}
result := make(map[uint]struct{}, len(s.retainedMessageIDs))
for id := range s.retainedMessageIDs {
result[id] = struct{}{}
}
return result
}

func messageIDSet(messages []model.Message) map[uint]struct{} {
if len(messages) == 0 {
return nil
}
result := make(map[uint]struct{}, len(messages))
for _, message := range messages {
if message.ID == 0 {
continue
if message.ID > 0 && message.ID != currentMessageID {
return repository.HistoricalMessageScope{
ConversationID: conversationID,
UserID: userID,
LeafMessageID: currentMessageID,
ExcludeThroughMessageID: s.CoveredUntilID,
}
}
result[message.ID] = struct{}{}
}
return result
return repository.HistoricalMessageScope{}
}

type historyMessageOptions struct {
Expand Down
32 changes: 22 additions & 10 deletions backend/internal/application/conversation/prompt_scope_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,22 +55,34 @@ func TestBuildPromptScopeReplacesCoveredPrefix(t *testing.T) {
}
}

func TestPromptScopeFilterRecallChunksDropsCoveredMessages(t *testing.T) {
func TestPromptScopeHistoricalMessageScopeUsesSnapshotBoundary(t *testing.T) {
messages := promptScopeMessages()
scope := buildPromptScope(messages, promptScopeSnapshot(messages[:2]), contextCompactionPolicy{AdminEnabled: true, UserEnabled: true})

chunks := []model.MessageChunk{
{MessageID: 1, Content: "covered"},
{MessageID: 3, Content: "retained"},
{MessageID: 99, Content: "sibling branch"},
historicalScope := scope.historicalMessageScope(7, 11, 4)
if !historicalScope.Valid() {
t.Fatal("expected valid historical scope")
}
filtered := scope.filterRecallChunks(chunks)
if historicalScope.ConversationID != 7 || historicalScope.UserID != 11 || historicalScope.LeafMessageID != 4 || historicalScope.ExcludeThroughMessageID != 2 {
t.Fatalf("unexpected historical scope: %#v", historicalScope)
}
}

func TestPromptScopeHistoricalMessageScopeUsesFullBranchWithoutSnapshot(t *testing.T) {
messages := promptScopeMessages()
scope := buildPromptScope(messages, nil, contextCompactionPolicy{})

if len(filtered) != 1 {
t.Fatalf("expected one retained recall chunk, got %d", len(filtered))
historicalScope := scope.historicalMessageScope(7, 11, 4)
if !historicalScope.Valid() || historicalScope.ExcludeThroughMessageID != 0 {
t.Fatalf("unexpected historical scope: %#v", historicalScope)
}
if filtered[0].MessageID != 3 {
t.Fatalf("expected retained chunk from message 3, got %d", filtered[0].MessageID)
}

func TestPromptScopeHistoricalMessageScopeFailsClosedOnFirstTurn(t *testing.T) {
scope := buildPromptScope([]model.Message{{ID: 9, Role: "user"}}, nil, contextCompactionPolicy{})

if historicalScope := scope.historicalMessageScope(7, 11, 9); historicalScope.Valid() {
t.Fatalf("expected no historical scope, got %#v", historicalScope)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
domainbilling "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/billing"
model "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/conversation"
"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/llm"
"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository"
"github.com/google/uuid"
"go.uber.org/zap"
)
Expand Down Expand Up @@ -86,15 +87,20 @@ func reasoningPayload(delta *llm.ReasoningDelta) map[string]interface{} {
}

// recallSemanticContext 语义召回历史消息;无结果时返回空列表。
func (s *Service) recallSemanticContext(ctx context.Context, conversationID uint, userID uint, query string) []model.MessageChunk {
if s.embeddingSvc == nil || strings.TrimSpace(query) == "" {
func (s *Service) recallSemanticContext(ctx context.Context, scope repository.HistoricalMessageScope, query string) []model.MessageChunk {
if s.embeddingSvc == nil || !scope.Valid() || strings.TrimSpace(query) == "" {
return nil
}
embeddings, err := s.embeddingSvc.EmbedTexts(ctx, []string{query})
if err != nil || len(embeddings) == 0 {
return nil
}
chunks, err := s.repo.SearchMessageChunks(ctx, conversationID, userID, embeddings[0], 5, 0.75)
chunks, err := s.repo.SearchMessageChunks(ctx, repository.MessageChunkSearchInput{
Scope: scope,
QueryEmbedding: embeddings[0],
TopK: 5,
MinSimilarity: 0.75,
})
if err != nil || len(chunks) == 0 {
return nil
}
Expand Down
Loading
Loading