Skip to main content

lc_chains/document_chains/
stuff.rs

1// lc-chains/src/document_chains/stuff.rs
2//! StuffDocumentsChain - stuffs all documents into a single prompt.
3
4use async_trait::async_trait;
5use futures_util::StreamExt;
6use lc_core::BaseChatModel;
7use lc_providers::{wrap_chat_model, ProviderError};
8use lc_schema::Message;
9use lc_shared::document::Document;
10use serde_json::Value;
11use std::collections::HashMap;
12
13use crate::base::{BaseChain, ChainError, ChainResult, ChainStream, StreamToken};
14use crate::BoxedChatModel;
15
16/// Default Stuff prompt template.
17pub(crate) const DEFAULT_STUFF_PROMPT: &str =
18    "Answer the user's question based on the following reference information.
19
20Reference information:
21{context}
22
23Question: {input}
24
25Answer:";
26
27/// StuffDocumentsChain
28///
29/// Stuffs all documents into a single prompt for LLM processing.
30/// Suitable when the total document content fits within the LLM context window.
31pub struct StuffDocumentsChain {
32    llm: BoxedChatModel,
33    prompt_template: String,
34    document_variable_name: String,
35    input_key: String,
36    output_key: String,
37    name: String,
38    verbose: bool,
39    /// Maximum character count per document (truncated if exceeded).
40    max_doc_length: Option<usize>,
41}
42
43impl StuffDocumentsChain {
44    /// Create a new [`StuffDocumentsChain`] with the given LLM.
45    pub fn new<L>(llm: L) -> Self
46    where
47        L: BaseChatModel + Send + Sync + 'static,
48        L::Error: Into<ProviderError>,
49    {
50        Self {
51            llm: wrap_chat_model(llm),
52            prompt_template: DEFAULT_STUFF_PROMPT.to_string(),
53            document_variable_name: "context".to_string(),
54            input_key: "input".to_string(),
55            output_key: "output".to_string(),
56            name: "stuff_documents".to_string(),
57            verbose: false,
58            max_doc_length: None,
59        }
60    }
61
62    /// Set the prompt template.
63    pub fn with_prompt_template(mut self, template: impl Into<String>) -> Self {
64        self.prompt_template = template.into();
65        self
66    }
67
68    /// Set the document variable name used in the prompt.
69    pub fn with_document_variable(mut self, name: impl Into<String>) -> Self {
70        self.document_variable_name = name.into();
71        self
72    }
73
74    /// Set the input key.
75    pub fn with_input_key(mut self, key: impl Into<String>) -> Self {
76        self.input_key = key.into();
77        self
78    }
79
80    /// Set the output key.
81    pub fn with_output_key(mut self, key: impl Into<String>) -> Self {
82        self.output_key = key.into();
83        self
84    }
85
86    /// Set the chain name.
87    pub fn with_name(mut self, name: impl Into<String>) -> Self {
88        self.name = name.into();
89        self
90    }
91
92    /// Set verbose mode.
93    pub fn with_verbose(mut self, verbose: bool) -> Self {
94        self.verbose = verbose;
95        self
96    }
97
98    /// Set the maximum character count per document (documents are truncated if exceeded).
99    pub fn with_max_doc_length(mut self, max: usize) -> Self {
100        self.max_doc_length = Some(max);
101        self
102    }
103
104    /// Format document list into context text.
105    pub fn format_documents(&self, documents: &[Document]) -> String {
106        let mut parts = Vec::new();
107        for (i, doc) in documents.iter().enumerate() {
108            let mut content = doc.content.clone();
109            if let Some(max_len) = self.max_doc_length {
110                let char_count: usize = content.chars().count();
111                if char_count > max_len {
112                    content = content.chars().take(max_len).collect::<String>();
113                    content.push_str("...\n[document truncated]");
114                }
115            }
116            parts.push(format!("Document {}:\n{}", i + 1, content));
117        }
118        parts.join("\n\n---\n\n")
119    }
120
121    /// Build prompt.
122    pub fn build_prompt(&self, context: &str, input: &str) -> String {
123        self.prompt_template
124            .replace(&format!("{{{}}}", self.document_variable_name), context)
125            .replace("{input}", input)
126    }
127
128    /// Invoke with documents and input directly.
129    pub async fn invoke_with_documents(
130        &self,
131        documents: Vec<Document>,
132        input: &str,
133    ) -> Result<String, ChainError> {
134        // P2-7: same loud empty-documents guard as map_reduce/refine/map_rerank —
135        // without it the LLM would be called with an empty context and fabricate
136        // an answer that has no reference information at all.
137        if documents.is_empty() {
138            return Err(ChainError::ExecutionError(
139                "Document list is empty".to_string(),
140            ));
141        }
142
143        let context = self.format_documents(&documents);
144
145        if self.verbose {
146            println!("\n=== StuffDocumentsChain ===");
147            println!("Document count: {}", documents.len());
148            println!("Context length: {} characters", context.len());
149        }
150
151        let prompt = self.build_prompt(&context, input);
152
153        if self.verbose {
154            println!("Prompt length: {} characters", prompt.len());
155        }
156
157        let messages = vec![Message::human(&prompt)];
158        let response = self
159            .llm
160            .invoke(messages, None)
161            .await
162            .map_err(|e| ChainError::ExecutionError(format!("LLM call failed: {}", e)))?;
163
164        let output = response.content;
165
166        if self.verbose {
167            println!("Output: {}", output);
168            println!("=== StuffDocumentsChain complete ===\n");
169        }
170
171        Ok(output)
172    }
173}
174
175#[async_trait]
176impl BaseChain for StuffDocumentsChain {
177    fn input_keys(&self) -> Vec<&str> {
178        vec![&self.input_key, "documents"]
179    }
180
181    fn output_keys(&self) -> Vec<&str> {
182        vec![&self.output_key]
183    }
184
185    async fn invoke(&self, inputs: HashMap<String, Value>) -> Result<ChainResult, ChainError> {
186        // P2-8: validate inputs on the invoke path too, matching stream (and the
187        // crate-wide invoke+stream convention) so missing keys fail identically.
188        self.validate_inputs(&inputs)?;
189
190        let input = inputs
191            .get(&self.input_key)
192            .and_then(|v| v.as_str())
193            .ok_or_else(|| ChainError::MissingInput(self.input_key.clone()))?;
194
195        let documents = crate::base::documents_from_input(inputs.get("documents"))?;
196
197        let output = self.invoke_with_documents(documents, input).await?;
198
199        let mut result = HashMap::new();
200        result.insert(self.output_key.clone(), Value::String(output));
201        Ok(result)
202    }
203
204    /// Stream execution for StuffDocumentsChain — token by token output.
205    ///
206    /// Stuffs all documents into a single prompt, then streams the LLM
207    /// response token by token.
208    async fn stream(&self, inputs: HashMap<String, Value>) -> Result<ChainStream, ChainError> {
209        self.validate_inputs(&inputs)?;
210
211        let input = inputs
212            .get(&self.input_key)
213            .and_then(|v| v.as_str())
214            .ok_or_else(|| ChainError::MissingInput(self.input_key.clone()))?;
215
216        let documents = crate::base::documents_from_input(inputs.get("documents"))?;
217
218        // P2-7: guard empty documents on the stream path too, mirroring the
219        // other document chains — streaming with zero context would still call
220        // the LLM and emit a fabricated, reference-free answer.
221        if documents.is_empty() {
222            return Err(ChainError::ExecutionError(
223                "Document list is empty".to_string(),
224            ));
225        }
226
227        let context = self.format_documents(&documents);
228        let prompt = self.build_prompt(&context, input);
229        let messages = vec![Message::human(&prompt)];
230
231        let llm_stream = self
232            .llm
233            .stream_chat(messages, None)
234            .await
235            .map_err(|e| ChainError::StreamError(format!("LLM stream failed: {}", e)))?;
236
237        let stream = llm_stream.map(|result| match result {
238            Ok(chunk) => Ok(StreamToken {
239                token: chunk.text,
240                is_final: false,
241            }),
242            Err(e) => Err(ChainError::StreamError(format!(
243                "Stream token error: {}",
244                e
245            ))),
246        });
247
248        let final_stream = stream.chain(futures_util::stream::once(async move {
249            Ok(StreamToken {
250                token: String::new(),
251                is_final: true,
252            })
253        }));
254
255        Ok(Box::pin(final_stream))
256    }
257
258    fn name(&self) -> &str {
259        &self.name
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use async_trait::async_trait;
267    use futures_util::Stream;
268    use lc_core::language_models::{LLMResult, StreamChunk};
269    use lc_core::runnables::RunnableConfig;
270    use lc_core::{BaseLanguageModel, Runnable};
271    use std::pin::Pin;
272    use std::sync::atomic::{AtomicUsize, Ordering};
273    use std::sync::Arc;
274
275    /// Counting chat model that records `invoke` calls so the P2-7 typed entry
276    /// is provable: non-empty documents reach the LLM exactly once, and empty
277    /// documents never reach it at all.
278    struct CountingLLM {
279        invokes: Arc<AtomicUsize>,
280    }
281
282    #[async_trait]
283    impl Runnable<Vec<Message>, LLMResult> for CountingLLM {
284        type Error = ProviderError;
285        async fn invoke(
286            &self,
287            _input: Vec<Message>,
288            _config: Option<RunnableConfig>,
289        ) -> Result<LLMResult, Self::Error> {
290            self.invokes.fetch_add(1, Ordering::SeqCst);
291            Ok(LLMResult {
292                content: "stuffed answer".to_string(),
293                model: "mock".to_string(),
294                token_usage: None,
295                tool_calls: None,
296                thinking_content: None,
297            })
298        }
299    }
300
301    #[async_trait]
302    impl BaseLanguageModel<Vec<Message>, LLMResult> for CountingLLM {
303        fn model_name(&self) -> &str {
304            "mock"
305        }
306        fn get_num_tokens(&self, t: &str) -> usize {
307            t.len()
308        }
309        fn with_temperature(self, _: f32) -> Self {
310            self
311        }
312        fn with_max_tokens(self, _: usize) -> Self {
313            self
314        }
315    }
316
317    #[async_trait]
318    impl BaseChatModel for CountingLLM {
319        async fn chat(
320            &self,
321            _messages: Vec<Message>,
322            _config: Option<RunnableConfig>,
323        ) -> Result<LLMResult, Self::Error> {
324            self.invokes.fetch_add(1, Ordering::SeqCst);
325            Ok(LLMResult {
326                content: "stuffed answer".to_string(),
327                model: "mock".to_string(),
328                token_usage: None,
329                tool_calls: None,
330                thinking_content: None,
331            })
332        }
333        async fn stream_chat(
334            &self,
335            _messages: Vec<Message>,
336            _config: Option<RunnableConfig>,
337        ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, Self::Error>> + Send>>, Self::Error>
338        {
339            let tokens = [Ok(StreamChunk::new("streamed answer"))];
340            Ok(Box::pin(futures_util::stream::iter(tokens)))
341        }
342    }
343
344    /// P2-7: the typed `invoke_with_documents` entry skips the HashMap roundtrip
345    /// and runs the LLM over the documents directly.
346    #[tokio::test]
347    async fn test_stuff_typed_invoke_runs_llm() {
348        let invokes = Arc::new(AtomicUsize::new(0));
349        let chain = StuffDocumentsChain::new(CountingLLM {
350            invokes: invokes.clone(),
351        });
352
353        let out = chain
354            .invoke_with_documents(vec![Document::new("doc one")], "question")
355            .await
356            .unwrap();
357        assert_eq!(out, "stuffed answer");
358        assert_eq!(invokes.load(Ordering::SeqCst), 1);
359    }
360
361    /// P2-7: empty documents error loudly on the typed invoke path and the LLM
362    /// is never called with an empty context (consistent with
363    /// map_reduce/refine/map_rerank).
364    #[tokio::test]
365    async fn test_stuff_typed_invoke_empty_documents_errors() {
366        let invokes = Arc::new(AtomicUsize::new(0));
367        let chain = StuffDocumentsChain::new(CountingLLM {
368            invokes: invokes.clone(),
369        });
370
371        let err = match chain.invoke_with_documents(vec![], "q").await {
372            Ok(_) => panic!("expected an execution error"),
373            Err(e) => e,
374        };
375        assert!(matches!(err, ChainError::ExecutionError(_)));
376        assert_eq!(invokes.load(Ordering::SeqCst), 0);
377    }
378
379    /// P2-8: the HashMap `invoke` path validates inputs before any LLM call,
380    /// matching the stream path — a missing `input` key yields `MissingInput`
381    /// with zero invokes, instead of reaching the LLM first.
382    #[tokio::test]
383    async fn test_stuff_invoke_missing_input_errors_before_llm() {
384        let invokes = Arc::new(AtomicUsize::new(0));
385        let chain = StuffDocumentsChain::new(CountingLLM {
386            invokes: invokes.clone(),
387        });
388        let mut inputs = HashMap::new();
389        inputs.insert("documents".to_string(), serde_json::json!([]));
390
391        let err = match chain.invoke(inputs).await {
392            Ok(_) => panic!("expected a missing-input error"),
393            Err(e) => e,
394        };
395        assert!(matches!(err, ChainError::MissingInput(_)));
396        assert_eq!(invokes.load(Ordering::SeqCst), 0);
397    }
398
399    /// P2-7: the stream path guards empty documents the same way, erroring
400    /// before any `stream_chat` call.
401    #[tokio::test]
402    async fn test_stuff_stream_empty_documents_errors() {
403        let chain = StuffDocumentsChain::new(CountingLLM {
404            invokes: Arc::new(AtomicUsize::new(0)),
405        });
406        let mut inputs = HashMap::new();
407        inputs.insert("input".to_string(), Value::String("q".to_string()));
408        inputs.insert("documents".to_string(), serde_json::json!([]));
409
410        let err = match chain.stream(inputs).await {
411            Ok(_) => panic!("expected an execution error"),
412            Err(e) => e,
413        };
414        assert!(matches!(err, ChainError::ExecutionError(_)));
415    }
416}