From b717e5b6017535607d501194a303fd507923f46d Mon Sep 17 00:00:00 2001 From: necusjz Date: Wed, 22 Jul 2026 17:04:35 +1000 Subject: [PATCH 01/10] chore: blue to cyan --- src/azure-cli/azure/cli/command_modules/find/custom.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/find/custom.py b/src/azure-cli/azure/cli/command_modules/find/custom.py index 2912dc70688..21343bad683 100644 --- a/src/azure-cli/azure/cli/command_modules/find/custom.py +++ b/src/azure-cli/azure/cli/command_modules/find/custom.py @@ -512,7 +512,7 @@ def format_results(query, docs_results, code_results): if examples: print("Examples") for title, command_lines, url in examples: - print(format_styled_text((Style.ACTION, " - " + title))) + print(format_styled_text((Style.HIGHLIGHT, " - " + title))) for line in command_lines: print(" " + line) if url: @@ -540,7 +540,7 @@ def format_results(query, docs_results, code_results): if docs: print("Documentation") for title, summary, url in docs: - print(format_styled_text((Style.ACTION, " - " + title))) + print(format_styled_text((Style.HIGHLIGHT, " - " + title))) if summary: print(" " + summary) if url: From 74a0819f9de07a7d92d27b2e70080a727bb78a7a Mon Sep 17 00:00:00 2001 From: necusjz Date: Wed, 22 Jul 2026 17:07:50 +1000 Subject: [PATCH 02/10] style: add blank line --- src/azure-cli/azure/cli/command_modules/find/custom.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/azure-cli/azure/cli/command_modules/find/custom.py b/src/azure-cli/azure/cli/command_modules/find/custom.py index 21343bad683..1bd9a9629a4 100644 --- a/src/azure-cli/azure/cli/command_modules/find/custom.py +++ b/src/azure-cli/azure/cli/command_modules/find/custom.py @@ -20,7 +20,7 @@ from knack.log import get_logger logger = get_logger(__name__) -WAIT_MESSAGE = ['Finding examples and documentation...'] +WAIT_MESSAGE = ['\nFinding examples and documentation...'] # Display limits MAX_DOC_RESULTS = 2 From 3aa976f7115337f65e6d9c8e7f6b1bc151ca13e7 Mon Sep 17 00:00:00 2001 From: necusjz Date: Thu, 23 Jul 2026 14:46:38 +1000 Subject: [PATCH 03/10] fix: inflectional variants --- .../azure/cli/command_modules/find/custom.py | 57 ++++++++++++++++--- .../find/tests/latest/test_find.py | 22 ++++++- 2 files changed, 71 insertions(+), 8 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/find/custom.py b/src/azure-cli/azure/cli/command_modules/find/custom.py index 1bd9a9629a4..d779d70b3a3 100644 --- a/src/azure-cli/azure/cli/command_modules/find/custom.py +++ b/src/azure-cli/azure/cli/command_modules/find/custom.py @@ -259,6 +259,49 @@ def _get_query_keywords(query): return {w for w in words if w != 'az' and w not in stop_words} +def _stem(word): + """Reduce a word to a crude stem so morphological variants match. + + Strips common inflectional suffixes and a trailing 'e' so that variants + such as 'creating', 'creates', 'created' and 'create' all collapse to the + same stem ('creat'). This is a lightweight, dependency-free approximation + (not a full Porter stemmer) that is sufficient for matching query terms + against documentation and code samples. + + Args: + word: A single word. + + Returns: + The stemmed, lowercased word. + """ + word = word.lower() + for suffix in ('ings', 'ing', 'ies', 'ied', 'es', 'ed', 's'): + if word.endswith(suffix) and len(word) - len(suffix) >= 3: + word = word[:-len(suffix)] + break + if len(word) > 3 and word.endswith('e'): + word = word[:-1] + return word + + +def _matches_keywords(text, query_words): + """Check if any query keyword matches a word in the text after stemming. + + Both the query keywords and the text words are stemmed before comparison, + so morphological variants match (e.g. query 'creating' matches text + 'create'). + + Args: + text: The text to search within. + query_words: Set of query keywords to match against. + + Returns: + True if at least one query keyword stem matches a word stem in the text. + """ + text_stems = {_stem(w) for w in re.findall(r'[a-zA-Z]+', text.lower())} + return any(_stem(word) in text_stems for word in query_words) + + def _has_keyword_overlap(result, query_words): """Check if a result has meaningful keyword overlap with the query. @@ -269,11 +312,11 @@ def _has_keyword_overlap(result, query_words): Returns: True if at least one query keyword appears in the result's title or content. """ - title = result.get("title", "").lower() - content = result.get("content", "").lower()[:500] # Only check first 500 chars of content + title = result.get("title", "") + content = result.get("content", "")[:500] # Only check first 500 chars of content combined = title + " " + content - return any(word in combined for word in query_words) + return _matches_keywords(combined, query_words) def search_mslearn(query): @@ -314,9 +357,9 @@ def search_mslearn(query): query_words = _get_query_keywords(query) if query_words: code_results = [r for r in code_results - if any(word in (r.get("codeSnippet", "") + " " + - r.get("description", "")).lower() - for word in query_words)] + if _matches_keywords( + r.get("codeSnippet", "") + " " + r.get("description", ""), + query_words)] return docs_results, code_results @@ -550,7 +593,7 @@ def format_results(query, docs_results, code_results): def process_query(cli_term): if not cli_term: - logger.error('Please provide a search term e.g. az find "vm"') + logger.error('Please provide a search term, e.g., az find "vm"') else: print(random.choice(WAIT_MESSAGE), file=sys.stderr) diff --git a/src/azure-cli/azure/cli/command_modules/find/tests/latest/test_find.py b/src/azure-cli/azure/cli/command_modules/find/tests/latest/test_find.py index 57282b671ae..26e351debc9 100644 --- a/src/azure-cli/azure/cli/command_modules/find/tests/latest/test_find.py +++ b/src/azure-cli/azure/cli/command_modules/find/tests/latest/test_find.py @@ -12,7 +12,7 @@ Example, MCPClient, search_mslearn, format_results, get_generated_examples, process_query, _extract_summary, _is_cli_command_relevant, _extract_query_command, _filter_results, - _get_query_keywords, _has_keyword_overlap, + _get_query_keywords, _has_keyword_overlap, _stem, _matches_keywords, MAX_DOC_RESULTS, MAX_CODE_RESULTS ) @@ -287,6 +287,26 @@ def test_has_keyword_overlap_no_match(self): result = {"title": "Albanian Keyboard", "content": "KLID code"} self.assertFalse(_has_keyword_overlap(result, {"alskdn1k2lenasd"})) + def test_stem_verb_variants_collapse(self): + # Inflected verb forms should collapse to the same stem + self.assertEqual(_stem("creating"), _stem("create")) + self.assertEqual(_stem("creates"), _stem("create")) + self.assertEqual(_stem("created"), _stem("create")) + self.assertEqual(_stem("deleting"), _stem("delete")) + + def test_matches_keywords_morphological_variant(self): + # 'creating' in the query should match 'create' in the sample text + self.assertTrue(_matches_keywords("az vm create --name myvm", {"creating"})) + self.assertTrue(_matches_keywords("az vm delete --name myvm", {"deleting"})) + + def test_matches_keywords_no_match(self): + self.assertFalse(_matches_keywords("az vm create", {"storage"})) + + def test_has_keyword_overlap_morphological_variant(self): + # Regression: 'creating a vm' should still surface 'az vm create' results + result = {"title": "az vm create", "content": "Create a virtual machine"} + self.assertTrue(_has_keyword_overlap(result, _get_query_keywords("creating a vm"))) + class TestSearchMslearn(unittest.TestCase): From a4602fc85dbdb32fccaffc8b7cec06de7b7be290 Mon Sep 17 00:00:00 2001 From: necusjz Date: Mon, 27 Jul 2026 15:39:47 +1000 Subject: [PATCH 04/10] style: remove line continuation characters --- .../azure/cli/command_modules/find/custom.py | 37 +++++++++++-------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/find/custom.py b/src/azure-cli/azure/cli/command_modules/find/custom.py index d779d70b3a3..378ac1eef18 100644 --- a/src/azure-cli/azure/cli/command_modules/find/custom.py +++ b/src/azure-cli/azure/cli/command_modules/find/custom.py @@ -476,15 +476,15 @@ def _extract_description(description): def _extract_command(snippet): """Extract the `az` command block from a code snippet. - Returns the snippet lines starting from the first line that begins with - 'az', preserving the server's original formatting. Stops at a blank line - that isn't a shell line-continuation, so a single example is returned. + Returns the command lines starting from the first line that begins with + 'az'. Shell line-continuations (`\\`, `^` or backtick) are collapsed so that + each command is returned as a single line. Args: snippet: Raw code snippet string. Returns: - List of command lines (server formatting preserved), or empty list. + List of single-line commands, or empty list. """ if not snippet: return [] @@ -494,21 +494,26 @@ def _extract_command(snippet): if start is None: return [] - # If the command block is indented, strip the leading indent of the first - # `az` line from every line, preserving relative indentation. - indent = len(lines[start]) - len(lines[start].lstrip()) result = [] + pending = None for line in lines[start:]: - if not line.strip(): + stripped = line.strip() + if not stripped: continue - # Remove up to `indent` leading whitespace chars, keeping deeper indents. - stripped = line - for _ in range(indent): - if stripped[:1] in (' ', '\t'): - stripped = stripped[1:] - else: - break - result.append(stripped.rstrip()) + + continued = stripped.endswith(('\\', '^', '`')) + if continued: + stripped = stripped[:-1].rstrip() + + pending = stripped if pending is None else (pending + ' ' + stripped).strip() + + if not continued: + result.append(pending) + pending = None + + if pending: + result.append(pending) + return result From 3493c99bd945c59b9b5d079ccd2797084da80f63 Mon Sep 17 00:00:00 2001 From: necusjz Date: Mon, 27 Jul 2026 15:48:34 +1000 Subject: [PATCH 05/10] feat: cli specific doc --- .../azure/cli/command_modules/find/custom.py | 74 ++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/src/azure-cli/azure/cli/command_modules/find/custom.py b/src/azure-cli/azure/cli/command_modules/find/custom.py index 378ac1eef18..97998981e64 100644 --- a/src/azure-cli/azure/cli/command_modules/find/custom.py +++ b/src/azure-cli/azure/cli/command_modules/find/custom.py @@ -26,6 +26,9 @@ MAX_DOC_RESULTS = 2 MAX_CODE_RESULTS = 3 +# Hints appended to the docs query so results stay Azure CLI specific +DOCS_QUERY_HINTS = ['Azure CLI', 'az command'] + Example = namedtuple("Example", "title snippet") @@ -319,6 +322,68 @@ def _has_keyword_overlap(result, query_words): return _matches_keywords(combined, query_words) +def _build_docs_query(query): + """Scope a user query to Azure CLI documentation. + + The docs search is semantic and otherwise returns portal/PowerShell/SDK + articles. Appending Azure CLI hints biases results toward `az` content. + + Args: + query: The user's query string. + + Returns: + The query string with Azure CLI hint keywords appended. + """ + query = (query or "").strip() + lowered = query.lower() + hints = [hint for hint in DOCS_QUERY_HINTS if hint.lower() not in lowered] + return " ".join([query] + hints) if hints else query + + +def _cli_doc_score(result): + """Score how Azure CLI specific a doc result is. + + Args: + result: A doc result dict from MCP. + + Returns: + 3 for the `az` command reference, 2 for content with `az` invocations, + 1 for content merely mentioning the Azure CLI, 0 otherwise. + """ + url = (result.get("contentUrl", "") or "").lower() + if "/cli/azure" in url: + return 3 + + content = (result.get("content", "") or "") + " " + (result.get("title", "") or "") + if re.search(r'(?m)^\s*az\s+[a-z][\w-]*', content) or "azurecli" in content.lower(): + return 2 + + return 1 if "azure cli" in content.lower() else 0 + + +def _prefer_cli_docs(results): + """Keep and rank the Azure CLI related doc results. + + Results without any Azure CLI signal are dropped; the rest are ordered + most-CLI-specific first, preserving the server's relative ranking within + the same score. Falls back to the original results if none look CLI + specific, so the user still gets something back. + + Args: + results: List of doc result dicts from MCP. + + Returns: + Filtered and ranked list of doc results. + """ + scored = [(_cli_doc_score(r), i, r) for i, r in enumerate(results)] + cli_results = [item for item in scored if item[0] > 0] + if not cli_results: + return results + + cli_results.sort(key=lambda item: (-item[0], item[1])) + return [r for _, _, r in cli_results] + + def search_mslearn(query): """Search Microsoft Learn via MCP for docs and code samples. @@ -339,7 +404,7 @@ def search_mslearn(query): docs_response = client.call_tool( "microsoft_docs_search", - {"query": query} + {"query": _build_docs_query(query)} ) code_response = client.call_tool( @@ -353,6 +418,9 @@ def search_mslearn(query): # Filter out irrelevant CLI commands (e.g., 'az lab vm' when searching 'az vm') docs_results = _filter_results(docs_results, query) + # Drop docs that aren't about the Azure CLI (portal/PowerShell/SDK articles) + docs_results = _prefer_cli_docs(docs_results) + # Filter code results by keyword overlap too query_words = _get_query_keywords(query) if query_words: @@ -502,6 +570,10 @@ def _extract_command(snippet): continue continued = stripped.endswith(('\\', '^', '`')) + # Docs sometimes typo the continuation as ' /'; only treat a slash + # preceded by whitespace as one so trailing-slash values are kept. + if not continued and re.search(r'\s/$', stripped): + continued = True if continued: stripped = stripped[:-1].rstrip() From c2406d3b876cba68d95ebdb1d71110bc88a8659a Mon Sep 17 00:00:00 2001 From: necusjz Date: Mon, 27 Jul 2026 16:01:07 +1000 Subject: [PATCH 06/10] style: to be continued --- .../azure/cli/command_modules/find/custom.py | 88 ++++++++++++++++--- 1 file changed, 77 insertions(+), 11 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/find/custom.py b/src/azure-cli/azure/cli/command_modules/find/custom.py index 97998981e64..6a4aad72ead 100644 --- a/src/azure-cli/azure/cli/command_modules/find/custom.py +++ b/src/azure-cli/azure/cli/command_modules/find/custom.py @@ -29,6 +29,11 @@ # Hints appended to the docs query so results stay Azure CLI specific DOCS_QUERY_HINTS = ['Azure CLI', 'az command'] +# Doc summary length and the marker shown when a summary is cut short +MAX_SUMMARY_LENGTH = 150 +MIN_SUMMARY_LENGTH = 40 +CONTINUATION_MARKER = ' ... (see link for the full article)' + Example = namedtuple("Example", "title snippet") @@ -432,17 +437,67 @@ def search_mslearn(query): return docs_results, code_results +def _clean_markdown(text): + """Strip markdown noise so a summary reads as plain prose. + + Converts `[label](url)` links to their label and removes emphasis and + inline code markers. + + Args: + text: Raw markdown text. + + Returns: + Plain-text version of the input. + """ + text = re.sub(r'!\[[^\]]*\]\([^)]*\)', '', text) + text = re.sub(r'\[([^\]]+)\]\([^)]*\)', r'\1', text) + text = re.sub(r'[*_`]+', '', text) + return re.sub(r'\s+', ' ', text).strip() + + +def _shorten(text): + """Trim a summary to MAX_SUMMARY_LENGTH without cutting mid-word. + + Prefers ending on a sentence boundary. When the text is cut short, a + continuation marker is appended so it's clear more content follows at the + documentation link. + + Args: + text: The summary text. + + Returns: + A summary no longer than MAX_SUMMARY_LENGTH (plus the marker). + """ + text = text.strip() + if len(text) <= MAX_SUMMARY_LENGTH: + return text + + window = text[:MAX_SUMMARY_LENGTH + 1] + + # Prefer a sentence boundary, as long as it keeps most of the window. + sentence_end = max(window.rfind('. '), window.rfind('! '), window.rfind('? ')) + if sentence_end >= MAX_SUMMARY_LENGTH // 2: + return window[:sentence_end + 1] + CONTINUATION_MARKER + + cut = window.rfind(' ') + if cut <= 0: + cut = MAX_SUMMARY_LENGTH + return window[:cut].rstrip(' ,;:-') + CONTINUATION_MARKER + + def _extract_summary(content): """Extract a clean summary from MCP doc content. MCP returns markdown content with headers. Extract the Summary section - or first meaningful paragraph. + or first meaningful paragraph, then trim it to a readable length with a + continuation marker when the text is cut short. Args: content: Raw markdown content string. Returns: - Clean summary string, max 150 chars. + Clean summary string, at most MAX_SUMMARY_LENGTH chars plus a + continuation marker. """ if not content: return "" @@ -450,19 +505,30 @@ def _extract_summary(content): # Try to find a "### Summary" section summary_match = re.search(r'###\s*Summary\s*\n(.+?)(?:\n###|\Z)', content, re.DOTALL) if summary_match: - summary = summary_match.group(1).strip() - # Take first sentence/line - first_line = summary.split('\n')[0].strip() - if first_line: - return first_line[:150] + summary = _clean_markdown(summary_match.group(1).split('\n\n')[0]) + if summary: + return _shorten(summary) - # Try first non-header, non-empty line + # Try the first meaningful prose line, preferring one that reads as a + # complete thought over a short fragment or heading-like lead-in. + candidates = [] for line in content.split('\n'): line = line.strip() - if line and not line.startswith('#') and not line.startswith('---'): - return line[:150] + if not line or line.startswith(('#', '---', '|', '```', '>')): + continue + cleaned = _clean_markdown(line) + if cleaned: + candidates.append(cleaned) + if len(candidates) >= 10: + break + + if candidates: + best = next((c for c in candidates + if len(c) >= MIN_SUMMARY_LENGTH and not c.endswith((':', ';', ','))), + candidates[0]) + return _shorten(best) - return content[:150] + return _shorten(_clean_markdown(content)) def _clean_title(title): From 0fa7870ec193e8bae8071df8b48f9a4267189db9 Mon Sep 17 00:00:00 2001 From: necusjz Date: Mon, 27 Jul 2026 16:10:08 +1000 Subject: [PATCH 07/10] feat: deduplication by title --- .../azure/cli/command_modules/find/custom.py | 161 ++++++++++++------ 1 file changed, 105 insertions(+), 56 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/find/custom.py b/src/azure-cli/azure/cli/command_modules/find/custom.py index 6a4aad72ead..93c21b228a8 100644 --- a/src/azure-cli/azure/cli/command_modules/find/custom.py +++ b/src/azure-cli/azure/cli/command_modules/find/custom.py @@ -655,6 +655,92 @@ def _extract_command(snippet): return result +def _dedupe_key(text): + """Normalize text into a key for duplicate detection. + + Lowercases and collapses whitespace and punctuation so titles that differ + only in casing or trailing punctuation compare equal. + + Args: + text: The text to normalize. + + Returns: + Normalized key string, or '' if there's nothing meaningful. + """ + return re.sub(r'[^a-z0-9]+', ' ', (text or "").lower()).strip() + + +def _collect_unique(results, limit, build_entry): + """Collect entries from results, skipping duplicates and empty entries. + + An entry is skipped when its URL or its normalized title has already been + seen, so the same article never appears twice even if its URL fragment + differs. + + Args: + results: List of raw result dicts from MCP. + limit: Maximum number of entries to collect. + build_entry: Callable taking a result and returning + (title, body, url), or None to skip the result. + + Returns: + List of (title, body, url) tuples. + """ + entries = [] + seen_urls = set() + seen_titles = set() + + for result in results: + entry = build_entry(result) + if not entry: + continue + + title, _, url = entry + title_key = _dedupe_key(title) + if (url and url in seen_urls) or (title_key and title_key in seen_titles): + continue + + seen_urls.add(url) + seen_titles.add(title_key) + entries.append(entry) + if len(entries) >= limit: + break + + return entries + + +def _build_example_entry(result): + """Build an example entry from a code sample result. + + Args: + result: A code sample dict from MCP. + + Returns: + (title, command_lines, url) tuple, or None if there's no `az` command. + """ + command_lines = _extract_command(result.get("codeSnippet", "")) + if not command_lines: + return None + + return (_extract_description(result.get("description", "")), + command_lines, + result.get("link", "")) + + +def _build_doc_entry(result): + """Build a documentation entry from a doc result. + + Args: + result: A doc result dict from MCP. + + Returns: + (title, summary, url) tuple. + """ + return (_clean_title(result.get("title", "")), + _extract_summary(result.get("content", "")), + result.get("contentUrl", "")) + + def format_results(query, docs_results, code_results): """Format and print search results to stdout. @@ -674,64 +760,27 @@ def format_results(query, docs_results, code_results): print("\nHere is what I found for [" + query + "]: \n", file=sys.stderr) - if code_results: - examples = [] - seen_urls = set() - for result in code_results: - command_lines = _extract_command(result.get("codeSnippet", "")) - if not command_lines: - continue - url = result.get("link", "") - # Skip duplicate URLs, keeping only the first occurrence. - if url and url in seen_urls: - continue + examples = _collect_unique(code_results, MAX_CODE_RESULTS, _build_example_entry) + if examples: + print("Examples") + for title, command_lines, url in examples: + print(format_styled_text((Style.HIGHLIGHT, " - " + title))) + for line in command_lines: + print(" " + line) if url: - seen_urls.add(url) - examples.append(( - _extract_description(result.get("description", "")), - command_lines, - url - )) - if len(examples) >= MAX_CODE_RESULTS: - break - - if examples: - print("Examples") - for title, command_lines, url in examples: - print(format_styled_text((Style.HIGHLIGHT, " - " + title))) - for line in command_lines: - print(" " + line) - if url: - print(" " + format_styled_text((Style.SECONDARY, url))) - print() - - if docs_results: - docs = [] - seen_urls = set() - for result in docs_results: - url = result.get("contentUrl", "") - # Skip duplicate URLs, keeping only the first occurrence. - if url and url in seen_urls: - continue + print(" " + format_styled_text((Style.SECONDARY, url))) + print() + + docs = _collect_unique(docs_results, MAX_DOC_RESULTS, _build_doc_entry) + if docs: + print("Documentation") + for title, summary, url in docs: + print(format_styled_text((Style.HIGHLIGHT, " - " + title))) + if summary: + print(" " + summary) if url: - seen_urls.add(url) - docs.append(( - _clean_title(result.get("title", "")), - _extract_summary(result.get("content", "")), - url - )) - if len(docs) >= MAX_DOC_RESULTS: - break - - if docs: - print("Documentation") - for title, summary, url in docs: - print(format_styled_text((Style.HIGHLIGHT, " - " + title))) - if summary: - print(" " + summary) - if url: - print(" " + format_styled_text((Style.SECONDARY, url))) - print() + print(" " + format_styled_text((Style.SECONDARY, url))) + print() def process_query(cli_term): From 0714207837474b0bc8ddbbf89afc0e68a3b26476 Mon Sep 17 00:00:00 2001 From: necusjz Date: Mon, 27 Jul 2026 16:24:43 +1000 Subject: [PATCH 08/10] fix: more stop words --- .../azure/cli/command_modules/find/custom.py | 30 ++-- .../find/tests/latest/test_find.py | 132 +++++++++++++++++- 2 files changed, 148 insertions(+), 14 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/find/custom.py b/src/azure-cli/azure/cli/command_modules/find/custom.py index 93c21b228a8..4043b8808c6 100644 --- a/src/azure-cli/azure/cli/command_modules/find/custom.py +++ b/src/azure-cli/azure/cli/command_modules/find/custom.py @@ -34,6 +34,17 @@ MIN_SUMMARY_LENGTH = 40 CONTINUATION_MARKER = ' ... (see link for the full article)' +# Filler words that carry no search signal and would otherwise skew filtering +STOP_WORDS = { + 'about', 'all', 'also', 'an', 'and', 'any', 'are', 'as', 'at', 'be', 'been', 'being', 'but', + 'by', 'can', 'could', 'did', 'do', 'does', 'for', 'from', 'had', 'has', 'have', 'here', 'how', + 'if', 'in', 'into', 'is', 'it', 'its', 'may', 'me', 'might', 'must', 'my', 'no', 'not', 'of', + 'on', 'or', 'our', 'over', 'please', 'shall', 'should', 'so', 'some', 'such', 'than', 'that', + 'the', 'their', 'them', 'then', 'there', 'these', 'they', 'this', 'those', 'to', 'up', 'us', + 'via', 'want', 'was', 'we', 'were', 'what', 'when', 'where', 'which', 'while', 'who', 'why', + 'will', 'with', 'would', 'you', 'your', +} + Example = namedtuple("Example", "title snippet") @@ -254,7 +265,11 @@ def _filter_results(results, query): def _get_query_keywords(query): - """Extract meaningful keywords from the query (words with 3+ chars, excluding 'az'). + """Extract meaningful keywords from the query. + + Keeps words of 2+ characters so short but meaningful Azure terms such as + 'vm', 'ad' or 'k8s' survive, while dropping the 'az' prefix and common + filler words (articles, auxiliaries, interrogatives) that carry no signal. Args: query: The user's query string. @@ -262,9 +277,8 @@ def _get_query_keywords(query): Returns: Set of lowercase keyword strings. """ - words = re.findall(r'[a-zA-Z]{3,}', query.lower()) - stop_words = {'the', 'and', 'for', 'with', 'from', 'that', 'this', 'are', 'was', 'has', 'have'} - return {w for w in words if w != 'az' and w not in stop_words} + words = re.findall(r'[a-z0-9]{2,}', query.lower()) + return {w for w in words if w != 'az' and w not in STOP_WORDS} def _stem(word): @@ -306,7 +320,7 @@ def _matches_keywords(text, query_words): Returns: True if at least one query keyword stem matches a word stem in the text. """ - text_stems = {_stem(w) for w in re.findall(r'[a-zA-Z]+', text.lower())} + text_stems = {_stem(w) for w in re.findall(r'[a-z0-9]+', text.lower())} return any(_stem(word) in text_stems for word in query_words) @@ -754,8 +768,8 @@ def format_results(query, docs_results, code_results): code_results: List of code sample dicts from MCP. """ if not docs_results and not code_results: - print("\nSorry I am not able to help with [" + query + "]." - "\nTry typing the beginning of a command e.g., " + style_message('az vm') + ".", file=sys.stderr) + print('\nSorry I am not able to help with [' + query + '].' + '\nTry typing the beginning of a command, e.g., "az vm create".\n', file=sys.stderr) return print("\nHere is what I found for [" + query + "]: \n", file=sys.stderr) @@ -785,7 +799,7 @@ def format_results(query, docs_results, code_results): def process_query(cli_term): if not cli_term: - logger.error('Please provide a search term, e.g., az find "vm"') + logger.error('Please provide a search term, e.g., az find "az vm create".') else: print(random.choice(WAIT_MESSAGE), file=sys.stderr) diff --git a/src/azure-cli/azure/cli/command_modules/find/tests/latest/test_find.py b/src/azure-cli/azure/cli/command_modules/find/tests/latest/test_find.py index 26e351debc9..ac36ab47a7e 100644 --- a/src/azure-cli/azure/cli/command_modules/find/tests/latest/test_find.py +++ b/src/azure-cli/azure/cli/command_modules/find/tests/latest/test_find.py @@ -13,7 +13,8 @@ get_generated_examples, process_query, _extract_summary, _is_cli_command_relevant, _extract_query_command, _filter_results, _get_query_keywords, _has_keyword_overlap, _stem, _matches_keywords, - MAX_DOC_RESULTS, MAX_CODE_RESULTS + _extract_command, _build_docs_query, _prefer_cli_docs, _dedupe_key, + MAX_DOC_RESULTS, MAX_CODE_RESULTS, MAX_SUMMARY_LENGTH, CONTINUATION_MARKER ) @@ -69,7 +70,7 @@ def _make_tool_response(results, request_id=2): }, { "title": "Delete a VM and attached resources", - "content": "# Delete a VM and attached resources\nYou can change the behavior when you delete a VM.", + "content": "# Delete a VM and attached resources\nYou can change the behavior when you delete a VM.\n\naz vm delete --resource-group myResourceGroup --name myVM", "contentUrl": "https://learn.microsoft.com/azure/virtual-machines/delete" } ] @@ -210,7 +211,27 @@ def test_extract_summary_empty(self): def test_extract_summary_truncates(self): long_content = "A" * 200 summary = _extract_summary(long_content) - self.assertEqual(len(summary), 150) + self.assertTrue(summary.endswith(CONTINUATION_MARKER)) + self.assertEqual(len(summary), MAX_SUMMARY_LENGTH + len(CONTINUATION_MARKER)) + + def test_extract_summary_marks_truncated_sentence(self): + content = "Create a VM with the Azure CLI. " + ("word " * 60) + summary = _extract_summary(content) + self.assertTrue(summary.startswith("Create a VM with the Azure CLI.")) + self.assertTrue(summary.endswith(CONTINUATION_MARKER)) + + def test_extract_summary_not_truncated_has_no_marker(self): + self.assertNotIn(CONTINUATION_MARKER, _extract_summary("A short summary.")) + + def test_extract_summary_strips_markdown_links(self): + content = "# Title\n[New-AZVM](https://learn.microsoft.com/powershell) creates the resources you need." + self.assertEqual(_extract_summary(content), + "New-AZVM creates the resources you need.") + + def test_extract_summary_skips_fragment_lines(self): + content = "# Title\nIf you plan to use Cloud Shell:\nCloud Shell is an interactive shell that you run in your browser." + self.assertEqual(_extract_summary(content), + "Cloud Shell is an interactive shell that you run in your browser.") class TestRelevanceFiltering(unittest.TestCase): @@ -275,10 +296,16 @@ def test_filter_results_gibberish_query(self): self.assertEqual(len(filtered), 0) def test_get_query_keywords(self): - self.assertEqual(_get_query_keywords("az vm create"), {"create"}) # 'az' excluded, 'vm' < 3 chars + # 'az' is dropped, short-but-meaningful terms like 'vm' are kept + self.assertEqual(_get_query_keywords("az vm create"), {"vm", "create"}) self.assertEqual(_get_query_keywords("az storage blob list"), {"storage", "blob", "list"}) self.assertEqual(_get_query_keywords("deploy arm template"), {"deploy", "arm", "template"}) + def test_get_query_keywords_drops_filler_words(self): + # Regression: natural-language queries must keep their subject + self.assertEqual(_get_query_keywords("what is vm"), {"vm"}) + self.assertEqual(_get_query_keywords("how do i create a vm"), {"vm", "create"}) + def test_has_keyword_overlap_match(self): result = {"title": "az vm create", "content": "Create a virtual machine"} self.assertTrue(_has_keyword_overlap(result, {"create"})) @@ -308,8 +335,75 @@ def test_has_keyword_overlap_morphological_variant(self): self.assertTrue(_has_keyword_overlap(result, _get_query_keywords("creating a vm"))) -class TestSearchMslearn(unittest.TestCase): +class TestCliScoping(unittest.TestCase): + + def test_build_docs_query_appends_hints(self): + query = _build_docs_query("create a vm") + self.assertTrue(query.startswith("create a vm")) + self.assertIn("Azure CLI", query) + + def test_build_docs_query_skips_existing_hint(self): + self.assertEqual(_build_docs_query("azure cli vm create").count("Azure CLI"), 0) + + def test_prefer_cli_docs_drops_non_cli(self): + results = [ + {"title": "Create a VM in the portal", "content": "Select Create.", "contentUrl": "https://x/portal"}, + {"title": "az vm create", "content": "Create a VM.", "contentUrl": "https://learn.microsoft.com/cli/azure/vm"}, + ] + kept = _prefer_cli_docs(results) + self.assertEqual([r["title"] for r in kept], ["az vm create"]) + + def test_prefer_cli_docs_ranks_reference_first(self): + results = [ + {"title": "Tutorial", "content": "Run:\naz vm create --name myVM", "contentUrl": "https://x/tutorial"}, + {"title": "az vm create", "content": "Create a VM.", "contentUrl": "https://learn.microsoft.com/cli/azure/vm"}, + ] + self.assertEqual([r["title"] for r in _prefer_cli_docs(results)], ["az vm create", "Tutorial"]) + + def test_prefer_cli_docs_falls_back_when_none_match(self): + results = [{"title": "Portal", "content": "Select Create.", "contentUrl": "https://x/portal"}] + self.assertEqual(_prefer_cli_docs(results), results) + + +class TestExtractCommand(unittest.TestCase): + + def test_collapses_backslash_continuations(self): + snippet = "az vm create \\\n -n myVM \\\n --image myImage" + self.assertEqual(_extract_command(snippet), ["az vm create -n myVM --image myImage"]) + + def test_collapses_caret_and_backtick_continuations(self): + self.assertEqual(_extract_command("az vm create ^\n -n myVM"), ["az vm create -n myVM"]) + self.assertEqual(_extract_command("az vm create `\n -n myVM"), ["az vm create -n myVM"]) + def test_collapses_mistyped_slash_continuation(self): + snippet = "az storage account create -n acc /\n -g rg --sku Standard_GRS" + self.assertEqual(_extract_command(snippet), ["az storage account create -n acc -g rg --sku Standard_GRS"]) + + def test_keeps_trailing_slash_in_values(self): + snippet = "az storage blob upload --url https://x.blob.core.windows.net/c/" + self.assertEqual(_extract_command(snippet), [snippet]) + + def test_keeps_separate_commands_on_separate_lines(self): + self.assertEqual(_extract_command("az vm create -n x\n\naz vm show -n x"), + ["az vm create -n x", "az vm show -n x"]) + + def test_no_command_returns_empty(self): + self.assertEqual(_extract_command("Write-Host hello"), []) + self.assertEqual(_extract_command(""), []) + + +class TestDedupeKey(unittest.TestCase): + + def test_ignores_case_and_punctuation(self): + self.assertEqual(_dedupe_key("Create an Azure storage account"), + _dedupe_key("Create an Azure Storage Account.")) + + def test_empty_input(self): + self.assertEqual(_dedupe_key(""), "") + self.assertEqual(_dedupe_key(None), "") + + +class TestSearchMslearn(unittest.TestCase): @mock.patch('azure.cli.command_modules.find.custom.telemetry_core') @mock.patch('requests.post') def test_search_returns_docs_and_code(self, mock_post, mock_telemetry): @@ -390,15 +484,17 @@ def test_format_code_only(self, mock_stdout, _): @mock.patch('azure.cli.command_modules.find.custom.should_enable_styling', return_value=False) @mock.patch('sys.stdout', new_callable=StringIO) def test_format_caps_results(self, mock_stdout, _): - # Create more (distinct-URL) results than the max. + # Create more (distinct URL and title) results than the max. many_docs = [] for i in range(MAX_DOC_RESULTS + 5): doc = dict(SAMPLE_DOC_RESULTS[0]) + doc["title"] = "az vm delete %d" % i doc["contentUrl"] = "https://learn.microsoft.com/doc/%d" % i many_docs.append(doc) many_code = [] for i in range(MAX_CODE_RESULTS + 5): code = dict(SAMPLE_CODE_RESULTS[0]) + code["description"] = "Deletes an Azure virtual machine, variant %d." % i code["link"] = "https://learn.microsoft.com/code/%d" % i many_code.append(code) format_results("az vm", many_docs, many_code) @@ -423,6 +519,7 @@ def test_format_dedupes_by_url(self, mock_stdout, _): dup_code = dict(SAMPLE_CODE_RESULTS[0]) extra_code = dict(SAMPLE_CODE_RESULTS[1]) + extra_code["description"] = "Deletes a virtual machine and its disks in Azure." extra_code["link"] = "https://learn.microsoft.com/code/extra" code = [SAMPLE_CODE_RESULTS[0], dup_code, SAMPLE_CODE_RESULTS[1], extra_code] @@ -436,6 +533,29 @@ def test_format_dedupes_by_url(self, mock_stdout, _): self.assertIn("https://learn.microsoft.com/doc/extra", output) self.assertIn("https://learn.microsoft.com/code/extra", output) + @mock.patch('azure.cli.command_modules.find.custom.should_enable_styling', return_value=False) + @mock.patch('sys.stdout', new_callable=StringIO) + def test_format_dedupes_by_title(self, mock_stdout, _): + # Same article under different URL fragments must appear only once. + dup_doc = dict(SAMPLE_DOC_RESULTS[0]) + dup_doc["title"] = "AZ VM Delete." # same title, different casing/punctuation + dup_doc["contentUrl"] = "https://learn.microsoft.com/cli/azure/vm#delete" + other_doc = dict(SAMPLE_DOC_RESULTS[1]) + + dup_code = dict(SAMPLE_CODE_RESULTS[0]) + dup_code["link"] = "https://learn.microsoft.com/code/other-fragment" + + format_results("az vm", [SAMPLE_DOC_RESULTS[0], dup_doc, other_doc], + [SAMPLE_CODE_RESULTS[0], dup_code, SAMPLE_CODE_RESULTS[1]]) + output = mock_stdout.getvalue() + + self.assertEqual(output.count("az vm delete."), 1) + self.assertNotIn("https://learn.microsoft.com/cli/azure/vm#delete", output) + self.assertNotIn("https://learn.microsoft.com/code/other-fragment", output) + # The next unique results still fill the freed slots. + self.assertIn(SAMPLE_DOC_RESULTS[1]["contentUrl"], output) + self.assertIn(SAMPLE_CODE_RESULTS[1]["link"], output) + class TestProcessQuery(unittest.TestCase): From 3558781671bbf5593191395ee610bfa980950f21 Mon Sep 17 00:00:00 2001 From: necusjz Date: Mon, 27 Jul 2026 16:51:12 +1000 Subject: [PATCH 09/10] style: clean code --- .../azure/cli/command_modules/find/custom.py | 629 +++++------------- .../find/tests/latest/test_find.py | 38 +- 2 files changed, 180 insertions(+), 487 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/find/custom.py b/src/azure-cli/azure/cli/command_modules/find/custom.py index 4043b8808c6..62f138fbd67 100644 --- a/src/azure-cli/azure/cli/command_modules/find/custom.py +++ b/src/azure-cli/azure/cli/command_modules/find/custom.py @@ -2,31 +2,30 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -from collections import namedtuple import hashlib -import random import json +import platform import re import sys -import platform + import requests import colorama # pylint: disable=import-error - from azure.cli.core import telemetry as telemetry_core from azure.cli.core import __version__ as core_version from azure.cli.core.style import Style, format_styled_text from packaging.version import parse from knack.log import get_logger + logger = get_logger(__name__) -WAIT_MESSAGE = ['\nFinding examples and documentation...'] +WAIT_MESSAGE = '\nFinding examples and documentation...' -# Display limits -MAX_DOC_RESULTS = 2 +# Number of entries printed in each section MAX_CODE_RESULTS = 3 +MAX_DOC_RESULTS = 2 -# Hints appended to the docs query so results stay Azure CLI specific +# Hints appended to the docs query so the semantic search stays Azure CLI specific DOCS_QUERY_HINTS = ['Azure CLI', 'az command'] # Doc summary length and the marker shown when a summary is cut short @@ -45,11 +44,9 @@ 'will', 'with', 'would', 'you', 'your', } -Example = namedtuple("Example", "title snippet") - class MCPClient: - """Lightweight MCP client for Microsoft Learn MCP Server. + """Lightweight MCP client for the Microsoft Learn MCP server. Implements the minimum JSON-RPC 2.0 over Streamable HTTP protocol needed for single-invocation tool calls (initialize → notify → tools/call). @@ -68,30 +65,21 @@ def __init__(self, client_version=None): self._context = self._build_telemetry_context() def _build_telemetry_context(self): - """Build telemetry context sent alongside MCP requests. + """Build the telemetry context sent alongside MCP requests. - Mirrors the dev branch behavior: the hashed installation id is always - sent as the ``X-UserId`` header (used for DDOS protection and rate - limiting), while the remaining contextual values are only included when - the user has consented to telemetry. + The hashed installation id is always sent as the ``X-UserId`` header + (used for DDOS protection and rate limiting); the remaining contextual + values are only included when the user has consented to telemetry. """ - # Used for DDOS protection and rate limiting user_id = telemetry_core._get_installation_id() # pylint: disable=protected-access - hashed_user_id = hashlib.sha256(user_id.encode('utf-8')).hexdigest() - self._headers["X-UserId"] = hashed_user_id + self._headers["X-UserId"] = hashlib.sha256(user_id.encode('utf-8')).hexdigest() - context = { - "versionNumber": self.client_version, - } + context = {"versionNumber": self.client_version} - # Only pull in the contextual values if we have consent if telemetry_core.is_telemetry_enabled(): - correlation_id = telemetry_core._session.correlation_id # pylint: disable=protected-access - event_id = telemetry_core._session.event_id # pylint: disable=protected-access + context["correlationId"] = telemetry_core._session.correlation_id # pylint: disable=protected-access + context["eventId"] = telemetry_core._session.event_id # pylint: disable=protected-access subscription_id = telemetry_core._get_azure_subscription_id() # pylint: disable=protected-access - - context["correlationId"] = correlation_id - context["eventId"] = event_id if subscription_id is not None: context["subscriptionId"] = subscription_id @@ -106,7 +94,7 @@ def _next_id(self): return self._request_id def initialize(self): - """Send initialize request and store session ID.""" + """Send the initialize request and store the session ID.""" body = { "jsonrpc": "2.0", "id": self._next_id(), @@ -128,20 +116,12 @@ def initialize(self): return self._parse_sse(resp.text) def notify_initialized(self): - """Send initialized notification to confirm client readiness.""" + """Send the initialized notification to confirm client readiness.""" body = {"jsonrpc": "2.0", "method": "notifications/initialized"} requests.post(self.MCP_ENDPOINT, json=body, headers=self._headers, params=self._params, timeout=10) def call_tool(self, tool_name, arguments): - """Call an MCP tool and return parsed results. - - Args: - tool_name: Name of the MCP tool (e.g., 'microsoft_docs_search'). - arguments: Dict of tool arguments. - - Returns: - Parsed JSON result from the tool's text content. - """ + """Call an MCP tool (e.g. 'microsoft_docs_search') and return its parsed result.""" body = { "jsonrpc": "2.0", "id": self._next_id(), @@ -153,110 +133,112 @@ def call_tool(self, tool_name, arguments): } resp = requests.post(self.MCP_ENDPOINT, json=body, headers=self._headers, params=self._params, timeout=30) resp.raise_for_status() - result = self._parse_sse(resp.text) - content_list = result.get("result", {}).get("content", []) - if content_list and content_list[0].get("text"): - return json.loads(content_list[0]["text"]) + content = self._parse_sse(resp.text).get("result", {}).get("content", []) + if content and content[0].get("text"): + return json.loads(content[0]["text"]) return {} @staticmethod def _parse_sse(text): - """Parse single-event SSE response. - - The MCP server returns responses in SSE format with a single 'data:' event. - """ + """Parse a single-event SSE response, which carries one 'data:' line.""" for line in text.split("\n"): if line.startswith("data: "): return json.loads(line[6:]) return {} -def _extract_query_command(query): - """Extract the CLI command group/name from a query string. +def _get_query_keywords(query): + """Extract the meaningful keywords of a query. + + Words of 2+ characters are kept so short but meaningful Azure terms such as + 'vm', 'ad' or 'k8s' survive; the 'az' prefix and filler words (articles, + auxiliaries, interrogatives) are dropped because they carry no signal. + """ + words = re.findall(r'[a-z0-9]{2,}', query.lower()) + return {w for w in words if w != 'az' and w not in STOP_WORDS} + - Examples: - 'az vm create' → 'az vm create' - 'az vm' → 'az vm' - 'vm create' → 'az vm create' - 'deploy arm template' → None (not a CLI command pattern) +def _stem(word): + """Reduce a word to a crude stem so morphological variants match. - Returns: - Normalized command string or None if not a CLI command pattern. + Strips common inflectional suffixes and a trailing 'e' so that 'creating', + 'creates', 'created' and 'create' all collapse to 'creat'. A lightweight, + dependency-free approximation of a real stemmer, good enough for matching + query terms against documentation and code samples. + """ + word = word.lower() + for suffix in ('ings', 'ing', 'ies', 'ied', 'es', 'ed', 's'): + if word.endswith(suffix) and len(word) - len(suffix) >= 3: + word = word[:-len(suffix)] + break + if len(word) > 3 and word.endswith('e'): + word = word[:-1] + return word + + +def _matches_keywords(text, query_words): + """Check whether any query keyword appears in the text, comparing stems.""" + text_stems = {_stem(w) for w in re.findall(r'[a-z0-9]+', text.lower())} + return any(_stem(word) in text_stems for word in query_words) + + +def _has_keyword_overlap(result, query_words): + """Check whether a doc result's title or content shares a keyword with the query.""" + combined = result.get("title", "") + " " + result.get("content", "")[:500] + return _matches_keywords(combined, query_words) + + +def _extract_query_command(query): + """Normalize a query into an `az` command, or None if it isn't one. + + 'az vm create' → 'az vm create'; 'vm create' → 'az vm create'; + 'deploy/arm template' → None (doesn't look like a command). """ query = query.strip().lower() if query.startswith('az '): return query - # If the query looks like a CLI subcommand (single words that could be a group) + parts = query.split() - if parts and not any(c in parts[0] for c in ' ./-'): + if parts and not any(c in parts[0] for c in './-'): return 'az ' + query return None def _is_cli_command_relevant(title, query): - """Check if a CLI command result title is relevant to the query. - - For CLI command results (titles starting with 'az '), checks that - the result command shares the same command group as the query. - - Examples (query='az vm create'): - 'az vm create' → True (exact match) - 'az vm run-command create' → True (same group) - 'az lab vm create' → False (different group: 'lab' vs 'vm') - 'az connectedvmware vm create' → False (different group) + """Check whether a CLI command title belongs to the same command group as the query. - Args: - title: The result title string. - query: The user's query string. + Only titles that look like a command ('az ...') are judged; anything else + (tutorials, concept articles) is always considered relevant. - Returns: - True if the result is relevant, False otherwise. + With query='az vm create': 'az vm create' and 'az vm run-command create' + are relevant, while 'az lab vm create' and 'az connectedvmware vm create' + belong to other groups. """ title_lower = title.strip().lower() - - # Only filter CLI command titles (starting with 'az ') if not title_lower.startswith('az '): return True - cmd = _extract_query_command(query) - if not cmd: - return True - - # Extract the command group (first word after 'az') - cmd_parts = cmd.split() - if len(cmd_parts) < 2: + command = _extract_query_command(query) + if not command: return True - query_group = cmd_parts[1] # e.g., 'vm' from 'az vm create' - + command_parts = command.split() title_parts = title_lower.split() - if len(title_parts) < 2: + if len(command_parts) < 2 or len(title_parts) < 2: return True - title_group = title_parts[1] # e.g., 'lab' from 'az lab vm create' - - # The result's first command group must match the query's command group - return title_group == query_group + # e.g. 'vm' from 'az vm create' vs 'lab' from 'az lab vm create' + return title_parts[1] == command_parts[1] def _filter_results(results, query): - """Filter MCP results for relevance to the query. - - Removes CLI command results that belong to different command groups, - and filters out results whose titles have no meaningful word overlap - with the query (to discard noise from semantic search on gibberish queries). + """Drop doc results that belong to another command group or share no keyword. - Args: - results: List of MCP doc result dicts. - query: The user's query string. - - Returns: - Filtered list of result dicts. + The keyword check discards the noise a semantic search returns for + gibberish queries. """ filtered = [r for r in results if _is_cli_command_relevant(r.get("title", ""), query)] - # For non-empty queries, check that at least some results are genuinely relevant - # by verifying word overlap between the query and result titles/content query_words = _get_query_keywords(query) if query_words: filtered = [r for r in filtered if _has_keyword_overlap(r, query_words)] @@ -264,94 +246,11 @@ def _filter_results(results, query): return filtered -def _get_query_keywords(query): - """Extract meaningful keywords from the query. - - Keeps words of 2+ characters so short but meaningful Azure terms such as - 'vm', 'ad' or 'k8s' survive, while dropping the 'az' prefix and common - filler words (articles, auxiliaries, interrogatives) that carry no signal. - - Args: - query: The user's query string. - - Returns: - Set of lowercase keyword strings. - """ - words = re.findall(r'[a-z0-9]{2,}', query.lower()) - return {w for w in words if w != 'az' and w not in STOP_WORDS} - - -def _stem(word): - """Reduce a word to a crude stem so morphological variants match. - - Strips common inflectional suffixes and a trailing 'e' so that variants - such as 'creating', 'creates', 'created' and 'create' all collapse to the - same stem ('creat'). This is a lightweight, dependency-free approximation - (not a full Porter stemmer) that is sufficient for matching query terms - against documentation and code samples. - - Args: - word: A single word. - - Returns: - The stemmed, lowercased word. - """ - word = word.lower() - for suffix in ('ings', 'ing', 'ies', 'ied', 'es', 'ed', 's'): - if word.endswith(suffix) and len(word) - len(suffix) >= 3: - word = word[:-len(suffix)] - break - if len(word) > 3 and word.endswith('e'): - word = word[:-1] - return word - - -def _matches_keywords(text, query_words): - """Check if any query keyword matches a word in the text after stemming. - - Both the query keywords and the text words are stemmed before comparison, - so morphological variants match (e.g. query 'creating' matches text - 'create'). - - Args: - text: The text to search within. - query_words: Set of query keywords to match against. - - Returns: - True if at least one query keyword stem matches a word stem in the text. - """ - text_stems = {_stem(w) for w in re.findall(r'[a-z0-9]+', text.lower())} - return any(_stem(word) in text_stems for word in query_words) - - -def _has_keyword_overlap(result, query_words): - """Check if a result has meaningful keyword overlap with the query. - - Args: - result: A doc result dict with 'title' and optionally 'content'. - query_words: Set of query keywords to match against. - - Returns: - True if at least one query keyword appears in the result's title or content. - """ - title = result.get("title", "") - content = result.get("content", "")[:500] # Only check first 500 chars of content - combined = title + " " + content - - return _matches_keywords(combined, query_words) - - def _build_docs_query(query): - """Scope a user query to Azure CLI documentation. - - The docs search is semantic and otherwise returns portal/PowerShell/SDK - articles. Appending Azure CLI hints biases results toward `az` content. + """Append Azure CLI hints to a query so the docs search stays CLI specific. - Args: - query: The user's query string. - - Returns: - The query string with Azure CLI hint keywords appended. + Without them the semantic search happily returns portal, PowerShell or SDK + articles. Hints already present in the query are not repeated. """ query = (query or "").strip() lowered = query.lower() @@ -362,62 +261,43 @@ def _build_docs_query(query): def _cli_doc_score(result): """Score how Azure CLI specific a doc result is. - Args: - result: A doc result dict from MCP. - - Returns: - 3 for the `az` command reference, 2 for content with `az` invocations, - 1 for content merely mentioning the Azure CLI, 0 otherwise. + 3 for the `az` command reference, 2 for content showing `az` invocations, + 1 for content merely mentioning the Azure CLI, 0 for everything else. """ - url = (result.get("contentUrl", "") or "").lower() - if "/cli/azure" in url: + if "/cli/azure" in (result.get("contentUrl", "") or "").lower(): return 3 content = (result.get("content", "") or "") + " " + (result.get("title", "") or "") - if re.search(r'(?m)^\s*az\s+[a-z][\w-]*', content) or "azurecli" in content.lower(): + lowered = content.lower() + if "azurecli" in lowered or re.search(r'(?m)^\s*az\s+[a-z][\w-]*', content): return 2 - return 1 if "azure cli" in content.lower() else 0 + return 1 if "azure cli" in lowered else 0 def _prefer_cli_docs(results): - """Keep and rank the Azure CLI related doc results. + """Keep the Azure CLI related doc results, most CLI specific first. - Results without any Azure CLI signal are dropped; the rest are ordered - most-CLI-specific first, preserving the server's relative ranking within - the same score. Falls back to the original results if none look CLI - specific, so the user still gets something back. - - Args: - results: List of doc result dicts from MCP. - - Returns: - Filtered and ranked list of doc results. + The server's relative ranking is preserved within the same score. If no + result looks CLI specific at all, the original list is returned so the user + still gets something back. """ - scored = [(_cli_doc_score(r), i, r) for i, r in enumerate(results)] - cli_results = [item for item in scored if item[0] > 0] - if not cli_results: + scored = [(score, i, result) for i, result in enumerate(results) + if (score := _cli_doc_score(result)) > 0] + if not scored: return results - cli_results.sort(key=lambda item: (-item[0], item[1])) - return [r for _, _, r in cli_results] + scored.sort(key=lambda item: (-item[0], item[1])) + return [result for _, _, result in scored] def search_mslearn(query): - """Search Microsoft Learn via MCP for docs and code samples. - - Calls two MCP tools: - 1. microsoft_docs_search - for command reference and documentation - 2. microsoft_code_sample_search - for runnable CLI examples + """Search Microsoft Learn for docs and code samples matching the query. - Args: - query: Search query string (e.g., 'az vm delete'). - - Returns: - Tuple of (docs_results, code_results) where each is a list of dicts. + Returns a (docs_results, code_results) tuple, each a list of result dicts + already filtered down to what is relevant to the Azure CLI. """ client = MCPClient() - client.initialize() client.notify_initialized() @@ -425,44 +305,25 @@ def search_mslearn(query): "microsoft_docs_search", {"query": _build_docs_query(query)} ) - code_response = client.call_tool( "microsoft_code_sample_search", {"query": query, "language": "azurecli"} ) - docs_results = docs_response.get("results", []) - code_results = code_response.get("results", []) - - # Filter out irrelevant CLI commands (e.g., 'az lab vm' when searching 'az vm') - docs_results = _filter_results(docs_results, query) - - # Drop docs that aren't about the Azure CLI (portal/PowerShell/SDK articles) - docs_results = _prefer_cli_docs(docs_results) + docs_results = _prefer_cli_docs(_filter_results(docs_response.get("results", []), query)) - # Filter code results by keyword overlap too + code_results = code_response.get("results", []) query_words = _get_query_keywords(query) if query_words: code_results = [r for r in code_results - if _matches_keywords( - r.get("codeSnippet", "") + " " + r.get("description", ""), - query_words)] + if _matches_keywords(r.get("codeSnippet", "") + " " + r.get("description", ""), + query_words)] return docs_results, code_results def _clean_markdown(text): - """Strip markdown noise so a summary reads as plain prose. - - Converts `[label](url)` links to their label and removes emphasis and - inline code markers. - - Args: - text: Raw markdown text. - - Returns: - Plain-text version of the input. - """ + """Strip markdown noise (images, links, emphasis) so text reads as plain prose.""" text = re.sub(r'!\[[^\]]*\]\([^)]*\)', '', text) text = re.sub(r'\[([^\]]+)\]\([^)]*\)', r'\1', text) text = re.sub(r'[*_`]+', '', text) @@ -470,17 +331,11 @@ def _clean_markdown(text): def _shorten(text): - """Trim a summary to MAX_SUMMARY_LENGTH without cutting mid-word. - - Prefers ending on a sentence boundary. When the text is cut short, a - continuation marker is appended so it's clear more content follows at the - documentation link. + """Trim text to MAX_SUMMARY_LENGTH, never cutting mid-word. - Args: - text: The summary text. - - Returns: - A summary no longer than MAX_SUMMARY_LENGTH (plus the marker). + A sentence boundary is preferred as the cut point. Whenever text is + dropped, CONTINUATION_MARKER is appended so it's clear the rest of the + article lives behind the link. """ text = text.strip() if len(text) <= MAX_SUMMARY_LENGTH: @@ -488,7 +343,7 @@ def _shorten(text): window = text[:MAX_SUMMARY_LENGTH + 1] - # Prefer a sentence boundary, as long as it keeps most of the window. + # Only cut at a sentence boundary if that still keeps most of the window. sentence_end = max(window.rfind('. '), window.rfind('! '), window.rfind('? ')) if sentence_end >= MAX_SUMMARY_LENGTH // 2: return window[:sentence_end + 1] + CONTINUATION_MARKER @@ -500,31 +355,16 @@ def _shorten(text): def _extract_summary(content): - """Extract a clean summary from MCP doc content. - - MCP returns markdown content with headers. Extract the Summary section - or first meaningful paragraph, then trim it to a readable length with a - continuation marker when the text is cut short. - - Args: - content: Raw markdown content string. - - Returns: - Clean summary string, at most MAX_SUMMARY_LENGTH chars plus a - continuation marker. - """ + """Build a short, readable summary from a doc result's markdown content.""" if not content: return "" - # Try to find a "### Summary" section summary_match = re.search(r'###\s*Summary\s*\n(.+?)(?:\n###|\Z)', content, re.DOTALL) if summary_match: summary = _clean_markdown(summary_match.group(1).split('\n\n')[0]) if summary: return _shorten(summary) - # Try the first meaningful prose line, preferring one that reads as a - # complete thought over a short fragment or heading-like lead-in. candidates = [] for line in content.split('\n'): line = line.strip() @@ -537,6 +377,7 @@ def _extract_summary(content): break if candidates: + # Prefer a line that reads as a complete thought over a lead-in fragment. best = next((c for c in candidates if len(c) >= MIN_SUMMARY_LENGTH and not c.endswith((':', ';', ','))), candidates[0]) @@ -546,17 +387,7 @@ def _extract_summary(content): def _clean_title(title): - """Normalize a title into a real sentence. - - Strips leading markdown header markers ('#') and ensures the title - ends with a sentence mark (period, question mark, or exclamation mark). - - Args: - title: Raw title string. - - Returns: - Cleaned title string, or empty string if no title. - """ + """Normalize a title into a sentence: no leading '#', always end-punctuated.""" if not title: return "" @@ -569,16 +400,8 @@ def _clean_title(title): def _to_imperative(text): """Convert a leading third-person-singular verb to imperative mood. - Examples: - 'Deploys the template.' -> 'Deploy the template.' - 'Creates a virtual machine.' -> 'Create a virtual machine.' - 'Specifies the name.' -> 'Specify the name.' - - Args: - text: A description sentence. - - Returns: - The sentence with its first word converted to imperative mood. + 'Deploys the template.' → 'Deploy the template.' + 'Specifies the name.' → 'Specify the name.' """ if not text: return text @@ -594,17 +417,10 @@ def _to_imperative(text): def _extract_description(description): - """Extract the human-readable description from a code sample's metadata. - - The MCP code sample 'description' field is a metadata blob such as: - 'description: Deploys the ARM template ...\\nlanguage: azurecli\\n' - This extracts just the description text as a clean sentence. + """Pull the human-readable sentence out of a code sample's metadata blob. - Args: - description: Raw description metadata string. - - Returns: - Cleaned description sentence, or 'Example.' as a fallback. + The raw field looks like 'description: Deploys the ARM template\\nlanguage: + azurecli\\n'. """ if description: for line in description.split('\n'): @@ -612,7 +428,6 @@ def _extract_description(description): if line.lower().startswith('description:'): return _clean_title(_to_imperative(line[len('description:'):].strip())) - # Fallback: first non-empty, non-metadata line for line in description.split('\n'): line = line.strip() if line and not line.lower().startswith(('language:', 'package:')): @@ -622,17 +437,10 @@ def _extract_description(description): def _extract_command(snippet): - """Extract the `az` command block from a code snippet. - - Returns the command lines starting from the first line that begins with - 'az'. Shell line-continuations (`\\`, `^` or backtick) are collapsed so that - each command is returned as a single line. - - Args: - snippet: Raw code snippet string. + """Extract the `az` commands from a code snippet, one per line. - Returns: - List of single-line commands, or empty list. + Shell line-continuations are collapsed so a command that the docs wrapped + over many lines is printed as a single copy-pasteable line. """ if not snippet: return [] @@ -642,63 +450,61 @@ def _extract_command(snippet): if start is None: return [] - result = [] + commands = [] pending = None for line in lines[start:]: - stripped = line.strip() - if not stripped: + line = line.strip() + if not line: continue - continued = stripped.endswith(('\\', '^', '`')) - # Docs sometimes typo the continuation as ' /'; only treat a slash - # preceded by whitespace as one so trailing-slash values are kept. - if not continued and re.search(r'\s/$', stripped): - continued = True + # '/' is a typo for '\' seen in some docs; require preceding whitespace + # so that trailing slashes in URLs and paths are left alone. + continued = line.endswith(('\\', '^', '`')) or bool(re.search(r'\s/$', line)) if continued: - stripped = stripped[:-1].rstrip() - - pending = stripped if pending is None else (pending + ' ' + stripped).strip() + line = line[:-1].rstrip() + pending = line if pending is None else (pending + ' ' + line).strip() if not continued: - result.append(pending) + commands.append(pending) pending = None if pending: - result.append(pending) + commands.append(pending) - return result + return commands def _dedupe_key(text): - """Normalize text into a key for duplicate detection. + """Normalize text into a comparison key, ignoring case and punctuation.""" + return re.sub(r'[^a-z0-9]+', ' ', (text or "").lower()).strip() - Lowercases and collapses whitespace and punctuation so titles that differ - only in casing or trailing punctuation compare equal. - Args: - text: The text to normalize. +def _build_example_entry(result): + """Turn a code sample result into a (title, command lines, url) entry. - Returns: - Normalized key string, or '' if there's nothing meaningful. + Returns None when the sample contains no `az` command. """ - return re.sub(r'[^a-z0-9]+', ' ', (text or "").lower()).strip() + command_lines = _extract_command(result.get("codeSnippet", "")) + if not command_lines: + return None + return (_extract_description(result.get("description", "")), + command_lines, + result.get("link", "")) -def _collect_unique(results, limit, build_entry): - """Collect entries from results, skipping duplicates and empty entries. - An entry is skipped when its URL or its normalized title has already been - seen, so the same article never appears twice even if its URL fragment - differs. +def _build_doc_entry(result): + """Turn a doc result into a (title, summary, url) entry.""" + return (_clean_title(result.get("title", "")), + _extract_summary(result.get("content", "")), + result.get("contentUrl", "")) + - Args: - results: List of raw result dicts from MCP. - limit: Maximum number of entries to collect. - build_entry: Callable taking a result and returning - (title, body, url), or None to skip the result. +def _collect_unique(results, limit, build_entry): + """Build up to `limit` entries, skipping empty ones and duplicates. - Returns: - List of (title, body, url) tuples. + A result is skipped when its URL or its normalized title was already used, + so the same article never shows up twice under different URL fragments. """ entries = [] seen_urls = set() @@ -723,50 +529,18 @@ def _collect_unique(results, limit, build_entry): return entries -def _build_example_entry(result): - """Build an example entry from a code sample result. - - Args: - result: A code sample dict from MCP. - - Returns: - (title, command_lines, url) tuple, or None if there's no `az` command. - """ - command_lines = _extract_command(result.get("codeSnippet", "")) - if not command_lines: - return None - - return (_extract_description(result.get("description", "")), - command_lines, - result.get("link", "")) - - -def _build_doc_entry(result): - """Build a documentation entry from a doc result. - - Args: - result: A doc result dict from MCP. - - Returns: - (title, summary, url) tuple. - """ - return (_clean_title(result.get("title", "")), - _extract_summary(result.get("content", "")), - result.get("contentUrl", "")) +def _print_entry(title, body_lines, url): + """Print one result: a highlighted title, its body, then the source link.""" + print(format_styled_text((Style.HIGHLIGHT, " - " + title))) + for line in body_lines: + print(" " + line) + if url: + print(" " + format_styled_text((Style.SECONDARY, url))) + print() def format_results(query, docs_results, code_results): - """Format and print search results to stdout. - - Displays results in two sections: - 1. Examples - from microsoft_code_sample_search (shown first) - 2. Documentation - from microsoft_docs_search - - Args: - query: Original search query (for display). - docs_results: List of doc result dicts from MCP. - code_results: List of code sample dicts from MCP. - """ + """Print the runnable examples first, then the documentation links.""" if not docs_results and not code_results: print('\nSorry I am not able to help with [' + query + '].' '\nTry typing the beginning of a command, e.g., "az vm create".\n', file=sys.stderr) @@ -778,30 +552,29 @@ def format_results(query, docs_results, code_results): if examples: print("Examples") for title, command_lines, url in examples: - print(format_styled_text((Style.HIGHLIGHT, " - " + title))) - for line in command_lines: - print(" " + line) - if url: - print(" " + format_styled_text((Style.SECONDARY, url))) - print() + _print_entry(title, command_lines, url) docs = _collect_unique(docs_results, MAX_DOC_RESULTS, _build_doc_entry) if docs: print("Documentation") for title, summary, url in docs: - print(format_styled_text((Style.HIGHLIGHT, " - " + title))) - if summary: - print(" " + summary) - if url: - print(" " + format_styled_text((Style.SECONDARY, url))) - print() + _print_entry(title, [summary] if summary else [], url) + + +def should_enable_styling(): + """Check whether output is going to a terminal that can render styling.""" + try: + return bool(sys.stdout.isatty()) + except AttributeError: + return False def process_query(cli_term): + """Entry point for `az find`.""" if not cli_term: logger.error('Please provide a search term, e.g., az find "az vm create".') else: - print(random.choice(WAIT_MESSAGE), file=sys.stderr) + print(WAIT_MESSAGE, file=sys.stderr) try: docs_results, code_results = search_mslearn(cli_term) @@ -821,47 +594,3 @@ def process_query(cli_term): from azure.cli.core.util import show_updates_available show_updates_available() - - -def get_generated_examples(cli_term): - """Get generated examples for a CLI term. - - Returns list of Example namedtuples for backward compatibility. - """ - examples = [] - try: - docs_results, code_results = search_mslearn(cli_term) - - for result in docs_results: - title = result.get("title", "") - summary = _extract_summary(result.get("content", "")) - examples.append(Example(title, summary)) - - for result in code_results: - snippet = result.get("codeSnippet", "") - desc = result.get("description", "") - examples.append(Example(desc[:100] if desc else "Example", snippet)) - - except requests.exceptions.RequestException: - pass - - return examples - - -def style_message(msg): - if should_enable_styling(): - try: - msg = colorama.Style.BRIGHT + msg + colorama.Style.RESET_ALL - except KeyError: - pass - return msg - - -def should_enable_styling(): - try: - # Style if tty stream is available - if sys.stdout.isatty(): - return True - except AttributeError: - pass - return False diff --git a/src/azure-cli/azure/cli/command_modules/find/tests/latest/test_find.py b/src/azure-cli/azure/cli/command_modules/find/tests/latest/test_find.py index ac36ab47a7e..0ffcaa230ef 100644 --- a/src/azure-cli/azure/cli/command_modules/find/tests/latest/test_find.py +++ b/src/azure-cli/azure/cli/command_modules/find/tests/latest/test_find.py @@ -9,8 +9,7 @@ from io import StringIO from azure.cli.command_modules.find.custom import ( - Example, MCPClient, search_mslearn, format_results, - get_generated_examples, process_query, _extract_summary, + MCPClient, search_mslearn, format_results, process_query, _extract_summary, _is_cli_command_relevant, _extract_query_command, _filter_results, _get_query_keywords, _has_keyword_overlap, _stem, _matches_keywords, _extract_command, _build_docs_query, _prefer_cli_docs, _dedupe_key, @@ -600,40 +599,5 @@ def test_process_query_empty_term(self, _): process_query(None) -class TestGetGeneratedExamples(unittest.TestCase): - - @mock.patch('azure.cli.command_modules.find.custom.telemetry_core') - @mock.patch('requests.post') - def test_get_generated_examples(self, mock_post, mock_telemetry): - mock_telemetry._get_installation_id.return_value = "test-install-id" - mock_telemetry.is_telemetry_enabled.return_value = False - - mock_post.side_effect = [ - _make_init_response(), - _make_notify_response(), - _make_tool_response(SAMPLE_DOC_RESULTS), - _make_tool_response(SAMPLE_CODE_RESULTS), - ] - - examples = get_generated_examples("az vm delete") - - self.assertGreater(len(examples), 0) - # Should return Example namedtuples - self.assertIsInstance(examples[0], Example) - self.assertEqual(examples[0].title, "az vm delete") - - @mock.patch('azure.cli.command_modules.find.custom.telemetry_core') - @mock.patch('requests.post') - def test_get_generated_examples_network_error(self, mock_post, mock_telemetry): - mock_telemetry._get_installation_id.return_value = "test-install-id" - mock_telemetry.is_telemetry_enabled.return_value = False - - import requests as req - mock_post.side_effect = req.exceptions.ConnectionError("fail") - - examples = get_generated_examples("az vm delete") - self.assertEqual(len(examples), 0) - - if __name__ == '__main__': unittest.main() From 1b43ade8676149732e5f14a0114218c59898d828 Mon Sep 17 00:00:00 2001 From: necusjz Date: Mon, 27 Jul 2026 17:06:57 +1000 Subject: [PATCH 10/10] fix: filter out non `az` --- .../azure/cli/command_modules/find/custom.py | 9 ++++++--- .../command_modules/find/tests/latest/test_find.py | 12 ++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/find/custom.py b/src/azure-cli/azure/cli/command_modules/find/custom.py index 62f138fbd67..d46bcf71aa9 100644 --- a/src/azure-cli/azure/cli/command_modules/find/custom.py +++ b/src/azure-cli/azure/cli/command_modules/find/custom.py @@ -541,20 +541,23 @@ def _print_entry(title, body_lines, url): def format_results(query, docs_results, code_results): """Print the runnable examples first, then the documentation links.""" - if not docs_results and not code_results: + # Collect first: results can still end up empty here, for instance when no + # code sample contains an actual `az` command. + examples = _collect_unique(code_results, MAX_CODE_RESULTS, _build_example_entry) + docs = _collect_unique(docs_results, MAX_DOC_RESULTS, _build_doc_entry) + + if not examples and not docs: print('\nSorry I am not able to help with [' + query + '].' '\nTry typing the beginning of a command, e.g., "az vm create".\n', file=sys.stderr) return print("\nHere is what I found for [" + query + "]: \n", file=sys.stderr) - examples = _collect_unique(code_results, MAX_CODE_RESULTS, _build_example_entry) if examples: print("Examples") for title, command_lines, url in examples: _print_entry(title, command_lines, url) - docs = _collect_unique(docs_results, MAX_DOC_RESULTS, _build_doc_entry) if docs: print("Documentation") for title, summary, url in docs: diff --git a/src/azure-cli/azure/cli/command_modules/find/tests/latest/test_find.py b/src/azure-cli/azure/cli/command_modules/find/tests/latest/test_find.py index 0ffcaa230ef..36f5a3195d7 100644 --- a/src/azure-cli/azure/cli/command_modules/find/tests/latest/test_find.py +++ b/src/azure-cli/azure/cli/command_modules/find/tests/latest/test_find.py @@ -464,6 +464,18 @@ def test_format_empty_results(self, mock_stderr, _): output = mock_stderr.getvalue() self.assertIn("Sorry I am not able to help with", output) + @mock.patch('azure.cli.command_modules.find.custom.should_enable_styling', return_value=False) + @mock.patch('sys.stdout', new_callable=StringIO) + @mock.patch('sys.stderr', new_callable=StringIO) + def test_format_unusable_results(self, mock_stderr, mock_stdout, _): + # Regression: code samples without an `az` command produce no entries, + # so the apology must be shown instead of an empty result header. + code = [dict(SAMPLE_CODE_RESULTS[0], codeSnippet="Remove-AzVM -Name myVM")] + format_results("what is rm", [], code) + self.assertIn("Sorry I am not able to help with", mock_stderr.getvalue()) + self.assertNotIn("Here is what I found", mock_stderr.getvalue()) + self.assertEqual(mock_stdout.getvalue(), "") + @mock.patch('azure.cli.command_modules.find.custom.should_enable_styling', return_value=False) @mock.patch('sys.stdout', new_callable=StringIO) def test_format_docs_only(self, mock_stdout, _):