seawolf2357 commited on
Commit
8324256
·
verified ·
1 Parent(s): c56d266

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +0 -222
app.py CHANGED
@@ -1,8 +1,3 @@
1
- """
2
- 기업마당 AI 분석기 - 메인 애플리케이션
3
- - 벡터 DB 캐시 통합으로 빠른 로딩
4
- - KST 10:00/22:00 자동 동기화
5
- """
6
  import gradio as gr
7
  import pandas as pd
8
  import json
@@ -11,7 +6,6 @@ import tempfile
11
  from pathlib import Path
12
  from typing import Optional, Tuple, List, Dict, Generator
13
  from datetime import datetime
14
-
15
  from utils import (
16
  CATEGORY_CODES, REGION_LIST, SIDO_LIST, ORG_TYPE_OPTIONS, SORT_OPTIONS, STATUS_OPTIONS,
17
  COMPANY_TYPE_OPTIONS, CORP_TYPE_OPTIONS, COMPANY_SIZE_OPTIONS, INDUSTRY_MAJOR_OPTIONS,
@@ -19,33 +13,22 @@ from utils import (
19
  ISO_CERT_OPTIONS, extract_region_from_text, extract_region_from_hashtags,
20
  classify_org_type, parse_deadline, is_ongoing, calculate_age, calculate_company_age
21
  )
22
-
23
  from file_api import (
24
  fetch_all_from_api, fetch_with_cache, download_file, extract_text_from_file, extract_zip_files,
25
  call_groq_api_stream, fetch_announcement_detail, CACHE_AVAILABLE
26
  )
27
-
28
- # 캐시 시스템 임포트
29
  if CACHE_AVAILABLE:
30
  from cache_db import (
31
  initialize_cache_system, get_sync_status, manual_sync,
32
  get_cached_announcements, get_cache
33
  )
34
-
35
-
36
- # ============================================================
37
- # 공고 조회 함수 (캐시 통합)
38
- # ============================================================
39
  def fetch_announcements(keyword="", category="전체", region="전체(지역)", org_type="전체",
40
  sort_by="등록일순", status_filter="진행중", page=1, rows=20) -> Tuple[pd.DataFrame, str]:
41
  """기업마당 API로 공고 목록 조회 (캐시 우선)"""
42
  try:
43
- # ⭐ 항상 캐시 우선 사용 (필터가 있어도 캐시에서 필터링 - 빠름!)
44
  items, status_prefix = fetch_with_cache(category, region, keyword)
45
-
46
  if not items:
47
  return pd.DataFrame(), f"⚠️ 검색 결과가 없습니다. {status_prefix}"
48
-
49
  data = []
50
  for item in items:
51
  if not isinstance(item, dict):
@@ -54,29 +37,23 @@ def fetch_announcements(keyword="", category="전체", region="전체(지역)",
54
  title = item.get("title", "") or item.get("pblancNm", "")
55
  exec_org = item.get("excInsttNm", "") or ""
56
  hash_tags = item.get("hashTags", "")
57
-
58
  item_region = extract_region_from_hashtags(hash_tags)
59
  if not item_region:
60
  item_region = extract_region_from_text(title)
61
  if not item_region:
62
  item_region = extract_region_from_text(author)
63
-
64
  item_org_type = classify_org_type(author)
65
  req_dt = item.get("reqstDt", "") or item.get("reqstBeginEndDe", "")
66
  item_ongoing = is_ongoing(req_dt)
67
  pub_date = item.get("pubDate", "") or item.get("creatPnttm", "") or ""
68
  if pub_date and len(str(pub_date)) >= 10:
69
  pub_date = str(pub_date)[:10]
70
-
71
  link = item.get("link", "") or item.get("pblancUrl", "")
72
  pblanc_id = item.get("seq", "") or item.get("pblancId", "")
73
-
74
- # 일반 첨부파일 (서식, 양식 등)
75
  attachments = []
76
  file_url = item.get("flpthNm", "") or ""
77
  file_name = item.get("fileNm", "") or ""
78
  if file_url and file_name:
79
- # 여러 파일이 @로 구분되어 있을 수 있음
80
  urls = file_url.split("@") if "@" in file_url else [file_url]
81
  names = file_name.split("@") if "@" in file_name else [file_name]
82
  for i, (url, name) in enumerate(zip(urls, names)):
@@ -85,8 +62,6 @@ def fetch_announcements(keyword="", category="전체", region="전체(지역)",
85
  if url and name:
86
  ext = Path(name).suffix.lower()[1:] if Path(name).suffix else "unknown"
87
  attachments.append({"url": url, "filename": name, "type": ext})
88
-
89
- # ⭐ 본문출력파일 (공고 본문 PDF/HWP) - AI 분석용 핵심 파일
90
  print_file = None
91
  print_url = item.get("printFlpthNm", "") or ""
92
  print_name = item.get("printFileNm", "") or ""
@@ -95,12 +70,10 @@ def fetch_announcements(keyword="", category="전체", region="전체(지역)",
95
  print_name = print_name.strip()
96
  ext = Path(print_name).suffix.lower()[1:] if Path(print_name).suffix else "unknown"
97
  print_file = {"url": print_url, "filename": print_name, "type": ext}
98
-
99
  description = item.get("description", "") or item.get("bsnsSumryCn", "")
100
  if description:
101
  import re
102
  description = re.sub(r'<[^>]+>', '', description).strip()
103
-
104
  deadline = parse_deadline(req_dt)
105
  row = {
106
  "지원분야": item.get("lcategory", "") or item.get("pldirSportRealmLclasCodeNm", ""),
@@ -116,13 +89,10 @@ def fetch_announcements(keyword="", category="전체", region="전���(지역)",
116
  "_pub_date": pub_date, "_region": item_region,
117
  }
118
  data.append(row)
119
-
120
  if not data:
121
  return pd.DataFrame(), f"⚠️ 검색 결과가 없습니다. {status_prefix}"
122
-
123
  df = pd.DataFrame(data)
124
  total_before_filter = len(df)
125
-
126
  if org_type == "중앙부처":
127
  df = df[df["_org_type"] == "중앙부처"]
128
  elif org_type == "지자체":
@@ -131,22 +101,17 @@ def fetch_announcements(keyword="", category="전체", region="전체(지역)",
131
  df = df[df["_region"] == region]
132
  if status_filter == "진행중":
133
  df = df[df["_ongoing"] == True]
134
-
135
  if sort_by == "등록일순":
136
  df = df.sort_values(by="_pub_date", ascending=False)
137
  elif sort_by == "마감일순":
138
  df = df.sort_values(by="_deadline", ascending=True, na_position='last')
139
-
140
  if len(df) == 0:
141
  return pd.DataFrame(), f"⚠️ 필터 조건에 맞는 결과가 없습니다. (전체 {total_before_filter}건 중)"
142
-
143
  total_filtered = len(df)
144
  start_idx = (page - 1) * rows
145
  end_idx = start_idx + rows
146
  df_page = df.iloc[start_idx:end_idx].copy()
147
  df_page.insert(0, "번호", range(total_filtered - start_idx, total_filtered - start_idx - len(df_page), -1))
148
-
149
- # ⭐ 바로가기 링크 컬럼 추가 (새 탭에서 열기)
150
  def make_link(row):
151
  link = row.get("상세링크", "")
152
  if link:
@@ -154,28 +119,20 @@ def fetch_announcements(keyword="", category="전체", region="전체(지역)",
154
  link = f"https://www.bizinfo.go.kr{link}"
155
  return f'<a href="{link}" target="_blank">🔗 열기</a>'
156
  return ""
157
-
158
  df_page["바로가기"] = df_page.apply(make_link, axis=1)
159
-
160
- # 컬럼 순서 재정렬 (바로가기를 앞쪽으로)
161
  cols = df_page.columns.tolist()
162
- # 상세링크 컬럼 제거하고 바로가기로 대체
163
  if "상세링크" in cols:
164
  cols.remove("상세링크")
165
  if "바로가기" in cols:
166
  cols.remove("바로가기")
167
  cols.insert(2, "바로가기") # 번호, 지원분야 다음에 배치
168
  df_page = df_page[cols]
169
-
170
  internal_cols = [c for c in df_page.columns if c.startswith("_")]
171
  df_page = df_page.drop(columns=internal_cols)
172
-
173
- # 불필요한 컬럼 숨기기
174
  hide_cols = ["공고ID", "첨부파일", "본문출력파일", "사업개요", "지원대상", "문의처", "신청URL"]
175
  for col in hide_cols:
176
  if col in df_page.columns:
177
  df_page = df_page.drop(columns=[col])
178
-
179
  status = f"✅ {len(df_page)}건 표시 (페이지 {page}) | 필터 결과: {total_filtered}건 | 수집: {total_before_filter}건"
180
  if status_prefix:
181
  status = f"{status_prefix} | {status}"
@@ -183,14 +140,8 @@ def fetch_announcements(keyword="", category="전체", region="전체(지역)",
183
  except Exception as e:
184
  import traceback
185
  return pd.DataFrame(), f"❌ 오류: {str(e)[:80]}"
186
-
187
-
188
- # ============================================================
189
- # AI 분석 함수
190
- # ============================================================
191
  def analyze_announcement(detail_url, project_name, print_file=None, api_description="", progress=gr.Progress()):
192
  """공고 본문출력파일을 다운로드하고 AI로 분석
193
-
194
  Args:
195
  detail_url: 공고 상세 링크
196
  project_name: 공고명
@@ -200,24 +151,18 @@ def analyze_announcement(detail_url, project_name, print_file=None, api_descript
200
  if not detail_url:
201
  yield "❌ 분석할 공고를 선택해주세요."
202
  return
203
-
204
  output = f"# 📄 {project_name}\n\n---\n\n"
205
  all_text = f"## 공고명: {project_name}\n\n"
206
-
207
  if api_description:
208
  all_text += f"### 사업개요:\n{api_description}\n\n"
209
  output += f"📋 **사업개요**\n{api_description}\n\n"
210
  yield output
211
-
212
- # ⭐ 본문출력파일이 없으면 상세 페이지에서 추출 시도
213
  if not print_file or not isinstance(print_file, dict) or not print_file.get("url"):
214
  progress(0.1, desc="상세 페이지에서 본문출력파일 검색 중...")
215
  output += "🔍 **상세 페이지에서 본문출력파일 검색 중...**\n"
216
  yield output
217
-
218
  try:
219
  content, attachments, scraped_print_file = fetch_announcement_detail(detail_url)
220
-
221
  if scraped_print_file:
222
  print_file = scraped_print_file
223
  output += f" ✅ 본문출력파일 발견: `{print_file.get('filename')}`\n\n"
@@ -233,23 +178,17 @@ def analyze_announcement(detail_url, project_name, print_file=None, api_descript
233
  except Exception as e:
234
  output += f" ❌ 상세 페이지 조회 실패: {str(e)}\n\n"
235
  yield output
236
-
237
  extracted_text = None
238
-
239
- # ⭐ 본문출력파일 분석 (핵심!)
240
  if print_file and isinstance(print_file, dict) and print_file.get("url"):
241
  output += f"📄 **본문출력파일 발견**\n"
242
  output += f" - 파일명: `{print_file.get('filename', '알 수 없음')}`\n"
243
  output += f" - 형식: {print_file.get('type', 'unknown').upper()}\n\n"
244
  yield output
245
-
246
  with tempfile.TemporaryDirectory() as tmp_dir:
247
  progress(0.2, desc="본문출력파일 다운로드 중...")
248
  output += f"📥 다운로드 중...\n"
249
  yield output
250
-
251
  file_path, error = download_file(print_file['url'], tmp_dir, print_file.get('filename'))
252
-
253
  if error:
254
  output += f" - ⚠️ 다운로드 실패: {error}\n"
255
  yield output
@@ -257,8 +196,6 @@ def analyze_announcement(detail_url, project_name, print_file=None, api_descript
257
  progress(0.5, desc="텍스트 추출 중...")
258
  output += f" - ✅ 다운로드 완료\n"
259
  yield output
260
-
261
- # 텍스트 추출
262
  text, err = extract_text_from_file(file_path)
263
  if text:
264
  extracted_text = text
@@ -271,16 +208,13 @@ def analyze_announcement(detail_url, project_name, print_file=None, api_descript
271
  output += "⚠️ **본문출력파일이 없습니다.**\n"
272
  output += "사업개요만으로 분석을 진행합니다.\n\n"
273
  yield output
274
-
275
  if len(all_text) < 100 and not extracted_text:
276
  output += "\n❌ **분석할 내용이 충분하지 않습니다.**\n"
277
  output += "본문출력파일이 없거나 텍스트 추출에 실패했습니다.\n"
278
  yield output
279
  return
280
-
281
  output += f"\n📊 **분석 준비 완료** (총 {len(all_text):,}자)\n\n---\n\n## 🤖 AI 분석 결과\n\n"
282
  yield output
283
-
284
  progress(0.7, desc="AI 분석 중...")
285
  system_prompt = """당신은 정부 지원사업 공고 분석 전문가입니다.
286
  주어진 공고 내용을 분석하여 다음 항목을 명확하게 정리해주세요:
@@ -290,38 +224,27 @@ def analyze_announcement(detail_url, project_name, print_file=None, api_descript
290
  - 신청 기간, 신청 방법, 제출 서류
291
  - 중요 유의사항, 제한 사항
292
  - 이 사업의 핵심 포인트를 3줄로 요약"""
293
-
294
  messages = [
295
  {"role": "system", "content": system_prompt},
296
  {"role": "user", "content": f"다음 지원사업 공고를 분석해주세요:\n\n{all_text[:15000]}"}
297
  ]
298
-
299
  for chunk in call_groq_api_stream(messages):
300
  output += chunk
301
  yield output
302
-
303
  output += "\n\n---\n✅ **분석 완료**"
304
  yield output
305
-
306
-
307
- # ============================================================
308
- # 맞춤 과제 매칭 함수
309
- # ============================================================
310
  def analyze_uploaded_documents(files, progress=gr.Progress()):
311
  """업로드된 문서들을 분석하여 기업 정보 추출"""
312
  if not files:
313
  yield "❌ 분석할 파일을 업로드해주세요."
314
  return
315
-
316
  output = "# 📄 업로드 문서 분석 결과\n\n"
317
  all_extracted_text = []
318
-
319
  for i, file in enumerate(files):
320
  progress((i + 1) / len(files), desc=f"파일 분석 중... ({i+1}/{len(files)})")
321
  filename = os.path.basename(file.name) if hasattr(file, 'name') else f"파일_{i+1}"
322
  output += f"## 📎 {filename}\n\n"
323
  yield output
324
-
325
  try:
326
  text, error = extract_text_from_file(file.name if hasattr(file, 'name') else file)
327
  if text:
@@ -333,13 +256,10 @@ def analyze_uploaded_documents(files, progress=gr.Progress()):
333
  except Exception as e:
334
  output += f"❌ 오류: {str(e)}\n\n"
335
  yield output
336
-
337
  if all_extracted_text:
338
  output += "---\n\n## 🤖 AI 기업정보 추출\n\n"
339
  yield output
340
-
341
  combined_text = "\n\n".join([f"[{item['filename']}]\n{item['text'][:3000]}" for item in all_extracted_text])
342
-
343
  system_prompt = """당신은 기업 서류 분석 전문가입니다.
344
  주어진 문서들에서 다음 정보를 추출해주세요:
345
  1. 사업자 정보 (사업자등록번호, 법인등록번호, 상호, 대표자, 설립일, 주소, 업종)
@@ -348,48 +268,38 @@ def analyze_uploaded_documents(files, progress=gr.Progress()):
348
  4. 인증/등록 정보 (부설연구소, 벤처기업 인증 등)
349
  5. 기타 특이사항
350
  JSON 형식으로 정리해주세요."""
351
-
352
  messages = [
353
  {"role": "system", "content": system_prompt},
354
  {"role": "user", "content": f"다음 기업 서류들을 분석해주세요:\n\n{combined_text[:12000]}"}
355
  ]
356
-
357
  for chunk in call_groq_api_stream(messages):
358
  output += chunk
359
  yield output
360
-
361
  output += "\n\n---\n✅ **문서 분석 완료**"
362
  yield output
363
-
364
-
365
  def match_announcements_with_profile(profile_data, announcements_df, progress=gr.Progress()):
366
  """기업 프로필과 공고를 매칭"""
367
  if not profile_data:
368
  yield "❌ 기업 프로필을 먼저 입력해주세요."
369
  return
370
-
371
  if announcements_df is None or (isinstance(announcements_df, pd.DataFrame) and announcements_df.empty):
372
  yield "❌ 매칭할 공고 데이터가 없습니다. 먼저 공고를 검색해주세요."
373
  return
374
-
375
  output = "# 🎯 맞춤 과제 매칭 결과\n\n"
376
  output += "## 📋 입력된 기업 프로필\n\n"
377
  output += f"```json\n{json.dumps(profile_data, ensure_ascii=False, indent=2)[:2000]}\n```\n\n"
378
  output += "---\n\n## 🔍 AI 매칭 분석 중...\n\n"
379
  yield output
380
-
381
  announcements_text = ""
382
  df_to_use = announcements_df if isinstance(announcements_df, pd.DataFrame) else pd.DataFrame()
383
  for idx, row in df_to_use.head(20).iterrows():
384
  announcements_text += f"""
385
- ### {row.get('지원사업명', '')}
386
  - 지원분야: {row.get('지원분야', '')}
387
  - 소관부처: {row.get('소관부처', '')}
388
  - 신청기간: {row.get('신청기간', '')}
389
  - 지원대상: {row.get('지원대상', '')}
390
  ---
391
  """
392
-
393
  system_prompt = """당신은 정부 지원사업 매칭 전문가입니다.
394
  기업 프로필과 공고 목록을 분석하여 신청 가능한 과제를 추천해주세요.
395
  각 공고에 대해:
@@ -397,28 +307,19 @@ def match_announcements_with_profile(profile_data, announcements_df, progress=gr
397
  - ⚠️ 확인필요: 일부 조건 확인 필요
398
  - ❌ 부적합: 자격 미달
399
  추천 순위와 이유를 설명해주세요."""
400
-
401
  messages = [
402
  {"role": "system", "content": system_prompt},
403
  {"role": "user", "content": f"기업 프로필:\n{json.dumps(profile_data, ensure_ascii=False)}\n\n공고 목록:\n{announcements_text[:8000]}"}
404
  ]
405
-
406
  for chunk in call_groq_api_stream(messages):
407
  output += chunk
408
  yield output
409
-
410
  output += "\n\n---\n✅ **매칭 분석 완료**"
411
  yield output
412
-
413
-
414
- # ============================================================
415
- # 캐시 관리 함수
416
- # ============================================================
417
  def get_cache_info():
418
  """캐시 상태 정보 반환"""
419
  if not CACHE_AVAILABLE:
420
  return "⚠️ 캐시 시스템 미사용 (API 직접 호출)"
421
-
422
  status = get_sync_status()
423
  info = f"""📦 **캐시 상태**
424
  - 총 캐시: {status.get('total_count', 0):,}건
@@ -427,21 +328,13 @@ def get_cache_info():
427
  - 스케줄러: {'✅ 활성' if status.get('scheduler_available') else '❌ 비활성'}
428
  """
429
  return info
430
-
431
-
432
  def do_manual_sync():
433
  """수동 동기화 실행"""
434
  if not CACHE_AVAILABLE:
435
  return "⚠️ 캐시 시스템을 사용할 수 없습니다."
436
  return manual_sync()
437
-
438
-
439
- # ============================================================
440
- # CSS 스타일 - Neumorphism Design
441
- # ============================================================
442
  CUSTOM_CSS = """
443
  @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap');
444
-
445
  /* ═══════════════════════════════════════════════════════════
446
  🔘 NEUMORPHISM 핵심 변수
447
  ═══════════════════════════════════════════════════════════ */
@@ -456,7 +349,6 @@ CUSTOM_CSS = """
456
  --neu-success: #48bb78;
457
  --neu-warning: #ed8936;
458
  }
459
-
460
  /* ═══════════════════════════════════════════════════════════
461
  📦 기본 컨테이너
462
  ═══════════════════════════════════════════════════════════ */
@@ -467,11 +359,9 @@ CUSTOM_CSS = """
467
  margin: 0 auto !important;
468
  min-height: 100vh;
469
  }
470
-
471
  body, .dark {
472
  background: var(--neu-bg) !important;
473
  }
474
-
475
  /* ═══════════════════════════════════════════════════════════
476
  🎯 헤더 배너 (Neumorphism)
477
  ═══════════════════════════════════════════════════════════ */
@@ -485,16 +375,13 @@ body, .dark {
485
  12px 12px 24px var(--neu-shadow-dark),
486
  -12px -12px 24px var(--neu-shadow-light);
487
  }
488
-
489
  .header-banner h1 {
490
  color: var(--neu-text-dark) !important;
491
  text-shadow: 2px 2px 4px var(--neu-shadow-light), -1px -1px 3px rgba(0,0,0,0.1);
492
  }
493
-
494
  .header-banner p {
495
  color: var(--neu-text) !important;
496
  }
497
-
498
  /* ═══════════════════════════════════════════════════════════
499
  🏷️ 배지 스타일
500
  ═══════════════════════════════════════════════════════════ */
@@ -511,7 +398,6 @@ body, .dark {
511
  4px 4px 8px var(--neu-shadow-dark),
512
  -4px -4px 8px var(--neu-shadow-light);
513
  }
514
-
515
  /* ═══════════════════════════════════════════════════════════
516
  📋 섹션 헤더
517
  ═══════════════════════════════════════════════════════════ */
@@ -526,7 +412,6 @@ body, .dark {
526
  6px 6px 12px var(--neu-shadow-dark),
527
  -6px -6px 12px var(--neu-shadow-light);
528
  }
529
-
530
  /* ═══════════════════════════════════════════════════════════
531
  🔘 버튼 스타일
532
  ═══════════════════════════════════════════════════════════ */
@@ -542,21 +427,18 @@ body, .dark {
542
  -6px -6px 12px var(--neu-shadow-light) !important;
543
  transition: all 0.2s ease !important;
544
  }
545
-
546
  .gr-button:hover, button.primary:hover, button.secondary:hover {
547
  box-shadow:
548
  4px 4px 8px var(--neu-shadow-dark),
549
  -4px -4px 8px var(--neu-shadow-light) !important;
550
  transform: translateY(-1px);
551
  }
552
-
553
  .gr-button:active, button.primary:active, button.secondary:active {
554
  box-shadow:
555
  inset 4px 4px 8px var(--neu-shadow-dark),
556
  inset -4px -4px 8px var(--neu-shadow-light) !important;
557
  transform: translateY(0);
558
  }
559
-
560
  /* 🟢 분석 버튼 */
561
  .analyze-btn button, .analyze-btn {
562
  background: linear-gradient(145deg, #52c992, #3ea87a) !important;
@@ -565,11 +447,9 @@ body, .dark {
565
  6px 6px 12px var(--neu-shadow-dark),
566
  -6px -6px 12px var(--neu-shadow-light) !important;
567
  }
568
-
569
  .analyze-btn:hover button, .analyze-btn:hover {
570
  background: linear-gradient(145deg, #3ea87a, #359968) !important;
571
  }
572
-
573
  /* 🟣 매칭 버튼 */
574
  .match-btn button, .match-btn {
575
  background: linear-gradient(145deg, #9f7aea, #805ad5) !important;
@@ -578,7 +458,6 @@ body, .dark {
578
  6px 6px 12px var(--neu-shadow-dark),
579
  -6px -6px 12px var(--neu-shadow-light) !important;
580
  }
581
-
582
  /* 🟠 동기화 버튼 */
583
  .sync-btn button, .sync-btn {
584
  background: linear-gradient(145deg, #f6ad55, #ed8936) !important;
@@ -587,7 +466,6 @@ body, .dark {
587
  4px 4px 8px var(--neu-shadow-dark),
588
  -4px -4px 8px var(--neu-shadow-light) !important;
589
  }
590
-
591
  /* ═══════════════════════════════════════════════════════════
592
  📝 입력 필드 (Inset 효과)
593
  ═══════════════════════════════════════════════════════════ */
@@ -603,7 +481,6 @@ input[type="text"], input[type="number"], textarea,
603
  inset 4px 4px 8px var(--neu-shadow-dark),
604
  inset -4px -4px 8px var(--neu-shadow-light) !important;
605
  }
606
-
607
  .gr-textbox textarea:focus, .gr-textbox input:focus,
608
  input[type="text"]:focus, textarea:focus {
609
  outline: none !important;
@@ -611,7 +488,6 @@ input[type="text"]:focus, textarea:focus {
611
  inset 6px 6px 12px var(--neu-shadow-dark),
612
  inset -6px -6px 12px var(--neu-shadow-light) !important;
613
  }
614
-
615
  /* ═══════════════════════════════════════════════════════════
616
  📊 데이터프레임 테이블
617
  ═══════════════════════════════════════════════════════════ */
@@ -623,7 +499,6 @@ input[type="text"]:focus, textarea:focus {
623
  8px 8px 16px var(--neu-shadow-dark),
624
  -8px -8px 16px var(--neu-shadow-light) !important;
625
  }
626
-
627
  .gr-dataframe th, .dataframe th {
628
  background: var(--neu-bg) !important;
629
  color: var(--neu-text-dark) !important;
@@ -631,25 +506,21 @@ input[type="text"]:focus, textarea:focus {
631
  padding: 14px 12px !important;
632
  border-bottom: 2px solid var(--neu-shadow-dark) !important;
633
  }
634
-
635
  .gr-dataframe td, .dataframe td {
636
  background: var(--neu-bg) !important;
637
  color: var(--neu-text) !important;
638
  padding: 12px !important;
639
  border-bottom: 1px solid rgba(163, 177, 198, 0.3) !important;
640
  }
641
-
642
  .gr-dataframe tr:hover td, .dataframe tr:hover td {
643
  background: rgba(74, 125, 189, 0.1) !important;
644
  }
645
-
646
  /* ════════════════════════��══════════════════════════════════
647
  📁 탭 스타일
648
  ═══════════════════════════════════════════════════════════ */
649
  .gr-tabs, .tabs {
650
  background: var(--neu-bg) !important;
651
  }
652
-
653
  .gr-tab-item, .tab-nav button {
654
  background: var(--neu-bg) !important;
655
  border: none !important;
@@ -662,7 +533,6 @@ input[type="text"]:focus, textarea:focus {
662
  4px -4px 8px var(--neu-shadow-dark),
663
  -4px -4px 8px var(--neu-shadow-light) !important;
664
  }
665
-
666
  .gr-tab-item.selected, .tab-nav button.selected {
667
  background: var(--neu-bg) !important;
668
  color: var(--neu-primary) !important;
@@ -670,7 +540,6 @@ input[type="text"]:focus, textarea:focus {
670
  inset 3px 3px 6px var(--neu-shadow-dark),
671
  inset -3px -3px 6px var(--neu-shadow-light) !important;
672
  }
673
-
674
  /* ═══════════════════════════════════════════════════════════
675
  ✅ 체크박스
676
  ═══════════════════════════════════════════════════════════ */
@@ -685,14 +554,12 @@ input[type="text"]:focus, textarea:focus {
685
  inset -3px -3px 6px var(--neu-shadow-light);
686
  cursor: pointer;
687
  }
688
-
689
  .gr-checkbox input[type="checkbox"]:checked {
690
  background: linear-gradient(145deg, #48bb78, #38a169);
691
  box-shadow:
692
  3px 3px 6px var(--neu-shadow-dark),
693
  -3px -3px 6px var(--neu-shadow-light);
694
  }
695
-
696
  /* ═══════════════════════════════════════════════════════════
697
  📋 캐시 정보 박스
698
  ═══════════════════════════════════════════════════════════ */
@@ -708,7 +575,6 @@ input[type="text"]:focus, textarea:focus {
708
  inset 4px 4px 8px var(--neu-shadow-dark),
709
  inset -4px -4px 8px var(--neu-shadow-light) !important;
710
  }
711
-
712
  /* ═══════════════════════════════════════════════════════════
713
  📄 카드/패널 스타일
714
  ═══════════════════════════════════════════════════════════ */
@@ -721,7 +587,6 @@ input[type="text"]:focus, textarea:focus {
721
  8px 8px 16px var(--neu-shadow-dark),
722
  -8px -8px 16px var(--neu-shadow-light) !important;
723
  }
724
-
725
  /* ═══════════════════════════════════════════════════════════
726
  📊 라벨 스타일
727
  ═══════════════════════════════════════════════════════════ */
@@ -730,7 +595,6 @@ label, .gr-label {
730
  font-weight: 600 !important;
731
  text-shadow: 1px 1px 2px var(--neu-shadow-light);
732
  }
733
-
734
  /* ═══════════════════════════════════════════════════════════
735
  🔗 링크 스타일
736
  ═══════════════════════════════════════════════════════════ */
@@ -740,12 +604,10 @@ a {
740
  font-weight: 600;
741
  transition: all 0.2s ease;
742
  }
743
-
744
  a:hover {
745
  color: #3a6aa8 !important;
746
  text-shadow: 0 0 8px rgba(74, 125, 189, 0.3);
747
  }
748
-
749
  /* ═══════════════════════════════════════════════════════════
750
  📱 스크롤바 스타일
751
  ═══════════════════════════════════════════════════════════ */
@@ -753,13 +615,11 @@ a:hover {
753
  width: 10px;
754
  height: 10px;
755
  }
756
-
757
  ::-webkit-scrollbar-track {
758
  background: var(--neu-bg);
759
  border-radius: 10px;
760
  box-shadow: inset 2px 2px 4px var(--neu-shadow-dark);
761
  }
762
-
763
  ::-webkit-scrollbar-thumb {
764
  background: linear-gradient(145deg, #d1d9e6, #b8c0cc);
765
  border-radius: 10px;
@@ -767,7 +627,6 @@ a:hover {
767
  2px 2px 4px var(--neu-shadow-dark),
768
  -2px -2px 4px var(--neu-shadow-light);
769
  }
770
-
771
  /* ═══════════════════════════════════════════════════════════
772
  🎨 마크다운 출력 영역
773
  ═══════════════════════════════════════════════════════════ */
@@ -780,12 +639,10 @@ a:hover {
780
  inset 4px 4px 8px var(--neu-shadow-dark),
781
  inset -4px -4px 8px var(--neu-shadow-light) !important;
782
  }
783
-
784
  .gr-markdown h1, .gr-markdown h2, .gr-markdown h3 {
785
  color: var(--neu-text-dark) !important;
786
  text-shadow: 2px 2px 4px var(--neu-shadow-light);
787
  }
788
-
789
  /* ═══════════════════════════════════════════════════════════
790
  ✨ 애니메이션
791
  ═══════════════════════════════════════════════════════════ */
@@ -801,16 +658,10 @@ a:hover {
801
  -12px -12px 24px var(--neu-shadow-light);
802
  }
803
  }
804
-
805
  .loading {
806
  animation: pulse-neu 1.5s ease-in-out infinite;
807
  }
808
  """
809
-
810
-
811
- # ============================================================
812
- # 메인 인터페이스
813
- # ============================================================
814
  def create_interface():
815
  with gr.Blocks(title="기업마당 AI 분석기", css=CUSTOM_CSS) as demo:
816
  gr.HTML("""
@@ -830,23 +681,17 @@ def create_interface():
830
  </div>
831
  </div>
832
  """)
833
-
834
- # 상태 변수
835
  selected_url = gr.State("")
836
  selected_name = gr.State("")
837
  selected_print_file = gr.State(None) # ⭐ 본문출력파일 (dict)
838
  selected_description = gr.State("")
839
  current_df = gr.State(value=pd.DataFrame())
840
  company_profile = gr.State(value={})
841
-
842
  with gr.Tabs():
843
- # 탭 1: 공고 검색
844
  with gr.Tab("🔍 공고 검색"):
845
- # 캐시 상태 표시
846
  with gr.Row():
847
  cache_status = gr.Markdown(value=get_cache_info(), elem_classes=["cache-info"])
848
  sync_btn = gr.Button("🔄 수동 동기화", size="sm", elem_classes=["sync-btn"])
849
-
850
  with gr.Row():
851
  keyword_input = gr.Textbox(label="🔍 검색어", placeholder="예: AI, 스타트업, R&D", scale=3)
852
  category_dropdown = gr.Dropdown(label="📂 지원분야", choices=list(CATEGORY_CODES.keys()), value="전체", scale=1)
@@ -859,14 +704,11 @@ def create_interface():
859
  page_input = gr.Number(label="📄 페이지", value=1, minimum=1, scale=1)
860
  rows_dropdown = gr.Dropdown(label="📊 표시개수", choices=[10, 15, 20, 30, 50], value=20, scale=1)
861
  search_btn = gr.Button("🔎 검색", variant="primary", scale=2)
862
-
863
  status_output = gr.Textbox(label="📊 조회 결과", interactive=False)
864
  results_output = gr.Dataframe(label="📋 공고 목록 (행 클릭으로 선택)", wrap=True, interactive=False, datatype="markdown")
865
  with gr.Row():
866
  prev_btn = gr.Button("◀️ 이전", size="sm")
867
  next_btn = gr.Button("다음 ▶️", size="sm")
868
-
869
- # 탭 2: AI 분석
870
  with gr.Tab("🤖 AI 분석"):
871
  with gr.Row():
872
  with gr.Column(scale=1):
@@ -874,8 +716,6 @@ def create_interface():
874
  analyze_btn = gr.Button("🚀 AI 분석 시작", variant="primary", size="lg", elem_classes=["analyze-btn"])
875
  with gr.Column(scale=2):
876
  analysis_output = gr.Markdown(value="### 📊 분석 결과\n\n*공고를 선택하고 분석 버튼을 클릭하세요*", height=500)
877
-
878
- # 탭 3: 맞춤 과제 추출
879
  with gr.Tab("🎯 맞춤 과제 추출"):
880
  gr.HTML("""
881
  <div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; border-radius: 12px; margin-bottom: 20px;">
@@ -883,9 +723,7 @@ def create_interface():
883
  <p style="margin: 0; opacity: 0.9;">기업 정보를 입력하고 문서를 업로드하면 AI가 신청 가능한 과제를 자동으로 매칭해드립니다.</p>
884
  </div>
885
  """)
886
-
887
  with gr.Tabs():
888
- # 서브탭 1: 기업 기본정보
889
  with gr.Tab("1️⃣ 기업 기본정보"):
890
  with gr.Row():
891
  with gr.Column():
@@ -897,7 +735,6 @@ def create_interface():
897
  company_type = gr.Dropdown(label="기업형태", choices=COMPANY_TYPE_OPTIONS, value="법인사업자")
898
  corp_type = gr.Dropdown(label="법인 종류", choices=CORP_TYPE_OPTIONS, value="주식회사")
899
  company_size = gr.Dropdown(label="기업규모", choices=COMPANY_SIZE_OPTIONS, value="소기업")
900
-
901
  with gr.Column():
902
  gr.HTML('<div class="section-header">🏆 인증 현황</div>')
903
  venture_cert = gr.Checkbox(label="벤처기업 인증")
@@ -907,7 +744,6 @@ def create_interface():
907
  small_biz_cert = gr.Checkbox(label="소상공인확인서 보유")
908
  social_venture = gr.Checkbox(label="소셜벤처")
909
  startup_flag = gr.Checkbox(label="스타트업")
910
-
911
  with gr.Column():
912
  gr.HTML('<div class="section-header">📍 소재지 정보</div>')
913
  hq_sido = gr.Dropdown(label="본사 소재지 (시/도)", choices=SIDO_LIST, value="서울특별시")
@@ -916,13 +752,10 @@ def create_interface():
916
  industrial_complex = gr.Checkbox(label="산업단지 입주")
917
  free_zone = gr.Checkbox(label="규제자유특구 소재")
918
  non_capital = gr.Checkbox(label="비수도권 (지방기업)")
919
-
920
  gr.HTML('<div class="section-header">📊 업종 정보</div>')
921
  industry_major = gr.Dropdown(label="주업종 (대분류)", choices=INDUSTRY_MAJOR_OPTIONS, value="C. 제조업")
922
  is_manufacturing = gr.Checkbox(label="제조업 여부")
923
  is_knowledge_service = gr.Checkbox(label="지식서비스업 여부")
924
-
925
- # 서브탭 2: 대표자/인력 정보
926
  with gr.Tab("2️⃣ 대표자/인력 정보"):
927
  with gr.Row():
928
  with gr.Column():
@@ -934,7 +767,6 @@ def create_interface():
934
  senior_ceo = gr.Checkbox(label="시니어창업자 (만40세 이상)")
935
  women_company = gr.Checkbox(label="여성기업확인서 보유")
936
  disabled_company = gr.Checkbox(label="장애인기업확인서 보유")
937
-
938
  with gr.Column():
939
  gr.HTML('<div class="section-header">👥 고용 현황</div>')
940
  insurance_employees = gr.Number(label="4대보험 가입자 수", value=0, minimum=0)
@@ -942,7 +774,6 @@ def create_interface():
942
  youth_employees = gr.Number(label="청년고용 인원", value=0, minimum=0)
943
  female_ratio = gr.Slider(label="여성고용 비율 (%)", minimum=0, maximum=100, value=0)
944
  new_hire_plan = gr.Number(label="신규채용 계획 (명)", value=0, minimum=0)
945
-
946
  with gr.Column():
947
  gr.HTML('<div class="section-header">🔬 연구인력/역량</div>')
948
  rd_personnel = gr.Number(label="연구인력 수", value=0, minimum=0)
@@ -950,8 +781,6 @@ def create_interface():
950
  research_center = gr.Checkbox(label="기업부설연구소 등록")
951
  rd_dept = gr.Checkbox(label="연구개발전담부서 등록")
952
  patent_count = gr.Number(label="보유 특허 수", value=0, minimum=0)
953
-
954
- # 서브탭 3: 재무 정보
955
  with gr.Tab("3️⃣ 재무 정보"):
956
  with gr.Row():
957
  with gr.Column():
@@ -961,7 +790,6 @@ def create_interface():
961
  operating_profit = gr.Number(label="영업이익 (백만원)", value=0)
962
  net_income = gr.Number(label="당기순이익 (백만원)", value=0)
963
  export_amount = gr.Number(label="수출액 (천달러)", value=0, minimum=0)
964
-
965
  with gr.Column():
966
  gr.HTML('<div class="section-header">📊 재무건전성</div>')
967
  capital = gr.Number(label="자본금 (백만원)", value=0, minimum=0)
@@ -970,14 +798,11 @@ def create_interface():
970
  credit_grade = gr.Dropdown(label="신용등급", choices=CREDIT_GRADE_OPTIONS, value="미평가")
971
  tcb_grade = gr.Dropdown(label="TCB 등급", choices=TCB_GRADE_OPTIONS, value="미평가")
972
  capital_impairment = gr.Checkbox(label="자본잠식 여부")
973
-
974
  with gr.Column():
975
  gr.HTML('<div class="section-header">🔬 R&D 투자</div>')
976
  rd_investment = gr.Number(label="연간 R&D 투자액 (백만원)", value=0, minimum=0)
977
  gov_project_exp = gr.Checkbox(label="정부과제 수행 경험")
978
  gov_support_3yr = gr.Number(label="최근 3년 정부지원금 (백만원)", value=0, minimum=0)
979
-
980
- # 서브탭 4: 기술분야/제한사항
981
  with gr.Tab("4️⃣ 기술분야/제한사항"):
982
  with gr.Row():
983
  with gr.Column():
@@ -987,12 +812,10 @@ def create_interface():
987
  green_tech = gr.Checkbox(label="녹색기술 분야")
988
  digital_transform = gr.Checkbox(label="디지털전환 분야")
989
  defense_industry = gr.Checkbox(label="국방/방산 분야")
990
-
991
  with gr.Column():
992
  gr.HTML('<div class="section-header">📜 인증/ISO</div>')
993
  iso_certs = gr.CheckboxGroup(label="ISO 인증", choices=ISO_CERT_OPTIONS)
994
  gmp_cert = gr.Checkbox(label="GMP 인증")
995
-
996
  with gr.Column():
997
  gr.HTML('<div class="section-header">⚠️ 결격사유 확인</div>')
998
  tax_delinquent = gr.Checkbox(label="국세 체납")
@@ -1000,8 +823,6 @@ def create_interface():
1000
  gov_project_fail = gr.Checkbox(label="정부과제 불성실")
1001
  bankruptcy = gr.Checkbox(label="휴/폐업 이력")
1002
  financial_default = gr.Checkbox(label="금융기관 연체")
1003
-
1004
- # 서브탭 5: 문서 업로드 및 매칭
1005
  with gr.Tab("5️⃣ 문서 업로드 & 매칭"):
1006
  gr.HTML("""
1007
  <div style="background: #EBF5FF; border: 1px solid #3B82F6; border-radius: 8px; padding: 16px; margin-bottom: 16px;">
@@ -1009,7 +830,6 @@ def create_interface():
1009
  <p style="margin: 0; color: #1E3A8A;">사업자등록증, 등기부등본, 재무제표, 중소기업확인서 등을 업로드하면 AI가 자동으로 정보를 추출합니다.</p>
1010
  </div>
1011
  """)
1012
-
1013
  with gr.Row():
1014
  with gr.Column(scale=1):
1015
  file_upload = gr.File(
@@ -1018,22 +838,14 @@ def create_interface():
1018
  file_types=[".hwp", ".hwpx", ".pdf", ".txt", ".xlsx", ".xls"]
1019
  )
1020
  analyze_docs_btn = gr.Button("📄 문서 분석", variant="secondary", size="lg")
1021
-
1022
  with gr.Column(scale=2):
1023
  doc_analysis_output = gr.Markdown(value="### 📄 문서 분석 결과\n\n*문서를 업로드하고 분석 버튼을 클릭하세요*", height=400)
1024
-
1025
  gr.HTML('<hr style="margin: 24px 0;">')
1026
-
1027
  with gr.Row():
1028
  save_profile_btn = gr.Button("💾 프로필 저장", variant="secondary", size="lg")
1029
  match_btn = gr.Button("🎯 맞춤 과제 매칭 시작", variant="primary", size="lg", elem_classes=["match-btn"])
1030
-
1031
  profile_status = gr.Textbox(label="프로필 저장 상태", interactive=False)
1032
  match_output = gr.Markdown(value="### 🎯 매칭 결과\n\n*프로필을 저장하고 매칭 버튼을 클릭하세요*", height=500)
1033
-
1034
- # ============================================================
1035
- # 이벤트 핸들러
1036
- # ============================================================
1037
  def search_fn(keyword, category, region, org_type, sort_by, status_filter, page, rows):
1038
  df, status = fetch_announcements(keyword or "", category or "전체", region or "전체(지역)",
1039
  org_type or "전체", sort_by or "등록일순",
@@ -1041,7 +853,6 @@ def create_interface():
1041
  display_cols = ["번호", "지원분야", "지원사업명", "신청기간", "소관부처", "등록일", "지원대상"]
1042
  display_df = df[[c for c in display_cols if c in df.columns]] if not df.empty else df
1043
  return display_df, status, df
1044
-
1045
  def prev_fn(page, keyword, category, region, org_type, sort_by, status_filter, rows):
1046
  new_page = max(1, int(page) - 1) if page else 1
1047
  df, status = fetch_announcements(keyword or "", category or "전체", region or "전체(지역)",
@@ -1050,7 +861,6 @@ def create_interface():
1050
  display_cols = ["번호", "지원분야", "지원사업명", "신청기간", "소관부처", "등록일", "지원대상"]
1051
  display_df = df[[c for c in display_cols if c in df.columns]] if not df.empty else df
1052
  return display_df, status, df, new_page
1053
-
1054
  def next_fn(page, keyword, category, region, org_type, sort_by, status_filter, rows):
1055
  new_page = int(page) + 1 if page else 2
1056
  df, status = fetch_announcements(keyword or "", category or "전체", region or "전체(지역)",
@@ -1059,7 +869,6 @@ def create_interface():
1059
  display_cols = ["번호", "지원분야", "지원사업명", "신청기간", "소관부처", "등록일", "지원대상"]
1060
  display_df = df[[c for c in display_cols if c in df.columns]] if not df.empty else df
1061
  return display_df, status, df, new_page
1062
-
1063
  def on_row_select(evt: gr.SelectData, df):
1064
  if evt.index[0] < len(df):
1065
  row = df.iloc[evt.index[0]]
@@ -1068,25 +877,17 @@ def create_interface():
1068
  attachments = row.get("첨부파일", [])
1069
  print_file = row.get("본문출력파일", None) # ⭐ AI 분석용 핵심 파일
1070
  description = row.get("사업개요", "")
1071
-
1072
- # 정보 표시
1073
  info_parts = [f"📌 {name}", f"🔗 {url}"]
1074
-
1075
- # 본문출력파일 (AI 분석 대상)
1076
  if print_file:
1077
  info_parts.append(f"\n\n📄 **본문출력파일 (AI 분석 대상)**:")
1078
  info_parts.append(f" - {print_file.get('filename', '파일')}")
1079
-
1080
- # 일반 첨부파일 (서식, 양식)
1081
  if attachments and len(attachments) > 0:
1082
  info_parts.append(f"\n\n📎 기타 첨부파일 {len(attachments)}개:")
1083
  for att in attachments:
1084
  info_parts.append(f" - {att.get('filename', '파일')}")
1085
-
1086
  info = "\n".join(info_parts)
1087
  return url, name, print_file, description, info
1088
  return "", "", None, "", ""
1089
-
1090
  def save_profile_fn(biz_num, corp_num, comp_name, est_date, comp_type, corp_tp, comp_size,
1091
  venture, innobiz, mainbiz, sme, small_biz, social, startup,
1092
  sido, sigungu, innov_city, ind_complex, free_z, non_cap,
@@ -1100,7 +901,6 @@ def create_interface():
1100
  core_ind, strat_tech, green, digital, defense,
1101
  iso, gmp,
1102
  tax_del, local_tax, gov_fail, bankrupt, fin_def):
1103
-
1104
  profile = {
1105
  "사업자정보": {
1106
  "사업자등록번호": biz_num, "법인등록번호": corp_num, "상호": comp_name,
@@ -1144,41 +944,28 @@ def create_interface():
1144
  }
1145
  }
1146
  return profile, "✅ 프로필이 저장되었습니다."
1147
-
1148
  def sync_and_update():
1149
  result = do_manual_sync()
1150
  info = get_cache_info()
1151
  return info, result
1152
-
1153
- # 이벤트 연결
1154
  search_btn.click(fn=search_fn, inputs=[keyword_input, category_dropdown, region_dropdown, org_type_dropdown,
1155
  sort_dropdown, status_dropdown, page_input, rows_dropdown],
1156
  outputs=[results_output, status_output, current_df])
1157
-
1158
  keyword_input.submit(fn=search_fn, inputs=[keyword_input, category_dropdown, region_dropdown, org_type_dropdown,
1159
  sort_dropdown, status_dropdown, page_input, rows_dropdown],
1160
  outputs=[results_output, status_output, current_df])
1161
-
1162
- # 수동 동기화 버튼
1163
  sync_btn.click(fn=sync_and_update, outputs=[cache_status, status_output])
1164
-
1165
- # 페이지네이션 이벤트
1166
  prev_btn.click(fn=prev_fn, inputs=[page_input, keyword_input, category_dropdown, region_dropdown,
1167
  org_type_dropdown, sort_dropdown, status_dropdown, rows_dropdown],
1168
  outputs=[results_output, status_output, current_df, page_input])
1169
-
1170
  next_btn.click(fn=next_fn, inputs=[page_input, keyword_input, category_dropdown, region_dropdown,
1171
  org_type_dropdown, sort_dropdown, status_dropdown, rows_dropdown],
1172
  outputs=[results_output, status_output, current_df, page_input])
1173
-
1174
  results_output.select(fn=on_row_select, inputs=[current_df],
1175
  outputs=[selected_url, selected_name, selected_print_file, selected_description, selected_info])
1176
-
1177
  analyze_btn.click(fn=analyze_announcement, inputs=[selected_url, selected_name, selected_print_file, selected_description],
1178
  outputs=[analysis_output])
1179
-
1180
  analyze_docs_btn.click(fn=analyze_uploaded_documents, inputs=[file_upload], outputs=[doc_analysis_output])
1181
-
1182
  save_profile_btn.click(
1183
  fn=save_profile_fn,
1184
  inputs=[biz_number, corp_number, company_name, establish_date, company_type, corp_type, company_size,
@@ -1196,21 +983,12 @@ def create_interface():
1196
  tax_delinquent, local_tax_delinquent, gov_project_fail, bankruptcy, financial_default],
1197
  outputs=[company_profile, profile_status]
1198
  )
1199
-
1200
  match_btn.click(fn=match_announcements_with_profile, inputs=[company_profile, current_df], outputs=[match_output])
1201
-
1202
  return demo
1203
-
1204
-
1205
- # ============================================================
1206
- # 앱 시작
1207
- # ============================================================
1208
  if __name__ == "__main__":
1209
- # 캐시 시스템 초기화 (백그라운드 스케줄러 시작)
1210
  if CACHE_AVAILABLE:
1211
  print("🚀 캐시 시스템 초기화 중...")
1212
  status = initialize_cache_system()
1213
  print(f"✅ 캐시 상태: {status}")
1214
-
1215
  demo = create_interface()
1216
  demo.launch(server_name="0.0.0.0", server_port=7860, ssr_mode=False)
 
 
 
 
 
 
1
  import gradio as gr
2
  import pandas as pd
3
  import json
 
6
  from pathlib import Path
7
  from typing import Optional, Tuple, List, Dict, Generator
8
  from datetime import datetime
 
9
  from utils import (
10
  CATEGORY_CODES, REGION_LIST, SIDO_LIST, ORG_TYPE_OPTIONS, SORT_OPTIONS, STATUS_OPTIONS,
11
  COMPANY_TYPE_OPTIONS, CORP_TYPE_OPTIONS, COMPANY_SIZE_OPTIONS, INDUSTRY_MAJOR_OPTIONS,
 
13
  ISO_CERT_OPTIONS, extract_region_from_text, extract_region_from_hashtags,
14
  classify_org_type, parse_deadline, is_ongoing, calculate_age, calculate_company_age
15
  )
 
16
  from file_api import (
17
  fetch_all_from_api, fetch_with_cache, download_file, extract_text_from_file, extract_zip_files,
18
  call_groq_api_stream, fetch_announcement_detail, CACHE_AVAILABLE
19
  )
 
 
20
  if CACHE_AVAILABLE:
21
  from cache_db import (
22
  initialize_cache_system, get_sync_status, manual_sync,
23
  get_cached_announcements, get_cache
24
  )
 
 
 
 
 
25
  def fetch_announcements(keyword="", category="전체", region="전체(지역)", org_type="전체",
26
  sort_by="등록일순", status_filter="진행중", page=1, rows=20) -> Tuple[pd.DataFrame, str]:
27
  """기업마당 API로 공고 목록 조회 (캐시 우선)"""
28
  try:
 
29
  items, status_prefix = fetch_with_cache(category, region, keyword)
 
30
  if not items:
31
  return pd.DataFrame(), f"⚠️ 검색 결과가 없습니다. {status_prefix}"
 
32
  data = []
33
  for item in items:
34
  if not isinstance(item, dict):
 
37
  title = item.get("title", "") or item.get("pblancNm", "")
38
  exec_org = item.get("excInsttNm", "") or ""
39
  hash_tags = item.get("hashTags", "")
 
40
  item_region = extract_region_from_hashtags(hash_tags)
41
  if not item_region:
42
  item_region = extract_region_from_text(title)
43
  if not item_region:
44
  item_region = extract_region_from_text(author)
 
45
  item_org_type = classify_org_type(author)
46
  req_dt = item.get("reqstDt", "") or item.get("reqstBeginEndDe", "")
47
  item_ongoing = is_ongoing(req_dt)
48
  pub_date = item.get("pubDate", "") or item.get("creatPnttm", "") or ""
49
  if pub_date and len(str(pub_date)) >= 10:
50
  pub_date = str(pub_date)[:10]
 
51
  link = item.get("link", "") or item.get("pblancUrl", "")
52
  pblanc_id = item.get("seq", "") or item.get("pblancId", "")
 
 
53
  attachments = []
54
  file_url = item.get("flpthNm", "") or ""
55
  file_name = item.get("fileNm", "") or ""
56
  if file_url and file_name:
 
57
  urls = file_url.split("@") if "@" in file_url else [file_url]
58
  names = file_name.split("@") if "@" in file_name else [file_name]
59
  for i, (url, name) in enumerate(zip(urls, names)):
 
62
  if url and name:
63
  ext = Path(name).suffix.lower()[1:] if Path(name).suffix else "unknown"
64
  attachments.append({"url": url, "filename": name, "type": ext})
 
 
65
  print_file = None
66
  print_url = item.get("printFlpthNm", "") or ""
67
  print_name = item.get("printFileNm", "") or ""
 
70
  print_name = print_name.strip()
71
  ext = Path(print_name).suffix.lower()[1:] if Path(print_name).suffix else "unknown"
72
  print_file = {"url": print_url, "filename": print_name, "type": ext}
 
73
  description = item.get("description", "") or item.get("bsnsSumryCn", "")
74
  if description:
75
  import re
76
  description = re.sub(r'<[^>]+>', '', description).strip()
 
77
  deadline = parse_deadline(req_dt)
78
  row = {
79
  "지원분야": item.get("lcategory", "") or item.get("pldirSportRealmLclasCodeNm", ""),
 
89
  "_pub_date": pub_date, "_region": item_region,
90
  }
91
  data.append(row)
 
92
  if not data:
93
  return pd.DataFrame(), f"⚠️ 검색 결과가 없습니다. {status_prefix}"
 
94
  df = pd.DataFrame(data)
95
  total_before_filter = len(df)
 
96
  if org_type == "중앙부처":
97
  df = df[df["_org_type"] == "중앙부처"]
98
  elif org_type == "지자체":
 
101
  df = df[df["_region"] == region]
102
  if status_filter == "진행중":
103
  df = df[df["_ongoing"] == True]
 
104
  if sort_by == "등록일순":
105
  df = df.sort_values(by="_pub_date", ascending=False)
106
  elif sort_by == "마감일순":
107
  df = df.sort_values(by="_deadline", ascending=True, na_position='last')
 
108
  if len(df) == 0:
109
  return pd.DataFrame(), f"⚠️ 필터 조건에 맞는 결과가 없습니다. (전체 {total_before_filter}건 중)"
 
110
  total_filtered = len(df)
111
  start_idx = (page - 1) * rows
112
  end_idx = start_idx + rows
113
  df_page = df.iloc[start_idx:end_idx].copy()
114
  df_page.insert(0, "번호", range(total_filtered - start_idx, total_filtered - start_idx - len(df_page), -1))
 
 
115
  def make_link(row):
116
  link = row.get("상세링크", "")
117
  if link:
 
119
  link = f"https://www.bizinfo.go.kr{link}"
120
  return f'<a href="{link}" target="_blank">🔗 열기</a>'
121
  return ""
 
122
  df_page["바로가기"] = df_page.apply(make_link, axis=1)
 
 
123
  cols = df_page.columns.tolist()
 
124
  if "상세링크" in cols:
125
  cols.remove("상세링크")
126
  if "바로가기" in cols:
127
  cols.remove("바로가기")
128
  cols.insert(2, "바로가기") # 번호, 지원분야 다음에 배치
129
  df_page = df_page[cols]
 
130
  internal_cols = [c for c in df_page.columns if c.startswith("_")]
131
  df_page = df_page.drop(columns=internal_cols)
 
 
132
  hide_cols = ["공고ID", "첨부파일", "본문출력파일", "사업개요", "지원대상", "문의처", "신청URL"]
133
  for col in hide_cols:
134
  if col in df_page.columns:
135
  df_page = df_page.drop(columns=[col])
 
136
  status = f"✅ {len(df_page)}건 표시 (페이지 {page}) | 필터 결과: {total_filtered}건 | 수집: {total_before_filter}건"
137
  if status_prefix:
138
  status = f"{status_prefix} | {status}"
 
140
  except Exception as e:
141
  import traceback
142
  return pd.DataFrame(), f"❌ 오류: {str(e)[:80]}"
 
 
 
 
 
143
  def analyze_announcement(detail_url, project_name, print_file=None, api_description="", progress=gr.Progress()):
144
  """공고 본문출력파일을 다운로드하고 AI로 분석
 
145
  Args:
146
  detail_url: 공고 상세 링크
147
  project_name: 공고명
 
151
  if not detail_url:
152
  yield "❌ 분석할 공고를 선택해주세요."
153
  return
 
154
  output = f"# 📄 {project_name}\n\n---\n\n"
155
  all_text = f"## 공고명: {project_name}\n\n"
 
156
  if api_description:
157
  all_text += f"### 사업개요:\n{api_description}\n\n"
158
  output += f"📋 **사업개요**\n{api_description}\n\n"
159
  yield output
 
 
160
  if not print_file or not isinstance(print_file, dict) or not print_file.get("url"):
161
  progress(0.1, desc="상세 페이지에서 본문출력파일 검색 중...")
162
  output += "🔍 **상세 페이지에서 본문출력파일 검색 중...**\n"
163
  yield output
 
164
  try:
165
  content, attachments, scraped_print_file = fetch_announcement_detail(detail_url)
 
166
  if scraped_print_file:
167
  print_file = scraped_print_file
168
  output += f" ✅ 본문출력파일 발견: `{print_file.get('filename')}`\n\n"
 
178
  except Exception as e:
179
  output += f" ❌ 상세 페이지 조회 실패: {str(e)}\n\n"
180
  yield output
 
181
  extracted_text = None
 
 
182
  if print_file and isinstance(print_file, dict) and print_file.get("url"):
183
  output += f"📄 **본문출력파일 발견**\n"
184
  output += f" - 파일명: `{print_file.get('filename', '알 수 없음')}`\n"
185
  output += f" - 형식: {print_file.get('type', 'unknown').upper()}\n\n"
186
  yield output
 
187
  with tempfile.TemporaryDirectory() as tmp_dir:
188
  progress(0.2, desc="본문출력파일 다운로드 중...")
189
  output += f"📥 다운로드 중...\n"
190
  yield output
 
191
  file_path, error = download_file(print_file['url'], tmp_dir, print_file.get('filename'))
 
192
  if error:
193
  output += f" - ⚠️ 다운로드 실패: {error}\n"
194
  yield output
 
196
  progress(0.5, desc="텍스트 추출 중...")
197
  output += f" - ✅ 다운로드 완료\n"
198
  yield output
 
 
199
  text, err = extract_text_from_file(file_path)
200
  if text:
201
  extracted_text = text
 
208
  output += "⚠️ **본문출력파일이 없습니다.**\n"
209
  output += "사업개요만으로 분석을 진행합니다.\n\n"
210
  yield output
 
211
  if len(all_text) < 100 and not extracted_text:
212
  output += "\n❌ **분석할 내용이 충분하지 않습니다.**\n"
213
  output += "본문출력파일이 없거나 텍스트 추출에 실패했습니다.\n"
214
  yield output
215
  return
 
216
  output += f"\n📊 **분석 준비 완료** (총 {len(all_text):,}자)\n\n---\n\n## 🤖 AI 분석 결과\n\n"
217
  yield output
 
218
  progress(0.7, desc="AI 분석 중...")
219
  system_prompt = """당신은 정부 지원사업 공고 분석 전문가입니다.
220
  주어진 공고 내용을 분석하여 다음 항목을 명확하게 정리해주세요:
 
224
  - 신청 기간, 신청 방법, 제출 서류
225
  - 중요 유의사항, 제한 사항
226
  - 이 사업의 핵심 포인트를 3줄로 요약"""
 
227
  messages = [
228
  {"role": "system", "content": system_prompt},
229
  {"role": "user", "content": f"다음 지원사업 공고를 분석해주세요:\n\n{all_text[:15000]}"}
230
  ]
 
231
  for chunk in call_groq_api_stream(messages):
232
  output += chunk
233
  yield output
 
234
  output += "\n\n---\n✅ **분석 완료**"
235
  yield output
 
 
 
 
 
236
  def analyze_uploaded_documents(files, progress=gr.Progress()):
237
  """업로드된 문서들을 분석하여 기업 정보 추출"""
238
  if not files:
239
  yield "❌ 분석할 파일을 업로드해주세요."
240
  return
 
241
  output = "# 📄 업로드 문서 분석 결과\n\n"
242
  all_extracted_text = []
 
243
  for i, file in enumerate(files):
244
  progress((i + 1) / len(files), desc=f"파일 분석 중... ({i+1}/{len(files)})")
245
  filename = os.path.basename(file.name) if hasattr(file, 'name') else f"파일_{i+1}"
246
  output += f"## 📎 {filename}\n\n"
247
  yield output
 
248
  try:
249
  text, error = extract_text_from_file(file.name if hasattr(file, 'name') else file)
250
  if text:
 
256
  except Exception as e:
257
  output += f"❌ 오류: {str(e)}\n\n"
258
  yield output
 
259
  if all_extracted_text:
260
  output += "---\n\n## 🤖 AI 기업정보 추출\n\n"
261
  yield output
 
262
  combined_text = "\n\n".join([f"[{item['filename']}]\n{item['text'][:3000]}" for item in all_extracted_text])
 
263
  system_prompt = """당신은 기업 서류 분석 전문가입니다.
264
  주어진 문서들에서 다음 정보를 추출해주세요:
265
  1. 사업자 정보 (사업자등록번호, 법인등록번호, 상호, 대표자, 설립일, 주소, 업종)
 
268
  4. 인증/등록 정보 (부설연구소, 벤처기업 인증 등)
269
  5. 기타 특이사항
270
  JSON 형식으로 정리해주세요."""
 
271
  messages = [
272
  {"role": "system", "content": system_prompt},
273
  {"role": "user", "content": f"다음 기업 서류들을 분석해주세요:\n\n{combined_text[:12000]}"}
274
  ]
 
275
  for chunk in call_groq_api_stream(messages):
276
  output += chunk
277
  yield output
 
278
  output += "\n\n---\n✅ **문서 분석 완료**"
279
  yield output
 
 
280
  def match_announcements_with_profile(profile_data, announcements_df, progress=gr.Progress()):
281
  """기업 프로필과 공고를 매칭"""
282
  if not profile_data:
283
  yield "❌ 기업 프로필을 먼저 입력해주세요."
284
  return
 
285
  if announcements_df is None or (isinstance(announcements_df, pd.DataFrame) and announcements_df.empty):
286
  yield "❌ 매칭할 공고 데이터가 없습니다. 먼저 공고를 검색해주세요."
287
  return
 
288
  output = "# 🎯 맞춤 과제 매칭 결과\n\n"
289
  output += "## 📋 입력된 기업 프로필\n\n"
290
  output += f"```json\n{json.dumps(profile_data, ensure_ascii=False, indent=2)[:2000]}\n```\n\n"
291
  output += "---\n\n## 🔍 AI 매칭 분석 중...\n\n"
292
  yield output
 
293
  announcements_text = ""
294
  df_to_use = announcements_df if isinstance(announcements_df, pd.DataFrame) else pd.DataFrame()
295
  for idx, row in df_to_use.head(20).iterrows():
296
  announcements_text += f"""
 
297
  - 지원분야: {row.get('지원분야', '')}
298
  - 소관부처: {row.get('소관부처', '')}
299
  - 신청기간: {row.get('신청기간', '')}
300
  - 지원대상: {row.get('지원대상', '')}
301
  ---
302
  """
 
303
  system_prompt = """당신은 정부 지원사업 매칭 전문가입니다.
304
  기업 프로필과 공고 목록을 분석하여 신청 가능한 과제를 추천해주세요.
305
  각 공고에 대해:
 
307
  - ⚠️ 확인필요: 일부 조건 확인 필요
308
  - ❌ 부적합: 자격 미달
309
  추천 순위와 이유를 설명해주세요."""
 
310
  messages = [
311
  {"role": "system", "content": system_prompt},
312
  {"role": "user", "content": f"기업 프로필:\n{json.dumps(profile_data, ensure_ascii=False)}\n\n공고 목록:\n{announcements_text[:8000]}"}
313
  ]
 
314
  for chunk in call_groq_api_stream(messages):
315
  output += chunk
316
  yield output
 
317
  output += "\n\n---\n✅ **매칭 분석 완료**"
318
  yield output
 
 
 
 
 
319
  def get_cache_info():
320
  """캐시 상태 정보 반환"""
321
  if not CACHE_AVAILABLE:
322
  return "⚠️ 캐시 시스템 미사용 (API 직접 호출)"
 
323
  status = get_sync_status()
324
  info = f"""📦 **캐시 상태**
325
  - 총 캐시: {status.get('total_count', 0):,}건
 
328
  - 스케줄러: {'✅ 활성' if status.get('scheduler_available') else '❌ 비활성'}
329
  """
330
  return info
 
 
331
  def do_manual_sync():
332
  """수동 동기화 실행"""
333
  if not CACHE_AVAILABLE:
334
  return "⚠️ 캐시 시스템을 사용할 수 없습니다."
335
  return manual_sync()
 
 
 
 
 
336
  CUSTOM_CSS = """
337
  @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap');
 
338
  /* ═══════════════════════════════════════════════════════════
339
  🔘 NEUMORPHISM 핵심 변수
340
  ═══════════════════════════════════════════════════════════ */
 
349
  --neu-success: #48bb78;
350
  --neu-warning: #ed8936;
351
  }
 
352
  /* ═══════════════════════════════════════════════════════════
353
  📦 기본 컨테이너
354
  ═══════════════════════════════════════════════════════════ */
 
359
  margin: 0 auto !important;
360
  min-height: 100vh;
361
  }
 
362
  body, .dark {
363
  background: var(--neu-bg) !important;
364
  }
 
365
  /* ═══════════════════════════════════════════════════════════
366
  🎯 헤더 배너 (Neumorphism)
367
  ═══════════════════════════════════════════════════════════ */
 
375
  12px 12px 24px var(--neu-shadow-dark),
376
  -12px -12px 24px var(--neu-shadow-light);
377
  }
 
378
  .header-banner h1 {
379
  color: var(--neu-text-dark) !important;
380
  text-shadow: 2px 2px 4px var(--neu-shadow-light), -1px -1px 3px rgba(0,0,0,0.1);
381
  }
 
382
  .header-banner p {
383
  color: var(--neu-text) !important;
384
  }
 
385
  /* ═══════════════════════════════════════════════════════════
386
  🏷️ 배지 스타일
387
  ═══════════════════════════════════════════════════════════ */
 
398
  4px 4px 8px var(--neu-shadow-dark),
399
  -4px -4px 8px var(--neu-shadow-light);
400
  }
 
401
  /* ═══════════════════════════════════════════════════════════
402
  📋 섹션 헤더
403
  ═══════════════════════════════════════════════════════════ */
 
412
  6px 6px 12px var(--neu-shadow-dark),
413
  -6px -6px 12px var(--neu-shadow-light);
414
  }
 
415
  /* ═══════════════════════════════════════════════════════════
416
  🔘 버튼 스타일
417
  ═══════════════════════════════════════════════════════════ */
 
427
  -6px -6px 12px var(--neu-shadow-light) !important;
428
  transition: all 0.2s ease !important;
429
  }
 
430
  .gr-button:hover, button.primary:hover, button.secondary:hover {
431
  box-shadow:
432
  4px 4px 8px var(--neu-shadow-dark),
433
  -4px -4px 8px var(--neu-shadow-light) !important;
434
  transform: translateY(-1px);
435
  }
 
436
  .gr-button:active, button.primary:active, button.secondary:active {
437
  box-shadow:
438
  inset 4px 4px 8px var(--neu-shadow-dark),
439
  inset -4px -4px 8px var(--neu-shadow-light) !important;
440
  transform: translateY(0);
441
  }
 
442
  /* 🟢 분석 버튼 */
443
  .analyze-btn button, .analyze-btn {
444
  background: linear-gradient(145deg, #52c992, #3ea87a) !important;
 
447
  6px 6px 12px var(--neu-shadow-dark),
448
  -6px -6px 12px var(--neu-shadow-light) !important;
449
  }
 
450
  .analyze-btn:hover button, .analyze-btn:hover {
451
  background: linear-gradient(145deg, #3ea87a, #359968) !important;
452
  }
 
453
  /* 🟣 매칭 버튼 */
454
  .match-btn button, .match-btn {
455
  background: linear-gradient(145deg, #9f7aea, #805ad5) !important;
 
458
  6px 6px 12px var(--neu-shadow-dark),
459
  -6px -6px 12px var(--neu-shadow-light) !important;
460
  }
 
461
  /* 🟠 동기화 버튼 */
462
  .sync-btn button, .sync-btn {
463
  background: linear-gradient(145deg, #f6ad55, #ed8936) !important;
 
466
  4px 4px 8px var(--neu-shadow-dark),
467
  -4px -4px 8px var(--neu-shadow-light) !important;
468
  }
 
469
  /* ═══════════════════════════════════════════════════════════
470
  📝 입력 필드 (Inset 효과)
471
  ═══════════════════════════════════════════════════════════ */
 
481
  inset 4px 4px 8px var(--neu-shadow-dark),
482
  inset -4px -4px 8px var(--neu-shadow-light) !important;
483
  }
 
484
  .gr-textbox textarea:focus, .gr-textbox input:focus,
485
  input[type="text"]:focus, textarea:focus {
486
  outline: none !important;
 
488
  inset 6px 6px 12px var(--neu-shadow-dark),
489
  inset -6px -6px 12px var(--neu-shadow-light) !important;
490
  }
 
491
  /* ═══════════════════════════════════════════════════════════
492
  📊 데이터프레임 테이블
493
  ═══════════════════════════════════════════════════════════ */
 
499
  8px 8px 16px var(--neu-shadow-dark),
500
  -8px -8px 16px var(--neu-shadow-light) !important;
501
  }
 
502
  .gr-dataframe th, .dataframe th {
503
  background: var(--neu-bg) !important;
504
  color: var(--neu-text-dark) !important;
 
506
  padding: 14px 12px !important;
507
  border-bottom: 2px solid var(--neu-shadow-dark) !important;
508
  }
 
509
  .gr-dataframe td, .dataframe td {
510
  background: var(--neu-bg) !important;
511
  color: var(--neu-text) !important;
512
  padding: 12px !important;
513
  border-bottom: 1px solid rgba(163, 177, 198, 0.3) !important;
514
  }
 
515
  .gr-dataframe tr:hover td, .dataframe tr:hover td {
516
  background: rgba(74, 125, 189, 0.1) !important;
517
  }
 
518
  /* ════════════════════════��══════════════════════════════════
519
  📁 탭 스타일
520
  ═══════════════════════════════════════════════════════════ */
521
  .gr-tabs, .tabs {
522
  background: var(--neu-bg) !important;
523
  }
 
524
  .gr-tab-item, .tab-nav button {
525
  background: var(--neu-bg) !important;
526
  border: none !important;
 
533
  4px -4px 8px var(--neu-shadow-dark),
534
  -4px -4px 8px var(--neu-shadow-light) !important;
535
  }
 
536
  .gr-tab-item.selected, .tab-nav button.selected {
537
  background: var(--neu-bg) !important;
538
  color: var(--neu-primary) !important;
 
540
  inset 3px 3px 6px var(--neu-shadow-dark),
541
  inset -3px -3px 6px var(--neu-shadow-light) !important;
542
  }
 
543
  /* ═══════════════════════════════════════════════════════════
544
  ✅ 체크박스
545
  ═══════════════════════════════════════════════════════════ */
 
554
  inset -3px -3px 6px var(--neu-shadow-light);
555
  cursor: pointer;
556
  }
 
557
  .gr-checkbox input[type="checkbox"]:checked {
558
  background: linear-gradient(145deg, #48bb78, #38a169);
559
  box-shadow:
560
  3px 3px 6px var(--neu-shadow-dark),
561
  -3px -3px 6px var(--neu-shadow-light);
562
  }
 
563
  /* ═══════════════════════════════════════════════════════════
564
  📋 캐시 정보 박스
565
  ═══════════════════════════════════════════════════════════ */
 
575
  inset 4px 4px 8px var(--neu-shadow-dark),
576
  inset -4px -4px 8px var(--neu-shadow-light) !important;
577
  }
 
578
  /* ═══════════════════════════════════════════════════════════
579
  📄 카드/패널 스타일
580
  ═══════════════════════════════════════════════════════════ */
 
587
  8px 8px 16px var(--neu-shadow-dark),
588
  -8px -8px 16px var(--neu-shadow-light) !important;
589
  }
 
590
  /* ═══════════════════════════════════════════════════════════
591
  📊 라벨 스타일
592
  ═══════════════════════════════════════════════════════════ */
 
595
  font-weight: 600 !important;
596
  text-shadow: 1px 1px 2px var(--neu-shadow-light);
597
  }
 
598
  /* ═══════════════════════════════════════════════════════════
599
  🔗 링크 스타일
600
  ═══════════════════════════════════════════════════════════ */
 
604
  font-weight: 600;
605
  transition: all 0.2s ease;
606
  }
 
607
  a:hover {
608
  color: #3a6aa8 !important;
609
  text-shadow: 0 0 8px rgba(74, 125, 189, 0.3);
610
  }
 
611
  /* ═══════════════════════════════════════════════════════════
612
  📱 스크롤바 스타일
613
  ═══════════════════════════════════════════════════════════ */
 
615
  width: 10px;
616
  height: 10px;
617
  }
 
618
  ::-webkit-scrollbar-track {
619
  background: var(--neu-bg);
620
  border-radius: 10px;
621
  box-shadow: inset 2px 2px 4px var(--neu-shadow-dark);
622
  }
 
623
  ::-webkit-scrollbar-thumb {
624
  background: linear-gradient(145deg, #d1d9e6, #b8c0cc);
625
  border-radius: 10px;
 
627
  2px 2px 4px var(--neu-shadow-dark),
628
  -2px -2px 4px var(--neu-shadow-light);
629
  }
 
630
  /* ═══════════════════════════════════════════════════════════
631
  🎨 마크다운 출력 영역
632
  ═══════════════════════════════════════════════════════════ */
 
639
  inset 4px 4px 8px var(--neu-shadow-dark),
640
  inset -4px -4px 8px var(--neu-shadow-light) !important;
641
  }
 
642
  .gr-markdown h1, .gr-markdown h2, .gr-markdown h3 {
643
  color: var(--neu-text-dark) !important;
644
  text-shadow: 2px 2px 4px var(--neu-shadow-light);
645
  }
 
646
  /* ═══════════════════════════════════════════════════════════
647
  ✨ 애니메이션
648
  ═══════════════════════════════════════════════════════════ */
 
658
  -12px -12px 24px var(--neu-shadow-light);
659
  }
660
  }
 
661
  .loading {
662
  animation: pulse-neu 1.5s ease-in-out infinite;
663
  }
664
  """
 
 
 
 
 
665
  def create_interface():
666
  with gr.Blocks(title="기업마당 AI 분석기", css=CUSTOM_CSS) as demo:
667
  gr.HTML("""
 
681
  </div>
682
  </div>
683
  """)
 
 
684
  selected_url = gr.State("")
685
  selected_name = gr.State("")
686
  selected_print_file = gr.State(None) # ⭐ 본문출력파일 (dict)
687
  selected_description = gr.State("")
688
  current_df = gr.State(value=pd.DataFrame())
689
  company_profile = gr.State(value={})
 
690
  with gr.Tabs():
 
691
  with gr.Tab("🔍 공고 검색"):
 
692
  with gr.Row():
693
  cache_status = gr.Markdown(value=get_cache_info(), elem_classes=["cache-info"])
694
  sync_btn = gr.Button("🔄 수동 동기화", size="sm", elem_classes=["sync-btn"])
 
695
  with gr.Row():
696
  keyword_input = gr.Textbox(label="🔍 검색어", placeholder="예: AI, 스타트업, R&D", scale=3)
697
  category_dropdown = gr.Dropdown(label="📂 지원분야", choices=list(CATEGORY_CODES.keys()), value="전체", scale=1)
 
704
  page_input = gr.Number(label="📄 페이지", value=1, minimum=1, scale=1)
705
  rows_dropdown = gr.Dropdown(label="📊 표시개수", choices=[10, 15, 20, 30, 50], value=20, scale=1)
706
  search_btn = gr.Button("🔎 검색", variant="primary", scale=2)
 
707
  status_output = gr.Textbox(label="📊 조회 결과", interactive=False)
708
  results_output = gr.Dataframe(label="📋 공고 목록 (행 클릭으로 선택)", wrap=True, interactive=False, datatype="markdown")
709
  with gr.Row():
710
  prev_btn = gr.Button("◀️ 이전", size="sm")
711
  next_btn = gr.Button("다음 ▶️", size="sm")
 
 
712
  with gr.Tab("🤖 AI 분석"):
713
  with gr.Row():
714
  with gr.Column(scale=1):
 
716
  analyze_btn = gr.Button("🚀 AI 분석 시작", variant="primary", size="lg", elem_classes=["analyze-btn"])
717
  with gr.Column(scale=2):
718
  analysis_output = gr.Markdown(value="### 📊 분석 결과\n\n*공고를 선택하고 분석 버튼을 클릭하세요*", height=500)
 
 
719
  with gr.Tab("🎯 맞춤 과제 추출"):
720
  gr.HTML("""
721
  <div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; border-radius: 12px; margin-bottom: 20px;">
 
723
  <p style="margin: 0; opacity: 0.9;">기업 정보를 입력하고 문서를 업로드하면 AI가 신청 가능한 과제를 자동으로 매칭해드립니다.</p>
724
  </div>
725
  """)
 
726
  with gr.Tabs():
 
727
  with gr.Tab("1️⃣ 기업 기본정보"):
728
  with gr.Row():
729
  with gr.Column():
 
735
  company_type = gr.Dropdown(label="기업형태", choices=COMPANY_TYPE_OPTIONS, value="법인사업자")
736
  corp_type = gr.Dropdown(label="법인 종류", choices=CORP_TYPE_OPTIONS, value="주식회사")
737
  company_size = gr.Dropdown(label="기업규모", choices=COMPANY_SIZE_OPTIONS, value="소기업")
 
738
  with gr.Column():
739
  gr.HTML('<div class="section-header">🏆 인증 현황</div>')
740
  venture_cert = gr.Checkbox(label="벤처기업 인증")
 
744
  small_biz_cert = gr.Checkbox(label="소상공인확인서 보유")
745
  social_venture = gr.Checkbox(label="소셜벤처")
746
  startup_flag = gr.Checkbox(label="스타트업")
 
747
  with gr.Column():
748
  gr.HTML('<div class="section-header">📍 소재지 정보</div>')
749
  hq_sido = gr.Dropdown(label="본사 소재지 (시/도)", choices=SIDO_LIST, value="서울특별시")
 
752
  industrial_complex = gr.Checkbox(label="산업단지 입주")
753
  free_zone = gr.Checkbox(label="규제자유특구 소재")
754
  non_capital = gr.Checkbox(label="비수도권 (지방기업)")
 
755
  gr.HTML('<div class="section-header">📊 업종 정보</div>')
756
  industry_major = gr.Dropdown(label="주업종 (대분류)", choices=INDUSTRY_MAJOR_OPTIONS, value="C. 제조업")
757
  is_manufacturing = gr.Checkbox(label="제조업 여부")
758
  is_knowledge_service = gr.Checkbox(label="지식서비스업 여부")
 
 
759
  with gr.Tab("2️⃣ 대표자/인력 정보"):
760
  with gr.Row():
761
  with gr.Column():
 
767
  senior_ceo = gr.Checkbox(label="시니어창업자 (만40세 이상)")
768
  women_company = gr.Checkbox(label="여성기업확인서 보유")
769
  disabled_company = gr.Checkbox(label="장애인기업확인서 보유")
 
770
  with gr.Column():
771
  gr.HTML('<div class="section-header">👥 고용 현황</div>')
772
  insurance_employees = gr.Number(label="4대보험 가입자 수", value=0, minimum=0)
 
774
  youth_employees = gr.Number(label="청년고용 인원", value=0, minimum=0)
775
  female_ratio = gr.Slider(label="여성고용 비율 (%)", minimum=0, maximum=100, value=0)
776
  new_hire_plan = gr.Number(label="신규채용 계획 (명)", value=0, minimum=0)
 
777
  with gr.Column():
778
  gr.HTML('<div class="section-header">🔬 연구인력/역량</div>')
779
  rd_personnel = gr.Number(label="연구인력 수", value=0, minimum=0)
 
781
  research_center = gr.Checkbox(label="기업부설연구소 등록")
782
  rd_dept = gr.Checkbox(label="연구개발전담부서 등록")
783
  patent_count = gr.Number(label="보유 특허 수", value=0, minimum=0)
 
 
784
  with gr.Tab("3️⃣ 재무 정보"):
785
  with gr.Row():
786
  with gr.Column():
 
790
  operating_profit = gr.Number(label="영업이익 (백만원)", value=0)
791
  net_income = gr.Number(label="당기순이익 (백만원)", value=0)
792
  export_amount = gr.Number(label="수출액 (천달러)", value=0, minimum=0)
 
793
  with gr.Column():
794
  gr.HTML('<div class="section-header">📊 재무건전성</div>')
795
  capital = gr.Number(label="자본금 (백만원)", value=0, minimum=0)
 
798
  credit_grade = gr.Dropdown(label="신용등급", choices=CREDIT_GRADE_OPTIONS, value="미평가")
799
  tcb_grade = gr.Dropdown(label="TCB 등급", choices=TCB_GRADE_OPTIONS, value="미평가")
800
  capital_impairment = gr.Checkbox(label="자본잠식 여부")
 
801
  with gr.Column():
802
  gr.HTML('<div class="section-header">🔬 R&D 투자</div>')
803
  rd_investment = gr.Number(label="연간 R&D 투자액 (백만원)", value=0, minimum=0)
804
  gov_project_exp = gr.Checkbox(label="정부과제 수행 경험")
805
  gov_support_3yr = gr.Number(label="최근 3년 정부지원금 (백만원)", value=0, minimum=0)
 
 
806
  with gr.Tab("4️⃣ 기술분야/제한사항"):
807
  with gr.Row():
808
  with gr.Column():
 
812
  green_tech = gr.Checkbox(label="녹색기술 분야")
813
  digital_transform = gr.Checkbox(label="디지털전환 분야")
814
  defense_industry = gr.Checkbox(label="국방/방산 분야")
 
815
  with gr.Column():
816
  gr.HTML('<div class="section-header">📜 인증/ISO</div>')
817
  iso_certs = gr.CheckboxGroup(label="ISO 인증", choices=ISO_CERT_OPTIONS)
818
  gmp_cert = gr.Checkbox(label="GMP 인증")
 
819
  with gr.Column():
820
  gr.HTML('<div class="section-header">⚠️ 결격사유 확인</div>')
821
  tax_delinquent = gr.Checkbox(label="국세 체납")
 
823
  gov_project_fail = gr.Checkbox(label="정부과제 불성실")
824
  bankruptcy = gr.Checkbox(label="휴/폐업 이력")
825
  financial_default = gr.Checkbox(label="금융기관 연체")
 
 
826
  with gr.Tab("5️⃣ 문서 업로드 & 매칭"):
827
  gr.HTML("""
828
  <div style="background: #EBF5FF; border: 1px solid #3B82F6; border-radius: 8px; padding: 16px; margin-bottom: 16px;">
 
830
  <p style="margin: 0; color: #1E3A8A;">사업자등록증, 등기부등본, 재무제표, 중소기업확인서 등을 업로드하면 AI가 자동으로 정보를 추출합니다.</p>
831
  </div>
832
  """)
 
833
  with gr.Row():
834
  with gr.Column(scale=1):
835
  file_upload = gr.File(
 
838
  file_types=[".hwp", ".hwpx", ".pdf", ".txt", ".xlsx", ".xls"]
839
  )
840
  analyze_docs_btn = gr.Button("📄 문서 분석", variant="secondary", size="lg")
 
841
  with gr.Column(scale=2):
842
  doc_analysis_output = gr.Markdown(value="### 📄 문서 분석 결과\n\n*문서를 업로드하고 분석 버튼을 클릭하세요*", height=400)
 
843
  gr.HTML('<hr style="margin: 24px 0;">')
 
844
  with gr.Row():
845
  save_profile_btn = gr.Button("💾 프로필 저장", variant="secondary", size="lg")
846
  match_btn = gr.Button("🎯 맞춤 과제 매칭 시작", variant="primary", size="lg", elem_classes=["match-btn"])
 
847
  profile_status = gr.Textbox(label="프로필 저장 상태", interactive=False)
848
  match_output = gr.Markdown(value="### 🎯 매칭 결과\n\n*프로필을 저장하고 매칭 버튼을 클릭하세요*", height=500)
 
 
 
 
849
  def search_fn(keyword, category, region, org_type, sort_by, status_filter, page, rows):
850
  df, status = fetch_announcements(keyword or "", category or "전체", region or "전체(지역)",
851
  org_type or "전체", sort_by or "등록일순",
 
853
  display_cols = ["번호", "지원분야", "지원사업명", "신청기간", "소관부처", "등록일", "지원대상"]
854
  display_df = df[[c for c in display_cols if c in df.columns]] if not df.empty else df
855
  return display_df, status, df
 
856
  def prev_fn(page, keyword, category, region, org_type, sort_by, status_filter, rows):
857
  new_page = max(1, int(page) - 1) if page else 1
858
  df, status = fetch_announcements(keyword or "", category or "전체", region or "전체(지역)",
 
861
  display_cols = ["번호", "지원분야", "지원사업명", "신청기간", "소관부처", "등록일", "지원대상"]
862
  display_df = df[[c for c in display_cols if c in df.columns]] if not df.empty else df
863
  return display_df, status, df, new_page
 
864
  def next_fn(page, keyword, category, region, org_type, sort_by, status_filter, rows):
865
  new_page = int(page) + 1 if page else 2
866
  df, status = fetch_announcements(keyword or "", category or "전체", region or "전체(지역)",
 
869
  display_cols = ["번호", "지원분야", "지원사업명", "신청기간", "소관부처", "등록일", "지원대상"]
870
  display_df = df[[c for c in display_cols if c in df.columns]] if not df.empty else df
871
  return display_df, status, df, new_page
 
872
  def on_row_select(evt: gr.SelectData, df):
873
  if evt.index[0] < len(df):
874
  row = df.iloc[evt.index[0]]
 
877
  attachments = row.get("첨부파일", [])
878
  print_file = row.get("본문출력파일", None) # ⭐ AI 분석용 핵심 파일
879
  description = row.get("사업개요", "")
 
 
880
  info_parts = [f"📌 {name}", f"🔗 {url}"]
 
 
881
  if print_file:
882
  info_parts.append(f"\n\n📄 **본문출력파일 (AI 분석 대상)**:")
883
  info_parts.append(f" - {print_file.get('filename', '파일')}")
 
 
884
  if attachments and len(attachments) > 0:
885
  info_parts.append(f"\n\n📎 기타 첨부파일 {len(attachments)}개:")
886
  for att in attachments:
887
  info_parts.append(f" - {att.get('filename', '파일')}")
 
888
  info = "\n".join(info_parts)
889
  return url, name, print_file, description, info
890
  return "", "", None, "", ""
 
891
  def save_profile_fn(biz_num, corp_num, comp_name, est_date, comp_type, corp_tp, comp_size,
892
  venture, innobiz, mainbiz, sme, small_biz, social, startup,
893
  sido, sigungu, innov_city, ind_complex, free_z, non_cap,
 
901
  core_ind, strat_tech, green, digital, defense,
902
  iso, gmp,
903
  tax_del, local_tax, gov_fail, bankrupt, fin_def):
 
904
  profile = {
905
  "사업자정보": {
906
  "사업자등록번호": biz_num, "법인등록번호": corp_num, "상호": comp_name,
 
944
  }
945
  }
946
  return profile, "✅ 프로필이 저장되었습니다."
 
947
  def sync_and_update():
948
  result = do_manual_sync()
949
  info = get_cache_info()
950
  return info, result
 
 
951
  search_btn.click(fn=search_fn, inputs=[keyword_input, category_dropdown, region_dropdown, org_type_dropdown,
952
  sort_dropdown, status_dropdown, page_input, rows_dropdown],
953
  outputs=[results_output, status_output, current_df])
 
954
  keyword_input.submit(fn=search_fn, inputs=[keyword_input, category_dropdown, region_dropdown, org_type_dropdown,
955
  sort_dropdown, status_dropdown, page_input, rows_dropdown],
956
  outputs=[results_output, status_output, current_df])
 
 
957
  sync_btn.click(fn=sync_and_update, outputs=[cache_status, status_output])
 
 
958
  prev_btn.click(fn=prev_fn, inputs=[page_input, keyword_input, category_dropdown, region_dropdown,
959
  org_type_dropdown, sort_dropdown, status_dropdown, rows_dropdown],
960
  outputs=[results_output, status_output, current_df, page_input])
 
961
  next_btn.click(fn=next_fn, inputs=[page_input, keyword_input, category_dropdown, region_dropdown,
962
  org_type_dropdown, sort_dropdown, status_dropdown, rows_dropdown],
963
  outputs=[results_output, status_output, current_df, page_input])
 
964
  results_output.select(fn=on_row_select, inputs=[current_df],
965
  outputs=[selected_url, selected_name, selected_print_file, selected_description, selected_info])
 
966
  analyze_btn.click(fn=analyze_announcement, inputs=[selected_url, selected_name, selected_print_file, selected_description],
967
  outputs=[analysis_output])
 
968
  analyze_docs_btn.click(fn=analyze_uploaded_documents, inputs=[file_upload], outputs=[doc_analysis_output])
 
969
  save_profile_btn.click(
970
  fn=save_profile_fn,
971
  inputs=[biz_number, corp_number, company_name, establish_date, company_type, corp_type, company_size,
 
983
  tax_delinquent, local_tax_delinquent, gov_project_fail, bankruptcy, financial_default],
984
  outputs=[company_profile, profile_status]
985
  )
 
986
  match_btn.click(fn=match_announcements_with_profile, inputs=[company_profile, current_df], outputs=[match_output])
 
987
  return demo
 
 
 
 
 
988
  if __name__ == "__main__":
 
989
  if CACHE_AVAILABLE:
990
  print("🚀 캐시 시스템 초기화 중...")
991
  status = initialize_cache_system()
992
  print(f"✅ 캐시 상태: {status}")
 
993
  demo = create_interface()
994
  demo.launch(server_name="0.0.0.0", server_port=7860, ssr_mode=False)