feat: 세이버메트릭스 기반 프로젝트 자산 가치 분석 시스템 고도화 (AVI/VCI 도입)

- analysis_service.py: AVI 및 VCI(자산 기여도) 산출 로직 구현
- prediction_service.py: 정체 프로젝트 AI 예보 최적화
- js/analysis.js: 전략 매트릭스 및 VCI 등급 시스템 시각화
- templates/analysis.html: UI 용어 최신화 및 스타일 통합
- ANALYSIS_REPORT.md: 분석 지표 공식 및 가이드라인 상세 기술
This commit is contained in:
2026-03-24 17:54:01 +09:00
parent be3210463f
commit dff3305da1
11 changed files with 481 additions and 322 deletions

BIN
.gitignore vendored

Binary file not shown.

View File

@@ -1,49 +1,76 @@
# 📊 Project Master Sabermetrics 분석 엔진 리포트 # 📊 시스템 운영 자산 가치 분석 보고서 (Sabermetrics Edition)
## 1. 개요 (Vision) 본 보고서는 야구의 통계 분석 기법인 **세이버메트릭스(Sabermetrics)**를 프로젝트 관리 시스템에 이식하여, 단순 활동량 측정을 넘어 **'실질적 자산 가치'**와 **'미래 운영 위험'**을 정밀 분석한 결과입니다.
본 시스템은 방대한 프로젝트 운영 데이터(파일 수, 활동 로그, 조직 정보)를 기반으로 **AI 기반 프로젝트 건강도(P-SOI)**를 산출합니다. 단순히 "살아있는가"를 넘어, "실무적으로 가치 있게 관리되고 있는가"를 정밀 진단하는 것이 목적입니다.
--- ---
## 2. P-SOI 산출 로직 (The Formula) ## 1. 핵심 분석 지표 정의 (Core Metrics)
### 2.1 기초 모델: 지수 감쇄 (Exponential Decay) ### 1.1 운영 활력 지수 (AVI, Activity Vitality Index)
프로젝트 정보의 가치는 관리 활동이 멈춘 시점부터 시간이 흐를수록 급격히 하락합니다. 프로젝트가 현재 얼마나 '살아서 숨 쉬고 있는가'를 나타내는 생존 지수입니다.
- **수식**: $SOI = 100 \times e^{-\lambda t}$
- **의미**: 14일 방치 시 가치가 약 50% 소실되는 현장 현실을 반영합니다.
### 2.2 고도화 1: AAS (AI-Hazard Adaptive SOI) * **산출 공식**: $AVI = exp(-\lambda \times days) \times Quality \times 100$
프로젝트의 중요도와 주변 환경에 따라 하락 곡선의 기울기를 동적으로 조정합니다. * **핵심 데이터**:
- **자산 규모 영향**: 파일 수가 많을수록 관리 부재 리스크가 크므로 AI가 하락 속도를 가속시킵니다. * **정체 일수(days)**: 마지막 유의미한 파일 업데이트 이후 경과 시간.
- **조직 위험 전염**: 소속 부서나 담당자의 전체 SOI가 낮을 경우, 시스템적 붕괴 리스크 가중치를 부여합니다. * **감쇄 계수($\lambda$)**: 자산 규모(파일 수)가 클수록, 소속 부서의 방치율이 높을수록 커지며 점수를 더 빠르게 하락시킵니다.
* **활동 품질(Quality)**: 단순 시스템 로그(단순 접속, 설정 변경)는 낮게 평가하고, 실질적인 파일 증분 활동에 가점을 부여합니다.
* **의미**: 100%에 가까울수록 실시간 가동 상태이며, 0%에 가까울수록 데이터 노후화가 완료된 '사망' 상태를 뜻합니다.
### 2.3 고도화 2: ECV (Existence-Conditioned Vitality) ### 1.2 자산 가치 기여도 (VCI, Value Contribution Index)
'빈 껍데기' 활동을 걸러내는 존재론적 패널티입니다. 시스템 전체의 운영 표준 대비, 해당 프로젝트가 기여하고 있는 가치의 상대적 하중을 측정합니다.
- **유령 프로젝트**: 파일 수가 0개인 경우, 최근 로그와 관계없이 SOI 점수를 **5% 미만**으로 강제 고정합니다.
- **신뢰 보정**: 파일 10개 미만의 소규모 프로젝트는 활동 신뢰도를 40% 수준으로 제한합니다.
### 2.4 고도화 3: 로그 품질 및 실무 투입 분석 * **산출 공식**: $VCI = (AVI - 70.0) \times (\frac{Files}{200} + 0.5)$
- **Log Quality**: 로그 텍스트를 분석하여 [실무 활동(1.0), 관리 활동(0.7), 행정 활동(0.4)] 가중치를 부여합니다. * **핵심 로직**:
- **Work Effort**: 최근 30개 히스토리 중 실제 **파일 증감**이 발생한 날의 비율을 계산하여 실질 공수 투입률을 산출합니다. * **건강 기준선(70.0%)**: 시스템 자산 가치를 유지하기 위한 최소 마지노선(Replacement Level)입니다.
* **규모 가중치**: 파일 수가 많은 대형 프로젝트일수록 동일한 방치 상황에서 시스템에 주는 충격(음수값)이 기하급수적으로 커집니다.
* **의미**: 양수(+)는 가치 창출, 음수(-)는 시스템 기회비용을 갉아먹는 '가치 파괴' 상태임을 나타냅니다.
### 1.3 업무 집중도 (Job Focus)
단순 관리 행위를 제외하고, 실제 성과물(파일)을 생산하는 데 얼마나 몰입했는지를 판별합니다.
* **산출 공식**: $Job Focus = \frac{\text{최근 30회 중 실질 파일 변동 발생 횟수}}{\text{전체 데이터 수집 횟수}} \times 100$
* **의미**: 로그만 남기는 '보여주기식 활동'을 필터링하여 운영의 진정성을 확인합니다.
--- ---
## 3. 전략적 분석 도구 (Visualization) ## 2. 등급 체계 및 관리 가이드 (Grade System)
### 3.1 프로젝트 SWOT 매트릭스 ### 2.1 VCI 등급 (프로젝트 위상)
X축(자산 규모)과 Y축(활동성)을 결합하여 4가지 국면으로 프로젝트를 진단합니다. | 등급 (Grade) | 점수 기준 | 운영 의미 및 관리 전략 |
1. **핵심 우량 (Strategic)**: 대규모 핵심 자산이며 활발히 관리 중. | :--- | :--- | :--- |
2. **활동 양호 (Agile)**: 규모는 작으나 매우 탄력적으로 업데이트 중. | **Masterpiece** | +10.0 이상 | **핵심 자산 (MVP)**: 시스템 가치를 견인하는 최우량 프로젝트 |
3. **방치/소규모**: 중요도가 낮고 방치된 상태. | **Blue Chip** | +2.0 ~ +10.0 | **우량 자산 (주전)**: 꾸준한 활력으로 가치를 창출하는 핵심군 |
4. **관리 사각지대 (Critical Risk)**: **자산 규모는 크나 장기 방치됨 (최우선 관리 대상)** | **Steady** | -2.0 ~ +2.0 | **현상 유지 (보결)**: 표준 수준의 운영을 유지 중인 안정군 |
| **Underperform** | -10.0 ~ -2.0 | **저성과 (마이너)**: 규모 대비 활력이 부족하여 리소스 투입 필요 |
| **Liability** | -10.0 이하 | **가치 파괴 (방출)**: 시스템 가치를 훼손 중인 좀비 프로젝트. 타절 검토 시급 |
### 3.2 AI 진단 아코디언 리포트 ### 2.2 상태 예보 (AI Forecast)
사용자가 지수 산출 결과에 납득할 수 있도록, 개별 프로젝트 행 클릭 시 **4단계 AI 추론 과정**을 실시간 리포트로 제공합니다. 최근 활동의 **가속도(Acceleration)**와 **관성(Momentum)**을 AI가 분석한 14일 뒤 전망입니다.
* **성장 가속 (Bullish)**: 활동 에너지가 증가 추세이며 가치가 오를 전망.
* **안정 유지 (Neutral)**: 현재의 안정적인 운영 리듬을 지속할 전망.
* **활력 저하 (Bearish)**: 정체 징후 포착. 단기 내 가동률 하락 예상.
* **중단 위기 (Warning)**: 급격한 활동 저하로 인한 자산 소멸 위험 노출.
--- ---
## 4. 향후 딥러닝 로드맵 (Evolution) ## 3. 데이터 분석 프로세스 (Analysis Process)
데이터가 누적됨에 따라 다음과 같은 자가 학습형 엔진으로 진화합니다.
- **LSTM 기반 리듬 학습**: 각 프로젝트의 고유한 업데이트 주기와 패턴(Life Rhythm)을 인코딩하여 맞춤형 예보 수행. 1. **데이터 수집**: `projects_history` 테이블로부터 일별 파일 수 및 로그 텍스트를 추출합니다.
- **NLP 임베딩**: 단순 키워드를 넘어 로그 텍스트의 맥락적 의미를 딥러닝이 스스로 학습하여 가중치 자동 산정. 2. **피처 추출**:
- **병목 예측 AI**: 특정 담당자나 부서의 업무 과부하 패턴을 학습하여 집단 방치 위험을 선제적으로 예보. * **Velocity**: 파일 수의 변화 속도 계산.
* **Acceleration**: 활동의 가속/감속 여부 판별.
* **Stagnation**: 마지막 활동 이후의 공백 기간 측정.
3. **AI 시뮬레이션**: 추출된 피처를 AAS(AI 위험 적응형 모델)에 입력하여 개별 프로젝트만의 **'위험 곡선'**을 생성합니다.
4. **최종 판정**: AVI와 VCI를 결합하여 리더보드에 등급과 관리 가이드라인을 송출합니다.
---
## 4. 관리자 제언 (Action Plan)
* **VCI 음수 프로젝트 집중 관리**: 단순 활동량이 아닌 VCI가 낮은 대형 프로젝트부터 우선적으로 인력을 배치하거나 운영 정책을 재점검해야 합니다.
* **AI Forecast 활용**: '활력 저하' 예보가 뜬 프로젝트는 실제 AVI가 급락하기 전 선제적인 조치(업무 독려, 파일 현행화)를 취할 수 있습니다.
* **파일 수와 활력의 균형**: 파일 수가 많은데 활력(AVI)이 낮은 경우, 시스템 전체의 데이터 무결성을 해칠 수 있으므로 데이터 클렌징이나 아카이빙을 권고합니다.
---
*본 분석 엔진은 Project Master Sabermetrics 알고리즘에 의해 자동 생성되었습니다.*

