fix(chat): stop treating failed summary writes as durable

新增 chat_summaries 迁移。数据库写失败会返回错误并标明仅本进程可见。test_manager 血缘改用文档版本,不再写死 1。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-20 04:02:41 +00:00
co-authored by Cursor
parent 033462a4e6
commit 848a0457a0
9 changed files with 337 additions and 47 deletions
+1
View File
@@ -15,6 +15,7 @@
| owner | task | branch | worktree | mutex | status |
|-------|------|--------|----------|-------|--------|
| Cursor-Auto | 2026-09-20-semantic-summary-persistence | feature/capability-enablement-impact-plan | - | chat+lineage-version | done |
| Cursor-Auto | 2026-09-20-feature-readiness-and-lineage-authz | feature/capability-enablement-impact-plan | - | sysadmin+lineage | done |
| Codex | 2026-09-20-capability-enablement-impact-plan | feature/capability-enablement-impact-plan | - | capability-impact-research | done |
| Cursor | audit-b1-data-consistency | fix/audit-b1-data-consistency | - | docgov+workflow+chat | done |
@@ -0,0 +1,57 @@
---
title: "语义摘要持久化与 lineage 版本"
status: done
created: 2026-09-20
updated: 2026-09-20
owner: Cursor-Auto
branch: feature/capability-enablement-impact-plan
worktree: "-"
mutex: chat+lineage-version
---
# 语义摘要持久化与 lineage 版本
## 目标
`chat_summaries` 有迁移;数据库写失败必须返回错误,不能再被当成保存成功。test_manager 血缘使用文档上的版本号。
## 范围
- goose `00054` 建 `chat_summaries`。
- `SaveChatSummary` 在有连接池时把 INSERT 错误返回给调用方,并标记仅本进程可见。
- 确定性摘要写入 revision 与消息区间。不接 `SEMANTIC_SUMMARY_REPLACE`,不计算授权可见集 digest。
- lineage 下游/上游版本不再写死 1。
## 非目标
- 不重启 dev/test/main,因此运行中的库仍停留在 schema 53,直到下次占槽迁移。
- 不打开 shadow/replace 开关。
- 不实现 trace_reviewer 与 D7 删除传播。
## 步骤
1. [x] 迁移与保存错误语义。
2. [x] lineage 版本改读文档版本。
3. [x] `go test` 覆盖写失败与版本选择。
## 验收标准
- [x] 无法连接数据库时 `SaveChatSummary` 返回错误,且结果 `Persisted=false`。
- [x] 无连接池的测试存储仍可读写摘要。
- [x] 文档版本为 4 时 lineage 版本为 4;缺失时仍为 1。
## 相关链接
- 整改方案: [2026-09-20-capability-enablement-impact-plan.md](../../remediation/2026-09-20-capability-enablement-impact-plan.md)
## 证据 / 备注
2026-09-20 本地 `go test`(未占集成槽、未执行迁移):
- `TestSaveChatSummary_DatabaseFailureIsNotSuccess` PASS(连接拒绝时返回错误,`Persisted=false`)
- `TestGenerateRollingSummary_RecordsRevisionAndMessageSpan` PASS
- `TestRollingSummarizer_LifecycleAndFactPreservation` PASS
- `TestLineageVersion_UsesDocumentVersion` PASS
- `TestMigrationGate_ExpectedFiles` PASS
goose `00054` 仅入库。dev/test 进程仍是 schema 53,要等下次占槽启动才会建表。
+2 -2
View File
@@ -53,7 +53,7 @@
| 多意图持久计划(U1–U8 基线) | app/intentflow 生命周期、HITL、默认 `legacy`、U1–U8 代码与安全基线 | partial | non_blocker(默认 legacy) | [设计](../system-design/2026-09-15-multi-intent-hitl-plan.md);[U1](../test-reports/2026-09-15-multi-intent-u1-contracts-test-report.md)–[U8](../test-reports/2026-09-16-multi-intent-u8-task-generate-test-report.md);[Wave2 真 PI](../test-reports/2026-09-16-wave2-real-pi-evidence-test-report.md);[2026-09-20 复核](../ops/research/2026-09-20-capability-enablement-impact-research.md) | legacy 有限只读多意图仍默认启用;新持久计划生产组合根仍使用 Fake query owner,confirm 后缺可靠调度,typed 参数/目标版本不足。原 U1–U8 测试证据保留,但不足以证明升 `auto_read/confirm` 可用。关闭条件见[整改方案](../remediation/2026-09-20-capability-enablement-impact-plan.md) Foundation 1 与 Track I | `intent-plan` |
| 多意图扩面(U9–U12) | U9 LLM 分段实验;U10 mutate;U11 docgov;U12 workflow/run-control | partial | deferred_enhancement | [设计](../system-design/2026-09-15-multi-intent-hitl-plan.md);[go/no-go GO](../ops/research/2026-09-16-wave3-pu6-pu8-go-no-go.md);[脚手架](../test-reports/2026-09-16-pu8-u9-u12-flags-test-report.md);[U9](../test-reports/2026-09-17-w4-u9-segment-test-report.md);[U10](../test-reports/2026-09-17-w4-u10-mutate-test-report.md);[U11](../test-reports/2026-09-17-w4-u11-docgov-test-report.md);[U12](../test-reports/2026-09-17-w4-u12-run-control-test-report.md);[2026-09-20 复核](../ops/research/2026-09-20-capability-enablement-impact-research.md) | U9 proposer 已实现和注入但未进入生产聊天分段调用;U10–U12 当前为 dry-run/validated-only adapter。历史直接调用/PI 测试继续有效,不代表 live 接线。逐域实现和验收,禁止以 `full` 一次放量 | `intent-plan` |
| 对话 / ctxmgr baseline | 多轮上下文、刷新/重登/重启恢复、授权同集正文与计数、U10 端口化停止扩面 | done | non_blocker | [multi-turn](../system-design/multi-turn-dialogue/);[刷新审计](../audit/2026-09-13-chat-history-refresh-disappears.md);[U10](../test-reports/2026-09-15-chatlog-contextasm-u10-test-report.md);[门禁 B3](../test-reports/2026-09-16-chat-refresh-gate-test-report.md)(项目 6359) | **before**:语义摘要误标 D5 混入 → **after**:摘要迁「语义摘要 D3」;D7 数据治理独立。U10 停止扩面 | `chat` |
| 语义摘要(D3) | 授权后可见集合上的语义摘要;shadow → 可选替换 deterministic | partial | deferred_enhancement | [chat-agent-p6-backlog](../backlog/chat-agent-p6-backlog.md);[go/no-go GO](../ops/research/2026-09-16-wave3-pu6-pu8-go-no-go.md);[pu6](../ops/long-term/2026-09-16-pu6-semantic-summary-contract.md);[脚手架](../test-reports/2026-09-16-pu6-semantic-summary-test-report.md);[W2 真 PI](../test-reports/2026-09-17-w2-semantic-summary-shadow-test-report.md);[2026-09-20 复核](../ops/research/2026-09-20-capability-enablement-impact-research.md) | shadow PI 组件存在且默认 off;三环境缺 `chat_summaries` 表,持久化失败会静默退内存;Runner 的工具集未真正 no-tool。`SEMANTIC_SUMMARY_REPLACE` 明确未接线。先完成持久化、授权水位、后台生成和工具隔离,再灰度 shadow/replace | `chat` |
| 语义摘要(D3) | 授权后可见集合上的语义摘要;shadow → 可选替换 deterministic | partial | deferred_enhancement | [chat-agent-p6-backlog](../backlog/chat-agent-p6-backlog.md);[go/no-go GO](../ops/research/2026-09-16-wave3-pu6-pu8-go-no-go.md);[pu6](../ops/long-term/2026-09-16-pu6-semantic-summary-contract.md);[脚手架](../test-reports/2026-09-16-pu6-semantic-summary-test-report.md);[W2 真 PI](../test-reports/2026-09-17-w2-semantic-summary-shadow-test-report.md);[2026-09-20 复核](../ops/research/2026-09-20-capability-enablement-impact-research.md) | shadow PI 组件存在且默认 off。`00054` 增加 `chat_summaries`,写入失败会返回错误并标明仅本进程可见,不再静默当成持久化成功;运行中的 dev/test 仍是 schema 53,表要等下次占槽迁移后才存在。授权可见集 digest 仍空。Runner 的工具集未真正 no-tool。`SEMANTIC_SUMMARY_REPLACE` 明确未接线。 | `chat` |
| 对话数据治理(D7) | 留存/删除口径、库·备份·日志控制、敏感字段加密边界 | deferred | deferred_enhancement(违规则升 blocker) | [计划](../remediation/2026-09-16-feature-matrix-production-readiness-plan.md) Scope Boundaries;[收口 §7.1](../remediation/2026-09-16-partial-deferred-completion-plan.md);[D7 ADR 草案](../adr/2026-09-17-d7-chat-data-retention.md);[W7 盘点](../audit/2026-09-17-d7-data-inventory.md);[报告](../test-reports/2026-09-17-w7-d7-inventory-test-report.md) | **after W7**:只读盘点完成(sessions/messages/KB/日志/备份/event)。**未发现**已确认基线违规,未写 gap-matrix。口径仍 `pending-compliance-signoff`;禁止按 365/180/30 物理删除。本行不升 done | `chat` |
| 智能体小队 | 角色-任务-工作区-黑板 | done | non_blocker | [agent-squad](../system-design/agent-squad/);[test 2026-09-07](../test-reports/2026-09-07-dynamic-squad-planning-test-report.md) | 非关键步骤降级(A5/A6)属 backlog | `squad` |
| 工作流治理 | 三正式工作流、设置页/DutyMode、动作级鉴权、真实 PI 三工作流 E2E、full/none 混合长链 | done | non_blocker | [三工作流整改](../remediation/2026-09-12-three-workflows-strict-governance-remediation.md);[409/职责](../test-reports/2026-09-14-workflow-plan-409-frozen-and-optional-duties-test-report.md);[鉴权](../test-reports/2026-09-14-governance-action-level-authz-test-report.md);[PI E2E](../test-reports/2026-09-16-pi-three-workflow-e2e-test-report.md);[Wave2 full/none](../test-reports/2026-09-16-wave2-real-pi-evidence-test-report.md) | full 跨用户审批 + none 免审批不阻塞已签。纯软件自举仍独立 planned | `workflow` |
@@ -61,7 +61,7 @@
| 纯软件项目自举 | 软件项目创建、Bootstrap、Delivery、受控工程交接 | partial | deferred_enhancement(明确不挡首发) | [设计](../system-design/2026-09-14-software-project-bootstrap-workflow.md);[ADR](../adr/2026-09-16-software-project-bootstrap-keys.md);[L1 报告](../test-reports/2026-09-16-software-project-bootstrap-test-report.md);[W5a](../test-reports/2026-09-17-w5a-software-bootstrap-ui-pi-test-report.md) | **before**:L1 + UI 仍克隆医疗模板。**after W5a**:catalog 驱动创建、软件不挂医疗 key、docs_only 真 PI 建议 delivery 且不自动激活。**SWBOOT-10 仍 stub**(W5b);本行不升 done | `workflow-design` |
| IAM / 鉴权 | 项目级 API 鉴权 + RBAC;会话/阶段授权 | done | 加固 | [v3 full inventory](../audit/authorization-matrix-v3-full.md);[session 中断](../test-reports/2026-09-16-iam-route-inventory-test-report.md);[fail-closed](../test-reports/2026-09-16-wave1-iam-fail-closed-test-report.md);[410 策略](../audit/2026-09-16-bug11-410-retire-decision.md);[R13](../audit/2026-09-16-r13-data-safety-checklist.md) | 全路由 inventory + session 中断负例 + 410 冻结 + R13 基线已签 | `authz` |
| 文档治理 | 法定文档生命周期 + 租约 + 受控正文读取 | done | non_blocker | [docgov](../test-reports/2026-09-08-docgov-lifecycle-and-claiming-framework-test-report.md);[正文 UI](../test-reports/2026-09-14-controlled-content-ui-acceptance.md);[canonical hash](../remediation/2026-09-14-controlled-content-canonical-hash-remediation.md) | 项目 143 只读;不终止 | `docgov` |
| 文档版本 baseline | 账本、人工修订、确定性 diff+lineage、运行时 UI、负责人通知 | done | non_blocker | [管理](../test-reports/2026-09-13-document-revision-management-test-report.md);[Round20 UI](../test-reports/2026-09-14-document-revision-runtime-ui-test-report.md);[ADR](../adr/2026-09-13-document-version-boundary.md) | **before**:完整 PI narrative 混入 → **after**:迁「PI change-report 叙事」。fallback 报告是本行内显式行为,不是缺口 | `docgov` |
| 文档版本 baseline | 账本、人工修订、确定性 diff+lineage、运行时 UI、负责人通知 | done | non_blocker | [管理](../test-reports/2026-09-13-document-revision-management-test-report.md);[Round20 UI](../test-reports/2026-09-14-document-revision-runtime-ui-test-report.md);[ADR](../adr/2026-09-13-document-version-boundary.md) | **before**:完整 PI narrative 混入 → **after**:迁「PI change-report 叙事」。fallback 报告是本行内显式行为,不是缺口。lineage GET 已按 URL 项目和文档可见性裁剪。test_manager 血缘的下游/上游版本改读文档版本,不再写死 1。项目追踪索引和影响报告仍见整改方案 Track T | `docgov` |
| PI change-report 叙事 | 只读消费 diff+lineage 的 PI 叙事;失败全量降级 fallback | partial | deferred_enhancement | [Round20](../test-reports/2026-09-14-document-revision-runtime-ui-test-report.md);[go/no-go GO](../ops/research/2026-09-16-wave3-pu6-pu8-go-no-go.md);[pu7](../ops/long-term/2026-09-16-pu7-pi-narrative-contract.md);[脚手架](../test-reports/2026-09-16-pu7-pi-narrative-test-report.md);[W3 真 PI](../test-reports/2026-09-17-w3-pi-change-report-narrative-test-report.md);[2026-09-20 复核](../ops/research/2026-09-20-capability-enablement-impact-research.md) | flag/Fetcher/digest/fallback 已实现且默认 off;生产 PI 输入目前仅含 SHA、风险及 hunk/impact 数量,未传具体差异;Runner 工具集也未真正 no-tool。补完整只读证据包和纯推理 profile 后再灰度 | `docgov` |
| 前端工作台 | 三栏 + 块文档 + SSE;错误卡/进度/busy;版本冲突信封;demo seed 默认关;终止 preview | done | non_blocker | [workbench](../test-reports/2026-09-08-three-column-workbench-and-block-document-test-report.md);[双标签](../test-reports/assets/2026-09-15-conversation-intent-dual-tab/screenshots/tab-b-busy.png);[demo](../test-reports/2026-09-16-demo-seed-pollution-test-report.md);[B4](../test-reports/2026-09-16-frontend-gate-b4-test-report.md)(项目 6363) | **before**:空「缺口」却 partial → **after**:本行 done。其他 409 消费方信封复用另列 hardening backlog | `web` |
| PI Runner / 配置链 | pi-agent + MiniMax + typed-result-v1 | done | non_blocker | [piagent](../test-reports/2026-09-13-codex-audit-p0-remediation-test-report.md);[container-env](../test-reports/2026-09-13-container-env-configuration-test-report.md);[typed-result](../test-reports/2026-09-15-pi-typed-result-v1-test-report.md) | — | `pi-agent` |
@@ -0,0 +1,24 @@
-- +goose Up
-- 滚动摘要的持久化位置。此前 INSERT 指向不存在的 chat_summaries,失败被吞掉并退回进程内存。
-- 授权可见集 digest、模型版本留空,直到语义摘要 shadow 生产化时写入;REPLACE 仍不接线。
CREATE TABLE IF NOT EXISTS chat_summaries (
session_id TEXT PRIMARY KEY,
narrative TEXT NOT NULL DEFAULT '',
facts JSONB NOT NULL DEFAULT '{}'::jsonb,
open_questions JSONB NOT NULL DEFAULT '[]'::jsonb,
covered_count INT NOT NULL DEFAULT 0,
covered_until TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
source TEXT NOT NULL DEFAULT 'deterministic',
visibility_digest TEXT NOT NULL DEFAULT '',
from_message_id BIGINT,
to_message_id BIGINT,
previous_revision INT NOT NULL DEFAULT 0,
revision INT NOT NULL DEFAULT 1,
protocol_version TEXT NOT NULL DEFAULT '',
model_version TEXT NOT NULL DEFAULT ''
);
-- +goose Down
DROP TABLE IF EXISTS chat_summaries;
+19
View File
@@ -0,0 +1,19 @@
package bizdev
import (
"testing"
"chat-agent-system/internal/docgov"
)
func TestLineageVersion_UsesDocumentVersion(t *testing.T) {
if got := lineageVersion(nil); got != 1 {
t.Fatalf("missing document version = %d", got)
}
if got := lineageVersion(&docgov.DocumentRecord{}); got != 1 {
t.Fatalf("zero version = %d", got)
}
if got := lineageVersion(&docgov.DocumentRecord{Version: 4}); got != 4 {
t.Fatalf("recorded version = %d", got)
}
}
+9 -2
View File
@@ -1170,9 +1170,9 @@ func (s *Service) finalizeSquadDeliverable(
_ = s.projectWorkflow.RecordLineage(ctx, &projectworkflow.DocumentLineage{
ProjectID: req.ProjectID,
DownstreamDocID: docID,
DownstreamVersion: 1,
DownstreamVersion: lineageVersion(docRec),
UpstreamDocID: upstreamSRDoc.DocID,
UpstreamVersion: upstreamSRDoc.Version,
UpstreamVersion: lineageVersion(upstreamSRDoc),
UpstreamSHA256: upstreamSRDoc.ContentSHA256,
IsStale: false,
})
@@ -1562,6 +1562,13 @@ func holderName(members []docduty.Member, creator docduty.Holder, userID int64,
return strings.TrimSpace(fallback)
}
func lineageVersion(doc *docgov.DocumentRecord) int {
if doc != nil && doc.Version > 0 {
return doc.Version
}
return 1
}
func bindingFromDoc(doc *docgov.DocumentRecord) docduty.Binding {
if doc == nil {
return docduty.Binding{}
+145 -42
View File
@@ -3,25 +3,40 @@ 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 未接线
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) {
@@ -61,18 +76,13 @@ func (m *ContextManager) GetChatSummary(ctx context.Context, sessionID string) (
}
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
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)
}
}
@@ -100,26 +110,87 @@ func (m *ContextManager) SaveChatSummary(ctx context.Context, sum *ChatSummary)
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
}
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)
}
_, 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
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
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
`, 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)
if err != nil {
sum.Persisted = false
noteSummaryNotDurable(sum)
m.rememberSummary(sum)
slog.Warn("chat summary persist failed", "session_id", sum.SessionID, "err", err)
return fmt.Errorf("chat summary persist failed: %w", err)
}
sum.Persisted = true
m.rememberSummary(sum)
return nil
}
func (m *ContextManager) loadChatSummary(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 (m *ContextManager) rememberSummary(sum *ChatSummary) {
m.mu.Lock()
defer m.mu.Unlock()
if m.summaries == nil {
@@ -127,7 +198,24 @@ func (m *ContextManager) SaveChatSummary(ctx context.Context, sum *ChatSummary)
}
copySum := *sum
m.summaries[sum.SessionID] = &copySum
return nil
}
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 结合已覆盖旧摘要与本批被淘汰出窗口的早期消息,滚动增量压缩生成新摘要
@@ -192,12 +280,27 @@ func (m *ContextManager) GenerateRollingSummary(ctx context.Context, sessionID s
}
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
}
// SEMANTIC_SUMMARY_SHADOW:仅在授权可见集合上评估语义摘要;失败保留 deterministic + freshness。
// SEMANTIC_SUMMARY_REPLACE 故意不接线,served Narrative 永不被语义结果替换。
applySemanticShadow(ctx, newSum, earlyMsgs, m.semanticSummarizerOrDefault())
// 保存并返回
// 写库失败仍返回确定性摘要,供本轮使用;错误留在 Persisted/FreshnessNote,不伪装成已持久化。
_ = m.SaveChatSummary(ctx, newSum)
return newSum, nil
}
@@ -0,0 +1,79 @@
package ctxmgr
import (
"context"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestSaveChatSummary_MemoryStoreCountsAsPersisted(t *testing.T) {
mgr := NewContextManager(nil, nil, DefaultTimeDecayConfig())
sum := &ChatSummary{SessionID: "sess_mem", Narrative: "仅内存", Facts: map[string]string{}, OpenQuestions: []string{}}
if err := mgr.SaveChatSummary(context.Background(), sum); err != nil {
t.Fatal(err)
}
if !sum.Persisted {
t.Fatal("nil pool is the configured store and must report persisted")
}
}
func TestSaveChatSummary_DatabaseFailureIsNotSuccess(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
pool, err := pgxpool.New(ctx, "postgres://summary:summary@127.0.0.1:1/none?sslmode=disable&connect_timeout=1")
if err != nil {
t.Fatal(err)
}
t.Cleanup(pool.Close)
mgr := NewContextManager(pool, nil, DefaultTimeDecayConfig())
sum := &ChatSummary{
SessionID: "sess_db_down", Narrative: "不应伪装成功",
Facts: map[string]string{}, OpenQuestions: []string{},
}
err = mgr.SaveChatSummary(ctx, sum)
if err == nil {
t.Fatal("database write failure must be returned")
}
if sum.Persisted {
t.Fatal("failed write must not be marked persisted")
}
if !strings.Contains(sum.FreshnessNote, summaryNotDurableNote) {
t.Fatalf("freshness note = %q", sum.FreshnessNote)
}
got, getErr := mgr.GetChatSummary(context.Background(), sum.SessionID)
if getErr != nil || got == nil {
t.Fatalf("current process should still serve the summary, err=%v", getErr)
}
if got.Persisted || !strings.Contains(got.FreshnessNote, summaryNotDurableNote) {
t.Fatalf("served fallback must stay non-durable: %+v", got)
}
}
func TestGenerateRollingSummary_RecordsRevisionAndMessageSpan(t *testing.T) {
mgr := NewContextManager(nil, nil, DefaultTimeDecayConfig())
ctx := context.Background()
first := []ChatMessageRecord{{
ID: 10, Role: "user", Content: "临床评价路径该怎么走?", CreatedAt: time.Now(),
}}
sum, err := mgr.GenerateRollingSummary(ctx, "sess_rev", nil, first)
if err != nil {
t.Fatal(err)
}
if !sum.Persisted || sum.Revision != 1 || sum.FromMessageID != 10 || sum.ToMessageID != 10 || sum.ProtocolVersion != "deterministic-v1" {
t.Fatalf("first summary = %+v", sum)
}
second := []ChatMessageRecord{{
ID: 11, Role: "user", Content: "预算大约在200万左右", CreatedAt: time.Now(),
}}
next, err := mgr.GenerateRollingSummary(ctx, "sess_rev", sum, second)
if err != nil {
t.Fatal(err)
}
if next.Revision != 2 || next.PreviousRevision != 1 || next.FromMessageID != 11 || next.Facts["budget"] != "200万元" {
t.Fatalf("rolled summary = %+v", next)
}
}
+1 -1
View File
@@ -72,7 +72,7 @@ var featureMaturityCatalog = map[string]featureMaturity{
},
"SEMANTIC_SUMMARY_SHADOW": {
state: implShadow, adapter: adapterDegraded,
reason: "影子组件默认关闭;chat_summaries 缺失时持久化会静默退回内存,Runner 也还不是纯推理",
reason: "影子组件默认关闭;00054 增加 chat_summaries,但运行中的环境仍是 schema 53,写入失败只留在本进程并返回错误。Runner 也还不是纯推理",
},
"SEMANTIC_SUMMARY_REPLACE": {
state: implPlaceholder, adapter: adapterUnavailable,