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

1504 lines
55 KiB
C#

using Microsoft.Extensions.DependencyInjection;
using StellaOps.Cli.Services;
using StellaOps.Cli.Services.Models;
using StellaOps.Cli.Services.Models.AdvisoryAi;
using StellaOps.Doctor.Engine;
using System;
using System.Collections.Generic;
using System.CommandLine;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
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 const string DefaultDocsAllowListPath = "src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/knowledge-docs-allowlist.json";
private const string DefaultDocsManifestPath = "src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/knowledge-docs-manifest.json";
private const string DefaultDoctorSeedPath = "src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/doctor-search-seed.json";
private const string DefaultDoctorControlsPath = "src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/doctor-search-controls.json";
private const string DefaultOpenApiAggregatePath = "devops/compose/openapi_current.json";
private static readonly HashSet<string> AllowedTypes = new(StringComparer.Ordinal)
{
"docs",
"api",
"doctor",
"findings",
"vex",
"policy"
};
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 rebuildJsonOption = new Option<bool>("--json")
{
Description = "Emit machine-readable JSON output."
};
rebuild.Add(rebuildJsonOption);
rebuild.Add(verboseOption);
rebuild.SetAction(async (parseResult, _) =>
{
var emitJson = parseResult.GetValue(rebuildJsonOption);
var verbose = parseResult.GetValue(verboseOption);
await ExecuteRebuildAsync(services, emitJson, verbose, cancellationToken).ConfigureAwait(false);
});
var sources = new Command("sources", "Prepare deterministic knowledge source artifacts.");
var prepare = new Command("prepare", "Aggregate docs allowlist, OpenAPI snapshot, and doctor controls seed data.");
var repoRootOption = new Option<string>("--repo-root")
{
Description = "Repository root used to resolve relative source paths."
};
repoRootOption.SetDefaultValue(".");
var docsAllowListOption = new Option<string>("--docs-allowlist")
{
Description = "Path to docs allowlist JSON (include/includes/paths)."
};
docsAllowListOption.SetDefaultValue(DefaultDocsAllowListPath);
var docsManifestOutputOption = new Option<string>("--docs-manifest-output")
{
Description = "Output path for resolved markdown manifest JSON."
};
docsManifestOutputOption.SetDefaultValue(DefaultDocsManifestPath);
var openApiOutputOption = new Option<string>("--openapi-output")
{
Description = "Output path for aggregated OpenAPI snapshot."
};
openApiOutputOption.SetDefaultValue(DefaultOpenApiAggregatePath);
var doctorSeedOption = new Option<string>("--doctor-seed")
{
Description = "Input doctor seed JSON used to derive controls."
};
doctorSeedOption.SetDefaultValue(DefaultDoctorSeedPath);
var doctorControlsOutputOption = new Option<string>("--doctor-controls-output")
{
Description = "Output path for doctor controls JSON."
};
doctorControlsOutputOption.SetDefaultValue(DefaultDoctorControlsPath);
var overwriteOption = new Option<bool>("--overwrite")
{
Description = "Overwrite generated artifacts and OpenAPI snapshot."
};
var prepareJsonOption = new Option<bool>("--json")
{
Description = "Emit machine-readable JSON output."
};
prepare.Add(repoRootOption);
prepare.Add(docsAllowListOption);
prepare.Add(docsManifestOutputOption);
prepare.Add(openApiOutputOption);
prepare.Add(doctorSeedOption);
prepare.Add(doctorControlsOutputOption);
prepare.Add(overwriteOption);
prepare.Add(prepareJsonOption);
prepare.Add(verboseOption);
prepare.SetAction(async (parseResult, _) =>
{
await ExecutePrepareSourcesAsync(
services,
repoRoot: parseResult.GetValue(repoRootOption) ?? ".",
docsAllowListPath: parseResult.GetValue(docsAllowListOption) ?? DefaultDocsAllowListPath,
docsManifestOutputPath: parseResult.GetValue(docsManifestOutputOption) ?? DefaultDocsManifestPath,
openApiOutputPath: parseResult.GetValue(openApiOutputOption) ?? DefaultOpenApiAggregatePath,
doctorSeedPath: parseResult.GetValue(doctorSeedOption) ?? DefaultDoctorSeedPath,
doctorControlsOutputPath: parseResult.GetValue(doctorControlsOutputOption) ?? DefaultDoctorControlsPath,
overwrite: parseResult.GetValue(overwriteOption),
emitJson: parseResult.GetValue(prepareJsonOption),
verbose: parseResult.GetValue(verboseOption),
cancellationToken).ConfigureAwait(false);
});
sources.Add(prepare);
index.Add(rebuild);
advisoryAi.Add(index);
advisoryAi.Add(sources);
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>();
// Try unified search endpoint first (covers all domains)
var unifiedResult = await TryUnifiedSearchAsync(
backend, normalizedQuery, normalizedTypes, normalizedTags,
product, version, service, boundedTopK, verbose,
cancellationToken).ConfigureAwait(false);
if (unifiedResult is not null)
{
if (emitJson)
{
WriteJson(ToUnifiedJsonPayload(unifiedResult));
return;
}
if (suggestMode)
{
RenderUnifiedSuggestionOutput(unifiedResult, verbose);
return;
}
RenderUnifiedSearchOutput(unifiedResult, verbose);
return;
}
// Fallback to legacy knowledge search
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 async Task ExecutePrepareSourcesAsync(
IServiceProvider services,
string repoRoot,
string docsAllowListPath,
string docsManifestOutputPath,
string openApiOutputPath,
string doctorSeedPath,
string doctorControlsOutputPath,
bool overwrite,
bool emitJson,
bool verbose,
CancellationToken cancellationToken)
{
try
{
var repositoryRoot = ResolvePath(Directory.GetCurrentDirectory(), repoRoot);
var docsAllowListAbsolute = ResolvePath(repositoryRoot, docsAllowListPath);
var docsManifestAbsolute = ResolvePath(repositoryRoot, docsManifestOutputPath);
var openApiAbsolute = ResolvePath(repositoryRoot, openApiOutputPath);
var doctorSeedAbsolute = ResolvePath(repositoryRoot, doctorSeedPath);
var doctorControlsAbsolute = ResolvePath(repositoryRoot, doctorControlsOutputPath);
var includeEntries = LoadAllowListIncludes(docsAllowListAbsolute);
var markdownFiles = ResolveMarkdownFiles(repositoryRoot, includeEntries);
var markdownDocuments = markdownFiles
.Select(path => new DocsManifestDocument(
path,
ComputeSha256Hex(ResolvePath(repositoryRoot, path))))
.ToArray();
if (verbose)
{
Console.WriteLine($"Docs allowlist: {docsAllowListAbsolute}");
Console.WriteLine($"Resolved markdown files: {markdownFiles.Count}");
}
WriteDocsManifest(
docsManifestAbsolute,
includeEntries,
markdownDocuments,
overwrite);
var backend = services.GetRequiredService<IBackendOperationsClient>();
var apiResult = await backend.DownloadApiSpecAsync(
new ApiSpecDownloadRequest
{
OutputPath = openApiAbsolute,
Service = null,
Format = "openapi-json",
Overwrite = overwrite,
ChecksumAlgorithm = "sha256"
},
cancellationToken).ConfigureAwait(false);
if (!apiResult.Success)
{
Console.Error.WriteLine($"OpenAPI aggregation failed: {apiResult.Error ?? "unknown error"}");
Environment.ExitCode = CliExitCodes.GeneralError;
return;
}
var configuredDoctorSeedEntries = LoadDoctorSeedEntries(doctorSeedAbsolute);
var discoveredDoctorEntries = LoadDoctorSeedEntriesFromEngine(services, verbose);
var doctorSeedEntries = MergeDoctorSeedEntries(configuredDoctorSeedEntries, discoveredDoctorEntries);
var doctorControlEntries = BuildDoctorControls(doctorSeedEntries);
WriteDoctorControls(
doctorControlsAbsolute,
doctorControlEntries,
overwrite);
if (emitJson)
{
WriteJson(new
{
repositoryRoot,
docs = new
{
allowListPath = ToRelativePath(repositoryRoot, docsAllowListAbsolute),
manifestPath = ToRelativePath(repositoryRoot, docsManifestAbsolute),
includeCount = includeEntries.Count,
documentCount = markdownDocuments.Length
},
openApi = new
{
path = ToRelativePath(repositoryRoot, openApiAbsolute),
fromCache = apiResult.FromCache,
checksum = apiResult.Checksum
},
doctor = new
{
seedPath = ToRelativePath(repositoryRoot, doctorSeedAbsolute),
configuredSeedCount = configuredDoctorSeedEntries.Count,
discoveredSeedCount = discoveredDoctorEntries.Count,
mergedSeedCount = doctorSeedEntries.Count,
controlsPath = ToRelativePath(repositoryRoot, doctorControlsAbsolute),
controlCount = doctorControlEntries.Length
}
});
Environment.ExitCode = 0;
return;
}
Console.WriteLine("AdvisoryAI source artifacts prepared.");
Console.WriteLine($" Docs allowlist: {ToRelativePath(repositoryRoot, docsAllowListAbsolute)}");
Console.WriteLine($" Docs manifest: {ToRelativePath(repositoryRoot, docsManifestAbsolute)} ({markdownDocuments.Length} markdown files)");
Console.WriteLine($" OpenAPI aggregate: {ToRelativePath(repositoryRoot, openApiAbsolute)}");
Console.WriteLine($" Doctor seed (configured/discovered/merged): {configuredDoctorSeedEntries.Count}/{discoveredDoctorEntries.Count}/{doctorSeedEntries.Count}");
Console.WriteLine($" Doctor controls: {ToRelativePath(repositoryRoot, doctorControlsAbsolute)} ({doctorControlEntries.Length} checks)");
Environment.ExitCode = 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"AdvisoryAI source preparation failed: {ex.Message}");
Environment.ExitCode = CliExitCodes.GeneralError;
}
}
private static string ResolvePath(string repositoryRoot, string configuredPath)
{
if (string.IsNullOrWhiteSpace(configuredPath))
{
return repositoryRoot;
}
return Path.IsPathRooted(configuredPath)
? Path.GetFullPath(configuredPath)
: Path.GetFullPath(Path.Combine(repositoryRoot, configuredPath));
}
private static string ToRelativePath(string repositoryRoot, string absolutePath)
{
var relative = Path.GetRelativePath(repositoryRoot, absolutePath).Replace('\\', '/');
return string.IsNullOrWhiteSpace(relative) ? "." : relative;
}
private static IReadOnlyList<string> LoadAllowListIncludes(string allowListPath)
{
if (!File.Exists(allowListPath))
{
throw new FileNotFoundException($"Docs allowlist file was not found: {allowListPath}", allowListPath);
}
using var stream = File.OpenRead(allowListPath);
using var document = JsonDocument.Parse(stream);
var root = document.RootElement;
if (root.ValueKind == JsonValueKind.Array)
{
return ReadStringList(root);
}
if (root.ValueKind != JsonValueKind.Object)
{
return [];
}
if (TryGetStringListProperty(root, "include", out var include))
{
return include;
}
if (TryGetStringListProperty(root, "includes", out include))
{
return include;
}
if (TryGetStringListProperty(root, "paths", out include))
{
return include;
}
return [];
}
private static IReadOnlyList<string> ResolveMarkdownFiles(string repositoryRoot, IReadOnlyList<string> includeEntries)
{
var files = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var include in includeEntries)
{
if (string.IsNullOrWhiteSpace(include))
{
continue;
}
var absolutePath = ResolvePath(repositoryRoot, include);
if (File.Exists(absolutePath))
{
if (Path.GetExtension(absolutePath).Equals(".md", StringComparison.OrdinalIgnoreCase))
{
files.Add(ToRelativePath(repositoryRoot, Path.GetFullPath(absolutePath)));
}
continue;
}
if (!Directory.Exists(absolutePath))
{
continue;
}
foreach (var file in Directory.EnumerateFiles(absolutePath, "*.md", SearchOption.AllDirectories))
{
files.Add(ToRelativePath(repositoryRoot, Path.GetFullPath(file)));
}
}
return files.OrderBy(static file => file, StringComparer.Ordinal).ToArray();
}
private static void WriteDocsManifest(
string outputPath,
IReadOnlyList<string> includeEntries,
IReadOnlyList<DocsManifestDocument> documents,
bool overwrite)
{
if (File.Exists(outputPath) && !overwrite)
{
return;
}
var directory = Path.GetDirectoryName(outputPath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
var payload = new
{
schema = "stellaops.advisoryai.docs-manifest.v1",
include = includeEntries.OrderBy(static entry => entry, StringComparer.Ordinal).ToArray(),
documents = documents.OrderBy(static entry => entry.Path, StringComparer.Ordinal).ToArray()
};
File.WriteAllText(outputPath, JsonSerializer.Serialize(payload, JsonOutputOptions));
}
private static IReadOnlyList<DoctorSeedEntry> LoadDoctorSeedEntries(string doctorSeedPath)
{
if (!File.Exists(doctorSeedPath))
{
return [];
}
using var stream = File.OpenRead(doctorSeedPath);
using var document = JsonDocument.Parse(stream);
if (document.RootElement.ValueKind != JsonValueKind.Array)
{
return [];
}
var entries = new List<DoctorSeedEntry>();
foreach (var item in document.RootElement.EnumerateArray())
{
if (item.ValueKind != JsonValueKind.Object)
{
continue;
}
var checkCode = ReadString(item, "checkCode");
if (string.IsNullOrWhiteSpace(checkCode))
{
continue;
}
entries.Add(new DoctorSeedEntry(
checkCode,
ReadString(item, "title") ?? checkCode,
ReadString(item, "severity") ?? "warn",
ReadString(item, "description") ?? string.Empty,
ReadString(item, "remediation") ?? string.Empty,
ReadString(item, "runCommand") ?? $"stella doctor run --check {checkCode}",
TryGetStringListProperty(item, "symptoms", out var symptoms) ? symptoms : [],
TryGetStringListProperty(item, "tags", out var tags) ? tags : [],
TryGetStringListProperty(item, "references", out var references) ? references : []));
}
return entries
.OrderBy(static entry => entry.CheckCode, StringComparer.Ordinal)
.ToArray();
}
private static IReadOnlyList<DoctorSeedEntry> LoadDoctorSeedEntriesFromEngine(IServiceProvider services, bool verbose)
{
var engine = services.GetService<DoctorEngine>();
if (engine is null)
{
return [];
}
try
{
var checks = engine.ListChecks();
return checks
.OrderBy(static check => check.CheckId, StringComparer.Ordinal)
.Select(static check =>
{
var runCommand = $"stella doctor run --check {check.CheckId}";
var references = MergeUniqueStrings(
string.IsNullOrWhiteSpace(check.Category) ? [] : [$"category:{check.Category}"],
string.IsNullOrWhiteSpace(check.PluginId) ? [] : [$"plugin:{check.PluginId}"]);
var tags = MergeUniqueStrings(
check.Tags,
string.IsNullOrWhiteSpace(check.Category) ? [] : [check.Category],
["doctor", "diagnostics"]);
return new DoctorSeedEntry(
check.CheckId,
string.IsNullOrWhiteSpace(check.Name) ? check.CheckId : check.Name.Trim(),
check.DefaultSeverity.ToString().ToLowerInvariant(),
(check.Description ?? string.Empty).Trim(),
$"Run `{runCommand}` and follow the diagnosis output.",
runCommand,
BuildSymptomsFromCheckText(check.Name, check.Description, check.Tags),
tags,
references);
})
.ToArray();
}
catch (Exception ex)
{
if (verbose)
{
Console.Error.WriteLine($"Doctor check discovery from engine failed: {ex.Message}");
}
return [];
}
}
private static IReadOnlyList<DoctorSeedEntry> MergeDoctorSeedEntries(
IReadOnlyList<DoctorSeedEntry> configuredEntries,
IReadOnlyList<DoctorSeedEntry> discoveredEntries)
{
var merged = discoveredEntries
.ToDictionary(static entry => entry.CheckCode, StringComparer.OrdinalIgnoreCase);
foreach (var configured in configuredEntries)
{
if (!merged.TryGetValue(configured.CheckCode, out var discovered))
{
merged[configured.CheckCode] = configured;
continue;
}
merged[configured.CheckCode] = new DoctorSeedEntry(
configured.CheckCode,
PreferConfigured(configured.Title, discovered.Title, configured.CheckCode),
PreferConfigured(configured.Severity, discovered.Severity, "warn"),
PreferConfigured(configured.Description, discovered.Description, string.Empty),
PreferConfigured(configured.Remediation, discovered.Remediation, string.Empty),
PreferConfigured(configured.RunCommand, discovered.RunCommand, $"stella doctor run --check {configured.CheckCode}"),
MergeUniqueStrings(configured.Symptoms, discovered.Symptoms),
MergeUniqueStrings(configured.Tags, discovered.Tags),
MergeUniqueStrings(configured.References, discovered.References));
}
return merged.Values
.OrderBy(static entry => entry.CheckCode, StringComparer.Ordinal)
.ToArray();
}
private static DoctorControlEntry[] BuildDoctorControls(IReadOnlyList<DoctorSeedEntry> seedEntries)
{
return seedEntries
.OrderBy(static entry => entry.CheckCode, StringComparer.Ordinal)
.Select(static entry =>
{
var mode = InferControlMode(entry.Severity);
var requiresConfirmation = !mode.Equals("safe", StringComparison.Ordinal);
return new DoctorControlEntry(
entry.CheckCode,
mode,
requiresConfirmation,
IsDestructive: false,
RequiresBackup: false,
InspectCommand: $"stella doctor run --check {entry.CheckCode} --mode quick",
VerificationCommand: string.IsNullOrWhiteSpace(entry.RunCommand)
? $"stella doctor run --check {entry.CheckCode}"
: entry.RunCommand.Trim(),
Keywords: BuildKeywordList(entry),
Title: entry.Title,
Severity: entry.Severity,
Description: entry.Description,
Remediation: entry.Remediation,
RunCommand: string.IsNullOrWhiteSpace(entry.RunCommand)
? $"stella doctor run --check {entry.CheckCode}"
: entry.RunCommand.Trim(),
Symptoms: entry.Symptoms,
Tags: entry.Tags,
References: entry.References);
})
.ToArray();
}
private static string InferControlMode(string severity)
{
var normalized = (severity ?? string.Empty).Trim().ToLowerInvariant();
return normalized switch
{
"critical" => "manual",
"error" => "manual",
"fail" => "manual",
"failure" => "manual",
"high" => "manual",
_ => "safe"
};
}
private static IReadOnlyList<string> BuildKeywordList(DoctorSeedEntry entry)
{
var terms = new SortedSet<string>(StringComparer.Ordinal);
foreach (var symptom in entry.Symptoms)
{
if (!string.IsNullOrWhiteSpace(symptom))
{
terms.Add(symptom.Trim().ToLowerInvariant());
}
}
foreach (var token in (entry.Title + " " + entry.Description)
.Split(new[] { ' ', '\t', '\r', '\n', ',', ';', ':', '.', '(', ')', '[', ']', '{', '}', '"', '\'' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (token.Length >= 4)
{
terms.Add(token.Trim().ToLowerInvariant());
}
}
return terms.Take(12).ToArray();
}
private static IReadOnlyList<string> BuildSymptomsFromCheckText(
string? title,
string? description,
IReadOnlyList<string>? tags)
{
var terms = new SortedSet<string>(StringComparer.Ordinal);
foreach (var token in (title + " " + description)
.Split(new[] { ' ', '\t', '\r', '\n', ',', ';', ':', '.', '(', ')', '[', ']', '{', '}', '"', '\'' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (token.Length >= 5)
{
terms.Add(token.Trim().ToLowerInvariant());
}
}
if (tags is not null)
{
foreach (var tag in tags)
{
if (!string.IsNullOrWhiteSpace(tag))
{
terms.Add(tag.Trim().ToLowerInvariant());
}
}
}
return terms.Take(12).ToArray();
}
private static IReadOnlyList<string> MergeUniqueStrings(params IReadOnlyList<string>?[] values)
{
var merged = new SortedSet<string>(StringComparer.Ordinal);
foreach (var source in values)
{
if (source is null)
{
continue;
}
foreach (var value in source)
{
if (!string.IsNullOrWhiteSpace(value))
{
merged.Add(value.Trim());
}
}
}
return merged.ToArray();
}
private static string PreferConfigured(string? configured, string? discovered, string fallback)
{
if (!string.IsNullOrWhiteSpace(configured))
{
return configured.Trim();
}
if (!string.IsNullOrWhiteSpace(discovered))
{
return discovered.Trim();
}
return fallback;
}
private static void WriteDoctorControls(string outputPath, IReadOnlyList<DoctorControlEntry> controls, bool overwrite)
{
if (File.Exists(outputPath) && !overwrite)
{
return;
}
var directory = Path.GetDirectoryName(outputPath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
var payload = controls
.OrderBy(static entry => entry.CheckCode, StringComparer.Ordinal)
.Select(static entry => new
{
checkCode = entry.CheckCode,
control = entry.Control,
requiresConfirmation = entry.RequiresConfirmation,
isDestructive = entry.IsDestructive,
requiresBackup = entry.RequiresBackup,
inspectCommand = entry.InspectCommand,
verificationCommand = entry.VerificationCommand,
keywords = entry.Keywords,
title = entry.Title,
severity = entry.Severity,
description = entry.Description,
remediation = entry.Remediation,
runCommand = entry.RunCommand,
symptoms = entry.Symptoms,
tags = entry.Tags,
references = entry.References
})
.ToArray();
File.WriteAllText(outputPath, JsonSerializer.Serialize(payload, JsonOutputOptions));
}
private static string ComputeSha256Hex(string absolutePath)
{
using var stream = File.OpenRead(absolutePath);
using var sha = SHA256.Create();
var hash = sha.ComputeHash(stream);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static bool TryGetStringListProperty(JsonElement element, string propertyName, out IReadOnlyList<string> values)
{
if (element.ValueKind == JsonValueKind.Object &&
element.TryGetProperty(propertyName, out var property) &&
property.ValueKind == JsonValueKind.Array)
{
values = ReadStringList(property);
return true;
}
values = [];
return false;
}
private static string? ReadString(JsonElement element, string propertyName)
{
return element.ValueKind == JsonValueKind.Object &&
element.TryGetProperty(propertyName, out var property) &&
property.ValueKind == JsonValueKind.String
? property.GetString()
: null;
}
private static IReadOnlyList<string> ReadStringList(JsonElement array)
{
return array.EnumerateArray()
.Where(static item => item.ValueKind == JsonValueKind.String)
.Select(static item => item.GetString())
.Where(static item => !string.IsNullOrWhiteSpace(item))
.Select(static item => item!.Trim())
.Distinct(StringComparer.Ordinal)
.OrderBy(static item => item, StringComparer.Ordinal)
.ToArray();
}
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} control={doctor.Control} confirm={doctor.RequiresConfirmation.ToString().ToLowerInvariant()} 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 sealed record DocsManifestDocument(
string Path,
string Sha256);
private sealed record DoctorSeedEntry(
string CheckCode,
string Title,
string Severity,
string Description,
string Remediation,
string RunCommand,
IReadOnlyList<string> Symptoms,
IReadOnlyList<string> Tags,
IReadOnlyList<string> References);
private sealed record DoctorControlEntry(
string CheckCode,
string Control,
bool RequiresConfirmation,
bool IsDestructive,
bool RequiresBackup,
string InspectCommand,
string VerificationCommand,
IReadOnlyList<string> Keywords,
string Title,
string Severity,
string Description,
string Remediation,
string RunCommand,
IReadOnlyList<string> Symptoms,
IReadOnlyList<string> Tags,
IReadOnlyList<string> References);
private static void WriteJson(object payload)
{
Console.WriteLine(JsonSerializer.Serialize(payload, JsonOutputOptions));
}
private static async Task<UnifiedSearchResponseModel?> TryUnifiedSearchAsync(
IBackendOperationsClient backend,
string query,
IReadOnlyList<string> types,
IReadOnlyList<string> tags,
string? product,
string? version,
string? service,
int? topK,
bool verbose,
CancellationToken cancellationToken)
{
var domains = MapTypesToDomains(types);
var request = new UnifiedSearchRequestModel
{
Q = query,
K = topK,
Filters = new UnifiedSearchFilterModel
{
Domains = domains.Count > 0 ? domains : null,
Product = product,
Version = version,
Service = service,
Tags = tags.Count > 0 ? tags : null
},
IncludeSynthesis = true,
IncludeDebug = verbose
};
return await backend.SearchUnifiedAsync(request, cancellationToken).ConfigureAwait(false);
}
private static IReadOnlyList<string> MapTypesToDomains(IReadOnlyList<string> types)
{
if (types.Count == 0) return [];
var domains = new HashSet<string>(StringComparer.Ordinal);
foreach (var type in types)
{
switch (type)
{
case "docs":
case "api":
case "doctor":
domains.Add("knowledge");
break;
case "findings":
domains.Add("findings");
break;
case "vex":
domains.Add("vex");
break;
case "policy":
domains.Add("policy");
break;
}
}
return domains.ToArray();
}
private static void RenderUnifiedSearchOutput(UnifiedSearchResponseModel response, bool verbose)
{
Console.WriteLine($"Query: {response.Query}");
Console.WriteLine($"Results: {response.Cards.Count.ToString(CultureInfo.InvariantCulture)} cards / 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.Synthesis is not null)
{
Console.WriteLine($"Summary ({response.Synthesis.Confidence} confidence):");
Console.WriteLine($" {response.Synthesis.Summary}");
Console.WriteLine();
}
if (response.Cards.Count == 0)
{
Console.WriteLine("No results found.");
return;
}
for (var index = 0; index < response.Cards.Count; index++)
{
var card = response.Cards[index];
var severity = string.IsNullOrWhiteSpace(card.Severity)
? string.Empty
: $" severity={card.Severity}";
Console.WriteLine($"[{(index + 1).ToString(CultureInfo.InvariantCulture)}] {card.Domain.ToUpperInvariant()}/{card.EntityType.ToUpperInvariant()} score={card.Score.ToString("F6", CultureInfo.InvariantCulture)}{severity}");
Console.WriteLine($" {card.Title}");
var snippet = CollapseWhitespace(card.Snippet);
if (!string.IsNullOrWhiteSpace(snippet))
{
Console.WriteLine($" {snippet}");
}
foreach (var action in card.Actions)
{
var actionDetail = action.IsPrimary ? " [primary]" : "";
if (!string.IsNullOrWhiteSpace(action.Route))
{
Console.WriteLine($" -> {action.Label}: {action.Route}{actionDetail}");
}
else if (!string.IsNullOrWhiteSpace(action.Command))
{
Console.WriteLine($" -> {action.Label}: {action.Command}{actionDetail}");
}
}
Console.WriteLine();
}
}
private static void RenderUnifiedSuggestionOutput(UnifiedSearchResponseModel response, bool verbose)
{
Console.WriteLine($"Symptom: {response.Query}");
Console.WriteLine($"Mode: {response.Diagnostics.Mode} (duration={response.Diagnostics.DurationMs.ToString(CultureInfo.InvariantCulture)}ms)");
Console.WriteLine();
if (response.Synthesis is not null)
{
Console.WriteLine($"Analysis ({response.Synthesis.Confidence}):");
Console.WriteLine($" {response.Synthesis.Summary}");
Console.WriteLine();
}
var byDomain = response.Cards
.GroupBy(static c => c.Domain, StringComparer.Ordinal)
.OrderBy(static g => g.Key, StringComparer.Ordinal);
foreach (var group in byDomain)
{
Console.WriteLine($"{group.Key.ToUpperInvariant()} results:");
var items = group.ToArray();
for (var i = 0; i < items.Length; i++)
{
var card = items[i];
Console.WriteLine($" {(i + 1).ToString(CultureInfo.InvariantCulture)}. {card.Title} (score={card.Score.ToString("F6", CultureInfo.InvariantCulture)})");
var snippet = CollapseWhitespace(card.Snippet);
if (!string.IsNullOrWhiteSpace(snippet))
{
Console.WriteLine($" {snippet}");
}
}
Console.WriteLine();
}
}
private static object ToUnifiedJsonPayload(UnifiedSearchResponseModel response)
{
return new
{
query = response.Query,
topK = response.TopK,
diagnostics = new
{
ftsMatches = response.Diagnostics.FtsMatches,
vectorMatches = response.Diagnostics.VectorMatches,
entityCardCount = response.Diagnostics.EntityCardCount,
durationMs = response.Diagnostics.DurationMs,
usedVector = response.Diagnostics.UsedVector,
mode = response.Diagnostics.Mode
},
synthesis = response.Synthesis is null ? null : new
{
summary = response.Synthesis.Summary,
template = response.Synthesis.Template,
confidence = response.Synthesis.Confidence,
sourceCount = response.Synthesis.SourceCount,
domainsCovered = response.Synthesis.DomainsCovered
},
cards = response.Cards.Select(static card => new
{
entityKey = card.EntityKey,
entityType = card.EntityType,
domain = card.Domain,
title = card.Title,
snippet = card.Snippet,
score = card.Score,
severity = card.Severity,
actions = card.Actions.Select(static action => new
{
label = action.Label,
actionType = action.ActionType,
route = action.Route,
command = action.Command,
isPrimary = action.IsPrimary
}).ToArray(),
sources = card.Sources
}).ToArray()
};
}
}