ableman82's picture
Update app.py
ba12523 verified
Raw
History Blame
37.2 kB
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"""
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:12px;">
<div>
<p style="font-size:11px; color:#888; margin:0; letter-spacing:0.5px;">{gen_label}</p>
<p style="font-size:16px; font-weight:500; margin:2px 0 0;">{title}</p>
</div>
<div style="text-align:right;">
<span style="background:{bg_color}; color:{text_color}; font-size:12px; padding:4px 12px; border-radius:8px; font-weight:500;">{decision_text}</span>
<p style="font-size:13px; color:#666; margin:4px 0 0;">{sub}</p>
</div>
</div>
"""
def formula_box(html):
return f"""<div style="background:#f5f5f0; padding:10px 12px; border-radius:6px; font-family:'Courier New',monospace; font-size:12px; margin-bottom:10px; line-height:1.6;">{html}</div>"""
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"""
<tr style="background:{row_bg};">
<td style="padding:6px 4px; color:{td_color};">{rule['name']}</td>
<td style="padding:6px 4px; color:{sub_color};">{rule['condition']}</td>
<td style="text-align:center; padding:6px 4px; color:{sub_color};">+{rule['weight']}</td>
<td style="text-align:center; padding:6px 4px; color:{sub_color};">{mark}</td>
<td style="text-align:right; padding:6px 4px; font-weight:500; color:{td_color};">+{applied}</td>
</tr>"""
return f"""
<div style="{CARD_STYLE}">
{card_header("GEN 1 ยท RULE-BASED", "๊ทœ์น™ ๊ธฐ๋ฐ˜ ํŒ๋‹จ", dec, bg, fg, f"๋ˆ„์  {score}์  / 100์ ")}
{formula_box("์ด์  = ฮฃ (๋ฐœ๋™๋œ ๋ฃฐ์˜ ๊ฐ€์ค‘์น˜) โ†’ ์ž„๊ณ„๊ฐ’ ๋น„๊ต (โ‰ฅ70 ์ฐจ๋‹จ / โ‰ฅ40 ์ถ”๊ฐ€์ธ์ฆ)")}
<table style="width:100%; font-size:13px; border-collapse:collapse;">
<thead>
<tr style="border-bottom:0.5px solid rgba(0,0,0,0.15);">
<th style="text-align:left; padding:8px 4px; font-weight:500; color:#666;">๋ฃฐ</th>
<th style="text-align:left; padding:8px 4px; font-weight:500; color:#666;">์กฐ๊ฑด</th>
<th style="text-align:center; padding:8px 4px; font-weight:500; color:#666;">๊ฐ€์ค‘์น˜</th>
<th style="text-align:center; padding:8px 4px; font-weight:500; color:#666;">๋ฐœ๋™</th>
<th style="text-align:right; padding:8px 4px; font-weight:500; color:#666;">์ ์šฉ</th>
</tr>
</thead>
<tbody>{rows}</tbody>
<tfoot>
<tr style="border-top:0.5px solid rgba(0,0,0,0.3);">
<td colspan="4" style="text-align:right; padding:8px 4px; font-weight:500;">์ตœ์ข… ํ•ฉ๊ณ„</td>
<td style="text-align:right; padding:8px 4px; font-weight:500;">{score}์ </td>
</tr>
</tfoot>
</table>
<p style="font-size:12px; color:#888; margin:10px 0 0; font-style:italic;">ํ•œ๊ณ„: ๋ฃฐ์ด ๊ณ ์ •๊ฐ’์ด๋ผ ์ž„๊ณ„๊ฐ’ ๋ฐ”๋กœ ์•„๋ž˜(์˜ˆ: 499๋งŒ์› 23์‹œ) ๊ฑฐ๋ž˜๋ฅผ ๋†“์นจ</p>
</div>
"""
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"""
<tr style="background:{row_bg};">
<td style="padding:6px 4px; color:{td_c};">{f}</td>
<td style="text-align:right; padding:6px 4px; color:{sub_c}; font-family:monospace;">{x:.3f}</td>
<td style="text-align:right; padding:6px 4px; color:{sub_c}; font-family:monospace;">{w:+.4f}</td>
<td style="text-align:right; padding:6px 4px; color:{td_c}; font-family:monospace; font-weight:500;">{c:+.4f}</td>
</tr>"""
contrib_str = " + ".join([f"({c:+.4f})" for c in contributions])
calc_html = (
f"z = {contrib_str} + ({GEN2_INTERCEPT:+.4f})<br>"
f"z = <span style='font-weight:500;'>{logit:+.4f}</span><br>"
f"P = 1 / (1 + e<sup>{-logit:+.4f}</sup>) = "
f"<span style='font-weight:500;'>{prob:.4f} โ†’ {prob*100:.2f}%</span>"
)
return f"""
<div style="{CARD_STYLE}">
{card_header("GEN 2 ยท LOGISTIC REGRESSION", "๋กœ์ง€์Šคํ‹ฑ ํšŒ๊ท€", dec, bg, fg, f"์‚ฌ๊ธฐ ํ™•๋ฅ  {prob*100:.2f}%")}
{formula_box("z = wโ‚ยท๊ธˆ์•ก + wโ‚‚ยท์‹œ๊ฐ„ + wโ‚ƒยท์‹ ๊ทœ์ˆ˜์ทจ์ธ + wโ‚„ยท๊ธˆ์•ก๋น„์œจ + b<br>P(์‚ฌ๊ธฐ) = 1 / (1 + e<sup>-z</sup>)")}
<table style="width:100%; font-size:13px; border-collapse:collapse;">
<thead>
<tr style="border-bottom:0.5px solid rgba(0,0,0,0.15);">
<th style="text-align:left; padding:8px 4px; font-weight:500; color:#666;">ํ”ผ์ฒ˜</th>
<th style="text-align:right; padding:8px 4px; font-weight:500; color:#666;">์ž…๋ ฅ๊ฐ’ x</th>
<th style="text-align:right; padding:8px 4px; font-weight:500; color:#666;">ํ•™์Šต ๊ฐ€์ค‘์น˜ w</th>
<th style="text-align:right; padding:8px 4px; font-weight:500; color:#666;">๊ธฐ์—ฌ๋„ wยทx</th>
</tr>
</thead>
<tbody>{rows}
<tr style="background:#F1EFE8;">
<td colspan="3" style="padding:6px 4px; text-align:right;">์ ˆํŽธ (bias) b</td>
<td style="text-align:right; padding:6px 4px; font-weight:500; font-family:monospace;">{GEN2_INTERCEPT:+.4f}</td>
</tr>
</tbody>
</table>
{formula_box(calc_html)}
<p style="font-size:12px; color:#888; margin:10px 0 0; font-style:italic;">ํ•ด์„: ๊ฐ€์ค‘์น˜ ๋ถ€ํ˜ธ๊ฐ€ ๊ณง ํŒ๋‹จ ๋ฐฉํ–ฅ. ์–‘์ˆ˜๋Š” ์‚ฌ๊ธฐ ์ชฝ, ์Œ์ˆ˜๋Š” ์ •์ƒ ์ชฝ์œผ๋กœ ๋Œ์–ด๋‹น๊น€</p>
</div>
"""
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"""
<div style="display:grid; grid-template-columns:90px 1fr 60px; gap:8px; align-items:center;">
<span>{f}</span>
<div style="background:#f0ede5; height:16px; border-radius:3px; overflow:hidden;">
<div style="background:#D85A30; height:100%; width:{bar_w:.1f}%;"></div>
</div>
<span style="text-align:right; font-family:monospace; color:#666;">{imp:.3f}</span>
</div>"""
# ๋Œ€ํ‘œ ํŠธ๋ฆฌ ๋ถ„๊ธฐ ๊ฒฝ๋กœ (์ž…๋ ฅ๊ฐ’ ๊ธฐ๋ฐ˜)
if ratio < 4.8:
leaf_val = "-0.18" if amount < 320 else "+0.12"
path_lines = [
f"[๊ธˆ์•ก๋น„์œจ &lt; 4.8] โ†’ <b>Yes</b> (์ž…๋ ฅ {ratio:.1f})",
f" โ””โ”€ [๊ธˆ์•ก &lt; 320] โ†’ {'Yes' if amount < 320 else 'No'} โ†’ leaf=<b>{leaf_val}</b> โ† ๋ณธ ๊ฑฐ๋ž˜ ๋„๋‹ฌ",
]
else:
leaf_val = "+0.41" if new_payee_bin == 1 else "+0.08"
path_lines = [
f"[๊ธˆ์•ก๋น„์œจ &lt; 4.8] โ†’ <b>No</b> (์ž…๋ ฅ {ratio:.1f})",
f" โ””โ”€ [์‹ ๊ทœ์ˆ˜์ทจ์ธ &lt; 0.5] โ†’ {'Yes' if new_payee_bin == 0 else 'No'} โ†’ leaf=<b>{leaf_val}</b> โ† ๋ณธ ๊ฑฐ๋ž˜ ๋„๋‹ฌ",
]
tree_html = "<br>".join(path_lines)
return f"""
<div style="{CARD_STYLE}">
{card_header("GEN 3 ยท XGBOOST (TREE ENSEMBLE)", "๊ทธ๋ž˜๋””์–ธํŠธ ๋ถ€์ŠคํŒ… ํŠธ๋ฆฌ", dec, bg, fg, f"์‚ฌ๊ธฐ ํ™•๋ฅ  {prob*100:.2f}%")}
{formula_box("F(x) = ฮฃ<sub>k=1..K</sub> f<sub>k</sub>(x), &nbsp; f<sub>k</sub> โˆˆ ํŠธ๋ฆฌ ๊ณต๊ฐ„<br>P(์‚ฌ๊ธฐ) = sigmoid(F(x))&nbsp;&nbsp;[K=10, max_depth=3, lr=0.1]")}
<p style="font-size:13px; color:#666; margin:12px 0 6px;">ํ”ผ์ฒ˜ ์ค‘์š”๋„ (Gain ๊ธฐ๋ฐ˜)</p>
<div style="display:flex; flex-direction:column; gap:6px; font-size:13px;">{imp_bars}</div>
<p style="font-size:13px; color:#666; margin:14px 0 6px;">๋Œ€ํ‘œ ํŠธ๋ฆฌ (Tree #1) - ๋ณธ ๊ฑฐ๋ž˜ ๋ถ„๊ธฐ ๊ฒฝ๋กœ</p>
{formula_box(tree_html)}
<p style="font-size:12px; color:#888; margin:10px 0 0; font-style:italic;">ํŠธ๋ฆฌ 10๊ฐœ์˜ leaf ๊ฐ’์„ ํ•ฉ์‚ฐํ•œ raw score๋ฅผ ์‹œ๊ทธ๋ชจ์ด๋“œ๋กœ ๋ณ€ํ™˜ํ•˜์—ฌ ์ตœ์ข… ํ™•๋ฅ  ์‚ฐ์ถœ</p>
</div>
"""
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"""
<svg viewBox="0 0 600 200" xmlns="http://www.w3.org/2000/svg" style="width:100%; height:auto; max-height:200px;">
<defs>
<marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5" markerHeight="5" orient="auto">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#888780"/>
</marker>
</defs>
<line x1="300" y1="100" x2="130" y2="50" stroke="#888780" stroke-width="1" marker-end="url(#arr)"/>
<line x1="300" y1="100" x2="130" y2="150" stroke="#888780" stroke-width="1" marker-end="url(#arr)"/>
<line x1="300" y1="100" x2="470" y2="100" stroke="{edge2_color}" stroke-width="2" marker-end="url(#arr)"/>
<line x1="470" y1="100" x2="560" y2="50" stroke="{edge2_color}" stroke-width="1.5" {edge2_dash}marker-end="url(#arr)"/>
<line x1="470" y1="100" x2="560" y2="150" stroke="#888780" stroke-width="1" stroke-dasharray="3,3" marker-end="url(#arr)"/>
<circle cx="130" cy="50" r="26" fill="#B5D4F4" stroke="#185FA5" stroke-width="1"/>
<text x="130" y="54" text-anchor="middle" font-size="11" font-weight="500" fill="#0C447C">์†ก๊ธˆ์ธ</text>
<text x="130" y="22" text-anchor="middle" font-size="10" fill="#185FA5">์ •์ƒ์ด๋ ฅ 95%</text>
<circle cx="130" cy="150" r="22" fill="#D3D1C7" stroke="#5F5E5A" stroke-width="1"/>
<text x="130" y="154" text-anchor="middle" font-size="11" fill="#444441">๋‹จ๋ง๊ธฐ</text>
<text x="130" y="183" text-anchor="middle" font-size="10" fill="#5F5E5A">์‹ ๊ทœ IP</text>
<rect x="260" y="78" width="80" height="44" rx="6" fill="#F0997B" stroke="#993C1D" stroke-width="1.5"/>
<text x="300" y="96" text-anchor="middle" font-size="11" font-weight="500" fill="#4A1B0C">๋ณธ ๊ฑฐ๋ž˜</text>
<text x="300" y="112" text-anchor="middle" font-size="10" fill="#712B13">{amount:.0f}๋งŒ / {int(hour):02d}์‹œ</text>
<circle cx="470" cy="100" r="26" fill="#F0997B" stroke="#993C1D" stroke-width="1.5"/>
<text x="470" y="100" text-anchor="middle" font-size="11" font-weight="500" fill="#4A1B0C">์ˆ˜์ทจ์ธ</text>
<text x="470" y="138" text-anchor="middle" font-size="10" fill="#712B13">{payee_type} 1-hop</text>
<circle cx="560" cy="50" r="18" fill="{risk_fill}" stroke="{risk_stroke}" stroke-width="1"/>
<text x="560" y="54" text-anchor="middle" font-size="10" fill="{risk_color}">{risk_text}</text>
<circle cx="560" cy="150" r="18" fill="#D3D1C7" stroke="#5F5E5A" stroke-width="1"/>
<text x="560" y="154" text-anchor="middle" font-size="10" fill="#444441">์ผ๋ฐ˜</text>
<text x="300" y="20" text-anchor="middle" font-size="11" fill="#5F5E5A">1-hop ์ด์›ƒ</text>
<text x="560" y="20" text-anchor="middle" font-size="11" fill="#5F5E5A">2-hop ์ด์›ƒ</text>
</svg>
"""
table_html = f"""
<table style="width:100%; font-size:13px; border-collapse:collapse;">
<thead>
<tr style="border-bottom:0.5px solid rgba(0,0,0,0.15);">
<th style="text-align:left; padding:6px 4px; font-weight:500; color:#666;">๊ณ„์ธต</th>
<th style="text-align:left; padding:6px 4px; font-weight:500; color:#666;">์ง‘๊ณ„ ๋‚ด์šฉ</th>
<th style="text-align:right; padding:6px 4px; font-weight:500; color:#666;">๊ธฐ์—ฌ</th>
</tr>
</thead>
<tbody>
<tr>
<td style="padding:6px 4px;">Layer 1</td>
<td style="padding:6px 4px; color:#666;">์†ก๊ธˆ์ธ + ๋‹จ๋ง๊ธฐ ์ž„๋ฒ ๋”ฉ ํ‰๊ท </td>
<td style="text-align:right; padding:6px 4px; font-family:monospace; color:#04342C;">{layer1_contrib:+.2f}</td>
</tr>
<tr style="background:{layer2_bg};">
<td style="padding:6px 4px; color:{layer2_td};">Layer 2</td>
<td style="padding:6px 4px; color:{layer2_sub};">{layer2_desc}</td>
<td style="text-align:right; padding:6px 4px; font-family:monospace; font-weight:500; color:{layer2_td};">{layer2_contrib:+.2f}</td>
</tr>
<tr>
<td style="padding:6px 4px;">MLP</td>
<td style="padding:6px 4px; color:#666;">๊ฑฐ๋ž˜ ์ž„๋ฒ ๋”ฉ โ†’ ๋ถ„๋ฅ˜๊ธฐ (raw score)</td>
<td style="text-align:right; padding:6px 4px; font-family:monospace;">{mlp_contrib:+.2f}</td>
</tr>
</tbody>
</table>
"""
return prob, f"""
<div style="{CARD_STYLE}">
{card_header("GEN 4 ยท GNN (GRAPH NEURAL NETWORK)", "๊ทธ๋ž˜ํ”„ ์‹ ๊ฒฝ๋ง (์‹œ๋ฎฌ๋ ˆ์ด์…˜)", dec, bg, fg, f"์‚ฌ๊ธฐ ํ™•๋ฅ  {prob*100:.2f}%")}
{formula_box("h<sub>v</sub><sup>(l+1)</sup> = ฯƒ(W ยท AGG({{h<sub>u</sub><sup>(l)</sup> : u โˆˆ N(v)}}) + B ยท h<sub>v</sub><sup>(l)</sup>)<br>P(์‚ฌ๊ธฐ) = MLP(h<sub>๊ฑฐ๋ž˜</sub><sup>(L)</sup>) &nbsp; [2-hop ๋ฉ”์‹œ์ง€ ํŒจ์‹ฑ]")}
<p style="font-size:13px; color:#666; margin:12px 0 6px;">2-hop ์ด์›ƒ ๊ทธ๋ž˜ํ”„</p>
{svg}
<p style="font-size:13px; color:#666; margin:14px 0 6px;">์ด์›ƒ ์ง‘๊ณ„ ๊ฒฐ๊ณผ (Message Passing)</p>
{table_html}
<p style="font-size:12px; color:#888; margin:10px 0 0; font-style:italic;">๊ฐ•์ : 1-3์„ธ๋Œ€๊ฐ€ ๋ณด์ง€ ๋ชปํ•˜๋Š” '๊ด€๊ณ„๋ง'์„ ํ•™์Šต. ์ˆ˜์ทจ์ธ์ด 2-hop ๋‚ด ์‚ฌ๊ธฐ๊ณ„์ขŒ์™€ ์—ฐ๊ฒฐ๋œ ์‚ฌ์‹ค์„ ์ž๋™ ํฌ์ฐฉ</p>
</div>
"""
# ============================================================
# 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์„ธ๋Œ€ ์‚ฌ์ „ ํŒ๋‹จ์„ ํ† ๋Œ€๋กœ ์ข…ํ•ฉ ํŒ์ •ํ•˜์„ธ์š”.<br>"
f"[INPUT] amount={amount:.0f}๋งŒ, hour={int(hour):02d}, "
f"new_payee={'true' if new_payee_bin==1 else 'false'}, ratio={ratio:.1f}ร—<br>"
f"[PRIOR] 1-4์„ธ๋Œ€ ํ‰๊ท : {prior_avg*100:.1f}% / ์ข…ํ•ฉ: {prior_dec}<br>"
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"<tr><td style='padding:6px 4px;'>{i}</td>"
f"<td style='padding:6px 4px; color:#666;'>{step_text}</td>"
f"<td style='text-align:right; padding:6px 4px; font-family:monospace; color:#666;'>{attn:.2f}</td></tr>"
)
judg_color = "#3B6D11" if prob < 0.5 else "#633806"
judgment_html = (
f"<b style='color:{judg_color};'>{decision_text} ๊ถŒ๊ณ  (์˜์‹ฌ๋„ {prob*100:.0f}%)</b><br><br>"
f"{judgment_text}"
)
return f"""
<div style="{CARD_STYLE}">
{card_header("GEN 5 ยท FOUNDATION MODEL (LLM)", f"์ดˆ๊ฑฐ๋Œ€ ์ถ”๋ก  ๋ชจ๋ธ ({source_label})", dec, bg, fg, f"์˜์‹ฌ๋„ {prob*100:.0f}%")}
<p style="font-size:13px; color:#666; margin:4px 0 6px;">์ปจํ…์ŠคํŠธ ํ† ํฐํ™”</p>
<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>
<p style="font-size:13px; color:#666; margin:4px 0 6px;">์ถ”๋ก  ์ฒด์ธ (Chain-of-Thought)</p>
<table style="width:100%; font-size:13px; border-collapse:collapse; margin-bottom:12px;">
<thead>
<tr style="border-bottom:0.5px solid rgba(0,0,0,0.15);">
<th style="text-align:left; padding:6px 4px; font-weight:500; color:#666; width:8%;">๋‹จ๊ณ„</th>
<th style="text-align:left; padding:6px 4px; font-weight:500; color:#666; width:72%;">์ถ”๋ก  ๋‚ด์šฉ</th>
<th style="text-align:right; padding:6px 4px; font-weight:500; color:#666; width:20%;">Attention</th>
</tr>
</thead>
<tbody>{cot_rows}</tbody>
</table>
<p style="font-size:13px; color:#666; margin:4px 0 6px;">์ƒ์„ฑ๋œ ์ž์—ฐ์–ด ํŒ๋‹จ</p>
<div style="background:#FAEEDA; padding:12px 14px; border-radius:6px; font-size:13px; line-height:1.7; color:#412402;">{judgment_html}</div>
<details style="margin-top:10px;">
<summary style="font-size:12px; color:#666; cursor:pointer;">์ƒ์„ฑ ํŒŒ๋ผ๋ฏธํ„ฐ ๋ณด๊ธฐ</summary>
<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>
</details>
<p style="font-size:12px; color:#888; margin:10px 0 0; font-style:italic;">๊ฐ•์ : ์‚ฌ์ „ ํ•™์Šต๋œ ๋„๋ฉ”์ธ ์ง€์‹์œผ๋กœ '์™œ ์‚ฌ๊ธฐ์ธ์ง€' ์ž์—ฐ์–ด ์„ค๋ช… + ๊ถŒ๊ณ  ์กฐ์น˜๊นŒ์ง€ ์ž๋™ ์ƒ์„ฑ</p>
</div>
"""
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"<u>{payee_str}</u>์—๊ฒŒ ์ด์ฒด๋˜๋Š” ๊ฒƒ์€ ์ „ํ˜•์ ์ธ ๋ณด์ด์Šคํ”ผ์‹ฑ ํŒจํ„ด์ž…๋‹ˆ๋‹ค. "
f"์ˆ˜์ทจ์ธ ๊ณ„์ขŒ๊ฐ€ ์‚ฌ๊ธฐ ์˜์‹ฌ ๊ณ„์ขŒ์™€ 2-hop ๊ฑฐ๋ฆฌ์— ์žˆ๋‹ค๋Š” GNN ์‹ ํ˜ธ๊นŒ์ง€ ๋”ํ•ด์ ธ ์œ„ํ—˜๋„๊ฐ€ ๋งค์šฐ ๋†’์Šต๋‹ˆ๋‹ค.<br><br>"
f"<b>๊ถŒ๊ณ  ์กฐ์น˜:</b> โ‘  ์ฆ‰์‹œ ๊ฑฐ๋ž˜ ๋ณด๋ฅ˜, โ‘ก ๋“ฑ๋ก๋œ ์ „ํ™”๋ฒˆํ˜ธ๋กœ ๋ณธ์ธ ์ง์ ‘ ํ™•์ธ, โ‘ข ํ™•์ธ ์ „ ์ž๊ธˆ ๋™๊ฒฐ 24์‹œ๊ฐ„ ์œ ์ง€"
)
elif prob >= 0.5:
decision_text = "์ถ”๊ฐ€ ์ธ์ฆ"
judgment_text = (
f"{int(hour):02d}์‹œ ๊ฑฐ๋ž˜์—์„œ ์ผ๋ถ€ ์ด์ƒ ์‹ ํ˜ธ({ratio:.1f}๋ฐฐ ๊ธˆ์•ก, {payee_str})๊ฐ€ ๊ฐ์ง€๋˜์—ˆ์œผ๋‚˜ "
f"๊ฒฐ์ •์  ์œ„ํ—˜ ํŒจํ„ด์€ ์•„๋‹™๋‹ˆ๋‹ค. ์ฐจ๋‹จ๋ณด๋‹ค๋Š” ์ถ”๊ฐ€ ์ธ์ฆ์œผ๋กœ ๋ณธ์ธ ์˜์‚ฌ๋ฅผ ํ™•์ธํ•˜๋Š” ๊ฒƒ์ด ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค.<br><br>"
f"<b>๊ถŒ๊ณ  ์กฐ์น˜:</b> โ‘  ARS ๋˜๋Š” OTP ์ถ”๊ฐ€ ์ธ์ฆ, โ‘ก ์†ก๊ธˆ ์˜๋„ ์žฌํ™•์ธ ๋ฉ”์‹œ์ง€ ๋ฐœ์†ก"
)
else:
decision_text = "ํ†ต๊ณผ"
judgment_text = (
f"{int(hour):02d}์‹œ ๊ฑฐ๋ž˜์˜ ํŒจํ„ด์ด ์†ก๊ธˆ์ธ์˜ ํ‰์†Œ ํ–‰๋™ ๋ฒ”์œ„ ๋‚ด์— ์žˆ์œผ๋ฉฐ, "
f"1-4์„ธ๋Œ€ ๋ชจ๋ธ ๋ชจ๋‘ ์œ„ํ—˜ ์‹ ํ˜ธ๋ฅผ ๊ฐ•ํ•˜๊ฒŒ ๋ณด๋‚ด์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. ์ •์ƒ ๊ฑฐ๋ž˜๋กœ ํŒ๋‹จ๋ฉ๋‹ˆ๋‹ค.<br><br>"
f"<b>๊ถŒ๊ณ  ์กฐ์น˜:</b> ๋ณ„๋„ ์กฐ์น˜ ์—†์ด ๊ฑฐ๋ž˜ ์ง„ํ–‰"
)
meta_html = (
"mode: <b>SIMULATION</b> (API ๋ฏธ์—ฐ๊ฒฐ)<br>"
"์ด ๊ฒฐ๊ณผ๋Š” if-else ํ•˜๋“œ์ฝ”๋”ฉ์œผ๋กœ ์ƒ์„ฑ๋œ ์‹œ๋ฎฌ๋ ˆ์ด์…˜์ž…๋‹ˆ๋‹ค.<br>"
"์‹ค์ œ 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'<div style="background:#FCEBEB; border-left:3px solid #A32D2D; padding:10px 14px; '
f'border-radius:6px; margin-bottom:10px; font-size:13px; color:#4A1B0C;">'
f'โš ๏ธ Claude API ํ˜ธ์ถœ ์‹คํŒจ. ์‹œ๋ฎฌ๋ ˆ์ด์…˜ ๋ชจ๋“œ๋กœ ๋Œ€์ฒดํ•ฉ๋‹ˆ๋‹ค.<br>'
f'<span style="font-family:monospace; font-size:11px; color:#712B13;">์‚ฌ์œ : {meta_or_err}</span>'
f'</div>'
)
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: <b>{meta['model']}</b> / temperature: 0.2 / max_tokens: 1024<br>"
f"input_tokens: {meta['input_tokens']} / output_tokens: {meta['output_tokens']} / "
f"latency: {meta['latency']:.2f}s<br>"
f"mode: <b style='color:#3B6D11;'>LIVE API CALL โœ“</b>"
)
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'<div style="background:#FCEBEB; border-left:3px solid #A32D2D; padding:10px 14px; '
f'border-radius:6px; margin-bottom:10px; font-size:13px; color:#4A1B0C;">'
f'โš ๏ธ Claude ์‘๋‹ต ํŒŒ์‹ฑ ์‹คํŒจ. ์‹œ๋ฎฌ๋ ˆ์ด์…˜ ๋ชจ๋“œ๋กœ ๋Œ€์ฒดํ•ฉ๋‹ˆ๋‹ค.<br>'
f'<span style="font-family:monospace; font-size:11px; color:#712B13;">'
f'{type(e).__name__}: {str(e)[:150]}</span></div>'
)
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"""
<div style="background:#f5f5f0; border-radius:12px; padding:16px 20px; margin-bottom:14px;">
<p style="font-size:13px; color:#666; margin:0 0 8px;">๋ถ„์„ ๋Œ€์ƒ ๊ฑฐ๋ž˜</p>
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(120px, 1fr)); gap:12px;">
<div><p style="font-size:12px; color:#888; margin:0;">๊ธˆ์•ก</p><p style="font-size:18px; font-weight:500; margin:2px 0 0;">{amount:.0f}๋งŒ์›</p></div>
<div><p style="font-size:12px; color:#888; margin:0;">๊ฑฐ๋ž˜ ์‹œ๊ฐ„</p><p style="font-size:18px; font-weight:500; margin:2px 0 0;">{int(hour):02d}์‹œ</p></div>
<div><p style="font-size:12px; color:#888; margin:0;">์‹ ๊ทœ ์ˆ˜์ทจ์ธ</p><p style="font-size:18px; font-weight:500; margin:2px 0 0;">{new_payee}</p></div>
<div><p style="font-size:12px; color:#888; margin:0;">๊ณผ๊ฑฐ ๋Œ€๋น„ ๋ฐฐ์œจ</p><p style="font-size:18px; font-weight:500; margin:2px 0 0;">{ratio:.1f}ร—</p></div>
</div>
<p style="font-size:12px; color:#888; margin:10px 0 0;">๋ถ„์„ ์†Œ์š”์‹œ๊ฐ„: {elapsed:.3f}์ดˆ</p>
</div>
"""
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'<span style="background:#EAF3DE; color:#3B6D11; padding:3px 10px; border-radius:8px; font-size:12px; font-weight:500;">'
f'โ— Claude API ์—ฐ๊ฒฐ๋จ ({CLAUDE_MODEL})</span>'
if CLAUDE_AVAILABLE else
'<span style="background:#FAEEDA; color:#854F0B; padding:3px 10px; border-radius:8px; font-size:12px; font-weight:500;">'
'โ—‹ API ๋ฏธ์—ฐ๊ฒฐ (5์„ธ๋Œ€ ์‹œ๋ฎฌ๋ ˆ์ด์…˜ ๋ชจ๋“œ)</span>'
)
gr.HTML(f"""
<div style="text-align:center; padding:10px 0;">
<h1 style="margin:0;">๐Ÿ›ก๏ธ FDS 1-5์„ธ๋Œ€ ๋น„๊ต ๋ฐ๋ชจ</h1>
<p style="color:#666; margin:6px 0 0;">์‹ค๋ฌด ์šด์˜ ๋‹ด๋‹น์ž ์‹œ์—ฐ์šฉ ยท ํŒ๋‹จ ์ˆ˜์‹ยท๊ฐ€์ค‘์น˜ยท๊ทผ๊ฑฐ ์ „์ฒด ๋…ธ์ถœ ๋ชจ๋“œ</p>
<div style="margin-top:8px;">{_api_badge}</div>
</div>
""")
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(
"<div style='padding:20px; color:#666;'>"
"์ขŒ์ธก์—์„œ ๊ฑฐ๋ž˜ ์กฐ๊ฑด์„ ์„ค์ •ํ•˜๊ณ  [๋ถ„์„ ์‹คํ–‰] ๋ฒ„ํŠผ์„ ๋ˆ„๋ฅด์„ธ์š”."
"</div>"
)
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)