using GB5Shared.DTO.Framework.Login; using GB5Shared.Resource.Response; using KmsBLL.Sync; using KmsDAL.CustomCode.KmArtifact; using KmsDAL.CustomCode.KmContent; using KmsDAL.CustomCode.KmGovernance; using KmsDAL.DTO.KmGovernance; using Microsoft.Extensions.Logging; using Newtonsoft.Json; namespace KmsBLL.KmGovernance { public class KmGovernanceBLL : IKmGovernanceBLL { // GovernanceState: 0=Draft, 1=UnderReview, 2=Approved, 3=Published, 4=Retired private static readonly HashSet<(byte from, byte to)> ValidTransitions = new() { (0, 1), // Draft → UnderReview (1, 2), // UnderReview → Approved (1, 0), // UnderReview → Draft (rejected) (2, 3), // Approved → Published (2, 0), // Approved → Draft (admin revoke) (3, 4), // Published → Retired (4, 0), // Retired → Draft (restart) }; private readonly IKmArtifactDAL _artifactDal; private readonly IKmGovernanceDAL _govDal; private readonly IKmContentDAL _contentDal; private readonly IKmsAISyncClient _kmsAiSyncClient; private readonly ILogger _logger; public KmGovernanceBLL( IKmArtifactDAL artifactDal, IKmGovernanceDAL govDal, IKmContentDAL contentDal, IKmsAISyncClient kmsAiSyncClient, ILogger logger) { _artifactDal = artifactDal; _govDal = govDal; _contentDal = contentDal; _kmsAiSyncClient = kmsAiSyncClient; _logger = logger; } public async Task SubmitForReview(KmGovernanceActionDTO action, LoginDTO login, CancellationToken ct) => await Transition(action, 0, 1, login, ct); public async Task Approve(KmGovernanceActionDTO action, LoginDTO login, CancellationToken ct) => await Transition(action, 1, 2, login, ct); public async Task Reject(KmGovernanceActionDTO action, LoginDTO login, CancellationToken ct) { if (string.IsNullOrWhiteSpace(action.RejectionReason)) throw new ArgumentException("RejectionReason is required when rejecting."); return await Transition(action, 1, 0, login, ct, isReject: true); } public async Task Publish(KmGovernanceActionDTO action, LoginDTO login, CancellationToken ct) { var state = await GetAndValidateState(action.ArtifactId, 2, login, ct); // Archive version snapshot before state change var artifactJson = await _artifactDal.GetKmArtifact(action.ArtifactId, login, ct); await _govDal.InsertArtifactVersion( action.ArtifactId, state.VersionNumber, artifactJson, 3, action.ChangeSummary, login, ct); await _artifactDal.UpdateGovernanceState(action.ArtifactId, 3, login, ct); await _govDal.InsertGovernanceTransition( action.ArtifactId, 2, 3, action.Notes, null, login, ct); // Embed into Qdrant via gbEAI — synchronous, called AFTER the governance transition // already committed. A failure here does NOT roll back the state change or the // version snapshot (both are already correctly persisted); it's logged and // QDRANTPOINTID is left null for a manual retry later. Explicit partial-failure // choice, not incidental — the artifact IS published in GB5's own governance sense // even if Qdrant indexing needs a follow-up. var (domainId, artifactType) = ParseArtifactShape(artifactJson); var content = await _contentDal.GetPrimaryContentByArtifact(action.ArtifactId, login, ct) .ConfigureAwait(false); if (string.IsNullOrWhiteSpace(content)) { _logger.LogWarning( "KmGovernanceBLL | Publish | No content found — skipping gbEAI embed | ArtifactId={ArtifactId}", action.ArtifactId); return $"{SuccessResponse.UpdateSuccessMessage} — Artifact published. " + "No content found to embed into Qdrant — index manually once content exists."; } var syncResult = await _kmsAiSyncClient.EmbedArtifactAsync( action.ArtifactId, domainId, artifactType, content, login, ct).ConfigureAwait(false); if (syncResult.Success && !string.IsNullOrWhiteSpace(syncResult.QdrantPointId)) { await _artifactDal.UpdateQdrantPoint( action.ArtifactId, syncResult.QdrantPointId, "text-embedding-3-small", login, ct) .ConfigureAwait(false); return $"{SuccessResponse.UpdateSuccessMessage} — Artifact published and indexed."; } _logger.LogWarning( "KmGovernanceBLL | Publish | gbEAI embed failed | ArtifactId={ArtifactId} | {Message}", action.ArtifactId, syncResult.Message); return $"{SuccessResponse.UpdateSuccessMessage} — Artifact published. " + $"Qdrant indexing failed: {syncResult.Message ?? syncResult.Status} — retry from this screen."; } public async Task Retire(KmGovernanceActionDTO action, LoginDTO login, CancellationToken ct) { var state = await GetAndValidateState(action.ArtifactId, 3, login, ct); await _artifactDal.UpdateGovernanceState(action.ArtifactId, 4, login, ct); await _govDal.InsertGovernanceTransition( action.ArtifactId, 3, 4, action.Notes, null, login, ct); // Same partial-failure tolerance as Publish — the state transition already committed // and stays correct even if the Qdrant delete fails. var qdrantPointId = await _artifactDal.GetKmArtifact(action.ArtifactId, login, ct) .ContinueWith(t => ExtractQdrantPointId(t.Result), ct).ConfigureAwait(false); var syncResult = await _kmsAiSyncClient.RetireArtifactAsync( action.ArtifactId, qdrantPointId, login, ct).ConfigureAwait(false); if (syncResult.Success) { await _artifactDal.UpdateQdrantPoint(action.ArtifactId, string.Empty, string.Empty, login, ct) .ConfigureAwait(false); return $"{SuccessResponse.UpdateSuccessMessage} — Artifact retired and removed from Qdrant."; } _logger.LogWarning( "KmGovernanceBLL | Retire | gbEAI Qdrant delete failed | ArtifactId={ArtifactId} | {Message}", action.ArtifactId, syncResult.Message); return $"{SuccessResponse.UpdateSuccessMessage} — Artifact retired. " + $"Qdrant removal failed: {syncResult.Message ?? syncResult.Status} — retry from this screen."; } private static (int DomainId, byte ArtifactType) ParseArtifactShape(string artifactJson) { try { dynamic parsed = JsonConvert.DeserializeObject(artifactJson)!; int domainId = (int)(parsed.DomainId ?? -1); byte artifactType = (byte)(parsed.ArtifactType ?? 6); return (domainId, artifactType); } catch { return (-1, 6); // 6 = Document, matches AcceptCandidate's own "reviewer should update" default } } private static string? ExtractQdrantPointId(string artifactJson) { try { dynamic parsed = JsonConvert.DeserializeObject(artifactJson)!; return (string?)parsed.QdrantPointId; } catch { return null; } } public async Task GetGovernanceHistory(int artifactId, LoginDTO login, CancellationToken ct) => await _govDal.GetGovernanceHistory(artifactId, login, ct); // ── Private helpers ──────────────────────────────────────────────────── private async Task Transition(KmGovernanceActionDTO action, byte expectedFrom, byte targetTo, LoginDTO login, CancellationToken ct, bool isReject = false) { await GetAndValidateState(action.ArtifactId, expectedFrom, login, ct); await _artifactDal.UpdateGovernanceState(action.ArtifactId, targetTo, login, ct); await _govDal.InsertGovernanceTransition( action.ArtifactId, expectedFrom, targetTo, action.Notes, isReject ? action.RejectionReason : null, login, ct); return $"{SuccessResponse.UpdateSuccessMessage} — State updated."; } private async Task GetAndValidateState( int artifactId, byte expectedState, LoginDTO login, CancellationToken ct) { var state = await _artifactDal.GetArtifactCurrentState(artifactId, login, ct) ?? throw new InvalidOperationException("Artifact not found."); if (state.GovernanceState != expectedState) throw new InvalidOperationException( $"Invalid transition: artifact is in state {state.GovernanceState}, expected {expectedState}."); return state; } } }