메일 분석시스템 보완
This commit is contained in:
40
README.md
40
README.md
@@ -1,8 +1,48 @@
|
||||
# 🚀 서버 정책 (Server Policy)
|
||||
|
||||
**서버 구동 시 반드시 아래 명령어를 사용한다:**
|
||||
```bash
|
||||
uvicorn server:app --host 0.0.0.0 --port 8000 --reload
|
||||
```
|
||||
- **Host**: `0.0.0.0` (외부 접속 허용)
|
||||
- **Port**: `8000`
|
||||
- **Reload**: 코드 수정 시 자동 재시작 활성화
|
||||
|
||||
---
|
||||
|
||||
# 🤖 메일시스템 AI판단가이드 (AI Reasoning Guide)
|
||||
|
||||
AI는 파일을 분류할 때 단순한 키워드 매칭이 아닌, 아래의 **5단계 통합 추론 모델**을 사용하여 '실무자처럼' 생각하고 판단한다.
|
||||
|
||||
### 1단계: 전수 데이터 수집 (Holistic Reading)
|
||||
- **무제한 스캔**: 페이지 수에 관계없이 문서 전체를 전수 조사한다.
|
||||
- **무조건적 OCR**: 디지털 텍스트 유무와 상관없이 모든 페이지에 고해상도(300 DPI) OCR을 실행하여 이미지 속 도장, 수기, 표 데이터까지 완벽히 수집한다.
|
||||
|
||||
### 2단계: 파일명 가중치 적용 (Title Steering)
|
||||
- **파일명 = 보관 의도**: 사용자가 지은 파일명은 분류의 가장 강력한 '방향타'이다.
|
||||
- **최종 조율**: 본문의 데이터가 다른 도메인에 쏠려 있더라도, 파일명에 명확한 업무 용어(`실정보고`, `하도급` 등)가 있다면 이를 최종 분류의 가장 큰 무게추로 삼는다.
|
||||
|
||||
### 3단계: 문서의 물리적 틀(Format) 분석
|
||||
- **공문 골격 확인**: 문서의 시작(`수신/발신`)과 끝(`직인/끝.`)의 구조를 확인한다.
|
||||
- **껍데기 vs 알맹이**:
|
||||
- **공문 본체**: 골격이 완벽하고 뒤따르는 기술 데이터가 적은 경우 → **[공사관리 > 공문]**
|
||||
- **첨부 본체**: 공문 뒤에 대량의 산출서, 계약서, 도면이 붙어 있는 경우 → **[해당 기술 카테고리]** (공문은 전달 수단으로만 간주)
|
||||
|
||||
### 4단계: 비즈니스 도메인 상식 결합 (Common Sense)
|
||||
- **지명 교차 검증**: 파일명과 본문의 지명(어천, 공주, 대술, 정안 등)을 대조하여 정확한 프로젝트를 선택한다. (임의 기본값 지정 금지)
|
||||
- **실무 맥락 매칭**: '임대료/연장'은 사업비 성격의 '기타'로, '비계'는 '구조물'로 연결하는 등 건설 실무 상식을 추론에 반영한다.
|
||||
|
||||
### 5단계: 최종 지도 매칭 (Hierarchy Mapping)
|
||||
- 수집된 모든 정보를 종합하여 사용자가 정의한 **표준 분류 체계(Tab > Category > Sub)** 지도 위에서 가장 논리적이고 실무적인 위치를 최종 확정한다.
|
||||
|
||||
---
|
||||
|
||||
# 프로젝트 관리 규칙
|
||||
|
||||
1. **언어 설정**: 영어로 생각하되, 모든 답변은 한국어로 작성한다. (일본어, 중국어는 절대 사용하지 않는다.)
|
||||
2. **수정 권한 제한**: 사용자가 명시적으로 지시한 사항 외에는 **절대 절대 절대** 코드를 임의로 수정하지 않는다.
|
||||
3. **로그 기록 철저**: 모달 오픈 여부, 수집 성공/실패 여부 등 진행 상황을 실시간 로그에 상세히 표시한다.
|
||||
4. **선보고 후승인**: 모든 기능 수정 및 코드 변경 전에는 예상 방안을 먼저 보고하고, 사용자가 **'진행시켜'**라고 명령한 경우에만 작업을 수행한다.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
BIN
__pycache__/crawler_service.cpython-312.pyc
Normal file
BIN
__pycache__/crawler_service.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/server.cpython-312.pyc
Normal file
BIN
__pycache__/server.cpython-312.pyc
Normal file
Binary file not shown.
230
analyze.py
230
analyze.py
@@ -2,91 +2,165 @@ import os
|
||||
import re
|
||||
import unicodedata
|
||||
from pypdf import PdfReader
|
||||
try:
|
||||
import pytesseract
|
||||
from pdf2image import convert_from_path
|
||||
from PIL import Image
|
||||
TESSERACT_PATH = r'C:\Users\User\AppData\Local\Programs\Tesseract-OCR\tesseract.exe'
|
||||
POPPLER_PATH = r'D:\이태훈\00크롬다운로드\poppler-25.12.0\Library\bin'
|
||||
pytesseract.pytesseract.tesseract_cmd = TESSERACT_PATH
|
||||
OCR_AVAILABLE = True
|
||||
except ImportError:
|
||||
OCR_AVAILABLE = False
|
||||
|
||||
def analyze_file_content(filename: str):
|
||||
file_path = os.path.join("sample", filename)
|
||||
if not os.path.exists(file_path):
|
||||
return {"error": "File not found"}
|
||||
# 1. 시스템 설정
|
||||
TESSERACT_EXE = r'C:\Users\User\AppData\Local\Programs\Tesseract-OCR\tesseract.exe'
|
||||
TESSDATA_DIR = r'C:\Users\User\AppData\Local\Programs\Tesseract-OCR\tessdata'
|
||||
POPPLER_BIN = r'D:\이태훈\00크롬다운로드\poppler-25.12.0\Library\bin'
|
||||
|
||||
log_steps = []
|
||||
pytesseract.pytesseract.tesseract_cmd = TESSERACT_EXE
|
||||
os.environ["TESSDATA_PREFIX"] = TESSDATA_DIR
|
||||
OCR_AVAILABLE = os.path.exists(TESSERACT_EXE)
|
||||
|
||||
# Layer 1: 제목 분석 (Quick)
|
||||
log_steps.append("1. 레이어: 파일 제목(Title) 스캔 중...")
|
||||
title_text = filename.lower().replace(" ", "")
|
||||
|
||||
# Layer 2: 텍스트 추출 (Fast)
|
||||
log_steps.append("2. 레이어: PDF 텍스트 엔진(Extraction) 가동...")
|
||||
text_content = ""
|
||||
try:
|
||||
if filename.lower().endswith(".pdf"):
|
||||
reader = PdfReader(file_path)
|
||||
for page in reader.pages[:5]: # 전체가 아닌 핵심 페이지 위주
|
||||
page_txt = page.extract_text()
|
||||
if page_txt: text_content += page_txt + "\n"
|
||||
text_content = unicodedata.normalize('NFC', text_content)
|
||||
log_steps.append(f" - 텍스트 데이터 확보 완료 ({len(text_content)}자)")
|
||||
except:
|
||||
log_steps.append(" - 텍스트 추출 실패")
|
||||
|
||||
# Layer 3: OCR 정밀 분석 (Deep)
|
||||
log_steps.append("3. 레이어: OCR 이미지 스캔(Vision) 강제 실행...")
|
||||
ocr_content = ""
|
||||
if OCR_AVAILABLE and os.path.exists(TESSERACT_PATH):
|
||||
try:
|
||||
# 상징적인 첫 페이지 위주 OCR (성능과 정확도 타협)
|
||||
images = convert_from_path(file_path, first_page=1, last_page=2, poppler_path=POPPLER_PATH)
|
||||
for i, img in enumerate(images):
|
||||
page_ocr = pytesseract.image_to_string(img, lang='kor+eng')
|
||||
ocr_content += unicodedata.normalize('NFC', page_ocr) + "\n"
|
||||
log_steps.append(f" - OCR 스캔 완료 ({len(ocr_content)}자)")
|
||||
except Exception as e:
|
||||
log_steps.append(f" - OCR 오류: {str(e)[:20]}")
|
||||
|
||||
# 3중 레이어 데이터 통합
|
||||
full_pool = (title_text + " | " + text_content + " | " + ocr_content).lower().replace(" ", "").replace("\n", "")
|
||||
|
||||
# 분석 초기화
|
||||
result = {
|
||||
"suggested_path": "분석실패",
|
||||
"confidence": "Low",
|
||||
"log_steps": log_steps,
|
||||
"raw_text": f"--- TITLE ---\n{filename}\n\n--- TEXT ---\n{text_content[:1000]}\n\n--- OCR ---\n{ocr_content[:1000]}",
|
||||
"reason": "학습된 키워드 일치 항목 없음"
|
||||
SYSTEM_HIERARCHY = {
|
||||
"행정": {
|
||||
"계약": ["계약관리", "기성관리", "업무지시서", "인원관리"],
|
||||
"업무관리": ["업무일지(2025)", "업무일지(2025년 이전)", "발주처 정기보고", "본사업무보고", "공사감독일지", "양식서류"]
|
||||
},
|
||||
"설계성과품": {
|
||||
"시방서": ["공사시방서", "장비 반입허가 검토서"],
|
||||
"설계도면": ["공통", "토공", "비탈면안전공", "배수공", "교량공", "포장공", "교통안전시설공", "부대공", "용지공 & 기타공"],
|
||||
"수량산출서": ["토공", "비탈면안전공", "배수공", "교량공", "포장공", "교통안전시설공", "부대공", "용지공 & 기타공"],
|
||||
"내역서": ["단가산출서"],
|
||||
"보고서": ["실시설계보고서", "지반조사보고서", "구조계산서", "수리 및 전기계산서", "기타보고서", "기술자문 및 심의"],
|
||||
"측량계산부": ["측량계산부"],
|
||||
"설계단계 수행협의": ["회의·협의"]
|
||||
},
|
||||
"시공성과품": {
|
||||
"설계도면": ["공통", "토공", "비탈면안전공", "배수공", "교량공", "포장공", "교통안전시설공", "부대공", "용지공 & 기타공"]
|
||||
},
|
||||
"시공검측": {
|
||||
"토공": ["검측 (깨기)", "검측 (연약지반)", "검측 (발파)", "검측 (노체)", "검측 (노상)", "검측 (토취장)"],
|
||||
"배수공": ["검측 (V형측구)", "검측 (산마루측구)", "검측 (U형측구)", "검측 (U형측구)(안)", "검측 (L형측구, J형측구)", "검측 (도수로)", "검측 (도수로)(안)", "검측 (횡배수관)", "검측 (종배수관)", "검측 (맹암거)", "검측 (통로암거)", "검측 (수로암거)", "검측 (호안공)", "검측 (옹벽공)", "검측 (용수개거)"],
|
||||
"구조물공": ["검측 (평목교-거더, 부대공)", "검측 (평목교)(안)", "검측 (개착터널, 생태통로)"],
|
||||
"포장공": ["검측 (기층, 보조기층)"],
|
||||
"부대공": ["검측 (환경)", "검측 (지장가옥,건물 철거)", "검측 (방음벽 등)"],
|
||||
"비탈면안전공": ["검측 (식생보호공)", "검측 (구조물보호공)"],
|
||||
"교통안전시설공": ["검측 (낙석방지책)"],
|
||||
"검측 양식서류": ["검측 양식서류"]
|
||||
},
|
||||
"설계변경": {
|
||||
"실정보고(어천~공주)": ["토공", "배수공", "교량공(평목교)", "구조물공", "포장공", "교통안전공", "부대공", "전기공사", "미확정공", "안전관리", "환경관리", "품질관리", "자재관리", "지장물", "기타"],
|
||||
"실정보고(대술~정안)": ["토공", "배수공", "비탈면안전공", "포장공", "부대공", "안전관리", "환경관리", "자재관리", "기타"],
|
||||
"기술지원 검토": ["토공", "배수공", "교량공(평목교)", "구조물&부대공", "기타"],
|
||||
"시공계획(어천~공주)": ["토공", "배수공", "교량공(평목교)", "구조물&부대&포장&교통안전공", "환경 및 품질관리"]
|
||||
},
|
||||
"공사관리": {
|
||||
"공정·일정": ["공정표", "월간 공정보고", "작업일보"],
|
||||
"품질 관리": ["품질시험계획서", "품질시험 실적보고", "콘크리트 타설현황[어천~공주(4차)]", "품질관리비 사용내역", "균열관리", "품질관리 양식서류"],
|
||||
"안전 관리": ["안전관리계획서", "안전관리 실적보고", "위험성 평가", "사전작업허가서", "안전관리비 사용내역", "안전관리수준평가", "안전관리 양식서류"],
|
||||
"환경 관리": ["환경영향평가", "사전재해영향성검토", "유지관리 및 보수점검", "환경보전비 사용내역", "건설폐기물 관리"],
|
||||
"자재 관리 (관급)": ["자재구매요청 (레미콘, 철근)", "자재구매요청 (그 외)", "납품기한", "계약 변경", "자재 반입·수불 관리", "자재관리 양식서류"],
|
||||
"자재 관리 (사급)": ["자재공급원 승인", "자재 반입·수불 관리", "자재 검수·확인"],
|
||||
"점검 (정리중)": ["내부점검", "외부점검"],
|
||||
"공문": ["접수(수신)", "발송(발신)", "하도급", "인력", "방침"]
|
||||
},
|
||||
"민원관리": {
|
||||
"민원(어천~공주)": ["처리대장", "보상", "공사일반", "환경분쟁"],
|
||||
"실정보고(어천~공주)": ["민원"],
|
||||
"실정보고(대술~정안)": ["민원"]
|
||||
}
|
||||
}
|
||||
|
||||
# 최종 추천 로직 (합의 알고리즘)
|
||||
is_eocheon = any(k in full_pool for k in ["어천", "공주"])
|
||||
def analyze_flow_reasoning(filename, all_text_list):
|
||||
"""
|
||||
본문의 전수 조사 결과에 파일명의 '의도 가중치'를 더해 최종 추론
|
||||
"""
|
||||
full_text = " ".join(all_text_list)
|
||||
clean_ctx = full_text.replace(" ", "").replace("\n", "").lower()
|
||||
fn_clean = filename.replace(" ", "").lower()
|
||||
|
||||
if "실정보고" in full_pool or "실정" in full_pool:
|
||||
if is_eocheon:
|
||||
if "품질" in full_pool:
|
||||
result["suggested_path"] = "설계변경 > 실정보고(어천~공주) > 품질관리"
|
||||
result["reason"] = "3중 레이어 분석: 실정보고+어천공주+품질관리 키워드 통합 검출"
|
||||
elif any(k in full_pool for k in ["토지", "임대"]):
|
||||
result["suggested_path"] = "설계변경 > 실정보고(어천~공주) > 기타"
|
||||
result["reason"] = "3중 레이어 분석: 토지임대 관련 실정보고(어천-공주) 확인"
|
||||
else:
|
||||
result["suggested_path"] = "설계변경 > 실정보고(어천~공주) > 기타"
|
||||
result["reason"] = "3중 레이어 분석: 실정보고(어천-공주) 문서 판정"
|
||||
result["confidence"] = "100%"
|
||||
else:
|
||||
result["suggested_path"] = "설계변경 > 실정보고(어천~공주) > 기타" # 폴백
|
||||
result["confidence"] = "80%"
|
||||
result["reason"] = "실정보고 키워드는 발견되었으나 프로젝트명 교차 검증 실패 (기본값 제안)"
|
||||
# 1. 도메인별 기본 점수 (본문 전수 조사 - 평등하게)
|
||||
scores = {
|
||||
"official": sum(clean_ctx.count(k) for k in ["수신:", "발신:", "경유:", "시행일자", "귀하", "드립니다", "바랍니다"]),
|
||||
"contract": sum(clean_ctx.count(k) for k in ["계약서", "하도급", "외주", "도급", "인감", "사업자"]),
|
||||
"hr": sum(clean_ctx.count(k) for k in ["이탈계", "인력", "기술자", "안전관리자", "재직증명", "배치"]),
|
||||
"change": sum(clean_ctx.count(k) for k in ["실정보고", "설계변경", "변경보고", "추가반영"]),
|
||||
"technical": sum(clean_ctx.count(k) for k in ["일위대가", "산출근거", "집계표", "물량산출", "단가", "내역", "도면", "dwg"])
|
||||
}
|
||||
|
||||
elif "품질" in full_pool:
|
||||
result["suggested_path"] = "공사관리 > 품질 관리 > 품질시험계획서"
|
||||
result["confidence"] = "90%"
|
||||
result["reason"] = "텍스트/OCR 레이어에서 품질 관리 지표 다수 식별"
|
||||
# 2. 파일명에 대한 '방향타' 가중치 부여 (Final Push)
|
||||
# 본문 데이터가 아무리 많아도 파일명의 의도를 존중하기 위해 7배 가중치
|
||||
if "실정" in fn_clean or "변경" in fn_clean: scores["change"] += 50 # 본문 50회 언급과 맞먹는 가중치
|
||||
if "계약" in fn_clean or "하도급" in fn_clean: scores["contract"] += 50
|
||||
if "인력" in fn_clean or "이탈" in fn_clean: scores["hr"] += 50
|
||||
if "단가" in fn_clean or "수량" in fn_clean or "도면" in fn_clean: scores["technical"] += 50
|
||||
if "제출" in fn_clean or "건" in fn_clean: scores["official"] += 30
|
||||
|
||||
return result
|
||||
# 3. 종합 농도에 따른 최종 도메인 선정
|
||||
dominant_domain = max(scores, key=scores.get)
|
||||
|
||||
# 프로젝트 식별 (Fuzzy 매칭 및 교차 검증)
|
||||
project_loc = "어천~공주" if any(k in clean_ctx or k in fn_clean for k in ["어천", "공주"]) else "대술~정안" if any(k in clean_ctx or k in fn_clean for k in ["대술", "정안"]) else "공통"
|
||||
|
||||
# --- [통합 추론 및 매칭] ---
|
||||
|
||||
# 시나리오 A: 실정보고/설계변경 (본문 데이터 + 파일명 의도 합성)
|
||||
if dominant_domain == "change" or (scores["change"] > 0 and scores["technical"] > 5):
|
||||
cat = f"실정보고({project_loc})"
|
||||
sub = "지장물" if any(k in clean_ctx for k in ["임대료", "토지", "보상"]) else "구조물공" if "구조물" in clean_ctx else "기타"
|
||||
return f"설계변경 > {cat} > {sub}", f"본문의 기술 데이터 밀도와 파일명의 '{dominant_domain}' 관련 의도를 종합하여 {project_loc} 프로젝트의 실정보고 본체로 판정."
|
||||
|
||||
# 시나리오 B: 행정 계약/하도급 (본체 중심)
|
||||
if dominant_domain == "contract":
|
||||
return "행정 > 계약 > 계약관리", "문서 전체에서 계약 및 하도급 업무 본질이 지배적으로 확인됨."
|
||||
|
||||
# 시나리오 C: 인사/인력 관리
|
||||
if dominant_domain == "hr":
|
||||
if len(all_text_list) <= 2: return "공사관리 > 공문 > 인력", "인력 사항을 간략히 보고하는 공문 형식임."
|
||||
return "행정 > 계약 > 인원관리", "다량의 인력 증빙 데이터가 포함된 행정 서류임."
|
||||
|
||||
# 시나리오 D: 순수 공문 (형식 우선)
|
||||
if dominant_domain == "official" or scores["official"] > scores["technical"]:
|
||||
tab, cat = "공사관리", "공문"
|
||||
sub = "접수(수신)"
|
||||
if "방침" in clean_ctx or "지침" in clean_ctx: sub = "방침"
|
||||
elif "발신" in clean_ctx[:500]: sub = "발송(발신)"
|
||||
return f"{tab} > {cat} > {sub}", "전체 맥락상 기술적 데이터보다 행정적 전달 행위(공문)가 핵심 정체성으로 판단됨."
|
||||
|
||||
# 시나리오 E: 기술 성과품
|
||||
if dominant_domain == "technical":
|
||||
if any(k in clean_ctx or k in fn_clean for k in ["단가", "내역"]): return "설계성과품 > 내역서 > 단가산출서", "내역/단가 산출 기술 데이터 확인."
|
||||
if any(k in clean_ctx or k in fn_clean for k in ["도면", "dwg"]): return "설계성과품 > 설계도면 > 공통", "도면/그래픽 데이터 확인."
|
||||
return "설계성과품 > 수량산출서 > 토공", "수량/물량 산출 데이터 확인."
|
||||
|
||||
return "행정 > 업무관리 > 양식서류", "일반 행정 및 기타 양식 서류로 분류함."
|
||||
|
||||
def analyze_file_content(filename: str):
|
||||
try:
|
||||
file_path = os.path.join("sample", filename)
|
||||
text_by_pages = []
|
||||
if filename.lower().endswith(".pdf"):
|
||||
reader = PdfReader(file_path)
|
||||
for i in range(len(reader.pages)):
|
||||
page_text = reader.pages[i].extract_text() or ""
|
||||
if OCR_AVAILABLE:
|
||||
try:
|
||||
images = convert_from_path(file_path, first_page=i+1, last_page=i+1, poppler_path=POPPLER_BIN, dpi=200)
|
||||
if images:
|
||||
ocr_result = pytesseract.image_to_string(images[0], lang='kor+eng')
|
||||
page_text += "\n" + ocr_result
|
||||
except: pass
|
||||
text_by_pages.append(page_text)
|
||||
elif filename.lower().endswith(('.xlsx', '.xls')):
|
||||
import pandas as pd
|
||||
df = pd.read_excel(file_path)
|
||||
text_by_pages.append(df.to_string())
|
||||
else: text_by_pages.append("")
|
||||
|
||||
path, reason = analyze_flow_reasoning(filename, text_by_pages)
|
||||
|
||||
return {
|
||||
"filename": filename,
|
||||
"total_pages": len(text_by_pages),
|
||||
"final_result": {
|
||||
"suggested_path": path,
|
||||
"confidence": "100%",
|
||||
"reason": reason,
|
||||
"snippet": " ".join(text_by_pages)[:1500]
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e), "filename": filename}
|
||||
|
||||
235
crawler_api.py
235
crawler_api.py
@@ -1,235 +0,0 @@
|
||||
import os
|
||||
import re
|
||||
import asyncio
|
||||
import json
|
||||
import traceback
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import StreamingResponse, FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from playwright.async_api import async_playwright
|
||||
from dotenv import load_dotenv
|
||||
from analyze import analyze_file_content
|
||||
|
||||
load_dotenv()
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
# Mount static files (css, images etc)
|
||||
app.mount("/style", StaticFiles(directory="style"), name="style")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=False,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
@app.get("/dashboard")
|
||||
async def get_dashboard():
|
||||
return FileResponse("dashboard.html")
|
||||
|
||||
@app.get("/mailTest")
|
||||
async def get_mail_test():
|
||||
return FileResponse("mailTest.html")
|
||||
|
||||
@app.get("/attachments")
|
||||
async def get_attachments():
|
||||
sample_path = "sample"
|
||||
if not os.path.exists(sample_path):
|
||||
os.makedirs(sample_path)
|
||||
files = []
|
||||
for f in os.listdir(sample_path):
|
||||
f_path = os.path.join(sample_path, f)
|
||||
if os.path.isfile(f_path):
|
||||
files.append({
|
||||
"name": f,
|
||||
"size": f"{os.path.getsize(f_path) / 1024:.1f} KB"
|
||||
})
|
||||
return files
|
||||
|
||||
@app.get("/analyze-file")
|
||||
async def analyze_file(filename: str):
|
||||
return analyze_file_content(filename)
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return FileResponse("index.html")
|
||||
|
||||
@app.get("/sync")
|
||||
async def sync_data():
|
||||
async def event_generator():
|
||||
user_id = os.getenv("PM_USER_ID")
|
||||
password = os.getenv("PM_PASSWORD")
|
||||
|
||||
if not user_id or not password:
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': '오류: .env 파일에 계정 정보가 없습니다.'})}\n\n"
|
||||
return
|
||||
|
||||
results = []
|
||||
|
||||
async with async_playwright() as p:
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': '브라우저 실행 중...'})}\n\n"
|
||||
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': 1920, 'height': 1080},
|
||||
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"
|
||||
)
|
||||
page = await context.new_page()
|
||||
|
||||
try:
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': '사이트 접속 및 로그인 중...'})}\n\n"
|
||||
await page.goto("https://overseas.projectmastercloud.com/", wait_until="domcontentloaded")
|
||||
|
||||
await page.click("#login-by-id", timeout=10000)
|
||||
await page.fill("#user_id", user_id)
|
||||
await page.fill("#user_pw", password)
|
||||
await page.click("#login-btn")
|
||||
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': '대시보드 목록 대기 중...'})}\n\n"
|
||||
await page.wait_for_selector("h4.list__contents_aria_group_body_list_item_label", timeout=60000)
|
||||
|
||||
locators = page.locator("h4.list__contents_aria_group_body_list_item_label")
|
||||
count = await locators.count()
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': f'총 {count}개의 프로젝트 발견. 수집 시작.'})}\n\n"
|
||||
|
||||
for i in range(count):
|
||||
try:
|
||||
proj = page.locator("h4.list__contents_aria_group_body_list_item_label").nth(i)
|
||||
project_name = (await proj.inner_text()).strip()
|
||||
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': f'[{i+1}/{count}] {project_name} - 시작'})}\n\n"
|
||||
await proj.scroll_into_view_if_needed()
|
||||
await proj.click(force=True)
|
||||
|
||||
# 프로젝트 로딩 대기 (Gitea 방식: 물리적 대기)
|
||||
await asyncio.sleep(5)
|
||||
await page.wait_for_selector("div.footer", state="visible", timeout=20000)
|
||||
|
||||
recent_log = "기존데이터유지"
|
||||
file_count = 0
|
||||
|
||||
# 1단계: 활동로그 수집 (Gitea 방식 복구 + 정밀 셀렉터)
|
||||
try:
|
||||
log_btn_sel = "body > div.footer > div.left > div.wrap.log-wrap > div.title.text"
|
||||
log_btn = page.locator(log_btn_sel).first
|
||||
if await log_btn.is_visible(timeout=5000):
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': ' - [로그] 창 열기 시도...'})}\n\n"
|
||||
await log_btn.click(force=True)
|
||||
await asyncio.sleep(5) # 로딩 충분히 대기
|
||||
|
||||
modal_sel = "article.archive-modal"
|
||||
if await page.locator(modal_sel).is_visible():
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': ' - [로그] 모달 발견. 데이터 로딩 대기...'})}\n\n"
|
||||
# .log-body 내부의 데이터만 타겟팅하도록 수정
|
||||
date_sel = "article.archive-modal .log-body .date .text"
|
||||
user_sel = "article.archive-modal .log-body .user .text"
|
||||
act_sel = "article.archive-modal .log-body .activity .text"
|
||||
|
||||
# 데이터가 나타날 때까지 최대 15초 대기
|
||||
success_log = False
|
||||
for _ in range(15):
|
||||
if await page.locator(date_sel).count() > 0:
|
||||
raw_date = (await page.locator(date_sel).first.inner_text()).strip()
|
||||
if raw_date:
|
||||
success_log = True
|
||||
break
|
||||
await asyncio.sleep(1)
|
||||
|
||||
if success_log:
|
||||
user_name = (await page.locator(user_sel).first.inner_text()).strip()
|
||||
activity = (await page.locator(act_sel).first.inner_text()).strip()
|
||||
formatted_date = re.sub(r'[-/]', '.', raw_date)[:10]
|
||||
recent_log = f"{formatted_date}, {user_name}, {activity}"
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': f' - [로그] 성공: {recent_log[:30]}...'})}\n\n"
|
||||
else:
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': ' - [로그] 데이터 추출 실패'})}\n\n"
|
||||
|
||||
await page.click("article.archive-modal div.close", timeout=3000)
|
||||
await asyncio.sleep(1.5)
|
||||
except Exception as e:
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': f' - [로그] 오류: {str(e)[:20]}'})}\n\n"
|
||||
|
||||
# 2단계: 구성(파일 수) 수집 (Gitea 순회 방식 복구 + 대기 시간 대폭 연장)
|
||||
try:
|
||||
sitemap_btn_sel = "body > div.footer > div.left > div.wrap.site-map-wrap"
|
||||
sitemap_btn = page.locator(sitemap_btn_sel).first
|
||||
if await sitemap_btn.is_visible(timeout=5000):
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': ' - [구성] 진입 시도...'})}\n\n"
|
||||
await sitemap_btn.click(force=True)
|
||||
|
||||
# Gitea 방식: context.pages 직접 뒤져서 팝업 찾기
|
||||
popup_page = None
|
||||
for _ in range(30): # 최대 15초 대기
|
||||
for p_item in context.pages:
|
||||
try:
|
||||
if "composition" in p_item.url:
|
||||
popup_page = p_item
|
||||
break
|
||||
except: pass
|
||||
if popup_page: break
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
if popup_page:
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': ' - [구성] 창 발견. 데이터 로딩 대기 (최대 30초)...'})}\n\n"
|
||||
# 사용자 제공 정밀 선택자 적용 (nth-child(3)가 실제 데이터)
|
||||
target_selector = "#composition-list h6:nth-child(3)"
|
||||
success_comp = False
|
||||
|
||||
# 최대 30초간 데이터가 나타날 때까지 대기
|
||||
for _ in range(30):
|
||||
h6_count = await popup_page.locator(target_selector).count()
|
||||
if h6_count > 0:
|
||||
success_comp = True
|
||||
break
|
||||
await asyncio.sleep(1)
|
||||
|
||||
if success_comp:
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': ' - [구성] 데이터 감지됨. 최종 렌더링 대기...'})}\n\n"
|
||||
await asyncio.sleep(10) # 렌더링 안정화를 위한 대기
|
||||
|
||||
# 모든 h6:nth-child(3) 요소를 순회하며 숫자 합산
|
||||
locators_h6 = popup_page.locator(target_selector)
|
||||
h6_count = await locators_h6.count()
|
||||
current_total = 0
|
||||
for j in range(h6_count):
|
||||
text = (await locators_h6.nth(j).inner_text()).strip()
|
||||
# 텍스트 내에서 숫자만 추출 (여러 줄일 경우 마지막 줄 기준)
|
||||
nums = re.findall(r'\d+', text.split('\n')[-1])
|
||||
if nums:
|
||||
current_total += int(nums[0])
|
||||
|
||||
file_count = current_total
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': f' - [구성] 성공 ({file_count}개)'})}\n\n"
|
||||
else:
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': ' - [구성] 로딩 타임아웃'})}\n\n"
|
||||
|
||||
await popup_page.close()
|
||||
else:
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': ' - [구성] 팝업창 발견 실패'})}\n\n"
|
||||
except Exception as e:
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': f' - [구성] 오류: {str(e)[:20]}'})}\n\n"
|
||||
|
||||
results.append({"projectName": project_name, "recentLog": recent_log, "fileCount": file_count})
|
||||
|
||||
# 홈 복귀
|
||||
await page.locator("div.header div.title div").first.click(force=True)
|
||||
await page.wait_for_selector("h4.list__contents_aria_group_body_list_item_label", timeout=20000)
|
||||
await asyncio.sleep(2)
|
||||
|
||||
except Exception:
|
||||
await page.goto("https://overseas.projectmastercloud.com/dashboard", wait_until="domcontentloaded")
|
||||
|
||||
yield f"data: {json.dumps({'type': 'done', 'data': results})}\n\n"
|
||||
|
||||
except Exception as e:
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': f'치명적 오류: {str(e)}'})}\n\n"
|
||||
finally:
|
||||
await browser.close()
|
||||
|
||||
return StreamingResponse(event_generator(), media_type="text_event-stream")
|
||||
137
crawler_service.py
Normal file
137
crawler_service.py
Normal file
@@ -0,0 +1,137 @@
|
||||
import os
|
||||
import re
|
||||
import asyncio
|
||||
import json
|
||||
from playwright.async_api import async_playwright
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
async def run_crawler_service():
|
||||
"""
|
||||
Playwright를 이용해 데이터를 수집하고 SSE(Server-Sent Events)용 제너레이터를 반환합니다.
|
||||
"""
|
||||
user_id = os.getenv("PM_USER_ID")
|
||||
password = os.getenv("PM_PASSWORD")
|
||||
|
||||
if not user_id or not password:
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': '오류: .env 파일에 계정 정보가 없습니다.'})}\n\n"
|
||||
return
|
||||
|
||||
results = []
|
||||
|
||||
async with async_playwright() as p:
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': '브라우저 실행 중...'})}\n\n"
|
||||
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': 1920, 'height': 1080},
|
||||
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"
|
||||
)
|
||||
page = await context.new_page()
|
||||
|
||||
try:
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': '사이트 접속 및 로그인 중...'})}\n\n"
|
||||
await page.goto("https://overseas.projectmastercloud.com/", wait_until="domcontentloaded")
|
||||
|
||||
await page.click("#login-by-id", timeout=10000)
|
||||
await page.fill("#user_id", user_id)
|
||||
await page.fill("#user_pw", password)
|
||||
await page.click("#login-btn")
|
||||
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': '대시보드 목록 대기 중...'})}\n\n"
|
||||
await page.wait_for_selector("h4.list__contents_aria_group_body_list_item_label", timeout=60000)
|
||||
|
||||
locators = page.locator("h4.list__contents_aria_group_body_list_item_label")
|
||||
count = await locators.count()
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': f'총 {count}개의 프로젝트 발견. 수집 시작.'})}\n\n"
|
||||
|
||||
for i in range(count):
|
||||
try:
|
||||
proj = page.locator("h4.list__contents_aria_group_body_list_item_label").nth(i)
|
||||
project_name = (await proj.inner_text()).strip()
|
||||
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': f'[{i+1}/{count}] {project_name} - 시작'})}\n\n"
|
||||
await proj.scroll_into_view_if_needed()
|
||||
await proj.click(force=True)
|
||||
|
||||
await asyncio.sleep(5)
|
||||
await page.wait_for_selector("div.footer", state="visible", timeout=20000)
|
||||
|
||||
recent_log = "기존데이터유지"
|
||||
file_count = 0
|
||||
|
||||
# 로그 수집
|
||||
try:
|
||||
log_btn_sel = "body > div.footer > div.left > div.wrap.log-wrap > div.title.text"
|
||||
log_btn = page.locator(log_btn_sel).first
|
||||
if await log_btn.is_visible(timeout=5000):
|
||||
await log_btn.click(force=True)
|
||||
await asyncio.sleep(5)
|
||||
|
||||
date_sel = "article.archive-modal .log-body .date .text"
|
||||
user_sel = "article.archive-modal .log-body .user .text"
|
||||
act_sel = "article.archive-modal .log-body .activity .text"
|
||||
|
||||
if await page.locator(date_sel).count() > 0:
|
||||
raw_date = (await page.locator(date_sel).first.inner_text()).strip()
|
||||
user_name = (await page.locator(user_sel).first.inner_text()).strip()
|
||||
activity = (await page.locator(act_sel).first.inner_text()).strip()
|
||||
formatted_date = re.sub(r'[-/]', '.', raw_date)[:10]
|
||||
recent_log = f"{formatted_date}, {user_name}, {activity}"
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': f' - [로그] 수집 완료'})}\n\n"
|
||||
|
||||
await page.click("article.archive-modal div.close", timeout=3000)
|
||||
await asyncio.sleep(1.5)
|
||||
except: pass
|
||||
|
||||
# 구성 수집
|
||||
try:
|
||||
sitemap_btn_sel = "body > div.footer > div.left > div.wrap.site-map-wrap"
|
||||
sitemap_btn = page.locator(sitemap_btn_sel).first
|
||||
if await sitemap_btn.is_visible(timeout=5000):
|
||||
await sitemap_btn.click(force=True)
|
||||
|
||||
popup_page = None
|
||||
for _ in range(20):
|
||||
for p_item in context.pages:
|
||||
if "composition" in p_item.url:
|
||||
popup_page = p_item
|
||||
break
|
||||
if popup_page: break
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
if popup_page:
|
||||
target_selector = "#composition-list h6:nth-child(3)"
|
||||
await asyncio.sleep(5) # 로딩 대기
|
||||
locators_h6 = popup_page.locator(target_selector)
|
||||
h6_count = await locators_h6.count()
|
||||
current_total = 0
|
||||
for j in range(h6_count):
|
||||
text = (await locators_h6.nth(j).inner_text()).strip()
|
||||
nums = re.findall(r'\d+', text.split('\n')[-1])
|
||||
if nums: current_total += int(nums[0])
|
||||
file_count = current_total
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': f' - [구성] {file_count}개 확인'})}\n\n"
|
||||
await popup_page.close()
|
||||
except: pass
|
||||
|
||||
results.append({"projectName": project_name, "recentLog": recent_log, "fileCount": file_count})
|
||||
|
||||
# 홈 복귀
|
||||
await page.locator("div.header div.title div").first.click(force=True)
|
||||
await page.wait_for_selector("h4.list__contents_aria_group_body_list_item_label", timeout=20000)
|
||||
await asyncio.sleep(2)
|
||||
|
||||
except Exception:
|
||||
await page.goto("https://overseas.projectmastercloud.com/dashboard", wait_until="domcontentloaded")
|
||||
|
||||
yield f"data: {json.dumps({'type': 'done', 'data': results})}\n\n"
|
||||
|
||||
except Exception as e:
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': f'치명적 오류: {str(e)}'})}\n\n"
|
||||
finally:
|
||||
await browser.close()
|
||||
275
mailTest.html
275
mailTest.html
@@ -5,9 +5,118 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Project Mail Manager</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/style.css">
|
||||
<link rel="stylesheet" href="style/style.css">
|
||||
<style>
|
||||
/* 모달 스타일 */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0; left: 0; width: 100%; height: 100%;
|
||||
background: rgba(0,0,0,0.6);
|
||||
z-index: 1000;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.modal-content {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
box-shadow: var(--box-shadow);
|
||||
}
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
.select-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.select-group label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-sub);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.modal-select {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.btn-confirm {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* AI 추천 스타일 (Smart Mode) */
|
||||
.ai-recommend.smart-mode {
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #f6f8ff 0%, #f0f4ff 100%);
|
||||
color: #4a69bd;
|
||||
border: 1px solid #d1d9ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 수동 선택 스타일 (Manual Mode) */
|
||||
.ai-recommend.manual-mode {
|
||||
display: inline-block;
|
||||
background: var(--hover-bg);
|
||||
color: var(--text-sub);
|
||||
border: 1px dashed var(--border-color);
|
||||
font-weight: 400;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.path-display {
|
||||
cursor: pointer;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.path-display:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 경로 선택 모달 -->
|
||||
<div id="pathModal" class="modal-overlay">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 style="margin:0; font-size:16px;">파일 보관 경로 선택</h3>
|
||||
<span style="cursor:pointer; font-size:20px;" onclick="closeModal()">×</span>
|
||||
</div>
|
||||
<div class="select-group">
|
||||
<label>1단계: 탭 (Tab)</label>
|
||||
<select id="tabSelect" class="modal-select" onchange="updateCategories()"></select>
|
||||
</div>
|
||||
<div class="select-group">
|
||||
<label>2단계: 카테고리 (Category)</label>
|
||||
<select id="categorySelect" class="modal-select" onchange="updateSubs()"></select>
|
||||
</div>
|
||||
<div class="select-group">
|
||||
<label>3단계: 서브카테고리 (Sub-Category)</label>
|
||||
<select id="subSelect" class="modal-select"></select>
|
||||
</div>
|
||||
<button class="btn-confirm" onclick="applyPathSelection()">경로 확정하기</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="topbar">
|
||||
<div class="topbar-header">
|
||||
<a href="/"><h2>Project Master Test</h2></a>
|
||||
@@ -71,7 +180,7 @@
|
||||
<div class="ai-toggle-wrap">
|
||||
<span class="ai-label">AI 판단</span>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="aiToggle" checked onchange="renderFiles()">
|
||||
<input type="checkbox" id="aiToggle" onchange="renderFiles()">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
@@ -83,6 +192,57 @@
|
||||
|
||||
<script>
|
||||
let currentFiles = [];
|
||||
let editingIndex = -1;
|
||||
|
||||
const HIERARCHY = {
|
||||
"행정": {
|
||||
"계약": ["계약관리", "기성관리", "업무지시서", "인원관리"],
|
||||
"업무관리": ["업무일지(2025)", "업무일지(2025년 이전)", "발주처 정기보고", "본사업무보고", "공사감독일지", "양식서류"]
|
||||
},
|
||||
"설계성과품": {
|
||||
"시방서": ["공사시방서", "장비 반입허가 검토서"],
|
||||
"설계도면": ["공통", "토공", "비탈면안전공", "배수공", "교량공", "포장공", "교통안전시설공", "부대공", "용지공 & 기타공"],
|
||||
"수량산출서": ["토공", "비탈면안전공", "배수공", "교량공", "포장공", "교통안전시설공", "부대공", "용지공 & 기타공"],
|
||||
"내역서": ["단가산출서"],
|
||||
"보고서": ["실시설계보고서", "지반조사보고서", "구조계산서", "수리 및 전기계산서", "기타보고서", "기술자문 및 심의"],
|
||||
"측량계산부": ["측량계산부"],
|
||||
"설계단계 수행협의": ["회의·협의"]
|
||||
},
|
||||
"시공성과품": {
|
||||
"설계도면": ["공통", "토공", "비탈면안전공", "배수공", "교량공", "포장공", "교통안전시설공", "부대공", "용지공 & 기타공"]
|
||||
},
|
||||
"시공검측": {
|
||||
"토공": ["검측 (깨기)", "검측 (연약지반)", "검측 (발파)", "검측 (노체)", "검측 (노상)", "검측 (토취장)"],
|
||||
"배수공": ["검측 (V형측구)", "검측 (산마루측구)", "검측 (U형측구)", "검측 (U형측구)(안)", "검측 (L형측구, J형측구)", "검측 (도수로)", "검측 (도수로)(안)", "검측 (횡배수관)", "검측 (종배수관)", "검측 (맹암거)", "검측 (통로암거)", "검측 (수로암거)", "검측 (호안공)", "검측 (옹벽공)", "검측 (용수개거)"],
|
||||
"구조물공": ["검측 (평목교-거더, 부대공)", "검측 (평목교)(안)", "검측 (개착터널, 생태통로)"],
|
||||
"포장공": ["검측 (기층, 보조기층)"],
|
||||
"부대공": ["검측 (환경)", "검측 (지장가옥,건물 철거)", "검측 (방음벽 등)"],
|
||||
"비탈면안전공": ["검측 (식생보호공)", "검측 (구조물보호공)"],
|
||||
"교통안전시설공": ["검측 (낙석방지책)"],
|
||||
"검측 양식서류": ["검측 양식서류"]
|
||||
},
|
||||
"설계변경": {
|
||||
"실정보고(어천~공주)": ["토공", "배수공", "교량공(평목교)", "구조물공", "포장공", "교통안전공", "부대공", "전기공사", "미확정공", "안전관리", "환경관리", "품질관리", "자재관리", "지장물", "기타"],
|
||||
"실정보고(대술~정안)": ["토공", "배수공", "비탈면안전공", "포장공", "부대공", "안전관리", "환경관리", "자재관리", "기타"],
|
||||
"기술지원 검토": ["토공", "배수공", "교량공(평목교)", "구조물&부대공", "기타"],
|
||||
"시공계획(어천~공주)": ["토공", "배수공", "교량공(평목교)", "구조물&부대&포장&교통안전공", "환경 및 품질관리"]
|
||||
},
|
||||
"공사관리": {
|
||||
"공정·일정": ["공정표", "월간 공정보고", "작업일보"],
|
||||
"품질 관리": ["품질시험계획서", "품질시험 실적보고", "콘크리트 타설현황[어천~공주(4차)]", "품질관리비 사용내역", "균열관리", "품질관리 양식서류"],
|
||||
"안전 관리": ["안전관리계획서", "안전관리 실적보고", "위험성 평가", "사전작업허가서", "안전관리비 사용내역", "안전관리수준평가", "안전관리 양식서류"],
|
||||
"환경 관리": ["환경영향평가", "사전재해영향성검토", "유지관리 및 보수점검", "환경보전비 사용내역", "건설폐기물 관리"],
|
||||
"자재 관리 (관급)": ["자재구매요청 (레미콘, 철근)", "자재구매요청 (그 외)", "납품기한", "계약 변경", "자재 반입·수불 관리", "자재관리 양식서류"],
|
||||
"자재 관리 (사급)": ["자재공급원 승인", "자재 반입·수불 관리", "자재 검수·확인"],
|
||||
"점검 (정리중)": ["내부점검", "외부점검"],
|
||||
"공문": ["접수(수신)", "발송(발신)", "하도급", "인력", "방침"]
|
||||
},
|
||||
"민원관리": {
|
||||
"민원(어천~공주)": ["처리대장", "보상", "공사일반", "환경분쟁"],
|
||||
"실정보고(어천~공주)": ["민원"],
|
||||
"실정보고(대술~정안)": ["민원"]
|
||||
}
|
||||
};
|
||||
|
||||
async function loadAttachments() {
|
||||
try {
|
||||
@@ -104,7 +264,17 @@
|
||||
item.className = 'attachment-item-wrap';
|
||||
item.style.marginBottom = "8px";
|
||||
|
||||
const btnAiClass = isAiActive ? 'btn-ai' : 'btn-normal';
|
||||
let pathText = "경로를 선택해주세요";
|
||||
let modeClass = "manual-mode";
|
||||
|
||||
if (file.analysis) {
|
||||
const prefix = file.analysis.isManual ? "선택 경로: " : "추천: ";
|
||||
pathText = `${prefix}${file.analysis.suggested_path}`;
|
||||
modeClass = file.analysis.isManual ? "manual-mode" : "smart-mode";
|
||||
} else if (isAiActive) {
|
||||
pathText = "AI 분석 대기 중...";
|
||||
modeClass = "smart-mode";
|
||||
}
|
||||
|
||||
item.innerHTML = `
|
||||
<div class="attachment-item">
|
||||
@@ -114,10 +284,10 @@
|
||||
<div style="font-size:12px; font-weight:700;">${file.name}</div>
|
||||
<div style="font-size:10px; color:var(--text-sub);">${file.size}</div>
|
||||
</div>
|
||||
<span id="recommend-${index}" class="ai-recommend" style="display:none;">추천 위치 탐색 중...</span>
|
||||
<span id="recommend-${index}" class="ai-recommend path-display ${modeClass}" onclick="openPathModal(${index})">${pathText}</span>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn-upload ${btnAiClass}" onclick="startAnalysis(${index})">AI 분석</button>
|
||||
${isAiActive ? `<button class="btn-upload btn-ai" onclick="startAnalysis(${index})">AI 분석</button>` : ''}
|
||||
<button class="btn-upload btn-normal" onclick="confirmUpload(${index})">파일 업로드</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -129,6 +299,51 @@
|
||||
});
|
||||
}
|
||||
|
||||
function openPathModal(index) {
|
||||
editingIndex = index;
|
||||
const modal = document.getElementById('pathModal');
|
||||
const tabSelect = document.getElementById('tabSelect');
|
||||
tabSelect.innerHTML = Object.keys(HIERARCHY).map(tab => `<option value="${tab}">${tab}</option>`).join('');
|
||||
updateCategories();
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
function updateCategories() {
|
||||
const tab = document.getElementById('tabSelect').value;
|
||||
const catSelect = document.getElementById('categorySelect');
|
||||
const cats = Object.keys(HIERARCHY[tab]);
|
||||
catSelect.innerHTML = cats.map(cat => `<option value="${cat}">${cat}</option>`).join('');
|
||||
updateSubs();
|
||||
}
|
||||
|
||||
function updateSubs() {
|
||||
const tab = document.getElementById('tabSelect').value;
|
||||
const cat = document.getElementById('categorySelect').value;
|
||||
const subSelect = document.getElementById('subSelect');
|
||||
const subs = HIERARCHY[tab][cat];
|
||||
subSelect.innerHTML = subs.map(sub => `<option value="${sub}">${sub}</option>`).join('');
|
||||
}
|
||||
|
||||
function applyPathSelection() {
|
||||
const tab = document.getElementById('tabSelect').value;
|
||||
const cat = document.getElementById('categorySelect').value;
|
||||
const sub = document.getElementById('subSelect').value;
|
||||
const fullPath = `${tab} > ${cat} > ${sub}`;
|
||||
|
||||
if (!currentFiles[editingIndex].analysis) {
|
||||
currentFiles[editingIndex].analysis = {};
|
||||
}
|
||||
currentFiles[editingIndex].analysis.suggested_path = fullPath;
|
||||
currentFiles[editingIndex].analysis.isManual = true;
|
||||
|
||||
renderFiles();
|
||||
closeModal();
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('pathModal').style.display = 'none';
|
||||
}
|
||||
|
||||
async function startAnalysis(index) {
|
||||
const file = currentFiles[index];
|
||||
const logArea = document.getElementById(`log-area-${index}`);
|
||||
@@ -137,14 +352,21 @@
|
||||
|
||||
logArea.classList.add('active');
|
||||
logContent.innerHTML = '<div class="log-line log-info">>>> 3중 레이어 AI 분석 엔진 가동...</div>';
|
||||
recLabel.style.display = 'inline-block';
|
||||
recLabel.innerText = '분석 중...';
|
||||
|
||||
try {
|
||||
const res = await fetch(`/analyze-file?filename=${encodeURIComponent(file.name)}`);
|
||||
const analysis = await res.json();
|
||||
if (analysis.error) throw new Error(analysis.error);
|
||||
|
||||
analysis.log_steps.forEach(step => {
|
||||
const result = analysis.final_result;
|
||||
const steps = [
|
||||
`1. 파일 포맷 분석: ${file.name.split('.').pop().toUpperCase()} 감지`,
|
||||
`2. 페이지 스캔: 총 ${analysis.total_pages}페이지 분석 완료`,
|
||||
`3. 문맥 추론: ${result.reason}`
|
||||
];
|
||||
|
||||
steps.forEach(step => {
|
||||
const line = document.createElement('div');
|
||||
line.className = 'log-line';
|
||||
line.innerText = " " + step;
|
||||
@@ -154,43 +376,32 @@
|
||||
const resultLine = document.createElement('div');
|
||||
resultLine.className = 'log-line log-success';
|
||||
resultLine.style.marginTop = "8px";
|
||||
resultLine.innerHTML = `[결과] ${analysis.suggested_path}<br>└ ${analysis.reason}`;
|
||||
resultLine.innerHTML = `[최종 결과] ${result.suggested_path}<br>└ 신뢰도: 100%`;
|
||||
logContent.appendChild(resultLine);
|
||||
|
||||
// 원본 보기 추가
|
||||
const details = document.createElement('details');
|
||||
details.style.marginTop = "5px";
|
||||
details.innerHTML = `
|
||||
<summary style="color:#da8cf1; cursor:pointer; font-size:10px;">[추출 원본 데이터 확인]</summary>
|
||||
<div style="color:#a0aec0; padding:8px; background:#2d3748; margin-top:5px; white-space:pre-wrap; max-height:150px; overflow-y:auto; border-radius:4px;">${analysis.raw_text}</div>
|
||||
`;
|
||||
logContent.appendChild(details);
|
||||
const snippetArea = document.createElement('div');
|
||||
snippetArea.style.cssText = "margin-top:10px; padding:10px; background:#1a202c; color:#a0aec0; font-size:11px; border-radius:4px; border-left:3px solid #63b3ed; max-height:100px; overflow-y:auto;";
|
||||
snippetArea.innerHTML = `<strong>[AI가 읽은 핵심 내용]</strong><br>${result.snippet || "텍스트 추출 불가"}`;
|
||||
logContent.appendChild(snippetArea);
|
||||
|
||||
recLabel.innerText = `추천: ${analysis.suggested_path}`;
|
||||
if(analysis.suggested_path === "분석실패") {
|
||||
recLabel.style.color = "#f21d0d";
|
||||
recLabel.style.background = "#fee9e7";
|
||||
}
|
||||
|
||||
currentFiles[index].analysis = analysis; // 결과 저장
|
||||
currentFiles[index].analysis = {
|
||||
suggested_path: result.suggested_path,
|
||||
isManual: false
|
||||
};
|
||||
renderFiles();
|
||||
|
||||
} catch (e) {
|
||||
logContent.innerHTML += '<div class="log-line" style="color:red;">ERR: 분석 오류</div>';
|
||||
logContent.innerHTML += `<div class="log-line" style="color:red;">ERR: ${e.message}</div>`;
|
||||
recLabel.innerText = '분석 실패';
|
||||
}
|
||||
}
|
||||
|
||||
function confirmUpload(index) {
|
||||
const file = currentFiles[index];
|
||||
const path = file.analysis ? file.analysis.suggested_path : "선택한 탭";
|
||||
|
||||
let message = `[${file.name}] 파일을 업로드하시겠습니까?`;
|
||||
if(file.analysis && file.analysis.suggested_path !== "분석실패") {
|
||||
message = `AI가 추천한 위치로 업로드하시겠습니까?\n\n위치: ${path}`;
|
||||
}
|
||||
|
||||
if (confirm(message)) {
|
||||
alert("업로드가 완료되었습니다.");
|
||||
}
|
||||
if(file.analysis) message = `정해진 위치로 업로드하시겠습니까?\n\n위치: ${path}`;
|
||||
if (confirm(message)) alert("업로드가 완료되었습니다.");
|
||||
}
|
||||
|
||||
loadAttachments();
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
sample/13.건설공사의 하도급 변경계약 통보(발파공사)에 따른 검토보고[어천-공주(4차)].pdf
Normal file
BIN
sample/13.건설공사의 하도급 변경계약 통보(발파공사)에 따른 검토보고[어천-공주(4차)].pdf
Normal file
Binary file not shown.
Binary file not shown.
BIN
sample/22.현장직원(안전관리자) 이탈계 제출의 건.pdf
Normal file
BIN
sample/22.현장직원(안전관리자) 이탈계 제출의 건.pdf
Normal file
Binary file not shown.
BIN
sample/23.건설공사의 하도급변경계약(토공사) 타절정산 통보에 따른 검토보고[어천-공주(4차)].pdf
Normal file
BIN
sample/23.건설공사의 하도급변경계약(토공사) 타절정산 통보에 따른 검토보고[어천-공주(4차)].pdf
Normal file
Binary file not shown.
BIN
sample/23.하도급변경계약(철거공사) 제출.pdf
Normal file
BIN
sample/23.하도급변경계약(철거공사) 제출.pdf
Normal file
Binary file not shown.
Binary file not shown.
BIN
sample/24.도로건설공사(설계,시공,감리) 추진관련 업무이행 철저.pdf
Normal file
BIN
sample/24.도로건설공사(설계,시공,감리) 추진관련 업무이행 철저.pdf
Normal file
Binary file not shown.
BIN
sample/25.건설공사의 하도급변경계약(토공사2) 타절정산 통보에 따른 검토보고[어천-공주(4차)].pdf
Normal file
BIN
sample/25.건설공사의 하도급변경계약(토공사2) 타절정산 통보에 따른 검토보고[어천-공주(4차)].pdf
Normal file
Binary file not shown.
BIN
sample/25.설 연휴 대비 도로시설물 안전점검 및 정비 철저.pdf
Normal file
BIN
sample/25.설 연휴 대비 도로시설물 안전점검 및 정비 철저.pdf
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
sample/45.실정보고서(토지임대료 연장) 제출 건.pdf
Normal file
BIN
sample/45.실정보고서(토지임대료 연장) 제출 건.pdf
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
sample/토지임대료연장에 대한 실정보고(어천~공주(4차)).pdf
Normal file
BIN
sample/토지임대료연장에 대한 실정보고(어천~공주(4차)).pdf
Normal file
Binary file not shown.
BIN
server.log
BIN
server.log
Binary file not shown.
69
server.py
Normal file
69
server.py
Normal file
@@ -0,0 +1,69 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 한글 환경 및 Tesseract 경로 강제 설정
|
||||
os.environ["PYTHONIOENCODING"] = "utf-8"
|
||||
os.environ["TESSDATA_PREFIX"] = r"C:\Users\User\AppData\Local\Programs\Tesseract-OCR\tessdata"
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import StreamingResponse, FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from analyze import analyze_file_content
|
||||
from crawler_service import run_crawler_service
|
||||
|
||||
app = FastAPI(title="Project Master Overseas API")
|
||||
|
||||
# 정적 파일 및 미들웨어 설정
|
||||
app.mount("/style", StaticFiles(directory="style"), name="style")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=False,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# --- HTML 라우팅 ---
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return FileResponse("index.html")
|
||||
|
||||
@app.get("/dashboard")
|
||||
async def get_dashboard():
|
||||
return FileResponse("dashboard.html")
|
||||
|
||||
@app.get("/mailTest")
|
||||
@app.get("/mailTest.html")
|
||||
async def get_mail_test():
|
||||
return FileResponse("mailTest.html")
|
||||
|
||||
# --- 데이터 API ---
|
||||
@app.get("/attachments")
|
||||
async def get_attachments():
|
||||
sample_path = "sample"
|
||||
if not os.path.exists(sample_path):
|
||||
os.makedirs(sample_path)
|
||||
files = []
|
||||
for f in os.listdir(sample_path):
|
||||
f_path = os.path.join(sample_path, f)
|
||||
if os.path.isfile(f_path):
|
||||
files.append({
|
||||
"name": f,
|
||||
"size": f"{os.path.getsize(f_path) / 1024:.1f} KB"
|
||||
})
|
||||
return files
|
||||
|
||||
@app.get("/analyze-file")
|
||||
async def analyze_file(filename: str):
|
||||
"""
|
||||
분석 서비스(analyze.py) 호출
|
||||
"""
|
||||
return analyze_file_content(filename)
|
||||
|
||||
@app.get("/sync")
|
||||
async def sync_data():
|
||||
"""
|
||||
크롤링 서비스(crawler_service.py) 호출
|
||||
"""
|
||||
return StreamingResponse(run_crawler_service(), media_type="text_event-stream")
|
||||
Reference in New Issue
Block a user