Initial commit to new repository
This commit is contained in:
213
src/AxCopilot/Services/AgentStatsService.cs
Normal file
213
src/AxCopilot/Services/AgentStatsService.cs
Normal file
@@ -0,0 +1,213 @@
|
||||
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}"); }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user