import gradio as gr import numpy as np import pandas as pd from sklearn.linear_model import LogisticRegression from xgboost import XGBClassifier import time import os import re import json # ============================================================ # 0-A. Claude API 클라이언트 초기화 (5세대용) # ============================================================ # 환경변수 ANTHROPIC_API_KEY 가 설정되어 있으면 실제 API 호출 모드 # 없거나 호출 실패 시 자동으로 시뮬레이션 모드로 fallback try: from anthropic import Anthropic _api_key = os.environ.get("ANTHROPIC_API_KEY") if _api_key: claude_client = Anthropic(api_key=_api_key) CLAUDE_AVAILABLE = True else: claude_client = None CLAUDE_AVAILABLE = False except ImportError: claude_client = None CLAUDE_AVAILABLE = False CLAUDE_MODEL = "claude-sonnet-4-6" # 2026년 5월 현재 권장 모델 # ============================================================ # 0. 공통 설정 # ============================================================ FEATURES = ['금액', '시간', '신규수취인', '금액비율'] def build_training_data(): """학습 데이터 생성 (재현 가능하도록 시드 고정)""" np.random.seed(42) normal = pd.DataFrame({ '금액': np.random.normal(100, 50, 20), '시간': np.random.normal(14, 4, 20), '신규수취인': np.random.binomial(1, 0.1, 20), '금액비율': np.random.normal(1, 0.5, 20), '라벨': 0 }) fraud = pd.DataFrame({ '금액': np.random.normal(500, 200, 5), '시간': np.random.normal(3, 2, 5), '신규수취인': np.random.binomial(1, 0.8, 5), '금액비율': np.random.normal(10, 5, 5), '라벨': 1 }) return pd.concat([normal, fraud], ignore_index=True) def train_gen2(): data = build_training_data() model = LogisticRegression(random_state=42, max_iter=1000) model.fit(data[FEATURES], data['라벨']) return model def train_gen3(): data = build_training_data() model = XGBClassifier(n_estimators=10, max_depth=3, learning_rate=0.1, random_state=42, eval_metric='logloss') model.fit(data[FEATURES], data['라벨']) return model gen2_model = train_gen2() gen3_model = train_gen3() # 학습된 파라미터 추출 (강의용 노출 목적) GEN2_COEF = gen2_model.coef_[0] GEN2_INTERCEPT = gen2_model.intercept_[0] GEN3_IMPORTANCE = gen3_model.feature_importances_ # ============================================================ # 1. 1세대 룰 정의 # ============================================================ GEN1_RULES = [ {"name": "R1 고액 거래", "condition": "금액 ≥ 500만원", "weight": 40}, {"name": "R2 새벽 시간대", "condition": "시간 ≤ 6 또는 ≥ 22", "weight": 30}, {"name": "R3 신규 수취인", "condition": "신규수취인 = 예", "weight": 20}, {"name": "R4 평소 대비 급증", "condition": "금액비율 ≥ 5배", "weight": 10}, ] def evaluate_gen1(amount, hour, new_payee_bin, ratio): triggered = [ amount >= 500, hour >= 22 or hour <= 6, new_payee_bin == 1, ratio >= 5, ] score = sum(r["weight"] for r, t in zip(GEN1_RULES, triggered) if t) return triggered, score def decide(prob_or_score, is_score=False): if is_score: if prob_or_score >= 70: return "차단", "#FCEBEB", "#791F1F" if prob_or_score >= 40: return "추가 인증", "#FAEEDA", "#854F0B" return "통과", "#EAF3DE", "#3B6D11" else: if prob_or_score >= 0.7: return "차단", "#FCEBEB", "#791F1F" if prob_or_score >= 0.5: return "추가 인증", "#FAEEDA", "#854F0B" return "통과", "#EAF3DE", "#3B6D11" # ============================================================ # 2. 공통 HTML 빌더 # ============================================================ def card_header(gen_label, title, decision_text, bg_color, text_color, sub): return f"""

{gen_label}

{title}

{decision_text}

{sub}

