using GB5Shared.DTO.Framework.Login; using GB5Shared.GenerateAutoNumber; using GB5Shared.Resource.Response; using KmsBLL.Sync; using KmsDAL.CustomCode.KmArtifact; using KmsDAL.CustomCode.KmContent; using KmsDAL.CustomCode.KmDiscovery; using KmsDAL.DTO.KmArtifact; using KmsDAL.DTO.KmContent; using KmsDAL.DTO.KmDiscovery; using Microsoft.Extensions.Logging; namespace KmsBLL.KmDiscovery { public class KmDiscoveryBLL : IKmDiscoveryBLL { private readonly IKmDiscoveryDAL _dal; private readonly IKmArtifactDAL _artifactDal; private readonly IKmContentDAL _contentDal; private readonly AutoNumber _autoNumber; private readonly IKmsAISyncClient _kmsAiSyncClient; private readonly ILogger _logger; public KmDiscoveryBLL(IKmDiscoveryDAL dal, IKmArtifactDAL artifactDal, IKmContentDAL contentDal, AutoNumber autoNumber, IKmsAISyncClient kmsAiSyncClient, ILogger logger) { _dal = dal; _artifactDal = artifactDal; _contentDal = contentDal; _autoNumber = autoNumber; _kmsAiSyncClient = kmsAiSyncClient; _logger = logger; } // Machine-to-machine calls (SaveCandidate/UpdateJobStatus, invoked directly by gbEAI's // kms_discovery_adapter.py) arrive via KmsApiKeyAuthMiddleware's placeholder LoginDTO // (ClientId = -1) — there's no HTTP session to inherit a real tenant from. Rebuilds a // real, DB-routable LoginDTO from the DTO's own TenantId field, matching the established // `new LoginDTO { ClientId = tenantId, UserId = -1 }` idiom already used by // EntitlementLoginFactory / FlsReminderJobHandler / ComplianceSL's AlertCheckSubscriber // for exactly this "no HTTP session, only a tenant id" situation. private static LoginDTO EffectiveLogin(LoginDTO login, int dtoTenantId) => login.ClientId > 0 ? login : new LoginDTO { ClientId = dtoTenantId, UserId = 0 }; public async Task TriggerExtractionJob(KmTriggerJobDTO trigger, LoginDTO login, CancellationToken ct) { if (string.IsNullOrWhiteSpace(trigger.SourceReference)) throw new ArgumentException("SourceReference is required."); var autoNum = await _autoNumber.GetNumberAsync(1, "TKMJOB", login); var job = new KmExtractionJobDTO { JobId = autoNum.StartNumber, ConnectorId = trigger.ConnectorId <= 0 ? -1 : trigger.ConnectorId, SourceReference = trigger.SourceReference, TenantId = login.ClientId, CreatedById = login.UserId }; await _dal.SaveKmJob(job, login, ct); // Domain association isn't part of KmTriggerJobDTO today — the pipeline runs // unassigned (domain_id = -1) until a candidate is reviewed and assigned a domain via // AcceptCandidate. Push failures don't roll back the local job row — GB5 stays // system-of-record even when gbEAI is unreachable; the failure surfaces in the // returned message so the caller knows to retry. var syncResult = await _kmsAiSyncClient.TriggerExtractionAsync( job.JobId, domainId: -1, trigger.SourceText, login, ct).ConfigureAwait(false); if (!syncResult.Success) { _logger.LogWarning( "KmDiscoveryBLL | TriggerExtractionJob | gbEAI sync failed | JobId={JobId} | {Error}", job.JobId, syncResult.Message); return $"{SuccessResponse.SaveSuccessMessage} {job.JobId}. " + $"Sync to AI-Enterprise-v1.0 failed: {syncResult.Message} — retry from this screen."; } return $"{SuccessResponse.SaveSuccessMessage} {job.JobId}"; } public async Task GetExtractionJob(int jobId, LoginDTO login, CancellationToken ct) => await _dal.GetKmJob(jobId, login, ct); public async Task GetExtractionJobs(int page, int pageSize, LoginDTO login, CancellationToken ct) => await _dal.GetKmJobs(page, pageSize, login, ct); public async Task GetCandidatesPending(LoginDTO login, CancellationToken ct) => await _dal.GetKmCandidatesPending(login, ct); public async Task AcceptCandidate(KmAcceptCandidateDTO dto, LoginDTO login, CancellationToken ct) { // Create artifact in Draft state from accepted candidate var autoNum = await _autoNumber.GetNumberAsync(2, "KMARTIFACT", login); int nextId = autoNum.StartNumber; var artifactDto = new KmArtifactDTO { ArtifactId = nextId++, DomainId = dto.DomainId, ArtifactCode = $"KN-{nextId:D6}", Title = dto.ModifiedTitle ?? "Pending Title", ArtifactType = 6, // Document — reviewer should update SourceKind = 1, // FileUpload as default ScopeLevel = 3, // Tenant ScopeValue = login.ClientId.ToString(), VersionNumber = 1, ParentArtifactId = -1, OwnerUserId = login.UserId, ReviewerUserId = -1, ExtractionJobId = -1, TenantId = login.ClientId, CreatedById = login.UserId, ModifiedById = login.UserId, SortOrder = 9999 }; await _artifactDal.SaveKmArtifact(artifactDto, login, ct); // Save primary content var contentDto = new KmContentDTO { ContentId = nextId, ArtifactId = artifactDto.ArtifactId, Slno = 1, ContentBody = dto.ModifiedContent ?? string.Empty, ContentType = "Text", LanguageCode = "en", IsPrimary = 1, ChunkIndex = 0, ChunkTotal = 1 }; await _contentDal.SaveKmContent(contentDto, login, ct); // Update candidate with artifact reference dto.ArtifactId = artifactDto.ArtifactId; await _dal.AcceptCandidate(dto, login, ct); return $"{SuccessResponse.SaveSuccessMessage} — Artifact created in Draft: {artifactDto.ArtifactId}"; } public async Task RejectCandidate(KmRejectCandidateDTO dto, LoginDTO login, CancellationToken ct) { if (string.IsNullOrWhiteSpace(dto.RejectionReason)) throw new ArgumentException("RejectionReason is required."); await _dal.RejectCandidate(dto, login, ct); return $"{SuccessResponse.UpdateSuccessMessage} — Candidate rejected."; } public async Task SaveCandidate(KmCandidateDTO dto, LoginDTO login, CancellationToken ct) { // Called directly by gbEAI's kms_discovery_adapter.py (X-Api-Key, no browser // session) — dto.TenantId is already on the wire (gbEAI's own _post_candidate sends // it), trusted here instead of the placeholder login when there's no real session. var effectiveLogin = EffectiveLogin(login, dto.TenantId); var autoNum = await _autoNumber.GetNumberAsync(1, "TKMCANDIDATE", effectiveLogin); dto.CandidateId = autoNum.StartNumber; dto.TenantId = effectiveLogin.ClientId; await _dal.SaveKmCandidate(dto, effectiveLogin, ct); return $"{SuccessResponse.SaveSuccessMessage} {dto.CandidateId}"; } public async Task UpdateJobStatus(KmExtractionJobDTO dto, LoginDTO login, CancellationToken ct) { // Same M2M caller/rationale as SaveCandidate above. var effectiveLogin = EffectiveLogin(login, dto.TenantId); dto.TenantId = effectiveLogin.ClientId; dto.ModifiedById = effectiveLogin.UserId; await _dal.UpdateKmJobStatus(dto, effectiveLogin, ct); return SuccessResponse.UpdateSuccessMessage; } } }