//! The `recall` tool: search game history — the campaign log and the //! transcript — by keyword. //! //! `execute` turns tool arguments into a searched `Outcome`. `render` //! turns that outcome into the lines the transcript shows. `call` runs //! the two in sequence and composes the tool result text. use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span, Text}; use serde_json::{Value, json}; use crate::campaign::{Campaign, RecallHit, Source, search}; use super::{Tool, ToolReply, Visibility}; const RECALL_MD: &str = include_str!("recall.md"); /// The magnifier shown at the front of a recall's line. const RECALL_MARKER: &str = "🔍"; /// What the transcript shows for a screened recall: no results named, /// just that the DM consulted the record. const SCREENED_LINE: &str = "🔍 the DM consults the record"; /// The `recall` tool: searches a campaign's history by keyword. pub struct RecallTool { campaign: Campaign, } impl RecallTool { /// Builds a recall tool that searches `campaign`. pub fn new(campaign: Campaign) -> Self { Self { campaign } } } /// A recall this tool ran: the keywords given, and what matched. #[derive(Debug)] struct Outcome { keywords: Vec, hits: Vec, } /// Turns tool arguments into a searched `Outcome`. /// /// `keywords` is required and must be a nonempty array of strings; /// `source` is optional but must be `campaign-log` or `transcript` when /// present. Any other key in `args` is ignored. fn execute(args: &Value, campaign: &Campaign) -> Result { let keywords = parse_keywords(args)?; let source = parse_source(args)?; let hits = search(campaign, &keywords, source)?; Ok(Outcome { keywords, hits }) } /// Reads `keywords` from `args`: a required, nonempty array of strings. fn parse_keywords(args: &Value) -> Result, String> { let keywords = match args.get("keywords") { None => { return Err( "`keywords` is required; give one or more keywords like `[\"goblin\"]`".to_string(), ); } Some(Value::Array(keywords)) => keywords, Some(other) => { return Err(format!( "`keywords` was `{other}`, but it must be an array of strings like `[\"goblin\"]`" )); } }; if keywords.is_empty() { return Err("`keywords` must not be empty; give at least one keyword".to_string()); } keywords .iter() .map(|keyword| match keyword { Value::String(keyword) => Ok(keyword.clone()), other => Err(format!( "`keywords` contains `{other}`, but every keyword must be a string" )), }) .collect() } /// Reads `source` from `args`: an optional source name. fn parse_source(args: &Value) -> Result, String> { match args.get("source") { None => Ok(None), Some(Value::String(source)) => Source::parse(source).map(Some), Some(other) => Err(format!( "`source` was `{other}`, but it must be a string like `campaign-log`" )), } } /// Renders an `Outcome` as the public and screened transcript lines. fn render(outcome: &Outcome) -> (Text<'static>, Text<'static>) { (public_line(outcome), screened_line()) } /// The public line: the keywords searched, and how much matched. fn public_line(outcome: &Outcome) -> Text<'static> { let query = outcome.keywords.join(" "); let found = match outcome.hits.len() { 0 => "no matches".to_string(), 1 => "1 match".to_string(), n => format!("{n} matches"), }; Text::from(Line::from(Span::raw(format!( "{RECALL_MARKER} recall \"{query}\" → {found}" )))) } /// The screened line: the DM consulted the record, with nothing else said. fn screened_line() -> Text<'static> { Text::from(Line::from(Span::styled(SCREENED_LINE, dim_style()))) } fn dim_style() -> Style { Style::new().add_modifier(Modifier::DIM) } /// The tool result text: every hit's time and source with its text, and, /// on zero hits, what to try next. fn for_model(outcome: &Outcome) -> String { if outcome.hits.is_empty() { return format!( "No entries matched \"{}\". Try a shorter keyword or a different word; a \ match must appear, case-insensitively, in an entry's time or text.", outcome.keywords.join(" ") ); } outcome .hits .iter() .map(|hit| format!("- {} [{}]: {}", hit.time, hit.source.name(), hit.text)) .collect::>() .join("\n") } impl Tool for RecallTool { fn name(&self) -> &'static str { "recall" } fn definition(&self) -> Value { json!({ "type": "function", "function": { "name": "recall", "description": RECALL_MD.trim_end(), "parameters": { "type": "object", "properties": { "keywords": { "type": "array", "items": { "type": "string" }, "description": "Keywords to search for; every keyword must hit for an entry to match, like [\"goblin\"] or [\"goblin\", \"ambush\"].", }, "source": { "type": "string", "enum": Source::VALUES, "description": "Restrict the search to the campaign log or the transcript. Leave out to search both.", }, }, "required": ["keywords"], }, }, }) } fn call(&mut self, args: &Value, _visibility: Visibility) -> Result { let outcome = execute(args, &self.campaign)?; let (public, screened) = render(&outcome); Ok(ToolReply { for_model: for_model(&outcome), public, screened, cap: Visibility::Public, }) } } #[cfg(test)] #[path = "recall_tests.rs"] mod tests;