diff --git a/internal/ctxmgr/decay.go b/internal/ctxmgr/decay.go new file mode 100644 index 00000000..a5ccf547 --- /dev/null +++ b/internal/ctxmgr/decay.go @@ -0,0 +1,63 @@ +package ctxmgr + +import ( + "math" + "time" +) + +// CalculateDecayedWeight 计算考虑时间半衰期与里程碑豁免后的有效权重 +func CalculateDecayedWeight(rawWeight float64, createdAt time.Time, isMilestone bool, cfg TimeDecayConfig) float64 { + if rawWeight <= 0 { + rawWeight = 1.00 + } + if !cfg.Enabled { + return rawWeight + } + // 里程碑事件豁免衰减,作为永久基石 + if isMilestone && cfg.MilestoneExempt { + return rawWeight + } + + halfLife := cfg.HalfLifeDays + if halfLife <= 0 { + halfLife = 7.0 + } + + daysDiff := time.Since(createdAt).Hours() / 24.0 + if daysDiff <= 0 { + return rawWeight + } + + lambda := math.Ln2 / halfLife + decayFactor := math.Exp(-lambda * daysDiff) + + decayed := rawWeight * decayFactor + + // 应用衰减下限保护,防止早期有价值信息完全被截断 + floor := rawWeight * cfg.MinWeightFloor + if decayed < floor { + decayed = floor + } + + return math.Round(decayed*1000) / 1000 +} + +// ApplyTimeDecayToActivities 对活动流批量应用时间衰减并按最终有效权重重排 +func ApplyTimeDecayToActivities(items []ActivityItem, cfg TimeDecayConfig) []ActivityItem { + res := make([]ActivityItem, len(items)) + for i, it := range items { + it.DecayedWeight = CalculateDecayedWeight(it.RawWeight, it.CreatedAt, it.IsMilestone, cfg) + res[i] = it + } + + // 稳定排序:按 DecayedWeight 降序,相同时按时间倒序 + for i := 0; i < len(res); i++ { + for j := i + 1; j < len(res); j++ { + if res[j].DecayedWeight > res[i].DecayedWeight || + (res[j].DecayedWeight == res[i].DecayedWeight && res[j].CreatedAt.After(res[i].CreatedAt)) { + res[i], res[j] = res[j], res[i] + } + } + } + return res +} diff --git a/internal/ctxmgr/manager.go b/internal/ctxmgr/manager.go new file mode 100644 index 00000000..13ae555a --- /dev/null +++ b/internal/ctxmgr/manager.go @@ -0,0 +1,431 @@ +package ctxmgr + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sync" + "time" + + "chat-agent-system/internal/storage" + "github.com/jackc/pgx/v5/pgxpool" +) + +// ContextManager 多级与多类型上下文总管理器 +type ContextManager struct { + pool *pgxpool.Pool + cas *storage.CAS + reflector *PreferenceReflector + decayCfg TimeDecayConfig + + // 离线降级内存缓存 + mu sync.RWMutex + users map[int64]*UserPreference + projects map[int64]*ProjectCollectiveContext + messages map[string][]ChatMessageRecord // session_id -> messages + activities map[int64][]ActivityItem // project_id -> activities + nextActivityID int64 + nextMessageID int64 +} + +// NewContextManager 创建上下文管理器 +func NewContextManager(pool *pgxpool.Pool, cas *storage.CAS, decayCfg TimeDecayConfig) *ContextManager { + if decayCfg.HalfLifeDays <= 0 { + decayCfg = DefaultTimeDecayConfig() + } + return &ContextManager{ + pool: pool, + cas: cas, + reflector: NewPreferenceReflector(), + decayCfg: decayCfg, + users: make(map[int64]*UserPreference), + projects: make(map[int64]*ProjectCollectiveContext), + messages: make(map[string][]ChatMessageRecord), + activities: make(map[int64][]ActivityItem), + nextActivityID: 1, + nextMessageID: 1, + } +} + +// RecordUserMessage 记录单轮对话消息并自动驱动工作习惯偏好更新 +func (m *ContextManager) RecordUserMessage(ctx context.Context, sessionID string, userID int64, role, content, intent, branch string, meta map[string]any) error { + if sessionID == "" || content == "" { + return errors.New("sessionID and content required") + } + + rec := ChatMessageRecord{ + SessionID: sessionID, + UserID: userID, + Role: role, + Content: content, + Intent: intent, + Branch: branch, + Metadata: meta, + CreatedAt: time.Now(), + } + + if m.pool != nil { + // 确保会话存在 + _, _ = m.pool.Exec(ctx, ` + INSERT INTO chat_sessions (session_id, user_id, title, created_at, updated_at) + VALUES ($1, $2, '对话协同会话', now(), now()) + ON CONFLICT (session_id) DO UPDATE SET updated_at = now() + `, sessionID, userID) + + metaJSON, _ := json.Marshal(meta) + _, err := m.pool.Exec(ctx, ` + INSERT INTO chat_messages (session_id, user_id, role, content, intent, branch, metadata, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, now()) + `, sessionID, userID, role, content, intent, branch, metaJSON) + if err != nil { + return fmt.Errorf("insert chat message failed: %w", err) + } + } else { + m.mu.Lock() + rec.ID = m.nextMessageID + m.nextMessageID++ + m.messages[sessionID] = append(m.messages[sessionID], rec) + m.mu.Unlock() + } + + // 若为用户输入,触发工作习惯偏好异步更新 + if role == "user" && userID > 0 { + _ = m.ReflectUserPreference(ctx, userID, sessionID) + } + + return nil +} + +// RecordActivity 记录项目协同操作/决策/上传/下载活动 +func (m *ContextManager) RecordActivity(ctx context.Context, projectID, userID int64, userName, actType, title string, detail map[string]any, weight float64, isMilestone bool) (*ActivityItem, error) { + if projectID <= 0 || title == "" { + return nil, errors.New("projectID and title required") + } + if weight <= 0 { + weight = 1.00 + } + + item := &ActivityItem{ + ProjectID: projectID, + UserID: userID, + UserName: userName, + ActivityType: actType, + Title: title, + Detail: detail, + RawWeight: weight, + IsMilestone: isMilestone, + CreatedAt: time.Now(), + } + + if m.pool != nil { + detailJSON, _ := json.Marshal(detail) + var actID int64 + var createdAt time.Time + err := m.pool.QueryRow(ctx, ` + INSERT INTO project_activities (project_id, user_id, user_name, activity_type, title, detail, weight, is_milestone, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now()) + RETURNING id, created_at + `, projectID, userID, userName, actType, title, detailJSON, weight, isMilestone).Scan(&actID, &createdAt) + if err != nil { + return nil, fmt.Errorf("insert activity failed: %w", err) + } + item.ID = actID + item.CreatedAt = createdAt + } else { + m.mu.Lock() + item.ID = m.nextActivityID + m.nextActivityID++ + m.activities[projectID] = append(m.activities[projectID], *item) + m.mu.Unlock() + } + + item.DecayedWeight = CalculateDecayedWeight(item.RawWeight, item.CreatedAt, item.IsMilestone, m.decayCfg) + return item, nil +} + +// GetUserContext 装配单用户私有上下文 (包含个人偏好画像、近期会话、个人操作与经时间衰减的文件资料) +func (m *ContextManager) GetUserContext(ctx context.Context, projectID, userID int64, sessionID string) (*SingleUserContext, error) { + pref, _ := m.GetUserPreference(ctx, userID) + + userCtx := &SingleUserContext{ + UserID: userID, + Preference: pref, + SessionID: sessionID, + RecentChats: make([]ChatMessageRecord, 0), + MyActivities: make([]ActivityItem, 0), + MyFiles: make([]*storage.FileReference, 0), + AssembledAt: time.Now(), + } + + // 1. 获取近期会话 + if sessionID != "" { + if m.pool != nil { + rows, err := m.pool.Query(ctx, ` + SELECT id, session_id, user_id, role, content, intent, branch, metadata, created_at + FROM chat_messages + WHERE session_id = $1 + ORDER BY created_at ASC LIMIT 30 + `, sessionID) + if err == nil { + defer rows.Close() + for rows.Next() { + var r ChatMessageRecord + var metaJSON []byte + if err := rows.Scan(&r.ID, &r.SessionID, &r.UserID, &r.Role, &r.Content, &r.Intent, &r.Branch, &metaJSON, &r.CreatedAt); err == nil { + _ = json.Unmarshal(metaJSON, &r.Metadata) + userCtx.RecentChats = append(userCtx.RecentChats, r) + } + } + } + } else { + m.mu.RLock() + userCtx.RecentChats = append(userCtx.RecentChats, m.messages[sessionID]...) + m.mu.RUnlock() + } + } + + // 2. 获取用户个人在项目内的活动并应用时间衰减 + var rawActs []ActivityItem + if m.pool != nil { + rows, err := m.pool.Query(ctx, ` + SELECT id, project_id, user_id, user_name, activity_type, title, detail, weight, is_milestone, created_at + FROM project_activities + WHERE project_id = $1 AND user_id = $2 + ORDER BY created_at DESC LIMIT 50 + `, projectID, userID) + if err == nil { + defer rows.Close() + for rows.Next() { + var it ActivityItem + var detJSON []byte + if err := rows.Scan(&it.ID, &it.ProjectID, &it.UserID, &it.UserName, &it.ActivityType, &it.Title, &detJSON, &it.RawWeight, &it.IsMilestone, &it.CreatedAt); err == nil { + _ = json.Unmarshal(detJSON, &it.Detail) + rawActs = append(rawActs, it) + } + } + } + } else { + m.mu.RLock() + for _, it := range m.activities[projectID] { + if it.UserID == userID { + rawActs = append(rawActs, it) + } + } + m.mu.RUnlock() + } + + userCtx.MyActivities = ApplyTimeDecayToActivities(rawActs, m.decayCfg) + return userCtx, nil +} + +// GetProjectContext 装配项目级多方协同上下文 (黑板事实、里程碑决策、团队活动流与共享文件) +func (m *ContextManager) GetProjectContext(ctx context.Context, projectID int64) (*ProjectCollectiveContext, error) { + pCtx := &ProjectCollectiveContext{ + ProjectID: projectID, + BlackboardFacts: make(map[string]string), + AllActivities: make([]ActivityItem, 0), + MilestoneDecisions: make([]ActivityItem, 0), + SharedFiles: make([]*storage.FileReference, 0), + ParticipatingUsers: make([]UserSummary, 0), + AssembledAt: time.Now(), + } + + if m.pool != nil { + // 1. 获取项目基准与黑板事实 + var pCode, pName, cStage string + _ = m.pool.QueryRow(ctx, "SELECT code, name, current_stage FROM projects WHERE id = $1", projectID).Scan(&pCode, &pName, &cStage) + pCtx.ProjectCode = pCode + pCtx.ProjectName = pName + pCtx.CurrentStage = cStage + + var factsJSON []byte + err := m.pool.QueryRow(ctx, "SELECT facts FROM project_blackboards WHERE project_id = $1", projectID).Scan(&factsJSON) + if err == nil { + _ = json.Unmarshal(factsJSON, &pCtx.BlackboardFacts) + } + + // 2. 获取全项目活动 + rows, err := m.pool.Query(ctx, ` + SELECT id, project_id, user_id, user_name, activity_type, title, detail, weight, is_milestone, created_at + FROM project_activities + WHERE project_id = $1 + ORDER BY created_at DESC LIMIT 100 + `, projectID) + if err == nil { + defer rows.Close() + var rawActs []ActivityItem + for rows.Next() { + var it ActivityItem + var detJSON []byte + if err := rows.Scan(&it.ID, &it.ProjectID, &it.UserID, &it.UserName, &it.ActivityType, &it.Title, &detJSON, &it.RawWeight, &it.IsMilestone, &it.CreatedAt); err == nil { + _ = json.Unmarshal(detJSON, &it.Detail) + rawActs = append(rawActs, it) + if it.IsMilestone || it.ActivityType == "decision" { + pCtx.MilestoneDecisions = append(pCtx.MilestoneDecisions, it) + } + } + } + pCtx.AllActivities = ApplyTimeDecayToActivities(rawActs, m.decayCfg) + } + } else { + m.mu.RLock() + if existing, ok := m.projects[projectID]; ok { + pCtx.ProjectName = existing.ProjectName + pCtx.CurrentStage = existing.CurrentStage + for k, v := range existing.BlackboardFacts { + pCtx.BlackboardFacts[k] = v + } + } + rawActs := m.activities[projectID] + for _, it := range rawActs { + if it.IsMilestone || it.ActivityType == "decision" { + pCtx.MilestoneDecisions = append(pCtx.MilestoneDecisions, it) + } + } + pCtx.AllActivities = ApplyTimeDecayToActivities(rawActs, m.decayCfg) + m.mu.RUnlock() + } + + return pCtx, nil +} + +// BuildBlendedSquadContext 混合上下文装配引擎 (Context Blender) +// 将项目事实、里程碑决策、触发用户的工作习惯偏好合成最适合目标 Agent 角色执行的混合上下文 +func (m *ContextManager) BuildBlendedSquadContext(ctx context.Context, projectID, triggerUserID int64, taskKey, role string) (*BlendedSquadContext, error) { + projCtx, err := m.GetProjectContext(ctx, projectID) + if err != nil { + return nil, fmt.Errorf("get project context failed: %w", err) + } + + userPref, _ := m.GetUserPreference(ctx, triggerUserID) + guidance := m.reflector.GeneratePromptGuidance(userPref) + + var decisions []string + for _, d := range projCtx.MilestoneDecisions { + decisions = append(decisions, fmt.Sprintf("[%s] %s (%s)", d.ActivityType, d.Title, d.UserName)) + } + + return &BlendedSquadContext{ + ProjectName: projCtx.ProjectName, + CurrentStage: projCtx.CurrentStage, + TaskKey: taskKey, + AgentRole: role, + BlackboardFacts: projCtx.BlackboardFacts, + KeyDecisions: decisions, + TriggerUserPreference: userPref, + PersonalizedGuidance: guidance, + AssembledAt: time.Now(), + }, nil +} + +// GetUserPreference 获取用户偏好画像 +func (m *ContextManager) GetUserPreference(ctx context.Context, userID int64) (*UserPreference, error) { + if userID <= 0 { + return nil, errors.New("invalid userID") + } + + if m.pool != nil { + var pref UserPreference + var focusArr []string + var factsJSON []byte + err := m.pool.QueryRow(ctx, ` + SELECT u.id, u.account_id, u.name, u.role, p.communication_style, p.focus_areas, p.output_format_pref, p.custom_instructions, p.memory_facts, p.updated_at + FROM users u + LEFT JOIN user_preferences p ON u.id = p.user_id + WHERE u.id = $1 + `, userID).Scan(&pref.UserID, &pref.AccountID, &pref.Name, &pref.Role, &pref.CommunicationStyle, &focusArr, &pref.OutputFormatPref, &pref.CustomInstructions, &factsJSON, &pref.UpdatedAt) + + if err == nil { + pref.FocusAreas = focusArr + _ = json.Unmarshal(factsJSON, &pref.MemoryFacts) + if pref.CommunicationStyle == "" { + pref.CommunicationStyle = StyleConciseStructured + } + return &pref, nil + } + } + + m.mu.RLock() + defer m.mu.RUnlock() + if pref, ok := m.users[userID]; ok { + return pref, nil + } + + return &UserPreference{ + UserID: userID, + CommunicationStyle: StyleConciseStructured, + FocusAreas: []string{"reg_compliance", "timeline"}, + OutputFormatPref: "standard_report", + MemoryFacts: make(map[string]string), + UpdatedAt: time.Now(), + }, nil +} + +// UpdateUserPreference 保存用户偏好画像 +func (m *ContextManager) UpdateUserPreference(ctx context.Context, pref *UserPreference) error { + if pref == nil || pref.UserID <= 0 { + return errors.New("invalid user preference") + } + + pref.UpdatedAt = time.Now() + + if m.pool != nil { + factsJSON, _ := json.Marshal(pref.MemoryFacts) + _, err := m.pool.Exec(ctx, ` + INSERT INTO user_preferences (user_id, communication_style, focus_areas, output_format_pref, custom_instructions, memory_facts, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, now()) + ON CONFLICT (user_id) DO UPDATE SET + communication_style = EXCLUDED.communication_style, + focus_areas = EXCLUDED.focus_areas, + output_format_pref = EXCLUDED.output_format_pref, + custom_instructions = EXCLUDED.custom_instructions, + memory_facts = EXCLUDED.memory_facts, + updated_at = now() + `, pref.UserID, string(pref.CommunicationStyle), pref.FocusAreas, pref.OutputFormatPref, pref.CustomInstructions, factsJSON) + if err != nil { + return fmt.Errorf("upsert user preference failed: %w", err) + } + } + + m.mu.Lock() + m.users[pref.UserID] = pref + m.mu.Unlock() + return nil +} + +// ReflectUserPreference 根据用户在指定会话的历史记录智能总结工作习惯与偏好 +func (m *ContextManager) ReflectUserPreference(ctx context.Context, userID int64, sessionID string) error { + var msgs []ChatMessageRecord + if m.pool != nil { + rows, err := m.pool.Query(ctx, ` + SELECT id, session_id, user_id, role, content, intent, branch, metadata, created_at + FROM chat_messages + WHERE user_id = $1 OR session_id = $2 + ORDER BY created_at DESC LIMIT 20 + `, userID, sessionID) + if err == nil { + defer rows.Close() + for rows.Next() { + var r ChatMessageRecord + var metaJSON []byte + if err := rows.Scan(&r.ID, &r.SessionID, &r.UserID, &r.Role, &r.Content, &r.Intent, &r.Branch, &metaJSON, &r.CreatedAt); err == nil { + _ = json.Unmarshal(metaJSON, &r.Metadata) + msgs = append(msgs, r) + } + } + } + } else { + m.mu.RLock() + msgs = append(msgs, m.messages[sessionID]...) + m.mu.RUnlock() + } + + if len(msgs) == 0 { + return nil + } + + currentPref, _ := m.GetUserPreference(ctx, userID) + updatedPref := m.reflector.ReflectFromMessages(msgs, currentPref) + return m.UpdateUserPreference(ctx, updatedPref) +} diff --git a/internal/ctxmgr/manager_test.go b/internal/ctxmgr/manager_test.go new file mode 100644 index 00000000..69248461 --- /dev/null +++ b/internal/ctxmgr/manager_test.go @@ -0,0 +1,205 @@ +package ctxmgr + +import ( + "context" + "os" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func TestContextManager_PreferenceReflection(t *testing.T) { + mgr := NewContextManager(nil, nil, DefaultTimeDecayConfig()) + ctx := context.Background() + + userID := int64(99) + sessionID := "sess_pref_test" + + // 模拟用户发送了多次体现其工作习惯的消息 + _ = mgr.RecordUserMessage(ctx, sessionID, userID, "user", "说重点,太长了不要废话,直接给结论。", "QUERY_PROGRESS", "PREEMPT", nil) + _ = mgr.RecordUserMessage(ctx, sessionID, userID, "user", "本次申报重点关注国内NMPA与欧洲CE合规,另外严格把控工期排期和成本预算。", "SUPPLEMENT_INFO", "EXPECTATION_HIT", nil) + _ = mgr.RecordUserMessage(ctx, sessionID, userID, "user", "文末请务必附带适合向高管汇报的 PPT 要点清单。", "SUPPLEMENT_INFO", "EXPECTATION_HIT", nil) + + pref, err := mgr.GetUserPreference(ctx, userID) + if err != nil { + t.Fatalf("get user preference failed: %v", err) + } + + // 1. 验证沟通风格是否被提炼为精炼结构化 + if pref.CommunicationStyle != StyleConciseStructured { + t.Errorf("expected StyleConciseStructured, got %s", pref.CommunicationStyle) + } + + // 2. 验证关注领域是否包含合规、预算和周期 + hasReg := false + hasCost := false + hasTimeline := false + for _, f := range pref.FocusAreas { + if f == "reg_compliance" { + hasReg = true + } + if f == "cost_budget" { + hasCost = true + } + if f == "timeline" { + hasTimeline = true + } + } + if !hasReg || !hasCost || !hasTimeline { + t.Errorf("missing focus areas in preference: %v", pref.FocusAreas) + } + + // 3. 验证输出格式偏好 + if pref.OutputFormatPref != "ppt_bullets" { + t.Errorf("expected ppt_bullets, got %s", pref.OutputFormatPref) + } + + // 4. 验证生成的个性化 Prompt 指导语 + guidance := mgr.reflector.GeneratePromptGuidance(pref) + if !strings.Contains(guidance, "结构精炼、结论先行") { + t.Errorf("guidance missing concise style instruction: %s", guidance) + } + if !strings.Contains(guidance, "法规标准合规与审评审批风险") { + t.Errorf("guidance missing reg compliance focus: %s", guidance) + } + if !strings.Contains(guidance, "PPT 的条目清单") { + t.Errorf("guidance missing ppt format instruction: %s", guidance) + } +} + +func TestContextManager_TimeDecayActivities(t *testing.T) { + mgr := NewContextManager(nil, nil, TimeDecayConfig{ + Enabled: true, + HalfLifeDays: 7.0, + MinWeightFloor: 0.10, + MilestoneExempt: true, + }) + ctx := context.Background() + projectID := int64(888) + + // 记录一项里程碑决策 (不衰减) + milestone, _ := mgr.RecordActivity(ctx, projectID, 1, "张工", "decision", "通过V0.5阶段可行性初审", nil, 1.0, true) + + // 记录一项 14 天前的日常协同讨论 (经历 2 个半衰期) + oldOp, _ := mgr.RecordActivity(ctx, projectID, 2, "李工", "operation", "日常测试参数备选记录", nil, 1.0, false) + oldOp.CreatedAt = time.Now().Add(-14 * 24 * time.Hour) + + // 更新内存中的活动列表时间以供测试排序 + mgr.mu.Lock() + for i := range mgr.activities[projectID] { + if mgr.activities[projectID][i].ID == oldOp.ID { + mgr.activities[projectID][i].CreatedAt = oldOp.CreatedAt + } + } + mgr.mu.Unlock() + + pCtx, err := mgr.GetProjectContext(ctx, projectID) + if err != nil { + t.Fatalf("get project context failed: %v", err) + } + + if len(pCtx.AllActivities) < 2 { + t.Fatalf("expected 2 activities, got %d", len(pCtx.AllActivities)) + } + + // 里程碑事件排在最前且权重维持 1.0 + if pCtx.AllActivities[0].ID != milestone.ID { + t.Errorf("expected milestone to rank highest, got ID %d", pCtx.AllActivities[0].ID) + } + if pCtx.AllActivities[0].DecayedWeight != 1.0 { + t.Errorf("expected milestone weight 1.0, got %.2f", pCtx.AllActivities[0].DecayedWeight) + } + + // 14 天前的旧操作权重应衰减至 0.25 左右 + if pCtx.AllActivities[1].DecayedWeight > 0.30 { + t.Errorf("expected old op decayed below 0.30, got %.3f", pCtx.AllActivities[1].DecayedWeight) + } +} + +func TestContextManager_DatabaseIntegration(t *testing.T) { + dsn := os.Getenv("CONTEXT_DB_DSN") + if dsn == "" { + dsn = "postgres://app:dev_secret_db_pass@127.0.0.1:5433/agent_system_dev?sslmode=disable" + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Skipf("cannot connect to context db (%v), skipping DB test", err) + return + } + defer pool.Close() + + if err := pool.Ping(ctx); err != nil { + t.Skipf("context db ping failed (%v), skipping DB test", err) + return + } + + mgr := NewContextManager(pool, nil, DefaultTimeDecayConfig()) + + // 准备真实项目与用户 + var projID int64 + projCode := "PROJ_MGR_" + time.Now().Format("20060102150405") + err = pool.QueryRow(ctx, ` + INSERT INTO projects (code, name, customer_name, device_type, current_stage) + VALUES ($1, '智能微创内窥镜', '水木创新中心', '内窥镜', 'V0.5_feasibility') + RETURNING id + `, projCode).Scan(&projID) + if err != nil { + t.Fatalf("insert project failed: %v", err) + } + + var userID int64 + accountID := "acc_mgr_" + time.Now().Format("150405") + err = pool.QueryRow(ctx, ` + INSERT INTO users (account_id, name, role) + VALUES ($1, '首席法规官', 'regulatory') + RETURNING id + `, accountID).Scan(&userID) + if err != nil { + t.Fatalf("insert user failed: %v", err) + } + + sessID := "sess_db_" + time.Now().Format("150405") + _ = mgr.RecordUserMessage(ctx, sessID, userID, "user", "请简短汇报,结论先行,关注NMPA注册进度和排期。", "QUERY_PROGRESS", "PREEMPT", nil) + _ = mgr.RecordUserMessage(ctx, sessID, userID, "assistant", "已为您梳理完成,当前已完成法规初筛。", "", "", nil) + + _, err = mgr.RecordActivity(ctx, projID, userID, "首席法规官", "decision", "选定注册检验所并锁定型检方案", nil, 1.0, true) + if err != nil { + t.Fatalf("record activity in db failed: %v", err) + } + + // 验证单用户上下文读取 + uCtx, err := mgr.GetUserContext(ctx, projID, userID, sessID) + if err != nil { + t.Fatalf("get user context from db failed: %v", err) + } + if len(uCtx.RecentChats) < 2 { + t.Errorf("expected at least 2 chat messages in db, got %d", len(uCtx.RecentChats)) + } + if uCtx.Preference.CommunicationStyle != StyleConciseStructured { + t.Errorf("expected preference StyleConciseStructured in db, got %s", uCtx.Preference.CommunicationStyle) + } + + // 验证项目级上下文读取 + pCtx, err := mgr.GetProjectContext(ctx, projID) + if err != nil { + t.Fatalf("get project context from db failed: %v", err) + } + if len(pCtx.MilestoneDecisions) == 0 { + t.Errorf("expected milestone decisions in db, got 0") + } + + // 验证混合上下文装配 + blended, err := mgr.BuildBlendedSquadContext(ctx, projID, userID, "feasibility_reg", "regulatory") + if err != nil { + t.Fatalf("build blended context failed: %v", err) + } + if !strings.Contains(blended.PersonalizedGuidance, "结构精炼、结论先行") { + t.Errorf("blended context missing user personalized guidance: %s", blended.PersonalizedGuidance) + } +} diff --git a/internal/ctxmgr/preference.go b/internal/ctxmgr/preference.go new file mode 100644 index 00000000..eeb8472e --- /dev/null +++ b/internal/ctxmgr/preference.go @@ -0,0 +1,137 @@ +package ctxmgr + +import ( + "fmt" + "strings" + "time" +) + +// PreferenceReflector 用户工作习惯与偏好智能总结反思器 +type PreferenceReflector struct{} + +func NewPreferenceReflector() *PreferenceReflector { + return &PreferenceReflector{} +} + +// ReflectFromMessages 分析用户会话历史,提炼或更新用户的工作习惯画像 +func (r *PreferenceReflector) ReflectFromMessages(messages []ChatMessageRecord, existing *UserPreference) *UserPreference { + if existing == nil { + existing = &UserPreference{ + CommunicationStyle: StyleConciseStructured, + FocusAreas: make([]string, 0), + OutputFormatPref: "standard_report", + MemoryFacts: make(map[string]string), + UpdatedAt: time.Now(), + } + } + + focusSet := make(map[string]bool) + for _, f := range existing.FocusAreas { + focusSet[f] = true + } + + for _, msg := range messages { + if msg.Role != "user" { + continue + } + c := strings.ToLower(msg.Content) + + // 1. 风格偏好识别 + if strings.Contains(c, "简短") || strings.Contains(c, "简要") || strings.Contains(c, "说重点") || + strings.Contains(c, "太长") || strings.Contains(c, "不要太啰嗦") || strings.Contains(c, "结论先行") { + existing.CommunicationStyle = StyleConciseStructured + } else if strings.Contains(c, "详细") || strings.Contains(c, "展开讲") || strings.Contains(c, "深度分析") || strings.Contains(c, "推演过程") { + existing.CommunicationStyle = StyleDetailedAnalytical + } else if strings.Contains(c, "高管决策") || strings.Contains(c, "决策要点") || strings.Contains(c, "高管版") || strings.Contains(c, "高管摘要") { + existing.CommunicationStyle = StyleExecutiveSummary + } + + // 2. 关注维度识别 + if strings.Contains(c, "法规") || strings.Contains(c, "标准") || strings.Contains(c, "合规") || strings.Contains(c, "nmpa") || strings.Contains(c, "ce") { + focusSet["reg_compliance"] = true + } + if strings.Contains(c, "成本") || strings.Contains(c, "价格") || strings.Contains(c, "预算") || strings.Contains(c, "工时") || strings.Contains(c, "报价") { + focusSet["cost_budget"] = true + } + if strings.Contains(c, "周期") || strings.Contains(c, "排期") || strings.Contains(c, "进度") || strings.Contains(c, "尽快") || strings.Contains(c, "节点") { + focusSet["timeline"] = true + } + if strings.Contains(c, "光学") || strings.Contains(c, "结构") || strings.Contains(c, "硬件") || strings.Contains(c, "参数") || strings.Contains(c, "公差") { + focusSet["technical_parameters"] = true + } + + // 3. 输出格式偏好 + if strings.Contains(c, "双语") || strings.Contains(c, "英文") { + existing.OutputFormatPref = "bilingual_brief" + } else if strings.Contains(c, "ppt") || strings.Contains(c, "幻灯片") || strings.Contains(c, "要点清单") { + existing.OutputFormatPref = "ppt_bullets" + } + + // 4. 定制化指令捕获 (如 "记住...", "以后请...") + if strings.Contains(c, "记住") || strings.Contains(c, "以后请") || strings.Contains(c, "默认都") { + existing.CustomInstructions = msg.Content + } + } + + var updatedFocus []string + for f := range focusSet { + updatedFocus = append(updatedFocus, f) + } + existing.FocusAreas = updatedFocus + existing.UpdatedAt = time.Now() + + return existing +} + +// GeneratePromptGuidance 将用户偏好编译为注入到智能体或参谋 Prompt 的定制化指导语 +func (r *PreferenceReflector) GeneratePromptGuidance(pref *UserPreference) string { + if pref == nil { + return "" + } + + var sb strings.Builder + sb.WriteString("【用户个性化工作习惯与偏好指导 (Memory Injection)】\n") + + switch pref.CommunicationStyle { + case StyleConciseStructured: + sb.WriteString("• 沟通风格:该用户偏好【结构精炼、结论先行】的呈现方式,严禁冗长铺垫,优先给出摘要与下一步行动。\n") + case StyleDetailedAnalytical: + sb.WriteString("• 沟通风格:该用户偏好【详尽论证与深度分析】,请提供充分的依据、竞品横向对比与技术参数细节。\n") + case StyleExecutiveSummary: + sb.WriteString("• 沟通风格:该用户偏好【高管决策摘要】,突出商务价值、风险敞口与关键资源投入,弱化底层算法推演。\n") + } + + if len(pref.FocusAreas) > 0 { + var areaNames []string + for _, a := range pref.FocusAreas { + switch a { + case "reg_compliance": + areaNames = append(areaNames, "法规标准合规与审评审批风险") + case "cost_budget": + areaNames = append(areaNames, "CDO工时核算、BOM成本与预算边界") + case "timeline": + areaNames = append(areaNames, "关键研发排期、型检周期与备料冗余") + case "technical_parameters": + areaNames = append(areaNames, "核心光学/机械/电气参数与工艺公差") + default: + areaNames = append(areaNames, a) + } + } + sb.WriteString(fmt.Sprintf("• 重点关注:该用户高度敏感于【%s】,请在产出结论时予以重点呈现并做避险预警。\n", strings.Join(areaNames, "、"))) + } + + if pref.OutputFormatPref != "" && pref.OutputFormatPref != "standard_report" { + switch pref.OutputFormatPref { + case "bilingual_brief": + sb.WriteString("• 格式要求:用户偏好附带中英双语核心术语与摘要。\n") + case "ppt_bullets": + sb.WriteString("• 格式要求:文末请专门附带适合制作汇报 PPT 的条目清单 (Bullet Points)。\n") + } + } + + if pref.CustomInstructions != "" { + sb.WriteString(fmt.Sprintf("• 用户明确要求:%s\n", pref.CustomInstructions)) + } + + return sb.String() +} diff --git a/internal/ctxmgr/types.go b/internal/ctxmgr/types.go new file mode 100644 index 00000000..3f4612ed --- /dev/null +++ b/internal/ctxmgr/types.go @@ -0,0 +1,125 @@ +package ctxmgr + +import ( + "time" + + "chat-agent-system/internal/storage" +) + +// CommunicationStyle 用户沟通风格 +type CommunicationStyle string + +const ( + StyleConciseStructured CommunicationStyle = "concise_structured" // 结构精炼、结论先行、拒绝冗长 + StyleDetailedAnalytical CommunicationStyle = "detailed_analytical" // 详尽论证、全面对比、深挖细节 + StyleExecutiveSummary CommunicationStyle = "executive_summary" // 高管要点、商业决策导向 +) + +// UserPreference 用户工作习惯与产出偏好画像 +type UserPreference struct { + UserID int64 `json:"user_id"` + AccountID string `json:"account_id"` + Name string `json:"name"` + Role string `json:"role"` + CommunicationStyle CommunicationStyle `json:"communication_style"` + FocusAreas []string `json:"focus_areas"` // 关注维度,如 "reg_compliance", "cost_budget", "timeline", "optical_quality" + OutputFormatPref string `json:"output_format_pref"` // 报告格式要求,如 "standard_report", "bilingual_brief", "ppt_bullets" + CustomInstructions string `json:"custom_instructions"` // 用户直接指定的定制要求 + MemoryFacts map[string]string `json:"memory_facts"` // 异步蒸馏出的长期事实记忆 (如 "预算通常按千万级申报", "偏好使用迈瑞作为对标竞品") + UpdatedAt time.Time `json:"updated_at"` +} + +// ActivityItem 项目活动记录 (操作、决策、上传、下载) +type ActivityItem struct { + ID int64 `json:"id"` + ProjectID int64 `json:"project_id"` + UserID int64 `json:"user_id"` + UserName string `json:"user_name"` + ActivityType string `json:"activity_type"` // decision, operation, upload, download, stage_advance, chat + Title string `json:"title"` + Detail map[string]any `json:"detail,omitempty"` + RawWeight float64 `json:"raw_weight"` // 初始权重 + DecayedWeight float64 `json:"decayed_weight"`// 时间衰减后当前有效权重 + IsMilestone bool `json:"is_milestone"` // 里程碑事件不衰减 + CreatedAt time.Time `json:"created_at"` +} + +// TimeDecayConfig 时间权重衰减配置 +type TimeDecayConfig struct { + Enabled bool `json:"enabled"` + HalfLifeDays float64 `json:"half_life_days"` // 半衰期天数 (如 7.0 天) + MinWeightFloor float64 `json:"min_weight_floor"` // 权重衰减下限 (如 0.10,防止被完全抹除) + MilestoneExempt bool `json:"milestone_exempt"` // 里程碑事件是否豁免衰减 (默认 true) +} + +// DefaultTimeDecayConfig 默认时间衰减配置 +func DefaultTimeDecayConfig() TimeDecayConfig { + return TimeDecayConfig{ + Enabled: true, + HalfLifeDays: 7.0, + MinWeightFloor: 0.15, + MilestoneExempt: true, + } +} + +// SingleUserContext 单用户私有上下文 +type SingleUserContext struct { + UserID int64 `json:"user_id"` + AccountID string `json:"account_id"` + Name string `json:"name"` + Role string `json:"role"` + Preference *UserPreference `json:"preference"` + SessionID string `json:"session_id"` + RecentChats []ChatMessageRecord `json:"recent_chats"` + MyActivities []ActivityItem `json:"my_activities"` + MyFiles []*storage.FileReference `json:"my_files"` + AssembledAt time.Time `json:"assembled_at"` +} + +// ProjectCollectiveContext 项目级多方协同上下文 +type ProjectCollectiveContext struct { + ProjectID int64 `json:"project_id"` + ProjectCode string `json:"project_code"` + ProjectName string `json:"project_name"` + CurrentStage string `json:"current_stage"` + BlackboardFacts map[string]string `json:"blackboard_facts"` + AllActivities []ActivityItem `json:"all_activities"` + MilestoneDecisions []ActivityItem `json:"milestone_decisions"` + SharedFiles []*storage.FileReference `json:"shared_files"` + ParticipatingUsers []UserSummary `json:"participating_users"` + AssembledAt time.Time `json:"assembled_at"` +} + +// UserSummary 参与项目的用户摘要 +type UserSummary struct { + UserID int64 `json:"user_id"` + Name string `json:"name"` + Role string `json:"role"` +} + +// ChatMessageRecord 结构化会话消息 +type ChatMessageRecord struct { + ID int64 `json:"id"` + SessionID string `json:"session_id"` + UserID int64 `json:"user_id"` + Role string `json:"role"` + Content string `json:"content"` + Intent string `json:"intent,omitempty"` + Branch string `json:"branch,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// BlendedSquadContext 智能体小队混合上下文 (用于注入到 Agent Prompt 中) +type BlendedSquadContext struct { + ProjectName string `json:"project_name"` + CurrentStage string `json:"current_stage"` + TaskKey string `json:"task_key"` + AgentRole string `json:"agent_role"` + BlackboardFacts map[string]string `json:"blackboard_facts"` + KeyDecisions []string `json:"key_decisions"` + RelevantFiles []string `json:"relevant_files"` + TriggerUserPreference *UserPreference `json:"trigger_user_preference,omitempty"` + PersonalizedGuidance string `json:"personalized_guidance"` // 针对当前用户偏好生成的注入指导语 + AssembledAt time.Time `json:"assembled_at"` +}