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