Add Serilog console logging across the API

Information at endpoint and service-method boundaries, Debug in deeper
helpers, Warning before expected/recoverable failures (not-found,
validation, agent tool errors), Error on caught exceptions. Serilog
wraps the exception handler so request-completion logs report the
resolved status code rather than the raw exception. Never logs prose
bodies or the Anthropic API key.
This commit is contained in:
James Wampler
2026-08-06 12:11:20 -07:00
parent 4f396bb5f9
commit 04917fa09e
34 changed files with 790 additions and 147 deletions
+44 -34
View File
@@ -1,51 +1,61 @@
# CLAUDE.md # CLAUDE.md
Guidance for Claude Code (claude.ai/code) in this repo. Guidance for Claude Code (claude.ai/code) in repo.
## Project ## Project
Novelly: software for planning and writing a novel. ASP.NET Core 10, C#, TypeScript, React, .NET Aspire. Chapter outlines, character dossiers, prose drafting, an embedded Claude agent, and an MCP server over the same API. Novelly: software plan + write novel. ASP.NET Core 10, C#, TypeScript, React, .NET Aspire. Chapter outlines, character dossiers, prose drafting, embedded Claude agent, MCP server over same API.
## Structure ## Structure
- `src/Novelly.Api/` the whole back end, organised by feature. One folder per feature holds its - `src/Novelly.Api/` — whole back end, organised by feature. One folder per feature holds
entity, DTOs, service and endpoints together: `Projects/`, `Characters/`, `Chapters/`, `Beats/`, entity, DTOs, service, endpoints together: `Projects/`, `Characters/`, `Chapters/`, `Beats/`,
`Scenes/`, `Tags/`, `Agent/`. `Common/` holds what genuinely crosses features; `Data/` holds the `Scenes/`, `Tags/`, `Agent/`. `Common/` holds what crosses features; `Data/` holds
`DbContext` and EF migrations. `DbContext` + EF migrations.
- `src/Novelly.AppHost/` — .NET Aspire orchestration; run this to bring up the API and the web client - `src/Novelly.AppHost/` — .NET Aspire orchestration; run this to bring up API + web client
- `src/Novelly.ServiceDefaults/` — shared Aspire wiring: OpenTelemetry, health checks, service discovery - `src/Novelly.ServiceDefaults/` — shared Aspire wiring: OpenTelemetry, health checks, service discovery
- `src/Novelly.Mcp/` — MCP stdio server - `src/Novelly.Mcp/` — MCP stdio server
- `src/Novelly.Web/` — React + Vite client - `src/Novelly.Web/` — React + Vite client
- `tests/` — test suite - `tests/` — test suite
- `docs/` — documentation - `docs/` — documentation
- `scripts/ci/` — bash CI steps; `prepush.sh` is what the Husky pre-push hook runs - `scripts/ci/` — bash CI steps; `prepush.sh` = what Husky pre-push hook runs
## Best Practices ## Best Practices
- Use latest .NET + latest supported nuget packages for that version - Use latest .NET + latest supported nuget packages for that version
- Set `langVersion` to latest in all csproj files; enable nullable - Set `langVersion` latest in all csproj files; enable nullable
- Organize code by feature/area, not layer or type. A new capability adds files to one feature folder - Organize code by feature/area, not layer or type. New capability adds files to one feature folder
rather than a row to each of an entity/DTO/service/endpoint folder rather than row to each of entity/DTO/service/endpoint folder
- New features need unit tests covering logic as much as possible - New features need unit tests covering logic much as possible
- Modified file: check missing test coverage, all tests pass - Modified file: check missing test coverage, all tests pass
## Architecture ## Architecture
- The React client, the embedded agent, and the MCP server all go through the same REST API. One source of truth — never let a client reach past the API to the database. - React client, embedded agent, MCP server all go through same REST API. One source of truth — never let client reach past API to database.
- Agent tools and MCP tools call the same application services the endpoints do. New capability = new service method, then surface it in all three. - Agent tools + MCP tools call same application services endpoints do. New capability = new service method, then surface in all three.
- Domain has no dependencies. Application depends on Domain. Infrastructure depends on Application. Nothing depends on Api.
## Logging
Serilog console via `AddSerilog` (not `UseSerilog` — keeps OTel provider for Aspire dashboard). `app.UseSerilogRequestLogging()` registered before `UseExceptionHandler` (outermost), else it logs raw exception status instead of handled one.
- Endpoints: Information via `RequestLoggingEndpointFilter` on each `MapGroup`. No per-lambda logging.
- Service public methods: Information at entry, ids/enums/counts as args.
- Deeper/private methods: Debug at start + end.
- Caught exceptions: `LogError(ex, ...)` with identifying values.
- Expected/recoverable (NotFound, validation, agent tool errors): Warning before throw/return.
- Structured templates only — `{ChapterId}`, never interpolation.
- Never log prose (summary/notes/content) or `ANTHROPIC_API_KEY`. Prose = length only.
# Coding # Coding
- Descriptive names all classes/methods. No generic: Provider, Manager, Helper - Descriptive names all classes/methods. No generic: Provider, Manager, Helper
- Match formatting/style from `.editorconfig` - Match formatting/style from `.editorconfig`
- Wrap lines at 220 chars, single line if fewer - Wrap lines at 220 chars, single line if fewer
- Interfaces implemented by single class → bottom of class file. Interface w/ multiple implementations → separate file. - Interface implemented by single class → bottom of class file. Interface w/ multiple implementations → separate file.
- No tuples for return types. Prefer records or classes for multiple values - No tuples for return types. Prefer records or classes for multiple values
- Use `record` for data objects, `class` for objects with behavior. Avoid mutable state where possible. - Use `record` for data objects, `class` for objects with behavior. Avoid mutable state where possible.
- DTOs are records; entities are classes - DTOs are records; entities are classes. DO NOT use Dto in names.
- `PATCH` requests are partial: null field = leave alone, empty string = clear. Keep new update endpoints consistent with `Patch.Apply`. - `PATCH` requests partial: null field = leave alone, empty string = clear. Keep new update endpoints consistent with `Patch.Apply`.
- Enums cross the wire as names, never ordinals - Enums cross wire as names, never ordinals
## Testing ## Testing
@@ -53,28 +63,28 @@ Novelly: software for planning and writing a novel. ASP.NET Core 10, C#, TypeScr
- Don't write tests just for coverage. Call out missing coverage rather than cover stuff not valuable to end user. - Don't write tests just for coverage. Call out missing coverage rather than cover stuff not valuable to end user.
- Code not cleanly unit-testable → mark `[ExcludeFromCodeCoverage]` or exclude namespace from coverage in .runsettings file - Code not cleanly unit-testable → mark `[ExcludeFromCodeCoverage]` or exclude namespace from coverage in .runsettings file
- BDD-style unit tests, end-to-end as possible. e.g. `Deleting_a_scene_leaves_its_beats_alone` - BDD-style unit tests, end-to-end as possible. e.g. `Deleting_a_scene_leaves_its_beats_alone`
- NUnit. No FluentAssertions — assert with `Assert.That` and NUnit's constraint model: - NUnit. No FluentAssertions — assert with `Assert.That` + NUnit's constraint model:
`Assert.That(beat.SceneId, Is.Null)`, `Assert.That(listed, Has.Count.EqualTo(3))`, `Assert.That(beat.SceneId, Is.Null)`, `Assert.That(listed, Has.Count.EqualTo(3))`,
`Assert.That(titles, Is.EqualTo(new[] { "First", "Second" }))` `Assert.That(titles, Is.EqualTo(new[] { "First", "Second" }))`
- Grouping related asserts in `Assert.Multiple` beats a chain that stops at the first failure - Grouping related asserts in `Assert.Multiple` beats chain that stops at first failure
- Expected exceptions: `Assert.That(() => service.Foo(), Throws.TypeOf<NotFoundException>())` - Expected exceptions: `Assert.That(() => service.Foo(), Throws.TypeOf<NotFoundException>())`
- Tests run against real in-memory SQLite via `TestDatabase`, not the EF InMemory provider — cascade deletes and query translation must be exercised, and the InMemory provider fakes both - Tests run against real in-memory SQLite via `TestDatabase`, not EF InMemory provider — cascade deletes + query translation must be exercised, InMemory provider fakes both
- Model calls are faked at the `IAgentModelClient` seam (see `ScriptedModelClient`). Never hit the Anthropic API from a test. - Model calls faked at `IAgentModelClient` seam (see `ScriptedModelClient`). Never hit Anthropic API from test.
- No "Mock" in mocked object names - No "Mock" in mocked object names
- No Arrange/Act/Assert comments - No Arrange/Act/Assert comments
- All tests pass before commit - All tests pass before commit
## Verifying ## Verifying
Build and tests passing is not the same as working. For anything touching an endpoint, the agent loop, or the MCP server, run it: Build + tests passing working. Anything touching endpoint, agent loop, or MCP server run it:
- Everything at once: `dotnet run --project src/Novelly.AppHost` — Aspire starts the API on :5080 and - Everything at once: `dotnet run --project src/Novelly.AppHost` — Aspire starts API on :5080 +
the Vite dev server on :5173, with the dashboard for logs and traces Vite dev server on :5173, dashboard for logs + traces
- API alone: `ASPNETCORE_URLS=http://localhost:5080 dotnet run --project src/Novelly.Api`, then exercise the route with curl - API alone: `ASPNETCORE_URLS=http://localhost:5080 dotnet run --project src/Novelly.Api`, then exercise route with curl
- Web alone: `cd src/Novelly.Web && npm run dev` — proxies `/api` to :5080 - Web alone: `cd src/Novelly.Web && npm run dev` — proxies `/api` to :5080
- MCP: build it, then drive it over stdio JSON-RPC (`initialize``notifications/initialized``tools/list``tools/call`) - MCP: build it, then drive over stdio JSON-RPC (`initialize``notifications/initialized``tools/list``tools/call`)
Several real bugs here — SQLite refusing to ORDER BY a DateTimeOffset, the agent's model client throwing at construction and taking read-only endpoints down with it — passed the build and the test suite and only showed up when the app actually ran. Several real bugs here — SQLite refusing ORDER BY DateTimeOffset, agent's model client throwing at construction + taking read-only endpoints down with it — passed build + test suite, only showed up when app actually ran.
## Claude ## Claude
@@ -87,8 +97,8 @@ Several real bugs here — SQLite refusing to ORDER BY a DateTimeOffset, the age
`.gitignore` set for .NET/Visual Studio (C#, NuGet, MSBuild) plus Node. Update if stack change. `.gitignore` set for .NET/Visual Studio (C#, NuGet, MSBuild) plus Node. Update if stack change.
- Anthropic model id lives in `appsettings.json` under `Agent:Model`. Don't hardcode it. - Anthropic model id lives in `appsettings.json` under `Agent:Model`. Don't hardcode.
- API key comes from `ANTHROPIC_API_KEY` or `Agent:ApiKey` — never commit one. The app must stay fully usable without a key; only the agent endpoints require it. - API key comes from `ANTHROPIC_API_KEY` or `Agent:ApiKey` — never commit one. App must stay fully usable without key; only agent endpoints require it.
- EF migrations: `dotnet ef migrations add <Name> -p src/Novelly.Api -o Data/Migrations`. The API migrates on boot. - EF migrations: `dotnet ef migrations add <Name> -p src/Novelly.Api -o Data/Migrations`. API migrates on boot.
- `git push` runs `scripts/ci/prepush.sh` through Husky: build, test, then a web build. Run `npm install` - `git push` runs `scripts/ci/prepush.sh` through Husky: build, test, then web build. Run `npm install`
once at the repo root to install the hook. once at repo root to install hook.
+106
View File
@@ -0,0 +1,106 @@
# CLAUDE.md
Guidance for Claude Code (claude.ai/code) in this repo.
## Project
Novelly: software for planning and writing a novel. ASP.NET Core 10, C#, TypeScript, React, .NET Aspire. Chapter outlines, character dossiers, prose drafting, an embedded Claude agent, and an MCP server over the same API.
## Structure
- `src/Novelly.Api/` — the whole back end, organised by feature. One folder per feature holds its
entity, DTOs, service and endpoints together: `Projects/`, `Characters/`, `Chapters/`, `Beats/`,
`Scenes/`, `Tags/`, `Agent/`. `Common/` holds what genuinely crosses features; `Data/` holds the
`DbContext` and EF migrations.
- `src/Novelly.AppHost/` — .NET Aspire orchestration; run this to bring up the API and the web client
- `src/Novelly.ServiceDefaults/` — shared Aspire wiring: OpenTelemetry, health checks, service discovery
- `src/Novelly.Mcp/` — MCP stdio server
- `src/Novelly.Web/` — React + Vite client
- `tests/` — test suite
- `docs/` — documentation
- `scripts/ci/` — bash CI steps; `prepush.sh` is what the Husky pre-push hook runs
## Best Practices
- Use latest .NET + latest supported nuget packages for that version
- Set `langVersion` to latest in all csproj files; enable nullable
- Organize code by feature/area, not layer or type. A new capability adds files to one feature folder
rather than a row to each of an entity/DTO/service/endpoint folder
- New features need unit tests covering logic as much as possible
- Modified file: check missing test coverage, all tests pass
## Architecture
- The React client, the embedded agent, and the MCP server all go through the same REST API. One source of truth — never let a client reach past the API to the database.
- Agent tools and MCP tools call the same application services the endpoints do. New capability = new service method, then surface it in all three.
- Domain has no dependencies. Application depends on Domain. Infrastructure depends on Application. Nothing depends on Api.
## Logging
Logging goes through Serilog, writing to the console, registered with `AddSerilog` rather than `UseSerilog` — this makes it an additional logging provider alongside the OpenTelemetry one that `AddServiceDefaults` wires up, instead of replacing it, so the Aspire dashboard keeps seeing structured logs. `app.UseSerilogRequestLogging()` must be registered before `app.UseExceptionHandler(...)` so it wraps the exception handler as the outermost middleware; otherwise it observes the raw exception as it propagates past it and logs the request as a 500 even when the exception handler goes on to turn it into a handled 404.
- Endpoints get their Information-level logging from a single `RequestLoggingEndpointFilter`, applied once per `MapGroup` call. No individual endpoint lambda needs to log anything itself.
- Every public service method logs an Information entry naming the operation and its identifying arguments — ids, enums, counts.
- Deeper, private helper methods log at Debug on entry and again before they return, including whatever state drove the decision.
- Anywhere an exception is caught, log it at Error with `LogError(ex, ...)`, including enough identifying values (ids, not prose) to debug from.
- Expected or recoverable failures — a not-found lookup, a validation rejection, an agent tool call that fails and is reported back to the model instead of thrown — log at Warning immediately before the throw or the recovery, not at Error.
- Always use structured message templates with named holes, like `{ChapterId}`, rather than string interpolation, so Serilog captures them as properties.
- Never log prose bodies (summary, notes, scene prose, agent message content) or the `ANTHROPIC_API_KEY`. Where a prose field is worth acknowledging in a log, log its length instead of its content.
# Coding
- Descriptive names all classes/methods. No generic: Provider, Manager, Helper
- Match formatting/style from `.editorconfig`
- Wrap lines at 220 chars, single line if fewer
- Interfaces implemented by single class → bottom of class file. Interface w/ multiple implementations → separate file.
- No tuples for return types. Prefer records or classes for multiple values
- Use `record` for data objects, `class` for objects with behavior. Avoid mutable state where possible.
- DTOs are records; entities are classes
- `PATCH` requests are partial: null field = leave alone, empty string = clear. Keep new update endpoints consistent with `Patch.Apply`.
- Enums cross the wire as names, never ordinals
## Testing
- Min 70% code coverage, target 90%. Unit tests focus end-user scenarios first.
- Don't write tests just for coverage. Call out missing coverage rather than cover stuff not valuable to end user.
- Code not cleanly unit-testable → mark `[ExcludeFromCodeCoverage]` or exclude namespace from coverage in .runsettings file
- BDD-style unit tests, end-to-end as possible. e.g. `Deleting_a_scene_leaves_its_beats_alone`
- NUnit. No FluentAssertions — assert with `Assert.That` and NUnit's constraint model:
`Assert.That(beat.SceneId, Is.Null)`, `Assert.That(listed, Has.Count.EqualTo(3))`,
`Assert.That(titles, Is.EqualTo(new[] { "First", "Second" }))`
- Grouping related asserts in `Assert.Multiple` beats a chain that stops at the first failure
- Expected exceptions: `Assert.That(() => service.Foo(), Throws.TypeOf<NotFoundException>())`
- Tests run against real in-memory SQLite via `TestDatabase`, not the EF InMemory provider — cascade deletes and query translation must be exercised, and the InMemory provider fakes both
- Model calls are faked at the `IAgentModelClient` seam (see `ScriptedModelClient`). Never hit the Anthropic API from a test.
- No "Mock" in mocked object names
- No Arrange/Act/Assert comments
- All tests pass before commit
## Verifying
Build and tests passing is not the same as working. For anything touching an endpoint, the agent loop, or the MCP server, run it:
- Everything at once: `dotnet run --project src/Novelly.AppHost` — Aspire starts the API on :5080 and
the Vite dev server on :5173, with the dashboard for logs and traces
- API alone: `ASPNETCORE_URLS=http://localhost:5080 dotnet run --project src/Novelly.Api`, then exercise the route with curl
- Web alone: `cd src/Novelly.Web && npm run dev` — proxies `/api` to :5080
- MCP: build it, then drive it over stdio JSON-RPC (`initialize``notifications/initialized``tools/list``tools/call`)
Several real bugs here — SQLite refusing to ORDER BY a DateTimeOffset, the agent's model client throwing at construction and taking read-only endpoints down with it — passed the build and the test suite and only showed up when the app actually ran.
## Claude
- Plans = `.md` files in `docs/plans/`. Web → `docs/plans/web/`, API → `docs/plans/api/`
- Split large plans into discrete chunks — each buildable + committable independently
- Plan generated from `docs/plans/<name>.md` → save as `docs/plans/<name>_plan.md`
- Plan implemented from `docs/plans/<name>.md` → save summary as `docs/plans/<name>_output.md`
## Stack
`.gitignore` set for .NET/Visual Studio (C#, NuGet, MSBuild) plus Node. Update if stack change.
- Anthropic model id lives in `appsettings.json` under `Agent:Model`. Don't hardcode it.
- API key comes from `ANTHROPIC_API_KEY` or `Agent:ApiKey` — never commit one. The app must stay fully usable without a key; only the agent endpoints require it.
- EF migrations: `dotnet ef migrations add <Name> -p src/Novelly.Api -o Data/Migrations`. The API migrates on boot.
- `git push` runs `scripts/ci/prepush.sh` through Husky: build, test, then a web build. Run `npm install`
once at the repo root to install the hook.
+4 -2
View File
@@ -1,10 +1,12 @@
using Novelly.Api.Common;
namespace Novelly.Api.Agent; namespace Novelly.Api.Agent;
public static class AgentEndpoints public static class AgentEndpoints
{ {
public static IEndpointRouteBuilder MapAgentEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapAgentEndpoints(this IEndpointRouteBuilder app)
{ {
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/agent").WithTags("Agent"); var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/agent").WithTags("Agent").AddEndpointFilter<RequestLoggingEndpointFilter>();
projectScoped.MapGet("/conversations", async ( projectScoped.MapGet("/conversations", async (
Guid projectId, NovelAgentService agent, CancellationToken ct) => Guid projectId, NovelAgentService agent, CancellationToken ct) =>
@@ -19,7 +21,7 @@ public static class AgentEndpoints
Results.Ok(await agent.SendMessageAsync(projectId, request, ct))) Results.Ok(await agent.SendMessageAsync(projectId, request, ct)))
.WithSummary("Send a message to the writing agent and run it to completion."); .WithSummary("Send a message to the writing agent and run it to completion.");
var conversations = app.MapGroup("/api/conversations").WithTags("Agent"); var conversations = app.MapGroup("/api/conversations").WithTags("Agent").AddEndpointFilter<RequestLoggingEndpointFilter>();
conversations.MapGet("/{id:guid}", async (Guid id, NovelAgentService agent, CancellationToken ct) => conversations.MapGet("/{id:guid}", async (Guid id, NovelAgentService agent, CancellationToken ct) =>
Results.Ok(await agent.GetConversationAsync(id, ct))) Results.Ok(await agent.GetConversationAsync(id, ct)))
@@ -11,7 +11,7 @@ namespace Novelly.Api.Agent;
/// model-agnostic block types and the SDK's request/response shapes; the tool-use loop /// model-agnostic block types and the SDK's request/response shapes; the tool-use loop
/// itself lives in <see cref="NovelAgentService"/>. /// itself lives in <see cref="NovelAgentService"/>.
/// </summary> /// </summary>
public class AnthropicAgentModelClient(IOptions<AgentOptions> options) : IAgentModelClient public class AnthropicAgentModelClient(IOptions<AgentOptions> options, ILogger<AnthropicAgentModelClient> logger) : IAgentModelClient
{ {
private readonly AgentOptions _options = options.Value; private readonly AgentOptions _options = options.Value;
private AnthropicClient? _client; private AnthropicClient? _client;
@@ -36,6 +36,10 @@ public class AnthropicAgentModelClient(IOptions<AgentOptions> options) : IAgentM
IReadOnlyList<AgentToolDefinition> tools, IReadOnlyList<AgentToolDefinition> tools,
CancellationToken ct = default) CancellationToken ct = default)
{ {
logger.LogInformation(
"Calling model {Model} with {MessageCount} messages, {ToolCount} tools, effort {Effort}",
_options.Model, messages.Count, tools.Count, _options.Effort);
var parameters = new MessageCreateParams var parameters = new MessageCreateParams
{ {
Model = _options.Model, Model = _options.Model,
@@ -53,6 +57,10 @@ public class AnthropicAgentModelClient(IOptions<AgentOptions> options) : IAgentM
var response = await Client.Messages.Create(parameters, cancellationToken: ct); var response = await Client.Messages.Create(parameters, cancellationToken: ct);
logger.LogInformation(
"Model {Model} responded with stop reason {StopReason}, input tokens {InputTokens}, output tokens {OutputTokens}",
_options.Model, response.StopReason, response.Usage?.InputTokens, response.Usage?.OutputTokens);
return new AgentModelResponse( return new AgentModelResponse(
[.. response.Content.Select(FromSdkBlock).OfType<AgentContentBlock>()], [.. response.Content.Select(FromSdkBlock).OfType<AgentContentBlock>()],
response.StopReason?.ToString()); response.StopReason?.ToString());
+44 -8
View File
@@ -28,15 +28,21 @@ public class NovelAgentService(
private readonly AgentOptions _options = options.Value; private readonly AgentOptions _options = options.Value;
public async Task<IReadOnlyList<ConversationSummaryDto>> ListConversationsAsync( public async Task<IReadOnlyList<ConversationSummaryDto>> ListConversationsAsync(
Guid projectId, CancellationToken ct = default) => Guid projectId, CancellationToken ct = default)
await db.Conversations {
logger.LogInformation("Listing agent conversations for project {ProjectId}", projectId);
return await db.Conversations
.Where(c => c.ProjectId == projectId) .Where(c => c.ProjectId == projectId)
.OrderByDescending(c => c.UpdatedAt) .OrderByDescending(c => c.UpdatedAt)
.Select(c => new ConversationSummaryDto(c.Id, c.ProjectId, c.Title, c.Messages.Count, c.UpdatedAt)) .Select(c => new ConversationSummaryDto(c.Id, c.ProjectId, c.Title, c.Messages.Count, c.UpdatedAt))
.ToListAsync(ct); .ToListAsync(ct);
}
public async Task<ConversationDto> GetConversationAsync(Guid conversationId, CancellationToken ct = default) public async Task<ConversationDto> GetConversationAsync(Guid conversationId, CancellationToken ct = default)
{ {
logger.LogInformation("Getting agent conversation {ConversationId}", conversationId);
var conversation = await LoadConversationAsync(conversationId, ct); var conversation = await LoadConversationAsync(conversationId, ct);
return new ConversationDto( return new ConversationDto(
@@ -49,6 +55,8 @@ public class NovelAgentService(
public async Task DeleteConversationAsync(Guid conversationId, CancellationToken ct = default) public async Task DeleteConversationAsync(Guid conversationId, CancellationToken ct = default)
{ {
logger.LogInformation("Deleting agent conversation {ConversationId}", conversationId);
var conversation = await LoadConversationAsync(conversationId, ct); var conversation = await LoadConversationAsync(conversationId, ct);
db.Conversations.Remove(conversation); db.Conversations.Remove(conversation);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
@@ -61,6 +69,10 @@ public class NovelAgentService(
public async Task<AgentTurnDto> SendMessageAsync( public async Task<AgentTurnDto> SendMessageAsync(
Guid projectId, SendAgentMessageRequest request, CancellationToken ct = default) Guid projectId, SendAgentMessageRequest request, CancellationToken ct = default)
{ {
logger.LogInformation(
"Sending agent message for project {ProjectId}, conversation {ConversationId}, message length {MessageLength}",
projectId, request.ConversationId, request.Message.Length);
var conversation = request.ConversationId is { } id var conversation = request.ConversationId is { } id
? await LoadConversationAsync(id, ct) ? await LoadConversationAsync(id, ct)
: await StartConversationAsync(projectId, request.Message, ct); : await StartConversationAsync(projectId, request.Message, ct);
@@ -77,6 +89,8 @@ public class NovelAgentService(
for (var iteration = 0; iteration < _options.MaxIterations; iteration++) 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); var response = await model.CompleteAsync(systemPrompt, transcript, toolset.Definitions, ct);
foreach (var block in response.Content.OfType<AgentTextBlock>()) foreach (var block in response.Content.OfType<AgentTextBlock>())
@@ -142,6 +156,8 @@ public class NovelAgentService(
private async Task<AgentMessage> AppendMessageAsync( private async Task<AgentMessage> AppendMessageAsync(
AgentConversation conversation, AgentRole role, string content, string? toolCallsJson, CancellationToken ct) 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 var message = new AgentMessage
{ {
ConversationId = conversation.Id, ConversationId = conversation.Id,
@@ -170,8 +186,11 @@ public class NovelAgentService(
private async Task<AgentConversation> StartConversationAsync( private async Task<AgentConversation> StartConversationAsync(
Guid projectId, string firstMessage, CancellationToken ct) Guid projectId, string firstMessage, CancellationToken ct)
{ {
logger.LogDebug("Starting new agent conversation for project {ProjectId}", projectId);
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct)) if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
{ {
logger.LogWarning("Project {ProjectId} not found", projectId);
throw new NotFoundException(nameof(Project), projectId); throw new NotFoundException(nameof(Project), projectId);
} }
@@ -185,11 +204,22 @@ public class NovelAgentService(
return conversation; return conversation;
} }
private async Task<AgentConversation> LoadConversationAsync(Guid conversationId, CancellationToken ct) => private async Task<AgentConversation> LoadConversationAsync(Guid conversationId, CancellationToken ct)
await db.Conversations {
logger.LogDebug("Loading agent conversation {ConversationId}", conversationId);
var conversation = await db.Conversations
.Include(c => c.Messages) .Include(c => c.Messages)
.FirstOrDefaultAsync(c => c.Id == conversationId, ct) .FirstOrDefaultAsync(c => c.Id == conversationId, ct);
?? throw new NotFoundException(nameof(AgentConversation), conversationId);
if (conversation is null)
{
logger.LogWarning("AgentConversation {ConversationId} not found", conversationId);
throw new NotFoundException(nameof(AgentConversation), conversationId);
}
return conversation;
}
/// <summary> /// <summary>
/// Replays the stored conversation as plain text turns. Tool calls are not replayed — /// Replays the stored conversation as plain text turns. Tool calls are not replayed —
@@ -208,8 +238,14 @@ public class NovelAgentService(
private async Task<string> BuildSystemPromptAsync(Guid projectId, CancellationToken ct) private async Task<string> BuildSystemPromptAsync(Guid projectId, CancellationToken ct)
{ {
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == projectId, ct) logger.LogDebug("Building system prompt for project {ProjectId}", projectId);
?? throw new NotFoundException(nameof(Project), projectId);
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == projectId, ct);
if (project is null)
{
logger.LogWarning("Project {ProjectId} not found", projectId);
throw new NotFoundException(nameof(Project), projectId);
}
var brief = new StringBuilder(); var brief = new StringBuilder();
brief.AppendLine($"Title: {project.Title}"); brief.AppendLine($"Title: {project.Title}");
+9 -1
View File
@@ -33,7 +33,8 @@ public class NovelAgentToolset(
BeatService beats, BeatService beats,
SceneService scenes, SceneService scenes,
TagService tags, TagService tags,
OpenQuestionService questions) OpenQuestionService questions,
ILogger<NovelAgentToolset> logger)
{ {
private static readonly JsonSerializerOptions SerializerOptions = new() private static readonly JsonSerializerOptions SerializerOptions = new()
{ {
@@ -57,24 +58,31 @@ public class NovelAgentToolset(
{ {
if (!ByName.TryGetValue(name, out var tool)) if (!ByName.TryGetValue(name, out var tool))
{ {
logger.LogWarning("Agent requested unknown tool {Tool}", name);
return new AgentToolResult($"No such tool: '{name}'.", true); return new AgentToolResult($"No such tool: '{name}'.", true);
} }
logger.LogDebug("Running tool {Tool} for project {ProjectId}", name, projectId);
try try
{ {
var result = await tool.Handler(projectId, input, ct); var result = await tool.Handler(projectId, input, ct);
logger.LogDebug("Tool {Tool} for project {ProjectId} succeeded", name, projectId);
return new AgentToolResult(JsonSerializer.Serialize(result, SerializerOptions), false); return new AgentToolResult(JsonSerializer.Serialize(result, SerializerOptions), false);
} }
catch (NotFoundException ex) catch (NotFoundException ex)
{ {
logger.LogWarning(ex, "Tool {Tool} for project {ProjectId} failed: not found", name, projectId);
return new AgentToolResult(ex.Message, true); return new AgentToolResult(ex.Message, true);
} }
catch (ArgumentException ex) catch (ArgumentException ex)
{ {
logger.LogWarning(ex, "Tool {Tool} for project {ProjectId} failed: invalid argument", name, projectId);
return new AgentToolResult(ex.Message, true); return new AgentToolResult(ex.Message, true);
} }
catch (InvalidOperationException ex) catch (InvalidOperationException ex)
{ {
logger.LogWarning(ex, "Tool {Tool} for project {ProjectId} failed: invalid operation", name, projectId);
return new AgentToolResult(ex.Message, true); return new AgentToolResult(ex.Message, true);
} }
} }
+4 -2
View File
@@ -1,10 +1,12 @@
using Novelly.Api.Common;
namespace Novelly.Api.Beats; namespace Novelly.Api.Beats;
public static class BeatEndpoints public static class BeatEndpoints
{ {
public static IEndpointRouteBuilder MapBeatEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapBeatEndpoints(this IEndpointRouteBuilder app)
{ {
var chapterScoped = app.MapGroup("/api/chapters/{chapterId:guid}/beats").WithTags("Beats"); var chapterScoped = app.MapGroup("/api/chapters/{chapterId:guid}/beats").WithTags("Beats").AddEndpointFilter<RequestLoggingEndpointFilter>();
chapterScoped.MapGet("/", async (Guid chapterId, BeatService service, CancellationToken ct) => chapterScoped.MapGet("/", async (Guid chapterId, BeatService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(chapterId, ct))) Results.Ok(await service.ListAsync(chapterId, ct)))
@@ -29,7 +31,7 @@ public static class BeatEndpoints
.WithTags("Beats") .WithTags("Beats")
.WithSummary("Every beat this character appears in, in manuscript order."); .WithSummary("Every beat this character appears in, in manuscript order.");
var beats = app.MapGroup("/api/beats").WithTags("Beats"); var beats = app.MapGroup("/api/beats").WithTags("Beats").AddEndpointFilter<RequestLoggingEndpointFilter>();
beats.MapGet("/{id:guid}", async (Guid id, BeatService service, CancellationToken ct) => beats.MapGet("/{id:guid}", async (Guid id, BeatService service, CancellationToken ct) =>
Results.Ok(await service.GetAsync(id, ct))) Results.Ok(await service.GetAsync(id, ct)))
+52 -10
View File
@@ -11,10 +11,12 @@ namespace Novelly.Api.Beats;
/// Beats are a chapter's outline: a flat, ordered table rather than a tree. Everything /// Beats are a chapter's outline: a flat, ordered table rather than a tree. Everything
/// here is scoped to one chapter. /// here is scoped to one chapter.
/// </summary> /// </summary>
public class BeatService(INovelDbContext db, TagService tags) public class BeatService(INovelDbContext db, TagService tags, ILogger<BeatService> logger)
{ {
public async Task<IReadOnlyList<BeatDto>> ListAsync(Guid chapterId, CancellationToken ct = default) public async Task<IReadOnlyList<BeatDto>> ListAsync(Guid chapterId, CancellationToken ct = default)
{ {
logger.LogInformation("Listing beats for chapter {ChapterId}", chapterId);
var beats = await Query() var beats = await Query()
.Where(b => b.ChapterId == chapterId) .Where(b => b.ChapterId == chapterId)
.OrderBy(b => b.SortOrder) .OrderBy(b => b.SortOrder)
@@ -23,8 +25,11 @@ public class BeatService(INovelDbContext db, TagService tags)
return [.. beats.Select(b => b.ToDto())]; return [.. beats.Select(b => b.ToDto())];
} }
public async Task<BeatDto> GetAsync(Guid id, CancellationToken ct = default) => public async Task<BeatDto> GetAsync(Guid id, CancellationToken ct = default)
(await FindAsync(id, ct)).ToDto(); {
logger.LogInformation("Getting beat {BeatId}", id);
return (await FindAsync(id, ct)).ToDto();
}
/// <summary> /// <summary>
/// Every beat this character appears in, in manuscript order. This is the character /// Every beat this character appears in, in manuscript order. This is the character
@@ -34,8 +39,11 @@ public class BeatService(INovelDbContext db, TagService tags)
public async Task<IReadOnlyList<CharacterBeatDto>> ListForCharacterAsync( public async Task<IReadOnlyList<CharacterBeatDto>> ListForCharacterAsync(
Guid characterId, CancellationToken ct = default) Guid characterId, CancellationToken ct = default)
{ {
logger.LogInformation("Listing beats for character {CharacterId}", characterId);
if (!await db.Characters.AnyAsync(c => c.Id == characterId, ct)) if (!await db.Characters.AnyAsync(c => c.Id == characterId, ct))
{ {
logger.LogWarning("Character {CharacterId} not found", characterId);
throw new NotFoundException(nameof(Character), characterId); throw new NotFoundException(nameof(Character), characterId);
} }
@@ -66,8 +74,14 @@ public class BeatService(INovelDbContext db, TagService tags)
public async Task<BeatDto> CreateAsync(Guid chapterId, CreateBeatRequest request, CancellationToken ct = default) public async Task<BeatDto> CreateAsync(Guid chapterId, CreateBeatRequest request, CancellationToken ct = default)
{ {
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == chapterId, ct) logger.LogInformation("Creating beat {Title} for chapter {ChapterId}", request.Title, chapterId);
?? throw new NotFoundException(nameof(Chapter), chapterId);
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == chapterId, ct);
if (chapter is null)
{
logger.LogWarning("Chapter {ChapterId} not found", chapterId);
throw new NotFoundException(nameof(Chapter), chapterId);
}
await ValidateReferencesAsync(chapter, request.CharacterId, request.SceneId, ct); await ValidateReferencesAsync(chapter, request.CharacterId, request.SceneId, ct);
@@ -94,9 +108,15 @@ public class BeatService(INovelDbContext db, TagService tags)
public async Task<BeatDto> UpdateAsync(Guid id, UpdateBeatRequest request, CancellationToken ct = default) public async Task<BeatDto> UpdateAsync(Guid id, UpdateBeatRequest request, CancellationToken ct = default)
{ {
logger.LogInformation("Updating beat {BeatId}", id);
var beat = await FindAsync(id, ct); var beat = await FindAsync(id, ct);
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == beat.ChapterId, ct) var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == beat.ChapterId, ct);
?? throw new NotFoundException(nameof(Chapter), beat.ChapterId); if (chapter is null)
{
logger.LogWarning("Chapter {ChapterId} not found", beat.ChapterId);
throw new NotFoundException(nameof(Chapter), beat.ChapterId);
}
await ValidateReferencesAsync(chapter, request.CharacterId, request.SceneId, ct); await ValidateReferencesAsync(chapter, request.CharacterId, request.SceneId, ct);
@@ -119,6 +139,8 @@ public class BeatService(INovelDbContext db, TagService tags)
public async Task DeleteAsync(Guid id, CancellationToken ct = default) public async Task DeleteAsync(Guid id, CancellationToken ct = default)
{ {
logger.LogInformation("Deleting beat {BeatId}", id);
var beat = await FindAsync(id, ct); var beat = await FindAsync(id, ct);
db.Beats.Remove(beat); db.Beats.Remove(beat);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
@@ -131,11 +153,14 @@ public class BeatService(INovelDbContext db, TagService tags)
public async Task<IReadOnlyList<BeatDto>> ReorderAsync( public async Task<IReadOnlyList<BeatDto>> ReorderAsync(
Guid chapterId, ReorderBeatsRequest request, CancellationToken ct = default) Guid chapterId, ReorderBeatsRequest request, CancellationToken ct = default)
{ {
logger.LogInformation("Reordering {Count} beats for chapter {ChapterId}", request.BeatIds.Count, chapterId);
var beats = await db.Beats.Where(b => b.ChapterId == chapterId).ToListAsync(ct); var beats = await db.Beats.Where(b => b.ChapterId == chapterId).ToListAsync(ct);
var missing = request.BeatIds.Where(id => beats.All(b => b.Id != id)).ToList(); var missing = request.BeatIds.Where(id => beats.All(b => b.Id != id)).ToList();
if (missing.Count > 0) if (missing.Count > 0)
{ {
logger.LogWarning("Reorder for chapter {ChapterId} referenced missing beat {BeatId}", chapterId, missing[0]);
throw new NotFoundException(nameof(Beat), missing[0]); throw new NotFoundException(nameof(Beat), missing[0]);
} }
@@ -159,6 +184,8 @@ public class BeatService(INovelDbContext db, TagService tags)
private async Task ValidateReferencesAsync( private async Task ValidateReferencesAsync(
Chapter chapter, Guid? characterId, Guid? sceneId, CancellationToken ct) Chapter chapter, Guid? characterId, Guid? sceneId, CancellationToken ct)
{ {
logger.LogDebug("Validating beat references for chapter {ChapterId}: character {CharacterId}, scene {SceneId}", chapter.Id, characterId, sceneId);
if (characterId is { } cid) if (characterId is { } cid)
{ {
var belongs = await db.Characters var belongs = await db.Characters
@@ -166,6 +193,7 @@ public class BeatService(INovelDbContext db, TagService tags)
if (!belongs) if (!belongs)
{ {
logger.LogWarning("Rejected beat reference: character {CharacterId} does not belong to project {ProjectId}", cid, chapter.ProjectId);
throw new InvalidOperationException( throw new InvalidOperationException(
"A beat's character must belong to the same project as its chapter."); "A beat's character must belong to the same project as its chapter.");
} }
@@ -177,6 +205,7 @@ public class BeatService(INovelDbContext db, TagService tags)
if (!belongs) if (!belongs)
{ {
logger.LogWarning("Rejected beat reference: scene {SceneId} does not belong to chapter {ChapterId}", sid, chapter.Id);
throw new InvalidOperationException( throw new InvalidOperationException(
"A beat can only be grouped under a scene in the same chapter."); "A beat can only be grouped under a scene in the same chapter.");
} }
@@ -185,6 +214,8 @@ public class BeatService(INovelDbContext db, TagService tags)
private async Task<int> NextSortOrderAsync(Guid chapterId, CancellationToken ct) private async Task<int> NextSortOrderAsync(Guid chapterId, CancellationToken ct)
{ {
logger.LogDebug("Computing next sort order for chapter {ChapterId}", chapterId);
var max = await db.Beats var max = await db.Beats
.Where(b => b.ChapterId == chapterId) .Where(b => b.ChapterId == chapterId)
.MaxAsync(b => (int?)b.SortOrder, ct); .MaxAsync(b => (int?)b.SortOrder, ct);
@@ -198,7 +229,18 @@ public class BeatService(INovelDbContext db, TagService tags)
.Include(b => b.Scene) .Include(b => b.Scene)
.Include(b => b.Tags); .Include(b => b.Tags);
private async Task<Beat> FindAsync(Guid id, CancellationToken ct) => private async Task<Beat> FindAsync(Guid id, CancellationToken ct)
await Query().FirstOrDefaultAsync(b => b.Id == id, ct) {
?? throw new NotFoundException(nameof(Beat), id); logger.LogDebug("Finding beat {BeatId}", id);
var beat = await Query().FirstOrDefaultAsync(b => b.Id == id, ct);
if (beat is null)
{
logger.LogWarning("Beat {BeatId} not found", id);
throw new NotFoundException(nameof(Beat), id);
}
logger.LogDebug("Found beat {BeatId}", id);
return beat;
}
} }
+4 -2
View File
@@ -1,10 +1,12 @@
using Novelly.Api.Common;
namespace Novelly.Api.Chapters; namespace Novelly.Api.Chapters;
public static class ChapterEndpoints public static class ChapterEndpoints
{ {
public static IEndpointRouteBuilder MapChapterEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapChapterEndpoints(this IEndpointRouteBuilder app)
{ {
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/chapters").WithTags("Chapters"); var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/chapters").WithTags("Chapters").AddEndpointFilter<RequestLoggingEndpointFilter>();
projectScoped.MapGet("/", async (Guid projectId, ChapterService service, CancellationToken ct) => projectScoped.MapGet("/", async (Guid projectId, ChapterService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(projectId, ct))) Results.Ok(await service.ListAsync(projectId, ct)))
@@ -18,7 +20,7 @@ public static class ChapterEndpoints
}) })
.WithSummary("Add a chapter."); .WithSummary("Add a chapter.");
var chapters = app.MapGroup("/api/chapters").WithTags("Chapters"); var chapters = app.MapGroup("/api/chapters").WithTags("Chapters").AddEndpointFilter<RequestLoggingEndpointFilter>();
chapters.MapGet("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) => chapters.MapGet("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) =>
Results.Ok(await service.GetAsync(id, ct))) Results.Ok(await service.GetAsync(id, ct)))
+36 -8
View File
@@ -6,10 +6,12 @@ using Novelly.Api.Tags;
namespace Novelly.Api.Chapters; namespace Novelly.Api.Chapters;
public class ChapterService(INovelDbContext db, TagService tags) public class ChapterService(INovelDbContext db, TagService tags, ILogger<ChapterService> logger)
{ {
public async Task<IReadOnlyList<ChapterSummaryDto>> ListAsync(Guid projectId, CancellationToken ct = default) public async Task<IReadOnlyList<ChapterSummaryDto>> ListAsync(Guid projectId, CancellationToken ct = default)
{ {
logger.LogInformation("Listing chapters for project {ProjectId}", projectId);
var chapters = await db.Chapters var chapters = await db.Chapters
.Include(c => c.PovCharacter) .Include(c => c.PovCharacter)
.Include(c => c.Beats) .Include(c => c.Beats)
@@ -22,13 +24,19 @@ public class ChapterService(INovelDbContext db, TagService tags)
return [.. chapters.Select(c => c.ToSummaryDto())]; return [.. chapters.Select(c => c.ToSummaryDto())];
} }
public async Task<ChapterDto> GetAsync(Guid id, CancellationToken ct = default) => public async Task<ChapterDto> GetAsync(Guid id, CancellationToken ct = default)
(await FindAsync(id, ct)).ToDto(); {
logger.LogInformation("Getting chapter {ChapterId}", id);
return (await FindAsync(id, ct)).ToDto();
}
public async Task<ChapterDto> CreateAsync(Guid projectId, CreateChapterRequest request, CancellationToken ct = default) public async Task<ChapterDto> CreateAsync(Guid projectId, CreateChapterRequest request, CancellationToken ct = default)
{ {
logger.LogInformation("Creating chapter {Title} for project {ProjectId}", request.Title, projectId);
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct)) if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
{ {
logger.LogWarning("Project {ProjectId} not found", projectId);
throw new NotFoundException(nameof(Project), projectId); throw new NotFoundException(nameof(Project), projectId);
} }
@@ -57,6 +65,8 @@ public class ChapterService(INovelDbContext db, TagService tags)
public async Task<ChapterDto> UpdateAsync(Guid id, UpdateChapterRequest request, CancellationToken ct = default) public async Task<ChapterDto> UpdateAsync(Guid id, UpdateChapterRequest request, CancellationToken ct = default)
{ {
logger.LogInformation("Updating chapter {ChapterId}", id);
var chapter = await FindAsync(id, ct); var chapter = await FindAsync(id, ct);
chapter.Title = Patch.Apply(chapter.Title, request.Title) ?? chapter.Title; chapter.Title = Patch.Apply(chapter.Title, request.Title) ?? chapter.Title;
@@ -80,6 +90,8 @@ public class ChapterService(INovelDbContext db, TagService tags)
public async Task DeleteAsync(Guid id, CancellationToken ct = default) public async Task DeleteAsync(Guid id, CancellationToken ct = default)
{ {
logger.LogInformation("Deleting chapter {ChapterId}", id);
var chapter = await FindAsync(id, ct); var chapter = await FindAsync(id, ct);
db.Chapters.Remove(chapter); db.Chapters.Remove(chapter);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
@@ -87,21 +99,37 @@ public class ChapterService(INovelDbContext db, TagService tags)
private async Task<int> NextChapterNumberAsync(Guid projectId, CancellationToken ct) private async Task<int> NextChapterNumberAsync(Guid projectId, CancellationToken ct)
{ {
logger.LogDebug("Computing next chapter number for project {ProjectId}", projectId);
var max = await db.Chapters var max = await db.Chapters
.Where(c => c.ProjectId == projectId) .Where(c => c.ProjectId == projectId)
.MaxAsync(c => (int?)c.Number, ct); .MaxAsync(c => (int?)c.Number, ct);
return (max ?? 0) + 1; var next = (max ?? 0) + 1;
logger.LogDebug("Next chapter number for project {ProjectId} is {Number}", projectId, next);
return next;
} }
private async Task<Chapter> FindAsync(Guid id, CancellationToken ct) => private async Task<Chapter> FindAsync(Guid id, CancellationToken ct)
await db.Chapters {
logger.LogDebug("Finding chapter {ChapterId}", id);
var chapter = await db.Chapters
.Include(c => c.PovCharacter) .Include(c => c.PovCharacter)
.Include(c => c.Beats).ThenInclude(b => b.Character) .Include(c => c.Beats).ThenInclude(b => b.Character)
.Include(c => c.Beats).ThenInclude(b => b.Scene) .Include(c => c.Beats).ThenInclude(b => b.Scene)
.Include(c => c.Beats).ThenInclude(b => b.Tags) .Include(c => c.Beats).ThenInclude(b => b.Tags)
.Include(c => c.Scenes).ThenInclude(s => s.PovCharacter) .Include(c => c.Scenes).ThenInclude(s => s.PovCharacter)
.Include(c => c.Tags) .Include(c => c.Tags)
.FirstOrDefaultAsync(c => c.Id == id, ct) .FirstOrDefaultAsync(c => c.Id == id, ct);
?? throw new NotFoundException(nameof(Chapter), id);
if (chapter is null)
{
logger.LogWarning("Chapter {ChapterId} not found", id);
throw new NotFoundException(nameof(Chapter), id);
}
logger.LogDebug("Found chapter {ChapterId}", id);
return chapter;
}
} }
@@ -13,10 +13,12 @@ namespace Novelly.Api.Characters;
/// on a supporting character. Demoting someone should not delete work, and a character /// on a supporting character. Demoting someone should not delete work, and a character
/// who turns out to matter gets promoted after the arc is already sketched. /// who turns out to matter gets promoted after the arc is already sketched.
/// </remarks> /// </remarks>
public class CharacterArcService(INovelDbContext db) public class CharacterArcService(INovelDbContext db, ILogger<CharacterArcService> logger)
{ {
public async Task<IReadOnlyList<ArcStageDto>> ListAsync(Guid characterId, CancellationToken ct = default) public async Task<IReadOnlyList<ArcStageDto>> ListAsync(Guid characterId, CancellationToken ct = default)
{ {
logger.LogInformation("Listing arc stages for character {CharacterId}", characterId);
var stages = await Query() var stages = await Query()
.Where(s => s.CharacterId == characterId) .Where(s => s.CharacterId == characterId)
.OrderBy(s => s.SortOrder) .OrderBy(s => s.SortOrder)
@@ -25,14 +27,23 @@ public class CharacterArcService(INovelDbContext db)
return [.. stages.Select(s => s.ToDto())]; return [.. stages.Select(s => s.ToDto())];
} }
public async Task<ArcStageDto> GetAsync(Guid id, CancellationToken ct = default) => public async Task<ArcStageDto> GetAsync(Guid id, CancellationToken ct = default)
(await FindAsync(id, ct)).ToDto(); {
logger.LogInformation("Getting arc stage {ArcStageId}", id);
return (await FindAsync(id, ct)).ToDto();
}
public async Task<ArcStageDto> CreateAsync( public async Task<ArcStageDto> CreateAsync(
Guid characterId, CreateArcStageRequest request, CancellationToken ct = default) Guid characterId, CreateArcStageRequest request, CancellationToken ct = default)
{ {
var character = await db.Characters.FirstOrDefaultAsync(c => c.Id == characterId, ct) logger.LogInformation("Creating arc stage {Title} for character {CharacterId}", request.Title, characterId);
?? throw new NotFoundException(nameof(Character), characterId);
var character = await db.Characters.FirstOrDefaultAsync(c => c.Id == characterId, ct);
if (character is null)
{
logger.LogWarning("Character {CharacterId} not found", characterId);
throw new NotFoundException(nameof(Character), characterId);
}
await EnsureChapterIsInSameProjectAsync(character, request.ChapterId, ct); await EnsureChapterIsInSameProjectAsync(character, request.ChapterId, ct);
@@ -53,10 +64,16 @@ public class CharacterArcService(INovelDbContext db)
public async Task<ArcStageDto> UpdateAsync( public async Task<ArcStageDto> UpdateAsync(
Guid id, UpdateArcStageRequest request, CancellationToken ct = default) Guid id, UpdateArcStageRequest request, CancellationToken ct = default)
{ {
logger.LogInformation("Updating arc stage {ArcStageId}", id);
var stage = await FindAsync(id, ct); var stage = await FindAsync(id, ct);
var character = await db.Characters.FirstOrDefaultAsync(c => c.Id == stage.CharacterId, ct) var character = await db.Characters.FirstOrDefaultAsync(c => c.Id == stage.CharacterId, ct);
?? throw new NotFoundException(nameof(Character), stage.CharacterId); if (character is null)
{
logger.LogWarning("Character {CharacterId} not found", stage.CharacterId);
throw new NotFoundException(nameof(Character), stage.CharacterId);
}
await EnsureChapterIsInSameProjectAsync(character, request.ChapterId, ct); await EnsureChapterIsInSameProjectAsync(character, request.ChapterId, ct);
@@ -72,6 +89,8 @@ public class CharacterArcService(INovelDbContext db)
public async Task DeleteAsync(Guid id, CancellationToken ct = default) public async Task DeleteAsync(Guid id, CancellationToken ct = default)
{ {
logger.LogInformation("Deleting arc stage {ArcStageId}", id);
var stage = await FindAsync(id, ct); var stage = await FindAsync(id, ct);
db.CharacterArcStages.Remove(stage); db.CharacterArcStages.Remove(stage);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
@@ -84,6 +103,8 @@ public class CharacterArcService(INovelDbContext db)
public async Task<IReadOnlyList<ArcStageDto>> ReorderAsync( public async Task<IReadOnlyList<ArcStageDto>> ReorderAsync(
Guid characterId, ReorderArcStagesRequest request, CancellationToken ct = default) Guid characterId, ReorderArcStagesRequest request, CancellationToken ct = default)
{ {
logger.LogInformation("Reordering {Count} arc stages for character {CharacterId}", request.StageIds.Count, characterId);
var stages = await db.CharacterArcStages var stages = await db.CharacterArcStages
.Where(s => s.CharacterId == characterId) .Where(s => s.CharacterId == characterId)
.ToListAsync(ct); .ToListAsync(ct);
@@ -91,6 +112,7 @@ public class CharacterArcService(INovelDbContext db)
var missing = request.StageIds.Where(id => stages.All(s => s.Id != id)).ToList(); var missing = request.StageIds.Where(id => stages.All(s => s.Id != id)).ToList();
if (missing.Count > 0) if (missing.Count > 0)
{ {
logger.LogWarning("Reorder for character {CharacterId} referenced missing arc stage {ArcStageId}", characterId, missing[0]);
throw new NotFoundException(nameof(CharacterArcStage), missing[0]); throw new NotFoundException(nameof(CharacterArcStage), missing[0]);
} }
@@ -117,10 +139,13 @@ public class CharacterArcService(INovelDbContext db)
return; return;
} }
logger.LogDebug("Checking chapter {ChapterId} belongs to project {ProjectId}", id, character.ProjectId);
var belongs = await db.Chapters.AnyAsync(c => c.Id == id && c.ProjectId == character.ProjectId, ct); var belongs = await db.Chapters.AnyAsync(c => c.Id == id && c.ProjectId == character.ProjectId, ct);
if (!belongs) if (!belongs)
{ {
logger.LogWarning("Rejected arc stage: chapter {ChapterId} does not belong to project {ProjectId}", id, character.ProjectId);
throw new InvalidOperationException( throw new InvalidOperationException(
"An arc stage can only point at a chapter in the same project as its character."); "An arc stage can only point at a chapter in the same project as its character.");
} }
@@ -128,6 +153,8 @@ public class CharacterArcService(INovelDbContext db)
private async Task<int> NextSortOrderAsync(Guid characterId, CancellationToken ct) private async Task<int> NextSortOrderAsync(Guid characterId, CancellationToken ct)
{ {
logger.LogDebug("Computing next sort order for character {CharacterId}", characterId);
var max = await db.CharacterArcStages var max = await db.CharacterArcStages
.Where(s => s.CharacterId == characterId) .Where(s => s.CharacterId == characterId)
.MaxAsync(s => (int?)s.SortOrder, ct); .MaxAsync(s => (int?)s.SortOrder, ct);
@@ -137,7 +164,18 @@ public class CharacterArcService(INovelDbContext db)
private IQueryable<CharacterArcStage> Query() => db.CharacterArcStages.Include(s => s.Chapter); private IQueryable<CharacterArcStage> Query() => db.CharacterArcStages.Include(s => s.Chapter);
private async Task<CharacterArcStage> FindAsync(Guid id, CancellationToken ct) => private async Task<CharacterArcStage> FindAsync(Guid id, CancellationToken ct)
await Query().FirstOrDefaultAsync(s => s.Id == id, ct) {
?? throw new NotFoundException(nameof(CharacterArcStage), id); logger.LogDebug("Finding arc stage {ArcStageId}", id);
var stage = await Query().FirstOrDefaultAsync(s => s.Id == id, ct);
if (stage is null)
{
logger.LogWarning("CharacterArcStage {ArcStageId} not found", id);
throw new NotFoundException(nameof(CharacterArcStage), id);
}
logger.LogDebug("Found arc stage {ArcStageId}", id);
return stage;
}
} }
@@ -1,10 +1,12 @@
using Novelly.Api.Common;
namespace Novelly.Api.Characters; namespace Novelly.Api.Characters;
public static class CharacterEndpoints public static class CharacterEndpoints
{ {
public static IEndpointRouteBuilder MapCharacterEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapCharacterEndpoints(this IEndpointRouteBuilder app)
{ {
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/characters").WithTags("Characters"); var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/characters").WithTags("Characters").AddEndpointFilter<RequestLoggingEndpointFilter>();
projectScoped.MapGet("/", async (Guid projectId, CharacterService service, CancellationToken ct) => projectScoped.MapGet("/", async (Guid projectId, CharacterService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(projectId, ct))) Results.Ok(await service.ListAsync(projectId, ct)))
@@ -18,7 +20,7 @@ public static class CharacterEndpoints
}) })
.WithSummary("Add a character dossier."); .WithSummary("Add a character dossier.");
var characters = app.MapGroup("/api/characters").WithTags("Characters"); var characters = app.MapGroup("/api/characters").WithTags("Characters").AddEndpointFilter<RequestLoggingEndpointFilter>();
characters.MapGet("/{id:guid}", async (Guid id, CharacterService service, CancellationToken ct) => characters.MapGet("/{id:guid}", async (Guid id, CharacterService service, CancellationToken ct) =>
Results.Ok(await service.GetAsync(id, ct))) Results.Ok(await service.GetAsync(id, ct)))
@@ -67,7 +69,7 @@ public static class CharacterEndpoints
Results.Ok(await service.ReorderAsync(id, request, ct))) Results.Ok(await service.ReorderAsync(id, request, ct)))
.WithSummary("Renumber a character's arc to match the order given."); .WithSummary("Renumber a character's arc to match the order given.");
var arcStages = app.MapGroup("/api/arc-stages").WithTags("Characters"); var arcStages = app.MapGroup("/api/arc-stages").WithTags("Characters").AddEndpointFilter<RequestLoggingEndpointFilter>();
arcStages.MapGet("/{id:guid}", async (Guid id, CharacterArcService service, CancellationToken ct) => arcStages.MapGet("/{id:guid}", async (Guid id, CharacterArcService service, CancellationToken ct) =>
Results.Ok(await service.GetAsync(id, ct))) Results.Ok(await service.GetAsync(id, ct)))
+48 -12
View File
@@ -6,7 +6,7 @@ using Novelly.Api.Tags;
namespace Novelly.Api.Characters; namespace Novelly.Api.Characters;
public class CharacterService(INovelDbContext db, TagService tags) public class CharacterService(INovelDbContext db, TagService tags, ILogger<CharacterService> logger)
{ {
/// <summary> /// <summary>
/// Main characters first, then by the part they play, then by name. /// Main characters first, then by the part they play, then by name.
@@ -20,6 +20,8 @@ public class CharacterService(INovelDbContext db, TagService tags)
/// </remarks> /// </remarks>
public async Task<IReadOnlyList<CharacterDto>> ListAsync(Guid projectId, CancellationToken ct = default) public async Task<IReadOnlyList<CharacterDto>> ListAsync(Guid projectId, CancellationToken ct = default)
{ {
logger.LogInformation("Listing characters for project {ProjectId}", projectId);
var characters = await Query() var characters = await Query()
.Where(c => c.ProjectId == projectId) .Where(c => c.ProjectId == projectId)
.ToListAsync(ct); .ToListAsync(ct);
@@ -34,11 +36,16 @@ public class CharacterService(INovelDbContext db, TagService tags)
]; ];
} }
public async Task<CharacterDto> GetAsync(Guid id, CancellationToken ct = default) => public async Task<CharacterDto> GetAsync(Guid id, CancellationToken ct = default)
(await FindAsync(id, ct)).ToDto(); {
logger.LogInformation("Getting character {CharacterId}", id);
return (await FindAsync(id, ct)).ToDto();
}
public async Task<CharacterDto> CreateAsync(Guid projectId, CreateCharacterRequest request, CancellationToken ct = default) public async Task<CharacterDto> CreateAsync(Guid projectId, CreateCharacterRequest request, CancellationToken ct = default)
{ {
logger.LogInformation("Creating character {Name} for project {ProjectId}, role {Role}, importance {Importance}", request.Name, projectId, request.Role, request.Importance);
await EnsureProjectExists(projectId, ct); await EnsureProjectExists(projectId, ct);
var character = new Character var character = new Character
@@ -74,6 +81,8 @@ public class CharacterService(INovelDbContext db, TagService tags)
public async Task<CharacterDto> UpdateAsync(Guid id, UpdateCharacterRequest request, CancellationToken ct = default) public async Task<CharacterDto> UpdateAsync(Guid id, UpdateCharacterRequest request, CancellationToken ct = default)
{ {
logger.LogInformation("Updating character {CharacterId}", id);
var character = await FindAsync(id, ct); var character = await FindAsync(id, ct);
character.Name = Patch.Apply(character.Name, request.Name) ?? character.Name; character.Name = Patch.Apply(character.Name, request.Name) ?? character.Name;
@@ -105,6 +114,8 @@ public class CharacterService(INovelDbContext db, TagService tags)
public async Task DeleteAsync(Guid id, CancellationToken ct = default) public async Task DeleteAsync(Guid id, CancellationToken ct = default)
{ {
logger.LogInformation("Deleting character {CharacterId}", id);
var character = await FindAsync(id, ct); var character = await FindAsync(id, ct);
db.Characters.Remove(character); db.Characters.Remove(character);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
@@ -113,14 +124,20 @@ public class CharacterService(INovelDbContext db, TagService tags)
public async Task<CharacterDto> AddRelationshipAsync( public async Task<CharacterDto> AddRelationshipAsync(
Guid characterId, CreateRelationshipRequest request, CancellationToken ct = default) Guid characterId, CreateRelationshipRequest request, CancellationToken ct = default)
{ {
logger.LogInformation("Adding relationship {RelationshipType} from character {CharacterId} to {RelatedCharacterId}", request.RelationshipType, characterId, request.RelatedCharacterId);
var character = await FindAsync(characterId, ct); var character = await FindAsync(characterId, ct);
var related = await db.Characters var related = await db.Characters.FirstOrDefaultAsync(c => c.Id == request.RelatedCharacterId, ct);
.FirstOrDefaultAsync(c => c.Id == request.RelatedCharacterId, ct) if (related is null)
?? throw new NotFoundException(nameof(Character), request.RelatedCharacterId); {
logger.LogWarning("Character {RelatedCharacterId} not found", request.RelatedCharacterId);
throw new NotFoundException(nameof(Character), request.RelatedCharacterId);
}
if (related.ProjectId != character.ProjectId) if (related.ProjectId != character.ProjectId)
{ {
logger.LogWarning("Rejected relationship: character {CharacterId} and {RelatedCharacterId} belong to different projects", characterId, request.RelatedCharacterId);
throw new InvalidOperationException("Characters must belong to the same project to be related."); throw new InvalidOperationException("Characters must belong to the same project to be related.");
} }
@@ -138,9 +155,14 @@ public class CharacterService(INovelDbContext db, TagService tags)
public async Task RemoveRelationshipAsync(Guid relationshipId, CancellationToken ct = default) public async Task RemoveRelationshipAsync(Guid relationshipId, CancellationToken ct = default)
{ {
var relationship = await db.CharacterRelationships logger.LogInformation("Removing relationship {RelationshipId}", relationshipId);
.FirstOrDefaultAsync(r => r.Id == relationshipId, ct)
?? throw new NotFoundException(nameof(CharacterRelationship), relationshipId); var relationship = await db.CharacterRelationships.FirstOrDefaultAsync(r => r.Id == relationshipId, ct);
if (relationship is null)
{
logger.LogWarning("CharacterRelationship {RelationshipId} not found", relationshipId);
throw new NotFoundException(nameof(CharacterRelationship), relationshipId);
}
db.CharacterRelationships.Remove(relationship); db.CharacterRelationships.Remove(relationship);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
@@ -154,14 +176,28 @@ public class CharacterService(INovelDbContext db, TagService tags)
.Include(c => c.ArcStages) .Include(c => c.ArcStages)
.ThenInclude(s => s.Chapter); .ThenInclude(s => s.Chapter);
private async Task<Character> FindAsync(Guid id, CancellationToken ct) => private async Task<Character> FindAsync(Guid id, CancellationToken ct)
await Query().FirstOrDefaultAsync(c => c.Id == id, ct) {
?? throw new NotFoundException(nameof(Character), id); logger.LogDebug("Finding character {CharacterId}", id);
var character = await Query().FirstOrDefaultAsync(c => c.Id == id, ct);
if (character is null)
{
logger.LogWarning("Character {CharacterId} not found", id);
throw new NotFoundException(nameof(Character), id);
}
logger.LogDebug("Found character {CharacterId}", id);
return character;
}
private async Task EnsureProjectExists(Guid projectId, CancellationToken ct) private async Task EnsureProjectExists(Guid projectId, CancellationToken ct)
{ {
logger.LogDebug("Checking project {ProjectId} exists", projectId);
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct)) if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
{ {
logger.LogWarning("Project {ProjectId} not found", projectId);
throw new NotFoundException(nameof(Project), projectId); throw new NotFoundException(nameof(Project), projectId);
} }
} }
@@ -0,0 +1,24 @@
namespace Novelly.Api.Common;
/// <summary>
/// Logs every request an endpoint group handles: Information on entry with the route's
/// name and values, Debug on exit with the resulting status. Applied per <c>MapGroup</c>
/// rather than inside each handler, so no endpoint lambda needs to know about logging.
/// </summary>
public class RequestLoggingEndpointFilter(ILogger<RequestLoggingEndpointFilter> logger) : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
var routeName = context.HttpContext.GetEndpoint()?.DisplayName ?? context.HttpContext.Request.Path;
logger.LogInformation(
"HTTP {Method} {Route} invoked with {@RouteValues}",
context.HttpContext.Request.Method, routeName, context.HttpContext.Request.RouteValues);
var result = await next(context);
logger.LogDebug("HTTP {Method} {Route} completed with {ResultType}", context.HttpContext.Request.Method, routeName, result?.GetType().Name);
return result;
}
}
+2
View File
@@ -13,6 +13,8 @@
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
<PackageReference Include="Microsoft.OpenApi" Version="2.11.0" /> <PackageReference Include="Microsoft.OpenApi" Version="2.11.0" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.1" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.5" /> <PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.5" />
</ItemGroup> </ItemGroup>
+17
View File
@@ -11,9 +11,17 @@ using Novelly.Api.Projects;
using Novelly.Api.Questions; using Novelly.Api.Questions;
using Novelly.Api.Scenes; using Novelly.Api.Scenes;
using Novelly.Api.Tags; using Novelly.Api.Tags;
using Serilog;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// AddSerilog (not UseSerilog) so it becomes an additional logging provider rather than
// replacing the one AddServiceDefaults wires up for the Aspire dashboard.
builder.Services.AddSerilog((services, config) => config
.ReadFrom.Configuration(builder.Configuration)
.ReadFrom.Services(services)
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}"));
builder.AddServiceDefaults(); builder.AddServiceDefaults();
builder.Services.AddNovelly(builder.Configuration); builder.Services.AddNovelly(builder.Configuration);
builder.Services.AddOpenApi(); builder.Services.AddOpenApi();
@@ -41,6 +49,11 @@ using (var scope = app.Services.CreateScope())
await scope.ServiceProvider.GetRequiredService<NovelDbContext>().Database.MigrateAsync(); await scope.ServiceProvider.GetRequiredService<NovelDbContext>().Database.MigrateAsync();
} }
// Serilog's request logging wraps the exception handler (registered first = outermost)
// so it reads the status code the handler already resolved, rather than seeing the raw
// exception fly past and misreporting a handled 404 as a 500.
app.UseSerilogRequestLogging();
app.UseExceptionHandler(handler => handler.Run(async context => app.UseExceptionHandler(handler => handler.Run(async context =>
{ {
var exception = context.Features.Get<IExceptionHandlerFeature>()?.Error; var exception = context.Features.Get<IExceptionHandlerFeature>()?.Error;
@@ -57,6 +70,10 @@ app.UseExceptionHandler(handler => handler.Run(async context =>
{ {
app.Logger.LogError(exception, "Unhandled exception on {Path}", context.Request.Path); app.Logger.LogError(exception, "Unhandled exception on {Path}", context.Request.Path);
} }
else
{
app.Logger.LogWarning(exception, "Handled {StatusCode} on {Path}: {Title}", status, context.Request.Path, title);
}
await Results await Results
.Problem(title: title, detail: exception?.Message, statusCode: status) .Problem(title: title, detail: exception?.Message, statusCode: status)
+3 -1
View File
@@ -1,10 +1,12 @@
using Novelly.Api.Common;
namespace Novelly.Api.Projects; namespace Novelly.Api.Projects;
public static class ProjectEndpoints public static class ProjectEndpoints
{ {
public static IEndpointRouteBuilder MapProjectEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapProjectEndpoints(this IEndpointRouteBuilder app)
{ {
var group = app.MapGroup("/api/projects").WithTags("Projects"); var group = app.MapGroup("/api/projects").WithTags("Projects").AddEndpointFilter<RequestLoggingEndpointFilter>();
group.MapGet("/", async (ProjectService service, CancellationToken ct) => group.MapGet("/", async (ProjectService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(ct))) Results.Ok(await service.ListAsync(ct)))
+32 -8
View File
@@ -4,10 +4,13 @@ using Novelly.Api.Data;
namespace Novelly.Api.Projects; namespace Novelly.Api.Projects;
public class ProjectService(INovelDbContext db) public class ProjectService(INovelDbContext db, ILogger<ProjectService> logger)
{ {
public async Task<IReadOnlyList<ProjectSummaryDto>> ListAsync(CancellationToken ct = default) => public async Task<IReadOnlyList<ProjectSummaryDto>> ListAsync(CancellationToken ct = default)
await db.Projects {
logger.LogInformation("Listing projects");
return await db.Projects
.OrderByDescending(p => p.UpdatedAt) .OrderByDescending(p => p.UpdatedAt)
.Select(p => new ProjectSummaryDto( .Select(p => new ProjectSummaryDto(
p.Id, p.Id,
@@ -21,12 +24,18 @@ public class ProjectService(INovelDbContext db)
p.Chapters.SelectMany(c => c.Scenes).Sum(s => (int?)s.WordCount) ?? 0, p.Chapters.SelectMany(c => c.Scenes).Sum(s => (int?)s.WordCount) ?? 0,
p.UpdatedAt)) p.UpdatedAt))
.ToListAsync(ct); .ToListAsync(ct);
}
public async Task<ProjectDto> GetAsync(Guid id, CancellationToken ct = default) => public async Task<ProjectDto> GetAsync(Guid id, CancellationToken ct = default)
(await FindAsync(id, ct)).ToDto(); {
logger.LogInformation("Getting project {ProjectId}", id);
return (await FindAsync(id, ct)).ToDto();
}
public async Task<ProjectDto> CreateAsync(CreateProjectRequest request, CancellationToken ct = default) public async Task<ProjectDto> CreateAsync(CreateProjectRequest request, CancellationToken ct = default)
{ {
logger.LogInformation("Creating project {Title}", request.Title);
var project = new Project var project = new Project
{ {
Title = request.Title, Title = request.Title,
@@ -45,6 +54,8 @@ public class ProjectService(INovelDbContext db)
public async Task<ProjectDto> UpdateAsync(Guid id, UpdateProjectRequest request, CancellationToken ct = default) public async Task<ProjectDto> UpdateAsync(Guid id, UpdateProjectRequest request, CancellationToken ct = default)
{ {
logger.LogInformation("Updating project {ProjectId}", id);
var project = await FindAsync(id, ct); var project = await FindAsync(id, ct);
project.Title = Patch.Apply(project.Title, request.Title) ?? project.Title; project.Title = Patch.Apply(project.Title, request.Title) ?? project.Title;
@@ -62,12 +73,25 @@ public class ProjectService(INovelDbContext db)
public async Task DeleteAsync(Guid id, CancellationToken ct = default) public async Task DeleteAsync(Guid id, CancellationToken ct = default)
{ {
logger.LogInformation("Deleting project {ProjectId}", id);
var project = await FindAsync(id, ct); var project = await FindAsync(id, ct);
db.Projects.Remove(project); db.Projects.Remove(project);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
} }
private async Task<Project> FindAsync(Guid id, CancellationToken ct) => private async Task<Project> FindAsync(Guid id, CancellationToken ct)
await db.Projects.FirstOrDefaultAsync(p => p.Id == id, ct) {
?? throw new NotFoundException(nameof(Project), id); logger.LogDebug("Finding project {ProjectId}", id);
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == id, ct);
if (project is null)
{
logger.LogWarning("Project {ProjectId} not found", id);
throw new NotFoundException(nameof(Project), id);
}
logger.LogDebug("Found project {ProjectId}", id);
return project;
}
} }
@@ -1,10 +1,12 @@
using Novelly.Api.Common;
namespace Novelly.Api.Questions; namespace Novelly.Api.Questions;
public static class OpenQuestionEndpoints public static class OpenQuestionEndpoints
{ {
public static IEndpointRouteBuilder MapOpenQuestionEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapOpenQuestionEndpoints(this IEndpointRouteBuilder app)
{ {
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/questions").WithTags("Questions"); var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/questions").WithTags("Questions").AddEndpointFilter<RequestLoggingEndpointFilter>();
projectScoped.MapGet("/", async ( projectScoped.MapGet("/", async (
Guid projectId, Guid projectId,
@@ -24,7 +26,7 @@ public static class OpenQuestionEndpoints
}) })
.WithSummary("Raise an open question, optionally against a chapter outline and/or a character."); .WithSummary("Raise an open question, optionally against a chapter outline and/or a character.");
var questions = app.MapGroup("/api/questions").WithTags("Questions"); var questions = app.MapGroup("/api/questions").WithTags("Questions").AddEndpointFilter<RequestLoggingEndpointFilter>();
questions.MapGet("/{id:guid}", async (Guid id, OpenQuestionService service, CancellationToken ct) => questions.MapGet("/{id:guid}", async (Guid id, OpenQuestionService service, CancellationToken ct) =>
Results.Ok(await service.GetAsync(id, ct))) Results.Ok(await service.GetAsync(id, ct)))
@@ -11,7 +11,7 @@ namespace Novelly.Api.Questions;
/// The project's open questions — the decisions still outstanding. A question can be /// The project's open questions — the decisions still outstanding. A question can be
/// attached to a chapter outline, a character, both, or neither. /// attached to a chapter outline, a character, both, or neither.
/// </summary> /// </summary>
public class OpenQuestionService(INovelDbContext db) public class OpenQuestionService(INovelDbContext db, ILogger<OpenQuestionService> logger)
{ {
/// <summary> /// <summary>
/// Lists a project's questions, open ones first and newest first within each group. /// Lists a project's questions, open ones first and newest first within each group.
@@ -25,6 +25,10 @@ public class OpenQuestionService(INovelDbContext db)
bool includeResolved = false, bool includeResolved = false,
CancellationToken ct = default) CancellationToken ct = default)
{ {
logger.LogInformation(
"Listing open questions for project {ProjectId}, chapter {ChapterId}, character {CharacterId}, includeResolved {IncludeResolved}",
projectId, chapterId, characterId, includeResolved);
var query = Query().Where(q => q.ProjectId == projectId); var query = Query().Where(q => q.ProjectId == projectId);
if (chapterId is { } cid) if (chapterId is { } cid)
@@ -53,19 +57,26 @@ public class OpenQuestionService(INovelDbContext db)
]; ];
} }
public async Task<OpenQuestionDto> GetAsync(Guid id, CancellationToken ct = default) => public async Task<OpenQuestionDto> GetAsync(Guid id, CancellationToken ct = default)
(await FindAsync(id, ct)).ToDto(); {
logger.LogInformation("Getting open question {QuestionId}", id);
return (await FindAsync(id, ct)).ToDto();
}
public async Task<OpenQuestionDto> CreateAsync( public async Task<OpenQuestionDto> CreateAsync(
Guid projectId, CreateOpenQuestionRequest request, CancellationToken ct = default) Guid projectId, CreateOpenQuestionRequest request, CancellationToken ct = default)
{ {
logger.LogInformation("Creating open question for project {ProjectId}", projectId);
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct)) if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
{ {
logger.LogWarning("Project {ProjectId} not found", projectId);
throw new NotFoundException(nameof(Project), projectId); throw new NotFoundException(nameof(Project), projectId);
} }
if (string.IsNullOrWhiteSpace(request.Question)) if (string.IsNullOrWhiteSpace(request.Question))
{ {
logger.LogWarning("Rejected open question creation for project {ProjectId}: question text was blank", projectId);
throw new ArgumentException("A question needs to say something."); throw new ArgumentException("A question needs to say something.");
} }
@@ -88,6 +99,8 @@ public class OpenQuestionService(INovelDbContext db)
public async Task<OpenQuestionDto> UpdateAsync( public async Task<OpenQuestionDto> UpdateAsync(
Guid id, UpdateOpenQuestionRequest request, CancellationToken ct = default) Guid id, UpdateOpenQuestionRequest request, CancellationToken ct = default)
{ {
logger.LogInformation("Updating open question {QuestionId}", id);
var question = await FindAsync(id, ct); var question = await FindAsync(id, ct);
await ValidateAssociationsAsync(question.ProjectId, request.ChapterId, request.CharacterId, ct); await ValidateAssociationsAsync(question.ProjectId, request.ChapterId, request.CharacterId, ct);
@@ -110,10 +123,13 @@ public class OpenQuestionService(INovelDbContext db)
public async Task<OpenQuestionDto> ResolveAsync( public async Task<OpenQuestionDto> ResolveAsync(
Guid id, ResolveOpenQuestionRequest request, CancellationToken ct = default) Guid id, ResolveOpenQuestionRequest request, CancellationToken ct = default)
{ {
logger.LogInformation("Resolving open question {QuestionId}, appendToNotes {AppendToNotes}", id, request.AppendToNotes);
var question = await FindAsync(id, ct); var question = await FindAsync(id, ct);
if (string.IsNullOrWhiteSpace(request.Resolution)) if (string.IsNullOrWhiteSpace(request.Resolution))
{ {
logger.LogWarning("Rejected resolution for open question {QuestionId}: resolution text was blank", id);
throw new ArgumentException("A resolution needs to say what was decided."); throw new ArgumentException("A resolution needs to say what was decided.");
} }
@@ -127,6 +143,8 @@ public class OpenQuestionService(INovelDbContext db)
if (question.ChapterId is { } chapterId) if (question.ChapterId is { } chapterId)
{ {
logger.LogDebug("Appending resolution note to chapter {ChapterId}", chapterId);
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == chapterId, ct); var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == chapterId, ct);
if (chapter is not null) if (chapter is not null)
{ {
@@ -137,6 +155,8 @@ public class OpenQuestionService(INovelDbContext db)
if (question.CharacterId is { } characterId) if (question.CharacterId is { } characterId)
{ {
logger.LogDebug("Appending resolution note to character {CharacterId}", characterId);
var character = await db.Characters.FirstOrDefaultAsync(c => c.Id == characterId, ct); var character = await db.Characters.FirstOrDefaultAsync(c => c.Id == characterId, ct);
if (character is not null) if (character is not null)
{ {
@@ -153,6 +173,8 @@ public class OpenQuestionService(INovelDbContext db)
/// <summary>Puts a question back on the list. The resolution goes; anything already appended to notes stays.</summary> /// <summary>Puts a question back on the list. The resolution goes; anything already appended to notes stays.</summary>
public async Task<OpenQuestionDto> ReopenAsync(Guid id, CancellationToken ct = default) public async Task<OpenQuestionDto> ReopenAsync(Guid id, CancellationToken ct = default)
{ {
logger.LogInformation("Reopening open question {QuestionId}", id);
var question = await FindAsync(id, ct); var question = await FindAsync(id, ct);
question.Resolution = null; question.Resolution = null;
@@ -165,6 +187,8 @@ public class OpenQuestionService(INovelDbContext db)
public async Task DeleteAsync(Guid id, CancellationToken ct = default) public async Task DeleteAsync(Guid id, CancellationToken ct = default)
{ {
logger.LogInformation("Deleting open question {QuestionId}", id);
var question = await FindAsync(id, ct); var question = await FindAsync(id, ct);
db.OpenQuestions.Remove(question); db.OpenQuestions.Remove(question);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
@@ -177,9 +201,12 @@ public class OpenQuestionService(INovelDbContext db)
private async Task ValidateAssociationsAsync( private async Task ValidateAssociationsAsync(
Guid projectId, Guid? chapterId, Guid? characterId, CancellationToken ct) Guid projectId, Guid? chapterId, Guid? characterId, CancellationToken ct)
{ {
logger.LogDebug("Validating associations for project {ProjectId}: chapter {ChapterId}, character {CharacterId}", projectId, chapterId, characterId);
if (chapterId is { } cid if (chapterId is { } cid
&& !await db.Chapters.AnyAsync(c => c.Id == cid && c.ProjectId == projectId, ct)) && !await db.Chapters.AnyAsync(c => c.Id == cid && c.ProjectId == projectId, ct))
{ {
logger.LogWarning("Rejected question association: chapter {ChapterId} does not belong to project {ProjectId}", cid, projectId);
throw new InvalidOperationException( throw new InvalidOperationException(
"A question can only be attached to a chapter in the same project."); "A question can only be attached to a chapter in the same project.");
} }
@@ -187,6 +214,7 @@ public class OpenQuestionService(INovelDbContext db)
if (characterId is { } chid if (characterId is { } chid
&& !await db.Characters.AnyAsync(c => c.Id == chid && c.ProjectId == projectId, ct)) && !await db.Characters.AnyAsync(c => c.Id == chid && c.ProjectId == projectId, ct))
{ {
logger.LogWarning("Rejected question association: character {CharacterId} does not belong to project {ProjectId}", chid, projectId);
throw new InvalidOperationException( throw new InvalidOperationException(
"A question can only be attached to a character in the same project."); "A question can only be attached to a character in the same project.");
} }
@@ -195,7 +223,18 @@ public class OpenQuestionService(INovelDbContext db)
private IQueryable<OpenQuestion> Query() => private IQueryable<OpenQuestion> Query() =>
db.OpenQuestions.Include(q => q.Chapter).Include(q => q.Character); db.OpenQuestions.Include(q => q.Chapter).Include(q => q.Character);
private async Task<OpenQuestion> FindAsync(Guid id, CancellationToken ct) => private async Task<OpenQuestion> FindAsync(Guid id, CancellationToken ct)
await Query().FirstOrDefaultAsync(q => q.Id == id, ct) {
?? throw new NotFoundException(nameof(OpenQuestion), id); logger.LogDebug("Finding open question {QuestionId}", id);
var question = await Query().FirstOrDefaultAsync(q => q.Id == id, ct);
if (question is null)
{
logger.LogWarning("OpenQuestion {QuestionId} not found", id);
throw new NotFoundException(nameof(OpenQuestion), id);
}
logger.LogDebug("Found open question {QuestionId}", id);
return question;
}
} }
+4 -2
View File
@@ -1,10 +1,12 @@
using Novelly.Api.Common;
namespace Novelly.Api.Scenes; namespace Novelly.Api.Scenes;
public static class SceneEndpoints public static class SceneEndpoints
{ {
public static IEndpointRouteBuilder MapSceneEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapSceneEndpoints(this IEndpointRouteBuilder app)
{ {
var chapterScoped = app.MapGroup("/api/chapters/{chapterId:guid}/scenes").WithTags("Scenes"); var chapterScoped = app.MapGroup("/api/chapters/{chapterId:guid}/scenes").WithTags("Scenes").AddEndpointFilter<RequestLoggingEndpointFilter>();
chapterScoped.MapGet("/", async (Guid chapterId, SceneService service, CancellationToken ct) => chapterScoped.MapGet("/", async (Guid chapterId, SceneService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(chapterId, ct))) Results.Ok(await service.ListAsync(chapterId, ct)))
@@ -18,7 +20,7 @@ public static class SceneEndpoints
}) })
.WithSummary("Add a scene to a chapter."); .WithSummary("Add a scene to a chapter.");
var scenes = app.MapGroup("/api/scenes").WithTags("Scenes"); var scenes = app.MapGroup("/api/scenes").WithTags("Scenes").AddEndpointFilter<RequestLoggingEndpointFilter>();
scenes.MapGet("/{id:guid}", async (Guid id, SceneService service, CancellationToken ct) => scenes.MapGet("/{id:guid}", async (Guid id, SceneService service, CancellationToken ct) =>
Results.Ok(await service.GetAsync(id, ct))) Results.Ok(await service.GetAsync(id, ct)))
+31 -6
View File
@@ -5,10 +5,12 @@ using Novelly.Api.Data;
namespace Novelly.Api.Scenes; namespace Novelly.Api.Scenes;
public class SceneService(INovelDbContext db) public class SceneService(INovelDbContext db, ILogger<SceneService> logger)
{ {
public async Task<IReadOnlyList<SceneDto>> ListAsync(Guid chapterId, CancellationToken ct = default) public async Task<IReadOnlyList<SceneDto>> ListAsync(Guid chapterId, CancellationToken ct = default)
{ {
logger.LogInformation("Listing scenes for chapter {ChapterId}", chapterId);
var scenes = await Query() var scenes = await Query()
.Where(s => s.ChapterId == chapterId) .Where(s => s.ChapterId == chapterId)
.OrderBy(s => s.SortOrder) .OrderBy(s => s.SortOrder)
@@ -17,13 +19,19 @@ public class SceneService(INovelDbContext db)
return [.. scenes.Select(s => s.ToDto())]; return [.. scenes.Select(s => s.ToDto())];
} }
public async Task<SceneDto> GetAsync(Guid id, CancellationToken ct = default) => public async Task<SceneDto> GetAsync(Guid id, CancellationToken ct = default)
(await FindAsync(id, ct)).ToDto(); {
logger.LogInformation("Getting scene {SceneId}", id);
return (await FindAsync(id, ct)).ToDto();
}
public async Task<SceneDto> CreateAsync(Guid chapterId, CreateSceneRequest request, CancellationToken ct = default) public async Task<SceneDto> CreateAsync(Guid chapterId, CreateSceneRequest request, CancellationToken ct = default)
{ {
logger.LogInformation("Creating scene {Title} for chapter {ChapterId}, prose length {ProseLength}", request.Title, chapterId, request.Prose?.Length ?? 0);
if (!await db.Chapters.AnyAsync(c => c.Id == chapterId, ct)) if (!await db.Chapters.AnyAsync(c => c.Id == chapterId, ct))
{ {
logger.LogWarning("Chapter {ChapterId} not found", chapterId);
throw new NotFoundException(nameof(Chapter), chapterId); throw new NotFoundException(nameof(Chapter), chapterId);
} }
@@ -50,6 +58,8 @@ public class SceneService(INovelDbContext db)
public async Task<SceneDto> UpdateAsync(Guid id, UpdateSceneRequest request, CancellationToken ct = default) public async Task<SceneDto> UpdateAsync(Guid id, UpdateSceneRequest request, CancellationToken ct = default)
{ {
logger.LogInformation("Updating scene {SceneId}, prose length {ProseLength}", id, request.Prose?.Length ?? 0);
var scene = await FindAsync(id, ct); var scene = await FindAsync(id, ct);
scene.Title = Patch.Apply(scene.Title, request.Title) ?? scene.Title; scene.Title = Patch.Apply(scene.Title, request.Title) ?? scene.Title;
@@ -76,6 +86,8 @@ public class SceneService(INovelDbContext db)
public async Task DeleteAsync(Guid id, CancellationToken ct = default) public async Task DeleteAsync(Guid id, CancellationToken ct = default)
{ {
logger.LogInformation("Deleting scene {SceneId}", id);
var scene = await FindAsync(id, ct); var scene = await FindAsync(id, ct);
db.Scenes.Remove(scene); db.Scenes.Remove(scene);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
@@ -83,6 +95,8 @@ public class SceneService(INovelDbContext db)
private async Task<int> NextSortOrderAsync(Guid chapterId, CancellationToken ct) private async Task<int> NextSortOrderAsync(Guid chapterId, CancellationToken ct)
{ {
logger.LogDebug("Computing next sort order for chapter {ChapterId}", chapterId);
var max = await db.Scenes var max = await db.Scenes
.Where(s => s.ChapterId == chapterId) .Where(s => s.ChapterId == chapterId)
.MaxAsync(s => (int?)s.SortOrder, ct); .MaxAsync(s => (int?)s.SortOrder, ct);
@@ -92,7 +106,18 @@ public class SceneService(INovelDbContext db)
private IQueryable<Scene> Query() => db.Scenes.Include(s => s.PovCharacter); private IQueryable<Scene> Query() => db.Scenes.Include(s => s.PovCharacter);
private async Task<Scene> FindAsync(Guid id, CancellationToken ct) => private async Task<Scene> FindAsync(Guid id, CancellationToken ct)
await Query().FirstOrDefaultAsync(s => s.Id == id, ct) {
?? throw new NotFoundException(nameof(Scene), id); logger.LogDebug("Finding scene {SceneId}", id);
var scene = await Query().FirstOrDefaultAsync(s => s.Id == id, ct);
if (scene is null)
{
logger.LogWarning("Scene {SceneId} not found", id);
throw new NotFoundException(nameof(Scene), id);
}
logger.LogDebug("Found scene {SceneId}", id);
return scene;
}
} }
+4 -2
View File
@@ -1,10 +1,12 @@
using Novelly.Api.Common;
namespace Novelly.Api.Tags; namespace Novelly.Api.Tags;
public static class TagEndpoints public static class TagEndpoints
{ {
public static IEndpointRouteBuilder MapTagEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapTagEndpoints(this IEndpointRouteBuilder app)
{ {
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/tags").WithTags("Tags"); var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/tags").WithTags("Tags").AddEndpointFilter<RequestLoggingEndpointFilter>();
projectScoped.MapGet("/", async (Guid projectId, TagService service, CancellationToken ct) => projectScoped.MapGet("/", async (Guid projectId, TagService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(projectId, ct))) Results.Ok(await service.ListAsync(projectId, ct)))
@@ -18,7 +20,7 @@ public static class TagEndpoints
}) })
.WithSummary("Create a tag. Tags are also created on demand when applied by name."); .WithSummary("Create a tag. Tags are also created on demand when applied by name.");
var tags = app.MapGroup("/api/tags").WithTags("Tags"); var tags = app.MapGroup("/api/tags").WithTags("Tags").AddEndpointFilter<RequestLoggingEndpointFilter>();
tags.MapGet("/{id:guid}/references", async (Guid id, TagService service, CancellationToken ct) => tags.MapGet("/{id:guid}/references", async (Guid id, TagService service, CancellationToken ct) =>
Results.Ok(await service.GetReferencesAsync(id, ct))) Results.Ok(await service.GetReferencesAsync(id, ct)))
+43 -9
View File
@@ -5,27 +5,38 @@ using Novelly.Api.Projects;
namespace Novelly.Api.Tags; namespace Novelly.Api.Tags;
public class TagService(INovelDbContext db) public class TagService(INovelDbContext db, ILogger<TagService> logger)
{ {
public async Task<IReadOnlyList<TagSummaryDto>> ListAsync(Guid projectId, CancellationToken ct = default) => public async Task<IReadOnlyList<TagSummaryDto>> ListAsync(Guid projectId, CancellationToken ct = default)
await db.Tags {
logger.LogInformation("Listing tags for project {ProjectId}", projectId);
return await db.Tags
.Where(t => t.ProjectId == projectId) .Where(t => t.ProjectId == projectId)
.OrderBy(t => t.Name) .OrderBy(t => t.Name)
.Select(t => new TagSummaryDto( .Select(t => new TagSummaryDto(
t.Id, t.Name, t.Color, t.Id, t.Name, t.Color,
t.Characters.Count, t.Chapters.Count, t.Beats.Count)) t.Characters.Count, t.Chapters.Count, t.Beats.Count))
.ToListAsync(ct); .ToListAsync(ct);
}
/// <summary>Everything in the project carrying this tag.</summary> /// <summary>Everything in the project carrying this tag.</summary>
public async Task<TagReferencesDto> GetReferencesAsync(Guid tagId, CancellationToken ct = default) public async Task<TagReferencesDto> GetReferencesAsync(Guid tagId, CancellationToken ct = default)
{ {
logger.LogInformation("Getting references for tag {TagId}", tagId);
var tag = await db.Tags var tag = await db.Tags
.Include(t => t.Characters) .Include(t => t.Characters)
.Include(t => t.Chapters) .Include(t => t.Chapters)
.Include(t => t.Beats).ThenInclude(b => b.Character) .Include(t => t.Beats).ThenInclude(b => b.Character)
.Include(t => t.Beats).ThenInclude(b => b.Chapter) .Include(t => t.Beats).ThenInclude(b => b.Chapter)
.FirstOrDefaultAsync(t => t.Id == tagId, ct) .FirstOrDefaultAsync(t => t.Id == tagId, ct);
?? throw new NotFoundException(nameof(Tag), tagId);
if (tag is null)
{
logger.LogWarning("Tag {TagId} not found", tagId);
throw new NotFoundException(nameof(Tag), tagId);
}
return new TagReferencesDto( return new TagReferencesDto(
tag.ToDto(), tag.ToDto(),
@@ -51,20 +62,25 @@ public class TagService(INovelDbContext db)
public async Task<TagDto> CreateAsync(Guid projectId, CreateTagRequest request, CancellationToken ct = default) public async Task<TagDto> CreateAsync(Guid projectId, CreateTagRequest request, CancellationToken ct = default)
{ {
logger.LogInformation("Creating tag {Name} for project {ProjectId}", request.Name, projectId);
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct)) if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
{ {
logger.LogWarning("Project {ProjectId} not found", projectId);
throw new NotFoundException(nameof(Project), projectId); throw new NotFoundException(nameof(Project), projectId);
} }
var name = TagMapping.Normalise(request.Name); var name = TagMapping.Normalise(request.Name);
if (string.IsNullOrWhiteSpace(name)) if (string.IsNullOrWhiteSpace(name))
{ {
logger.LogWarning("Rejected tag creation for project {ProjectId}: name was blank", projectId);
throw new ArgumentException("A tag needs a name."); throw new ArgumentException("A tag needs a name.");
} }
var existing = await FindByNameAsync(projectId, name, ct); var existing = await FindByNameAsync(projectId, name, ct);
if (existing is not null) if (existing is not null)
{ {
logger.LogWarning("Rejected tag creation for project {ProjectId}: '{Name}' already exists", projectId, existing.Name);
throw new InvalidOperationException($"The project already has a tag called '{existing.Name}'."); throw new InvalidOperationException($"The project already has a tag called '{existing.Name}'.");
} }
@@ -76,20 +92,28 @@ public class TagService(INovelDbContext db)
public async Task<TagDto> UpdateAsync(Guid tagId, UpdateTagRequest request, CancellationToken ct = default) public async Task<TagDto> UpdateAsync(Guid tagId, UpdateTagRequest request, CancellationToken ct = default)
{ {
var tag = await db.Tags.FirstOrDefaultAsync(t => t.Id == tagId, ct) logger.LogInformation("Updating tag {TagId}", tagId);
?? throw new NotFoundException(nameof(Tag), tagId);
var tag = await db.Tags.FirstOrDefaultAsync(t => t.Id == tagId, ct);
if (tag is null)
{
logger.LogWarning("Tag {TagId} not found", tagId);
throw new NotFoundException(nameof(Tag), tagId);
}
if (request.Name is not null) if (request.Name is not null)
{ {
var name = TagMapping.Normalise(request.Name); var name = TagMapping.Normalise(request.Name);
if (string.IsNullOrWhiteSpace(name)) if (string.IsNullOrWhiteSpace(name))
{ {
logger.LogWarning("Rejected update for tag {TagId}: name was blank", tagId);
throw new ArgumentException("A tag needs a name."); throw new ArgumentException("A tag needs a name.");
} }
var clash = await FindByNameAsync(tag.ProjectId, name, ct); var clash = await FindByNameAsync(tag.ProjectId, name, ct);
if (clash is not null && clash.Id != tag.Id) if (clash is not null && clash.Id != tag.Id)
{ {
logger.LogWarning("Rejected update for tag {TagId}: '{Name}' already exists as {ClashTagId}", tagId, clash.Name, clash.Id);
throw new InvalidOperationException($"The project already has a tag called '{clash.Name}'."); throw new InvalidOperationException($"The project already has a tag called '{clash.Name}'.");
} }
@@ -104,8 +128,14 @@ public class TagService(INovelDbContext db)
/// <summary>Deletes a tag. Whatever carried it keeps existing — only the label goes.</summary> /// <summary>Deletes a tag. Whatever carried it keeps existing — only the label goes.</summary>
public async Task DeleteAsync(Guid tagId, CancellationToken ct = default) public async Task DeleteAsync(Guid tagId, CancellationToken ct = default)
{ {
var tag = await db.Tags.FirstOrDefaultAsync(t => t.Id == tagId, ct) logger.LogInformation("Deleting tag {TagId}", tagId);
?? throw new NotFoundException(nameof(Tag), tagId);
var tag = await db.Tags.FirstOrDefaultAsync(t => t.Id == tagId, ct);
if (tag is null)
{
logger.LogWarning("Tag {TagId} not found", tagId);
throw new NotFoundException(nameof(Tag), tagId);
}
db.Tags.Remove(tag); db.Tags.Remove(tag);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
@@ -119,6 +149,8 @@ public class TagService(INovelDbContext db)
internal async Task<List<Tag>> ResolveAsync( internal async Task<List<Tag>> ResolveAsync(
Guid projectId, IReadOnlyList<string> names, CancellationToken ct) Guid projectId, IReadOnlyList<string> names, CancellationToken ct)
{ {
logger.LogDebug("Resolving {Count} tag names for project {ProjectId}", names.Count, projectId);
var wanted = names var wanted = names
.Select(TagMapping.Normalise) .Select(TagMapping.Normalise)
.Where(n => !string.IsNullOrWhiteSpace(n)) .Where(n => !string.IsNullOrWhiteSpace(n))
@@ -127,6 +159,7 @@ public class TagService(INovelDbContext db)
if (wanted.Count == 0) if (wanted.Count == 0)
{ {
logger.LogDebug("No usable tag names for project {ProjectId}", projectId);
return []; return [];
} }
@@ -150,6 +183,7 @@ public class TagService(INovelDbContext db)
resolved.Add(match); resolved.Add(match);
} }
logger.LogDebug("Resolved {Count} tags for project {ProjectId}", resolved.Count, projectId);
return resolved; return resolved;
} }
@@ -5,5 +5,15 @@
"Novelly": "Debug", "Novelly": "Debug",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
},
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Novelly": "Debug",
"Microsoft.AspNetCore": "Warning",
"Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware": "Fatal"
}
}
} }
} }
+11
View File
@@ -6,6 +6,17 @@
"Microsoft.EntityFrameworkCore.Database.Command": "Warning" "Microsoft.EntityFrameworkCore.Database.Command": "Warning"
} }
}, },
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Novelly": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware": "Fatal",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
}
}
},
"AllowedHosts": "*", "AllowedHosts": "*",
"ConnectionStrings": { "ConnectionStrings": {
"Novel": "Data Source=novel.db" "Novel": "Data Source=novel.db"
+1 -2
View File
@@ -3,8 +3,7 @@ var builder = DistributedApplication.CreateBuilder(args);
// Port 5080 is pinned to match src/Novelly.Web's Vite proxy default and the curl-based // Port 5080 is pinned to match src/Novelly.Web's Vite proxy default and the curl-based
// smoke checks in CLAUDE.md, so the API sits at the same address whether it is started // smoke checks in CLAUDE.md, so the API sits at the same address whether it is started
// on its own with `dotnet run` or through this AppHost. // on its own with `dotnet run` or through this AppHost.
var api = builder.AddProject<Projects.Novelly_Api>("api") var api = builder.AddProject<Projects.Novelly_Api>("api").WithHttpEndpoint(port: 5080, name: "http");
.WithHttpEndpoint(port: 5080, name: "http");
builder.AddViteApp("web", "../Novelly.Web", "dev") builder.AddViteApp("web", "../Novelly.Web", "dev")
.WithReference(api) .WithReference(api)
+2 -4
View File
@@ -17,8 +17,7 @@ public class NovelApiClient(HttpClient http)
WriteIndented = true WriteIndented = true
}; };
public Task<CallToolResult> GetAsync(string path, CancellationToken ct = default) => public Task<CallToolResult> GetAsync(string path, CancellationToken ct = default) => SendAsync(new HttpRequestMessage(HttpMethod.Get, path), ct);
SendAsync(new HttpRequestMessage(HttpMethod.Get, path), ct);
public Task<CallToolResult> PostAsync(string path, object body, CancellationToken ct = default) => public Task<CallToolResult> PostAsync(string path, object body, CancellationToken ct = default) =>
SendAsync(new HttpRequestMessage(HttpMethod.Post, path) SendAsync(new HttpRequestMessage(HttpMethod.Post, path)
@@ -32,8 +31,7 @@ public class NovelApiClient(HttpClient http)
Content = JsonContent.Create(body, options: Options) Content = JsonContent.Create(body, options: Options)
}, ct); }, ct);
public Task<CallToolResult> DeleteAsync(string path, CancellationToken ct = default) => public Task<CallToolResult> DeleteAsync(string path, CancellationToken ct = default) => SendAsync(new HttpRequestMessage(HttpMethod.Delete, path), ct);
SendAsync(new HttpRequestMessage(HttpMethod.Delete, path), ct);
/// <summary> /// <summary>
/// Sends the request and shapes the outcome as a tool result. Failures come back as /// Sends the request and shapes the outcome as a tool result. Failures come back as
@@ -1,3 +1,4 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Novelly.Api.Agent; using Novelly.Api.Agent;
using Novelly.Api.Common; using Novelly.Api.Common;
@@ -13,7 +14,7 @@ public class AnthropicClientTests
// (listing conversations, reading a transcript). Throwing at construction would // (listing conversations, reading a transcript). Throwing at construction would
// take those down on any install that has not configured a key yet. // take those down on any install that has not configured a key yet.
Assert.That( Assert.That(
() => new AnthropicAgentModelClient(Options.Create(new AgentOptions())), () => new AnthropicAgentModelClient(Options.Create(new AgentOptions()), NullLogger<AnthropicAgentModelClient>.Instance),
Throws.Nothing); Throws.Nothing);
[Test] [Test]
@@ -24,7 +25,7 @@ public class AnthropicClientTests
try try
{ {
var client = new AnthropicAgentModelClient(Options.Create(new AgentOptions())); var client = new AnthropicAgentModelClient(Options.Create(new AgentOptions()), NullLogger<AnthropicAgentModelClient>.Instance);
Assert.That( Assert.That(
async () => await client.CompleteAsync("system", [], []), async () => await client.CompleteAsync("system", [], []),
@@ -0,0 +1,24 @@
using Microsoft.Extensions.Logging;
namespace Novelly.Api.Tests;
/// <summary>Records every entry logged through it, so tests can assert on what a service logged.</summary>
public record CapturedLogEntry(LogLevel Level, string Message, Exception? Exception);
/// <summary>
/// A test double for <see cref="ILogger{TCategoryName}"/> that captures entries instead of
/// writing them anywhere, so tests can assert a service logged at the right level with the
/// right values without standing up a real sink.
/// </summary>
public class CapturingLogger<T> : ILogger<T>
{
public List<CapturedLogEntry> Entries { get; } = [];
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) =>
Entries.Add(new CapturedLogEntry(logLevel, formatter(state, exception), exception));
}
+1 -1
View File
@@ -97,7 +97,7 @@ public class ListingTests : ServiceTestFixture
var agent = new NovelAgentService( var agent = new NovelAgentService(
Db.Context, Db.Context,
new ScriptedModelClient([[new AgentTextBlock("Reply.")]]), new ScriptedModelClient([[new AgentTextBlock("Reply.")]]),
new NovelAgentToolset(Projects, Characters, Arcs, Chapters, Beats, Scenes, Tags, Questions), new NovelAgentToolset(Projects, Characters, Arcs, Chapters, Beats, Scenes, Tags, Questions, NullLogger<NovelAgentToolset>.Instance),
Options.Create(new AgentOptions()), Options.Create(new AgentOptions()),
NullLogger<NovelAgentService>.Instance); NullLogger<NovelAgentService>.Instance);
+88
View File
@@ -0,0 +1,88 @@
using Microsoft.Extensions.Logging;
using Novelly.Api.Beats;
using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Common;
using Novelly.Api.Projects;
namespace Novelly.Api.Tests;
/// <summary>
/// Covers the logging behaviour added across the services: a warning fires before a
/// not-found is thrown, and prose bodies never leak into a log message.
/// </summary>
[TestFixture]
public class LoggingTests : ServiceTestFixture
{
[Test]
public void Fetching_a_missing_chapter_logs_a_warning_before_throwing()
{
var missingId = Guid.NewGuid();
Assert.That(() => Chapters.GetAsync(missingId), Throws.TypeOf<NotFoundException>());
var warning = ChapterLogs.Entries.Single(e => e.Level == LogLevel.Warning);
Assert.That(warning.Message, Does.Contain(missingId.ToString()));
}
[Test]
public async Task Creating_a_chapter_logs_the_project_and_title_at_information()
{
var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"));
ChapterLogs.Entries.Clear();
await Chapters.CreateAsync(project.Id, new CreateChapterRequest("Landfall"));
var info = ChapterLogs.Entries.Single(e => e.Level == LogLevel.Information);
Assert.Multiple(() =>
{
Assert.That(info.Message, Does.Contain("Landfall"));
Assert.That(info.Message, Does.Contain(project.Id.ToString()));
});
}
[Test]
public async Task Logged_values_never_include_a_chapter_summary_body()
{
var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"));
const string secretSummary = "A very specific plot twist nobody should see in a log line.";
ChapterLogs.Entries.Clear();
await Chapters.CreateAsync(project.Id, new CreateChapterRequest("Landfall", Summary: secretSummary));
Assert.That(ChapterLogs.Entries.Select(e => e.Message), Has.None.Contain(secretSummary));
}
[Test]
public async Task Deleting_a_project_logs_information_before_the_lookup()
{
var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"));
ProjectLogs.Entries.Clear();
await Projects.DeleteAsync(project.Id);
Assert.That(
ProjectLogs.Entries,
Has.Some.Matches<CapturedLogEntry>(e => e.Level == LogLevel.Information && e.Message.Contains(project.Id.ToString())));
}
[Test]
public async Task Rejecting_a_beat_with_a_foreign_character_logs_a_warning_not_an_error()
{
var projectA = await Projects.CreateAsync(new CreateProjectRequest("Project A"));
var projectB = await Projects.CreateAsync(new CreateProjectRequest("Project B"));
var chapter = await Chapters.CreateAsync(projectA.Id, new CreateChapterRequest("Landfall"));
var foreignCharacter = await Characters.CreateAsync(projectB.Id, new CreateCharacterRequest("Ines"));
BeatLogs.Entries.Clear();
Assert.That(
() => Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Arrival", CharacterId: foreignCharacter.Id)),
Throws.TypeOf<InvalidOperationException>());
Assert.Multiple(() =>
{
Assert.That(BeatLogs.Entries.Where(e => e.Level == LogLevel.Error), Is.Empty);
Assert.That(BeatLogs.Entries.Any(e => e.Level == LogLevel.Warning), Is.True);
});
}
}
@@ -12,7 +12,7 @@ public class NovelAgentServiceTests : ServiceTestFixture
private NovelAgentToolset _toolset = null!; private NovelAgentToolset _toolset = null!;
protected override void OnSetUp() => protected override void OnSetUp() =>
_toolset = new NovelAgentToolset(Projects, Characters, Arcs, Chapters, Beats, Scenes, Tags, Questions); _toolset = new NovelAgentToolset(Projects, Characters, Arcs, Chapters, Beats, Scenes, Tags, Questions, NullLogger<NovelAgentToolset>.Instance);
private NovelAgentService BuildAgent(ScriptedModelClient model) => new( private NovelAgentService BuildAgent(ScriptedModelClient model) => new(
Db.Context, Db.Context,
+27 -8
View File
@@ -29,18 +29,37 @@ public abstract class ServiceTestFixture
protected CharacterArcService Arcs { get; private set; } = null!; protected CharacterArcService Arcs { get; private set; } = null!;
protected OpenQuestionService Questions { get; private set; } = null!; protected OpenQuestionService Questions { get; private set; } = null!;
protected CapturingLogger<ProjectService> ProjectLogs { get; private set; } = null!;
protected CapturingLogger<CharacterService> CharacterLogs { get; private set; } = null!;
protected CapturingLogger<ChapterService> ChapterLogs { get; private set; } = null!;
protected CapturingLogger<SceneService> SceneLogs { get; private set; } = null!;
protected CapturingLogger<BeatService> BeatLogs { get; private set; } = null!;
protected CapturingLogger<TagService> TagLogs { get; private set; } = null!;
protected CapturingLogger<CharacterArcService> ArcLogs { get; private set; } = null!;
protected CapturingLogger<OpenQuestionService> QuestionLogs { get; private set; } = null!;
[SetUp] [SetUp]
public void SetUpFixture() public void SetUpFixture()
{ {
Db = new TestDatabase(); Db = new TestDatabase();
Tags = new TagService(Db.Context);
Projects = new ProjectService(Db.Context); TagLogs = new CapturingLogger<TagService>();
Characters = new CharacterService(Db.Context, Tags); ProjectLogs = new CapturingLogger<ProjectService>();
Chapters = new ChapterService(Db.Context, Tags); CharacterLogs = new CapturingLogger<CharacterService>();
Scenes = new SceneService(Db.Context); ChapterLogs = new CapturingLogger<ChapterService>();
Beats = new BeatService(Db.Context, Tags); SceneLogs = new CapturingLogger<SceneService>();
Arcs = new CharacterArcService(Db.Context); BeatLogs = new CapturingLogger<BeatService>();
Questions = new OpenQuestionService(Db.Context); ArcLogs = new CapturingLogger<CharacterArcService>();
QuestionLogs = new CapturingLogger<OpenQuestionService>();
Tags = new TagService(Db.Context, TagLogs);
Projects = new ProjectService(Db.Context, ProjectLogs);
Characters = new CharacterService(Db.Context, Tags, CharacterLogs);
Chapters = new ChapterService(Db.Context, Tags, ChapterLogs);
Scenes = new SceneService(Db.Context, SceneLogs);
Beats = new BeatService(Db.Context, Tags, BeatLogs);
Arcs = new CharacterArcService(Db.Context, ArcLogs);
Questions = new OpenQuestionService(Db.Context, QuestionLogs);
OnSetUp(); OnSetUp();
} }