Files
tiku-backend.net/Tiku.Infrastructure/ContentV2/ContentV2AuthoringService.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

543 lines
25 KiB
C#

using System.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Application.Content;
using Tiku.Application.Security;
using Tiku.Domain.Content;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.ContentV2;
internal sealed class ContentV2AuthoringService(
IQuestionBankPersistence content,
ITenantExecutionScope tenantExecutionScope) : IContentV2AuthoringService
{
private const int MaxImportRows = 2000;
public async Task<QuestionDuplicateSearchResult> SearchDuplicatesAsync(
ContentAuthorActor actor,
QuestionRevisionDraft draft,
int limit = 20,
CancellationToken cancellationToken = default)
{
ValidateDraft(draft);
var fingerprint = Fingerprint(draft);
var platformOwnerId = await GetPlatformOwnerIdAsync(cancellationToken);
var ownerIds = new[] { actor.TenantId, platformOwnerId }.Distinct().ToArray();
var normalized = QuestionDeliveryFingerprint.NormalizePrompt(draft.Content);
var take = Math.Clamp(limit, 1, 100);
var exact = new List<QuestionDuplicateCandidate>();
foreach (var ownerTenantId in ownerIds)
exact.AddRange(await SearchOwnerAsync(
actor.TenantId,
ownerTenantId,
fingerprint,
normalized,
true,
take,
[],
cancellationToken));
if (normalized.Length < 3 || exact.Count >= take)
return new QuestionDuplicateSearchResult(fingerprint, exact.Take(take).ToArray());
var exactAssetIds = exact.Select(item => item.QuestionAssetId).ToArray();
var similar = new List<QuestionDuplicateCandidate>();
foreach (var ownerTenantId in ownerIds)
{
var remaining = take - exact.Count - similar.Count;
if (remaining <= 0) break;
similar.AddRange(await SearchOwnerAsync(
actor.TenantId,
ownerTenantId,
fingerprint,
normalized,
false,
remaining,
exactAssetIds,
cancellationToken));
}
return new QuestionDuplicateSearchResult(
fingerprint,
exact.Concat(similar.OrderByDescending(item => item.PromptSimilarity).ThenBy(item => item.QuestionAssetId))
.Take(take).ToArray());
}
public async Task<QuestionAuthoringResult> ExecuteAsync(
ContentAuthorActor actor,
ExecuteQuestionAuthoringCommand command,
CancellationToken cancellationToken = default)
{
ValidateDraft(command.Draft);
var fingerprint = Fingerprint(command.Draft);
if (command.Decision is QuestionImportDecision.Skip or QuestionImportDecision.ManualReview)
return new QuestionAuthoringResult(
command.Decision, null, null, null, fingerprint,
command.Decision == QuestionImportDecision.Skip ? "skipped" : "manual_review");
if (command.Decision is QuestionImportDecision.ReusePlatform or QuestionImportDecision.ReuseTenant)
return await ResolveReuseAsync(actor, command, fingerprint, cancellationToken);
if (command.Decision is not (QuestionImportDecision.CreateNew or QuestionImportDecision.CreateVariant))
throw Error("question_authoring_decision_invalid", "The question authoring decision is invalid.");
if (command.Decision == QuestionImportDecision.CreateVariant && !command.FamilyId.HasValue)
throw Error("question_family_required", "A variant must belong to a question family.");
await using var transaction = await content.Database.BeginTransactionAsync(
IsolationLevel.ReadCommitted,
cancellationToken);
var lockKey = $"question-fingerprint:{actor.TenantId:N}:{fingerprint}";
await content.Database.ExecuteSqlInterpolatedAsync(
$"SELECT pg_advisory_xact_lock(hashtextextended({lockKey}, 0))",
cancellationToken);
if (await content.QuestionAssets.AnyAsync(item =>
item.TenantId == actor.TenantId && item.DeliveryFingerprint == fingerprint,
cancellationToken))
throw Error("question_exact_duplicate_exists",
"An exact tenant question already exists; choose reuse_tenant instead.");
if (command.FamilyId.HasValue && !await content.QuestionFamilies.AnyAsync(item =>
item.TenantId == actor.TenantId && item.Id == command.FamilyId.Value,
cancellationToken))
throw Error("question_family_not_found", "The question family was not found in this tenant.");
var asset = new QuestionAsset
{
TenantId = actor.TenantId,
FamilyId = command.FamilyId,
DeliveryFingerprint = fingerprint,
Status = command.PublishAsset ? QuestionAssetStatus.Published : QuestionAssetStatus.Draft,
CreatedBy = actor.UserId,
UpdatedBy = actor.UserId
};
content.QuestionAssets.Add(asset);
await content.SaveChangesAsync(cancellationToken);
var revision = BuildRevision(actor, asset.Id, 1, command.Draft, fingerprint);
content.QuestionRevisions.Add(revision);
await content.SaveChangesAsync(cancellationToken);
asset.CurrentRevisionId = revision.Id;
await content.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return Result(command.Decision, asset, revision);
}
public async Task<QuestionAuthoringResult> CreateRevisionAsync(
ContentAuthorActor actor,
Guid questionAssetId,
QuestionRevisionDraft draft,
bool publishAsset,
CancellationToken cancellationToken = default)
{
ValidateDraft(draft);
var fingerprint = Fingerprint(draft);
await using var transaction = await content.Database.BeginTransactionAsync(cancellationToken);
var lockKey = $"question-asset:{actor.TenantId:N}:{questionAssetId:N}";
await content.Database.ExecuteSqlInterpolatedAsync(
$"SELECT pg_advisory_xact_lock(hashtextextended({lockKey}, 0))",
cancellationToken);
var asset = await content.QuestionAssets.SingleOrDefaultAsync(item =>
item.TenantId == actor.TenantId && item.Id == questionAssetId,
cancellationToken) ?? throw Error("question_asset_not_found", "The tenant question asset was not found.");
if (asset.Status == QuestionAssetStatus.Archived)
throw Error("question_asset_archived", "An archived question asset cannot receive a new revision.");
if (await content.QuestionAssets.AnyAsync(item =>
item.TenantId == actor.TenantId &&
item.Id != asset.Id &&
item.DeliveryFingerprint == fingerprint,
cancellationToken))
throw Error("question_exact_duplicate_exists", "Another exact tenant question already exists.");
var nextRevision = (await content.QuestionRevisions
.Where(item => item.TenantId == actor.TenantId && item.QuestionAssetId == asset.Id)
.Select(item => (int?)item.RevisionNo)
.MaxAsync(cancellationToken) ?? 0) + 1;
var revision = BuildRevision(actor, asset.Id, nextRevision, draft, fingerprint);
asset.CurrentRevisionId = revision.Id;
asset.DeliveryFingerprint = fingerprint;
asset.Status = publishAsset ? QuestionAssetStatus.Published : QuestionAssetStatus.Draft;
asset.UpdatedBy = actor.UserId;
content.QuestionRevisions.Add(revision);
await content.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return Result(QuestionImportDecision.CreateNew, asset, revision);
}
public async Task<QuestionPlacementItem> CreatePlacementAsync(
ContentAuthorActor actor,
CreateQuestionPlacementCommand command,
CancellationToken cancellationToken = default)
{
if (command.RuleGroups.GroupBy(item => item.GroupOrder).Any(group => group.Count() > 1) ||
command.RuleGroups.Any(group => group.Conditions.Count == 0))
throw Error("placement_rule_invalid", "Rule group order must be unique and every group must have conditions.");
var platformOwnerId = await GetPlatformOwnerIdAsync(cancellationToken);
if (command.QuestionAssetOwnerTenantId != actor.TenantId &&
command.QuestionAssetOwnerTenantId != platformOwnerId)
throw Error("question_asset_owner_forbidden",
"A placement may reference only this tenant's private question or the platform public owner.");
var asset = await ReadForOwnerAsync(
command.QuestionAssetOwnerTenantId,
"Validate an explicitly selected reusable V2 question asset",
(persistence, token) => persistence.QuestionAssets.AsNoTracking().SingleOrDefaultAsync(item =>
item.TenantId == command.QuestionAssetOwnerTenantId &&
item.Id == command.QuestionAssetId &&
item.Status == QuestionAssetStatus.Published,
token),
cancellationToken) ?? throw Error("question_asset_not_found", "A published reusable question was not found.");
var curriculumNodeValid = await (
from version in content.CurriculumVersions.AsNoTracking()
join node in content.CurriculumNodes.AsNoTracking()
on new { version.TenantId, VersionId = version.Id }
equals new { node.TenantId, VersionId = node.CurriculumVersionId }
where version.TenantId == actor.TenantId &&
version.Id == command.CurriculumVersionId &&
node.Id == command.CurriculumNodeId &&
version.Status != ContentDefinitionStatus.Retired
select node.Id)
.AnyAsync(cancellationToken);
if (!curriculumNodeValid)
throw Error("curriculum_node_not_found", "The curriculum node does not belong to the requested version.");
if (!await content.AssessmentPolicyVersions.AsNoTracking().AnyAsync(item =>
item.TenantId == actor.TenantId &&
item.Id == command.AssessmentPolicyVersionId &&
item.Status != ContentDefinitionStatus.Retired,
cancellationToken))
throw Error("assessment_policy_not_found", "The assessment policy version was not found.");
var conceptIds = command.KnowledgeConceptIds.Distinct().ToArray();
if (await content.KnowledgeConcepts.CountAsync(item =>
item.TenantId == actor.TenantId && conceptIds.Contains(item.Id) && item.IsActive,
cancellationToken) != conceptIds.Length)
throw Error("knowledge_concept_not_found", "One or more knowledge concepts were not found.");
var conditions = command.RuleGroups.SelectMany(group => group.Conditions).ToArray();
await ValidateConditionsAsync(conditions, cancellationToken);
var placement = new QuestionPlacement
{
TenantId = actor.TenantId,
QuestionAssetOwnerTenantId = asset.TenantId,
QuestionAssetId = asset.Id,
CurriculumVersionId = command.CurriculumVersionId,
CurriculumNodeId = command.CurriculumNodeId,
AssessmentPolicyVersionId = command.AssessmentPolicyVersionId,
DifficultyOverride = command.DifficultyOverride,
Status = command.Activate ? QuestionPlacementStatus.Active : QuestionPlacementStatus.Draft,
CreatedBy = actor.UserId,
UpdatedBy = actor.UserId
};
content.QuestionPlacements.Add(placement);
foreach (var conceptId in conceptIds)
content.QuestionPlacementConcepts.Add(new QuestionPlacementConcept
{
TenantId = actor.TenantId,
PlacementId = placement.Id,
KnowledgeConceptId = conceptId
});
foreach (var groupCommand in command.RuleGroups.OrderBy(item => item.GroupOrder))
{
var group = new QuestionPlacementRuleGroup
{
TenantId = actor.TenantId,
PlacementId = placement.Id,
GroupOrder = groupCommand.GroupOrder
};
content.QuestionPlacementRuleGroups.Add(group);
foreach (var condition in groupCommand.Conditions)
content.QuestionPlacementRuleConditions.Add(new QuestionPlacementRuleCondition
{
TenantId = actor.TenantId,
RuleGroupId = group.Id,
TargetDimensionDefinitionId = condition.TargetDimensionDefinitionId,
TargetNodeId = condition.TargetNodeId,
Operator = condition.Operator
});
}
await content.SaveChangesAsync(cancellationToken);
return new QuestionPlacementItem(
placement.Id,
placement.QuestionAssetOwnerTenantId,
placement.QuestionAssetId,
placement.CurriculumVersionId,
placement.CurriculumNodeId,
placement.AssessmentPolicyVersionId,
placement.Status.ToString());
}
public async Task<QuestionImportPreviewResult> PreviewImportAsync(
ContentAuthorActor actor,
IReadOnlyCollection<(string RowKey, QuestionRevisionDraft Draft)> rows,
CancellationToken cancellationToken = default)
{
ValidateImportRows(rows.Select(item => item.RowKey).ToArray());
var result = new List<QuestionImportPreviewRow>(rows.Count);
foreach (var row in rows)
{
var duplicates = await SearchDuplicatesAsync(actor, row.Draft, 20, cancellationToken);
var exact = duplicates.Candidates.Where(item => item.ExactDeliveryMatch).ToArray();
var suggested = exact.Any(item => item.Source == "tenant")
? QuestionImportDecision.ReuseTenant
: exact.Any(item => item.Source == "platform")
? QuestionImportDecision.ReusePlatform
: duplicates.Candidates.Count > 0
? QuestionImportDecision.ManualReview
: QuestionImportDecision.CreateNew;
result.Add(new QuestionImportPreviewRow(
row.RowKey.Trim(),
duplicates.DeliveryFingerprint,
suggested,
duplicates.Candidates));
}
return new QuestionImportPreviewResult(result.Count, result);
}
public async Task<QuestionImportExecutionResult> ExecuteImportAsync(
ContentAuthorActor actor,
IReadOnlyCollection<ExecuteQuestionImportRow> rows,
CancellationToken cancellationToken = default)
{
ValidateImportRows(rows.Select(item => item.RowKey).ToArray());
var result = new List<QuestionImportExecutionRow>(rows.Count);
foreach (var row in rows)
result.Add(new QuestionImportExecutionRow(
row.RowKey.Trim(),
await ExecuteAsync(actor, row.Command, cancellationToken)));
return new QuestionImportExecutionResult(result.Count, result);
}
private async Task<QuestionAuthoringResult> ResolveReuseAsync(
ContentAuthorActor actor,
ExecuteQuestionAuthoringCommand command,
string fingerprint,
CancellationToken cancellationToken)
{
var ownerId = command.Decision == QuestionImportDecision.ReuseTenant
? actor.TenantId
: await GetPlatformOwnerIdAsync(cancellationToken);
var asset = await ReadForOwnerAsync(
ownerId,
"Resolve an explicitly selected exact V2 question reuse",
async (persistence, token) =>
{
var query = persistence.QuestionAssets.AsNoTracking().Where(item =>
item.TenantId == ownerId &&
item.DeliveryFingerprint == fingerprint &&
item.Status == QuestionAssetStatus.Published &&
item.CurrentRevisionId != null);
if (command.ReuseQuestionAssetId.HasValue)
query = query.Where(item => item.Id == command.ReuseQuestionAssetId.Value);
return await query.SingleOrDefaultAsync(token);
},
cancellationToken) ?? throw Error(
"question_exact_reuse_not_found",
"The selected exact reusable question was not found in the requested owner scope.");
return new QuestionAuthoringResult(
command.Decision,
asset.TenantId,
asset.Id,
asset.CurrentRevisionId,
asset.DeliveryFingerprint,
"reused");
}
private async Task ValidateConditionsAsync(
IReadOnlyCollection<PlacementConditionCommand> conditions,
CancellationToken cancellationToken)
{
if (conditions.Count == 0) return;
if (conditions.GroupBy(item => new
{
item.TargetDimensionDefinitionId,
item.TargetNodeId,
item.Operator
}).Any(group => group.Count() > 1))
throw Error("placement_rule_duplicate", "A placement rule contains duplicate conditions.");
var nodeIds = conditions.Select(item => item.TargetNodeId).Distinct().ToArray();
var nodes = await content.TargetNodes.AsNoTracking()
.Where(item => nodeIds.Contains(item.Id) && item.IsActive)
.Select(item => new { item.Id, item.TargetDimensionDefinitionId })
.ToArrayAsync(cancellationToken);
if (conditions.Any(condition => !nodes.Any(node =>
node.Id == condition.TargetNodeId &&
node.TargetDimensionDefinitionId == condition.TargetDimensionDefinitionId)))
throw Error("placement_rule_target_invalid", "A target node does not belong to its declared dimension.");
}
private async Task<Guid> GetPlatformOwnerIdAsync(CancellationToken cancellationToken)
{
var ids = await tenantExecutionScope.ExecuteAsync(
new SystemScopeRequest(
null,
SystemScopeCallerType.Platform,
nameof(ContentV2AuthoringService),
"Resolve the unique platform V2 content owner",
Guid.NewGuid().ToString("N"),
true),
(provider, token) => provider.GetRequiredService<ITenancyPersistence>().Tenants.AsNoTracking()
.Where(item => item.Mode == TenantMode.PlatformOwned)
.Select(item => item.Id)
.Take(2)
.ToArrayAsync(token),
cancellationToken);
return ids.Length == 1
? ids[0]
: throw Error("platform_content_owner_invalid",
"Exactly one platform-owned content tenant is required.");
}
private static QuestionRevision BuildRevision(
ContentAuthorActor actor,
Guid assetId,
int revisionNo,
QuestionRevisionDraft draft,
string fingerprint)
{
return new QuestionRevision
{
TenantId = actor.TenantId,
QuestionAssetId = assetId,
RevisionNo = revisionNo,
QuestionType = draft.QuestionType.Trim(),
TypeLabel = draft.TypeLabel?.Trim(),
Difficulty = draft.Difficulty,
NormalizedContent = QuestionDeliveryFingerprint.NormalizePrompt(draft.Content),
Content = draft.Content,
Options = draft.Options.Clone(),
CorrectOptionIndex = draft.CorrectOptionIndex,
CorrectOptionIndices = draft.CorrectOptionIndices.Clone(),
AnswerText = draft.AnswerText,
Explanation = draft.Explanation,
SubQuestions = draft.SubQuestions.Clone(),
CodeLang = draft.CodeLang?.Trim(),
CodeTemplate = draft.CodeTemplate,
DeliveryFingerprint = fingerprint,
CreatedBy = actor.UserId
};
}
private static QuestionAuthoringResult Result(
QuestionImportDecision decision,
QuestionAsset asset,
QuestionRevision revision)
{
return new QuestionAuthoringResult(
decision,
asset.TenantId,
asset.Id,
revision.Id,
asset.DeliveryFingerprint,
asset.Status.ToString());
}
private static string Fingerprint(QuestionRevisionDraft draft)
{
return QuestionDeliveryFingerprint.Compute(new QuestionDeliveryFingerprintInput(
draft.QuestionType,
draft.Content,
draft.Options,
draft.CorrectOptionIndex,
draft.CorrectOptionIndices,
draft.AnswerText,
draft.SubQuestions,
draft.CodeLang,
draft.CodeTemplate));
}
private static void ValidateDraft(QuestionRevisionDraft draft)
{
if (string.IsNullOrWhiteSpace(draft.QuestionType))
throw Error("question_type_required", "Question type is required.");
if (string.IsNullOrWhiteSpace(draft.Content) && string.IsNullOrWhiteSpace(draft.CodeTemplate))
throw Error("question_content_required", "Question content or a code template is required.");
if (draft.Difficulty is < 1 or > 5)
throw Error("question_difficulty_invalid", "Question difficulty must be between 1 and 5.");
}
private static void ValidateImportRows(IReadOnlyCollection<string> rowKeys)
{
if (rowKeys.Count == 0 || rowKeys.Count > MaxImportRows)
throw Error("question_import_size_invalid",
$"An import must contain between 1 and {MaxImportRows} rows.");
if (rowKeys.Any(string.IsNullOrWhiteSpace) ||
rowKeys.Select(item => item.Trim()).Distinct(StringComparer.Ordinal).Count() != rowKeys.Count)
throw Error("question_import_row_key_invalid", "Import row keys must be non-empty and unique.");
}
private static ContentV2Exception Error(string code, string message) => new(code, message);
private Task<T> ReadForOwnerAsync<T>(
Guid ownerTenantId,
string reason,
Func<IQuestionBankPersistence, CancellationToken, Task<T>> read,
CancellationToken cancellationToken)
{
return tenantExecutionScope.ExecuteAsync(
new SystemScopeRequest(
ownerTenantId,
SystemScopeCallerType.PublicQuestionBank,
nameof(ContentV2AuthoringService),
reason,
Guid.NewGuid().ToString("N")),
(provider, token) => read(provider.GetRequiredService<IQuestionBankPersistence>(), token),
cancellationToken);
}
private Task<QuestionDuplicateCandidate[]> SearchOwnerAsync(
Guid actorTenantId,
Guid ownerTenantId,
string fingerprint,
string normalized,
bool exact,
int limit,
IReadOnlyCollection<Guid> excludedAssetIds,
CancellationToken cancellationToken)
{
var excluded = excludedAssetIds.ToArray();
return ReadForOwnerAsync(
ownerTenantId,
exact ? "Search exact V2 question duplicates" : "Search similar V2 question candidates",
async (persistence, token) =>
{
var query =
from asset in persistence.QuestionAssets.AsNoTracking()
join revision in persistence.QuestionRevisions.AsNoTracking()
on new { asset.TenantId, QuestionAssetId = asset.Id, Id = asset.CurrentRevisionId }
equals new
{
revision.TenantId,
revision.QuestionAssetId,
Id = (Guid?)revision.Id
}
where asset.TenantId == ownerTenantId &&
!excluded.Contains(asset.Id) &&
asset.Status != QuestionAssetStatus.Archived
select new { Asset = asset, Revision = revision };
if (exact)
query = query.Where(item => item.Asset.DeliveryFingerprint == fingerprint);
else
query = query.Where(item =>
EF.Functions.TrigramsAreSimilar(item.Revision.NormalizedContent, normalized));
return await query
.OrderByDescending(item => exact
? 1
: EF.Functions.TrigramsSimilarity(item.Revision.NormalizedContent, normalized))
.ThenBy(item => item.Asset.Id)
.Select(item => new QuestionDuplicateCandidate(
item.Asset.TenantId,
item.Asset.Id,
item.Revision.Id,
item.Asset.TenantId == actorTenantId ? "tenant" : "platform",
exact,
exact ? 1 : EF.Functions.TrigramsSimilarity(item.Revision.NormalizedContent, normalized),
item.Revision.QuestionType,
item.Revision.Content,
item.Revision.Difficulty))
.Take(limit)
.ToArrayAsync(token);
},
cancellationToken);
}
}