Skip to main content

blazegraph_io_core/
processor.rs

1use crate::analytics::{
2    AnalysisBuilder, DocumentAnalysis, FontStatsBuilder, GeometryStatsBuilder, PageStatsBuilder,
3    RegionStatsBuilder, Statistic,
4};
5use crate::cache::{self, GraphCacheKey};
6use crate::classifier::DocumentClassifier;
7use crate::config::ParsingConfig;
8use crate::graphs::builder::GraphBuilder;
9use crate::graphs::NodeIdGenerator;
10use crate::preprocessors::{Preprocessor, TikaPreprocessor};
11use crate::rules::RuleEngine;
12use crate::storage::{
13    calculate_config_hash, calculate_pdf_hash, CacheDefaults, CachePoint, DocumentStorage,
14    FileStorage, FreshFrom,
15};
16use crate::types::*;
17use anyhow::Result;
18use std::path::Path;
19use std::time::{Duration, Instant};
20
21/// Captured intermediate outputs from each pipeline stage
22/// Used for testing and diagnostics — lets you inspect/compare each boundary
23#[derive(Debug, Clone, serde::Serialize)]
24pub struct PipelineStages {
25    pub xhtml: String,
26    pub text_elements: Vec<PdfTextElement>,
27    pub parsed_elements: Vec<ParsedPdfElement>,
28    pub graph: DocumentGraph,
29}
30
31/// Simple profiler that collects timings for pipeline steps
32pub struct StepProfiler {
33    enabled: bool,
34    timings: Vec<(String, Duration)>,
35}
36
37impl StepProfiler {
38    pub fn new(enabled: bool) -> Self {
39        Self {
40            enabled,
41            timings: Vec::new(),
42        }
43    }
44
45    pub fn time_step<F, R>(&mut self, step_name: &str, f: F) -> R
46    where
47        F: FnOnce() -> R,
48    {
49        if !self.enabled {
50            return f();
51        }
52
53        let start = Instant::now();
54        let result = f();
55        let elapsed = start.elapsed();
56
57        self.timings.push((step_name.to_string(), elapsed));
58        println!("ā±ļø  {}: {:.0}ms", step_name, elapsed.as_millis());
59
60        result
61    }
62
63    pub fn print_summary(&self) {
64        if !self.enabled || self.timings.is_empty() {
65            return;
66        }
67
68        println!("\nšŸ“Š Performance Summary:");
69        let total: Duration = self.timings.iter().map(|(_, d)| *d).sum();
70
71        for (step, duration) in &self.timings {
72            let percentage = (duration.as_secs_f64() / total.as_secs_f64()) * 100.0;
73            println!(
74                "   {:.<35} {:.0}ms ({:.1}%)",
75                step,
76                duration.as_millis(),
77                percentage
78            );
79        }
80        println!("   {:.<35} {:.0}ms", "Total", total.as_millis());
81    }
82}
83
84pub struct DocumentProcessor {
85    preprocessor: Box<dyn Preprocessor>,
86    storage: Box<dyn DocumentStorage + Send + Sync>,
87    classifier: DocumentClassifier,
88    rule_engine: RuleEngine,
89    graph_builder: GraphBuilder,
90}
91
92impl DocumentProcessor {
93    /// Create DocumentProcessor with full dependency injection
94    pub fn new_with_dependencies(
95        preprocessor: Box<dyn Preprocessor>,
96        storage: Box<dyn DocumentStorage + Send + Sync>,
97    ) -> Result<Self> {
98        Ok(Self {
99            preprocessor,
100            storage,
101            classifier: DocumentClassifier::new(),
102            rule_engine: RuleEngine::new()?,
103            graph_builder: GraphBuilder::new(),
104        })
105    }
106
107    /// Convenience constructor for CLI usage with JNI backend (cross-platform)
108    #[cfg(feature = "jni-backend")]
109    pub fn new_cli_jni(jre_path: &std::path::Path, jar_path: &std::path::Path) -> Result<Self> {
110        let preprocessor = Box::new(TikaPreprocessor::new_with_jni(jre_path, jar_path)?);
111        let storage = Box::new(FileStorage::new("cache")?);
112        Self::new_with_dependencies(preprocessor, storage)
113    }
114
115    /// Convenience constructor for CLI with JNI backend and custom cache directory
116    #[cfg(feature = "jni-backend")]
117    pub fn new_cli_jni_with_cache(
118        jre_path: &std::path::Path,
119        jar_path: &std::path::Path,
120        cache_dir: &str,
121    ) -> Result<Self> {
122        let preprocessor = Box::new(TikaPreprocessor::new_with_jni(jre_path, jar_path)?);
123        let storage = Box::new(FileStorage::new(cache_dir)?);
124        Self::new_with_dependencies(preprocessor, storage)
125    }
126
127    // =========================================================================
128    // Main entry points
129    // =========================================================================
130
131    /// Process document with cache point awareness (CR-11).
132    /// This is the primary entry point for CLI usage.
133    pub fn process_document_with_cache(
134        &mut self,
135        input_path: &str,
136        config: &ParsingConfig,
137        fresh_from: FreshFrom,
138        cache_defaults: &CacheDefaults,
139        enable_profiling: bool,
140    ) -> Result<DocumentGraph> {
141        let mut profiler = StepProfiler::new(enable_profiling);
142        let start_time = Instant::now();
143
144        // Read PDF and calculate hash
145        let pdf_bytes = std::fs::read(input_path)?;
146        let pdf_hash = calculate_pdf_hash(&pdf_bytes);
147
148        println!("šŸ“„ Processing: {}", input_path);
149
150        // --- C3: Graph cache check ---
151        if fresh_from.should_use_cache(CachePoint::C3)
152            && cache_defaults.should_write(CachePoint::C3)
153        {
154            let config_hash = calculate_config_hash(config)?;
155            let cache_key = GraphCacheKey::new(pdf_hash.clone(), config_hash);
156            if let Some(cached) = self.storage.get_graph_output(&cache_key)? {
157                println!(
158                    "šŸŽÆ C3 graph cache hit ({:.3}s)",
159                    start_time.elapsed().as_secs_f64()
160                );
161                return Ok(cached.graph);
162            }
163        }
164
165        // Create deterministic ID generator: version + pdf_hash + config_hash
166        let config_hash = calculate_config_hash(config)?;
167        let id_gen =
168            NodeIdGenerator::new(cache::versions::BLAZEGRAPH_VERSION, &pdf_hash, &config_hash);
169
170        // --- C2: Preprocessor cache check ---
171        let preprocessor_output = if fresh_from.should_use_cache(CachePoint::C2) {
172            if let Some(cached) = self.storage.get_preprocessor_output(&pdf_hash)? {
173                println!("šŸŽÆ C2 preprocessor cache hit — skipping extraction + parsing");
174                cached
175            } else {
176                self.extract_and_parse(
177                    input_path,
178                    &pdf_bytes,
179                    &pdf_hash,
180                    &fresh_from,
181                    cache_defaults,
182                    &mut profiler,
183                )?
184            }
185        } else {
186            self.extract_and_parse(
187                input_path,
188                &pdf_bytes,
189                &pdf_hash,
190                &fresh_from,
191                cache_defaults,
192                &mut profiler,
193            )?
194        };
195
196        // --- Stages 2-5: Classification → Rules → Graph → Post-processing ---
197        let graph = self.rules_and_graph(
198            &preprocessor_output,
199            config,
200            &id_gen,
201            &pdf_hash,
202            &mut profiler,
203        )?;
204
205        if enable_profiling {
206            profiler.print_summary();
207        }
208        println!("ā±ļø  Total: {:.0}ms", start_time.elapsed().as_millis());
209
210        Ok(graph)
211    }
212
213    /// Simple document processing function using default config (no cache awareness)
214    pub fn process_document(&mut self, input_path: &str) -> Result<DocumentGraph> {
215        let default_config = ParsingConfig::default();
216        self.process_document_with_cache(
217            input_path,
218            &default_config,
219            FreshFrom::None,
220            &CacheDefaults::default(),
221            false,
222        )
223    }
224
225    /// Process document with config loaded from file
226    pub fn process_document_with_config_file(
227        &mut self,
228        input_path: &str,
229        config_path: &str,
230    ) -> Result<DocumentGraph> {
231        let config = ParsingConfig::load_from_file(config_path)?;
232        self.process_document_with_cache(
233            input_path,
234            &config,
235            FreshFrom::None,
236            &CacheDefaults::default(),
237            false,
238        )
239    }
240
241    /// Process document and capture all intermediate stage outputs
242    /// Used for pipeline diagnostics (--dump-stages). Always runs fresh.
243    pub fn process_document_capture_stages(
244        &mut self,
245        input_path: &str,
246        config: &ParsingConfig,
247    ) -> Result<PipelineStages> {
248        let input_path_ref = Path::new(input_path);
249        let pdf_bytes = std::fs::read(input_path_ref)?;
250        let pdf_hash = calculate_pdf_hash(&pdf_bytes);
251
252        // Stage 1a: PDF → XHTML (always fresh for diagnostics)
253        let xhtml = self.preprocessor.parse_pdf_to_markup_language(&pdf_bytes)?;
254        println!("šŸ“‹ Stage 1a: XHTML captured ({} bytes)", xhtml.len());
255
256        // Stage 1b: XHTML → TextElements
257        let preprocessor_output = self
258            .preprocessor
259            .parse_markup_to_preprocessor_output(&xhtml)?;
260        println!(
261            "šŸ“‹ Stage 1b: {} TextElements captured",
262            preprocessor_output.text_elements.len()
263        );
264
265        // Stage 2: Classification + Rules → ParsedElements
266        let classification = self.classifier.classify(&preprocessor_output)?;
267        let document_analysis = run_analytics(&preprocessor_output.text_elements);
268        if config.dump_analytics {
269            dump_stats(&*self.storage, &pdf_hash, &document_analysis)?;
270        }
271
272        // Reading-order resort + region tagging (Block 06b). Capture the
273        // post-resort stream as the canonical Stage 1b snapshot — this is
274        // the version that flows into rules and carries `region_label`.
275        let text_elements = crate::analytics::tag_and_resort(
276            preprocessor_output.text_elements.clone(),
277            &document_analysis,
278        );
279        let resorted_elements = text_elements.clone();
280
281        let parsed_elements = if config.minimal_parse {
282            self.rule_engine
283                .convert_text_elements_to_parsed(&resorted_elements)
284        } else {
285            let font_size_analysis = self
286                .rule_engine
287                .analyze_font_sizes(&resorted_elements, &preprocessor_output.style_data);
288            self.rule_engine.apply_rules_with_config(
289                &resorted_elements,
290                &classification,
291                &document_analysis,
292                &font_size_analysis,
293                &preprocessor_output.style_data,
294                config,
295            )?
296        };
297        println!(
298            "šŸ“‹ Stage 2: {} ParsedElements captured",
299            parsed_elements.len()
300        );
301
302        // Infer title from content before graph build
303        let inferred_title = infer_title(&parsed_elements);
304
305        // Stage 3: ParsedElements → DocumentGraph
306        let mut graph = self.graph_builder.build_graph(parsed_elements.clone())?;
307
308        // Wire metadata and compute post-processing
309        if let Some(title) = inferred_title {
310            graph.document_info.document_metadata.title = Some(title);
311        }
312        graph
313            .document_info
314            .document_metadata
315            .merge_extracted(preprocessor_output.metadata);
316        graph.document_info.bookmark_data = preprocessor_output.bookmark_data;
317        graph.compute_structural_profile();
318        graph.compute_breadcrumbs();
319        crate::graphs::graph_sanity::apply(&mut graph, &config.graph_sanity);
320
321        println!("šŸ“‹ Stage 3: Graph captured ({} nodes)", graph.nodes.len());
322
323        Ok(PipelineStages {
324            xhtml,
325            text_elements,
326            parsed_elements,
327            graph,
328        })
329    }
330
331    // =========================================================================
332    // Internal: extraction + parsing with C1/C2 cache awareness
333    // =========================================================================
334
335    /// Extract XHTML and parse to PreprocessorOutput, respecting C1 and C2 caches.
336    fn extract_and_parse(
337        &mut self,
338        _input_path: &str,
339        pdf_bytes: &[u8],
340        pdf_hash: &str,
341        fresh_from: &FreshFrom,
342        cache_defaults: &CacheDefaults,
343        profiler: &mut StepProfiler,
344    ) -> Result<PreprocessorOutput> {
345        // --- C1: XHTML cache check ---
346        let xhtml = if fresh_from.should_use_cache(CachePoint::C1) {
347            if let Some(cached) = self.storage.get_xhtml(pdf_hash)? {
348                println!("šŸŽÆ C1 XHTML cache hit — skipping Tika extraction");
349                cached
350            } else {
351                let markup = profiler.time_step("C1: PDF → XHTML (Tika)", || {
352                    self.preprocessor.parse_pdf_to_markup_language(pdf_bytes)
353                })?;
354                if cache_defaults.should_write(CachePoint::C1) {
355                    self.storage.store_xhtml(pdf_hash, &markup)?;
356                    println!("šŸ’¾ C1: XHTML cached ({} bytes)", markup.len());
357                }
358                markup
359            }
360        } else {
361            // Fresh extraction requested
362            let markup = profiler.time_step("C1: PDF → XHTML (Tika, fresh)", || {
363                self.preprocessor.parse_pdf_to_markup_language(pdf_bytes)
364            })?;
365            if cache_defaults.should_write(CachePoint::C1) {
366                self.storage.store_xhtml(pdf_hash, &markup)?;
367                println!("šŸ’¾ C1: XHTML cached ({} bytes, refreshed)", markup.len());
368            }
369            markup
370        };
371
372        // --- C2: Parse XHTML → PreprocessorOutput ---
373        let output = profiler.time_step("C2: XHTML → PreprocessorOutput", || {
374            self.preprocessor
375                .parse_markup_to_preprocessor_output(&xhtml)
376        })?;
377
378        if cache_defaults.should_write(CachePoint::C2) {
379            self.storage.store_preprocessor_output(pdf_hash, &output)?;
380            println!("šŸ’¾ C2: PreprocessorOutput cached");
381        }
382
383        Ok(output)
384    }
385
386    // =========================================================================
387    // Internal: classification → rules → graph (shared by all entry points)
388    // =========================================================================
389
390    /// Run classification, rules, and graph building on PreprocessorOutput.
391    fn rules_and_graph(
392        &mut self,
393        preprocessor_output: &PreprocessorOutput,
394        config: &ParsingConfig,
395        id_gen: &NodeIdGenerator,
396        pdf_hash: &str,
397        profiler: &mut StepProfiler,
398    ) -> Result<DocumentGraph> {
399        // Classification
400        let classification = profiler.time_step("Classification", || {
401            self.classifier.classify(preprocessor_output)
402        })?;
403
404        // Document analytics pre-pass (read by rules; sidecar-dumped to
405        // `{cache_dir}/stat/<name>/<pdf_hash>.json` when `config.dump_analytics`).
406        // No longer persisted into graph.json — that field went away with schema 0.4.0.
407        let document_analysis = profiler.time_step("Document Analytics", || {
408            run_analytics(&preprocessor_output.text_elements)
409        });
410        if config.dump_analytics {
411            dump_stats(&*self.storage, pdf_hash, &document_analysis)?;
412        }
413
414        // Reading-order resort + region tagging (Block 06b). Annotates each
415        // element with its Region tree leaf label and reorders the stream so
416        // multi-column pages no longer interleave columns. Owned-clone of
417        // `text_elements` because PreprocessorOutput is borrowed immutably
418        // here; the cost is one Vec clone per document, negligible vs the
419        // rules / graph-build work that follows.
420        let text_elements = profiler.time_step("Reading-Order Resort", || {
421            crate::analytics::tag_and_resort(
422                preprocessor_output.text_elements.clone(),
423                &document_analysis,
424            )
425        });
426
427        // Rule processing
428        let parsed_elements = if config.minimal_parse {
429            println!("šŸ”„ Minimal parse mode — skipping rule processing");
430            self.rule_engine
431                .convert_text_elements_to_parsed(&text_elements)
432        } else {
433            let font_size_analysis = profiler.time_step("Font Analysis", || {
434                self.rule_engine
435                    .analyze_font_sizes(&text_elements, &preprocessor_output.style_data)
436            });
437
438            profiler.time_step("Rules Processing", || {
439                self.rule_engine.apply_rules_with_config(
440                    &text_elements,
441                    &classification,
442                    &document_analysis,
443                    &font_size_analysis,
444                    &preprocessor_output.style_data,
445                    config,
446                )
447            })?
448        };
449
450        // Infer title before graph build consumes elements
451        let inferred_title = infer_title(&parsed_elements);
452
453        // Graph construction (deterministic UUIDv5 node IDs)
454        let mut graph = profiler.time_step("Graph Construction", || {
455            self.graph_builder
456                .build_graph_deterministic(parsed_elements, id_gen)
457        })?;
458
459        // Post-processing: metadata, analysis, breadcrumbs
460        if let Some(title) = inferred_title {
461            graph.document_info.document_metadata.title = Some(title);
462        }
463        graph
464            .document_info
465            .document_metadata
466            .merge_extracted(preprocessor_output.metadata.clone());
467        graph.document_info.bookmark_data = preprocessor_output.bookmark_data.clone();
468        graph.compute_structural_profile();
469        graph.compute_breadcrumbs();
470        crate::graphs::graph_sanity::apply(&mut graph, &config.graph_sanity);
471
472        Ok(graph)
473    }
474}
475
476/// Run the document-analytics pre-pass over a slice of text elements.
477///
478/// Single-pass walk: dispatches each element to every enabled stat kind via
479/// `AnalysisBuilder`, then finalizes in dependency order. Output is consumed
480/// in pipeline memory by downstream rules and (when `dump_analytics`) written
481/// to per-stat sidecar files via [`dump_stats`].
482fn run_analytics(text_elements: &[PdfTextElement]) -> DocumentAnalysis {
483    let mut builder = AnalysisBuilder::new();
484    for element in text_elements {
485        builder.observe(element);
486    }
487    builder.finalize()
488}
489
490/// Per-stat sidecar dump. One JSON file per stat kind under
491/// `{cache_dir}/stat/<Statistic::NAME>/<pdf_hash>.json`. Folder-per-stat
492/// scoping (Marcus, Block 05) lets future stat kinds (RegionStats,
493/// PageOutlier, …) drop in without colliding. The full composite is the
494/// in-memory shape; the sidecar splits it for grep-ability and per-stat diff
495/// against Python prototype outputs.
496fn dump_stats(
497    storage: &dyn DocumentStorage,
498    pdf_hash: &str,
499    analysis: &DocumentAnalysis,
500) -> Result<()> {
501    let font_json = serde_json::to_string_pretty(&analysis.font)?;
502    storage.store_stat(pdf_hash, FontStatsBuilder::NAME, &font_json)?;
503
504    let geometry_json = serde_json::to_string_pretty(&analysis.geometry)?;
505    storage.store_stat(pdf_hash, GeometryStatsBuilder::NAME, &geometry_json)?;
506
507    let page_stats_json = serde_json::to_string_pretty(&analysis.page_stats)?;
508    storage.store_stat(pdf_hash, PageStatsBuilder::NAME, &page_stats_json)?;
509
510    let region_json = serde_json::to_string_pretty(&analysis.region)?;
511    storage.store_stat(pdf_hash, RegionStatsBuilder::NAME, &region_json)?;
512
513    Ok(())
514}