diff --git a/internal/ctxmgr/chat_digest.go b/internal/ctxmgr/chat_digest.go index 7222f4ed..2a0adf1f 100644 --- a/internal/ctxmgr/chat_digest.go +++ b/internal/ctxmgr/chat_digest.go @@ -99,6 +99,7 @@ func (m *ContextManager) GenerateDailyDigestFromMessages(ctx context.Context, se } digest := m.extractRuleDigest(sessionID, projectID, dayKey, msgs) + applyDigestSemanticShadow(ctx, digest, msgs, m.semanticSummarizerOrDefault()) if m.pool != nil { keyPointsJSON, _ := json.Marshal(digest.KeyPoints) diff --git a/internal/ctxmgr/manager.go b/internal/ctxmgr/manager.go index ae72a5cf..430e60e5 100644 --- a/internal/ctxmgr/manager.go +++ b/internal/ctxmgr/manager.go @@ -22,20 +22,21 @@ type ContextManager struct { embedder VectorEmbedder // 离线降级内存缓存 - mu sync.RWMutex - users map[int64]*UserPreference - projects map[int64]*ProjectCollectiveContext - messages map[string][]ChatMessageRecord // session_id -> messages - sessions map[string]*ChatSessionSummary // session_id -> summary - activities map[int64][]ActivityItem // project_id -> activities - memoryChunks []MemoryChunkItem // 语义记忆切片缓存 - summaries map[string]*ChatSummary // session_id -> rolling summary - digests map[string]map[string]*DailyDigest // session_id -> day_key -> DailyDigest - readPositions map[string]*ChatReadPosition // session_id:user_id -> read position - cache *MultiLevelCache // 多级统一缓存系统 (C1-C5) - nextActivityID int64 - nextMessageID int64 - nextChunkID int64 + mu sync.RWMutex + users map[int64]*UserPreference + projects map[int64]*ProjectCollectiveContext + messages map[string][]ChatMessageRecord // session_id -> messages + sessions map[string]*ChatSessionSummary // session_id -> summary + activities map[int64][]ActivityItem // project_id -> activities + memoryChunks []MemoryChunkItem // 语义记忆切片缓存 + summaries map[string]*ChatSummary // session_id -> rolling summary + digests map[string]map[string]*DailyDigest // session_id -> day_key -> DailyDigest + readPositions map[string]*ChatReadPosition // session_id:user_id -> read position + cache *MultiLevelCache // 多级统一缓存系统 (C1-C5) + semanticSummarizer SemanticSummarizer // no-tool summarizer; nil → heuristic default + nextActivityID int64 + nextMessageID int64 + nextChunkID int64 } // NewContextManager 创建上下文管理器 diff --git a/internal/ctxmgr/semantic_summary.go b/internal/ctxmgr/semantic_summary.go new file mode 100644 index 00000000..d8ab6e58 --- /dev/null +++ b/internal/ctxmgr/semantic_summary.go @@ -0,0 +1,274 @@ +package ctxmgr + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "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 +} + +// SemanticSummaryResult is the summarizer output (advisory under shadow). +type SemanticSummaryResult struct { + Narrative string + Model string + Protocol string +} + +// SemanticShadowReport captures shadow evaluation without replacing deterministic narrative. +type SemanticShadowReport struct { + Status string `json:"status"` + Narrative string `json:"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)) +} + +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 evaluates shadow semantic summary without replacing deterministic fields. +// 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) + 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, + }) + 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 even if REPLACE env is true. + _ = SemanticSummaryReplaceEnabled() + 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. +} diff --git a/internal/ctxmgr/summarizer.go b/internal/ctxmgr/summarizer.go index 77c754ee..a92d8b9e 100644 --- a/internal/ctxmgr/summarizer.go +++ b/internal/ctxmgr/summarizer.go @@ -10,13 +10,16 @@ import ( // ChatSummary 会话滚动摘要数据模型 type ChatSummary struct { - SessionID string `json:"session_id"` - Narrative string `json:"narrative"` // 叙事摘要(≤300 字) - 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"` + 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 未接线 } // RenderPromptSnippet 渲染为 Prompt 中的【早期对话摘要】段 @@ -188,6 +191,11 @@ func (m *ContextManager) GenerateRollingSummary(ctx context.Context, sessionID s fullNarrative = string(runes[len(runes)-260:]) + "..." } newSum.Narrative = fullNarrative + newSum.Source = "deterministic" + + // SEMANTIC_SUMMARY_SHADOW:仅在授权可见集合上评估语义摘要;失败保留 deterministic + freshness。 + // SEMANTIC_SUMMARY_REPLACE 故意不接线,served Narrative 永不被语义结果替换。 + applySemanticShadow(ctx, newSum, earlyMsgs, m.semanticSummarizerOrDefault()) // 保存并返回 _ = m.SaveChatSummary(ctx, newSum) diff --git a/internal/ctxmgr/summary_flags.go b/internal/ctxmgr/summary_flags.go new file mode 100644 index 00000000..31bf2764 --- /dev/null +++ b/internal/ctxmgr/summary_flags.go @@ -0,0 +1,71 @@ +package ctxmgr + +import ( + "os" + "strings" + "sync" +) + +const ( + EnvSemanticSummaryShadow = "SEMANTIC_SUMMARY_SHADOW" + EnvSemanticSummaryReplace = "SEMANTIC_SUMMARY_REPLACE" +) + +// SummaryFeatureFlags controls semantic summary rollout. +// REPLACE is reserved and must remain unwired in PU6 shadow delivery. +type SummaryFeatureFlags struct { + Shadow bool + Replace bool +} + +var ( + summaryFlagsMu sync.RWMutex + activeSummaryFlags = loadSummaryFlagsFromEnv() +) + +func loadSummaryFlagsFromEnv() SummaryFeatureFlags { + return SummaryFeatureFlags{ + Shadow: envBoolDefault(EnvSemanticSummaryShadow, false), + Replace: envBoolDefault(EnvSemanticSummaryReplace, false), + } +} + +func envBoolDefault(key string, def bool) bool { + v := strings.TrimSpace(os.Getenv(key)) + if v == "" { + return def + } + return v == "1" || strings.EqualFold(v, "true") || strings.EqualFold(v, "yes") +} + +// CurrentSummaryFlags returns the active summary feature flags. +func CurrentSummaryFlags() SummaryFeatureFlags { + summaryFlagsMu.RLock() + defer summaryFlagsMu.RUnlock() + return activeSummaryFlags +} + +// SemanticSummaryShadowEnabled reports whether shadow semantic summary is on. +func SemanticSummaryShadowEnabled() bool { + return CurrentSummaryFlags().Shadow +} + +// SemanticSummaryReplaceEnabled reports the REPLACE env bit. +// PU6 must not use this to swap the served deterministic narrative. +func SemanticSummaryReplaceEnabled() bool { + return CurrentSummaryFlags().Replace +} + +// SetSummaryFlagsForTest overrides flags in tests. +func SetSummaryFlagsForTest(f SummaryFeatureFlags) { + summaryFlagsMu.Lock() + activeSummaryFlags = f + summaryFlagsMu.Unlock() +} + +// ResetSummaryFlagsForTest restores flags from the process environment. +func ResetSummaryFlagsForTest() { + summaryFlagsMu.Lock() + activeSummaryFlags = loadSummaryFlagsFromEnv() + summaryFlagsMu.Unlock() +} diff --git a/internal/ctxmgr/types.go b/internal/ctxmgr/types.go index 3b3496a6..f1f4fa53 100644 --- a/internal/ctxmgr/types.go +++ b/internal/ctxmgr/types.go @@ -17,39 +17,39 @@ const ( // UserPreference 用户工作习惯与产出偏好画像 type UserPreference struct { - UserID int64 `json:"user_id"` - AccountID string `json:"account_id"` - Name string `json:"name"` - Role string `json:"role"` - CommunicationStyle CommunicationStyle `json:"communication_style"` - FocusAreas []string `json:"focus_areas"` // 关注维度,如 "reg_compliance", "cost_budget", "timeline", "optical_quality" - OutputFormatPref string `json:"output_format_pref"` // 报告格式要求,如 "standard_report", "bilingual_brief", "ppt_bullets" - CustomInstructions string `json:"custom_instructions"` // 用户直接指定的定制要求 - MemoryFacts map[string]string `json:"memory_facts"` // 异步蒸馏出的长期事实记忆 (如 "预算通常按千万级申报", "偏好使用迈瑞作为对标竞品") - UpdatedAt time.Time `json:"updated_at"` + UserID int64 `json:"user_id"` + AccountID string `json:"account_id"` + Name string `json:"name"` + Role string `json:"role"` + CommunicationStyle CommunicationStyle `json:"communication_style"` + FocusAreas []string `json:"focus_areas"` // 关注维度,如 "reg_compliance", "cost_budget", "timeline", "optical_quality" + OutputFormatPref string `json:"output_format_pref"` // 报告格式要求,如 "standard_report", "bilingual_brief", "ppt_bullets" + CustomInstructions string `json:"custom_instructions"` // 用户直接指定的定制要求 + MemoryFacts map[string]string `json:"memory_facts"` // 异步蒸馏出的长期事实记忆 (如 "预算通常按千万级申报", "偏好使用迈瑞作为对标竞品") + UpdatedAt time.Time `json:"updated_at"` } // ActivityItem 项目活动记录 (操作、决策、上传、下载) type ActivityItem struct { - ID int64 `json:"id"` - ProjectID int64 `json:"project_id"` - UserID int64 `json:"user_id"` - UserName string `json:"user_name"` - ActivityType string `json:"activity_type"` // decision, operation, upload, download, stage_advance, chat - Title string `json:"title"` - Detail map[string]any `json:"detail,omitempty"` - RawWeight float64 `json:"raw_weight"` // 初始权重 - DecayedWeight float64 `json:"decayed_weight"`// 时间衰减后当前有效权重 - IsMilestone bool `json:"is_milestone"` // 里程碑事件不衰减 - CreatedAt time.Time `json:"created_at"` + ID int64 `json:"id"` + ProjectID int64 `json:"project_id"` + UserID int64 `json:"user_id"` + UserName string `json:"user_name"` + ActivityType string `json:"activity_type"` // decision, operation, upload, download, stage_advance, chat + Title string `json:"title"` + Detail map[string]any `json:"detail,omitempty"` + RawWeight float64 `json:"raw_weight"` // 初始权重 + DecayedWeight float64 `json:"decayed_weight"` // 时间衰减后当前有效权重 + IsMilestone bool `json:"is_milestone"` // 里程碑事件不衰减 + CreatedAt time.Time `json:"created_at"` } // TimeDecayConfig 时间权重衰减配置 type TimeDecayConfig struct { - Enabled bool `json:"enabled"` - HalfLifeDays float64 `json:"half_life_days"` // 半衰期天数 (如 7.0 天) - MinWeightFloor float64 `json:"min_weight_floor"` // 权重衰减下限 (如 0.10,防止被完全抹除) - MilestoneExempt bool `json:"milestone_exempt"` // 里程碑事件是否豁免衰减 (默认 true) + Enabled bool `json:"enabled"` + HalfLifeDays float64 `json:"half_life_days"` // 半衰期天数 (如 7.0 天) + MinWeightFloor float64 `json:"min_weight_floor"` // 权重衰减下限 (如 0.10,防止被完全抹除) + MilestoneExempt bool `json:"milestone_exempt"` // 里程碑事件是否豁免衰减 (默认 true) } // DefaultTimeDecayConfig 默认时间衰减配置 @@ -64,40 +64,40 @@ func DefaultTimeDecayConfig() TimeDecayConfig { // SingleUserContext 单用户私有上下文 type SingleUserContext struct { - UserID int64 `json:"user_id"` - AccountID string `json:"account_id"` - Name string `json:"name"` - Role string `json:"role"` - Preference *UserPreference `json:"preference"` - SessionID string `json:"session_id"` - RecentChats []ChatMessageRecord `json:"recent_chats"` - MyActivities []ActivityItem `json:"my_activities"` - MyFiles []*storage.FileReference `json:"my_files"` - AssembledAt time.Time `json:"assembled_at"` + UserID int64 `json:"user_id"` + AccountID string `json:"account_id"` + Name string `json:"name"` + Role string `json:"role"` + Preference *UserPreference `json:"preference"` + SessionID string `json:"session_id"` + RecentChats []ChatMessageRecord `json:"recent_chats"` + MyActivities []ActivityItem `json:"my_activities"` + MyFiles []*storage.FileReference `json:"my_files"` + AssembledAt time.Time `json:"assembled_at"` } // ProjectCollectiveContext 项目级多方协同上下文 type ProjectCollectiveContext struct { - ProjectID int64 `json:"project_id"` - ProjectCode string `json:"project_code"` - ProjectName string `json:"project_name"` + ProjectID int64 `json:"project_id"` + ProjectCode string `json:"project_code"` + ProjectName string `json:"project_name"` CurrentStage string `json:"current_stage"` CreatorUserID int64 `json:"creator_user_id,omitempty"` ActiveWorkflowKey string `json:"active_workflow_key,omitempty"` WorkflowSelectionVersion int `json:"workflow_selection_version,omitempty"` BlackboardFacts map[string]string `json:"blackboard_facts"` - AllActivities []ActivityItem `json:"all_activities"` - MilestoneDecisions []ActivityItem `json:"milestone_decisions"` - SharedFiles []*storage.FileReference `json:"shared_files"` - ParticipatingUsers []UserSummary `json:"participating_users"` - AssembledAt time.Time `json:"assembled_at"` + AllActivities []ActivityItem `json:"all_activities"` + MilestoneDecisions []ActivityItem `json:"milestone_decisions"` + SharedFiles []*storage.FileReference `json:"shared_files"` + ParticipatingUsers []UserSummary `json:"participating_users"` + AssembledAt time.Time `json:"assembled_at"` } // UserSummary 参与项目的用户摘要 type UserSummary struct { - UserID int64 `json:"user_id"` - Name string `json:"name"` - Role string `json:"role"` + UserID int64 `json:"user_id"` + Name string `json:"name"` + Role string `json:"role"` } // ChatMessageRecord 结构化会话消息 @@ -181,16 +181,16 @@ type ChatCategoryStat struct { // BlendedSquadContext 智能体小队混合上下文 (用于注入到 Agent Prompt 中) type BlendedSquadContext struct { - ProjectName string `json:"project_name"` - CurrentStage string `json:"current_stage"` - TaskKey string `json:"task_key"` - AgentRole string `json:"agent_role"` - BlackboardFacts map[string]string `json:"blackboard_facts"` - KeyDecisions []string `json:"key_decisions"` - RelevantFiles []string `json:"relevant_files"` - TriggerUserPreference *UserPreference `json:"trigger_user_preference,omitempty"` - PersonalizedGuidance string `json:"personalized_guidance"` // 针对当前用户偏好生成的注入指导语 - AssembledAt time.Time `json:"assembled_at"` + ProjectName string `json:"project_name"` + CurrentStage string `json:"current_stage"` + TaskKey string `json:"task_key"` + AgentRole string `json:"agent_role"` + BlackboardFacts map[string]string `json:"blackboard_facts"` + KeyDecisions []string `json:"key_decisions"` + RelevantFiles []string `json:"relevant_files"` + TriggerUserPreference *UserPreference `json:"trigger_user_preference,omitempty"` + PersonalizedGuidance string `json:"personalized_guidance"` // 针对当前用户偏好生成的注入指导语 + AssembledAt time.Time `json:"assembled_at"` } // SessionFilter 历史会话查询过滤条件 @@ -308,25 +308,26 @@ type DigestDeliverable struct { // DailyDigest 按日智能摘要 type DailyDigest struct { - ID int64 `json:"id,omitempty"` - SessionID string `json:"session_id"` - ProjectID int64 `json:"project_id,omitempty"` - DayKey string `json:"day_key"` - Headline string `json:"headline"` - KeyPoints []string `json:"key_points"` - Deliverables []DigestDeliverable `json:"deliverables"` - Decisions []string `json:"decisions"` - OpenQuestions []string `json:"open_questions"` - StageProgress string `json:"stage_progress"` - FirstMessageID int64 `json:"first_message_id"` - LastMessageID int64 `json:"last_message_id"` - MessageCount int `json:"message_count"` - Status string `json:"status"` // pending | generating | ready | fallback | failed - Source string `json:"source"` // llm | rule - Model string `json:"model,omitempty"` - TokenUsage int `json:"token_usage,omitempty"` - DigestVersion int `json:"digest_version"` - GeneratedAt *time.Time `json:"generated_at,omitempty"` - UpdatedAt time.Time `json:"updated_at"` + ID int64 `json:"id,omitempty"` + SessionID string `json:"session_id"` + ProjectID int64 `json:"project_id,omitempty"` + DayKey string `json:"day_key"` + Headline string `json:"headline"` + KeyPoints []string `json:"key_points"` + Deliverables []DigestDeliverable `json:"deliverables"` + Decisions []string `json:"decisions"` + OpenQuestions []string `json:"open_questions"` + StageProgress string `json:"stage_progress"` + FirstMessageID int64 `json:"first_message_id"` + LastMessageID int64 `json:"last_message_id"` + MessageCount int `json:"message_count"` + Status string `json:"status"` // pending | generating | ready | fallback | failed + Source string `json:"source"` // llm | rule + Model string `json:"model,omitempty"` + TokenUsage int `json:"token_usage,omitempty"` + DigestVersion int `json:"digest_version"` + GeneratedAt *time.Time `json:"generated_at,omitempty"` + UpdatedAt time.Time `json:"updated_at"` + FreshnessNote string `json:"freshness_note,omitempty"` + Shadow *SemanticShadowReport `json:"shadow,omitempty"` } -