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
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:
commit
3422fe526d
11 changed files with 1445 additions and 0 deletions
161
src/lib.rs
Normal file
161
src/lib.rs
Normal 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, ¶ms)
|
||||
.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
323
src/llm.rs
Normal 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"))
|
||||
));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue