From 099f6a38ee6d2944b1c44c250d96d28743097944 Mon Sep 17 00:00:00 2001 From: Brian H Date: Wed, 26 Aug 2026 22:39:43 +1000 Subject: [PATCH 1/2] fix(mcp): real docling_ready probe + DoclingProcessSurface attestation (#60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PdfIngestOp and DoclingDocumentGraph (the actual PDF-ingest path, via a uv-run reqif-opa-mcp sidecar) already existed before this change, landed by #200. This closes the remaining gap from #60: l3dg3rr_get_pipeline_status hardcoded docling_ready to true instead of checking anything. Adds b00t_iface::docling::DoclingProcessSurface, a ProcessSurface attestation checking the sidecar's two real hard preconditions (uv on PATH, reqif-opa-mcp checkout present) — adapted from #60's literal `which::which("docling")` sketch, which predates the uv/reqif-opa-mcp architecture and no longer matches how PdfIngestOp actually works. Also removes integration_tests.rs's stale #[ignore]'d test_ingest_statement_via_pdf_sidecar, which asserted behavior (IngestStatementOp itself doing PDF ingest) that contradicts the PdfIngestOp design actually shipped; PdfIngestOp has its own coverage in ledger_ops.rs, including a real ignored subprocess integration test. Co-Authored-By: Claude Sonnet 5 --- Cargo.lock | 1 + crates/b00t-iface/src/docling/mod.rs | 169 ++++++++++++++++++ crates/b00t-iface/src/lib.rs | 1 + crates/ledger-core/src/integration_tests.rs | 83 ++------- crates/ledgerr-mcp/Cargo.toml | 1 + .../ledgerr-mcp/src/bin/ledgerr-mcp-server.rs | 3 +- 6 files changed, 187 insertions(+), 71 deletions(-) create mode 100644 crates/b00t-iface/src/docling/mod.rs 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..4238bf04 --- /dev/null +++ b/crates/b00t-iface/src/docling/mod.rs @@ -0,0 +1,169 @@ +//! 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> { + if !uv_on_path() { + return Err(DoclingError::NotOnPath); + } + if !config.reqif_opa_mcp_dir.exists() { + return Err(DoclingError::CheckoutMissing( + config.reqif_opa_mcp_dir.display().to_string(), + )); + } + 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); From e57e1f00241c3e9bc5ae5dd995314ae00ad423f9 Mon Sep 17 00:00:00 2001 From: Brian H Date: Wed, 26 Aug 2026 23:29:41 +1000 Subject: [PATCH 2/2] fix(b00t-iface): make docling init() check order CI-independent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's test-and-build check failed on init_fails_when_checkout_missing: it asserted Err(DoclingError::CheckoutMissing(_)) with a nonexistent checkout dir, but init() checked `uv` on PATH first, and CI runners don't have uv installed — so it returned NotOnPath instead, which the test didn't expect. Passed locally only because uv happens to be installed on this dev machine. Reorders init() to check the checkout path first, so the test (and init()'s behavior generally) no longer depends on whether uv happens to be present on whatever machine is running it. Verified locally both with uv on PATH and with a PATH that excludes it. Co-Authored-By: Claude Sonnet 5 --- crates/b00t-iface/src/docling/mod.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/b00t-iface/src/docling/mod.rs b/crates/b00t-iface/src/docling/mod.rs index 4238bf04..5842f164 100644 --- a/crates/b00t-iface/src/docling/mod.rs +++ b/crates/b00t-iface/src/docling/mod.rs @@ -90,14 +90,19 @@ impl ProcessSurface for DoclingProcessSurface { } fn init(&mut self, config: Self::Config) -> Result<(), Self::Error> { - if !uv_on_path() { - return Err(DoclingError::NotOnPath); - } + // 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()