package ctxmgr import ( "context" "encoding/json" "errors" "fmt" "log/slog" "strings" "time" "github.com/jackc/pgx/v5" ) // ChatSummary 会话滚动摘要数据模型 type ChatSummary struct { SessionID string `json:"session_id"` Narrative string `json:"narrative"` // 叙事摘要(≤300 字);PU6 shadow 下始终为 deterministic 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"` Source string `json:"source,omitempty"` // deterministic(served) FreshnessNote string `json:"freshness_note,omitempty"` // shadow 失败或未持久化时的可解释回退说明 Shadow *SemanticShadowReport `json:"shadow,omitempty"` // advisory only;REPLACE 未接线 VisibilityDigest string `json:"visibility_digest,omitempty"` // 授权可见集;未计算时为空 FromMessageID int64 `json:"from_message_id,omitempty"` ToMessageID int64 `json:"to_message_id,omitempty"` PreviousRevision int `json:"previous_revision,omitempty"` Revision int `json:"revision,omitempty"` ProtocolVersion string `json:"protocol_version,omitempty"` ModelVersion string `json:"model_version,omitempty"` // Persisted 为 true 表示配置的存储接受了这次写入。有数据库时只有 INSERT 成功才为 true。 Persisted bool `json:"persisted,omitempty"` } const summaryNotDurableNote = "摘要未写入数据库,仅本进程可见" // 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 ©Sum, nil } } if m.pool != nil { sum, err := m.loadChatSummary(ctx, sessionID) if err == nil && sum != nil { m.Cache().Set(LayerC5Summary, sessionID, sum, 15) return sum, nil } if err != nil && !errors.Is(err, pgx.ErrNoRows) { slog.Warn("chat summary load failed", "session_id", sessionID, "err", err) reportBackground(err) } } 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, ©Sum, 15) return ©Sum, 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 { sum.Persisted = true m.rememberSummary(sum) return nil } factsJSON, err := json.Marshal(sum.Facts) if err != nil { return fmt.Errorf("chat summary facts: %w", err) } openQuestionsJSON, err := json.Marshal(sum.OpenQuestions) if err != nil { return fmt.Errorf("chat summary questions: %w", err) } var shadowJSON []byte if sum.Shadow != nil { shadowJSON, err = json.Marshal(sum.Shadow) if err != nil { return fmt.Errorf("chat summary shadow: %w", err) } } _, err = m.pool.Exec(ctx, ` INSERT INTO chat_summaries ( session_id, narrative, facts, open_questions, covered_count, covered_until, updated_at, source, visibility_digest, from_message_id, to_message_id, previous_revision, revision, protocol_version, model_version, freshness_note, shadow_json ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) 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, source = EXCLUDED.source, visibility_digest = EXCLUDED.visibility_digest, from_message_id = EXCLUDED.from_message_id, to_message_id = EXCLUDED.to_message_id, previous_revision = EXCLUDED.previous_revision, revision = EXCLUDED.revision, protocol_version = EXCLUDED.protocol_version, model_version = EXCLUDED.model_version, freshness_note = EXCLUDED.freshness_note, shadow_json = EXCLUDED.shadow_json `, sum.SessionID, sum.Narrative, factsJSON, openQuestionsJSON, sum.CoveredCount, sum.CoveredUntil, sum.UpdatedAt, sum.Source, sum.VisibilityDigest, nullSummaryID(sum.FromMessageID), nullSummaryID(sum.ToMessageID), sum.PreviousRevision, sum.Revision, sum.ProtocolVersion, sum.ModelVersion, sum.FreshnessNote, nullShadowJSON(shadowJSON)) if err != nil { sum.Persisted = false noteSummaryNotDurable(sum) m.rememberSummary(sum) slog.Warn("chat summary persist failed", "session_id", sum.SessionID, "err", err) reportBackground(err) return fmt.Errorf("chat summary persist failed: %w", err) } sum.Persisted = true m.rememberSummary(sum) return nil } func nullShadowJSON(b []byte) any { if len(b) == 0 { return nil } return b } func (m *ContextManager) loadChatSummary(ctx context.Context, sessionID string) (*ChatSummary, error) { var sum ChatSummary var factsJSON, openQuestionsJSON, shadowJSON []byte var fromID, toID *int64 err := m.pool.QueryRow(ctx, ` SELECT session_id, narrative, facts, open_questions, covered_count, covered_until, updated_at, source, visibility_digest, from_message_id, to_message_id, previous_revision, revision, protocol_version, model_version, freshness_note, shadow_json FROM chat_summaries WHERE session_id = $1 `, sessionID).Scan( &sum.SessionID, &sum.Narrative, &factsJSON, &openQuestionsJSON, &sum.CoveredCount, &sum.CoveredUntil, &sum.UpdatedAt, &sum.Source, &sum.VisibilityDigest, &fromID, &toID, &sum.PreviousRevision, &sum.Revision, &sum.ProtocolVersion, &sum.ModelVersion, &sum.FreshnessNote, &shadowJSON, ) if err != nil { // Compatibility: older schemas without provenance columns. if isUndefinedColumn(err) { return m.loadChatSummaryLegacy(ctx, sessionID) } return nil, err } _ = json.Unmarshal(factsJSON, &sum.Facts) _ = json.Unmarshal(openQuestionsJSON, &sum.OpenQuestions) if len(shadowJSON) > 0 { var report SemanticShadowReport if json.Unmarshal(shadowJSON, &report) == nil { sum.Shadow = &report } } if fromID != nil { sum.FromMessageID = *fromID } if toID != nil { sum.ToMessageID = *toID } sum.Persisted = true return &sum, nil } func (m *ContextManager) loadChatSummaryLegacy(ctx context.Context, sessionID string) (*ChatSummary, error) { var sum ChatSummary var factsJSON, openQuestionsJSON []byte var fromID, toID *int64 err := m.pool.QueryRow(ctx, ` SELECT session_id, narrative, facts, open_questions, covered_count, covered_until, updated_at, source, visibility_digest, from_message_id, to_message_id, previous_revision, revision, protocol_version, model_version FROM chat_summaries WHERE session_id = $1 `, sessionID).Scan( &sum.SessionID, &sum.Narrative, &factsJSON, &openQuestionsJSON, &sum.CoveredCount, &sum.CoveredUntil, &sum.UpdatedAt, &sum.Source, &sum.VisibilityDigest, &fromID, &toID, &sum.PreviousRevision, &sum.Revision, &sum.ProtocolVersion, &sum.ModelVersion, ) if err != nil { return nil, err } _ = json.Unmarshal(factsJSON, &sum.Facts) _ = json.Unmarshal(openQuestionsJSON, &sum.OpenQuestions) if fromID != nil { sum.FromMessageID = *fromID } if toID != nil { sum.ToMessageID = *toID } sum.Persisted = true return &sum, nil } func isUndefinedColumn(err error) bool { if err == nil { return false } msg := strings.ToLower(err.Error()) return strings.Contains(msg, "freshness_note") || strings.Contains(msg, "shadow_json") || strings.Contains(msg, "undefined_column") } func (m *ContextManager) rememberSummary(sum *ChatSummary) { m.mu.Lock() defer m.mu.Unlock() if m.summaries == nil { m.summaries = make(map[string]*ChatSummary) } copySum := *sum m.summaries[sum.SessionID] = ©Sum } func nullSummaryID(id int64) any { if id <= 0 { return nil } return id } func noteSummaryNotDurable(sum *ChatSummary) { if sum == nil || strings.Contains(sum.FreshnessNote, summaryNotDurableNote) { return } if sum.FreshnessNote == "" { sum.FreshnessNote = summaryNotDurableNote return } sum.FreshnessNote += ";" + summaryNotDurableNote } // 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 != "" { priorNarrative := oldSum.Narrative // A replaced (semantic) narrative is not deterministic material: merge // the displaced rule-based narrative kept in the shadow report so a // rollback leaves no semantic residue in the narrative chain. if oldSum.Source == "semantic_replace" && oldSum.Shadow != nil && strings.TrimSpace(oldSum.Shadow.DisplacedNarrative) != "" { priorNarrative = strings.TrimSpace(oldSum.Shadow.DisplacedNarrative) } narrativeParts = append(narrativeParts, priorNarrative) } 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 newSum.Source = "deterministic" newSum.ProtocolVersion = "deterministic-v1" if len(earlyMsgs) > 0 { newSum.FromMessageID = earlyMsgs[0].ID newSum.ToMessageID = earlyMsgs[len(earlyMsgs)-1].ID } if oldSum != nil { prev := oldSum.Revision if prev < 1 { prev = 1 } newSum.PreviousRevision = prev newSum.Revision = prev + 1 } else { newSum.Revision = 1 } // Always bind the authorized-visible set digest (even when shadow is off). minimized := MinimizeAuthorizedMessages(earlyMsgs) newSum.VisibilityDigest = DigestAuthorizedVisibility(minimized) // SEMANTIC_SUMMARY_SHADOW:后台可靠任务生成 advisory 语义摘要;请求路径立即返回确定性结果。 // SEMANTIC_SUMMARY_REPLACE 故意不接线,served Narrative 永不被语义结果替换。 scheduleSemanticShadow(ctx, m, newSum, earlyMsgs, oldSum) // 写库失败仍返回确定性摘要,供本轮使用;错误留在 Persisted/FreshnessNote,不伪装成已持久化。 _ = 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, "、") }