Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions apps/springtaled/src/runtime/boot/bot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,9 @@ pub(super) async fn init_bot(

let bot = springtale_bot::BotBuilder::new()
.recipe_deployer(recipe_deployer)
// Plan 5.4 — chat reaches the platform verbs through the same
// runtime every other surface calls.
.runtime(runtime.clone())
.store(runtime.store.clone())
.registry(runtime.registry.clone())
.engine(runtime.engine.clone())
Expand Down
3 changes: 3 additions & 0 deletions crates/springtale-bot/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ async-trait = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
# Sentence templates ship as per-locale YAML (plan 5.4); already pinned
# at the workspace root for the recipe/template loaders.
serde_yaml = { workspace = true }
toml = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
Expand Down
2 changes: 2 additions & 0 deletions crates/springtale-bot/src/conversation/catalog/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
//! Recipe catalogue projected for the conversational engine.

pub mod platform;
pub mod snapshot;

pub use platform::platform_docs;
pub use snapshot::{CatalogSnapshot, IntentDoc, SlotKindTag, SlotSpec};
178 changes: 178 additions & 0 deletions crates/springtale-bot/src/conversation/catalog/platform.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
//! Platform verbs projected as intent documents (plan 5.4).
//!
//! The NLU already scores recipes as [`IntentDoc`]s; the platform verbs
//! become documents of the same shape, so "hold the research squad"
//! ranks against `formation.pause` exactly the way "morning weather"
//! ranks against a recipe. Two things differ from a recipe document:
//! the token bag is phrased from the per-locale sentence templates
//! rather than a recipe name, and the `{formation}` slot's gazetteer is
//! built from the live formation list at match time — never hard-coded.

use springtale_runtime::operations::platform::{PlatformVerb, platform_verbs};
use springtale_runtime::operations::recipes::types::{
FieldKind, FieldVisibility, InputField, RecipeCategory, SelectOption,
};

use super::snapshot::{IntentDoc, SlotKindTag, SlotSpec};
use crate::conversation::nlu::gazetteer::Gazetteer;
use crate::conversation::nlu::normalize::tokenize;
use crate::conversation::sentences;

/// Document id prefix, so a platform document can never collide with a
/// recipe id and `IntentDoc::platform_verb` is the only thing routing
/// reads.
pub const PLATFORM_PREFIX: &str = "platform:";

/// Build one [`IntentDoc`] per platform verb.
///
/// `formation_names` is the live roster read from the store at match
/// time; it fills the `{formation}` slot's gazetteer.
pub fn platform_docs(locale: &str, formation_names: &[String]) -> Vec<IntentDoc> {
let catalog = sentences::for_locale(locale);
platform_verbs()
.iter()
.map(|v| project_verb(v, catalog.phrases(v.name), formation_names))
.collect()
}

fn project_verb(verb: &PlatformVerb, phrases: &[String], formation_names: &[String]) -> IntentDoc {
// The verb's own words are the strongest signal; the sentence
// templates carry the synonyms ("pause", "hold", "stop for now").
let mut name_stems: Vec<String> = tokenize(&verb.name.replace(['.', '_'], " "))
.into_iter()
.map(|t| t.stem)
.collect();
for phrase in phrases {
// Slot markers are not words the user says.
let bare = strip_slots(phrase);
name_stems.extend(tokenize(&bare).into_iter().map(|t| t.stem));
}
name_stems.sort();
name_stems.dedup();

let desc_stems = tokenize(verb.description)
.into_iter()
.map(|t| t.stem)
.collect();

let mut slots = Vec::new();
if verb.takes_formation() {
slots.push(formation_slot(formation_names));
}

IntentDoc {
recipe_id: format!("{PLATFORM_PREFIX}{}", verb.name),
name: verb.name.to_owned(),
description: verb.description.to_owned(),
// Not a recipe category in any real sense — platform documents are
// routed by `platform_verb`, never by category.
category: RecipeCategory::Custom,
ai_required: false,
name_stems,
tag_stems: vec![verb.group.as_str().to_owned()],
desc_stems,
slots,
platform_verb: Some(verb.name),
}
}

/// `pause {formation}` → `pause`.
fn strip_slots(phrase: &str) -> String {
let mut out = String::with_capacity(phrase.len());
let mut depth = 0usize;
for ch in phrase.chars() {
match ch {
'{' => depth += 1,
'}' => depth = depth.saturating_sub(1),
c if depth == 0 => out.push(c),
_ => {}
}
}
out
}

/// The `{formation}` slot — a `Select` over the live formation names, so
/// the existing gazetteer extractor fills it from the sentence.
fn formation_slot(formation_names: &[String]) -> SlotSpec {
let options: Vec<SelectOption> = formation_names
.iter()
.map(|n| SelectOption {
value: n.clone(),
label: n.clone(),
})
.collect();
let gazetteer = Gazetteer::from_options(
options
.iter()
.map(|o| (o.value.clone(), o.label.clone()))
.collect::<Vec<_>>(),
);
SlotSpec {
field: InputField {
id: "formation".to_owned(),
label: "formation".to_owned(),
kind: FieldKind::Select { options },
visibility: FieldVisibility::Required,
default: None,
hint: None,
},
tag: SlotKindTag::Select,
gazetteer: Some(gazetteer),
}
}

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;

fn names() -> Vec<String> {
vec!["Research Squad".to_owned(), "Watchtower".to_owned()]
}

#[test]
fn test_one_document_per_platform_verb() {
let docs = platform_docs("en", &names());
assert_eq!(docs.len(), platform_verbs().len());
assert!(docs.iter().all(|d| d.platform_verb.is_some()));
}

#[test]
fn test_formation_slot_reads_the_live_names() {
let docs = platform_docs("en", &names());
let pause = docs
.iter()
.find(|d| d.platform_verb == Some("formation.pause"))
.expect("pause doc");
let slot = pause.slot("formation").expect("formation slot");
let g = slot.gazetteer.as_ref().expect("gazetteer");
assert!(g.match_in("hold the research squad please").is_some());
// A name that is not in the live roster does not match.
assert!(g.match_in("hold the kitchen brigade").is_none());
}

#[test]
fn test_synonyms_from_the_sentence_file_reach_the_token_bag() {
let docs = platform_docs("en", &names());
let pause = docs
.iter()
.find(|d| d.platform_verb == Some("formation.pause"))
.expect("pause doc");
assert!(pause.name_stems.iter().any(|s| s.starts_with("hold")));
}

/// The drum rule again, this time over what chat can *understand*.
#[test]
fn test_no_platform_document_assigns_work_to_a_member() {
for doc in platform_docs("en", &names()) {
assert!(!doc.name.contains("assign"), "{} assigns", doc.name);
assert!(
!doc.slots
.iter()
.any(|s| s.id() == "member" || s.id() == "agent"),
"{} takes a member slot",
doc.name
);
}
}
}
25 changes: 25 additions & 0 deletions crates/springtale-bot/src/conversation/catalog/snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ pub struct IntentDoc {
/// Stemmed tokens from the description.
pub desc_stems: Vec<String>,
pub slots: Vec<SlotSpec>,
/// Set on the documents built from `platform::platform_docs` — the
/// dotted platform verb this document stands for (plan 5.4). `None`
/// for recipes. Routing reads this and nothing else: a document with
/// a verb runs a command, a document without one starts a setup
/// frame.
pub platform_verb: Option<&'static str>,
}

