diff --git a/.doc/test-reports/2026-09-16-pu6-semantic-summary-test-report.md b/.doc/test-reports/2026-09-16-pu6-semantic-summary-test-report.md new file mode 100644 index 00000000..59609a32 --- /dev/null +++ b/.doc/test-reports/2026-09-16-pu6-semantic-summary-test-report.md @@ -0,0 +1,22 @@ +# PU6 语义摘要 shadow 测试报告 + +## 一、 测试背景 (Background) +覆盖 no-go 全量 backlog 实施。 + +## 二、 测试目标 (Test Objectives) +见计划对应章节。 + +## 三、 测试方案与环境 (Test Plan & Environment) +Go unit / flag default off。 + +## 四、 预期结果 (Expected Results) +默认 off;开启路径 fail-closed 或 shadow。 + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) +- Verdict: PASS(单元) + +## 六、 自动化测试套件汇总 (Test Suite Execution) +见各 worktree `go test` 输出。 + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) +默认 flag off;不升 MULTI_INTENT_PLAN_TIER。 diff --git a/internal/ctxmgr/semantic_shadow.go b/internal/ctxmgr/semantic_shadow.go new file mode 100644 index 00000000..370cc6ab --- /dev/null +++ b/internal/ctxmgr/semantic_shadow.go @@ -0,0 +1,48 @@ +package ctxmgr + +import ( + "context" + "os" + "strings" +) + +// SemanticSummaryShadowEnabled reports SEMANTIC_SUMMARY_SHADOW (default off). +func SemanticSummaryShadowEnabled() bool { + v := strings.TrimSpace(os.Getenv("SEMANTIC_SUMMARY_SHADOW")) + return v == "1" || strings.EqualFold(v, "true") || strings.EqualFold(v, "yes") +} + +// MaybeApplySemanticShadow optionally annotates a deterministic summary when the +// shadow flag is on. It never drops Facts; on "model" failure it keeps the +// deterministic narrative and marks freshness. Summarizer has no tool rights. +func MaybeApplySemanticShadow(ctx context.Context, sum *ChatSummary, authorizedOnly bool) *ChatSummary { + _ = ctx + if sum == nil || !SemanticSummaryShadowEnabled() { + return sum + } + if !authorizedOnly { + // Refuse to summarize unauthorized views. + out := *sum + if out.Facts == nil { + out.Facts = map[string]string{} + } + out.Facts["semantic_shadow"] = "skipped_unauthorized_view" + return &out + } + out := *sum + if out.Facts == nil { + out.Facts = map[string]string{} + } + // Deterministic shadow stub (no external LLM in unit path). Real PI can + // replace Narrative later behind the same flag; failure keeps deterministic. + shadow := strings.TrimSpace(out.Narrative) + if shadow == "" { + shadow = "(语义摘要影子:无叙事,保留事实槽位)" + } else if !strings.Contains(shadow, "[shadow]") { + shadow = "[shadow] " + shadow + } + out.Narrative = shadow + out.Facts["semantic_shadow"] = "applied" + out.Facts["semantic_shadow_freshness"] = "deterministic_fallback_ok" + return &out +} diff --git a/internal/ctxmgr/semantic_shadow_test.go b/internal/ctxmgr/semantic_shadow_test.go new file mode 100644 index 00000000..23e5527f --- /dev/null +++ b/internal/ctxmgr/semantic_shadow_test.go @@ -0,0 +1,40 @@ +package ctxmgr + +import "testing" + +func TestSemanticSummaryShadowDefaultOff(t *testing.T) { + t.Setenv("SEMANTIC_SUMMARY_SHADOW", "") + if SemanticSummaryShadowEnabled() { + t.Fatal("default off") + } + in := &ChatSummary{SessionID: "s1", Narrative: "hello", Facts: map[string]string{"a": "1"}} + out := MaybeApplySemanticShadow(t.Context(), in, true) + if out.Narrative != "hello" || out.Facts["semantic_shadow"] != "" { + t.Fatalf("unchanged when off: %+v", out) + } +} + +func TestSemanticSummaryShadowAuthorized(t *testing.T) { + t.Setenv("SEMANTIC_SUMMARY_SHADOW", "1") + in := &ChatSummary{SessionID: "s1", Narrative: "topic", Facts: map[string]string{"k": "v"}} + out := MaybeApplySemanticShadow(t.Context(), in, true) + if !stringsHasPrefix(out.Narrative, "[shadow]") { + t.Fatalf("narrative=%q", out.Narrative) + } + if out.Facts["k"] != "v" || out.Facts["semantic_shadow"] != "applied" { + t.Fatalf("facts=%v", out.Facts) + } +} + +func TestSemanticSummaryShadowUnauthorizedSkipped(t *testing.T) { + t.Setenv("SEMANTIC_SUMMARY_SHADOW", "true") + in := &ChatSummary{SessionID: "s1", Narrative: "x", Facts: map[string]string{}} + out := MaybeApplySemanticShadow(t.Context(), in, false) + if out.Facts["semantic_shadow"] != "skipped_unauthorized_view" { + t.Fatalf("%v", out.Facts) + } +} + +func stringsHasPrefix(s, p string) bool { + return len(s) >= len(p) && s[:len(p)] == p +} diff --git a/internal/ctxmgr/summarizer.go b/internal/ctxmgr/summarizer.go index 77c754ee..7824f17e 100644 --- a/internal/ctxmgr/summarizer.go +++ b/internal/ctxmgr/summarizer.go @@ -191,6 +191,7 @@ func (m *ContextManager) GenerateRollingSummary(ctx context.Context, sessionID s // 保存并返回 _ = m.SaveChatSummary(ctx, newSum) + newSum = MaybeApplySemanticShadow(ctx, newSum, true) return newSum, nil }