File size: 20,271 Bytes
6d40f77
 
7ca482a
 
 
 
 
 
 
 
 
 
 
eb55100
 
415ebef
 
 
6d40f77
53c9adb
 
 
0112759
737169d
eb55100
5393c73
14f70ce
 
6d40f77
 
737169d
eb55100
415ebef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dfd1ae6
 
5393c73
 
 
 
 
 
 
 
0112759
5393c73
53c9adb
5393c73
 
3155350
 
737169d
3155350
 
 
 
 
 
 
737169d
3155350
737169d
 
3155350
737169d
3155350
 
 
 
 
 
 
 
b1040cf
 
 
3155350
415ebef
737169d
415ebef
737169d
415ebef
5393c73
3155350
53c9adb
5393c73
 
 
d42dc1b
 
5393c73
 
 
 
 
 
 
0112759
5393c73
 
 
 
 
 
 
 
 
 
737169d
53c9adb
5393c73
 
 
 
 
 
 
09dbaf7
 
b1040cf
09dbaf7
b1040cf
 
 
 
5393c73
 
c353fe2
5393c73
 
 
 
 
 
 
 
 
 
 
 
 
 
0112759
eb55100
5994e00
270cbe3
 
 
 
 
 
 
 
1f20aa8
 
270cbe3
1f20aa8
270cbe3
5994e00
 
eb55100
dc01a88
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34a4c19
6e12111
 
 
34a4c19
 
 
dc01a88
 
34a4c19
 
 
 
dc01a88
 
 
 
34a4c19
dc01a88
34a4c19
3155350
 
 
 
34a4c19
3155350
34a4c19
 
3155350
34a4c19
 
3155350
 
 
dc01a88
3155350
dc01a88
 
 
 
dfd1ae6
 
 
5994e00
14f70ce
0112759
5994e00
0112759
5994e00
53c9adb
0112759
5393c73
737169d
270cbe3
 
5393c73
14f70ce
53c9adb
dc01a88
53c9adb
14f70ce
dc01a88
5994e00
dc01a88
dfd1ae6
5393c73
 
14f70ce
737169d
 
c5ed44a
5393c73
1d4b2ed
 
dfd1ae6
 
c353fe2
 
5393c73
 
0112759
 
 
 
5393c73
c5ed44a
 
 
1d4b2ed
2c9909d
 
dc01a88
 
2c9909d
 
 
5393c73
 
737169d
1f20aa8
5393c73
1f20aa8
c5ed44a
 
 
3f9602c
1f20aa8
1d4b2ed
737169d
 
5393c73
 
1d4b2ed
737169d
5393c73
eb55100
2be8232
eb55100
34a4c19
eb55100
34a4c19
 
 
82f601c
5994e00
34a4c19
 
 
 
 
a973827
 
8cb66e5
 
 
 
 
 
34a4c19
 
 
 
 
 
5994e00
34a4c19
eb55100
6e12111
34a4c19
dc01a88
5393c73
34a4c19
5393c73
eb55100
 
5393c73
0112759
eb55100
 
e3388ce
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
import sys
# 1. ์ตœ์ƒ๋‹จ ๋ชฝํ‚ค ํŒจ์น˜ - ๋ชจ๋“  ์ž„ํฌํŠธ๋ณด๋‹ค ์ตœ์šฐ์„ 
import huggingface_hub
if not hasattr(huggingface_hub, "HfFolder"):
    class MockHfFolder:
        @staticmethod
        def get_token(): return None
        @staticmethod
        def save_token(token): pass
        @staticmethod
        def delete_token(): pass
    huggingface_hub.HfFolder = MockHfFolder

import gradio as gr
import os
import re
import zipfile
import tempfile
import numpy as np
import pandas as pd
import tensorflow as tf
import joblib
import traceback
from huggingface_hub import hf_hub_download

# TF ์ตœ์ ํ™” ๊ฒฝ๊ณ  ๋ฐฉ์ง€ ๋ฐ ์•ˆ์ •์„ฑ
os.environ['TF_ENABLE_ONEDNN_OPTS'] = '0'

# ๋ฌด๊ฑฐ์šด ๋ชจ๋ธ ์ง€์—ฐ ๋กœ๋”ฉ
_models = {"predictor": None, "consultant": None}
REPO_ID = "dev-yuje/gardio_test"

