From d416fee414adc6198151e19cd8c00d4befd2c769 Mon Sep 17 00:00:00 2001 From: Taehoon Date: Mon, 23 Mar 2026 13:51:05 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=B6=84=EC=84=9D=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EA=B3=A0=EB=8F=84=ED=99=94=20=EB=B0=8F=20AI=20?= =?UTF-8?q?=EC=9C=84=ED=97=98=20=EC=A0=81=EC=9D=91=ED=98=95(AAS)=20SOI=20?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- analysis_service.py | 168 +++++++++++++++ inquiry_service.py | 42 ++++ js/analysis.js | 447 ++++++++++++++++++++++++++++++++++------ prediction_service.py | 78 +++++++ project_service.py | 33 +++ schemas.py | 10 + server.py | 228 +++----------------- style/analysis.css | 304 ++++++++++++++++++++++++++- templates/analysis.html | 146 ++++++------- 9 files changed, 1119 insertions(+), 337 deletions(-) create mode 100644 analysis_service.py create mode 100644 inquiry_service.py create mode 100644 prediction_service.py create mode 100644 project_service.py create mode 100644 schemas.py diff --git a/analysis_service.py b/analysis_service.py new file mode 100644 index 0000000..786b5fd --- /dev/null +++ b/analysis_service.py @@ -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 diff --git a/inquiry_service.py b/inquiry_service.py new file mode 100644 index 0000000..262af3d --- /dev/null +++ b/inquiry_service.py @@ -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} diff --git a/js/analysis.js b/js/analysis.js index 16940aa..e195d06 100644 --- a/js/analysis.js +++ b/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 = `
- - + - - + + + - ${sortedData.map(p => { - let statusBadge = ""; - if (p.is_auto_delete) { - statusBadge = '잠김예정 프로젝트'; - } else if (p.p_war > 0) { - statusBadge = '운영 중'; - } else if (p.p_war <= -0.3) { - statusBadge = '방치-삭제대상'; - } else { - statusBadge = '위험군'; + ${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 = '▼ 급락'; + else if (diff < 0) trendIcon = '↘ 하락'; + else trendIcon = '↗ 유지'; } + // 수식 상세 데이터 준비 + 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 ` - + - - - + + + + + `; @@ -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 => ` -
-
${p.project_nm} (${p.master})
-
- ${p.is_auto_delete ? '[잠김예정] 활동 부재로 인한 시스템 자동 삭제 발생' : `P-WAR ${p.p_war} (대체 수준 이하 정체)`} -
-
위험
-
- `).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 = '

해당 조건의 프로젝트가 없습니다.

'; + } else { + body.innerHTML = ` +
+
프로젝트명관리 상태프로젝트명 파일 수 방치일미결리스크P-WAR (기여도)상태 판정 + 현재 SOI + + AI 예보 (14d) +
${p.project_nm}${statusBadge} ${p.file_count.toLocaleString()}개 ${p.days_stagnant}일${p.risk_count}건 - ${p.p_war > 0 ? '+' : ''}${p.p_war} + ${status.label} + ${soi.toFixed(1)}% + +
+ + ${pred !== null ? pred.toFixed(1) + '%' : '-'} + + ${trendIcon} +
+
+
+
+
+ ⚙️ AI 위험 적응형 모델(AAS) 산출 시뮬레이션 +
+ +
+
1
+
+
동적 위험 계수(λ) 산출
+
기본 감쇄율에 자산 규모와 부서 위험도를 합산합니다.
+
+ λ = ${baseLambda} (Base) + ${scaleImpact.toFixed(4)} (Scale) + ${envImpact.toFixed(4)} (Env) + = ${p.ai_lambda.toFixed(4)} +
+
+
+ +
+
2
+
+
방치 시간 감쇄 적용
+
마지막 로그 이후 경과된 시간만큼 가치를 하락시킵니다.
+
+ AAS_Score = exp(-${p.ai_lambda.toFixed(4)} × ${p.days_stagnant}일) × 100 + = ${((soi / (p.file_count === 0 ? 0.05 : p.file_count < 10 ? 0.4 : 1)) || 0).toFixed(1)}% +
+
+
+ +
+
3
+
+
존재 진정성 검증 (ECV Penalty)
+
파일 수 기반의 활동 신뢰도를 적용하여 유령 활동을 차단합니다.
+
+ Final_SOI = AAS_Score × ${ecvText} + = ${soi.toFixed(1)}% +
+
+
+
+
+ + + + + + + + + + ${projects.sort((a,b) => a.p_war - b.p_war).map(p => ` + + + + + + + `).join('')} + +
프로젝트명관리자방치일현재 SOI
${p.project_nm}${p.master || '-'}${p.days_stagnant}일${p.p_war.toFixed(1)}%
+
+ `; + } + + 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 = ` +
+ 기본 산술 수식 +
SOI = exp(-0.05 × days) × 100
+
+
+

본 지수는 프로젝트의 '절대적 가치 보존율'을 측정합니다.

