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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +333 -50
app.py CHANGED
@@ -1,3 +1,8 @@
 
 
 
 
 
1
  import gradio as gr
2
  import pandas as pd
3
  import json
@@ -6,6 +11,7 @@ import tempfile
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,22 +19,33 @@ from utils import (
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,23 +54,29 @@ def fetch_announcements(keyword="", category="전체", region="전체(지역)",
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,6 +85,8 @@ def fetch_announcements(keyword="", category="전체", region="전체(지역)",
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,10 +95,12 @@ def fetch_announcements(keyword="", category="전체", region="전체(지역)",
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,10 +116,13 @@ def fetch_announcements(keyword="", category="전체", region="전���(지역)",
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,47 +131,51 @@ def fetch_announcements(keyword="", category="전체", region="전체(지역)",
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:
118
- if link.startswith('/'):
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}"
139
- return df_page, 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,18 +185,24 @@ def analyze_announcement(detail_url, project_name, print_file=None, api_descript
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,17 +218,23 @@ def analyze_announcement(detail_url, project_name, print_file=None, api_descript
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,6 +242,8 @@ def analyze_announcement(detail_url, project_name, print_file=None, api_descript
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,13 +256,16 @@ def analyze_announcement(detail_url, project_name, print_file=None, api_descript
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,27 +275,38 @@ def analyze_announcement(detail_url, project_name, print_file=None, api_descript
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,10 +318,13 @@ def analyze_uploaded_documents(files, progress=gr.Progress()):
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,38 +333,48 @@ def analyze_uploaded_documents(files, progress=gr.Progress()):
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,19 +382,28 @@ def match_announcements_with_profile(profile_data, announcements_df, progress=gr
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,13 +412,21 @@ def get_cache_info():
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,19 +441,63 @@ CUSTOM_CSS = """
349
  --neu-success: #48bb78;
350
  --neu-warning: #ed8936;
351
  }
 
352
  /* ═══════════════════════════════════════════════════════════
353
  📦 기본 컨테이너
354
  ═══════════════════════════════════════════════════════════ */
355
  .gradio-container {
356
  font-family: 'Noto Sans KR', sans-serif !important;
357
  background: var(--neu-bg) !important;
358
- max-width: 1600px !important;
 
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,13 +511,16 @@ body, .dark {
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,6 +537,7 @@ body, .dark {
398
  4px 4px 8px var(--neu-shadow-dark),
399
  -4px -4px 8px var(--neu-shadow-light);
400
  }
 
401
  /* ═══════════════════════════════════════════════════════════
402
  📋 섹션 헤더
403
  ═══════════════════════════════════════════════════════════ */
@@ -412,6 +552,7 @@ body, .dark {
412
  6px 6px 12px var(--neu-shadow-dark),
413
  -6px -6px 12px var(--neu-shadow-light);
414
  }
 
415
  /* ═══════════════════════════════════════════════════════════
416
  🔘 버튼 스타일
417
  ═══════════════════════════════════════════════════════════ */
@@ -427,18 +568,21 @@ body, .dark {
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,9 +591,11 @@ body, .dark {
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,6 +604,7 @@ body, .dark {
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,6 +613,7 @@ body, .dark {
466
  4px 4px 8px var(--neu-shadow-dark),
467
  -4px -4px 8px var(--neu-shadow-light) !important;
468
  }
 
469
  /* ═══════════════════════════════════════════════════════════
470
  📝 입력 필드 (Inset 효과)
471
  ═══════════════════════════════════════════════════════════ */
@@ -481,6 +629,7 @@ input[type="text"], input[type="number"], textarea,
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,6 +637,7 @@ input[type="text"]:focus, textarea:focus {
488
  inset 6px 6px 12px var(--neu-shadow-dark),
489
  inset -6px -6px 12px var(--neu-shadow-light) !important;
490
  }
 
491
  /* ═══════════════════════════════════════════════════════════
492
  📊 데이터프레임 테이블
493
  ═══════════════════════════════════════════════════════════ */
@@ -499,6 +649,7 @@ input[type="text"]:focus, textarea:focus {
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,21 +657,25 @@ input[type="text"]:focus, textarea:focus {
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,6 +688,7 @@ input[type="text"]:focus, textarea:focus {
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,6 +696,7 @@ input[type="text"]:focus, textarea:focus {
540
  inset 3px 3px 6px var(--neu-shadow-dark),
541
  inset -3px -3px 6px var(--neu-shadow-light) !important;
542
  }
 
543
  /* ═══════════════════════════════════════════════════════════
544
  ✅ 체크박스
545
  ═══════════════════════════════════════════════════════════ */
@@ -554,12 +711,14 @@ input[type="text"]:focus, textarea:focus {
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,6 +734,7 @@ input[type="text"]:focus, textarea:focus {
575
  inset 4px 4px 8px var(--neu-shadow-dark),
576
  inset -4px -4px 8px var(--neu-shadow-light) !important;
577
  }
 
578
  /* ═══════════════════════════════════════════════════════════
579
  📄 카드/패널 스타일
580
  ═══════════════════════════════════════════════════════════ */
@@ -587,6 +747,7 @@ input[type="text"]:focus, textarea:focus {
587
  8px 8px 16px var(--neu-shadow-dark),
588
  -8px -8px 16px var(--neu-shadow-light) !important;
589
  }
 
590
  /* ═══════════════════════════════════════════════════════════
591
  📊 라벨 스타일
592
  ═══════════════════════════════════════════════════════════ */
@@ -595,6 +756,7 @@ label, .gr-label {
595
  font-weight: 600 !important;
596
  text-shadow: 1px 1px 2px var(--neu-shadow-light);
597
  }
 
598
  /* ═════════════════════════════════��═════════════════════════
599
  🔗 링크 스타일
600
  ═══════════════════════════════════════════════════════════ */
@@ -604,10 +766,12 @@ a {
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,11 +779,13 @@ a:hover {
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,6 +793,7 @@ a:hover {
627
  2px 2px 4px var(--neu-shadow-dark),
628
  -2px -2px 4px var(--neu-shadow-light);
629
  }
 
630
  /* ═══════════════════════════════════════════════════════════
631
  🎨 마크다운 출력 영역
632
  ═══════════════════════════════════════════════════════════ */
@@ -639,10 +806,12 @@ a:hover {
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,10 +827,16 @@ a:hover {
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,17 +856,23 @@ def create_interface():
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,26 +885,37 @@ def create_interface():
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):
715
- selected_info = gr.Textbox(label="📌 선택된 공고", placeholder="공고 검색 탭에서 선택", lines=3, interactive=False)
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;">
722
- <h2 style="margin: 0 0 8px 0;">🎯 나만의 맞춤 과제 추출</h2>
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,6 +927,7 @@ def create_interface():
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,6 +937,7 @@ def create_interface():
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,10 +946,13 @@ def create_interface():
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,6 +964,7 @@ def create_interface():
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,6 +972,7 @@ def create_interface():
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,6 +980,8 @@ def create_interface():
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,6 +991,7 @@ def create_interface():
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,11 +1000,14 @@ def create_interface():
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,10 +1017,12 @@ def create_interface():
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,13 +1030,16 @@ def create_interface():
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;">
829
- <h3 style="margin: 0 0 8px 0; color: #1E40AF;">📁 문서 업로드</h3>
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,37 +1048,42 @@ def create_interface():
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 "등록일순",
852
  status_filter or "진행중", int(page) if page else 1, int(rows) if rows else 20)
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 "전체(지역)",
859
  org_type or "전체", sort_by or "등록일순",
860
  status_filter or "진행중", new_page, int(rows) if rows else 20)
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 "전체(지역)",
867
  org_type or "전체", sort_by or "등록일순",
868
  status_filter or "진행중", new_page, int(rows) if rows else 20)
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,17 +1092,49 @@ def create_interface():
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,6 +1148,7 @@ def create_interface():
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,28 +1192,54 @@ def create_interface():
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,12 +1257,21 @@ def create_interface():
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)
 
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
  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
  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
  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
  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
  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
  "_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
  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
+ # 원본 데이터 보존 (AI 분석용)
150
+ df_full = df_page.copy()
151
+
152
+ # 표시용 DataFrame 생성
 
 
 
 
 
 
 
 
 
 
153
  internal_cols = [c for c in df_page.columns if c.startswith("_")]
154
+ df_display = df_page.drop(columns=internal_cols)
155
+
156
+ # 표시용에서 불필요한 컬럼 숨기기
157
+ hide_cols = ["공고ID", "첨부파일", "본문출력파일", "사업개요", "문의처", "신청URL"]
158
  for col in hide_cols:
159
+ if col in df_display.columns:
160
+ df_display = df_display.drop(columns=[col])
161
+
162
  status = f"✅ {len(df_page)}건 표시 (페이지 {page}) | 필터 결과: {total_filtered}건 | 수집: {total_before_filter}건"
163
  if status_prefix:
164
  status = f"{status_prefix} | {status}"
165
+
166
+ # 표시용 df, 상태, 원본 df 반환
167
+ return df_display, status, df_full
168
  except Exception as e:
169
  import traceback
170
+ return pd.DataFrame(), f"❌ 오류: {str(e)[:80]}", pd.DataFrame()
171
+
172
+
173
+ # ============================================================
174
+ # AI 분석 함수
175
+ # ============================================================
176
  def analyze_announcement(detail_url, project_name, print_file=None, api_description="", progress=gr.Progress()):
177
  """공고 본문출력파일을 다운로드하고 AI로 분석
178
+
179
  Args:
180
  detail_url: 공고 상세 링크
181
  project_name: 공고명
 
185
  if not detail_url:
186
  yield "❌ 분석할 공고를 선택해주세요."
187
  return
188
+
189
  output = f"# 📄 {project_name}\n\n---\n\n"
190
  all_text = f"## 공고명: {project_name}\n\n"
191
+
192
  if api_description:
193
  all_text += f"### 사업개요:\n{api_description}\n\n"
194
  output += f"📋 **사업개요**\n{api_description}\n\n"
195
  yield output
196
+
197
+ # ⭐ 본문출력파일이 없으면 상세 페이지에서 추출 시도
198
  if not print_file or not isinstance(print_file, dict) or not print_file.get("url"):
199
  progress(0.1, desc="상세 페이지에서 본문출력파일 검색 중...")
200
  output += "🔍 **상세 페이지에서 본문출력파일 검색 중...**\n"
201
  yield output
202
+
203
  try:
204
  content, attachments, scraped_print_file = fetch_announcement_detail(detail_url)
205
+
206
  if scraped_print_file:
207
  print_file = scraped_print_file
208
  output += f" ✅ 본문출력파일 발견: `{print_file.get('filename')}`\n\n"
 
218
  except Exception as e:
219
  output += f" ❌ 상세 페이지 조회 실패: {str(e)}\n\n"
220
  yield output
221
+
222
  extracted_text = None
223
+
224
+ # ⭐ 본문출력파일 분석 (핵심!)
225
  if print_file and isinstance(print_file, dict) and print_file.get("url"):
226
  output += f"📄 **본문출력파일 발견**\n"
227
  output += f" - 파일명: `{print_file.get('filename', '알 수 없음')}`\n"
228
  output += f" - 형식: {print_file.get('type', 'unknown').upper()}\n\n"
229
  yield output
230
+
231
  with tempfile.TemporaryDirectory() as tmp_dir:
232
  progress(0.2, desc="본문출력파일 다운로드 중...")
233
  output += f"📥 다운로드 중...\n"
234
  yield output
235
+
236
  file_path, error = download_file(print_file['url'], tmp_dir, print_file.get('filename'))
237
+
238
  if error:
239
  output += f" - ⚠️ 다운로드 실패: {error}\n"
240
  yield output
 
242
  progress(0.5, desc="텍스트 추출 중...")
243
  output += f" - ✅ 다운로드 완료\n"
244
  yield output
245
+
246
+ # 텍스트 추출
247
  text, err = extract_text_from_file(file_path)
248
  if text:
249
  extracted_text = text
 
256
  output += "⚠️ **본문출력파일이 없습니다.**\n"
257
  output += "사업개요만으로 분석을 진행합니다.\n\n"
258
  yield output
259
+
260
  if len(all_text) < 100 and not extracted_text:
261
  output += "\n❌ **분석할 내용이 충분하지 않습니다.**\n"
262
  output += "본문출력파일이 없거나 텍스트 추출에 실패했습니다.\n"
263
  yield output
264
  return
265
+
266
  output += f"\n📊 **분석 준비 완료** (총 {len(all_text):,}자)\n\n---\n\n## 🤖 AI 분석 결과\n\n"
267
  yield output
268
+
269
  progress(0.7, desc="AI 분석 중...")
270
  system_prompt = """당신은 정부 지원사업 공고 분석 전문가입니다.
271
  주어진 공고 내용을 분석하여 다음 항목을 명확하게 정리해주세요:
 
275
  - 신청 기간, 신청 방법, 제출 서류
276
  - 중요 유의사항, 제한 사항
277
  - 이 사업의 핵심 포인트를 3줄로 요약"""
278
+
279
  messages = [
280
  {"role": "system", "content": system_prompt},
281
  {"role": "user", "content": f"다음 지원사업 공고를 분석해주세요:\n\n{all_text[:15000]}"}
282
  ]
283
+
284
  for chunk in call_groq_api_stream(messages):
285
  output += chunk
286
  yield output
287
+
288
  output += "\n\n---\n✅ **분석 완료**"
289
  yield output
290
+
291
+
292
+ # ============================================================
293
+ # 맞춤 과제 매칭 함수
294
+ # ============================================================
295
  def analyze_uploaded_documents(files, progress=gr.Progress()):
296
  """업로드된 문서들을 분석하여 기업 정보 추출"""
297
  if not files:
298
  yield "❌ 분석할 파일을 업로드해주세요."
299
  return
300
+
301
  output = "# 📄 업로드 문서 분석 결과\n\n"
302
  all_extracted_text = []
303
+
304
  for i, file in enumerate(files):
305
  progress((i + 1) / len(files), desc=f"파일 분석 중... ({i+1}/{len(files)})")
306
  filename = os.path.basename(file.name) if hasattr(file, 'name') else f"파일_{i+1}"
307
  output += f"## 📎 {filename}\n\n"
308
  yield output
309
+
310
  try:
311
  text, error = extract_text_from_file(file.name if hasattr(file, 'name') else file)
312
  if text:
 
318
  except Exception as e:
319
  output += f"❌ 오류: {str(e)}\n\n"
320
  yield output
321
+
322
  if all_extracted_text:
323
  output += "---\n\n## 🤖 AI 기업정보 추출\n\n"
324
  yield output
325
+
326
  combined_text = "\n\n".join([f"[{item['filename']}]\n{item['text'][:3000]}" for item in all_extracted_text])
327
+
328
  system_prompt = """당신은 기업 서류 분석 전문가입니다.
329
  주어진 문서들에서 다음 정보를 추출해주세요:
330
  1. 사업자 정보 (사업자등록번호, 법인등록번호, 상호, 대표자, 설립일, 주소, 업종)
 
333
  4. 인증/등록 정보 (부설연구소, 벤처기업 인증 등)
334
  5. 기타 특이사항
335
  JSON 형식으로 정리해주세요."""
336
+
337
  messages = [
338
  {"role": "system", "content": system_prompt},
339
  {"role": "user", "content": f"다음 기업 서류들을 분석해주세요:\n\n{combined_text[:12000]}"}
340
  ]
341
+
342
  for chunk in call_groq_api_stream(messages):
343
  output += chunk
344
  yield output
345
+
346
  output += "\n\n---\n✅ **문서 분석 완료**"
347
  yield output
348
+
349
+
350
  def match_announcements_with_profile(profile_data, announcements_df, progress=gr.Progress()):
351
  """기업 프로필과 공고를 매칭"""
352
  if not profile_data:
353
  yield "❌ 기업 프로필을 먼저 입력해주세요."
354
  return
355
+
356
  if announcements_df is None or (isinstance(announcements_df, pd.DataFrame) and announcements_df.empty):
357
  yield "❌ 매칭할 공고 데이터가 없습니다. 먼저 공고를 검색해주세요."
358
  return
359
+
360
  output = "# 🎯 맞춤 과제 매칭 결과\n\n"
361
  output += "## 📋 입력된 기업 프로필\n\n"
362
  output += f"```json\n{json.dumps(profile_data, ensure_ascii=False, indent=2)[:2000]}\n```\n\n"
363
  output += "---\n\n## 🔍 AI 매칭 분석 중...\n\n"
364
  yield output
365
+
366
  announcements_text = ""
367
  df_to_use = announcements_df if isinstance(announcements_df, pd.DataFrame) else pd.DataFrame()
368
  for idx, row in df_to_use.head(20).iterrows():
369
  announcements_text += f"""
370
+ ### {row.get('지원사업명', '')}
371
  - 지원분야: {row.get('지원분야', '')}
372
  - 소관부처: {row.get('소관부처', '')}
373
  - 신청기간: {row.get('신청기간', '')}
374
  - 지원대상: {row.get('지원대상', '')}
375
  ---
376
  """
377
+
378
  system_prompt = """당신은 정부 지원사업 매칭 전문가입니다.
379
  기업 프로필과 공고 목록을 분석하여 신청 가능한 과제를 추천해주세요.
380
  각 공고에 대해:
 
382
  - ⚠️ 확인필요: 일부 조건 확인 필요
383
  - ❌ 부적합: 자격 미달
384
  추천 순위와 이유를 설명해주세요."""
385
+
386
  messages = [
387
  {"role": "system", "content": system_prompt},
388
  {"role": "user", "content": f"기업 프로필:\n{json.dumps(profile_data, ensure_ascii=False)}\n\n공고 목록:\n{announcements_text[:8000]}"}
389
  ]
390
+
391
  for chunk in call_groq_api_stream(messages):
392
  output += chunk
393
  yield output
394
+
395
  output += "\n\n---\n✅ **매칭 분석 완료**"
396
  yield output
397
+
398
+
399
+ # ============================================================
400
+ # 캐시 관리 함수
401
+ # ============================================================
402
  def get_cache_info():
403
  """캐시 상태 정보 반환"""
404
  if not CACHE_AVAILABLE:
405
  return "⚠️ 캐시 시스템 미사용 (API 직접 호출)"
406
+
407
  status = get_sync_status()
408
  info = f"""📦 **캐시 상태**
409
  - 총 캐시: {status.get('total_count', 0):,}건
 
412
  - 스케줄러: {'✅ 활성' if status.get('scheduler_available') else '❌ 비활성'}
413
  """
414
  return info
415
+
416
+
417
  def do_manual_sync():
418
  """수동 동기화 실행"""
419
  if not CACHE_AVAILABLE:
420
  return "⚠️ 캐시 시스템을 사용할 수 없습니다."
421
  return manual_sync()
422
+
423
+
424
+ # ============================================================
425
+ # CSS 스타일 - Neumorphism Design
426
+ # ============================================================
427
  CUSTOM_CSS = """
428
  @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap');
429
+
430
  /* ═══════════════════════════════════════════════════════════
431
  🔘 NEUMORPHISM 핵심 변수
432
  ═══════════════════════════════════════════════════════════ */
 
441
  --neu-success: #48bb78;
442
  --neu-warning: #ed8936;
443
  }
444
+
445
  /* ═══════════════════════════════════════════════════════════
446
  📦 기본 컨테이너
447
  ═══════════════════════════════════════════════════════════ */
448
  .gradio-container {
449
  font-family: 'Noto Sans KR', sans-serif !important;
450
  background: var(--neu-bg) !important;
451
+ max-width: 100% !important;
452
+ width: 100% !important;
453
  margin: 0 auto !important;
454
  min-height: 100vh;
455
+ padding: 20px !important;
456
  }
457
+
458
  body, .dark {
459
  background: var(--neu-bg) !important;
460
  }
461
+
462
+ /* ═══════════════════════════════════════════════════════════
463
+ 📐 전체 폭 강제 (탭 전환 시 폭 변화 방지)
464
+ ════════════════════════════════════════════════���══════════ */
465
+ .gr-tabs, .tabs, .tabitem, .tab-content {
466
+ width: 100% !important;
467
+ max-width: 100% !important;
468
+ }
469
+
470
+ .gr-tab-item, .tab-nav button {
471
+ flex-shrink: 0 !important;
472
+ }
473
+
474
+ .gr-row, .gr-column, .row, .column {
475
+ width: 100% !important;
476
+ max-width: 100% !important;
477
+ }
478
+
479
+ .gr-block, .block {
480
+ width: 100% !important;
481
+ max-width: 100% !important;
482
+ }
483
+
484
+ /* 탭 패널 내부 컨텐츠 */
485
+ .gr-tabitem > div, .tabitem > div {
486
+ width: 100% !important;
487
+ max-width: 100% !important;
488
+ }
489
+
490
+ /* 컨테이너 내부 요소들 */
491
+ .contain {
492
+ width: 100% !important;
493
+ max-width: 100% !important;
494
+ }
495
+
496
+ #component-0 {
497
+ width: 100% !important;
498
+ max-width: 100% !important;
499
+ }
500
+
501
  /* ═══════════════════════════════════════════════════════════
502
  🎯 헤더 배너 (Neumorphism)
503
  ═══════════════════════════════════════════════════════════ */
 
511
  12px 12px 24px var(--neu-shadow-dark),
512
  -12px -12px 24px var(--neu-shadow-light);
513
  }
514
+
515
  .header-banner h1 {
516
  color: var(--neu-text-dark) !important;
517
  text-shadow: 2px 2px 4px var(--neu-shadow-light), -1px -1px 3px rgba(0,0,0,0.1);
518
  }
519
+
520
  .header-banner p {
521
  color: var(--neu-text) !important;
522
  }
523
+
524
  /* ═══════════════════════════════════════════════════════════
525
  🏷️ 배지 스타일
526
  ═══════════════════════════════════════════════════════════ */
 
537
  4px 4px 8px var(--neu-shadow-dark),
538
  -4px -4px 8px var(--neu-shadow-light);
539
  }
540
+
541
  /* ═══════════════════════════════════════════════════════════
542
  📋 섹션 헤더
543
  ═══════════════════════════════════════════════════════════ */
 
552
  6px 6px 12px var(--neu-shadow-dark),
553
  -6px -6px 12px var(--neu-shadow-light);
554
  }
555
+
556
  /* ═══════════════════════════════════════════════════════════
557
  🔘 버튼 스타일
558
  ═══════════════════════════════════════════════════════════ */
 
568
  -6px -6px 12px var(--neu-shadow-light) !important;
569
  transition: all 0.2s ease !important;
570
  }
571
+
572
  .gr-button:hover, button.primary:hover, button.secondary:hover {
573
  box-shadow:
574
  4px 4px 8px var(--neu-shadow-dark),
575
  -4px -4px 8px var(--neu-shadow-light) !important;
576
  transform: translateY(-1px);
577
  }
578
+
579
  .gr-button:active, button.primary:active, button.secondary:active {
580
  box-shadow:
581
  inset 4px 4px 8px var(--neu-shadow-dark),
582
  inset -4px -4px 8px var(--neu-shadow-light) !important;
583
  transform: translateY(0);
584
  }
585
+
586
  /* 🟢 분석 버튼 */
587
  .analyze-btn button, .analyze-btn {
588
  background: linear-gradient(145deg, #52c992, #3ea87a) !important;
 
591
  6px 6px 12px var(--neu-shadow-dark),
592
  -6px -6px 12px var(--neu-shadow-light) !important;
593
  }
594
+
595
  .analyze-btn:hover button, .analyze-btn:hover {
596
  background: linear-gradient(145deg, #3ea87a, #359968) !important;
597
  }
598
+
599
  /* 🟣 매칭 버튼 */
600
  .match-btn button, .match-btn {
601
  background: linear-gradient(145deg, #9f7aea, #805ad5) !important;
 
604
  6px 6px 12px var(--neu-shadow-dark),
605
  -6px -6px 12px var(--neu-shadow-light) !important;
606
  }
607
+
608
  /* 🟠 동기화 버튼 */
609
  .sync-btn button, .sync-btn {
610
  background: linear-gradient(145deg, #f6ad55, #ed8936) !important;
 
613
  4px 4px 8px var(--neu-shadow-dark),
614
  -4px -4px 8px var(--neu-shadow-light) !important;
615
  }
616
+
617
  /* ═══════════════════════════════════════════════════════════
618
  📝 입력 필드 (Inset 효과)
619
  ═══════════════════════════════════════════════════════════ */
 
629
  inset 4px 4px 8px var(--neu-shadow-dark),
630
  inset -4px -4px 8px var(--neu-shadow-light) !important;
631
  }
632
+
633
  .gr-textbox textarea:focus, .gr-textbox input:focus,
634
  input[type="text"]:focus, textarea:focus {
635
  outline: none !important;
 
637
  inset 6px 6px 12px var(--neu-shadow-dark),
638
  inset -6px -6px 12px var(--neu-shadow-light) !important;
639
  }
640
+
641
  /* ═══════════════════════════════════════════════════════════
642
  📊 데이터프레임 테이블
643
  ═══════════════════════════════════════════════════════════ */
 
649
  8px 8px 16px var(--neu-shadow-dark),
650
  -8px -8px 16px var(--neu-shadow-light) !important;
651
  }
652
+
653
  .gr-dataframe th, .dataframe th {
654
  background: var(--neu-bg) !important;
655
  color: var(--neu-text-dark) !important;
 
657
  padding: 14px 12px !important;
658
  border-bottom: 2px solid var(--neu-shadow-dark) !important;
659
  }
660
+
661
  .gr-dataframe td, .dataframe td {
662
  background: var(--neu-bg) !important;
663
  color: var(--neu-text) !important;
664
  padding: 12px !important;
665
  border-bottom: 1px solid rgba(163, 177, 198, 0.3) !important;
666
  }
667
+
668
  .gr-dataframe tr:hover td, .dataframe tr:hover td {
669
  background: rgba(74, 125, 189, 0.1) !important;
670
  }
671
+
672
  /* ═══════════════════════════════════════════════════════════
673
  📁 탭 스타일
674
  ═══════════════════════════════════════════════════════════ */
675
  .gr-tabs, .tabs {
676
  background: var(--neu-bg) !important;
677
  }
678
+
679
  .gr-tab-item, .tab-nav button {
680
  background: var(--neu-bg) !important;
681
  border: none !important;
 
688
  4px -4px 8px var(--neu-shadow-dark),
689
  -4px -4px 8px var(--neu-shadow-light) !important;
690
  }
691
+
692
  .gr-tab-item.selected, .tab-nav button.selected {
693
  background: var(--neu-bg) !important;
694
  color: var(--neu-primary) !important;
 
696
  inset 3px 3px 6px var(--neu-shadow-dark),
697
  inset -3px -3px 6px var(--neu-shadow-light) !important;
698
  }
699
+
700
  /* ═══════════════════════════════════════════════════════════
701
  ✅ 체크박스
702
  ═══════════════════════════════════════════════════════════ */
 
711
  inset -3px -3px 6px var(--neu-shadow-light);
712
  cursor: pointer;
713
  }
714
+
715
  .gr-checkbox input[type="checkbox"]:checked {
716
  background: linear-gradient(145deg, #48bb78, #38a169);
717
  box-shadow:
718
  3px 3px 6px var(--neu-shadow-dark),
719
  -3px -3px 6px var(--neu-shadow-light);
720
  }
721
+
722
  /* ═══════════════════════════════════════════════════════════
723
  📋 캐시 정보 박스
724
  ═══════════════════════════════════════════════════════════ */
 
734
  inset 4px 4px 8px var(--neu-shadow-dark),
735
  inset -4px -4px 8px var(--neu-shadow-light) !important;
736
  }
737
+
738
  /* ═══════════════════════════════════════════════════════════
739
  📄 카드/패널 스타일
740
  ═══════════════════════════════════════════════════════════ */
 
747
  8px 8px 16px var(--neu-shadow-dark),
748
  -8px -8px 16px var(--neu-shadow-light) !important;
749
  }
750
+
751
  /* ═══════════════════════════════════════════════════════════
752
  📊 라벨 스타일
753
  ═══════════════════════════════════════════════════════════ */
 
756
  font-weight: 600 !important;
757
  text-shadow: 1px 1px 2px var(--neu-shadow-light);
758
  }
759
+
760
  /* ═════════════════════════════════��═════════════════════════
761
  🔗 링크 스타일
762
  ═══════════════════════════════════════════════════════════ */
 
766
  font-weight: 600;
767
  transition: all 0.2s ease;
768
  }
769
+
770
  a:hover {
771
  color: #3a6aa8 !important;
772
  text-shadow: 0 0 8px rgba(74, 125, 189, 0.3);
773
  }
774
+
775
  /* ═══════════════════════════════════════════════════════════
776
  📱 스크롤바 스타일
777
  ═══════════════════════════════════════════════════════════ */
 
779
  width: 10px;
780
  height: 10px;
781
  }
782
+
783
  ::-webkit-scrollbar-track {
784
  background: var(--neu-bg);
785
  border-radius: 10px;
786
  box-shadow: inset 2px 2px 4px var(--neu-shadow-dark);
787
  }
788
+
789
  ::-webkit-scrollbar-thumb {
790
  background: linear-gradient(145deg, #d1d9e6, #b8c0cc);
791
  border-radius: 10px;
 
793
  2px 2px 4px var(--neu-shadow-dark),
794
  -2px -2px 4px var(--neu-shadow-light);
795
  }
796
+
797
  /* ═══════════════════════════════════════════════════════════
798
  🎨 마크다운 출력 영역
799
  ═══════════════════════════════════════════════════════════ */
 
806
  inset 4px 4px 8px var(--neu-shadow-dark),
807
  inset -4px -4px 8px var(--neu-shadow-light) !important;
808
  }
809
+
810
  .gr-markdown h1, .gr-markdown h2, .gr-markdown h3 {
811
  color: var(--neu-text-dark) !important;
812
  text-shadow: 2px 2px 4px var(--neu-shadow-light);
813
  }
814
+
815
  /* ═══════════════════════════════════════════════════════════
816
  ✨ 애니메이션
817
  ═══════════════════════════════════════════════════════════ */
 
827
  -12px -12px 24px var(--neu-shadow-light);
828
  }
829
  }
830
+
831
  .loading {
832
  animation: pulse-neu 1.5s ease-in-out infinite;
833
  }
834
  """
835
+
836
+
837
+ # ============================================================
838
+ # 메인 인터페이스
839
+ # ============================================================
840
  def create_interface():
841
  with gr.Blocks(title="기업마당 AI 분석기", css=CUSTOM_CSS) as demo:
842
  gr.HTML("""
 
856
  </div>
857
  </div>
858
  """)
859
+
860
+ # 상태 변수
861
  selected_url = gr.State("")
862
  selected_name = gr.State("")
863
  selected_print_file = gr.State(None) # ⭐ 본문출력파일 (dict)
864
  selected_description = gr.State("")
865
  current_df = gr.State(value=pd.DataFrame())
866
  company_profile = gr.State(value={})
867
+
868
  with gr.Tabs():
869
+ # 탭 1: 공고 검색
870
  with gr.Tab("🔍 공고 검색"):
871
+ # 캐시 상태 표시
872
  with gr.Row():
873
  cache_status = gr.Markdown(value=get_cache_info(), elem_classes=["cache-info"])
874
  sync_btn = gr.Button("🔄 수동 동기화", size="sm", elem_classes=["sync-btn"])
875
+
876
  with gr.Row():
877
  keyword_input = gr.Textbox(label="🔍 검색어", placeholder="예: AI, 스타트업, R&D", scale=3)
878
  category_dropdown = gr.Dropdown(label="📂 지원분야", choices=list(CATEGORY_CODES.keys()), value="전체", scale=1)
 
885
  page_input = gr.Number(label="📄 페이지", value=1, minimum=1, scale=1)
886
  rows_dropdown = gr.Dropdown(label="📊 표시개수", choices=[10, 15, 20, 30, 50], value=20, scale=1)
887
  search_btn = gr.Button("🔎 검색", variant="primary", scale=2)
888
+
889
  status_output = gr.Textbox(label="📊 조회 결과", interactive=False)
890
+ results_output = gr.Dataframe(label="📋 공고 목록 (행 클릭으로 선택)", wrap=True, interactive=False)
891
  with gr.Row():
892
  prev_btn = gr.Button("◀️ 이전", size="sm")
893
  next_btn = gr.Button("다음 ▶️", size="sm")
894
+ open_link_btn = gr.Button("🔗 선택 공고 열기", size="sm", variant="secondary")
895
+
896
+ # 선택된 공��� 링크를 표시하는 HTML (JavaScript로 새 탭 열기)
897
+ link_output = gr.HTML(value="<div style='padding: 10px; color: #5a6677;'>📌 공고를 선택하면 여기에 링크가 표시됩니다</div>")
898
+
899
+ # 탭 2: AI 분석
900
  with gr.Tab("🤖 AI 분석"):
901
+ with gr.Row(equal_height=False):
902
+ with gr.Column(scale=1, min_width=300):
903
+ selected_info = gr.Textbox(label="📌 선택된 공고", placeholder="공고 검색 탭에서 선택", lines=5, interactive=False)
904
  analyze_btn = gr.Button("🚀 AI 분석 시작", variant="primary", size="lg", elem_classes=["analyze-btn"])
905
+ with gr.Column(scale=3, min_width=600):
906
+ analysis_output = gr.Markdown(value="### 📊 분석 결과\n\n*공고를 선택하고 분석 버튼을 클릭하세요*", height=600)
907
+
908
+ # 탭 3: 맞춤 과제 추출
909
  with gr.Tab("🎯 맞춤 과제 추출"):
910
  gr.HTML("""
911
+ <div style="background: var(--neu-bg, #e0e5ec); color: #3d4856; padding: 24px 28px; border-radius: 20px; margin-bottom: 24px; box-shadow: 8px 8px 16px #a3b1c6, -8px -8px 16px #ffffff;">
912
+ <h2 style="margin: 0 0 8px 0; text-shadow: 2px 2px 4px #ffffff;">🎯 나만의 맞춤 과제 추출</h2>
913
+ <p style="margin: 0; color: #5a6677;">기업 정보를 입력하고 문서를 업로드하면 AI가 신청 가능한 과제를 자동으로 매칭해드립니다.</p>
914
  </div>
915
  """)
916
+
917
  with gr.Tabs():
918
+ # 서브탭 1: 기업 기본정보
919
  with gr.Tab("1️⃣ 기업 기본정보"):
920
  with gr.Row():
921
  with gr.Column():
 
927
  company_type = gr.Dropdown(label="기업형태", choices=COMPANY_TYPE_OPTIONS, value="법인사업자")
928
  corp_type = gr.Dropdown(label="법인 종류", choices=CORP_TYPE_OPTIONS, value="주식회사")
929
  company_size = gr.Dropdown(label="기업규모", choices=COMPANY_SIZE_OPTIONS, value="소기업")
930
+
931
  with gr.Column():
932
  gr.HTML('<div class="section-header">🏆 인증 현황</div>')
933
  venture_cert = gr.Checkbox(label="벤처기업 인증")
 
937
  small_biz_cert = gr.Checkbox(label="소상공인확인서 보유")
938
  social_venture = gr.Checkbox(label="소셜벤처")
939
  startup_flag = gr.Checkbox(label="스타트업")
940
+
941
  with gr.Column():
942
  gr.HTML('<div class="section-header">📍 소재지 정보</div>')
943
  hq_sido = gr.Dropdown(label="본사 소재지 (시/도)", choices=SIDO_LIST, value="서울특별시")
 
946
  industrial_complex = gr.Checkbox(label="산업단지 입주")
947
  free_zone = gr.Checkbox(label="규제자유특구 소재")
948
  non_capital = gr.Checkbox(label="비수도권 (지방기업)")
949
+
950
  gr.HTML('<div class="section-header">📊 업종 정보</div>')
951
  industry_major = gr.Dropdown(label="주업종 (대분류)", choices=INDUSTRY_MAJOR_OPTIONS, value="C. 제조업")
952
  is_manufacturing = gr.Checkbox(label="제조업 여부")
953
  is_knowledge_service = gr.Checkbox(label="지식서비스업 여부")
954
+
955
+ # 서브탭 2: 대표자/인력 정보
956
  with gr.Tab("2️⃣ 대표자/인력 정보"):
957
  with gr.Row():
958
  with gr.Column():
 
964
  senior_ceo = gr.Checkbox(label="시니어창업자 (만40세 이상)")
965
  women_company = gr.Checkbox(label="여성기업확인서 보유")
966
  disabled_company = gr.Checkbox(label="장애인기업확인서 보유")
967
+
968
  with gr.Column():
969
  gr.HTML('<div class="section-header">👥 고용 현황</div>')
970
  insurance_employees = gr.Number(label="4대보험 가입자 수", value=0, minimum=0)
 
972
  youth_employees = gr.Number(label="청년고용 인원", value=0, minimum=0)
973
  female_ratio = gr.Slider(label="여성고용 비율 (%)", minimum=0, maximum=100, value=0)
974
  new_hire_plan = gr.Number(label="신규채용 계획 (명)", value=0, minimum=0)
975
+
976
  with gr.Column():
977
  gr.HTML('<div class="section-header">🔬 연구인력/역량</div>')
978
  rd_personnel = gr.Number(label="연구인력 수", value=0, minimum=0)
 
980
  research_center = gr.Checkbox(label="기업부설연구소 등록")
981
  rd_dept = gr.Checkbox(label="연구개발전담부서 등록")
982
  patent_count = gr.Number(label="보유 특허 수", value=0, minimum=0)
983
+
984
+ # 서브탭 3: 재무 정보
985
  with gr.Tab("3️⃣ 재무 정보"):
986
  with gr.Row():
987
  with gr.Column():
 
991
  operating_profit = gr.Number(label="영업이익 (백만원)", value=0)
992
  net_income = gr.Number(label="당기순이익 (백만원)", value=0)
993
  export_amount = gr.Number(label="수출액 (천달러)", value=0, minimum=0)
994
+
995
  with gr.Column():
996
  gr.HTML('<div class="section-header">📊 재무건전성</div>')
997
  capital = gr.Number(label="자본금 (백만원)", value=0, minimum=0)
 
1000
  credit_grade = gr.Dropdown(label="신용등급", choices=CREDIT_GRADE_OPTIONS, value="미평가")
1001
  tcb_grade = gr.Dropdown(label="TCB 등급", choices=TCB_GRADE_OPTIONS, value="미평가")
1002
  capital_impairment = gr.Checkbox(label="자본잠식 여부")
1003
+
1004
  with gr.Column():
1005
  gr.HTML('<div class="section-header">🔬 R&D 투자</div>')
1006
  rd_investment = gr.Number(label="연간 R&D 투자액 (백만원)", value=0, minimum=0)
1007
  gov_project_exp = gr.Checkbox(label="정부과제 수행 경험")
1008
  gov_support_3yr = gr.Number(label="최근 3년 정부지원금 (백만원)", value=0, minimum=0)
1009
+
1010
+ # 서브탭 4: 기술분야/제한사항
1011
  with gr.Tab("4️⃣ 기술분야/제한사항"):
1012
  with gr.Row():
1013
  with gr.Column():
 
1017
  green_tech = gr.Checkbox(label="녹색기술 분야")
1018
  digital_transform = gr.Checkbox(label="디지털전환 분야")
1019
  defense_industry = gr.Checkbox(label="국방/방산 분야")
1020
+
1021
  with gr.Column():
1022
  gr.HTML('<div class="section-header">📜 인증/ISO</div>')
1023
  iso_certs = gr.CheckboxGroup(label="ISO 인증", choices=ISO_CERT_OPTIONS)
1024
  gmp_cert = gr.Checkbox(label="GMP 인증")
1025
+
1026
  with gr.Column():
1027
  gr.HTML('<div class="section-header">⚠️ 결격사유 확인</div>')
1028
  tax_delinquent = gr.Checkbox(label="국세 체납")
 
1030
  gov_project_fail = gr.Checkbox(label="정부과제 불성실")
1031
  bankruptcy = gr.Checkbox(label="휴/폐업 이력")
1032
  financial_default = gr.Checkbox(label="금융기관 연체")
1033
+
1034
+ # 서브탭 5: 문서 업로드 및 매칭
1035
  with gr.Tab("5️⃣ 문서 ���로드 & 매칭"):
1036
  gr.HTML("""
1037
+ <div style="background: var(--neu-bg, #e0e5ec); border-radius: 15px; padding: 18px 22px; margin-bottom: 16px; box-shadow: inset 4px 4px 8px #a3b1c6, inset -4px -4px 8px #ffffff;">
1038
+ <h3 style="margin: 0 0 8px 0; color: #3d4856;">📁 문서 업로드</h3>
1039
+ <p style="margin: 0; color: #5a6677;">사업자등록증, 등기부등본, 재무제표, 중소기업확인서 등을 업로드하면 AI가 자동으로 정보를 추출합니다.</p>
1040
  </div>
1041
  """)
1042
+
1043
  with gr.Row():
1044
  with gr.Column(scale=1):
1045
  file_upload = gr.File(
 
1048
  file_types=[".hwp", ".hwpx", ".pdf", ".txt", ".xlsx", ".xls"]
1049
  )
1050
  analyze_docs_btn = gr.Button("📄 문서 분석", variant="secondary", size="lg")
1051
+
1052
  with gr.Column(scale=2):
1053
  doc_analysis_output = gr.Markdown(value="### 📄 문서 분석 결과\n\n*문서를 업로드하고 분석 버튼을 클릭하세요*", height=400)
1054
+
1055
  gr.HTML('<hr style="margin: 24px 0;">')
1056
+
1057
  with gr.Row():
1058
  save_profile_btn = gr.Button("💾 프로필 저장", variant="secondary", size="lg")
1059
  match_btn = gr.Button("🎯 맞춤 과제 매칭 시작", variant="primary", size="lg", elem_classes=["match-btn"])
1060
+
1061
  profile_status = gr.Textbox(label="프로필 저장 상태", interactive=False)
1062
  match_output = gr.Markdown(value="### 🎯 매칭 결과\n\n*프로필을 저장하고 매칭 버튼을 클릭하세요*", height=500)
1063
+
1064
+ # ============================================================
1065
+ # 이벤트 핸들러
1066
+ # ============================================================
1067
  def search_fn(keyword, category, region, org_type, sort_by, status_filter, page, rows):
1068
+ display_df, status, full_df = fetch_announcements(keyword or "", category or "전체", region or "전체(지역)",
1069
  org_type or "전체", sort_by or "등록일순",
1070
  status_filter or "진행중", int(page) if page else 1, int(rows) if rows else 20)
1071
+ return display_df, status, full_df
1072
+
 
1073
  def prev_fn(page, keyword, category, region, org_type, sort_by, status_filter, rows):
1074
  new_page = max(1, int(page) - 1) if page else 1
1075
+ display_df, status, full_df = fetch_announcements(keyword or "", category or "전체", region or "전체(지역)",
1076
  org_type or "전체", sort_by or "등록일순",
1077
  status_filter or "진행중", new_page, int(rows) if rows else 20)
1078
+ return display_df, status, full_df, new_page
1079
+
 
1080
  def next_fn(page, keyword, category, region, org_type, sort_by, status_filter, rows):
1081
  new_page = int(page) + 1 if page else 2
1082
+ display_df, status, full_df = fetch_announcements(keyword or "", category or "전체", region or "전체(지역)",
1083
  org_type or "전체", sort_by or "등록일순",
1084
  status_filter or "진행중", new_page, int(rows) if rows else 20)
1085
+ return display_df, status, full_df, new_page
1086
+
 
1087
  def on_row_select(evt: gr.SelectData, df):
1088
  if evt.index[0] < len(df):
1089
  row = df.iloc[evt.index[0]]
 
1092
  attachments = row.get("첨부파일", [])
1093
  print_file = row.get("본문출력파일", None) # ⭐ AI 분석용 핵심 파일
1094
  description = row.get("사업개요", "")
1095
+
1096
+ # URL 정규화
1097
+ full_url = url
1098
+ if url and url.startswith('/'):
1099
+ full_url = f"https://www.bizinfo.go.kr{url}"
1100
+
1101
+ # 정보 표시 (AI 분석 탭용)
1102
+ info_parts = [f"📌 {name}", f"🔗 {full_url}"]
1103
+
1104
+ # 본문출력파일 (AI 분석 대상)
1105
  if print_file:
1106
  info_parts.append(f"\n\n📄 **본문출력파일 (AI 분석 대상)**:")
1107
  info_parts.append(f" - {print_file.get('filename', '파일')}")
1108
+
1109
+ # 일반 첨부파일 (서식, 양식)
1110
  if attachments and len(attachments) > 0:
1111
  info_parts.append(f"\n\n📎 기타 첨부파일 {len(attachments)}개:")
1112
  for att in attachments:
1113
  info_parts.append(f" - {att.get('filename', '파일')}")
1114
+
1115
  info = "\n".join(info_parts)
1116
+
1117
+ # 링크 HTML (첫 번째 탭용)
1118
+ if full_url:
1119
+ link_html = f'''
1120
+ <div style="background: var(--neu-bg, #e0e5ec); padding: 16px 20px; border-radius: 15px;
1121
+ box-shadow: inset 4px 4px 8px #a3b1c6, inset -4px -4px 8px #ffffff;">
1122
+ <div style="font-weight: 700; color: #3d4856; margin-bottom: 8px;">📌 {name}</div>
1123
+ <a href="{full_url}" target="_blank"
1124
+ style="display: inline-block; background: linear-gradient(145deg, #4a7dbd, #3a6aa8);
1125
+ color: white !important; padding: 10px 20px; border-radius: 25px;
1126
+ text-decoration: none; font-weight: 600; margin-top: 8px;
1127
+ box-shadow: 4px 4px 8px #a3b1c6, -4px -4px 8px #ffffff;">
1128
+ 🔗 기업마당에서 상세보기
1129
+ </a>
1130
+ </div>
1131
+ '''
1132
+ else:
1133
+ link_html = "<div style='padding: 10px; color: #5a6677;'>⚠️ 링크 정보가 없습니다</div>"
1134
+
1135
+ return url, name, print_file, description, info, link_html
1136
+ return "", "", None, "", "", "<div style='padding: 10px; color: #5a6677;'>📌 공고를 선택하세요</div>"
1137
+
1138
  def save_profile_fn(biz_num, corp_num, comp_name, est_date, comp_type, corp_tp, comp_size,
1139
  venture, innobiz, mainbiz, sme, small_biz, social, startup,
1140
  sido, sigungu, innov_city, ind_complex, free_z, non_cap,
 
1148
  core_ind, strat_tech, green, digital, defense,
1149
  iso, gmp,
1150
  tax_del, local_tax, gov_fail, bankrupt, fin_def):
1151
+
1152
  profile = {
1153
  "사업자정보": {
1154
  "사업자등록번호": biz_num, "법인등록번호": corp_num, "상호": comp_name,
 
1192
  }
1193
  }
1194
  return profile, "✅ 프로필이 저장되었습니다."
1195
+
1196
  def sync_and_update():
1197
  result = do_manual_sync()
1198
  info = get_cache_info()
1199
  return info, result
1200
+
1201
+ # 이벤트 연결
1202
  search_btn.click(fn=search_fn, inputs=[keyword_input, category_dropdown, region_dropdown, org_type_dropdown,
1203
  sort_dropdown, status_dropdown, page_input, rows_dropdown],
1204
  outputs=[results_output, status_output, current_df])
1205
+
1206
  keyword_input.submit(fn=search_fn, inputs=[keyword_input, category_dropdown, region_dropdown, org_type_dropdown,
1207
  sort_dropdown, status_dropdown, page_input, rows_dropdown],
1208
  outputs=[results_output, status_output, current_df])
1209
+
1210
+ # 수동 동기화 버튼
1211
  sync_btn.click(fn=sync_and_update, outputs=[cache_status, status_output])
1212
+
1213
+ # 페이지네이션 이벤트
1214
  prev_btn.click(fn=prev_fn, inputs=[page_input, keyword_input, category_dropdown, region_dropdown,
1215
  org_type_dropdown, sort_dropdown, status_dropdown, rows_dropdown],
1216
  outputs=[results_output, status_output, current_df, page_input])
1217
+
1218
  next_btn.click(fn=next_fn, inputs=[page_input, keyword_input, category_dropdown, region_dropdown,
1219
  org_type_dropdown, sort_dropdown, status_dropdown, rows_dropdown],
1220
  outputs=[results_output, status_output, current_df, page_input])
1221
+
1222
+ # 행 선택 시 link_output도 업데이트
1223
  results_output.select(fn=on_row_select, inputs=[current_df],
1224
+ outputs=[selected_url, selected_name, selected_print_file, selected_description, selected_info, link_output])
1225
+
1226
+ # 바로가기 버튼 클릭 시 - JavaScript로 새 탭 열기
1227
+ def open_selected_link(url):
1228
+ if url:
1229
+ full_url = url if url.startswith('http') else f"https://www.bizinfo.go.kr{url}"
1230
+ return f'''
1231
+ <script>window.open("{full_url}", "_blank");</script>
1232
+ <div style="padding: 10px; color: #48bb78;">✅ 새 탭에서 열렸습니다</div>
1233
+ '''
1234
+ return "<div style='padding: 10px; color: #ed8936;'>⚠️ 먼저 공고를 선택하세요</div>"
1235
+
1236
+ open_link_btn.click(fn=open_selected_link, inputs=[selected_url], outputs=[link_output])
1237
+
1238
  analyze_btn.click(fn=analyze_announcement, inputs=[selected_url, selected_name, selected_print_file, selected_description],
1239
  outputs=[analysis_output])
1240
+
1241
  analyze_docs_btn.click(fn=analyze_uploaded_documents, inputs=[file_upload], outputs=[doc_analysis_output])
1242
+
1243
  save_profile_btn.click(
1244
  fn=save_profile_fn,
1245
  inputs=[biz_number, corp_number, company_name, establish_date, company_type, corp_type, company_size,
 
1257
  tax_delinquent, local_tax_delinquent, gov_project_fail, bankruptcy, financial_default],
1258
  outputs=[company_profile, profile_status]
1259
  )
1260
+
1261
  match_btn.click(fn=match_announcements_with_profile, inputs=[company_profile, current_df], outputs=[match_output])
1262
+
1263
  return demo
1264
+
1265
+
1266
+ # ============================================================
1267
+ # 앱 시작
1268
+ # ============================================================
1269
  if __name__ == "__main__":
1270
+ # 캐시 시스템 초기화 (백그라운드 스케줄러 시작)
1271
  if CACHE_AVAILABLE:
1272
  print("🚀 캐시 시스템 초기화 중...")
1273
  status = initialize_cache_system()
1274
  print(f"✅ 캐시 상태: {status}")
1275
+
1276
  demo = create_interface()
1277
  demo.launch(server_name="0.0.0.0", server_port=7860, ssr_mode=False)