def load_keras_model_compat(model_path):
    """quantization_config ์—ญ์ง๋ ฌํ™” ์˜ค๋ฅ˜๋ฅผ config.json ํŒจ์น˜๋กœ ์šฐํšŒ"""
    with tempfile.TemporaryDirectory() as tmpdir:
        with zipfile.ZipFile(model_path, 'r') as z:
            z.extractall(tmpdir)
        config_path = os.path.join(tmpdir, 'config.json')
        with open(config_path, 'r', encoding='utf-8') as f:
            config_str = f.read()
        config_str = re.sub(r',\s*"quantization_config":\s*null', '', config_str)
        config_str = re.sub(r'"quantization_config":\s*null,?\s*', '', config_str)
        with open(config_path, 'w', encoding='utf-8') as f:
            f.write(config_str)
        fixed_path = model_path + '.tmp_fixed.keras'
        with zipfile.ZipFile(fixed_path, 'w', zipfile.ZIP_DEFLATED) as z:
            for root, dirs, files in os.walk(tmpdir):
                for file in files:
                    fp = os.path.join(root, file)
                    arcname = os.path.relpath(fp, tmpdir)
                    z.write(fp, arcname)
        model = tf.keras.models.load_model(fixed_path, compile=False)
        os.remove(fixed_path)
        return model

def load_all_models():
    if _models["predictor"] is None:
        try:
            # ๋‚ด๋ถ€ ์˜ˆ์ธก ๋กœ์ง (Self-contained)
            class RobustCreditPredictor:
                def __init__(self):
                    self.preprocessor_path = "models/preprocessor.pkl"
                    self.model_path = "models/telecom_cb_model.keras"
                    self.preprocessor = None
                    self.model = None
                    self.load_error = "์ดˆ๊ธฐํ™”๋จ"
                    self.load_resources()

                def load_resources(self):
                    try:
                        # [๊ฐœ์„ ] ๋กœ์ปฌ ํŒŒ์ผ ์šฐ์„  ๋กœ๋“œ โ†’ ์—†์œผ๋ฉด HuggingFace ๋‹ค์šด๋กœ๋“œ
                        # --- ์ „์ฒ˜๋ฆฌ๊ธฐ ๋กœ๋“œ ---
                        try:
                            if os.path.exists(self.preprocessor_path):
                                self.preprocessor = joblib.load(self.preprocessor_path)
                                print(f"โœ… ์ „์ฒ˜๋ฆฌ๊ธฐ ๋กœ์ปฌ ๋กœ๋“œ ์„ฑ๊ณต: {self.preprocessor_path}")
                            else:
                                cached_preprocessor = hf_hub_download(repo_id=REPO_ID, filename=self.preprocessor_path, repo_type="space")
                                self.preprocessor = joblib.load(cached_preprocessor)
                                print(f"โœ… ์ „์ฒ˜๋ฆฌ๊ธฐ HuggingFace ๋‹ค์šด๋กœ๋“œ ์„ฑ๊ณต")
                        except Exception as prep_e:
                            self.load_error = f"์ „์ฒ˜๋ฆฌ๊ธฐ ๋กœ๋“œ ์‹คํŒจ: {prep_e}"
                            return

                        # --- ๋ชจ๋ธ ๋กœ๋“œ ---
                        try:
                            if os.path.exists(self.model_path):
                                target_path = self.model_path
                                print(f"โœ… ๋ชจ๋ธ ๋กœ์ปฌ ๊ฒฝ๋กœ ์‚ฌ์šฉ: {target_path}")
                            else:
                                target_path = hf_hub_download(repo_id=REPO_ID, filename=self.model_path, repo_type="space")
                                print(f"โœ… ๋ชจ๋ธ HuggingFace ๋‹ค์šด๋กœ๋“œ ์™„๋ฃŒ")

                            fsize = os.path.getsize(target_path)
                            if fsize < 1000:
                                self.load_error = f"ํŒŒ์ผ์ด ๋„ˆ๋ฌด ์ž‘์Œ({fsize}B). LFS ํฌ์ธํ„ฐ์ผ ๊ฐ€๋Šฅ์„ฑ ์žˆ์Œ."
                                return

                            self.model = load_keras_model_compat(target_path)
                            self.load_error = "์„ฑ๊ณต"
                            print(f"โœ… ๋ชจ๋ธ ๋กœ๋“œ ์„ฑ๊ณต (ํŒŒ์ผ ํฌ๊ธฐ: {fsize:,}B)")
                        except Exception as model_e:
                            self.load_error = f"๋ชจ๋ธ ๋กœ๋“œ ์‹คํŒจ: {str(model_e)[:300]}"
                    except Exception as e:
                        self.load_error = f"๋ฆฌ์†Œ์Šค ๋กœ๋“œ ํ†ตํ•ฉ ์—๋Ÿฌ: {e}"

                def predict(self, features_dict):
                    try:
                        if self.model is None or self.preprocessor is None:
                            err_short = self.load_error[:500] if self.load_error else "์›์ธ ๋ถˆ๋ช…"
                            return f"Error: ๋กœ๋“œ ์ƒํƒœ ํ™•์ธ ์š”๋ง. ์›๋ณธ ์—๋Ÿฌ: {err_short}"
                        
                        ALL_FEATURES = [
                            'C1Z001386', 'C1M210000', 'C18210000', 'C1L120001', 'C1L120004', 
                            'L10210000', 'L90210100', 'L90210200', 'L10210B00', 'L10216000', 
                            'L10217000', 'D10110000', 'D10133000', 'PERF1'
                        ]
                        
                        input_values = [float(features_dict.get(col, 0.0)) for col in ALL_FEATURES]
                        df = pd.DataFrame([input_values], columns=ALL_FEATURES)
                        
                        log_cols = ['C1Z001386', 'C1L120004', 'D10110000', 'D10133000', 'L90210200',
                                    'L10216000', 'L10210B00', 'L10217000', 'L90210100', 'L10210000']
                        df[log_cols] = np.log1p(df[log_cols].astype(float).clip(lower=0))
                        
                        scaled_data = self.preprocessor.transform(df)
                        prediction = self.model.predict(scaled_data, verbose=0)
                        return float(prediction[0][0])
                    except Exception as e:
                        return f"Error: ์˜ˆ์ธก ์—ฐ์‚ฐ ์—๋Ÿฌ: {str(e)}"
            
            _models["predictor"] = RobustCreditPredictor()
            
            # ์ƒ๋‹ด์‚ฌ ๋กœ์ง
            from langchain_google_genai import ChatGoogleGenerativeAI
            class Consultant:
                def __init__(self):
                    api_key = os.getenv("GOOGLE_API_KEY", "")
                    from config import LLM_MODEL
                    # [์ˆ˜์ •] ๋ชจ๋ธ ๋ช…์นญ ๋ฐ API ๋ฒ„์ „ ํ˜ธํ™˜์„ฑ ๊ณ ๋ ค (config ์—ฐ๋™)
                    self.llm = ChatGoogleGenerativeAI(
                        model=LLM_MODEL, 
                        google_api_key=api_key, 
                        temperature=0.7,
                        convert_system_message_to_human=True
                    )
                    self.embedding_model = None
                    self.retriever = None

                def lazy_load_search(self):
                    if self.embedding_model is None:
                        try:
                            from langchain_huggingface import HuggingFaceEmbeddings
                            from langchain_community.vectorstores import FAISS
                            from config import EMBEDDING_MODEL, FAISS_PATH, RETRIEVER_K
                            self.embedding_model = HuggingFaceEmbeddings(model_name=EMBEDDING_MODEL)
                            if os.path.exists(FAISS_PATH):
                                self.vectorstore = FAISS.load_local(FAISS_PATH, self.embedding_model, allow_dangerous_deserialization=True)
                                self.retriever = self.vectorstore.as_retriever(search_kwargs={"k": RETRIEVER_K})
                        except: pass
            
            _models["consultant"] = Consultant()
        except Exception as e:
            print(f"Grand Load Error: {e}")

