Files
git.stella-ops.org/src/Cli/StellaOps.Cli/Commands/KnowledgeSearchCommandGroup.cs

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));
}
}