Files changed (1) hide show
  1. app.py +238 -56
app.py CHANGED
@@ -4,6 +4,29 @@ import pandas as pd
4
  from sklearn.linear_model import LogisticRegression
5
  from xgboost import XGBClassifier
6
  import time
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
 
8
  # ============================================================
9
  # 0. 공통 설정
@@ -361,89 +384,240 @@ def render_gen4(amount, hour, new_payee_bin, ratio, prob3):
361
  </div>
362
  """
363
 
364
- def render_gen5(amount, hour, new_payee_bin, ratio, prior_avg):
365
- """5세대 LLM 시뮬레이션 (안정성을 위해 API 호출 없이 하드코딩)"""
366
- is_high_risk = (new_payee_bin == 1 and (hour <= 6 or hour >= 22) and amount >= 500)
367
- prob = 0.95 if is_high_risk else min(prior_avg + 0.02, 0.98)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
368
  dec, bg, fg = decide(prob)
369
- payee_str = "신규 수취인" if new_payee_bin == 1 else "기존 수취인"
370
- time_str = "새벽" if (hour <= 6 or hour >= 22) else "일반"
371
 
372
  context_html = (
373
- f"[SYS] 당신은 FDS 분석가입니다. 거래 패턴을 분석해 사기 여부를 판정하세요.<br>"
374
  f"[INPUT] amount={amount:.0f}만, hour={int(hour):02d}, "
375
  f"new_payee={'true' if new_payee_bin==1 else 'false'}, ratio={ratio:.1f}×<br>"
376
- f"[PRIOR] 1-4세대 합의: {dec} (avg {prior_avg*100:.1f}%)<br>"
377
- f"[TASK] 의심 사유와 권고 조치를 한국어로."
378
  )
379
 
380
- cot_rows = (
381
- f"<tr><td style='padding:6px 4px;'>1</td>"
382
- f"<td style='padding:6px 4px; color:#666;'>시간 {int(hour):02d}시 + {payee_str} → 보이스피싱 정형 패턴 매칭</td>"
383
- f"<td style='text-align:right; padding:6px 4px; font-family:monospace; color:#666;'>0.42</td></tr>"
384
- f"<tr><td style='padding:6px 4px;'>2</td>"
385
- f"<td style='padding:6px 4px; color:#666;'>평소 {ratio:.1f}배 금액 → 송금인 평소 행동 이탈도 측정</td>"
386
- f"<td style='text-align:right; padding:6px 4px; font-family:monospace; color:#666;'>0.28</td></tr>"
387
- f"<tr><td style='padding:6px 4px;'>3</td>"
388
- f"<td style='padding:6px 4px; color:#666;'>4세대 GNN 신호 + {amount:.0f}만원 고액 → 위험 가중</td>"
389
- f"<td style='text-align:right; padding:6px 4px; font-family:monospace; color:#666;'>0.21</td></tr>"
390
- f"<tr><td style='padding:6px 4px;'>4</td>"
391
- f"<td style='padding:6px 4px; color:#666;'>최근 보이스피싱 트렌드 사전 지식 활용</td>"
392
- f"<td style='text-align:right; padding:6px 4px; font-family:monospace; color:#666;'>0.09</td></tr>"
393
- )
394
-
395
- if is_high_risk:
396
- nl_judgment = (
397
- f"<b style='color:#633806;'>차단 권고 (의심도 {prob*100:.0f}%)</b><br><br>"
398
- f"{int(hour):02d}시 {time_str} 시간대에 평소보다 {ratio:.1f}배 급증한 {amount:.0f}만원이 "
399
- f"<u>{payee_str}</u>에게 이체되는 것은 전형적인 보이스피싱 패턴입니다. "
400
- f"특히 수취인 계좌가 최근 사기 의심 계좌와 2-hop 거리에 있다는 GNN 신호까지 더해져 위험도가 매우 높습니다.<br><br>"
401
- f"<b>권고 조치:</b> ① 즉시 거래 보류, ② 등록된 전화번호로 본인 직접 확인, ③ 확인 전 자금 동결 24시간 유지"
402
- )
403
- elif prob >= 0.5:
404
- nl_judgment = (
405
- f"<b style='color:#633806;'>추가 인증 권고 (의심도 {prob*100:.0f}%)</b><br><br>"
406
- f"{int(hour):02d}시 거래에서 일부 이상 신호({ratio:.1f}배 금액, {payee_str})가 감지되었으나 "
407
- f"결정적 위험 패턴은 아닙니다. 차단보다는 추가 인증으로 본인 의사를 확인하는 것이 적절합니다.<br><br>"
408
- f"<b>권고 조치:</b> ① ARS 또는 OTP 추가 인증, ② 송금 의도 재확인 메시지 발송"
409
- )
410
- else:
411
- nl_judgment = (
412
- f"<b style='color:#3B6D11;'>통과 권고 (의심도 {prob*100:.0f}%)</b><br><br>"
413
- f"{int(hour):02d}시 거래의 패턴이 송금인의 평소 행동 범위 내에 있으며, "
414
- f"1-4세대 모델 모두 위험 신호를 강하게 보내지 않았습니다. 정상 거래로 판단됩니다.<br><br>"
415
- f"<b>권고 조치:</b> 별도 조치 없이 거래 진행"
416
  )
417
 
 
 
 
 
 
 
418
  return f"""
