diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index fd5330d4..11c57fda 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -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 | diff --git a/.doc/ops/short-term/2026-09-20-semantic-summary-persistence.md b/.doc/ops/short-term/2026-09-20-semantic-summary-persistence.md new file mode 100644 index 00000000..82964187 --- /dev/null +++ b/.doc/ops/short-term/2026-09-20-semantic-summary-persistence.md @@ -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,要等下次占槽启动才会建表。 diff --git a/.doc/status/FEATURE-MATRIX.md b/.doc/status/FEATURE-MATRIX.md index 2814c46c..98d09116 100644 --- a/.doc/status/FEATURE-MATRIX.md +++ b/.doc/status/FEATURE-MATRIX.md @@ -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` | diff --git a/deploy/postgres/goose/00054_2026-09-20-chat-summaries.sql b/deploy/postgres/goose/00054_2026-09-20-chat-summaries.sql new file mode 100644 index 00000000..95e52d61 --- /dev/null +++ b/deploy/postgres/goose/00054_2026-09-20-chat-summaries.sql @@ -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; diff --git a/internal/bizdev/lineage_version_test.go b/internal/bizdev/lineage_version_test.go new file mode 100644 index 00000000..e956e34d --- /dev/null +++ b/internal/bizdev/lineage_version_test.go @@ -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) + } +} diff --git a/internal/bizdev/service.go b/internal/bizdev/service.go index 6c65dbae..63af33fd 100644 --- a/internal/bizdev/service.go +++ b/internal/bizdev/service.go @@ -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{} diff --git a/internal/ctxmgr/summarizer.go b/internal/ctxmgr/summarizer.go index a92d8b9e..4fdc7e7f 100644 --- a/internal/ctxmgr/summarizer.go +++ b/internal/ctxmgr/summarizer.go @@ -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] = ©Sum - 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 } diff --git a/internal/ctxmgr/summarizer_persist_test.go b/internal/ctxmgr/summarizer_persist_test.go new file mode 100644 index 00000000..1604f923 --- /dev/null +++ b/internal/ctxmgr/summarizer_persist_test.go @@ -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) + } +} diff --git a/internal/sysadmin/features.go b/internal/sysadmin/features.go index dabd88fb..29ef81a1 100644 --- a/internal/sysadmin/features.go +++ b/internal/sysadmin/features.go @@ -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,