Files
Agent-System/internal/ctxmgr/summarizer.go

271 lines
8.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package ctxmgr
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
)
// ChatSummary 会话滚动摘要数据模型
type ChatSummary struct {
SessionID string `json:"session_id"`
Narrative string `json:"narrative"` // 叙事摘要(≤300 字)
Facts map[string]string `json:"facts"` // 硬事实槽位字典(永不被压缩稀释)
OpenQuestions []string `json:"open_questions"` // 未决技术或商务待办
CoveredCount int `json:"covered_count"` // 累计已覆盖并压缩的历史消息条数
CoveredUntil time.Time `json:"covered_until"` // 摘要覆盖消息的截止时间戳
UpdatedAt time.Time `json:"updated_at"`
}
// RenderPromptSnippet 渲染为 Prompt 中的【早期对话摘要】段
func (s *ChatSummary) RenderPromptSnippet() string {
if s == nil || (s.Narrative == "" && len(s.Facts) == 0) {
return ""
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("【早期对话摘要】(已压缩前 %d 条对话内容)\n", s.CoveredCount))
if s.Narrative != "" {
sb.WriteString(fmt.Sprintf("• 主题与结论:%s\n", s.Narrative))
}
if len(s.Facts) > 0 {
var factStrs []string
for k, v := range s.Facts {
factStrs = append(factStrs, fmt.Sprintf("%s=%s", k, v))
}
sb.WriteString(fmt.Sprintf("• 已沉淀事实:%s\n", strings.Join(factStrs, "; ")))
}
if len(s.OpenQuestions) > 0 {
sb.WriteString(fmt.Sprintf("• 未决待办:%s\n", strings.Join(s.OpenQuestions, ";")))
}
return sb.String()
}
// GetChatSummary 获取会话现有滚动摘要
func (m *ContextManager) GetChatSummary(ctx context.Context, sessionID string) (*ChatSummary, error) {
if sessionID == "" {
return nil, fmt.Errorf("sessionID required")
}
if cached, ok := m.Cache().Get(LayerC5Summary, sessionID); ok {
if sum, ok := cached.(*ChatSummary); ok && sum != nil {
copySum := *sum
return &copySum, nil
}
}
if m.pool != nil {
var sum ChatSummary
var factsJSON, openQuestionsJSON []byte
err := m.pool.QueryRow(ctx, `
SELECT session_id, narrative, facts, open_questions, covered_count, covered_until, updated_at
FROM chat_summaries
WHERE session_id = $1
`, sessionID).Scan(&sum.SessionID, &sum.Narrative, &factsJSON, &openQuestionsJSON, &sum.CoveredCount, &sum.CoveredUntil, &sum.UpdatedAt)
if err == nil {
_ = json.Unmarshal(factsJSON, &sum.Facts)
_ = json.Unmarshal(openQuestionsJSON, &sum.OpenQuestions)
m.Cache().Set(LayerC5Summary, sessionID, &sum, 15)
return &sum, nil
}
}
m.mu.RLock()
defer m.mu.RUnlock()
if m.summaries != nil {
if s, ok := m.summaries[sessionID]; ok && s != nil {
// 返回副本
copySum := *s
m.Cache().Set(LayerC5Summary, sessionID, &copySum, 15)
return &copySum, nil
}
}
return nil, nil
}
// SaveChatSummary 保存或更新滚动摘要
func (m *ContextManager) SaveChatSummary(ctx context.Context, sum *ChatSummary) error {
if sum == nil || sum.SessionID == "" {
return fmt.Errorf("invalid summary or sessionID")
}
sum.UpdatedAt = time.Now()
// 主动失效 C5 摘要缓存与 C4 上下文缓存
m.Cache().Invalidate(LayerC5Summary, sum.SessionID)
m.Cache().InvalidateLayer(LayerC4Context)
if m.pool != nil {
factsJSON, _ := json.Marshal(sum.Facts)
openQuestionsJSON, _ := json.Marshal(sum.OpenQuestions)
_, err := m.pool.Exec(ctx, `
INSERT INTO chat_summaries (session_id, narrative, facts, open_questions, covered_count, covered_until, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (session_id) DO UPDATE SET
narrative = EXCLUDED.narrative,
facts = EXCLUDED.facts,
open_questions = EXCLUDED.open_questions,
covered_count = EXCLUDED.covered_count,
covered_until = EXCLUDED.covered_until,
updated_at = EXCLUDED.updated_at
`, sum.SessionID, sum.Narrative, factsJSON, openQuestionsJSON, sum.CoveredCount, sum.CoveredUntil, sum.UpdatedAt)
if err != nil {
// DB 失败时仍降级写入内存
_ = err
}
}
m.mu.Lock()
defer m.mu.Unlock()
if m.summaries == nil {
m.summaries = make(map[string]*ChatSummary)
}
copySum := *sum
m.summaries[sum.SessionID] = &copySum
return nil
}
// GenerateRollingSummary 结合已覆盖旧摘要与本批被淘汰出窗口的早期消息,滚动增量压缩生成新摘要
// 具备确定性规则降级引擎:无论外部大模型是否可用,均能稳定提取关键实体槽位与未决问题,不丢事实
func (m *ContextManager) GenerateRollingSummary(ctx context.Context, sessionID string, oldSum *ChatSummary, earlyMsgs []ChatMessageRecord) (*ChatSummary, error) {
if sessionID == "" {
return nil, fmt.Errorf("sessionID required")
}
if len(earlyMsgs) == 0 {
return oldSum, nil
}
newSum := &ChatSummary{
SessionID: sessionID,
Facts: make(map[string]string),
OpenQuestions: make([]string, 0),
CoveredCount: len(earlyMsgs),
CoveredUntil: earlyMsgs[len(earlyMsgs)-1].CreatedAt,
UpdatedAt: time.Now(),
}
if oldSum != nil {
newSum.CoveredCount += oldSum.CoveredCount
for k, v := range oldSum.Facts {
newSum.Facts[k] = v
}
newSum.OpenQuestions = append(newSum.OpenQuestions, oldSum.OpenQuestions...)
}
// 1. 硬事实抽取(永不丢失)
extractedFacts := extractFactsFromMessages(earlyMsgs)
for k, v := range extractedFacts {
newSum.Facts[k] = v
}
// 2. 未决问题与待办抽取
newQuestions := extractOpenQuestions(earlyMsgs)
for _, q := range newQuestions {
if !containsString(newSum.OpenQuestions, q) {
newSum.OpenQuestions = append(newSum.OpenQuestions, q)
}
}
if len(newSum.OpenQuestions) > 4 {
newSum.OpenQuestions = newSum.OpenQuestions[len(newSum.OpenQuestions)-4:]
}
// 3. 叙事要点抽取与合并(确定性规则降级)
narrativeParts := make([]string, 0)
if oldSum != nil && oldSum.Narrative != "" {
narrativeParts = append(narrativeParts, oldSum.Narrative)
}
newPoints := extractNarrativePoints(earlyMsgs)
if newPoints != "" {
narrativeParts = append(narrativeParts, newPoints)
}
fullNarrative := strings.Join(narrativeParts, ";")
if len([]rune(fullNarrative)) > 260 {
runes := []rune(fullNarrative)
fullNarrative = string(runes[len(runes)-260:]) + "..."
}
newSum.Narrative = fullNarrative
// 保存并返回
_ = m.SaveChatSummary(ctx, newSum)
return newSum, nil
}
func extractFactsFromMessages(msgs []ChatMessageRecord) map[string]string {
facts := make(map[string]string)
for _, m := range msgs {
c := m.Content
if strings.Contains(c, "超声手术") || strings.Contains(c, "超声刀") || strings.Contains(c, "高频超声") || strings.Contains(c, "超声") {
facts["device_type"] = "超声手术设备"
} else if strings.Contains(c, "内窥镜") {
facts["device_type"] = "医用内窥镜系统"
} else if strings.Contains(c, "监护仪") {
facts["device_type"] = "病人监护设备"
}
if strings.Contains(c, "国内") && (strings.Contains(c, "欧洲") || strings.Contains(c, "CE")) {
facts["target_market"] = "国内及欧洲CE"
} else if strings.Contains(c, "国内") && facts["target_market"] == "" {
facts["target_market"] = "国内NMPA"
}
if strings.Contains(c, "200万") || strings.Contains(c, "200 万元") {
facts["budget"] = "200万元"
} else if strings.Contains(c, "150万") || strings.Contains(c, "150 万元") {
facts["budget"] = "150万元"
}
if strings.Contains(c, "6个月") || strings.Contains(c, "半年") {
facts["timeline"] = "6个月"
}
}
return facts
}
func extractOpenQuestions(msgs []ChatMessageRecord) []string {
questions := make([]string, 0)
for _, m := range msgs {
c := strings.TrimSpace(m.Content)
if m.Role == "user" && (strings.HasSuffix(c, "?") || strings.HasSuffix(c, "?") || strings.Contains(c, "如何") || strings.Contains(c, "怎么") || strings.Contains(c, "多久")) {
clauses := strings.FieldsFunc(c, func(r rune) bool {
return r == ',' || r == '。' || r == ';' || r == ',' || r == ';'
})
for _, clause := range clauses {
cl := strings.TrimSpace(clause)
if strings.HasSuffix(cl, "?") || strings.HasSuffix(cl, "?") || strings.Contains(cl, "多久") || strings.Contains(cl, "如何") || strings.Contains(cl, "怎么") {
if len([]rune(cl)) <= 50 {
questions = append(questions, cl)
break
}
}
}
}
}
return questions
}
func extractNarrativePoints(msgs []ChatMessageRecord) string {
var points []string
for _, m := range msgs {
if m.Role == "user" {
trimmed := strings.TrimSpace(m.Content)
if strings.Contains(trimmed, "立项") || strings.Contains(trimmed, "标准") || strings.Contains(trimmed, "进展") || strings.Contains(trimmed, "咨询") || strings.Contains(trimmed, "沟通") {
firstClause := strings.Split(trimmed, ",")[0]
if len([]rune(firstClause)) <= 30 && !containsString(points, firstClause) {
points = append(points, firstClause)
}
}
}
}
if len(points) == 0 {
return "双方开展了研发立项与合规细节沟通"
}
if len(points) > 3 {
points = points[len(points)-3:]
}
return "讨论了" + strings.Join(points, "、")
}