using Microsoft.Extensions.DependencyInjection; using StellaOps.Cli.Services; using StellaOps.Cli.Services.Models.AdvisoryAi; using System; using System.Collections.Generic; using System.CommandLine; using System.Globalization; using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; namespace StellaOps.Cli.Commands; internal static class KnowledgeSearchCommandGroup { private static readonly JsonSerializerOptions JsonOutputOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true }; private static readonly HashSet AllowedTypes = new(StringComparer.Ordinal) { "docs", "api", "doctor" }; internal static Command BuildSearchCommand( IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { var queryArgument = new Argument("query") { Description = "Knowledge query (error text, endpoint question, runbook task)." }; var typeOption = new Option("--type") { Description = "Filter by result type: docs, api, doctor (repeatable or comma-separated).", Arity = ArgumentArity.ZeroOrMore, AllowMultipleArgumentsPerToken = true }; var productOption = new Option("--product") { Description = "Filter by product identifier." }; var versionOption = new Option("--version") { Description = "Filter by product version." }; var serviceOption = new Option("--service") { Description = "Filter by service (especially useful for API operations)." }; var tagOption = new Option("--tag") { Description = "Filter by tags (repeatable or comma-separated).", Arity = ArgumentArity.ZeroOrMore, AllowMultipleArgumentsPerToken = true }; var topKOption = new Option("--k") { Description = "Number of results to return (1-100, default 10)." }; var jsonOption = new Option("--json") { Description = "Emit machine-readable JSON output." }; var search = new Command("search", "Search AdvisoryAI knowledge index across docs, API operations, and doctor checks."); search.Add(queryArgument); search.Add(typeOption); search.Add(productOption); search.Add(versionOption); search.Add(serviceOption); search.Add(tagOption); search.Add(topKOption); search.Add(jsonOption); search.Add(verboseOption); search.SetAction(async (parseResult, _) => { var query = parseResult.GetValue(queryArgument) ?? string.Empty; var types = parseResult.GetValue(typeOption) ?? Array.Empty(); var tags = parseResult.GetValue(tagOption) ?? Array.Empty(); var product = parseResult.GetValue(productOption); var version = parseResult.GetValue(versionOption); var service = parseResult.GetValue(serviceOption); var topK = parseResult.GetValue(topKOption); var emitJson = parseResult.GetValue(jsonOption); var verbose = parseResult.GetValue(verboseOption); await ExecuteSearchAsync( services, query, types, tags, product, version, service, topK, emitJson, verbose, suggestMode: false, cancellationToken).ConfigureAwait(false); }); return search; } internal static Command BuildAdvisoryAiCommand( IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { var advisoryAi = new Command("advisoryai", "AdvisoryAI maintenance commands."); var index = new Command("index", "Knowledge index operations."); var rebuild = new Command("rebuild", "Rebuild AdvisoryAI deterministic knowledge index."); var jsonOption = new Option("--json") { Description = "Emit machine-readable JSON output." }; rebuild.Add(jsonOption); rebuild.Add(verboseOption); rebuild.SetAction(async (parseResult, _) => { var emitJson = parseResult.GetValue(jsonOption); var verbose = parseResult.GetValue(verboseOption); await ExecuteRebuildAsync(services, emitJson, verbose, cancellationToken).ConfigureAwait(false); }); index.Add(rebuild); advisoryAi.Add(index); return advisoryAi; } internal static Command BuildDoctorSuggestCommand( IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { var symptomArgument = new Argument("symptom") { Description = "Symptom text, log fragment, or error message." }; var productOption = new Option("--product") { Description = "Optional product filter." }; var versionOption = new Option("--version") { Description = "Optional version filter." }; var topKOption = new Option("--k") { Description = "Number of results to return (1-100, default 10)." }; var jsonOption = new Option("--json") { Description = "Emit machine-readable JSON output." }; var suggest = new Command("suggest", "Suggest checks, docs, and API operations for a symptom via AdvisoryAI knowledge search."); suggest.Add(symptomArgument); suggest.Add(productOption); suggest.Add(versionOption); suggest.Add(topKOption); suggest.Add(jsonOption); suggest.Add(verboseOption); suggest.SetAction(async (parseResult, _) => { var symptom = parseResult.GetValue(symptomArgument) ?? string.Empty; var product = parseResult.GetValue(productOption); var version = parseResult.GetValue(versionOption); var topK = parseResult.GetValue(topKOption); var emitJson = parseResult.GetValue(jsonOption); var verbose = parseResult.GetValue(verboseOption); await ExecuteSearchAsync( services, symptom, types: Array.Empty(), tags: Array.Empty(), product, version, service: null, topK, emitJson, verbose, suggestMode: true, cancellationToken).ConfigureAwait(false); }); return suggest; } private static async Task ExecuteSearchAsync( IServiceProvider services, string query, IReadOnlyList types, IReadOnlyList tags, string? product, string? version, string? service, int? topK, bool emitJson, bool verbose, bool suggestMode, CancellationToken cancellationToken) { var normalizedQuery = (query ?? string.Empty).Trim(); if (string.IsNullOrWhiteSpace(normalizedQuery)) { Console.Error.WriteLine("Query text is required."); Environment.ExitCode = CliExitCodes.MissingRequiredOption; return; } var normalizedTypes = NormalizeTypes(types); var normalizedTags = NormalizeTags(tags); var boundedTopK = topK.HasValue ? Math.Clamp(topK.Value, 1, 100) : (int?)null; var filter = BuildFilter(normalizedTypes, normalizedTags, product, version, service); var request = new AdvisoryKnowledgeSearchRequestModel { Q = normalizedQuery, K = boundedTopK, Filters = filter, IncludeDebug = verbose }; var backend = services.GetRequiredService(); AdvisoryKnowledgeSearchResponseModel response; try { response = await backend.SearchAdvisoryKnowledgeAsync(request, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { Console.Error.WriteLine($"Knowledge search failed: {ex.Message}"); Environment.ExitCode = CliExitCodes.GeneralError; return; } if (emitJson) { WriteJson(ToJsonPayload(response)); return; } if (suggestMode) { RenderSuggestionOutput(response, verbose); return; } RenderSearchOutput(response, verbose); } private static async Task ExecuteRebuildAsync( IServiceProvider services, bool emitJson, bool verbose, CancellationToken cancellationToken) { var backend = services.GetRequiredService(); AdvisoryKnowledgeRebuildResponseModel summary; try { summary = await backend.RebuildAdvisoryKnowledgeIndexAsync(cancellationToken).ConfigureAwait(false); } catch (Exception ex) { Console.Error.WriteLine($"AdvisoryAI index rebuild failed: {ex.Message}"); Environment.ExitCode = CliExitCodes.GeneralError; return; } if (emitJson) { WriteJson(new { documentCount = summary.DocumentCount, chunkCount = summary.ChunkCount, apiSpecCount = summary.ApiSpecCount, apiOperationCount = summary.ApiOperationCount, doctorProjectionCount = summary.DoctorProjectionCount, durationMs = summary.DurationMs }); return; } Console.WriteLine("AdvisoryAI knowledge index rebuilt."); Console.WriteLine($" Documents: {summary.DocumentCount}"); Console.WriteLine($" Chunks: {summary.ChunkCount}"); Console.WriteLine($" API specs: {summary.ApiSpecCount}"); Console.WriteLine($" API operations: {summary.ApiOperationCount}"); Console.WriteLine($" Doctor projections: {summary.DoctorProjectionCount}"); Console.WriteLine($" Duration: {summary.DurationMs.ToString(CultureInfo.InvariantCulture)} ms"); if (verbose) { Console.WriteLine(" Rebuild scope: markdown + openapi + doctor projection."); } } private static AdvisoryKnowledgeSearchFilterModel? BuildFilter( IReadOnlyList types, IReadOnlyList tags, string? product, string? version, string? service) { var normalizedProduct = NormalizeOptional(product); var normalizedVersion = NormalizeOptional(version); var normalizedService = NormalizeOptional(service); if (types.Count == 0 && tags.Count == 0 && normalizedProduct is null && normalizedVersion is null && normalizedService is null) { return null; } return new AdvisoryKnowledgeSearchFilterModel { Type = types.Count == 0 ? null : types, Product = normalizedProduct, Version = normalizedVersion, Service = normalizedService, Tags = tags.Count == 0 ? null : tags }; } private static IReadOnlyList NormalizeTypes(IEnumerable types) { var result = new SortedSet(StringComparer.Ordinal); foreach (var raw in types) { foreach (var token in SplitCsvTokens(raw)) { var normalized = token.ToLowerInvariant(); if (AllowedTypes.Contains(normalized)) { result.Add(normalized); } } } return result.ToArray(); } private static IReadOnlyList NormalizeTags(IEnumerable tags) { var result = new SortedSet(StringComparer.Ordinal); foreach (var raw in tags) { foreach (var token in SplitCsvTokens(raw)) { result.Add(token.ToLowerInvariant()); } } return result.ToArray(); } private static IEnumerable SplitCsvTokens(string? value) { if (string.IsNullOrWhiteSpace(value)) { yield break; } foreach (var token in value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { if (!string.IsNullOrWhiteSpace(token)) { yield return token.Trim(); } } } private static string? NormalizeOptional(string? value) { return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } private static void RenderSearchOutput(AdvisoryKnowledgeSearchResponseModel response, bool verbose) { Console.WriteLine($"Query: {response.Query}"); Console.WriteLine($"Results: {response.Results.Count.ToString(CultureInfo.InvariantCulture)} / topK {response.TopK.ToString(CultureInfo.InvariantCulture)}"); Console.WriteLine($"Mode: {response.Diagnostics.Mode} (fts={response.Diagnostics.FtsMatches.ToString(CultureInfo.InvariantCulture)}, vector={response.Diagnostics.VectorMatches.ToString(CultureInfo.InvariantCulture)}, duration={response.Diagnostics.DurationMs.ToString(CultureInfo.InvariantCulture)}ms)"); Console.WriteLine(); if (response.Results.Count == 0) { Console.WriteLine("No results found."); return; } for (var index = 0; index < response.Results.Count; index++) { var result = response.Results[index]; Console.WriteLine($"[{(index + 1).ToString(CultureInfo.InvariantCulture)}] {result.Type.ToUpperInvariant()} score={result.Score.ToString("F6", CultureInfo.InvariantCulture)}"); Console.WriteLine($" {result.Title}"); var snippet = CollapseWhitespace(result.Snippet); if (!string.IsNullOrWhiteSpace(snippet)) { Console.WriteLine($" {snippet}"); } var reference = FormatOpenReference(result); if (!string.IsNullOrWhiteSpace(reference)) { Console.WriteLine($" {reference}"); } if (verbose && result.Debug is { Count: > 0 }) { foreach (var pair in result.Debug.OrderBy(static pair => pair.Key, StringComparer.Ordinal)) { Console.WriteLine($" debug.{pair.Key}: {pair.Value}"); } } Console.WriteLine(); } } private static void RenderSuggestionOutput(AdvisoryKnowledgeSearchResponseModel response, bool verbose) { Console.WriteLine($"Symptom: {response.Query}"); Console.WriteLine($"Mode: {response.Diagnostics.Mode} (duration={response.Diagnostics.DurationMs.ToString(CultureInfo.InvariantCulture)}ms)"); Console.WriteLine(); var doctor = response.Results.Where(static result => result.Type.Equals("doctor", StringComparison.OrdinalIgnoreCase)).ToArray(); var docs = response.Results.Where(static result => result.Type.Equals("docs", StringComparison.OrdinalIgnoreCase)).ToArray(); var api = response.Results.Where(static result => result.Type.Equals("api", StringComparison.OrdinalIgnoreCase)).ToArray(); RenderSuggestionGroup("Recommended checks", doctor, verbose); RenderSuggestionGroup("Related docs", docs, verbose); RenderSuggestionGroup("Related endpoints", api, verbose); } private static void RenderSuggestionGroup(string label, IReadOnlyList results, bool verbose) { Console.WriteLine(label + ":"); if (results.Count == 0) { Console.WriteLine(" (none)"); Console.WriteLine(); return; } for (var index = 0; index < results.Count; index++) { var result = results[index]; Console.WriteLine($" {(index + 1).ToString(CultureInfo.InvariantCulture)}. {result.Title} (score={result.Score.ToString("F6", CultureInfo.InvariantCulture)})"); var reference = FormatOpenReference(result); if (!string.IsNullOrWhiteSpace(reference)) { Console.WriteLine($" {reference}"); } var snippet = CollapseWhitespace(result.Snippet); if (!string.IsNullOrWhiteSpace(snippet)) { Console.WriteLine($" {snippet}"); } if (verbose && result.Debug is { Count: > 0 }) { foreach (var pair in result.Debug.OrderBy(static pair => pair.Key, StringComparer.Ordinal)) { Console.WriteLine($" debug.{pair.Key}: {pair.Value}"); } } } Console.WriteLine(); } private static string FormatOpenReference(AdvisoryKnowledgeSearchResultModel result) { if (result.Open is null) { return string.Empty; } if (result.Type.Equals("docs", StringComparison.OrdinalIgnoreCase) && result.Open.Docs is not null) { var docs = result.Open.Docs; return $"docs: {docs.Path}#{docs.Anchor} lines {docs.SpanStart.ToString(CultureInfo.InvariantCulture)}-{docs.SpanEnd.ToString(CultureInfo.InvariantCulture)}"; } if (result.Type.Equals("api", StringComparison.OrdinalIgnoreCase) && result.Open.Api is not null) { var api = result.Open.Api; return $"api: {api.Method} {api.Path} operationId={api.OperationId} service={api.Service}"; } if (result.Type.Equals("doctor", StringComparison.OrdinalIgnoreCase) && result.Open.Doctor is not null) { var doctor = result.Open.Doctor; return $"doctor: {doctor.CheckCode} severity={doctor.Severity} run=\"{doctor.RunCommand}\""; } return string.Empty; } private static string CollapseWhitespace(string? value) { if (string.IsNullOrWhiteSpace(value)) { return string.Empty; } var collapsed = string.Join( ' ', value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); const int maxLength = 320; if (collapsed.Length <= maxLength) { return collapsed; } return collapsed[..maxLength] + "..."; } private static object ToJsonPayload(AdvisoryKnowledgeSearchResponseModel response) { return new { query = response.Query, topK = response.TopK, diagnostics = new { ftsMatches = response.Diagnostics.FtsMatches, vectorMatches = response.Diagnostics.VectorMatches, durationMs = response.Diagnostics.DurationMs, usedVector = response.Diagnostics.UsedVector, mode = response.Diagnostics.Mode }, results = response.Results.Select(static result => new { type = result.Type, title = result.Title, snippet = result.Snippet, score = result.Score, open = new { kind = result.Open.Kind, docs = result.Open.Docs, api = result.Open.Api, doctor = result.Open.Doctor }, debug = result.Debug is null ? null : result.Debug .OrderBy(static pair => pair.Key, StringComparer.Ordinal) .ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal) }).ToArray() }; } private static void WriteJson(object payload) { Console.WriteLine(JsonSerializer.Serialize(payload, JsonOutputOptions)); } }