feat(service): 实现单用户/项目多级上下文模型、时间衰减与用户习惯偏好管理
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
committed by
weichen
co-authored by
Cursor
parent
4de6c12721
commit
96be8fdd94
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
Reference in New Issue
Block a user