214 lines
9.6 KiB
C#
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}"); }
|
|
}
|
|
}
|