Phase 16K - Live Asset Detection While Writing

This commit is contained in:
Nick Beckley 2026-06-28 21:49:21 +01:00
parent df03f46ca8
commit c8bae2d1d1
7 changed files with 399 additions and 21 deletions

View File

@ -156,6 +156,20 @@ public sealed class WordCompanionController(
return response is null ? BadRequest() : Ok(response);
}
[HttpGet("runtime/assets")]
public async Task<IActionResult> GetRuntimeAssets([FromQuery] int projectId, [FromQuery] Guid documentGuid)
{
var response = await wordCompanion.ListRuntimeAssetAliasesAsync(projectId, documentGuid);
return response is null ? BadRequest() : Ok(response);
}
[HttpPost("runtime/asset-suggestions")]
public async Task<IActionResult> CreateRuntimeAssetSuggestions(WordCompanionRuntimeAssetSuggestionsRequest request)
{
var response = await wordCompanion.CreateRuntimeAssetSuggestionsAsync(request);
return response is null ? BadRequest() : Ok(response);
}
[HttpGet("manuscript/book/{bookId:int}")]
public async Task<IActionResult> GetManuscriptForBook(int bookId)
{

View File

@ -23,6 +23,8 @@ public interface IWordCompanionRepository
Task<WordCompanionRuntimeCurrentSceneResponse?> ValidateRuntimeCurrentSceneAsync(int userId, WordCompanionRuntimeCurrentSceneRequest request);
Task<IReadOnlyList<WordCompanionRuntimeCharacterAliasDto>> ListRuntimeCharacterAliasesAsync(int userId, int projectId, Guid documentGuid);
Task<WordCompanionRuntimeCharacterSuggestionsResponse?> CreateRuntimeCharacterSuggestionsAsync(int userId, WordCompanionRuntimeCharacterSuggestionsRequest request);
Task<IReadOnlyList<WordCompanionRuntimeAssetAliasDto>> ListRuntimeAssetAliasesAsync(int userId, int projectId, Guid documentGuid);
Task<WordCompanionRuntimeAssetSuggestionsResponse?> CreateRuntimeAssetSuggestionsAsync(int userId, WordCompanionRuntimeAssetSuggestionsRequest request);
Task<WordCompanionManuscriptAnalyseResponse?> AnalyseManuscriptAsync(int userId, WordCompanionManuscriptAnalyseRequest request);
Task<WordCompanionManuscriptImportResponse?> ImportManuscriptAsync(int userId, WordCompanionManuscriptImportRequest request);
Task<bool> HasProjectAccessAsync(int projectId, int userId);
@ -298,6 +300,31 @@ public sealed class WordCompanionRepository(ISqlConnectionFactory connectionFact
commandType: CommandType.StoredProcedure);
}
public async Task<IReadOnlyList<WordCompanionRuntimeAssetAliasDto>> ListRuntimeAssetAliasesAsync(int userId, int projectId, Guid documentGuid)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<WordCompanionRuntimeAssetAliasDto>(
"dbo.WordCompanion_Runtime_AssetAlias_List",
new { UserID = userId, ProjectID = projectId, DocumentGuid = documentGuid },
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task<WordCompanionRuntimeAssetSuggestionsResponse?> CreateRuntimeAssetSuggestionsAsync(int userId, WordCompanionRuntimeAssetSuggestionsRequest request)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<WordCompanionRuntimeAssetSuggestionsResponse>(
"dbo.WordCompanion_Runtime_AssetSuggestions_Create",
new
{
UserID = userId,
request.DocumentGuid,
request.SceneId,
AssetIds = ToCsv(request.AssetIds)
},
commandType: CommandType.StoredProcedure);
}
public async Task<WordCompanionManuscriptAnalyseResponse?> AnalyseManuscriptAsync(int userId, WordCompanionManuscriptAnalyseRequest request)
{
using var connection = connectionFactory.CreateConnection();

View File

@ -275,6 +275,35 @@ public sealed class WordCompanionRuntimeCharacterSuggestionsResponse
public string Message { get; init; } = "No new character suggestions.";
}
public sealed class WordCompanionRuntimeAssetAliasDto
{
public int AssetId { get; init; }
public string Name { get; init; } = string.Empty;
public string MatchText { get; init; } = string.Empty;
public bool IsDisplayName { get; init; }
}
public sealed class WordCompanionRuntimeAssetAliasResponse
{
public int ProjectId { get; init; }
public IReadOnlyList<WordCompanionRuntimeAssetAliasDto> Assets { get; init; } = [];
}
public sealed class WordCompanionRuntimeAssetSuggestionsRequest
{
public Guid DocumentGuid { get; set; }
public int SceneId { get; set; }
public IReadOnlyList<int> AssetIds { get; set; } = [];
}
public sealed class WordCompanionRuntimeAssetSuggestionsResponse
{
public int SceneId { get; init; }
public int CreatedCount { get; init; }
public int SkippedCount { get; init; }
public string Message { get; init; } = "No new asset suggestions.";
}
public sealed class WordCompanionManuscriptDocumentDto
{
public int ManuscriptDocumentId { get; init; }

View File

@ -23,6 +23,8 @@ public interface IWordCompanionService
Task<WordCompanionRuntimeCurrentSceneResponse?> ValidateRuntimeCurrentSceneAsync(WordCompanionRuntimeCurrentSceneRequest request);
Task<WordCompanionRuntimeCharacterAliasResponse?> ListRuntimeCharacterAliasesAsync(int projectId, Guid documentGuid);
Task<WordCompanionRuntimeCharacterSuggestionsResponse?> CreateRuntimeCharacterSuggestionsAsync(WordCompanionRuntimeCharacterSuggestionsRequest request);
Task<WordCompanionRuntimeAssetAliasResponse?> ListRuntimeAssetAliasesAsync(int projectId, Guid documentGuid);
Task<WordCompanionRuntimeAssetSuggestionsResponse?> CreateRuntimeAssetSuggestionsAsync(WordCompanionRuntimeAssetSuggestionsRequest request);
Task<WordCompanionRuntimeBookResponse?> GetRuntimeBookAsync(int bookId, Guid documentGuid);
Task<WordCompanionManuscriptBookResponse?> GetManuscriptForBookAsync(int bookId);
Task<WordCompanionManuscriptLinkResponse?> LinkManuscriptAsync(WordCompanionManuscriptLinkRequest request);
@ -278,6 +280,36 @@ public sealed class WordCompanionService(
: repository.CreateRuntimeCharacterSuggestionsAsync(RequireUserId(), request);
}
public async Task<WordCompanionRuntimeAssetAliasResponse?> ListRuntimeAssetAliasesAsync(int projectId, Guid documentGuid)
{
if (projectId <= 0 || documentGuid == Guid.Empty)
{
return null;
}
var aliases = await repository.ListRuntimeAssetAliasesAsync(RequireUserId(), projectId, documentGuid);
return new WordCompanionRuntimeAssetAliasResponse
{
ProjectId = projectId,
Assets = aliases
};
}
public Task<WordCompanionRuntimeAssetSuggestionsResponse?> CreateRuntimeAssetSuggestionsAsync(WordCompanionRuntimeAssetSuggestionsRequest request)
{
request.AssetIds = request.AssetIds
.Where(id => id > 0)
.Distinct()
.Take(CharacterDiscoverySuggestionLimit)
.ToList();
return request.DocumentGuid == Guid.Empty
|| request.SceneId <= 0
|| request.AssetIds.Count == 0
? Task.FromResult<WordCompanionRuntimeAssetSuggestionsResponse?>(null)
: repository.CreateRuntimeAssetSuggestionsAsync(RequireUserId(), request);
}
public async Task<WordCompanionRuntimeBookResponse?> GetRuntimeBookAsync(int bookId, Guid documentGuid)
{
if (bookId <= 0 || documentGuid == Guid.Empty)

View File

@ -0,0 +1,189 @@
IF OBJECT_ID(N'dbo.SceneAssetSuggestions', N'U') IS NULL
BEGIN
CREATE TABLE dbo.SceneAssetSuggestions
(
SceneAssetSuggestionID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_SceneAssetSuggestions PRIMARY KEY,
SceneID int NOT NULL,
AssetID int NOT NULL,
SuggestionSource nvarchar(50) NOT NULL CONSTRAINT DF_SceneAssetSuggestions_Source DEFAULT (N'Word Companion'),
DetectedUtc datetime2 NOT NULL CONSTRAINT DF_SceneAssetSuggestions_DetectedUtc DEFAULT SYSUTCDATETIME(),
Status nvarchar(20) NOT NULL CONSTRAINT DF_SceneAssetSuggestions_Status DEFAULT (N'Pending'),
Confidence nvarchar(20) NOT NULL CONSTRAINT DF_SceneAssetSuggestions_Confidence DEFAULT (N'High'),
Reason nvarchar(200) NULL,
CreatedByUserID int NOT NULL,
ReviewedByUserID int NULL,
ReviewedUtc datetime2 NULL,
CONSTRAINT FK_SceneAssetSuggestions_Scenes FOREIGN KEY (SceneID) REFERENCES dbo.Scenes(SceneID),
CONSTRAINT FK_SceneAssetSuggestions_StoryAssets FOREIGN KEY (AssetID) REFERENCES dbo.StoryAssets(StoryAssetID),
CONSTRAINT FK_SceneAssetSuggestions_CreatedByUser FOREIGN KEY (CreatedByUserID) REFERENCES dbo.AppUser(UserID),
CONSTRAINT FK_SceneAssetSuggestions_ReviewedByUser FOREIGN KEY (ReviewedByUserID) REFERENCES dbo.AppUser(UserID),
CONSTRAINT CK_SceneAssetSuggestions_Status CHECK (Status IN (N'Pending', N'Accepted', N'Rejected', N'Dismissed')),
CONSTRAINT CK_SceneAssetSuggestions_Confidence CHECK (Confidence IN (N'High', N'Medium', N'Low'))
);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_SceneAssetSuggestions_Pending' AND object_id = OBJECT_ID(N'dbo.SceneAssetSuggestions'))
CREATE UNIQUE INDEX UX_SceneAssetSuggestions_Pending ON dbo.SceneAssetSuggestions(SceneID, AssetID) WHERE Status = N'Pending';
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_SceneAssetSuggestions_SceneStatus' AND object_id = OBJECT_ID(N'dbo.SceneAssetSuggestions'))
CREATE INDEX IX_SceneAssetSuggestions_SceneStatus ON dbo.SceneAssetSuggestions(SceneID, Status, AssetID);
GO
CREATE OR ALTER PROCEDURE dbo.WordCompanion_Runtime_AssetAlias_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 sa.StoryAssetID AS AssetId,
sa.AssetName AS Name,
sa.AssetName AS MatchText,
CAST(1 AS bit) AS IsDisplayName
FROM dbo.StoryAssets sa
WHERE sa.ProjectID = @ProjectID
AND sa.IsArchived = 0
AND NULLIF(LTRIM(RTRIM(sa.AssetName)), N'') IS NOT NULL
UNION ALL
SELECT sa.StoryAssetID AS AssetId,
sa.AssetName AS Name,
aa.Alias AS MatchText,
CAST(0 AS bit) AS IsDisplayName
FROM dbo.StoryAssets sa
INNER JOIN dbo.AssetAliases aa ON aa.StoryAssetID = sa.StoryAssetID
WHERE sa.ProjectID = @ProjectID
AND sa.IsArchived = 0
AND NULLIF(LTRIM(RTRIM(aa.Alias)), N'') IS NOT NULL
ORDER BY Name, AssetId, IsDisplayName DESC, MatchText;
END;
GO
CREATE OR ALTER PROCEDURE dbo.WordCompanion_Runtime_AssetSuggestions_Create
@UserID int,
@DocumentGuid uniqueidentifier,
@SceneID int,
@AssetIds 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 (AssetID int NOT NULL PRIMARY KEY);
INSERT @Requested (AssetID)
SELECT DISTINCT TRY_CONVERT(int, LTRIM(RTRIM(value)))
FROM STRING_SPLIT(COALESCE(@AssetIds, 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 asset suggestions detected.' AS Message;
RETURN;
END;
IF EXISTS
(
SELECT 1
FROM @Requested requested
WHERE NOT EXISTS
(
SELECT 1
FROM dbo.StoryAssets sa
WHERE sa.StoryAssetID = requested.AssetID
AND sa.ProjectID = @ProjectID
AND sa.IsArchived = 0
)
)
RETURN;
DECLARE @Inserted table (AssetID int NOT NULL PRIMARY KEY);
INSERT dbo.SceneAssetSuggestions
(SceneID, AssetID, SuggestionSource, Status, Confidence, Reason, CreatedByUserID)
OUTPUT inserted.AssetID INTO @Inserted
SELECT @SceneID,
requested.AssetID,
N'Word Companion',
N'Pending',
N'High',
N'Detected in manuscript text',
@UserID
FROM @Requested requested
WHERE NOT EXISTS
(SELECT 1 FROM dbo.AssetEvents ae WHERE ae.SceneID = @SceneID AND ae.StoryAssetID = requested.AssetID)
AND NOT EXISTS
(SELECT 1 FROM dbo.SceneAssetLocations sal WHERE sal.SceneID = @SceneID AND sal.StoryAssetID = requested.AssetID)
AND NOT EXISTS
(SELECT 1 FROM dbo.AssetCustodyEvents ace WHERE ace.SceneID = @SceneID AND ace.StoryAssetID = requested.AssetID)
AND NOT EXISTS
(SELECT 1 FROM dbo.SceneAssetSuggestions suggestion WHERE suggestion.SceneID = @SceneID AND suggestion.AssetID = requested.AssetID);
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 asset suggestion detected.'
WHEN (SELECT COUNT(*) FROM @Inserted) > 1 THEN CONCAT((SELECT COUNT(*) FROM @Inserted), N' asset suggestions detected.')
ELSE N'No new asset suggestions.'
END AS Message;
END;
GO

View File

@ -215,6 +215,11 @@
let characterAliasCache = [];
let lastSuggestedSceneId = null;
let lastSuggestedCharacterSignature = "";
let assetAliasCacheProjectId = null;
let assetAliasCacheDocumentGuid = "";
let assetAliasCache = [];
let lastSuggestedAssetSceneId = null;
let lastSuggestedAssetSignature = "";
let lastFollowSceneNotificationSignature = "";
let lastLinkedSceneTitle = "";
let lastLinkedChapterTitle = "";
@ -577,6 +582,7 @@
plotDirectorStructureCache = null;
plotDirectorStructureBookId = null;
resetRuntimeCharacterAliasCache();
resetRuntimeAssetAliasCache();
setRuntimeBookContext(null);
};
@ -2986,7 +2992,10 @@
setDocumentMessage("Reading manuscript position...");
try {
clearPlotDirectorStructureCache();
await loadRuntimeCharacterAliases(true);
await Promise.all([
loadRuntimeCharacterAliases(true),
loadRuntimeAssetAliases(true)
]);
await getBookStructureChapters(book.bookId);
const structure = await window.Word.run(async (context) => await readRuntimeDocumentStructure(context, !!book.usesExplicitScenes));
renderOutline(structure);
@ -3896,6 +3905,14 @@
clearPendingCharacterSuggestionDetection();
};
const resetRuntimeAssetAliasCache = () => {
assetAliasCacheProjectId = null;
assetAliasCacheDocumentGuid = "";
assetAliasCache = [];
lastSuggestedAssetSceneId = null;
lastSuggestedAssetSignature = "";
};
const loadRuntimeCharacterAliases = async (force = false) => {
const project = selectedProject();
const documentGuid = currentDocumentGuid();
@ -3919,6 +3936,29 @@
return characterAliasCache;
};
const loadRuntimeAssetAliases = async (force = false) => {
const project = selectedProject();
const documentGuid = currentDocumentGuid();
if (!project || !documentGuid) {
assetAliasCache = [];
return [];
}
if (!force
&& assetAliasCacheProjectId === project.projectId
&& assetAliasCacheDocumentGuid === documentGuid
&& assetAliasCache.length > 0) {
return assetAliasCache;
}
const response = await fetchJson(`/api/word-companion/runtime/assets?projectId=${encodeURIComponent(project.projectId)}&documentGuid=${encodeURIComponent(documentGuid)}`);
assetAliasCacheProjectId = project.projectId;
assetAliasCacheDocumentGuid = documentGuid;
assetAliasCache = Array.isArray(response?.assets) ? response.assets : [];
runtimeDebug(`Asset cache loaded: ${assetAliasCache.length} aliases.`);
return assetAliasCache;
};
const escapeRegex = (value) => String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const hasWholeWordMatch = (text, matchText) => {
@ -3951,6 +3991,26 @@
return [...detected.entries()].map(([characterId, name]) => ({ characterId, name }));
};
const detectAssetsInSceneText = (sceneText, aliases) => {
if (!sceneText || !Array.isArray(aliases) || aliases.length === 0) {
return [];
}
const detected = new Map();
for (const item of aliases) {
const assetId = Number.parseInt(item.assetId || "", 10);
if (!Number.isInteger(assetId) || assetId <= 0 || detected.has(assetId)) {
continue;
}
if (hasWholeWordMatch(sceneText, item.matchText)) {
detected.set(assetId, item.name || item.matchText || "Asset");
}
}
return [...detected.entries()].map(([assetId, name]) => ({ assetId, name }));
};
const finishCharacterSuggestionDetection = () => {
characterSuggestionInFlight = false;
if (characterSuggestionPending) {
@ -3975,18 +4035,21 @@
characterSuggestionPending = false;
try {
const [sceneText, aliases] = await Promise.all([
const [sceneText, characterAliases, assetAliases] = await Promise.all([
readCachedCurrentSceneText(),
loadRuntimeCharacterAliases()
loadRuntimeCharacterAliases(),
loadRuntimeAssetAliases()
]);
const detected = detectCharactersInSceneText(sceneText, aliases);
const detected = detectCharactersInSceneText(sceneText, characterAliases);
const characterIds = detected.map((item) => item.characterId);
const signature = characterIds.slice().sort((left, right) => left - right).join(",");
const detectedAssets = detectAssetsInSceneText(sceneText, assetAliases);
const assetIds = detectedAssets.map((item) => item.assetId);
const assetSignature = assetIds.slice().sort((left, right) => left - right).join(",");
if (!signature || (lastSuggestedSceneId === resolvedSceneId && lastSuggestedCharacterSignature === signature)) {
runtimeDebug("Character suggestions skipped.");
return;
}
} else {
const response = await postJson("/api/word-companion/runtime/character-suggestions", {
documentGuid: currentDocumentGuid(),
sceneId: resolvedSceneId,
@ -4001,8 +4064,29 @@
setDocumentMessage(names ? `Characters detected: ${names}${suffix}` : response.message);
}
runtimeDebug(response?.message || "Character suggestions submitted.");
}
if (!assetSignature || (lastSuggestedAssetSceneId === resolvedSceneId && lastSuggestedAssetSignature === assetSignature)) {
runtimeDebug("Asset suggestions skipped.");
} else {
runtimeDebug(`Asset matches found: ${detectedAssets.map((item) => item.name).join(", ")}`);
const assetResponse = await postJson("/api/word-companion/runtime/asset-suggestions", {
documentGuid: currentDocumentGuid(),
sceneId: resolvedSceneId,
assetIds
});
lastSuggestedAssetSceneId = resolvedSceneId;
lastSuggestedAssetSignature = assetSignature;
if ((assetResponse?.createdCount || 0) > 0) {
const names = detectedAssets.slice(0, 3).map((item) => item.name).join(", ");
const suffix = detectedAssets.length > 3 ? ` +${detectedAssets.length - 3}` : "";
setDocumentMessage(names ? `Assets detected: ${names}${suffix}` : assetResponse.message);
}
runtimeDebug(assetResponse?.message || "Asset suggestions submitted.");
}
} catch (error) {
console.error("Unable to detect character suggestions.", error);
console.error("Unable to detect runtime suggestions.", error);
setDiagnostics({ lastError: errorText(error) });
} finally {
finishCharacterSuggestionDetection();
@ -4480,7 +4564,10 @@
setRefreshCurrentSceneBusy(true);
setSceneTrackingState("Updating...");
try {
await loadRuntimeCharacterAliases(true);
await Promise.all([
loadRuntimeCharacterAliases(true),
loadRuntimeAssetAliases(true)
]);
const scanned = await refreshDocumentStructure();
if (!scanned) {
return;

File diff suppressed because one or more lines are too long