aiwf
Version:
AI Workflow Framework for Claude Code with multi-language support (Korean/English)
452 lines (407 loc) • 15.7 kB
JavaScript
import fs from 'fs';
import path from 'path';
import { TokenStorage } from './token-storage.js';
import { TokenCounter } from './token-counter.js';
/**
* 토큰 사용량 보고서를 생성하고 포맷팅하는 클래스
*/
export class TokenReporter {
constructor() {
this.storage = new TokenStorage();
this.counter = new TokenCounter();
}
/**
* 프로젝트 전체 토큰 분석 보고서를 생성합니다
* @param {string} projectPath - 프로젝트 경로
* @returns {Object} 프로젝트 토큰 분석 보고서
*/
generateProjectAnalysisReport(projectPath = '.') {
try {
const report = {
generatedAt: new Date().toISOString(),
projectPath,
analysis: {
totalTokens: 0,
fileBreakdown: {},
directoryBreakdown: {},
compressionOpportunities: [],
recommendations: []
}
};
// 주요 AIWF 디렉토리 분석
const directories = [
'.aiwf/01_PROJECT_DOCS',
'.aiwf/02_REQUIREMENTS',
'.aiwf/03_SPRINTS',
'.aiwf/04_GENERAL_TASKS',
'.aiwf/05_ARCHITECTURAL_DECISIONS',
'.aiwf/10_STATE_OF_PROJECT'
];
// 각 디렉토리별 토큰 수 분석
for (const dir of directories) {
const dirPath = path.join(projectPath, dir);
if (fs.existsSync(dirPath)) {
const tokens = this.counter.countDirectoryTokens(dirPath);
report.analysis.directoryBreakdown[dir] = {
tokens,
percentage: 0, // 나중에 계산
files: this.getFileAnalysis(dirPath)
};
report.analysis.totalTokens += tokens;
}
}
// 퍼센티지 계산
Object.keys(report.analysis.directoryBreakdown).forEach(dir => {
const breakdown = report.analysis.directoryBreakdown[dir];
breakdown.percentage = report.analysis.totalTokens > 0
? ((breakdown.tokens / report.analysis.totalTokens) * 100).toFixed(1)
: 0;
});
// 압축 기회 식별
report.analysis.compressionOpportunities = this.identifyCompressionOpportunities(
report.analysis.directoryBreakdown
);
// 권장사항 생성
report.analysis.recommendations = this.generateRecommendations(
report.analysis.totalTokens,
report.analysis.compressionOpportunities
);
return report;
} catch (error) {
console.error('프로젝트 분석 보고서 생성 중 오류 발생:', error);
return null;
}
}
/**
* 디렉토리 내 파일별 토큰 분석을 수행합니다
* @param {string} dirPath - 디렉토리 경로
* @returns {Array<Object>} 파일별 분석 결과
*/
getFileAnalysis(dirPath) {
try {
const files = this.counter.getMarkdownFiles(dirPath);
const analysis = [];
for (const file of files) {
const tokens = this.counter.countFileTokens(file);
const relativePath = path.relative(process.cwd(), file);
analysis.push({
path: relativePath,
tokens,
size: fs.statSync(file).size,
tokensPerByte: tokens > 0 ? (tokens / fs.statSync(file).size).toFixed(3) : 0
});
}
return analysis.sort((a, b) => b.tokens - a.tokens);
} catch (error) {
console.error('파일 분석 중 오류 발생:', error);
return [];
}
}
/**
* 압축 기회를 식별합니다
* @param {Object} directoryBreakdown - 디렉토리별 분석 결과
* @returns {Array<Object>} 압축 기회 목록
*/
identifyCompressionOpportunities(directoryBreakdown) {
const opportunities = [];
try {
// 대용량 디렉토리 식별
const largeDirectories = Object.entries(directoryBreakdown)
.filter(([_, data]) => data.tokens > 5000)
.sort((a, b) => b[1].tokens - a[1].tokens);
for (const [dir, data] of largeDirectories) {
opportunities.push({
type: 'large_directory',
target: dir,
currentTokens: data.tokens,
description: `대용량 디렉토리 (${data.tokens} 토큰)`,
potentialSavings: Math.round(data.tokens * 0.3),
priority: data.tokens > 10000 ? 'high' : 'medium'
});
}
// 반복 콘텐츠 식별
opportunities.push({
type: 'repetitive_content',
target: 'all',
description: '반복되는 템플릿 및 보일러플레이트 콘텐츠',
potentialSavings: Math.round(Object.values(directoryBreakdown).reduce((sum, data) => sum + data.tokens, 0) * 0.15),
priority: 'medium'
});
// 완료된 태스크 압축
if (directoryBreakdown['.aiwf/05_COMPLETED_TASKS']) {
opportunities.push({
type: 'completed_tasks',
target: '.aiwf/05_COMPLETED_TASKS',
currentTokens: directoryBreakdown['.aiwf/05_COMPLETED_TASKS'].tokens,
description: '완료된 태스크 아카이브',
potentialSavings: Math.round(directoryBreakdown['.aiwf/05_COMPLETED_TASKS'].tokens * 0.6),
priority: 'low'
});
}
} catch (error) {
console.error('압축 기회 식별 중 오류 발생:', error);
}
return opportunities.sort((a, b) => {
const priorityOrder = { high: 3, medium: 2, low: 1 };
return priorityOrder[b.priority] - priorityOrder[a.priority];
});
}
/**
* 권장사항을 생성합니다
* @param {number} totalTokens - 총 토큰 수
* @param {Array<Object>} opportunities - 압축 기회 목록
* @returns {Array<Object>} 권장사항 목록
*/
generateRecommendations(totalTokens, opportunities) {
const recommendations = [];
try {
// 총 토큰 수에 따른 권장사항
if (totalTokens > 50000) {
recommendations.push({
type: 'aggressive_compression',
priority: 'high',
title: '적극적 압축 모드 권장',
description: `프로젝트 크기가 ${totalTokens.toLocaleString()} 토큰으로 매우 큽니다. 적극적 압축 모드를 사용하여 50-70% 압축을 권장합니다.`,
action: 'aiwf compress --mode aggressive --level 4'
});
} else if (totalTokens > 20000) {
recommendations.push({
type: 'balanced_compression',
priority: 'medium',
title: '균형 압축 모드 권장',
description: `프로젝트 크기가 ${totalTokens.toLocaleString()} 토큰입니다. 균형 압축 모드를 사용하여 30-50% 압축을 권장합니다.`,
action: 'aiwf compress --mode balanced --level 3'
});
}
// 압축 기회 기반 권장사항
const highPriorityOpportunities = opportunities.filter(op => op.priority === 'high');
if (highPriorityOpportunities.length > 0) {
recommendations.push({
type: 'targeted_compression',
priority: 'high',
title: '특정 디렉토리 압축 권장',
description: `${highPriorityOpportunities.length}개의 대용량 디렉토리가 식별되었습니다.`,
action: 'aiwf compress --target-directories'
});
}
// 정기적 정리 권장사항
recommendations.push({
type: 'regular_cleanup',
priority: 'low',
title: '정기적 프로젝트 정리',
description: '오래된 로그, 완료된 태스크 등을 정기적으로 정리하여 토큰 사용량을 최적화하세요.',
action: 'aiwf cleanup --older-than 30d'
});
// 토큰 모니터링 권장사항
recommendations.push({
type: 'monitoring',
priority: 'medium',
title: '토큰 사용량 모니터링 설정',
description: '토큰 사용량을 실시간으로 모니터링하고 임계값을 설정하세요.',
action: 'aiwf token-monitor --enable --threshold 10000'
});
} catch (error) {
console.error('권장사항 생성 중 오류 발생:', error);
}
return recommendations.sort((a, b) => {
const priorityOrder = { high: 3, medium: 2, low: 1 };
return priorityOrder[b.priority] - priorityOrder[a.priority];
});
}
/**
* 사용량 보고서를 생성합니다
* @param {string} type - 보고서 타입
* @returns {Object} 사용량 보고서
*/
generateUsageReport(type = 'daily') {
try {
const report = this.storage.generateUsageReport(type);
if (!report) {
return null;
}
// 보고서 포맷팅
const formattedReport = {
...report,
formatted: {
summary: this.formatSummary(report.summary),
dailyUsage: this.formatDailyUsage(report.dailyUsage),
topCommands: this.formatTopCommands(report.topCommands),
compressionStats: this.formatCompressionStats(report.compressionStats),
trends: this.formatTrends(report.trends)
}
};
return formattedReport;
} catch (error) {
console.error('사용량 보고서 생성 중 오류 발생:', error);
return null;
}
}
/**
* 요약 정보를 포맷팅합니다
* @param {Object} summary - 요약 정보
* @returns {Object} 포맷팅된 요약 정보
*/
formatSummary(summary) {
return {
totalTokens: summary.totalTokens.toLocaleString(),
totalSessions: summary.totalSessions.toLocaleString(),
avgTokensPerSession: summary.avgTokensPerSession.toLocaleString(),
efficiency: summary.avgTokensPerSession > 1000 ? 'High' :
summary.avgTokensPerSession > 500 ? 'Medium' : 'Low'
};
}
/**
* 일일 사용량을 포맷팅합니다
* @param {Object} dailyUsage - 일일 사용량
* @returns {Array<Object>} 포맷팅된 일일 사용량
*/
formatDailyUsage(dailyUsage) {
return Object.entries(dailyUsage)
.map(([date, usage]) => ({
date,
tokens: usage.tokens.toLocaleString(),
sessions: usage.sessions,
commands: usage.commands,
avgTokensPerSession: usage.sessions > 0 ?
Math.round(usage.tokens / usage.sessions).toLocaleString() : '0'
}))
.sort((a, b) => new Date(b.date) - new Date(a.date));
}
/**
* 상위 명령어를 포맷팅합니다
* @param {Array<Object>} topCommands - 상위 명령어 목록
* @returns {Array<Object>} 포맷팅된 상위 명령어 목록
*/
formatTopCommands(topCommands) {
return topCommands.map(cmd => ({
command: cmd.command,
count: cmd.count.toLocaleString(),
totalTokens: cmd.totalTokens.toLocaleString(),
avgTokens: cmd.avgTokens.toLocaleString(),
efficiency: cmd.avgTokens > 1000 ? 'High' :
cmd.avgTokens > 500 ? 'Medium' : 'Low'
}));
}
/**
* 압축 통계를 포맷팅합니다
* @param {Object} compressionStats - 압축 통계
* @returns {Object} 포맷팅된 압축 통계
*/
formatCompressionStats(compressionStats) {
return {
totalOriginal: compressionStats.totalOriginal.toLocaleString(),
totalCompressed: compressionStats.totalCompressed.toLocaleString(),
totalSaved: compressionStats.totalSaved.toLocaleString(),
avgRatio: compressionStats.avgRatio.toFixed(1) + '%',
efficiency: compressionStats.avgRatio > 50 ? 'High' :
compressionStats.avgRatio > 25 ? 'Medium' : 'Low'
};
}
/**
* 트렌드를 포맷팅합니다
* @param {Object} trends - 트렌드 정보
* @returns {Object} 포맷팅된 트렌드 정보
*/
formatTrends(trends) {
const trendEmoji = {
increasing: '📈',
decreasing: '📉',
stable: '➡️',
insufficient_data: '❓',
error: '❌'
};
return {
trend: trends.trend,
emoji: trendEmoji[trends.trend] || '❓',
change: trends.change > 0 ? `+${trends.change}%` : `${trends.change}%`,
recentAvg: trends.recentAvg?.toLocaleString() || '0',
olderAvg: trends.olderAvg?.toLocaleString() || '0',
description: this.getTrendDescription(trends.trend, trends.change)
};
}
/**
* 트렌드 설명을 생성합니다
* @param {string} trend - 트렌드
* @param {number} change - 변화율
* @returns {string} 트렌드 설명
*/
getTrendDescription(trend, change) {
switch (trend) {
case 'increasing':
return `토큰 사용량이 ${Math.abs(change)}% 증가하고 있습니다`;
case 'decreasing':
return `토큰 사용량이 ${Math.abs(change)}% 감소하고 있습니다`;
case 'stable':
return '토큰 사용량이 안정적입니다';
case 'insufficient_data':
return '분석할 데이터가 부족합니다';
default:
return '트렌드 분석에 오류가 발생했습니다';
}
}
/**
* 보고서를 콘솔에 출력합니다
* @param {Object} report - 보고서 데이터
*/
printReport(report) {
try {
console.log('\n=== 토큰 사용량 보고서 ===');
console.log(`생성일시: ${new Date(report.generatedAt).toLocaleString()}`);
console.log(`보고서 타입: ${report.type}`);
console.log(`기간: ${report.period.start} ~ ${report.period.end}`);
if (report.formatted) {
console.log('\n--- 요약 ---');
console.log(`총 토큰: ${report.formatted.summary.totalTokens}`);
console.log(`총 세션: ${report.formatted.summary.totalSessions}`);
console.log(`세션당 평균 토큰: ${report.formatted.summary.avgTokensPerSession}`);
console.log(`효율성: ${report.formatted.summary.efficiency}`);
console.log('\n--- 트렌드 ---');
console.log(`${report.formatted.trends.emoji} ${report.formatted.trends.description}`);
console.log(`변화율: ${report.formatted.trends.change}`);
if (report.formatted.topCommands.length > 0) {
console.log('\n--- 상위 명령어 ---');
report.formatted.topCommands.slice(0, 5).forEach((cmd, idx) => {
console.log(`${idx + 1}. ${cmd.command}: ${cmd.totalTokens} 토큰 (${cmd.count}회)`);
});
}
if (report.formatted.compressionStats.totalOriginal > 0) {
console.log('\n--- 압축 통계 ---');
console.log(`원본: ${report.formatted.compressionStats.totalOriginal} 토큰`);
console.log(`압축: ${report.formatted.compressionStats.totalCompressed} 토큰`);
console.log(`절약: ${report.formatted.compressionStats.totalSaved} 토큰`);
console.log(`압축률: ${report.formatted.compressionStats.avgRatio}`);
}
}
console.log('\n========================\n');
} catch (error) {
console.error('보고서 출력 중 오류 발생:', error);
}
}
/**
* 보고서를 파일로 저장합니다
* @param {Object} report - 보고서 데이터
* @param {string} filename - 파일명
*/
saveReportToFile(report, filename) {
try {
const reportDir = '.aiwf/token-data/reports';
if (!fs.existsSync(reportDir)) {
fs.mkdirSync(reportDir, { recursive: true });
}
const filepath = path.join(reportDir, filename);
fs.writeFileSync(filepath, JSON.stringify(report, null, 2));
console.log(`보고서가 ${filepath}에 저장되었습니다.`);
} catch (error) {
console.error('보고서 저장 중 오류 발생:', error);
}
}
/**
* 리소스를 정리합니다
*/
cleanup() {
if (this.counter) {
this.counter.cleanup();
}
}
}
export default TokenReporter;