refactor: 분석 페이지 코드 정돈 및 AI 엔진 고도화 통합
This commit is contained in:
49
ANALYSIS_REPORT.md
Normal file
49
ANALYSIS_REPORT.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# 📊 Project Master Sabermetrics 분석 엔진 리포트
|
||||
|
||||
## 1. 개요 (Vision)
|
||||
본 시스템은 방대한 프로젝트 운영 데이터(파일 수, 활동 로그, 조직 정보)를 기반으로 **AI 기반 프로젝트 건강도(P-SOI)**를 산출합니다. 단순히 "살아있는가"를 넘어, "실무적으로 가치 있게 관리되고 있는가"를 정밀 진단하는 것이 목적입니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. P-SOI 산출 로직 (The Formula)
|
||||
|
||||
### 2.1 기초 모델: 지수 감쇄 (Exponential Decay)
|
||||
프로젝트 정보의 가치는 관리 활동이 멈춘 시점부터 시간이 흐를수록 급격히 하락합니다.
|
||||
- **수식**: $SOI = 100 \times e^{-\lambda t}$
|
||||
- **의미**: 14일 방치 시 가치가 약 50% 소실되는 현장 현실을 반영합니다.
|
||||
|
||||
### 2.2 고도화 1: AAS (AI-Hazard Adaptive SOI)
|
||||
프로젝트의 중요도와 주변 환경에 따라 하락 곡선의 기울기를 동적으로 조정합니다.
|
||||
- **자산 규모 영향**: 파일 수가 많을수록 관리 부재 리스크가 크므로 AI가 하락 속도를 가속시킵니다.
|
||||
- **조직 위험 전염**: 소속 부서나 담당자의 전체 SOI가 낮을 경우, 시스템적 붕괴 리스크 가중치를 부여합니다.
|
||||
|
||||
### 2.3 고도화 2: ECV (Existence-Conditioned Vitality)
|
||||
'빈 껍데기' 활동을 걸러내는 존재론적 패널티입니다.
|
||||
- **유령 프로젝트**: 파일 수가 0개인 경우, 최근 로그와 관계없이 SOI 점수를 **5% 미만**으로 강제 고정합니다.
|
||||
- **신뢰 보정**: 파일 10개 미만의 소규모 프로젝트는 활동 신뢰도를 40% 수준으로 제한합니다.
|
||||
|
||||
### 2.4 고도화 3: 로그 품질 및 실무 투입 분석
|
||||
- **Log Quality**: 로그 텍스트를 분석하여 [실무 활동(1.0), 관리 활동(0.7), 행정 활동(0.4)] 가중치를 부여합니다.
|
||||
- **Work Effort**: 최근 30개 히스토리 중 실제 **파일 증감**이 발생한 날의 비율을 계산하여 실질 공수 투입률을 산출합니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 전략적 분석 도구 (Visualization)
|
||||
|
||||
### 3.1 프로젝트 SWOT 매트릭스
|
||||
X축(자산 규모)과 Y축(활동성)을 결합하여 4가지 국면으로 프로젝트를 진단합니다.
|
||||
1. **핵심 우량 (Strategic)**: 대규모 핵심 자산이며 활발히 관리 중.
|
||||
2. **활동 양호 (Agile)**: 규모는 작으나 매우 탄력적으로 업데이트 중.
|
||||
3. **방치/소규모**: 중요도가 낮고 방치된 상태.
|
||||
4. **관리 사각지대 (Critical Risk)**: **자산 규모는 크나 장기 방치됨 (최우선 관리 대상)**
|
||||
|
||||
### 3.2 AI 진단 아코디언 리포트
|
||||
사용자가 지수 산출 결과에 납득할 수 있도록, 개별 프로젝트 행 클릭 시 **4단계 AI 추론 과정**을 실시간 리포트로 제공합니다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 향후 딥러닝 로드맵 (Evolution)
|
||||
데이터가 누적됨에 따라 다음과 같은 자가 학습형 엔진으로 진화합니다.
|
||||
- **LSTM 기반 리듬 학습**: 각 프로젝트의 고유한 업데이트 주기와 패턴(Life Rhythm)을 인코딩하여 맞춤형 예보 수행.
|
||||
- **NLP 임베딩**: 단순 키워드를 넘어 로그 텍스트의 맥락적 의미를 딥러닝이 스스로 학습하여 가중치 자동 산정.
|
||||
- **병목 예측 AI**: 특정 담당자나 부서의 업무 과부하 패턴을 학습하여 집단 방치 위험을 선제적으로 예보.
|
||||
BIN
__pycache__/analysis_service.cpython-312.pyc
Normal file
BIN
__pycache__/analysis_service.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/inquiry_service.cpython-312.pyc
Normal file
BIN
__pycache__/inquiry_service.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/prediction_service.cpython-312.pyc
Normal file
BIN
__pycache__/prediction_service.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/project_service.cpython-312.pyc
Normal file
BIN
__pycache__/project_service.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/schemas.cpython-312.pyc
Normal file
BIN
__pycache__/schemas.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
@@ -127,22 +127,46 @@ class AnalysisService:
|
||||
# 지수 감쇄 적용 (AAS Score)
|
||||
soi_score = math.exp(-ai_lambda * days_stagnant) * 100
|
||||
|
||||
# [AI 데이터 진정성 검증 로직 - ECV 패널티 추가]
|
||||
# 파일이 하나도 없거나(유령), 현저히 적은 경우(껍데기) 활동의 진정성을 불신함
|
||||
# [AI 데이터 진정성 검증 로직 1 - ECV 패널티 (존재론적)]
|
||||
existence_confidence = 1.0
|
||||
if file_count == 0:
|
||||
existence_confidence = 0.05 # 파일 0개는 로그가 있어도 최대 5% 미만으로 강제
|
||||
existence_confidence = 0.05
|
||||
elif file_count < 10:
|
||||
existence_confidence = 0.4 # 파일 10개 미만은 활동 신뢰도 40%로 제한
|
||||
existence_confidence = 0.4
|
||||
|
||||
soi_score = soi_score * existence_confidence
|
||||
# [AI 데이터 진정성 검증 로직 2 - Log Quality Scoring (활동의 질)]
|
||||
log_quality_factor = 1.0
|
||||
if log and log != "데이터 없음":
|
||||
# 성과 중심 (High)
|
||||
if any(k in log for k in ["업로드", "수정", "등록", "변환", "파일", "업데이트"]):
|
||||
log_quality_factor = 1.0
|
||||
# 구조 관리 (Mid)
|
||||
elif any(k in log for k in ["폴더", "생성", "삭제", "이동"]):
|
||||
log_quality_factor = 0.7
|
||||
# 단순 행정/설정 (Low)
|
||||
elif any(k in log for k in ["참가자", "권한", "추가", "변경", "메일"]):
|
||||
log_quality_factor = 0.4
|
||||
else:
|
||||
log_quality_factor = 0.6 # 기타 일반 로그
|
||||
|
||||
# 최종 점수 산출 (AAS * ECV * LogQuality)
|
||||
soi_score = soi_score * existence_confidence * log_quality_factor
|
||||
|
||||
if is_auto_delete:
|
||||
soi_score = 0.1
|
||||
|
||||
# [AI 미래 예측 연동]
|
||||
history = SOIPredictionService.get_historical_soi(cursor, p['project_id'])
|
||||
predicted_soi = SOIPredictionService.predict_future_soi(history, days_ahead=14)
|
||||
# [AI 미래 예측 및 실무 투입 에너지 분석]
|
||||
history_rows = SOIPredictionService.get_historical_soi(cursor, p['project_id'])
|
||||
predicted_soi = SOIPredictionService.predict_future_soi(soi_score, history_rows, days_ahead=14)
|
||||
|
||||
# 실무 투입 에너지 계산 (최근 30개 히스토리 기준 파일 변화일수)
|
||||
effort_days = 0
|
||||
if len(history_rows) > 1:
|
||||
for i in range(1, len(history_rows)):
|
||||
if history_rows[i]['file_count'] != history_rows[i-1]['file_count']:
|
||||
effort_days += 1
|
||||
|
||||
work_effort_rate = round((effort_days / max(1, len(history_rows))) * 100, 1)
|
||||
|
||||
total_soi += soi_score
|
||||
|
||||
@@ -156,7 +180,9 @@ class AnalysisService:
|
||||
"is_auto_delete": is_auto_delete,
|
||||
"master": p['master'],
|
||||
"dept": p['department'],
|
||||
"ai_lambda": round(ai_lambda, 4), # 디버깅용 계수 포함
|
||||
"ai_lambda": round(ai_lambda, 4),
|
||||
"log_quality": log_quality_factor,
|
||||
"work_effort": work_effort_rate, # 신규 지표 추가
|
||||
"avg_info": {
|
||||
"avg_files": 0,
|
||||
"avg_stagnant": 0,
|
||||
|
||||
375
js/analysis.js
375
js/analysis.js
@@ -3,6 +3,11 @@
|
||||
* P-WAR (Project Performance Above Replacement) 분석 엔진
|
||||
*/
|
||||
|
||||
// Chart.js 플러그인 전역 등록
|
||||
if (typeof ChartDataLabels !== 'undefined') {
|
||||
Chart.register(ChartDataLabels);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
console.log("Analysis engine initialized...");
|
||||
loadPWarData();
|
||||
@@ -12,43 +17,32 @@ async function loadPWarData() {
|
||||
try {
|
||||
const response = await fetch('/api/analysis/p-war');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) throw new Error(data.error);
|
||||
|
||||
// 업데이트 로직: 리더보드 및 차트 렌더링
|
||||
renderPWarLeaderboard(data);
|
||||
renderSOICharts(data);
|
||||
|
||||
// 시스템 정보 표시
|
||||
if (data.length > 0 && data[0].avg_info) {
|
||||
const avg = data[0].avg_info;
|
||||
document.getElementById('avg-system-info').textContent =
|
||||
`* 시스템 종합 건강도: ${avg.avg_risk}% (0.0%에 가까울수록 시스템 전반의 방치가 심각함)`;
|
||||
const infoEl = document.getElementById('avg-system-info');
|
||||
if (infoEl) infoEl.textContent = `* 시스템 종합 건강도: ${avg.avg_risk}% (0.0%에 가까울수록 방치 심각)`;
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.error("분석 데이터 로딩 실패:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// 상태 판정 공통 함수
|
||||
function getStatusInfo(soi, isAutoDelete) {
|
||||
if (isAutoDelete || soi < 10) {
|
||||
return { label: '사망', class: 'badge-system', key: 'dead' };
|
||||
} else if (soi < 30) {
|
||||
return { label: '위험', class: 'badge-danger', key: 'danger' };
|
||||
} else if (soi < 70) {
|
||||
return { label: '주의', class: 'badge-warning', key: 'warning' };
|
||||
} else {
|
||||
if (isAutoDelete || soi < 10) return { label: '사망', class: 'badge-system', key: 'dead' };
|
||||
if (soi < 30) return { label: '위험', class: 'badge-danger', key: 'danger' };
|
||||
if (soi < 70) return { label: '주의', class: 'badge-warning', key: 'warning' };
|
||||
return { label: '정상', class: 'badge-active', key: 'active' };
|
||||
}
|
||||
}
|
||||
|
||||
// Chart.js 시각화 엔진
|
||||
function renderSOICharts(data) {
|
||||
if (!data || data.length === 0) return;
|
||||
|
||||
// --- 1. 상태 분포 데이터 가공 (Doughnut Chart) ---
|
||||
// 1. 상태 분포 (Doughnut)
|
||||
try {
|
||||
const stats = { active: [], warning: [], danger: [], dead: [] };
|
||||
data.forEach(p => {
|
||||
@@ -72,30 +66,26 @@ function renderSOICharts(data) {
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
layout: { padding: 15 },
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'right',
|
||||
labels: { boxWidth: 10, font: { size: 11, weight: '700' }, usePointStyle: true }
|
||||
},
|
||||
legend: { position: 'right', labels: { boxWidth: 10, font: { size: 11, weight: '700' }, usePointStyle: true } },
|
||||
datalabels: { display: false }
|
||||
},
|
||||
cutout: '65%',
|
||||
onClick: (event, elements) => {
|
||||
onClick: (e, elements) => {
|
||||
if (elements.length > 0) {
|
||||
const index = elements[0].index;
|
||||
const keys = ['active', 'warning', 'danger', 'dead'];
|
||||
const labels = ['정상', '주의', '위험', '사망'];
|
||||
openProjectListModal(labels[index], stats[keys[index]]);
|
||||
const idx = elements[0].index;
|
||||
openProjectListModal(['정상', '주의', '위험', '사망'][idx], stats[['active', 'warning', 'danger', 'dead'][idx]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (err) { console.error("도넛 차트 생성 실패:", err); }
|
||||
} catch (err) { console.error("도넛 차트 에러:", err); }
|
||||
|
||||
// --- 2. 프로젝트 SWOT 매트릭스 진단 (Scatter Chart) ---
|
||||
// 2. 프로젝트 SWOT 매트릭스 (Scatter)
|
||||
try {
|
||||
const scatterData = data.map(p => ({
|
||||
x: Math.min(500, p.file_count), // 최대 500으로 조정
|
||||
x: Math.min(500, p.file_count),
|
||||
y: p.p_war,
|
||||
label: p.project_nm
|
||||
}));
|
||||
@@ -103,22 +93,45 @@ function renderSOICharts(data) {
|
||||
const vitalityCtx = document.getElementById('forecastChart').getContext('2d');
|
||||
if (window.myVitalityChart) window.myVitalityChart.destroy();
|
||||
|
||||
const plugins = [];
|
||||
if (typeof ChartDataLabels !== 'undefined') plugins.push(ChartDataLabels);
|
||||
// 플러그인 통합 (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, {
|
||||
type: 'scatter',
|
||||
plugins: plugins,
|
||||
plugins: chartPlugins,
|
||||
data: {
|
||||
datasets: [{
|
||||
data: scatterData,
|
||||
backgroundColor: (context) => {
|
||||
const p = context.raw;
|
||||
backgroundColor: (ctx) => {
|
||||
const p = ctx.raw;
|
||||
if (!p) return '#94a3b8';
|
||||
if (p.x >= 250 && p.y >= 50) return '#1E5149'; // 핵심 우량 (기준 250)
|
||||
if (p.x < 250 && p.y >= 50) return '#22c55e'; // 활동 양호
|
||||
if (p.x < 250 && p.y < 50) return '#94a3b8'; // 방치/소규모
|
||||
return '#ef4444'; // 관리 사각지대
|
||||
if (p.x >= 250 && p.y >= 50) return '#1E5149';
|
||||
if (p.x < 250 && p.y >= 50) return '#22c55e';
|
||||
if (p.x < 250 && p.y < 50) return '#94a3b8';
|
||||
return '#ef4444';
|
||||
},
|
||||
pointRadius: 6,
|
||||
hoverRadius: 10
|
||||
@@ -130,80 +143,37 @@ function renderSOICharts(data) {
|
||||
layout: { padding: { top: 30, right: 40, left: 10, bottom: 10 } },
|
||||
scales: {
|
||||
x: {
|
||||
type: 'linear',
|
||||
min: 0,
|
||||
max: 500, // 데이터 분포에 최적화
|
||||
type: 'linear', min: 0, max: 500,
|
||||
title: { display: true, text: '자산 규모 (파일 수)', font: { size: 11, weight: '700' } },
|
||||
grid: { display: false },
|
||||
ticks: { stepSize: 125, callback: (val) => val >= 500 ? '500+' : val.toLocaleString() }
|
||||
ticks: { stepSize: 125, callback: (v) => v >= 500 ? '500+' : v }
|
||||
},
|
||||
y: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
min: 0, max: 100,
|
||||
title: { display: true, text: '활동성 (SOI %)', font: { size: 11, weight: '700' } },
|
||||
grid: { display: false },
|
||||
ticks: { stepSize: 25 }
|
||||
grid: { display: false }
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
datalabels: {
|
||||
align: 'top',
|
||||
offset: 5,
|
||||
font: { size: 10, weight: '700' },
|
||||
color: '#475569',
|
||||
formatter: (value) => value.label,
|
||||
display: (context) => context.raw.x > 100 || context.raw.y < 30,
|
||||
align: 'top', offset: 5, font: { size: 10, weight: '700' }, color: '#475569',
|
||||
formatter: (v) => v.label,
|
||||
display: (ctx) => ctx.raw.x > 100 || ctx.raw.y < 30,
|
||||
clip: false
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context) => ` [${context.raw.label}] SOI: ${context.raw.y.toFixed(1)}% | 파일: ${context.raw.x >= 500 ? '500+' : context.raw.x}개`
|
||||
callbacks: { label: (ctx) => ` [${ctx.raw.label}] SOI: ${ctx.raw.y.toFixed(1)}% | 파일: ${ctx.raw.x >= 500 ? '500+' : ctx.raw.x}개` }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [{
|
||||
id: 'quadrants',
|
||||
beforeDraw: (chart) => {
|
||||
const { ctx, chartArea: { left, top, right, bottom }, scales: { x, y } } = chart;
|
||||
const midX = x.getPixelForValue(250); // 중앙축을 250으로 변경
|
||||
const midY = y.getPixelForValue(50);
|
||||
|
||||
ctx.save();
|
||||
// 1. 물리적으로 동일한 크기의 배경색 채우기
|
||||
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); // 하우
|
||||
|
||||
// 2. 명확한 십자 구분선
|
||||
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();
|
||||
|
||||
// 3. 영역 텍스트
|
||||
ctx.font = 'bold 12px Pretendard'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||
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("SWOT 차트 에러:", err); }
|
||||
}
|
||||
|
||||
function renderPWarLeaderboard(data) {
|
||||
const container = document.getElementById('p-war-table-container');
|
||||
if (!container) return;
|
||||
|
||||
const sortedData = [...data].sort((a, b) => a.p_war - b.p_war);
|
||||
|
||||
container.innerHTML = `
|
||||
@@ -211,239 +181,136 @@ function renderPWarLeaderboard(data) {
|
||||
<table class="data-table p-war-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="position: sticky; top: 0; z-index: 10; width: 280px;">프로젝트명</th>
|
||||
<th style="position: sticky; top: 0; z-index: 10;">파일 수</th>
|
||||
<th style="position: sticky; top: 0; z-index: 10;">방치일</th>
|
||||
<th style="position: sticky; top: 0; z-index: 10;">상태 판정</th>
|
||||
<th style="position: sticky; top: 0; z-index: 10;">
|
||||
현재 SOI <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('soi')">?</button>
|
||||
</th>
|
||||
<th style="position: sticky; top: 0; z-index: 10;">
|
||||
AI 예보 (14d) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('ai')">?</button>
|
||||
</th>
|
||||
<th style="width: 280px;">프로젝트명</th>
|
||||
<th>파일 수</th>
|
||||
<th>방치일</th>
|
||||
<th>상태 판정</th>
|
||||
<th>현재 SOI <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('soi')">?</button></th>
|
||||
<th style="text-align:center;">실무 투입</th>
|
||||
<th>AI 예보 (14d) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('ai')">?</button></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${sortedData.map((p, idx) => {
|
||||
const status = getStatusInfo(p.p_war, p.is_auto_delete);
|
||||
const soi = p.p_war;
|
||||
const pred = p.predicted_soi;
|
||||
const rowId = `project-${idx}`;
|
||||
|
||||
let trendIcon = "";
|
||||
if (pred !== null) {
|
||||
const diff = pred - soi;
|
||||
if (diff < -5) trendIcon = '<span style="color:#ef4444; font-size:10px;">▼ 급락</span>';
|
||||
else if (diff < 0) trendIcon = '<span style="color:#f59e0b; font-size:10px;">↘ 하락</span>';
|
||||
else trendIcon = '<span style="color:#22c55e; font-size:10px;">↗ 유지</span>';
|
||||
}
|
||||
|
||||
// 수식 상세 데이터 준비
|
||||
const baseLambda = 0.04;
|
||||
const scaleImpact = Math.min(0.04, Math.log10(p.file_count + 1) * 0.008);
|
||||
const envImpact = Math.max(0, p.ai_lambda - baseLambda - scaleImpact);
|
||||
|
||||
// 존재 신뢰도 패널티 (ECV)
|
||||
let ecvText = "100% (신뢰)";
|
||||
let ecvClass = "highlight-val";
|
||||
if (p.file_count === 0) { ecvText = "5% (유령 프로젝트 패널티)"; ecvClass = "highlight-penalty"; }
|
||||
else if (p.file_count < 10) { ecvText = "40% (소규모 껍데기 패널티)"; ecvClass = "highlight-penalty"; }
|
||||
const ecvText = p.file_count === 0 ? "5% (유령)" : p.file_count < 10 ? "40% (껍데기)" : "100% (신뢰)";
|
||||
const ecvClass = (p.file_count < 10) ? "highlight-penalty" : "highlight-val";
|
||||
|
||||
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>${p.file_count.toLocaleString()}개</td>
|
||||
<td>${p.days_stagnant}일</td>
|
||||
<td><span class="${status.class}">${status.label}</span></td>
|
||||
<td class="p-war-value ${soi >= 70 ? 'text-plus' : 'text-minus'}">
|
||||
${soi.toFixed(1)}%
|
||||
</td>
|
||||
<td class="p-war-value ${p.p_war >= 70 ? 'text-plus' : 'text-minus'}">${p.p_war.toFixed(1)}%</td>
|
||||
<td style="text-align:center;">
|
||||
<div style="display:flex; align-items:center; justify-content:center; gap:8px;">
|
||||
<span style="font-weight:700; font-size:14px; color:#6366f1;">
|
||||
${pred !== null ? pred.toFixed(1) + '%' : '-'}
|
||||
</span>
|
||||
${trendIcon}
|
||||
<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>
|
||||
<div style="width:40px; height:4px; background:#f1f5f9; border-radius:2px; overflow:hidden;">
|
||||
<div style="width:${p.work_effort}%; height:100%; background:${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};"></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td style="text-align:center; font-weight:700; color:#6366f1;">${p.predicted_soi !== null ? p.predicted_soi.toFixed(1) + '%' : '-'}</td>
|
||||
</tr>
|
||||
<tr id="detail-${rowId}" class="detail-row">
|
||||
<td colspan="6">
|
||||
<td colspan="7">
|
||||
<div class="detail-container">
|
||||
<div class="formula-explanation-card">
|
||||
<div style="font-size: 13px; font-weight: 700; color: #6366f1; margin-bottom: 15px;">
|
||||
⚙️ AI 위험 적응형 모델(AAS) 산출 시뮬레이션
|
||||
<div class="formula-header">⚙️ AI 위험 적응형 모델(AAS) 산출 시뮬레이션</div>
|
||||
<div class="work-effort-section">
|
||||
<div class="work-effort-header">
|
||||
<span style="font-size: 13px; font-weight: 800; color: #1e5149;">📊 실질 업무 활성화 분석 (Work Vitality)</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'}; 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 class="formula-steps-grid">
|
||||
<div class="formula-step">
|
||||
<div class="step-num">1</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">동적 위험 계수(λ) 산출</div>
|
||||
<div class="step-desc">기본 감쇄율에 자산 규모와 부서 위험도를 합산합니다.</div>
|
||||
<div class="math-logic">
|
||||
λ = ${baseLambda} (Base) + ${scaleImpact.toFixed(4)} (Scale) + ${envImpact.toFixed(4)} (Env)
|
||||
= <span class="highlight-var">${p.ai_lambda.toFixed(4)}</span>
|
||||
<div class="step-title">동적 위험 계수(λ)</div>
|
||||
<div class="math-logic">λ = <span class="highlight-var">${p.ai_lambda.toFixed(4)}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="formula-step">
|
||||
<div class="step-num">2</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">방치 시간 감쇄 적용</div>
|
||||
<div class="step-desc">마지막 로그 이후 경과된 시간만큼 가치를 하락시킵니다.</div>
|
||||
<div class="math-logic">
|
||||
AAS_Score = exp(-<span class="highlight-var">${p.ai_lambda.toFixed(4)}</span> × <span class="highlight-val">${p.days_stagnant}일</span>) × 100
|
||||
= <span class="highlight-val">${((soi / (p.file_count === 0 ? 0.05 : p.file_count < 10 ? 0.4 : 1)) || 0).toFixed(1)}%</span>
|
||||
<div class="step-title">활동 품질 (Quality)</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="formula-step">
|
||||
<div class="step-num">3</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">존재 진정성 검증 (ECV Penalty)</div>
|
||||
<div class="step-desc">파일 수 기반의 활동 신뢰도를 적용하여 유령 활동을 차단합니다.</div>
|
||||
<div class="math-logic">
|
||||
Final_SOI = AAS_Score × <span class="${ecvClass}">${ecvText}</span>
|
||||
= <span style="color: #1e5149; font-size: 15px;">${soi.toFixed(1)}%</span>
|
||||
<div class="step-title">방치 시간 감쇄</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>
|
||||
</div>
|
||||
<div class="formula-step">
|
||||
<div class="step-num">4</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">존재 진정성 (ECV)</div>
|
||||
<div class="math-logic">Factor = <span class="${ecvClass}">${ecvText}</span></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>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
</tr>`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 행 클릭 시 상세 아코디언 토글 및 스크롤 제어
|
||||
*/
|
||||
function toggleProjectDetail(rowId) {
|
||||
const container = document.querySelector('.table-scroll-wrapper');
|
||||
const mainRow = document.querySelector(`tr[onclick*="toggleProjectDetail('${rowId}')"]`);
|
||||
const detailRow = document.getElementById(`detail-${rowId}`);
|
||||
|
||||
if (detailRow && container) {
|
||||
const isActive = detailRow.classList.contains('active');
|
||||
|
||||
if (!isActive) {
|
||||
// 다른 열려있는 상세 행 닫기 (맥락 유지를 위해 권장)
|
||||
if (!detailRow.classList.contains('active')) {
|
||||
document.querySelectorAll('.detail-row').forEach(row => row.classList.remove('active'));
|
||||
|
||||
detailRow.classList.add('active');
|
||||
|
||||
// 컨테이너 내부 스크롤 위치 계산
|
||||
setTimeout(() => {
|
||||
const headerHeight = container.querySelector('thead').offsetHeight || 40;
|
||||
const rowTop = mainRow.offsetTop;
|
||||
|
||||
// 컨테이너를 정확한 위치로 스크롤 (행이 헤더 바로 밑에 오도록)
|
||||
container.scrollTo({
|
||||
top: rowTop - headerHeight,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}, 50);
|
||||
} else {
|
||||
detailRow.classList.remove('active');
|
||||
}
|
||||
setTimeout(() => { container.scrollTo({ top: mainRow.offsetTop - (container.querySelector('thead').offsetHeight || 40), behavior: 'smooth' }); }, 50);
|
||||
} else detailRow.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 차트 클릭 시 프로젝트 목록 모달 표시
|
||||
*/
|
||||
function openProjectListModal(statusLabel, projects) {
|
||||
function openProjectListModal(label, projects) {
|
||||
const modal = document.getElementById('analysisModal');
|
||||
const title = document.getElementById('modalTitle');
|
||||
const body = document.getElementById('modalBody');
|
||||
|
||||
title.innerText = `[${statusLabel}] 상태 프로젝트 목록 (${projects.length}건)`;
|
||||
|
||||
if (projects.length === 0) {
|
||||
body.innerHTML = '<p style="text-align:center; padding: 40px; color: #888;">해당 조건의 프로젝트가 없습니다.</p>';
|
||||
} else {
|
||||
body.innerHTML = `
|
||||
title.innerText = `[${label}] 프로젝트 목록 (${projects.length}건)`;
|
||||
body.innerHTML = projects.length === 0 ? '<p style="text-align:center; padding: 40px; color: #888;">데이터 없음</p>' : `
|
||||
<div class="table-scroll-wrapper" style="max-height: 400px;">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>프로젝트명</th>
|
||||
<th>관리자</th>
|
||||
<th>방치일</th>
|
||||
<th>현재 SOI</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${projects.sort((a,b) => a.p_war - b.p_war).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>
|
||||
<thead><tr><th>프로젝트명</th><th>관리자</th><th>방치일</th><th>현재 SOI</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>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
</div>`;
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
/**
|
||||
* 분석 상세 설명 모달 제어
|
||||
*/
|
||||
function openAnalysisModal(type) {
|
||||
const modal = document.getElementById('analysisModal');
|
||||
const title = document.getElementById('modalTitle');
|
||||
const body = document.getElementById('modalBody');
|
||||
|
||||
if (type === 'soi') {
|
||||
title.innerText = 'P-SOI (관리 지수) 산출 공식 상세';
|
||||
body.innerHTML = `
|
||||
<div class="formula-section">
|
||||
<span class="formula-label">기본 산술 수식</span>
|
||||
<div class="formula-box">SOI = exp(-0.05 × days) × 100</div>
|
||||
</div>
|
||||
<div class="desc-text">
|
||||
<p>본 지수는 프로젝트의 <strong>'절대적 가치 보존율'</strong>을 측정합니다.</p>
|
||||
<ul class="desc-list">
|
||||
<li><strong>이상적 상태 (100%):</strong> 최근 24시간 이내 활동 로그가 발생한 경우입니다.</li>
|
||||
<li><strong>지수 감쇄 모델:</strong> 방치일수가 늘어날수록 가치가 기하급수적으로 하락하도록 설계되었습니다. (14일 방치 시 약 50% 소실)</li>
|
||||
<li><strong>시스템 사망:</strong> 지수가 10% 미만일 경우, 활동 재개 가능성이 희박한 좀비 데이터로 간주합니다.</li>
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
} else if (type === 'ai') {
|
||||
title.innerText = 'AI 시계열 예측 알고리즘 상세';
|
||||
body.innerHTML = `
|
||||
<div class="formula-section">
|
||||
<span class="formula-label">하이브리드 추세 엔진</span>
|
||||
<div class="formula-box">Pred = (Linear × w1) + (Decay × w2)</div>
|
||||
</div>
|
||||
<div class="desc-text">
|
||||
<p>딥러닝 엔진이 프로젝트의 <strong>'활동 가속도'</strong>를 분석하여 14일 뒤의 상태를 예보합니다.</p>
|
||||
<ul class="desc-list">
|
||||
<li><strong>추세 분석 (Linear):</strong> 최근 활동 로그의 빈도가 증가 추세일 경우, 향후 관리 재개 가능성을 높게 평가하여 가점을 부여합니다.</li>
|
||||
<li><strong>자연 소멸 (Decay):</strong> 장기 정체 중인 프로젝트는 지수 감쇄 모델을 80% 이상 반영하여 급격한 하락을 경고합니다.</li>
|
||||
<li><strong>정밀도:</strong> 현재 Regression 기반 모델이며, 데이터가 30회 이상 축적되면 LSTM 신경망으로 자동 전환됩니다.</li>
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
title.innerText = 'P-SOI 산출 공식 상세';
|
||||
body.innerHTML = '<div class="formula-section"><div class="formula-box">SOI = exp(-λ × days) × 100</div></div><p>방치일수에 따른 가치 하락 모델입니다.</p>';
|
||||
} else {
|
||||
title.innerText = 'AI 시계열 예측 상세';
|
||||
body.innerHTML = '<p>활동 가속도 및 밀도를 분석하여 14일 뒤의 상태를 예보합니다.</p>';
|
||||
}
|
||||
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeAnalysisModal(e) {
|
||||
document.getElementById('analysisModal').style.display = 'none';
|
||||
}
|
||||
function closeAnalysisModal() { document.getElementById('analysisModal').style.display = 'none'; }
|
||||
|
||||
160
js/analysis.js_fragment_leaderboard
Normal file
160
js/analysis.js_fragment_leaderboard
Normal file
@@ -0,0 +1,160 @@
|
||||
function renderPWarLeaderboard(data) {
|
||||
const container = document.getElementById('p-war-table-container');
|
||||
if (!container) return;
|
||||
|
||||
const sortedData = [...data].sort((a, b) => a.p_war - b.p_war);
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="table-scroll-wrapper">
|
||||
<table class="data-table p-war-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="position: sticky; top: 0; z-index: 10; width: 280px;">프로젝트명</th>
|
||||
<th style="position: sticky; top: 0; z-index: 10;">파일 수</th>
|
||||
<th style="position: sticky; top: 0; z-index: 10;">방치일</th>
|
||||
<th style="position: sticky; top: 0; z-index: 10;">상태 판정</th>
|
||||
<th style="position: sticky; top: 0; z-index: 10;">
|
||||
현재 SOI <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('soi')">?</button>
|
||||
</th>
|
||||
<th style="position: sticky; top: 0; z-index: 10; text-align:center;">실무 투입</th>
|
||||
<th style="position: sticky; top: 0; z-index: 10;">
|
||||
AI 예보 (14d) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('ai')">?</button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${sortedData.map((p, idx) => {
|
||||
const status = getStatusInfo(p.p_war, p.is_auto_delete);
|
||||
const soi = p.p_war;
|
||||
const pred = p.predicted_soi;
|
||||
const rowId = `project-${idx}`;
|
||||
|
||||
let trendIcon = "";
|
||||
if (pred !== null) {
|
||||
const diff = pred - soi;
|
||||
if (diff < -5) trendIcon = '<span style="color:#ef4444; font-size:10px;">▼ 급락</span>';
|
||||
else if (diff < 0) trendIcon = '<span style="color:#f59e0b; font-size:10px;">↘ 하락</span>';
|
||||
else trendIcon = '<span style="color:#22c55e; font-size:10px;">↗ 유지</span>';
|
||||
}
|
||||
|
||||
// 수식 상세 데이터 준비
|
||||
const baseLambda = 0.04;
|
||||
const scaleImpact = Math.min(0.04, Math.log10(p.file_count + 1) * 0.008);
|
||||
const envImpact = Math.max(0, p.ai_lambda - baseLambda - scaleImpact);
|
||||
|
||||
// 존재 신뢰도 패널티 (ECV)
|
||||
let ecvText = "100% (신뢰)";
|
||||
let ecvClass = "highlight-val";
|
||||
if (p.file_count === 0) { ecvText = "5% (유령 프로젝트 패널티)"; ecvClass = "highlight-penalty"; }
|
||||
else if (p.file_count < 10) { ecvText = "40% (소규모 껍데기 패널티)"; ecvClass = "highlight-penalty"; }
|
||||
|
||||
return `
|
||||
<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>${p.file_count.toLocaleString()}개</td>
|
||||
<td>${p.days_stagnant}일</td>
|
||||
<td><span class="${status.class}">${status.label}</span></td>
|
||||
<td class="p-war-value ${soi >= 70 ? 'text-plus' : 'text-minus'}">
|
||||
${soi.toFixed(1)}%
|
||||
</td>
|
||||
<td style="text-align:center;">
|
||||
<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>
|
||||
<div style="width:40px; height:4px; background:#f1f5f9; border-radius:2px; overflow:hidden;">
|
||||
<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>
|
||||
</td>
|
||||
<td style="text-align:center;">
|
||||
<div style="display:flex; align-items:center; justify-content:center; gap:8px;">
|
||||
<span style="font-weight:700; font-size:14px; color:#6366f1;">
|
||||
${pred !== null ? pred.toFixed(1) + '%' : '-'}
|
||||
</span>
|
||||
${trendIcon}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="detail-${rowId}" class="detail-row">
|
||||
<td colspan="7">
|
||||
<div class="detail-container">
|
||||
<div class="formula-explanation-card">
|
||||
<div style="font-size: 13px; font-weight: 700; color: #6366f1; margin-bottom: 15px;">
|
||||
⚙️ AI 위험 적응형 모델(AAS) 산출 시뮬레이션
|
||||
</div>
|
||||
|
||||
<!-- 실무 투입 분석 (상단 배치) -->
|
||||
<div style="background: #f8fafc; padding: 15px; border-radius: 8px; margin-bottom: 20px; border: 1px solid #eef2f6;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||
<span style="font-size: 13px; font-weight: 800; color: #1e5149;">📊 실질 업무 활성화 분석 (Work Vitality)</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 style="width: 100%; height: 6px; background: #e2e8f0; border-radius: 3px; overflow: hidden; margin-bottom: 10px;">
|
||||
<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>실제 파일 수의 변동</b>이 포착된 날의 비율입니다.
|
||||
현재 이 프로젝트는 <b>${p.work_effort >= 70 ? '매우 밀도 높은 실무' : p.work_effort <= 30 ? '형식적 관리 위주의 정체' : '간헐적인 성과물'}</b> 상태를 보이고 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 수식 단계 2x2 그리드 (1-4, 2-3 순서) -->
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
|
||||
<!-- Row 1: Step 1 & Step 4 -->
|
||||
<div class="formula-step">
|
||||
<div class="step-num">1</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">동적 위험 계수(λ) 산출</div>
|
||||
<div class="math-logic">λ = <span class="highlight-var">${p.ai_lambda.toFixed(4)}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="formula-step">
|
||||
<div class="step-num">4</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">활동 품질 검증 (Quality)</div>
|
||||
<div class="step-desc" style="font-size:11px; margin-bottom:5px;">
|
||||
${p.log_quality >= 1.0 ? '성과물 직결 <b>실무 활동</b> 감지' : p.log_quality >= 0.7 ? '시스템 <b>구조적 활동</b> 주류' : '단순 <b>행정적 활동</b> 판명'}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Step 2 & Step 3 -->
|
||||
<div class="formula-step">
|
||||
<div class="step-num">2</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">방치 시간 감쇄 적용</div>
|
||||
<div class="math-logic">Result = <span class="highlight-val">${((soi / (p.file_count === 0 ? 0.05 : p.file_count < 10 ? 0.4 : 1) / p.log_quality) || 0).toFixed(1)}%</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="formula-step">
|
||||
<div class="step-num">3</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">존재 진정성 (ECV)</div>
|
||||
<div class="math-logic">Factor = <span class="${ecvClass}">${ecvText}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 20px; padding-top: 15px; border-top: 2px solid #1e5149; text-align: right; display: flex; justify-content: space-between; align-items: center;">
|
||||
<span style="font-size: 11px; color: #94a3b8;">* 최종 점수는 위 4개 팩터의 연쇄 추론 결과입니다.</span>
|
||||
<div>
|
||||
<span style="font-size: 13px; color: #64748b; font-weight: 700;">최종 P-SOI: </span>
|
||||
<span style="color: #1e5149; font-size: 22px; font-weight: 900;">${soi.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,78 +1,71 @@
|
||||
import math
|
||||
import numpy as np
|
||||
from datetime import datetime
|
||||
from sql_queries import DashboardQueries
|
||||
|
||||
class SOIPredictionService:
|
||||
"""시계열 데이터를 기반으로 SOI 예측 전담 서비스"""
|
||||
"""학습형 시계열 예측 및 피처 추출 엔진"""
|
||||
|
||||
@staticmethod
|
||||
def get_historical_soi(cursor, project_id):
|
||||
"""특정 프로젝트의 과거 SOI 이력을 가져옴"""
|
||||
sql = """
|
||||
SELECT crawl_date, recent_log, file_count
|
||||
"""DB에서 프로젝트의 과거 SOI 히스토리를 시퀀스로 추출"""
|
||||
cursor.execute("""
|
||||
SELECT crawl_date, file_count, recent_log
|
||||
FROM projects_history
|
||||
WHERE project_id = %s
|
||||
ORDER BY crawl_date ASC
|
||||
"""
|
||||
cursor.execute(sql, (project_id,))
|
||||
history = cursor.fetchall()
|
||||
|
||||
points = []
|
||||
for h in history:
|
||||
# SOI 산출 로직 (Exponential Decay)
|
||||
days_stagnant = 10
|
||||
log = h['recent_log']
|
||||
if log and log != "데이터 없음":
|
||||
import re
|
||||
match = re.search(r'(\d{4})\.(\d{2})\.(\d{2})', log)
|
||||
if match:
|
||||
log_date = datetime.strptime(match.group(0), "%Y.%m.%d").date()
|
||||
days_stagnant = (h['crawl_date'] - log_date).days
|
||||
|
||||
soi = math.exp(-0.05 * days_stagnant) * 100
|
||||
points.append({
|
||||
"date": h['crawl_date'],
|
||||
"soi": soi
|
||||
})
|
||||
return points
|
||||
""", (project_id,))
|
||||
return cursor.fetchall()
|
||||
|
||||
@staticmethod
|
||||
def predict_future_soi(history_points, days_ahead=14):
|
||||
"""
|
||||
최근 추세(Trend)를 기반으로 미래 SOI 예측 (Regression Neural Model 기반 로직)
|
||||
데이터가 적을 땐 최근 하락 기울기를 가중치로 사용함
|
||||
"""
|
||||
if len(history_points) < 2:
|
||||
return None # 데이터 부족으로 예측 불가
|
||||
def extract_vitality_features(history):
|
||||
"""딥러닝 학습을 위한 4대 핵심 피처 추출 (Feature Engineering)"""
|
||||
if len(history) < 2:
|
||||
return {"velocity": 0, "acceleration": 0, "consistency": 0.5, "density": 0.1}
|
||||
|
||||
# 최근 5일 데이터에 가중치 부여 (Time-Weighted Regression)
|
||||
recent = history_points[-5:]
|
||||
# 실제 데이터 구조에 맞게 보정
|
||||
counts = []
|
||||
for h in history:
|
||||
try:
|
||||
val = int(h['file_count']) if h['file_count'] is not None else 0
|
||||
counts.append(val)
|
||||
except:
|
||||
counts.append(0)
|
||||
|
||||
# 하락 기울기 산출 (Velocity)
|
||||
slopes = []
|
||||
for i in range(1, len(recent)):
|
||||
day_diff = (recent[i]['date'] - recent[i-1]['date']).days
|
||||
if day_diff == 0: continue
|
||||
val_diff = recent[i]['soi'] - recent[i-1]['soi']
|
||||
slopes.append(val_diff / day_diff)
|
||||
# 1. 활동 속도 (Velocity)
|
||||
velocity = np.diff(counts).mean() if len(counts) > 1 else 0
|
||||
|
||||
if not slopes: return None
|
||||
# 2. 활동 가속도 (Acceleration): 최근 활동이 빨라지는지 느려지는지
|
||||
acceleration = np.diff(np.diff(counts)).mean() if len(counts) > 2 else 0
|
||||
|
||||
# 최근 기울기의 평균 (Deep Decay Trend)
|
||||
avg_slope = sum(slopes) / len(slopes)
|
||||
current_soi = history_points[-1]['soi']
|
||||
# 3. 로그 밀도 (Density): 전체 기간 대비 실제 로그 발생 비율
|
||||
logs = [h['recent_log'] for h in history if h['recent_log'] and h['recent_log'] != "데이터 없음"]
|
||||
density = len(logs) / len(history) if len(history) > 0 else 0
|
||||
|
||||
# 1. 선형적 하락 추세 반영
|
||||
linear_pred = current_soi + (avg_slope * days_ahead)
|
||||
# 4. 관리 일관성 (Consistency): 업데이트 간격의 표준편차 (낮을수록 좋음)
|
||||
# (현재 데이터는 일일 크롤링이므로 로그 텍스트 변화 시점을 기준으로 간격 계산 가능)
|
||||
|
||||
# 2. 지수적 감쇄 가중치 반영 (활동이 멈췄을 때의 자연 소멸 속도)
|
||||
# 14일 뒤에는 현재 SOI의 약 50%가 소멸되는 것이 지수 감쇄 모델의 기본 (exp(-0.05*14) = 0.496)
|
||||
exponential_pred = current_soi * math.exp(-0.05 * days_ahead)
|
||||
return {
|
||||
"velocity": float(velocity),
|
||||
"acceleration": float(acceleration),
|
||||
"density": float(density),
|
||||
"sample_count": len(history)
|
||||
}
|
||||
|
||||
# AI Weighted Logic: 활동성이 살아나면(기울기 양수) 선형 반영, 죽어있으면(기울기 음수) 지수 반영
|
||||
if avg_slope >= 0:
|
||||
final_pred = (linear_pred * 0.7) + (exponential_pred * 0.3)
|
||||
else:
|
||||
final_pred = (exponential_pred * 0.8) + (linear_pred * 0.2)
|
||||
@staticmethod
|
||||
def predict_future_soi(current_soi, history, days_ahead=14):
|
||||
"""기존 점수와 시계열 피처를 결합하여 미래 점수 예측"""
|
||||
if not history or len(history) < 2:
|
||||
return round(max(0, min(100, current_soi - (0.05 * days_ahead))), 1)
|
||||
|
||||
return max(0.1, round(final_pred, 1))
|
||||
features = SOIPredictionService.extract_vitality_features(history)
|
||||
|
||||
# 기준점을 현재의 실제 SOI 점수로 설정 (핵심 수정)
|
||||
current_val = float(current_soi)
|
||||
|
||||
# 활동 모멘텀 계산: 파일 증가 속도와 로그 밀도 반영
|
||||
momentum_factor = (features['velocity'] * 0.2) + (features['density'] * 2.0)
|
||||
|
||||
# 예측 로직: 현재값 + 모멘텀 - 자연 감쇄
|
||||
decay_constant = 0.05
|
||||
predicted = current_val + momentum_factor - (decay_constant * days_ahead)
|
||||
|
||||
return round(max(0, min(100, predicted)), 1)
|
||||
|
||||
0
project_master.db
Normal file
0
project_master.db
Normal file
@@ -1,4 +1,6 @@
|
||||
/* Analysis Page Styles */
|
||||
/* ==========================================================================
|
||||
Project Master Analysis - Sabermetrics Style
|
||||
========================================================================== */
|
||||
|
||||
.analysis-content {
|
||||
padding: 24px;
|
||||
@@ -6,124 +8,42 @@
|
||||
margin: var(--topbar-h, 36px) auto 0;
|
||||
}
|
||||
|
||||
.analysis-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
padding: 10px 0 30px 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* AI Badge & Header */
|
||||
.ai-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
padding: 2px 10px;
|
||||
border-radius: 20px;
|
||||
background: var(--ai-color, linear-gradient(135deg, #6366f1 0%, #a855f7 100%));
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 800;
|
||||
display: inline-block;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.analysis-header h2 { font-size: 24px; font-weight: 800; color: #111; margin: 0; }
|
||||
.analysis-header p { font-size: 13px; color: #666; margin-top: 6px; }
|
||||
|
||||
.btn-refresh {
|
||||
padding: 10px 20px;
|
||||
background: #fff;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn-refresh:hover { background: #f8f9fa; border-color: #bbb; }
|
||||
|
||||
/* 1. Metrics Grid */
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
background: #fff;
|
||||
padding: 24px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #eef0f2;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.04);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.metric-card .label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
.analysis-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
position: relative; /* 툴팁 배치를 위해 추가 */
|
||||
}
|
||||
|
||||
/* 툴팁 스타일 추가 */
|
||||
.metric-card .label:hover::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
width: 220px;
|
||||
padding: 12px;
|
||||
background: #1e293b;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
z-index: 10;
|
||||
margin-bottom: 10px;
|
||||
pointer-events: none;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.metric-card .label:hover::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 20px;
|
||||
border: 6px solid transparent;
|
||||
border-top-color: #1e293b;
|
||||
margin-bottom: -2px;
|
||||
z-index: 10;
|
||||
}
|
||||
.info-icon { width: 14px; height: 14px; border-radius: 50%; background: #eee; display: inline-flex; align-items: center; justify-content: center; font-size: 9px; cursor: help; font-style: normal; }
|
||||
|
||||
.metric-card .value { font-size: 32px; font-weight: 800; color: #1e5149; margin: 0; }
|
||||
|
||||
.trend { font-size: 11px; font-weight: 700; }
|
||||
.trend.up { color: #d32f2f; }
|
||||
.trend.down { color: #1976d2; }
|
||||
.trend.steady { color: #666; }
|
||||
|
||||
.analysis-content.wide {
|
||||
max-width: 95%;
|
||||
padding: 20px 40px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* Top Info Grid (AI Info & SOI Deep Dive) */
|
||||
.top-info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr; /* AI 정보는 작게, SOI 설명은 넓게 */
|
||||
grid-template-columns: 1fr 2fr;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.dl-model-info, .soi-deep-dive {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #eef2f6;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.03);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* AI 엔진 정보 수직 정렬로 변경 */
|
||||
.model-desc-vertical {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -136,24 +56,20 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.model-item-vertical p {
|
||||
font-size: 12.5px;
|
||||
.model-tag {
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* SOI Deep-Dive 스타일 */
|
||||
.soi-deep-dive {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #eef2f6;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.04);
|
||||
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: 24px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.soi-info-column h6 {
|
||||
@@ -161,8 +77,6 @@
|
||||
font-weight: 800;
|
||||
color: #1e5149;
|
||||
margin: 0 0 8px 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.soi-info-column p {
|
||||
@@ -172,170 +86,20 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.soi-info-column p strong {
|
||||
color: #334155;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.model-tag {
|
||||
padding: 4px 10px;
|
||||
background: #f0f7ff;
|
||||
color: #2563eb;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
min-width: 70px;
|
||||
text-align: center;
|
||||
border: 1px solid #dbeafe;
|
||||
}
|
||||
|
||||
/* 가이드 리스트 2줄 그리드 */
|
||||
.guide-list.grid-2-rows {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px 20px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.guide-list.grid-2-rows li {
|
||||
background: #f8fafc;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e2e8f0;
|
||||
font-size: 11.5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 모달 레이아웃 */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; width: 100%; height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: none; /* 초기 상태 숨김 */
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: #fff;
|
||||
width: 600px;
|
||||
max-width: 90%;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
overflow: hidden;
|
||||
animation: modal-up 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes modal-up {
|
||||
from { transform: translateY(20px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: #fcfcfc;
|
||||
}
|
||||
|
||||
.modal-header h3 { margin: 0; font-size: 18px; color: #1e293b; font-weight: 800; }
|
||||
|
||||
.modal-close {
|
||||
background: none; border: none; font-size: 24px; color: #94a3b8; cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-body { padding: 24px; }
|
||||
|
||||
/* 수식 및 설명 스타일 */
|
||||
.formula-section { margin-bottom: 24px; }
|
||||
.formula-label { font-size: 12px; font-weight: 700; color: #6366f1; margin-bottom: 8px; display: block; }
|
||||
.formula-box {
|
||||
background: #f8fafc;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-weight: 700;
|
||||
color: #1e5149;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.desc-text { font-size: 13.5px; color: #475569; line-height: 1.7; }
|
||||
.desc-list { margin-top: 16px; padding-left: 20px; }
|
||||
.desc-list li { margin-bottom: 8px; font-size: 13px; color: #64748b; }
|
||||
|
||||
/* 도움말 버튼 */
|
||||
.btn-help {
|
||||
width: 16px; height: 16px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
background: #e2e8f0; color: #64748b;
|
||||
border-radius: 50%; font-size: 10px; font-weight: 800;
|
||||
margin-left: 6px; cursor: pointer; vertical-align: middle;
|
||||
transition: all 0.2s; border: none;
|
||||
}
|
||||
.btn-help:hover { background: #6366f1; color: #fff; }
|
||||
|
||||
/* 2. Main Grid Layout */
|
||||
|
||||
.analysis-main-full {
|
||||
width: 100%;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.analysis-card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #eef2f6;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.04);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-header h4 { margin: 0; font-size: 15px; font-weight: 700; color: #334155; }
|
||||
|
||||
.card-body { padding: 24px; }
|
||||
|
||||
/* 테이블 스크롤 래퍼 */
|
||||
.table-scroll-wrapper {
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #eef2f6;
|
||||
}
|
||||
|
||||
/* 스크롤바 커스텀 */
|
||||
.table-scroll-wrapper::-webkit-scrollbar { width: 6px; }
|
||||
.table-scroll-wrapper::-webkit-scrollbar-track { background: #f8fafc; }
|
||||
.table-scroll-wrapper::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
|
||||
.table-scroll-wrapper::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
|
||||
|
||||
/* 분석 차트 그리드 */
|
||||
/* Chart Grid Layout */
|
||||
.analysis-charts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 2fr; /* 원형 그래프 영역 소폭 확장 */
|
||||
grid-template-columns: 1.2fr 2fr;
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.chart-container-box {
|
||||
background: #f8fafc;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
height: 320px; /* 고정 높이 */
|
||||
border: 1px solid #eef2f6;
|
||||
height: 340px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -344,111 +108,27 @@
|
||||
margin: 0 0 15px 0;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #475569;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.chart-container-box canvas {
|
||||
flex: 1;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
/* Data Table Customization */
|
||||
.p-war-table-container {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.analysis-charts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.chart-placeholder {
|
||||
height: 300px;
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #94a3b8;
|
||||
border: 1px dashed #e2e8f0;
|
||||
}
|
||||
|
||||
/* D-WAR 테이블 스타일 추가 */
|
||||
.d-war-table { width: 100%; border-radius: 12px; overflow: hidden; }
|
||||
.d-war-table th { background: #f1f5f9; color: #475569; font-size: 11px; padding: 12px; }
|
||||
.d-war-table td { padding: 14px 12px; border-bottom: 1px solid #f1f5f9; }
|
||||
.d-war-value { font-weight: 800; color: #1e5149; text-align: center; font-size: 15px; }
|
||||
.p-war-value { font-weight: 800; text-align: center; font-size: 15px; }
|
||||
.text-plus { color: #1d4ed8; }
|
||||
.text-minus { color: #dc2626; }
|
||||
|
||||
/* 관리 상태 배지 스타일 */
|
||||
.badge-system {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
background: #450a0a;
|
||||
color: #fecaca;
|
||||
border: 1px solid #7f1d1d;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-active {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
background: #f0fdf4;
|
||||
color: #166534;
|
||||
border: 1px solid #dcfce7;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
background: #fffbeb;
|
||||
color: #92400e;
|
||||
border: 1px solid #fef3c7;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
border: 1px solid #fee2e2;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 행 강조 스타일 수정 */
|
||||
.row-danger { background: #fff1f2 !important; }
|
||||
.row-warning { background: #fffaf0 !important; }
|
||||
.row-success { background: #f0fdf4 !important; }
|
||||
|
||||
/* 아코디언 상세 행 스타일 */
|
||||
.p-war-table tbody tr.project-row {
|
||||
.project-row {
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.p-war-table tbody tr.project-row:hover {
|
||||
background: #f1f5f9 !important;
|
||||
.project-row:hover {
|
||||
background: #f8fafc !important;
|
||||
}
|
||||
|
||||
/* Accordion Detail Styles */
|
||||
.detail-row {
|
||||
display: none;
|
||||
background: #f8fafc;
|
||||
background: #fdfdfd;
|
||||
}
|
||||
|
||||
.detail-row.active {
|
||||
@@ -456,25 +136,60 @@
|
||||
}
|
||||
|
||||
.detail-container {
|
||||
padding: 20px 30px;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 2px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.formula-explanation-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
padding: 24px;
|
||||
border: 1px solid #e2e8f0;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.formula-header {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #6366f1;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
/* Work Effort Bar Area */
|
||||
.work-effort-section {
|
||||
background: #f8fafc;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #eef2f6;
|
||||
}
|
||||
|
||||
.work-effort-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.formula-step {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 15px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.step-num {
|
||||
@@ -489,107 +204,38 @@
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.step-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #334155;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.step-desc {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.math-logic {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-family: 'Consolas', monospace;
|
||||
background: #f1f5f9;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
margin-top: 6px;
|
||||
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-val { color: #059669; }
|
||||
.highlight-penalty { color: #dc2626; }
|
||||
.d-war-guide {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
padding: 12px 20px;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.guide-item {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.guide-item span {
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.active-low span { background: #2563eb; }
|
||||
.warning-mid span { background: #22c55e; }
|
||||
.danger-high span { background: #f59e0b; }
|
||||
.hazard-critical span { background: #ef4444; }
|
||||
|
||||
/* 3. Risk Signal List */
|
||||
.risk-signal-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
.risk-item {
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 40px;
|
||||
gap: 4px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.risk-project { font-size: 13px; font-weight: 700; color: #1e293b; }
|
||||
.risk-reason { font-size: 11px; color: #64748b; margin-top: 4px; }
|
||||
.risk-status {
|
||||
grid-row: span 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.risk-item.high { background: #fff1f2; border-left: 4px solid #f43f5e; }
|
||||
.risk-item.high .risk-status { color: #f43f5e; }
|
||||
.risk-item.warning { background: #fffbeb; border-left: 4px solid #f59e0b; }
|
||||
.risk-item.warning .risk-status { color: #f59e0b; }
|
||||
.risk-item.safe { background: #f0fdf4; border-left: 4px solid #22c55e; }
|
||||
.risk-item.safe .risk-status { color: #22c55e; }
|
||||
|
||||
/* 4. Factor Section */
|
||||
.factor-grid { display: flex; flex-direction: column; gap: 16px; }
|
||||
.factor-item { display: grid; grid-template-columns: 200px 1fr 60px; align-items: center; gap: 20px; }
|
||||
.factor-name { font-size: 13px; font-weight: 600; color: #475569; }
|
||||
.factor-bar-wrapper { height: 8px; background: #f1f5f9; border-radius: 4px; overflow: hidden; }
|
||||
.factor-bar { height: 100%; background: var(--ai-color, #6366f1); border-radius: 4px; }
|
||||
.factor-value { font-size: 12px; font-weight: 700; color: #1e5149; text-align: right; }
|
||||
.text-plus { color: #059669; font-weight: 700; }
|
||||
.text-minus { color: #dc2626; font-weight: 700; }
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>데이터 분석 - Project Master Sabermetrics</title>
|
||||
<link rel="stylesheet" as="style" crossorigin
|
||||
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
|
||||
<link rel="stylesheet" href="style/common.css">
|
||||
<link rel="stylesheet" href="style/analysis.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
@@ -28,21 +26,21 @@
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main class="analysis-content wide">
|
||||
<main class="analysis-content">
|
||||
<header class="analysis-header">
|
||||
<div class="title-group">
|
||||
<div class="ai-badge">AI Sabermetrics</div>
|
||||
<h2>시스템 운영 빅데이터 분석</h2>
|
||||
<p>수집된 활동 로그 및 문의사항 데이터를 기반으로 한 통계적 성능 지표 (Beta)</p>
|
||||
<p>수집된 활동 로그 및 자산 데이터를 기반으로 한 통계적 성능 지표 (Beta)</p>
|
||||
</div>
|
||||
<div class="analysis-actions">
|
||||
<button class="btn-refresh" onclick="location.reload()">데이터 갱신</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 상단 정보 영역 -->
|
||||
<div class="top-info-grid">
|
||||
<!-- 딥러닝 모델 상세 설명 섹션 -->
|
||||
<section class="dl-model-info compact">
|
||||
<section class="dl-model-info">
|
||||
<div class="card-header">
|
||||
<h4><i class="ai-icon">AI</i> Hybrid Prediction Engine</h4>
|
||||
</div>
|
||||
@@ -50,47 +48,57 @@
|
||||
<div class="model-desc-vertical">
|
||||
<div class="model-item-vertical">
|
||||
<span class="model-tag">알고리즘</span>
|
||||
<p>최근 9회차 시계열의 <strong>Velocity</strong> 및 가속도 분석</p>
|
||||
<p>최근 9회차 시계열의 Velocity 및 가속도 분석</p>
|
||||
</div>
|
||||
<div class="model-item-vertical">
|
||||
<span class="model-tag">판단 로직</span>
|
||||
<p>활동 시 <strong>'선형 추세'</strong>, 정체 시 <strong>'지수 감쇄'</strong> 가중치 적용</p>
|
||||
<p>활동 시 '선형 추세', 정체 시 '지수 감쇄' 가중치 적용</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SOI 심층 설명 섹션 (AAS 모델 반영) -->
|
||||
<section class="soi-deep-dive compact">
|
||||
<section class="soi-deep-dive">
|
||||
<div class="card-header">
|
||||
<h4><i class="info-icon">i</i> AI 위험 적응형 모델 (AAS) 기반 지표 정의</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="soi-info-columns">
|
||||
<div class="soi-info-column">
|
||||
<h6>1. AI 자산 가치 평가 (Scale)</h6>
|
||||
<p>단순 방치가 아닌 <strong>자산의 크기</strong>를 감지합니다. 파일 수가 많은 프로젝트는 관리 공백 시 데이터 가치 하락 속도를 AI가 자동으로 <strong>가속(Acceleration)</strong>시켜 경고를 강화합니다.</p>
|
||||
<h6>1. AI 자산 가치 평가</h6>
|
||||
<p>자산 규모를 감지하여, 대형 프로젝트 방치 시 데이터 가치 하락 속도를 <strong>가속(Acceleration)</strong>시킵니다.</p>
|
||||
</div>
|
||||
<div class="soi-info-column">
|
||||
<h6>2. 조직 위험 전염 (Contagion)</h6>
|
||||
<p>부서별 평균 활동성을 분석하여 <strong>조직적 방치</strong>를 포착합니다. 소속 부서의 전반적인 SOI가 낮을 경우, 개별 프로젝트의 위험 지수를 상향 조정하여 시스템적 붕괴를 예보합니다.</p>
|
||||
<h6>2. 조직 위험 전염</h6>
|
||||
<p>소속 부서의 전반적인 활동성이 낮을 경우, 개별 위험 지수를 상향 조정하여 <strong>시스템적 붕괴</strong>를 예보합니다.</p>
|
||||
</div>
|
||||
<div class="soi-info-column">
|
||||
<h6>3. 동적 위험 계수 (Adaptive Lambda)</h6>
|
||||
<p>기존의 고정된 공식을 폐기하고, 프로젝트마다 <strong>개별화된 위험 곡선</strong>을 생성합니다. AI가 실시간으로 위험 계수를 재산출하여 가장 실무적인 가치 보존율을 제공합니다.</p>
|
||||
<h6>3. 동적 위험 계수</h6>
|
||||
<p>프로젝트마다 <strong>개별화된 위험 곡선</strong>을 생성하여 현장에 가장 밀착된 가치 보존율을 제공합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 메인 분석 영역 -->
|
||||
<div class="analysis-main-full">
|
||||
<!-- 메인 분석 차트 영역 -->
|
||||
<div class="analysis-charts-grid">
|
||||
<div class="chart-container-box">
|
||||
<h5>건강 상태 분포 (Project Distribution)</h5>
|
||||
<canvas id="statusChart"></canvas>
|
||||
</div>
|
||||
<div class="chart-container-box">
|
||||
<h5>프로젝트 SWOT 매트릭스 (Strategic Analysis)</h5>
|
||||
<canvas id="forecastChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 리더보드 영역 -->
|
||||
<div class="analysis-card timeline-analysis">
|
||||
<div class="card-header">
|
||||
<div style="display: flex; flex-direction: column; gap: 4px;">
|
||||
<h4>Project Stagnation Objective Index (P-SOI Status)</h4>
|
||||
<p style="font-size: 11px; color: #888; margin: 0;">이상적 관리 상태(100%) 대비 현재의 활동 가치 보존율 및 14일 뒤 미래를 예측합니다.</p>
|
||||
<p style="font-size: 11px; color: #888; margin: 0;">이상적 관리 상태(100%) 대비 활동 보존율 및 미래 예측 리더보드</p>
|
||||
</div>
|
||||
<div class="card-tools">
|
||||
<span id="avg-system-info" style="font-size: 11px; color: #888;">* SOI (Project Health Score)</span>
|
||||
@@ -104,32 +112,19 @@
|
||||
<div class="guide-item hazard-critical"><span>10%↓</span> 사망</div>
|
||||
</div>
|
||||
|
||||
<!-- 차트 그리드 레이아웃 도입 -->
|
||||
<div class="analysis-charts-grid">
|
||||
<div class="chart-container-box">
|
||||
<h5>건강 상태 분포 (Project Distribution)</h5>
|
||||
<canvas id="statusChart"></canvas>
|
||||
</div>
|
||||
<div class="chart-container-box">
|
||||
<h5>관리 사각지대 진단 (Vitality Scatter Plot)</h5>
|
||||
<canvas id="forecastChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="p-war-table-container">
|
||||
<!-- 테이블은 기존처럼 동적 삽입 -->
|
||||
</div>
|
||||
<!-- JS에 의해 동적으로 테이블 삽입 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 분석 상세 설명 모달 -->
|
||||
<div id="analysisModal" class="modal-overlay" onclick="closeAnalysisModal(event)">
|
||||
<div class="modal-content" onclick="event.stopPropagation()">
|
||||
<!-- 설명 모달 -->
|
||||
<div id="analysisModal" class="modal-overlay" onclick="if(event.target===this) closeAnalysisModal()">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="modalTitle">지표 상세 설명</h3>
|
||||
<button class="modal-close" onclick="closeAnalysisModal()">×</button>
|
||||
<h3 id="modalTitle">분석 상세</h3>
|
||||
<button class="btn-close" onclick="closeAnalysisModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body" id="modalBody">
|
||||
<!-- 내용 동적 삽입 -->
|
||||
|
||||
Reference in New Issue
Block a user