Introduces accounts (ASP.NET Identity + cookie auth), four global roles (Admin/Writer/Editor/Reviewer), per-novel ownership and grants via ProjectMember, and a service-API-key principal for the MCP server and background import jobs. Enforcement lives in the application services (not endpoint filters) so the embedded agent and MCP tools, which call the same services directly, can't bypass it. Web client gets a login page, session-aware routing, and a People section for managing per-novel access. Also includes prior in-flight changes from this branch (CLAUDE.md compliance pass, dev-deploy docker-compose setup) that were uncommitted when this feature work started.
256 lines
10 KiB
C#
256 lines
10 KiB
C#
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Options;
|
|
using Novelly.Api.Common;
|
|
using Novelly.Api.Common.Validation;
|
|
using Novelly.Api.Data;
|
|
using Novelly.Api.Projects;
|
|
|
|
namespace Novelly.Api.Agent;
|
|
|
|
public class NovelAgentService(
|
|
INovelDbContext db,
|
|
IAgentModelClient model,
|
|
NovelAgentToolset toolset,
|
|
IOptions<AgentOptions> options,
|
|
ILogger<NovelAgentService> logger,
|
|
IModelValidator<SendAgentMessageRequest> sendMessageValidator)
|
|
{
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
Converters = { new JsonStringEnumConverter() }
|
|
};
|
|
|
|
private readonly AgentOptions _options = options.Value;
|
|
|
|
public async Task<IReadOnlyList<ConversationSummaryResponse>> ListConversationsAsync(
|
|
Guid projectId, CancellationToken ct = default)
|
|
{
|
|
logger.LogInformation("Listing agent conversations for project {ProjectId}", projectId);
|
|
|
|
return await db.Conversations
|
|
.Where(c => c.ProjectId == projectId)
|
|
.OrderByDescending(c => c.UpdatedAt)
|
|
.Select(c => new ConversationSummaryResponse(c.Id, c.ProjectId, c.Title, c.Messages.Count, c.UpdatedAt))
|
|
.ToListAsync(ct);
|
|
}
|
|
|
|
public async Task<AgentConversation?> GetConversationAsync(Guid conversationId, CancellationToken ct = default)
|
|
{
|
|
Guard.Default(conversationId, nameof(conversationId));
|
|
|
|
logger.LogInformation("Getting agent conversation {ConversationId}", conversationId);
|
|
|
|
return await FindConversationAsync(conversationId, ct);
|
|
}
|
|
|
|
public async Task<bool> DeleteConversationAsync(Guid conversationId, CancellationToken ct = default)
|
|
{
|
|
Guard.Default(conversationId, nameof(conversationId));
|
|
|
|
logger.LogInformation("Deleting agent conversation {ConversationId}", conversationId);
|
|
|
|
var conversation = await FindConversationAsync(conversationId, ct);
|
|
if (conversation is null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
db.Conversations.Remove(conversation);
|
|
await db.SaveChangesAsync(ct);
|
|
return true;
|
|
}
|
|
|
|
public async Task<AgentMessage?> SendMessageAsync(Guid projectId, SendAgentMessageRequest request, CancellationToken ct = default)
|
|
{
|
|
Guard.Default(projectId, nameof(projectId));
|
|
Guard.Null(request, nameof(request));
|
|
sendMessageValidator.Validate(request).ThrowIfInvalid(logger);
|
|
|
|
logger.LogInformation(
|
|
"Sending agent message for project {ProjectId}, conversation {ConversationId}, message length {MessageLength}",
|
|
projectId, request.ConversationId, request.Message.Length);
|
|
|
|
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == projectId, ct);
|
|
if (project is null)
|
|
{
|
|
logger.LogWarning("Project {ProjectId} not found", projectId);
|
|
return null;
|
|
}
|
|
|
|
var conversation = request.ConversationId is { } id
|
|
? await FindConversationAsync(id, ct)
|
|
: StartConversation(projectId, request.Message);
|
|
|
|
if (conversation is null) return null;
|
|
|
|
await AppendMessageAsync(conversation, AgentRole.User, request.Message, null, ct);
|
|
|
|
var systemPrompt = BuildSystemPrompt(project);
|
|
var transcript = BuildTranscript(conversation);
|
|
var toolCalls = new List<ToolCallResponse>();
|
|
var text = new StringBuilder();
|
|
|
|
for (var iteration = 0; iteration < _options.MaxIterations; iteration++)
|
|
{
|
|
logger.LogDebug("Agent iteration {Iteration} for project {ProjectId}", iteration, projectId);
|
|
|
|
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;
|
|
|
|
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.Log(outcome.IsError ? LogLevel.Warning : LogLevel.Information, "Agent tool {Tool} on project {ProjectId} {Outcome}", call.Name, projectId, outcome.IsError ? "failed" : "succeeded");
|
|
|
|
toolCalls.Add(new ToolCallResponse(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) continue;
|
|
|
|
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 reply;
|
|
}
|
|
|
|
private async Task<AgentMessage> AppendMessageAsync(AgentConversation conversation, AgentRole role, string content, string? toolCallsJson, CancellationToken ct)
|
|
{
|
|
logger.LogDebug("Appending {Role} message to conversation {ConversationId}, content length {ContentLength}", role, conversation.Id, content.Length);
|
|
|
|
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);
|
|
|
|
if (!conversation.Messages.Contains(message))
|
|
conversation.Messages.Add(message);
|
|
|
|
logger.LogDebug("Appended {Role} message {MessageId} to conversation {ConversationId}", role, message.Id, conversation.Id);
|
|
return message;
|
|
}
|
|
|
|
private AgentConversation StartConversation(Guid projectId, string firstMessage)
|
|
{
|
|
logger.LogDebug("Starting new agent conversation for project {ProjectId}", projectId);
|
|
|
|
var conversation = new AgentConversation
|
|
{
|
|
ProjectId = projectId,
|
|
Title = Summarise(firstMessage)
|
|
};
|
|
|
|
db.Conversations.Add(conversation);
|
|
|
|
logger.LogDebug("Started agent conversation {ConversationId} for project {ProjectId}", conversation.Id, projectId);
|
|
return conversation;
|
|
}
|
|
|
|
private async Task<AgentConversation?> FindConversationAsync(Guid conversationId, CancellationToken ct)
|
|
{
|
|
logger.LogDebug("Finding agent conversation {ConversationId}", conversationId);
|
|
|
|
var conversation = await db.Conversations
|
|
.Include(c => c.Messages)
|
|
.FirstOrDefaultAsync(c => c.Id == conversationId, ct);
|
|
|
|
if (conversation is null)
|
|
{
|
|
logger.LogWarning("AgentConversation {ConversationId} not found", conversationId);
|
|
return conversation;
|
|
}
|
|
|
|
logger.LogDebug("Found agent conversation {ConversationId}", conversationId);
|
|
return conversation;
|
|
}
|
|
|
|
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 static string BuildSystemPrompt(Project project)
|
|
{
|
|
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 (beats) and each
|
|
chapter's drafted prose.
|
|
|
|
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 a chapter's prose, match the voice already established in the
|
|
project. Write the chapter, 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 string Summarise(string message)
|
|
{
|
|
var trimmed = message.Trim().ReplaceLineEndings(" ");
|
|
return trimmed.Length <= 60 ? trimmed : string.Concat(trimmed.AsSpan(0, 57), "...");
|
|
}
|
|
}
|