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 });
|
||||
}
|
||||
|
||||
[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")]
|
||||
public async Task<IActionResult> StoryIntelligenceComplete(Guid batchId)
|
||||
{
|
||||
|
||||
@ -2397,7 +2397,7 @@ public sealed class ChapterRepository(ISqlConnectionFactory connectionFactory) :
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleAsync<int>(
|
||||
"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);
|
||||
}
|
||||
|
||||
@ -2657,7 +2657,8 @@ public sealed class LookupRepository(ISqlConnectionFactory connectionFactory) :
|
||||
TimeModes = (await result.ReadAsync<TimeMode>()).ToList(),
|
||||
DurationUnits = (await result.ReadAsync<DurationUnit>()).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();
|
||||
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 int? POVCharacterID { get; set; }
|
||||
public string? Summary { get; set; }
|
||||
public int? ChapterPurposeID { get; set; }
|
||||
public string? ChapterPurposeName { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public int RevisionStatusID { get; set; }
|
||||
public string RevisionStatusName { get; set; } = string.Empty;
|
||||
@ -439,6 +441,14 @@ public sealed class RevisionStatus
|
||||
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 int TimeModeID { get; set; }
|
||||
@ -2555,4 +2565,5 @@ public sealed class LookupData
|
||||
public IReadOnlyList<DurationUnit> DurationUnits { get; init; } = [];
|
||||
public IReadOnlyList<TimeConfidence> TimeConfidences { 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 RevisionStatusID { 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<string> Warnings { get; init; } = [];
|
||||
}
|
||||
|
||||
@ -206,6 +206,7 @@ public class Program
|
||||
builder.Services.AddScoped<IStoryIntelligenceExistingChapterQueueService, StoryIntelligenceExistingChapterQueueService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceFullChapterTestService, StoryIntelligenceFullChapterTestService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceImportCommitService, StoryIntelligenceImportCommitService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceCharacterImportService, StoryIntelligenceCharacterImportService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>();
|
||||
builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>();
|
||||
builder.Services.AddScoped<IPersistedStoryIntelligenceRunner, PersistedStoryIntelligenceRunner>();
|
||||
|
||||
@ -1377,7 +1377,8 @@ public sealed class ChapterService(
|
||||
Project = project,
|
||||
ChapterNumber = existing.Count + 1,
|
||||
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,
|
||||
ChapterTitle = chapter.ChapterTitle,
|
||||
Summary = chapter.Summary,
|
||||
ChapterPurposeID = chapter.ChapterPurposeID,
|
||||
RevisionStatusID = chapter.RevisionStatusID,
|
||||
IsLinkedToManuscript = chapter.IsLinkedToManuscript,
|
||||
Book = book,
|
||||
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,
|
||||
ChapterTitle = model.ChapterTitle,
|
||||
Summary = model.Summary,
|
||||
ChapterPurposeID = model.ChapterPurposeID,
|
||||
RevisionStatusID = model.RevisionStatusID
|
||||
});
|
||||
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)
|
||||
=> 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(
|
||||
|
||||
@ -14,6 +14,7 @@ public interface IOnboardingStoryIntelligenceService
|
||||
Task<StoryIntelligenceProgressViewModel?> CancelAsync(Guid batchId);
|
||||
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAsync(Guid batchId, int runId);
|
||||
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAllAsync(Guid batchId);
|
||||
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportCharactersAsync(Guid batchId, StoryIntelligenceCharacterImportForm form);
|
||||
Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId);
|
||||
}
|
||||
|
||||
@ -23,6 +24,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
||||
IManuscriptScanPreviewStore scanStore,
|
||||
IStoryIntelligenceResultRepository runs,
|
||||
IStoryIntelligenceImportCommitService commits,
|
||||
IStoryIntelligenceCharacterImportService characterImport,
|
||||
IStoryIntelligenceClient client,
|
||||
IOnboardingStoryIntelligenceBatchStore batchStore,
|
||||
IStoryIntelligenceProgressNotifier notifier,
|
||||
@ -261,7 +263,8 @@ public sealed class OnboardingStoryIntelligenceService(
|
||||
BookID = batch.BookID,
|
||||
ProjectName = batch.ProjectName,
|
||||
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)
|
||||
{
|
||||
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,
|
||||
RevisionStatusID = prepared.RevisionStatusID,
|
||||
TimelineNoteTypeID = prepared.TimelineNoteTypeID,
|
||||
ChapterSummary = prepared.ChapterSummary,
|
||||
ChapterPurposeID = prepared.ChapterPurposeID,
|
||||
Scenes = prepared.Scenes,
|
||||
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 chapterPurposeMap = lookupData.ChapterPurposeTypes.ToDictionary(type => Clean(type.PurposeName), type => type.ChapterPurposeID, StringComparer.OrdinalIgnoreCase);
|
||||
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>();
|
||||
foreach (var sceneResult in sceneResults.OrderBy(scene => scene.TemporarySceneNumber))
|
||||
{
|
||||
@ -216,7 +223,9 @@ public sealed class StoryIntelligenceImportCommitService(
|
||||
timeModeId,
|
||||
timeConfidenceId,
|
||||
revisionStatusId,
|
||||
timelineNoteTypeId);
|
||||
timelineNoteTypeId,
|
||||
string.IsNullOrWhiteSpace(chapterSummary) ? null : chapterSummary,
|
||||
chapterPurposeId);
|
||||
}
|
||||
|
||||
private static StoryIntelligenceSceneImportItem BuildImportScene(
|
||||
@ -671,6 +680,84 @@ public sealed class StoryIntelligenceImportCommitService(
|
||||
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)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
@ -781,5 +868,7 @@ public sealed class StoryIntelligenceImportCommitService(
|
||||
int TimeModeID,
|
||||
int TimeConfidenceID,
|
||||
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; }
|
||||
|
||||
[Display(Name = "Chapter purpose")]
|
||||
public int? ChapterPurposeID { get; set; }
|
||||
|
||||
[Display(Name = "Revision status")]
|
||||
public int RevisionStatusID { get; set; }
|
||||
|
||||
@ -268,6 +271,7 @@ public sealed class ChapterEditViewModel
|
||||
public Book? Book { get; set; }
|
||||
public Project? Project { get; set; }
|
||||
public IReadOnlyList<SelectListItem> RevisionStatuses { get; set; } = [];
|
||||
public IReadOnlyList<SelectListItem> ChapterPurposes { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ChapterDetailViewModel
|
||||
|
||||
@ -137,6 +137,7 @@ public sealed class StoryIntelligenceProgressViewModel
|
||||
public string? ProjectName { get; init; }
|
||||
public string? BookTitle { get; init; }
|
||||
public IReadOnlyList<StoryIntelligenceOnboardingChapterViewModel> Chapters { get; init; } = [];
|
||||
public StoryIntelligenceCharacterReviewViewModel CharacterReview { get; init; } = new();
|
||||
public int ChapterCount => Chapters.Count;
|
||||
public int CompletedChapterCount => Chapters.Count(chapter => chapter.IsRunComplete);
|
||||
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 AllCommitted => Chapters.Count > 0 && Chapters.All(chapter => chapter.HasCompletedCommit);
|
||||
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
|
||||
|
||||
@ -147,7 +147,13 @@
|
||||
<tr>
|
||||
<td class="narrow">@chapter.ChapterNumber</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>@chapter.Summary</td>
|
||||
<td class="text-end">
|
||||
|
||||
@ -26,6 +26,10 @@
|
||||
</p>
|
||||
<h1>Chapter @Model.Chapter.ChapterNumber: @Model.Chapter.ChapterTitle <help-icon key="chapters.overview" /></h1>
|
||||
<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))
|
||||
{
|
||||
<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>
|
||||
<input asp-for="ChapterNumber" class="form-control" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="col-md-3">
|
||||
<label asp-for="ChapterTitle" class="form-label"></label>
|
||||
<input asp-for="ChapterTitle" class="form-control" />
|
||||
<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>
|
||||
<select asp-for="RevisionStatusID" asp-items="Model.RevisionStatuses" class="form-select"></select>
|
||||
</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">
|
||||
<label asp-for="Summary" class="form-label"></label>
|
||||
<textarea asp-for="Summary" class="form-control" rows="4"></textarea>
|
||||
|
||||
@ -17,7 +17,7 @@
|
||||
<div class="onboarding-copy">
|
||||
<p class="eyebrow">Step 9 of 9</p>
|
||||
<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>
|
||||
|
||||
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
|
||||
@ -30,7 +30,7 @@
|
||||
}
|
||||
|
||||
<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 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.
|
||||
</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">
|
||||
@if (Model.BatchID.HasValue)
|
||||
{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user