Reorganise by feature, rename to Novelly, add Aspire and a pre-push hook
The layered split into Domain/Application/Infrastructure/Api was forcing organisation by layer: adding one capability meant touching four projects and four folders that each held a slice of it. Those four projects are now one feature-organised Novelly.Api, where each folder — Projects, Characters, Chapters, Beats, Scenes, Tags, Agent — holds its entity, DTOs, service and endpoints together. Common/ holds what genuinely crosses features (the patch semantics, the two exception types, DraftStatus) and Data/ holds the DbContext and migrations. Six .NET projects become five: the three layer projects are gone, and Novelly.AppHost and Novelly.ServiceDefaults are new. - Namespaces move from NovelSoftware.* to Novelly.*, including the entity type names recorded in the EF model snapshots. The migration ids are untouched, so an existing novel.db still migrates cleanly — verified against a fresh file. - Aspire orchestration mirrors the mic-check setup: the AppHost starts the API on :5080 and the Vite dev server on :5173, and the API picks up OpenTelemetry, health checks and service discovery from ServiceDefaults. /health and /alive now answer in development. - A Husky pre-push hook runs scripts/ci/prepush.sh: build, test, then a web build. The scripts are plain bash so CI can run the same steps. - The MCP server's env var is now NOVELLY_API_URL. Verified beyond the build: 44 tests pass, the web client builds, the API was exercised over curl (project/chapter/beat/tag round trip, tag cross-reference, 503 on the agent without a key while conversation listing still returns 200), the MCP server was driven over stdio JSON-RPC (26 tools, errors still surface the API's own message rather than being flattened), and the AppHost was run to confirm both resources come up and Vite proxies /api through to the API. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S56bfZMGe1hnhpWP4CjjNw
This commit is contained in:
co-authored by
Claude Opus 5
parent
30e0c6926e
commit
725758ccd9
@@ -0,0 +1,265 @@
|
||||
using System.Text.Json;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Data;
|
||||
using Novelly.Api.Projects;
|
||||
|
||||
namespace Novelly.Api.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// The embedded writing agent. Runs the tool-use loop against the model, persists the
|
||||
/// conversation, and returns the finished turn together with a record of what it changed.
|
||||
/// </summary>
|
||||
public class NovelAgentService(
|
||||
INovelDbContext db,
|
||||
IAgentModelClient model,
|
||||
NovelAgentToolset toolset,
|
||||
IOptions<AgentOptions> options,
|
||||
ILogger<NovelAgentService> logger)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
private readonly AgentOptions _options = options.Value;
|
||||
|
||||
public async Task<IReadOnlyList<ConversationSummaryDto>> ListConversationsAsync(
|
||||
Guid projectId, CancellationToken ct = default) =>
|
||||
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))
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task<ConversationDto> GetConversationAsync(Guid conversationId, CancellationToken ct = default)
|
||||
{
|
||||
var conversation = await LoadConversationAsync(conversationId, ct);
|
||||
|
||||
return new ConversationDto(
|
||||
conversation.Id,
|
||||
conversation.ProjectId,
|
||||
conversation.Title,
|
||||
[.. conversation.Messages.OrderBy(m => m.Sequence).Select(ToDto)],
|
||||
conversation.UpdatedAt);
|
||||
}
|
||||
|
||||
public async Task DeleteConversationAsync(Guid conversationId, CancellationToken ct = default)
|
||||
{
|
||||
var conversation = await LoadConversationAsync(conversationId, ct);
|
||||
db.Conversations.Remove(conversation);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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(
|
||||
Guid projectId, SendAgentMessageRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var conversation = request.ConversationId is { } id
|
||||
? await LoadConversationAsync(id, ct)
|
||||
: await StartConversationAsync(projectId, request.Message, ct);
|
||||
|
||||
// Persist the user's turn before running the loop. The tools save through the
|
||||
// same DbContext, so leaving this pending would entangle it with their writes —
|
||||
// and recording the question even if the model call fails is the behaviour we want.
|
||||
await AppendMessageAsync(conversation, AgentRole.User, request.Message, null, ct);
|
||||
|
||||
var systemPrompt = await BuildSystemPromptAsync(projectId, ct);
|
||||
var transcript = BuildTranscript(conversation);
|
||||
var toolCalls = new List<ToolCallDto>();
|
||||
var text = new StringBuilder();
|
||||
|
||||
for (var iteration = 0; iteration < _options.MaxIterations; iteration++)
|
||||
{
|
||||
var response = await model.CompleteAsync(systemPrompt, transcript, toolset.Definitions, ct);
|
||||
|
||||
foreach (var block in response.Content.OfType<AgentTextBlock>())
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(block.Text))
|
||||
{
|
||||
text.AppendLine(block.Text.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
var requestedTools = response.Content.OfType<AgentToolUseBlock>().ToList();
|
||||
if (requestedTools.Count == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
// Echo the assistant's turn back verbatim, then answer every tool_use block in a
|
||||
// single user turn — splitting the results would train the model out of
|
||||
// requesting tools in parallel.
|
||||
transcript.Add(AgentChatMessage.Assistant(response.Content));
|
||||
|
||||
var results = new List<AgentContentBlock>();
|
||||
foreach (var call in requestedTools)
|
||||
{
|
||||
var outcome = await toolset.ExecuteAsync(call.Name, projectId, call.Input, ct);
|
||||
|
||||
logger.LogInformation(
|
||||
"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));
|
||||
results.Add(new AgentToolResultBlock(call.Id, outcome.Content, outcome.IsError));
|
||||
}
|
||||
|
||||
transcript.Add(AgentChatMessage.User([.. results]));
|
||||
|
||||
if (iteration == _options.MaxIterations - 1)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Agent hit the {Max}-iteration ceiling on project {ProjectId}",
|
||||
_options.MaxIterations, projectId);
|
||||
|
||||
text.AppendLine(
|
||||
"_I reached my tool-call limit for this turn. Ask me to continue if there's more to do._");
|
||||
}
|
||||
}
|
||||
|
||||
var reply = await AppendMessageAsync(
|
||||
conversation,
|
||||
AgentRole.Assistant,
|
||||
text.ToString().TrimEnd(),
|
||||
toolCalls.Count > 0 ? JsonSerializer.Serialize(toolCalls, JsonOptions) : null,
|
||||
ct);
|
||||
|
||||
return new AgentTurnDto(conversation.Id, ToDto(reply));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends a turn and commits it. Messages are added to the set directly rather than
|
||||
/// through the parent's collection so their insert never depends on EF discovering
|
||||
/// the graph change at an inconvenient moment.
|
||||
/// </summary>
|
||||
private async Task<AgentMessage> AppendMessageAsync(
|
||||
AgentConversation conversation, AgentRole role, string content, string? toolCallsJson, CancellationToken ct)
|
||||
{
|
||||
var message = new AgentMessage
|
||||
{
|
||||
ConversationId = conversation.Id,
|
||||
Role = role,
|
||||
Sequence = conversation.Messages.Count == 0 ? 0 : conversation.Messages.Max(m => m.Sequence) + 1,
|
||||
Content = content,
|
||||
ToolCallsJson = toolCallsJson
|
||||
};
|
||||
|
||||
db.AgentMessages.Add(message);
|
||||
conversation.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
// EF's relationship fixup normally puts the message into the parent's collection
|
||||
// once both are tracked. Guard rather than assume, since the sequence number of
|
||||
// the next turn is derived from it.
|
||||
if (!conversation.Messages.Contains(message))
|
||||
{
|
||||
conversation.Messages.Add(message);
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
private async Task<AgentConversation> StartConversationAsync(
|
||||
Guid projectId, string firstMessage, CancellationToken ct)
|
||||
{
|
||||
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
|
||||
{
|
||||
throw new NotFoundException(nameof(Project), projectId);
|
||||
}
|
||||
|
||||
var conversation = new AgentConversation
|
||||
{
|
||||
ProjectId = projectId,
|
||||
Title = Summarise(firstMessage)
|
||||
};
|
||||
|
||||
db.Conversations.Add(conversation);
|
||||
return conversation;
|
||||
}
|
||||
|
||||
private async Task<AgentConversation> LoadConversationAsync(Guid conversationId, CancellationToken ct) =>
|
||||
await db.Conversations
|
||||
.Include(c => c.Messages)
|
||||
.FirstOrDefaultAsync(c => c.Id == conversationId, ct)
|
||||
?? throw new NotFoundException(nameof(AgentConversation), conversationId);
|
||||
|
||||
/// <summary>
|
||||
/// Replays the stored conversation as plain text turns. Tool calls are not replayed —
|
||||
/// the agent re-reads current state through its tools, which is more reliable than
|
||||
/// trusting a transcript of edits that may since have been changed in the UI.
|
||||
/// </summary>
|
||||
private static List<AgentChatMessage> BuildTranscript(AgentConversation conversation) =>
|
||||
[
|
||||
.. conversation.Messages
|
||||
.Where(m => !string.IsNullOrWhiteSpace(m.Content))
|
||||
.OrderBy(m => m.Sequence)
|
||||
.Select(m => new AgentChatMessage(
|
||||
m.Role == AgentRole.User ? "user" : "assistant",
|
||||
[new AgentTextBlock(m.Content)]))
|
||||
];
|
||||
|
||||
private async Task<string> BuildSystemPromptAsync(Guid projectId, CancellationToken ct)
|
||||
{
|
||||
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == projectId, ct)
|
||||
?? throw new NotFoundException(nameof(Project), projectId);
|
||||
|
||||
var brief = new StringBuilder();
|
||||
brief.AppendLine($"Title: {project.Title}");
|
||||
if (!string.IsNullOrWhiteSpace(project.Genre)) brief.AppendLine($"Genre: {project.Genre}");
|
||||
if (!string.IsNullOrWhiteSpace(project.Logline)) brief.AppendLine($"Logline: {project.Logline}");
|
||||
if (project.TargetWordCount is { } target) brief.AppendLine($"Target length: {target:N0} words");
|
||||
|
||||
return $"""
|
||||
You are a developmental editor and writing partner embedded in the software the
|
||||
writer is using to plan their novel. You have tools that read and write the
|
||||
project's real data: the brief, character dossiers, the outline tree, chapters
|
||||
and scenes.
|
||||
|
||||
The project you are working on:
|
||||
{brief}
|
||||
Working principles:
|
||||
|
||||
- Read before you write. Call get_project_brief, get_outline, or list_characters
|
||||
to ground yourself rather than assuming what is already there.
|
||||
- The book is the writer's. Ask about the choices that define the story — what a
|
||||
character wants, what the ending costs them — instead of deciding for them.
|
||||
- Do not invent biographical detail to fill an empty field. An unanswered
|
||||
question in a dossier is more useful than a plausible-sounding fabrication.
|
||||
- When you do have enough to act, act. Make the edit and say what you changed in
|
||||
a sentence; do not narrate every tool call or ask permission for routine work.
|
||||
- Prefer structural help — where a beat lands, whether a want and a need are
|
||||
genuinely in tension, what the outline is missing — over line-level polish,
|
||||
unless the writer asks for prose.
|
||||
- When drafting prose into a scene, match the voice already established in the
|
||||
project. Write the scene, then stop; do not append notes about your choices.
|
||||
- Destructive operations (deleting outline nodes) need the writer's explicit
|
||||
go-ahead first.
|
||||
|
||||
Keep replies short. Lead with the outcome, then the reasoning if it earns its place.
|
||||
""";
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
var trimmed = message.Trim().ReplaceLineEndings(" ");
|
||||
return trimmed.Length <= 60 ? trimmed : string.Concat(trimmed.AsSpan(0, 57), "...");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user