From 50d84f12c990a0ed9458c4f3462ecff24b4e54e6 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Wed, 3 Jun 2026 09:42:21 +0200 Subject: [PATCH] feat(worker): WeekReportService orchestrates generate + store --- .../Report/Interfaces/IWeekReportService.cs | 7 ++ .../Report/WeekReportService.cs | 91 +++++++++++++++++++ .../Report/WeekReportServiceTests.cs | 81 +++++++++++++++++ 3 files changed, 179 insertions(+) create mode 100644 src/ClaudeDo.Worker/Report/Interfaces/IWeekReportService.cs create mode 100644 src/ClaudeDo.Worker/Report/WeekReportService.cs create mode 100644 tests/ClaudeDo.Worker.Tests/Report/WeekReportServiceTests.cs diff --git a/src/ClaudeDo.Worker/Report/Interfaces/IWeekReportService.cs b/src/ClaudeDo.Worker/Report/Interfaces/IWeekReportService.cs new file mode 100644 index 0000000..ebddd92 --- /dev/null +++ b/src/ClaudeDo.Worker/Report/Interfaces/IWeekReportService.cs @@ -0,0 +1,7 @@ +namespace ClaudeDo.Worker.Report.Interfaces; + +public interface IWeekReportService +{ + Task GetStoredAsync(DateOnly start, DateOnly end, CancellationToken ct = default); + Task GenerateAsync(DateOnly start, DateOnly end, CancellationToken ct = default); +} diff --git a/src/ClaudeDo.Worker/Report/WeekReportService.cs b/src/ClaudeDo.Worker/Report/WeekReportService.cs new file mode 100644 index 0000000..1727c0b --- /dev/null +++ b/src/ClaudeDo.Worker/Report/WeekReportService.cs @@ -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 _dbFactory; + private readonly IClaudeProcess _claude; + private readonly ILogger _logger; + + public WeekReportService( + IClaudeHistoryReader reader, + IDbContextFactory dbFactory, + IClaudeProcess claude, + ILogger logger) + { + _reader = reader; + _dbFactory = dbFactory; + _claude = claude; + _logger = logger; + } + + public async Task 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 GenerateAsync(DateOnly start, DateOnly end, CancellationToken ct = default) + { + string[] excludes; + string model; + IReadOnlyList 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>(json); + return list is { Count: > 0 } ? list.ToArray() : DefaultExcludes; + } + catch (JsonException) { return DefaultExcludes; } + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Report/WeekReportServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Report/WeekReportServiceTests.cs new file mode 100644 index 0000000..8287ebb --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Report/WeekReportServiceTests.cs @@ -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 Result = Array.Empty(); + public Task> ReadAsync( + DateOnly s, DateOnly e, IReadOnlyList ex, CancellationToken ct) => Task.FromResult(Result); + } + + private sealed class FakeClaude : IClaudeProcess + { + public int Calls; + public RunResult Next = new() { ExitCode = 0, ResultMarkdown = "## Bericht" }; + public Task RunAsync(string args, string prompt, string wd, Func onLine, CancellationToken ct) + { Calls++; return Task.FromResult(Next); } + } + + private WeekReportService Make(FakeReader reader, FakeClaude claude) => + new(reader, _db.CreateFactory(), claude, NullLogger.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(() => svc.GenerateAsync(Start, End)); + Assert.Null(await svc.GetStoredAsync(Start, End)); + } +}