588 lines
20 KiB
C#
588 lines
20 KiB
C#
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<string> AllowedTypes = new(StringComparer.Ordinal)
|
|
{
|
|
"docs",
|
|
"api",
|
|
"doctor"
|
|
};
|
|
|
|
internal static Command BuildSearchCommand(
|
|
IServiceProvider services,
|
|
Option<bool> verboseOption,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var queryArgument = new Argument<string>("query")
|
|
{
|
|
Description = "Knowledge query (error text, endpoint question, runbook task)."
|
|
};
|
|
|
|
var typeOption = new Option<string[]>("--type")
|
|
{
|
|
Description = "Filter by result type: docs, api, doctor (repeatable or comma-separated).",
|
|
Arity = ArgumentArity.ZeroOrMore,
|
|
AllowMultipleArgumentsPerToken = true
|
|
};
|
|
|
|
var productOption = new Option<string?>("--product")
|
|
{
|
|
Description = "Filter by product identifier."
|
|
};
|
|
|
|
var versionOption = new Option<string?>("--version")
|
|
{
|
|
Description = "Filter by product version."
|
|
};
|
|
|
|
var serviceOption = new Option<string?>("--service")
|
|
{
|
|
Description = "Filter by service (especially useful for API operations)."
|
|
};
|
|
|
|
var tagOption = new Option<string[]>("--tag")
|
|
{
|
|
Description = "Filter by tags (repeatable or comma-separated).",
|
|
Arity = ArgumentArity.ZeroOrMore,
|
|
AllowMultipleArgumentsPerToken = true
|
|
};
|
|
|
|
var topKOption = new Option<int?>("--k")
|
|
{
|
|
Description = "Number of results to return (1-100, default 10)."
|
|
};
|
|
|
|
var jsonOption = new Option<bool>("--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<string>();
|
|
var tags = parseResult.GetValue(tagOption) ?? Array.Empty<string>();
|
|
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<bool> 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<bool>("--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<bool> verboseOption,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var symptomArgument = new Argument<string>("symptom")
|
|
{
|
|
Description = "Symptom text, log fragment, or error message."
|
|
};
|
|
|
|
var productOption = new Option<string?>("--product")
|
|
{
|
|
Description = "Optional product filter."
|
|
};
|
|
|
|
var versionOption = new Option<string?>("--version")
|
|
{
|
|
Description = "Optional version filter."
|
|
};
|
|
|
|
var topKOption = new Option<int?>("--k")
|
|
{
|
|
Description = "Number of results to return (1-100, default 10)."
|
|
};
|
|
|
|
var jsonOption = new Option<bool>("--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<string>(),
|
|
tags: Array.Empty<string>(),
|
|
product,
|
|
version,
|
|
service: null,
|
|
topK,
|
|
emitJson,
|
|
verbose,
|
|
suggestMode: true,
|
|
cancellationToken).ConfigureAwait(false);
|
|
});
|
|
|
|
return suggest;
|
|
}
|
|
|
|
private static async Task ExecuteSearchAsync(
|
|
IServiceProvider services,
|
|
string query,
|
|
IReadOnlyList<string> types,
|
|
IReadOnlyList<string> 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<IBackendOperationsClient>();
|
|
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<IBackendOperationsClient>();
|
|
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<string> types,
|
|
IReadOnlyList<string> 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<string> NormalizeTypes(IEnumerable<string> types)
|
|
{
|
|
var result = new SortedSet<string>(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<string> NormalizeTags(IEnumerable<string> tags)
|
|
{
|
|
var result = new SortedSet<string>(StringComparer.Ordinal);
|
|
foreach (var raw in tags)
|
|
{
|
|
foreach (var token in SplitCsvTokens(raw))
|
|
{
|
|
result.Add(token.ToLowerInvariant());
|
|
}
|
|
}
|
|
|
|
return result.ToArray();
|
|
}
|
|
|
|
private static IEnumerable<string> 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<AdvisoryKnowledgeSearchResultModel> 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));
|
|
}
|
|
}
|