Add outline import feature; drop Dto naming, map entities at the API boundary
Services now return entities; endpoints (and the agent toolsets) map to *Response records instead of services building wire DTOs themselves. Also brings in the outline-import agent, MCP tool, ledger and web dialog that were already in progress on disk.
This commit is contained in:
@@ -91,8 +91,8 @@ public class BeatServiceTests : ServiceTestFixture
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(beat.CharacterName, Is.EqualTo("Ines"));
|
||||
Assert.That(beat.SceneTitle, Is.EqualTo("The dock at dawn"));
|
||||
Assert.That(beat.Character!.Name, Is.EqualTo("Ines"));
|
||||
Assert.That(beat.Scene!.Title, Is.EqualTo("The dock at dawn"));
|
||||
Assert.That(beat.WhatHappened, Does.Contain("faster than she expected"));
|
||||
});
|
||||
}
|
||||
@@ -164,4 +164,25 @@ public class BeatServiceTests : ServiceTestFixture
|
||||
Assert.That(cleared.WhatHappened, Is.EqualTo("Behind the lining of the case."));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ClearCharacter_and_ClearScene_detach_the_reference_since_a_null_id_means_leave_it_alone()
|
||||
{
|
||||
var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines"));
|
||||
var scene = await Scenes.CreateAsync(_chapterId, new CreateSceneRequest("The dock at dawn"));
|
||||
var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest(
|
||||
"She burns the atlas", CharacterId: ines.Id, SceneId: scene.Id));
|
||||
|
||||
var untouched = (await Beats.UpdateAsync(beat.Id, new UpdateBeatRequest(Title: "She burns it")))!;
|
||||
Assert.That(untouched.Character!.Name, Is.EqualTo("Ines"));
|
||||
|
||||
var cleared = (await Beats.UpdateAsync(
|
||||
beat.Id, new UpdateBeatRequest(ClearCharacter: true, ClearScene: true)))!;
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(cleared.Character, Is.Null);
|
||||
Assert.That(cleared.Scene, Is.Null);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,8 +136,8 @@ public class CharacterArcTests : ServiceTestFixture
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(stage.ChapterTitle, Is.EqualTo("Landfall"));
|
||||
Assert.That(stage.ChapterNumber, Is.EqualTo(1));
|
||||
Assert.That(stage.Chapter!.Title, Is.EqualTo("Landfall"));
|
||||
Assert.That(stage.Chapter!.Number, Is.EqualTo(1));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -206,8 +206,8 @@ public class CharacterArcTests : ServiceTestFixture
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(beats.Select(b => b.Title), Is.EqualTo(new[] { "She finds the map", "She boards anyway" }));
|
||||
Assert.That(beats[0].ChapterNumber, Is.EqualTo(1));
|
||||
Assert.That(beats[0].ChapterTitle, Is.EqualTo("First"));
|
||||
Assert.That(beats[0].Chapter!.Number, Is.EqualTo(1));
|
||||
Assert.That(beats[0].Chapter!.Title, Is.EqualTo("First"));
|
||||
Assert.That(beats[1].ChapterId, Is.EqualTo(second.Id));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
using System.Text.Json;
|
||||
using Novelly.Api.Imports;
|
||||
|
||||
namespace Novelly.Api.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public class ImportAgentToolsetTests : ServiceTestFixture
|
||||
{
|
||||
private string _root = null!;
|
||||
private ImportAgentToolset _toolset = null!;
|
||||
|
||||
protected override void OnSetUp()
|
||||
{
|
||||
_root = Directory.CreateTempSubdirectory("novelly-import-toolset-test-").FullName;
|
||||
_toolset = new ImportAgentToolset(
|
||||
Projects, Characters, Arcs, Chapters, Beats, new CapturingLogger<ImportAgentToolset>());
|
||||
_toolset.Initialize(_root, existingProjectId: null);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void CleanUpTempFolder()
|
||||
{
|
||||
if (Directory.Exists(_root))
|
||||
{
|
||||
Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Read_source_file_refuses_a_path_that_escapes_the_source_root()
|
||||
{
|
||||
var outsideFile = Path.Combine(Path.GetTempPath(), "novelly-outside-root.md");
|
||||
await File.WriteAllTextAsync(outsideFile, "secret");
|
||||
try
|
||||
{
|
||||
var result = await _toolset.ExecuteAsync("read_source_file", Input(new { path = "../novelly-outside-root.md" }));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(result.IsError, Is.True);
|
||||
Assert.That(result.Content, Does.Contain("escapes"));
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(outsideFile);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Read_source_file_reads_a_file_inside_the_source_root()
|
||||
{
|
||||
await File.WriteAllTextAsync(Path.Combine(_root, "outline.md"), "# The Blade Itself");
|
||||
|
||||
var result = await _toolset.ExecuteAsync("read_source_file", Input(new { path = "outline.md" }));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(result.IsError, Is.False);
|
||||
Assert.That(result.Content, Does.Contain("The Blade Itself"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Write_ledger_can_only_ever_touch_the_ledger_file_no_matter_what_path_is_asked_for()
|
||||
{
|
||||
// The tool takes no path argument at all — this is the enforcement, not a check
|
||||
// against a supplied path. Confirm the write always lands at exactly the ledger name.
|
||||
await _toolset.ExecuteAsync("write_ledger", Input(new { json = """{"completedPasses": ["project"]}""" }));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(File.Exists(Path.Combine(_root, ".novelly-import.json")), Is.True);
|
||||
Assert.That(Directory.GetFiles(_root), Has.Length.EqualTo(1));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Write_ledger_rejects_malformed_json_without_touching_the_file()
|
||||
{
|
||||
var result = await _toolset.ExecuteAsync("write_ledger", Input(new { json = "{not valid json" }));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(result.IsError, Is.True);
|
||||
Assert.That(File.Exists(Path.Combine(_root, ".novelly-import.json")), Is.False);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_project_binds_the_toolsets_project_id_for_later_calls()
|
||||
{
|
||||
await _toolset.ExecuteAsync("create_project", Input(new { title = "The Blade Itself", author = "Joe Abercrombie" }));
|
||||
|
||||
Assert.That(_toolset.ProjectId, Is.Not.Null);
|
||||
|
||||
var project = await Projects.GetAsync(_toolset.ProjectId!.Value);
|
||||
Assert.That(project!.Title, Is.EqualTo("The Blade Itself"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Domain_tools_refuse_to_run_before_a_project_exists()
|
||||
{
|
||||
var result = await _toolset.ExecuteAsync("create_character", Input(new { name = "Logen" }));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(result.IsError, Is.True);
|
||||
Assert.That(result.Content, Does.Contain("create_project first"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Every_tool_declares_an_object_schema_and_a_description()
|
||||
{
|
||||
Assert.That(_toolset.Definitions, Is.Not.Empty);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
foreach (var tool in _toolset.Definitions)
|
||||
{
|
||||
Assert.That(string.IsNullOrWhiteSpace(tool.Description), Is.False, tool.Name);
|
||||
Assert.That(tool.InputSchema.GetProperty("type").GetString(), Is.EqualTo("object"), tool.Name);
|
||||
}
|
||||
|
||||
Assert.That(_toolset.Definitions.Select(t => t.Name), Is.Unique);
|
||||
});
|
||||
}
|
||||
|
||||
private static JsonElement Input(object value) => JsonSerializer.SerializeToElement(value);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
using System.Threading.Channels;
|
||||
using Novelly.Api.Imports;
|
||||
using Novelly.Api.Projects;
|
||||
|
||||
namespace Novelly.Api.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public class ImportServiceTests : ServiceTestFixture
|
||||
{
|
||||
private string _root = null!;
|
||||
private Channel<Guid> _queue = null!;
|
||||
private ImportService _imports = null!;
|
||||
|
||||
protected override void OnSetUp()
|
||||
{
|
||||
_root = Directory.CreateTempSubdirectory("novelly-import-test-").FullName;
|
||||
_queue = Channel.CreateUnbounded<Guid>();
|
||||
_imports = new ImportService(
|
||||
Db.Context,
|
||||
Projects,
|
||||
_queue,
|
||||
new CapturingLogger<ImportService>(),
|
||||
new InspectImportRequestValidator(),
|
||||
new StartImportRequestValidator());
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void CleanUpTempFolder()
|
||||
{
|
||||
if (Directory.Exists(_root))
|
||||
{
|
||||
Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Inspecting_a_folder_with_no_ledger_reports_fresh()
|
||||
{
|
||||
WriteChapterFiles(3);
|
||||
|
||||
var inspection = await _imports.InspectAsync(new InspectImportRequest(_root));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(inspection.Readiness, Is.EqualTo(ImportReadiness.Fresh));
|
||||
Assert.That(inspection.ProjectId, Is.Null);
|
||||
Assert.That(inspection.ChaptersTotal, Is.EqualTo(3));
|
||||
Assert.That(inspection.ChaptersCompleted, Is.EqualTo(0));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Inspecting_a_folder_with_an_incomplete_ledger_reports_resumable()
|
||||
{
|
||||
WriteChapterFiles(3);
|
||||
WriteLedger("""{"projectId": "11111111-1111-1111-1111-111111111111", "completedPasses": ["project"], "completedChapters": [1]}""");
|
||||
|
||||
var inspection = await _imports.InspectAsync(new InspectImportRequest(_root));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(inspection.Readiness, Is.EqualTo(ImportReadiness.Resumable));
|
||||
Assert.That(inspection.ChaptersCompleted, Is.EqualTo(1));
|
||||
Assert.That(inspection.ChaptersTotal, Is.EqualTo(3));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Inspecting_a_folder_whose_ledger_covers_every_pass_and_chapter_reports_complete()
|
||||
{
|
||||
WriteChapterFiles(2);
|
||||
WriteLedger("""
|
||||
{
|
||||
"projectId": "11111111-1111-1111-1111-111111111111",
|
||||
"completedPasses": ["project", "characters", "chapters", "arcs"],
|
||||
"completedChapters": [1, 2]
|
||||
}
|
||||
""");
|
||||
|
||||
var inspection = await _imports.InspectAsync(new InspectImportRequest(_root));
|
||||
|
||||
Assert.That(inspection.Readiness, Is.EqualTo(ImportReadiness.Complete));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Starting_an_import_enqueues_a_pending_job()
|
||||
{
|
||||
var job = await _imports.StartOrResumeAsync(new StartImportRequest(_root));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(job.Status, Is.EqualTo(ImportJobStatus.Pending));
|
||||
Assert.That(job.SourceRoot, Is.EqualTo(_root));
|
||||
});
|
||||
|
||||
Assert.That(_queue.Reader.TryRead(out var queued), Is.True);
|
||||
Assert.That(queued, Is.EqualTo(job.Id));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Starting_an_import_a_second_time_reuses_the_pending_job_instead_of_duplicating_it()
|
||||
{
|
||||
var first = await _imports.StartOrResumeAsync(new StartImportRequest(_root));
|
||||
var second = await _imports.StartOrResumeAsync(new StartImportRequest(_root));
|
||||
|
||||
Assert.That(second.Id, Is.EqualTo(first.Id));
|
||||
|
||||
// Only one job was ever queued.
|
||||
Assert.That(_queue.Reader.TryRead(out _), Is.True);
|
||||
Assert.That(_queue.Reader.TryRead(out _), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Force_restarting_a_completed_import_deletes_the_ledger_and_its_project()
|
||||
{
|
||||
var project = await Projects.CreateAsync(new CreateProjectRequest("The Blade Itself"));
|
||||
WriteLedger($$"""{"projectId": "{{project.Id}}", "completedPasses": ["project", "characters", "chapters", "arcs"], "completedChapters": [1]}""");
|
||||
|
||||
await _imports.StartOrResumeAsync(new StartImportRequest(_root, ForceRestart: true));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(File.Exists(Path.Combine(_root, ".novelly-import.json")), Is.False);
|
||||
Assert.That(Projects.GetAsync(project.Id).Result, Is.Null);
|
||||
});
|
||||
}
|
||||
|
||||
private void WriteChapterFiles(int count)
|
||||
{
|
||||
var outlines = Path.Combine(_root, "outlines");
|
||||
Directory.CreateDirectory(outlines);
|
||||
for (var i = 1; i <= count; i++)
|
||||
{
|
||||
File.WriteAllText(Path.Combine(outlines, $"{i:D2}-chapter.md"), $"# Chapter {i}");
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteLedger(string json) =>
|
||||
File.WriteAllText(Path.Combine(_root, ".novelly-import.json"), json);
|
||||
}
|
||||
@@ -84,9 +84,9 @@ public class ListingTests : ServiceTestFixture
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(listed.Select(c => c.Title), Is.EqualTo(new[] { "First", "Second" }));
|
||||
Assert.That(listed.Single(c => c.Id == second.Id).SceneCount, Is.EqualTo(2));
|
||||
Assert.That(listed.Single(c => c.Id == second.Id).WordCount, Is.EqualTo(3));
|
||||
Assert.That(listed.Single(c => c.Id == first.Id).WordCount, Is.EqualTo(0));
|
||||
Assert.That(listed.Single(c => c.Id == second.Id).Scenes, Has.Count.EqualTo(2));
|
||||
Assert.That(listed.Single(c => c.Id == second.Id).Scenes.Sum(s => s.WordCount), Is.EqualTo(3));
|
||||
Assert.That(listed.Single(c => c.Id == first.Id).Scenes.Sum(s => s.WordCount), Is.EqualTo(0));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ public class NovelAgentServiceTests : ServiceTestFixture
|
||||
|
||||
var turn = await agent.SendMessageAsync(projectId, new SendAgentMessageRequest("Where do I start?"));
|
||||
|
||||
Assert.That(turn.Message.Content, Is.EqualTo("Tell me about the ending."));
|
||||
Assert.That(turn.Content, Is.EqualTo("Tell me about the ending."));
|
||||
|
||||
var conversation = (await agent.GetConversationAsync(turn.ConversationId))!;
|
||||
|
||||
@@ -62,9 +62,9 @@ public class NovelAgentServiceTests : ServiceTestFixture
|
||||
{
|
||||
Assert.That(characters, Has.Count.EqualTo(1));
|
||||
Assert.That(characters[0].Name, Is.EqualTo("Ines"));
|
||||
Assert.That(turn.Message.Content, Is.EqualTo("Added Ines as the protagonist."));
|
||||
Assert.That(turn.Message.ToolCalls, Has.Count.EqualTo(1));
|
||||
Assert.That(turn.Message.ToolCalls[0].Name, Is.EqualTo("create_character"));
|
||||
Assert.That(turn.Content, Is.EqualTo("Added Ines as the protagonist."));
|
||||
Assert.That(turn.ToResponse().ToolCalls, Has.Count.EqualTo(1));
|
||||
Assert.That(turn.ToResponse().ToolCalls[0].Name, Is.EqualTo("create_character"));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ public class NovelAgentServiceTests : ServiceTestFixture
|
||||
{
|
||||
Assert.That(errorResult.IsError, Is.True);
|
||||
Assert.That(errorResult.Content, Does.Contain("was not found"));
|
||||
Assert.That(turn.Message.Content, Does.Contain("does not exist yet"));
|
||||
Assert.That(turn.Content, Does.Contain("does not exist yet"));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ public class NovelAgentServiceTests : ServiceTestFixture
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(model.Transcripts, Has.Count.EqualTo(4));
|
||||
Assert.That(turn.Message.Content, Does.Contain("tool-call limit"));
|
||||
Assert.That(turn.Content, Does.Contain("tool-call limit"));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -30,9 +30,9 @@ public class OpenQuestionTests : ServiceTestFixture
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(question.ChapterTitle, Is.EqualTo("Landfall"));
|
||||
Assert.That(question.ChapterNumber, Is.EqualTo(1));
|
||||
Assert.That(question.CharacterName, Is.EqualTo("Ines"));
|
||||
Assert.That(question.Chapter!.Title, Is.EqualTo("Landfall"));
|
||||
Assert.That(question.Chapter!.Number, Is.EqualTo(1));
|
||||
Assert.That(question.Character!.Name, Is.EqualTo("Ines"));
|
||||
Assert.That(question.IsResolved, Is.False);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ public class ProjectDataTests : ServiceTestFixture
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(updated.Relationships, Has.Count.EqualTo(1));
|
||||
Assert.That(updated.Relationships[0].RelatedCharacterName, Is.EqualTo("Mara"));
|
||||
Assert.That(updated.Relationships[0].RelatedCharacter!.Name, Is.EqualTo("Mara"));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -98,8 +98,8 @@ public class TagServiceTests : ServiceTestFixture
|
||||
Assert.That(references.Chapters[0].Title, Is.EqualTo("Landfall"));
|
||||
Assert.That(references.Beats, Has.Count.EqualTo(1));
|
||||
Assert.That(references.Beats[0].Title, Is.EqualTo("She burns the atlas"));
|
||||
Assert.That(references.Beats[0].ChapterTitle, Is.EqualTo("Landfall"));
|
||||
Assert.That(references.Beats[0].ChapterNumber, Is.EqualTo(1));
|
||||
Assert.That(references.Beats[0].Chapter!.Title, Is.EqualTo("Landfall"));
|
||||
Assert.That(references.Beats[0].Chapter!.Number, Is.EqualTo(1));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user