FEATURES_DETAIL = {
    'C1Z001386': ('1๋…„ ๋‚ด ์นด๋“œ ์ด ์ด์šฉ๊ธˆ์•ก', '๋งŒ์› ๋‹จ์œ„', '0'),
    'C1M210000': ('๋ณด์œ  ์‹ ์šฉ์นด๋“œ ์ˆ˜',         '๊ฐœ์ˆ˜',     '0'),
    'C18210000': ('๋ณด์œ  ์ฒดํฌ์นด๋“œ ์ˆ˜',         '๊ฐœ์ˆ˜',     '0'),
    'C1L120001': ('์นด๋“œ ์ด ํ•œ๋„๊ธˆ์•ก',         '๋งŒ์› ๋‹จ์œ„', '0'),
    'C1L120004': ('์‹ ์šฉ์นด๋“œ ๊ฐœ์„ค ํ›„ ๊ฒฝ๊ณผ์ผ์ˆ˜', '์ผ ๋‹จ์œ„',   '0'),
    'L90210100': ('์€ํ–‰์—…์ข… ๋Œ€์ถœ ๊ฑด์ˆ˜',       '๊ฐœ์ˆ˜',     '0'),
    'L90210200': ('์นด๋“œ์—…์ข… ๋Œ€์ถœ ๊ฑด์ˆ˜',       '๊ฐœ์ˆ˜',     '0'),
    'L10210B00': ('๋ณดํ—˜์—…์ข… ๋Œ€์ถœ ๊ฑด์ˆ˜',       '๊ฐœ์ˆ˜',     '0'),
    'L10216000': ('์‹ ์šฉ๋Œ€์ถœ ๊ฑด์ˆ˜',           '๊ฐœ์ˆ˜',     '0'),
    'L10217000': ('๋‹ด๋ณด๋Œ€์ถœ ๊ฑด์ˆ˜',           '๊ฐœ์ˆ˜',     '0'),
    'D10110000': ('๊ณผ๊ฑฐ ์—ฐ์ฒด ๊ฑด์ˆ˜',           '๊ฐœ์ˆ˜',     '0'),
    'D10133000': ('์ด ์—ฐ์ฒด ์ƒํ™˜ ๊ธˆ์•ก',        '๋งŒ์› ๋‹จ์œ„', '0'),
    'PERF1':     ('1๋…„ ๋‚ด 90์ผ ์ด์ƒ ์—ฐ์ฒด ๊ฒฝํ—˜', '์ฒดํฌ ์‹œ ์—ฐ์ฒด ๊ฒฝํ—˜ ์žˆ์Œ', None),
}
ALL_KEYS = list(FEATURES_DETAIL.keys())

