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