//! `text.anonymize.legal-de` — NER anonymization of German legal //! texts, bridged to the `judge-ner` host service. //! //! The heavy lifting (JuraNER transformers model, DOCX rewriting) //! happens in the judge-ner container — see //! `fai_judge/host_services/judge-ner/`. This module forwards the //! document over loopback HTTP and maps the response onto the frozen //! capability contract: //! //! inputs: `request` (json) {"mode":"text"|"docx","options":{...}} //! `document` (bytes) UTF-8 text or DOCX //! `endpoint` (text, optional) service base URL override //! outputs: `result` (json) {"replacements":[...], "text" only for //! mode=text} //! `document` (bytes) anonymized text/DOCX mod anonymize; pub use anonymize::DEFAULT_ENDPOINT; use chain_module_sdk::prelude::*; use crate::anonymize::{AnonError, HttpClient, HttpResponse}; #[fai_module] pub fn invoke(_ctx: Context, inputs: Inputs) -> Result { let request_raw = read_json_or_text(&inputs, "request")?; let request = anonymize::parse_request(&request_raw).map_err(to_module_error)?; let document = inputs.require_bytes("document")?; let endpoint = inputs .get("endpoint") .and_then(payload_text) .unwrap_or_default(); let client = make_client(); let outcome = anonymize::run(&client, &request, &document.data, &endpoint) .map_err(to_module_error)?; Ok(Outputs::new() .with_json_str("result", outcome.result_json) .with_bytes("document", outcome.document_mime, outcome.document)) } fn to_module_error(err: AnonError) -> ModuleError { match err { AnonError::InvalidInput(msg) => ModuleError::invalid_input(msg), AnonError::Service(msg) => ModuleError::internal(msg), } } fn payload_text(p: &Payload) -> Option { match p { Payload::Text(s) => Some(s.clone()), _ => None, } } /// Accept the request payload either as `json` (the documented form) /// or `text` (the form gRPC clients without first-class JSON-payload /// support fall through to). Both carry the same JSON string. fn read_json_or_text(inputs: &Inputs, name: &str) -> Result { if let Ok(s) = inputs.require_json_str(name) { return Ok(s.to_string()); } if let Ok(s) = inputs.require_text(name) { return Ok(s.to_string()); } Err(ModuleError::invalid_input(format!( "input '{name}' must be json or text (carrying a JSON string)" ))) } #[cfg(target_arch = "wasm32")] fn make_client() -> WakiClient { WakiClient } #[cfg(not(target_arch = "wasm32"))] fn make_client() -> HostStubClient { HostStubClient } /// Host builds (unit tests, `cargo test`) have no outbound HTTP — /// only the wasm32 build talks to the service. #[cfg(not(target_arch = "wasm32"))] struct HostStubClient; #[cfg(not(target_arch = "wasm32"))] #[allow(dead_code)] impl HttpClient for HostStubClient { fn post_json(&self, _url: &str, _body: &str) -> Result { Err(AnonError::Service( "HTTP path is unavailable on the host build; only wasm32 supports outbound HTTP" .to_string(), )) } } #[cfg(target_arch = "wasm32")] struct WakiClient; #[cfg(target_arch = "wasm32")] impl HttpClient for WakiClient { fn post_json(&self, url: &str, body: &str) -> Result { let response = waki::Client::new() .post(url) .header("Content-Type", "application/json") .body(body.to_string()) .send() .map_err(|e| { AnonError::Service(format!( "judge-ner service unreachable at {url}: {e} \ (is the judge-ner container running?)" )) })?; let status = response.status_code(); let bytes = response .body() .map_err(|e| AnonError::Service(format!("read response body: {e}")))?; let body = String::from_utf8(bytes) .map_err(|e| AnonError::Service(format!("response body is not UTF-8: {e}")))?; Ok(HttpResponse { status, body }) } }