pub mod detect;
pub mod error;
pub mod ingest;
pub mod redact;
pub mod types;
pub use error::EngineError;
pub use types::{
DetectionSource, DocumentFormat, Entity, ExtractedDocument, Input, OutputFormat,
RedactionResult,
};
use detect::TierA;
use ingest::{DefaultIngestor, Ingestor};
pub struct Engine {
ingestor: Box<dyn Ingestor>,
tier_a: TierA,
liteparse_config: liteparse::config::LiteParseConfig,
}
impl Default for Engine {
fn default() -> Self {
Self::with_liteparse_config(liteparse::config::LiteParseConfig::default())
}
}
impl Engine {
pub fn new(ingestor: Box<dyn Ingestor>) -> Self {
Self {
ingestor,
tier_a: TierA::default(),
liteparse_config: liteparse::config::LiteParseConfig::default(),
}
}
pub fn with_liteparse_config(config: liteparse::config::LiteParseConfig) -> Self {
Self {
ingestor: Box::new(DefaultIngestor::with_liteparse_config(config.clone())),
tier_a: TierA::default(),
liteparse_config: config,
}
}
pub async fn process(
&self,
input: Input,
format: OutputFormat,
entities: Option<&[String]>,
score_threshold: Option<f32>,
) -> Result<RedactionResult, EngineError> {
if format == OutputFormat::Native && matches!(input, Input::Document { .. }) {
return Err(EngineError::Unsupported(
"OutputFormat::Native has no meaning for a Document input — anydoc only converts \
to markdown, never back to DOCX/XLSX/etc.; request OutputFormat::Markdown instead"
.into(),
));
}
let needs_boxes =
format == OutputFormat::Native && matches!(input, Input::Pdf(_) | Input::Image(_));
let doc = self.ingestor.ingest(&input, needs_boxes).await?;
let entities = self.tier_a.analyze(&doc, entities, score_threshold);
if format == OutputFormat::Markdown {
return Ok(redact::redact_text(&doc, &entities, format));
}
match input {
Input::Text(_) | Input::Document { .. } => {
Ok(redact::redact_text(&doc, &entities, format))
}
Input::Image(bytes) => {
let redacted_bytes = redact::redact_image_bytes(&bytes, &entities)?;
Ok(RedactionResult {
format,
bytes: Some(redacted_bytes),
entities,
..Default::default()
})
}
#[cfg_attr(target_arch = "wasm32", allow(unused_variables))]
Input::Pdf(bytes) => {
#[cfg(target_arch = "wasm32")]
{
let _ = &self.liteparse_config; Err(EngineError::Unsupported(
"pixel-level PDF redaction is unavailable in a wasm32 build — request \
OutputFormat::Markdown instead, or use a native/Node/Python build for \
OutputFormat::Native"
.into(),
))
}
#[cfg(not(target_arch = "wasm32"))]
{
let redacted_bytes =
redact::redact_pdf_bytes(&bytes, &entities, &self.liteparse_config).await?;
Ok(RedactionResult {
format,
bytes: Some(redacted_bytes),
entities,
..Default::default()
})
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn redacts_fr_nir_in_plain_text() {
let engine = Engine::default();
let result = engine
.process(
Input::Text("mon NIR est 291059933807692, merci.".to_string()),
OutputFormat::Native,
None,
None,
)
.await
.expect("text pipeline never hits the unimplemented pixel path");
assert_eq!(result.entities.len(), 1);
assert_eq!(result.entities[0].entity_type, "FR_NIR");
assert!(!result.text.unwrap().contains("291059933807692"));
}
#[tokio::test]
async fn entities_filter_redacts_only_the_requested_type() {
let engine = Engine::default();
let text = "email: john@example.com, nir: 291059933807692".to_string();
let filtered = engine
.process(
Input::Text(text.clone()),
OutputFormat::Native,
Some(&["FR_NIR".to_string()]),
None,
)
.await
.unwrap();
assert_eq!(filtered.entities.len(), 1);
assert_eq!(filtered.entities[0].entity_type, "FR_NIR");
let redacted = filtered.text.unwrap();
assert!(redacted.contains("john@example.com")); assert!(!redacted.contains("291059933807692"));
let unfiltered = engine
.process(Input::Text(text), OutputFormat::Native, None, None)
.await
.unwrap();
assert_eq!(unfiltered.entities.len(), 2); }
#[tokio::test]
async fn score_threshold_drops_low_confidence_matches() {
let engine = Engine::default();
let text = "email: john@example.com, nir: 291059933807692".to_string();
let result = engine
.process(Input::Text(text), OutputFormat::Native, None, Some(0.95))
.await
.unwrap();
assert_eq!(result.entities.len(), 1);
assert_eq!(result.entities[0].entity_type, "FR_NIR");
assert!(result.text.unwrap().contains("john@example.com")); }
}