use async_trait::async_trait;
use rucora_core::agent::{Agent, AgentContext, AgentDecision, AgentError, AgentInput, AgentOutput};
use rucora_core::provider::LlmProvider;
use rucora_core::provider::types::{ChatMessage, ChatRequest, LlmParams, Role, Usage};
use serde_json::json;
use std::sync::Arc;
use text_splitter::{ChunkConfig, ChunkSizer, TextSplitter};
use tracing::{debug, info, warn};
use crate::agent::execution::DefaultExecution;
use crate::agent::tool_registry::ToolRegistry;
pub struct DynSizer {
pub(crate) inner: Box<dyn ChunkSizer + Send + Sync>,
}
impl ChunkSizer for DynSizer {
fn size(&self, chunk: &str) -> usize {
self.inner.size(chunk)
}
}
pub type DynTextSplitter = TextSplitter<DynSizer>;
pub fn text_splitter(capacity: usize) -> DynTextSplitter {
TextSplitter::new(ChunkConfig::new(capacity).with_sizer(DynSizer {
inner: Box::new(text_splitter::Characters),
}))
}
pub fn text_splitter_with_sizer<S: ChunkSizer + Send + Sync + 'static>(
capacity: usize,
sizer: S,
) -> DynTextSplitter {
TextSplitter::new(ChunkConfig::new(capacity).with_sizer(DynSizer {
inner: Box::new(sizer),
}))
}
pub fn text_splitter_with_overlap(capacity: usize, overlap: usize) -> DynTextSplitter {
TextSplitter::new(
ChunkConfig::new(capacity)
.with_overlap(overlap)
.expect("重叠应小于块大小")
.with_sizer(DynSizer {
inner: Box::new(text_splitter::Characters),
}),
)
}
#[derive(Debug, Clone)]
pub struct ChunkSummary {
pub index: usize,
pub summary: String,
pub usage: Option<Usage>,
}
fn render_template(template: &str, replacements: &[(&str, &str)]) -> String {
let mut result = template.to_string();
for (key, value) in replacements {
result = result.replace(key, value);
}
result
}
pub trait TextChunker: Send + Sync {
fn chunk_text<'a>(&self, text: &'a str) -> Vec<&'a str>;
}
impl TextChunker for DynTextSplitter {
fn chunk_text<'a>(&self, text: &'a str) -> Vec<&'a str> {
self.chunks(text).collect()
}
}
#[derive(Debug, Clone, PartialEq, Default)]
pub enum SummaryMode {
#[default]
Concise,
Detailed,
BulletPoints,
KeyPoints,
Custom(String),
}
impl SummaryMode {
fn instruction(&self) -> &str {
match self {
Self::Concise => {
"请用简洁的 2-3 句话总结以下文本的核心内容。直接给出总结,不要添加额外说明。"
}
Self::Detailed => {
"请详细总结以下文本的内容,保留重要细节、论据和结论。确保覆盖所有关键部分。"
}
Self::BulletPoints => "请用要点列表形式总结以下文本的核心内容。每个要点应独立且完整。",
Self::KeyPoints => {
"请提取以下文本的关键信息和核心观点。只列出最重要的内容,忽略次要细节。"
}
Self::Custom(instruction) => instruction,
}
}
}
impl From<String> for SummaryMode {
fn from(instruction: String) -> Self {
Self::Custom(instruction)
}
}
impl From<&str> for SummaryMode {
fn from(instruction: &str) -> Self {
Self::Custom(instruction.to_string())
}
}
const DEFAULT_PROMPT_TEMPLATE: &str = "\
请总结以下文本:\n\
\n\
{text}\n\
\n\
{mode}";
const DEFAULT_CHUNK_TEMPLATE: &str = "\
以下是待总结文本的第 {index}/{total} 部分:\n\
\n\
{text}\n\
\n\
请总结以上文本部分的要点。";
const DEFAULT_COMBINE_TEMPLATE: &str = "\
以下是文本各部分的局部总结:\n\
\n\
{summaries}\n\
\n\
请将以上 {total} 份局部总结合并为一份完整的最终总结。\n\
\n\
{mode}";
pub struct SummaryAgent<P> {
provider: Arc<P>,
model: Option<String>,
system_prompt: Option<String>,
llm_params: LlmParams,
mode: SummaryMode,
splitter: DynTextSplitter,
max_concurrency: usize,
prompt_template: String,
chunk_template: String,
combine_template: String,
execution: DefaultExecution,
}
#[async_trait]
impl<P> Agent for SummaryAgent<P>
where
P: LlmProvider + Send + Sync + 'static,
{
async fn think(&self, context: &AgentContext) -> AgentDecision {
let text = context.input.text();
let step = context.step;
let chunks = self.split_text(text);
let total = chunks.len();
let max_steps = if total <= 1 { 1 } else { 2 };
info!(
text_len = text.len(),
total_chunks = total,
step,
max_steps,
"SummaryAgent::think"
);
match step {
0 if total <= 1 => {
debug!("单块模式,直接发送摘要请求");
AgentDecision::Chat {
request: Box::new(self.build_single_request(text)),
}
}
0 => {
debug!(
"多块模式,并发处理 {} 块(并发数 {})",
total, self.max_concurrency
);
let requests: Vec<ChatRequest> = chunks
.iter()
.enumerate()
.map(|(i, chunk)| {
let content = render_template(
&self.chunk_template,
&[
("{index}", &(i + 1).to_string()),
("{total}", &total.to_string()),
("{text}", chunk),
("{mode}", self.mode.instruction()),
],
);
let mut messages = Vec::new();
if let Some(ref prompt) = self.system_prompt {
messages.push(ChatMessage::system(prompt.clone()));
}
messages.push(ChatMessage::user(content));
let mut request = ChatRequest {
messages,
model: self.model.clone(),
tools: None,
..Default::default()
};
self.llm_params.apply_to(&mut request);
request
})
.collect();
AgentDecision::MapAll {
requests,
max_concurrency: self.max_concurrency,
}
}
1 if total > 1 => {
let summaries = self.extract_chunk_summaries(context);
let content = render_template(
&self.combine_template,
&[
("{summaries}", &summaries),
("{total}", &total.to_string()),
("{mode}", self.mode.instruction()),
],
);
info!(
summary_count = summaries.lines().count(),
summaries_len = summaries.len(),
"多块模式,进入合并阶段({} 个局部摘要)",
total
);
AgentDecision::Reduce {
request: Box::new(self.build_multi_request(context, content)),
}
}
_ => {
debug!("步骤 {},返回最终结果", step);
self.return_last_assistant(context)
}
}
}
fn name(&self) -> &str {
"summary_agent"
}
fn description(&self) -> Option<&str> {
Some("文本摘要 Agent,支持长文档自动分块和多模式输出")
}
async fn run(&self, input: AgentInput) -> Result<AgentOutput, AgentError> {
info!(
text_len = input.text().len(),
"SummaryAgent::run 委托给 DefaultExecution"
);
self.execution.run(self, input).await
}
fn run_stream(
&self,
input: AgentInput,
) -> futures_util::stream::BoxStream<
'static,
Result<rucora_core::channel::types::ChannelEvent, AgentError>,
> {
self.execution.run_stream_simple(input)
}
}
impl<P> SummaryAgent<P>
where
P: LlmProvider + Send + Sync + 'static,
{
pub async fn run_stream_text(
&self,
input: impl Into<AgentInput>,
) -> Result<String, AgentError> {
let output = self.run(input.into()).await?;
Ok(output.text_unwrap().to_string())
}
}
impl<P> SummaryAgent<P> {
#[must_use = "构建器必须调用 try_build() 来创建 Agent"]
pub fn builder() -> SummaryAgentBuilder<P> {
SummaryAgentBuilder::new()
}
pub fn provider(&self) -> &P {
&self.provider
}
pub fn model(&self) -> Option<&str> {
self.model.as_deref()
}
pub fn mode(&self) -> &SummaryMode {
&self.mode
}
pub fn splitter(&self) -> &DynTextSplitter {
&self.splitter
}
pub fn max_concurrency(&self) -> usize {
self.max_concurrency
}
pub fn split_text<'a>(&self, text: &'a str) -> Vec<&'a str> {
self.splitter.chunks(text).collect()
}
}
impl<P> SummaryAgent<P> {
fn build_single_request(&self, text: &str) -> ChatRequest {
let content = render_template(
&self.prompt_template,
&[("{text}", text), ("{mode}", self.mode.instruction())],
);
let mut messages = Vec::new();
if let Some(ref prompt) = self.system_prompt {
messages.push(ChatMessage::system(prompt.clone()));
}
messages.push(ChatMessage::user(content));
let mut request = ChatRequest {
messages,
model: self.model.clone(),
tools: None,
..Default::default()
};
self.llm_params.apply_to(&mut request);
request
}
fn build_multi_request(&self, context: &AgentContext, content: String) -> ChatRequest {
let mut messages = context.messages.clone();
if let Some(ref sys_prompt) = self.system_prompt
&& (messages.is_empty() || messages.first().map(|m| &m.role) != Some(&Role::System))
{
messages.insert(0, ChatMessage::system(sys_prompt.clone()));
}
messages.push(ChatMessage::user(content));
debug!(
message_count = messages.len(),
last_user_content_len = messages
.iter()
.rev()
.find(|m| m.role == Role::User)
.map_or(0, |m| m.content_text().len()),
"构建多块请求"
);
let mut request = ChatRequest {
messages,
model: self.model.clone(),
tools: None,
..Default::default()
};
self.llm_params.apply_to(&mut request);
request
}
fn return_last_assistant(&self, context: &AgentContext) -> AgentDecision {
let content = context
.messages
.iter()
.rev()
.find(|m| m.role == Role::Assistant)
.map(|m| m.content_text().to_string())
.unwrap_or_default();
AgentDecision::Return(json!({"content": content}))
}
fn extract_chunk_summaries(&self, context: &AgentContext) -> String {
let mut summaries = Vec::new();
let mut chunk_index = 1;
for msg in &context.messages {
if msg.role == Role::Assistant && !msg.content_text().trim().is_empty() {
summaries.push(format!("【部分 {}】\n{}", chunk_index, msg.content_text()));
chunk_index += 1;
}
}
if summaries.is_empty() {
warn!("未找到任何局部摘要,合并阶段可能无法正常工作");
return String::from("(未找到局部摘要)");
}
summaries.join("\n\n")
}
}
pub struct SummaryAgentBuilder<P> {
provider: Option<P>,
system_prompt: Option<String>,
model: Option<String>,
llm_params: LlmParams,
mode: SummaryMode,
splitter: Option<DynTextSplitter>,
max_concurrency: usize,
prompt_template: Option<String>,
chunk_template: Option<String>,
combine_template: Option<String>,
}
impl<P> SummaryAgentBuilder<P> {
pub fn new() -> Self {
Self {
provider: None,
system_prompt: None,
model: None,
llm_params: LlmParams::default(),
mode: SummaryMode::Concise,
splitter: None,
max_concurrency: 8,
prompt_template: None,
chunk_template: None,
combine_template: None,
}
}
}
impl<P> SummaryAgentBuilder<P>
where
P: LlmProvider + Send + Sync + 'static,
{
pub fn provider(mut self, provider: P) -> Self {
self.provider = Some(provider);
self
}
pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
self.system_prompt = Some(prompt.into());
self
}
pub fn model(mut self, model: impl Into<String>) -> Self {
self.model = Some(model.into());
self
}
pub fn mode(mut self, mode: impl Into<SummaryMode>) -> Self {
self.mode = mode.into();
self
}
pub fn chunk_size(mut self, size: usize) -> Self {
self.splitter = Some(text_splitter(size));
self
}
pub fn splitter(mut self, splitter: DynTextSplitter) -> Self {
self.splitter = Some(splitter);
self
}
pub fn max_concurrency(mut self, concurrency: usize) -> Self {
self.max_concurrency = concurrency.max(1);
self
}
pub fn prompt_template(mut self, template: impl Into<String>) -> Self {
self.prompt_template = Some(template.into());
self
}
pub fn chunk_template(mut self, template: impl Into<String>) -> Self {
self.chunk_template = Some(template.into());
self
}
pub fn combine_template(mut self, template: impl Into<String>) -> Self {
self.combine_template = Some(template.into());
self
}
pub fn temperature(mut self, value: f32) -> Self {
self.llm_params.temperature = Some(value);
self
}
pub fn top_p(mut self, value: f32) -> Self {
self.llm_params.top_p = Some(value);
self
}
pub fn top_k(mut self, value: u32) -> Self {
self.llm_params.top_k = Some(value);
self
}
pub fn max_tokens(mut self, value: u32) -> Self {
self.llm_params.max_tokens = Some(value);
self
}
pub fn frequency_penalty(mut self, value: f32) -> Self {
self.llm_params.frequency_penalty = Some(value);
self
}
pub fn presence_penalty(mut self, value: f32) -> Self {
self.llm_params.presence_penalty = Some(value);
self
}
pub fn stop(mut self, value: Vec<String>) -> Self {
self.llm_params.stop = Some(value);
self
}
pub fn extra_params(mut self, value: serde_json::Value) -> Self {
self.llm_params.extra = Some(value);
self
}
pub fn llm_params(mut self, params: LlmParams) -> Self {
self.llm_params = params;
self
}
pub fn try_build(self) -> Result<SummaryAgent<P>, AgentError> {
let provider = self.provider.ok_or_else(|| {
AgentError::Message("构建 SummaryAgent 失败:缺少 provider".to_string())
})?;
let splitter = self.splitter.unwrap_or_else(|| text_splitter(4000));
let provider_arc = Arc::new(provider);
let execution = DefaultExecution::new(
provider_arc.clone() as Arc<dyn LlmProvider>,
self.model.clone(),
ToolRegistry::new(),
)
.with_llm_params(self.llm_params.clone())
.with_system_prompt_opt(self.system_prompt.clone())
.with_max_steps(10);
Ok(SummaryAgent {
provider: provider_arc,
model: self.model,
system_prompt: self.system_prompt,
llm_params: self.llm_params,
mode: self.mode,
splitter,
max_concurrency: self.max_concurrency,
prompt_template: self
.prompt_template
.unwrap_or_else(|| DEFAULT_PROMPT_TEMPLATE.to_string()),
chunk_template: self
.chunk_template
.unwrap_or_else(|| DEFAULT_CHUNK_TEMPLATE.to_string()),
combine_template: self
.combine_template
.unwrap_or_else(|| DEFAULT_COMBINE_TEMPLATE.to_string()),
execution,
})
}
#[deprecated(note = "请使用 try_build() 处理配置错误")]
pub fn build(self) -> SummaryAgent<P> {
self.try_build()
.unwrap_or_else(|err| panic!("SummaryAgentBuilder::build 失败:{err}"))
}
}
impl<P> Default for SummaryAgentBuilder<P> {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
#[allow(deprecated)]
mod tests {
use super::*;
use rucora_core::test_utils::MockProvider;
#[test]
fn test_summary_agent_builder() {
let _agent = SummaryAgentBuilder::<MockProvider>::new()
.provider(MockProvider)
.model("gpt-4o-mini")
.mode(SummaryMode::Concise)
.chunk_size(2000)
.build();
}
#[test]
fn test_chunk_text_short() {
let agent = SummaryAgentBuilder::<MockProvider>::new()
.provider(MockProvider)
.model("gpt-4o-mini")
.chunk_size(4000)
.build();
let text = "这是一段短文本。";
let count = agent.splitter.chunks(text).count();
assert_eq!(count, 1);
}
#[test]
fn test_chunk_text_long() {
let agent = SummaryAgentBuilder::<MockProvider>::new()
.provider(MockProvider)
.model("gpt-4o-mini")
.chunk_size(100)
.build();
let mut text = String::new();
for i in 0..6 {
text.push_str(&format!(
"第{}段落。{}",
i + 1,
"这是该段的内容说明。这里有一些补充信息用于填充。\n\n"
));
}
let chunks: Vec<&str> = agent.splitter.chunks(&text).collect();
assert!(
chunks.len() > 1,
"长文本应被分块,但得到 {} 块",
chunks.len()
);
for chunk in &chunks {
assert!(!chunk.is_empty(), "分块不应为空");
assert!(text.contains(chunk), "每个分块应是原文的子串");
}
}
#[test]
fn test_summary_mode_instruction() {
assert!(!SummaryMode::Concise.instruction().is_empty());
assert!(!SummaryMode::Detailed.instruction().is_empty());
assert!(!SummaryMode::BulletPoints.instruction().is_empty());
assert!(!SummaryMode::KeyPoints.instruction().is_empty());
assert!(
!SummaryMode::Custom("自定义指令".to_string())
.instruction()
.is_empty()
);
}
#[test]
fn test_custom_mode() {
let instruction = "请用英文总结,至少 5 个要点";
let mode = SummaryMode::Custom(instruction.to_string());
assert_eq!(mode.instruction(), instruction);
}
#[test]
fn test_mode_from_str() {
let mode: SummaryMode = "自定义指令".into();
assert_eq!(mode, SummaryMode::Custom("自定义指令".to_string()));
}
#[test]
fn test_custom_templates() {
let agent = SummaryAgentBuilder::<MockProvider>::new()
.provider(MockProvider)
.model("gpt-4o-mini")
.prompt_template("自定义单块模板:{text} -- {mode}")
.chunk_template("第{index}/{total}块:{text}")
.combine_template("合并{total}块:{mode}")
.build();
assert!(agent.prompt_template.contains("自定义单块模板"));
assert!(agent.chunk_template.contains("第{index}/{total}块"));
assert!(agent.combine_template.contains("合并{total}块"));
}
#[test]
fn test_long_text_chunking_flow() {
let agent = SummaryAgentBuilder::<MockProvider>::new()
.provider(MockProvider)
.model("gpt-4o-mini")
.chunk_size(100)
.mode(SummaryMode::Concise)
.build();
let mut text = String::new();
for i in 0..10 {
text.push_str(&format!(
"第{}段落。这是该段的内容说明。这里有一些补充信息用于填充文本长度。\n\n",
i + 1
));
}
let chunks: Vec<&str> = agent.splitter.chunks(&text).collect();
let total = chunks.len();
assert!(total > 1, "长文本应被分为多块,实际得到 {total} 块");
eprintln!("文本长度: {} 字符,分为 {total} 块", text.len());
for (i, chunk) in chunks.iter().enumerate() {
let preview: String = chunk.chars().take(20).collect();
eprintln!("块 {}: 长度 {} 字符,预览: {preview}", i + 1, chunk.len());
assert!(!chunk.is_empty(), "块 {i} 不应为空");
}
let combine_summaries = (1..=total)
.map(|i| format!("【部分 {i}】\nMock response"))
.collect::<Vec<_>>()
.join("\n\n");
assert!(
combine_summaries.contains("【部分 1】")
&& combine_summaries.contains(&format!("【部分 {total}】")),
"摘要应包含 {total} 个部分标记",
);
}
#[test]
fn test_extract_chunk_summaries() {
let agent = SummaryAgentBuilder::<MockProvider>::new()
.provider(MockProvider)
.model("gpt-4o-mini")
.build();
let input = AgentInput::new("测试文本").unwrap();
let mut context = rucora_core::agent::AgentContext::new(input, 10);
context.messages.push(ChatMessage::system("系统提示"));
context.messages.push(ChatMessage::user("用户消息"));
context
.messages
.push(ChatMessage::assistant("第一个块的摘要"));
context.messages.push(ChatMessage::user("第二块请求"));
context
.messages
.push(ChatMessage::assistant("第二个块的摘要"));
let summaries = agent.extract_chunk_summaries(&context);
assert!(summaries.contains("第一个块的摘要"), "应包含第一个摘要");
assert!(summaries.contains("第二个块的摘要"), "应包含第二个摘要");
assert!(summaries.contains("【部分 1】"), "应有部分标记");
assert!(summaries.contains("【部分 2】"), "应有部分标记");
}
#[test]
fn test_empty_summaries_warning() {
let agent = SummaryAgentBuilder::<MockProvider>::new()
.provider(MockProvider)
.model("gpt-4o-mini")
.build();
let input = AgentInput::new("测试文本").unwrap();
let context = rucora_core::agent::AgentContext::new(input, 10);
let summaries = agent.extract_chunk_summaries(&context);
assert!(summaries.contains("未找到"), "无摘要时应返回提示信息");
}
#[test]
fn test_text_splitter() {
let splitter = text_splitter(1000);
let text = "Hello world. This is a test. ".repeat(10);
let chunks: Vec<&str> = splitter.chunks(&text).collect();
assert!(!chunks.is_empty());
for chunk in &chunks {
assert!(!chunk.is_empty());
}
}
#[test]
fn test_text_splitter_with_builder() {
let agent = SummaryAgentBuilder::<MockProvider>::new()
.provider(MockProvider)
.model("gpt-4o-mini")
.splitter(text_splitter(2000))
.build();
let text = "Hello world. This is a test. ".repeat(200);
assert!(agent.splitter.chunks(&text).next().is_some());
}
#[test]
fn test_text_splitter_with_overlap() {
let splitter = text_splitter_with_overlap(100, 20);
let text = "Hello world. This is a test. ".repeat(10);
assert!(splitter.chunks(&text).next().is_some());
}
#[test]
#[should_panic(expected = "重叠应小于块大小")]
fn test_text_splitter_with_overlap_panics_on_invalid() {
text_splitter_with_overlap(50, 100);
}
#[test]
fn test_chunk_integrity() {
let mut text = String::new();
for i in 0..10 {
text.push_str(&format!(
"Paragraph {} with some content to make it longer than a very short chunk.\n\n",
i + 1
));
}
let splitter = text_splitter(200);
let chunks: Vec<&str> = splitter.chunks(&text).collect();
assert!(chunks.len() > 1);
for chunk in &chunks {
assert!(text.contains(chunk), "分块 {chunk} 应是原文的子串");
}
for chunk in &chunks {
assert_eq!(
text.matches(chunk).count(),
1,
"分块 {chunk} 应在原文中恰好出现一次"
);
}
}
}