use crate::heuristics::confidence::ConfidenceSignals;
use crate::plugins::{Plugin, PostProcessor, ProcessingStage};
use crate::{ExtractedDocument, ExtractionConfig, Result};
use async_trait::async_trait;
#[cfg(test)]
use std::borrow::Cow;
#[cfg_attr(alef, alef(skip))]
#[derive(Debug, Clone, Copy)]
pub struct QualityProcessor;
impl Plugin for QualityProcessor {
fn name(&self) -> &str {
"quality-processing"
}
fn version(&self) -> String {
env!("CARGO_PKG_VERSION").to_string()
}
fn initialize(&self) -> Result<()> {
Ok(())
}
fn shutdown(&self) -> Result<()> {
Ok(())
}
}
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
impl PostProcessor for QualityProcessor {
async fn process(&self, result: &mut ExtractedDocument, _config: &ExtractionConfig) -> Result<()> {
let mut quality_score = if should_use_metadata(&result.metadata) {
crate::text::quality::calculate_quality_score(&result.content, Some(&result.metadata.additional))
} else {
crate::text::quality::calculate_quality_score(&result.content, None)
};
if let Some(ocr_confidence) = ocr_confidence_for_cap(result) {
quality_score = quality_score.min(ocr_confidence);
}
result.quality_score = Some(quality_score);
Ok(())
}
fn processing_stage(&self) -> ProcessingStage {
ProcessingStage::Early
}
fn should_process(&self, _result: &ExtractedDocument, config: &ExtractionConfig) -> bool {
config.enable_quality_processing
}
fn estimated_duration_ms(&self, result: &ExtractedDocument) -> u64 {
let text_length = result.content.len();
(text_length / 102400).max(1) as u64
}
fn priority(&self) -> i32 {
30
}
}
fn should_use_metadata(metadata: &crate::types::Metadata) -> bool {
const IMPORTANT_FIELDS: &[&str] = &["title", "author", "subject", "description", "keywords"];
IMPORTANT_FIELDS
.iter()
.any(|field| metadata.additional.contains_key(*field))
}
const MIN_OCR_WORDS_FOR_CONFIDENCE_FLOOR: u64 = 20;
fn ocr_confidence_for_cap(result: &ExtractedDocument) -> Option<f64> {
let from_elements = result
.ocr_elements
.as_deref()
.and_then(ConfidenceSignals::ocr_confidence_from_elements);
let (mean, total_words) = from_elements.or_else(|| {
result
.pages
.as_deref()
.and_then(ConfidenceSignals::ocr_confidence_from_pages)
})?;
(total_words >= MIN_OCR_WORDS_FOR_CONFIDENCE_FLOOR).then_some(mean)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::heuristics::confidence::SchemaCompliance;
use crate::types::ocr_elements::{OcrConfidence, OcrElement};
use crate::types::{PageContent, PageOcrConfidence};
fn ocrd_page(page_number: u32, score: Option<f64>, word_count: u32) -> PageContent {
PageContent {
page_number,
content: String::new(),
tables: Vec::new(),
image_indices: Vec::new(),
image_preprocessing: None,
hierarchy: None,
is_blank: None,
layout_regions: None,
speaker_notes: None,
section_name: None,
sheet_name: None,
ocr_confidence: Some(PageOcrConfidence {
score,
word_count,
backend: "tesseract".to_string(),
}),
native_content: None,
}
}
fn ocrd_element(recognition: f64, word_count: usize) -> OcrElement {
OcrElement {
text: vec!["word"; word_count].join(" "),
confidence: OcrConfidence {
detection: None,
recognition,
},
..Default::default()
}
}
#[tokio::test]
async fn quality_score_is_capped_by_low_ocr_confidence_on_shape_clean_text() {
let processor = QualityProcessor;
let config = ExtractionConfig {
enable_quality_processing: true,
..Default::default()
};
let mut result = ExtractedDocument {
content: "The retained paragraph reads as clean, readable prose with complete \
sentences and conventional punctuation throughout the page."
.to_string(),
mime_type: Cow::Borrowed("application/pdf"),
pages: Some(vec![ocrd_page(1, Some(0.81), 248)]),
..Default::default()
};
processor.process(&mut result, &config).await.unwrap();
assert_eq!(result.quality_score, Some(0.81));
}
#[tokio::test]
async fn quality_score_ignores_a_tiny_low_confidence_fragment() {
let processor = QualityProcessor;
let config = ExtractionConfig {
enable_quality_processing: true,
..Default::default()
};
let mut result = ExtractedDocument {
content: "The retained paragraph reads as clean, readable prose with complete \
sentences and conventional punctuation throughout the page."
.to_string(),
mime_type: Cow::Borrowed("application/pdf"),
pages: Some(vec![ocrd_page(1, Some(0.1), 1)]),
..Default::default()
};
processor.process(&mut result, &config).await.unwrap();
assert_eq!(result.quality_score, Some(1.0));
}
#[tokio::test]
async fn quality_score_is_unaffected_by_an_uncalibrated_ocr_backend() {
let processor = QualityProcessor;
let config = ExtractionConfig {
enable_quality_processing: true,
..Default::default()
};
let mut result = ExtractedDocument {
content: "The retained paragraph reads as clean, readable prose with complete \
sentences and conventional punctuation throughout the page."
.to_string(),
mime_type: Cow::Borrowed("application/pdf"),
pages: Some(vec![ocrd_page(1, None, 500)]),
..Default::default()
};
processor.process(&mut result, &config).await.unwrap();
assert_eq!(result.quality_score, Some(1.0));
}
#[tokio::test]
async fn quality_score_is_capped_on_the_embedded_image_route() {
let processor = QualityProcessor;
let config = ExtractionConfig {
enable_quality_processing: true,
..Default::default()
};
let mut result = ExtractedDocument {
content: "The retained paragraph reads as clean, readable prose with complete \
sentences and conventional punctuation throughout the page."
.to_string(),
mime_type: Cow::Borrowed("application/pdf"),
ocr_elements: Some(vec![ocrd_element(0.4, 25)]),
pages: None,
..Default::default()
};
processor.process(&mut result, &config).await.unwrap();
assert_eq!(result.quality_score, Some(0.4));
}
#[tokio::test]
async fn words_repeated_by_their_lines_do_not_reach_the_evidence_floor() {
let processor = QualityProcessor;
let config = ExtractionConfig {
enable_quality_processing: true,
..Default::default()
};
let mut elements = vec![
OcrElement {
level: crate::types::OcrElementLevel::Line,
..ocrd_element(0.4, 5)
};
2
];
elements.extend(vec![
OcrElement {
level: crate::types::OcrElementLevel::Word,
..ocrd_element(0.4, 1)
};
10
]);
let mut result = ExtractedDocument {
content: "The retained paragraph reads as clean, readable prose with complete \
sentences and conventional punctuation throughout the page."
.to_string(),
mime_type: Cow::Borrowed("application/pdf"),
ocr_elements: Some(elements),
pages: None,
..Default::default()
};
processor.process(&mut result, &config).await.unwrap();
assert_eq!(result.quality_score, Some(1.0));
}
#[tokio::test]
async fn aggregate_reports_below_the_floor_while_the_score_stays_uncapped() {
let processor = QualityProcessor;
let config = ExtractionConfig {
enable_quality_processing: true,
..Default::default()
};
let content = "The retained paragraph reads as clean, readable prose with complete \
sentences and conventional punctuation throughout the page."
.to_string();
let mut below_floor = ExtractedDocument {
content: content.clone(),
mime_type: Cow::Borrowed("application/pdf"),
pages: Some(vec![ocrd_page(1, Some(0.5), 19)]),
..Default::default()
};
let signals = ConfidenceSignals::from_extraction_result(&below_floor, SchemaCompliance::AllValid, 1.0);
let ocr_aggregate = signals.ocr_aggregate.expect("aggregate reports below the cap's floor");
assert!(
(ocr_aggregate - 0.5).abs() < 0.001,
"expected ~0.5, got {ocr_aggregate}"
);
processor.process(&mut below_floor, &config).await.unwrap();
assert_eq!(below_floor.quality_score, Some(1.0));
let mut at_floor = ExtractedDocument {
content,
mime_type: Cow::Borrowed("application/pdf"),
pages: Some(vec![ocrd_page(1, Some(0.5), 20)]),
..Default::default()
};
processor.process(&mut at_floor, &config).await.unwrap();
assert_eq!(at_floor.quality_score, Some(0.5));
}
#[test]
fn ocr_confidence_for_cap_weights_by_word_count() {
let result = ExtractedDocument {
pages: Some(vec![ocrd_page(1, Some(0.9), 100), ocrd_page(2, Some(0.5), 300)]),
..Default::default()
};
let confidence = ocr_confidence_for_cap(&result).expect("must aggregate");
assert!((confidence - 0.6).abs() < 1e-9, "got {confidence}");
}
#[test]
fn ocr_confidence_for_cap_is_none_without_ocr_output() {
let result = ExtractedDocument::default();
assert_eq!(ocr_confidence_for_cap(&result), None);
}
#[tokio::test]
async fn test_quality_processor() {
let processor = QualityProcessor;
let config = ExtractionConfig {
enable_quality_processing: true,
..Default::default()
};
let mut result = ExtractedDocument {
content: "This is a well-written paragraph with proper structure. It contains multiple sentences. The quality should be good.".to_string(),
mime_type: Cow::Borrowed("text/plain"),
..Default::default()
};
processor.process(&mut result, &config).await.unwrap();
assert!(result.quality_score.is_some());
let score = result.quality_score.unwrap();
assert!((0.0..=1.0).contains(&score));
}
#[tokio::test]
async fn quality_score_does_not_hide_completeness_warning() {
let processor = QualityProcessor;
let config = ExtractionConfig {
enable_quality_processing: true,
..Default::default()
};
let warning = crate::types::ProcessingWarning {
source: Cow::Borrowed("ocr"),
message: Cow::Borrowed("Page 4 was rejected and its text was discarded."),
};
let mut result = ExtractedDocument {
content:
"The retained paragraph is clean, readable prose with complete sentences and conventional punctuation."
.to_string(),
mime_type: Cow::Borrowed("application/pdf"),
processing_warnings: vec![warning.clone()],
..Default::default()
};
processor.process(&mut result, &config).await.unwrap();
assert_eq!(result.quality_score, Some(1.0));
assert_eq!(result.processing_warnings.len(), 1);
assert_eq!(result.processing_warnings[0].source, warning.source);
assert_eq!(result.processing_warnings[0].message, warning.message);
}
#[tokio::test]
async fn test_quality_processor_disabled() {
let processor = QualityProcessor;
let config = ExtractionConfig {
enable_quality_processing: false,
..Default::default()
};
let mut result = ExtractedDocument {
content: "Some text".to_string(),
mime_type: Cow::Borrowed("text/plain"),
..Default::default()
};
processor.process(&mut result, &config).await.unwrap();
}
#[test]
fn test_quality_processor_plugin_interface() {
let processor = QualityProcessor;
assert_eq!(processor.name(), "quality-processing");
assert!(!processor.version().is_empty());
assert!(processor.initialize().is_ok());
assert!(processor.shutdown().is_ok());
}
#[test]
fn test_quality_processor_stage() {
let processor = QualityProcessor;
assert_eq!(processor.processing_stage(), ProcessingStage::Early);
}
#[test]
fn test_quality_processor_should_process() {
let processor = QualityProcessor;
let result = ExtractedDocument {
content: "Sample text".to_string(),
mime_type: Cow::Borrowed("text/plain"),
..Default::default()
};
let config_with_quality = ExtractionConfig {
enable_quality_processing: true,
..Default::default()
};
assert!(processor.should_process(&result, &config_with_quality));
let config_without_quality = ExtractionConfig {
enable_quality_processing: false,
..Default::default()
};
assert!(!processor.should_process(&result, &config_without_quality));
}
#[test]
fn test_quality_processor_estimated_duration() {
let processor = QualityProcessor;
let short_result = ExtractedDocument {
content: "Short".to_string(),
mime_type: Cow::Borrowed("text/plain"),
..Default::default()
};
let long_result = ExtractedDocument {
content: "a".repeat(1000000),
mime_type: Cow::Borrowed("text/plain"),
..Default::default()
};
let short_duration = processor.estimated_duration_ms(&short_result);
let long_duration = processor.estimated_duration_ms(&long_result);
assert!(long_duration > short_duration);
}
}