1use docling::InputFormat;
21use serde::Serialize;
22
23#[derive(Debug, Clone, Copy, Default, Serialize)]
25pub struct PhaseMetrics {
26 pub seconds: f64,
28 #[serde(skip_serializing_if = "Option::is_none")]
30 pub words_per_sec: Option<f64>,
31 #[serde(skip_serializing_if = "Option::is_none")]
33 pub pages_per_sec: Option<f64>,
34}
35
36impl PhaseMetrics {
37 fn new(words: usize, pages: Option<usize>, seconds: f64) -> Self {
38 PhaseMetrics {
39 seconds: round3(seconds),
40 words_per_sec: rate(words, seconds),
41 pages_per_sec: pages.and_then(|p| rate(p, seconds)),
42 }
43 }
44}
45
46#[derive(Debug, Clone, Default, Serialize)]
48pub struct ProcessingMetrics {
49 pub file_bytes: u64,
51 #[serde(skip_serializing_if = "Option::is_none")]
53 pub pages: Option<usize>,
54 pub words: usize,
56 pub chunks: usize,
58 pub embedded_words: usize,
60 pub parsing: PhaseMetrics,
62 pub chunking: PhaseMetrics,
64 pub embedding: PhaseMetrics,
66}
67
68#[derive(Debug, Clone, Copy, Default)]
70pub struct Timings {
71 pub parse_secs: f64,
72 pub chunk_secs: f64,
73 pub embed_secs: f64,
74}
75
76impl ProcessingMetrics {
77 pub fn compute(
79 file_bytes: u64,
80 pages: Option<usize>,
81 words: usize,
82 chunks: usize,
83 embedded_words: usize,
84 t: Timings,
85 ) -> Self {
86 ProcessingMetrics {
87 file_bytes,
88 pages,
89 words,
90 chunks,
91 embedded_words,
92 parsing: PhaseMetrics::new(words, pages, t.parse_secs),
93 chunking: PhaseMetrics::new(words, None, t.chunk_secs),
94 embedding: PhaseMetrics::new(embedded_words, None, t.embed_secs),
95 }
96 }
97
98 pub fn to_json(&self) -> serde_json::Value {
100 serde_json::to_value(self).unwrap_or(serde_json::Value::Null)
101 }
102}
103
104fn rate(count: usize, secs: f64) -> Option<f64> {
106 (secs > 1e-9).then(|| round1(count as f64 / secs))
107}
108
109fn round1(x: f64) -> f64 {
110 (x * 10.0).round() / 10.0
111}
112
113fn round3(x: f64) -> f64 {
114 (x * 1000.0).round() / 1000.0
115}
116
117pub fn count_pages(format: InputFormat, bytes: &[u8]) -> Option<usize> {
120 match format {
121 InputFormat::Pdf => docling_pdf::pdfium_backend::page_count(bytes, None).ok(),
123 InputFormat::Pptx => zip_entry_count(bytes, "ppt/slides/slide", ".xml"),
125 InputFormat::Xlsx => zip_entry_count(bytes, "xl/worksheets/sheet", ".xml"),
127 _ => None,
128 }
129}
130
131fn zip_entry_count(bytes: &[u8], prefix: &str, suffix: &str) -> Option<usize> {
132 let archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)).ok()?;
133 let n = archive
134 .file_names()
135 .filter(|name| name.starts_with(prefix) && name.ends_with(suffix))
136 .count();
137 (n > 0).then_some(n)
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143
144 #[test]
145 fn computes_rates_and_serializes() {
146 let m = ProcessingMetrics::compute(
147 1000,
148 Some(4),
149 2000,
150 10,
151 2200,
152 Timings {
153 parse_secs: 2.0,
154 chunk_secs: 0.5,
155 embed_secs: 4.0,
156 },
157 );
158 assert_eq!(m.parsing.words_per_sec, Some(1000.0));
159 assert_eq!(m.parsing.pages_per_sec, Some(2.0));
160 assert_eq!(m.chunking.words_per_sec, Some(4000.0));
161 assert_eq!(m.chunking.pages_per_sec, None);
162 assert_eq!(m.embedding.words_per_sec, Some(550.0));
163
164 let j = m.to_json();
165 assert_eq!(j["file_bytes"], 1000);
166 assert_eq!(j["pages"], 4);
167 assert_eq!(j["parsing"]["pages_per_sec"], 2.0);
168 assert!(j["chunking"].get("pages_per_sec").is_none());
170 }
171
172 #[test]
173 fn zero_duration_yields_no_rate() {
174 let m = ProcessingMetrics::compute(1, None, 100, 1, 100, Timings::default());
175 assert_eq!(m.parsing.words_per_sec, None);
176 assert_eq!(m.parsing.pages_per_sec, None);
177 assert!(m.to_json().get("pages").is_none());
179 }
180
181 #[test]
182 fn non_container_formats_have_no_pages() {
183 assert_eq!(count_pages(InputFormat::Md, b"# hi"), None);
184 assert_eq!(count_pages(InputFormat::Pptx, b"not a zip"), None);
185 }
186}