use docling::InputFormat;
use serde::Serialize;
#[derive(Debug, Clone, Copy, Default, Serialize)]
pub struct PhaseMetrics {
pub seconds: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub words_per_sec: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pages_per_sec: Option<f64>,
}
impl PhaseMetrics {
fn new(words: usize, pages: Option<usize>, seconds: f64) -> Self {
PhaseMetrics {
seconds: round3(seconds),
words_per_sec: rate(words, seconds),
pages_per_sec: pages.and_then(|p| rate(p, seconds)),
}
}
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct ProcessingMetrics {
pub file_bytes: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub pages: Option<usize>,
pub words: usize,
pub chunks: usize,
pub embedded_words: usize,
pub parsing: PhaseMetrics,
pub chunking: PhaseMetrics,
pub embedding: PhaseMetrics,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct Timings {
pub parse_secs: f64,
pub chunk_secs: f64,
pub embed_secs: f64,
}
impl ProcessingMetrics {
pub fn compute(
file_bytes: u64,
pages: Option<usize>,
words: usize,
chunks: usize,
embedded_words: usize,
t: Timings,
) -> Self {
ProcessingMetrics {
file_bytes,
pages,
words,
chunks,
embedded_words,
parsing: PhaseMetrics::new(words, pages, t.parse_secs),
chunking: PhaseMetrics::new(words, None, t.chunk_secs),
embedding: PhaseMetrics::new(embedded_words, None, t.embed_secs),
}
}
pub fn to_json(&self) -> serde_json::Value {
serde_json::to_value(self).unwrap_or(serde_json::Value::Null)
}
}
fn rate(count: usize, secs: f64) -> Option<f64> {
(secs > 1e-9).then(|| round1(count as f64 / secs))
}
fn round1(x: f64) -> f64 {
(x * 10.0).round() / 10.0
}
fn round3(x: f64) -> f64 {
(x * 1000.0).round() / 1000.0
}
pub fn count_pages(format: InputFormat, bytes: &[u8]) -> Option<usize> {
match format {
InputFormat::Pdf => docling_pdf::pdfium_backend::page_count(bytes, None).ok(),
InputFormat::Pptx => zip_entry_count(bytes, "ppt/slides/slide", ".xml"),
InputFormat::Xlsx => zip_entry_count(bytes, "xl/worksheets/sheet", ".xml"),
_ => None,
}
}
fn zip_entry_count(bytes: &[u8], prefix: &str, suffix: &str) -> Option<usize> {
let archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)).ok()?;
let n = archive
.file_names()
.filter(|name| name.starts_with(prefix) && name.ends_with(suffix))
.count();
(n > 0).then_some(n)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn computes_rates_and_serializes() {
let m = ProcessingMetrics::compute(
1000,
Some(4),
2000,
10,
2200,
Timings {
parse_secs: 2.0,
chunk_secs: 0.5,
embed_secs: 4.0,
},
);
assert_eq!(m.parsing.words_per_sec, Some(1000.0));
assert_eq!(m.parsing.pages_per_sec, Some(2.0));
assert_eq!(m.chunking.words_per_sec, Some(4000.0));
assert_eq!(m.chunking.pages_per_sec, None);
assert_eq!(m.embedding.words_per_sec, Some(550.0));
let j = m.to_json();
assert_eq!(j["file_bytes"], 1000);
assert_eq!(j["pages"], 4);
assert_eq!(j["parsing"]["pages_per_sec"], 2.0);
assert!(j["chunking"].get("pages_per_sec").is_none());
}
#[test]
fn zero_duration_yields_no_rate() {
let m = ProcessingMetrics::compute(1, None, 100, 1, 100, Timings::default());
assert_eq!(m.parsing.words_per_sec, None);
assert_eq!(m.parsing.pages_per_sec, None);
assert!(m.to_json().get("pages").is_none());
}
#[test]
fn non_container_formats_have_no_pages() {
assert_eq!(count_pages(InputFormat::Md, b"# hi"), None);
assert_eq!(count_pages(InputFormat::Pptx, b"not a zip"), None);
}
}