sokuto_server/guardrails/
streaming.rs1use std::sync::Arc;
10
11use bytes::Bytes;
12use futures_util::Stream;
13use futures_util::stream::BoxStream;
14
15use crate::guardrails::nli::{
16 HallucinationVerificationResult, HallucinationVerifierConfig, verify_claim_nli,
17};
18
19pub const SENTENCE_DELIMITERS: &[char] = &['。', '\n', '!', '?', '!', '?'];
21
22pub const TERMINATION_REPLACEMENT_MESSAGE: &str =
24 "\n[安全基準に基づき生成を中断しました。事実整合性エラーを検知しました]";
25
26#[derive(Debug, Clone, PartialEq)]
28pub struct StreamingVerifierConfig {
29 pub enabled: bool,
31 pub min_sentence_chars: usize,
33 pub replacement_message: String,
35 pub verifier_config: HallucinationVerifierConfig,
37}
38
39impl Default for StreamingVerifierConfig {
40 fn default() -> Self {
41 Self {
42 enabled: true,
43 min_sentence_chars: 6,
44 replacement_message: TERMINATION_REPLACEMENT_MESSAGE.to_string(),
45 verifier_config: HallucinationVerifierConfig::default(),
46 }
47 }
48}
49
50#[derive(Debug, Default)]
52pub struct SentenceChunkBuffer {
53 buffer: String,
54}
55
56impl SentenceChunkBuffer {
57 pub fn new() -> Self {
59 Self {
60 buffer: String::new(),
61 }
62 }
63
64 pub fn push_and_drain_sentences(&mut self, chunk: &str, min_chars: usize) -> Vec<String> {
73 self.buffer.push_str(chunk);
74 let mut sentences = Vec::new();
75
76 while let Some(idx) = self.find_delimiter_index(min_chars) {
77 let delim_char = self.buffer[idx..].chars().next().unwrap();
79 let split_byte_idx = idx + delim_char.len_utf8();
80
81 let sentence = self.buffer[..split_byte_idx].to_string();
82 self.buffer = self.buffer[split_byte_idx..].to_string();
83 sentences.push(sentence);
84 }
85
86 sentences
87 }
88
89 pub fn flush(&mut self) -> Option<String> {
91 if self.buffer.is_empty() {
92 None
93 } else {
94 let remaining = std::mem::take(&mut self.buffer);
95 Some(remaining)
96 }
97 }
98
99 #[inline]
100 fn find_delimiter_index(&self, min_chars: usize) -> Option<usize> {
101 let mut char_count = 0;
102 for (byte_idx, c) in self.buffer.char_indices() {
103 char_count += 1;
104 if char_count >= min_chars && SENTENCE_DELIMITERS.contains(&c) {
105 return Some(byte_idx);
106 }
107 }
108 None
109 }
110}
111
112pub fn extract_content_from_sse_line(line: &str) -> Option<String> {
116 let trimmed = line.trim();
117 if !trimmed.starts_with("data:") {
118 return None;
119 }
120 let data_body = trimmed.strip_prefix("data:")?.trim();
121 if data_body == "[DONE]" {
122 return None;
123 }
124
125 if let Ok(v) = serde_json::from_str::<serde_json::Value>(data_body) {
126 if let Some(content) = v
127 .pointer("/choices/0/delta/content")
128 .and_then(|c| c.as_str())
129 {
130 return Some(content.to_string());
131 }
132 if let Some(text) = v.get("text").and_then(|t| t.as_str()) {
133 return Some(text.to_string());
134 }
135 }
136
137 None
138}
139
140pub struct SpeculativeStreamInterceptor {
142 config: Arc<StreamingVerifierConfig>,
143 state_context: Arc<String>,
144}
145
146impl SpeculativeStreamInterceptor {
147 pub fn new(config: StreamingVerifierConfig, state_context: String) -> Self {
149 Self {
150 config: Arc::new(config),
151 state_context: Arc::new(state_context),
152 }
153 }
154
155 pub fn verify_sentence(
157 &self,
158 sentence: &str,
159 mock_nli_logits: Option<[f64; 3]>,
160 ) -> HallucinationVerificationResult {
161 let logits = mock_nli_logits.unwrap_or([3.0, -1.0, -3.0]);
163 verify_claim_nli(
164 &self.state_context,
165 sentence,
166 logits,
167 &self.config.verifier_config,
168 )
169 }
170
171 pub fn build_error_event_bytes(&self, reason: &str) -> Bytes {
173 let payload = serde_json::json!({
174 "error": "guardrail_violation",
175 "rule": "outbound_hallucination",
176 "message": self.config.replacement_message,
177 "reason": reason
178 });
179 let sse = format!(
180 "event: guardrail_violation\ndata: {}\n\ndata: [DONE]\n\n",
181 payload
182 );
183 Bytes::from(sse)
184 }
185
186 pub fn config(&self) -> &StreamingVerifierConfig {
188 &self.config
189 }
190
191 pub fn state_context(&self) -> &str {
193 &self.state_context
194 }
195}
196
197pub fn intercept_sse_stream<S, E>(
201 stream: S,
202 interceptor: Arc<SpeculativeStreamInterceptor>,
203) -> BoxStream<'static, Result<Bytes, E>>
204where
205 S: Stream<Item = Result<Bytes, E>> + Send + 'static,
206 E: Send + 'static,
207{
208 use futures_util::StreamExt;
209
210 let mut buffer = SentenceChunkBuffer::new();
211 let mut terminated = false;
212
213 let stream = stream.flat_map(move |item| {
214 if terminated {
215 return futures_util::stream::empty().boxed();
216 }
217
218 match item {
219 Ok(bytes) => {
220 let text = String::from_utf8_lossy(&bytes);
221 let sentences =
222 buffer.push_and_drain_sentences(&text, interceptor.config.min_sentence_chars);
223
224 for sentence in sentences {
225 let verification = interceptor.verify_sentence(&sentence, None);
226 if verification.is_hallucination {
227 terminated = true;
228 let err_reason = verification
229 .reason
230 .unwrap_or_else(|| "事実不整合".to_string());
231 let err_bytes = interceptor.build_error_event_bytes(&err_reason);
232 return futures_util::stream::iter(vec![Ok(err_bytes)]).boxed();
233 }
234 }
235
236 futures_util::stream::iter(vec![Ok(bytes)]).boxed()
237 }
238 Err(e) => futures_util::stream::iter(vec![Err(e)]).boxed(),
239 }
240 });
241
242 stream.boxed()
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248
249 #[test]
250 fn test_sentence_chunk_buffer_drain() {
251 let mut buf = SentenceChunkBuffer::new();
252 let chunks = vec![
253 "本日の",
254 "契約金額は",
255 "1,000万円です。",
256 "次回は",
257 "来月です。",
258 ];
259
260 let mut sentences = Vec::new();
261 for c in chunks {
262 let drained = buf.push_and_drain_sentences(c, 5);
263 sentences.extend(drained);
264 }
265 assert_eq!(sentences.len(), 2);
266 assert_eq!(sentences[0], "本日の契約金額は1,000万円です。");
267 assert_eq!(sentences[1], "次回は来月です。");
268 }
269
270 #[test]
271 fn test_extract_content_from_sse() {
272 let line = r#"data: {"choices":[{"delta":{"content":"認証完了。"}}]}"#;
273 let content = extract_content_from_sse_line(line);
274 assert_eq!(content.as_deref(), Some("認証完了。"));
275 }
276
277 #[test]
278 fn test_interceptor_error_generation() {
279 let config = StreamingVerifierConfig::default();
280 let interceptor = SpeculativeStreamInterceptor::new(config, "前提コンテキスト".to_string());
281 let err_bytes = interceptor.build_error_event_bytes("テスト理由");
282 let s = String::from_utf8_lossy(&err_bytes);
283 assert!(s.contains("event: guardrail_violation"));
284 assert!(s.contains("data: [DONE]"));
285 }
286
287 #[tokio::test]
288 async fn test_intercept_sse_stream_circuit_breaker() {
289 use futures_util::StreamExt;
290
291 let config = StreamingVerifierConfig::default();
292 let interceptor = Arc::new(SpeculativeStreamInterceptor::new(
294 config,
295 "前提: 契約金額は 1,000万円です。".to_string(),
296 ));
297
298 let chunks = vec![
300 Ok::<_, std::io::Error>(Bytes::from("data: 本件の確認を開始します。\n\n")),
301 Ok(Bytes::from(
302 "data: 契約金額は 1,000億円に確定しました。\n\n",
303 )),
304 Ok(Bytes::from("data: 完了しました。\n\n")),
305 ];
306 let raw_stream = futures_util::stream::iter(chunks);
307
308 let verified_stream = intercept_sse_stream(raw_stream, interceptor);
309 let items: Vec<Result<Bytes, std::io::Error>> = verified_stream.collect().await;
310
311 let texts: Vec<String> = items
312 .into_iter()
313 .map(|r| String::from_utf8_lossy(&r.unwrap()).to_string())
314 .collect();
315
316 assert!(texts[0].contains("本件の確認を開始します。"));
318 assert!(texts[1].contains("event: guardrail_violation"));
320 assert!(texts[1].contains("[安全基準に基づき生成を中断しました"));
321 assert_eq!(texts.len(), 2);
323 }
324}