Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

174 changes: 174 additions & 0 deletions crates/b00t-iface/src/docling/mod.rs
Original file line number Diff line number Diff line change
@@ -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<Self::Handle, Self::Error> {
if !self.is_ready() {
return Err(DoclingError::NotOnPath);
}
Ok(())
}

fn terminate((): Self::Handle) -> Result<AuditRecord, Self::Error> {
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())));
}
}
1 change: 1 addition & 0 deletions crates/b00t-iface/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
83 changes: 13 additions & 70 deletions crates/ledger-core/src/integration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path> --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 <path> --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
Expand Down
1 change: 1 addition & 0 deletions crates/ledgerr-mcp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
3 changes: 2 additions & 1 deletion crates/ledgerr-mcp/src/bin/ledgerr-mcp-server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,8 @@ fn handle_request(request: Value) -> Option<Value> {
}
"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);
Expand Down
Loading