stela ops usage fixes roles propagation and timoeut, one account to support multi tenants, migrations consolidation, search to support documentation, doctor and open api vector db search
This commit is contained in:
587
src/Cli/StellaOps.Cli/Commands/KnowledgeSearchCommandGroup.cs
Normal file
587
src/Cli/StellaOps.Cli/Commands/KnowledgeSearchCommandGroup.cs
Normal file
@@ -0,0 +1,587 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user