Phase 16H - Live Character Detection While Writing
This commit is contained in:
parent
507e16165e
commit
8be690857e
@ -116,6 +116,20 @@ public sealed class WordCompanionController(IWordCompanionService wordCompanion)
|
|||||||
return response is null ? BadRequest() : Ok(response);
|
return response is null ? BadRequest() : Ok(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("runtime/characters")]
|
||||||
|
public async Task<IActionResult> GetRuntimeCharacters([FromQuery] int projectId, [FromQuery] Guid documentGuid)
|
||||||
|
{
|
||||||
|
var response = await wordCompanion.ListRuntimeCharacterAliasesAsync(projectId, documentGuid);
|
||||||
|
return response is null ? BadRequest() : Ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("runtime/character-suggestions")]
|
||||||
|
public async Task<IActionResult> CreateRuntimeCharacterSuggestions(WordCompanionRuntimeCharacterSuggestionsRequest request)
|
||||||
|
{
|
||||||
|
var response = await wordCompanion.CreateRuntimeCharacterSuggestionsAsync(request);
|
||||||
|
return response is null ? BadRequest() : Ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("manuscript/book/{bookId:int}")]
|
[HttpGet("manuscript/book/{bookId:int}")]
|
||||||
public async Task<IActionResult> GetManuscriptForBook(int bookId)
|
public async Task<IActionResult> GetManuscriptForBook(int bookId)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -20,6 +20,8 @@ public interface IWordCompanionRepository
|
|||||||
Task<WordCompanionUpdateSceneLinksResponse?> UpdateSceneLinksAsync(int sceneId, int userId, WordCompanionUpdateSceneLinksRequest request);
|
Task<WordCompanionUpdateSceneLinksResponse?> UpdateSceneLinksAsync(int sceneId, int userId, WordCompanionUpdateSceneLinksRequest request);
|
||||||
Task<WordCompanionUpdateWordCountResponse?> UpdateWordCountAsync(int sceneId, int userId, int actualWords, DateTime? lastWorkedOn);
|
Task<WordCompanionUpdateWordCountResponse?> UpdateWordCountAsync(int sceneId, int userId, int actualWords, DateTime? lastWorkedOn);
|
||||||
Task<WordCompanionUpdateWordCountResponse?> UpdateRuntimeSceneWordCountAsync(int userId, WordCompanionRuntimeSceneWordCountRequest request);
|
Task<WordCompanionUpdateWordCountResponse?> UpdateRuntimeSceneWordCountAsync(int userId, WordCompanionRuntimeSceneWordCountRequest request);
|
||||||
|
Task<IReadOnlyList<WordCompanionRuntimeCharacterAliasDto>> ListRuntimeCharacterAliasesAsync(int userId, int projectId, Guid documentGuid);
|
||||||
|
Task<WordCompanionRuntimeCharacterSuggestionsResponse?> CreateRuntimeCharacterSuggestionsAsync(int userId, WordCompanionRuntimeCharacterSuggestionsRequest request);
|
||||||
Task<WordCompanionManuscriptAnalyseResponse?> AnalyseManuscriptAsync(int userId, WordCompanionManuscriptAnalyseRequest request);
|
Task<WordCompanionManuscriptAnalyseResponse?> AnalyseManuscriptAsync(int userId, WordCompanionManuscriptAnalyseRequest request);
|
||||||
Task<WordCompanionManuscriptImportResponse?> ImportManuscriptAsync(int userId, WordCompanionManuscriptImportRequest request);
|
Task<WordCompanionManuscriptImportResponse?> ImportManuscriptAsync(int userId, WordCompanionManuscriptImportRequest request);
|
||||||
Task<bool> HasProjectAccessAsync(int projectId, int userId);
|
Task<bool> HasProjectAccessAsync(int projectId, int userId);
|
||||||
@ -253,6 +255,31 @@ public sealed class WordCompanionRepository(ISqlConnectionFactory connectionFact
|
|||||||
commandType: CommandType.StoredProcedure);
|
commandType: CommandType.StoredProcedure);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<WordCompanionRuntimeCharacterAliasDto>> ListRuntimeCharacterAliasesAsync(int userId, int projectId, Guid documentGuid)
|
||||||
|
{
|
||||||
|
using var connection = connectionFactory.CreateConnection();
|
||||||
|
var rows = await connection.QueryAsync<WordCompanionRuntimeCharacterAliasDto>(
|
||||||
|
"dbo.WordCompanion_Runtime_CharacterAlias_List",
|
||||||
|
new { UserID = userId, ProjectID = projectId, DocumentGuid = documentGuid },
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
return rows.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WordCompanionRuntimeCharacterSuggestionsResponse?> CreateRuntimeCharacterSuggestionsAsync(int userId, WordCompanionRuntimeCharacterSuggestionsRequest request)
|
||||||
|
{
|
||||||
|
using var connection = connectionFactory.CreateConnection();
|
||||||
|
return await connection.QuerySingleOrDefaultAsync<WordCompanionRuntimeCharacterSuggestionsResponse>(
|
||||||
|
"dbo.WordCompanion_Runtime_CharacterSuggestions_Create",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
UserID = userId,
|
||||||
|
request.DocumentGuid,
|
||||||
|
request.SceneId,
|
||||||
|
CharacterIds = ToCsv(request.CharacterIds)
|
||||||
|
},
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<WordCompanionManuscriptAnalyseResponse?> AnalyseManuscriptAsync(int userId, WordCompanionManuscriptAnalyseRequest request)
|
public async Task<WordCompanionManuscriptAnalyseResponse?> AnalyseManuscriptAsync(int userId, WordCompanionManuscriptAnalyseRequest request)
|
||||||
{
|
{
|
||||||
using var connection = connectionFactory.CreateConnection();
|
using var connection = connectionFactory.CreateConnection();
|
||||||
|
|||||||
@ -228,6 +228,35 @@ public sealed class WordCompanionRuntimeSceneWordCountRequest
|
|||||||
public int WordCount { get; set; }
|
public int WordCount { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class WordCompanionRuntimeCharacterAliasDto
|
||||||
|
{
|
||||||
|
public int CharacterId { get; init; }
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
public string MatchText { get; init; } = string.Empty;
|
||||||
|
public bool IsDisplayName { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class WordCompanionRuntimeCharacterAliasResponse
|
||||||
|
{
|
||||||
|
public int ProjectId { get; init; }
|
||||||
|
public IReadOnlyList<WordCompanionRuntimeCharacterAliasDto> Characters { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class WordCompanionRuntimeCharacterSuggestionsRequest
|
||||||
|
{
|
||||||
|
public Guid DocumentGuid { get; set; }
|
||||||
|
public int SceneId { get; set; }
|
||||||
|
public IReadOnlyList<int> CharacterIds { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class WordCompanionRuntimeCharacterSuggestionsResponse
|
||||||
|
{
|
||||||
|
public int SceneId { get; init; }
|
||||||
|
public int CreatedCount { get; init; }
|
||||||
|
public int SkippedCount { get; init; }
|
||||||
|
public string Message { get; init; } = "No new character suggestions.";
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class WordCompanionManuscriptDocumentDto
|
public sealed class WordCompanionManuscriptDocumentDto
|
||||||
{
|
{
|
||||||
public int ManuscriptDocumentId { get; init; }
|
public int ManuscriptDocumentId { get; init; }
|
||||||
|
|||||||
@ -20,6 +20,8 @@ public interface IWordCompanionService
|
|||||||
Task<WordCompanionUpdateSceneLinksResponse?> UpdateSceneLinksAsync(int sceneId, WordCompanionUpdateSceneLinksRequest request);
|
Task<WordCompanionUpdateSceneLinksResponse?> UpdateSceneLinksAsync(int sceneId, WordCompanionUpdateSceneLinksRequest request);
|
||||||
Task<WordCompanionUpdateWordCountResponse?> UpdateWordCountAsync(int sceneId, WordCompanionUpdateWordCountRequest request);
|
Task<WordCompanionUpdateWordCountResponse?> UpdateWordCountAsync(int sceneId, WordCompanionUpdateWordCountRequest request);
|
||||||
Task<WordCompanionUpdateWordCountResponse?> UpdateRuntimeSceneWordCountAsync(WordCompanionRuntimeSceneWordCountRequest request);
|
Task<WordCompanionUpdateWordCountResponse?> UpdateRuntimeSceneWordCountAsync(WordCompanionRuntimeSceneWordCountRequest request);
|
||||||
|
Task<WordCompanionRuntimeCharacterAliasResponse?> ListRuntimeCharacterAliasesAsync(int projectId, Guid documentGuid);
|
||||||
|
Task<WordCompanionRuntimeCharacterSuggestionsResponse?> CreateRuntimeCharacterSuggestionsAsync(WordCompanionRuntimeCharacterSuggestionsRequest request);
|
||||||
Task<WordCompanionRuntimeBookResponse?> GetRuntimeBookAsync(int bookId, Guid documentGuid);
|
Task<WordCompanionRuntimeBookResponse?> GetRuntimeBookAsync(int bookId, Guid documentGuid);
|
||||||
Task<WordCompanionManuscriptBookResponse?> GetManuscriptForBookAsync(int bookId);
|
Task<WordCompanionManuscriptBookResponse?> GetManuscriptForBookAsync(int bookId);
|
||||||
Task<WordCompanionManuscriptLinkResponse?> LinkManuscriptAsync(WordCompanionManuscriptLinkRequest request);
|
Task<WordCompanionManuscriptLinkResponse?> LinkManuscriptAsync(WordCompanionManuscriptLinkRequest request);
|
||||||
@ -234,6 +236,36 @@ public sealed class WordCompanionService(
|
|||||||
: repository.UpdateRuntimeSceneWordCountAsync(RequireUserId(), request);
|
: repository.UpdateRuntimeSceneWordCountAsync(RequireUserId(), request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<WordCompanionRuntimeCharacterAliasResponse?> ListRuntimeCharacterAliasesAsync(int projectId, Guid documentGuid)
|
||||||
|
{
|
||||||
|
if (projectId <= 0 || documentGuid == Guid.Empty)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var aliases = await repository.ListRuntimeCharacterAliasesAsync(RequireUserId(), projectId, documentGuid);
|
||||||
|
return new WordCompanionRuntimeCharacterAliasResponse
|
||||||
|
{
|
||||||
|
ProjectId = projectId,
|
||||||
|
Characters = aliases
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<WordCompanionRuntimeCharacterSuggestionsResponse?> CreateRuntimeCharacterSuggestionsAsync(WordCompanionRuntimeCharacterSuggestionsRequest request)
|
||||||
|
{
|
||||||
|
request.CharacterIds = request.CharacterIds
|
||||||
|
.Where(id => id > 0)
|
||||||
|
.Distinct()
|
||||||
|
.Take(CharacterDiscoverySuggestionLimit)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return request.DocumentGuid == Guid.Empty
|
||||||
|
|| request.SceneId <= 0
|
||||||
|
|| request.CharacterIds.Count == 0
|
||||||
|
? Task.FromResult<WordCompanionRuntimeCharacterSuggestionsResponse?>(null)
|
||||||
|
: repository.CreateRuntimeCharacterSuggestionsAsync(RequireUserId(), request);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<WordCompanionRuntimeBookResponse?> GetRuntimeBookAsync(int bookId, Guid documentGuid)
|
public async Task<WordCompanionRuntimeBookResponse?> GetRuntimeBookAsync(int bookId, Guid documentGuid)
|
||||||
{
|
{
|
||||||
if (bookId <= 0 || documentGuid == Guid.Empty)
|
if (bookId <= 0 || documentGuid == Guid.Empty)
|
||||||
|
|||||||
185
PlotLine/Sql/098_Phase16H_WordCompanionCharacterSuggestions.sql
Normal file
185
PlotLine/Sql/098_Phase16H_WordCompanionCharacterSuggestions.sql
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
IF OBJECT_ID(N'dbo.SceneCharacterSuggestions', N'U') IS NULL
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE dbo.SceneCharacterSuggestions
|
||||||
|
(
|
||||||
|
SceneCharacterSuggestionID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_SceneCharacterSuggestions PRIMARY KEY,
|
||||||
|
SceneID int NOT NULL,
|
||||||
|
CharacterID int NOT NULL,
|
||||||
|
SuggestionSource nvarchar(50) NOT NULL CONSTRAINT DF_SceneCharacterSuggestions_Source DEFAULT (N'Word Companion'),
|
||||||
|
DetectedUtc datetime2 NOT NULL CONSTRAINT DF_SceneCharacterSuggestions_DetectedUtc DEFAULT SYSUTCDATETIME(),
|
||||||
|
Status nvarchar(20) NOT NULL CONSTRAINT DF_SceneCharacterSuggestions_Status DEFAULT (N'Pending'),
|
||||||
|
Confidence nvarchar(20) NOT NULL CONSTRAINT DF_SceneCharacterSuggestions_Confidence DEFAULT (N'High'),
|
||||||
|
Reason nvarchar(200) NULL,
|
||||||
|
CreatedByUserID int NOT NULL,
|
||||||
|
ReviewedByUserID int NULL,
|
||||||
|
ReviewedUtc datetime2 NULL,
|
||||||
|
CONSTRAINT FK_SceneCharacterSuggestions_Scenes FOREIGN KEY (SceneID) REFERENCES dbo.Scenes(SceneID),
|
||||||
|
CONSTRAINT FK_SceneCharacterSuggestions_Characters FOREIGN KEY (CharacterID) REFERENCES dbo.Characters(CharacterID),
|
||||||
|
CONSTRAINT FK_SceneCharacterSuggestions_CreatedByUser FOREIGN KEY (CreatedByUserID) REFERENCES dbo.AppUser(UserID),
|
||||||
|
CONSTRAINT FK_SceneCharacterSuggestions_ReviewedByUser FOREIGN KEY (ReviewedByUserID) REFERENCES dbo.AppUser(UserID),
|
||||||
|
CONSTRAINT CK_SceneCharacterSuggestions_Status CHECK (Status IN (N'Pending', N'Accepted', N'Rejected', N'Dismissed')),
|
||||||
|
CONSTRAINT CK_SceneCharacterSuggestions_Confidence CHECK (Confidence IN (N'High', N'Medium', N'Low'))
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_SceneCharacterSuggestions_Pending' AND object_id = OBJECT_ID(N'dbo.SceneCharacterSuggestions'))
|
||||||
|
CREATE UNIQUE INDEX UX_SceneCharacterSuggestions_Pending ON dbo.SceneCharacterSuggestions(SceneID, CharacterID) WHERE Status = N'Pending';
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_SceneCharacterSuggestions_SceneStatus' AND object_id = OBJECT_ID(N'dbo.SceneCharacterSuggestions'))
|
||||||
|
CREATE INDEX IX_SceneCharacterSuggestions_SceneStatus ON dbo.SceneCharacterSuggestions(SceneID, Status, CharacterID);
|
||||||
|
GO
|
||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.WordCompanion_Runtime_CharacterAlias_List
|
||||||
|
@UserID int,
|
||||||
|
@ProjectID int,
|
||||||
|
@DocumentGuid uniqueidentifier
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
|
||||||
|
IF @DocumentGuid IS NULL
|
||||||
|
OR @DocumentGuid = '00000000-0000-0000-0000-000000000000'
|
||||||
|
OR @ProjectID <= 0
|
||||||
|
RETURN;
|
||||||
|
|
||||||
|
IF NOT EXISTS
|
||||||
|
(
|
||||||
|
SELECT 1
|
||||||
|
FROM dbo.ManuscriptDocuments md
|
||||||
|
INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = md.ProjectID
|
||||||
|
INNER JOIN dbo.Projects p ON p.ProjectID = md.ProjectID
|
||||||
|
WHERE md.DocumentGuid = @DocumentGuid
|
||||||
|
AND md.ProjectID = @ProjectID
|
||||||
|
AND md.IsActive = 1
|
||||||
|
AND p.IsArchived = 0
|
||||||
|
AND pua.UserID = @UserID
|
||||||
|
AND pua.IsActive = 1
|
||||||
|
)
|
||||||
|
RETURN;
|
||||||
|
|
||||||
|
SELECT c.CharacterID AS CharacterId,
|
||||||
|
c.CharacterName AS Name,
|
||||||
|
c.CharacterName AS MatchText,
|
||||||
|
CAST(1 AS bit) AS IsDisplayName
|
||||||
|
FROM dbo.Characters c
|
||||||
|
WHERE c.ProjectID = @ProjectID
|
||||||
|
AND c.IsArchived = 0
|
||||||
|
AND NULLIF(LTRIM(RTRIM(c.CharacterName)), N'') IS NOT NULL
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT c.CharacterID AS CharacterId,
|
||||||
|
c.CharacterName AS Name,
|
||||||
|
ca.Alias AS MatchText,
|
||||||
|
CAST(0 AS bit) AS IsDisplayName
|
||||||
|
FROM dbo.Characters c
|
||||||
|
INNER JOIN dbo.CharacterAliases ca ON ca.CharacterID = c.CharacterID
|
||||||
|
WHERE c.ProjectID = @ProjectID
|
||||||
|
AND c.IsArchived = 0
|
||||||
|
AND NULLIF(LTRIM(RTRIM(ca.Alias)), N'') IS NOT NULL
|
||||||
|
ORDER BY Name, CharacterId, IsDisplayName DESC, MatchText;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.WordCompanion_Runtime_CharacterSuggestions_Create
|
||||||
|
@UserID int,
|
||||||
|
@DocumentGuid uniqueidentifier,
|
||||||
|
@SceneID int,
|
||||||
|
@CharacterIds nvarchar(max)
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
|
||||||
|
IF @DocumentGuid IS NULL
|
||||||
|
OR @DocumentGuid = '00000000-0000-0000-0000-000000000000'
|
||||||
|
OR @SceneID <= 0
|
||||||
|
RETURN;
|
||||||
|
|
||||||
|
DECLARE @ProjectID int;
|
||||||
|
DECLARE @ManuscriptDocumentID int;
|
||||||
|
|
||||||
|
SELECT @ProjectID = b.ProjectID,
|
||||||
|
@ManuscriptDocumentID = md.ManuscriptDocumentID
|
||||||
|
FROM dbo.Scenes s
|
||||||
|
INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID
|
||||||
|
INNER JOIN dbo.Books b ON b.BookID = c.BookID
|
||||||
|
INNER JOIN dbo.Projects p ON p.ProjectID = b.ProjectID
|
||||||
|
INNER JOIN dbo.ManuscriptDocuments md ON md.BookID = b.BookID
|
||||||
|
INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = b.ProjectID
|
||||||
|
WHERE s.SceneID = @SceneID
|
||||||
|
AND s.IsArchived = 0
|
||||||
|
AND c.IsArchived = 0
|
||||||
|
AND b.IsArchived = 0
|
||||||
|
AND p.IsArchived = 0
|
||||||
|
AND md.DocumentGuid = @DocumentGuid
|
||||||
|
AND md.IsActive = 1
|
||||||
|
AND s.IsLinkedToManuscript = 1
|
||||||
|
AND s.ManuscriptDocumentID = md.ManuscriptDocumentID
|
||||||
|
AND pua.UserID = @UserID
|
||||||
|
AND pua.IsActive = 1;
|
||||||
|
|
||||||
|
IF @ProjectID IS NULL
|
||||||
|
RETURN;
|
||||||
|
|
||||||
|
DECLARE @Requested table (CharacterID int NOT NULL PRIMARY KEY);
|
||||||
|
|
||||||
|
INSERT @Requested (CharacterID)
|
||||||
|
SELECT DISTINCT TRY_CONVERT(int, LTRIM(RTRIM(value)))
|
||||||
|
FROM STRING_SPLIT(COALESCE(@CharacterIds, N''), N',')
|
||||||
|
WHERE TRY_CONVERT(int, LTRIM(RTRIM(value))) IS NOT NULL
|
||||||
|
AND TRY_CONVERT(int, LTRIM(RTRIM(value))) > 0;
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM @Requested)
|
||||||
|
BEGIN
|
||||||
|
SELECT @SceneID AS SceneId,
|
||||||
|
0 AS CreatedCount,
|
||||||
|
0 AS SkippedCount,
|
||||||
|
N'No character suggestions detected.' AS Message;
|
||||||
|
RETURN;
|
||||||
|
END;
|
||||||
|
|
||||||
|
IF EXISTS
|
||||||
|
(
|
||||||
|
SELECT 1
|
||||||
|
FROM @Requested requested
|
||||||
|
WHERE NOT EXISTS
|
||||||
|
(
|
||||||
|
SELECT 1
|
||||||
|
FROM dbo.Characters ch
|
||||||
|
WHERE ch.CharacterID = requested.CharacterID
|
||||||
|
AND ch.ProjectID = @ProjectID
|
||||||
|
AND ch.IsArchived = 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
RETURN;
|
||||||
|
|
||||||
|
DECLARE @Inserted table (CharacterID int NOT NULL PRIMARY KEY);
|
||||||
|
|
||||||
|
INSERT dbo.SceneCharacterSuggestions
|
||||||
|
(SceneID, CharacterID, SuggestionSource, Status, Confidence, Reason, CreatedByUserID)
|
||||||
|
OUTPUT inserted.CharacterID INTO @Inserted
|
||||||
|
SELECT @SceneID,
|
||||||
|
requested.CharacterID,
|
||||||
|
N'Word Companion',
|
||||||
|
N'Pending',
|
||||||
|
N'High',
|
||||||
|
N'Detected in manuscript text',
|
||||||
|
@UserID
|
||||||
|
FROM @Requested requested
|
||||||
|
WHERE NOT EXISTS
|
||||||
|
(SELECT 1 FROM dbo.SceneCharacters sc WHERE sc.SceneID = @SceneID AND sc.CharacterID = requested.CharacterID)
|
||||||
|
AND NOT EXISTS
|
||||||
|
(SELECT 1 FROM dbo.SceneCharacterSuggestions suggestion WHERE suggestion.SceneID = @SceneID AND suggestion.CharacterID = requested.CharacterID AND suggestion.Status = N'Pending');
|
||||||
|
|
||||||
|
SELECT @SceneID AS SceneId,
|
||||||
|
(SELECT COUNT(*) FROM @Inserted) AS CreatedCount,
|
||||||
|
(SELECT COUNT(*) FROM @Requested) - (SELECT COUNT(*) FROM @Inserted) AS SkippedCount,
|
||||||
|
CASE
|
||||||
|
WHEN (SELECT COUNT(*) FROM @Inserted) = 1 THEN N'1 character suggestion detected.'
|
||||||
|
WHEN (SELECT COUNT(*) FROM @Inserted) > 1 THEN CONCAT((SELECT COUNT(*) FROM @Inserted), N' character suggestions detected.')
|
||||||
|
ELSE N'No new character suggestions.'
|
||||||
|
END AS Message;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
@ -207,6 +207,14 @@
|
|||||||
let sceneWordCountSyncPending = false;
|
let sceneWordCountSyncPending = false;
|
||||||
let lastSyncedSceneId = null;
|
let lastSyncedSceneId = null;
|
||||||
let lastSyncedWordCount = null;
|
let lastSyncedWordCount = null;
|
||||||
|
let characterSuggestionTimer = null;
|
||||||
|
let characterSuggestionInFlight = false;
|
||||||
|
let characterSuggestionPending = false;
|
||||||
|
let characterAliasCacheProjectId = null;
|
||||||
|
let characterAliasCacheDocumentGuid = "";
|
||||||
|
let characterAliasCache = [];
|
||||||
|
let lastSuggestedSceneId = null;
|
||||||
|
let lastSuggestedCharacterSignature = "";
|
||||||
let lastLinkedSceneTitle = "";
|
let lastLinkedSceneTitle = "";
|
||||||
let lastLinkedChapterTitle = "";
|
let lastLinkedChapterTitle = "";
|
||||||
let runtimeLastDebugAt = 0;
|
let runtimeLastDebugAt = 0;
|
||||||
@ -481,6 +489,7 @@
|
|||||||
sceneContextStale = !!isStale;
|
sceneContextStale = !!isStale;
|
||||||
if (sceneContextStale) {
|
if (sceneContextStale) {
|
||||||
clearPendingSceneWordCountSync();
|
clearPendingSceneWordCountSync();
|
||||||
|
clearPendingCharacterSuggestionDetection();
|
||||||
}
|
}
|
||||||
setText(sceneTrackingStatus, sceneContextStale ? "Stale" : sceneTrackingMode);
|
setText(sceneTrackingStatus, sceneContextStale ? "Stale" : sceneTrackingMode);
|
||||||
updateSceneActionButtons();
|
updateSceneActionButtons();
|
||||||
@ -499,6 +508,7 @@
|
|||||||
}
|
}
|
||||||
if (!resolvedSceneId) {
|
if (!resolvedSceneId) {
|
||||||
clearPendingSceneWordCountSync();
|
clearPendingSceneWordCountSync();
|
||||||
|
clearPendingCharacterSuggestionDetection();
|
||||||
}
|
}
|
||||||
updateSceneActionButtons();
|
updateSceneActionButtons();
|
||||||
setSyncStatus(sceneContextStale
|
setSyncStatus(sceneContextStale
|
||||||
@ -565,6 +575,7 @@
|
|||||||
documentStructureCache = null;
|
documentStructureCache = null;
|
||||||
plotDirectorStructureCache = null;
|
plotDirectorStructureCache = null;
|
||||||
plotDirectorStructureBookId = null;
|
plotDirectorStructureBookId = null;
|
||||||
|
resetRuntimeCharacterAliasCache();
|
||||||
setRuntimeBookContext(null);
|
setRuntimeBookContext(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -919,6 +930,7 @@
|
|||||||
setResolvedScene(sceneId, chapterId, resolutionMethod);
|
setResolvedScene(sceneId, chapterId, resolutionMethod);
|
||||||
renderCompanionData(companionData);
|
renderCompanionData(companionData);
|
||||||
scheduleSceneWordCountSync();
|
scheduleSceneWordCountSync();
|
||||||
|
scheduleCharacterSuggestionDetection();
|
||||||
};
|
};
|
||||||
|
|
||||||
const setText = (element, value) => {
|
const setText = (element, value) => {
|
||||||
@ -2220,6 +2232,36 @@
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const readCachedCurrentSceneText = async () => {
|
||||||
|
const scene = documentStructureCache?.currentScene;
|
||||||
|
if (!scene || !Number.isInteger(scene.startParagraphIndex) || !Number.isInteger(scene.endParagraphIndex)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!officeReady || !wordHostAvailable || !window.Word || typeof window.Word.run !== "function") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return await window.Word.run(async (context) => {
|
||||||
|
const bodyParagraphs = context.document.body.paragraphs;
|
||||||
|
bodyParagraphs.load("text");
|
||||||
|
await context.sync();
|
||||||
|
|
||||||
|
const texts = [];
|
||||||
|
const endIndex = Math.min(scene.endParagraphIndex, bodyParagraphs.items.length - 1);
|
||||||
|
const chapterHeadingIndex = documentStructureCache.currentChapter?.startParagraphIndex;
|
||||||
|
for (let index = scene.startParagraphIndex; index <= endIndex; index += 1) {
|
||||||
|
const text = String(bodyParagraphs.items[index]?.text || "").trim();
|
||||||
|
if (!text || sceneSeparatorTexts.has(text) || index === chapterHeadingIndex) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
texts.push(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
return texts.join("\n");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const refreshDocumentStructure = async () => {
|
const refreshDocumentStructure = async () => {
|
||||||
setDocumentMessage("");
|
setDocumentMessage("");
|
||||||
|
|
||||||
@ -2911,6 +2953,7 @@
|
|||||||
setDocumentMessage("Reading manuscript position...");
|
setDocumentMessage("Reading manuscript position...");
|
||||||
try {
|
try {
|
||||||
clearPlotDirectorStructureCache();
|
clearPlotDirectorStructureCache();
|
||||||
|
await loadRuntimeCharacterAliases(true);
|
||||||
await getBookStructureChapters(book.bookId);
|
await getBookStructureChapters(book.bookId);
|
||||||
const structure = await window.Word.run(async (context) => await readRuntimeDocumentStructure(context, !!book.usesExplicitScenes));
|
const structure = await window.Word.run(async (context) => await readRuntimeDocumentStructure(context, !!book.usesExplicitScenes));
|
||||||
renderOutline(structure);
|
renderOutline(structure);
|
||||||
@ -3804,6 +3847,149 @@
|
|||||||
}, delayMs);
|
}, delayMs);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearPendingCharacterSuggestionDetection = () => {
|
||||||
|
if (characterSuggestionTimer) {
|
||||||
|
window.clearTimeout(characterSuggestionTimer);
|
||||||
|
characterSuggestionTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetRuntimeCharacterAliasCache = () => {
|
||||||
|
characterAliasCacheProjectId = null;
|
||||||
|
characterAliasCacheDocumentGuid = "";
|
||||||
|
characterAliasCache = [];
|
||||||
|
lastSuggestedSceneId = null;
|
||||||
|
lastSuggestedCharacterSignature = "";
|
||||||
|
clearPendingCharacterSuggestionDetection();
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadRuntimeCharacterAliases = async (force = false) => {
|
||||||
|
const project = selectedProject();
|
||||||
|
const documentGuid = currentDocumentGuid();
|
||||||
|
if (!project || !documentGuid) {
|
||||||
|
characterAliasCache = [];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!force
|
||||||
|
&& characterAliasCacheProjectId === project.projectId
|
||||||
|
&& characterAliasCacheDocumentGuid === documentGuid
|
||||||
|
&& characterAliasCache.length > 0) {
|
||||||
|
return characterAliasCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetchJson(`/api/word-companion/runtime/characters?projectId=${encodeURIComponent(project.projectId)}&documentGuid=${encodeURIComponent(documentGuid)}`);
|
||||||
|
characterAliasCacheProjectId = project.projectId;
|
||||||
|
characterAliasCacheDocumentGuid = documentGuid;
|
||||||
|
characterAliasCache = Array.isArray(response?.characters) ? response.characters : [];
|
||||||
|
runtimeDebug("Character alias cache refreshed.");
|
||||||
|
return characterAliasCache;
|
||||||
|
};
|
||||||
|
|
||||||
|
const escapeRegex = (value) => String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
|
||||||
|
const hasWholeWordMatch = (text, matchText) => {
|
||||||
|
const normalized = String(matchText || "").trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pattern = escapeRegex(normalized).replace(/\s+/g, "\\s+");
|
||||||
|
return new RegExp(`(^|[^\\p{L}\\p{N}_])${pattern}(?=$|[^\\p{L}\\p{N}_])`, "iu").test(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
const detectCharactersInSceneText = (sceneText, aliases) => {
|
||||||
|
if (!sceneText || !Array.isArray(aliases) || aliases.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const detected = new Map();
|
||||||
|
for (const item of aliases) {
|
||||||
|
const characterId = Number.parseInt(item.characterId || "", 10);
|
||||||
|
if (!Number.isInteger(characterId) || characterId <= 0 || detected.has(characterId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasWholeWordMatch(sceneText, item.matchText)) {
|
||||||
|
detected.set(characterId, item.name || item.matchText || "Character");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...detected.entries()].map(([characterId, name]) => ({ characterId, name }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const finishCharacterSuggestionDetection = () => {
|
||||||
|
characterSuggestionInFlight = false;
|
||||||
|
if (characterSuggestionPending) {
|
||||||
|
characterSuggestionPending = false;
|
||||||
|
scheduleCharacterSuggestionDetection(1500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const detectAndSubmitCharacterSuggestions = async () => {
|
||||||
|
characterSuggestionTimer = null;
|
||||||
|
if (!resolvedSceneId || sceneContextStale || !currentDocumentGuid()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (characterSuggestionInFlight) {
|
||||||
|
characterSuggestionPending = true;
|
||||||
|
runtimeDebug("Character suggestion detection deferred; detection already running.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
characterSuggestionInFlight = true;
|
||||||
|
characterSuggestionPending = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [sceneText, aliases] = await Promise.all([
|
||||||
|
readCachedCurrentSceneText(),
|
||||||
|
loadRuntimeCharacterAliases()
|
||||||
|
]);
|
||||||
|
const detected = detectCharactersInSceneText(sceneText, aliases);
|
||||||
|
const characterIds = detected.map((item) => item.characterId);
|
||||||
|
const signature = characterIds.slice().sort((left, right) => left - right).join(",");
|
||||||
|
if (!signature || (lastSuggestedSceneId === resolvedSceneId && lastSuggestedCharacterSignature === signature)) {
|
||||||
|
runtimeDebug("Character suggestions skipped.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await postJson("/api/word-companion/runtime/character-suggestions", {
|
||||||
|
documentGuid: currentDocumentGuid(),
|
||||||
|
sceneId: resolvedSceneId,
|
||||||
|
characterIds
|
||||||
|
});
|
||||||
|
|
||||||
|
lastSuggestedSceneId = resolvedSceneId;
|
||||||
|
lastSuggestedCharacterSignature = signature;
|
||||||
|
if ((response?.createdCount || 0) > 0) {
|
||||||
|
const names = detected.slice(0, 3).map((item) => item.name).join(", ");
|
||||||
|
const suffix = detected.length > 3 ? ` +${detected.length - 3}` : "";
|
||||||
|
setDocumentMessage(names ? `Characters detected: ${names}${suffix}` : response.message);
|
||||||
|
}
|
||||||
|
runtimeDebug(response?.message || "Character suggestions submitted.");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Unable to detect character suggestions.", error);
|
||||||
|
setDiagnostics({ lastError: errorText(error) });
|
||||||
|
} finally {
|
||||||
|
finishCharacterSuggestionDetection();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleCharacterSuggestionDetection = (delayMs = 2500) => {
|
||||||
|
clearPendingCharacterSuggestionDetection();
|
||||||
|
if (!resolvedSceneId || sceneContextStale || characterSuggestionInFlight) {
|
||||||
|
if (characterSuggestionInFlight) {
|
||||||
|
characterSuggestionPending = true;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
characterSuggestionTimer = window.setTimeout(() => {
|
||||||
|
void detectAndSubmitCharacterSuggestions();
|
||||||
|
}, delayMs);
|
||||||
|
};
|
||||||
|
|
||||||
const refreshPlotDirectorChapter = async () => {
|
const refreshPlotDirectorChapter = async () => {
|
||||||
const project = selectedProject();
|
const project = selectedProject();
|
||||||
const book = selectedBook();
|
const book = selectedBook();
|
||||||
@ -4131,6 +4317,7 @@
|
|||||||
setSceneContextStale(false);
|
setSceneContextStale(false);
|
||||||
setSceneTrackingState("Current scene updated");
|
setSceneTrackingState("Current scene updated");
|
||||||
scheduleSceneWordCountSync();
|
scheduleSceneWordCountSync();
|
||||||
|
scheduleCharacterSuggestionDetection();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -4143,6 +4330,7 @@
|
|||||||
setSceneTrackingState("Current scene updated");
|
setSceneTrackingState("Current scene updated");
|
||||||
setDocumentMessage("Current scene updated.");
|
setDocumentMessage("Current scene updated.");
|
||||||
scheduleSceneWordCountSync();
|
scheduleSceneWordCountSync();
|
||||||
|
scheduleCharacterSuggestionDetection();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Unable to refresh current scene from Word selection.", error);
|
console.error("Unable to refresh current scene from Word selection.", error);
|
||||||
setDiagnostics({ lastError: errorText(error) });
|
setDiagnostics({ lastError: errorText(error) });
|
||||||
@ -4169,6 +4357,7 @@
|
|||||||
runtimeSelectionDirty = true;
|
runtimeSelectionDirty = true;
|
||||||
runtimeLastActivityAt = Date.now();
|
runtimeLastActivityAt = Date.now();
|
||||||
clearPendingSceneWordCountSync();
|
clearPendingSceneWordCountSync();
|
||||||
|
clearPendingCharacterSuggestionDetection();
|
||||||
runtimeDebug(`${reason} changed.`);
|
runtimeDebug(`${reason} changed.`);
|
||||||
|
|
||||||
if (isAutoRefreshingScene) {
|
if (isAutoRefreshingScene) {
|
||||||
@ -4258,6 +4447,7 @@
|
|||||||
setRefreshCurrentSceneBusy(true);
|
setRefreshCurrentSceneBusy(true);
|
||||||
setSceneTrackingState("Updating...");
|
setSceneTrackingState("Updating...");
|
||||||
try {
|
try {
|
||||||
|
await loadRuntimeCharacterAliases(true);
|
||||||
const scanned = await refreshDocumentStructure();
|
const scanned = await refreshDocumentStructure();
|
||||||
if (!scanned) {
|
if (!scanned) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user