419
  <div style="{CARD_STYLE}">
420
- {card_header("GEN 5 · FOUNDATION MODEL (LLM)", "초거대 추론 모델 (시뮬레이션)", dec, bg, fg, f"의심도 {prob*100:.0f}%")}
421
  <p style="font-size:13px; color:#666; margin:4px 0 6px;">컨텍스트 토큰화</p>
422
  <div style="background:#f5f5f0; padding:10px 12px; border-radius:6px; font-family:monospace; font-size:11px; line-height:1.7; margin-bottom:12px;">{context_html}</div>
423
  <p style="font-size:13px; color:#666; margin:4px 0 6px;">추론 체인 (Chain-of-Thought)</p>
424
  <table style="width:100%; font-size:13px; border-collapse:collapse; margin-bottom:12px;">
425
  <thead>
426
  <tr style="border-bottom:0.5px solid rgba(0,0,0,0.15);">
427
- <th style="text-align:left; padding:6px 4px; font-weight:500; color:#666; width:12%;">단계</th>
428
- <th style="text-align:left; padding:6px 4px; font-weight:500; color:#666; width:58%;">추론 내용</th>
429
- <th style="text-align:right; padding:6px 4px; font-weight:500; color:#666; width:30%;">Attention</th>
430
  </tr>
431
  </thead>
432
  <tbody>{cot_rows}</tbody>
433
  </table>
434
- <p style="font-size:13px; color:#666; margin:4px 0 6px;">생성된 자연어 판단 (Token 확률)</p>
435
- <div style="background:#FAEEDA; padding:12px 14px; border-radius:6px; font-size:13px; line-height:1.7; color:#412402;">{nl_judgment}</div>
436
  <details style="margin-top:10px;">
437
  <summary style="font-size:12px; color:#666; cursor:pointer;">생성 파라미터 보기</summary>
438
- <div style="background:#f5f5f0; padding:8px 12px; border-radius:6px; font-family:monospace; font-size:11px; margin-top:6px; line-height:1.6;">
439
- model: claude-sonnet-4-5 / temperature: 0.2 / top_p: 0.9 / max_tokens: 512<br>
440
- input_tokens: 142 / output_tokens: 98 / latency: 1.83s (시뮬레이션)
441
- </div>
442
  </details>
