Phase 20AH – Complete Chapter Intelligence & Begin Character Import
This commit is contained in:
parent
8afc0a3b40
commit
7a48a91f21
@ -164,6 +164,20 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
|||||||
: RedirectToAction(nameof(StoryIntelligenceReview), new { batchId });
|
: RedirectToAction(nameof(StoryIntelligenceReview), new { batchId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("story-intelligence/characters")]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> ImportStoryIntelligenceCharacters(StoryIntelligenceCharacterImportForm form)
|
||||||
|
{
|
||||||
|
var (progress, result) = await storyIntelligence.ImportCharactersAsync(form.BatchID, form);
|
||||||
|
if (progress is null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message;
|
||||||
|
return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId = form.BatchID });
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("story-intelligence/complete")]
|
[HttpGet("story-intelligence/complete")]
|
||||||
public async Task<IActionResult> StoryIntelligenceComplete(Guid batchId)
|
public async Task<IActionResult> StoryIntelligenceComplete(Guid batchId)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -2397,7 +2397,7 @@ public sealed class ChapterRepository(ISqlConnectionFactory connectionFactory) :
|
|||||||
using var connection = connectionFactory.CreateConnection();
|
using var connection = connectionFactory.CreateConnection();
|
||||||
return await connection.QuerySingleAsync<int>(
|
return await connection.QuerySingleAsync<int>(
|
||||||
"dbo.Chapter_Save",
|
"dbo.Chapter_Save",
|
||||||
new { chapter.ChapterID, chapter.BookID, chapter.ChapterNumber, chapter.ChapterTitle, chapter.Summary, chapter.RevisionStatusID },
|
new { chapter.ChapterID, chapter.BookID, chapter.ChapterNumber, chapter.ChapterTitle, chapter.Summary, chapter.ChapterPurposeID, chapter.RevisionStatusID },
|
||||||
commandType: CommandType.StoredProcedure);
|
commandType: CommandType.StoredProcedure);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2657,7 +2657,8 @@ public sealed class LookupRepository(ISqlConnectionFactory connectionFactory) :
|
|||||||
TimeModes = (await result.ReadAsync<TimeMode>()).ToList(),
|
TimeModes = (await result.ReadAsync<TimeMode>()).ToList(),
|
||||||
DurationUnits = (await result.ReadAsync<DurationUnit>()).ToList(),
|
DurationUnits = (await result.ReadAsync<DurationUnit>()).ToList(),
|
||||||
TimeConfidences = (await result.ReadAsync<TimeConfidence>()).ToList(),
|
TimeConfidences = (await result.ReadAsync<TimeConfidence>()).ToList(),
|
||||||
ScenePurposeTypes = (await result.ReadAsync<ScenePurposeType>()).ToList()
|
ScenePurposeTypes = (await result.ReadAsync<ScenePurposeType>()).ToList(),
|
||||||
|
ChapterPurposeTypes = (await result.ReadAsync<ChapterPurposeType>()).ToList()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -437,6 +437,20 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.ChapterSummary) || request.ChapterPurposeID.HasValue)
|
||||||
|
{
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"dbo.StoryIntelligenceImportCommit_UpdateChapterPlanning",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
request.ChapterID,
|
||||||
|
Summary = request.ChapterSummary,
|
||||||
|
request.ChapterPurposeID
|
||||||
|
},
|
||||||
|
transaction,
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
}
|
||||||
|
|
||||||
var orderedScenes = request.Scenes.OrderBy(scene => scene.TemporarySceneNumber).ToList();
|
var orderedScenes = request.Scenes.OrderBy(scene => scene.TemporarySceneNumber).ToList();
|
||||||
for (var index = 0; index < orderedScenes.Count; index++)
|
for (var index = 0; index < orderedScenes.Count; index++)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -0,0 +1,67 @@
|
|||||||
|
# Phase 20AH - Chapter Intelligence and Character Import
|
||||||
|
|
||||||
|
Phase 20AH completes the first chapter-level import path and begins author-reviewed character import from existing stored Story Intelligence JSON.
|
||||||
|
|
||||||
|
## Chapter Intelligence Import
|
||||||
|
|
||||||
|
When a Story Intelligence scene commit runs, PlotDirector now also updates the target chapter inside the same import transaction.
|
||||||
|
|
||||||
|
Imported chapter fields:
|
||||||
|
|
||||||
|
- Chapter summary: populated from the Chapter Structure `chapterSummary` response.
|
||||||
|
- Chapter purpose: mapped to an existing Chapter Purpose lookup value where the stored Chapter Intelligence response supports a clear match.
|
||||||
|
|
||||||
|
The import does not concatenate scene summaries to create a chapter summary.
|
||||||
|
|
||||||
|
Chapter Purpose is a chapter-level lookup, separate from Scene Purpose. The default lookup values are:
|
||||||
|
|
||||||
|
- Opening
|
||||||
|
- Character Development
|
||||||
|
- Relationship Development
|
||||||
|
- World Building
|
||||||
|
- Investigation
|
||||||
|
- Revelation
|
||||||
|
- Escalation
|
||||||
|
- Turning Point
|
||||||
|
- Climax
|
||||||
|
- Resolution
|
||||||
|
- Transition
|
||||||
|
|
||||||
|
Chapter Purpose is editable on the standard chapter edit screen and visible on the chapter detail page.
|
||||||
|
|
||||||
|
## Character Import
|
||||||
|
|
||||||
|
After scenes have been created, the onboarding review page can show a character review section.
|
||||||
|
|
||||||
|
The review is built from stored Scene Intelligence results only. It does not call OpenAI again.
|
||||||
|
|
||||||
|
For each candidate, PlotDirector shows:
|
||||||
|
|
||||||
|
- Character name
|
||||||
|
- Number of scenes where the character appears or is mentioned
|
||||||
|
- Confidence band
|
||||||
|
- Possible aliases
|
||||||
|
- First appearance example
|
||||||
|
- Existing matched character where available
|
||||||
|
|
||||||
|
The author can approve, ignore, or rename new characters before import.
|
||||||
|
|
||||||
|
When approved:
|
||||||
|
|
||||||
|
- Existing characters are linked where names or aliases match.
|
||||||
|
- New characters are created only for approved candidates.
|
||||||
|
- Aliases from Scene Intelligence are saved where they do not duplicate the primary name or an existing alias.
|
||||||
|
- Scene People panels are populated for characters physically present in imported scenes.
|
||||||
|
- Mentioned-only characters can be created if approved, but they are not added as physically present scene appearances.
|
||||||
|
- Previously unresolved scene POV links are retried after character creation.
|
||||||
|
|
||||||
|
The import does not infer gender or create placeholder characters.
|
||||||
|
|
||||||
|
## Still Future Phases
|
||||||
|
|
||||||
|
- Locations
|
||||||
|
- Assets
|
||||||
|
- Relationships
|
||||||
|
- Knowledge changes
|
||||||
|
- Continuity warnings
|
||||||
|
- Real timeline events
|
||||||
@ -345,6 +345,8 @@ public sealed class Chapter
|
|||||||
public string ChapterTitle { get; set; } = string.Empty;
|
public string ChapterTitle { get; set; } = string.Empty;
|
||||||
public int? POVCharacterID { get; set; }
|
public int? POVCharacterID { get; set; }
|
||||||
public string? Summary { get; set; }
|
public string? Summary { get; set; }
|
||||||
|
public int? ChapterPurposeID { get; set; }
|
||||||
|
public string? ChapterPurposeName { get; set; }
|
||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
public int RevisionStatusID { get; set; }
|
public int RevisionStatusID { get; set; }
|
||||||
public string RevisionStatusName { get; set; } = string.Empty;
|
public string RevisionStatusName { get; set; } = string.Empty;
|
||||||
@ -439,6 +441,14 @@ public sealed class RevisionStatus
|
|||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class ChapterPurposeType
|
||||||
|
{
|
||||||
|
public int ChapterPurposeID { get; set; }
|
||||||
|
public string PurposeName { get; set; } = string.Empty;
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
public bool IsArchived { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class TimeMode
|
public sealed class TimeMode
|
||||||
{
|
{
|
||||||
public int TimeModeID { get; set; }
|
public int TimeModeID { get; set; }
|
||||||
@ -2555,4 +2565,5 @@ public sealed class LookupData
|
|||||||
public IReadOnlyList<DurationUnit> DurationUnits { get; init; } = [];
|
public IReadOnlyList<DurationUnit> DurationUnits { get; init; } = [];
|
||||||
public IReadOnlyList<TimeConfidence> TimeConfidences { get; init; } = [];
|
public IReadOnlyList<TimeConfidence> TimeConfidences { get; init; } = [];
|
||||||
public IReadOnlyList<ScenePurposeType> ScenePurposeTypes { get; init; } = [];
|
public IReadOnlyList<ScenePurposeType> ScenePurposeTypes { get; init; } = [];
|
||||||
|
public IReadOnlyList<ChapterPurposeType> ChapterPurposeTypes { get; init; } = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -409,6 +409,8 @@ public sealed class StoryIntelligenceImportCommitRequest
|
|||||||
public int TimeConfidenceID { get; init; }
|
public int TimeConfidenceID { get; init; }
|
||||||
public int RevisionStatusID { get; init; }
|
public int RevisionStatusID { get; init; }
|
||||||
public int? TimelineNoteTypeID { get; init; }
|
public int? TimelineNoteTypeID { get; init; }
|
||||||
|
public string? ChapterSummary { get; init; }
|
||||||
|
public int? ChapterPurposeID { get; init; }
|
||||||
public IReadOnlyList<StoryIntelligenceSceneImportItem> Scenes { get; init; } = [];
|
public IReadOnlyList<StoryIntelligenceSceneImportItem> Scenes { get; init; } = [];
|
||||||
public IReadOnlyList<string> Warnings { get; init; } = [];
|
public IReadOnlyList<string> Warnings { get; init; } = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -206,6 +206,7 @@ public class Program
|
|||||||
builder.Services.AddScoped<IStoryIntelligenceExistingChapterQueueService, StoryIntelligenceExistingChapterQueueService>();
|
builder.Services.AddScoped<IStoryIntelligenceExistingChapterQueueService, StoryIntelligenceExistingChapterQueueService>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceFullChapterTestService, StoryIntelligenceFullChapterTestService>();
|
builder.Services.AddScoped<IStoryIntelligenceFullChapterTestService, StoryIntelligenceFullChapterTestService>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceImportCommitService, StoryIntelligenceImportCommitService>();
|
builder.Services.AddScoped<IStoryIntelligenceImportCommitService, StoryIntelligenceImportCommitService>();
|
||||||
|
builder.Services.AddScoped<IStoryIntelligenceCharacterImportService, StoryIntelligenceCharacterImportService>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>();
|
builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>();
|
||||||
builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>();
|
builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>();
|
||||||
builder.Services.AddScoped<IPersistedStoryIntelligenceRunner, PersistedStoryIntelligenceRunner>();
|
builder.Services.AddScoped<IPersistedStoryIntelligenceRunner, PersistedStoryIntelligenceRunner>();
|
||||||
|
|||||||
@ -1377,7 +1377,8 @@ public sealed class ChapterService(
|
|||||||
Project = project,
|
Project = project,
|
||||||
ChapterNumber = existing.Count + 1,
|
ChapterNumber = existing.Count + 1,
|
||||||
RevisionStatusID = DefaultRevisionStatusId(lookupData),
|
RevisionStatusID = DefaultRevisionStatusId(lookupData),
|
||||||
RevisionStatuses = ToSelectList(lookupData.RevisionStatuses, x => x.RevisionStatusID, x => x.StatusName)
|
RevisionStatuses = ToSelectList(lookupData.RevisionStatuses, x => x.RevisionStatusID, x => x.StatusName),
|
||||||
|
ChapterPurposes = ToOptionalSelectList(lookupData.ChapterPurposeTypes, x => x.ChapterPurposeID, x => x.PurposeName)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1404,11 +1405,13 @@ public sealed class ChapterService(
|
|||||||
ChapterNumber = chapter.ChapterNumber,
|
ChapterNumber = chapter.ChapterNumber,
|
||||||
ChapterTitle = chapter.ChapterTitle,
|
ChapterTitle = chapter.ChapterTitle,
|
||||||
Summary = chapter.Summary,
|
Summary = chapter.Summary,
|
||||||
|
ChapterPurposeID = chapter.ChapterPurposeID,
|
||||||
RevisionStatusID = chapter.RevisionStatusID,
|
RevisionStatusID = chapter.RevisionStatusID,
|
||||||
IsLinkedToManuscript = chapter.IsLinkedToManuscript,
|
IsLinkedToManuscript = chapter.IsLinkedToManuscript,
|
||||||
Book = book,
|
Book = book,
|
||||||
Project = project,
|
Project = project,
|
||||||
RevisionStatuses = ToSelectList(lookupData.RevisionStatuses, x => x.RevisionStatusID, x => x.StatusName)
|
RevisionStatuses = ToSelectList(lookupData.RevisionStatuses, x => x.RevisionStatusID, x => x.StatusName),
|
||||||
|
ChapterPurposes = ToOptionalSelectList(lookupData.ChapterPurposeTypes, x => x.ChapterPurposeID, x => x.PurposeName)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1427,6 +1430,7 @@ public sealed class ChapterService(
|
|||||||
ChapterNumber = model.ChapterNumber,
|
ChapterNumber = model.ChapterNumber,
|
||||||
ChapterTitle = model.ChapterTitle,
|
ChapterTitle = model.ChapterTitle,
|
||||||
Summary = model.Summary,
|
Summary = model.Summary,
|
||||||
|
ChapterPurposeID = model.ChapterPurposeID,
|
||||||
RevisionStatusID = model.RevisionStatusID
|
RevisionStatusID = model.RevisionStatusID
|
||||||
});
|
});
|
||||||
var book = await books.GetAsync(model.BookID);
|
var book = await books.GetAsync(model.BookID);
|
||||||
@ -1454,6 +1458,9 @@ public sealed class ChapterService(
|
|||||||
|
|
||||||
internal static IReadOnlyList<SelectListItem> ToSelectList<T>(IEnumerable<T> rows, Func<T, int> value, Func<T, string> text)
|
internal static IReadOnlyList<SelectListItem> ToSelectList<T>(IEnumerable<T> rows, Func<T, int> value, Func<T, string> text)
|
||||||
=> rows.Select(x => new SelectListItem(text(x), value(x).ToString())).ToList();
|
=> rows.Select(x => new SelectListItem(text(x), value(x).ToString())).ToList();
|
||||||
|
|
||||||
|
internal static IReadOnlyList<SelectListItem> ToOptionalSelectList<T>(IEnumerable<T> rows, Func<T, int> value, Func<T, string> text)
|
||||||
|
=> rows.Select(x => new SelectListItem(text(x), value(x).ToString())).Prepend(new SelectListItem("None", string.Empty)).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class SceneService(
|
public sealed class SceneService(
|
||||||
|
|||||||
@ -14,6 +14,7 @@ public interface IOnboardingStoryIntelligenceService
|
|||||||
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);
|
||||||
|
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportCharactersAsync(Guid batchId, StoryIntelligenceCharacterImportForm form);
|
||||||
Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId);
|
Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -23,6 +24,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
IManuscriptScanPreviewStore scanStore,
|
IManuscriptScanPreviewStore scanStore,
|
||||||
IStoryIntelligenceResultRepository runs,
|
IStoryIntelligenceResultRepository runs,
|
||||||
IStoryIntelligenceImportCommitService commits,
|
IStoryIntelligenceImportCommitService commits,
|
||||||
|
IStoryIntelligenceCharacterImportService characterImport,
|
||||||
IStoryIntelligenceClient client,
|
IStoryIntelligenceClient client,
|
||||||
IOnboardingStoryIntelligenceBatchStore batchStore,
|
IOnboardingStoryIntelligenceBatchStore batchStore,
|
||||||
IStoryIntelligenceProgressNotifier notifier,
|
IStoryIntelligenceProgressNotifier notifier,
|
||||||
@ -261,7 +263,8 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
BookID = batch.BookID,
|
BookID = batch.BookID,
|
||||||
ProjectName = batch.ProjectName,
|
ProjectName = batch.ProjectName,
|
||||||
BookTitle = batch.BookTitle,
|
BookTitle = batch.BookTitle,
|
||||||
Chapters = chapters
|
Chapters = chapters,
|
||||||
|
CharacterReview = await characterImport.BuildReviewAsync(batch)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -328,6 +331,18 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportCharactersAsync(Guid batchId, StoryIntelligenceCharacterImportForm form)
|
||||||
|
{
|
||||||
|
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
|
||||||
|
if (batch is null)
|
||||||
|
{
|
||||||
|
return (null, new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence batch could not be found." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await characterImport.ImportAsync(batch, form);
|
||||||
|
return (await GetProgressAsync(batchId), result);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId)
|
public async Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId)
|
||||||
{
|
{
|
||||||
var progress = await GetProgressAsync(batchId);
|
var progress = await GetProgressAsync(batchId);
|
||||||
|
|||||||
472
PlotLine/Services/StoryIntelligenceCharacterImportService.cs
Normal file
472
PlotLine/Services/StoryIntelligenceCharacterImportService.cs
Normal file
@ -0,0 +1,472 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using PlotLine.Data;
|
||||||
|
using PlotLine.Models;
|
||||||
|
using PlotLine.ViewModels;
|
||||||
|
|
||||||
|
namespace PlotLine.Services;
|
||||||
|
|
||||||
|
public interface IStoryIntelligenceCharacterImportService
|
||||||
|
{
|
||||||
|
Task<StoryIntelligenceCharacterReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch);
|
||||||
|
Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceCharacterImportForm form);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class StoryIntelligenceCharacterImportService(
|
||||||
|
IStoryIntelligenceResultRepository runs,
|
||||||
|
ICharacterRepository characters,
|
||||||
|
ISceneRepository scenes,
|
||||||
|
ILogger<StoryIntelligenceCharacterImportService> logger) : IStoryIntelligenceCharacterImportService
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly HashSet<string> IgnoredNames = CreateSet("narrator", "unknown");
|
||||||
|
|
||||||
|
private static readonly HashSet<string> GenericPeople = CreateSet(
|
||||||
|
"man", "woman", "boy", "girl", "child", "children", "person", "people", "doctor", "nurse",
|
||||||
|
"police officer", "driver", "receptionist", "attendant", "guard", "security guard", "waiter", "waitress",
|
||||||
|
"clerk", "shopkeeper", "cashier", "crowd", "family", "group", "small group");
|
||||||
|
|
||||||
|
public async Task<StoryIntelligenceCharacterReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
|
||||||
|
{
|
||||||
|
var data = await BuildCandidateDataAsync(batch);
|
||||||
|
return new StoryIntelligenceCharacterReviewViewModel
|
||||||
|
{
|
||||||
|
HasCommittedScenes = data.HasCommittedScenes,
|
||||||
|
CanImport = data.Candidates.Count > 0,
|
||||||
|
AlreadyLinkedCount = data.AlreadyLinkedCount,
|
||||||
|
Candidates = data.Candidates.Select(candidate => new StoryIntelligenceCharacterReviewCandidateViewModel
|
||||||
|
{
|
||||||
|
Key = candidate.Key,
|
||||||
|
CharacterName = candidate.DisplayName,
|
||||||
|
ImportName = candidate.DisplayName,
|
||||||
|
AppearsInScenes = candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances).Select(appearance => appearance.SceneID).Distinct().Count(),
|
||||||
|
Confidence = ConfidenceBand(candidate.Confidence),
|
||||||
|
PossibleAliases = candidate.Aliases.OrderBy(value => value).ToList(),
|
||||||
|
ExampleFirstAppearance = candidate.FirstAppearanceNote,
|
||||||
|
ExistingCharacterID = candidate.ExistingCharacterID,
|
||||||
|
ExistingCharacterName = candidate.ExistingCharacterName
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceCharacterImportForm form)
|
||||||
|
{
|
||||||
|
var data = await BuildCandidateDataAsync(batch);
|
||||||
|
if (!data.HasCommittedScenes)
|
||||||
|
{
|
||||||
|
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Create scenes before importing characters." };
|
||||||
|
}
|
||||||
|
|
||||||
|
var choices = form.Characters
|
||||||
|
.Where(choice => choice.Include && !string.IsNullOrWhiteSpace(choice.Key))
|
||||||
|
.ToDictionary(choice => choice.Key, choice => choice, StringComparer.OrdinalIgnoreCase);
|
||||||
|
if (choices.Count == 0)
|
||||||
|
{
|
||||||
|
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose at least one character to create or link." };
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingIndex = await BuildCharacterIndexAsync(batch.ProjectID);
|
||||||
|
var lookupData = await characters.GetLookupsAsync(batch.ProjectID);
|
||||||
|
var roleTypes = lookupData.RoleTypes;
|
||||||
|
var presenceTypes = lookupData.PresenceTypes;
|
||||||
|
var created = 0;
|
||||||
|
var linkedAppearances = 0;
|
||||||
|
var povLinks = 0;
|
||||||
|
var resolvedCharacters = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var candidate in data.Candidates)
|
||||||
|
{
|
||||||
|
if (!choices.TryGetValue(candidate.Key, out var choice))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var requestedName = Clean(choice.ImportName);
|
||||||
|
var importName = string.IsNullOrWhiteSpace(requestedName) ? candidate.DisplayName : requestedName;
|
||||||
|
var characterId = candidate.ExistingCharacterID
|
||||||
|
?? existingIndex.Find(importName)?.CharacterID
|
||||||
|
?? existingIndex.Find(candidate.DisplayName)?.CharacterID;
|
||||||
|
|
||||||
|
if (!characterId.HasValue)
|
||||||
|
{
|
||||||
|
characterId = await characters.SaveCharacterAsync(new Character
|
||||||
|
{
|
||||||
|
ProjectID = batch.ProjectID,
|
||||||
|
CharacterName = importName,
|
||||||
|
ShortName = FirstName(importName),
|
||||||
|
DefaultDescription = candidate.Description
|
||||||
|
});
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
|
||||||
|
AddResolvedName(resolvedCharacters, candidate.DisplayName, characterId.Value);
|
||||||
|
AddResolvedName(resolvedCharacters, importName, characterId.Value);
|
||||||
|
foreach (var alias in candidate.Aliases)
|
||||||
|
{
|
||||||
|
AddResolvedName(resolvedCharacters, alias, characterId.Value);
|
||||||
|
await TryAddAliasAsync(characterId.Value, alias, importName);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var appearance in candidate.PresentAppearances)
|
||||||
|
{
|
||||||
|
await characters.SaveSceneCharacterAsync(new SceneCharacter
|
||||||
|
{
|
||||||
|
SceneID = appearance.SceneID,
|
||||||
|
CharacterID = characterId.Value,
|
||||||
|
RoleInSceneTypeID = MatchRole(roleTypes, appearance.RoleInScene),
|
||||||
|
PresenceTypeID = MatchPresence(presenceTypes, appearance.MentionedOnly),
|
||||||
|
AppearanceNotes = appearance.Notes
|
||||||
|
});
|
||||||
|
linkedAppearances++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var scene in data.SceneAnalyses)
|
||||||
|
{
|
||||||
|
var povName = Clean(scene.Parsed.PointOfView?.CharacterName);
|
||||||
|
if (string.IsNullOrWhiteSpace(povName) || !resolvedCharacters.TryGetValue(povName, out var characterId))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await scenes.UpdatePovCharacterAsync(scene.SceneID, characterId);
|
||||||
|
povLinks++;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"Imported Story Intelligence characters for batch {BatchID}. Created={Created} LinkedAppearances={LinkedAppearances} PovLinks={PovLinks}",
|
||||||
|
batch.BatchID,
|
||||||
|
created,
|
||||||
|
linkedAppearances,
|
||||||
|
povLinks);
|
||||||
|
|
||||||
|
return new StoryIntelligenceImportCommitResult
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
ScenesCreated = linkedAppearances,
|
||||||
|
Message = $"Characters imported. {created:N0} character(s) created, {linkedAppearances:N0} scene appearance(s) linked, {povLinks:N0} POV link(s) updated."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CharacterCandidateData> BuildCandidateDataAsync(OnboardingStoryIntelligenceBatch batch)
|
||||||
|
{
|
||||||
|
var existingIndex = await BuildCharacterIndexAsync(batch.ProjectID);
|
||||||
|
var groups = new Dictionary<string, CharacterCandidate>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var sceneAnalyses = new List<ImportedSceneAnalysis>();
|
||||||
|
var alreadyLinked = 0;
|
||||||
|
var hasCommittedScenes = false;
|
||||||
|
|
||||||
|
foreach (var item in batch.Items)
|
||||||
|
{
|
||||||
|
var commit = await runs.GetImportCommitAsync(item.RunID);
|
||||||
|
if (commit is null || !string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasCommittedScenes = true;
|
||||||
|
var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID))
|
||||||
|
.Where(scene => scene.ImportRunID == item.RunID)
|
||||||
|
.ToList();
|
||||||
|
var importedByRange = importedScenes
|
||||||
|
.Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue)
|
||||||
|
.ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase);
|
||||||
|
var importedByNumber = importedScenes.ToDictionary(scene => Convert.ToInt32(scene.SceneNumber), scene => scene);
|
||||||
|
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
|
||||||
|
|
||||||
|
foreach (var sceneResult in sceneResults)
|
||||||
|
{
|
||||||
|
var parsed = TryReadScene(sceneResult);
|
||||||
|
if (parsed is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var importedScene = ResolveImportedScene(sceneResult, importedByRange, importedByNumber);
|
||||||
|
if (importedScene is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
sceneAnalyses.Add(new ImportedSceneAnalysis(importedScene.SceneID, parsed));
|
||||||
|
var existingAppearances = await characters.ListSceneCharactersAsync(importedScene.SceneID);
|
||||||
|
alreadyLinked += existingAppearances.Count;
|
||||||
|
|
||||||
|
foreach (var character in parsed.Characters ?? [])
|
||||||
|
{
|
||||||
|
AddMention(groups, existingIndex, importedScene, parsed, character, existingAppearances);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidates = groups.Values
|
||||||
|
.Where(candidate => candidate.PresentAppearances.Count > 0 || candidate.MentionedOnlyAppearances.Count > 0)
|
||||||
|
.Where(candidate => candidate.PresentAppearances.Any(appearance => !appearance.AlreadyLinked))
|
||||||
|
.OrderByDescending(candidate => candidate.PresentAppearances.Select(appearance => appearance.SceneID).Distinct().Count())
|
||||||
|
.ThenBy(candidate => candidate.DisplayName)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new CharacterCandidateData(hasCommittedScenes, alreadyLinked, candidates, sceneAnalyses);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddMention(
|
||||||
|
Dictionary<string, CharacterCandidate> groups,
|
||||||
|
CharacterIndex existingIndex,
|
||||||
|
Scene importedScene,
|
||||||
|
SceneIntelligenceScene parsed,
|
||||||
|
SceneIntelligenceCharacter character,
|
||||||
|
IReadOnlyList<SceneCharacter> existingAppearances)
|
||||||
|
{
|
||||||
|
var name = Clean(character.Name);
|
||||||
|
if (string.IsNullOrWhiteSpace(name) || IgnoredNames.Contains(name) || IsGenericPerson(name))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = Normalise(name);
|
||||||
|
if (!groups.TryGetValue(key, out var candidate))
|
||||||
|
{
|
||||||
|
var match = existingIndex.Find(name);
|
||||||
|
candidate = new CharacterCandidate(key, name, match?.CharacterID, match?.CharacterName);
|
||||||
|
groups[key] = candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var alias in character.Aliases ?? [])
|
||||||
|
{
|
||||||
|
var cleanAlias = Clean(alias);
|
||||||
|
if (!string.IsNullOrWhiteSpace(cleanAlias) && !string.Equals(cleanAlias, candidate.DisplayName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
candidate.Aliases.Add(cleanAlias);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var notes = Clean(character.Notes);
|
||||||
|
if (string.IsNullOrWhiteSpace(notes) && character.Actions?.Count > 0)
|
||||||
|
{
|
||||||
|
notes = Clean(string.Join("; ", character.Actions));
|
||||||
|
}
|
||||||
|
|
||||||
|
var appearance = new CharacterAppearanceImport(
|
||||||
|
importedScene.SceneID,
|
||||||
|
importedScene.SceneNumber,
|
||||||
|
importedScene.SceneTitle,
|
||||||
|
character.RoleInScene,
|
||||||
|
character.MentionedOnly == true,
|
||||||
|
notes,
|
||||||
|
character.Confidence,
|
||||||
|
candidate.ExistingCharacterID.HasValue && existingAppearances.Any(appearance => appearance.CharacterID == candidate.ExistingCharacterID.Value));
|
||||||
|
if (character.MentionedOnly == true)
|
||||||
|
{
|
||||||
|
candidate.MentionedOnlyAppearances.Add(appearance);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
candidate.PresentAppearances.Add(appearance);
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate.Confidence = Max(candidate.Confidence, character.Confidence);
|
||||||
|
candidate.Description ??= notes;
|
||||||
|
candidate.FirstAppearanceNote ??= BuildFirstAppearance(importedScene, parsed, character);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CharacterIndex> BuildCharacterIndexAsync(int projectId)
|
||||||
|
{
|
||||||
|
var index = new CharacterIndex();
|
||||||
|
foreach (var character in await characters.ListCharactersAsync(projectId))
|
||||||
|
{
|
||||||
|
index.Add(character.CharacterName, character.CharacterID, character.CharacterName);
|
||||||
|
index.Add(character.ShortName, character.CharacterID, character.CharacterName);
|
||||||
|
foreach (var alias in await characters.ListAliasesAsync(character.CharacterID))
|
||||||
|
{
|
||||||
|
index.Add(alias.Alias, character.CharacterID, character.CharacterName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task TryAddAliasAsync(int characterId, string alias, string primaryName)
|
||||||
|
{
|
||||||
|
var cleanAlias = Clean(alias);
|
||||||
|
if (string.IsNullOrWhiteSpace(cleanAlias) || string.Equals(cleanAlias, primaryName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingAliases = await characters.ListAliasesAsync(characterId);
|
||||||
|
if (existingAliases.Any(item => string.Equals(Clean(item.Alias), cleanAlias, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await characters.AddAliasAsync(characterId, cleanAlias, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Scene? ResolveImportedScene(
|
||||||
|
StoryIntelligenceSavedSceneResult sceneResult,
|
||||||
|
IReadOnlyDictionary<string, Scene> byRange,
|
||||||
|
IReadOnlyDictionary<int, Scene> byNumber)
|
||||||
|
{
|
||||||
|
if (sceneResult.StartParagraph.HasValue
|
||||||
|
&& sceneResult.EndParagraph.HasValue
|
||||||
|
&& byRange.TryGetValue($"{sceneResult.StartParagraph}-{sceneResult.EndParagraph}", out var rangeMatch))
|
||||||
|
{
|
||||||
|
return rangeMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
return byNumber.TryGetValue(sceneResult.TemporarySceneNumber, out var numberMatch) ? numberMatch : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SceneIntelligenceScene? TryReadScene(StoryIntelligenceSavedSceneResult result)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(result.ParsedJson))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var direct = JsonSerializer.Deserialize<SceneIntelligenceScene>(result.ParsedJson, JsonOptions);
|
||||||
|
if (!string.IsNullOrWhiteSpace(direct?.SchemaVersion))
|
||||||
|
{
|
||||||
|
return direct;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var document = JsonDocument.Parse(result.ParsedJson);
|
||||||
|
if (document.RootElement.TryGetProperty("parsedScene", out var parsedScene))
|
||||||
|
{
|
||||||
|
return parsedScene.Deserialize<SceneIntelligenceScene>(JsonOptions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int? MatchRole(IReadOnlyList<CharacterRoleInSceneType> roleTypes, string? role)
|
||||||
|
{
|
||||||
|
var value = Clean(role);
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return roleTypes.FirstOrDefault(type =>
|
||||||
|
value.Contains(type.TypeName, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| type.TypeName.Contains(value, StringComparison.OrdinalIgnoreCase))?.CharacterRoleInSceneTypeID;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int? MatchPresence(IReadOnlyList<PresenceType> presenceTypes, bool mentionedOnly)
|
||||||
|
{
|
||||||
|
var preferred = mentionedOnly ? "Mentioned" : "Present";
|
||||||
|
return presenceTypes.FirstOrDefault(type => type.TypeName.Contains(preferred, StringComparison.OrdinalIgnoreCase))?.PresenceTypeID;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddResolvedName(Dictionary<string, int> map, string name, int characterId)
|
||||||
|
{
|
||||||
|
var clean = Clean(name);
|
||||||
|
if (!string.IsNullOrWhiteSpace(clean))
|
||||||
|
{
|
||||||
|
map[clean] = characterId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed, SceneIntelligenceCharacter character)
|
||||||
|
{
|
||||||
|
var note = Clean(character.Notes);
|
||||||
|
if (!string.IsNullOrWhiteSpace(note))
|
||||||
|
{
|
||||||
|
return $"Scene {scene.SceneNumber:g}: {note}";
|
||||||
|
}
|
||||||
|
|
||||||
|
var summary = Clean(parsed.Summary?.Short);
|
||||||
|
return string.IsNullOrWhiteSpace(summary) ? $"Scene {scene.SceneNumber:g}: {scene.SceneTitle}" : $"Scene {scene.SceneNumber:g}: {summary}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal? Max(decimal? current, decimal? next)
|
||||||
|
=> current.HasValue && next.HasValue ? Math.Max(current.Value, next.Value) : current ?? next;
|
||||||
|
|
||||||
|
private static string ConfidenceBand(decimal? confidence)
|
||||||
|
=> confidence switch
|
||||||
|
{
|
||||||
|
>= 0.75m => "High",
|
||||||
|
>= 0.45m => "Medium",
|
||||||
|
null => "Unknown",
|
||||||
|
_ => "Low"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string? FirstName(string name)
|
||||||
|
{
|
||||||
|
var parts = Clean(name).Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
return parts.Length > 1 ? parts[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsGenericPerson(string name)
|
||||||
|
=> GenericPeople.Contains(name) || GenericPeople.Contains(Normalise(name));
|
||||||
|
|
||||||
|
private static string Normalise(string? value)
|
||||||
|
=> Clean(value).ToLowerInvariant();
|
||||||
|
|
||||||
|
private static string Clean(string? value)
|
||||||
|
=> string.Join(' ', (value ?? string.Empty).Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
|
||||||
|
|
||||||
|
private static HashSet<string> CreateSet(params string[] values)
|
||||||
|
=> values.Select(Normalise).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private sealed class CharacterIndex
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, CharacterMatch> byName = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public void Add(string? name, int characterId, string characterName)
|
||||||
|
{
|
||||||
|
var key = Normalise(name);
|
||||||
|
if (!string.IsNullOrWhiteSpace(key))
|
||||||
|
{
|
||||||
|
byName.TryAdd(key, new CharacterMatch(characterId, characterName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public CharacterMatch? Find(string name)
|
||||||
|
=> byName.TryGetValue(Normalise(name), out var match) ? match : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record CharacterMatch(int CharacterID, string CharacterName);
|
||||||
|
|
||||||
|
private sealed class CharacterCandidate(string key, string displayName, int? existingCharacterId, string? existingCharacterName)
|
||||||
|
{
|
||||||
|
public string Key { get; } = key;
|
||||||
|
public string DisplayName { get; } = displayName;
|
||||||
|
public int? ExistingCharacterID { get; } = existingCharacterId;
|
||||||
|
public string? ExistingCharacterName { get; } = existingCharacterName;
|
||||||
|
public HashSet<string> Aliases { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
public List<CharacterAppearanceImport> PresentAppearances { get; } = [];
|
||||||
|
public List<CharacterAppearanceImport> MentionedOnlyAppearances { get; } = [];
|
||||||
|
public decimal? Confidence { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public string? FirstAppearanceNote { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record CharacterAppearanceImport(
|
||||||
|
int SceneID,
|
||||||
|
decimal SceneNumber,
|
||||||
|
string SceneTitle,
|
||||||
|
string? RoleInScene,
|
||||||
|
bool MentionedOnly,
|
||||||
|
string? Notes,
|
||||||
|
decimal? Confidence,
|
||||||
|
bool AlreadyLinked);
|
||||||
|
|
||||||
|
private sealed record ImportedSceneAnalysis(int SceneID, SceneIntelligenceScene Parsed);
|
||||||
|
|
||||||
|
private sealed record CharacterCandidateData(
|
||||||
|
bool HasCommittedScenes,
|
||||||
|
int AlreadyLinkedCount,
|
||||||
|
IReadOnlyList<CharacterCandidate> Candidates,
|
||||||
|
IReadOnlyList<ImportedSceneAnalysis> SceneAnalyses);
|
||||||
|
}
|
||||||
@ -84,6 +84,8 @@ public sealed class StoryIntelligenceImportCommitService(
|
|||||||
TimeConfidenceID = prepared.TimeConfidenceID,
|
TimeConfidenceID = prepared.TimeConfidenceID,
|
||||||
RevisionStatusID = prepared.RevisionStatusID,
|
RevisionStatusID = prepared.RevisionStatusID,
|
||||||
TimelineNoteTypeID = prepared.TimelineNoteTypeID,
|
TimelineNoteTypeID = prepared.TimelineNoteTypeID,
|
||||||
|
ChapterSummary = prepared.ChapterSummary,
|
||||||
|
ChapterPurposeID = prepared.ChapterPurposeID,
|
||||||
Scenes = prepared.Scenes,
|
Scenes = prepared.Scenes,
|
||||||
Warnings = prepared.Confirmation.Warnings
|
Warnings = prepared.Confirmation.Warnings
|
||||||
});
|
});
|
||||||
@ -158,7 +160,12 @@ public sealed class StoryIntelligenceImportCommitService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var purposeMap = lookupData.ScenePurposeTypes.ToDictionary(type => Clean(type.PurposeName), type => type.ScenePurposeTypeID, StringComparer.OrdinalIgnoreCase);
|
var purposeMap = lookupData.ScenePurposeTypes.ToDictionary(type => Clean(type.PurposeName), type => type.ScenePurposeTypeID, StringComparer.OrdinalIgnoreCase);
|
||||||
|
var chapterPurposeMap = lookupData.ChapterPurposeTypes.ToDictionary(type => Clean(type.PurposeName), type => type.ChapterPurposeID, StringComparer.OrdinalIgnoreCase);
|
||||||
var metricMap = BuildMetricMap(activeMetricTypes);
|
var metricMap = BuildMetricMap(activeMetricTypes);
|
||||||
|
var chapterResult = await storyRuns.GetChapterResultAsync(runId);
|
||||||
|
var parsedChapter = TryReadChapter(chapterResult);
|
||||||
|
var chapterSummary = Clean(parsedChapter?.ChapterSummary);
|
||||||
|
var chapterPurposeId = MatchChapterPurpose(parsedChapter, chapterPurposeMap);
|
||||||
var importScenes = new List<StoryIntelligenceSceneImportItem>();
|
var importScenes = new List<StoryIntelligenceSceneImportItem>();
|
||||||
foreach (var sceneResult in sceneResults.OrderBy(scene => scene.TemporarySceneNumber))
|
foreach (var sceneResult in sceneResults.OrderBy(scene => scene.TemporarySceneNumber))
|
||||||
{
|
{
|
||||||
@ -216,7 +223,9 @@ public sealed class StoryIntelligenceImportCommitService(
|
|||||||
timeModeId,
|
timeModeId,
|
||||||
timeConfidenceId,
|
timeConfidenceId,
|
||||||
revisionStatusId,
|
revisionStatusId,
|
||||||
timelineNoteTypeId);
|
timelineNoteTypeId,
|
||||||
|
string.IsNullOrWhiteSpace(chapterSummary) ? null : chapterSummary,
|
||||||
|
chapterPurposeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static StoryIntelligenceSceneImportItem BuildImportScene(
|
private static StoryIntelligenceSceneImportItem BuildImportScene(
|
||||||
@ -671,6 +680,84 @@ public sealed class StoryIntelligenceImportCommitService(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ChapterStructureModel? TryReadChapter(StoryIntelligenceSavedChapterResult? result)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(result?.ParsedJson))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<ChapterStructureModel>(result.ParsedJson, JsonOptions);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int? MatchChapterPurpose(ChapterStructureModel? chapter, IReadOnlyDictionary<string, int> purposeMap)
|
||||||
|
{
|
||||||
|
if (chapter is null || purposeMap.Count == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var texts = new List<string>();
|
||||||
|
if (!string.IsNullOrWhiteSpace(chapter.ChapterSummary))
|
||||||
|
{
|
||||||
|
texts.Add(chapter.ChapterSummary);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chapter.ExtensionData is not null)
|
||||||
|
{
|
||||||
|
foreach (var name in new[] { "chapterPurpose", "primaryPurpose", "purpose" })
|
||||||
|
{
|
||||||
|
if (chapter.ExtensionData.TryGetValue(name, out var element) && element.ValueKind == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
texts.Add(element.GetString() ?? string.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
texts.AddRange((chapter.SceneBoundaries ?? []).Select(boundary => boundary.Reason ?? string.Empty));
|
||||||
|
var combined = Clean(string.Join(" ", texts));
|
||||||
|
if (string.IsNullOrWhiteSpace(combined))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var direct = purposeMap.FirstOrDefault(pair => combined.Contains(pair.Key, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (direct.Value > 0)
|
||||||
|
{
|
||||||
|
return direct.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var lowered = combined.ToLowerInvariant();
|
||||||
|
foreach (var (key, value) in purposeMap)
|
||||||
|
{
|
||||||
|
var purpose = key.ToLowerInvariant();
|
||||||
|
if ((purpose.Contains("investigation") && ContainsAny(lowered, "investigat", "question", "search", "clue"))
|
||||||
|
|| (purpose.Contains("revelation") && ContainsAny(lowered, "reveal", "discover", "learns", "truth"))
|
||||||
|
|| (purpose.Contains("relationship") && ContainsAny(lowered, "relationship", "bond", "trust", "conflict between"))
|
||||||
|
|| (purpose.Contains("character") && ContainsAny(lowered, "character", "grief", "fear", "choice", "realises", "realizes"))
|
||||||
|
|| (purpose.Contains("escalation") && ContainsAny(lowered, "escalat", "danger", "worsens", "pressure"))
|
||||||
|
|| (purpose.Contains("turning") && ContainsAny(lowered, "turning point", "changes direction", "decision"))
|
||||||
|
|| (purpose.Contains("transition") && ContainsAny(lowered, "transition", "moves from", "sets up"))
|
||||||
|
|| (purpose.Contains("resolution") && ContainsAny(lowered, "resolve", "settles", "concludes"))
|
||||||
|
|| (purpose.Contains("world") && ContainsAny(lowered, "world", "setting", "institution", "rules")))
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ContainsAny(string value, params string[] needles)
|
||||||
|
=> needles.Any(needle => value.Contains(needle, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
private static string? ExtractSummaryFromJsonText(string? value)
|
private static string? ExtractSummaryFromJsonText(string? value)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(value))
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
@ -781,5 +868,7 @@ public sealed class StoryIntelligenceImportCommitService(
|
|||||||
int TimeModeID,
|
int TimeModeID,
|
||||||
int TimeConfidenceID,
|
int TimeConfidenceID,
|
||||||
int RevisionStatusID,
|
int RevisionStatusID,
|
||||||
int? TimelineNoteTypeID);
|
int? TimelineNoteTypeID,
|
||||||
|
string? ChapterSummary,
|
||||||
|
int? ChapterPurposeID);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,140 @@
|
|||||||
|
SET ANSI_NULLS ON;
|
||||||
|
GO
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.ChapterPurposeTypes', N'U') IS NULL
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE dbo.ChapterPurposeTypes
|
||||||
|
(
|
||||||
|
ChapterPurposeID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_ChapterPurposeTypes PRIMARY KEY,
|
||||||
|
PurposeName nvarchar(100) NOT NULL,
|
||||||
|
SortOrder int NOT NULL CONSTRAINT DF_ChapterPurposeTypes_SortOrder DEFAULT 0,
|
||||||
|
IsArchived bit NOT NULL CONSTRAINT DF_ChapterPurposeTypes_IsArchived DEFAULT 0,
|
||||||
|
CONSTRAINT UQ_ChapterPurposeTypes_PurposeName UNIQUE (PurposeName)
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
MERGE dbo.ChapterPurposeTypes AS target
|
||||||
|
USING (VALUES
|
||||||
|
(N'Opening', 10),
|
||||||
|
(N'Character Development', 20),
|
||||||
|
(N'Relationship Development', 30),
|
||||||
|
(N'World Building', 40),
|
||||||
|
(N'Investigation', 50),
|
||||||
|
(N'Revelation', 60),
|
||||||
|
(N'Escalation', 70),
|
||||||
|
(N'Turning Point', 80),
|
||||||
|
(N'Climax', 90),
|
||||||
|
(N'Resolution', 100),
|
||||||
|
(N'Transition', 110)
|
||||||
|
) AS source (PurposeName, SortOrder)
|
||||||
|
ON target.PurposeName = source.PurposeName
|
||||||
|
WHEN MATCHED THEN UPDATE SET SortOrder = source.SortOrder, IsArchived = 0
|
||||||
|
WHEN NOT MATCHED THEN INSERT (PurposeName, SortOrder, IsArchived) VALUES (source.PurposeName, source.SortOrder, 0);
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF COL_LENGTH(N'dbo.Chapters', N'ChapterPurposeID') IS NULL
|
||||||
|
ALTER TABLE dbo.Chapters ADD ChapterPurposeID int NULL;
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_Chapters_ChapterPurposeTypes')
|
||||||
|
ALTER TABLE dbo.Chapters ADD CONSTRAINT FK_Chapters_ChapterPurposeTypes FOREIGN KEY (ChapterPurposeID) REFERENCES dbo.ChapterPurposeTypes(ChapterPurposeID);
|
||||||
|
GO
|
||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.Lookup_All
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
SELECT RevisionStatusID, StatusName, SortOrder FROM dbo.RevisionStatuses ORDER BY SortOrder;
|
||||||
|
SELECT TimeModeID, TimeModeName, SortOrder FROM dbo.TimeModes ORDER BY SortOrder;
|
||||||
|
SELECT DurationUnitID, DurationUnitName, SortOrder FROM dbo.DurationUnits ORDER BY SortOrder;
|
||||||
|
SELECT TimeConfidenceID, TimeConfidenceName, SortOrder FROM dbo.TimeConfidences ORDER BY SortOrder;
|
||||||
|
SELECT ScenePurposeTypeID, PurposeName, SortOrder FROM dbo.ScenePurposeTypes WHERE IsArchived = 0 ORDER BY SortOrder;
|
||||||
|
SELECT ChapterPurposeID, PurposeName, SortOrder, IsArchived FROM dbo.ChapterPurposeTypes WHERE IsArchived = 0 ORDER BY SortOrder;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.Chapter_ListByBook
|
||||||
|
@BookID int
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
SELECT c.ChapterID, c.BookID, c.ChapterNumber, c.ChapterTitle, c.POVCharacterID, c.Summary,
|
||||||
|
c.ChapterPurposeID, cpt.PurposeName AS ChapterPurposeName,
|
||||||
|
c.SortOrder, c.RevisionStatusID, rs.StatusName AS RevisionStatusName,
|
||||||
|
c.CreatedDate, c.UpdatedDate, c.IsArchived, c.IsLinkedToManuscript,
|
||||||
|
c.ManuscriptDocumentID, c.ExternalReference, c.LinkedUtc
|
||||||
|
FROM dbo.Chapters c
|
||||||
|
INNER JOIN dbo.RevisionStatuses rs ON rs.RevisionStatusID = c.RevisionStatusID
|
||||||
|
LEFT JOIN dbo.ChapterPurposeTypes cpt ON cpt.ChapterPurposeID = c.ChapterPurposeID
|
||||||
|
WHERE c.BookID = @BookID AND c.IsArchived = 0
|
||||||
|
ORDER BY c.SortOrder, c.ChapterNumber;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.Chapter_Get
|
||||||
|
@ChapterID int
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
SELECT c.ChapterID, c.BookID, c.ChapterNumber, c.ChapterTitle, c.POVCharacterID, c.Summary,
|
||||||
|
c.ChapterPurposeID, cpt.PurposeName AS ChapterPurposeName,
|
||||||
|
c.SortOrder, c.RevisionStatusID, rs.StatusName AS RevisionStatusName,
|
||||||
|
c.CreatedDate, c.UpdatedDate, c.IsArchived, c.IsLinkedToManuscript,
|
||||||
|
c.ManuscriptDocumentID, c.ExternalReference, c.LinkedUtc
|
||||||
|
FROM dbo.Chapters c
|
||||||
|
INNER JOIN dbo.RevisionStatuses rs ON rs.RevisionStatusID = c.RevisionStatusID
|
||||||
|
LEFT JOIN dbo.ChapterPurposeTypes cpt ON cpt.ChapterPurposeID = c.ChapterPurposeID
|
||||||
|
WHERE c.ChapterID = @ChapterID AND c.IsArchived = 0;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.Chapter_Save
|
||||||
|
@ChapterID int = NULL,
|
||||||
|
@BookID int,
|
||||||
|
@ChapterNumber decimal(10,2),
|
||||||
|
@ChapterTitle nvarchar(200),
|
||||||
|
@Summary nvarchar(max) = NULL,
|
||||||
|
@ChapterPurposeID int = NULL,
|
||||||
|
@RevisionStatusID int
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
IF @ChapterID IS NULL OR @ChapterID = 0
|
||||||
|
BEGIN
|
||||||
|
DECLARE @NextChapterOrder int = ISNULL((SELECT MAX(SortOrder) FROM dbo.Chapters WHERE BookID = @BookID), 0) + 10;
|
||||||
|
INSERT dbo.Chapters (BookID, ChapterNumber, ChapterTitle, Summary, ChapterPurposeID, SortOrder, RevisionStatusID)
|
||||||
|
VALUES (@BookID, @ChapterNumber, @ChapterTitle, @Summary, @ChapterPurposeID, @NextChapterOrder, @RevisionStatusID);
|
||||||
|
SELECT CAST(SCOPE_IDENTITY() AS int) AS ChapterID;
|
||||||
|
RETURN;
|
||||||
|
END;
|
||||||
|
|
||||||
|
UPDATE dbo.Chapters
|
||||||
|
SET ChapterNumber = @ChapterNumber,
|
||||||
|
ChapterTitle = @ChapterTitle,
|
||||||
|
Summary = @Summary,
|
||||||
|
ChapterPurposeID = @ChapterPurposeID,
|
||||||
|
RevisionStatusID = @RevisionStatusID,
|
||||||
|
UpdatedDate = SYSUTCDATETIME()
|
||||||
|
WHERE ChapterID = @ChapterID;
|
||||||
|
SELECT @ChapterID AS ChapterID;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceImportCommit_UpdateChapterPlanning
|
||||||
|
@ChapterID int,
|
||||||
|
@Summary nvarchar(max) = NULL,
|
||||||
|
@ChapterPurposeID int = NULL
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
|
||||||
|
UPDATE dbo.Chapters
|
||||||
|
SET Summary = COALESCE(NULLIF(LTRIM(RTRIM(@Summary)), N''), Summary),
|
||||||
|
ChapterPurposeID = COALESCE(@ChapterPurposeID, ChapterPurposeID),
|
||||||
|
UpdatedDate = SYSUTCDATETIME()
|
||||||
|
WHERE ChapterID = @ChapterID;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
@ -260,6 +260,9 @@ public sealed class ChapterEditViewModel
|
|||||||
|
|
||||||
public string? Summary { get; set; }
|
public string? Summary { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "Chapter purpose")]
|
||||||
|
public int? ChapterPurposeID { get; set; }
|
||||||
|
|
||||||
[Display(Name = "Revision status")]
|
[Display(Name = "Revision status")]
|
||||||
public int RevisionStatusID { get; set; }
|
public int RevisionStatusID { get; set; }
|
||||||
|
|
||||||
@ -268,6 +271,7 @@ public sealed class ChapterEditViewModel
|
|||||||
public Book? Book { get; set; }
|
public Book? Book { get; set; }
|
||||||
public Project? Project { get; set; }
|
public Project? Project { get; set; }
|
||||||
public IReadOnlyList<SelectListItem> RevisionStatuses { get; set; } = [];
|
public IReadOnlyList<SelectListItem> RevisionStatuses { get; set; } = [];
|
||||||
|
public IReadOnlyList<SelectListItem> ChapterPurposes { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ChapterDetailViewModel
|
public sealed class ChapterDetailViewModel
|
||||||
|
|||||||
@ -137,6 +137,7 @@ 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 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);
|
||||||
public int CommittedChapterCount => Chapters.Count(chapter => chapter.HasCompletedCommit);
|
public int CommittedChapterCount => Chapters.Count(chapter => chapter.HasCompletedCommit);
|
||||||
@ -150,6 +151,43 @@ public sealed class StoryIntelligenceProgressViewModel
|
|||||||
public bool HasActiveRuns => Chapters.Any(chapter => chapter.IsActive);
|
public bool HasActiveRuns => Chapters.Any(chapter => chapter.IsActive);
|
||||||
public bool AllCommitted => Chapters.Count > 0 && Chapters.All(chapter => chapter.HasCompletedCommit);
|
public bool AllCommitted => Chapters.Count > 0 && Chapters.All(chapter => chapter.HasCompletedCommit);
|
||||||
public bool HasReadyScenesToCreate => Chapters.Any(chapter => chapter.CanCommit);
|
public bool HasReadyScenesToCreate => Chapters.Any(chapter => chapter.CanCommit);
|
||||||
|
public bool HasCharactersToReview => CharacterReview.Candidates.Count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class StoryIntelligenceCharacterReviewViewModel
|
||||||
|
{
|
||||||
|
public bool CanImport { get; init; }
|
||||||
|
public bool HasCommittedScenes { get; init; }
|
||||||
|
public int AlreadyLinkedCount { get; init; }
|
||||||
|
public IReadOnlyList<StoryIntelligenceCharacterReviewCandidateViewModel> Candidates { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class StoryIntelligenceCharacterReviewCandidateViewModel
|
||||||
|
{
|
||||||
|
public string Key { get; init; } = string.Empty;
|
||||||
|
public string CharacterName { get; init; } = string.Empty;
|
||||||
|
public string ImportName { get; init; } = string.Empty;
|
||||||
|
public int AppearsInScenes { get; init; }
|
||||||
|
public string Confidence { get; init; } = "Unknown";
|
||||||
|
public IReadOnlyList<string> PossibleAliases { get; init; } = [];
|
||||||
|
public string? ExampleFirstAppearance { get; init; }
|
||||||
|
public int? ExistingCharacterID { get; init; }
|
||||||
|
public string? ExistingCharacterName { get; init; }
|
||||||
|
public bool IsExistingMatch => ExistingCharacterID.HasValue;
|
||||||
|
public string ActionLabel => IsExistingMatch ? "Link existing" : "Create";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class StoryIntelligenceCharacterImportForm
|
||||||
|
{
|
||||||
|
public Guid BatchID { get; set; }
|
||||||
|
public List<StoryIntelligenceCharacterImportChoiceForm> Characters { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class StoryIntelligenceCharacterImportChoiceForm
|
||||||
|
{
|
||||||
|
public string Key { get; set; } = string.Empty;
|
||||||
|
public bool Include { get; set; }
|
||||||
|
public string? ImportName { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class StoryIntelligenceCompletionViewModel
|
public sealed class StoryIntelligenceCompletionViewModel
|
||||||
|
|||||||
@ -147,7 +147,13 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td class="narrow">@chapter.ChapterNumber</td>
|
<td class="narrow">@chapter.ChapterNumber</td>
|
||||||
<td><a asp-controller="Chapters" asp-action="Details" asp-route-id="@chapter.ChapterID">@chapter.ChapterTitle</a></td>
|
<td><a asp-controller="Chapters" asp-action="Details" asp-route-id="@chapter.ChapterID">@chapter.ChapterTitle</a></td>
|
||||||
<td><span class="status-pill">@chapter.RevisionStatusName</span></td>
|
<td>
|
||||||
|
<span class="status-pill">@chapter.RevisionStatusName</span>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(chapter.ChapterPurposeName))
|
||||||
|
{
|
||||||
|
<span class="status-pill">@chapter.ChapterPurposeName</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
<td><span class="status-pill">@(chapter.IsLinkedToManuscript ? "🔗 Linked" : "📝 Planned")</span></td>
|
<td><span class="status-pill">@(chapter.IsLinkedToManuscript ? "🔗 Linked" : "📝 Planned")</span></td>
|
||||||
<td>@chapter.Summary</td>
|
<td>@chapter.Summary</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
|
|||||||
@ -26,6 +26,10 @@
|
|||||||
</p>
|
</p>
|
||||||
<h1>Chapter @Model.Chapter.ChapterNumber: @Model.Chapter.ChapterTitle <help-icon key="chapters.overview" /></h1>
|
<h1>Chapter @Model.Chapter.ChapterNumber: @Model.Chapter.ChapterTitle <help-icon key="chapters.overview" /></h1>
|
||||||
<span class="status-pill">@(Model.Chapter.IsLinkedToManuscript ? "🔗 Linked" : "📝 Planned")</span>
|
<span class="status-pill">@(Model.Chapter.IsLinkedToManuscript ? "🔗 Linked" : "📝 Planned")</span>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(Model.Chapter.ChapterPurposeName))
|
||||||
|
{
|
||||||
|
<span class="status-pill">@Model.Chapter.ChapterPurposeName</span>
|
||||||
|
}
|
||||||
@if (!string.IsNullOrWhiteSpace(Model.Chapter.Summary))
|
@if (!string.IsNullOrWhiteSpace(Model.Chapter.Summary))
|
||||||
{
|
{
|
||||||
<p class="lead-text">@Model.Chapter.Summary</p>
|
<p class="lead-text">@Model.Chapter.Summary</p>
|
||||||
|
|||||||
@ -20,7 +20,7 @@
|
|||||||
<label asp-for="ChapterNumber" class="form-label">Chapter number <help-icon key="chapters.fields.chapterNumber" mode="inline" /></label>
|
<label asp-for="ChapterNumber" class="form-label">Chapter number <help-icon key="chapters.fields.chapterNumber" mode="inline" /></label>
|
||||||
<input asp-for="ChapterNumber" class="form-control" />
|
<input asp-for="ChapterNumber" class="form-control" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-3">
|
||||||
<label asp-for="ChapterTitle" class="form-label"></label>
|
<label asp-for="ChapterTitle" class="form-label"></label>
|
||||||
<input asp-for="ChapterTitle" class="form-control" />
|
<input asp-for="ChapterTitle" class="form-control" />
|
||||||
<span asp-validation-for="ChapterTitle" class="text-danger"></span>
|
<span asp-validation-for="ChapterTitle" class="text-danger"></span>
|
||||||
@ -29,6 +29,10 @@
|
|||||||
<label asp-for="RevisionStatusID" class="form-label">Revision status <help-icon key="chapters.fields.revisionStatus" mode="inline" /></label>
|
<label asp-for="RevisionStatusID" class="form-label">Revision status <help-icon key="chapters.fields.revisionStatus" mode="inline" /></label>
|
||||||
<select asp-for="RevisionStatusID" asp-items="Model.RevisionStatuses" class="form-select"></select>
|
<select asp-for="RevisionStatusID" asp-items="Model.RevisionStatuses" class="form-select"></select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label asp-for="ChapterPurposeID" class="form-label"></label>
|
||||||
|
<select asp-for="ChapterPurposeID" asp-items="Model.ChapterPurposes" class="form-select"></select>
|
||||||
|
</div>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label asp-for="Summary" class="form-label"></label>
|
<label asp-for="Summary" class="form-label"></label>
|
||||||
<textarea asp-for="Summary" class="form-control" rows="4"></textarea>
|
<textarea asp-for="Summary" class="form-control" rows="4"></textarea>
|
||||||
|
|||||||
@ -17,7 +17,7 @@
|
|||||||
<div class="onboarding-copy">
|
<div class="onboarding-copy">
|
||||||
<p class="eyebrow">Step 9 of 9</p>
|
<p class="eyebrow">Step 9 of 9</p>
|
||||||
<h1 id="story-review-title">Review Story Intelligence Results</h1>
|
<h1 id="story-review-title">Review Story Intelligence Results</h1>
|
||||||
<p>PlotDirector has finished reading your manuscript. Nothing has been added to your project yet. Please review the detected scenes before creating them.</p>
|
<p>PlotDirector has finished reading your manuscript. Review the detected scenes first, then approve any characters you want to add or link.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
|
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
|
||||||
@ -30,7 +30,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<section class="alert alert-info">
|
<section class="alert alert-info">
|
||||||
Only scenes will be created. Characters, locations, assets, relationships and knowledge will be imported during later Story Intelligence phases.
|
Scene creation runs first. Character import is reviewed separately and only creates or links approved characters. Locations, assets, relationships and knowledge remain for later Story Intelligence phases.
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="story-review-summary" aria-label="Analysis summary">
|
<section class="story-review-summary" aria-label="Analysis summary">
|
||||||
@ -178,6 +178,56 @@
|
|||||||
Scene creation uses PlotDirector import safeguards. Duplicate imports are blocked, and failures roll back safely.
|
Scene creation uses PlotDirector import safeguards. Duplicate imports are blocked, and failures roll back safely.
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
@if (Model.CharacterReview.HasCommittedScenes)
|
||||||
|
{
|
||||||
|
<section class="story-review-chapter-list" aria-label="Character review">
|
||||||
|
<h2>Review Characters</h2>
|
||||||
|
@if (Model.CharacterReview.Candidates.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-muted">No new character suggestions are waiting. Existing scene character links are already up to date where PlotDirector could match them.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p>Approve the characters PlotDirector should create or link from the imported scenes. You can rename new characters before import.</p>
|
||||||
|
<form asp-action="ImportStoryIntelligenceCharacters" method="post">
|
||||||
|
<input type="hidden" name="BatchID" value="@Model.BatchID" />
|
||||||
|
<div class="story-scene-preview-list">
|
||||||
|
@for (var i = 0; i < Model.CharacterReview.Candidates.Count; i++)
|
||||||
|
{
|
||||||
|
var candidate = Model.CharacterReview.Candidates[i];
|
||||||
|
<article class="story-scene-preview">
|
||||||
|
<input type="hidden" name="Characters[@i].Key" value="@candidate.Key" />
|
||||||
|
<div>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="Characters[@i].Include" value="true" checked />
|
||||||
|
<strong>@candidate.CharacterName</strong>
|
||||||
|
</label>
|
||||||
|
<span class="story-review-status story-review-status--ready">@candidate.ActionLabel</span>
|
||||||
|
</div>
|
||||||
|
<dl>
|
||||||
|
<div><dt>Appears in</dt><dd>@candidate.AppearsInScenes.ToString("N0") scene@(candidate.AppearsInScenes == 1 ? string.Empty : "s")</dd></div>
|
||||||
|
<div><dt>Confidence</dt><dd>@candidate.Confidence</dd></div>
|
||||||
|
<div><dt>Match</dt><dd>@(candidate.ExistingCharacterName ?? "New character")</dd></div>
|
||||||
|
</dl>
|
||||||
|
@if (candidate.PossibleAliases.Count > 0)
|
||||||
|
{
|
||||||
|
<p>Aliases: @string.Join(", ", candidate.PossibleAliases)</p>
|
||||||
|
}
|
||||||
|
@if (!string.IsNullOrWhiteSpace(candidate.ExampleFirstAppearance))
|
||||||
|
{
|
||||||
|
<p>@candidate.ExampleFirstAppearance</p>
|
||||||
|
}
|
||||||
|
<label class="form-label" for="character-import-name-@i">Import name</label>
|
||||||
|
<input id="character-import-name-@i" class="form-control" name="Characters[@i].ImportName" value="@candidate.ImportName" readonly="@candidate.IsExistingMatch" />
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary mt-3" type="submit">Import approved characters</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="onboarding-actions">
|
<div class="onboarding-actions">
|
||||||
@if (Model.BatchID.HasValue)
|
@if (Model.BatchID.HasValue)
|
||||||
{
|
{
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user