diff --git a/Cargo.lock b/Cargo.lock index 95bdc078..729f1cae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4778,6 +4778,7 @@ version = "1.10.0" dependencies = [ "agentmesh", "arc-kit-au", + "b00t-iface", "beankeeper-bridge", "blake3", "calamine", diff --git a/crates/b00t-iface/src/docling/mod.rs b/crates/b00t-iface/src/docling/mod.rs new file mode 100644 index 00000000..5842f164 --- /dev/null +++ b/crates/b00t-iface/src/docling/mod.rs @@ -0,0 +1,174 @@ +//! Docling process surface — b00t attestation for the PDF-ingest sidecar. +//! +//! `PdfIngestOp` (in `ledger-core`) shells out to a `reqif-opa-mcp` Python +//! checkout via `uv run python -m reqif_ingest_cli extract ...` to turn a PDF +//! into a `DoclingDocumentGraph` (see `ledger-core/src/docling_bridge.rs`). +//! There is no standalone `docling` binary on `PATH` in this architecture — +//! the two hard preconditions for that subprocess to succeed are `uv` being +//! on `PATH` and the `reqif-opa-mcp` checkout existing on disk. This surface +//! is the node-level attestation of those two things, so callers can check +//! readiness before claiming `docling_ready: true` instead of hardcoding it. + +use crate::core::{ + AuditRecord, GovernancePolicy, MaintenanceAction, ProcessSurface, Requirement, + SurfaceCapability, +}; +use serde::Deserialize; +use std::path::PathBuf; +use std::time::Duration; + +#[derive(Debug, Clone, Deserialize)] +pub struct DoclingProcessSurfaceConfig { + /// Path to a `reqif-opa-mcp` checkout (e.g. `~/promptexecution/reqif-opa-mcp`). + #[serde(default = "default_reqif_opa_mcp_dir")] + pub reqif_opa_mcp_dir: PathBuf, +} + +fn default_reqif_opa_mcp_dir() -> PathBuf { + dirs::home_dir() + .unwrap_or_default() + .join("promptexecution/reqif-opa-mcp") +} + +impl Default for DoclingProcessSurfaceConfig { + fn default() -> Self { + Self { + reqif_opa_mcp_dir: default_reqif_opa_mcp_dir(), + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum DoclingError { + #[error("uv not on PATH")] + NotOnPath, + #[error("reqif-opa-mcp checkout not found: {0}")] + CheckoutMissing(String), +} + +fn uv_on_path() -> bool { + std::process::Command::new("uv") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// Attests that the PDF-ingest sidecar (`uv` + a `reqif-opa-mcp` checkout) is +/// operational on this node. +#[derive(Debug, Default)] +pub struct DoclingProcessSurface { + config: DoclingProcessSurfaceConfig, +} + +impl DoclingProcessSurface { + pub fn new() -> Self { + Self::default() + } + + /// True iff `uv` is on `PATH` and the configured sidecar checkout exists. + /// This is what a caller should check before advertising `docling_ready`. + pub fn is_ready(&self) -> bool { + uv_on_path() && self.config.reqif_opa_mcp_dir.exists() + } +} + +impl ProcessSurface for DoclingProcessSurface { + type Config = DoclingProcessSurfaceConfig; + type Error = DoclingError; + type Handle = (); + + fn capability(&self) -> SurfaceCapability { + SurfaceCapability { + name: "docling", + requirements: vec![ + Requirement::BinaryOnPath("uv".into()), + Requirement::PathExists(self.config.reqif_opa_mcp_dir.display().to_string()), + ], + governance: GovernancePolicy::default(), + } + } + + fn init(&mut self, config: Self::Config) -> Result<(), Self::Error> { + // Checkout-existence checked before the uv binary check so this stays + // deterministic in environments (e.g. CI) that have neither uv nor the + // checkout — CheckoutMissing is the more specific/actionable error in + // that case, and it must not depend on whether uv happens to be + // installed on the machine running the check. + if !config.reqif_opa_mcp_dir.exists() { + return Err(DoclingError::CheckoutMissing( + config.reqif_opa_mcp_dir.display().to_string(), + )); + } + if !uv_on_path() { + return Err(DoclingError::NotOnPath); + } + tracing::info!( + "DoclingProcessSurface initialized: sidecar at {}", + config.reqif_opa_mcp_dir.display() + ); + self.config = config; + Ok(()) + } + + fn operate(&self) -> Result { + if !self.is_ready() { + return Err(DoclingError::NotOnPath); + } + Ok(()) + } + + fn terminate((): Self::Handle) -> Result { + Ok(AuditRecord { + surface_name: "docling".into(), + uptime: Duration::from_secs(0), + exit_reason: "manual".into(), + crash_count: 0, + bytes_logged: 0, + }) + } + + fn maintain(&self) -> MaintenanceAction { + if self.is_ready() { + MaintenanceAction::NoOp + } else { + MaintenanceAction::Quarantine { + reason: "docling sidecar precondition no longer satisfied".into(), + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn not_ready_when_checkout_missing() { + let surface = DoclingProcessSurface { + config: DoclingProcessSurfaceConfig { + reqif_opa_mcp_dir: PathBuf::from("/nonexistent/reqif-opa-mcp"), + }, + }; + assert!(!surface.is_ready()); + } + + #[test] + fn init_fails_when_checkout_missing() { + let mut surface = DoclingProcessSurface::new(); + let result = surface.init(DoclingProcessSurfaceConfig { + reqif_opa_mcp_dir: PathBuf::from("/nonexistent/reqif-opa-mcp"), + }); + assert!(matches!(result, Err(DoclingError::CheckoutMissing(_)))); + } + + #[test] + fn capability_declares_uv_and_checkout_requirements() { + let surface = DoclingProcessSurface::new(); + let cap = surface.capability(); + assert_eq!(cap.name, "docling"); + assert!(cap + .requirements + .contains(&Requirement::BinaryOnPath("uv".into()))); + } +} diff --git a/crates/b00t-iface/src/lib.rs b/crates/b00t-iface/src/lib.rs index 01c88899..03b77439 100644 --- a/crates/b00t-iface/src/lib.rs +++ b/crates/b00t-iface/src/lib.rs @@ -20,6 +20,7 @@ //! - `autoresearch`: adds reqwest for remote eval dispatch pub mod core; +pub mod docling; pub mod exec; pub mod metric; pub mod sarif; diff --git a/crates/ledger-core/src/integration_tests.rs b/crates/ledger-core/src/integration_tests.rs index 19324c22..50443514 100644 --- a/crates/ledger-core/src/integration_tests.rs +++ b/crates/ledger-core/src/integration_tests.rs @@ -54,76 +54,19 @@ mod integration { // ------------------------------------------------------------------------- // Test #6 — PDF ingest via subprocess sidecar // ------------------------------------------------------------------------- - - /// Verify that `IngestStatementOp::execute()` can process a fixture PDF via - /// the Docling sidecar and produce at least one ingested transaction row. - /// - /// # What needs to be built first - /// Phase-2 work: `IngestStatementOp::execute()` must: - /// - Spawn `docling --pdf --output ndjson` (or equivalent) - /// - Parse NDJSON stdout into transaction rows - /// - Compute Blake3 content-hash IDs - /// - Return `OperationResult { success: true, items_processed: N }` - /// - /// Also requires: `tests/fixtures/sample_hsbc_statement.pdf` - #[test] - #[ignore = "requires IngestStatementOp::execute() subprocess wiring — phase-2 work; also needs fixture PDF"] - fn test_ingest_statement_via_pdf_sidecar() { - // DESIRED BEHAVIOR: - // IngestStatementOp::execute() should: - // 1. Glob ctx.working_dir / self.source_glob for PDF files - // 2. For each file, spawn the Docling sidecar CLI: - // docling --pdf --output ndjson - // 3. Read NDJSON lines from stdout; deserialize each as a transaction row - // 4. Compute Blake3 ID: blake3(account_id + date + amount + description) - // 5. Upsert rows (skip duplicates by hash) - // 6. Return OperationResult { success: true, items_processed: rows_seen, - // items_flagged: rows_needing_review } - // - // The fixture at tests/fixtures/sample_hsbc_statement.pdf should contain - // exactly one transaction line for deterministic test assertions. - use crate::ledger_ops::{ - IngestStatementOp, LedgerOpError, LedgerOperation, OperationContext, - }; - - let op = IngestStatementOp { - source_glob: "tests/fixtures/*.pdf".to_string(), - vendor_hint: Some("HSBC".to_string()), - }; - - // Point working_dir at the repo root so the glob resolves correctly. - let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() // crates/ledger-core → crates - .parent() - .unwrap() // crates → repo root - .to_path_buf(); - - let ctx = OperationContext::new(repo_root, PathBuf::from("/tmp/rules")); - - let result = op.execute(&ctx); - - // Current expectation: returns NotImplemented (phase-1 stub) - // Future expectation after phase-2: returns Ok with items_processed > 0 - match &result { - Err(LedgerOpError::NotImplemented(_)) => { - panic!( - "IngestStatementOp still returns NotImplemented — implement PDF sidecar \ - subprocess call in phase-2 to make this test pass" - ); - } - Ok(op_result) if !op_result.success => { - panic!("PDF ingest returned success=false: {:?}", op_result.issues); - } - Ok(op_result) => { - assert!( - op_result.items_processed > 0, - "should have ingested at least one row from fixture PDF; got 0" - ); - } - Err(e) => panic!("unexpected error during PDF ingest: {e:?}"), - } - } + // + // This used to be a `#[ignore]`d spec-as-test documenting desired behavior + // for a not-yet-built `IngestStatementOp::execute()` PDF branch that would + // shell out directly to a `docling` binary. That's no longer the shape of + // the system: PDF ingest is implemented as its own `PdfIngestOp` (see + // `ledger_ops.rs`), which shells out to a `reqif-opa-mcp` Python sidecar + // via `uv run python -m reqif_ingest_cli extract` and parses its output as + // a `docling_bridge::DoclingDocumentGraph`. `IngestStatementOp::execute()` + // now deliberately rejects `.pdf` input and points callers at `PdfIngestOp` + // instead (see `ingest_statement_op_rejects_pdf_with_clear_error` in + // `ledger_ops.rs`). `PdfIngestOp`'s own tests, including a real + // (`#[ignore]`d) subprocess integration test, live alongside it in + // `ledger_ops.rs` rather than here. // ------------------------------------------------------------------------- // Test #7 — Cedar/AGT gate filters transactions by compliance grade diff --git a/crates/ledgerr-mcp/Cargo.toml b/crates/ledgerr-mcp/Cargo.toml index 5ea34629..c6ff237a 100644 --- a/crates/ledgerr-mcp/Cargo.toml +++ b/crates/ledgerr-mcp/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true [dependencies] agentmesh = { workspace = true } +b00t-iface = { path = "../b00t-iface" } beankeeper-bridge = { path = "../beankeeper-bridge" } ofx-rs = { workspace = true } arc-kit-au = { path = "../arc-kit-au" } diff --git a/crates/ledgerr-mcp/src/bin/ledgerr-mcp-server.rs b/crates/ledgerr-mcp/src/bin/ledgerr-mcp-server.rs index 152331f7..a4271b7a 100644 --- a/crates/ledgerr-mcp/src/bin/ledgerr-mcp-server.rs +++ b/crates/ledgerr-mcp/src/bin/ledgerr-mcp-server.rs @@ -133,7 +133,8 @@ fn handle_request(request: Value) -> Option { } "l3dg3rr_list_accounts" => mcp_adapter::handle_list_accounts(global_raw_service()), "l3dg3rr_get_pipeline_status" => { - mcp_adapter::handle_pipeline_status(true, true, true, Vec::new()) + let docling_ready = b00t_iface::docling::DoclingProcessSurface::new().is_ready(); + mcp_adapter::handle_pipeline_status(true, true, docling_ready, Vec::new()) } "proxy_docling_ingest_pdf" => { let arguments = params.get("arguments").cloned().unwrap_or(Value::Null);