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, Default)]
36pub struct SummaryOptions {
37    /// Maximum number of decisions to include
38    pub decisions_limit: Option<usize>,
39    /// Maximum number of entities to include
40    pub entities_limit: Option<usize>,
41    /// Maximum number of questions to include
42    pub questions_limit: Option<usize>,
43    /// Maximum number of concepts to include
44    pub concepts_limit: Option<usize>,
45    /// Minimum confidence threshold for decisions
46    pub min_confidence: Option<f32>,
47    /// Whether to produce a compact (minimal) summary
48    pub compact: bool,
49}
50
51impl SummaryOptions {
52    /// Create compact mode options (returns minimal data)
53    pub fn compact() -> Self {
54        Self {
55            decisions_limit: Some(10), // Increased from 5
56            entities_limit: Some(15),  // Increased from 10
57            questions_limit: Some(5),
58            concepts_limit: Some(5),
59            min_confidence: Some(0.4), // Lowered from 0.6 to include more decisions
60            compact: true,
61        }
62    }
63
64    /// Create default options with limits
65    pub fn with_limits(
66        decisions: usize,
67        entities: usize,
68        questions: usize,
69        concepts: usize,
70    ) -> Self {
71        Self {
72            decisions_limit: Some(decisions),
73            entities_limit: Some(entities),
74            questions_limit: Some(questions),
75            concepts_limit: Some(concepts),
76            min_confidence: None,
77            compact: false,
78        }
79    }
80}
81
82/// Main summary generator that uses existing structured data
83/// All methods are associated functions (no state needed)
84pub struct SummaryGenerator;
85
86impl SummaryGenerator {
87    /// Generate structured summary from existing ActiveSession data
88    pub fn generate_structured_summary(session: &ActiveSession) -> StructuredSummaryView {
89        Self::generate_structured_summary_filtered(session, &SummaryOptions::default())
90    }
91
92    /// Generate filtered/limited structured summary
93    pub fn generate_structured_summary_filtered(
94        session: &ActiveSession,
95        options: &SummaryOptions,
96    ) -> StructuredSummaryView {
97        let session_stats = Self::calculate_session_stats(session);
98
99        // Get entity limit for both summaries and names
100        let entity_limit = if options.compact {
101            10
102        } else {
103            options.entities_limit.unwrap_or(20)
104        };
105
106        // Single pass: get entity analysis, truncate, then derive names
107        let mut entity_analysis = session.entity_graph.analyze_entity_importance();
108        entity_analysis.truncate(entity_limit);
109
110        // Derive important_entities from entity_analysis (already sorted by importance)
111        let important_entities: Vec<String> = entity_analysis
112            .iter()
113            .map(|e| e.entity_name.clone())
114            .collect();
115
116        // Filter and limit decisions
117        let mut key_decisions: Vec<DecisionSummary> = session
118            .current_state
119            .key_decisions
120            .iter()
121            .filter(|d| {
122                if let Some(min_conf) = options.min_confidence {
123                    d.confidence >= min_conf
124                } else {
125                    true
126                }
127            })
128            .map(DecisionSummary::from_decision_item)
129            .collect();
130
131        // Sort by confidence (highest first) then by timestamp
132        key_decisions.sort_by(|a, b| {
133            b.confidence
134                .partial_cmp(&a.confidence)
135                .unwrap_or(std::cmp::Ordering::Equal)
136                .then_with(|| b.timestamp.cmp(&a.timestamp))
137        });
138
139        if let Some(limit) = options.decisions_limit {
140            key_decisions.truncate(limit);
141        }
142
143        // Limit questions
144        let mut open_questions: Vec<QuestionSummary> = session
145            .current_state
146            .open_questions
147            .iter()
148            .map(QuestionSummary::from_question_item)
149            .collect();
150
151        // Sort by urgency and recency
152        open_questions.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
153
154        if let Some(limit) = options.questions_limit {
155            open_questions.truncate(limit);
156        }
157
158        // Limit concepts
159        let mut key_concepts: Vec<ConceptSummary> = session
160            .current_state
161            .key_concepts
162            .iter()
163            .map(ConceptSummary::from_concept_item)
164            .collect();
165
166        // Sort by timestamp (most recent first)
167        key_concepts.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
168
169        if let Some(limit) = options.concepts_limit {
170            key_concepts.truncate(limit);
171        }
172
173        StructuredSummaryView {
174            session_id: session.id(),
175            generated_at: Utc::now(),
176
177            // Filtered and limited data
178            key_decisions,
179            open_questions,
180            key_concepts,
181
182            // From SimpleEntityGraph (limited)
183            important_entities,
184            entity_summaries: entity_analysis
185                .into_iter()
186                .map(|analysis| EntitySummary::from_entity_analysis(&analysis))
187                .collect(),
188
189            // Session metadata
190            session_stats,
191        }
192    }
193
194    /// Calculate session statistics from existing data
195    /// This is a lightweight operation that doesn't require full summary generation
196    pub fn calculate_session_stats(session: &ActiveSession) -> SessionStats {
197        use crate::summary::presentation::SessionStatsBuilder;
198
199        SessionStatsBuilder::new(session.id(), session.created_at(), session.last_updated)
200            .with_context_sizes(
201                session.hot_context.len(),
202                session.warm_context.len(),
203                session.cold_context.len(),
204            )
205            .with_counts(
206                session.incremental_updates.len(),
207                session.entity_graph.entities.len(),
208                session.current_state.key_decisions.len(),
209            )
210            .with_references(
211                session.current_state.open_questions.len(),
212                session.current_state.key_concepts.len(),
213                session.code_references.values().map(|v| v.len()).sum(),
214            )
215            .build()
216    }
217
218    /// Estimate summary size in tokens without full generation
219    /// Returns (estimated_tokens, should_use_compact)
220    pub fn estimate_summary_size(session: &ActiveSession, max_tokens: usize) -> (usize, bool) {
221        // Average sizes per item (empirically determined from JSON serialization)
222        const DECISION_AVG_TOKENS: usize = 150;
223        const ENTITY_AVG_TOKENS: usize = 80;
224        const QUESTION_AVG_TOKENS: usize = 100;
225        const CONCEPT_AVG_TOKENS: usize = 120;
226        const BASE_OVERHEAD_TOKENS: usize = 500;
227
228        let decision_count = session.current_state.key_decisions.len();
229        let entity_count = session.entity_graph.entities.len();
230        let question_count = session.current_state.open_questions.len();
231        let concept_count = session.current_state.key_concepts.len();
232
233        let estimated_tokens = BASE_OVERHEAD_TOKENS
234            + (decision_count * DECISION_AVG_TOKENS)
235            + (entity_count * ENTITY_AVG_TOKENS)
236            + (question_count * QUESTION_AVG_TOKENS)
237            + (concept_count * CONCEPT_AVG_TOKENS);
238
239        (estimated_tokens, estimated_tokens > max_tokens)
240    }
241
242    /// Extract key insights from existing data
243    pub fn extract_key_insights(session: &ActiveSession, limit: usize) -> Vec<String> {
244        let mut insights = Vec::new();
245
246        // Insights from decisions
247        for decision in &session.current_state.key_decisions {
248            if decision.confidence > 0.8 {
249                insights.push(format!(
250                    "High-confidence decision: {}",
251                    decision.description
252                ));
253            }
254        }
255
256        // Insights from entity importance
257        let top_entities = session.entity_graph.get_most_important_entities(3);
258        if !top_entities.is_empty() {
259            let entity_names: Vec<String> = top_entities.iter().map(|e| e.name.clone()).collect();
260            let entity_list = entity_names.join(", ");
261            insights.push(format!("Primary focus areas: {}", entity_list));
262        }
263
264        // Insights from update patterns
265        let total_updates = session.incremental_updates.len();
266        if total_updates > 10 {
267            insights.push(format!(
268                "Comprehensive discussion with {} updates",
269                total_updates
270            ));
271        }
272
273        // Insights from code references
274        let code_files: Vec<_> = session.code_references.keys().collect();
275        if code_files.len() > 1 {
276            insights.push(format!(
277                "Multi-file code analysis covering {} files",
278                code_files.len()
279            ));
280        }
281
282        // Limit results
283        insights.truncate(limit);
284        insights
285    }
286
287    /// Extract decision timeline from existing data
288    pub fn extract_decision_timeline(session: &ActiveSession) -> Vec<DecisionSummary> {
289        let mut decisions: Vec<_> = session
290            .current_state
291            .key_decisions
292            .iter()
293            .map(DecisionSummary::from_decision_item)
294            .collect();
295
296        // Sort by timestamp
297        decisions.sort_by_key(|d| d.timestamp);
298        decisions
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305    use crate::session::active_session::ActiveSession;
306    use uuid::Uuid;
307
308    #[test]
309    fn test_summary_generation() {
310        let session = ActiveSession::new(
311            Uuid::new_v4(),
312            Some("Test Session".to_string()),
313            Some("Test session for summary generation".to_string()),
314        );
315
316        let summary = SummaryGenerator::generate_structured_summary(&session);
317
318        assert_eq!(summary.session_id, session.id());
319        assert!(summary.generated_at <= Utc::now());
320        assert_eq!(summary.session_stats.hot_context_size, 0); // Empty session
321    }
322
323    #[test]
324    fn test_key_insights_extraction() {
325        let session = ActiveSession::new(
326            Uuid::new_v4(),
327            Some("Test Session".to_string()),
328            Some("Test session for insights".to_string()),
329        );
330
331        let insights = SummaryGenerator::extract_key_insights(&session, 5);
332
333        // Empty session should have minimal insights
334        assert!(insights.len() <= 5);
335    }
336}