644 lines
33 KiB
C#
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);
|
|
}
|