443
  <p style="font-size:12px; color:#888; margin:10px 0 0; font-style:italic;">강점: 사전 학습된 도메인 지식으로 '왜 사기인지' 자연어 설명 + 권고 조치까지 자동 생성</p>
444
  </div>
445
  """
446
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
447
  # ============================================================
448
  # 4. 메인 분석 함수
449
  # ============================================================
@@ -495,10 +669,18 @@ def analyze_transaction(amount, hour, new_payee, ratio):
495
  # 5. Gradio UI
496
  # ============================================================
497
  with gr.Blocks(theme=gr.themes.Default(), title="FDS 1-5세대 비교 데모") as demo:
498
- gr.HTML("""
 
 
 
 
 
 
 
499
  <div style="text-align:center; padding:10px 0;">
500
  <h1 style="margin:0;">🛡️ FDS 1-5세대 비교 데모</h1>
501
  <p style="color:#666; margin:6px 0 0;">실무 운영 담당자 시연용 · 판단 수식·가중치·근거 전체 노출 모드</p>
 
502
  </div>
503
  """)
504
 
 
4
  from sklearn.linear_model import LogisticRegression
5
  from xgboost import XGBClassifier
6
  import time
7
+ import os
8
+ import re
9
+ import json
10
+
11
+ # ============================================================
12
+ # 0-A. Claude API 클라이언트 초기화 (5세대용)
13
+ # ============================================================
14
+ # 환경변수 ANTHROPIC_API_KEY 가 설정되어 있으면 실제 API 호출 모드
15
+ # 없거나 호출 실패 시 자동으로 시뮬레이션 모드로 fallback
16
+ try:
17
+ from anthropic import Anthropic
18
+ _api_key = os.environ.get("ANTHROPIC_API_KEY")
19
+ if _api_key:
20
+ claude_client = Anthropic(api_key=_api_key)
21
+ CLAUDE_AVAILABLE = True
22
+ else:
23
+ claude_client = None
24
+ CLAUDE_AVAILABLE = False
25
+ except ImportError:
26
+ claude_client = None
27
+ CLAUDE_AVAILABLE = False
28
+
29
+ CLAUDE_MODEL = "claude-sonnet-4-6" # 2026년 5월 현재 권장 모델
30
 
31
  # ============================================================
32
  # 0. 공통 설정
 
384
  </div>
385
  """
386
 
