514 lines
30 KiB
C#
514 lines
30 KiB
C#
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using PlotLine.Data;
|
|
using PlotLine.Models;
|
|
|
|
namespace PlotLine.Services;
|
|
|
|
public interface IStoryMemoryService
|
|
{
|
|
Task<StoryMemoryRebuildResult> RebuildImportSessionAsync(int importSessionId, int userId, StoryMemoryRebuildOptions options, CancellationToken cancellationToken = default);
|
|
Task ProcessSceneResultAsync(int importSessionId, int userId, int sceneResultId, StoryMemoryResolveOptions options, CancellationToken cancellationToken = default);
|
|
Task ResolveIllustrationsAsync(int importSessionId, StoryMemoryResolveOptions options, CancellationToken cancellationToken = default);
|
|
}
|
|
|
|
public sealed class StoryMemoryService(
|
|
IStoryIntelligenceResultRepository results,
|
|
IStoryIntelligencePipelineRepository pipelines,
|
|
IStoryMemoryRepository memory,
|
|
IIllustrationLibraryRepository illustrationLibrary,
|
|
IUploadStorageService uploadStorage,
|
|
ILogger<StoryMemoryService> logger) : IStoryMemoryService
|
|
{
|
|
private const string ProcessorVersion = "21S";
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
PropertyNameCaseInsensitive = true,
|
|
WriteIndented = false
|
|
};
|
|
|
|
public async Task<StoryMemoryRebuildResult> RebuildImportSessionAsync(int importSessionId, int userId, StoryMemoryRebuildOptions options, CancellationToken cancellationToken = default)
|
|
{
|
|
var session = await pipelines.GetByIdForUserAsync(importSessionId, userId)
|
|
?? throw new InvalidOperationException($"Import Session {importSessionId} could not be found for this user.");
|
|
var runScenes = await LoadImportScenesAsync(session, cancellationToken);
|
|
var completedScenes = runScenes.Where(item => item.Scene is not null).ToList();
|
|
|
|
if (options.DryRun)
|
|
{
|
|
return new StoryMemoryRebuildResult
|
|
{
|
|
ImportSessionID = importSessionId,
|
|
DryRun = true,
|
|
SceneResultsRead = runScenes.Count,
|
|
SceneResultsProcessed = completedScenes.Count,
|
|
CharactersDiscovered = completedScenes.Sum(item => item.Scene!.Characters?.Count ?? 0),
|
|
LocationsDiscovered = completedScenes.Sum(item => (item.Scene!.Locations?.Count ?? 0) + (string.IsNullOrWhiteSpace(item.Scene.Setting?.LocationName) ? 0 : 1)),
|
|
AssetsDiscovered = completedScenes.Sum(item => item.Scene!.Assets?.Count ?? 0),
|
|
RelationshipsDiscovered = completedScenes.Sum(item => item.Scene!.Relationships?.Count ?? 0),
|
|
AiCalls = 0,
|
|
Warnings = runScenes.Where(item => item.Scene is null && !string.IsNullOrWhiteSpace(item.ParseError)).Select(item => item.ParseError!).Take(20).ToList()
|
|
};
|
|
}
|
|
|
|
if (options.RebuildAll)
|
|
{
|
|
await memory.ClearDerivedStateAsync(importSessionId);
|
|
}
|
|
|
|
await memory.SetAppearancePreferenceAsync(importSessionId, options.AppearancePreference);
|
|
var processed = await memory.ListProcessedSceneResultIdsAsync(importSessionId, ProcessorVersion);
|
|
var processedCount = 0;
|
|
foreach (var scene in completedScenes)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
if (!options.RebuildAll && processed.Contains(scene.Result.SceneResultID))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
await UpsertSceneMemoryAsync(session, scene.Result, scene.Scene!, cancellationToken);
|
|
await memory.MarkSceneResultProcessedAsync(importSessionId, scene.Result.SceneResultID, ProcessorVersion, StableContentHash(scene.Result.ParsedJson));
|
|
processedCount++;
|
|
}
|
|
|
|
await ResolveIllustrationsAsync(importSessionId, new StoryMemoryResolveOptions
|
|
{
|
|
QueueDemand = options.QueueDemand,
|
|
MaxDemandToQueue = options.MaxDemandToQueue,
|
|
AppearancePreference = options.AppearancePreference
|
|
}, cancellationToken);
|
|
|
|
var diagnostics = await memory.GetDiagnosticsAsync(importSessionId);
|
|
return new StoryMemoryRebuildResult
|
|
{
|
|
ImportSessionID = importSessionId,
|
|
DryRun = false,
|
|
SceneResultsRead = runScenes.Count,
|
|
SceneResultsProcessed = processedCount,
|
|
CharactersDiscovered = diagnostics.CharacterCount,
|
|
LocationsDiscovered = diagnostics.LocationCount,
|
|
AssetsDiscovered = diagnostics.AssetCount,
|
|
RelationshipsDiscovered = diagnostics.RelationshipCount,
|
|
AssignmentsCreatedOrValidated = diagnostics.AssignmentCount,
|
|
DemandsCreated = diagnostics.DemandCount,
|
|
FallbacksRemaining = diagnostics.FallbackCount,
|
|
AiCalls = 0
|
|
};
|
|
}
|
|
|
|
public async Task ProcessSceneResultAsync(int importSessionId, int userId, int sceneResultId, StoryMemoryResolveOptions options, CancellationToken cancellationToken = default)
|
|
{
|
|
var session = await pipelines.GetByIdForUserAsync(importSessionId, userId);
|
|
if (session is null)
|
|
{
|
|
logger.LogWarning("Story Memory skipped scene result {SceneResultId}: import session {ImportSessionId} was not visible to user {UserId}.", sceneResultId, importSessionId, userId);
|
|
return;
|
|
}
|
|
|
|
var scene = (await LoadImportScenesAsync(session, cancellationToken)).FirstOrDefault(item => item.Result.SceneResultID == sceneResultId);
|
|
if (scene is null || scene.Scene is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
await UpsertSceneMemoryAsync(session, scene.Result, scene.Scene, cancellationToken);
|
|
await memory.MarkSceneResultProcessedAsync(importSessionId, sceneResultId, ProcessorVersion, StableContentHash(scene.Result.ParsedJson));
|
|
await ResolveIllustrationsAsync(importSessionId, options, cancellationToken);
|
|
}
|
|
|
|
public async Task ResolveIllustrationsAsync(int importSessionId, StoryMemoryResolveOptions options, CancellationToken cancellationToken = default)
|
|
{
|
|
var characters = await memory.ListCharactersAsync(importSessionId);
|
|
var attributes = await memory.ListCharacterAttributesAsync(importSessionId);
|
|
var locations = await memory.ListLocationsAsync(importSessionId);
|
|
var assets = await memory.ListAssetsAsync(importSessionId);
|
|
var assignments = await memory.ListAssignmentsAsync(importSessionId);
|
|
var catalogue = (await illustrationLibrary.ListAsync(new IllustrationLibraryFilter()))
|
|
.Where(item => item.Status is IllustrationLibraryStatuses.Approved or IllustrationLibraryStatuses.Generated)
|
|
.Where(item => ResolveUploadUrl(item) is not null)
|
|
.ToList();
|
|
|
|
var usedItemIds = assignments
|
|
.Where(item => item.IllustrationLibraryItemID.HasValue)
|
|
.Select(item => item.IllustrationLibraryItemID!.Value)
|
|
.ToHashSet();
|
|
|
|
foreach (var character in characters.Where(item => item.EntityContext == "Story" && !item.IsGroupEntity))
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
var evidence = CharacterEvidence(character, attributes.Where(item => item.StoryMemoryCharacterID == character.StoryMemoryCharacterID));
|
|
var existing = assignments.FirstOrDefault(item => item.EntityType == StoryMemoryEntityTypes.Character && item.EntityID == character.StoryMemoryCharacterID);
|
|
var existingItem = existing?.IllustrationLibraryItemID is int existingId
|
|
? catalogue.FirstOrDefault(item => item.IllustrationLibraryItemID == existingId)
|
|
: null;
|
|
var existingValid = existingItem is not null && StoryIntelligenceIllustrationCompatibility
|
|
.CharacterHardRejections(evidence, StoryIntelligenceIllustrationCompatibility.CharacterMetadataFrom(existingItem), character.IsNamed, false)
|
|
.Count == 0;
|
|
|
|
var candidates = catalogue
|
|
.Where(item => item.Category == IllustrationLibraryCategories.Character)
|
|
.Select(item => ScoreCharacter(item, evidence, character.IsNamed, usedItemIds.Contains(item.IllustrationLibraryItemID) && item.IllustrationLibraryItemID != existing?.IllustrationLibraryItemID))
|
|
.Where(item => item.IsCompatible)
|
|
.OrderByDescending(item => item.Score)
|
|
.ThenBy(item => item.Item!.StableCode)
|
|
.ToList();
|
|
var chosen = candidates.FirstOrDefault();
|
|
if (chosen.Item is not null)
|
|
{
|
|
usedItemIds.Add(chosen.Item.IllustrationLibraryItemID);
|
|
await memory.UpsertAssignmentAsync(new StoryMemoryIllustrationAssignmentSave(
|
|
importSessionId,
|
|
StoryMemoryEntityTypes.Character,
|
|
character.StoryMemoryCharacterID,
|
|
chosen.Item.IllustrationLibraryItemID,
|
|
chosen.Item.StableCode,
|
|
"Assigned",
|
|
false,
|
|
chosen.Score,
|
|
Math.Min(0.99m, chosen.Score / 200m),
|
|
existingValid && existingItem?.IllustrationLibraryItemID == chosen.Item.IllustrationLibraryItemID
|
|
? $"Retained {chosen.Item.StableCode}; durable evidence passed hard revalidation."
|
|
: $"Assigned {chosen.Item.StableCode}; durable Story Memory was resolved against hard character constraints.",
|
|
ProcessorVersion));
|
|
continue;
|
|
}
|
|
|
|
await RecordDemandAsync(importSessionId, character.ProjectID, StoryMemoryEntityTypes.Character, DemandKey(character.CanonicalIdentityKey, evidence.AgeBand, evidence.Presentation), cancellationToken);
|
|
await memory.UpsertAssignmentAsync(new StoryMemoryIllustrationAssignmentSave(importSessionId, StoryMemoryEntityTypes.Character, character.StoryMemoryCharacterID, null, null, "DemandRecorded", true, 0, 0, "No compatible character illustration exists; durable demand recorded.", ProcessorVersion));
|
|
}
|
|
|
|
foreach (var location in locations)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
var requestedType = StoryIntelligenceIllustrationCompatibility.LocationType($"{location.DisplayName} {location.SemanticType} {location.Subtype}");
|
|
var chosen = catalogue
|
|
.Where(item => item.Category == IllustrationLibraryCategories.Location)
|
|
.Select(item => (Item: item, Type: StoryIntelligenceIllustrationCompatibility.LocationType($"{item.StableCode} {item.DisplayTitle} {item.ShortDescription} {item.MetadataJson} {item.SpecificationJson}")))
|
|
.Where(item => StoryIntelligenceIllustrationCompatibility.LocationCompatible(requestedType, item.Type))
|
|
.OrderByDescending(item => string.Equals(item.Type, requestedType, StringComparison.OrdinalIgnoreCase) ? 100 : 50)
|
|
.ThenBy(item => item.Item.StableCode)
|
|
.FirstOrDefault();
|
|
if (chosen.Item is not null)
|
|
{
|
|
await memory.UpsertAssignmentAsync(new StoryMemoryIllustrationAssignmentSave(importSessionId, StoryMemoryEntityTypes.Location, location.StoryMemoryLocationID, chosen.Item.IllustrationLibraryItemID, chosen.Item.StableCode, "Assigned", false, 100, 0.9m, $"Assigned {chosen.Item.StableCode}; location type {requestedType} matched {chosen.Type}.", ProcessorVersion));
|
|
}
|
|
else
|
|
{
|
|
await RecordDemandAsync(importSessionId, location.ProjectID, StoryMemoryEntityTypes.Location, DemandKey(location.CanonicalIdentityKey, requestedType), cancellationToken);
|
|
await memory.UpsertAssignmentAsync(new StoryMemoryIllustrationAssignmentSave(importSessionId, StoryMemoryEntityTypes.Location, location.StoryMemoryLocationID, null, null, "DemandRecorded", true, 0, 0, $"No compatible {requestedType} location illustration exists; demand recorded.", ProcessorVersion));
|
|
}
|
|
}
|
|
|
|
foreach (var asset in assets)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
var requestedType = StoryIntelligenceIllustrationCompatibility.AssetType($"{asset.DisplayName} {asset.SemanticType} {asset.Subtype}");
|
|
var requestedColour = StoryIntelligenceIllustrationCompatibility.AssetColour($"{asset.DisplayName} {asset.Colour}");
|
|
var chosen = catalogue
|
|
.Where(item => item.Category == IllustrationLibraryCategories.Asset)
|
|
.Select(item => (Item: item, Type: StoryIntelligenceIllustrationCompatibility.AssetType($"{item.StableCode} {item.DisplayTitle} {item.ShortDescription} {item.MetadataJson} {item.SpecificationJson}"), Colour: StoryIntelligenceIllustrationCompatibility.AssetColour($"{item.StableCode} {item.DisplayTitle} {item.ShortDescription} {item.MetadataJson} {item.SpecificationJson}")))
|
|
.Where(item => StoryIntelligenceIllustrationCompatibility.AssetCompatible(requestedType, item.Type))
|
|
.Where(item => requestedColour == "Unknown" || item.Colour == "Unknown" || string.Equals(requestedColour, item.Colour, StringComparison.OrdinalIgnoreCase))
|
|
.OrderByDescending(item => string.Equals(item.Type, requestedType, StringComparison.OrdinalIgnoreCase) ? 100 : 50)
|
|
.ThenByDescending(item => string.Equals(item.Colour, requestedColour, StringComparison.OrdinalIgnoreCase) ? 10 : 0)
|
|
.ThenBy(item => item.Item.StableCode)
|
|
.FirstOrDefault();
|
|
if (chosen.Item is not null)
|
|
{
|
|
await memory.UpsertAssignmentAsync(new StoryMemoryIllustrationAssignmentSave(importSessionId, StoryMemoryEntityTypes.Asset, asset.StoryMemoryAssetID, chosen.Item.IllustrationLibraryItemID, chosen.Item.StableCode, "Assigned", false, 100, 0.9m, $"Assigned {chosen.Item.StableCode}; asset type {requestedType} matched {chosen.Type}.", ProcessorVersion));
|
|
}
|
|
else
|
|
{
|
|
await RecordDemandAsync(importSessionId, asset.ProjectID, StoryMemoryEntityTypes.Asset, DemandKey(asset.CanonicalIdentityKey, requestedType, requestedColour), cancellationToken);
|
|
await memory.UpsertAssignmentAsync(new StoryMemoryIllustrationAssignmentSave(importSessionId, StoryMemoryEntityTypes.Asset, asset.StoryMemoryAssetID, null, null, "DemandRecorded", true, 0, 0, $"No compatible {requestedType} asset illustration exists; demand recorded.", ProcessorVersion));
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task UpsertSceneMemoryAsync(StoryIntelligenceBookPipelineState session, StoryIntelligenceSavedSceneResult result, SceneIntelligenceScene scene, CancellationToken cancellationToken)
|
|
{
|
|
var context = SourceSectionType(result, scene);
|
|
var sourceSectionType = context == "Story" ? "Story" : "Authorial";
|
|
foreach (var sceneCharacter in scene.Characters ?? [])
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
if (string.IsNullOrWhiteSpace(sceneCharacter.Name))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var key = CharacterKey(sceneCharacter.Name);
|
|
var isGroup = StoryIntelligenceIllustrationCompatibility.IsGroupEntity(sceneCharacter.Name);
|
|
var character = await memory.UpsertCharacterAsync(new StoryMemoryCharacterSave(
|
|
session.StoryIntelligenceBookPipelineID,
|
|
session.ProjectID,
|
|
session.BookID,
|
|
key,
|
|
sceneCharacter.Name.Trim(),
|
|
context,
|
|
sourceSectionType,
|
|
IsNamedCharacter(sceneCharacter.Name),
|
|
isGroup,
|
|
true,
|
|
string.Equals(scene.PointOfView?.CharacterName, sceneCharacter.Name, StringComparison.OrdinalIgnoreCase),
|
|
string.Equals(scene.PointOfView?.CharacterName, sceneCharacter.Name, StringComparison.OrdinalIgnoreCase),
|
|
CharacterSignificance(sceneCharacter),
|
|
result.SceneResultID));
|
|
await memory.AddCharacterAliasAsync(character.StoryMemoryCharacterID, sceneCharacter.Name.Trim(), Normalise(sceneCharacter.Name), "Scene character name", result.SceneResultID, sceneCharacter.Confidence ?? 0.8m);
|
|
foreach (var alias in sceneCharacter.Aliases ?? [])
|
|
{
|
|
await memory.AddCharacterAliasAsync(character.StoryMemoryCharacterID, alias, Normalise(alias), "Scene character alias", result.SceneResultID, sceneCharacter.Confidence ?? 0.7m);
|
|
}
|
|
|
|
var identityEvidence = new[] { sceneCharacter.Name, sceneCharacter.RoleInScene, sceneCharacter.Notes }
|
|
.Concat(sceneCharacter.Actions ?? [])
|
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
|
.Select(value => value!);
|
|
var relationshipEvidence = (scene.Relationships ?? [])
|
|
.Where(item => string.Equals(item.CharacterA, sceneCharacter.Name, StringComparison.OrdinalIgnoreCase) || string.Equals(item.CharacterB, sceneCharacter.Name, StringComparison.OrdinalIgnoreCase))
|
|
.Select(item => $"{item.RelationshipSignal} {item.Evidence}");
|
|
var inferred = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence(sceneCharacter.Name, identityEvidence, relationshipEvidence);
|
|
await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "AgeBand", inferred.AgeBand, inferred.AgeConfidence, false, "Deterministic evidence inference", result.SceneResultID);
|
|
await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "Presentation", inferred.Presentation, inferred.PresentationConfidence, inferred.PresentationConfidence >= 0.8m, "Deterministic evidence inference", result.SceneResultID);
|
|
await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "HairColour", inferred.HairColour, 0.7m, false, "Deterministic evidence inference", result.SceneResultID);
|
|
await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "SkinTone", inferred.SkinTone, 0.7m, false, "Deterministic evidence inference", result.SceneResultID);
|
|
await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "HairLength", inferred.HairLength, 0.6m, false, "Deterministic evidence inference", result.SceneResultID);
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(scene.Setting?.LocationName))
|
|
{
|
|
await UpsertLocationAsync(session, result, context, sourceSectionType, scene.Setting.LocationName, scene.Setting.LocationType, scene.Setting.GenericRoomType, scene.Setting.ParentLocationHint, scene.Setting.Confidence);
|
|
}
|
|
|
|
foreach (var location in scene.Locations ?? [])
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(location.Name))
|
|
{
|
|
await UpsertLocationAsync(session, result, context, sourceSectionType, location.Name, location.LocationType, location.GenericRoomType, location.ParentLocationHint, location.Confidence);
|
|
}
|
|
}
|
|
|
|
foreach (var asset in scene.Assets ?? [])
|
|
{
|
|
if (string.IsNullOrWhiteSpace(asset.Name))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var semantic = StoryIntelligenceIllustrationCompatibility.AssetType($"{asset.Name} {asset.AssetType} {asset.Notes}");
|
|
await memory.UpsertAssetAsync(new StoryMemoryAssetSave(
|
|
session.StoryIntelligenceBookPipelineID,
|
|
session.ProjectID,
|
|
session.BookID,
|
|
AssetKey(asset.Name),
|
|
asset.Name.Trim(),
|
|
semantic,
|
|
asset.AssetType,
|
|
StoryIntelligenceIllustrationCompatibility.AssetColour($"{asset.Name} {asset.Notes}"),
|
|
null,
|
|
result.SceneResultID));
|
|
}
|
|
|
|
foreach (var relationship in scene.Relationships ?? [])
|
|
{
|
|
if (string.IsNullOrWhiteSpace(relationship.CharacterA) || string.IsNullOrWhiteSpace(relationship.CharacterB))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var source = await memory.UpsertCharacterAsync(new StoryMemoryCharacterSave(session.StoryIntelligenceBookPipelineID, session.ProjectID, session.BookID, CharacterKey(relationship.CharacterA), relationship.CharacterA.Trim(), context, sourceSectionType, IsNamedCharacter(relationship.CharacterA), StoryIntelligenceIllustrationCompatibility.IsGroupEntity(relationship.CharacterA), true, false, false, 40, result.SceneResultID));
|
|
var target = await memory.UpsertCharacterAsync(new StoryMemoryCharacterSave(session.StoryIntelligenceBookPipelineID, session.ProjectID, session.BookID, CharacterKey(relationship.CharacterB), relationship.CharacterB.Trim(), context, sourceSectionType, IsNamedCharacter(relationship.CharacterB), StoryIntelligenceIllustrationCompatibility.IsGroupEntity(relationship.CharacterB), true, false, false, 40, result.SceneResultID));
|
|
await memory.UpsertRelationshipAsync(session.StoryIntelligenceBookPipelineID, StoryMemoryEntityTypes.Character, source.StoryMemoryCharacterID, StoryMemoryEntityTypes.Character, target.StoryMemoryCharacterID, relationship.RelationshipSignal ?? "related", relationship.Confidence ?? 0.6m, !string.IsNullOrWhiteSpace(relationship.Evidence), result.SceneResultID);
|
|
}
|
|
}
|
|
|
|
private async Task UpsertLocationAsync(StoryIntelligenceBookPipelineState session, StoryIntelligenceSavedSceneResult result, string context, string sourceSectionType, string name, string? locationType, string? genericRoomType, string? parent, decimal? confidence)
|
|
{
|
|
var semantic = StoryIntelligenceIllustrationCompatibility.LocationType($"{name} {locationType} {genericRoomType}");
|
|
await memory.UpsertLocationAsync(new StoryMemoryLocationSave(session.StoryIntelligenceBookPipelineID, session.ProjectID, session.BookID, LocationKey(name), name.Trim(), semantic, genericRoomType ?? locationType, InferInteriorExterior(semantic), result.SceneResultID));
|
|
}
|
|
|
|
private async Task AddKnownAttributeAsync(int characterId, string attributeType, string value, decimal confidence, bool isExplicit, string evidence, int sceneResultId)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value) || string.Equals(value, "Unknown", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return;
|
|
}
|
|
|
|
await memory.AddCharacterAttributeAsync(characterId, attributeType, value, confidence <= 0 ? 0.6m : confidence, isExplicit, evidence, sceneResultId);
|
|
}
|
|
|
|
private async Task<IReadOnlyList<SceneResultWithParse>> LoadImportScenesAsync(StoryIntelligenceBookPipelineState session, CancellationToken cancellationToken)
|
|
{
|
|
var runs = (await results.ListRunsAsync())
|
|
.Where(run => run.ProjectID == session.ProjectID && run.BookID == session.BookID)
|
|
.Select(run => new StoryIntelligenceSavedRun
|
|
{
|
|
StoryIntelligenceRunID = run.StoryIntelligenceRunID,
|
|
UserID = run.UserID,
|
|
ProjectID = run.ProjectID,
|
|
ProjectTitle = run.ProjectTitle,
|
|
BookID = run.BookID,
|
|
BookTitle = run.BookTitle,
|
|
ChapterID = run.ChapterID,
|
|
ChapterNumber = run.ChapterNumber,
|
|
Status = run.Status,
|
|
SourceType = run.SourceType,
|
|
CreatedUtc = run.CreatedUtc,
|
|
UpdatedUtc = run.CreatedUtc
|
|
})
|
|
.ToList();
|
|
|
|
var items = new List<SceneResultWithParse>();
|
|
foreach (var run in runs.OrderBy(item => item.ChapterNumber ?? decimal.MaxValue).ThenBy(item => item.StoryIntelligenceRunID))
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
var sceneResults = await results.ListSceneResultsAsync(run.StoryIntelligenceRunID);
|
|
items.AddRange(sceneResults.Select(item => new SceneResultWithParse(run, item, TryReadScene(item, out var error), error)));
|
|
}
|
|
|
|
return items
|
|
.OrderBy(item => item.Run.ChapterNumber ?? decimal.MaxValue)
|
|
.ThenBy(item => item.Result.TemporarySceneNumber)
|
|
.ThenBy(item => item.Result.SceneResultID)
|
|
.ToList();
|
|
}
|
|
|
|
private static SceneIntelligenceScene? TryReadScene(StoryIntelligenceSavedSceneResult result, out string? safeError)
|
|
{
|
|
safeError = null;
|
|
try
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(result.ParsedJson))
|
|
{
|
|
var parsed = JsonSerializer.Deserialize<SceneIntelligenceScene>(result.ParsedJson, JsonOptions);
|
|
if (parsed?.SceneReference is not null || parsed?.Summary is not null || parsed?.Characters is not null)
|
|
{
|
|
return parsed;
|
|
}
|
|
|
|
using var document = JsonDocument.Parse(result.ParsedJson);
|
|
if (document.RootElement.TryGetProperty("parsedScene", out var parsedScene))
|
|
{
|
|
return parsedScene.Deserialize<SceneIntelligenceScene>(JsonOptions);
|
|
}
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(result.OutputTextJson))
|
|
{
|
|
return JsonSerializer.Deserialize<SceneIntelligenceScene>(result.OutputTextJson, JsonOptions);
|
|
}
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
safeError = $"SceneResultID {result.SceneResultID} could not be parsed: {ex.Message}";
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private CharacterEvidenceProfile CharacterEvidence(StoryMemoryCharacter character, IEnumerable<StoryMemoryCharacterAttribute> attributes)
|
|
{
|
|
var values = attributes.Select(item => $"{item.AttributeType} {item.NormalisedValue}").ToList();
|
|
return StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence(character.DisplayName, values, []);
|
|
}
|
|
|
|
private (IllustrationLibraryItem? Item, int Score, bool IsCompatible) ScoreCharacter(IllustrationLibraryItem item, CharacterEvidenceProfile evidence, bool isNamed, bool alreadyUsed)
|
|
{
|
|
var metadata = StoryIntelligenceIllustrationCompatibility.CharacterMetadataFrom(item);
|
|
var rejections = StoryIntelligenceIllustrationCompatibility.CharacterHardRejections(evidence, metadata, isNamed, alreadyUsed);
|
|
if (rejections.Count > 0)
|
|
{
|
|
return (item, 0, false);
|
|
}
|
|
|
|
var score = 25;
|
|
if (string.Equals(evidence.AgeBand, metadata.AgeBand, StringComparison.OrdinalIgnoreCase)) score += 50;
|
|
if (string.Equals(evidence.Presentation, metadata.Presentation, StringComparison.OrdinalIgnoreCase)) score += 100;
|
|
if (string.Equals(evidence.HairColour, metadata.HairColour, StringComparison.OrdinalIgnoreCase)) score += 20;
|
|
if (string.Equals(evidence.SkinTone, metadata.SkinTone, StringComparison.OrdinalIgnoreCase)) score += 10;
|
|
if (item.Status == IllustrationLibraryStatuses.Approved) score += 10;
|
|
return (item, score, true);
|
|
}
|
|
|
|
private string? ResolveUploadUrl(IllustrationLibraryItem item)
|
|
=> ResolveExistingPublicUploadUrl(item.ThumbnailPath) ?? ResolveExistingPublicUploadUrl(item.FilePath);
|
|
|
|
private string? ResolveExistingPublicUploadUrl(string? storedPath)
|
|
{
|
|
var physicalPath = uploadStorage.TryResolvePublicPath(storedPath, "uploads/story-intelligence-library");
|
|
return physicalPath is not null && File.Exists(physicalPath) ? storedPath : null;
|
|
}
|
|
|
|
private async Task RecordDemandAsync(int importSessionId, int projectId, string entityType, string archetypeKey, CancellationToken cancellationToken)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
await memory.UpsertDemandAsync(new StoryMemoryIllustrationDemandSave(importSessionId, projectId, entityType, archetypeKey, 1, "Recorded", null, null));
|
|
}
|
|
|
|
private static string CharacterKey(string? name) => $"character-result-{StableKey(CanonicalCharacterIdentity(name))}";
|
|
private static string LocationKey(string? name) => $"location-result-{StableKey(name)}";
|
|
private static string AssetKey(string? name) => $"asset-result-{StableKey(name)}";
|
|
private static string DemandKey(params string[] parts) => StableKey(string.Join("-", parts.Where(part => !string.IsNullOrWhiteSpace(part))));
|
|
|
|
private static string CanonicalCharacterIdentity(string? name)
|
|
{
|
|
var clean = (name ?? string.Empty).Trim();
|
|
if (clean.Length == 0) return "unknown";
|
|
var lower = clean.ToLowerInvariant();
|
|
return lower switch
|
|
{
|
|
"i" or "me" or "myself" or "narrator" => "pov-character",
|
|
"mother" or "mum" or "her mother" or "his mother" => "mother",
|
|
"father" or "dad" or "her father" or "his father" => "father",
|
|
_ => clean
|
|
};
|
|
}
|
|
|
|
private static string Normalise(string? value)
|
|
=> string.Join(' ', (value ?? string.Empty).Trim().ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries));
|
|
|
|
private static string StableKey(string? value)
|
|
{
|
|
var normalised = Normalise(value);
|
|
if (normalised.Length == 0) return "unknown";
|
|
var sb = new StringBuilder(normalised.Length);
|
|
foreach (var character in normalised)
|
|
{
|
|
if (char.IsLetterOrDigit(character)) sb.Append(character);
|
|
else if (sb.Length == 0 || sb[^1] != '-') sb.Append('-');
|
|
}
|
|
return sb.ToString().Trim('-');
|
|
}
|
|
|
|
private static string StableContentHash(string? value)
|
|
{
|
|
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value ?? string.Empty));
|
|
return Convert.ToHexString(bytes).ToLowerInvariant();
|
|
}
|
|
|
|
private static bool IsNamedCharacter(string? name)
|
|
=> !string.IsNullOrWhiteSpace(name)
|
|
&& !StoryIntelligenceIllustrationCompatibility.IsGroupEntity(name)
|
|
&& char.IsUpper(name.Trim()[0]);
|
|
|
|
private static int CharacterSignificance(SceneIntelligenceCharacter character)
|
|
=> character.MentionedOnly == true ? 25 : string.Equals(character.RoleInScene, "POV", StringComparison.OrdinalIgnoreCase) ? 100 : 60;
|
|
|
|
private static string InferInteriorExterior(string semanticType)
|
|
=> semanticType is "Road" or "CarPark" or "Beach" or "Canal" or "HouseExterior" or "StreetFurniture" ? "Exterior" : "Interior";
|
|
|
|
private static string SourceSectionType(StoryIntelligenceSavedSceneResult result, SceneIntelligenceScene scene)
|
|
{
|
|
var text = $"{result.SourceLabel} {scene.Summary?.Short} {scene.ScenePurpose?.ObservedFunction}".ToLowerInvariant();
|
|
return text.Contains("author", StringComparison.Ordinal) || text.Contains("acknowledg", StringComparison.Ordinal) || text.Contains("copyright", StringComparison.Ordinal) || text.Contains("preface", StringComparison.Ordinal)
|
|
? "Authorial"
|
|
: "Story";
|
|
}
|
|
|
|
private sealed record SceneResultWithParse(StoryIntelligenceSavedRun Run, StoryIntelligenceSavedSceneResult Result, SceneIntelligenceScene? Scene, string? ParseError);
|
|
}
|