543 lines
25 KiB
C#
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);
|
|
}
|
|
}
|