use async_trait::async_trait;
use futures_util::StreamExt;
use serde_json::Value;
use std::collections::HashMap;
use std::sync::LazyLock;
use super::base::{BaseChain, ChainError, ChainResult, ChainStream, StreamToken};
use crate::schema::Message;
use crate::BaseChatModel;
use crate::Runnable;
pub struct LLMChain<M: BaseChatModel> {
llm: M,
prompt_template: String,
input_key: String,
output_key: String,
name: String,
}
static TEMPLATE_VAR_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}").unwrap());
impl<M: BaseChatModel> LLMChain<M> {
pub fn new(llm: M, prompt_template: impl Into<String>) -> Self {
Self {
llm,
prompt_template: prompt_template.into(),
input_key: "question".to_string(),
output_key: "text".to_string(),
name: "llm_chain".to_string(),
}
}
pub fn with_input_key(mut self, key: impl Into<String>) -> Self {
self.input_key = key.into();
self
}
pub fn with_output_key(mut self, key: impl Into<String>) -> Self {
self.output_key = key.into();
self
}
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = name.into();
self
}
fn render_prompt(&self, inputs: &HashMap<String, Value>) -> Result<String, ChainError> {
let mut prompt = self.prompt_template.clone();
for (key, value) in inputs {
let placeholder = format!("{{{}}}", key);
let value_str = match value {
Value::String(s) => s.clone(),
_ => value.to_string(),
};
prompt = prompt.replace(&placeholder, &value_str);
}
let unreplaced: Vec<&str> = TEMPLATE_VAR_RE
.captures_iter(&prompt)
.filter_map(|c| c.get(1).map(|m| m.as_str()))
.collect();
if !unreplaced.is_empty() {
return Err(ChainError::ExecutionError(format!(
"Prompt template has unreplaced variable(s): {}",
unreplaced.join(", ")
)));
}
Ok(prompt)
}
}
#[async_trait]
impl<M: BaseChatModel + Send + Sync + 'static> BaseChain for LLMChain<M>
where
<M as Runnable<Vec<Message>, crate::core::language_models::LLMResult>>::Error:
std::fmt::Display,
{
fn input_keys(&self) -> Vec<&str> {
vec![&self.input_key]
}
fn output_keys(&self) -> Vec<&str> {
vec![&self.output_key]
}
async fn invoke(&self, inputs: HashMap<String, Value>) -> Result<ChainResult, ChainError> {
self.validate_inputs(&inputs)?;
let prompt = self.render_prompt(&inputs)?;
let messages = vec![Message::human(&prompt)];
let result = self
.llm
.invoke(messages, None)
.await
.map_err(|e| ChainError::ExecutionError(format!("LLM call failed: {}", e)))?;
let mut output = HashMap::new();
output.insert(self.output_key.clone(), Value::String(result.content));
Ok(output)
}
async fn stream(&self, inputs: HashMap<String, Value>) -> Result<ChainStream, ChainError> {
self.validate_inputs(&inputs)?;
let prompt = self.render_prompt(&inputs)?;
let messages = vec![Message::human(&prompt)];
let llm_stream = self
.llm
.stream_chat(messages, None)
.await
.map_err(|e| ChainError::StreamError(format!("LLM stream failed: {}", e)))?;
let stream = llm_stream.map(move |result| match result {
Ok(token) => Ok(StreamToken {
token,
is_final: false,
}),
Err(e) => Err(ChainError::StreamError(format!(
"Stream token error: {}",
e
))),
});
let final_stream = stream.chain(futures_util::stream::once(async move {
Ok(StreamToken {
token: String::new(),
is_final: true,
})
}));
Ok(Box::pin(final_stream))
}
fn name(&self) -> &str {
&self.name
}
}
pub struct LLMChainBuilder<M: BaseChatModel> {
llm: M,
prompt_template: String,
input_key: Option<String>,
output_key: Option<String>,
name: Option<String>,
}
impl<M: BaseChatModel> LLMChainBuilder<M> {
pub fn new(llm: M, prompt_template: impl Into<String>) -> Self {
Self {
llm,
prompt_template: prompt_template.into(),
input_key: None,
output_key: None,
name: None,
}
}
pub fn input_key(mut self, key: impl Into<String>) -> Self {
self.input_key = Some(key.into());
self
}
pub fn output_key(mut self, key: impl Into<String>) -> Self {
self.output_key = Some(key.into());
self
}
pub fn name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn build(self) -> LLMChain<M> {
let mut chain = LLMChain::new(self.llm, self.prompt_template);
if let Some(key) = self.input_key {
chain = chain.with_input_key(key);
}
if let Some(key) = self.output_key {
chain = chain.with_output_key(key);
}
if let Some(name) = self.name {
chain = chain.with_name(name);
}
chain
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::language_models::OpenAIChat;
use crate::OpenAIConfig;
fn create_test_config() -> OpenAIConfig {
OpenAIConfig {
api_key: "sk-6eb65fcf5d17491ca10b984efe1f43e7".to_string(),
base_url:
"https://llm-8xo1b7o30z27y2xc.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"
.to_string(),
model: "glm-5.2".to_string(),
streaming: false,
organization: None,
frequency_penalty: None,
max_tokens: None,
presence_penalty: None,
temperature: None,
top_p: None,
tools: None,
tool_choice: None,
}
}
#[test]
fn test_render_prompt() {
let llm = OpenAIChat::new(create_test_config());
let chain = LLMChain::new(llm, "Question: {question}");
let inputs = HashMap::from([(
"question".to_string(),
Value::String("What is Rust?".to_string()),
)]);
let prompt = chain.render_prompt(&inputs).unwrap();
assert_eq!(prompt, "Question: What is Rust?");
}
#[test]
fn test_render_prompt_multiple_vars() {
let llm = OpenAIChat::new(create_test_config());
let chain = LLMChain::new(llm, "Name: {name}, Age: {age}");
let inputs = HashMap::from([
("name".to_string(), Value::String("Alice".to_string())),
("age".to_string(), Value::Number(25.into())),
]);
let prompt = chain.render_prompt(&inputs).unwrap();
assert_eq!(prompt, "Name: Alice, Age: 25");
}
#[tokio::test]
#[ignore]
async fn test_llm_chain_simple() {
let llm = OpenAIChat::new(create_test_config());
let chain = LLMChain::new(llm, "Answer in one sentence: {question}");
let inputs = HashMap::from([(
"question".to_string(),
Value::String("What is Rust?".to_string()),
)]);
println!("\n=== Test LLMChain - simple question ===");
let result = chain.invoke(inputs).await.unwrap();
println!("Output: {:?}", result);
assert!(result.contains_key("text"));
assert!(!result.get("text").unwrap().as_str().unwrap().is_empty());
}
#[tokio::test]
#[ignore]
async fn test_llm_chain_template() {
let llm = OpenAIChat::new(create_test_config());
let chain = LLMChain::new(llm, "Answer in {style} style: {question}");
let inputs = HashMap::from([
("style".to_string(), Value::String("humorous".to_string())),
(
"question".to_string(),
Value::String("What is programming?".to_string()),
),
]);
println!("\n=== Test LLMChain - multi-variable template ===");
let result = chain.invoke(inputs).await.unwrap();
println!("Output: {:?}", result);
assert!(result.contains_key("text"));
}
#[tokio::test]
#[ignore]
async fn test_llm_chain_builder() {
let llm = OpenAIChat::new(create_test_config());
let chain = LLMChainBuilder::new(llm, "Translate to {language}: {text}")
.input_key("text")
.output_key("translation")
.name("translator")
.build();
let inputs = HashMap::from([
("language".to_string(), Value::String("English".to_string())),
(
"text".to_string(),
Value::String("Hello, world".to_string()),
),
]);
println!("\n=== Test LLMChain - Builder ===");
let result = chain.invoke(inputs).await.unwrap();
println!("Output: {:?}", result);
assert!(result.contains_key("translation"));
}
}