""" PDF 도면 분석기 - 메인 애플리케이션 (업데이트됨) Flet 기반의 PDF 업로드 및 Gemini API 이미지 분석 애플리케이션 """ import flet as ft import logging import threading from typing import Optional import time # 프로젝트 모듈 임포트 from config import Config from pdf_processor import PDFProcessor from gemini_analyzer import GeminiAnalyzer from ui_components import UIComponents from utils import AnalysisResultSaver, DateTimeUtils # 로깅 설정 logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) logger = logging.getLogger(__name__) class PDFAnalyzerApp: """PDF 분석기 메인 애플리케이션 클래스""" def __init__(self, page: ft.Page): self.page = page self.pdf_processor = PDFProcessor() self.gemini_analyzer = None self.current_pdf_path = None self.current_pdf_info = None self.analysis_results = {} self.result_saver = AnalysisResultSaver("results") self.analysis_start_time = None # UI 컴포넌트 참조 self.file_picker = None self.selected_file_text = None self.upload_button = None self.progress_bar = None self.progress_ring = None self.status_text = None self.results_text = None self.results_container = None self.save_button = None self.organization_selector = None # 새로 추가 self.page_selector = None self.analysis_mode = None self.custom_prompt = None self.pdf_preview_container = None self.page_nav_text = None self.prev_button = None self.next_button = None # 초기화 self.setup_page() self.init_gemini_analyzer() def setup_page(self): """페이지 기본 설정""" self.page.title = Config.APP_TITLE self.page.theme_mode = ft.ThemeMode.LIGHT self.page.padding = 0 self.page.bgcolor = ft.Colors.GREY_100 # 윈도우 크기 설정 self.page.window_width = 1200 self.page.window_height = 800 self.page.window_min_width = 1000 self.page.window_min_height = 700 logger.info("페이지 설정 완료") def init_gemini_analyzer(self): """Gemini 분석기 초기화""" try: config_errors = Config.validate_config() if config_errors: self.show_error_dialog( "설정 오류", "\\n".join(config_errors) + "\\n\\n.env 파일을 확인하세요." ) return self.gemini_analyzer = GeminiAnalyzer() logger.info("Gemini 분석기 초기화 완료") except Exception as e: logger.error(f"Gemini 분석기 초기화 실패: {e}") self.show_error_dialog( "초기화 오류", f"Gemini API 초기화에 실패했습니다:\\n{str(e)}" ) def build_ui(self): """UI 구성""" # 앱바 app_bar = UIComponents.create_app_bar() self.page.appbar = app_bar # 파일 업로드 섹션 upload_section = self.create_file_upload_section() # 분석 설정 섹션 settings_section = self.create_analysis_settings_section() # 진행률 섹션 progress_section = self.create_progress_section() # 결과 및 미리보기 섹션 content_row = ft.Row([ ft.Column([ self.create_results_section(), ], expand=2), ft.Column([ self.create_pdf_preview_section(), ], expand=1), ]) # 메인 레이아웃 main_content = ft.Column([ upload_section, settings_section, progress_section, content_row, ], scroll=ft.ScrollMode.AUTO) # 페이지에 추가 self.page.add(main_content) logger.info("UI 구성 완료") def create_file_upload_section(self) -> ft.Container: """파일 업로드 섹션 생성""" # 파일 선택기 self.file_picker = ft.FilePicker(on_result=self.on_file_selected) self.page.overlay.append(self.file_picker) # 선택된 파일 정보 self.selected_file_text = ft.Text( "선택된 파일이 없습니다", size=14, color=ft.Colors.GREY_600 ) # 파일 선택 버튼 select_button = ft.ElevatedButton( text="PDF 파일 선택", icon=ft.Icons.UPLOAD_FILE, on_click=self.on_select_file_click, style=ft.ButtonStyle( bgcolor=ft.Colors.BLUE_100, color=ft.Colors.BLUE_800, ) ) # 분석 시작 버튼 self.upload_button = ft.ElevatedButton( text="분석 시작", icon=ft.Icons.ANALYTICS, on_click=self.on_analysis_start_click, disabled=True, style=ft.ButtonStyle( bgcolor=ft.Colors.GREEN_100, color=ft.Colors.GREEN_800, ) ) return ft.Container( content=ft.Column([ ft.Text( "📄 PDF 파일 업로드", size=18, weight=ft.FontWeight.BOLD, color=ft.Colors.BLUE_800 ), ft.Divider(), ft.Row([ select_button, self.upload_button, ], alignment=ft.MainAxisAlignment.START), self.selected_file_text, ]), padding=20, margin=10, bgcolor=ft.Colors.WHITE, border_radius=10, border=ft.border.all(1, ft.Colors.GREY_300), ) def create_analysis_settings_section(self) -> ft.Container: """분석 설정 섹션 생성""" # UI 컴포넌트와 참조를 가져오기 container, organization_selector, page_selector, analysis_mode, custom_prompt = \ UIComponents.create_analysis_settings_section_with_refs() # 인스턴스 변수에 참조 저장 self.organization_selector = organization_selector self.page_selector = page_selector self.analysis_mode = analysis_mode self.custom_prompt = custom_prompt # 이벤트 핸들러 설정 self.analysis_mode.on_change = self.on_analysis_mode_change self.organization_selector.on_change = self.on_organization_change return container def create_progress_section(self) -> ft.Container: """진행률 섹션 생성""" # 진행률 바 self.progress_bar = ft.ProgressBar( width=400, color=ft.Colors.BLUE_600, bgcolor=ft.Colors.GREY_300, visible=False, ) # 상태 텍스트 self.status_text = ft.Text( "대기 중...", size=14, color=ft.Colors.GREY_600 ) # 진행률 링 self.progress_ring = ft.ProgressRing( width=50, height=50, stroke_width=4, visible=False, ) return ft.Container( content=ft.Column([ ft.Text( "📊 분석 진행 상황", size=18, weight=ft.FontWeight.BOLD, color=ft.Colors.PURPLE_800 ), ft.Divider(), ft.Row([ self.progress_ring, ft.Column([ self.status_text, self.progress_bar, ], expand=1), ], alignment=ft.MainAxisAlignment.START), ]), padding=20, margin=10, bgcolor=ft.Colors.WHITE, border_radius=10, border=ft.border.all(1, ft.Colors.GREY_300), ) def create_results_section(self) -> ft.Container: """결과 섹션 생성""" # 결과 텍스트 self.results_text = ft.Text( "분석 결과가 여기에 표시됩니다.", size=14, selectable=True, ) # 결과 컨테이너 self.results_container = ft.Container( content=ft.Column([ self.results_text, ], scroll=ft.ScrollMode.AUTO), padding=15, height=350, bgcolor=ft.Colors.GREY_50, border_radius=8, border=ft.border.all(1, ft.Colors.GREY_300), ) # 저장 버튼들 save_text_button = ft.ElevatedButton( text="텍스트 저장", icon=ft.Icons.SAVE, disabled=True, on_click=self.on_save_text_click, style=ft.ButtonStyle( bgcolor=ft.Colors.TEAL_100, color=ft.Colors.TEAL_800, ) ) save_json_button = ft.ElevatedButton( text="JSON 저장", icon=ft.Icons.SAVE_ALT, disabled=True, on_click=self.on_save_json_click, style=ft.ButtonStyle( bgcolor=ft.Colors.INDIGO_100, color=ft.Colors.INDIGO_800, ) ) # 저장 버튼들을 인스턴스 변수로 저장 self.save_text_button = save_text_button self.save_json_button = save_json_button return ft.Container( content=ft.Column([ ft.Row([ ft.Text( "📋 분석 결과", size=18, weight=ft.FontWeight.BOLD, color=ft.Colors.GREEN_800 ), ft.Row([ save_text_button, save_json_button, ]), ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN), ft.Divider(), self.results_container, ]), padding=20, margin=10, bgcolor=ft.Colors.WHITE, border_radius=10, border=ft.border.all(1, ft.Colors.GREY_300), ) def create_pdf_preview_section(self) -> ft.Container: """PDF 미리보기 섹션 생성""" # 미리보기 컨테이너 self.pdf_preview_container = ft.Container( content=ft.Column([ ft.Icon( ft.Icons.PICTURE_AS_PDF, size=100, color=ft.Colors.GREY_400 ), ft.Text( "PDF 미리보기", size=14, color=ft.Colors.GREY_600 ) ], alignment=ft.MainAxisAlignment.CENTER), width=300, height=400, bgcolor=ft.Colors.GREY_100, border_radius=8, border=ft.border.all(1, ft.Colors.GREY_300), alignment=ft.alignment.center, ) # 페이지 네비게이션 self.prev_button = ft.IconButton( icon=ft.Icons.ARROW_BACK, disabled=True, ) self.page_nav_text = ft.Text("1 / 1", size=14) self.next_button = ft.IconButton( icon=ft.Icons.ARROW_FORWARD, disabled=True, ) page_nav = ft.Row([ self.prev_button, self.page_nav_text, self.next_button, ], alignment=ft.MainAxisAlignment.CENTER) return ft.Container( content=ft.Column([ ft.Text( "👁️ PDF 미리보기", size=18, weight=ft.FontWeight.BOLD, color=ft.Colors.INDIGO_800 ), ft.Divider(), self.pdf_preview_container, page_nav, ], alignment=ft.MainAxisAlignment.START), padding=20, margin=10, bgcolor=ft.Colors.WHITE, border_radius=10, border=ft.border.all(1, ft.Colors.GREY_300), ) # 이벤트 핸들러들 def on_select_file_click(self, e): """파일 선택 버튼 클릭 핸들러""" self.file_picker.pick_files( allowed_extensions=["pdf"], allow_multiple=False ) def on_file_selected(self, e: ft.FilePickerResultEvent): """파일 선택 결과 핸들러""" if e.files: file = e.files[0] self.current_pdf_path = file.path # 파일 검증 if self.pdf_processor.validate_pdf_file(self.current_pdf_path): # PDF 정보 조회 self.current_pdf_info = self.pdf_processor.get_pdf_info(self.current_pdf_path) # 파일 크기 정보 추가 file_size_mb = self.current_pdf_info['file_size'] / (1024 * 1024) file_info = f"📄 {file.name} ({self.current_pdf_info['page_count']}페이지, {file_size_mb:.1f}MB)" self.selected_file_text.value = file_info self.selected_file_text.color = ft.Colors.GREEN_600 self.upload_button.disabled = False # 페이지 네비게이션 업데이트 self.page_nav_text.value = f"1 / {self.current_pdf_info['page_count']}" logger.info(f"PDF 파일 선택됨: {file.name}") else: self.selected_file_text.value = "❌ 유효하지 않은 PDF 파일입니다" self.selected_file_text.color = ft.Colors.RED_600 self.upload_button.disabled = True self.current_pdf_path = None self.current_pdf_info = None else: self.selected_file_text.value = "선택된 파일이 없습니다" self.selected_file_text.color = ft.Colors.GREY_600 self.upload_button.disabled = True self.current_pdf_path = None self.current_pdf_info = None self.page.update() def on_analysis_mode_change(self, e): """분석 모드 변경 핸들러""" if e.control.value == "custom": self.custom_prompt.visible = True else: self.custom_prompt.visible = False self.page.update() def on_organization_change(self, e): """조직 선택 변경 핸들러""" selected_org = e.control.value logger.info(f"조직 선택 변경: {selected_org}") # 필요한 경우 추가 작업 수행 self.page.update() def on_analysis_start_click(self, e): """분석 시작 버튼 클릭 핸들러""" if not self.current_pdf_path or not self.gemini_analyzer: return # 분석을 별도 스레드에서 실행 threading.Thread(target=self.run_analysis, daemon=True).start() def on_save_text_click(self, e): """텍스트 저장 버튼 클릭 핸들러""" self._save_results("text") def on_save_json_click(self, e): """JSON 저장 버튼 클릭 핸들러""" self._save_results("json") def _save_results(self, format_type: str): """결과 저장 공통 함수""" if not self.analysis_results or not self.current_pdf_info: self.show_error_dialog("저장 오류", "저장할 분석 결과가 없습니다.") return try: # 분석 설정 정보 수집 analysis_settings = { "페이지_선택": self.page_selector.value, "분석_모드": self.analysis_mode.value, "사용자_정의_프롬프트": self.custom_prompt.value if self.analysis_mode.value == "custom" else None, "분석_시간": DateTimeUtils.get_timestamp() } if format_type == "text": # 텍스트 파일로 저장 saved_path = self.result_saver.save_analysis_results( pdf_filename=self.current_pdf_info['filename'], analysis_results=self.analysis_results, pdf_info=self.current_pdf_info, analysis_settings=analysis_settings ) if saved_path: self.show_info_dialog( "저장 완료", f"분석 결과가 텍스트 파일로 저장되었습니다:\\n\\n{saved_path}" ) else: self.show_error_dialog("저장 실패", "텍스트 파일 저장 중 오류가 발생했습니다.") elif format_type == "json": # JSON 파일로 저장 saved_path = self.result_saver.save_analysis_json( pdf_filename=self.current_pdf_info['filename'], analysis_results=self.analysis_results, pdf_info=self.current_pdf_info, analysis_settings=analysis_settings ) if saved_path: self.show_info_dialog( "저장 완료", f"분석 결과가 JSON 파일로 저장되었습니다:\\n\\n{saved_path}" ) else: self.show_error_dialog("저장 실패", "JSON 파일 저장 중 오류가 발생했습니다.") except Exception as e: logger.error(f"결과 저장 중 오류: {e}") self.show_error_dialog("저장 오류", f"결과 저장 중 오류가 발생했습니다:\\n{str(e)}") def run_analysis(self): """분석 실행 (백그라운드 스레드)""" try: # 분석 시작 시간 기록 self.analysis_start_time = time.time() # UI 상태 업데이트 self.update_progress_ui(True, "PDF 이미지 변환 중...") # 조직 유형 결정 organization_type = "transportation" # 기본값 if self.organization_selector and self.organization_selector.value: if self.organization_selector.value == "한국도로공사": organization_type = "expressway" else: organization_type = "transportation" logger.info(f"선택된 조직 유형: {organization_type}") # 분석할 페이지 결정 if self.page_selector.value == "첫 번째 페이지": pages_to_analyze = [0] else: # 모든 페이지 pages_to_analyze = list(range(self.current_pdf_info['page_count'])) # 분석 프롬프트 결정 if self.analysis_mode.value == "custom": prompt = self.custom_prompt.value or Config.DEFAULT_PROMPT elif self.analysis_mode.value == "detailed": prompt = "이 PDF 이미지를 자세히 분석하여 다음 정보를 제공해주세요: 1) 문서 유형, 2) 주요 내용, 3) 도면/도표 정보, 4) 텍스트 내용, 5) 기타 특징" else: # basic prompt = Config.DEFAULT_PROMPT # 페이지별 분석 수행 total_pages = len(pages_to_analyze) self.analysis_results = {} for i, page_num in enumerate(pages_to_analyze): # 진행률 업데이트 progress = (i + 1) / total_pages self.update_progress_ui( True, f"페이지 {page_num + 1} 분석 중... ({i + 1}/{total_pages})", progress ) # PDF 페이지를 base64로 변환 base64_data = self.pdf_processor.pdf_page_to_base64( self.current_pdf_path, page_num ) if base64_data: # Gemini API로 분석 (조직 유형 전달) result = self.gemini_analyzer.analyze_image_from_base64( base64_data=base64_data, prompt=prompt, organization_type=organization_type ) if result: self.analysis_results[page_num] = result else: self.analysis_results[page_num] = f"페이지 {page_num + 1} 분석 실패" else: self.analysis_results[page_num] = f"페이지 {page_num + 1} 이미지 변환 실패" # 결과 표시 self.display_analysis_results() # 완료 상태로 업데이트 if self.analysis_start_time: duration = time.time() - self.analysis_start_time duration_str = DateTimeUtils.format_duration(duration) self.update_progress_ui(False, f"분석 완료! (소요시간: {duration_str})", 1.0) else: self.update_progress_ui(False, "분석 완료!", 1.0) except Exception as e: logger.error(f"분석 중 오류 발생: {e}") self.update_progress_ui(False, f"분석 오류: {str(e)}") self.show_error_dialog("분석 오류", f"분석 중 오류가 발생했습니다:\\n{str(e)}") def update_progress_ui( self, is_running: bool, status: str, progress: Optional[float] = None ): """진행률 UI 업데이트""" def update(): self.progress_ring.visible = is_running self.status_text.value = status if progress is not None: self.progress_bar.value = progress self.progress_bar.visible = True else: self.progress_bar.visible = is_running self.page.update() # 메인 스레드에서 UI 업데이트 self.page.run_thread(update) def display_analysis_results(self): """분석 결과 표시""" def update_results(): if self.analysis_results: # 결과 텍스트 구성 (요약 정보 포함) result_text = "📊 분석 요약\\n" result_text += f"- 분석된 페이지: {len(self.analysis_results)}개\\n" result_text += f"- 분석 완료 시간: {DateTimeUtils.get_timestamp()}\\n\\n" for page_num, result in self.analysis_results.items(): result_text += f"\\n📋 페이지 {page_num + 1} 분석 결과\\n" result_text += "=" * 50 + "\\n" result_text += result result_text += "\\n\\n" self.results_text.value = result_text.strip() # 저장 버튼 활성화 self.save_text_button.disabled = False self.save_json_button.disabled = False else: self.results_text.value = "분석 결과가 없습니다." self.save_text_button.disabled = True self.save_json_button.disabled = True self.page.update() # 메인 스레드에서 UI 업데이트 self.page.run_thread(update_results) def show_error_dialog(self, title: str, message: str): """오류 다이얼로그 표시""" dialog = UIComponents.create_error_dialog(title, message) def close_dialog(e): dialog.open = False self.page.update() dialog.actions[0].on_click = close_dialog self.page.dialog = dialog dialog.open = True self.page.update() def show_info_dialog(self, title: str, message: str): """정보 다이얼로그 표시""" dialog = UIComponents.create_info_dialog(title, message) def close_dialog(e): dialog.open = False self.page.update() dialog.actions[0].on_click = close_dialog self.page.dialog = dialog dialog.open = True self.page.update() def main(page: ft.Page): """메인 함수""" try: # 애플리케이션 초기화 app = PDFAnalyzerApp(page) # UI 구성 app.build_ui() logger.info("애플리케이션 시작 완료") except Exception as e: logger.error(f"애플리케이션 시작 실패: {e}") # 간단한 오류 페이지 표시 page.add( ft.Container( content=ft.Column([ ft.Text("애플리케이션 초기화 오류", size=24, weight=ft.FontWeight.BOLD), ft.Text(f"오류 내용: {str(e)}", size=16), ft.Text("설정을 확인하고 다시 시도하세요.", size=14), ], alignment=ft.MainAxisAlignment.CENTER), alignment=ft.alignment.center, expand=True, ) ) if __name__ == "__main__": # 애플리케이션 실행 ft.app( target=main, view=ft.AppView.FLET_APP, upload_dir="uploads", )