feat(ctxmgr): 增加历史多轮会话列表查询、消息拉取与会话重构引擎

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WeiCHEN
2026-09-13 02:50:19 +00:00
committed by weichen
co-authored by Cursor
parent edf2f759ae
commit db3dc282ba
4 changed files with 348 additions and 0 deletions
+200
View File
@@ -0,0 +1,200 @@
package ctxmgr
import (
"context"
"errors"
"fmt"
"sort"
"strings"
)
// ListChatSessions 分页查询历史会话列表
func (m *ContextManager) ListChatSessions(ctx context.Context, filter SessionFilter) ([]ChatSessionSummary, error) {
if filter.Limit <= 0 {
filter.Limit = 20
}
if filter.Offset < 0 {
filter.Offset = 0
}
if m.pool != nil {
query := `
SELECT s.session_id, s.title, COALESCE(s.user_id, 0), COALESCE(s.project_id, 0),
COALESCE(p.name, ''),
COUNT(m.id)::int as msg_count,
COALESCE((SELECT content FROM chat_messages WHERE session_id = s.session_id ORDER BY created_at DESC LIMIT 1), '') as last_msg,
s.updated_at
FROM chat_sessions s
LEFT JOIN projects p ON s.project_id = p.id
LEFT JOIN chat_messages m ON s.session_id = m.session_id
WHERE ($1 = 0 OR s.user_id = $1) AND ($2 = 0 OR s.project_id = $2)
GROUP BY s.session_id, s.title, s.user_id, s.project_id, p.name, s.updated_at
ORDER BY s.updated_at DESC
LIMIT $3 OFFSET $4
`
rows, err := m.pool.Query(ctx, query, filter.UserID, filter.ProjectID, filter.Limit, filter.Offset)
if err != nil {
return nil, fmt.Errorf("list chat sessions failed: %w", err)
}
defer rows.Close()
var sessions []ChatSessionSummary
for rows.Next() {
var item ChatSessionSummary
if err := rows.Scan(&item.SessionID, &item.Title, &item.UserID, &item.ProjectID, &item.ProjectName, &item.MessageCount, &item.LastMessage, &item.UpdatedAt); err == nil {
sessions = append(sessions, item)
}
}
return sessions, nil
}
m.mu.RLock()
defer m.mu.RUnlock()
var all []ChatSessionSummary
for _, sess := range m.sessions {
if filter.UserID > 0 && sess.UserID != filter.UserID {
continue
}
if filter.ProjectID > 0 && sess.ProjectID != filter.ProjectID {
continue
}
all = append(all, *sess)
}
sort.Slice(all, func(i, j int) bool {
return all[i].UpdatedAt.After(all[j].UpdatedAt)
})
start := filter.Offset
if start >= len(all) {
return []ChatSessionSummary{}, nil
}
end := start + filter.Limit
if end > len(all) {
end = len(all)
}
return all[start:end], nil
}
// GetChatHistory 分页拉取指定会话的历史多轮消息记录
func (m *ContextManager) GetChatHistory(ctx context.Context, sessionID string, limit, offset int) ([]ChatMessageRecord, error) {
if sessionID == "" {
return nil, errors.New("sessionID required")
}
if limit <= 0 {
limit = 50
}
if offset < 0 {
offset = 0
}
if m.pool != nil {
rows, err := m.pool.Query(ctx, `
SELECT id, session_id, user_id, role, content, intent, branch, created_at
FROM chat_messages
WHERE session_id = $1
ORDER BY created_at ASC
LIMIT $2 OFFSET $3
`, sessionID, limit, offset)
if err != nil {
return nil, fmt.Errorf("query chat history failed: %w", err)
}
defer rows.Close()
var msgs []ChatMessageRecord
for rows.Next() {
var rec ChatMessageRecord
if err := rows.Scan(&rec.ID, &rec.SessionID, &rec.UserID, &rec.Role, &rec.Content, &rec.Intent, &rec.Branch, &rec.CreatedAt); err == nil {
msgs = append(msgs, rec)
}
}
return msgs, nil
}
m.mu.RLock()
defer m.mu.RUnlock()
rawMsgs := m.messages[sessionID]
if offset >= len(rawMsgs) {
return []ChatMessageRecord{}, nil
}
end := offset + limit
if end > len(rawMsgs) {
end = len(rawMsgs)
}
res := make([]ChatMessageRecord, end-offset)
copy(res, rawMsgs[offset:end])
return res, nil
}
// RestoreSessionContext 从数据库/内存历史中重构会话上下文
func (m *ContextManager) RestoreSessionContext(ctx context.Context, sessionID string) (*RestoredSessionContext, error) {
if sessionID == "" {
return nil, errors.New("sessionID required")
}
// 1. 获取全量历史消息
msgs, err := m.GetChatHistory(ctx, sessionID, 200, 0)
if err != nil {
return nil, err
}
restored := &RestoredSessionContext{
SessionID: sessionID,
Title: "对话协同会话",
Messages: msgs,
Facts: make(map[string]string),
}
// 2. 从消息或会话表中提取归属用户与项目
if m.pool != nil {
var userID, projectID int64
var title string
err := m.pool.QueryRow(ctx, "SELECT user_id, project_id, title FROM chat_sessions WHERE session_id = $1", sessionID).Scan(&userID, &projectID, &title)
if err == nil {
restored.UserID = userID
restored.ProjectID = projectID
if title != "" {
restored.Title = title
}
}
} else {
m.mu.RLock()
if s, ok := m.sessions[sessionID]; ok {
restored.UserID = s.UserID
restored.ProjectID = s.ProjectID
restored.Title = s.Title
}
m.mu.RUnlock()
}
// 3. 若有项目ID,同步抽取黑板已有事实
if restored.ProjectID > 0 {
pCtx, err := m.GetProjectContext(ctx, restored.ProjectID)
if err == nil && pCtx != nil {
for k, v := range pCtx.BlackboardFacts {
restored.Facts[k] = v
}
}
}
// 4. 遍历历史消息尝试提炼显式槽位或事实
for _, msg := range msgs {
if msg.UserID > 0 && restored.UserID == 0 {
restored.UserID = msg.UserID
}
// 简单提取器械与市场关键词(若未固化)
if strings.Contains(msg.Content, "超声") && restored.Facts["device_type"] == "" {
restored.Facts["device_type"] = "超声手术设备"
}
if strings.Contains(msg.Content, "内窥镜") && restored.Facts["device_type"] == "" {
restored.Facts["device_type"] = "内窥镜系统"
}
}
return restored, nil
}
+63
View File
@@ -0,0 +1,63 @@
package ctxmgr_test
import (
"context"
"testing"
"time"
"chat-agent-system/internal/ctxmgr"
)
func TestContextManager_ChatHistoryAndSessionRestoration(t *testing.T) {
mgr := ctxmgr.NewContextManager(nil, nil, ctxmgr.DefaultTimeDecayConfig())
ctx := context.Background()
sessID := "sess_history_test_001"
userID := int64(101)
// 1. 模拟记录多轮会话
_ = mgr.RecordUserMessage(ctx, sessID, userID, "user", "我想了解超声手术刀注册审批要求", "QUERY_PROGRESS", "PREEMPT", nil)
time.Sleep(10 * time.Millisecond)
_ = mgr.RecordUserMessage(ctx, sessID, 0, "assistant", "超声软组织切割设备通常按二类或三类申报,需执行GB 9706.1-2020", "ADVISOR_BRIEF", "EXPECTATION_HIT", nil)
time.Sleep(10 * time.Millisecond)
_ = mgr.RecordUserMessage(ctx, sessID, userID, "user", "帮我查查大致工期和报价", "SUPPLEMENT_INFO", "EXPECTATION_HIT", nil)
// 2. 分页查询历史消息
msgs, err := mgr.GetChatHistory(ctx, sessID, 10, 0)
if err != nil {
t.Fatalf("GetChatHistory failed: %v", err)
}
if len(msgs) != 3 {
t.Fatalf("expected 3 messages, got %d", len(msgs))
}
if msgs[0].Content != "我想了解超声手术刀注册审批要求" {
t.Errorf("unexpected first message: %s", msgs[0].Content)
}
// 3. 查询会话列表
sessions, err := mgr.ListChatSessions(ctx, ctxmgr.SessionFilter{UserID: userID, Limit: 10})
if err != nil {
t.Fatalf("ListChatSessions failed: %v", err)
}
if len(sessions) != 1 {
t.Fatalf("expected 1 session, got %d", len(sessions))
}
if sessions[0].SessionID != sessID || sessions[0].MessageCount != 3 {
t.Errorf("session summary mismatch: %+v", sessions[0])
}
// 4. 从历史中恢复上下文与消息队列
restored, err := mgr.RestoreSessionContext(ctx, sessID)
if err != nil {
t.Fatalf("RestoreSessionContext failed: %v", err)
}
if restored == nil {
t.Fatalf("restored context is nil")
}
if len(restored.Messages) != 3 {
t.Fatalf("restored chat messages expected 3, got %d", len(restored.Messages))
}
if restored.Messages[2].Content != "帮我查查大致工期和报价" {
t.Errorf("unexpected last message: %s", restored.Messages[2].Content)
}
}
+16
View File
@@ -24,6 +24,7 @@ type ContextManager struct {
users map[int64]*UserPreference
projects map[int64]*ProjectCollectiveContext
messages map[string][]ChatMessageRecord // session_id -> messages
sessions map[string]*ChatSessionSummary // session_id -> summary
activities map[int64][]ActivityItem // project_id -> activities
nextActivityID int64
nextMessageID int64
@@ -42,6 +43,7 @@ func NewContextManager(pool *pgxpool.Pool, cas *storage.CAS, decayCfg TimeDecayC
users: make(map[int64]*UserPreference),
projects: make(map[int64]*ProjectCollectiveContext),
messages: make(map[string][]ChatMessageRecord),
sessions: make(map[string]*ChatSessionSummary),
activities: make(map[int64][]ActivityItem),
nextActivityID: 1,
nextMessageID: 1,
@@ -86,6 +88,20 @@ func (m *ContextManager) RecordUserMessage(ctx context.Context, sessionID string
rec.ID = m.nextMessageID
m.nextMessageID++
m.messages[sessionID] = append(m.messages[sessionID], rec)
sessSum, exists := m.sessions[sessionID]
if !exists {
sessSum = &ChatSessionSummary{
SessionID: sessionID,
Title: "对话协同会话",
UserID: userID,
UpdatedAt: time.Now(),
}
m.sessions[sessionID] = sessSum
}
sessSum.MessageCount = len(m.messages[sessionID])
sessSum.LastMessage = content
sessSum.UpdatedAt = time.Now()
m.mu.Unlock()
}
+69
View File
@@ -123,3 +123,72 @@ type BlendedSquadContext struct {
PersonalizedGuidance string `json:"personalized_guidance"` // 针对当前用户偏好生成的注入指导语
AssembledAt time.Time `json:"assembled_at"`
}
// SessionFilter 历史会话查询过滤条件
type SessionFilter struct {
UserID int64 `json:"user_id,omitempty"`
ProjectID int64 `json:"project_id,omitempty"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
// ChatSessionSummary 会话摘要说明
type ChatSessionSummary struct {
SessionID string `json:"session_id"`
Title string `json:"title"`
UserID int64 `json:"user_id"`
ProjectID int64 `json:"project_id,omitempty"`
ProjectName string `json:"project_name,omitempty"`
MessageCount int `json:"message_count"`
LastMessage string `json:"last_message,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
// CaseRecommendationItem 相似标杆案例自推荐条目
type CaseRecommendationItem struct {
DocID string `json:"doc_id"`
ProjectID int64 `json:"project_id"`
ProjectName string `json:"project_name"`
TaskKey string `json:"task_key"`
ArtifactTitle string `json:"artifact_title"`
SectionTitle string `json:"section_title"`
SummarySnippet string `json:"summary_snippet"`
KeyRegulations []string `json:"key_regulations,omitempty"`
SimilarityScore float64 `json:"similarity_score"`
ApprovedAt string `json:"approved_at"`
}
// CaseSearchOptions 标杆案例语义检索参数
type CaseSearchOptions struct {
Query string `json:"query"`
TaskKey string `json:"task_key,omitempty"`
ExcludeDoc string `json:"exclude_doc,omitempty"`
Limit int `json:"limit"`
MinScore float64 `json:"min_score"`
}
// MemoryChunkItem 语义记忆切片结构
type MemoryChunkItem struct {
ID int64 `json:"id"`
Domain string `json:"domain"`
ProjectID int64 `json:"project_id"`
UserID int64 `json:"user_id"`
ReferenceID string `json:"reference_id"`
Title string `json:"title"`
Content string `json:"content"`
ChunkHash string `json:"chunk_hash"`
Embedding []float32 `json:"embedding,omitempty"`
ImportanceWeight float64 `json:"importance_weight"`
Metadata map[string]any `json:"metadata,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// RestoredSessionContext 从持久化记录中重构的会话上下文
type RestoredSessionContext struct {
SessionID string `json:"session_id"`
UserID int64 `json:"user_id"`
ProjectID int64 `json:"project_id,omitempty"`
Title string `json:"title"`
Messages []ChatMessageRecord `json:"messages"`
Facts map[string]string `json:"facts,omitempty"`
}