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:
@@ -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,要等下次占槽启动才会建表。
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user