impl IntentDoc {
Expand Down Expand Up @@ -137,6 +143,24 @@ impl CatalogSnapshot {
Self { intents }
}

/// The catalogue plus one document per platform verb (plan 5.4).
/// `formation_names` is the live roster, read at match time so the
/// `{formation}` slot list is never hard-coded.
pub fn build_with_platform(
recipes: Vec<Recipe>,
locale: &str,
formation_names: &[String],
) -> Self {
let mut intents: Vec<IntentDoc> = recipes.into_iter().map(project_recipe).collect();
intents.extend(super::platform::platform_docs(locale, formation_names));
Self { intents }
}

/// The document for a dotted platform verb, if it is in the snapshot.
pub fn find_verb(&self, verb: &str) -> Option<&IntentDoc> {
self.intents.iter().find(|d| d.platform_verb == Some(verb))
}

pub fn find(&self, recipe_id: &str) -> Option<&IntentDoc> {
self.intents.iter().find(|d| d.recipe_id == recipe_id)
}
Expand Down Expand Up @@ -189,6 +213,7 @@ fn project_recipe(r: Recipe) -> IntentDoc {
tag_stems,
desc_stems,
slots,
platform_verb: None,
}
}

Expand Down
60 changes: 60 additions & 0 deletions crates/springtale-bot/src/conversation/dispatch.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
//! Running a platform verb the NLU recognised (plan 5.4).
//!
//! The language understanding and the `/formation` command must not
//! drift apart, so a recognised verb is executed by the same builtin
//! handler the slash command hits — the sentence is turned into the
//! argument line and dispatched, nothing more.

use crate::conversation::catalog::IntentDoc;
use crate::conversation::dialogue::slots;
use crate::handler::registry::{HandlerContext, HandlerResult};
use crate::runtime::lifecycle::Bot;
use crate::state::session::SessionKey;

