lc_chains/document_chains/
stuff.rs1use async_trait::async_trait;
5use futures_util::StreamExt;
6use lc_core::runnables::RunnableConfig;
7use lc_core::BaseChatModel;
8use lc_providers::{wrap_chat_model, ProviderError};
9use lc_schema::Message;
10use lc_shared::document::Document;
11use serde_json::Value;
12use std::collections::HashMap;
13
14use crate::base::{
15 stream_chain_with_callbacks, substitute_template, BaseChain, ChainError, ChainResult,
16 ChainStream, StreamToken,
17};
18use crate::BoxedChatModel;
19
20pub(crate) const DEFAULT_STUFF_PROMPT: &str =
22 "Answer the user's question based on the following reference information.
23
24Reference information:
25{context}
26
27Question: {input}
28
29Answer:";
30
31pub struct StuffDocumentsChain {
36 llm: BoxedChatModel,
37 prompt_template: String,
38 document_variable_name: String,
39 input_key: String,
40 output_key: String,
41 name: String,
42 verbose: bool,
43 max_doc_length: Option<usize>,
45}
46
47impl StuffDocumentsChain {
48 async fn stream_body(
51 &self,
52 inputs: HashMap<String, Value>,
53 config: Option<RunnableConfig>,
54 ) -> Result<ChainStream, ChainError> {
55 self.validate_inputs(&inputs)?;
56
57 if config.as_ref().is_some_and(|c| c.is_cancelled()) {
58 return Err(ChainError::StreamError("Operation cancelled".to_string()));
59 }
60
61 let input = inputs
62 .get(&self.input_key)
63 .and_then(|v| v.as_str())
64 .ok_or_else(|| ChainError::MissingInput(self.input_key.clone()))?;
65
66 let documents = crate::base::documents_from_input(inputs.get("documents"))?;
67
68 if documents.is_empty() {
72 return Err(ChainError::ExecutionError(
73 "Document list is empty".to_string(),
74 ));
75 }
76
77 let context = self.format_documents(&documents);
78 let prompt = self.build_prompt(&context, input);
79 let messages = vec![Message::human(&prompt)];
80
81 let llm_stream = self
82 .llm
83 .stream_chat(messages, config)
84 .await
85 .map_err(|e| ChainError::StreamError(format!("LLM stream failed: {}", e)))?;
86
87 let stream = llm_stream.map(|result| match result {
88 Ok(chunk) => Ok(StreamToken {
89 token: chunk.text,
90 is_final: false,
91 }),
92 Err(e) => Err(ChainError::StreamError(format!(
93 "Stream token error: {}",
94 e
95 ))),
96 });
97
98 let final_stream = stream.chain(futures_util::stream::once(async move {
99 Ok(StreamToken {
100 token: String::new(),
101 is_final: true,
102 })
103 }));
104
105 Ok(Box::pin(final_stream))
106 }
107
108 pub fn new<L>(llm: L) -> Self
110 where
111 L: BaseChatModel + Send + Sync + 'static,
112 L::Error: Into<ProviderError>,
113 {
114 Self {
115 llm: wrap_chat_model(llm),
116 prompt_template: DEFAULT_STUFF_PROMPT.to_string(),
117 document_variable_name: "context".to_string(),
118 input_key: "input".to_string(),
119 output_key: "output".to_string(),
120 name: "stuff_documents".to_string(),
121 verbose: false,
122 max_doc_length: None,
123 }
124 }
125
126 pub fn with_prompt_template(mut self, template: impl Into<String>) -> Self {
128 self.prompt_template = template.into();
129 self
130 }
131
132 pub fn with_document_variable(mut self, name: impl Into<String>) -> Self {
134 self.document_variable_name = name.into();
135 self
136 }
137
138 pub fn with_input_key(mut self, key: impl Into<String>) -> Self {
140 self.input_key = key.into();
141 self
142 }
143
144 pub fn with_output_key(mut self, key: impl Into<String>) -> Self {
146 self.output_key = key.into();
147 self
148 }
149
150 pub fn with_name(mut self, name: impl Into<String>) -> Self {
152 self.name = name.into();
153 self
154 }
155
156 pub fn with_verbose(mut self, verbose: bool) -> Self {
158 self.verbose = verbose;
159 self
160 }
161
162 pub fn with_max_doc_length(mut self, max: usize) -> Self {
164 self.max_doc_length = Some(max);
165 self
166 }
167
168 pub fn format_documents(&self, documents: &[Document]) -> String {
170 let mut parts = Vec::new();
171 for (i, doc) in documents.iter().enumerate() {
172 let mut content = doc.content.clone();
173 if let Some(max_len) = self.max_doc_length {
174 let char_count: usize = content.chars().count();
175 if char_count > max_len {
176 content = content.chars().take(max_len).collect::<String>();
177 content.push_str("...\n[document truncated]");
178 }
179 }
180 parts.push(format!("Document {}:\n{}", i + 1, content));
181 }
182 parts.join("\n\n---\n\n")
183 }
184
185 pub fn build_prompt(&self, context: &str, input: &str) -> String {
187 let vars = HashMap::from([
191 (self.document_variable_name.clone(), context.to_string()),
192 ("input".to_string(), input.to_string()),
193 ]);
194 substitute_template(&self.prompt_template, &vars).0
195 }
196
197 pub async fn invoke_with_documents(
199 &self,
200 documents: Vec<Document>,
201 input: &str,
202 ) -> Result<String, ChainError> {
203 if documents.is_empty() {
207 return Err(ChainError::ExecutionError(
208 "Document list is empty".to_string(),
209 ));
210 }
211
212 let context = self.format_documents(&documents);
213
214 if self.verbose {
215 println!("\n=== StuffDocumentsChain ===");
216 println!("Document count: {}", documents.len());
217 println!("Context length: {} characters", context.len());
218 }
219
220 let prompt = self.build_prompt(&context, input);
221
222 if self.verbose {
223 println!("Prompt length: {} characters", prompt.len());
224 }
225
226 let messages = vec![Message::human(&prompt)];
227 let response = self
228 .llm
229 .invoke(messages, None)
230 .await
231 .map_err(|e| ChainError::ExecutionError(format!("LLM call failed: {}", e)))?;
232
233 let output = response.content;
234
235 if self.verbose {
236 println!("Output: {}", output);
237 println!("=== StuffDocumentsChain complete ===\n");
238 }
239
240 Ok(output)
241 }
242}
243
244#[async_trait]
245impl BaseChain for StuffDocumentsChain {
246 fn input_keys(&self) -> Vec<&str> {
247 vec![&self.input_key, "documents"]
248 }
249
250 fn output_keys(&self) -> Vec<&str> {
251 vec![&self.output_key]
252 }
253
254 async fn invoke(&self, inputs: HashMap<String, Value>) -> Result<ChainResult, ChainError> {
255 self.validate_inputs(&inputs)?;
258
259 let input = inputs
260 .get(&self.input_key)
261 .and_then(|v| v.as_str())
262 .ok_or_else(|| ChainError::MissingInput(self.input_key.clone()))?;
263
264 let documents = crate::base::documents_from_input(inputs.get("documents"))?;
265
266 let output = self.invoke_with_documents(documents, input).await?;
267
268 let mut result = HashMap::new();
269 result.insert(self.output_key.clone(), Value::String(output));
270 Ok(result)
271 }
272
273 async fn stream(&self, inputs: HashMap<String, Value>) -> Result<ChainStream, ChainError> {
278 self.stream_body(inputs, None).await
279 }
280
281 async fn stream_with_config(
287 &self,
288 inputs: HashMap<String, Value>,
289 config: Option<RunnableConfig>,
290 ) -> Result<ChainStream, ChainError> {
291 let output_key = Some(self.output_key.clone());
292 stream_chain_with_callbacks(
293 self.name(),
294 inputs,
295 config.clone(),
296 output_key,
297 |inputs| async move { self.stream_body(inputs, config).await },
298 )
299 .await
300 }
301
302 fn name(&self) -> &str {
303 &self.name
304 }
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310 use async_trait::async_trait;
311 use futures_util::Stream;
312 use lc_core::language_models::{LLMResult, StreamChunk};
313 use lc_core::runnables::RunnableConfig;
314 use lc_core::{BaseLanguageModel, Runnable};
315 use std::pin::Pin;
316 use std::sync::atomic::{AtomicUsize, Ordering};
317 use std::sync::Arc;
318
319 struct CountingLLM {
323 invokes: Arc<AtomicUsize>,
324 }
325
326 #[async_trait]
327 impl Runnable<Vec<Message>, LLMResult> for CountingLLM {
328 type Error = ProviderError;
329 async fn invoke(
330 &self,
331 _input: Vec<Message>,
332 _config: Option<RunnableConfig>,
333 ) -> Result<LLMResult, Self::Error> {
334 self.invokes.fetch_add(1, Ordering::SeqCst);
335 Ok(LLMResult {
336 content: "stuffed answer".to_string(),
337 model: "mock".to_string(),
338 token_usage: None,
339 tool_calls: None,
340 thinking_content: None,
341 })
342 }
343 }
344
345 #[async_trait]
346 impl BaseLanguageModel<Vec<Message>, LLMResult> for CountingLLM {
347 fn model_name(&self) -> &str {
348 "mock"
349 }
350 fn get_num_tokens(&self, t: &str) -> usize {
351 t.len()
352 }
353 fn with_temperature(self, _: f32) -> Self {
354 self
355 }
356 fn with_max_tokens(self, _: usize) -> Self {
357 self
358 }
359 }
360
361 #[async_trait]
362 impl BaseChatModel for CountingLLM {
363 async fn chat(
364 &self,
365 _messages: Vec<Message>,
366 _config: Option<RunnableConfig>,
367 ) -> Result<LLMResult, Self::Error> {
368 self.invokes.fetch_add(1, Ordering::SeqCst);
369 Ok(LLMResult {
370 content: "stuffed answer".to_string(),
371 model: "mock".to_string(),
372 token_usage: None,
373 tool_calls: None,
374 thinking_content: None,
375 })
376 }
377 async fn stream_chat(
378 &self,
379 _messages: Vec<Message>,
380 _config: Option<RunnableConfig>,
381 ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, Self::Error>> + Send>>, Self::Error>
382 {
383 let tokens = [Ok(StreamChunk::new("streamed answer"))];
384 Ok(Box::pin(futures_util::stream::iter(tokens)))
385 }
386 }
387
388 #[tokio::test]
391 async fn test_stuff_typed_invoke_runs_llm() {
392 let invokes = Arc::new(AtomicUsize::new(0));
393 let chain = StuffDocumentsChain::new(CountingLLM {
394 invokes: invokes.clone(),
395 });
396
397 let out = chain
398 .invoke_with_documents(vec![Document::new("doc one")], "question")
399 .await
400 .unwrap();
401 assert_eq!(out, "stuffed answer");
402 assert_eq!(invokes.load(Ordering::SeqCst), 1);
403 }
404
405 #[tokio::test]
409 async fn test_stuff_typed_invoke_empty_documents_errors() {
410 let invokes = Arc::new(AtomicUsize::new(0));
411 let chain = StuffDocumentsChain::new(CountingLLM {
412 invokes: invokes.clone(),
413 });
414
415 let err = match chain.invoke_with_documents(vec![], "q").await {
416 Ok(_) => panic!("expected an execution error"),
417 Err(e) => e,
418 };
419 assert!(matches!(err, ChainError::ExecutionError(_)));
420 assert_eq!(invokes.load(Ordering::SeqCst), 0);
421 }
422
423 #[tokio::test]
427 async fn test_stuff_invoke_missing_input_errors_before_llm() {
428 let invokes = Arc::new(AtomicUsize::new(0));
429 let chain = StuffDocumentsChain::new(CountingLLM {
430 invokes: invokes.clone(),
431 });
432 let mut inputs = HashMap::new();
433 inputs.insert("documents".to_string(), serde_json::json!([]));
434
435 let err = match chain.invoke(inputs).await {
436 Ok(_) => panic!("expected a missing-input error"),
437 Err(e) => e,
438 };
439 assert!(matches!(err, ChainError::MissingInput(_)));
440 assert_eq!(invokes.load(Ordering::SeqCst), 0);
441 }
442
443 #[tokio::test]
446 async fn test_stuff_stream_empty_documents_errors() {
447 let chain = StuffDocumentsChain::new(CountingLLM {
448 invokes: Arc::new(AtomicUsize::new(0)),
449 });
450 let mut inputs = HashMap::new();
451 inputs.insert("input".to_string(), Value::String("q".to_string()));
452 inputs.insert("documents".to_string(), serde_json::json!([]));
453
454 let err = match chain.stream(inputs).await {
455 Ok(_) => panic!("expected an execution error"),
456 Err(e) => e,
457 };
458 assert!(matches!(err, ChainError::ExecutionError(_)));
459 }
460}