def get_grade_info(score_str):
    """์ ์ˆ˜ โ†’ (๋“ฑ๊ธ‰, ๋“ฑ๊ธ‰ ๊ตฌ๋ถ„, ์˜๋ฏธ/ํŠน์ง•, ์ƒ‰์ƒ) ๋ฐ˜ํ™˜"""
    try:
        score = int(score_str)
    except:
        return None
    
    if score >= 942:   grade, label, desc, color = "1๋“ฑ๊ธ‰", "์ตœ์šฐ๋Ÿ‰๋“ฑ๊ธ‰", "์˜ค๋žœ ์‹ ์šฉ๊ฑฐ๋ž˜ ๊ฒฝ๋ ฅ๊ณผ ๋‹ค์–‘ํ•˜๊ณ  ์šฐ๋Ÿ‰ํ•œ ์‹ ์šฉ๊ฑฐ๋ž˜ ์‹ค์ ์„ ๋ณด์œ ํ•˜๊ณ  ์žˆ์–ด ๋ถ€์‹คํ™” ๊ฐ€๋Šฅ์„ฑ์ด ๋งค์šฐ ๋‚™์Œ", "#A8D8EA"
    elif score >= 891: grade, label, desc, color = "2๋“ฑ๊ธ‰", "์ตœ์šฐ๋Ÿ‰๋“ฑ๊ธ‰", "์˜ค๋žœ ์‹ ์šฉ๊ฑฐ๋ž˜ ๊ฒฝ๋ ฅ๊ณผ ๋‹ค์–‘ํ•˜๊ณ  ์šฐ๋Ÿ‰ํ•œ ์‹ ์šฉ๊ฑฐ๋ž˜ ์‹ค์ ์„ ๋ณด์œ ํ•˜๊ณ  ์žˆ์–ด ๋ถ€์‹คํ™” ๊ฐ€๋Šฅ์„ฑ์ด ๋งค์šฐ ๋‚™์Œ", "#A8D8EA"
    elif score >= 832: grade, label, desc, color = "3๋“ฑ๊ธ‰", "์šฐ๋Ÿ‰๋“ฑ๊ธ‰", "ํ™œ๋ฐœํ•œ ์‹ ์šฉ๊ฑฐ๋ž˜ ์‹ค์ ์€ ์—†์œผ๋‚˜, ๊ผธ์ค€ํžˆ ์šฐ๋Ÿ‰ ๊ฑฐ๋ž˜๋ฅผ ์ง€์†ํ•œ๋‹ค๋ฉด ์ƒ์œ„๋“ฑ๊ธ‰ ์ง„์ž… ๊ฐ€๋Šฅํ•˜๋ฉฐ ๋ถ€์‹คํ™” ๊ฐ€๋Šฅ์„ฑ์€ ๋‚™์€ ์ˆ˜์ค€์ž„", "#B8F0C8"
    elif score >= 768: grade, label, desc, color = "4๋“ฑ๊ธ‰", "์šฐ๋Ÿ‰๋“ฑ๊ธ‰", "ํ™œ๋ฐœํ•œ ์‹ ์šฉ๊ฑฐ๋ž˜ ์‹ค์ ์€ ์—†์œผ๋‚˜, ๊ผธ์ค€ํžˆ ์šฐ๋Ÿ‰ ๊ฑฐ๋ž˜๋ฅผ ์ง€์†ํ•œ๋‹ค๋ฉด ์ƒ์œ„๋“ฑ๊ธ‰ ์ง„์ž… ๊ฐ€๋Šฅํ•˜๋ฉฐ ๋ถ€์‹คํ™” ๊ฐ€๋Šฅ์„ฑ์€ ๋‚™์€ ์ˆ˜์ค€์ž„", "#B8F0C8"
    elif score >= 698: grade, label, desc, color = "5๋“ฑ๊ธ‰", "์ผ๋ฐ˜๋“ฑ๊ธ‰", "๋น„๊ต์  ๊ธˆ๋ฆฌ๊ฐ€ ๋†’์€ ๊ธˆ์œต์—…๊ถŒ๊ณผ์˜ ๊ฑฐ๋ž˜๊ฐ€ ์žˆ๋Š” ๊ณ ๊ฐ์œผ๋กœ, ๋‹จ๊ธฐ์—ฐ์ฒด ๊ฒฝํ—˜์ด ์žˆ์œผ๋ฉฐ ๋ถ€์‹คํ™” ๊ฐ€๋Šฅ์„ฑ์€ ์ผ๋ฐ˜์ ์ธ ์ˆ˜์ค€์ž„", "#FEE8A0"
    elif score >= 620: grade, label, desc, color = "6๋“ฑ๊ธ‰", "์ผ๋ฐ˜๋“ฑ๊ธ‰", "๋น„๊ต์  ๊ธˆ๋ฆฌ๊ฐ€ ๋†’์€ ๊ธˆ์œต์—…๊ถŒ๊ณผ์˜ ๊ฑฐ๋ž˜๊ฐ€ ์žˆ๋Š” ๊ณ ๊ฐ์œผ๋กœ, ๋‹จ๊ธฐ์—ฐ์ฒด ๊ฒฝํ—˜์ด ์žˆ์œผ๋ฉฐ ๋ถ€์‹คํ™” ๊ฐ€๋Šฅ์„ฑ์€ ์ผ๋ฐ˜์ ์ธ ์ˆ˜์ค€์ž„", "#FEE8A0"
    elif score >= 530: grade, label, desc, color = "7๋“ฑ๊ธ‰", "์ฃผ์˜๋“ฑ๊ธ‰", "๋น„๊ต์  ๊ธˆ๋ฆฌ๊ฐ€ ๋†’์€ ๊ธˆ์œต์—…๊ถŒ๊ณผ์˜ ๊ฑฐ๋ž˜๊ฐ€ ๋งŽ์€ ๊ณ ๊ฐ์œผ๋กœ, ๋‹จ๊ธฐ์—ฐ์ฒด ๊ฒฝํ—˜์„ ๋น„๊ต์  ๋งŽ์ด ๋ณด์œ ํ•˜๊ณ  ์žˆ์–ด ๋ถ€์‹คํ™” ๊ฐ€๋Šฅ์„ฑ์ด ๋†’์Œ", "#FFCB9A"
    elif score >= 454: grade, label, desc, color = "8๋“ฑ๊ธ‰", "์ฃผ์˜๋“ฑ๊ธ‰", "๋น„๊ต์  ๊ธˆ๋ฆฌ๊ฐ€ ๋†’์€ ๊ธˆ์œต์—…๊ถŒ๊ณผ์˜ ๊ฑฐ๋ž˜๊ฐ€ ๋งŽ์€ ๊ณ ๊ฐ์œผ๋กœ, ๋‹จ๊ธฐ์—ฐ์ฒด ๊ฒฝํ—˜์„ ๋น„๊ต์  ๋งŽ์ด ๋ณด์œ ํ•˜๊ณ  ์žˆ์–ด ๋ถ€์‹คํ™” ๊ฐ€๋Šฅ์„ฑ์ด ๋†’์Œ", "#FFCB9A"
    elif score >= 335: grade, label, desc, color = "9๋“ฑ๊ธ‰", "์œ„ํ—˜๋“ฑ๊ธ‰", "ํ˜„์žฌ ์—ฐ์ฒด ์ค‘์ด๊ฑฐ๋‚˜ ๋งค์šฐ ์‹ฌ๊ฐํ•œ ์—ฐ์ฒด ๊ฒฝํ—˜์„ ๋ณด์œ ํ•˜๊ณ  ์žˆ์–ด ๋ถ€์‹คํ™” ๊ฐ€๋Šฅ์„ฑ์ด ๋งค์šฐ ๋†’์Œ", "#FFB3B3"
    else:              grade, label, desc, color = "10๋“ฑ๊ธ‰", "์œ„ํ—˜๋“ฑ๊ธ‰", "ํ˜„์žฌ ์—ฐ์ฒด ์ค‘์ด๊ฑฐ๋‚˜ ๋งค์šฐ ์‹ฌ๊ฐํ•œ ์—ฐ์ฒด ๊ฒฝํ—˜์„ ๋ณด์œ ํ•˜๊ณ  ์žˆ์–ด ๋ถ€์‹คํ™” ๊ฐ€๋Šฅ์„ฑ์ด ๋งค์šฐ ๋†’์Œ", "#FFB3B3"
    return grade, label, desc, color

