Root causes of the remaining 5 CI PG failures (and the hidden flaky set):
1. All PG-integration packages shared ONE database under parallel package
execution - migrations/seeds/truncates raced (nondeterministic failure
sets, locally and in CI).
Fix: RequireTestDatabaseURL/SkipIfNoTestDatabaseURL now derive a
per-package database (<base>_<pkgHash>, advisory-lock-guarded
CREATE); 9 raw-env straggler tests funnelled through the helpers;
db-package tests call it unqualified (no import cycle).
2. Approval-chain fixtures were not production-shaped: freezeReviewSnapshot
only honors head_snapshot_id, fake content hashes never froze a
snapshot, so ApproverApprove rejected with 'approved requires frozen
review_snapshot_id'. Errors were swallowed (_, _ =) hiding this.
Fix (ownership-respecting, arch-enforced):
- docrevision.SeedContentSnapshotForTest seeds the ledger tables it owns
- docgov GovernanceEngine.FreezeHeadSnapshotForTest freezes head/review
pointers on document_records (docgov-owned) and clears engine cache
- all four integration fixtures use the pair; swallowed errors checked
3. Fixed-name fixtures collide once a package shares one DB (projects
uq_projects_name_global): uniquified in projectinstance + server tests.
4. CAS/ctxmgr tests never bootstrapped migrations (relied on other packages
having migrated the shared DB): explicit RunMigrationsOnceForTest.
Verified: full parallel short suite = 75 packages, 0 failures against an
isolated throwaway PG. CI skip list removed - short tests fully required.
388 lines
12 KiB
Go
388 lines
12 KiB
Go
package ctxmgr
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
"unicode/utf8"
|
||
)
|
||
|
||
// SemanticToolPermissions is the only allowed tool permission for summarizers.
|
||
// Summarizers must never receive tool callbacks or escalate auth.
|
||
const SemanticToolPermissions = "none"
|
||
|
||
const (
|
||
ShadowStatusOff = "off"
|
||
ShadowStatusOK = "ok"
|
||
ShadowStatusFallback = "fallback"
|
||
)
|
||
|
||
const (
|
||
semanticMaxMessages = 40
|
||
semanticMaxContentRunes = 240
|
||
semanticProtocolV1 = "semantic-summary-v1"
|
||
heuristicModelID = "heuristic-semantic-v1"
|
||
)
|
||
|
||
// MinimizedMessage is the R13-minimized outbound unit for a summarizer.
|
||
// Only id/role/content are included; no metadata, stage, or auth claims.
|
||
type MinimizedMessage struct {
|
||
ID int64 `json:"id"`
|
||
Role string `json:"role"`
|
||
Content string `json:"content"`
|
||
}
|
||
|
||
// SemanticSummaryRequest is the sole input to a no-tool summarizer.
|
||
type SemanticSummaryRequest struct {
|
||
SessionID string
|
||
Messages []MinimizedMessage
|
||
InputDigest string
|
||
VisibilityDigest string
|
||
PriorNarrative string
|
||
PriorFacts map[string]string
|
||
WatermarkToMessageID int64
|
||
ExpectedRevision int
|
||
}
|
||
|
||
// SemanticSummaryResult is the summarizer output (advisory under shadow).
|
||
type SemanticSummaryResult struct {
|
||
Narrative string
|
||
Model string
|
||
Protocol string
|
||
InputDigest string // optional echo for server validation
|
||
VisibilityDigest string // optional echo
|
||
WatermarkToMessageID int64 // optional echo
|
||
}
|
||
|
||
// SemanticShadowReport captures shadow evaluation. Under the fail-closed
|
||
// gray rollout a replaced report keeps the displaced deterministic narrative
|
||
// so later deterministic regeneration merges clean material (rollback-safe).
|
||
type SemanticShadowReport struct {
|
||
Status string `json:"status"`
|
||
Narrative string `json:"narrative,omitempty"`
|
||
DisplacedNarrative string `json:"displaced_narrative,omitempty"`
|
||
InputDigest string `json:"input_digest,omitempty"`
|
||
Model string `json:"model,omitempty"`
|
||
Protocol string `json:"protocol,omitempty"`
|
||
ToolPermissions string `json:"tool_permissions"`
|
||
FreshnessNote string `json:"freshness_note,omitempty"`
|
||
GeneratedAt time.Time `json:"generated_at"`
|
||
}
|
||
|
||
// SemanticSummarizer produces a narrative from minimized messages.
|
||
// Implementations MUST NOT accept tools, callbacks, or auth mutation hooks.
|
||
type SemanticSummarizer interface {
|
||
Summarize(ctx context.Context, req SemanticSummaryRequest) (*SemanticSummaryResult, error)
|
||
}
|
||
|
||
// StubSemanticSummarizer is a test double with fixed narrative or error.
|
||
type StubSemanticSummarizer struct {
|
||
Narrative string
|
||
Model string
|
||
Err error
|
||
}
|
||
|
||
func (s *StubSemanticSummarizer) Summarize(_ context.Context, _ SemanticSummaryRequest) (*SemanticSummaryResult, error) {
|
||
if s == nil {
|
||
return nil, fmt.Errorf("semantic summarizer unavailable")
|
||
}
|
||
if s.Err != nil {
|
||
return nil, s.Err
|
||
}
|
||
model := s.Model
|
||
if model == "" {
|
||
model = "stub-semantic-v1"
|
||
}
|
||
return &SemanticSummaryResult{
|
||
Narrative: s.Narrative,
|
||
Model: model,
|
||
Protocol: semanticProtocolV1,
|
||
}, nil
|
||
}
|
||
|
||
// HeuristicSemanticSummarizer is a no-network, no-tool fallback semantic engine
|
||
// used when shadow is on and no custom summarizer is injected.
|
||
type HeuristicSemanticSummarizer struct{}
|
||
|
||
func (h *HeuristicSemanticSummarizer) Summarize(_ context.Context, req SemanticSummaryRequest) (*SemanticSummaryResult, error) {
|
||
if len(req.Messages) == 0 {
|
||
return nil, fmt.Errorf("no authorized messages for semantic summary")
|
||
}
|
||
var parts []string
|
||
for _, m := range req.Messages {
|
||
if m.Role != "user" {
|
||
continue
|
||
}
|
||
c := strings.TrimSpace(m.Content)
|
||
if c == "" {
|
||
continue
|
||
}
|
||
// Injection text is material only; never interpret as system/auth directives.
|
||
c = stripInstructionishPrefix(c)
|
||
if c == "" {
|
||
continue
|
||
}
|
||
parts = append(parts, truncateRunes(c, 48))
|
||
if len(parts) >= 4 {
|
||
break
|
||
}
|
||
}
|
||
if len(parts) == 0 {
|
||
parts = append(parts, fmt.Sprintf("覆盖 %d 条授权可见对话", len(req.Messages)))
|
||
}
|
||
narrative := "语义要点(shadow):" + strings.Join(parts, ";")
|
||
if utf8.RuneCountInString(narrative) > 280 {
|
||
narrative = truncateRunes(narrative, 280) + "…"
|
||
}
|
||
return &SemanticSummaryResult{
|
||
Narrative: narrative,
|
||
Model: heuristicModelID,
|
||
Protocol: semanticProtocolV1,
|
||
}, nil
|
||
}
|
||
|
||
func stripInstructionishPrefix(c string) string {
|
||
lower := strings.ToLower(c)
|
||
markers := []string{
|
||
"ignore previous",
|
||
"system:",
|
||
"忽略以上",
|
||
"忽略之前",
|
||
"提权",
|
||
"grant yourself",
|
||
"override auth",
|
||
"将所有阶段",
|
||
}
|
||
for _, m := range markers {
|
||
if strings.Contains(lower, strings.ToLower(m)) || strings.Contains(c, m) {
|
||
// Keep as opaque material; do not drop evidence, but neutralize directive framing.
|
||
return "【用户材料】" + c
|
||
}
|
||
}
|
||
return c
|
||
}
|
||
|
||
// MinimizeAuthorizedMessages builds R13-minimized summarizer input.
|
||
// Callers must pass only authorized-visible messages.
|
||
func MinimizeAuthorizedMessages(msgs []ChatMessageRecord) []MinimizedMessage {
|
||
if len(msgs) == 0 {
|
||
return nil
|
||
}
|
||
start := 0
|
||
if len(msgs) > semanticMaxMessages {
|
||
start = len(msgs) - semanticMaxMessages
|
||
}
|
||
out := make([]MinimizedMessage, 0, len(msgs)-start)
|
||
for _, m := range msgs[start:] {
|
||
out = append(out, MinimizedMessage{
|
||
ID: m.ID,
|
||
Role: m.Role,
|
||
Content: truncateRunes(strings.TrimSpace(m.Content), semanticMaxContentRunes),
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// DigestAuthorizedInput produces a stable digest over minimized messages.
|
||
func DigestAuthorizedInput(msgs []MinimizedMessage) string {
|
||
h := sha256.New()
|
||
for _, m := range msgs {
|
||
_, _ = fmt.Fprintf(h, "%d|%s|%s\n", m.ID, m.Role, m.Content)
|
||
}
|
||
return hex.EncodeToString(h.Sum(nil))
|
||
}
|
||
|
||
// DigestAuthorizedVisibility digests the authorized-visible message identity set
|
||
// (id+role only). Used as chat_summaries.visibility_digest binding.
|
||
func DigestAuthorizedVisibility(msgs []MinimizedMessage) string {
|
||
h := sha256.New()
|
||
for _, m := range msgs {
|
||
_, _ = fmt.Fprintf(h, "%d|%s\n", m.ID, m.Role)
|
||
}
|
||
return hex.EncodeToString(h.Sum(nil))
|
||
}
|
||
|
||
// MergePriorSummaryIntoInput folds the previous deterministic summary into the
|
||
// semantic request so shadow generation does not drop earlier covered facts.
|
||
func MergePriorSummaryIntoInput(msgs []MinimizedMessage, prior *ChatSummary) (merged []MinimizedMessage, priorNarrative string, priorFacts map[string]string) {
|
||
merged = msgs
|
||
if prior == nil {
|
||
return merged, "", nil
|
||
}
|
||
priorNarrative = strings.TrimSpace(prior.Narrative)
|
||
if len(prior.Facts) > 0 {
|
||
priorFacts = make(map[string]string, len(prior.Facts))
|
||
for k, v := range prior.Facts {
|
||
priorFacts[k] = v
|
||
}
|
||
}
|
||
return merged, priorNarrative, priorFacts
|
||
}
|
||
|
||
// DigestSemanticInput digests minimized messages plus merged prior summary material.
|
||
func DigestSemanticInput(msgs []MinimizedMessage, priorNarrative string, priorFacts map[string]string) string {
|
||
h := sha256.New()
|
||
for _, m := range msgs {
|
||
_, _ = fmt.Fprintf(h, "%d|%s|%s\n", m.ID, m.Role, m.Content)
|
||
}
|
||
_, _ = fmt.Fprintf(h, "prior|%s\n", priorNarrative)
|
||
if len(priorFacts) > 0 {
|
||
keys := make([]string, 0, len(priorFacts))
|
||
for k := range priorFacts {
|
||
keys = append(keys, k)
|
||
}
|
||
sort.Strings(keys)
|
||
for _, k := range keys {
|
||
_, _ = fmt.Fprintf(h, "fact|%s=%s\n", k, priorFacts[k])
|
||
}
|
||
}
|
||
return hex.EncodeToString(h.Sum(nil))
|
||
}
|
||
|
||
// scheduleSemanticShadow attaches a pending advisory report and enqueues background work.
|
||
// Served Narrative/Facts remain deterministic; REPLACE stays unwired.
|
||
func scheduleSemanticShadow(ctx context.Context, m *ContextManager, sum *ChatSummary, msgs []ChatMessageRecord, prior *ChatSummary) {
|
||
if sum == nil || m == nil {
|
||
return
|
||
}
|
||
sum.Source = "deterministic"
|
||
if !SemanticSummaryShadowEnabled() {
|
||
return
|
||
}
|
||
now := time.Now()
|
||
minimized := MinimizeAuthorizedMessages(msgs)
|
||
merged, priorNarrative, priorFacts := MergePriorSummaryIntoInput(minimized, prior)
|
||
visDigest := DigestAuthorizedVisibility(merged)
|
||
if sum.VisibilityDigest == "" {
|
||
sum.VisibilityDigest = visDigest
|
||
}
|
||
inputDigest := DigestSemanticInput(merged, priorNarrative, priorFacts)
|
||
report := &SemanticShadowReport{
|
||
Status: ShadowStatusPending,
|
||
InputDigest: inputDigest,
|
||
ToolPermissions: SemanticToolPermissions,
|
||
GeneratedAt: now,
|
||
FreshnessNote: fmt.Sprintf("semantic_shadow_pending; deterministic served immediately at %s", now.UTC().Format(time.RFC3339)),
|
||
}
|
||
sum.Shadow = report
|
||
sum.FreshnessNote = report.FreshnessNote
|
||
job := SemanticShadowJob{
|
||
SessionID: sum.SessionID,
|
||
SummaryRevision: sum.Revision,
|
||
InputDigest: inputDigest,
|
||
VisibilityDigest: sum.VisibilityDigest,
|
||
WatermarkToMessageID: sum.ToMessageID,
|
||
PriorRevision: sum.PreviousRevision,
|
||
Messages: merged,
|
||
PriorNarrative: priorNarrative,
|
||
PriorFacts: priorFacts,
|
||
}
|
||
if err := m.EnqueueSemanticShadow(ctx, job); err != nil {
|
||
report.Status = ShadowStatusFallback
|
||
report.FreshnessNote = fmt.Sprintf("semantic_shadow_fallback: enqueue_failed; deterministic retained at %s", now.UTC().Format(time.RFC3339))
|
||
sum.Shadow = report
|
||
sum.FreshnessNote = report.FreshnessNote
|
||
}
|
||
}
|
||
|
||
func (m *ContextManager) SetSemanticSummarizer(s SemanticSummarizer) {
|
||
if m == nil {
|
||
return
|
||
}
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
m.semanticSummarizer = s
|
||
}
|
||
|
||
func (m *ContextManager) semanticSummarizerOrDefault() SemanticSummarizer {
|
||
if m == nil {
|
||
return &HeuristicSemanticSummarizer{}
|
||
}
|
||
m.mu.RLock()
|
||
s := m.semanticSummarizer
|
||
m.mu.RUnlock()
|
||
if s != nil {
|
||
return s
|
||
}
|
||
return &HeuristicSemanticSummarizer{}
|
||
}
|
||
|
||
// applySemanticShadow is retained for daily digest sync advisory evaluation.
|
||
// Rolling chat summaries use scheduleSemanticShadow (background reliable task).
|
||
// REPLACE is intentionally unwired: served narrative/facts stay deterministic.
|
||
func applySemanticShadow(ctx context.Context, sum *ChatSummary, msgs []ChatMessageRecord, summarizer SemanticSummarizer) {
|
||
if sum == nil {
|
||
return
|
||
}
|
||
sum.Source = "deterministic"
|
||
if !SemanticSummaryShadowEnabled() {
|
||
return
|
||
}
|
||
now := time.Now()
|
||
minimized := MinimizeAuthorizedMessages(msgs)
|
||
if sum.VisibilityDigest == "" {
|
||
sum.VisibilityDigest = DigestAuthorizedVisibility(minimized)
|
||
}
|
||
digest := DigestAuthorizedInput(minimized)
|
||
report := &SemanticShadowReport{
|
||
Status: ShadowStatusFallback,
|
||
InputDigest: digest,
|
||
ToolPermissions: SemanticToolPermissions,
|
||
GeneratedAt: now,
|
||
}
|
||
if summarizer == nil {
|
||
report.FreshnessNote = fmt.Sprintf("semantic_shadow_fallback: summarizer_unavailable; deterministic retained at %s", now.UTC().Format(time.RFC3339))
|
||
sum.Shadow = report
|
||
sum.FreshnessNote = report.FreshnessNote
|
||
return
|
||
}
|
||
res, err := summarizer.Summarize(ctx, SemanticSummaryRequest{
|
||
SessionID: sum.SessionID,
|
||
Messages: minimized,
|
||
InputDigest: digest,
|
||
VisibilityDigest: sum.VisibilityDigest,
|
||
WatermarkToMessageID: sum.ToMessageID,
|
||
ExpectedRevision: sum.Revision,
|
||
})
|
||
if err != nil || res == nil || strings.TrimSpace(res.Narrative) == "" {
|
||
reason := "empty_or_error"
|
||
if err != nil {
|
||
reason = "model_error"
|
||
}
|
||
report.FreshnessNote = fmt.Sprintf("semantic_shadow_fallback: %s; deterministic retained at %s", reason, now.UTC().Format(time.RFC3339))
|
||
sum.Shadow = report
|
||
sum.FreshnessNote = report.FreshnessNote
|
||
return
|
||
}
|
||
report.Status = ShadowStatusOK
|
||
report.Narrative = res.Narrative
|
||
report.Model = res.Model
|
||
report.Protocol = res.Protocol
|
||
if report.Protocol == "" {
|
||
report.Protocol = semanticProtocolV1
|
||
}
|
||
// Explicitly do NOT swap sum.Narrative here: the rolling-chat replacement
|
||
// path runs in persistShadowProvenance under the fail-closed gray rollout;
|
||
// this advisory path (daily digest sync) stays deterministic.
|
||
sum.Shadow = report
|
||
}
|
||
|
||
// applyDigestSemanticShadow attaches shadow metadata to a daily digest without replacing rule fields.
|
||
func applyDigestSemanticShadow(ctx context.Context, digest *DailyDigest, msgs []ChatMessageRecord, summarizer SemanticSummarizer) {
|
||
if digest == nil || !SemanticSummaryShadowEnabled() {
|
||
return
|
||
}
|
||
tmp := &ChatSummary{SessionID: digest.SessionID, Narrative: digest.Headline}
|
||
applySemanticShadow(ctx, tmp, msgs, summarizer)
|
||
if tmp.Shadow == nil {
|
||
return
|
||
}
|
||
digest.Shadow = tmp.Shadow
|
||
digest.FreshnessNote = tmp.FreshnessNote
|
||
// Headline/KeyPoints remain rule/deterministic; shadow narrative is advisory only.
|
||
}
|