Consolidate story intelligence review module loads
This commit is contained in:
parent
a1cba8a057
commit
d6d52b09e3
@ -279,6 +279,7 @@ var tests = new (string Name, Action Test)[]
|
|||||||
("Story Intelligence heavy review modules offer bounded rendering", StoryIntelligenceHeavyReviewModulesOfferBoundedRendering),
|
("Story Intelligence heavy review modules offer bounded rendering", StoryIntelligenceHeavyReviewModulesOfferBoundedRendering),
|
||||||
("Story Intelligence Review Centre adds no AI calls", StoryIntelligenceReviewCentreAddsNoAiCalls),
|
("Story Intelligence Review Centre adds no AI calls", StoryIntelligenceReviewCentreAddsNoAiCalls),
|
||||||
("Story Intelligence Review Centre uses bounded summary query", StoryIntelligenceReviewCentreUsesBoundedSummaryQuery),
|
("Story Intelligence Review Centre uses bounded summary query", StoryIntelligenceReviewCentreUsesBoundedSummaryQuery),
|
||||||
|
("Story Intelligence detailed review GETs use selected module paths", StoryIntelligenceDetailedReviewGetsUseSelectedModulePaths),
|
||||||
("Story Intelligence Job 6 uses bounded concurrent chapter execution", StoryIntelligenceJob6UsesBoundedConcurrentChapterExecution),
|
("Story Intelligence Job 6 uses bounded concurrent chapter execution", StoryIntelligenceJob6UsesBoundedConcurrentChapterExecution),
|
||||||
("Story Intelligence global progress uses whole run scene totals", StoryIntelligenceGlobalProgressUsesWholeRunSceneTotals),
|
("Story Intelligence global progress uses whole run scene totals", StoryIntelligenceGlobalProgressUsesWholeRunSceneTotals),
|
||||||
("Story Intelligence recovered provider failures stay operational", StoryIntelligenceRecoveredProviderFailuresStayOperational),
|
("Story Intelligence recovered provider failures stay operational", StoryIntelligenceRecoveredProviderFailuresStayOperational),
|
||||||
@ -1059,11 +1060,11 @@ static void StoryIntelligenceReviewCentreUsesBoundedSummaryQuery()
|
|||||||
var viewModel = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs"));
|
var viewModel = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs"));
|
||||||
var migration = File.ReadAllText(Path.Combine(root, "Sql/176_Phase27C_StoryIntelligenceReviewCentreSummary.sql"));
|
var migration = File.ReadAllText(Path.Combine(root, "Sql/176_Phase27C_StoryIntelligenceReviewCentreSummary.sql"));
|
||||||
var centreAction = ExtractBetween(controller, "public async Task<IActionResult> StoryIntelligenceReviewCentre", "[HttpGet(\"story-intelligence/characters\")]");
|
var centreAction = ExtractBetween(controller, "public async Task<IActionResult> StoryIntelligenceReviewCentre", "[HttpGet(\"story-intelligence/characters\")]");
|
||||||
var centreSummary = ExtractBetween(service, "public async Task<StoryIntelligenceProgressViewModel?> GetReviewCentreAsync", "public async Task<StoryIntelligenceProgressViewModel?> CancelAsync");
|
var centreSummary = ExtractBetween(service, "public async Task<StoryIntelligenceProgressViewModel?> GetReviewCentreAsync", "public Task<StoryIntelligenceProgressViewModel?> GetCharacterReviewAsync");
|
||||||
|
|
||||||
Assert(centreAction.Contains("GetReviewCentreAsync(batchId)", StringComparison.Ordinal), "Review Centre GET should use the bounded summary path.");
|
Assert(centreAction.Contains("GetReviewCentreAsync(batchId)", StringComparison.Ordinal), "Review Centre GET should use the bounded summary path.");
|
||||||
Assert(!centreAction.Contains("GetProgressAsync(batchId)", StringComparison.Ordinal), "Review Centre GET should not build the full progress/candidate model.");
|
Assert(!centreAction.Contains("GetProgressAsync(batchId)", StringComparison.Ordinal), "Review Centre GET should not build the full progress/candidate model.");
|
||||||
Assert(centreSummary.Contains("GetReviewCentreSummaryAsync(batch.BookID, batch.UserID)", StringComparison.Ordinal), "Review Centre summary should use one aggregate DB read.");
|
Assert(centreSummary.Contains("GetReviewFrameAsync(batchId)", StringComparison.Ordinal), "Review Centre summary should use the shared aggregate review frame.");
|
||||||
Assert(!centreSummary.Contains("BuildReviewAsync", StringComparison.Ordinal), "Review Centre summary should not call detailed module candidate builders.");
|
Assert(!centreSummary.Contains("BuildReviewAsync", StringComparison.Ordinal), "Review Centre summary should not call detailed module candidate builders.");
|
||||||
Assert(!centreSummary.Contains("EnsureCandidatesAsync", StringComparison.Ordinal), "Review Centre summary should not generate narrative candidates.");
|
Assert(!centreSummary.Contains("EnsureCandidatesAsync", StringComparison.Ordinal), "Review Centre summary should not generate narrative candidates.");
|
||||||
Assert(!centreSummary.Contains("ListSceneResultsAsync", StringComparison.Ordinal), "Review Centre summary should not parse scene intelligence results.");
|
Assert(!centreSummary.Contains("ListSceneResultsAsync", StringComparison.Ordinal), "Review Centre summary should not parse scene intelligence results.");
|
||||||
@ -1076,6 +1077,61 @@ static void StoryIntelligenceReviewCentreUsesBoundedSummaryQuery()
|
|||||||
Assert(viewModel.Contains("SummaryNeedsAttentionCount", StringComparison.Ordinal), "Tile counts should support lightweight summary values without full candidate objects.");
|
Assert(viewModel.Contains("SummaryNeedsAttentionCount", StringComparison.Ordinal), "Tile counts should support lightweight summary values without full candidate objects.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void StoryIntelligenceDetailedReviewGetsUseSelectedModulePaths()
|
||||||
|
{
|
||||||
|
var root = PlotLineRoot();
|
||||||
|
var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs"));
|
||||||
|
var service = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs"));
|
||||||
|
|
||||||
|
var expectedActions = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["StoryIntelligenceCharacters"] = "GetCharacterReviewAsync(batchId)",
|
||||||
|
["StoryIntelligenceLocations"] = "GetLocationReviewAsync(batchId)",
|
||||||
|
["StoryIntelligenceAssets"] = "GetAssetReviewAsync(batchId)",
|
||||||
|
["StoryIntelligenceRelationships"] = "GetRelationshipReviewAsync(batchId)",
|
||||||
|
["StoryIntelligenceKnowledge"] = "GetKnowledgeReviewAsync(batchId)",
|
||||||
|
["StoryIntelligenceNarrative"] = "GetNarrativeReviewAsync(batchId)"
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var (actionName, serviceCall) in expectedActions)
|
||||||
|
{
|
||||||
|
var action = ExtractAction(controller, $"public async Task<IActionResult> {actionName}");
|
||||||
|
Assert(action.Contains(serviceCall, StringComparison.Ordinal), $"{actionName} should load only its selected review module.");
|
||||||
|
Assert(!action.Contains("GetProgressAsync(batchId)", StringComparison.Ordinal), $"{actionName} should not build full Story Intelligence progress.");
|
||||||
|
Assert(!action.Contains("ResumeBookAsync", StringComparison.Ordinal), $"{actionName} should not resume/rebuild book analysis.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedReview = ExtractBetween(service, "private async Task<StoryIntelligenceProgressViewModel?> GetSelectedReviewAsync", "private async Task<StoryIntelligenceReviewFrame?> GetReviewFrameAsync");
|
||||||
|
Assert(selectedReview.Contains("GetReviewFrameAsync(batchId)", StringComparison.Ordinal), "Selected module loads should start from the compact persisted review frame.");
|
||||||
|
Assert(!selectedReview.Contains("GetProgressAsync", StringComparison.Ordinal), "Selected module loads should not call full progress.");
|
||||||
|
Assert(!selectedReview.Contains("ListSceneResultsAsync", StringComparison.Ordinal), "Selected module orchestration should not read all retained scene JSON.");
|
||||||
|
Assert(!selectedReview.Contains("EnsureCandidatesAsync", StringComparison.Ordinal), "Selected module orchestration should not regenerate narrative candidates.");
|
||||||
|
Assert(!selectedReview.Contains("ResumeBookAsync", StringComparison.Ordinal), "Selected module orchestration should not resume book analysis.");
|
||||||
|
|
||||||
|
var frame = ExtractBetween(service, "private async Task<StoryIntelligenceReviewFrame?> GetReviewFrameAsync", "private sealed record StoryIntelligenceReviewFrame");
|
||||||
|
Assert(frame.Contains("GetReviewCentreSummaryAsync(batch.BookID, batch.UserID)", StringComparison.Ordinal), "Selected module frame should use the persisted Review Centre summary.");
|
||||||
|
Assert(!frame.Contains("BuildReviewAsync", StringComparison.Ordinal), "Selected module frame should not build unrelated review candidates.");
|
||||||
|
Assert(!frame.Contains("ListSceneResultsAsync", StringComparison.Ordinal), "Selected module frame should not parse retained scene JSON.");
|
||||||
|
Assert(!frame.Contains("EnsureCandidatesAsync", StringComparison.Ordinal), "Selected module frame should not generate candidates.");
|
||||||
|
|
||||||
|
var narrativeMethod = ExtractBetween(service, "public Task<StoryIntelligenceProgressViewModel?> GetNarrativeReviewAsync", "private async Task<StoryIntelligenceProgressViewModel?> GetSelectedReviewAsync");
|
||||||
|
Assert(narrativeMethod.Contains("narrativeReview.GetReviewAsync(frame.Batch)", StringComparison.Ordinal), "Plot Lines & Threads should use persisted narrative candidates.");
|
||||||
|
Assert(!narrativeMethod.Contains("EnsureCandidatesAsync", StringComparison.Ordinal), "Plot Lines & Threads GET should not generate narrative candidates.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static string ExtractAction(string value, string signature)
|
||||||
|
{
|
||||||
|
var start = value.IndexOf(signature, StringComparison.Ordinal);
|
||||||
|
Assert(start >= 0, $"Could not find action signature {signature}.");
|
||||||
|
var next = value.IndexOf(" [Http", start + signature.Length, StringComparison.Ordinal);
|
||||||
|
if (next < 0)
|
||||||
|
{
|
||||||
|
next = value.IndexOf(" private ", start + signature.Length, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next < 0 ? value[start..] : value[start..next];
|
||||||
|
}
|
||||||
|
|
||||||
static string PlotLineRoot()
|
static string PlotLineRoot()
|
||||||
=> Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
=> Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||||
|
|
||||||
|
|||||||
@ -323,7 +323,7 @@ public sealed class OnboardingController(
|
|||||||
[HttpGet("story-intelligence/characters")]
|
[HttpGet("story-intelligence/characters")]
|
||||||
public async Task<IActionResult> StoryIntelligenceCharacters(Guid batchId)
|
public async Task<IActionResult> StoryIntelligenceCharacters(Guid batchId)
|
||||||
{
|
{
|
||||||
var model = await storyIntelligence.GetProgressAsync(batchId);
|
var model = await storyIntelligence.GetCharacterReviewAsync(batchId);
|
||||||
if (model is null)
|
if (model is null)
|
||||||
{
|
{
|
||||||
return NotFound();
|
return NotFound();
|
||||||
@ -347,7 +347,7 @@ public sealed class OnboardingController(
|
|||||||
[HttpGet("story-intelligence/locations")]
|
[HttpGet("story-intelligence/locations")]
|
||||||
public async Task<IActionResult> StoryIntelligenceLocations(Guid batchId)
|
public async Task<IActionResult> StoryIntelligenceLocations(Guid batchId)
|
||||||
{
|
{
|
||||||
var model = await storyIntelligence.GetProgressAsync(batchId);
|
var model = await storyIntelligence.GetLocationReviewAsync(batchId);
|
||||||
if (model is null)
|
if (model is null)
|
||||||
{
|
{
|
||||||
return NotFound();
|
return NotFound();
|
||||||
@ -371,7 +371,7 @@ public sealed class OnboardingController(
|
|||||||
[HttpGet("story-intelligence/assets")]
|
[HttpGet("story-intelligence/assets")]
|
||||||
public async Task<IActionResult> StoryIntelligenceAssets(Guid batchId)
|
public async Task<IActionResult> StoryIntelligenceAssets(Guid batchId)
|
||||||
{
|
{
|
||||||
var model = await storyIntelligence.GetProgressAsync(batchId);
|
var model = await storyIntelligence.GetAssetReviewAsync(batchId);
|
||||||
if (model is null)
|
if (model is null)
|
||||||
{
|
{
|
||||||
return NotFound();
|
return NotFound();
|
||||||
@ -398,7 +398,7 @@ public sealed class OnboardingController(
|
|||||||
[HttpGet("story-intelligence/relationships")]
|
[HttpGet("story-intelligence/relationships")]
|
||||||
public async Task<IActionResult> StoryIntelligenceRelationships(Guid batchId)
|
public async Task<IActionResult> StoryIntelligenceRelationships(Guid batchId)
|
||||||
{
|
{
|
||||||
var model = await storyIntelligence.GetProgressAsync(batchId);
|
var model = await storyIntelligence.GetRelationshipReviewAsync(batchId);
|
||||||
if (model is null)
|
if (model is null)
|
||||||
{
|
{
|
||||||
return NotFound();
|
return NotFound();
|
||||||
@ -555,7 +555,7 @@ public sealed class OnboardingController(
|
|||||||
[HttpGet("story-intelligence/knowledge")]
|
[HttpGet("story-intelligence/knowledge")]
|
||||||
public async Task<IActionResult> StoryIntelligenceKnowledge(Guid batchId)
|
public async Task<IActionResult> StoryIntelligenceKnowledge(Guid batchId)
|
||||||
{
|
{
|
||||||
var model = await storyIntelligence.GetProgressAsync(batchId);
|
var model = await storyIntelligence.GetKnowledgeReviewAsync(batchId);
|
||||||
if (model is null)
|
if (model is null)
|
||||||
{
|
{
|
||||||
return NotFound();
|
return NotFound();
|
||||||
@ -604,7 +604,7 @@ public sealed class OnboardingController(
|
|||||||
[HttpGet("story-intelligence/narrative")]
|
[HttpGet("story-intelligence/narrative")]
|
||||||
public async Task<IActionResult> StoryIntelligenceNarrative(Guid batchId)
|
public async Task<IActionResult> StoryIntelligenceNarrative(Guid batchId)
|
||||||
{
|
{
|
||||||
var model = await storyIntelligence.GetProgressAsync(batchId);
|
var model = await storyIntelligence.GetNarrativeReviewAsync(batchId);
|
||||||
if (model is null)
|
if (model is null)
|
||||||
{
|
{
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
|||||||
@ -13,6 +13,12 @@ public interface IOnboardingStoryIntelligenceService
|
|||||||
Task<OnboardingStoryIntelligenceBatch?> StartAsync(string? appearancePreference = null, string? primaryAgeGroup = null, string? storyEra = null, string? storyLocation = null);
|
Task<OnboardingStoryIntelligenceBatch?> StartAsync(string? appearancePreference = null, string? primaryAgeGroup = null, string? storyEra = null, string? storyLocation = null);
|
||||||
Task<StoryIntelligenceProgressViewModel?> GetProgressAsync(Guid batchId);
|
Task<StoryIntelligenceProgressViewModel?> GetProgressAsync(Guid batchId);
|
||||||
Task<StoryIntelligenceProgressViewModel?> GetReviewCentreAsync(Guid batchId);
|
Task<StoryIntelligenceProgressViewModel?> GetReviewCentreAsync(Guid batchId);
|
||||||
|
Task<StoryIntelligenceProgressViewModel?> GetCharacterReviewAsync(Guid batchId);
|
||||||
|
Task<StoryIntelligenceProgressViewModel?> GetLocationReviewAsync(Guid batchId);
|
||||||
|
Task<StoryIntelligenceProgressViewModel?> GetAssetReviewAsync(Guid batchId);
|
||||||
|
Task<StoryIntelligenceProgressViewModel?> GetRelationshipReviewAsync(Guid batchId);
|
||||||
|
Task<StoryIntelligenceProgressViewModel?> GetKnowledgeReviewAsync(Guid batchId);
|
||||||
|
Task<StoryIntelligenceProgressViewModel?> GetNarrativeReviewAsync(Guid batchId);
|
||||||
Task<StoryIntelligenceProgressViewModel?> CancelAsync(Guid batchId);
|
Task<StoryIntelligenceProgressViewModel?> CancelAsync(Guid batchId);
|
||||||
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAsync(Guid batchId, int runId);
|
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAsync(Guid batchId, int runId);
|
||||||
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAllAsync(Guid batchId);
|
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAllAsync(Guid batchId);
|
||||||
@ -724,6 +730,88 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
public async Task<StoryIntelligenceProgressViewModel?> GetReviewCentreAsync(Guid batchId)
|
public async Task<StoryIntelligenceProgressViewModel?> GetReviewCentreAsync(Guid batchId)
|
||||||
{
|
{
|
||||||
var totalStopwatch = Stopwatch.StartNew();
|
var totalStopwatch = Stopwatch.StartNew();
|
||||||
|
var frame = await GetReviewFrameAsync(batchId);
|
||||||
|
if (frame is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"Story Intelligence Review Centre summary built. BatchID={BatchID} ProjectID={ProjectID} BookID={BookID} SummaryMs={SummaryMs} TotalMs={TotalMs} ActiveRunCount={ActiveRunCount} NarrativeCandidates={NarrativeCandidateCount} NarrativePending={NarrativePendingCount}",
|
||||||
|
frame.Batch.BatchID,
|
||||||
|
frame.Summary.ProjectID,
|
||||||
|
frame.Summary.BookID,
|
||||||
|
frame.SummaryElapsedMs,
|
||||||
|
totalStopwatch.ElapsedMilliseconds,
|
||||||
|
frame.Summary.ActiveRunCount,
|
||||||
|
frame.Summary.NarrativeCandidateCount,
|
||||||
|
frame.Summary.NarrativePendingCount);
|
||||||
|
|
||||||
|
return frame.Model;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<StoryIntelligenceProgressViewModel?> GetCharacterReviewAsync(Guid batchId)
|
||||||
|
=> GetSelectedReviewAsync(batchId, "Characters", async frame => frame.Model.CharacterReview = await characterImport.BuildReviewAsync(frame.Batch));
|
||||||
|
|
||||||
|
public Task<StoryIntelligenceProgressViewModel?> GetLocationReviewAsync(Guid batchId)
|
||||||
|
=> GetSelectedReviewAsync(batchId, "Locations", async frame => frame.Model.LocationReview = await locationImport.BuildReviewAsync(frame.Batch));
|
||||||
|
|
||||||
|
public Task<StoryIntelligenceProgressViewModel?> GetAssetReviewAsync(Guid batchId)
|
||||||
|
=> GetSelectedReviewAsync(batchId, "Assets", async frame => frame.Model.AssetReview = await assetImport.BuildReviewAsync(frame.Batch));
|
||||||
|
|
||||||
|
public Task<StoryIntelligenceProgressViewModel?> GetRelationshipReviewAsync(Guid batchId)
|
||||||
|
=> GetSelectedReviewAsync(batchId, "Relationships", async frame => frame.Model.RelationshipReview = await relationshipImport.BuildReviewAsync(frame.Batch));
|
||||||
|
|
||||||
|
public Task<StoryIntelligenceProgressViewModel?> GetKnowledgeReviewAsync(Guid batchId)
|
||||||
|
=> GetSelectedReviewAsync(batchId, "Knowledge", async frame => frame.Model.KnowledgeReview = await knowledgeImport.BuildReviewAsync(frame.Batch));
|
||||||
|
|
||||||
|
public Task<StoryIntelligenceProgressViewModel?> GetNarrativeReviewAsync(Guid batchId)
|
||||||
|
=> GetSelectedReviewAsync(batchId, "PlotLinesThreads", async frame =>
|
||||||
|
{
|
||||||
|
frame.Model.NarrativeReview = await narrativeReview.GetReviewAsync(frame.Batch);
|
||||||
|
frame.Model.PlotSynthesis = new WholeBookPlotSynthesisStatusViewModel
|
||||||
|
{
|
||||||
|
WholeBookPlotSynthesisRunID = frame.Model.PlotSynthesis.WholeBookPlotSynthesisRunID,
|
||||||
|
Status = frame.Model.PlotSynthesis.Status,
|
||||||
|
CandidateCount = frame.Model.NarrativeReview.Candidates.Count,
|
||||||
|
ThreadCount = frame.Model.NarrativeReview.Candidates.Count(candidate =>
|
||||||
|
string.Equals(candidate.CandidateType, StoryIntelligenceNarrativeCandidateTypes.Thread, StringComparison.OrdinalIgnoreCase)),
|
||||||
|
EventCount = frame.Model.NarrativeReview.Candidates.Sum(candidate => candidate.Events.Count),
|
||||||
|
ErrorMessage = frame.Model.PlotSynthesis.ErrorMessage
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
private async Task<StoryIntelligenceProgressViewModel?> GetSelectedReviewAsync(
|
||||||
|
Guid batchId,
|
||||||
|
string module,
|
||||||
|
Func<StoryIntelligenceReviewFrame, Task> loadModule)
|
||||||
|
{
|
||||||
|
var totalStopwatch = Stopwatch.StartNew();
|
||||||
|
var frame = await GetReviewFrameAsync(batchId);
|
||||||
|
if (frame is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var moduleStopwatch = Stopwatch.StartNew();
|
||||||
|
await loadModule(frame);
|
||||||
|
moduleStopwatch.Stop();
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"Story Intelligence selected review module built. Module={Module} BatchID={BatchID} ProjectID={ProjectID} BookID={BookID} SummaryMs={SummaryMs} ModuleMs={ModuleMs} TotalMs={TotalMs}",
|
||||||
|
module,
|
||||||
|
frame.Batch.BatchID,
|
||||||
|
frame.Summary.ProjectID,
|
||||||
|
frame.Summary.BookID,
|
||||||
|
frame.SummaryElapsedMs,
|
||||||
|
moduleStopwatch.ElapsedMilliseconds,
|
||||||
|
totalStopwatch.ElapsedMilliseconds);
|
||||||
|
|
||||||
|
return frame.Model;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<StoryIntelligenceReviewFrame?> GetReviewFrameAsync(Guid batchId)
|
||||||
|
{
|
||||||
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
|
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
|
||||||
if (batch is null)
|
if (batch is null)
|
||||||
{
|
{
|
||||||
@ -823,20 +911,15 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
logger.LogInformation(
|
return new StoryIntelligenceReviewFrame(batch, summary, model, summaryStopwatch.ElapsedMilliseconds);
|
||||||
"Story Intelligence Review Centre summary built. BatchID={BatchID} ProjectID={ProjectID} BookID={BookID} SummaryMs={SummaryMs} TotalMs={TotalMs} ActiveRunCount={ActiveRunCount} NarrativeCandidates={NarrativeCandidateCount} NarrativePending={NarrativePendingCount}",
|
|
||||||
batch.BatchID,
|
|
||||||
summary.ProjectID,
|
|
||||||
summary.BookID,
|
|
||||||
summaryStopwatch.ElapsedMilliseconds,
|
|
||||||
totalStopwatch.ElapsedMilliseconds,
|
|
||||||
summary.ActiveRunCount,
|
|
||||||
summary.NarrativeCandidateCount,
|
|
||||||
summary.NarrativePendingCount);
|
|
||||||
|
|
||||||
return model;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed record StoryIntelligenceReviewFrame(
|
||||||
|
OnboardingStoryIntelligenceBatch Batch,
|
||||||
|
StoryIntelligenceReviewCentreSummary Summary,
|
||||||
|
StoryIntelligenceProgressViewModel Model,
|
||||||
|
long SummaryElapsedMs);
|
||||||
|
|
||||||
public async Task<StoryIntelligenceProgressViewModel?> CancelAsync(Guid batchId)
|
public async Task<StoryIntelligenceProgressViewModel?> CancelAsync(Guid batchId)
|
||||||
{
|
{
|
||||||
var progress = await GetProgressAsync(batchId);
|
var progress = await GetProgressAsync(batchId);
|
||||||
@ -862,7 +945,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var result = await commits.CommitAsync(runId, RequireUserId());
|
var result = await commits.CommitAsync(runId, RequireUserId());
|
||||||
return (await GetProgressAsync(batchId), result);
|
return (await GetReviewCentreAsync(batchId), result);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAllAsync(Guid batchId)
|
public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAllAsync(Guid batchId)
|
||||||
@ -909,7 +992,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var result = await characterImport.ImportAsync(batch, form);
|
var result = await characterImport.ImportAsync(batch, form);
|
||||||
return (await GetProgressAsync(batchId), result);
|
return (await GetReviewCentreAsync(batchId), result);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<StoryIntelligenceCharacterImportResultViewModel?> GetCharacterImportResultAsync(Guid batchId)
|
public async Task<StoryIntelligenceCharacterImportResultViewModel?> GetCharacterImportResultAsync(Guid batchId)
|
||||||
@ -943,7 +1026,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var result = await locationImport.ImportAsync(batch, form);
|
var result = await locationImport.ImportAsync(batch, form);
|
||||||
return (await GetProgressAsync(batchId), result);
|
return (await GetReviewCentreAsync(batchId), result);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<StoryIntelligenceLocationImportResultViewModel?> GetLocationImportResultAsync(Guid batchId)
|
public async Task<StoryIntelligenceLocationImportResultViewModel?> GetLocationImportResultAsync(Guid batchId)
|
||||||
@ -976,7 +1059,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var result = await assetImport.ImportAsync(batch, form);
|
var result = await assetImport.ImportAsync(batch, form);
|
||||||
return (await GetProgressAsync(batchId), result);
|
return (await GetReviewCentreAsync(batchId), result);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<StoryIntelligenceAssetImportResultViewModel?> GetAssetImportResultAsync(Guid batchId)
|
public async Task<StoryIntelligenceAssetImportResultViewModel?> GetAssetImportResultAsync(Guid batchId)
|
||||||
@ -1010,7 +1093,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var result = await relationshipImport.ImportAsync(batch, form);
|
var result = await relationshipImport.ImportAsync(batch, form);
|
||||||
return (await GetProgressAsync(batchId), result);
|
return (await GetReviewCentreAsync(batchId), result);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<StoryIntelligenceRelationshipImportResultViewModel?> GetRelationshipImportResultAsync(Guid batchId)
|
public async Task<StoryIntelligenceRelationshipImportResultViewModel?> GetRelationshipImportResultAsync(Guid batchId)
|
||||||
@ -1043,7 +1126,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var result = await knowledgeImport.ImportAsync(batch, form);
|
var result = await knowledgeImport.ImportAsync(batch, form);
|
||||||
return (await GetProgressAsync(batchId), result);
|
return (await GetReviewCentreAsync(batchId), result);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<StoryIntelligenceKnowledgeImportResultViewModel?> GetKnowledgeImportResultAsync(Guid batchId)
|
public async Task<StoryIntelligenceKnowledgeImportResultViewModel?> GetKnowledgeImportResultAsync(Guid batchId)
|
||||||
@ -1076,7 +1159,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
|
|
||||||
var result = await narrativeReview.ImportAsync(batch, form, RequireUserId());
|
var result = await narrativeReview.ImportAsync(batch, form, RequireUserId());
|
||||||
await batchStore.SaveAsync(batch);
|
await batchStore.SaveAsync(batch);
|
||||||
return (await GetProgressAsync(batchId), result);
|
return (await GetReviewCentreAsync(batchId), result);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<StoryIntelligenceNarrativeImportResultViewModel?> GetNarrativeImportResultAsync(Guid batchId)
|
public async Task<StoryIntelligenceNarrativeImportResultViewModel?> GetNarrativeImportResultAsync(Guid batchId)
|
||||||
|
|||||||
@ -55,20 +55,19 @@ public sealed class StoryIntelligenceAssetImportService(
|
|||||||
|
|
||||||
public async Task<StoryIntelligenceAssetReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
|
public async Task<StoryIntelligenceAssetReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
|
||||||
{
|
{
|
||||||
var data = await BuildCandidateDataAsync(batch);
|
|
||||||
var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID);
|
var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID);
|
||||||
if (batch.AssetStageComplete || pipeline?.AssetReviewCompletedUtc is not null)
|
if (batch.AssetStageComplete || pipeline?.AssetReviewCompletedUtc is not null)
|
||||||
{
|
{
|
||||||
return new StoryIntelligenceAssetReviewViewModel
|
return new StoryIntelligenceAssetReviewViewModel
|
||||||
{
|
{
|
||||||
HasCommittedScenes = data.HasCommittedScenes,
|
HasCommittedScenes = true,
|
||||||
CanImport = false,
|
CanImport = false,
|
||||||
AlreadyLinkedCount = data.AlreadyLinkedCount,
|
IsComplete = true,
|
||||||
IsComplete = data.HasCommittedScenes,
|
|
||||||
Candidates = []
|
Candidates = []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var data = await BuildCandidateDataAsync(batch);
|
||||||
var decidedKeys = batch.AssetDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
var decidedKeys = batch.AssetDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList();
|
var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList();
|
||||||
return new StoryIntelligenceAssetReviewViewModel
|
return new StoryIntelligenceAssetReviewViewModel
|
||||||
|
|||||||
@ -71,20 +71,19 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
|
|
||||||
public async Task<StoryIntelligenceCharacterReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
|
public async Task<StoryIntelligenceCharacterReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
|
||||||
{
|
{
|
||||||
var data = await BuildCandidateDataAsync(batch);
|
|
||||||
var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID);
|
var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID);
|
||||||
if (batch.CharacterStageComplete || pipeline?.CharacterReviewCompletedUtc is not null)
|
if (batch.CharacterStageComplete || pipeline?.CharacterReviewCompletedUtc is not null)
|
||||||
{
|
{
|
||||||
return new StoryIntelligenceCharacterReviewViewModel
|
return new StoryIntelligenceCharacterReviewViewModel
|
||||||
{
|
{
|
||||||
HasCommittedScenes = data.HasCommittedScenes,
|
HasCommittedScenes = true,
|
||||||
CanImport = false,
|
CanImport = false,
|
||||||
AlreadyLinkedCount = data.AlreadyLinkedCount,
|
IsComplete = true,
|
||||||
IsComplete = data.HasCommittedScenes,
|
|
||||||
Candidates = []
|
Candidates = []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var data = await BuildCandidateDataAsync(batch);
|
||||||
var decidedKeys = batch.CharacterDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
var decidedKeys = batch.CharacterDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList();
|
var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList();
|
||||||
var existingIndex = await BuildCharacterIndexAsync(batch.ProjectID);
|
var existingIndex = await BuildCharacterIndexAsync(batch.ProjectID);
|
||||||
|
|||||||
@ -29,23 +29,31 @@ public sealed class StoryIntelligenceKnowledgeImportService(
|
|||||||
|
|
||||||
public async Task<StoryIntelligenceKnowledgeReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
|
public async Task<StoryIntelligenceKnowledgeReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
|
||||||
{
|
{
|
||||||
|
var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID);
|
||||||
|
if (batch.KnowledgeStageComplete || pipeline?.KnowledgeReviewCompletedUtc is not null)
|
||||||
|
{
|
||||||
|
return new StoryIntelligenceKnowledgeReviewViewModel
|
||||||
|
{
|
||||||
|
HasCommittedScenes = true,
|
||||||
|
CanImport = false,
|
||||||
|
IsComplete = true,
|
||||||
|
KnowledgeStateOptions = [],
|
||||||
|
Candidates = []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
var lookupData = await characters.GetLookupsAsync(batch.ProjectID);
|
var lookupData = await characters.GetLookupsAsync(batch.ProjectID);
|
||||||
var data = await BuildCandidateDataAsync(batch, lookupData.KnowledgeStates);
|
var data = await BuildCandidateDataAsync(batch, lookupData.KnowledgeStates);
|
||||||
var decidedKeys = batch.KnowledgeDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
var decidedKeys = batch.KnowledgeDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
var selectedStateIds = batch.KnowledgeSelections.ToDictionary(selection => selection.Key, selection => selection.KnowledgeStateID, StringComparer.OrdinalIgnoreCase);
|
var selectedStateIds = batch.KnowledgeSelections.ToDictionary(selection => selection.Key, selection => selection.KnowledgeStateID, StringComparer.OrdinalIgnoreCase);
|
||||||
var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList();
|
var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList();
|
||||||
var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID);
|
|
||||||
if (batch.KnowledgeStageComplete || pipeline?.KnowledgeReviewCompletedUtc is not null)
|
|
||||||
{
|
|
||||||
visibleCandidates = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return new StoryIntelligenceKnowledgeReviewViewModel
|
return new StoryIntelligenceKnowledgeReviewViewModel
|
||||||
{
|
{
|
||||||
HasCommittedScenes = data.HasCommittedScenes,
|
HasCommittedScenes = data.HasCommittedScenes,
|
||||||
CanImport = visibleCandidates.Count > 0,
|
CanImport = visibleCandidates.Count > 0,
|
||||||
AlreadyLinkedCount = data.AlreadyLinkedCount,
|
AlreadyLinkedCount = data.AlreadyLinkedCount,
|
||||||
IsComplete = data.HasCommittedScenes && (visibleCandidates.Count == 0 || batch.KnowledgeStageComplete || pipeline?.KnowledgeReviewCompletedUtc is not null),
|
IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0,
|
||||||
KnowledgeStateOptions = lookupData.KnowledgeStates
|
KnowledgeStateOptions = lookupData.KnowledgeStates
|
||||||
.OrderBy(state => state.SortOrder)
|
.OrderBy(state => state.SortOrder)
|
||||||
.ThenBy(state => state.StateName)
|
.ThenBy(state => state.StateName)
|
||||||
|
|||||||
@ -69,20 +69,19 @@ public sealed class StoryIntelligenceLocationImportService(
|
|||||||
|
|
||||||
public async Task<StoryIntelligenceLocationReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
|
public async Task<StoryIntelligenceLocationReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
|
||||||
{
|
{
|
||||||
var data = await BuildCandidateDataAsync(batch);
|
|
||||||
var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID);
|
var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID);
|
||||||
if (batch.LocationStageComplete || pipeline?.LocationReviewCompletedUtc is not null)
|
if (batch.LocationStageComplete || pipeline?.LocationReviewCompletedUtc is not null)
|
||||||
{
|
{
|
||||||
return new StoryIntelligenceLocationReviewViewModel
|
return new StoryIntelligenceLocationReviewViewModel
|
||||||
{
|
{
|
||||||
HasCommittedScenes = data.HasCommittedScenes,
|
HasCommittedScenes = true,
|
||||||
CanImport = false,
|
CanImport = false,
|
||||||
AlreadyLinkedCount = data.AlreadyLinkedCount,
|
IsComplete = true,
|
||||||
IsComplete = data.HasCommittedScenes,
|
|
||||||
Candidates = []
|
Candidates = []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var data = await BuildCandidateDataAsync(batch);
|
||||||
var decidedKeys = batch.LocationDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
var decidedKeys = batch.LocationDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList();
|
var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList();
|
||||||
return new StoryIntelligenceLocationReviewViewModel
|
return new StoryIntelligenceLocationReviewViewModel
|
||||||
|
|||||||
@ -28,24 +28,32 @@ public sealed class StoryIntelligenceRelationshipImportService(
|
|||||||
|
|
||||||
public async Task<StoryIntelligenceRelationshipReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
|
public async Task<StoryIntelligenceRelationshipReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
|
||||||
{
|
{
|
||||||
|
var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID);
|
||||||
|
if (batch.RelationshipStageComplete || pipeline?.RelationshipReviewCompletedUtc is not null)
|
||||||
|
{
|
||||||
|
return new StoryIntelligenceRelationshipReviewViewModel
|
||||||
|
{
|
||||||
|
HasCommittedScenes = true,
|
||||||
|
CanImport = false,
|
||||||
|
IsComplete = true,
|
||||||
|
RelationshipTypeOptions = [],
|
||||||
|
Candidates = []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
var data = await BuildCandidateDataAsync(batch);
|
var data = await BuildCandidateDataAsync(batch);
|
||||||
var lookupData = await characters.GetLookupsAsync(batch.ProjectID);
|
var lookupData = await characters.GetLookupsAsync(batch.ProjectID);
|
||||||
var typeOptions = ToRelationshipTypeOptions(lookupData.RelationshipTypes);
|
var typeOptions = ToRelationshipTypeOptions(lookupData.RelationshipTypes);
|
||||||
var selectedTypeIds = batch.RelationshipSelections.ToDictionary(selection => selection.Key, selection => selection.RelationshipTypeID, StringComparer.OrdinalIgnoreCase);
|
var selectedTypeIds = batch.RelationshipSelections.ToDictionary(selection => selection.Key, selection => selection.RelationshipTypeID, StringComparer.OrdinalIgnoreCase);
|
||||||
var decidedKeys = batch.RelationshipDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
var decidedKeys = batch.RelationshipDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList();
|
var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList();
|
||||||
var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID);
|
|
||||||
if (batch.RelationshipStageComplete || pipeline?.RelationshipReviewCompletedUtc is not null)
|
|
||||||
{
|
|
||||||
visibleCandidates = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return new StoryIntelligenceRelationshipReviewViewModel
|
return new StoryIntelligenceRelationshipReviewViewModel
|
||||||
{
|
{
|
||||||
HasCommittedScenes = data.HasCommittedScenes,
|
HasCommittedScenes = data.HasCommittedScenes,
|
||||||
CanImport = visibleCandidates.Count > 0,
|
CanImport = visibleCandidates.Count > 0,
|
||||||
AlreadyLinkedCount = data.AlreadyLinkedCount,
|
AlreadyLinkedCount = data.AlreadyLinkedCount,
|
||||||
IsComplete = data.HasCommittedScenes && (visibleCandidates.Count == 0 || batch.RelationshipStageComplete || pipeline?.RelationshipReviewCompletedUtc is not null),
|
IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0,
|
||||||
RelationshipTypeOptions = typeOptions,
|
RelationshipTypeOptions = typeOptions,
|
||||||
Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceRelationshipReviewCandidateViewModel
|
Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceRelationshipReviewCandidateViewModel
|
||||||
{
|
{
|
||||||
|
|||||||
@ -169,13 +169,13 @@ public sealed class StoryIntelligenceProgressViewModel
|
|||||||
public string? ProjectName { get; init; }
|
public string? ProjectName { get; init; }
|
||||||
public string? BookTitle { get; init; }
|
public string? BookTitle { get; init; }
|
||||||
public IReadOnlyList<StoryIntelligenceOnboardingChapterViewModel> Chapters { get; init; } = [];
|
public IReadOnlyList<StoryIntelligenceOnboardingChapterViewModel> Chapters { get; init; } = [];
|
||||||
public StoryIntelligenceCharacterReviewViewModel CharacterReview { get; init; } = new();
|
public StoryIntelligenceCharacterReviewViewModel CharacterReview { get; set; } = new();
|
||||||
public StoryIntelligenceLocationReviewViewModel LocationReview { get; init; } = new();
|
public StoryIntelligenceLocationReviewViewModel LocationReview { get; set; } = new();
|
||||||
public StoryIntelligenceAssetReviewViewModel AssetReview { get; init; } = new();
|
public StoryIntelligenceAssetReviewViewModel AssetReview { get; set; } = new();
|
||||||
public StoryIntelligenceRelationshipReviewViewModel RelationshipReview { get; init; } = new();
|
public StoryIntelligenceRelationshipReviewViewModel RelationshipReview { get; set; } = new();
|
||||||
public StoryIntelligenceKnowledgeReviewViewModel KnowledgeReview { get; init; } = new();
|
public StoryIntelligenceKnowledgeReviewViewModel KnowledgeReview { get; set; } = new();
|
||||||
public StoryIntelligenceNarrativeReviewViewModel NarrativeReview { get; init; } = new();
|
public StoryIntelligenceNarrativeReviewViewModel NarrativeReview { get; set; } = new();
|
||||||
public WholeBookPlotSynthesisStatusViewModel PlotSynthesis { get; init; } = new();
|
public WholeBookPlotSynthesisStatusViewModel PlotSynthesis { get; set; } = new();
|
||||||
public StoryIntelligencePipelineDashboardViewModel PipelineDashboard { get; init; } = new();
|
public StoryIntelligencePipelineDashboardViewModel PipelineDashboard { get; init; } = new();
|
||||||
public int ChapterCount => Chapters.Count;
|
public int ChapterCount => Chapters.Count;
|
||||||
public int CompletedChapterCount => Chapters.Count(chapter => chapter.IsRunComplete);
|
public int CompletedChapterCount => Chapters.Count(chapter => chapter.IsRunComplete);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user