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 = "_No activity in this period._"; 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); // Guard against argument injection via the model setting: model aliases/ids are // alphanumerics, dashes and dots only. var safeModel = new string(model.Where(c => char.IsLetterOrDigit(c) || c is '-' or '.').ToArray()); if (safeModel.Length == 0) safeModel = "sonnet"; IReadOnlyList args = [ "-p", "--output-format", "stream-json", "--verbose", "--permission-mode", "auto", "--model", safeModel, ]; var result = await _claude.RunAsync(args, prompt, Path.GetTempPath(), _ => Task.CompletedTask, ct); if (!result.IsSuccess) throw new InvalidOperationException(result.ErrorMarkdown ?? "Claude could not generate the report."); 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; } } }