1pub mod arithmetic;
12pub mod attention;
13pub mod bimodal;
14pub mod compactor;
15pub mod fast_fail;
16pub mod limits;
17pub mod multitask;
18pub mod nli;
19pub mod proxy;
20pub mod sanitization;
21pub mod streaming;
22pub mod temporal;
23pub mod window;
24
25use std::sync::Arc;
26
27use chrono::{DateTime, Utc};
28use indexmap::IndexMap;
29use serde_json::Value;
30use sokuto_core::schema::{Answer, Criteria, Question, QuestionType, SystemOneRequest};
31
32use crate::error::ServerError;
33use crate::guardrails::arithmetic::{ArithmeticConfig, compute_arithmetic_summary};
34pub use crate::guardrails::attention::{
35 ATTN_MASK_ALLOW, ATTN_MASK_BLOCK, AttentionIsolationConfig, SegmentType,
36 build_flat_attention_mask, build_segment_isolated_attention_mask,
37};
38use crate::guardrails::bimodal::{BimodalConfig, BimodalDetector, verify_and_adjust_bimodal};
39use crate::guardrails::compactor::{CompactorConfig, compact_text};
40pub use crate::guardrails::fast_fail::{
41 FastFailConfig, FastFailScanner, GuardrailAction, GuardrailViolation,
42};
43use crate::guardrails::limits::{LimitsConfig, validate_limits};
44pub use crate::guardrails::multitask::{
45 MARKER_INJECTION, MARKER_PII, MARKER_TOXICITY, MultiTaskScanResult, MultiTaskScannerConfig,
46 build_multitask_prompt, compute_free_energy, evaluate_multitask_logits, sigmoid,
47};
48pub use crate::guardrails::nli::{
49 HallucinationVerificationResult, HallucinationVerifierConfig, NormalizedEntity,
50 check_entity_mismatch, compute_synonym_coverage, extract_normalized_entities, verify_claim_nli,
51};
52pub use crate::guardrails::proxy::{GuardrailEngine, inline_guardrail_middleware};
53use crate::guardrails::sanitization::{
54 SanitizerConfig, normalize_noul_instruction, sanitize_score_criteria_text, sanitize_text,
55};
56pub use crate::guardrails::streaming::{
57 SENTENCE_DELIMITERS, SentenceChunkBuffer, SpeculativeStreamInterceptor,
58 StreamingVerifierConfig, extract_content_from_sse_line, intercept_sse_stream,
59};
60use crate::guardrails::temporal::{
61 TemporalConfig, append_reference_time_metadata, normalize_temporal,
62};
63pub use crate::guardrails::window::{
64 WindowConfig, WindowExtractionResult, estimate_tokens, extract_head_tail_window,
65};
66
67#[derive(Debug, Clone, PartialEq)]
69pub struct GuardrailConfig {
70 pub enabled: bool,
72 pub limits: LimitsConfig,
74 pub fast_fail: FastFailConfig,
76 pub window: WindowConfig,
78 pub attention: AttentionIsolationConfig,
80 pub multitask: MultiTaskScannerConfig,
82 pub hallucination: HallucinationVerifierConfig,
84 pub streaming: StreamingVerifierConfig,
86 pub sanitizer: SanitizerConfig,
88 pub compactor: CompactorConfig,
90 pub temporal: TemporalConfig,
92 pub arithmetic: ArithmeticConfig,
94 pub bimodal: BimodalConfig,
96}
97
98impl Default for GuardrailConfig {
99 fn default() -> Self {
100 Self {
101 enabled: true,
102 limits: LimitsConfig::default(),
103 fast_fail: FastFailConfig::default(),
104 window: WindowConfig::default(),
105 attention: AttentionIsolationConfig::default(),
106 multitask: MultiTaskScannerConfig::default(),
107 hallucination: HallucinationVerifierConfig::default(),
108 streaming: StreamingVerifierConfig::default(),
109 sanitizer: SanitizerConfig::default(),
110 compactor: CompactorConfig::default(),
111 temporal: TemporalConfig::default(),
112 arithmetic: ArithmeticConfig::default(),
113 bimodal: BimodalConfig::default(),
114 }
115 }
116}
117
118#[derive(Debug, Clone, Default)]
120pub struct PreprocessReport {
121 pub estimated_tokens: usize,
123 pub added_arithmetic_summary: bool,
125 pub added_reference_time: bool,
127 pub window_truncated: bool,
129}
130
131#[derive(Clone)]
133pub struct GuardrailPipeline {
134 config: Arc<GuardrailConfig>,
135 bimodal_detector: BimodalDetector,
136 fast_fail_scanner: Arc<FastFailScanner>,
137}
138
139impl GuardrailPipeline {
140 pub fn new(config: GuardrailConfig) -> Self {
142 let bimodal_detector = BimodalDetector::new(config.bimodal.clone());
143 let fast_fail_scanner = Arc::new(FastFailScanner::new(config.fast_fail.clone()));
144 Self {
145 config: Arc::new(config),
146 bimodal_detector,
147 fast_fail_scanner,
148 }
149 }
150
151 pub fn config(&self) -> &GuardrailConfig {
153 &self.config
154 }
155
156 pub fn process(
165 &self,
166 req: &mut SystemOneRequest,
167 ref_time: Option<DateTime<Utc>>,
168 ) -> Result<PreprocessReport, ServerError> {
169 if !self.config.enabled {
170 return Ok(PreprocessReport::default());
171 }
172
173 let now = ref_time.unwrap_or_else(Utc::now);
174 let mut report = PreprocessReport::default();
175
176 if let Value::String(ref s) = req.state
178 && let Err(violation) = self.fast_fail_scanner.scan(s)
179 {
180 return Err(ServerError::GuardrailViolation(violation.rule));
181 }
182 for question in req.questions.values() {
183 if let Err(violation) = self.fast_fail_scanner.scan(&question.instructions) {
184 return Err(ServerError::GuardrailViolation(violation.rule));
185 }
186 }
187
188 let (_, estimated_tokens) = validate_limits(req, &self.config.limits)?;
190 report.estimated_tokens = estimated_tokens;
191
192 let mut raw_state = match &req.state {
194 Value::String(s) => s.clone(),
195 other => other.to_string(),
196 };
197
198 let window_res = extract_head_tail_window(&raw_state, &self.config.window);
200 if window_res.was_truncated {
201 raw_state = window_res.window_text.into_owned();
202 report.window_truncated = true;
203 }
204
205 let arithmetic_summary = compute_arithmetic_summary(&req.state, &self.config.arithmetic);
207
208 let state_sanitized = sanitize_text(&raw_state, &self.config.sanitizer);
210 let state_compacted = compact_text(&state_sanitized, &self.config.compactor);
211 let state_temporal = normalize_temporal(&state_compacted, now, &self.config.temporal);
212
213 raw_state = state_temporal.into_owned();
214
215 if let Some(summary) = arithmetic_summary {
217 raw_state.push_str(&summary);
218 report.added_arithmetic_summary = true;
219 }
220
221 if self.config.temporal.attach_reference_time {
223 append_reference_time_metadata(
224 &mut raw_state,
225 now,
226 self.config.temporal.timezone_offset_hours,
227 );
228 report.added_reference_time = true;
229 }
230
231 req.state = Value::String(raw_state);
233
234 let mut normalized_questions = IndexMap::with_capacity(req.questions.len());
236
237 for (q_key, question) in &req.questions {
238 let inst_sanitized = sanitize_text(&question.instructions, &self.config.sanitizer);
240 let inst_compacted = compact_text(&inst_sanitized, &self.config.compactor);
241 let inst_temporal = normalize_temporal(&inst_compacted, now, &self.config.temporal);
242 let mut normalized_inst = inst_temporal.into_owned();
243
244 if question.question_type == QuestionType::Noul
246 && self.config.sanitizer.normalize_noul_instructions
247 {
248 normalized_inst = normalize_noul_instruction(&normalized_inst).into_owned();
249 }
250
251 let normalized_criteria = match &question.criteria {
253 Some(Criteria::Map(map)) => {
254 let mut new_map = IndexMap::with_capacity(map.len());
255 for (k, v) in map {
256 let k_sanitized = sanitize_text(k, &self.config.sanitizer);
257 let v_sanitized = sanitize_text(v, &self.config.sanitizer);
258 let v_compacted = compact_text(&v_sanitized, &self.config.compactor);
259 new_map.insert(k_sanitized.into_owned(), v_compacted.into_owned());
260 }
261 Some(Criteria::Map(new_map))
262 }
263 Some(Criteria::List(list)) => {
264 let mut new_list = Vec::with_capacity(list.len());
265 for item in list {
266 let item_sanitized = sanitize_text(item, &self.config.sanitizer);
267 let item_compacted = compact_text(&item_sanitized, &self.config.compactor);
268 let mut item_final = item_compacted.into_owned();
269
270 if question.question_type == QuestionType::Score
272 && self.config.sanitizer.sanitize_score_prefixes
273 {
274 item_final = sanitize_score_criteria_text(&item_final).into_owned();
275 }
276
277 new_list.push(item_final);
278 }
279 Some(Criteria::List(new_list))
280 }
281 Some(Criteria::None) => Some(Criteria::None),
282 None => None,
283 };
284
285 let new_question = Question {
286 question_type: question.question_type,
287 instructions: normalized_inst,
288 criteria: normalized_criteria,
289 };
290
291 normalized_questions.insert(q_key.clone(), new_question);
292 }
293
294 req.questions = normalized_questions;
295
296 Ok(report)
297 }
298
299 pub fn post_process(
305 &self,
306 answers: &mut IndexMap<String, Answer>,
307 original_questions: &IndexMap<String, Question>,
308 ) {
309 if !self.config.enabled {
310 return;
311 }
312
313 verify_and_adjust_bimodal(answers, original_questions, &self.bimodal_detector);
315
316 if self.config.sanitizer.sanitize_score_prefixes {
318 for (qid, answer) in answers.iter_mut() {
319 let Some(orig_q) = original_questions.get(qid) else {
320 continue;
321 };
322
323 if orig_q.question_type != QuestionType::Score {
324 continue;
325 }
326
327 let Some(Criteria::List(orig_list)) = &orig_q.criteria else {
328 continue;
329 };
330
331 let Some(ref mut probs) = answer.probabilities else {
332 continue;
333 };
334
335 if probs.len() == orig_list.len() {
337 let mut restored_probs = IndexMap::with_capacity(orig_list.len());
338 for (k, orig_label) in orig_list.iter().enumerate() {
339 if let Some((_, &p)) = probs.get_index(k) {
340 restored_probs.insert(orig_label.clone(), p);
341 }
342 }
343 *probs = restored_probs;
344 }
345 }
346 }
347 }
348}
349
350impl Default for GuardrailPipeline {
351 fn default() -> Self {
352 Self::new(GuardrailConfig::default())
353 }
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359 use chrono::TimeZone;
360 use serde_json::json;
361
362 #[test]
363 fn test_pipeline_end_to_end() {
364 let pipeline = GuardrailPipeline::default();
365 let ref_time = Utc.with_ymd_and_hms(2026, 9, 22, 10, 0, 0).unwrap();
366
367 let mut questions = IndexMap::new();
368 questions.insert(
369 "q1".to_string(),
370 Question::new_choice(
371 "3日前の注文 [OP] を確認せよ。".to_string(),
372 IndexMap::from([("yes".to_string(), "はい [OP]".to_string())]),
373 ),
374 );
375
376 let mut req = SystemOneRequest::new(
377 json!({
378 "items": [
379 {"name": "apple", "amount": 100},
380 {"name": "banana", "amount": 200}
381 ]
382 }),
383 questions,
384 );
385
386 let res = pipeline.process(&mut req, Some(ref_time));
387 assert!(res.is_ok());
388
389 let state_str = req.state.as_str().unwrap();
390 assert!(state_str.contains("items_count=2"));
392 assert!(state_str.contains("total_amount=300"));
393 assert!(state_str.contains("[Reference Time: 2026-09-22T10:00:00Z]"));
394
395 let q1 = req.questions.get("q1").unwrap();
397 assert!(q1.instructions.contains("3日前 (2026-09-19)"));
398 assert!(q1.instructions.contains("[ OP ]"));
399
400 let criteria_map = q1.criteria.as_ref().unwrap().as_map().unwrap();
401 assert_eq!(criteria_map.get("yes").unwrap(), "はい [ OP ]");
402 }
403
404 #[test]
405 fn test_pipeline_score_and_noul_guardrails() {
406 let pipeline = GuardrailPipeline::default();
407
408 let mut questions = IndexMap::new();
409 questions.insert(
411 "q_score".to_string(),
412 Question::new_score(
413 "インシデントの緊急度を判定せよ。".to_string(),
414 vec![
415 "1: 軽微".to_string(),
416 "2. 中度".to_string(),
417 "(3) 重大".to_string(),
418 "第4段階: 致命的".to_string(),
419 ],
420 ),
421 );
422 questions.insert(
424 "q_noul".to_string(),
425 Question::new_noul("初期不良か?".to_string()),
426 );
427
428 let orig_questions = questions.clone();
429 let mut req = SystemOneRequest::new("システムログ本文".to_string(), questions);
430
431 let res = pipeline.process(&mut req, None);
433 assert!(res.is_ok());
434
435 let q_score = req.questions.get("q_score").unwrap();
437 match &q_score.criteria {
438 Some(Criteria::List(list)) => {
439 assert_eq!(list, &["軽微", "中度", "重大", "致命的"]);
440 }
441 _ => panic!("Criteria::List が必要です。"),
442 }
443
444 let q_noul = req.questions.get("q_noul").unwrap();
446 assert_eq!(
447 q_noul.instructions,
448 "前提テキストの情報のみに基づいて、言明「初期不良」が真実であるか評価せよ。"
449 );
450
451 let mut answers = IndexMap::new();
453 let mut score_probs = IndexMap::new();
454 score_probs.insert("軽微".to_string(), 0.1);
455 score_probs.insert("中度".to_string(), 0.7);
456 score_probs.insert("重大".to_string(), 0.15);
457 score_probs.insert("致命的".to_string(), 0.05);
458
459 answers.insert(
460 "q_score".to_string(),
461 Answer::score(1.15, score_probs, 0.85),
462 );
463
464 pipeline.post_process(&mut answers, &orig_questions);
466
467 let ans_score = answers.get("q_score").unwrap();
469 let probs = ans_score.probabilities.as_ref().unwrap();
470 assert_eq!(probs.get("1: 軽微"), Some(&0.1));
471 assert_eq!(probs.get("2. 中度"), Some(&0.7));
472 assert_eq!(probs.get("(3) 重大"), Some(&0.15));
473 assert_eq!(probs.get("第4段階: 致命的"), Some(&0.05));
474 }
475}