Remove chapter-level POV character
Character assignment now lives on beats only, per batch-assign. Drops Chapter.PovCharacterId (entity, contracts, service, agent/import/MCP tools, web UI) and the backing column via migration.
This commit is contained in:
@@ -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<DraftStatus>())
|
||||
@@ -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<DraftStatus>(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<DraftStatus>())
|
||||
@@ -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<DraftStatus>(input, "status"),
|
||||
|
||||
@@ -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<Tag> Tags { get; set; } = [];
|
||||
}
|
||||
|
||||
public class ChapterEntityTypeConfiguration : IEntityTypeConfiguration<Chapter>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Chapter> entity)
|
||||
{
|
||||
entity.Property(c => c.Title).IsRequired().HasMaxLength(300);
|
||||
entity.Property(c => c.Status).HasConversion<string>().HasMaxLength(32);
|
||||
entity.HasIndex(c => new { c.ProjectId, c.Number });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<CreateChapterReques
|
||||
{
|
||||
var result = new ValidationResult();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(model.Title))
|
||||
result.AddError("Title", "'Title' must not be empty.");
|
||||
else if (model.Title.Length > 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<UpdateChapterReques
|
||||
{
|
||||
var result = new ValidationResult();
|
||||
|
||||
if (model.Title is not null)
|
||||
{
|
||||
if (model.Title.Length == 0)
|
||||
result.AddError("Title", "'Title' can not be cleared — a chapter always needs one.");
|
||||
else if (model.Title.Length > 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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+867
@@ -0,0 +1,867 @@
|
||||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
|
||||
|
||||
modelBuilder.Entity("BeatCharacter", b =>
|
||||
{
|
||||
b.Property<Guid>("BeatsId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("CharactersId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("BeatsId", "CharactersId");
|
||||
|
||||
b.HasIndex("CharactersId");
|
||||
|
||||
b.ToTable("BeatCharacters", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BeatTag", b =>
|
||||
{
|
||||
b.Property<Guid>("BeatsId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("TagsId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("BeatsId", "TagsId");
|
||||
|
||||
b.HasIndex("TagsId");
|
||||
|
||||
b.ToTable("BeatTags", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ChapterTag", b =>
|
||||
{
|
||||
b.Property<Guid>("ChaptersId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("TagsId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("ChaptersId", "TagsId");
|
||||
|
||||
b.HasIndex("TagsId");
|
||||
|
||||
b.ToTable("ChapterTags", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CharacterTag", b =>
|
||||
{
|
||||
b.Property<Guid>("CharactersId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("TagsId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("CharactersId", "TagsId");
|
||||
|
||||
b.HasIndex("TagsId");
|
||||
|
||||
b.ToTable("CharacterTags", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
|
||||
b.ToTable("Conversations");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ConversationId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Role")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Sequence")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ToolCallsJson")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ConversationId", "Sequence")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AgentMessages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Beats.Beat", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ChapterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("WhatHappened")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("WhatsNext")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ChapterId", "SortOrder");
|
||||
|
||||
b.ToTable("Beats");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Number")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Prose")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Setting")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TargetWordCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("WordCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProjectId", "Number");
|
||||
|
||||
b.ToTable("Chapters");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Characters.Character", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Age")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Appearance")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ArcSummary")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Backstory")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ExternalConflict")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Importance")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("InternalConflict")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Need")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Occupation")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Personality")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Pronouns")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Role")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Voice")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Want")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
|
||||
b.ToTable("Characters");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Characters.CharacterArcStage", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("ChapterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("CharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("CharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("RelatedCharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("ChaptersCompleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ChaptersTotal")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("SourceRoot")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("StatusMessage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SourceRoot");
|
||||
|
||||
b.ToTable("ImportJobs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Projects.Project", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Author")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Logline")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Phase")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Synopsis")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TargetWordCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Projects");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Questions.OpenQuestion", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("ChapterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("CharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Detail")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Question")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Resolution")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("ResolvedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Novelly.Api.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class RemoveChapterPovCharacter : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -190,9 +190,6 @@ namespace Novelly.Api.Data.Migrations
|
||||
b.Property<int>("Number")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("PovCharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("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");
|
||||
});
|
||||
|
||||
|
||||
@@ -3,16 +3,8 @@ using Novelly.Api.Agent;
|
||||
|
||||
namespace Novelly.Api.Imports;
|
||||
|
||||
/// <summary>What one import run produced, for <see cref="ImportJobRunner"/> to persist onto the job.</summary>
|
||||
public record ImportRunResult(bool Completed, Guid? ProjectId, int ChaptersCompleted, string? Message);
|
||||
|
||||
/// <summary>
|
||||
/// Drives the outline-import agent to completion (or to its per-run safety limit) against
|
||||
/// one source folder. Structurally like <see cref="NovelAgentService"/>'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.
|
||||
/// </summary>
|
||||
public class ImportAgentService(
|
||||
IAgentModelClient model,
|
||||
ImportAgentToolset toolset,
|
||||
@@ -24,6 +16,10 @@ public class ImportAgentService(
|
||||
public async Task<ImportRunResult> 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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One bounded round of model calls and tool execution — the same shape as
|
||||
/// <see cref="NovelAgentService.SendMessageAsync"/>'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.
|
||||
/// </summary>
|
||||
private async Task RunOneTurnAsync(string systemPrompt, List<AgentChatMessage> 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<AgentToolUseBlock>().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:<raw Thread text>"]. Then
|
||||
create_beat for each table row, in order. If `## Notes` is present, call
|
||||
summary, tags [Part value, "thread:<raw Thread text>"]. 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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<CallToolResult> 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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -84,22 +84,7 @@ export default function ChapterPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="label">POV character</span>
|
||||
<select
|
||||
className="input"
|
||||
value={chapter.povCharacterId ?? ''}
|
||||
onChange={(e) => patch({ povCharacterId: e.target.value || null })}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{characters?.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="mt-4">
|
||||
<AutoField
|
||||
label="Setting"
|
||||
value={chapter.setting}
|
||||
|
||||
@@ -57,7 +57,6 @@ export default function ChaptersPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-3 text-xs muted">
|
||||
{chapter.povCharacterName && <span>POV: {chapter.povCharacterName}</span>}
|
||||
<span>{chapter.beatCount} beats</span>
|
||||
<span>{chapter.wordCount.toLocaleString()} words</span>
|
||||
<StatusBadge status={chapter.status} />
|
||||
|
||||
Reference in New Issue
Block a user