Skip to main content

lore_cli/summarize/
mod.rs

1//! Session summary generation via LLM providers.
2//!
3//! This module provides the ability to generate summaries of AI-assisted
4//! development sessions using various LLM providers (Anthropic, OpenAI,
5//! OpenRouter). It includes provider configuration, API communication,
6//! and error handling.
7//!
8//! # Usage
9//!
10//! The main entry point is [`generate_summary`], which resolves the provider
11//! configuration and calls the appropriate LLM API. Configuration is read
12//! from `~/.lore/config.yaml` with environment variable overrides.
13
14pub mod prompt;
15pub mod provider;
16
17use std::env;
18
19use crate::config::Config;
20use crate::storage::models::Message;
21
22pub use provider::{create_provider, SummaryProvider, SummaryProviderKind};
23
24/// Maximum character limit for the conversation transcript sent to the LLM.
25const MAX_CONVERSATION_CHARS: usize = 100_000;
26
27/// Resolved summary configuration from config file and environment variables.
28#[derive(Debug, Clone)]
29pub struct SummaryConfig {
30    /// The LLM provider kind.
31    pub kind: SummaryProviderKind,
32    /// API key for the provider.
33    pub api_key: String,
34    /// Optional model override (uses provider default if None).
35    pub model: Option<String>,
36}
37
38/// Resolves summary configuration from the config file and environment variables.
39///
40/// Environment variables take precedence over config file values:
41/// - `LORE_SUMMARY_PROVIDER` overrides `summary_provider`
42/// - `LORE_SUMMARY_API_KEY` overrides the provider-specific API key
43/// - `LORE_SUMMARY_MODEL` overrides the provider-specific model
44///
45/// Returns `NotConfigured` if no provider or API key is set.
46pub fn resolve_config() -> Result<SummaryConfig, SummarizeError> {
47    let config = Config::load().map_err(|_| SummarizeError::NotConfigured)?;
48
49    // Provider: env var > config file
50    let provider_str = env::var("LORE_SUMMARY_PROVIDER")
51        .ok()
52        .or_else(|| config.summary_provider.clone());
53
54    let provider_str = provider_str
55        .ok_or(SummarizeError::NotConfigured)?
56        .to_lowercase();
57
58    let kind: SummaryProviderKind = provider_str
59        .parse()
60        .map_err(|_| SummarizeError::NotConfigured)?;
61
62    // API key: env var > provider-specific config key > generic config key
63    let api_key = env::var("LORE_SUMMARY_API_KEY")
64        .ok()
65        .or_else(|| config.summary_api_key_for_provider(&provider_str));
66
67    let api_key = api_key.ok_or(SummarizeError::NotConfigured)?;
68
69    if api_key.is_empty() {
70        return Err(SummarizeError::NotConfigured);
71    }
72
73    // Model: env var > provider-specific config key
74    let model = env::var("LORE_SUMMARY_MODEL")
75        .ok()
76        .or_else(|| config.summary_model_for_provider(&provider_str));
77
78    Ok(SummaryConfig {
79        kind,
80        api_key,
81        model,
82    })
83}
84
85/// Generates a summary for a set of session messages using the configured LLM provider.
86///
87/// This is the main entry point for summary generation. It:
88/// 1. Resolves the provider configuration
89/// 2. Prepares the conversation transcript from messages
90/// 3. Calls the LLM API to generate a summary
91///
92/// Returns `EmptySession` if there are no messages or all messages are empty.
93/// Returns `NotConfigured` if no provider is set up.
94pub fn generate_summary(messages: &[Message]) -> Result<String, SummarizeError> {
95    if messages.is_empty() {
96        return Err(SummarizeError::EmptySession);
97    }
98
99    let config = resolve_config()?;
100
101    let conversation = prompt::prepare_conversation(messages, MAX_CONVERSATION_CHARS);
102    if conversation.is_empty() {
103        return Err(SummarizeError::EmptySession);
104    }
105
106    let system = prompt::system_prompt();
107    let provider = create_provider(config.kind, config.api_key, config.model);
108
109    let response = provider.summarize(system, &conversation)?;
110    Ok(normalize_whitespace(&response.content))
111}
112
113/// Normalizes whitespace in a summary string.
114///
115/// Trims leading/trailing whitespace and collapses runs of 3+ consecutive
116/// newlines down to 2 (one blank line). This keeps the summary readable
117/// (paragraph breaks preserved) while removing excessive spacing that
118/// some models produce.
119fn normalize_whitespace(text: &str) -> String {
120    let trimmed = text.trim();
121    let mut result = String::with_capacity(trimmed.len());
122    let mut consecutive_newlines = 0u32;
123
124    for ch in trimmed.chars() {
125        if ch == '\n' {
126            consecutive_newlines += 1;
127            if consecutive_newlines <= 2 {
128                result.push(ch);
129            }
130        } else {
131            consecutive_newlines = 0;
132            result.push(ch);
133        }
134    }
135
136    result
137}
138
139/// Errors that can occur during summary generation.
140#[derive(Debug, thiserror::Error)]
141pub enum SummarizeError {
142    /// No summary provider is configured.
143    #[error(
144        "Summary provider not configured. Set LORE_SUMMARY_PROVIDER and the corresponding API key."
145    )]
146    NotConfigured,
147
148    /// Network or connection error when calling the provider API.
149    #[error("Request failed: {0}")]
150    RequestFailed(String),
151
152    /// The provider API returned a non-success HTTP status code.
153    #[error("HTTP error ({status}): {body}")]
154    HttpError {
155        /// HTTP status code.
156        status: u16,
157        /// Response body text.
158        body: String,
159    },
160
161    /// The provider API returned an error in its JSON response.
162    #[error("API error ({status}): {message}")]
163    #[allow(dead_code)]
164    ApiError {
165        /// HTTP status code.
166        status: u16,
167        /// Error message from the API.
168        message: String,
169    },
170
171    /// Failed to parse the provider API response.
172    #[error("Failed to parse response: {0}")]
173    ParseError(String),
174
175    /// The session has no content to summarize.
176    #[error("Session has no content to summarize")]
177    EmptySession,
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use crate::storage::models::{MessageContent, MessageRole};
184    use chrono::Utc;
185    use uuid::Uuid;
186
187    #[test]
188    fn test_summarize_error_display_not_configured() {
189        let err = SummarizeError::NotConfigured;
190        assert!(err.to_string().contains("not configured"));
191    }
192
193    #[test]
194    fn test_summarize_error_display_request_failed() {
195        let err = SummarizeError::RequestFailed("connection refused".to_string());
196        assert!(err.to_string().contains("connection refused"));
197    }
198
199    #[test]
200    fn test_summarize_error_display_http_error() {
201        let err = SummarizeError::HttpError {
202            status: 429,
203            body: "rate limited".to_string(),
204        };
205        let msg = err.to_string();
206        assert!(msg.contains("429"));
207        assert!(msg.contains("rate limited"));
208    }
209
210    #[test]
211    fn test_summarize_error_display_api_error() {
212        let err = SummarizeError::ApiError {
213            status: 400,
214            message: "invalid model".to_string(),
215        };
216        let msg = err.to_string();
217        assert!(msg.contains("400"));
218        assert!(msg.contains("invalid model"));
219    }
220
221    #[test]
222    fn test_summarize_error_display_parse_error() {
223        let err = SummarizeError::ParseError("missing field".to_string());
224        assert!(err.to_string().contains("missing field"));
225    }
226
227    #[test]
228    fn test_summarize_error_display_empty_session() {
229        let err = SummarizeError::EmptySession;
230        assert!(err.to_string().contains("no content"));
231    }
232
233    #[test]
234    fn test_generate_summary_empty_messages() {
235        let messages: Vec<Message> = vec![];
236        let result = generate_summary(&messages);
237        assert!(result.is_err());
238        match result.unwrap_err() {
239            SummarizeError::EmptySession => {}
240            other => panic!("Expected EmptySession, got: {other:?}"),
241        }
242    }
243
244    #[test]
245    fn test_generate_summary_tool_only_messages_returns_empty_session() {
246        // Messages that contain only tool blocks produce empty text,
247        // so generate_summary should return EmptySession.
248        let messages = vec![Message {
249            id: Uuid::new_v4(),
250            session_id: Uuid::new_v4(),
251            parent_id: None,
252            index: 0,
253            timestamp: Utc::now(),
254            role: MessageRole::Assistant,
255            content: MessageContent::Blocks(vec![crate::storage::models::ContentBlock::ToolUse {
256                id: "tool_1".to_string(),
257                name: "Read".to_string(),
258                input: serde_json::json!({"file_path": "/tmp/test.rs"}),
259            }]),
260            model: None,
261            git_branch: None,
262            cwd: None,
263        }];
264
265        let result = generate_summary(&messages);
266        // Without a configured provider, this should fail. But if the conversation
267        // text is empty, it should return EmptySession before trying the provider.
268        match result {
269            Err(SummarizeError::EmptySession) => {}
270            Err(SummarizeError::NotConfigured) => {
271                // Also acceptable: config check happens before content check
272            }
273            other => panic!("Expected EmptySession or NotConfigured, got: {other:?}"),
274        }
275    }
276
277    #[test]
278    fn test_summary_config_debug() {
279        let config = SummaryConfig {
280            kind: SummaryProviderKind::Anthropic,
281            api_key: "sk-test".to_string(),
282            model: Some("claude-haiku-4-5-20241022".to_string()),
283        };
284        let debug = format!("{config:?}");
285        assert!(debug.contains("Anthropic"));
286        assert!(debug.contains("sk-test"));
287    }
288
289    #[test]
290    fn test_max_conversation_chars_constant() {
291        assert_eq!(MAX_CONVERSATION_CHARS, 100_000);
292    }
293
294    #[test]
295    fn test_normalize_whitespace_trims_edges() {
296        assert_eq!(normalize_whitespace("  hello  "), "hello");
297        assert_eq!(normalize_whitespace("\n\nhello\n\n"), "hello");
298    }
299
300    #[test]
301    fn test_normalize_whitespace_preserves_single_blank_line() {
302        let input = "Overview sentence.\n\n- Bullet one\n- Bullet two";
303        assert_eq!(normalize_whitespace(input), input);
304    }
305
306    #[test]
307    fn test_normalize_whitespace_collapses_triple_newlines() {
308        let input = "Overview.\n\n\n- Bullet one\n\n\n\n- Bullet two";
309        let expected = "Overview.\n\n- Bullet one\n\n- Bullet two";
310        assert_eq!(normalize_whitespace(input), expected);
311    }
312
313    #[test]
314    fn test_normalize_whitespace_empty_string() {
315        assert_eq!(normalize_whitespace(""), "");
316        assert_eq!(normalize_whitespace("   "), "");
317    }
318
319    #[test]
320    fn test_normalize_whitespace_no_change_needed() {
321        let input = "Line one\nLine two\n\nLine three";
322        assert_eq!(normalize_whitespace(input), input);
323    }
324}