View File

@@ -170,11 +170,17 @@ class AnalysisService:
total_soi += soi_score total_soi += soi_score
# [최종 세이버메트릭스 보정: P-WAR+ (Adjusted Score)]
# 절대 기준선(Replacement Level): 70.0% (이 이하는 자산 가치 파괴로 간주)
REPLACEMENT_LEVEL = 70.0
asset_weight = (file_count / 200.0) + 0.5 # 파일 100개당 0.5배 가중 (최소 0.5배)
p_war_score = (soi_score - REPLACEMENT_LEVEL) * asset_weight
results.append({ results.append({
"project_nm": p['short_nm'] or p['project_nm'], "project_nm": p['short_nm'] or p['project_nm'],
"file_count": file_count, "file_count": file_count,
"days_stagnant": days_stagnant, "days_stagnant": days_stagnant,
"risk_count": 0, "risk_count": round(p_war_score, 2), # P-WAR+ 절대 기여도 점수 (평균의 함정 극복용)
"p_war": round(soi_score, 1), "p_war": round(soi_score, 1),
"predicted_soi": predicted_soi, "predicted_soi": predicted_soi,
"is_auto_delete": is_auto_delete, "is_auto_delete": is_auto_delete,

View File

@@ -1,6 +1,6 @@
/** /**
* Project Master Analysis JS * Project Master Analysis JS
* P-WAR (Project Performance Above Replacement) 분석 엔진 * AVI (Activity Vitality Index) & VCI (Value Contribution Index) 분석 엔진
*/ */
// Chart.js 플러그인 전역 등록 // Chart.js 플러그인 전역 등록
@@ -9,40 +9,49 @@ if (typeof ChartDataLabels !== 'undefined') {
} }
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
console.log("Analysis engine initialized..."); console.log("Business Analysis Engine initialized...");
loadPWarData(); loadProjectAnalysisData();
}); });
async function loadPWarData() { async function loadProjectAnalysisData() {
try { try {
const response = await fetch('/api/analysis/p-war'); const response = await fetch('/api/analysis/p-war');
const data = await response.json(); const data = await response.json();
if (data.error) throw new Error(data.error); if (data.error) throw new Error(data.error);
renderPWarLeaderboard(data); renderVitalityLeaderboard(data);
renderSOICharts(data); renderValueCharts(data);
if (data.length > 0 && data[0].avg_info) { if (data.length > 0 && data[0].avg_info) {
const avg = data[0].avg_info; const avg = data[0].avg_info;
const infoEl = document.getElementById('avg-system-info'); const infoEl = document.getElementById('avg-system-info');
if (infoEl) infoEl.textContent = `* 시스템 종합 건강도: ${avg.avg_risk}% (0.0%에 가까울수록 방치 심각)`; if (infoEl) infoEl.textContent = `* 시스템 종합 자산 건전도: ${avg.avg_risk}% (운영 표준 70.0% 대비)`;
} }
} catch (e) { } catch (e) {
console.error("분석 데이터 로딩 실패:", e); console.error("분석 데이터 로딩 실패:", e);
} }
} }
function getStatusInfo(soi, isAutoDelete) { function getStatusInfo(avi, isAutoDelete) {
if (isAutoDelete || soi < 10) return { label: '사망', class: 'badge-system', key: 'dead' }; if (isAutoDelete || avi < 10) return { label: '중단/방치', class: 'badge-system', key: 'dead' };
if (soi < 30) return { label: '위험', class: 'badge-danger', key: 'danger' }; if (avi < 30) return { label: '위험 노출', class: 'badge-danger', key: 'danger' };
if (soi < 70) return { label: '주의', class: 'badge-warning', key: 'warning' }; if (avi < 70) return { label: '관리 주의', class: 'badge-warning', key: 'warning' };
return { label: '정상', class: 'badge-active', key: 'active' }; return { label: '정상 운영', class: 'badge-active', key: 'active' };
} }
function renderSOICharts(data) { // VCI 등급 판정 로직 (Sabermetrics WAR 등급 기준 응용)
function getVciGrade(vci) {
if (vci >= 10) return { label: 'Masterpiece', class: 'grade-mvp', desc: '시스템 가치를 견인하는 핵심 자산 (MVP급)' };
if (vci >= 2) return { label: 'Blue Chip', class: 'grade-allstar', desc: '꾸준한 활력의 우량 자산 (주전급)' };
if (vci >= -2) return { label: 'Steady', class: 'grade-starter', desc: '표준 수준의 현상 유지 (보결급)' };
if (vci >= -10) return { label: 'Underperform', class: 'grade-bench', desc: '운영 미비로 인한 가치 하락 (마이너급)' };
return { label: 'Liability', class: 'grade-out', desc: '가치를 훼손 중인 방치 자산 (방출급)' };
}
function renderValueCharts(data) {
if (!data || data.length === 0) return; if (!data || data.length === 0) return;
// 1. 상태 분포 (Doughnut) // 1. 운영 활력 분포 (Doughnut)
try { try {
const stats = { active: [], warning: [], danger: [], dead: [] }; const stats = { active: [], warning: [], danger: [], dead: [] };
data.forEach(p => { data.forEach(p => {
@@ -56,7 +65,7 @@ function renderSOICharts(data) {
window.myStatusChart = new Chart(statusCtx, { window.myStatusChart = new Chart(statusCtx, {
type: 'doughnut', type: 'doughnut',
data: { data: {
labels: ['정상', '주의', '위험', '사망'], labels: ['정상 운영', '관리 주의', '위험 노출', '중단/방치'],
datasets: [{ datasets: [{
data: [stats.active.length, stats.warning.length, stats.danger.length, stats.dead.length], data: [stats.active.length, stats.warning.length, stats.danger.length, stats.dead.length],
backgroundColor: ['#1E5149', '#22c55e', '#f59e0b', '#ef4444'], backgroundColor: ['#1E5149', '#22c55e', '#f59e0b', '#ef4444'],
@@ -75,53 +84,40 @@ function renderSOICharts(data) {
onClick: (e, elements) => { onClick: (e, elements) => {
if (elements.length > 0) { if (elements.length > 0) {
const idx = elements[0].index; const idx = elements[0].index;
openProjectListModal(['정상', '주의', '위험', '사망'][idx], stats[['active', 'warning', 'danger', 'dead'][idx]]); openProjectListModal(['정상 운영', '관리 주의', '위험 노출', '중단/방치'][idx], stats[['active', 'warning', 'danger', 'dead'][idx]]);
} }
} }
} }
}); });
} catch (err) { console.error("도넛 차트 에러:", err); } } catch (err) { console.error("도넛 차트 에러:", err); }
// 2. 프로젝트 SWOT 매트릭스 (Scatter) // 2. 전략적 자산 매트릭스 (Scatter)
try { try {
const scatterData = data.map(p => ({ const sortedByAVI = [...data].sort((a, b) => b.p_war - a.p_war);
x: Math.min(500, p.file_count), const top5Ids = sortedByAVI.slice(0, 5).map(p => p.project_nm);
y: p.p_war, const bottom5Ids = sortedByAVI.slice(-5).map(p => p.project_nm);
label: p.project_nm const largeProjects = data.filter(p => p.file_count > 450).map(p => p.project_nm);
}));
const vipProjectNames = new Set([...top5Ids, ...bottom5Ids, ...largeProjects]);
const scatterData = data.map(p => {
const vci = p.risk_count || 0;
const absVci = Math.abs(vci);
return {
x: Math.min(500, p.file_count),
y: p.p_war,
label: p.project_nm,
isVip: vipProjectNames.has(p.project_nm),
vci: vci,
radius: Math.max(5, Math.min(25, 5 + (absVci / 10)))
};
});
const vitalityCtx = document.getElementById('forecastChart').getContext('2d'); const vitalityCtx = document.getElementById('forecastChart').getContext('2d');
if (window.myVitalityChart) window.myVitalityChart.destroy(); if (window.myVitalityChart) window.myVitalityChart.destroy();
// 플러그인 통합 (Duplicate Key 방지)
const chartPlugins = [];
if (typeof ChartDataLabels !== 'undefined') chartPlugins.push(ChartDataLabels);
chartPlugins.push({
id: 'quadrants',
beforeDraw: (chart) => {
const { ctx, chartArea: { left, top, right, bottom }, scales: { x, y } } = chart;
const midX = x.getPixelForValue(250);
const midY = y.getPixelForValue(50);
ctx.save();
ctx.fillStyle = 'rgba(34, 197, 94, 0.03)'; ctx.fillRect(left, top, midX - left, midY - top);
ctx.fillStyle = 'rgba(30, 81, 73, 0.03)'; ctx.fillRect(midX, top, right - midX, midY - top);
ctx.fillStyle = 'rgba(148, 163, 184, 0.03)'; ctx.fillRect(left, midY, midX - left, bottom - midY);
ctx.fillStyle = 'rgba(239, 68, 68, 0.05)'; ctx.fillRect(midX, midY, right - midX, bottom - midY);
ctx.lineWidth = 2; ctx.strokeStyle = 'rgba(0,0,0,0.1)'; ctx.beginPath();
ctx.moveTo(midX, top); ctx.lineTo(midX, bottom); ctx.moveTo(left, midY); ctx.lineTo(right, midY); ctx.stroke();
ctx.font = 'bold 12px Pretendard'; ctx.textAlign = 'center'; ctx.fillStyle = 'rgba(0,0,0,0.2)';
ctx.fillText('활동 양호', (left + midX) / 2, (top + midY) / 2);
ctx.fillText('핵심 우량', (midX + right) / 2, (top + midY) / 2);
ctx.fillText('방치/소규모', (left + midX) / 2, (midY + bottom) / 2);
ctx.fillStyle = 'rgba(239, 68, 68, 0.4)'; ctx.fillText('관리 사각지대', (midX + right) / 2, (midY + bottom) / 2);
ctx.restore();
}
});
window.myVitalityChart = new Chart(vitalityCtx, { window.myVitalityChart = new Chart(vitalityCtx, {
type: 'scatter', type: 'scatter',
plugins: chartPlugins,
data: { data: {
datasets: [{ datasets: [{
data: scatterData, data: scatterData,
@@ -133,45 +129,73 @@ function renderSOICharts(data) {
if (p.x < 250 && p.y < 50) return '#94a3b8'; if (p.x < 250 && p.y < 50) return '#94a3b8';
return '#ef4444'; return '#ef4444';
}, },
pointRadius: 6, pointRadius: (ctx) => ctx.raw ? ctx.raw.radius : 5,
hoverRadius: 10 hoverRadius: (ctx) => (ctx.raw ? ctx.raw.radius : 5) + 3
}] }]
}, },
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
layout: { padding: { top: 30, right: 40, left: 10, bottom: 10 } }, layout: { padding: { top: 30, right: 45, left: 10, bottom: 10 } },
scales: { scales: {
x: { x: {
type: 'linear', min: 0, max: 500, type: 'linear', min: 0, max: 500,
title: { display: true, text: '자산 규모 (파일 수)', font: { size: 11, weight: '700' } }, title: { display: true, text: '파일 수 (Files)', font: { size: 11, weight: '700' } },
grid: { display: false }, grid: { display: false },
ticks: { stepSize: 125, callback: (v) => v >= 500 ? '500+' : v } ticks: { stepSize: 125, callback: (v) => v >= 500 ? '500+' : v }
}, },
y: { y: {
min: 0, max: 100, min: 0, max: 100,
title: { display: true, text: '활동성 (SOI %)', font: { size: 11, weight: '700' } }, title: { display: true, text: '운영 활력 (AVI %)', font: { size: 11, weight: '700' } },
grid: { display: false } grid: { display: false }
} }
}, },
plugins: { plugins: {
legend: { display: false }, legend: { display: false },
datalabels: { datalabels: {
align: 'top', offset: 5, font: { size: 10, weight: '700' }, color: '#475569', backgroundColor: 'rgba(255, 255, 255, 0.8)',
formatter: (v) => v.label, borderRadius: 4, padding: 4,
display: (ctx) => ctx.raw.x > 100 || ctx.raw.y < 30, align: (ctx) => (ctx.raw && ctx.raw.y > 80 ? 'bottom' : 'top'),
offset: (ctx) => (ctx.raw ? ctx.raw.radius : 5) + 2,
font: { size: 10, weight: '800' },
color: '#1e293b',
formatter: (v) => v ? v.label : '',
display: (ctx) => ctx.raw && ctx.raw.isVip,
clip: false clip: false
}, },
tooltip: { tooltip: {
callbacks: { label: (ctx) => ` [${ctx.raw.label}] SOI: ${ctx.raw.y.toFixed(1)}% | 파일: ${ctx.raw.x >= 500 ? '500+' : ctx.raw.x}` } callbacks: {
label: (ctx) => ` [${ctx.raw.label}] 활력(AVI): ${ctx.raw.y.toFixed(1)}% | 가치 기여(VCI): ${ctx.raw.vci.toFixed(1)}`
}
} }
} }
} },
plugins: [{
id: 'quadrants',
beforeDraw: (chart) => {
const { ctx, chartArea: { left, top, right, bottom }, scales: { x, y } } = chart;
const midX = x.getPixelForValue(250);
const midY = y.getPixelForValue(50);
ctx.save();
ctx.fillStyle = 'rgba(34, 197, 94, 0.03)'; ctx.fillRect(left, top, midX - left, midY - top);
ctx.fillStyle = 'rgba(30, 81, 73, 0.03)'; ctx.fillRect(midX, top, right - midX, midY - top);
ctx.fillStyle = 'rgba(148, 163, 184, 0.03)'; ctx.fillRect(left, midY, midX - left, bottom - midY);
ctx.fillStyle = 'rgba(239, 68, 68, 0.05)'; ctx.fillRect(midX, midY, right - midX, bottom - midY);
ctx.lineWidth = 2; ctx.strokeStyle = 'rgba(0,0,0,0.1)'; ctx.beginPath();
ctx.moveTo(midX, top); ctx.lineTo(midX, bottom); ctx.moveTo(left, midY); ctx.lineTo(right, midY); ctx.stroke();
ctx.font = 'bold 12px Pretendard'; ctx.textAlign = 'center'; ctx.fillStyle = 'rgba(0,0,0,0.2)';
ctx.fillText('활력 양호', (left + midX) / 2, (top + midY) / 2);
ctx.fillText('핵심 가치', (midX + right) / 2, (top + midY) / 2);
ctx.fillText('정체/소규모', (left + midX) / 2, (midY + bottom) / 2);
ctx.fillStyle = 'rgba(239, 68, 68, 0.4)'; ctx.fillText('자산 손실 위험', (midX + right) / 2, (midY + bottom) / 2);
ctx.restore();
}
}]
}); });
} catch (err) { console.error("SWOT 차트 에러:", err); } } catch (err) { console.error("전략 매트릭스 에러:", err); }
} }
function renderPWarLeaderboard(data) { function renderVitalityLeaderboard(data) {
const container = document.getElementById('p-war-table-container'); const container = document.getElementById('p-war-table-container');
if (!container) return; if (!container) return;
const sortedData = [...data].sort((a, b) => a.p_war - b.p_war); const sortedData = [...data].sort((a, b) => a.p_war - b.p_war);
@@ -181,29 +205,34 @@ function renderPWarLeaderboard(data) {
<table class="data-table p-war-table"> <table class="data-table p-war-table">
<thead> <thead>
<tr> <tr>
<th style="width: 280px;">프로젝트명</th> <th style="width: 250px;">프로젝트명</th>
<th>파일 수</th> <th>파일 수</th>
<th>방치일</th> <th>정체 일수</th>
<th>상태 판정</th> <th>상태 판정</th>
<th>현재 SOI <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('soi')">?</button></th> <th style="text-align:right;">가치 기여 (VCI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('vci')">?</button></th>
<th style="text-align:center;">실무 투입</th> <th>활력 지수 (AVI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('avi')">?</button></th>
<th>AI 예보 (14d) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('ai')">?</button></th> <th style="text-align:center;">업무 집중도 <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('focus')">?</button></th>
<th>상태 예보 (14d) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('ai')">?</button></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
${sortedData.map((p, idx) => { ${sortedData.map((p, idx) => {
const status = getStatusInfo(p.p_war, p.is_auto_delete); const status = getStatusInfo(p.p_war, p.is_auto_delete);
const rowId = `project-${idx}`; const rowId = `project-${idx}`;
const ecvText = p.file_count === 0 ? "5% (유령)" : p.file_count < 10 ? "40% (껍데기)" : "100% (신뢰)"; const vci = p.risk_count || 0;
const ecvClass = (p.file_count < 10) ? "highlight-penalty" : "highlight-val"; const avi = p.p_war || 0;
const grade = getVciGrade(vci);
return ` return `
<tr class="project-row ${status.key === 'danger' ? 'row-danger' : status.key === 'warning' ? 'row-warning' : ''}" onclick="toggleProjectDetail('${rowId}')"> <tr class="project-row ${status.key === 'danger' ? 'row-danger' : status.key === 'warning' ? 'row-warning' : ''}" onclick="toggleProjectDetail('${rowId}')">
<td class="font-bold">${p.project_nm}</td> <td class="font-bold">${p.project_nm}</td>
<td>${p.file_count.toLocaleString()}개</td> <td>${p.file_count.toLocaleString()}개</td>
<td>${p.days_stagnant}일</td> <td>${p.days_stagnant}일</td>
<td><span class="${status.class}">${status.label}</span></td> <td><span class="${status.class}">${status.label === '사망' ? '중단' : status.label}</span></td>
<td class="p-war-value ${p.p_war >= 70 ? 'text-plus' : 'text-minus'}">${p.p_war.toFixed(1)}%</td> <td style="text-align:right; font-weight:800; color:${vci >= 0 ? '#059669' : '#dc2626'};">
${vci > 0 ? '+' : ''}${vci.toFixed(1)}
</td>
<td class="p-war-value ${avi >= 70 ? 'text-plus' : 'text-minus'}">${avi.toFixed(1)}%</td>
<td style="text-align:center;"> <td style="text-align:center;">
<div style="display:flex; flex-direction:column; align-items:center; gap:4px;"> <div style="display:flex; flex-direction:column; align-items:center; gap:4px;">
<span style="font-weight:800; font-size:12px; color:${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};">${p.work_effort}%</span> <span style="font-weight:800; font-size:12px; color:${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};">${p.work_effort}%</span>
@@ -215,52 +244,60 @@ function renderPWarLeaderboard(data) {
<td style="text-align:center; font-weight:700; color:#6366f1;">${p.predicted_soi !== null ? p.predicted_soi.toFixed(1) + '%' : '-'}</td> <td style="text-align:center; font-weight:700; color:#6366f1;">${p.predicted_soi !== null ? p.predicted_soi.toFixed(1) + '%' : '-'}</td>
</tr> </tr>
<tr id="detail-${rowId}" class="detail-row"> <tr id="detail-${rowId}" class="detail-row">
<td colspan="7"> <td colspan="8">
<div class="detail-container"> <div class="detail-container">
<div class="formula-explanation-card"> <div class="formula-explanation-card">
<div class="formula-header">⚙️ AI 위험 적응형 모델(AAS) 산출 시뮬레이션</div> <div class="formula-header">⚙️ AI 자산 건전성 분석 시뮬레이션 (AAS Metrics)</div>
<div class="work-effort-section">
<div class="work-effort-header"> <div style="display: flex; gap: 20px; margin-bottom: 20px;">
<span style="font-size: 13px; font-weight: 800; color: #1e5149;">📊 실질 업무 활성화 분석 (Work Vitality)</span> <div class="work-effort-section" style="flex: 1; margin-bottom: 0;">
<span style="font-size: 14px; font-weight: 800; color: ${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};">투입률 ${p.work_effort}%</span> <div class="work-effort-header">
<span style="font-size: 13px; font-weight: 800; color: #1e5149;">📊 실질 업무 집중도 Analysis</span>
<span style="font-size: 14px; font-weight: 800; color: ${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};">${p.work_effort}%</span>
</div>
<div class="work-effort-bar-bg"><div style="width: ${p.work_effort}%; height: 100%; background: ${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};"></div></div>
<div style="font-size: 11px; color: #64748b; line-height: 1.5;">최근 수집 로그 중 실질적 <b>자산 증분</b>이 포착된 밀도입니다.</div>
</div>
<div style="flex: 1; background: #f8fafc; border-radius: 8px; padding: 16px; border: 1px solid #e2e8f0; display: flex; align-items: center; gap: 15px;">
<div style="text-align: center;">
<div style="font-size: 10px; color: #64748b; font-weight: 700; margin-bottom: 2px;">VCI GRADE</div>
<div class="grade-badge ${grade.class}" style="padding: 4px 12px; border-radius: 6px; font-weight: 900; font-size: 14px; display: inline-block;">${grade.label}</div>
</div>
<div style="font-size: 12px; color: #475569; line-height: 1.4; font-weight: 600;">${grade.desc}</div>
</div> </div>
<div class="work-effort-bar-bg"><div style="width: ${p.work_effort}%; height: 100%; background: ${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'}; transition: width 0.5s;"></div></div>
<div style="font-size: 11.5px; color: #64748b; line-height: 1.5;">최근 30회 중 실제 파일 변동이 포착된 날의 비율입니다. 현재 <b>${p.work_effort >= 70 ? '매우 활발' : p.work_effort <= 30 ? '정체' : '간헐적'}</b> 상태입니다.</div>
</div> </div>
<div class="formula-steps-grid"> <div class="formula-steps-grid">
<div class="formula-step"> <div class="formula-step">
<div class="step-num">1</div> <div class="step-num">1</div>
<div class="step-content"> <div class="step-content">
<div class="step-title">동적 위험 계수(λ)</div> <div class="step-title">동적 감쇄 계수(λ) 산출</div>
<div class="step-desc" style="font-size:11px; color:#64748b; margin-bottom:5px;">자산 규모 및 조직 위험을 합산하여 개별 활력 곡선을 생성합니다.</div>
<div class="math-logic">λ = <span class="highlight-var">${p.ai_lambda.toFixed(4)}</span></div> <div class="math-logic">λ = <span class="highlight-var">${p.ai_lambda.toFixed(4)}</span></div>
</div> </div>
</div> </div>
<div class="formula-step"> <div class="formula-step">
<div class="step-num">2</div> <div class="step-num">2</div>
<div class="step-content"> <div class="step-content">
<div class="step-title">활동 품질 (Quality)</div> <div class="step-title">활동 진정성 검증</div>
<div class="math-logic">Factor = <span class="${p.log_quality < 0.7 ? 'highlight-penalty' : 'highlight-val'}">${(p.log_quality * 100).toFixed(0)}%</span></div> <div class="math-logic">Factor = <span class="highlight-val">${(p.log_quality * 100).toFixed(0)}%</span></div>
</div> </div>
</div> </div>
<div class="formula-step"> <div class="formula-step">
<div class="step-num">3</div> <div class="step-num">3</div>
<div class="step-content"> <div class="step-content">
<div class="step-title">방치 시간 감쇄</div> <div class="step-title">가동 보존율 (AVI)</div>
<div class="math-logic">Result = <span class="highlight-val">${((p.p_war / (p.file_count === 0 ? 0.05 : p.file_count < 10 ? 0.4 : 1) / p.log_quality) || 0).toFixed(1)}%</span></div> <div class="math-logic">Result = <span class="highlight-val">${avi.toFixed(1)}%</span></div>
</div> </div>
</div> </div>
<div class="formula-step"> <div class="formula-step">
<div class="step-num">4</div> <div class="step-num">4</div>
<div class="step-content"> <div class="step-content">
<div class="step-title">존재 진정성 (ECV)</div> <div class="step-title">가치 기여 영향력 (VCI)</div>
<div class="math-logic">Factor = <span class="${ecvClass}">${ecvText}</span></div> <div class="math-logic">VCI = <span class="highlight-val">${vci.toFixed(1)}</span></div>
</div> </div>
</div> </div>
</div> </div>
<div class="final-result-area">
<div style="font-size: 11px; color: #64748b;">* 공식: AAS_Score × Quality_Factor × ECV_Factor</div>
<div><span style="font-size: 13px; color: #64748b; font-weight: 700;">최종 P-SOI: </span><span style="color: #1e5149; font-size: 22px; font-weight: 900;">${p.p_war.toFixed(1)}%</span></div>
</div>
</div> </div>
</div> </div>
</td> </td>
@@ -275,12 +312,19 @@ function toggleProjectDetail(rowId) {
const container = document.querySelector('.table-scroll-wrapper'); const container = document.querySelector('.table-scroll-wrapper');
const mainRow = document.querySelector(`tr[onclick*="toggleProjectDetail('${rowId}')"]`); const mainRow = document.querySelector(`tr[onclick*="toggleProjectDetail('${rowId}')"]`);
const detailRow = document.getElementById(`detail-${rowId}`); const detailRow = document.getElementById(`detail-${rowId}`);
if (detailRow && container) { if (detailRow && container) {
if (!detailRow.classList.contains('active')) { if (!detailRow.classList.contains('active')) {
document.querySelectorAll('.detail-row').forEach(row => row.classList.remove('active')); document.querySelectorAll('.detail-row').forEach(row => row.classList.remove('active'));
detailRow.classList.add('active'); detailRow.classList.add('active');
setTimeout(() => { container.scrollTo({ top: mainRow.offsetTop - (container.querySelector('thead').offsetHeight || 40), behavior: 'smooth' }); }, 50); setTimeout(() => {
} else detailRow.classList.remove('active'); const headerH = container.querySelector('thead').offsetHeight || 45;
const targetScrollTop = mainRow.offsetTop - headerH;
container.scrollTo({ top: targetScrollTop, behavior: 'smooth' });
}, 100);
} else {
detailRow.classList.remove('active');
}
} }
} }
@@ -288,14 +332,15 @@ function openProjectListModal(label, projects) {
const modal = document.getElementById('analysisModal'); const modal = document.getElementById('analysisModal');
const title = document.getElementById('modalTitle'); const title = document.getElementById('modalTitle');
const body = document.getElementById('modalBody'); const body = document.getElementById('modalBody');
title.innerText = `[${label}] 프로젝트 목록 (${projects.length}건)`; title.innerText = `[${label}] 프로젝트 리스트 (${projects.length}건)`;
body.innerHTML = projects.length === 0 ? '<p style="text-align:center; padding: 40px; color: #888;">데이터 없음</p>' : ` body.innerHTML = projects.length === 0 ? '<p style="text-align:center; padding: 40px; color: #888;">대상 프로젝트 없음</p>' : `
<div class="table-scroll-wrapper" style="max-height: 400px;"> <div class="table-scroll-wrapper" style="max-height: 400px;">
<table class="data-table"> <table class="data-table">
<thead><tr><th>프로젝트명</th><th>관리자</th><th>방치일</th><th>현재 SOI</th></tr></thead> <thead><tr><th>프로젝트명</th><th>부서</th><th>관리자</th><th>정체일</th><th>활력(AVI)</th></tr></thead>
<tbody>${projects.map(p => `<tr><td class="font-bold">${p.project_nm}</td><td>${p.master || '-'}</td><td>${p.days_stagnant}일</td><td style="font-weight:700; color:#1e5149;">${p.p_war.toFixed(1)}%</td></tr>`).join('')}</tbody> <tbody>${projects.map(p => `<tr><td class="font-bold">${p.project_nm}</td><td>${p.dept || '-'}</td><td>${p.master || '-'}</td><td>${p.days_stagnant}일</td><td style="font-weight:700; color:#1e5149;">${p.p_war.toFixed(1)}%</td></tr>`).join('')}</tbody>
</table> </table>
</div>`; </div>
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
modal.style.display = 'flex'; modal.style.display = 'flex';
} }
@@ -303,12 +348,75 @@ function openAnalysisModal(type) {
const modal = document.getElementById('analysisModal'); const modal = document.getElementById('analysisModal');
const title = document.getElementById('modalTitle'); const title = document.getElementById('modalTitle');
const body = document.getElementById('modalBody'); const body = document.getElementById('modalBody');
if (type === 'soi') {
title.innerText = 'P-SOI 산출 공식 상세'; if (type === 'avi') {
body.innerHTML = '<div class="formula-section"><div class="formula-box">SOI = exp(-λ × days) × 100</div></div><p>방치일수에 따른 가치 하락 모델입니다.</p>'; title.innerText = '운영 활력 지수 (AVI) 등급 가이드';
body.innerHTML = `
<div class="formula-box" style="margin-bottom:15px;">AVI = exp(-λ × days) × Quality × 100</div>
<p style="font-size:13px; color:#64748b; margin-bottom:15px;">자산의 가동 상태와 생존율을 나타내는 지표입니다.</p>
<table class="data-table" style="font-size:12px;">
<thead><tr style="background:#f8fafc;"><th>지수 (AVI)</th><th>등급</th><th>운영 상태</th></tr></thead>
<tbody>
<tr><td>90%↑</td><td style="font-weight:900; color:#059669;">Live</td><td>실시간 성과물이 도출되는 최상급 가동</td></tr>
<tr><td>70~90%</td><td style="font-weight:900; color:#1e5149;">Stable</td><td>주기적 업데이트가 이뤄지는 표준 안정</td></tr>
<tr><td>30~70%</td><td style="font-weight:900; color:#f59e0b;">Idle</td><td>관리가 필요한 유휴/정체 상태</td></tr>
<tr><td>10~30%</td><td style="font-weight:900; color:#dc2626;">Risk</td><td>자산 가치 소멸 직전의 위험 상태</td></tr>
<tr><td>10%↓</td><td style="font-weight:900; color:#64748b;">Frozen</td><td>운영이 중단된 동결/방치 상태</td></tr>
</tbody>
</table>
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
} else if (type === 'vci') {
title.innerText = '자산 가치 기여도 (VCI) 등급 가이드';
body.innerHTML = `
<p style="font-size:13px; color:#64748b; margin-bottom:15px;">운영 표준(AVI 70%) 대비 자산 가치 기여도에 따른 프로젝트 위상 분류입니다.</p>
<table class="data-table" style="font-size:12px;">
<thead><tr style="background:#f8fafc;"><th>점수 (VCI)</th><th>등급</th><th>운영 의미</th></tr></thead>
<tbody>
<tr><td>+10.0↑</td><td style="font-weight:900; color:#6366f1;">Masterpiece</td><td>시스템 가치를 견인하는 핵심 자산 (MVP급)</td></tr>
<tr><td>+2.0 ~ +10.0</td><td style="font-weight:900; color:#059669;">Blue Chip</td><td>꾸준한 활력의 우량 자산 (주전급)</td></tr>
<tr><td>-2.0 ~ +2.0</td><td style="font-weight:900; color:#475569;">Steady</td><td>표준 수준의 현상 유지 (보결급)</td></tr>
<tr><td>-10.0 ~ -2.0</td><td style="font-weight:900; color:#f59e0b;">Underperform</td><td>운영 미비로 인한 가치 하락 (마이너급)</td></tr>
<tr><td>-10.0↓</td><td style="font-weight:900; color:#dc2626;">Liability</td><td>가치를 훼손 중인 방치 자산 (방출급)</td></tr>
</tbody>
</table>
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
} else if (type === 'focus') {
title.innerText = '업무 집중도 (Job Focus) 등급 가이드';
body.innerHTML = `
<p style="font-size:13px; color:#64748b; margin-bottom:15px;">단순 관리 로그를 제외한 실질적인 산출물 변화의 밀도입니다.</p>
<table class="data-table" style="font-size:12px;">
<thead><tr style="background:#f8fafc;"><th>비율 (%)</th><th>등급</th><th>활동 성격</th></tr></thead>
<tbody>
<tr><td>80%↑</td><td style="font-weight:900; color:#6366f1;">Intensive</td><td>성과물 위주의 고밀도 집중 작업</td></tr>
<tr><td>50~80%</td><td style="font-weight:900; color:#059669;">Active</td><td>성과와 관리가 균형 잡힌 원활한 실행</td></tr>
<tr><td>20~50%</td><td style="font-weight:900; color:#f59e0b;">Maintenance</td><td>설정/행정 등 단순 관리 중심의 작업</td></tr>
<tr><td>20%↓</td><td style="font-weight:900; color:#dc2626;">Surface</td><td>실체적 변화가 적은 형식적 로그 중심</td></tr>
</tbody>
</table>
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
} else { } else {
title.innerText = 'AI 시계열 예측 상세'; title.innerText = '상태 예보 (AI Forecast) 분석 가이드';
body.innerHTML = '<p>활동 가속도 및 밀도를 분석하여 14일 뒤의 상태를 예보합니다.</p>'; body.innerHTML = `
<div style="background:#eef2ff; padding:15px; border-radius:8px; border-left:4px solid #6366f1; margin-bottom:15px;">
<strong style="color:#3730a3; display:block; margin-bottom:5px;">"2주 뒤의 프로젝트 건강 상태를 예측합니다"</strong>
<p style="font-size:12.5px; color:#3730a3; margin:0;">단순한 현재 점수 나열이 아닌, 최근 활동의 <b>가속도(Acceleration)</b>와 <b>변화 패턴</b>을 AI가 분석하여 미래의 활력 지수(AVI)를 예보합니다.</p>
</div>
<table class="data-table" style="font-size:12px;">
<thead><tr style="background:#f8fafc;"><th>분석 결과</th><th>상태 등급</th><th>관리 가이드라인</th></tr></thead>
<tbody>
<tr><td style="color:#059669;">AVI 상승↑</td><td style="font-weight:900; color:#059669;">성장 가속</td><td>활동 모멘텀이 상승 중인 우수 자산</td></tr>
<tr><td style="color:#475569;">AVI 유지</td><td style="font-weight:900; color:#475569;">안정 유지</td><td>현재의 리듬을 유지하는 표준 운영 상태</td></tr>
<tr><td style="color:#f59e0b;">AVI 하락↓</td><td style="font-weight:900; color:#f59e0b;">활력 저하</td><td>정체 징후 포착, 관리 리소스 투입 검토</td></tr>
<tr><td style="color:#dc2626;">AVI 10%↓</td><td style="font-weight:900; color:#dc2626;">중단 위기</td><td>단기 내 완전 방치 및 가치 소멸 위험</td></tr>
</tbody>
</table>
<div style="margin-top:15px; font-size:11.5px; color:#64748b; line-height:1.6;">
<strong>※ 분석 알고리즘 안내:</strong><br>
파일 수의 실질적 증가가 없는 프로젝트는 '성장 가속' 예보를 받을 수 없도록 설계되어 있으며, 정체가 길어질수록 감쇄 가중치가 자동으로 강화됩니다.
</div>
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
} }
modal.style.display = 'flex'; modal.style.display = 'flex';
} }

View File

@@ -53,19 +53,45 @@ class SOIPredictionService:
@staticmethod @staticmethod
def predict_future_soi(current_soi, history, days_ahead=14): def predict_future_soi(current_soi, history, days_ahead=14):
"""기존 점수와 시계열 피처를 결합하여 미래 점수 예측""" """기존 점수와 시계열 피처를 결합하여 미래 점수 예측"""
if not history or len(history) < 2: # 데이터가 너무 적으면 무조건 보수적 감쇄 (14일 기준 약 -2.1점)
return round(max(0, min(100, current_soi - (0.05 * days_ahead))), 1) if not history or len(history) < 3:
return round(max(0, min(100, current_soi - (0.15 * days_ahead))), 1)
features = SOIPredictionService.extract_vitality_features(history) features = SOIPredictionService.extract_vitality_features(history)
# 기준점을 현재의 실제 SOI 점수로 설정 (핵심 수정)
current_val = float(current_soi) current_val = float(current_soi)
# 활동 모멘텀 계산: 파일 증가 속도와 로그 밀도 반영 # [정밀 정체 분석]
momentum_factor = (features['velocity'] * 0.2) + (features['density'] * 2.0) # 1. 파일 수 변화 확인 (최근 5개 샘플)
recent_counts = [int(h['file_count'] or 0) for h in history[-5:]]
is_hard_stagnant = len(set(recent_counts)) <= 1 # 파일 수 변동이 전혀 없음
# 예측 로직: 현재값 + 모멘텀 - 자연 감쇄 # 2. 최근 로그 상태 확인
decay_constant = 0.05 last_log = history[-1]['recent_log']
is_no_activity = last_log is None or last_log == "데이터 없음" or "폴더자동삭제" in last_log
# [모멘텀 산출 로직 개편]
if is_hard_stagnant:
# 파일 변화가 없다면 아무리 로그가 있어도 '유지 관리'일 뿐 '성장'이 아님
# 오히려 시간이 갈수록 기술 부채와 데이터 노후화가 진행된다고 판단 (강력 패널티)
momentum_factor = -2.5 if is_no_activity else -1.0
else:
# 실질적인 파일 수 변화(Velocity)가 있을 때만 긍정적 모멘텀 검토
v_gain = features['velocity'] * 0.5
d_gain = features['density'] * 0.8
momentum_factor = v_gain + d_gain - 0.5 # 기본적으로 하향 압력 부여
# 예측 로직: 현재값 + 모멘텀 - (시간에 따른 자연 부식)
# 정체 시 momentum_factor가 -1.0~-2.5이므로 감쇄가 매우 빠름
decay_constant = 0.08
predicted = current_val + momentum_factor - (decay_constant * days_ahead) predicted = current_val + momentum_factor - (decay_constant * days_ahead)
# [최종 방어 로직]
# 실질적 파일 증가(velocity > 0)가 포착되지 않았다면 예보는 현재값보다 클 수 없음
if features['velocity'] <= 0 and predicted > current_val:
predicted = current_val - 1.5 # 강제 하락
# 사망 선고 (AVI가 이미 낮고 정체 중이면 0에 수렴하도록 가속)
if current_val < 20 and is_hard_stagnant:
predicted = max(0, predicted - 5.0)
return round(max(0, min(100, predicted)), 1) return round(max(0, min(100, predicted)), 1)

View File

@@ -40,7 +40,7 @@ class DashboardQueries:
# 활성도 분석을 위한 프로젝트 목록 조회 # 활성도 분석을 위한 프로젝트 목록 조회
GET_PROJECT_LIST_FOR_ANALYSIS = """ GET_PROJECT_LIST_FOR_ANALYSIS = """
SELECT m.project_id, m.project_nm, m.short_nm, h.recent_log, h.file_count SELECT m.project_id, m.project_nm, m.short_nm, m.department, h.recent_log, h.file_count
FROM projects_master m FROM projects_master m
LEFT JOIN projects_history h ON m.project_id = h.project_id AND h.crawl_date = %s LEFT JOIN projects_history h ON m.project_id = h.project_id AND h.crawl_date = %s
""" """

View File

@@ -1,5 +1,6 @@
/* ========================================================================== /* ==========================================================================
Project Master Analysis - Sabermetrics Style Project Master Analysis - Specific Styles
(Inherits base styles from common.css)
========================================================================== */ ========================================================================== */
.analysis-content { .analysis-content {
@@ -18,7 +19,6 @@
font-weight: 800; font-weight: 800;
display: inline-block; display: inline-block;
margin-bottom: 8px; margin-bottom: 8px;
letter-spacing: 0.5px;
} }
.analysis-header { .analysis-header {
@@ -26,216 +26,208 @@
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 24px; margin-bottom: 24px;
padding: 10px 0;
border-bottom: 1px solid var(--border-color);
} }
/* Top Info Grid (AI Info & SOI Deep Dive) */ .analysis-header h2 { font-size: 22px; font-weight: 800; color: var(--text-main); margin-bottom: 4px; }
.analysis-header p { font-size: 13px; color: var(--text-sub); }
/* Top Info Grid */
.top-info-grid { .top-info-grid {
display: grid; display: grid;
grid-template-columns: 1fr 2fr; grid-template-columns: 1fr 2.2fr;
gap: 16px; gap: 16px;
margin-bottom: 24px; margin-bottom: 24px;
} }
.dl-model-info, .soi-deep-dive { .dl-model-info, .soi-deep-dive {
background: white; background: white;
border-radius: 12px; border-radius: var(--radius-xl);
border: 1px solid #eef2f6; border: 1px solid var(--border-color);
box-shadow: 0 4px 12px rgba(0,0,0,0.03);
padding: 20px; padding: 20px;
box-shadow: var(--box-shadow);
} }
.model-desc-vertical { .card-header { margin-bottom: 15px; display: flex; align-items: center; justify-content: space-between; }
display: flex; .card-header h4 { font-size: 14px; font-weight: 800; color: var(--primary-color); margin: 0; }
flex-direction: column;
gap: 12px;
}
.model-item-vertical { .model-desc-vertical { display: flex; flex-direction: column; gap: 12px; }
display: flex; .model-item-vertical { display: flex; align-items: center; gap: 12px; }
align-items: center; .model-tag { background: var(--bg-muted); color: var(--text-sub); padding: 2px 8px; border-radius: 4px; font-size: 10px; font-weight: 700; }
gap: 12px;
}
.model-tag { .soi-info-columns { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
background: #f1f5f9; .soi-info-column h6 { font-size: 12px; font-weight: 800; color: var(--primary-color); margin: 0 0 8px 0; }
color: #475569; .soi-info-column p { font-size: 11.5px; color: var(--text-sub); line-height: 1.6; margin: 0; }
padding: 2px 8px;
border-radius: 4px;
font-size: 10px;
font-weight: 700;
white-space: nowrap;
}
.soi-info-columns {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
.soi-info-column h6 {
font-size: 12px;
font-weight: 800;
color: #1e5149;
margin: 0 0 8px 0;
}
.soi-info-column p {
font-size: 11.5px;
color: #64748b;
line-height: 1.6;
margin: 0;
}
/* Chart Grid Layout */ /* Chart Grid Layout */
.analysis-charts-grid { .analysis-charts-grid {
display: grid; display: grid;
grid-template-columns: 1.2fr 2fr; grid-template-columns: 1fr 1.8fr;
gap: 20px; gap: 20px;
margin-bottom: 24px; margin-bottom: 24px;
} }
.chart-container-box { .chart-container-box {
background: white; background: white;
border-radius: 12px; border-radius: var(--radius-xl);
padding: 20px; padding: 20px;
border: 1px solid #eef2f6; border: 1px solid var(--border-color);
height: 340px; height: 360px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
box-shadow: var(--box-shadow);
} }
.chart-container-box h5 { .chart-container-box h5 { margin: 0 0 15px 0; font-size: 13px; font-weight: 700; color: var(--text-main); }
margin: 0 0 15px 0;
font-size: 13px; /* Timeline Analysis Card */
font-weight: 700; .analysis-card {
color: #334155; background: white;
border-radius: var(--radius-xl);
border: 1px solid var(--border-color);
box-shadow: var(--box-shadow);
margin-bottom: 24px;
overflow: hidden;
} }
.analysis-card .card-header {
padding: 16px 24px;
background: #fff;
border-bottom: 1px solid var(--border-color);
}
.analysis-card .card-body { padding: 24px; }
/* SOI Guide Styles */
.d-war-guide {
display: flex;
gap: 10px;
margin-bottom: 20px;
padding: 12px;
background: var(--bg-muted);
border-radius: var(--radius-lg);
}
.guide-item {
font-size: 11px;
font-weight: 700;
padding: 4px 10px;
border-radius: 4px;
display: flex;
align-items: center;
gap: 6px;
}
.guide-item.active-low { background: #dcfce7; color: #166534; }
.guide-item.warning-mid { background: #fef9c3; color: #854d0e; }
.guide-item.danger-high { background: #ffedd5; color: #9a3412; }
.guide-item.hazard-critical { background: #fee2e2; color: #991b1b; }
/* Data Table Customization */ /* Data Table Customization */
.p-war-table-container { .table-scroll-wrapper {
margin-top: 24px; overflow-x: auto;
overflow-y: auto;
max-height: 600px;
border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
background: white;
} }
.project-row { .p-war-table {
cursor: pointer; width: 100%;
transition: background 0.2s ease; border-collapse: separate;
border-spacing: 0;
table-layout: fixed; /* 컬럼 너비 고정 */
} }
.project-row:hover { .p-war-table th {
background: #f8fafc !important; position: sticky;
top: 0;
z-index: 20;
background: #f8fafc;
padding: 16px 15px;
font-size: 12px;
font-weight: 800;
color: #475569;
border-bottom: 2px solid #e2e8f0;
white-space: nowrap;
} }
.p-war-table td {
padding: 14px 15px;
font-size: 13px;
border-bottom: 1px solid #f1f5f9;
vertical-align: middle;
}
/* 컬럼별 너비 정의 */
.p-war-table th:nth-child(1), .p-war-table td:nth-child(1) { width: 28%; text-align: left; } /* 프로젝트명 */
.p-war-table th:nth-child(2), .p-war-table td:nth-child(2) { width: 10%; text-align: right; } /* 파일 수 */
.p-war-table th:nth-child(3), .p-war-table td:nth-child(3) { width: 10%; text-align: right; } /* 방치일 */
.p-war-table th:nth-child(4), .p-war-table td:nth-child(4) { width: 10%; text-align: center; } /* 상태 판정 */
.p-war-table th:nth-child(5), .p-war-table td:nth-child(5) { width: 14%; text-align: right; } /* P-WAR+ */
.p-war-table th:nth-child(6), .p-war-table td:nth-child(6) { width: 12%; text-align: right; } /* 현재 SOI */
.p-war-table th:nth-child(7), .p-war-table td:nth-child(7) { width: 12%; text-align: center; } /* 실무 투입 */
.p-war-table th:nth-child(8), .p-war-table td:nth-child(8) { width: 14%; text-align: center; } /* AI 예보 */
.project-row { cursor: pointer; transition: background 0.2s; }
.project-row:hover { background: var(--hover-bg) !important; }
/* SOI Value Styling */
.p-war-value { font-weight: 800; font-family: 'Consolas', monospace; }
/* Accordion Detail Styles */ /* Accordion Detail Styles */
.detail-row { .detail-row { display: none; background: #fafafa; }
display: none; .detail-row.active { display: table-row; }
background: #fdfdfd; .detail-container { padding: 20px 24px; }
}
.detail-row.active {
display: table-row;
}
.detail-container {
padding: 20px 24px;
border-bottom: 2px solid #f1f5f9;
}
.formula-explanation-card { .formula-explanation-card {
background: white; background: white;
border-radius: 12px; border-radius: var(--radius-lg);
padding: 24px; padding: 24px;
border: 1px solid #e2e8f0; border: 1px solid var(--border-color);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05); box-shadow: var(--box-shadow);
} }
.formula-header { .formula-header { font-size: 13px; font-weight: 700; color: #6366f1; margin-bottom: 15px; }
font-size: 13px;
font-weight: 700;
color: #6366f1;
margin-bottom: 15px;
}
/* Work Effort Bar Area */ /* Work Effort Section */
.work-effort-section { .work-effort-section { background: var(--bg-muted); padding: 16px; border-radius: var(--radius-lg); margin-bottom: 20px; }
background: #f8fafc; .work-effort-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
padding: 16px; .work-effort-bar-bg { width: 100%; height: 6px; background: #e2e8f0; border-radius: 3px; overflow: hidden; margin-bottom: 10px; }
border-radius: 8px;
margin-bottom: 20px;
border: 1px solid #eef2f6;
}
.work-effort-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.work-effort-bar-bg {
width: 100%;
height: 6px;
background: #e2e8f0;
border-radius: 3px;
overflow: hidden;
margin-bottom: 10px;
}
/* Formula Steps Grid */ /* Formula Steps Grid */
.formula-steps-grid { .formula-steps-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
display: grid; .formula-step { display: flex; gap: 12px; }
grid-template-columns: 1fr 1fr; .step-num { background: var(--primary-color); color: white; width: 20px; height: 20px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 800; flex-shrink: 0; }
gap: 20px; .step-title { font-size: 12px; font-weight: 700; color: var(--text-main); margin-bottom: 4px; }
} .math-logic { font-family: 'Consolas', monospace; background: var(--bg-muted); padding: 4px 8px; border-radius: 4px; font-weight: 700; color: var(--text-main); font-size: 12px; display: inline-block; }
.formula-step { .final-result-area { margin-top: 20px; padding-top: 15px; border-top: 2px solid var(--primary-color); display: flex; justify-content: space-between; align-items: center; }
/* Modal Analysis Specific */
.modal-footer {
padding: 16px 24px;
background: #fff;
border-top: 1px solid var(--border-color);
text-align: right;
display: flex; display: flex;
gap: 12px; justify-content: flex-end;
} }
.step-num { /* Formula & Badges */
background: #1e5149; .formula-section { margin-bottom: 20px; }
color: white; .formula-box { background: var(--primary-lv-0); color: var(--primary-color); padding: 15px; border-radius: var(--radius-lg); font-weight: 800; text-align: center; font-family: monospace; font-size: 16px; }
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 800;
flex-shrink: 0;
}
.step-title { .badge-active { background: #dcfce7; color: #166534; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; }
font-size: 12px; .badge-warning { background: #fef9c3; color: #854d0e; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; }
font-weight: 700; .badge-danger { background: #ffedd5; color: #9a3412; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; }
color: #334155; .badge-system { background: #fee2e2; color: #991b1b; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; }
margin-bottom: 4px;
}
.math-logic {
font-family: 'Consolas', monospace;
background: #f1f5f9;
padding: 4px 8px;
border-radius: 4px;
font-weight: 700;
color: #0f172a;
font-size: 12px;
display: inline-block;
}
.final-result-area {
margin-top: 20px;
padding-top: 15px;
border-top: 2px solid #1e5149;
display: flex;
justify-content: space-between;
align-items: center;
}
/* Utility Classes */
.highlight-var { color: #2563eb; } .highlight-var { color: #2563eb; }
.highlight-val { color: #059669; } .highlight-val { color: #059669; }
.highlight-penalty { color: #dc2626; } .highlight-penalty { color: #dc2626; }
.text-plus { color: #059669; font-weight: 700; } .text-plus { color: #059669; font-weight: 700; }
.text-minus { color: #dc2626; font-weight: 700; } .text-minus { color: #dc2626; font-weight: 700; }
.font-bold { font-weight: 700; }

View File

@@ -30,11 +30,11 @@
<header class="analysis-header"> <header class="analysis-header">
<div class="title-group"> <div class="title-group">
<div class="ai-badge">AI Sabermetrics</div> <div class="ai-badge">AI Sabermetrics</div>
<h2>시스템 운영 빅데이터 분석</h2> <h2>시스템 운영 자산 가치 분석</h2>
<p>수집된 활동 로그 및 자산 데이터를 기반으로 한 통계적 성능 지표 (Beta)</p> <p>수집된 활동 로그 및 자산 데이터를 기반으로 한 통계적 활력 지표 (Beta)</p>
</div> </div>
<div class="analysis-actions"> <div class="analysis-actions">
<button class="btn-refresh" onclick="location.reload()">데이터 갱신</button> <button class="btn btn-primary" onclick="location.reload()">데이터 갱신</button>
</div> </div>
</header> </header>
@@ -47,12 +47,12 @@
<div class="card-body"> <div class="card-body">
<div class="model-desc-vertical"> <div class="model-desc-vertical">
<div class="model-item-vertical"> <div class="model-item-vertical">
<span class="model-tag">알고리즘</span> <span class="model-tag">분석 모델</span>
<p>최근 9회차 시계열의 Velocity 및 가속도 분석</p> <p>최근 9회차 시계열의 Velocity 및 변화율 분석</p>
</div> </div>
<div class="model-item-vertical"> <div class="model-item-vertical">
<span class="model-tag">판단 로직</span> <span class="model-tag">가중치 로직</span>
<p>활동 시 '선형 추세', 정체 시 '지수 감쇄' 가중치 적용</p> <p>활동 시 '선형 유지', 정체 시 '지수 감쇄' 동적 적용</p>
</div> </div>
</div> </div>
</div> </div>
@@ -65,16 +65,16 @@
<div class="card-body"> <div class="card-body">
<div class="soi-info-columns"> <div class="soi-info-columns">
<div class="soi-info-column"> <div class="soi-info-column">
<h6>1. AI 자산 가치 평가</h6> <h6>1. 자산 가치 변동 추적</h6>
<p>자산 규모를 감지하여, 대형 프로젝트 방치 시 데이터 가치 하락 속도를 <strong>가속(Acceleration)</strong>시킵니다.</p> <p>규모를 감지하여, 대형 프로젝트 정체 시 데이터 가치 하락 속도를 <strong>가속(Acceleration)</strong>시킵니다.</p>
</div> </div>
<div class="soi-info-column"> <div class="soi-info-column">
<h6>2. 조직 위험 전염</h6> <h6>2. 조직 위험 전염</h6>
<p>소속 부서의 전반적인 활동성이 낮을 경우, 개별 위험 지수를 상향 조정하여 <strong>시스템적 붕괴</strong>를 예보합니다.</p> <p>소속 부서의 전반적인 활이 낮을 경우, 개별 위험 지수를 상향 조정하여 <strong>시스템적 붕괴</strong>를 예보합니다.</p>
</div> </div>
<div class="soi-info-column"> <div class="soi-info-column">
<h6>3. 동적 위험 계수</h6> <h6>3. 동적 가치 계수</h6>
<p>프로젝트마다 <strong>개별화된 위험 곡선</strong>을 생성하여 현장에 가장 밀착된 가치 보존율을 제공합니다.</p> <p>프로젝트마다 <strong>개별화된 감쇄 곡선</strong>을 생성하여 현장에 가장 밀착된 보존율을 제공합니다.</p>
</div> </div>
</div> </div>
</div> </div>
@@ -84,11 +84,11 @@
<!-- 메인 분석 차트 영역 --> <!-- 메인 분석 차트 영역 -->
<div class="analysis-charts-grid"> <div class="analysis-charts-grid">
<div class="chart-container-box"> <div class="chart-container-box">
<h5>건강 상태 분포 (Project Distribution)</h5> <h5>운영 활력 분포 (Activity Distribution)</h5>
<canvas id="statusChart"></canvas> <canvas id="statusChart"></canvas>
</div> </div>
<div class="chart-container-box"> <div class="chart-container-box">
<h5>프로젝트 SWOT 매트릭스 (Strategic Analysis)</h5> <h5>자산 가치 전략 매트릭스 (Strategic Analysis)</h5>
<canvas id="forecastChart"></canvas> <canvas id="forecastChart"></canvas>
</div> </div>
</div> </div>
@@ -97,19 +97,19 @@
<div class="analysis-card timeline-analysis"> <div class="analysis-card timeline-analysis">
<div class="card-header"> <div class="card-header">
<div style="display: flex; flex-direction: column; gap: 4px;"> <div style="display: flex; flex-direction: column; gap: 4px;">
<h4>Project Stagnation Objective Index (P-SOI Status)</h4> <h4>Project Activity Vitality Leaderboard (AVI Status)</h4>
<p style="font-size: 11px; color: #888; margin: 0;">이상적 관리 상태(100%) 대비 활동 보존율 및 미래 예측 리더보드</p> <p style="font-size: 11px; color: #888; margin: 0;">운영 표준(AVI 70%) 대비 파일 보존율 및 미래 가치 기여 리더보드</p>
</div> </div>
<div class="card-tools"> <div class="card-tools">
<span id="avg-system-info" style="font-size: 11px; color: #888;">* SOI (Project Health Score)</span> <span id="avg-system-info" style="font-size: 11px; color: #888;">* AVI (Activity Vitality Index)</span>
</div> </div>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="d-war-guide"> <div class="d-war-guide">
<div class="guide-item active-low"><span>70%↑</span> 정상</div> <div class="guide-item active-low"><span>70%↑</span> 정상 운영</div>
<div class="guide-item warning-mid"><span>30~70%</span> 주의</div> <div class="guide-item warning-mid"><span>30~70%</span> 관리 주의</div>
<div class="guide-item danger-high"><span>10~30%</span> 위험</div> <div class="guide-item danger-high"><span>10~30%</span> 위험 노출</div>
<div class="guide-item hazard-critical"><span>10%↓</span> 사망</div> <div class="guide-item hazard-critical"><span>10%↓</span> 중단/방치</div>
</div> </div>
<div id="p-war-table-container"> <div id="p-war-table-container">
@@ -124,7 +124,7 @@
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h3 id="modalTitle">분석 상세</h3> <h3 id="modalTitle">분석 상세</h3>
<button class="btn-close" onclick="closeAnalysisModal()">×</button> <span class="modal-close" onclick="closeAnalysisModal()">&times;</span>
</div> </div>
<div class="modal-body" id="modalBody"> <div class="modal-body" id="modalBody">
<!-- 내용 동적 삽입 --> <!-- 내용 동적 삽입 -->