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:
master
2026-02-23 15:30:50 +02:00
parent bd8fee6ed8
commit e746577380
1424 changed files with 81225 additions and 25251 deletions

View File

@@ -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();
}
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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"];

View File

@@ -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);

View File

@@ -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;
}
}
}
}

View File

@@ -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"
]
}
]

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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))]

View File

@@ -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();
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
});
}
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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>();
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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. |