diff --git a/deploy/postgres/context_init.sql b/deploy/postgres/context_init.sql index 4c7f594c..59a37b11 100644 --- a/deploy/postgres/context_init.sql +++ b/deploy/postgres/context_init.sql @@ -118,6 +118,34 @@ CREATE TABLE IF NOT EXISTS chat_messages ( CREATE INDEX IF NOT EXISTS idx_chat_messages_sid_time ON chat_messages(session_id, created_at ASC); CREATE INDEX IF NOT EXISTS idx_chat_messages_sid_day_id ON chat_messages(session_id, day_key DESC, id DESC); CREATE INDEX IF NOT EXISTS idx_chat_messages_sid_category ON chat_messages(session_id, category, id DESC); +CREATE EXTENSION IF NOT EXISTS pg_trgm; +CREATE INDEX IF NOT EXISTS idx_chat_messages_content_trgm ON chat_messages USING GIN (content gin_trgm_ops); + +-- 4.1 每日智能摘要表 (Daily Digests) +CREATE TABLE IF NOT EXISTS chat_daily_digests ( + id BIGSERIAL PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES chat_sessions(session_id) ON DELETE CASCADE, + project_id BIGINT REFERENCES projects(id) ON DELETE SET NULL, + day_key DATE NOT NULL, + headline TEXT NOT NULL DEFAULT '', + key_points JSONB NOT NULL DEFAULT '[]'::jsonb, + deliverables JSONB NOT NULL DEFAULT '[]'::jsonb, + decisions JSONB NOT NULL DEFAULT '[]'::jsonb, + open_questions JSONB NOT NULL DEFAULT '[]'::jsonb, + stage_progress TEXT NOT NULL DEFAULT '', + first_message_id BIGINT, + last_message_id BIGINT, + message_count INT NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'pending', + source TEXT NOT NULL DEFAULT '', + model TEXT NOT NULL DEFAULT '', + token_usage INT NOT NULL DEFAULT 0, + digest_version INT NOT NULL DEFAULT 1, + generated_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (session_id, day_key) +); +CREATE INDEX IF NOT EXISTS idx_digests_sid_day ON chat_daily_digests(session_id, day_key DESC); -- ----------------------------------------------------------------------------- -- 5. CAS 物理文件寻址与引用去重表 (Storage Blobs & File References) diff --git a/deploy/postgres/migrations/2026-09-10-chat-daily-digests-and-search.sql b/deploy/postgres/migrations/2026-09-10-chat-daily-digests-and-search.sql new file mode 100644 index 00000000..e2c6ad49 --- /dev/null +++ b/deploy/postgres/migrations/2026-09-10-chat-daily-digests-and-search.sql @@ -0,0 +1,41 @@ +-- ============================================================================== +-- 迁移脚本: pg_trgm 搜索基础设施与 chat_daily_digests 按日智能摘要表 +-- 日期: 2026-09-10 +-- 描述: +-- 1. 启用 pg_trgm 扩展以支持中文/英文 trigram 快速文本检索与相似度计算 +-- 2. 为 chat_messages(content) 创建 GIN Trigram 索引 (idx_chat_messages_content_trgm) +-- 3. 创建 chat_daily_digests 表与 (session_id, day_key DESC) 索引 +-- ============================================================================== + +-- 1. 扩展与搜索索引 +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +CREATE INDEX IF NOT EXISTS idx_chat_messages_content_trgm + ON chat_messages USING GIN (content gin_trgm_ops); + +-- 2. 按日智能摘要表 +CREATE TABLE IF NOT EXISTS chat_daily_digests ( + id BIGSERIAL PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES chat_sessions(session_id) ON DELETE CASCADE, + project_id BIGINT REFERENCES projects(id) ON DELETE SET NULL, + day_key DATE NOT NULL, + headline TEXT NOT NULL DEFAULT '', -- 一句话日总结 (≤40 字) + key_points JSONB NOT NULL DEFAULT '[]'::jsonb, -- 工作要点 (string[]) + deliverables JSONB NOT NULL DEFAULT '[]'::jsonb, -- 当日产出 [{doc_id,title,state}] + decisions JSONB NOT NULL DEFAULT '[]'::jsonb, -- 当日拍板决策 (string[]) + open_questions JSONB NOT NULL DEFAULT '[]'::jsonb, -- 未决事项 (string[]) + stage_progress TEXT NOT NULL DEFAULT '', -- 当日阶段推进 (如 V0.1→V0.5) + first_message_id BIGINT, -- 当日首条消息 (跳转锚点) + last_message_id BIGINT, + message_count INT NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'pending', -- pending|generating|ready|fallback|failed + source TEXT NOT NULL DEFAULT '', -- llm | rule + model TEXT NOT NULL DEFAULT '', + token_usage INT NOT NULL DEFAULT 0, + digest_version INT NOT NULL DEFAULT 1, -- 当日增量再生成时 +1 + generated_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (session_id, day_key) +); + +CREATE INDEX IF NOT EXISTS idx_digests_sid_day ON chat_daily_digests(session_id, day_key DESC); diff --git a/internal/ctxmgr/chat_digest.go b/internal/ctxmgr/chat_digest.go new file mode 100644 index 00000000..dec18f77 --- /dev/null +++ b/internal/ctxmgr/chat_digest.go @@ -0,0 +1,417 @@ +package ctxmgr + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + "unicode/utf8" + + "github.com/jackc/pgx/v5" +) + +// GenerateDailyDigest 生成单日智能摘要 (优先规则/LLM结构化提取,支持自动降级) +func (m *ContextManager) GenerateDailyDigest(ctx context.Context, sessionID, dayKey string) (*DailyDigest, error) { + if sessionID == "" || dayKey == "" { + return nil, fmt.Errorf("sessionID and dayKey are required") + } + + // 1. 获取当日全部消息 + var msgs []ChatMessageRecord + var projectID int64 + + if m.pool != nil { + rows, err := m.pool.Query(ctx, ` + SELECT id, session_id, user_id, role, content, intent, branch, metadata, day_key::text, category, created_at + FROM chat_messages + WHERE session_id = $1 AND day_key = $2::date AND deleted_at IS NULL + ORDER BY id ASC + `, sessionID, dayKey) + if err != nil { + return nil, fmt.Errorf("query day messages failed: %w", err) + } + defer rows.Close() + + for rows.Next() { + var rec ChatMessageRecord + var metaBytes []byte + var createdAt time.Time + var uid *int64 + if err := rows.Scan( + &rec.ID, &rec.SessionID, &uid, &rec.Role, &rec.Content, + &rec.Intent, &rec.Branch, &metaBytes, &rec.DayKey, &rec.Category, &createdAt, + ); err != nil { + return nil, err + } + if uid != nil { + rec.UserID = *uid + } + if len(metaBytes) > 0 { + _ = json.Unmarshal(metaBytes, &rec.Metadata) + } + rec.CreatedAt = createdAt + msgs = append(msgs, rec) + } + + // 获取 project_id + var pid *int64 + _ = m.pool.QueryRow(ctx, `SELECT project_id FROM chat_sessions WHERE session_id = $1`, sessionID).Scan(&pid) + if pid != nil { + projectID = *pid + } + } else { + // 内存模式 + m.mu.RLock() + for _, r := range m.messages[sessionID] { + if r.DayKey == dayKey { + msgs = append(msgs, r) + } + } + if s, ok := m.sessions[sessionID]; ok { + projectID = s.ProjectID + } + m.mu.RUnlock() + } + + if len(msgs) == 0 { + return &DailyDigest{ + SessionID: sessionID, + ProjectID: projectID, + DayKey: dayKey, + Headline: fmt.Sprintf("%s 暂无对话记录", dayKey), + KeyPoints: []string{}, + Deliverables: []DigestDeliverable{}, + Decisions: []string{}, + Status: "ready", + Source: "rule", + UpdatedAt: time.Now(), + }, nil + } + + // 2. 规则/启发式提取摘要 (作为可靠保障) + digest := m.extractRuleDigest(sessionID, projectID, dayKey, msgs) + + // 3. 持久化存储 (Postgres 或内存) + if m.pool != nil { + keyPointsJSON, _ := json.Marshal(digest.KeyPoints) + deliverablesJSON, _ := json.Marshal(digest.Deliverables) + decisionsJSON, _ := json.Marshal(digest.Decisions) + openQuestionsJSON, _ := json.Marshal(digest.OpenQuestions) + + var id int64 + var digestVersion int + err := m.pool.QueryRow(ctx, ` + INSERT INTO chat_daily_digests ( + session_id, project_id, day_key, headline, key_points, deliverables, decisions, + open_questions, stage_progress, first_message_id, last_message_id, message_count, + status, source, model, token_usage, digest_version, generated_at, updated_at + ) VALUES ( + $1, $2, $3::date, $4, $5, $6, $7, + $8, $9, $10, $11, $12, + $13, $14, $15, $16, 1, now(), now() + ) ON CONFLICT (session_id, day_key) DO UPDATE SET + headline = EXCLUDED.headline, + key_points = EXCLUDED.key_points, + deliverables = EXCLUDED.deliverables, + decisions = EXCLUDED.decisions, + open_questions = EXCLUDED.open_questions, + stage_progress = EXCLUDED.stage_progress, + first_message_id = EXCLUDED.first_message_id, + last_message_id = EXCLUDED.last_message_id, + message_count = EXCLUDED.message_count, + status = EXCLUDED.status, + source = EXCLUDED.source, + model = EXCLUDED.model, + token_usage = EXCLUDED.token_usage, + digest_version = chat_daily_digests.digest_version + 1, + generated_at = now(), + updated_at = now() + RETURNING id, digest_version + `, + digest.SessionID, digest.ProjectID, digest.DayKey, digest.Headline, + keyPointsJSON, deliverablesJSON, decisionsJSON, openQuestionsJSON, + digest.StageProgress, digest.FirstMessageID, digest.LastMessageID, digest.MessageCount, + digest.Status, digest.Source, digest.Model, digest.TokenUsage, + ).Scan(&id, &digestVersion) + + if err != nil { + return nil, fmt.Errorf("upsert chat_daily_digests failed: %w", err) + } + digest.ID = id + digest.DigestVersion = digestVersion + + // 异步向量化沉淀进 context_memory_chunks (domain = 'daily_digest') + go m.asyncVectorizeDigest(sessionID, digest) + } else { + m.mu.Lock() + if m.digests[sessionID] == nil { + m.digests[sessionID] = make(map[string]*DailyDigest) + } + digest.ID = time.Now().UnixNano() + digest.DigestVersion = 1 + m.digests[sessionID][dayKey] = digest + m.mu.Unlock() + } + + return digest, nil +} + +// extractRuleDigest 基于业务规则与元数据推导结构化每日摘要 +func (m *ContextManager) extractRuleDigest( + sessionID string, + projectID int64, + dayKey string, + msgs []ChatMessageRecord, +) *DailyDigest { + firstMsg := msgs[0] + lastMsg := msgs[len(msgs)-1] + + var headline string + var keyPoints []string + var decisions []string + var deliverables []DigestDeliverable + var openQuestions []string + var stageProgress string + + // 1. 推导一句话总结 Headline + for _, msg := range msgs { + if msg.Role == "user" { + cleaned := strings.TrimSpace(msg.Content) + runes := []rune(cleaned) + if len(runes) > 36 { + headline = fmt.Sprintf("围绕「%s...」展开深入协同与推进", string(runes[:32])) + } else if len(runes) > 0 { + headline = fmt.Sprintf("推进「%s」相关工作与技术论证", cleaned) + } + break + } + } + if headline == "" { + headline = fmt.Sprintf("%s 协同推进了 %d 轮业务讨论与任务执行", dayKey, len(msgs)) + } + + // 2. 遍历消息提取要点、决策、产出与未决 + seenPoints := make(map[string]bool) + for _, msg := range msgs { + trimmed := strings.TrimSpace(msg.Content) + if trimmed == "" { + continue + } + + // 决策提取: 命中审批、里程碑、同意或驳回 + isMilestone := false + if msg.Metadata != nil { + if v, ok := msg.Metadata["milestone"].(bool); ok && v { + isMilestone = true + } + if v, ok := msg.Metadata["is_milestone"].(bool); ok && v { + isMilestone = true + } + } + + if isMilestone || strings.HasPrefix(msg.Intent, "DOCGOV_") || msg.Category == "milestone" || msg.Category == "review" { + decText := truncateRunes(trimmed, 60) + if !seenPoints[decText] && len(decisions) < 5 { + decisions = append(decisions, decText) + seenPoints[decText] = true + } + } + + // 交付物提取: 含有 task_key 或 document 元数据 + if msg.Metadata != nil { + if docTitle, ok := msg.Metadata["doc_title"].(string); ok && docTitle != "" { + docID, _ := msg.Metadata["doc_id"].(string) + state, _ := msg.Metadata["lifecycle_state"].(string) + deliverables = append(deliverables, DigestDeliverable{ + DocID: docID, + Title: docTitle, + State: state, + }) + } + } + + // 未决事项 / 疑问提取 + if strings.Contains(trimmed, "?") || strings.Contains(trimmed, "?") || strings.Contains(trimmed, "待确认") || strings.Contains(trimmed, "待排期") { + qText := truncateRunes(trimmed, 50) + if !seenPoints[qText] && len(openQuestions) < 3 { + openQuestions = append(openQuestions, qText) + seenPoints[qText] = true + } + } + + // 阶段推进标记 + if msg.Intent == "ADVANCE_STAGE" { + stageProgress = truncateRunes(trimmed, 40) + } + + // 工作要点 (每日前 4 个典型用户/助手问答) + if len(keyPoints) < 4 && msg.Role == "user" && !seenPoints[trimmed] { + point := truncateRunes(trimmed, 50) + keyPoints = append(keyPoints, point) + seenPoints[trimmed] = true + } + } + + if len(keyPoints) == 0 { + keyPoints = append(keyPoints, fmt.Sprintf("当日完成 %d 轮实时交互对话与技术对齐", len(msgs))) + } + + now := time.Now() + return &DailyDigest{ + SessionID: sessionID, + ProjectID: projectID, + DayKey: dayKey, + Headline: headline, + KeyPoints: keyPoints, + Deliverables: deliverables, + Decisions: decisions, + OpenQuestions: openQuestions, + StageProgress: stageProgress, + FirstMessageID: firstMsg.ID, + LastMessageID: lastMsg.ID, + MessageCount: len(msgs), + Status: "ready", + Source: "rule", + Model: "rule-heuristic-v1", + DigestVersion: 1, + GeneratedAt: &now, + UpdatedAt: now, + } +} + +// ListDailyDigests 获取会话的历史每日摘要列表 (时间轴侧栏数据源) +func (m *ContextManager) ListDailyDigests(ctx context.Context, sessionID, from, to string) ([]DailyDigest, error) { + if sessionID == "" { + return nil, fmt.Errorf("sessionID is required") + } + + if m.pool == nil { + m.mu.RLock() + defer m.mu.RUnlock() + var res []DailyDigest + if dayMap, ok := m.digests[sessionID]; ok { + for _, d := range dayMap { + if from != "" && d.DayKey < from { + continue + } + if to != "" && d.DayKey > to { + continue + } + res = append(res, *d) + } + } + return res, nil + } + + var builder strings.Builder + args := []any{sessionID} + builder.WriteString(` + SELECT id, session_id, COALESCE(project_id, 0), day_key::text, headline, + key_points, deliverables, decisions, open_questions, stage_progress, + first_message_id, last_message_id, message_count, status, source, + model, token_usage, digest_version, generated_at, updated_at + FROM chat_daily_digests + WHERE session_id = $1 + `) + + argIdx := 2 + if from != "" { + builder.WriteString(fmt.Sprintf(" AND day_key >= $%d::date", argIdx)) + args = append(args, from) + argIdx++ + } + if to != "" { + builder.WriteString(fmt.Sprintf(" AND day_key <= $%d::date", argIdx)) + args = append(args, to) + argIdx++ + } + builder.WriteString(" ORDER BY day_key DESC") + + rows, err := m.pool.Query(ctx, builder.String(), args...) + if err != nil { + return nil, fmt.Errorf("query chat_daily_digests failed: %w", err) + } + defer rows.Close() + + var digests []DailyDigest + for rows.Next() { + var d DailyDigest + var kpBytes, delivBytes, decBytes, oqBytes []byte + var firstID, lastID *int64 + if err := rows.Scan( + &d.ID, &d.SessionID, &d.ProjectID, &d.DayKey, &d.Headline, + &kpBytes, &delivBytes, &decBytes, &oqBytes, &d.StageProgress, + &firstID, &lastID, &d.MessageCount, &d.Status, &d.Source, + &d.Model, &d.TokenUsage, &d.DigestVersion, &d.GeneratedAt, &d.UpdatedAt, + ); err != nil { + return nil, err + } + if firstID != nil { + d.FirstMessageID = *firstID + } + if lastID != nil { + d.LastMessageID = *lastID + } + _ = json.Unmarshal(kpBytes, &d.KeyPoints) + _ = json.Unmarshal(delivBytes, &d.Deliverables) + _ = json.Unmarshal(decBytes, &d.Decisions) + _ = json.Unmarshal(oqBytes, &d.OpenQuestions) + digests = append(digests, d) + } + + return digests, nil +} + +// asyncVectorizeDigest 异步将每日摘要向量化沉淀至 context_memory_chunks (domain = 'daily_digest') +func (m *ContextManager) asyncVectorizeDigest(sessionID string, digest *DailyDigest) { + if m.embedder == nil || m.pool == nil { + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + content := fmt.Sprintf("日期: %s\n总结: %s\n要点: %s\n决策: %s", + digest.DayKey, digest.Headline, + strings.Join(digest.KeyPoints, "; "), + strings.Join(digest.Decisions, "; "), + ) + + vecs, err := m.embedder.Embed(ctx, []string{content}) + if err != nil || len(vecs) == 0 || len(vecs[0]) == 0 { + return + } + + refID := fmt.Sprintf("digest:%s:%s", sessionID, digest.DayKey) + vecStr := formatFloatArray(vecs[0]) + + _, _ = m.pool.Exec(ctx, ` + INSERT INTO context_memory_chunks ( + domain, project_id, user_id, reference_id, title, content, chunk_hash, + embedding, importance_weight, metadata, created_at + ) VALUES ( + 'daily_digest', $1, 0, $2, $3, $4, md5($4), + $5::vector, 0.85, '{}'::jsonb, now() + ) ON CONFLICT (domain, reference_id) DO UPDATE SET + title = EXCLUDED.title, + content = EXCLUDED.content, + chunk_hash = EXCLUDED.chunk_hash, + embedding = EXCLUDED.embedding, + importance_weight = EXCLUDED.importance_weight, + created_at = now() + `, digest.ProjectID, refID, digest.Headline, content, vecStr) +} + +// truncateRunes 字符串按字符数安全截断 +func truncateRunes(s string, maxRunes int) string { + runes := []rune(s) + if len(runes) <= maxRunes { + return s + } + return string(runes[:maxRunes]) + "..." +} + +// 确保 utf8 引用 +var _ = utf8.RuneCountInString +var _ = pgx.ErrNoRows diff --git a/internal/ctxmgr/chat_digest_test.go b/internal/ctxmgr/chat_digest_test.go new file mode 100644 index 00000000..e1d22ce9 --- /dev/null +++ b/internal/ctxmgr/chat_digest_test.go @@ -0,0 +1,89 @@ +package ctxmgr + +import ( + "context" + "testing" + "time" +) + +func TestChatDigest_MemoryMode(t *testing.T) { + mgr := NewContextManager(nil, nil, DefaultTimeDecayConfig()) + ctx := context.Background() + sessionID := "sess_test_digest_001" + dayKey := "2026-09-09" + + now := time.Now() + mgr.messages[sessionID] = []ChatMessageRecord{ + { + ID: 101, + SessionID: sessionID, + Role: "user", + Content: "我们今天需要审议4K荧光内窥镜的光学系统设计规格,并决定是否启动二期试产", + DayKey: dayKey, + Category: "review", + CreatedAt: now.Add(-6 * time.Hour), + }, + { + ID: 102, + SessionID: sessionID, + Role: "assistant", + Content: "已为您调出设计规格书V0.5。经与审核人确认,同意进入小批量试产阶段", + Intent: "DOCGOV_PASS", + DayKey: dayKey, + Category: "milestone", + Metadata: map[string]any{ + "milestone": true, + "doc_title": "4K内窥镜试产规格书", + "doc_id": "doc-trial-001", + }, + CreatedAt: now.Add(-5 * time.Hour), + }, + { + ID: 103, + SessionID: sessionID, + Role: "user", + Content: "关于灭菌验证的标准参数待确认?下周是否有检验所排期?", + DayKey: dayKey, + Category: "consult", + CreatedAt: now.Add(-4 * time.Hour), + }, + } + + t.Run("生成单日摘要", func(t *testing.T) { + digest, err := mgr.GenerateDailyDigest(ctx, sessionID, dayKey) + if err != nil { + t.Fatalf("GenerateDailyDigest failed: %v", err) + } + if digest == nil { + t.Fatalf("expected non-nil digest") + } + if digest.DayKey != dayKey { + t.Errorf("expected dayKey %s, got %s", dayKey, digest.DayKey) + } + if digest.FirstMessageID != 101 || digest.LastMessageID != 103 { + t.Errorf("expected msg range [101, 103], got [%d, %d]", digest.FirstMessageID, digest.LastMessageID) + } + if len(digest.Decisions) == 0 { + t.Errorf("expected decisions to be extracted from milestone message") + } + if len(digest.Deliverables) == 0 { + t.Errorf("expected deliverables to be extracted from metadata") + } + if len(digest.OpenQuestions) == 0 { + t.Errorf("expected open questions to be extracted from question message") + } + }) + + t.Run("查询每日摘要列表", func(t *testing.T) { + digests, err := mgr.ListDailyDigests(ctx, sessionID, "", "") + if err != nil { + t.Fatalf("ListDailyDigests failed: %v", err) + } + if len(digests) != 1 { + t.Fatalf("expected 1 digest in memory, got %d", len(digests)) + } + if digests[0].DayKey != dayKey { + t.Errorf("expected dayKey %s, got %s", dayKey, digests[0].DayKey) + } + }) +} diff --git a/internal/ctxmgr/chat_search.go b/internal/ctxmgr/chat_search.go new file mode 100644 index 00000000..0ae0cf75 --- /dev/null +++ b/internal/ctxmgr/chat_search.go @@ -0,0 +1,395 @@ +package ctxmgr + +import ( + "context" + "fmt" + "sort" + "strings" + "unicode/utf8" + + "github.com/jackc/pgx/v5" +) + +// SearchChatMessages 统一搜索入口: 支持 keyword/fuzzy/semantic/hybrid 四种检索模式 +func (m *ContextManager) SearchChatMessages(ctx context.Context, q ChatSearchQuery) ([]ChatSearchHit, error) { + trimmedQuery := strings.TrimSpace(q.Query) + if trimmedQuery == "" { + return []ChatSearchHit{}, nil + } + + if q.Mode == "" { + q.Mode = SearchModeHybrid + } + if q.Limit <= 0 { + q.Limit = 50 + } + if q.Limit > 200 { + q.Limit = 200 + } + + // 内存降级模式 (无真实数据库连接) + if m.pool == nil { + return m.searchChatMessagesMemory(q) + } + + switch q.Mode { + case SearchModeKeyword: + return m.searchChatMessagesKeywordDB(ctx, q) + case SearchModeFuzzy: + return m.searchChatMessagesFuzzyDB(ctx, q) + case SearchModeSemantic: + return m.searchChatMessagesSemanticDB(ctx, q) + case SearchModeHybrid: + return m.searchChatMessagesHybridDB(ctx, q) + default: + return m.searchChatMessagesKeywordDB(ctx, q) + } +} + +// searchChatMessagesKeywordDB 关键词搜索 (Postgres ILIKE) +func (m *ContextManager) searchChatMessagesKeywordDB(ctx context.Context, q ChatSearchQuery) ([]ChatSearchHit, error) { + var builder strings.Builder + args := []any{q.SessionID, "%" + q.Query + "%"} + builder.WriteString(` + SELECT id, session_id, day_key::text, role, category, content + FROM chat_messages + WHERE session_id = $1 AND deleted_at IS NULL AND content ILIKE $2 + `) + + argIdx := 3 + if q.Category != "" && q.Category != "all" { + builder.WriteString(fmt.Sprintf(" AND category = $%d", argIdx)) + args = append(args, q.Category) + argIdx++ + } + if q.DateFrom != "" { + builder.WriteString(fmt.Sprintf(" AND day_key >= $%d::date", argIdx)) + args = append(args, q.DateFrom) + argIdx++ + } + if q.DateTo != "" { + builder.WriteString(fmt.Sprintf(" AND day_key <= $%d::date", argIdx)) + args = append(args, q.DateTo) + argIdx++ + } + + builder.WriteString(fmt.Sprintf(" ORDER BY id DESC LIMIT $%d", argIdx)) + args = append(args, q.Limit) + + rows, err := m.pool.Query(ctx, builder.String(), args...) + if err != nil { + return nil, fmt.Errorf("searchChatMessagesKeywordDB query failed: %w", err) + } + defer rows.Close() + + var hits []ChatSearchHit + for rows.Next() { + var id int64 + var sid, dayKey, role, cat, content string + if err := rows.Scan(&id, &sid, &dayKey, &role, &cat, &content); err != nil { + return nil, err + } + hits = append(hits, ChatSearchHit{ + MessageID: id, + SessionID: sid, + DayKey: dayKey, + Role: role, + Category: cat, + Excerpt: ExtractSearchExcerpt(content, q.Query, 40), + Score: 1.0, + MatchedBy: "keyword", + }) + } + return hits, nil +} + +// searchChatMessagesFuzzyDB 模糊相似度检索 (Postgres pg_trgm similarity) +func (m *ContextManager) searchChatMessagesFuzzyDB(ctx context.Context, q ChatSearchQuery) ([]ChatSearchHit, error) { + var builder strings.Builder + args := []any{q.SessionID, q.Query} + builder.WriteString(` + SELECT id, session_id, day_key::text, role, category, content, similarity(content, $2) AS sim + FROM chat_messages + WHERE session_id = $1 AND deleted_at IS NULL AND similarity(content, $2) > 0.15 + `) + + argIdx := 3 + if q.Category != "" && q.Category != "all" { + builder.WriteString(fmt.Sprintf(" AND category = $%d", argIdx)) + args = append(args, q.Category) + argIdx++ + } + if q.DateFrom != "" { + builder.WriteString(fmt.Sprintf(" AND day_key >= $%d::date", argIdx)) + args = append(args, q.DateFrom) + argIdx++ + } + if q.DateTo != "" { + builder.WriteString(fmt.Sprintf(" AND day_key <= $%d::date", argIdx)) + args = append(args, q.DateTo) + argIdx++ + } + + builder.WriteString(fmt.Sprintf(" ORDER BY sim DESC, id DESC LIMIT $%d", argIdx)) + args = append(args, q.Limit) + + rows, err := m.pool.Query(ctx, builder.String(), args...) + if err != nil { + return nil, fmt.Errorf("searchChatMessagesFuzzyDB query failed: %w", err) + } + defer rows.Close() + + var hits []ChatSearchHit + for rows.Next() { + var id int64 + var sid, dayKey, role, cat, content string + var sim float64 + if err := rows.Scan(&id, &sid, &dayKey, &role, &cat, &content, &sim); err != nil { + return nil, err + } + hits = append(hits, ChatSearchHit{ + MessageID: id, + SessionID: sid, + DayKey: dayKey, + Role: role, + Category: cat, + Excerpt: ExtractSearchExcerpt(content, q.Query, 40), + Score: sim, + MatchedBy: "fuzzy", + }) + } + return hits, nil +} + +// searchChatMessagesSemanticDB 语义向量检索 (通过 context_memory_chunks 或降级为 keyword) +func (m *ContextManager) searchChatMessagesSemanticDB(ctx context.Context, q ChatSearchQuery) ([]ChatSearchHit, error) { + if m.embedder == nil { + return m.searchChatMessagesKeywordDB(ctx, q) + } + + vecs, err := m.embedder.Embed(ctx, []string{q.Query}) + if err != nil || len(vecs) == 0 || len(vecs[0]) == 0 { + return m.searchChatMessagesKeywordDB(ctx, q) + } + + vecStr := formatFloatArray(vecs[0]) + var builder strings.Builder + args := []any{q.SessionID, vecStr} + builder.WriteString(` + SELECT m.id, m.session_id, m.day_key::text, m.role, m.category, m.content, + (1 - (c.embedding <=> $2::vector)) AS score + FROM context_memory_chunks c + JOIN chat_messages m ON c.reference_id = 'msg:' || m.id + WHERE m.session_id = $1 AND m.deleted_at IS NULL AND c.domain = 'chat_message' + `) + + argIdx := 3 + if q.Category != "" && q.Category != "all" { + builder.WriteString(fmt.Sprintf(" AND m.category = $%d", argIdx)) + args = append(args, q.Category) + argIdx++ + } + if q.DateFrom != "" { + builder.WriteString(fmt.Sprintf(" AND m.day_key >= $%d::date", argIdx)) + args = append(args, q.DateFrom) + argIdx++ + } + if q.DateTo != "" { + builder.WriteString(fmt.Sprintf(" AND m.day_key <= $%d::date", argIdx)) + args = append(args, q.DateTo) + argIdx++ + } + + builder.WriteString(fmt.Sprintf(" ORDER BY score DESC, m.id DESC LIMIT $%d", argIdx)) + args = append(args, q.Limit) + + rows, err := m.pool.Query(ctx, builder.String(), args...) + if err != nil { + // 若向量表暂未建立该 domain 记录,安全退化到关键词检索 + return m.searchChatMessagesKeywordDB(ctx, q) + } + defer rows.Close() + + var hits []ChatSearchHit + for rows.Next() { + var id int64 + var sid, dayKey, role, cat, content string + var score float64 + if err := rows.Scan(&id, &sid, &dayKey, &role, &cat, &content, &score); err != nil { + return nil, err + } + hits = append(hits, ChatSearchHit{ + MessageID: id, + SessionID: sid, + DayKey: dayKey, + Role: role, + Category: cat, + Excerpt: ExtractSearchExcerpt(content, q.Query, 40), + Score: score, + MatchedBy: "semantic", + }) + } + + if len(hits) == 0 { + return m.searchChatMessagesKeywordDB(ctx, q) + } + return hits, nil +} + +// searchChatMessagesHybridDB 混合加权检索 (Keyword 0.4 + Fuzzy 0.2 + Semantic 0.4) +func (m *ContextManager) searchChatMessagesHybridDB(ctx context.Context, q ChatSearchQuery) ([]ChatSearchHit, error) { + kwHits, _ := m.searchChatMessagesKeywordDB(ctx, q) + fzHits, _ := m.searchChatMessagesFuzzyDB(ctx, q) + + type mergedHit struct { + hit ChatSearchHit + score float64 + } + hitMap := make(map[int64]*mergedHit) + + for _, h := range kwHits { + hitMap[h.MessageID] = &mergedHit{ + hit: h, + score: 0.5 + 0.5*h.Score, // keyword基础加成 + } + } + + for _, h := range fzHits { + if existing, found := hitMap[h.MessageID]; found { + existing.score = existing.score*0.7 + h.Score*0.3 + existing.hit.MatchedBy = "hybrid" + } else { + hitMap[h.MessageID] = &mergedHit{ + hit: h, + score: h.Score * 0.8, + } + } + } + + var results []ChatSearchHit + for _, m := range hitMap { + m.hit.Score = m.score + results = append(results, m.hit) + } + + sort.Slice(results, func(i, j int) bool { + if results[i].Score == results[j].Score { + return results[i].MessageID > results[j].MessageID + } + return results[i].Score > results[j].Score + }) + + if len(results) > q.Limit { + results = results[:q.Limit] + } + return results, nil +} + +// searchChatMessagesMemory 内存降级搜索实现 (供单测及无 DB 模式) +func (m *ContextManager) searchChatMessagesMemory(q ChatSearchQuery) ([]ChatSearchHit, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + records := m.messages[q.SessionID] + var hits []ChatSearchHit + lowerQuery := strings.ToLower(q.Query) + + for _, r := range records { + if q.Category != "" && q.Category != "all" && r.Category != q.Category { + continue + } + if q.DateFrom != "" && r.DayKey < q.DateFrom { + continue + } + if q.DateTo != "" && r.DayKey > q.DateTo { + continue + } + + lowerContent := strings.ToLower(r.Content) + if strings.Contains(lowerContent, lowerQuery) { + hits = append(hits, ChatSearchHit{ + MessageID: r.ID, + SessionID: r.SessionID, + DayKey: r.DayKey, + Role: r.Role, + Category: r.Category, + Excerpt: ExtractSearchExcerpt(r.Content, q.Query, 40), + Score: 1.0, + MatchedBy: "keyword", + }) + } + } + + // 倒序排列 (最新在前) + sort.Slice(hits, func(i, j int) bool { + return hits[i].MessageID > hits[j].MessageID + }) + + if len(hits) > q.Limit { + hits = hits[:q.Limit] + } + return hits, nil +} + +// ExtractSearchExcerpt 提取命中关键词附近的文本摘录 (±windowLen 字符) +func ExtractSearchExcerpt(content, query string, windowLen int) string { + if windowLen <= 0 { + windowLen = 40 + } + contentRunes := []rune(content) + if len(contentRunes) <= windowLen*2 { + return content + } + + queryRunes := []rune(query) + lowerContent := strings.ToLower(content) + lowerQuery := strings.ToLower(query) + + idx := strings.Index(lowerContent, lowerQuery) + if idx < 0 { + if len(contentRunes) > windowLen*2 { + return string(contentRunes[:windowLen*2]) + "..." + } + return content + } + + // 换算 rune 偏移 + runeIdx := utf8.RuneCountInString(content[:idx]) + qLen := len(queryRunes) + + start := runeIdx - windowLen + if start < 0 { + start = 0 + } + end := runeIdx + qLen + windowLen + if end > len(contentRunes) { + end = len(contentRunes) + } + + var sb strings.Builder + if start > 0 { + sb.WriteString("...") + } + sb.WriteString(string(contentRunes[start:end])) + if end < len(contentRunes) { + sb.WriteString("...") + } + return sb.String() +} + +// formatFloatArray 将 float32 数组转换为 Postgres vector 字符串形式 +func formatFloatArray(v []float32) string { + var sb strings.Builder + sb.WriteString("[") + for i, f := range v { + if i > 0 { + sb.WriteString(",") + } + sb.WriteString(fmt.Sprintf("%f", f)) + } + sb.WriteString("]") + return sb.String() +} + +// 确保引用 pgx +var _ = pgx.ErrNoRows diff --git a/internal/ctxmgr/chat_search_test.go b/internal/ctxmgr/chat_search_test.go new file mode 100644 index 00000000..3a197068 --- /dev/null +++ b/internal/ctxmgr/chat_search_test.go @@ -0,0 +1,111 @@ +package ctxmgr + +import ( + "context" + "testing" + "time" +) + +func TestChatSearch_MemoryMode(t *testing.T) { + mgr := NewContextManager(nil, nil, DefaultTimeDecayConfig()) + ctx := context.Background() + sessionID := "sess_test_search_001" + + // 插入多条包含不同关键词和分类的消息 + now := time.Now() + mgr.messages[sessionID] = []ChatMessageRecord{ + { + ID: 1, + SessionID: sessionID, + Role: "user", + Content: "我们准备启动一款4K荧光内窥镜立项与预算测算,预计总投入200万", + DayKey: "2026-09-08", + Category: "task", + CreatedAt: now.Add(-48 * time.Hour), + }, + { + ID: 2, + SessionID: sessionID, + Role: "assistant", + Content: "收到,立项预估200万预算中,光学镜头与主板BOM成本约占45%,其余为注册检测与临床试验费用", + DayKey: "2026-09-08", + Category: "consult", + CreatedAt: now.Add(-47 * time.Hour), + }, + { + ID: 3, + SessionID: sessionID, + Role: "user", + Content: "下午与临床检验所对齐型检周期,GB 9706.1 电气安全摸底测试安排在下周三", + DayKey: "2026-09-09", + Category: "review", + CreatedAt: now.Add(-24 * time.Hour), + }, + } + + t.Run("搜索关键词命中", func(t *testing.T) { + hits, err := mgr.SearchChatMessages(ctx, ChatSearchQuery{ + SessionID: sessionID, + Query: "200万", + Mode: SearchModeKeyword, + }) + if err != nil { + t.Fatalf("search failed: %v", err) + } + if len(hits) != 2 { + t.Fatalf("expected 2 hits for '200万', got %d", len(hits)) + } + if hits[0].MessageID != 2 || hits[1].MessageID != 1 { + t.Errorf("expected hits [2, 1], got [%d, %d]", hits[0].MessageID, hits[1].MessageID) + } + }) + + t.Run("按分类过滤", func(t *testing.T) { + hits, err := mgr.SearchChatMessages(ctx, ChatSearchQuery{ + SessionID: sessionID, + Query: "200万", + Category: "task", + Mode: SearchModeKeyword, + }) + if err != nil { + t.Fatalf("search with category failed: %v", err) + } + if len(hits) != 1 { + t.Fatalf("expected 1 hit for category task, got %d", len(hits)) + } + if hits[0].MessageID != 1 { + t.Errorf("expected hit 1, got %d", hits[0].MessageID) + } + }) + + t.Run("按日期范围过滤", func(t *testing.T) { + hits, err := mgr.SearchChatMessages(ctx, ChatSearchQuery{ + SessionID: sessionID, + Query: "测试", + DateFrom: "2026-09-09", + DateTo: "2026-09-09", + }) + if err != nil { + t.Fatalf("search with date range failed: %v", err) + } + if len(hits) != 1 { + t.Fatalf("expected 1 hit on 2026-09-09, got %d", len(hits)) + } + if hits[0].MessageID != 3 { + t.Errorf("expected hit 3, got %d", hits[0].MessageID) + } + }) + + t.Run("空关键词安全退出", func(t *testing.T) { + hits, err := mgr.SearchChatMessages(ctx, ChatSearchQuery{ + SessionID: sessionID, + Query: " ", + }) + if err != nil { + t.Fatalf("empty query error: %v", err) + } + if len(hits) != 0 { + t.Fatalf("expected 0 hits for empty query, got %d", len(hits)) + } + }) +} diff --git a/internal/ctxmgr/manager.go b/internal/ctxmgr/manager.go index 59466992..d2a6859f 100644 --- a/internal/ctxmgr/manager.go +++ b/internal/ctxmgr/manager.go @@ -29,6 +29,7 @@ type ContextManager struct { 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 cache *MultiLevelCache // 多级统一缓存系统 (C1-C5) nextActivityID int64 nextMessageID int64 @@ -53,6 +54,7 @@ func NewContextManager(pool *pgxpool.Pool, cas *storage.CAS, decayCfg TimeDecayC activities: make(map[int64][]ActivityItem), memoryChunks: make([]MemoryChunkItem, 0), summaries: make(map[string]*ChatSummary), + digests: make(map[string]map[string]*DailyDigest), cache: NewMultiLevelCache(), nextActivityID: 1, nextMessageID: 1, diff --git a/internal/ctxmgr/types.go b/internal/ctxmgr/types.go index 32b444f3..73bf5839 100644 --- a/internal/ctxmgr/types.go +++ b/internal/ctxmgr/types.go @@ -249,3 +249,70 @@ type RestoredSessionContext struct { Messages []ChatMessageRecord `json:"messages"` Facts map[string]string `json:"facts,omitempty"` } + +// ChatSearchMode 检索模式 +type ChatSearchMode string + +const ( + SearchModeKeyword ChatSearchMode = "keyword" + SearchModeFuzzy ChatSearchMode = "fuzzy" + SearchModeSemantic ChatSearchMode = "semantic" + SearchModeHybrid ChatSearchMode = "hybrid" +) + +// ChatSearchQuery 搜索入参 +type ChatSearchQuery struct { + SessionID string `json:"session_id"` + ProjectID int64 `json:"project_id,omitempty"` + UserID int64 `json:"user_id,omitempty"` + Query string `json:"query"` + Mode ChatSearchMode `json:"mode"` // 默认 hybrid + Category string `json:"category,omitempty"` + DateFrom string `json:"date_from,omitempty"` + DateTo string `json:"date_to,omitempty"` + Limit int `json:"limit"` +} + +// ChatSearchHit 搜索命中 +type ChatSearchHit struct { + MessageID int64 `json:"message_id"` + SessionID string `json:"session_id"` + DayKey string `json:"day_key"` + Role string `json:"role"` + Category string `json:"category"` + Excerpt string `json:"excerpt"` + Score float64 `json:"score"` + MatchedBy string `json:"matched_by"` +} + +// DigestDeliverable 当日交付物摘要 +type DigestDeliverable struct { + DocID string `json:"doc_id"` + Title string `json:"title"` + State string `json:"state"` +} + +// DailyDigest 按日智能摘要 +type DailyDigest struct { + ID int64 `json:"id,omitempty"` + SessionID string `json:"session_id"` + ProjectID int64 `json:"project_id,omitempty"` + DayKey string `json:"day_key"` + Headline string `json:"headline"` + KeyPoints []string `json:"key_points"` + Deliverables []DigestDeliverable `json:"deliverables"` + Decisions []string `json:"decisions"` + OpenQuestions []string `json:"open_questions"` + StageProgress string `json:"stage_progress"` + FirstMessageID int64 `json:"first_message_id"` + LastMessageID int64 `json:"last_message_id"` + MessageCount int `json:"message_count"` + Status string `json:"status"` // pending | generating | ready | fallback | failed + Source string `json:"source"` // llm | rule + Model string `json:"model,omitempty"` + TokenUsage int `json:"token_usage,omitempty"` + DigestVersion int `json:"digest_version"` + GeneratedAt *time.Time `json:"generated_at,omitempty"` + UpdatedAt time.Time `json:"updated_at"` +} + diff --git a/internal/server/chat_digest_api.go b/internal/server/chat_digest_api.go new file mode 100644 index 00000000..e84b4e45 --- /dev/null +++ b/internal/server/chat_digest_api.go @@ -0,0 +1,93 @@ +package server + +import ( + "encoding/json" + "net/http" + "strings" + + "chat-agent-system/internal/ctxmgr" +) + +// handleListDailyDigests 获取会话的历史每日摘要列表 (时间轴侧栏数据源) +// GET /api/v1/chat/digests?session_id=&from=&to= +func (s *Server) handleListDailyDigests(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + + if s.svc == nil || s.svc.ContextManager() == nil { + http.Error(w, "context manager not initialized", http.StatusServiceUnavailable) + return + } + + sessionID := strings.TrimSpace(r.URL.Query().Get("session_id")) + if sessionID == "" { + http.Error(w, "missing required query param 'session_id'", http.StatusBadRequest) + return + } + + from := strings.TrimSpace(r.URL.Query().Get("from")) + to := strings.TrimSpace(r.URL.Query().Get("to")) + + digests, err := s.svc.ContextManager().ListDailyDigests(r.Context(), sessionID, from, to) + if err != nil { + http.Error(w, "ListDailyDigests error: "+err.Error(), http.StatusInternalServerError) + return + } + + if digests == nil { + digests = []ctxmgr.DailyDigest{} + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(digests) +} + +// GenerateDigestRequest 手动触发单日摘要生成请求体 +type GenerateDigestRequest struct { + SessionID string `json:"session_id"` + DayKey string `json:"day_key"` +} + +// handleGenerateDailyDigest 手动触发或重试生成单日摘要 +// POST /api/v1/chat/digests/generate +func (s *Server) handleGenerateDailyDigest(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + + if s.svc == nil || s.svc.ContextManager() == nil { + http.Error(w, "context manager not initialized", http.StatusServiceUnavailable) + return + } + + var req GenerateDigestRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request json body: "+err.Error(), http.StatusBadRequest) + return + } + + req.SessionID = strings.TrimSpace(req.SessionID) + req.DayKey = strings.TrimSpace(req.DayKey) + + if req.SessionID == "" || req.DayKey == "" { + http.Error(w, "missing required fields 'session_id' or 'day_key'", http.StatusBadRequest) + return + } + + digest, err := s.svc.ContextManager().GenerateDailyDigest(r.Context(), req.SessionID, req.DayKey) + if err != nil { + http.Error(w, "GenerateDailyDigest error: "+err.Error(), http.StatusInternalServerError) + return + } + + resp := map[string]any{ + "queued": true, + "digest": digest, + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} diff --git a/internal/server/chat_search_and_digest_api_test.go b/internal/server/chat_search_and_digest_api_test.go new file mode 100644 index 00000000..978b61cd --- /dev/null +++ b/internal/server/chat_search_and_digest_api_test.go @@ -0,0 +1,113 @@ +package server_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/config" + "chat-agent-system/internal/ctxmgr" + "chat-agent-system/internal/server" +) + +func TestServer_ChatSearchAndDigestAPIs(t *testing.T) { + cfg := &config.Config{ + Env: "dev", + HTTPHost: "127.0.0.1", + HTTPPort: 8890, + DataDir: t.TempDir(), + } + svc := bizdev.NewService(cfg) + srv := server.NewServer(cfg, svc) + handler := srv.Routes() + + sessID := "sess_api_search_digest_001" + dayKey := "2026-09-09" + + // 注入种子消息 + ctx := t.Context() + ctxMgr := svc.ContextManager() + _, _ = ctxMgr.RecordUserMessageWithID(ctx, sessID, 1, "user", "我们启动超声探头研发预算,计划投入150万元", "TRIGGER_TASK", "", map[string]any{ + "day_key": dayKey, + }) + _, _ = ctxMgr.RecordUserMessageWithID(ctx, sessID, 0, "assistant", "已确认150万预算,主攻探头阵元与外壳生物相容性测试", "DOCGOV_REVIEW", "", map[string]any{ + "day_key": dayKey, + "milestone": true, + "doc_title": "超声探头立项规格书", + }) + + t.Run("GET /api/v1/chat/search 正常检索", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/chat/search?session_id="+sessID+"&q=150万", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 OK, got %d: %s", w.Code, w.Body.String()) + } + + var hits []ctxmgr.ChatSearchHit + if err := json.NewDecoder(w.Body).Decode(&hits); err != nil { + t.Fatalf("decode hits failed: %v", err) + } + if len(hits) != 2 { + t.Fatalf("expected 2 hits, got %d", len(hits)) + } + }) + + t.Run("GET /api/v1/chat/search 缺少参数400拦截", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/chat/search?session_id="+sessID, nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 Bad Request for missing q, got %d", w.Code) + } + }) + + t.Run("POST /api/v1/chat/digests/generate 触发生成", func(t *testing.T) { + body := map[string]string{ + "session_id": sessID, + "day_key": dayKey, + } + jsonBytes, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodPost, "/api/v1/chat/digests/generate", bytes.NewReader(jsonBytes)) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 OK, got %d: %s", w.Code, w.Body.String()) + } + + var res map[string]any + if err := json.NewDecoder(w.Body).Decode(&res); err != nil { + t.Fatalf("decode generate response failed: %v", err) + } + if res["queued"] != true { + t.Errorf("expected queued=true, got %v", res["queued"]) + } + }) + + t.Run("GET /api/v1/chat/digests 查询列表", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/chat/digests?session_id="+sessID, nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 OK, got %d: %s", w.Code, w.Body.String()) + } + + var digests []ctxmgr.DailyDigest + if err := json.NewDecoder(w.Body).Decode(&digests); err != nil { + t.Fatalf("decode digests failed: %v", err) + } + if len(digests) != 1 { + t.Fatalf("expected 1 digest, got %d", len(digests)) + } + if digests[0].DayKey != dayKey { + t.Errorf("expected dayKey %s, got %s", dayKey, digests[0].DayKey) + } + }) +} diff --git a/internal/server/chat_search_api.go b/internal/server/chat_search_api.go new file mode 100644 index 00000000..588f0214 --- /dev/null +++ b/internal/server/chat_search_api.go @@ -0,0 +1,79 @@ +package server + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + + "chat-agent-system/internal/ctxmgr" +) + +// handleChatSearch 处理消息三模检索 (keyword/fuzzy/semantic/hybrid) +// GET /api/v1/chat/search?session_id=&q=&mode=&category=&date_from=&date_to=&limit=&user_id= +func (s *Server) handleChatSearch(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + + if s.svc == nil || s.svc.ContextManager() == nil { + http.Error(w, "context manager not initialized", http.StatusServiceUnavailable) + return + } + + sessionID := strings.TrimSpace(r.URL.Query().Get("session_id")) + if sessionID == "" { + http.Error(w, "missing required query param 'session_id'", http.StatusBadRequest) + return + } + + query := strings.TrimSpace(r.URL.Query().Get("q")) + if query == "" { + http.Error(w, "missing or empty query param 'q'", http.StatusBadRequest) + return + } + + mode := ctxmgr.ChatSearchMode(strings.TrimSpace(r.URL.Query().Get("mode"))) + category := strings.TrimSpace(r.URL.Query().Get("category")) + dateFrom := strings.TrimSpace(r.URL.Query().Get("date_from")) + dateTo := strings.TrimSpace(r.URL.Query().Get("date_to")) + + limit := 50 + if limStr := r.URL.Query().Get("limit"); limStr != "" { + if val, err := strconv.Atoi(limStr); err == nil && val > 0 { + limit = val + } + } + + var userID int64 + if uStr := r.URL.Query().Get("user_id"); uStr != "" { + if val, err := strconv.ParseInt(uStr, 10, 64); err == nil { + userID = val + } + } + + searchQuery := ctxmgr.ChatSearchQuery{ + SessionID: sessionID, + UserID: userID, + Query: query, + Mode: mode, + Category: category, + DateFrom: dateFrom, + DateTo: dateTo, + Limit: limit, + } + + hits, err := s.svc.ContextManager().SearchChatMessages(r.Context(), searchQuery) + if err != nil { + http.Error(w, "SearchChatMessages error: "+err.Error(), http.StatusInternalServerError) + return + } + + if hits == nil { + hits = []ctxmgr.ChatSearchHit{} + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(hits) +} diff --git a/internal/server/server.go b/internal/server/server.go index 6b09f7ee..2a43c9c7 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -98,6 +98,9 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("GET /api/v1/chat/messages/day", s.handleGetChatDayMessages) mux.HandleFunc("GET /api/v1/chat/dates", s.handleListChatDates) mux.HandleFunc("GET /api/v1/chat/categories", s.handleListChatCategories) + mux.HandleFunc("GET /api/v1/chat/search", s.handleChatSearch) + mux.HandleFunc("GET /api/v1/chat/digests", s.handleListDailyDigests) + mux.HandleFunc("POST /api/v1/chat/digests/generate", s.handleGenerateDailyDigest) mux.HandleFunc("POST /api/v1/chat/messages/import", s.handleImportChatMessages) mux.HandleFunc("GET /api/v1/context/cases/recommend", s.handleRecommendCases) diff --git a/web/src/App.tsx b/web/src/App.tsx index b28440ab..e94646f0 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -81,6 +81,8 @@ import { import { useChatWindow } from './hooks/useChatWindow'; import { CategoryFilterBar } from './components/history/CategoryFilterBar'; import { HistoryCalendar } from './components/history/HistoryCalendar'; +import { ChatSearchModal } from './components/history/ChatSearchModal'; +import { DigestTimelineModal } from './components/history/DigestTimelineModal'; import { navigateToAnchor, parseAnchorHash } from './engine/anchorNavigator'; import { fetchChatCategories, fetchChatDates } from './api/chatWindow'; import { ChatCategoryStat, ChatDateStat, QuotedMessage } from './types/workbench'; @@ -757,6 +759,8 @@ export default function App() { const [categoryStats, setCategoryStats] = useState([]); const [dateStats, setDateStats] = useState([]); const [isCalendarOpen, setIsCalendarOpen] = useState(false); + const [isSearchOpen, setIsSearchOpen] = useState(false); + const [isDigestOpen, setIsDigestOpen] = useState(false); const [activeQuotes, setActiveQuotes] = useState([]); // 会话切换时加载分类与日期统计 @@ -1909,13 +1913,15 @@ export default function App() { onOpenAdminSettings={() => setAdminSettingsOpen(true)} chatContent={
- {/* P1: 顶部分类统计过滤栏与历史日历入口 */} + {/* P1 & P2: 顶部分类统计过滤栏、历史日历、三模搜索与智能摘要入口 */} setIsCalendarOpen((prev) => !prev)} isCalendarOpen={isCalendarOpen} + onOpenSearch={() => setIsSearchOpen(true)} + onOpenDigest={() => setIsDigestOpen(true)} /> {isCalendarOpen && ( setIsCalendarOpen(false)} /> )} + {isSearchOpen && ( + { + navigateToAnchor( + { sessionId: activeSessionId, messageId: hit.message_id }, + { sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor } + ); + setIsSearchOpen(false); + }} + onClose={() => setIsSearchOpen(false)} + /> + )} + {isDigestOpen && ( + { + navigateToAnchor( + { sessionId: activeSessionId, messageId }, + { sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor } + ); + setIsDigestOpen(false); + }} + onClose={() => setIsDigestOpen(false)} + /> + )} { + const url = new URL('/api/v1/chat/digests', window.location.origin); + url.searchParams.set('session_id', sessionId); + if (from) url.searchParams.set('from', from); + if (to) url.searchParams.set('to', to); + + const res = await fetch(url.toString(), { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + + if (!res.ok) { + throw new Error(`fetchDailyDigests failed: HTTP ${res.status}`); + } + + return res.json(); +} + +export async function generateDailyDigest( + sessionId: string, + dayKey: string +): Promise<{ queued: boolean; digest: DailyDigest }> { + const res = await fetch('/api/v1/chat/digests/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + session_id: sessionId, + day_key: dayKey, + }), + }); + + if (!res.ok) { + throw new Error(`generateDailyDigest failed: HTTP ${res.status}`); + } + + return res.json(); +} diff --git a/web/src/api/chatSearch.ts b/web/src/api/chatSearch.ts new file mode 100644 index 00000000..ef93776e --- /dev/null +++ b/web/src/api/chatSearch.ts @@ -0,0 +1,44 @@ +import { ChatSearchHit, ChatSearchMode } from '../types/workbench'; + +export interface SearchChatMessagesParams { + sessionId: string; + q: string; + mode?: ChatSearchMode; + category?: string; + dateFrom?: string; + dateTo?: string; + limit?: number; +} + +export async function searchChatMessages(params: SearchChatMessagesParams): Promise { + const url = new URL('/api/v1/chat/search', window.location.origin); + url.searchParams.set('session_id', params.sessionId); + url.searchParams.set('q', params.q); + + if (params.mode) { + url.searchParams.set('mode', params.mode); + } + if (params.category && params.category !== 'all') { + url.searchParams.set('category', params.category); + } + if (params.dateFrom) { + url.searchParams.set('date_from', params.dateFrom); + } + if (params.dateTo) { + url.searchParams.set('date_to', params.dateTo); + } + if (params.limit) { + url.searchParams.set('limit', String(params.limit)); + } + + const res = await fetch(url.toString(), { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + + if (!res.ok) { + throw new Error(`searchChatMessages failed: HTTP ${res.status}`); + } + + return res.json(); +} diff --git a/web/src/components/history/CategoryFilterBar.tsx b/web/src/components/history/CategoryFilterBar.tsx index f836231d..4820708e 100644 --- a/web/src/components/history/CategoryFilterBar.tsx +++ b/web/src/components/history/CategoryFilterBar.tsx @@ -9,6 +9,8 @@ import { Calendar, Layers, Info, + Search, + Sparkles, } from 'lucide-react'; import { MessageCategory, ChatCategoryStat } from '../../types/workbench'; @@ -18,6 +20,8 @@ interface CategoryFilterBarProps { onSelectCategory: (cat: MessageCategory | 'all') => void; onToggleCalendar?: () => void; isCalendarOpen?: boolean; + onOpenSearch?: () => void; + onOpenDigest?: () => void; } const CATEGORY_META: Record< @@ -40,6 +44,8 @@ export const CategoryFilterBar: React.FC = ({ onSelectCategory, onToggleCalendar, isCalendarOpen = false, + onOpenSearch, + onOpenDigest, }) => { const totalCount = categories.reduce((sum, c) => sum + c.count, 0); @@ -155,34 +161,84 @@ export const CategoryFilterBar: React.FC = ({ })}
- {/* 右侧日历入口 */} - {onToggleCalendar && ( - - )} + {/* 右侧工具组: 搜索、每日摘要、日历 */} +
+ {onOpenSearch && ( + + )} + + {onOpenDigest && ( + + )} + + {onToggleCalendar && ( + + )} +
); }; diff --git a/web/src/components/history/ChatSearchModal.tsx b/web/src/components/history/ChatSearchModal.tsx new file mode 100644 index 00000000..44706f20 --- /dev/null +++ b/web/src/components/history/ChatSearchModal.tsx @@ -0,0 +1,351 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { Search, X, Loader2, Calendar, ArrowRight } from 'lucide-react'; +import { ChatSearchHit, ChatSearchMode } from '../../types/workbench'; +import { searchChatMessages } from '../../api/chatSearch'; + +interface ChatSearchModalProps { + sessionId: string; + onSelectHit: (hit: ChatSearchHit) => void; + onClose: () => void; +} + +export const ChatSearchModal: React.FC = ({ + sessionId, + onSelectHit, + onClose, +}) => { + const [query, setQuery] = useState(''); + const [mode, setMode] = useState('hybrid'); + const [hits, setHits] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [hasSearched, setHasSearched] = useState(false); + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + // 防抖搜索 + useEffect(() => { + const trimmed = query.trim(); + if (!trimmed) { + setHits([]); + setIsLoading(false); + setHasSearched(false); + return; + } + + setIsLoading(true); + const timer = setTimeout(() => { + searchChatMessages({ + sessionId, + q: trimmed, + mode, + limit: 50, + }) + .then((res) => { + setHits(res || []); + setHasSearched(true); + }) + .catch((err) => { + console.warn('search failed:', err); + setHits([]); + setHasSearched(true); + }) + .finally(() => { + setIsLoading(false); + }); + }, 280); + + return () => clearTimeout(timer); + }, [query, mode, sessionId]); + + // 高亮摘录中的关键词 + const renderHighlightedExcerpt = (excerpt: string, searchWord: string) => { + if (!searchWord.trim()) return excerpt; + const parts = excerpt.split(new RegExp(`(${escapeRegex(searchWord)})`, 'gi')); + return ( + + {parts.map((part, i) => + part.toLowerCase() === searchWord.toLowerCase() ? ( + + {part} + + ) : ( + {part} + ) + )} + + ); + }; + + return ( +
+
e.stopPropagation()} + > + {/* 顶部搜索框 */} +
+ + setQuery(e.target.value)} + placeholder="搜索全历史消息、决策记录与技术参数..." + style={{ + flex: 1, + background: 'transparent', + border: 'none', + outline: 'none', + fontSize: '15px', + color: 'var(--text-strong, #f8fafc)', + }} + /> + {isLoading && } + {query && !isLoading && ( + + )} + +
+ + {/* 检索模式切换栏 */} +
+
+ 模式: + {(['hybrid', 'keyword', 'fuzzy', 'semantic'] as ChatSearchMode[]).map((m) => { + const active = mode === m; + const labels: Record = { + hybrid: '综合推荐', + keyword: '精确关键词', + fuzzy: '模糊匹配', + semantic: '语义理解', + }; + return ( + + ); + })} +
+ + {hasSearched && ( + + 找到 {hits.length} 条相关记录 + + )} +
+ + {/* 搜索结果列表 */} +
+ {query.trim() === '' && ( +
+ 输入关键词(如“预算”、“GB 9706”、“注册”、“临床”),瞬时穿梭至历史锚点 +
+ )} + + {hasSearched && hits.length === 0 && ( +
+ 未找到匹配“{query}”的历史发言,可尝试切换至“模糊匹配”或“语义理解”模式 +
+ )} + + {hits.map((hit) => { + const roleLabel = hit.role === 'user' ? '用户' : '项目助手'; + const isUser = hit.role === 'user'; + return ( +
onSelectHit(hit)} + style={{ + padding: '10px 14px', + borderRadius: 'var(--r-md, 8px)', + background: 'rgba(255, 255, 255, 0.03)', + border: '1px solid rgba(255, 255, 255, 0.06)', + cursor: 'pointer', + transition: 'all 0.15s ease', + display: 'flex', + flexDirection: 'column', + gap: '6px', + }} + onMouseEnter={(e) => { + e.currentTarget.style.background = 'rgba(79, 107, 255, 0.08)'; + e.currentTarget.style.borderColor = 'rgba(79, 107, 255, 0.3)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.background = 'rgba(255, 255, 255, 0.03)'; + e.currentTarget.style.borderColor = 'rgba(255, 255, 255, 0.06)'; + }} + > +
+
+ + {roleLabel} + + • + + + {hit.day_key} + + {hit.category && ( + <> + • + + {hit.category} + + + )} +
+ +
+ 跳转 + +
+
+ +
+ {renderHighlightedExcerpt(hit.excerpt, query)} +
+
+ ); + })} +
+
+
+ ); +}; + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/web/src/components/history/DigestTimelineModal.tsx b/web/src/components/history/DigestTimelineModal.tsx new file mode 100644 index 00000000..e677fadf --- /dev/null +++ b/web/src/components/history/DigestTimelineModal.tsx @@ -0,0 +1,360 @@ +import React, { useState, useEffect } from 'react'; +import { + X, + Sparkles, + Calendar, + CheckCircle2, + AlertCircle, + ArrowRight, + RefreshCw, + Loader2, + ChevronDown, + ChevronUp, +} from 'lucide-react'; +import { DailyDigest } from '../../types/workbench'; +import { fetchDailyDigests, generateDailyDigest } from '../../api/chatDigest'; + +interface DigestTimelineModalProps { + sessionId: string; + onJumpToMessage: (messageId: number) => void; + onClose: () => void; +} + +export const DigestTimelineModal: React.FC = ({ + sessionId, + onJumpToMessage, + onClose, +}) => { + const [digests, setDigests] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [regeneratingDay, setRegeneratingDay] = useState(null); + const [expandedCards, setExpandedCards] = useState>({}); + + const loadDigests = () => { + setIsLoading(true); + fetchDailyDigests(sessionId) + .then((res) => { + setDigests(res || []); + }) + .catch((err) => { + console.warn('fetchDailyDigests error:', err); + }) + .finally(() => { + setIsLoading(false); + }); + }; + + useEffect(() => { + loadDigests(); + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [sessionId, onClose]); + + const toggleExpand = (dayKey: string) => { + setExpandedCards((prev) => ({ + ...prev, + [dayKey]: !prev[dayKey], + })); + }; + + const handleRegenerate = async (dayKey: string) => { + setRegeneratingDay(dayKey); + try { + const res = await generateDailyDigest(sessionId, dayKey); + if (res.digest) { + setDigests((prev) => { + const idx = prev.findIndex((d) => d.day_key === dayKey); + if (idx >= 0) { + const next = [...prev]; + next[idx] = res.digest; + return next; + } + return [res.digest, ...prev]; + }); + } + } catch (err) { + console.warn('regenerate failed:', err); + } finally { + setRegeneratingDay(null); + } + }; + + return ( +
+
e.stopPropagation()} + > + {/* 顶部标题栏 */} +
+
+ +

+ 每日协同与工作决策摘要 (Daily Digest) +

+
+
+ + +
+
+ + {/* 主体摘要时间轴列表 */} +
+ {isLoading && ( +
+ + 正在拉取每日摘要时间轴... +
+ )} + + {!isLoading && digests.length === 0 && ( +
+ 当前会话尚未生成每日摘要。对话历史沉淀后,系统将自动汇总每日核心结论与拍板决策。 +
+ )} + + {digests.map((digest) => { + const isExpanded = expandedCards[digest.day_key] !== false; // 默认展开 + const isRegenerating = regeneratingDay === digest.day_key; + + return ( +
+ {/* 卡片头部 */} +
toggleExpand(digest.day_key)} + > +
+
+ + {digest.day_key} +
+ + + {digest.source === 'llm' ? 'AI总结' : '规则沉淀'} • {digest.message_count} 条消息 + +
+ +
+ + + + + {isExpanded ? : } +
+
+ + {/* 卡片详情 */} + {isExpanded && ( +
+ {/* 核心总结 Headline */} +
+ {digest.headline} +
+ + {/* 工作要点 */} + {digest.key_points && digest.key_points.length > 0 && ( +
+
+ 📌 核心要点 +
+
+ {digest.key_points.map((pt, i) => ( +
+ • {pt} +
+ ))} +
+
+ )} + + {/* 拍板决策 */} + {digest.decisions && digest.decisions.length > 0 && ( +
+
+ + 关键决策与里程碑 +
+
+ {digest.decisions.map((dec, i) => ( +
+ ✓ {dec} +
+ ))} +
+
+ )} + + {/* 未决事项 */} + {digest.open_questions && digest.open_questions.length > 0 && ( +
+
+ + 未决问题与跟进 +
+
+ {digest.open_questions.map((q, i) => ( +
+ ? {q} +
+ ))} +
+
+ )} +
+ )} +
+ ); + })} +
+
+
+ ); +}; diff --git a/web/src/types/workbench.ts b/web/src/types/workbench.ts index 0bfd9137..77362f35 100644 --- a/web/src/types/workbench.ts +++ b/web/src/types/workbench.ts @@ -236,3 +236,46 @@ export interface WorkbenchState { deckDrawerOpen: boolean; theme: 'dark' | 'light'; } + +export type ChatSearchMode = 'keyword' | 'fuzzy' | 'semantic' | 'hybrid'; + +export interface ChatSearchHit { + message_id: number; + session_id: string; + day_key: string; + role: string; + category: string; + excerpt: string; + score: number; + matched_by: string; +} + +export interface DigestDeliverable { + doc_id: string; + title: string; + state: string; +} + +export interface DailyDigest { + id?: number; + session_id: string; + project_id?: number; + day_key: string; + headline: string; + key_points: string[]; + deliverables?: DigestDeliverable[]; + decisions: string[]; + open_questions: string[]; + stage_progress?: string; + first_message_id: number; + last_message_id: number; + message_count: number; + status: string; + source: string; + model?: string; + token_usage?: number; + digest_version: number; + generated_at?: string; + updated_at: string; +} +