ginipick commited on
Commit
606e64a
·
verified ·
1 Parent(s): a88e0cc

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +385 -445
app.py CHANGED
@@ -14,7 +14,10 @@ import pathlib
14
  import sqlite3
15
  import pytz
16
 
 
 
17
  # 한국 기업 리스트
 
18
  KOREAN_COMPANIES = [
19
  "NVIDIA",
20
  "ALPHABET",
@@ -34,11 +37,16 @@ KOREAN_COMPANIES = [
34
  "investing"
35
  ]
36
 
 
37
  #########################################################
38
  # 공통 함수
39
  #########################################################
40
 
41
  def convert_to_seoul_time(timestamp_str):
 
 
 
 
42
  try:
43
  dt = datetime.strptime(timestamp_str, '%Y-%m-%d %H:%M:%S')
44
  seoul_tz = pytz.timezone('Asia/Seoul')
@@ -48,16 +56,20 @@ def convert_to_seoul_time(timestamp_str):
48
  print(f"시간 변환 오류: {str(e)}")
49
  return timestamp_str
50
 
 
51
  def analyze_sentiment_batch(articles, client):
52
  """
53
- OpenAI API를 통해 뉴스 기사들의 종합 감성 분석을 수행
 
54
  """
55
  try:
 
56
  combined_text = "\n\n".join([
57
  f"제목: {article.get('title', '')}\n내용: {article.get('snippet', '')}"
58
  for article in articles
59
  ])
60
 
 
61
  prompt = f"""다음 뉴스 모음에 대해 전반적인 감성 분석을 수행하세요:
62
 
63
  뉴스 내용:
@@ -82,15 +94,20 @@ def analyze_sentiment_batch(articles, client):
82
  )
83
 
84
  return response.choices[0].message.content
 
85
  except Exception as e:
86
  return f"감성 분석 실패: {str(e)}"
87
 
88
 
89
  #########################################################
90
- # DB 관련
91
  #########################################################
92
 
93
  def init_db():
 
 
 
 
94
  db_path = pathlib.Path("search_results.db")
95
  conn = sqlite3.connect(db_path)
96
  c = conn.cursor()
@@ -103,7 +120,11 @@ def init_db():
103
  conn.commit()
104
  conn.close()
105
 
 
106
  def save_to_db(keyword, country, results):
 
 
 
107
  conn = sqlite3.connect("search_results.db")
108
  c = conn.cursor()
109
  seoul_tz = pytz.timezone('Asia/Seoul')
@@ -117,10 +138,20 @@ def save_to_db(keyword, country, results):
117
  conn.commit()
118
  conn.close()
119
 
 
120
  def load_from_db(keyword, country):
 
 
 
 
 
121
  conn = sqlite3.connect("search_results.db")
122
  c = conn.cursor()
123
- c.execute("SELECT results, timestamp FROM searches WHERE keyword=? AND country=? ORDER BY timestamp DESC LIMIT 1",
 
 
 
 
124
  (keyword, country))
125
  result = c.fetchone()
126
  conn.close()
@@ -129,16 +160,16 @@ def load_from_db(keyword, country):
129
  return None, None
130
 
131
 
132
- #########################################################
133
- # "id"로 직접 로딩하기 (히스토리에서 사용)
134
- #########################################################
135
  def load_by_id(search_id):
136
  """
137
- DB의 PRIMARY KEY(id)로 특정 검색 기록을 로딩
 
 
138
  """
139
  conn = sqlite3.connect("search_results.db")
140
  c = conn.cursor()
141
- c.execute("SELECT keyword, country, results, timestamp FROM searches WHERE id=?", (search_id,))
 
142
  row = c.fetchone()
143
  conn.close()
144
 
@@ -153,7 +184,15 @@ def load_by_id(search_id):
153
  }
154
  return None
155
 
 
 
 
 
 
156
  def display_results(articles):
 
 
 
157
  output = ""
158
  for idx, article in enumerate(articles, 1):
159
  output += f"### {idx}. {article['title']}\n"
@@ -165,250 +204,59 @@ def display_results(articles):
165
 
166
 
167
  #########################################################
168
- # (1) 검색 => 기사 + 분석 ���시 출력, DB 저장
169
- #########################################################
170
- def search_company(company):
171
- """
172
- 지정된 KOREAN_COMPANIES 기업(또는 키워드)에 대해
173
- 미국 뉴스 검색 -> 감성 분석 -> DB 저장 -> 기사+분석 보고 출력
174
- """
175
- error_message, articles = serphouse_search(company, "United States")
176
- if not error_message and articles:
177
- analysis = analyze_sentiment_batch(articles, client)
178
- store_dict = {
179
- "articles": articles,
180
- "analysis": analysis
181
- }
182
- save_to_db(company, "United States", store_dict)
183
- output = display_results(articles)
184
- output += f"\n\n### 분석 보고\n{analysis}\n"
185
- return output
186
- return f"{company}에 대한 검색 결과가 없습니다."
187
-
188
- #########################################################
189
- # (2) 출력 시 => DB에 저장된 기사 + 분석 함께 출력
190
- #########################################################
191
- def load_company(company):
192
- """
193
- DB에 이미 저장된 (기업명) 에 대한 기사+분석을 함께 출력
194
- """
195
- data, timestamp = load_from_db(company, "United States")
196
- if data:
197
- articles = data.get("articles", [])
198
- analysis = data.get("analysis", "")
199
-
200
- output = f"### {company} 검색 결과\n저장 시간: {timestamp}\n\n"
201
- output += display_results(articles)
202
- output += f"\n\n### 분석 보고\n{analysis}\n"
203
- return output
204
- return f"{company}에 대한 저장된 결과가 없습니다."
205
-
206
-
207
- #########################################################
208
- # (3) EarnBOT 분석 리포트
209
- #########################################################
210
- def show_stats():
211
- """
212
- 기존 "한국 기업 뉴스 분석 리포트" -> "EarnBOT 분석 리포트"
213
- """
214
- conn = sqlite3.connect("search_results.db")
215
- c = conn.cursor()
216
-
217
- output = "## EarnBOT 분석 리포트\n\n"
218
-
219
- data_list = []
220
- for company in KOREAN_COMPANIES:
221
- c.execute("""
222
- SELECT results, timestamp
223
- FROM searches
224
- WHERE keyword = ?
225
- ORDER BY timestamp DESC
226
- LIMIT 1
227
- """, (company,))
228
-
229
- row = c.fetchone()
230
- if row:
231
- results_json, tstamp = row
232
- data_list.append((company, tstamp, results_json))
233
-
234
- conn.close()
235
-
236
- def analyze_data(item):
237
- comp, tstamp, results_json = item
238
- data = json.loads(results_json)
239
- articles = data.get("articles", [])
240
- analysis = data.get("analysis", "")
241
- count_articles = len(articles)
242
- return (comp, tstamp, count_articles, analysis)
243
-
244
- results_list = []
245
- with ThreadPoolExecutor(max_workers=5) as executor:
246
- futures = [executor.submit(analyze_data, dl) for dl in data_list]
247
- for future in as_completed(futures):
248
- results_list.append(future.result())
249
-
250
- for comp, tstamp, count, analysis in results_list:
251
- seoul_time = convert_to_seoul_time(tstamp)
252
- output += f"### {comp}\n"
253
- output += f"- 마지막 업데이트: {seoul_time}\n"
254
- output += f"- 저장된 기사 수: {count}건\n\n"
255
- if analysis:
256
- output += "#### 뉴스 감성 분석\n"
257
- output += f"{analysis}\n\n"
258
- output += "---\n\n"
259
-
260
- return output
261
-
262
- #########################################################
263
- # 전체 검색 (병렬) / 전체 출력 / 전체 리포트
264
  #########################################################
265
- def search_all_companies():
266
- overall_result = "# [전체 검색 결과]\n\n"
267
-
268
- def do_search(comp):
269
- return comp, search_company(comp)
270
-
271
- # 병렬 처리
272
- with ThreadPoolExecutor(max_workers=5) as executor:
273
- futures = [executor.submit(do_search, c) for c in KOREAN_COMPANIES]
274
- for future in as_completed(futures):
275
- comp, res_text = future.result()
276
- overall_result += f"## {comp}\n"
277
- overall_result += res_text + "\n\n"
278
-
279
- return overall_result
280
-
281
- def load_all_companies():
282
- overall_result = "# [전체 출력 결과]\n\n"
283
- for comp in KOREAN_COMPANIES:
284
- overall_result += f"## {comp}\n"
285
- overall_result += load_company(comp)
286
- overall_result += "\n"
287
- return overall_result
288
-
289
- def full_summary_report():
290
- search_result_text = search_all_companies()
291
- load_result_text = load_all_companies()
292
- stats_text = show_stats()
293
-
294
- combined_report = (
295
- "# 전체 분석 보고 요약\n\n"
296
- "아래 순서로 실행되었습니다:\n"
297
- "1. 모든 종목 검색(병렬) + 분석 => 2. 모든 종목 DB 결과 출력 => 3. 전체 감성 분석 통계\n\n"
298
- f"{search_result_text}\n\n"
299
- f"{load_result_text}\n\n"
300
- "## [전체 감성 분석 통계]\n\n"
301
- f"{stats_text}"
302
- )
303
- return combined_report
304
 
 
305
 
306
- #########################################################
307
- # (추가) 사용자 임의 검색 함수 (두 번째 탭)
308
- #########################################################
309
- def search_custom(query, country):
310
  """
311
- 사용자가 입력한 (query, country)에 대해
312
- 1) 검색 + 분석 => DB 저장
313
- 2) DB 로드 => 결과(기사 목록 + 분석) 출력
314
  """
315
- error_message, articles = serphouse_search(query, country)
316
- if error_message:
317
- return f"오류 발생: {error_message}"
318
- if not articles:
319
- return "검색 결과가 없습니다."
320
-
321
- analysis = analyze_sentiment_batch(articles, client)
322
- save_data = {
323
- "articles": articles,
324
- "analysis": analysis
325
- }
326
- save_to_db(query, country, save_data)
327
-
328
- loaded_data, timestamp = load_from_db(query, country)
329
- if not loaded_data:
330
- return "DB에서 로드 실패"
331
-
332
- arts = loaded_data.get("articles", [])
333
- analy = loaded_data.get("analysis", "")
334
-
335
- out = f"## [사용자 임의 검색 결과]\n\n"
336
- out += f"**키워드**: {query}\n\n"
337
- out += f"**국가**: {country}\n\n"
338
- out += f"**저장 시간**: {timestamp}\n\n"
339
-
340
- out += display_results(arts)
341
- out += f"### 뉴스 감성 분석\n{analy}\n"
342
-
343
- return out
344
 
345
 
346
- #########################################################
347
- # (추가) 히스토리 함수
348
- #########################################################
349
- def get_custom_search_history():
350
- """
351
- KOREAN_COMPANIES에 없는 keyword로 검색된 기록만
352
- (id, timestamp, keyword, country) 형태로 반환
353
- 최신순 정렬
354
- """
355
- company_set = set(k.lower() for k in KOREAN_COMPANIES)
356
-
357
- conn = sqlite3.connect("search_results.db")
358
- c = conn.cursor()
359
- c.execute("""SELECT id, keyword, country, timestamp
360
- FROM searches
361
- ORDER BY timestamp DESC""")
362
- rows = c.fetchall()
363
- conn.close()
364
-
365
- history_list = []
366
- for (sid, kw, cty, ts) in rows:
367
- # KOREAN_COMPANIES 에 없는 경우 -> 사용자 임의 검색
368
- if kw.lower() not in company_set:
369
- display_time = convert_to_seoul_time(ts)
370
- label = f"{sid} | {display_time} | {kw} ({cty})"
371
- history_list.append((str(sid), label))
372
-
373
- return history_list
374
-
375
- def view_history_record(record_id):
376
  """
