1use std::collections::BTreeMap;
7use std::future::Future;
8use std::io::{self, Write};
9use std::sync::Arc;
10
11use serde::{Deserialize, Serialize};
12use serde_json::{Map, Value, json};
13use switchyard_protocol::{
14 Category, ChoiceOption, DecisionKind, DecisionQuestion, DecisionRequest, DecisionResponse,
15 DecisionValue, FormatId, InstructionBlock, LlmRequest, LlmResponse, Message, ModelId,
16 OutputParams, PreservationMetadata, ProviderExtensions, ReasoningParams, Request, Role,
17 ToolChoice, ToolDefinition,
18};
19
20use crate::algorithms::util::classifier_contract::{ClassifierContract, ClassifierContractConfig};
21use crate::algorithms::util::llm_judge::{JudgeRuntimeConfig, SerdeDecoder, StructuredJudge};
22use crate::{Call, ClassifierResponseFormat, Driver, Result, RuntimeModels, Step};
23
24const MAX_CONTEXT_BYTES: usize = 64 * 1024;
25const QUESTION_ID: &str = "privacy";
26const PROBABILITY_SUM_TOLERANCE: f64 = 1e-6;
27const DEFAULT_PROMPT: &str = include_str!("prompts/privacy-classifier/prompt.md");
28
29pub struct PrivacyAssessment {
31 reason_code: &'static str,
32 clear_score: Option<f64>,
33 has_sensitive_content: bool,
34}
35
36impl PrivacyAssessment {
37 pub const fn reason_code(&self) -> &'static str {
39 self.reason_code
40 }
41
42 pub const fn clear_score(&self) -> Option<f64> {
44 self.clear_score
45 }
46
47 pub const fn has_sensitive_content(&self) -> bool {
49 self.has_sensitive_content
50 }
51
52 fn failure(reason_code: &'static str) -> Self {
53 Self {
54 reason_code,
55 clear_score: None,
56 has_sensitive_content: false,
57 }
58 }
59}
60
61pub struct PrivacyPreflight {
65 target: ModelId,
66 models: Arc<RuntimeModels>,
67 backend: Backend,
68}
69
70enum Backend {
71 Decision(DecisionQuestion),
72 Llm(StructuredJudge<(), SerdeDecoder<LlmVerdict>>),
73}
74
75impl PrivacyPreflight {
76 pub fn decision(target: ModelId, instructions: Option<&Value>) -> Self {
78 let question = DecisionQuestion {
79 instructions: instructions
80 .cloned()
81 .unwrap_or_else(|| Value::String(DEFAULT_PROMPT.trim().into())),
82 kind: DecisionKind::Choice {
83 options: PrivacyVerdict::ALL
84 .into_iter()
85 .map(|verdict| ChoiceOption {
86 id: verdict.as_str().into(),
87 description: Some(Value::String(verdict.description().into())),
88 })
89 .collect(),
90 },
91 };
92 Self::new(target, Backend::Decision(question))
93 }
94
95 pub fn llm(
98 target: ModelId,
99 prompt: Option<&str>,
100 format: ClassifierResponseFormat,
101 ) -> Result<Self> {
102 if prompt.is_some_and(|prompt| prompt.trim().is_empty()) {
103 return Err(crate::LibsyError::AlgorithmError {
104 message: "privacy classifier prompt must not be empty".into(),
105 });
106 }
107 let prompt = prompt.map(str::to_owned).unwrap_or_else(|| {
108 format!(
109 "{}\n\n{}",
110 DEFAULT_PROMPT.trim(),
111 include_str!("prompts/privacy-classifier/llm.md").trim()
112 )
113 });
114 let schema = verdict_schema();
115 let prompt = match format {
117 ClassifierResponseFormat::JsonSchema => format!(
118 "{prompt}\n\nReturn exactly one JSON object matching this JSON Schema:\n{schema:#}"
119 ),
120 ClassifierResponseFormat::JsonObject => prompt,
121 };
122 let contract = ClassifierContract::from_config(
123 &ClassifierContractConfig::default()
124 .with_prompt(prompt)
125 .with_response_format_type(format),
126 DEFAULT_PROMPT,
127 &json!({
128 "type": "json_schema",
129 "json_schema": {
130 "name": "switchyard_privacy_verdict",
131 "strict": true,
132 "schema": schema,
133 }
134 })
135 .to_string(),
136 )?;
137 let judge = StructuredJudge::new(
138 (),
139 contract,
140 SerdeDecoder::new(),
141 JudgeRuntimeConfig::new(512)?,
142 );
143 Ok(Self::new(target, Backend::Llm(judge)))
144 }
145
146 fn new(target: ModelId, backend: Backend) -> Self {
147 let models = RuntimeModels::new([(Category::Judge, vec![target.clone()])].into())
148 .with_target_restriction();
149 Self {
150 target,
151 models: Arc::new(models),
152 backend,
153 }
154 }
155
156 pub async fn assess<F, Fut>(&self, request: &Request, serve: F) -> PrivacyAssessment
160 where
161 F: FnOnce(Call) -> Fut + Send,
162 Fut: Future<Output = Result<()>> + Send,
163 {
164 let context = match classifier_context(&request.llm_request) {
165 Ok(context) => context,
166 Err(()) => return PrivacyAssessment::failure("input_unavailable"),
167 };
168 let (driver, mut steps) = Driver::new("privacy_classifier", Arc::clone(&self.models));
169 let handled = async {
171 let call = match steps.recv().await {
172 Some(Ok(Step::CallModel(call))) => Call::Model(call),
173 Some(Ok(Step::CallDecision(call))) => Call::Decision(call),
174 _ => return Err("classifier_failed"),
175 };
176 serve(call).await.map_err(|_| "classifier_failed")
177 };
178 tokio::try_join!(self.verdict(context, &driver), handled)
179 .map(|(assessment, ())| assessment)
180 .unwrap_or_else(PrivacyAssessment::failure)
181 }
182
183 async fn verdict(
184 &self,
185 context: BoundedJson,
186 driver: &Driver,
187 ) -> std::result::Result<PrivacyAssessment, &'static str> {
188 let (selected, clear_score) = match &self.backend {
189 Backend::Decision(question) => {
190 let request = DecisionRequest {
191 model: None,
192 context: context.into_value().map_err(|()| "input_unavailable")?,
193 questions: BTreeMap::from([(QUESTION_ID.into(), question.clone())]),
194 };
195 let response = driver
196 .call_decision(request, self.target.clone())
197 .await
198 .map_err(|_| "classifier_failed")?;
199 decision_verdict(&response).ok_or("invalid_verdict")?
200 }
201 Backend::Llm(judge) => {
202 let mut request = judge.build_request_with_messages(vec![Message::text(
203 Role::User,
204 context.into_string().map_err(|()| "input_unavailable")?,
205 )]);
206 request
207 .llm_request
208 .extensions
209 .fields
210 .insert("store".into(), Value::Bool(false));
211 let response = driver
212 .call_model_with_error_recovery(request, vec![self.target.clone()], true)
213 .await
214 .map_err(|_| "classifier_failed")?;
215 let LlmResponse::Agg(response) = response.llm_response else {
216 return Err("invalid_verdict");
217 };
218 let verdict = judge.decode(&response).map_err(|_| "invalid_verdict")?;
219 (verdict.reason_code, verdict.clear_score)
220 }
221 };
222 if !clear_score.is_finite() || !(0.0..=1.0).contains(&clear_score) {
223 return Err("invalid_verdict");
224 }
225 Ok(PrivacyAssessment {
226 reason_code: selected.as_str(),
227 clear_score: Some(clear_score),
228 has_sensitive_content: matches!(
229 selected,
230 PrivacyVerdict::PersonalData
231 | PrivacyVerdict::Credentials
232 | PrivacyVerdict::ConfidentialData
233 | PrivacyVerdict::RegulatedData
234 ),
235 })
236 }
237}
238
239#[derive(Clone, Copy, Deserialize)]
240#[serde(rename_all = "snake_case")]
241enum PrivacyVerdict {
242 NoSensitiveContent,
243 PersonalData,
244 Credentials,
245 ConfidentialData,
246 RegulatedData,
247 Uncertain,
248}
249
250impl PrivacyVerdict {
251 const ALL: [Self; 6] = [
252 Self::NoSensitiveContent,
253 Self::PersonalData,
254 Self::Credentials,
255 Self::ConfidentialData,
256 Self::RegulatedData,
257 Self::Uncertain,
258 ];
259
260 const fn as_str(self) -> &'static str {
261 match self {
262 Self::NoSensitiveContent => "no_sensitive_content",
263 Self::PersonalData => "personal_data",
264 Self::Credentials => "credentials",
265 Self::ConfidentialData => "confidential_data",
266 Self::RegulatedData => "regulated_data",
267 Self::Uncertain => "uncertain",
268 }
269 }
270
271 const fn description(self) -> &'static str {
272 match self {
273 Self::NoSensitiveContent => "No personal, secret, confidential, or regulated content.",
274 Self::PersonalData => "Personal or identifying data is present.",
275 Self::Credentials => "Credentials, secrets, or authentication material are present.",
276 Self::ConfidentialData => "Confidential organizational data is present.",
277 Self::RegulatedData => "Regulated data is present.",
278 Self::Uncertain => "The privacy boundary cannot be determined confidently.",
279 }
280 }
281}
282
283#[derive(Deserialize)]
284#[serde(deny_unknown_fields)]
285struct LlmVerdict {
286 clear_score: f64,
287 reason_code: PrivacyVerdict,
288}
289
290fn verdict_schema() -> Value {
291 json!({
292 "type": "object", "additionalProperties": false,
293 "properties": {
294 "clear_score": {"type": "number", "minimum": 0, "maximum": 1},
295 "reason_code": {"type": "string", "enum": PrivacyVerdict::ALL.map(PrivacyVerdict::as_str)}
296 },
297 "required": ["clear_score", "reason_code"]
298 })
299}
300
301fn decision_verdict(response: &DecisionResponse) -> Option<(PrivacyVerdict, f64)> {
302 let DecisionValue::Choice {
303 selected,
304 probabilities: Some(probabilities),
305 } = &response.answers.get(QUESTION_ID)?.value
306 else {
307 return None;
308 };
309 let selected = PrivacyVerdict::deserialize(serde::de::value::StrDeserializer::<
310 serde::de::value::Error,
311 >::new(selected))
312 .ok()?;
313 let valid_distribution = probabilities.len() == PrivacyVerdict::ALL.len()
314 && PrivacyVerdict::ALL.iter().all(|verdict| {
315 probabilities
316 .get(verdict.as_str())
317 .is_some_and(|p| p.0.is_finite() && (0.0..=1.0).contains(&p.0))
318 })
319 && (probabilities.values().map(|p| p.0).sum::<f64>() - 1.0).abs()
320 <= PROBABILITY_SUM_TOLERANCE;
321 if !valid_distribution {
322 return None;
323 }
324 Some((selected, probabilities.get("no_sensitive_content")?.0))
325}
326
327#[derive(Serialize)]
329struct PrivacyContext<'a> {
330 instructions: &'a [InstructionBlock],
331 messages: &'a [Message],
332 tools: &'a [ToolDefinition],
333 tool_choice: Option<&'a ToolChoice>,
334 response_format: Option<&'a Value>,
335 reasoning_effort: Option<&'a str>,
336 reasoning_raw: Option<&'a Value>,
337 extensions: &'a Map<String, Value>,
338 preserved_requests: &'a BTreeMap<FormatId, Value>,
339}
340
341fn classifier_context(request: &LlmRequest) -> std::result::Result<BoundedJson, ()> {
342 let LlmRequest {
344 model: _,
345 instructions,
346 messages,
347 tools,
348 tool_choice,
349 sampling: _,
350 output,
351 reasoning,
352 stream: _,
353 extensions,
354 preservation,
355 } = request;
356 let OutputParams {
357 max_output_tokens: _,
358 response_format,
359 is_schema_enforced: _,
360 } = output;
361 let ReasoningParams { effort, raw } = reasoning;
362 let ProviderExtensions { fields: extensions } = extensions;
363 let PreservationMetadata {
364 requests: preserved_requests,
365 responses: _,
366 } = preservation;
367 let context = PrivacyContext {
368 instructions,
369 messages,
370 tools,
371 tool_choice: tool_choice.as_ref(),
372 response_format: response_format.as_ref(),
373 reasoning_effort: effort.as_deref(),
374 reasoning_raw: raw.as_ref(),
375 extensions,
376 preserved_requests,
377 };
378 let mut json = BoundedJson::default();
379 serde_json::to_writer(&mut json, &context).map_err(|_| ())?;
380 Ok(json)
381}
382
383#[derive(Default)]
384struct BoundedJson(Vec<u8>);
385
386impl BoundedJson {
387 fn into_value(self) -> std::result::Result<Value, ()> {
388 serde_json::from_slice(&self.0).map_err(|_| ())
389 }
390
391 fn into_string(self) -> std::result::Result<String, ()> {
392 String::from_utf8(self.0).map_err(|_| ())
393 }
394}
395
396impl Write for BoundedJson {
397 fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
398 if bytes.len() > MAX_CONTEXT_BYTES.saturating_sub(self.0.len()) {
399 return Err(io::Error::other("privacy classifier context exceeds limit"));
400 }
401 self.0.extend_from_slice(bytes);
402 Ok(bytes.len())
403 }
404
405 fn flush(&mut self) -> io::Result<()> {
406 Ok(())
407 }
408}
409
410#[cfg(test)]
411mod tests {
412 use std::sync::atomic::{AtomicBool, Ordering};
413
414 use switchyard_protocol::{
415 DecisionAnswer, LlmClientError, Metadata, Probability, Response, Usage, text_response,
416 };
417 use tokio::sync::Notify;
418
419 use super::*;
420 use crate::LibsyError;
421
422 fn preflights() -> [PrivacyPreflight; 3] {
423 [
424 PrivacyPreflight::decision("private/classifier".into(), None),
425 PrivacyPreflight::llm(
426 "private/classifier".into(),
427 None,
428 ClassifierResponseFormat::JsonSchema,
429 )
430 .expect("schema judge"),
431 PrivacyPreflight::llm(
432 "private/classifier".into(),
433 None,
434 ClassifierResponseFormat::JsonObject,
435 )
436 .expect("object judge"),
437 ]
438 }
439
440 fn reply(call: Call, reason: &str, score: f64) -> Result<()> {
441 let failed = || {
442 LibsyError::client_call(
443 "private/classifier",
444 LlmClientError::Configuration {
445 message: "private provider diagnostic".into(),
446 },
447 )
448 };
449 match call {
450 Call::Model(call) => {
451 assert!(call.recover_errors);
452 if reason == "failed" {
453 return call.respond(Err(failed()));
454 }
455 let content = if reason == "malformed" {
456 "not JSON".into()
457 } else {
458 json!({"reason_code": reason, "clear_score": score}).to_string()
459 };
460 call.respond(Ok(Response {
461 llm_response: LlmResponse::Agg(text_response(None, content)),
462 metadata: None,
463 upstream_headers: Default::default(),
464 }))
465 }
466 Call::Decision(call) => {
467 if reason == "failed" {
468 return call.respond(Err(failed()));
469 }
470 call.respond(Ok(decision_response(reason, score)))
471 }
472 }
473 }
474
475 fn decision_response(reason: &str, score: f64) -> DecisionResponse {
476 let remaining = if reason == "no_sensitive_content" {
477 "uncertain"
478 } else {
479 reason
480 };
481 let probabilities = PrivacyVerdict::ALL
482 .map(|verdict| {
483 let probability = match verdict.as_str() {
484 "no_sensitive_content" => score,
485 value if value == remaining => 1.0 - score,
486 _ => 0.0,
487 };
488 (verdict.as_str().into(), Probability(probability))
489 })
490 .into();
491 DecisionResponse {
492 id: None,
493 model: None,
494 answers: BTreeMap::from([(
495 QUESTION_ID.into(),
496 DecisionAnswer {
497 value: DecisionValue::Choice {
498 selected: reason.into(),
499 probabilities: Some(probabilities),
500 },
501 provider_confidence: None,
502 },
503 )]),
504 usage: Usage::default(),
505 }
506 }
507
508 #[tokio::test]
510 async fn returns_only_valid_evidence_across_backends() {
511 for preflight in preflights() {
512 for (reason, score, expected, sensitive) in [
513 ("no_sensitive_content", 0.95, "no_sensitive_content", false),
514 ("personal_data", 0.1, "personal_data", true),
515 ("uncertain", 0.5, "uncertain", false),
516 ("malformed", 0.0, "invalid_verdict", false),
517 ("no_sensitive_content", 1.1, "invalid_verdict", false),
518 ("failed", 0.0, "classifier_failed", false),
519 ] {
520 let assessment = preflight
521 .assess(&Request::default(), |call| async move {
522 reply(call, reason, score)
523 })
524 .await;
525 assert_eq!(assessment.reason_code(), expected);
526 assert_eq!(assessment.has_sensitive_content(), sensitive);
527 assert_eq!(
528 assessment.clear_score(),
529 (!matches!(expected, "invalid_verdict" | "classifier_failed")).then_some(score)
530 );
531 }
532 let assessment = preflight
533 .assess(&Request::default(), |call| async move {
534 reply(call, "no_sensitive_content", 0.95)?;
535 Err(LibsyError::NoTargets)
536 })
537 .await;
538 assert_eq!(assessment.reason_code(), "classifier_failed");
539 }
540 for invalid in ["missing", "incomplete", "unnormalized"] {
541 let mut response = decision_response("no_sensitive_content", 0.95);
542 let DecisionValue::Choice { probabilities, .. } = &mut response
543 .answers
544 .get_mut(QUESTION_ID)
545 .expect("privacy answer")
546 .value
547 else {
548 panic!("expected choice");
549 };
550 match invalid {
551 "missing" => *probabilities = None,
552 "incomplete" => {
553 probabilities
554 .as_mut()
555 .expect("distribution")
556 .remove("uncertain");
557 }
558 _ => {
559 probabilities
560 .as_mut()
561 .expect("distribution")
562 .insert("personal_data".into(), Probability(0.2));
563 }
564 }
565 let assessment = PrivacyPreflight::decision("private/classifier".into(), None)
566 .assess(&Request::default(), |call| async move {
567 let Call::Decision(call) = call else {
568 panic!("expected decision call");
569 };
570 call.respond(Ok(response))
571 })
572 .await;
573 assert_eq!(assessment.reason_code(), "invalid_verdict");
574 assert_eq!(assessment.clear_score(), None);
575 assert!(!assessment.has_sensitive_content());
576 }
577 let preflight = PrivacyPreflight::llm(
578 "private/classifier".into(),
579 None,
580 ClassifierResponseFormat::JsonSchema,
581 )
582 .expect("judge");
583 let assessment = preflight
584 .assess(&Request::default(), |call| async move {
585 let Call::Model(call) = call else {
586 panic!("expected LLM call");
587 };
588 call.respond(Ok(Response {
589 llm_response: LlmResponse::Stream(Box::pin(futures::stream::empty())),
590 metadata: None,
591 upstream_headers: Default::default(),
592 }))
593 })
594 .await;
595 assert_eq!(assessment.reason_code(), "invalid_verdict");
596 }
597
598 #[tokio::test]
600 async fn bounds_full_context_and_preserves_judge_contracts() {
601 let mut request = Request {
602 llm_request: LlmRequest {
603 model: Some("public/answer".into()),
604 instructions: vec![InstructionBlock {
605 role: Role::System,
606 content: Message::text(Role::System, "system content").content,
607 }],
608 messages: vec![Message::text(Role::User, "user content")],
609 tools: vec![ToolDefinition {
610 name: "private_tool".into(),
611 description: Some("confidential tool content".into()),
612 parameters: json!({"type": "object"}),
613 strict: Some(true),
614 }],
615 ..Default::default()
616 },
617 raw_request: Some(json!({"host_only": "original body"})),
618 metadata: Some(Metadata {
619 task_id: Some("host-only-task".into()),
620 ..Default::default()
621 }),
622 };
623 request
624 .llm_request
625 .extensions
626 .fields
627 .insert("provider_content".into(), json!("private"));
628 request
629 .llm_request
630 .preservation
631 .requests
632 .insert("custom".into(), json!({"private": "preserved"}));
633 for preflight in preflights() {
634 let assessment = preflight
635 .assess(&request, |call| async move {
636 let context = match &call {
637 Call::Decision(call) => {
638 assert_eq!(call.model.as_str(), "private/classifier");
639 assert_eq!(call.request.questions.len(), 1);
640 call.request.context.clone()
641 }
642 Call::Model(call) => {
643 assert!(call.request.raw_request.is_none());
644 assert!(call.request.metadata.is_none());
645 assert_eq!(call.request.llm_request.output.max_output_tokens, Some(512));
646 assert_eq!(call.request.llm_request.extensions.fields["store"], false);
647 assert_eq!(call.request.model_id(), Some("private/classifier".into()));
648 let prompt = Message {
649 role: Role::System,
650 content: call.request.llm_request.instructions[0].content.clone(),
651 }.text_content("").expect("prompt");
652 let expected = format!(
653 "{}\n\n{}\n\nReturn exactly one JSON object matching this JSON Schema:\n{:#}",
654 DEFAULT_PROMPT.trim(),
655 include_str!("prompts/privacy-classifier/llm.md").trim(),
656 verdict_schema(),
657 );
658 assert_eq!(prompt, expected);
659 let format = call.request.llm_request.output.response_format.as_ref().expect("format");
660 if format["type"] == "json_schema" {
661 assert_eq!(format["json_schema"]["name"], "switchyard_privacy_verdict");
662 assert_eq!(format["json_schema"]["strict"], true);
663 } else {
664 assert_eq!(format, &json!({"type": "json_object"}));
665 }
666 serde_json::from_str(&call.request.llm_request.messages[0].text_content("").expect("context")).expect("JSON")
667 }
668 };
669 assert_eq!(context["tools"][0]["name"], "private_tool");
670 assert_eq!(context["extensions"]["provider_content"], "private");
671 assert_eq!(context["preserved_requests"]["custom"]["private"], "preserved");
672 let serialized = context.to_string();
673 assert!(serialized.contains("system content"));
674 assert!(serialized.contains("user content"));
675 assert!(!serialized.contains("host-only-task"));
676 assert!(!serialized.contains("host_only"));
677 reply(call, "no_sensitive_content", 0.95)
678 })
679 .await;
680 assert_eq!(assessment.reason_code(), "no_sensitive_content");
681 }
682 request.llm_request.messages =
683 vec![Message::text(Role::User, "x".repeat(MAX_CONTEXT_BYTES))];
684 let assessment = PrivacyPreflight::decision("private/classifier".into(), None)
685 .assess(&request, |_| async {
686 panic!("oversized input must not leave the host")
687 })
688 .await;
689 assert_eq!(assessment.reason_code(), "input_unavailable");
690 assert!(assessment.clear_score().is_none());
691 }
692
693 #[tokio::test]
695 async fn cancellation_drops_pending_host_work() {
696 struct OnDrop(Arc<AtomicBool>);
697 impl Drop for OnDrop {
698 fn drop(&mut self) {
699 self.0.store(true, Ordering::SeqCst);
700 }
701 }
702 let dropped = Arc::new(AtomicBool::new(false));
703 let started = Arc::new(Notify::new());
704 let host_started = Arc::clone(&started);
705 let host_dropped = Arc::clone(&dropped);
706 let task = tokio::spawn(async move {
707 PrivacyPreflight::decision("private/classifier".into(), None)
708 .assess(&Request::default(), |call| async move {
709 let _call = call;
710 let _guard = OnDrop(host_dropped);
711 host_started.notify_one();
712 futures::future::pending().await
713 })
714 .await
715 });
716 started.notified().await;
717 task.abort();
718 let Err(error) = task.await else {
719 panic!("assessment was not cancelled");
720 };
721 assert!(error.is_cancelled());
722 assert!(dropped.load(Ordering::SeqCst));
723 }
724}