use std::collections::HashSet;
use roboticus_llm::semantic_classifier::SemanticClassifier;
pub(super) const INTENT_THRESHOLD: f64 = 0.60;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(super) enum Intent {
Execution,
TaskManagement,
Delegation,
Cron,
FileDistribution,
FolderScan,
RandomToolUse,
ModelIdentity,
CurrentEvents,
Introspection,
Acknowledgement,
ProviderInventory,
PersonalityProfile,
CapabilitySummary,
WalletAddressScan,
ImageCountScan,
MarkdownCountScan,
ObsidianInsights,
EmailTriage,
LiteraryQuoteContext,
Contradiction,
ShortFollowup,
ReactiveSarcasm,
}
impl Intent {
#[deprecated(
note = "Use pre-classified intents from ctx.intents or classify_semantic() instead"
)]
pub fn matches(self, prompt: &str) -> bool {
let lower = prompt.to_ascii_lowercase();
match self {
Self::ObsidianInsights => {
(lower.contains("obsidian") || lower.contains("vault"))
&& (lower.contains("insight")
|| lower.contains("summary")
|| lower.contains("what")
|| lower.contains("say about"))
}
Self::CurrentEvents => {
lower.contains("geopolit")
|| lower.contains("sitrep")
|| lower.contains("current events")
|| lower.contains("latest news")
|| lower.contains("what's happening")
}
Self::CapabilitySummary => {
lower.contains("what are you able to do")
|| lower.contains("what can you do")
|| lower.contains("what can you help")
}
Self::PersonalityProfile => {
(lower.contains("personality") && lower.contains("you"))
|| lower.contains("who are you")
}
Self::ProviderInventory => {
lower.contains("llm provider")
|| lower.contains("which provider")
|| lower.contains("what provider")
}
other => {
let registry = IntentRegistry::default_registry();
registry.classify(prompt).contains(&other)
}
}
}
fn from_category(name: &str) -> Option<Self> {
use roboticus_llm::intent_exemplars::*;
match name {
CAT_EXECUTION => Some(Self::Execution),
CAT_TASK_MANAGEMENT => Some(Self::TaskManagement),
CAT_DELEGATION => Some(Self::Delegation),
CAT_CRON => Some(Self::Cron),
CAT_FILE_DISTRIBUTION => Some(Self::FileDistribution),
CAT_FOLDER_SCAN => Some(Self::FolderScan),
CAT_RANDOM_TOOL_USE => Some(Self::RandomToolUse),
CAT_MODEL_IDENTITY => Some(Self::ModelIdentity),
CAT_CURRENT_EVENTS => Some(Self::CurrentEvents),
CAT_INTROSPECTION => Some(Self::Introspection),
CAT_ACKNOWLEDGEMENT => Some(Self::Acknowledgement),
CAT_PROVIDER_INVENTORY => Some(Self::ProviderInventory),
CAT_PERSONALITY_PROFILE => Some(Self::PersonalityProfile),
CAT_CAPABILITY_SUMMARY => Some(Self::CapabilitySummary),
CAT_WALLET_ADDRESS_SCAN => Some(Self::WalletAddressScan),
CAT_IMAGE_COUNT_SCAN => Some(Self::ImageCountScan),
CAT_MARKDOWN_COUNT_SCAN => Some(Self::MarkdownCountScan),
CAT_OBSIDIAN_INSIGHTS => Some(Self::ObsidianInsights),
CAT_EMAIL_TRIAGE => Some(Self::EmailTriage),
CAT_LITERARY_QUOTE_CONTEXT => Some(Self::LiteraryQuoteContext),
_ => None,
}
}
}
pub(super) enum IntentMatcher {
Syntactic(fn(&str) -> bool),
Semantic,
}
pub(super) struct IntentDescriptor {
pub intent: Intent,
pub priority: u8,
pub bypasses_cache: bool,
pub matcher: IntentMatcher,
}
pub(super) struct IntentRegistry {
descriptors: Vec<IntentDescriptor>,
}
impl IntentRegistry {
pub fn default_registry() -> Self {
Self {
descriptors: vec![
IntentDescriptor {
intent: Intent::Execution,
priority: 10,
bypasses_cache: true,
matcher: IntentMatcher::Semantic,
},
IntentDescriptor {
intent: Intent::FileDistribution,
priority: 37,
bypasses_cache: true,
matcher: IntentMatcher::Semantic,
},
IntentDescriptor {
intent: Intent::ModelIdentity,
priority: 80,
bypasses_cache: false,
matcher: IntentMatcher::Semantic,
},
IntentDescriptor {
intent: Intent::CurrentEvents,
priority: 65,
bypasses_cache: true,
matcher: IntentMatcher::Semantic,
},
IntentDescriptor {
intent: Intent::Introspection,
priority: 60,
bypasses_cache: true,
matcher: IntentMatcher::Semantic,
},
IntentDescriptor {
intent: Intent::ProviderInventory,
priority: 75,
bypasses_cache: true,
matcher: IntentMatcher::Semantic,
},
IntentDescriptor {
intent: Intent::CapabilitySummary,
priority: 71,
bypasses_cache: true,
matcher: IntentMatcher::Semantic,
},
IntentDescriptor {
intent: Intent::FolderScan,
priority: 39,
bypasses_cache: true,
matcher: IntentMatcher::Semantic,
},
IntentDescriptor {
intent: Intent::Acknowledgement,
priority: 85,
bypasses_cache: true,
matcher: IntentMatcher::Semantic,
},
IntentDescriptor {
intent: Intent::WalletAddressScan,
priority: 45,
bypasses_cache: true,
matcher: IntentMatcher::Semantic,
},
IntentDescriptor {
intent: Intent::ImageCountScan,
priority: 43,
bypasses_cache: true,
matcher: IntentMatcher::Semantic,
},
IntentDescriptor {
intent: Intent::MarkdownCountScan,
priority: 41,
bypasses_cache: true,
matcher: IntentMatcher::Semantic,
},
IntentDescriptor {
intent: Intent::ObsidianInsights,
priority: 35,
bypasses_cache: true,
matcher: IntentMatcher::Semantic,
},
IntentDescriptor {
intent: Intent::Delegation,
priority: 55,
bypasses_cache: false,
matcher: IntentMatcher::Semantic,
},
IntentDescriptor {
intent: Intent::Cron,
priority: 53,
bypasses_cache: false,
matcher: IntentMatcher::Semantic,
},
IntentDescriptor {
intent: Intent::RandomToolUse,
priority: 50,
bypasses_cache: true,
matcher: IntentMatcher::Semantic,
},
IntentDescriptor {
intent: Intent::PersonalityProfile,
priority: 73,
bypasses_cache: true,
matcher: IntentMatcher::Semantic,
},
IntentDescriptor {
intent: Intent::EmailTriage,
priority: 63,
bypasses_cache: true,
matcher: IntentMatcher::Semantic,
},
IntentDescriptor {
intent: Intent::LiteraryQuoteContext,
priority: 30,
bypasses_cache: true,
matcher: IntentMatcher::Semantic,
},
IntentDescriptor {
intent: Intent::Contradiction,
priority: 95,
bypasses_cache: false,
matcher: IntentMatcher::Syntactic(match_contradiction),
},
IntentDescriptor {
intent: Intent::ShortFollowup,
priority: 93,
bypasses_cache: false,
matcher: IntentMatcher::Syntactic(match_short_followup),
},
IntentDescriptor {
intent: Intent::ReactiveSarcasm,
priority: 91,
bypasses_cache: false,
matcher: IntentMatcher::Syntactic(match_reactive_sarcasm),
},
],
}
}
pub fn classify(&self, prompt: &str) -> Vec<Intent> {
let lower = prompt.to_ascii_lowercase();
let mut matches: Vec<(Intent, u8)> = self
.descriptors
.iter()
.filter(|d| {
if let IntentMatcher::Syntactic(f) = d.matcher {
f(&lower)
} else {
false
}
})
.map(|d| (d.intent, d.priority))
.collect();
matches.sort_by(|a, b| b.1.cmp(&a.1));
matches.into_iter().map(|(intent, _)| intent).collect()
}
pub async fn classify_semantic(
&self,
prompt: &str,
classifier: &SemanticClassifier,
threshold: f64,
) -> Vec<Intent> {
use roboticus_llm::intent_exemplars::INTENT_EXEMPLARS;
let syntactic = self.classify(prompt);
let semantic_results = match classifier
.classify(prompt, INTENT_EXEMPLARS, threshold, None)
.await
{
Ok(results) => results,
Err(e) => {
tracing::warn!(error = %e, "semantic intent classification failed; falling back to syntactic only");
return syntactic;
}
};
let priority_map: std::collections::HashMap<Intent, u8> = self
.descriptors
.iter()
.map(|d| (d.intent, d.priority))
.collect();
let mut combined: Vec<(Intent, u8)> = Vec::new();
for result in &semantic_results {
if let Some(intent) = Intent::from_category(&result.category) {
let priority = priority_map.get(&intent).copied().unwrap_or(0);
tracing::debug!(
intent = %result.category,
score = result.score,
trust = ?result.trust,
source = "semantic",
"intent classification"
);
combined.push((intent, priority));
}
}
for intent in &syntactic {
let priority = priority_map.get(intent).copied().unwrap_or(0);
tracing::debug!(
intent = ?intent,
source = "syntactic",
"intent classification"
);
if !combined.iter().any(|(i, _)| i == intent) {
combined.push((*intent, priority));
}
}
if is_affirmative_continuation(prompt) {
combined.retain(|(intent, _)| {
!matches!(
intent,
Intent::Execution
| Intent::FileDistribution
| Intent::FolderScan
| Intent::WalletAddressScan
| Intent::ImageCountScan
| Intent::MarkdownCountScan
| Intent::ObsidianInsights
| Intent::EmailTriage
)
});
}
combined.sort_by(|a, b| b.1.cmp(&a.1));
combined.into_iter().map(|(intent, _)| intent).collect()
}
}
fn is_affirmative_continuation(prompt: &str) -> bool {
let affirmatives = [
"awesome",
"great",
"perfect",
"cool",
"nice",
"sounds good",
"sounds great",
"let's do it",
"do it",
"go for it",
"go ahead",
"proceed",
"yes",
"yes please",
"yep",
"yeah",
"ok",
"okay",
"sure",
"absolutely",
"agreed",
"exactly",
"love it",
];
let lower = prompt.trim().to_ascii_lowercase();
if affirmatives
.iter()
.any(|a| lower == *a || lower == format!("{a}.") || lower == format!("{a}!"))
{
return true;
}
if let Some(last_sentence) = lower.rsplit_terminator(['.', '!', '?']).next() {
let trimmed = last_sentence.trim();
if !trimmed.is_empty() && affirmatives.contains(&trimmed) {
return true;
}
}
if let Some(last_word) = lower.split_whitespace().last() {
let clean = last_word.trim_matches(|c: char| c.is_ascii_punctuation());
if affirmatives.contains(&clean) && lower.split_whitespace().count() > 2 {
return true;
}
}
false
}
impl IntentRegistry {
pub fn should_bypass_cache(&self, intents: &[Intent]) -> bool {
let set: HashSet<Intent> = intents.iter().copied().collect();
self.descriptors
.iter()
.any(|d| d.bypasses_cache && set.contains(&d.intent))
}
}
fn match_contradiction(lower: &str) -> bool {
let trimmed = lower.trim();
if trimmed.len() > 48 {
return false;
}
const MARKERS: &[&str] = &[
"that's not true",
"that is not true",
"not true",
"that's wrong",
"that is wrong",
"incorrect",
];
MARKERS.iter().any(|m| trimmed.contains(m))
}
fn match_short_followup(lower: &str) -> bool {
let trimmed = lower.trim();
if trimmed.len() > 80 {
return false;
}
const MARKERS: &[&str] = &[
"what's that from",
"what is that from",
"where is that from",
"no, your quote",
"your quote",
"what quote",
"source?",
];
MARKERS.iter().any(|m| trimmed.contains(m))
}
fn match_reactive_sarcasm(lower: &str) -> bool {
let trimmed = lower.trim();
if trimmed.len() > 32 {
return false;
}
const MARKERS: &[&str] = &[
"wow",
"great",
"fantastic",
"amazing",
"incredible",
"brilliant",
"sure",
"right",
];
MARKERS.iter().any(|m| {
trimmed == *m || {
let stripped = trimmed
.strip_suffix("...")
.or_else(|| trimmed.strip_suffix('.'));
stripped.is_some_and(|s| s == *m)
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use roboticus_llm::embedding::EmbeddingClient;
fn make_classifier() -> SemanticClassifier {
SemanticClassifier::new(EmbeddingClient::new(None).unwrap())
}
async fn classify(prompt: &str) -> Vec<Intent> {
let classifier = make_classifier();
IntentRegistry::default_registry()
.classify_semantic(prompt, &classifier, 0.50)
.await
}
async fn has(prompt: &str, intent: Intent) -> bool {
classify(prompt).await.contains(&intent)
}
fn bypass(intents: &[Intent]) -> bool {
IntentRegistry::default_registry().should_bypass_cache(intents)
}
#[tokio::test]
async fn execution_markers_cover_shortcut_and_guard_triggers() {
assert!(has("use a tool to accomplish this task", Intent::Execution).await);
assert!(has("run the command and show me the output", Intent::Execution).await);
}
#[tokio::test]
async fn delegation_and_cron_markers_match_expected_prompts() {
assert!(has("assign this work to a subagent", Intent::Delegation).await);
assert!(has("schedule a cron job for this", Intent::Cron).await);
}
#[tokio::test]
async fn model_identity_markers_match_expected_prompts() {
assert!(has("what model are you using right now", Intent::ModelIdentity,).await);
assert!(has("which AI model is this", Intent::ModelIdentity).await);
}
#[tokio::test]
async fn current_events_markers_match_expected_prompts() {
assert!(
has(
"give me a geopolitical update on world events",
Intent::CurrentEvents,
)
.await
);
assert!(has("what's happening in the world today", Intent::CurrentEvents,).await);
}
#[tokio::test]
async fn introspection_markers_match_expected_prompts() {
assert!(has("what tools do you have available", Intent::Introspection,).await);
assert!(
has(
"list the tools you can currently use",
Intent::Introspection,
)
.await
);
}
#[tokio::test]
async fn email_triage_markers_match_expected_prompts() {
assert!(has("check my email inbox", Intent::EmailTriage,).await);
assert!(has("triage my unread messages", Intent::EmailTriage,).await);
}
#[tokio::test]
async fn literary_quote_markers_match_expected_prompts() {
assert!(
has(
"give me a Dune quote for this conflict",
Intent::LiteraryQuoteContext,
)
.await
);
}
#[tokio::test]
async fn acknowledgement_markers_match_expected_prompts() {
assert!(
has(
"acknowledge this and wait for my next command",
Intent::Acknowledgement,
)
.await
);
}
#[tokio::test]
async fn provider_inventory_markers_match_expected_prompts() {
assert!(has("which LLM providers do you have", Intent::ProviderInventory).await);
assert!(
has(
"what AI providers are configured",
Intent::ProviderInventory,
)
.await
);
}
#[tokio::test]
async fn personality_and_capability_markers_match_expected_prompts() {
assert!(has("tell me about your personality", Intent::PersonalityProfile,).await);
assert!(has("who are you", Intent::PersonalityProfile).await);
assert!(has("what are you able to do", Intent::CapabilitySummary,).await);
}
#[tokio::test]
async fn wallet_scan_markers_match_expected_prompts() {
assert!(
has(
"find wallet addresses in my files",
Intent::WalletAddressScan,
)
.await
);
}
#[tokio::test]
async fn image_count_markers_match_expected_prompts() {
assert!(has("how many images are in this folder", Intent::ImageCountScan,).await);
assert!(has("count the photos", Intent::ImageCountScan,).await);
}
#[tokio::test]
async fn markdown_count_markers_match_expected_prompts() {
assert!(
has(
"how many markdown files do I have",
Intent::MarkdownCountScan,
)
.await
);
assert!(
has(
"count all .md and .markdown files",
Intent::MarkdownCountScan,
)
.await
);
}
#[tokio::test]
async fn folder_scan_markers_match_expected_prompts() {
assert!(has("look in my downloads folder", Intent::FolderScan,).await);
assert!(has("scan the documents directory", Intent::FolderScan,).await);
}
#[tokio::test]
async fn obsidian_insight_markers_match_expected_prompts() {
assert!(has("summarize my Obsidian vault", Intent::ObsidianInsights,).await);
}
#[tokio::test]
async fn cache_bypass_markers_cover_shortcut_handled_prompts() {
let intents = classify("use a tool to accomplish this task").await;
assert!(bypass(&intents));
let intents2 = classify("what's happening in the world today").await;
assert!(bypass(&intents2));
assert!(!bypass(&[]));
}
#[test]
fn acknowledgement_has_higher_priority_than_current_events_in_descriptor() {
let reg = IntentRegistry::default_registry();
let ack = reg
.descriptors
.iter()
.find(|d| d.intent == Intent::Acknowledgement)
.unwrap()
.priority;
let ce = reg
.descriptors
.iter()
.find(|d| d.intent == Intent::CurrentEvents)
.unwrap()
.priority;
assert!(
ack > ce,
"Acknowledgement (priority {ack}) must exceed CurrentEvents (priority {ce})"
);
}
#[test]
fn model_identity_higher_priority_than_execution() {
let reg = IntentRegistry::default_registry();
let mi = reg
.descriptors
.iter()
.find(|d| d.intent == Intent::ModelIdentity)
.unwrap()
.priority;
let ex = reg
.descriptors
.iter()
.find(|d| d.intent == Intent::Execution)
.unwrap()
.priority;
assert!(
mi > ex,
"ModelIdentity (priority {mi}) should exceed Execution (priority {ex})"
);
}
#[test]
fn contradiction_matches_short_prompts() {
let reg = IntentRegistry::default_registry();
assert!(
reg.classify("that's not true")
.contains(&Intent::Contradiction)
);
assert!(
reg.classify("That is wrong.")
.contains(&Intent::Contradiction)
);
assert!(reg.classify("incorrect").contains(&Intent::Contradiction));
assert!(
!reg.classify("I think that is not true based on extensive research and evidence")
.contains(&Intent::Contradiction)
);
}
#[test]
fn short_followup_matches_quote_references() {
let reg = IntentRegistry::default_registry();
assert!(
reg.classify("what's that from?")
.contains(&Intent::ShortFollowup)
);
assert!(
reg.classify("Where is that from?")
.contains(&Intent::ShortFollowup)
);
assert!(reg.classify("source?").contains(&Intent::ShortFollowup));
let long = format!(
"What's that from? I need to know because {}",
"a".repeat(80)
);
assert!(!reg.classify(&long).contains(&Intent::ShortFollowup));
}
#[test]
fn reactive_sarcasm_matches_exact_and_suffixed() {
let reg = IntentRegistry::default_registry();
assert!(reg.classify("wow").contains(&Intent::ReactiveSarcasm));
assert!(reg.classify("Great.").contains(&Intent::ReactiveSarcasm));
assert!(
reg.classify("fantastic...")
.contains(&Intent::ReactiveSarcasm)
);
assert!(reg.classify(" sure ").contains(&Intent::ReactiveSarcasm));
assert!(
!reg.classify("wow that was great")
.contains(&Intent::ReactiveSarcasm)
);
assert!(
!reg.classify("wow this is incredibly amazing work")
.contains(&Intent::ReactiveSarcasm)
);
}
#[test]
fn channel_intents_have_highest_priorities() {
let reg = IntentRegistry::default_registry();
let channel_priorities: Vec<u8> = reg
.descriptors
.iter()
.filter(|d| {
matches!(
d.intent,
Intent::Contradiction | Intent::ShortFollowup | Intent::ReactiveSarcasm
)
})
.map(|d| d.priority)
.collect();
let max_standard: u8 = reg
.descriptors
.iter()
.filter(|d| {
!matches!(
d.intent,
Intent::Contradiction | Intent::ShortFollowup | Intent::ReactiveSarcasm
)
})
.map(|d| d.priority)
.max()
.unwrap_or(0);
assert!(
channel_priorities.iter().all(|&p| p > max_standard),
"Channel intents must have higher priority than all standard intents"
);
}
#[test]
fn classify_lowercases_once_for_syntactic_intents() {
let reg = IntentRegistry::default_registry();
assert!(reg.classify("INCORRECT").contains(&Intent::Contradiction));
assert!(reg.classify("WOW").contains(&Intent::ReactiveSarcasm));
}
#[tokio::test]
async fn bashar_does_not_match_execution() {
assert!(!has("bashar", Intent::Execution).await);
}
#[tokio::test]
async fn what_is_delegation_does_not_match_delegation() {
assert!(!has("what is delegation", Intent::Delegation).await);
}
#[tokio::test]
async fn i_would_like_to_play_a_game_does_not_match_execution() {
assert!(!has("I would like to play a game", Intent::Execution).await);
}
}