From db3dc282ba1bd5ad7a496bf64fbc425cd9592804 Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Tue, 8 Sep 2026 13:37:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(ctxmgr):=20=E5=A2=9E=E5=8A=A0=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E5=A4=9A=E8=BD=AE=E4=BC=9A=E8=AF=9D=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E3=80=81=E6=B6=88=E6=81=AF=E6=8B=89=E5=8F=96?= =?UTF-8?q?=E4=B8=8E=E4=BC=9A=E8=AF=9D=E9=87=8D=E6=9E=84=E5=BC=95=E6=93=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- internal/ctxmgr/chat_history.go | 200 +++++++++++++++++++++++++++ internal/ctxmgr/chat_history_test.go | 63 +++++++++ internal/ctxmgr/manager.go | 16 +++ internal/ctxmgr/types.go | 69 +++++++++ 4 files changed, 348 insertions(+) create mode 100644 internal/ctxmgr/chat_history.go create mode 100644 internal/ctxmgr/chat_history_test.go diff --git a/internal/ctxmgr/chat_history.go b/internal/ctxmgr/chat_history.go new file mode 100644 index 00000000..dccdb742 --- /dev/null +++ b/internal/ctxmgr/chat_history.go @@ -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 +} diff --git a/internal/ctxmgr/chat_history_test.go b/internal/ctxmgr/chat_history_test.go new file mode 100644 index 00000000..ec1e13eb --- /dev/null +++ b/internal/ctxmgr/chat_history_test.go @@ -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) + } +} diff --git a/internal/ctxmgr/manager.go b/internal/ctxmgr/manager.go index 2ebb997f..2b150f2e 100644 --- a/internal/ctxmgr/manager.go +++ b/internal/ctxmgr/manager.go @@ -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() } diff --git a/internal/ctxmgr/types.go b/internal/ctxmgr/types.go index 3f4612ed..f91bfaab 100644 --- a/internal/ctxmgr/types.go +++ b/internal/ctxmgr/types.go @@ -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"` +}