diff --git a/docs/tool-api.md b/docs/tool-api.md index 8eb1262f7..69e42cad2 100644 --- a/docs/tool-api.md +++ b/docs/tool-api.md @@ -174,11 +174,13 @@ datasets. returns `not_found` if its telemetry type does not match. - Otherwise it scores the visible datasets that match `telemetryType` by name against `query`, ignoring case: 1000 if the name equals the query, 750 if the - query contains the name, or 500 if the name contains the query, plus 25 for - each query term longer than two characters that the name contains, plus 15 - when `telemetryType` is given. It resolves only a unique top score of at least - 500. In every other case it returns `ambiguous` with up to `limit` scored - `candidates`, or `not_found` if no visible dataset matches `telemetryType`. + query contains the name as a whole word (`-` and `_` count as part of a name, + so `api` is not in `rapid` or `api-gateway`), or 500 if the name contains the + query, plus 25 for each query term longer than two characters that the name + contains, plus 15 when `telemetryType` is given. It resolves only a unique top + score of at least 500. In every other case it returns `ambiguous` with up to + `limit` scored `candidates`, or `not_found` if no visible dataset matches + `telemetryType`. It never falls back to an arbitrary dataset. diff --git a/src/tool_executor.rs b/src/tool_executor.rs index 89e337220..054b56a20 100644 --- a/src/tool_executor.rs +++ b/src/tool_executor.rs @@ -344,6 +344,24 @@ fn dataset_matches_telemetry_type(dataset: &Value, expected: Option<&str>) -> bo }) } +/// Whether `name` occurs in `query` as a whole token. Dataset names may contain +/// `-` and `_`, so those count as part of a name rather than as boundaries. +fn query_mentions_dataset_name(query: &str, name: &str) -> bool { + let is_name_character = + |character: char| character.is_ascii_alphanumeric() || character == '_' || character == '-'; + !name.is_empty() + && query.match_indices(name).any(|(start, matched)| { + !query[..start] + .chars() + .next_back() + .is_some_and(is_name_character) + && !query[start + matched.len()..] + .chars() + .next() + .is_some_and(is_name_character) + }) +} + pub fn resolve_dataset_candidates(payload: Value, arguments: &Value) -> Value { let query = arguments .get("query") @@ -470,7 +488,7 @@ pub fn resolve_dataset_candidates(payload: Value, arguments: &Value) -> Value { if !query.is_empty() && name == query { score += 1_000; reasons.push("exact dataset name"); - } else if !query.is_empty() && query.contains(&name) { + } else if !query.is_empty() && query_mentions_dataset_name(&query, &name) { score += 750; reasons.push("dataset name explicitly mentioned in the question"); } else if !query.is_empty() && name.contains(&query) { @@ -2203,6 +2221,35 @@ mod tests { assert_eq!(sole_mismatch["status"], "not_found"); } + #[actix_web::test] + async fn shared_dataset_resolution_requires_whole_name_mentions() { + let datasets = json!([{ "name": "api" }, { "name": "web" }]); + let embedded = resolve_dataset_candidates( + datasets.clone(), + &json!({ "query": "show rapid failures" }), + ); + assert_ne!(embedded["status"], "resolved"); + + let mentioned = + resolve_dataset_candidates(datasets.clone(), &json!({ "query": "show api errors" })); + assert_eq!(mentioned["status"], "resolved"); + assert_eq!(mentioned["selectedDatasets"], json!(["api"])); + + let punctuated = + resolve_dataset_candidates(datasets, &json!({ "query": "errors in (api)?" })); + assert_eq!(punctuated["selectedDatasets"], json!(["api"])); + + let hyphenated = json!([{ "name": "otel-metrics" }, { "name": "otel-traces" }]); + let longer_name = resolve_dataset_candidates( + hyphenated.clone(), + &json!({ "query": "show otel-metrics-v2 errors" }), + ); + assert_ne!(longer_name["status"], "resolved"); + let underscored = + resolve_dataset_candidates(hyphenated, &json!({ "query": "show my_otel-metrics" })); + assert_ne!(underscored["status"], "resolved"); + } + #[actix_web::test] async fn shared_dataset_resolution_prefers_exact_case_selection() { let datasets = json!([{ "name": "Logs" }, { "name": "logs" }, { "name": "Traces" }]);