save checkpoint: save features
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Doctor.AdvisoryAI.Models;
|
||||
using StellaOps.Doctor.Models;
|
||||
|
||||
namespace StellaOps.Doctor.AdvisoryAI;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic implementation of diagnosis service for offline-safe Doctor analysis.
|
||||
/// </summary>
|
||||
public sealed class DeterministicDoctorAIDiagnosisService : IDoctorAIDiagnosisService
|
||||
{
|
||||
public Task<DoctorAIDiagnosisResponse> AnalyzeAsync(
|
||||
DoctorAIContext context,
|
||||
DoctorAIDiagnosisOptions? options = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var effectiveOptions = options ?? new DoctorAIDiagnosisOptions();
|
||||
var focused = FilterResults(context.Results, effectiveOptions.FocusOnChecks);
|
||||
|
||||
var issues = BuildIssues(focused);
|
||||
var rootCauses = effectiveOptions.IncludeRootCauseAnalysis ? BuildRootCauses(focused) : [];
|
||||
var correlations = effectiveOptions.IncludeCorrelationAnalysis ? BuildCorrelations(focused) : [];
|
||||
var actions = effectiveOptions.IncludeRemediationSuggestions ? BuildActions(focused) : [];
|
||||
var documentation = focused
|
||||
.Select(static result => result.Remediation?.RunbookUrl)
|
||||
.Where(static url => !string.IsNullOrWhiteSpace(url))
|
||||
.Select(static url => url!)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static url => url, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var response = new DoctorAIDiagnosisResponse
|
||||
{
|
||||
Assessment = BuildAssessment(focused),
|
||||
HealthScore = CalculateHealthScore(focused),
|
||||
Issues = issues,
|
||||
RootCauses = rootCauses,
|
||||
Correlations = correlations,
|
||||
RecommendedActions = actions,
|
||||
RelatedDocumentation = documentation
|
||||
};
|
||||
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
|
||||
public Task<AICheckRecommendations> GetRecommendationsAsync(
|
||||
AICheckResult checkResult,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(checkResult);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var recommendations = new AICheckRecommendations
|
||||
{
|
||||
CheckId = checkResult.CheckId,
|
||||
Explanation = checkResult.Diagnosis,
|
||||
MostLikelyCause = checkResult.LikelyCauses.FirstOrDefault()?.Cause,
|
||||
Steps = checkResult.Remediation?.Steps
|
||||
.OrderBy(static step => step.Order)
|
||||
.Select(FormatRemediationStep)
|
||||
.ToImmutableArray() ?? [],
|
||||
VerificationCommand = checkResult.Remediation?.Steps
|
||||
.OrderBy(static step => step.Order)
|
||||
.Select(static step => step.VerificationCommand)
|
||||
.FirstOrDefault(static command => !string.IsNullOrWhiteSpace(command)),
|
||||
RelatedChecks = []
|
||||
};
|
||||
|
||||
return Task.FromResult(recommendations);
|
||||
}
|
||||
|
||||
private static ImmutableArray<AICheckResult> FilterResults(
|
||||
ImmutableArray<AICheckResult> results,
|
||||
IReadOnlyList<string>? focusOnChecks)
|
||||
{
|
||||
IEnumerable<AICheckResult> query = results;
|
||||
if (focusOnChecks is { Count: > 0 })
|
||||
{
|
||||
var focusSet = focusOnChecks
|
||||
.Where(static checkId => !string.IsNullOrWhiteSpace(checkId))
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
if (focusSet.Count > 0)
|
||||
{
|
||||
query = query.Where(result => focusSet.Contains(result.CheckId));
|
||||
}
|
||||
}
|
||||
|
||||
return query
|
||||
.OrderBy(static result => SeverityRank(result.Severity))
|
||||
.ThenBy(static result => result.CheckId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<AIIdentifiedIssue> BuildIssues(ImmutableArray<AICheckResult> results)
|
||||
{
|
||||
return results
|
||||
.Where(static result => result.Severity is DoctorSeverity.Fail or DoctorSeverity.Warn)
|
||||
.Select(result => new AIIdentifiedIssue
|
||||
{
|
||||
Summary = $"{result.CheckId}: {result.Diagnosis}",
|
||||
AffectedChecks = [result.CheckId],
|
||||
Severity = result.Severity.ToString().ToLowerInvariant(),
|
||||
Impact = $"Category '{result.Category}' health is degraded.",
|
||||
Urgency = result.Severity == DoctorSeverity.Fail ? "high" : "medium"
|
||||
})
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<AIRootCause> BuildRootCauses(ImmutableArray<AICheckResult> results)
|
||||
{
|
||||
var failing = results.Where(static result => result.Severity is DoctorSeverity.Fail or DoctorSeverity.Warn).ToImmutableArray();
|
||||
if (failing.Length == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var causes = failing
|
||||
.SelectMany(result =>
|
||||
result.LikelyCauses.Select(cause => new
|
||||
{
|
||||
cause.Cause,
|
||||
result.CheckId,
|
||||
Evidence = result.Evidence.Fields.Keys.OrderBy(static key => key, StringComparer.Ordinal).Take(3).ToImmutableArray()
|
||||
}))
|
||||
.GroupBy(static entry => entry.Cause, StringComparer.Ordinal)
|
||||
.OrderByDescending(static group => group.Count())
|
||||
.ThenBy(static group => group.Key, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
if (causes.Length == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return causes
|
||||
.Select(group => new AIRootCause
|
||||
{
|
||||
Cause = group.Key,
|
||||
Confidence = (float)group.Count() / failing.Length,
|
||||
SupportingEvidence = group
|
||||
.SelectMany(static entry => entry.Evidence)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static evidence => evidence, StringComparer.Ordinal)
|
||||
.ToImmutableArray(),
|
||||
AffectedChecks = group
|
||||
.Select(static entry => entry.CheckId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static check => check, StringComparer.Ordinal)
|
||||
.ToImmutableArray()
|
||||
})
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<AICorrelation> BuildCorrelations(ImmutableArray<AICheckResult> results)
|
||||
{
|
||||
return results
|
||||
.Where(static result => result.Severity is DoctorSeverity.Fail or DoctorSeverity.Warn)
|
||||
.GroupBy(static result => result.Category, StringComparer.Ordinal)
|
||||
.SelectMany(group =>
|
||||
{
|
||||
var checks = group
|
||||
.Select(static result => result.CheckId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static check => check, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
if (checks.Length < 2)
|
||||
{
|
||||
return Enumerable.Empty<AICorrelation>();
|
||||
}
|
||||
|
||||
return new[]
|
||||
{
|
||||
new AICorrelation
|
||||
{
|
||||
Issue1 = checks[0],
|
||||
Issue2 = checks[1],
|
||||
Description = $"Both issues are in category '{group.Key}' and likely share the same subsystem dependency.",
|
||||
Strength = 0.7f
|
||||
}
|
||||
};
|
||||
})
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> BuildActions(ImmutableArray<AICheckResult> results)
|
||||
{
|
||||
var remediationSteps = results
|
||||
.SelectMany(static result => result.Remediation is null
|
||||
? Enumerable.Empty<AIRemediationStep>()
|
||||
: result.Remediation.Steps)
|
||||
.OrderBy(static step => step.Order)
|
||||
.ThenBy(static step => step.Description, StringComparer.Ordinal)
|
||||
.Select(FormatRemediationStep);
|
||||
|
||||
var verificationSteps = results
|
||||
.SelectMany(static result => result.Remediation is null
|
||||
? Enumerable.Empty<AIRemediationStep>()
|
||||
: result.Remediation.Steps)
|
||||
.OrderBy(static step => step.Order)
|
||||
.Select(static step => step.VerificationCommand)
|
||||
.Where(static command => !string.IsNullOrWhiteSpace(command))
|
||||
.Select(static command => $"Verify: {command}");
|
||||
|
||||
var actions = remediationSteps
|
||||
.Concat(verificationSteps)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
if (actions.Length > 0)
|
||||
{
|
||||
return actions;
|
||||
}
|
||||
|
||||
return results.Any(static result => result.Severity is DoctorSeverity.Fail or DoctorSeverity.Warn)
|
||||
? ["Re-run failed checks after validating environment configuration and dependencies."]
|
||||
: ["No remediation actions required; system health is stable."];
|
||||
}
|
||||
|
||||
private static string BuildAssessment(ImmutableArray<AICheckResult> results)
|
||||
{
|
||||
var failCount = results.Count(static result => result.Severity == DoctorSeverity.Fail);
|
||||
var warnCount = results.Count(static result => result.Severity == DoctorSeverity.Warn);
|
||||
var total = results.Length;
|
||||
|
||||
if (total == 0)
|
||||
{
|
||||
return "No Doctor check results were provided for diagnosis.";
|
||||
}
|
||||
|
||||
if (failCount > 0)
|
||||
{
|
||||
return $"Critical health issues detected: {failCount} failing checks and {warnCount} warnings.";
|
||||
}
|
||||
|
||||
if (warnCount > 0)
|
||||
{
|
||||
return $"No critical failures detected; {warnCount} warning checks require follow-up.";
|
||||
}
|
||||
|
||||
return "Doctor checks indicate a healthy system state.";
|
||||
}
|
||||
|
||||
private static int CalculateHealthScore(ImmutableArray<AICheckResult> results)
|
||||
{
|
||||
if (results.Length == 0)
|
||||
{
|
||||
return 100;
|
||||
}
|
||||
|
||||
var penalty = results.Sum(static result => result.Severity switch
|
||||
{
|
||||
DoctorSeverity.Fail => 100,
|
||||
DoctorSeverity.Warn => 40,
|
||||
DoctorSeverity.Info => 10,
|
||||
DoctorSeverity.Skip => 5,
|
||||
_ => 0
|
||||
});
|
||||
|
||||
var maxPenalty = results.Length * 100;
|
||||
var score = 100 - ((penalty * 100) / maxPenalty);
|
||||
return Math.Clamp(score, 0, 100);
|
||||
}
|
||||
|
||||
private static string FormatRemediationStep(AIRemediationStep step)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(step.Command)
|
||||
? step.Description
|
||||
: $"{step.Description}: {step.Command}";
|
||||
}
|
||||
|
||||
private static int SeverityRank(DoctorSeverity severity) => severity switch
|
||||
{
|
||||
DoctorSeverity.Fail => 0,
|
||||
DoctorSeverity.Warn => 1,
|
||||
DoctorSeverity.Info => 2,
|
||||
DoctorSeverity.Skip => 3,
|
||||
DoctorSeverity.Pass => 4,
|
||||
_ => 5
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Doctor.AdvisoryAI.Models;
|
||||
using StellaOps.Doctor.Models;
|
||||
|
||||
namespace StellaOps.Doctor.AdvisoryAI;
|
||||
|
||||
/// <summary>
|
||||
/// Default context adapter that projects Doctor reports into AI context models.
|
||||
/// </summary>
|
||||
public sealed class DoctorContextAdapter : IDoctorContextAdapter
|
||||
{
|
||||
private readonly IEvidenceSchemaRegistry _schemaRegistry;
|
||||
private readonly ILogger<DoctorContextAdapter> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public DoctorContextAdapter(
|
||||
IEvidenceSchemaRegistry schemaRegistry,
|
||||
ILogger<DoctorContextAdapter> logger,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_schemaRegistry = schemaRegistry;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public Task<DoctorAIContext> CreateContextAsync(DoctorReport report, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(report);
|
||||
|
||||
var projectedResults = report.Results
|
||||
.OrderBy(static result => result.CheckId, StringComparer.Ordinal)
|
||||
.Select(ConvertResult)
|
||||
.ToImmutableArray();
|
||||
|
||||
var summary = new DoctorSummary
|
||||
{
|
||||
TotalChecks = report.Summary.Total,
|
||||
PassedChecks = report.Summary.Passed,
|
||||
InfoChecks = report.Summary.Info,
|
||||
WarnedChecks = report.Summary.Warnings,
|
||||
FailedChecks = report.Summary.Failed,
|
||||
SkippedChecks = report.Summary.Skipped,
|
||||
CategoriesWithIssues = projectedResults
|
||||
.Where(static result => result.Severity is DoctorSeverity.Warn or DoctorSeverity.Fail)
|
||||
.Select(static result => result.Category)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static category => category, StringComparer.Ordinal)
|
||||
.ToImmutableArray()
|
||||
};
|
||||
|
||||
var platformContext = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
platformContext["captured_at_utc"] = _timeProvider.GetUtcNow().ToString("o", CultureInfo.InvariantCulture);
|
||||
platformContext["doctor_version"] = typeof(DoctorContextAdapter).Assembly.GetName().Version?.ToString() ?? "unknown";
|
||||
|
||||
var context = new DoctorAIContext
|
||||
{
|
||||
RunId = report.RunId,
|
||||
ExecutedAt = report.CompletedAt,
|
||||
OverallSeverity = report.OverallSeverity,
|
||||
Summary = summary,
|
||||
Results = projectedResults,
|
||||
PlatformContext = platformContext.ToImmutable()
|
||||
};
|
||||
|
||||
_logger.LogDebug("Created DoctorAIContext for run {RunId} with {Count} results", report.RunId, projectedResults.Length);
|
||||
return Task.FromResult(context);
|
||||
}
|
||||
|
||||
public AICheckResult ConvertResult(DoctorCheckResult result)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
|
||||
var remediation = ConvertRemediation(result);
|
||||
var likelyCauses = (result.LikelyCauses ?? [])
|
||||
.OrderBy(static cause => cause, StringComparer.Ordinal)
|
||||
.Select(static cause => new AICause
|
||||
{
|
||||
Cause = cause
|
||||
})
|
||||
.ToImmutableArray();
|
||||
|
||||
return new AICheckResult
|
||||
{
|
||||
CheckId = result.CheckId,
|
||||
PluginId = result.PluginId,
|
||||
Category = result.Category,
|
||||
CheckName = result.CheckId,
|
||||
Severity = result.Severity,
|
||||
Diagnosis = result.Diagnosis,
|
||||
Evidence = CreateAIEvidence(result),
|
||||
LikelyCauses = likelyCauses,
|
||||
Remediation = remediation
|
||||
};
|
||||
}
|
||||
|
||||
public AIEvidence CreateAIEvidence(DoctorCheckResult result)
|
||||
{
|
||||
var fields = ImmutableDictionary.CreateBuilder<string, AIEvidenceField>(StringComparer.Ordinal);
|
||||
foreach (var entry in result.Evidence.Data.OrderBy(static kv => kv.Key, StringComparer.Ordinal))
|
||||
{
|
||||
var schema = _schemaRegistry.GetFieldSchema(result.CheckId, entry.Key);
|
||||
fields[entry.Key] = new AIEvidenceField
|
||||
{
|
||||
Value = entry.Value,
|
||||
Type = DetermineType(entry.Value),
|
||||
Description = schema?.Description,
|
||||
ExpectedRange = schema?.ExpectedRange,
|
||||
AbsenceSemantics = schema?.AbsenceSemantics,
|
||||
DiscriminatesFor = schema?.DiscriminatesFor ?? []
|
||||
};
|
||||
}
|
||||
|
||||
return new AIEvidence
|
||||
{
|
||||
Description = BuildEvidenceDescription(result),
|
||||
Fields = fields.ToImmutable()
|
||||
};
|
||||
}
|
||||
|
||||
private static AIRemediation? ConvertRemediation(DoctorCheckResult result)
|
||||
{
|
||||
if (result.Remediation is null || result.Remediation.Steps.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var steps = result.Remediation.Steps
|
||||
.OrderBy(static step => step.Order)
|
||||
.Select(step => new AIRemediationStep
|
||||
{
|
||||
Order = step.Order,
|
||||
Description = step.Description,
|
||||
Command = step.Command,
|
||||
CommandType = step.CommandType.ToString().ToLowerInvariant(),
|
||||
IsSafeToAutoExecute = step.CommandType != CommandType.Manual && !step.IsDestructive,
|
||||
VerificationCommand = result.VerificationCommand
|
||||
})
|
||||
.ToImmutableArray();
|
||||
|
||||
return new AIRemediation
|
||||
{
|
||||
RequiresBackup = result.Remediation.RequiresBackup,
|
||||
SafetyNote = result.Remediation.SafetyNote,
|
||||
Steps = steps,
|
||||
RunbookUrl = result.Remediation.RunbookUrl
|
||||
};
|
||||
}
|
||||
|
||||
private static string DetermineType(string value)
|
||||
{
|
||||
if (bool.TryParse(value, out _))
|
||||
{
|
||||
return "bool";
|
||||
}
|
||||
|
||||
if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
|
||||
{
|
||||
return "int";
|
||||
}
|
||||
|
||||
if (decimal.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out _))
|
||||
{
|
||||
return "float";
|
||||
}
|
||||
|
||||
return "string";
|
||||
}
|
||||
|
||||
private static string BuildEvidenceDescription(DoctorCheckResult result)
|
||||
{
|
||||
if (result.Evidence.Data.Count == 0)
|
||||
{
|
||||
return result.Evidence.Description;
|
||||
}
|
||||
|
||||
var preview = result.Evidence.Data
|
||||
.OrderBy(static kv => kv.Key, StringComparer.Ordinal)
|
||||
.Take(4)
|
||||
.Select(static kv => $"{kv.Key}={kv.Value}");
|
||||
|
||||
return $"{result.Evidence.Description} ({string.Join(", ", preview)})";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using StellaOps.Doctor.AdvisoryAI.Models;
|
||||
|
||||
namespace StellaOps.Doctor.AdvisoryAI;
|
||||
|
||||
/// <summary>
|
||||
/// Service contract for generating deterministic diagnosis from Doctor run context.
|
||||
/// </summary>
|
||||
public interface IDoctorAIDiagnosisService
|
||||
{
|
||||
Task<DoctorAIDiagnosisResponse> AnalyzeAsync(
|
||||
DoctorAIContext context,
|
||||
DoctorAIDiagnosisOptions? options = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
Task<AICheckRecommendations> GetRecommendationsAsync(
|
||||
AICheckResult checkResult,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public sealed record DoctorAIDiagnosisOptions
|
||||
{
|
||||
public bool IncludeRootCauseAnalysis { get; init; } = true;
|
||||
|
||||
public bool IncludeRemediationSuggestions { get; init; } = true;
|
||||
|
||||
public bool IncludeCorrelationAnalysis { get; init; } = true;
|
||||
|
||||
public int? MaxResponseTokens { get; init; }
|
||||
|
||||
public IReadOnlyList<string>? FocusOnChecks { get; init; }
|
||||
}
|
||||
|
||||
public sealed record DoctorAIDiagnosisResponse
|
||||
{
|
||||
public required string Assessment { get; init; }
|
||||
|
||||
public int HealthScore { get; init; }
|
||||
|
||||
public IReadOnlyList<AIIdentifiedIssue> Issues { get; init; } = [];
|
||||
|
||||
public IReadOnlyList<AIRootCause> RootCauses { get; init; } = [];
|
||||
|
||||
public IReadOnlyList<AICorrelation> Correlations { get; init; } = [];
|
||||
|
||||
public IReadOnlyList<string> RecommendedActions { get; init; } = [];
|
||||
|
||||
public IReadOnlyList<string> RelatedDocumentation { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed record AIIdentifiedIssue
|
||||
{
|
||||
public required string Summary { get; init; }
|
||||
|
||||
public IReadOnlyList<string> AffectedChecks { get; init; } = [];
|
||||
|
||||
public string Severity { get; init; } = "unknown";
|
||||
|
||||
public string? Impact { get; init; }
|
||||
|
||||
public string Urgency { get; init; } = "normal";
|
||||
}
|
||||
|
||||
public sealed record AIRootCause
|
||||
{
|
||||
public required string Cause { get; init; }
|
||||
|
||||
public float Confidence { get; init; }
|
||||
|
||||
public IReadOnlyList<string> SupportingEvidence { get; init; } = [];
|
||||
|
||||
public IReadOnlyList<string> AffectedChecks { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed record AICorrelation
|
||||
{
|
||||
public required string Issue1 { get; init; }
|
||||
|
||||
public required string Issue2 { get; init; }
|
||||
|
||||
public required string Description { get; init; }
|
||||
|
||||
public float Strength { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AICheckRecommendations
|
||||
{
|
||||
public required string CheckId { get; init; }
|
||||
|
||||
public required string Explanation { get; init; }
|
||||
|
||||
public string? MostLikelyCause { get; init; }
|
||||
|
||||
public IReadOnlyList<string> Steps { get; init; } = [];
|
||||
|
||||
public string? VerificationCommand { get; init; }
|
||||
|
||||
public IReadOnlyList<string> RelatedChecks { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using StellaOps.Doctor.AdvisoryAI.Models;
|
||||
using StellaOps.Doctor.Models;
|
||||
|
||||
namespace StellaOps.Doctor.AdvisoryAI;
|
||||
|
||||
/// <summary>
|
||||
/// Translates Doctor run data into an AdvisoryAI diagnosis context.
|
||||
/// </summary>
|
||||
public interface IDoctorContextAdapter
|
||||
{
|
||||
Task<DoctorAIContext> CreateContextAsync(DoctorReport report, CancellationToken ct = default);
|
||||
|
||||
AICheckResult ConvertResult(DoctorCheckResult result);
|
||||
|
||||
AIEvidence CreateAIEvidence(DoctorCheckResult result);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Doctor.AdvisoryAI;
|
||||
|
||||
/// <summary>
|
||||
/// Registry of field-level evidence metadata used to enrich AI context.
|
||||
/// </summary>
|
||||
public interface IEvidenceSchemaRegistry
|
||||
{
|
||||
EvidenceFieldSchema? GetFieldSchema(string checkId, string fieldName);
|
||||
|
||||
IReadOnlyDictionary<string, EvidenceFieldSchema> GetCheckSchemas(string checkId);
|
||||
|
||||
void RegisterSchema(string checkId, string fieldName, EvidenceFieldSchema schema);
|
||||
}
|
||||
|
||||
public sealed record EvidenceFieldSchema
|
||||
{
|
||||
public string? Description { get; init; }
|
||||
|
||||
public string? ExpectedRange { get; init; }
|
||||
|
||||
public string? AbsenceSemantics { get; init; }
|
||||
|
||||
public ImmutableArray<string> DiscriminatesFor { get; init; } = [];
|
||||
|
||||
public string? Unit { get; init; }
|
||||
|
||||
public bool IsKeyDiagnostic { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory deterministic registry implementation.
|
||||
/// </summary>
|
||||
public sealed class InMemoryEvidenceSchemaRegistry : IEvidenceSchemaRegistry
|
||||
{
|
||||
private readonly object _sync = new();
|
||||
private readonly Dictionary<string, Dictionary<string, EvidenceFieldSchema>> _schemas =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
public EvidenceFieldSchema? GetFieldSchema(string checkId, string fieldName)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (_schemas.TryGetValue(checkId, out var checkSchemas) &&
|
||||
checkSchemas.TryGetValue(fieldName, out var schema))
|
||||
{
|
||||
return schema;
|
||||
}
|
||||
|
||||
if (_schemas.TryGetValue("*", out var wildcardSchemas) &&
|
||||
wildcardSchemas.TryGetValue(fieldName, out var wildcardSchema))
|
||||
{
|
||||
return wildcardSchema;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<string, EvidenceFieldSchema> GetCheckSchemas(string checkId)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (_schemas.TryGetValue(checkId, out var checkSchemas))
|
||||
{
|
||||
return new Dictionary<string, EvidenceFieldSchema>(checkSchemas, StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
return new Dictionary<string, EvidenceFieldSchema>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public void RegisterSchema(string checkId, string fieldName, EvidenceFieldSchema schema)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(checkId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(fieldName);
|
||||
ArgumentNullException.ThrowIfNull(schema);
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
if (!_schemas.TryGetValue(checkId, out var checkSchemas))
|
||||
{
|
||||
checkSchemas = new Dictionary<string, EvidenceFieldSchema>(StringComparer.Ordinal);
|
||||
_schemas[checkId] = checkSchemas;
|
||||
}
|
||||
|
||||
checkSchemas[fieldName] = schema;
|
||||
}
|
||||
}
|
||||
|
||||
public void RegisterCommonSchemas()
|
||||
{
|
||||
RegisterSchema("*", "connection_latency_ms", new EvidenceFieldSchema
|
||||
{
|
||||
Description = "Time to establish connection.",
|
||||
ExpectedRange = "< 1000",
|
||||
Unit = "milliseconds",
|
||||
IsKeyDiagnostic = true,
|
||||
DiscriminatesFor = ["network_issue", "service_overloaded"]
|
||||
});
|
||||
|
||||
RegisterSchema("*", "disk_usage_percent", new EvidenceFieldSchema
|
||||
{
|
||||
Description = "Disk space utilization percentage.",
|
||||
ExpectedRange = "< 80",
|
||||
Unit = "percent",
|
||||
IsKeyDiagnostic = true,
|
||||
DiscriminatesFor = ["disk_full", "log_growth"]
|
||||
});
|
||||
|
||||
RegisterSchema("*", "memory_usage_percent", new EvidenceFieldSchema
|
||||
{
|
||||
Description = "Memory utilization percentage.",
|
||||
ExpectedRange = "< 85",
|
||||
Unit = "percent",
|
||||
IsKeyDiagnostic = true,
|
||||
DiscriminatesFor = ["memory_pressure", "load_spike"]
|
||||
});
|
||||
|
||||
RegisterSchema("*", "cpu_usage_percent", new EvidenceFieldSchema
|
||||
{
|
||||
Description = "CPU utilization percentage.",
|
||||
ExpectedRange = "< 80",
|
||||
Unit = "percent",
|
||||
IsKeyDiagnostic = true,
|
||||
DiscriminatesFor = ["cpu_saturation", "runaway_process"]
|
||||
});
|
||||
|
||||
RegisterSchema("*", "queue_depth", new EvidenceFieldSchema
|
||||
{
|
||||
Description = "Pending items waiting in queue.",
|
||||
ExpectedRange = "< 100",
|
||||
AbsenceSemantics = "Queue may be unavailable or empty.",
|
||||
DiscriminatesFor = ["backpressure", "slow_consumer"]
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Doctor.Models;
|
||||
|
||||
namespace StellaOps.Doctor.AdvisoryAI.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Context pack containing Doctor run data structured for deterministic diagnosis.
|
||||
/// </summary>
|
||||
public sealed record DoctorAIContext
|
||||
{
|
||||
public required string RunId { get; init; }
|
||||
|
||||
public DateTimeOffset ExecutedAt { get; init; }
|
||||
|
||||
public DoctorSeverity OverallSeverity { get; init; }
|
||||
|
||||
public required DoctorSummary Summary { get; init; }
|
||||
|
||||
public ImmutableArray<AICheckResult> Results { get; init; } = [];
|
||||
|
||||
public ImmutableDictionary<string, string> PlatformContext { get; init; } = ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregate summary for a Doctor run.
|
||||
/// </summary>
|
||||
public sealed record DoctorSummary
|
||||
{
|
||||
public int TotalChecks { get; init; }
|
||||
|
||||
public int PassedChecks { get; init; }
|
||||
|
||||
public int InfoChecks { get; init; }
|
||||
|
||||
public int WarnedChecks { get; init; }
|
||||
|
||||
public int FailedChecks { get; init; }
|
||||
|
||||
public int SkippedChecks { get; init; }
|
||||
|
||||
public ImmutableArray<string> CategoriesWithIssues { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AI-friendly projection of a Doctor check result.
|
||||
/// </summary>
|
||||
public sealed record AICheckResult
|
||||
{
|
||||
public required string CheckId { get; init; }
|
||||
|
||||
public required string PluginId { get; init; }
|
||||
|
||||
public required string Category { get; init; }
|
||||
|
||||
public required string CheckName { get; init; }
|
||||
|
||||
public DoctorSeverity Severity { get; init; }
|
||||
|
||||
public required string Diagnosis { get; init; }
|
||||
|
||||
public required AIEvidence Evidence { get; init; }
|
||||
|
||||
public ImmutableArray<AICause> LikelyCauses { get; init; } = [];
|
||||
|
||||
public AIRemediation? Remediation { get; init; }
|
||||
|
||||
public ImmutableArray<string> Tags { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence container enriched with semantic metadata.
|
||||
/// </summary>
|
||||
public sealed record AIEvidence
|
||||
{
|
||||
public required string Description { get; init; }
|
||||
|
||||
public ImmutableDictionary<string, AIEvidenceField> Fields { get; init; } = ImmutableDictionary<string, AIEvidenceField>.Empty;
|
||||
}
|
||||
|
||||
public sealed record AIEvidenceField
|
||||
{
|
||||
public required string Value { get; init; }
|
||||
|
||||
public required string Type { get; init; }
|
||||
|
||||
public string? Description { get; init; }
|
||||
|
||||
public string? ExpectedRange { get; init; }
|
||||
|
||||
public string? AbsenceSemantics { get; init; }
|
||||
|
||||
public ImmutableArray<string> DiscriminatesFor { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed record AICause
|
||||
{
|
||||
public required string Cause { get; init; }
|
||||
|
||||
public float? Probability { get; init; }
|
||||
|
||||
public ImmutableArray<string> EvidenceIndicating { get; init; } = [];
|
||||
|
||||
public string? Discriminator { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AIRemediation
|
||||
{
|
||||
public bool RequiresBackup { get; init; }
|
||||
|
||||
public string? SafetyNote { get; init; }
|
||||
|
||||
public ImmutableArray<AIRemediationStep> Steps { get; init; } = [];
|
||||
|
||||
public string? RunbookUrl { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AIRemediationStep
|
||||
{
|
||||
public int Order { get; init; }
|
||||
|
||||
public required string Description { get; init; }
|
||||
|
||||
public string? Command { get; init; }
|
||||
|
||||
public required string CommandType { get; init; }
|
||||
|
||||
public bool IsSafeToAutoExecute { get; init; }
|
||||
|
||||
public string? ExpectedOutcome { get; init; }
|
||||
|
||||
public string? VerificationCommand { get; init; }
|
||||
}
|
||||
Reference in New Issue
Block a user