All checks were successful
CI / Linux x86_64 (Forgejo) (push) Successful in 1m34s
Ollama-backed translation module — third real F∆I capability,
following text.extract and llm.chat. Used for the second leg of
a regulated document workflow (extract -> translate -> review
-> summarise).
Capability:
Inputs:
text : text (source text)
target_language : text (e.g. "German", "French")
source_language : text (optional hint; empty = auto)
endpoint : text (Ollama /api/chat URL)
model : text
api_key : text (optional)
Outputs:
translation : text
source_language : text (echo for audit)
target_language : text (echo for audit)
model_endpoint : text
model_name : text
model_digest : text (Ollama /api/show probe; empty
for non-Ollama or transient
failures)
Permissions: net to localhost / 127.0.0.1 / api.openai.com /
api.anthropic.com.
The translation prompt is conservative: a fixed system prompt
demands faithful translation with structure preservation and
emits no metadata, no quoting. The user prompt names the target
language and (when supplied) the source language. The LLM
client + digest-probe pattern is reused verbatim from llm-chat;
when a third module joins, the shared client moves into a small
crate.
14 host-side tests cover prompt-building, Ollama-shaped response
parsing, URL transform, digest extraction (top-level + nested),
end-to-end success, end-to-end probe-failure swallow, and
end-to-end skip-for-non-Ollama.
Wasm artifact verified to build with v1.0 fai:platform imports
baked in. Bootstrapped via 'fai new module text.translate'.
Signed-off-by: flemming-it <sf@flemming.it>
156 lines
4.8 KiB
Rust
156 lines
4.8 KiB
Rust
//! `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<Outputs, ModuleError> {
|
|
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<String> {
|
|
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<String, crate::llm::LlmError> {
|
|
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<String, crate::llm::LlmError> {
|
|
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"));
|
|
}
|
|
}
|