Skip to main content

post_cortex_core/summary/
mod.rs

1// Copyright (c) 2025 Julius ML
2//
3// Permission is hereby granted, free of charge, to any person obtaining a copy
4// of this software and associated documentation files (the "Software"), to deal
5// in the Software without restriction, including without limitation the rights
6// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7// copies of the Software, and to permit persons to whom the Software is
8// furnished to do so, subject to the following conditions:
9//
10// The above copyright notice and this permission notice shall be included in all
11// copies or substantial portions of the Software.
12//
13// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19// SOFTWARE.
20//! Summary generation and presentation types.
21//!
22//! Provides the `SummaryGenerator` which produces structured summary views
23//! from an `ActiveSession`, along with filtering options.
24pub mod presentation;
25
26pub use presentation::{
27    ConceptSummary, DecisionSummary, EntitySummary, QuestionSummary, SessionStats,
28    StructuredSummaryView,
29};
30
31use crate::session::active_session::ActiveSession;
32use chrono::Utc;
33
34/// Options for filtering and limiting summary output
35#[derive(Debug, Clone)]
36#[derive(Default)]
37pub struct SummaryOptions {
38    /// Maximum number of decisions to include
39    pub decisions_limit: Option<usize>,
40    /// Maximum number of entities to include
41    pub entities_limit: Option<usize>,
42    /// Maximum number of questions to include
43    pub questions_limit: Option<usize>,
44    /// Maximum number of concepts to include
45    pub concepts_limit: Option<usize>,
46    /// Minimum confidence threshold for decisions
47    pub min_confidence: Option<f32>,
48    /// Whether to produce a compact (minimal) summary
49    pub compact: bool,
50}
51
52
53impl SummaryOptions {
54    /// Create compact mode options (returns minimal data)
55    pub fn compact() -> Self {
56        Self {
57            decisions_limit: Some(10), // Increased from 5
58            entities_limit: Some(15),  // Increased from 10
59            questions_limit: Some(5),
60            concepts_limit: Some(5),
61            min_confidence: Some(0.4), // Lowered from 0.6 to include more decisions
62            compact: true,
63        }
64    }
65
66    /// Create default options with limits
67    pub fn with_limits(
68        decisions: usize,
69        entities: usize,
70        questions: usize,
71        concepts: usize,
72    ) -> Self {
73        Self {
74            decisions_limit: Some(decisions),
75            entities_limit: Some(entities),
76            questions_limit: Some(questions),
77            concepts_limit: Some(concepts),
78            min_confidence: None,
79            compact: false,
80        }
81    }
82}
83
84/// Main summary generator that uses existing structured data
85/// All methods are associated functions (no state needed)
86pub struct SummaryGenerator;
87
88impl SummaryGenerator {
89    /// Generate structured summary from existing ActiveSession data
90    pub fn generate_structured_summary(session: &ActiveSession) -> StructuredSummaryView {
91        Self::generate_structured_summary_filtered(session, &SummaryOptions::default())
92    }
93
94    /// Generate filtered/limited structured summary
95    pub fn generate_structured_summary_filtered(
96        session: &ActiveSession,
97        options: &SummaryOptions,
98    ) -> StructuredSummaryView {
99        let session_stats = Self::calculate_session_stats(session);
100
101        // Get entity limit for both summaries and names
102        let entity_limit = if options.compact {
103            10
104        } else {
105            options.entities_limit.unwrap_or(20)
106        };
107
108        // Single pass: get entity analysis, truncate, then derive names
109        let mut entity_analysis = session.entity_graph.analyze_entity_importance();
110        entity_analysis.truncate(entity_limit);
111
112        // Derive important_entities from entity_analysis (already sorted by importance)
113        let important_entities: Vec<String> = entity_analysis
114            .iter()
115            .map(|e| e.entity_name.clone())
116            .collect();
117
118        // Filter and limit decisions
119        let mut key_decisions: Vec<DecisionSummary> = session
120            .current_state
121            .key_decisions
122            .iter()
123            .filter(|d| {
124                if let Some(min_conf) = options.min_confidence {
125                    d.confidence >= min_conf
126                } else {
127                    true
128                }
129            })
130            .map(DecisionSummary::from_decision_item)
131            .collect();
132
133        // Sort by confidence (highest first) then by timestamp
134        key_decisions.sort_by(|a, b| {
135            b.confidence
136                .partial_cmp(&a.confidence)
137                .unwrap_or(std::cmp::Ordering::Equal)
138                .then_with(|| b.timestamp.cmp(&a.timestamp))
139        });
140
141        if let Some(limit) = options.decisions_limit {
142            key_decisions.truncate(limit);
143        }
144
145        // Limit questions
146        let mut open_questions: Vec<QuestionSummary> = session
147            .current_state
148            .open_questions
149            .iter()
150            .map(QuestionSummary::from_question_item)
151            .collect();
152
153        // Sort by urgency and recency
154        open_questions.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
155
156        if let Some(limit) = options.questions_limit {
157            open_questions.truncate(limit);
158        }
159
160        // Limit concepts
161        let mut key_concepts: Vec<ConceptSummary> = session
162            .current_state
163            .key_concepts
164            .iter()
165            .map(ConceptSummary::from_concept_item)
166            .collect();
167
168        // Sort by timestamp (most recent first)
169        key_concepts.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
170
171        if let Some(limit) = options.concepts_limit {
172            key_concepts.truncate(limit);
173        }
174
175        StructuredSummaryView {
176            session_id: session.id(),
177            generated_at: Utc::now(),
178
179            // Filtered and limited data
180            key_decisions,
181            open_questions,
182            key_concepts,
183
184            // From SimpleEntityGraph (limited)
185            important_entities,
186            entity_summaries: entity_analysis
187                .into_iter()
188                .map(|analysis| EntitySummary::from_entity_analysis(&analysis))
189                .collect(),
190
191            // Session metadata
192            session_stats,
193        }
194    }
195
196    /// Calculate session statistics from existing data
197    /// This is a lightweight operation that doesn't require full summary generation
198    pub fn calculate_session_stats(session: &ActiveSession) -> SessionStats {
199        use crate::summary::presentation::SessionStatsBuilder;
200
201        SessionStatsBuilder::new(session.id(), session.created_at(), session.last_updated)
202            .with_context_sizes(
203                session.hot_context.len(),
204                session.warm_context.len(),
205                session.cold_context.len(),
206            )
207            .with_counts(
208                session.incremental_updates.len(),
209                session.entity_graph.entities.len(),
210                session.current_state.key_decisions.len(),
211            )
212            .with_references(
213                session.current_state.open_questions.len(),
214                session.current_state.key_concepts.len(),
215                session.code_references.values().map(|v| v.len()).sum(),
216            )
217            .build()
218    }
219
220    /// Estimate summary size in tokens without full generation
221    /// Returns (estimated_tokens, should_use_compact)
222    pub fn estimate_summary_size(session: &ActiveSession, max_tokens: usize) -> (usize, bool) {
223        // Average sizes per item (empirically determined from JSON serialization)
224        const DECISION_AVG_TOKENS: usize = 150;
225        const ENTITY_AVG_TOKENS: usize = 80;
226        const QUESTION_AVG_TOKENS: usize = 100;
227        const CONCEPT_AVG_TOKENS: usize = 120;
228        const BASE_OVERHEAD_TOKENS: usize = 500;
229
230        let decision_count = session.current_state.key_decisions.len();
231        let entity_count = session.entity_graph.entities.len();
232        let question_count = session.current_state.open_questions.len();
233        let concept_count = session.current_state.key_concepts.len();
234
235        let estimated_tokens = BASE_OVERHEAD_TOKENS
236            + (decision_count * DECISION_AVG_TOKENS)
237            + (entity_count * ENTITY_AVG_TOKENS)
238            + (question_count * QUESTION_AVG_TOKENS)
239            + (concept_count * CONCEPT_AVG_TOKENS);
240
241        (estimated_tokens, estimated_tokens > max_tokens)
242    }
243
244    /// Extract key insights from existing data
245    pub fn extract_key_insights(session: &ActiveSession, limit: usize) -> Vec<String> {
246        let mut insights = Vec::new();
247
248        // Insights from decisions
249        for decision in &session.current_state.key_decisions {
250            if decision.confidence > 0.8 {
251                insights.push(format!(
252                    "High-confidence decision: {}",
253                    decision.description
254                ));
255            }
256        }
257
258        // Insights from entity importance
259        let top_entities = session.entity_graph.get_most_important_entities(3);
260        if !top_entities.is_empty() {
261            let entity_names: Vec<String> = top_entities.iter().map(|e| e.name.clone()).collect();
262            let entity_list = entity_names.join(", ");
263            insights.push(format!("Primary focus areas: {}", entity_list));
264        }
265
266        // Insights from update patterns
267        let total_updates = session.incremental_updates.len();
268        if total_updates > 10 {
269            insights.push(format!(
270                "Comprehensive discussion with {} updates",
271                total_updates
272            ));
273        }
274
275        // Insights from code references
276        let code_files: Vec<_> = session.code_references.keys().collect();
277        if code_files.len() > 1 {
278            insights.push(format!(
279                "Multi-file code analysis covering {} files",
280                code_files.len()
281            ));
282        }
283
284        // Limit results
285        insights.truncate(limit);
286        insights
287    }
288
289    /// Extract decision timeline from existing data
290    pub fn extract_decision_timeline(session: &ActiveSession) -> Vec<DecisionSummary> {
291        let mut decisions: Vec<_> = session
292            .current_state
293            .key_decisions
294            .iter()
295            .map(DecisionSummary::from_decision_item)
296            .collect();
297
298        // Sort by timestamp
299        decisions.sort_by_key(|d| d.timestamp);
300        decisions
301    }
302}
303
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use crate::session::active_session::ActiveSession;
309    use uuid::Uuid;
310
311    #[test]
312    fn test_summary_generation() {
313        let session = ActiveSession::new(
314            Uuid::new_v4(),
315            Some("Test Session".to_string()),
316            Some("Test session for summary generation".to_string()),
317        );
318
319        let summary = SummaryGenerator::generate_structured_summary(&session);
320
321        assert_eq!(summary.session_id, session.id());
322        assert!(summary.generated_at <= Utc::now());
323        assert_eq!(summary.session_stats.hot_context_size, 0); // Empty session
324    }
325
326    #[test]
327    fn test_key_insights_extraction() {
328        let session = ActiveSession::new(
329            Uuid::new_v4(),
330            Some("Test Session".to_string()),
331            Some("Test session for insights".to_string()),
332        );
333
334        let insights = SummaryGenerator::extract_key_insights(&session, 5);
335
336        // Empty session should have minimal insights
337        assert!(insights.len() <= 5);
338    }
339}