387
+ # ============================================================
388
+ # 5세대 — Claude API 실제 호출
389
+ # ============================================================
390
+ def build_claude_prompt(amount, hour, new_payee_bin, ratio, prior_avg, prior_dec):
391
+ """Claude에게 보낼 시스템 프롬프트와 사용자 메시지 구성"""
392
+ system_prompt = (
393
+ "당신은 한국 은행의 FDS(이상금융거래탐지시스템) 분석 전문가입니다. "
394
+ "주어진 거래 정보와 1-4세대 모델의 사전 판단을 바탕으로 사기 여부를 종합 판정하세요. "
395
+ "특히 부동산 잔금 송금, 사업자 대금 결제 같은 정상 거래 패턴과 보이스피싱·대포통장 패턴을 구분해야 합니다. "
396
+ "반드시 다음 JSON 형식으로만 응답하세요(다른 텍스트 금지):\n"
397
+ '{\n'
398
+ ' "risk_score": <0.0~1.0 사이 사기 의심도>,\n'
399
+ ' "decision": "차단" | "추가 인증" | "통과",\n'
400
+ ' "reasoning_steps": [\n'
401
+ ' {"step": "<추론 단계>", "attention": <0.0~1.0>},\n'
402
+ ' ... (3~5개)\n'
403
+ ' ],\n'
404
+ ' "judgment": "<최종 판단 자연어 (왜 그렇게 판단했는지 + 권고 조치)>"\n'
405
+ '}'
406
+ )
407
+ payee_str = "신규" if new_payee_bin == 1 else "기존"
408
+ time_period = "새벽" if (hour <= 6 or hour >= 22) else ("주간" if 9 <= hour <= 18 else "저녁")
409
+ user_message = (
410
+ f"[거래 정보]\n"
411
+ f"- 금액: {amount:.0f}만원\n"
412
+ f"- 거래 시간: {int(hour):02d}시 ({time_period})\n"
413
+ f"- 수취인: {payee_str} 수취인\n"
414
+ f"- 과거 대비 배율: {ratio:.1f}배 (송금인의 평균 거래액 대비)\n\n"
415
+ f"[1-4세대 모델 사전 판단]\n"
416
+ f"- 평균 사기 확률: {prior_avg*100:.1f}%\n"
417
+ f"- 종합 판정: {prior_dec}\n\n"
418
+ f"위 거래에 대해 FDS 전문가 관점에서 종합 판정해주세요."
419
+ )
420
+ return system_prompt, user_message
421
+
422
+ def call_claude_api(amount, hour, new_payee_bin, ratio, prior_avg, prior_dec):
423
+ """실제 Claude API 호출. 성공 시 (parsed_dict, raw_meta) 반환, 실패 시 (None, error_msg)"""
424
+ if not CLAUDE_AVAILABLE:
425
+ return None, "API 키 미설정 (ANTHROPIC_API_KEY 환경변수 없음)"
426
+
427
+ system_prompt, user_message = build_claude_prompt(
428
+ amount, hour, new_payee_bin, ratio, prior_avg, prior_dec
429
+ )
430
+ try:
431
+ t0 = time.time()
432
+ response = claude_client.messages.create(
433
+ model=CLAUDE_MODEL,
434
+ max_tokens=1024,
435
+ temperature=0.2,
436
+ system=system_prompt,
437
+ messages=[{"role": "user", "content": user_message}]
438
+ )
439
+ latency = time.time() - t0
440
+
441
+ raw_text = response.content[0].text.strip()
442
+
443
+ # JSON 추출: 응답 중 첫 번째 { 부터 마지막 } 까지
444
+ json_match = re.search(r'\{.*\}', raw_text, re.DOTALL)
445
+ if not json_match:
446
+ return None, f"JSON 파싱 실패: {raw_text[:200]}"
447
+
448
+ parsed = json.loads(json_match.group(0))
449
+
450
+ meta = {
451
+ "input_tokens": response.usage.input_tokens,
452
+ "output_tokens": response.usage.output_tokens,
453
+ "latency": latency,
454
+ "model": CLAUDE_MODEL,
455
+ "raw_text": raw_text,
456
+ }
457
+ return parsed, meta
458
+ except Exception as e:
459
+ return None, f"API 호출 실패: {type(e).__name__}: {str(e)[:200]}"
460
+
461
+ def render_gen5_card(amount, hour, new_payee_bin, ratio, prior_avg, prior_dec,
462
+ prob, decision_text, reasoning_steps, judgment_text,
463
+ meta_html, source_label):
464
+ """5세대 카드 HTML 렌더링 (API/시뮬레이션 공용)"""
465
  dec, bg, fg = decide(prob)
 
 
466
 
467
  context_html = (
468
+ f"[SYS] 당신은 한국 은행의 FDS 분석 전문가입니다. 거래 정보와 1-4세대 사전 판단을 토대로 종합 판정하세요.<br>"
469
  f"[INPUT] amount={amount:.0f}만, hour={int(hour):02d}, "
470
  f"new_payee={'true' if new_payee_bin==1 else 'false'}, ratio={ratio:.1f}×<br>"
471
+ f"[PRIOR] 1-4세대 평균: {prior_avg*100:.1f}% / 종합: {prior_dec}<br>"
472
+ f"[TASK] JSON 형식으로 risk_score, decision, reasoning_steps, judgment 출력"
473
  )
474
 
