diff --git "a/app-backup.py" "b/app-backup.py" --- "a/app-backup.py" +++ "b/app-backup.py" @@ -1,519 +1,51 @@ +""" +기업마당 AI 분석기 - 메인 애플리케이션 +- 벡터 DB 캐시 통합으로 빠른 로딩 +- KST 10:00/22:00 자동 동기화 +""" import gradio as gr -import requests import pandas as pd -from typing import Optional, Tuple, List, Dict, Generator +import json import os import tempfile -import zipfile -import re -import json -import zlib from pathlib import Path -from bs4 import BeautifulSoup -import urllib3 -from datetime import datetime, timedelta -urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) -try: - import olefile - OLEFILE_AVAILABLE = True -except ImportError: - OLEFILE_AVAILABLE = False -try: - import PyPDF2 - PYPDF2_AVAILABLE = True -except ImportError: - PYPDF2_AVAILABLE = False -try: - import pdfplumber - PDFPLUMBER_AVAILABLE = True -except ImportError: - PDFPLUMBER_AVAILABLE = False -try: - from groq import Groq - GROQ_AVAILABLE = True -except ImportError: - GROQ_AVAILABLE = False -from xml.etree import ElementTree as ET -API_URL = "https://www.bizinfo.go.kr/uss/rss/bizinfoApi.do" -API_KEY = os.getenv("BIZ_API", "") -GROQ_API_KEY = os.getenv("GROQ_API_KEY", "") -CATEGORY_CODES = { - "전체": "", - "금융": "01", - "기술": "02", - "인력": "03", - "수출": "04", - "내수": "05", - "창업": "06", - "경영": "07", - "기타": "09" -} -REGION_LIST = [ - "전체(지역)", "서울", "부산", "대구", "인천", "광주", "대전", "울산", "세종", - "경기", "강원", "충북", "충남", "전북", "전남", "경북", "경남", "제주" -] -ORG_TYPE_OPTIONS = ["전체", "중앙부처", "지자체"] -SORT_OPTIONS = ["등록일순", "마감일순"] -STATUS_OPTIONS = ["진행중", "전체(지난공고포함)"] -CENTRAL_GOV_KEYWORDS = [ - "부", "처", "청", "원", "위원회", "중소벤처기업부", "산업통상", "과학기술", "고용노동", - "농림축산", "해양수산", "환경부", "국토교통", "문화체육", "보건복지", "행정안전", - "기획재정", "외교부", "법무부", "국방부", "교육부", "통일부", "여성가족", - "특허청", "관세청", "조달청", "통계청", "기상청", "소방청", "경찰청", - "산림청", "농촌진흥청", "식품의약품안전처", "방위사업청", "병무청", "국세청", - "대한무역투자진흥공사", "한국산업기술진흥원", "한국에너지공단", "한국인터넷진흥원", - "정보통신산업진흥원", "한국콘텐츠진흥원", "한국관광공사", "대한상공회의소", - "중소기업중앙회", "기술보증기금", "신용보증기금", "한국산업안전보건공단" -] -LOCAL_GOV_KEYWORDS = [ - "도", "시", "군", "구청", "특별시", "광역시", "특별자치", "자치도", "자치시", - "서울", "부산", "대구", "인천", "광주", "대전", "울산", "세종", - "경기", "강원", "충북", "충남", "전북", "전남", "경북", "경남", "제주", - "테크노파크", "TP", "경제진흥원", "창조경제", "지역혁신", "소상공인진흥" -] -REGION_MATCH_KEYWORDS = { - "서울": ["서울", "서울특별시", "서울시", "SBA", "서울산업진흥원", "서울경제진흥원", - "서울신용보증재단", "서울테크노파크", "서울창업허브", "SH공사"], - "부산": ["부산", "부산광역시", "부산시", "부산경제진흥원", "부산테크노파크"], - "대구": ["대구", "대구광역시", "대구시", "대구경북", "대구테크노파크"], - "인천": ["인천", "인천광역시", "인천시", "인천경제자유구역", "인천테크노파크"], - "광주": ["광주", "광주광역시", "광주시", "광주전남", "광주테크노파크"], # 경기도 광주시와 구분 필요 - "대전": ["대전", "대전광역시", "대전시", "대전테크노파크", "대전경제통상진흥원"], - "울산": ["울산", "울산광역시", "울산시", "울산테크노파크", "울산경제진흥원"], - "세종": ["세종", "세종특별자치시", "세종시", "세종테크노파크"], - "경기": ["경기", "경기도", "경기TP", "경기테크노파크", "경기경제과학진흥원", - "수원", "성남", "고양", "용인", "부천", "안산", "안양", - "남양주", "화성", "평택", "의정부", "시흥", "파주", "김포", "광명", "광주시", - "군포", "하남", "오산", "이천", "안성", "의왕", "양평", "여주", "과천", - "구리", "포천", "동두천", "가평", "연천"], - "강원": ["강원", "강원도", "강원특별자치도", "강원TP", "강원테크노파크", "강원경제진흥원", - "춘천", "원주", "강릉", "동해", "태백", - "속초", "삼척", "홍천", "횡성", "영월", "평창", "정선", "철원", "화천", - "양구", "인제", "고성", "양양"], - "충북": ["충북", "충청북도", "충북TP", "충북테크노파크", "충청북도기업진흥원", - "청주", "충주", "제천", "보은", "���천", "영동", "증평", - "진천", "괴산", "음성", "단양"], - "충남": ["충남", "충청남도", "충남TP", "충남테크노파크", "충남경제진흥원", - "천안", "공주", "보령", "아산", "서산", "논산", "계룡", - "당진", "금산", "부여", "서천", "청양", "홍성", "예산", "태안"], - "전북": ["전북", "전라북도", "전북특별자치도", "전북TP", "전북테크노파크", "전북경제통상진흥원", - "전주", "군산", "익산", "정읍", "남원", - "김제", "완주", "진안", "무주", "장수", "임실", "순창", "고창", "부안"], - "전남": ["전남", "전라남도", "전남TP", "전남테크노파크", "전남경제통상진흥원", - "목포", "여수", "순천", "나주", "광양", "담양", "곡성", - "구례", "고흥", "보성", "화순", "장흥", "강진", "해남", "영암", "무안", - "함평", "영광", "장성", "완도", "진도", "신안"], - "경북": ["경북", "경상북도", "경북TP", "경북테크노파크", "경북경제진흥원", - "포항", "경주", "김천", "안동", "구미", "영주", "영천", - "상주", "문경", "경산", "군위", "의성", "청송", "영양", "영덕", "청도", - "고령", "성주", "칠곡", "예천", "봉화", "울진", "울릉"], - "경남": ["경남", "경상남도", "경남TP", "경남테크노파크", "경남경제진흥원", - "창원", "진주", "통영", "사천", "김해", "밀양", "거제", - "양산", "의령", "함안", "창녕", "고성", "남해", "하동", "산청", "함양", - "거창", "합천"], - "제주": ["제주", "제주도", "제주특별자치도", "제주시", "서귀포", "제주TP", "제주테크노파크"] -} -def extract_region_from_text(text: str) -> Optional[str]: - """텍스트(사업명, 소관기관명 등)에서 지역 정보 추출""" - if not text: - return None - text = text.strip() - bracket_match = re.match(r'\[([가-힣]+)\]', text) - if bracket_match: - region_text = bracket_match.group(1) - for region, keywords in REGION_MATCH_KEYWORDS.items(): - for kw in keywords: - if kw in region_text: - return region - priority_regions = ["서울", "부산", "대구", "인천", "광주", "대전", "울산", "세종"] - for region in priority_regions: - keywords = REGION_MATCH_KEYWORDS.get(region, []) - for kw in keywords: - if kw in text: - if region == "광주": - if "경기" in text or "경기도" in text: - continue # 경기도 광주시이므로 스킵 - return "광주" - else: - return region - province_regions = ["경기", "강원", "충북", "충남", "전북", "전남", "경북", "경남", "제주"] - for region in province_regions: - keywords = REGION_MATCH_KEYWORDS.get(region, []) - for kw in keywords: - if kw in text: - return region - return None -def extract_region_from_hashtags(hash_tags: str) -> Optional[str]: - """해시태그 문자열에서 지역 정보 추출 (쉼표로 구분된 형태)""" - if not hash_tags: - return None - tags = [t.strip() for t in hash_tags.split(",")] - region_names = list(REGION_MATCH_KEYWORDS.keys()) - for tag in tags: - if tag in region_names: - return tag - for region in region_names: - if region in tag or tag in REGION_MATCH_KEYWORDS.get(region, []): - return region - return None -def match_region(item_region: Optional[str], filter_region: str) -> bool: - """아이템의 지역이 필터 지역과 일치하는지 확인""" - if filter_region == "전체(지역)": - return True - if item_region is None: - return False - return item_region == filter_region -def classify_org_type(author: str) -> str: - """소관기관명을 기반으로 기관유형 분류""" - if not author: - return "기타" - author = author.strip() - if author.startswith("[") and "]" in author: - return "지자체" - for keyword in LOCAL_GOV_KEYWORDS: - if keyword in author: - is_central = False - for central_kw in CENTRAL_GOV_KEYWORDS: - if central_kw in author and len(central_kw) > 2: - is_central = True - break - if not is_central: - return "지자체" - for keyword in CENTRAL_GOV_KEYWORDS: - if keyword in author: - return "중앙부처" - return "중앙부처" # 기본값 -def parse_deadline(req_dt: str) -> Optional[datetime]: - """신청기간에서 마감일 추출""" - if not req_dt: - return None - try: - if "~" in req_dt: - parts = req_dt.split("~") - end_date_str = parts[-1].strip() - else: - end_date_str = req_dt.strip() - end_date_str = re.sub(r'[^0-9]', '', end_date_str) - if len(end_date_str) >= 8: - return datetime.strptime(end_date_str[:8], "%Y%m%d") - except: - pass - return None -def is_ongoing(req_dt: str) -> bool: - """진행중 공고인지 확인""" - if not req_dt: - return True # 기간 정보 없으면 진행중으로 간주 - if any(kw in req_dt for kw in ["소진", "추후", "상시", "별도"]): - return True - deadline = parse_deadline(req_dt) - if deadline: - return deadline >= datetime.now() - return True # 파싱 실패시 진행중으로 간주 -def extract_text_from_hwpx(file_path: str) -> Tuple[Optional[str], Optional[str]]: - """HWPX 파일에서 텍스트 추출""" - try: - text_parts = [] - with zipfile.ZipFile(file_path, 'r') as zf: - file_list = zf.namelist() - section_files = sorted([f for f in file_list if f.startswith('Contents/section') and f.endswith('.xml')]) - if not section_files: - section_files = sorted([f for f in file_list if 'section' in f.lower() and f.endswith('.xml')]) - for section_file in section_files: - try: - with zf.open(section_file) as sf: - content = sf.read() - content_str = content.decode('utf-8') - content_str = re.sub(r'\sxmlns[^"]*"[^"]*"', '', content_str) - content_str = re.sub(r'<[a-zA-Z]+:', '<', content_str) - content_str = re.sub(r'([^<]+)<', content.decode('utf-8', errors='ignore')) - clean_texts = [t.strip() for t in text_matches if t.strip() and len(t.strip()) > 1] - if clean_texts: - text_parts.append(' '.join(clean_texts)) - except: - continue - if text_parts: - result = '\n\n'.join(text_parts) - result = re.sub(r'\s+', ' ', result) - result = re.sub(r'\n{3,}', '\n\n', result) - return result.strip(), None - return None, "HWPX에서 텍스트를 찾을 수 없습니다" - except zipfile.BadZipFile: - return None, "유효하지 않은 HWPX 파일" - except Exception as e: - return None, f"HWPX 처리 오류: {str(e)}" -def extract_hwp_section_text(data: bytes) -> Optional[str]: - """HWP 섹션 데이터에서 텍스트 추출""" - texts = [] - pos = 0 - while pos < len(data) - 4: - try: - header = int.from_bytes(data[pos:pos+4], 'little') - tag_id = header & 0x3FF - size = (header >> 20) & 0xFFF - pos += 4 - if size == 0xFFF: - if pos + 4 > len(data): - break - size = int.from_bytes(data[pos:pos+4], 'little') - pos += 4 - if pos + size > len(data): - break - record_data = data[pos:pos+size] - pos += size - if tag_id == 67 and size > 0: - text = decode_para_text(record_data) - if text: - texts.append(text) - except: - pos += 1 - continue - return '\n'.join(texts) if texts else None -def decode_para_text(data: bytes) -> Optional[str]: - """HWP 문단 텍스트 디코딩""" - result = [] - i = 0 - while i < len(data) - 1: - code = int.from_bytes(data[i:i+2], 'little') - if code == 0: - pass - elif code == 1: - i += 14 - elif code == 2: - i += 14 - elif code == 3: - i += 14 - elif code == 4: - pass - elif code == 9: - result.append('\t') - elif code == 10: - result.append('\n') - elif code == 13: - result.append('\n') - elif code == 24: - result.append('-') - elif code == 30 or code == 31: - result.append(' ') - elif code < 32: - pass - else: - try: - char = chr(code) - if char.isprintable() or char in '\n\t ': - result.append(char) - except: - pass - i += 2 - text = ''.join(result).strip() - text = re.sub(r'[ \t]+', ' ', text) - text = re.sub(r'\n{3,}', '\n\n', text) - return text if len(text) > 2 else None -def extract_text_from_hwp(file_path: str) -> Tuple[Optional[str], Optional[str]]: - """HWP 파일에서 텍스트 추출 (olefile 사용)""" - if not OLEFILE_AVAILABLE: - return None, "olefile 모듈 없음" - try: - ole = olefile.OleFileIO(file_path) - if not ole.exists('FileHeader'): - ole.close() - return None, "HWP 파일 헤더 없음" - header_data = ole.openstream('FileHeader').read() - is_compressed = (header_data[36] & 1) == 1 if len(header_data) > 36 else True - all_texts = [] - for entry in ole.listdir(): - entry_path = '/'.join(entry) - if entry_path.startswith('BodyText/Section'): - try: - stream_data = ole.openstream(entry).read() - if is_compressed: - try: - stream_data = zlib.decompress(stream_data, -15) - except: - try: - stream_data = zlib.decompress(stream_data) - except: - pass - section_text = extract_hwp_section_text(stream_data) - if section_text: - all_texts.append(section_text) - except: - continue - ole.close() - if all_texts: - return '\n\n'.join(all_texts).strip(), None - return None, "텍스트를 찾을 수 없습니다" - except Exception as e: - return None, f"olefile 오류: {str(e)}" -def extract_text_from_pdf(file_path: str) -> Optional[str]: - """PDF 파일에서 텍스트 추출""" - text_parts = [] - if PDFPLUMBER_AVAILABLE: - try: - with pdfplumber.open(file_path) as pdf: - for page in pdf.pages: - text = page.extract_text() - if text: - text_parts.append(text) - if text_parts: - return "\n\n".join(text_parts) - except Exception as e: - print(f"pdfplumber error: {e}") - if PYPDF2_AVAILABLE: - try: - with open(file_path, 'rb') as f: - reader = PyPDF2.PdfReader(f) - for page in reader.pages: - text = page.extract_text() - if text: - text_parts.append(text) - if text_parts: - return "\n\n".join(text_parts) - except Exception as e: - print(f"PyPDF2 error: {e}") - return None -def extract_text_from_file(file_path: str) -> Tuple[Optional[str], Optional[str]]: - """파일 유형에 따라 텍스트 추출""" - ext = Path(file_path).suffix.lower() - if ext == '.hwp': - return extract_text_from_hwp(file_path) - elif ext == '.hwpx': - return extract_text_from_hwpx(file_path) - elif ext == '.pdf': - text = extract_text_from_pdf(file_path) - if text: - return text, None - return None, "PDF 텍스트 추출 실패" - elif ext in ['.txt', '.md']: - try: - with open(file_path, 'r', encoding='utf-8') as f: - return f.read(), None - except: - try: - with open(file_path, 'r', encoding='cp949') as f: - return f.read(), None - except Exception as e: - return None, f"텍스트 파일 읽기 실패: {e}" - else: - return None, f"지원하지 않는 파일 형식: {ext}" -def fetch_all_from_api( - category: str = "전체", - region: str = "전체(지역)", - keyword: str = "" -) -> Tuple[List[Dict], str]: - """ - API에서 전체 데이터를 페이지네이션으로 수집 - Returns: - (items 리스트, 에러메시지 or "") - """ - if not API_KEY: - return [], "❌ API 키가 설정되지 않았습니다. (BIZ_API 환경변수)" - all_items = [] - page_size = 100 # 한 페이지당 가져올 건수 - max_pages = 10 # 최대 10페이지 (1000건) - headers = { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", - "Accept": "application/json", - } - hashtags = [] - if category and category != "전체": - hashtags.append(category) - if region and region != "전체(지역)": - hashtags.append(region) - if keyword and keyword.strip(): - hashtags.append(keyword.strip()) - for page_idx in range(1, max_pages + 1): - try: - params = { - "crtfcKey": API_KEY, - "dataType": "json", - "pageUnit": page_size, - "pageIndex": page_idx, - } - if category and category != "전체" and category in CATEGORY_CODES: - if CATEGORY_CODES[category]: - params["searchLclasId"] = CATEGORY_CODES[category] - if hashtags: - params["hashtags"] = ",".join(hashtags) - response = requests.get(API_URL, params=params, headers=headers, timeout=(15, 60), verify=True) - response.raise_for_status() - result = response.json() - items = [] - json_array = result.get("jsonArray", result) - if isinstance(json_array, dict): - items = json_array.get("item", []) - if isinstance(items, dict): - items = [items] - elif isinstance(json_array, list): - items = json_array - if not items: - break # 더 이상 데이터 없음 - all_items.extend(items) - if page_idx == 1 and items: - total_cnt = items[0].get("totCnt", 0) - try: - total_cnt = int(total_cnt) - except: - total_cnt = len(items) - needed_pages = (total_cnt + page_size - 1) // page_size - max_pages = min(max_pages, needed_pages) - if len(items) < page_size: - break - except requests.exceptions.Timeout: - return all_items, f"⏱️ 페이지 {page_idx} 요청 시간 초과" - except requests.exceptions.RequestException as e: - if all_items: # 이미 일부 데이터가 있으면 그것으로 진행 - break - return [], f"❌ API 요청 오류: {str(e)[:50]}" - except Exception as e: - if all_items: - break - return [], f"❌ 오류: {str(e)[:50]}" - return all_items, "" -def fetch_announcements( - keyword: str = "", - category: str = "전체", - region: str = "전체(지역)", - org_type: str = "전체", - sort_by: str = "등록일순", - status_filter: str = "진행중", - page: int = 1, - rows: int = 20 -) -> Tuple[pd.DataFrame, str]: - """ - 기업마당 API로 공고 목록 조회 - [중요] API 제한사항: - - API는 정렬/기관유형 필터를 지원하지 않음 - - 따라서 전체 데이터를 가져온 후 클라이언트에서 필터링/정렬 - Args: - keyword: 검색어 - category: 지원분야 (전체/금융/기술 등) - region: 지역 (전체/서울/부산 등) - org_type: 기관유형 (전체/중앙부처/지자체) - 클라이언트 필터링 - sort_by: 정렬 (등록일순/마감일순) - 클라이언트 정렬 - status_filter: 공고상태 (진행중/전체) - 클라이언트 필터링 - page: 페이지 번호 - rows: 페이지당 표시 건수 - """ +from typing import Optional, Tuple, List, Dict, Generator +from datetime import datetime + +from utils import ( + CATEGORY_CODES, REGION_LIST, SIDO_LIST, ORG_TYPE_OPTIONS, SORT_OPTIONS, STATUS_OPTIONS, + COMPANY_TYPE_OPTIONS, CORP_TYPE_OPTIONS, COMPANY_SIZE_OPTIONS, INDUSTRY_MAJOR_OPTIONS, + CORE_INDUSTRY_OPTIONS, NATIONAL_STRATEGIC_TECH, CREDIT_GRADE_OPTIONS, TCB_GRADE_OPTIONS, + ISO_CERT_OPTIONS, extract_region_from_text, extract_region_from_hashtags, + classify_org_type, parse_deadline, is_ongoing, calculate_age, calculate_company_age +) + +from file_api import ( + fetch_all_from_api, fetch_with_cache, download_file, extract_text_from_file, extract_zip_files, + call_groq_api_stream, fetch_announcement_detail, CACHE_AVAILABLE +) + +# 캐시 시스템 임포트 +if CACHE_AVAILABLE: + from cache_db import ( + initialize_cache_system, get_sync_status, manual_sync, + get_cached_announcements, get_cache + ) + + +# ============================================================ +# 공고 조회 함수 (캐시 통합) +# ============================================================ +def fetch_announcements(keyword="", category="전체", region="전체(지역)", org_type="전체", + sort_by="등록일순", status_filter="진행중", page=1, rows=20) -> Tuple[pd.DataFrame, str]: + """기업마당 API로 공고 목록 조회 (캐시 우선)""" try: - items, error = fetch_all_from_api(category, region, keyword) - if error and not items: - return pd.DataFrame(), error + # ⭐ 항상 캐시 우선 사용 (필터가 있어도 캐시에서 필터링 - 빠름!) + items, status_prefix = fetch_with_cache(category, region, keyword) + if not items: - return pd.DataFrame(), "⚠️ 검색 결과가 없습니다." + return pd.DataFrame(), f"⚠️ 검색 결과가 없습니다. {status_prefix}" + data = [] for item in items: if not isinstance(item, dict): @@ -522,71 +54,75 @@ def fetch_announcements( title = item.get("title", "") or item.get("pblancNm", "") exec_org = item.get("excInsttNm", "") or "" hash_tags = item.get("hashTags", "") + item_region = extract_region_from_hashtags(hash_tags) if not item_region: item_region = extract_region_from_text(title) if not item_region: item_region = extract_region_from_text(author) - if not item_region: - item_region = extract_region_from_text(exec_org) + item_org_type = classify_org_type(author) req_dt = item.get("reqstDt", "") or item.get("reqstBeginEndDe", "") item_ongoing = is_ongoing(req_dt) pub_date = item.get("pubDate", "") or item.get("creatPnttm", "") or "" if pub_date and len(str(pub_date)) >= 10: pub_date = str(pub_date)[:10] + link = item.get("link", "") or item.get("pblancUrl", "") pblanc_id = item.get("seq", "") or item.get("pblancId", "") - if not pblanc_id and "pblancId=" in link: - pblanc_id = link.split("pblancId=")[-1].split("&")[0] + + # 일반 첨부파일 (서식, 양식 등) attachments = [] - file_url = item.get("flpthNm", "") - file_name = item.get("fileNm", "") + file_url = item.get("flpthNm", "") or "" + file_name = item.get("fileNm", "") or "" if file_url and file_name: - attachments.append({ - "url": file_url, - "filename": file_name, - "type": Path(file_name).suffix.lower()[1:] if Path(file_name).suffix else "unknown" - }) - print_url = item.get("printFlpthNm", "") - print_name = item.get("printFileNm", "") + # 여러 파일이 @로 구분되어 있을 수 있음 + urls = file_url.split("@") if "@" in file_url else [file_url] + names = file_name.split("@") if "@" in file_name else [file_name] + for i, (url, name) in enumerate(zip(urls, names)): + url = url.strip() + name = name.strip() + if url and name: + ext = Path(name).suffix.lower()[1:] if Path(name).suffix else "unknown" + attachments.append({"url": url, "filename": name, "type": ext}) + + # ⭐ 본문출력파일 (공고 본문 PDF/HWP) - AI 분석용 핵심 파일 + print_file = None + print_url = item.get("printFlpthNm", "") or "" + print_name = item.get("printFileNm", "") or "" if print_url and print_name: - if not any(att['url'] == print_url for att in attachments): - attachments.append({ - "url": print_url, - "filename": print_name, - "type": Path(print_name).suffix.lower()[1:] if Path(print_name).suffix else "unknown" - }) + print_url = print_url.strip() + print_name = print_name.strip() + ext = Path(print_name).suffix.lower()[1:] if Path(print_name).suffix else "unknown" + print_file = {"url": print_url, "filename": print_name, "type": ext} + description = item.get("description", "") or item.get("bsnsSumryCn", "") if description: + import re description = re.sub(r'<[^>]+>', '', description).strip() + deadline = parse_deadline(req_dt) row = { "지원분야": item.get("lcategory", "") or item.get("pldirSportRealmLclasCodeNm", ""), - "지원사업명": title, - "신청기간": req_dt, - "소관부처": author, - "수행기관": exec_org, - "등록일": pub_date, - "조회수": item.get("inqireCo", "") or "", - "상세링크": link, - "공고ID": pblanc_id, + "지원사업명": title, "신청기간": req_dt, "소관부처": author, + "수행기관": exec_org, "등록일": pub_date, "조회수": item.get("inqireCo", "") or "", + "상세링크": link, "공고ID": pblanc_id, "사업개요": description[:200] + "..." if len(description) > 200 else description, - "첨부파일": attachments, + "첨부파일": attachments, # 일반 첨부파일 (서식, 양식) + "본문출력파일": print_file, # ⭐ AI 분석용 핵심 파일 "지원대상": item.get("trgetNm", ""), - "문의처": item.get("refrncNm", ""), - "신청URL": item.get("rceptEngnHmpgUrl", ""), - "_org_type": item_org_type, - "_ongoing": item_ongoing, - "_deadline": deadline, - "_pub_date": pub_date, - "_region": item_region, # 지역 정보 추�� + "문의처": item.get("refrncNm", ""), "신청URL": item.get("rceptEngnHmpgUrl", ""), + "_org_type": item_org_type, "_ongoing": item_ongoing, "_deadline": deadline, + "_pub_date": pub_date, "_region": item_region, } data.append(row) + if not data: - return pd.DataFrame(), "⚠️ 검색 결과가 없습니다." + return pd.DataFrame(), f"⚠️ 검색 결과가 없습니다. {status_prefix}" + df = pd.DataFrame(data) total_before_filter = len(df) + if org_type == "중앙부처": df = df[df["_org_type"] == "중앙부처"] elif org_type == "지자체": @@ -595,695 +131,1147 @@ def fetch_announcements( df = df[df["_region"] == region] if status_filter == "진행중": df = df[df["_ongoing"] == True] + if sort_by == "등록일순": df = df.sort_values(by="_pub_date", ascending=False) elif sort_by == "마감일순": - df = df.sort_values( - by="_deadline", - ascending=True, - na_position='last' - ) + df = df.sort_values(by="_deadline", ascending=True, na_position='last') + if len(df) == 0: return pd.DataFrame(), f"⚠️ 필터 조건에 맞는 결과가 없습니다. (전체 {total_before_filter}건 중)" + total_filtered = len(df) start_idx = (page - 1) * rows end_idx = start_idx + rows df_page = df.iloc[start_idx:end_idx].copy() df_page.insert(0, "번호", range(total_filtered - start_idx, total_filtered - start_idx - len(df_page), -1)) + + # ⭐ 원본 데이터 보존 (AI 분석용) + df_full = df_page.copy() + + # 표시용 DataFrame 생성 internal_cols = [c for c in df_page.columns if c.startswith("_")] - df_page = df_page.drop(columns=internal_cols) - filter_info = [] - if category != "전체": - filter_info.append(f"분야:{category}") - if region != "전체(지역)": - filter_info.append(f"지역:{region}") - if org_type != "전체": - filter_info.append(f"기관:{org_type}") - if keyword: - filter_info.append(f"검색:{keyword}") - status = f"✅ {len(df_page)}건 표시 (페이지 {page}) | 필터 결과: {total_filtered}건 | API 수집: {total_before_filter}건" - if filter_info: - status += f" | {', '.join(filter_info)}" - status += f" | 정렬: {sort_by}" - if status_filter == "진행중": - status += " | 진행중 공고만" - return df_page, status - except Exception as e: - import traceback - return pd.DataFrame(), f"❌ 오류: {str(e)[:80]}\n{traceback.format_exc()[:200]}" -def fetch_announcement_detail(url: str) -> Tuple[str, List[Dict]]: - """공고 상세 페이지에서 본문과 첨부파일 정보 추출""" - try: - headers = { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", - "Accept-Language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7", - } - response = requests.get(url, headers=headers, timeout=30, verify=False) - response.raise_for_status() - html_text = response.text - soup = BeautifulSoup(html_text, 'html.parser') - content_text = "" - tables = soup.find_all('table') - for table in tables: - text = table.get_text(separator='\n', strip=True) - if '사업개요' in text or '지원대상' in text or '신청기간' in text: - content_text += text + "\n\n" - main_content = soup.find('div', {'id': 'container'}) or soup.find('main') or soup.find('article') - if main_content and not content_text: - content_text = main_content.get_text(separator='\n', strip=True) - attachments = [] - for a_tag in soup.find_all('a', href=True): - href = a_tag.get('href', '') - href_clean = re.sub(r';jsessionid=[^?]*', '', href) - if 'getImageFile.do' in href_clean or 'fileDown' in href_clean or 'atchFileId' in href_clean: - filename = a_tag.get_text(strip=True) - if filename in ['다운로드', '바로보기', '내려받기', '']: - parent = a_tag.parent - if parent: - parent_text = parent.get_text(separator='|', strip=True) - parts = [p.strip() for p in parent_text.split('|') if p.strip()] - for part in parts: - if part not in ['다운로드', '바로보기', '내려받기'] and ('.' in part): - filename = part - break - title = a_tag.get('title', '') - if title and '첨부파일' in title: - match = re.search(r'첨부파일\s+(.+?)\s+다운로드', title) - if match: - filename = match.group(1) - if not filename or filename in ['다운로드', '바로보기', '내려받기']: - filename = f"첨부파일_{len(attachments)+1}" - if href_clean.startswith('/'): - full_url = f"https://www.bizinfo.go.kr{href_clean}" - elif href_clean.startswith('http'): - full_url = href_clean - else: - continue - ext = Path(filename).suffix.lower() - if not ext: - ext = '.unknown' - if not any(att['url'] == full_url for att in attachments): - attachments.append({ - "filename": filename, - "url": full_url, - "type": ext[1:] if ext.startswith('.') else ext - }) - return content_text, attachments + df_display = df_page.drop(columns=internal_cols) + + # 표시용에서 불필요한 컬럼 숨기기 + hide_cols = ["공고ID", "첨부파일", "본문출력파일", "사업개요", "문의처", "신청URL"] + for col in hide_cols: + if col in df_display.columns: + df_display = df_display.drop(columns=[col]) + + status = f"✅ {len(df_page)}건 표시 (페이지 {page}) | 필터 결과: {total_filtered}건 | 수집: {total_before_filter}건" + if status_prefix: + status = f"{status_prefix} | {status}" + + # 표시용 df, 상태, 원본 df 반환 + return df_display, status, df_full except Exception as e: import traceback - return f"상세 정보 조회 실패: {str(e)}\n{traceback.format_exc()}", [] -def download_file(url: str, save_dir: str, hint_filename: str = None) -> Tuple[Optional[str], Optional[str]]: - """파일 다운로드""" - try: - headers = { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", - "Accept": "*/*", - "Referer": "https://www.bizinfo.go.kr/", - } - response = requests.get(url, headers=headers, timeout=60, stream=True, verify=False, allow_redirects=True) - response.raise_for_status() - cd = response.headers.get('Content-Disposition', '') - filename = None - if cd: - match = re.search(r"filename\*=(?:UTF-8''|utf-8'')(.+)", cd, re.IGNORECASE) - if match: - from urllib.parse import unquote - filename = unquote(match.group(1)) - else: - match = re.search(r'filename=(["\']?)(.+?)\1(?:;|$)', cd) - if match: - filename = match.group(2) - try: - from urllib.parse import unquote - decoded = unquote(filename) - if decoded != filename: - filename = decoded - except: - pass - try: - filename = filename.encode('iso-8859-1').decode('utf-8') - except: - pass - if not filename and hint_filename: - filename = hint_filename - if not filename: - from urllib.parse import urlparse - parsed = urlparse(url) - filename = parsed.path.split('/')[-1] - if not filename or '.' not in filename or filename.endswith('.do'): - content_type = response.headers.get('Content-Type', '').lower() - if 'pdf' in content_type: - filename = f"document_{hash(url) % 10000}.pdf" - elif 'hwp' in content_type or 'haansoft' in content_type: - filename = f"document_{hash(url) % 10000}.hwp" - elif 'zip' in content_type or 'compressed' in content_type: - filename = f"archive_{hash(url) % 10000}.zip" - else: - filename = f"file_{hash(url) % 10000}.bin" - filename = re.sub(r'[<>:"/\\|?*]', '_', filename) - file_path = os.path.join(save_dir, filename) - base, ext = os.path.splitext(file_path) - counter = 1 - while os.path.exists(file_path): - file_path = f"{base}_{counter}{ext}" - counter += 1 - with open(file_path, 'wb') as f: - for chunk in response.iter_content(chunk_size=8192): - if chunk: - f.write(chunk) - if os.path.getsize(file_path) == 0: - os.remove(file_path) - return None, "빈 파일이 다운로드됨" - return file_path, None - except Exception as e: - return None, f"다운로드 실패: {str(e)}" -def extract_zip_files(zip_path: str, extract_dir: str) -> List[str]: - """ZIP 파일 압축 해제""" - extracted_files = [] - try: - with zipfile.ZipFile(zip_path, 'r') as zf: - for name in zf.namelist(): - ext = Path(name).suffix.lower() - if ext in ['.hwp', '.hwpx', '.pdf', '.txt', '.doc', '.docx']: - try: - try: - decoded_name = name.encode('cp437').decode('cp949') - except: - decoded_name = name - safe_name = os.path.basename(decoded_name) - extract_path = os.path.join(extract_dir, safe_name) - with zf.open(name) as src, open(extract_path, 'wb') as dst: - dst.write(src.read()) - extracted_files.append(extract_path) - except Exception as e: - print(f"ZIP 파일 추출 오류 ({name}): {e}") - continue - except Exception as e: - print(f"ZIP 처리 오류: {e}") - return extracted_files -def call_groq_api_stream(messages: List[Dict]) -> Generator[str, None, None]: - """Groq API 스트리밍 호출""" - if not GROQ_AVAILABLE: - yield "❌ Groq 라이브러리가 설치되지 않았습니다." - return - if not GROQ_API_KEY: - yield "❌ GROQ_API_KEY 환경변수가 설정되지 않았습니다." - return - try: - client = Groq(api_key=GROQ_API_KEY) - completion = client.chat.completions.create( - model="llama-3.3-70b-versatile", - messages=messages, - temperature=0.3, - max_tokens=4096, - stream=True, - ) - for chunk in completion: - if chunk.choices[0].delta.content: - yield chunk.choices[0].delta.content - except Exception as e: - yield f"❌ API 오류: {str(e)}" -def analyze_announcement( - detail_url: str, - project_name: str, - api_attachments: list = None, - api_description: str = "", - progress=gr.Progress() -) -> Generator[str, None, None]: - """공고 첨부파일을 다운로드하고 AI로 분석""" + return pd.DataFrame(), f"❌ 오류: {str(e)[:80]}", pd.DataFrame() + + +# ============================================================ +# AI 분석 함수 +# ============================================================ +def analyze_announcement(detail_url, project_name, print_file=None, api_description="", progress=gr.Progress()): + """공고 본문출력파일을 다운로드하고 AI로 분석 + + Args: + detail_url: 공고 상세 링크 + project_name: 공고명 + print_file: 본문출력파일 정보 (dict: url, filename, type) - API에서 온 경우 + api_description: 사업개요 + """ if not detail_url: yield "❌ 분석할 공고를 선택해주세요." return - output_buffer = "" - output_buffer += f"# 📄 {project_name}\n\n" - output_buffer += "---\n\n" - attachments = [] - content_text = "" - if api_attachments and len(api_attachments) > 0: - output_buffer += "✅ **API에서 첨부파일 정보를 가져왔습니다.**\n\n" - attachments = api_attachments - else: - output_buffer += "🔍 **공고 상세 페이지에서 첨부파일 정보 조회 중...**\n\n" - yield output_buffer - progress(0.1, desc="상세 정보 조회 중...") - content_text, attachments = fetch_announcement_detail(detail_url) + + output = f"# 📄 {project_name}\n\n---\n\n" all_text = f"## 공고명: {project_name}\n\n" + if api_description: all_text += f"### 사업개요:\n{api_description}\n\n" - output_buffer += f"📋 **사업개요**\n{api_description}\n\n" - yield output_buffer - if content_text: - content_preview = content_text[:500] + "..." if len(content_text) > 500 else content_text - output_buffer += f"📝 **본문 내용 ({len(content_text):,}자)**\n```\n{content_preview}\n```\n\n" - yield output_buffer - all_text += f"### 공고 본문:\n{content_text[:5000]}...\n\n" if len(content_text) > 5000 else f"### 공고 본문:\n{content_text}\n\n" - output_buffer += f"📎 **첨부파일 {len(attachments)}개 발견**\n" - if attachments: - for att in attachments: - fname = att.get('filename', '파일') - ftype = att.get('type', 'unknown') - output_buffer += f" - `{fname}` ({ftype})\n" - else: - output_buffer += " - ⚠️ 첨부파일이 없습니다.\n" - output_buffer += "\n" - yield output_buffer - extracted_texts = [] - if attachments: + output += f"📋 **사업개요**\n{api_description}\n\n" + yield output + + # ⭐ 본문출력파일이 없으면 상세 페이지에서 추출 시도 + if not print_file or not isinstance(print_file, dict) or not print_file.get("url"): + progress(0.1, desc="상세 페이지에서 본문출력파일 검색 중...") + output += "🔍 **상세 페이지에서 본문출력파일 검색 중...**\n" + yield output + + try: + content, attachments, scraped_print_file = fetch_announcement_detail(detail_url) + + if scraped_print_file: + print_file = scraped_print_file + output += f" ✅ 본문출력파일 발견: `{print_file.get('filename')}`\n\n" + yield output + else: + output += f" ⚠️ 본문출력파일을 찾지 못했습니다.\n" + if attachments: + output += f" 📎 일반 첨부파일 {len(attachments)}개 발견:\n" + for att in attachments: + output += f" - {att.get('filename')}\n" + output += "\n" + yield output + except Exception as e: + output += f" ❌ 상세 페이지 조회 실패: {str(e)}\n\n" + yield output + + extracted_text = None + + # ⭐ 본문출력파일 분석 (핵심!) + if print_file and isinstance(print_file, dict) and print_file.get("url"): + output += f"📄 **본문출력파일 발견**\n" + output += f" - 파일명: `{print_file.get('filename', '알 수 없음')}`\n" + output += f" - 형식: {print_file.get('type', 'unknown').upper()}\n\n" + yield output + with tempfile.TemporaryDirectory() as tmp_dir: - for i, att in enumerate(attachments): - progress(0.2 + (0.4 * i / len(attachments)), desc=f"파일 다운로드 중... ({i+1}/{len(attachments)})") - output_buffer += f"📥 다운로드 중: `{att['filename']}`\n" - yield output_buffer - file_path, error = download_file(att['url'], tmp_dir, att['filename']) - if error: - output_buffer += f" - ⚠️ {error}\n" - yield output_buffer - continue - if file_path and file_path.lower().endswith('.zip'): - output_buffer += f" - 📦 ZIP 압축 해제 중...\n" - yield output_buffer - extracted = extract_zip_files(file_path, tmp_dir) - for ext_file in extracted: - output_buffer += f" - 📄 추출: `{os.path.basename(ext_file)}`\n" - yield output_buffer - text, err = extract_text_from_file(ext_file) - if text: - extracted_texts.append({ - "filename": os.path.basename(ext_file), - "text": text - }) - output_buffer += f" - ✅ 텍스트 추출 성공 ({len(text):,} 글자)\n" - else: - output_buffer += f" - ⚠️ 텍스트 추출 실패: {err}\n" - yield output_buffer - elif file_path: - text, err = extract_text_from_file(file_path) - if text: - extracted_texts.append({ - "filename": os.path.basename(file_path), - "text": text - }) - output_buffer += f" - ✅ 텍스트 추출 성공 ({len(text):,} 글자)\n" - else: - output_buffer += f" - ⚠️ 텍스트 추출 실패: {err}\n" - yield output_buffer - if extracted_texts: - all_text += "### 첨부파일 내용:\n\n" - for ext in extracted_texts: - text_preview = ext['text'][:5000] if len(ext['text']) > 5000 else ext['text'] - all_text += f"#### 📄 {ext['filename']}\n{text_preview}\n\n" - if len(all_text) < 100 and not extracted_texts: - output_buffer += "\n❌ **분석할 내용이 충분하지 않습니다.**\n" - yield output_buffer + progress(0.2, desc="본문출력파일 다운로드 중...") + output += f"📥 다운로드 중...\n" + yield output + + file_path, error = download_file(print_file['url'], tmp_dir, print_file.get('filename')) + + if error: + output += f" - ⚠️ 다운로드 실패: {error}\n" + yield output + elif file_path: + progress(0.5, desc="텍스트 추출 중...") + output += f" - ✅ 다운로드 완료\n" + yield output + + # 텍스트 추출 + text, err = extract_text_from_file(file_path) + if text: + extracted_text = text + output += f" - ✅ 텍스트 추출 성공 ({len(text):,} 글자)\n\n" + all_text += f"### 본문출력파일 내용:\n\n{text[:8000]}\n\n" + else: + output += f" - ⚠️ 텍스트 추출 실패: {err}\n" + yield output + else: + output += "⚠️ **본문출력파일이 없습니다.**\n" + output += "사업개요만으로 분석을 진행합니다.\n\n" + yield output + + if len(all_text) < 100 and not extracted_text: + output += "\n❌ **분석할 내용이 충분하지 않습니다.**\n" + output += "본문출력파일이 없거나 텍스트 추출에 실패했습니다.\n" + yield output return - output_buffer += f"\n📊 **분석 준비 완료** (총 {len(all_text):,}자)\n" - output_buffer += "\n---\n\n## 🤖 AI 분석 결과\n\n" - yield output_buffer + + output += f"\n📊 **분석 준비 완료** (총 {len(all_text):,}자)\n\n---\n\n## 🤖 AI 분석 결과\n\n" + yield output + progress(0.7, desc="AI 분석 중...") system_prompt = """당신은 정부 지원사업 공고 분석 전문가입니다. 주어진 공고 내용을 분석하여 다음 항목을 명확하게 정리해주세요: -- 사업명 -- 주관기관 -- 지원 목적 -- 신청 자격 요건 -- 제외 대상 -- 지원 금액/규모 -- 지원 항목/내용 -- 신청 기간 -- 신청 방법 -- 제출 서류 -- 중요 유의사항 -- 제한 사항 -- 이 사업의 핵심 포인트를 3줄로 요약 -분석 시 주의사항: -1. 문서에 명시된 내용만 기반으로 작성 -2. 불확실한 정보는 "확인 필요"로 표시 -3. 금액, 날짜 등 중요 정보는 정확하게 기재 -4. 한국어로 명확하고 간결하게 작성 -""" +- 사업명, 주관기관, 지원 목적 +- 신청 자격 요건, 제외 대상 +- 지원 금액/규모, 지원 항목/내용 +- 신청 기간, 신청 방법, 제출 서류 +- 중요 유의사항, 제한 사항 +- 이 사업의 핵심 포인트를 3줄로 요약""" + messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": f"다음 지원사업 공고를 분석해주세요:\n\n{all_text[:15000]}"} ] + + for chunk in call_groq_api_stream(messages): + output += chunk + yield output + + output += "\n\n---\n✅ **분석 완료**" + yield output + + +# ============================================================ +# 맞춤 과제 매칭 함수 +# ============================================================ +def analyze_uploaded_documents(files, progress=gr.Progress()): + """업로드된 문서들을 분석하여 기업 정보 추출""" + if not files: + yield "❌ 분석할 파일을 업로드해주세요." + return + + output = "# 📄 업로드 문서 분석 결과\n\n" + all_extracted_text = [] + + for i, file in enumerate(files): + progress((i + 1) / len(files), desc=f"파일 분석 중... ({i+1}/{len(files)})") + filename = os.path.basename(file.name) if hasattr(file, 'name') else f"파일_{i+1}" + output += f"## 📎 {filename}\n\n" + yield output + + try: + text, error = extract_text_from_file(file.name if hasattr(file, 'name') else file) + if text: + all_extracted_text.append({"filename": filename, "text": text}) + preview = text[:500] + "..." if len(text) > 500 else text + output += f"✅ 텍스트 추출 성공 ({len(text):,}자)\n\n```\n{preview}\n```\n\n" + else: + output += f"⚠️ 텍스트 추출 실패: {error}\n\n" + except Exception as e: + output += f"❌ 오류: {str(e)}\n\n" + yield output + + if all_extracted_text: + output += "---\n\n## 🤖 AI 기업정보 추출\n\n" + yield output + + combined_text = "\n\n".join([f"[{item['filename']}]\n{item['text'][:3000]}" for item in all_extracted_text]) + + system_prompt = """당신은 기업 서류 분석 전문가입니다. +주어진 문서들에서 다음 정보를 추출해주세요: +1. 사업자 정보 (사업자등록번호, 법인등록번호, 상호, 대표자, 설립일, 주소, 업종) +2. 재무 정보 (자본금, 매출액, 영업이익, 당기순이익) +3. 인력 정보 (상시근로자 수, 4대보험 가입자 수) +4. 인증/등록 정보 (부설연구소, 벤처기업 인증 등) +5. 기타 특이사항 +JSON 형식으로 정리해주세요.""" + + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": f"다음 기업 서류들을 분석해주세요:\n\n{combined_text[:12000]}"} + ] + + for chunk in call_groq_api_stream(messages): + output += chunk + yield output + + output += "\n\n---\n✅ **문서 분석 완료**" + yield output + + +def match_announcements_with_profile(profile_data, announcements_df, progress=gr.Progress()): + """기업 프로필과 공고를 매칭""" + if not profile_data: + yield "❌ 기업 프로필��� 먼저 입력해주세요." + return + + if announcements_df is None or (isinstance(announcements_df, pd.DataFrame) and announcements_df.empty): + yield "❌ 매칭할 공고 데이터가 없습니다. 먼저 공고를 검색해주세요." + return + + output = "# 🎯 맞춤 과제 매칭 결과\n\n" + output += "## 📋 입력된 기업 프로필\n\n" + output += f"```json\n{json.dumps(profile_data, ensure_ascii=False, indent=2)[:2000]}\n```\n\n" + output += "---\n\n## 🔍 AI 매칭 분석 중...\n\n" + yield output + + announcements_text = "" + df_to_use = announcements_df if isinstance(announcements_df, pd.DataFrame) else pd.DataFrame() + for idx, row in df_to_use.head(20).iterrows(): + announcements_text += f""" +### {row.get('지원사업명', '')} +- 지원분야: {row.get('지원분야', '')} +- 소관부처: {row.get('소관부처', '')} +- 신청기간: {row.get('신청기간', '')} +- 지원대상: {row.get('지원대상', '')} +--- +""" + + system_prompt = """당신은 정부 지원사업 매칭 전문가입니다. +기업 프로필과 공고 목록을 분석하여 신청 가능한 과제를 추천해주세요. +각 공고에 대해: +- ✅ 적합: 신청 자격 충족 +- ⚠️ 확인필요: 일부 조건 확인 필요 +- ❌ 부적합: 자격 미달 +추천 순위와 이유를 설명해주세요.""" + + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": f"기업 프로필:\n{json.dumps(profile_data, ensure_ascii=False)}\n\n공고 목록:\n{announcements_text[:8000]}"} + ] + for chunk in call_groq_api_stream(messages): - output_buffer += chunk - yield output_buffer - output_buffer += "\n\n---\n✅ **분석 완료**" - yield output_buffer - progress(1.0, desc="완료!") + output += chunk + yield output + + output += "\n\n---\n✅ **매칭 분석 완료**" + yield output + + +# ============================================================ +# 캐시 관리 함수 +# ============================================================ +def get_cache_info(): + """캐시 상태 정보 반환""" + if not CACHE_AVAILABLE: + return "⚠️ 캐시 시스템 미사용 (API 직접 호출)" + + status = get_sync_status() + info = f"""📦 **캐시 상태** +- 총 캐시: {status.get('total_count', 0):,}건 +- 마지막 동기화: {status.get('last_sync', '없음')} +- ChromaDB: {'✅ 사용' if status.get('chromadb_available') else '❌ 미사용'} +- 스케줄러: {'✅ 활성' if status.get('scheduler_available') else '❌ 비활성'} +""" + return info + + +def do_manual_sync(): + """수동 동기화 실행""" + if not CACHE_AVAILABLE: + return "⚠️ 캐시 시스템을 사용할 수 없습니다." + return manual_sync() + + +# ============================================================ +# CSS 스타일 - Neumorphism Design +# ============================================================ CUSTOM_CSS = """ @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap'); + +/* ═══════════════════════════════════════════════════════════ + 🔘 NEUMORPHISM 핵심 변수 + ═══════════════════════════════════════════════════════════ */ +:root { + --neu-bg: #e0e5ec; + --neu-shadow-dark: #a3b1c6; + --neu-shadow-light: #ffffff; + --neu-text: #5a6677; + --neu-text-dark: #3d4856; + --neu-accent: #6d7b8d; + --neu-primary: #4a7dbd; + --neu-success: #48bb78; + --neu-warning: #ed8936; +} + +/* ═══════════════════════════════════════════════════════════ + 📦 기본 컨테이너 + ═══════════════════════════════════════════════════════════ */ .gradio-container { font-family: 'Noto Sans KR', sans-serif !important; - background: linear-gradient(135deg, #f5f7fa 0%, #e8eef5 100%) !important; - max-width: 1400px !important; + background: var(--neu-bg) !important; + max-width: 100% !important; + width: 100% !important; margin: 0 auto !important; + min-height: 100vh; + padding: 20px !important; } -.header-banner { - background: linear-gradient(135deg, #1a5cb0 0%, #0d4a94 100%); - color: white; - padding: 24px 28px; - border-radius: 16px; - margin-bottom: 20px; - box-shadow: 0 4px 20px rgba(26, 92, 176, 0.3); + +body, .dark { + background: var(--neu-bg) !important; } -.header-content { - display: flex; - align-items: center; - gap: 16px; + +/* ═══════════════════════════════════════════════════════════ + 📐 전체 폭 강제 (탭 전환 시 폭 변화 방지) + ═══════════════════════════════════════════════════════════ */ +.gr-tabs, .tabs, .tabitem, .tab-content { + width: 100% !important; + max-width: 100% !important; } -.header-icon { - width: 56px; - height: 56px; - background: white; - border-radius: 14px; - display: flex; - align-items: center; - justify-content: center; - font-size: 28px; + +.gr-tab-item, .tab-nav button { + flex-shrink: 0 !important; } -.header-text h1 { - margin: 0; - font-size: 24px; - font-weight: 700; + +.gr-row, .gr-column, .row, .column { + width: 100% !important; + max-width: 100% !important; +} + +.gr-block, .block { + width: 100% !important; + max-width: 100% !important; +} + +/* 탭 패널 내부 컨텐츠 */ +.gr-tabitem > div, .tabitem > div { + width: 100% !important; + max-width: 100% !important; +} + +/* 컨테이너 내부 요소들 */ +.contain { + width: 100% !important; + max-width: 100% !important; } -.header-text p { - margin: 4px 0 0 0; - opacity: 0.9; - font-size: 14px; + +#component-0 { + width: 100% !important; + max-width: 100% !important; } + +/* ═══════════════════════════════════════════════════════════ + 🎯 헤더 배너 (Neumorphism) + ═══════════════════════════════════════════════════════════ */ +.header-banner { + background: var(--neu-bg); + color: var(--neu-text-dark); + padding: 28px 32px; + border-radius: 24px; + margin-bottom: 24px; + box-shadow: + 12px 12px 24px var(--neu-shadow-dark), + -12px -12px 24px var(--neu-shadow-light); +} + +.header-banner h1 { + color: var(--neu-text-dark) !important; + text-shadow: 2px 2px 4px var(--neu-shadow-light), -1px -1px 3px rgba(0,0,0,0.1); +} + +.header-banner p { + color: var(--neu-text) !important; +} + +/* ═══════════════════════════════════════════════════════════ + 🏷️ 배지 스타일 + ═══════════════════════════════════════════════════════════ */ .feature-badge { display: inline-block; - background: rgba(255,255,255,0.2); - padding: 4px 12px; - border-radius: 20px; + background: var(--neu-bg); + padding: 8px 16px; + border-radius: 50px; font-size: 12px; + font-weight: 600; margin: 4px 4px 0 0; + color: var(--neu-text); + box-shadow: + 4px 4px 8px var(--neu-shadow-dark), + -4px -4px 8px var(--neu-shadow-light); } -.analyze-btn { - background: linear-gradient(135deg, #10B981 0%, #059669 100%) !important; + +/* ═══════════════════════════════════════════════════════════ + 📋 섹션 헤더 + ═══════════════════════════════════════════════════════════ */ +.section-header { + background: var(--neu-bg); + color: var(--neu-text-dark); + padding: 14px 20px; + border-radius: 15px; + margin: 16px 0 12px 0; + font-weight: 700; + box-shadow: + 6px 6px 12px var(--neu-shadow-dark), + -6px -6px 12px var(--neu-shadow-light); +} + +/* ═══════════════════════════════════════════════════════════ + 🔘 버튼 스타일 + ═══════════════════════════════════════════════════════════ */ +.gr-button, button.primary, button.secondary { + background: var(--neu-bg) !important; border: none !important; - color: white !important; + border-radius: 50px !important; + padding: 12px 28px !important; + color: var(--neu-text-dark) !important; font-weight: 600 !important; + box-shadow: + 6px 6px 12px var(--neu-shadow-dark), + -6px -6px 12px var(--neu-shadow-light) !important; + transition: all 0.2s ease !important; } -.analyze-btn:hover { - background: linear-gradient(135deg, #059669 0%, #047857 100%) !important; + +.gr-button:hover, button.primary:hover, button.secondary:hover { + box-shadow: + 4px 4px 8px var(--neu-shadow-dark), + -4px -4px 8px var(--neu-shadow-light) !important; + transform: translateY(-1px); } -.analysis-output { - background: white !important; - border: 2px solid #e5e7eb !important; - border-radius: 12px !important; - padding: 20px !important; - min-height: 500px !important; - max-height: 700px !important; - overflow-y: auto !important; - font-size: 14px !important; - line-height: 1.8 !important; + +.gr-button:active, button.primary:active, button.secondary:active { + box-shadow: + inset 4px 4px 8px var(--neu-shadow-dark), + inset -4px -4px 8px var(--neu-shadow-light) !important; + transform: translateY(0); +} + +/* 🟢 분석 버튼 */ +.analyze-btn button, .analyze-btn { + background: linear-gradient(145deg, #52c992, #3ea87a) !important; + color: white !important; + box-shadow: + 6px 6px 12px var(--neu-shadow-dark), + -6px -6px 12px var(--neu-shadow-light) !important; } -.filter-info { - background: #FEF3C7; - border: 1px solid #F59E0B; + +.analyze-btn:hover button, .analyze-btn:hover { + background: linear-gradient(145deg, #3ea87a, #359968) !important; +} + +/* 🟣 매칭 버튼 */ +.match-btn button, .match-btn { + background: linear-gradient(145deg, #9f7aea, #805ad5) !important; + color: white !important; + box-shadow: + 6px 6px 12px var(--neu-shadow-dark), + -6px -6px 12px var(--neu-shadow-light) !important; +} + +/* 🟠 동기화 버튼 */ +.sync-btn button, .sync-btn { + background: linear-gradient(145deg, #f6ad55, #ed8936) !important; + color: white !important; + box-shadow: + 4px 4px 8px var(--neu-shadow-dark), + -4px -4px 8px var(--neu-shadow-light) !important; +} + +/* ═══════════════════════════════════════════════════════════ + 📝 입력 필드 (Inset 효과) + ═══════════════════════════════════════════════════════════ */ +.gr-textbox textarea, .gr-textbox input, +input[type="text"], input[type="number"], textarea, +.gr-dropdown select { + background: var(--neu-bg) !important; + border: none !important; + border-radius: 15px !important; + padding: 14px 18px !important; + color: var(--neu-text-dark) !important; + box-shadow: + inset 4px 4px 8px var(--neu-shadow-dark), + inset -4px -4px 8px var(--neu-shadow-light) !important; +} + +.gr-textbox textarea:focus, .gr-textbox input:focus, +input[type="text"]:focus, textarea:focus { + outline: none !important; + box-shadow: + inset 6px 6px 12px var(--neu-shadow-dark), + inset -6px -6px 12px var(--neu-shadow-light) !important; +} + +/* ═══════════════════════════════════════════════════════════ + 📊 데이터프레임 테이블 + ═══════════════════════════════════════════════════════════ */ +.gr-dataframe, .dataframe, table { + background: var(--neu-bg) !important; + border-radius: 20px !important; + overflow: hidden; + box-shadow: + 8px 8px 16px var(--neu-shadow-dark), + -8px -8px 16px var(--neu-shadow-light) !important; +} + +.gr-dataframe th, .dataframe th { + background: var(--neu-bg) !important; + color: var(--neu-text-dark) !important; + font-weight: 700 !important; + padding: 14px 12px !important; + border-bottom: 2px solid var(--neu-shadow-dark) !important; +} + +.gr-dataframe td, .dataframe td { + background: var(--neu-bg) !important; + color: var(--neu-text) !important; + padding: 12px !important; + border-bottom: 1px solid rgba(163, 177, 198, 0.3) !important; +} + +.gr-dataframe tr:hover td, .dataframe tr:hover td { + background: rgba(74, 125, 189, 0.1) !important; +} + +/* ═══════════════════════════════════════════════════════════ + 📁 탭 스타일 + ═══════════════════════════════════════════════════════════ */ +.gr-tabs, .tabs { + background: var(--neu-bg) !important; +} + +.gr-tab-item, .tab-nav button { + background: var(--neu-bg) !important; + border: none !important; + border-radius: 15px 15px 0 0 !important; + padding: 12px 24px !important; + color: var(--neu-text) !important; + font-weight: 600 !important; + margin-right: 4px !important; + box-shadow: + 4px -4px 8px var(--neu-shadow-dark), + -4px -4px 8px var(--neu-shadow-light) !important; +} + +.gr-tab-item.selected, .tab-nav button.selected { + background: var(--neu-bg) !important; + color: var(--neu-primary) !important; + box-shadow: + inset 3px 3px 6px var(--neu-shadow-dark), + inset -3px -3px 6px var(--neu-shadow-light) !important; +} + +/* ═══════════════════════════════════════════════════════════ + ✅ 체크박스 + ═══════════════════════════════════════════════════════════ */ +.gr-checkbox input[type="checkbox"] { + appearance: none; + width: 24px; + height: 24px; + background: var(--neu-bg); border-radius: 8px; - padding: 12px; - margin-bottom: 16px; - font-size: 13px; - color: #92400E; + box-shadow: + inset 3px 3px 6px var(--neu-shadow-dark), + inset -3px -3px 6px var(--neu-shadow-light); + cursor: pointer; } -.footer-text { - text-align: center; - color: #868e96; + +.gr-checkbox input[type="checkbox"]:checked { + background: linear-gradient(145deg, #48bb78, #38a169); + box-shadow: + 3px 3px 6px var(--neu-shadow-dark), + -3px -3px 6px var(--neu-shadow-light); +} + +/* ═══════════════════════════════════════════════════════════ + 📋 캐시 정보 박스 + ═══════════════════════════════════════════════════════════ */ +.cache-info { + background: var(--neu-bg) !important; + border: none !important; + border-radius: 15px !important; + padding: 16px 20px !important; + margin-bottom: 16px !important; font-size: 13px; - margin-top: 16px; + color: var(--neu-text) !important; + box-shadow: + inset 4px 4px 8px var(--neu-shadow-dark), + inset -4px -4px 8px var(--neu-shadow-light) !important; +} + +/* ═══════════════════════════════════════════════════════════ + 📄 카드/패널 스타일 + ═══════════════════════════════════════════════════════════ */ +.gr-panel, .gr-box, .gr-form { + background: var(--neu-bg) !important; + border: none !important; + border-radius: 20px !important; + padding: 20px !important; + box-shadow: + 8px 8px 16px var(--neu-shadow-dark), + -8px -8px 16px var(--neu-shadow-light) !important; +} + +/* ═══════════════════════════════════════════════════════════ + 📊 라벨 스타일 + ═══════════════════════════════════════════════════════════ */ +label, .gr-label { + color: var(--neu-text-dark) !important; + font-weight: 600 !important; + text-shadow: 1px 1px 2px var(--neu-shadow-light); +} + +/* ═══════════════════════════════════════════════════════════ + 🔗 링크 스타일 + ═══════════════════════════════════════════════════════════ */ +a { + color: var(--neu-primary) !important; + text-decoration: none !important; + font-weight: 600; + transition: all 0.2s ease; +} + +a:hover { + color: #3a6aa8 !important; + text-shadow: 0 0 8px rgba(74, 125, 189, 0.3); +} + +/* ═══════════════════════════════════════════════════════════ + 📱 스크롤바 스타일 + ═══════════════════════════════════════════════════════════ */ +::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +::-webkit-scrollbar-track { + background: var(--neu-bg); + border-radius: 10px; + box-shadow: inset 2px 2px 4px var(--neu-shadow-dark); +} + +::-webkit-scrollbar-thumb { + background: linear-gradient(145deg, #d1d9e6, #b8c0cc); + border-radius: 10px; + box-shadow: + 2px 2px 4px var(--neu-shadow-dark), + -2px -2px 4px var(--neu-shadow-light); +} + +/* ═══════════════════════════════════════════════════════════ + 🎨 마크다운 출력 영역 + ════════════════════════════════════════════════════════��══ */ +.gr-markdown { + background: var(--neu-bg) !important; + border-radius: 15px !important; + padding: 20px !important; + color: var(--neu-text-dark) !important; + box-shadow: + inset 4px 4px 8px var(--neu-shadow-dark), + inset -4px -4px 8px var(--neu-shadow-light) !important; +} + +.gr-markdown h1, .gr-markdown h2, .gr-markdown h3 { + color: var(--neu-text-dark) !important; + text-shadow: 2px 2px 4px var(--neu-shadow-light); +} + +/* ═══════════════════════════════════════════════════════════ + ✨ 애니메이션 + ═══════════════════════════════════════════════════════════ */ +@keyframes pulse-neu { + 0%, 100% { + box-shadow: + 8px 8px 16px var(--neu-shadow-dark), + -8px -8px 16px var(--neu-shadow-light); + } + 50% { + box-shadow: + 12px 12px 24px var(--neu-shadow-dark), + -12px -12px 24px var(--neu-shadow-light); + } +} + +.loading { + animation: pulse-neu 1.5s ease-in-out infinite; } """ + + +# ============================================================ +# 메인 인터페이스 +# ============================================================ def create_interface(): with gr.Blocks(title="기업마당 AI 분석기", css=CUSTOM_CSS) as demo: gr.HTML("""
-
-
🏢
-
-

기업마당 지원사업 AI 분석기

-

공고 검색 · 첨부파일 자동 분석 · AI 요약

-
+
+
🏢
+
+

기업마당 지원사업 AI 분석기

+

공고 검색 · 첨부파일 자동 분석 · AI 요약 · 맞춤 과제 추출

+
📄 HWP/HWPX 지원 - 📦 ZIP 자동 해제 🤖 AI 분석 - 🔄 정렬/필터 지원 + 🎯 맞춤 매칭 + ⚡ 빠른 캐시
""") + + # 상태 변수 selected_url = gr.State("") selected_name = gr.State("") - selected_attachments = gr.State([]) + selected_print_file = gr.State(None) # ⭐ 본문출력파일 (dict) selected_description = gr.State("") current_df = gr.State(value=pd.DataFrame()) + company_profile = gr.State(value={}) + with gr.Tabs(): + # 탭 1: 공고 검색 with gr.Tab("🔍 공고 검색"): - gr.HTML(""" -
- ℹ️ 참고: 기업마당 API는 정렬/기관유형 파라미터를 지원하지 않습니다. - 따라서 데이터를 가져온 후 클라이언트에서 필터링/정렬합니다. - 실제 웹사이트와 완전히 동일한 결과를 원하시면 더 많은 데이터(표시개수)를 가져오세요. -
- """) - with gr.Group(): - with gr.Row(): - keyword_input = gr.Textbox( - label="🔍 검색어", - placeholder="예: AI, 스타트업, R&D", - lines=1, - scale=3 - ) - category_dropdown = gr.Dropdown( - label="📂 지원분야", - choices=list(CATEGORY_CODES.keys()), - value="전체", - interactive=True, - scale=1 - ) - region_dropdown = gr.Dropdown( - label="📍 지역", - choices=REGION_LIST, - value="��체(지역)", - interactive=True, - scale=1 - ) - with gr.Row(): - org_type_dropdown = gr.Dropdown( - label="🏛️ 기관유형", - choices=ORG_TYPE_OPTIONS, - value="전체", - interactive=True, - scale=1 - ) - sort_dropdown = gr.Dropdown( - label="📊 정렬", - choices=SORT_OPTIONS, - value="등록일순", - interactive=True, - scale=1 - ) - status_dropdown = gr.Dropdown( - label="📌 공고상태", - choices=STATUS_OPTIONS, - value="진행중", - interactive=True, - scale=1 - ) - with gr.Row(): - page_input = gr.Number( - label="📄 페이지", - value=1, - minimum=1, - maximum=100, - step=1, - precision=0, - scale=1 - ) - rows_dropdown = gr.Dropdown( - label="📊 표시개수", - choices=[10, 15, 20, 30, 50], - value=20, - interactive=True, - scale=1 - ) - search_btn = gr.Button( - "🔎 검색", - variant="primary", - scale=2 - ) - status_output = gr.Textbox( - label="📊 조회 결과", - interactive=False, - lines=1 - ) - results_output = gr.Dataframe( - label="📋 공고 목록 (행 클릭으로 선택)", - headers=["번호", "지원분야", "지원사업명", "신청기간", "소관부처", "등록일", "지원대상"], - datatype=["number", "str", "str", "str", "str", "str", "str"], - wrap=True, - interactive=False - ) + # 캐시 상태 표시 + with gr.Row(): + cache_status = gr.Markdown(value=get_cache_info(), elem_classes=["cache-info"]) + sync_btn = gr.Button("🔄 수동 동기화", size="sm", elem_classes=["sync-btn"]) + + with gr.Row(): + keyword_input = gr.Textbox(label="🔍 검색어", placeholder="예: AI, 스타트업, R&D", scale=3) + category_dropdown = gr.Dropdown(label="📂 지원분야", choices=list(CATEGORY_CODES.keys()), value="전체", scale=1) + region_dropdown = gr.Dropdown(label="📍 지역", choices=REGION_LIST, value="전체(지역)", scale=1) + with gr.Row(): + org_type_dropdown = gr.Dropdown(label="🏛️ 기관유형", choices=ORG_TYPE_OPTIONS, value="전체", scale=1) + sort_dropdown = gr.Dropdown(label="📊 정렬", choices=SORT_OPTIONS, value="등록일순", scale=1) + status_dropdown = gr.Dropdown(label="📌 공고상태", choices=STATUS_OPTIONS, value="진행중", scale=1) + with gr.Row(): + page_input = gr.Number(label="📄 페이지", value=1, minimum=1, scale=1) + rows_dropdown = gr.Dropdown(label="📊 표시개수", choices=[10, 15, 20, 30, 50], value=20, scale=1) + search_btn = gr.Button("🔎 검색", variant="primary", scale=2) + + status_output = gr.Textbox(label="📊 조회 결과", interactive=False) + results_output = gr.Dataframe(label="📋 공고 목록 (행 클릭으로 선택)", wrap=True, interactive=False) with gr.Row(): prev_btn = gr.Button("◀️ 이전", size="sm") next_btn = gr.Button("다음 ▶️", size="sm") - export_btn = gr.Button("📥 CSV", size="sm", variant="secondary") - csv_output = gr.File(label="📁 다운로드") + open_link_btn = gr.Button("🔗 선택 공고 열기", size="sm", variant="secondary") + + # 선택된 공고 링크를 표시하는 HTML (JavaScript로 새 탭 열기) + link_output = gr.HTML(value="
📌 공고를 선택하면 여기에 링크가 표시됩니다
") + + # 탭 2: AI 분석 with gr.Tab("🤖 AI 분석"): + with gr.Row(equal_height=False): + with gr.Column(scale=1, min_width=300): + selected_info = gr.Textbox(label="📌 선택된 공고", placeholder="공고 검색 탭에서 선택", lines=5, interactive=False) + analyze_btn = gr.Button("🚀 AI 분석 시작", variant="primary", size="lg", elem_classes=["analyze-btn"]) + with gr.Column(scale=3, min_width=600): + analysis_output = gr.Markdown(value="### 📊 분석 결과\n\n*공고를 선택하고 분석 버튼을 클릭하세요*", height=600) + + # 탭 3: 맞춤 과제 추출 + with gr.Tab("🎯 맞춤 과제 추출"): gr.HTML(""" -
-

📌 사용 방법

-
    -
  1. 공고 검색 탭에서 원하는 공고를 검색합니다.
  2. -
  3. 목록에서 행을 클릭하여 분석할 공고를 선택합니다.
  4. -
  5. 🚀 AI 분석 시작 버튼을 클릭합니다.
  6. -
+
+

🎯 나만의 맞춤 과제 추출

+

기업 정보를 입력하고 문서를 업로드하면 AI가 신청 가능한 과제를 자동으로 매칭해드립니다.

""") - with gr.Row(): - with gr.Column(scale=1): - selected_info = gr.Textbox( - label="📌 선택된 공고", - placeholder="공고 검색 탭에서 분석할 공고를 선택해주세요", - lines=3, - interactive=False - ) - analyze_btn = gr.Button( - "🚀 AI 분석 시작", - variant="primary", - size="lg", - elem_classes=["analyze-btn"] - ) - with gr.Column(scale=2): - analysis_output = gr.Markdown( - value="### 📊 분석 결과\n\n*공고를 선택하고 분석 버튼을 클릭하세요*", - elem_classes=["analysis-output"], - height=500 - ) - gr.HTML(""" - - """) + + with gr.Tabs(): + # 서브탭 1: 기업 기본정보 + with gr.Tab("1️⃣ 기업 기본정보"): + with gr.Row(): + with gr.Column(): + gr.HTML('
📋 사업자 정보
') + biz_number = gr.Textbox(label="사업자등록번호", placeholder="000-00-00000") + corp_number = gr.Textbox(label="법인등록번호", placeholder="000000-0000000") + company_name = gr.Textbox(label="상호/법인명", placeholder="(주)회사명") + establish_date = gr.Textbox(label="설립일자", placeholder="YYYY-MM-DD") + company_type = gr.Dropdown(label="기업형태", choices=COMPANY_TYPE_OPTIONS, value="법인사업자") + corp_type = gr.Dropdown(label="법인 종류", choices=CORP_TYPE_OPTIONS, value="주식회사") + company_size = gr.Dropdown(label="기업규모", choices=COMPANY_SIZE_OPTIONS, value="소기업") + + with gr.Column(): + gr.HTML('
🏆 인증 현황
') + venture_cert = gr.Checkbox(label="벤처기업 인증") + innobiz_cert = gr.Checkbox(label="이노비즈 인증") + mainbiz_cert = gr.Checkbox(label="메인비즈 인증") + sme_cert = gr.Checkbox(label="중소기업확인서 보유") + small_biz_cert = gr.Checkbox(label="소상공인확인서 보유") + social_venture = gr.Checkbox(label="소셜벤처") + startup_flag = gr.Checkbox(label="스타트업") + + with gr.Column(): + gr.HTML('
📍 소재지 정보
') + hq_sido = gr.Dropdown(label="본사 소재지 (시/도)", choices=SIDO_LIST, value="서울특별시") + hq_sigungu = gr.Textbox(label="본사 소재지 (시/군/구)", placeholder="예: 강남구") + innovation_city = gr.Checkbox(label="혁신도시 입주") + industrial_complex = gr.Checkbox(label="산업단지 입주") + free_zone = gr.Checkbox(label="규제자유특구 소재") + non_capital = gr.Checkbox(label="비수도권 (지방기업)") + + gr.HTML('
📊 업종 정보
') + industry_major = gr.Dropdown(label="주업종 (대분류)", choices=INDUSTRY_MAJOR_OPTIONS, value="C. 제조업") + is_manufacturing = gr.Checkbox(label="제조업 여부") + is_knowledge_service = gr.Checkbox(label="지식서비스업 여부") + + # 서브탭 2: 대표자/인력 정보 + with gr.Tab("2️⃣ 대표자/인력 정보"): + with gr.Row(): + with gr.Column(): + gr.HTML('
👤 대표자 정보
') + ceo_name = gr.Textbox(label="대표자명", placeholder="홍길동") + ceo_gender = gr.Radio(label="대표자 성별", choices=["남성", "여성"], value="남성") + ceo_birthdate = gr.Textbox(label="대표자 생년월일", placeholder="YYYY-MM-DD") + youth_ceo = gr.Checkbox(label="청년창업자 (만39세 미만)") + senior_ceo = gr.Checkbox(label="시니어창업자 (만40세 이상)") + women_company = gr.Checkbox(label="여성기업확인서 보유") + disabled_company = gr.Checkbox(label="장애인기업확인서 보유") + + with gr.Column(): + gr.HTML('
👥 고용 현황
') + insurance_employees = gr.Number(label="4대보험 가입자 수", value=0, minimum=0) + regular_employees = gr.Number(label="상시근로자 수", value=0, minimum=0) + youth_employees = gr.Number(label="청년고용 인원", value=0, minimum=0) + female_ratio = gr.Slider(label="여성고용 비율 (%)", minimum=0, maximum=100, value=0) + new_hire_plan = gr.Number(label="신규채용 계획 (명)", value=0, minimum=0) + + with gr.Column(): + gr.HTML('
🔬 연구인력/역량
') + rd_personnel = gr.Number(label="연구인력 수", value=0, minimum=0) + phd_researchers = gr.Number(label="박사급 연구원", value=0, minimum=0) + research_center = gr.Checkbox(label="기업부설연구소 등록") + rd_dept = gr.Checkbox(label="연구개발전담부서 등록") + patent_count = gr.Number(label="보유 특허 수", value=0, minimum=0) + + # 서브탭 3: 재무 정보 + with gr.Tab("3️⃣ 재무 정보"): + with gr.Row(): + with gr.Column(): + gr.HTML('
💰 매출 및 수익
') + revenue_current = gr.Number(label="최근년도 매출액 (백만원)", value=0, minimum=0) + revenue_prev = gr.Number(label="전년도 매출액 (백만원)", value=0, minimum=0) + operating_profit = gr.Number(label="영업이익 (백만원)", value=0) + net_income = gr.Number(label="당기순이익 (백만원)", value=0) + export_amount = gr.Number(label="수출액 (천달러)", value=0, minimum=0) + + with gr.Column(): + gr.HTML('
📊 재무건전성
') + capital = gr.Number(label="자본금 (백만원)", value=0, minimum=0) + total_assets = gr.Number(label="자산총계 (백만원)", value=0, minimum=0) + debt_ratio = gr.Slider(label="부채비율 (%)", minimum=0, maximum=500, value=0) + credit_grade = gr.Dropdown(label="신용등급", choices=CREDIT_GRADE_OPTIONS, value="미평가") + tcb_grade = gr.Dropdown(label="TCB 등급", choices=TCB_GRADE_OPTIONS, value="미평가") + capital_impairment = gr.Checkbox(label="자본잠식 여부") + + with gr.Column(): + gr.HTML('
🔬 R&D 투자
') + rd_investment = gr.Number(label="연간 R&D 투자액 (백만원)", value=0, minimum=0) + gov_project_exp = gr.Checkbox(label="정부과제 수행 경험") + gov_support_3yr = gr.Number(label="최근 3년 정부지원금 (백만원)", value=0, minimum=0) + + # 서브탭 4: 기술분야/제한사항 + with gr.Tab("4️⃣ 기술분야/제한사항"): + with gr.Row(): + with gr.Column(): + gr.HTML('
🔬 기술 분야
') + core_industry = gr.CheckboxGroup(label="10대 핵심산업", choices=CORE_INDUSTRY_OPTIONS) + strategic_tech = gr.CheckboxGroup(label="12대 국가전략기술", choices=NATIONAL_STRATEGIC_TECH) + green_tech = gr.Checkbox(label="녹색기술 분야") + digital_transform = gr.Checkbox(label="디지털전환 분야") + defense_industry = gr.Checkbox(label="국방/방산 분야") + + with gr.Column(): + gr.HTML('
📜 인증/ISO
') + iso_certs = gr.CheckboxGroup(label="ISO 인증", choices=ISO_CERT_OPTIONS) + gmp_cert = gr.Checkbox(label="GMP 인증") + + with gr.Column(): + gr.HTML('
⚠️ 결격사유 확인
') + tax_delinquent = gr.Checkbox(label="국세 체납") + local_tax_delinquent = gr.Checkbox(label="지방세 체납") + gov_project_fail = gr.Checkbox(label="정부과제 불성실") + bankruptcy = gr.Checkbox(label="휴/폐업 이력") + financial_default = gr.Checkbox(label="금융기관 연체") + + # 서브탭 5: 문서 업로드 및 매칭 + with gr.Tab("5️⃣ 문서 업로드 & 매칭"): + gr.HTML(""" +
+

📁 문서 업로드

+

사업자등록증, 등기부등본, 재무제표, 중소기업확인서 등을 업로드하면 AI가 자동으로 정보를 추출합니다.

+
+ """) + + with gr.Row(): + with gr.Column(scale=1): + file_upload = gr.File( + label="📎 문서 업로드 (HWP, PDF, TXT, XLSX)", + file_count="multiple", + file_types=[".hwp", ".hwpx", ".pdf", ".txt", ".xlsx", ".xls"] + ) + analyze_docs_btn = gr.Button("📄 문서 분석", variant="secondary", size="lg") + + with gr.Column(scale=2): + doc_analysis_output = gr.Markdown(value="### 📄 문서 분석 결과\n\n*문서를 업로드하고 분석 버튼을 클릭하세요*", height=400) + + gr.HTML('
') + + with gr.Row(): + save_profile_btn = gr.Button("💾 프로필 저장", variant="secondary", size="lg") + match_btn = gr.Button("���� 맞춤 과제 매칭 시작", variant="primary", size="lg", elem_classes=["match-btn"]) + + profile_status = gr.Textbox(label="프로필 저장 상태", interactive=False) + match_output = gr.Markdown(value="### 🎯 매칭 결과\n\n*프로필을 저장하고 매칭 버튼을 클릭하세요*", height=500) + + # ============================================================ + # 이벤트 핸들러 + # ============================================================ def search_fn(keyword, category, region, org_type, sort_by, status_filter, page, rows): - df, status = fetch_announcements( - keyword=keyword or "", - category=category or "전체", - region=region or "전체(지역)", - org_type=org_type or "전체", - sort_by=sort_by or "등록일순", - status_filter=status_filter or "진행중", - page=int(page) if page else 1, - rows=int(rows) if rows else 20 - ) - display_cols = ["번호", "지원분야", "지원사업명", "신청기간", "소관부처", "등록일", "지원대상"] - if not df.empty: - display_df = df[[c for c in display_cols if c in df.columns]] - else: - display_df = df - return display_df, status, df + display_df, status, full_df = fetch_announcements(keyword or "", category or "전체", region or "전체(지역)", + org_type or "전체", sort_by or "등록일순", + status_filter or "진행중", int(page) if page else 1, int(rows) if rows else 20) + return display_df, status, full_df + def prev_fn(page, keyword, category, region, org_type, sort_by, status_filter, rows): new_page = max(1, int(page) - 1) if page else 1 - df, status = fetch_announcements( - keyword=keyword or "", - category=category or "전체", - region=region or "전체(지역)", - org_type=org_type or "전체", - sort_by=sort_by or "등록일순", - status_filter=status_filter or "진행중", - page=new_page, - rows=int(rows) if rows else 20 - ) - display_cols = ["번호", "지원분야", "지원사업명", "신청기간", "소관부처", "등록일", "지원대상"] - if not df.empty: - display_df = df[[c for c in display_cols if c in df.columns]] - else: - display_df = df - return display_df, status, df, new_page + display_df, status, full_df = fetch_announcements(keyword or "", category or "전체", region or "전체(지역)", + org_type or "전체", sort_by or "등록일순", + status_filter or "진행중", new_page, int(rows) if rows else 20) + return display_df, status, full_df, new_page + def next_fn(page, keyword, category, region, org_type, sort_by, status_filter, rows): new_page = int(page) + 1 if page else 2 - df, status = fetch_announcements( - keyword=keyword or "", - category=category or "전체", - region=region or "전체(지역)", - org_type=org_type or "전체", - sort_by=sort_by or "등록일순", - status_filter=status_filter or "진행중", - page=new_page, - rows=int(rows) if rows else 20 - ) - display_cols = ["번호", "지원분야", "지원사업명", "신청기간", "소관부처", "등록일", "지원대상"] - if not df.empty: - display_df = df[[c for c in display_cols if c in df.columns]] - else: - display_df = df - return display_df, status, df, new_page - def export_to_csv(df): - if df is None or (isinstance(df, pd.DataFrame) and df.empty): - return None - filepath = "/tmp/bizinfo_announcements.csv" - export_cols = [c for c in df.columns if not c.startswith("_")] - df[export_cols].to_csv(filepath, index=False, encoding='utf-8-sig') - return filepath + display_df, status, full_df = fetch_announcements(keyword or "", category or "전체", region or "전체(지역)", + org_type or "전체", sort_by or "등록일순", + status_filter or "진행중", new_page, int(rows) if rows else 20) + return display_df, status, full_df, new_page + def on_row_select(evt: gr.SelectData, df): - """테이블 행 선택 시 처리""" if evt.index[0] < len(df): row = df.iloc[evt.index[0]] url = row.get("상세링크", "") name = row.get("지원사업명", "") attachments = row.get("첨부파일", []) + print_file = row.get("본문출력파일", None) # ⭐ AI 분석용 핵심 파일 description = row.get("사업개요", "") - att_info = "" + + # URL 정규화 + full_url = url + if url and url.startswith('/'): + full_url = f"https://www.bizinfo.go.kr{url}" + + # 정보 표시 (AI 분석 탭용) + info_parts = [f"📌 {name}", f"🔗 {full_url}"] + + # 본문출력파일 (AI 분석 대상) + if print_file: + info_parts.append(f"\n\n📄 **본문출력파일 (AI 분석 대상)**:") + info_parts.append(f" - {print_file.get('filename', '파일')}") + + # 일반 첨부파일 (서식, 양식) if attachments and len(attachments) > 0: - att_info = f"\n\n📎 첨부파일 {len(attachments)}개:" + info_parts.append(f"\n\n📎 기타 첨부파일 {len(attachments)}개:") for att in attachments: - att_info += f"\n - {att.get('filename', '파일')}" - info = f"📌 {name}\n\n🔗 {url}{att_info}" - return url, name, attachments, description, info - return "", "", [], "", "" - search_btn.click( - fn=search_fn, - inputs=[keyword_input, category_dropdown, region_dropdown, org_type_dropdown, sort_dropdown, status_dropdown, page_input, rows_dropdown], - outputs=[results_output, status_output, current_df] - ) - keyword_input.submit( - fn=search_fn, - inputs=[keyword_input, category_dropdown, region_dropdown, org_type_dropdown, sort_dropdown, status_dropdown, page_input, rows_dropdown], - outputs=[results_output, status_output, current_df] - ) - prev_btn.click( - fn=prev_fn, - inputs=[page_input, keyword_input, category_dropdown, region_dropdown, org_type_dropdown, sort_dropdown, status_dropdown, rows_dropdown], - outputs=[results_output, status_output, current_df, page_input] - ) - next_btn.click( - fn=next_fn, - inputs=[page_input, keyword_input, category_dropdown, region_dropdown, org_type_dropdown, sort_dropdown, status_dropdown, rows_dropdown], - outputs=[results_output, status_output, current_df, page_input] - ) - export_btn.click(fn=export_to_csv, inputs=[current_df], outputs=[csv_output]) - results_output.select( - fn=on_row_select, - inputs=[current_df], - outputs=[selected_url, selected_name, selected_attachments, selected_description, selected_info] - ) - analyze_btn.click( - fn=analyze_announcement, - inputs=[selected_url, selected_name, selected_attachments, selected_description], - outputs=[analysis_output] + info_parts.append(f" - {att.get('filename', '파일')}") + + info = "\n".join(info_parts) + + # 링크 HTML (첫 번째 탭용) + if full_url: + link_html = f''' +
+
📌 {name}
+ + 🔗 기업마당에서 상세보기 + +
+ ''' + else: + link_html = "
⚠️ 링크 정보가 없습니다
" + + return url, name, print_file, description, info, link_html + return "", "", None, "", "", "
📌 공고를 선택하세요
" + + def save_profile_fn(biz_num, corp_num, comp_name, est_date, comp_type, corp_tp, comp_size, + venture, innobiz, mainbiz, sme, small_biz, social, startup, + sido, sigungu, innov_city, ind_complex, free_z, non_cap, + ind_major, is_manu, is_know, + ceo_nm, ceo_gen, ceo_birth, youth, senior, women, disabled, + ins_emp, reg_emp, youth_emp, fem_ratio, new_hire, + rd_per, phd, res_ctr, rd_dep, patent, + rev_cur, rev_prev, op_profit, net_inc, export, + cap, assets, debt, credit, tcb, impair, + rd_inv, gov_exp, gov_sup, + core_ind, strat_tech, green, digital, defense, + iso, gmp, + tax_del, local_tax, gov_fail, bankrupt, fin_def): + + profile = { + "사업자정보": { + "사업자등록번호": biz_num, "법인등록번호": corp_num, "상호": comp_name, + "설립일자": est_date, "기업형태": comp_type, "법인종류": corp_tp, "기업규모": comp_size + }, + "인증현황": { + "벤처기업": venture, "이노비즈": innobiz, "메인비즈": mainbiz, + "중소기업확인서": sme, "소상공인확인서": small_biz, "소셜벤처": social, "스타트업": startup + }, + "소재지": { + "시도": sido, "시군구": sigungu, "혁신도시": innov_city, + "산업단지": ind_complex, "규제자유특구": free_z, "비수도권": non_cap + }, + "업종": {"대분류": ind_major, "제조업": is_manu, "지식서비스업": is_know}, + "대표자": { + "이름": ceo_nm, "성별": ceo_gen, "생년월일": ceo_birth, + "청년창업자": youth, "시니어창업자": senior, "여성기업": women, "장애인기업": disabled + }, + "고용현황": { + "4대보험가입자": ins_emp, "상시근로자": reg_emp, "청년고용": youth_emp, + "여성비율": fem_ratio, "신규채용계획": new_hire + }, + "연구역량": { + "연구인력": rd_per, "박사급": phd, "부설연구소": res_ctr, + "전담부서": rd_dep, "특허수": patent + }, + "재무정보": { + "매출액_당해": rev_cur, "매출액_전년": rev_prev, "영업이익": op_profit, + "당기순이익": net_inc, "수출액": export, "자본금": cap, "자산총계": assets, + "부채비율": debt, "신용등급": credit, "TCB등급": tcb, "자본잠식": impair + }, + "R&D투자": {"연간투자액": rd_inv, "정부과제경험": gov_exp, "최근3년지원금": gov_sup}, + "기술분야": { + "핵심산업": core_ind, "국가전략기술": strat_tech, + "녹색기술": green, "디지털전환": digital, "국방방산": defense + }, + "인증": {"ISO": iso, "GMP": gmp}, + "결격사유": { + "국세체납": tax_del, "지방세체납": local_tax, "불성실이력": gov_fail, + "휴폐업": bankrupt, "금융연체": fin_def + } + } + return profile, "✅ 프로필이 저장되었습니다." + + def sync_and_update(): + result = do_manual_sync() + info = get_cache_info() + return info, result + + # 이벤트 연결 + search_btn.click(fn=search_fn, inputs=[keyword_input, category_dropdown, region_dropdown, org_type_dropdown, + sort_dropdown, status_dropdown, page_input, rows_dropdown], + outputs=[results_output, status_output, current_df]) + + keyword_input.submit(fn=search_fn, inputs=[keyword_input, category_dropdown, region_dropdown, org_type_dropdown, + sort_dropdown, status_dropdown, page_input, rows_dropdown], + outputs=[results_output, status_output, current_df]) + + # 수동 동기화 버튼 + sync_btn.click(fn=sync_and_update, outputs=[cache_status, status_output]) + + # 페이지네이션 이벤트 + prev_btn.click(fn=prev_fn, inputs=[page_input, keyword_input, category_dropdown, region_dropdown, + org_type_dropdown, sort_dropdown, status_dropdown, rows_dropdown], + outputs=[results_output, status_output, current_df, page_input]) + + next_btn.click(fn=next_fn, inputs=[page_input, keyword_input, category_dropdown, region_dropdown, + org_type_dropdown, sort_dropdown, status_dropdown, rows_dropdown], + outputs=[results_output, status_output, current_df, page_input]) + + # 행 선택 시 link_output도 업데이트 + results_output.select(fn=on_row_select, inputs=[current_df], + outputs=[selected_url, selected_name, selected_print_file, selected_description, selected_info, link_output]) + + # 바로가기 버튼 클릭 시 - JavaScript로 새 탭 열기 + def open_selected_link(url): + if url: + full_url = url if url.startswith('http') else f"https://www.bizinfo.go.kr{url}" + return f''' + +
✅ 새 탭에서 열렸습니다
+ ''' + return "
⚠️ 먼저 공고를 선택하세요
" + + open_link_btn.click(fn=open_selected_link, inputs=[selected_url], outputs=[link_output]) + + analyze_btn.click(fn=analyze_announcement, inputs=[selected_url, selected_name, selected_print_file, selected_description], + outputs=[analysis_output]) + + analyze_docs_btn.click(fn=analyze_uploaded_documents, inputs=[file_upload], outputs=[doc_analysis_output]) + + save_profile_btn.click( + fn=save_profile_fn, + inputs=[biz_number, corp_number, company_name, establish_date, company_type, corp_type, company_size, + venture_cert, innobiz_cert, mainbiz_cert, sme_cert, small_biz_cert, social_venture, startup_flag, + hq_sido, hq_sigungu, innovation_city, industrial_complex, free_zone, non_capital, + industry_major, is_manufacturing, is_knowledge_service, + ceo_name, ceo_gender, ceo_birthdate, youth_ceo, senior_ceo, women_company, disabled_company, + insurance_employees, regular_employees, youth_employees, female_ratio, new_hire_plan, + rd_personnel, phd_researchers, research_center, rd_dept, patent_count, + revenue_current, revenue_prev, operating_profit, net_income, export_amount, + capital, total_assets, debt_ratio, credit_grade, tcb_grade, capital_impairment, + rd_investment, gov_project_exp, gov_support_3yr, + core_industry, strategic_tech, green_tech, digital_transform, defense_industry, + iso_certs, gmp_cert, + tax_delinquent, local_tax_delinquent, gov_project_fail, bankruptcy, financial_default], + outputs=[company_profile, profile_status] ) + + match_btn.click(fn=match_announcements_with_profile, inputs=[company_profile, current_df], outputs=[match_output]) + return demo + + +# ============================================================ +# 앱 시작 +# ============================================================ if __name__ == "__main__": + # 캐시 시스템 초기화 (백그라운드 스케줄러 시작) + if CACHE_AVAILABLE: + print("🚀 캐시 시스템 초기화 중...") + status = initialize_cache_system() + print(f"✅ 캐시 상태: {status}") + demo = create_interface() - demo.launch( - server_name="0.0.0.0", - server_port=7860, - ssr_mode=False - ) \ No newline at end of file + demo.launch(server_name="0.0.0.0", server_port=7860, ssr_mode=False) \ No newline at end of file