wip: doctor/cli/docs/api to vector db consolidation; api hardening for descriptions, tenant, and scopes; migrations and conversions of all DALs to EF v10
This commit is contained in:
@@ -11,7 +11,34 @@ internal sealed record DoctorSearchSeedEntry(
|
||||
string RunCommand,
|
||||
IReadOnlyList<string> Symptoms,
|
||||
IReadOnlyList<string> Tags,
|
||||
IReadOnlyList<string> References);
|
||||
IReadOnlyList<string> References,
|
||||
DoctorSearchControl? Control = null);
|
||||
|
||||
internal sealed record DoctorSearchControl(
|
||||
string Mode,
|
||||
bool RequiresConfirmation,
|
||||
bool IsDestructive,
|
||||
bool RequiresBackup,
|
||||
string? InspectCommand,
|
||||
string? VerificationCommand);
|
||||
|
||||
internal sealed record DoctorControlSeedEntry(
|
||||
string CheckCode,
|
||||
string Control,
|
||||
bool RequiresConfirmation,
|
||||
bool IsDestructive,
|
||||
bool RequiresBackup,
|
||||
string? InspectCommand,
|
||||
string? VerificationCommand,
|
||||
IReadOnlyList<string> Keywords,
|
||||
string? Title = null,
|
||||
string? Severity = null,
|
||||
string? Description = null,
|
||||
string? Remediation = null,
|
||||
string? RunCommand = null,
|
||||
IReadOnlyList<string>? Symptoms = null,
|
||||
IReadOnlyList<string>? Tags = null,
|
||||
IReadOnlyList<string>? References = null);
|
||||
|
||||
internal static class DoctorSearchSeedLoader
|
||||
{
|
||||
@@ -33,3 +60,24 @@ internal static class DoctorSearchSeedLoader
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
internal static class DoctorControlSeedLoader
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public static IReadOnlyList<DoctorControlSeedEntry> Load(string absolutePath)
|
||||
{
|
||||
if (!File.Exists(absolutePath))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
using var stream = File.OpenRead(absolutePath);
|
||||
var entries = JsonSerializer.Deserialize<List<DoctorControlSeedEntry>>(stream, JsonOptions) ?? [];
|
||||
|
||||
return entries
|
||||
.Where(static entry => !string.IsNullOrWhiteSpace(entry.CheckCode))
|
||||
.OrderBy(static entry => entry.CheckCode, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,16 +61,16 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
|
||||
|
||||
private async Task<KnowledgeIndexSnapshot> BuildSnapshotAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var repositoryRoot = ResolveRepositoryRoot();
|
||||
var effective = ResolveEffectiveOptions();
|
||||
var documents = new Dictionary<string, KnowledgeSourceDocument>(StringComparer.Ordinal);
|
||||
var chunks = new Dictionary<string, KnowledgeChunkDocument>(StringComparer.Ordinal);
|
||||
var apiSpecs = new Dictionary<string, KnowledgeApiSpec>(StringComparer.Ordinal);
|
||||
var apiOperations = new Dictionary<string, KnowledgeApiOperation>(StringComparer.Ordinal);
|
||||
var doctorProjections = new Dictionary<string, KnowledgeDoctorProjection>(StringComparer.Ordinal);
|
||||
|
||||
IngestMarkdown(repositoryRoot, documents, chunks);
|
||||
IngestOpenApi(repositoryRoot, documents, chunks, apiSpecs, apiOperations);
|
||||
await IngestDoctorAsync(repositoryRoot, documents, chunks, doctorProjections, cancellationToken).ConfigureAwait(false);
|
||||
IngestMarkdown(effective, documents, chunks);
|
||||
IngestOpenApi(effective, documents, chunks, apiSpecs, apiOperations);
|
||||
await IngestDoctorAsync(effective, documents, chunks, doctorProjections, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new KnowledgeIndexSnapshot(
|
||||
documents.Values.OrderBy(static item => item.DocId, StringComparer.Ordinal).ToArray(),
|
||||
@@ -80,36 +80,64 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
|
||||
doctorProjections.Values.OrderBy(static item => item.CheckCode, StringComparer.Ordinal).ToArray());
|
||||
}
|
||||
|
||||
private string ResolveRepositoryRoot()
|
||||
private EffectiveIngestionOptions ResolveEffectiveOptions()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.RepositoryRoot))
|
||||
var repositoryRoot = string.IsNullOrWhiteSpace(_options.RepositoryRoot)
|
||||
? Directory.GetCurrentDirectory()
|
||||
: Path.IsPathRooted(_options.RepositoryRoot)
|
||||
? Path.GetFullPath(_options.RepositoryRoot)
|
||||
: Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), _options.RepositoryRoot));
|
||||
|
||||
var markdownRoots = (_options.MarkdownRoots ?? [])
|
||||
.Where(static root => !string.IsNullOrWhiteSpace(root))
|
||||
.Select(static root => root.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static root => root, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
if (markdownRoots.Length == 0)
|
||||
{
|
||||
return Directory.GetCurrentDirectory();
|
||||
markdownRoots = ["docs"];
|
||||
}
|
||||
|
||||
if (Path.IsPathRooted(_options.RepositoryRoot))
|
||||
var openApiRoots = (_options.OpenApiRoots ?? [])
|
||||
.Where(static root => !string.IsNullOrWhiteSpace(root))
|
||||
.Select(static root => root.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static root => root, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
if (openApiRoots.Length == 0)
|
||||
{
|
||||
return Path.GetFullPath(_options.RepositoryRoot);
|
||||
openApiRoots = ["src", "devops/compose"];
|
||||
}
|
||||
|
||||
return Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), _options.RepositoryRoot));
|
||||
return new EffectiveIngestionOptions(
|
||||
string.IsNullOrWhiteSpace(_options.Product) ? "stella-ops" : _options.Product.Trim(),
|
||||
string.IsNullOrWhiteSpace(_options.Version) ? "local" : _options.Version.Trim(),
|
||||
repositoryRoot,
|
||||
_options.DoctorChecksEndpoint ?? string.Empty,
|
||||
_options.DoctorSeedPath ?? string.Empty,
|
||||
_options.DoctorControlsPath ?? string.Empty,
|
||||
_options.MarkdownAllowListPath ?? string.Empty,
|
||||
markdownRoots,
|
||||
_options.OpenApiAggregatePath ?? string.Empty,
|
||||
openApiRoots);
|
||||
}
|
||||
|
||||
private void IngestMarkdown(
|
||||
string repositoryRoot,
|
||||
EffectiveIngestionOptions options,
|
||||
IDictionary<string, KnowledgeSourceDocument> documents,
|
||||
IDictionary<string, KnowledgeChunkDocument> chunks)
|
||||
{
|
||||
var markdownFiles = EnumerateMarkdownFiles(repositoryRoot);
|
||||
var markdownFiles = EnumerateMarkdownFiles(options.RepositoryRoot, options);
|
||||
foreach (var filePath in markdownFiles)
|
||||
{
|
||||
var relativePath = ToRelativeRepositoryPath(repositoryRoot, filePath);
|
||||
var relativePath = ToRelativeRepositoryPath(options.RepositoryRoot, filePath);
|
||||
var lines = File.ReadAllLines(filePath);
|
||||
var content = string.Join('\n', lines);
|
||||
var title = ExtractMarkdownDocumentTitle(lines, relativePath);
|
||||
var pathTags = ExtractPathTags(relativePath);
|
||||
|
||||
var docId = KnowledgeSearchText.StableId("doc", "markdown", _options.Product, _options.Version, relativePath);
|
||||
var docId = KnowledgeSearchText.StableId("doc", "markdown", options.Product, options.Version, relativePath);
|
||||
var docMetadata = CreateJsonDocument(new SortedDictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["kind"] = "markdown",
|
||||
@@ -120,8 +148,8 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
|
||||
documents[docId] = new KnowledgeSourceDocument(
|
||||
docId,
|
||||
"markdown",
|
||||
_options.Product,
|
||||
_options.Version,
|
||||
options.Product,
|
||||
options.Version,
|
||||
"repo",
|
||||
relativePath,
|
||||
title,
|
||||
@@ -158,16 +186,16 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
|
||||
}
|
||||
|
||||
private void IngestOpenApi(
|
||||
string repositoryRoot,
|
||||
EffectiveIngestionOptions options,
|
||||
IDictionary<string, KnowledgeSourceDocument> documents,
|
||||
IDictionary<string, KnowledgeChunkDocument> chunks,
|
||||
IDictionary<string, KnowledgeApiSpec> apiSpecs,
|
||||
IDictionary<string, KnowledgeApiOperation> apiOperations)
|
||||
{
|
||||
var apiFiles = EnumerateOpenApiFiles(repositoryRoot);
|
||||
var apiFiles = EnumerateOpenApiFiles(options.RepositoryRoot, options);
|
||||
foreach (var filePath in apiFiles)
|
||||
{
|
||||
var relativePath = ToRelativeRepositoryPath(repositoryRoot, filePath);
|
||||
var relativePath = ToRelativeRepositoryPath(options.RepositoryRoot, filePath);
|
||||
JsonDocument document;
|
||||
try
|
||||
{
|
||||
@@ -194,7 +222,7 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
|
||||
var apiVersion = TryGetNestedString(root, "info", "version");
|
||||
var pathTags = ExtractPathTags(relativePath);
|
||||
|
||||
var docId = KnowledgeSearchText.StableId("doc", "openapi", _options.Product, _options.Version, relativePath);
|
||||
var docId = KnowledgeSearchText.StableId("doc", "openapi", options.Product, options.Version, relativePath);
|
||||
var docMetadata = CreateJsonDocument(new SortedDictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["kind"] = "openapi",
|
||||
@@ -206,15 +234,15 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
|
||||
documents[docId] = new KnowledgeSourceDocument(
|
||||
docId,
|
||||
"openapi",
|
||||
_options.Product,
|
||||
_options.Version,
|
||||
options.Product,
|
||||
options.Version,
|
||||
"repo",
|
||||
relativePath,
|
||||
title,
|
||||
KnowledgeSearchText.StableId(root.GetRawText()),
|
||||
docMetadata);
|
||||
|
||||
var specId = KnowledgeSearchText.StableId("api-spec", _options.Product, _options.Version, relativePath, service);
|
||||
var specId = KnowledgeSearchText.StableId("api-spec", options.Product, options.Version, relativePath, service);
|
||||
apiSpecs[specId] = new KnowledgeApiSpec(
|
||||
specId,
|
||||
docId,
|
||||
@@ -299,19 +327,24 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
|
||||
}
|
||||
|
||||
private async Task IngestDoctorAsync(
|
||||
string repositoryRoot,
|
||||
EffectiveIngestionOptions options,
|
||||
IDictionary<string, KnowledgeSourceDocument> documents,
|
||||
IDictionary<string, KnowledgeChunkDocument> chunks,
|
||||
IDictionary<string, KnowledgeDoctorProjection> doctorProjections,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var seedPath = ResolvePath(repositoryRoot, _options.DoctorSeedPath);
|
||||
var seedPath = ResolvePath(options.RepositoryRoot, options.DoctorSeedPath);
|
||||
var seedEntries = DoctorSearchSeedLoader.Load(seedPath)
|
||||
.ToDictionary(static entry => entry.CheckCode, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var endpointEntries = await LoadDoctorEndpointMetadataAsync(cancellationToken).ConfigureAwait(false);
|
||||
var controlsPath = ResolvePath(options.RepositoryRoot, options.DoctorControlsPath);
|
||||
var controlEntries = DoctorControlSeedLoader.Load(controlsPath)
|
||||
.ToDictionary(static entry => entry.CheckCode, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var endpointEntries = await LoadDoctorEndpointMetadataAsync(options.DoctorChecksEndpoint, cancellationToken).ConfigureAwait(false);
|
||||
var checkCodes = seedEntries.Keys
|
||||
.Union(endpointEntries.Keys, StringComparer.OrdinalIgnoreCase)
|
||||
.Union(controlEntries.Keys, StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static code => code, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
@@ -319,11 +352,12 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
|
||||
{
|
||||
seedEntries.TryGetValue(checkCode, out var seeded);
|
||||
endpointEntries.TryGetValue(checkCode, out var endpoint);
|
||||
controlEntries.TryGetValue(checkCode, out var seededControl);
|
||||
|
||||
var title = seeded?.Title ?? endpoint?.Title ?? checkCode;
|
||||
var severity = NormalizeSeverity(seeded?.Severity ?? endpoint?.Severity ?? "warn");
|
||||
var description = seeded?.Description ?? endpoint?.Description ?? string.Empty;
|
||||
var remediation = seeded?.Remediation;
|
||||
var title = seeded?.Title ?? endpoint?.Title ?? seededControl?.Title ?? checkCode;
|
||||
var severity = NormalizeSeverity(seeded?.Severity ?? endpoint?.Severity ?? seededControl?.Severity ?? "warn");
|
||||
var description = seeded?.Description ?? endpoint?.Description ?? seededControl?.Description ?? string.Empty;
|
||||
var remediation = seeded?.Remediation ?? seededControl?.Remediation;
|
||||
if (string.IsNullOrWhiteSpace(remediation))
|
||||
{
|
||||
remediation = !string.IsNullOrWhiteSpace(description)
|
||||
@@ -331,45 +365,69 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
|
||||
: $"Inspect {checkCode} and run targeted diagnostics.";
|
||||
}
|
||||
|
||||
var runCommand = string.IsNullOrWhiteSpace(seeded?.RunCommand)
|
||||
var seededRunCommand = seeded?.RunCommand;
|
||||
if (string.IsNullOrWhiteSpace(seededRunCommand) && !string.IsNullOrWhiteSpace(seededControl?.RunCommand))
|
||||
{
|
||||
seededRunCommand = seededControl.RunCommand;
|
||||
}
|
||||
|
||||
var runCommand = string.IsNullOrWhiteSpace(seededRunCommand)
|
||||
? $"stella doctor run --check {checkCode}"
|
||||
: seeded!.RunCommand.Trim();
|
||||
: seededRunCommand.Trim();
|
||||
|
||||
var symptoms = MergeOrdered(
|
||||
seeded?.Symptoms ?? [],
|
||||
endpoint?.Symptoms ?? [],
|
||||
seededControl?.Symptoms ?? [],
|
||||
seededControl?.Keywords ?? [],
|
||||
ExpandSymptomsFromText(description),
|
||||
ExpandSymptomsFromText(title));
|
||||
|
||||
var tags = MergeOrdered(
|
||||
seeded?.Tags ?? [],
|
||||
endpoint?.Tags ?? [],
|
||||
seededControl?.Tags ?? [],
|
||||
["doctor", "diagnostics"]);
|
||||
|
||||
var references = MergeOrdered(
|
||||
seeded?.References ?? [],
|
||||
endpoint?.References ?? []);
|
||||
endpoint?.References ?? [],
|
||||
seededControl?.References ?? []);
|
||||
|
||||
var docId = KnowledgeSearchText.StableId("doc", "doctor", _options.Product, _options.Version, checkCode);
|
||||
var control = BuildDoctorControl(
|
||||
checkCode,
|
||||
severity,
|
||||
runCommand,
|
||||
seeded?.Control,
|
||||
seededControl,
|
||||
symptoms,
|
||||
title,
|
||||
description);
|
||||
|
||||
var docId = KnowledgeSearchText.StableId("doc", "doctor", options.Product, options.Version, checkCode);
|
||||
var docMetadata = CreateJsonDocument(new SortedDictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["kind"] = "doctor",
|
||||
["checkCode"] = checkCode,
|
||||
["tags"] = tags
|
||||
["tags"] = tags,
|
||||
["control"] = control.Control,
|
||||
["requiresConfirmation"] = control.RequiresConfirmation,
|
||||
["isDestructive"] = control.IsDestructive,
|
||||
["requiresBackup"] = control.RequiresBackup
|
||||
});
|
||||
|
||||
documents[docId] = new KnowledgeSourceDocument(
|
||||
docId,
|
||||
"doctor",
|
||||
_options.Product,
|
||||
_options.Version,
|
||||
options.Product,
|
||||
options.Version,
|
||||
"doctor",
|
||||
$"doctor://{checkCode}",
|
||||
title,
|
||||
KnowledgeSearchText.StableId(checkCode, title, remediation),
|
||||
docMetadata);
|
||||
|
||||
var body = BuildDoctorSearchBody(checkCode, title, severity, description, remediation, runCommand, symptoms, references);
|
||||
var body = BuildDoctorSearchBody(checkCode, title, severity, description, remediation, runCommand, symptoms, references, control);
|
||||
var anchor = KnowledgeSearchText.Slugify(checkCode);
|
||||
var chunkId = KnowledgeSearchText.StableId("chunk", "doctor", checkCode, severity);
|
||||
var chunkMetadata = CreateJsonDocument(new SortedDictionary<string, object?>(StringComparer.Ordinal)
|
||||
@@ -378,7 +436,14 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
|
||||
["severity"] = severity,
|
||||
["runCommand"] = runCommand,
|
||||
["tags"] = tags,
|
||||
["service"] = "doctor"
|
||||
["service"] = "doctor",
|
||||
["control"] = control.Control,
|
||||
["requiresConfirmation"] = control.RequiresConfirmation,
|
||||
["isDestructive"] = control.IsDestructive,
|
||||
["requiresBackup"] = control.RequiresBackup,
|
||||
["inspectCommand"] = control.InspectCommand,
|
||||
["verificationCommand"] = control.VerificationCommand,
|
||||
["keywords"] = control.Keywords
|
||||
});
|
||||
|
||||
chunks[chunkId] = new KnowledgeChunkDocument(
|
||||
@@ -407,9 +472,9 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, DoctorEndpointMetadata>> LoadDoctorEndpointMetadataAsync(CancellationToken cancellationToken)
|
||||
private async Task<Dictionary<string, DoctorEndpointMetadata>> LoadDoctorEndpointMetadataAsync(string endpoint, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.DoctorChecksEndpoint))
|
||||
if (string.IsNullOrWhiteSpace(endpoint))
|
||||
{
|
||||
return new Dictionary<string, DoctorEndpointMetadata>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -419,10 +484,10 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
|
||||
using var client = _httpClientFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromMilliseconds(Math.Max(500, _options.QueryTimeoutMs));
|
||||
|
||||
using var response = await client.GetAsync(_options.DoctorChecksEndpoint, cancellationToken).ConfigureAwait(false);
|
||||
using var response = await client.GetAsync(endpoint, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("Doctor check metadata endpoint {Endpoint} returned {StatusCode}.", _options.DoctorChecksEndpoint, (int)response.StatusCode);
|
||||
_logger.LogWarning("Doctor check metadata endpoint {Endpoint} returned {StatusCode}.", endpoint, (int)response.StatusCode);
|
||||
return new Dictionary<string, DoctorEndpointMetadata>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
@@ -482,7 +547,7 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load doctor metadata from {Endpoint}.", _options.DoctorChecksEndpoint);
|
||||
_logger.LogWarning(ex, "Failed to load doctor metadata from {Endpoint}.", endpoint);
|
||||
return new Dictionary<string, DoctorEndpointMetadata>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -603,30 +668,61 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
|
||||
return true;
|
||||
}
|
||||
|
||||
private IReadOnlyList<string> EnumerateMarkdownFiles(string repositoryRoot)
|
||||
private IReadOnlyList<string> EnumerateMarkdownFiles(string repositoryRoot, EffectiveIngestionOptions options)
|
||||
{
|
||||
var files = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var root in _options.MarkdownRoots.OrderBy(static item => item, StringComparer.Ordinal))
|
||||
{
|
||||
var absoluteRoot = ResolvePath(repositoryRoot, root);
|
||||
if (!Directory.Exists(absoluteRoot))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var file in Directory.EnumerateFiles(absoluteRoot, "*.md", SearchOption.AllDirectories))
|
||||
{
|
||||
files.Add(Path.GetFullPath(file));
|
||||
}
|
||||
var allowListPath = ResolvePath(repositoryRoot, options.MarkdownAllowListPath);
|
||||
var allowListIncludes = MarkdownSourceAllowListLoader.LoadIncludes(allowListPath);
|
||||
foreach (var includePath in allowListIncludes)
|
||||
{
|
||||
AddMarkdownFilesFromPath(repositoryRoot, includePath, files);
|
||||
}
|
||||
|
||||
if (files.Count > 0)
|
||||
{
|
||||
return files.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
}
|
||||
|
||||
foreach (var root in options.MarkdownRoots.OrderBy(static item => item, StringComparer.Ordinal))
|
||||
{
|
||||
AddMarkdownFilesFromPath(repositoryRoot, root, files);
|
||||
}
|
||||
|
||||
return files.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
}
|
||||
|
||||
private IReadOnlyList<string> EnumerateOpenApiFiles(string repositoryRoot)
|
||||
private void AddMarkdownFilesFromPath(string repositoryRoot, string configuredPath, ISet<string> files)
|
||||
{
|
||||
var absolutePath = ResolvePath(repositoryRoot, configuredPath);
|
||||
if (File.Exists(absolutePath) &&
|
||||
Path.GetExtension(absolutePath).Equals(".md", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
files.Add(Path.GetFullPath(absolutePath));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Directory.Exists(absolutePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var file in Directory.EnumerateFiles(absolutePath, "*.md", SearchOption.AllDirectories))
|
||||
{
|
||||
files.Add(Path.GetFullPath(file));
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<string> EnumerateOpenApiFiles(string repositoryRoot, EffectiveIngestionOptions options)
|
||||
{
|
||||
var aggregatePath = ResolvePath(repositoryRoot, options.OpenApiAggregatePath);
|
||||
if (File.Exists(aggregatePath))
|
||||
{
|
||||
return [Path.GetFullPath(aggregatePath)];
|
||||
}
|
||||
|
||||
var files = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var root in _options.OpenApiRoots.OrderBy(static item => item, StringComparer.Ordinal))
|
||||
foreach (var root in options.OpenApiRoots.OrderBy(static item => item, StringComparer.Ordinal))
|
||||
{
|
||||
var absoluteRoot = ResolvePath(repositoryRoot, root);
|
||||
if (!Directory.Exists(absoluteRoot))
|
||||
@@ -764,7 +860,8 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
|
||||
string remediation,
|
||||
string runCommand,
|
||||
IReadOnlyList<string> symptoms,
|
||||
IReadOnlyList<string> references)
|
||||
IReadOnlyList<string> references,
|
||||
DoctorControlSeedEntry control)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.Append("check: ").Append(checkCode).AppendLine();
|
||||
@@ -778,6 +875,14 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
|
||||
|
||||
builder.Append("remediation: ").Append(remediation).AppendLine();
|
||||
builder.Append("run: ").Append(runCommand).AppendLine();
|
||||
builder.Append("control: ").Append(control.Control).AppendLine();
|
||||
builder.Append("requiresConfirmation: ").Append(control.RequiresConfirmation ? "true" : "false").AppendLine();
|
||||
builder.Append("isDestructive: ").Append(control.IsDestructive ? "true" : "false").AppendLine();
|
||||
builder.Append("requiresBackup: ").Append(control.RequiresBackup ? "true" : "false").AppendLine();
|
||||
if (!string.IsNullOrWhiteSpace(control.InspectCommand))
|
||||
{
|
||||
builder.Append("inspect: ").Append(control.InspectCommand).AppendLine();
|
||||
}
|
||||
|
||||
if (symptoms.Count > 0)
|
||||
{
|
||||
@@ -789,9 +894,96 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
|
||||
builder.Append("references: ").Append(string.Join(", ", references)).AppendLine();
|
||||
}
|
||||
|
||||
if (control.Keywords.Count > 0)
|
||||
{
|
||||
builder.Append("keywords: ").Append(string.Join(", ", control.Keywords)).AppendLine();
|
||||
}
|
||||
|
||||
return builder.ToString().Trim();
|
||||
}
|
||||
|
||||
private static DoctorControlSeedEntry BuildDoctorControl(
|
||||
string checkCode,
|
||||
string severity,
|
||||
string runCommand,
|
||||
DoctorSearchControl? embeddedControl,
|
||||
DoctorControlSeedEntry? seededControl,
|
||||
IReadOnlyList<string> symptoms,
|
||||
string title,
|
||||
string description)
|
||||
{
|
||||
var mode = NormalizeControlMode(seededControl?.Control ?? embeddedControl?.Mode ?? InferControlFromSeverity(severity));
|
||||
var requiresConfirmation = seededControl?.RequiresConfirmation ?? embeddedControl?.RequiresConfirmation ?? !mode.Equals("safe", StringComparison.Ordinal);
|
||||
var isDestructive = seededControl?.IsDestructive ?? embeddedControl?.IsDestructive ?? mode.Equals("destructive", StringComparison.Ordinal);
|
||||
var requiresBackup = seededControl?.RequiresBackup ?? embeddedControl?.RequiresBackup ?? isDestructive;
|
||||
|
||||
var inspectCommand = FirstNonEmpty(
|
||||
seededControl?.InspectCommand,
|
||||
embeddedControl?.InspectCommand,
|
||||
$"stella doctor run --check {checkCode} --mode quick");
|
||||
|
||||
var verificationCommand = FirstNonEmpty(
|
||||
seededControl?.VerificationCommand,
|
||||
embeddedControl?.VerificationCommand,
|
||||
runCommand);
|
||||
|
||||
var keywords = MergeOrdered(
|
||||
seededControl?.Keywords ?? [],
|
||||
symptoms,
|
||||
ExpandSymptomsFromText(title),
|
||||
ExpandSymptomsFromText(description));
|
||||
|
||||
return new DoctorControlSeedEntry(
|
||||
checkCode,
|
||||
mode,
|
||||
requiresConfirmation,
|
||||
isDestructive,
|
||||
requiresBackup,
|
||||
inspectCommand,
|
||||
verificationCommand,
|
||||
keywords);
|
||||
}
|
||||
|
||||
private static string FirstNonEmpty(params string?[] values)
|
||||
{
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static string InferControlFromSeverity(string severity)
|
||||
{
|
||||
return NormalizeSeverity(severity) switch
|
||||
{
|
||||
"fail" => "manual",
|
||||
"warn" => "safe",
|
||||
_ => "safe"
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeControlMode(string control)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(control))
|
||||
{
|
||||
return "safe";
|
||||
}
|
||||
|
||||
return control.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"safe" => "safe",
|
||||
"manual" => "manual",
|
||||
"destructive" => "destructive",
|
||||
"disabled" => "disabled",
|
||||
_ => "safe"
|
||||
};
|
||||
}
|
||||
|
||||
private static JsonDocument CloneOrDefault(JsonElement source, string propertyName, string fallbackJson)
|
||||
{
|
||||
if (source.ValueKind == JsonValueKind.Object && source.TryGetProperty(propertyName, out var value))
|
||||
@@ -989,6 +1181,18 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
|
||||
return JsonDocument.Parse(json);
|
||||
}
|
||||
|
||||
private sealed record EffectiveIngestionOptions(
|
||||
string Product,
|
||||
string Version,
|
||||
string RepositoryRoot,
|
||||
string DoctorChecksEndpoint,
|
||||
string DoctorSeedPath,
|
||||
string DoctorControlsPath,
|
||||
string MarkdownAllowListPath,
|
||||
IReadOnlyList<string> MarkdownRoots,
|
||||
string OpenApiAggregatePath,
|
||||
IReadOnlyList<string> OpenApiRoots);
|
||||
|
||||
private sealed record DoctorEndpointMetadata(
|
||||
string Title,
|
||||
string Severity,
|
||||
|
||||
@@ -65,7 +65,10 @@ public sealed record KnowledgeOpenDoctorAction(
|
||||
string CheckCode,
|
||||
string Severity,
|
||||
bool CanRun,
|
||||
string RunCommand);
|
||||
string RunCommand,
|
||||
string Control = "safe",
|
||||
bool RequiresConfirmation = false,
|
||||
bool IsDestructive = false);
|
||||
|
||||
public sealed record KnowledgeSearchDiagnostics(
|
||||
int FtsMatches,
|
||||
|
||||
@@ -42,6 +42,14 @@ public sealed class KnowledgeSearchOptions
|
||||
public string DoctorSeedPath { get; set; } =
|
||||
"src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/doctor-search-seed.json";
|
||||
|
||||
public string DoctorControlsPath { get; set; } =
|
||||
"src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/doctor-search-controls.json";
|
||||
|
||||
public string MarkdownAllowListPath { get; set; } =
|
||||
"src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/knowledge-docs-allowlist.json";
|
||||
|
||||
public string OpenApiAggregatePath { get; set; } = "devops/compose/openapi_current.json";
|
||||
|
||||
public List<string> MarkdownRoots { get; set; } = ["docs"];
|
||||
|
||||
public List<string> OpenApiRoots { get; set; } = ["src", "devops/compose"];
|
||||
|
||||
@@ -11,6 +11,66 @@ internal sealed class KnowledgeSearchService : IKnowledgeSearchService
|
||||
{
|
||||
private const int ReciprocalRankConstant = 60;
|
||||
private static readonly Regex MethodPathPattern = new("\\b(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|TRACE)\\s+(/[^\\s]+)", RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
|
||||
private static readonly string[] ApiIntentTerms =
|
||||
[
|
||||
"endpoint",
|
||||
"api",
|
||||
"openapi",
|
||||
"swagger",
|
||||
"operation",
|
||||
"route",
|
||||
"path",
|
||||
"method",
|
||||
"contract"
|
||||
];
|
||||
private static readonly string[] DoctorIntentTerms =
|
||||
[
|
||||
"doctor",
|
||||
"check",
|
||||
"readiness",
|
||||
"health",
|
||||
"diagnostic",
|
||||
"remediation",
|
||||
"symptom"
|
||||
];
|
||||
private static readonly string[] DocsIntentTerms =
|
||||
[
|
||||
"how to",
|
||||
"how do i",
|
||||
"guide",
|
||||
"runbook",
|
||||
"troubleshoot",
|
||||
"documentation",
|
||||
"docs",
|
||||
"playbook",
|
||||
"steps"
|
||||
];
|
||||
private static readonly HashSet<string> NoiseTerms = new(StringComparer.Ordinal)
|
||||
{
|
||||
"a",
|
||||
"an",
|
||||
"and",
|
||||
"api",
|
||||
"do",
|
||||
"endpoint",
|
||||
"for",
|
||||
"how",
|
||||
"i",
|
||||
"in",
|
||||
"is",
|
||||
"method",
|
||||
"of",
|
||||
"on",
|
||||
"operation",
|
||||
"or",
|
||||
"path",
|
||||
"route",
|
||||
"the",
|
||||
"to",
|
||||
"what",
|
||||
"where",
|
||||
"which"
|
||||
};
|
||||
|
||||
private readonly KnowledgeSearchOptions _options;
|
||||
private readonly IKnowledgeSearchStore _store;
|
||||
@@ -181,9 +241,31 @@ internal sealed class KnowledgeSearchService : IKnowledgeSearchService
|
||||
var normalizedQuery = query.Trim();
|
||||
var lowerQuery = normalizedQuery.ToLowerInvariant();
|
||||
var metadata = row.Metadata.RootElement;
|
||||
var isApi = row.Kind.Equals("api_operation", StringComparison.OrdinalIgnoreCase);
|
||||
var isDoctor = row.Kind.Equals("doctor_check", StringComparison.OrdinalIgnoreCase);
|
||||
var isDocs = !isApi && !isDoctor;
|
||||
|
||||
var boost = 0d;
|
||||
if (row.Kind.Equals("doctor_check", StringComparison.OrdinalIgnoreCase))
|
||||
var apiIntent = ContainsAnyTerm(lowerQuery, ApiIntentTerms);
|
||||
var doctorIntent = ContainsAnyTerm(lowerQuery, DoctorIntentTerms);
|
||||
var docsIntent = ContainsAnyTerm(lowerQuery, DocsIntentTerms);
|
||||
|
||||
if (apiIntent)
|
||||
{
|
||||
boost += isApi ? 0.28d : -0.04d;
|
||||
}
|
||||
|
||||
if (doctorIntent && isDoctor)
|
||||
{
|
||||
boost += 0.20d;
|
||||
}
|
||||
|
||||
if (docsIntent && isDocs)
|
||||
{
|
||||
boost += 0.12d;
|
||||
}
|
||||
|
||||
if (isDoctor)
|
||||
{
|
||||
var checkCode = GetMetadataString(metadata, "checkCode");
|
||||
if (!string.IsNullOrWhiteSpace(checkCode) && checkCode.Equals(normalizedQuery, StringComparison.OrdinalIgnoreCase))
|
||||
@@ -192,8 +274,16 @@ internal sealed class KnowledgeSearchService : IKnowledgeSearchService
|
||||
}
|
||||
}
|
||||
|
||||
if (row.Kind.Equals("api_operation", StringComparison.OrdinalIgnoreCase))
|
||||
if (isApi)
|
||||
{
|
||||
var apiText = $"{row.Title} {row.Body}".ToLowerInvariant();
|
||||
var termMatches = ExtractSalientTerms(lowerQuery)
|
||||
.Count(term => apiText.Contains(term, StringComparison.Ordinal));
|
||||
if (termMatches > 0)
|
||||
{
|
||||
boost += Math.Min(0.30d, termMatches * 0.08d);
|
||||
}
|
||||
|
||||
var operationId = GetMetadataString(metadata, "operationId");
|
||||
if (!string.IsNullOrWhiteSpace(operationId) && operationId.Equals(normalizedQuery, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@@ -249,6 +339,40 @@ internal sealed class KnowledgeSearchService : IKnowledgeSearchService
|
||||
return boost;
|
||||
}
|
||||
|
||||
private static bool ContainsAnyTerm(string query, IReadOnlyList<string> terms)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query) || terms.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var term in terms)
|
||||
{
|
||||
if (query.Contains(term, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ExtractSalientTerms(string query)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return query
|
||||
.Split([' ', '\t', '\r', '\n', ':', ';', ',', '.', '/', '\\', '?', '!', '[', ']', '{', '}', '(', ')', '"', '\''], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(static token => token.ToLowerInvariant())
|
||||
.Where(static token => token.Length >= 4)
|
||||
.Where(token => !NoiseTerms.Contains(token))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private KnowledgeSearchResult BuildResult(
|
||||
KnowledgeChunkRow row,
|
||||
string query,
|
||||
@@ -282,8 +406,11 @@ internal sealed class KnowledgeSearchService : IKnowledgeSearchService
|
||||
Doctor: new KnowledgeOpenDoctorAction(
|
||||
GetMetadataString(metadata, "checkCode") ?? row.Title,
|
||||
GetMetadataString(metadata, "severity") ?? "warn",
|
||||
true,
|
||||
GetMetadataString(metadata, "runCommand") ?? $"stella doctor run --check {row.Title}")),
|
||||
!string.Equals(GetMetadataString(metadata, "control"), "disabled", StringComparison.OrdinalIgnoreCase),
|
||||
GetMetadataString(metadata, "runCommand") ?? $"stella doctor run --check {row.Title}",
|
||||
GetMetadataString(metadata, "control") ?? "safe",
|
||||
GetMetadataBoolean(metadata, "requiresConfirmation"),
|
||||
GetMetadataBoolean(metadata, "isDestructive"))),
|
||||
_ => new KnowledgeOpenAction(
|
||||
KnowledgeOpenActionType.Docs,
|
||||
Docs: new KnowledgeOpenDocAction(
|
||||
@@ -360,6 +487,22 @@ internal sealed class KnowledgeSearchService : IKnowledgeSearchService
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static bool GetMetadataBoolean(JsonElement metadata, string propertyName)
|
||||
{
|
||||
if (metadata.ValueKind != JsonValueKind.Object || !metadata.TryGetProperty(propertyName, out var value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.String => bool.TryParse(value.GetString(), out var parsed) && parsed,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private int ResolveTopK(int? requested)
|
||||
{
|
||||
var fallback = Math.Max(1, _options.DefaultTopK);
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.KnowledgeSearch;
|
||||
|
||||
internal static class MarkdownSourceAllowListLoader
|
||||
{
|
||||
public static IReadOnlyList<string> LoadIncludes(string absolutePath)
|
||||
{
|
||||
if (!File.Exists(absolutePath))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(absolutePath);
|
||||
using var document = JsonDocument.Parse(stream);
|
||||
return ExtractIncludes(document.RootElement);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ExtractIncludes(JsonElement element)
|
||||
{
|
||||
IEnumerable<string> values = [];
|
||||
if (element.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
values = ReadStringArray(element);
|
||||
}
|
||||
else if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (TryGetArray(element, "include", out var include))
|
||||
{
|
||||
values = ReadStringArray(include);
|
||||
}
|
||||
else if (TryGetArray(element, "includes", out include))
|
||||
{
|
||||
values = ReadStringArray(include);
|
||||
}
|
||||
else if (TryGetArray(element, "paths", out include))
|
||||
{
|
||||
values = ReadStringArray(include);
|
||||
}
|
||||
}
|
||||
|
||||
return values
|
||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(static value => value.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static value => value, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static bool TryGetArray(JsonElement element, string propertyName, out JsonElement value)
|
||||
{
|
||||
if (element.TryGetProperty(propertyName, out value) && value.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ReadStringArray(JsonElement array)
|
||||
{
|
||||
foreach (var item in array.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var value = item.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
yield return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
[
|
||||
{
|
||||
"checkCode": "check.airgap.bundle.integrity",
|
||||
"control": "manual",
|
||||
"requiresConfirmation": true,
|
||||
"isDestructive": false,
|
||||
"requiresBackup": false,
|
||||
"inspectCommand": "stella doctor run --check check.airgap.bundle.integrity --mode quick",
|
||||
"verificationCommand": "stella doctor run --check check.airgap.bundle.integrity",
|
||||
"keywords": [
|
||||
"checksum mismatch",
|
||||
"signature invalid",
|
||||
"offline import failed"
|
||||
]
|
||||
},
|
||||
{
|
||||
"checkCode": "check.core.db.connectivity",
|
||||
"control": "manual",
|
||||
"requiresConfirmation": true,
|
||||
"isDestructive": false,
|
||||
"requiresBackup": false,
|
||||
"inspectCommand": "stella doctor run --check check.core.db.connectivity --mode quick",
|
||||
"verificationCommand": "stella doctor run --check check.core.db.connectivity",
|
||||
"keywords": [
|
||||
"connection refused",
|
||||
"database unavailable",
|
||||
"timeout expired"
|
||||
]
|
||||
},
|
||||
{
|
||||
"checkCode": "check.core.disk.space",
|
||||
"control": "manual",
|
||||
"requiresConfirmation": true,
|
||||
"isDestructive": false,
|
||||
"requiresBackup": false,
|
||||
"inspectCommand": "stella doctor run --check check.core.disk.space --mode quick",
|
||||
"verificationCommand": "stella doctor run --check check.core.disk.space",
|
||||
"keywords": [
|
||||
"disk full",
|
||||
"no space left on device",
|
||||
"write failure"
|
||||
]
|
||||
},
|
||||
{
|
||||
"checkCode": "check.integrations.secrets.binding",
|
||||
"control": "safe",
|
||||
"requiresConfirmation": false,
|
||||
"isDestructive": false,
|
||||
"requiresBackup": false,
|
||||
"inspectCommand": "stella doctor run --check check.integrations.secrets.binding --mode quick",
|
||||
"verificationCommand": "stella doctor run --check check.integrations.secrets.binding",
|
||||
"keywords": [
|
||||
"auth failed",
|
||||
"invalid credential",
|
||||
"secret missing"
|
||||
]
|
||||
},
|
||||
{
|
||||
"checkCode": "check.release.policy.gate",
|
||||
"control": "safe",
|
||||
"requiresConfirmation": false,
|
||||
"isDestructive": false,
|
||||
"requiresBackup": false,
|
||||
"inspectCommand": "stella doctor run --check check.release.policy.gate --mode quick",
|
||||
"verificationCommand": "stella doctor run --check check.release.policy.gate",
|
||||
"keywords": [
|
||||
"missing attestation",
|
||||
"policy gate failed",
|
||||
"promotion blocked"
|
||||
]
|
||||
},
|
||||
{
|
||||
"checkCode": "check.router.gateway.routes",
|
||||
"control": "safe",
|
||||
"requiresConfirmation": false,
|
||||
"isDestructive": false,
|
||||
"requiresBackup": false,
|
||||
"inspectCommand": "stella doctor run --check check.router.gateway.routes --mode quick",
|
||||
"verificationCommand": "stella doctor run --check check.router.gateway.routes",
|
||||
"keywords": [
|
||||
"404 on expected endpoint",
|
||||
"gateway routing",
|
||||
"route missing"
|
||||
]
|
||||
},
|
||||
{
|
||||
"checkCode": "check.security.oidc.readiness",
|
||||
"control": "safe",
|
||||
"requiresConfirmation": false,
|
||||
"isDestructive": false,
|
||||
"requiresBackup": false,
|
||||
"inspectCommand": "stella doctor run --check check.security.oidc.readiness --mode quick",
|
||||
"verificationCommand": "stella doctor run --check check.security.oidc.readiness",
|
||||
"keywords": [
|
||||
"invalid issuer",
|
||||
"jwks fetch failed",
|
||||
"oidc setup"
|
||||
]
|
||||
},
|
||||
{
|
||||
"checkCode": "check.telemetry.pipeline.delivery",
|
||||
"control": "safe",
|
||||
"requiresConfirmation": false,
|
||||
"isDestructive": false,
|
||||
"requiresBackup": false,
|
||||
"inspectCommand": "stella doctor run --check check.telemetry.pipeline.delivery --mode quick",
|
||||
"verificationCommand": "stella doctor run --check check.telemetry.pipeline.delivery",
|
||||
"keywords": [
|
||||
"delivery timeout",
|
||||
"queue backlog",
|
||||
"telemetry lag"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"schema": "stellaops.advisoryai.docs-allowlist.v1",
|
||||
"include": [
|
||||
"docs/README.md",
|
||||
"docs/INSTALL_GUIDE.md",
|
||||
"docs/modules/advisory-ai",
|
||||
"docs/modules/authority",
|
||||
"docs/modules/cli",
|
||||
"docs/modules/platform",
|
||||
"docs/modules/policy",
|
||||
"docs/modules/router",
|
||||
"docs/modules/scanner",
|
||||
"docs/operations",
|
||||
"docs/operations/devops/runbooks"
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,11 @@
|
||||
<InternalsVisibleTo Include="StellaOps.AdvisoryAI.Tests" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Storage/Migrations/*.sql" />
|
||||
<EmbeddedResource Include="Storage\Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<!-- Prevent automatic compiled-model binding so non-default schemas can build runtime models. -->
|
||||
<Compile Remove="Storage\EfCore\CompiledModels\AdvisoryAiDbContextAssemblyAttributes.cs" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="KnowledgeSearch/doctor-search-seed.json">
|
||||
@@ -20,10 +24,13 @@
|
||||
</None>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Concelier\__Libraries\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj" />
|
||||
@@ -36,5 +43,7 @@
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Canonicalization\StellaOps.Canonicalization.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Infrastructure.EfCore\StellaOps.Infrastructure.EfCore.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -2,24 +2,25 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.AdvisoryAI.Chat;
|
||||
using StellaOps.AdvisoryAI.Storage.EfCore.Models;
|
||||
using StellaOps.AdvisoryAI.Storage.Postgres;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL-backed conversation storage.
|
||||
/// PostgreSQL-backed conversation storage using EF Core.
|
||||
/// Sprint: SPRINT_20260107_006_003 Task CH-008
|
||||
/// Sprint: SPRINT_20260222_074 (EF Core conversion)
|
||||
/// </summary>
|
||||
public sealed class ConversationStore : IConversationStore, IAsyncDisposable
|
||||
public sealed class ConversationStore : RepositoryBase<AdvisoryAiDataSource>, IConversationStore, IAsyncDisposable
|
||||
{
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly ILogger<ConversationStore> _logger;
|
||||
private readonly ConversationStoreOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
@@ -32,13 +33,12 @@ public sealed class ConversationStore : IConversationStore, IAsyncDisposable
|
||||
/// Initializes a new instance of the <see cref="ConversationStore"/> class.
|
||||
/// </summary>
|
||||
public ConversationStore(
|
||||
NpgsqlDataSource dataSource,
|
||||
AdvisoryAiDataSource dataSource,
|
||||
ILogger<ConversationStore> logger,
|
||||
ConversationStoreOptions? options = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
_options = options ?? new ConversationStoreOptions();
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
@@ -48,28 +48,37 @@ public sealed class ConversationStore : IConversationStore, IAsyncDisposable
|
||||
Conversation conversation,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO advisoryai.conversations (
|
||||
conversation_id, tenant_id, user_id, created_at, updated_at,
|
||||
context, metadata
|
||||
) VALUES (
|
||||
@conversationId, @tenantId, @userId, @createdAt, @updatedAt,
|
||||
@context::jsonb, @metadata::jsonb
|
||||
)
|
||||
""";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(
|
||||
conversation.TenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AdvisoryAiDbContextFactory.Create(
|
||||
connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("conversationId", conversation.ConversationId);
|
||||
cmd.Parameters.AddWithValue("tenantId", conversation.TenantId);
|
||||
cmd.Parameters.AddWithValue("userId", conversation.UserId);
|
||||
cmd.Parameters.AddWithValue("createdAt", conversation.CreatedAt);
|
||||
cmd.Parameters.AddWithValue("updatedAt", conversation.UpdatedAt);
|
||||
cmd.Parameters.AddWithValue("context", JsonSerializer.Serialize(conversation.Context, JsonOptions));
|
||||
cmd.Parameters.AddWithValue("metadata", JsonSerializer.Serialize(conversation.Metadata, JsonOptions));
|
||||
var entity = new ConversationEntity
|
||||
{
|
||||
ConversationId = conversation.ConversationId,
|
||||
TenantId = conversation.TenantId,
|
||||
UserId = conversation.UserId,
|
||||
CreatedAt = conversation.CreatedAt.UtcDateTime,
|
||||
UpdatedAt = conversation.UpdatedAt.UtcDateTime,
|
||||
Context = JsonSerializer.Serialize(conversation.Context, JsonOptions),
|
||||
Metadata = JsonSerializer.Serialize(conversation.Metadata, JsonOptions)
|
||||
};
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
dbContext.Conversations.Add(entity);
|
||||
|
||||
_logger.LogInformation(
|
||||
try
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
|
||||
{
|
||||
// Idempotency: conversation already exists, treat as success.
|
||||
Logger.LogDebug(
|
||||
"Conversation {ConversationId} already exists (idempotent create)",
|
||||
conversation.ConversationId);
|
||||
}
|
||||
|
||||
Logger.LogInformation(
|
||||
"Created conversation {ConversationId} for user {UserId}",
|
||||
conversation.ConversationId, conversation.UserId);
|
||||
|
||||
@@ -81,25 +90,32 @@ public sealed class ConversationStore : IConversationStore, IAsyncDisposable
|
||||
string conversationId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM advisoryai.conversations
|
||||
WHERE conversation_id = @conversationId
|
||||
""";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(
|
||||
string.Empty, "reader", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AdvisoryAiDbContextFactory.Create(
|
||||
connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("conversationId", conversationId);
|
||||
var entity = await dbContext.Conversations
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.ConversationId == conversationId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
if (entity is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var conversation = await MapConversationAsync(reader, cancellationToken).ConfigureAwait(false);
|
||||
var conversation = MapConversation(entity);
|
||||
|
||||
// Load turns
|
||||
var turns = await GetTurnsAsync(conversationId, cancellationToken).ConfigureAwait(false);
|
||||
// Load turns ordered by timestamp ASC (preserves original ordering semantics)
|
||||
var turnEntities = await dbContext.Turns
|
||||
.AsNoTracking()
|
||||
.Where(t => t.ConversationId == conversationId)
|
||||
.OrderBy(t => t.Timestamp)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var turns = turnEntities.Select(MapTurn).ToImmutableArray();
|
||||
|
||||
return conversation with { Turns = turns };
|
||||
}
|
||||
@@ -111,26 +127,20 @@ public sealed class ConversationStore : IConversationStore, IAsyncDisposable
|
||||
int limit = 20,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = string.Create(CultureInfo.InvariantCulture, $"""
|
||||
SELECT * FROM advisoryai.conversations
|
||||
WHERE tenant_id = @tenantId AND user_id = @userId
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT {limit}
|
||||
""");
|
||||
await using var connection = await DataSource.OpenConnectionAsync(
|
||||
tenantId, "reader", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AdvisoryAiDbContextFactory.Create(
|
||||
connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
cmd.Parameters.AddWithValue("userId", userId);
|
||||
var entities = await dbContext.Conversations
|
||||
.AsNoTracking()
|
||||
.Where(c => c.TenantId == tenantId && c.UserId == userId)
|
||||
.OrderByDescending(c => c.UpdatedAt)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var conversations = new List<Conversation>();
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
conversations.Add(await MapConversationAsync(reader, cancellationToken).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
return conversations;
|
||||
return entities.Select(MapConversation).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -139,49 +149,39 @@ public sealed class ConversationStore : IConversationStore, IAsyncDisposable
|
||||
ConversationTurn turn,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string insertSql = """
|
||||
INSERT INTO advisoryai.turns (
|
||||
turn_id, conversation_id, role, content, timestamp,
|
||||
evidence_links, proposed_actions, metadata
|
||||
) VALUES (
|
||||
@turnId, @conversationId, @role, @content, @timestamp,
|
||||
@evidenceLinks::jsonb, @proposedActions::jsonb, @metadata::jsonb
|
||||
)
|
||||
""";
|
||||
|
||||
const string updateSql = """
|
||||
UPDATE advisoryai.conversations
|
||||
SET updated_at = @updatedAt
|
||||
WHERE conversation_id = @conversationId
|
||||
""";
|
||||
|
||||
await using var transaction = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await DataSource.OpenConnectionAsync(
|
||||
string.Empty, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AdvisoryAiDbContextFactory.Create(
|
||||
connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
// Insert turn
|
||||
await using (var insertCmd = _dataSource.CreateCommand(insertSql))
|
||||
var turnEntity = new TurnEntity
|
||||
{
|
||||
insertCmd.Parameters.AddWithValue("turnId", turn.TurnId);
|
||||
insertCmd.Parameters.AddWithValue("conversationId", conversationId);
|
||||
insertCmd.Parameters.AddWithValue("role", turn.Role.ToString());
|
||||
insertCmd.Parameters.AddWithValue("content", turn.Content);
|
||||
insertCmd.Parameters.AddWithValue("timestamp", turn.Timestamp);
|
||||
insertCmd.Parameters.AddWithValue("evidenceLinks", JsonSerializer.Serialize(turn.EvidenceLinks, JsonOptions));
|
||||
insertCmd.Parameters.AddWithValue("proposedActions", JsonSerializer.Serialize(turn.ProposedActions, JsonOptions));
|
||||
insertCmd.Parameters.AddWithValue("metadata", JsonSerializer.Serialize(turn.Metadata, JsonOptions));
|
||||
TurnId = turn.TurnId,
|
||||
ConversationId = conversationId,
|
||||
Role = turn.Role.ToString(),
|
||||
Content = turn.Content,
|
||||
Timestamp = turn.Timestamp.UtcDateTime,
|
||||
EvidenceLinks = JsonSerializer.Serialize(turn.EvidenceLinks, JsonOptions),
|
||||
ProposedActions = JsonSerializer.Serialize(turn.ProposedActions, JsonOptions),
|
||||
Metadata = JsonSerializer.Serialize(turn.Metadata, JsonOptions)
|
||||
};
|
||||
|
||||
await insertCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
dbContext.Turns.Add(turnEntity);
|
||||
|
||||
// Update conversation timestamp
|
||||
await using (var updateCmd = _dataSource.CreateCommand(updateSql))
|
||||
{
|
||||
updateCmd.Parameters.AddWithValue("conversationId", conversationId);
|
||||
updateCmd.Parameters.AddWithValue("updatedAt", turn.Timestamp);
|
||||
var conversation = await dbContext.Conversations
|
||||
.FirstOrDefaultAsync(c => c.ConversationId == conversationId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await updateCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (conversation is not null)
|
||||
{
|
||||
conversation.UpdatedAt = turn.Timestamp.UtcDateTime;
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
Logger.LogDebug(
|
||||
"Added turn {TurnId} to conversation {ConversationId}",
|
||||
turn.TurnId, conversationId);
|
||||
|
||||
@@ -193,19 +193,19 @@ public sealed class ConversationStore : IConversationStore, IAsyncDisposable
|
||||
string conversationId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
DELETE FROM advisoryai.conversations
|
||||
WHERE conversation_id = @conversationId
|
||||
""";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(
|
||||
string.Empty, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AdvisoryAiDbContextFactory.Create(
|
||||
connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("conversationId", conversationId);
|
||||
|
||||
var rowsAffected = await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
var rowsAffected = await dbContext.Conversations
|
||||
.Where(c => c.ConversationId == conversationId)
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (rowsAffected > 0)
|
||||
{
|
||||
_logger.LogInformation("Deleted conversation {ConversationId}", conversationId);
|
||||
Logger.LogInformation("Deleted conversation {ConversationId}", conversationId);
|
||||
}
|
||||
|
||||
return rowsAffected > 0;
|
||||
@@ -216,128 +216,129 @@ public sealed class ConversationStore : IConversationStore, IAsyncDisposable
|
||||
TimeSpan maxAge,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
DELETE FROM advisoryai.conversations
|
||||
WHERE updated_at < @cutoff
|
||||
""";
|
||||
|
||||
var cutoff = _timeProvider.GetUtcNow() - maxAge;
|
||||
var cutoffUtc = cutoff.UtcDateTime;
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("cutoff", cutoff);
|
||||
await using var connection = await DataSource.OpenConnectionAsync(
|
||||
string.Empty, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AdvisoryAiDbContextFactory.Create(
|
||||
connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var rowsDeleted = await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
var rowsDeleted = await dbContext.Conversations
|
||||
.Where(c => c.UpdatedAt < cutoffUtc)
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (rowsDeleted > 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
Logger.LogInformation(
|
||||
"Cleaned up {Count} expired conversations older than {MaxAge}",
|
||||
rowsDeleted, maxAge);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
// NpgsqlDataSource is typically managed by DI, so we don't dispose it here
|
||||
await Task.CompletedTask;
|
||||
// DataSource is managed by DI, so we don't dispose it here
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task<ImmutableArray<ConversationTurn>> GetTurnsAsync(
|
||||
string conversationId,
|
||||
CancellationToken cancellationToken)
|
||||
private string GetSchemaName()
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM advisoryai.turns
|
||||
WHERE conversation_id = @conversationId
|
||||
ORDER BY timestamp ASC
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("conversationId", conversationId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var turns = new List<ConversationTurn>();
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
if (!string.IsNullOrWhiteSpace(DataSource.SchemaName))
|
||||
{
|
||||
turns.Add(MapTurn(reader));
|
||||
return DataSource.SchemaName!;
|
||||
}
|
||||
|
||||
return turns.ToImmutableArray();
|
||||
return AdvisoryAiDataSource.DefaultSchemaName;
|
||||
}
|
||||
|
||||
private async Task<Conversation> MapConversationAsync(
|
||||
NpgsqlDataReader reader,
|
||||
CancellationToken cancellationToken)
|
||||
private static Conversation MapConversation(ConversationEntity entity)
|
||||
{
|
||||
_ = cancellationToken; // Suppress unused parameter warning
|
||||
|
||||
var contextJson = reader.IsDBNull(reader.GetOrdinal("context"))
|
||||
? null : reader.GetString(reader.GetOrdinal("context"));
|
||||
var metadataJson = reader.IsDBNull(reader.GetOrdinal("metadata"))
|
||||
? null : reader.GetString(reader.GetOrdinal("metadata"));
|
||||
|
||||
var context = contextJson != null
|
||||
? JsonSerializer.Deserialize<ConversationContext>(contextJson, JsonOptions) ?? new ConversationContext()
|
||||
var context = entity.Context != null
|
||||
? JsonSerializer.Deserialize<ConversationContext>(entity.Context, JsonOptions) ?? new ConversationContext()
|
||||
: new ConversationContext();
|
||||
|
||||
var metadata = metadataJson != null
|
||||
? JsonSerializer.Deserialize<ImmutableDictionary<string, string>>(metadataJson, JsonOptions)
|
||||
var metadata = entity.Metadata != null
|
||||
? JsonSerializer.Deserialize<ImmutableDictionary<string, string>>(entity.Metadata, JsonOptions)
|
||||
?? ImmutableDictionary<string, string>.Empty
|
||||
: ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
return new Conversation
|
||||
{
|
||||
ConversationId = reader.GetString(reader.GetOrdinal("conversation_id")),
|
||||
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
|
||||
UserId = reader.GetString(reader.GetOrdinal("user_id")),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
|
||||
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("updated_at")),
|
||||
ConversationId = entity.ConversationId,
|
||||
TenantId = entity.TenantId,
|
||||
UserId = entity.UserId,
|
||||
CreatedAt = ToUtcOffset(entity.CreatedAt),
|
||||
UpdatedAt = ToUtcOffset(entity.UpdatedAt),
|
||||
Context = context,
|
||||
Metadata = metadata,
|
||||
Turns = ImmutableArray<ConversationTurn>.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private static ConversationTurn MapTurn(NpgsqlDataReader reader)
|
||||
private static ConversationTurn MapTurn(TurnEntity entity)
|
||||
{
|
||||
var evidenceLinksJson = reader.IsDBNull(reader.GetOrdinal("evidence_links"))
|
||||
? null : reader.GetString(reader.GetOrdinal("evidence_links"));
|
||||
var proposedActionsJson = reader.IsDBNull(reader.GetOrdinal("proposed_actions"))
|
||||
? null : reader.GetString(reader.GetOrdinal("proposed_actions"));
|
||||
var metadataJson = reader.IsDBNull(reader.GetOrdinal("metadata"))
|
||||
? null : reader.GetString(reader.GetOrdinal("metadata"));
|
||||
|
||||
var evidenceLinks = evidenceLinksJson != null
|
||||
? JsonSerializer.Deserialize<ImmutableArray<EvidenceLink>>(evidenceLinksJson, JsonOptions)
|
||||
var evidenceLinks = entity.EvidenceLinks != null
|
||||
? JsonSerializer.Deserialize<ImmutableArray<EvidenceLink>>(entity.EvidenceLinks, JsonOptions)
|
||||
: ImmutableArray<EvidenceLink>.Empty;
|
||||
|
||||
var proposedActions = proposedActionsJson != null
|
||||
? JsonSerializer.Deserialize<ImmutableArray<ProposedAction>>(proposedActionsJson, JsonOptions)
|
||||
var proposedActions = entity.ProposedActions != null
|
||||
? JsonSerializer.Deserialize<ImmutableArray<ProposedAction>>(entity.ProposedActions, JsonOptions)
|
||||
: ImmutableArray<ProposedAction>.Empty;
|
||||
|
||||
var metadata = metadataJson != null
|
||||
? JsonSerializer.Deserialize<ImmutableDictionary<string, string>>(metadataJson, JsonOptions)
|
||||
var metadata = entity.Metadata != null
|
||||
? JsonSerializer.Deserialize<ImmutableDictionary<string, string>>(entity.Metadata, JsonOptions)
|
||||
?? ImmutableDictionary<string, string>.Empty
|
||||
: ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
var roleStr = reader.GetString(reader.GetOrdinal("role"));
|
||||
var role = Enum.TryParse<TurnRole>(roleStr, ignoreCase: true, out var parsedRole)
|
||||
var role = Enum.TryParse<TurnRole>(entity.Role, ignoreCase: true, out var parsedRole)
|
||||
? parsedRole
|
||||
: TurnRole.User;
|
||||
|
||||
return new ConversationTurn
|
||||
{
|
||||
TurnId = reader.GetString(reader.GetOrdinal("turn_id")),
|
||||
TurnId = entity.TurnId,
|
||||
Role = role,
|
||||
Content = reader.GetString(reader.GetOrdinal("content")),
|
||||
Timestamp = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("timestamp")),
|
||||
Content = entity.Content,
|
||||
Timestamp = ToUtcOffset(entity.Timestamp),
|
||||
EvidenceLinks = evidenceLinks,
|
||||
ProposedActions = proposedActions,
|
||||
Metadata = metadata
|
||||
};
|
||||
}
|
||||
|
||||
private static DateTimeOffset ToUtcOffset(DateTime value)
|
||||
{
|
||||
if (value.Kind == DateTimeKind.Utc)
|
||||
{
|
||||
return new DateTimeOffset(value, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
if (value.Kind == DateTimeKind.Local)
|
||||
{
|
||||
return new DateTimeOffset(value.ToUniversalTime(), TimeSpan.Zero);
|
||||
}
|
||||
|
||||
return new DateTimeOffset(DateTime.SpecifyKind(value, DateTimeKind.Utc), TimeSpan.Zero);
|
||||
}
|
||||
|
||||
private static bool IsUniqueViolation(DbUpdateException exception)
|
||||
{
|
||||
Exception? current = exception;
|
||||
while (current is not null)
|
||||
{
|
||||
if (current is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation })
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
current = current.InnerException;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// <auto-generated />
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.AdvisoryAI.Storage.EfCore.CompiledModels;
|
||||
using StellaOps.AdvisoryAI.Storage.EfCore.Context;
|
||||
|
||||
[assembly: DbContext(typeof(AdvisoryAiDbContext), optimizedModel: typeof(AdvisoryAiDbContextModel))]
|
||||
@@ -0,0 +1,48 @@
|
||||
// <auto-generated />
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using StellaOps.AdvisoryAI.Storage.EfCore.Context;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Storage.EfCore.CompiledModels
|
||||
{
|
||||
[DbContext(typeof(AdvisoryAiDbContext))]
|
||||
public partial class AdvisoryAiDbContextModel : RuntimeModel
|
||||
{
|
||||
private static readonly bool _useOldBehavior31751 =
|
||||
System.AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue31751", out var enabled31751) && enabled31751;
|
||||
|
||||
static AdvisoryAiDbContextModel()
|
||||
{
|
||||
var model = new AdvisoryAiDbContextModel();
|
||||
|
||||
if (_useOldBehavior31751)
|
||||
{
|
||||
model.Initialize();
|
||||
}
|
||||
else
|
||||
{
|
||||
var thread = new System.Threading.Thread(RunInitialization, 10 * 1024 * 1024);
|
||||
thread.Start();
|
||||
thread.Join();
|
||||
|
||||
void RunInitialization()
|
||||
{
|
||||
model.Initialize();
|
||||
}
|
||||
}
|
||||
|
||||
model.Customize();
|
||||
_instance = (AdvisoryAiDbContextModel)model.FinalizeModel();
|
||||
}
|
||||
|
||||
private static AdvisoryAiDbContextModel _instance;
|
||||
public static IModel Instance => _instance;
|
||||
|
||||
partial void Initialize();
|
||||
|
||||
partial void Customize();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Storage.EfCore.CompiledModels
|
||||
{
|
||||
public partial class AdvisoryAiDbContextModel
|
||||
{
|
||||
private AdvisoryAiDbContextModel()
|
||||
: base(skipDetectChanges: false, modelId: new Guid("a2b3c4d5-e6f7-4890-ab12-cd34ef567890"), entityTypeCount: 2)
|
||||
{
|
||||
}
|
||||
|
||||
partial void Initialize()
|
||||
{
|
||||
var conversationEntity = ConversationEntityEntityType.Create(this);
|
||||
var turnEntity = TurnEntityEntityType.Create(this);
|
||||
|
||||
ConversationEntityEntityType.CreateAnnotations(conversationEntity);
|
||||
TurnEntityEntityType.CreateAnnotations(turnEntity);
|
||||
|
||||
TurnEntityEntityType.CreateForeignKeys(turnEntity, conversationEntity);
|
||||
|
||||
AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
|
||||
AddAnnotation("ProductVersion", "10.0.0");
|
||||
AddAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using StellaOps.AdvisoryAI.Storage.EfCore.Models;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Storage.EfCore.CompiledModels
|
||||
{
|
||||
[EntityFrameworkInternal]
|
||||
public partial class ConversationEntityEntityType
|
||||
{
|
||||
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
|
||||
{
|
||||
var runtimeEntityType = model.AddEntityType(
|
||||
"StellaOps.AdvisoryAI.Storage.EfCore.Models.ConversationEntity",
|
||||
typeof(ConversationEntity),
|
||||
baseEntityType,
|
||||
propertyCount: 7,
|
||||
namedIndexCount: 2,
|
||||
keyCount: 1);
|
||||
|
||||
var conversationId = runtimeEntityType.AddProperty(
|
||||
"ConversationId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(ConversationEntity).GetProperty("ConversationId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ConversationEntity).GetField("<ConversationId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
afterSaveBehavior: PropertySaveBehavior.Throw);
|
||||
conversationId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
conversationId.AddAnnotation("Relational:ColumnName", "conversation_id");
|
||||
|
||||
var tenantId = runtimeEntityType.AddProperty(
|
||||
"TenantId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(ConversationEntity).GetProperty("TenantId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ConversationEntity).GetField("<TenantId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
tenantId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
tenantId.AddAnnotation("Relational:ColumnName", "tenant_id");
|
||||
|
||||
var userId = runtimeEntityType.AddProperty(
|
||||
"UserId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(ConversationEntity).GetProperty("UserId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ConversationEntity).GetField("<UserId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
userId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
userId.AddAnnotation("Relational:ColumnName", "user_id");
|
||||
|
||||
var createdAt = runtimeEntityType.AddProperty(
|
||||
"CreatedAt",
|
||||
typeof(DateTime),
|
||||
propertyInfo: typeof(ConversationEntity).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ConversationEntity).GetField("<CreatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
|
||||
createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
createdAt.AddAnnotation("Relational:ColumnName", "created_at");
|
||||
|
||||
var updatedAt = runtimeEntityType.AddProperty(
|
||||
"UpdatedAt",
|
||||
typeof(DateTime),
|
||||
propertyInfo: typeof(ConversationEntity).GetProperty("UpdatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ConversationEntity).GetField("<UpdatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
|
||||
updatedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
updatedAt.AddAnnotation("Relational:ColumnName", "updated_at");
|
||||
|
||||
var context = runtimeEntityType.AddProperty(
|
||||
"Context",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(ConversationEntity).GetProperty("Context", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ConversationEntity).GetField("<Context>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
context.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
context.AddAnnotation("Relational:ColumnName", "context");
|
||||
context.AddAnnotation("Relational:ColumnType", "jsonb");
|
||||
|
||||
var metadata = runtimeEntityType.AddProperty(
|
||||
"Metadata",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(ConversationEntity).GetProperty("Metadata", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ConversationEntity).GetField("<Metadata>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
metadata.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
metadata.AddAnnotation("Relational:ColumnName", "metadata");
|
||||
metadata.AddAnnotation("Relational:ColumnType", "jsonb");
|
||||
|
||||
var key = runtimeEntityType.AddKey(
|
||||
new[] { conversationId });
|
||||
runtimeEntityType.SetPrimaryKey(key);
|
||||
key.AddAnnotation("Relational:Name", "conversations_pkey");
|
||||
|
||||
var idx_advisoryai_conversations_tenant_updated = runtimeEntityType.AddIndex(
|
||||
new[] { tenantId, updatedAt },
|
||||
name: "idx_advisoryai_conversations_tenant_updated");
|
||||
idx_advisoryai_conversations_tenant_updated.AddAnnotation("Relational:IsDescending", new[] { false, true });
|
||||
|
||||
var idx_advisoryai_conversations_tenant_user = runtimeEntityType.AddIndex(
|
||||
new[] { tenantId, userId },
|
||||
name: "idx_advisoryai_conversations_tenant_user");
|
||||
|
||||
return runtimeEntityType;
|
||||
}
|
||||
|
||||
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
|
||||
{
|
||||
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:Schema", "advisoryai");
|
||||
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:TableName", "conversations");
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
|
||||
|
||||
Customize(runtimeEntityType);
|
||||
}
|
||||
|
||||
static partial void Customize(RuntimeEntityType runtimeEntityType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using StellaOps.AdvisoryAI.Storage.EfCore.Models;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Storage.EfCore.CompiledModels
|
||||
{
|
||||
[EntityFrameworkInternal]
|
||||
public partial class TurnEntityEntityType
|
||||
{
|
||||
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
|
||||
{
|
||||
var runtimeEntityType = model.AddEntityType(
|
||||
"StellaOps.AdvisoryAI.Storage.EfCore.Models.TurnEntity",
|
||||
typeof(TurnEntity),
|
||||
baseEntityType,
|
||||
propertyCount: 8,
|
||||
namedIndexCount: 2,
|
||||
foreignKeyCount: 1,
|
||||
keyCount: 1);
|
||||
|
||||
var turnId = runtimeEntityType.AddProperty(
|
||||
"TurnId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(TurnEntity).GetProperty("TurnId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TurnEntity).GetField("<TurnId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
afterSaveBehavior: PropertySaveBehavior.Throw);
|
||||
turnId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
turnId.AddAnnotation("Relational:ColumnName", "turn_id");
|
||||
|
||||
var conversationId = runtimeEntityType.AddProperty(
|
||||
"ConversationId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(TurnEntity).GetProperty("ConversationId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TurnEntity).GetField("<ConversationId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
conversationId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
conversationId.AddAnnotation("Relational:ColumnName", "conversation_id");
|
||||
|
||||
var role = runtimeEntityType.AddProperty(
|
||||
"Role",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(TurnEntity).GetProperty("Role", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TurnEntity).GetField("<Role>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
role.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
role.AddAnnotation("Relational:ColumnName", "role");
|
||||
|
||||
var content = runtimeEntityType.AddProperty(
|
||||
"Content",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(TurnEntity).GetProperty("Content", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TurnEntity).GetField("<Content>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
content.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
content.AddAnnotation("Relational:ColumnName", "content");
|
||||
|
||||
var timestamp = runtimeEntityType.AddProperty(
|
||||
"Timestamp",
|
||||
typeof(DateTime),
|
||||
propertyInfo: typeof(TurnEntity).GetProperty("Timestamp", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TurnEntity).GetField("<Timestamp>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
|
||||
timestamp.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
timestamp.AddAnnotation("Relational:ColumnName", "timestamp");
|
||||
|
||||
var evidenceLinks = runtimeEntityType.AddProperty(
|
||||
"EvidenceLinks",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(TurnEntity).GetProperty("EvidenceLinks", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TurnEntity).GetField("<EvidenceLinks>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
evidenceLinks.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
evidenceLinks.AddAnnotation("Relational:ColumnName", "evidence_links");
|
||||
evidenceLinks.AddAnnotation("Relational:ColumnType", "jsonb");
|
||||
|
||||
var proposedActions = runtimeEntityType.AddProperty(
|
||||
"ProposedActions",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(TurnEntity).GetProperty("ProposedActions", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TurnEntity).GetField("<ProposedActions>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
proposedActions.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
proposedActions.AddAnnotation("Relational:ColumnName", "proposed_actions");
|
||||
proposedActions.AddAnnotation("Relational:ColumnType", "jsonb");
|
||||
|
||||
var metadata = runtimeEntityType.AddProperty(
|
||||
"Metadata",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(TurnEntity).GetProperty("Metadata", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TurnEntity).GetField("<Metadata>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
metadata.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
metadata.AddAnnotation("Relational:ColumnName", "metadata");
|
||||
metadata.AddAnnotation("Relational:ColumnType", "jsonb");
|
||||
|
||||
var key = runtimeEntityType.AddKey(
|
||||
new[] { turnId });
|
||||
runtimeEntityType.SetPrimaryKey(key);
|
||||
key.AddAnnotation("Relational:Name", "turns_pkey");
|
||||
|
||||
var idx_advisoryai_turns_conversation = runtimeEntityType.AddIndex(
|
||||
new[] { conversationId },
|
||||
name: "idx_advisoryai_turns_conversation");
|
||||
|
||||
var idx_advisoryai_turns_conversation_timestamp = runtimeEntityType.AddIndex(
|
||||
new[] { conversationId, timestamp },
|
||||
name: "idx_advisoryai_turns_conversation_timestamp");
|
||||
|
||||
return runtimeEntityType;
|
||||
}
|
||||
|
||||
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
|
||||
{
|
||||
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:Schema", "advisoryai");
|
||||
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:TableName", "turns");
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
|
||||
|
||||
Customize(runtimeEntityType);
|
||||
}
|
||||
|
||||
public static void CreateForeignKeys(RuntimeEntityType runtimeEntityType, RuntimeEntityType conversationEntityEntityType)
|
||||
{
|
||||
var conversationId = runtimeEntityType.FindProperty("ConversationId");
|
||||
var fk = runtimeEntityType.AddForeignKey(
|
||||
new[] { conversationId },
|
||||
conversationEntityEntityType.FindKey(new[] { conversationEntityEntityType.FindProperty("ConversationId") }),
|
||||
conversationEntityEntityType,
|
||||
deleteBehavior: Microsoft.EntityFrameworkCore.DeleteBehavior.Cascade,
|
||||
required: true);
|
||||
fk.AddAnnotation("Relational:Name", "fk_turns_conversation_id");
|
||||
|
||||
var navigation = runtimeEntityType.AddNavigation(
|
||||
"Conversation",
|
||||
fk,
|
||||
onDependent: true,
|
||||
typeof(ConversationEntity),
|
||||
propertyInfo: typeof(TurnEntity).GetProperty("Conversation", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TurnEntity).GetField("<Conversation>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
|
||||
var inverseNavigation = conversationEntityEntityType.AddNavigation(
|
||||
"Turns",
|
||||
fk,
|
||||
onDependent: false,
|
||||
typeof(System.Collections.Generic.ICollection<TurnEntity>),
|
||||
propertyInfo: typeof(ConversationEntity).GetProperty("Turns", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ConversationEntity).GetField("<Turns>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
}
|
||||
|
||||
static partial void Customize(RuntimeEntityType runtimeEntityType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// <copyright file="AdvisoryAiDbContext.Partial.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.AdvisoryAI.Storage.EfCore.Models;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Storage.EfCore.Context;
|
||||
|
||||
public partial class AdvisoryAiDbContext
|
||||
{
|
||||
partial void OnModelCreatingPartial(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<TurnEntity>(entity =>
|
||||
{
|
||||
entity.HasOne(e => e.Conversation)
|
||||
.WithMany(c => c.Turns)
|
||||
.HasForeignKey(e => e.ConversationId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// <copyright file="AdvisoryAiDbContext.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.AdvisoryAI.Storage.EfCore.Models;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Storage.EfCore.Context;
|
||||
|
||||
public partial class AdvisoryAiDbContext : DbContext
|
||||
{
|
||||
private readonly string _schemaName;
|
||||
|
||||
public AdvisoryAiDbContext(DbContextOptions<AdvisoryAiDbContext> options, string? schemaName = null)
|
||||
: base(options)
|
||||
{
|
||||
_schemaName = string.IsNullOrWhiteSpace(schemaName)
|
||||
? "advisoryai"
|
||||
: schemaName.Trim();
|
||||
}
|
||||
|
||||
public virtual DbSet<ConversationEntity> Conversations { get; set; }
|
||||
|
||||
public virtual DbSet<TurnEntity> Turns { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
var schemaName = _schemaName;
|
||||
|
||||
modelBuilder.Entity<ConversationEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.ConversationId).HasName("conversations_pkey");
|
||||
|
||||
entity.ToTable("conversations", schemaName);
|
||||
|
||||
entity.HasIndex(e => new { e.TenantId, e.UpdatedAt }, "idx_advisoryai_conversations_tenant_updated")
|
||||
.IsDescending(false, true);
|
||||
|
||||
entity.HasIndex(e => new { e.TenantId, e.UserId }, "idx_advisoryai_conversations_tenant_user");
|
||||
|
||||
entity.Property(e => e.ConversationId).HasColumnName("conversation_id");
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.UserId).HasColumnName("user_id");
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at");
|
||||
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at");
|
||||
entity.Property(e => e.Context)
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("context");
|
||||
entity.Property(e => e.Metadata)
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("metadata");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<TurnEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.TurnId).HasName("turns_pkey");
|
||||
|
||||
entity.ToTable("turns", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.ConversationId, "idx_advisoryai_turns_conversation");
|
||||
|
||||
entity.HasIndex(e => new { e.ConversationId, e.Timestamp }, "idx_advisoryai_turns_conversation_timestamp");
|
||||
|
||||
entity.Property(e => e.TurnId).HasColumnName("turn_id");
|
||||
entity.Property(e => e.ConversationId).HasColumnName("conversation_id");
|
||||
entity.Property(e => e.Role).HasColumnName("role");
|
||||
entity.Property(e => e.Content).HasColumnName("content");
|
||||
entity.Property(e => e.Timestamp).HasColumnName("timestamp");
|
||||
entity.Property(e => e.EvidenceLinks)
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("evidence_links");
|
||||
entity.Property(e => e.ProposedActions)
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("proposed_actions");
|
||||
entity.Property(e => e.Metadata)
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("metadata");
|
||||
});
|
||||
|
||||
OnModelCreatingPartial(modelBuilder);
|
||||
}
|
||||
|
||||
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// <copyright file="AdvisoryAiDesignTimeDbContextFactory.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Storage.EfCore.Context;
|
||||
|
||||
public sealed class AdvisoryAiDesignTimeDbContextFactory : IDesignTimeDbContextFactory<AdvisoryAiDbContext>
|
||||
{
|
||||
private const string DefaultConnectionString =
|
||||
"Host=localhost;Port=55433;Database=postgres;Username=postgres;Password=postgres;Search Path=advisoryai,public";
|
||||
private const string ConnectionStringEnvironmentVariable =
|
||||
"STELLAOPS_ADVISORYAI_EF_CONNECTION";
|
||||
|
||||
public AdvisoryAiDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var connectionString = ResolveConnectionString();
|
||||
var options = new DbContextOptionsBuilder<AdvisoryAiDbContext>()
|
||||
.UseNpgsql(connectionString)
|
||||
.Options;
|
||||
|
||||
return new AdvisoryAiDbContext(options);
|
||||
}
|
||||
|
||||
private static string ResolveConnectionString()
|
||||
{
|
||||
var fromEnvironment = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable);
|
||||
return string.IsNullOrWhiteSpace(fromEnvironment) ? DefaultConnectionString : fromEnvironment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// <copyright file="ConversationEntity.Partials.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Storage.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Navigation properties for ConversationEntity.
|
||||
/// </summary>
|
||||
public partial class ConversationEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Turns belonging to this conversation.
|
||||
/// </summary>
|
||||
public virtual ICollection<TurnEntity> Turns { get; set; } = new List<TurnEntity>();
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// <copyright file="ConversationEntity.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Storage.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for advisoryai.conversations table.
|
||||
/// </summary>
|
||||
public partial class ConversationEntity
|
||||
{
|
||||
public string ConversationId { get; set; } = null!;
|
||||
|
||||
public string TenantId { get; set; } = null!;
|
||||
|
||||
public string UserId { get; set; } = null!;
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
|
||||
public string? Context { get; set; }
|
||||
|
||||
public string? Metadata { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// <copyright file="TurnEntity.Partials.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Storage.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Navigation properties for TurnEntity.
|
||||
/// </summary>
|
||||
public partial class TurnEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Parent conversation.
|
||||
/// </summary>
|
||||
public virtual ConversationEntity? Conversation { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// <copyright file="TurnEntity.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Storage.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for advisoryai.turns table.
|
||||
/// </summary>
|
||||
public partial class TurnEntity
|
||||
{
|
||||
public string TurnId { get; set; } = null!;
|
||||
|
||||
public string ConversationId { get; set; } = null!;
|
||||
|
||||
public string Role { get; set; } = null!;
|
||||
|
||||
public string Content { get; set; } = null!;
|
||||
|
||||
public DateTime Timestamp { get; set; }
|
||||
|
||||
public string? EvidenceLinks { get; set; }
|
||||
|
||||
public string? ProposedActions { get; set; }
|
||||
|
||||
public string? Metadata { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// <copyright file="AdvisoryAiDataSource.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Connections;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Storage.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL data source for AdvisoryAI module.
|
||||
/// </summary>
|
||||
public sealed class AdvisoryAiDataSource : DataSourceBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Default schema name for AdvisoryAI tables.
|
||||
/// </summary>
|
||||
public const string DefaultSchemaName = "advisoryai";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new AdvisoryAI data source.
|
||||
/// </summary>
|
||||
public AdvisoryAiDataSource(IOptions<PostgresOptions> options, ILogger<AdvisoryAiDataSource> logger)
|
||||
: base(CreateOptions(options.Value), logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string ModuleName => "AdvisoryAI";
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void ConfigureDataSourceBuilder(NpgsqlDataSourceBuilder builder)
|
||||
{
|
||||
base.ConfigureDataSourceBuilder(builder);
|
||||
}
|
||||
|
||||
private static PostgresOptions CreateOptions(PostgresOptions baseOptions)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(baseOptions.SchemaName))
|
||||
{
|
||||
baseOptions.SchemaName = DefaultSchemaName;
|
||||
}
|
||||
return baseOptions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// <copyright file="AdvisoryAiDbContextFactory.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using StellaOps.AdvisoryAI.Storage.EfCore.CompiledModels;
|
||||
using StellaOps.AdvisoryAI.Storage.EfCore.Context;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Storage.Postgres;
|
||||
|
||||
internal static class AdvisoryAiDbContextFactory
|
||||
{
|
||||
public static AdvisoryAiDbContext Create(NpgsqlConnection connection, int commandTimeoutSeconds, string schemaName)
|
||||
{
|
||||
var normalizedSchema = string.IsNullOrWhiteSpace(schemaName)
|
||||
? AdvisoryAiDataSource.DefaultSchemaName
|
||||
: schemaName.Trim();
|
||||
|
||||
var optionsBuilder = new DbContextOptionsBuilder<AdvisoryAiDbContext>()
|
||||
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
|
||||
|
||||
if (string.Equals(normalizedSchema, AdvisoryAiDataSource.DefaultSchemaName, StringComparison.Ordinal))
|
||||
{
|
||||
// Use the static compiled model when schema mapping matches the default model.
|
||||
optionsBuilder.UseModel(AdvisoryAiDbContextModel.Instance);
|
||||
}
|
||||
|
||||
return new AdvisoryAiDbContext(optionsBuilder.Options, normalizedSchema);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ Source of truth: `docs/implplan/SPRINT_20260113_005_ADVISORYAI_controlled_conver
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| SPRINT_20260222_051-AKS-INGEST | DONE | Added deterministic AKS ingestion controls: markdown allow-list manifest loading, OpenAPI aggregate source path support, and doctor control projection integration for search chunks, including fallback doctor metadata hydration from controls projection fields. |
|
||||
| AUDIT-0017-M | DONE | Maintainability audit for StellaOps.AdvisoryAI. |
|
||||
| AUDIT-0017-T | DONE | Test coverage audit for StellaOps.AdvisoryAI. |
|
||||
| AUDIT-0017-A | DONE | Pending approval for changes. |
|
||||
@@ -18,4 +19,5 @@ Source of truth: `docs/implplan/SPRINT_20260113_005_ADVISORYAI_controlled_conver
|
||||
| QA-AIAI-VERIFY-003 | DONE | FLOW verification complete for `ai-action-policy-gate` with Tier 0/1/2 artifacts under `docs/qa/feature-checks/runs/advisoryai/ai-action-policy-gate/run-001/`. |
|
||||
| QA-AIAI-VERIFY-004 | DONE | FLOW verification complete for `ai-codex-zastava-companion` with Tier 0/1/2 artifacts under `docs/qa/feature-checks/runs/advisoryai/ai-codex-zastava-companion/run-002/`. |
|
||||
| QA-AIAI-VERIFY-005 | DONE | FLOW verification complete for `deterministic-ai-artifact-replay` with Tier 0/1/2 artifacts under `docs/qa/feature-checks/runs/advisoryai/deterministic-ai-artifact-replay/run-001/`. |
|
||||
| SPRINT_20260222_074-ADVAI-EF | DONE | AdvisoryAI Storage DAL converted from Npgsql repositories to EF Core v10. Created AdvisoryAiDataSource, AdvisoryAiDbContext, ConversationEntity/TurnEntity models, compiled model artifacts, runtime DbContextFactory with UseModel for default schema. ConversationStore rewritten to use EF Core (per-operation DbContext, AsNoTracking reads, ExecuteDeleteAsync bulk deletes, UniqueViolation idempotency). AdvisoryAiMigrationModulePlugin registered in Platform migration registry. Sequential builds pass (0 errors, 0 warnings). 560/584 tests pass; 24 failures are pre-existing auth-related (403 Forbidden), not storage-related. |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user