reddb_server/runtime/ai/
moderation.rs1use std::sync::{Arc, OnceLock, RwLock};
20
21use crate::{RedDBError, RedDBResult};
22
23pub use reddb_engine::storage::visibility::{
24 entity_moderation_hidden, MODERATION_STATUS_FIELD, MODERATION_STATUS_PENDING,
25 MODERATION_STATUS_REJECTED,
26};
27
28#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum ModerationOutcome {
31 Allow,
33 Reject { categories: Vec<String> },
36 ProviderDown { reason: String },
39}
40
41impl ModerationOutcome {
42 pub fn is_reject(&self) -> bool {
44 matches!(self, Self::Reject { .. })
45 }
46
47 pub fn is_provider_down(&self) -> bool {
49 matches!(self, Self::ProviderDown { .. })
50 }
51}
52
53#[derive(Debug, Clone)]
57pub struct ModerationRequest {
58 pub model: String,
60 pub text: String,
62}
63
64pub trait LocalModerationBackend: Send + Sync {
68 fn moderate(&self, request: &ModerationRequest) -> RedDBResult<ModerationOutcome>;
69}
70
71const LOCAL_MODERATION_DISABLED_MESSAGE: &str =
72 "local moderation requires a backend installed via \
73 runtime::ai::moderation::install_local_moderation_backend. Alternatively, \
74 declare a moderation-capable remote provider in the collection's MODERATE \
75 policy.";
76
77type BackendSlot = Arc<dyn LocalModerationBackend>;
78
79fn backend_slot() -> &'static RwLock<Option<BackendSlot>> {
80 static SLOT: OnceLock<RwLock<Option<BackendSlot>>> = OnceLock::new();
81 SLOT.get_or_init(|| RwLock::new(None))
82}
83
84pub fn install_local_moderation_backend(backend: Arc<dyn LocalModerationBackend>) {
90 let mut guard = backend_slot()
91 .write()
92 .expect("moderation backend slot poisoned");
93 *guard = Some(backend);
94}
95
96#[doc(hidden)]
99pub fn clear_local_moderation_backend_for_tests() {
100 let mut guard = backend_slot()
101 .write()
102 .expect("moderation backend slot poisoned");
103 *guard = None;
104}
105
106fn current_backend() -> Option<BackendSlot> {
107 backend_slot()
108 .read()
109 .expect("moderation backend slot poisoned")
110 .as_ref()
111 .map(Arc::clone)
112}
113
114pub fn moderate_local(model: &str, text: String) -> RedDBResult<ModerationOutcome> {
119 let backend = current_backend().ok_or_else(|| {
120 RedDBError::FeatureNotEnabled(LOCAL_MODERATION_DISABLED_MESSAGE.to_string())
121 })?;
122 backend.moderate(&ModerationRequest {
123 model: model.to_string(),
124 text,
125 })
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131
132 struct AllowAll;
133 impl LocalModerationBackend for AllowAll {
134 fn moderate(&self, _request: &ModerationRequest) -> RedDBResult<ModerationOutcome> {
135 Ok(ModerationOutcome::Allow)
136 }
137 }
138
139 #[test]
140 fn missing_backend_is_feature_disabled_error() {
141 clear_local_moderation_backend_for_tests();
142 let err = moderate_local("m", "hello".to_string()).expect_err("no backend");
143 assert!(matches!(err, RedDBError::FeatureNotEnabled(_)));
144 }
145
146 #[test]
147 fn installed_backend_drives_outcome() {
148 install_local_moderation_backend(Arc::new(AllowAll));
149 let outcome = moderate_local("m", "hello".to_string()).expect("allowed");
150 assert_eq!(outcome, ModerationOutcome::Allow);
151 clear_local_moderation_backend_for_tests();
152 }
153
154 #[test]
155 fn outcome_predicates() {
156 assert!(ModerationOutcome::Reject {
157 categories: vec!["hate".to_string()],
158 }
159 .is_reject());
160 assert!(ModerationOutcome::ProviderDown {
161 reason: "timeout".to_string(),
162 }
163 .is_provider_down());
164 assert!(!ModerationOutcome::Allow.is_reject());
165 }
166}