도먼 추가 정보 생성 기능 구현

This commit is contained in:
2025-07-23 13:37:33 +09:00
parent 1a2d57a306
commit 3fc100697f
6 changed files with 582 additions and 111 deletions

216
batch_cli.py Normal file
View File

@@ -0,0 +1,216 @@
"""
배치 처리 명령줄 인터페이스
WPF 애플리케이션에서 호출 가능한 간단한 배치 처리 도구
Usage:
python batch_cli.py --files "file1.pdf,file2.dxf" --schema "한국도로공사" --concurrent 3 --batch-mode true --save-intermediate false --include-errors true --output "results.csv"
"""
import argparse
import asyncio
import logging
import os
import sys
import time
from datetime import datetime
from typing import List
# 프로젝트 모듈 임포트
from config import Config
from multi_file_processor import MultiFileProcessor, BatchProcessingConfig, generate_default_csv_filename
# 로깅 설정
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
class BatchCLI:
"""배치 처리 명령줄 인터페이스 클래스"""
def __init__(self):
self.processor = None
self.start_time = None
def setup_processor(self) -> bool:
"""다중 파일 처리기 설정"""
try:
# 설정 검증
config_errors = Config.validate_config()
if config_errors:
for error in config_errors:
print(f"ERROR: {error}")
return False
# Gemini API 키 확인
gemini_api_key = Config.get_gemini_api_key()
if not gemini_api_key:
print("ERROR: Gemini API 키가 설정되지 않았습니다")
return False
# 처리기 초기화
self.processor = MultiFileProcessor(gemini_api_key)
print("START: 배치 처리기 초기화 완료")
return True
except Exception as e:
print(f"ERROR: 처리기 초기화 실패: {e}")
return False
def parse_file_paths(self, files_arg: str) -> List[str]:
"""파일 경로 문자열을 리스트로 파싱"""
if not files_arg:
return []
# 쉼표로 구분된 파일 경로들을 분리
file_paths = [path.strip().strip('"\'') for path in files_arg.split(',')]
# 파일 존재 여부 확인
valid_paths = []
for path in file_paths:
if os.path.exists(path):
valid_paths.append(path)
print(f"START: 파일 확인: {os.path.basename(path)}")
else:
print(f"ERROR: 파일을 찾을 수 없습니다: {path}")
return valid_paths
def parse_file_list_from_file(self, file_list_path: str) -> List[str]:
"""파일 리스트 파일에서 파일 경로들을 읽어옴"""
if not file_list_path or not os.path.exists(file_list_path):
return []
valid_paths = []
try:
with open(file_list_path, 'r', encoding='utf-8') as f:
for line in f:
path = line.strip().strip('"\'')
if path and os.path.exists(path):
valid_paths.append(path)
print(f"START: 파일 확인: {os.path.basename(path)}")
elif path:
print(f"ERROR: 파일을 찾을 수 없음: {path}")
print(f"START: 총 {len(valid_paths)}개 파일 로드됨")
return valid_paths
except Exception as e:
print(f"ERROR: 파일 리스트 읽기 실패: {e}")
return []
def create_batch_config(self, args) -> BatchProcessingConfig:
"""명령줄 인수에서 배치 설정 생성"""
config = BatchProcessingConfig(
organization_type=args.schema,
enable_gemini_batch_mode=args.batch_mode,
max_concurrent_files=args.concurrent,
save_intermediate_results=args.save_intermediate,
output_csv_path=args.output,
include_error_files=args.include_errors
)
return config
def progress_callback(self, current: int, total: int, status: str):
"""진행률 콜백 함수 - WPF가 기대하는 형식으로 출력"""
# WPF가 파싱할 수 있는 간단한 형식으로 출력
print(f"PROGRESS: {current}/{total}")
print(f"COMPLETED: {status}")
async def run_batch_processing(self, file_paths: List[str], config: BatchProcessingConfig) -> bool:
"""배치 처리 실행"""
try:
self.start_time = time.time()
total_files = len(file_paths)
print(f"START: 배치 처리 시작: {total_files}개 파일")
# 처리 실행
results = await self.processor.process_multiple_files(
file_paths, config, self.progress_callback
)
# 처리 완료
end_time = time.time()
total_time = end_time - self.start_time
# 요약 정보
summary = self.processor.get_processing_summary()
print(f"COMPLETED: 배치 처리 완료!")
print(f"COMPLETED: 총 처리 시간: {total_time:.1f}")
print(f"COMPLETED: 성공: {summary['success_files']}개, 실패: {summary['failed_files']}")
print(f"COMPLETED: CSV 결과 저장: {config.output_csv_path}")
print(f"COMPLETED: JSON 결과 저장: {config.output_csv_path.replace('.csv', '.json')}")
return True
except Exception as e:
print(f"ERROR: 배치 처리 중 오류: {e}")
return False
def str_to_bool(value: str) -> bool:
"""문자열을 boolean으로 변환"""
return value.lower() in ["true", "1", "yes", "on"]
async def main():
"""메인 함수"""
parser = argparse.ArgumentParser(description="PDF/DXF 파일 배치 처리 도구")
# 파일 입력 방식 (둘 중 하나 필수)
input_group = parser.add_mutually_exclusive_group(required=True)
input_group.add_argument("--files", "-f", help="처리할 파일 경로들 (쉼표로 구분)")
input_group.add_argument("--file-list", "-fl", help="처리할 파일 경로가 담긴 텍스트 파일")
# 선택적 인수들
parser.add_argument("--schema", "-s", default="한국도로공사", help="분석 스키마")
parser.add_argument("--concurrent", "-c", type=int, default=3, help="동시 처리할 파일 수")
parser.add_argument("--batch-mode", "-b", default="false", help="배치 모드 사용 여부")
parser.add_argument("--save-intermediate", "-i", default="true", help="중간 결과 저장 여부")
parser.add_argument("--include-errors", "-e", default="true", help="오류 파일 포함 여부")
parser.add_argument("--output", "-o", help="출력 CSV 파일 경로 (JSON 파일도 함께 생성됨)")
args = parser.parse_args()
# CLI 인스턴스 생성
cli = BatchCLI()
# 처리기 설정
if not cli.setup_processor():
sys.exit(1)
# 파일 경로 파싱
if args.files:
input_files = cli.parse_file_paths(args.files)
else:
input_files = cli.parse_file_list_from_file(args.file_list)
if not input_files:
print("ERROR: 처리할 파일이 없습니다.")
sys.exit(1)
# boolean 변환
args.batch_mode = str_to_bool(args.batch_mode)
args.save_intermediate = str_to_bool(args.save_intermediate)
args.include_errors = str_to_bool(args.include_errors)
# 배치 설정 생성
config = cli.create_batch_config(args)
# 배치 처리 실행
success = await cli.run_batch_processing(input_files, config)
if success:
print("SUCCESS: 배치 처리가 성공적으로 완료되었습니다.")
sys.exit(0)
else:
print("ERROR: 배치 처리 중 오류가 발생했습니다.")
sys.exit(1)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -29,33 +29,65 @@ ValueWithCoords = types.Schema(
required=["value", "x", "y"]
)
# 범용 표의 한 행을 위한 스키마
GenericTableRow = types.Schema(
type=types.Type.ARRAY,
items=ValueWithCoords,
description="표의 한 행. 각 셀의 값과 좌표를 포함합니다."
)
# 페이지에서 추출된 범용 표를 위한 스키마
GenericTable = types.Schema(
type=types.Type.OBJECT,
properties={
"table_title": types.Schema(type=types.Type.STRING, description="추출된 표의 내용을 설명하는 제목 (예: '범례', 'IP 정보', '개정 이력')."),
"table_data": types.Schema(
type=types.Type.ARRAY,
items=GenericTableRow,
description="표의 데이터. 각 내부 리스트가 하나의 행을 나타냅니다."
)
},
description="도면에서 발견된 구조화된 정보 블록이나 표."
)
# 모든 필드가 ValueWithCoords를 사용하도록 스키마 업데이트
SCHEMA_EXPRESSWAY = types.Schema(
type=types.Type.OBJECT,
properties={
"도면명": ValueWithCoords,
"도면명_line0": ValueWithCoords,
"도면명_line1": ValueWithCoords,
"도면명_line2": ValueWithCoords,
"편철번호": ValueWithCoords,
"도면번호": ValueWithCoords,
"Main Title": ValueWithCoords,
"Sub Title": ValueWithCoords,
"수평축척": ValueWithCoords,
"수직축척": ValueWithCoords,
"Main_Title": ValueWithCoords,
"Sub_Title": ValueWithCoords,
"수평_도면_축척": ValueWithCoords,
"수직_도면_축척": ValueWithCoords,
"적용표준버전": ValueWithCoords,
"사업명": ValueWithCoords,
"사업명_top": ValueWithCoords,
"사업명_bot": ValueWithCoords,
"시설_공구": ValueWithCoords,
"설계공구_Station": ValueWithCoords,
"시공공구_Station": ValueWithCoords,
"설계공구_공구명": ValueWithCoords,
"설계공구_범위": ValueWithCoords,
"시공공구_공구명": ValueWithCoords,
"시공공구_범위": ValueWithCoords,
"건설분야": ValueWithCoords,
"건설단계": ValueWithCoords,
"설계사": ValueWithCoords,
"시공사": ValueWithCoords,
"노선이정": ValueWithCoords,
"정번호": ValueWithCoords,
"정날짜": ValueWithCoords,
"정내용": ValueWithCoords,
"작성자": ValueWithCoords,
"검토자": ValueWithCoords,
"확인자": ValueWithCoords
"정번호_1": ValueWithCoords,
"정날짜_1": ValueWithCoords,
"정내용_1": ValueWithCoords,
"작성자_1": ValueWithCoords,
"검토자_1": ValueWithCoords,
"확인자_1": ValueWithCoords,
"additional_tables": types.Schema(
type=types.Type.ARRAY,
items=GenericTable,
description="도면에서 발견된 추가적인 표나 정보 블록 목록."
)
},
)
@@ -74,12 +106,17 @@ SCHEMA_TRANSPORTATION = types.Schema(
"시설_공구": ValueWithCoords,
"건설분야": ValueWithCoords,
"건설단계": ValueWithCoords,
"정차수": ValueWithCoords,
"정일자": ValueWithCoords,
"정차수": ValueWithCoords,
"정일자": ValueWithCoords,
"과업책임자": ValueWithCoords,
"분야별책임자": ValueWithCoords,
"설계자": ValueWithCoords,
"위치정보": ValueWithCoords
"위치정보": ValueWithCoords,
"additional_tables": types.Schema(
type=types.Type.ARRAY,
items=GenericTable,
description="도면에서 발견된 추가적인 표나 정보 블록 목록."
)
},
)
@@ -139,9 +176,17 @@ class GeminiAnalyzer:
"\n\n--- 추출된 텍스트와 좌표 정보 ---\n" +
text_context +
"\n\n--- 지시사항 ---\n"
"위 텍스트와 좌표 정보를 바탕으로, 이미지의 내용을 분석하여 JSON 스키마를 채워주세요."
"각 필드에 해당하는 텍스트를 찾고, 해당 텍스트의 'value'와 시작 'x', 'y' 좌표를 JSON에 기입하세요."
"해당하는 값이 없으면 빈 문자열을 사용하세요."
"1. 위 텍스트와 좌표 정보를 바탕으로, 이미지의 내용을 분석하여 JSON 스키마의 기본 필드를 채워주세요.\n"
"2. **(중요)** 도면 내에 표나 사각형으로 구분된 정보 블록이 있다면, `additional_tables` 필드에 추가해주세요. 예를 들어 'IP 정보 표', '범례(Legend)', '구조물 설명', '개정 이력' 등이 해당됩니다.\n"
" - 각 표/블록에 대해 `table_title`에 적절한 제목을 붙여주세요.\n"
" - `table_data`에는 표의 모든 행을 추출하여 리스트 형태로 넣어주세요. 각 행은 셀들의 리스트입니다.\n"
"3. 각 필드에 해당하는 텍스트를 찾고, 해당 텍스트의 'value'와 시작 'x', 'y' 좌표를 JSON에 기입하세요.\n"
"4. 해당하는 값이 없으면 빈 문자열이나 빈 리스트를 사용하세요.\n"
"\n--- 필드 설명 ---\n"
"- `{ }_Title`: 중앙 상단의 비교적 큰 폰트입니다.\n"
"- `사업명_top`에 해당하는 텍스트 아랫줄은 '시설_공구' 항목입니다.\n"
"- `도면명_line{n}`: 도면명에 해당하는 여러 줄의 텍스트를 위에서부터 0, 1, 2 순서로 기입합니다. 만약 두 줄이라면 line0은 비워두고 line1, line2를 채웁니다.\n"
"- `설계공구`/`시공공구`: '공구명''범위'로 나뉘어 기입될 수 있습니다. (예: '설계공구 | 제2-1공구 | 12780.00-15860.00' -> `설계공구_공구명`: '제2-1공구', `설계공구_범위`: '12780.00-15860.00')\n"
)
contents = [
@@ -178,6 +223,49 @@ class GeminiAnalyzer:
result = response.text
# JSON 응답을 파싱하여 다시 직렬화 (일관된 포맷팅)
parsed_json = json.loads(result)
# 디버깅: Gemini 응답 내용 로깅
logger.info(f"=== Gemini 응답 디버깅 ===")
logger.info(f"조직 유형: {organization_type}")
logger.info(f"응답 필드 수: {len(parsed_json) if isinstance(parsed_json, dict) else 'N/A'}")
if isinstance(parsed_json, dict):
# 새로운 필드들이 응답에 포함되었는지 확인
new_fields = ["설계공구_Station_col1", "설계공구_Station_col2", "시공공구_Station_col1", "시공공구_Station_col2"]
old_fields = ["설계공구_Station", "시공공구_Station"]
logger.info("=== 새 필드 확인 ===")
for field in new_fields:
if field in parsed_json:
field_data = parsed_json[field]
if isinstance(field_data, dict) and field_data.get('value'):
logger.info(f"{field}: '{field_data.get('value')}' at ({field_data.get('x', 'N/A')}, {field_data.get('y', 'N/A')})")
else:
logger.info(f"⚠️ {field}: 빈 값 또는 잘못된 형식 - {field_data}")
else:
logger.info(f"{field}: 응답에 없음")
logger.info("=== 기존 필드 확인 ===")
for field in old_fields:
if field in parsed_json:
field_data = parsed_json[field]
if isinstance(field_data, dict) and field_data.get('value'):
logger.info(f"⚠️ {field}: '{field_data.get('value')}' (기존 필드가 여전히 존재)")
else:
logger.info(f"⚠️ {field}: 빈 값 - {field_data}")
else:
logger.info(f"{field}: 응답에 없음 (예상됨)")
logger.info("=== 전체 응답 필드 목록 ===")
for key in parsed_json.keys():
value = parsed_json[key]
if isinstance(value, dict) and 'value' in value:
logger.info(f"필드: {key} = '{value.get('value', '')}' at ({value.get('x', 'N/A')}, {value.get('y', 'N/A')})")
else:
logger.info(f"필드: {key} = {type(value).__name__}")
logger.info("=== 디버깅 끝 ===")
pretty_result = json.dumps(parsed_json, ensure_ascii=False, indent=2)
logger.info(f"분석 완료: {len(pretty_result)} 문자")
return pretty_result

View File

@@ -1,77 +1,89 @@
{
"mapping_table": {
"ailabel_to_systems": {
"도면명": {
"도면명_line0": {
"molit": "",
"expressway": "TD_DNAME_TOP",
"railway": "",
"docaikey": "DNAME_TOP"
},
"도면명_line1": {
"molit": "DI_TITLE",
"expressway": "TD_DNAME_MAIN",
"expressway": "TD_DNAME_MAIN",
"railway": "TD_DNAME_MAIN",
"docaikey": "DNAME_MAIN"
},
"편철번호": {
"도면명_line2": {
"molit": "DI_SUBTITLE",
"expressway": "TD_DNAME_BOT",
"railway": "TD_DNAME_BOT",
"railway": "TD_DNAME_BOT",
"docaikey": "DNAME_BOT"
},
"도면번호": {
"편철번호": {
"molit": "DA_PAGENO",
"expressway": "TD_DWGNO",
"railway": "TD_DWGNO",
"docaikey": "DWGNO"
},
"Main Title": {
"도면번호": {
"molit": "DI_DRWNO",
"expressway": "TD_DWGCODE",
"railway": "TD_DWGCODE",
"docaikey": "DWGCODE"
},
"Sub Title": {
"Main_Title": {
"molit": "UD_TITLE",
"expressway": "TB_MTITIL",
"railway": "TB_MTITIL",
"docaikey": "MTITIL"
},
"수평축척": {
"Sub_Title": {
"molit": "UD_SUBTITLE",
"expressway": "TB_STITL",
"railway": "TB_STITL",
"docaikey": "STITL"
},
"수축척": {
"molit": "",
"expressway": "TD_DWGCODE_PREV",
"railway": "",
"docaikey": "DWGCODE_PREV"
},
"도면축척": {
"수평_도면_축척": {
"molit": "DA_HSCALE",
"expressway": "TD_HSCAL",
"railway": "",
"docaikey": "HSCAL"
},
"적용표준버전": {
"molit": "DA_STDNAME",
"expressway": "STDNAME",
"수직_도면_축척": {
"molit": "DA_VSCALE",
"expressway": "TD_VSCAL",
"railway": "",
"docaikey": ""
"docaikey": "VSCAL"
},
"사업명": {
"적용표준버전": {
"molit": "DA_STDVER",
"expressway": "TD_VERSION",
"railway": "TD_VERSION",
"docaikey": "VERSION"
},
"시설_공구": {
"사업명_top": {
"molit": "PI_CNAME",
"expressway": "TB_CNAME",
"railway": "",
"docaikey": "TBCNAME"
},
"설계공구_Station": {
"시설_공구": {
"molit": "UD_CDNAME",
"expressway": "TB_CSCOP",
"railway": "",
"docaikey": "CSCOP"
},
"설계공구_공구명": {
"molit": "",
"expressway": "TD_DSECT",
"railway": "",
"docaikey": "DSECT"
},
"시공공구_공구명": {
"molit": "",
"expressway": "TD_CSECT",
"railway": "",
"docaikey": "CSECT"
},
"건설분야": {
"molit": "PA_CCLASS",
@@ -86,54 +98,66 @@
"docaikey": "CSTEP"
},
"설계사": {
"molit": "TD_DCOMP",
"molit": "",
"expressway": "TD_DCOMP",
"railway": "",
"railway": "TD_DCOMP",
"docaikey": "DCOMP"
},
"시공사": {
"molit": "TD_CCOMP",
"molit": "",
"expressway": "TD_CCOMP",
"railway": "",
"railway": "TD_CCOMP",
"docaikey": "CCOMP"
},
"노선이정": {
"molit": "TD_LNDST",
"expressway": "",
"molit": "",
"expressway": "TD_LNDST",
"railway": "",
"docaikey": "LNDST"
},
"계정번호": {
"설계공구_범위": {
"molit": "",
"expressway": "TD_DDIST",
"railway": "",
"docaikey": "DDIST"
},
"시공공구_범위": {
"molit": "",
"expressway": "TD_CDIST",
"railway": "",
"docaikey": "CDIST"
},
"개정번호_1": {
"molit": "DC_RNUM1",
"expressway": "TR_RNUM1",
"railway": "TR_RNUM1",
"docaikey": "RNUM1"
},
"정날짜": {
"정날짜_1": {
"molit": "DC_RDATE1",
"expressway": "TR_RDAT1",
"railway": "TR_RDAT1",
"docaikey": "RDAT1"
},
"개정내용": {
"개정내용_1": {
"molit": "DC_RDES1",
"expressway": "TR_RCON1",
"railway": "TR_RCON1",
"docaikey": "RCON1"
},
"작성자": {
"작성자_1": {
"molit": "DC_RDGN1",
"expressway": "TR_DGN1",
"railway": "TR_DGN1",
"docaikey": "DGN1"
},
"검토자": {
"검토자_1": {
"molit": "DC_RCHK1",
"expressway": "TR_CHK1",
"railway": "TR_CHK1",
"docaikey": "CHK1"
},
"확인자": {
"확인자_1": {
"molit": "DC_RAPP1",
"expressway": "TR_APP1",
"railway": "TR_APP1",
@@ -143,7 +167,7 @@
"system_mappings": {
"expressway_to_transportation": {
"TD_DNAME_MAIN": "DNAME_MAIN",
"TD_DNAME_BOT": "DNAME_BOT",
"TD_DNAME_BOT": "DNAME_BOT",
"TD_DWGNO": "DWGNO",
"TD_DWGCODE": "DWGCODE",
"TB_MTITIL": "MTITIL",

47
merge_excel_files.py Normal file
View File

@@ -0,0 +1,47 @@
import pandas as pd
import os
import glob
import sys
def merge_excel_files(input_directory, output_filename="merged_output.xlsx"):
"""
Merges all .xlsx files in the specified directory into a single Excel file,
with each original file becoming a sheet named after the original filename.
Args:
input_directory (str): The path to the directory containing the Excel files.
output_filename (str): The name of the output merged Excel file.
"""
excel_files = glob.glob(os.path.join(input_directory, "*.xlsx"))
if not excel_files:
print(f"No .xlsx files found in '{input_directory}'.")
return
print(f"Found {len(excel_files)} Excel files to merge:")
for f in excel_files:
print(f"- {os.path.basename(f)}")
output_path = os.path.join(input_directory, output_filename)
with pd.ExcelWriter(output_path, engine='openpyxl') as writer:
for excel_file in excel_files:
try:
df = pd.read_excel(excel_file)
sheet_name = os.path.splitext(os.path.basename(excel_file))[0]
df.to_excel(writer, sheet_name=sheet_name, index=False)
print(f"Successfully added '{os.path.basename(excel_file)}' as sheet '{sheet_name}'.")
except Exception as e:
print(f"Error processing '{os.path.basename(excel_file)}': {e}")
print(f"All Excel files merged into '{output_path}'.")
if __name__ == "__main__":
if len(sys.argv) > 1:
input_dir = sys.argv[1]
else:
input_dir = os.getcwd()
directory_name = os.path.basename(input_dir)
output_filename = f"{directory_name}.xlsx"
merge_excel_files(input_dir, output_filename=output_filename)

View File

@@ -52,7 +52,7 @@ class FileProcessingResult:
@dataclass
class BatchProcessingConfig:
"""배치 처리 설정"""
organization_type: str = "국토교통부"
organization_type: str = "한국도로공사"
enable_gemini_batch_mode: bool = False
max_concurrent_files: int = 3
save_intermediate_results: bool = True
@@ -137,6 +137,10 @@ class MultiFileProcessor:
# CSV 저장
if config.output_csv_path:
await self.save_results_to_csv(config.output_csv_path)
# JSON 출력도 함께 생성 (좌표 정보 포함)
json_output_path = config.output_csv_path.replace('.csv', '.json')
await self.save_results_to_json(json_output_path)
return self.processing_results
@@ -351,96 +355,179 @@ class MultiFileProcessor:
async def save_results_to_csv(self, output_path: str) -> None:
"""
처리 결과를 CSV 파일로 저장
처리 결과를 CSV 파일로 저장합니다. (파일당 한 줄, 추가 테이블은 JSON으로 저장)
Args:
output_path: 출력 CSV 파일 경로
"""
try:
# 결과를 DataFrame으로 변환
data_rows = []
# 모든 결과를 순회하며 한 줄 데이터로 가공
for result in self.processing_results:
# 기본 정보
row = {
'file_name': result.file_name,
'file_path': result.file_path,
'file_type': result.file_type,
'file_size_bytes': result.file_size,
'file_size_mb': round(result.file_size / (1024 * 1024), 2),
'processing_time_seconds': result.processing_time,
'success': result.success,
'error_message': result.error_message or '',
'processed_at': result.processed_at
}
if result.file_type.lower() == 'pdf' and result.success and result.pdf_analysis_result:
try:
pdf_data = json.loads(result.pdf_analysis_result)
# 'additional_tables'를 추출하여 별도 처리
additional_tables = pdf_data.pop('additional_tables', [])
if additional_tables:
# JSON 문자열로 변환하여 한 셀에 저장 (한글 유지, 좌표 제외)
tables_for_csv = [
{
"table_title": table.get("table_title"),
"table_data": [
[cell.get("value") for cell in table_row]
for table_row in table.get("table_data", [])
]
} for table in additional_tables
]
row['additional_tables'] = json.dumps(tables_for_csv, ensure_ascii=False)
else:
row['additional_tables'] = ''
# 나머지 기본 정보들을 row에 추가 (좌표 제외)
for key, value in pdf_data.items():
if isinstance(value, dict):
row[key] = value.get('value', '')
else:
row[key] = value
except (json.JSONDecodeError, TypeError) as e:
logger.warning(f"PDF 결과 파싱 오류 ({result.file_name}): {e}")
row['error_message'] = f"JSON 파싱 실패: {e}"
# PDF 분석 결과
if result.file_type.lower() == 'pdf':
row['pdf_analysis_result'] = result.pdf_analysis_result or ''
row['dxf_total_attributes'] = ''
row['dxf_total_text_entities'] = ''
row['dxf_title_blocks_summary'] = ''
# DXF 분석 결과
elif result.file_type.lower() == 'dxf':
row['pdf_analysis_result'] = ''
row['dxf_total_attributes'] = result.dxf_total_attributes or 0
row['dxf_total_text_entities'] = result.dxf_total_text_entities or 0
# 타이틀 블록 요약
if result.dxf_title_blocks:
summary = f"{len(result.dxf_title_blocks)}개 타이틀블록"
for tb in result.dxf_title_blocks[:3]: # 처음 3개만 표시
summary += f" | {tb['block_name']}({tb['attributes_count']}속성)"
if len(result.dxf_title_blocks) > 3:
summary += f" | ...외 {len(result.dxf_title_blocks)-3}"
row['dxf_title_blocks_summary'] = summary
else:
row['dxf_title_blocks_summary'] = '타이틀블록 없음'
data_rows.append(row)
# DataFrame 생성 및 CSV 저장
if not data_rows:
logger.warning("CSV로 저장할 데이터가 없습니다.")
return
df = pd.DataFrame(data_rows)
# pdf_analysis_result 컬럼 평탄화
if 'pdf_analysis_result' in df.columns:
# JSON 문자열을 딕셔너리로 변환 (이미 딕셔너리인 경우도 처리)
df['pdf_analysis_result'] = df['pdf_analysis_result'].apply(lambda x: json.loads(x) if isinstance(x, str) and x.strip() else {}).fillna({})
# 평탄화된 데이터를 새로운 DataFrame으로 생성
# errors='ignore'를 사용하여 JSON이 아닌 값은 무시
# record_prefix를 사용하여 컬럼 이름에 접두사 추가
pdf_analysis_df = pd.json_normalize(df['pdf_analysis_result'], errors='ignore', record_prefix='pdf_analysis_result_')
# 원본 df에서 pdf_analysis_result 컬럼 제거
df = df.drop(columns=['pdf_analysis_result'])
# 원본 df와 평탄화된 DataFrame을 병합
df = pd.concat([df, pdf_analysis_df], axis=1)
# 컬럼 순서 정렬을 위한 기본 순서 정의
column_order = [
'file_name', 'file_type', 'file_size_mb', 'processing_time_seconds',
'success', 'error_message', 'processed_at', 'file_path', 'file_size_bytes',
'dxf_total_attributes', 'dxf_total_text_entities', 'dxf_title_blocks_summary'
# --- 컬럼 순서 정의 및 재정렬 ---
base_columns = [
'file_name', 'file_path', 'file_type', 'file_size_mb',
'processing_time_seconds', 'success', 'error_message', 'processed_at'
]
pdf_metadata_columns = [
"도면명", "도면명_line0", "도면명_line1", "도면명_line2", "편철번호", "도면번호",
"Main_Title", "Sub_Title", "Main Title", "Sub Title", "사업명", "사업명_top", "사업명_bot",
"시설_공구", "수평_도면_축척", "수직_도면_축척", "수평축척", "수직축척", "적용표준버전", "적용표준",
"건설분야", "건설단계", "설계공구_공구명", "설계공구_범위", "시공공구_공구명", "시공공구_범위",
"설계사", "시공사", "노선이정", "개정번호_1", "개정날짜_1", "개정내용_1", "작성자_1", "검토자_1", "확인자_1",
"개정차수", "개정일자", "과업책임자", "분야별책임자", "설계자", "위치정보"
]
# 새로 추가된 컬럼은 맨 뒤로
new_columns = ['additional_tables']
dxf_columns = ['dxf_total_attributes', 'dxf_total_text_entities']
# 기존 컬럼 순서를 유지하면서 새로운 컬럼을 추가
existing_columns = [col for col in column_order if col in df.columns]
new_columns = [col for col in df.columns if col not in existing_columns]
df = df[existing_columns + sorted(new_columns)]
potential_columns = base_columns + pdf_metadata_columns + dxf_columns + new_columns
# UTF-8 BOM으로 저장 (한글 호환성)
final_ordered_columns = [col for col in potential_columns if col in df.columns]
remaining_columns = sorted([col for col in df.columns if col not in final_ordered_columns])
df = df[final_ordered_columns + remaining_columns]
# UTF-8 BOM으로 저장
df.to_csv(output_path, index=False, encoding='utf-8-sig')
logger.info(f"CSV 저장 완료: {output_path}")
logger.info(f"{len(data_rows)}파일 결과 저장")
logger.info(f"{len(df)} 결과 저장")
except Exception as e:
logger.error(f"CSV 저장 오류: {str(e)}")
raise
async def save_results_to_json(self, output_path: str) -> None:
"""
처리 결과를 JSON 파일로 저장 (좌표 정보 포함)
Args:
output_path: 출력 JSON 파일 경로
"""
try:
# 결과를 JSON 구조로 변환
json_data = {
"metadata": {
"total_files": len(self.processing_results),
"success_files": sum(1 for r in self.processing_results if r.success),
"failed_files": sum(1 for r in self.processing_results if not r.success),
"generated_at": datetime.now().isoformat(),
"format_version": "1.0"
},
"results": []
}
for result in self.processing_results:
# 기본 정보
result_data = {
"file_info": {
"name": result.file_name,
"path": result.file_path,
"type": result.file_type,
"size_bytes": result.file_size,
"size_mb": round(result.file_size / (1024 * 1024), 2)
},
"processing_info": {
"success": result.success,
"processing_time_seconds": result.processing_time,
"processed_at": result.processed_at,
"error_message": result.error_message
}
}
# PDF 분석 결과 (좌표 정보 포함)
if result.file_type.lower() == 'pdf' and result.pdf_analysis_result:
try:
# JSON 문자열을 딕셔너리로 변환 (이미 딕셔너리인 경우도 처리)
if isinstance(result.pdf_analysis_result, str):
analysis_data = json.loads(result.pdf_analysis_result)
else:
analysis_data = result.pdf_analysis_result
result_data["pdf_analysis"] = analysis_data
except (json.JSONDecodeError, TypeError) as e:
logger.warning(f"PDF 분석 결과 JSON 파싱 오류: {e}")
result_data["pdf_analysis"] = {"error": "JSON 파싱 실패", "raw_data": str(result.pdf_analysis_result)}
# DXF 분석 결과
elif result.file_type.lower() == 'dxf':
result_data["dxf_analysis"] = {
"total_attributes": result.dxf_total_attributes or 0,
"total_text_entities": result.dxf_total_text_entities or 0,
"title_blocks": result.dxf_title_blocks or []
}
json_data["results"].append(result_data)
# JSON 파일 저장 (예쁜 포맷팅과 한글 지원)
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(json_data, f, ensure_ascii=False, indent=2, default=str)
logger.info(f"JSON 저장 완료: {output_path}")
logger.info(f"{len(json_data['results'])}개 파일 결과 저장 (좌표 정보 포함)")
except Exception as e:
logger.error(f"JSON 저장 오류: {str(e)}")
raise
def get_processing_summary(self) -> Dict[str, Any]:
"""처리 결과 요약 정보 반환"""
if not self.processing_results:
@@ -484,7 +571,7 @@ if __name__ == "__main__":
processor = MultiFileProcessor("your-gemini-api-key")
config = BatchProcessingConfig(
organization_type="국토교통부",
organization_type="한국도로공사",
max_concurrent_files=2,
output_csv_path="test_results.csv"
)

9
requirements-cli.txt Normal file
View File

@@ -0,0 +1,9 @@
# Essential packages for CLI batch processing only
PyMuPDF>=1.26.3
google-genai>=1.0.0
Pillow>=10.0.0
ezdxf>=1.4.2
numpy>=1.24.0
python-dotenv>=1.0.0
pandas>=2.0.0
requests>=2.31.0