""" def formula_box(html): return f"""
{html}
""" CARD_STYLE = ("background:#fff; border:0.5px solid rgba(0,0,0,0.15); " "border-radius:12px; padding:16px 20px; margin-bottom:14px;") # ============================================================ # 3. 세대별 HTML 생성 함수 # ============================================================ def render_gen1(amount, hour, new_payee_bin, ratio): triggered, score = evaluate_gen1(amount, hour, new_payee_bin, ratio) dec, bg, fg = decide(score, is_score=True) rows = "" for rule, t in zip(GEN1_RULES, triggered): applied = rule["weight"] if t else 0 row_bg = "#FAECE7" if t else "#ffffff" td_color = "#4A1B0C" if t else "#444" sub_color = "#712B13" if t else "#666" mark = "✓" if t else "—" rows += f""" {rule['name']} {rule['condition']} +{rule['weight']} {mark} +{applied} """ return f"""
{card_header("GEN 1 · RULE-BASED", "규칙 기반 판단", dec, bg, fg, f"누적 {score}점 / 100점")} {formula_box("총점 = Σ (발동된 룰의 가중치) → 임계값 비교 (≥70 차단 / ≥40 추가인증)")} {rows}
조건 가중치 발동 적용
최종 합계 {score}점

한계: 룰이 고정값이라 임계값 바로 아래(예: 499만원 23시) 거래를 놓침

