Files
tiku-backend.net/Tiku.Infrastructure/Learning/PracticeSessions/V2PracticeSessionService.cs
xiong 3f5957d744
Some checks failed
ci / release-gate (push) Has been cancelled
feat(content): establish v2 learning delivery foundation
2026-08-06 09:52:18 +08:00

644 lines
33 KiB
C#

using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Application.Content;
using Tiku.Application.Learning;
using Tiku.Application.Security;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
using Tiku.Domain.Learning;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Learning;
internal interface IV2PracticeSessionService
{
Task<PracticeSessionItem> CreateAsync(
LearningActor actor,
PracticeSessionCommand command,
CancellationToken cancellationToken);
Task<Dictionary<Guid, QuestionSolutionItem>> LoadSolutionsAsync(
IReadOnlyCollection<PracticeSessionQuestion> sessionQuestions,
CancellationToken cancellationToken);
}
internal sealed class V2PracticeSessionService(LearningServiceDependencies dependencies)
: LearningActivityServiceBase(dependencies), IV2PracticeSessionService
{
public async Task<PracticeSessionItem> CreateAsync(
LearningActor actor,
PracticeSessionCommand command,
CancellationToken cancellationToken)
{
var resourceType = command.ResourceType;
var resourceId = command.ResourceId;
var now = DateTimeOffset.UtcNow;
var resourceManifests = await (
from resource in learningAccessPersistence.ProductManifestResources.AsNoTracking()
join version in learningAccessPersistence.ProductAccessManifestVersions.AsNoTracking()
on new { resource.TenantId, Id = resource.ProductAccessManifestVersionId }
equals new { version.TenantId, version.Id }
join manifest in learningAccessPersistence.ProductAccessManifests.AsNoTracking()
on new { version.TenantId, Id = version.ProductAccessManifestId }
equals new { manifest.TenantId, manifest.Id }
join product in learningAccessPersistence.LearningProducts.AsNoTracking()
on new { manifest.TenantId, Id = manifest.LearningProductId }
equals new { product.TenantId, product.Id }
where resource.TenantId == actor.TenantId &&
resource.ResourceType == resourceType &&
resource.ResourceId == resourceId &&
version.Status == AccessManifestStatus.Published &&
manifest.IsActive &&
product.IsActive
select new { resource.ProductAccessManifestVersionId, product.BusinessLineId })
.ToArrayAsync(cancellationToken);
var businessLineIds = resourceManifests.Select(item => item.BusinessLineId).Distinct().ToArray();
if (businessLineIds.Length != 1)
throw new LearningAccessException("practice_resource_not_entitled",
"The requested resource is unavailable in the current learning context.");
var snapshot = await effectiveLearningAccessService.GetSnapshotAsync(
actor, businessLineIds[0], cancellationToken);
var resourceManifestIds = resourceManifests.Select(item => item.ProductAccessManifestVersionId)
.Where(snapshot.ManifestVersionIds.Contains)
.Distinct()
.ToArray();
var grant = await ResolveV2GrantAsync(
actor, resourceType, resourceId, resourceManifestIds, now, cancellationToken);
if (grant.ManifestVersionIds.Length == 0)
throw new LearningAccessException("practice_resource_not_entitled",
"The requested resource is not covered by an active product or class grant.");
var releaseLinks = await learningAccessPersistence.ProductManifestReleases.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId &&
grant.ManifestVersionIds.Contains(item.ProductAccessManifestVersionId) &&
snapshot.ContentReleaseIds.Contains(item.ContentReleaseId))
.ToArrayAsync(cancellationToken);
releaseLinks = await RestrictReleaseLinksAsync(
actor.TenantId, resourceType, resourceId, releaseLinks, cancellationToken);
if (releaseLinks.Length == 0)
throw new LearningAccessException("practice_resource_release_missing",
"The requested resource has no ready content release in this Cell.");
const int limit = 100;
var seed = StableSeed(actor.UserId, resourceType, resourceId);
var candidates = new List<ContentCandidateItem>(limit);
var ownerGroups = releaseLinks.GroupBy(item => item.ContentOwnerTenantId)
.OrderBy(group => group.Key)
.ToArray();
var rotatedGroups = Rotate(ownerGroups, seed);
foreach (var group in rotatedGroups)
{
var remaining = limit - candidates.Count;
if (remaining == 0) break;
var ownerCandidates = await contentCandidateReader.GetCandidatesAsync(
new ContentCandidateQuery(
group.Key,
group.Select(item => item.ContentReleaseId).Distinct().ToArray(),
snapshot.AudienceSegmentIds,
resourceType == AccessResourceType.CurriculumNode ? resourceId : null,
resourceType is AccessResourceType.CollectionRelease or AccessResourceType.BlueprintRelease
? await ResolveCollectionReleaseIdsAsync(group.Key, resourceType, resourceId, cancellationToken)
: null,
remaining,
seed),
cancellationToken);
candidates.AddRange(ownerCandidates);
}
var distinctCandidates = candidates.DistinctBy(item => item.ContentReleaseQuestionId).Take(limit).ToArray();
if (distinctCandidates.Length == 0)
throw new LearningValidationException("no_practice_questions",
"No published questions match the current targets and resource.");
var deliveries = await LoadV2DeliveriesAsync(distinctCandidates, cancellationToken);
var specification = resourceType == AccessResourceType.BlueprintRelease
? await LoadBlueprintSpecificationAsync(releaseLinks, resourceId, cancellationToken)
: null;
var mode = NormalizeMode(command.Mode ?? (resourceType == AccessResourceType.BlueprintRelease ? "mock_exam" : "chapter"));
var durationMinutes = specification?.DurationMinutes;
var totalScore = specification?.TotalScore ?? deliveries.Sum(item => item.Policy.DefaultScore);
var sessionExpiry = durationMinutes.HasValue
? now.AddMinutes(Math.Min(240, durationMinutes.Value + 15))
: now.AddHours(2);
await using var transaction = await unitOfWork.Database.BeginTransactionAsync(cancellationToken);
var consumedQuota = await ReserveV2QuestionQuotaAsync(
actor,
grant,
distinctCandidates.Length,
cancellationToken);
var session = new PracticeSession
{
TenantId = actor.TenantId,
UserId = actor.UserId,
Mode = mode,
TargetType = resourceType.ToString(),
TargetId = resourceId,
BlueprintId = resourceType == AccessResourceType.BlueprintRelease ? resourceId : null,
CollectionId = resourceType == AccessResourceType.CollectionRelease ? resourceId : null,
ContentNodeId = resourceType == AccessResourceType.CurriculumNode ? resourceId : null,
QuestionCount = distinctCandidates.Length,
DurationMinutes = durationMinutes,
TotalScore = totalScore,
ExpiresAt = sessionExpiry,
AccessMode = grant.ClassAssignmentGrantId.HasValue
? PracticeAccessMode.ClassAssignment
: PracticeAccessMode.Package,
AccessEntitlementId = grant.StudentEntitlementId,
AccessClassAssignmentId = grant.ClassAssignmentGrantId,
ConsumedFreeQuota = consumedQuota,
AccessGrantVersion = snapshot.GrantVersion,
StrongRevocationVersion = snapshot.StrongRevocationVersion,
AuthorizationExpiresAt = sessionExpiry,
AccessSnapshot = JsonSerializer.SerializeToElement(new
{
strategy = "effective_access_v2",
businessLineId = snapshot.BusinessLineId,
primaryProfileVersionId = snapshot.PrimaryProfileVersionId,
alternateProfileVersionIds = snapshot.AlternateProfileVersionIds.Order().ToArray(),
releaseIds = releaseLinks.Select(item => item.ContentReleaseId).Distinct().Order().ToArray(),
audienceSegmentIds = snapshot.AudienceSegmentIds.Order().ToArray(),
manifestVersionIds = grant.ManifestVersionIds.Order().ToArray(),
grantVersion = snapshot.GrantVersion,
contentVersion = snapshot.ContentVersion,
strongRevocationVersion = snapshot.StrongRevocationVersion,
resourceType,
resourceId,
seed,
requestedCount = limit,
grantedCount = distinctCandidates.Length
}),
Metadata = JsonDefaults.Object()
};
learningPersistence.PracticeSessions.Add(session);
foreach (var delivery in deliveries.OrderBy(item =>
Array.FindIndex(distinctCandidates, candidate =>
candidate.ContentReleaseQuestionId == item.Candidate.ContentReleaseQuestionId)))
{
if (!QuestionGrader.HasValidAuthoritativeAnswer(
delivery.Revision.QuestionType,
delivery.Revision.CorrectOptionIndex,
delivery.Revision.CorrectOptionIndices,
delivery.Revision.AnswerText))
throw new LearningValidationException("practice_question_grading_rule_invalid",
$"Question revision '{delivery.Revision.Id}' has no valid authoritative grading rule.");
learningPersistence.PracticeSessionQuestions.Add(new PracticeSessionQuestion
{
TenantId = actor.TenantId,
PracticeSessionId = session.Id,
ContentReleaseQuestionId = delivery.Candidate.ContentReleaseQuestionId,
QuestionOwnerTenantId = delivery.Candidate.QuestionAssetOwnerTenantId,
QuestionAssetId = delivery.Candidate.QuestionAssetId,
QuestionRevisionId = delivery.Candidate.QuestionRevisionId,
QuestionPlacementId = delivery.Candidate.QuestionPlacementId,
AssessmentPolicyVersionId = delivery.Candidate.AssessmentPolicyVersionId,
Position = Array.FindIndex(distinctCandidates, candidate =>
candidate.ContentReleaseQuestionId == delivery.Candidate.ContentReleaseQuestionId),
Score = delivery.Policy.DefaultScore,
QuestionType = delivery.Revision.QuestionType,
TypeLabelSnapshot = delivery.Revision.TypeLabel,
DifficultySnapshot = delivery.Candidate.Difficulty,
TagsSnapshot = JsonDefaults.Array(),
GradingRulesSnapshot = BuildV2GradingSnapshot(delivery),
SnapshotVersion = 2
});
}
learningPersistence.PracticeAccessEvents.Add(new PracticeAccessEvent
{
TenantId = actor.TenantId,
UserId = actor.UserId,
PracticeSessionId = session.Id,
EventType = PracticeAccessEventType.SessionCreated,
AccessMode = grant.ClassAssignmentGrantId.HasValue
? PracticeAccessEventMode.ClassAssignment
: PracticeAccessEventMode.Package,
RequestedCount = limit,
GrantedCount = distinctCandidates.Length,
ConsumedFreeQuota = consumedQuota,
EntitlementId = grant.StudentEntitlementId,
Metadata = session.AccessSnapshot
});
await unitOfWork.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return ToItem(session);
}
private async Task<int> ReserveV2QuestionQuotaAsync(
LearningActor actor,
V2Grant grant,
int questionCount,
CancellationToken cancellationToken)
{
if (questionCount <= 0) return 0;
if (grant.TotalQuestionLimit.HasValue && grant.StudentEntitlementId.HasValue)
{
var updated = await learningAccessPersistence.StudentEntitlementsV2
.Where(item => item.TenantId == actor.TenantId &&
item.Id == grant.StudentEntitlementId.Value &&
item.Status == StudentEntitlementStatus.Active &&
item.UsedQuestionCount + questionCount <= grant.TotalQuestionLimit.Value)
.ExecuteUpdateAsync(setters => setters
.SetProperty(item => item.UsedQuestionCount, item => item.UsedQuestionCount + questionCount)
.SetProperty(item => item.UpdatedAt, DateTimeOffset.UtcNow), cancellationToken);
if (updated != 1)
throw new LearningAccessException("practice_total_quota_exhausted",
"The product question quota has been exhausted.");
}
if (!grant.DailyQuestionLimit.HasValue) return grant.TotalQuestionLimit.HasValue ? questionCount : 0;
var usageId = Guid.NewGuid();
var scopeType = "product_manifest";
var emptyMetadata = "{}";
var reserved = await learningPersistence.Database.ExecuteSqlInterpolatedAsync($"""
INSERT INTO practice_daily_usage
(id, tenant_id, user_id, usage_date, scope_type, scope_id,
free_limit, used_count, metadata, created_at, updated_at)
VALUES
({usageId}, {actor.TenantId}, {actor.UserId}, current_date, {scopeType},
{grant.ManifestVersionIds[0]}, {grant.DailyQuestionLimit.Value}, {questionCount},
{emptyMetadata}::jsonb, now(), now())
ON CONFLICT (tenant_id, user_id, usage_date, scope_type, scope_id)
DO UPDATE SET
used_count = practice_daily_usage.used_count + {questionCount},
updated_at = now()
WHERE practice_daily_usage.used_count + {questionCount} <= EXCLUDED.free_limit
""", cancellationToken);
if (reserved != 1)
throw new LearningAccessException("practice_daily_quota_exhausted",
"The daily product question quota has been exhausted.");
return questionCount;
}
private async Task<V2Grant> ResolveV2GrantAsync(
LearningActor actor,
AccessResourceType resourceType,
Guid resourceId,
Guid[] resourceManifestIds,
DateTimeOffset now,
CancellationToken cancellationToken)
{
var entitlement = await (
from item in learningAccessPersistence.StudentEntitlementsV2.AsNoTracking()
join manifest in learningAccessPersistence.ProductAccessManifestVersions.AsNoTracking()
on new { item.TenantId, Id = item.ProductAccessManifestVersionId }
equals new { manifest.TenantId, manifest.Id }
where item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.Status == StudentEntitlementStatus.Active &&
item.StartsAt <= now &&
(item.EndsAt == null || item.EndsAt > now) &&
resourceManifestIds.Contains(item.ProductAccessManifestVersionId)
orderby item.EndsAt
select new
{
item.Id,
item.ProductAccessManifestVersionId,
manifest.DailyQuestionLimit,
manifest.TotalQuestionLimit
})
.FirstOrDefaultAsync(cancellationToken);
if (entitlement is not null)
return new V2Grant(
[entitlement.ProductAccessManifestVersionId],
entitlement.Id,
null,
entitlement.DailyQuestionLimit,
entitlement.TotalQuestionLimit);
var classGrant = await (
from member in learningAccessPersistence.TenantClassMembers.AsNoTracking()
join grant in learningAccessPersistence.ClassAssignmentGrants.AsNoTracking()
on new { member.TenantId, member.ClassId } equals new { grant.TenantId, grant.ClassId }
join manifest in learningAccessPersistence.ProductAccessManifestVersions.AsNoTracking()
on new { grant.TenantId, Id = grant.ProductAccessManifestVersionId }
equals new { manifest.TenantId, manifest.Id }
where member.TenantId == actor.TenantId &&
member.UserId == actor.UserId &&
member.MemberType == TenantClassMemberType.Student &&
member.Status == TenantClassMemberStatus.Active &&
grant.Status == ClassAssignmentGrantStatus.Active &&
grant.StartsAt <= now &&
(grant.EndsAt == null || grant.EndsAt > now) &&
grant.ResourceType == resourceType &&
grant.ResourceId == resourceId &&
resourceManifestIds.Contains(grant.ProductAccessManifestVersionId)
select new
{
grant.Id,
grant.ProductAccessManifestVersionId,
manifest.DailyQuestionLimit,
manifest.TotalQuestionLimit
})
.FirstOrDefaultAsync(cancellationToken);
return classGrant is null
? new V2Grant([], null, null, null, null)
: new V2Grant(
[classGrant.ProductAccessManifestVersionId],
null,
classGrant.Id,
classGrant.DailyQuestionLimit,
null);
}
private async Task<ProductManifestRelease[]> RestrictReleaseLinksAsync(
Guid tenantId,
AccessResourceType resourceType,
Guid resourceId,
ProductManifestRelease[] links,
CancellationToken cancellationToken)
{
if (resourceType == AccessResourceType.ContentRelease)
links = links.Where(item => item.ContentReleaseId == resourceId).ToArray();
else if (resourceType == AccessResourceType.CollectionRelease)
links = await FilterLinksByCollectionAsync(links, resourceId, cancellationToken);
else if (resourceType == AccessResourceType.BlueprintRelease)
links = await FilterLinksByBlueprintAsync(links, resourceId, cancellationToken);
var packageLinks = links.Where(item => item.PlatformContentPackageVersionId.HasValue).ToArray();
if (packageLinks.Length == 0) return links;
var cellId = await learningAccessPersistence.Tenants.AsNoTracking()
.Where(item => item.Id == tenantId)
.Select(item => item.CellId)
.SingleAsync(cancellationToken);
var packageIds = packageLinks.Select(item => item.PlatformContentPackageVersionId!.Value).ToArray();
var readyPackageIds = await questionBankPersistence.PlatformContentPackageCellProjections.AsNoTracking()
.Where(item => item.CellId == cellId &&
packageIds.Contains(item.PlatformContentPackageVersionId) &&
item.Status == CellProjectionStatus.Ready)
.Select(item => item.PlatformContentPackageVersionId)
.ToArrayAsync(cancellationToken);
return links.Where(item => !item.PlatformContentPackageVersionId.HasValue ||
readyPackageIds.Contains(item.PlatformContentPackageVersionId.Value))
.ToArray();
}
private async Task<ProductManifestRelease[]> FilterLinksByCollectionAsync(
ProductManifestRelease[] links,
Guid collectionReleaseId,
CancellationToken cancellationToken)
{
var releaseIds = links.Select(item => item.ContentReleaseId).Distinct().ToArray();
var matches = new List<(Guid TenantId, Guid ContentReleaseId)>();
foreach (var ownerTenantId in links.Select(item => item.ContentOwnerTenantId).Distinct())
matches.AddRange(await ReadForContentOwnerAsync(
ownerTenantId,
"Resolve an authorized V2 collection release",
async (persistence, token) => (await persistence.CollectionReleases.AsNoTracking()
.Where(item => item.TenantId == ownerTenantId &&
item.Id == collectionReleaseId &&
releaseIds.Contains(item.ContentReleaseId) &&
item.Status == ContentReleaseStatus.Published)
.Select(item => new { item.TenantId, item.ContentReleaseId })
.ToArrayAsync(token))
.Select(item => (item.TenantId, item.ContentReleaseId)).ToArray(),
cancellationToken));
return links.Where(link => matches.Any(match =>
match.TenantId == link.ContentOwnerTenantId && match.ContentReleaseId == link.ContentReleaseId)).ToArray();
}
private async Task<ProductManifestRelease[]> FilterLinksByBlueprintAsync(
ProductManifestRelease[] links,
Guid blueprintReleaseId,
CancellationToken cancellationToken)
{
var releaseIds = links.Select(item => item.ContentReleaseId).Distinct().ToArray();
var matches = new List<(Guid TenantId, Guid ContentReleaseId)>();
foreach (var ownerTenantId in links.Select(item => item.ContentOwnerTenantId).Distinct())
matches.AddRange(await ReadForContentOwnerAsync(
ownerTenantId,
"Resolve an authorized V2 blueprint release",
async (persistence, token) => (await persistence.BlueprintReleases.AsNoTracking()
.Where(item => item.TenantId == ownerTenantId &&
item.Id == blueprintReleaseId &&
releaseIds.Contains(item.ContentReleaseId) &&
item.Status == ContentReleaseStatus.Published)
.Select(item => new { item.TenantId, item.ContentReleaseId })
.ToArrayAsync(token))
.Select(item => (item.TenantId, item.ContentReleaseId)).ToArray(),
cancellationToken));
return links.Where(link => matches.Any(match =>
match.TenantId == link.ContentOwnerTenantId && match.ContentReleaseId == link.ContentReleaseId)).ToArray();
}
private async Task<IReadOnlyCollection<Guid>?> ResolveCollectionReleaseIdsAsync(
Guid ownerTenantId,
AccessResourceType resourceType,
Guid resourceId,
CancellationToken cancellationToken)
{
if (resourceType == AccessResourceType.CollectionRelease) return [resourceId];
if (resourceType != AccessResourceType.BlueprintRelease) return null;
var collectionId = (await ReadForContentOwnerAsync(
ownerTenantId,
"Resolve the collection pinned by an authorized V2 blueprint",
(persistence, token) => persistence.BlueprintReleases.AsNoTracking()
.Where(item => item.TenantId == ownerTenantId && item.Id == resourceId)
.Select(item => item.CollectionReleaseId)
.ToArrayAsync(token),
cancellationToken)).SingleOrDefault();
return collectionId.HasValue ? [collectionId.Value] : null;
}
private async Task<V2Delivery[]> LoadV2DeliveriesAsync(
IReadOnlyCollection<ContentCandidateItem> candidates,
CancellationToken cancellationToken)
{
var revisions = new List<QuestionRevision>();
foreach (var ownerGroup in candidates.GroupBy(item => item.QuestionAssetOwnerTenantId))
{
var revisionIds = ownerGroup.Select(item => item.QuestionRevisionId).Distinct().ToArray();
revisions.AddRange(await ReadForContentOwnerAsync(
ownerGroup.Key,
"Load explicitly authorized immutable V2 question revisions",
(persistence, token) => persistence.QuestionRevisions.AsNoTracking()
.Where(item => item.TenantId == ownerGroup.Key && revisionIds.Contains(item.Id))
.ToArrayAsync(token),
cancellationToken));
}
var policies = new List<AssessmentPolicyVersion>();
var objectiveRules = new List<ObjectiveGradingRule>();
foreach (var ownerGroup in candidates.GroupBy(item => item.ContentOwnerTenantId))
{
var policyIds = ownerGroup.Select(item => item.AssessmentPolicyVersionId).Distinct().ToArray();
policies.AddRange(await ReadForContentOwnerAsync(
ownerGroup.Key,
"Load explicitly authorized immutable V2 assessment policies",
(persistence, token) => persistence.AssessmentPolicyVersions.AsNoTracking()
.Where(item => item.TenantId == ownerGroup.Key && policyIds.Contains(item.Id) &&
item.Status == ContentDefinitionStatus.Published)
.ToArrayAsync(token),
cancellationToken));
objectiveRules.AddRange(await ReadForContentOwnerAsync(
ownerGroup.Key,
"Load explicitly authorized immutable V2 objective rules",
(persistence, token) => persistence.ObjectiveGradingRules.AsNoTracking()
.Where(item => item.TenantId == ownerGroup.Key &&
policyIds.Contains(item.AssessmentPolicyVersionId))
.ToArrayAsync(token),
cancellationToken));
}
return candidates.Select(candidate =>
{
var revision = revisions.SingleOrDefault(item =>
item.TenantId == candidate.QuestionAssetOwnerTenantId &&
item.Id == candidate.QuestionRevisionId) ?? throw new LearningValidationException(
"practice_question_revision_missing", "An immutable question revision is missing.");
var policy = policies.SingleOrDefault(item =>
item.TenantId == candidate.ContentOwnerTenantId &&
item.Id == candidate.AssessmentPolicyVersionId) ?? throw new LearningValidationException(
"practice_assessment_policy_missing", "A published assessment policy is missing.");
return new V2Delivery(
candidate,
revision,
policy,
objectiveRules.Where(item =>
item.TenantId == candidate.ContentOwnerTenantId &&
item.AssessmentPolicyVersionId == policy.Id)
.OrderBy(item => item.Id)
.ToArray());
}).ToArray();
}
private Task<T[]> ReadForContentOwnerAsync<T>(
Guid ownerTenantId,
string reason,
Func<IQuestionBankPersistence, CancellationToken, Task<T[]>> read,
CancellationToken cancellationToken)
{
return tenantExecutionScope.ExecuteAsync(
new SystemScopeRequest(
ownerTenantId,
SystemScopeCallerType.PublicQuestionBank,
nameof(PracticeSessionService),
reason,
Guid.NewGuid().ToString("N")),
(provider, token) => read(provider.GetRequiredService<IQuestionBankPersistence>(), token),
cancellationToken);
}
private async Task<ExamPaperSpecificationVersion?> LoadBlueprintSpecificationAsync(
IReadOnlyCollection<ProductManifestRelease> links,
Guid blueprintReleaseId,
CancellationToken cancellationToken)
{
var owners = links.Select(item => item.ContentOwnerTenantId).Distinct().ToArray();
return await tenantExecutionScope.ExecuteAsync(
new SystemScopeRequest(
null,
SystemScopeCallerType.PublicQuestionBank,
nameof(PracticeSessionService),
"Load an explicitly authorized published blueprint specification",
Guid.NewGuid().ToString("N"),
true),
async (provider, token) =>
{
var persistence = provider.GetRequiredService<IQuestionBankPersistence>();
return await (
from blueprint in persistence.BlueprintReleases.AsNoTracking()
join specification in persistence.ExamPaperSpecificationVersions.AsNoTracking()
on new
{
blueprint.TenantId,
Id = blueprint.ExamPaperSpecificationVersionId
}
equals new { specification.TenantId, specification.Id }
where owners.Contains(blueprint.TenantId) &&
blueprint.Id == blueprintReleaseId &&
blueprint.Status == ContentReleaseStatus.Published &&
specification.Status == ContentDefinitionStatus.Published
select specification)
.SingleOrDefaultAsync(token);
},
cancellationToken);
}
public async Task<Dictionary<Guid, QuestionSolutionItem>> LoadSolutionsAsync(
IReadOnlyCollection<PracticeSessionQuestion> sessionQuestions,
CancellationToken cancellationToken)
{
if (sessionQuestions.Count == 0) return [];
var revisions = new List<QuestionRevision>();
foreach (var ownerGroup in sessionQuestions.GroupBy(item => item.QuestionOwnerTenantId))
{
var revisionIds = ownerGroup.Select(item => item.QuestionRevisionId!.Value).Distinct().ToArray();
revisions.AddRange(await ReadForContentOwnerAsync(
ownerGroup.Key,
"Reveal explicitly locked V2 question solutions",
(persistence, token) => persistence.QuestionRevisions.AsNoTracking()
.Where(item => item.TenantId == ownerGroup.Key && revisionIds.Contains(item.Id))
.ToArrayAsync(token),
cancellationToken));
}
return sessionQuestions.ToDictionary(
item => item.Id,
item =>
{
var revision = revisions.Single(version =>
version.TenantId == item.QuestionOwnerTenantId &&
version.Id == item.QuestionRevisionId);
return new QuestionSolutionItem(
revision.CorrectOptionIndex,
revision.CorrectOptionIndices,
revision.AnswerText,
revision.Explanation);
});
}
private static JsonElement BuildV2GradingSnapshot(V2Delivery delivery)
{
return JsonSerializer.SerializeToElement(new
{
version = 2,
assessmentPolicyVersionId = delivery.Policy.Id,
gradingMode = delivery.Policy.GradingMode,
roundingScale = delivery.Policy.RoundingScale,
correctOptionIndex = delivery.Revision.CorrectOptionIndex,
correctOptionIndices = delivery.Revision.CorrectOptionIndices,
answerText = delivery.Revision.AnswerText,
objectiveRules = delivery.ObjectiveRules.Select(item => new
{
item.RuleType,
item.Configuration
}).ToArray()
});
}
private static int StableSeed(Guid userId, AccessResourceType resourceType, Guid resourceId)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes($"{userId:N}:{resourceType}:{resourceId:N}"));
return BinaryPrimitives.ReadInt32LittleEndian(hash);
}
private static T[] Rotate<T>(T[] values, int seed)
{
if (values.Length <= 1) return values;
var start = (int)((uint)seed % (uint)values.Length);
return values.Skip(start).Concat(values.Take(start)).ToArray();
}
private sealed record V2Grant(
Guid[] ManifestVersionIds,
Guid? StudentEntitlementId,
Guid? ClassAssignmentGrantId,
int? DailyQuestionLimit,
int? TotalQuestionLimit);
private sealed record V2Delivery(
ContentCandidateItem Candidate,
QuestionRevision Revision,
AssessmentPolicyVersion Policy,
ObjectiveGradingRule[] ObjectiveRules);
}