+ +
+ `; + } else if (type === 'ai') { + title.innerText = 'AI 시계열 예측 알고리즘 상세'; + body.innerHTML = ` +
+ 하이브리드 추세 엔진 +
Pred = (Linear × w1) + (Decay × w2)
+
+
+

딥러닝 엔진이 프로젝트의 '활동 가속도'를 분석하여 14일 뒤의 상태를 예보합니다.

+ +
+ `; + } + + modal.style.display = 'flex'; +} + +function closeAnalysisModal(e) { + document.getElementById('analysisModal').style.display = 'none'; } diff --git a/prediction_service.py b/prediction_service.py new file mode 100644 index 0000000..dc4c361 --- /dev/null +++ b/prediction_service.py @@ -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)) diff --git a/project_service.py b/project_service.py new file mode 100644 index 0000000..6ad3702 --- /dev/null +++ b/project_service.py @@ -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} diff --git a/schemas.py b/schemas.py new file mode 100644 index 0000000..790822f --- /dev/null +++ b/schemas.py @@ -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 diff --git a/server.py b/server.py index 4b45c10..11b04ea 100644 --- a/server.py +++ b/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" diff --git a/style/analysis.css b/style/analysis.css index 6e87950..47d62bf 100644 --- a/style/analysis.css +++ b/style/analysis.css @@ -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; diff --git a/templates/analysis.html b/templates/analysis.html index 37c963d..32e3d3f 100644 --- a/templates/analysis.html +++ b/templates/analysis.html @@ -9,6 +9,8 @@ href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" /> + + @@ -26,7 +28,7 @@ -
+
AI Sabermetrics
@@ -38,99 +40,105 @@
- -
-
-
- 평균 P-WAR (기여도) ? -

0.00

- 대체 수준(0.0) 대비 +
+ +
+
+

AI Hybrid Prediction Engine

-
-
-
-
- 미결 리스크 총합 ? -

0

- 실시간 집계 +
+
+
+ 알고리즘 +

최근 9회차 시계열의 Velocity 및 가속도 분석

+
+
+ 판단 로직 +

활동 시 '선형 추세', 정체 시 '지수 감쇄' 가중치 적용

+
+
-
-
-
-
- 활성 자원 규모 ? -

0

- 시스템 기여 자원 +
+ + +
+
+

i AI 위험 적응형 모델 (AAS) 기반 지표 정의

-
- -
-
- 좀비 프로젝트 비율 ? -

0%

- 집중 관리 대상 +
+
+
+
1. AI 자산 가치 평가 (Scale)
+

단순 방치가 아닌 자산의 크기를 감지합니다. 파일 수가 많은 프로젝트는 관리 공백 시 데이터 가치 하락 속도를 AI가 자동으로 가속(Acceleration)시켜 경고를 강화합니다.

+
+
+
2. 조직 위험 전염 (Contagion)
+

부서별 평균 활동성을 분석하여 조직적 방치를 포착합니다. 소속 부서의 전반적인 SOI가 낮을 경우, 개별 프로젝트의 위험 지수를 상향 조정하여 시스템적 붕괴를 예보합니다.

+
+
+
3. 동적 위험 계수 (Adaptive Lambda)
+

기존의 고정된 공식을 폐기하고, 프로젝트마다 개별화된 위험 곡선을 생성합니다. AI가 실시간으로 위험 계수를 재산출하여 가장 실무적인 가치 보존율을 제공합니다.

+
+
-
-
-
+ + -
- +
-

Project Performance Above Replacement (P-WAR Ranking)

-

대체 수준(Replacement Level) 프로젝트 대비 기여도를 측정합니다.

+

Project Stagnation Objective Index (P-SOI Status)

+

이상적 관리 상태(100%) 대비 현재의 활동 가치 보존율 및 14일 뒤 미래를 예측합니다.

- * 0.0 = 시스템 평균 계산 중... + * SOI (Project Health Score)
-
-
양수(+) 운영 중
-
음수(-) 위험군
-
-0.3 이하 방치-삭제대상
-
시스템삭제 잠김예정 프로젝트
+
70%↑ 정상
+
30~70% 주의
+
10~30% 위험
+
10%↓ 사망
-
-

R-Engine 시각화 대기 중...

+ + +
+
+
건강 상태 분포 (Project Distribution)
+ +
+
+
관리 사각지대 진단 (Vitality Scatter Plot)
+ +
-
-
- -
-
-

Deep Learning 기반 장애 예보 (Risk Signal)

-
-
-
-
-
프로젝트 A (해외/중동)
-
파일 급증 대비 활동 정체 (P-ISO 급락)
-
위험
-
-
-
프로젝트 B (기술개발)
-
특정 환경(IE/Edge) 문의 집중 발생
-
주의
-
-
-
프로젝트 C (국내/장헌)
-
로그 활동성 및 해결률 안정적 유지
-
안전
-
+
+
+ + + - \ No newline at end of file +