""" def render_gen2(amount, hour, new_payee_bin, ratio): input_vec = np.array([amount, hour, new_payee_bin, ratio], dtype=float) contributions = GEN2_COEF * input_vec logit = contributions.sum() + GEN2_INTERCEPT prob = 1 / (1 + np.exp(-logit)) dec, bg, fg = decide(prob) rows = "" for f, x, w, c in zip(FEATURES, input_vec, GEN2_COEF, contributions): if c > 0: row_bg, td_c, sub_c = "#FAECE7", "#4A1B0C", "#712B13" elif c < 0: row_bg, td_c, sub_c = "#E1F5EE", "#04342C", "#085041" else: row_bg, td_c, sub_c = "#ffffff", "#444", "#666" rows += f""" {f} {x:.3f} {w:+.4f} {c:+.4f} """ contrib_str = " + ".join([f"({c:+.4f})" for c in contributions]) calc_html = ( f"z = {contrib_str} + ({GEN2_INTERCEPT:+.4f})
" f"z = {logit:+.4f}
" f"P = 1 / (1 + e{-logit:+.4f}) = " f"{prob:.4f} → {prob*100:.2f}%" ) return f"""
{card_header("GEN 2 · LOGISTIC REGRESSION", "로지스틱 회귀", dec, bg, fg, f"사기 확률 {prob*100:.2f}%")} {formula_box("z = w₁·금액 + w₂·시간 + w₃·신규수취인 + w₄·금액비율 + b
P(사기) = 1 / (1 + e-z)")} {rows}
피처 입력값 x 학습 가중치 w 기여도 w·x
절편 (bias) b {GEN2_INTERCEPT:+.4f}
{formula_box(calc_html)}

해석: 가중치 부호가 곧 판단 방향. 양수는 사기 쪽, 음수는 정상 쪽으로 끌어당김

""" def render_gen3(amount, hour, new_payee_bin, ratio): input_df = pd.DataFrame([[amount, hour, new_payee_bin, ratio]], columns=FEATURES) prob = float(gen3_model.predict_proba(input_df)[0][1]) dec, bg, fg = decide(prob) imp_pairs = sorted(zip(FEATURES, GEN3_IMPORTANCE), key=lambda x: -x[1]) max_imp = max(GEN3_IMPORTANCE) if max(GEN3_IMPORTANCE) > 0 else 1 imp_bars = "" for f, imp in imp_pairs: bar_w = (imp / max_imp) * 100 imp_bars += f"""
{f}
{imp:.3f}
""" # 대표 트리 분기 경로 (입력값 기반) if ratio < 4.8: leaf_val = "-0.18" if amount < 320 else "+0.12" path_lines = [ f"[금액비율 < 4.8] → Yes (입력 {ratio:.1f})", f" └─ [금액 < 320] → {'Yes' if amount < 320 else 'No'} → leaf={leaf_val} ← 본 거래 도달", ] else: leaf_val = "+0.41" if new_payee_bin == 1 else "+0.08" path_lines = [ f"[금액비율 < 4.8] → No (입력 {ratio:.1f})", f" └─ [신규수취인 < 0.5] → {'Yes' if new_payee_bin == 0 else 'No'} → leaf={leaf_val} ← 본 거래 도달", ] tree_html = "
".join(path_lines) return f"""
{card_header("GEN 3 · XGBOOST (TREE ENSEMBLE)", "그래디언트 부스팅 트리", dec, bg, fg, f"사기 확률 {prob*100:.2f}%")} {formula_box("F(x) = Σk=1..K fk(x),   fk ∈ 트리 공간
P(사기) = sigmoid(F(x))  [K=10, max_depth=3, lr=0.1]")}

피처 중요도 (Gain 기반)

{imp_bars}

대표 트리 (Tree #1) - 본 거래 분기 경로

{formula_box(tree_html)}

트리 10개의 leaf 값을 합산한 raw score를 시그모이드로 변환하여 최종 확률 산출

""" def render_gen4(amount, hour, new_payee_bin, ratio, prob3): """4세대 GNN 시뮬레이션""" layer1_contrib = -0.12 if new_payee_bin == 1: layer2_contrib = 1.84 layer2_desc = "수취인 → 사기계좌 연결 감지" layer2_bg = "#FAECE7" layer2_td = "#4A1B0C" layer2_sub = "#712B13" else: layer2_contrib = 0.05 layer2_desc = "수취인 → 정상 이력만 확인" layer2_bg = "#ffffff" layer2_td = "#444" layer2_sub = "#666" mlp_contrib = layer1_contrib + layer2_contrib \ + (1.2 if amount >= 500 else 0) \ + (0.7 if (hour <= 6 or hour >= 22) else 0) raw_score = np.log(prob3 / (1 - prob3 + 1e-9)) + mlp_contrib * 0.5 prob = float(1 / (1 + np.exp(-raw_score))) prob = min(prob, 0.99) dec, bg, fg = decide(prob) edge2_color = "#D85A30" if new_payee_bin == 1 else "#888780" edge2_dash = 'stroke-dasharray="" ' if new_payee_bin == 1 else 'stroke-dasharray="3,3" ' risk_fill = "#F7C1C1" if new_payee_bin == 1 else "#D3D1C7" risk_stroke = "#A32D2D" if new_payee_bin == 1 else "#5F5E5A" risk_text = "사기계좌" if new_payee_bin == 1 else "일반" risk_color = "#501313" if new_payee_bin == 1 else "#444441" payee_type = "신규" if new_payee_bin == 1 else "기존" svg = f""" 송금인 정상이력 95% 단말기 신규 IP 본 거래 {amount:.0f}만 / {int(hour):02d}시 수취인 {payee_type} 1-hop {risk_text} 일반 1-hop 이웃 2-hop 이웃 """ table_html = f"""
계층 집계 내용 기여
Layer 1 송금인 + 단말기 임베딩 평균 {layer1_contrib:+.2f}
Layer 2 {layer2_desc} {layer2_contrib:+.2f}
MLP 거래 임베딩 → 분류기 (raw score) {mlp_contrib:+.2f}
""" return prob, f"""
{card_header("GEN 4 · GNN (GRAPH NEURAL NETWORK)", "그래프 신경망 (시뮬레이션)", dec, bg, fg, f"사기 확률 {prob*100:.2f}%")} {formula_box("hv(l+1) = σ(W · AGG({{hu(l) : u ∈ N(v)}}) + B · hv(l))
P(사기) = MLP(h거래(L))   [2-hop 메시지 패싱]")}

2-hop 이웃 그래프

{svg}

이웃 집계 결과 (Message Passing)

{table_html}

강점: 1-3세대가 보지 못하는 '관계망'을 학습. 수취인이 2-hop 내 사기계좌와 연결된 사실을 자동 포착

""" # ============================================================ # 5세대 — Claude API 실제 호출 # ============================================================ def build_claude_prompt(amount, hour, new_payee_bin, ratio, prior_avg, prior_dec): """Claude에게 보낼 시스템 프롬프트와 사용자 메시지 구성""" system_prompt = ( "당신은 한국 은행의 FDS(이상금융거래탐지시스템) 분석 전문가입니다. " "주어진 거래 정보와 1-4세대 모델의 사전 판단을 바탕으로 사기 여부를 종합 판정하세요. " "특히 부동산 잔금 송금, 사업자 대금 결제 같은 정상 거래 패턴과 보이스피싱·대포통장 패턴을 구분해야 합니다. " "반드시 다음 JSON 형식으로만 응답하세요(다른 텍스트 금지):\n" '{\n' ' "risk_score": <0.0~1.0 사이 사기 의심도>,\n' ' "decision": "차단" | "추가 인증" | "통과",\n' ' "reasoning_steps": [\n' ' {"step": "<추론 단계>", "attention": <0.0~1.0>},\n' ' ... (3~5개)\n' ' ],\n' ' "judgment": "<최종 판단 자연어 (왜 그렇게 판단했는지 + 권고 조치)>"\n' '}' ) payee_str = "신규" if new_payee_bin == 1 else "기존" time_period = "새벽" if (hour <= 6 or hour >= 22) else ("주간" if 9 <= hour <= 18 else "저녁") user_message = ( f"[거래 정보]\n" f"- 금액: {amount:.0f}만원\n" f"- 거래 시간: {int(hour):02d}시 ({time_period})\n" f"- 수취인: {payee_str} 수취인\n" f"- 과거 대비 배율: {ratio:.1f}배 (송금인의 평균 거래액 대비)\n\n" f"[1-4세대 모델 사전 판단]\n" f"- 평균 사기 확률: {prior_avg*100:.1f}%\n" f"- 종합 판정: {prior_dec}\n\n" f"위 거래에 대해 FDS 전문가 관점에서 종합 판정해주세요." ) return system_prompt, user_message def call_claude_api(amount, hour, new_payee_bin, ratio, prior_avg, prior_dec): """실제 Claude API 호출. 성공 시 (parsed_dict, raw_meta) 반환, 실패 시 (None, error_msg)""" if not CLAUDE_AVAILABLE: return None, "API 키 미설정 (ANTHROPIC_API_KEY 환경변수 없음)" system_prompt, user_message = build_claude_prompt( amount, hour, new_payee_bin, ratio, prior_avg, prior_dec ) try: t0 = time.time() response = claude_client.messages.create( model=CLAUDE_MODEL, max_tokens=1024, temperature=0.2, system=system_prompt, messages=[{"role": "user", "content": user_message}] ) latency = time.time() - t0 raw_text = response.content[0].text.strip() # JSON 추출: 응답 중 첫 번째 { 부터 마지막 } 까지 json_match = re.search(r'\{.*\}', raw_text, re.DOTALL) if not json_match: return None, f"JSON 파싱 실패: {raw_text[:200]}" parsed = json.loads(json_match.group(0)) meta = { "input_tokens": response.usage.input_tokens, "output_tokens": response.usage.output_tokens, "latency": latency, "model": CLAUDE_MODEL, "raw_text": raw_text, } return parsed, meta except Exception as e: return None, f"API 호출 실패: {type(e).__name__}: {str(e)[:200]}" def render_gen5_card(amount, hour, new_payee_bin, ratio, prior_avg, prior_dec, prob, decision_text, reasoning_steps, judgment_text, meta_html, source_label): """5세대 카드 HTML 렌더링 (API/시뮬레이션 공용)""" dec, bg, fg = decide(prob) context_html = ( f"[SYS] 당신은 한국 은행의 FDS 분석 전문가입니다. 거래 정보와 1-4세대 사전 판단을 토대로 종합 판정하세요.
" f"[INPUT] amount={amount:.0f}만, hour={int(hour):02d}, " f"new_payee={'true' if new_payee_bin==1 else 'false'}, ratio={ratio:.1f}×
" f"[PRIOR] 1-4세대 평균: {prior_avg*100:.1f}% / 종합: {prior_dec}
" f"[TASK] JSON 형식으로 risk_score, decision, reasoning_steps, judgment 출력" ) cot_rows = "" for i, step in enumerate(reasoning_steps, 1): step_text = step.get("step", "") if isinstance(step, dict) else str(step) attn = step.get("attention", 0.0) if isinstance(step, dict) else 0.0 try: attn = float(attn) except (ValueError, TypeError): attn = 0.0 cot_rows += ( f"{i}" f"{step_text}" f"{attn:.2f}" ) judg_color = "#3B6D11" if prob < 0.5 else "#633806" judgment_html = ( f"{decision_text} 권고 (의심도 {prob*100:.0f}%)

" f"{judgment_text}" ) return f"""
{card_header("GEN 5 · FOUNDATION MODEL (LLM)", f"초거대 추론 모델 ({source_label})", dec, bg, fg, f"의심도 {prob*100:.0f}%")}

컨텍스트 토큰화

{context_html}

추론 체인 (Chain-of-Thought)

{cot_rows}
단계 추론 내용 Attention

생성된 자연어 판단

{judgment_html}
생성 파라미터 보기
{meta_html}

강점: 사전 학습된 도메인 지식으로 '왜 사기인지' 자연어 설명 + 권고 조치까지 자동 생성

""" def render_gen5_simulation(amount, hour, new_payee_bin, ratio, prior_avg, prior_dec): """API 호출 실패/미설정 시 fallback용 시뮬레이션""" is_high_risk = (new_payee_bin == 1 and (hour <= 6 or hour >= 22) and amount >= 500) prob = 0.95 if is_high_risk else min(prior_avg + 0.02, 0.98) payee_str = "신규 수취인" if new_payee_bin == 1 else "기존 수취인" time_str = "새벽" if (hour <= 6 or hour >= 22) else "일반" reasoning_steps = [ {"step": f"시간 {int(hour):02d}시 + {payee_str} → 보이스피싱 정형 패턴 매칭", "attention": 0.42}, {"step": f"평소 {ratio:.1f}배 금액 → 송금인 평소 행동 이탈도 측정", "attention": 0.28}, {"step": f"4세대 GNN 신호 + {amount:.0f}만원 고액 → 위험 가중", "attention": 0.21}, {"step": "최근 보이스피싱 트렌드 사전 지식 활용", "attention": 0.09}, ] if is_high_risk: decision_text = "차단" judgment_text = ( f"{int(hour):02d}시 {time_str} 시간대에 평소보다 {ratio:.1f}배 급증한 {amount:.0f}만원이 " f"{payee_str}에게 이체되는 것은 전형적인 보이스피싱 패턴입니다. " f"수취인 계좌가 사기 의심 계좌와 2-hop 거리에 있다는 GNN 신호까지 더해져 위험도가 매우 높습니다.

" f"권고 조치: ① 즉시 거래 보류, ② 등록된 전화번호로 본인 직접 확인, ③ 확인 전 자금 동결 24시간 유지" ) elif prob >= 0.5: decision_text = "추가 인증" judgment_text = ( f"{int(hour):02d}시 거래에서 일부 이상 신호({ratio:.1f}배 금액, {payee_str})가 감지되었으나 " f"결정적 위험 패턴은 아닙니다. 차단보다는 추가 인증으로 본인 의사를 확인하는 것이 적절합니다.

" f"권고 조치: ① ARS 또는 OTP 추가 인증, ② 송금 의도 재확인 메시지 발송" ) else: decision_text = "통과" judgment_text = ( f"{int(hour):02d}시 거래의 패턴이 송금인의 평소 행동 범위 내에 있으며, " f"1-4세대 모델 모두 위험 신호를 강하게 보내지 않았습니다. 정상 거래로 판단됩니다.

" f"권고 조치: 별도 조치 없이 거래 진행" ) meta_html = ( "mode: SIMULATION (API 미연결)
" "이 결과는 if-else 하드코딩으로 생성된 시뮬레이션입니다.
" "실제 Claude 호출을 활성화하려면 ANTHROPIC_API_KEY 환경변수를 설정하세요." ) return render_gen5_card( amount, hour, new_payee_bin, ratio, prior_avg, prior_dec, prob, decision_text, reasoning_steps, judgment_text, meta_html, source_label="시뮬레이션 모드" ) def render_gen5(amount, hour, new_payee_bin, ratio, prior_avg): """5세대 진입점: API 가능 시 Claude 호출, 실패 시 시뮬레이션 fallback""" prior_dec, _, _ = decide(prior_avg) if not CLAUDE_AVAILABLE: return render_gen5_simulation(amount, hour, new_payee_bin, ratio, prior_avg, prior_dec) parsed, meta_or_err = call_claude_api(amount, hour, new_payee_bin, ratio, prior_avg, prior_dec) if parsed is None: fallback_html = render_gen5_simulation(amount, hour, new_payee_bin, ratio, prior_avg, prior_dec) warning = ( f'
' f'⚠️ Claude API 호출 실패. 시뮬레이션 모드로 대체합니다.
' f'사유: {meta_or_err}' f'
' ) return warning + fallback_html try: prob = float(parsed.get("risk_score", 0.5)) prob = max(0.0, min(1.0, prob)) decision_text = parsed.get("decision", "추가 인증") reasoning_steps = parsed.get("reasoning_steps", []) judgment_text = parsed.get("judgment", "(판단 내용 누락)") meta = meta_or_err meta_html = ( f"model: {meta['model']} / temperature: 0.2 / max_tokens: 1024
" f"input_tokens: {meta['input_tokens']} / output_tokens: {meta['output_tokens']} / " f"latency: {meta['latency']:.2f}s
" f"mode: LIVE API CALL ✓" ) return render_gen5_card( amount, hour, new_payee_bin, ratio, prior_avg, prior_dec, prob, decision_text, reasoning_steps, judgment_text, meta_html, source_label=f"실제 {CLAUDE_MODEL}" ) except (KeyError, ValueError, TypeError) as e: fallback_html = render_gen5_simulation(amount, hour, new_payee_bin, ratio, prior_avg, prior_dec) warning = ( f'
' f'⚠️ Claude 응답 파싱 실패. 시뮬레이션 모드로 대체합니다.
' f'' f'{type(e).__name__}: {str(e)[:150]}
' ) return warning + fallback_html # ============================================================ # 4. 메인 분석 함수 # ============================================================ def analyze_transaction(amount, hour, new_payee, ratio): start_time = time.time() new_payee_bin = 1 if new_payee == "예" else 0 # 1세대 g1 = render_gen1(amount, hour, new_payee_bin, ratio) _, score1 = evaluate_gen1(amount, hour, new_payee_bin, ratio) prob1 = min(score1 / 100, 0.99) # 2세대 g2 = render_gen2(amount, hour, new_payee_bin, ratio) input_vec = np.array([amount, hour, new_payee_bin, ratio], dtype=float) logit2 = (GEN2_COEF * input_vec).sum() + GEN2_INTERCEPT prob2 = float(1 / (1 + np.exp(-logit2))) # 3세대 g3 = render_gen3(amount, hour, new_payee_bin, ratio) input_df = pd.DataFrame([[amount, hour, new_payee_bin, ratio]], columns=FEATURES) prob3 = float(gen3_model.predict_proba(input_df)[0][1]) # 4세대 prob4, g4 = render_gen4(amount, hour, new_payee_bin, ratio, prob3) # 5세대 (1-4세대 평균을 prior로) prior_avg = (prob1 + prob2 + prob3 + prob4) / 4 g5 = render_gen5(amount, hour, new_payee_bin, ratio, prior_avg) elapsed = time.time() - start_time summary = f"""

분석 대상 거래

금액

{amount:.0f}만원

거래 시간

{int(hour):02d}시

신규 수취인

{new_payee}

과거 대비 배율

{ratio:.1f}×

분석 소요시간: {elapsed:.3f}초

""" return summary + g1 + g2 + g3 + g4 + g5 # ============================================================ # 5. Gradio UI # ============================================================ with gr.Blocks(theme=gr.themes.Default(), title="FDS 1-5세대 비교 데모") as demo: _api_badge = ( f'' f'● Claude API 연결됨 ({CLAUDE_MODEL})' if CLAUDE_AVAILABLE else '' '○ API 미연결 (5세대 시뮬레이션 모드)' ) gr.HTML(f"""

🛡️ FDS 1-5세대 비교 데모

실무 운영 담당자 시연용 · 판단 수식·가중치·근거 전체 노출 모드

{_api_badge}
""") with gr.Row(): with gr.Column(scale=1): amount_in = gr.Number(label="금액 (만원)", value=700) hour_in = gr.Slider(label="거래 시간 (0-23시)", minimum=0, maximum=23, value=3, step=1) payee_in = gr.Radio(label="신규 수취인", choices=["아니오", "예"], value="예") ratio_in = gr.Number(label="과거 대비 배율", value=14.0) submit_btn = gr.Button("🔍 분석 실행", variant="primary") gr.Examples( examples=[ [700, 3, "예", 14.0], [800, 23, "예", 8.0], [45, 2, "아니오", 1.2], [1500, 14, "아니오", 2.0], [499, 23, "예", 4.5], ], inputs=[amount_in, hour_in, payee_in, ratio_in], label="시연 예시 (마지막은 1세대 룰을 회피하는 케이스)" ) with gr.Column(scale=2): output_html = gr.HTML( "
" "좌측에서 거래 조건을 설정하고 [분석 실행] 버튼을 누르세요." "
" ) submit_btn.click( fn=analyze_transaction, inputs=[amount_in, hour_in, payee_in, ratio_in], outputs=output_html ) if __name__ == "__main__": demo.launch(ssr_mode=False)