SCORE_PLACEHOLDER = '''
<div style="border:2px dashed rgba(128,128,128,0.3); border-radius:16px; padding:24px; text-align:center; min-height:130px; display:flex; flex-direction:column; align-items:center; justify-content:center;">
  <div style="font-size:36px; opacity:0.25;">๐Ÿ“‹</div>
  <div style="font-size:14px; opacity:0.45; margin-top:10px;">๐Ÿ“Œ ์ •๋ณด๋ฅผ ์ž…๋ ฅํ•˜๊ณ  ์ ์ˆ˜ ๋ถ„์„ํ•˜๊ธฐ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ์ฃผ์„ธ์š”</div>
</div>
'''

def make_score_html(score_str):
    if not score_str or score_str.startswith("โŒ") or score_str.startswith("โš ๏ธ"):
        return f'''
<div style="border:2px solid #e74c3c33; border-radius:16px; padding:24px; text-align:center; min-height:120px; display:flex; align-items:center; justify-content:center;">
  <div style="font-size:15px; color:#e74c3c;">{score_str}</div>
</div>'''
    info = get_grade_info(score_str)
    if info is None:
        return f'<div style="padding:20px; text-align:center;">{score_str}</div>'
    grade, label, desc, color = info
    # ๋‹คํฌ๋ชจ๋“œ ํ˜ธํ™˜: ๋ฐฐ๊ฒฝ ๋ฐ˜ํˆฌ๋ช…, ํ…์ŠคํŠธ๋Š” CSS inherit ๋Œ€์‹  ๋ช…์‹œ์  ๋‹คํฌ๋ชจ๋“œ ๊ณต์œ  ์ƒˆ๋ฌธ (prefers-color-scheme)
    return f'''
<style>
  .score-card-{grade[0]} {{ background:{color}22; border:2px solid {color}; border-radius:16px; padding:24px; text-align:center; font-family:sans-serif; }}
  .score-num-{grade[0]} {{ font-size:64px; font-weight:900; color:{color}; letter-spacing:-2px; line-height:1; }}
  .score-grade-{grade[0]} {{ font-size:22px; font-weight:700; margin-top:8px; color:{color}; }}
  .score-desc-{grade[0]} {{ font-size:13px; line-height:1.6; opacity:0.75; }}
  @media (prefers-color-scheme: dark) {{
    .score-desc-{grade[0]} {{ color: #ccc; }}
  }}
  @media (prefers-color-scheme: light) {{
    .score-desc-{grade[0]} {{ color: #555; }}
  }}
</style>
<div class="score-card-{grade[0]}">
  <div class="score-num-{grade[0]}">{score_str}์ </div>
  <div class="score-grade-{grade[0]}">{grade} &nbsp;|&nbsp; {label}</div>
  <hr style="border:none; border-top:1px solid {color}; margin:14px 0;">
  <div class="score-desc-{grade[0]}">{desc}</div>
</div>
'''