377
- Dropdown 에서 선택된 record_id (문자열)로부터
378
- 해당 검색 결과(기사+분석) Markdown 형태로 반환
379
  """
380
- if not record_id:
381
- return "기록이 없습니다."
382
-
383
- data = load_by_id(int(record_id))
384
- if not data:
385
- return "해당 ID의 기록이 없습니다."
386
-
387
- keyword = data["keyword"]
388
- country = data["country"]
389
- timestamp = data["timestamp"]
390
- stored = data["data"]
391
-
392
- articles = stored.get("articles", [])
393
- analysis = stored.get("analysis", "")
394
-
395
- out = f"### [히스토리 검색 결과]\n\n"
396
- out += f"- ID: {record_id}\n"
397
- out += f"- 키워드: {keyword}\n"
398
- out += f"- 국가: {country}\n"
399
- out += f"- 저장 시간: {timestamp}\n\n"
400
- out += display_results(articles)
401
- out += f"\n\n### 분석 보고\n{analysis}\n"
402
-
403
- return out
 
 
 
 
 
404
 
405
 
406
- #########################################################
407
- # SerpHouse API (함수명 수정) => serphouse_search
408
- #########################################################
409
  def serphouse_search(query, country, page=1, num_result=10):
410
  """
411
- SerpHouse API 호출 + 결과 포매팅
 
