diff --git a/Makefile b/Makefile index 5a0bc5ba..bd2a19ad 100644 --- a/Makefile +++ b/Makefile @@ -110,21 +110,22 @@ reset: ## Resets Docker containers (down -v, then up -d). @docker compose -f ./docker/compose.yaml up -d @$(call log,Docker containers reset successfully.) -# `NER=1` also starts the GLiNER2 NER service and points the server at the NER -# engine config. Set by the `run` recipe below. -ifdef NER -RUN_ENGINE_ENV := ENGINE_CONFIG_FILEPATH="$$engine_config" -endif +# Inference services to start alongside the server. Set NER=1, OCR=1, and/or +# STT=1 to bring up that bento (first run downloads its model). The `run` recipe +# below starts the matching compose profiles and generates an engine config +# naming only the services that are up; with none set, the server runs the +# built-in pattern recognizers only. +INFERENCE_PROFILES := $(if $(NER),--profile ner) $(if $(OCR),--profile ocr) $(if $(STT),--profile stt) .PHONY: run -run: ## Runs the server (NATS, Postgres, RustFS first). Pass NER=1 to also start the GLiNER2 NER service. +run: ## Runs the server (NATS, Postgres, RustFS first). Set NER=1, OCR=1, and/or STT=1 to also start that inference service. @$(call log,Starting Postgres, NATS, and RustFS...) @docker compose -f ./docker/compose.yaml up -d --wait postgres nats rustfs @$(call log,Provisioning the blob store bucket...) @docker compose -f ./docker/compose.yaml up --exit-code-from rustfs-init rustfs-init -ifdef NER - @$(call log,Starting the NER service (first run downloads the model)...) - @docker compose -f ./docker/compose.yaml --profile ner up -d --wait ner +ifneq ($(strip $(INFERENCE_PROFILES)),) + @$(call log,Starting inference services (first run downloads their models)...) + @docker compose -f ./docker/compose.yaml $(INFERENCE_PROFILES) up -d --wait endif @$(call log,Starting server... (output also written to latest.log)) @# Tee via process substitution rather than a pipe: a pipe would put `tee` @@ -133,7 +134,19 @@ endif @# then lost and, under `pipefail`, the recipe reports exit 130. With `> >(tee)` @# the server owns the terminal and receives the signal directly; its shutdown @# runs and is logged, and the process exits cleanly. - @bash -c 'engine_config=$$(mktemp); trap "rm -f \"$$engine_config\"" EXIT; sed "s|\$${ELIDE_NER_PORT:-3200}|$${ELIDE_NER_PORT:-3200}|g" docker/engine.dev.toml > "$$engine_config"; $(RUN_ENGINE_ENV) cargo run --features dotenv --bin nvisy-cli > >(tee latest.log) 2>&1' + @# The engine config is generated from the enabled services, so it always + @# matches the running stack and its resolved ports; no config file is used + @# when none are enabled. + @bash -c '\ + set -o pipefail; \ + engine_env=; \ + if [ -n "$(strip $(INFERENCE_PROFILES))" ]; then \ + engine_config=$$(mktemp); \ + trap "rm -f \"$$engine_config\"" EXIT; \ + NER=$(if $(NER),1) OCR=$(if $(OCR),1) STT=$(if $(STT),1) ./scripts/gen-engine-config.sh > "$$engine_config"; \ + engine_env="ENGINE_CONFIG_FILEPATH=$$engine_config"; \ + fi; \ + env $$engine_env cargo run --features dotenv --bin nvisy-cli > >(tee latest.log) 2>&1' .PHONY: generate-all generate-all: generate-env generate-keys generate-migrations diff --git a/README.md b/README.md index b6e6d404..178fc880 100644 --- a/README.md +++ b/README.md @@ -58,10 +58,10 @@ docker compose -f docker/compose.yaml up -d # Start Postgres, NATS, RustFS make run # Run the server ``` -To run with the GLiNER2 NER service (pattern recognition otherwise runs alone), -pass `make run NER=1` — it also starts the `ner` compose profile and points the -server at `docker/engine.dev.toml`. The first run downloads the model (~1.2 GB) -into a cached volume. +To run with the inference services (pattern recognition otherwise runs alone), +set `NER=1`, `OCR=1`, and/or `STT=1` — e.g. `make run NER=1 OCR=1`. Each starts +its bento compose profile and is added to a generated engine config matching the +running stack. The first run of each downloads its model into a cached volume. The API then serves interactive OpenAPI docs (Scalar UI) at the running server's docs path. For self-hosted deployments, see [`docker/`](docker/) for compose diff --git a/crates/nvisy-postgres/src/query/mod.rs b/crates/nvisy-postgres/src/query/mod.rs index 7c1a4903..da1b67f6 100644 --- a/crates/nvisy-postgres/src/query/mod.rs +++ b/crates/nvisy-postgres/src/query/mod.rs @@ -68,8 +68,8 @@ pub use workspace_connections::{ pub use workspace_detection_jobs::DetectionJobOutboxRepository; pub use workspace_detection_policy_versions::DetectionPolicyVersionRepository; pub use workspace_detections::{ - DetectionCursor, DetectionDocuments, DetectionListRow, DetectionSummary, EditWriteOutcome, - WorkspaceDetectionRepository, + DetectionCursor, DetectionDocuments, DetectionListRow, DetectionSummary, DetectionTreeRow, + EditWriteOutcome, WorkspaceDetectionRepository, }; pub use workspace_documents::{ DocumentCursor, DocumentWithBlob, ImportedDocumentRef, WorkspaceDocumentRepository, diff --git a/crates/nvisy-postgres/src/query/workspace_detections.rs b/crates/nvisy-postgres/src/query/workspace_detections.rs index 609c9575..166c8f12 100644 --- a/crates/nvisy-postgres/src/query/workspace_detections.rs +++ b/crates/nvisy-postgres/src/query/workspace_detections.rs @@ -1,5 +1,6 @@ //! Workspace detections repository for managing analysis instances. +use std::collections::HashMap; use std::future::Future; use diesel::prelude::*; @@ -13,6 +14,7 @@ use crate::model::{ NewWorkspaceDetectionCallMeter, UpdateWorkspaceDetection, WorkspaceDetection, }; use crate::query::search::ilike_contains; +use crate::query::{DetectionRedactedOutput, WorkspaceRedactionRepository}; use crate::schema::{ workspace_blobs as blobs, workspace_detections as detections, workspace_documents as documents, workspace_redactions as redactions, @@ -146,11 +148,34 @@ pub struct DetectionListRow { pub input_document_name: Option, } +/// One detection tree: a detection run with its artifacts. +#[derive(Debug, Clone)] +pub struct DetectionTreeRow { + /// Detection run identifier. + pub detection_id: Uuid, + /// Input document identifier, retained even when the input is deleted. + pub input_document_id: Uuid, + /// Live input name, or None for a deleted input. + pub input_document_name: Option, + /// Whether intermediate artifact bytes are retained. + pub intermediate_available: bool, + /// Live redacted output files for this run, newest first. + pub redacted_outputs: Vec, +} + /// Repository for workspace detection database operations. /// /// Handles detection lifecycle management including creation, status updates, /// completion tracking, and queries. pub trait WorkspaceDetectionRepository { + /// Lists detection trees, paginating the roots before fetching their outputs. + fn cursor_list_detection_trees( + &mut self, + workspace_id: Uuid, + pagination: CursorPagination, + filter: &DetectionFilter, + ) -> impl Future>> + Send; + /// Creates a new workspace detection record. fn create_workspace_detection( &mut self, @@ -286,6 +311,33 @@ pub trait WorkspaceDetectionRepository { } impl WorkspaceDetectionRepository for PgConnection { + async fn cursor_list_detection_trees( + &mut self, + workspace_id: Uuid, + pagination: CursorPagination, + filter: &DetectionFilter, + ) -> Result> { + let page = self + .cursor_list_workspace_detections(workspace_id, pagination, filter) + .await?; + let ids: Vec<_> = page.items.iter().map(|row| row.detection.id).collect(); + let outputs = self.detection_outputs_batch(workspace_id, &ids).await?; + let mut by_detection: HashMap> = HashMap::new(); + for output in outputs { + by_detection + .entry(output.detection_id) + .or_default() + .push(output); + } + Ok(page.map(|row| DetectionTreeRow { + detection_id: row.detection.id, + input_document_id: row.detection.input_document_id, + input_document_name: row.input_document_name, + intermediate_available: row.detection.has_intermediate, + redacted_outputs: by_detection.remove(&row.detection.id).unwrap_or_default(), + })) + } + async fn create_workspace_detection( &mut self, new_detection: NewWorkspaceDetection, @@ -1076,6 +1128,100 @@ mod tests { Ok((seeded, ids)) } + #[tokio::test] + async fn artifact_trees_paginate_roots_and_keep_outputs_with_their_detection() + -> anyhow::Result<()> { + use crate::query::WorkspaceDocumentRepository; + + let db = TestDatabase::start().await; + let (seeded, _) = seed_searchable_runs(&db).await?; + let mut conn = db.client.get_connection().await?; + let filter = DetectionFilter { + search: Some("Report".into()), + ..Default::default() + }; + let first = conn + .cursor_list_detection_trees( + seeded.workspace_id, + CursorPagination::new(1).with_count(), + &filter, + ) + .await?; + assert_eq!(first.total, Some(2)); + assert_eq!(first.items.len(), 1); + assert!(first.next_cursor.is_some()); + let second = conn + .cursor_list_detection_trees( + seeded.workspace_id, + CursorPagination::from_cursor_string(1, first.next_cursor.as_deref()).with_count(), + &filter, + ) + .await?; + assert_eq!(second.total, Some(2)); + assert_eq!(second.items.len(), 1); + assert_ne!(first.items[0].detection_id, second.items[0].detection_id); + assert!(second.next_cursor.is_none()); + for tree in [&first.items[0], &second.items[0]] { + assert_eq!(tree.input_document_name.as_deref(), Some("Report_50%.pdf")); + assert!(tree.intermediate_available); + assert_eq!(tree.redacted_outputs.len(), 3); + assert!( + tree.redacted_outputs + .iter() + .all(|output| output.detection_id == tree.detection_id) + ); + } + let unknown_workspace = conn + .cursor_list_detection_trees( + Uuid::new_v4(), + CursorPagination::new(10).with_count(), + &filter, + ) + .await?; + assert_eq!(unknown_workspace.total, Some(0)); + assert_eq!(unknown_workspace.items.len(), 0); + assert!(unknown_workspace.next_cursor.is_none()); + + // Deleting child files does not remove their detection roots. + conn.delete_workspace_document(seeded.document_id).await?; + let input_id = first.items[0].input_document_id; + let by_input = DetectionFilter { + input_document_id: Some(input_id), + ..Default::default() + }; + let without_outputs = conn + .cursor_list_detection_trees( + seeded.workspace_id, + CursorPagination::new(10).with_count(), + &by_input, + ) + .await?; + assert_eq!(without_outputs.total, Some(2)); + assert!( + without_outputs + .items + .iter() + .all(|tree| tree.redacted_outputs.is_empty()) + ); + // Deleted inputs also leave the run history intact, with no input metadata. + conn.delete_workspace_document(input_id).await?; + let without_input = conn + .cursor_list_detection_trees( + seeded.workspace_id, + CursorPagination::new(10).with_count(), + &by_input, + ) + .await?; + assert_eq!(without_input.total, Some(2)); + assert!( + without_input + .items + .iter() + .all(|tree| tree.input_document_name.is_none()) + ); + Ok(()) + } + #[tokio::test] async fn cursor_list_search_and_artifact_counts_preserve_pagination() -> anyhow::Result<()> { use crate::query::{WorkspaceDocumentRepository, WorkspaceRedactionRepository}; diff --git a/crates/nvisy-postgres/src/query/workspace_redactions.rs b/crates/nvisy-postgres/src/query/workspace_redactions.rs index 8a3e9e57..4a4a397d 100644 --- a/crates/nvisy-postgres/src/query/workspace_redactions.rs +++ b/crates/nvisy-postgres/src/query/workspace_redactions.rs @@ -25,6 +25,8 @@ pub struct RedactionCursor { /// Redacted output metadata for one detection, used by the artifacts endpoint. #[derive(Debug, Clone)] pub struct DetectionRedactedOutput { + /// Detection run that owns this output. + pub detection_id: Uuid, /// Redaction pass that produced the output. pub redaction_id: Uuid, /// Output document id. @@ -43,6 +45,13 @@ pub struct DetectionRedactedOutput { /// have many. Each redaction owns the review audit it applied and the redacted /// document it produced. pub trait WorkspaceRedactionRepository { + /// Lists live outputs for a page of detections, newest first within each run. + fn detection_outputs_batch( + &mut self, + workspace_id: Uuid, + detection_ids: &[Uuid], + ) -> impl Future>> + Send; + /// Lists live redacted output files for a detection, newest first. fn detection_outputs( &mut self, @@ -94,6 +103,15 @@ impl WorkspaceRedactionRepository for PgConnection { &mut self, workspace_id: Uuid, detection_id: Uuid, + ) -> Result> { + self.detection_outputs_batch(workspace_id, &[detection_id]) + .await + } + + async fn detection_outputs_batch( + &mut self, + workspace_id: Uuid, + detection_ids: &[Uuid], ) -> Result> { use schema::workspace_detections::dsl as detections; use schema::workspace_documents::dsl as documents; @@ -102,6 +120,9 @@ impl WorkspaceRedactionRepository for PgConnection { workspace_blobs, workspace_detections, workspace_documents, workspace_redactions, }; + if detection_ids.is_empty() { + return Ok(Vec::new()); + } let rows = workspace_redactions::table .inner_join(workspace_detections::table) .inner_join( @@ -119,22 +140,24 @@ impl WorkspaceRedactionRepository for PgConnection { .filter(workspace_blobs::workspace_id.eq(workspace_id)) .filter(workspace_blobs::purged_at.is_null()) .order((redactions::created_at.desc(), redactions::id.desc())) - .filter(redactions::detection_id.eq(detection_id)) + .filter(redactions::detection_id.eq_any(detection_ids)) .select(( + redactions::detection_id, redactions::id, documents::id, documents::display_name, documents::file_extension, workspace_blobs::file_size_bytes, )) - .load::<(Uuid, Uuid, String, String, i64)>(self) + .load::<(Uuid, Uuid, Uuid, String, String, i64)>(self) .await .map_err(Error::from)?; Ok(rows .into_iter() .map( - |(redaction_id, document_id, display_name, extension, size)| { + |(detection_id, redaction_id, document_id, display_name, extension, size)| { DetectionRedactedOutput { + detection_id, redaction_id, document_id, display_name, diff --git a/crates/nvisy-server/src/domain/output/workspace_detections.rs b/crates/nvisy-server/src/domain/output/workspace_detections.rs index 2f382a11..14c55b3c 100644 --- a/crates/nvisy-server/src/domain/output/workspace_detections.rs +++ b/crates/nvisy-server/src/domain/output/workspace_detections.rs @@ -20,13 +20,3 @@ pub struct CreatedDetection { /// idempotency key replayed an existing one (`200`). pub created: bool, } - -/// Artifact metadata for one detection run. -pub struct DetectionArtifacts { - /// Live input document, if it has not been deleted. - pub input: Option, - /// Whether an intermediate artifact is retained. - pub intermediate_available: bool, - /// Live redacted output files. - pub redacted_outputs: Vec, -} diff --git a/crates/nvisy-server/src/domain/workspace_detections.rs b/crates/nvisy-server/src/domain/workspace_detections.rs index 0efd82d3..b9caac08 100644 --- a/crates/nvisy-server/src/domain/workspace_detections.rs +++ b/crates/nvisy-server/src/domain/workspace_detections.rs @@ -20,9 +20,8 @@ use nvisy_postgres::model::{ }; use nvisy_postgres::query::{ DetectionCursor, DetectionDocuments, DetectionJobOutboxRepository, DetectionListRow, - DetectionPolicyVersionRepository, EditWriteOutcome, WorkspaceBlobRepository, - WorkspaceDetectionRepository, WorkspaceDocumentRepository, WorkspacePolicyRepository, - WorkspacePolicyVersionRepository, WorkspaceRedactionRepository, + DetectionPolicyVersionRepository, EditWriteOutcome, WorkspaceDetectionRepository, + WorkspaceDocumentRepository, WorkspacePolicyRepository, WorkspacePolicyVersionRepository, }; use nvisy_postgres::types::{CursorPage, CursorPagination, DetectionFilter, DetectionStatus, Json}; use nvisy_postgres::{AsyncConnection, PgClient, PgConn}; @@ -211,39 +210,22 @@ impl WorkspaceDetectionService { .await?) } - /// Returns the input, intermediate availability, and live output files for a - /// detection. + /// Lists a workspace's detection trees using the detection filters + /// and cursor order. Children are fetched in one batch after paginating roots. /// /// # Errors /// - /// - `NotFound` if the detection is missing or deleted. /// - A database error if a connection or query fails. - pub async fn artifacts( + pub async fn list_trees( &self, workspace_id: Uuid, - detection_id: Uuid, - ) -> Result { + pagination: CursorPagination, + filter: &DetectionFilter, + ) -> Result> { let mut conn = self.postgres.get_connection().await?; - let detection = conn - .find_workspace_detection_by_id(workspace_id, detection_id) - .await? - .ok_or_else(|| Error::not_found("detection"))?; - let input = conn - .find_document_in_workspace(workspace_id, detection.input_document_id) - .await?; - let intermediate_available = if let Some(blob_id) = detection.intermediate_blob_id { - conn.find_blob_by_id(blob_id) - .await? - .is_some_and(|blob| blob.workspace_id == workspace_id && blob.purged_at.is_none()) - } else { - false - }; - let outputs = conn.detection_outputs(workspace_id, detection_id).await?; - Ok(crate::domain::output::DetectionArtifacts { - input, - intermediate_available, - redacted_outputs: outputs, - }) + Ok(conn + .cursor_list_detection_trees(workspace_id, pagination, filter) + .await?) } /// Finds a detection by id within a workspace, with the triggering account and diff --git a/crates/nvisy-server/src/handler/response/workspace_detections.rs b/crates/nvisy-server/src/handler/response/workspace_detections.rs index 15ce9adb..d16a3918 100644 --- a/crates/nvisy-server/src/handler/response/workspace_detections.rs +++ b/crates/nvisy-server/src/handler/response/workspace_detections.rs @@ -158,10 +158,13 @@ impl WorkspaceDetection { } } -/// File and intermediate metadata belonging to a single detection run. +/// A detection run with its artifacts: the input, the intermediate, and the +/// redacted outputs produced from it. #[derive(Debug, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] -pub struct WorkspaceDetectionArtifacts { +pub struct DetectionTree { + /// Detection run represented by this tree. + pub detection_id: DetectionId, /// Input document metadata, omitted if the document was deleted. #[serde(skip_serializing_if = "Option::is_none")] pub input: Option, @@ -204,13 +207,16 @@ pub struct DetectionArtifactOutput { pub size: i64, } -impl From for WorkspaceDetectionArtifacts { - fn from(artifacts: crate::domain::output::DetectionArtifacts) -> Self { +impl From for DetectionTree { + fn from(artifacts: nvisy_postgres::query::DetectionTreeRow) -> Self { Self { - input: artifacts.input.map(|document| DetectionArtifactInput { - id: document.id, - display_name: document.display_name, - }), + detection_id: DetectionId::from_uuid(artifacts.detection_id), + input: artifacts + .input_document_name + .map(|name| DetectionArtifactInput { + id: artifacts.input_document_id, + display_name: name, + }), intermediate: DetectionArtifactIntermediate { available: artifacts.intermediate_available, }, @@ -230,3 +236,6 @@ impl From for WorkspaceDetectionArtif } } } + +/// Detection trees paginated by detection, with complete children for each root. +pub type DetectionTreesPage = Page; diff --git a/crates/nvisy-server/src/handler/workspace_detections.rs b/crates/nvisy-server/src/handler/workspace_detections.rs index 533d0982..3d790a08 100644 --- a/crates/nvisy-server/src/handler/workspace_detections.rs +++ b/crates/nvisy-server/src/handler/workspace_detections.rs @@ -41,8 +41,8 @@ use crate::handler::request::{ WorkspaceDetectionPathParams, WorkspaceDetectionsQuery, }; use crate::handler::response::{ - WorkspaceDetection, WorkspaceDetectionArtifacts, WorkspaceDetectionSummary, - WorkspaceDetectionsPage, WorkspaceRedactionResult, + DetectionTreesPage, WorkspaceDetection, WorkspaceDetectionSummary, WorkspaceDetectionsPage, + WorkspaceRedactionResult, }; use crate::handler::utility::resolve_account_ref; use crate::response::{Error, ErrorKind, ErrorResponse, Result, SseResponse}; @@ -231,23 +231,25 @@ fn get_detection_docs(op: TransformOperation) -> TransformOperation { .response::<404, Json>() } -/// Returns metadata for the files and intermediate produced by a detection. +/// Lists a workspace's detection trees, with pagination on detections. #[tracing::instrument(skip_all)] -async fn get_detection_artifacts( +async fn list_detection_trees( State(detections): State, authz: Authorized, - Path(path_params): Path, -) -> Result> { - let artifacts = detections - .artifacts(authz.workspace.id, path_params.detection_id.as_uuid()) + Query(pagination): Query, + Query(query): Query, +) -> Result> { + let page = detections + .list_trees(authz.workspace.id, pagination.into_cursor(), &query.into()) .await?; - Ok(Json(artifacts.into())) + Ok(Json(DetectionTreesPage::from_cursor_page(page, Into::into))) } -fn get_detection_artifacts_docs(op: TransformOperation) -> TransformOperation { - op.summary("Get detection artifacts") - .description("Returns the input file, intermediate availability, and live redacted output file metadata for one detection. Deleted input files are omitted; deleted or reclaimed outputs are omitted. No file content is embedded.") - .response::<200, Json>() +fn list_detection_trees_docs(op: TransformOperation) -> TransformOperation { + op.summary("List detection trees") + .description("Lists detection trees newest first with the same cursor pagination and status, document, policy, account, and filename search filters as the detection list. Each root contains its detection ID, live input metadata, intermediate availability, and all live redacted outputs newest first. Deleted input files are omitted; deleted or reclaimed outputs are omitted. Totals count detections, not child files. No file content is embedded.") + .response::<200, Json>() + .response::<400, Json>() .response::<401, Json>() .response::<403, Json>() .response::<404, Json>() @@ -825,8 +827,8 @@ pub fn routes() -> ApiRouter { .patch_with(edit_detection, edit_detection_docs), ) .api_route( - "/workspaces/{workspaceId}/detections/{detectionId}/artifacts", - get_with(get_detection_artifacts, get_detection_artifacts_docs), + "/workspaces/{workspaceId}/detections/trees", + get_with(list_detection_trees, list_detection_trees_docs), ) .api_route( "/workspaces/{workspaceId}/detections/{detectionId}/events", diff --git a/crates/nvisy-server/tests/detection_trees.rs b/crates/nvisy-server/tests/detection_trees.rs new file mode 100644 index 00000000..ea1b09e2 --- /dev/null +++ b/crates/nvisy-server/tests/detection_trees.rs @@ -0,0 +1,30 @@ +//! HTTP contract and authorization for the workspace detection-tree listing. +#![cfg(feature = "test_util")] + +use nvisy_server::handler::response::DetectionTreesPage; +use nvisy_server::test_util::TestApp; + +#[tokio::test] +async fn detection_trees_is_a_workspace_scoped_page() { + let app = TestApp::spawn().await; + let (owner, workspace) = app.workspace_owner().await; + let path = format!("/workspaces/{workspace}/detections/trees?limit=1&includeCount=true"); + let response = app + .server() + .get(&path) + .authorization_bearer(&owner.jwt) + .await; + response.assert_status_ok(); + let page: DetectionTreesPage = response.json(); + assert_eq!(page.items.len(), 0); + assert_eq!(page.total, Some(0)); + assert!(page.next_cursor.is_none()); + + let outsider = app.actor().await; + app.server() + .get(&path) + .authorization_bearer(&outsider.jwt) + .await + .assert_status_forbidden(); + app.server().get(&path).await.assert_status_unauthorized(); +} diff --git a/docker/README.md b/docker/README.md index c0e592ad..35bcdba0 100644 --- a/docker/README.md +++ b/docker/README.md @@ -43,15 +43,21 @@ make generate-all # .env, keys, migrations cargo run --features dotenv --bin nvisy-cli ``` -#### Optional: NER (GLiNER2) - -The `ner` compose profile adds the GLiNER2 NER/PII service (`elide-ner`), which -the engine config reaches at `http://localhost:3200` by default. Its published -port binds to localhost only. `make run NER=1` brings it up and runs the server -with `engine.dev.toml`, substituting `ELIDE_NER_PORT` into a temporary config so -custom port settings stay in sync; the first start downloads the -model (~1.2 GB) into the `hf_cache` volume via a one-shot `ner-init` warmer. Set -`ELIDE_NER_MODEL`, `ELIDE_NER_PORT`, or `ELIDE_VERSION` to override the defaults. +#### Optional: inference services (NER, OCR, STT) + +Three bento inference services sit behind compose profiles, each published on a +localhost-only port: NER (`elide-ner`, GLiNER2 PII) on 3200, OCR (`elide-ocr`, +docTR) on 3201, and STT (`elide-stt`, faster-whisper) on 3202. `make run NER=1 +OCR=1 STT=1` (any subset) brings up the matching profiles and runs the server +with an engine config generated by `scripts/gen-engine-config.sh` from the enabled +services, so it always matches the running stack and its ports. + +Each image carries no weights: a one-shot `*-init` warmer downloads the model +into a cache volume (`hf_cache` for NER and STT, `doctr_cache` for OCR) on first +start, then the service runs non-root against the cache read-only. Override with +`ELIDE_{NER,OCR,STT}_MODEL`, `ELIDE_{NER,OCR,STT}_PORT`, or `ELIDE_VERSION`. STT +diarization is off by default (gated model); enable with `ELIDE_STT_DIARIZE=true` +and `ELIDE_STT_HF_TOKEN`. The API documentation is available at: diff --git a/docker/compose.yaml b/docker/compose.yaml index e9d30c14..141ea6e7 100644 --- a/docker/compose.yaml +++ b/docker/compose.yaml @@ -1,5 +1,29 @@ name: nvisy-dev +# Shared config for the inference services (NER, OCR, STT). Each is published on +# a loopback host port in the 32xx range — not the service's own 3000, and not +# the 31xx a standalone elide-runtime stack uses — so the two can coexist. The +# images carry no model weights: a one-shot `*-init` warmer downloads the model +# into a shared cache (as root, then hands it to the service uid) and exits; the +# service gates on it, then runs non-root with the cache read-only and the Hub +# offline, so request text never leaves the network. All three sit behind +# profiles, so `make run` skips them; `make run NER=1` brings them up. +x-inference: &inference + restart: unless-stopped + networks: + - nvisy-dev + +# The bentoml images ship no curl/wget, so readiness is probed with the +# `elide_core.readyz` module shipped in every image (rc.4+). The model loads in +# __init__, so readiness lags liveness; the start_period covers a cold load +# without flapping. +x-readyz: &readyz + test: ["CMD", "python", "-m", "elide_core.readyz"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 60s + services: postgres: image: postgres:18 @@ -96,18 +120,13 @@ services: networks: - nvisy-dev - # Warms the NER model into the shared Hugging Face cache and exits. The - # elide-ner image carries no weights, so this one-shot `elide_ner.warm` run - # downloads them first (as root, to populate the fresh named volume, which it - # hands to the uid the service runs as); the service gates on it completing. - # Not started by `make run`'s default set — bring NER up with the `ner` - # profile (`make run-ner`). + # GLiNER2 schema-driven NER/PII. The host-run server reaches it at + # localhost:3200 (ELIDE_NER_PORT); point the engine config's NER recognizer + # there. ner-init: image: ${ELIDE_NER_IMAGE:-ghcr.io/nvisycom/elide-ner}:${ELIDE_VERSION:-0.1.0-rc.6} container_name: nvisy-ner-init-dev profiles: ["ner"] - # The warmer is the one process that must reach the Hub; the service then - # runs offline against the cache it leaves behind. user: root environment: HF_HOME: /cache/huggingface @@ -120,17 +139,11 @@ services: networks: - nvisy-dev - # GLiNER2 schema-driven NER/PII service. The host-run server reaches it at - # localhost:3200 (ELIDE_NER_PORT); point the engine config's NER recognizer - # there. The host port is 3200, not the service's own 3000, so this coexists - # with a standalone elide-runtime stack (which publishes NER on 3100). The - # service runs as a non-root uid with the cache read-only and HF_HUB_OFFLINE - # on, so request text never reaches the Hub. ner: + <<: *inference image: ${ELIDE_NER_IMAGE:-ghcr.io/nvisycom/elide-ner}:${ELIDE_VERSION:-0.1.0-rc.6} container_name: nvisy-ner-dev profiles: ["ner"] - restart: unless-stopped ports: - "127.0.0.1:${ELIDE_NER_PORT:-3200}:3000" environment: @@ -143,32 +156,105 @@ services: LOG_LEVEL: ${ELIDE_LOG_LEVEL:-INFO} volumes: - hf_cache:/cache/huggingface:ro - # The bentoml image ships no curl/wget, so /readyz is probed with Python. - # The model loads in __init__, so readiness lags liveness; the long - # start_period covers a cold load without flapping. - healthcheck: - test: - - CMD - - python - - -c - - "import urllib.request; urllib.request.urlopen('http://localhost:3000/readyz', timeout=5)" - interval: 10s - timeout: 5s - retries: 5 - start_period: 120s + healthcheck: *readyz depends_on: ner-init: condition: service_completed_successfully + + # docTR OCR (image modality). Reached at localhost:3201 (ELIDE_OCR_PORT). + # docTR uses its own cache, not the Hugging Face Hub. + ocr-init: + image: ${ELIDE_OCR_IMAGE:-ghcr.io/nvisycom/elide-ocr}:${ELIDE_VERSION:-0.1.0-rc.6} + container_name: nvisy-ocr-init-dev + profiles: ["ocr"] + user: root + environment: + DOCTR_CACHE_DIR: /cache/doctr + ELIDE_BENTO_MODEL_NAME: ${ELIDE_OCR_MODEL:-db_resnet50+crnn_vgg16_bn} + entrypoint: ["python", "-m", "elide_ocr.warm"] + volumes: + - doctr_cache:/cache/doctr networks: - nvisy-dev + ocr: + <<: *inference + image: ${ELIDE_OCR_IMAGE:-ghcr.io/nvisycom/elide-ocr}:${ELIDE_VERSION:-0.1.0-rc.6} + container_name: nvisy-ocr-dev + profiles: ["ocr"] + ports: + - "127.0.0.1:${ELIDE_OCR_PORT:-3201}:3000" + environment: + DOCTR_CACHE_DIR: /cache/doctr + # Present and non-empty wins over the model id; an operator mounts weights + # over /models to override. + ELIDE_BENTO_MODEL_PATH: /models + ELIDE_BENTO_MODEL_NAME: ${ELIDE_OCR_MODEL:-db_resnet50+crnn_vgg16_bn} + LOG_LEVEL: ${ELIDE_LOG_LEVEL:-INFO} + volumes: + - doctr_cache:/cache/doctr:ro + healthcheck: *readyz + depends_on: + ocr-init: + condition: service_completed_successfully + + # faster-whisper speech-to-text (audio modality). Reached at localhost:3202 + # (ELIDE_STT_PORT). Diarization is off by default (its model is gated on the + # Hub); enable it with ELIDE_STT_DIARIZE=true and an ELIDE_STT_HF_TOKEN. + stt-init: + image: ${ELIDE_STT_IMAGE:-ghcr.io/nvisycom/elide-stt}:${ELIDE_VERSION:-0.1.0-rc.6} + container_name: nvisy-stt-init-dev + profiles: ["stt"] + user: root + environment: + HF_HOME: /cache/huggingface + ELIDE_BENTO_MODEL_NAME: ${ELIDE_STT_MODEL:-large-v3-turbo} + ELIDE_BENTO_STT_DIARIZE: ${ELIDE_STT_DIARIZE:-false} + ELIDE_BENTO_STT_DIARIZE_MODEL: ${ELIDE_STT_DIARIZE_MODEL:-pyannote/speaker-diarization-community-1} + ELIDE_BENTO_STT_HF_TOKEN: ${ELIDE_STT_HF_TOKEN:-} + entrypoint: ["python", "-m", "elide_stt.warm"] + volumes: + - hf_cache:/cache/huggingface + networks: + - nvisy-dev + + stt: + <<: *inference + image: ${ELIDE_STT_IMAGE:-ghcr.io/nvisycom/elide-stt}:${ELIDE_VERSION:-0.1.0-rc.6} + container_name: nvisy-stt-dev + profiles: ["stt"] + ports: + - "127.0.0.1:${ELIDE_STT_PORT:-3202}:3000" + environment: + HF_HOME: /cache/huggingface + ELIDE_BENTO_MODEL_PATH: /models + ELIDE_BENTO_MODEL_NAME: ${ELIDE_STT_MODEL:-large-v3-turbo} + ELIDE_BENTO_STT_DEVICE: ${ELIDE_STT_DEVICE:-cpu} + ELIDE_BENTO_STT_COMPUTE_TYPE: ${ELIDE_STT_COMPUTE_TYPE:-} + ELIDE_BENTO_STT_DIARIZE: ${ELIDE_STT_DIARIZE:-false} + ELIDE_BENTO_STT_DIARIZE_MODEL: ${ELIDE_STT_DIARIZE_MODEL:-pyannote/speaker-diarization-community-1} + ELIDE_BENTO_STT_HF_TOKEN: ${ELIDE_STT_HF_TOKEN:-} + LOG_LEVEL: ${ELIDE_LOG_LEVEL:-INFO} + volumes: + - hf_cache:/cache/huggingface:ro + # Transcription is long-running (up to 900s per request), so the probe gets + # a longer timeout than the other two. + healthcheck: + <<: *readyz + timeout: 10s + depends_on: + stt-init: + condition: service_completed_successfully + volumes: postgres_data: nats_data: rustfs_data: - # Hugging Face cache the NER init container populates and the service mounts - # read-only; keeps the model weights out of the image and off repeat pulls. + # Hugging Face cache for NER and STT; docTR keeps its own. The init containers + # populate them and the services mount them read-only, so model weights stay + # out of the images and off repeat pulls. hf_cache: + doctr_cache: networks: nvisy-dev: diff --git a/docker/engine.dev.toml b/docker/engine.dev.toml deleted file mode 100644 index 65d03e31..00000000 --- a/docker/engine.dev.toml +++ /dev/null @@ -1,16 +0,0 @@ -# Dev engine configuration: NER only. -# -# Point the server at this file with ENGINE_CONFIG_FILEPATH to run the GLiNER2 -# NER recognizer from the `ner` compose profile. The server runs on the host -# (`make run`), so it reaches the service at the published host port, not the -# compose hostname. See docker/engine.example.toml for the full lineup (LLM, -# OCR, STT). - -# GLiNER2 schema-driven NER/PII. Served by the `ner` service in -# docker/compose.yaml (profile `ner`), published on host port 3200 by default. -# `make run NER=1` substitutes ELIDE_NER_PORT when generating this config. -[[recognizers.ner]] -name = "gliner2-ner" -kind = "bento" -base_url = "http://localhost:${ELIDE_NER_PORT:-3200}" -model = "fastino/gliner2-privacy-filter-PII-multi" diff --git a/scripts/gen-engine-config.sh b/scripts/gen-engine-config.sh new file mode 100755 index 00000000..7a2c9da4 --- /dev/null +++ b/scripts/gen-engine-config.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Generates a dev engine configuration from the enabled inference services and +# prints it to stdout. `make run` calls this with NER/OCR/STT set to "1" for each +# service it started, so the engine config always matches the running stack and +# its resolved host ports. Nothing is emitted for a service that is off, so the +# server runs only what is up (and the built-in pattern recognizers always run). +set -euo pipefail + +ner_port="${ELIDE_NER_PORT:-3200}" +ocr_port="${ELIDE_OCR_PORT:-3201}" +stt_port="${ELIDE_STT_PORT:-3202}" +ner_model="${ELIDE_NER_MODEL:-fastino/gliner2-privacy-filter-PII-multi}" +ocr_model="${ELIDE_OCR_MODEL:-db_resnet50+crnn_vgg16_bn}" +stt_model="${ELIDE_STT_MODEL:-large-v3-turbo}" + +echo "# Generated by docker/gen-engine-config.sh for the running dev stack." +echo "# Regenerated on every \`make run\`; edits here are overwritten." + +if [ "${NER:-}" = "1" ]; then + cat <