feat(worker): WeekReportService orchestrates generate + store
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
namespace ClaudeDo.Worker.Report.Interfaces;
|
||||||
|
|
||||||
|
public interface IWeekReportService
|
||||||
|
{
|
||||||
|
Task<string?> GetStoredAsync(DateOnly start, DateOnly end, CancellationToken ct = default);
|
||||||
|
Task<string> GenerateAsync(DateOnly start, DateOnly end, CancellationToken ct = default);
|
||||||
|
}
|
||||||
91
src/ClaudeDo.Worker/Report/WeekReportService.cs
Normal file
91
src/ClaudeDo.Worker/Report/WeekReportService.cs
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Report.Interfaces;
|
||||||
|
using ClaudeDo.Worker.Runner;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Report;
|
||||||
|
|
||||||
|
public sealed class WeekReportService : IWeekReportService
|
||||||
|
{
|
||||||
|
private static readonly string[] DefaultExcludes = { @"C:\Private" };
|
||||||
|
private const string NoActivity = "_Keine Aktivität in diesem Zeitraum._";
|
||||||
|
|
||||||
|
private readonly IClaudeHistoryReader _reader;
|
||||||
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
|
private readonly IClaudeProcess _claude;
|
||||||
|
private readonly ILogger<WeekReportService> _logger;
|
||||||
|
|
||||||
|
public WeekReportService(
|
||||||
|
IClaudeHistoryReader reader,
|
||||||
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||||
|
IClaudeProcess claude,
|
||||||
|
ILogger<WeekReportService> logger)
|
||||||
|
{
|
||||||
|
_reader = reader;
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
_claude = claude;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string?> GetStoredAsync(DateOnly start, DateOnly end, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
|
var row = await new WeekReportRepository(ctx).GetByRangeAsync(start, end, ct);
|
||||||
|
return row?.Markdown;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> GenerateAsync(DateOnly start, DateOnly end, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
string[] excludes;
|
||||||
|
string model;
|
||||||
|
IReadOnlyList<Data.Models.DailyNoteEntity> noteRows;
|
||||||
|
|
||||||
|
await using (var ctx = await _dbFactory.CreateDbContextAsync(ct))
|
||||||
|
{
|
||||||
|
var settings = await new AppSettingsRepository(ctx).GetAsync(ct);
|
||||||
|
excludes = ParseExcludes(settings.ReportExcludedPaths);
|
||||||
|
model = string.IsNullOrWhiteSpace(settings.DefaultModel) ? "sonnet" : settings.DefaultModel;
|
||||||
|
noteRows = await new DailyNoteRepository(ctx).ListBetweenAsync(start, end, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
var activity = await _reader.ReadAsync(start, end, excludes, ct);
|
||||||
|
var notesByDay = noteRows
|
||||||
|
.GroupBy(n => n.Date)
|
||||||
|
.ToDictionary(g => g.Key, g => g.Select(n => n.Text).ToList());
|
||||||
|
|
||||||
|
string markdown;
|
||||||
|
var hasActivity = activity.Any(r => r.Days.Any(d => d.Prompts.Count > 0 || d.Summaries.Count > 0));
|
||||||
|
if (!hasActivity && notesByDay.Count == 0)
|
||||||
|
{
|
||||||
|
markdown = NoActivity;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var prompt = WeekReportPromptBuilder.Build(start, end, activity, notesByDay);
|
||||||
|
var args = $"-p --output-format stream-json --verbose --permission-mode auto --model {model}";
|
||||||
|
var result = await _claude.RunAsync(args, prompt, Path.GetTempPath(), _ => Task.CompletedTask, ct);
|
||||||
|
if (!result.IsSuccess)
|
||||||
|
throw new InvalidOperationException(result.ErrorMarkdown ?? "Claude konnte den Bericht nicht erzeugen.");
|
||||||
|
markdown = result.ResultMarkdown!;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var ctx = await _dbFactory.CreateDbContextAsync(ct))
|
||||||
|
await new WeekReportRepository(ctx).UpsertAsync(start, end, markdown, ct);
|
||||||
|
|
||||||
|
return markdown;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string[] ParseExcludes(string? json)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(json)) return DefaultExcludes;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var list = JsonSerializer.Deserialize<List<string>>(json);
|
||||||
|
return list is { Count: > 0 } ? list.ToArray() : DefaultExcludes;
|
||||||
|
}
|
||||||
|
catch (JsonException) { return DefaultExcludes; }
|
||||||
|
}
|
||||||
|
}
|
||||||
81
tests/ClaudeDo.Worker.Tests/Report/WeekReportServiceTests.cs
Normal file
81
tests/ClaudeDo.Worker.Tests/Report/WeekReportServiceTests.cs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Report;
|
||||||
|
using ClaudeDo.Worker.Report.Interfaces;
|
||||||
|
using ClaudeDo.Worker.Runner;
|
||||||
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Report;
|
||||||
|
|
||||||
|
public class WeekReportServiceTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly DbFixture _db = new();
|
||||||
|
public void Dispose() => _db.Dispose();
|
||||||
|
|
||||||
|
private static readonly DateOnly Start = new(2026, 5, 28);
|
||||||
|
private static readonly DateOnly End = new(2026, 6, 3);
|
||||||
|
|
||||||
|
private sealed class FakeReader : IClaudeHistoryReader
|
||||||
|
{
|
||||||
|
public IReadOnlyList<RepoActivity> Result = Array.Empty<RepoActivity>();
|
||||||
|
public Task<IReadOnlyList<RepoActivity>> ReadAsync(
|
||||||
|
DateOnly s, DateOnly e, IReadOnlyList<string> ex, CancellationToken ct) => Task.FromResult(Result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeClaude : IClaudeProcess
|
||||||
|
{
|
||||||
|
public int Calls;
|
||||||
|
public RunResult Next = new() { ExitCode = 0, ResultMarkdown = "## Bericht" };
|
||||||
|
public Task<RunResult> RunAsync(string args, string prompt, string wd, Func<string, Task> onLine, CancellationToken ct)
|
||||||
|
{ Calls++; return Task.FromResult(Next); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private WeekReportService Make(FakeReader reader, FakeClaude claude) =>
|
||||||
|
new(reader, _db.CreateFactory(), claude, NullLogger<WeekReportService>.Instance);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EmptyWindow_ProducesNoActivityReport_WithoutCallingClaude()
|
||||||
|
{
|
||||||
|
var claude = new FakeClaude();
|
||||||
|
var svc = Make(new FakeReader(), claude);
|
||||||
|
|
||||||
|
var md = await svc.GenerateAsync(Start, End);
|
||||||
|
|
||||||
|
Assert.Equal(0, claude.Calls);
|
||||||
|
Assert.Contains("Keine Aktivität", md);
|
||||||
|
using var ctx = _db.CreateContext();
|
||||||
|
Assert.NotNull(await new WeekReportRepository(ctx).GetByRangeAsync(Start, End));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SuccessPath_StoresAndReturnsClaudeMarkdown()
|
||||||
|
{
|
||||||
|
var repo = new RepoActivity { RepoPath = @"C:\Dev\App" };
|
||||||
|
var day = new DayActivity { Date = new DateOnly(2026, 6, 1) };
|
||||||
|
day.Prompts.Add("Add login");
|
||||||
|
repo.Days.Add(day);
|
||||||
|
var claude = new FakeClaude { Next = new RunResult { ExitCode = 0, ResultMarkdown = "## Bericht\n- Habe Login umgesetzt" } };
|
||||||
|
var svc = Make(new FakeReader { Result = new[] { repo } }, claude);
|
||||||
|
|
||||||
|
var md = await svc.GenerateAsync(Start, End);
|
||||||
|
|
||||||
|
Assert.Equal(1, claude.Calls);
|
||||||
|
Assert.Contains("Habe Login umgesetzt", md);
|
||||||
|
Assert.Equal(md, await svc.GetStoredAsync(Start, End));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ClaudeFailure_Throws_AndDoesNotStore()
|
||||||
|
{
|
||||||
|
var repo = new RepoActivity { RepoPath = @"C:\Dev\App" };
|
||||||
|
var day = new DayActivity { Date = new DateOnly(2026, 6, 1) };
|
||||||
|
day.Prompts.Add("x");
|
||||||
|
repo.Days.Add(day);
|
||||||
|
var claude = new FakeClaude { Next = new RunResult { ExitCode = 1, ErrorMarkdown = "boom" } };
|
||||||
|
var svc = Make(new FakeReader { Result = new[] { repo } }, claude);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => svc.GenerateAsync(Start, End));
|
||||||
|
Assert.Null(await svc.GetStoredAsync(Start, End));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user