def handle_predict(*args):
    try:
        load_all_models()
        features_dict = {}
        for i, key in enumerate(ALL_KEYS):
            val_raw = str(args[i]).strip().replace(",", "")
            if key == 'PERF1':
                features_dict[key] = 1.0 if (val_raw.lower() == 'true' or val_raw == '1' or args[i] is True) else 0.0
            else:
                try:
                    features_dict[key] = float(val_raw or 0)
                except:
                    return f"โŒ ์˜ค๋ฅ˜: '{FEATURES_DETAIL[key][0]}' ์ˆซ์ž ์•„๋‹˜", "โŒ"
        
        features_dict['L10210000'] = features_dict['L10216000'] + features_dict['L10217000']
        
        res = _models["predictor"].predict(features_dict)
        if isinstance(res, str) and "Error" in res:
            return f"โŒ ๋ถ„์„ ์‹คํŒจ: {res}", make_score_html(f"โŒ ๋กœ๋“œ ์‹คํŒจ: {res[:60]}")
        
        score_val = str(int(round(float(res))))
        return {"features": features_dict, "score": score_val}, make_score_html(score_val)
    except Exception as e:
        return f"โŒ ์‹œ์Šคํ…œ ์˜ค๋ฅ˜: {str(e)}", make_score_html(f"โŒ ์‹œ์Šคํ…œ ์—๋Ÿฌ")

