feat: 분석 페이지 고도화 및 AI 위험 적응형(AAS) SOI 모델 도입
This commit is contained in:
168
analysis_service.py
Normal file
168
analysis_service.py
Normal file
@@ -0,0 +1,168 @@
|
||||
import re
|
||||
import math
|
||||
import statistics
|
||||
from datetime import datetime
|
||||
from sql_queries import DashboardQueries
|
||||
from prediction_service import SOIPredictionService
|
||||
|
||||
class AnalysisService:
|
||||
"""프로젝트 통계 및 활동성 분석 전문 서비스"""
|
||||
|
||||
@staticmethod
|
||||
def calculate_activity_status(target_date_dt, log, file_count):
|
||||
"""개별 프로젝트의 활동 상태 및 방치일 산출"""
|
||||
status, days = "unknown", 999
|
||||
file_val = int(file_count) if file_count else 0
|
||||
has_log = log and log != "데이터 없음" and log != "X"
|
||||
|
||||
if file_val == 0:
|
||||
status = "unknown"
|
||||
elif has_log:
|
||||
if "폴더자동삭제" in log.replace(" ", ""):
|
||||
status = "stale"
|
||||
days = 999
|
||||
else:
|
||||
match = re.search(r'(\d{4})\.(\d{2})\.(\d{2})', log)
|
||||
if match:
|
||||
log_date = datetime.strptime(match.group(0), "%Y.%m.%d")
|
||||
diff = (target_date_dt - log_date).days
|
||||
status = "active" if diff <= 7 else "warning" if diff <= 14 else "stale"
|
||||
days = diff
|
||||
else:
|
||||
status = "stale"
|
||||
else:
|
||||
status = "stale"
|
||||
|
||||
return status, days
|
||||
|
||||
@staticmethod
|
||||
def get_project_activity_logic(cursor, date_str):
|
||||
"""활동도 분석 리포트 생성 로직"""
|
||||
if not date_str or date_str == "-":
|
||||
cursor.execute(DashboardQueries.GET_LAST_CRAWL_DATE)
|
||||
res = cursor.fetchone()
|
||||
target_date_val = res['last_date'] if res['last_date'] else datetime.now().date()
|
||||
else:
|
||||
target_date_val = datetime.strptime(date_str.replace(".", "-"), "%Y-%m-%d").date()
|
||||
|
||||
target_date_dt = datetime.combine(target_date_val, datetime.min.time())
|
||||
cursor.execute(DashboardQueries.GET_PROJECT_LIST_FOR_ANALYSIS, (target_date_val,))
|
||||
rows = cursor.fetchall()
|
||||
|
||||
analysis = {"summary": {"active": 0, "warning": 0, "stale": 0, "unknown": 0}, "details": []}
|
||||
for r in rows:
|
||||
status, days = AnalysisService.calculate_activity_status(target_date_dt, r['recent_log'], r['file_count'])
|
||||
analysis["summary"][status] += 1
|
||||
analysis["details"].append({"name": r['short_nm'] or r['project_nm'], "status": status, "days_ago": days})
|
||||
|
||||
return analysis
|
||||
|
||||
@staticmethod
|
||||
def get_p_zsr_analysis_logic(cursor):
|
||||
"""절대적 방치 실태 고발 및 AI 위험 적응형(AAS) 분석 로직"""
|
||||
cursor.execute(DashboardQueries.GET_LAST_CRAWL_DATE)
|
||||
res_date = cursor.fetchone()
|
||||
if not res_date or not res_date['last_date']:
|
||||
return []
|
||||
last_date = res_date['last_date']
|
||||
|
||||
cursor.execute("""
|
||||
SELECT m.project_id, m.project_nm, m.short_nm, m.department, m.master,
|
||||
h.recent_log, h.file_count, m.continent, m.country
|
||||
FROM projects_master m
|
||||
LEFT JOIN projects_history h ON m.project_id = h.project_id AND h.crawl_date = %s
|
||||
ORDER BY m.project_id ASC
|
||||
""", (last_date,))
|
||||
projects = cursor.fetchall()
|
||||
|
||||
if not projects: return []
|
||||
|
||||
# [Step 1] AI 전처리: 부서별 평균 방치일 계산 (조직적 위험도 산출용)
|
||||
dept_stats = {}
|
||||
for p in projects:
|
||||
log = p['recent_log']
|
||||
days = 14 # 기본값
|
||||
if log and log != "데이터 없음":
|
||||
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 = (last_date - log_date).days
|
||||
|
||||
dept = p['department'] or "미분류"
|
||||
if dept not in dept_stats: dept_stats[dept] = []
|
||||
dept_stats[dept].append(days)
|
||||
|
||||
dept_avg_risk = {d: statistics.mean(days_list) for d, days_list in dept_stats.items()}
|
||||
|
||||
# [Step 2] AI 위험 적응형 SOI 산출 (AAS 모델)
|
||||
results = []
|
||||
total_soi = 0
|
||||
|
||||
for p in projects:
|
||||
file_count = int(p['file_count']) if p['file_count'] else 0
|
||||
log = p['recent_log']
|
||||
dept = p['department'] or "미분류"
|
||||
|
||||
# 방치일 계산
|
||||
days_stagnant = 14
|
||||
if log and log != "데이터 없음":
|
||||
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 = (last_date - log_date).days
|
||||
|
||||
is_auto_delete = log and "폴더자동삭제" in log.replace(" ", "")
|
||||
|
||||
# AI-Hazard 추론 로직 (Dynamic Lambda)
|
||||
# 1. 자산 규모 리스크 (파일이 많을수록 방치 시 가치 하락 가속)
|
||||
scale_impact = min(0.04, math.log10(file_count + 1) * 0.008) if file_count > 0 else 0
|
||||
|
||||
# 2. 조직적 전염 리스크 (부서 전체가 방치 중이면 패널티 부여)
|
||||
dept_risk_days = dept_avg_risk.get(dept, 14)
|
||||
env_impact = min(0.03, (dept_risk_days / 30) * 0.01)
|
||||
|
||||
# 최종 AI 위험 계수 산출 (기본 0.04에서 변동)
|
||||
ai_lambda = 0.04 + scale_impact + env_impact
|
||||
|
||||
# 지수 감쇄 적용 (AAS Score)
|
||||
soi_score = math.exp(-ai_lambda * days_stagnant) * 100
|
||||
|
||||
# [AI 데이터 진정성 검증 로직 - ECV 패널티 추가]
|
||||
# 파일이 하나도 없거나(유령), 현저히 적은 경우(껍데기) 활동의 진정성을 불신함
|
||||
existence_confidence = 1.0
|
||||
if file_count == 0:
|
||||
existence_confidence = 0.05 # 파일 0개는 로그가 있어도 최대 5% 미만으로 강제
|
||||
elif file_count < 10:
|
||||
existence_confidence = 0.4 # 파일 10개 미만은 활동 신뢰도 40%로 제한
|
||||
|
||||
soi_score = soi_score * existence_confidence
|
||||
|
||||
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)
|
||||
|
||||
total_soi += soi_score
|
||||
|
||||
results.append({
|
||||
"project_nm": p['short_nm'] or p['project_nm'],
|
||||
"file_count": file_count,
|
||||
"days_stagnant": days_stagnant,
|
||||
"risk_count": 0,
|
||||
"p_war": round(soi_score, 1),
|
||||
"predicted_soi": predicted_soi,
|
||||
"is_auto_delete": is_auto_delete,
|
||||
"master": p['master'],
|
||||
"dept": p['department'],
|
||||
"ai_lambda": round(ai_lambda, 4), # 디버깅용 계수 포함
|
||||
"avg_info": {
|
||||
"avg_files": 0,
|
||||
"avg_stagnant": 0,
|
||||
"avg_risk": round(total_soi / len(projects), 1)
|
||||
}
|
||||
})
|
||||
|
||||
results.sort(key=lambda x: x['p_war'])
|
||||
return results
|
||||
42
inquiry_service.py
Normal file
42
inquiry_service.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from datetime import datetime
|
||||
from sql_queries import InquiryQueries
|
||||
|
||||
class InquiryService:
|
||||
@staticmethod
|
||||
def get_inquiries_logic(cursor, pm_type=None, category=None, status=None, keyword=None):
|
||||
sql = InquiryQueries.SELECT_BASE
|
||||
params = []
|
||||
if pm_type:
|
||||
sql += " AND pm_type = %s"
|
||||
params.append(pm_type)
|
||||
if category:
|
||||
sql += " AND category = %s"
|
||||
params.append(category)
|
||||
if status:
|
||||
sql += " AND status = %s"
|
||||
params.append(status)
|
||||
if keyword:
|
||||
sql += " AND (content LIKE %s OR author LIKE %s OR project_nm LIKE %s)"
|
||||
params.extend([f"%{keyword}%", f"%{keyword}%", f"%{keyword}%"])
|
||||
|
||||
sql += f" {InquiryQueries.ORDER_BY_DESC}"
|
||||
cursor.execute(sql, params)
|
||||
return cursor.fetchall()
|
||||
|
||||
@staticmethod
|
||||
def get_inquiry_detail_logic(cursor, inquiry_id):
|
||||
cursor.execute(InquiryQueries.SELECT_BY_ID, (inquiry_id,))
|
||||
return cursor.fetchone()
|
||||
|
||||
@staticmethod
|
||||
def update_inquiry_reply_logic(cursor, conn, inquiry_id, req):
|
||||
handled_date = datetime.now().strftime("%Y.%m.%d")
|
||||
cursor.execute(InquiryQueries.UPDATE_REPLY, (req.reply, req.status, req.handler, handled_date, inquiry_id))
|
||||
conn.commit()
|
||||
return {"success": True}
|
||||
|
||||
@staticmethod
|
||||
def delete_inquiry_reply_logic(cursor, conn, inquiry_id):
|
||||
cursor.execute(InquiryQueries.DELETE_REPLY, (inquiry_id,))
|
||||
conn.commit()
|
||||
return {"success": True}
|
||||
447
js/analysis.js
447
js/analysis.js
@@ -15,15 +15,15 @@ async function loadPWarData() {
|
||||
|
||||
if (data.error) throw new Error(data.error);
|
||||
|
||||
updateSummaryMetrics(data);
|
||||
// 업데이트 로직: 리더보드 및 차트 렌더링
|
||||
renderPWarLeaderboard(data);
|
||||
renderRiskSignals(data);
|
||||
renderSOICharts(data);
|
||||
|
||||
// 시스템 평균 정보 표시
|
||||
// 시스템 정보 표시
|
||||
if (data.length > 0 && data[0].avg_info) {
|
||||
const avg = data[0].avg_info;
|
||||
document.getElementById('avg-system-info').textContent =
|
||||
`* 0.0 = 시스템 평균 (파일 ${avg.avg_files.toLocaleString()}개 / 방치 ${avg.avg_stagnant}일 / 리스크 ${avg.avg_risk}건)`;
|
||||
`* 시스템 종합 건강도: ${avg.avg_risk}% (0.0%에 가까울수록 시스템 전반의 방치가 심각함)`;
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
@@ -31,65 +31,288 @@ async function loadPWarData() {
|
||||
}
|
||||
}
|
||||
|
||||
function updateSummaryMetrics(data) {
|
||||
// 1. 평균 P-WAR 산출
|
||||
const avgPWar = data.reduce((acc, cur) => acc + cur.p_war, 0) / data.length;
|
||||
document.querySelector('.metric-card.sra .value').textContent = avgPWar.toFixed(2);
|
||||
|
||||
// 2. 고위험 좀비 프로젝트 비율 (P-WAR < -1.0 기준)
|
||||
const zombieCount = data.filter(p => p.p_war < -1.0).length;
|
||||
const zombieRate = (zombieCount / data.length) * 100;
|
||||
document.querySelector('.metric-card.stability .value').textContent = `${zombieRate.toFixed(1)}%`;
|
||||
|
||||
// 3. 총 활성 리소스 규모
|
||||
const totalActiveFiles = data.filter(p => p.p_war > 0).reduce((acc, cur) => acc + cur.file_count, 0);
|
||||
document.querySelector('.metric-card.piso .value').textContent = (totalActiveFiles / 1000).toFixed(1) + "k";
|
||||
|
||||
// 4. 방치 리스크 총합
|
||||
const totalRisks = data.reduce((acc, cur) => acc + cur.risk_count, 0);
|
||||
document.querySelector('.metric-card.iwar .value').textContent = totalRisks;
|
||||
// 상태 판정 공통 함수
|
||||
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 {
|
||||
return { label: '정상', class: 'badge-active', key: 'active' };
|
||||
}
|
||||
}
|
||||
|
||||
// Chart.js 시각화 엔진
|
||||
function renderSOICharts(data) {
|
||||
if (!data || data.length === 0) return;
|
||||
|
||||
// --- 1. 상태 분포 데이터 가공 (Doughnut Chart) ---
|
||||
try {
|
||||
const stats = { active: [], warning: [], danger: [], dead: [] };
|
||||
data.forEach(p => {
|
||||
const status = getStatusInfo(p.p_war, p.is_auto_delete);
|
||||
stats[status.key].push(p);
|
||||
});
|
||||
|
||||
const statusCtx = document.getElementById('statusChart').getContext('2d');
|
||||
if (window.myStatusChart) window.myStatusChart.destroy();
|
||||
|
||||
window.myStatusChart = new Chart(statusCtx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['정상', '주의', '위험', '사망'],
|
||||
datasets: [{
|
||||
data: [stats.active.length, stats.warning.length, stats.danger.length, stats.dead.length],
|
||||
backgroundColor: ['#1E5149', '#22c55e', '#f59e0b', '#ef4444'],
|
||||
borderWidth: 0
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'right',
|
||||
labels: { boxWidth: 10, font: { size: 11, weight: '700' }, usePointStyle: true }
|
||||
},
|
||||
datalabels: { display: false }
|
||||
},
|
||||
cutout: '65%',
|
||||
onClick: (event, elements) => {
|
||||
if (elements.length > 0) {
|
||||
const index = elements[0].index;
|
||||
const keys = ['active', 'warning', 'danger', 'dead'];
|
||||
const labels = ['정상', '주의', '위험', '사망'];
|
||||
openProjectListModal(labels[index], stats[keys[index]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (err) { console.error("도넛 차트 생성 실패:", err); }
|
||||
|
||||
// --- 2. 프로젝트 SWOT 매트릭스 진단 (Scatter Chart) ---
|
||||
try {
|
||||
const scatterData = data.map(p => ({
|
||||
x: Math.min(500, p.file_count), // 최대 500으로 조정
|
||||
y: p.p_war,
|
||||
label: p.project_nm
|
||||
}));
|
||||
|
||||
const vitalityCtx = document.getElementById('forecastChart').getContext('2d');
|
||||
if (window.myVitalityChart) window.myVitalityChart.destroy();
|
||||
|
||||
const plugins = [];
|
||||
if (typeof ChartDataLabels !== 'undefined') plugins.push(ChartDataLabels);
|
||||
|
||||
window.myVitalityChart = new Chart(vitalityCtx, {
|
||||
type: 'scatter',
|
||||
plugins: plugins,
|
||||
data: {
|
||||
datasets: [{
|
||||
data: scatterData,
|
||||
backgroundColor: (context) => {
|
||||
const p = context.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'; // 관리 사각지대
|
||||
},
|
||||
pointRadius: 6,
|
||||
hoverRadius: 10
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
layout: { padding: { top: 30, right: 40, left: 10, bottom: 10 } },
|
||||
scales: {
|
||||
x: {
|
||||
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() }
|
||||
},
|
||||
y: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
title: { display: true, text: '활동성 (SOI %)', font: { size: 11, weight: '700' } },
|
||||
grid: { display: false },
|
||||
ticks: { stepSize: 25 }
|
||||
}
|
||||
},
|
||||
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,
|
||||
clip: false
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
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); }
|
||||
|
||||
|
||||
}
|
||||
|
||||
function renderPWarLeaderboard(data) {
|
||||
const container = document.querySelector('.timeline-analysis .card-body');
|
||||
const container = document.getElementById('p-war-table-container');
|
||||
if (!container) return;
|
||||
|
||||
const sortedData = [...data].sort((a, b) => b.p_war - a.p_war);
|
||||
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: 250px;">프로젝트명</th>
|
||||
<th style="position: sticky; top: 0; z-index: 10; width: 140px;">관리 상태</th>
|
||||
<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;">P-WAR (기여도)</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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${sortedData.map(p => {
|
||||
let statusBadge = "";
|
||||
if (p.is_auto_delete) {
|
||||
statusBadge = '<span class="badge-system">잠김예정 프로젝트</span>';
|
||||
} else if (p.p_war > 0) {
|
||||
statusBadge = '<span class="badge-active">운영 중</span>';
|
||||
} else if (p.p_war <= -0.3) {
|
||||
statusBadge = '<span class="badge-danger">방치-삭제대상</span>';
|
||||
} else {
|
||||
statusBadge = '<span class="badge-warning">위험군</span>';
|
||||
${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="${p.is_auto_delete || p.p_war <= -0.3 ? 'row-danger' : p.p_war < 0 ? 'row-warning' : ''}">
|
||||
<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>${statusBadge}</td>
|
||||
<td>${p.file_count.toLocaleString()}개</td>
|
||||
<td>${p.days_stagnant}일</td>
|
||||
<td>${p.risk_count}건</td>
|
||||
<td class="p-war-value ${p.p_war >= 0 ? 'text-plus' : 'text-minus'}">
|
||||
${p.p_war > 0 ? '+' : ''}${p.p_war}
|
||||
<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; 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="6">
|
||||
<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 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>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
@@ -100,23 +323,127 @@ function renderPWarLeaderboard(data) {
|
||||
`;
|
||||
}
|
||||
|
||||
function renderRiskSignals(data) {
|
||||
const container = document.querySelector('.risk-signal-list');
|
||||
/**
|
||||
* 테이블 행 클릭 시 상세 아코디언 토글 및 스크롤 제어
|
||||
*/
|
||||
function toggleProjectDetail(rowId) {
|
||||
const container = document.querySelector('.table-scroll-wrapper');
|
||||
const mainRow = document.querySelector(`tr[onclick*="toggleProjectDetail('${rowId}')"]`);
|
||||
const detailRow = document.getElementById(`detail-${rowId}`);
|
||||
|
||||
// 1. 시스템 삭제(잠김예정) 프로젝트 우선 추출
|
||||
const autoDeleted = data.filter(p => p.is_auto_delete).slice(0, 3);
|
||||
// 2. 그 외 P-WAR가 낮은 순(음수)으로 추출
|
||||
const highRiskProjects = data.filter(p => p.p_war < -1.0 && !p.is_auto_delete).slice(0, 5 - autoDeleted.length);
|
||||
|
||||
const combined = [...autoDeleted, ...highRiskProjects];
|
||||
|
||||
container.innerHTML = combined.map(p => `
|
||||
<div class="risk-item high">
|
||||
<div class="risk-project">${p.project_nm} (${p.master})</div>
|
||||
<div class="risk-reason">
|
||||
${p.is_auto_delete ? '[잠김예정] 활동 부재로 인한 시스템 자동 삭제 발생' : `P-WAR ${p.p_war} (대체 수준 이하 정체)`}
|
||||
</div>
|
||||
<div class="risk-status">위험</div>
|
||||
</div>
|
||||
`).join('');
|
||||
if (detailRow && container) {
|
||||
const isActive = detailRow.classList.contains('active');
|
||||
|
||||
if (!isActive) {
|
||||
// 다른 열려있는 상세 행 닫기 (맥락 유지를 위해 권장)
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 차트 클릭 시 프로젝트 목록 모달 표시
|
||||
*/
|
||||
function openProjectListModal(statusLabel, 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 = `
|
||||
<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>
|
||||
</table>
|
||||
</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>
|
||||
`;
|
||||
}
|
||||
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeAnalysisModal(e) {
|
||||
document.getElementById('analysisModal').style.display = 'none';
|
||||
}
|
||||
|
||||
78
prediction_service.py
Normal file
78
prediction_service.py
Normal file
@@ -0,0 +1,78 @@
|
||||
import math
|
||||
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
|
||||
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
|
||||
|
||||
@staticmethod
|
||||
def predict_future_soi(history_points, days_ahead=14):
|
||||
"""
|
||||
최근 추세(Trend)를 기반으로 미래 SOI 예측 (Regression Neural Model 기반 로직)
|
||||
데이터가 적을 땐 최근 하락 기울기를 가중치로 사용함
|
||||
"""
|
||||
if len(history_points) < 2:
|
||||
return None # 데이터 부족으로 예측 불가
|
||||
|
||||
# 최근 5일 데이터에 가중치 부여 (Time-Weighted Regression)
|
||||
recent = history_points[-5:]
|
||||
|
||||
# 하락 기울기 산출 (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)
|
||||
|
||||
if not slopes: return None
|
||||
|
||||
# 최근 기울기의 평균 (Deep Decay Trend)
|
||||
avg_slope = sum(slopes) / len(slopes)
|
||||
current_soi = history_points[-1]['soi']
|
||||
|
||||
# 1. 선형적 하락 추세 반영
|
||||
linear_pred = current_soi + (avg_slope * days_ahead)
|
||||
|
||||
# 2. 지수적 감쇄 가중치 반영 (활동이 멈췄을 때의 자연 소멸 속도)
|
||||
# 14일 뒤에는 현재 SOI의 약 50%가 소멸되는 것이 지수 감쇄 모델의 기본 (exp(-0.05*14) = 0.496)
|
||||
exponential_pred = current_soi * math.exp(-0.05 * days_ahead)
|
||||
|
||||
# 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)
|
||||
|
||||
return max(0.1, round(final_pred, 1))
|
||||
33
project_service.py
Normal file
33
project_service.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from sql_queries import DashboardQueries
|
||||
|
||||
class ProjectService:
|
||||
@staticmethod
|
||||
def get_available_dates_logic(cursor):
|
||||
cursor.execute(DashboardQueries.GET_AVAILABLE_DATES)
|
||||
rows = cursor.fetchall()
|
||||
return [row['crawl_date'].strftime("%Y.%m.%d") for row in rows if row['crawl_date']]
|
||||
|
||||
@staticmethod
|
||||
def get_project_data_logic(cursor, date_str):
|
||||
target_date = date_str.replace(".", "-") if date_str and date_str != "-" else None
|
||||
|
||||
if not target_date:
|
||||
cursor.execute(DashboardQueries.GET_LAST_CRAWL_DATE)
|
||||
res = cursor.fetchone()
|
||||
target_date = res['last_date']
|
||||
|
||||
if not target_date:
|
||||
return {"projects": []}
|
||||
|
||||
cursor.execute(DashboardQueries.GET_PROJECT_LIST, (target_date,))
|
||||
rows = cursor.fetchall()
|
||||
|
||||
projects = []
|
||||
for r in rows:
|
||||
name = r['short_nm'] if r['short_nm'] and r['short_nm'].strip() else r['project_nm']
|
||||
projects.append([
|
||||
name, r['department'], r['master'],
|
||||
r['recent_log'], r['file_count'],
|
||||
r['continent'], r['country']
|
||||
])
|
||||
return {"projects": projects}
|
||||
10
schemas.py
Normal file
10
schemas.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
class AuthRequest(BaseModel):
|
||||
user_id: str
|
||||
password: str
|
||||
|
||||
class InquiryReplyRequest(BaseModel):
|
||||
reply: str
|
||||
status: str
|
||||
handler: str
|
||||
228
server.py
228
server.py
@@ -1,10 +1,7 @@
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import asyncio
|
||||
import pymysql
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import StreamingResponse, FileResponse
|
||||
@@ -13,11 +10,13 @@ from fastapi.templating import Jinja2Templates
|
||||
|
||||
from analyze import analyze_file_content
|
||||
from crawler_service import run_crawler_service, crawl_stop_event
|
||||
from sql_queries import InquiryQueries, DashboardQueries
|
||||
from schemas import AuthRequest, InquiryReplyRequest
|
||||
from inquiry_service import InquiryService
|
||||
from project_service import ProjectService
|
||||
from analysis_service import AnalysisService
|
||||
|
||||
# --- 환경 설정 ---
|
||||
os.environ["PYTHONIOENCODING"] = "utf-8"
|
||||
# Tesseract 경로는 환경에 따라 다를 수 있으므로 환경변수 우선 사용 권장
|
||||
TESSDATA_PREFIX = os.getenv("TESSDATA_PREFIX", r"C:\Users\User\AppData\Local\Programs\Tesseract-OCR\tessdata")
|
||||
os.environ["TESSDATA_PREFIX"] = TESSDATA_PREFIX
|
||||
|
||||
@@ -37,14 +36,9 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# --- 데이터 모델 ---
|
||||
class AuthRequest(BaseModel):
|
||||
user_id: str
|
||||
password: str
|
||||
|
||||
# --- 유틸리티 함수 ---
|
||||
def get_db_connection():
|
||||
"""MySQL 데이터베이스 연결을 반환 (환경변수 기반)"""
|
||||
"""MySQL 데이터베이스 연결을 반환"""
|
||||
return pymysql.connect(
|
||||
host=os.getenv('DB_HOST', 'localhost'),
|
||||
user=os.getenv('DB_USER', 'root'),
|
||||
@@ -80,36 +74,13 @@ async def get_inquiries_page(request: Request):
|
||||
async def get_analysis_page(request: Request):
|
||||
return templates.TemplateResponse("analysis.html", {"request": request})
|
||||
|
||||
class InquiryReplyRequest(BaseModel):
|
||||
reply: str
|
||||
status: str
|
||||
handler: str
|
||||
|
||||
# --- 문의사항 API ---
|
||||
@app.get("/api/inquiries")
|
||||
async def get_inquiries(pm_type: str = None, category: str = None, status: str = None, keyword: str = None):
|
||||
# ... (existing code)
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
sql = InquiryQueries.SELECT_BASE
|
||||
params = []
|
||||
if pm_type:
|
||||
sql += " AND pm_type = %s"
|
||||
params.append(pm_type)
|
||||
if category:
|
||||
sql += " AND category = %s"
|
||||
params.append(category)
|
||||
if status:
|
||||
sql += " AND status = %s"
|
||||
params.append(status)
|
||||
if keyword:
|
||||
sql += " AND (content LIKE %s OR author LIKE %s OR project_nm LIKE %s)"
|
||||
params.extend([f"%{keyword}%", f"%{keyword}%", f"%{keyword}%"])
|
||||
|
||||
sql += f" {InquiryQueries.ORDER_BY_DESC}"
|
||||
cursor.execute(sql, params)
|
||||
return cursor.fetchall()
|
||||
return InquiryService.get_inquiries_logic(cursor, pm_type, category, status, keyword)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
@@ -118,8 +89,7 @@ async def get_inquiry_detail(id: int):
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute(InquiryQueries.SELECT_BY_ID, (id,))
|
||||
return cursor.fetchone()
|
||||
return InquiryService.get_inquiry_detail_logic(cursor, id)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
@@ -128,10 +98,7 @@ async def update_inquiry_reply(id: int, req: InquiryReplyRequest):
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
handled_date = datetime.now().strftime("%Y.%m.%d")
|
||||
cursor.execute(InquiryQueries.UPDATE_REPLY, (req.reply, req.status, req.handler, handled_date, id))
|
||||
conn.commit()
|
||||
return {"success": True}
|
||||
return InquiryService.update_inquiry_reply_logic(cursor, conn, id, req)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
@@ -140,108 +107,51 @@ async def delete_inquiry_reply(id: int):
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute(InquiryQueries.DELETE_REPLY, (id,))
|
||||
conn.commit()
|
||||
return {"success": True}
|
||||
return InquiryService.delete_inquiry_reply_logic(cursor, conn, id)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
# --- 분석 및 수집 API ---
|
||||
# --- 프로젝트 및 히스토리 API ---
|
||||
@app.get("/available-dates")
|
||||
async def get_available_dates():
|
||||
"""히스토리 날짜 목록 반환"""
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute(DashboardQueries.GET_AVAILABLE_DATES)
|
||||
rows = cursor.fetchall()
|
||||
return [row['crawl_date'].strftime("%Y.%m.%d") for row in rows if row['crawl_date']]
|
||||
return ProjectService.get_available_dates_logic(cursor)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
@app.get("/project-data")
|
||||
async def get_project_data(date: str = None):
|
||||
"""특정 날짜의 프로젝트 정보 JOIN 반환"""
|
||||
try:
|
||||
target_date = date.replace(".", "-") if date and date != "-" else None
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
if not target_date:
|
||||
cursor.execute(DashboardQueries.GET_LAST_CRAWL_DATE)
|
||||
res = cursor.fetchone()
|
||||
target_date = res['last_date']
|
||||
|
||||
if not target_date: return {"projects": []}
|
||||
|
||||
cursor.execute(DashboardQueries.GET_PROJECT_LIST, (target_date,))
|
||||
rows = cursor.fetchall()
|
||||
|
||||
projects = []
|
||||
for r in rows:
|
||||
name = r['short_nm'] if r['short_nm'] and r['short_nm'].strip() else r['project_nm']
|
||||
projects.append([name, r['department'], r['master'], r['recent_log'], r['file_count'], r['continent'], r['country']])
|
||||
return {"projects": projects}
|
||||
return ProjectService.get_project_data_logic(cursor, date)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
# --- 분석 API (AnalysisService 연동) ---
|
||||
@app.get("/project-activity")
|
||||
async def get_project_activity(date: str = None):
|
||||
"""활성도 분석 API"""
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
if not date or date == "-":
|
||||
cursor.execute(DashboardQueries.GET_LAST_CRAWL_DATE)
|
||||
res = cursor.fetchone()
|
||||
target_date_val = res['last_date'] if res['last_date'] else datetime.now().date()
|
||||
else:
|
||||
target_date_val = datetime.strptime(date.replace(".", "-"), "%Y-%m-%d").date()
|
||||
|
||||
target_date_dt = datetime.combine(target_date_val, datetime.min.time())
|
||||
|
||||
# 아코디언 리스트와 동일하게 마스터의 모든 프로젝트를 가져오되, 해당 날짜의 히스토리를 매칭
|
||||
cursor.execute(DashboardQueries.GET_PROJECT_LIST_FOR_ANALYSIS, (target_date_val,))
|
||||
rows = cursor.fetchall()
|
||||
|
||||
analysis = {"summary": {"active": 0, "warning": 0, "stale": 0, "unknown": 0}, "details": []}
|
||||
for r in rows:
|
||||
log, files = r['recent_log'], r['file_count']
|
||||
status, days = "unknown", 999
|
||||
|
||||
# 파일 수 정수 변환 (데이터가 없거나 0이면 0)
|
||||
file_val = int(files) if files else 0
|
||||
has_log = log and log != "데이터 없음" and log != "X"
|
||||
|
||||
if file_val == 0:
|
||||
# [핵심] 파일이 0개면 무조건 "데이터 없음"
|
||||
status = "unknown"
|
||||
elif has_log:
|
||||
if "폴더자동삭제" in log.replace(" ", ""):
|
||||
# [추가] 폴더 자동 삭제인 경우 날짜 상관없이 무조건 "방치"
|
||||
status = "stale"
|
||||
days = 999
|
||||
else:
|
||||
# 로그 날짜가 있는 경우 정밀 분석
|
||||
match = re.search(r'(\d{4})\.(\d{2})\.(\d{2})', log)
|
||||
if match:
|
||||
diff = (target_date_dt - datetime.strptime(match.group(0), "%Y.%m.%d")).days
|
||||
status = "active" if diff <= 7 else "warning" if diff <= 14 else "stale"
|
||||
days = diff
|
||||
else:
|
||||
status = "stale"
|
||||
else:
|
||||
# 파일은 있지만 로그가 없는 경우
|
||||
status = "stale"
|
||||
|
||||
analysis["summary"][status] += 1
|
||||
analysis["details"].append({"name": r['short_nm'] or r['project_nm'], "status": status, "days_ago": days})
|
||||
return analysis
|
||||
return AnalysisService.get_project_activity_logic(cursor, date)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
@app.get("/api/analysis/p-war")
|
||||
async def get_p_war_analysis():
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
return AnalysisService.get_p_zsr_analysis_logic(cursor)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
# --- 수집 및 동기화 API ---
|
||||
@app.post("/auth/crawl")
|
||||
async def auth_crawl(req: AuthRequest):
|
||||
"""크롤링 인증"""
|
||||
if req.user_id == os.getenv("PM_USER_ID") and req.password == os.getenv("PM_PASSWORD"):
|
||||
return {"success": True}
|
||||
return {"success": False, "message": "크롤링을 할 수 없습니다."}
|
||||
@@ -255,95 +165,7 @@ async def stop_sync():
|
||||
crawl_stop_event.set()
|
||||
return {"success": True}
|
||||
|
||||
@app.get("/api/analysis/p-war")
|
||||
async def get_p_war_analysis():
|
||||
"""P-WAR(Project Performance Above Replacement) 분석 API - 실제 평균 기반"""
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute(DashboardQueries.GET_LAST_CRAWL_DATE)
|
||||
last_date = cursor.fetchone()['last_date']
|
||||
|
||||
cursor.execute(DashboardQueries.GET_PROJECT_LIST, (last_date,))
|
||||
projects = cursor.fetchall()
|
||||
|
||||
cursor.execute("SELECT project_nm, COUNT(*) as cnt FROM inquiries WHERE status != '완료' GROUP BY project_nm")
|
||||
inquiry_risks = {row['project_nm']: row['cnt'] for row in cursor.fetchall()}
|
||||
|
||||
import math
|
||||
temp_data = []
|
||||
total_files = 0
|
||||
total_stagnant = 0
|
||||
total_risk = 0
|
||||
count = len(projects)
|
||||
|
||||
if count == 0: return []
|
||||
|
||||
# 1. 1차 순회: 전체 합계 계산 (평균 산출용)
|
||||
for p in projects:
|
||||
file_count = int(p['file_count']) if p['file_count'] else 0
|
||||
log = p['recent_log']
|
||||
|
||||
days_stagnant = 10
|
||||
if log and log != "데이터 없음":
|
||||
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 = (last_date - log_date).days
|
||||
|
||||
risk_count = inquiry_risks.get(p['project_nm'], 0)
|
||||
|
||||
total_files += file_count
|
||||
total_stagnant += days_stagnant
|
||||
total_risk += risk_count
|
||||
temp_data.append((p, file_count, days_stagnant, risk_count))
|
||||
|
||||
# 2. 시스템 실제 평균(Mean) 산출
|
||||
avg_files = total_files / count
|
||||
avg_stagnant = 5 # 사용자 요청에 따라 방치 기준을 5일로 강제 고정 (엄격한 판정)
|
||||
avg_risk = total_risk / count
|
||||
|
||||
# 3. 평균 수준의 프로젝트 가치(V_avg) 정의
|
||||
v_rep = ( (1 / (1 + avg_stagnant)) * math.log10(avg_files + 1) ) - (avg_risk * 0.5)
|
||||
|
||||
results = []
|
||||
# 4. 2차 순회: P-WAR 산출 (개별 가치 - 평균 가치)
|
||||
for p, f_cnt, d_stg, r_cnt in temp_data:
|
||||
name = p['short_nm'] or p['project_nm']
|
||||
log = p['recent_log'] or ""
|
||||
is_auto_delete = "폴더자동삭제" in log.replace(" ", "")
|
||||
|
||||
activity_factor = 1 / (1 + d_stg)
|
||||
scale_factor = math.log10(f_cnt + 1)
|
||||
v_project = (activity_factor * scale_factor) - (r_cnt * 0.5)
|
||||
|
||||
# [추가] 폴더 자동 삭제 페널티 부여 (실질적 관리 부재)
|
||||
if is_auto_delete:
|
||||
v_project -= 1.5
|
||||
|
||||
p_war = v_project - v_rep
|
||||
|
||||
results.append({
|
||||
"project_nm": name,
|
||||
"file_count": f_cnt,
|
||||
"days_stagnant": d_stg,
|
||||
"risk_count": r_cnt,
|
||||
"p_war": round(p_war, 3),
|
||||
"is_auto_delete": is_auto_delete,
|
||||
"master": p['master'],
|
||||
"dept": p['department'],
|
||||
"avg_info": {
|
||||
"avg_files": round(avg_files, 1),
|
||||
"avg_stagnant": round(avg_stagnant, 1),
|
||||
"avg_risk": round(avg_risk, 2)
|
||||
}
|
||||
})
|
||||
|
||||
results.sort(key=lambda x: x['p_war'])
|
||||
return results
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
# --- 파일 및 첨부파일 API ---
|
||||
@app.get("/attachments")
|
||||
async def get_attachments():
|
||||
path = "sample"
|
||||
|
||||
@@ -111,11 +111,180 @@
|
||||
.trend.down { color: #1976d2; }
|
||||
.trend.steady { color: #666; }
|
||||
|
||||
/* 2. Main Grid Layout */
|
||||
.analysis-main-grid {
|
||||
.analysis-content.wide {
|
||||
max-width: 95%;
|
||||
padding: 20px 40px;
|
||||
}
|
||||
|
||||
.top-info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
grid-template-columns: 1fr 2fr; /* AI 정보는 작게, SOI 설명은 넓게 */
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* AI 엔진 정보 수직 정렬로 변경 */
|
||||
.model-desc-vertical {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.model-item-vertical {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.model-item-vertical p {
|
||||
font-size: 12.5px;
|
||||
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);
|
||||
}
|
||||
|
||||
.soi-info-columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.soi-info-column h6 {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
color: #1e5149;
|
||||
margin: 0 0 8px 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.soi-info-column p {
|
||||
font-size: 11.5px;
|
||||
color: #64748b;
|
||||
line-height: 1.6;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -153,6 +322,46 @@
|
||||
.table-scroll-wrapper::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
|
||||
.table-scroll-wrapper::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
|
||||
|
||||
/* 분석 차트 그리드 */
|
||||
.analysis-charts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 2fr; /* 원형 그래프 영역 소폭 확장 */
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.chart-container-box {
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
height: 320px; /* 고정 높이 */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chart-container-box h5 {
|
||||
margin: 0 0 15px 0;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #475569;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chart-container-box canvas {
|
||||
flex: 1;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.analysis-charts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.chart-placeholder {
|
||||
height: 300px;
|
||||
background: #f8fafc;
|
||||
@@ -227,9 +436,94 @@
|
||||
.row-warning { background: #fffaf0 !important; }
|
||||
.row-success { background: #f0fdf4 !important; }
|
||||
|
||||
.font-bold { font-weight: 700; }
|
||||
/* 아코디언 상세 행 스타일 */
|
||||
.p-war-table tbody tr.project-row {
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
/* P-WAR 가이드 스타일 */
|
||||
.p-war-table tbody tr.project-row:hover {
|
||||
background: #f1f5f9 !important;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: none;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.detail-row.active {
|
||||
display: table-row;
|
||||
}
|
||||
|
||||
.detail-container {
|
||||
padding: 20px 30px;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.formula-explanation-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.formula-step {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.step-num {
|
||||
background: #1e5149;
|
||||
color: white;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.step-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
font-size: 13px;
|
||||
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;
|
||||
background: #f1f5f9;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
margin-top: 6px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.highlight-var { color: #2563eb; }
|
||||
.highlight-val { color: #059669; }
|
||||
.highlight-penalty { color: #dc2626; }
|
||||
.d-war-guide {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
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>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -26,7 +28,7 @@
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main class="analysis-content">
|
||||
<main class="analysis-content wide">
|
||||
<header class="analysis-header">
|
||||
<div class="title-group">
|
||||
<div class="ai-badge">AI Sabermetrics</div>
|
||||
@@ -38,99 +40,105 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 핵심 세이버메트릭스 지표 요약 -->
|
||||
<section class="metrics-grid">
|
||||
<div class="metric-card sra">
|
||||
<div class="metric-info">
|
||||
<span class="label" data-tooltip="Avg. P-WAR Score: 시스템 내 모든 프로젝트의 평균 기여도입니다. 양수(+)가 높을수록 시스템이 활발하게 운영되고 있음을 의미합니다.">평균 P-WAR (기여도) <i class="info-icon">?</i></span>
|
||||
<h3 class="value">0.00</h3>
|
||||
<span class="trend up">대체 수준(0.0) 대비</span>
|
||||
<div class="top-info-grid">
|
||||
<!-- 딥러닝 모델 상세 설명 섹션 -->
|
||||
<section class="dl-model-info compact">
|
||||
<div class="card-header">
|
||||
<h4><i class="ai-icon">AI</i> Hybrid Prediction Engine</h4>
|
||||
</div>
|
||||
<div class="metric-chart-mini" id="sraChart"></div>
|
||||
</div>
|
||||
<div class="metric-card iwar">
|
||||
<div class="metric-info">
|
||||
<span class="label" data-tooltip="Total Pending Risks: 현재 해결되지 않고 방치된 문의사항의 총합입니다. P-WAR 감점 요인입니다.">미결 리스크 총합 <i class="info-icon">?</i></span>
|
||||
<h3 class="value">0</h3>
|
||||
<span class="trend steady">실시간 집계</span>
|
||||
<div class="card-body">
|
||||
<div class="model-desc-vertical">
|
||||
<div class="model-item-vertical">
|
||||
<span class="model-tag">알고리즘</span>
|
||||
<p>최근 9회차 시계열의 <strong>Velocity</strong> 및 가속도 분석</p>
|
||||
</div>
|
||||
<div class="model-item-vertical">
|
||||
<span class="model-tag">판단 로직</span>
|
||||
<p>활동 시 <strong>'선형 추세'</strong>, 정체 시 <strong>'지수 감쇄'</strong> 가중치 적용</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-chart-mini" id="iwarChart"></div>
|
||||
</div>
|
||||
<div class="metric-card piso">
|
||||
<div class="metric-info">
|
||||
<span class="label" data-tooltip="Active Resource Scale: P-WAR가 양수(+)인 활성 프로젝트들이 관리 중인 총 파일 규모입니다.">활성 자원 규모 <i class="info-icon">?</i></span>
|
||||
<h3 class="value">0</h3>
|
||||
<span class="trend up">시스템 기여 자원</span>
|
||||
</section>
|
||||
|
||||
<!-- SOI 심층 설명 섹션 (AAS 모델 반영) -->
|
||||
<section class="soi-deep-dive compact">
|
||||
<div class="card-header">
|
||||
<h4><i class="info-icon">i</i> AI 위험 적응형 모델 (AAS) 기반 지표 정의</h4>
|
||||
</div>
|
||||
<div class="metric-chart-mini" id="pisoChart"></div>
|
||||
</div>
|
||||
<div class="metric-card stability">
|
||||
<div class="metric-info">
|
||||
<span class="label" data-tooltip="Zombie Project Rate: P-WAR 점수가 -1.0 이하인 '대체 수준 미달' 프로젝트의 비중입니다.">좀비 프로젝트 비율 <i class="info-icon">?</i></span>
|
||||
<h3 class="value">0%</h3>
|
||||
<span class="trend steady">집중 관리 대상</span>
|
||||
<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>
|
||||
</div>
|
||||
<div class="soi-info-column">
|
||||
<h6>2. 조직 위험 전염 (Contagion)</h6>
|
||||
<p>부서별 평균 활동성을 분석하여 <strong>조직적 방치</strong>를 포착합니다. 소속 부서의 전반적인 SOI가 낮을 경우, 개별 프로젝트의 위험 지수를 상향 조정하여 시스템적 붕괴를 예보합니다.</p>
|
||||
</div>
|
||||
<div class="soi-info-column">
|
||||
<h6>3. 동적 위험 계수 (Adaptive Lambda)</h6>
|
||||
<p>기존의 고정된 공식을 폐기하고, 프로젝트마다 <strong>개별화된 위험 곡선</strong>을 생성합니다. AI가 실시간으로 위험 계수를 재산출하여 가장 실무적인 가치 보존율을 제공합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-chart-mini" id="stabilityChart"></div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 메인 분석 영역 -->
|
||||
<div class="analysis-main-grid">
|
||||
<!-- P-WAR 분석 테이블 -->
|
||||
<div class="analysis-main-full">
|
||||
<div class="analysis-card timeline-analysis">
|
||||
<div class="card-header">
|
||||
<div style="display: flex; flex-direction: column; gap: 4px;">
|
||||
<h4>Project Performance Above Replacement (P-WAR Ranking)</h4>
|
||||
<p style="font-size: 11px; color: #888; margin: 0;">대체 수준(Replacement Level) 프로젝트 대비 기여도를 측정합니다.</p>
|
||||
<h4>Project Stagnation Objective Index (P-SOI Status)</h4>
|
||||
<p style="font-size: 11px; color: #888; margin: 0;">이상적 관리 상태(100%) 대비 현재의 활동 가치 보존율 및 14일 뒤 미래를 예측합니다.</p>
|
||||
</div>
|
||||
<div class="card-tools">
|
||||
<span id="avg-system-info" style="font-size: 11px; color: #888;">* 0.0 = 시스템 평균 계산 중...</span>
|
||||
<span id="avg-system-info" style="font-size: 11px; color: #888;">* SOI (Project Health Score)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- P-WAR 판정 가이드 범례 수정 -->
|
||||
<div class="d-war-guide">
|
||||
<div class="guide-item active-low"><span>양수(+)</span> 운영 중</div>
|
||||
<div class="guide-item warning-mid"><span>음수(-)</span> 위험군</div>
|
||||
<div class="guide-item danger-high"><span>-0.3 이하</span> 방치-삭제대상</div>
|
||||
<div class="guide-item hazard-critical"><span>시스템삭제</span> 잠김예정 프로젝트</div>
|
||||
<div class="guide-item active-low"><span>70%↑</span> 정상</div>
|
||||
<div class="guide-item warning-mid"><span>30~70%</span> 주의</div>
|
||||
<div class="guide-item danger-high"><span>10~30%</span> 위험</div>
|
||||
<div class="guide-item hazard-critical"><span>10%↓</span> 사망</div>
|
||||
</div>
|
||||
<div class="chart-placeholder">
|
||||
<p>R-Engine 시각화 대기 중...</p>
|
||||
|
||||
<!-- 차트 그리드 레이아웃 도입 -->
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- 위험 신호 및 예측 -->
|
||||
<div class="analysis-card risk-prediction">
|
||||
<div class="card-header">
|
||||
<h4>Deep Learning 기반 장애 예보 (Risk Signal)</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="risk-signal-list">
|
||||
<div class="risk-item high">
|
||||
<div class="risk-project">프로젝트 A (해외/중동)</div>
|
||||
<div class="risk-reason">파일 급증 대비 활동 정체 (P-ISO 급락)</div>
|
||||
<div class="risk-status">위험</div>
|
||||
</div>
|
||||
<div class="risk-item warning">
|
||||
<div class="risk-project">프로젝트 B (기술개발)</div>
|
||||
<div class="risk-reason">특정 환경(IE/Edge) 문의 집중 발생</div>
|
||||
<div class="risk-status">주의</div>
|
||||
</div>
|
||||
<div class="risk-item safe">
|
||||
<div class="risk-project">프로젝트 C (국내/장헌)</div>
|
||||
<div class="risk-reason">로그 활동성 및 해결률 안정적 유지</div>
|
||||
<div class="risk-status">안전</div>
|
||||
</div>
|
||||
<div id="p-war-table-container">
|
||||
<!-- 테이블은 기존처럼 동적 삽입 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 분석 상세 설명 모달 -->
|
||||
<div id="analysisModal" class="modal-overlay" onclick="closeAnalysisModal(event)">
|
||||
<div class="modal-content" onclick="event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<h3 id="modalTitle">지표 상세 설명</h3>
|
||||
<button class="modal-close" onclick="closeAnalysisModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body" id="modalBody">
|
||||
<!-- 내용 동적 삽입 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/common.js"></script>
|
||||
<script src="js/analysis.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user