Reorganise by feature, rename to Novelly, add Aspire and a pre-push hook
The layered split into Domain/Application/Infrastructure/Api was forcing organisation by layer: adding one capability meant touching four projects and four folders that each held a slice of it. Those four projects are now one feature-organised Novelly.Api, where each folder — Projects, Characters, Chapters, Beats, Scenes, Tags, Agent — holds its entity, DTOs, service and endpoints together. Common/ holds what genuinely crosses features (the patch semantics, the two exception types, DraftStatus) and Data/ holds the DbContext and migrations. Six .NET projects become five: the three layer projects are gone, and Novelly.AppHost and Novelly.ServiceDefaults are new. - Namespaces move from NovelSoftware.* to Novelly.*, including the entity type names recorded in the EF model snapshots. The migration ids are untouched, so an existing novel.db still migrates cleanly — verified against a fresh file. - Aspire orchestration mirrors the mic-check setup: the AppHost starts the API on :5080 and the Vite dev server on :5173, and the API picks up OpenTelemetry, health checks and service discovery from ServiceDefaults. /health and /alive now answer in development. - A Husky pre-push hook runs scripts/ci/prepush.sh: build, test, then a web build. The scripts are plain bash so CI can run the same steps. - The MCP server's env var is now NOVELLY_API_URL. Verified beyond the build: 44 tests pass, the web client builds, the API was exercised over curl (project/chapter/beat/tag round trip, tag cross-reference, 503 on the agent without a key while conversation listing still returns 200), the MCP server was driven over stdio JSON-RPC (26 tools, errors still surface the API's own message rather than being flattened), and the AppHost was run to confirm both resources come up and Vite proxies /api through to the API. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S56bfZMGe1hnhpWP4CjjNw
This commit is contained in:
co-authored by
Claude Opus 5
parent
30e0c6926e
commit
725758ccd9
@@ -0,0 +1,416 @@
|
||||
using System.Text.Json;
|
||||
using Novelly.Api.Beats;
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Scenes;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
namespace Novelly.Api.Agent;
|
||||
|
||||
/// <summary>The outcome of running a tool: what to hand back to the model, and whether it failed.</summary>
|
||||
public record AgentToolResult(string Content, bool IsError);
|
||||
|
||||
/// <summary>A tool the agent can call, bound to a handler that runs against the project's data.</summary>
|
||||
public record AgentTool(
|
||||
string Name,
|
||||
string Description,
|
||||
JsonElement InputSchema,
|
||||
Func<Guid, JsonElement, CancellationToken, Task<object?>> Handler);
|
||||
|
||||
/// <summary>
|
||||
/// The tools the writing agent can reach for. Everything here goes through the same
|
||||
/// application services the REST API uses, so an edit made by the agent is
|
||||
/// indistinguishable from one made in the UI.
|
||||
/// </summary>
|
||||
public class NovelAgentToolset(
|
||||
ProjectService projects,
|
||||
CharacterService characters,
|
||||
ChapterService chapters,
|
||||
BeatService beats,
|
||||
SceneService scenes,
|
||||
TagService tags)
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
private Dictionary<string, AgentTool>? _byName;
|
||||
|
||||
public IReadOnlyList<AgentTool> Tools => [.. ByName.Values];
|
||||
|
||||
public IReadOnlyList<AgentToolDefinition> Definitions =>
|
||||
[.. Tools.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema))];
|
||||
|
||||
/// <summary>
|
||||
/// Runs a tool and serialises its result. Failures come back as text rather than
|
||||
/// exceptions so the model can read the message and correct itself.
|
||||
/// </summary>
|
||||
public async Task<AgentToolResult> ExecuteAsync(
|
||||
string name, Guid projectId, JsonElement input, CancellationToken ct = default)
|
||||
{
|
||||
if (!ByName.TryGetValue(name, out var tool))
|
||||
{
|
||||
return new AgentToolResult($"No such tool: '{name}'.", true);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await tool.Handler(projectId, input, ct);
|
||||
return new AgentToolResult(JsonSerializer.Serialize(result, SerializerOptions), false);
|
||||
}
|
||||
catch (NotFoundException ex)
|
||||
{
|
||||
return new AgentToolResult(ex.Message, true);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return new AgentToolResult(ex.Message, true);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return new AgentToolResult(ex.Message, true);
|
||||
}
|
||||
}
|
||||
|
||||
private Dictionary<string, AgentTool> ByName => _byName ??= Build().ToDictionary(t => t.Name);
|
||||
|
||||
private IEnumerable<AgentTool> Build()
|
||||
{
|
||||
yield return new AgentTool(
|
||||
"get_project_brief",
|
||||
"Read the project's title, logline, synopsis, genre, notes and word-count target. "
|
||||
+ "Call this first in a conversation to ground yourself in what the book is.",
|
||||
new JsonSchemaBuilder().Build(),
|
||||
async (projectId, _, ct) => await projects.GetAsync(projectId, ct));
|
||||
|
||||
yield return new AgentTool(
|
||||
"update_project_brief",
|
||||
"Revise the project's top-level fields. Only the fields you supply change; "
|
||||
+ "pass an empty string to clear a field.",
|
||||
new JsonSchemaBuilder()
|
||||
.Str("title", "New title.")
|
||||
.Str("author", "Author name.")
|
||||
.Str("genre", "Genre or category.")
|
||||
.Str("logline", "One-sentence pitch.")
|
||||
.Str("synopsis", "Paragraph-length summary of the whole book.")
|
||||
.Str("notes", "Free-form notes on theme, tone, comparable titles.")
|
||||
.Int("target_word_count", "Target manuscript length in words.")
|
||||
.Build(),
|
||||
async (projectId, input, ct) => await projects.UpdateAsync(projectId, new UpdateProjectRequest(
|
||||
JsonInput.String(input, "title"),
|
||||
JsonInput.String(input, "author"),
|
||||
JsonInput.String(input, "genre"),
|
||||
JsonInput.String(input, "logline"),
|
||||
JsonInput.String(input, "synopsis"),
|
||||
JsonInput.String(input, "notes"),
|
||||
JsonInput.Int(input, "target_word_count")), ct));
|
||||
|
||||
yield return new AgentTool(
|
||||
"list_characters",
|
||||
"List every character in the project with their full dossiers.",
|
||||
new JsonSchemaBuilder().Build(),
|
||||
async (projectId, _, ct) => await characters.ListAsync(projectId, ct));
|
||||
|
||||
yield return new AgentTool(
|
||||
"create_character",
|
||||
"Add a character dossier. Name is the only requirement — leave fields blank when "
|
||||
+ "the writer has not decided them yet rather than inventing detail.",
|
||||
CharacterSchema(includeName: true, nameRequired: true).Build(),
|
||||
async (projectId, input, ct) => await characters.CreateAsync(projectId, new CreateCharacterRequest(
|
||||
JsonInput.RequiredString(input, "name"),
|
||||
JsonInput.Enum<CharacterRole>(input, "role") ?? CharacterRole.Supporting,
|
||||
JsonInput.String(input, "age"),
|
||||
JsonInput.String(input, "pronouns"),
|
||||
JsonInput.String(input, "occupation"),
|
||||
JsonInput.String(input, "appearance"),
|
||||
JsonInput.String(input, "personality"),
|
||||
JsonInput.String(input, "backstory"),
|
||||
JsonInput.String(input, "want"),
|
||||
JsonInput.String(input, "need"),
|
||||
JsonInput.String(input, "internal_conflict"),
|
||||
JsonInput.String(input, "external_conflict"),
|
||||
JsonInput.String(input, "arc_summary"),
|
||||
JsonInput.String(input, "voice"),
|
||||
JsonInput.String(input, "notes"),
|
||||
JsonInput.Strings(input, "tags")), ct));
|
||||
|
||||
yield return new AgentTool(
|
||||
"update_character",
|
||||
"Revise an existing character dossier. Only the fields you supply change.",
|
||||
CharacterSchema(includeName: true, nameRequired: false)
|
||||
.Str("character_id", "Id of the character to update.", required: true)
|
||||
.Build(),
|
||||
async (_, input, ct) => await characters.UpdateAsync(
|
||||
JsonInput.RequiredGuid(input, "character_id"),
|
||||
new UpdateCharacterRequest(
|
||||
JsonInput.String(input, "name"),
|
||||
JsonInput.Enum<CharacterRole>(input, "role"),
|
||||
JsonInput.String(input, "age"),
|
||||
JsonInput.String(input, "pronouns"),
|
||||
JsonInput.String(input, "occupation"),
|
||||
JsonInput.String(input, "appearance"),
|
||||
JsonInput.String(input, "personality"),
|
||||
JsonInput.String(input, "backstory"),
|
||||
JsonInput.String(input, "want"),
|
||||
JsonInput.String(input, "need"),
|
||||
JsonInput.String(input, "internal_conflict"),
|
||||
JsonInput.String(input, "external_conflict"),
|
||||
JsonInput.String(input, "arc_summary"),
|
||||
JsonInput.String(input, "voice"),
|
||||
JsonInput.String(input, "notes"),
|
||||
JsonInput.Strings(input, "tags")), ct));
|
||||
|
||||
yield return new AgentTool(
|
||||
"get_chapter_outline",
|
||||
"Read a chapter's outline: its summary paragraph and its beat table, in order. "
|
||||
+ "A beat is one row — a short title, whose beat it is, what happened, and what it sets up.",
|
||||
new JsonSchemaBuilder()
|
||||
.Str("chapter_id", "Id of the chapter whose outline to read.", required: true)
|
||||
.Build(),
|
||||
async (_, input, ct) => await beats.ListAsync(JsonInput.RequiredGuid(input, "chapter_id"), ct));
|
||||
|
||||
yield return new AgentTool(
|
||||
"create_beat",
|
||||
"Add a beat to a chapter's outline. Keep the title to three to five words — it is a "
|
||||
+ "handle, not a sentence; the detail belongs in what_happened and whats_next.",
|
||||
BeatSchema()
|
||||
.Str("chapter_id", "Id of the chapter the beat belongs to.", required: true)
|
||||
.Str("title", "Three to five words naming the beat.", required: true)
|
||||
.Build(),
|
||||
async (_, input, ct) => await beats.CreateAsync(
|
||||
JsonInput.RequiredGuid(input, "chapter_id"),
|
||||
new CreateBeatRequest(
|
||||
JsonInput.RequiredString(input, "title"),
|
||||
JsonInput.Int(input, "sort_order"),
|
||||
JsonInput.Guid(input, "character_id"),
|
||||
JsonInput.String(input, "what_happened"),
|
||||
JsonInput.String(input, "whats_next"),
|
||||
JsonInput.Guid(input, "scene_id"),
|
||||
JsonInput.Strings(input, "tags")), ct));
|
||||
|
||||
yield return new AgentTool(
|
||||
"update_beat",
|
||||
"Revise a beat. Only the fields you supply change. Supplying a tag list replaces "
|
||||
+ "the beat's tags outright, so include the ones you want to keep.",
|
||||
BeatSchema()
|
||||
.Str("beat_id", "Id of the beat to update.", required: true)
|
||||
.Str("title", "Three to five words naming the beat.")
|
||||
.Build(),
|
||||
async (_, input, ct) => await beats.UpdateAsync(
|
||||
JsonInput.RequiredGuid(input, "beat_id"),
|
||||
new UpdateBeatRequest(
|
||||
JsonInput.String(input, "title"),
|
||||
JsonInput.Int(input, "sort_order"),
|
||||
JsonInput.Guid(input, "character_id"),
|
||||
JsonInput.String(input, "what_happened"),
|
||||
JsonInput.String(input, "whats_next"),
|
||||
JsonInput.Guid(input, "scene_id"),
|
||||
JsonInput.Strings(input, "tags")), ct));
|
||||
|
||||
yield return new AgentTool(
|
||||
"delete_beat",
|
||||
"Remove a beat from a chapter's outline. Confirm with the writer before calling it.",
|
||||
new JsonSchemaBuilder()
|
||||
.Str("beat_id", "Id of the beat to delete.", required: true)
|
||||
.Build(),
|
||||
async (_, input, ct) =>
|
||||
{
|
||||
await beats.DeleteAsync(JsonInput.RequiredGuid(input, "beat_id"), ct);
|
||||
return new { deleted = true };
|
||||
});
|
||||
|
||||
yield return new AgentTool(
|
||||
"reorder_beats",
|
||||
"Renumber a chapter's beats to match the order given. List every beat id in the "
|
||||
+ "order you want; any you leave out keep their relative position at the end.",
|
||||
new JsonSchemaBuilder()
|
||||
.Str("chapter_id", "Id of the chapter whose beats to reorder.", required: true)
|
||||
.StringArray("beat_ids", "Beat ids in their new order.", required: true)
|
||||
.Build(),
|
||||
async (_, input, ct) => await beats.ReorderAsync(
|
||||
JsonInput.RequiredGuid(input, "chapter_id"),
|
||||
new ReorderBeatsRequest(
|
||||
[.. (JsonInput.Strings(input, "beat_ids") ?? [])
|
||||
.Select(id => Guid.TryParse(id, out var g) ? g : Guid.Empty)
|
||||
.Where(g => g != Guid.Empty)]), ct));
|
||||
|
||||
yield return new AgentTool(
|
||||
"list_tags",
|
||||
"List the project's tags with how many characters, chapters and beats carry each. "
|
||||
+ "Read this before inventing a new tag so you reuse the writer's vocabulary.",
|
||||
new JsonSchemaBuilder().Build(),
|
||||
async (projectId, _, ct) => await tags.ListAsync(projectId, ct));
|
||||
|
||||
yield return new AgentTool(
|
||||
"get_tag_references",
|
||||
"Cross-reference a tag: every character, chapter and beat carrying it. Use this to "
|
||||
+ "trace a motif or a thread through the book.",
|
||||
new JsonSchemaBuilder()
|
||||
.Str("tag_id", "Id of the tag to trace.", required: true)
|
||||
.Build(),
|
||||
async (_, input, ct) => await tags.GetReferencesAsync(JsonInput.RequiredGuid(input, "tag_id"), ct));
|
||||
|
||||
yield return new AgentTool(
|
||||
"list_chapters",
|
||||
"List the project's chapters in manuscript order with scene and word counts.",
|
||||
new JsonSchemaBuilder().Build(),
|
||||
async (projectId, _, ct) => await chapters.ListAsync(projectId, ct));
|
||||
|
||||
yield return new AgentTool(
|
||||
"get_chapter",
|
||||
"Read one chapter in full, including all of its scenes and any drafted prose.",
|
||||
new JsonSchemaBuilder()
|
||||
.Str("chapter_id", "Id of the chapter to read.", required: true)
|
||||
.Build(),
|
||||
async (_, input, ct) => await chapters.GetAsync(JsonInput.RequiredGuid(input, "chapter_id"), ct));
|
||||
|
||||
yield return new AgentTool(
|
||||
"create_chapter",
|
||||
"Add a chapter. Its number is appended to the end of the manuscript unless you supply one.",
|
||||
new JsonSchemaBuilder()
|
||||
.Str("title", "Chapter title.", required: true)
|
||||
.Int("number", "Position in the manuscript, 1-based.")
|
||||
.Str("summary", "What the chapter covers.")
|
||||
.Str("pov_character_id", "Id of the point-of-view character.")
|
||||
.Str("setting", "Where and when the chapter takes place.")
|
||||
.Str("notes", "Anything else worth recording.")
|
||||
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>())
|
||||
.Int("target_word_count", "Target length in words.")
|
||||
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
|
||||
.Build(),
|
||||
async (projectId, input, ct) => await chapters.CreateAsync(projectId, new CreateChapterRequest(
|
||||
JsonInput.RequiredString(input, "title"),
|
||||
JsonInput.Int(input, "number"),
|
||||
JsonInput.String(input, "summary"),
|
||||
JsonInput.Guid(input, "pov_character_id"),
|
||||
JsonInput.String(input, "setting"),
|
||||
JsonInput.String(input, "notes"),
|
||||
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned,
|
||||
JsonInput.Int(input, "target_word_count"),
|
||||
JsonInput.Strings(input, "tags")), ct));
|
||||
|
||||
yield return new AgentTool(
|
||||
"update_chapter",
|
||||
"Revise a chapter's title, number, summary, POV, setting, notes or status.",
|
||||
new JsonSchemaBuilder()
|
||||
.Str("chapter_id", "Id of the chapter to update.", required: true)
|
||||
.Str("title", "New title.")
|
||||
.Int("number", "Position in the manuscript.")
|
||||
.Str("summary", "What the chapter covers.")
|
||||
.Str("pov_character_id", "Id of the point-of-view character.")
|
||||
.Str("setting", "Where and when the chapter takes place.")
|
||||
.Str("notes", "Anything else worth recording.")
|
||||
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>())
|
||||
.Int("target_word_count", "Target length in words.")
|
||||
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
|
||||
.Build(),
|
||||
async (_, input, ct) => await chapters.UpdateAsync(
|
||||
JsonInput.RequiredGuid(input, "chapter_id"),
|
||||
new UpdateChapterRequest(
|
||||
JsonInput.String(input, "title"),
|
||||
JsonInput.Int(input, "number"),
|
||||
JsonInput.String(input, "summary"),
|
||||
JsonInput.Guid(input, "pov_character_id"),
|
||||
JsonInput.String(input, "setting"),
|
||||
JsonInput.String(input, "notes"),
|
||||
JsonInput.Enum<DraftStatus>(input, "status"),
|
||||
JsonInput.Int(input, "target_word_count"),
|
||||
JsonInput.Strings(input, "tags")), ct));
|
||||
|
||||
yield return new AgentTool(
|
||||
"create_scene",
|
||||
"Add a scene to a chapter. The goal/conflict/outcome trio is what makes a scene "
|
||||
+ "draftable later, so fill those in when the writer has given you enough to work with.",
|
||||
SceneSchema()
|
||||
.Str("chapter_id", "Id of the chapter the scene belongs to.", required: true)
|
||||
.Str("title", "Scene title.", required: true)
|
||||
.Build(),
|
||||
async (_, input, ct) => await scenes.CreateAsync(
|
||||
JsonInput.RequiredGuid(input, "chapter_id"),
|
||||
new CreateSceneRequest(
|
||||
JsonInput.RequiredString(input, "title"),
|
||||
JsonInput.Int(input, "sort_order"),
|
||||
JsonInput.String(input, "summary"),
|
||||
JsonInput.String(input, "goal"),
|
||||
JsonInput.String(input, "conflict"),
|
||||
JsonInput.String(input, "outcome"),
|
||||
JsonInput.Guid(input, "pov_character_id"),
|
||||
JsonInput.String(input, "location"),
|
||||
JsonInput.String(input, "prose"),
|
||||
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned), ct));
|
||||
|
||||
yield return new AgentTool(
|
||||
"update_scene",
|
||||
"Revise a scene. Use the 'prose' argument to write or replace the scene's draft text; "
|
||||
+ "the word count is recomputed automatically.",
|
||||
SceneSchema()
|
||||
.Str("scene_id", "Id of the scene to update.", required: true)
|
||||
.Str("title", "New title.")
|
||||
.Build(),
|
||||
async (_, input, ct) => await scenes.UpdateAsync(
|
||||
JsonInput.RequiredGuid(input, "scene_id"),
|
||||
new UpdateSceneRequest(
|
||||
JsonInput.String(input, "title"),
|
||||
JsonInput.Int(input, "sort_order"),
|
||||
JsonInput.String(input, "summary"),
|
||||
JsonInput.String(input, "goal"),
|
||||
JsonInput.String(input, "conflict"),
|
||||
JsonInput.String(input, "outcome"),
|
||||
JsonInput.Guid(input, "pov_character_id"),
|
||||
JsonInput.String(input, "location"),
|
||||
JsonInput.String(input, "prose"),
|
||||
JsonInput.Enum<DraftStatus>(input, "status")), ct));
|
||||
}
|
||||
|
||||
private static JsonSchemaBuilder CharacterSchema(bool includeName, bool nameRequired)
|
||||
{
|
||||
var schema = new JsonSchemaBuilder();
|
||||
|
||||
if (includeName)
|
||||
{
|
||||
schema.Str("name", "The character's name.", nameRequired);
|
||||
}
|
||||
|
||||
return schema
|
||||
.Enum("role", "The part they play in the story.", System.Enum.GetNames<CharacterRole>())
|
||||
.Str("age", "Age, exact or approximate.")
|
||||
.Str("pronouns", "The pronouns this character uses.")
|
||||
.Str("occupation", "What they do.")
|
||||
.Str("appearance", "How they look.")
|
||||
.Str("personality", "Temperament, habits, how they treat people.")
|
||||
.Str("backstory", "History that shapes who they are now.")
|
||||
.Str("want", "What they consciously pursue.")
|
||||
.Str("need", "What they actually need, usually at odds with what they want.")
|
||||
.Str("internal_conflict", "The war inside them.")
|
||||
.Str("external_conflict", "What in the world opposes them.")
|
||||
.Str("arc_summary", "How they change over the course of the book.")
|
||||
.Str("voice", "Speech patterns and register that make their dialogue theirs.")
|
||||
.Str("notes", "Anything else worth recording.")
|
||||
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.");
|
||||
}
|
||||
|
||||
private static JsonSchemaBuilder BeatSchema() =>
|
||||
new JsonSchemaBuilder()
|
||||
.Int("sort_order", "Position in the chapter. Appended to the end when omitted.")
|
||||
.Str("character_id", "Id of the character whose beat this is.")
|
||||
.Str("what_happened", "The event itself.")
|
||||
.Str("whats_next", "What it sets in motion — the hook into the next beat.")
|
||||
.Str("scene_id", "Id of the scene this beat will be written into, if decided.")
|
||||
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.");
|
||||
|
||||
private static JsonSchemaBuilder SceneSchema() =>
|
||||
new JsonSchemaBuilder()
|
||||
.Int("sort_order", "Position within the chapter. Appended to the end when omitted.")
|
||||
.Str("summary", "What happens in the scene.")
|
||||
.Str("goal", "What the POV character is trying to achieve.")
|
||||
.Str("conflict", "What stands in the way.")
|
||||
.Str("outcome", "How it lands, and what it costs.")
|
||||
.Str("pov_character_id", "Id of the point-of-view character.")
|
||||
.Str("location", "Where the scene takes place.")
|
||||
.Str("prose", "The drafted prose for this scene.")
|
||||
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>());
|
||||
}
|
||||
Reference in New Issue
Block a user