def generate_response(chatbot, user_message, analysis_report):
    if not user_message: yield chatbot, ""; return
    
    # [์ตœ์ข… ์ˆ˜์ •] Gradio 5์˜ 'Data incompatible' ์—๋Ÿฌ ํ•ด๊ฒฐ์„ ์œ„ํ•ด ๋ช…์‹œ์ ์ธ ๋”•์…”๋„ˆ๋ฆฌ ํฌ๋งท ์‚ฌ์šฉ
    chatbot.append({"role": "user", "content": user_message})
    chatbot.append({"role": "assistant", "content": "๐Ÿ” [Retrieval] ๊ด€๋ จ ๊ทœ์ • ๋ฐ ์ง€์นจ์„ ๊ฒ€์ƒ‰ํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค..."})
    yield chatbot, ""
    
    try:
        load_all_models()
        cons = _models["consultant"]
        cons.lazy_load_search()
        
        context = ""
        if cons.retriever:
            try:
                docs = cons.retriever.invoke(user_message)
                context = "\n\n".join([d.page_content for d in docs])
            except: pass
        
        chatbot[-1] = {"role": "assistant", "content": "๐Ÿง  [Augmentation] ๊ฒ€์ƒ‰๋œ ์ง€์‹์„ ๋ฐ”ํƒ•์œผ๋กœ ํ”„๋กฌํ”„ํŠธ๋ฅผ ๊ตฌ์„ฑํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค..."}
        yield chatbot, ""
        
        from llm.prompt import QA_PROMPT
        if isinstance(analysis_report, dict) and "features" in analysis_report:
            score_val = analysis_report.get("score", "๋ฏธ์ธก์ •")
            # L10210000์€ ํŒŒ์ƒ ์ปจ๋Ÿผ์œผ๋กœ FEATURES_DETAIL์— ์—†์Œ - ํ‚ค๊ฐ€ ์žˆ๋Š” ๊ฒƒ๋งŒ ํ‘œ์‹œ
            features_text = "\n".join([f"- {FEATURES_DETAIL[k][0]}: {v}" for k, v in analysis_report["features"].items() if k in FEATURES_DETAIL])
            query_text = f"โ–  ๊ณ ๊ฐ ์‹ ์šฉ ์ ์ˆ˜: {score_val}์ \nโ–  ๊ณ ๊ฐ์˜ ํ˜„์žฌ ์ƒํƒœ(์ž…๋ ฅ๋œ ์ •๋ณด):\n{features_text}\n\nโ–  ๊ณ ๊ฐ ์งˆ๋ฌธ: {user_message}"
        else:
            query_text = f"โ–  ๊ณ ๊ฐ ์‹ ์šฉ ์ ์ˆ˜: ์ •๋ณด ์—†์Œ(์ผ๋ฐ˜ ์งˆ๋ฌธ ์ƒํƒœ)\nโ–  ์ƒํƒœ: ๋ถ„์„์„ ์ง„ํ–‰ํ•˜์ง€ ์•Š์Œ\nโ–  ์งˆ๋ฌธ: {user_message}"
        full_prompt = QA_PROMPT.format(context=context, query=query_text)
        
        # [์ค‘์š”] ๋”•์…”๋„ˆ๋ฆฌ ๋ฆฌ์ŠคํŠธ๋กœ ์ „๋‹ฌ (Gradio 5 UI ๋Œ€์‘)
        from langchain_core.messages import HumanMessage
        messages = [HumanMessage(content=full_prompt)]
        
        chatbot[-1] = {"role": "assistant", "content": "๐Ÿ’ก [Generation] ๋‹ต๋ณ€์„ ์ƒ์„ฑ ์ค‘์ž…๋‹ˆ๋‹ค...\n\n"}
        yield chatbot, ""
        
        answer_buffer = ""
        for chunk in cons.llm.stream(messages):
            answer_buffer += chunk.content
            # ๋งˆ์ง€๋ง‰ ๋ฉ”์‹œ์ง€์˜ ๋‚ด์šฉ์„ ๋”•์…”๋„ˆ๋ฆฌ ํฌ๋งท์œผ๋กœ ์—…๋ฐ์ดํŠธ
            chatbot[-1] = {"role": "assistant", "content": answer_buffer}
            yield chatbot, ""
            
    except Exception as e:
        chatbot[-1] = {"role": "assistant", "content": f"โš ๏ธ ์ƒ๋‹ด ์—๋Ÿฌ: {str(e)}"}
        yield chatbot, ""

