using System.IO; using System.Text.Json; using System.Text.Json.Serialization; namespace AxCopilot.Services; /// /// 에이전트 실행 통계를 로컬 JSON 파일에 기록/집계합니다. /// %APPDATA%\AxCopilot\stats\agent_stats.json /// public static class AgentStatsService { private static readonly string StatsDir = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "AxCopilot", "stats"); private static readonly string StatsFile = Path.Combine(StatsDir, "agent_stats.json"); private static readonly JsonSerializerOptions _jsonOpts = new() { WriteIndented = false, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, }; // ─── 데이터 모델 ────────────────────────────────────────────────────── public record AgentSessionRecord { [JsonPropertyName("ts")] public DateTime Timestamp { get; init; } = DateTime.Now; [JsonPropertyName("tab")] public string Tab { get; init; } = ""; [JsonPropertyName("task")] public string TaskType { get; init; } = ""; [JsonPropertyName("model")] public string Model { get; init; } = ""; [JsonPropertyName("calls")] public int ToolCalls { get; init; } [JsonPropertyName("ok")] public int SuccessCount{ get; init; } [JsonPropertyName("fail")] public int FailCount { get; init; } [JsonPropertyName("itok")] public int InputTokens { get; init; } [JsonPropertyName("otok")] public int OutputTokens{ get; init; } [JsonPropertyName("ms")] public long DurationMs { get; init; } [JsonPropertyName("retryBlocked")] public int RepeatedFailureBlockedCount { get; init; } [JsonPropertyName("retryRecovered")] public int RecoveredAfterFailureCount { get; init; } [JsonPropertyName("tools")] public List UsedTools { get; init; } = new(); } /// 집계 결과. public record AgentStatsSummary { public int TotalSessions { get; init; } public int TotalToolCalls { get; init; } public int TotalTokens { get; init; } public int TotalInputTokens { get; init; } public int TotalOutputTokens { get; init; } public long TotalDurationMs { get; init; } public int TotalRepeatedFailureBlocked { get; init; } public int TotalRecoveredAfterFailure { get; init; } public double RetryQualityRate { get; init; } public Dictionary ToolFrequency { get; init; } = new(); public Dictionary ModelBreakdown { get; init; } = new(); public Dictionary TabBreakdown { get; init; } = new(); public Dictionary TaskTypeBreakdown { get; init; } = new(); public Dictionary RetryQualityByTaskType { get; init; } = new(); /// 일별 세션 수 (날짜 키 "yyyy-MM-dd"). public Dictionary DailySessions { get; init; } = new(); /// 일별 토큰 수 (날짜 키 "yyyy-MM-dd"). public Dictionary DailyTokens { get; init; } = new(); } // ─── 기록 ───────────────────────────────────────────────────────────── /// 에이전트 세션 결과를 기록합니다. 비동기 fire-and-forget. public static void RecordSession(AgentSessionRecord record) { Task.Run(() => { try { if (!Directory.Exists(StatsDir)) Directory.CreateDirectory(StatsDir); // 한 줄씩 append (JSONL 형식) var line = JsonSerializer.Serialize(record, _jsonOpts); File.AppendAllText(StatsFile, line + "\n"); } catch (Exception ex) { LogService.Warn($"통계 기록 실패: {ex.Message}"); } }); } // ─── 집계 ───────────────────────────────────────────────────────────── /// 지정 일수 이내의 통계를 집계합니다. days=0이면 전체. public static AgentStatsSummary Aggregate(int days = 0) { var records = LoadRecords(days); return BuildSummary(records); } /// 통계 파일에서 레코드를 읽어옵니다. public static List LoadRecords(int days = 0) { if (!File.Exists(StatsFile)) return new(); var cutoff = days > 0 ? DateTime.Now.AddDays(-days) : DateTime.MinValue; var result = new List(); foreach (var line in File.ReadLines(StatsFile)) { if (string.IsNullOrWhiteSpace(line)) continue; try { var rec = JsonSerializer.Deserialize(line, _jsonOpts); if (rec != null && rec.Timestamp >= cutoff) result.Add(rec); } catch { /* 손상된 줄 건너뜀 */ } } return result; } private static AgentStatsSummary BuildSummary(List records) { var toolFreq = new Dictionary(); var modelBreak = new Dictionary(); var tabBreak = new Dictionary(); var taskBreak = new Dictionary(StringComparer.OrdinalIgnoreCase); var retryRecoveredByTask = new Dictionary(StringComparer.OrdinalIgnoreCase); var retryBlockedByTask = new Dictionary(StringComparer.OrdinalIgnoreCase); var dailySess = new Dictionary(); var dailyTok = new Dictionary(); int totalCalls = 0, totalIn = 0, totalOut = 0; int totalRetryBlocked = 0, totalRecovered = 0; long totalMs = 0; foreach (var r in records) { totalCalls += r.ToolCalls; totalIn += r.InputTokens; totalOut += r.OutputTokens; totalMs += r.DurationMs; totalRetryBlocked += r.RepeatedFailureBlockedCount; totalRecovered += r.RecoveredAfterFailureCount; foreach (var t in r.UsedTools) toolFreq[t] = toolFreq.GetValueOrDefault(t) + 1; if (!string.IsNullOrEmpty(r.Model)) modelBreak[r.Model] = modelBreak.GetValueOrDefault(r.Model) + 1; if (!string.IsNullOrEmpty(r.Tab)) tabBreak[r.Tab] = tabBreak.GetValueOrDefault(r.Tab) + 1; var taskType = string.IsNullOrWhiteSpace(r.TaskType) ? "unknown" : r.TaskType.Trim().ToLowerInvariant(); taskBreak[taskType] = taskBreak.GetValueOrDefault(taskType) + 1; retryRecoveredByTask[taskType] = retryRecoveredByTask.GetValueOrDefault(taskType) + r.RecoveredAfterFailureCount; retryBlockedByTask[taskType] = retryBlockedByTask.GetValueOrDefault(taskType) + r.RepeatedFailureBlockedCount; var dateKey = r.Timestamp.ToString("yyyy-MM-dd"); dailySess[dateKey] = dailySess.GetValueOrDefault(dateKey) + 1; dailyTok[dateKey] = dailyTok.GetValueOrDefault(dateKey) + r.InputTokens + r.OutputTokens; } var retryQualityByTask = taskBreak.Keys .ToDictionary( k => k, k => CalculateRetryQualityRate( retryRecoveredByTask.GetValueOrDefault(k), retryBlockedByTask.GetValueOrDefault(k))); return new AgentStatsSummary { TotalSessions = records.Count, TotalToolCalls = totalCalls, TotalTokens = totalIn + totalOut, TotalInputTokens = totalIn, TotalOutputTokens= totalOut, TotalDurationMs = totalMs, TotalRepeatedFailureBlocked = totalRetryBlocked, TotalRecoveredAfterFailure = totalRecovered, RetryQualityRate = CalculateRetryQualityRate(totalRecovered, totalRetryBlocked), ToolFrequency = toolFreq.OrderByDescending(kv => kv.Value).Take(10) .ToDictionary(kv => kv.Key, kv => kv.Value), ModelBreakdown = modelBreak, TabBreakdown = tabBreak, TaskTypeBreakdown = taskBreak.ToDictionary(kv => kv.Key, kv => kv.Value), RetryQualityByTaskType = retryQualityByTask, DailySessions = dailySess, DailyTokens = dailyTok, }; } private static double CalculateRetryQualityRate(int recoveredAfterFailure, int repeatedFailureBlocked) { var total = recoveredAfterFailure + repeatedFailureBlocked; if (total <= 0) return 1.0; return Math.Clamp((double)recoveredAfterFailure / total, 0.0, 1.0); } /// 통계 파일 크기 (bytes). 파일 없으면 0. public static long GetFileSize() => File.Exists(StatsFile) ? new FileInfo(StatsFile).Length : 0; /// 모든 통계 데이터를 삭제합니다. public static void Clear() { try { if (File.Exists(StatsFile)) File.Delete(StatsFile); } catch (Exception ex) { LogService.Warn($"통계 삭제 실패: {ex.Message}"); } } }