//! `text.translate` — Ollama-backed text translation. //! //! Sends `text` plus a target-language directive to an Ollama //! `/api/chat` endpoint and returns the translated text. Reports //! `model_endpoint`, `model_name`, and `model_digest` as audit //! fields, same as `llm.chat`. //! //! v0.1.0 targets Ollama only. Cloud-provider adapters (OpenAI, //! Anthropic) follow when a flow needs them. mod llm; use fai_module_sdk::prelude::*; const SYSTEM_PROMPT: &str = "You are a faithful translation engine. \ Translate the user's text into the requested target language. \ Preserve formatting, paragraph structure, named entities, and \ proper nouns. Emit only the translation — no preamble, no \ metadata, no quoting."; #[fai_module] pub fn invoke(_ctx: Context, inputs: Inputs) -> Result { let text = inputs.require_text("text")?.to_string(); let target_language = inputs.require_text("target_language")?.to_string(); let endpoint = inputs.require_text("endpoint")?.to_string(); let model = inputs.require_text("model")?.to_string(); let api_key = inputs .get("api_key") .and_then(payload_text) .unwrap_or_default(); let source_language = inputs .get("source_language") .and_then(payload_text) .unwrap_or_default(); let prompt = build_prompt(&source_language, &target_language, &text); let client = make_client(); let params = crate::llm::ChatParams { endpoint: &endpoint, model: &model, api_key: &api_key, system_prompt: SYSTEM_PROMPT, prompt: &prompt, }; let result = crate::llm::chat_with_identity(&client, ¶ms) .map_err(|e| ModuleError::internal(e.to_string()))?; Ok(Outputs::new() .with_text("translation", result.response) .with_text("source_language", source_language) .with_text("target_language", target_language) .with_text("model_endpoint", endpoint) .with_text("model_name", model) .with_text("model_digest", result.model_digest.unwrap_or_default())) } fn build_prompt(source: &str, target: &str, text: &str) -> String { if source.is_empty() { format!( "Translate the following text into {target}. Output only the translation:\n\n{text}" ) } else { format!( "Translate the following {source} text into {target}. Output only the translation:\n\n{text}" ) } } fn payload_text(p: &Payload) -> Option { match p { Payload::Text(s) => Some(s.clone()), _ => None, } } #[cfg(target_arch = "wasm32")] fn make_client() -> WakiClient { WakiClient } #[cfg(not(target_arch = "wasm32"))] fn make_client() -> HostStubClient { HostStubClient } #[cfg(not(target_arch = "wasm32"))] struct HostStubClient; #[cfg(not(target_arch = "wasm32"))] #[allow(dead_code)] impl crate::llm::LlmClient for HostStubClient { fn post_json( &self, _url: &str, _body: &str, _api_key: &str, ) -> Result { Err(crate::llm::LlmError::Http( "LLM 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 crate::llm::LlmClient for WakiClient { fn post_json( &self, url: &str, body: &str, api_key: &str, ) -> Result { let mut request = waki::Client::new() .post(url) .header("Content-Type", "application/json") .body(body.to_string()); if !api_key.is_empty() { request = request.header("Authorization", &format!("Bearer {api_key}")); } let response = request .send() .map_err(|e| crate::llm::LlmError::Http(e.to_string()))?; let status = response.status_code(); if !(200..300).contains(&status) { return Err(crate::llm::LlmError::Status(status)); } let bytes = response .body() .map_err(|e| crate::llm::LlmError::Http(e.to_string()))?; String::from_utf8(bytes).map_err(|e| crate::llm::LlmError::Decode(e.to_string())) } } #[cfg(test)] mod tests { #![allow(clippy::unwrap_used)] use super::*; #[test] fn build_prompt_includes_target_language() { let p = build_prompt("", "German", "Hello world"); assert!(p.contains("into German")); assert!(p.contains("Hello world")); assert!(p.contains("Output only")); } #[test] fn build_prompt_includes_source_when_supplied() { let p = build_prompt("English", "French", "Hello"); assert!(p.contains("English text into French")); } }