Files
Agent-System/internal/ctxmgr/manager.go
T
weichen 6a3a0dc201 fix(security): harden bypass authz, gate blind spot, command exec, validated_only (review P0/P1)
- FIND-SEC-01: GetUserContext requires session ownership (handler 403 +
  SQL/memory owner filter); in-memory HTTP + isolated-PG regressions
- FIND-ARCH-01: archtest fails closed on untracked table writes (+noise
  exemption) and registers 50 existing writer tables in table_owners
- FIND-SEC-02: Authorizer without pool denies by default; explicit
  NewInMemoryAuthorizer seam opt-in via ALLOW_IN_MEMORY at composition
- FIND-SEC-03: swboot commands run argv-exec (no sh -c) with exact
  basename allowlist match; prefix-spoof and no-shell-eval tests
- FIND-FLOW-01: confirm rejects validated_only plans; ExecuteClaimed
  hard-refuses validated_only steps (new error codes + HTTP mapping)
- FIND-QUAL-01: intentflow.ValidateFlagConsistency fail-fast at Assemble
2026-09-26 02:00:14 +00:00

774 lines
25 KiB
Go

package ctxmgr
import (
"log/slog"
"context"
"encoding/json"
"errors"
"fmt"
"sync"
"time"
"chat-agent-system/internal/replyexport"
"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
embedder VectorEmbedder
// 离线降级内存缓存
mu sync.RWMutex
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
memoryChunks []MemoryChunkItem // 语义记忆切片缓存
summaries map[string]*ChatSummary // session_id -> rolling summary
digests map[string]map[string]*DailyDigest // session_id -> day_key -> DailyDigest
readPositions map[string]*ChatReadPosition // session_id:user_id -> read position
turns map[string]*ChatTurnRecord // sessionID|clientTurnID -> 幂等回合(仅内存降级)
cache *MultiLevelCache // 多级统一缓存系统 (C1-C5)
semanticSummarizer SemanticSummarizer // no-tool summarizer; nil → heuristic default
shadowJobs []SemanticShadowJob // in-process durable fallback queue
shadowWorkerRunning bool
shadowReclaimStarted bool
nextActivityID int64
nextMessageID int64
nextChunkID int64
// message scope column probe (information_schema); cached once per manager
messageScopeOnce sync.Once
messageScopeOK bool
}
// 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,
embedder: NewDeterministicEmbedder(1024),
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),
memoryChunks: make([]MemoryChunkItem, 0),
summaries: make(map[string]*ChatSummary),
digests: make(map[string]map[string]*DailyDigest),
readPositions: make(map[string]*ChatReadPosition),
turns: make(map[string]*ChatTurnRecord),
cache: NewMultiLevelCache(),
nextActivityID: 1,
nextMessageID: 1,
nextChunkID: 1,
}
}
// Cache 获取上下文管理器的多级缓存实例
func (m *ContextManager) Cache() *MultiLevelCache {
if m == nil {
return GetGlobalCache()
}
if m.cache == nil {
m.cache = GetGlobalCache()
}
return m.cache
}
// SetEmbedder 设置自定义向量嵌入器 (如生产环境 bge-m3)
func (m *ContextManager) SetEmbedder(emb VectorEmbedder) {
if emb != nil {
m.embedder = emb
}
}
// RecordUserMessage 记录单轮对话消息并自动驱动工作习惯偏好更新
func (m *ContextManager) RecordUserMessage(ctx context.Context, sessionID string, userID int64, role, content, intent, branch string, meta map[string]any) error {
_, err := m.RecordUserMessageWithID(ctx, sessionID, userID, role, content, intent, branch, meta)
return err
}
// RecordUserMessageWithID 记录单轮对话消息并返回生成的消息真实自增 ID
func (m *ContextManager) RecordUserMessageWithID(ctx context.Context, sessionID string, userID int64, role, content, intent, branch string, meta map[string]any) (int64, error) {
if sessionID == "" || content == "" {
return 0, errors.New("sessionID and content required")
}
shanghaiTZ := time.FixedZone("CST", 8*3600)
now := time.Now()
dayKey := now.In(shanghaiTZ).Format("2006-01-02")
if meta != nil {
if customTime, ok := meta["created_at"].(time.Time); ok && !customTime.IsZero() {
now = customTime
dayKey = now.In(shanghaiTZ).Format("2006-01-02")
} else if customTimeStr, ok := meta["created_at"].(string); ok && customTimeStr != "" {
if tParsed, err := time.Parse(time.RFC3339, customTimeStr); err == nil {
now = tParsed
dayKey = now.In(shanghaiTZ).Format("2006-01-02")
}
}
if customDayKey, ok := meta["day_key"].(string); ok && customDayKey != "" {
dayKey = customDayKey
}
}
category := DeriveMessageCategory(role, intent, branch, meta)
contentKind := ""
exportPolicy := "none"
contentRevision := 1
contentSha256 := ""
if meta != nil {
if k, ok := meta["content_kind"].(string); ok && k != "" {
contentKind = k
}
if p, ok := meta["export_policy"].(string); ok && p != "" {
exportPolicy = p
}
if rev, ok := meta["content_revision"].(int); ok && rev > 0 {
contentRevision = rev
} else if revF, ok := meta["content_revision"].(float64); ok && revF > 0 {
contentRevision = int(revF)
}
if hash, ok := meta["content_sha256"].(string); ok && hash != "" {
contentSha256 = hash
}
}
if contentKind == "" || exportPolicy == "none" {
hasCard := false
if meta != nil {
if _, ok := meta["interactive_card"]; ok {
hasCard = true
}
}
evalKind, evalCap := replyexport.EvaluatePolicy(replyexport.PolicyOptions{
Role: role,
Status: "completed",
Content: content,
HasInteractiveCard: hasCard,
Revision: contentRevision,
})
contentKind = string(evalKind)
exportPolicy = string(evalCap.Policy)
contentSha256 = evalCap.ContentSha256
}
rec := ChatMessageRecord{
SessionID: sessionID,
UserID: userID,
Role: role,
Content: content,
Intent: intent,
Branch: branch,
Metadata: meta,
DayKey: dayKey,
Category: category,
ContentKind: contentKind,
ExportPolicy: exportPolicy,
ContentRevision: contentRevision,
ContentSha256: contentSha256,
CreatedAt: now,
}
var messageID int64
if m.pool != nil {
var uid *int64
if userID > 0 {
uid = &userID
}
var pid *int64
if meta != nil {
if pVal, ok := meta["project_id"].(int64); ok && pVal > 0 {
pid = &pVal
} else if pValFloat, ok := meta["project_id"].(float64); ok && pValFloat > 0 {
p := int64(pValFloat)
pid = &p
}
}
// 确保会话存在(user_id 已有值优先,防止跨用户覆盖归属)
_, _ = m.pool.Exec(ctx, `
INSERT INTO chat_sessions (session_id, user_id, project_id, title, created_at, updated_at)
VALUES ($1, $2, $3, '对话协同会话', now(), now())
ON CONFLICT (session_id) DO UPDATE SET
updated_at = now(),
user_id = COALESCE(chat_sessions.user_id, $2),
project_id = COALESCE($3, chat_sessions.project_id)
`, sessionID, uid, pid)
wfInstID, stInstID, stageKey, scopeKind := scopeFieldsFromMeta(meta)
rec.WorkflowInstanceID = wfInstID
rec.StageInstanceID = stInstID
rec.StageKey = stageKey
rec.ScopeKind = scopeKind
metaJSON, _ := json.Marshal(meta)
var err error
if m.hasMessageScopeColumns(ctx) {
err = m.pool.QueryRow(ctx, `
INSERT INTO chat_messages (
session_id, user_id, role, content, intent, branch, metadata, day_key, category,
content_kind, export_policy, content_revision, content_sha256,
workflow_instance_id, stage_instance_id, stage_key, scope_kind, created_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
RETURNING id
`, sessionID, uid, role, content, intent, branch, metaJSON, dayKey, category, contentKind, exportPolicy, contentRevision, contentSha256,
nullScopeInt64(wfInstID), nullScopeInt64(stInstID), nullScopeText(stageKey), scopeKind, now).Scan(&messageID)
} else {
err = m.pool.QueryRow(ctx, `
INSERT INTO chat_messages (session_id, user_id, role, content, intent, branch, metadata, day_key, category, content_kind, export_policy, content_revision, content_sha256, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING id
`, sessionID, uid, role, content, intent, branch, metaJSON, dayKey, category, contentKind, exportPolicy, contentRevision, contentSha256, now).Scan(&messageID)
}
if err != nil {
return 0, fmt.Errorf("insert chat message failed: %w", err)
}
rec.ID = messageID
} else {
m.mu.Lock()
rec.ID = m.nextMessageID
messageID = rec.ID
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
}
if meta != nil {
if pVal, ok := meta["project_id"].(int64); ok && pVal > 0 {
sessSum.ProjectID = pVal
} else if pValFloat, ok := meta["project_id"].(float64); ok && pValFloat > 0 {
sessSum.ProjectID = int64(pValFloat)
}
}
sessSum.MessageCount = len(m.messages[sessionID])
sessSum.LastMessage = content
sessSum.UpdatedAt = time.Now()
m.mu.Unlock()
}
// 若为用户输入,触发工作习惯偏好异步更新
if role == "user" && userID > 0 {
_ = m.ReflectUserPreference(ctx, userID, sessionID)
}
return messageID, nil
}
// GetMessageByID 根据真实 ID 查询单条消息记录并填充导出能力
func (m *ContextManager) GetMessageByID(ctx context.Context, messageID int64) (*ChatMessageRecord, error) {
if messageID <= 0 {
return nil, errors.New("invalid message_id")
}
if m.pool != nil {
scoped := m.hasMessageScopeColumns(ctx)
r, err := scanChatMessageRow(m.pool.QueryRow(ctx, `
SELECT `+m.chatMessageSelectColumns(ctx)+`
FROM chat_messages
WHERE id = $1 AND deleted_at IS NULL
`, messageID), scoped)
if err != nil {
return nil, err
}
return &r, nil
}
m.mu.RLock()
defer m.mu.RUnlock()
for _, msgs := range m.messages {
for _, msg := range msgs {
if msg.ID == messageID {
cp := msg
enrichMessageExportCapability(&cp)
return &cp, nil
}
}
}
return nil, errors.New("message not found")
}
// 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. 获取近期会话(FIND-SEC-01 防御纵深:SQL 层限定会话归属目标用户,
// 即使未来新增调用方遗漏 handler 校验也不会泄露他人会话正文)
if sessionID != "" {
if m.pool != nil {
rows, err := m.pool.Query(ctx, `
SELECT id, session_id, COALESCE(user_id, 0), role, content, intent, branch, metadata, created_at
FROM chat_messages
WHERE session_id = $1
AND EXISTS (
SELECT 1 FROM chat_sessions cs
WHERE cs.session_id = $1 AND COALESCE(cs.user_id, 0) = $2
)
ORDER BY created_at ASC LIMIT 30
`, sessionID, userID)
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)
}
}
if err := rows.Err(); err != nil {
slog.Warn("ctxmgr_recent_chats_scan_incomplete", "error", err)
}
}
} else {
m.mu.RLock()
if sess, ok := m.sessions[sessionID]; ok && sess.UserID == userID {
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)
}
}
if err := rows.Err(); err != nil {
slog.Warn("ctxmgr_user_activities_scan_incomplete", "error", err)
}
}
} 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, actWf string
var creatorID int64
var wfVer int
_ = m.pool.QueryRow(ctx, `
SELECT code, name, current_stage, COALESCE(creator_user_id, 1),
COALESCE(active_workflow_key, 'main'), COALESCE(workflow_selection_version, 1)
FROM projects WHERE id = $1
`, projectID).Scan(&pCode, &pName, &cStage, &creatorID, &actWf, &wfVer)
pCtx.ProjectCode = pCode
pCtx.ProjectName = pName
pCtx.CurrentStage = cStage
pCtx.CreatorUserID = creatorID
pCtx.ActiveWorkflowKey = actWf
pCtx.WorkflowSelectionVersion = wfVer
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)
}
}
}
if err := rows.Err(); err != nil {
slog.Warn("ctxmgr_project_activities_scan_incomplete", "error", err)
}
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
}
// UpdateProjectBlackboard 更新并持久化项目事实黑板数据
func (m *ContextManager) UpdateProjectBlackboard(ctx context.Context, projectID int64, facts map[string]string) error {
if projectID <= 0 {
return errors.New("invalid projectID")
}
m.mu.Lock()
proj, ok := m.projects[projectID]
if !ok {
proj = &ProjectCollectiveContext{
ProjectID: projectID,
BlackboardFacts: make(map[string]string),
}
m.projects[projectID] = proj
}
if proj.BlackboardFacts == nil {
proj.BlackboardFacts = make(map[string]string)
}
for k, v := range facts {
proj.BlackboardFacts[k] = v
}
m.mu.Unlock()
if m.pool != nil {
factsJSON, _ := json.Marshal(facts)
_, err := m.pool.Exec(ctx, `
INSERT INTO project_blackboards (project_id, facts, version, updated_at)
VALUES ($1, $2, 1, now())
ON CONFLICT (project_id) DO UPDATE SET
facts = project_blackboards.facts || EXCLUDED.facts,
version = project_blackboards.version + 1,
updated_at = now()
`, projectID, factsJSON)
if err != nil {
return fmt.Errorf("upsert project blackboard failed: %w", err)
}
}
m.Cache().InvalidateLayer(LayerC4Context)
return nil
}
// UpdateProjectStage 更新项目当前成熟度阶段
func (m *ContextManager) UpdateProjectStage(ctx context.Context, projectID int64, stage string) error {
if projectID <= 0 || stage == "" {
return errors.New("invalid projectID or stage")
}
m.mu.Lock()
if proj, ok := m.projects[projectID]; ok {
proj.CurrentStage = stage
} else {
m.projects[projectID] = &ProjectCollectiveContext{
ProjectID: projectID,
CurrentStage: stage,
BlackboardFacts: make(map[string]string),
}
}
m.mu.Unlock()
if m.pool != nil {
_, err := m.pool.Exec(ctx, `
UPDATE projects
SET current_stage = $2, updated_at = now()
WHERE id = $1
`, projectID, stage)
if err != nil {
return fmt.Errorf("update project stage failed: %w", err)
}
}
return 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)
}
return m.BuildBlendedSquadContextFromProject(ctx, projCtx, triggerUserID, taskKey, role)
}
// BuildBlendedSquadContextFromProject 基于已过滤的项目上下文装配混合 Agent 上下文(供 ABAC gate 注入后使用)。
func (m *ContextManager) BuildBlendedSquadContextFromProject(ctx context.Context, projCtx *ProjectCollectiveContext, triggerUserID int64, taskKey, role string) (*BlendedSquadContext, error) {
if projCtx == nil {
return nil, nil
}
userPref, _ := m.GetUserPreference(ctx, triggerUserID)
guidance := ""
if m.reflector != nil {
guidance = m.reflector.GeneratePromptGuidance(userPref)
}
decisions := make([]string, 0, len(projCtx.MilestoneDecisions))
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
}
// CompilePromptGuidance 编译指定用户的个性化偏好指导语 (供对话 LLM 或智能体 Prompt 注入)
func (m *ContextManager) CompilePromptGuidance(ctx context.Context, userID int64) (string, error) {
if userID <= 0 {
return "", errors.New("invalid userID")
}
pref, err := m.GetUserPreference(ctx, userID)
if err != nil {
return "", err
}
if pref == nil || m.reflector == nil {
return "", nil
}
return m.reflector.GeneratePromptGuidance(pref), 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)
}
}
if err := rows.Err(); err != nil {
slog.Warn("ctxmgr_reflect_msgs_scan_incomplete", "error", err)
}
}
} 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)
}