412
  """
413
  url = "https://api.serphouse.com/serp/live"
414
 
@@ -416,6 +264,7 @@ def serphouse_search(query, country, page=1, num_result=10):
416
  yesterday = now - timedelta(days=1)
417
  date_range = f"{yesterday.strftime('%Y-%m-%d')},{now.strftime('%Y-%m-%d')}"
418
 
 
419
  translated_query = translate_query(query, country)
420
 
421
  payload = {
@@ -453,16 +302,18 @@ def serphouse_search(query, country, page=1, num_result=10):
453
  session.mount('http://', adapter)
454
  session.mount('https://', adapter)
455
 
 
456
  response = session.post(
457
- url,
458
- json=payload,
459
- headers=headers,
460
  timeout=(30, 30)
461
  )
462
 
463
  response.raise_for_status()
464
  response_data = response.json()
465
 
 
466
  return format_results_from_raw({
467
  "results": response_data,
468
  "translated_query": translated_query
@@ -477,8 +328,14 @@ def serphouse_search(query, country, page=1, num_result=10):
477
 
478
 
479
  def format_results_from_raw(response_data):
 
 
 
 
 
 
480
  if "error" in response_data:
481
- return "Error: " + response_data["error"], []
482
 
483
  try:
484
  results = response_data["results"]
@@ -486,8 +343,9 @@ def format_results_from_raw(response_data):
486
 
487
  news_results = results.get('results', {}).get('results', {}).get('news', [])
488
  if not news_results:
489
- return "검색 결과가 없습니다.", []
490
 
 
491
  korean_domains = [
492
  '.kr', 'korea', 'korean', 'yonhap', 'hankyung', 'chosun',
493
  'donga', 'joins', 'hani', 'koreatimes', 'koreaherald'
@@ -503,6 +361,7 @@ def format_results_from_raw(response_data):
503
  title = result.get("title", "").lower()
504
  channel = result.get("channel", result.get("source", "")).lower()
505
 
 
506
  is_korean_content = (
507
  any(domain in url or domain in channel for domain in korean_domains) or
508
  any(keyword in title for keyword in korean_keywords)
@@ -520,30 +379,21 @@ def format_results_from_raw(response_data):
520
  "translated_query": translated_query
521
  })
522
 
523
- return "", filtered_articles
524
- except Exception as e:
525
- return f"결과 처리 중 오류 발생: {str(e)}", []
526
-
527
 
528
- ###################################
529
- # 환경 변수 CSS
530
- ###################################
531
- ACCESS_TOKEN = os.getenv("HF_TOKEN")
532
- if not ACCESS_TOKEN:
533
- raise ValueError("HF_TOKEN environment variable is not set")
534
 
535
- client = OpenAI(
536
- base_url="https://api-inference.huggingface.co/v1/",
537
- api_key=ACCESS_TOKEN,
538
- )
539
 
540
- API_KEY = os.getenv("SERPHOUSE_API_KEY")
 
 
541
 
542
  COUNTRY_LANGUAGES = {
543
  "United States": "en",
544
  "KOREA": "ko",
545
  "United Kingdom": "en",
546
- "Taiwan": "zh-TW",
547
  "Canada": "en",
548
  "Australia": "en",
549
  "Germany": "de",
@@ -607,7 +457,7 @@ COUNTRY_LANGUAGES = {
607
  "Luxembourg": "Luxembourg",
608
  "Malta": "Malta",
609
  "Cyprus": "Cyprus",
610
- "Iceland": "Iceland"
611
  }
612
 
613
  COUNTRY_LOCATIONS = {
@@ -663,14 +513,14 @@ COUNTRY_LOCATIONS = {
663
  "Bangladesh": "Bangladesh",
664
  "Pakistan": "Pakistan",
665
  "Egypt": "Egypt",
666
- "Morocco": "ar",
667
- "Nigeria": "en",
668
- "Kenya": "sw",
669
- "Ukraine": "uk",
670
- "Croatia": "hr",
671
- "Slovakia": "sk",
672
- "Bulgaria": "bg",
673
- "Serbia": "sr",
674
  "Estonia": "et",
675
  "Latvia": "lv",
676
  "Lithuania": "lt",
@@ -681,203 +531,302 @@ COUNTRY_LOCATIONS = {
681
  "Iceland": "Iceland"
682
  }
683
 
684
- css = """
685
- /* 전역 스타일 */
686
- footer {visibility: hidden;}
687
 
688
- /* 레이아웃 컨테이너 */
689
- #status_area {
690
- background: rgba(255, 255, 255, 0.9);
691
- padding: 15px;
692
- border-bottom: 1px solid #ddd;
693
- margin-bottom: 20px;
694
- box-shadow: 0 2px 5px rgba(0,0,0,0.1);
695
- }
696
 
697
- #results_area {
698
- padding: 10px;
699
- margin-top: 10px;
700
- }
 
 
 
 
 
 
 
 
 
 
701
 
702
- /* 스타일 */
703
- .tabs {
704
- border-bottom: 2px solid #ddd !important;
705
- margin-bottom: 20px !important;
706
- }
 
 
 
707
 
708
- .tab-nav {
709
- border-bottom: none !important;
710
- margin-bottom: 0 !important;
711
- }
712
 
713
- .tab-nav button {
714
- font-weight: bold !important;
715
- padding: 10px 20px !important;
716
- }
 
 
 
 
 
 
 
 
 
 
717
 
718
- .tab-nav button.selected {
719
- border-bottom: 2px solid #1f77b4 !important;
720
- color: #1f77b4 !important;
721
- }
722
 
723
- /* 상태 메시지 */
724
- #status_area .markdown-text {
725
- font-size: 1.1em;
726
- color: #2c3e50;
727
- padding: 10px 0;
728
- }
729
 