475
+ cot_rows = ""
476
+ for i, step in enumerate(reasoning_steps, 1):
477
+ step_text = step.get("step", "") if isinstance(step, dict) else str(step)
478
+ attn = step.get("attention", 0.0) if isinstance(step, dict) else 0.0
479
+ try:
480
+ attn = float(attn)
481
+ except (ValueError, TypeError):
482
+ attn = 0.0
483
+ cot_rows += (
484
+ f"<tr><td style='padding:6px 4px;'>{i}</td>"
485
+ f"<td style='padding:6px 4px; color:#666;'>{step_text}</td>"
486
+ f"<td style='text-align:right; padding:6px 4px; font-family:monospace; color:#666;'>{attn:.2f}</td></tr>"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
487
  )
488
 
489
+ judg_color = "#3B6D11" if prob < 0.5 else "#633806"
490
+ judgment_html = (
491
+ f"<b style='color:{judg_color};'>{decision_text} 권고 (의심도 {prob*100:.0f}%)</b><br><br>"
492
+ f"{judgment_text}"
493
+ )
494
+
495
  return f"""
496
  <div style="{CARD_STYLE}">
497
+ {card_header("GEN 5 · FOUNDATION MODEL (LLM)", f"초거대 추론 모��� ({source_label})", dec, bg, fg, f"의심도 {prob*100:.0f}%")}
498
  <p style="font-size:13px; color:#666; margin:4px 0 6px;">컨텍스트 토큰화</p>
499
  <div style="background:#f5f5f0; padding:10px 12px; border-radius:6px; font-family:monospace; font-size:11px; line-height:1.7; margin-bottom:12px;">{context_html}</div>
500
  <p style="font-size:13px; color:#666; margin:4px 0 6px;">추론 체인 (Chain-of-Thought)</p>
501
  <table style="width:100%; font-size:13px; border-collapse:collapse; margin-bottom:12px;">
502
  <thead>
503
  <tr style="border-bottom:0.5px solid rgba(0,0,0,0.15);">
504
+ <th style="text-align:left; padding:6px 4px; font-weight:500; color:#666; width:8%;">단계</th>
505
+ <th style="text-align:left; padding:6px 4px; font-weight:500; color:#666; width:72%;">추론 내용</th>
506
+ <th style="text-align:right; padding:6px 4px; font-weight:500; color:#666; width:20%;">Attention</th>
507
  </tr>
508
  </thead>
509
  <tbody>{cot_rows}</tbody>
510
  </table>
511
+ <p style="font-size:13px; color:#666; margin:4px 0 6px;">생성된 자연어 판단</p>
512
+ <div style="background:#FAEEDA; padding:12px 14px; border-radius:6px; font-size:13px; line-height:1.7; color:#412402;">{judgment_html}</div>
513
  <details style="margin-top:10px;">
514
  <summary style="font-size:12px; color:#666; cursor:pointer;">생성 파라미터 보기</summary>
515
+ <div style="background:#f5f5f0; padding:8px 12px; border-radius:6px; font-family:monospace; font-size:11px; margin-top:6px; line-height:1.6;">{meta_html}</div>
 
 
 
516
  </details>
517
  <p style="font-size:12px; color:#888; margin:10px 0 0; font-style:italic;">강점: 사전 학습된 도메인 지식으로 '왜 사기인지' 자연어 설명 + 권고 조치까지 자동 생성</p>
518
  </div>
519
  """
520
 
