use async_trait::async_trait;
use crate::document::{DocumentTree, NodeId};
use crate::llm::memo::{MemoKey, MemoStore, MemoValue};
use crate::llm::{LlmClient, LlmResult};
use crate::utils::fingerprint::Fingerprint;
#[derive(Debug, Clone)]
pub struct SummaryStrategyConfig {
pub max_tokens: usize,
pub min_content_tokens: usize,
pub persist_lazy: bool,
pub shortcut_threshold: usize,
}
impl Default for SummaryStrategyConfig {
fn default() -> Self {
Self {
max_tokens: 200,
min_content_tokens: 50,
persist_lazy: false,
shortcut_threshold: 50,
}
}
}
#[derive(Debug, Clone)]
pub enum SummaryStrategy {
None,
Full {
config: SummaryStrategyConfig,
},
Selective {
min_tokens: usize,
branch_only: bool,
config: SummaryStrategyConfig,
},
Lazy {
persist: bool,
config: SummaryStrategyConfig,
},
}
impl Default for SummaryStrategy {
fn default() -> Self {
Self::Full {
config: SummaryStrategyConfig::default(),
}
}
}
impl SummaryStrategy {
pub fn none() -> Self {
Self::None
}
pub fn full() -> Self {
Self::Full {
config: SummaryStrategyConfig::default(),
}
}
pub fn selective(min_tokens: usize, branch_only: bool) -> Self {
Self::Selective {
min_tokens,
branch_only,
config: SummaryStrategyConfig::default(),
}
}
pub fn lazy(persist: bool) -> Self {
Self::Lazy {
persist,
config: SummaryStrategyConfig::default(),
}
}
pub fn should_generate(
&self,
tree: &DocumentTree,
node_id: NodeId,
token_count: usize,
) -> bool {
match self {
Self::None => false,
Self::Full { .. } => token_count > 0,
Self::Selective {
min_tokens,
branch_only,
..
} => {
let is_branch = !tree.is_leaf(node_id);
let enough_tokens = token_count >= *min_tokens;
if *branch_only {
is_branch && enough_tokens
} else {
enough_tokens
}
}
Self::Lazy { .. } => false, }
}
pub fn is_lazy(&self) -> bool {
matches!(self, Self::Lazy { .. })
}
pub fn config(&self) -> SummaryStrategyConfig {
match self {
Self::None => SummaryStrategyConfig::default(),
Self::Full { config } => config.clone(),
Self::Selective { config, .. } => config.clone(),
Self::Lazy { config, .. } => config.clone(),
}
}
pub fn shortcut_threshold(&self) -> usize {
self.config().shortcut_threshold
}
}
#[async_trait]
pub trait SummaryGenerator: Send + Sync {
async fn generate(&self, title: &str, content: &str) -> LlmResult<String>;
async fn generate_for_node(
&self,
title: &str,
content: &str,
is_leaf: bool,
) -> LlmResult<String> {
let _ = is_leaf;
self.generate(title, content).await
}
}
pub struct LlmSummaryGenerator {
client: LlmClient,
max_tokens: usize,
memo_store: Option<MemoStore>,
}
impl LlmSummaryGenerator {
pub fn new(client: LlmClient) -> Self {
Self {
client,
max_tokens: 200,
memo_store: None,
}
}
pub fn with_max_tokens(mut self, max_tokens: usize) -> Self {
self.max_tokens = max_tokens;
self
}
pub fn with_memo_store(mut self, store: MemoStore) -> Self {
self.memo_store = Some(store);
self
}
}
#[async_trait]
impl SummaryGenerator for LlmSummaryGenerator {
async fn generate(&self, title: &str, content: &str) -> LlmResult<String> {
let content_fp = Fingerprint::from_str(&format!("{}|{}", title, content));
let memo_key = MemoKey::summary(&content_fp);
if let Some(ref store) = self.memo_store {
if let Some(cached) = store.get(&memo_key) {
if let Some(summary) = cached.as_summary() {
tracing::debug!("Memo cache hit for summary: {}", title);
return Ok(summary.to_string());
}
}
}
let system_prompt = "You are a document summarization assistant. \
Generate a concise summary (2-3 sentences) of the given section. \
Focus on the main topics and key information. \
Respond with only the summary, no additional text.";
let user_prompt = format!("Title: {}\n\nContent:\n{}", title, content);
let summary = self
.client
.complete_with_max_tokens(&system_prompt, &user_prompt, self.max_tokens as u16)
.await?;
if let Some(ref store) = self.memo_store {
let tokens_saved = (title.len() + content.len() + summary.len()) / 4;
store.put_with_tokens(
memo_key,
MemoValue::Summary(summary.clone()),
tokens_saved as u64,
);
tracing::debug!("Memo cache stored for summary: {}", title);
}
Ok(summary)
}
async fn generate_for_node(
&self,
title: &str,
content: &str,
is_leaf: bool,
) -> LlmResult<String> {
let content_fp = Fingerprint::from_str(&format!("{}|{}|leaf={}", title, content, is_leaf));
let memo_key = MemoKey::summary(&content_fp);
if let Some(ref store) = self.memo_store {
if let Some(cached) = store.get(&memo_key) {
if let Some(summary) = cached.as_summary() {
tracing::debug!("Memo cache hit for summary: {}", title);
return Ok(summary.to_string());
}
}
}
let system_prompt = if is_leaf {
"You are a document summarization assistant. \
Generate a concise summary (2-3 sentences) of the given section's content. \
Focus on the key information and facts presented. \
Respond with only the summary, no additional text."
} else {
"You are a document navigation assistant. \
Generate a structured overview of this section for navigation purposes. \
Respond in EXACTLY this format (one section per line):\n\
OVERVIEW: <2-3 sentence description of what topics this section covers>\n\
QUESTIONS: <comma-separated list of 3-5 typical questions this section can answer>\n\
TAGS: <comma-separated list of 2-4 topic keywords>"
};
let user_prompt = if is_leaf {
format!("Title: {}\n\nContent:\n{}", title, content)
} else {
format!("Title: {}\n\nContent:\n{}", title, content)
};
let summary = self
.client
.complete_with_max_tokens(&system_prompt, &user_prompt, self.max_tokens as u16)
.await?;
if let Some(ref store) = self.memo_store {
let tokens_saved = (title.len() + content.len() + summary.len()) / 4;
store.put_with_tokens(
memo_key,
MemoValue::Summary(summary.clone()),
tokens_saved as u64,
);
tracing::debug!("Memo cache stored for summary: {}", title);
}
Ok(summary)
}
}