From dff3305da12c41a73a382c0a091d33f32bea6dcb Mon Sep 17 00:00:00 2001 From: Taehoon Date: Tue, 24 Mar 2026 17:54:01 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=84=B8=EC=9D=B4=EB=B2=84=EB=A9=94?= =?UTF-8?q?=ED=8A=B8=EB=A6=AD=EC=8A=A4=20=EA=B8=B0=EB=B0=98=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=EC=A0=9D=ED=8A=B8=20=EC=9E=90=EC=82=B0=20=EA=B0=80?= =?UTF-8?q?=EC=B9=98=20=EB=B6=84=EC=84=9D=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20?= =?UTF-8?q?=EA=B3=A0=EB=8F=84=ED=99=94=20(AVI/VCI=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: AVI 및 VCI(자산 기여도) 산출 로직 구현 - prediction_service.py: 정체 프로젝트 AI 예보 최적화 - js/analysis.js: 전략 매트릭스 및 VCI 등급 시스템 시각화 - templates/analysis.html: UI 용어 최신화 및 스타일 통합 - ANALYSIS_REPORT.md: 분석 지표 공식 및 가이드라인 상세 기술 --- .gitignore | Bin 84 -> 103 bytes ANALYSIS_REPORT.md | 93 ++++-- __pycache__/analysis_service.cpython-312.pyc | Bin 8966 -> 9149 bytes .../prediction_service.cpython-312.pyc | Bin 3473 -> 4164 bytes __pycache__/sql_queries.cpython-312.pyc | Bin 2828 -> 2842 bytes analysis_service.py | 8 +- js/analysis.js | 310 ++++++++++++------ prediction_service.py | 42 ++- sql_queries.py | 2 +- style/analysis.css | 302 +++++++++-------- templates/analysis.html | 46 +-- 11 files changed, 481 insertions(+), 322 deletions(-) diff --git a/.gitignore b/.gitignore index c8a273e058ba9ad7108ac6379462541bdb253741..b86406d1585ac2efa762572e5a953a7d38bf7f81 100644 GIT binary patch delta 24 ecmWGZpAaI<6(3(vnVguMks2Sb&!wdYw~iO^{iu$cy< zL}{W4dUlG5OA}n6O$-ZN7)@LlBb5yyF4VXoE(HAp-T_TGx%u*)`+fJEdvfpXqp#zY zADqr|!s7edo6%^)^UAox`lF1HI~4mI-zuoKBX2EGT`f?IJ0n1gX=gvcL;O_Pp-_X@ z>=HXW2^kKER-U&ryL-DG)6V-YwlfKOHFG!$^h!OZIq`+t2NKm21*-8^I|$L^*;exw zX3S9R5Ss0D7&Lj>rYYDd)JVSk&TG1tWw@@ z)Ed4Vo6qp)c$NpyB}&b^{@s}e!X}8Anc*_r*@I?xGmLL~p{-a}J;jGdHZ3>KbZrqb`k#F5)b}w> zZ)dQ^;XUaV^r7Gxgh9-DF2Z|U_WXh%zV#l06oP!fddYNa*e$ofFpkS{7{Wz46kQdP zvD1m}!?6>wljqb#>_RND>KMpmQdxB(H9B%RyIL`>P7LU3W@t=L>3&uv6J&rJa`gj+ zXA3zc2{()9l~(Zxi`b8Myu1*(*P z@zp=+SrC?e?bt?pZT%dn@~j`R;4H0mpS2K4g^ZOodgl(DYQmTFI3Pl>H{8SVb$=|x qTD8HMRF9Z*vF}DAcHMjjJ#E4mP6d@$;+Q{e5!?uQn5M)GnbXCVNKzb1nR9|EM{V4Na3pG1S%ImRzA6qcOpMfq=K~!n zTlzht{A6hvTgH^hkup|n@gRlClV{54F{VyFA>+=NG+97am8(b|C|(RACKt-ePVSWr zU<}!OPj(OU(!NkBR(9hG!b5T;agXao|-VJ`S4!)nC z-I*CA;~9(DfQB3qQ1BO+oU7!;*gtuPQn_$LMs304dMV+Dm1 diff --git a/__pycache__/prediction_service.cpython-312.pyc b/__pycache__/prediction_service.cpython-312.pyc index b100aa727c2da4a3fcb0408c024632f39ba72e67..d80a302238a3e38c0143c44e8fa5e6d0e59448ee 100644 GIT binary patch delta 1192 zcmX|AO>7%g5PrM1*K6;3ojA6$j%&wNq7sXrl0@NGAt?!z)QC_7Qu!&Cwa*xnW?kN{ zQykVZ;$#pgjkX6HMaV}>704AmUtPAF=SoK^@BhcIuGb`JBt`DWfX zPdl^g(eD$|k3OFlfo(qaZT_Xe4N2jcKTN{C$^2zn+@R18DH5PAXx`y2^xuoS&P#=y zMuK0x;of1`=UDR*&y5O$IqV)n^JvYbusNi#7lR0uUAmukpk}x*AA$2dMq?R-+3So= zqjmO^uoIOzs6A#7(rGW-4DN2--4nZ(hCtbEFxW%&u$THunKyXsE>d}cL4bv&F}s-) znu!L#g32DlP27fuu-KGULEz-B@0#O0temf4>v1kZ}xJsM!t-_}&d+|Wo zXE+Tnk3K^n%JAF>^Y!>(9%5&E;ni0cQYhRss9cS4__c5 z3IQFGK|n+XSESSYbM5A>Pp|ENe)ZN`egDHR_8S{HV8NVQ$DsE$v1i=}B!MZS+-G%a zdo#KBgI}KgeKmG-=D75qnLWFJ-bW}noAi(&bKg5OdJ004pe_oo>(H`#(%%)7A|Y8- zPiuudIcX+@Qx!5o^FESQD6bTXs;+hW@>=@s48dtl&*apMs(1NI8BI^a-gPC&sfBb# z(eo>Lz0~zD7nb2_aXI}Ka0SxkR?@jlUhQ)D=#+3)$&^4^1vQZJTqaqZSM)5_{K$h) z;>gS^!XjUS75o4l@a9j#TxB;Lt);fZV^yKYqv?5O>*=kz&F6oS7h3Ob4AjoojC#5g z8*ec`M<&|xLe&RuHyYp28q?duq1x+UDgRhl0`C#QKvp!y0&?%I`!@HePN= zlATDQd7=Gy%6c#bu)V0XbKnbA>3~DxiGwFNbl+4RZ;pPn2nykN?HwzWY|dGs)758p zLa|!j3XL@HxZBh&C`F?jWyB@8#lJHlu)(t5pJiPgfVrSHZG&lQj5!%OC&}qdtE6{o-tqa$;q<- e>Cdjw)$BPKhWQl*enYXpofjEq>NcX@7W*G=9YyZ| delta 527 zcmX@2Fj1QCG%qg~0}$lDJdml(vym^EjWK<45nCK%>Ew%S;@kp2sUkrTAw2mrTZAB- ze~Tq2KfSm}cykQ<4Mrv{#>r-!8?BjB*d&2!Q`oCm85nAqQ#impP7tpOC|=8&!cxmt z!&1Wv;?=O#Fo9)&_Sdpcj^b2iWS`u{DZ`}6HMxn~yS|7YWGxerc$oqu(r3*CftB_} zq9AdQL!d$@?Tds#JRk-E2Tk@O2_SuowJ5(dFXa|Me-m$Tq%i_#qo(5sfm-f@<<5Y;?K>`1u7`bjZaHVF3B&Ntj8gYrdb(;XTI zEH7I5UI+-jXcc@>Dx`xSs6a}tgYSlj`~v-pB8D5{4rE<44Y(*0*ugpZAD=X95iiIN z9e!^%uzL+B*YbyY39_1gW@ca$`p5)ifmMLLq{(uNHLo { - console.log("Analysis engine initialized..."); - loadPWarData(); + console.log("Business Analysis Engine initialized..."); + loadProjectAnalysisData(); }); -async function loadPWarData() { +async function loadProjectAnalysisData() { try { const response = await fetch('/api/analysis/p-war'); const data = await response.json(); if (data.error) throw new Error(data.error); - renderPWarLeaderboard(data); - renderSOICharts(data); + renderVitalityLeaderboard(data); + renderValueCharts(data); if (data.length > 0 && data[0].avg_info) { const avg = data[0].avg_info; const infoEl = document.getElementById('avg-system-info'); - if (infoEl) infoEl.textContent = `* 시스템 종합 건강도: ${avg.avg_risk}% (0.0%에 가까울수록 방치 심각)`; + if (infoEl) infoEl.textContent = `* 시스템 종합 자산 건전도: ${avg.avg_risk}% (운영 표준 70.0% 대비)`; } } catch (e) { console.error("분석 데이터 로딩 실패:", e); } } -function getStatusInfo(soi, isAutoDelete) { - if (isAutoDelete || soi < 10) return { label: '사망', class: 'badge-system', key: 'dead' }; - if (soi < 30) return { label: '위험', class: 'badge-danger', key: 'danger' }; - if (soi < 70) return { label: '주의', class: 'badge-warning', key: 'warning' }; - return { label: '정상', class: 'badge-active', key: 'active' }; +function getStatusInfo(avi, isAutoDelete) { + if (isAutoDelete || avi < 10) return { label: '중단/방치', class: 'badge-system', key: 'dead' }; + if (avi < 30) return { label: '위험 노출', class: 'badge-danger', key: 'danger' }; + if (avi < 70) return { label: '관리 주의', class: 'badge-warning', key: 'warning' }; + return { label: '정상 운영', class: 'badge-active', key: 'active' }; } -function renderSOICharts(data) { +// VCI 등급 판정 로직 (Sabermetrics WAR 등급 기준 응용) +function getVciGrade(vci) { + if (vci >= 10) return { label: 'Masterpiece', class: 'grade-mvp', desc: '시스템 가치를 견인하는 핵심 자산 (MVP급)' }; + if (vci >= 2) return { label: 'Blue Chip', class: 'grade-allstar', desc: '꾸준한 활력의 우량 자산 (주전급)' }; + if (vci >= -2) return { label: 'Steady', class: 'grade-starter', desc: '표준 수준의 현상 유지 (보결급)' }; + if (vci >= -10) return { label: 'Underperform', class: 'grade-bench', desc: '운영 미비로 인한 가치 하락 (마이너급)' }; + return { label: 'Liability', class: 'grade-out', desc: '가치를 훼손 중인 방치 자산 (방출급)' }; +} + +function renderValueCharts(data) { if (!data || data.length === 0) return; - // 1. 상태 분포 (Doughnut) + // 1. 운영 활력 분포 (Doughnut) try { const stats = { active: [], warning: [], danger: [], dead: [] }; data.forEach(p => { @@ -56,7 +65,7 @@ function renderSOICharts(data) { window.myStatusChart = new Chart(statusCtx, { type: 'doughnut', data: { - labels: ['정상', '주의', '위험', '사망'], + labels: ['정상 운영', '관리 주의', '위험 노출', '중단/방치'], datasets: [{ data: [stats.active.length, stats.warning.length, stats.danger.length, stats.dead.length], backgroundColor: ['#1E5149', '#22c55e', '#f59e0b', '#ef4444'], @@ -75,53 +84,40 @@ function renderSOICharts(data) { onClick: (e, elements) => { if (elements.length > 0) { const idx = elements[0].index; - openProjectListModal(['정상', '주의', '위험', '사망'][idx], stats[['active', 'warning', 'danger', 'dead'][idx]]); + openProjectListModal(['정상 운영', '관리 주의', '위험 노출', '중단/방치'][idx], stats[['active', 'warning', 'danger', 'dead'][idx]]); } } } }); } catch (err) { console.error("도넛 차트 에러:", err); } - // 2. 프로젝트 SWOT 매트릭스 (Scatter) + // 2. 전략적 자산 매트릭스 (Scatter) try { - const scatterData = data.map(p => ({ - x: Math.min(500, p.file_count), - y: p.p_war, - label: p.project_nm - })); + const sortedByAVI = [...data].sort((a, b) => b.p_war - a.p_war); + const top5Ids = sortedByAVI.slice(0, 5).map(p => p.project_nm); + const bottom5Ids = sortedByAVI.slice(-5).map(p => p.project_nm); + const largeProjects = data.filter(p => p.file_count > 450).map(p => p.project_nm); + + const vipProjectNames = new Set([...top5Ids, ...bottom5Ids, ...largeProjects]); + + const scatterData = data.map(p => { + const vci = p.risk_count || 0; + const absVci = Math.abs(vci); + return { + x: Math.min(500, p.file_count), + y: p.p_war, + label: p.project_nm, + isVip: vipProjectNames.has(p.project_nm), + vci: vci, + radius: Math.max(5, Math.min(25, 5 + (absVci / 10))) + }; + }); const vitalityCtx = document.getElementById('forecastChart').getContext('2d'); if (window.myVitalityChart) window.myVitalityChart.destroy(); - // 플러그인 통합 (Duplicate Key 방지) - const chartPlugins = []; - if (typeof ChartDataLabels !== 'undefined') chartPlugins.push(ChartDataLabels); - - chartPlugins.push({ - id: 'quadrants', - beforeDraw: (chart) => { - const { ctx, chartArea: { left, top, right, bottom }, scales: { x, y } } = chart; - const midX = x.getPixelForValue(250); - const midY = y.getPixelForValue(50); - ctx.save(); - ctx.fillStyle = 'rgba(34, 197, 94, 0.03)'; ctx.fillRect(left, top, midX - left, midY - top); - ctx.fillStyle = 'rgba(30, 81, 73, 0.03)'; ctx.fillRect(midX, top, right - midX, midY - top); - ctx.fillStyle = 'rgba(148, 163, 184, 0.03)'; ctx.fillRect(left, midY, midX - left, bottom - midY); - ctx.fillStyle = 'rgba(239, 68, 68, 0.05)'; ctx.fillRect(midX, midY, right - midX, bottom - midY); - ctx.lineWidth = 2; ctx.strokeStyle = 'rgba(0,0,0,0.1)'; ctx.beginPath(); - ctx.moveTo(midX, top); ctx.lineTo(midX, bottom); ctx.moveTo(left, midY); ctx.lineTo(right, midY); ctx.stroke(); - ctx.font = 'bold 12px Pretendard'; ctx.textAlign = 'center'; ctx.fillStyle = 'rgba(0,0,0,0.2)'; - ctx.fillText('활동 양호', (left + midX) / 2, (top + midY) / 2); - ctx.fillText('핵심 우량', (midX + right) / 2, (top + midY) / 2); - ctx.fillText('방치/소규모', (left + midX) / 2, (midY + bottom) / 2); - ctx.fillStyle = 'rgba(239, 68, 68, 0.4)'; ctx.fillText('관리 사각지대', (midX + right) / 2, (midY + bottom) / 2); - ctx.restore(); - } - }); - window.myVitalityChart = new Chart(vitalityCtx, { type: 'scatter', - plugins: chartPlugins, data: { datasets: [{ data: scatterData, @@ -133,45 +129,73 @@ function renderSOICharts(data) { if (p.x < 250 && p.y < 50) return '#94a3b8'; return '#ef4444'; }, - pointRadius: 6, - hoverRadius: 10 + pointRadius: (ctx) => ctx.raw ? ctx.raw.radius : 5, + hoverRadius: (ctx) => (ctx.raw ? ctx.raw.radius : 5) + 3 }] }, options: { responsive: true, maintainAspectRatio: false, - layout: { padding: { top: 30, right: 40, left: 10, bottom: 10 } }, + layout: { padding: { top: 30, right: 45, left: 10, bottom: 10 } }, scales: { x: { type: 'linear', min: 0, max: 500, - title: { display: true, text: '자산 규모 (파일 수)', font: { size: 11, weight: '700' } }, + title: { display: true, text: '파일 수 (Files)', font: { size: 11, weight: '700' } }, grid: { display: false }, ticks: { stepSize: 125, callback: (v) => v >= 500 ? '500+' : v } }, y: { min: 0, max: 100, - title: { display: true, text: '활동성 (SOI %)', font: { size: 11, weight: '700' } }, + title: { display: true, text: '운영 활력 (AVI %)', font: { size: 11, weight: '700' } }, grid: { display: false } } }, plugins: { legend: { display: false }, datalabels: { - align: 'top', offset: 5, font: { size: 10, weight: '700' }, color: '#475569', - formatter: (v) => v.label, - display: (ctx) => ctx.raw.x > 100 || ctx.raw.y < 30, + backgroundColor: 'rgba(255, 255, 255, 0.8)', + borderRadius: 4, padding: 4, + align: (ctx) => (ctx.raw && ctx.raw.y > 80 ? 'bottom' : 'top'), + offset: (ctx) => (ctx.raw ? ctx.raw.radius : 5) + 2, + font: { size: 10, weight: '800' }, + color: '#1e293b', + formatter: (v) => v ? v.label : '', + display: (ctx) => ctx.raw && ctx.raw.isVip, clip: false }, tooltip: { - callbacks: { label: (ctx) => ` [${ctx.raw.label}] SOI: ${ctx.raw.y.toFixed(1)}% | 파일: ${ctx.raw.x >= 500 ? '500+' : ctx.raw.x}개` } + callbacks: { + label: (ctx) => ` [${ctx.raw.label}] 활력(AVI): ${ctx.raw.y.toFixed(1)}% | 가치 기여(VCI): ${ctx.raw.vci.toFixed(1)}` + } } } - } + }, + plugins: [{ + id: 'quadrants', + beforeDraw: (chart) => { + const { ctx, chartArea: { left, top, right, bottom }, scales: { x, y } } = chart; + const midX = x.getPixelForValue(250); + const midY = y.getPixelForValue(50); + ctx.save(); + ctx.fillStyle = 'rgba(34, 197, 94, 0.03)'; ctx.fillRect(left, top, midX - left, midY - top); + ctx.fillStyle = 'rgba(30, 81, 73, 0.03)'; ctx.fillRect(midX, top, right - midX, midY - top); + ctx.fillStyle = 'rgba(148, 163, 184, 0.03)'; ctx.fillRect(left, midY, midX - left, bottom - midY); + ctx.fillStyle = 'rgba(239, 68, 68, 0.05)'; ctx.fillRect(midX, midY, right - midX, bottom - midY); + ctx.lineWidth = 2; ctx.strokeStyle = 'rgba(0,0,0,0.1)'; ctx.beginPath(); + ctx.moveTo(midX, top); ctx.lineTo(midX, bottom); ctx.moveTo(left, midY); ctx.lineTo(right, midY); ctx.stroke(); + ctx.font = 'bold 12px Pretendard'; ctx.textAlign = 'center'; ctx.fillStyle = 'rgba(0,0,0,0.2)'; + ctx.fillText('활력 양호', (left + midX) / 2, (top + midY) / 2); + ctx.fillText('핵심 가치', (midX + right) / 2, (top + midY) / 2); + ctx.fillText('정체/소규모', (left + midX) / 2, (midY + bottom) / 2); + ctx.fillStyle = 'rgba(239, 68, 68, 0.4)'; ctx.fillText('자산 손실 위험', (midX + right) / 2, (midY + bottom) / 2); + ctx.restore(); + } + }] }); - } catch (err) { console.error("SWOT 차트 에러:", err); } + } catch (err) { console.error("전략 매트릭스 에러:", err); } } -function renderPWarLeaderboard(data) { +function renderVitalityLeaderboard(data) { const container = document.getElementById('p-war-table-container'); if (!container) return; const sortedData = [...data].sort((a, b) => a.p_war - b.p_war); @@ -181,29 +205,34 @@ function renderPWarLeaderboard(data) { - + - + - - - + + + + ${sortedData.map((p, idx) => { const status = getStatusInfo(p.p_war, p.is_auto_delete); const rowId = `project-${idx}`; - const ecvText = p.file_count === 0 ? "5% (유령)" : p.file_count < 10 ? "40% (껍데기)" : "100% (신뢰)"; - const ecvClass = (p.file_count < 10) ? "highlight-penalty" : "highlight-val"; + const vci = p.risk_count || 0; + const avi = p.p_war || 0; + const grade = getVciGrade(vci); return ` - - + + + - @@ -275,12 +312,19 @@ function toggleProjectDetail(rowId) { const container = document.querySelector('.table-scroll-wrapper'); const mainRow = document.querySelector(`tr[onclick*="toggleProjectDetail('${rowId}')"]`); const detailRow = document.getElementById(`detail-${rowId}`); + if (detailRow && container) { if (!detailRow.classList.contains('active')) { document.querySelectorAll('.detail-row').forEach(row => row.classList.remove('active')); detailRow.classList.add('active'); - setTimeout(() => { container.scrollTo({ top: mainRow.offsetTop - (container.querySelector('thead').offsetHeight || 40), behavior: 'smooth' }); }, 50); - } else detailRow.classList.remove('active'); + setTimeout(() => { + const headerH = container.querySelector('thead').offsetHeight || 45; + const targetScrollTop = mainRow.offsetTop - headerH; + container.scrollTo({ top: targetScrollTop, behavior: 'smooth' }); + }, 100); + } else { + detailRow.classList.remove('active'); + } } } @@ -288,14 +332,15 @@ function openProjectListModal(label, projects) { const modal = document.getElementById('analysisModal'); const title = document.getElementById('modalTitle'); const body = document.getElementById('modalBody'); - title.innerText = `[${label}] 프로젝트 목록 (${projects.length}건)`; - body.innerHTML = projects.length === 0 ? '

데이터 없음

' : ` + title.innerText = `[${label}] 프로젝트 리스트 (${projects.length}건)`; + body.innerHTML = projects.length === 0 ? '

대상 프로젝트 없음

' : `
프로젝트명프로젝트명 파일 수방치일정체 일수 상태 판정현재 SOI 실무 투입AI 예보 (14d) 가치 기여 (VCI) 활력 지수 (AVI) 업무 집중도 상태 예보 (14d)
${p.project_nm} ${p.file_count.toLocaleString()}개 ${p.days_stagnant}일${status.label}${p.p_war.toFixed(1)}%${status.label === '사망' ? '중단' : status.label} + ${vci > 0 ? '+' : ''}${vci.toFixed(1)} + ${avi.toFixed(1)}%
${p.work_effort}% @@ -215,52 +244,60 @@ function renderPWarLeaderboard(data) {
${p.predicted_soi !== null ? p.predicted_soi.toFixed(1) + '%' : '-'}
+
-
⚙️ AI 위험 적응형 모델(AAS) 산출 시뮬레이션
-
-
- 📊 실질 업무 활성화 분석 (Work Vitality) - 투입률 ${p.work_effort}% +
⚙️ AI 자산 건전성 분석 시뮬레이션 (AAS Metrics)
+ +
+
+
+ 📊 실질 업무 집중도 Analysis + ${p.work_effort}% +
+
+
최근 수집 로그 중 실질적 자산 증분이 포착된 밀도입니다.
+
+
+
+
VCI GRADE
+
${grade.label}
+
+
${grade.desc}
-
-
최근 30회 중 실제 파일 변동이 포착된 날의 비율입니다. 현재 ${p.work_effort >= 70 ? '매우 활발' : p.work_effort <= 30 ? '정체' : '간헐적'} 상태입니다.
+
1
-
동적 위험 계수(λ)
+
동적 감쇄 계수(λ) 산출
+
자산 규모 및 조직 위험을 합산하여 개별 활력 곡선을 생성합니다.
λ = ${p.ai_lambda.toFixed(4)}
2
-
활동 품질 (Quality)
-
Factor = ${(p.log_quality * 100).toFixed(0)}%
+
활동 진정성 검증
+
Factor = ${(p.log_quality * 100).toFixed(0)}%
3
-
방치 시간 감쇄
-
Result = ${((p.p_war / (p.file_count === 0 ? 0.05 : p.file_count < 10 ? 0.4 : 1) / p.log_quality) || 0).toFixed(1)}%
+
가동 보존율 (AVI)
+
Result = ${avi.toFixed(1)}%
4
-
존재 진정성 (ECV)
-
Factor = ${ecvText}
+
가치 기여 영향력 (VCI)
+
VCI = ${vci.toFixed(1)}
-
-
* 공식: AAS_Score × Quality_Factor × ECV_Factor
-
최종 P-SOI: ${p.p_war.toFixed(1)}%
-
- - ${projects.map(p => ``).join('')} + + ${projects.map(p => ``).join('')}
프로젝트명관리자방치일현재 SOI
${p.project_nm}${p.master || '-'}${p.days_stagnant}일${p.p_war.toFixed(1)}%
프로젝트명부서관리자정체일활력(AVI)
${p.project_nm}${p.dept || '-'}${p.master || '-'}${p.days_stagnant}일${p.p_war.toFixed(1)}%
- `; + + `; modal.style.display = 'flex'; } @@ -303,12 +348,75 @@ 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(-λ × days) × 100

방치일수에 따른 가치 하락 모델입니다.

'; + + if (type === 'avi') { + title.innerText = '운영 활력 지수 (AVI) 등급 가이드'; + body.innerHTML = ` +
AVI = exp(-λ × days) × Quality × 100
+

자산의 가동 상태와 생존율을 나타내는 지표입니다.

+ + + + + + + + + +
지수 (AVI)등급운영 상태
90%↑Live실시간 성과물이 도출되는 최상급 가동
70~90%Stable주기적 업데이트가 이뤄지는 표준 안정
30~70%Idle관리가 필요한 유휴/정체 상태
10~30%Risk자산 가치 소멸 직전의 위험 상태
10%↓Frozen운영이 중단된 동결/방치 상태
+ `; + } else if (type === 'vci') { + title.innerText = '자산 가치 기여도 (VCI) 등급 가이드'; + body.innerHTML = ` +

운영 표준(AVI 70%) 대비 자산 가치 기여도에 따른 프로젝트 위상 분류입니다.

+ + + + + + + + + +
점수 (VCI)등급운영 의미
+10.0↑Masterpiece시스템 가치를 견인하는 핵심 자산 (MVP급)
+2.0 ~ +10.0Blue Chip꾸준한 활력의 우량 자산 (주전급)
-2.0 ~ +2.0Steady표준 수준의 현상 유지 (보결급)
-10.0 ~ -2.0Underperform운영 미비로 인한 가치 하락 (마이너급)
-10.0↓Liability가치를 훼손 중인 방치 자산 (방출급)
+ `; + } else if (type === 'focus') { + title.innerText = '업무 집중도 (Job Focus) 등급 가이드'; + body.innerHTML = ` +

단순 관리 로그를 제외한 실질적인 산출물 변화의 밀도입니다.

+ + + + + + + + +
비율 (%)등급활동 성격
80%↑Intensive성과물 위주의 고밀도 집중 작업
50~80%Active성과와 관리가 균형 잡힌 원활한 실행
20~50%Maintenance설정/행정 등 단순 관리 중심의 작업
20%↓Surface실체적 변화가 적은 형식적 로그 중심
+ `; } else { - title.innerText = 'AI 시계열 예측 상세'; - body.innerHTML = '

활동 가속도 및 밀도를 분석하여 14일 뒤의 상태를 예보합니다.

'; + title.innerText = '상태 예보 (AI Forecast) 분석 가이드'; + body.innerHTML = ` +
+ "2주 뒤의 프로젝트 건강 상태를 예측합니다" +

단순한 현재 점수 나열이 아닌, 최근 활동의 가속도(Acceleration)변화 패턴을 AI가 분석하여 미래의 활력 지수(AVI)를 예보합니다.

+
+ + + + + + + + + +
분석 결과상태 등급관리 가이드라인
AVI 상승↑성장 가속활동 모멘텀이 상승 중인 우수 자산
AVI 유지안정 유지현재의 리듬을 유지하는 표준 운영 상태
AVI 하락↓활력 저하정체 징후 포착, 관리 리소스 투입 검토
AVI 10%↓중단 위기단기 내 완전 방치 및 가치 소멸 위험
+ +
+ ※ 분석 알고리즘 안내:
+ 파일 수의 실질적 증가가 없는 프로젝트는 '성장 가속' 예보를 받을 수 없도록 설계되어 있으며, 정체가 길어질수록 감쇄 가중치가 자동으로 강화됩니다. +
+ `; } modal.style.display = 'flex'; } diff --git a/prediction_service.py b/prediction_service.py index d97e419..7212d0b 100644 --- a/prediction_service.py +++ b/prediction_service.py @@ -53,19 +53,45 @@ class SOIPredictionService: @staticmethod def predict_future_soi(current_soi, history, days_ahead=14): """기존 점수와 시계열 피처를 결합하여 미래 점수 예측""" - if not history or len(history) < 2: - return round(max(0, min(100, current_soi - (0.05 * days_ahead))), 1) + # 데이터가 너무 적으면 무조건 보수적 감쇄 (14일 기준 약 -2.1점) + if not history or len(history) < 3: + return round(max(0, min(100, current_soi - (0.15 * days_ahead))), 1) features = SOIPredictionService.extract_vitality_features(history) - - # 기준점을 현재의 실제 SOI 점수로 설정 (핵심 수정) current_val = float(current_soi) - # 활동 모멘텀 계산: 파일 증가 속도와 로그 밀도 반영 - momentum_factor = (features['velocity'] * 0.2) + (features['density'] * 2.0) + # [정밀 정체 분석] + # 1. 파일 수 변화 확인 (최근 5개 샘플) + recent_counts = [int(h['file_count'] or 0) for h in history[-5:]] + is_hard_stagnant = len(set(recent_counts)) <= 1 # 파일 수 변동이 전혀 없음 - # 예측 로직: 현재값 + 모멘텀 - 자연 감쇄 - decay_constant = 0.05 + # 2. 최근 로그 상태 확인 + last_log = history[-1]['recent_log'] + is_no_activity = last_log is None or last_log == "데이터 없음" or "폴더자동삭제" in last_log + + # [모멘텀 산출 로직 개편] + if is_hard_stagnant: + # 파일 변화가 없다면 아무리 로그가 있어도 '유지 관리'일 뿐 '성장'이 아님 + # 오히려 시간이 갈수록 기술 부채와 데이터 노후화가 진행된다고 판단 (강력 패널티) + momentum_factor = -2.5 if is_no_activity else -1.0 + else: + # 실질적인 파일 수 변화(Velocity)가 있을 때만 긍정적 모멘텀 검토 + v_gain = features['velocity'] * 0.5 + d_gain = features['density'] * 0.8 + momentum_factor = v_gain + d_gain - 0.5 # 기본적으로 하향 압력 부여 + + # 예측 로직: 현재값 + 모멘텀 - (시간에 따른 자연 부식) + # 정체 시 momentum_factor가 -1.0~-2.5이므로 감쇄가 매우 빠름 + decay_constant = 0.08 predicted = current_val + momentum_factor - (decay_constant * days_ahead) + # [최종 방어 로직] + # 실질적 파일 증가(velocity > 0)가 포착되지 않았다면 예보는 현재값보다 클 수 없음 + if features['velocity'] <= 0 and predicted > current_val: + predicted = current_val - 1.5 # 강제 하락 + + # 사망 선고 (AVI가 이미 낮고 정체 중이면 0에 수렴하도록 가속) + if current_val < 20 and is_hard_stagnant: + predicted = max(0, predicted - 5.0) + return round(max(0, min(100, predicted)), 1) diff --git a/sql_queries.py b/sql_queries.py index 99ac959..fd61b1f 100644 --- a/sql_queries.py +++ b/sql_queries.py @@ -40,7 +40,7 @@ class DashboardQueries: # 활성도 분석을 위한 프로젝트 목록 조회 GET_PROJECT_LIST_FOR_ANALYSIS = """ - SELECT m.project_id, m.project_nm, m.short_nm, h.recent_log, h.file_count + SELECT m.project_id, m.project_nm, m.short_nm, m.department, h.recent_log, h.file_count FROM projects_master m LEFT JOIN projects_history h ON m.project_id = h.project_id AND h.crawl_date = %s """ diff --git a/style/analysis.css b/style/analysis.css index e814b19..208ced2 100644 --- a/style/analysis.css +++ b/style/analysis.css @@ -1,5 +1,6 @@ /* ========================================================================== - Project Master Analysis - Sabermetrics Style + Project Master Analysis - Specific Styles + (Inherits base styles from common.css) ========================================================================== */ .analysis-content { @@ -18,7 +19,6 @@ font-weight: 800; display: inline-block; margin-bottom: 8px; - letter-spacing: 0.5px; } .analysis-header { @@ -26,216 +26,208 @@ justify-content: space-between; align-items: center; margin-bottom: 24px; + padding: 10px 0; + border-bottom: 1px solid var(--border-color); } -/* Top Info Grid (AI Info & SOI Deep Dive) */ +.analysis-header h2 { font-size: 22px; font-weight: 800; color: var(--text-main); margin-bottom: 4px; } +.analysis-header p { font-size: 13px; color: var(--text-sub); } + +/* Top Info Grid */ .top-info-grid { display: grid; - grid-template-columns: 1fr 2fr; + grid-template-columns: 1fr 2.2fr; gap: 16px; margin-bottom: 24px; } .dl-model-info, .soi-deep-dive { background: white; - border-radius: 12px; - border: 1px solid #eef2f6; - box-shadow: 0 4px 12px rgba(0,0,0,0.03); + border-radius: var(--radius-xl); + border: 1px solid var(--border-color); padding: 20px; + box-shadow: var(--box-shadow); } -.model-desc-vertical { - display: flex; - flex-direction: column; - gap: 12px; -} +.card-header { margin-bottom: 15px; display: flex; align-items: center; justify-content: space-between; } +.card-header h4 { font-size: 14px; font-weight: 800; color: var(--primary-color); margin: 0; } -.model-item-vertical { - display: flex; - align-items: center; - gap: 12px; -} +.model-desc-vertical { display: flex; flex-direction: column; gap: 12px; } +.model-item-vertical { display: flex; align-items: center; gap: 12px; } +.model-tag { background: var(--bg-muted); color: var(--text-sub); padding: 2px 8px; border-radius: 4px; font-size: 10px; font-weight: 700; } -.model-tag { - background: #f1f5f9; - color: #475569; - padding: 2px 8px; - border-radius: 4px; - font-size: 10px; - font-weight: 700; - white-space: nowrap; -} - -.soi-info-columns { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 20px; -} - -.soi-info-column h6 { - font-size: 12px; - font-weight: 800; - color: #1e5149; - margin: 0 0 8px 0; -} - -.soi-info-column p { - font-size: 11.5px; - color: #64748b; - line-height: 1.6; - margin: 0; -} +.soi-info-columns { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; } +.soi-info-column h6 { font-size: 12px; font-weight: 800; color: var(--primary-color); margin: 0 0 8px 0; } +.soi-info-column p { font-size: 11.5px; color: var(--text-sub); line-height: 1.6; margin: 0; } /* Chart Grid Layout */ .analysis-charts-grid { display: grid; - grid-template-columns: 1.2fr 2fr; + grid-template-columns: 1fr 1.8fr; gap: 20px; margin-bottom: 24px; } .chart-container-box { background: white; - border-radius: 12px; + border-radius: var(--radius-xl); padding: 20px; - border: 1px solid #eef2f6; - height: 340px; + border: 1px solid var(--border-color); + height: 360px; display: flex; flex-direction: column; + box-shadow: var(--box-shadow); } -.chart-container-box h5 { - margin: 0 0 15px 0; - font-size: 13px; - font-weight: 700; - color: #334155; +.chart-container-box h5 { margin: 0 0 15px 0; font-size: 13px; font-weight: 700; color: var(--text-main); } + +/* Timeline Analysis Card */ +.analysis-card { + background: white; + border-radius: var(--radius-xl); + border: 1px solid var(--border-color); + box-shadow: var(--box-shadow); + margin-bottom: 24px; + overflow: hidden; } +.analysis-card .card-header { + padding: 16px 24px; + background: #fff; + border-bottom: 1px solid var(--border-color); +} + +.analysis-card .card-body { padding: 24px; } + +/* SOI Guide Styles */ +.d-war-guide { + display: flex; + gap: 10px; + margin-bottom: 20px; + padding: 12px; + background: var(--bg-muted); + border-radius: var(--radius-lg); +} + +.guide-item { + font-size: 11px; + font-weight: 700; + padding: 4px 10px; + border-radius: 4px; + display: flex; + align-items: center; + gap: 6px; +} + +.guide-item.active-low { background: #dcfce7; color: #166534; } +.guide-item.warning-mid { background: #fef9c3; color: #854d0e; } +.guide-item.danger-high { background: #ffedd5; color: #9a3412; } +.guide-item.hazard-critical { background: #fee2e2; color: #991b1b; } + /* Data Table Customization */ -.p-war-table-container { - margin-top: 24px; +.table-scroll-wrapper { + overflow-x: auto; + overflow-y: auto; + max-height: 600px; + border-radius: var(--radius-lg); + border: 1px solid var(--border-color); + background: white; } -.project-row { - cursor: pointer; - transition: background 0.2s ease; +.p-war-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + table-layout: fixed; /* 컬럼 너비 고정 */ } -.project-row:hover { - background: #f8fafc !important; +.p-war-table th { + position: sticky; + top: 0; + z-index: 20; + background: #f8fafc; + padding: 16px 15px; + font-size: 12px; + font-weight: 800; + color: #475569; + border-bottom: 2px solid #e2e8f0; + white-space: nowrap; } +.p-war-table td { + padding: 14px 15px; + font-size: 13px; + border-bottom: 1px solid #f1f5f9; + vertical-align: middle; +} + +/* 컬럼별 너비 정의 */ +.p-war-table th:nth-child(1), .p-war-table td:nth-child(1) { width: 28%; text-align: left; } /* 프로젝트명 */ +.p-war-table th:nth-child(2), .p-war-table td:nth-child(2) { width: 10%; text-align: right; } /* 파일 수 */ +.p-war-table th:nth-child(3), .p-war-table td:nth-child(3) { width: 10%; text-align: right; } /* 방치일 */ +.p-war-table th:nth-child(4), .p-war-table td:nth-child(4) { width: 10%; text-align: center; } /* 상태 판정 */ +.p-war-table th:nth-child(5), .p-war-table td:nth-child(5) { width: 14%; text-align: right; } /* P-WAR+ */ +.p-war-table th:nth-child(6), .p-war-table td:nth-child(6) { width: 12%; text-align: right; } /* 현재 SOI */ +.p-war-table th:nth-child(7), .p-war-table td:nth-child(7) { width: 12%; text-align: center; } /* 실무 투입 */ +.p-war-table th:nth-child(8), .p-war-table td:nth-child(8) { width: 14%; text-align: center; } /* AI 예보 */ + +.project-row { cursor: pointer; transition: background 0.2s; } +.project-row:hover { background: var(--hover-bg) !important; } + +/* SOI Value Styling */ +.p-war-value { font-weight: 800; font-family: 'Consolas', monospace; } + /* Accordion Detail Styles */ -.detail-row { - display: none; - background: #fdfdfd; -} - -.detail-row.active { - display: table-row; -} - -.detail-container { - padding: 20px 24px; - border-bottom: 2px solid #f1f5f9; -} +.detail-row { display: none; background: #fafafa; } +.detail-row.active { display: table-row; } +.detail-container { padding: 20px 24px; } .formula-explanation-card { background: white; - border-radius: 12px; + border-radius: var(--radius-lg); padding: 24px; - border: 1px solid #e2e8f0; - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05); + border: 1px solid var(--border-color); + box-shadow: var(--box-shadow); } -.formula-header { - font-size: 13px; - font-weight: 700; - color: #6366f1; - margin-bottom: 15px; -} +.formula-header { font-size: 13px; font-weight: 700; color: #6366f1; margin-bottom: 15px; } -/* Work Effort Bar Area */ -.work-effort-section { - background: #f8fafc; - padding: 16px; - border-radius: 8px; - margin-bottom: 20px; - border: 1px solid #eef2f6; -} - -.work-effort-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 8px; -} - -.work-effort-bar-bg { - width: 100%; - height: 6px; - background: #e2e8f0; - border-radius: 3px; - overflow: hidden; - margin-bottom: 10px; -} +/* Work Effort Section */ +.work-effort-section { background: var(--bg-muted); padding: 16px; border-radius: var(--radius-lg); margin-bottom: 20px; } +.work-effort-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; } +.work-effort-bar-bg { width: 100%; height: 6px; background: #e2e8f0; border-radius: 3px; overflow: hidden; margin-bottom: 10px; } /* Formula Steps Grid */ -.formula-steps-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 20px; -} +.formula-steps-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } +.formula-step { display: flex; gap: 12px; } +.step-num { background: var(--primary-color); 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; } +.step-title { font-size: 12px; font-weight: 700; color: var(--text-main); margin-bottom: 4px; } +.math-logic { font-family: 'Consolas', monospace; background: var(--bg-muted); padding: 4px 8px; border-radius: 4px; font-weight: 700; color: var(--text-main); font-size: 12px; display: inline-block; } -.formula-step { +.final-result-area { margin-top: 20px; padding-top: 15px; border-top: 2px solid var(--primary-color); display: flex; justify-content: space-between; align-items: center; } + +/* Modal Analysis Specific */ +.modal-footer { + padding: 16px 24px; + background: #fff; + border-top: 1px solid var(--border-color); + text-align: right; display: flex; - gap: 12px; + justify-content: flex-end; } -.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; -} +/* Formula & Badges */ +.formula-section { margin-bottom: 20px; } +.formula-box { background: var(--primary-lv-0); color: var(--primary-color); padding: 15px; border-radius: var(--radius-lg); font-weight: 800; text-align: center; font-family: monospace; font-size: 16px; } -.step-title { - font-size: 12px; - font-weight: 700; - color: #334155; - margin-bottom: 4px; -} +.badge-active { background: #dcfce7; color: #166534; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; } +.badge-warning { background: #fef9c3; color: #854d0e; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; } +.badge-danger { background: #ffedd5; color: #9a3412; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; } +.badge-system { background: #fee2e2; color: #991b1b; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; } -.math-logic { - font-family: 'Consolas', monospace; - background: #f1f5f9; - padding: 4px 8px; - border-radius: 4px; - font-weight: 700; - color: #0f172a; - font-size: 12px; - display: inline-block; -} - -.final-result-area { - margin-top: 20px; - padding-top: 15px; - border-top: 2px solid #1e5149; - display: flex; - justify-content: space-between; - align-items: center; -} - -/* Utility Classes */ .highlight-var { color: #2563eb; } .highlight-val { color: #059669; } .highlight-penalty { color: #dc2626; } .text-plus { color: #059669; font-weight: 700; } .text-minus { color: #dc2626; font-weight: 700; } +.font-bold { font-weight: 700; } diff --git a/templates/analysis.html b/templates/analysis.html index 973da5a..630c45d 100644 --- a/templates/analysis.html +++ b/templates/analysis.html @@ -30,11 +30,11 @@
AI Sabermetrics
-

시스템 운영 빅데이터 분석

-

수집된 활동 로그 및 자산 데이터를 기반으로 한 통계적 성능 지표 (Beta)

+

시스템 운영 자산 가치 분석

+

수집된 활동 로그 및 자산 데이터를 기반으로 한 통계적 활력 지표 (Beta)

- +
@@ -47,12 +47,12 @@
- 알고리즘 -

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

+ 분석 모델 +

최근 9회차 시계열의 Velocity 및 변화율 분석

- 판단 로직 -

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

+ 가중치 로직 +

활동 시 '선형 유지', 정체 시 '지수 감쇄' 동적 적용

@@ -65,16 +65,16 @@
-
1. AI 자산 가치 평가
-

자산 규모를 감지하여, 대형 프로젝트 방치 시 데이터 가치 하락 속도를 가속(Acceleration)시킵니다.

+
1. 자산 가치 변동 추적
+

규모를 감지하여, 대형 프로젝트 정체 시 데이터 가치 하락 속도를 가속(Acceleration)시킵니다.

-
2. 조직 위험 전염
-

소속 부서의 전반적인 활동성이 낮을 경우, 개별 위험 지수를 상향 조정하여 시스템적 붕괴를 예보합니다.

+
2. 조직적 위험 전염
+

소속 부서의 전반적인 활력이 낮을 경우, 개별 위험 지수를 상향 조정하여 시스템적 붕괴를 예보합니다.

-
3. 동적 위험 계수
-

프로젝트마다 개별화된 위험 곡선을 생성하여 현장에 가장 밀착된 가치 보존율을 제공합니다.

+
3. 동적 가치 계수
+

프로젝트마다 개별화된 감쇄 곡선을 생성하여 현장에 가장 밀착된 보존율을 제공합니다.

@@ -84,11 +84,11 @@
-
건강 상태 분포 (Project Distribution)
+
운영 활력 분포 (Activity Distribution)
-
프로젝트 SWOT 매트릭스 (Strategic Analysis)
+
자산 가치 전략 매트릭스 (Strategic Analysis)
@@ -97,19 +97,19 @@
-

Project Stagnation Objective Index (P-SOI Status)

-

이상적 관리 상태(100%) 대비 활동 보존율 및 미래 예측 리더보드

+

Project Activity Vitality Leaderboard (AVI Status)

+

운영 표준(AVI 70%) 대비 파일 보존율 및 미래 가치 기여 리더보드

- * SOI (Project Health Score) + * AVI (Activity Vitality Index)
-
70%↑ 정상
-
30~70% 주의
-
10~30% 위험
-
10%↓ 사망
+
70%↑ 정상 운영
+
30~70% 관리 주의
+
10~30% 위험 노출
+
10%↓ 중단/방치
@@ -124,7 +124,7 @@