521
+ def render_gen5_simulation(amount, hour, new_payee_bin, ratio, prior_avg, prior_dec):
522
+ """API 호출 실패/미설정 시 fallback용 시뮬레이션"""
523
+ is_high_risk = (new_payee_bin == 1 and (hour <= 6 or hour >= 22) and amount >= 500)
524
+ prob = 0.95 if is_high_risk else min(prior_avg + 0.02, 0.98)
525
+ payee_str = "신규 수취인" if new_payee_bin == 1 else "기존 수취인"
526
+ time_str = "새벽" if (hour <= 6 or hour >= 22) else "일반"
527
+
528
+ reasoning_steps = [
529
+ {"step": f"시간 {int(hour):02d}시 + {payee_str} → 보이스피싱 정형 패턴 매칭", "attention": 0.42},
530
+ {"step": f"평소 {ratio:.1f}배 금액 → 송금인 평소 행동 이탈도 측정", "attention": 0.28},
531
+ {"step": f"4세대 GNN 신호 + {amount:.0f}만원 고액 → 위험 가중", "attention": 0.21},
532
+ {"step": "최근 보이스피싱 트렌드 사전 지식 활용", "attention": 0.09},
533
+ ]
534
+
535
+ if is_high_risk:
536
+ decision_text = "차단"
537
+ judgment_text = (
538
+ f"{int(hour):02d}시 {time_str} 시간대에 평소보다 {ratio:.1f}배 급증한 {amount:.0f}만원이 "
539
+ f"<u>{payee_str}</u>에게 이체되는 것은 전형적인 보이스피싱 패턴입니다. "
540
+ f"수취인 계좌가 사기 의심 계좌와 2-hop 거리에 있다는 GNN 신호까지 더해져 위험도가 매우 높습니다.<br><br>"
541
+ f"<b>권고 조치:</b> ① 즉시 거래 보류, ② 등록된 전화번호로 본인 직접 확인, ③ 확인 전 자금 동결 24시간 유지"
542
+ )
543
+ elif prob >= 0.5:
544
+ decision_text = "추가 인증"
545
+ judgment_text = (
546
+ f"{int(hour):02d}시 거래에서 일부 이상 신호({ratio:.1f}배 금액, {payee_str})가 감지되었으나 "
547
+ f"결정적 위험 패턴은 아닙니다. 차단보다는 추가 인증으로 본인 의사를 확인하는 것이 적절합니다.<br><br>"
548
+ f"<b>권고 조치:</b> ① ARS 또는 OTP 추가 인증, ② 송금 의도 재확인 메시지 발송"
549
+ )
550
+ else:
551
+ decision_text = "통과"
552
+ judgment_text = (
553
+ f"{int(hour):02d}시 거래의 패턴이 송금인의 평소 행동 범위 내에 있으며, "
554
+ f"1-4세대 모델 모두 위험 신호를 강하게 보내지 않았습니다. 정상 거래로 판단됩니다.<br><br>"
555
+ f"<b>권고 조치:</b> 별도 조치 없이 거래 진행"
556
+ )
557
+
558
+ meta_html = (
559
+ "mode: <b>SIMULATION</b> (API 미연결)<br>"
560
+ "이 결과는 if-else 하드코딩으로 생성된 시뮬레이션입니다.<br>"
561
+ "실제 Claude 호출을 활성화하려면 ANTHROPIC_API_KEY 환경변수를 설정하세요."
562
+ )
563
+
564
+ return render_gen5_card(
565
+ amount, hour, new_payee_bin, ratio, prior_avg, prior_dec,
566
+ prob, decision_text, reasoning_steps, judgment_text,
567
+ meta_html, source_label="시뮬레이션 모드"
568
+ )
569
+
570
+ def render_gen5(amount, hour, new_payee_bin, ratio, prior_avg):
571
+ """5세대 진입점: API 가능 시 Claude 호출, 실패 시 시뮬레이션 fallback"""
572
+ prior_dec, _, _ = decide(prior_avg)
573
+
574
+ if not CLAUDE_AVAILABLE:
575
+ return render_gen5_simulation(amount, hour, new_payee_bin, ratio, prior_avg, prior_dec)
576
+
577
+ parsed, meta_or_err = call_claude_api(amount, hour, new_payee_bin, ratio, prior_avg, prior_dec)
578
+
579
+ if parsed is None:
580
+ fallback_html = render_gen5_simulation(amount, hour, new_payee_bin, ratio, prior_avg, prior_dec)
581
+ warning = (
582
+ f'<div style="background:#FCEBEB; border-left:3px solid #A32D2D; padding:10px 14px; '
583
+ f'border-radius:6px; margin-bottom:10px; font-size:13px; color:#4A1B0C;">'
584
+ f'⚠️ Claude API 호출 실패. 시뮬레이션 모드로 대체합니다.<br>'
585
+ f'<span style="font-family:monospace; font-size:11px; color:#712B13;">사유: {meta_or_err}</span>'
586
+ f'</div>'
587
+ )
588
+ return warning + fallback_html
589
+
590
+ try:
591
+ prob = float(parsed.get("risk_score", 0.5))
592
+ prob = max(0.0, min(1.0, prob))
593
+ decision_text = parsed.get("decision", "추가 인증")
594
+ reasoning_steps = parsed.get("reasoning_steps", [])
595
+ judgment_text = parsed.get("judgment", "(판단 내용 누락)")
596
+
597
+ meta = meta_or_err
598
+ meta_html = (
599
+ f"model: <b>{meta['model']}</b> / temperature: 0.2 / max_tokens: 1024<br>"
600
+ f"input_tokens: {meta['input_tokens']} / output_tokens: {meta['output_tokens']} / "
601
+ f"latency: {meta['latency']:.2f}s<br>"
602
+ f"mode: <b style='color:#3B6D11;'>LIVE API CALL ✓</b>"
603
+ )
604
+
605
+ return render_gen5_card(
606
+ amount, hour, new_payee_bin, ratio, prior_avg, prior_dec,
607
+ prob, decision_text, reasoning_steps, judgment_text,
608
+ meta_html, source_label=f"실제 {CLAUDE_MODEL}"
609
+ )
610
+ except (KeyError, ValueError, TypeError) as e:
611
+ fallback_html = render_gen5_simulation(amount, hour, new_payee_bin, ratio, prior_avg, prior_dec)
612
+ warning = (
613
+ f'<div style="background:#FCEBEB; border-left:3px solid #A32D2D; padding:10px 14px; '
614
+ f'border-radius:6px; margin-bottom:10px; font-size:13px; color:#4A1B0C;">'
615
+ f'⚠️ Claude 응답 파싱 실패. 시뮬레이션 모드로 대체합니다.<br>'
616
+ f'<span style="font-family:monospace; font-size:11px; color:#712B13;">'
617
+ f'{type(e).__name__}: {str(e)[:150]}</span></div>'
618
+ )
619
+ return warning + fallback_html
620
+
621
  # ============================================================
