Unseal SEMANTIC_SUMMARY_REPLACE under explicit user authorization with a defense-in-depth session allowlist (SEMANTIC_SUMMARY_REPLACE_SESSIONS): swap happens only for allowlisted sessions after result digests and served watermark validation, bounded narrative size; facts/counts stay deterministic and the daily digest stays advisory. Master flag alone replaces nothing.
208 lines
8.3 KiB
Go
208 lines
8.3 KiB
Go
package ctxmgr
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func replaceBatch() []ChatMessageRecord {
|
|
return []ChatMessageRecord{
|
|
{ID: 1, Role: "user", Content: "我们准备研发高频超声手术刀系统,请教立项建议", CreatedAt: time.Now()},
|
|
{ID: 2, Role: "assistant", Content: "超声手术设备属于二类或三类器械。", CreatedAt: time.Now()},
|
|
{ID: 3, Role: "user", Content: "目标市场是国内及欧洲CE,项目预算大约在200万左右", CreatedAt: time.Now()},
|
|
}
|
|
}
|
|
|
|
func drainShadow(t *testing.T, m *ContextManager, sessionID string) *ChatSummary {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
_ = m.DrainSemanticShadowTasks(ctx)
|
|
if !m.WaitShadowIdle(2 * time.Second) {
|
|
t.Fatal("shadow worker did not become idle")
|
|
}
|
|
sum, err := m.GetChatSummary(ctx, sessionID)
|
|
if err != nil || sum == nil {
|
|
t.Fatalf("get summary: %v %+v", err, sum)
|
|
}
|
|
return sum
|
|
}
|
|
|
|
// Master flag on but empty allowlist: nothing is replaced (defense in depth).
|
|
func TestSemanticReplace_EmptyAllowlistKeepsDeterministic(t *testing.T) {
|
|
t.Cleanup(ResetSummaryFlagsForTest)
|
|
SetSummaryFlagsForTest(SummaryFeatureFlags{Shadow: true, Replace: true})
|
|
|
|
m := NewContextManager(nil, nil, DefaultTimeDecayConfig())
|
|
m.SetSemanticSummarizer(&StubSemanticSummarizer{Narrative: "语义影子叙事:超声刀立项"})
|
|
if _, err := m.GenerateRollingSummary(context.Background(), "sess_r_empty", nil, replaceBatch()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sum := drainShadow(t, m, "sess_r_empty")
|
|
if sum.Source != "deterministic" || strings.Contains(sum.Narrative, "语义影子叙事") {
|
|
t.Fatalf("empty allowlist must keep deterministic, got source=%s narrative=%.60s", sum.Source, sum.Narrative)
|
|
}
|
|
}
|
|
|
|
// Allowlisted session + OK report: served narrative swaps with provenance.
|
|
func TestSemanticReplace_AllowlistedSessionSwapsNarrative(t *testing.T) {
|
|
t.Cleanup(ResetSummaryFlagsForTest)
|
|
SetSummaryFlagsForTest(SummaryFeatureFlags{
|
|
Shadow: true, Replace: true,
|
|
ReplaceSessions: map[string]struct{}{"sess_r_hit": {}},
|
|
})
|
|
|
|
m := NewContextManager(nil, nil, DefaultTimeDecayConfig())
|
|
m.SetSemanticSummarizer(&StubSemanticSummarizer{Narrative: "语义影子叙事:超声刀立项"})
|
|
if _, err := m.GenerateRollingSummary(context.Background(), "sess_r_hit", nil, replaceBatch()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sum := drainShadow(t, m, "sess_r_hit")
|
|
if sum.Source != "semantic_replace" {
|
|
t.Fatalf("expected semantic_replace source, got %s", sum.Source)
|
|
}
|
|
if sum.Narrative != "语义影子叙事:超声刀立项" {
|
|
t.Fatalf("narrative not replaced: %.80s", sum.Narrative)
|
|
}
|
|
if !strings.Contains(sum.FreshnessNote, "semantic_replace") {
|
|
t.Fatalf("freshness note missing replace provenance: %s", sum.FreshnessNote)
|
|
}
|
|
// Facts/counts stay deterministic under the declared scope.
|
|
if sum.Facts["device_type"] != "超声手术设备" {
|
|
t.Fatalf("deterministic facts lost: %+v", sum.Facts)
|
|
}
|
|
}
|
|
|
|
// Non-allowlisted session on the same config: deterministic retained.
|
|
func TestSemanticReplace_NonAllowlistedKeepsDeterministic(t *testing.T) {
|
|
t.Cleanup(ResetSummaryFlagsForTest)
|
|
SetSummaryFlagsForTest(SummaryFeatureFlags{
|
|
Shadow: true, Replace: true,
|
|
ReplaceSessions: map[string]struct{}{"sess_other": {}},
|
|
})
|
|
|
|
m := NewContextManager(nil, nil, DefaultTimeDecayConfig())
|
|
m.SetSemanticSummarizer(&StubSemanticSummarizer{Narrative: "语义影子叙事:超声刀立项"})
|
|
if _, err := m.GenerateRollingSummary(context.Background(), "sess_r_miss", nil, replaceBatch()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sum := drainShadow(t, m, "sess_r_miss")
|
|
if sum.Source != "deterministic" || strings.Contains(sum.Narrative, "语义影子叙事") {
|
|
t.Fatalf("non-allowlisted session must keep deterministic, got %s", sum.Source)
|
|
}
|
|
}
|
|
|
|
// Failing summarizer on an allowlisted session: fail-closed keeps deterministic.
|
|
func TestSemanticReplace_ModelErrorFailsClosed(t *testing.T) {
|
|
t.Cleanup(ResetSummaryFlagsForTest)
|
|
SetSummaryFlagsForTest(SummaryFeatureFlags{
|
|
Shadow: true, Replace: true,
|
|
ReplaceSessions: map[string]struct{}{"sess_r_err": {}},
|
|
})
|
|
|
|
m := NewContextManager(nil, nil, DefaultTimeDecayConfig())
|
|
m.SetSemanticSummarizer(&StubSemanticSummarizer{Err: context.DeadlineExceeded})
|
|
if _, err := m.GenerateRollingSummary(context.Background(), "sess_r_err", nil, replaceBatch()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sum := drainShadow(t, m, "sess_r_err")
|
|
if sum.Source != "deterministic" || sum.Shadow == nil || sum.Shadow.Status != ShadowStatusFallback {
|
|
t.Fatalf("expected fallback with deterministic retained, got source=%s shadow=%+v", sum.Source, sum.Shadow)
|
|
}
|
|
}
|
|
|
|
// Oversized narrative: treated as anomaly, deterministic retained.
|
|
func TestSemanticReplace_OversizedNarrativeFailsClosed(t *testing.T) {
|
|
t.Cleanup(ResetSummaryFlagsForTest)
|
|
SetSummaryFlagsForTest(SummaryFeatureFlags{
|
|
Shadow: true, Replace: true,
|
|
ReplaceSessions: map[string]struct{}{"sess_r_big": {}},
|
|
})
|
|
|
|
m := NewContextManager(nil, nil, DefaultTimeDecayConfig())
|
|
m.SetSemanticSummarizer(&StubSemanticSummarizer{Narrative: strings.Repeat("超", semanticReplaceMaxRunes+10)})
|
|
if _, err := m.GenerateRollingSummary(context.Background(), "sess_r_big", nil, replaceBatch()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sum := drainShadow(t, m, "sess_r_big")
|
|
if sum.Source != "deterministic" || strings.Contains(sum.Narrative, "超超超") {
|
|
t.Fatalf("oversized narrative must fail closed, got source=%s", sum.Source)
|
|
}
|
|
if !strings.Contains(sum.FreshnessNote, "narrative_out_of_bounds") {
|
|
t.Fatalf("missing out-of-bounds note: %s", sum.FreshnessNote)
|
|
}
|
|
}
|
|
|
|
// Stale job (summary advanced past the job's revision): watermark guard
|
|
// rejects persistence; no swap even if the session is eligible.
|
|
func TestSemanticReplace_StaleWatermarkKeepsDeterministic(t *testing.T) {
|
|
t.Cleanup(ResetSummaryFlagsForTest)
|
|
SetSummaryFlagsForTest(SummaryFeatureFlags{
|
|
Shadow: true, Replace: true,
|
|
ReplaceSessions: map[string]struct{}{"sess_r_stale": {}},
|
|
})
|
|
|
|
m := NewContextManager(nil, nil, DefaultTimeDecayConfig())
|
|
ctx := context.Background()
|
|
sum1, err := m.GenerateRollingSummary(ctx, "sess_r_stale", nil, replaceBatch())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Turn shadow scheduling off so the advanced summary enqueues no new job;
|
|
// replacement flags stay armed to prove only the watermark guard rejects.
|
|
SetSummaryFlagsForTest(SummaryFeatureFlags{
|
|
Shadow: false, Replace: true,
|
|
ReplaceSessions: map[string]struct{}{"sess_r_stale": {}},
|
|
})
|
|
// Advance the served summary to a later revision.
|
|
advanced := append(replaceBatch(),
|
|
ChatMessageRecord{ID: 4, Role: "user", Content: "补充:预算调整为300万,型检周期多久", CreatedAt: time.Now()})
|
|
if _, err := m.GenerateRollingSummary(ctx, "sess_r_stale", nil, advanced); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Replay the stale (rev1) OK report directly: watermark must reject it.
|
|
staleJob := SemanticShadowJob{
|
|
SessionID: "sess_r_stale", SummaryRevision: sum1.Revision,
|
|
InputDigest: "digest-rev1", VisibilityDigest: sum1.VisibilityDigest,
|
|
WatermarkToMessageID: sum1.ToMessageID,
|
|
}
|
|
okReport := &SemanticShadowReport{
|
|
Status: ShadowStatusOK, Narrative: "旧版语义叙事", InputDigest: "digest-rev1",
|
|
Model: "stub-semantic-v1", Protocol: semanticProtocolV1, GeneratedAt: time.Now(),
|
|
}
|
|
if err := m.persistShadowProvenance(ctx, staleJob, okReport, nil); err == nil {
|
|
t.Fatal("stale job must be rejected by watermark guard")
|
|
}
|
|
sum := drainShadow(t, m, "sess_r_stale")
|
|
if sum.Source == "semantic_replace" || strings.Contains(sum.Narrative, "旧版语义叙事") {
|
|
t.Fatalf("stale report must not replace, got source=%s narrative=%.60s", sum.Source, sum.Narrative)
|
|
}
|
|
}
|
|
|
|
// Flag parse: allowlist parsing and fail-closed accessor.
|
|
func TestSemanticReplace_FlagParsing(t *testing.T) {
|
|
t.Cleanup(ResetSummaryFlagsForTest)
|
|
got := parseReplaceSessions(" a , b ,, ")
|
|
if len(got) != 2 {
|
|
t.Fatalf("expected 2 sessions, got %d", len(got))
|
|
}
|
|
if _, ok := got["a"]; !ok {
|
|
t.Fatal("session a missing")
|
|
}
|
|
if SemanticReplaceEnabledFor("") {
|
|
t.Fatal("empty session must never be eligible")
|
|
}
|
|
SetSummaryFlagsForTest(SummaryFeatureFlags{Replace: true, ReplaceSessions: map[string]struct{}{"s1": {}}})
|
|
if !SemanticReplaceEnabledFor("s1") {
|
|
t.Fatal("s1 should be eligible")
|
|
}
|
|
if SemanticReplaceEnabledFor("s2") {
|
|
t.Fatal("s2 must not be eligible")
|
|
}
|
|
SetSummaryFlagsForTest(SummaryFeatureFlags{Replace: true})
|
|
if SemanticReplaceEnabledFor("s1") {
|
|
t.Fatal("master on + empty allowlist must replace nothing")
|
|
}
|
|
}
|