feat: 분석 페이지 고도화 및 AI 위험 적응형(AAS) SOI 모델 도입

This commit is contained in:
2026-03-23 13:51:05 +09:00
parent 0953f49db1
commit d416fee414
9 changed files with 1119 additions and 337 deletions

168
analysis_service.py Normal file
View 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
View 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}

View File

@@ -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);
// 상태 판정 공통 함수
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' };
}
}
// 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)}%`;
// 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); }
// 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 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);
if (detailRow && container) {
const isActive = detailRow.classList.contains('active');
const combined = [...autoDeleted, ...highRiskProjects];
if (!isActive) {
// 다른 열려있는 상세 행 닫기 (맥락 유지를 위해 권장)
document.querySelectorAll('.detail-row').forEach(row => row.classList.remove('active'));
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('');
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
View 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
View 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
View 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
View File

@@ -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"

View File

@@ -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;

View File

@@ -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,97 +40,103 @@
</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 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="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="model-item-vertical">
<span class="model-tag">판단 로직</span>
<p>활동 시 <strong>'선형 추세'</strong>, 정체 시 <strong>'지수 감쇄'</strong> 가중치 적용</p>
</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>
</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>
<div class="metric-chart-mini" id="stabilityChart"></div>
</div>
</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="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>
</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 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()">&times;</button>
</div>
<div class="modal-body" id="modalBody">
<!-- 내용 동적 삽입 -->
</div>
</div>
</div>
<script src="js/common.js"></script>
<script src="js/analysis.js"></script>
</body>