730
- /* 기본 컨테이너 */
731
- .group {
732
- border: 1px solid #eee;
733
- padding: 15px;
734
- margin-bottom: 15px;
735
- border-radius: 5px;
736
- background: white;
737
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
738
 
739
- /* 버튼 스타일 */
740
- .primary-btn {
741
- background: #1f77b4 !important;
742
- border: none !important;
743
- }
 
 
 
 
 
 
 
 
 
 
 
 
744
 
745
- /* 입력 필드 */
746
- .textbox {
747
- border: 1px solid #ddd !important;
748
- border-radius: 4px !important;
749
- }
750
 
751
- /* 프로그레스바 컨테이너 */
752
- .progress-container {
753
- position: fixed;
754
- top: 0;
755
- left: 0;
756
- width: 100%;
757
- height: 6px;
758
- background: #e0e0e0;
759
- z-index: 1000;
760
- }
761
 
762
- /* 프로그레스bar */
763
- .progress-bar {
764
- height: 100%;
765
- background: linear-gradient(90deg, #2196F3, #00BCD4);
766
- box-shadow: 0 0 10px rgba(33, 150, 243, 0.5);
767
- transition: width 0.3s ease;
768
- animation: progress-glow 1.5s ease-in-out infinite;
769
- }
 
 
 
 
 
 
 
 
 
770
 
771
- /* 프로그레스 텍스트 */
772
- .progress-text {
773
- position: fixed;
774
- top: 8px;
775
- left: 50%;
776
- transform: translateX(-50%);
777
- background: #333;
778
- color: white;
779
- padding: 4px 12px;
780
- border-radius: 15px;
781
- font-size: 14px;
782
- z-index: 1001;
783
- box-shadow: 0 2px 5px rgba(0,0,0,0.2);
784
- }
785
 
786
- /* 프로그레스바 애니메이션 */
787
- @keyframes progress-glow {
788
- 0% {
789
- box-shadow: 0 0 5px rgba(33, 150, 243, 0.5);
790
- }
791
- 50% {
792
- box-shadow: 0 0 20px rgba(33, 150, 243, 0.8);
793
- }
794
- 100% {
795
- box-shadow: 0 0 5px rgba(33, 150, 243, 0.5);
796
- }
797
- }
798
 
799
- /* 반응형 디자인 */
800
- @media (max-width: 768px) {
801
- .group {
802
- padding: 10px;
803
- margin-bottom: 15px;
804
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
805
 
806
- .progress-text {
807
- font-size: 12px;
808
- padding: 3px 10px;
 
809
  }
810
- }
 
 
 
 
811
 
812
- /* 로딩 상태 표시 개선 */
813
- .loading {
814
- opacity: 0.7;
815
- pointer-events: none;
816
- transition: opacity 0.3s ease;
817
- }
 
818
 
819
- /* 결과 컨테이너 애니메이션 */
820
- .group {
821
- transition: all 0.3s ease;
822
- opacity: 0;
823
- transform: translateY(20px);
824
- }
825
 
826
- .group.visible {
827
- opacity: 1;
828
- transform: translateY(0);
829
- }
830
 
831
- /* Examples 스타일링 */
832
- .examples-table {
833
- margin-top: 10px !important;
834
- margin-bottom: 20px !important;
835
- }
836
 
837
- .examples-table button {
838
- background-color: #f0f0f0 !important;
839
- border: 1px solid #ddd !important;
840
- border-radius: 4px !important;
841
- padding: 5px 10px !important;
842
- margin: 2px !important;
843
- transition: all 0.3s ease !important;
844
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
845
 
846
- .examples-table button:hover {
847
- background-color: #e0e0e0 !important;
848
- transform: translateY(-1px) !important;
849
- box-shadow: 0 2px 5px rgba(0,0,0,0.1) !important;
850
- }
851
 
852
- .examples-table .label {
853
- font-weight: bold !important;
854
- color: #444 !important;
855
- margin-bottom: 5px !important;
856
- }
857
- """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
858
 
859
- import gradio as gr
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
860
 
861
  with gr.Blocks(theme="Yntec/HaleyCH_Theme_Orange", css=css, title="NewsAI 서비스") as iface:
862
  init_db()
863
 
 
864
  with gr.Tabs():
865
- # 첫 번째 탭
866
  with gr.Tab("지정 자동 검색/분석"):
867
  gr.Markdown("## EarnBot: 글로벌 빅테크 기업 및 투자 전망 AI 자동 분석")
868
- gr.Markdown(" - '전체 분석 보고 요약' 클릭 시 전체 자동 보고 생성.\n - 아래 개별 종목의 '검색(DB 자동 저장)'과 '출력(DB 자동 호출)'도 가능.\n - 하단 '수동 검색 히스토리'에서 이전에 수동 입력한 검색어 기록 확인 가능.")
 
 
869
 
870
- # 전체 분석 보고 요약
871
  with gr.Row():
872
  full_report_btn = gr.Button("전체 분석 보고 요약", variant="primary")
873
  full_report_display = gr.Markdown()
 
874
 
875
- full_report_btn.click(
876
- fn=full_summary_report,
877
- outputs=full_report_display
878
- )
879
-
880
- # 지정된 (KOREAN_COMPANIES) 기업 검색/출력
881
  with gr.Column():
882
  for i in range(0, len(KOREAN_COMPANIES), 2):
883
  with gr.Row():
@@ -924,20 +873,16 @@ with gr.Blocks(theme="Yntec/HaleyCH_Theme_Orange", css=css, title="NewsAI 서비
924
  outputs=result_display
925
  )
926
 
927
- # (추가) 수동 검색 히스토리 (첫 번째 탭)
928
  gr.Markdown("---")
929
  gr.Markdown("### 수동 검색 히스토리")
 
930
  with gr.Row():
931
  refresh_hist_btn = gr.Button("히스토리 갱신", variant="secondary")
932
 
933
- history_dropdown = gr.Dropdown(
934
- label="검색 기록 목록",
935
- choices=[],
936
- value=None
937
- )
938
  hist_view_btn = gr.Button("보기", variant="primary")
939
  hist_result_display = gr.Markdown()
940
-
941
  def update_history_dropdown():
942
  history_list = get_custom_search_history()
943
  choice_list = []
@@ -966,7 +911,7 @@ with gr.Blocks(theme="Yntec/HaleyCH_Theme_Orange", css=css, title="NewsAI 서비
966
  outputs=hist_result_display
967
  )
968
 
969
- # 두 번째 탭: "수동 검색/분석" + 히스토리
970
  with gr.Tab("수동 검색/분석"):
971
  gr.Markdown("## 사용자 임의 키워드 + 국가 검색/분석")
972
  gr.Markdown("검색 결과가 DB에 저장되며, 아래 '수동 검색 히스토리'에서도 확인 가능합니다.")
@@ -994,21 +939,16 @@ with gr.Blocks(theme="Yntec/HaleyCH_Theme_Orange", css=css, title="NewsAI 서비
994
  outputs=custom_search_output
995
  )
996
 
997
- # 동일하게, 두 번째 탭에도 히스토리 적용
998
  gr.Markdown("---")
999
  gr.Markdown("### 수동 검색 히스토리 (두 번째 탭)")
 
1000
  with gr.Row():
1001
  refresh_hist_btn2 = gr.Button("히스토리 갱신", variant="secondary")
1002
 
1003
- history_dropdown2 = gr.Dropdown(
1004
- label="검색 기록 목록",
1005
- choices=[],
1006
- value=None
1007
- )
1008
  hist_view_btn2 = gr.Button("보기", variant="primary")
1009
  hist_result_display2 = gr.Markdown()
1010
-
1011
- # 동일 로직 재사용
1012
  refresh_hist_btn2.click(
1013
  fn=update_history_dropdown,
1014
  inputs=[],
 
14
  import sqlite3
15
  import pytz
16
 
17
+
18
+ #########################################################
19
  # 한국 기업 리스트
20
+ #########################################################
21
  KOREAN_COMPANIES = [
22
  "NVIDIA",
23
  "ALPHABET",
 
37
  "investing"
38
  ]
39
 
40
+
41
  #########################################################
42
  # 공통 함수
43
  #########################################################
44
 
45
  def convert_to_seoul_time(timestamp_str):
46
+ """
47
+ 주어진 'YYYY-MM-DD HH:MM:SS' 형태의 UTC 시각을 서울 시간(KST)으로 변환하여
48
+ 'YYYY-MM-DD HH:MM:SS KST' 형태의 문자열로 반환.
49
+ """
50
  try:
51
  dt = datetime.strptime(timestamp_str, '%Y-%m-%d %H:%M:%S')
52
  seoul_tz = pytz.timezone('Asia/Seoul')
 
56
  print(f"시간 변환 오류: {str(e)}")
57
  return timestamp_str
58
 
59
+
60
  def analyze_sentiment_batch(articles, client):
61
  """
62
+ OpenAI API를 통해 뉴스 기사들(articles)의 제목/내용을 종합하여
63
+ 감성 분석(긍정/부정/중립 등)을 수행하고, 요약된 결과를 문자열로 반환.
64
  """
65
  try:
66
+ # 모든 기사(제목, snippet)을 합쳐 하나의 텍스트로 만든다.
67
  combined_text = "\n\n".join([
68
  f"제목: {article.get('title', '')}\n내용: {article.get('snippet', '')}"
69
  for article in articles
70
  ])
71
 
72
+ # 감성 분석을 요청하는 프롬프트
73
  prompt = f"""다음 뉴스 모음에 대해 전반적인 감성 분석을 수행하세요:
74
 
75
  뉴스 내용:
 
94
  )
95
 
96
  return response.choices[0].message.content
97
+
98
  except Exception as e:
99
  return f"감성 분석 실패: {str(e)}"
100
 
101
 
102
  #########################################################
103
+ # DB 초기화 및 입출력 함수
104
  #########################################################
105
 
106
  def init_db():
107
+ """
108
+ SQLite DB 파일(search_results.db)이 없다면 생성하고,
109
+ 'searches' 테이블이 없다면 생성한다.
110
+ """
111
  db_path = pathlib.Path("search_results.db")
112
  conn = sqlite3.connect(db_path)
113
  c = conn.cursor()
 
120
  conn.commit()
121
  conn.close()
122
 
123
+
124
  def save_to_db(keyword, country, results):
125
+ """
126
+ 특정 (keyword, country)에 대한 검색 결과(results: JSON 형태)를 DB에 저장.
127
+ """
128
  conn = sqlite3.connect("search_results.db")
129
  c = conn.cursor()
130
  seoul_tz = pytz.timezone('Asia/Seoul')
 
138
  conn.commit()
139
  conn.close()
140
 
141
+
142
  def load_from_db(keyword, country):
143
+ """
144
+ DB에서 (keyword, country)에 해당하는 가장 최근 검색 결과를 불러온다.
145
+ 결과가 있으면 (JSON 디코딩된 값, 'YYYY-MM-DD HH:MM:SS KST' 형태의 저장 시각) 반환.
146
+ 없으면 (None, None) 반환.
147
+ """
148
  conn = sqlite3.connect("search_results.db")
149
  c = conn.cursor()
150
+ c.execute("""SELECT results, timestamp
151
+ FROM searches
152
+ WHERE keyword=? AND country=?
153
+ ORDER BY timestamp DESC
154
+ LIMIT 1""",
155
  (keyword, country))
156
  result = c.fetchone()
157
  conn.close()
 
160
  return None, None
161
 
162
 
 
 
 
163
  def load_by_id(search_id):
164
  """
165
+ DB의 PRIMARY KEY(id)로 특정 검색 기록을 불러온다.
166
+ - keyword, country, results, timestamp
167
+ - results를 JSON 디코딩한 뒤 'data'로서 반환.
168
  """
169
  conn = sqlite3.connect("search_results.db")
170
  c = conn.cursor()
171
+ c.execute("SELECT keyword, country, results, timestamp FROM searches WHERE id=?",
172
+ (search_id,))
173
  row = c.fetchone()
174
  conn.close()
175
 
 
184
  }
185
  return None
186
 
187
+
188
+ #########################################################
189
+ # 결과 표시
190
+ #########################################################
191
+
192
  def display_results(articles):
193
+ """
194
+ 기사 목록(articles)을 Markdown 형식으로 예쁘게 정리.
195
+ """
196
  output = ""
197
  for idx, article in enumerate(articles, 1):
198
  output += f"### {idx}. {article['title']}\n"
 
204
 
205
 
206
  #########################################################
207
+ # SerpHouse API: 번역 / 요청 / 응답 가공
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  #########################################################
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
 
210
+ API_KEY = os.getenv("SERPHOUSE_API_KEY") # SERPHOUSE_API_KEY 환경변수
211
 
212
+ def is_english(text):
 
 
 
213
  """
214
+ text가 모두 ASCII 범위 내에 있으면 True, 아니면 False.
 
 
215
  """
216
+ return all(ord(char) < 128 for char in text.replace(' ', '').replace('-', '').replace('_', ''))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
 
218
 
219
+ @lru_cache(maxsize=100)
220
+ def translate_query(query, country):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
  """
222
+ 검색어(query)를 해당 country 언어로 번역.
223
+ (단, is_english(query) True이거나, country가 특정 조건이면 그대로 리턴)
224
  """
225
+ try:
226
+ if is_english(query):
227
+ return query
228
+
229
+ if country in COUNTRY_LANGUAGES:
230
+ target_lang = COUNTRY_LANGUAGES[country]
231
+
232
+ url = "https://translate.googleapis.com/translate_a/single"
233
+ params = {
234
+ "client": "gtx",
235
+ "sl": "auto",
236
+ "tl": target_lang,
237
+ "dt": "t",
238
+ "q": query
239
+ }
240
+
241
+ session = requests.Session()
242
+ retries = Retry(total=3, backoff_factor=0.5)
243
+ session.mount('https://', HTTPAdapter(max_retries=retries))
244
+
245
+ response = session.get(url, params=params, timeout=(5, 10))
246
+ translated_text = response.json()[0][0][0]
247
+ return translated_text
248
+
249
+ return query
250
+
251
+ except Exception as e:
252
+ print(f"번역 오류: {str(e)}")
253
+ return query
254
 
255
 
 
 
 
256
  def serphouse_search(query, country, page=1, num_result=10):
257
  """
258
+ SerpHouse API 실시간 'news' 검색 요청을 보내고,
259
+ 결과를 특정 형식(오류 메시지 or 기사 목록)으로 반환한다.
260
  """
261
  url = "https://api.serphouse.com/serp/live"
262
 
 
264
  yesterday = now - timedelta(days=1)
265
  date_range = f"{yesterday.strftime('%Y-%m-%d')},{now.strftime('%Y-%m-%d')}"
266
 
267
+ # 검색어 번역
268
  translated_query = translate_query(query, country)
269
 
270
  payload = {
 
302
  session.mount('http://', adapter)
303
  session.mount('https://', adapter)
304
 
305
+ # API 호출
306
  response = session.post(
307
+ url,
308
+ json=payload,
309
+ headers=headers,
310
  timeout=(30, 30)
311
  )
312
 
313
  response.raise_for_status()
314
  response_data = response.json()
315
 
316
+ # 응답 데이터에서 기사 부분만 추출
317
  return format_results_from_raw({
318
  "results": response_data,
319
  "translated_query": translated_query
 
328
 
329
 
330
  def format_results_from_raw(response_data):
331
+ """
332
+ SerpHouse API raw 데이터(response_data)에서
333
+ - error 가 있으면 ("Error: ...", [])
334
+ - 정상인 경우 ("", [기사1, 기사2, ...]) 형태로 반환
335
+ - 또한 한국 도메인/키워드를 가진 기사 제외 (필터링)
336
+ """
337
  if "error" in response_data:
338
+ return ("Error: " + response_data["error"], [])
339
 
340
  try:
341
  results = response_data["results"]
 
343
 
344
  news_results = results.get('results', {}).get('results', {}).get('news', [])
345
  if not news_results:
346
+ return ("검색 결과가 없습니다.", [])
347
 
348
+ # 한국 도메인 / 한국 키워드 기사 제외
349
  korean_domains = [
350
  '.kr', 'korea', 'korean', 'yonhap', 'hankyung', 'chosun',
351
  'donga', 'joins', 'hani', 'koreatimes', 'koreaherald'
 
361
  title = result.get("title", "").lower()
362
  channel = result.get("channel", result.get("source", "")).lower()
363
 
364
+ # 한국 관련 기사 필터
365
  is_korean_content = (
366
  any(domain in url or domain in channel for domain in korean_domains) or
367
  any(keyword in title for keyword in korean_keywords)
 
379
  "translated_query": translated_query
380
  })
381
 
382
+ return ("", filtered_articles)
 
 
 
383
 
384
+ except Exception as e:
385
+ return (f"결과 처리 오류 발생: {str(e)}", [])
 
 
 
 
386
 
 
 
 
 
387
 
388
+ #########################################################
389
+ # 국가 설정
390
+ #########################################################
391
 
392
  COUNTRY_LANGUAGES = {
393
  "United States": "en",
394
  "KOREA": "ko",
395
  "United Kingdom": "en",
396
+ "Taiwan": "zh-TW",
397
  "Canada": "en",
398
  "Australia": "en",
399
  "Germany": "de",
 
457
  "Luxembourg": "Luxembourg",
458
  "Malta": "Malta",
459
  "Cyprus": "Cyprus",
460
+ "Iceland": "is"
461
  }
462
 
463
  COUNTRY_LOCATIONS = {
 
513
  "Bangladesh": "Bangladesh",
514
  "Pakistan": "Pakistan",
515
  "Egypt": "Egypt",
516
+ "Morocco": "Morocco",
517
+ "Nigeria": "Nigeria",
518
+ "Kenya": "Kenya",
519
+ "Ukraine": "Ukraine",
520
+ "Croatia": "Croatia",
521
+ "Slovakia": "Slovakia",
522
+ "Bulgaria": "Bulgaria",
523
+ "Serbia": "Serbia",
524
  "Estonia": "et",
525
  "Latvia": "lv",
526
  "Lithuania": "lt",
 
531
  "Iceland": "Iceland"
532
  }
533
 
 
 
 
534
 
535
+ #########################################################
536
+ # 검색/출력 함수 (기업 검색, 로드)
537
+ #########################################################
 
 
 
 
 
538
 
539
+ def search_company(company):
540
+ """
541
+ KOREAN_COMPANIES에 있는 company를 미국(United States) 기준으로 검색 + 감성 분석
542
+ DB에 저장 후 기사 목록 + 분석 결과를 markdown으로 반환.
543
+ """
544
+ error_message, articles = serphouse_search(company, "United States")
545
+ if not error_message and articles:
546
+ analysis = analyze_sentiment_batch(articles, client)
547
+ store_dict = {
548
+ "articles": articles,
549
+ "analysis": analysis
550
+ }
551
+ # DB 저장
552
+ save_to_db(company, "United States", store_dict)
553
 
554
+ # 결과 출력
555
+ output = display_results(articles)
556
+ output += f"\n\n### 분석 보고\n{analysis}\n"
557
+ return output
558
+ else:
559
+ if error_message:
560
+ return error_message
561
+ return f"{company}에 대한 검색 결과가 없습니다."
562
 
 
 
 
 
563
 
564
+ def load_company(company):
565
+ """
566
+ DB에 저장된 (company, United States) 검색 결과를 불러와 기사+분석 을 반환.
567
+ """
568
+ data, timestamp = load_from_db(company, "United States")
569
+ if data:
570
+ articles = data.get("articles", [])
571
+ analysis = data.get("analysis", "")
572
+
573
+ output = f"### {company} 검색 결과\n저장 시간: {timestamp}\n\n"
574
+ output += display_results(articles)
575
+ output += f"\n\n### 분석 보고\n{analysis}\n"
576
+ return output
577
+ return f"{company}에 대한 저장된 결과가 없습니다."
578
 
 
 
 
 
579
 
580
+ #########################################################
581
+ # 전체 통계
582
+ #########################################################
 
 
 
583
 
584
+ def show_stats():
585
+ """
586
+ (기존 "한국 기업 뉴스 분석 리포트") -> "EarnBOT 분석 리포트" 로 명칭 변경
587
+ 기업의 최신 DB 기록(기사+분석) 수를 표시하고,
588
+ 감성 분석 결과를 함께 출력.
589
+ """
590
+ conn = sqlite3.connect("search_results.db")
591
+ c = conn.cursor()
592
+
593
+ output = "## EarnBOT 분석 리포트\n\n"
594
+
595
+ data_list = []
596
+ for company in KOREAN_COMPANIES:
597
+ c.execute("""
598
+ SELECT results, timestamp
599
+ FROM searches
600
+ WHERE keyword = ?
601
+ ORDER BY timestamp DESC
602
+ LIMIT 1
603
+ """, (company,))
604
+
605
+ row = c.fetchone()
606
+ if row:
607
+ results_json, tstamp = row
608
+ data_list.append((company, tstamp, results_json))
609
+
610
+ conn.close()
611
+
612
+ def analyze_data(item):
613
+ comp, tstamp, results_json = item
614
+ data = json.loads(results_json)
615
+ articles = data.get("articles", [])
616
+ analysis = data.get("analysis", "")
617
+ count_articles = len(articles)
618
+ return (comp, tstamp, count_articles, analysis)
619
 
620
+ results_list = []
621
+ with ThreadPoolExecutor(max_workers=5) as executor:
622
+ futures = [executor.submit(analyze_data, dl) for dl in data_list]
623
+ for future in as_completed(futures):
624
+ results_list.append(future.result())
625
+
626
+ for comp, tstamp, count, analysis in results_list:
627
+ seoul_time = convert_to_seoul_time(tstamp)
628
+ output += f"### {comp}\n"
629
+ output += f"- 마지막 업데이트: {seoul_time}\n"
630
+ output += f"- 저장된 기사 수: {count}건\n\n"
631
+ if analysis:
632
+ output += "#### 뉴스 감성 분석\n"
633
+ output += f"{analysis}\n\n"
634
+ output += "---\n\n"
635
+
636
+ return output
637
 
 
 
 
 
 
638
 
639
+ #########################################################
640
+ # 전체 검색/출력 + 종합 보고
641
+ #########################################################
 
 
 
 
 
 
 
642
 
643
+ def search_all_companies():
644
+ """
645
+ KOREAN_COMPANIES 리스트에 대해 병렬 검색 + 분석
646
+ """
647
+ overall_result = "# [전체 검색 결과]\n\n"
648
+
649
+ def do_search(comp):
650
+ return comp, search_company(comp)
651
+
652
+ with ThreadPoolExecutor(max_workers=5) as executor:
653
+ futures = [executor.submit(do_search, c) for c in KOREAN_COMPANIES]
654
+ for future in as_completed(futures):
655
+ comp, res_text = future.result()
656
+ overall_result += f"## {comp}\n"
657
+ overall_result += res_text + "\n\n"
658
+
659
+ return overall_result
660
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
661
 
662
+ def load_all_companies():
663
+ """
664
+ KOREAN_COMPANIES 리스트에 대해 DB에 저장된 값(기사+분석) 일괄 출력
665
+ """
666
+ overall_result = "# [전체 출력 결과]\n\n"
667
+ for comp in KOREAN_COMPANIES:
668
+ overall_result += f"## {comp}\n"
669
+ overall_result += load_company(comp)
670
+ overall_result += "\n"
671
+ return overall_result
 
 
672
 
673
+
674
+ def full_summary_report():
675
+ """
676
+ 1) search_all_companies => 기사+분석 => DB 저장
677
+ 2) load_all_companies => DB 로드
678
+ 3) show_stats => 종합 감성 분석
679
+ """
680
+ search_result_text = search_all_companies()
681
+ load_result_text = load_all_companies()
682
+ stats_text = show_stats()
683
+
684
+ combined_report = (
685
+ "# 전체 분석 보고 요약\n\n"
686
+ "아래 순서로 실행되었습니다:\n"
687
+ "1. 모든 종목 검색(병렬) + 분석 => 2. 모든 종목 DB 결과 출력 => 3. 전체 감성 분석 통계\n\n"
688
+ f"{search_result_text}\n\n"
689
+ f"{load_result_text}\n\n"
690
+ "## [전체 감성 분석 통계]\n\n"
691
+ f"{stats_text}"
692
+ )
693
+ return combined_report
694
+
695
+
696
+ #########################################################
697
+ # (추가) 사용자 임의 검색 + 분석
698
+ #########################################################
699
+
700
+ def search_custom(query, country):
701
+ """
702
+ 1) query & country에 대해 검색 + 분석 => DB저장
703
+ 2) DB에서 다시 로딩 => 기사 + 분석 결과 표시
704
+ """
705
+ error_message, articles = serphouse_search(query, country)
706
+ if error_message:
707
+ return f"오류 발생: {error_message}"
708
+ if not articles:
709
+ return "검색 결과가 없습니다."
710
 
711
+ analysis = analyze_sentiment_batch(articles, client)
712
+ save_data = {
713
+ "articles": articles,
714
+ "analysis": analysis
715
  }
716
+ save_to_db(query, country, save_data)
717
+
718
+ loaded_data, timestamp = load_from_db(query, country)
719
+ if not loaded_data:
720
+ return "DB에서 로드 실패"
721
 
722
+ arts = loaded_data.get("articles", [])
723
+ analy = loaded_data.get("analysis", "")
724
+
725
+ out = f"## [사용자 임의 검색 결과]\n\n"
726
+ out += f"**키워드**: {query}\n\n"
727
+ out += f"**국가**: {country}\n\n"
728
+ out += f"**저장 시간**: {timestamp}\n\n"
729
 
730
+ out += display_results(arts)
731
+ out += f"### 뉴스 감성 분석\n{analy}\n"
732
+
733
+ return out
 
 
734
 
 
 
 
 
735
 
736
+ #########################################################
737
+ # (추가) 히스토리 함수
738
+ #########################################################
 
 
739
 
740
+ def get_custom_search_history():
741
+ """
742
+ KOREAN_COMPANIES 목록에 없는 keyword로 검색된 기록만 (id, label) 리스트로 반환.
743
+ label 예: "12 | 2025-01-22 10:23:00 KST | Apple (United States)"
744
+ """
745
+ company_set = set(k.lower() for k in KOREAN_COMPANIES)
746
+
747
+ conn = sqlite3.connect("search_results.db")
748
+ c = conn.cursor()
749
+ c.execute("""SELECT id, keyword, country, timestamp
750
+ FROM searches
751
+ ORDER BY timestamp DESC""")
752
+ rows = c.fetchall()
753
+ conn.close()
754
+
755
+ history_list = []
756
+ for sid, kw, cty, ts in rows:
757
+ if kw.lower() not in company_set:
758
+ display_time = convert_to_seoul_time(ts)
759
+ label = f"{sid} | {display_time} | {kw} ({cty})"
760
+ history_list.append((str(sid), label))
761
+ return history_list
762
 
 
 
 
 
 
763
 
764
+ def view_history_record(record_id):
765
+ """
766
+ 주어진 record_id 로부터 load_by_id() 로 로드한 기사+분석 결과를 Markdown 표시
767
+ """
768
+ if not record_id:
769
+ return "기록이 없습니다."
770
+
771
+ data = load_by_id(int(record_id))
772
+ if not data:
773
+ return "해당 ID의 기록이 없습니다."
774
+
775
+ keyword = data["keyword"]
776
+ country = data["country"]
777
+ timestamp = data["timestamp"]
778
+ stored = data["data"] # {"articles": [...], "analysis": ...}
779
+
780
+ articles = stored.get("articles", [])
781
+ analysis = stored.get("analysis", "")
782
+
783
+ out = f"### [히스토리 검색 결과]\n\n"
784
+ out += f"- ID: {record_id}\n"
785
+ out += f"- 키워드: {keyword}\n"
786
+ out += f"- 국가: {country}\n"
787
+ out += f"- 저장 시간: {timestamp}\n\n"
788
+ out += display_results(articles)
789
+ out += f"\n\n### 분석 보고\n{analysis}\n"
790
+ return out
791
 
792
+
793
+ #########################################################
794
+ # Gradio 인터페이스
795
+ #########################################################
796
+
797
+ ACCESS_TOKEN = os.getenv("HF_TOKEN")
798
+ if not ACCESS_TOKEN:
799
+ raise ValueError("HF_TOKEN environment variable is not set")
800
+
801
+ client = OpenAI(
802
+ base_url="https://api-inference.huggingface.co/v1/",
803
+ api_key=ACCESS_TOKEN,
804
+ )
805
+
806
+ css = """
807
+ /* 전역 스타일 */
808
+ footer {visibility: hidden;}
809
+
810
+ /* 기타 CSS 등... (이하 동일) */
811
+ """
812
 
813
  with gr.Blocks(theme="Yntec/HaleyCH_Theme_Orange", css=css, title="NewsAI 서비스") as iface:
814
  init_db()
815
 
816
+ # 원하는 탭들 구성
817
  with gr.Tabs():
 
818
  with gr.Tab("지정 자동 검색/분석"):
819
  gr.Markdown("## EarnBot: 글로벌 빅테크 기업 및 투자 전망 AI 자동 분석")
820
+ gr.Markdown("- '전체 분석 보고 요약' 클릭 시 전체 자동 보고 생성.\n"
821
+ "- 아래 개별 종목의 '검색(DB 자동 저장)'과 '출력(DB 자동 호출)'도 가능.\n"
822
+ "- 하단 '수동 검색 히스토리'에서 이전에 수동 입력한 검색어 기록을 확인 가능.")
823
 
 
824
  with gr.Row():
825
  full_report_btn = gr.Button("전체 분석 보고 요약", variant="primary")
826
  full_report_display = gr.Markdown()
827
+ full_report_btn.click(fn=full_summary_report, outputs=full_report_display)
828
 
829
+ # 개별 종목 검색/출력
 
 
 
 
 
830
  with gr.Column():
831
  for i in range(0, len(KOREAN_COMPANIES), 2):
832
  with gr.Row():
 
873
  outputs=result_display
874
  )
875
 
 
876
  gr.Markdown("---")
877
  gr.Markdown("### 수동 검색 히스토리")
878
+
879
  with gr.Row():
880
  refresh_hist_btn = gr.Button("히스토리 갱신", variant="secondary")
881
 
882
+ history_dropdown = gr.Dropdown(label="검색 기록 목록", choices=[], value=None)
 
 
 
 
883
  hist_view_btn = gr.Button("보기", variant="primary")
884
  hist_result_display = gr.Markdown()
885
+
886
  def update_history_dropdown():
887
  history_list = get_custom_search_history()
888
  choice_list = []
 
911
  outputs=hist_result_display
912
  )
913
 
914
+ # 두 번째 탭: "수동 검색/분석"
915
  with gr.Tab("수동 검색/분석"):
916
  gr.Markdown("## 사용자 임의 키워드 + 국가 검색/분석")
917
  gr.Markdown("검색 결과가 DB에 저장되며, 아래 '수동 검색 히스토리'에서도 확인 가능합니다.")
 
939
  outputs=custom_search_output
940
  )
941
 
 
942
  gr.Markdown("---")
943
  gr.Markdown("### 수동 검색 히스토리 (두 번째 탭)")
944
+
945
  with gr.Row():
946
  refresh_hist_btn2 = gr.Button("히스토리 갱신", variant="secondary")
947
 
948
+ history_dropdown2 = gr.Dropdown(label="검색 기록 목록", choices=[], value=None)
 
 
 
 
949
  hist_view_btn2 = gr.Button("보기", variant="primary")
950
  hist_result_display2 = gr.Markdown()
951
+
 
952
  refresh_hist_btn2.click(
953
  fn=update_history_dropdown,
954
  inputs=[],