Files
AX-Copilot-Codex/src/AxCopilot/Services/AgentStatsService.cs

214 lines
9.6 KiB
C#

using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace AxCopilot.Services;
/// <summary>
/// 에이전트 실행 통계를 로컬 JSON 파일에 기록/집계합니다.
/// %APPDATA%\AxCopilot\stats\agent_stats.json
/// </summary>
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<string> UsedTools { get; init; } = new();
}
/// <summary>집계 결과.</summary>
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<string, int> ToolFrequency { get; init; } = new();
public Dictionary<string, int> ModelBreakdown { get; init; } = new();
public Dictionary<string, int> TabBreakdown { get; init; } = new();
public Dictionary<string, int> TaskTypeBreakdown { get; init; } = new();
public Dictionary<string, double> RetryQualityByTaskType { get; init; } = new();
/// <summary>일별 세션 수 (날짜 키 "yyyy-MM-dd").</summary>
public Dictionary<string, int> DailySessions { get; init; } = new();
/// <summary>일별 토큰 수 (날짜 키 "yyyy-MM-dd").</summary>
public Dictionary<string, int> DailyTokens { get; init; } = new();
}
// ─── 기록 ─────────────────────────────────────────────────────────────
/// <summary>에이전트 세션 결과를 기록합니다. 비동기 fire-and-forget.</summary>
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}");
}
});
}
// ─── 집계 ─────────────────────────────────────────────────────────────
/// <summary>지정 일수 이내의 통계를 집계합니다. days=0이면 전체.</summary>
public static AgentStatsSummary Aggregate(int days = 0)
{
var records = LoadRecords(days);
return BuildSummary(records);
}
/// <summary>통계 파일에서 레코드를 읽어옵니다.</summary>
public static List<AgentSessionRecord> LoadRecords(int days = 0)
{
if (!File.Exists(StatsFile)) return new();
var cutoff = days > 0 ? DateTime.Now.AddDays(-days) : DateTime.MinValue;
var result = new List<AgentSessionRecord>();
foreach (var line in File.ReadLines(StatsFile))
{
if (string.IsNullOrWhiteSpace(line)) continue;
try
{
var rec = JsonSerializer.Deserialize<AgentSessionRecord>(line, _jsonOpts);
if (rec != null && rec.Timestamp >= cutoff)
result.Add(rec);
}
catch { /* 손상된 줄 건너뜀 */ }
}
return result;
}
private static AgentStatsSummary BuildSummary(List<AgentSessionRecord> records)
{
var toolFreq = new Dictionary<string, int>();
var modelBreak = new Dictionary<string, int>();
var tabBreak = new Dictionary<string, int>();
var taskBreak = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var retryRecoveredByTask = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var retryBlockedByTask = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var dailySess = new Dictionary<string, int>();
var dailyTok = new Dictionary<string, int>();
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);
}
/// <summary>통계 파일 크기 (bytes). 파일 없으면 0.</summary>
public static long GetFileSize() =>
File.Exists(StatsFile) ? new FileInfo(StatsFile).Length : 0;
/// <summary>모든 통계 데이터를 삭제합니다.</summary>
public static void Clear()
{
try { if (File.Exists(StatsFile)) File.Delete(StatsFile); }
catch (Exception ex) { LogService.Warn($"통계 삭제 실패: {ex.Message}"); }
}
}