feat: 리눅스 환경 파일 동기화, Dockerfile 및 docker-compose 추가, 캐시 무시 설정
This commit is contained in:
BIN
.gitignore
vendored
BIN
.gitignore
vendored
Binary file not shown.
@@ -1,81 +1,60 @@
|
||||
# 📊 시스템 운영 자산 가치 분석 보고서 (Sabermetrics Edition)
|
||||
# 📊 시스템 운영 자산 가치 분석 보고 (Sabermetrics Report)
|
||||
|
||||
본 보고서는 야구의 통계 분석 기법인 **세이버메트릭스(Sabermetrics)**를 프로젝트 관리 시스템에 이식하여, 단순 활동량 측정을 넘어 **'실질적 자산 가치'**와 **'미래 운영 위험'**을 정밀 분석한 결과입니다.
|
||||
본 보고서는 프로젝트 관리 시스템 내에서 수집된 활동 로그 및 자산 데이터를 통계적/AI 기법으로 분석하여, 각 프로젝트의 운영 활력과 조직 기여도를 정량화한 지표를 정의합니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 핵심 분석 지표 정의 (Core Metrics)
|
||||
## 1. 운영 활력 지수 (AVI, Activity Vitality Index)
|
||||
프로젝트가 현재 얼마나 건강하게 가동되고 있는지를 나타내는 **'디지털 자산 생존 지표'**입니다.
|
||||
|
||||
### 1.1 운영 활력 지수 (AVI, Activity Vitality Index)
|
||||
프로젝트가 현재 얼마나 '살아서 숨 쉬고 있는가'를 나타내는 생존 지수입니다.
|
||||
### 1.1 산출 공식
|
||||
$$AVI = e^{-\lambda \times Stagnant\_Days} \times Quality \times 100$$
|
||||
|
||||
* **산출 공식**: $AVI = exp(-\lambda \times days) \times Quality \times ECV \times 100$
|
||||
* **핵심 데이터**:
|
||||
* **정체 일수(days)**: 마지막 유의미한 파일 업데이트 이후 경과 시간.
|
||||
* **감쇄 계수($\lambda$)**: 기본 $0.04$에서 시작하여, 자산 규모(최대 $+0.04$)와 부서 정체율(최대 $+0.03$)을 동적으로 결합합니다.
|
||||
* **활동 품질(Quality)**: 파일 증분 활동($1.0$), 구조적 관리($0.7$), 단순 행정 로그($0.4$)로 차등 배점합니다.
|
||||
* **존재 신뢰도(ECV)**: 파일 수 $0$개($0.05$), $10$개 미만($0.4$) 등 유령 프로젝트에 패널티를 부여합니다.
|
||||
* **의미**: 100%에 가까울수록 실시간 가동 상태이며, 0%에 가까울수록 데이터 노후화가 완료된 '사망' 상태를 뜻합니다.
|
||||
### 1.2 3대 핵심 변수 상세 설명
|
||||
|
||||
### 1.2 자산 가치 기여도 (VCI, Value Contribution Index)
|
||||
시스템 전체의 운영 표준 대비, 해당 프로젝트가 기여하고 있는 가치의 상대적 하중을 측정합니다.
|
||||
#### ① 지수 감쇄 모델 ($e^{-\lambda \times t}$) : "가치의 시한폭탄"
|
||||
자산은 관리하지 않으면 시간이 흐를수록 가치가 기하급수적으로 소멸한다는 **'정보 휘발성'** 원리를 반영합니다.
|
||||
* **Stagnant Days (정체 일수)**: 마지막 유효 활동 로그 기록일로부터 오늘까지 경과된 날짜입니다.
|
||||
* **특징**: 정체 초기에는 점수가 빠르게 하락하다가, 시간이 지날수록 하락 폭이 둔화되며 0에 수렴합니다. 이는 관리가 중단된 직후의 정보 망실 위험이 가장 크다는 실무적 경험을 반영한 것입니다.
|
||||
|
||||
* **산출 공식**: $VCI = (AVI - 70.0) \times (\frac{Files}{200} + 0.5)$
|
||||
* **핵심 로직**:
|
||||
* **건강 기준선(70.0%)**: 시스템 자산 가치를 유지하기 위한 최소 마지노선(Replacement Level)입니다.
|
||||
* **규모 가중치**: 파일 $200$개를 $1.0$ 가중치 기준으로 삼아, 대형 프로젝트일수록 시스템에 주는 충격을 기하급수적으로 반영합니다.
|
||||
* **의미**: 양수(+)는 가치 창출, 음수(-)는 시스템 기회비용을 갉아먹는 '가치 파괴' 상태임을 나타냅니다.
|
||||
#### ② 위험 가속 계수 ($\lambda$) : "대형 자산의 높은 관리 비용"
|
||||
모든 프로젝트는 자산 규모에 따라 '늙어가는 속도'가 다릅니다.
|
||||
* **공식**: $\lambda = 0.04 + \log_{10}(Files + 1) \times 0.008$
|
||||
* **비즈니스 로직**: 파일이 많은 대형 프로젝트일수록 관리 부재 시 조직에 미치는 타격이 큽니다. 따라서 대형 프로젝트일수록 $\lambda$ 값이 커지며, 소형 프로젝트보다 **훨씬 빠른 속도로 AVI가 하락**하도록 설계되었습니다. (대형 프로젝트는 더 자주 관리해야 점수가 유지됨)
|
||||
|
||||
### 1.3 업무 집중도 (Job Focus)
|
||||
단순 관리 행위를 제외하고, 실제 성과물(파일)을 생산하는 데 얼마나 몰입했는지를 판별합니다.
|
||||
|
||||
* **산출 공식**: $Job Focus = \frac{\text{최근 히스토리 중 실제 파일 변동 발생 횟수}}{\text{전체 데이터 수집 횟수}} \times 100$
|
||||
* **의미**: 로그만 남기는 '보여주기식 활동'을 필터링하여 운영의 진정성을 확인합니다.
|
||||
|
||||
### 1.4 운영 일관성 지수 (OCI, Operational Consistency Index)
|
||||
프로젝트 관리의 '리듬'과 '성실도'를 측정하는 지표입니다.
|
||||
|
||||
* **산출 공식**: 최근 30일 데이터를 4개 주차로 분할하여 활동 여부 분석 (주차별 성실도 70% + 활동 밀도 30%)
|
||||
* **의미**: 특정 시점에 몰아치기식 작업을 하는 프로젝트보다, 매주 꾸준히 관리되는 프로젝트에 더 높은 신뢰 점수를 부여합니다.
|
||||
#### ③ 활동 품질 가중치 ($Quality$) : "행정과 실무의 구분"
|
||||
단순히 접속하거나 로그가 찍혔다고 해서 활력이 100% 회복되지 않습니다. AI가 로그 키워드를 분석하여 활동의 **'진정성'**을 평가합니다.
|
||||
* **High (1.0)**: **성과물 중심 활동** (파일 업로드, 수정, 등록, 업데이트 등)
|
||||
* **Medium (0.7)**: **구조적 유지 활동** (폴더 생성, 삭제, 이동 등)
|
||||
* **Low (0.4)**: **단순 행정 활동** (권한 변경, 메일 확인, 참가자 추가 등)
|
||||
|
||||
---
|
||||
|
||||
## 2. 등급 체계 및 관리 가이드 (Grade System)
|
||||
## 2. 자산 가치 기여도 (VCI, Value Contribution Index)
|
||||
야구의 **WAR(Wins Above Replacement)** 개념을 도입하여, 전체 포트폴리오 평균 대비 개별 프로젝트가 조직 가치에 얼마나 기여하는지 산출합니다.
|
||||
|
||||
### 2.1 VCI 등급 (프로젝트 위상)
|
||||
| 등급 (Grade) | 점수 기준 | 운영 의미 및 관리 전략 |
|
||||
| :--- | :--- | :--- |
|
||||
| **Masterpiece** | +10.0 이상 | **최우량 자산**: 시스템 가치를 견인하는 핵심 프로젝트 |
|
||||
| **Blue Chip** | +2.0 ~ +10.0 | **우량 자산**: 꾸준한 활력으로 가치를 창출하는 핵심군 |
|
||||
| **Steady** | -2.0 ~ +2.0 | **안정 자산**: 표준 수준의 운영을 유지 중인 현상 유지군 |
|
||||
| **Underperform** | -10.0 ~ -2.0 | **저성과 자산**: 규모 대비 활력이 부족하여 가치 하락 중인 그룹 |
|
||||
| **Liability** | -10.0 이하 | **고위험 자산**: 시스템 가치를 훼손 중인 방치 프로젝트. 즉시 조치 필요 |
|
||||
### 2.1 산출 공식
|
||||
$$VCI = (Individual\_AVI - Portfolio\_Avg\_AVI) \times Asset\_Weight$$
|
||||
* **Asset Weight (파일 규모 가중치)**: $max(0.2, \frac{Individual\_Files}{Portfolio\_Avg\_Files})$
|
||||
|
||||
### 2.2 운영 일관성 (OCI) 판정
|
||||
* **정기적 (80%↑)**: 주 단위의 정기적 관리가 완벽히 이뤄지는 최우량 관리 상태.
|
||||
* **안정적 (50~80%)**: 간헐적 정체는 있으나 전반적인 관리 리듬을 유지하는 상태.
|
||||
* **간헐적 (20~50%)**: 관리 활동이 불규칙하며, 필요에 의한 일회성 작업 중심인 상태.
|
||||
* **불규칙 (20%↓)**: 장기 정체 중이거나 관리의 영속성을 확인하기 어려운 위험 상태.
|
||||
### 2.2 지표의 의미: "평균(0.0)을 기준으로 한 상대 평가"
|
||||
* **0.0 (평균)**: 조직 내 평균적인 관리 수준과 규모를 가진 표준 프로젝트.
|
||||
* **(+) 점수**: 평균 이상의 활력으로 조직의 디지털 자산 가치를 증대시키는 프로젝트.
|
||||
* **(-) 점수**: 평균 이하의 방치로 인해 조직에 잠재적 기회비용 손실을 입히는 리스크 프로젝트.
|
||||
* **상대 가중치**: 조직의 평균 파일 수보다 큰 프로젝트가 방치될 때 마이너스 점수가 더 가파르게 하락하여 **'우선 관리 대상'**을 명확히 식별합니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 데이터 분석 프로세스 (Analysis Process)
|
||||
## 3. 강력한 예외 처리 (Hard Rules)
|
||||
|
||||
1. **데이터 수집**: `projects_history` 테이블로부터 일별 파일 수 및 로그 텍스트를 추출합니다.
|
||||
2. **피처 추출**:
|
||||
* **Velocity**: 파일 수의 변화 속도 계산.
|
||||
* **Acceleration**: 활동의 가속/감속 여부 판별.
|
||||
* **Stagnation**: 마지막 활동 이후의 공백 기간 측정.
|
||||
3. **AI 시뮬레이션**: 추출된 피처를 AI 위험 적응형 모델 (AAS)에 입력하여 개별 프로젝트만의 **'위험 곡선'**을 생성합니다.
|
||||
4. **최종 판정**: AVI와 VCI를 결합하여 리더보드에 등급과 관리 가이드라인을 송출합니다.
|
||||
데이터의 신뢰도를 확보하기 위해 다음과 같은 **'사망 판정'** 규칙이 적용됩니다.
|
||||
1. **자동 삭제 패널티**: 최근 로그가 시스템에 의한 '폴더자동삭제'인 경우, AVI는 즉시 **0.1%**로 고정됩니다. (관리 포기 상태)
|
||||
2. **자산 부재 패널티 (ECV)**: 파일 개수가 0개인 경우 운영 일관성(OCI)은 **0.0점**이며, 파일 10개 미만은 최종 가중치에 **50% 패널티**를 적용하여 '껍데기 프로젝트'를 걸러냅니다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 관리자 제언 (Action Plan)
|
||||
## 4. 발표 및 분석 가이드 (Executive Summary)
|
||||
|
||||
* **VCI 음수 프로젝트 집중 관리**: 단순 활동량이 아닌 VCI가 낮은 대형 프로젝트부터 우선적으로 인력을 배치하거나 운영 정책을 재점검해야 합니다.
|
||||
* **AI Forecast 활용**: '활력 저하' 예보가 뜬 프로젝트는 실제 AVI가 급락하기 전 선제적인 조치(업무 독려, 파일 현행화)를 취할 수 있습니다.
|
||||
* **파일 수와 활력의 균형**: 파일 수가 많은데 활력(AVI)이 낮은 경우, 시스템 전체의 데이터 무결성을 해칠 수 있으므로 데이터 클렌징이나 아카이빙을 권고합니다.
|
||||
|
||||
---
|
||||
*본 분석 엔진은 Project Master Sabermetrics 알고리즘에 의해 자동 생성되었습니다.*
|
||||
* **AVI가 낮은 프로젝트**: "데이터가 낡아가고 있으니 즉시 최신 성과물을 업데이트하십시오."
|
||||
* **VCI가 음수(-)인 대형 프로젝트**: "조직에서 가장 중요한 자산임에도 불구하고 평균 이하로 방치되고 있습니다. **최우선 관리 대상**입니다."
|
||||
* **OCI가 낮은 프로젝트**: "활동은 있으나 불규칙합니다. 관리의 지속성을 확보하여 운영 리듬을 찾으십시오."
|
||||
|
||||
40
Dockerfile
Normal file
40
Dockerfile
Normal file
@@ -0,0 +1,40 @@
|
||||
# 1. 베이스 이미지 설정 (안정적인 bookworm 버전 사용)
|
||||
FROM python:3.9-slim-bookworm
|
||||
|
||||
# 2. 시스템 의존성 설치 (OCR, PDF, Playwright 관련 핵심 라이브러리)
|
||||
RUN apt-get update && apt-get install -y \
|
||||
tesseract-ocr \
|
||||
libtesseract-dev \
|
||||
poppler-utils \
|
||||
libgl1 \
|
||||
libnss3 \
|
||||
libnspr4 \
|
||||
libatk1.0-0 \
|
||||
libatk-bridge2.0-0 \
|
||||
libcups2 \
|
||||
libdrm2 \
|
||||
libxkbcommon0 \
|
||||
libxcomposite1 \
|
||||
libxdamage1 \
|
||||
libxrandr2 \
|
||||
libgbm1 \
|
||||
libpango-1.0-0 \
|
||||
libcairo2 \
|
||||
libasound2 \
|
||||
fonts-liberation \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 3. 작업 디렉토리 설정
|
||||
WORKDIR /app
|
||||
|
||||
# 4. 필요한 패키지 설치
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
# 브라우저만 설치 (의존성은 위에서 설치함)
|
||||
RUN playwright install chromium
|
||||
|
||||
# 5. 프로젝트 전체 파일 복사
|
||||
COPY . .
|
||||
|
||||
# 6. 서버 구동
|
||||
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -10,64 +10,100 @@ class AnalysisService:
|
||||
|
||||
@staticmethod
|
||||
def calculate_operational_consistency(history_rows, days_stagnant):
|
||||
"""운영 일관성 지수(OCI) 산출 로직 (장기 정체 패널티 포함)
|
||||
"""운영 일관성 지수(OCI) 산출 로직 (자산 규모 및 장기 정체 패널티 포함)
|
||||
최근 30일간 활동 리듬 분석 + 현재 방치 기간에 따른 강력한 감쇄
|
||||
"""
|
||||
if not history_rows or len(history_rows) < 2:
|
||||
return 0.0
|
||||
|
||||
# [추가] 최신 상태 확인: 현재 로그가 '폴더자동삭제'면 점수 즉시 0점 (일수는 실제 일수 유지)
|
||||
latest_log = history_rows[-1].get('recent_log', '') or ''
|
||||
if latest_log and "폴더자동삭제" in latest_log.replace(" ", ""):
|
||||
return 0.0
|
||||
|
||||
# 1. 최근 30일 이력 기반 Base Score 산출
|
||||
now = datetime.now().date()
|
||||
recent_30 = [h for h in history_rows if (now - h['crawl_date']).days <= 30]
|
||||
|
||||
# 주차별 활동 여부 (4주)
|
||||
if not recent_30:
|
||||
return 0.0
|
||||
|
||||
# [추가] 자산 규모 확인: 파일이 0개면 운영 일관성 산출 자체가 무의미함
|
||||
max_files = max([int(h['file_count'] or 0) for h in recent_30])
|
||||
if max_files == 0:
|
||||
return 0.0
|
||||
|
||||
# 주차별 활동 여부 (4주) - 파일이 1개 이상 존재할 때만 유효 활동으로 인정
|
||||
weeks_active = [False, False, False, False]
|
||||
for h in recent_30:
|
||||
days_ago = (now - h['crawl_date']).days
|
||||
week_idx = min(3, days_ago // 7)
|
||||
weeks_active[week_idx] = True
|
||||
if int(h['file_count'] or 0) > 0:
|
||||
days_ago = (now - h['crawl_date']).days
|
||||
week_idx = min(3, days_ago // 7)
|
||||
weeks_active[week_idx] = True
|
||||
|
||||
base_consistency = (sum(weeks_active) / 4) * 70
|
||||
|
||||
# 활동 밀도 (변화 발생일 비율)
|
||||
effort_days = 0
|
||||
for i in range(1, len(recent_30)):
|
||||
# '폴더자동삭제' 로그가 포함된 날의 변화는 관리 노력으로 인정하지 않음
|
||||
log_content = recent_30[i].get('recent_log', '') or ''
|
||||
if "폴더자동삭제" in log_content.replace(" ", ""):
|
||||
continue
|
||||
|
||||
if recent_30[i]['file_count'] != recent_30[i-1]['file_count']:
|
||||
effort_days += 1
|
||||
|
||||
density_score = (effort_days / max(1, len(recent_30))) * 30
|
||||
base_oci = base_consistency + density_score
|
||||
|
||||
# 2. [핵심] 장기 정체 패널티 적용
|
||||
# 방치일이 100일 이상이면 OCI는 0점으로 수렴 (성실도 무효화)
|
||||
# 2. [핵심] 패널티 엔진 적용
|
||||
# A. 장기 정체 패널티: 방치일이 100일 이상이면 0점으로 수렴
|
||||
stagnation_factor = max(0, (100 - days_stagnant) / 100.0)
|
||||
|
||||
final_oci = base_oci * stagnation_factor
|
||||
# B. 자산 부족 패널티 (Existence Confidence): 파일이 너무 적으면 관리 신뢰도 하락
|
||||
# 10개 미만은 50%만 인정, 그 이상은 점진적으로 100%까지 회복
|
||||
asset_confidence = 1.0
|
||||
if max_files < 10:
|
||||
asset_confidence = 0.5
|
||||
elif max_files < 30:
|
||||
asset_confidence = 0.8
|
||||
|
||||
final_oci = base_oci * stagnation_factor * asset_confidence
|
||||
|
||||
return round(final_oci, 1)
|
||||
|
||||
@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"
|
||||
|
||||
# 실질적인 오늘 날짜를 기준으로 정체일 산출 (사용자 직관성 강화)
|
||||
now_dt = datetime.now()
|
||||
|
||||
if file_val == 0:
|
||||
status = "unknown"
|
||||
elif has_log:
|
||||
if "폴더자동삭제" in log.replace(" ", ""):
|
||||
is_auto = "폴더자동삭제" in log.replace(" ", "")
|
||||
# 2자리 또는 4자리 연도 지원 정규식
|
||||
match = re.search(r'(\d{2,4})\.(\d{2})\.(\d{2})', log)
|
||||
if match:
|
||||
y, m, d = match.groups()
|
||||
# 2자리 연도 보정
|
||||
if len(y) == 2: y = "20" + y
|
||||
log_date = datetime.strptime(f"{y}.{m}.{d}", "%Y.%m.%d")
|
||||
|
||||
# 수집일(target_date_dt)이 아닌 현재 시점(now_dt) 기준으로 차이 계산
|
||||
diff = (now_dt - log_date).days
|
||||
days = diff
|
||||
# 상태 판정은 수집 시점의 target_date_dt를 기준으로 할지 검토 필요하나,
|
||||
# 사용자 요청에 따라 '이상한 계산'을 바로잡기 위해 현재 시점 기준 판정 적용
|
||||
status = "stale" if is_auto or diff > 14 else "warning" if diff > 7 else "active"
|
||||
else:
|
||||
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"
|
||||
|
||||
@@ -104,11 +140,16 @@ class AnalysisService:
|
||||
return []
|
||||
last_date = res_date['last_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
|
||||
LEFT JOIN projects_history h ON h.project_id = m.project_id AND h.crawl_date = (
|
||||
SELECT MAX(crawl_date)
|
||||
FROM projects_history
|
||||
WHERE project_id = m.project_id AND crawl_date <= %s
|
||||
)
|
||||
ORDER BY m.project_id ASC
|
||||
""", (last_date,))
|
||||
projects = cursor.fetchall()
|
||||
@@ -116,48 +157,74 @@ class AnalysisService:
|
||||
if not projects: return []
|
||||
|
||||
results = []
|
||||
total_soi = 0
|
||||
total_avi = 0
|
||||
total_files = 0
|
||||
project_data_list = []
|
||||
|
||||
# 1차 Pass: 개별 AVI 산출 및 전체 합계 집계
|
||||
now_dt = datetime.now()
|
||||
for p in projects:
|
||||
file_count = int(p['file_count']) if p['file_count'] else 0
|
||||
log = p['recent_log']
|
||||
|
||||
# 방치일 계산
|
||||
# 방치일 계산 (현재 시각 기준 동기화)
|
||||
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(" ", "")
|
||||
|
||||
if log and log != "데이터 없음":
|
||||
match = re.search(r'(\d{2,4})\.(\d{2})\.(\d{2})', log)
|
||||
if match:
|
||||
y, m, d = match.groups()
|
||||
if len(y) == 2: y = "20" + y
|
||||
log_date = datetime.strptime(f"{y}.{m}.{d}", "%Y.%m.%d")
|
||||
days_stagnant = (now_dt - log_date).days
|
||||
elif is_auto_delete:
|
||||
days_stagnant = 999
|
||||
|
||||
# AI-Hazard 추론 로직 (Dynamic Lambda)
|
||||
scale_impact = min(0.04, math.log10(file_count + 1) * 0.008) if file_count > 0 else 0
|
||||
ai_lambda = 0.04 + scale_impact
|
||||
|
||||
# 지수 감쇄 적용
|
||||
soi_score = math.exp(-ai_lambda * days_stagnant) * 100
|
||||
avi_score = math.exp(-ai_lambda * days_stagnant) * 100
|
||||
|
||||
# ECV 패널티
|
||||
existence_confidence = 1.0
|
||||
if file_count == 0: existence_confidence = 0.05
|
||||
elif file_count < 10: existence_confidence = 0.4
|
||||
|
||||
# Log Quality Scoring
|
||||
log_quality_factor = 1.0
|
||||
if log and log != "데이터 없음":
|
||||
if any(k in log for k in ["업로드", "수정", "등록", "변환", "파일", "업데이트"]): log_quality_factor = 1.0
|
||||
elif any(k in log for k in ["폴더", "생성", "삭제", "이동"]): log_quality_factor = 0.7
|
||||
elif any(k in log for k in ["참가자", "권한", "추가", "변경", "메일"]): log_quality_factor = 0.4
|
||||
else: log_quality_factor = 0.6
|
||||
# Log Quality Scoring (SWVW 모델 적용)
|
||||
from log_scorer import LogScorer
|
||||
log_quality_factor = LogScorer.get_score(log)
|
||||
|
||||
soi_score = soi_score * existence_confidence * log_quality_factor
|
||||
if is_auto_delete: soi_score = 0.1
|
||||
avi_score = avi_score * existence_confidence * log_quality_factor
|
||||
if is_auto_delete: avi_score = 0.1
|
||||
|
||||
total_avi += avi_score
|
||||
total_files += file_count
|
||||
project_data_list.append({
|
||||
"p": p,
|
||||
"avi_score": avi_score,
|
||||
"file_count": file_count,
|
||||
"days_stagnant": days_stagnant,
|
||||
"is_auto_delete": is_auto_delete,
|
||||
"log_quality": log_quality_factor,
|
||||
"ai_lambda": ai_lambda
|
||||
})
|
||||
|
||||
# 2차 Pass: 평균 기반 가치기여도(WAR) 산출
|
||||
num_projects = len(projects) if projects else 1
|
||||
avg_avi = total_avi / num_projects
|
||||
avg_files = total_files / num_projects
|
||||
|
||||
for item in project_data_list:
|
||||
p = item['p']
|
||||
avi_score = item['avi_score']
|
||||
file_count = item['file_count']
|
||||
|
||||
# [운영 일관성 분석 (OCI)]
|
||||
history_rows = SOIPredictionService.get_historical_soi(cursor, p['project_id'])
|
||||
oci_score = AnalysisService.calculate_operational_consistency(history_rows, days_stagnant)
|
||||
history_rows = SOIPredictionService.get_historical_avi(cursor, p['project_id'])
|
||||
oci_score = AnalysisService.calculate_operational_consistency(history_rows, item['days_stagnant'])
|
||||
|
||||
# 실무 투입 에너지 계산
|
||||
effort_days = 0
|
||||
@@ -167,30 +234,35 @@ class AnalysisService:
|
||||
effort_days += 1
|
||||
|
||||
work_effort_rate = round((effort_days / max(1, len(history_rows))) * 100, 1)
|
||||
total_soi += soi_score
|
||||
|
||||
# VCI 산출
|
||||
REPLACEMENT_LEVEL = 70.0
|
||||
asset_weight = (file_count / 200.0) + 0.5
|
||||
p_war_score = (soi_score - REPLACEMENT_LEVEL) * asset_weight
|
||||
# [VCI 산출 - 로그 기반 상대 가중치 모델 (수정)]
|
||||
# 1. 파일 규모 가중치를 로그(log10) 기반으로 변경하여 선형 폭주 방지
|
||||
# 2. 평균 파일 수 대비 상대적 규모를 반영하되, 최대 가중치를 2.5로 캡핑(Capping)
|
||||
if avg_files > 0:
|
||||
relative_size = math.log10(file_count + 1) / math.log10(avg_files + 1)
|
||||
else:
|
||||
relative_size = 1.0
|
||||
|
||||
asset_weight = min(2.5, max(0.2, relative_size))
|
||||
p_war_score = (avi_score - avg_avi) * asset_weight
|
||||
|
||||
results.append({
|
||||
"project_nm": p['short_nm'] or p['project_nm'],
|
||||
"file_count": file_count,
|
||||
"days_stagnant": days_stagnant,
|
||||
"risk_count": round(p_war_score, 2),
|
||||
"p_war": round(soi_score, 1),
|
||||
"oci_score": oci_score, # 운영 일관성 지수 추가
|
||||
"is_auto_delete": is_auto_delete,
|
||||
"days_stagnant": item['days_stagnant'],
|
||||
"risk_count": round(p_war_score, 2), # WAR 기반 가치기여도 (평균 0)
|
||||
"p_war": round(avi_score, 1),
|
||||
"oci_score": oci_score,
|
||||
"is_auto_delete": item['is_auto_delete'],
|
||||
"master": p['master'],
|
||||
"dept": p['department'],
|
||||
"ai_lambda": round(ai_lambda, 4),
|
||||
"log_quality": log_quality_factor,
|
||||
"ai_lambda": round(item['ai_lambda'], 4),
|
||||
"log_quality": item['log_quality'],
|
||||
"work_effort": work_effort_rate,
|
||||
"avg_info": {
|
||||
"avg_files": 0,
|
||||
"avg_files": round(avg_files, 1),
|
||||
"avg_stagnant": 0,
|
||||
"avg_risk": round(total_soi / len(projects), 1)
|
||||
"avg_risk": round(avg_avi, 1)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
48
analyze_logs_pattern.py
Normal file
48
analyze_logs_pattern.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import pymysql
|
||||
import re
|
||||
from collections import Counter
|
||||
|
||||
def get_db_connection():
|
||||
return pymysql.connect(
|
||||
host='localhost',
|
||||
user='root',
|
||||
password='45278434',
|
||||
database='pm_proto_test',
|
||||
charset='utf8mb4',
|
||||
cursorclass=pymysql.cursors.DictCursor
|
||||
)
|
||||
|
||||
def analyze_logs():
|
||||
conn = get_db_connection()
|
||||
try:
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute("SELECT DISTINCT recent_log FROM projects_history WHERE recent_log IS NOT NULL AND recent_log != ''")
|
||||
rows = cursor.fetchall()
|
||||
|
||||
logs = [r['recent_log'] for r in rows]
|
||||
|
||||
output = []
|
||||
output.append("[Raw Log Samples]")
|
||||
for log in logs[:20]:
|
||||
output.append(f"- {log}")
|
||||
|
||||
patterns = []
|
||||
for log in logs:
|
||||
p = re.sub(r'\d{2,4}\.\d{2}\.\d{2}', '[DATE]', log)
|
||||
p = re.sub(r'\d+', '[NUM]', p)
|
||||
patterns.append(p)
|
||||
|
||||
output.append("\n[Log Patterns Frequency]")
|
||||
pattern_counts = Counter(patterns).most_common(20)
|
||||
for p, count in pattern_counts:
|
||||
output.append(f"({count}) {p}")
|
||||
|
||||
with open("log_analysis_result.txt", "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(output))
|
||||
print("Analysis complete. Result saved to log_analysis_result.txt")
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
analyze_logs()
|
||||
29
check_tables.py
Normal file
29
check_tables.py
Normal file
@@ -0,0 +1,29 @@
|
||||
import pymysql
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
def show_tables():
|
||||
conn = pymysql.connect(
|
||||
host=os.getenv('DB_HOST', 'localhost'),
|
||||
user=os.getenv('DB_USER', 'root'),
|
||||
password=os.getenv('DB_PASSWORD', '45278434'),
|
||||
database='PM_proto_test',
|
||||
charset='utf8mb4',
|
||||
cursorclass=pymysql.cursors.DictCursor
|
||||
)
|
||||
try:
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute("SHOW TABLES")
|
||||
tables = cursor.fetchall()
|
||||
print("Tables in PM_proto_test:")
|
||||
for t in tables:
|
||||
print(f" - {list(t.values())[0]}")
|
||||
except Exception as e:
|
||||
print(f"Error occurred: {e}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
show_tables()
|
||||
29
clear_test_db.py
Normal file
29
clear_test_db.py
Normal file
@@ -0,0 +1,29 @@
|
||||
import pymysql
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
def clear_project_history():
|
||||
conn = pymysql.connect(
|
||||
host=os.getenv('DB_HOST', 'localhost'),
|
||||
user=os.getenv('DB_USER', 'root'),
|
||||
password=os.getenv('DB_PASSWORD', '45278434'),
|
||||
database='PM_proto_test',
|
||||
charset='utf8mb4',
|
||||
cursorclass=pymysql.cursors.DictCursor
|
||||
)
|
||||
try:
|
||||
with conn.cursor() as cursor:
|
||||
# 테이블의 모든 데이터를 삭제
|
||||
print("Cleaning projects_history table in PM_proto_test...")
|
||||
cursor.execute("DELETE FROM projects_history")
|
||||
conn.commit()
|
||||
print("Successfully cleared all records from projects_history.")
|
||||
except Exception as e:
|
||||
print(f"Error occurred: {e}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
clear_project_history()
|
||||
45
clone_db.py
Normal file
45
clone_db.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import pymysql
|
||||
import os
|
||||
|
||||
def clone_database():
|
||||
try:
|
||||
connection = pymysql.connect(
|
||||
host=os.getenv('DB_HOST', 'localhost'),
|
||||
user=os.getenv('DB_USER', 'root'),
|
||||
password=os.getenv('DB_PASSWORD', '45278434'),
|
||||
charset='utf8mb4',
|
||||
cursorclass=pymysql.cursors.DictCursor
|
||||
)
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
# 1. Create test database
|
||||
cursor.execute("CREATE DATABASE IF NOT EXISTS PM_proto_test")
|
||||
print("Database PM_proto_test created or already exists.")
|
||||
|
||||
# 2. Get all tables from source database
|
||||
cursor.execute("SHOW TABLES FROM PM_proto")
|
||||
tables = cursor.fetchall()
|
||||
|
||||
for table_row in tables:
|
||||
table_name = list(table_row.values())[0]
|
||||
|
||||
# 3. Drop existing table in test DB if exists
|
||||
cursor.execute(f"DROP TABLE IF EXISTS PM_proto_test.{table_name}")
|
||||
|
||||
# 4. Clone schema and data
|
||||
# Note: CREATE TABLE ... LIKE doesn't copy data, and CREATE TABLE ... AS SELECT doesn't copy indexes.
|
||||
# So we use LIKE first, then INSERT INTO ... SELECT *
|
||||
cursor.execute(f"CREATE TABLE PM_proto_test.{table_name} LIKE PM_proto.{table_name}")
|
||||
cursor.execute(f"INSERT INTO PM_proto_test.{table_name} SELECT * FROM PM_proto.{table_name}")
|
||||
print(f"Table {table_name} cloned.")
|
||||
|
||||
connection.commit()
|
||||
print("Database cloning completed successfully.")
|
||||
except Exception as e:
|
||||
print(f"Error during database cloning: {e}")
|
||||
finally:
|
||||
if 'connection' in locals():
|
||||
connection.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
clone_database()
|
||||
273
crawler_service_test.py
Normal file
273
crawler_service_test.py
Normal file
@@ -0,0 +1,273 @@
|
||||
import os
|
||||
import re
|
||||
import asyncio
|
||||
import json
|
||||
import traceback
|
||||
import sys
|
||||
import threading
|
||||
import queue
|
||||
import pymysql
|
||||
from datetime import datetime, timedelta
|
||||
from playwright.async_api import async_playwright
|
||||
from dotenv import load_dotenv
|
||||
from sql_queries import CrawlerQueries
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
# 글로벌 중단 제어용 이벤트
|
||||
crawl_stop_event = threading.Event()
|
||||
|
||||
def get_db_connection():
|
||||
"""MySQL 데이터베이스(TEST) 연결을 반환"""
|
||||
return pymysql.connect(
|
||||
host=os.getenv('DB_HOST', 'localhost'),
|
||||
user=os.getenv('DB_USER', 'root'),
|
||||
password=os.getenv('DB_PASSWORD', '45278434'),
|
||||
database='PM_proto_test', # 테스트용 DB 고정
|
||||
charset='utf8mb4',
|
||||
cursorclass=pymysql.cursors.DictCursor
|
||||
)
|
||||
|
||||
def clean_date_string(date_str):
|
||||
"""원본 crawler_service.py와 동일한 날짜 정리 로직"""
|
||||
if not date_str: return ""
|
||||
match = re.search(r'(\d{2})[./-](\d{2})[./-](\d{2})', date_str)
|
||||
if match: return f"20{match.group(1)}.{match.group(2)}.{match.group(3)}"
|
||||
return date_str[:10].replace("-", ".")
|
||||
|
||||
def parse_log_id(log_id):
|
||||
"""원본 crawler_service.py와 동일한 로그 ID 파싱 로직"""
|
||||
if not log_id or "_" not in log_id: return log_id
|
||||
try:
|
||||
parts = log_id.split('_')
|
||||
if len(parts) >= 4:
|
||||
date_part = clean_date_string(parts[1])
|
||||
activity = parts[3].strip()
|
||||
activity = re.sub(r'\(.*?\)', '', activity).strip()
|
||||
return f"{date_part}, {activity}"
|
||||
except: pass
|
||||
return log_id
|
||||
|
||||
def crawler_thread_worker(msg_queue, user_id, password):
|
||||
crawl_stop_event.clear()
|
||||
if sys.platform == 'win32':
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
async def run():
|
||||
async with async_playwright() as p:
|
||||
browser = None
|
||||
try:
|
||||
msg_queue.put(json.dumps({'type': 'log', 'message': '[TEST] 원본 수집 방식 복구 및 추론 엔진 가동...'}))
|
||||
browser = await p.chromium.launch(headless=True, args=[
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-blink-features=AutomationControlled"
|
||||
])
|
||||
context = await browser.new_context(
|
||||
viewport={'width': 1600, 'height': 900},
|
||||
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
captured_data = {"tree": None, "_is_root_archive": False, "project_list": [], "last_project_data": None}
|
||||
|
||||
async def global_interceptor(response):
|
||||
url = response.url
|
||||
try:
|
||||
if "getAllList" in url:
|
||||
data = await response.json()
|
||||
captured_data["project_list"] = data.get("data", [])
|
||||
elif "getTreeObject" in url:
|
||||
# [복구] 원본과 100% 동일한 루트 판정 로직
|
||||
is_root = False
|
||||
if "params[resourcePath]=" in url:
|
||||
path_val = url.split("params[resourcePath]=")[1].split("&")[0]
|
||||
if path_val in ["%2F", "/"]: is_root = True
|
||||
if is_root:
|
||||
captured_data["tree"] = await response.json()
|
||||
captured_data["_is_root_archive"] = True
|
||||
elif "getData" in url and "overview" in url:
|
||||
captured_data["last_project_data"] = await response.json()
|
||||
except: pass
|
||||
|
||||
context.on("response", global_interceptor)
|
||||
page = await context.new_page()
|
||||
await page.goto("https://overseas.projectmastercloud.com/dashboard", wait_until="domcontentloaded")
|
||||
|
||||
if await page.locator("#login-by-id").is_visible(timeout=10000):
|
||||
await page.click("#login-by-id"); await page.fill("#user_id", user_id); await page.fill("#user_pw", password); await page.click("#login-btn")
|
||||
|
||||
await page.wait_for_selector("h4.list__contents_aria_group_body_list_item_label", timeout=60000)
|
||||
await asyncio.sleep(2)
|
||||
|
||||
project_names = list(dict.fromkeys([n.strip() for n in await page.locator("h4.list__contents_aria_group_body_list_item_label").all_inner_texts() if n.strip()]))
|
||||
count = len(project_names)
|
||||
|
||||
for i, project_name in enumerate(project_names):
|
||||
if crawl_stop_event.is_set(): break
|
||||
msg_queue.put(json.dumps({'type': 'log', 'message': f'[TEST] [{i+1}/{count}] {project_name} 수집'}))
|
||||
p_match = next((p for p in captured_data["project_list"] if p.get('project_nm') == project_name or p.get('short_nm', '').strip() == project_name), None)
|
||||
current_p_id = p_match.get('project_id') if p_match else None
|
||||
|
||||
try:
|
||||
# 1. 프로젝트 진입
|
||||
target_el = page.locator(f"h4.list__contents_aria_group_body_list_item_label:has-text('{project_name}')").first
|
||||
await target_el.scroll_into_view_if_needed()
|
||||
box = await target_el.bounding_box()
|
||||
if box: await page.mouse.click(box['x'] + 5, box['y'] + 5)
|
||||
else: await target_el.click(force=True)
|
||||
await page.wait_for_selector("text=활동로그", timeout=30000)
|
||||
|
||||
# 2. [복구] 최신 파일 수 실측 (원본의 수동 Fetch 방식 그대로)
|
||||
captured_data["tree"] = None; captured_data["_is_root_archive"] = False
|
||||
await page.evaluate("""() => {
|
||||
const baseUrl = window.location.origin + window.location.pathname.split('/').slice(0, 2).join('/');
|
||||
fetch(`${baseUrl}/archive/getTreeObject?params[storageType]=CLOUD¶ms[resourcePath]=/`);
|
||||
}""")
|
||||
for _ in range(30):
|
||||
if captured_data["_is_root_archive"]: break
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
actual_count = 0
|
||||
if captured_data["tree"]:
|
||||
tree_data = captured_data["tree"]
|
||||
if isinstance(tree_data, list) and len(tree_data) > 0: tree_data = tree_data[0]
|
||||
if isinstance(tree_data, dict):
|
||||
tree = tree_data.get('currentTreeObject', tree_data)
|
||||
if isinstance(tree, dict):
|
||||
# 원본 파일 수 합산 로직
|
||||
total = len(tree.get("file", {}))
|
||||
folders = tree.get("folder", {})
|
||||
if isinstance(folders, dict):
|
||||
for f in folders.values(): total += int(f.get("filesCount", 0))
|
||||
actual_count = total
|
||||
|
||||
# 3. 활동로그 전수 수집 (하이브리드 방식: 최상단 우선 확보 + 전수 스크롤)
|
||||
all_logs = []
|
||||
await page.get_by_text("활동로그").first.click()
|
||||
if await page.wait_for_selector("article.archive-modal", timeout=10000):
|
||||
# 날짜 필터 적용 (2020-01-01)
|
||||
inputs = await page.locator("article.archive-modal input").all()
|
||||
for inp in inputs:
|
||||
if (await inp.get_attribute("type")) == "date": await inp.fill("2020-01-01"); break
|
||||
|
||||
apply_btn = page.locator("article.archive-modal").get_by_text("적용").first
|
||||
if await apply_btn.is_visible():
|
||||
await apply_btn.click()
|
||||
# [핵심] 첫 번째 로그가 나타날 때까지 명시적 대기 (최대 10초)
|
||||
try:
|
||||
await page.wait_for_selector("article.archive-modal div[id*='_']", timeout=10000)
|
||||
except: pass
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# (1) 최상단 로그 즉시 확보 (안전장치)
|
||||
first_log_el = await page.locator("article.archive-modal div[id*='_']").first.get_attribute("id")
|
||||
if first_log_el:
|
||||
first_log_text = parse_log_id(first_log_el)
|
||||
if ", " in first_log_text:
|
||||
d, a = first_log_text.split(", ", 1)
|
||||
all_logs.append({'date': d, 'activity': a})
|
||||
|
||||
# (2) 전수 수집을 위한 무한 스크롤 및 지정된 클래스 내 ID 수집
|
||||
last_count = len(all_logs)
|
||||
for _ in range(20):
|
||||
# 스크롤 수행 (사용자가 지정한 log-body 클래스 기준)
|
||||
await page.evaluate("""() => {
|
||||
const body = document.querySelector('.log-item-wrap.log-body.scrollbar.scroll-container') ||
|
||||
document.querySelector('article.archive-modal .modal-body') ||
|
||||
document.querySelector('article.archive-modal');
|
||||
if (body) body.scrollTop = body.scrollHeight;
|
||||
}""")
|
||||
await asyncio.sleep(1.5)
|
||||
|
||||
# 사용자 지정 클래스 내의 모든 div ID 수집
|
||||
# .log-item-wrap.log-body.scrollbar.scroll-container 내부의 div들을 타겟팅
|
||||
selector = ".log-item-wrap.log-body.scrollbar.scroll-container div"
|
||||
current_elements = await page.locator(selector).all()
|
||||
|
||||
# 만약 지정된 클래스로 검색되지 않을 경우 기존 div[id*='_']를 백업으로 사용
|
||||
if not current_elements:
|
||||
current_elements = await page.locator("article.archive-modal div[id*='_']").all()
|
||||
|
||||
seen_ids = {f"{log['date']}, {log['activity']}" for log in all_logs}
|
||||
for el in current_elements:
|
||||
log_id = await el.get_attribute("id")
|
||||
if not log_id: continue
|
||||
|
||||
log_text = parse_log_id(log_id)
|
||||
if ", " in log_text and log_text not in seen_ids:
|
||||
d, a = log_text.split(", ", 1)
|
||||
all_logs.append({'date': d, 'activity': a})
|
||||
seen_ids.add(log_text)
|
||||
|
||||
if len(all_logs) == last_count: break
|
||||
last_count = len(all_logs)
|
||||
|
||||
if not all_logs:
|
||||
msg_queue.put(json.dumps({'type': 'log', 'message': f' - [주의] {project_name}: 수집된 로그가 없습니다.'}))
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
|
||||
# 4. 파일 수 추론 (전수 보존 모드)
|
||||
history_map = {}
|
||||
curr_calc_count = actual_count
|
||||
|
||||
if all_logs:
|
||||
# 오늘 날짜 강제 주입 대신 수집된 로그의 실제 날짜 사용
|
||||
for log in all_logs:
|
||||
d_db = log['date'].replace(".", "-")
|
||||
act = log['activity']
|
||||
if d_db not in history_map:
|
||||
history_map[d_db] = {"log": act, "count": curr_calc_count}
|
||||
|
||||
if "업로드" in act: curr_calc_count -= 1
|
||||
elif "삭제" in act: curr_calc_count += 1
|
||||
if curr_calc_count < 0: curr_calc_count = 0
|
||||
history_map[d_db]["count"] = curr_calc_count
|
||||
else:
|
||||
# 로그가 전혀 없을 경우에만 기본값 생성
|
||||
today_str = datetime.now().strftime("%Y-%m-%d")
|
||||
history_map[today_str] = {"log": "기존 상태 유지 (활동 없음)", "count": actual_count}
|
||||
|
||||
# 5. DB 저장
|
||||
if current_p_id:
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
for date_key, data in history_map.items():
|
||||
cursor.execute(CrawlerQueries.UPSERT_HISTORY_WITH_DATE,
|
||||
(current_p_id, date_key, f"{date_key.replace('-', '.')}, {data['log']}", data['count']))
|
||||
conn.commit()
|
||||
msg_queue.put(json.dumps({'type': 'log', 'message': f' - [성공] 실측 {actual_count}개 기준 시계열 적재 완료'}))
|
||||
|
||||
await page.goto("https://overseas.projectmastercloud.com/dashboard", wait_until="domcontentloaded")
|
||||
|
||||
except Exception as e:
|
||||
msg_queue.put(json.dumps({'type': 'log', 'message': f' - {project_name} 에러: {str(e)}'}))
|
||||
await page.goto("https://overseas.projectmastercloud.com/dashboard")
|
||||
|
||||
msg_queue.put(json.dumps({'type': 'done', 'data': []}))
|
||||
|
||||
except Exception as e:
|
||||
msg_queue.put(json.dumps({'type': 'log', 'message': f'치명적 오류: {str(e)}'}))
|
||||
finally:
|
||||
if browser: await browser.close()
|
||||
msg_queue.put(None)
|
||||
|
||||
loop.run_until_complete(run())
|
||||
loop.close()
|
||||
|
||||
async def run_crawler_service():
|
||||
msg_queue = queue.Queue()
|
||||
thread = threading.Thread(target=crawler_thread_worker, args=(msg_queue, os.getenv("PM_USER_ID"), os.getenv("PM_PASSWORD")))
|
||||
thread.start()
|
||||
while True:
|
||||
try:
|
||||
msg = await asyncio.to_thread(msg_queue.get, timeout=1.0)
|
||||
if msg is None: break
|
||||
yield f"data: {msg}\n\n"
|
||||
except queue.Empty:
|
||||
if not thread.is_alive(): break
|
||||
await asyncio.sleep(0.1)
|
||||
thread.join()
|
||||
17
docker-compose.yml
Normal file
17
docker-compose.yml
Normal file
@@ -0,0 +1,17 @@
|
||||
services:
|
||||
web:
|
||||
# 현재 폴더의 Dockerfile을 사용하여 빌드
|
||||
build: .
|
||||
# 컨테이너 이름 설정
|
||||
container_name: aicode-server
|
||||
# 포트 포워딩 (호스트 8000 -> 컨테이너 8000)
|
||||
ports:
|
||||
- "8000:8000"
|
||||
# 소스 코드 수정 시 실시간 반영 (볼륨 마운트)
|
||||
volumes:
|
||||
- .:/app
|
||||
# 환경 변수 설정
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
# 컨테이너 종료 시 자동 재시작
|
||||
restart: always
|
||||
@@ -26,7 +26,7 @@ async function loadProjectAnalysisData() {
|
||||
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}% (운영 표준 70.0% 대비)`;
|
||||
if (infoEl) infoEl.textContent = `* 시스템 종합 운영 활력(AVI): ${avg.avg_risk}% (평균 관리 수준)`;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("분석 데이터 로딩 실패:", e);
|
||||
@@ -326,12 +326,17 @@ function renderVitalityLeaderboard(data) {
|
||||
|
||||
<div style="margin-top: 20px; padding-top: 15px; border-top: 2px solid #1e5149; text-align: right; display: flex; justify-content: space-between; align-items: center;">
|
||||
<div style="text-align: left; max-width: 70%;">
|
||||
<div style="font-size: 13px; font-weight: 800; color: ${vci >= 0 ? '#059669' : '#dc2626'}; margin-bottom: 4px;">
|
||||
가치 기여도 (VCI) 진단: ${vci >= 0 ? '+' : ''}${vci.toFixed(2)}
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 4px;">
|
||||
<div style="font-size: 13px; font-weight: 800; color: ${vci >= 0 ? '#059669' : '#dc2626'};">
|
||||
가치 기여도 (VCI) 진단: ${vci >= 0 ? '+' : ''}${vci.toFixed(2)}
|
||||
</div>
|
||||
<div style="font-size: 10px; color: #94a3b8; background: #f8fafc; padding: 2px 6px; border-radius: 4px; border: 1px solid #e2e8f0;">
|
||||
조직 평균 자산: ${p.avg_info.avg_files}개
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size: 11px; color: #64748b; line-height: 1.5;">
|
||||
현재 프로젝트는 운영 표준(AVI 70%) 대비 <b>${Math.abs(avi - 70).toFixed(1)}%p ${avi >= 70 ? '상회' : '하회'}</b>하고 있으며,
|
||||
<b>${p.file_count}개</b>의 자산 규모에 따른 <b>${((p.file_count / 200) + 0.5).toFixed(2)}배</b>의 가중치가 적용되었습니다.
|
||||
현재 프로젝트는 <b>포트폴리오 평균 관리 수준</b> 대비 <b>${Math.abs(vci / Math.max(0.2, p.file_count / p.avg_info.avg_files)).toFixed(1)}%p ${vci >= 0 ? '상회' : '하회'}</b>하고 있으며,
|
||||
<b>${p.file_count}개</b>의 자산 규모에 따른 <b>${Math.max(0.2, p.file_count / p.avg_info.avg_files).toFixed(2)}배</b>의 상대 가중치가 적용되었습니다.
|
||||
이는 시스템 전체 관점에서 <b>${vci >= 0 ? '순자산 가치를 증대' : '잠재적 기회비용을 손실'}</b>시키고 있는 상태로 분석됩니다.
|
||||
</div>
|
||||
</div>
|
||||
@@ -394,33 +399,50 @@ function openAnalysisModal(type) {
|
||||
const body = document.getElementById('modalBody');
|
||||
|
||||
if (type === 'avi') {
|
||||
title.innerText = '운영 활력 지수 (AVI) 등급 가이드';
|
||||
title.innerText = '운영 활력 지수 (AVI) 분석 가이드';
|
||||
body.innerHTML = `
|
||||
<div class="formula-box" style="margin-bottom:15px;">AVI = exp(-λ × days) × Quality × 100</div>
|
||||
<p style="font-size:13px; color:#64748b; margin-bottom:15px;">자산의 가동 상태와 생존율을 나타내는 지표입니다.</p>
|
||||
<table class="data-table" style="font-size:12px;">
|
||||
<div class="formula-box" style="margin-bottom:15px; background:#f1f5f9; padding:12px; border-radius:8px; font-family:'Consolas', monospace; font-weight:700; text-align:center;">
|
||||
AVI = exp(-λ × Stagnant Days) × Quality × 100
|
||||
</div>
|
||||
<div style="margin-bottom:15px; font-size:13px; line-height:1.6; color:#334155;">
|
||||
<p style="margin-bottom:8px;"><b>운영 활력 지수(AVI)</b>는 프로젝트가 현재 얼마나 건강하게 가동되고 있는지를 나타내는 '디지털 생존 지표'입니다.</p>
|
||||
<ul style="padding-left:18px; margin-bottom:10px;">
|
||||
<li style="margin-bottom:4px;"><b>지수 감쇄(Exponential Decay)</b>: 마지막 활동 이후 정체 기간이 길어질수록 자산의 최신성과 가치는 기하급수적으로 하락합니다.</li>
|
||||
<li style="margin-bottom:4px;"><b>위험 가속 계수(λ)</b>: 자산 규모(파일 수)가 클수록 관리 부재 시의 정보 망실 위험이 크다고 판단하여, 더 가파른 감쇄 곡선을 적용합니다.</li>
|
||||
<li style="margin-bottom:4px;"><b>활동 품질(Quality Factor)</b>: 단순 행정 로그(권한 변경 등)보다 실무 성과물(파일 업로드 등)이 발생했을 때 지수 복원력을 더 높게 부여합니다.</li>
|
||||
</ul>
|
||||
<p style="margin:0; font-weight:700; color:#1e5149;">※ 70% 미만 하락 시, 해당 프로젝트의 데이터 노후화 및 관리 방치 위험이 시작된 것으로 간주합니다.</p>
|
||||
</div>
|
||||
<table class="data-table" style="font-size:12px; width:100%;">
|
||||
<thead><tr style="background:#f8fafc;"><th>지수 (AVI)</th><th>등급</th><th>운영 상태</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>90%↑</td><td style="font-weight:900; color:#059669;">Live</td><td>실시간 성과물이 도출되는 최상급 가동</td></tr>
|
||||
<tr><td>70~90%</td><td style="font-weight:900; color:#1e5149;">Stable</td><td>주기적 업데이트가 이뤄지는 표준 안정</td></tr>
|
||||
<tr><td>30~70%</td><td style="font-weight:900; color:#f59e0b;">Idle</td><td>관리가 필요한 유휴/정체 상태</td></tr>
|
||||
<tr><td>10~30%</td><td style="font-weight:900; color:#dc2626;">Risk</td><td>자산 가치 소멸 직전의 위험 상태</td></tr>
|
||||
<tr><td>10%↓</td><td style="font-weight:900; color:#64748b;">Frozen</td><td>운영이 중단된 사망/방치 상태</td></tr>
|
||||
<tr><td class="font-bold">90%↑</td><td style="font-weight:900; color:#059669;">Live</td><td>실시간 성과물이 도출되는 최상급 가동 상태</td></tr>
|
||||
<tr><td class="font-bold">70~90%</td><td style="font-weight:900; color:#1e5149;">Stable</td><td>주기적 업데이트가 이뤄지는 표준 안정 상태</td></tr>
|
||||
<tr><td class="font-bold">30~70%</td><td style="font-weight:900; color:#f59e0b;">Idle</td><td>활력이 저하되어 관리가 필요한 정체 상태</td></tr>
|
||||
<tr><td class="font-bold">10~30%</td><td style="font-weight:900; color:#dc2626;">Risk</td><td>데이터 노후화 및 자산 가치 소멸 위험 상태</td></tr>
|
||||
<tr><td class="font-bold">10%↓</td><td style="font-weight:900; color:#64748b;">Frozen</td><td>운영이 중단된 사망/방치 상태</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
|
||||
} else if (type === 'vci') {
|
||||
title.innerText = '자산 가치 기여도 (VCI) 등급 가이드';
|
||||
title.innerText = '자산 가치 기여도 (VCI) 분석 가이드';
|
||||
body.innerHTML = `
|
||||
<div class="formula-box" style="margin-bottom:15px;">VCI = (AVI - 70.0) × (Files / 200 + 0.5)</div>
|
||||
<p style="font-size:13px; color:#64748b; margin-bottom:15px;">운영 표준(AVI 70%) 대비 자산 가치 기여도에 따른 프로젝트 위상 분류입니다.</p>
|
||||
<table class="data-table" style="font-size:12px;">
|
||||
<p style="margin-bottom:12px; font-size:13.5px;">VCI는 야구의 <b>WAR(Wins Above Replacement)</b> 개념을 도입하여, 개별 프로젝트가 전체 포트폴리오 평균 대비 얼마나 조직의 가치에 기여하는지 산출한 지표입니다.</p>
|
||||
<div class="formula-box" style="margin-bottom:15px; background:#f1f5f9; padding:12px; border-radius:8px; font-family:'Consolas', monospace; font-weight:700; text-align:center;">
|
||||
VCI = (현재 AVI - 전체 평균 AVI) × (파일 규모 가중치)
|
||||
</div>
|
||||
<p style="margin-bottom:15px; font-size:13px; line-height:1.6;">
|
||||
• <b>0.0 (평균)</b>: 우리 조직의 평균적인 관리 수준을 유지 중인 상태<br>
|
||||
• <b>(+) 점수</b>: 평균 이상의 활력으로 조직의 디지털 자산 가치를 증대시킴<br>
|
||||
• <b>(-) 점수</b>: 평균 이하의 방치로 인해 잠재적 기회비용 손실 발생 중
|
||||
</p>
|
||||
<table class="data-table" style="font-size:12px; width:100%;">
|
||||
<thead><tr style="background:#f8fafc;"><th>점수 (VCI)</th><th>등급</th><th>운영 의미</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>+10.0↑</td><td style="font-weight:900; color:#6366f1;">Masterpiece</td><td>시스템 가치를 견인하는 최우량 핵심 자산</td></tr>
|
||||
<tr><td>+2.0 ~ +10.0</td><td style="font-weight:900; color:#059669;">Blue Chip</td><td>꾸준한 활력으로 가치를 창출하는 우량 자산</td></tr>
|
||||
<tr><td>-2.0 ~ +2.0</td><td style="font-weight:900; color:#475569;">Steady</td><td>표준 수준의 운영을 유지 중인 안정 자산</td></tr>
|
||||
<tr><td>-10.0 ~ -2.0</td><td style="font-weight:900; color:#f59e0b;">Underperform</td><td>규모 대비 활력 부족으로 가치 하락 중인 자산</td></tr>
|
||||
<tr><td>-2.0 ~ +2.0</td><td style="font-weight:900; color:#475569;">Steady</td><td>평균 수준의 운영을 유지 중인 안정 자산</td></tr>
|
||||
<tr><td>-10.0 ~ -2.0</td><td style="font-weight:900; color:#f59e0b;">Underperform</td><td>평균 대비 활력 부족으로 가치 하락 중인 자산</td></tr>
|
||||
<tr><td>-10.0↓</td><td style="font-weight:900; color:#dc2626;">Liability</td><td>가치를 훼손 중인 고위험 방치 자산</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
485
js/analysis_test.js
Normal file
485
js/analysis_test.js
Normal file
@@ -0,0 +1,485 @@
|
||||
/**
|
||||
* Project Master Analysis JS (TEST VERSION)
|
||||
* AVI (Activity Vitality Index) & VCI (Value Contribution Index) 분석 엔진
|
||||
* OCI (Operational Consistency Index) 통합 버전
|
||||
*/
|
||||
|
||||
// Chart.js 플러그인 전역 등록
|
||||
if (typeof ChartDataLabels !== 'undefined') {
|
||||
Chart.register(ChartDataLabels);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
console.log("Business Analysis Engine (TEST) initialized...");
|
||||
loadProjectAnalysisData();
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
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 = `* 시스템 종합 운영 활력(AVI): ${avg.avg_risk}% (평균 관리 수준) [TEST MODE]`;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("분석 데이터 로딩 실패:", e);
|
||||
}
|
||||
}
|
||||
|
||||
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 getVciGrade(vci) {
|
||||
if (vci >= 10) return { label: 'Masterpiece', class: 'grade-mvp', desc: '시스템 가치를 견인하는 최우량 핵심 자산' };
|
||||
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)
|
||||
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,
|
||||
layout: { padding: 15 },
|
||||
plugins: {
|
||||
legend: { position: 'right', labels: { boxWidth: 10, font: { size: 11, weight: '700' }, usePointStyle: true } },
|
||||
datalabels: { display: false }
|
||||
},
|
||||
cutout: '65%',
|
||||
onClick: (e, elements) => {
|
||||
if (elements.length > 0) {
|
||||
const idx = elements[0].index;
|
||||
openProjectListModal(['정상 운영', '관리 주의', '위험 노출', '사망'][idx], stats[['active', 'warning', 'danger', 'dead'][idx]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (err) { console.error("도넛 차트 에러:", err); }
|
||||
|
||||
// 2. 전략적 자산 매트릭스 (Scatter) - 정밀 복구
|
||||
try {
|
||||
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();
|
||||
|
||||
window.myVitalityChart = new Chart(vitalityCtx, {
|
||||
type: 'scatter',
|
||||
data: {
|
||||
datasets: [{
|
||||
data: scatterData,
|
||||
backgroundColor: (ctx) => {
|
||||
const p = ctx.raw;
|
||||
if (!p) return '#94a3b8';
|
||||
if (p.x >= 250 && p.y >= 50) return '#1E5149';
|
||||
if (p.x < 250 && p.y >= 50) return '#22c55e';
|
||||
if (p.x < 250 && p.y < 50) return '#94a3b8';
|
||||
return '#ef4444';
|
||||
},
|
||||
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: 45, left: 10, bottom: 10 } },
|
||||
scales: {
|
||||
x: {
|
||||
type: 'linear', min: 0, max: 500,
|
||||
title: { display: true, text: '자산 규모 (파일 수)', font: { size: 11, weight: '700' } },
|
||||
grid: { display: false }
|
||||
},
|
||||
y: {
|
||||
min: 0, max: 100,
|
||||
title: { display: true, text: '운영 활력 (AVI %)', font: { size: 11, weight: '700' } },
|
||||
grid: { display: false }
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
datalabels: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.8)',
|
||||
borderRadius: 4, padding: 4,
|
||||
font: { size: 10, weight: '800' },
|
||||
formatter: (v) => v ? v.label : '',
|
||||
display: (ctx) => ctx.raw && ctx.raw.isVip,
|
||||
clip: false
|
||||
},
|
||||
tooltip: {
|
||||
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("전략 매트릭스 에러:", err); }
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="table-scroll-wrapper">
|
||||
<table class="data-table p-war-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 250px;">프로젝트명</th>
|
||||
<th>파일 수</th>
|
||||
<th>정체 일수</th>
|
||||
<th>상태 판정</th>
|
||||
<th style="text-align:right;">가치 기여 (VCI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('vci')">?</button></th>
|
||||
<th>운영 활력 (AVI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('avi')">?</button></th>
|
||||
<th style="text-align:center;">업무 집중도 <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('focus')">?</button></th>
|
||||
<th>운영 일관성 (OCI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('oci')">?</button></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${sortedData.map((p, idx) => {
|
||||
const status = getStatusInfo(p.p_war, p.is_auto_delete);
|
||||
const avi = p.p_war;
|
||||
const vci = p.risk_count;
|
||||
const oci = p.oci_score || 0;
|
||||
const rowId = `project-${idx}`;
|
||||
const grade = getVciGrade(vci);
|
||||
|
||||
let rhythmLabel = oci >= 80 ? "정기적" : oci >= 50 ? "안정적" : oci >= 20 ? "간헐적" : "불규칙";
|
||||
let rhythmColor = oci >= 80 ? "#059669" : oci >= 50 ? "#1e5149" : oci >= 20 ? "#f59e0b" : "#dc2626";
|
||||
|
||||
// 존재 신뢰도 패널티 (ECV) 상세 설명 복구
|
||||
let ecvText = "100% (데이터 실체 검증)";
|
||||
let ecvClass = "highlight-val";
|
||||
let ecvDesc = `현재 ${p.file_count}개의 유효 성과물이 확인됩니다. 시스템적으로 실체가 완벽히 존재하는 상태입니다.`;
|
||||
if (p.file_count === 0) {
|
||||
ecvText = "5% (유령 프로젝트 판명)";
|
||||
ecvClass = "highlight-penalty";
|
||||
ecvDesc = "데이터가 전무하여 프로젝트의 디지털 실체가 없습니다. 모든 분석에서 최하위 패널티가 적용됩니다.";
|
||||
} else if (p.file_count < 10) {
|
||||
ecvText = "40% (형식적 껍데기 판명)";
|
||||
ecvClass = "highlight-penalty";
|
||||
ecvDesc = "최소 수준의 문서만 존재하며, 실질적인 운영 가치를 인정하기 어려운 소규모 상태입니다.";
|
||||
}
|
||||
|
||||
// 활동 품질 텍스트 복구
|
||||
const qualityLabel = p.log_quality >= 1.0 ? '성과물 중심의 <b>실무 활동</b>' : p.log_quality >= 0.7 ? '구조 관리를 위한 <b>시스템 활동</b>' : '단순 행정 기반의 <b>형식 활동</b>';
|
||||
const qualityDetail = p.log_quality >= 1.0 ? '최근 로그에서 파일 업로드/수정 등 가치 증분 활동이 명확히 포착되었습니다.' : p.log_quality >= 0.7 ? '폴더 생성/이동 등 구조적 관리는 이뤄지고 있으나, 직접적 결과물 생산은 부족합니다.' : '메일 확인, 권한 변경 등 시스템 유지성 활동 위주로 파악되어 품질 가중치가 낮게 적용되었습니다.';
|
||||
|
||||
return `
|
||||
<tr class="project-row ${status.key === 'danger' ? 'row-danger' : status.key === 'warning' ? 'row-warning' : ''}" onclick="toggleProjectDetail('${rowId}')">
|
||||
<td class="font-bold">${p.project_nm}</td>
|
||||
<td>${p.file_count.toLocaleString()}개</td>
|
||||
<td>${p.days_stagnant}일</td>
|
||||
<td><span class="${status.class}">${status.label}</span></td>
|
||||
<td style="text-align:right; font-weight:800; color:${vci >= 0 ? '#059669' : '#dc2626'};">
|
||||
${vci > 0 ? '+' : ''}${vci.toFixed(1)}
|
||||
</td>
|
||||
<td class="p-war-value ${avi >= 70 ? 'text-plus' : 'text-minus'}">${avi.toFixed(1)}%</td>
|
||||
<td style="text-align:center;">
|
||||
<div style="display:flex; flex-direction:column; align-items:center; gap:4px;">
|
||||
<span style="font-weight:800; font-size:12px; color:${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};">${p.work_effort}%</span>
|
||||
<div style="width:40px; height:4px; background:#f1f5f9; border-radius:2px; overflow:hidden;">
|
||||
<div style="width:${p.work_effort}%; height:100%; background:${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};"></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td style="text-align:center;">
|
||||
<div style="display:flex; align-items:center; justify-content:center; gap:8px;">
|
||||
<span style="font-weight:800; font-size:13px; color:${rhythmColor};">${oci}%</span>
|
||||
<span style="font-size:10px; padding:2px 6px; border-radius:10px; background:${rhythmColor}15; color:${rhythmColor}; border:1px solid ${rhythmColor}30;">${rhythmLabel}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="detail-${rowId}" class="detail-row">
|
||||
<td colspan="8">
|
||||
<div class="detail-container">
|
||||
<div class="formula-explanation-card">
|
||||
<div class="formula-header">⚙️ AI 위험 적응형 모델(AAS) 기반 인과관계 분석</div>
|
||||
|
||||
<div style="display: flex; gap: 20px; margin-bottom: 20px;">
|
||||
<div class="work-effort-section" style="flex: 1; margin-bottom: 0;">
|
||||
<div class="work-effort-header">
|
||||
<span style="font-size: 13px; font-weight: 800; color: #1e5149;">📊 실질 업무 집중도 (Job Focus)</span>
|
||||
<span style="font-size: 14px; font-weight: 800; color: ${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};">${p.work_effort}%</span>
|
||||
</div>
|
||||
<div class="work-effort-bar-bg"><div style="width: ${p.work_effort}%; height: 100%; background: ${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};"></div></div>
|
||||
<div style="font-size: 11px; color: #64748b; line-height: 1.5;">최근 30회 수집 이력 중 단순 로그 갱신이 아닌 <b>실제 성과물의 변동</b>이 포착된 날의 비율입니다. 이는 운영의 '진정성'을 보여주는 핵심 지표입니다.</div>
|
||||
</div>
|
||||
<div style="flex: 1; background: #f8fafc; border-radius: 8px; padding: 16px; border: 1px solid #e2e8f0; display: flex; align-items: center; gap: 15px;">
|
||||
<div style="text-align: center;">
|
||||
<div style="font-size: 10px; color: #64748b; font-weight: 700; margin-bottom: 2px;">VCI GRADE</div>
|
||||
<div class="grade-badge ${grade.class}" style="padding: 4px 12px; border-radius: 6px; font-weight: 900; font-size: 14px; display: inline-block;">${grade.label}</div>
|
||||
</div>
|
||||
<div style="font-size: 12px; color: #475569; line-height: 1.4; font-weight: 600;">${grade.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="formula-steps-grid">
|
||||
<div class="formula-step">
|
||||
<div class="step-num">1</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">동적 위험 계수(λ) 산출</div>
|
||||
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">프로젝트 규모가 클수록 정보 망실 시의 충격을 반영하여 데이터의 하락 속도가 가속됩니다. 현재 <b>λ=${p.ai_lambda.toFixed(4)}</b>는 귀하의 자산 규모가 정밀하게 투영된 결과입니다.</div>
|
||||
<div class="math-logic">Dynamic λ = <span class="highlight-var">${p.ai_lambda.toFixed(4)}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="formula-step">
|
||||
<div class="step-num">4</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">활동 품질 검증 (Quality)</div>
|
||||
<div class="step-desc" style="font-size:11px; margin-bottom:5px;">최근 로그 분석 결과 <b>${qualityLabel}</b>으로 판명되었습니다. ${qualityDetail}</div>
|
||||
<div class="math-logic">Quality Factor = <span class="${p.log_quality < 0.7 ? 'highlight-penalty' : 'highlight-val'}">${(p.log_quality * 100).toFixed(0)}%</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="formula-step">
|
||||
<div class="step-num">2</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">방치 시간 감쇄 적용</div>
|
||||
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">마지막 유효 활동 이후 <b>${p.days_stagnant}일</b>간의 누적 정체 시간은 지수 감쇄 곡선을 따라 데이터의 최신성과 가치를 상쇄시켰습니다.</div>
|
||||
<div class="math-logic">Decay Result = <span class="highlight-val">${((avi / (p.file_count === 0 ? 0.05 : p.file_count < 10 ? 0.4 : 1) / p.log_quality) || 0).toFixed(1)}%</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="formula-step">
|
||||
<div class="step-num">3</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">존재 진정성 (ECV)</div>
|
||||
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">${ecvDesc} 파일 수 자체가 분석의 데이터 진정성을 보정하는 핵심 팩터로 작용합니다.</div>
|
||||
<div class="math-logic">Entity Factor = <span class="${ecvClass}">${ecvText}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 20px; padding-top: 15px; border-top: 2px solid #1e5149; text-align: right; display: flex; justify-content: space-between; align-items: center;">
|
||||
<div style="text-align: left; max-width: 70%;">
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 4px;">
|
||||
<div style="font-size: 13px; font-weight: 800; color: ${vci >= 0 ? '#059669' : '#dc2626'};">
|
||||
가치 기여도 (VCI) 진단: ${vci >= 0 ? '+' : ''}${vci.toFixed(2)}
|
||||
</div>
|
||||
<div style="font-size: 10px; color: #94a3b8; background: #f8fafc; padding: 2px 6px; border-radius: 4px; border: 1px solid #e2e8f0;">
|
||||
조직 평균 자산: ${p.avg_info.avg_files}개
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size: 11px; color: #64748b; line-height: 1.5;">
|
||||
현재 프로젝트는 <b>포트폴리오 평균 관리 수준</b> 대비 <b>${Math.abs(vci / Math.max(0.2, p.file_count / p.avg_info.avg_files)).toFixed(1)}%p ${vci >= 0 ? '상회' : '하회'}</b>하고 있으며,
|
||||
<b>${p.file_count}개</b>의 자산 규모에 따른 <b>${Math.max(0.2, p.file_count / p.avg_info.avg_files).toFixed(2)}배</b>의 상대 가중치가 적용되었습니다.
|
||||
이는 시스템 전체 관점에서 <b>${vci >= 0 ? '순자산 가치를 증대' : '잠재적 기회비용을 손실'}</b>시키고 있는 상태로 분석됩니다.
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span style="font-size: 13px; color: #64748b; font-weight: 700;">최종 AVI: </span>
|
||||
<span style="color: #1e5149; font-size: 22px; font-weight: 900;">${avi.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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(() => {
|
||||
const headerH = container.querySelector('thead').offsetHeight || 45;
|
||||
const targetScrollTop = mainRow.offsetTop - headerH;
|
||||
container.scrollTo({ top: targetScrollTop, behavior: 'smooth' });
|
||||
}, 100);
|
||||
} else {
|
||||
detailRow.classList.remove('active');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 = `
|
||||
<div class="table-scroll-wrapper" style="max-height: 400px;">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>프로젝트명</th><th>관리자</th><th>정체일</th><th>AVI</th></tr></thead>
|
||||
<tbody>${projects.map(p => `<tr><td class="font-bold">${p.project_nm}</td><td>${p.master || '-'}</td><td>${p.days_stagnant}일</td><td style="font-weight:700; color:#1e5149;">${p.p_war.toFixed(1)}%</td></tr>`).join('')}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></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 === 'avi') {
|
||||
title.innerText = '운영 활력 지수 (AVI) 분석 가이드 (TEST)';
|
||||
body.innerHTML = `
|
||||
<div class="formula-box" style="margin-bottom:15px; background:#f1f5f9; padding:12px; border-radius:8px; font-family:'Consolas', monospace; font-weight:700; text-align:center;">
|
||||
AVI = exp(-λ × Stagnant Days) × Quality × 100
|
||||
</div>
|
||||
<div style="margin-bottom:15px; font-size:13px; line-height:1.6; color:#334155;">
|
||||
<p style="margin-bottom:8px;"><b>운영 활력 지수(AVI)</b>는 프로젝트가 현재 얼마나 건강하게 가동되고 있는지를 나타내는 '디지털 생존 지표'입니다.</p>
|
||||
<ul style="padding-left:18px; margin-bottom:10px;">
|
||||
<li style="margin-bottom:4px;"><b>지수 감쇄(Exponential Decay)</b>: 마지막 활동 이후 정체 기간이 길어질수록 자산의 최신성과 가치는 기하급수적으로 하락합니다.</li>
|
||||
<li style="margin-bottom:4px;"><b>위험 가속 계수(λ)</b>: 자산 규모(파일 수)가 클수록 관리 부재 시의 정보 망실 위험이 크다고 판단하여, 더 가파른 감쇄 곡선을 적용합니다.</li>
|
||||
<li style="margin-bottom:4px;"><b>활동 품질(Quality Factor)</b>: 단순 행정 로그(권한 변경 등)보다 실무 성과물(파일 업로드 등)이 발생했을 때 지수 복원력을 더 높게 부여합니다.</li>
|
||||
</ul>
|
||||
<p style="margin:0; font-weight:700; color:#1e5149;">※ 70% 미만 하락 시, 해당 프로젝트의 데이터 노후화 및 관리 방치 위험이 시작된 것으로 간주합니다.</p>
|
||||
</div>
|
||||
<table class="data-table" style="font-size:12px; width:100%;">
|
||||
<thead><tr style="background:#f8fafc;"><th>지수 (AVI)</th><th>등급</th><th>운영 상태</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="font-bold">90%↑</td><td style="font-weight:900; color:#059669;">Live</td><td>실시간 성과물이 도출되는 최상급 가동 상태</td></tr>
|
||||
<tr><td class="font-bold">70~90%</td><td style="font-weight:900; color:#1e5149;">Stable</td><td>주기적 업데이트가 이뤄지는 표준 안정 상태</td></tr>
|
||||
<tr><td class="font-bold">30~70%</td><td style="font-weight:900; color:#f59e0b;">Idle</td><td>활력이 저하되어 관리가 필요한 정체 상태</td></tr>
|
||||
<tr><td class="font-bold">10~30%</td><td style="font-weight:900; color:#dc2626;">Risk</td><td>데이터 노후화 및 자산 가치 소멸 위험 상태</td></tr>
|
||||
<tr><td class="font-bold">10%↓</td><td style="font-weight:900; color:#64748b;">Frozen</td><td>운영이 중단된 사망/방치 상태</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
|
||||
} else if (type === 'vci') {
|
||||
title.innerText = '자산 가치 기여도 (VCI) 분석 가이드 (TEST)';
|
||||
body.innerHTML = `
|
||||
<p style="margin-bottom:12px; font-size:13.5px;">VCI는 야구의 <b>WAR(Wins Above Replacement)</b> 개념을 도입하여, 개별 프로젝트가 전체 포트폴리오 평균 대비 얼마나 조직의 가치에 기여하는지 산출한 지표입니다.</p>
|
||||
<div class="formula-box" style="margin-bottom:15px; background:#f1f5f9; padding:12px; border-radius:8px; font-family:'Consolas', monospace; font-weight:700; text-align:center;">
|
||||
VCI = (현재 AVI - 전체 평균 AVI) × (파일 규모 가중치)
|
||||
</div>
|
||||
<p style="margin-bottom:15px; font-size:13px; line-height:1.6;">
|
||||
• <b>0.0 (평균)</b>: 우리 조직의 평균적인 관리 수준을 유지 중인 상태<br>
|
||||
• <b>(+) 점수</b>: 평균 이상의 활력으로 조직의 디지털 자산 가치를 증대시킴<br>
|
||||
• <b>(-) 점수</b>: 평균 이하의 방치로 인해 잠재적 기회비용 손실 발생 중
|
||||
</p>
|
||||
<table class="data-table" style="font-size:12px; width:100%;">
|
||||
<thead><tr style="background:#f8fafc;"><th>점수 (VCI)</th><th>등급</th><th>운영 의미</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>+10.0↑</td><td style="font-weight:900; color:#6366f1;">Masterpiece</td><td>시스템 가치를 견인하는 최우량 핵심 자산</td></tr>
|
||||
<tr><td>+2.0 ~ +10.0</td><td style="font-weight:900; color:#059669;">Blue Chip</td><td>꾸준한 활력으로 가치를 창출하는 우량 자산</td></tr>
|
||||
<tr><td>-2.0 ~ +2.0</td><td style="font-weight:900; color:#475569;">Steady</td><td>평균 수준의 운영을 유지 중인 안정 자산</td></tr>
|
||||
<tr><td>-10.0 ~ -2.0</td><td style="font-weight:900; color:#f59e0b;">Underperform</td><td>평균 대비 활력 부족으로 가치 하락 중인 자산</td></tr>
|
||||
<tr><td>-10.0↓</td><td style="font-weight:900; color:#dc2626;">Liability</td><td>가치를 훼손 중인 고위험 방치 자산</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
|
||||
} else if (type === 'oci') {
|
||||
title.innerText = '운영 일관성 지수 (OCI) 분석 가이드 (TEST)';
|
||||
body.innerHTML = `
|
||||
<div style="background:#f0fdf4; padding:15px; border-radius:8px; margin-bottom:15px;">
|
||||
<strong style="color:#166534; display:block; margin-bottom:5px;">"얼마나 꾸준하게 관리되고 있는가?"</strong>
|
||||
<p style="font-size:12.5px; color:#166534; margin:0;">미래 예측이 아닌, 최근 30일간의 <b>활동 리듬</b>과 <b>관리의 규칙성</b>을 분석하여 성실도를 점수화합니다.</p>
|
||||
</div>
|
||||
<table class="data-table" style="font-size:12px;">
|
||||
<thead><tr style="background:#f8fafc;"><th>분석 결과</th><th>일관성 등급</th><th>관리 신뢰도</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td style="color:#059669;">80%↑</td><td style="font-weight:900; color:#059669;">매우 우수</td><td>주 단위의 정기적 관리가 완벽히 이뤄짐</td></tr>
|
||||
<tr><td style="color:#1e5149;">50~80%</td><td style="font-weight:900; color:#1e5149;">양호</td><td>간헐적 정체는 있으나 꾸준히 관리됨</td></tr>
|
||||
<tr><td style="color:#f59e0b;">20~50%</td><td style="font-weight:900; color:#f59e0b;">주의</td><td>돌발적 활동 위주, 관리의 리듬이 깨짐</td></tr>
|
||||
<tr><td style="color:#dc2626;">20%↓</td><td style="font-weight:900; color:#dc2626;">매우 불량</td><td>장기 정체 중이거나 관리 의지 확인 불가</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
|
||||
} else {
|
||||
title.innerText = '업무 집중도 (Job Focus) 등급 가이드 (TEST)';
|
||||
body.innerHTML = `
|
||||
<p style="font-size:13px; color:#64748b; margin-bottom:15px;">최근 수집 로그 중 단순 행정 로그를 제외하고 실질적인 성과물(파일) 변동이 포착된 비율입니다.</p>
|
||||
<table class="data-table" style="font-size:12px;">
|
||||
<thead><tr style="background:#f8fafc;"><th>비율 (%)</th><th>등급</th><th>활동 성격</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>80%↑</td><td style="font-weight:900; color:#6366f1;">Intensive</td><td>성과물 위주의 고밀도 집중 작업</td></tr>
|
||||
<tr><td>50~80%</td><td style="font-weight:900; color:#059669;">Active</td><td>성과와 관리가 균형 잡힌 원활한 실행</td></tr>
|
||||
<tr><td>20~50%</td><td style="font-weight:900; color:#f59e0b;">Maintenance</td><td>설정/행정 등 단순 관리 중심의 작업</td></tr>
|
||||
<tr><td>20%↓</td><td style="font-weight:900; color:#dc2626;">Surface</td><td>실체적 변화가 적은 형식적 로그 중심</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
|
||||
}
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeAnalysisModal() { document.getElementById('analysisModal').style.display = 'none'; }
|
||||
245
js/dashboard_test.js
Normal file
245
js/dashboard_test.js
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Project Master Overseas Dashboard JS (TEST VERSION)
|
||||
* 기능: 데이터 로드, 활성도 분석, 인증 모달 제어, 크롤링 동기화 및 중단
|
||||
*/
|
||||
|
||||
// --- 글로벌 상태 관리 ---
|
||||
let rawData = [];
|
||||
let projectActivityDetails = [];
|
||||
let isCrawling = false;
|
||||
|
||||
const CONTINENT_ORDER = { "아시아": 1, "아프리카": 2, "아메리카": 3, "지사": 4 };
|
||||
|
||||
// --- 초기화 ---
|
||||
async function init() {
|
||||
console.log("Dashboard (TEST) Initializing...");
|
||||
if (!document.getElementById('projectAccordion')) return;
|
||||
|
||||
await loadAvailableDates();
|
||||
await loadDataByDate();
|
||||
}
|
||||
|
||||
// --- 데이터 통신 및 로드 ---
|
||||
async function loadAvailableDates() {
|
||||
try {
|
||||
const response = await fetch(API.AVAILABLE_DATES);
|
||||
const dates = await response.json(); // YYYY.MM.DD 형식 리스트
|
||||
|
||||
if (dates?.length > 0) {
|
||||
// 날짜 형식 변환 (YYYY.MM.DD -> YYYY-MM-DD)
|
||||
const formattedDates = dates.map(d => d.replace(/\./g, '-')).sort();
|
||||
const minDate = formattedDates[0];
|
||||
const maxDate = new Date().toISOString().split('T')[0]; // 오늘
|
||||
const defaultDate = formattedDates[formattedDates.length - 1]; // 가장 최신 수집일
|
||||
|
||||
const dateInputHtml = `
|
||||
<input type="date" id="dateSelector"
|
||||
min="${minDate}" max="${maxDate}" value="${defaultDate}"
|
||||
onchange="loadDataByDate(this.value)"
|
||||
style="margin-left:10px; border:1px solid var(--border-color); border-radius:4px; padding:2px 8px; font-weight:700; cursor:pointer; font-family:inherit; color:var(--text-main); font-size:14px;">
|
||||
`;
|
||||
const baseDateStrong = document.getElementById('baseDate');
|
||||
if (baseDateStrong) baseDateStrong.innerHTML = dateInputHtml;
|
||||
}
|
||||
} catch (e) { console.error("날짜 로드 실패:", e); }
|
||||
}
|
||||
|
||||
async function loadDataByDate(selectedDate = "") {
|
||||
try {
|
||||
await loadActivityAnalysis(selectedDate);
|
||||
const url = selectedDate ? `${API.PROJECT_DATA}?date=${selectedDate}` : `${API.PROJECT_DATA}?t=${Date.now()}`;
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
rawData = data.projects || [];
|
||||
renderDashboard(rawData);
|
||||
} catch (e) {
|
||||
console.error("데이터 로드 실패:", e);
|
||||
alert("데이터를 가져오는 데 실패했습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadActivityAnalysis(date = "") {
|
||||
const dashboard = document.getElementById('activityDashboard');
|
||||
if (!dashboard) return;
|
||||
try {
|
||||
const url = date ? `${API.PROJECT_ACTIVITY}?date=${date}` : API.PROJECT_ACTIVITY;
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
if (data.error) return;
|
||||
const { summary, details } = data;
|
||||
projectActivityDetails = details;
|
||||
dashboard.innerHTML = `
|
||||
<div class="activity-card active" onclick="showActivityDetails('active')">
|
||||
<div class="label">정상 (7일 이내) [TEST]</div><div class="count">${summary.active}</div>
|
||||
</div>
|
||||
<div class="activity-card warning" onclick="showActivityDetails('warning')">
|
||||
<div class="label">주의 (14일 이내) [TEST]</div><div class="count">${summary.warning}</div>
|
||||
</div>
|
||||
<div class="activity-card stale" onclick="showActivityDetails('stale')">
|
||||
<div class="label">방치 (14일 초과) [TEST]</div><div class="count">${summary.stale}</div>
|
||||
</div>
|
||||
<div class="activity-card unknown" onclick="showActivityDetails('unknown')">
|
||||
<div class="label">데이터 없음 (파일 0개) [TEST]</div><div class="count">${summary.unknown}</div>
|
||||
</div>`;
|
||||
} catch (e) { console.error("분석 로드 실패:", e); }
|
||||
}
|
||||
|
||||
// --- 렌더링 엔진 ---
|
||||
function renderDashboard(data) {
|
||||
const container = document.getElementById('projectAccordion');
|
||||
container.innerHTML = '';
|
||||
const grouped = groupData(data);
|
||||
Object.keys(grouped).sort((a, b) => (CONTINENT_ORDER[a] || 99) - (CONTINENT_ORDER[b] || 99)).forEach(continent => {
|
||||
const continentDiv = document.createElement('div');
|
||||
continentDiv.className = 'continent-group active';
|
||||
let html = `<div class="continent-header" onclick="toggleGroup(this)"><span>${continent}</span><span class="toggle-icon">▼</span></div><div class="continent-body">`;
|
||||
Object.keys(grouped[continent]).sort().forEach(country => {
|
||||
html += `<div class="country-group active"><div class="country-header" onclick="toggleGroup(this)"><span>${country}</span><span class="toggle-icon">▼</span></div><div class="country-body"><div class="accordion-container">
|
||||
<div class="accordion-list-header"><div>프로젝트명</div><div>담당부서</div><div>담당자</div><div style="text-align:center;">파일수</div><div>최근로그</div></div>
|
||||
${grouped[continent][country].sort((a, b) => a[0].localeCompare(b[0])).map(p => createProjectHtml(p)).join('')}</div></div></div>`;
|
||||
});
|
||||
html += `</div>`;
|
||||
continentDiv.innerHTML = html;
|
||||
container.appendChild(continentDiv);
|
||||
});
|
||||
}
|
||||
|
||||
function groupData(data) {
|
||||
const res = {};
|
||||
data.forEach(item => {
|
||||
const c1 = item[5] || "기타", c2 = item[6] || "미분류";
|
||||
if (!res[c1]) res[c1] = {};
|
||||
if (!res[c1][c2]) res[c1][c2] = [];
|
||||
res[c1][c2].push(item);
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
function createProjectHtml(p) {
|
||||
const [name, dept, admin, logRaw, files] = p;
|
||||
const recentLog = (!logRaw || logRaw === "X" || logRaw === "데이터 없음") ? "기록 없음" : logRaw;
|
||||
const logTime = recentLog !== "기록 없음" ? recentLog.split(',')[0] : "기록 없음";
|
||||
|
||||
const isStaleLog = recentLog.replace(/\s/g, "").includes("폴더자동삭제");
|
||||
const isNoFiles = (files === 0 || files === null);
|
||||
const statusClass = isNoFiles ? "status-error" : "";
|
||||
|
||||
let logStyleClass = "";
|
||||
if (isStaleLog) logStyleClass = "error-text";
|
||||
else if (recentLog === "기록 없음") logStyleClass = "warning-text";
|
||||
|
||||
const logBoldStyle = isStaleLog ? 'font-weight: 800;' : '';
|
||||
|
||||
return `
|
||||
<div class="accordion-item ${statusClass}">
|
||||
<div class="accordion-header" onclick="toggleAccordion(this)">
|
||||
<div class="repo-title" title="${name}">${name}</div><div class="repo-dept">${dept}</div><div class="repo-admin">${admin}</div><div class="repo-files ${isNoFiles ? 'error-text' : ''}">${files || 0}</div><div class="repo-log ${logStyleClass}" style="${logBoldStyle}" title="${recentLog}">${recentLog}</div>
|
||||
</div>
|
||||
<div class="accordion-body">
|
||||
<div class="detail-grid">
|
||||
<div class="detail-section">
|
||||
<h4>참여 인원 상세 (TEST)</h4>
|
||||
<table class="data-table">
|
||||
<thead><tr><th>이름</th><th>소속</th><th>권한</th></tr></thead>
|
||||
<tbody><tr><td>${admin}</td><td>${dept}</td><td>관리자</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="detail-section">
|
||||
<h4>최근 활동 (TEST)</h4>
|
||||
<table class="data-table">
|
||||
<thead><tr><th>유형</th><th>내용</th><th>일시</th></tr></thead>
|
||||
<tbody><tr><td><span class="badge">로그</span></td><td>동기화 완료</td><td>${logTime}</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// --- 이벤트 핸들러 ---
|
||||
function toggleGroup(h) { h.parentElement.classList.toggle('active'); }
|
||||
function toggleAccordion(h) {
|
||||
const item = h.parentElement;
|
||||
item.parentElement.querySelectorAll('.accordion-item').forEach(el => { if (el !== item) el.classList.remove('active'); });
|
||||
item.classList.toggle('active');
|
||||
}
|
||||
|
||||
function showActivityDetails(status) {
|
||||
const names = { active: '정상', warning: '주의', stale: '방치', unknown: '데이터 없음' };
|
||||
const filtered = (projectActivityDetails || []).filter(d => d.status === status);
|
||||
document.getElementById('modalTitle').innerText = `${names[status]} 목록 (${filtered.length}개) [TEST]`;
|
||||
document.getElementById('modalTableBody').innerHTML = filtered.map(p => {
|
||||
const o = rawData.find(r => r[0] === p.name);
|
||||
return `<tr class="modal-row" onclick="scrollToProject('${p.name}')"><td><strong>${p.name}</strong></td><td>${o ? o[1] : "-"}</td><td>${o ? o[2] : "-"}</td></tr>`;
|
||||
}).join('');
|
||||
ModalManager.open('activityDetailModal');
|
||||
}
|
||||
|
||||
function scrollToProject(name) {
|
||||
ModalManager.close('activityDetailModal');
|
||||
const target = Array.from(document.querySelectorAll('.repo-title')).find(t => t.innerText.trim() === name.trim())?.closest('.accordion-header');
|
||||
if (target) {
|
||||
let p = target.parentElement;
|
||||
while (p && p !== document.body) {
|
||||
if (p.classList.contains('continent-group') || p.classList.contains('country-group')) p.classList.add('active');
|
||||
p = p.parentElement;
|
||||
}
|
||||
target.parentElement.classList.add('active');
|
||||
const pos = target.getBoundingClientRect().top + window.pageYOffset - 260;
|
||||
window.scrollTo({ top: pos, behavior: 'smooth' });
|
||||
target.style.backgroundColor = 'var(--primary-lv-1)';
|
||||
setTimeout(() => target.style.backgroundColor = '', 2000);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 크롤링 및 인증 제어 ---
|
||||
async function syncData() {
|
||||
if (isCrawling) {
|
||||
if (confirm("크롤링을 중단하시겠습니까?")) {
|
||||
const res = await fetch(API.STOP_SYNC);
|
||||
if ((await res.json()).success) document.getElementById('syncBtn').innerText = "중단 요청 중...";
|
||||
}
|
||||
return;
|
||||
}
|
||||
document.getElementById('authId').value = '';
|
||||
document.getElementById('authPw').value = '';
|
||||
document.getElementById('authErrorMessage').style.display = 'none';
|
||||
ModalManager.open('authModal');
|
||||
}
|
||||
|
||||
async function submitAuth() {
|
||||
const id = document.getElementById('authId').value, pw = document.getElementById('authPw').value, err = document.getElementById('authErrorMessage');
|
||||
try {
|
||||
const res = await fetch(API.AUTH_CRAWL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: id, password: pw }) });
|
||||
const data = await res.json();
|
||||
if (data.success) { ModalManager.close('authModal'); startCrawlProcess(); }
|
||||
else { err.innerText = "크롤링을 할 수 없습니다."; err.style.display = 'block'; }
|
||||
} catch { err.innerText = "서버 연결 실패"; err.style.display = 'block'; }
|
||||
}
|
||||
|
||||
async function startCrawlProcess() {
|
||||
isCrawling = true;
|
||||
const btn = document.getElementById('syncBtn'), logC = document.getElementById('logConsole'), logB = document.getElementById('logBody');
|
||||
btn.classList.add('loading'); btn.style.backgroundColor = 'var(--error-color)'; btn.innerHTML = `<span class="spinner"></span> 크롤링 중단`;
|
||||
logC.style.display = 'block'; logB.innerHTML = '<div style="color:#aaa; margin-bottom:10px;">>>> 엔진 초기화 중...</div>';
|
||||
try {
|
||||
const res = await fetch(API.SYNC);
|
||||
const reader = res.body.getReader(), decoder = new TextDecoder();
|
||||
while (true) {
|
||||
const { done, value } = await reader.read(); if (done) break;
|
||||
decoder.decode(value).split('\n').forEach(line => {
|
||||
if (line.startsWith('data: ')) {
|
||||
const p = JSON.parse(line.substring(6));
|
||||
if (p.type === 'log') {
|
||||
const div = document.createElement('div'); div.innerText = `[${new Date().toLocaleTimeString()}] ${p.message}`;
|
||||
logB.appendChild(div); logC.scrollTop = logC.scrollHeight;
|
||||
} else if (p.type === 'done') { init(); alert(`동기화 종료`); logC.style.display = 'none'; }
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch { alert("스트림 끊김"); }
|
||||
finally { isCrawling = false; btn.classList.remove('loading'); btn.style.backgroundColor = ''; btn.innerHTML = `<span class="spinner"></span> 데이터 동기화 (크롤링)`; }
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
63
log_scorer.py
Normal file
63
log_scorer.py
Normal file
@@ -0,0 +1,63 @@
|
||||
import re
|
||||
|
||||
class LogScorer:
|
||||
"""로그 텍스트의 시맨틱 가치를 판별하여 점수화하는 모듈 (SWVW)"""
|
||||
|
||||
# 업무 가치 범주별 가중치 정의
|
||||
# 1.0: 핵심 의사결정/계약, 0.7: 지능형/권한관리, 0.4: 일반관리, 0.1: 단순활동
|
||||
WEIGHT_MAP = {
|
||||
"CORE": 1.0, # 설계변경, 실정보고, 계약, 정산 등 (추후 확장 대비)
|
||||
"INTELLIGENT": 0.7, # AI요약, PDF변환, 분석 등
|
||||
"AUTH": 0.6, # 권한 추가, 보안 설정 등
|
||||
"MGMT": 0.4, # 업로드, 수정, 이름 변경 등
|
||||
"SIMPLE": 0.2, # 다운로드, 삭제, 생성 등
|
||||
"AUTO": 0.0 # 자동 삭제, 시스템 로그 등
|
||||
}
|
||||
|
||||
# 범주별 키워드 정의 (실무 문맥 반영)
|
||||
KEYWORDS = {
|
||||
"CORE": ["보고", "계약", "정산", "설계", "검토", "승인", "공문", "통보"],
|
||||
"INTELLIGENT": ["AI", "요약", "변환", "PDF"],
|
||||
"AUTH": ["권한", "참여자", "관리자", "보안"],
|
||||
"MGMT": ["업로드", "수정", "변경", "첨부", "추가"],
|
||||
"SIMPLE": ["다운로드", "생성", "이동", "삭제"],
|
||||
"AUTO": ["자동", "시스템"]
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_score(cls, log_text):
|
||||
"""로그 텍스트를 분석하여 0.0 ~ 1.0 사이의 가치 점수를 반환"""
|
||||
if not log_text or log_text == "데이터 없음":
|
||||
return 0.0
|
||||
|
||||
# 날짜 부분 제거 (예: "2024.03.01, " 제거)
|
||||
clean_log = re.sub(r'^\d{2,4}\.\d{2}\.\d{2},\s*', '', log_text)
|
||||
|
||||
# 1. 특정 키워드 매칭을 통한 기본 범주 판별
|
||||
for category, keywords in cls.KEYWORDS.items():
|
||||
if any(k in clean_log for k in keywords):
|
||||
# '자동 삭제'는 별도 처리
|
||||
if category == "SIMPLE" and "자동" in clean_log:
|
||||
return cls.WEIGHT_MAP["AUTO"]
|
||||
return cls.WEIGHT_MAP[category]
|
||||
|
||||
return 0.3 # 기본값 (분류되지 않은 활동)
|
||||
|
||||
@classmethod
|
||||
def calculate_work_density(cls, logs):
|
||||
"""로그 목록을 받아 평균 업무 밀도 산출"""
|
||||
if not logs: return 0.0
|
||||
scores = [cls.get_score(log) for log in logs]
|
||||
return sum(scores) / len(scores)
|
||||
|
||||
# 테스트 코드
|
||||
if __name__ == "__main__":
|
||||
test_logs = [
|
||||
"2026.03.30, 하도급 계약 통보서 검토",
|
||||
"2026.03.25, AI요약 완료",
|
||||
"2026.03.20, 부관리자 권한 추가",
|
||||
"2026.03.15, 파일 업로드",
|
||||
"2026.03.10, 폴더 자동 삭제"
|
||||
]
|
||||
for log in test_logs:
|
||||
print(f"Log: {log} => Score: {LogScorer.get_score(log)}")
|
||||
@@ -5,8 +5,8 @@ class SOIPredictionService:
|
||||
"""학습형 시계열 예측 및 피처 추출 엔진"""
|
||||
|
||||
@staticmethod
|
||||
def get_historical_soi(cursor, project_id):
|
||||
"""DB에서 프로젝트의 과거 SOI 히스토리를 시퀀스로 추출"""
|
||||
def get_historical_avi(cursor, project_id):
|
||||
"""DB에서 프로젝트의 과거 AVI 히스토리를 시퀀스로 추출"""
|
||||
cursor.execute("""
|
||||
SELECT crawl_date, file_count, recent_log
|
||||
FROM projects_history
|
||||
@@ -51,14 +51,14 @@ class SOIPredictionService:
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def predict_future_soi(current_soi, history, days_ahead=14):
|
||||
def predict_future_avi(current_avi, history, days_ahead=14):
|
||||
"""기존 점수와 시계열 피처를 결합하여 미래 점수 예측"""
|
||||
# 데이터가 너무 적으면 무조건 보수적 감쇄 (14일 기준 약 -2.1점)
|
||||
if not history or len(history) < 3:
|
||||
return round(max(0, min(100, current_soi - (0.15 * days_ahead))), 1)
|
||||
return round(max(0, min(100, current_avi - (0.15 * days_ahead))), 1)
|
||||
|
||||
features = SOIPredictionService.extract_vitality_features(history)
|
||||
current_val = float(current_soi)
|
||||
current_val = float(current_avi)
|
||||
|
||||
# [정밀 정체 분석]
|
||||
# 1. 파일 수 변화 확인 (최근 5개 샘플)
|
||||
|
||||
@@ -3,3 +3,13 @@ uvicorn==0.29.0
|
||||
playwright==1.42.0
|
||||
python-dotenv==1.0.1
|
||||
pypdf==4.1.0
|
||||
pymysql
|
||||
pandas
|
||||
sqlalchemy
|
||||
openpyxl
|
||||
pytesseract
|
||||
pdf2image
|
||||
pillow
|
||||
numpy
|
||||
pydantic
|
||||
jinja2
|
||||
|
||||
190
server_test.py
Normal file
190
server_test.py
Normal file
@@ -0,0 +1,190 @@
|
||||
import os
|
||||
import sys
|
||||
import asyncio
|
||||
import pymysql
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import StreamingResponse, FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from analyze import analyze_file_content
|
||||
from crawler_service_test import run_crawler_service, crawl_stop_event
|
||||
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"
|
||||
TESSDATA_PREFIX = os.getenv("TESSDATA_PREFIX", r"C:\Users\User\AppData\Local\Programs\Tesseract-OCR\tessdata")
|
||||
os.environ["TESSDATA_PREFIX"] = TESSDATA_PREFIX
|
||||
|
||||
app = FastAPI(title="Project Master Overseas API (TEST MODE)")
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
# 정적 파일 마운트
|
||||
app.mount("/style", StaticFiles(directory="style"), name="style")
|
||||
app.mount("/js", StaticFiles(directory="js"), name="js")
|
||||
app.mount("/sample_files", StaticFiles(directory="sample"), name="sample_files")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=False,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# --- 유틸리티 함수 ---
|
||||
def get_db_connection():
|
||||
"""MySQL 데이터베이스(TEST) 연결을 반환"""
|
||||
return pymysql.connect(
|
||||
host=os.getenv('DB_HOST', 'localhost'),
|
||||
user=os.getenv('DB_USER', 'root'),
|
||||
password=os.getenv('DB_PASSWORD', '45278434'),
|
||||
database='PM_proto_test', # 테스트용 DB로 고정
|
||||
charset='utf8mb4',
|
||||
cursorclass=pymysql.cursors.DictCursor
|
||||
)
|
||||
|
||||
async def run_in_threadpool(func, *args):
|
||||
"""동기 함수를 비차단 방식으로 실행"""
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, func, *args)
|
||||
|
||||
# --- HTML 라우팅 ---
|
||||
@app.get("/")
|
||||
async def root(request: Request):
|
||||
return templates.TemplateResponse("index.html", {"request": request})
|
||||
|
||||
@app.get("/dashboard")
|
||||
@app.get("/dashboard_test")
|
||||
async def get_dashboard_test(request: Request):
|
||||
# 테스트 환경에서는 dashboard_test.html을 우선적으로 반환
|
||||
return templates.TemplateResponse("dashboard_test.html", {"request": request})
|
||||
|
||||
@app.get("/mailTest")
|
||||
async def get_mail_test(request: Request):
|
||||
return templates.TemplateResponse("mailTest.html", {"request": request})
|
||||
|
||||
@app.get("/inquiries")
|
||||
async def get_inquiries_page(request: Request):
|
||||
return templates.TemplateResponse("inquiries.html", {"request": request})
|
||||
|
||||
@app.get("/analysis")
|
||||
@app.get("/analysis_test")
|
||||
async def get_analysis_test(request: Request):
|
||||
# 테스트 환경에서는 analysis_test.html을 우선적으로 반환
|
||||
return templates.TemplateResponse("analysis_test.html", {"request": request})
|
||||
|
||||
# --- 문의사항 API ---
|
||||
@app.get("/api/inquiries")
|
||||
async def get_inquiries(pm_type: str = None, category: str = None, status: str = None, keyword: str = None):
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
return InquiryService.get_inquiries_logic(cursor, pm_type, category, status, keyword)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
@app.get("/api/inquiries/{id}")
|
||||
async def get_inquiry_detail(id: int):
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
return InquiryService.get_inquiry_detail_logic(cursor, id)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
@app.post("/api/inquiries/{id}/reply")
|
||||
async def update_inquiry_reply(id: int, req: InquiryReplyRequest):
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
return InquiryService.update_inquiry_reply_logic(cursor, conn, id, req)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
@app.delete("/api/inquiries/{id}/reply")
|
||||
async def delete_inquiry_reply(id: int):
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
return InquiryService.delete_inquiry_reply_logic(cursor, conn, id)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
# --- 프로젝트 및 히스토리 API ---
|
||||
@app.get("/available-dates")
|
||||
async def get_available_dates():
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
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):
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
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):
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
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": "크롤링을 할 수 없습니다."}
|
||||
|
||||
@app.get("/sync")
|
||||
async def sync_data():
|
||||
return StreamingResponse(run_crawler_service(), media_type="text_event-stream")
|
||||
|
||||
@app.get("/stop-sync")
|
||||
async def stop_sync():
|
||||
crawl_stop_event.set()
|
||||
return {"success": True}
|
||||
|
||||
# --- 파일 및 첨부파일 API ---
|
||||
@app.get("/attachments")
|
||||
async def get_attachments():
|
||||
path = "sample"
|
||||
if not os.path.exists(path): os.makedirs(path)
|
||||
return [{"name": f, "size": f"{os.path.getsize(os.path.join(path, f))/1024:.1f} KB"}
|
||||
for f in os.listdir(path) if os.path.isfile(os.path.join(path, f))]
|
||||
|
||||
@app.get("/analyze-file")
|
||||
async def analyze_file(filename: str):
|
||||
return await run_in_threadpool(analyze_file_content, filename)
|
||||
|
||||
@app.get("/sample.png")
|
||||
async def get_sample_img():
|
||||
return FileResponse("sample.png")
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8001)
|
||||
@@ -29,20 +29,28 @@ class DashboardQueries:
|
||||
# 최신 수집 날짜 조회
|
||||
GET_LAST_CRAWL_DATE = "SELECT MAX(crawl_date) as last_date FROM projects_history"
|
||||
|
||||
# 특정 날짜 프로젝트 데이터 JOIN 조회
|
||||
# 특정 날짜(또는 그 이하 최신) 프로젝트 데이터 JOIN 조회
|
||||
GET_PROJECT_LIST = """
|
||||
SELECT 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
|
||||
LEFT JOIN projects_history h ON h.project_id = m.project_id AND h.crawl_date = (
|
||||
SELECT MAX(crawl_date)
|
||||
FROM projects_history
|
||||
WHERE project_id = m.project_id AND crawl_date <= %s
|
||||
)
|
||||
ORDER BY m.project_id ASC
|
||||
"""
|
||||
|
||||
# 활성도 분석을 위한 프로젝트 목록 조회
|
||||
# 활성도 분석을 위한 프로젝트 목록 조회 (특정 날짜 이하 최신 데이터 기준)
|
||||
GET_PROJECT_LIST_FOR_ANALYSIS = """
|
||||
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
|
||||
LEFT JOIN projects_history h ON h.project_id = m.project_id AND h.crawl_date = (
|
||||
SELECT MAX(crawl_date)
|
||||
FROM projects_history
|
||||
WHERE project_id = m.project_id AND crawl_date <= %s
|
||||
)
|
||||
"""
|
||||
|
||||
class CrawlerQueries:
|
||||
@@ -59,9 +67,16 @@ class CrawlerQueries:
|
||||
# 부서 정보 업데이트
|
||||
UPDATE_DEPARTMENT = "UPDATE projects_master SET department = %s WHERE project_id = %s"
|
||||
|
||||
# 히스토리(로그/파일수) 저장
|
||||
UPSERT_HISTORY = """
|
||||
# 히스토리(로그/파일수) 저장 (날짜 지정형)
|
||||
UPSERT_HISTORY_WITH_DATE = """
|
||||
INSERT INTO projects_history (project_id, crawl_date, recent_log, file_count)
|
||||
VALUES (%s, CURRENT_DATE(), %s, %s)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
ON DUPLICATE KEY UPDATE recent_log=VALUES(recent_log), file_count=VALUES(file_count)
|
||||
"""
|
||||
|
||||
# 히스토리(로그/파일수) 저장 (기본형 - 오늘 날짜)
|
||||
UPSERT_HISTORY = """
|
||||
INSERT INTO projects_history (project_id, crawl_date, recent_log, file_count)
|
||||
VALUES (%s, CURDATE(), %s, %s)
|
||||
ON DUPLICATE KEY UPDATE recent_log=VALUES(recent_log), file_count=VALUES(file_count)
|
||||
"""
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
<div class="card-header">
|
||||
<div style="display: flex; flex-direction: column; gap: 4px;">
|
||||
<h4>Project Activity Vitality Leaderboard (AVI Status)</h4>
|
||||
<p style="font-size: 11px; color: #888; margin: 0;">운영 표준(AVI 70%) 대비 운영 활력 및 VCI 기여 리더보드</p>
|
||||
<p style="font-size: 11px; color: #888; margin: 0;">전체 포트폴리오 평균(0.0) 대비 운영 가치 기여(VCI) 리더보드</p>
|
||||
</div>
|
||||
<div class="card-tools">
|
||||
<span id="avg-system-info" style="font-size: 11px; color: #888;">* AVI (Activity Vitality Index)</span>
|
||||
|
||||
139
templates/analysis_test.html
Normal file
139
templates/analysis_test.html
Normal file
@@ -0,0 +1,139 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>데이터 분석 (테스트) - Project Master Sabermetrics</title>
|
||||
<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>
|
||||
<nav class="topbar">
|
||||
<div class="topbar-header">
|
||||
<a href="/">
|
||||
<h2>Project Master Test (TEST)</h2>
|
||||
</a>
|
||||
</div>
|
||||
<ul class="nav-list">
|
||||
<li class="nav-item" onclick="location.href='/dashboard_test'">대시보드(테스트)</li>
|
||||
<li class="nav-item" onclick="location.href='/inquiries'">문의사항</li>
|
||||
<li class="nav-item" onclick="location.href='/mailTest'">메일관리</li>
|
||||
<li class="nav-item active" onclick="location.href='/analysis_test'">분석(테스트)</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main class="analysis-content">
|
||||
<header class="analysis-header">
|
||||
<div class="title-group">
|
||||
<div class="ai-badge" style="background: var(--ai-color);">AI Sabermetrics [TEST]</div>
|
||||
<h2>시스템 운영 자산 가치 분석 (테스트 환경)</h2>
|
||||
<p>테스트용 데이터베이스(PM_proto_test)를 기반으로 한 활력 지표 분석입니다.</p>
|
||||
</div>
|
||||
<div class="analysis-actions">
|
||||
<button class="btn btn-primary" onclick="location.reload()">데이터 갱신</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 상단 정보 영역 -->
|
||||
<div class="top-info-grid">
|
||||
<section class="dl-model-info">
|
||||
<div class="card-header">
|
||||
<h4><i class="ai-icon">AI</i> Hybrid Prediction Engine (TEST)</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="model-desc-vertical">
|
||||
<div class="model-item-vertical">
|
||||
<span class="model-tag">분석 모델</span>
|
||||
<p>최근 9회차 시계열의 Velocity 및 변화율 분석</p>
|
||||
</div>
|
||||
<div class="model-item-vertical">
|
||||
<span class="model-tag">가중치 로직</span>
|
||||
<p>활동 시 '선형 유지', 정체 시 '지수 감쇄' 동적 적용</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="soi-deep-dive">
|
||||
<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. 자산 가치 변동 추적</h6>
|
||||
<p>규모를 감지하여, 대형 프로젝트 정체 시 데이터 가치 하락 속도를 <strong>가속(Acceleration)</strong>시킵니다.</p>
|
||||
</div>
|
||||
<div class="soi-info-column">
|
||||
<h6>2. 활동 시계열 관성 분석</h6>
|
||||
<p>최근 활동의 연속성을 분석하여, 단기 정체 시에도 과거의 <strong>운영 모멘텀</strong>을 반영하여 지수를 보정합니다.</p>
|
||||
</div>
|
||||
<div class="soi-info-column">
|
||||
<h6>3. 동적 가치 계수</h6>
|
||||
<p>프로젝트마다 <strong>개별화된 감쇄 곡선</strong>을 생성하여 현장에 가장 밀착된 보존율을 제공합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 메인 분석 차트 영역 -->
|
||||
<div class="analysis-charts-grid">
|
||||
<div class="chart-container-box">
|
||||
<h5>운영 활력 분포 (Activity Distribution)</h5>
|
||||
<canvas id="statusChart"></canvas>
|
||||
</div>
|
||||
<div class="chart-container-box">
|
||||
<h5>자산 가치 전략 매트릭스 (Strategic Analysis)</h5>
|
||||
<canvas id="forecastChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 리더보드 영역 -->
|
||||
<div class="analysis-card timeline-analysis">
|
||||
<div class="card-header">
|
||||
<div style="display: flex; flex-direction: column; gap: 4px;">
|
||||
<h4>Project Activity Vitality Leaderboard (AVI Status) [TEST]</h4>
|
||||
<p style="font-size: 11px; color: #888; margin: 0;">전체 포트폴리오 평균(0.0) 대비 운영 가치 기여(VCI) 리더보드</p>
|
||||
</div>
|
||||
<div class="card-tools">
|
||||
<span id="avg-system-info" style="font-size: 11px; color: #888;">* AVI (Activity Vitality Index)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-war-guide">
|
||||
<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 id="p-war-table-container">
|
||||
<!-- JS에 의해 동적으로 테이블 삽입 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 설명 모달 -->
|
||||
<div id="analysisModal" class="modal-overlay" onclick="if(event.target===this) closeAnalysisModal()">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="modalTitle">분석 상세 (TEST)</h3>
|
||||
<span class="modal-close" onclick="closeAnalysisModal()">×</span>
|
||||
</div>
|
||||
<div class="modal-body" id="modalBody">
|
||||
<!-- 내용 동적 삽입 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/common.js"></script>
|
||||
<script src="js/analysis_test.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
118
templates/dashboard_test.html
Normal file
118
templates/dashboard_test.html
Normal file
@@ -0,0 +1,118 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Project Master Overseas 관리자 (테스트)</title>
|
||||
<link rel="stylesheet" as="style" crossorigin
|
||||
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
|
||||
<link rel="stylesheet" href="style/common.css">
|
||||
<link rel="stylesheet" href="style/dashboard.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<nav class="topbar">
|
||||
<div class="topbar-header">
|
||||
<a href="/">
|
||||
<h2>Project Master Test (TEST)</h2>
|
||||
</a>
|
||||
</div>
|
||||
<ul class="nav-list">
|
||||
<li class="nav-item active" onclick="location.href='/dashboard_test'">대시보드(테스트)</li>
|
||||
<li class="nav-item" onclick="location.href='/inquiries'">문의사항</li>
|
||||
<li class="nav-item" onclick="location.href='/mailTest'">메일관리</li>
|
||||
<li class="nav-item" onclick="location.href='/analysis_test'">분석(테스트)</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main class="main-content">
|
||||
<header>
|
||||
<h2>프로젝트 현황 (테스트 환경)</h2>
|
||||
<div class="header-actions" style="display: flex; align-items: center; gap: 15px;">
|
||||
<div class="base-date-info">기준날짜: <strong id="baseDate">-</strong></div>
|
||||
<button id="syncBtn" class="sync-btn" onclick="syncData()">
|
||||
<span class="spinner"></span>
|
||||
데이터 동기화 (TEST)
|
||||
</button>
|
||||
<div class="admin-info">접속자: <strong>이태훈[테스트관리자]</strong></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 프로젝트 활성도 대시보드 (전체 너비 래퍼) -->
|
||||
<div class="activity-dashboard-wrapper">
|
||||
<div id="activityDashboard" class="activity-dashboard">
|
||||
<!-- JS에서 동적 삽입 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 실시간 로그 콘솔 (본문 내부로 복구) -->
|
||||
<div id="logConsole" class="log-console" style="display:none;">
|
||||
<div class="log-console-header">실시간 수집 로그 [PM Overseas - TEST]</div>
|
||||
<div id="logBody"></div>
|
||||
</div>
|
||||
|
||||
<div id="projectAccordion">
|
||||
<!-- Multi-level Accordion items will be generated here -->
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 모달 레이어 (공통 규격 적용) -->
|
||||
<div id="authModal" class="modal-overlay">
|
||||
<div class="modal-content" style="max-width: 440px; padding: 40px; text-align: center;" onclick="event.stopPropagation()">
|
||||
<div class="auth-header" style="margin-bottom: 32px;">
|
||||
<i class="fas fa-lock" style="font-size: 32px; color: var(--primary-color); margin-bottom: 16px; display: block;"></i>
|
||||
<h3 style="font-size: 20px; font-weight: 800; color: #111; margin-bottom: 8px;">크롤링 권한 인증 (TEST)</h3>
|
||||
<p style="font-size: 13px; color: var(--text-sub);">테스트 DB 동기화를 위해 계정으로 로그인하세요.</p>
|
||||
</div>
|
||||
<div class="auth-body" style="display: flex; flex-direction: column; gap: 20px; text-align: left; margin-bottom: 32px;">
|
||||
<div class="input-group">
|
||||
<label>관리자 아이디</label>
|
||||
<input type="text" id="authId" placeholder="아이디를 입력하세요">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>비밀번호</label>
|
||||
<input type="password" id="authPw" placeholder="비밀번호를 입력하세요"
|
||||
onkeyup="if(event.key==='Enter') submitAuth()">
|
||||
</div>
|
||||
<div id="authErrorMessage" class="error-text" style="display:none; color: var(--error-color); font-size: 12px; font-weight: 600; text-align: center; margin-top: -10px;">크롤링을 할 수 없습니다.</div>
|
||||
</div>
|
||||
<div class="auth-footer" style="display: grid; grid-template-columns: 1fr 1.5fr; gap: 12px;">
|
||||
<button class="btn btn-secondary" style="height: 48px;" onclick="ModalManager.close('authModal')">취소</button>
|
||||
<button class="btn btn-primary" style="height: 48px;" onclick="submitAuth()">인증 및 실행</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="activityDetailModal" class="modal-overlay" onclick="ModalManager.close('activityDetailModal')">
|
||||
<div class="modal-content" style="max-width: 600px; padding: 0; overflow: hidden;" onclick="event.stopPropagation()">
|
||||
<div class="modal-header" style="padding: 20px; margin-bottom: 0;">
|
||||
<h3 id="modalTitle">상세 목록 (TEST)</h3>
|
||||
<span class="modal-close" onclick="ModalManager.close('activityDetailModal')">×</span>
|
||||
</div>
|
||||
<div class="modal-body" style="padding: 20px; max-height: 70vh; overflow-y: auto;">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>프로젝트명</th>
|
||||
<th>담당부서</th>
|
||||
<th>담당자</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="modalTableBody">
|
||||
<!-- JS에서 동적 삽입 -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="padding: 16px 20px; border-top: 1px solid var(--border-color); text-align: right; background: #fdfdfd;">
|
||||
<button class="btn btn-secondary" onclick="ModalManager.close('activityDetailModal')">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/common.js"></script>
|
||||
<script src="js/dashboard_test.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -28,14 +28,26 @@
|
||||
<div class="button-grid">
|
||||
<a href="/dashboard" class="portal-card">
|
||||
<div class="icon">📊</div>
|
||||
<h2>관리자 페이지 테스트</h2>
|
||||
<p>관리자 페이지 테스트 입니다.</p>
|
||||
<h3>대시보드</h3>
|
||||
<p>시스템 운영 현황 및 핵심 지표 요약</p>
|
||||
</a>
|
||||
|
||||
<a href="/inquiries" class="portal-card">
|
||||
<div class="icon">📝</div>
|
||||
<h3>문의사항</h3>
|
||||
<p>프로젝트 관련 문의 및 기술 지원 관리</p>
|
||||
</a>
|
||||
|
||||
<a href="/mailTest" class="portal-card">
|
||||
<div class="icon">✉️</div>
|
||||
<h2>메일 테스트</h2>
|
||||
<p>메일 기능 테스트 페이지입니다.</p>
|
||||
<div class="icon">📧</div>
|
||||
<h3>메일관리</h3>
|
||||
<p>수집 메일 분석 및 첨부파일 분류 현황</p>
|
||||
</a>
|
||||
|
||||
<a href="/analysis" class="portal-card">
|
||||
<div class="icon">📈</div>
|
||||
<h3>분석 (Sabermetrics)</h3>
|
||||
<p>AI 기반 운영 활력 및 자산 가치 정밀 분석</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
28
verify_swvw.py
Normal file
28
verify_swvw.py
Normal file
@@ -0,0 +1,28 @@
|
||||
import pymysql
|
||||
from analysis_service import AnalysisService
|
||||
|
||||
def verify_analysis():
|
||||
conn = pymysql.connect(
|
||||
host='localhost',
|
||||
user='root',
|
||||
password='45278434',
|
||||
database='pm_proto_test',
|
||||
charset='utf8mb4',
|
||||
cursorclass=pymysql.cursors.DictCursor
|
||||
)
|
||||
try:
|
||||
with conn.cursor() as cursor:
|
||||
results = AnalysisService.get_p_zsr_analysis_logic(cursor)
|
||||
print(f"Total Projects Analyzed: {len(results)}")
|
||||
print("\n[Sample Project Analysis Result]")
|
||||
for res in results[:5]:
|
||||
print(f"Project: {res['project_nm']}")
|
||||
print(f" - Log Quality Score (Semantic): {res['log_quality']}")
|
||||
print(f" - AVI Score (p_war): {res['p_war']}")
|
||||
print(f" - OCI Score: {res['oci_score']}")
|
||||
print("-" * 30)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
verify_analysis()
|
||||
Reference in New Issue
Block a user