diff --git a/src/Novelly.Api/Agent/NovelAgentToolset.cs b/src/Novelly.Api/Agent/NovelAgentToolset.cs index 9dedcb9..0170ed0 100644 --- a/src/Novelly.Api/Agent/NovelAgentToolset.cs +++ b/src/Novelly.Api/Agent/NovelAgentToolset.cs @@ -334,7 +334,6 @@ public class NovelAgentToolset( .Str("title", "Chapter title.", required: true) .Int("number", "Position in the manuscript, 1-based.") .Str("summary", "What the chapter covers.") - .Str("pov_character_id", "Id of the point-of-view character.") .Str("setting", "Where and when the chapter takes place.") .Str("notes", "Anything else worth recording.") .Enum("status", "Drafting status.", System.Enum.GetNames()) @@ -346,7 +345,6 @@ public class NovelAgentToolset( JsonInput.RequiredString(input, "title"), JsonInput.Int(input, "number"), JsonInput.String(input, "summary"), - JsonInput.Guid(input, "pov_character_id"), JsonInput.String(input, "setting"), JsonInput.String(input, "notes"), JsonInput.Enum(input, "status") ?? DraftStatus.Planned, @@ -356,7 +354,7 @@ public class NovelAgentToolset( yield return new AgentTool( "update_chapter", - "Revise a chapter's title, number, summary, POV, setting, notes, status or drafted " + "Revise a chapter's title, number, summary, setting, notes, status or drafted " + "prose. Use 'prose' to write or replace the chapter's draft text in markdown; the " + "word count is recomputed automatically.", new JsonSchemaBuilder() @@ -364,7 +362,6 @@ public class NovelAgentToolset( .Str("title", "New title.") .Int("number", "Position in the manuscript.") .Str("summary", "What the chapter covers.") - .Str("pov_character_id", "Id of the point-of-view character.") .Str("setting", "Where and when the chapter takes place.") .Str("notes", "Anything else worth recording.") .Enum("status", "Drafting status.", System.Enum.GetNames()) @@ -381,7 +378,6 @@ public class NovelAgentToolset( JsonInput.String(input, "title"), JsonInput.Int(input, "number"), JsonInput.String(input, "summary"), - JsonInput.Guid(input, "pov_character_id"), JsonInput.String(input, "setting"), JsonInput.String(input, "notes"), JsonInput.Enum(input, "status"), diff --git a/src/Novelly.Api/Chapters/Chapter.cs b/src/Novelly.Api/Chapters/Chapter.cs index 740fab1..dd5a085 100644 --- a/src/Novelly.Api/Chapters/Chapter.cs +++ b/src/Novelly.Api/Chapters/Chapter.cs @@ -1,5 +1,6 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; using Novelly.Api.Beats; -using Novelly.Api.Characters; using Novelly.Api.Common; using Novelly.Api.Projects; using Novelly.Api.Tags; @@ -18,9 +19,6 @@ public class Chapter public string? Summary { get; set; } - public Guid? PovCharacterId { get; set; } - public Character? PovCharacter { get; set; } - public string? Setting { get; set; } public string? Notes { get; set; } @@ -38,3 +36,13 @@ public class Chapter public List Tags { get; set; } = []; } + +public class ChapterEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder entity) + { + entity.Property(c => c.Title).IsRequired().HasMaxLength(300); + entity.Property(c => c.Status).HasConversion().HasMaxLength(32); + entity.HasIndex(c => new { c.ProjectId, c.Number }); + } +} diff --git a/src/Novelly.Api/Chapters/ChapterContracts.cs b/src/Novelly.Api/Chapters/ChapterContracts.cs index 0dff154..f4039e4 100644 --- a/src/Novelly.Api/Chapters/ChapterContracts.cs +++ b/src/Novelly.Api/Chapters/ChapterContracts.cs @@ -11,8 +11,6 @@ public record ChapterSummaryResponse( int Number, string Title, string? Summary, - Guid? PovCharacterId, - string? PovCharacterName, string? Setting, DraftStatus Status, int? TargetWordCount, @@ -27,8 +25,6 @@ public record ChapterResponse( int Number, string Title, string? Summary, - Guid? PovCharacterId, - string? PovCharacterName, string? Setting, string? Notes, DraftStatus Status, @@ -43,7 +39,6 @@ public record CreateChapterRequest( string Title, int? Number = null, string? Summary = null, - Guid? PovCharacterId = null, string? Setting = null, string? Notes = null, DraftStatus Status = DraftStatus.Planned, @@ -57,11 +52,7 @@ public class CreateChapterRequestValidator : IModelValidator 200) - result.AddError("Title", "'Title' must be 200 characters or fewer."); - + result.AddRequiredTextErrors("Title", "Title", model.Title, 200); ChapterValidation.OptionalFields(model.Number, model.Summary, model.Setting, model.Notes, model.TargetWordCount, model.Prose, model.Tags, result); return result; @@ -72,7 +63,6 @@ public record UpdateChapterRequest( string? Title = null, int? Number = null, string? Summary = null, - Guid? PovCharacterId = null, string? Setting = null, string? Notes = null, DraftStatus? Status = null, @@ -86,14 +76,7 @@ public class UpdateChapterRequestValidator : IModelValidator 200) - result.AddError("Title", "'Title' must be 200 characters or fewer."); - } - + result.AddUnclearableTextErrors("Title", "Title", model.Title, "a chapter", 200); ChapterValidation.OptionalFields(model.Number, model.Summary, model.Setting, model.Notes, model.TargetWordCount, model.Prose, model.Tags, result); return result; @@ -133,7 +116,7 @@ public static class ChapterMapping { public static ChapterResponse ToResponse(this Chapter c) => new( c.Id, c.ProjectId, c.Number, c.Title, c.Summary, - c.PovCharacterId, c.PovCharacter?.Name, c.Setting, c.Notes, + c.Setting, c.Notes, c.Status, c.TargetWordCount, [.. c.Beats.OrderBy(b => b.SortOrder).Select(b => b.ToResponse())], c.Prose, c.WordCount, @@ -142,7 +125,7 @@ public static class ChapterMapping public static ChapterSummaryResponse ToSummaryResponse(this Chapter c) => new( c.Id, c.ProjectId, c.Number, c.Title, c.Summary, - c.PovCharacterId, c.PovCharacter?.Name, c.Setting, c.Status, c.TargetWordCount, + c.Setting, c.Status, c.TargetWordCount, c.Beats.Count, c.WordCount, [.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())], c.UpdatedAt); diff --git a/src/Novelly.Api/Chapters/ChapterService.cs b/src/Novelly.Api/Chapters/ChapterService.cs index 73763ec..79f1fa2 100644 --- a/src/Novelly.Api/Chapters/ChapterService.cs +++ b/src/Novelly.Api/Chapters/ChapterService.cs @@ -20,7 +20,6 @@ public class ChapterService( logger.LogInformation("Listing chapters for project {ProjectId}", projectId); return await db.Chapters - .Include(c => c.PovCharacter) .Include(c => c.Beats) .Include(c => c.Tags) .Where(c => c.ProjectId == projectId) @@ -40,13 +39,13 @@ public class ChapterService( { Guard.Default(projectId, nameof(projectId)); Guard.Null(request, nameof(request)); - createValidator.Validate(request).ThrowIfInvalid(); + createValidator.Validate(request).ThrowIfInvalid(logger); logger.LogInformation("Creating chapter {Title} for project {ProjectId}", request.Title, projectId); if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct)) { - logger.LogInformation("Rejected chapter creation: project {ProjectId} not found", projectId); + logger.LogWarning("Rejected chapter creation: project {ProjectId} not found", projectId); return null; } @@ -56,7 +55,6 @@ public class ChapterService( Title = request.Title, Number = request.Number ?? await NextChapterNumberAsync(projectId, ct), Summary = request.Summary, - PovCharacterId = request.PovCharacterId, Setting = request.Setting, Notes = request.Notes, Status = request.Status, @@ -80,7 +78,7 @@ public class ChapterService( { Guard.Default(id, nameof(id)); Guard.Null(request, nameof(request)); - updateValidator.Validate(request).ThrowIfInvalid(); + updateValidator.Validate(request).ThrowIfInvalid(logger); logger.LogInformation("Updating chapter {ChapterId}", id); @@ -93,7 +91,6 @@ public class ChapterService( chapter.Title = Patch.Apply(chapter.Title, request.Title) ?? chapter.Title; chapter.Number = request.Number ?? chapter.Number; chapter.Summary = Patch.Apply(chapter.Summary, request.Summary); - chapter.PovCharacterId = request.PovCharacterId ?? chapter.PovCharacterId; chapter.Setting = Patch.Apply(chapter.Setting, request.Setting); chapter.Notes = Patch.Apply(chapter.Notes, request.Notes); chapter.Status = request.Status ?? chapter.Status; @@ -151,7 +148,6 @@ public class ChapterService( logger.LogDebug("Finding chapter {ChapterId}", id); var chapter = await db.Chapters - .Include(c => c.PovCharacter) .Include(c => c.Beats).ThenInclude(b => b.Characters) .Include(c => c.Beats).ThenInclude(b => b.Tags) .Include(c => c.Tags) @@ -159,13 +155,11 @@ public class ChapterService( if (chapter is null) { - logger.LogInformation("Chapter {ChapterId} not found", id); - } - else - { - logger.LogDebug("Found chapter {ChapterId}", id); + logger.LogWarning("Chapter {ChapterId} not found", id); + return chapter; } + logger.LogDebug("Found chapter {ChapterId}", id); return chapter; } } diff --git a/src/Novelly.Api/Data/Migrations/20260816034124_RemoveChapterPovCharacter.Designer.cs b/src/Novelly.Api/Data/Migrations/20260816034124_RemoveChapterPovCharacter.Designer.cs new file mode 100644 index 0000000..843880e --- /dev/null +++ b/src/Novelly.Api/Data/Migrations/20260816034124_RemoveChapterPovCharacter.Designer.cs @@ -0,0 +1,867 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Novelly.Api.Data; + +#nullable disable + +namespace Novelly.Api.Data.Migrations +{ + [DbContext(typeof(NovelDbContext))] + [Migration("20260816034124_RemoveChapterPovCharacter")] + partial class RemoveChapterPovCharacter + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + + modelBuilder.Entity("BeatCharacter", b => + { + b.Property("BeatsId") + .HasColumnType("TEXT"); + + b.Property("CharactersId") + .HasColumnType("TEXT"); + + b.HasKey("BeatsId", "CharactersId"); + + b.HasIndex("CharactersId"); + + b.ToTable("BeatCharacters", (string)null); + }); + + modelBuilder.Entity("BeatTag", b => + { + b.Property("BeatsId") + .HasColumnType("TEXT"); + + b.Property("TagsId") + .HasColumnType("TEXT"); + + b.HasKey("BeatsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("BeatTags", (string)null); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("TEXT"); + + b.Property("TagsId") + .HasColumnType("TEXT"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTags", (string)null); + }); + + modelBuilder.Entity("CharacterTag", b => + { + b.Property("CharactersId") + .HasColumnType("TEXT"); + + b.Property("TagsId") + .HasColumnType("TEXT"); + + b.HasKey("CharactersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("CharacterTags", (string)null); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("Conversations"); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ConversationId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Sequence") + .HasColumnType("INTEGER"); + + b.Property("ToolCallsJson") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ConversationId", "Sequence") + .IsUnique(); + + b.ToTable("AgentMessages"); + }); + + modelBuilder.Entity("Novelly.Api.Beats.Beat", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("WhatHappened") + .HasColumnType("TEXT"); + + b.Property("WhatsNext") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId", "SortOrder"); + + b.ToTable("Beats"); + }); + + modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("Prose") + .HasColumnType("TEXT"); + + b.Property("Setting") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("TargetWordCount") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId", "Number"); + + b.ToTable("Chapters"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.Character", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Age") + .HasColumnType("TEXT"); + + b.Property("Appearance") + .HasColumnType("TEXT"); + + b.Property("ArcSummary") + .HasColumnType("TEXT"); + + b.Property("Backstory") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("ExternalConflict") + .HasColumnType("TEXT"); + + b.Property("Importance") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("InternalConflict") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Need") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("Occupation") + .HasColumnType("TEXT"); + + b.Property("Personality") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("Pronouns") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("Voice") + .HasColumnType("TEXT"); + + b.Property("Want") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("Characters"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.CharacterArcStage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("TEXT"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("CharacterId", "SortOrder"); + + b.ToTable("CharacterArcStages"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.CharacterRelationship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("RelatedCharacterId") + .HasColumnType("TEXT"); + + b.Property("RelationshipType") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CharacterId"); + + b.HasIndex("RelatedCharacterId"); + + b.ToTable("CharacterRelationships"); + }); + + modelBuilder.Entity("Novelly.Api.Genres.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Genres"); + + b.HasData( + new + { + Id = new Guid("b89aadb3-ee96-5a33-897d-94946b037f96"), + Name = "Adventure" + }, + new + { + Id = new Guid("1295b746-5de1-5724-aab8-186d4220c84f"), + Name = "Contemporary Fiction" + }, + new + { + Id = new Guid("786d6d01-be6c-5dff-ab53-17081d2979ed"), + Name = "Crime" + }, + new + { + Id = new Guid("800eea0a-52cb-5e03-8b6f-5e1ceaec8554"), + Name = "Dystopian" + }, + new + { + Id = new Guid("8dbe0291-1ab6-5045-b327-00f2025a7b0a"), + Name = "Fantasy" + }, + new + { + Id = new Guid("93face5a-9a61-5d63-9a8d-7fd5d49eab7d"), + Name = "Historical Fiction" + }, + new + { + Id = new Guid("4eba456f-b706-5f1f-bfc9-5d32cab0da62"), + Name = "Horror" + }, + new + { + Id = new Guid("d49c5adf-3ed9-5bc9-8652-1f7a9a098ecb"), + Name = "Literary Fiction" + }, + new + { + Id = new Guid("f72c6437-c8e7-519f-8d35-5aefeebbff9e"), + Name = "Magical Realism" + }, + new + { + Id = new Guid("1b670010-b4cc-5b22-a879-d36eb1bf3429"), + Name = "Memoir" + }, + new + { + Id = new Guid("03063bbf-de5d-5dd0-af06-0ee939de58bc"), + Name = "Middle Grade" + }, + new + { + Id = new Guid("c22ed045-52e5-54b0-8cdd-cd1d6a699c19"), + Name = "Mystery" + }, + new + { + Id = new Guid("abe2e8bc-a35e-5a30-a07f-7ae30a00d838"), + Name = "Non-Fiction" + }, + new + { + Id = new Guid("f8543db0-c519-56a0-996a-c6028176e57e"), + Name = "Poetry" + }, + new + { + Id = new Guid("b6251b9e-63a1-563f-94c0-834162fb580b"), + Name = "Romance" + }, + new + { + Id = new Guid("4f188842-488e-567a-b31d-831e0c551fa5"), + Name = "Science Fiction" + }, + new + { + Id = new Guid("ae67fc84-1ed9-55ae-8c9f-8a37adb52b57"), + Name = "Thriller" + }, + new + { + Id = new Guid("37956a94-e9c4-5d29-abbc-f121d687f997"), + Name = "Young Adult" + }); + }); + + modelBuilder.Entity("Novelly.Api.Imports.ImportJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChaptersCompleted") + .HasColumnType("INTEGER"); + + b.Property("ChaptersTotal") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("SourceRoot") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("StatusMessage") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SourceRoot"); + + b.ToTable("ImportJobs"); + }); + + modelBuilder.Entity("Novelly.Api.Projects.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Genre") + .HasColumnType("TEXT"); + + b.Property("Logline") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Synopsis") + .HasColumnType("TEXT"); + + b.Property("TargetWordCount") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("Novelly.Api.Questions.OpenQuestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("TEXT"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Detail") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Resolution") + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("CharacterId"); + + b.HasIndex("ProjectId"); + + b.ToTable("OpenQuestions"); + }); + + modelBuilder.Entity("Novelly.Api.Tags.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Color") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId", "Name") + .IsUnique(); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("BeatCharacter", b => + { + b.HasOne("Novelly.Api.Beats.Beat", null) + .WithMany() + .HasForeignKey("BeatsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Characters.Character", null) + .WithMany() + .HasForeignKey("CharactersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BeatTag", b => + { + b.HasOne("Novelly.Api.Beats.Beat", null) + .WithMany() + .HasForeignKey("BeatsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Tags.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("Novelly.Api.Chapters.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Tags.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CharacterTag", b => + { + b.HasOne("Novelly.Api.Characters.Character", null) + .WithMany() + .HasForeignKey("CharactersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Tags.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b => + { + b.HasOne("Novelly.Api.Projects.Project", "Project") + .WithMany("Conversations") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b => + { + b.HasOne("Novelly.Api.Agent.AgentConversation", "Conversation") + .WithMany("Messages") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Conversation"); + }); + + modelBuilder.Entity("Novelly.Api.Beats.Beat", b => + { + b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter") + .WithMany("Beats") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b => + { + b.HasOne("Novelly.Api.Projects.Project", "Project") + .WithMany("Chapters") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.Character", b => + { + b.HasOne("Novelly.Api.Projects.Project", "Project") + .WithMany("Characters") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.CharacterArcStage", b => + { + b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Characters.Character", "Character") + .WithMany("ArcStages") + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Character"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.CharacterRelationship", b => + { + b.HasOne("Novelly.Api.Characters.Character", "Character") + .WithMany("Relationships") + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Characters.Character", "RelatedCharacter") + .WithMany() + .HasForeignKey("RelatedCharacterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Character"); + + b.Navigation("RelatedCharacter"); + }); + + modelBuilder.Entity("Novelly.Api.Questions.OpenQuestion", b => + { + b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Characters.Character", "Character") + .WithMany() + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Projects.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Character"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Novelly.Api.Tags.Tag", b => + { + b.HasOne("Novelly.Api.Projects.Project", "Project") + .WithMany("Tags") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b => + { + b.Navigation("Beats"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.Character", b => + { + b.Navigation("ArcStages"); + + b.Navigation("Relationships"); + }); + + modelBuilder.Entity("Novelly.Api.Projects.Project", b => + { + b.Navigation("Chapters"); + + b.Navigation("Characters"); + + b.Navigation("Conversations"); + + b.Navigation("Tags"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Novelly.Api/Data/Migrations/20260816034124_RemoveChapterPovCharacter.cs b/src/Novelly.Api/Data/Migrations/20260816034124_RemoveChapterPovCharacter.cs new file mode 100644 index 0000000..9b5a016 --- /dev/null +++ b/src/Novelly.Api/Data/Migrations/20260816034124_RemoveChapterPovCharacter.cs @@ -0,0 +1,50 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Novelly.Api.Data.Migrations +{ + /// + public partial class RemoveChapterPovCharacter : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Chapters_Characters_PovCharacterId", + table: "Chapters"); + + migrationBuilder.DropIndex( + name: "IX_Chapters_PovCharacterId", + table: "Chapters"); + + migrationBuilder.DropColumn( + name: "PovCharacterId", + table: "Chapters"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PovCharacterId", + table: "Chapters", + type: "TEXT", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Chapters_PovCharacterId", + table: "Chapters", + column: "PovCharacterId"); + + migrationBuilder.AddForeignKey( + name: "FK_Chapters_Characters_PovCharacterId", + table: "Chapters", + column: "PovCharacterId", + principalTable: "Characters", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + } + } +} diff --git a/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs b/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs index c46254e..f0e144c 100644 --- a/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs +++ b/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs @@ -190,9 +190,6 @@ namespace Novelly.Api.Data.Migrations b.Property("Number") .HasColumnType("INTEGER"); - b.Property("PovCharacterId") - .HasColumnType("TEXT"); - b.Property("ProjectId") .HasColumnType("TEXT"); @@ -226,8 +223,6 @@ namespace Novelly.Api.Data.Migrations b.HasKey("Id"); - b.HasIndex("PovCharacterId"); - b.HasIndex("ProjectId", "Number"); b.ToTable("Chapters"); @@ -743,19 +738,12 @@ namespace Novelly.Api.Data.Migrations modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b => { - b.HasOne("Novelly.Api.Characters.Character", "PovCharacter") - .WithMany() - .HasForeignKey("PovCharacterId") - .OnDelete(DeleteBehavior.SetNull); - b.HasOne("Novelly.Api.Projects.Project", "Project") .WithMany("Chapters") .HasForeignKey("ProjectId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.Navigation("PovCharacter"); - b.Navigation("Project"); }); diff --git a/src/Novelly.Api/Imports/ImportAgentService.cs b/src/Novelly.Api/Imports/ImportAgentService.cs index bd11380..e320d40 100644 --- a/src/Novelly.Api/Imports/ImportAgentService.cs +++ b/src/Novelly.Api/Imports/ImportAgentService.cs @@ -3,16 +3,8 @@ using Novelly.Api.Agent; namespace Novelly.Api.Imports; -/// What one import run produced, for to persist onto the job. public record ImportRunResult(bool Completed, Guid? ProjectId, int ChaptersCompleted, string? Message); -/// -/// Drives the outline-import agent to completion (or to its per-run safety limit) against -/// one source folder. Structurally like 's tool-use loop, but -/// with two differences that matter: it runs many turns per call rather than one, and after -/// each turn it re-reads the ledger itself to decide whether to continue — the model saying -/// it's done is not trusted, the file it wrote is. -/// public class ImportAgentService( IAgentModelClient model, ImportAgentToolset toolset, @@ -24,6 +16,10 @@ public class ImportAgentService( public async Task RunAsync( string sourceRoot, Guid? existingProjectId, int chaptersTotal, CancellationToken ct = default) { + logger.LogInformation( + "Running import for {SourceRoot}, existing project {ExistingProjectId}, {ChaptersTotal} chapters total", + sourceRoot, existingProjectId, chaptersTotal); + toolset.Initialize(sourceRoot, existingProjectId); var startingLedger = toolset.ReadLedgerOrNull(); @@ -70,14 +66,10 @@ public class ImportAgentService( + "again for the same folder will resume from the ledger."); } - /// - /// One bounded round of model calls and tool execution — the same shape as - /// 's inner loop, just against the import - /// toolset and with a higher iteration ceiling, since a batch of chapters needs far more - /// tool calls than a chat reply. - /// private async Task RunOneTurnAsync(string systemPrompt, List transcript, CancellationToken ct) { + logger.LogDebug("Starting import turn with {IterationCeiling}-iteration ceiling", _options.ImportMaxIterationsPerTurn); + for (var iteration = 0; iteration < _options.ImportMaxIterationsPerTurn; iteration++) { var response = await model.CompleteAsync(systemPrompt, transcript, toolset.Definitions, ct); @@ -85,6 +77,7 @@ public class ImportAgentService( var requestedTools = response.Content.OfType().ToList(); if (requestedTools.Count == 0) { + logger.LogDebug("Import turn finished after {Iterations} iterations with no further tool calls", iteration); return; } @@ -95,7 +88,8 @@ public class ImportAgentService( { var outcome = await toolset.ExecuteAsync(call.Name, call.Input, ct); - logger.LogInformation( + logger.Log( + outcome.IsError ? LogLevel.Warning : LogLevel.Information, "Import tool {Tool} {Outcome}", call.Name, outcome.IsError ? "failed" : "succeeded"); results.Add(new AgentToolResultBlock(call.Id, outcome.Content, outcome.IsError)); @@ -109,8 +103,6 @@ public class ImportAgentService( _options.ImportMaxIterationsPerTurn); } - // Not a raw interpolated string: the ledger example below is full of JSON braces, and - // escaping every one of them for $"""...""" is more error-prone than a single Replace. private static string BuildSystemPrompt(string sourceRoot) => SystemPromptTemplate.Replace("{{SOURCE_ROOT}}", sourceRoot); private const string SystemPromptTemplate = """ @@ -139,10 +131,9 @@ public class ImportAgentService( tagline, `## Appearance`, `## Background`, `## Motivation`, an optional `## Events` section (bulleted, each optionally marked `*(Ch. N)*`), and an optional `## Notes`. - `**Thread:**` may name one character, several, or a character plus a qualifier — - only treat it as a POV character, and only auto-create an undossiered name from it, - when it names exactly one clear proper name. A list or vague reference stays - unresolved; never guess which one was meant. + `**Thread:**` may name one character, several, or a character plus a qualifier — only + auto-create an undossiered name from it when it names exactly one clear proper name. A + list or vague reference stays unresolved; never guess which one was meant. ## The ledger @@ -182,12 +173,13 @@ public class ImportAgentService( any chapter number already in `completedChapters`. Do roughly 10 chapters, then stop this pass for now — the run driver will call you again to continue if more remain, so there is no need to force the rest into one turn. - For each: resolve `pov_character_id` only when Thread names exactly one known - character; **auto-create** a character stub (name only, via create_character) for + For each: **auto-create** a character stub (name only, via create_character) for any single, unqualified name — in the Thread or in a beat's Character column — that isn't in the ledger yet, then use its id. create_chapter with title, number, - summary, pov_character_id, tags [Part value, "thread:"]. Then - create_beat for each table row, in order. If `## Notes` is present, call + summary, tags [Part value, "thread:"]. Then + create_beat for each table row, in order, passing character_ids for every name that + resolves — the Thread character included, on every beat in the chapter, since a + chapter no longer carries a POV of its own. If `## Notes` is present, call update_chapter with notes. Record `chapters[number]`, append to `completedChapters`. Mark "chapters" done only once every chapter file is processed, across however many turns that takes. diff --git a/src/Novelly.Api/Imports/ImportAgentToolset.cs b/src/Novelly.Api/Imports/ImportAgentToolset.cs index 05615f0..ebf8907 100644 --- a/src/Novelly.Api/Imports/ImportAgentToolset.cs +++ b/src/Novelly.Api/Imports/ImportAgentToolset.cs @@ -8,7 +8,10 @@ using Novelly.Api.Projects; namespace Novelly.Api.Imports; -internal record ImportToolNotFound(string Message); +internal record ImportToolNotFound(string Entity, Guid Id) +{ + public string Message => $"{Entity} '{Id}' was not found."; +} internal record ImportAgentTool( string Name, @@ -62,7 +65,7 @@ public class ImportAgentToolset( if (result is ImportToolNotFound notFound) { - logger.LogInformation("Import tool {Tool} found nothing: {Message}", name, notFound.Message); + logger.LogWarning("Import tool {Tool} found no {Entity} {EntityId}", name, notFound.Entity, notFound.Id); return new AgentToolResult(notFound.Message, true); } @@ -221,7 +224,7 @@ public class ImportAgentToolset( Notes: JsonInput.String(input, "notes")), ct); return updated is null - ? new ImportToolNotFound($"Project '{projectId}' was not found.") + ? new ImportToolNotFound("Project", projectId) : updated.ToResponse(); }); @@ -242,7 +245,7 @@ public class ImportAgentToolset( Notes: JsonInput.String(input, "notes")), ct); return created is null - ? new ImportToolNotFound($"Project '{projectId}' was not found.") + ? new ImportToolNotFound("Project", projectId) : created.ToResponse(); }); @@ -265,7 +268,7 @@ public class ImportAgentToolset( Notes: JsonInput.String(input, "notes")), ct); return updated is null - ? new ImportToolNotFound($"Character '{characterId}' was not found.") + ? new ImportToolNotFound("Character", characterId) : updated.ToResponse(); }); @@ -276,7 +279,6 @@ public class ImportAgentToolset( .Str("title", "Chapter title.", required: true) .Int("number", "Position in the manuscript, 1-based, matching the outline's chapter number.") .Str("summary", "The chapter's prose summary paragraph(s).") - .Str("pov_character_id", "Id of the point-of-view character, only when the Thread names exactly one.") .Str("notes", "The chapter file's ## Notes section, if present.") .StringArray("tags", "The Part value and the raw Thread text, e.g. ['Part I', 'thread:Logen'].") .Build(), @@ -287,22 +289,20 @@ public class ImportAgentToolset( JsonInput.RequiredString(input, "title"), JsonInput.Int(input, "number"), JsonInput.String(input, "summary"), - JsonInput.Guid(input, "pov_character_id"), Notes: JsonInput.String(input, "notes"), Tags: JsonInput.Strings(input, "tags")), ct); return created is null - ? new ImportToolNotFound($"Project '{projectId}' was not found.") + ? new ImportToolNotFound("Project", projectId) : created.ToResponse(); }); yield return new ImportAgentTool( "update_chapter", - "Revise a chapter's summary, POV or notes.", + "Revise a chapter's summary or notes.", new JsonSchemaBuilder() .Str("chapter_id", "Id of the chapter to update.", required: true) .Str("summary", "The chapter's prose summary paragraph(s).") - .Str("pov_character_id", "Id of the point-of-view character.") .Str("notes", "The chapter file's ## Notes section.") .Build(), async (input, ct) => @@ -310,11 +310,10 @@ public class ImportAgentToolset( var chapterId = JsonInput.RequiredGuid(input, "chapter_id"); var updated = await chapters.UpdateAsync(chapterId, new UpdateChapterRequest( Summary: JsonInput.String(input, "summary"), - PovCharacterId: JsonInput.Guid(input, "pov_character_id"), Notes: JsonInput.String(input, "notes")), ct); return updated is null - ? new ImportToolNotFound($"Chapter '{chapterId}' was not found.") + ? new ImportToolNotFound("Chapter", chapterId) : updated.ToResponse(); }); @@ -340,7 +339,7 @@ public class ImportAgentToolset( WhatsNext: JsonInput.String(input, "whats_next")), ct); return created is null - ? new ImportToolNotFound($"Chapter '{chapterId}' was not found.") + ? new ImportToolNotFound("Chapter", chapterId) : created.ToResponse(); }); @@ -364,7 +363,7 @@ public class ImportAgentToolset( ChapterId: JsonInput.Guid(input, "chapter_id")), ct); return created is null - ? new ImportToolNotFound($"Character '{characterId}' was not found.") + ? new ImportToolNotFound("Character", characterId) : created.ToResponse(); }); } diff --git a/src/Novelly.Mcp/Tools/ManuscriptTools.cs b/src/Novelly.Mcp/Tools/ManuscriptTools.cs index 4f68102..53bd20c 100644 --- a/src/Novelly.Mcp/Tools/ManuscriptTools.cs +++ b/src/Novelly.Mcp/Tools/ManuscriptTools.cs @@ -32,7 +32,6 @@ public static class ManuscriptTools CancellationToken ct, [Description("Position in the manuscript, 1-based.")] int? number = null, [Description("The chapter's outline summary paragraph.")] string? summary = null, - [Description("Id of the point-of-view character.")] Guid? povCharacterId = null, [Description("Where and when the chapter takes place.")] string? setting = null, [Description("Planned, Outlined, Drafted, Revised or Final.")] string? status = null, [Description("Target length in words.")] int? targetWordCount = null, @@ -43,7 +42,6 @@ public static class ManuscriptTools title, number, summary, - povCharacterId, setting, status = status ?? "Planned", targetWordCount, @@ -52,7 +50,7 @@ public static class ManuscriptTools }, ct); [McpServerTool(Name = "update_chapter")] - [Description("Revise a chapter's title, number, summary, POV character, setting, notes, status " + [Description("Revise a chapter's title, number, summary, setting, notes, status " + "or drafted prose. Use 'prose' to write or replace the chapter's draft text in " + "markdown; the word count is recomputed automatically.")] public static Task UpdateChapter( @@ -62,7 +60,6 @@ public static class ManuscriptTools [Description("New title.")] string? title = null, [Description("Position in the manuscript.")] int? number = null, [Description("The chapter's outline summary paragraph.")] string? summary = null, - [Description("Id of the point-of-view character.")] Guid? povCharacterId = null, [Description("Where and when the chapter takes place.")] string? setting = null, [Description("Anything else worth recording.")] string? notes = null, [Description("Planned, Outlined, Drafted, Revised or Final.")] string? status = null, @@ -70,5 +67,5 @@ public static class ManuscriptTools [Description("The chapter's drafted text, in markdown.")] string? prose = null, [Description("Tags for cross-referencing. Replaces the existing tags.")] string[]? tags = null) => api.PatchAsync($"/api/chapters/{chapterId}", - new { title, number, summary, povCharacterId, setting, notes, status, targetWordCount, prose, tags }, ct); + new { title, number, summary, setting, notes, status, targetWordCount, prose, tags }, ct); } diff --git a/src/Novelly.Web/src/api/types.ts b/src/Novelly.Web/src/api/types.ts index 9ff7d90..58ac3ab 100644 --- a/src/Novelly.Web/src/api/types.ts +++ b/src/Novelly.Web/src/api/types.ts @@ -173,8 +173,6 @@ export interface ChapterSummary { number: number title: string summary: string | null - povCharacterId: string | null - povCharacterName: string | null setting: string | null status: DraftStatus targetWordCount: number | null diff --git a/src/Novelly.Web/src/pages/ChapterPage.tsx b/src/Novelly.Web/src/pages/ChapterPage.tsx index 58e8b0a..17f6e67 100644 --- a/src/Novelly.Web/src/pages/ChapterPage.tsx +++ b/src/Novelly.Web/src/pages/ChapterPage.tsx @@ -84,22 +84,7 @@ export default function ChapterPage() { /> -
- +
- {chapter.povCharacterName && POV: {chapter.povCharacterName}} {chapter.beatCount} beats {chapter.wordCount.toLocaleString()} words