1504 lines
55 KiB
C#
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()
|
|
};
|
|
}
|
|
}
|