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}"); }
}
}