refactor: 분석 페이지 코드 정돈 및 AI 엔진 고도화 통합

This commit is contained in:
2026-03-23 17:49:24 +09:00
parent d416fee414
commit be3210463f
14 changed files with 590 additions and 854 deletions

49
ANALYSIS_REPORT.md Normal file
View 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**: 특정 담당자나 부서의 업무 과부하 패턴을 학습하여 집단 방치 위험을 선제적으로 예보.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -127,22 +127,46 @@ class AnalysisService:
# 지수 감쇄 적용 (AAS Score) # 지수 감쇄 적용 (AAS Score)
soi_score = math.exp(-ai_lambda * days_stagnant) * 100 soi_score = math.exp(-ai_lambda * days_stagnant) * 100
# [AI 데이터 진정성 검증 로직 - ECV 패널티 추가] # [AI 데이터 진정성 검증 로직 1 - ECV 패널티 (존재론적)]
# 파일이 하나도 없거나(유령), 현저히 적은 경우(껍데기) 활동의 진정성을 불신함
existence_confidence = 1.0 existence_confidence = 1.0
if file_count == 0: if file_count == 0:
existence_confidence = 0.05 # 파일 0개는 로그가 있어도 최대 5% 미만으로 강제 existence_confidence = 0.05
elif file_count < 10: 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: if is_auto_delete:
soi_score = 0.1 soi_score = 0.1
# [AI 미래 예측 연동] # [AI 미래 예측 및 실무 투입 에너지 분석]
history = SOIPredictionService.get_historical_soi(cursor, p['project_id']) history_rows = SOIPredictionService.get_historical_soi(cursor, p['project_id'])
predicted_soi = SOIPredictionService.predict_future_soi(history, days_ahead=14) 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 total_soi += soi_score
@@ -156,7 +180,9 @@ class AnalysisService:
"is_auto_delete": is_auto_delete, "is_auto_delete": is_auto_delete,
"master": p['master'], "master": p['master'],
"dept": p['department'], "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_info": {
"avg_files": 0, "avg_files": 0,
"avg_stagnant": 0, "avg_stagnant": 0,

View File

@@ -3,6 +3,11 @@
* P-WAR (Project Performance Above Replacement) 분석 엔진 * P-WAR (Project Performance Above Replacement) 분석 엔진
*/ */
// Chart.js 플러그인 전역 등록
if (typeof ChartDataLabels !== 'undefined') {
Chart.register(ChartDataLabels);
}
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
console.log("Analysis engine initialized..."); console.log("Analysis engine initialized...");
loadPWarData(); loadPWarData();
@@ -12,43 +17,32 @@ async function loadPWarData() {
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); renderPWarLeaderboard(data);
renderSOICharts(data); renderSOICharts(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;
document.getElementById('avg-system-info').textContent = const infoEl = document.getElementById('avg-system-info');
`* 시스템 종합 건강도: ${avg.avg_risk}% (0.0%에 가까울수록 시스템 전반의 방치 심각)`; if (infoEl) infoEl.textContent = `* 시스템 종합 건강도: ${avg.avg_risk}% (0.0%에 가까울수록 방치 심각)`;
} }
} catch (e) { } catch (e) {
console.error("분석 데이터 로딩 실패:", e); console.error("분석 데이터 로딩 실패:", e);
} }
} }
// 상태 판정 공통 함수
function getStatusInfo(soi, isAutoDelete) { function getStatusInfo(soi, isAutoDelete) {
if (isAutoDelete || soi < 10) { if (isAutoDelete || soi < 10) return { label: '사망', class: 'badge-system', key: 'dead' };
return { label: '사망', class: 'badge-system', key: 'dead' }; if (soi < 30) return { label: '위험', class: 'badge-danger', key: 'danger' };
} else if (soi < 30) { if (soi < 70) return { label: '주의', class: 'badge-warning', key: 'warning' };
return { label: '위험', class: 'badge-danger', key: 'danger' };
} else if (soi < 70) {
return { label: '주의', class: 'badge-warning', key: 'warning' };
} else {
return { label: '정상', class: 'badge-active', key: 'active' }; return { label: '정상', class: 'badge-active', key: 'active' };
}
} }
// Chart.js 시각화 엔진
function renderSOICharts(data) { function renderSOICharts(data) {
if (!data || data.length === 0) return; if (!data || data.length === 0) return;
// --- 1. 상태 분포 데이터 가공 (Doughnut Chart) --- // 1. 상태 분포 (Doughnut)
try { try {
const stats = { active: [], warning: [], danger: [], dead: [] }; const stats = { active: [], warning: [], danger: [], dead: [] };
data.forEach(p => { data.forEach(p => {
@@ -72,30 +66,26 @@ function renderSOICharts(data) {
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
layout: { padding: 15 },
plugins: { plugins: {
legend: { legend: { position: 'right', labels: { boxWidth: 10, font: { size: 11, weight: '700' }, usePointStyle: true } },
position: 'right',
labels: { boxWidth: 10, font: { size: 11, weight: '700' }, usePointStyle: true }
},
datalabels: { display: false } datalabels: { display: false }
}, },
cutout: '65%', cutout: '65%',
onClick: (event, elements) => { onClick: (e, elements) => {
if (elements.length > 0) { if (elements.length > 0) {
const index = elements[0].index; const idx = elements[0].index;
const keys = ['active', 'warning', 'danger', 'dead']; openProjectListModal(['정상', '주의', '위험', '사망'][idx], stats[['active', 'warning', 'danger', 'dead'][idx]]);
const labels = ['정상', '주의', '위험', '사망'];
openProjectListModal(labels[index], stats[keys[index]]);
} }
} }
} }
}); });
} catch (err) { console.error("도넛 차트 생성 실패:", err); } } catch (err) { console.error("도넛 차트 에러:", err); }
// --- 2. 프로젝트 SWOT 매트릭스 진단 (Scatter Chart) --- // 2. 프로젝트 SWOT 매트릭스 (Scatter)
try { try {
const scatterData = data.map(p => ({ const scatterData = data.map(p => ({
x: Math.min(500, p.file_count), // 최대 500으로 조정 x: Math.min(500, p.file_count),
y: p.p_war, y: p.p_war,
label: p.project_nm label: p.project_nm
})); }));
@@ -103,22 +93,45 @@ function renderSOICharts(data) {
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();
const plugins = []; // 플러그인 통합 (Duplicate Key 방지)
if (typeof ChartDataLabels !== 'undefined') plugins.push(ChartDataLabels); 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: plugins, plugins: chartPlugins,
data: { data: {
datasets: [{ datasets: [{
data: scatterData, data: scatterData,
backgroundColor: (context) => { backgroundColor: (ctx) => {
const p = context.raw; const p = ctx.raw;
if (!p) return '#94a3b8'; if (!p) return '#94a3b8';
if (p.x >= 250 && p.y >= 50) return '#1E5149'; // 핵심 우량 (기준 250) 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 '#22c55e';
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: 6,
hoverRadius: 10 hoverRadius: 10
@@ -130,80 +143,37 @@ function renderSOICharts(data) {
layout: { padding: { top: 30, right: 40, left: 10, bottom: 10 } }, layout: { padding: { top: 30, right: 40, left: 10, bottom: 10 } },
scales: { scales: {
x: { x: {
type: 'linear', type: 'linear', min: 0, max: 500,
min: 0,
max: 500, // 데이터 분포에 최적화
title: { display: true, text: '자산 규모 (파일 수)', font: { size: 11, weight: '700' } }, title: { display: true, text: '자산 규모 (파일 수)', font: { size: 11, weight: '700' } },
grid: { display: false }, grid: { display: false },
ticks: { stepSize: 125, callback: (val) => val >= 500 ? '500+' : val.toLocaleString() } ticks: { stepSize: 125, callback: (v) => v >= 500 ? '500+' : v }
}, },
y: { y: {
min: 0, min: 0, max: 100,
max: 100,
title: { display: true, text: '활동성 (SOI %)', font: { size: 11, weight: '700' } }, title: { display: true, text: '활동성 (SOI %)', font: { size: 11, weight: '700' } },
grid: { display: false }, grid: { display: false }
ticks: { stepSize: 25 }
} }
}, },
plugins: { plugins: {
legend: { display: false }, legend: { display: false },
datalabels: { datalabels: {
align: 'top', align: 'top', offset: 5, font: { size: 10, weight: '700' }, color: '#475569',
offset: 5, formatter: (v) => v.label,
font: { size: 10, weight: '700' }, display: (ctx) => ctx.raw.x > 100 || ctx.raw.y < 30,
color: '#475569',
formatter: (value) => value.label,
display: (context) => context.raw.x > 100 || context.raw.y < 30,
clip: false clip: false
}, },
tooltip: { tooltip: {
callbacks: { callbacks: { label: (ctx) => ` [${ctx.raw.label}] SOI: ${ctx.raw.y.toFixed(1)}% | 파일: ${ctx.raw.x >= 500 ? '500+' : ctx.raw.x}` }
label: (context) => ` [${context.raw.label}] SOI: ${context.raw.y.toFixed(1)}% | 파일: ${context.raw.x >= 500 ? '500+' : context.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) { function renderPWarLeaderboard(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);
container.innerHTML = ` container.innerHTML = `
@@ -211,239 +181,136 @@ function renderPWarLeaderboard(data) {
<table class="data-table p-war-table"> <table class="data-table p-war-table">
<thead> <thead>
<tr> <tr>
<th style="position: sticky; top: 0; z-index: 10; width: 280px;">프로젝트명</th> <th style="width: 280px;">프로젝트명</th>
<th style="position: sticky; top: 0; z-index: 10;">파일 수</th> <th>파일 수</th>
<th style="position: sticky; top: 0; z-index: 10;">방치일</th> <th>방치일</th>
<th style="position: sticky; top: 0; z-index: 10;">상태 판정</th> <th>상태 판정</th>
<th style="position: sticky; top: 0; z-index: 10;"> <th>현재 SOI <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('soi')">?</button></th>
현재 SOI <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('soi')">?</button> <th style="text-align:center;">실무 투입</th>
</th> <th>AI 예보 (14d) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('ai')">?</button></th>
<th style="position: sticky; top: 0; z-index: 10;">
AI 예보 (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 soi = p.p_war;
const pred = p.predicted_soi;
const rowId = `project-${idx}`; const rowId = `project-${idx}`;
const ecvText = p.file_count === 0 ? "5% (유령)" : p.file_count < 10 ? "40% (껍데기)" : "100% (신뢰)";
let trendIcon = ""; const ecvClass = (p.file_count < 10) ? "highlight-penalty" : "highlight-val";
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 ` return `
<tr class="project-row ${status.key === 'danger' ? 'row-danger' : status.key === 'warning' ? 'row-warning' : ''}" <tr class="project-row ${status.key === 'danger' ? 'row-danger' : status.key === 'warning' ? 'row-warning' : ''}" onclick="toggleProjectDetail('${rowId}')">
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}</span></td>
<td class="p-war-value ${soi >= 70 ? 'text-plus' : 'text-minus'}"> <td class="p-war-value ${p.p_war >= 70 ? 'text-plus' : 'text-minus'}">${p.p_war.toFixed(1)}%</td>
${soi.toFixed(1)}%
</td>
<td style="text-align:center;"> <td style="text-align:center;">
<div style="display:flex; align-items:center; justify-content:center; gap:8px;"> <div style="display:flex; flex-direction:column; align-items:center; gap:4px;">
<span style="font-weight:700; font-size:14px; color:#6366f1;"> <span style="font-weight:800; font-size:12px; color:${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};">${p.work_effort}%</span>
${pred !== null ? pred.toFixed(1) + '%' : '-'} <div style="width:40px; height:4px; background:#f1f5f9; border-radius:2px; overflow:hidden;">
</span> <div style="width:${p.work_effort}%; height:100%; background:${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};"></div>
${trendIcon} </div>
</div> </div>
</td> </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="6"> <td colspan="7">
<div class="detail-container"> <div class="detail-container">
<div class="formula-explanation-card"> <div class="formula-explanation-card">
<div style="font-size: 13px; font-weight: 700; color: #6366f1; margin-bottom: 15px;"> <div class="formula-header">⚙️ AI 위험 적응형 모델(AAS) 산출 시뮬레이션</div>
AI 위험 적응형 모델(AAS) 산출 시뮬레이션 <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>
<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="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">기본 감쇄율에 자산 규모와 부서 위험도를 합산합니다.</div> <div class="math-logic">λ = <span class="highlight-var">${p.ai_lambda.toFixed(4)}</span></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> </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">방치 시간 감쇄 적용</div> <div class="step-title">활동 품질 (Quality)</div>
<div class="step-desc">마지막 로그 이후 경과된 시간만큼 가치를 하락시킵니다.</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">
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> </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">존재 진정성 검증 (ECV Penalty)</div> <div class="step-title">방치 시간 감쇄</div>
<div class="step-desc">파일 기반의 활동 신뢰도를 적용하여 유령 활동을 차단합니다.</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">
Final_SOI = AAS_Score × <span class="${ecvClass}">${ecvText}</span>
= <span style="color: #1e5149; font-size: 15px;">${soi.toFixed(1)}%</span>
</div> </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> </div>
</div> </div>
</td> </td>
</tr> </tr>`;
`;
}).join('')} }).join('')}
</tbody> </tbody>
</table> </table>
</div> </div>`;
`;
} }
/**
* 테이블 행 클릭 시 상세 아코디언 토글 및 스크롤 제어
*/
function toggleProjectDetail(rowId) { 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) {
const isActive = detailRow.classList.contains('active'); if (!detailRow.classList.contains('active')) {
if (!isActive) {
// 다른 열려있는 상세 행 닫기 (맥락 유지를 위해 권장)
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);
// 컨테이너 내부 스크롤 위치 계산 } else detailRow.classList.remove('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');
}
} }
} }
/** function openProjectListModal(label, projects) {
* 차트 클릭 시 프로젝트 목록 모달 표시
*/
function openProjectListModal(statusLabel, 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 = `[${statusLabel}] 상태 프로젝트 목록 (${projects.length}건)`; body.innerHTML = projects.length === 0 ? '<p style="text-align:center; padding: 40px; color: #888;">데이터 없음</p>' : `
if (projects.length === 0) {
body.innerHTML = '<p style="text-align:center; padding: 40px; color: #888;">해당 조건의 프로젝트가 없습니다.</p>';
} else {
body.innerHTML = `
<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> <thead><tr><th>프로젝트명</th><th>관리자</th><th>방치일</th><th>현재 SOI</th></tr></thead>
<tr> <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>
<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>
</table> </table>
</div> </div>`;
`;
}
modal.style.display = 'flex'; modal.style.display = 'flex';
} }
/**
* 분석 상세 설명 모달 제어
*/
function openAnalysisModal(type) { 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') { if (type === 'soi') {
title.innerText = 'P-SOI (관리 지수) 산출 공식 상세'; title.innerText = 'P-SOI 산출 공식 상세';
body.innerHTML = ` body.innerHTML = '<div class="formula-section"><div class="formula-box">SOI = exp(-λ × days) × 100</div></div><p>방치일수에 따른 가치 하락 모델입니다.</p>';
<div class="formula-section"> } else {
<span class="formula-label">기본 산술 수식</span> title.innerText = 'AI 시계열 예측 상세';
<div class="formula-box">SOI = exp(-0.05 × days) × 100</div> body.innerHTML = '<p>활동 가속도 및 밀도를 분석하여 14일 뒤의 상태를 예보합니다.</p>';
</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>
`;
} }
modal.style.display = 'flex'; modal.style.display = 'flex';
} }
function closeAnalysisModal(e) { function closeAnalysisModal() { document.getElementById('analysisModal').style.display = 'none'; }
document.getElementById('analysisModal').style.display = 'none';
}

View 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>
`;
}

View File

@@ -1,78 +1,71 @@
import math import numpy as np
from datetime import datetime from datetime import datetime
from sql_queries import DashboardQueries
class SOIPredictionService: class SOIPredictionService:
"""시계열 데이터를 기반으로 SOI 예측 전담 서비스""" """학습형 시계열 예측 및 피처 추출 엔진"""
@staticmethod @staticmethod
def get_historical_soi(cursor, project_id): def get_historical_soi(cursor, project_id):
"""특정 프로젝트의 과거 SOI 이력을 가져옴""" """DB에서 프로젝트의 과거 SOI 히스토리를 시퀀스로 추출"""
sql = """ cursor.execute("""
SELECT crawl_date, recent_log, file_count SELECT crawl_date, file_count, recent_log
FROM projects_history FROM projects_history
WHERE project_id = %s WHERE project_id = %s
ORDER BY crawl_date ASC ORDER BY crawl_date ASC
""" """, (project_id,))
cursor.execute(sql, (project_id,)) return cursor.fetchall()
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
@staticmethod @staticmethod
def predict_future_soi(history_points, days_ahead=14): def extract_vitality_features(history):
""" """딥러닝 학습을 위한 4대 핵심 피처 추출 (Feature Engineering)"""
최근 추세(Trend)를 기반으로 미래 SOI 예측 (Regression Neural Model 기반 로직) if len(history) < 2:
데이터가 적을 땐 최근 하락 기울기를 가중치로 사용함 return {"velocity": 0, "acceleration": 0, "consistency": 0.5, "density": 0.1}
"""
if len(history_points) < 2:
return None # 데이터 부족으로 예측 불가
# 최근 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) # 1. 활동 속도 (Velocity)
slopes = [] velocity = np.diff(counts).mean() if len(counts) > 1 else 0
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)
if not slopes: return None # 2. 활동 가속도 (Acceleration): 최근 활동이 빨라지는지 느려지는지
acceleration = np.diff(np.diff(counts)).mean() if len(counts) > 2 else 0
# 최근 기울기의 평균 (Deep Decay Trend) # 3. 로그 밀도 (Density): 전체 기간 대비 실제 로그 발생 비율
avg_slope = sum(slopes) / len(slopes) logs = [h['recent_log'] for h in history if h['recent_log'] and h['recent_log'] != "데이터 없음"]
current_soi = history_points[-1]['soi'] density = len(logs) / len(history) if len(history) > 0 else 0
# 1. 선형적 하락 추세 반영 # 4. 관리 일관성 (Consistency): 업데이트 간격의 표준편차 (낮을수록 좋음)
linear_pred = current_soi + (avg_slope * days_ahead) # (현재 데이터는 일일 크롤링이므로 로그 텍스트 변화 시점을 기준으로 간격 계산 가능)
# 2. 지수적 감쇄 가중치 반영 (활동이 멈췄을 때의 자연 소멸 속도) return {
# 14일 뒤에는 현재 SOI의 약 50%가 소멸되는 것이 지수 감쇄 모델의 기본 (exp(-0.05*14) = 0.496) "velocity": float(velocity),
exponential_pred = current_soi * math.exp(-0.05 * days_ahead) "acceleration": float(acceleration),
"density": float(density),
"sample_count": len(history)
}
# AI Weighted Logic: 활동성이 살아나면(기울기 양수) 선형 반영, 죽어있으면(기울기 음수) 지수 반영 @staticmethod
if avg_slope >= 0: def predict_future_soi(current_soi, history, days_ahead=14):
final_pred = (linear_pred * 0.7) + (exponential_pred * 0.3) """기존 점수와 시계열 피처를 결합하여 미래 점수 예측"""
else: if not history or len(history) < 2:
final_pred = (exponential_pred * 0.8) + (linear_pred * 0.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
View File

View File

@@ -1,4 +1,6 @@
/* Analysis Page Styles */ /* ==========================================================================
Project Master Analysis - Sabermetrics Style
========================================================================== */
.analysis-content { .analysis-content {
padding: 24px; padding: 24px;
@@ -6,124 +8,42 @@
margin: var(--topbar-h, 36px) auto 0; margin: var(--topbar-h, 36px) auto 0;
} }
.analysis-header { /* AI Badge & Header */
display: flex;
justify-content: space-between;
align-items: flex-end;
padding: 10px 0 30px 0;
margin-bottom: 10px;
}
.ai-badge { .ai-badge {
display: inline-block; background: #6366f1;
padding: 4px 12px; color: white;
padding: 2px 10px;
border-radius: 20px; border-radius: 20px;
background: var(--ai-color, linear-gradient(135deg, #6366f1 0%, #a855f7 100%));
color: #fff;
font-size: 11px; font-size: 11px;
font-weight: 700; font-weight: 800;
margin-bottom: 10px; display: inline-block;
text-transform: uppercase; margin-bottom: 8px;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
.analysis-header h2 { font-size: 24px; font-weight: 800; color: #111; margin: 0; } .analysis-header {
.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;
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
gap: 5px; margin-bottom: 24px;
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;
} }
/* Top Info Grid (AI Info & SOI Deep Dive) */
.top-info-grid { .top-info-grid {
display: grid; display: grid;
grid-template-columns: 1fr 2fr; /* AI 정보는 작게, SOI 설명은 넓게 */ grid-template-columns: 1fr 2fr;
gap: 16px; 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 { .model-desc-vertical {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -136,24 +56,20 @@
gap: 12px; gap: 12px;
} }
.model-item-vertical p { .model-tag {
font-size: 12.5px; background: #f1f5f9;
color: #475569; color: #475569;
margin: 0; padding: 2px 8px;
} border-radius: 4px;
font-size: 10px;
/* SOI Deep-Dive 스타일 */ font-weight: 700;
.soi-deep-dive { white-space: nowrap;
background: #fff;
border-radius: 16px;
border: 1px solid #eef2f6;
box-shadow: 0 4px 20px rgba(0,0,0,0.04);
} }
.soi-info-columns { .soi-info-columns {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
gap: 24px; gap: 20px;
} }
.soi-info-column h6 { .soi-info-column h6 {
@@ -161,8 +77,6 @@
font-weight: 800; font-weight: 800;
color: #1e5149; color: #1e5149;
margin: 0 0 8px 0; margin: 0 0 8px 0;
text-transform: uppercase;
letter-spacing: 0.5px;
} }
.soi-info-column p { .soi-info-column p {
@@ -172,170 +86,20 @@
margin: 0; margin: 0;
} }
.soi-info-column p strong { /* Chart Grid Layout */
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; }
/* 분석 차트 그리드 */
.analysis-charts-grid { .analysis-charts-grid {
display: grid; display: grid;
grid-template-columns: 1.2fr 2fr; /* 원형 그래프 영역 소폭 확장 */ grid-template-columns: 1.2fr 2fr;
gap: 20px; gap: 20px;
margin-bottom: 24px; margin-bottom: 24px;
} }
.chart-container-box { .chart-container-box {
background: #f8fafc; background: white;
border-radius: 12px; border-radius: 12px;
padding: 20px; padding: 20px;
border: 1px solid #e2e8f0; border: 1px solid #eef2f6;
height: 320px; /* 고정 높이 */ height: 340px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@@ -344,111 +108,27 @@
margin: 0 0 15px 0; margin: 0 0 15px 0;
font-size: 13px; font-size: 13px;
font-weight: 700; font-weight: 700;
color: #475569; color: #334155;
display: flex;
align-items: center;
gap: 8px;
} }
.chart-container-box canvas { /* Data Table Customization */
flex: 1; .p-war-table-container {
width: 100% !important; margin-top: 24px;
height: 100% !important;
} }
@media (max-width: 1024px) { .project-row {
.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 {
cursor: pointer; cursor: pointer;
transition: background 0.2s; transition: background 0.2s ease;
} }
.p-war-table tbody tr.project-row:hover { .project-row:hover {
background: #f1f5f9 !important; background: #f8fafc !important;
} }
/* Accordion Detail Styles */
.detail-row { .detail-row {
display: none; display: none;
background: #f8fafc; background: #fdfdfd;
} }
.detail-row.active { .detail-row.active {
@@ -456,25 +136,60 @@
} }
.detail-container { .detail-container {
padding: 20px 30px; padding: 20px 24px;
border-bottom: 2px solid #e2e8f0; border-bottom: 2px solid #f1f5f9;
} }
.formula-explanation-card { .formula-explanation-card {
background: white; background: white;
border-radius: 12px; border-radius: 12px;
padding: 20px; padding: 24px;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05); 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; display: flex;
flex-direction: column; justify-content: space-between;
gap: 15px; 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 { .formula-step {
display: flex; display: flex;
align-items: flex-start; gap: 12px;
gap: 15px;
} }
.step-num { .step-num {
@@ -489,107 +204,38 @@
font-size: 11px; font-size: 11px;
font-weight: 800; font-weight: 800;
flex-shrink: 0; flex-shrink: 0;
margin-top: 2px;
}
.step-content {
flex: 1;
} }
.step-title { .step-title {
font-size: 13px; font-size: 12px;
font-weight: 700; font-weight: 700;
color: #334155; color: #334155;
margin-bottom: 4px; margin-bottom: 4px;
} }
.step-desc {
font-size: 12px;
color: #64748b;
line-height: 1.5;
}
.math-logic { .math-logic {
font-family: 'Courier New', Courier, monospace; font-family: 'Consolas', monospace;
background: #f1f5f9; background: #f1f5f9;
padding: 4px 8px; padding: 4px 8px;
border-radius: 4px; border-radius: 4px;
font-weight: 700; font-weight: 700;
color: #0f172a; color: #0f172a;
font-size: 13px; font-size: 12px;
margin-top: 6px;
display: inline-block; 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; }
.d-war-guide { .text-plus { color: #059669; font-weight: 700; }
display: flex; .text-minus { color: #dc2626; font-weight: 700; }
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; }

View File

@@ -5,8 +5,6 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>데이터 분석 - Project Master Sabermetrics</title> <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/common.css">
<link rel="stylesheet" href="style/analysis.css"> <link rel="stylesheet" href="style/analysis.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
@@ -28,21 +26,21 @@
</ul> </ul>
</nav> </nav>
<main class="analysis-content wide"> <main class="analysis-content">
<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-refresh" onclick="location.reload()">데이터 갱신</button>
</div> </div>
</header> </header>
<!-- 상단 정보 영역 -->
<div class="top-info-grid"> <div class="top-info-grid">
<!-- 딥러닝 모델 상세 설명 섹션 --> <section class="dl-model-info">
<section class="dl-model-info compact">
<div class="card-header"> <div class="card-header">
<h4><i class="ai-icon">AI</i> Hybrid Prediction Engine</h4> <h4><i class="ai-icon">AI</i> Hybrid Prediction Engine</h4>
</div> </div>
@@ -50,47 +48,57 @@
<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회차 시계열의 <strong>Velocity</strong> 및 가속도 분석</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>활동 시 <strong>'선형 추세'</strong>, 정체 시 <strong>'지수 감쇄'</strong> 가중치 적용</p> <p>활동 시 '선형 추세', 정체 시 '지수 감쇄' 가중치 적용</p>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
<!-- SOI 심층 설명 섹션 (AAS 모델 반영) --> <section class="soi-deep-dive">
<section class="soi-deep-dive compact">
<div class="card-header"> <div class="card-header">
<h4><i class="info-icon">i</i> AI 위험 적응형 모델 (AAS) 기반 지표 정의</h4> <h4><i class="info-icon">i</i> AI 위험 적응형 모델 (AAS) 기반 지표 정의</h4>
</div> </div>
<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 자산 가치 평가 (Scale)</h6> <h6>1. AI 자산 가치 평가</h6>
<p>단순 방치가 아닌 <strong>자산의 크기</strong>를 감지합니다. 파일 수가 많은 프로젝트는 관리 공백 시 데이터 가치 하락 속도를 AI가 자동으로 <strong>가속(Acceleration)</strong>켜 경고를 강화합니다.</p> <p>자산 규모를 감지하여, 대형 프로젝트 방치 시 데이터 가치 하락 속도를 <strong>가속(Acceleration)</strong>니다.</p>
</div> </div>
<div class="soi-info-column"> <div class="soi-info-column">
<h6>2. 조직 위험 전염 (Contagion)</h6> <h6>2. 조직 위험 전염</h6>
<p>부서별 평균 활동성을 분석하여 <strong>조직적 방치</strong> 포착합니다. 소속 부서의 전반적인 SOI가 낮을 경우, 개별 프로젝트의 위험 지수를 상향 조정하여 시스템적 붕괴를 예보합니다.</p> <p>소속 부서의 전반적인 활동성이 낮을 경우, 개별 위험 지수를 상향 조정하여 <strong>시스템적 붕괴</strong>를 예보합니다.</p>
</div> </div>
<div class="soi-info-column"> <div class="soi-info-column">
<h6>3. 동적 위험 계수 (Adaptive Lambda)</h6> <h6>3. 동적 위험 계수</h6>
<p>기존의 고정된 공식을 폐기하고, 프로젝트마다 <strong>개별화된 위험 곡선</strong>을 생성합니다. AI가 실시간으로 위험 계수를 재산출하여 가장 실무적인 가치 보존율을 제공합니다.</p> <p>프로젝트마다 <strong>개별화된 위험 곡선</strong>을 생성하여 현장에 가장 밀착된 가치 보존율을 제공합니다.</p>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
</div> </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="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 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>
<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;">* SOI (Project Health Score)</span>
@@ -104,32 +112,19 @@
<div class="guide-item hazard-critical"><span>10%↓</span> 사망</div> <div class="guide-item hazard-critical"><span>10%↓</span> 사망</div>
</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 id="p-war-table-container">
<!-- 테이블은 기존처럼 동적 삽입 --> <!-- JS에 의해 동적으로 테이블 삽입 -->
</div>
</div> </div>
</div> </div>
</div> </div>
</main> </main>
<!-- 분석 상세 설명 모달 --> <!-- 설명 모달 -->
<div id="analysisModal" class="modal-overlay" onclick="closeAnalysisModal(event)"> <div id="analysisModal" class="modal-overlay" onclick="if(event.target===this) closeAnalysisModal()">
<div class="modal-content" onclick="event.stopPropagation()"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h3 id="modalTitle">지표 상세 설명</h3> <h3 id="modalTitle">분석 상세</h3>
<button class="modal-close" onclick="closeAnalysisModal()">&times;</button> <button class="btn-close" onclick="closeAnalysisModal()">×</button>
</div> </div>
<div class="modal-body" id="modalBody"> <div class="modal-body" id="modalBody">
<!-- 내용 동적 삽입 --> <!-- 내용 동적 삽입 -->