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 AllowedTypes = new(StringComparer.Ordinal) { "docs", "api", "doctor", "findings", "vex", "policy" }; internal static Command BuildSearchCommand( IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { var queryArgument = new Argument("query") { Description = "Knowledge query (error text, endpoint question, runbook task)." }; var typeOption = new Option("--type") { Description = "Filter by result type: docs, api, doctor (repeatable or comma-separated).", Arity = ArgumentArity.ZeroOrMore, AllowMultipleArgumentsPerToken = true }; var productOption = new Option("--product") { Description = "Filter by product identifier." }; var versionOption = new Option("--version") { Description = "Filter by product version." }; var serviceOption = new Option("--service") { Description = "Filter by service (especially useful for API operations)." }; var tagOption = new Option("--tag") { Description = "Filter by tags (repeatable or comma-separated).", Arity = ArgumentArity.ZeroOrMore, AllowMultipleArgumentsPerToken = true }; var topKOption = new Option("--k") { Description = "Number of results to return (1-100, default 10)." }; var jsonOption = new Option("--json") { Description = "Emit machine-readable JSON output." }; var search = new Command("search", "Search AdvisoryAI knowledge index across docs, API operations, and doctor checks."); search.Add(queryArgument); search.Add(typeOption); search.Add(productOption); search.Add(versionOption); search.Add(serviceOption); search.Add(tagOption); search.Add(topKOption); search.Add(jsonOption); search.Add(verboseOption); search.SetAction(async (parseResult, _) => { var query = parseResult.GetValue(queryArgument) ?? string.Empty; var types = parseResult.GetValue(typeOption) ?? Array.Empty(); var tags = parseResult.GetValue(tagOption) ?? Array.Empty(); var product = parseResult.GetValue(productOption); var version = parseResult.GetValue(versionOption); var service = parseResult.GetValue(serviceOption); var topK = parseResult.GetValue(topKOption); var emitJson = parseResult.GetValue(jsonOption); var verbose = parseResult.GetValue(verboseOption); await ExecuteSearchAsync( services, query, types, tags, product, version, service, topK, emitJson, verbose, suggestMode: false, cancellationToken).ConfigureAwait(false); }); return search; } internal static Command BuildAdvisoryAiCommand( IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { var advisoryAi = new Command("advisoryai", "AdvisoryAI maintenance commands."); var index = new Command("index", "Knowledge index operations."); var rebuild = new Command("rebuild", "Rebuild AdvisoryAI deterministic knowledge index."); var rebuildJsonOption = new Option("--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("--repo-root") { Description = "Repository root used to resolve relative source paths." }; repoRootOption.SetDefaultValue("."); var docsAllowListOption = new Option("--docs-allowlist") { Description = "Path to docs allowlist JSON (include/includes/paths)." }; docsAllowListOption.SetDefaultValue(DefaultDocsAllowListPath); var docsManifestOutputOption = new Option("--docs-manifest-output") { Description = "Output path for resolved markdown manifest JSON." }; docsManifestOutputOption.SetDefaultValue(DefaultDocsManifestPath); var openApiOutputOption = new Option("--openapi-output") { Description = "Output path for aggregated OpenAPI snapshot." }; openApiOutputOption.SetDefaultValue(DefaultOpenApiAggregatePath); var doctorSeedOption = new Option("--doctor-seed") { Description = "Input doctor seed JSON used to derive controls." }; doctorSeedOption.SetDefaultValue(DefaultDoctorSeedPath); var doctorControlsOutputOption = new Option("--doctor-controls-output") { Description = "Output path for doctor controls JSON." }; doctorControlsOutputOption.SetDefaultValue(DefaultDoctorControlsPath); var overwriteOption = new Option("--overwrite") { Description = "Overwrite generated artifacts and OpenAPI snapshot." }; var prepareJsonOption = new Option("--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 verboseOption, CancellationToken cancellationToken) { var symptomArgument = new Argument("symptom") { Description = "Symptom text, log fragment, or error message." }; var productOption = new Option("--product") { Description = "Optional product filter." }; var versionOption = new Option("--version") { Description = "Optional version filter." }; var topKOption = new Option("--k") { Description = "Number of results to return (1-100, default 10)." }; var jsonOption = new Option("--json") { Description = "Emit machine-readable JSON output." }; var suggest = new Command("suggest", "Suggest checks, docs, and API operations for a symptom via AdvisoryAI knowledge search."); suggest.Add(symptomArgument); suggest.Add(productOption); suggest.Add(versionOption); suggest.Add(topKOption); suggest.Add(jsonOption); suggest.Add(verboseOption); suggest.SetAction(async (parseResult, _) => { var symptom = parseResult.GetValue(symptomArgument) ?? string.Empty; var product = parseResult.GetValue(productOption); var version = parseResult.GetValue(versionOption); var topK = parseResult.GetValue(topKOption); var emitJson = parseResult.GetValue(jsonOption); var verbose = parseResult.GetValue(verboseOption); await ExecuteSearchAsync( services, symptom, types: Array.Empty(), tags: Array.Empty(), product, version, service: null, topK, emitJson, verbose, suggestMode: true, cancellationToken).ConfigureAwait(false); }); return suggest; } private static async Task ExecuteSearchAsync( IServiceProvider services, string query, IReadOnlyList types, IReadOnlyList tags, string? product, string? version, string? service, int? topK, bool emitJson, bool verbose, bool suggestMode, CancellationToken cancellationToken) { var normalizedQuery = (query ?? string.Empty).Trim(); if (string.IsNullOrWhiteSpace(normalizedQuery)) { Console.Error.WriteLine("Query text is required."); Environment.ExitCode = CliExitCodes.MissingRequiredOption; return; } var normalizedTypes = NormalizeTypes(types); var normalizedTags = NormalizeTags(tags); var boundedTopK = topK.HasValue ? Math.Clamp(topK.Value, 1, 100) : (int?)null; var filter = BuildFilter(normalizedTypes, normalizedTags, product, version, service); var request = new AdvisoryKnowledgeSearchRequestModel { Q = normalizedQuery, K = boundedTopK, Filters = filter, IncludeDebug = verbose }; var backend = services.GetRequiredService(); // 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(); 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(); 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 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 ResolveMarkdownFiles(string repositoryRoot, IReadOnlyList includeEntries) { var files = new HashSet(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 includeEntries, IReadOnlyList 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 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(); 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 LoadDoctorSeedEntriesFromEngine(IServiceProvider services, bool verbose) { var engine = services.GetService(); 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 MergeDoctorSeedEntries( IReadOnlyList configuredEntries, IReadOnlyList 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 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 BuildKeywordList(DoctorSeedEntry entry) { var terms = new SortedSet(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 BuildSymptomsFromCheckText( string? title, string? description, IReadOnlyList? tags) { var terms = new SortedSet(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 MergeUniqueStrings(params IReadOnlyList?[] values) { var merged = new SortedSet(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 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 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 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 types, IReadOnlyList tags, string? product, string? version, string? service) { var normalizedProduct = NormalizeOptional(product); var normalizedVersion = NormalizeOptional(version); var normalizedService = NormalizeOptional(service); if (types.Count == 0 && tags.Count == 0 && normalizedProduct is null && normalizedVersion is null && normalizedService is null) { return null; } return new AdvisoryKnowledgeSearchFilterModel { Type = types.Count == 0 ? null : types, Product = normalizedProduct, Version = normalizedVersion, Service = normalizedService, Tags = tags.Count == 0 ? null : tags }; } private static IReadOnlyList NormalizeTypes(IEnumerable types) { var result = new SortedSet(StringComparer.Ordinal); foreach (var raw in types) { foreach (var token in SplitCsvTokens(raw)) { var normalized = token.ToLowerInvariant(); if (AllowedTypes.Contains(normalized)) { result.Add(normalized); } } } return result.ToArray(); } private static IReadOnlyList NormalizeTags(IEnumerable tags) { var result = new SortedSet(StringComparer.Ordinal); foreach (var raw in tags) { foreach (var token in SplitCsvTokens(raw)) { result.Add(token.ToLowerInvariant()); } } return result.ToArray(); } private static IEnumerable SplitCsvTokens(string? value) { if (string.IsNullOrWhiteSpace(value)) { yield break; } foreach (var token in value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { if (!string.IsNullOrWhiteSpace(token)) { yield return token.Trim(); } } } private static string? NormalizeOptional(string? value) { return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } private static void RenderSearchOutput(AdvisoryKnowledgeSearchResponseModel response, bool verbose) { Console.WriteLine($"Query: {response.Query}"); Console.WriteLine($"Results: {response.Results.Count.ToString(CultureInfo.InvariantCulture)} / topK {response.TopK.ToString(CultureInfo.InvariantCulture)}"); Console.WriteLine($"Mode: {response.Diagnostics.Mode} (fts={response.Diagnostics.FtsMatches.ToString(CultureInfo.InvariantCulture)}, vector={response.Diagnostics.VectorMatches.ToString(CultureInfo.InvariantCulture)}, duration={response.Diagnostics.DurationMs.ToString(CultureInfo.InvariantCulture)}ms)"); Console.WriteLine(); if (response.Results.Count == 0) { Console.WriteLine("No results found."); return; } for (var index = 0; index < response.Results.Count; index++) { var result = response.Results[index]; Console.WriteLine($"[{(index + 1).ToString(CultureInfo.InvariantCulture)}] {result.Type.ToUpperInvariant()} score={result.Score.ToString("F6", CultureInfo.InvariantCulture)}"); Console.WriteLine($" {result.Title}"); var snippet = CollapseWhitespace(result.Snippet); if (!string.IsNullOrWhiteSpace(snippet)) { Console.WriteLine($" {snippet}"); } var reference = FormatOpenReference(result); if (!string.IsNullOrWhiteSpace(reference)) { Console.WriteLine($" {reference}"); } if (verbose && result.Debug is { Count: > 0 }) { foreach (var pair in result.Debug.OrderBy(static pair => pair.Key, StringComparer.Ordinal)) { Console.WriteLine($" debug.{pair.Key}: {pair.Value}"); } } Console.WriteLine(); } } private static void RenderSuggestionOutput(AdvisoryKnowledgeSearchResponseModel response, bool verbose) { Console.WriteLine($"Symptom: {response.Query}"); Console.WriteLine($"Mode: {response.Diagnostics.Mode} (duration={response.Diagnostics.DurationMs.ToString(CultureInfo.InvariantCulture)}ms)"); Console.WriteLine(); var doctor = response.Results.Where(static result => result.Type.Equals("doctor", StringComparison.OrdinalIgnoreCase)).ToArray(); var docs = response.Results.Where(static result => result.Type.Equals("docs", StringComparison.OrdinalIgnoreCase)).ToArray(); var api = response.Results.Where(static result => result.Type.Equals("api", StringComparison.OrdinalIgnoreCase)).ToArray(); RenderSuggestionGroup("Recommended checks", doctor, verbose); RenderSuggestionGroup("Related docs", docs, verbose); RenderSuggestionGroup("Related endpoints", api, verbose); } private static void RenderSuggestionGroup(string label, IReadOnlyList results, bool verbose) { Console.WriteLine(label + ":"); if (results.Count == 0) { Console.WriteLine(" (none)"); Console.WriteLine(); return; } for (var index = 0; index < results.Count; index++) { var result = results[index]; Console.WriteLine($" {(index + 1).ToString(CultureInfo.InvariantCulture)}. {result.Title} (score={result.Score.ToString("F6", CultureInfo.InvariantCulture)})"); var reference = FormatOpenReference(result); if (!string.IsNullOrWhiteSpace(reference)) { Console.WriteLine($" {reference}"); } var snippet = CollapseWhitespace(result.Snippet); if (!string.IsNullOrWhiteSpace(snippet)) { Console.WriteLine($" {snippet}"); } if (verbose && result.Debug is { Count: > 0 }) { foreach (var pair in result.Debug.OrderBy(static pair => pair.Key, StringComparer.Ordinal)) { Console.WriteLine($" debug.{pair.Key}: {pair.Value}"); } } } Console.WriteLine(); } private static string FormatOpenReference(AdvisoryKnowledgeSearchResultModel result) { if (result.Open is null) { return string.Empty; } if (result.Type.Equals("docs", StringComparison.OrdinalIgnoreCase) && result.Open.Docs is not null) { var docs = result.Open.Docs; return $"docs: {docs.Path}#{docs.Anchor} lines {docs.SpanStart.ToString(CultureInfo.InvariantCulture)}-{docs.SpanEnd.ToString(CultureInfo.InvariantCulture)}"; } if (result.Type.Equals("api", StringComparison.OrdinalIgnoreCase) && result.Open.Api is not null) { var api = result.Open.Api; return $"api: {api.Method} {api.Path} operationId={api.OperationId} service={api.Service}"; } if (result.Type.Equals("doctor", StringComparison.OrdinalIgnoreCase) && result.Open.Doctor is not null) { var doctor = result.Open.Doctor; return $"doctor: {doctor.CheckCode} severity={doctor.Severity} 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 Symptoms, IReadOnlyList Tags, IReadOnlyList References); private sealed record DoctorControlEntry( string CheckCode, string Control, bool RequiresConfirmation, bool IsDestructive, bool RequiresBackup, string InspectCommand, string VerificationCommand, IReadOnlyList Keywords, string Title, string Severity, string Description, string Remediation, string RunCommand, IReadOnlyList Symptoms, IReadOnlyList Tags, IReadOnlyList References); private static void WriteJson(object payload) { Console.WriteLine(JsonSerializer.Serialize(payload, JsonOutputOptions)); } private static async Task TryUnifiedSearchAsync( IBackendOperationsClient backend, string query, IReadOnlyList types, IReadOnlyList 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 MapTypesToDomains(IReadOnlyList types) { if (types.Count == 0) return []; var domains = new HashSet(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() }; } }