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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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));
|
||||
|
||||
+12
-12
@@ -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())]);
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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));
|
||||
|
||||
+11
-11
@@ -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) =>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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><sourceRoot>/.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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
+3
-3
@@ -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);
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
-2
@@ -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,
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user