/// Build the argument line for a platform document from the utterance.
///
/// `formation.pause` + "hold the research squad" → `pause Research Squad`.
pub fn argument_line(doc: &IntentDoc, text: &str) -> Option<String> {
let verb = doc.platform_verb?;
let sub = verb.split_once('.').map(|(_, s)| s).unwrap_or(verb);
let mut line = String::from(sub);
for fill in slots::extract_all(text, doc) {
if fill.slot_id == "formation" {
line.push(' ');
line.push_str(&fill.display);
}
}
Some(line)
}

/// Execute a recognised platform verb through its builtin handler.
/// `None` when the document is not a platform verb, the handler is not
/// registered, or the sentence did not name what the verb needs.
pub async fn run(bot: &Bot, key: &SessionKey, doc: &IntentDoc, text: &str) -> Option<String> {
let verb = doc.platform_verb?;
let command = verb.split_once('.').map(|(c, _)| c).unwrap_or(verb);
let args = argument_line(doc, text)?;
// A verb that needs a formation but got none is not a match — fall
// through so the user gets the ordinary "which one?" path rather
// than a silently wrong target.
if doc.slot("formation").is_some() && args.split_whitespace().count() < 2 {
return None;
}
let handler = bot.handlers.get(command)?;
let ctx = HandlerContext {
user_id: key.user_id.clone(),
channel_id: key.channel_id.clone(),
source_connector: "chat".to_owned(),
store: bot.store.clone(),
registry: bot.registry.clone(),
engine: bot.engine.clone(),
capability_bridge: bot.capability_bridge.clone(),
sentinel: bot.sentinel.clone(),
formation_tier: None,
runtime: bot.runtime.clone(),
};
match handler.handle(&args, &ctx).await {
Ok(HandlerResult { response }) => Some(response),
Err(e) => Some(e.to_string()),
}
}
39 changes: 38 additions & 1 deletion crates/springtale-bot/src/conversation/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ pub async fn try_start(
let Some(doc) = catalog.find(&cand.recipe_id).cloned() else {
return Ok(None);
};
// A platform verb is run, not set up: no slot-filling frame,
// no deploy. Same handler the slash command reaches.
if doc.platform_verb.is_some() {
match super::dispatch::run(bot, key, &doc, text).await {
Some(reply) => return Ok(Some(reply)),
None => return Ok(None),
}
}
start_frame(&mut session, &doc, text, now)
}
IntentDecision::Ambiguous(cands) => {
Expand Down Expand Up @@ -123,7 +131,36 @@ pub(super) async fn build_catalog(bot: &Bot) -> Result<CatalogSnapshot, Conversa
let recipes =
springtale_runtime::operations::recipes::list_recipes(&*bot.store, RecipeFilter::default())
.await?;
Ok(CatalogSnapshot::build(recipes))
// Plan 5.4 — the platform verbs are documents too, and their
// `{formation}` slot list is the live roster read here, at match
// time, not a hard-coded list.
let formation_names = live_formation_names(bot).await;
Ok(CatalogSnapshot::build_with_platform(
recipes,
CHAT_LOCALE,
&formation_names,
))
}

/// The locale the chat sentence templates are read in. Only `en` is
/// translated today; the other seven files are stubs that fall back to
/// it (`conversation::sentences`).
const CHAT_LOCALE: &str = "en";

/// Formation names from the store, or none when this bot has no runtime
/// (headless / CLI / tests) — then the platform documents simply carry
/// an empty slot list and never win a match.
async fn live_formation_names(bot: &Bot) -> Vec<String> {
let Some(rt) = bot.runtime.as_ref() else {
return Vec::new();
};
match springtale_runtime::operations::formations::list_formations(rt).await {
Ok(list) => list.into_iter().map(|f| f.name).collect(),
Err(e) => {
tracing::warn!(error = %e, "formation roster unavailable for chat slots");
Vec::new()
}
}
}

/// Choose the right hand-off message: a security-framed one for secrets
Expand Down
2 changes: 2 additions & 0 deletions crates/springtale-bot/src/conversation/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ pub mod augment;
pub mod catalog;
pub mod deploy;
pub mod dialogue;
pub mod dispatch;
pub mod engine;
pub mod error;
pub mod nlg;
pub mod nlu;
pub mod sentences;

pub use deploy::{DeployError, RecipeDeployer, SharedDeployer};
pub use engine::{capability_reply, continue_active, try_start};
Expand Down
9 changes: 9 additions & 0 deletions crates/springtale-bot/src/conversation/sentences/ar.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# STUB — ar sentence templates for the platform verbs (plan 5.4).
#
# Only `en` is populated today. This file exists so the layout is the
# one the plan asks for (one file per locale the UI speaks) and so a
# translator has somewhere to write. `verbs` being empty means the ar
# chat falls back to the English phrasings; nothing breaks, but nothing
# is translated either.
locale: ar
verbs: {}
Loading
Loading