feat: initial text-summarize v0.1.0 (text.summarize@0.1.0)
All checks were successful
CI / Linux x86_64 (Forgejo) (push) Successful in 1m32s

Ollama-backed summarisation module — fourth real F∆I capability,
following text.extract / llm.chat / text.translate. Completes
the text-processing trio for compose-grade workflows
(extract -> translate -> summarise, or any subset).

Capability:
  Inputs:
    text       : text   (source text)
    style      : text   (e.g. "one paragraph", "three bullets";
                          defaults to "one paragraph")
    language   : text   (output language hint; empty = source)
    endpoint   : text   (Ollama /api/chat URL)
    model      : text
    api_key    : text   (optional)

  Outputs:
    summary        : text
    style          : text   (echo for audit)
    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.

System prompt is conservative: faithful, neutral, preserves
named entities, no editorialising, no metadata. Reuses the LLM
client + digest-probe pattern from llm-chat / text-translate;
the duplication is acceptable at four modules and will be
factored into a shared crate when a fifth lands.

15 host-side tests cover prompt-building (style + language +
empty-language), Ollama-shaped response parsing, URL transform,
digest extraction, end-to-end success / probe-failure / non-
Ollama paths.

Wasm artifact: 118 KB. Built with v1.0 fai:platform imports.
Bootstrapped via 'fai new module text.summarize'.

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-05-04 11:18:29 +02:00
commit 3422fe526d
11 changed files with 1445 additions and 0 deletions

161
src/lib.rs Normal file
View file

@ -0,0 +1,161 @@
//! `text.summarize` — Ollama-backed text summarisation.
//!
//! Sends `text` plus a length / style directive to an Ollama
//! `/api/chat` endpoint and returns a summary. Reports
//! `model_endpoint`, `model_name`, and `model_digest` as audit
//! fields, same as `llm.chat` and `text.translate`.
//!
//! v0.1.0 targets Ollama only.
mod llm;
use fai_module_sdk::prelude::*;
const SYSTEM_PROMPT: &str = "You are a careful summarisation assistant. \
Produce a faithful, neutral summary of the user's text in the user's language. \
Preserve named entities, numbers, and dates verbatim. Do not editorialise. \
Emit only the summary no preamble, no metadata.";
#[fai_module]
pub fn invoke(_ctx: Context, inputs: Inputs) -> Result<Outputs, ModuleError> {
let text = inputs.require_text("text")?.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 style = inputs
.get("style")
.and_then(payload_text)
.unwrap_or_else(|| "one paragraph".to_string());
let language = inputs
.get("language")
.and_then(payload_text)
.unwrap_or_default();
let prompt = build_prompt(&style, &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, &params)
.map_err(|e| ModuleError::internal(e.to_string()))?;
Ok(Outputs::new()
.with_text("summary", result.response)
.with_text("style", style)
.with_text("language", language)
.with_text("model_endpoint", endpoint)
.with_text("model_name", model)
.with_text("model_digest", result.model_digest.unwrap_or_default()))
}
fn build_prompt(style: &str, language: &str, text: &str) -> String {
let lang_clause = if language.is_empty() {
String::new()
} else {
format!(" in {language}")
};
format!(
"Summarise the following text as {style}{lang_clause}. Output only the summary:\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_uses_style() {
let p = build_prompt("three bullet points", "", "Some text");
assert!(p.contains("as three bullet points"));
assert!(p.contains("Some text"));
}
#[test]
fn build_prompt_includes_language_when_supplied() {
let p = build_prompt("one paragraph", "German", "x");
assert!(p.contains("in German"));
}
#[test]
fn build_prompt_omits_language_clause_when_empty() {
let p = build_prompt("one paragraph", "", "x");
assert!(!p.contains(" in "));
}
}

323
src/llm.rs Normal file
View file

