feat(ctxmgr): 增加历史多轮会话列表查询、消息拉取与会话重构引擎
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user