using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using FrameworkDAL.CustomCode.GEF; using GB5Shared.DTO.Framework.Login; using GB5Shared.DTO.Qualifier; using GB5Shared.GEF; using GB5Shared.GOP.Qualifier; using GB5Shared.Telemetry; using Microsoft.Extensions.Logging; namespace FrameworkBLL.GEF { // ============================================================ // GEFEngine — GB5Framework implementation of GB5Shared.GEF.IGEFEngine. // // Deterministic situation→guidance evaluation. Does NOT invent its own rule/condition // storage: EvaluateAsync builds a QualifierExecutionContext from the caller's // ExecutionContextDTO and delegates to the existing IQualifierFacadeImpl DAG engine // (GB5Shared/GOP/Qualifier/QualifierFacadeImpl.cs) — an admin configures GEF behavior per // domain entirely through MQUALIFIERDEFINITION/MQUALIFIERENTITY/MQUALIFIERRULEGROUP/ // MQUALIFIERRULECONDITION/MQUALIFIEROUTPUT/MQUALIFIERMETHOD/MQUALIFIERVERSION rows. // // Well-known Document fact keys a qualifier binding may populate (all optional — GEF degrades // gracefully to an empty/neutral GuidanceOutputDTO when none are set, e.g. because no // qualifier is bound yet for a given EntityCode/Stage): // SituationType, Severity, Confidence, SupportingSignals -> GuidanceOutputDTO.Situation // FocusType, Rationale, AllowedActions, DiscouragedActions -> GuidanceOutputDTO.Focus // Recommendations -> GuidanceOutputDTO.Recommendations // Error/Warning/Info QualifierFindings map to Guardrails/Risks/KeyMessages respectively. // ============================================================ public class GEFEngine : IGEFEngine { private readonly IQualifierFacadeImpl _QualifierFacade; private readonly IGEFOutcomeFeedbackDAL _OutcomeFeedbackDal; private readonly ILogger _Logger; public GEFEngine( IQualifierFacadeImpl qualifierFacade, IGEFOutcomeFeedbackDAL outcomeFeedbackDal, ILogger logger) { _QualifierFacade = qualifierFacade; _OutcomeFeedbackDal = outcomeFeedbackDal; _Logger = logger; } public async Task EvaluateAsync( ExecutionContextDTO context, IEnumerable signals, LoginDTO login, CancellationToken ct) { var signalList = signals?.ToList() ?? new List(); GB5Trace.Step("gef-evaluate-start", new { context.Domain, context.EntityCode, context.EntityId, context.Stage, signalCount = signalList.Count }); var qCtx = new QualifierExecutionContext { ClientId = login.ClientId, EntityId = context.EntityId, EntityCode = context.EntityCode, Stage = context.Stage, // TODO: GEF doesn't yet distinguish Scope (Document/Line/Group per // MQUALIFIERENTITY.SCOPE) — every evaluation runs at Scope=1 (Document) until a // domain needs Line/Group-level guidance. Revisit when SalesMind/Marketing need it. Scope = 1, Document = context.Document, CorrelationId = Guid.NewGuid().ToString() }; if (signalList.Count > 0) { // Flat list of signal type codes so a qualifier rule condition (which compares a // fact's ToString() against an expected value — see // QualifierFacadeImpl.EvaluateCondition) can test against it, e.g. an IN/REGEX // condition on "SupportingSignals". Full per-signal detail (Detail/Strength/ // DetectedOn) is not modeled as a separate document fact in this first slice. qCtx.Facts.SetDocumentFact("SupportingSignals", signalList.Select(s => s.SignalType).ToList()); } QualifierResult result; try { result = await _QualifierFacade.ExecuteStageAsync(qCtx, login, ct).ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("gef-evaluate-failed", ex); _Logger.LogError(ex, "GEFEngine.EvaluateAsync failed for domain {Domain} entity {EntityCode}/{EntityId}", context.Domain, context.EntityCode, context.EntityId); throw; } var keyMessages = new List(); var risks = new List(); var guardrails = new List(); foreach (var finding in result.Findings) { switch (finding.Severity) { case FindingSeverity.Error: guardrails.Add(new GuardrailDTO { RestrictedAction = finding.Code, ViolationMessage = finding.Message, Severity = "Error" }); break; case FindingSeverity.Warning: risks.Add(finding.Message); break; case FindingSeverity.Info: keyMessages.Add(finding.Message); break; } } SituationDTO? situation = null; ExecutionFocusDTO? focus = null; IReadOnlyList recommendations = Array.Empty(); if (result.Facts.TryGetValue("Document", out var docObj) && docObj is IReadOnlyDictionary docFacts) { var situationType = GetDocString(docFacts, "SituationType"); if (!string.IsNullOrWhiteSpace(situationType)) { situation = new SituationDTO { SituationType = situationType, Severity = GetDocString(docFacts, "Severity") ?? string.Empty, Confidence = GetDocDouble(docFacts, "Confidence"), SupportingSignals = GetDocStringList(docFacts, "SupportingSignals") }; } var focusType = GetDocString(docFacts, "FocusType"); if (!string.IsNullOrWhiteSpace(focusType)) { focus = new ExecutionFocusDTO { FocusType = focusType, Rationale = GetDocString(docFacts, "Rationale") ?? string.Empty, AllowedActions = GetDocStringList(docFacts, "AllowedActions"), DiscouragedActions = GetDocStringList(docFacts, "DiscouragedActions") }; } recommendations = GetDocStringList(docFacts, "Recommendations"); } GB5Trace.Step("gef-evaluate-complete", new { context.Domain, context.EntityCode, context.EntityId, findingCount = result.Findings.Count, hasSituation = situation is not null, hasFocus = focus is not null, guardrailCount = guardrails.Count }); return new GuidanceOutputDTO { KeyMessages = keyMessages, Risks = risks, Recommendations = recommendations, Situation = situation, Focus = focus, Guardrails = guardrails }; } public async Task RecordOutcomeFeedbackAsync( OutcomeFeedbackDTO feedback, LoginDTO login, CancellationToken ct) { GB5Trace.Step("gef-record-outcome-feedback", new { feedback.Domain, feedback.EntityCode, feedback.EntityId, feedback.OutcomeType }); try { // NOT routed through BaseEntityAppService.ExecuteSaveAsync: this is an internal, // append-only outcome-feedback log entry, not a primary business entity — no // workflow/WIP gate applies, and no separate EventLogPublish audit event is // warranted for a feedback row that IS itself the audit record. A plain DAL insert // is the correct, simpler choice per CLAUDE.md's own carve-out for "legitimate // non-entity operations that don't map to a single ExecuteSaveAsync call". await _OutcomeFeedbackDal.SaveOutcomeFeedback(feedback, login, ct).ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("gef-record-outcome-feedback-failed", ex); _Logger.LogError(ex, "GEFEngine.RecordOutcomeFeedbackAsync failed for domain {Domain} entity {EntityCode}/{EntityId}", feedback.Domain, feedback.EntityCode, feedback.EntityId); throw; } } // ═════════════════════════════════════════════════════════ // PRIVATE HELPERS — read well-known Document facts defensively; a qualifier's NCalc/ // DataSource/Service method can populate these as a string, a JSON array string, or a // native list — support all three rather than assuming one authoring convention. // ═════════════════════════════════════════════════════════ private static string? GetDocString(IReadOnlyDictionary doc, string key) => doc.TryGetValue(key, out var v) ? v?.ToString() : null; private static double GetDocDouble(IReadOnlyDictionary doc, string key) { if (!doc.TryGetValue(key, out var raw) || raw is null) return 0d; return raw switch { double d => d, float f => f, int i => i, decimal m => (double)m, string s when double.TryParse(s, out var parsed) => parsed, _ => 0d }; } private static IReadOnlyList GetDocStringList(IReadOnlyDictionary doc, string key) { if (!doc.TryGetValue(key, out var raw) || raw is null) return Array.Empty(); if (raw is IEnumerable objEnumerable) return objEnumerable.Select(o => o?.ToString() ?? string.Empty) .Where(s => s.Length > 0) .ToList(); var text = raw.ToString(); if (string.IsNullOrWhiteSpace(text)) return Array.Empty(); try { var arr = System.Text.Json.JsonSerializer.Deserialize>(text); if (arr is not null) return arr; } catch (System.Text.Json.JsonException) { // Not JSON — fall through to comma-split below. } return text.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); } } }