using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using FrameworkDAL.CustomCode.SIP; using GB5Shared.DTO.Framework.Login; using GB5Shared.GEF; using GB5Shared.SIP; using GB5Shared.Telemetry; using Microsoft.Extensions.Logging; namespace FrameworkBLL.SIP { // ============================================================ // SIPSignalService — GB5Framework implementation of GB5Shared.SIP.ISIPSignalService. // // v1 ingests signals via direct EmitSignalAsync calls from producing modules (e.g. // SalesMind's InteractionRecord save path); generalizing to a Dapr event-subscriber that // ingests any module's EventLogPublish output without each producer needing a direct SIP // reference is a near-term follow-up, not yet built in this slice. // // GEF must never be called from here — one-way dependency: GEF may read SIP's output, SIP // never calls GEF (canvas §8.2). This class has no reference to IGEFEngine and must not gain // one. // ============================================================ public class SIPSignalService : ISIPSignalService { private readonly ISIPPatternEntryDAL _Dal; private readonly ILogger _Logger; public SIPSignalService(ISIPPatternEntryDAL dal, ILogger logger) { _Dal = dal; _Logger = logger; } public async Task EmitSignalAsync( SignalDTO signal, string domain, string entityCode, int entityId, LoginDTO login, CancellationToken ct) { GB5Trace.Step("sip-emit-signal", new { domain, entityCode, entityId, signal.SignalType }); try { var entry = new PatternEntryDTO { Domain = domain, EntityCode = entityCode, EntityId = entityId, SignalType = signal.SignalType, Detail = signal.Detail, Strength = signal.Strength, // ISIPSignalService.EmitSignalAsync's contract does not accept a separate actor // parameter (SignalDTO carries none either) — ActorId is the authenticated // caller (login.UserId); ActorRole is left null since no clean role-name string // is available at this layer without additional plumbing (login.RoleId is an // ID, not a display name). Revisit if a consumer needs it populated. ActorRole = null, ActorId = login.UserId, DetectedOn = signal.DetectedOn == default ? DateTime.UtcNow : signal.DetectedOn }; // Immutable append — NOT routed through ExecuteSaveAsync: this is a ledger row, not // a primary business entity (no workflow/WIP gate applies, and the row IS itself // the audit record) — a plain DAL insert is the correct, simpler choice per // CLAUDE.md's carve-out for non-entity operations. await _Dal.InsertPatternEntry(entry, login, ct).ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("sip-emit-signal-failed", ex); _Logger.LogError(ex, "SIPSignalService.EmitSignalAsync failed for domain {Domain} entity {EntityCode}/{EntityId}", domain, entityCode, entityId); throw; } } public async Task> GetPatternSummaryAsync( string domain, string entityCode, int entityId, LoginDTO login, CancellationToken ct) { var rows = (await _Dal.GetPatternSummary(domain, entityCode, entityId, login, ct) .ConfigureAwait(false)).ToList(); // v1 hardcoded recurrence rule: 3+ occurrences of the same SignalType clustered within // a 30-day span. TODO: make both the threshold and window configurable per // domain/signal type once a real tenant needs a different rule. const int recurringThresholdCount = 3; var recurringWindow = TimeSpan.FromDays(30); foreach (var row in rows) { row.IsRecurring = row.OccurrenceCount >= recurringThresholdCount && (row.LastSeen - row.FirstSeen) <= recurringWindow; } return rows; } } }