Add outline import feature; drop Dto naming, map entities at the API boundary

Services now return entities; endpoints (and the agent toolsets) map to
*Response records instead of services building wire DTOs themselves.
Also brings in the outline-import agent, MCP tool, ledger and web dialog
that were already in progress on disk.
This commit is contained in:
James Wampler
2026-08-06 18:36:40 -07:00
parent 40f93e40a8
commit 189ebf3237
66 changed files with 3310 additions and 364 deletions
+15
View File
@@ -57,4 +57,19 @@ public class AgentOptions
/// <summary>Falls back to the ANTHROPIC_API_KEY environment variable when unset.</summary>
public string? ApiKey { get; set; }
/// <summary>
/// Ceiling on model round-trips per <em>turn</em> of an outline import — higher than
/// <see cref="MaxIterations"/> because a batch of chapters needs far more tool calls
/// than a chat reply, but still bounded so a confused run can't spin forever.
/// </summary>
public int ImportMaxIterationsPerTurn { get; set; } = 40;
/// <summary>
/// Ceiling on synthetic "continue" turns per import run. The run driver — not the
/// model — decides whether to keep going, by re-reading the ledger after each turn; this
/// is the safety net if it never reports done. Hitting it pauses the job rather than
/// failing it: re-starting the same source root resumes from the ledger.
/// </summary>
public int ImportMaxTurns { get; set; } = 8;
}
-46
View File
@@ -1,46 +0,0 @@
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Agent;
public record ConversationSummaryDto(
Guid Id,
Guid ProjectId,
string Title,
int MessageCount,
DateTimeOffset UpdatedAt);
public record ConversationDto(
Guid Id,
Guid ProjectId,
string Title,
IReadOnlyList<AgentMessageDto> Messages,
DateTimeOffset UpdatedAt);
public record AgentMessageDto(
Guid Id,
AgentRole Role,
string Content,
IReadOnlyList<ToolCallDto> ToolCalls,
DateTimeOffset CreatedAt);
/// <summary>A record of one tool the agent invoked, surfaced so the writer can audit changes.</summary>
public record ToolCallDto(string Name, string Input, string Result);
public record SendAgentMessageRequest(string Message, Guid? ConversationId = null);
public class SendAgentMessageRequestValidator : IModelValidator<SendAgentMessageRequest>
{
public ValidationResult Validate(SendAgentMessageRequest model)
{
var result = new ValidationResult();
if (string.IsNullOrWhiteSpace(model.Message))
result.AddError("Message", "'Message' must not be empty.");
else if (model.Message.Length > 20000)
result.AddError("Message", "'Message' must be 20,000 characters or fewer.");
return result;
}
}
public record AgentTurnDto(Guid ConversationId, AgentMessageDto Message);
+5 -2
View File
@@ -21,7 +21,10 @@ public static class AgentEndpoints
SendAgentMessageRequest request,
NovelAgentService agent,
CancellationToken ct) =>
Results.Ok(await agent.SendMessageAsync(projectId, request, ct)))
{
var reply = await agent.SendMessageAsync(projectId, request, ct);
return Results.Ok(new AgentTurnResponse(reply.ConversationId, reply.ToResponse()));
})
.WithSummary("Send a message to the writing agent and run it to completion.");
var conversations = app.MapGroup("/api/conversations").WithTags("Agent")
@@ -29,7 +32,7 @@ public static class AgentEndpoints
.AddEndpointFilter<ValidationEndpointFilter>();
conversations.MapGet("/{id:guid}", async (Guid id, NovelAgentService agent, CancellationToken ct) =>
(await agent.GetConversationAsync(id, ct)).ToApiResult())
(await agent.GetConversationAsync(id, ct))?.ToResponse().ToApiResult())
.WithSummary("Read a conversation's full transcript.");
conversations.MapDelete("/{id:guid}", async (Guid id, NovelAgentService agent, CancellationToken ct) =>
@@ -0,0 +1,56 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Agent;
public record ConversationSummaryResponse(Guid Id, Guid ProjectId, string Title, int MessageCount, DateTimeOffset UpdatedAt);
public record ConversationResponse(Guid Id, Guid ProjectId, string Title, IReadOnlyList<AgentMessageResponse> Messages, DateTimeOffset UpdatedAt);
public record AgentMessageResponse(Guid Id, AgentRole Role, string Content, IReadOnlyList<ToolCallResponse> ToolCalls, DateTimeOffset CreatedAt);
public record ToolCallResponse(string Name, string Input, string Result);
public record SendAgentMessageRequest(string Message, Guid? ConversationId = null);
public class SendAgentMessageRequestValidator : IModelValidator<SendAgentMessageRequest>
{
public ValidationResult Validate(SendAgentMessageRequest model)
{
var result = new ValidationResult();
if (string.IsNullOrWhiteSpace(model.Message))
result.AddError("Message", "'Message' must not be empty.");
else if (model.Message.Length > 20000)
result.AddError("Message", "'Message' must be 20,000 characters or fewer.");
return result;
}
}
public record AgentTurnResponse(Guid ConversationId, AgentMessageResponse Message);
public static class AgentMapping
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
Converters = { new JsonStringEnumConverter() }
};
public static AgentMessageResponse ToResponse(this AgentMessage message) => new(
message.Id,
message.Role,
message.Content,
message.ToolCallsJson is null
? []
: JsonSerializer.Deserialize<List<ToolCallResponse>>(message.ToolCallsJson, JsonOptions) ?? [],
message.CreatedAt);
public static ConversationResponse ToResponse(this AgentConversation conversation) => new(
conversation.Id,
conversation.ProjectId,
conversation.Title,
[.. conversation.Messages.OrderBy(m => m.Sequence).Select(m => m.ToResponse())],
conversation.UpdatedAt);
}
+8 -28
View File
@@ -29,7 +29,7 @@ public class NovelAgentService(
private readonly AgentOptions _options = options.Value;
public async Task<IReadOnlyList<ConversationSummaryDto>> ListConversationsAsync(
public async Task<IReadOnlyList<ConversationSummaryResponse>> ListConversationsAsync(
Guid projectId, CancellationToken ct = default)
{
logger.LogInformation("Listing agent conversations for project {ProjectId}", projectId);
@@ -37,29 +37,18 @@ public class NovelAgentService(
return await db.Conversations
.Where(c => c.ProjectId == projectId)
.OrderByDescending(c => c.UpdatedAt)
.Select(c => new ConversationSummaryDto(c.Id, c.ProjectId, c.Title, c.Messages.Count, c.UpdatedAt))
.Select(c => new ConversationSummaryResponse(c.Id, c.ProjectId, c.Title, c.Messages.Count, c.UpdatedAt))
.ToListAsync(ct);
}
/// <summary>Null when no conversation has this id — a lookup miss is expected, not exceptional.</summary>
public async Task<ConversationDto?> GetConversationAsync(Guid conversationId, CancellationToken ct = default)
public async Task<AgentConversation?> GetConversationAsync(Guid conversationId, CancellationToken ct = default)
{
Guard.Default(conversationId, nameof(conversationId));
logger.LogInformation("Getting agent conversation {ConversationId}", conversationId);
var conversation = await FindConversationAsync(conversationId, ct);
if (conversation is null)
{
return null;
}
return new ConversationDto(
conversation.Id,
conversation.ProjectId,
conversation.Title,
[.. conversation.Messages.OrderBy(m => m.Sequence).Select(ToDto)],
conversation.UpdatedAt);
return await FindConversationAsync(conversationId, ct);
}
/// <summary>True if a conversation was deleted; false if no conversation had this id.</summary>
@@ -84,7 +73,7 @@ public class NovelAgentService(
/// Sends a message to the agent and runs it to completion, executing any tools it
/// calls along the way. Returns the assistant's final turn.
/// </summary>
public async Task<AgentTurnDto> SendMessageAsync(
public async Task<AgentMessage> SendMessageAsync(
Guid projectId, SendAgentMessageRequest request, CancellationToken ct = default)
{
Guard.Default(projectId, nameof(projectId));
@@ -109,7 +98,7 @@ public class NovelAgentService(
var systemPrompt = await BuildSystemPromptAsync(projectId, ct);
var transcript = BuildTranscript(conversation);
var toolCalls = new List<ToolCallDto>();
var toolCalls = new List<ToolCallResponse>();
var text = new StringBuilder();
for (var iteration = 0; iteration < _options.MaxIterations; iteration++)
@@ -146,7 +135,7 @@ public class NovelAgentService(
"Agent tool {Tool} on project {ProjectId} {Outcome}",
call.Name, projectId, outcome.IsError ? "failed" : "succeeded");
toolCalls.Add(new ToolCallDto(call.Name, call.Input.ToString(), outcome.Content));
toolCalls.Add(new ToolCallResponse(call.Name, call.Input.ToString(), outcome.Content));
results.Add(new AgentToolResultBlock(call.Id, outcome.Content, outcome.IsError));
}
@@ -170,7 +159,7 @@ public class NovelAgentService(
toolCalls.Count > 0 ? JsonSerializer.Serialize(toolCalls, JsonOptions) : null,
ct);
return new AgentTurnDto(conversation.Id, ToDto(reply));
return reply;
}
/// <summary>
@@ -307,15 +296,6 @@ public class NovelAgentService(
""";
}
private static AgentMessageDto ToDto(AgentMessage message) => new(
message.Id,
message.Role,
message.Content,
message.ToolCallsJson is null
? []
: JsonSerializer.Deserialize<List<ToolCallDto>>(message.ToolCallsJson, JsonOptions) ?? [],
message.CreatedAt);
/// <summary>Derives a conversation title from its opening message.</summary>
private static string Summarise(string message)
{
+44 -35
View File
@@ -104,6 +104,11 @@ public class NovelAgentToolset(
private static async Task<object> OrNotFound<T>(Task<T?> lookup, string entity, Guid id) where T : class =>
await lookup as object ?? new ToolNotFound($"{entity} '{id}' was not found.");
/// <summary>Turns a nullable lookup into either the mapped response or a <see cref="ToolNotFound"/> the model can read.</summary>
private static async Task<object> OrNotFound<TEntity, TResponse>(
Task<TEntity?> lookup, Func<TEntity, TResponse> map, string entity, Guid id) where TEntity : class =>
await lookup is { } value ? map(value)! : new ToolNotFound($"{entity} '{id}' was not found.");
/// <summary>Turns a delete's success flag into either a confirmation or a <see cref="ToolNotFound"/>.</summary>
private static async Task<object> DeletedOrNotFound(Task<bool> delete, string entity, Guid id) =>
await delete ? new { deleted = true } : new ToolNotFound($"{entity} '{id}' was not found.");
@@ -117,7 +122,7 @@ public class NovelAgentToolset(
"Read the project's title, logline, synopsis, genre, notes and word-count target. "
+ "Call this first in a conversation to ground yourself in what the book is.",
new JsonSchemaBuilder().Build(),
async (projectId, _, ct) => await OrNotFound(projects.GetAsync(projectId, ct), "Project", projectId));
async (projectId, _, ct) => await OrNotFound(projects.GetAsync(projectId, ct), p => p.ToResponse(), "Project", projectId));
yield return new AgentTool(
"update_project_brief",
@@ -139,20 +144,20 @@ public class NovelAgentToolset(
JsonInput.String(input, "logline"),
JsonInput.String(input, "synopsis"),
JsonInput.String(input, "notes"),
JsonInput.Int(input, "target_word_count")), ct), "Project", projectId));
JsonInput.Int(input, "target_word_count")), ct), p => p.ToResponse(), "Project", projectId));
yield return new AgentTool(
"list_characters",
"List every character in the project with their full dossiers.",
new JsonSchemaBuilder().Build(),
async (projectId, _, ct) => await characters.ListAsync(projectId, ct));
async (projectId, _, ct) => (await characters.ListAsync(projectId, ct)).Select(c => c.ToResponse()));
yield return new AgentTool(
"create_character",
"Add a character dossier. Name is the only requirement — leave fields blank when "
+ "the writer has not decided them yet rather than inventing detail.",
CharacterSchema(includeName: true, nameRequired: true).Build(),
async (projectId, input, ct) => await characters.CreateAsync(projectId, new CreateCharacterRequest(
async (projectId, input, ct) => (await characters.CreateAsync(projectId, new CreateCharacterRequest(
JsonInput.RequiredString(input, "name"),
JsonInput.Enum<CharacterRole>(input, "role") ?? CharacterRole.Supporting,
JsonInput.Enum<CharacterImportance>(input, "importance") ?? CharacterImportance.Supporting,
@@ -169,7 +174,7 @@ public class NovelAgentToolset(
JsonInput.String(input, "arc_summary"),
JsonInput.String(input, "voice"),
JsonInput.String(input, "notes"),
JsonInput.Strings(input, "tags")), ct));
JsonInput.Strings(input, "tags")), ct)).ToResponse());
yield return new AgentTool(
"update_character",
@@ -199,7 +204,7 @@ public class NovelAgentToolset(
JsonInput.String(input, "arc_summary"),
JsonInput.String(input, "voice"),
JsonInput.String(input, "notes"),
JsonInput.Strings(input, "tags")), ct), "Character", characterId);
JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Character", characterId);
});
yield return new AgentTool(
@@ -209,7 +214,7 @@ public class NovelAgentToolset(
new JsonSchemaBuilder()
.Str("chapter_id", "Id of the chapter whose outline to read.", required: true)
.Build(),
async (_, input, ct) => await beats.ListAsync(JsonInput.RequiredGuid(input, "chapter_id"), ct));
async (_, input, ct) => (await beats.ListAsync(JsonInput.RequiredGuid(input, "chapter_id"), ct)).Select(b => b.ToResponse()));
yield return new AgentTool(
"create_beat",
@@ -219,7 +224,7 @@ public class NovelAgentToolset(
.Str("chapter_id", "Id of the chapter the beat belongs to.", required: true)
.Str("title", "Three to five words naming the beat.", required: true)
.Build(),
async (_, input, ct) => await beats.CreateAsync(
async (_, input, ct) => (await beats.CreateAsync(
JsonInput.RequiredGuid(input, "chapter_id"),
new CreateBeatRequest(
JsonInput.RequiredString(input, "title"),
@@ -228,7 +233,7 @@ public class NovelAgentToolset(
JsonInput.String(input, "what_happened"),
JsonInput.String(input, "whats_next"),
JsonInput.Guid(input, "scene_id"),
JsonInput.Strings(input, "tags")), ct));
JsonInput.Strings(input, "tags")), ct)).ToResponse());
yield return new AgentTool(
"update_beat",
@@ -250,7 +255,7 @@ public class NovelAgentToolset(
JsonInput.String(input, "what_happened"),
JsonInput.String(input, "whats_next"),
JsonInput.Guid(input, "scene_id"),
JsonInput.Strings(input, "tags")), ct), "Beat", beatId);
JsonInput.Strings(input, "tags")), ct), b => b.ToResponse(), "Beat", beatId);
});
yield return new AgentTool(
@@ -273,12 +278,12 @@ public class NovelAgentToolset(
.Str("chapter_id", "Id of the chapter whose beats to reorder.", required: true)
.StringArray("beat_ids", "Beat ids in their new order.", required: true)
.Build(),
async (_, input, ct) => await beats.ReorderAsync(
async (_, input, ct) => (await beats.ReorderAsync(
JsonInput.RequiredGuid(input, "chapter_id"),
new ReorderBeatsRequest(
[.. (JsonInput.Strings(input, "beat_ids") ?? [])
.Select(id => Guid.TryParse(id, out var g) ? g : Guid.Empty)
.Where(g => g != Guid.Empty)]), ct));
.Where(g => g != Guid.Empty)]), ct)).Select(b => b.ToResponse()));
yield return new AgentTool(
"list_tags",
@@ -297,14 +302,14 @@ public class NovelAgentToolset(
async (_, input, ct) =>
{
var tagId = JsonInput.RequiredGuid(input, "tag_id");
return await OrNotFound(tags.GetReferencesAsync(tagId, ct), "Tag", tagId);
return await OrNotFound(tags.GetReferencesAsync(tagId, ct), t => t.ToReferencesResponse(), "Tag", tagId);
});
yield return new AgentTool(
"list_chapters",
"List the project's chapters in manuscript order with scene and word counts.",
new JsonSchemaBuilder().Build(),
async (projectId, _, ct) => await chapters.ListAsync(projectId, ct));
async (projectId, _, ct) => (await chapters.ListAsync(projectId, ct)).Select(c => c.ToSummaryResponse()));
yield return new AgentTool(
"get_chapter",
@@ -315,7 +320,7 @@ public class NovelAgentToolset(
async (_, input, ct) =>
{
var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
return await OrNotFound(chapters.GetAsync(chapterId, ct), "Chapter", chapterId);
return await OrNotFound(chapters.GetAsync(chapterId, ct), c => c.ToResponse(), "Chapter", chapterId);
});
yield return new AgentTool(
@@ -332,7 +337,7 @@ public class NovelAgentToolset(
.Int("target_word_count", "Target length in words.")
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
.Build(),
async (projectId, input, ct) => await chapters.CreateAsync(projectId, new CreateChapterRequest(
async (projectId, input, ct) => (await chapters.CreateAsync(projectId, new CreateChapterRequest(
JsonInput.RequiredString(input, "title"),
JsonInput.Int(input, "number"),
JsonInput.String(input, "summary"),
@@ -341,7 +346,7 @@ public class NovelAgentToolset(
JsonInput.String(input, "notes"),
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned,
JsonInput.Int(input, "target_word_count"),
JsonInput.Strings(input, "tags")), ct));
JsonInput.Strings(input, "tags")), ct)).ToResponse());
yield return new AgentTool(
"update_chapter",
@@ -372,7 +377,7 @@ public class NovelAgentToolset(
JsonInput.String(input, "notes"),
JsonInput.Enum<DraftStatus>(input, "status"),
JsonInput.Int(input, "target_word_count"),
JsonInput.Strings(input, "tags")), ct), "Chapter", chapterId);
JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Chapter", chapterId);
});
yield return new AgentTool(
@@ -383,7 +388,7 @@ public class NovelAgentToolset(
.Str("chapter_id", "Id of the chapter the scene belongs to.", required: true)
.Str("title", "Scene title.", required: true)
.Build(),
async (_, input, ct) => await scenes.CreateAsync(
async (_, input, ct) => (await scenes.CreateAsync(
JsonInput.RequiredGuid(input, "chapter_id"),
new CreateSceneRequest(
JsonInput.RequiredString(input, "title"),
@@ -395,7 +400,7 @@ public class NovelAgentToolset(
JsonInput.Guid(input, "pov_character_id"),
JsonInput.String(input, "location"),
JsonInput.String(input, "prose"),
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned), ct));
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned), ct)).ToResponse());
yield return new AgentTool(
"update_scene",
@@ -420,7 +425,7 @@ public class NovelAgentToolset(
JsonInput.Guid(input, "pov_character_id"),
JsonInput.String(input, "location"),
JsonInput.String(input, "prose"),
JsonInput.Enum<DraftStatus>(input, "status")), ct), "Scene", sceneId);
JsonInput.Enum<DraftStatus>(input, "status")), ct), s => s.ToResponse(), "Scene", sceneId);
});
yield return new AgentTool(
@@ -434,7 +439,11 @@ public class NovelAgentToolset(
async (_, input, ct) =>
{
var characterId = JsonInput.RequiredGuid(input, "character_id");
return await OrNotFound(beats.ListForCharacterAsync(characterId, ct), "Character", characterId);
return await OrNotFound(
beats.ListForCharacterAsync(characterId, ct),
list => list.Select(b => b.ToCharacterBeatResponse()),
"Character",
characterId);
});
yield return new AgentTool(
@@ -444,8 +453,8 @@ public class NovelAgentToolset(
new JsonSchemaBuilder()
.Str("character_id", "Id of the character.", required: true)
.Build(),
async (_, input, ct) => await arcs.ListAsync(
JsonInput.RequiredGuid(input, "character_id"), ct));
async (_, input, ct) => (await arcs.ListAsync(
JsonInput.RequiredGuid(input, "character_id"), ct)).Select(s => s.ToResponse()));
yield return new AgentTool(
"add_arc_stage",
@@ -455,13 +464,13 @@ public class NovelAgentToolset(
.Str("character_id", "Id of the character whose arc to add to.", required: true)
.Str("title", "A short handle for the change, three to five words.", required: true)
.Build(),
async (_, input, ct) => await arcs.CreateAsync(
async (_, input, ct) => (await arcs.CreateAsync(
JsonInput.RequiredGuid(input, "character_id"),
new CreateArcStageRequest(
JsonInput.RequiredString(input, "title"),
JsonInput.Int(input, "sort_order"),
JsonInput.String(input, "description"),
JsonInput.Guid(input, "chapter_id")), ct));
JsonInput.Guid(input, "chapter_id")), ct)).ToResponse());
yield return new AgentTool(
"update_arc_stage",
@@ -479,7 +488,7 @@ public class NovelAgentToolset(
JsonInput.String(input, "title"),
JsonInput.Int(input, "sort_order"),
JsonInput.String(input, "description"),
JsonInput.Guid(input, "chapter_id")), ct), "CharacterArcStage", arcStageId);
JsonInput.Guid(input, "chapter_id")), ct), s => s.ToResponse(), "CharacterArcStage", arcStageId);
});
yield return new AgentTool(
@@ -502,10 +511,10 @@ public class NovelAgentToolset(
.Str("character_id", "Id of the character whose arc to reorder.", required: true)
.StringArray("stage_ids", "Arc stage ids in the order wanted.", required: true)
.Build(),
async (_, input, ct) => await arcs.ReorderAsync(
async (_, input, ct) => (await arcs.ReorderAsync(
JsonInput.RequiredGuid(input, "character_id"),
new ReorderArcStagesRequest(
[.. JsonInput.Strings(input, "stage_ids")?.Select(Guid.Parse) ?? []]), ct));
[.. JsonInput.Strings(input, "stage_ids")?.Select(Guid.Parse) ?? []]), ct)).Select(s => s.ToResponse()));
yield return new AgentTool(
"list_open_questions",
@@ -516,12 +525,12 @@ public class NovelAgentToolset(
.Str("character_id", "Narrow to questions about one character.")
.Bool("include_resolved", "Include questions already settled. Defaults to false.")
.Build(),
async (projectId, input, ct) => await questions.ListAsync(
async (projectId, input, ct) => (await questions.ListAsync(
projectId,
JsonInput.Guid(input, "chapter_id"),
JsonInput.Guid(input, "character_id"),
JsonInput.Bool(input, "include_resolved") ?? false,
ct));
ct)).Select(q => q.ToResponse()));
yield return new AgentTool(
"raise_open_question",
@@ -534,13 +543,13 @@ public class NovelAgentToolset(
.Str("chapter_id", "The chapter outline this is about, if any.")
.Str("character_id", "The character this is about, if any.")
.Build(),
async (projectId, input, ct) => await questions.CreateAsync(
async (projectId, input, ct) => (await questions.CreateAsync(
projectId,
new CreateOpenQuestionRequest(
JsonInput.RequiredString(input, "question"),
JsonInput.String(input, "detail"),
JsonInput.Guid(input, "chapter_id"),
JsonInput.Guid(input, "character_id")), ct));
JsonInput.Guid(input, "character_id")), ct)).ToResponse());
yield return new AgentTool(
"resolve_open_question",
@@ -558,7 +567,7 @@ public class NovelAgentToolset(
questionId,
new ResolveOpenQuestionRequest(
JsonInput.RequiredString(input, "resolution"),
JsonInput.Bool(input, "append_to_notes") ?? false), ct), "OpenQuestion", questionId);
JsonInput.Bool(input, "append_to_notes") ?? false), ct), q => q.ToResponse(), "OpenQuestion", questionId);
});
yield return new AgentTool(
@@ -570,7 +579,7 @@ public class NovelAgentToolset(
async (_, input, ct) =>
{
var questionId = JsonInput.RequiredGuid(input, "question_id");
return await OrNotFound(questions.ReopenAsync(questionId, ct), "OpenQuestion", questionId);
return await OrNotFound(questions.ReopenAsync(questionId, ct), q => q.ToResponse(), "OpenQuestion", questionId);
});
yield return new AgentTool(
@@ -3,7 +3,7 @@ using Novelly.Api.Tags;
namespace Novelly.Api.Beats;
public record BeatDto(
public record BeatResponse(
Guid Id,
Guid ChapterId,
int SortOrder,
@@ -14,7 +14,7 @@ public record BeatDto(
string? WhatsNext,
Guid? SceneId,
string? SceneTitle,
IReadOnlyList<TagDto> Tags,
IReadOnlyList<TagResponse> Tags,
DateTimeOffset UpdatedAt);
public record CreateBeatRequest(
@@ -45,7 +45,9 @@ public class CreateBeatRequestValidator : IModelValidator<CreateBeatRequest>
/// <summary>
/// Patch-style update. A null field is left alone; an empty string clears it. Passing a
/// <see cref="Tags"/> list replaces the beat's tags outright.
/// <see cref="Tags"/> list replaces the beat's tags outright. Use <see cref="ClearCharacter"/> /
/// <see cref="ClearScene"/> to detach a reference, since a null id already means "leave the
/// association alone".
/// </summary>
public record UpdateBeatRequest(
string? Title = null,
@@ -54,7 +56,9 @@ public record UpdateBeatRequest(
string? WhatHappened = null,
string? WhatsNext = null,
Guid? SceneId = null,
IReadOnlyList<string>? Tags = null);
IReadOnlyList<string>? Tags = null,
bool ClearCharacter = false,
bool ClearScene = false);
public class UpdateBeatRequestValidator : IModelValidator<UpdateBeatRequest>
{
@@ -98,7 +102,7 @@ file static class BeatValidation
/// A beat this character appears in, carrying enough of its chapter to link straight to
/// the row in that chapter's outline.
/// </summary>
public record CharacterBeatDto(
public record CharacterBeatResponse(
Guid Id,
Guid ChapterId,
int ChapterNumber,
@@ -128,7 +132,7 @@ public class ReorderBeatsRequestValidator : IModelValidator<ReorderBeatsRequest>
public static class BeatMapping
{
public static BeatDto ToDto(this Beat b) => new(
public static BeatResponse ToResponse(this Beat b) => new(
b.Id,
b.ChapterId,
b.SortOrder,
@@ -139,6 +143,18 @@ public static class BeatMapping
b.WhatsNext,
b.SceneId,
b.Scene?.Title,
[.. b.Tags.OrderBy(t => t.Name).Select(t => t.ToDto())],
[.. b.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
b.UpdatedAt);
public static CharacterBeatResponse ToCharacterBeatResponse(this Beat b) => new(
b.Id,
b.ChapterId,
b.Chapter?.Number ?? 0,
b.Chapter?.Title ?? "(unknown chapter)",
b.SortOrder,
b.Title,
b.WhatHappened,
b.WhatsNext,
b.SceneId,
b.Scene?.Title);
}
+6 -6
View File
@@ -12,25 +12,25 @@ public static class BeatEndpoints
.AddEndpointFilter<ValidationEndpointFilter>();
chapterScoped.MapGet("/", async (Guid chapterId, BeatService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(chapterId, ct)))
Results.Ok((await service.ListAsync(chapterId, ct)).Select(b => b.ToResponse())))
.WithSummary("Read a chapter's outline: its beats, in order.");
chapterScoped.MapPost("/", async (
Guid chapterId, CreateBeatRequest request, BeatService service, CancellationToken ct) =>
{
var created = await service.CreateAsync(chapterId, request, ct);
var created = (await service.CreateAsync(chapterId, request, ct)).ToResponse();
return Results.Created($"/api/beats/{created.Id}", created);
})
.WithSummary("Add a beat to a chapter's outline.");
chapterScoped.MapPost("/reorder", async (
Guid chapterId, ReorderBeatsRequest request, BeatService service, CancellationToken ct) =>
Results.Ok(await service.ReorderAsync(chapterId, request, ct)))
Results.Ok((await service.ReorderAsync(chapterId, request, ct)).Select(b => b.ToResponse())))
.WithSummary("Renumber a chapter's beats to match the order given.");
app.MapGet("/api/characters/{characterId:guid}/beats", async (
Guid characterId, BeatService service, CancellationToken ct) =>
(await service.ListForCharacterAsync(characterId, ct)).ToApiResult())
(await service.ListForCharacterAsync(characterId, ct))?.Select(b => b.ToCharacterBeatResponse()).ToList().ToApiResult())
.WithTags("Beats")
.WithSummary("Every beat this character appears in, in manuscript order.");
@@ -39,12 +39,12 @@ public static class BeatEndpoints
.AddEndpointFilter<ValidationEndpointFilter>();
beats.MapGet("/{id:guid}", async (Guid id, BeatService service, CancellationToken ct) =>
(await service.GetAsync(id, ct)).ToApiResult())
(await service.GetAsync(id, ct))?.ToResponse().ToApiResult())
.WithSummary("Read one beat.");
beats.MapPatch("/{id:guid}", async (
Guid id, UpdateBeatRequest request, BeatService service, CancellationToken ct) =>
(await service.UpdateAsync(id, request, ct)).ToApiResult())
(await service.UpdateAsync(id, request, ct))?.ToResponse().ToApiResult())
.WithSummary("Update a beat. Sending a tag list replaces the beat's tags.");
beats.MapDelete("/{id:guid}", async (Guid id, BeatService service, CancellationToken ct) =>
+12 -25
View File
@@ -20,27 +20,25 @@ public class BeatService(
IModelValidator<UpdateBeatRequest> updateValidator,
IModelValidator<ReorderBeatsRequest> reorderValidator)
{
public async Task<IReadOnlyList<BeatDto>> ListAsync(Guid chapterId, CancellationToken ct = default)
public async Task<IReadOnlyList<Beat>> ListAsync(Guid chapterId, CancellationToken ct = default)
{
Guard.Default(chapterId, nameof(chapterId));
logger.LogInformation("Listing beats for chapter {ChapterId}", chapterId);
var beats = await Query()
return await Query()
.Where(b => b.ChapterId == chapterId)
.OrderBy(b => b.SortOrder)
.ToListAsync(ct);
return [.. beats.Select(b => b.ToDto())];
}
/// <summary>Null when no beat has this id — a lookup miss is expected, not exceptional.</summary>
public async Task<BeatDto?> GetAsync(Guid id, CancellationToken ct = default)
public async Task<Beat?> GetAsync(Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
logger.LogInformation("Getting beat {BeatId}", id);
return (await FindAsync(id, ct))?.ToDto();
return await FindAsync(id, ct);
}
/// <summary>
@@ -49,7 +47,7 @@ public class BeatService(
/// straight to the beat in that chapter's outline. Null when no character has this id;
/// an empty list means the character exists but has no beats yet.
/// </summary>
public async Task<IReadOnlyList<CharacterBeatDto>?> ListForCharacterAsync(
public async Task<IReadOnlyList<Beat>?> ListForCharacterAsync(
Guid characterId, CancellationToken ct = default)
{
Guard.Default(characterId, nameof(characterId));
@@ -73,21 +71,10 @@ public class BeatService(
.. beats
.OrderBy(b => b.Chapter?.Number ?? 0)
.ThenBy(b => b.SortOrder)
.Select(b => new CharacterBeatDto(
b.Id,
b.ChapterId,
b.Chapter?.Number ?? 0,
b.Chapter?.Title ?? "(unknown chapter)",
b.SortOrder,
b.Title,
b.WhatHappened,
b.WhatsNext,
b.SceneId,
b.Scene?.Title))
];
}
public async Task<BeatDto> CreateAsync(Guid chapterId, CreateBeatRequest request, CancellationToken ct = default)
public async Task<Beat> CreateAsync(Guid chapterId, CreateBeatRequest request, CancellationToken ct = default)
{
Guard.Default(chapterId, nameof(chapterId));
Guard.Null(request, nameof(request));
@@ -124,10 +111,10 @@ public class BeatService(
await db.SaveChangesAsync(ct);
// Just created it — the reload is only to pick up includes, not to check existence.
return (await FindAsync(beat.Id, ct))!.ToDto();
return (await FindAsync(beat.Id, ct))!;
}
public async Task<BeatDto?> UpdateAsync(Guid id, UpdateBeatRequest request, CancellationToken ct = default)
public async Task<Beat?> UpdateAsync(Guid id, UpdateBeatRequest request, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
Guard.Null(request, nameof(request));
@@ -154,10 +141,10 @@ public class BeatService(
beat.Title = Patch.Apply(beat.Title, request.Title) ?? beat.Title;
beat.SortOrder = request.SortOrder ?? beat.SortOrder;
beat.CharacterId = request.CharacterId ?? beat.CharacterId;
beat.CharacterId = request.ClearCharacter ? null : request.CharacterId ?? beat.CharacterId;
beat.WhatHappened = Patch.Apply(beat.WhatHappened, request.WhatHappened);
beat.WhatsNext = Patch.Apply(beat.WhatsNext, request.WhatsNext);
beat.SceneId = request.SceneId ?? beat.SceneId;
beat.SceneId = request.ClearScene ? null : request.SceneId ?? beat.SceneId;
beat.UpdatedAt = DateTimeOffset.UtcNow;
if (request.Tags is { } names)
@@ -166,7 +153,7 @@ public class BeatService(
}
await db.SaveChangesAsync(ct);
return (await FindAsync(id, ct))!.ToDto();
return (await FindAsync(id, ct))!;
}
/// <summary>True if a beat was deleted; false if no beat had this id.</summary>
@@ -191,7 +178,7 @@ public class BeatService(
/// Renumbers a chapter's beats to match the order given. Sending the whole list beats
/// patching sort orders one at a time, which is fiddly to get right from a drag handle.
/// </summary>
public async Task<IReadOnlyList<BeatDto>> ReorderAsync(
public async Task<IReadOnlyList<Beat>> ReorderAsync(
Guid chapterId, ReorderBeatsRequest request, CancellationToken ct = default)
{
Guard.Default(chapterId, nameof(chapterId));
@@ -6,7 +6,7 @@ using Novelly.Api.Tags;
namespace Novelly.Api.Chapters;
public record ChapterSummaryDto(
public record ChapterSummaryResponse(
Guid Id,
Guid ProjectId,
int Number,
@@ -20,13 +20,13 @@ public record ChapterSummaryDto(
int BeatCount,
int SceneCount,
int WordCount,
IReadOnlyList<TagDto> Tags);
IReadOnlyList<TagResponse> Tags);
/// <summary>
/// A chapter in full: the outline (a paragraph of summary plus an ordered beat table)
/// and the prose layer (scenes).
/// </summary>
public record ChapterDto(
public record ChapterResponse(
Guid Id,
Guid ProjectId,
int Number,
@@ -38,9 +38,9 @@ public record ChapterDto(
string? Notes,
DraftStatus Status,
int? TargetWordCount,
IReadOnlyList<BeatDto> Beats,
IReadOnlyList<SceneDto> Scenes,
IReadOnlyList<TagDto> Tags,
IReadOnlyList<BeatResponse> Beats,
IReadOnlyList<SceneResponse> Scenes,
IReadOnlyList<TagResponse> Tags,
DateTimeOffset UpdatedAt);
public record CreateChapterRequest(
@@ -133,18 +133,18 @@ file static class ChapterValidation
public static class ChapterMapping
{
public static ChapterDto ToDto(this Chapter c) => new(
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.Status, c.TargetWordCount,
[.. c.Beats.OrderBy(b => b.SortOrder).Select(b => b.ToDto())],
[.. c.Scenes.OrderBy(s => s.SortOrder).Select(s => s.ToDto())],
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToDto())],
[.. c.Beats.OrderBy(b => b.SortOrder).Select(b => b.ToResponse())],
[.. c.Scenes.OrderBy(s => s.SortOrder).Select(s => s.ToResponse())],
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
c.UpdatedAt);
public static ChapterSummaryDto ToSummaryDto(this Chapter c) => new(
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.Beats.Count, c.Scenes.Count, c.Scenes.Sum(s => s.WordCount),
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToDto())]);
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())]);
}
+4 -4
View File
@@ -12,13 +12,13 @@ public static class ChapterEndpoints
.AddEndpointFilter<ValidationEndpointFilter>();
projectScoped.MapGet("/", async (Guid projectId, ChapterService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(projectId, ct)))
Results.Ok((await service.ListAsync(projectId, ct)).Select(c => c.ToSummaryResponse())))
.WithSummary("List a project's chapters in manuscript order.");
projectScoped.MapPost("/", async (
Guid projectId, CreateChapterRequest request, ChapterService service, CancellationToken ct) =>
{
var created = await service.CreateAsync(projectId, request, ct);
var created = (await service.CreateAsync(projectId, request, ct)).ToResponse();
return Results.Created($"/api/chapters/{created.Id}", created);
})
.WithSummary("Add a chapter.");
@@ -28,12 +28,12 @@ public static class ChapterEndpoints
.AddEndpointFilter<ValidationEndpointFilter>();
chapters.MapGet("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) =>
(await service.GetAsync(id, ct)).ToApiResult())
(await service.GetAsync(id, ct))?.ToResponse().ToApiResult())
.WithSummary("Read a chapter with all of its scenes.");
chapters.MapPatch("/{id:guid}", async (
Guid id, UpdateChapterRequest request, ChapterService service, CancellationToken ct) =>
(await service.UpdateAsync(id, request, ct)).ToApiResult())
(await service.UpdateAsync(id, request, ct))?.ToResponse().ToApiResult())
.WithSummary("Update a chapter.");
chapters.MapDelete("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) =>
+8 -10
View File
@@ -14,13 +14,13 @@ public class ChapterService(
IModelValidator<CreateChapterRequest> createValidator,
IModelValidator<UpdateChapterRequest> updateValidator)
{
public async Task<IReadOnlyList<ChapterSummaryDto>> ListAsync(Guid projectId, CancellationToken ct = default)
public async Task<IReadOnlyList<Chapter>> ListAsync(Guid projectId, CancellationToken ct = default)
{
Guard.Default(projectId, nameof(projectId));
logger.LogInformation("Listing chapters for project {ProjectId}", projectId);
var chapters = await db.Chapters
return await db.Chapters
.Include(c => c.PovCharacter)
.Include(c => c.Beats)
.Include(c => c.Scenes)
@@ -28,20 +28,18 @@ public class ChapterService(
.Where(c => c.ProjectId == projectId)
.OrderBy(c => c.Number)
.ToListAsync(ct);
return [.. chapters.Select(c => c.ToSummaryDto())];
}
/// <summary>Null when no chapter has this id — a lookup miss is expected, not exceptional.</summary>
public async Task<ChapterDto?> GetAsync(Guid id, CancellationToken ct = default)
public async Task<Chapter?> GetAsync(Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
logger.LogInformation("Getting chapter {ChapterId}", id);
return (await FindAsync(id, ct))?.ToDto();
return await FindAsync(id, ct);
}
public async Task<ChapterDto> CreateAsync(Guid projectId, CreateChapterRequest request, CancellationToken ct = default)
public async Task<Chapter> CreateAsync(Guid projectId, CreateChapterRequest request, CancellationToken ct = default)
{
Guard.Default(projectId, nameof(projectId));
Guard.Null(request, nameof(request));
@@ -77,10 +75,10 @@ public class ChapterService(
await db.SaveChangesAsync(ct);
// Just created it — the reload is only to pick up includes, not to check existence.
return (await FindAsync(chapter.Id, ct))!.ToDto();
return (await FindAsync(chapter.Id, ct))!;
}
public async Task<ChapterDto?> UpdateAsync(Guid id, UpdateChapterRequest request, CancellationToken ct = default)
public async Task<Chapter?> UpdateAsync(Guid id, UpdateChapterRequest request, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
Guard.Null(request, nameof(request));
@@ -110,7 +108,7 @@ public class ChapterService(
}
await db.SaveChangesAsync(ct);
return (await FindAsync(id, ct))!.ToDto();
return (await FindAsync(id, ct))!;
}
/// <summary>True if a chapter was deleted; false if no chapter had this id.</summary>
@@ -21,7 +21,7 @@ public class CharacterArcService(
IModelValidator<UpdateArcStageRequest> updateValidator,
IModelValidator<ReorderArcStagesRequest> reorderValidator)
{
public async Task<IReadOnlyList<ArcStageDto>> ListAsync(Guid characterId, CancellationToken ct = default)
public async Task<IReadOnlyList<CharacterArcStage>> ListAsync(Guid characterId, CancellationToken ct = default)
{
Guard.Default(characterId, nameof(characterId));
@@ -32,19 +32,19 @@ public class CharacterArcService(
.OrderBy(s => s.SortOrder)
.ToListAsync(ct);
return [.. stages.Select(s => s.ToDto())];
return stages;
}
/// <summary>Null when no arc stage has this id — a lookup miss is expected, not exceptional.</summary>
public async Task<ArcStageDto?> GetAsync(Guid id, CancellationToken ct = default)
public async Task<CharacterArcStage?> GetAsync(Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
logger.LogInformation("Getting arc stage {ArcStageId}", id);
return (await FindAsync(id, ct))?.ToDto();
return await FindAsync(id, ct);
}
public async Task<ArcStageDto> CreateAsync(
public async Task<CharacterArcStage> CreateAsync(
Guid characterId, CreateArcStageRequest request, CancellationToken ct = default)
{
Guard.Default(characterId, nameof(characterId));
@@ -75,10 +75,10 @@ public class CharacterArcService(
await db.SaveChangesAsync(ct);
// Just created it — the reload is only to pick up includes, not to check existence.
return (await FindAsync(stage.Id, ct))!.ToDto();
return (await FindAsync(stage.Id, ct))!;
}
public async Task<ArcStageDto?> UpdateAsync(
public async Task<CharacterArcStage?> UpdateAsync(
Guid id, UpdateArcStageRequest request, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
@@ -111,7 +111,7 @@ public class CharacterArcService(
stage.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct);
return (await FindAsync(id, ct))!.ToDto();
return (await FindAsync(id, ct))!;
}
/// <summary>True if an arc stage was deleted; false if no stage had this id.</summary>
@@ -136,7 +136,7 @@ public class CharacterArcService(
/// Renumbers a character's arc to match the order given. Stages left out keep their
/// relative position after the ones listed, exactly as beat reordering works.
/// </summary>
public async Task<IReadOnlyList<ArcStageDto>> ReorderAsync(
public async Task<IReadOnlyList<CharacterArcStage>> ReorderAsync(
Guid characterId, ReorderArcStagesRequest request, CancellationToken ct = default)
{
Guard.Default(characterId, nameof(characterId));
@@ -3,7 +3,7 @@ using Novelly.Api.Tags;
namespace Novelly.Api.Characters;
public record CharacterDto(
public record CharacterResponse(
Guid Id,
Guid ProjectId,
string Name,
@@ -22,12 +22,12 @@ public record CharacterDto(
string? ArcSummary,
string? Voice,
string? Notes,
IReadOnlyList<RelationshipDto> Relationships,
IReadOnlyList<TagDto> Tags,
IReadOnlyList<ArcStageDto> ArcStages,
IReadOnlyList<RelationshipResponse> Relationships,
IReadOnlyList<TagResponse> Tags,
IReadOnlyList<ArcStageResponse> ArcStages,
DateTimeOffset UpdatedAt);
public record RelationshipDto(
public record RelationshipResponse(
Guid Id,
Guid RelatedCharacterId,
string RelatedCharacterName,
@@ -177,7 +177,7 @@ public class CreateRelationshipRequestValidator : IModelValidator<CreateRelation
}
}
public record ArcStageDto(
public record ArcStageResponse(
Guid Id,
Guid CharacterId,
int SortOrder,
@@ -269,21 +269,21 @@ public class ReorderArcStagesRequestValidator : IModelValidator<ReorderArcStages
public static class CharacterMapping
{
public static CharacterDto ToDto(this Character c) => new(
public static CharacterResponse ToResponse(this Character c) => new(
c.Id, c.ProjectId, c.Name, c.Role, c.Importance, c.Age, c.Pronouns, c.Occupation,
c.Appearance, c.Personality, c.Backstory, c.Want, c.Need,
c.InternalConflict, c.ExternalConflict, c.ArcSummary, c.Voice, c.Notes,
[.. c.Relationships.Select(r => new RelationshipDto(
[.. c.Relationships.Select(r => new RelationshipResponse(
r.Id,
r.RelatedCharacterId,
r.RelatedCharacter?.Name ?? "(unknown)",
r.RelationshipType,
r.Description))],
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToDto())],
[.. c.ArcStages.OrderBy(s => s.SortOrder).Select(s => s.ToDto())],
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
[.. c.ArcStages.OrderBy(s => s.SortOrder).Select(s => s.ToResponse())],
c.UpdatedAt);
public static ArcStageDto ToDto(this CharacterArcStage s) => new(
public static ArcStageResponse ToResponse(this CharacterArcStage s) => new(
s.Id,
s.CharacterId,
s.SortOrder,
@@ -12,13 +12,13 @@ public static class CharacterEndpoints
.AddEndpointFilter<ValidationEndpointFilter>();
projectScoped.MapGet("/", async (Guid projectId, CharacterService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(projectId, ct)))
Results.Ok((await service.ListAsync(projectId, ct)).Select(c => c.ToResponse())))
.WithSummary("List a project's character dossiers.");
projectScoped.MapPost("/", async (
Guid projectId, CreateCharacterRequest request, CharacterService service, CancellationToken ct) =>
{
var created = await service.CreateAsync(projectId, request, ct);
var created = (await service.CreateAsync(projectId, request, ct)).ToResponse();
return Results.Created($"/api/characters/{created.Id}", created);
})
.WithSummary("Add a character dossier.");
@@ -28,12 +28,12 @@ public static class CharacterEndpoints
.AddEndpointFilter<ValidationEndpointFilter>();
characters.MapGet("/{id:guid}", async (Guid id, CharacterService service, CancellationToken ct) =>
(await service.GetAsync(id, ct)).ToApiResult())
(await service.GetAsync(id, ct))?.ToResponse().ToApiResult())
.WithSummary("Read a character dossier.");
characters.MapPatch("/{id:guid}", async (
Guid id, UpdateCharacterRequest request, CharacterService service, CancellationToken ct) =>
(await service.UpdateAsync(id, request, ct)).ToApiResult())
(await service.UpdateAsync(id, request, ct))?.ToResponse().ToApiResult())
.WithSummary("Update a character dossier.");
characters.MapDelete("/{id:guid}", async (Guid id, CharacterService service, CancellationToken ct) =>
@@ -42,7 +42,7 @@ public static class CharacterEndpoints
characters.MapPost("/{id:guid}/relationships", async (
Guid id, CreateRelationshipRequest request, CharacterService service, CancellationToken ct) =>
(await service.AddRelationshipAsync(id, request, ct)).ToApiResult())
(await service.AddRelationshipAsync(id, request, ct))?.ToResponse().ToApiResult())
.WithSummary("Relate this character to another in the same project.");
characters.MapDelete("/relationships/{relationshipId:guid}", async (
@@ -52,20 +52,20 @@ public static class CharacterEndpoints
characters.MapGet("/{id:guid}/arc", async (
Guid id, CharacterArcService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(id, ct)))
Results.Ok((await service.ListAsync(id, ct)).Select(s => s.ToResponse())))
.WithSummary("Read a character's arc: its stages, in order.");
characters.MapPost("/{id:guid}/arc", async (
Guid id, CreateArcStageRequest request, CharacterArcService service, CancellationToken ct) =>
{
var created = await service.CreateAsync(id, request, ct);
var created = (await service.CreateAsync(id, request, ct)).ToResponse();
return Results.Created($"/api/arc-stages/{created.Id}", created);
})
.WithSummary("Add a stage to a character's arc.");
characters.MapPost("/{id:guid}/arc/reorder", async (
Guid id, ReorderArcStagesRequest request, CharacterArcService service, CancellationToken ct) =>
Results.Ok(await service.ReorderAsync(id, request, ct)))
Results.Ok((await service.ReorderAsync(id, request, ct)).Select(s => s.ToResponse())))
.WithSummary("Renumber a character's arc to match the order given.");
var arcStages = app.MapGroup("/api/arc-stages").WithTags("Characters")
@@ -73,12 +73,12 @@ public static class CharacterEndpoints
.AddEndpointFilter<ValidationEndpointFilter>();
arcStages.MapGet("/{id:guid}", async (Guid id, CharacterArcService service, CancellationToken ct) =>
(await service.GetAsync(id, ct)).ToApiResult())
(await service.GetAsync(id, ct))?.ToResponse().ToApiResult())
.WithSummary("Read one arc stage.");
arcStages.MapPatch("/{id:guid}", async (
Guid id, UpdateArcStageRequest request, CharacterArcService service, CancellationToken ct) =>
(await service.UpdateAsync(id, request, ct)).ToApiResult())
(await service.UpdateAsync(id, request, ct))?.ToResponse().ToApiResult())
.WithSummary("Update an arc stage.");
arcStages.MapDelete("/{id:guid}", async (Guid id, CharacterArcService service, CancellationToken ct) =>
+9 -10
View File
@@ -25,7 +25,7 @@ public class CharacterService(
/// order, which is the significance order these enums are written in. A project's cast is
/// small enough that this costs nothing.
/// </remarks>
public async Task<IReadOnlyList<CharacterDto>> ListAsync(Guid projectId, CancellationToken ct = default)
public async Task<IReadOnlyList<Character>> ListAsync(Guid projectId, CancellationToken ct = default)
{
Guard.Default(projectId, nameof(projectId));
@@ -41,20 +41,19 @@ public class CharacterService(
.OrderBy(c => c.Importance)
.ThenBy(c => c.Role)
.ThenBy(c => c.Name)
.Select(c => c.ToDto())
];
}
/// <summary>Null when no character has this id — a lookup miss is expected, not exceptional.</summary>
public async Task<CharacterDto?> GetAsync(Guid id, CancellationToken ct = default)
public async Task<Character?> GetAsync(Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
logger.LogInformation("Getting character {CharacterId}", id);
return (await FindAsync(id, ct))?.ToDto();
return await FindAsync(id, ct);
}
public async Task<CharacterDto> CreateAsync(Guid projectId, CreateCharacterRequest request, CancellationToken ct = default)
public async Task<Character> CreateAsync(Guid projectId, CreateCharacterRequest request, CancellationToken ct = default)
{
Guard.Default(projectId, nameof(projectId));
Guard.Null(request, nameof(request));
@@ -94,10 +93,10 @@ public class CharacterService(
await db.SaveChangesAsync(ct);
// Just created it — the reload is only to pick up includes, not to check existence.
return (await FindAsync(character.Id, ct))!.ToDto();
return (await FindAsync(character.Id, ct))!;
}
public async Task<CharacterDto?> UpdateAsync(Guid id, UpdateCharacterRequest request, CancellationToken ct = default)
public async Task<Character?> UpdateAsync(Guid id, UpdateCharacterRequest request, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
Guard.Null(request, nameof(request));
@@ -135,7 +134,7 @@ public class CharacterService(
}
await db.SaveChangesAsync(ct);
return (await FindAsync(id, ct))!.ToDto();
return (await FindAsync(id, ct))!;
}
/// <summary>True if a character was deleted; false if no character had this id.</summary>
@@ -157,7 +156,7 @@ public class CharacterService(
}
/// <summary>Null when the subject character (<paramref name="characterId"/>) doesn't exist.</summary>
public async Task<CharacterDto?> AddRelationshipAsync(
public async Task<Character?> AddRelationshipAsync(
Guid characterId, CreateRelationshipRequest request, CancellationToken ct = default)
{
Guard.Default(characterId, nameof(characterId));
@@ -194,7 +193,7 @@ public class CharacterService(
});
await db.SaveChangesAsync(ct);
return (await FindAsync(characterId, ct))!.ToDto();
return (await FindAsync(characterId, ct))!;
}
/// <summary>True if a relationship was removed; false if no relationship had this id.</summary>
@@ -2,11 +2,5 @@ namespace Novelly.Api.Common;
public static class ApiResultExtensions
{
/// <summary>
/// A missing entity is not exceptional, so lookups return null instead of throwing.
/// This is where that null finally becomes an HTTP 404 — the one place the API layer
/// needs to know about it.
/// </summary>
public static IResult ToApiResult<T>(this T? value) where T : class =>
value is null ? Results.NotFound() : Results.Ok(value);
public static IResult ToApiResult<T>(this T? value) where T : class => value is null ? Results.NotFound() : Results.Ok(value);
}
+1 -2
View File
@@ -4,8 +4,7 @@ namespace Novelly.Api.Common;
/// Thrown when a service is asked for an entity that does not exist. The API translates
/// this into a 404 so services never have to know about HTTP.
/// </summary>
public class NotFoundException(string entity, Guid id)
: Exception($"{entity} '{id}' was not found.")
public class NotFoundException(string entity, Guid id) : Exception($"{entity} '{id}' was not found.")
{
public string Entity { get; } = entity;
public Guid Id { get; } = id;
@@ -1,3 +1,4 @@
using System.Threading.Channels;
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Agent;
using Novelly.Api.Beats;
@@ -5,6 +6,7 @@ using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
using Novelly.Api.Imports;
using Novelly.Api.Projects;
using Novelly.Api.Questions;
using Novelly.Api.Scenes;
@@ -41,6 +43,14 @@ public static class NovellyServiceRegistration
services.Configure<AgentOptions>(configuration.GetSection(AgentOptions.SectionName));
services.AddScoped<IAgentModelClient, AnthropicAgentModelClient>();
// A single unbounded queue shared by the request path (writer, in ImportService)
// and the background runner (reader) — the only background-job infra in the app.
services.AddSingleton(Channel.CreateUnbounded<Guid>());
services.AddScoped<ImportService>();
services.AddScoped<ImportAgentToolset>();
services.AddScoped<ImportAgentService>();
services.AddHostedService<ImportJobRunner>();
services.AddModelValidatorsFromAssemblyContaining<Program>();
return services;
+2
View File
@@ -3,6 +3,7 @@ using Novelly.Api.Agent;
using Novelly.Api.Beats;
using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Imports;
using Novelly.Api.Projects;
using Novelly.Api.Questions;
using Novelly.Api.Scenes;
@@ -27,6 +28,7 @@ public interface INovelDbContext
DbSet<OpenQuestion> OpenQuestions { get; }
DbSet<AgentConversation> Conversations { get; }
DbSet<AgentMessage> AgentMessages { get; }
DbSet<ImportJob> ImportJobs { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,832 @@
// <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("20260807001613_AddImportJobs")]
partial class AddImportJobs
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
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<Guid?>("CharacterId")
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("SceneId")
.HasColumnType("TEXT");
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("CharacterId");
b.HasIndex("SceneId");
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?>("PovCharacterId")
.HasColumnType("TEXT");
b.Property<Guid>("ProjectId")
.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.HasKey("Id");
b.HasIndex("PovCharacterId");
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.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>("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.Scenes.Scene", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("ChapterId")
.HasColumnType("TEXT");
b.Property<string>("Conflict")
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Goal")
.HasColumnType("TEXT");
b.Property<string>("Location")
.HasColumnType("TEXT");
b.Property<string>("Outcome")
.HasColumnType("TEXT");
b.Property<Guid?>("PovCharacterId")
.HasColumnType("TEXT");
b.Property<string>("Prose")
.HasColumnType("TEXT");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("Summary")
.HasColumnType("TEXT");
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("PovCharacterId");
b.HasIndex("ChapterId", "SortOrder");
b.ToTable("Scenes");
});
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("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.HasOne("Novelly.Api.Characters.Character", "Character")
.WithMany()
.HasForeignKey("CharacterId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Novelly.Api.Scenes.Scene", "Scene")
.WithMany()
.HasForeignKey("SceneId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Chapter");
b.Navigation("Character");
b.Navigation("Scene");
});
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");
});
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.Scenes.Scene", b =>
{
b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter")
.WithMany("Scenes")
.HasForeignKey("ChapterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Novelly.Api.Characters.Character", "PovCharacter")
.WithMany()
.HasForeignKey("PovCharacterId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Chapter");
b.Navigation("PovCharacter");
});
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");
b.Navigation("Scenes");
});
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,46 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Novelly.Api.Data.Migrations
{
/// <inheritdoc />
public partial class AddImportJobs : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ImportJobs",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
SourceRoot = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
ProjectId = table.Column<Guid>(type: "TEXT", nullable: true),
Status = table.Column<string>(type: "TEXT", maxLength: 16, nullable: false),
StatusMessage = table.Column<string>(type: "TEXT", nullable: true),
ChaptersCompleted = table.Column<int>(type: "INTEGER", nullable: false),
ChaptersTotal = table.Column<int>(type: "INTEGER", nullable: false),
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false),
UpdatedAt = table.Column<long>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ImportJobs", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_ImportJobs_SourceRoot",
table: "ImportJobs",
column: "SourceRoot");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ImportJobs");
}
}
}
@@ -365,6 +365,47 @@ namespace Novelly.Api.Data.Migrations
b.ToTable("CharacterRelationships");
});
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")
+12
View File
@@ -4,6 +4,7 @@ using Novelly.Api.Agent;
using Novelly.Api.Beats;
using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Imports;
using Novelly.Api.Projects;
using Novelly.Api.Questions;
using Novelly.Api.Scenes;
@@ -36,6 +37,7 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options)
public DbSet<OpenQuestion> OpenQuestions => Set<OpenQuestion>();
public DbSet<AgentConversation> Conversations => Set<AgentConversation>();
public DbSet<AgentMessage> AgentMessages => Set<AgentMessage>();
public DbSet<ImportJob> ImportJobs => Set<ImportJob>();
Task<int> INovelDbContext.SaveChangesAsync(CancellationToken cancellationToken) =>
base.SaveChangesAsync(cancellationToken);
@@ -183,5 +185,15 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options)
entity.Property(m => m.Role).HasConversion<string>().HasMaxLength(16);
entity.HasIndex(m => new { m.ConversationId, m.Sequence }).IsUnique();
});
builder.Entity<ImportJob>(entity =>
{
entity.Property(j => j.SourceRoot).IsRequired().HasMaxLength(1000);
entity.Property(j => j.Status).HasConversion<string>().HasMaxLength(16);
// No FK to Project: a job outlives the project it created, including the
// force-restart path where that project is deleted out from under it.
entity.HasIndex(j => j.SourceRoot);
});
}
}
@@ -0,0 +1,210 @@
using Microsoft.Extensions.Options;
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,
IOptions<AgentOptions> options,
ILogger<ImportAgentService> logger)
{
private readonly AgentOptions _options = options.Value;
public async Task<ImportRunResult> RunAsync(
string sourceRoot, Guid? existingProjectId, int chaptersTotal, CancellationToken ct = default)
{
toolset.Initialize(sourceRoot, existingProjectId);
var startingLedger = toolset.ReadLedgerOrNull();
var systemPrompt = BuildSystemPrompt(sourceRoot);
var transcript = new List<AgentChatMessage>
{
AgentChatMessage.User(new AgentTextBlock(
startingLedger is null
? "Start the import. No ledger exists yet — this is a fresh run."
: "Resume the import. Read the ledger first to see what's already done."))
};
for (var turn = 0; turn < _options.ImportMaxTurns; turn++)
{
logger.LogInformation(
"Import run turn {Turn} for {SourceRoot}", turn, sourceRoot);
await RunOneTurnAsync(systemPrompt, transcript, ct);
var ledger = toolset.ReadLedgerOrNull();
if (ImportPaths.IsComplete(ledger, chaptersTotal))
{
logger.LogInformation("Import for {SourceRoot} completed after {Turns} turns", sourceRoot, turn + 1);
return new ImportRunResult(
Completed: true,
toolset.ProjectId,
ledger?.CompletedChapters?.Count ?? 0,
null);
}
transcript.Add(AgentChatMessage.User(new AgentTextBlock(
"Continue the import from the ledger. If a batch of chapters remains, keep going.")));
}
var finalLedger = toolset.ReadLedgerOrNull();
logger.LogWarning("Import for {SourceRoot} hit its {MaxTurns}-turn safety limit without finishing", sourceRoot, _options.ImportMaxTurns);
return new ImportRunResult(
Completed: false,
toolset.ProjectId,
finalLedger?.CompletedChapters?.Count ?? 0,
"Reached the safety limit for this run without finishing. Starting the import "
+ "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)
{
for (var iteration = 0; iteration < _options.ImportMaxIterationsPerTurn; iteration++)
{
var response = await model.CompleteAsync(systemPrompt, transcript, toolset.Definitions, ct);
var requestedTools = response.Content.OfType<AgentToolUseBlock>().ToList();
if (requestedTools.Count == 0)
{
return;
}
transcript.Add(AgentChatMessage.Assistant(response.Content));
var results = new List<AgentContentBlock>();
foreach (var call in requestedTools)
{
var outcome = await toolset.ExecuteAsync(call.Name, call.Input, ct);
logger.LogInformation(
"Import tool {Tool} {Outcome}", call.Name, outcome.IsError ? "failed" : "succeeded");
results.Add(new AgentToolResultBlock(call.Id, outcome.Content, outcome.IsError));
}
transcript.Add(AgentChatMessage.User([.. results]));
}
logger.LogWarning(
"Import turn hit its {Max}-iteration ceiling; will re-check the ledger and, if incomplete, start another turn",
_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 = """
You import a novel outline that already exists as markdown files on disk into this
app's project data. You are running unattended nobody will read your replies or
answer questions mid-run, so make the judgment calls the instructions below call
for yourself and record anything genuinely ambiguous rather than stalling on it.
Your tools give you exactly two things: read-only access to files under the import
source folder, and application tools that create the project's chapters, characters,
beats and arcs the same ones the writer's own UI uses. You cannot write or edit
anything on disk except the resume ledger, and you cannot read anything outside the
source folder.
## Source folder
The source root is `{{SOURCE_ROOT}}`. Use list_source_files and read_source_file to
explore it. Expect:
- `outline.md` title/author heading, blurb paragraph(s), a chapter table.
- `outlines/NN-slug.md` (or `chapters/NN-slug.md`) one file per chapter:
`# Chapter NN`, `### Title`, a `**Thread:** X | **Part:** Y` line, one or more
prose summary paragraphs, a beat table (`| Beat | Character | What | Why |`), and
an optional `## Notes` section.
- `characters/<slug>.md` one file per character dossier: `# Name`, an italic
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.
## The ledger
Before writing anything, call read_ledger. If it returns `{{}}`, this is a fresh run.
Otherwise it tells you what a previous run already created do not re-create
anything whose id is already recorded. Shape:
```json
{{
"projectId": "guid",
"characters": {{ "Name": "guid", "Alias": "guid" }},
"chapters": {{ "1": "guid" }},
"completedPasses": ["project", "characters"],
"completedChapters": [1, 2, 3]
}}
```
Call write_ledger with the full, updated ledger after every successful write it's
small, send the whole thing each time. There is no server-side dedupe: if you skip
the ledger, a resumed run will duplicate everything.
## Passes, strictly in order
Skip a pass whose completion is already recorded. Jump straight to the first
incomplete one.
1. **Project** skip if `completedPasses` has "project". Parse title and author from
`outline.md`'s heading. The paragraph(s) before the chapter table are the blurb
pass them as `notes` to create_project. Record `projectId`, mark "project" done.
2. **Characters (dossiers)** skip if "characters" is complete. For each
`characters/*.md` not already in the ledger's `characters` map: name from the `#`
heading, occupation from the tagline, appearance/backstory/want from
Appearance/Background/Motivation. Record the id under the exact name and any
shorter alias worth matching later. Mark "characters" done once every dossier is
processed.
3. **Chapters + beats** process chapter files in ascending number order, skipping
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
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
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.
4. **Arc stages** skip if "arcs" is complete. Re-scan character dossiers for
`## Events`. For each: update_character(importance: "Main"), then for each bullet
add_arc_stage with a synthesized 3-5 word title (not a truncation) and the
bullet's text as description, with chapter_id when a `(Ch. N)` marker resolves to
an already-imported chapter. Mark "arcs" done once every dossier with `## Events`
is processed.
## Constraints
- Never invent plot content or character detail, and never guess which of several
candidate names an ambiguous reference means.
- Never write to disk except via write_ledger.
- Never call a create tool for something the ledger already records.
- If a tool call fails, stop that item and move on rather than retrying blindly
the ledger stays at the last successful write either way.
""";
}
@@ -0,0 +1,352 @@
using System.Text.Json;
using Novelly.Api.Agent;
using Novelly.Api.Beats;
using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Common;
using Novelly.Api.Projects;
namespace Novelly.Api.Imports;
/// <summary>A tool the import agent can call, bound to a handler that runs against this run's state.</summary>
internal record ImportAgentTool(
string Name,
string Description,
JsonElement InputSchema,
Func<JsonElement, CancellationToken, Task<object?>> Handler);
/// <summary>
/// The tools the outline-import agent can reach for: read-only, root-scoped filesystem
/// access to the source folder, a write capability limited to exactly the resume ledger,
/// and the same application services the chat agent and REST API use for everything else.
///
/// Deliberately a separate toolset from <see cref="NovelAgentToolset"/> rather than an
/// extension of it — filesystem access must never be reachable from a normal chat
/// conversation. One instance is built per import run (see <see cref="Initialize"/>), so
/// the current project id lives here rather than being threaded through every call.
/// </summary>
public class ImportAgentToolset(
ProjectService projects,
CharacterService characters,
CharacterArcService arcs,
ChapterService chapters,
BeatService beats,
ILogger<ImportAgentToolset> logger)
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
WriteIndented = false,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }
};
private string _sourceRoot = string.Empty;
private Dictionary<string, ImportAgentTool>? _byName;
public Guid? ProjectId { get; private set; }
public IReadOnlyList<AgentToolDefinition> Definitions =>
[.. ByName.Values.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema))];
/// <summary>Binds this instance to one run. Must be called before any tool executes.</summary>
public void Initialize(string sourceRoot, Guid? existingProjectId)
{
_sourceRoot = sourceRoot;
ProjectId = existingProjectId;
}
/// <summary>Reads the ledger directly — the run driver's ground truth for "is this done", not the model's say-so.</summary>
public ImportLedger? ReadLedgerOrNull() => ImportPaths.ReadLedger(_sourceRoot);
/// <summary>Runs a tool and serialises its result. Failures come back as text so the model can read and self-correct.</summary>
public async Task<AgentToolResult> ExecuteAsync(string name, JsonElement input, CancellationToken ct = default)
{
if (!ByName.TryGetValue(name, out var tool))
{
logger.LogWarning("Import agent requested unknown tool {Tool}", name);
return new AgentToolResult($"No such tool: '{name}'.", true);
}
logger.LogDebug("Running import tool {Tool}", name);
try
{
var result = await tool.Handler(input, ct);
logger.LogDebug("Import tool {Tool} succeeded", name);
return new AgentToolResult(JsonSerializer.Serialize(result, SerializerOptions), false);
}
catch (NotFoundException ex)
{
logger.LogWarning(ex, "Import tool {Tool} failed: not found", name);
return new AgentToolResult(ex.Message, true);
}
catch (ArgumentException ex)
{
logger.LogWarning(ex, "Import tool {Tool} failed: invalid argument", name);
return new AgentToolResult(ex.Message, true);
}
catch (JsonException ex)
{
logger.LogWarning(ex, "Import tool {Tool} failed: malformed JSON input", name);
return new AgentToolResult($"Malformed JSON: {ex.Message}", true);
}
catch (InvalidOperationException ex)
{
logger.LogWarning(ex, "Import tool {Tool} failed: invalid operation", name);
return new AgentToolResult(ex.Message, true);
}
catch (IOException ex)
{
logger.LogWarning(ex, "Import tool {Tool} failed: I/O error", name);
return new AgentToolResult(ex.Message, true);
}
catch (UnauthorizedAccessException ex)
{
logger.LogWarning(ex, "Import tool {Tool} failed: access denied", name);
return new AgentToolResult(ex.Message, true);
}
}
private Guid RequireProjectId() =>
ProjectId ?? throw new InvalidOperationException(
"No project exists yet for this import — call create_project first.");
private Dictionary<string, ImportAgentTool> ByName => _byName ??= Build().ToDictionary(t => t.Name);
private IEnumerable<ImportAgentTool> Build()
{
yield return new ImportAgentTool(
"list_source_files",
"List markdown files under the import source folder, optionally narrowed to one "
+ "subfolder (e.g. 'outlines', 'characters'). Returns paths relative to the source "
+ "folder, for use with read_source_file.",
new JsonSchemaBuilder()
.Str("subfolder", "Subfolder to list, relative to the source root. Omit to list the root.")
.Build(),
(input, ct) =>
{
var subfolder = JsonInput.String(input, "subfolder");
var dir = string.IsNullOrWhiteSpace(subfolder)
? _sourceRoot
: ImportPaths.ResolveWithin(_sourceRoot, subfolder);
if (!Directory.Exists(dir))
{
return Task.FromResult<object?>(Array.Empty<string>());
}
var files = Directory.EnumerateFiles(dir, "*.md", SearchOption.TopDirectoryOnly)
.Select(f => Path.GetRelativePath(_sourceRoot, f).Replace(Path.DirectorySeparatorChar, '/'))
.OrderBy(f => f, StringComparer.Ordinal)
.ToArray();
return Task.FromResult<object?>(files);
});
yield return new ImportAgentTool(
"read_source_file",
"Read one markdown file from the import source folder, by its path relative to "
+ "the source root (as returned by list_source_files, or a known name like "
+ "'outline.md'). Read-only — this tool never writes.",
new JsonSchemaBuilder()
.Str("path", "Path relative to the import source root.", required: true)
.Build(),
(input, ct) =>
{
var path = ImportPaths.ResolveWithin(_sourceRoot, JsonInput.RequiredString(input, "path"));
if (!File.Exists(path))
{
throw new ArgumentException($"'{JsonInput.RequiredString(input, "path")}' does not exist.");
}
var content = File.ReadAllText(path);
logger.LogDebug("Read source file, length {Length}", content.Length);
return Task.FromResult<object?>(content);
});
yield return new ImportAgentTool(
"read_ledger",
"Read the resume ledger (.novelly-import.json) at the root of the source folder. "
+ "Returns an empty object if none exists yet — this is a fresh import.",
new JsonSchemaBuilder().Build(),
(_, ct) =>
{
var path = ImportPaths.LedgerPath(_sourceRoot);
return Task.FromResult<object?>(File.Exists(path) ? File.ReadAllText(path) : "{}");
});
yield return new ImportAgentTool(
"write_ledger",
"Overwrite the resume ledger (.novelly-import.json) with the given JSON. This is "
+ "the only file this tool can write anywhere under the source folder — call it "
+ "after every successful write so a resumed run doesn't repeat it. Pass the full "
+ "ledger, not a diff; it's small.",
new JsonSchemaBuilder()
.Str("json", "The full ledger contents to write, as a JSON string.", required: true)
.Build(),
(input, ct) =>
{
var json = JsonInput.RequiredString(input, "json");
// Fail loudly on malformed JSON now rather than writing garbage the next
// run's read_ledger can't parse.
using var _ = JsonDocument.Parse(json);
File.WriteAllText(ImportPaths.LedgerPath(_sourceRoot), json);
return Task.FromResult<object?>(new { written = true });
});
yield return new ImportAgentTool(
"create_project",
"Create the novel project this import populates. Call once, in the first pass.",
new JsonSchemaBuilder()
.Str("title", "The book's title.", required: true)
.Str("author", "Author name, if known.")
.Str("notes", "The blurb/summary paragraph(s) from outline.md.")
.Build(),
async (input, ct) =>
{
var created = await projects.CreateAsync(new CreateProjectRequest(
JsonInput.RequiredString(input, "title"),
JsonInput.String(input, "author"),
Notes: JsonInput.String(input, "notes")), ct);
ProjectId = created.Id;
return created.ToResponse();
});
yield return new ImportAgentTool(
"update_project_brief",
"Revise the project's top-level fields. Only the fields you supply change.",
new JsonSchemaBuilder()
.Str("title", "New title.")
.Str("author", "Author name.")
.Str("genre", "Genre or category.")
.Str("notes", "Free-form notes — the blurb, if not already set.")
.Build(),
async (input, ct) => (await projects.UpdateAsync(RequireProjectId(), new UpdateProjectRequest(
JsonInput.String(input, "title"),
JsonInput.String(input, "author"),
JsonInput.String(input, "genre"),
Notes: JsonInput.String(input, "notes")), ct)
?? throw new NotFoundException(nameof(Project), RequireProjectId())).ToResponse());
yield return new ImportAgentTool(
"create_character",
"Add a character dossier, parsed from a characters/*.md file.",
CharacterSchema(nameRequired: true).Build(),
async (input, ct) => (await characters.CreateAsync(RequireProjectId(), new CreateCharacterRequest(
JsonInput.RequiredString(input, "name"),
Importance: JsonInput.Enum<CharacterImportance>(input, "importance") ?? CharacterImportance.Supporting,
Occupation: JsonInput.String(input, "occupation"),
Appearance: JsonInput.String(input, "appearance"),
Backstory: JsonInput.String(input, "backstory"),
Want: JsonInput.String(input, "want"),
Notes: JsonInput.String(input, "notes")), ct)).ToResponse());
yield return new ImportAgentTool(
"update_character",
"Revise an existing character dossier. Only the fields you supply change.",
CharacterSchema(nameRequired: false)
.Str("character_id", "Id of the character to update.", required: true)
.Build(),
async (input, ct) =>
{
var characterId = JsonInput.RequiredGuid(input, "character_id");
return (await characters.UpdateAsync(characterId, new UpdateCharacterRequest(
JsonInput.String(input, "name"),
Importance: JsonInput.Enum<CharacterImportance>(input, "importance"),
Occupation: JsonInput.String(input, "occupation"),
Appearance: JsonInput.String(input, "appearance"),
Backstory: JsonInput.String(input, "backstory"),
Want: JsonInput.String(input, "want"),
Notes: JsonInput.String(input, "notes")), ct)
?? throw new NotFoundException("Character", characterId)).ToResponse();
});
yield return new ImportAgentTool(
"create_chapter",
"Add a chapter. Its number is appended to the end of the manuscript unless you supply one.",
new JsonSchemaBuilder()
.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(),
async (input, ct) => (await chapters.CreateAsync(RequireProjectId(), new CreateChapterRequest(
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)).ToResponse());
yield return new ImportAgentTool(
"update_chapter",
"Revise a chapter's summary, POV 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) =>
{
var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
return (await chapters.UpdateAsync(chapterId, new UpdateChapterRequest(
Summary: JsonInput.String(input, "summary"),
PovCharacterId: JsonInput.Guid(input, "pov_character_id"),
Notes: JsonInput.String(input, "notes")), ct)
?? throw new NotFoundException("Chapter", chapterId)).ToResponse();
});
yield return new ImportAgentTool(
"create_beat",
"Add a beat to a chapter's outline, from one row of its beat table.",
new JsonSchemaBuilder()
.Str("chapter_id", "Id of the chapter the beat belongs to.", required: true)
.Str("title", "The Beat column — three to five words.", required: true)
.Str("character_id", "Id of the character named in the Character column, if it resolves.")
.Str("what_happened", "The What column.")
.Str("whats_next", "The Why column.")
.Build(),
async (input, ct) => (await beats.CreateAsync(
JsonInput.RequiredGuid(input, "chapter_id"),
new CreateBeatRequest(
JsonInput.RequiredString(input, "title"),
CharacterId: JsonInput.Guid(input, "character_id"),
WhatHappened: JsonInput.String(input, "what_happened"),
WhatsNext: JsonInput.String(input, "whats_next")), ct)).ToResponse());
yield return new ImportAgentTool(
"add_arc_stage",
"Add a stage to a character's arc, from one bullet under a dossier's ## Events section.",
new JsonSchemaBuilder()
.Str("character_id", "Id of the character whose arc to add to.", required: true)
.Str("title", "A 3-5 word handle for the change, synthesized from the bullet.", required: true)
.Str("description", "The bullet's text.")
.Str("chapter_id", "The chapter this stage is pinned to, if the (Ch. N) marker resolves to an imported chapter.")
.Build(),
async (input, ct) => (await arcs.CreateAsync(
JsonInput.RequiredGuid(input, "character_id"),
new CreateArcStageRequest(
JsonInput.RequiredString(input, "title"),
Description: JsonInput.String(input, "description"),
ChapterId: JsonInput.Guid(input, "chapter_id")), ct)).ToResponse());
}
private static JsonSchemaBuilder CharacterSchema(bool nameRequired) =>
new JsonSchemaBuilder()
.Str("name", "The character's name.", nameRequired)
.Enum(
"importance",
"How much of the book they carry. Only characters with a dossier ## Events "
+ "section should be promoted to Main.",
System.Enum.GetNames<CharacterImportance>())
.Str("occupation", "The italic tagline under the heading.")
.Str("appearance", "The ## Appearance section.")
.Str("backstory", "The ## Background section.")
.Str("want", "The ## Motivation section.")
.Str("notes", "The ## Notes section, if present.");
}
@@ -0,0 +1,78 @@
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Imports;
public record ImportJobResponse(
Guid Id,
string SourceRoot,
Guid? ProjectId,
ImportJobStatus Status,
string? StatusMessage,
int ChaptersCompleted,
int ChaptersTotal,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt);
/// <summary>Whether a source folder is ready for a fresh import, has one to resume, or is already done.</summary>
public enum ImportReadiness
{
Fresh,
Resumable,
Complete
}
public record ImportInspectionResponse(
ImportReadiness Readiness,
Guid? ProjectId,
int ChaptersCompleted,
int ChaptersTotal,
IReadOnlyList<string> CompletedPasses);
public record InspectImportRequest(string SourceRoot);
public class InspectImportRequestValidator : IModelValidator<InspectImportRequest>
{
public ValidationResult Validate(InspectImportRequest model)
{
var result = new ValidationResult();
if (string.IsNullOrWhiteSpace(model.SourceRoot))
result.AddError("SourceRoot", "'Source Root' must not be empty.");
return result;
}
}
/// <summary>
/// Starts a fresh import, resumes an incomplete one, or — with <see cref="ForceRestart"/> —
/// deletes the ledger and the project it points at before starting clean. Resuming needs no
/// flag: the importer always continues from the ledger it finds unless told to wipe it.
/// </summary>
public record StartImportRequest(string SourceRoot, bool ForceRestart = false);
public class StartImportRequestValidator : IModelValidator<StartImportRequest>
{
public ValidationResult Validate(StartImportRequest model)
{
var result = new ValidationResult();
if (string.IsNullOrWhiteSpace(model.SourceRoot))
result.AddError("SourceRoot", "'Source Root' must not be empty.");
return result;
}
}
public static class ImportMapping
{
public static ImportJobResponse ToResponse(this ImportJob job) => new(
job.Id,
job.SourceRoot,
job.ProjectId,
job.Status,
job.StatusMessage,
job.ChaptersCompleted,
job.ChaptersTotal,
job.CreatedAt,
job.UpdatedAt);
}
@@ -0,0 +1,33 @@
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Imports;
public static class ImportEndpoints
{
public static IEndpointRouteBuilder MapImportEndpoints(this IEndpointRouteBuilder app)
{
var imports = app.MapGroup("/api/imports").WithTags("Imports")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
imports.MapPost("/inspect", async (
InspectImportRequest request, ImportService service, CancellationToken ct) =>
Results.Ok(await service.InspectAsync(request, ct)))
.WithSummary("Check whether a source folder is a fresh import, has one to resume, or is already complete.");
imports.MapPost("/", async (
StartImportRequest request, ImportService service, CancellationToken ct) =>
{
var job = (await service.StartOrResumeAsync(request, ct)).ToResponse();
return Results.Created($"/api/imports/{job.Id}", job);
})
.WithSummary("Start, resume, or (with forceRestart) wipe and restart an outline import.");
imports.MapGet("/{id:guid}", async (Guid id, ImportService service, CancellationToken ct) =>
(await service.GetStatusAsync(id, ct))?.ToResponse().ToApiResult())
.WithSummary("Poll an import job's progress.");
return app;
}
}
+41
View File
@@ -0,0 +1,41 @@
namespace Novelly.Api.Imports;
/// <summary>
/// Where an import run stands. <see cref="Paused"/> means it hit its safety limit for a
/// single run without finishing — not an error, just more work than fit in one pass —
/// and re-starting the same source root resumes it from the ledger.
/// </summary>
public enum ImportJobStatus
{
Pending,
Running,
Completed,
Failed,
Paused
}
/// <summary>
/// One run of the outline importer against a source folder, tracked so the web client can
/// poll progress while the embedded agent works through it in the background.
/// </summary>
public class ImportJob
{
public Guid Id { get; init; } = Guid.NewGuid();
/// <summary>Absolute, canonicalised path to the outline folder this job reads from.</summary>
public string SourceRoot { get; init; } = string.Empty;
/// <summary>Set once the import creates (or resumes) the project it's populating.</summary>
public Guid? ProjectId { get; set; }
public ImportJobStatus Status { get; set; } = ImportJobStatus.Pending;
/// <summary>Human-readable detail for <see cref="Paused"/> or <see cref="Failed"/> — null otherwise.</summary>
public string? StatusMessage { get; set; }
public int ChaptersCompleted { get; set; }
public int ChaptersTotal { get; set; }
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}
@@ -0,0 +1,79 @@
using System.Threading.Channels;
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Data;
namespace Novelly.Api.Imports;
/// <summary>
/// The only background-job infrastructure in the app. Drains import job ids off a queue
/// and runs each one to completion (or its safety limit) in its own DI scope, persisting
/// progress and the terminal status onto the <see cref="ImportJob"/> row the web client
/// polls. Everything else in Novelly runs synchronously on the request thread; imports are
/// the first thing long enough that it can't.
/// </summary>
public class ImportJobRunner(
Channel<Guid> queue,
IServiceScopeFactory scopeFactory,
ILogger<ImportJobRunner> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var jobId in queue.Reader.ReadAllAsync(stoppingToken))
{
try
{
await RunJobAsync(jobId, stoppingToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// A failure here means the job row itself couldn't be updated (e.g. the
// scope's DbContext failed) — RunJobAsync already turns ordinary import
// failures into a Failed status rather than throwing.
logger.LogError(ex, "Import job {JobId} runner failed unexpectedly", jobId);
}
}
}
private async Task RunJobAsync(Guid jobId, CancellationToken ct)
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<INovelDbContext>();
var agent = scope.ServiceProvider.GetRequiredService<ImportAgentService>();
var job = await db.ImportJobs.FirstOrDefaultAsync(j => j.Id == jobId, ct);
if (job is null)
{
logger.LogWarning("Import job {JobId} not found when the runner picked it up", jobId);
return;
}
logger.LogInformation("Import job {JobId} starting for {SourceRoot}", job.Id, job.SourceRoot);
job.Status = ImportJobStatus.Running;
job.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct);
try
{
var existingProjectId = ImportPaths.ReadLedger(job.SourceRoot)?.ProjectId;
var result = await agent.RunAsync(job.SourceRoot, existingProjectId, job.ChaptersTotal, ct);
job.ProjectId = result.ProjectId;
job.ChaptersCompleted = result.ChaptersCompleted;
job.Status = result.Completed ? ImportJobStatus.Completed : ImportJobStatus.Paused;
job.StatusMessage = result.Message;
}
catch (Exception ex)
{
logger.LogError(ex, "Import job {JobId} failed", job.Id);
job.Status = ImportJobStatus.Failed;
job.StatusMessage = ex.Message;
}
job.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct);
logger.LogInformation("Import job {JobId} finished as {Status}", job.Id, job.Status);
}
}
+136
View File
@@ -0,0 +1,136 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Novelly.Api.Imports;
/// <summary>
/// The resume ledger an import run writes to <c>&lt;sourceRoot&gt;/.novelly-import.json</c>.
/// Shape matches the one the <c>outline-importer</c> Claude Code subagent already writes,
/// so a partially-completed CLI import can be finished from the web app and vice versa.
/// </summary>
public record ImportLedger(
Guid? ProjectId,
Dictionary<string, Guid>? Characters,
Dictionary<string, Guid>? Chapters,
List<string>? CompletedPasses,
List<int>? CompletedChapters);
/// <summary>
/// Path resolution and ledger I/O shared by <see cref="ImportService"/> (which only ever
/// peeks at the ledger to report status) and <see cref="ImportAgentToolset"/> (which reads
/// and writes it as the agent's only file-write capability). Centralising the containment
/// check here means there is exactly one place that decides whether a path is inside the
/// import root, rather than one per caller.
/// </summary>
internal static class ImportPaths
{
private const string LedgerFileName = ".novelly-import.json";
private static readonly JsonSerializerOptions LedgerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
/// <summary>
/// Canonicalises a source root and confirms it's a directory that exists. Throws
/// <see cref="ArgumentException"/> on anything else — bad input from the request, not
/// an exceptional server condition.
/// </summary>
public static string ResolveRoot(string sourceRoot)
{
if (string.IsNullOrWhiteSpace(sourceRoot))
throw new ArgumentException("'Source Root' must not be empty.", nameof(sourceRoot));
string full;
try
{
full = Path.GetFullPath(sourceRoot);
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
{
throw new ArgumentException($"'{sourceRoot}' is not a valid path.", nameof(sourceRoot));
}
if (!Directory.Exists(full))
throw new ArgumentException($"'{full}' does not exist or is not a directory.", nameof(sourceRoot));
return full;
}
/// <summary>
/// Resolves a path the agent supplied relative to the import root, rejecting anything
/// that would escape it (`..`, absolute paths, symlink traversal). This is the tool
/// layer's actual security boundary — the system prompt asking nicely is not.
/// </summary>
public static string ResolveWithin(string root, string relativePath)
{
if (string.IsNullOrWhiteSpace(relativePath))
throw new ArgumentException("Path must not be empty.");
var combined = Path.GetFullPath(Path.Combine(root, relativePath));
var relativeToRoot = Path.GetRelativePath(root, combined);
if (relativeToRoot.StartsWith("..", StringComparison.Ordinal) || Path.IsPathRooted(relativeToRoot))
throw new ArgumentException($"'{relativePath}' escapes the import source folder.");
return combined;
}
public static string LedgerPath(string root) => Path.Combine(root, LedgerFileName);
/// <summary>Null when no ledger exists yet — a fresh import, not an error.</summary>
public static ImportLedger? ReadLedger(string root)
{
var path = LedgerPath(root);
if (!File.Exists(path))
{
return null;
}
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<ImportLedger>(json, LedgerOptions);
}
public static void DeleteLedger(string root)
{
var path = LedgerPath(root);
if (File.Exists(path))
{
File.Delete(path);
}
}
/// <summary>
/// Counts chapter source files as a stand-in for "how many chapters does this outline
/// have" — good enough to drive a progress bar without parsing <c>outline.md</c>'s
/// chapter table in C#.
/// </summary>
public static int CountChapterFiles(string root)
{
foreach (var folder in new[] { "outlines", "chapters" })
{
var path = Path.Combine(root, folder);
if (Directory.Exists(path))
{
return Directory.EnumerateFiles(path, "*.md").Count();
}
}
return 0;
}
public static bool IsComplete(ImportLedger? ledger, int chaptersTotal)
{
if (ledger is null)
{
return false;
}
var passes = ledger.CompletedPasses ?? [];
var requiredPasses = new[] { "project", "characters", "chapters", "arcs" };
var chaptersDone = ledger.CompletedChapters?.Count ?? 0;
return requiredPasses.All(passes.Contains) && (chaptersTotal == 0 || chaptersDone >= chaptersTotal);
}
}
+110
View File
@@ -0,0 +1,110 @@
using System.Threading.Channels;
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
using Novelly.Api.Projects;
namespace Novelly.Api.Imports;
/// <summary>
/// Read-only inspection and job creation for outline imports. The actual import — reading
/// source files, calling the model, writing project data — runs in <see cref="ImportAgentService"/>,
/// driven off the request thread by <see cref="ImportJobRunner"/>; this service only ever
/// touches the filesystem to peek at a ledger, never to import anything itself.
/// </summary>
public class ImportService(
INovelDbContext db,
ProjectService projects,
Channel<Guid> queue,
ILogger<ImportService> logger,
IModelValidator<InspectImportRequest> inspectValidator,
IModelValidator<StartImportRequest> startValidator)
{
/// <summary>
/// Reports whether a folder is a fresh import, one to resume, or already complete —
/// so the UI can offer the right action before committing to anything.
/// </summary>
public Task<ImportInspectionResponse> InspectAsync(InspectImportRequest request, CancellationToken ct = default)
{
Guard.Null(request, nameof(request));
inspectValidator.Validate(request).ThrowIfInvalid();
logger.LogInformation("Inspecting import source {SourceRoot}", request.SourceRoot);
var root = ImportPaths.ResolveRoot(request.SourceRoot);
var ledger = ImportPaths.ReadLedger(root);
var total = ImportPaths.CountChapterFiles(root);
if (ledger is null)
{
return Task.FromResult(new ImportInspectionResponse(ImportReadiness.Fresh, null, 0, total, []));
}
var chaptersDone = ledger.CompletedChapters?.Count ?? 0;
var readiness = ImportPaths.IsComplete(ledger, total) ? ImportReadiness.Complete : ImportReadiness.Resumable;
return Task.FromResult(new ImportInspectionResponse(
readiness, ledger.ProjectId, chaptersDone, total, ledger.CompletedPasses ?? []));
}
/// <summary>
/// Creates (or reuses) an <see cref="ImportJob"/> for this source root and enqueues it
/// for the background runner. <see cref="StartImportRequest.ForceRestart"/> deletes the
/// ledger and the project it points at first — the "complete, delete and reimport" path —
/// so make sure the caller has confirmed with the writer before setting it.
/// </summary>
public async Task<ImportJob> StartOrResumeAsync(StartImportRequest request, CancellationToken ct = default)
{
Guard.Null(request, nameof(request));
startValidator.Validate(request).ThrowIfInvalid();
logger.LogInformation(
"Starting import for {SourceRoot}, forceRestart {ForceRestart}", request.SourceRoot, request.ForceRestart);
var root = ImportPaths.ResolveRoot(request.SourceRoot);
if (request.ForceRestart)
{
var ledger = ImportPaths.ReadLedger(root);
if (ledger?.ProjectId is { } existingProjectId)
{
logger.LogWarning(
"Force-restarting import for {SourceRoot}: deleting project {ProjectId}", root, existingProjectId);
await projects.DeleteAsync(existingProjectId, ct);
}
ImportPaths.DeleteLedger(root);
}
var existing = await db.ImportJobs
.Where(j => j.SourceRoot == root
&& (j.Status == ImportJobStatus.Pending || j.Status == ImportJobStatus.Running))
.FirstOrDefaultAsync(ct);
if (existing is not null)
{
logger.LogInformation("Import for {SourceRoot} is already {Status} as job {JobId}", root, existing.Status, existing.Id);
return existing;
}
var job = new ImportJob { SourceRoot = root, ChaptersTotal = ImportPaths.CountChapterFiles(root) };
db.ImportJobs.Add(job);
await db.SaveChangesAsync(ct);
await queue.Writer.WriteAsync(job.Id, ct);
return job;
}
/// <summary>Null when no job has this id — a lookup miss is expected, not exceptional.</summary>
public async Task<ImportJob?> GetStatusAsync(Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
logger.LogInformation("Getting import job {JobId}", id);
var job = await db.ImportJobs.FirstOrDefaultAsync(j => j.Id == id, ct);
return job;
}
}
+3 -1
View File
@@ -7,6 +7,7 @@ using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Common;
using Novelly.Api.Data;
using Novelly.Api.Imports;
using Novelly.Api.Projects;
using Novelly.Api.Questions;
using Novelly.Api.Scenes;
@@ -98,7 +99,8 @@ app.MapProjectEndpoints()
.MapSceneEndpoints()
.MapTagEndpoints()
.MapOpenQuestionEndpoints()
.MapAgentEndpoints();
.MapAgentEndpoints()
.MapImportEndpoints();
app.Run();
@@ -2,7 +2,7 @@ using Novelly.Api.Common.Validation;
namespace Novelly.Api.Projects;
public record ProjectSummaryDto(
public record ProjectSummaryResponse(
Guid Id,
string Title,
string? Author,
@@ -14,7 +14,7 @@ public record ProjectSummaryDto(
int WordCount,
DateTimeOffset UpdatedAt);
public record ProjectDto(
public record ProjectResponse(
Guid Id,
string Title,
string? Author,
@@ -116,7 +116,7 @@ file static class ProjectValidation
public static class ProjectMapping
{
public static ProjectDto ToDto(this Project p) => new(
public static ProjectResponse ToResponse(this Project p) => new(
p.Id, p.Title, p.Author, p.Genre, p.Logline, p.Synopsis, p.Notes,
p.TargetWordCount, p.CreatedAt, p.UpdatedAt);
}
+3 -3
View File
@@ -16,19 +16,19 @@ public static class ProjectEndpoints
.WithSummary("List all novel projects.");
group.MapGet("/{id:guid}", async (Guid id, ProjectService service, CancellationToken ct) =>
(await service.GetAsync(id, ct)).ToApiResult())
(await service.GetAsync(id, ct))?.ToResponse().ToApiResult())
.WithSummary("Read a project's brief.");
group.MapPost("/", async (CreateProjectRequest request, ProjectService service, CancellationToken ct) =>
{
var created = await service.CreateAsync(request, ct);
var created = (await service.CreateAsync(request, ct)).ToResponse();
return Results.Created($"/api/projects/{created.Id}", created);
})
.WithSummary("Create a novel project.");
group.MapPatch("/{id:guid}", async (
Guid id, UpdateProjectRequest request, ProjectService service, CancellationToken ct) =>
(await service.UpdateAsync(id, request, ct)).ToApiResult())
(await service.UpdateAsync(id, request, ct))?.ToResponse().ToApiResult())
.WithSummary("Update a project's brief.");
group.MapDelete("/{id:guid}", async (Guid id, ProjectService service, CancellationToken ct) =>
+8 -8
View File
@@ -11,13 +11,13 @@ public class ProjectService(
IModelValidator<CreateProjectRequest> createValidator,
IModelValidator<UpdateProjectRequest> updateValidator)
{
public async Task<IReadOnlyList<ProjectSummaryDto>> ListAsync(CancellationToken ct = default)
public async Task<IReadOnlyList<ProjectSummaryResponse>> ListAsync(CancellationToken ct = default)
{
logger.LogInformation("Listing projects");
return await db.Projects
.OrderByDescending(p => p.UpdatedAt)
.Select(p => new ProjectSummaryDto(
.Select(p => new ProjectSummaryResponse(
p.Id,
p.Title,
p.Author,
@@ -32,15 +32,15 @@ public class ProjectService(
}
/// <summary>Null when no project has this id — a lookup miss is expected, not exceptional.</summary>
public async Task<ProjectDto?> GetAsync(Guid id, CancellationToken ct = default)
public async Task<Project?> GetAsync(Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
logger.LogInformation("Getting project {ProjectId}", id);
return (await FindAsync(id, ct))?.ToDto();
return await FindAsync(id, ct);
}
public async Task<ProjectDto> CreateAsync(CreateProjectRequest request, CancellationToken ct = default)
public async Task<Project> CreateAsync(CreateProjectRequest request, CancellationToken ct = default)
{
Guard.Null(request, nameof(request));
createValidator.Validate(request).ThrowIfInvalid();
@@ -60,10 +60,10 @@ public class ProjectService(
db.Projects.Add(project);
await db.SaveChangesAsync(ct);
return project.ToDto();
return project;
}
public async Task<ProjectDto?> UpdateAsync(Guid id, UpdateProjectRequest request, CancellationToken ct = default)
public async Task<Project?> UpdateAsync(Guid id, UpdateProjectRequest request, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
Guard.Null(request, nameof(request));
@@ -87,7 +87,7 @@ public class ProjectService(
project.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct);
return project.ToDto();
return project;
}
/// <summary>True if a project was deleted; false if no project had this id.</summary>
@@ -2,7 +2,7 @@ using Novelly.Api.Common.Validation;
namespace Novelly.Api.Questions;
public record OpenQuestionDto(
public record OpenQuestionResponse(
Guid Id,
Guid ProjectId,
string Question,
@@ -100,7 +100,7 @@ public class ResolveOpenQuestionRequestValidator : IModelValidator<ResolveOpenQu
public static class OpenQuestionMapping
{
public static OpenQuestionDto ToDto(this OpenQuestion q) => new(
public static OpenQuestionResponse ToResponse(this OpenQuestion q) => new(
q.Id,
q.ProjectId,
q.Question,
@@ -18,13 +18,13 @@ public static class OpenQuestionEndpoints
Guid? chapterId = null,
Guid? characterId = null,
bool includeResolved = false) =>
Results.Ok(await service.ListAsync(projectId, chapterId, characterId, includeResolved, ct)))
Results.Ok((await service.ListAsync(projectId, chapterId, characterId, includeResolved, ct)).Select(q => q.ToResponse())))
.WithSummary("List a project's open questions, optionally narrowed to one chapter or character.");
projectScoped.MapPost("/", async (
Guid projectId, CreateOpenQuestionRequest request, OpenQuestionService service, CancellationToken ct) =>
{
var created = await service.CreateAsync(projectId, request, ct);
var created = (await service.CreateAsync(projectId, request, ct)).ToResponse();
return Results.Created($"/api/questions/{created.Id}", created);
})
.WithSummary("Raise an open question, optionally against a chapter outline and/or a character.");
@@ -34,21 +34,21 @@ public static class OpenQuestionEndpoints
.AddEndpointFilter<ValidationEndpointFilter>();
questions.MapGet("/{id:guid}", async (Guid id, OpenQuestionService service, CancellationToken ct) =>
(await service.GetAsync(id, ct)).ToApiResult())
(await service.GetAsync(id, ct))?.ToResponse().ToApiResult())
.WithSummary("Read one question.");
questions.MapPatch("/{id:guid}", async (
Guid id, UpdateOpenQuestionRequest request, OpenQuestionService service, CancellationToken ct) =>
(await service.UpdateAsync(id, request, ct)).ToApiResult())
(await service.UpdateAsync(id, request, ct))?.ToResponse().ToApiResult())
.WithSummary("Update a question or change what it is attached to.");
questions.MapPost("/{id:guid}/resolve", async (
Guid id, ResolveOpenQuestionRequest request, OpenQuestionService service, CancellationToken ct) =>
(await service.ResolveAsync(id, request, ct)).ToApiResult())
(await service.ResolveAsync(id, request, ct))?.ToResponse().ToApiResult())
.WithSummary("Settle a question, optionally appending the resolution to the notes it hangs off.");
questions.MapPost("/{id:guid}/reopen", async (Guid id, OpenQuestionService service, CancellationToken ct) =>
(await service.ReopenAsync(id, ct)).ToApiResult())
(await service.ReopenAsync(id, ct))?.ToResponse().ToApiResult())
.WithSummary("Put a resolved question back on the list.");
questions.MapDelete("/{id:guid}", async (Guid id, OpenQuestionService service, CancellationToken ct) =>
@@ -24,7 +24,7 @@ public class OpenQuestionService(
/// Filters narrow to what one page cares about; resolved questions are left out
/// unless asked for, since the point of the list is what is still undecided.
/// </summary>
public async Task<IReadOnlyList<OpenQuestionDto>> ListAsync(
public async Task<IReadOnlyList<OpenQuestion>> ListAsync(
Guid projectId,
Guid? chapterId = null,
Guid? characterId = null,
@@ -61,20 +61,19 @@ public class OpenQuestionService(
.. questions
.OrderBy(q => q.ResolvedAt is not null)
.ThenByDescending(q => q.CreatedAt)
.Select(q => q.ToDto())
];
}
/// <summary>Null when no open question has this id — a lookup miss is expected, not exceptional.</summary>
public async Task<OpenQuestionDto?> GetAsync(Guid id, CancellationToken ct = default)
public async Task<OpenQuestion?> GetAsync(Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
logger.LogInformation("Getting open question {QuestionId}", id);
return (await FindAsync(id, ct))?.ToDto();
return await FindAsync(id, ct);
}
public async Task<OpenQuestionDto> CreateAsync(
public async Task<OpenQuestion> CreateAsync(
Guid projectId, CreateOpenQuestionRequest request, CancellationToken ct = default)
{
Guard.Default(projectId, nameof(projectId));
@@ -104,10 +103,10 @@ public class OpenQuestionService(
await db.SaveChangesAsync(ct);
// Just created it — the reload is only to pick up includes, not to check existence.
return (await FindAsync(question.Id, ct))!.ToDto();
return (await FindAsync(question.Id, ct))!;
}
public async Task<OpenQuestionDto?> UpdateAsync(
public async Task<OpenQuestion?> UpdateAsync(
Guid id, UpdateOpenQuestionRequest request, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
@@ -131,7 +130,7 @@ public class OpenQuestionService(
question.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct);
return (await FindAsync(id, ct))!.ToDto();
return (await FindAsync(id, ct))!;
}
/// <summary>
@@ -140,7 +139,7 @@ public class OpenQuestionService(
/// writer reads rather than only in a list they have stopped looking at. Null when no
/// open question has this id.
/// </summary>
public async Task<OpenQuestionDto?> ResolveAsync(
public async Task<OpenQuestion?> ResolveAsync(
Guid id, ResolveOpenQuestionRequest request, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
@@ -189,11 +188,11 @@ public class OpenQuestionService(
}
await db.SaveChangesAsync(ct);
return (await FindAsync(id, ct))!.ToDto();
return (await FindAsync(id, ct))!;
}
/// <summary>Puts a question back on the list. The resolution goes; anything already appended to notes stays. Null when no open question has this id.</summary>
public async Task<OpenQuestionDto?> ReopenAsync(Guid id, CancellationToken ct = default)
public async Task<OpenQuestion?> ReopenAsync(Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
@@ -210,7 +209,7 @@ public class OpenQuestionService(
question.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct);
return (await FindAsync(id, ct))!.ToDto();
return (await FindAsync(id, ct))!;
}
/// <summary>True if an open question was deleted; false if no question had this id.</summary>
@@ -3,7 +3,7 @@ using Novelly.Api.Common.Validation;
namespace Novelly.Api.Scenes;
public record SceneDto(
public record SceneResponse(
Guid Id,
Guid ChapterId,
int SortOrder,
@@ -111,7 +111,7 @@ file static class SceneValidation
public static class SceneMapping
{
public static SceneDto ToDto(this Scene s) => new(
public static SceneResponse ToResponse(this Scene s) => new(
s.Id, s.ChapterId, s.SortOrder, s.Title, s.Summary,
s.Goal, s.Conflict, s.Outcome,
s.PovCharacterId, s.PovCharacter?.Name, s.Location,
+4 -4
View File
@@ -12,13 +12,13 @@ public static class SceneEndpoints
.AddEndpointFilter<ValidationEndpointFilter>();
chapterScoped.MapGet("/", async (Guid chapterId, SceneService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(chapterId, ct)))
Results.Ok((await service.ListAsync(chapterId, ct)).Select(s => s.ToResponse())))
.WithSummary("List a chapter's scenes in order.");
chapterScoped.MapPost("/", async (
Guid chapterId, CreateSceneRequest request, SceneService service, CancellationToken ct) =>
{
var created = await service.CreateAsync(chapterId, request, ct);
var created = (await service.CreateAsync(chapterId, request, ct)).ToResponse();
return Results.Created($"/api/scenes/{created.Id}", created);
})
.WithSummary("Add a scene to a chapter.");
@@ -28,12 +28,12 @@ public static class SceneEndpoints
.AddEndpointFilter<ValidationEndpointFilter>();
scenes.MapGet("/{id:guid}", async (Guid id, SceneService service, CancellationToken ct) =>
(await service.GetAsync(id, ct)).ToApiResult())
(await service.GetAsync(id, ct))?.ToResponse().ToApiResult())
.WithSummary("Read a scene, including its prose.");
scenes.MapPatch("/{id:guid}", async (
Guid id, UpdateSceneRequest request, SceneService service, CancellationToken ct) =>
(await service.UpdateAsync(id, request, ct)).ToApiResult())
(await service.UpdateAsync(id, request, ct))?.ToResponse().ToApiResult())
.WithSummary("Update a scene. Sending prose recomputes the word count.");
scenes.MapDelete("/{id:guid}", async (Guid id, SceneService service, CancellationToken ct) =>
+8 -10
View File
@@ -12,30 +12,28 @@ public class SceneService(
IModelValidator<CreateSceneRequest> createValidator,
IModelValidator<UpdateSceneRequest> updateValidator)
{
public async Task<IReadOnlyList<SceneDto>> ListAsync(Guid chapterId, CancellationToken ct = default)
public async Task<IReadOnlyList<Scene>> ListAsync(Guid chapterId, CancellationToken ct = default)
{
Guard.Default(chapterId, nameof(chapterId));
logger.LogInformation("Listing scenes for chapter {ChapterId}", chapterId);
var scenes = await Query()
return await Query()
.Where(s => s.ChapterId == chapterId)
.OrderBy(s => s.SortOrder)
.ToListAsync(ct);
return [.. scenes.Select(s => s.ToDto())];
}
/// <summary>Null when no scene has this id — a lookup miss is expected, not exceptional.</summary>
public async Task<SceneDto?> GetAsync(Guid id, CancellationToken ct = default)
public async Task<Scene?> GetAsync(Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
logger.LogInformation("Getting scene {SceneId}", id);
return (await FindAsync(id, ct))?.ToDto();
return await FindAsync(id, ct);
}
public async Task<SceneDto> CreateAsync(Guid chapterId, CreateSceneRequest request, CancellationToken ct = default)
public async Task<Scene> CreateAsync(Guid chapterId, CreateSceneRequest request, CancellationToken ct = default)
{
Guard.Default(chapterId, nameof(chapterId));
Guard.Null(request, nameof(request));
@@ -69,10 +67,10 @@ public class SceneService(
await db.SaveChangesAsync(ct);
// Just created it — the reload is only to pick up includes, not to check existence.
return (await FindAsync(scene.Id, ct))!.ToDto();
return (await FindAsync(scene.Id, ct))!;
}
public async Task<SceneDto?> UpdateAsync(Guid id, UpdateSceneRequest request, CancellationToken ct = default)
public async Task<Scene?> UpdateAsync(Guid id, UpdateSceneRequest request, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
Guard.Null(request, nameof(request));
@@ -105,7 +103,7 @@ public class SceneService(
scene.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct);
return (await FindAsync(id, ct))!.ToDto();
return (await FindAsync(id, ct))!;
}
/// <summary>True if a scene was deleted; false if no scene had this id.</summary>
@@ -2,9 +2,9 @@ using Novelly.Api.Common.Validation;
namespace Novelly.Api.Tags;
public record TagDto(Guid Id, string Name, string? Color);
public record TagResponse(Guid Id, string Name, string? Color);
public record TagSummaryDto(
public record TagSummaryResponse(
Guid Id,
string Name,
string? Color,
@@ -63,17 +63,17 @@ public class UpdateTagRequestValidator : IModelValidator<UpdateTagRequest>
/// tags — seeing that a motif touches two characters, a chapter and four beats is what
/// makes them worth maintaining.
/// </summary>
public record TagReferencesDto(
TagDto Tag,
IReadOnlyList<TaggedCharacterDto> Characters,
IReadOnlyList<TaggedChapterDto> Chapters,
IReadOnlyList<TaggedBeatDto> Beats);
public record TagReferencesResponse(
TagResponse Tag,
IReadOnlyList<TaggedCharacterResponse> Characters,
IReadOnlyList<TaggedChapterResponse> Chapters,
IReadOnlyList<TaggedBeatResponse> Beats);
public record TaggedCharacterDto(Guid Id, string Name, string Role);
public record TaggedCharacterResponse(Guid Id, string Name, string Role);
public record TaggedChapterDto(Guid Id, int Number, string Title, string? Summary);
public record TaggedChapterResponse(Guid Id, int Number, string Title, string? Summary);
public record TaggedBeatDto(
public record TaggedBeatResponse(
Guid Id,
Guid ChapterId,
int ChapterNumber,
@@ -85,7 +85,28 @@ public record TaggedBeatDto(
public static class TagMapping
{
public static TagDto ToDto(this Tag t) => new(t.Id, t.Name, t.Color);
public static TagResponse ToResponse(this Tag t) => new(t.Id, t.Name, t.Color);
public static TagReferencesResponse ToReferencesResponse(this Tag tag) => new(
tag.ToResponse(),
[.. tag.Characters
.OrderBy(c => c.Name)
.Select(c => new TaggedCharacterResponse(c.Id, c.Name, c.Role.ToString()))],
[.. tag.Chapters
.OrderBy(c => c.Number)
.Select(c => new TaggedChapterResponse(c.Id, c.Number, c.Title, c.Summary))],
[.. tag.Beats
.OrderBy(b => b.Chapter?.Number ?? 0)
.ThenBy(b => b.SortOrder)
.Select(b => new TaggedBeatResponse(
b.Id,
b.ChapterId,
b.Chapter?.Number ?? 0,
b.Chapter?.Title ?? "(unknown chapter)",
b.SortOrder,
b.Title,
b.Character?.Name,
b.WhatHappened))]);
/// <summary>
/// Tags are matched case-insensitively but stored as first typed, so "Betrayal" and
+3 -3
View File
@@ -18,7 +18,7 @@ public static class TagEndpoints
projectScoped.MapPost("/", async (
Guid projectId, CreateTagRequest request, TagService service, CancellationToken ct) =>
{
var created = await service.CreateAsync(projectId, request, ct);
var created = (await service.CreateAsync(projectId, request, ct)).ToResponse();
return Results.Created($"/api/tags/{created.Id}", created);
})
.WithSummary("Create a tag. Tags are also created on demand when applied by name.");
@@ -28,12 +28,12 @@ public static class TagEndpoints
.AddEndpointFilter<ValidationEndpointFilter>();
tags.MapGet("/{id:guid}/references", async (Guid id, TagService service, CancellationToken ct) =>
(await service.GetReferencesAsync(id, ct)).ToApiResult())
(await service.GetReferencesAsync(id, ct))?.ToReferencesResponse().ToApiResult())
.WithSummary("Cross-reference: every character, chapter and beat carrying this tag.");
tags.MapPatch("/{id:guid}", async (
Guid id, UpdateTagRequest request, TagService service, CancellationToken ct) =>
(await service.UpdateAsync(id, request, ct)).ToApiResult())
(await service.UpdateAsync(id, request, ct))?.ToResponse().ToApiResult())
.WithSummary("Rename or recolour a tag.");
tags.MapDelete("/{id:guid}", async (Guid id, TagService service, CancellationToken ct) =>
+8 -30
View File
@@ -12,7 +12,7 @@ public class TagService(
IModelValidator<CreateTagRequest> createValidator,
IModelValidator<UpdateTagRequest> updateValidator)
{
public async Task<IReadOnlyList<TagSummaryDto>> ListAsync(Guid projectId, CancellationToken ct = default)
public async Task<IReadOnlyList<TagSummaryResponse>> ListAsync(Guid projectId, CancellationToken ct = default)
{
Guard.Default(projectId, nameof(projectId));
@@ -21,14 +21,14 @@ public class TagService(
return await db.Tags
.Where(t => t.ProjectId == projectId)
.OrderBy(t => t.Name)
.Select(t => new TagSummaryDto(
.Select(t => new TagSummaryResponse(
t.Id, t.Name, t.Color,
t.Characters.Count, t.Chapters.Count, t.Beats.Count))
.ToListAsync(ct);
}
/// <summary>Everything in the project carrying this tag. Null when no tag has this id.</summary>
public async Task<TagReferencesDto?> GetReferencesAsync(Guid tagId, CancellationToken ct = default)
public async Task<Tag?> GetReferencesAsync(Guid tagId, CancellationToken ct = default)
{
Guard.Default(tagId, nameof(tagId));
@@ -42,34 +42,12 @@ public class TagService(
.FirstOrDefaultAsync(t => t.Id == tagId, ct);
if (tag is null)
{
logger.LogInformation("Tag {TagId} not found", tagId);
return null;
}
return new TagReferencesDto(
tag.ToDto(),
[.. tag.Characters
.OrderBy(c => c.Name)
.Select(c => new TaggedCharacterDto(c.Id, c.Name, c.Role.ToString()))],
[.. tag.Chapters
.OrderBy(c => c.Number)
.Select(c => new TaggedChapterDto(c.Id, c.Number, c.Title, c.Summary))],
[.. tag.Beats
.OrderBy(b => b.Chapter?.Number ?? 0)
.ThenBy(b => b.SortOrder)
.Select(b => new TaggedBeatDto(
b.Id,
b.ChapterId,
b.Chapter?.Number ?? 0,
b.Chapter?.Title ?? "(unknown chapter)",
b.SortOrder,
b.Title,
b.Character?.Name,
b.WhatHappened))]);
return tag;
}
public async Task<TagDto> CreateAsync(Guid projectId, CreateTagRequest request, CancellationToken ct = default)
public async Task<Tag> CreateAsync(Guid projectId, CreateTagRequest request, CancellationToken ct = default)
{
Guard.Default(projectId, nameof(projectId));
Guard.Null(request, nameof(request));
@@ -95,10 +73,10 @@ public class TagService(
var tag = new Tag { ProjectId = projectId, Name = name, Color = request.Color };
db.Tags.Add(tag);
await db.SaveChangesAsync(ct);
return tag.ToDto();
return tag;
}
public async Task<TagDto?> UpdateAsync(Guid tagId, UpdateTagRequest request, CancellationToken ct = default)
public async Task<Tag?> UpdateAsync(Guid tagId, UpdateTagRequest request, CancellationToken ct = default)
{
Guard.Default(tagId, nameof(tagId));
Guard.Null(request, nameof(request));
@@ -129,7 +107,7 @@ public class TagService(
tag.Color = Patch.Apply(tag.Color, request.Color);
await db.SaveChangesAsync(ct);
return tag.ToDto();
return tag;
}
/// <summary>Deletes a tag. Whatever carried it keeps existing — only the label goes. True if deleted.</summary>