with gr.Blocks(title="KCB AI Consultant") as demo:
    analysis_report = gr.State(None)
    gr.Markdown("# ๐Ÿ›ก๏ธ KCB AI ์‹ ์šฉ ์ƒ๋‹ด ์‹œ์Šคํ…œ")
    
    with gr.Row(equal_height=False):
        # ==== ์™ผ์ชฝ: ์ž…๋ ฅ ํŒจ๋„ (2์—ด ๊ทธ๋ฆฌ๋“œ) ====
        with gr.Column(scale=1, min_width=340):
            gr.HTML('<h2 style="margin:4px 0 12px 0; font-size:20px; font-weight:700;">&#128202; ์‹ ์šฉ ์ง€ํ‘œ ์ž…๋ ฅ</h2>')
            input_list = []
            keys_no_perf = [k for k in ALL_KEYS if k != 'PERF1']
            # 2์—ด๋กœ ์ž…๋ ฅ ํ•„๋“œ ๋ฐฐ์น˜
            for i in range(0, len(keys_no_perf), 2):
                with gr.Row():
                    for key in keys_no_perf[i:i+2]:
                        field_label, unit, _ = FEATURES_DETAIL[key]
                        unit_short = unit.replace(" ๋‹จ์œ„", "").replace("๊ฐœ์ˆ˜", "๊ฐœ")
                        # label์— ๋‹จ์œ„๋ฅผ ๊ดดํ˜ธ๋กœ ๋ถ™์ด๋˜, info ์—†์•  ๋ ˆ์ด์•„์›ƒ ์ •๋ฆฌ
                        tb = gr.Textbox(
                            label=f"{field_label} ({unit_short})",
                            placeholder="0",
                            min_width=120
                        )
                        input_list.append(tb)
            # PERF1 ์ผฌ ์ฝ”๋„ˆ๋กœ ๋ฐฐ์น˜
            with gr.Row():
                perf_cb = gr.Checkbox(label=FEATURES_DETAIL['PERF1'][0], info=FEATURES_DETAIL['PERF1'][1], value=False)
                input_list.append(perf_cb)
            predict_btn = gr.Button("๐Ÿ“ˆ ์ ์ˆ˜ ๋ถ„์„ํ•˜๊ธฐ", variant="primary", size="lg")

        # ==== ์˜ค๋ฅธ์ชฝ: ๊ฒฐ๊ณผ ํŒจ๋„ ====
        with gr.Column(scale=2):
            gr.HTML('<h2 style="margin:0 0 8px 0; font-size:22px; font-weight:700;">๐ŸŽฏ AI ์˜ˆ์ธก ์‹ ์šฉ ์ ์ˆ˜</h2>')
            result_display = gr.HTML(value=SCORE_PLACEHOLDER)
            chatbot = gr.Chatbot(label="AI ์ƒ๋‹ด์‚ฌ", height=430)
            with gr.Row():
                msg = gr.Textbox(placeholder="์งˆ๋ฌธ์„ ์ž…๋ ฅํ•˜์„ธ์š” (์‹ ์šฉ์ ์ˆ˜ ๋ถ„์„ ํ›„์—๋Š” ๊ฐœ์ธํ™” ์ƒ๋‹ด์ด ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค)", show_label=False, scale=8)
                submit_btn = gr.Button("์ „์†ก", variant="primary", scale=1)

    predict_btn.click(handle_predict, inputs=input_list, outputs=[analysis_report, result_display])
    msg.submit(generate_response, inputs=[chatbot, msg, analysis_report], outputs=[chatbot, msg])
    submit_btn.click(generate_response, inputs=[chatbot, msg, analysis_report], outputs=[chatbot, msg])

if __name__ == "__main__":
    demo.launch(server_name="0.0.0.0", server_port=7860)