622
  # 4. 메인 분석 함수
623
  # ============================================================
 
669
  # 5. Gradio UI
670
  # ============================================================
671
  with gr.Blocks(theme=gr.themes.Default(), title="FDS 1-5세대 비교 데모") as demo:
672
+ _api_badge = (
673
+ f'<span style="background:#EAF3DE; color:#3B6D11; padding:3px 10px; border-radius:8px; font-size:12px; font-weight:500;">'
674
+ f'● Claude API 연결됨 ({CLAUDE_MODEL})</span>'
675
+ if CLAUDE_AVAILABLE else
676
+ '<span style="background:#FAEEDA; color:#854F0B; padding:3px 10px; border-radius:8px; font-size:12px; font-weight:500;">'
677
+ '○ API 미연결 (5세대 시뮬레이션 모드)</span>'
678
+ )
679
+ gr.HTML(f"""
680
  <div style="text-align:center; padding:10px 0;">
681
  <h1 style="margin:0;">🛡️ FDS 1-5세대 비교 데모</h1>
682
  <p style="color:#666; margin:6px 0 0;">실무 운영 담당자 시연용 · 판단 수식·가중치·근거 전체 노출 모드</p>
683
+ <div style="margin-top:8px;">{_api_badge}</div>
684
  </div>
685
  """)
686