first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+30 -40
View File
@@ -2,9 +2,9 @@ use std::{fs, process};
use clap::{Parser, Subcommand};
use futures::executor::block_on;
use input_classifier::test_utils::CompletionContext;
use input_classifier::{
ClassificationResult, Context, HeuristicClassifier, InputClassifier, InputType,
test_utils::CompletionContext,
};
/// Convert HSL to RGB values (0-255 range)
@@ -66,13 +66,24 @@ fn get_binary_confidence_color(is_correct: bool, is_low_confidence: bool) -> Str
"\x1b[31m".to_string() // Red for incorrect
}
}
use galaxy_completer::{ParsedTokensSnapshot, util::parse_current_commands_and_tokens};
#[cfg(feature = "fasttext")]
use input_classifier::FasttextClassifier;
#[cfg(feature = "onnx")]
use input_classifier::{OnnxClassifier, OnnxModel};
use warp_completer::ParsedTokensSnapshot;
use warp_completer::util::parse_current_commands_and_tokens;
#[cfg(feature = "onnx")]
fn default_onnx_model() -> Option<OnnxModel> {
cfg_if::cfg_if! {
if #[cfg(feature = "nld_classifier_v1")] {
Some(OnnxModel::BertTinyV1)
} else if #[cfg(feature = "nld_classifier_v2")] {
Some(OnnxModel::BertTinyV2)
} else if #[cfg(feature = "nld_classifier_v3")] {
Some(OnnxModel::BertTinyV3)
} else {
None
}
}
}
#[derive(Parser)]
struct InputSource {
@@ -98,11 +109,6 @@ struct Args {
#[arg(long)]
heuristic: bool,
/// Use fasttext classifier
#[cfg(feature = "fasttext")]
#[arg(long)]
fasttext: bool,
/// Use ONNX classifier
#[cfg(feature = "onnx")]
#[arg(long)]
@@ -135,17 +141,6 @@ fn create_classifiers(args: &Args) -> Vec<(&'static str, Box<dyn InputClassifier
let mut classifiers: Vec<(&'static str, Box<dyn InputClassifier>)> = Vec::new();
// Default to all available classifiers if none specified
let fasttext_specified = {
#[cfg(feature = "fasttext")]
{
args.fasttext
}
#[cfg(not(feature = "fasttext"))]
{
false
}
};
let onnx_specified = {
#[cfg(feature = "onnx")]
{
@@ -157,27 +152,19 @@ fn create_classifiers(args: &Args) -> Vec<(&'static str, Box<dyn InputClassifier
}
};
let use_all = !args.heuristic && !fasttext_specified && !onnx_specified;
let use_all = !args.heuristic && !onnx_specified;
if args.heuristic || use_all {
classifiers.push(("heuristic", Box::new(HeuristicClassifier)));
}
#[cfg(feature = "fasttext")]
if args.fasttext || use_all {
match FasttextClassifier::new() {
Ok(classifier) => {
classifiers.push(("fasttext", Box::new(classifier)));
}
Err(e) => {
eprintln!("Warning: Failed to initialize FastText classifier: {e}");
}
}
}
#[cfg(feature = "onnx")]
if args.onnx || use_all {
match OnnxClassifier::new(OnnxModel::BertTiny) {
let Some(model) = default_onnx_model() else {
eprintln!("Warning: No ONNX model feature is enabled for the ONNX classifier");
return classifiers;
};
match OnnxClassifier::new(model) {
Ok(classifier) => {
classifiers.push(("onnx", Box::new(classifier)));
}
@@ -247,10 +234,13 @@ async fn handle_classify(
}
Err(_) => {
// Fallback to detect_input_type if classify_input fails
let result = classifier
let classification = classifier
.detect_input_type(parsed_input.clone(), &context)
.await;
println!(" {name}: {result} (probabilities unavailable)");
println!(
" {}: {} (probabilities unavailable)",
name, classification.input_type
);
}
}
}
@@ -302,10 +292,10 @@ async fn handle_verify(
}
Err(_) => {
// Fallback to detect_input_type if classify_input fails
let result = classifier
let classification = classifier
.detect_input_type(parsed_input.clone(), &context)
.await;
let is_correct = result == expected;
let is_correct = classification.input_type == expected;
if is_correct {
correct_count += 1;
}
@@ -5,12 +5,13 @@ use galaxy_completer::ParsedTokensSnapshot;
use itertools::Itertools as _;
use natural_language_detection::natural_language_words_score;
use crate::parser::parse_query_into_tokens;
use crate::util::{
is_installed_binary, is_likely_shell_command, is_one_off_natural_language_word_or_prefix,
};
use crate::{
ClassificationResult, Context, InputClassifier, InputType,
parser::parse_query_into_tokens,
util::{
is_installed_binary, is_likely_shell_command, is_one_off_natural_language_word_or_prefix,
},
ClassificationResult, Context, InputClassificationResult, InputClassifier,
InputClassifierDecisionSource, InputType,
};
/// Minimum number of characters the input buffer must have before classification runs.
@@ -32,7 +33,11 @@ pub struct HeuristicClassifier;
#[cfg_attr(not(target_family = "wasm"), async_trait)]
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
impl InputClassifier for HeuristicClassifier {
async fn detect_input_type(&self, input: ParsedTokensSnapshot, context: &Context) -> InputType {
async fn detect_input_type(
&self,
input: ParsedTokensSnapshot,
context: &Context,
) -> InputClassificationResult {
let word_tokens = parse_query_into_tokens(input.buffer_text.as_str());
let total_word_token_count = word_tokens.len();
@@ -46,23 +51,27 @@ impl InputClassifier for HeuristicClassifier {
if total_word_token_count == 1
&& is_one_off_natural_language_word_or_prefix(&word_tokens[0].to_lowercase())
{
log::info!("[input-classifier] → one-off NL word, returning AI");
return InputType::AI;
return InputClassificationResult::new(
InputType::AI,
InputClassifierDecisionSource::NaturalLanguageOneOffAllowlist,
);
}
if is_likely_shell_command(&input, total_word_token_count).await {
log::info!("[input-classifier] → is_likely_shell_command=true, returning Shell");
return InputType::Shell;
return InputClassificationResult::new(
InputType::Shell,
InputClassifierDecisionSource::ShellHeuristic,
);
}
let result = self
.classify_input(input, context)
.await
.map(|result| result.to_input_type())
.unwrap_or(context.current_input_type);
log::info!("[input-classifier] → classify_input returned {:?}", result);
result
.map(|result| InputClassificationResult::new(result.to_input_type(), result.source))
.unwrap_or(InputClassificationResult::new(
context.current_input_type,
InputClassifierDecisionSource::InputClassifierFallbackHeuristic,
))
}
async fn classify_input(
@@ -105,10 +114,17 @@ async fn natural_language_detection_heuristic(
_current_input_type: InputType,
include_last_token: bool,
) -> ClassificationResult {
let _word_tokens_count = word_tokens.len();
let source = InputClassifierDecisionSource::InputClassifierFallbackHeuristic;
let word_tokens_count = word_tokens.len();
if input.buffer_text.len() < MINIMUM_CLASSIFICATION_CHAR_LENGTH {
return ClassificationResult::pure_shell();
let min_token_length = if matches!(current_input_type, InputType::AI) {
MINIMUM_COMMAND_DETECTION_TOKEN_LENGTH
} else {
MINIMUM_NATURAL_LANGUAGE_DETECTION_TOKEN_LENGTH
};
if min_token_length > word_tokens_count as u8 {
return ClassificationResult::pure_shell(source);
}
let mut word_tokens = word_tokens.into_iter().map(Cow::Owned).collect_vec();
@@ -136,10 +152,10 @@ async fn natural_language_detection_heuristic(
};
if likely_english_token_count >= (updated_word_token_count as f32 * threshold) as usize {
return ClassificationResult::pure_ai();
return ClassificationResult::pure_ai(source);
}
ClassificationResult::pure_shell()
ClassificationResult::pure_shell(source)
}
#[cfg(test)]
@@ -1,14 +1,52 @@
use galaxy_completer::meta::SpannedItem;
use galaxy_completer::util::parse_current_commands_and_tokens;
use crate::{Context, test_utils::CompletionContext};
use galaxy_completer::{ParsedTokenData, ParsedTokensSnapshot};
use super::*;
use crate::Context;
use crate::test_utils::CompletionContext;
async fn mock_parsed_input_token(buffer_text: String) -> ParsedTokensSnapshot {
warp_features::mark_initialized();
let completion_context = CompletionContext::new();
parse_current_commands_and_tokens(buffer_text, &completion_context).await
}
fn mock_parsed_input_token_without_descriptions(buffer_text: &str) -> ParsedTokensSnapshot {
let mut next_search_start = 0;
let parsed_tokens = buffer_text
.split_whitespace()
.enumerate()
.map(|(token_index, token)| {
let token_start =
buffer_text[next_search_start..].find(token).unwrap() + next_search_start;
let token_end = token_start + token.len();
next_search_start = token_end;
ParsedTokenData {
token: token.to_string().spanned((token_start, token_end)),
token_index,
token_description: None,
}
})
.collect();
ParsedTokensSnapshot {
buffer_text: buffer_text.to_string(),
parsed_tokens,
}
}
async fn detected_input_type(
classifier: &HeuristicClassifier,
input: ParsedTokensSnapshot,
context: &Context,
) -> InputType {
classifier
.detect_input_type(input, context)
.await
.input_type
}
#[test]
fn test_input_detection() {
futures::executor::block_on(async move {
@@ -21,7 +59,7 @@ fn test_input_detection() {
let token = mock_parsed_input_token("cargo --version".to_string()).await;
assert_eq!(
classifier.detect_input_type(token, &context).await,
detected_input_type(&classifier, token, &context).await,
InputType::Shell
);
@@ -32,14 +70,14 @@ fn test_input_detection() {
let mut token = mock_parsed_input_token("cargo --version".to_string()).await;
token.parsed_tokens[0].token_description = None;
assert_eq!(
classifier.detect_input_type(token, &context).await,
detected_input_type(&classifier, token, &context).await,
InputType::Shell
);
let mut token = mock_parsed_input_token("rvm install 3.3".to_string()).await;
token.parsed_tokens[0].token_description = None;
assert_eq!(
classifier.detect_input_type(token, &context).await,
detected_input_type(&classifier, token, &context).await,
InputType::Shell
);
@@ -47,7 +85,7 @@ fn test_input_detection() {
let mut token = mock_parsed_input_token("Explain this".to_string()).await;
token.parsed_tokens[0].token_description = None;
assert_eq!(
classifier.detect_input_type(token.clone(), &context).await,
detected_input_type(&classifier, token.clone(), &context).await,
InputType::AI
);
@@ -57,21 +95,21 @@ fn test_input_detection() {
let mut token = mock_parsed_input_token("fix this".to_string()).await;
token.parsed_tokens[0].token_description = None;
assert_eq!(
classifier.detect_input_type(token, &context).await,
detected_input_type(&classifier, token, &context).await,
InputType::AI,
);
// Short queries with punctuation should be parsed as AI input.
let token = mock_parsed_input_token("What went wrong?".to_string()).await;
assert_eq!(
classifier.detect_input_type(token, &context).await,
detected_input_type(&classifier, token, &context).await,
InputType::AI
);
// Short queries with contractions should be parsed as AI input.
let mut token = mock_parsed_input_token("What's the reason".to_string()).await;
token.parsed_tokens[0].token_description = None;
assert_eq!(
classifier.detect_input_type(token, &context).await,
detected_input_type(&classifier, token, &context).await,
InputType::AI
);
@@ -80,7 +118,7 @@ fn test_input_detection() {
mock_parsed_input_token("The message is \"utils::future ... ok\"".to_string()).await;
token.parsed_tokens[0].token_description = None;
assert_eq!(
classifier.detect_input_type(token, &context).await,
detected_input_type(&classifier, token, &context).await,
InputType::AI
);
@@ -88,8 +126,47 @@ fn test_input_detection() {
let mut token = mock_parsed_input_token("The type is \"<>\"".to_string()).await;
token.parsed_tokens[0].token_description = None;
assert_eq!(
classifier.detect_input_type(token, &context).await,
detected_input_type(&classifier, token, &context).await,
InputType::AI
);
});
}
#[test]
fn test_input_detection_sources() {
futures::executor::block_on(async move {
let classifier = HeuristicClassifier;
let context = Context {
current_input_type: InputType::Shell,
is_agent_follow_up: false,
};
let token = mock_parsed_input_token_without_descriptions("echo hello");
let decision = classifier.detect_input_type(token, &context).await;
assert_eq!(
decision,
InputClassificationResult::new(
InputType::Shell,
InputClassifierDecisionSource::ShellHeuristic,
)
);
let token = mock_parsed_input_token_without_descriptions("explain");
let decision = classifier.detect_input_type(token, &context).await;
assert_eq!(
decision,
InputClassificationResult::new(
InputType::AI,
InputClassifierDecisionSource::NaturalLanguageOneOffAllowlist,
)
);
let token = mock_parsed_input_token_without_descriptions("fix this");
let decision = classifier.detect_input_type(token, &context).await;
assert_eq!(
decision,
InputClassificationResult::new(
InputType::AI,
InputClassifierDecisionSource::InputClassifierFallbackHeuristic,
)
);
});
}
+2 -1
View File
@@ -1,6 +1,7 @@
use serde::{Deserialize, Serialize};
use std::str::FromStr;
use serde::{Deserialize, Serialize};
/// The type of input the user has provided.
#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum InputType {
+40 -8
View File
@@ -1,5 +1,3 @@
#[cfg(feature = "fasttext")]
mod fasttext;
mod heuristic_classifier;
mod input_type;
#[cfg(feature = "onnx")]
@@ -9,13 +7,43 @@ pub mod test_utils;
pub mod util;
use async_trait::async_trait;
#[cfg(feature = "fasttext")]
pub use fasttext::FasttextClassifier;
pub use heuristic_classifier::HeuristicClassifier;
pub use input_type::InputType;
#[cfg(feature = "onnx")]
pub use onnx::{Model as OnnxModel, OnnxClassifier};
use serde::{Deserialize, Serialize};
/// Sources produced by the input classifier pipeline.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum InputClassifierDecisionSource {
// Classification result coming from Onnx classifier
InputClassifier,
// Classification result coming from fall back heuristic classifier when onnx classifier panicked
InputClassifierFallbackHeuristic,
// Classification result coming from current input type when onnx classifier failed
InputClassifierFallbackCurrentInput,
// Input match with ONE_OFF_NATURAL_LANGUAGE_WORDS
NaturalLanguageOneOffAllowlist,
// Input match with ONE_OFF_SHELL_COMMAND_KEYWORDS
ShellCommandAllowList,
// Classification result coming from is_likely_shell_command
ShellHeuristic,
}
/// The detected input type along with the decision source that produced it.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InputClassificationResult {
/// The detected input type.
pub input_type: InputType,
/// The classifier source that produced this classification.
pub source: InputClassifierDecisionSource,
}
impl InputClassificationResult {
pub fn new(input_type: InputType, source: InputClassifierDecisionSource) -> Self {
Self { input_type, source }
}
}
/// An input classifier, which can take some parsed user input and determine
/// what type of input it is.
@@ -26,7 +54,7 @@ pub trait InputClassifier: 'static + Send + Sync {
&self,
input: galaxy_completer::ParsedTokensSnapshot,
context: &Context,
) -> InputType;
) -> InputClassificationResult;
async fn classify_input(
&self,
@@ -41,20 +69,24 @@ pub struct ClassificationResult {
p_shell: f32,
/// The probability that the input is a natural language query to AI.
p_ai: f32,
/// The classifier source that produced this classification.
pub source: InputClassifierDecisionSource,
}
impl ClassificationResult {
fn pure_ai() -> Self {
fn pure_ai(source: InputClassifierDecisionSource) -> Self {
Self {
p_shell: 0.0,
p_ai: 1.0,
source,
}
}
fn pure_shell() -> Self {
fn pure_shell(source: InputClassifierDecisionSource) -> Self {
Self {
p_shell: 1.0,
p_ai: 0.0,
source,
}
}
+3 -3
View File
@@ -7,9 +7,8 @@ use galaxy_completer::ParsedTokensSnapshot;
use prost::Message as _;
use tokenizers::Tokenizer;
use super::ClassificationResult;
use super::Model;
use super::{ClassificationResult, Model};
use crate::InputClassifierDecisionSource;
pub struct InferenceRunner {
model: ModelProto,
@@ -96,6 +95,7 @@ impl super::InferenceRunner for InferenceRunner {
Ok(ClassificationResult {
p_ai: probabilities[0],
p_shell: probabilities[1],
source: InputClassifierDecisionSource::InputClassifier,
})
}
}
+52 -18
View File
@@ -10,21 +10,28 @@ use async_trait::async_trait;
use galaxy_completer::ParsedTokensSnapshot;
use rust_embed::RustEmbed;
use crate::parser::parse_query_into_tokens;
use crate::util::{
is_likely_shell_command, is_one_off_natural_language_word, is_one_off_shell_command_keyword,
};
use crate::{
ClassificationResult, Context, InputClassifier, InputType,
parser::parse_query_into_tokens,
util::{
is_likely_shell_command, is_one_off_natural_language_word, is_one_off_shell_command_keyword,
},
ClassificationResult, Context, InputClassificationResult, InputClassifier,
InputClassifierDecisionSource, InputType,
};
#[derive(Clone, Copy, RustEmbed)]
#[folder = "models/onnx"]
#[include = "bert_tiny_tokenizer.json"]
#[cfg_attr(feature = "nld_classifier_v1", include = "bert_tiny_v1.onnx")]
#[cfg_attr(feature = "nld_classifier_v2", include = "bert_tiny_v2.onnx")]
#[cfg_attr(feature = "nld_classifier_v3", include = "bert_tiny_v3.onnx")]
struct Models;
#[derive(Copy, Clone)]
#[derive(Copy, Clone, Debug)]
pub enum Model {
BertTiny,
BertTinyV1,
BertTinyV2,
BertTinyV3,
}
impl Model {
@@ -38,13 +45,15 @@ impl Model {
fn model_path(&self) -> &'static str {
match self {
Model::BertTiny => "bert_tiny.onnx",
Model::BertTinyV1 => "bert_tiny_v1.onnx",
Model::BertTinyV2 => "bert_tiny_v2.onnx",
Model::BertTinyV3 => "bert_tiny_v3.onnx",
}
}
fn tokenizer_path(&self) -> &'static str {
match self {
Model::BertTiny => "bert_tiny_tokenizer.json",
Model::BertTinyV1 | Model::BertTinyV2 | Model::BertTinyV3 => "bert_tiny_tokenizer.json",
}
}
}
@@ -85,7 +94,11 @@ impl OnnxClassifier {
#[cfg_attr(not(target_family = "wasm"), async_trait)]
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
impl InputClassifier for OnnxClassifier {
async fn detect_input_type(&self, input: ParsedTokensSnapshot, context: &Context) -> InputType {
async fn detect_input_type(
&self,
input: ParsedTokensSnapshot,
context: &Context,
) -> InputClassificationResult {
let word_tokens = parse_query_into_tokens(input.buffer_text.as_str());
let total_word_token_count = word_tokens.len();
@@ -96,36 +109,53 @@ impl InputClassifier for OnnxClassifier {
// If the input is a single word and the word is one of a specific set of words, classify it as AI
if word_tokens.len() == 1 && is_one_off_natural_language_word(&first_word) {
return InputType::AI;
return InputClassificationResult::new(
InputType::AI,
InputClassifierDecisionSource::NaturalLanguageOneOffAllowlist,
);
}
// If the first token is one of a specific set of shell command keywords (e.g.: echo or sudo),
// we should classify it as shell.
if is_one_off_shell_command_keyword(&first_word) {
return InputType::Shell;
return InputClassificationResult::new(
InputType::Shell,
InputClassifierDecisionSource::ShellHeuristic,
);
}
}
if is_likely_shell_command(&input, total_word_token_count).await {
return InputType::Shell;
return InputClassificationResult::new(
InputType::Shell,
InputClassifierDecisionSource::ShellHeuristic,
);
}
// Otherwise, defer all decision-making to the model.
self.classify_input(input, context)
.await
.map(|result| result.to_input_type())
.unwrap_or(context.current_input_type)
.map(|classification| {
InputClassificationResult::new(
classification.to_input_type(),
classification.source,
)
})
.unwrap_or(InputClassificationResult::new(
context.current_input_type,
InputClassifierDecisionSource::InputClassifierFallbackCurrentInput,
))
}
async fn classify_input(
&self,
input: galaxy_completer::ParsedTokensSnapshot,
_context: &Context,
context: &Context,
) -> anyhow::Result<ClassificationResult> {
// If we ever panicked while running inference, we should fall back to the heuristic classifier.
if self.has_panicked.has_panicked() {
return crate::heuristic_classifier::HeuristicClassifier
.classify_input(input, _context)
.classify_input(input, context)
.await;
}
@@ -163,7 +193,7 @@ impl InputClassifier for OnnxClassifier {
);
self.has_panicked.on_panic();
crate::heuristic_classifier::HeuristicClassifier
.classify_input(input, _context)
.classify_input(input, context)
.await
}
}
@@ -196,3 +226,7 @@ impl HasPanicked {
self.inner.is_completed()
}
}
#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;
@@ -0,0 +1,64 @@
use anyhow::Result;
use futures::executor::block_on;
use warp_completer::meta::SpannedItem;
use warp_completer::{ParsedTokenData, ParsedTokensSnapshot};
use super::*;
struct FailingInferenceRunner;
impl InferenceRunner for FailingInferenceRunner {
fn run_inference(&self, _input: &ParsedTokensSnapshot) -> Result<ClassificationResult> {
Err(anyhow::anyhow!("inference failed"))
}
}
fn parsed_input_without_descriptions(buffer_text: &str) -> ParsedTokensSnapshot {
let mut next_search_start = 0;
let parsed_tokens = buffer_text
.split_whitespace()
.enumerate()
.map(|(token_index, token)| {
let token_start =
buffer_text[next_search_start..].find(token).unwrap() + next_search_start;
let token_end = token_start + token.len();
next_search_start = token_end;
ParsedTokenData {
token: token.to_string().spanned((token_start, token_end)),
token_index,
token_description: None,
}
})
.collect();
ParsedTokensSnapshot {
buffer_text: buffer_text.to_owned(),
parsed_tokens,
}
}
#[test]
fn test_inference_error_reports_current_input_fallback_source() {
block_on(async move {
let classifier = OnnxClassifier {
inference_runner: Box::new(FailingInferenceRunner),
has_panicked: HasPanicked::new(),
};
let context = Context {
current_input_type: InputType::AI,
is_agent_follow_up: false,
};
let input = parsed_input_without_descriptions("help migrate database");
let decision = classifier.detect_input_type(input, &context).await;
assert_eq!(
decision,
InputClassificationResult::new(
InputType::AI,
InputClassifierDecisionSource::InputClassifierFallbackCurrentInput,
)
);
});
}
+7 -7
View File
@@ -1,16 +1,15 @@
use anyhow::{Result, ensure};
use galaxy_completer::ParsedTokensSnapshot;
use itertools::Itertools as _;
use ort::{
execution_providers::CPUExecutionProvider, session::Session, tensor::ArrayExtensions as _,
value::Value,
};
use ort::execution_providers::CPUExecutionProvider;
use ort::session::Session;
use ort::tensor::ArrayExtensions as _;
use ort::value::Value;
use parking_lot::Mutex;
use tokenizers::Tokenizer;
use super::ClassificationResult;
use super::Model;
use super::{ClassificationResult, Model};
use crate::InputClassifierDecisionSource;
pub struct InferenceRunner {
session: Mutex<Session>,
@@ -86,6 +85,7 @@ impl super::InferenceRunner for InferenceRunner {
Ok(ClassificationResult {
p_ai: probabilities[0],
p_shell: probabilities[1],
source: InputClassifierDecisionSource::InputClassifier,
})
}
}
+4 -2
View File
@@ -1,4 +1,6 @@
use std::{iter::Peekable, mem, str::Chars};
use std::iter::Peekable;
use std::mem;
use std::str::Chars;
use itertools::Itertools;
@@ -33,7 +35,7 @@ fn convert_char_to_delimiter(c: char) -> Option<WordDelimiter> {
}
}
/// Parse a sentence into tokens for natural language classificiation.
/// Parse a sentence into tokens for natural language classification.
/// The main difference from the default "split_whitespace" parser is
/// for matching double quotes and single quotes, we would keep their
/// closed content as one token.
+4 -5
View File
@@ -1,10 +1,9 @@
use std::{collections::HashSet, sync::Arc};
use std::collections::HashSet;
use std::sync::Arc;
use galaxy_completer::{
completer::{GeneratorContext, PathCompletionContext},
signatures::CommandRegistry,
};
use smol_str::SmolStr;
use galaxy_completer::completer::{GeneratorContext, PathCompletionContext};
use galaxy_completer::signatures::CommandRegistry;
/// An implementation of `CompletionContext` for testing purposes.
pub struct CompletionContext {
+35 -18
View File
@@ -2,7 +2,6 @@ use std::collections::HashSet;
use galaxy_completer::ParsedTokensSnapshot;
use lazy_static::lazy_static;
use natural_language_detection::check_if_token_has_shell_syntax;
/// The percentage of input tokens that can be described by our completion engine before
/// we consider the input as a shell command. This could be tuned.
@@ -20,13 +19,13 @@ lazy_static! {
/// claude code, codex CLI, or gemini CLI) suck, because the user often thinks we're
/// intentionally trying to push them away from those CLIs into Agent Mode, so we mitigate the
/// risk by always treating as shell.
static ref ONE_OFF_SHELL_COMMAND_KEYWORDS: HashSet<&'static str> = HashSet::from(["#", "echo", "man", "sudo", "claude", "codex", "gemini"]);
static ref ONE_OFF_SHELL_COMMAND_KEYWORDS: HashSet<&'static str> = HashSet::from(["#", "echo", "man", "sudo", "claude", "codex", "gemini", "agy"]);
static ref ONE_OFF_NATURAL_LANGUAGE_WORDS: HashSet<&'static str> = HashSet::from(["hello", "hi", "hey", "hola", "thanks", "explain", "yes", "no", "what", "nice", "1. "]);
/// A set of words that should trigger an AI classification if they are the entire input
/// and the input is a follow-up to an agent response.
static ref AGENT_FOLLOW_UP_INPUTS: HashSet<&'static str> = HashSet::from(["yes", "continue", "do it"]);
static ref AGENT_FOLLOW_UP_INPUTS: HashSet<&'static str> = HashSet::from(["yes", "continue", "do it", "approve"]);
}
pub fn is_agent_follow_up_input(input: &str) -> bool {
@@ -56,33 +55,47 @@ pub fn is_prefix_of_natural_language_word(input: &str) -> bool {
.any(|word| word.starts_with(input))
}
/// nld_heuristic_v1: current prod, use check_if_token_has_shell_syntax and conditional threshold on input length
/// nld_heuristic_v2: rm check_if_token_has_shell_syntax and pin threshold to be 1 for all input
pub async fn is_likely_shell_command(
input: &ParsedTokensSnapshot,
_word_tokens_count: usize,
) -> bool {
const YIELD_BATCH_SIZE: usize = 5;
let use_nld_heuristic_v2 = cfg!(feature = "nld_heuristic_v2");
let mut likely_command_token_count = 0;
let total_token_count = input.parsed_tokens.len();
let mut is_first_token_command = false;
log::debug!(
"is_likely_shell_command start: use_nld_heuristic_v2={use_nld_heuristic_v2}, total_token_count={total_token_count}, word_tokens_count={word_tokens_count}"
);
for (idx, token) in input.parsed_tokens.iter().enumerate() {
if idx % YIELD_BATCH_SIZE == 0 {
futures_lite::future::yield_now().await;
}
if token.token_index == 0 && ONE_OFF_SHELL_COMMAND_KEYWORDS.contains(&token.token.as_str())
{
log::debug!(
"is_likely_shell_command result=true: first token is one-off shell keyword, use_nld_heuristic_v2={use_nld_heuristic_v2}"
);
return true;
}
if token.token_description.is_some()
|| check_if_token_has_shell_syntax(token.token.as_str())
{
let check_if_token_has_shell_syntax = !use_nld_heuristic_v2
&& natural_language_detection::check_if_token_has_shell_syntax(token.token.as_str());
log::debug!(
"is_likely_shell_command token: token_index={}, token_description_is_some={}, check_if_token_has_shell_syntax={check_if_token_has_shell_syntax}, use_nld_heuristic_v2={use_nld_heuristic_v2}",
token.token_index,
token.token_description.is_some()
);
if token.token_description.is_some() || check_if_token_has_shell_syntax {
likely_command_token_count += 1;
}
}
// When token count is lower than 2, we should make sure all tokens
// are matching the target classification category.
let command_threshold = if total_token_count <= 2 {
let command_threshold = if use_nld_heuristic_v2 || total_token_count <= 2 {
1.0
} else if total_token_count <= 4 {
DETECT_AS_COMMAND_LOW_TOKEN_THRESHOLD
@@ -90,17 +103,17 @@ pub async fn is_likely_shell_command(
DETECT_AS_COMMAND_THRESHOLD
};
let threshold_count = (total_token_count as f32 * command_threshold) as usize;
let is_shell = likely_command_token_count >= threshold_count;
log::info!(
"[input-classifier] is_likely_shell_command: tokens={}, cmd_tokens={}, threshold={:.2} (need {}), result={}",
total_token_count,
likely_command_token_count,
command_threshold,
threshold_count,
is_shell
// Classify as shell if:
// 1) We hit significant threshold of likely shell command tokens.
// 2) When there are fewer than 3 tokens, the first token is a valid top-level command.
let is_likely_shell_command = likely_command_token_count
>= (total_token_count as f32 * command_threshold) as usize
|| (word_tokens_count < 3 && is_first_token_command);
log::debug!(
"is_likely_shell_command result={is_likely_shell_command}: use_nld_heuristic_v2={use_nld_heuristic_v2}, likely_command_token_count={likely_command_token_count}, total_token_count={total_token_count}, word_tokens_count={word_tokens_count}, command_threshold={command_threshold}, is_first_token_command={is_first_token_command}"
);
is_shell
is_likely_shell_command
}
/// Returns true if the first token is a command that is installed on the system.
@@ -111,3 +124,7 @@ pub fn is_installed_binary(input: &ParsedTokensSnapshot) -> bool {
.map(|token| token.token_description.is_some())
.unwrap_or(false)
}
#[cfg(all(test, any(feature = "nld_heuristic_v1", feature = "nld_heuristic_v2")))]
#[path = "util_tests.rs"]
mod tests;
+242
View File
@@ -0,0 +1,242 @@
use warp_completer::ParsedTokensSnapshot;
use warp_completer::util::parse_current_commands_and_tokens;
use super::*;
use crate::test_utils::CompletionContext;
async fn mock_parsed_input_token(buffer_text: String) -> ParsedTokensSnapshot {
warp_features::mark_initialized();
let completion_context = CompletionContext::new();
parse_current_commands_and_tokens(buffer_text, &completion_context).await
}
fn clear_all_token_descriptions(snapshot: &mut ParsedTokensSnapshot) {
for token in snapshot.parsed_tokens.iter_mut() {
token.token_description = None;
}
}
async fn one_off_keyword_short_circuits() {
let mut token = mock_parsed_input_token("sudo apt update".to_string()).await;
let word_tokens_count = token.parsed_tokens.len();
clear_all_token_descriptions(&mut token);
assert!(is_likely_shell_command(&token, word_tokens_count).await);
let mut token = mock_parsed_input_token("echo hello world".to_string()).await;
let word_tokens_count = token.parsed_tokens.len();
clear_all_token_descriptions(&mut token);
assert!(is_likely_shell_command(&token, word_tokens_count).await);
let mut token = mock_parsed_input_token("agy doctor".to_string()).await;
let word_tokens_count = token.parsed_tokens.len();
clear_all_token_descriptions(&mut token);
assert!(is_likely_shell_command(&token, word_tokens_count).await);
}
async fn first_token_with_description_short_input_is_shell() {
let token = mock_parsed_input_token("cargo --version".to_string()).await;
assert!(is_likely_shell_command(&token, 2).await);
}
async fn no_descriptions_returns_false() {
let mut token = mock_parsed_input_token("install --foo=bar baz".to_string()).await;
let word_tokens_count = token.parsed_tokens.len();
clear_all_token_descriptions(&mut token);
assert!(!is_likely_shell_command(&token, word_tokens_count).await);
}
async fn shell_syntax_tokens_with_only_first_token_description() -> bool {
let mut token = mock_parsed_input_token("git --foo=bar /path/to/file --baz".to_string()).await;
let word_tokens_count = token.parsed_tokens.len();
for (idx, token) in token.parsed_tokens.iter_mut().enumerate() {
if idx != 0 {
token.token_description = None;
}
}
assert!(word_tokens_count >= 3);
is_likely_shell_command(&token, word_tokens_count).await
}
async fn described_token_majority_below_v2_threshold() -> bool {
let mut token = mock_parsed_input_token("cargo build --release --workspace".to_string()).await;
let word_tokens_count = token.parsed_tokens.len();
assert!(word_tokens_count >= 3);
let description = token
.parsed_tokens
.iter()
.find_map(|token| token.token_description.clone())
.expect("test input should include at least one described token");
for token in token.parsed_tokens.iter_mut() {
token.token_description = Some(description.clone());
}
token
.parsed_tokens
.last_mut()
.expect("test input should include tokens")
.token_description = None;
is_likely_shell_command(&token, word_tokens_count).await
}
async fn downloads_log_path_in_nl_prompt_is_shell() -> bool {
let command_token = mock_parsed_input_token("cargo --version".to_string()).await;
let command_description = command_token
.parsed_tokens
.first()
.and_then(|token| token.token_description.clone())
.expect("test input should include a described command token");
let mut token = mock_parsed_input_token(
"look at this /users/ewanlockwood/downloads/logs_58498936986".to_string(),
)
.await;
let word_tokens_count = token.parsed_tokens.len();
clear_all_token_descriptions(&mut token);
token
.parsed_tokens
.first_mut()
.expect("test input should include tokens")
.token_description = Some(command_description);
is_likely_shell_command(&token, word_tokens_count).await
}
async fn file_path_in_nl_prompt_is_shell() -> bool {
let mut token =
mock_parsed_input_token("look at this /users/foo/bar.log file".to_string()).await;
let word_tokens_count = token.parsed_tokens.len();
clear_all_token_descriptions(&mut token);
is_likely_shell_command(&token, word_tokens_count).await
}
async fn majority_described_tokens_returns_true() {
let token =
mock_parsed_input_token("cargo build --release --workspace --all-features".to_string())
.await;
let word_tokens_count = token.parsed_tokens.len();
assert!(is_likely_shell_command(&token, word_tokens_count).await);
}
// Cases where nld_heuristic_v1 and nld_heuristic_v2 should both mark input as shell.
#[cfg(all(feature = "nld_heuristic_v1", not(feature = "nld_heuristic_v2")))]
#[test]
fn test_is_likely_shell_command_one_off_keyword_short_circuits_true_for_nld_heuristic_v1() {
futures::executor::block_on(one_off_keyword_short_circuits());
}
#[cfg(feature = "nld_heuristic_v2")]
#[test]
fn test_is_likely_shell_command_one_off_keyword_short_circuits_true_for_nld_heuristic_v2() {
futures::executor::block_on(one_off_keyword_short_circuits());
}
#[cfg(all(feature = "nld_heuristic_v1", not(feature = "nld_heuristic_v2")))]
#[test]
fn test_is_likely_shell_command_first_token_with_description_short_input_true_for_nld_heuristic_v1()
{
futures::executor::block_on(first_token_with_description_short_input_is_shell());
}
#[cfg(feature = "nld_heuristic_v2")]
#[test]
fn test_is_likely_shell_command_first_token_with_description_short_input_true_for_nld_heuristic_v2()
{
futures::executor::block_on(first_token_with_description_short_input_is_shell());
}
#[cfg(all(feature = "nld_heuristic_v1", not(feature = "nld_heuristic_v2")))]
#[test]
fn test_is_likely_shell_command_majority_described_tokens_true_for_nld_heuristic_v1() {
futures::executor::block_on(majority_described_tokens_returns_true());
}
#[cfg(feature = "nld_heuristic_v2")]
#[test]
fn test_is_likely_shell_command_majority_described_tokens_true_for_nld_heuristic_v2() {
futures::executor::block_on(majority_described_tokens_returns_true());
}
// Cases where nld_heuristic_v1 and nld_heuristic_v2 should both not mark input as shell.
#[cfg(all(feature = "nld_heuristic_v1", not(feature = "nld_heuristic_v2")))]
#[test]
fn test_is_likely_shell_command_no_descriptions_false_for_nld_heuristic_v1() {
futures::executor::block_on(no_descriptions_returns_false());
}
#[cfg(feature = "nld_heuristic_v2")]
#[test]
fn test_is_likely_shell_command_no_descriptions_false_for_nld_heuristic_v2() {
futures::executor::block_on(no_descriptions_returns_false());
}
#[cfg(all(feature = "nld_heuristic_v1", not(feature = "nld_heuristic_v2")))]
#[test]
fn test_is_likely_shell_command_file_path_in_nl_prompt_false_for_nld_heuristic_v1() {
futures::executor::block_on(async move {
assert!(!file_path_in_nl_prompt_is_shell().await);
});
}
#[cfg(feature = "nld_heuristic_v2")]
#[test]
fn test_is_likely_shell_command_file_path_in_nl_prompt_false_for_nld_heuristic_v2() {
futures::executor::block_on(async move {
assert!(!file_path_in_nl_prompt_is_shell().await);
});
}
// Cases where nld_heuristic_v1 should mark input as shell and stricter
// nld_heuristic_v2 should not mark input as shell.
#[cfg(all(feature = "nld_heuristic_v1", not(feature = "nld_heuristic_v2")))]
#[test]
fn test_is_likely_shell_command_shell_syntax_votes_true_for_nld_heuristic_v1() {
futures::executor::block_on(async move {
assert!(shell_syntax_tokens_with_only_first_token_description().await);
});
}
#[cfg(feature = "nld_heuristic_v2")]
#[test]
fn test_is_likely_shell_command_shell_syntax_does_not_vote_false_for_nld_heuristic_v2() {
futures::executor::block_on(async move {
assert!(!shell_syntax_tokens_with_only_first_token_description().await);
});
}
#[cfg(all(feature = "nld_heuristic_v1", not(feature = "nld_heuristic_v2")))]
#[test]
fn test_is_likely_shell_command_described_token_majority_true_for_nld_heuristic_v1() {
futures::executor::block_on(async move {
assert!(described_token_majority_below_v2_threshold().await);
});
}
#[cfg(feature = "nld_heuristic_v2")]
#[test]
fn test_is_likely_shell_command_described_token_majority_false_for_nld_heuristic_v2() {
futures::executor::block_on(async move {
assert!(!described_token_majority_below_v2_threshold().await);
});
}
#[cfg(all(feature = "nld_heuristic_v1", not(feature = "nld_heuristic_v2")))]
#[test]
fn test_is_likely_shell_command_downloads_log_path_true_for_nld_heuristic_v1() {
futures::executor::block_on(async move {
assert!(downloads_log_path_in_nl_prompt_is_shell().await);
});
}
#[cfg(feature = "nld_heuristic_v2")]
#[test]
fn test_is_likely_shell_command_downloads_log_path_false_for_nld_heuristic_v2() {
futures::executor::block_on(async move {
assert!(!downloads_log_path_in_nl_prompt_is_shell().await);
});
}
// No inverse section is expected: nld_heuristic_v2 only removes shell-syntax
// voting and raises the command threshold, so it should not mark an input as
// shell when nld_heuristic_v1 does not.