@ -0,0 +1,323 @@
//! Ollama-shaped chat client.
//!
//! v0.1.0 targets the Ollama `/api/chat` endpoint. OpenAI- and
//! Anthropic-compatible adapters are deliberately deferred — each
//! has its own request/response shape that warrants its own crate
//! (or at least its own module here) once a flow needs it.
//!
//! All HTTP I/O lives behind a `LlmClient` trait so unit tests can
//! exercise prompt building, response parsing, and the digest
//! probe on the host without making real network calls.
use serde::Serialize;
#[allow(dead_code)]
#[derive(Debug, thiserror::Error)]
pub enum LlmError {
#[error("missing required input '{0}'")]
MissingInput(&'static str),
#[error("http error: {0}")]
Http(String),
#[error("non-success status: {0}")]
Status(u16),
#[error("response body could not be parsed as Ollama schema: {0}")]
Decode(String),
}
pub trait LlmClient {
fn post_json(&self, url: &str, body: &str, api_key: &str) -> Result<String, LlmError>;
}
#[derive(Debug, Clone)]
pub struct ChatParams<'a> {
pub endpoint: &'a str,
pub model: &'a str,
pub api_key: &'a str,
pub system_prompt: &'a str,
pub prompt: &'a str,
}
#[derive(Serialize)]
struct OllamaMessage<'a> {
role: &'a str,
content: &'a str,
}
#[derive(Serialize)]
struct OllamaRequest<'a> {
model: &'a str,
messages: Vec<OllamaMessage<'a>>,
stream: bool,
}
/// Build the request body for an Ollama `/api/chat` invocation.
/// The system prompt is omitted from the messages list when empty
/// so a deployment can use the model's built-in system prompt.
pub fn build_ollama_body(p: &ChatParams) -> String {
let mut messages = Vec::with_capacity(2);
if !p.system_prompt.is_empty() {
messages.push(OllamaMessage {
role: "system",
content: p.system_prompt,
});
}
messages.push(OllamaMessage {
role: "user",
content: p.prompt,
});
let req = OllamaRequest {
model: p.model,
messages,
stream: false,
};
serde_json::to_string(&req).unwrap_or_else(|_| String::from("{}"))
}
/// Extract the assistant's message text from an Ollama
/// /api/chat response.
pub fn extract_ollama_content(body: &str) -> Result<String, LlmError> {
let v: serde_json::Value =
serde_json::from_str(body).map_err(|e| LlmError::Decode(e.to_string()))?;
v.get("message")
.and_then(|m| m.get("content"))
.and_then(|c| c.as_str())
.map(|s| s.to_string())
.ok_or_else(|| LlmError::Decode("missing message.content".into()))
}
#[derive(Debug, Clone)]
pub struct ChatWithIdentity {
pub response: String,
pub model_digest: Option<String>,
}
/// Run an Ollama chat call AND probe the model digest. Probe is
/// best-effort — non-Ollama endpoints (no `/api/chat` suffix) and
/// transient failures yield `model_digest = None` rather than
/// failing the whole call.
pub fn chat_with_identity<C: LlmClient>(
client: &C,
p: &ChatParams,
) -> Result<ChatWithIdentity, LlmError> {
if p.endpoint.is_empty() {
return Err(LlmError::MissingInput("endpoint"));
}
if p.model.is_empty() {
return Err(LlmError::MissingInput("model"));
}
if p.prompt.is_empty() {
return Err(LlmError::MissingInput("prompt"));
}
let body = build_ollama_body(p);
let response_body = client.post_json(p.endpoint, &body, p.api_key)?;
let response = extract_ollama_content(&response_body)?;
let model_digest = probe_model_digest(client, p);
Ok(ChatWithIdentity {
response,
model_digest,
})
}
fn probe_model_digest<C: LlmClient>(client: &C, p: &ChatParams) -> Option<String> {
let show_url = derive_show_url(p.endpoint)?;
let body = serde_json::to_string(&serde_json::json!({ "name": p.model })).ok()?;
let response = client.post_json(&show_url, &body, p.api_key).ok()?;
extract_show_digest(&response)
}
pub fn derive_show_url(chat_endpoint: &str) -> Option<String> {
if chat_endpoint.ends_with("/api/chat") {
let head_len = chat_endpoint.len() - "/api/chat".len();
let mut url = String::with_capacity(head_len + "/api/show".len());
url.push_str(&chat_endpoint[..head_len]);
url.push_str("/api/show");
Some(url)
} else {
None
}
}
pub fn extract_show_digest(body: &str) -> Option<String> {
let v: serde_json::Value = serde_json::from_str(body).ok()?;
let candidates = [
v.get("digest"),
v.get("details").and_then(|d| d.get("digest")),
v.get("model_info").and_then(|d| d.get("digest")),
];
for cand in candidates {
if let Some(s) = cand.and_then(|v| v.as_str()) {
if !s.is_empty() {
return Some(s.to_string());
}
}
}
None
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use std::cell::RefCell;
struct MockClient {
responses: RefCell<Vec<Result<String, LlmError>>>,
}
impl MockClient {
fn new(responses: Vec<Result<String, LlmError>>) -> Self {
Self {
responses: RefCell::new(responses),
}
}
}
impl LlmClient for MockClient {
fn post_json(&self, _url: &str, _body: &str, _api_key: &str) -> Result<String, LlmError> {
self.responses
.borrow_mut()
.pop()
.unwrap_or(Err(LlmError::Http("no more mock responses".into())))
}
}
#[test]
fn ollama_body_includes_system_when_provided() {
let p = ChatParams {
endpoint: "http://x/api/chat",
model: "qwen",
api_key: "",
system_prompt: "be helpful",
prompt: "hello",
};
let body = build_ollama_body(&p);
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
let messages = v["messages"].as_array().unwrap();
assert_eq!(messages.len(), 2);
assert_eq!(messages[0]["role"], "system");
assert_eq!(messages[0]["content"], "be helpful");
assert_eq!(messages[1]["role"], "user");
assert_eq!(messages[1]["content"], "hello");
}
#[test]
fn ollama_body_omits_system_when_empty() {
let p = ChatParams {
endpoint: "http://x/api/chat",
model: "qwen",
api_key: "",
system_prompt: "",
prompt: "hello",
};
let body = build_ollama_body(&p);
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
let messages = v["messages"].as_array().unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0]["role"], "user");
}
#[test]
fn extract_content_pulls_message_content() {
let body = r#"{"message":{"role":"assistant","content":"hi there"},"done":true}"#;
assert_eq!(extract_ollama_content(body).unwrap(), "hi there");
}
#[test]
fn extract_content_errors_on_missing_field() {
let body = r#"{"done":true}"#;
assert!(matches!(
extract_ollama_content(body),
Err(LlmError::Decode(_))
));
}
#[test]
fn derive_show_url_swaps_chat_suffix() {
assert_eq!(
derive_show_url("http://localhost:11434/api/chat").as_deref(),
Some("http://localhost:11434/api/show")
);
}
#[test]
fn derive_show_url_returns_none_for_non_ollama() {
assert!(derive_show_url("https://api.openai.com/v1/chat/completions").is_none());
}
#[test]
fn extract_show_digest_finds_top_level() {
let body = r#"{"digest":"sha256:abc"}"#;
assert_eq!(extract_show_digest(body).as_deref(), Some("sha256:abc"));
}
#[test]
fn extract_show_digest_falls_back_to_nested() {
let body = r#"{"details":{"digest":"sha256:nested"}}"#;
assert_eq!(extract_show_digest(body).as_deref(), Some("sha256:nested"));
}
#[test]
fn chat_with_identity_records_digest_when_show_responds() {
let canned_chat = r#"{"message":{"content":"the response"},"done":true}"#.to_string();
let canned_show = r#"{"digest":"sha256:deadbeef"}"#.to_string();
let client = MockClient::new(vec![Ok(canned_show), Ok(canned_chat)]);
let p = ChatParams {
endpoint: "http://localhost:11434/api/chat",
model: "qwen",
api_key: "",
system_prompt: "",
prompt: "hi",
};
let result = chat_with_identity(&client, &p).unwrap();
assert_eq!(result.response, "the response");
assert_eq!(result.model_digest.as_deref(), Some("sha256:deadbeef"));
}
#[test]
fn chat_with_identity_swallows_show_failure() {
let canned_chat = r#"{"message":{"content":"ok"}}"#.to_string();
let client = MockClient::new(vec![Err(LlmError::Status(500)), Ok(canned_chat)]);
let p = ChatParams {
endpoint: "http://localhost:11434/api/chat",
model: "qwen",
api_key: "",
system_prompt: "",
prompt: "hi",
};
let result = chat_with_identity(&client, &p).unwrap();
assert_eq!(result.response, "ok");
assert_eq!(result.model_digest, None);
}
#[test]
fn chat_with_identity_skips_probe_for_non_ollama() {
let canned_chat = r#"{"message":{"content":"ok"}}"#.to_string();
let client = MockClient::new(vec![Ok(canned_chat)]);
let p = ChatParams {
endpoint: "https://api.openai.com/v1/chat/completions",
model: "gpt",
api_key: "k",
system_prompt: "",
prompt: "hi",
};
let result = chat_with_identity(&client, &p).unwrap();
assert_eq!(result.response, "ok");
assert_eq!(result.model_digest, None);
}
#[test]
fn empty_prompt_is_rejected() {
let client = MockClient::new(vec![]);
let p = ChatParams {
endpoint: "http://x/api/chat",
model: "x",
api_key: "",
system_prompt: "",
prompt: "",
};
assert!(matches!(
chat_with_identity(&client, &p),
Err(LlmError::MissingInput("prompt"))
));
}
}