Skip to main content

lc_testkit/
recording.rs

1//! `RecordingProvider`:真实调用一次,把请求/响应对追加到 JSONL 录制文件。
2//!
3//! 录制是**旁路**:真实调用失败就返回失败、不写录播;真实调用成功但写盘失败
4//! 仅 `log::warn!`,不阻断真实结果。
5
6use std::io::Write;
7use std::path::Path;
8use std::pin::Pin;
9use std::sync::{Arc, Mutex};
10
11use async_trait::async_trait;
12use futures_util::{Stream, StreamExt};
13use lc_core::language_models::{BaseChatModel, BaseLanguageModel, LLMResult};
14use lc_core::runnables::{Runnable, RunnableConfig};
15use lc_core::tools::ToolDefinition;
16use lc_providers::ProviderError;
17use lc_schema::Message;
18use serde::{Deserialize, Serialize};
19
20use crate::error::TestkitError;
21
22/// 一次录制的请求/响应对,序列化为 JSONL 一行。
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct RecordedExchange {
25    /// 请求(含 system/user/assistant/tool 历史)。
26    pub messages: Vec<Message>,
27    /// 完整响应。
28    pub response: LLMResult,
29    /// 请求侧绑定的工具定义(`bind_tools` 绑定后非空)。
30    ///
31    /// `#[serde(default)]` 让旧 fixture(无 `tools` 字段)读成 `None`,零改动兼容;
32    /// `skip_serializing_if` 让未绑定工具的录播文件保持旧格式。
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub tools: Option<Vec<ToolDefinition>>,
35}
36
37/// 追加写录制文件的共享句柄(append 模式,std Mutex 保护)。
38pub struct Recorder {
39    file: Mutex<std::fs::File>,
40}
41
42impl Recorder {
43    /// 打开/创建录制文件。打不开 → 构造期直接 `Err`(fail fast)。
44    pub fn new(path: impl AsRef<Path>) -> std::io::Result<Self> {
45        let file = std::fs::OpenOptions::new()
46            .create(true)
47            .append(true)
48            .open(path)?;
49        Ok(Self {
50            file: Mutex::new(file),
51        })
52    }
53
54    /// Best-effort 追加一条录播:失败只 `log::warn!`,绝不向上传播。
55    pub fn record(&self, exchange: &RecordedExchange) {
56        let line = match serde_json::to_string(exchange) {
57            Ok(line) => line,
58            Err(e) => {
59                log::warn!("lc-testkit: failed to serialize recording: {e}");
60                return;
61            }
62        };
63        let Ok(mut file) = self.file.lock() else {
64            log::warn!("lc-testkit: recording lock poisoned");
65            return;
66        };
67        if let Err(e) = writeln!(file, "{line}") {
68            log::warn!("lc-testkit: failed to append recording: {e}");
69        }
70    }
71}
72
73/// 把内层模型错误映射为 `TestkitError`(经 `ProviderError` 无损透传)。
74fn to_testkit<E: Into<ProviderError>>(e: E) -> TestkitError {
75    TestkitError::Inner(e.into())
76}
77
78/// 包裹任意 `BaseChatModel`:成功响应后把请求/响应对追加到 JSONL。
79pub struct RecordingProvider<M> {
80    inner: M,
81    recorder: Arc<Recorder>,
82    model_name: String,
83    /// 当前绑定的工具定义(`bind_tools` 设置,chat 时录进 exchange)。
84    tools: Option<Vec<ToolDefinition>>,
85}
86
87impl<M> RecordingProvider<M>
88where
89    M: BaseChatModel + Send + Sync + 'static,
90    M::Error: Into<ProviderError>,
91{
92    /// 用内层模型 + 录制文件构造。文件打不开 → `Err`。
93    pub fn new(inner: M, path: impl AsRef<Path>) -> std::io::Result<Self> {
94        let model_name = format!("{}-recorded", inner.model_name());
95        let recorder = Arc::new(Recorder::new(path)?);
96        Ok(Self {
97            inner,
98            recorder,
99            model_name,
100            tools: None,
101        })
102    }
103
104    /// 访问内层模型。
105    pub fn inner(&self) -> &M {
106        &self.inner
107    }
108
109    /// 绑定工具:返回一个记录该工具集的新实例。
110    ///
111    /// 后续 `chat` 会把工具定义录进 `RecordedExchange.tools`,让回放侧
112    /// 能按工具名路由(见 [`crate::ReplayStrategy::ByToolName`])。
113    pub fn bind_tools(&self, tools: Vec<ToolDefinition>) -> Self
114    where
115        M: Clone,
116    {
117        Self {
118            inner: self.inner.clone(),
119            recorder: self.recorder.clone(),
120            model_name: self.model_name.clone(),
121            tools: Some(tools),
122        }
123    }
124}
125
126#[async_trait]
127impl<M> Runnable<Vec<Message>, LLMResult> for RecordingProvider<M>
128where
129    M: BaseChatModel + Clone + Send + Sync + 'static,
130    M::Error: Into<ProviderError>,
131{
132    type Error = TestkitError;
133
134    async fn invoke(
135        &self,
136        input: Vec<Message>,
137        config: Option<RunnableConfig>,
138    ) -> Result<LLMResult, Self::Error> {
139        self.chat(input, config).await
140    }
141}
142
143impl<M> BaseLanguageModel<Vec<Message>, LLMResult> for RecordingProvider<M>
144where
145    M: BaseChatModel + Clone + Send + Sync + 'static,
146    M::Error: Into<ProviderError>,
147{
148    fn model_name(&self) -> &str {
149        &self.model_name
150    }
151
152    fn get_num_tokens(&self, text: &str) -> usize {
153        self.inner.get_num_tokens(text)
154    }
155
156    fn temperature(&self) -> Option<f32> {
157        self.inner.temperature()
158    }
159
160    fn max_tokens(&self) -> Option<usize> {
161        self.inner.max_tokens()
162    }
163
164    fn with_temperature(mut self, temp: f32) -> Self {
165        self.inner = self.inner.with_temperature(temp);
166        self
167    }
168
169    fn with_max_tokens(mut self, max: usize) -> Self {
170        self.inner = self.inner.with_max_tokens(max);
171        self
172    }
173}
174
175#[async_trait]
176impl<M> BaseChatModel for RecordingProvider<M>
177where
178    M: BaseChatModel + Clone + Send + Sync + 'static,
179    M::Error: Into<ProviderError>,
180{
181    async fn chat(
182        &self,
183        messages: Vec<Message>,
184        config: Option<RunnableConfig>,
185    ) -> Result<LLMResult, Self::Error> {
186        let response = self
187            .inner
188            .chat(messages.clone(), config)
189            .await
190            .map_err(to_testkit)?;
191        self.recorder.record(&RecordedExchange {
192            messages,
193            response: response.clone(),
194            tools: self.tools.clone(),
195        });
196        Ok(response)
197    }
198
199    async fn stream_chat(
200        &self,
201        messages: Vec<Message>,
202        config: Option<RunnableConfig>,
203    ) -> Result<Pin<Box<dyn Stream<Item = Result<String, Self::Error>> + Send>>, Self::Error> {
204        let mut stream = self
205            .inner
206            .stream_chat(messages.clone(), config)
207            .await
208            .map_err(to_testkit)?;
209        let mut chunks = Vec::new();
210        while let Some(chunk) = stream.next().await {
211            chunks.push(chunk.map_err(to_testkit)?);
212        }
213        let full = chunks.concat();
214        let response = LLMResult {
215            content: full.clone(),
216            model: self.model_name.clone(),
217            ..Default::default()
218        };
219        self.recorder.record(&RecordedExchange {
220            messages,
221            response,
222            tools: self.tools.clone(),
223        });
224        let stream = futures_util::stream::iter(vec![Ok(full)]);
225        Ok(Box::pin(stream))
226    }
227
228    fn bind_tools(
229        &self,
230        tools: Vec<ToolDefinition>,
231    ) -> Option<Box<dyn BaseChatModel<Error = Self::Error> + Send + Sync>> {
232        // 委托给 inherent `bind_tools`:克隆内层模型、记录工具集、返回新实例。
233        Some(Box::new(self.bind_tools(tools)))
234    }
235}